mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-11 23:55:37 -10:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23293b86b4 | ||
|
|
4fa9c5b082 | ||
|
|
a790d406c7 | ||
|
|
1d12ab67f5 | ||
|
|
98c8498c8b | ||
|
|
507e48073a | ||
|
|
85514bb934 |
No files matched your search
+12
-12
@@ -1,16 +1,16 @@
|
||||
# Changes to these files wait for a council member's review (council poll P35, 2026-11-10) once branch protection requires code owners' reviews
|
||||
# Changes to these files wait for Ludovic's review once branch protection requires code owners' reviews
|
||||
# (tools/ci/protect_pipeline.sh, docs/security.md). They are what builds, signs and ships releases, the code that
|
||||
# checks downloads before installing them (backend.rs downloads, installs and updates the launcher itself; links.json
|
||||
# says where from; the Cargo.toml files pick the crates that do it), and this file. Everything else merges as soon as
|
||||
# `verify` passes.
|
||||
/.github/ @horizon-rust/council
|
||||
/tools/ci/ @horizon-rust/council
|
||||
/tools/release_train.py @horizon-rust/council
|
||||
/crates/horizon-release-sign/ @horizon-rust/council
|
||||
/crates/horizon-server/src/update.rs @horizon-rust/council
|
||||
/crates/horizon-server/Cargo.toml @horizon-rust/council
|
||||
/launcher/src/signed.rs @horizon-rust/council
|
||||
/launcher/src/backend.rs @horizon-rust/council
|
||||
/launcher/links.json @horizon-rust/council
|
||||
/launcher/Cargo.toml @horizon-rust/council
|
||||
/packaging/ @horizon-rust/council
|
||||
/.github/ @ludovic111
|
||||
/tools/ci/ @ludovic111
|
||||
/tools/release_train.py @ludovic111
|
||||
/crates/horizon-release-sign/ @ludovic111
|
||||
/crates/horizon-server/src/update.rs @ludovic111
|
||||
/crates/horizon-server/Cargo.toml @ludovic111
|
||||
/launcher/src/signed.rs @ludovic111
|
||||
/launcher/src/backend.rs @ludovic111
|
||||
/launcher/links.json @ludovic111
|
||||
/launcher/Cargo.toml @ludovic111
|
||||
/packaging/ @ludovic111
|
||||
@@ -32,6 +32,13 @@
|
||||
<!-- The vanilla classes and methods this follows (tools/rea_vanilla.py), and the harness that checks it.
|
||||
"Horizon-only" for a feature vanilla doesn't have; "none" for tooling or docs. -->
|
||||
|
||||
## Other projects' code
|
||||
|
||||
<!-- "none", or for each project you read or copied from: its link at the commit, its license (only MIT,
|
||||
BSD-2-Clause, BSD-3-Clause, Apache-2.0, ISC, zlib, 0BSD, MIT-0, Unlicense or CC0-1.0), and what you took.
|
||||
CONTRIBUTING.md "Only original code". Shader packs: also the sections of docs/cleanroom/shader-packs-spec.md,
|
||||
the documentation pages and the packs you used. -->
|
||||
|
||||
## Self-check
|
||||
|
||||
<!-- Paste the block `python3 tools/pr_ready.py` prints (`--base upstream/main` from a fork). -->
|
||||
|
||||
@@ -13,3 +13,4 @@
|
||||
87152661 rdbtCVS # added by Ludovic, 2026-10-27
|
||||
84875247 Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
|
||||
86072227 levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
|
||||
169198624 itarqos5 # added by Ludovic, 2026-11-08 (Discord Literal; Horizon Team)
|
||||
@@ -36,9 +36,8 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable, 2026-11
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.target }}
|
||||
- name: The aarch64 Linux linker
|
||||
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
||||
|
||||
+389
-57
@@ -1,5 +1,5 @@
|
||||
name: checks
|
||||
# verify: every pull request, every merge queue entry and every push to main (runners: see the job). It is the
|
||||
# verify: every pull request and every push to main, on GitHub's runners (the repository is public). It is the
|
||||
# check main requires, so it never has a condition: a skipped job counts as passed, and every run of this workflow
|
||||
# must report a real verify (a pull request that changes no Rust finishes it in seconds instead).
|
||||
# parity: the vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the
|
||||
@@ -7,15 +7,11 @@ name: checks
|
||||
# request that changes a harness, oracle, probe or the runner: the rest are checked on main by parity-main. A fork's pull
|
||||
# request runs it only when a maintainer adds the run-parity label (.github/workflows/run-parity.yml), so labels
|
||||
# never start this workflow. See docs/release-flow.md.
|
||||
# merge_group: the merge queue's commit (main's tip, the pull requests ahead in the queue and this one) runs verify
|
||||
# before it lands, on GitHub's runners; parity and launcher wait for main's push (docs/release-flow.md#merge-queue).
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -23,8 +19,7 @@ concurrency:
|
||||
# A new push to a pull request replaces its running checks. On main, the running check finishes and only the
|
||||
# newest push waits behind it: GitHub cancels a waiting run when a newer one joins the group. A burst of merges
|
||||
# queued ~100 main runs on 2026-11-09; the release follows the newest finished main run (docs/release-flow.md).
|
||||
# Each merge queue entry has a group of its own (its temporary branch), and nothing cancels it: the queue does.
|
||||
group: checks-${{ github.event.pull_request.number || github.event.merge_group.head_ref || (github.event_name == 'push' && 'main') || github.sha }}
|
||||
group: checks-${{ github.event.pull_request.number || (github.event_name == 'push' && 'main') || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
env:
|
||||
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
|
||||
@@ -34,23 +29,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 (docs/ci-runners.md): our checks runners (label horizon-checks: the desktop's) take this repository's own
|
||||
# pull requests whose number ends in 0 or 1, about what their three runners can do; GitHub's runners take the others,
|
||||
# forks' pull requests 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. A fork's pull request sees no repository variable, so no switch
|
||||
# could move it off a machine that is off: it always runs on GitHub's. So does a merge queue's run, whatever
|
||||
# SELF_HOSTED_CI says: its commit can hold a fork's code, which job-started.sh refuses on ours. 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.
|
||||
# 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 != 'off' && vars.SELF_HOSTED_CI != 'light' && github.event_name != 'merge_group' &&
|
||||
(vars.SELF_HOSTED_CI == 'all' ||
|
||||
github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
(endsWith(format('{0}', github.event.pull_request.number), '0') ||
|
||||
${{ 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-24.04' }}
|
||||
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -60,24 +50,22 @@ jobs:
|
||||
if: runner.environment == 'github-hosted'
|
||||
with:
|
||||
python-version: '3.12'
|
||||
# A merge queue's run reads its commit against main's tip (merge_group's base and head).
|
||||
- name: Does this pull request need Rust checks?
|
||||
if: github.event_name == 'pull_request' || github.event_name == 'merge_group'
|
||||
if: github.event_name == 'pull_request'
|
||||
id: rust
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
# A pull request (or a merge queue entry) adds only its own commits to main, so it reads those; main's pushes
|
||||
# read every ref's history.
|
||||
# A pull request adds only its own commits to main, so it reads those; main's pushes read every ref's history.
|
||||
- name: Keep game artifacts out of Git
|
||||
env:
|
||||
EVENT: ${{ github.event_name }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
python tools/check_public_tree.py
|
||||
if [ "$EVENT" = pull_request ] || [ "$EVENT" = merge_group ]; then
|
||||
if [ "$EVENT" = pull_request ]; then
|
||||
python tools/check_public_tree.py --range "$BASE_SHA..$HEAD_SHA"
|
||||
else
|
||||
python tools/check_public_tree.py --history
|
||||
@@ -94,22 +82,21 @@ jobs:
|
||||
echo "WGPU_BACKEND=dx12" >> "$GITHUB_ENV"
|
||||
# On a pull request, the next three checks compare with HEAD^1: the main that GitHub's merge commit puts the pull
|
||||
# request on. pull_request.base.sha is main at the pull request's last push and falls behind, so main's own
|
||||
# changes since then would count as the pull request's (#13874). A merge queue's entry compares with main's tip
|
||||
# (merge_group.base_sha), whatever the queue's merge method makes of HEAD^1.
|
||||
# changes since then would count as the pull request's (#13874).
|
||||
# docs/layers.md: a crate depends only on its own layer or a lower one; today's exceptions only shrink.
|
||||
- name: Crates follow the layers
|
||||
env:
|
||||
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || github.event.merge_group.base_sha || '' }}
|
||||
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
|
||||
run: python3 tools/check_layers.py ${BASE:+--base "$BASE"}
|
||||
# docs/layers.md#built-in-addresses: every built-in outside address in the Horizon layer's home; the rest only shrinks.
|
||||
- name: Built-in addresses stay in their home
|
||||
env:
|
||||
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || github.event.merge_group.base_sha || '' }}
|
||||
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
|
||||
run: python3 tools/check_addresses.py ${BASE:+--base "$BASE"}
|
||||
# docs/layers.md#file-size: a Rust file over 3000 lines may not grow (pull requests; main has no base to compare).
|
||||
- name: Big files don't grow
|
||||
env:
|
||||
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || github.event.merge_group.base_sha || '' }}
|
||||
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
|
||||
run: python3 tools/check_file_size.py ${BASE:+--base "$BASE"}
|
||||
- name: Test offline research tooling
|
||||
run: python -m unittest discover -s tests -v
|
||||
@@ -123,7 +110,7 @@ jobs:
|
||||
fi
|
||||
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.rust.outputs.required != 'false' && runner.environment == 'github-hosted'
|
||||
with:
|
||||
shared-key: verify
|
||||
@@ -132,35 +119,31 @@ jobs:
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
cargo +stable fmt --all -- --check
|
||||
cargo +stable clippy --workspace --all-targets --locked -- -D warnings
|
||||
cargo +stable clippy --workspace --all-targets -- -D warnings
|
||||
# The server build (released as horizon-engine): the game without sound or controllers.
|
||||
cargo +stable clippy -p horizon-client --all-targets --locked --no-default-features -- -D warnings
|
||||
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
|
||||
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
|
||||
# type-checks and the parity job builds and runs (they were most of this step's build and link time).
|
||||
# cargo test --locked, not nextest: nextest's process per test reloads each binary's shared tables for every test, and on
|
||||
# cargo test, not nextest: nextest's process per test reloads each binary's shared tables for every test, and on
|
||||
# these 4-core runners it was no faster (#4978).
|
||||
- name: Test Rust core and voxel kernel correctness
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
cargo +stable test --locked --workspace --lib --bins --tests
|
||||
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. Keep the target selection identical to avoid
|
||||
# recompiling the workspace with a different dependency graph.
|
||||
cargo +stable test --locked --workspace --lib --bins --tests mod_content::tests:: -- --ignored
|
||||
cargo +stable test --locked --workspace --doc
|
||||
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). Not in the merge queue: no check of
|
||||
# its is required, so the queue wouldn't wait for it, and main's push runs it anyway.
|
||||
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
|
||||
launcher:
|
||||
if: github.event_name != 'merge_group'
|
||||
runs-on: >-
|
||||
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && github.event_name != 'merge_group' &&
|
||||
(vars.SELF_HOSTED_CI == 'all' ||
|
||||
github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
(endsWith(format('{0}', github.event.pull_request.number), '0') ||
|
||||
${{ 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-24.04' }}
|
||||
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -183,7 +166,7 @@ jobs:
|
||||
libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev libudev-dev
|
||||
fi
|
||||
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.launcher.outputs.required != 'false' && runner.environment == 'github-hosted'
|
||||
with:
|
||||
workspaces: launcher
|
||||
@@ -216,14 +199,13 @@ jobs:
|
||||
run: |
|
||||
$cores = (Get-CimInstance Win32_Processor | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum
|
||||
"CARGO_BUILD_JOBS=$cores" >> $env:GITHUB_ENV
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable, 2026-11
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: windows-arm64-game
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: launcher
|
||||
shared-key: windows-arm64-launcher
|
||||
@@ -331,8 +313,358 @@ jobs:
|
||||
head: ${{ github.event.pull_request.head.sha }}
|
||||
full: ${{ contains(github.event.pull_request.labels.*.name, 'full-parity') }}
|
||||
frame_identity: ${{ contains(github.event.pull_request.labels.*.name, 'frame-identity') }}
|
||||
# JFR analysis uses JDK-generated fixture recordings on the self-hosted Linux runner.
|
||||
# Start a manual checks dispatch with: gh workflow run checks --ref <branch>.
|
||||
# Horizon never downloads the game. These jobs need a self-hosted Linux runner
|
||||
# (label horizon-game) holding the maintainer's own copy: the official launcher's
|
||||
# game directory in HORIZON_MINECRAFT_DIR and the pinned server jar in
|
||||
# HORIZON_SERVER_JAR. Start them by hand: gh workflow run checks --ref <branch>.
|
||||
vanilla-parity:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: [self-hosted, linux, horizon-game]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Install Rust toolchain
|
||||
run: rustup toolchain install stable --profile minimal
|
||||
- uses: actions/setup-java@v6
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '25'
|
||||
- name: Compare Rust foundations with pinned vanilla client and server bytecode
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_rust_parity.py --prepare
|
||||
- name: Check Rust world-data interoperability with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_world_parity.py
|
||||
- name: Compare scheduled ticks and saved tick data with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_tick_parity.py
|
||||
- name: Compare simulation geometry and cached block collision shapes
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: |
|
||||
python tools/test_geometry_parity.py
|
||||
python tools/test_collision_parity.py
|
||||
- name: Compare noise samplers and density kernels with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_noise_parity.py
|
||||
- name: Compare compiled density samplers with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_density_parity.py
|
||||
- name: Compare world-generation JSON codecs with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_worldgen_parity.py
|
||||
- name: Compare heightmap predicates and chunk mutations with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_heightmap_parity.py
|
||||
- name: Compare pre-light chunk status transitions and block writes
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_chunk_stage_parity.py
|
||||
- name: Compare feature placement into resident neighboring chunks
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_feature_region_parity.py
|
||||
- name: Compare nested feature selectors and sequences
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_feature_graph_parity.py
|
||||
- name: Compare named feature holders and placed-feature tags
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_feature_catalog_parity.py
|
||||
- name: Compare configured fill-layer block placement
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_fill_layer_parity.py
|
||||
- name: Compare replacement-rule codecs and evaluation
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_rule_test_parity.py
|
||||
- name: Compare simple-block placement and feature composition
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_simple_block_parity.py
|
||||
- name: Compare block-column sampling and obstruction
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_block_column_parity.py
|
||||
- name: Compare vertical pillars and cap feature placement
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_pillar_parity.py
|
||||
- name: Compare exactly-one-neighbor feature spreading
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_neighbor_spread_parity.py
|
||||
- name: Compare block-pile support and placement
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_block_pile_parity.py
|
||||
- name: Compare disk replacement and postprocessing
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_disk_parity.py
|
||||
- name: Compare block-blob support search and geometry
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_block_blob_parity.py
|
||||
- name: Compare spring placement and fluid tick scheduling
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_spring_parity.py
|
||||
- name: Compare vine attachment priority and placement
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_vines_parity.py
|
||||
- name: Compare underwater magma floor scans and occlusion
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_underwater_magma_parity.py
|
||||
- name: Compare blue-ice seeding and growth attempts
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_blue_ice_parity.py
|
||||
- name: Compare spike geometry and discontinuous roots
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_spike_parity.py
|
||||
- name: Compare End podium construction and destruction requests
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_end_podium_parity.py
|
||||
- name: Rust End gateway parity
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_end_gateway_parity.py
|
||||
- name: Compare bonus chest placement and conditional loot seeding
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_bonus_chest_parity.py
|
||||
- name: Compare recursive chorus generation and connection refreshes
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_chorus_plant_parity.py
|
||||
- name: Compare small speleothem patches and waterlogged points
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_speleothem_parity.py
|
||||
- name: Compare replacement blob search and Manhattan shells
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_replace_blobs_parity.py
|
||||
- name: Compare bamboo stalks, podzol patches and leafy caps
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_bamboo_parity.py
|
||||
- name: Compare Nether delta rims and shifted contents
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_delta_parity.py
|
||||
- name: Compare biome freezing and snow eligibility
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_biome_weather_parity.py
|
||||
- name: Compare frozen top layers and snowy ground updates
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_snow_freeze_parity.py
|
||||
- name: Compare generation-stage tick storage and routing
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_generation_tick_parity.py
|
||||
- name: Compare below-zero upgrade metadata codecs
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_retrogen_data_parity.py
|
||||
- name: Compare below-zero upgrade lifecycle and block transformations
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_retrogen_chunk_parity.py
|
||||
- name: Compare chunk-status metadata, ordering and codecs
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_chunk_status_parity.py
|
||||
- name: Compare proto-chunk block-entity storage and coordinates
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_block_entity_storage_parity.py
|
||||
- name: Compare ordered single-block replacement features
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_replace_block_parity.py
|
||||
- name: Compare scattered ore placement and air-exposure checks
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_scattered_ore_parity.py
|
||||
- name: Compare resident bulk-section access and raw writes
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_bulk_section_parity.py
|
||||
- name: Compare biome feature ordering and cycle detection
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_feature_sort_parity.py
|
||||
- name: Compare world-generation random wrappers and seeding
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_worldgen_random_parity.py
|
||||
- name: Compare biome filtering and top-level feature context
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_biome_placement_parity.py
|
||||
- name: Compare biome decoration feature scheduling and seeding
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_decoration_parity.py
|
||||
- name: Compare decoration against resident biome and terrain sections
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_decoration_region_parity.py
|
||||
- name: Compare block-state provider choices and rotation
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_state_provider_parity.py
|
||||
- name: Compare contained fluids and height queries with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_fluid_parity.py
|
||||
- name: Compare entity obstruction with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_entity_obstruction_parity.py
|
||||
- name: Compare block solidity and replaceability with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_block_properties_parity.py
|
||||
- name: Compare face slicing and block support with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_face_support_parity.py
|
||||
- name: Compare cached block support with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_block_support_parity.py
|
||||
- name: Compare dynamic block support with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_dynamic_support_parity.py
|
||||
- name: Compare moving-piston collision and interpolation with pinned vanilla
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_piston_shape_parity.py
|
||||
- name: Compare supported block survival rules with pinned vanilla
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_block_survival_parity.py
|
||||
- name: Compare noise-stage chunk filling with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_terrain_parity.py
|
||||
- name: Compare structure terrain-adaptation density with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_structure_density_parity.py
|
||||
- name: Compare seeded material bands and surface queries with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_material_surface_parity.py
|
||||
- name: Compare material condition codecs and rule evaluation with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_material_condition_parity.py
|
||||
- name: Compare biome temperatures and cache behavior with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_biome_temperature_parity.py
|
||||
- name: Compare badlands and frozen-ocean extensions with pinned vanilla
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_material_extension_parity.py
|
||||
- name: Compare bundled material-rule libraries with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_material_rule_library_parity.py
|
||||
- name: Compare fixed, checkerboard and End biome sources with pinned vanilla
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_biome_source_parity.py
|
||||
- name: Compare seeded cave paths and failure state with pinned vanilla
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_cave_carver_parity.py
|
||||
- name: Compare seeded canyon paths and width factors with pinned vanilla
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_canyon_carver_parity.py
|
||||
- name: Compare initial block-writing feature algorithms with pinned vanilla
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_basic_feature_parity.py
|
||||
- name: Compare feature placement chains with pinned vanilla
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_feature_placement_parity.py
|
||||
- name: Compare feature placement primitives with pinned vanilla
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_placement_parity.py
|
||||
- name: Compare anchored height providers with pinned vanilla
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_height_provider_parity.py
|
||||
- name: Compare scalar value providers with pinned vanilla
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_value_provider_parity.py
|
||||
- name: Compare cave and canyon geometric primitives with pinned vanilla
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_carving_geometry_parity.py
|
||||
- name: Compare carving-mask traversal and callback mutations with pinned vanilla
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_carving_mask_parity.py
|
||||
- name: Compare registry-bound biome-source codecs with pinned vanilla
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_biome_source_codec_parity.py
|
||||
- name: Compare all four biome-source kinds in chunk generation with pinned vanilla
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_biome_generation_parity.py
|
||||
- name: Compare chunk biome filling and failure state with pinned vanilla
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_biome_chunk_parity.py
|
||||
- name: Compare biome zoom and seed obfuscation with pinned vanilla
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_biome_zoom_parity.py
|
||||
- name: Compare surface-stage chunks with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_material_stage_parity.py
|
||||
- name: Compare block-state JSON codecs with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_block_state_codec_parity.py
|
||||
- name: Compare vertical anchors with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_anchor_parity.py
|
||||
- name: Compare float logarithms at domain and rounding boundaries
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_float_log_parity.py
|
||||
jfr-analysis:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: [self-hosted, linux, horizon-game]
|
||||
|
||||
@@ -50,9 +50,7 @@ jobs:
|
||||
ref: ${{ inputs.ref }}
|
||||
persist-credentials: false
|
||||
clean: true
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable, 2026-11
|
||||
with:
|
||||
toolchain: stable
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: bash tools/ci/runner-env.sh
|
||||
env:
|
||||
HORIZON_NO_SCCACHE: ${{ inputs.trusted && '1' || '' }}
|
||||
@@ -158,11 +156,10 @@ jobs:
|
||||
# may end up in a release. target/ is rebuilt from scratch; .local/ is relinked by vanilla-data.sh, which
|
||||
# keeps its preparations outside the checkout.
|
||||
clean: true
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable, 2026-11
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: runner.environment == 'github-hosted' && !matrix.musl
|
||||
with:
|
||||
# A release restores only what a release saved: an untrusted build has its own key and saves nothing.
|
||||
|
||||
@@ -173,11 +173,10 @@ jobs:
|
||||
# Always from a clean checkout, the Mac mini's runner too: its work folder is shared with pull requests'
|
||||
# parity jobs, and nothing they leave may end up in a launcher release (target/ is rebuilt from scratch).
|
||||
clean: true
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable, 2026-11
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: runner.environment == 'github-hosted'
|
||||
with:
|
||||
workspaces: launcher
|
||||
|
||||
@@ -20,12 +20,8 @@ concurrency:
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
check:
|
||||
# Our checks runners (label horizon-checks, docs/ci-runners.md); SELF_HOSTED_CI=light or off: GitHub's. A fork's
|
||||
# pull request sees no repository variable, so it always runs on GitHub's (it would wait for the desktop when off).
|
||||
runs-on: >-
|
||||
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' &&
|
||||
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) &&
|
||||
fromJSON('["self-hosted", "linux", "horizon-checks"]') || '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:
|
||||
@@ -34,7 +30,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
- run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
# Ours keep their build folder between jobs.
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: runner.environment == 'github-hosted'
|
||||
with:
|
||||
workspaces: crates/horizon-mod-registry
|
||||
|
||||
@@ -55,10 +55,8 @@ jobs:
|
||||
persist-credentials: false
|
||||
# Keeps .local/ (parity state, probe builds, the previous audit) and target/.
|
||||
clean: false
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable, 2026-11
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
if: steps.when.outputs.run == 'true'
|
||||
with:
|
||||
toolchain: stable
|
||||
- name: Tools from the runner
|
||||
if: steps.when.outputs.run == 'true'
|
||||
run: bash tools/ci/runner-env.sh
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
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@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: runner.environment == 'github-hosted'
|
||||
with:
|
||||
shared-key: pack-bench
|
||||
|
||||
@@ -37,9 +37,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
# Keeps .local/ (parity state, probe builds, oracle results) and target/ between runs.
|
||||
clean: false
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable, 2026-11
|
||||
with:
|
||||
toolchain: stable
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Tools from the runner
|
||||
run: bash tools/ci/runner-env.sh
|
||||
- name: Vanilla data, and resources prepared by this preparer
|
||||
@@ -60,18 +58,6 @@ jobs:
|
||||
echo "Merges since ${BASE_SHA}:"
|
||||
git log --first-parent --format=' %h %s' "$BASE_SHA..HEAD"
|
||||
python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400
|
||||
- name: Preserve the initial failure logs
|
||||
if: steps.harnesses.outcome == 'failure'
|
||||
run: |
|
||||
rm -rf .local/parity-main-logs && cp -R .local/parity-runner/logs .local/parity-main-logs 2>/dev/null || true
|
||||
- name: Upload the initial failure logs
|
||||
if: steps.harnesses.outcome == 'failure'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: parity-main-logs
|
||||
path: .local/parity-main-logs/
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
- name: A flake, or the merge that broke them
|
||||
if: steps.harnesses.outcome == 'failure'
|
||||
env:
|
||||
@@ -79,4 +65,13 @@ jobs:
|
||||
PARITY_HOSTS: ''
|
||||
BASE_SHA: ${{ steps.base.outputs.sha }}
|
||||
run: |
|
||||
rm -rf .local/parity-main-logs && cp -R .local/parity-runner/logs .local/parity-main-logs 2>/dev/null || true
|
||||
python3 tools/parity_main.py bisect "$BASE_SHA" --comment
|
||||
- name: Logs of the failed harnesses
|
||||
if: steps.harnesses.outcome == 'failure'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: parity-main-logs
|
||||
path: .local/parity-main-logs/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
@@ -71,9 +71,7 @@ jobs:
|
||||
echo "::warning::head ${HEAD_SHA:0:9} conflicts with main ${main:0:9}: tested alone, without main's fixes"
|
||||
echo "Tested: head ${HEAD_SHA:0:9} alone (it conflicts with main ${main:0:9})" | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable, 2026-11
|
||||
with:
|
||||
toolchain: stable
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Tools from the runner
|
||||
run: bash tools/ci/runner-env.sh
|
||||
- name: Vanilla data, and resources prepared by this preparer
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
name: runner watch
|
||||
# Every 30 minutes: one issue when jobs wait over 2 hours for a self-hosted runner nobody runs (the maintainer's
|
||||
# machines are off), closed again once nothing waits (tools/ci/runner_watch.py, docs/operations.md). It runs on
|
||||
# GitHub's runners, never ours: it must work when every self-hosted runner is gone.
|
||||
# gh workflow run "runner watch" -f dry_run=true
|
||||
on:
|
||||
schedule:
|
||||
- cron: '11,41 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Only print what it would do with the issue
|
||||
type: boolean
|
||||
default: false
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
concurrency:
|
||||
group: runner-watch
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
watch:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Jobs waiting for a self-hosted runner
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: python3 tools/ci/runner_watch.py ${{ inputs.dry_run && '--dry-run' || '' }}
|
||||
@@ -20,12 +20,8 @@ concurrency:
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
check:
|
||||
# Our checks runners (label horizon-checks, docs/ci-runners.md); SELF_HOSTED_CI=light or off: GitHub's. A fork's
|
||||
# pull request sees no repository variable, so it always runs on GitHub's (it would wait for the desktop when off).
|
||||
runs-on: >-
|
||||
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' &&
|
||||
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) &&
|
||||
fromJSON('["self-hosted", "linux", "horizon-checks"]') || '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:
|
||||
@@ -34,7 +30,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
- run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
# Ours keep their build folder between jobs.
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: runner.environment == 'github-hosted'
|
||||
with:
|
||||
workspaces: crates/horizon-telemetry-server
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
# Horizon: a map for agents
|
||||
|
||||
Minecraft: Java Edition 26.3 rewritten in Rust (wgpu). Vanilla is the specification wherever it has the
|
||||
feature; performance work may differ inside as long as it stays compatible with vanilla
|
||||
([CONTRIBUTING.md](CONTRIBUTING.md#when-it-doesnt-have-to-be-11)). Read [CONTRIBUTING.md](CONTRIBUTING.md) once;
|
||||
this page tells you where things are.
|
||||
feature. Read [CONTRIBUTING.md](CONTRIBUTING.md) once; this page tells you where things are.
|
||||
Feature → files → harness for the most-changed areas: [docs/architecture.md](docs/architecture.md).
|
||||
|
||||
## Rules that bite
|
||||
@@ -37,6 +35,7 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
|
||||
- Small PRs (< ~500 lines). Labels: `feature` (players get something new), `skip-release` (they see no
|
||||
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Merges ship in batches of 5, or after
|
||||
6 hours ([docs/release-flow.md](docs/release-flow.md)).
|
||||
- Contributors first: `gh pr list --state open --search <area>` before you start; never duplicate theirs.
|
||||
- **No hardcoded user-facing strings.** Keep UI translation-ready from the start. In the launcher (`launcher/`), wrap visible text with `tr("...")` or `trf("... {arg} ...", &[("arg", &val)])` from `crate::i18n` instead of raw string literals or `format!`. In the game, use translation keys (`Text::tr`, `lang::get`), never hardcoded English strings.
|
||||
- **Claim before you start**: comment `/claim` on the issue (no issue yet: open one, then claim it). An assigned
|
||||
issue is taken, and so is one labelled `in progress` (claimed on Discord): pick another. `/unclaim` when you stop;
|
||||
@@ -48,29 +47,6 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
|
||||
- **Render passes keep HDR's light.** A pass that reads, filters or redraws the world's picture (upscaling,
|
||||
anti-aliasing, sharpening, frame generation, post effects, captures) follows [docs/hdr-passes.md](docs/hdr-passes.md).
|
||||
|
||||
## Don't lose time
|
||||
|
||||
Where agent time went, measured on sessions and CI in November 2026: reading code, compiling `horizon-client`,
|
||||
waiting in loops, and work someone else had already done. Tests were a small part.
|
||||
|
||||
- **Check nobody else is on it.** One in three pull requests closed unmerged was a duplicate. Before you write
|
||||
code, look at the issue's linked pull requests and run
|
||||
`gh pr list --state open --search "<issue number, function or file name>"`; then `/claim`.
|
||||
- **Find code with the map, not by reading.** Start from the tables below and
|
||||
`python3 tools/port_ledger.py where Class.method`. Read the function you change and its callers, not whole
|
||||
files.
|
||||
- **Compile cheaply while you work.** `cargo check -p <crate>` after an edit. Clippy, fmt and tests run once, at
|
||||
the end, through `pr_ready.py`. `cargo clippy -p horizon-client` and `cargo build -p horizon-client` are the
|
||||
slowest commands here: never after every edit, and build the game only when you need to run it.
|
||||
- **Don't wait in loops.** Start a long command in the background and carry on, or wait for it once. No
|
||||
`sleep` or `until grep` polling of builds, harnesses or CI.
|
||||
- **One test per changed behaviour.** None for a refactor or a file move, none that reads source text, and no
|
||||
harness where vanilla has nothing to compare (Horizon's own features, performance work).
|
||||
- **A red check isn't always yours.** When `verify` fails in a test your change can't reach, say so in the pull
|
||||
request and move on; don't hunt it.
|
||||
- **Unsure about a rule? Finish the work.** Take the reading that lets you finish, and say in the pull request
|
||||
which one you took. Don't stop, and don't change direction halfway.
|
||||
|
||||
## Crates
|
||||
|
||||
Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-server` are separate workspaces.
|
||||
@@ -120,7 +96,6 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
|
||||
- Use independent expected values or a vanilla oracle; do not derive the answer from the code under test.
|
||||
- Test outcomes, not only calls to stubs. Use mocks to isolate behavior, not as the behavior itself.
|
||||
- Skip tests of trivial constants, derives and getters, or behavior already covered by another test.
|
||||
- One test per changed behaviour is enough. A refactor or a move that changes no behaviour gets no new test.
|
||||
- Avoid pinning implementation details; a harmless refactor should not break a behavior test.
|
||||
- Make slow tests faster without removing cases or weakening their coverage.
|
||||
- Never delete parity harnesses or tests that pin vanilla behaviour: they are the spec.
|
||||
@@ -128,8 +103,8 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
|
||||
## Build and test fast
|
||||
|
||||
```sh
|
||||
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests; paste its block in the PR
|
||||
python3 tools/pr_ready.py --harnesses # also the affected harnesses (by default only when you change a harness, oracle, probe or the runner, as in CI)
|
||||
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
|
||||
python3 tools/pr_ready.py --quick # same without harnesses
|
||||
cargo test -p horizon-client -p horizon-client-lib --lib --bins --tests # skips the examples (pr_ready does this); `--doc` separately
|
||||
cargo test -p horizon-client-lib --lib mod_content::tests:: -- --ignored # the slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
|
||||
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
|
||||
@@ -138,8 +113,7 @@ python3 tools/parity.py --changed-files paths.txt --list # which harnesses cov
|
||||
python3 tools/parity.py mob_ai sculk # named harnesses
|
||||
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host)
|
||||
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
python3 tools/lang_coverage.py --summary # one line per language: how many keys it carries and what it misses
|
||||
python3 tools/lang_coverage.py --summary --show-keys # the same, then the keys behind the counts
|
||||
python3 tools/lang_coverage.py --summary # the keys each language still misses, and its English leftovers
|
||||
```
|
||||
|
||||
- Harnesses and the game need the vanilla data in `.local/` (`minecraft/`, `horizon-client/26.3`, `datagen/`,
|
||||
@@ -178,8 +152,7 @@ python3 tools/lang_coverage.py --summary --show-keys # the same, then the keys
|
||||
- A harness runs the oracle against the real 26.3 jars and the Rust probe on the same inputs, then compares
|
||||
their JSON. Oracle runs are cached by `tools/parity_shim.py`; `--fresh` reruns them.
|
||||
- Mirror vanilla's `Math` calls with `horizon_core::jmath`, and vanilla's RNG with `horizon_core::random`:
|
||||
random draws must happen in vanilla's order. Performance work may break that order where no player can rely
|
||||
on it; the pull request says so ([CONTRIBUTING.md](CONTRIBUTING.md#when-it-doesnt-have-to-be-11)).
|
||||
random draws must happen in vanilla's order.
|
||||
|
||||
## horizon-client: where things live
|
||||
|
||||
@@ -242,8 +215,7 @@ player does (#1514). A window's `Game` never ticks a world itself: the old in-fr
|
||||
Open `[server-thread]` issues list the gaps left.
|
||||
|
||||
Single player, LAN guests and dedicated servers all run the server code, so a fix there serves all three.
|
||||
When you change how the server and its clients talk (packets, tracking, guests, the server thread itself), check
|
||||
it there. Other gameplay fixes are covered by their own harness and CI's parity job:
|
||||
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
|
||||
|
||||
+37
-40
@@ -1,8 +1,7 @@
|
||||
# Contributing to Horizon
|
||||
|
||||
Thanks for helping. Horizon is Minecraft: Java Edition 26.3 rewritten in Rust, and the one rule that shapes
|
||||
everything is that **it plays like vanilla**: first exactly like it, then better where players gain from it
|
||||
([vanilla first, then better](#vanilla-first-then-better)). This page is the short version of how we work; questions are
|
||||
everything is that **it plays exactly like vanilla**. This page is the short version of how we work; questions are
|
||||
welcome on [Discord](https://discord.gg/playhorizon).
|
||||
|
||||
This repository holds the game and the launcher, plus the tools, tests and docs that build, check and release them.
|
||||
@@ -14,17 +13,9 @@ else (videos, Discord and other community material) lives elsewhere.
|
||||
suggest something. A pull request for a roadmap item carries its `roadmap:<id>` label; ideas go to the suggestion
|
||||
queue (the hub, or `/suggest` on Discord) rather than to issues, which are for bugs.
|
||||
|
||||
**Mod-related pull requests go to [arismoko/horizon](https://github.com/arismoko/horizon)**, the fork that carries the
|
||||
mod API and reviews and merges that work. That covers the mod API, native hooks and events for mods, native mods,
|
||||
content mods (`.hmod`), mixins, wasm mods, `cargo horizon-mod`, `horizon-mods`, mod builds and the registry, and
|
||||
their docs ([mods](docs/mods.md), [native mods](docs/native-mods.md)). Open the pull request there from your usual
|
||||
branch. Mod-related pull requests opened here are closed with a pointer to it. Engine extension points that Horizon's
|
||||
own crates register (the [layer split](docs/layers.md)), resource and shader pack formats, and gameplay fixes stay
|
||||
here.
|
||||
|
||||
## Build it
|
||||
|
||||
- **Prerequisites:** Rust (stable), Python 3.11+, JDK 25, git 2.38 or newer, and on Linux the ALSA and udev headers
|
||||
- **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).
|
||||
@@ -37,10 +28,7 @@ here.
|
||||
|
||||
- **Launcher:** its own Cargo workspace in `launcher/` (`cd launcher && cargo run`; on NixOS, `nix-shell` first).
|
||||
|
||||
## Vanilla first, then better
|
||||
|
||||
Horizon gets there in two stages. First it plays exactly like vanilla; that is what parity work is for. Then it
|
||||
improves on vanilla where players gain from it, mostly speed: FPS, TPS, loading, memory.
|
||||
## 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
|
||||
@@ -53,10 +41,8 @@ and compares their outputs.
|
||||
- **One version: 26.3.** Parity work targets 26.3 until its parity is good, redstone first. Don't open pull requests
|
||||
that move Horizon to a newer Minecraft version for now; to join servers on a newer version, put a Via proxy
|
||||
(ViaVersion, ViaProxy) in front of them.
|
||||
- **Ports of vanilla behaviour come with a harness**, new or extended, so the next change can't silently break
|
||||
them. [The parity workflow](docs/parity-workflow.md) shows how harnesses run; copy a nearby one. Where vanilla
|
||||
has nothing to compare (Horizon's own features, performance work, refactors), no harness is needed: one Rust
|
||||
test of the changed behaviour, or the numbers before and after.
|
||||
- **Gameplay changes come with a harness**, new or extended, so the next change can't silently break them.
|
||||
[The parity workflow](docs/parity-workflow.md) shows how harnesses run; copy a nearby one.
|
||||
- **Horizon's own features** (the launcher, the new menus) follow [the design system](docs/design-system.md) and are
|
||||
off by default wherever they would change the vanilla look.
|
||||
- **Three layers: engine, platform, Horizon.**
|
||||
@@ -73,26 +59,40 @@ and compares their outputs.
|
||||
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.
|
||||
|
||||
### When it doesn't have to be 1:1
|
||||
## Only original code, or code whose license allows it
|
||||
|
||||
Parity is about what players and the vanilla game see, not about copying vanilla's internals. Performance work (a
|
||||
multithreaded server tick, rendering, chunk loading, memory) doesn't have to keep vanilla's internal order or its
|
||||
exact random calls. It has to work together with normal Minecraft:
|
||||
Horizon is MIT. Its code is written from Minecraft 26.3 itself and from public documentation. Since 30 October 2026
|
||||
another project's source may also be read and used, **only when that project's license allows it**:
|
||||
|
||||
- **Vanilla clients and servers still work with it.** A vanilla 26.3 client plays on a Horizon server, Horizon joins
|
||||
vanilla servers, and saves and worlds open in both, as before.
|
||||
- **The game still plays the same.** Farms, redstone, mobs and drops work the way players know from vanilla. A
|
||||
difference may only show where no player can rely on it, such as which of two mobs ticks first or exactly which
|
||||
random number a mob gets.
|
||||
- **Vanilla's slow paths aren't copied.** When vanilla spends a lot of FPS or TPS on something that can reach the
|
||||
same result faster, Horizon does it faster. Copying a performance problem isn't parity.
|
||||
- **Say what changes.** The pull request names the vanilla behaviour it no longer reproduces exactly, why players
|
||||
won't notice it or why it's worth it, and how it was checked: numbers before and after, and the harnesses that
|
||||
still pass. A change players could notice, such as a different tick order on a busy server, goes behind a setting.
|
||||
- **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.
|
||||
- **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.
|
||||
- **Java's own library is GPL too.** OpenJDK's source (`java.lang.Math`, `StrictMath`, `FdLibm`, `HashMap`, the
|
||||
sorts, `Double.toString`, HotSpot's intrinsics...) is GPL-2.0, so it is off limits like the rest. Reproduce Java's
|
||||
behaviour from its API documentation (the Javadoc) and from what a JVM returns, or from a permissive original such
|
||||
as Sun's fdlibm C sources (see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)); see #6741.
|
||||
- **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.
|
||||
- **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 are a clean-room rewrite of the pack format, produced from a written description of it (see
|
||||
#1561): the [specification and its provenance log](docs/cleanroom/README.md) are public. They carry no foreign
|
||||
source, and the rules above still apply to anything you read while working on them. A shader-pack pull request
|
||||
names its sources: the sections of that specification, the documentation pages and the packs it ran.
|
||||
|
||||
## Vendor libraries and Java mods
|
||||
|
||||
- **Upscalers and latency tools.** AMD FSR (MIT) may go in the repository.
|
||||
- **Upscalers and latency tools.** AMD FSR (MIT) may go in the repository, like any code under an allowed license.
|
||||
NVIDIA's DLSS, Reflex and any other library under an NVIDIA license may not: they ship only as an optional native mod
|
||||
that downloads NVIDIA's files itself, on the player's machine. No NVIDIA binaries, headers or SDK files in the
|
||||
repository.
|
||||
@@ -109,10 +109,9 @@ python3 tools/pr_ready.py --base upstream/main # or origin/main if you work on
|
||||
```
|
||||
|
||||
It looks at what your branch changes and runs what matters: rustfmt, clippy with warnings denied, the tests of the
|
||||
crates you touched (and the launcher's), generated files, `Cargo.lock` and the Python tests. Like CI, it runs the
|
||||
vanilla harnesses only when your branch changes a harness, an oracle, a probe or the runner; otherwise it lists the
|
||||
ones your change can affect, and main's parity run checks them after the merge. `--harnesses` runs them on your
|
||||
machine anyway (it needs the vanilla data). It exits non-zero when something fails.
|
||||
crates you touched (and the launcher's), generated files, `Cargo.lock`, the Python tests, and the vanilla harnesses
|
||||
your change can affect. Without the vanilla data on your machine it lists those harnesses instead; CI runs them.
|
||||
`--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
|
||||
@@ -191,5 +190,3 @@ new pushes, we add it again.
|
||||
|
||||
[Discord](https://discord.gg/playhorizon) for questions and ideas, [issues](https://github.com/horizon-rust/horizon/issues)
|
||||
for bugs. To take something on, `/claim` its issue ([above](#claim-before-you-start)).
|
||||
|
||||
How Horizon is run, and how people join the Team or lead a group: [GOVERNANCE.md](GOVERNANCE.md).
|
||||
Generated
+10
-72
@@ -706,25 +706,6 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-queue"
|
||||
version = "0.3.14"
|
||||
@@ -945,12 +926,6 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io"
|
||||
version = "0.4.0"
|
||||
@@ -1443,7 +1418,6 @@ dependencies = [
|
||||
name = "horizon-chat-filter"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-server-files",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
@@ -1486,7 +1460,6 @@ dependencies = [
|
||||
"horizon-server-files",
|
||||
"horizon-shader-api",
|
||||
"horizon-shaders",
|
||||
"horizon-system",
|
||||
"horizon-telemetry",
|
||||
"horizon-theme",
|
||||
"horizon-threads",
|
||||
@@ -1549,11 +1522,13 @@ dependencies = [
|
||||
"horizon-anticheat",
|
||||
"horizon-assets",
|
||||
"horizon-bench",
|
||||
"horizon-chat-filter",
|
||||
"horizon-client-core",
|
||||
"horizon-client-input",
|
||||
"horizon-client-render",
|
||||
"horizon-command",
|
||||
"horizon-core",
|
||||
"horizon-friends",
|
||||
"horizon-hdr",
|
||||
"horizon-hooks",
|
||||
"horizon-items",
|
||||
@@ -1579,7 +1554,6 @@ dependencies = [
|
||||
"open",
|
||||
"png",
|
||||
"pollster",
|
||||
"rayon",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1610,6 +1584,7 @@ dependencies = [
|
||||
"horizon-client-input",
|
||||
"horizon-command",
|
||||
"horizon-core",
|
||||
"horizon-cosmetics",
|
||||
"horizon-hdr",
|
||||
"horizon-hooks",
|
||||
"horizon-items",
|
||||
@@ -1617,6 +1592,7 @@ dependencies = [
|
||||
"horizon-optifine",
|
||||
"horizon-polytone",
|
||||
"horizon-protocol",
|
||||
"horizon-render-pipeline",
|
||||
"horizon-shader-api",
|
||||
"horizon-shaders",
|
||||
"horizon-system",
|
||||
@@ -1675,15 +1651,6 @@ dependencies = [
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-discord"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-platform",
|
||||
"log",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-dualsense"
|
||||
version = "0.1.0"
|
||||
@@ -1701,7 +1668,6 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"hkdf",
|
||||
"horizon-addresses",
|
||||
"horizon-platform",
|
||||
"if-addrs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1827,31 +1793,19 @@ dependencies = [
|
||||
name = "horizon-official"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-discord",
|
||||
"horizon-friends",
|
||||
"horizon-game",
|
||||
"horizon-heartbeat",
|
||||
"horizon-official-servers",
|
||||
"horizon-platform",
|
||||
"log",
|
||||
"winresource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-official-servers"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-addresses",
|
||||
"horizon-platform",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-optifine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-bench",
|
||||
"horizon-core",
|
||||
"horizon-system",
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
@@ -1937,6 +1891,10 @@ dependencies = [
|
||||
"ring",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-render-pipeline"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "horizon-server"
|
||||
version = "0.1.0"
|
||||
@@ -1972,7 +1930,6 @@ name = "horizon-server-files"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"getrandom 0.3.4",
|
||||
"horizon-core",
|
||||
"horizon-protocol",
|
||||
"horizon-world",
|
||||
@@ -2000,6 +1957,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"half",
|
||||
"horizon-core",
|
||||
"horizon-render-pipeline",
|
||||
"naga",
|
||||
"png",
|
||||
"zip",
|
||||
@@ -3690,26 +3648,6 @@ dependencies = [
|
||||
"objc2-quartz-core 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rc2"
|
||||
version = "0.8.1"
|
||||
|
||||
+1
-2
@@ -34,8 +34,6 @@ members = [
|
||||
"crates/horizon-game",
|
||||
"crates/horizon-official",
|
||||
"crates/horizon-heartbeat",
|
||||
"crates/horizon-discord",
|
||||
"crates/horizon-official-servers",
|
||||
"crates/horizon-bench",
|
||||
"crates/horizon-friends",
|
||||
"crates/horizon-relay",
|
||||
@@ -45,6 +43,7 @@ members = [
|
||||
"crates/cargo-horizon-mod",
|
||||
"crates/horizon-port-pack",
|
||||
"crates/horizon-shaders",
|
||||
"crates/horizon-render-pipeline",
|
||||
"crates/horizon-optifine",
|
||||
"crates/horizon-polytone",
|
||||
"crates/horizon-shader-api",
|
||||
|
||||
-583
@@ -1,583 +0,0 @@
|
||||
# Horizon governance
|
||||
|
||||
> Adopted 2026-11-09 by the founder as his last decision as head of the project. It takes effect when he
|
||||
> announces that he is stepping down. From then on only the council changes it, by a 2/3 poll (section 15).
|
||||
>
|
||||
> Amended 2026-11-10 by the founder (three kinds of action, elections by tier, who may do what, and council poll P41:
|
||||
> the mods decide moderation matters). The council can overturn this amendment by poll.
|
||||
|
||||
Horizon has no boss. A **council of equals** decides by polls. **Theme groups** do the work in their areas, each
|
||||
with a **lead**. The **Horizon Team** reviews and merges code. **Moderators** keep the Discord server and the
|
||||
official server safe. Every role of trust is earned with work the project can measure, and it ends when that work
|
||||
stops.
|
||||
|
||||
## Contents
|
||||
|
||||
1. [Principles](#1-principles)
|
||||
2. [The levels at a glance](#2-the-levels-at-a-glance)
|
||||
3. [Each level in detail](#3-each-level-in-detail)
|
||||
4. [The council and its polls](#4-the-council-and-its-polls)
|
||||
5. [The selection filter](#5-the-selection-filter)
|
||||
6. [Theme groups](#6-theme-groups)
|
||||
7. [Moderation and appeals](#7-moderation-and-appeals)
|
||||
8. [Conflicts and hard cases](#8-conflicts-and-hard-cases)
|
||||
9. [The age rule (18+)](#9-the-age-rule-18)
|
||||
10. [The agents](#10-the-agents)
|
||||
11. [Accounts and the handover path](#11-accounts-and-the-handover-path)
|
||||
12. [Money](#12-money)
|
||||
13. [Transparency](#13-transparency)
|
||||
14. [Rules that are never voted on](#14-rules-that-are-never-voted-on)
|
||||
15. [Changing this document](#15-changing-this-document)
|
||||
|
||||
## 1. Principles
|
||||
|
||||
- **Equals.** Every council member has one vote. Nobody, the founder included, stands above the others.
|
||||
- **The founder.** Ludovic keeps the title of **Founder**: his role stays at the top of the member list and he is
|
||||
credited as Horizon's founder everywhere. The title is symbolic. It carries no extra vote or power: on the council
|
||||
he counts exactly like every other member.
|
||||
- **Evidence, not popularity.** You get a role by doing the work first. Kira measures that work: merged pull
|
||||
requests, reviews, triage, support answers and days active. A vote then confirms it.
|
||||
- **Small, clear powers.** Each role can do a short, written list of things. Anything else goes to the council.
|
||||
- **Roles are on loan.** Every role has a 30-day probation and an inactivity rule. Stepping down for inactivity is
|
||||
not a sanction, and you can come back.
|
||||
- **Public by default.** Polls, results and rules are public. Only matters about a person stay private, and even then
|
||||
the result is published.
|
||||
- **18+ for roles of trust** (section 9).
|
||||
- **Mojang's and Microsoft's rules come first.** They are never put to a vote (section 14).
|
||||
|
||||
## 2. The levels at a glance
|
||||
|
||||
From the most open level to the most trusted. A person can hold several levels at once: a group lead can also be on
|
||||
the Horizon Team, for example.
|
||||
|
||||
| Level | What it can do | How you get in | How you leave | 18+ |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **Member** | Play, chat, report bugs, suggest ideas, open issues and pull requests, pick group roles | Join the Discord server or the GitHub repo | Leave, or get banned | No |
|
||||
| **Contributor** (⛏️ Iron, 🥇 Gold, 💎 Diamond, 🟪 Netherite ranks) | Write in #contributors; `/claim` issues; vote in their group's elections (if active in that group) | One merged pull request in a public horizon-rust repo, linked with `/link-github` and `/verify-github`. Ranks come from merged pull requests: 10, 40, 100 and 200 | Contributor status and ranks stay | No |
|
||||
| **Group member** | Read and write in the group's channel; get the group's pings; vote in the group's elections and votes when active | Pick the group's role in Channels & Roles, or with `/groups` | Remove the role | No |
|
||||
| **🤝 Support** | Answer and close posts in #horizon-support | Given by any moderator (a free action, section 2.1) | Resign, 30 days inactive, or removed by a moderator, the Chief of Mods or the council | No |
|
||||
| **🛡️ Moderator** | Time out, delete messages, manage threads, quarantine. No bans | Elected by the mods (section 5.3); council veto within 24 h | Resign, 30 days inactive, suspended by the Chief of Mods, recalled by the mods, removed by a council poll (simple majority) | **Yes** |
|
||||
| **🛡️ Chief of Mods** | A moderator's powers, plus bans; leads the mods; suspends a mod at once pending the council | Elected by the mods, from the mods with at least 30 days (section 5.3); council veto within 24 h | Resign, 30 days inactive, recalled by the mods, council poll (simple majority) | **Yes** |
|
||||
| **🧭 Group lead** | Decides inside the group's topic (section 6); manages the group's channel; triage on the group's GitHub issues | Elected by the group's active members and the Team (section 5.3); council veto within 24 h; 30 days probation | Resign, 30 days inactive (automatic), recalled by the same electorate, council poll (simple majority); confirmed again every 6 months | **Yes** |
|
||||
| **🌌 Horizon Team** | Review and merge pull requests (GitHub `horizon-team`, write access); `/nightly` and triage commands (trusted reviewers); #horizon-team | Elected by the Team, 2/3 (section 5.3); council veto within 24 h; 30 days probation | Resign, 60 days without a merge or review (automatic), recalled by the Team (2/3), council poll (2/3, without the person) | **Yes** |
|
||||
| **🏛️ Council** | Decides everything else by poll (section 4); bans; emergency actions by any 2 members | Nominated by a council member, the filter, a 24 h public discussion, then a council poll (2/3); 30 days probation | Resign, inactivity (section 5.5), council poll (2/3, without the person) | **Yes** |
|
||||
|
||||
**The account holder** isn't a level. The founder (Ludovic) still holds the project's accounts (GitHub organisation
|
||||
owner, Discord server owner, the Microsoft app approved by Mojang, the domains, Railway, money). He carries out the
|
||||
council's decisions on them and hands them over step by step (section 11). As a council member he has one vote,
|
||||
like everyone else.
|
||||
|
||||
### 2.1 Three kinds of action
|
||||
|
||||
Most things need no vote at all. A vote is for power over other people, and for decisions that bind a whole body.
|
||||
|
||||
| Kind | What | How |
|
||||
| --- | --- | --- |
|
||||
| **Free** | Everyday actions inside your own role (the list below, and anything of the same weight) | Just do it. Kira logs the ones she sees in #staff-log. The tier above can reverse it; if people disagree, the tier above votes. A pattern of abuse is a recall |
|
||||
| **Body vote** | Giving or taking a role that carries power (an election), or a decision that belongs to one body: the mods, the Team, a group | `/elect` or `/vote body:` in Kira (section 5.3). Any council member can call a veto within 24 hours |
|
||||
| **Council poll** | Rules, money, accounts and ownership, the council's own members, vetoes, anything that spans several bodies | `/poll` (section 4) |
|
||||
|
||||
Free actions, for example:
|
||||
|
||||
- a lead gives or removes their group's helper role (🧩 Horizon Modding Team), through Kira;
|
||||
- a moderator gives or removes 🤝 Support, through Kira;
|
||||
- a moderator times out, deletes, quarantines, or changes a channel's slowmode or topic; the Chief of Mods bans and
|
||||
unbans;
|
||||
- the Team merges and labels;
|
||||
- a server operator restarts the official server, or prunes backups and chunks by the written rule;
|
||||
- a lead pins, renames or creates threads in their group's channels;
|
||||
- Kira's automatic actions.
|
||||
|
||||
**The 24-hour rule.** Every vote lasts 24 hours at most, and closes earlier once its result can't change. Only council
|
||||
polls on this document, the licence, money and the GitHub organisation's ownership last 48 hours. A veto is called
|
||||
within 24 hours of a result.
|
||||
|
||||
### 2.2 Who may do what
|
||||
|
||||
What each post decides, and what it holds on Discord, on the official server and on GitHub. Anything not listed is a
|
||||
council matter.
|
||||
|
||||
| Post | Decides | Discord | Official server | GitHub |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **Member** | — | Chat, threads, picks group roles | Play | Issues, pull requests, `/claim` |
|
||||
| **Group helper** (🧩 Horizon Modding Team) | — | Writes in the group's team channel | — | — |
|
||||
| **🤝 Support** | Closes support posts | Manages threads in #horizon-support | — | — |
|
||||
| **🛡️ Moderator** | Moderation matters, by the mods' vote (section 7) | Time out, delete messages, manage threads and nicknames, audit log, voice mute and move; quarantine through Kira; the staff channels | In-game op if also elected operator | — |
|
||||
| **🛡️ Chief of Mods** | A moderator's, plus bans and unbans; suspends a mod at once; breaks ties in the mods' votes | Plus Ban | As a moderator | — |
|
||||
| **Server operator** | In-game moderation by the server rules | — | Op (the server's ops list); no panel, no `server.toml` | — |
|
||||
| **🧭 Server & SMP lead** | The server's settings, by the written rules (backup first, warning in game) | As every lead | A panel sub-user once a council accounts poll gives one | As every lead |
|
||||
| **🧭 Group lead** | The group's free actions; gives and removes its helper role | In the group's channels: delete messages, manage and pin threads, ping the group; the helper role through Kira | — | Triage on the group's labels through Kira (no repository role) |
|
||||
| **🌌 Horizon Team** | Merges; elects Team members | #horizon-team and the staff channels. The role still carries timeout and delete; whether it keeps them is a mod matter | — | `horizon-team`: write on horizon, horizon-mods, horizon-mixins, horizon-native-mod-template and horizon-server; also kira and horizon-site if P37 passes |
|
||||
| **🔧 Server Manager** | — | Manage Channels and Manage Roles (the server's layout); given by council poll | — | — |
|
||||
| **🏛️ Council** | Council polls, vetoes, emergencies (any 2) | A moderator's permissions plus Ban, mention @everyone and pin; #council | Panel sub-users for two members if P38 passes | The `council` team: code-owner reviews and the release tools' owner powers if P35 passes, security managers if P36 passes; a second organisation owner if P28 passes |
|
||||
| **Account holder** (the founder) | Carries out decisions on his accounts (section 11) | Server owner | The panel's owner account | Organisation owner |
|
||||
|
||||
Kira applies a passed election everywhere she can: Discord roles herself, GitHub team membership when the council has
|
||||
given her a token for it. Whatever she can't do (a panel sub-user, the ops list, a GitHub team without a token) she
|
||||
posts as a list of clicks for a named person.
|
||||
|
||||
## 3. Each level in detail
|
||||
|
||||
### Member
|
||||
|
||||
Anyone in the Discord server or on GitHub. Members follow the server rules and Horizon's
|
||||
[contributing guide](CONTRIBUTING.md). They can propose anything: an idea in #horizon-ideas, a bug in #horizon-bugs,
|
||||
an issue or a pull request on GitHub, or a request for a council poll (any council member can pick it up).
|
||||
|
||||
### Contributor
|
||||
|
||||
- **Discord:** the 🛠️ Contributor role writes in #contributors and in the threads of #polls-and-decisions. A rank
|
||||
role (⛏️ Iron at 10 merged pull requests, 🥇 Gold at 40, 💎 Diamond at 100, 🟪 Netherite at 200, counted over every
|
||||
public horizon-rust repo) shows how much has been merged. Ranks only go up.
|
||||
- **GitHub:** no repository role. Anyone can `/claim` and `/unclaim` their own issues.
|
||||
- **Alone:** their own work.
|
||||
|
||||
### Group member
|
||||
|
||||
Each theme group (section 6) has a role that anyone can pick and drop. It opens the group's channel and receives the
|
||||
group's pings. Picking a role grants no power. A group member counts as **active in the group** when Kira has seen at
|
||||
least one counted action in the group's area (section 5.1) in the last 60 days. Active group members vote in the
|
||||
group's elections and decisions (section 5.3).
|
||||
|
||||
### 🤝 Support
|
||||
|
||||
Unchanged: Support members help players in #horizon-support. They answer every post there and mark posts solved.
|
||||
The role carries no permission, so it has no age rule. A moderator gives it, and takes it back, through Kira.
|
||||
|
||||
### 🛡️ Moderators and the Chief of Mods
|
||||
|
||||
- **Discord:** time out members, delete messages, manage threads and nicknames, read the audit log, quarantine
|
||||
through Kira, and see the staff channels. **Moderators don't ban.** Bans are done by council members and the Chief of
|
||||
Mods. Kira's automatic actions (the honeypot, clear scams, illegal images) stay as they are.
|
||||
- **Official server:** server operators (ops) are moderators in game. They need the 18+ rule and are confirmed the
|
||||
same way (section 6, Server and SMP).
|
||||
- **Alone:** a timeout, a deleted message, a quarantine, with a reason in the mod case.
|
||||
- **The Chief of Mods** is the first contact for moderation questions. They organise the mods, can ban, and can take a
|
||||
moderator's role away at once when there is a problem. The council confirms or reverses that within 72 hours.
|
||||
|
||||
### 🧭 Group leads
|
||||
|
||||
Section 6 says what a lead decides alone and what goes to the council.
|
||||
|
||||
- **Discord:** in their group's channel only, delete messages, manage and pin threads, and ping the group role. The
|
||||
🧭 lead role carries no server-wide permission.
|
||||
- **GitHub:** triage on the issues and pull requests that carry their group's label (`/label`, `/unlabel`, `/close`,
|
||||
`/reopen`, `/unclaim`), from a list of group leads that grants no repository role. Leads merge only if they are on
|
||||
the Horizon Team too.
|
||||
|
||||
### 🌌 Horizon Team
|
||||
|
||||
The Horizon Team **merges pull requests**. Its rules are in [the release flow](docs/release-flow.md): read the whole
|
||||
diff, never merge your own pull request, and use `needs-proof` for anything risky.
|
||||
|
||||
- **GitHub:** member of the `horizon-team` team (write access to the Horizon repos), on the trusted reviewers list
|
||||
(`/nightly` and triage commands).
|
||||
- **Discord:** the 🌌 Horizon Team role, #horizon-team, and writing in #contributors and the read-only channels.
|
||||
Moderation permissions belong to the moderators and the council: a Team member moderates only if they also hold
|
||||
one of those roles.
|
||||
- **During the 30-day probation:** they merge only ordinary pull requests. They don't merge `needs-proof` pull
|
||||
requests, don't approve files that CODEOWNERS guards, and don't use `/nightly`.
|
||||
|
||||
### 🏛️ Council
|
||||
|
||||
- **Discord:** the 🏛️ Council role: the moderators' permissions plus ban and mention @everyone; #council (private);
|
||||
starting polls in #polls-and-decisions.
|
||||
- **GitHub:** the `council` team. Over time it takes over the founder's CODEOWNERS review and his owner powers in the
|
||||
release tools (section 11).
|
||||
- **Alone (any one member):** start a poll; pick up a member's request for a poll; do anything their other roles
|
||||
allow.
|
||||
- **Any two members together:** an emergency action (section 4.6).
|
||||
- **Everything else** is decided by poll.
|
||||
|
||||
## 4. The council and its polls
|
||||
|
||||
### 4.1 Founding council
|
||||
|
||||
kacpherek, levzzz5154, rdbtCVS, Online5277, dezer-x and Ludovic: six members, one vote each. The founding members
|
||||
don't go through the filter or a probation. They do make the 18+ declaration (section 9). The council has at most
|
||||
**9** members. It adds a seat only when a 2/3 poll says it needs one.
|
||||
|
||||
### 4.2 How a poll works
|
||||
|
||||
| Rule | Value |
|
||||
| --- | --- |
|
||||
| Who starts one | Any council member, with `/poll` (Kira). Anyone else asks a council member to start it |
|
||||
| Where | #polls-and-decisions, in public. Polls about a person (removal, appeals, the age rule) run in #council, and only their result is public |
|
||||
| How long | 24 hours; 48 hours for this document, the licence, money and the GitHub organisation's ownership. It closes early only if every council member has voted |
|
||||
| Ballot | Yes, No or Abstain. You can change it until the poll closes |
|
||||
| Quorum | At least half of the council votes (Abstain counts). With 6 members, 3 votes |
|
||||
| Simple majority | More Yes than No |
|
||||
| Two-thirds | Yes is at least 2/3 of the Yes and No votes, **and at least 3 Yes votes** |
|
||||
| Tie | Nothing changes |
|
||||
| No quorum | Nothing changes. Kira reminds the members who haven't voted 12 hours and 2 hours before the end |
|
||||
| About yourself | You don't vote, and you don't count in the council's size for that poll |
|
||||
| Asking again | A poll that failed can't be asked again for 7 days, unless two members agree that new facts justify it |
|
||||
|
||||
### 4.3 Which threshold applies
|
||||
|
||||
| Simple majority | Two-thirds |
|
||||
| --- | --- |
|
||||
| Day-to-day project decisions: direction, priorities, settling disputes between groups, a public statement | Adding council members |
|
||||
| Vetoing a body's vote or election (section 5.3) | Removing a council member or a Team member (without the person concerned) |
|
||||
| Removing a group lead, a moderator, the Chief of Mods or Support (their bodies can also recall them) | Rules: this document, the server rules, the contributing policy, the licence |
|
||||
| Ending a probation early | Money (section 12) |
|
||||
| Confirming an emergency action | Account handover steps (section 11) |
|
||||
| Deciding for a body that has fewer than 3 voters | Waiving part of the selection filter (section 5.4) |
|
||||
| Bringing back someone who stepped down for inactivity, within 6 months | Changing the council's size |
|
||||
|
||||
**When in doubt, two-thirds.** Any council member can say a poll needs 2/3, and Kira then counts it at 2/3.
|
||||
|
||||
### 4.4 Discussion
|
||||
|
||||
Polls that add someone to the council start with a **24-hour public discussion**. Other polls, and elections, are
|
||||
discussed in their own thread while they run. A member who wants more time votes No and says
|
||||
why. A failed poll can come back after 7 days.
|
||||
|
||||
### 4.5 Conflicts of interest
|
||||
|
||||
A member doesn't vote on a poll about themselves: their own role, their own removal, an appeal of their own
|
||||
moderation action, or money paid to them. They say so in the poll's thread and Kira leaves them out of the count.
|
||||
|
||||
### 4.6 Emergencies
|
||||
|
||||
For **security** (a leaked secret, a malicious pull request or mod, a hijacked account), **Mojang or Microsoft** (a
|
||||
request from them, a risk to the approved sign-in) or **legal** matters (a takedown, the law), **any two council
|
||||
members act at once**. They may:
|
||||
|
||||
- pause releases and the Nightly;
|
||||
- take a mod down from the registry;
|
||||
- remove someone's roles or access for now;
|
||||
- lock or hide channels and content;
|
||||
- ask the account holder to rotate a secret or a key.
|
||||
|
||||
They record it with `/emergency` in Kira, and both of them confirm it there. Kira opens a council poll at once
|
||||
(simple majority) to confirm it. If the poll doesn't pass, the action is undone where it can be. An emergency never
|
||||
adds members, changes rules or spends money.
|
||||
|
||||
### 4.7 Removing a council member
|
||||
|
||||
A 2/3 poll of the other members, in #council. The person concerned doesn't vote and doesn't count in the quorum.
|
||||
Only the result is published, never the reasons. Other ways to leave: resigning, inactivity (section 5.5), or a
|
||||
false age declaration (section 9).
|
||||
|
||||
### 4.8 When the council drops below a quorum
|
||||
|
||||
- **Fewer than 5 members:** Kira opens nominations for the council at once. The council fills seats within 30 days.
|
||||
- **Fewer than 3 members, or fewer than 3 who voted in the last 30 days:** an **interim council** forms. It counts the
|
||||
remaining council members, the Horizon Team and the group leads past probation. Its only task is adding council
|
||||
members, by its own 2/3 poll with a quorum of half. Any two of its members can take emergency actions. All other
|
||||
decisions wait. The Team keeps merging and the leads keep leading.
|
||||
- **Nobody left:** the Team and the group leads form the interim council in the same way.
|
||||
|
||||
## 5. The selection filter
|
||||
|
||||
Nobody becomes a lead, a Team member or a council member just because they are well liked or talk a lot. Kira
|
||||
measures the work, and only people above a floor can be nominated. Their body then elects them (section 5.3).
|
||||
|
||||
### 5.1 What Kira measures
|
||||
|
||||
| Signal | What counts | Source |
|
||||
| --- | --- | --- |
|
||||
| **Merged pull requests** | Pull requests merged in a public horizon-rust repo. For Modding, also in the mod API's fork (arismoko/horizon). Counted overall, and per group by label or by files touched | GitHub search, as the ranks are counted today |
|
||||
| **Reviews** | Others' pull requests you reviewed: approved, asked for changes, or left a review with comments. One per pull request | GitHub search `reviewed-by:` and Kira's GitHub webhook |
|
||||
| **Triage** | Triage commands run (`/label`, `/close`, duplicates), issues labelled or closed, bug posts routed on Discord | The triage workflow's comments and Kira's webhook |
|
||||
| **Support answers** | #horizon-support posts you answered that ended solved | Kira's support feature |
|
||||
| **Active days** | Distinct days with any of the above, or with messages in #contributors or a group's channel. Chat elsewhere doesn't count | Kira |
|
||||
| **Tenure** | Days since your first counted contribution (since you joined the server, for moderators) | GitHub, Discord |
|
||||
| **Clean record** | No timeout, quarantine or ban in the last 90 days | Kira's mod cases |
|
||||
| **Verified GitHub** | `/verify-github` done | Kira |
|
||||
| **18+ declaration** | Made (section 9) | Kira |
|
||||
|
||||
Kira turns these into an **evidence card**: the person's numbers, overall and in the group's area, for the last 60
|
||||
days and in total. Anyone can see their own card with `/evidence`. A nomination shows the card publicly.
|
||||
|
||||
### 5.2 The floors
|
||||
|
||||
A nomination is refused below these floors.
|
||||
|
||||
| Role | Floor |
|
||||
| --- | --- |
|
||||
| **Group lead** | Verified GitHub; tenure of 30 days or more; **in the group's area: 8 or more merged pull requests, or 20 or more reviews** (Translations: 5 or more merged translation pull requests; Server and SMP: 8 pull requests, or 30 days as an official server operator with activity); 15 or more active days in the last 60; clean record; 18+ |
|
||||
| **Moderator** | In the server for 30 days or more; Kira level 10 or more; 15 or more active days in the last 60; clean record; 18+ |
|
||||
| **Chief of Mods** | Moderator for 30 days or more, past probation; 18+ |
|
||||
| **Horizon Team** | 🥇 Gold rank (40 merged pull requests); 20 or more reviews of others' pull requests; tenure of 30 days or more; 20 or more active days in the last 60; clean record; verified GitHub; 18+ |
|
||||
| **Council** | Horizon Team member, group lead or Chief of Mods for 60 days or more, past probation; 20 or more active days in the last 60; clean record; 18+ |
|
||||
|
||||
The numbers are checked again once a year (section 15). They should stay reachable for people who really work on
|
||||
Horizon and out of reach for people who don't.
|
||||
|
||||
### 5.3 Elections
|
||||
|
||||
The body that works with a person elects them. Kira runs it: `/elect post: member: [group:]`, and `action: remove`
|
||||
for a recall.
|
||||
|
||||
| Post | Who votes | Passes with | Who can start it |
|
||||
| --- | --- | --- | --- |
|
||||
| **🛡️ Moderator** | The mods (🛡️ Mod and 🛡️ Chief of Mods) | Simple majority; the Chief of Mods breaks a tie | Any mod, a council member |
|
||||
| **🛡️ Chief of Mods** | The mods; candidates are mods for 30 days or more | Simple majority | Any mod, a council member |
|
||||
| **Server operator** | The mods and the 🧭 Server lead | Simple majority | The Server lead, the Chief of Mods, a council member |
|
||||
| **🧭 Group lead** (a co-lead once the group has 8 active members) | The group's active members, and the Team | Simple majority | The current lead, a Team member, a council member |
|
||||
| **🌌 Horizon Team** | The Team | Two thirds, and at least 3 Yes | A Team member, a council member |
|
||||
| **🏛️ Council** | The council, by poll after a 24-hour discussion (section 4) | Two thirds, and at least 3 Yes | A council member |
|
||||
| 🤝 Support, a group's helper role | Nobody: a free action (section 2.1) | — | A moderator; the group's lead |
|
||||
|
||||
An **active group member** has held the group's role for 14 days or more (Kira counts from when she first saw it)
|
||||
and has at least one merged pull request or review with the group's label in the last 60 days.
|
||||
|
||||
1. **Start.** Kira checks the floor (5.2) and the 18+ declaration. If the candidate falls short, the person starting
|
||||
gets a private "not now", and the candidate a private message saying what is missing. Nothing is posted and no
|
||||
reason is ever shown to anyone else. Kira checks what she can measure (verified GitHub, merged pull requests and
|
||||
reviews, overall and by the group's label, rank, days in the server, Kira level, clean record, the declaration);
|
||||
the voters judge the rest.
|
||||
2. **Vote, 24 hours,** in the body's channel, discussed in the vote's thread. Yes, No or Abstain, changeable while it
|
||||
runs. Kira shows how many voted, never who. It closes early once the result can't change.
|
||||
3. **Result,** applied at once. Kira gives or takes the Discord role and, with a token, the GitHub team; she lists the
|
||||
rest as clicks for a named person. Only the result is published, never the ballots.
|
||||
4. **Veto, 24 hours.** Any council member can call it with the button under the result. A council poll (simple
|
||||
majority, without the people involved) decides; if it passes, Kira undoes the result.
|
||||
5. **Probation, 30 days,** for leads and Team members. It ends by itself unless someone who can vote starts a recall.
|
||||
|
||||
The council can always remove someone by its own poll. **Body votes** work the same way for a body's own decisions:
|
||||
`/vote body:mods`, `body:team` or `body:group`, simple majority, 24 hours, the same veto.
|
||||
|
||||
#### Hard cases
|
||||
|
||||
| Case | What happens |
|
||||
| --- | --- |
|
||||
| Fewer than 3 can vote | The council decides by poll |
|
||||
| Quorum | A third of the electorate, and at least 3 ballots (Abstain counts). Without it, nothing changes |
|
||||
| Tie | Fails, and nothing changes. In the mods' votes, the Chief of Mods' ballot breaks it |
|
||||
| The candidate | Doesn't vote on themselves, and can decline (the election ends). The person who started it votes normally |
|
||||
| Conflicts | Whoever the vote is about, or who gave or received the moderation action in question, doesn't vote |
|
||||
| Several candidates for one seat | Nominations within the first 12 hours join one approval ballot. The seat goes to the most approvals if that is at least half of the voters; otherwise a 24-hour run-off between the top two |
|
||||
| A seat that is held | Changes only by a recall or by the 6-month confirmation |
|
||||
| The council is already voting on it | No election starts for the same person and post |
|
||||
| Cooldown | A failed or vetoed election can't be started again for the same person and post for 30 days. At most 3 elections run at once in a body |
|
||||
| Vacancy | When a lead or the Chief of Mods leaves, Kira opens the seat's election herself: 24 hours of nominations, then the vote with everyone nominated. Nobody nominated: the seat stays open, and the tier above acts meanwhile. Kira never picks a candidate |
|
||||
| Abuse of a free action | The tier above reverses it; a disagreement goes to its vote. A pattern is a recall |
|
||||
| Emergency | Section 4.6. The Chief of Mods can still suspend a mod at once |
|
||||
| Kira is down | The body may run the same vote as a Discord poll in its channel for 24 hours; Kira records the result when she is back |
|
||||
|
||||
#### Reminders, not nominations
|
||||
|
||||
Once a month in a group's channel, Kira says how to propose someone, only when the group has no lead or someone in it
|
||||
meets a post's floor. When a seat opens, she posts once in the electorate's channel. Once a month in #council, she
|
||||
lists privately the people who meet a floor. These are suggestions: only people start elections.
|
||||
|
||||
### 5.4 Exceptions
|
||||
|
||||
Some valuable people don't show up in the numbers, such as a designer for the website. The council can **waive the
|
||||
tenure or the area floors** by a 2/3 poll that says why. The age rule and the clean record are never waived.
|
||||
|
||||
### 5.5 Inactivity
|
||||
|
||||
Kira checks every day. It warns the person in a DM and in #council, then posts the step-down in
|
||||
#polls-and-decisions with neutral wording ("stepped down: inactive"), and removes the Discord role. The account
|
||||
holder or an org owner removes GitHub access when the Team role ends.
|
||||
|
||||
| Role | What counts as activity | Warning | Steps down |
|
||||
| --- | --- | --- | --- |
|
||||
| Group lead | A counted action in the group's area, or messages in the group's channel | Day 21 | Day 30 |
|
||||
| Horizon Team | A merge or a review | Day 45 | Day 60 |
|
||||
| Moderator, Chief of Mods | A moderation action, or messages in the staff channels | Day 21 | Day 30 |
|
||||
| Support | An answer in #horizon-support | Day 21 | Day 30 |
|
||||
| Council | A vote in a poll. At 30 days with no vote, while at least 2 polls were held | Day 23 | Day 30, or 45 days with no counted activity at all |
|
||||
|
||||
- **Away.** Anyone can announce an absence with `/away` for up to 45 days, once every 6 months. Away days don't
|
||||
count. A lead names someone from the group to cover for them.
|
||||
- **Coming back.** Within 6 months: a simple-majority poll, with no new probation. After 6 months: the normal path.
|
||||
|
||||
### 5.6 Terms
|
||||
|
||||
- **Group leads** are confirmed every 6 months by an election in their group. If someone else in the group passes the
|
||||
floor and is nominated within its first 12 hours, both go on the same ballot.
|
||||
- **Council and Team** have no fixed term. They stay as long as they are active and keep the council's trust. Removal
|
||||
is always possible (section 4.7).
|
||||
|
||||
## 6. Theme groups
|
||||
|
||||
### 6.1 The groups
|
||||
|
||||
| Group | Covers | GitHub | Discord |
|
||||
| --- | --- | --- | --- |
|
||||
| **Modding** | The mod API, native and content mods, mixins, the mod registry, `cargo horizon-mod` | The mod API lives in Ari's fork **arismoko/horizon**. Its owner runs that repo by their own rules; the council governs only what is Horizon's: horizon-rust/horizon-mods, horizon-mixins, horizon-native-mod-template, the registry. Label `mods` | The 🧩 MODDING category (#modding, #horizon-modding-team); roles 🧩 Modder (picked) and 🧩 Horizon Modding Team (the lead gives it) |
|
||||
| **Shaders and rendering** | The renderer, shaders, shader packs, OptiFine and Polytone pack formats | Label `rendering`; `crates/horizon-client-render` (gfx), `*.wgsl`, `horizon-shaders`, `horizon-shader-api`, `horizon-optifine`, `horizon-polytone` | #shaders-rendering |
|
||||
| **Performance** | Frame rate, memory, server tick, benchmarks | Label `perf`; `horizon-bench`, the benchmark tools | #performance |
|
||||
| **Launcher** | The launcher: installs, updates, instances, its pages | Label `launcher`; `launcher/` | #launcher |
|
||||
| **Server and SMP** | Dedicated server, hosting, the anticheat, multiplayer, and the official SMP | Labels `server`, `multiplayer`; `horizon-server*`, `horizon-anticheat`, `lan/`, `dedicated*`; horizon-rust/horizon-server | #server-smp (plus #join-server, #server-chat) |
|
||||
| **Translations** | The game's and the launcher's languages | Label `roadmap:languages`; `launcher/lang/`, `crates/horizon-client/assets/horizon/lang/` | #translations and the 🌍 language channels |
|
||||
| **Website** | playhorizon.dev | horizon-rust/horizon-site; label `website`. The site's design stays as it is unless the council decides otherwise | #website |
|
||||
| **Vanilla accuracy** | Parity with vanilla 26.3: harnesses, oracles, the port ledger | Labels `parity`, `parity-regression`, `full-parity`; `tools/test_*_parity.py`, the Java oracles | #vanilla-accuracy |
|
||||
| **UI** | The game's menus, HUD, settings, controller navigation, the design system | Labels `ui`, `accessibility`; `title/`, `settings_shell.rs`, `hud*`, `ui.rs`, `docs/design-system.md`, `horizon-theme`. The launcher's look is shared with Launcher | #ui |
|
||||
|
||||
A pull request can belong to two groups. Both leads can comment, and the Team merges as usual. If the leads disagree,
|
||||
the council decides.
|
||||
|
||||
### 6.2 Leads per group
|
||||
|
||||
- **One lead per group.** A group with 8 or more active members may also have **one co-lead**, chosen the same way.
|
||||
- A person leads **at most two groups**. Council members may lead a group, though a lead from outside the council is
|
||||
preferred: it spreads the work.
|
||||
|
||||
### 6.3 What a lead decides alone
|
||||
|
||||
Inside their group's topic, and within this document, the contributing guide, the vanilla-first rule, the layers and
|
||||
the design system:
|
||||
|
||||
- the group's priorities and the order of its roadmap items;
|
||||
- which issues are `good first issue`, and triage of the group's issues;
|
||||
- technical and design direction inside the area, when vanilla behaviour and the default look don't change;
|
||||
- accepting or declining an idea or a proposal in the area. Any two active group members or one council member can
|
||||
take that decision to the council;
|
||||
- giving and removing the group's helper role, through Kira (a free action);
|
||||
- how the group organises itself (threads, meetings, the channel's rules on top of the server's);
|
||||
- asking the Team for reviews, adding `needs-proof`, and starting elections in the group.
|
||||
|
||||
### 6.4 What goes to the council
|
||||
|
||||
- the rules, money, accounts and secrets;
|
||||
- who is on the council, the Team or the mods;
|
||||
- anything that changes vanilla behaviour or turns a Horizon-only feature on by default;
|
||||
- breaking a public promise: the mod API, server compatibility, save and file formats;
|
||||
- disputes between groups;
|
||||
- anything involving Mojang, Microsoft or the law;
|
||||
- speaking for the whole project outside the group's topic.
|
||||
|
||||
### 6.5 A group without a lead
|
||||
|
||||
The work goes on. The Team reviews and merges, and Kira still routes the group's issues and questions to its
|
||||
channel. Until a lead is chosen, any council member may take a lead's decision for that group and says so in the
|
||||
channel. Kira posts once that the seat is open and how to start its election (section 5.3). **No lead is better than a lead who doesn't do the work:**
|
||||
nobody is appointed just to fill the seat.
|
||||
|
||||
## 7. Moderation and appeals
|
||||
|
||||
- **The mods decide moderation matters** (council poll P41, imposed by the founder on 2026-11-10): who joins or
|
||||
leaves the mod team, the mods' permissions and the staff channels, bans and unbans, and channel settings for
|
||||
moderation. Everyday actions are free (section 2.1). Anything bigger is a mods' vote (`/vote body:mods`, simple
|
||||
majority, 24 hours), and the Chief of Mods breaks ties. **The council keeps a veto:** within 24 hours of a mods'
|
||||
decision, any council member can call a council poll, and a simple majority overturns it.
|
||||
- **The server rules** are in #rules. Moderators enforce them, the Chief of Mods leads the mods, and the council can
|
||||
change the rules (2/3).
|
||||
- **Every action has a mod case**: who acted, what they did, and why. Kira keeps the cases.
|
||||
- **First appeal:** within 14 days, through #appeal (for a quarantine) or mod mail. A moderator who wasn't involved,
|
||||
or the Chief of Mods, answers within 72 hours.
|
||||
- **Second appeal:** for bans, timeouts over 24 hours and quarantines, a mods' vote in #mod-chat. Mods who were
|
||||
involved don't vote, and the council's veto applies. The answer is final for 90 days.
|
||||
- **Not appealable here:** actions that Discord's rules or the law require. Those are reported where the law says.
|
||||
- **Removing a moderator:** the Chief of Mods can suspend a moderator at once, and the mods decide within 72 hours by
|
||||
a recall vote. The mods recall the Chief of Mods the same way. The council can also remove either by poll.
|
||||
- **Council members keep the ban permission** for emergencies and when no moderator is around. They log the ban like
|
||||
any other, and the mods can reverse it by vote.
|
||||
- **Staff are moderated like everyone.** When a council member is involved, the council decides without them.
|
||||
|
||||
## 8. Conflicts and hard cases
|
||||
|
||||
| Case | What happens |
|
||||
| --- | --- |
|
||||
| Two groups disagree | The leads talk first. If they still disagree, the council decides (simple majority) |
|
||||
| A lead's decision is contested | Any two active group members or one council member can bring it to the council |
|
||||
| A Team member merges against the rules | Any council member can revert the merge. The council can remove the member (2/3) |
|
||||
| A council member breaks the rules | The council decides without them. Removal takes 2/3 (section 4.7) |
|
||||
| Someone holds a role and is under 18 | Section 9 |
|
||||
| The council can't reach quorum | Section 4.8 |
|
||||
| The founder and the council disagree about one of his own accounts | Until a step of section 11 hands it over, the account stays his. He carries out the council's decisions on project matters, or he says publicly that he won't |
|
||||
|
||||
## 9. The age rule (18+)
|
||||
|
||||
**Who must be 18 or older:** members of the council and the Horizon Team, trusted reviewers, group leads and
|
||||
co-leads, the Chief of Mods and the moderators, official server operators, and anyone with admin rights anywhere in
|
||||
the project: Discord (Administrator, Manage Roles, Manage Channels, ban), GitHub (write, admin, organisation),
|
||||
server panels, Railway, or Kira's admin side. The same goes for anyone who holds a project secret.
|
||||
|
||||
**Who doesn't have to be:** members, contributors (with any rank), group members and 🤝 Support. Contributors under
|
||||
18 are welcome and valued. They just can't hold the roles above.
|
||||
|
||||
**How it's checked, without collecting ID:**
|
||||
|
||||
- **A declaration.** Before taking such a role, and once at the start for everyone who holds one, the person presses
|
||||
a button in Kira: "I confirm that I am 18 or older. I understand that a false declaration removes all my roles of
|
||||
trust." Kira keeps only the Discord account, the date and the wording's version. It never stores a birth date, an
|
||||
ID, a photo, or any outside age-checking service's result.
|
||||
- **No declaration, no role.** At the start, current role holders have 7 days to make it. Until they do, their role is
|
||||
paused.
|
||||
- **When there's doubt** (the person says they are younger, or there is a credible private report), one council
|
||||
member asks the person privately. If the doubt remains, any council member pauses the role, and the council decides
|
||||
within 72 hours in #council.
|
||||
- **A false declaration** removes every role of trust at once, and the person can't hold one for 12 months.
|
||||
- **Privacy.** Age is never discussed in public. A role that ends for this reason ends with the same neutral line as
|
||||
any other step-down.
|
||||
|
||||
## 10. The agents
|
||||
|
||||
Horizon uses AI agents: the founder's orchestrator, which runs coding agents, and Kira, the Discord bot.
|
||||
|
||||
- **They obey council decisions only:** a passed poll recorded by Kira, a recorded emergency action of two council
|
||||
members, and the standing rules in this document and the project's docs.
|
||||
- **The only exception is the founder's own things:** his spending, his machines (they run CI and the vanilla tests),
|
||||
his personal accounts and his time. On those, his word decides. This list should get shorter over time (section 11).
|
||||
- **Role work isn't a decision.** A lead asking Kira to route issues, or a Team member asking an agent to prove a pull
|
||||
request, is that person using their role. The agents help, within the role's powers.
|
||||
- **They check the source.** An agent acts on a decision only when it finds it in Kira's decisions log
|
||||
(section 13). A message saying "the council decided", or a screenshot, is not enough.
|
||||
- **They post as agents.** Everything an agent posts says it comes from an agent, and states decisions as the
|
||||
project's.
|
||||
|
||||
## 11. Accounts and the handover path
|
||||
|
||||
Today the founder holds every account. The aim is for him to hold fewer, in an order that never leaves the project
|
||||
unable to act, and never puts an account that is tied to a person into anyone else's hands.
|
||||
|
||||
| Account | Today | Can it be shared? | Plan |
|
||||
| --- | --- | --- | --- |
|
||||
| **GitHub organisation** (horizon-rust) | Two owners since 2026-11-11: the founder and rdbtCVS (P28); a `council` team holds CODEOWNERS (P35) and is the security managers (P36) | Yes: an organisation can have several owners | **Step 1:** a second owner from the council, chosen by a 2/3 poll in the council's first week, with two-factor authentication on. **Step 2:** a `council` team; CODEOWNERS reviews and the release tools' owner powers (`paused`, `/nightly remove`) move from the founder to that team |
|
||||
| **Discord server** | Owned by the founder | Partly: a server has exactly one owner; powers can be given through roles | **Step 1:** the 🏛️ Council role gets moderation and ban permissions; Kira applies the outcomes of passed polls, so nobody needs Manage Roles; two-factor authentication becomes required for moderation. Ownership itself stays with the founder until a legal entity exists |
|
||||
| **Kira** (bot application, Railway service) | The founder's Discord developer account and Railway | The bot: yes (a Discord developer team). Railway: adding members may need a paid plan | **Step 1:** a Discord developer team with two council members. Railway members once the council decides on money |
|
||||
| **Official server** (hosting panel) | The founder's account | Yes: panel sub-users | **Step 1:** sub-users for the Server and SMP lead and for one council member |
|
||||
| **Domains** (playhorizon.dev and the others) | The founder's registrar account | Not without moving it | Auto-renewal on, registered for several years ahead. They move only to a legal entity, if one is created |
|
||||
| **The Microsoft app approved by Mojang** (sign-in) | The founder's Azure app registration; Mojang approved it after his application | **No.** The approval was given on his application. Moving the app or adding owners would change who Mojang deals with, and we have found no documented way to transfer an approval | **Never moves** without Mojang's written agreement. The only change considered: a legal entity, if one is created, and only if Mojang agrees |
|
||||
| **Signing** (Apple developer certificate, release signing keys) | The founder's | The Apple certificate is in his legal name: no | The Apple certificate never moves. Release keys stay in GitHub's protected environments, and the council team approves their use from step 2 |
|
||||
| **Money** (sponsorship, donations) | Personal accounts of the founder | Through a fiscal host | Section 12 |
|
||||
| **The founder's machines, AI accounts and password vault** | His | No | His own things: they never move. The project should depend on them less over time |
|
||||
|
||||
**Order:** first the safety nets that remove a single point of failure (a second organisation owner, the bot's
|
||||
developer team, panel sub-users). Then the day-to-day powers (CODEOWNERS and release tools to the council team). Last,
|
||||
whatever needs a legal entity: domains, money, and with Mojang's agreement the sign-in app. Each step is a 2/3 poll,
|
||||
and the founder carries it out.
|
||||
|
||||
## 12. Money
|
||||
|
||||
*Placeholder: the options (Open Collective and others) are being researched.*
|
||||
|
||||
- The council approves every expense by a **2/3 poll**.
|
||||
- **Every income and every expense is public.**
|
||||
- Nobody votes on money paid to themselves.
|
||||
|
||||
## 13. Transparency
|
||||
|
||||
- **#polls-and-decisions:** every poll and every result, posted by Kira, and the 24-hour discussions of nominations.
|
||||
For polls that aren't about a person, each member's vote is shown once the poll closes. The results of elections
|
||||
and body votes are posted there too; for elections, only the result.
|
||||
- **#staff-log:** the free actions Kira sees (roles given or taken, bans, unbans), so the tier above can check them.
|
||||
- **The decisions log:** Kira keeps an append-only record of every poll, emergency action, appointment and
|
||||
step-down, with its date and result. It is public and machine-readable, so the agents can check a decision there.
|
||||
- **This document** lives in horizon-rust/horizon as `GOVERNANCE.md`. A short summary is pinned in #rules and in
|
||||
#polls-and-decisions.
|
||||
- **Who holds which role:** the people page of playhorizon.dev's hub shows the council, the Team, the group leads,
|
||||
the Chief of Mods, the moderators and Support.
|
||||
- **#council** is private for discussion and for polls about people. Everything the council decides is published.
|
||||
|
||||
## 14. Rules that are never voted on
|
||||
|
||||
These are Horizon's conditions for existing, not choices. No poll can change them:
|
||||
|
||||
- Playing needs a **Microsoft account that owns Minecraft: Java Edition**. There is no play without one.
|
||||
- **No Mojang files** in Horizon's repos or downloads. Players' own copies stay on their own machines.
|
||||
- **No gambling**, in any form, including with in-game currency.
|
||||
- **Mojang's and Microsoft's terms and Usage Guidelines** apply to everything Horizon does.
|
||||
- **The law**, and the terms of service of Discord and GitHub.
|
||||
|
||||
## 15. Changing this document
|
||||
|
||||
A 2/3 council poll, then a pull request that a Team member merges. The council reviews the floors' numbers
|
||||
(section 5.2) once a year, or sooner if they turn out wrong.
|
||||
@@ -109,7 +109,7 @@ horizon-ctl break -1 65 -3 # mines it; replies with
|
||||
|
||||
## Build from source
|
||||
|
||||
Needs Rust 1.90+, Python 3.11+, JDK 25, and on Linux `libasound2-dev libudev-dev`. `horizon-prepare` builds the game's resources from **your own copy** of Java Edition 26.3 (checked against `research-lock.json`) and never downloads it:
|
||||
Needs Rust 1.90+, Python 3.10+, JDK 25, and on Linux `libasound2-dev libudev-dev`. `horizon-prepare` builds the game's resources from **your own copy** of Java Edition 26.3 (checked against `research-lock.json`) and never downloads it:
|
||||
|
||||
1. **Client:** play 26.3 once in the official launcher. Horizon reads `~/.minecraft` (or `--minecraft-dir` / `HORIZON_MINECRAFT_DIR`).
|
||||
2. **Server jar:** the 26.3 server jar (`downloads.server.url` in `versions/26.3/26.3.json`), passed once with `--server-jar` or `HORIZON_SERVER_JAR`.
|
||||
@@ -135,8 +135,6 @@ python3 tools/pr_ready.py
|
||||
|
||||
It runs rustfmt, clippy, the touched crates' tests, the Python tests, the public-tree check and the vanilla harnesses your change affects. Never commit game files, decompiled source or prepared resources.
|
||||
|
||||
How Horizon is run (the council, theme groups and their leads, the Horizon Team, moderation): [GOVERNANCE.md](GOVERNANCE.md).
|
||||
|
||||
## Project status
|
||||
|
||||
**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 table below says what the harnesses compare.
|
||||
|
||||
@@ -358,29 +358,3 @@ state of Starling's `crates/horizon-client-render/src/touch.rs` at the same comm
|
||||
|
||||
`crates/horizon-client-render/src/android_runtime.rs` and `android_runtime/tests/` are adapted from the
|
||||
input part of Starling's `crates/horizon-client-render/src/android_runtime.rs` at the same commit.
|
||||
|
||||
## PCG3D hash
|
||||
|
||||
`hash3` in `crates/horizon-client-render/src/hdr.wgsl`, the noise HDR10 output's dither is made from, is the
|
||||
`pcg3d` hash of Mark Jarzynski and Marc Olano ("Hash Functions for GPU Rendering", Journal of Computer Graphics
|
||||
Techniques 9(3), 2020), from <https://github.com/markjarzynski/PCG3D>. Copyright 2020 Mark Jarzynski & Marc Olano,
|
||||
licensed under MIT:
|
||||
|
||||
```
|
||||
Copyright 2020 Mark Jarzynski & Marc Olano
|
||||
|
||||
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.
|
||||
```
|
||||
@@ -15,18 +15,10 @@ pub const OFFICIAL_SERVER: &str = "play.playhorizon.dev";
|
||||
/// `official_server_previous` in `launcher/links.json`).
|
||||
pub const OFFICIAL_SERVER_PREVIOUS: &[&str] = &["horizon.bunchy7s.dev"];
|
||||
|
||||
/// Afri-Network, a partner server the game added once below the official server (#9299) until
|
||||
/// the partnership ended: kept so the game can take back out the entry it added.
|
||||
pub const WITHDRAWN_PARTNER_SERVER: &str = "afri-network.co.za";
|
||||
|
||||
/// [`OFFICIAL_SERVER`] with the port its SRV record gives, for askers that don't look SRV up
|
||||
/// (the telemetry server's status checks).
|
||||
pub const OFFICIAL_SERVER_STATUS: &str = "play.playhorizon.dev:24454";
|
||||
|
||||
/// Horizon's website, where the launcher is downloaded: the game started without its prepared
|
||||
/// files sends the player there.
|
||||
pub const WEBSITE: &str = "https://playhorizon.dev";
|
||||
|
||||
/// The cosmetics service: cosmetics, emotes, "Show me as a Horizon player"
|
||||
/// (`HORIZON_COSMETICS_API` overrides it).
|
||||
pub const COSMETICS_API: &str = "https://cosmetics.playhorizon.dev";
|
||||
|
||||
@@ -21,6 +21,7 @@ use std::collections::BTreeMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod combat;
|
||||
pub mod prediction;
|
||||
pub mod timer;
|
||||
|
||||
/// What one check's violation level leads to.
|
||||
@@ -161,8 +162,8 @@ checks! {
|
||||
|
||||
/// Prediction: each move played again from the client's own inputs with vanilla's movement code,
|
||||
/// and compared (speed, step, fly, jesus...). Each player's client ticks are recorded
|
||||
/// (horizon-client's `lan::prediction::record`), with a ping a tick to place what the server changes,
|
||||
/// and played again (`lan::prediction`). A move that lands farther than `tolerance` from the
|
||||
/// (`prediction::Record`), with a ping a tick to place what the server changes, and played again
|
||||
/// (horizon-client's `lan::prediction`). A move that lands farther than `tolerance` from the
|
||||
/// replay flags, by how far it lands (one `tolerance` off weighs 1, at most 5 a tick), and the
|
||||
/// setback puts the player back where its moves last agreed. A tick near what the replay can't
|
||||
/// play yet (another entity, a block change or abilities the client hadn't seen, a moving piston,
|
||||
@@ -278,6 +279,15 @@ pub struct Player {
|
||||
pub timer: timer::Balance,
|
||||
/// The server tick and height where the ground last held the player.
|
||||
pub ground: Option<(u64, f64)>,
|
||||
/// The client ticks the prediction plays again (only while it runs).
|
||||
pub record: Option<Box<prediction::Record>>,
|
||||
/// Sprinting as the client's own commands left it (ServerboundPlayerCommandPacket's
|
||||
/// START_SPRINTING and STOP_SPRINTING: LocalPlayer.wasSprinting), which the record's ticks take.
|
||||
/// Not the server's player: a hit with extra knockback stops its sprint
|
||||
/// (Player.causeExtraKnockback), the client's own `Player.attack` doesn't (a mob's `hurtClient`
|
||||
/// is false), and once the server's flag reaches it LocalPlayer.aiStep starts the sprint again
|
||||
/// while the sprint key is held, with nothing to say: wasSprinting never changed.
|
||||
pub sprinting: bool,
|
||||
/// The attacks of the client tick being heard.
|
||||
pub attacks: combat::Attacks,
|
||||
}
|
||||
|
||||
File renamed without changes.
@@ -26,23 +26,18 @@ pub struct AnimationMetadata {
|
||||
pub enum ClipMode {
|
||||
/// `day_time`: frame 0 at time 0, the last at the cycle's end, then again.
|
||||
DayTime { cycle: i64 },
|
||||
/// `screen_time`: the ticks the open screen has been open, over the cycle, held on the last
|
||||
/// frame once it has run (it doesn't loop, measured on the real mod).
|
||||
ScreenTime { cycle: i64 },
|
||||
/// `weather`: frame 0 in clear weather, 1 in rain, 2 in a storm (the rain and thunder levels
|
||||
/// added up), at most the last.
|
||||
Weather,
|
||||
/// A Polytone expression: its value's fraction is how far through the animation it is.
|
||||
Expression(String),
|
||||
/// `game_time`, `season`: not driven; the animation runs on ticks.
|
||||
/// `game_time`, `screen_time`, `season`: not driven yet; the animation runs on ticks.
|
||||
Unsupported(String),
|
||||
}
|
||||
|
||||
/// The values [`ClipMode`]s read this frame.
|
||||
pub struct ModeInputs<'a> {
|
||||
pub day_time: i64,
|
||||
/// The ticks the open screen has been open (0 without one).
|
||||
pub screen_time: i64,
|
||||
/// The rain level plus the thunder level, 0 to 2.
|
||||
pub weather: f32,
|
||||
/// An expression's value this frame, by its source.
|
||||
@@ -121,8 +116,7 @@ impl AnimationMetadata {
|
||||
match mode.as_str() {
|
||||
"day_time" => ClipMode::DayTime { cycle },
|
||||
"weather" => ClipMode::Weather,
|
||||
"screen_time" => ClipMode::ScreenTime { cycle },
|
||||
"game_time" | "season" => ClipMode::Unsupported(mode),
|
||||
"game_time" | "screen_time" | "season" => ClipMode::Unsupported(mode),
|
||||
_ => ClipMode::Expression(mode),
|
||||
}
|
||||
}),
|
||||
@@ -245,9 +239,6 @@ impl AnimationClip {
|
||||
pub fn sample_mode(&self, inputs: &ModeInputs) -> Option<AnimationSample> {
|
||||
let fraction = match self.mode.as_ref()? {
|
||||
ClipMode::DayTime { cycle } => day_fraction(inputs.day_time, *cycle),
|
||||
ClipMode::ScreenTime { cycle } => {
|
||||
(inputs.screen_time.max(0) as f64 / *cycle as f64).min(1.0 - f64::EPSILON)
|
||||
}
|
||||
ClipMode::Weather => {
|
||||
let last = self.sequence.len() - 1;
|
||||
let index = (inputs.weather.max(0.0) as usize).min(last);
|
||||
@@ -297,7 +288,6 @@ mod mode_tests {
|
||||
let expression = move |_: &str| Some(value);
|
||||
clip.sample_mode(&ModeInputs {
|
||||
day_time,
|
||||
screen_time: day_time,
|
||||
weather,
|
||||
expression: &expression,
|
||||
})
|
||||
@@ -312,13 +302,6 @@ mod mode_tests {
|
||||
.map(|t| frame(&day, t, 0.0, 0.0))
|
||||
.into();
|
||||
assert_eq!(frames, [0, 1, 2, 3, 1]);
|
||||
// 26.3-6.8.6, four frames over 200 ticks: 40 ticks open show the first, and from ten
|
||||
// seconds on the last, even after 27 (it doesn't loop).
|
||||
let screen = clip(r#"{"frametime": 1, "mode": "screen_time", "time_cycle_duration": 200}"#);
|
||||
let frames: Vec<u32> = [0, 40, 200, 360, 540]
|
||||
.map(|t| frame(&screen, t, 0.0, 0.0))
|
||||
.into();
|
||||
assert_eq!(frames, [0, 0, 3, 3, 3]);
|
||||
let value = clip(r#"{"frametime": 1, "mode": "0.6"}"#);
|
||||
assert_eq!(frame(&value, 0, 0.0, 0.6), 2);
|
||||
assert_eq!(frame(&value, 0, 0.0, 2.0), 0);
|
||||
@@ -338,7 +321,6 @@ mod mode_tests {
|
||||
clip(r#"{"frametime": 1}"#)
|
||||
.sample_mode(&ModeInputs {
|
||||
day_time: 0,
|
||||
screen_time: 0,
|
||||
weather: 0.0,
|
||||
expression: &|_| None
|
||||
})
|
||||
@@ -348,7 +330,6 @@ mod mode_tests {
|
||||
clip(r#"{"frametime": 1, "mode": "season"}"#)
|
||||
.sample_mode(&ModeInputs {
|
||||
day_time: 0,
|
||||
screen_time: 0,
|
||||
weather: 0.0,
|
||||
expression: &|_| None
|
||||
})
|
||||
|
||||
@@ -1764,13 +1764,14 @@ fn stack_from_template(template: &Value) -> Option<StackView> {
|
||||
|
||||
/// `RangeSelectItemModel.lastIndexLessOrEqual`.
|
||||
fn last_index_less_or_equal(entries: &[(f32, ItemModelNode)], needle: f32) -> Option<usize> {
|
||||
let found = if entries.len() < 16 {
|
||||
entries
|
||||
let haystack: Vec<f32> = entries.iter().map(|e| e.0).collect();
|
||||
let found = if haystack.len() < 16 {
|
||||
haystack
|
||||
.iter()
|
||||
.position(|e| e.0 > needle)
|
||||
.map_or(entries.len() as i64 - 1, |i| i as i64 - 1)
|
||||
.position(|h| *h > needle)
|
||||
.map_or(haystack.len() as i64 - 1, |i| i as i64 - 1)
|
||||
} else {
|
||||
match binary_search(entries, needle) {
|
||||
match binary_search(&haystack, needle) {
|
||||
Ok(index) => index as i64,
|
||||
Err(insertion) => insertion as i64 - 1,
|
||||
}
|
||||
@@ -1779,8 +1780,8 @@ fn last_index_less_or_equal(entries: &[(f32, ItemModelNode)], needle: f32) -> Op
|
||||
}
|
||||
/// `Arrays.binarySearch(float[], float)`, including its handling of equal
|
||||
/// values (-0.0 below 0.0, NaN above everything).
|
||||
fn binary_search(entries: &[(f32, ItemModelNode)], key: f32) -> std::result::Result<usize, usize> {
|
||||
let (mut low, mut high) = (0i64, entries.len() as i64 - 1);
|
||||
fn binary_search(a: &[f32], key: f32) -> std::result::Result<usize, usize> {
|
||||
let (mut low, mut high) = (0i64, a.len() as i64 - 1);
|
||||
let bits = |v: f32| -> i32 {
|
||||
if v.is_nan() {
|
||||
0x7fc0_0000
|
||||
@@ -1790,7 +1791,7 @@ fn binary_search(entries: &[(f32, ItemModelNode)], key: f32) -> std::result::Res
|
||||
};
|
||||
while low <= high {
|
||||
let mid = (low + high) >> 1;
|
||||
let value = entries[mid as usize].0;
|
||||
let value = a[mid as usize];
|
||||
if value < key {
|
||||
low = mid + 1;
|
||||
} else if value > key {
|
||||
|
||||
@@ -158,61 +158,3 @@ fn a_patched_stack_reads_its_patch_over_its_items_defaults() {
|
||||
}
|
||||
assert_eq!(view.to_map(), *merged);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_index_less_or_equal_linear_and_binary() {
|
||||
use super::last_index_less_or_equal;
|
||||
let make = |thresholds: &[f32]| -> Vec<(f32, ItemModelNode)> {
|
||||
thresholds
|
||||
.iter()
|
||||
.map(|&t| (t, ItemModelNode::Empty))
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Empty
|
||||
assert_eq!(last_index_less_or_equal(&[], 0.0), None);
|
||||
|
||||
// Short (< 16): linear search
|
||||
let short = make(&[-1.0, 0.0, 1.0, 2.5, 5.0]);
|
||||
assert_eq!(last_index_less_or_equal(&short, -2.0), None);
|
||||
assert_eq!(last_index_less_or_equal(&short, -1.0), Some(0));
|
||||
assert_eq!(last_index_less_or_equal(&short, -0.5), Some(0));
|
||||
assert_eq!(last_index_less_or_equal(&short, 0.0), Some(1));
|
||||
assert_eq!(last_index_less_or_equal(&short, 2.5), Some(3));
|
||||
assert_eq!(last_index_less_or_equal(&short, 3.0), Some(3));
|
||||
assert_eq!(last_index_less_or_equal(&short, 5.0), Some(4));
|
||||
assert_eq!(last_index_less_or_equal(&short, 10.0), Some(4));
|
||||
|
||||
// Long (>= 16): binary search
|
||||
let thresholds: Vec<f32> = (0..32).map(|i| i as f32 * 10.0).collect();
|
||||
let long = make(&thresholds);
|
||||
assert_eq!(last_index_less_or_equal(&long, -5.0), None);
|
||||
assert_eq!(last_index_less_or_equal(&long, 0.0), Some(0));
|
||||
assert_eq!(last_index_less_or_equal(&long, 5.0), Some(0));
|
||||
assert_eq!(last_index_less_or_equal(&long, 10.0), Some(1));
|
||||
assert_eq!(last_index_less_or_equal(&long, 150.0), Some(15));
|
||||
assert_eq!(last_index_less_or_equal(&long, 155.0), Some(15));
|
||||
assert_eq!(last_index_less_or_equal(&long, 310.0), Some(31));
|
||||
assert_eq!(last_index_less_or_equal(&long, 1000.0), Some(31));
|
||||
|
||||
// Short lists use upper-bound tie behavior, including signed zero.
|
||||
let duplicates = make(&[1.0, 1.0, 2.0]);
|
||||
assert_eq!(last_index_less_or_equal(&duplicates, 1.0), Some(1));
|
||||
|
||||
let zeroes = make(&[-0.0, 0.0]);
|
||||
assert_eq!(last_index_less_or_equal(&zeroes, -0.0), Some(1));
|
||||
assert_eq!(last_index_less_or_equal(&zeroes, 0.0), Some(1));
|
||||
|
||||
// At 16 entries, vanilla returns Arrays.binarySearch's hit, not the last tie.
|
||||
// Expected indices were checked on the JVM.
|
||||
let duplicates = make(&[
|
||||
0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 7.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0,
|
||||
]);
|
||||
assert_eq!(last_index_less_or_equal(&duplicates, 7.0), Some(7));
|
||||
|
||||
let zeroes = make(&[
|
||||
-8.0, -7.0, -6.0, -5.0, -4.0, -3.0, -2.0, -0.0, -0.0, 0.0, 0.0, 1.0, 2.0, 3.0, 4.0, 5.0,
|
||||
]);
|
||||
assert_eq!(last_index_less_or_equal(&zeroes, -0.0), Some(7));
|
||||
assert_eq!(last_index_less_or_equal(&zeroes, 0.0), Some(9));
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
//! The machine a report was measured on. CPU, RAM and OS are detected here; the GPU and the display
|
||||
//! come from the program that rendered (the game knows its adapter, the launcher its window).
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
use horizon_telemetry::system::command_output as output;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -69,12 +68,12 @@ impl Hardware {
|
||||
os: std::env::consts::OS.to_owned(),
|
||||
os_version: os_version(),
|
||||
arch: std::env::consts::ARCH.to_owned(),
|
||||
cpu: horizon_telemetry::system::cpu(),
|
||||
cpu: cpu(),
|
||||
cpu_threads: std::thread::available_parallelism().ok().map(|n| n.get()),
|
||||
cpu_cores: probes.cores,
|
||||
cpu_base_mhz: probes.base,
|
||||
cpu_boost_mhz: probes.boost,
|
||||
ram_mb: horizon_telemetry::system::ram_bytes().map(|b| b >> 20),
|
||||
ram_mb: ram_bytes().map(|b| b >> 20),
|
||||
storage: None,
|
||||
form: probes.form,
|
||||
gpu: None,
|
||||
@@ -110,16 +109,84 @@ fn probes() -> Probes {
|
||||
}
|
||||
}
|
||||
|
||||
/// The one-minute load average: above about the number of threads the machine was busy.
|
||||
pub fn load_average() -> Option<f64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
// "{ 1.23 1.10 1.00 }"
|
||||
output("sysctl", &["-n", "vm.loadavg"])?
|
||||
.trim_start_matches('{')
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
} else {
|
||||
std::fs::read_to_string("/proc/loadavg")
|
||||
.ok()?
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// `ac` or `battery`; `None` where it can't be read (and on machines without a battery on Linux).
|
||||
pub fn power_source() -> Option<&'static str> {
|
||||
horizon_telemetry::system::power()
|
||||
}
|
||||
|
||||
/// Resident memory of a process, in bytes (macOS, Linux).
|
||||
pub fn rss_bytes(pid: u32) -> Option<u64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
return output("ps", &["-o", "rss=", "-p", &pid.to_string()])?
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|kb| kb * 1024);
|
||||
}
|
||||
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
|
||||
let line = status.lines().find(|l| l.starts_with("VmRSS:"))?;
|
||||
line.split_whitespace()
|
||||
.nth(1)?
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|kb| kb * 1024)
|
||||
}
|
||||
|
||||
/// CPU time a process has used, user and system, in seconds (macOS, Linux).
|
||||
pub fn cpu_seconds(pid: u32) -> Option<f64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
// "[[dd-]hh:]mm:ss.cc"
|
||||
let time = output("ps", &["-o", "time=", "-p", &pid.to_string()])?;
|
||||
let (rest, days) = match time.split_once('-') {
|
||||
Some((d, rest)) => (rest.to_owned(), d.parse::<f64>().ok()?),
|
||||
None => (time, 0.0),
|
||||
};
|
||||
let seconds = rest.split(':').try_fold(0.0, |acc, part| {
|
||||
part.parse::<f64>().ok().map(|v| acc * 60.0 + v)
|
||||
})?;
|
||||
return Some(days * 86_400.0 + seconds);
|
||||
}
|
||||
// utime and stime, fields 14 and 15, in clock ticks (100 per second on Linux).
|
||||
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||
let fields: Vec<&str> = stat.rsplit_once(')')?.1.split_whitespace().collect();
|
||||
let ticks: f64 = fields.get(11)?.parse::<f64>().ok()? + fields.get(12)?.parse::<f64>().ok()?;
|
||||
Some(ticks / 100.0)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn os_version() -> Option<String> {
|
||||
output("sw_vers", &["-productVersion"])
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn cpu() -> Option<String> {
|
||||
output("sysctl", &["-n", "machdep.cpu.brand_string"])
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
output("sysctl", &["-n", "hw.memsize"])?.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn os_version() -> Option<String> {
|
||||
let release = std::fs::read_to_string("/etc/os-release").ok()?;
|
||||
@@ -132,6 +199,22 @@ fn os_version() -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn cpu() -> Option<String> {
|
||||
let info = std::fs::read_to_string("/proc/cpuinfo").ok()?;
|
||||
// x86 names the CPU per core; arm64 only gives the core's implementer and part.
|
||||
horizon_telemetry::system::linux_cpu_name(&info)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
let info = std::fs::read_to_string("/proc/meminfo").ok()?;
|
||||
let kb = key_value(&info, "MemTotal", ':')?
|
||||
.trim_end_matches("kB")
|
||||
.trim();
|
||||
kb.parse::<u64>().ok().map(|kb| kb * 1024)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn key_value<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
|
||||
text.lines().find_map(|line| {
|
||||
@@ -145,11 +228,39 @@ fn os_version() -> Option<String> {
|
||||
horizon_telemetry::system::windows_version()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn cpu() -> Option<String> {
|
||||
horizon_telemetry::system::windows_cpu()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
let out = output(
|
||||
"powershell",
|
||||
&[
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory",
|
||||
],
|
||||
)?;
|
||||
out.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn os_version() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn cpu() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -163,6 +274,9 @@ mod tests {
|
||||
assert!(hw.cpu.is_some(), "{hw:?}");
|
||||
assert!(hw.ram_mb.unwrap() > 256, "{hw:?}");
|
||||
assert!(hw.os_version.is_some(), "{hw:?}");
|
||||
assert!(load_average().is_some());
|
||||
assert!(rss_bytes(std::process::id()).unwrap() > 1 << 20);
|
||||
assert!(cpu_seconds(std::process::id()).unwrap() >= 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
mod hardware;
|
||||
|
||||
pub use hardware::{power_source, Display, Gpu, Hardware};
|
||||
/// The process probes, from the platform (`horizon-system`).
|
||||
pub use horizon_telemetry::system::{cpu_seconds, load_average, rss_bytes};
|
||||
pub use hardware::{cpu_seconds, load_average, power_source, rss_bytes, Display, Gpu, Hardware};
|
||||
|
||||
pub const FORMAT: &str = "horizon-benchmark-report";
|
||||
pub const VERSION: u32 = 1;
|
||||
|
||||
@@ -70,8 +70,6 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
other one 30 000 characters long), flood-book (100 pages of 1024 characters
|
||||
into a writable book), flood-sign (four 384-character lines on the block
|
||||
below), flood-recipe (recipe book placements)
|
||||
--ack-mode MODE chunk batch feedback: vanilla (default) or 64 (fixed chunks per tick);
|
||||
one ACK per completed batch; use explore for a controlled pacing test
|
||||
--flood N packets a tick for the flood behaviours; default 100
|
||||
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test),
|
||||
swinging after each attack as vanilla's client does. NAME,NAME...: each of them
|
||||
@@ -173,8 +171,6 @@ struct Options {
|
||||
/// --crits: fight bots alternate jumped critical hits and sweeps at a sword's cooldown.
|
||||
crits: bool,
|
||||
flood: usize,
|
||||
/// None uses vanilla estimation; --ack-mode 64 requests a fixed rate.
|
||||
ack_rate: Option<f32>,
|
||||
no_read: bool,
|
||||
no_loaded: bool,
|
||||
height: f64,
|
||||
@@ -213,7 +209,6 @@ fn options() -> Result<Options, String> {
|
||||
no_swing: false,
|
||||
crits: false,
|
||||
flood: 100,
|
||||
ack_rate: None,
|
||||
no_read: false,
|
||||
no_loaded: false,
|
||||
height: 24.0,
|
||||
@@ -271,13 +266,6 @@ fn options() -> Result<Options, String> {
|
||||
o.explore_speed = value.parse().map_err(|_| number("blocks a second"))?;
|
||||
}
|
||||
"--pvp" => o.pvp = value.parse().map_err(|_| number("true or false"))?,
|
||||
"--ack-mode" => {
|
||||
o.ack_rate = match value.as_str() {
|
||||
"vanilla" => None,
|
||||
"64" => Some(64.0),
|
||||
_ => return Err(format!("--ack-mode {value}: use vanilla or 64")),
|
||||
};
|
||||
}
|
||||
"--flood" => o.flood = value.parse().map_err(|_| number("packets a tick"))?,
|
||||
"--attack" => o.attack = Some(value),
|
||||
"--attack-after" => o.attack_after = value.parse().map_err(|_| number("seconds"))?,
|
||||
@@ -962,7 +950,6 @@ fn read(
|
||||
writer: Arc<Mutex<ConnWriter>>,
|
||||
bot: Arc<Bot>,
|
||||
track: bool,
|
||||
ack_rate: Option<f32>,
|
||||
mut capture: Option<Capture>,
|
||||
) {
|
||||
use ids::play::clientbound as cb;
|
||||
@@ -1048,8 +1035,7 @@ fn read(
|
||||
batch.finished(r.varint().unwrap_or(0));
|
||||
send(
|
||||
play::Serverbound::ChunkBatchReceived(play::ChunkBatchReceived {
|
||||
chunks_per_tick: ack_rate
|
||||
.unwrap_or_else(|| batch.desired_chunks_per_tick()),
|
||||
chunks_per_tick: batch.desired_chunks_per_tick(),
|
||||
})
|
||||
.encode(),
|
||||
)
|
||||
@@ -1153,8 +1139,7 @@ fn run_bot(
|
||||
let capture = (index == 0)
|
||||
.then(|| o.capture.as_deref().and_then(Capture::create))
|
||||
.flatten();
|
||||
let ack_rate = o.ack_rate;
|
||||
thread::spawn(move || read(reader, writer, bot, track, ack_rate, capture));
|
||||
thread::spawn(move || read(reader, writer, bot, track, capture));
|
||||
}
|
||||
use ids::play::serverbound as sb;
|
||||
// Wait for the spawn position.
|
||||
@@ -1534,7 +1519,6 @@ fn main() {
|
||||
"behaviour": o.behaviour,
|
||||
"seed": o.seed,
|
||||
"explore_speed": o.explore_speed,
|
||||
"ack_mode": o.ack_rate.map_or("vanilla", |_| "64"),
|
||||
"pvp": o.pvp,
|
||||
"view_distance": o.view_distance,
|
||||
"seconds": o.seconds,
|
||||
@@ -1600,83 +1584,6 @@ mod tests {
|
||||
AddEntity, Clientbound, MoveEntity, MoveEntityPos, RemoveEntities, SetEntityData, VecDelta,
|
||||
};
|
||||
|
||||
/// Exercise the production reader over framed connections. Pings are barriers
|
||||
/// so extra ACKs are observable without sleeps or timing guesses.
|
||||
fn check_chunk_acks(ack_rate: Option<f32>) {
|
||||
use horizon_protocol::connection::Pipe;
|
||||
let (client, server) = Pipe::pair();
|
||||
let (reader, writer) = Connection::local(client).split();
|
||||
let mut server = Connection::local(server);
|
||||
server
|
||||
.reader
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.unwrap();
|
||||
let bot = Arc::new(Bot::default());
|
||||
bot.online.store(true, Ordering::SeqCst);
|
||||
let handle = thread::spawn(move || {
|
||||
read(
|
||||
reader,
|
||||
Arc::new(Mutex::new(writer)),
|
||||
bot,
|
||||
false,
|
||||
ack_rate,
|
||||
None,
|
||||
)
|
||||
});
|
||||
for (nonce, size) in [0, 1, 5].into_iter().enumerate() {
|
||||
server
|
||||
.send(&raw(ids::play::clientbound::CHUNK_BATCH_START, |_| {}))
|
||||
.unwrap();
|
||||
server
|
||||
.send(&raw(ids::play::clientbound::PING, |w| w.i32(nonce as i32)))
|
||||
.unwrap();
|
||||
let (id, body) = server.recv().unwrap();
|
||||
assert_eq!(
|
||||
id,
|
||||
ids::play::serverbound::PONG,
|
||||
"batch start must not send an ACK"
|
||||
);
|
||||
assert_eq!(Reader::new(&body).i32().unwrap(), nonce as i32);
|
||||
server
|
||||
.send(&raw(ids::play::clientbound::CHUNK_BATCH_FINISHED, |w| {
|
||||
w.varint(size)
|
||||
}))
|
||||
.unwrap();
|
||||
server
|
||||
.send(&raw(ids::play::clientbound::PING, |w| w.i32(nonce as i32)))
|
||||
.unwrap();
|
||||
let (id, body) = server.recv().unwrap();
|
||||
assert_eq!(id, ids::play::serverbound::CHUNK_BATCH_RECEIVED);
|
||||
let rate = Reader::new(&body).f32().unwrap();
|
||||
if let Some(fixed) = ack_rate {
|
||||
assert_eq!(rate, fixed);
|
||||
} else if size == 0 {
|
||||
assert_eq!(rate, 3.5, "vanilla initial rate must remain the default");
|
||||
} else {
|
||||
assert!(rate.is_finite() && rate > 0.0);
|
||||
}
|
||||
let (id, body) = server.recv().unwrap();
|
||||
assert_eq!(
|
||||
id,
|
||||
ids::play::serverbound::PONG,
|
||||
"exactly one ACK per finished batch"
|
||||
);
|
||||
assert_eq!(Reader::new(&body).i32().unwrap(), nonce as i32);
|
||||
}
|
||||
server.writer.shutdown();
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_64_ack_is_sent_once_per_finished_batch() {
|
||||
check_chunk_acks(Some(64.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanilla_acks_remain_once_per_finished_batch() {
|
||||
check_chunk_acks(None);
|
||||
}
|
||||
|
||||
fn feed(entities: &mut Entities, packet: &Clientbound) {
|
||||
let bytes = packet.encode();
|
||||
let mut r = Reader::new(&bytes);
|
||||
|
||||
@@ -7,8 +7,6 @@ rust-version.workspace = true
|
||||
description = "The dedicated server's chat filter: its [chat_filter] settings, the slur list's matcher and the mutes, with no game state (docs/chat-filter.md)"
|
||||
|
||||
[dependencies]
|
||||
# The hook the hub asks (`chat_guard`), and the moderation durations.
|
||||
horizon-server-files = { path = "../horizon-server-files" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "1.1"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! The dedicated server's chat filter (docs/chat-filter.md), without the game: `server.toml`'s
|
||||
//! `[chat_filter]`, the matcher for the slur list (`data/slurs.txt`, or the owner's `words_file`), and
|
||||
//! the escalation (a refused message, then a mute, then a temporary ban), with the mutes kept in a file
|
||||
//! so that they outlive a restart. The server's hub (`horizon_client::lan`) asks it, through the engine's
|
||||
//! `horizon_server_files::chat_guard` hook, before it relays a player's text, and carries out what it decides.
|
||||
//! so that they outlive a restart. The server's hub (`horizon_client::lan`) asks it before it relays a
|
||||
//! player's text and carries out what it decides.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
@@ -10,11 +10,6 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
mod normalise;
|
||||
|
||||
use horizon_server_files::chat_guard::{self, ChatGuard, Loaded, Place};
|
||||
pub use horizon_server_files::chat_guard::{Action, Mute};
|
||||
pub use horizon_server_files::duration::{
|
||||
DurationUnit, duration_parts, duration_text, now_millis, parse_duration,
|
||||
};
|
||||
pub use normalise::{Run, Text};
|
||||
|
||||
/// The list built into the game, used when `words_file` is empty.
|
||||
@@ -223,59 +218,49 @@ fn fits(entry: &[Run], runs: &[Run], whole: bool) -> bool {
|
||||
.all(|(e, r)| e.letter == r.letter && r.count >= e.count)
|
||||
}
|
||||
|
||||
/// A mute as the mutes file keeps it.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
struct SavedMute {
|
||||
uuid: String,
|
||||
name: String,
|
||||
until: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
by: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
reason: Option<String>,
|
||||
/// What a hit leads to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// The text is refused, nothing more.
|
||||
Refused,
|
||||
/// Refused, and the player can't chat until then (epoch milliseconds).
|
||||
Muted { until: i64 },
|
||||
/// Refused, and the player is banned until then.
|
||||
Banned { until: i64 },
|
||||
}
|
||||
|
||||
impl From<SavedMute> for Mute {
|
||||
fn from(m: SavedMute) -> Self {
|
||||
let SavedMute {
|
||||
uuid,
|
||||
name,
|
||||
until,
|
||||
by,
|
||||
reason,
|
||||
} = m;
|
||||
Mute {
|
||||
uuid,
|
||||
name,
|
||||
until,
|
||||
by,
|
||||
reason,
|
||||
impl Action {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Action::Refused => "refused",
|
||||
Action::Muted { .. } => "muted",
|
||||
Action::Banned { .. } => "banned",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Mute> for SavedMute {
|
||||
fn from(m: Mute) -> Self {
|
||||
let Mute {
|
||||
uuid,
|
||||
name,
|
||||
until,
|
||||
by,
|
||||
reason,
|
||||
} = m;
|
||||
SavedMute {
|
||||
uuid,
|
||||
name,
|
||||
until,
|
||||
by,
|
||||
reason,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Mute {
|
||||
pub uuid: String,
|
||||
pub name: String,
|
||||
/// 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)]
|
||||
struct MutesFile {
|
||||
mutes: Vec<SavedMute>,
|
||||
mutes: Vec<Mute>,
|
||||
}
|
||||
|
||||
/// One player's hits: how many, and when the last one stops counting.
|
||||
@@ -305,7 +290,7 @@ impl Guard {
|
||||
{
|
||||
for m in saved.mutes {
|
||||
if let Some(uuid) = parse_uuid(&m.uuid) {
|
||||
mutes.insert(uuid, m.into());
|
||||
mutes.insert(uuid, m);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -443,7 +428,7 @@ impl Guard {
|
||||
};
|
||||
let now = now_millis();
|
||||
self.mutes.retain(|_, m| m.active(now));
|
||||
let mut mutes: Vec<SavedMute> = self.mutes.values().cloned().map(Into::into).collect();
|
||||
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 }) {
|
||||
let tmp = file.with_extension("json.tmp");
|
||||
@@ -454,94 +439,6 @@ impl Guard {
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatGuard for Guard {
|
||||
fn matches(&self, text: &str) -> bool {
|
||||
self.filter.matches(text)
|
||||
}
|
||||
|
||||
fn muted(&self, uuid: u128, now: i64) -> Option<&Mute> {
|
||||
Guard::muted(self, uuid, now)
|
||||
}
|
||||
|
||||
fn mutes(&self, now: i64) -> Vec<&Mute> {
|
||||
Guard::mutes(self, now)
|
||||
}
|
||||
|
||||
fn mute(
|
||||
&mut self,
|
||||
uuid: u128,
|
||||
name: &str,
|
||||
until: Option<i64>,
|
||||
by: &str,
|
||||
reason: Option<String>,
|
||||
) {
|
||||
Guard::mute(self, uuid, name, until, by, reason);
|
||||
}
|
||||
|
||||
fn strike(&mut self, uuid: u128, name: &str, now: i64) -> (u32, Action) {
|
||||
Guard::strike(self, uuid, name, now)
|
||||
}
|
||||
|
||||
fn unmute(&mut self, name: &str) -> Option<String> {
|
||||
Guard::unmute(self, name)
|
||||
}
|
||||
|
||||
fn log(
|
||||
&mut self,
|
||||
now: i64,
|
||||
name: &str,
|
||||
uuid: u128,
|
||||
place: Place,
|
||||
count: u32,
|
||||
action: Action,
|
||||
text: &str,
|
||||
) -> Result<(), String> {
|
||||
let path = &self.config.log;
|
||||
if path.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let line = log_line(now, name, uuid, place.name(), count, action, text);
|
||||
append_log(Path::new(path), &line).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// `server.toml`'s `[chat_filter]` for the dedicated server ([`chat_guard::install`] it at startup): on
|
||||
/// by default, off (with an error logged) when the section is wrong. The mutes are kept in `dir`.
|
||||
pub fn load(server_toml: &str, dir: &Path) -> Loaded {
|
||||
let mut loaded = Loaded::default();
|
||||
let config = match Config::from_server_toml(server_toml) {
|
||||
Ok(config) => config,
|
||||
Err(e) => {
|
||||
loaded.log.push((
|
||||
"ERROR",
|
||||
format!("server.toml: {e}; the chat filter stays off"),
|
||||
));
|
||||
return loaded;
|
||||
}
|
||||
};
|
||||
if !config.enabled {
|
||||
return loaded;
|
||||
}
|
||||
let (guard, problem) = Guard::load(config, dir);
|
||||
if let Some(problem) = problem {
|
||||
loaded.log.push(("WARN", problem));
|
||||
}
|
||||
loaded.log.push((
|
||||
"INFO",
|
||||
format!(
|
||||
"Chat filter on: {} words (server.toml [chat_filter]; enabled = false turns it off)",
|
||||
guard.filter.len()
|
||||
),
|
||||
));
|
||||
loaded.guard = Some(Box::new(guard));
|
||||
loaded
|
||||
}
|
||||
|
||||
/// Gives the dedicated server this chat filter.
|
||||
pub fn install() {
|
||||
chat_guard::install(load);
|
||||
}
|
||||
|
||||
/// One line of `logs/chat-filter.log`, without its newline.
|
||||
pub fn log_line(
|
||||
now: i64,
|
||||
@@ -577,6 +474,61 @@ 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()
|
||||
}
|
||||
|
||||
/// The unit used when presenting a moderation duration to a player.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DurationUnit {
|
||||
Minute,
|
||||
Hour,
|
||||
Day,
|
||||
}
|
||||
|
||||
/// A duration's rounded count and unit, shared by logs and translated replies.
|
||||
pub fn duration_parts(millis: i64) -> (i64, DurationUnit) {
|
||||
let (unit, kind) = if millis <= 60 * MINUTE {
|
||||
(MINUTE, DurationUnit::Minute)
|
||||
} else if millis <= 48 * 60 * MINUTE {
|
||||
(60 * MINUTE, DurationUnit::Hour)
|
||||
} else {
|
||||
(24 * 60 * MINUTE, DurationUnit::Day)
|
||||
};
|
||||
(
|
||||
millis.div_euclid(unit) + i64::from(millis.rem_euclid(unit) > 0),
|
||||
kind,
|
||||
)
|
||||
}
|
||||
|
||||
/// `millis` for logs, rounded up: minutes up to an hour, hours up to two days, then days.
|
||||
pub fn duration_text(millis: i64) -> String {
|
||||
let (count, unit) = duration_parts(millis);
|
||||
let unit = match unit {
|
||||
DurationUnit::Minute => "minute",
|
||||
DurationUnit::Hour => "hour",
|
||||
DurationUnit::Day => "day",
|
||||
};
|
||||
format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
|
||||
}
|
||||
|
||||
pub fn now_millis() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_millis() as i64)
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DDTHH:MM:SSZ` for epoch milliseconds.
|
||||
pub fn utc(millis: i64) -> String {
|
||||
let secs = millis.div_euclid(1000);
|
||||
|
||||
@@ -51,14 +51,8 @@ fn float(tag: Option<&Tag>) -> Option<f32> {
|
||||
other => int(Some(other))? as f32,
|
||||
})
|
||||
}
|
||||
/// `TagValueInput.getBooleanOr`: a number is true when its byte value is not 0.
|
||||
fn flag(tag: Option<&Tag>) -> Option<bool> {
|
||||
Some(int(tag)? as i8 != 0)
|
||||
}
|
||||
/// `Codec.BOOL` through `NbtOps.getBooleanValue`: a number is true when it
|
||||
/// is not 0 as a whole (`doubleValue() != 0`), unlike `getBooleanOr`.
|
||||
fn codec_flag(tag: Option<&Tag>) -> Option<bool> {
|
||||
Some(float(tag)? != 0.0)
|
||||
Some(int(tag)? != 0)
|
||||
}
|
||||
fn boolean(v: bool) -> Tag {
|
||||
Tag::Byte(i8::from(v))
|
||||
@@ -277,31 +271,15 @@ fn int_array(tag: Option<&Tag>) -> Option<&Vec<i32>> {
|
||||
fn is_pos(tag: &Tag) -> bool {
|
||||
matches!(tag, Tag::IntArray(v) if v.len() == 3)
|
||||
}
|
||||
/// The default `SignText`: four empty lines, black, not glowing.
|
||||
fn empty_sign_side() -> Tag {
|
||||
Tag::compound([
|
||||
("messages", Tag::List(vec![string(""); 4])),
|
||||
("color", string("black")),
|
||||
("has_glowing_text", boolean(false)),
|
||||
])
|
||||
}
|
||||
/// The loaded `SignText` of one side, or the empty text.
|
||||
pub fn sign_side(tag: Option<&Tag>) -> Tag {
|
||||
// `SignText.DIRECT_CODEC`: `messages` is a component list (an entry that
|
||||
// fails is dropped, `ListCodec`'s partial result) cut to 4 lines by
|
||||
// `Util.fixedSize`. Fewer than 4 valid lines fail the side, and
|
||||
// `SignBlockEntity.loadAdditional` then loads an empty one; more keep the
|
||||
// first 4 with the side's colour and glow.
|
||||
let lines: Option<Vec<Tag>> = match tag.and_then(|t| t.get("messages")) {
|
||||
Some(Tag::List(list)) => {
|
||||
let lines: Vec<Tag> = list.iter().filter_map(component).take(4).collect();
|
||||
(lines.len() == 4).then_some(lines)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let Some(messages) = lines else {
|
||||
return empty_sign_side();
|
||||
let empty = || string("");
|
||||
let mut messages: Vec<Tag> = match tag.and_then(|t| t.get("messages")) {
|
||||
Some(Tag::List(list)) => list.iter().filter_map(component).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
messages.resize_with(4, empty);
|
||||
messages.truncate(4);
|
||||
let mut out = vec![
|
||||
("messages", Tag::List(messages)),
|
||||
(
|
||||
@@ -314,7 +292,7 @@ pub fn sign_side(tag: Option<&Tag>) -> Tag {
|
||||
(
|
||||
"has_glowing_text",
|
||||
boolean(
|
||||
tag.and_then(|t| codec_flag(t.get("has_glowing_text")))
|
||||
tag.and_then(|t| flag(t.get("has_glowing_text")))
|
||||
.unwrap_or(false),
|
||||
),
|
||||
),
|
||||
@@ -874,32 +852,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `getBooleanOr` tests a number's byte value: an int 256 is false and
|
||||
/// 257 true, for a sign's wax and a piston's flags alike.
|
||||
#[test]
|
||||
fn booleans_are_read_as_bytes() {
|
||||
let sign = |waxed: &str| {
|
||||
canon(&format!(
|
||||
"{{id:\"minecraft:sign\",x:1,y:2,z:3,is_waxed:{waxed}}}"
|
||||
))
|
||||
.get("is_waxed")
|
||||
.cloned()
|
||||
};
|
||||
assert_eq!(sign("1b"), Some(Tag::Byte(1)));
|
||||
assert_eq!(sign("256"), Some(Tag::Byte(0)));
|
||||
assert_eq!(sign("257"), Some(Tag::Byte(1)));
|
||||
// `SignText` reads `has_glowing_text` through `Codec.BOOL`, which tests
|
||||
// the whole number: 256 is true there.
|
||||
let glowing = canon(
|
||||
r#"{id:"minecraft:sign",x:1,y:2,z:3,front_text:{messages:["","","",""],has_glowing_text:256}}"#,
|
||||
);
|
||||
let front = glowing.get("front_text").unwrap();
|
||||
assert_eq!(front.get("has_glowing_text"), Some(&Tag::Byte(1)));
|
||||
let piston = canon(r#"{id:"minecraft:piston",x:1,y:2,z:3,extending:256,source:513}"#);
|
||||
assert_eq!(piston.get("extending"), Some(&Tag::Byte(0)));
|
||||
assert_eq!(piston.get("source"), Some(&Tag::Byte(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_type_has_defaults() {
|
||||
for id in TYPES.iter().chain(NEWEST.iter()) {
|
||||
@@ -1005,32 +957,6 @@ mod tests {
|
||||
assert_eq!(structure.get("showboundingbox"), Some(&Tag::Byte(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_side_needs_exactly_four_messages() {
|
||||
let ok = snbt("{messages:[\"a\",\"\",\"\",\"\"],color:\"red\",has_glowing_text:1b}");
|
||||
let side = sign_side(Some(&ok));
|
||||
assert_eq!(side.get("color"), Some(&string("red")));
|
||||
// A fifth line, or a bad entry among five, leaves the first 4 valid
|
||||
// lines with the colour (checked against the 26.3 codec).
|
||||
for kept in [
|
||||
"{messages:[\"a\",\"\",\"\",\"\",\"e\"],color:\"red\",has_glowing_text:1b}",
|
||||
"{messages:[\"a\",1,\"\",\"\",\"\"],color:\"red\",has_glowing_text:1b}",
|
||||
] {
|
||||
let side = sign_side(Some(&snbt(kept)));
|
||||
assert_eq!(side.get("messages"), ok.get("messages"), "{kept}");
|
||||
assert_eq!(side.get("color"), Some(&string("red")), "{kept}");
|
||||
}
|
||||
for bad in [
|
||||
"{color:\"red\",has_glowing_text:1b}",
|
||||
"{messages:[\"a\",\"b\"],color:\"red\",has_glowing_text:1b}",
|
||||
"{messages:[\"a\",\"\",\"\",1],color:\"red\",has_glowing_text:1b}",
|
||||
] {
|
||||
let side = sign_side(Some(&snbt(bad)));
|
||||
assert_eq!(side, sign_side(None), "{bad}");
|
||||
assert_eq!(side.get("color"), Some(&string("black")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_is_idempotent() {
|
||||
for id in TYPES.iter().chain(NEWEST.iter()) {
|
||||
|
||||
@@ -514,34 +514,6 @@ pub fn name_text(tag: &Tag) -> Text {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nested_fallback_references_stay_bounded() {
|
||||
// 300 references to the argument, nested three deep: 27 million copies of "A" from a
|
||||
// 4 KB component, which ran a client out of memory.
|
||||
let bomb = |depth: usize, text: &str| {
|
||||
let pattern = "%1$s".repeat(300);
|
||||
let mut v = serde_json::json!({ "text": text });
|
||||
for _ in 0..depth {
|
||||
v = serde_json::json!({"translate": "x.unknown", "fallback": pattern, "with": [v]});
|
||||
}
|
||||
v
|
||||
};
|
||||
let started = std::time::Instant::now();
|
||||
let text = to_text(&bomb(3, "A"), &mut |s| Text::literal(s));
|
||||
assert!(text.spans.len() < 1 << 15, "{} spans", text.spans.len());
|
||||
// One long argument referenced many times multiplies bytes, not spans.
|
||||
let long = "B".repeat(64 << 10);
|
||||
let text = to_text(&bomb(1, &long), &mut |s| Text::literal(s));
|
||||
let bytes: usize = text.spans.iter().map(|s| s.text.len()).sum();
|
||||
assert!(bytes <= 4 << 20, "{bytes} bytes");
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(5));
|
||||
// A real fallback still formats.
|
||||
let ok = serde_json::json!({
|
||||
"translate": "x.unknown", "fallback": "%1$s and %1$s", "with": [{"text": "A"}]
|
||||
});
|
||||
assert_eq!(to_text(&ok, &mut |s| Text::literal(s)).plain(), "A and A");
|
||||
}
|
||||
use crate::lang::{self, Language, ResourceSource};
|
||||
|
||||
struct Mem(std::collections::HashMap<String, Vec<u8>>);
|
||||
|
||||
@@ -74,70 +74,11 @@ pub trait ResourceSource {
|
||||
vec!["minecraft".into()]
|
||||
}
|
||||
}
|
||||
/// The bundled file a language reads, and whether it drops `ß`: a regional variant reads its
|
||||
/// language's file (Swiss German writes "ss" for "ß", as vanilla's de_ch does).
|
||||
fn bundled_code(code: &str) -> (&str, bool) {
|
||||
match code {
|
||||
"ko" => ("ko_kr", false),
|
||||
"hr" => ("hr_hr", false),
|
||||
"de_at" => ("de_de", false),
|
||||
"de_ch" => ("de_de", true),
|
||||
"fr_ca" | "fr_ch" => ("fr_fr", false),
|
||||
"es_ar" | "es_cl" | "es_ec" | "es_mx" | "es_uy" | "es_ve" => ("es_es", false),
|
||||
code => (code, false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lang files crates above the engine ship (`horizon_hooks::lang`), each a list of
|
||||
/// (code, JSON) pairs. The game's entry installs them once, before any language loads.
|
||||
static CRATE_FILES: std::sync::OnceLock<&'static [&'static [(&'static str, &'static str)]]> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
/// Installs the crates' lang files (`horizon_hooks::lang::engine::all()`). Only the first call
|
||||
/// counts.
|
||||
pub fn install_crate_files(files: &'static [&'static [(&'static str, &'static str)]]) {
|
||||
let _ = CRATE_FILES.set(files);
|
||||
}
|
||||
|
||||
/// The crates' entries for language `code` (through [`bundled_code`]'s variants) that the
|
||||
/// engine's own `horizon` file for it doesn't have: on a key both have, the engine wins.
|
||||
fn read_crate_files(files: &[&[(&str, &str)]], code: &str, map: &mut HashMap<String, String>) {
|
||||
if files.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (file, swiss) = bundled_code(code);
|
||||
let mut own = HashMap::new();
|
||||
if let Some(bytes) = Bundled.read(&format!("assets/horizon/lang/{code}.json")) {
|
||||
read_lang_file(&bytes, &mut own);
|
||||
}
|
||||
for (c, json) in files.iter().flat_map(|f| f.iter()) {
|
||||
if *c != file {
|
||||
continue;
|
||||
}
|
||||
let mut theirs = HashMap::new();
|
||||
if swiss {
|
||||
read_lang_file(json.replace('ß', "ss").as_bytes(), &mut theirs);
|
||||
} else {
|
||||
read_lang_file(json.as_bytes(), &mut theirs);
|
||||
}
|
||||
for (key, value) in theirs {
|
||||
if !own.contains_key(&key) {
|
||||
map.insert(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizon's own strings, compiled in so they need no prepared resources.
|
||||
struct Bundled;
|
||||
impl ResourceSource for Bundled {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
let code = path
|
||||
.strip_prefix("assets/horizon/lang/")?
|
||||
.strip_suffix(".json")?;
|
||||
let (file, swiss) = bundled_code(code);
|
||||
let path = format!("assets/horizon/lang/{file}.json");
|
||||
let body = match path.as_str() {
|
||||
let body = match path {
|
||||
"assets/horizon/lang/en_us.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/en_us.json")
|
||||
}
|
||||
@@ -162,10 +103,10 @@ impl ResourceSource for Bundled {
|
||||
"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_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_hr.json" | "assets/horizon/lang/hr.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/hr_hr.json")
|
||||
}
|
||||
"assets/horizon/lang/pt_pt.json" => {
|
||||
@@ -390,20 +331,30 @@ impl ResourceSource for Bundled {
|
||||
"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_de.json" | "assets/horizon/lang/de_at.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/de_de.json")
|
||||
}
|
||||
"assets/horizon/lang/fr_fr.json" => {
|
||||
// Swiss German writes "ss" for "ß", as vanilla's de_ch does.
|
||||
"assets/horizon/lang/de_ch.json" => {
|
||||
let de = include_str!("../../horizon-client/assets/horizon/lang/de_de.json");
|
||||
return Some(de.replace('ß', "ss").into_bytes());
|
||||
}
|
||||
"assets/horizon/lang/fr_fr.json"
|
||||
| "assets/horizon/lang/fr_ca.json"
|
||||
| "assets/horizon/lang/fr_ch.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/fr_fr.json")
|
||||
}
|
||||
"assets/horizon/lang/es_es.json" => {
|
||||
"assets/horizon/lang/es_es.json"
|
||||
| "assets/horizon/lang/es_ar.json"
|
||||
| "assets/horizon/lang/es_cl.json"
|
||||
| "assets/horizon/lang/es_ec.json"
|
||||
| "assets/horizon/lang/es_mx.json"
|
||||
| "assets/horizon/lang/es_uy.json"
|
||||
| "assets/horizon/lang/es_ve.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/es_es.json")
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
if swiss {
|
||||
return Some(body.replace('ß', "ss").into_bytes());
|
||||
}
|
||||
Some(body.as_bytes().to_vec())
|
||||
}
|
||||
fn namespaces(&self) -> Vec<String> {
|
||||
@@ -487,16 +438,6 @@ impl Language {
|
||||
/// from every namespace and every source in priority order (later sources
|
||||
/// override).
|
||||
pub fn load(sources: &[&dyn ResourceSource], code: &str) -> Self {
|
||||
let crate_files = CRATE_FILES.get().copied().unwrap_or_default();
|
||||
Self::load_with(sources, code, crate_files)
|
||||
}
|
||||
|
||||
/// [`Language::load`] with these crates' lang files.
|
||||
fn load_with(
|
||||
sources: &[&dyn ResourceSource],
|
||||
code: &str,
|
||||
crate_files: &[&[(&str, &str)]],
|
||||
) -> Self {
|
||||
let mut map = HashMap::new();
|
||||
let mut codes = vec![DEFAULT];
|
||||
if code != DEFAULT {
|
||||
@@ -512,14 +453,12 @@ impl Language {
|
||||
sources.iter().flat_map(|s| s.namespaces()).collect();
|
||||
for c in codes {
|
||||
for namespace in &namespaces {
|
||||
for (i, source) in sources.iter().enumerate() {
|
||||
if let Some(bytes) = source.read(&format!("assets/{namespace}/lang/{c}.json")) {
|
||||
read_lang_file(&bytes, &mut map);
|
||||
}
|
||||
// The crates' text right after the engine's own (`Bundled`, the first source).
|
||||
if i == 0 && namespace == "horizon" {
|
||||
read_crate_files(crate_files, c, &mut map);
|
||||
}
|
||||
for source in sources {
|
||||
let Some(bytes) = source.read(&format!("assets/{namespace}/lang/{c}.json"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
read_lang_file(&bytes, &mut map);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -749,43 +688,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crates_text_loads_after_the_engines_and_before_packs() {
|
||||
static CRATE: &[(&str, &str)] = &[
|
||||
(
|
||||
"en_us",
|
||||
r#"{"test.crate.hello": "Hello", "horizon.osk.close": "Shut", "test.crate.packed": "Crate"}"#,
|
||||
),
|
||||
(
|
||||
"de_de",
|
||||
r#"{"test.crate.hello": "Grüß dich", "horizon.osk.close": "Zu"}"#,
|
||||
),
|
||||
("fr_fr", r#"{"test.crate.hello": "Bonjour"}"#),
|
||||
];
|
||||
let pack = Mem(HashMap::from([(
|
||||
"assets/horizon/lang/en_us.json".to_owned(),
|
||||
br#"{"test.crate.packed": "Pack"}"#.to_vec(),
|
||||
)]));
|
||||
let load = |code| Language::load_with(&[&pack], code, &[CRATE]);
|
||||
let en = load("en_us");
|
||||
assert_eq!(en.get("test.crate.hello"), Some("Hello"));
|
||||
// The engine's own file wins on a key both have; a pack wins over both.
|
||||
assert_eq!(en.get("horizon.osk.close"), Some("Close"));
|
||||
assert_eq!(en.get("test.crate.packed"), Some("Pack"));
|
||||
let de = load("de_de");
|
||||
assert_eq!(de.get("test.crate.hello"), Some("Grüß dich"));
|
||||
assert_eq!(de.get("horizon.osk.close"), Some("Schließen"));
|
||||
// Regional variants read their language's file, as the engine's do.
|
||||
assert_eq!(load("de_ch").get("test.crate.hello"), Some("Grüss dich"));
|
||||
assert_eq!(load("fr_ca").get("test.crate.hello"), Some("Bonjour"));
|
||||
// A language the crate lacks falls back to its English.
|
||||
assert_eq!(load("pl_pl").get("test.crate.hello"), Some("Hello"));
|
||||
// Without crate files, nothing changes.
|
||||
let plain = Language::load_with(&[&pack], "de_de", &[]);
|
||||
assert_eq!(plain.get("test.crate.hello"), None);
|
||||
assert_eq!(plain.len() + 1, de.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scoped_language_is_the_calling_threads_and_reports_what_it_lacks() {
|
||||
let empty = Mem(HashMap::new());
|
||||
@@ -837,20 +739,6 @@ mod tests {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../horizon-client/assets/horizon/lang")
|
||||
}
|
||||
/// The crates whose own lang files the game loads (`crates/<name>/assets/lang`,
|
||||
/// registered through `horizon_hooks::lang`), with that folder.
|
||||
fn crate_lang_dirs() -> Vec<(String, std::path::PathBuf)> {
|
||||
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let mut dirs: Vec<_> = std::fs::read_dir(&crates)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| (e.file_name().into_string().unwrap(), e.path()))
|
||||
.map(|(name, path)| (name, path.join("assets/lang")))
|
||||
.filter(|(_, dir)| dir.join("en_us.json").is_file())
|
||||
.collect();
|
||||
dirs.sort();
|
||||
dirs
|
||||
}
|
||||
/// The keys of a flat bundled file in file order, duplicates included (a map
|
||||
/// would keep only the last of two equal keys). One `"key": "value"` per line.
|
||||
fn file_keys(body: &str) -> Vec<String> {
|
||||
@@ -888,33 +776,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn bundled_files_are_served_and_have_no_stale_or_repeated_keys() {
|
||||
let engine = check_lang_dir(&bundled_dir(), true);
|
||||
assert!(
|
||||
engine.1 >= 7,
|
||||
"found {} bundled lang files in {}",
|
||||
engine.1,
|
||||
bundled_dir().display()
|
||||
);
|
||||
// A crate's files pass the same checks. Its keys are its own: on a key the engine's
|
||||
// files also have, the engine's text would win and hide the crate's.
|
||||
for (name, dir) in crate_lang_dirs() {
|
||||
let (en, _) = check_lang_dir(&dir, false);
|
||||
for key in en.keys() {
|
||||
assert!(
|
||||
!engine.0.contains_key(key),
|
||||
"{name}: {key} is also the engine's"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The checks every lang folder passes: flat maps, no repeated key, no key en_us lacks, the
|
||||
/// same placeholders as English, no empty text. Its en_us and how many files it has.
|
||||
fn check_lang_dir(dir: &std::path::Path, bundled: bool) -> (HashMap<String, String>, usize) {
|
||||
let dir = bundled_dir();
|
||||
let en_body = std::fs::read_to_string(dir.join("en_us.json")).unwrap();
|
||||
let en: HashMap<String, String> = serde_json::from_str(&en_body).unwrap();
|
||||
let mut seen = 0;
|
||||
for entry in std::fs::read_dir(dir).unwrap().flatten() {
|
||||
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
|
||||
let name = entry.file_name().into_string().unwrap();
|
||||
let Some(code) = name.strip_suffix(".json") else {
|
||||
continue;
|
||||
@@ -923,7 +789,7 @@ mod tests {
|
||||
// A file that Bundled::read does not name is never loaded.
|
||||
let path = format!("assets/horizon/lang/{name}");
|
||||
assert!(
|
||||
!bundled || Bundled.read(&path).is_some(),
|
||||
Bundled.read(&path).is_some(),
|
||||
"{name} is not served by Bundled"
|
||||
);
|
||||
let body = std::fs::read_to_string(entry.path()).unwrap();
|
||||
@@ -949,7 +815,11 @@ mod tests {
|
||||
assert!(!value.trim().is_empty(), "{code}: {key} is empty");
|
||||
}
|
||||
}
|
||||
(en, seen)
|
||||
assert!(
|
||||
seen >= 7,
|
||||
"found {seen} bundled lang files in {}",
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -964,10 +834,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
let english = |dir: &std::path::Path| -> HashMap<String, String> {
|
||||
serde_json::from_str(&std::fs::read_to_string(dir.join("en_us.json")).unwrap()).unwrap()
|
||||
};
|
||||
let mut en = english(&bundled_dir());
|
||||
let en: HashMap<String, String> = serde_json::from_str(
|
||||
&std::fs::read_to_string(bundled_dir().join("en_us.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let mut files = Vec::new();
|
||||
for krate in [
|
||||
@@ -977,11 +847,6 @@ mod tests {
|
||||
] {
|
||||
rust_files(&crates.join(krate).join("src"), &mut files);
|
||||
}
|
||||
// A crate that ships lang files names its keys in its own sources.
|
||||
for (name, dir) in crate_lang_dirs() {
|
||||
en.extend(english(&dir));
|
||||
rust_files(&crates.join(name).join("src"), &mut files);
|
||||
}
|
||||
assert!(files.len() > 100, "found only {} sources", files.len());
|
||||
let mut found = 0;
|
||||
for file in files {
|
||||
|
||||
@@ -529,13 +529,10 @@ impl Team {
|
||||
None => t,
|
||||
}
|
||||
}
|
||||
/// `PlayerTeam.getFormattedDisplayName`: the display name in the team's
|
||||
/// `displayNameStyle`, which its constructor makes the team's name on hover
|
||||
/// (`ShowText`) and as insertion.
|
||||
/// `PlayerTeam.getFormattedDisplayName`.
|
||||
pub fn formatted_display_name(&self) -> Text {
|
||||
let inner = hover_name(comp_text(&self.display_name), &self.name).with_style(&Style {
|
||||
click: None,
|
||||
insertion: Some(self.name.clone()),
|
||||
..Style::default()
|
||||
});
|
||||
self.apply_color(bracketed(inner))
|
||||
@@ -1414,21 +1411,6 @@ fn finish(node: &mut serde_json::Map<String, Value>, extra: Vec<Comp>) -> Comp {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// #14279: `PlayerTeam`'s `displayNameStyle` puts the team's name on hover
|
||||
/// and as insertion; `getFormattedDisplayName` brackets the name in it.
|
||||
#[test]
|
||||
fn a_teams_formatted_name_inserts_the_team_name() {
|
||||
let team = Team::new("reds");
|
||||
let c = team.formatted_display_name().component();
|
||||
assert_eq!(c["translate"], "chat.square_brackets");
|
||||
let inner = &c["with"][0];
|
||||
assert_eq!(inner["text"], "reds");
|
||||
assert_eq!(inner["insertion"], "reds");
|
||||
assert_eq!(inner["hover_event"]["action"], "show_text");
|
||||
// `{"text":"reds"}` or its short form `"reds"`: the same component.
|
||||
let value = &inner["hover_event"]["value"];
|
||||
assert!(value == "reds" || value["text"] == "reds", "{value}");
|
||||
}
|
||||
|
||||
fn board() -> Scoreboard {
|
||||
let mut sb = Scoreboard::default();
|
||||
|
||||
@@ -320,12 +320,7 @@ pub fn java_hash_order(
|
||||
"FallFlying",
|
||||
"sleeping_pos",
|
||||
"Brain",
|
||||
"last_hurt_by_player",
|
||||
"last_hurt_by_player_memory_time",
|
||||
"last_hurt_by_mob",
|
||||
"ticks_since_last_hurt_by_mob",
|
||||
"equipment",
|
||||
"locator_bar_icon",
|
||||
// ArmorStand.
|
||||
"Invisible",
|
||||
"Small",
|
||||
@@ -342,12 +337,6 @@ pub fn java_hash_order(
|
||||
"immovable",
|
||||
"description",
|
||||
"hide_description",
|
||||
// Interaction.addAdditionalSaveData.
|
||||
"width",
|
||||
"height",
|
||||
"attack",
|
||||
"interaction",
|
||||
"response",
|
||||
];
|
||||
// BlockEntity.saveMetadata writes the position after the entity's data.
|
||||
const AFTER: &[&str] = &["x", "y", "z"];
|
||||
@@ -447,20 +436,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn interaction_compound_orders_width_before_interaction_in_shared_bucket() {
|
||||
// `width` and `interaction` share a bucket at every table size;
|
||||
// `Interaction.addAdditionalSaveData` writes `width` first.
|
||||
let tag = parse_snbt(
|
||||
r#"{Air:300s,Fire:0s,Invulnerable:0b,Motion:[0.0d,0.0d,0.0d],OnGround:0b,PortalCooldown:0,Pos:[4.5d,100.0d,4.5d],Rotation:[0.0f,0.0f],UUID:[I;66,0,0,1],fall_distance:0.0d,height:1.25f,interaction:{player:[I;1,2,3,4],timestamp:9L},response:0b,width:1.0f}"#,
|
||||
)
|
||||
.unwrap()
|
||||
.0;
|
||||
assert_eq!(
|
||||
pretty(&tag).plain(),
|
||||
"{Motion: [0.0d, 0.0d, 0.0d], Invulnerable: 0b, fall_distance: 0.0d, Air: 300s, OnGround: 0b, PortalCooldown: 0, Rotation: [0.0f, 0.0f], Pos: [4.5d, 100.0d, 4.5d], response: 0b, Fire: 0s, width: 1.0f, interaction: {player: [I; 1, 2, 3, 4], timestamp: 9L}, UUID: [I; 66, 0, 0, 1], height: 1.25f}",
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn compact_form_matches_vanilla() {
|
||||
assert_eq!(
|
||||
rt("{b:1b,a:\"x\",c:[1,2],d:1.5f,e:2d,f:3L,g:4s}"),
|
||||
|
||||
@@ -282,20 +282,17 @@ impl Text {
|
||||
pub fn push(&mut self, other: impl Into<Text>) {
|
||||
let other = other.into();
|
||||
let sibling = other.component();
|
||||
// The tree is appended to in place: cloning all of it on every append made a component
|
||||
// with N siblings (a server's chat or MOTD "extra") cost N^2.
|
||||
let root = match self.source.take() {
|
||||
None if self.spans.is_empty() => std::sync::Arc::new(json!({ "text": "" })),
|
||||
None => std::sync::Arc::new(self.component()),
|
||||
Some(c) => c,
|
||||
};
|
||||
let mut root = if STYLE_KEYS.iter().any(|k| root.get(*k).is_some()) {
|
||||
let c = std::sync::Arc::try_unwrap(root).unwrap_or_else(|c| (*c).clone());
|
||||
std::sync::Arc::new(json!({ "text": "", "extra": [c] }))
|
||||
let mut root = if self.spans.is_empty() && self.source.is_none() {
|
||||
json!({ "text": "" })
|
||||
} else {
|
||||
root
|
||||
let c = self.component();
|
||||
if STYLE_KEYS.iter().any(|k| c.get(*k).is_some()) {
|
||||
json!({ "text": "", "extra": [c] })
|
||||
} else {
|
||||
c
|
||||
}
|
||||
};
|
||||
if let Value::Object(o) = std::sync::Arc::make_mut(&mut root) {
|
||||
if let Value::Object(o) = &mut root {
|
||||
match o.get_mut("extra") {
|
||||
Some(Value::Array(a)) => a.push(sibling),
|
||||
_ => {
|
||||
@@ -303,7 +300,7 @@ impl Text {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.source = Some(root);
|
||||
self.source = Some(root.into());
|
||||
self.spans.extend(other.spans);
|
||||
}
|
||||
/// Apply `style` underneath every span (MutableComponent.withStyle on a parent).
|
||||
@@ -674,22 +671,11 @@ pub fn legacy_format(style: &Style, code: char, reset: &Style) -> Style {
|
||||
s
|
||||
}
|
||||
|
||||
/// The most spans one translation expands to. Vanilla's `TranslatableContents.decompose` keeps
|
||||
/// references to its arguments, so its cost stays linear in the input; copying them here makes
|
||||
/// nested `%1$s` templates multiply (`k` references nested `D` deep: `k^D` copies), and a server's
|
||||
/// `fallback` template is any text it likes.
|
||||
const MAX_TRANSLATION_SPANS: usize = 1 << 15;
|
||||
/// The most text one translation expands to: one long argument referenced many times multiplies
|
||||
/// bytes, not spans.
|
||||
const MAX_TRANSLATION_BYTES: usize = 1 << 22;
|
||||
|
||||
/// TranslatableContents.decomposeTemplate: `%s` takes the next argument,
|
||||
/// `%n$s` a numbered one and `%%` is a percent sign. Malformed templates are
|
||||
/// shown verbatim, and so is one that would expand past [`MAX_TRANSLATION_SPANS`] or
|
||||
/// [`MAX_TRANSLATION_BYTES`].
|
||||
/// shown verbatim.
|
||||
pub fn format_translation(template: &str, args: &[Text]) -> Text {
|
||||
let mut out = Text::default();
|
||||
let mut bytes = 0usize;
|
||||
let mut literal = String::new();
|
||||
let mut next = 0usize;
|
||||
let chars: Vec<char> = template.chars().collect();
|
||||
@@ -739,14 +725,6 @@ pub fn format_translation(template: &str, args: &[Text]) -> Text {
|
||||
let Some(arg) = args.get(index) else {
|
||||
return Text::literal(template);
|
||||
};
|
||||
bytes = bytes
|
||||
.saturating_add(literal.len())
|
||||
.saturating_add(arg.spans.iter().map(|s| s.text.len()).sum());
|
||||
if out.spans.len().saturating_add(arg.spans.len()) >= MAX_TRANSLATION_SPANS
|
||||
|| bytes > MAX_TRANSLATION_BYTES
|
||||
{
|
||||
return Text::literal(template);
|
||||
}
|
||||
if !literal.is_empty() {
|
||||
out.spans.push(Span {
|
||||
text: std::mem::take(&mut literal),
|
||||
@@ -772,42 +750,6 @@ pub fn format_translation(template: &str, args: &[Text]) -> Text {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn appending_builds_the_same_tree_in_linear_time() {
|
||||
// A styled text is wrapped once, so its style doesn't reach what follows; a plain one
|
||||
// takes the siblings itself; a copy taken before an append keeps its own tree.
|
||||
let mut styled = Text::literal("a").with_style(&Style {
|
||||
bold: Some(true),
|
||||
..Style::default()
|
||||
});
|
||||
styled.push(Text::literal("b"));
|
||||
let before = styled.clone();
|
||||
styled.push(Text::literal("c"));
|
||||
assert_eq!(
|
||||
styled.component(),
|
||||
json!({"text": "", "extra": [{"text": "a", "bold": true}, {"text": "b"}, {"text": "c"}]})
|
||||
);
|
||||
assert_eq!(
|
||||
before.component(),
|
||||
json!({"text": "", "extra": [{"text": "a", "bold": true}, {"text": "b"}]})
|
||||
);
|
||||
let mut plain = Text::default();
|
||||
plain.push(Text::literal("x"));
|
||||
plain.push(Text::literal("y"));
|
||||
assert_eq!(
|
||||
plain.component(),
|
||||
json!({"text": "", "extra": [{"text": "x"}, {"text": "y"}]})
|
||||
);
|
||||
// 20 000 siblings, as a server's chat may carry: 76 s before, as each append cloned the
|
||||
// whole tree.
|
||||
let started = std::time::Instant::now();
|
||||
let mut long = Text::default();
|
||||
for _ in 0..20_000 {
|
||||
long.push(Text::literal("a"));
|
||||
}
|
||||
assert_eq!(long.spans.len(), 20_000);
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(5));
|
||||
}
|
||||
#[test]
|
||||
fn click_events_serialize_and_inherit_as_one_style_field() {
|
||||
for (event, json) in [
|
||||
(
|
||||
|
||||
@@ -9,13 +9,11 @@ description = "The client's input layer: key bindings and their names, mouse, co
|
||||
[dependencies]
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
winit = "0.30.13"
|
||||
# The window icon.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
png = "0.18"
|
||||
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# The window icon test decodes Horizon's icon.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
|
||||
[features]
|
||||
controllers = []
|
||||
|
||||
|
||||
@@ -98,7 +98,6 @@ actions! {
|
||||
Emote, "key.emote", Some(KeyCode::KeyB);
|
||||
HotbarPrevious, "pad.hotbarPrevious", None;
|
||||
HotbarNext, "pad.hotbarNext", None;
|
||||
Radial, "pad.radial", None;
|
||||
}
|
||||
|
||||
impl Action {
|
||||
|
||||
@@ -253,17 +253,6 @@ impl Activity {
|
||||
/// Horizon's pad-only actions (no vanilla key mapping).
|
||||
pub const HOTBAR_PREVIOUS: &str = "pad.hotbarPrevious";
|
||||
pub const HOTBAR_NEXT: &str = "pad.hotbarNext";
|
||||
/// Held, opens the radial menu (`radial.rs`).
|
||||
pub const RADIAL: &str = "pad.radial";
|
||||
|
||||
mod radial_file;
|
||||
pub use radial_file::{RadialMode, RadialStick, opener_refused};
|
||||
|
||||
/// The `bindLayout` the settings file is written in. 1: the defaults of the binds list as they
|
||||
/// are; 2: the radial menu's opener resolved against the player's own buttons.
|
||||
const BIND_LAYOUT: u32 = 2;
|
||||
/// The layout from which the file, not the default, says where the opener is.
|
||||
const OPENER_LAYOUT: u32 = 2;
|
||||
|
||||
/// Bindable actions and their default buttons: vanilla key mappings (the
|
||||
/// game acts as their default key) and Horizon's pad-only actions. Start is
|
||||
@@ -282,7 +271,6 @@ pub const ACTIONS: &[(&str, Option<Button>)] = &[
|
||||
("key.togglePerspective", Some(Button::Select)),
|
||||
("key.pickItem", Some(Button::DPadLeft)),
|
||||
("key.chat", Some(Button::DPadUp)),
|
||||
(RADIAL, Some(Button::DPadRight)),
|
||||
("key.advancements", None),
|
||||
("key.playerlist", None),
|
||||
("key.command", None),
|
||||
@@ -315,14 +303,11 @@ pub enum Route {
|
||||
HotbarStep {
|
||||
next: bool,
|
||||
},
|
||||
Radial,
|
||||
/// Held, or flipped when the pad's toggle sneak is on.
|
||||
Sneak,
|
||||
Screenshot,
|
||||
SmoothCamera,
|
||||
Fullscreen,
|
||||
/// F3's debug screen, shown or hidden as F3 alone does.
|
||||
DebugOverlay,
|
||||
/// Does nothing by itself: what reads it asks whether the button is held
|
||||
/// (`Minecraft.handleKeybinds`' toolbar keys, held with a hotbar key).
|
||||
Held,
|
||||
@@ -335,12 +320,10 @@ pub fn route(action: &str) -> Option<Route> {
|
||||
match action {
|
||||
HOTBAR_PREVIOUS => Some(Route::HotbarStep { next: false }),
|
||||
HOTBAR_NEXT => Some(Route::HotbarStep { next: true }),
|
||||
RADIAL => Some(Route::Radial),
|
||||
"key.sneak" => Some(Route::Sneak),
|
||||
"key.screenshot" => Some(Route::Screenshot),
|
||||
"key.smoothCamera" => Some(Route::SmoothCamera),
|
||||
"key.fullscreen" => Some(Route::Fullscreen),
|
||||
"key.debug.overlay" => Some(Route::DebugOverlay),
|
||||
"key.saveToolbarActivator" | "key.loadToolbarActivator" => Some(Route::Held),
|
||||
_ => {
|
||||
let default = crate::keybinds::KeyBinds::default_of(action);
|
||||
@@ -351,16 +334,12 @@ pub fn route(action: &str) -> Option<Route> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The radial menu's opener, as the binds list and the gameplay guide name it.
|
||||
pub const RADIAL_CAPTION: &str = "horizon.pad.action.radial";
|
||||
|
||||
/// An action's name in the binds list: vanilla's translation key, or
|
||||
/// Horizon's own English for the pad-only actions.
|
||||
pub fn action_caption(action: &str) -> &str {
|
||||
match action {
|
||||
HOTBAR_PREVIOUS => "Hotbar: Previous Slot",
|
||||
HOTBAR_NEXT => "Hotbar: Next Slot",
|
||||
RADIAL => RADIAL_CAPTION,
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
@@ -675,21 +654,6 @@ pub struct PadSettings {
|
||||
pub repeat_interval: f32,
|
||||
/// Action name -> button, `None` for unbound; missing means the default.
|
||||
bindings: BTreeMap<&'static str, Option<Button>>,
|
||||
/// The radial menu's pages.
|
||||
pub radial: crate::radial::Pages,
|
||||
/// Whether the radial menu's opener is held or pressed once.
|
||||
pub radial_mode: RadialMode,
|
||||
/// The stick that selects in it.
|
||||
pub radial_stick: RadialStick,
|
||||
/// Lines this game doesn't read (a newer Horizon's settings, an action it doesn't have, a
|
||||
/// button it doesn't know), kept as they were so a save here loses nothing.
|
||||
extra: Vec<(String, String)>,
|
||||
/// The newest `bindLayout` the file has been written in: this game's, or a newer Horizon's,
|
||||
/// kept so that one doesn't resolve its file again as older than it is.
|
||||
bind_layout: u32,
|
||||
/// The opener last used with each stick (`RadialStick::ALL` order), so stepping the Selects
|
||||
/// With row and back gives each stick's own opener again.
|
||||
openers: radial_file::StickOpeners,
|
||||
}
|
||||
impl Default for PadSettings {
|
||||
fn default() -> Self {
|
||||
@@ -716,12 +680,6 @@ impl Default for PadSettings {
|
||||
repeat_delay: 0.35,
|
||||
repeat_interval: 0.09,
|
||||
bindings: BTreeMap::new(),
|
||||
radial: Default::default(),
|
||||
radial_mode: RadialMode::Hold,
|
||||
radial_stick: RadialStick::Right,
|
||||
extra: Vec::new(),
|
||||
bind_layout: BIND_LAYOUT,
|
||||
openers: radial_file::StickOpeners::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -748,7 +706,6 @@ impl PadSettings {
|
||||
}
|
||||
pub fn parse(text: &str) -> Self {
|
||||
let mut s = Self::default();
|
||||
let mut saved_opener = None;
|
||||
for line in text.lines() {
|
||||
let Some((key, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
@@ -797,85 +754,22 @@ impl PadSettings {
|
||||
"scrollSpeed" => s.scroll_speed = bounded(s.scroll_speed, 1.0, 36.0),
|
||||
"repeatDelay" => s.repeat_delay = bounded(s.repeat_delay, 0.1, 1.0),
|
||||
"repeatInterval" => s.repeat_interval = bounded(s.repeat_interval, 0.03, 0.3),
|
||||
"radialMode" => s.radial_mode = RadialMode::parse(value),
|
||||
"radialStick" => s.radial_stick = RadialStick::parse(value),
|
||||
// Read after the loop, from the whole text.
|
||||
"bindLayout" => {}
|
||||
// The radial menu's pages (`radial::Pages::parse`).
|
||||
other if other.starts_with("radial_") || other == crate::radial::LAYOUT_KEY => {}
|
||||
other => {
|
||||
let known = other
|
||||
.strip_prefix("bind_")
|
||||
.filter(|action| ACTIONS.iter().any(|a| a.0 == *action));
|
||||
let button = if value == "none" {
|
||||
Some(None)
|
||||
} else {
|
||||
Button::parse(value).map(Some)
|
||||
};
|
||||
match (known, button) {
|
||||
// The opener once the selecting stick is known.
|
||||
(Some(RADIAL), Some(button)) => saved_opener = Some(button),
|
||||
(Some(action), Some(button)) => s.bind(action, button),
|
||||
_ => s.extra.push((other.to_owned(), value.to_owned())),
|
||||
if let Some(action) = other.strip_prefix("bind_") {
|
||||
let button = if value == "none" {
|
||||
Some(None)
|
||||
} else {
|
||||
Button::parse(value).map(Some)
|
||||
};
|
||||
if let Some(button) = button {
|
||||
s.bind(action, button);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// An opener on a button it can't be with this stick reads as never saved.
|
||||
let refused_opener = match saved_opener {
|
||||
Some(Some(b)) if opener_refused(s.radial_stick, b) => true,
|
||||
Some(button) => {
|
||||
s.bind(RADIAL, button);
|
||||
false
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
if let Some(pages) = crate::radial::Pages::parse(
|
||||
text.lines()
|
||||
.filter_map(|line| line.split_once(':'))
|
||||
.map(|(key, value)| (key.trim(), value.trim())),
|
||||
) {
|
||||
s.radial = pages;
|
||||
}
|
||||
let lines = || text.lines().map(str::trim);
|
||||
let saved = |action: &str| {
|
||||
let prefix = format!("bind_{action}:");
|
||||
lines().any(|line| line.starts_with(&prefix))
|
||||
};
|
||||
// The layout the file was written in; none in a file from before these defaults.
|
||||
let layout = lines()
|
||||
.find_map(|line| line.strip_prefix("bindLayout:"))
|
||||
.map(|value| value.trim().parse::<u32>().unwrap_or(0));
|
||||
s.bind_layout = layout.map_or(BIND_LAYOUT, |layout| layout.max(BIND_LAYOUT));
|
||||
// Once, for a file from before the radial menu's opener, or one with the opener on a
|
||||
// button it can no longer be: a button the player bound by hand stays theirs, and the
|
||||
// opener's default doesn't take it. From then on the file says where the opener is, and
|
||||
// a conflict made since stays shown in the binds list.
|
||||
let before_opener = layout.is_none_or(|layout| layout < OPENER_LAYOUT);
|
||||
if !text.is_empty() && (refused_opener || (before_opener && !saved(RADIAL))) {
|
||||
let default_opener = ACTIONS
|
||||
.iter()
|
||||
.find(|(action, _)| *action == RADIAL)
|
||||
.and_then(|(_, button)| *button);
|
||||
let taken = s.bindings.iter().any(|(action, button)| {
|
||||
*action != RADIAL && button.is_some() && *button == default_opener
|
||||
});
|
||||
if taken {
|
||||
s.bindings.insert(RADIAL, None);
|
||||
}
|
||||
}
|
||||
// The default too can be under the selecting thumb (D-pad Right, the left stick
|
||||
// selecting): no button opens the menu until the player picks one.
|
||||
if s.binding(RADIAL)
|
||||
.is_some_and(|b| opener_refused(s.radial_stick, b))
|
||||
{
|
||||
s.bindings.insert(RADIAL, None);
|
||||
}
|
||||
// A file from before these defaults: its player's buttons stay where they were, and
|
||||
// nothing they saved shares a button with the radial menu's opener. They can bind one
|
||||
// in the binds list.
|
||||
if !text.is_empty() && layout.is_none() {
|
||||
s.bindings.entry(RADIAL).or_insert(None);
|
||||
// A file from before these defaults: its player's buttons stay where they were.
|
||||
if !text.is_empty() && !text.lines().any(|line| line.starts_with("bindLayout:")) {
|
||||
for (action, button) in [
|
||||
("key.drop", Button::East),
|
||||
("key.togglePerspective", Button::DPadUp),
|
||||
@@ -884,7 +778,10 @@ impl PadSettings {
|
||||
("key.advancements", Button::DPadDown),
|
||||
] {
|
||||
// A button saved by hand stays, even one that's now this action's default.
|
||||
if !saved(action) {
|
||||
if !text
|
||||
.lines()
|
||||
.any(|line| line.starts_with(&format!("bind_{action}:")))
|
||||
{
|
||||
s.bindings.entry(action).or_insert(Some(button));
|
||||
}
|
||||
}
|
||||
@@ -925,25 +822,7 @@ impl PadSettings {
|
||||
"playInBackground:{}\n",
|
||||
bool_text(self.play_in_background)
|
||||
));
|
||||
text.push_str(&format!(
|
||||
"radialMode:{}\nradialStick:{}\n",
|
||||
self.radial_mode.name(),
|
||||
self.radial_stick.name()
|
||||
));
|
||||
text.push_str(&format!("bindLayout:{}\n", self.bind_layout));
|
||||
for (key, value) in &self.extra {
|
||||
// A button this game didn't know, for an action the player has bound here since.
|
||||
let rebound = key
|
||||
.strip_prefix("bind_")
|
||||
.is_some_and(|action| self.bindings.contains_key(action));
|
||||
if !rebound {
|
||||
text.push_str(&format!("{key}:{value}\n"));
|
||||
}
|
||||
}
|
||||
// The default pages aren't written: they can improve for a player who never changed them.
|
||||
if self.radial != crate::radial::Pages::default() {
|
||||
text.push_str(&self.radial.serialize());
|
||||
}
|
||||
text.push_str("bindLayout:1\n");
|
||||
text
|
||||
}
|
||||
pub fn default_of(action: &str) -> Option<Button> {
|
||||
@@ -956,15 +835,11 @@ impl PadSettings {
|
||||
None => Self::default_of(action),
|
||||
}
|
||||
}
|
||||
/// Put `action` on `button`, or unbind it. An unknown action, or a button it can't take
|
||||
/// (`can_bind`), changes nothing.
|
||||
/// Bind an action (`None` unbinds); unknown actions are ignored.
|
||||
pub fn bind(&mut self, action: &str, button: Option<Button>) {
|
||||
let Some((name, default)) = ACTIONS.iter().find(|a| a.0 == action) else {
|
||||
return;
|
||||
};
|
||||
if button.is_some_and(|b| !self.can_bind(action, b)) {
|
||||
return;
|
||||
}
|
||||
if button == *default {
|
||||
self.bindings.remove(name);
|
||||
} else {
|
||||
@@ -1565,24 +1440,6 @@ pub enum Rumble {
|
||||
radius: f32,
|
||||
},
|
||||
Totem,
|
||||
/// The radial menu's stick reached a slot that would run: a tick.
|
||||
RadialSlot,
|
||||
/// The radial menu's page turned: a shorter tick.
|
||||
RadialPage,
|
||||
/// The page turn went round an end: a second tick after the first.
|
||||
RadialWrap,
|
||||
/// A release ran nothing after something had been selected: a soft buzz.
|
||||
RadialNothing,
|
||||
}
|
||||
impl From<crate::input_source::Feedback> for Rumble {
|
||||
fn from(event: crate::input_source::Feedback) -> Self {
|
||||
use crate::input_source::Feedback;
|
||||
match event {
|
||||
Feedback::BlockBroken => Rumble::BlockBroken,
|
||||
Feedback::Explosion { distance, radius } => Rumble::Explosion { distance, radius },
|
||||
Feedback::Totem => Rumble::Totem,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A vibration: the heavy (low-frequency) and light motors, 0..1, and how
|
||||
/// long it lasts.
|
||||
@@ -1592,7 +1449,6 @@ pub struct Pulse {
|
||||
pub weak: f32,
|
||||
pub millis: u32,
|
||||
}
|
||||
use crate::radial::{RADIAL_NOTHING, RADIAL_PAGE_TICK, RADIAL_TICK};
|
||||
impl Rumble {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
@@ -1600,10 +1456,6 @@ impl Rumble {
|
||||
Rumble::BlockBroken => "block",
|
||||
Rumble::Explosion { .. } => "explosion",
|
||||
Rumble::Totem => "totem",
|
||||
Rumble::RadialSlot => "radial",
|
||||
Rumble::RadialPage => "radial_page",
|
||||
Rumble::RadialWrap => "radial_wrap",
|
||||
Rumble::RadialNothing => "radial_nothing",
|
||||
}
|
||||
}
|
||||
/// The pulse, or `None` when it is too faint to feel.
|
||||
@@ -1630,9 +1482,6 @@ impl Rumble {
|
||||
millis: 350,
|
||||
})
|
||||
}
|
||||
Rumble::RadialSlot => Some(RADIAL_TICK),
|
||||
Rumble::RadialPage | Rumble::RadialWrap => Some(RADIAL_PAGE_TICK),
|
||||
Rumble::RadialNothing => Some(RADIAL_NOTHING),
|
||||
Rumble::Totem => Some(Pulse {
|
||||
strong: 0.7,
|
||||
weak: 0.7,
|
||||
@@ -1654,10 +1503,6 @@ impl Rumble {
|
||||
};
|
||||
match self {
|
||||
Rumble::BlockBroken => vec![Pulse { millis: 35, ..p }],
|
||||
Rumble::RadialSlot
|
||||
| Rumble::RadialPage
|
||||
| Rumble::RadialWrap
|
||||
| Rumble::RadialNothing => vec![p],
|
||||
Rumble::Hurt(_) => {
|
||||
let tail = p.millis.saturating_sub(50) / 2;
|
||||
vec![step(1.0, 50), step(0.55, tail), step(0.25, tail)]
|
||||
@@ -2018,55 +1863,7 @@ bind_key.hotbar.2:north
|
||||
let odd = PadSettings::parse(
|
||||
"lookSensitivity:NaN\nbind_key.nope:south\nbind_key.jump:x9\nbindLayout:1\n",
|
||||
);
|
||||
assert_eq!(
|
||||
odd.look_sensitivity,
|
||||
PadSettings::default().look_sensitivity
|
||||
);
|
||||
assert_eq!(odd.binding("key.jump"), Some(Button::South));
|
||||
assert_eq!(odd.actions_of(Button::South), ["key.jump"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_this_game_doesnt_read_survive_a_save() {
|
||||
// A newer Horizon's setting, an action this game doesn't have, a button it doesn't know.
|
||||
let text =
|
||||
"bindLayout:2\nhapticStrength:0.4\nbind_horizon:dash:south\nbind_key.jump:paddle1\n";
|
||||
let s = PadSettings::parse(text);
|
||||
assert_eq!(
|
||||
s.binding("key.jump"),
|
||||
Some(Button::South),
|
||||
"the default meanwhile"
|
||||
);
|
||||
let saved = s.serialize();
|
||||
for line in [
|
||||
"hapticStrength:0.4\n",
|
||||
"bind_horizon:dash:south\n",
|
||||
"bind_key.jump:paddle1\n",
|
||||
] {
|
||||
assert!(saved.contains(line), "{line} lost from\n{saved}");
|
||||
}
|
||||
assert_eq!(PadSettings::parse(&saved), s);
|
||||
// Bound here since, the action's own line is the one written.
|
||||
let mut s = s;
|
||||
s.bind("key.jump", Some(Button::West));
|
||||
let saved = s.serialize();
|
||||
assert!(!saved.contains("paddle1"), "{saved}");
|
||||
assert_eq!(
|
||||
PadSettings::parse(&saved).binding("key.jump"),
|
||||
Some(Button::West)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_newer_bind_layout_is_kept() {
|
||||
let newer = BIND_LAYOUT + 1;
|
||||
let s = PadSettings::parse(&format!("bindLayout:{newer}\nvibration:false\n"));
|
||||
assert!(s.serialize().contains(&format!("bindLayout:{newer}\n")));
|
||||
let s = PadSettings::parse("bindLayout:1\nvibration:false\n");
|
||||
assert!(
|
||||
s.serialize()
|
||||
.contains(&format!("bindLayout:{BIND_LAYOUT}\n"))
|
||||
);
|
||||
assert_eq!(odd, PadSettings::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
//! The radial menu's settings in the controller file (`horizon-controller.txt`): how its opener
|
||||
//! holds it open, the stick that selects, which buttons the opener may be with that stick, and the
|
||||
//! opener each stick had this session. `PadSettings::parse` and `serialize` read and write their
|
||||
//! lines (`radialMode`, `radialStick`, `bind_pad.radial`).
|
||||
use super::{Button, PadSettings, RADIAL};
|
||||
|
||||
/// The buttons under the right thumb, which steers the right stick.
|
||||
const RIGHT_THUMB: [Button; 4] = [
|
||||
Button::South,
|
||||
Button::West,
|
||||
Button::North,
|
||||
Button::RightStick,
|
||||
];
|
||||
/// The buttons under the left thumb, which steers the left stick.
|
||||
const LEFT_THUMB: [Button; 5] = [
|
||||
Button::DPadUp,
|
||||
Button::DPadDown,
|
||||
Button::DPadLeft,
|
||||
Button::DPadRight,
|
||||
Button::LeftStick,
|
||||
];
|
||||
|
||||
/// Whether the radial menu's opener can't be `button` while `stick` selects. One rule: the opener
|
||||
/// is never under a thumb that selects (holding it and steering are two jobs for one thumb), nor
|
||||
/// B, which cancels the open menu. So with the right stick the face buttons and R3 go, with the
|
||||
/// left the D-pad and L3, with either both; a bumper, a trigger and View are always free, and
|
||||
/// with a bumper for the opener the other bumper turns the pages.
|
||||
pub fn opener_refused(stick: RadialStick, button: Button) -> bool {
|
||||
button == Button::East
|
||||
|| (stick != RadialStick::Left && RIGHT_THUMB.contains(&button))
|
||||
|| (stick != RadialStick::Right && LEFT_THUMB.contains(&button))
|
||||
}
|
||||
|
||||
/// How the radial menu's opener holds it open.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum RadialMode {
|
||||
/// Held: letting go runs what is selected.
|
||||
#[default]
|
||||
Hold,
|
||||
/// A press opens it; A or the opener again runs what is selected, B closes it.
|
||||
Toggle,
|
||||
}
|
||||
impl RadialMode {
|
||||
pub const ALL: [Self; 2] = [Self::Hold, Self::Toggle];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Hold => "hold",
|
||||
Self::Toggle => "toggle",
|
||||
}
|
||||
}
|
||||
pub fn parse(value: &str) -> Self {
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|mode| mode.name() == value)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The stick that selects in the radial menu.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum RadialStick {
|
||||
#[default]
|
||||
Right,
|
||||
Left,
|
||||
/// The last stick to lean past the select tilt.
|
||||
Either,
|
||||
}
|
||||
impl RadialStick {
|
||||
pub const ALL: [Self; 3] = [Self::Right, Self::Left, Self::Either];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Right => "right",
|
||||
Self::Left => "left",
|
||||
Self::Either => "either",
|
||||
}
|
||||
}
|
||||
pub fn parse(value: &str) -> Self {
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|stick| stick.name() == value)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The opener each stick had, for this session: what the player stepped through, not a setting,
|
||||
/// so it is neither saved nor compared.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(super) struct StickOpeners([Option<Button>; 3]);
|
||||
impl PartialEq for StickOpeners {
|
||||
fn eq(&self, _: &Self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
impl StickOpeners {
|
||||
fn slot(&mut self, stick: RadialStick) -> &mut Option<Button> {
|
||||
let at = RadialStick::ALL
|
||||
.iter()
|
||||
.position(|s| *s == stick)
|
||||
.unwrap_or(0);
|
||||
&mut self.0[at]
|
||||
}
|
||||
}
|
||||
|
||||
impl PadSettings {
|
||||
/// Whether `button` can be bound to `action`: anything but the radial menu's opener under
|
||||
/// a thumb that selects, or on B (`opener_refused`).
|
||||
pub fn can_bind(&self, action: &str, button: Button) -> bool {
|
||||
action != RADIAL || !opener_refused(self.radial_stick, button)
|
||||
}
|
||||
/// The stick the radial menu selects with. An opener under the thumb that now selects is
|
||||
/// unbound, and its row in the radial page says so. An unbound opener then takes the one this
|
||||
/// stick had before in this session, or else its default where nothing else has it (D-pad
|
||||
/// Right, back on the right stick).
|
||||
pub fn set_radial_stick(&mut self, stick: RadialStick) {
|
||||
let was = self.radial_stick;
|
||||
*self.openers.slot(was) = self.binding(RADIAL);
|
||||
self.radial_stick = stick;
|
||||
if self
|
||||
.binding(RADIAL)
|
||||
.is_some_and(|b| opener_refused(stick, b))
|
||||
{
|
||||
self.bindings.insert(RADIAL, None);
|
||||
}
|
||||
if self.binding(RADIAL).is_some() {
|
||||
return;
|
||||
}
|
||||
// The player's own, shared or not: they put it there.
|
||||
if let Some(own) = self
|
||||
.openers
|
||||
.slot(stick)
|
||||
.filter(|b| !opener_refused(stick, *b))
|
||||
{
|
||||
self.bind(RADIAL, Some(own));
|
||||
return;
|
||||
}
|
||||
let default = Self::default_of(RADIAL);
|
||||
let free =
|
||||
default.is_some_and(|d| !opener_refused(stick, d) && self.actions_of(d).is_empty());
|
||||
if free {
|
||||
self.bindings.remove(RADIAL);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::Rumble;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_new_player_holds_d_pad_right_for_the_radial_menu() {
|
||||
for s in [PadSettings::default(), PadSettings::parse("")] {
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
|
||||
assert!(!s.conflicts(RADIAL));
|
||||
}
|
||||
let s = PadSettings::parse(&PadSettings::default().serialize());
|
||||
assert_eq!(s, PadSettings::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_button_bound_by_hand_stays_with_it_over_the_openers_default() {
|
||||
let s = PadSettings::parse("bindLayout:1\nbind_key.playerlist:right\n");
|
||||
assert_eq!(s.binding("key.playerlist"), Some(Button::DPadRight));
|
||||
assert_eq!(s.binding(RADIAL), None);
|
||||
assert!(!s.conflicts("key.playerlist"));
|
||||
assert_eq!(PadSettings::parse(&s.serialize()), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_opener_stays_where_it_was_saved() {
|
||||
let s =
|
||||
PadSettings::parse("bindLayout:1\nbind_pad.radial:right\nbind_key.playerlist:right\n");
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_conflict_made_since_the_migration_stays_after_a_restart() {
|
||||
// Bound in the binds list onto the opener's button: both stay, and the list marks it.
|
||||
let mut s = PadSettings::default();
|
||||
s.bind("key.playerlist", Some(Button::DPadRight));
|
||||
let text = s.serialize();
|
||||
assert!(text.contains("bindLayout:2"));
|
||||
let read = PadSettings::parse(&text);
|
||||
assert_eq!(read.binding(RADIAL), Some(Button::DPadRight));
|
||||
assert!(read.conflicts(RADIAL));
|
||||
assert_eq!(read, s);
|
||||
// The same, hand-written with spaces around the lines.
|
||||
let read = PadSettings::parse(" bindLayout:2\n bind_key.playerlist:right\n");
|
||||
assert_eq!(read.binding(RADIAL), Some(Button::DPadRight));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layout_one_file_resolves_the_opener_once() {
|
||||
let s = PadSettings::parse("bind_key.chat:up\n bindLayout:1\nbind_key.playerlist:right\n");
|
||||
assert_eq!(s.binding(RADIAL), None, "the player's own button wins");
|
||||
assert_eq!(s.binding("key.playerlist"), Some(Button::DPadRight));
|
||||
assert_eq!(s.binding("key.chat"), Some(Button::DPadUp));
|
||||
let text = s.serialize();
|
||||
assert!(text.contains("bindLayout:2\n") && text.contains("bind_pad.radial:none\n"));
|
||||
assert_eq!(PadSettings::parse(&text), s);
|
||||
// Without a conflict the opener keeps its default.
|
||||
let s = PadSettings::parse("bindLayout:1\nvibration:false\n");
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
|
||||
// A line with leading spaces is still a saved opener.
|
||||
let s =
|
||||
PadSettings::parse("bindLayout:1\n bind_pad.radial:right\nbind_key.playerlist:right\n");
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_opener_never_goes_under_the_selecting_thumb_nor_on_b() {
|
||||
use Button as B;
|
||||
let face = [B::South, B::West, B::North, B::RightStick];
|
||||
let dpad = [
|
||||
B::DPadUp,
|
||||
B::DPadDown,
|
||||
B::DPadLeft,
|
||||
B::DPadRight,
|
||||
B::LeftStick,
|
||||
];
|
||||
for stick in RadialStick::ALL {
|
||||
let mut s = PadSettings::default();
|
||||
s.set_radial_stick(stick);
|
||||
for button in Button::ALL {
|
||||
let refused = button == B::East
|
||||
|| (stick != RadialStick::Left && face.contains(&button))
|
||||
|| (stick != RadialStick::Right && dpad.contains(&button));
|
||||
assert_eq!(!s.can_bind(RADIAL, button), refused, "{stick:?} {button:?}");
|
||||
// Other actions still take any of them.
|
||||
assert!(s.can_bind("key.chat", button));
|
||||
}
|
||||
// A bumper is always free, and stays one through the file.
|
||||
s.bind(RADIAL, Some(B::LeftBumper));
|
||||
assert_eq!(s.binding(RADIAL), Some(B::LeftBumper), "{stick:?}");
|
||||
assert_eq!(PadSettings::parse(&s.serialize()), s, "{stick:?}");
|
||||
}
|
||||
// A file with a refused opener reads as never saved: the default, unless taken.
|
||||
for button in [B::South, B::East, B::West, B::North, B::RightStick] {
|
||||
let text = format!("bindLayout:2\nbind_pad.radial:{}\n", button.name());
|
||||
let read = PadSettings::parse(&text);
|
||||
assert_eq!(read.binding(RADIAL), Some(B::DPadRight), "{button:?}");
|
||||
let taken = format!("{text}bind_key.playerlist:right\n");
|
||||
assert_eq!(
|
||||
PadSettings::parse(&taken).binding(RADIAL),
|
||||
None,
|
||||
"{button:?}"
|
||||
);
|
||||
}
|
||||
// Read with the stick it was saved for, wherever the stick's line is.
|
||||
let left = PadSettings::parse("bind_pad.radial:north\nbindLayout:2\nradialStick:left\n");
|
||||
assert_eq!(left.binding(RADIAL), Some(B::North));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stick_that_takes_the_openers_thumb_unbinds_it_and_the_right_gives_it_back() {
|
||||
let mut s = PadSettings::default();
|
||||
s.set_radial_stick(RadialStick::Left);
|
||||
assert_eq!(
|
||||
s.binding(RADIAL),
|
||||
None,
|
||||
"D-pad Right is under the left thumb"
|
||||
);
|
||||
assert_eq!(PadSettings::parse(&s.serialize()).binding(RADIAL), None);
|
||||
s.set_radial_stick(RadialStick::Right);
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
|
||||
// An opener the player picked that both sticks allow stays.
|
||||
s.bind(RADIAL, Some(Button::RightBumper));
|
||||
s.set_radial_stick(RadialStick::Either);
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::RightBumper));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stepping_the_stick_and_back_gives_each_stick_its_own_opener() {
|
||||
// Left with Y, back to the right stick: Y is under the right thumb, D-pad Right opens.
|
||||
let mut s = PadSettings::default();
|
||||
s.set_radial_stick(RadialStick::Left);
|
||||
s.bind(RADIAL, Some(Button::North));
|
||||
s.set_radial_stick(RadialStick::Right);
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
|
||||
// And Left again has its Y back, shared with Inventory as the player left it.
|
||||
s.set_radial_stick(RadialStick::Left);
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::North));
|
||||
// Right with D-pad Left: the left stick refuses it, and the right gets it back.
|
||||
let mut s = PadSettings::default();
|
||||
s.bind(RADIAL, Some(Button::DPadLeft));
|
||||
s.set_radial_stick(RadialStick::Left);
|
||||
assert_eq!(s.binding(RADIAL), None);
|
||||
s.set_radial_stick(RadialStick::Either);
|
||||
assert_eq!(s.binding(RADIAL), None);
|
||||
s.set_radial_stick(RadialStick::Right);
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::DPadLeft));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_from_before_the_radial_menu_binds_no_opener() {
|
||||
let s = PadSettings::parse("vibration:false\nbind_key.chat:up\n");
|
||||
assert_eq!(s.binding(RADIAL), None);
|
||||
assert_eq!(PadSettings::parse(&s.serialize()), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_radial_menus_ticks_are_felt_and_tell_apart() {
|
||||
let tick = Rumble::RadialSlot.pulse().unwrap();
|
||||
assert!((40..=50).contains(&tick.millis), "{tick:?}");
|
||||
assert!((0.25..=0.35).contains(&tick.weak), "{tick:?}");
|
||||
let page = Rumble::RadialPage.pulse().unwrap();
|
||||
assert!(page.millis < tick.millis, "a page turn is the shorter tick");
|
||||
assert_eq!(Rumble::RadialWrap.pulse(), Some(page));
|
||||
let nothing = Rumble::RadialNothing.pulse().unwrap();
|
||||
assert!(
|
||||
nothing.strong > 0.0 && nothing.millis > tick.millis,
|
||||
"{nothing:?}"
|
||||
);
|
||||
assert!(
|
||||
nothing.strong.max(nothing.weak) < 0.3,
|
||||
"a soft buzz: {nothing:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_radial_menus_mode_and_stick_survive_the_settings_file() {
|
||||
let s = PadSettings::default();
|
||||
assert_eq!(
|
||||
(s.radial_mode, s.radial_stick),
|
||||
(RadialMode::Hold, RadialStick::Right)
|
||||
);
|
||||
for mode in RadialMode::ALL {
|
||||
for stick in RadialStick::ALL {
|
||||
let mut s = PadSettings {
|
||||
radial_mode: mode,
|
||||
..PadSettings::default()
|
||||
};
|
||||
s.set_radial_stick(stick);
|
||||
assert_eq!(PadSettings::parse(&s.serialize()), s);
|
||||
}
|
||||
}
|
||||
let read = PadSettings::parse("radialMode:sideways\nradialStick:up\n");
|
||||
assert_eq!(
|
||||
(read.radial_mode, read.radial_stick),
|
||||
(RadialMode::Hold, RadialStick::Right)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_pages_survive_the_settings_file() {
|
||||
let mut s = PadSettings::default();
|
||||
let pages = "radial_0_label:Mine\nradial_0_slots:key.chat,,,,,,,\n";
|
||||
s.radial =
|
||||
crate::radial::Pages::parse(pages.lines().filter_map(|l| l.split_once(':'))).unwrap();
|
||||
let read = PadSettings::parse(&s.serialize());
|
||||
assert_eq!(read.radial, s.radial);
|
||||
assert_ne!(read.radial, crate::radial::Pages::default());
|
||||
}
|
||||
}
|
||||
@@ -141,13 +141,8 @@ impl Guide {
|
||||
pub fn at_detail(self, detail: GuideDetail) -> Self {
|
||||
match detail {
|
||||
GuideDetail::Full => self,
|
||||
// Nothing else in play names the radial menu.
|
||||
GuideDetail::Basic => Guide {
|
||||
left: self
|
||||
.left
|
||||
.into_iter()
|
||||
.filter(|row| row.caption == crate::gamepad::RADIAL_CAPTION)
|
||||
.collect(),
|
||||
left: Vec::new(),
|
||||
right: self.right,
|
||||
},
|
||||
GuideDetail::Off => Guide {
|
||||
@@ -183,8 +178,6 @@ pub fn resolve(facts: &Facts, settings: &PadSettings) -> Guide {
|
||||
"key.swapOffhand",
|
||||
(player && any_item).then_some("horizon.controller.guide.swap"),
|
||||
),
|
||||
// Nothing else in play says the menu is there; it opens for a spectator too.
|
||||
row(crate::gamepad::RADIAL, Some(crate::gamepad::RADIAL_CAPTION)),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
@@ -573,7 +566,6 @@ mod tests {
|
||||
(Button::North, "key.inventory"),
|
||||
(Button::DPadDown, "horizon.controller.guide.drop"),
|
||||
(Button::West, "horizon.controller.guide.swap"),
|
||||
(Button::DPadRight, crate::gamepad::RADIAL_CAPTION),
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -744,12 +736,12 @@ mod tests {
|
||||
gliding: true,
|
||||
..Facts::default()
|
||||
}),
|
||||
["key.inventory", crate::gamepad::RADIAL_CAPTION]
|
||||
["key.inventory"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spectator_only_flies_opens_spectates_and_has_its_menus() {
|
||||
fn a_spectator_only_flies_opens_spectates_and_has_its_menu() {
|
||||
let facts = Facts {
|
||||
spectator: true,
|
||||
flying: true,
|
||||
@@ -773,8 +765,7 @@ mod tests {
|
||||
captions(&guide.left),
|
||||
[
|
||||
"horizon.controller.guide.fly_up",
|
||||
"horizon.controller.guide.fly_down",
|
||||
crate::gamepad::RADIAL_CAPTION
|
||||
"horizon.controller.guide.fly_down"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
//! The engine's side of the input source (docs/layers.md, "Controllers, splitscreen, replays and
|
||||
//! UI pages"): what gameplay tells a device beyond the keyboard and mouse. Gameplay names these
|
||||
//! types, never a controller's; the controller code answers.
|
||||
|
||||
/// Something that happened to the player that a device may let them feel (a controller's
|
||||
/// vibration). The device decides how, or whether.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Feedback {
|
||||
/// The player broke a block.
|
||||
BlockBroken,
|
||||
/// An explosion this far away (blocks) with this power.
|
||||
Explosion { distance: f64, radius: f32 },
|
||||
/// A totem of undying saved the player.
|
||||
Totem,
|
||||
}
|
||||
@@ -356,12 +356,6 @@ impl KeyBinds {
|
||||
pub fn is_default(&self, name: &str) -> bool {
|
||||
self.get(name) == Self::default_of(name)
|
||||
}
|
||||
/// `KeyBindsScreen.extractRenderState`: Reset All is active only while a
|
||||
/// mapping differs from its default (`KeyMapping.isDefault`, the bound key
|
||||
/// against `defaultKey`).
|
||||
pub fn changed_from_defaults(&self) -> bool {
|
||||
self.keys.keys().any(|name| !self.is_default(name))
|
||||
}
|
||||
pub fn set(&mut self, name: &str, key: &str) {
|
||||
if let Some((n, _, _)) = all_mappings().find(|m| m.0 == name) {
|
||||
self.keys.insert(n, key.to_owned());
|
||||
@@ -651,22 +645,6 @@ mod tests {
|
||||
assert_eq!(k.translate_all(KeyCode::Digit9), [Ok(KeyCode::Digit9)]);
|
||||
assert_eq!(k.translate_all(KeyCode::F3), [Ok(KeyCode::F3)]);
|
||||
}
|
||||
|
||||
/// `KeyBindsScreen.extractRenderState`: Reset All is active only while a
|
||||
/// binding differs from its default.
|
||||
#[test]
|
||||
fn reset_all_is_active_only_after_a_binding_moves() {
|
||||
let mut keys = KeyBinds::default();
|
||||
assert!(!keys.changed_from_defaults());
|
||||
keys.set("key.attack", "key.mouse.right");
|
||||
assert!(keys.changed_from_defaults());
|
||||
// Setting a mapping back to its own default leaves nothing to reset.
|
||||
keys.set("key.attack", KeyBinds::default_of("key.attack"));
|
||||
assert!(!keys.changed_from_defaults());
|
||||
keys.set("key.jump", "key.keyboard.z");
|
||||
keys.reset_all();
|
||||
assert!(!keys.changed_from_defaults());
|
||||
}
|
||||
#[test]
|
||||
fn display_names() {
|
||||
assert_eq!(display_name("key.keyboard.w").plain(), "W");
|
||||
|
||||
@@ -9,13 +9,11 @@ pub mod gamepad;
|
||||
pub mod gameplay_guide;
|
||||
pub mod held;
|
||||
pub mod input;
|
||||
pub mod input_source;
|
||||
pub mod interaction_input;
|
||||
pub mod key_names;
|
||||
pub mod keybinds;
|
||||
pub mod keymapping;
|
||||
pub mod modifiers;
|
||||
pub mod mouse;
|
||||
pub mod radial;
|
||||
pub mod snap;
|
||||
pub mod window;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -638,30 +638,10 @@ pub fn fullscreen_request(
|
||||
}
|
||||
}
|
||||
|
||||
/// The PNG the windows show as their icon, given once at startup by the layer that brands the
|
||||
/// game (the engine has none of its own).
|
||||
static ICON_PNG: std::sync::OnceLock<&'static [u8]> = std::sync::OnceLock::new();
|
||||
|
||||
/// Give the windows their icon, a PNG: call it before the first window opens. A second call
|
||||
/// keeps the first icon.
|
||||
pub fn set_icon_png(png: &'static [u8]) {
|
||||
let _ = ICON_PNG.set(png);
|
||||
}
|
||||
|
||||
/// The icon given to [`set_icon_png`], if any.
|
||||
pub fn icon_png() -> Option<&'static [u8]> {
|
||||
ICON_PNG.get().copied()
|
||||
}
|
||||
|
||||
/// The icon for the window's title bar and the task bar (X11 and Windows; Wayland takes it from
|
||||
/// the `.desktop` entry, macOS from the app bundle). `None` with no icon given, or one that
|
||||
/// can't be read.
|
||||
/// Horizon's icon for the window's title bar and the task bar (X11 and Windows; Wayland takes
|
||||
/// it from the `horizon.desktop` entry, macOS from the app bundle). `None` if it can't be read.
|
||||
pub fn icon() -> Option<winit::window::Icon> {
|
||||
decode_icon(icon_png()?)
|
||||
}
|
||||
|
||||
fn decode_icon(png: &[u8]) -> Option<winit::window::Icon> {
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(png));
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(horizon_theme::ICON_PNG));
|
||||
decoder.ignore_checksums(true);
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info().ok()?;
|
||||
@@ -674,15 +654,14 @@ fn decode_icon(png: &[u8]) -> Option<winit::window::Icon> {
|
||||
winit::window::Icon::from_rgba(bytes, info.width, info.height).ok()
|
||||
}
|
||||
|
||||
/// Apply the icon given to [`set_icon_png`] to a window. Windows has a separate taskbar icon slot
|
||||
/// from the small window icon; requiring a given icon to decode there prevents a generic taskbar
|
||||
/// icon from appearing.
|
||||
pub fn with_icon(attributes: winit::window::WindowAttributes) -> winit::window::WindowAttributes {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
/// Apply Horizon's icon to every window. Windows has a separate taskbar icon slot from the small
|
||||
/// window icon; requiring the decoded icon there prevents a generic taskbar icon from appearing.
|
||||
pub fn with_horizon_icon(
|
||||
attributes: winit::window::WindowAttributes,
|
||||
) -> winit::window::WindowAttributes {
|
||||
let icon = icon();
|
||||
#[cfg(target_os = "windows")]
|
||||
let icon =
|
||||
icon_png().map(|png| decode_icon(png).expect("the window icon must decode on Windows"));
|
||||
let icon = Some(icon.expect("Horizon's icon must be available on Windows"));
|
||||
|
||||
let attributes = attributes.with_window_icon(icon.clone());
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -691,39 +670,6 @@ pub fn with_icon(attributes: winit::window::WindowAttributes) -> winit::window::
|
||||
attributes
|
||||
}
|
||||
|
||||
/// The screen a window is on: its size in physical pixels, its refresh rate where the OS gives
|
||||
/// it, and its scale. For `horizon-ctl status` and the performance statistics' `system.display`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Display {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub refresh_hz: Option<u32>,
|
||||
pub scale: f64,
|
||||
}
|
||||
|
||||
/// The screen `window` is on, else the primary one, else the only one: right after the window
|
||||
/// is made, Wayland doesn't say yet which screen it is on.
|
||||
pub fn monitor(window: &winit::window::Window) -> Option<winit::monitor::MonitorHandle> {
|
||||
let only = || {
|
||||
let mut all = window.available_monitors();
|
||||
all.next().filter(|_| all.next().is_none())
|
||||
};
|
||||
window
|
||||
.current_monitor()
|
||||
.or_else(|| window.primary_monitor())
|
||||
.or_else(only)
|
||||
}
|
||||
|
||||
/// [`monitor`]'s size, refresh rate and scale.
|
||||
pub fn display(window: &winit::window::Window) -> Option<Display> {
|
||||
monitor(window).map(|m| Display {
|
||||
width: m.size().width,
|
||||
height: m.size().height,
|
||||
refresh_hz: m.refresh_rate_millihertz().map(|r| r.div_ceil(1000)),
|
||||
scale: m.scale_factor(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -760,8 +706,7 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn the_window_icon_decodes() {
|
||||
assert!(decode_icon(horizon_theme::ICON_PNG).is_some());
|
||||
assert!(decode_icon(b"not a png").is_none());
|
||||
assert!(icon().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -27,9 +27,6 @@ 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" }
|
||||
# One pool of helper threads for the server tick's parallel phases (`parallel.rs`): threads made
|
||||
# once, not for every phase of every tick.
|
||||
rayon = "1.10"
|
||||
horizon-hdr = { path = "../horizon-hdr" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
@@ -39,10 +36,14 @@ horizon-server-files = { path = "../horizon-server-files" }
|
||||
getrandom = "0.3"
|
||||
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md).
|
||||
horizon-anticheat = { path = "../horizon-anticheat" }
|
||||
# The dedicated server's chat filter: [chat_filter], the slur list, mutes (docs/chat-filter.md).
|
||||
horizon-chat-filter = { path = "../horizon-chat-filter" }
|
||||
# Shader packs: reading them and turning their GLSL into naga modules.
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
# Play with Friends: invite codes and their tunnel; the `friends` feature.
|
||||
horizon-friends = { path = "../horizon-friends", optional = true }
|
||||
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
|
||||
horizon-narrator = { path = "../horizon-narrator" }
|
||||
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
|
||||
@@ -71,6 +72,7 @@ flate2 = { version = "1.1.10", default-features = false, features = ["zlib-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).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
@@ -109,12 +111,16 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
|
||||
[features]
|
||||
default = ["audio", "controllers"]
|
||||
default = ["audio", "controllers", "discord", "friends"]
|
||||
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
|
||||
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
|
||||
audio = ["horizon-client-render/audio"]
|
||||
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
|
||||
controllers = ["dep:gilrs", "horizon-client-input/controllers"]
|
||||
# "Playing Horizon" on the player's Discord profile (`discord_presence`).
|
||||
discord = []
|
||||
# Play with Friends, joining side (`net.rs`): without it no invite code joins.
|
||||
friends = ["dep:horizon-friends"]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace"]
|
||||
# Compatibility with earlier experiment commands: main already uses mimalloc.
|
||||
@@ -126,8 +132,6 @@ windows-segment-heap = []
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
# ui_theme's unit tests draw text in the design system's fonts.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
|
||||
horizon-items = { path = "../horizon-items", features = ["test-support"] }
|
||||
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
|
||||
|
||||
@@ -9,6 +9,7 @@ description = "The client's world data, rendering and screens: chunks, meshing,
|
||||
[dependencies]
|
||||
# The settings stores of the crates above the engine (docs/layers.md, "Platform registries").
|
||||
horizon-hooks = { path = "../horizon-hooks" }
|
||||
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" }
|
||||
@@ -25,9 +26,11 @@ horizon-narrator = { path = "../horizon-narrator" }
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-polytone = { path = "../horizon-polytone" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-render-pipeline = { path = "../horizon-render-pipeline" }
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
horizon-system = { path = "../horizon-system" }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
ab_glyph = "0.2.32"
|
||||
@@ -74,16 +77,10 @@ wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
audio = ["dep:cpal"]
|
||||
# Helpers horizon-client's tests build their fixtures with.
|
||||
test-support = []
|
||||
# horizon-client's `horizon-extras`: asking the device for hardware ray tracing on request
|
||||
# (`gfx/ray_query.rs`, `HORIZON_RAY_TRACING=1`). Without it the device is requested as without
|
||||
# the variable.
|
||||
horizon-extras = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
# Tests: a TrueType font to load, and the design system the menus' motion curves match.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-items = { path = "../horizon-items", features = ["test-support"] }
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "glsl-out", "msl-out", "spv-out"] }
|
||||
@@ -1,158 +0,0 @@
|
||||
//! Audio occlusion, attenuation and mixing benchmark.
|
||||
//! Run with: `cargo run --profile fast -p horizon-client-render --example audio_bench`
|
||||
use horizon_client_render::{
|
||||
audio::Audio,
|
||||
sound_engine::{MobSound, SoundInstance, Sounds, TickContext},
|
||||
};
|
||||
use std::{path::Path, time::Instant};
|
||||
|
||||
fn main() {
|
||||
println!("=== Horizon Audio Attenuation & Mixing Benchmark ===");
|
||||
|
||||
let mut audio = Audio::silent(Path::new("/nonexistent"));
|
||||
audio.engine.loaded = true;
|
||||
audio.backend_live_for_bench();
|
||||
audio.record_heard = false;
|
||||
|
||||
// Register a sound event with attenuation distance 16
|
||||
audio.engine.registry.add_file(
|
||||
"minecraft",
|
||||
br#"{"bench.loop":{"attenuation_distance":16,"sounds":[{"name":"bench/loop","stream":false}]}}"#,
|
||||
&|_, _| true,
|
||||
);
|
||||
// Insert 1 second of audio into cache (48000 mono samples)
|
||||
let samples: Vec<f32> = (0..48_000)
|
||||
.map(|i| ((i as f32) * 440.0 * 2.0 * std::f32::consts::PI / 48000.0).sin() * 0.5)
|
||||
.collect();
|
||||
audio.insert_cached_sound("minecraft:bench/loop", samples, 48_000, 1);
|
||||
|
||||
// 1. Benchmark set_listener (called every client render frame)
|
||||
let listener_iterations = 200_000;
|
||||
let start = Instant::now();
|
||||
for i in 0..listener_iterations {
|
||||
let angle = (i as f32) * 0.01;
|
||||
audio.set_listener(
|
||||
[
|
||||
100.0 + (angle.cos() as f64) * 5.0,
|
||||
64.0,
|
||||
200.0 + (angle.sin() as f64) * 5.0,
|
||||
],
|
||||
angle,
|
||||
0.0,
|
||||
);
|
||||
}
|
||||
let listener_elapsed = start.elapsed();
|
||||
println!(
|
||||
"1. set_listener moving (render frame): {:?} for {} calls ({:.2} ns/call)",
|
||||
listener_elapsed,
|
||||
listener_iterations,
|
||||
(listener_elapsed.as_nanos() as f64) / (listener_iterations as f64)
|
||||
);
|
||||
|
||||
// 1b. Benchmark set_listener stationary (lock elimination early-out)
|
||||
let stationary_iterations = 200_000;
|
||||
let start = Instant::now();
|
||||
for _ in 0..stationary_iterations {
|
||||
audio.set_listener([100.0, 64.0, 200.0], 0.0, 0.0);
|
||||
}
|
||||
let stationary_elapsed = start.elapsed();
|
||||
println!(
|
||||
"1b. set_listener stationary (lock early-out): {:?} for {} calls ({:.2} ns/call)",
|
||||
stationary_elapsed,
|
||||
stationary_iterations,
|
||||
(stationary_elapsed.as_nanos() as f64) / (stationary_iterations as f64)
|
||||
);
|
||||
|
||||
// 2. Play 128 sounds in the world at various distances from listener at [100.0, 64.0, 200.0]
|
||||
// 64 sounds within attenuation distance (<16 blocks)
|
||||
// 64 sounds outside attenuation distance (>16 blocks)
|
||||
for i in 0..128 {
|
||||
let dist = if i < 64 {
|
||||
(i as f64) * 0.24 // 0 to 15.1 blocks (audible)
|
||||
} else {
|
||||
17.0 + ((i - 64) as f64) * 1.5 // 17 to 111.5 blocks (out of attenuation range)
|
||||
};
|
||||
let pos = [100.0 + dist, 64.0, 200.0];
|
||||
let instance = SoundInstance::new(
|
||||
"minecraft:bench.loop",
|
||||
crate_sound_source(i),
|
||||
1.0,
|
||||
1.0,
|
||||
horizon_core::random::LegacyRandom::new(i as i64),
|
||||
true, // looping
|
||||
0,
|
||||
horizon_client_render::sound_engine::Attenuation::Linear,
|
||||
pos,
|
||||
false,
|
||||
);
|
||||
audio.play_instance(instance, false);
|
||||
}
|
||||
|
||||
// 3. Benchmark mix_buffer (5000 buffers of 1024 stereo frames @ 48kHz = ~106 seconds of audio mixing)
|
||||
let mut buffer = vec![0.0f32; 1024 * 2];
|
||||
let mix_iterations = 5_000;
|
||||
let start = Instant::now();
|
||||
for _ in 0..mix_iterations {
|
||||
audio.mix_buffer(&mut buffer, 2, 48_000);
|
||||
}
|
||||
let mix_elapsed = start.elapsed();
|
||||
println!(
|
||||
"2. mix_buffer (128 active channels, 1024 stereo frames): {:?} for {} buffers ({:.2} µs/buffer)",
|
||||
mix_elapsed,
|
||||
mix_iterations,
|
||||
(mix_elapsed.as_micros() as f64) / (mix_iterations as f64)
|
||||
);
|
||||
|
||||
// 4. Benchmark SoundEngine::tick with ticking mob sounds
|
||||
let mut mobs = Vec::new();
|
||||
for i in 0..64 {
|
||||
mobs.push(MobSound {
|
||||
id: 2000 + i,
|
||||
position: [100.0 + (i as f64) * 0.5, 64.0, 200.0 + (i as f64) * 0.5],
|
||||
speed: 0.25,
|
||||
baby: false,
|
||||
angry: i % 2 == 0,
|
||||
silent: false,
|
||||
attack_scale: 0.5,
|
||||
sniffer_digging: false,
|
||||
});
|
||||
}
|
||||
|
||||
let tick_ctx = TickContext {
|
||||
player_underwater: false,
|
||||
player_removed: false,
|
||||
vehicle: None,
|
||||
carts: Vec::new(),
|
||||
mobs: mobs.clone(),
|
||||
frozen: false,
|
||||
player_position: [100.0, 64.0, 200.0],
|
||||
player_speed_sqr: 0.1,
|
||||
player_fall_flying: false,
|
||||
ridden: None,
|
||||
camera_position: [100.0, 64.0, 200.0],
|
||||
};
|
||||
|
||||
let tick_iterations = 10_000;
|
||||
let start = Instant::now();
|
||||
for _ in 0..tick_iterations {
|
||||
audio.tick(false, &tick_ctx);
|
||||
}
|
||||
let tick_elapsed = start.elapsed();
|
||||
println!(
|
||||
"3. SoundEngine::tick (64 channels + ticking): {:?} for {} ticks ({:.2} µs/tick)",
|
||||
tick_elapsed,
|
||||
tick_iterations,
|
||||
(tick_elapsed.as_micros() as f64) / (tick_iterations as f64)
|
||||
);
|
||||
}
|
||||
|
||||
fn crate_sound_source(i: u32) -> usize {
|
||||
match i % 6 {
|
||||
0 => 0, // master
|
||||
1 => 1, // music
|
||||
2 => 2, // records
|
||||
3 => 3, // weather
|
||||
4 => 4, // blocks
|
||||
_ => 5, // hostile
|
||||
}
|
||||
}
|
||||
@@ -386,7 +386,6 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
for _ in 0..32 {
|
||||
draw(&mut renderer)?;
|
||||
|
||||
@@ -207,7 +207,6 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
path: out.join("cpu.png"),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
renderer.render(&frame)?;
|
||||
let expected = read(&out.join("cpu.png"));
|
||||
@@ -252,7 +251,6 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
path: out.join("gpu.png"),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
renderer.render(&frame)?;
|
||||
assert_eq!(
|
||||
|
||||
@@ -167,7 +167,6 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
// twice: the first frame of a new warp builds it
|
||||
renderer.render(&frame).unwrap();
|
||||
@@ -337,7 +336,6 @@ fn bench(renderer: &mut Renderer, out: &Path) -> Result<(), Box<dyn std::error::
|
||||
path: out.join("bench.png"),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
renderer.render(&frame).unwrap();
|
||||
renderer.render(&frame).unwrap();
|
||||
|
||||
@@ -6,12 +6,7 @@ use horizon_world::{
|
||||
worldgen::{tags::TagDefinitions, terrain::TerrainBlockStates},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
io,
|
||||
path::Path,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use std::{collections::BTreeSet, io, path::Path, sync::Arc};
|
||||
|
||||
pub const MATERIALS: [&str; 6] = ["bedrock", "dirt", "stone", "sand", "oak_planks", "bricks"];
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -187,23 +182,6 @@ static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::
|
||||
fn invalid(message: &str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||
}
|
||||
/// Every block registry [`Assets::load`] has handed out, one per distinct
|
||||
/// content. Callers keep `&'static` references, so a registry is never freed;
|
||||
/// loading equal data again (tests load the same root once per fixture)
|
||||
/// returns the one already leaked instead of leaking another (about 11 MB for
|
||||
/// vanilla).
|
||||
static REGISTRIES: Mutex<Vec<&'static BlockRegistry>> = Mutex::new(Vec::new());
|
||||
|
||||
/// `registry` leaked, or the equal one leaked before it.
|
||||
fn intern_registry(registry: BlockRegistry) -> &'static BlockRegistry {
|
||||
let mut leaked = REGISTRIES.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(&same) = leaked.iter().find(|&&known| *known == registry) {
|
||||
return same;
|
||||
}
|
||||
let registry: &'static BlockRegistry = Box::leak(Box::new(registry));
|
||||
leaked.push(registry);
|
||||
registry
|
||||
}
|
||||
impl Assets {
|
||||
pub fn load(root: &Path) -> Result<Self> {
|
||||
let manifest: Value = horizon_world::json_file::read(&root.join("horizon-resources.json"))?;
|
||||
@@ -278,7 +256,7 @@ impl Assets {
|
||||
// Chunks hold every state of this registry: content mods can take it
|
||||
// past vanilla's 16-bit global palette.
|
||||
crate::chunk::set_block_states(registry.len());
|
||||
let registry = intern_registry(registry);
|
||||
let registry: &'static BlockRegistry = Box::leak(Box::new(registry));
|
||||
Ok(Self {
|
||||
registry,
|
||||
properties,
|
||||
@@ -342,16 +320,6 @@ impl RenderAssets {
|
||||
}
|
||||
None => crate::model_library::ParsedModels::default(),
|
||||
};
|
||||
// Connected-texture rules find their faces by sprite layer: what they match by name keeps a
|
||||
// layer of its own, decided before the models insert their textures.
|
||||
let ctm_rules = if draws {
|
||||
horizon_optifine::ctm::load(root)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
sprites.keep_apart(crate::connected::ConnectedTextures::matched_sprites(
|
||||
&ctm_rules,
|
||||
));
|
||||
let mut models = if draws {
|
||||
let _step = crate::spans::startup::step("core.models_bake");
|
||||
crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?
|
||||
@@ -391,7 +359,6 @@ impl RenderAssets {
|
||||
let mut found = Vec::new();
|
||||
crate::connected::ConnectedTextures::load(
|
||||
root,
|
||||
ctm_rules,
|
||||
registry,
|
||||
&mut sprites,
|
||||
|state, face| {
|
||||
@@ -415,8 +382,8 @@ impl RenderAssets {
|
||||
};
|
||||
// After the connected tiles: a tile identical to a block shares its layer, and the
|
||||
// overlays skip shared layers.
|
||||
if draws && let Some(suffix) = crate::pack_hooks::formats().emissive_suffix(root) {
|
||||
emissive = crate::native_emissive::load_overlays(root, &suffix, &mut sprites);
|
||||
if draws {
|
||||
emissive = crate::native_emissive::load_optifine(root, &mut sprites);
|
||||
}
|
||||
sprites.save_disk_cache();
|
||||
if draws {
|
||||
@@ -529,29 +496,6 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_registries_are_leaked_once() {
|
||||
use horizon_world::registry::BlockRegistry;
|
||||
let registry = |extra: &str| {
|
||||
let facts = format!(
|
||||
r#"{{"defaults":{{"minecraft:air":0,"fixture:{extra}":1}},"states":[
|
||||
["air",true,false,"","minecraft:air"],
|
||||
["{extra}",false,false,"","fixture:{extra}"]
|
||||
]}}"#
|
||||
);
|
||||
BlockRegistry::from_facts_json(facts.as_bytes()).unwrap()
|
||||
};
|
||||
let first = super::intern_registry(registry("interned_once"));
|
||||
let again = super::intern_registry(registry("interned_once"));
|
||||
let other = super::intern_registry(registry("interned_other"));
|
||||
assert!(std::ptr::eq(first, again), "equal content leaked twice");
|
||||
assert!(!std::ptr::eq(first, other), "different content shared");
|
||||
assert_eq!(
|
||||
other.default_state("fixture:interned_other"),
|
||||
Some(horizon_world::registry::BlockStateId(1))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprite_pixels_come_back_the_same_once_packed() {
|
||||
// Two layers of 16x16 RGBA with some structure, as sprites have.
|
||||
|
||||
@@ -233,11 +233,11 @@ pub fn texture_path(root: &Path, texture: &str, directory: &str) -> PathBuf {
|
||||
}
|
||||
let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture));
|
||||
let rel = format!("assets/{namespace}/{directory}/{path}.png");
|
||||
// No `.`, `..` or empty segment, and no `\` (a separator on Windows, where `..\..` climbs).
|
||||
let stays = rel
|
||||
.split('/')
|
||||
.all(|segment| !matches!(segment, "" | "." | "..") && !segment.contains('\\'));
|
||||
root.join(if stays { &rel } else { NOWHERE })
|
||||
root.join(if horizon_optifine::paths::valid(&rel) {
|
||||
&rel
|
||||
} else {
|
||||
NOWHERE
|
||||
})
|
||||
}
|
||||
|
||||
/// `SpriteSourceList.list`: every sprite id of the atlas and its origin.
|
||||
|
||||
@@ -478,7 +478,6 @@ pub struct Audio {
|
||||
pub record_heard: bool,
|
||||
pub listener: [f64; 3],
|
||||
pub listener_yaw: f32,
|
||||
pub listener_pitch: f32,
|
||||
pub master: f32,
|
||||
/// Seeds of positional plays (the client level random's `nextLong`).
|
||||
random: LegacyRandom,
|
||||
@@ -723,29 +722,6 @@ impl Backend for MixerBackend {
|
||||
fn set_unmuffled(&mut self, channel: ChannelId, unmuffled: bool) {
|
||||
self.with(channel, |c| c.unmuffled = unmuffled);
|
||||
}
|
||||
fn update_channel(&mut self, channel: ChannelId, volume: f32, pitch: f32, position: [f64; 3]) {
|
||||
if let Ok(mut m) = self.mixer.lock()
|
||||
&& let Some(c) = m.channels.get_mut(&channel)
|
||||
{
|
||||
c.gain = volume;
|
||||
c.pitch = pitch;
|
||||
c.at = [position[0] as f32, position[1] as f32, position[2] as f32];
|
||||
}
|
||||
}
|
||||
fn filter_stopped(&mut self, live: &[(u64, ChannelId)], stopped: &mut Vec<u64>) {
|
||||
if let Ok(m) = self.mixer.lock() {
|
||||
for &(h, c) in live {
|
||||
if m.channels
|
||||
.get(&c)
|
||||
.is_none_or(|ch| ch.state == State::Stopped)
|
||||
{
|
||||
stopped.push(h);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
stopped.extend(live.iter().map(|&(h, _)| h));
|
||||
}
|
||||
}
|
||||
/// `getCompleteBuffer` (cached, else decoded on the decoder thread) or
|
||||
/// `getStream` (decoded on a thread of its own), then attach and
|
||||
/// `alSourcePlay`. A sound that cannot load leaves its source initial,
|
||||
@@ -846,24 +822,18 @@ impl Backend for MixerBackend {
|
||||
/// pinned client's) rendered through `ALC_SOFT_loopback` within 1e-5.
|
||||
fn stereo_gains(right: f32, up: f32, forward: f32) -> [f32; 2] {
|
||||
let length = (right * right + up * up + forward * forward).sqrt();
|
||||
stereo_gains_with_length(right, up, forward, length)
|
||||
}
|
||||
#[inline]
|
||||
fn stereo_gains_with_length(right: f32, up: f32, forward: f32, length: f32) -> [f32; 2] {
|
||||
if length <= f32::EPSILON {
|
||||
return stereo_gains_with_length(0.0, 0.0, 1.0, 1.0);
|
||||
return stereo_gains(0.0, 0.0, 1.0);
|
||||
}
|
||||
let u = (up / length).clamp(-1.0, 1.0);
|
||||
let cos_elev = (1.0 - u * u).max(0.0).sqrt();
|
||||
let elevation = (up / length).clamp(-1.0, 1.0).asin();
|
||||
let mut azimuth = right.atan2(forward);
|
||||
if azimuth.abs() < std::f32::consts::FRAC_PI_2 {
|
||||
azimuth = (azimuth.abs() * 1.5)
|
||||
.min(std::f32::consts::FRAC_PI_2)
|
||||
.copysign(azimuth);
|
||||
}
|
||||
let (sin_az, cos_az) = azimuth.sin_cos();
|
||||
let side = sin_az * cos_elev;
|
||||
let front = cos_az * cos_elev * 0.095_660_8;
|
||||
let side = azimuth.sin() * elevation.cos();
|
||||
let front = azimuth.cos() * elevation.cos() * 0.095_660_8;
|
||||
[0.5 - 0.5 * side + front, 0.5 + 0.5 * side + front]
|
||||
}
|
||||
/// The gain OpenAL applies for distance (`AL_LINEAR_DISTANCE` with reference
|
||||
@@ -883,10 +853,16 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
// and OpenAL's right = forward x up.
|
||||
let basis = |yaw: f32, pitch: f32| {
|
||||
let (yaw, pitch) = (f64::from(yaw.to_radians()), f64::from(pitch.to_radians()));
|
||||
let (yaw_sin, yaw_cos) = yaw.sin_cos();
|
||||
let (pitch_sin, pitch_cos) = pitch.sin_cos();
|
||||
let forward = [-yaw_sin * pitch_cos, -pitch_sin, yaw_cos * pitch_cos];
|
||||
let up = [-yaw_sin * pitch_sin, pitch_cos, yaw_cos * pitch_sin];
|
||||
let forward = [
|
||||
-yaw.sin() * pitch.cos(),
|
||||
-pitch.sin(),
|
||||
yaw.cos() * pitch.cos(),
|
||||
];
|
||||
let up = [
|
||||
-yaw.sin() * pitch.sin(),
|
||||
pitch.cos(),
|
||||
yaw.cos() * pitch.sin(),
|
||||
];
|
||||
let right = [
|
||||
forward[1] * up[2] - forward[2] * up[1],
|
||||
forward[2] * up[0] - forward[0] * up[2],
|
||||
@@ -920,7 +896,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
// Gains and pans per source for this buffer.
|
||||
let mut voices = std::mem::take(&mut mixer.voices);
|
||||
voices.clear();
|
||||
for (id, c) in &mut mixer.channels {
|
||||
for (id, c) in &mixer.channels {
|
||||
let Some((_, source_rate, source_channels)) = &c.samples else {
|
||||
continue;
|
||||
};
|
||||
@@ -931,7 +907,6 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
if c.state != State::Playing {
|
||||
continue;
|
||||
}
|
||||
|
||||
let at = [f64::from(c.at[0]), f64::from(c.at[1]), f64::from(c.at[2])];
|
||||
let away = |listener: [f64; 3]| {
|
||||
[
|
||||
@@ -940,56 +915,45 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
at[2] - listener[2],
|
||||
]
|
||||
};
|
||||
let length_sq = |d: [f64; 3]| d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
|
||||
let camera = if !shared {
|
||||
let cam = &mixer.cameras[0];
|
||||
if c.relative || cam.2 == c.world {
|
||||
Some((cam.0, cam.1))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
mixer
|
||||
.cameras
|
||||
.iter()
|
||||
.filter(|camera| c.relative || camera.2 == c.world)
|
||||
.min_by(|a, b| length_sq(away(a.0)).total_cmp(&length_sq(away(b.0))))
|
||||
.map(|camera| (camera.0, camera.1))
|
||||
};
|
||||
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 = 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))))
|
||||
.map(|camera| (camera.0, camera.1));
|
||||
// Unheard sources still advance: returning to a world must not resume stale sounds.
|
||||
let (listener, (forward, up, right)) = camera.unwrap_or(([0.0; 3], basis(0.0, 0.0)));
|
||||
let d = if c.relative { at } else { away(listener) };
|
||||
let d_sqr = length_sq(d);
|
||||
|
||||
let (gain, [left, right]) = if camera.is_none() {
|
||||
(0.0, [0.0, 0.0])
|
||||
} else if direct {
|
||||
let gain = c.gain.clamp(0.0, 1.0);
|
||||
(gain, [gain, gain])
|
||||
} else {
|
||||
let mut zero = false;
|
||||
if let Some(max) = c.linear {
|
||||
let max_d = max as f64;
|
||||
if max <= 0.0 || (!c.relative && d_sqr >= max_d * max_d) {
|
||||
zero = true;
|
||||
}
|
||||
}
|
||||
if zero {
|
||||
(0.0, [0.0, 0.0])
|
||||
} else {
|
||||
let distance = d_sqr.sqrt() as f32;
|
||||
let gain = c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance);
|
||||
let [mut left, mut right] = if c.relative {
|
||||
stereo_gains_with_length(d[0] as f32, d[1] as f32, -d[2] as f32, distance)
|
||||
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32;
|
||||
let gain = if camera.is_some() {
|
||||
c.gain.clamp(0.0, 1.0)
|
||||
* if direct {
|
||||
1.0
|
||||
} else {
|
||||
stereo_gains_with_length(dot(d, right), dot(d, up), dot(d, forward), distance)
|
||||
};
|
||||
if shared {
|
||||
let middle = (left + right) * 0.5;
|
||||
left = middle + (left - middle) * 0.5;
|
||||
right = middle + (right - middle) * 0.5;
|
||||
distance_gain(c.linear, distance)
|
||||
}
|
||||
(gain, [left * gain, right * gain])
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let [left, right] = if direct {
|
||||
[gain, gain]
|
||||
} else {
|
||||
// Relative sources are already in OpenAL's listener space (-Z ahead).
|
||||
let [mut left, mut right] = if c.relative {
|
||||
stereo_gains(d[0] as f32, d[1] as f32, -d[2] as f32)
|
||||
} else {
|
||||
stereo_gains(dot(d, right), dot(d, up), dot(d, forward))
|
||||
};
|
||||
if shared {
|
||||
// Several players on the speakers: half the stereo width, as left and right are
|
||||
// nobody's in particular.
|
||||
let middle = (left + right) / 2.0;
|
||||
left = middle + (left - middle) / 2.0;
|
||||
right = middle + (right - middle) / 2.0;
|
||||
}
|
||||
[left * gain, right * gain]
|
||||
};
|
||||
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
|
||||
voices.push((*id, [left, right, gain], step));
|
||||
@@ -1010,40 +974,6 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
};
|
||||
let source_channels = source_channels as usize;
|
||||
let frames = samples.len() / source_channels;
|
||||
let inaudible = if channels >= 2 {
|
||||
left == 0.0 && right == 0.0
|
||||
} else {
|
||||
gain == 0.0
|
||||
};
|
||||
if inaudible {
|
||||
let buffer_frames = data.len() / channels;
|
||||
// As the loop below: a sound of one frame or none stops, looping or not.
|
||||
if !c.looping || frames <= 1 {
|
||||
// `frames` may be 0: no `frames - 1` in usize. A step of 0 never
|
||||
// reaches the end, as in the loop below.
|
||||
let needed = frames as f64 - 1.0 - c.position;
|
||||
if needed <= 0.0 {
|
||||
c.state = State::Stopped;
|
||||
} else if step > 0.0 {
|
||||
let steps_to_stop = needed / step;
|
||||
if (buffer_frames as f64) >= steps_to_stop {
|
||||
c.state = State::Stopped;
|
||||
} else {
|
||||
c.position += step * (buffer_frames as f64);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _ in 0..buffer_frames {
|
||||
let i = c.position as usize;
|
||||
if i + 1 >= frames {
|
||||
c.position = 0.0;
|
||||
}
|
||||
c.position += step;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let out: &mut [f32] = if muffling && !c.unmuffled {
|
||||
&mut bus
|
||||
} else {
|
||||
@@ -1062,8 +992,8 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
}
|
||||
let t = (c.position - i as f64) as f32;
|
||||
if source_channels > 1 {
|
||||
let l = samples[i * 2] + (samples[(i + 1) * 2] - samples[i * 2]) * t;
|
||||
let r = samples[i * 2 + 1] + (samples[(i + 1) * 2 + 1] - samples[i * 2 + 1]) * t;
|
||||
let l = samples[i * 2] * (1.0 - t) + samples[(i + 1) * 2] * t;
|
||||
let r = samples[i * 2 + 1] * (1.0 - t) + samples[(i + 1) * 2 + 1] * t;
|
||||
if channels >= 2 {
|
||||
frame[0] += l * left;
|
||||
frame[1] += r * right;
|
||||
@@ -1071,7 +1001,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
frame[0] += (l + r) * 0.5 * gain;
|
||||
}
|
||||
} else {
|
||||
let s = samples[i] + (samples[i + 1] - samples[i]) * t;
|
||||
let s = samples[i] * (1.0 - t) + samples[i + 1] * t;
|
||||
if channels >= 2 {
|
||||
frame[0] += s * left;
|
||||
frame[1] += s * right;
|
||||
@@ -1482,7 +1412,6 @@ impl Audio {
|
||||
record_heard: false,
|
||||
listener: [0.0; 3],
|
||||
listener_yaw: 0.0,
|
||||
listener_pitch: 0.0,
|
||||
master: 1.0,
|
||||
random: crate::random_source::create(),
|
||||
source_override: None,
|
||||
@@ -1606,12 +1535,8 @@ impl Audio {
|
||||
/// `SoundManager.updateSource`: the camera position and rotation
|
||||
/// (`Listener.setTransform` takes its forward and up vectors).
|
||||
pub fn set_listener(&mut self, position: [f64; 3], yaw: f32, pitch: f32) {
|
||||
if self.listener == position && self.listener_yaw == yaw && self.listener_pitch == pitch {
|
||||
return;
|
||||
}
|
||||
self.listener = position;
|
||||
self.listener_yaw = yaw;
|
||||
self.listener_pitch = pitch;
|
||||
if let Ok(mut m) = self.backend.mixer.lock() {
|
||||
if self.backend.owner == 0 {
|
||||
m.listener = position;
|
||||
@@ -1651,7 +1576,6 @@ impl Audio {
|
||||
record_heard: false,
|
||||
listener: [0.0; 3],
|
||||
listener_yaw: 0.0,
|
||||
listener_pitch: 0.0,
|
||||
master: self.master,
|
||||
random: crate::random_source::create(),
|
||||
source_override: None,
|
||||
@@ -2185,25 +2109,6 @@ impl Audio {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Mixes one buffer of audio into `data` (channels: 1 or 2, rate in Hz).
|
||||
pub fn mix_buffer(&mut self, data: &mut [f32], channels: usize, rate: u32) {
|
||||
if let Ok(mut m) = self.backend.mixer.lock() {
|
||||
mix(&mut m, data, channels, rate);
|
||||
}
|
||||
}
|
||||
#[doc(hidden)]
|
||||
pub fn insert_cached_sound(&mut self, key: &str, samples: Vec<f32>, rate: u32, channels: u16) {
|
||||
if let Ok(mut c) = self.backend.cache.lock() {
|
||||
c.insert(
|
||||
key.to_owned(),
|
||||
Some((std::sync::Arc::new(samples), rate, channels)),
|
||||
);
|
||||
}
|
||||
}
|
||||
#[doc(hidden)]
|
||||
pub fn backend_live_for_bench(&mut self) {
|
||||
self.backend.live = true;
|
||||
}
|
||||
}
|
||||
impl Sounds for Audio {
|
||||
fn engine(&mut self) -> &mut SoundEngine {
|
||||
@@ -2708,25 +2613,6 @@ mod tests {
|
||||
assert_eq!(data, [0.0; 4], "returning must not resume the old one-shot");
|
||||
}
|
||||
|
||||
/// A sound of one frame or none, heard or not, looping or not, stops at its first
|
||||
/// buffer as the mixing loop stops it: none stays playing (and holds its channel).
|
||||
#[test]
|
||||
fn sounds_too_short_to_mix_stop_even_unheard() {
|
||||
for (samples, looping) in [(vec![], false), (vec![1.0], false), (vec![1.0], true)] {
|
||||
let mut mixer = Mixer::default();
|
||||
let mut source = channel(samples.clone(), None, [0.0; 3], looping);
|
||||
source.gain = 0.0;
|
||||
mixer.channels.insert(1, source);
|
||||
let mut data = [0.0; 4];
|
||||
mix(&mut mixer, &mut data, 2, 48_000);
|
||||
assert_eq!(
|
||||
mixer.channels[&1].state,
|
||||
State::Stopped,
|
||||
"{samples:?} {looping}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variants_of_one_sound_are_the_same_sound() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -7,7 +7,7 @@ use serde_json::Value;
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
path::Path,
|
||||
sync::{Arc, OnceLock},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -398,37 +398,7 @@ impl Biomes {
|
||||
pub fn contains(&self, name: &str) -> bool {
|
||||
self.index.contains_key(name)
|
||||
}
|
||||
/// The biome at `id`, clamped to the last entry when `id` is past the
|
||||
/// table (a stale column from an older registry keeps its id).
|
||||
///
|
||||
/// When the table is empty (a resource pack with no biome files), returns
|
||||
/// a shared default plains biome instead of panicking on `list.len() - 1`.
|
||||
pub fn get(&self, id: u16) -> &BiomeInfo {
|
||||
if self.list.is_empty() {
|
||||
static DEFAULT: OnceLock<BiomeInfo> = OnceLock::new();
|
||||
return DEFAULT.get_or_init(|| BiomeInfo {
|
||||
spawns: HashMap::new(),
|
||||
spawn_costs: HashMap::new(),
|
||||
creature_probability: 0.0,
|
||||
increased_fire_burnout: false,
|
||||
name: "minecraft:plains".to_owned(),
|
||||
temperature: 0.8,
|
||||
frozen: false,
|
||||
downfall: 0.4,
|
||||
precipitation: false,
|
||||
grass: 0x7f_c0_55,
|
||||
modifier: GrassModifier::None,
|
||||
foliage: 0x6a_ab_37,
|
||||
dry_foliage: 0x79_75_54,
|
||||
water: 0x3f_76_e4,
|
||||
sky: None,
|
||||
fog: None,
|
||||
water_fog: 0x3f_76_e4,
|
||||
ambient_particles: Vec::new(),
|
||||
features: Vec::new(),
|
||||
attributes: Default::default(),
|
||||
});
|
||||
}
|
||||
&self.list[usize::from(id).min(self.list.len() - 1)]
|
||||
}
|
||||
/// The name of biome `id`, plains (as for an unknown name) when the id is
|
||||
@@ -825,34 +795,4 @@ mod blend_tests {
|
||||
}
|
||||
assert!(at(-2.0) > 0.0 && at(-2.0) < 0.5);
|
||||
}
|
||||
|
||||
/// An empty biome table (a resource pack with no biome files) must not
|
||||
/// panic on `get`: before the fix, `list.len() - 1` underflowed.
|
||||
#[test]
|
||||
fn get_on_empty_biome_table_returns_default_instead_of_panicking() {
|
||||
use std::sync::Arc;
|
||||
|
||||
let empty = Biomes {
|
||||
builtin: Arc::new(Vec::new()),
|
||||
index: HashMap::new(),
|
||||
list: Vec::new(),
|
||||
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
|
||||
colormaps: Arc::new(Colormaps {
|
||||
grass: Vec::new(),
|
||||
foliage: Vec::new(),
|
||||
dry: Vec::new(),
|
||||
}),
|
||||
packs: BTreeMap::new(),
|
||||
};
|
||||
// Any id returns the shared default plains biome, no panic.
|
||||
let b = empty.get(0);
|
||||
assert_eq!(b.name, "minecraft:plains");
|
||||
let b = empty.get(65535);
|
||||
assert_eq!(b.name, "minecraft:plains");
|
||||
// Colours and temperature come from the default.
|
||||
assert_eq!(b.temperature, 0.8);
|
||||
assert_eq!(b.grass, 0x7f_c0_55);
|
||||
assert_eq!(b.foliage, 0x6a_ab_37);
|
||||
assert_eq!(b.water, 0x3f_76_e4);
|
||||
}
|
||||
}
|
||||
@@ -92,9 +92,6 @@ pub struct BlockEntities {
|
||||
/// 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>,
|
||||
/// The signs a player's editor is on (`SignBlockEntity.playerWhoMayEdit`
|
||||
/// set), so the level's tick checks those alone instead of every sign.
|
||||
sign_editors: FxHashSet<Pos>,
|
||||
asleep: FxHashSet<Pos>,
|
||||
}
|
||||
|
||||
@@ -315,44 +312,6 @@ impl BlockEntities {
|
||||
.filter(move |((cx, cz), _)| !far(*cx, x) && !far(*cz, z))
|
||||
.flat_map(|(_, set)| set)
|
||||
}
|
||||
/// `SignBlockEntity.playerWhoMayEdit` of the sign at `pos`.
|
||||
pub fn sign_editor(&self, pos: &Pos) -> Option<u128> {
|
||||
match self.map.get(pos) {
|
||||
Some(BlockEntity::Sign(sign)) => sign.allowed_editor,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// `SignBlockEntity.setAllowedPlayerEditor`: the one player whose editor
|
||||
/// is on the sign at `pos`, or `None` for nobody's.
|
||||
pub fn set_sign_editor(&mut self, pos: Pos, who: Option<u128>) {
|
||||
let Some(BlockEntity::Sign(sign)) = self.map.get_mut(&pos) else {
|
||||
return;
|
||||
};
|
||||
sign.allowed_editor = who;
|
||||
if who.is_some() {
|
||||
self.sign_editors.insert(pos);
|
||||
} else {
|
||||
self.sign_editors.remove(&pos);
|
||||
}
|
||||
}
|
||||
/// The positions of the signs a player's editor is on, in no order.
|
||||
pub fn sign_editors(&self) -> impl Iterator<Item = &Pos> {
|
||||
self.sign_editors.iter()
|
||||
}
|
||||
/// Drops `pos` from the signs an editor is on, for a sign that is gone or
|
||||
/// no longer holds one. `set_sign_editor` keeps the list itself.
|
||||
pub fn forget_sign_editor(&mut self, pos: Pos) {
|
||||
self.sign_editors.remove(&pos);
|
||||
}
|
||||
/// Keeps `pos` listed in `sign_editors` exactly while `entity` holds an
|
||||
/// editor, as `note_box` does for the boxes.
|
||||
fn note_sign_editor(&mut self, pos: Pos, entity: &BlockEntity) {
|
||||
if matches!(entity, BlockEntity::Sign(sign) if sign.allowed_editor.is_some()) {
|
||||
self.sign_editors.insert(pos);
|
||||
} else {
|
||||
self.sign_editors.remove(&pos);
|
||||
}
|
||||
}
|
||||
/// `LevelChunk.getBlockEntity`: the block entity at `pos`, if any.
|
||||
pub fn get(&self, pos: &Pos) -> Option<&BlockEntity> {
|
||||
self.map.get(pos)
|
||||
@@ -392,7 +351,6 @@ impl BlockEntities {
|
||||
self.attachments.remove(&pos);
|
||||
}
|
||||
let new_box = shows_box(&entity);
|
||||
self.note_sign_editor(pos, &entity);
|
||||
self.asleep.remove(&pos);
|
||||
let old = self.map.insert(pos, entity);
|
||||
if old.is_none() {
|
||||
@@ -440,7 +398,6 @@ impl BlockEntities {
|
||||
forget_mod(&mut self.mods, *pos);
|
||||
}
|
||||
self.boxes.remove(pos);
|
||||
self.sign_editors.remove(pos);
|
||||
removed
|
||||
}
|
||||
/// The block entity at `pos`, out of the map for a while: its slot stays
|
||||
@@ -461,7 +418,6 @@ impl BlockEntities {
|
||||
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
|
||||
}
|
||||
self.note_box(pos, Some(&entity));
|
||||
self.note_sign_editor(pos, &entity);
|
||||
if self.map.insert(pos, entity).is_none()
|
||||
&& !self.live.contains_key(&pos)
|
||||
&& !self.pending.contains_key(&pos)
|
||||
@@ -489,7 +445,6 @@ impl BlockEntities {
|
||||
&mut self.attachments,
|
||||
);
|
||||
let (mods, boxes) = (&mut self.mods, &mut self.boxes);
|
||||
let sign_editors = &mut self.sign_editors;
|
||||
self.asleep.clear();
|
||||
self.map.retain(|p, e| {
|
||||
let kept = keep(p, e);
|
||||
@@ -499,7 +454,6 @@ impl BlockEntities {
|
||||
pending.remove(p);
|
||||
forget_mod(mods, *p);
|
||||
boxes.remove(p);
|
||||
sign_editors.remove(p);
|
||||
}
|
||||
kept
|
||||
});
|
||||
@@ -509,7 +463,6 @@ impl BlockEntities {
|
||||
self.map.clear();
|
||||
self.mods.clear();
|
||||
self.boxes.clear();
|
||||
self.sign_editors.clear();
|
||||
self.asleep.clear();
|
||||
self.tickers = Arc::default();
|
||||
self.live.clear();
|
||||
@@ -531,7 +484,6 @@ impl BlockEntities {
|
||||
self.asleep.remove(&p);
|
||||
let new_is_mod = matches!(e, BlockEntity::Mod(_));
|
||||
self.note_box(p, Some(&e));
|
||||
self.note_sign_editor(p, &e);
|
||||
let old = self.map.insert(p, e);
|
||||
if old.is_some() {
|
||||
self.replaced.insert(p);
|
||||
|
||||
@@ -15,7 +15,7 @@ use crate::{
|
||||
font::{Font, Glyph},
|
||||
gfx::mark_oit,
|
||||
hud::Rect,
|
||||
mesher::{CULL_BACK, Vertex, cull_back},
|
||||
mesher::{CULL_BACK, Vertex},
|
||||
models::load_visibility,
|
||||
signs::{self, SignData, SignText},
|
||||
text::{Style, Text},
|
||||
@@ -3093,9 +3093,9 @@ impl BlockEntityRenderer {
|
||||
}
|
||||
}
|
||||
// `DecoratedPotRenderer` submits every part with `entitySolid`, which culls
|
||||
// back faces: the flat `top` and `bottom` planes show one side each (#15322).
|
||||
// back faces: the flat `top` and `bottom` planes show one side each.
|
||||
for vertex in &mut out[first..] {
|
||||
vertex.normal = cull_back(vertex.normal);
|
||||
vertex.normal = CULL_BACK;
|
||||
}
|
||||
}
|
||||
Kind::EndPortal => {
|
||||
@@ -4760,12 +4760,9 @@ mod tests {
|
||||
28,
|
||||
"the pot submits all of vanilla's quads"
|
||||
);
|
||||
// A part the shader shades keeps its normal (#15322): `w` alone says cull.
|
||||
assert!(
|
||||
verts
|
||||
.iter()
|
||||
.all(|v| v.normal[3] == crate::mesher::SHADED_CULL_W && v.normal[..3] != [0; 3]),
|
||||
"entitySolid culls the pot's back faces and shades it by its normals"
|
||||
verts.iter().all(|v| v.normal == CULL_BACK),
|
||||
"entitySolid culls the pot's back faces"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ struct Out {
|
||||
return out;
|
||||
}
|
||||
@fragment fn fs_border(in: Out) -> @location(0) vec4<f32> {
|
||||
return frame_stored(border_color(in));
|
||||
return border_color(in);
|
||||
}
|
||||
fn border_color(in: Out) -> vec4<f32> {
|
||||
let color = textureSample(forcefield, forcefield_sampler, in.uv);
|
||||
|
||||
@@ -84,71 +84,6 @@ pub fn contents_effects(stack: &ItemStack) -> Vec<(&'static str, i32, i32)> {
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
/// `PotionContents.getAllEffects`, retaining each custom instance's flags.
|
||||
pub fn contents_instances(stack: &ItemStack) -> Vec<crate::effects::EffectInstance> {
|
||||
use horizon_client_core::nbt_value::{number, text};
|
||||
use horizon_world::nbt::Tag;
|
||||
let mut effects: Vec<_> = stack
|
||||
.potion()
|
||||
.and_then(potion)
|
||||
.map_or(&[][..], |p| p.effects)
|
||||
.iter()
|
||||
.map(|(id, duration, amp)| crate::effects::EffectInstance::new(id, *duration, *amp))
|
||||
.collect();
|
||||
if let Some((_, Tag::List(custom))) = stack
|
||||
.data()
|
||||
.and_then(|d| d.potion_extra.iter().find(|(k, _)| k == "custom_effects"))
|
||||
{
|
||||
for e in custom {
|
||||
let Some(def) = text(e.get("id")).and_then(|id| effect(&id)) else {
|
||||
continue;
|
||||
};
|
||||
let mut instance = crate::effects::EffectInstance::new(
|
||||
def.id,
|
||||
number(e.get("duration")).unwrap_or(0) as i32,
|
||||
number(e.get("amplifier")).unwrap_or(0).clamp(0, 255) as i32,
|
||||
);
|
||||
instance.ambient = number(e.get("ambient")).unwrap_or(0) != 0;
|
||||
instance.visible = number(e.get("show_particles")) != Some(0);
|
||||
effects.push(instance);
|
||||
}
|
||||
}
|
||||
effects
|
||||
}
|
||||
/// `PotionContents.getColor`: an explicit colour wins; otherwise mix only
|
||||
/// visible base and custom effects, with their amplifier weights.
|
||||
pub fn contents_color(stack: &ItemStack) -> u32 {
|
||||
use horizon_client_core::nbt_value::number;
|
||||
if let Some(color) = stack
|
||||
.data()
|
||||
.and_then(|d| d.potion_extra.iter().find(|(k, _)| k == "custom_color"))
|
||||
.and_then(|(_, v)| number(Some(v)))
|
||||
{
|
||||
return color as u32;
|
||||
}
|
||||
0xFF00_0000
|
||||
| horizon_client_core::potions::effects_color(
|
||||
contents_instances(stack)
|
||||
.iter()
|
||||
.filter(|e| e.visible)
|
||||
.map(|e| (e.id, e.amplifier)),
|
||||
)
|
||||
}
|
||||
/// `PotionContents.equals(EMPTY)`: colour and custom name also make contents
|
||||
/// nonempty, even when there is no registered potion or custom effect.
|
||||
pub fn contents_empty(stack: &ItemStack) -> bool {
|
||||
use horizon_world::nbt::Tag;
|
||||
stack.potion().is_none()
|
||||
&& stack.data().is_none_or(|d| {
|
||||
d.potion_extra
|
||||
.iter()
|
||||
.all(|(key, value)| match key.as_str() {
|
||||
"custom_effects" => matches!(value, Tag::List(list) if list.is_empty()),
|
||||
"custom_color" | "custom_name" => false,
|
||||
_ => true,
|
||||
})
|
||||
})
|
||||
}
|
||||
/// Roman amplifier suffix (`potion.potency.N`) and duration lines of a potion.
|
||||
pub fn tooltip(
|
||||
potion: Option<&str>,
|
||||
@@ -771,39 +706,4 @@ mod tests {
|
||||
let lines = effect_lines([("minecraft:resistance", 15, 0)], 1.0, &translate);
|
||||
assert_eq!(lines[0].0, "resistance");
|
||||
}
|
||||
#[test]
|
||||
fn custom_contents_color_and_empty_semantics() {
|
||||
use horizon_world::nbt::Tag;
|
||||
let mut stack = ItemStack::new("minecraft:tipped_arrow", 1);
|
||||
assert!(contents_empty(&stack));
|
||||
stack
|
||||
.data_mut()
|
||||
.potion_extra
|
||||
.push(("custom_effects".into(), Tag::List(Vec::new())));
|
||||
assert!(contents_empty(&stack));
|
||||
stack.data_mut().potion_extra = vec![(
|
||||
"custom_effects".into(),
|
||||
Tag::List(vec![
|
||||
Tag::compound([
|
||||
("id", Tag::String("minecraft:glowing".into())),
|
||||
("amplifier", Tag::Int(1)),
|
||||
]),
|
||||
Tag::compound([
|
||||
("id", Tag::String("minecraft:poison".into())),
|
||||
("show_particles", Tag::Byte(0)),
|
||||
]),
|
||||
]),
|
||||
)];
|
||||
assert!(!contents_empty(&stack));
|
||||
// Glowing's colour; hidden poison contributes no weight.
|
||||
assert_eq!(contents_color(&stack), 0xFF94_A061);
|
||||
stack
|
||||
.data_mut()
|
||||
.potion_extra
|
||||
.push(("custom_color".into(), Tag::Int(123456)));
|
||||
assert_eq!(contents_color(&stack), 123456);
|
||||
stack.data_mut().potion_extra = vec![("custom_name".into(), Tag::String("test".into()))];
|
||||
assert!(!contents_empty(&stack));
|
||||
assert_eq!(contents_color(&stack), 0xFF38_5DC6);
|
||||
}
|
||||
}
|
||||
@@ -568,8 +568,6 @@ impl Chat {
|
||||
pub fn set_qol(&mut self, atlas: &HudAtlas, qol: ChatQol) {
|
||||
if self.qol != qol {
|
||||
self.qol = qol;
|
||||
self.scroll = 0;
|
||||
self.new_since_scroll = false;
|
||||
self.rewrap(&atlas.font);
|
||||
}
|
||||
}
|
||||
@@ -595,25 +593,20 @@ impl Chat {
|
||||
.filter(|q| !q.trim().is_empty());
|
||||
if self.search != query {
|
||||
self.search = query;
|
||||
self.scroll = 0;
|
||||
self.new_since_scroll = false;
|
||||
self.rewrap(font);
|
||||
}
|
||||
}
|
||||
pub fn searching(&self) -> bool {
|
||||
self.search.is_some()
|
||||
}
|
||||
/// Re-wrap after a width or scale change (`ChatComponent.rescaleChat`):
|
||||
/// `resetChatScroll`, then the messages the Chat option shows.
|
||||
/// Re-wrap after a width or scale change (ChatComponent.rescaleChat),
|
||||
/// keeping only the messages the Chat option shows.
|
||||
pub fn rescale(&mut self, atlas: &HudAtlas) {
|
||||
self.scroll = 0;
|
||||
self.new_since_scroll = false;
|
||||
self.rewrap(&atlas.font);
|
||||
}
|
||||
/// `ChatComponent.refreshTrimmedMessages`: the lines are laid out again
|
||||
/// where the reader was (`chatScrollbarPos` survives a filter change);
|
||||
/// `rescale` resets the scroll itself, as `rescaleChat` does.
|
||||
fn rewrap(&mut self, font: &crate::font::Font) {
|
||||
self.scroll = 0;
|
||||
self.new_since_scroll = false;
|
||||
self.lines.clear();
|
||||
let mut messages = std::mem::take(&mut self.messages);
|
||||
for m in messages.iter_mut().rev() {
|
||||
@@ -1981,11 +1974,7 @@ impl ChatScreen {
|
||||
return false;
|
||||
}
|
||||
let max = l.list.len().saturating_sub(self.layout.line_limit) as i32;
|
||||
// Round instead of truncating: touchpad/PixelDelta produces fractional
|
||||
// amounts and truncation drops movement in one direction (#14338).
|
||||
l.offset = (l.offset as f64 - amount.clamp(-1.0, 1.0))
|
||||
.round()
|
||||
.clamp(0.0, max as f64) as usize;
|
||||
l.offset = ((l.offset as f64 - amount.clamp(-1.0, 1.0)) as i32).clamp(0, max) as usize;
|
||||
true
|
||||
}
|
||||
/// `CommandSuggestions.formatText`: argument colours over the input. The
|
||||
@@ -2097,11 +2086,6 @@ impl ChatScreen {
|
||||
) {
|
||||
let limit = l.list.len().min(self.layout.line_limit);
|
||||
let [rx, ry, rw, rh] = l.rect;
|
||||
// SuggestionsList.extractRenderState: the whole list asks for the
|
||||
// pointing hand while the pointer is inside its rectangle.
|
||||
if mouse.0 >= rx && mouse.0 < rx + rw && mouse.1 >= ry && mouse.1 < ry + rh {
|
||||
crate::widgets::request_cursor(crate::widgets::Cursor::PointingHand);
|
||||
}
|
||||
let has_prev = l.offset > 0;
|
||||
let has_next = l.list.len() > l.offset + limit;
|
||||
let moved = l.last_mouse != mouse;
|
||||
|
||||
@@ -1066,60 +1066,3 @@ fn a_server_filter_mask_hides_or_masks_the_message() {
|
||||
.all(|s| s.style.color.is_none())
|
||||
);
|
||||
}
|
||||
|
||||
/// `ChatComponent.refreshTrimmedMessages`: a filter change lays the lines out
|
||||
/// again but keeps `chatScrollbarPos` and `newMessageSinceScroll`; only
|
||||
/// `rescaleChat` resets them, after `resetChatScroll`.
|
||||
#[test]
|
||||
fn a_chat_option_change_keeps_the_readers_place_but_a_rescale_does_not() {
|
||||
let font = font();
|
||||
let mut chat = Chat::default();
|
||||
for i in 0..60 {
|
||||
chat.add_message(
|
||||
&font,
|
||||
Text::literal(format!("line {i}")),
|
||||
None,
|
||||
ChatSource::Server,
|
||||
0,
|
||||
false,
|
||||
);
|
||||
}
|
||||
chat.scroll = 3;
|
||||
chat.new_since_scroll = true;
|
||||
chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::System));
|
||||
assert_eq!(chat.scroll, 3);
|
||||
assert!(chat.new_since_scroll);
|
||||
let atlas = crate::hud::HudAtlas::build(Path::new("/nonexistent/horizon-test-assets")).unwrap();
|
||||
chat.rescale(&atlas);
|
||||
assert_eq!(chat.scroll, 0);
|
||||
assert!(!chat.new_since_scroll);
|
||||
}
|
||||
|
||||
/// `SuggestionsList.extractRenderState`: the list's rectangle asks for the
|
||||
/// pointing hand; outside it the cursor stays the arrow.
|
||||
#[test]
|
||||
fn the_suggestion_list_asks_for_the_pointing_hand() {
|
||||
let font = font();
|
||||
let m = Measure(&font);
|
||||
let registries = Registries::default();
|
||||
let c = Commands {
|
||||
dispatcher: crate::command::vanilla::game_dispatcher(),
|
||||
registries: ®istries,
|
||||
};
|
||||
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
|
||||
screen.type_text(&m, &c, "/");
|
||||
let rect = screen
|
||||
.suggestions
|
||||
.as_ref()
|
||||
.expect("the root commands are a suggestion list")
|
||||
.rect;
|
||||
let _ = crate::widgets::take_cursor();
|
||||
screen.draw(&m, (rect[0] + 2, rect[1] + 2), 0, 0);
|
||||
assert_eq!(
|
||||
crate::widgets::take_cursor(),
|
||||
crate::widgets::Cursor::PointingHand
|
||||
);
|
||||
let _ = crate::widgets::take_cursor();
|
||||
screen.draw(&m, (rect[0] - 1, rect[1] + 2), 0, 0);
|
||||
assert_eq!(crate::widgets::take_cursor(), crate::widgets::Cursor::Arrow);
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
use horizon_core::paletted::{PalettedContainer, Strategy};
|
||||
use horizon_world::nbt::{Compound, Tag};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::sync::atomic::{AtomicI16, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
pub mod block_entity;
|
||||
@@ -196,7 +195,7 @@ impl ColumnLayout {
|
||||
}
|
||||
/// The index of the section holding block `y`, if the column holds it.
|
||||
pub fn section_index(self, y: i32) -> Option<usize> {
|
||||
usize::try_from((i64::from(y) - i64::from(self.min_y)) >> 4)
|
||||
usize::try_from((y - self.min_y) >> 4)
|
||||
.ok()
|
||||
.filter(|&s| s < self.sections)
|
||||
}
|
||||
@@ -458,44 +457,6 @@ impl Light {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// One of a chunk's heightmaps (`Chunk::surface`): the heights, built whole on the first read,
|
||||
/// and the columns a write may have moved since, each found again on its next read. Reads share
|
||||
/// the chunk (helper threads read the world): a stale column is found the same by any of them.
|
||||
#[derive(Default)]
|
||||
struct Heights {
|
||||
heights: OnceLock<Box<[AtomicI16; 256]>>,
|
||||
stale: [AtomicU64; 4],
|
||||
}
|
||||
impl Heights {
|
||||
/// `Heightmap.update`: a write two or more below column `i`'s height can't move it; any
|
||||
/// other marks the column.
|
||||
fn write(&mut self, i: usize, y: i32) {
|
||||
let Some(heights) = self.heights.get_mut() else {
|
||||
return;
|
||||
};
|
||||
let stale = self.stale[i >> 6].get_mut();
|
||||
if *stale & 1 << (i & 63) == 0 && y >= i32::from(*heights[i].get_mut()) - 1 {
|
||||
*stale |= 1 << (i & 63);
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Clone for Heights {
|
||||
fn clone(&self) -> Self {
|
||||
let copy = Self::default();
|
||||
// A reader can refresh a height and then clear its stale bit while we clone. Copy the
|
||||
// bits first: a stale snapshot will rescan; a clean one acquires the refreshed height.
|
||||
// Copying heights first could keep an old height with a newly cleared bit.
|
||||
for (to, from) in copy.stale.iter().zip(&self.stale) {
|
||||
to.store(from.load(Ordering::Acquire), Ordering::Relaxed);
|
||||
}
|
||||
if let Some(heights) = self.heights.get() {
|
||||
let _ = copy.heights.set(Box::new(std::array::from_fn(|i| {
|
||||
AtomicI16::new(heights[i].load(Ordering::Relaxed))
|
||||
})));
|
||||
}
|
||||
copy
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
fn column(x: i32, z: i32) -> usize {
|
||||
((z & 15) << 4 | (x & 15)) as usize
|
||||
@@ -613,10 +574,10 @@ pub struct Chunk {
|
||||
pub sections: Vec<Arc<Section>>,
|
||||
/// `Heightmap.Types.WORLD_SURFACE` per column (`z << 4 | x`): one above
|
||||
/// the highest non-air block, or the column's minimum Y. Built on the first read; a
|
||||
/// `set` that can move a column's surface marks that column, found again on its next read.
|
||||
surface: Heights,
|
||||
/// `set` that can move a column's surface drops it.
|
||||
surface: OnceLock<Box<[i16; 256]>>,
|
||||
/// `Heightmap.Types.MOTION_BLOCKING` per column, kept like `surface`.
|
||||
motion_blocking: Heights,
|
||||
motion_blocking: OnceLock<Box<[i16; 256]>>,
|
||||
pub lit: bool,
|
||||
/// Light read from a save with `isLightOn`: no light job is needed.
|
||||
pub light_loaded: bool,
|
||||
@@ -657,8 +618,8 @@ impl Chunk {
|
||||
z,
|
||||
min_y: layout.min_y(),
|
||||
sections: (0..layout.sections()).map(|_| empty.clone()).collect(),
|
||||
surface: Heights::default(),
|
||||
motion_blocking: Heights::default(),
|
||||
surface: OnceLock::new(),
|
||||
motion_blocking: OnceLock::new(),
|
||||
lit: false,
|
||||
light_loaded: false,
|
||||
fresh: false,
|
||||
@@ -700,7 +661,7 @@ impl Chunk {
|
||||
/// The index of the section holding block `y`, if the column holds it.
|
||||
#[inline]
|
||||
pub fn section_index(&self, y: i32) -> Option<usize> {
|
||||
usize::try_from((i64::from(y) - i64::from(self.min_y)) >> 4)
|
||||
usize::try_from((y - self.min_y) >> 4)
|
||||
.ok()
|
||||
.filter(|&s| s < self.sections.len())
|
||||
}
|
||||
@@ -737,7 +698,12 @@ impl Chunk {
|
||||
// move it. Any other change rebuilds that map on the next read.
|
||||
if previous != v {
|
||||
for map in [&mut self.surface, &mut self.motion_blocking] {
|
||||
map.write(column(x, z), y);
|
||||
if map
|
||||
.get()
|
||||
.is_some_and(|heights| y >= i32::from(heights[column(x, z)]) - 1)
|
||||
{
|
||||
*map = OnceLock::new();
|
||||
}
|
||||
}
|
||||
}
|
||||
previous
|
||||
@@ -748,55 +714,24 @@ impl Chunk {
|
||||
let section = Arc::make_mut(&mut self.sections[s]);
|
||||
section.blocks = Section::from_blocks(values);
|
||||
section.compact();
|
||||
self.surface = Heights::default();
|
||||
self.motion_blocking = Heights::default();
|
||||
self.surface = OnceLock::new();
|
||||
self.motion_blocking = OnceLock::new();
|
||||
}
|
||||
/// `getHeight(Heightmap.Types.WORLD_SURFACE, x, z)` (`x`, `z` within the
|
||||
/// chunk): one above the highest block that isn't `air`, or the column's
|
||||
/// minimum Y.
|
||||
/// Sections must change only through `set` once this has been read.
|
||||
pub fn world_surface(&self, x: i32, z: i32, air: impl Fn(u32) -> bool) -> i32 {
|
||||
self.height(&self.surface, x, z, |v| !air(v))
|
||||
let surface = self.surface.get_or_init(|| self.prime(|v| !air(v)));
|
||||
i32::from(surface[column(x, z)])
|
||||
}
|
||||
/// `getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)` (`x`, `z` within
|
||||
/// the chunk): one above the highest state where `blocks` (a motion
|
||||
/// blocking block or a fluid), or the column's minimum Y. Kept as
|
||||
/// `world_surface` is.
|
||||
pub fn motion_blocking(&self, x: i32, z: i32, blocks: impl Fn(u32) -> bool) -> i32 {
|
||||
self.height(&self.motion_blocking, x, z, blocks)
|
||||
}
|
||||
/// One column's height in `map`: the whole map built on its first read, a column a write
|
||||
/// marked found again down that column alone.
|
||||
fn height(&self, map: &Heights, x: i32, z: i32, counts: impl Fn(u32) -> bool) -> i32 {
|
||||
let heights = map.heights.get_or_init(|| {
|
||||
let built = self.prime(&counts);
|
||||
Box::new(std::array::from_fn(|i| AtomicI16::new(built[i])))
|
||||
});
|
||||
let i = column(x, z);
|
||||
let (word, bit) = (i >> 6, 1u64 << (i & 63));
|
||||
if map.stale[word].load(Ordering::Acquire) & bit != 0 {
|
||||
let height = self.column_height(i, &counts);
|
||||
heights[i].store(height, Ordering::Relaxed);
|
||||
map.stale[word].fetch_and(!bit, Ordering::Release);
|
||||
return i32::from(height);
|
||||
}
|
||||
i32::from(heights[i].load(Ordering::Relaxed))
|
||||
}
|
||||
/// [`Self::prime`] for column `i` alone.
|
||||
fn column_height(&self, i: usize, counts: impl Fn(u32) -> bool) -> i16 {
|
||||
for (s, section) in self.sections.iter().enumerate().rev() {
|
||||
if section.blocks.uniform().is_some_and(|v| !counts(v)) {
|
||||
continue;
|
||||
}
|
||||
let base = self.min_y + s as i32 * 16;
|
||||
if let Some(dy) = (0..16)
|
||||
.rev()
|
||||
.find(|&dy| counts(section.blocks.get(dy << 8 | i)))
|
||||
{
|
||||
return (base + dy as i32 + 1) as i16;
|
||||
}
|
||||
}
|
||||
self.min_y as i16
|
||||
let heights = self.motion_blocking.get_or_init(|| self.prime(blocks));
|
||||
i32::from(heights[column(x, z)])
|
||||
}
|
||||
/// `Heightmap.primeHeightmaps`: each section from the top, skipping
|
||||
/// those of one state that doesn't count, until every column is found.
|
||||
@@ -862,7 +797,7 @@ impl Chunk {
|
||||
+ self.sections.capacity() * std::mem::size_of::<Arc<Section>>();
|
||||
census.heightmap_bytes += [&self.surface, &self.motion_blocking]
|
||||
.iter()
|
||||
.filter(|map| map.heights.get().is_some())
|
||||
.filter(|map| map.get().is_some())
|
||||
.count()
|
||||
* std::mem::size_of::<[i16; 256]>();
|
||||
for section in &self.sections {
|
||||
@@ -984,24 +919,6 @@ pub struct ChunkExtras {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn extreme_y_queries_are_air_and_writes_leave_the_column_unchanged() {
|
||||
for layout in [ColumnLayout::VANILLA, ColumnLayout::new(0, 256).unwrap()] {
|
||||
let mut chunk = Chunk::with_layout(0, 0, layout);
|
||||
chunk.set(0, layout.min_y(), 0, 7);
|
||||
for y in [i32::MIN, i32::MIN + 63, -2_146_769_275, i32::MAX] {
|
||||
assert_eq!(layout.section_index(y), None);
|
||||
assert_eq!(chunk.section_index(y), None);
|
||||
assert_eq!(chunk.get(0, y, 0), 0);
|
||||
assert_eq!(
|
||||
chunk.light(0, y, 0),
|
||||
if y < layout.min_y() { 0 } else { 0xf0 }
|
||||
);
|
||||
chunk.set(0, y, 0, 9);
|
||||
}
|
||||
assert_eq!(chunk.get(0, layout.min_y(), 0), 7);
|
||||
}
|
||||
}
|
||||
#[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] = [
|
||||
@@ -1103,89 +1020,9 @@ mod tests {
|
||||
let before = chunk.world_surface(3, 3, is_air);
|
||||
chunk.set(3, MIN_Y + HEIGHT, 3, stone);
|
||||
chunk.set(3, before - 1, 3, chunk.get(3, before - 1, 3));
|
||||
assert!(chunk.surface.heights.get().is_some());
|
||||
assert!(chunk.surface.get().is_some());
|
||||
assert_eq!(chunk.world_surface(3, 3, |_| unreachable!()), before);
|
||||
}
|
||||
/// A write two below a column's height can't move it, so the next read asks about no block;
|
||||
/// a write at or above it (placing on top, clearing the top block) is found again on the next
|
||||
/// read by looking down that column alone, and the other columns keep their heights.
|
||||
#[test]
|
||||
fn a_write_finds_its_own_column_again() {
|
||||
// Each column holds its own state (16 + i), so a read that looks at another column's
|
||||
// blocks shows up as a state that isn't air or this column's.
|
||||
let state = |i: usize| 16 + i as u32;
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
for i in 0..256 {
|
||||
for y in MIN_Y..MIN_Y + 20 + (i as i32 % 7) {
|
||||
chunk.set(i as i32 & 15, y, i as i32 >> 4, state(i));
|
||||
}
|
||||
}
|
||||
let seen = std::cell::RefCell::new(Vec::new());
|
||||
let surface = |c: &Chunk, i: usize| {
|
||||
c.world_surface(i as i32 & 15, i as i32 >> 4, |v| {
|
||||
seen.borrow_mut().push(v);
|
||||
v == 0
|
||||
})
|
||||
};
|
||||
let heights: Vec<i32> = (0..256).map(|i| surface(&chunk, i)).collect();
|
||||
assert_eq!(heights[0], MIN_Y + 20);
|
||||
assert_eq!(heights[6], MIN_Y + 26);
|
||||
seen.borrow_mut().clear();
|
||||
|
||||
let (column, x, z) = (0x35, 5, 3);
|
||||
let top = heights[column];
|
||||
// Two below the height: the column keeps its map entry.
|
||||
chunk.set(x, top - 2, z, 0);
|
||||
assert_eq!(surface(&chunk, column), top);
|
||||
assert!(seen.borrow().is_empty());
|
||||
chunk.set(x, top - 2, z, state(column));
|
||||
|
||||
// At the height: placed on top, found again down this column only.
|
||||
chunk.set(x, top, z, state(column));
|
||||
assert_eq!(surface(&chunk, column), top + 1);
|
||||
assert!(!seen.borrow().is_empty());
|
||||
assert!(seen.borrow().iter().all(|&v| v == 0 || v == state(column)));
|
||||
seen.borrow_mut().clear();
|
||||
assert_eq!(surface(&chunk, column), top + 1);
|
||||
for i in (0..256).filter(|&i| i != column) {
|
||||
assert_eq!(surface(&chunk, i), heights[i]);
|
||||
}
|
||||
assert!(
|
||||
seen.borrow().is_empty(),
|
||||
"a read found again an untouched column"
|
||||
);
|
||||
|
||||
// One below the height: the top block cleared, the height drops by one.
|
||||
chunk.set(x, top, z, 0);
|
||||
assert_eq!(surface(&chunk, column), top);
|
||||
assert!(seen.borrow().iter().all(|&v| v == 0 || v == state(column)));
|
||||
}
|
||||
/// Chunk snapshots can be cloned while helper threads refresh a stale height. No snapshot
|
||||
/// may retain the old height with its stale bit cleared, for either map.
|
||||
#[test]
|
||||
fn cloning_during_a_height_refresh_keeps_the_new_height() {
|
||||
for _ in 0..4096 {
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
chunk.set(0, MIN_Y + 4, 0, 1);
|
||||
assert_eq!(chunk.world_surface(0, 0, |v| v == 0), MIN_Y + 5);
|
||||
assert_eq!(chunk.motion_blocking(0, 0, |v| v != 0), MIN_Y + 5);
|
||||
chunk.set(0, MIN_Y + 5, 0, 1);
|
||||
let ready = std::sync::Barrier::new(2);
|
||||
std::thread::scope(|scope| {
|
||||
scope.spawn(|| {
|
||||
ready.wait();
|
||||
assert_eq!(chunk.world_surface(0, 0, |v| v == 0), MIN_Y + 6);
|
||||
assert_eq!(chunk.motion_blocking(0, 0, |v| v != 0), MIN_Y + 6);
|
||||
});
|
||||
ready.wait();
|
||||
for _ in 0..4 {
|
||||
let snapshot = chunk.clone();
|
||||
assert_eq!(snapshot.world_surface(0, 0, |v| v == 0), MIN_Y + 6);
|
||||
assert_eq!(snapshot.motion_blocking(0, 0, |v| v != 0), MIN_Y + 6);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
/// MOTION_BLOCKING ignores states that don't count (air, grass) and
|
||||
/// follows writes as the surface map does, both maps kept at once.
|
||||
#[test]
|
||||
|
||||
@@ -513,10 +513,6 @@ pub struct SignData {
|
||||
pub waxed: bool,
|
||||
/// `allow_op_features`: the sign's click events may run commands.
|
||||
pub allow_op_features: bool,
|
||||
/// `playerWhoMayEdit`: the one player whose editor is on this sign, as a
|
||||
/// UUID. Every other player's click, write and applicator is refused
|
||||
/// while it is set. Not saved (vanilla keeps it out of the NBT).
|
||||
pub allowed_editor: Option<u128>,
|
||||
}
|
||||
/// `VibrationInfo`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -543,7 +539,6 @@ pub struct Data {
|
||||
pub current: Option<Info>,
|
||||
pub travel: i32,
|
||||
pub selector: Selector,
|
||||
pub reload_particle: bool,
|
||||
}
|
||||
/// Which sculk block entity this is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
|
||||
@@ -419,10 +419,8 @@ impl SceneCollisions<'_, '_> {
|
||||
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)]
|
||||
.into_iter()
|
||||
.try_fold(1i64, |n, size| n.checked_mul(size));
|
||||
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells.is_none_or(|n| n > 4096) {
|
||||
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;
|
||||
}
|
||||
// A column and section at a time. Most boxes lie in a few sections whose palette says that no
|
||||
@@ -1113,13 +1111,6 @@ mod section_tests {
|
||||
binding: &binding,
|
||||
subject: None,
|
||||
};
|
||||
// The fast path must decline an enormous volume without overflowing
|
||||
// its size calculation or beginning to walk it.
|
||||
let enormous = Aabb::new(
|
||||
Vec3::new(-1.0e9, -1.0e9, -1.0e9),
|
||||
Vec3::new(1.0e9, 1.0e9, 1.0e9),
|
||||
);
|
||||
assert!(!scene.cells_without_collision(enormous));
|
||||
// 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))));
|
||||
|
||||
@@ -121,8 +121,8 @@ impl CommandBlock {
|
||||
condition_met: flag("conditionMet", false),
|
||||
}
|
||||
}
|
||||
/// `CommandBlockEntity.saveAdditional`. The last output is the component
|
||||
/// itself (`ComponentSerialization.CODEC`), styles and click events kept.
|
||||
/// `CommandBlockEntity.saveAdditional`. The last output is written as a
|
||||
/// plain string component.
|
||||
pub fn save(&self) -> Vec<(&'static str, Tag)> {
|
||||
let mut out = vec![
|
||||
("Command", Tag::String(self.command.as_str().into())),
|
||||
@@ -135,7 +135,7 @@ impl CommandBlock {
|
||||
if self.track_output
|
||||
&& let Some(last) = &self.last_output
|
||||
{
|
||||
out.push(("LastOutput", scoreboard::component_tag(&last.component())));
|
||||
out.push(("LastOutput", Tag::String(last.plain().as_str().into())));
|
||||
}
|
||||
out.push((
|
||||
"UpdateLastExecution",
|
||||
|
||||
@@ -56,8 +56,6 @@ pub struct Overlay {
|
||||
struct LoadedRule {
|
||||
rule: Rule,
|
||||
tiles: Vec<TileLayer>,
|
||||
/// By tile: the rules whose `matchTiles` name it (a range of `lists`), which chain on it.
|
||||
chain: Vec<(u32, u32)>,
|
||||
overlay: OverlayJoin,
|
||||
}
|
||||
|
||||
@@ -126,25 +124,15 @@ fn sprite_name(resource: &str) -> String {
|
||||
}
|
||||
|
||||
impl ConnectedTextures {
|
||||
/// The sprites `rules` match by name (`matchTiles`, `connectTiles`), for
|
||||
/// [`Sprites::keep_apart`]: before the models insert theirs.
|
||||
pub fn matched_sprites(rules: &[Rule]) -> Vec<String> {
|
||||
(rules.iter())
|
||||
.flat_map(|r| r.match_tiles.iter().chain(&r.connect_tiles))
|
||||
.map(|t| sprite_name(t))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `rules` (`ctm::load` under `root`), with their tiles inserted into `sprites` (before it
|
||||
/// finishes). `face_layer` answers a block state's sprite on a side, for rules that connect
|
||||
/// by tile.
|
||||
/// The rules under `root`, with their tiles inserted into `sprites` (before it finishes).
|
||||
/// `face_layer` answers a block state's sprite on a side, for rules that connect by tile.
|
||||
pub fn load(
|
||||
root: &Path,
|
||||
mut rules: Vec<Rule>,
|
||||
registry: &BlockRegistry,
|
||||
sprites: &mut Sprites,
|
||||
mut face_layer: impl FnMut(BlockStateId, Face) -> Option<u32>,
|
||||
) -> Self {
|
||||
let mut rules = ctm::load(root);
|
||||
// Rules with a custom name load no tiles: section meshes see no block entity.
|
||||
rules.retain(|r| r.name.is_none());
|
||||
// Rule indices are `u16`.
|
||||
@@ -190,7 +178,6 @@ impl ConnectedTextures {
|
||||
Ok(tiles) => Some(LoadedRule {
|
||||
rule,
|
||||
tiles,
|
||||
chain: Vec::new(),
|
||||
overlay: OverlayJoin::default(),
|
||||
}),
|
||||
Err(e) => {
|
||||
@@ -295,23 +282,10 @@ impl ConnectedTextures {
|
||||
let mut by_layer: Vec<Vec<u16>> = Vec::new();
|
||||
// Block rules by the block each entry names, so a state tests only its own.
|
||||
let mut by_block: HashMap<&str, Vec<u16>> = HashMap::new();
|
||||
// Tile rules by the sprite or tile file each names, for chaining on a picked tile.
|
||||
let mut by_name: HashMap<String, Vec<u16>> = HashMap::new();
|
||||
for (i, r) in out.rules.iter().enumerate() {
|
||||
let i = i as u16;
|
||||
for tile in &r.rule.match_tiles {
|
||||
let name = sprite_name(tile);
|
||||
let list = by_name.entry(name.clone()).or_default();
|
||||
if list.last() != Some(&i) {
|
||||
list.push(i);
|
||||
}
|
||||
// A tile file (`./1.png`) is no model's sprite: only chaining reaches it, by
|
||||
// name. By layer it would match every face whose sprite has the same pixels
|
||||
// (identical stills share a layer: Conquest's blank placeholders, dozens).
|
||||
if !name.contains(':') {
|
||||
continue;
|
||||
}
|
||||
if let Some(l) = layer(&name) {
|
||||
if let Some(l) = layer(&sprite_name(tile)) {
|
||||
let l = l as usize;
|
||||
if by_layer.len() <= l {
|
||||
by_layer.resize(l + 1, Vec::new());
|
||||
@@ -332,26 +306,6 @@ impl ConnectedTextures {
|
||||
let range = push_list(&mut out.lists, &list);
|
||||
out.by_layer.push(range);
|
||||
}
|
||||
let by_name: HashMap<String, (u32, u32)> = by_name
|
||||
.into_iter()
|
||||
.map(|(name, list)| (name, push_list(&mut out.lists, &list)))
|
||||
.collect();
|
||||
// Set at the end: `by_block` borrows the rules' names.
|
||||
let chains: Vec<Vec<(u32, u32)>> = out
|
||||
.rules
|
||||
.iter()
|
||||
.map(|r| {
|
||||
r.rule
|
||||
.tiles
|
||||
.iter()
|
||||
.map(|t| match t {
|
||||
Tile::Texture(path) => by_name.get(&sprite_name(path)).copied(),
|
||||
_ => None,
|
||||
})
|
||||
.map(Option::unwrap_or_default)
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let mut list = Vec::new();
|
||||
for state in states {
|
||||
let Some((name, props)) = state else {
|
||||
@@ -391,9 +345,6 @@ impl ConnectedTextures {
|
||||
let range = push_list(&mut out.lists, &list);
|
||||
out.by_state.push(range);
|
||||
}
|
||||
for (r, chain) in out.rules.iter_mut().zip(chains) {
|
||||
r.chain = chain;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -431,11 +382,9 @@ impl ConnectedTextures {
|
||||
}
|
||||
|
||||
fn list(&self, ranges: &[(u32, u32)], i: usize) -> &[u16] {
|
||||
ranges.get(i).map_or(&[], |&range| self.range(range))
|
||||
}
|
||||
|
||||
fn range(&self, (start, len): (u32, u32)) -> &[u16] {
|
||||
&self.lists[start as usize..(start + len) as usize]
|
||||
ranges.get(i).map_or(&[], |&(start, len)| {
|
||||
&self.lists[start as usize..(start + len) as usize]
|
||||
})
|
||||
}
|
||||
|
||||
/// What the `face` of the block `state` at `pos` draws, for a quad with sprite `layer`.
|
||||
@@ -482,7 +431,7 @@ 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, index)) = self.apply(
|
||||
&& let Some(pick) = self.apply(
|
||||
r,
|
||||
state,
|
||||
layer,
|
||||
@@ -495,7 +444,7 @@ impl ConnectedTextures {
|
||||
)
|
||||
{
|
||||
// "Tiles output by CTM rules can also be matched by another rule": once, by the
|
||||
// tile rules naming the picked tile by name (and this block, if they name blocks).
|
||||
// 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;
|
||||
@@ -508,7 +457,7 @@ impl ConnectedTextures {
|
||||
// again: an overlay would draw twice.
|
||||
let first = self.list(&self.by_layer, layer as usize);
|
||||
let again = self
|
||||
.range(r.chain.get(index).copied().unwrap_or_default())
|
||||
.list(&self.by_layer, picked as usize)
|
||||
.iter()
|
||||
.map(|&j| (j, &self.rules[usize::from(j)]))
|
||||
.filter(|&(j, r)| {
|
||||
@@ -529,8 +478,8 @@ impl ConnectedTextures {
|
||||
&texture,
|
||||
&mut overlay,
|
||||
) {
|
||||
Some((Pick::Keep, _)) => return pick,
|
||||
Some((chained, _)) => return chained,
|
||||
Some(Pick::Keep) => return pick,
|
||||
Some(chained) => return chained,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
@@ -553,7 +502,7 @@ impl ConnectedTextures {
|
||||
biome: &impl Fn() -> Option<&'b str>,
|
||||
texture: &impl Fn() -> Option<Axes>,
|
||||
overlay: &mut impl FnMut(Overlay),
|
||||
) -> Option<(Pick, usize)> {
|
||||
) -> Option<Pick> {
|
||||
let rule = &r.rule;
|
||||
// `Rule::applies_at`, with the biome looked up only for rules that test one. Rules with
|
||||
// a custom name are not loaded.
|
||||
@@ -643,7 +592,7 @@ impl ConnectedTextures {
|
||||
return if quarters.contains(&TileLayer::Skip) {
|
||||
None
|
||||
} else {
|
||||
Some((Pick::Quarters(quarters), 0))
|
||||
Some(Pick::Quarters(quarters))
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -658,7 +607,7 @@ impl ConnectedTextures {
|
||||
return None;
|
||||
}
|
||||
if !joins([0, 1, 0]) {
|
||||
return Some((Pick::Keep, 0));
|
||||
return Some(Pick::Keep);
|
||||
}
|
||||
0
|
||||
}
|
||||
@@ -685,8 +634,8 @@ impl ConnectedTextures {
|
||||
}
|
||||
match tile(index) {
|
||||
TileLayer::Skip => None,
|
||||
TileLayer::Default => Some((Pick::Keep, index)),
|
||||
t => Some((Pick::Tile(t), index)),
|
||||
TileLayer::Default => Some(Pick::Keep),
|
||||
t => Some(Pick::Tile(t)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -716,7 +665,6 @@ mod tests {
|
||||
.enumerate()
|
||||
.map(|(n, rule)| LoadedRule {
|
||||
overlay: OverlayJoin::default(),
|
||||
chain: Vec::new(),
|
||||
tiles: rule
|
||||
.tiles
|
||||
.iter()
|
||||
@@ -764,10 +712,6 @@ mod tests {
|
||||
"assets/minecraft/optifine/ctm/b0.png" => Some(2000),
|
||||
// And `a0` of the first rule.
|
||||
"assets/minecraft/optifine/ctm/a0.png" => Some(1000),
|
||||
// Two blank placeholders with the same pixels, so one layer: the tile `grass/1`
|
||||
// of the third rule (`chaining_follows_the_tile_not_its_pixels`).
|
||||
"assets/minecraft/optifine/ctm/grass/1.png"
|
||||
| "assets/minecraft/optifine/ctm/aaa/1.png" => Some(3000),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
@@ -1222,31 +1166,6 @@ mod tests {
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chaining_follows_the_tile_not_its_pixels() {
|
||||
// Conquest's grass top: a placeholder tile that a rule beside it replaces. Another
|
||||
// folder's placeholder has the same pixels, hence the same layer, and its rule sorts
|
||||
// first; it must not draw on the grass.
|
||||
let ctm = world(&[
|
||||
(
|
||||
"grass/top.properties",
|
||||
"matchTiles=glass\nmethod=fixed\ntiles=1",
|
||||
),
|
||||
(
|
||||
"grass/random_1.properties",
|
||||
"matchTiles=./1.png\nmethod=fixed\ntiles=17\nfaces=top",
|
||||
),
|
||||
(
|
||||
"aaa/random_1.properties",
|
||||
"matchTiles=./1.png\nmethod=fixed\ntiles=5\nfaces=top",
|
||||
),
|
||||
]);
|
||||
// Rules in path order: aaa/random_1 (1000), grass/random_1 (2000), grass/top (3000).
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
|
||||
// A face whose own sprite has a placeholder's pixels is no placeholder.
|
||||
assert_eq!(pick_in(&ctm, 2, 3000, Face::Up, &[]), Pick::Keep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orient_texture_follows_the_quads_uvs() {
|
||||
let rule = |orient: &str| {
|
||||
@@ -1351,41 +1270,6 @@ mod tests {
|
||||
assert!(!ctm.stacked_pane_hides(3, 3, post));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rules_name_the_sprites_they_match() {
|
||||
// `matchTiles` among the textures and beside the file (a chain onto another rule's
|
||||
// tile), and `connectTiles`: each as the sprite name the textures and tiles are inserted
|
||||
// under.
|
||||
let dir = "assets/minecraft/optifine/ctm/pack/crops";
|
||||
let rule = |file: &str, text: &str| {
|
||||
Rule::parse(
|
||||
&format!("{dir}/{file}"),
|
||||
&horizon_optifine::properties::Properties::parse(text),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
};
|
||||
let rules = [
|
||||
rule(
|
||||
"1.properties",
|
||||
"matchTiles=./19.png\nmethod=random\ntiles=5-6",
|
||||
),
|
||||
rule(
|
||||
"2.properties",
|
||||
"matchTiles=textures/block/extra/placeholder.png\nmethod=overlay\n\
|
||||
connectTiles=textures/block/stone.png\ntiles=0-16",
|
||||
),
|
||||
];
|
||||
assert_eq!(
|
||||
ConnectedTextures::matched_sprites(&rules),
|
||||
[
|
||||
format!("{dir}/19.png"),
|
||||
"minecraft:block/extra/placeholder".to_owned(),
|
||||
"minecraft:block/stone".to_owned(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprite_names() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -57,8 +57,6 @@ pub enum Item {
|
||||
/// Wait for a button to bind to this action.
|
||||
Bind(&'static str),
|
||||
ResetBinds,
|
||||
/// Opens the radial menu's pages (`radial_editor`).
|
||||
Radial,
|
||||
Info(Info),
|
||||
}
|
||||
|
||||
@@ -108,15 +106,6 @@ fn choice_description(opt: Opt, pad: &PadSettings) -> Option<&'static str> {
|
||||
}
|
||||
gamepad::Lightbar::Off => "A DualSense keeps its own light.",
|
||||
},
|
||||
Opt::PadRadialMode => match pad.radial_mode {
|
||||
gamepad::RadialMode::Hold => "horizon.options.radial_mode.hold.about",
|
||||
gamepad::RadialMode::Toggle => "horizon.options.radial_mode.toggle.about",
|
||||
},
|
||||
Opt::PadRadialStick => match pad.radial_stick {
|
||||
gamepad::RadialStick::Right => "horizon.options.radial_stick.right.about",
|
||||
gamepad::RadialStick::Left => "horizon.options.radial_stick.left.about",
|
||||
gamepad::RadialStick::Either => "horizon.options.radial_stick.either.about",
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -134,11 +123,6 @@ fn info(info: Info, text: &'static str) -> Setting {
|
||||
}
|
||||
}
|
||||
|
||||
/// The radial menu's group, on General (how it opens and selects) and Bindings (its pages).
|
||||
const RADIAL_GROUP: &str = "Radial Menu";
|
||||
/// The row that opens the radial menu's pages.
|
||||
const RADIAL_PAGES: &str = "horizon.pages.open.controller_radial";
|
||||
|
||||
/// Shown on a binding's row until it is focused, and in the search.
|
||||
const BIND_TEXT: &str = "Press this row, then the button that should do it.";
|
||||
|
||||
@@ -217,13 +201,6 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
RADIAL_GROUP,
|
||||
vec![
|
||||
option(Opt::PadRadialMode, "horizon.options.radial_mode.about"),
|
||||
option(Opt::PadRadialStick, "horizon.options.radial_stick.about"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Menus",
|
||||
vec![
|
||||
@@ -254,13 +231,6 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
(
|
||||
Page::Bindings,
|
||||
vec![
|
||||
(
|
||||
RADIAL_GROUP,
|
||||
vec![Setting {
|
||||
item: Item::Radial,
|
||||
text: "Named pages of eight actions, held open with a button.",
|
||||
}],
|
||||
),
|
||||
("Actions", binds),
|
||||
(
|
||||
"Reset",
|
||||
@@ -467,7 +437,6 @@ fn row(setting: Setting, s: &Settings, context: &Context) -> Row<Item> {
|
||||
Row::new(item, name, value).about(about)
|
||||
}
|
||||
Item::ResetBinds => Row::new(item, lang::get(RESET_ALL), Value::Press).about(about),
|
||||
Item::Radial => Row::new(item, lang::get(RADIAL_PAGES), Value::Press).about(about),
|
||||
Item::Info(which) => {
|
||||
let (name, value) = context.read(which);
|
||||
Row::new(item, lang::get(name), Value::Read(value)).about(about)
|
||||
@@ -527,7 +496,6 @@ impl Screen for ControllerScreen {
|
||||
s.controller.reset_bindings();
|
||||
vec![Output::Event(Event::Changed)]
|
||||
}
|
||||
Item::Radial => vec![Output::Push(crate::options_ui::Page::ControllerRadial)],
|
||||
Item::Bind(_) | Item::Info(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -565,21 +533,15 @@ impl Screen for ControllerScreen {
|
||||
fn undoes(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// This player's controller settings, bindings included, as the screen found them. The
|
||||
/// radial menu's pages stay as they are: their own screen has its own Undo.
|
||||
/// This player's controller settings, bindings included, as the screen found them.
|
||||
fn undo(&mut self, s: &mut Settings, before: &Settings) -> Vec<Output> {
|
||||
let radial = std::mem::take(&mut s.controller.radial);
|
||||
s.controller = before.controller.clone();
|
||||
s.controller.radial = radial;
|
||||
Vec::new()
|
||||
}
|
||||
/// Undo is there exactly while the controller settings differ from what the screen opened
|
||||
/// with: a binding the game made while the row waited counts, a value stepped back doesn't,
|
||||
/// and nor do the radial menu's pages, which this Undo keeps.
|
||||
/// with: a binding the game made while the row waited counts, a value stepped back doesn't.
|
||||
fn differs(&self, s: &Settings, before: &Settings) -> Option<bool> {
|
||||
let mut now = s.controller.clone();
|
||||
now.radial = before.controller.radial.clone();
|
||||
Some(now != before.controller)
|
||||
Some(s.controller != before.controller)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -831,48 +793,6 @@ mod tests {
|
||||
assert_eq!(arts(&batch, row), 0);
|
||||
}
|
||||
|
||||
/// The radial menu's pages have their own screen and Undo: editing them shows no Undo here,
|
||||
/// and another change's Undo keeps them as edited.
|
||||
#[test]
|
||||
fn radial_pages_edited_on_their_own_screen_leave_undo_alone() {
|
||||
let mut s = Settings::default();
|
||||
let mut ui = ui(Page::General);
|
||||
draw(&mut ui, &s, None);
|
||||
s.controller.radial.remove(1);
|
||||
draw(&mut ui, &s, None);
|
||||
assert!(
|
||||
!ui.changed(),
|
||||
"Undo here keeps the pages: it has nothing to do"
|
||||
);
|
||||
s.controller.invert_y = !s.controller.invert_y;
|
||||
draw(&mut ui, &s, None);
|
||||
assert!(ui.changed());
|
||||
let reached = (0..100).any(|_| {
|
||||
key(&mut ui, &mut s, K::Tab);
|
||||
ui.focused() == Some(Target::Undo)
|
||||
});
|
||||
assert!(reached);
|
||||
key(&mut ui, &mut s, K::Enter);
|
||||
assert_eq!(s.controller.radial.len(), 1);
|
||||
assert_eq!(s.controller.invert_y, PadSettings::default().invert_y);
|
||||
}
|
||||
|
||||
/// The Bindings page's radial row opens the radial menu's pages.
|
||||
#[test]
|
||||
fn the_radial_row_opens_the_radial_pages() {
|
||||
let mut s = Settings::default();
|
||||
let mut ui = ui(Page::Bindings);
|
||||
let at = ui.point_at(&s, SIZE, Item::Radial);
|
||||
let outputs = ui.click(&mut s, at, SIZE, false, false, false);
|
||||
assert_eq!(
|
||||
outputs,
|
||||
[
|
||||
CLICK,
|
||||
Output::Push(crate::options_ui::Page::ControllerRadial)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_search_finds_rows_across_pages_and_drops_empty_pages() {
|
||||
let s = Settings::default();
|
||||
@@ -896,8 +816,8 @@ mod tests {
|
||||
let mut s = Settings::default();
|
||||
s.controller.bind(JUMP, Some(Button::North));
|
||||
let mut ui = ui(Page::Bindings);
|
||||
// Search, the three pages, the radial menu's row, then the first action.
|
||||
for _ in 0..Page::ALL.len() + 3 {
|
||||
// Search, the three pages, then the first action.
|
||||
for _ in 0..Page::ALL.len() + 2 {
|
||||
key(&mut ui, &mut s, K::Tab);
|
||||
}
|
||||
assert_eq!(ui.focused(), Some(Target::Row(Item::Bind(JUMP))));
|
||||
@@ -1019,8 +939,6 @@ mod tests {
|
||||
(Opt::PadNintendoButtons, 2),
|
||||
(Opt::PadKeyboard, 3),
|
||||
(Opt::PadLightbar, 3),
|
||||
(Opt::PadRadialMode, 2),
|
||||
(Opt::PadRadialStick, 3),
|
||||
] {
|
||||
let setting = option(opt, "the fixed sentence");
|
||||
let mut seen = Vec::new();
|
||||
@@ -1054,8 +972,6 @@ mod tests {
|
||||
Opt::PadNintendoButtons,
|
||||
Opt::PadKeyboard,
|
||||
Opt::PadLightbar,
|
||||
Opt::PadRadialMode,
|
||||
Opt::PadRadialStick,
|
||||
] {
|
||||
for _ in 0..4 {
|
||||
lang::get(choice_description(opt, &settings.controller).unwrap());
|
||||
@@ -1074,7 +990,6 @@ mod tests {
|
||||
}
|
||||
ControllerScreen::default().side();
|
||||
lang::get(TITLE);
|
||||
lang::get(RADIAL_PAGES);
|
||||
for text in [
|
||||
"Press the button for this. %s cancels.",
|
||||
"This button also does: %s",
|
||||
|
||||
@@ -4,13 +4,11 @@
|
||||
//!
|
||||
//! The pack's GLSL (26.3's `#include` or the older `#moj_import`, with explicit locations or
|
||||
//! without) is written again as the Vulkan-flavoured GLSL 450 naga reads:
|
||||
//! - GLSL vanilla's GL compiler takes and naga doesn't is rewritten first ([`glsl_fixups`]: a
|
||||
//! helper taking the `sampler2D` it reads, an array sized by its initializer only);
|
||||
//! - includes are inlined, each file once (as `GlslPreprocessor` imported them), from `include`;
|
||||
//! - vanilla's uniform blocks get the bindings of [`BLOCKS`], and each of [`SAMPLERS`] is split
|
||||
//! into a texture and a sampler object, combined again where the sampler is named;
|
||||
//! - the vertex inputs become globals the caller's [`Glue`] fills before the pack's `main`
|
||||
//! (renamed `hz_main`) runs, and `gl_VertexID` and `gl_VertexIndex` read the glue's expression;
|
||||
//! (renamed `hz_main`) runs, and `gl_VertexID` reads the glue's expression;
|
||||
//! - varyings get locations by name across the stages, the fragment output location 0.
|
||||
|
||||
use std::collections::HashSet;
|
||||
@@ -20,8 +18,6 @@ use naga::ShaderStage;
|
||||
use naga::front::glsl::{Frontend, Options};
|
||||
use naga::valid::{Capabilities, ValidationFlags, Validator};
|
||||
|
||||
mod glsl_fixups;
|
||||
|
||||
/// Vanilla's uniform blocks (`shaders/include/*.glsl`) and their bindings in set 0.
|
||||
pub const BLOCKS: [(&str, u32); 4] = [
|
||||
("Globals", 0),
|
||||
@@ -47,7 +43,7 @@ pub struct Glue<'a> {
|
||||
pub setup: &'a str,
|
||||
/// Statements after it, on `gl_Position`.
|
||||
pub finish: &'a str,
|
||||
/// The expression the pack's `gl_VertexID` and `gl_VertexIndex` read.
|
||||
/// The expression `gl_VertexID` reads.
|
||||
pub vertex_id: &'a str,
|
||||
}
|
||||
|
||||
@@ -56,15 +52,6 @@ pub struct Glue<'a> {
|
||||
pub struct CoreProgram {
|
||||
pub vertex: naga::Module,
|
||||
pub fragment: naga::Module,
|
||||
/// The blocks and samplers that are none of vanilla's, in binding order: the `i`th is at
|
||||
/// [`other_binding`]`(i)` (a sampler's object one after its texture; a block leaves that
|
||||
/// binding unused).
|
||||
pub other: Vec<String>,
|
||||
}
|
||||
|
||||
/// The binding of the `index`th block or sampler of [`CoreProgram::other`].
|
||||
pub fn other_binding(index: usize) -> u32 {
|
||||
OTHER_BINDINGS + 2 * index as u32
|
||||
}
|
||||
|
||||
/// The shaders of one pipeline (`text.vsh`, `text.fsh`) with vanilla's `defines` for it.
|
||||
@@ -75,39 +62,19 @@ pub fn translate_core(
|
||||
defines: &[&str],
|
||||
include: &dyn Fn(&str) -> Option<String>,
|
||||
glue: &Glue,
|
||||
) -> Result<CoreProgram, String> {
|
||||
translate_core_with(vertex, fragment, defines, include, glue, &|_| false)
|
||||
}
|
||||
|
||||
/// [`translate_core`] for a pipeline that also gives the blocks and samplers `provided` says
|
||||
/// yes to, at their [`CoreProgram::other`] bindings; the others are still an error. `provided`
|
||||
/// answers a block's name and a sampler's alike: the caller knows which kind each one is.
|
||||
pub fn translate_core_with(
|
||||
vertex: &str,
|
||||
fragment: &str,
|
||||
defines: &[&str],
|
||||
include: &dyn Fn(&str) -> Option<String>,
|
||||
glue: &Glue,
|
||||
provided: &dyn Fn(&str) -> bool,
|
||||
) -> Result<CoreProgram, String> {
|
||||
const CORE: &str = "minecraft:shaders/core/";
|
||||
let vertex_text = glsl_fixups::fixups(&strip_comments(&expand(vertex, CORE, include)?));
|
||||
let fragment_text = glsl_fixups::fixups(&strip_comments(&expand(fragment, CORE, include)?));
|
||||
let vertex_text = strip_comments(&expand(vertex, CORE, include)?);
|
||||
let fragment_text = strip_comments(&expand(fragment, CORE, include)?);
|
||||
let mut names = Names::default();
|
||||
let vertex_code = rewrite(&vertex_text, ShaderStage::Vertex, glue, &mut names)
|
||||
.map_err(|e| format!("vertex shader: {e}"))?;
|
||||
let fragment_code = rewrite(&fragment_text, ShaderStage::Fragment, glue, &mut names)
|
||||
.map_err(|e| format!("fragment shader: {e}"))?;
|
||||
let vertex = compile(ShaderStage::Vertex, &vertex_code, defines, &names, provided)
|
||||
let vertex = compile(ShaderStage::Vertex, &vertex_code, defines)
|
||||
.map_err(|e| format!("vertex shader: {e}"))?;
|
||||
let fragment = compile(
|
||||
ShaderStage::Fragment,
|
||||
&fragment_code,
|
||||
defines,
|
||||
&names,
|
||||
provided,
|
||||
)
|
||||
.map_err(|e| format!("fragment shader: {e}"))?;
|
||||
let fragment = compile(ShaderStage::Fragment, &fragment_code, defines)
|
||||
.map_err(|e| format!("fragment shader: {e}"))?;
|
||||
let given = locations(&vertex, false);
|
||||
if let Some(missing) = locations(&fragment, true)
|
||||
.into_iter()
|
||||
@@ -117,21 +84,11 @@ pub fn translate_core_with(
|
||||
"the fragment shader reads location {missing}, which the vertex shader doesn't write"
|
||||
));
|
||||
}
|
||||
Ok(CoreProgram {
|
||||
vertex,
|
||||
fragment,
|
||||
other: names.other,
|
||||
})
|
||||
Ok(CoreProgram { vertex, fragment })
|
||||
}
|
||||
|
||||
/// A bound on a pack's includes, which are pasted every time.
|
||||
const MAX_PASTES: u32 = 1024;
|
||||
const MAX_EXPANDED: usize = 1 << 20;
|
||||
|
||||
/// The file with its includes inlined (`#include <ns:path>`, `#include "relative"` and the same
|
||||
/// with `#moj_import`), `#version` and `#extension` lines dropped. An `#include` is pasted every
|
||||
/// time, as vanilla's shaderc does (the files' `#ifndef` guards keep one copy); a `#moj_import`
|
||||
/// once.
|
||||
/// with `#moj_import`), each file once, `#version` and `#extension` lines dropped.
|
||||
fn expand(
|
||||
text: &str,
|
||||
dir: &str,
|
||||
@@ -142,7 +99,6 @@ fn expand(
|
||||
dir: &str,
|
||||
include: &dyn Fn(&str) -> Option<String>,
|
||||
seen: &mut HashSet<String>,
|
||||
pastes: &mut u32,
|
||||
depth: u32,
|
||||
out: &mut String,
|
||||
) -> Result<(), String> {
|
||||
@@ -153,19 +109,15 @@ fn expand(
|
||||
let directive = line.trim_start().strip_prefix('#').map(str::trim_start);
|
||||
let target = directive.and_then(|d| {
|
||||
d.strip_prefix("include")
|
||||
.map(|t| (t.trim(), false))
|
||||
.or_else(|| d.strip_prefix("moj_import").map(|t| (t.trim(), true)))
|
||||
.or_else(|| d.strip_prefix("moj_import"))
|
||||
.map(str::trim)
|
||||
});
|
||||
if let Some((target, once)) = target {
|
||||
if let Some(target) = target {
|
||||
let id = include_id(target, dir).ok_or_else(|| format!("bad include {target}"))?;
|
||||
if seen.insert(id.clone()) || !once {
|
||||
*pastes += 1;
|
||||
if *pastes > MAX_PASTES {
|
||||
return Err(format!("includes paste more than {MAX_PASTES} files"));
|
||||
}
|
||||
if seen.insert(id.clone()) {
|
||||
let file = include(&id).ok_or_else(|| format!("{id} not found"))?;
|
||||
let parent = &id[..id.rfind('/').map_or(id.len(), |i| i + 1)];
|
||||
walk(&file, parent, include, seen, pastes, depth + 1, out)?;
|
||||
walk(&file, parent, include, seen, depth + 1, out)?;
|
||||
}
|
||||
out.push('\n');
|
||||
} else if directive
|
||||
@@ -176,16 +128,11 @@ fn expand(
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
if out.len() > MAX_EXPANDED {
|
||||
return Err(format!(
|
||||
"includes grow the shader past {MAX_EXPANDED} bytes"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
let mut out = String::new();
|
||||
walk(text, dir, include, &mut HashSet::new(), &mut 0, 0, &mut out)?;
|
||||
walk(text, dir, include, &mut HashSet::new(), 0, &mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -342,16 +289,14 @@ fn block_name(line: &str) -> Option<&str> {
|
||||
let rest = line[at + "uniform".len()..].trim();
|
||||
let name_end = rest.find(|c: char| !is_ident(c)).unwrap_or(rest.len());
|
||||
let (name, tail) = rest.split_at(name_end);
|
||||
// `{` alone, or the whole block on the line (`uniform Day { float daylight; };`).
|
||||
(!name.is_empty() && (tail.trim().is_empty() || tail.trim_start().starts_with('{')))
|
||||
.then_some(name)
|
||||
(!name.is_empty() && matches!(tail.trim(), "" | "{")).then_some(name)
|
||||
}
|
||||
|
||||
/// What the two stages share: the vertex stage's outputs and their locations, and the names of
|
||||
/// What the two stages share: the vertex stage's outputs in location order, and the names of
|
||||
/// the blocks and samplers of other pipelines in binding order.
|
||||
#[derive(Default)]
|
||||
struct Names {
|
||||
varyings: Vec<(String, u32)>,
|
||||
varyings: Vec<String>,
|
||||
other: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -359,8 +304,8 @@ impl Names {
|
||||
fn location(&self, varying: &str) -> Option<u32> {
|
||||
self.varyings
|
||||
.iter()
|
||||
.find(|(v, _)| v == varying)
|
||||
.map(|(_, location)| *location)
|
||||
.position(|v| v == varying)
|
||||
.map(|i| i as u32)
|
||||
}
|
||||
|
||||
/// A block or sampler of another pipeline: its binding (two for a sampler).
|
||||
@@ -372,7 +317,7 @@ impl Names {
|
||||
self.other.len() - 1
|
||||
}
|
||||
};
|
||||
other_binding(index)
|
||||
OTHER_BINDINGS + 2 * index as u32
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,19 +337,7 @@ fn rewrite(
|
||||
writeln!(out, "{ty} {name};").unwrap();
|
||||
}
|
||||
}
|
||||
// Where the pack's own code starts: the glue above it is left as it is.
|
||||
let pack_start = out.len();
|
||||
let mut outputs: Vec<&str> = Vec::new();
|
||||
let explicit: Vec<u32> = if vertex {
|
||||
text.lines()
|
||||
.flat_map(declaration_lines)
|
||||
.filter_map(declaration)
|
||||
.filter(|d| d.storage == "out")
|
||||
.filter_map(|d| d.location)
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
for source_line in text.lines() {
|
||||
for line in declaration_lines(source_line) {
|
||||
if let Some(name) = block_name(line) {
|
||||
@@ -484,21 +417,10 @@ fn rewrite(
|
||||
}
|
||||
"out" if vertex => {
|
||||
// One location each: naga takes scalars and vectors only.
|
||||
let location = decl
|
||||
.location
|
||||
.or_else(|| names.location(decl.name))
|
||||
.unwrap_or_else(|| {
|
||||
(0..)
|
||||
.find(|l| {
|
||||
!explicit.contains(l)
|
||||
&& names.varyings.iter().all(|(_, v)| v != l)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
});
|
||||
let varying = (decl.name.to_owned(), location);
|
||||
if !names.varyings.contains(&varying) {
|
||||
names.varyings.push(varying);
|
||||
}
|
||||
let location = names.location(decl.name).unwrap_or_else(|| {
|
||||
names.varyings.push(decl.name.to_owned());
|
||||
names.varyings.len() as u32 - 1
|
||||
});
|
||||
writeln!(
|
||||
out,
|
||||
"layout(location = {location}) {qualifiers} out {} {};",
|
||||
@@ -507,12 +429,9 @@ fn rewrite(
|
||||
.unwrap();
|
||||
}
|
||||
"in" => {
|
||||
let location = decl
|
||||
.location
|
||||
.or_else(|| names.location(decl.name))
|
||||
.ok_or_else(|| {
|
||||
format!("input {} isn't written by the vertex shader", decl.name)
|
||||
})?;
|
||||
let location = names.location(decl.name).ok_or_else(|| {
|
||||
format!("input {} isn't written by the vertex shader", decl.name)
|
||||
})?;
|
||||
writeln!(
|
||||
out,
|
||||
"layout(location = {location}) {qualifiers} in {} {};",
|
||||
@@ -543,18 +462,11 @@ fn rewrite(
|
||||
if !vertex {
|
||||
return Ok(out);
|
||||
}
|
||||
// The pack's `gl_VertexID` and 26.3's `gl_VertexIndex` both mean vanilla's vertex number
|
||||
// (four per quad, an `int`): both read the glue's expression, which for a pipeline that
|
||||
// draws six vertices per quad turns its own `gl_VertexIndex` back into that number. Only the
|
||||
// pack's code is rewritten, so the glue's expression keeps reading the real index.
|
||||
let vertex_id = format!("({})", glue.vertex_id);
|
||||
let pack = replace_ident(
|
||||
&rename_main(&out[pack_start..]),
|
||||
"gl_VertexIndex",
|
||||
&vertex_id,
|
||||
let mut code = replace_ident(
|
||||
&rename_main(&out),
|
||||
"gl_VertexID",
|
||||
&format!("({})", glue.vertex_id),
|
||||
);
|
||||
let pack = replace_ident(&pack, "gl_VertexID", &vertex_id);
|
||||
let mut code = format!("{}{pack}", &out[..pack_start]);
|
||||
write!(
|
||||
code,
|
||||
"void main() {{\n{}\nhz_main();\n{}\n}}\n",
|
||||
@@ -586,17 +498,10 @@ fn rename_main(text: &str) -> String {
|
||||
}
|
||||
|
||||
/// Parses and validates one stage; errors name the line of the rewritten code.
|
||||
fn compile(
|
||||
stage: ShaderStage,
|
||||
code: &str,
|
||||
defines: &[&str],
|
||||
names: &Names,
|
||||
provided: &dyn Fn(&str) -> bool,
|
||||
) -> Result<naga::Module, String> {
|
||||
fn compile(stage: ShaderStage, code: &str, defines: &[&str]) -> Result<naga::Module, String> {
|
||||
let mut options = Options::from(stage);
|
||||
for define in defines {
|
||||
let (name, value) = define.split_once(' ').unwrap_or((define, ""));
|
||||
options.defines.insert(name.to_owned(), value.to_owned());
|
||||
options.defines.insert((*define).to_owned(), String::new());
|
||||
}
|
||||
let module = Frontend::default().parse(&options, code).map_err(|e| {
|
||||
e.errors
|
||||
@@ -624,16 +529,16 @@ fn compile(
|
||||
})?;
|
||||
let entry = info.get_entry_point(0);
|
||||
for (handle, global) in module.global_variables.iter() {
|
||||
let Some(binding) = global.binding.as_ref().map(|b| b.binding) else {
|
||||
continue;
|
||||
};
|
||||
if binding >= OTHER_BINDINGS && !entry[handle].is_empty() {
|
||||
// The name it was declared with (a block's instance may have none).
|
||||
let index = ((binding - OTHER_BINDINGS) / 2) as usize;
|
||||
let name = names.other.get(index).map_or("?", String::as_str);
|
||||
if provided(name) {
|
||||
continue;
|
||||
}
|
||||
let other = global
|
||||
.binding
|
||||
.as_ref()
|
||||
.is_some_and(|b| b.binding >= OTHER_BINDINGS);
|
||||
if other && !entry[handle].is_empty() {
|
||||
let name = global.name.as_deref().unwrap_or("?");
|
||||
let name = name
|
||||
.strip_prefix("hz_tex_")
|
||||
.or_else(|| name.strip_prefix("hz_smp_"))
|
||||
.unwrap_or(name);
|
||||
return Err(format!("{name} is not a block or sampler of this pipeline"));
|
||||
}
|
||||
}
|
||||
@@ -728,44 +633,6 @@ mod tests {
|
||||
assert_eq!(program.fragment.entry_points.len(), 1);
|
||||
}
|
||||
|
||||
/// A 26.3 pack's `gl_VertexIndex` means vanilla's vertex number, four per quad. The text
|
||||
/// pipeline draws six per glyph, so the pack's code must read the glue's `hz_vertex_id()`
|
||||
/// (as `gl_VertexID` does), never the raw index.
|
||||
#[test]
|
||||
fn a_packs_gl_vertex_index_reads_the_glues_vertex_number() {
|
||||
let vsh = "#version 330\n#include <minecraft:dynamictransforms.glsl>\n#include <minecraft:projection.glsl>\n\
|
||||
layout(location = 0) in vec3 Position;\nlayout(location = 1) in vec4 Color;\nlayout(location = 2) in vec2 UV0;\n\
|
||||
layout(location = 2) out vec4 vertexColor;\nlayout(location = 3) out vec2 texCoord0;\n\
|
||||
void main() {\n int corner = gl_VertexIndex % 4;\n\
|
||||
gl_Position = ProjMat * ModelViewMat * vec4(Position + vec3(float(corner), 0.0, 0.0), 1.0);\n\
|
||||
vertexColor = Color;\n texCoord0 = UV0;\n}\n";
|
||||
let program = translate_core(vsh, VANILLA_FSH, &["IS_GUI"], &include, &GLUE).unwrap();
|
||||
let module = &program.vertex;
|
||||
let named = |name: &str| {
|
||||
module
|
||||
.functions
|
||||
.iter()
|
||||
.find(|(_, f)| f.name.as_deref() == Some(name))
|
||||
.unwrap_or_else(|| panic!("no {name}"))
|
||||
};
|
||||
let (vertex_id, _) = named("hz_vertex_id");
|
||||
let (_, pack_main) = named("hz_main");
|
||||
fn calls(block: &naga::Block, target: naga::Handle<naga::Function>) -> bool {
|
||||
block.iter().any(|statement| match statement {
|
||||
naga::Statement::Call { function, .. } => *function == target,
|
||||
naga::Statement::Block(inner) => calls(inner, target),
|
||||
naga::Statement::If { accept, reject, .. } => {
|
||||
calls(accept, target) || calls(reject, target)
|
||||
}
|
||||
naga::Statement::Loop {
|
||||
body, continuing, ..
|
||||
} => calls(body, target) || calls(continuing, target),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
assert!(calls(&pack_main.body, vertex_id));
|
||||
}
|
||||
|
||||
/// A 26.2-style pack shader as a HUD pack writes it: `#moj_import`, no locations, a flat
|
||||
/// varying, `gl_VertexID`, `texelFetch` and `textureSize` on the glyph atlas, `GameTime`.
|
||||
#[test]
|
||||
@@ -799,96 +666,6 @@ mod tests {
|
||||
assert_eq!(read, [0, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_varying_locations_are_kept() {
|
||||
let vsh = "#version 330\nin vec3 Position;\n\
|
||||
#ifdef IS_GUI\nlayout(location = 4) out float gui;\n#else\nlayout(location = 4) out float world;\n#endif\n\
|
||||
layout(location = 13) out vec4 a;\nout float b;\n\
|
||||
void main() { gl_Position = vec4(Position, 1.0); a = vec4(1.0); b = 1.0;\n\
|
||||
#ifdef IS_GUI\ngui = 1.0;\n#else\nworld = 1.0;\n#endif\n}\n";
|
||||
let fsh = "#version 330\n#ifdef IS_GUI\nlayout(location = 4) in float gui;\n#endif\n\
|
||||
layout(location = 13) in vec4 a;\nin float b;\nout vec4 fragColor;\n\
|
||||
void main() { fragColor = a * b;\n#ifdef IS_GUI\nfragColor *= gui;\n#endif\n}\n";
|
||||
let program = translate_core(vsh, fsh, &["IS_GUI"], &include, &GLUE).unwrap();
|
||||
let mut read = locations(&program.fragment, true);
|
||||
read.sort_unstable();
|
||||
assert_eq!(read, [0, 4, 13]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_include_is_pasted_every_time_and_a_moj_import_once() {
|
||||
let file = |id: &str| {
|
||||
(id == "minecraft:shaders/include/g.glsl")
|
||||
.then(|| "#ifndef G\n#define G\nconst float g = 1.0;\n#endif\n".to_owned())
|
||||
};
|
||||
let twice =
|
||||
"#ifdef A\n#include <minecraft:g.glsl>\n#else\n#include <minecraft:g.glsl>\n#endif\n";
|
||||
let expanded = expand(twice, "minecraft:shaders/core/", &file).unwrap();
|
||||
assert_eq!(expanded.matches("const float g").count(), 2);
|
||||
let imported = expand(
|
||||
&twice.replace("#include", "#moj_import"),
|
||||
"minecraft:shaders/core/",
|
||||
&file,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(imported.matches("const float g").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_location_another_branch_gave_the_same_name_isnt_reused() {
|
||||
let vsh = "#version 330\nin vec3 Position;\n\
|
||||
#ifdef IS_GUI\nlayout(location = 0) out vec4 vertexColor;\n\
|
||||
#else\nlayout(location = 1) out vec4 vertexColor;\nlayout(location = 0) out vec4 worldOther;\n#endif\n\
|
||||
void main() { gl_Position = vec4(Position, 1.0); vertexColor = vec4(1.0);\n\
|
||||
#ifndef IS_GUI\nworldOther = vec4(0.5);\n#endif\n}\n";
|
||||
let fsh = "#version 330\n\
|
||||
#ifdef IS_GUI\nlayout(location = 0) in vec4 vertexColor;\n\
|
||||
#else\nlayout(location = 1) in vec4 vertexColor;\nlayout(location = 0) in vec4 worldOther;\n#endif\n\
|
||||
out vec4 fragColor;\nvoid main() { fragColor = vertexColor;\n\
|
||||
#ifndef IS_GUI\nfragColor *= worldOther;\n#endif\n}\n";
|
||||
let world = translate_core(vsh, fsh, &[], &include, &GLUE).unwrap();
|
||||
let mut written = locations(&world.vertex, false);
|
||||
written.sort_unstable();
|
||||
assert_eq!(written, [0, 1]);
|
||||
let mut read = locations(&world.fragment, true);
|
||||
read.sort_unstable();
|
||||
assert_eq!(read, [0, 1]);
|
||||
let gui = translate_core(vsh, fsh, &["IS_GUI"], &include, &GLUE).unwrap();
|
||||
assert_eq!(locations(&gui.vertex, false), [0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn includes_that_multiply_are_refused_once_over_budget() {
|
||||
let file = |id: &str| {
|
||||
let level: u32 = id
|
||||
.strip_prefix("minecraft:shaders/include/l")?
|
||||
.strip_suffix(".glsl")?
|
||||
.parse()
|
||||
.ok()?;
|
||||
let next = format!("#include <minecraft:l{}.glsl>\n", level + 1).repeat(4);
|
||||
Some(format!(
|
||||
"#ifndef L{level}\n#define L{level}\n{}#endif\n",
|
||||
if level < 15 { next.as_str() } else { "" }
|
||||
))
|
||||
};
|
||||
let error = expand(
|
||||
"#include <minecraft:l0.glsl>\n",
|
||||
"minecraft:shaders/core/",
|
||||
&file,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.contains("paste more than"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_define_with_a_value_is_defined_to_it() {
|
||||
let vsh = "#version 330\nin vec3 Position;\n\
|
||||
void main() { gl_Position = vec4(Position, float(COUNT)); }\n";
|
||||
let fsh =
|
||||
"#version 330\nout vec4 fragColor;\nvoid main() { fragColor = vec4(COUNT / 4); }\n";
|
||||
assert!(translate_core(vsh, fsh, &["COUNT 8"], &include, &GLUE).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minified_varying_declarations_are_rewritten() {
|
||||
let vsh = "#version 330\nout float sphericalVertexDistance;out float cylindricalVertexDistance;\
|
||||
@@ -909,63 +686,6 @@ mod tests {
|
||||
translate_core(vsh, fsh, &[], &include, &GLUE).unwrap();
|
||||
}
|
||||
|
||||
/// A block declared on one line, as packs write a one-float uniform, is bound like one
|
||||
/// over several lines; the blocks and samplers the caller provides are accepted, in binding
|
||||
/// order, and one it doesn't provide is not.
|
||||
#[test]
|
||||
fn a_block_on_one_line_is_bound_and_provided_blocks_are_accepted() {
|
||||
let vsh = "#version 330\nlayout(std140) uniform Day { float daylight; };\nout vec4 vertexColor;\n\
|
||||
void main() { vertexColor = vec4(daylight); gl_Position = vec4(0.0); }\n";
|
||||
let fsh = "#version 330\nuniform sampler2D Bounds;\nin vec4 vertexColor;\nout vec4 fragColor;\n\
|
||||
void main() { fragColor = vertexColor * texture(Bounds, vec2(0.5)); }\n";
|
||||
let both = |name: &str| name == "Day" || name == "Bounds";
|
||||
let program = translate_core_with(vsh, fsh, &[], &include, &GLUE, &both).unwrap();
|
||||
assert_eq!(program.other, ["Day", "Bounds"]);
|
||||
// The bindings the caller gives them at.
|
||||
let bindings = |module: &naga::Module| -> Vec<(Option<String>, u32)> {
|
||||
module
|
||||
.global_variables
|
||||
.iter()
|
||||
.filter_map(|(_, g)| Some((g.name.clone(), g.binding.as_ref()?.binding)))
|
||||
.collect()
|
||||
};
|
||||
assert!(
|
||||
bindings(&program.vertex)
|
||||
.iter()
|
||||
.any(|(_, b)| *b == other_binding(0)),
|
||||
"{:?}",
|
||||
bindings(&program.vertex)
|
||||
);
|
||||
let fragment = bindings(&program.fragment);
|
||||
assert!(
|
||||
fragment.contains(&(Some("hz_tex_Bounds".into()), other_binding(1))),
|
||||
"{fragment:?}"
|
||||
);
|
||||
assert!(
|
||||
fragment.contains(&(Some("hz_smp_Bounds".into()), other_binding(1) + 1)),
|
||||
"{fragment:?}"
|
||||
);
|
||||
let error = translate_core(vsh, fsh, &[], &include, &GLUE).unwrap_err();
|
||||
assert!(error.contains("Day is not a block or sampler"), "{error}");
|
||||
let only_day = |name: &str| name == "Day";
|
||||
let error = translate_core_with(vsh, fsh, &[], &include, &GLUE, &only_day).unwrap_err();
|
||||
assert!(
|
||||
error.contains("Bounds is not a block or sampler"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Only a uniform block's declaration names a block, written over several lines or on one.
|
||||
#[test]
|
||||
fn block_names_are_read_from_block_declarations_only() {
|
||||
assert_eq!(block_name("layout(std140) uniform Day {"), Some("Day"));
|
||||
assert_eq!(block_name("uniform Day { float daylight; };"), Some("Day"));
|
||||
assert_eq!(block_name("uniform Foo{"), Some("Foo"));
|
||||
assert_eq!(block_name("uniform Globals"), Some("Globals"));
|
||||
assert_eq!(block_name("uniform float x;"), None);
|
||||
assert_eq!(block_name("uniform sampler2D Sampler0;"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_horizon_does_not_provide_is_an_error() {
|
||||
let fsh = "#version 330\nin vec4 vertexColor;\nout vec4 fragColor;\nvoid main() { fragColor = vertexColor; }\n";
|
||||
|
||||
@@ -1,603 +0,0 @@
|
||||
//! GLSL that vanilla's GL compiler takes and naga's front end doesn't (#9386), rewritten into
|
||||
//! what both take:
|
||||
//! - a function with `sampler2D` parameters is copied once per set of samplers its callers pass
|
||||
//! (all of them a shader's global samplers), the copy reading those samplers by name;
|
||||
//! - an unsized array (`const float[] w = float[](...)`, `float w[] = ...`) takes the size its
|
||||
//! initializer gives.
|
||||
|
||||
/// The longest shader rewritten (vanilla's, includes expanded, are a few KiB).
|
||||
const MAX_LEN: usize = 64 * 1024;
|
||||
|
||||
/// The bytes the rewrite may scan for calls (the whole text per call it rewrites): thousands of
|
||||
/// calls leave the text as it was rather than freeze a load.
|
||||
const SCAN_BUDGET: usize = 64 << 20;
|
||||
|
||||
/// `text` with the rewrites, where they apply; anything they can't follow is left as it was.
|
||||
pub(super) fn fixups(text: &str) -> String {
|
||||
if text.len() > MAX_LEN {
|
||||
return text.to_owned();
|
||||
}
|
||||
size_unsized_arrays(&specialize_sampler_params(text))
|
||||
}
|
||||
|
||||
fn is_ident(c: char) -> bool {
|
||||
c.is_ascii_alphanumeric() || c == '_'
|
||||
}
|
||||
|
||||
/// A name a sampler can have (`sampler2D ;` gives none, `t[2]` isn't one).
|
||||
fn is_name(s: &str) -> bool {
|
||||
s.chars().all(is_ident) && s.starts_with(|c: char| !c.is_ascii_digit()) && s != "sampler2D"
|
||||
}
|
||||
|
||||
/// The identifier that ends right before byte `end` (whitespace skipped), and where it starts.
|
||||
fn ident_before(text: &str, end: usize) -> Option<(usize, &str)> {
|
||||
let trimmed = text[..end].trim_end();
|
||||
let start = trimmed
|
||||
.char_indices()
|
||||
.rev()
|
||||
.take_while(|(_, c)| is_ident(*c))
|
||||
.last()?
|
||||
.0;
|
||||
let ident = &trimmed[start..];
|
||||
(!ident.is_empty() && !ident.starts_with(|c: char| c.is_ascii_digit()))
|
||||
.then_some((start, ident))
|
||||
}
|
||||
|
||||
/// For each byte of `text`, the byte of the bracket closing the one opening there (counting
|
||||
/// that kind of bracket only), or `None`: one pass, so a scan asking for many stays linear.
|
||||
fn closings(text: &str) -> Vec<Option<usize>> {
|
||||
let mut out = vec![None; text.len()];
|
||||
let mut open: [Vec<usize>; 3] = Default::default();
|
||||
for (i, b) in text.bytes().enumerate() {
|
||||
let (kind, opens) = match b {
|
||||
b'(' => (0, true),
|
||||
b')' => (0, false),
|
||||
b'[' => (1, true),
|
||||
b']' => (1, false),
|
||||
b'{' => (2, true),
|
||||
b'}' => (2, false),
|
||||
_ => continue,
|
||||
};
|
||||
if opens {
|
||||
open[kind].push(i);
|
||||
} else if let Some(at) = open[kind].pop() {
|
||||
out[at] = Some(i);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The comma-separated parts of `list` at its own nesting level.
|
||||
fn split_top(list: &str) -> Vec<&str> {
|
||||
let mut parts = Vec::new();
|
||||
let (mut depth, mut start) = (0i32, 0);
|
||||
for (i, c) in list.char_indices() {
|
||||
match c {
|
||||
'(' | '[' | '{' => depth += 1,
|
||||
')' | ']' | '}' => depth -= 1,
|
||||
',' if depth == 0 => {
|
||||
parts.push(&list[start..i]);
|
||||
start = i + 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !list[start..].trim().is_empty() || !parts.is_empty() {
|
||||
parts.push(&list[start..]);
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
/// A function at the file's top level whose parameters include a `sampler2D`.
|
||||
struct SamplerFn {
|
||||
name: String,
|
||||
/// The header from its return type to its `{` (or `;` for a prototype), and the body after.
|
||||
start: usize,
|
||||
end: usize,
|
||||
params: Vec<String>,
|
||||
/// The indices of the `sampler2D` parameters and their names.
|
||||
samplers: Vec<(usize, String)>,
|
||||
/// `None` for a prototype.
|
||||
body: Option<String>,
|
||||
return_type: String,
|
||||
}
|
||||
|
||||
fn sampler_functions(text: &str) -> Vec<SamplerFn> {
|
||||
let closing = closings(text);
|
||||
let mut out = Vec::new();
|
||||
let mut depth = 0i32;
|
||||
let bytes = text.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
match bytes[i] {
|
||||
b'{' => depth += 1,
|
||||
b'}' => depth -= 1,
|
||||
b'(' if depth == 0 => {
|
||||
if let (Some((name_at, name)), Some(close)) = (ident_before(text, i), closing[i])
|
||||
&& let Some((type_at, return_type)) = ident_before(text, name_at)
|
||||
{
|
||||
let params: Vec<String> = split_top(&text[i + 1..close])
|
||||
.into_iter()
|
||||
.map(|p| p.trim().to_owned())
|
||||
.collect();
|
||||
let samplers: Vec<(usize, String)> = params
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| p.split_whitespace().any(|w| w == "sampler2D"))
|
||||
.map(|(n, p)| (n, p.split_whitespace().last().unwrap_or("").to_owned()))
|
||||
.collect();
|
||||
// One without a name is left for naga to refuse.
|
||||
if !samplers.iter().all(|(_, name)| is_name(name)) {
|
||||
i = close + 1;
|
||||
continue;
|
||||
}
|
||||
let after = text[close + 1..].trim_start();
|
||||
let next = close + 1 + (text[close + 1..].len() - after.len());
|
||||
if !samplers.is_empty() && (after.starts_with('{') || after.starts_with(';')) {
|
||||
let (end, body) = if after.starts_with('{') {
|
||||
let Some(body_end) = closing[next] else {
|
||||
break;
|
||||
};
|
||||
(body_end + 1, Some(text[next..=body_end].to_owned()))
|
||||
} else {
|
||||
(next + 1, None)
|
||||
};
|
||||
out.push(SamplerFn {
|
||||
name: name.to_owned(),
|
||||
start: type_at,
|
||||
end,
|
||||
params,
|
||||
samplers,
|
||||
body,
|
||||
return_type: return_type.to_owned(),
|
||||
});
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Any other header: no other one sits in its parentheses.
|
||||
if let Some(close) = closing[i] {
|
||||
i = close + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The calls to `name` outside `skip` (its own header): each call's span and arguments. The
|
||||
/// bytes read are added to `scanned`.
|
||||
fn calls<'a>(
|
||||
scanned: &std::cell::Cell<usize>,
|
||||
text: &'a str,
|
||||
name: &str,
|
||||
skip: &[(usize, usize)],
|
||||
) -> Vec<(usize, usize, Vec<&'a str>)> {
|
||||
scanned.set(scanned.get() + text.len());
|
||||
let closing = closings(text);
|
||||
let mut out = Vec::new();
|
||||
let mut from = 0;
|
||||
while let Some(found) = text[from..].find(name) {
|
||||
let at = from + found;
|
||||
from = at + name.len();
|
||||
let before = text[..at].chars().next_back();
|
||||
let rest = &text[at + name.len()..];
|
||||
let open = at + name.len() + (rest.len() - rest.trim_start().len());
|
||||
if before.is_some_and(is_ident)
|
||||
|| !text[open..].starts_with('(')
|
||||
|| skip.iter().any(|(s, e)| (*s..*e).contains(&at))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(close) = closing[open] else {
|
||||
continue;
|
||||
};
|
||||
scanned.set(scanned.get() + close - open);
|
||||
out.push((at, close + 1, split_top(&text[open + 1..close])));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Every `from` standing as an identifier on its own, but not a member after a `.` (a
|
||||
/// parameter `s` leaves the swizzle `c.s` alone).
|
||||
fn replace_ident(text: &str, from: &str, to: &str) -> String {
|
||||
if from.is_empty() {
|
||||
return text.to_owned();
|
||||
}
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut done = 0;
|
||||
while let Some(found) = text[done..].find(from) {
|
||||
let at = done + found;
|
||||
// Read in the whole text: a match right after another (`ss`) is part of it.
|
||||
let before = text[..at].trim_end().chars().next_back();
|
||||
let joined = text[..at].chars().next_back().is_some_and(is_ident);
|
||||
let after = text[at + from.len()..].chars().next();
|
||||
out.push_str(&text[done..at]);
|
||||
if joined || before == Some('.') || after.is_some_and(is_ident) {
|
||||
out.push_str(from);
|
||||
} else {
|
||||
out.push_str(to);
|
||||
}
|
||||
done = at + from.len();
|
||||
}
|
||||
out.push_str(&text[done..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// A body with its sampler parameters reading `chosen`, all at once: a parameter named like
|
||||
/// another's sampler (`mix2(sampler2D Sampler0, sampler2D Sampler1)` called with them swapped)
|
||||
/// still reads its own.
|
||||
fn read_samplers(body: &str, params: &[(usize, String)], chosen: &[String]) -> String {
|
||||
let mut body = body.to_owned();
|
||||
for (n, (_, param)) in params.iter().enumerate() {
|
||||
body = replace_ident(&body, param, &format!("hz_sampler_param_{n}"));
|
||||
}
|
||||
for (n, sampler) in chosen.iter().enumerate().take(params.len()) {
|
||||
body = replace_ident(&body, &format!("hz_sampler_param_{n}"), sampler);
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
/// The byte after `uniform sampler2D <name>;`, where the shader declares that sampler.
|
||||
fn sampler_declaration_end(text: &str, name: &str) -> Option<usize> {
|
||||
let mut from = 0;
|
||||
while let Some(found) = text[from..].find("sampler2D") {
|
||||
let at = from + found;
|
||||
from = at + "sampler2D".len();
|
||||
let before = text[..at].trim_end();
|
||||
if !before.ends_with("uniform")
|
||||
|| before[..before.len() - 7]
|
||||
.chars()
|
||||
.next_back()
|
||||
.is_some_and(is_ident)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let rest = &text[from..];
|
||||
let ident = rest.trim_start();
|
||||
let len = ident.chars().take_while(|c| is_ident(*c)).count();
|
||||
if &ident[..len] != name {
|
||||
continue;
|
||||
}
|
||||
let after = ident[len..].trim_start();
|
||||
if after.starts_with(';') {
|
||||
return Some(text.len() - after.len() + 1);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The samplers a call passes for `f`'s `sampler2D` parameters, in order.
|
||||
fn chosen_samplers(f: &SamplerFn, args: &[&str]) -> Vec<String> {
|
||||
f.samplers
|
||||
.iter()
|
||||
.filter_map(|(n, _)| args.get(*n).map(|a| a.trim().to_owned()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One pass per function with `sampler2D` parameters, until none is left or one can't be
|
||||
/// followed (a caller passes something that is no sampler's name).
|
||||
fn specialize_sampler_params(original: &str) -> String {
|
||||
let mut text = original.to_owned();
|
||||
// Bytes scanned for calls so far, against [`SCAN_BUDGET`].
|
||||
let scanned = std::cell::Cell::new(0usize);
|
||||
let spent = || scanned.get() > SCAN_BUDGET;
|
||||
for _ in 0..16 {
|
||||
let functions = sampler_functions(&text);
|
||||
// From the outside in: no call inside another function still to be copied.
|
||||
let inside_another = |f: &SamplerFn, at: usize| {
|
||||
functions
|
||||
.iter()
|
||||
.any(|g| g.name != f.name && g.body.is_some() && (g.start..g.end).contains(&at))
|
||||
};
|
||||
let spans_of = |f: &SamplerFn| -> Vec<(usize, usize)> {
|
||||
functions
|
||||
.iter()
|
||||
.filter(|g| g.name == f.name)
|
||||
.map(|g| (g.start, g.end))
|
||||
.collect()
|
||||
};
|
||||
let Some(f) = functions.iter().find(|f| {
|
||||
f.body.is_some()
|
||||
&& !spent()
|
||||
&& calls(&scanned, &text, &f.name, &spans_of(f))
|
||||
.iter()
|
||||
.all(|(at, ..)| !inside_another(f, *at))
|
||||
}) else {
|
||||
if spent() {
|
||||
break;
|
||||
}
|
||||
// None left to copy.
|
||||
return text;
|
||||
};
|
||||
let spans = spans_of(f);
|
||||
let found = calls(&scanned, &text, &f.name, &spans);
|
||||
// Each call's samplers must be plain names (a global's, or a later pass's parameter).
|
||||
let mut variants: Vec<Vec<String>> = Vec::new();
|
||||
let mut call_starts: Vec<usize> = Vec::new();
|
||||
let mut followable = true;
|
||||
for (at, _, args) in &found {
|
||||
let chosen = chosen_samplers(f, args);
|
||||
if chosen.len() != f.samplers.len() || !chosen.iter().all(|a| is_name(a)) {
|
||||
followable = false;
|
||||
break;
|
||||
}
|
||||
call_starts.push(*at);
|
||||
if !variants.contains(&chosen) {
|
||||
variants.push(chosen);
|
||||
}
|
||||
if variants.len() > 64 {
|
||||
followable = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Overloads (`tap(sampler2D, vec2)` and `tap(sampler2D, vec3)`) each keep their own
|
||||
// body; a call picks its samplers by position, so they must sit at the same places.
|
||||
let same_places = functions.iter().filter(|g| g.name == f.name).all(|g| {
|
||||
g.samplers
|
||||
.iter()
|
||||
.map(|s| s.0)
|
||||
.eq(f.samplers.iter().map(|s| s.0))
|
||||
});
|
||||
if !followable || !same_places || spent() {
|
||||
break;
|
||||
}
|
||||
// A body may call another overload of itself with its own sampler (vanilla's
|
||||
// `sampleNearest` calls the six-argument one): once its sampler parameters read a
|
||||
// sampler, those calls name that copy too, and their sets join the variants.
|
||||
let rewrite_own_calls = |body: &str| -> Option<(String, Vec<Vec<String>>)> {
|
||||
let mut body = body.to_owned();
|
||||
let mut found = Vec::new();
|
||||
let mut starts: Vec<usize> = calls(&scanned, &body, &f.name, &[])
|
||||
.iter()
|
||||
.map(|c| c.0)
|
||||
.collect();
|
||||
starts.sort_unstable_by(|a, b| b.cmp(a));
|
||||
for start in starts {
|
||||
if spent() {
|
||||
return None;
|
||||
}
|
||||
let (end, chosen, with) = {
|
||||
let (_, end, args) = calls(&scanned, &body, &f.name, &[])
|
||||
.into_iter()
|
||||
.find(|c| c.0 == start)?;
|
||||
let chosen = chosen_samplers(f, &args);
|
||||
let rest: Vec<&str> = args
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(n, _)| !f.samplers.iter().any(|(s, _)| s == n))
|
||||
.map(|(_, a)| a.trim())
|
||||
.collect();
|
||||
let with = format!("{}_hz_{}({})", f.name, chosen.join("_"), rest.join(", "));
|
||||
(end, chosen, with)
|
||||
};
|
||||
if chosen.len() != f.samplers.len() || !chosen.iter().all(|a| is_name(a)) {
|
||||
return None;
|
||||
}
|
||||
found.push(chosen);
|
||||
body.replace_range(start..end, &with);
|
||||
}
|
||||
Some((body, found))
|
||||
};
|
||||
let specialize = |g: &SamplerFn, chosen: &[String]| {
|
||||
let body = read_samplers(g.body.as_deref().unwrap_or_default(), &g.samplers, chosen);
|
||||
rewrite_own_calls(&body)
|
||||
};
|
||||
let same: Vec<&SamplerFn> = functions.iter().filter(|g| g.name == f.name).collect();
|
||||
let mut next = 0;
|
||||
while followable && next < variants.len() {
|
||||
let chosen = variants[next].clone();
|
||||
for g in same.iter().filter(|g| g.body.is_some()) {
|
||||
match specialize(g, &chosen) {
|
||||
Some((_, inner)) => {
|
||||
for v in inner {
|
||||
if !variants.contains(&v) {
|
||||
variants.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => followable = false,
|
||||
}
|
||||
}
|
||||
next += 1;
|
||||
followable &= variants.len() <= 64 && !spent();
|
||||
}
|
||||
if !followable {
|
||||
break;
|
||||
}
|
||||
// One copy of an overload per set of samplers its callers pass, keeping its other
|
||||
// parameters; a prototype becomes the copies' prototypes.
|
||||
let signature = |g: &SamplerFn, chosen: &[String]| {
|
||||
let params: Vec<&str> = g
|
||||
.params
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(n, _)| !g.samplers.iter().any(|(s, _)| s == n))
|
||||
.map(|(_, p)| p.as_str())
|
||||
.collect();
|
||||
format!(
|
||||
"{} {}_hz_{}({})",
|
||||
g.return_type,
|
||||
g.name,
|
||||
chosen.join("_"),
|
||||
params.join(", ")
|
||||
)
|
||||
};
|
||||
let copies_of = |g: &SamplerFn| -> String {
|
||||
variants
|
||||
.iter()
|
||||
.map(|chosen| match &g.body {
|
||||
Some(_) => {
|
||||
let body = specialize(g, chosen).map(|(b, _)| b).unwrap_or_default();
|
||||
format!("{} {body}\n", signature(g, chosen))
|
||||
}
|
||||
None => format!("{};\n", signature(g, chosen)),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
// Rebuild from the back: the calls, then the function's own spans (each prototype and
|
||||
// definition becomes its copies). A call nested in another's arguments starts after it,
|
||||
// so it is rewritten first, and the call around it is read again from the text as it is
|
||||
// then. A copy reads its samplers by name, so definitions above their samplers'
|
||||
// declaration (vanilla's `sample_lightmap.glsl` is included above `uniform sampler2D
|
||||
// Sampler2;`) move after it, together and in their order, as long as no call comes first.
|
||||
let first_call = call_starts.iter().copied().min().unwrap_or(usize::MAX);
|
||||
let declared = variants
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter_map(|s| sampler_declaration_end(&text, s))
|
||||
.max();
|
||||
let mut edits: Vec<(usize, Option<(usize, String)>)> =
|
||||
call_starts.into_iter().map(|at| (at, None)).collect();
|
||||
let (mut moved, mut move_to) = (String::new(), None);
|
||||
for g in &same {
|
||||
match (
|
||||
&g.body,
|
||||
declared.filter(|&end| end > g.start && end <= first_call),
|
||||
) {
|
||||
(Some(_), Some(end)) => {
|
||||
edits.push((g.start, Some((g.end, String::new()))));
|
||||
moved.push_str(&copies_of(g));
|
||||
move_to = Some(end);
|
||||
}
|
||||
_ => edits.push((g.start, Some((g.end, copies_of(g))))),
|
||||
}
|
||||
}
|
||||
if let Some(end) = move_to {
|
||||
edits.push((end, Some((end, format!("\n{moved}")))));
|
||||
}
|
||||
// Pack text is untrusted: edits that overlap, or off the text, leave it as it was.
|
||||
let mut spans: Vec<(usize, usize)> = edits
|
||||
.iter()
|
||||
.filter_map(|(s, e)| e.as_ref().map(|(end, _)| (*s, *end)))
|
||||
.collect();
|
||||
spans.sort_unstable();
|
||||
if spans.windows(2).any(|w| w[1].0 < w[0].1) {
|
||||
break;
|
||||
}
|
||||
edits.sort_by_key(|(start, _)| std::cmp::Reverse(*start));
|
||||
let mut sound = true;
|
||||
for (start, edit) in edits {
|
||||
let (end, with) = match edit {
|
||||
Some(span) => span,
|
||||
None => {
|
||||
let found = calls(&scanned, &text, &f.name, &[])
|
||||
.into_iter()
|
||||
.find(|c| c.0 == start);
|
||||
let Some((_, end, args)) = found else {
|
||||
continue;
|
||||
};
|
||||
let rest: Vec<&str> = args
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(n, _)| !f.samplers.iter().any(|(s, _)| s == n))
|
||||
.map(|(_, a)| a.trim())
|
||||
.collect();
|
||||
let copy = format!("{}_hz_{}", f.name, chosen_samplers(f, &args).join("_"));
|
||||
(end, format!("{copy}({})", rest.join(", ")))
|
||||
}
|
||||
};
|
||||
if start > end || text.get(start..end).is_none() || spent() {
|
||||
sound = false;
|
||||
break;
|
||||
}
|
||||
text.replace_range(start..end, &with);
|
||||
}
|
||||
if !sound {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// One that couldn't be followed: the shader as written, never half rewritten.
|
||||
original.to_owned()
|
||||
}
|
||||
|
||||
/// Each array declared without a size that an initializer sizes: `T[] name = T[](a, b)` and
|
||||
/// `T name[] = T[](a, b)` (or `T[2](...)`) are written with the size.
|
||||
fn size_unsized_arrays(text: &str) -> String {
|
||||
let mut text = text.to_owned();
|
||||
let mut closing = closings(&text);
|
||||
// Each array sized reads the text's brackets again: a few hundred are plenty.
|
||||
let mut left = 256;
|
||||
let mut from = 0;
|
||||
while let Some(found) = text[from..].find('=') {
|
||||
let eq = from + found;
|
||||
from = eq + 1;
|
||||
// `==`, `<=`, `>=`, `!=`, `+=` ... are no initializers.
|
||||
let prev = text[..eq].chars().next_back();
|
||||
let next = text[eq + 1..].chars().next();
|
||||
if matches!(
|
||||
prev,
|
||||
Some('=' | '<' | '>' | '!' | '+' | '-' | '*' | '/' | '%' | '&' | '|' | '^')
|
||||
) || next == Some('=')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// The constructor: `T [ n? ] (`.
|
||||
let rest = &text[eq + 1..];
|
||||
let ty_start = eq + 1 + (rest.len() - rest.trim_start().len());
|
||||
let ty_len = text[ty_start..]
|
||||
.chars()
|
||||
.take_while(|c| is_ident(*c))
|
||||
.count();
|
||||
if ty_len == 0 {
|
||||
continue;
|
||||
}
|
||||
let after_ty = &text[ty_start + ty_len..];
|
||||
let bracket = ty_start + ty_len + (after_ty.len() - after_ty.trim_start().len());
|
||||
if !text[bracket..].starts_with('[') {
|
||||
continue;
|
||||
}
|
||||
let Some(bracket_end) = closing[bracket] else {
|
||||
continue;
|
||||
};
|
||||
let after_bracket = &text[bracket_end + 1..];
|
||||
let paren = bracket_end + 1 + (after_bracket.len() - after_bracket.trim_start().len());
|
||||
if !text[paren..].starts_with('(') {
|
||||
continue;
|
||||
}
|
||||
let Some(paren_end) = closing[paren] else {
|
||||
continue;
|
||||
};
|
||||
// The declarator: `name []` or `T [] name`, between the statement's start and `=`.
|
||||
// (Looked for on the line, at most 256 bytes back, so a long line stays linear.)
|
||||
let mut back = eq.saturating_sub(256);
|
||||
while !text.is_char_boundary(back) {
|
||||
back += 1;
|
||||
}
|
||||
let statement_start = text[back..eq]
|
||||
.rfind([';', '{', '}', '\n'])
|
||||
.map_or(back, |i| back + i + 1);
|
||||
let declarator = &text[statement_start..eq];
|
||||
let Some(empty) = declarator.rfind('[') else {
|
||||
continue;
|
||||
};
|
||||
let inside = &declarator[empty + 1..];
|
||||
let Some(close) = inside.find(']') else {
|
||||
continue;
|
||||
};
|
||||
if !inside[..close].trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let size = split_top(&text[paren + 1..paren_end]).len().to_string();
|
||||
// Sized in the constructor too, where it isn't.
|
||||
if text[bracket + 1..bracket_end].trim().is_empty() {
|
||||
text.replace_range(bracket + 1..bracket_end, &size);
|
||||
}
|
||||
let at = statement_start + empty + 1;
|
||||
text.replace_range(at..at + close, &size);
|
||||
// The declarator is before `=`, so `=` moved by what its size changed.
|
||||
from = eq + 1 + size.len() - close;
|
||||
closing = closings(&text);
|
||||
left -= 1;
|
||||
if left == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,291 +0,0 @@
|
||||
//! The rewrites on GLSL a pack writes, with the text each gives written out by hand.
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_sampler_parameter_becomes_a_copy_per_sampler_passed() {
|
||||
let text = "vec4 blur(sampler2D tex, vec2 uv);\n\
|
||||
vec4 blur(sampler2D tex, vec2 uv) { return texture(tex, uv) + texture(tex, uv * 2.0); }\n\
|
||||
void main() { vec4 a = blur(Sampler0, uv); vec4 b = blur( Sampler1 , uv + 1.0); vec4 c = blur(Sampler0, uv); }\n";
|
||||
let out = specialize_sampler_params(text);
|
||||
assert!(!out.contains("sampler2D"), "{out}");
|
||||
assert!(out.contains(
|
||||
"vec4 blur_hz_Sampler0(vec2 uv) { return texture(Sampler0, uv) + texture(Sampler0, uv * 2.0); }"
|
||||
));
|
||||
assert!(out.contains("vec4 blur_hz_Sampler1(vec2 uv)"));
|
||||
assert!(out.contains("blur_hz_Sampler0(uv)"));
|
||||
assert!(out.contains("blur_hz_Sampler1(uv + 1.0)"));
|
||||
// One copy per sampler, not per call (and its prototype, from the prototype above).
|
||||
assert_eq!(out.matches("vec4 blur_hz_Sampler0(vec2 uv) {").count(), 1);
|
||||
assert_eq!(out.matches("vec4 blur_hz_Sampler0(vec2 uv);").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sampler_passed_on_through_another_function_is_followed() {
|
||||
let text = "vec4 inner(sampler2D t, vec2 uv) { return texture(t, uv); }\n\
|
||||
vec4 outer(vec2 uv, sampler2D s) { return inner(s, uv); }\n\
|
||||
void main() { vec4 a = outer(uv, Sampler2); }\n";
|
||||
let out = specialize_sampler_params(text);
|
||||
assert!(!out.contains("sampler2D"), "{out}");
|
||||
assert!(out.contains("outer_hz_Sampler2(uv)"), "{out}");
|
||||
assert!(out.contains("inner_hz_Sampler2(uv)"), "{out}");
|
||||
}
|
||||
|
||||
/// A call in another call's arguments is rewritten before the call around it, which is
|
||||
/// then read again: neither edit lands on the other's old text.
|
||||
#[test]
|
||||
fn a_call_nested_in_another_calls_arguments_is_rewritten_inside_out() {
|
||||
let text = "vec2 tap(sampler2D s, vec2 uv) { return texture(s, uv).xy; }\n\
|
||||
void main() { vec2 a = tap(Sampler0, tap(Sampler0, uv));\n\
|
||||
vec2 b = tap( Sampler1 , tap( Sampler0 , uv ) ); }\n";
|
||||
let out = specialize_sampler_params(text);
|
||||
assert!(!out.contains("sampler2D") && !out.contains("tap("), "{out}");
|
||||
assert!(
|
||||
out.contains("vec2 a = tap_hz_Sampler0(tap_hz_Sampler0(uv));"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("vec2 b = tap_hz_Sampler1(tap_hz_Sampler0(uv)); }"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(out.contains("vec2 tap_hz_Sampler0(vec2 uv) { return texture(Sampler0, uv).xy; }"));
|
||||
assert!(out.contains("vec2 tap_hz_Sampler1(vec2 uv) { return texture(Sampler1, uv).xy; }"));
|
||||
}
|
||||
|
||||
/// Overloads that differ past their sampler each keep their own body in their copies.
|
||||
#[test]
|
||||
fn overloads_with_a_sampler_each_keep_their_own_body() {
|
||||
let text = "vec4 tap(sampler2D s, vec2 p) { return texture(s, p); }\n\
|
||||
vec4 tap(sampler2D s, vec3 p) { return texture(s, p.xy / p.z); }\n\
|
||||
void main() { vec4 a = tap(Sampler0, uv); vec4 b = tap(Sampler0, vec3(uv, 2.0)); }\n";
|
||||
let out = specialize_sampler_params(text);
|
||||
assert!(!out.contains("sampler2D"), "{out}");
|
||||
assert!(
|
||||
out.contains("vec4 tap_hz_Sampler0(vec2 p) { return texture(Sampler0, p); }"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("vec4 tap_hz_Sampler0(vec3 p) { return texture(Sampler0, p.xy / p.z); }"),
|
||||
"{out}"
|
||||
);
|
||||
assert_eq!(out.matches("vec4 tap_hz_Sampler0(").count(), 2, "{out}");
|
||||
assert!(out.contains("vec4 a = tap_hz_Sampler0(uv);"), "{out}");
|
||||
assert!(
|
||||
out.contains("vec4 b = tap_hz_Sampler0(vec3(uv, 2.0));"),
|
||||
"{out}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A copy reads its samplers by name, so it goes after a sampler declared below the helper
|
||||
/// (vanilla's world `text.vsh` includes `sample_lightmap.glsl` above `Sampler2`).
|
||||
#[test]
|
||||
fn a_copy_goes_after_its_samplers_declaration() {
|
||||
let text = "vec4 tap(sampler2D s, vec2 uv) { return texture(s, uv); }\n\
|
||||
uniform sampler2D Sampler2;\n\
|
||||
void main() { vec4 c = tap(Sampler2, vec2(0.5)); }\n";
|
||||
let out = specialize_sampler_params(text);
|
||||
let declared = out
|
||||
.find("uniform sampler2D Sampler2;")
|
||||
.expect("declaration kept");
|
||||
let copy = out
|
||||
.find("vec4 tap_hz_Sampler2(vec2 uv)")
|
||||
.expect("copy written");
|
||||
let call = out
|
||||
.find("tap_hz_Sampler2(vec2(0.5))")
|
||||
.expect("call rewritten");
|
||||
assert!(declared < copy && copy < call, "{out}");
|
||||
assert_eq!(out.matches("tap_hz_Sampler2(vec2 uv)").count(), 1, "{out}");
|
||||
assert!(!out.contains("tap(sampler2D"), "{out}");
|
||||
}
|
||||
|
||||
/// A pack's text is untrusted: a sampler declared inside the helper itself (and text that
|
||||
/// isn't ASCII) gives edits that would overlap, so the shader is left as it is for the
|
||||
/// translator to refuse: never a panic, never a half-rewritten shader.
|
||||
#[test]
|
||||
fn a_sampler_declared_inside_the_helper_never_panics() {
|
||||
let text = "vec4 tap(sampler2D s, vec2 uv) { é uniform sampler2D Sampler0; x return texture(s, uv); ééé }\n\
|
||||
void main() { fragColor = tap(Sampler0, vec2(0.0)); }\n";
|
||||
let out = specialize_sampler_params(text);
|
||||
assert_eq!(out, text);
|
||||
}
|
||||
|
||||
/// Vanilla's `texture_sampling.glsl` shape: one overload calls the other with its own
|
||||
/// sampler. That call is rewritten too, and both copies come after the sampler's declaration
|
||||
/// in their own order, the called one first.
|
||||
#[test]
|
||||
fn an_overload_passing_its_sampler_to_another_is_followed() {
|
||||
let text = "vec4 sampleNearest(sampler2D source, vec2 uv, vec2 pixelSize, vec2 du, vec2 dv, vec2 texelSize) { return texture(source, uv + du + dv); }\n\
|
||||
vec4 sampleNearest(sampler2D source, vec2 uv, vec2 pixelSize) { return sampleNearest(source, uv, pixelSize, dFdx(uv), dFdy(uv), pixelSize); }\n\
|
||||
uniform sampler2D Sampler0;\n\
|
||||
void main() { vec4 c = sampleNearest(Sampler0, uv, px); }\n";
|
||||
let out = specialize_sampler_params(text);
|
||||
assert!(
|
||||
!out.contains("sampleNearest(") && !out.contains("sampler2D source"),
|
||||
"{out}"
|
||||
);
|
||||
let declared = out
|
||||
.find("uniform sampler2D Sampler0;")
|
||||
.expect("declaration");
|
||||
let six = out
|
||||
.find("vec4 sampleNearest_hz_Sampler0(vec2 uv, vec2 pixelSize, vec2 du, vec2 dv, vec2 texelSize)")
|
||||
.expect("six-argument copy");
|
||||
let three = out
|
||||
.find("vec4 sampleNearest_hz_Sampler0(vec2 uv, vec2 pixelSize) { return sampleNearest_hz_Sampler0(uv, pixelSize, dFdx(uv), dFdy(uv), pixelSize); }")
|
||||
.expect("three-argument copy calling the six-argument one");
|
||||
let call = out
|
||||
.find("vec4 c = sampleNearest_hz_Sampler0(uv, px);")
|
||||
.expect("call");
|
||||
assert!(declared < six && six < three && three < call, "{out}");
|
||||
}
|
||||
|
||||
/// A prototype above `main` with the definition below it becomes the copies' prototypes, so
|
||||
/// the call still has a declaration above it.
|
||||
#[test]
|
||||
fn a_prototype_becomes_the_copies_prototypes() {
|
||||
let text = "uniform sampler2D Sampler0;\n\
|
||||
vec4 tap(sampler2D s, vec2 uv);\n\
|
||||
void main() { vec4 c = tap(Sampler0, uv); }\n\
|
||||
vec4 tap(sampler2D s, vec2 uv) { return texture(s, uv); }\n";
|
||||
let out = specialize_sampler_params(text);
|
||||
let prototype = out
|
||||
.find("vec4 tap_hz_Sampler0(vec2 uv);")
|
||||
.expect("prototype copy");
|
||||
let call = out.find("tap_hz_Sampler0(uv); }").expect("call");
|
||||
let definition = out
|
||||
.find("vec4 tap_hz_Sampler0(vec2 uv) { return texture(Sampler0, uv); }")
|
||||
.expect("definition copy");
|
||||
assert!(prototype < call && call < definition, "{out}");
|
||||
}
|
||||
|
||||
/// Parameters named like the samplers they are passed, swapped, still read their own; and a
|
||||
/// parameter `s` leaves the swizzle `c.s` alone.
|
||||
#[test]
|
||||
fn sampler_parameters_are_replaced_at_once_and_not_as_members() {
|
||||
let text = "uniform sampler2D Sampler0;\nuniform sampler2D Sampler1;\n\
|
||||
vec4 mix2(sampler2D Sampler0, sampler2D Sampler1, vec2 uv) { return texture(Sampler0, uv) - texture(Sampler1, uv); }\n\
|
||||
vec4 swz(sampler2D s, vec2 uv) { vec4 c = texture(s, uv); return vec4(c.s, c.t, 0.0, 1.0); }\n\
|
||||
void main() { vec4 a = mix2(Sampler1, Sampler0, uv) + swz(Sampler0, uv); }\n";
|
||||
let out = specialize_sampler_params(text);
|
||||
assert!(
|
||||
out.contains("{ return texture(Sampler1, uv) - texture(Sampler0, uv); }"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("{ vec4 c = texture(Sampler0, uv); return vec4(c.s, c.t, 0.0, 1.0); }"),
|
||||
"{out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_parameter_matched_twice_in_one_identifier_stays_whole() {
|
||||
assert_eq!(replace_ident("c.ss + ss * s", "s", "t"), "c.ss + ss * t");
|
||||
assert_eq!(replace_ident("brightness(s)", "s", "t"), "brightness(t)");
|
||||
}
|
||||
|
||||
/// `fsh` translated beside a vertex shader that passes its position on.
|
||||
fn translate(fsh: &str) -> Result<crate::core_shader::CoreProgram, String> {
|
||||
let vsh = "#version 330\nin vec3 Position;\nout vec2 texCoord0;\n\
|
||||
void main() {\n gl_Position = vec4(Position, 1.0);\n texCoord0 = Position.xy;\n}\n";
|
||||
let glue = crate::core_shader::Glue {
|
||||
inputs: &[("Position", "vec3")],
|
||||
prelude: "layout(location = 0) in vec3 hz_position;",
|
||||
setup: "Position = hz_position;",
|
||||
finish: "",
|
||||
vertex_id: "int(gl_VertexIndex)",
|
||||
};
|
||||
crate::core_shader::translate_core(vsh, fsh, &[], &|_| None, &glue)
|
||||
}
|
||||
|
||||
/// #9386 end to end: naga refuses a helper taking the sampler it reads, as GLSL vanilla's GL
|
||||
/// compiler takes it; the pack's shader translates, the helper a copy reading `Sampler0`.
|
||||
#[test]
|
||||
fn a_helper_taking_its_sampler_translates() {
|
||||
let fsh = "#version 330\nuniform sampler2D Sampler0;\nin vec2 texCoord0;\nout vec4 fragColor;\n\
|
||||
vec4 tap(sampler2D tex, vec2 at, float w) { return texture(tex, at) * w; }\n\
|
||||
void main() {\n fragColor = tap(Sampler0, texCoord0, 0.5) + tap(Sampler0, texCoord0 + 0.01, 0.5);\n}\n";
|
||||
let program = translate(fsh).unwrap();
|
||||
let names: Vec<_> = program
|
||||
.fragment
|
||||
.functions
|
||||
.iter()
|
||||
.filter_map(|(_, f)| f.name.as_deref())
|
||||
.collect();
|
||||
assert!(names.contains(&"tap_hz_Sampler0"), "{names:?}");
|
||||
assert!(!names.contains(&"tap"), "{names:?}");
|
||||
}
|
||||
|
||||
/// A sampler parameter with no name used to hand `replace_ident` an empty name to replace,
|
||||
/// which it found at the same place forever, growing the text until the game ran out of memory.
|
||||
#[test]
|
||||
fn a_sampler_parameter_without_a_name_is_left_as_it_was() {
|
||||
let text = "uniform sampler2D Sampler0;\n\
|
||||
vec4 tap(sampler2D ;, vec2 uv) { return texture(Sampler0, uv); }\n\
|
||||
void main() { vec4 c = tap(Sampler0, uv); }\n";
|
||||
assert_eq!(fixups(text), text);
|
||||
assert_eq!(replace_ident("abc", "", "x"), "abc");
|
||||
}
|
||||
|
||||
/// Each call rewritten reads the whole text again: thousands of them stop at the scan budget,
|
||||
/// the shader left as it was (never half rewritten) instead of freezing the load.
|
||||
#[test]
|
||||
fn thousands_of_calls_are_left_as_they_were() {
|
||||
let shader = format!(
|
||||
"uniform sampler2D S;\nvec4 t(sampler2D s) {{ return texture(s, vec2(0.0)); }}\n\
|
||||
void main() {{ vec4 c; {}}}\n",
|
||||
"c = t(S);".repeat(5000)
|
||||
);
|
||||
assert_eq!(fixups(&shader), shader);
|
||||
// A few of them are rewritten.
|
||||
let few = shader.replace(&"c = t(S);".repeat(5000), &"c = t(S);".repeat(3));
|
||||
assert_eq!(
|
||||
fixups(&few).matches("c = t_hz_S(").count(),
|
||||
3,
|
||||
"{}",
|
||||
fixups(&few)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsized_arrays_take_their_initializers_size() {
|
||||
let text = "const float[] w = float[](0.25, 0.5, 0.25);\n\
|
||||
const vec2 offsets[] = vec2[](vec2(1.0, 0.0), vec2(0.0, 1.0));\n\
|
||||
float k[] = float[4](1.0, 2.0, 3.0, 4.0);\n\
|
||||
bool same = a == b;\n";
|
||||
let out = size_unsized_arrays(text);
|
||||
assert!(
|
||||
out.contains("const float[3] w = float[3](0.25, 0.5, 0.25);"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("const vec2 offsets[2] = vec2[2](vec2(1.0, 0.0), vec2(0.0, 1.0));"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("float k[4] = float[4](1.0, 2.0, 3.0, 4.0);"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(out.contains("bool same = a == b;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wide_character_after_a_sized_array_never_panics() {
|
||||
// Ten values make the size two digits, which moves the `=` two bytes right: the scan
|
||||
// went on from where it used to be, inside the `é`.
|
||||
let text = "float w[]\u{e9}= float[](0.,1.,2.,3.,4.,5.,6.,7.,8.,9.);\n";
|
||||
assert_eq!(
|
||||
size_unsized_arrays(text),
|
||||
"float w[10]\u{e9}= float[10](0.,1.,2.,3.,4.,5.,6.,7.,8.,9.);\n"
|
||||
);
|
||||
}
|
||||
|
||||
/// #9386 end to end: naga refuses an array sized by its initializer only.
|
||||
#[test]
|
||||
fn an_array_sized_by_its_initializer_translates() {
|
||||
let fsh = "#version 330\nuniform sampler2D Sampler0;\nin vec2 texCoord0;\nout vec4 fragColor;\n\
|
||||
const float[] weights = float[](0.25, 0.5, 0.25);\n\
|
||||
void main() {\n vec4 sum = vec4(0.0);\n\
|
||||
for (int i = 0; i < 3; i++) {\n sum += texture(Sampler0, texCoord0 + float(i) * 0.01) * weights[i];\n }\n\
|
||||
fragColor = sum;\n}\n";
|
||||
let program = translate(fsh);
|
||||
assert!(program.is_ok(), "{:?}", program.err());
|
||||
}
|
||||
@@ -439,33 +439,20 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The report for a hung server tick, written by the watchdog thread (`ServerWatchdog`): the
|
||||
/// stack is the server thread's, read from the watchdog (`horizon_threads::stack_dump`), as
|
||||
/// vanilla's report carries the server thread's stack; without it, the details say why.
|
||||
pub fn report_watchdog(
|
||||
home: &Path,
|
||||
side: &'static str,
|
||||
message: String,
|
||||
server_stack: Result<String, horizon_threads::stack_dump::NoStack>,
|
||||
) -> Option<PathBuf> {
|
||||
let mut details = system_details(side);
|
||||
let stack = match server_stack {
|
||||
Ok(stack) => stack_lines(&stack, false),
|
||||
Err(why) => {
|
||||
details.push(("Server thread stack".into(), why.to_string()));
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
/// The report for a hung server tick, written by the watchdog thread
|
||||
/// (`ServerWatchdog`): the stuck thread's stack is not available to Rust, so
|
||||
/// the report carries none.
|
||||
pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Option<PathBuf> {
|
||||
deliver(
|
||||
home,
|
||||
Report {
|
||||
time: Local::now(),
|
||||
description: "Watching Server".into(),
|
||||
error: format!("{message} (Watching Server)"),
|
||||
stack,
|
||||
stack: String::new(),
|
||||
thread: thread_name(side),
|
||||
side,
|
||||
details,
|
||||
details: system_details(side),
|
||||
log: crate::logging::recent_lines(),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,34 +1,27 @@
|
||||
//! Custom sky layers: skyboxes a pack draws over the vanilla sky, in a format vanilla doesn't read
|
||||
//! (OptiFine's), which the platform's pack formats fill ([`crate::pack_hooks`]). The layers'
|
||||
//! textures are read once with the pack; each frame the game asks for the layers to draw, their
|
||||
//! brightness and rotation ([`SkyLayers::draws`]), and `gfx::custom_sky` draws them over the
|
||||
//! vanilla sky. A pack without layers keeps the vanilla sky.
|
||||
//! OptiFine custom skies (`horizon_optifine::sky`) as the game draws them: a pack's layers with
|
||||
//! their decoded textures, read once with the pack, and each frame's choice of layers, their
|
||||
//! brightness and rotation ([`CustomSky::draws`]), which `gfx::custom_sky` draws over the
|
||||
//! vanilla sky. A pack without `optifine/sky` gives an empty [`CustomSky`] and the sky stays
|
||||
//! vanilla.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use glam::Mat4;
|
||||
use glam::{Mat4, Vec3};
|
||||
use horizon_optifine::sky::{Blend, Conditions, Skies, SkyLayer, Transition};
|
||||
|
||||
/// A layer's texture, RGBA8: the skybox's six faces in a 3x2 grid, bottom, top and south in the
|
||||
/// top row, west, north and east below (`gfx::custom_sky`'s faces).
|
||||
/// A layer's texture, RGBA8.
|
||||
pub struct Image {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub rgba: Vec<u8>,
|
||||
}
|
||||
|
||||
/// How a layer mixes with what is drawn below it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Blend {
|
||||
Add,
|
||||
Subtract,
|
||||
Multiply,
|
||||
Dodge,
|
||||
Burn,
|
||||
Screen,
|
||||
Replace,
|
||||
Overlay,
|
||||
Alpha,
|
||||
/// The pack's layers, by world, with one texture per layer (`images[i]` for the `i`th layer
|
||||
/// over all worlds, in `worlds` order).
|
||||
#[derive(Default)]
|
||||
pub struct CustomSky {
|
||||
worlds: Vec<(i32, Vec<SkyLayer>)>,
|
||||
pub images: Vec<Image>,
|
||||
}
|
||||
|
||||
/// One layer of a frame: which texture, with which blend, how bright and turned how.
|
||||
@@ -41,44 +34,62 @@ pub struct Draw {
|
||||
pub rotation: Mat4,
|
||||
}
|
||||
|
||||
/// The weather of a frame: thunder is a storm's rain, so it is only `Thunder`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Weather {
|
||||
Clear,
|
||||
Rain,
|
||||
Thunder,
|
||||
}
|
||||
|
||||
/// What a frame's layers may depend on.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Conditions<'a> {
|
||||
/// Ticks of the day, counting the days.
|
||||
pub day_time: i64,
|
||||
pub weather: Weather,
|
||||
/// The biome at the camera, namespaced.
|
||||
pub biome: &'a str,
|
||||
/// The camera's block height.
|
||||
pub y: i32,
|
||||
}
|
||||
|
||||
/// The eased brightness of each layer, kept by the game between frames.
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
/// The world the layers were last drawn for.
|
||||
pub world: Option<i32>,
|
||||
/// Each layer's eased visibility, 0 to 1.
|
||||
pub eased: Vec<f32>,
|
||||
world: Option<i32>,
|
||||
eased: Vec<Transition>,
|
||||
}
|
||||
|
||||
/// A pack's custom sky layers, by world (`0` the Overworld, `1` the End).
|
||||
pub trait SkyLayers: Send + Sync {
|
||||
/// Every layer's texture: [`Draw::image`] indexes it.
|
||||
fn images(&self) -> &[Image];
|
||||
/// Whether `world` has layers.
|
||||
fn has(&self, world: i32) -> bool;
|
||||
/// This frame's layers of `world` into `out` (cleared first), in drawing order, eased by
|
||||
/// `seconds` since the last frame. `sun_turns` is the sun's angle over 360° (`0` at noon).
|
||||
fn draws(
|
||||
/// Textures past this many texels in all are left out (a skybox is a few megapixels at most).
|
||||
const TEXEL_BUDGET: u64 = 64 << 20;
|
||||
|
||||
impl CustomSky {
|
||||
/// The custom skies of the overlay at `root`. A layer whose texture does not decode is
|
||||
/// dropped with a warning.
|
||||
pub fn load(root: &Path) -> Self {
|
||||
let skies = Skies::scan(root);
|
||||
let mut out = Self::default();
|
||||
let mut texels = 0u64;
|
||||
for (world, sky) in skies.worlds {
|
||||
let mut layers = Vec::new();
|
||||
for layer in sky.layers {
|
||||
match crate::hud::decode(&root.join(&layer.source)) {
|
||||
Ok((width, height, rgba))
|
||||
if texels + u64::from(width) * u64::from(height) <= TEXEL_BUDGET =>
|
||||
{
|
||||
texels += u64::from(width) * u64::from(height);
|
||||
out.images.push(Image {
|
||||
width,
|
||||
height,
|
||||
rgba,
|
||||
});
|
||||
layers.push(layer);
|
||||
}
|
||||
Ok(_) => log::warn!("{}: past the custom sky's texture budget", layer.source),
|
||||
Err(e) => log::warn!("{}: {e:#}", layer.source),
|
||||
}
|
||||
}
|
||||
if !layers.is_empty() {
|
||||
out.worlds.push((world, layers));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.worlds.is_empty()
|
||||
}
|
||||
|
||||
/// Whether `world` has layers (`0` the Overworld, `1` the End).
|
||||
pub fn has(&self, world: i32) -> bool {
|
||||
self.worlds.iter().any(|(w, _)| *w == world)
|
||||
}
|
||||
|
||||
/// This frame's layers of `world` into `out` (cleared first), in drawing order: those whose
|
||||
/// conditions hold, eased over their `transition` by `seconds` since the last frame, at the
|
||||
/// brightness of their fade times. `sun_turns` is the sun's angle over 360° (`0` at noon).
|
||||
pub fn draws(
|
||||
&self,
|
||||
world: i32,
|
||||
conditions: &Conditions,
|
||||
@@ -86,30 +97,147 @@ pub trait SkyLayers: Send + Sync {
|
||||
seconds: f32,
|
||||
state: &mut State,
|
||||
out: &mut Vec<Draw>,
|
||||
);
|
||||
}
|
||||
|
||||
/// No layers: the vanilla sky.
|
||||
struct NoLayers;
|
||||
|
||||
impl SkyLayers for NoLayers {
|
||||
fn images(&self) -> &[Image] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn has(&self, _: i32) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn draws(&self, _: i32, _: &Conditions, _: f32, _: f32, s: &mut State, out: &mut Vec<Draw>) {
|
||||
) {
|
||||
out.clear();
|
||||
s.world = None;
|
||||
let mut first = 0;
|
||||
let mut found = None;
|
||||
for (w, layers) in &self.worlds {
|
||||
if *w == world {
|
||||
found = Some(layers);
|
||||
break;
|
||||
}
|
||||
first += layers.len();
|
||||
}
|
||||
let Some(layers) = found else {
|
||||
state.world = None;
|
||||
return;
|
||||
};
|
||||
// A new world (or pack) starts each layer where its conditions put it: no fade in.
|
||||
let snap = state.world != Some(world) || state.eased.len() != layers.len();
|
||||
if snap {
|
||||
state.world = Some(world);
|
||||
state.eased.clear();
|
||||
state.eased.resize(layers.len(), Transition::default());
|
||||
}
|
||||
for (i, (layer, eased)) in layers.iter().zip(&mut state.eased).enumerate() {
|
||||
let target = if layer.shows(conditions) { 1.0 } else { 0.0 };
|
||||
let shown = if snap {
|
||||
eased.value = target;
|
||||
target
|
||||
} else {
|
||||
eased.step(target, seconds, layer.transition)
|
||||
};
|
||||
let brightness = shown * layer.brightness(conditions.day_time);
|
||||
if brightness <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let turns = layer.turns(conditions.day_time, sun_turns);
|
||||
let rotation = if turns == 0.0 {
|
||||
Mat4::IDENTITY
|
||||
} else {
|
||||
Mat4::from_axis_angle(Vec3::from_array(layer.axis), turns * std::f32::consts::TAU)
|
||||
};
|
||||
out.push(Draw {
|
||||
image: (first + i) as u16,
|
||||
blend: layer.blend,
|
||||
brightness,
|
||||
rotation,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The custom sky layers of the overlay at `root`, from the installed pack formats.
|
||||
pub fn load(root: &Path) -> Arc<dyn SkyLayers> {
|
||||
crate::pack_hooks::formats()
|
||||
.custom_sky(root)
|
||||
.unwrap_or_else(|| Arc::new(NoLayers))
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_optifine::sky::Now;
|
||||
|
||||
fn pack(files: &[(&str, &str)]) -> (std::path::PathBuf, CustomSky) {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-custom-sky-{}-{}",
|
||||
std::process::id(),
|
||||
files.len()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let dir = root.join("assets/minecraft/optifine/sky/world0");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let png = {
|
||||
let mut bytes = Vec::new();
|
||||
let mut encoder = png::Encoder::new(&mut bytes, 6, 4);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
writer
|
||||
.write_image_data(&[255, 0, 0, 255].repeat(24))
|
||||
.unwrap();
|
||||
writer.finish().unwrap();
|
||||
bytes
|
||||
};
|
||||
for (name, text) in files {
|
||||
std::fs::write(dir.join(format!("{name}.properties")), text).unwrap();
|
||||
std::fs::write(dir.join(format!("{name}.png")), &png).unwrap();
|
||||
}
|
||||
let sky = CustomSky::load(&root);
|
||||
(root, sky)
|
||||
}
|
||||
|
||||
fn noon(biome: &str) -> Conditions<'_> {
|
||||
Conditions {
|
||||
day_time: 6000,
|
||||
weather: Now::Clear,
|
||||
biome,
|
||||
y: 64,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_ease_in_at_a_biome_border_but_not_on_arrival() {
|
||||
let (root, sky) = pack(&[
|
||||
("sky1", "biomes=desert\ntransition=2"),
|
||||
("sky2", "blend=alpha\nrotate=false"),
|
||||
]);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
assert!(sky.has(0) && !sky.has(1));
|
||||
assert_eq!(sky.images[0].width, 6);
|
||||
let (mut state, mut out) = (State::default(), Vec::new());
|
||||
sky.draws(
|
||||
0,
|
||||
&noon("minecraft:desert"),
|
||||
0.0,
|
||||
0.05,
|
||||
&mut state,
|
||||
&mut out,
|
||||
);
|
||||
assert_eq!(
|
||||
out.iter().map(|d| d.brightness).collect::<Vec<_>>(),
|
||||
[1.0, 1.0]
|
||||
);
|
||||
// Out of the desert: the first layer fades over its two seconds.
|
||||
sky.draws(0, &noon("minecraft:plains"), 0.0, 0.5, &mut state, &mut out);
|
||||
assert_eq!(out[0].brightness, 0.75);
|
||||
assert_eq!(out[1].blend, Blend::Alpha);
|
||||
sky.draws(0, &noon("minecraft:plains"), 0.0, 2.0, &mut state, &mut out);
|
||||
assert_eq!(out.len(), 1, "a dark layer is not drawn");
|
||||
assert_eq!(out[0].image, 1);
|
||||
// Into the End, which has no layers, and back: no fade.
|
||||
sky.draws(1, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
|
||||
assert!(out.is_empty());
|
||||
sky.draws(0, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
|
||||
assert_eq!(out.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_turns_west_like_the_sun() {
|
||||
let (root, sky) = pack(&[("sky1", "")]);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
let (mut state, mut out) = (State::default(), Vec::new());
|
||||
// A quarter turn after noon (sunset): the skybox's zenith has gone down to the west.
|
||||
let c = Conditions {
|
||||
day_time: 12000,
|
||||
..noon("minecraft:plains")
|
||||
};
|
||||
sky.draws(0, &c, 0.25, 0.0, &mut state, &mut out);
|
||||
let up = out[0].rotation.transform_vector3(Vec3::Y);
|
||||
assert!((up - Vec3::NEG_X).length() < 1e-5, "{up}");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
//! Data packs (`<world>/datapacks`: folders and zips with `pack.mcmeta`) and
|
||||
//! the parts of them commands use: `.mcfunction` functions (with macros and
|
||||
//! line continuation), function tags and predicates.
|
||||
mod slot_sources;
|
||||
use crate::zip::Zip;
|
||||
use horizon_world::{
|
||||
nbt::Tag,
|
||||
@@ -664,8 +663,6 @@ pub struct DataStore {
|
||||
pub item_modifiers: BTreeMap<String, serde_json::Value>,
|
||||
/// `slot_source` definitions, evaluated against a command's live accessor.
|
||||
pub slot_sources: BTreeMap<String, serde_json::Value>,
|
||||
/// Slot-source tags belong to the same loaded snapshot as their definitions.
|
||||
pub slot_source_tags: BTreeMap<String, Vec<(String, bool)>>,
|
||||
/// The content tables' raw files from the packs after the built-in one:
|
||||
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `damage_type/`, `data_maps/`, `enchantment/` and `tags/…/` files, by
|
||||
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
|
||||
@@ -762,16 +759,6 @@ impl DataStore {
|
||||
&& let Ok(v) = serde_json::from_slice(&bytes)
|
||||
{
|
||||
s.item_modifiers.insert(id, v);
|
||||
} else if let Some(id) = resource_id(&f, "tags/slot_source", ".json")
|
||||
&& let Some(bytes) = pack.read(&f)
|
||||
&& let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes)
|
||||
{
|
||||
let entries = tag_entries(&v);
|
||||
let slot = s.slot_source_tags.entry(id).or_default();
|
||||
if v["replace"].as_bool() == Some(true) {
|
||||
slot.clear();
|
||||
}
|
||||
slot.extend(entries);
|
||||
} else if let Some(id) = resource_id(&f, "slot_source", ".json")
|
||||
&& let Some(bytes) = pack.read(&f)
|
||||
&& let Ok(v) = serde_json::from_slice(&bytes)
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
//! Slot-source holder-set tags share DataStore's pack ordering and snapshot.
|
||||
use super::DataStore;
|
||||
use std::collections::BTreeSet;
|
||||
impl DataStore {
|
||||
/// TagLoader's required-member validation and first-seen membership order.
|
||||
pub fn slot_source_tag(&self, id: &str) -> Result<Vec<String>, String> {
|
||||
fn visit(
|
||||
store: &DataStore,
|
||||
id: &str,
|
||||
active: &mut BTreeSet<String>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
if !active.insert(id.to_owned()) {
|
||||
return Err(format!("Recursive slot-source tag #{id}"));
|
||||
}
|
||||
let result = (|| {
|
||||
let entries = store
|
||||
.slot_source_tags
|
||||
.get(id)
|
||||
.ok_or_else(|| format!("Unknown slot-source tag #{id}"))?;
|
||||
let mut out = Vec::new();
|
||||
for (entry, required) in entries {
|
||||
let values = if let Some(tag) = entry.strip_prefix('#') {
|
||||
visit(store, tag, active)
|
||||
} else if store.slot_sources.contains_key(entry) {
|
||||
Ok(vec![entry.clone()])
|
||||
} else {
|
||||
Err(format!("Unknown slot source {entry}"))
|
||||
};
|
||||
match values {
|
||||
Ok(values) => {
|
||||
for value in values {
|
||||
if !out.contains(&value) {
|
||||
out.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) if *required => return Err(error),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
})();
|
||||
active.remove(id);
|
||||
result
|
||||
}
|
||||
visit(self, id, &mut BTreeSet::new())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn holder_set_tags_keep_order_required_members_and_snapshot_authority() {
|
||||
let mut store = DataStore::default();
|
||||
for name in ["t:a", "t:b"] {
|
||||
store.slot_sources.insert(
|
||||
name.into(),
|
||||
serde_json::json!({"type":"slot_range","slots":"container.0"}),
|
||||
);
|
||||
}
|
||||
store.slot_source_tags.insert(
|
||||
"t:child".into(),
|
||||
vec![("t:b".into(), true), ("t:a".into(), true)],
|
||||
);
|
||||
store.slot_source_tags.insert(
|
||||
"t:all".into(),
|
||||
vec![
|
||||
("t:a".into(), true),
|
||||
("#t:child".into(), true),
|
||||
("t:absent".into(), false),
|
||||
],
|
||||
);
|
||||
assert_eq!(store.slot_source_tag("t:all").unwrap(), ["t:a", "t:b"]);
|
||||
store
|
||||
.slot_source_tags
|
||||
.get_mut("t:all")
|
||||
.unwrap()
|
||||
.push(("t:absent".into(), true));
|
||||
assert!(store.slot_source_tag("t:all").is_err());
|
||||
store
|
||||
.slot_source_tags
|
||||
.insert("t:cycle".into(), vec![("#t:cycle".into(), true)]);
|
||||
assert!(store.slot_source_tag("t:cycle").is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
//! Dynamic Resolution: the world's resolution scale follows the GPU's frame time toward a target
|
||||
//! frame rate, between the Resolution Scale setting (the floor) and 100%.
|
||||
//!
|
||||
//! The setting is the lowest scale the player accepts; where the GPU has room for more, the
|
||||
//! scale rises toward 100% a step at a time, and falls back toward the floor where frames take
|
||||
//! longer than the target allows. A verdict is the median GPU time of a window of frames, which
|
||||
//! a few slow frames (a chunk load) don't move, and a step up is taken only when the frame would
|
||||
//! still fit if its cost grew with the pixels: most of a frame doesn't, so it errs toward
|
||||
//! staying.
|
||||
//!
|
||||
//! Each change has a price: new render targets, and history that starts over (TAA, and a
|
||||
//! shader pack's own: its denoised light, reflections and accumulation). So a shader pack gets
|
||||
//! the gentle pace (long windows, big steps, a long rest after each change): walking from a
|
||||
//! heavy scene to a light one changes the scale once, not every few frames. Without a pack the
|
||||
//! quick pace follows the scene within a second.
|
||||
//!
|
||||
//! Nothing here draws or reads the GPU: the caller feeds it the frames and applies the scale it
|
||||
//! answers.
|
||||
|
||||
/// How fast the scale follows the frame.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Pace {
|
||||
/// A shader pack: its history is expensive to start over.
|
||||
Gentle,
|
||||
/// Horizon's own renderer.
|
||||
Quick,
|
||||
}
|
||||
|
||||
impl Pace {
|
||||
/// Milliseconds of frames in one verdict.
|
||||
fn window_ms(self) -> f64 {
|
||||
match self {
|
||||
Pace::Gentle => 3_000.0,
|
||||
Pace::Quick => 500.0,
|
||||
}
|
||||
}
|
||||
/// Milliseconds of frames after a change before the next verdict counts, while the new
|
||||
/// targets and the restarted history settle.
|
||||
fn settle_ms(self) -> f64 {
|
||||
match self {
|
||||
Pace::Gentle => 8_000.0,
|
||||
Pace::Quick => 1_000.0,
|
||||
}
|
||||
}
|
||||
/// Percentage points per change.
|
||||
fn step(self) -> u32 {
|
||||
match self {
|
||||
Pace::Gentle => 10,
|
||||
Pace::Quick => 5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame is too slow when its GPU time passes the target's frame time by this share.
|
||||
const DOWN_ABOVE: f32 = 1.05;
|
||||
/// A step up is taken when the frame, its cost grown with the pixels, stays under this share.
|
||||
const UP_BELOW: f32 = 0.95;
|
||||
/// Timed frames a window needs for a verdict (a GPU without timestamps gives none).
|
||||
const MIN_TIMED: usize = 8;
|
||||
|
||||
/// The scale in use and what it has measured since the last verdict.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DynamicResolution {
|
||||
gpu: Vec<f32>,
|
||||
window_ms: f64,
|
||||
since_change_ms: f64,
|
||||
/// The scale in use, in percent; `None` until the first change (the floor).
|
||||
scale: Option<u32>,
|
||||
}
|
||||
|
||||
impl DynamicResolution {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Back to the floor, with nothing measured (turned off, or the world changed).
|
||||
pub fn reset(&mut self) {
|
||||
*self = Self::new();
|
||||
}
|
||||
|
||||
/// The scale in use, in percent, for a Resolution Scale of `floor`: never below it, never
|
||||
/// above 100% unless the floor itself is (then it is the floor).
|
||||
pub fn scale(&self, floor: u32) -> u32 {
|
||||
self.scale.unwrap_or(floor).clamp(floor, floor.max(100))
|
||||
}
|
||||
|
||||
/// One frame: its GPU time where its timestamps have arrived, and its whole time, in
|
||||
/// milliseconds. Answers the new scale, in percent, when this frame completed a window that
|
||||
/// changed it.
|
||||
pub fn observe(
|
||||
&mut self,
|
||||
gpu_ms: Option<f32>,
|
||||
frame_ms: f32,
|
||||
floor: u32,
|
||||
target_fps: u32,
|
||||
pace: Pace,
|
||||
) -> Option<u32> {
|
||||
self.window_ms += f64::from(frame_ms);
|
||||
self.since_change_ms += f64::from(frame_ms);
|
||||
if let Some(gpu) = gpu_ms {
|
||||
self.gpu.push(gpu);
|
||||
}
|
||||
// A window lasts its time and holds enough timed frames: a slow scene (or timestamps that
|
||||
// arrive with gaps) keeps gathering past the time rather than starting over with too few,
|
||||
// which would never judge the scenes that most need a lower scale. A GPU that can't be
|
||||
// timed gathers nothing and so leaves the scale alone.
|
||||
if self.window_ms < pace.window_ms() || self.gpu.len() < MIN_TIMED {
|
||||
return None;
|
||||
}
|
||||
self.window_ms = 0.0;
|
||||
let timed = std::mem::take(&mut self.gpu);
|
||||
// At or above 100% there is nothing to vary; a change still settling isn't judged yet.
|
||||
if floor >= 100 || self.since_change_ms < pace.settle_ms() {
|
||||
return None;
|
||||
}
|
||||
let gpu = median(timed);
|
||||
let target = 1000.0 / target_fps.max(1) as f32;
|
||||
let now = self.scale(floor);
|
||||
let next = if gpu > target * DOWN_ABOVE && now > floor {
|
||||
now.saturating_sub(pace.step()).max(floor)
|
||||
} else if now < 100 {
|
||||
let up = (now + pace.step()).min(100);
|
||||
let grown = (up as f32 / now as f32).powi(2);
|
||||
if gpu * grown < target * UP_BELOW {
|
||||
up
|
||||
} else {
|
||||
now
|
||||
}
|
||||
} else {
|
||||
now
|
||||
};
|
||||
if next == now {
|
||||
return None;
|
||||
}
|
||||
self.scale = Some(next);
|
||||
self.since_change_ms = 0.0;
|
||||
Some(next)
|
||||
}
|
||||
}
|
||||
|
||||
fn median(mut values: Vec<f32>) -> f32 {
|
||||
values.sort_by(f32::total_cmp);
|
||||
values[values.len() / 2]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `ms` milliseconds of frames, each `gpu` long on the GPU and `frame` long in all; the last
|
||||
/// answer.
|
||||
fn run(
|
||||
d: &mut DynamicResolution,
|
||||
ms: f64,
|
||||
gpu: f32,
|
||||
frame: f32,
|
||||
floor: u32,
|
||||
pace: Pace,
|
||||
) -> Option<u32> {
|
||||
let mut answer = None;
|
||||
let mut t = 0.0;
|
||||
while t < ms {
|
||||
if let Some(scale) = d.observe(Some(gpu), frame, floor, 60, pace) {
|
||||
answer = Some(scale);
|
||||
}
|
||||
t += f64::from(frame);
|
||||
}
|
||||
answer
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_starts_at_the_floor_and_climbs_while_there_is_room() {
|
||||
let mut d = DynamicResolution::new();
|
||||
assert_eq!(d.scale(70), 70);
|
||||
// 8 ms of a 16.7 ms budget: room to grow, one step per window and settle.
|
||||
assert_eq!(run(&mut d, 1_200.0, 8.0, 8.0, 70, Pace::Quick), Some(75));
|
||||
assert_eq!(run(&mut d, 30_000.0, 8.0, 8.0, 70, Pace::Quick), Some(100));
|
||||
assert_eq!(d.scale(70), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slow_frame_steps_down_but_never_below_the_floor() {
|
||||
let mut d = DynamicResolution::new();
|
||||
run(&mut d, 30_000.0, 8.0, 8.0, 70, Pace::Quick);
|
||||
assert_eq!(d.scale(70), 100);
|
||||
// 25 ms against 16.7: down a step at a time, to the floor and no further.
|
||||
run(&mut d, 60_000.0, 25.0, 25.0, 70, Pace::Quick);
|
||||
assert_eq!(d.scale(70), 70);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slow_scene_still_steps_down() {
|
||||
let mut d = DynamicResolution::new();
|
||||
run(&mut d, 30_000.0, 8.0, 8.0, 70, Pace::Quick);
|
||||
assert_eq!(d.scale(70), 100);
|
||||
// 10 FPS: five frames in a 500 ms window, fewer than a verdict needs; the window keeps
|
||||
// gathering until it has them.
|
||||
run(&mut d, 60_000.0, 100.0, 100.0, 70, Pace::Quick);
|
||||
assert_eq!(d.scale(70), 70);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_near_the_target_stays_put() {
|
||||
let mut d = DynamicResolution::new();
|
||||
// 15 ms: under the target, but a step up (75 -> 80%) would make it 17 ms.
|
||||
assert_eq!(run(&mut d, 60_000.0, 15.0, 16.0, 75, Pace::Quick), None);
|
||||
assert_eq!(d.scale(75), 75);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_gentle_pace_takes_big_steps_and_long_rests() {
|
||||
let mut d = DynamicResolution::new();
|
||||
// The first window (3 s) can't count yet: a change rests 8 s, and the first counts
|
||||
// from the start.
|
||||
assert_eq!(run(&mut d, 7_000.0, 8.0, 8.0, 70, Pace::Gentle), None);
|
||||
assert_eq!(run(&mut d, 3_000.0, 8.0, 8.0, 70, Pace::Gentle), Some(80));
|
||||
// Then nothing for the 8 s rest.
|
||||
assert_eq!(run(&mut d, 7_000.0, 8.0, 8.0, 70, Pace::Gentle), None);
|
||||
assert_eq!(d.scale(70), 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_changes_at_or_above_100_or_without_gpu_times() {
|
||||
let mut d = DynamicResolution::new();
|
||||
assert_eq!(run(&mut d, 30_000.0, 30.0, 30.0, 100, Pace::Quick), None);
|
||||
assert_eq!(d.scale(120), 120);
|
||||
let mut d = DynamicResolution::new();
|
||||
for _ in 0..1_000 {
|
||||
assert_eq!(d.observe(None, 8.0, 70, 60, Pace::Quick), None);
|
||||
}
|
||||
assert_eq!(d.scale(70), 70);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_goes_back_to_the_floor() {
|
||||
let mut d = DynamicResolution::new();
|
||||
run(&mut d, 30_000.0, 8.0, 8.0, 70, Pace::Quick);
|
||||
d.reset();
|
||||
assert_eq!(d.scale(70), 70);
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ struct Globals {
|
||||
// w: apply the no-pack HDR gain; HDR_WRITE_LIGHT independently keeps float alpha as coverage.
|
||||
hdr_light: vec4<f32>,
|
||||
hdr_settings: vec4<f32>,
|
||||
hdr_gains: vec4<f32>,
|
||||
hdr_shares: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var atlas: texture_2d<f32>;
|
||||
@@ -155,8 +155,7 @@ 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).
|
||||
// -126/127 shades like -127 and also culls (entityCutoutCull models such as the bat, and the
|
||||
// entitySolid decorated pot).
|
||||
// -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);
|
||||
@@ -292,17 +291,12 @@ fn entity_written_color(in: Out, color: vec4<f32>) -> vec4<f32> {
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 && in.overlay != 11u && in.overlay != 12u {
|
||||
var mark = hdr_entity_mark(in);
|
||||
if in.overlay == 4u { mark = hdr_scene_alpha(globals.hdr_light.y); }
|
||||
return vec4(hdr_written(color.rgb, mark, globals.hdr_settings, globals.hdr_gains), color.a);
|
||||
return vec4(hdr_written(color.rgb, mark, globals.hdr_settings, globals.hdr_shares), color.a);
|
||||
}
|
||||
if FRAME_LINEAR && (in.overlay == 11u || in.overlay == 12u) {
|
||||
// The cracks scale what is under them by twice their colour (CRUMBLING_BLEND). Vanilla's
|
||||
// product is in gamma, so under light it is (2c)^2.2: they give half of that.
|
||||
return vec4(pow(2.0 * max(color.rgb, vec3(0.0)), vec3(2.2)) * 0.5, color.a);
|
||||
}
|
||||
return frame_stored(color);
|
||||
return color;
|
||||
}
|
||||
@fragment fn fs_entity_unmarked(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
return frame_stored(entity_color(in, front));
|
||||
return entity_color(in, front);
|
||||
}
|
||||
fn entity_color(in: Out, front: bool) -> vec4<f32> {
|
||||
var uv = in.uv;
|
||||
@@ -360,7 +354,7 @@ fn entity_color(in: Out, front: bool) -> vec4<f32> {
|
||||
// rendertype_entity_shadow.fsh: the shadow texture times the vertex colour,
|
||||
// then fog, with no alpha cutout, so a faint shadow in dim light still draws.
|
||||
@fragment fn fs_entity_shadow(in: Out) -> @location(0) vec4<f32> {
|
||||
return frame_stored(shadow_color(in));
|
||||
return shadow_color(in);
|
||||
}
|
||||
fn shadow_color(in: Out) -> vec4<f32> {
|
||||
var color = textureSample(atlas, atlas_sampler, in.uv) * in.color;
|
||||
@@ -451,9 +445,9 @@ fn celestial_color(in: Out) -> vec4<f32> {
|
||||
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = celestial_color(in);
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_gains), color.a);
|
||||
return vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_shares), color.a);
|
||||
}
|
||||
return frame_stored(color);
|
||||
return color;
|
||||
}
|
||||
|
||||
// The sky's celestial pass (sun, moon, the End sky): fs_celestial's light,
|
||||
@@ -472,10 +466,9 @@ fn celestial_color(in: Out) -> vec4<f32> {
|
||||
// or it would come out darker than the sky around it.
|
||||
let full = y >= globals.hdr_windows[1][clamp(tier, 1u, 4u) - 1u];
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(added, select(1.0, hdr_mark(tier, y), full), globals.hdr_settings, globals.hdr_gains), color.a);
|
||||
return vec4(hdr_written(added, select(1.0, hdr_mark(tier, y), full), globals.hdr_settings, globals.hdr_shares), color.a);
|
||||
}
|
||||
// Premultiplied after the decode: the light the texel adds, at its coverage.
|
||||
if HDR_WRITE_LIGHT { return vec4(frame_stored(color).rgb * color.a, color.a); }
|
||||
if HDR_WRITE_LIGHT { return vec4(added, color.a); }
|
||||
if !full { return vec4(added, 0.0); }
|
||||
return vec4(added, 1.0 - hdr_mark(tier, y));
|
||||
}
|
||||
@@ -498,7 +491,7 @@ fn energy_swirl_fog(in: Out) -> f32 {
|
||||
}
|
||||
@fragment fn fs_energy_swirl(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = energy_swirl_base(in);
|
||||
return frame_stored(vec4(mix(color.rgb, globals.fog_color.rgb, energy_swirl_fog(in)), color.a));
|
||||
return vec4(mix(color.rgb, globals.fog_color.rgb, energy_swirl_fog(in)), color.a);
|
||||
}
|
||||
|
||||
// RenderPipelines.WATER_MASK: depth only (the pipeline masks the colour off).
|
||||
@@ -627,7 +620,7 @@ struct WeatherTime { values: vec4<f32> }
|
||||
return out;
|
||||
}
|
||||
@fragment fn fs_weather(in: Out) -> @location(0) vec4<f32> {
|
||||
return frame_stored(weather_color(in));
|
||||
return weather_color(in);
|
||||
}
|
||||
fn weather_color(in: Out) -> vec4<f32> {
|
||||
// Repeat within this sprite, never across the atlas. The atlas uses nearest
|
||||
|
||||
@@ -12,8 +12,6 @@ use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
mod noise;
|
||||
|
||||
pub struct Environment {
|
||||
system: System,
|
||||
/// What the packs' expressions read; the frame sets it before sampling.
|
||||
@@ -561,31 +559,16 @@ impl<'a> BlockHost<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The noise `r.noise(x, y)` reads, the same for blocks and particles.
|
||||
pub(crate) fn polytone_noise() -> &'static noise::PerlinSimplex {
|
||||
noise::builtin()
|
||||
/// The simplex noise `r.noise(x, y)` reads, the same for blocks and particles.
|
||||
pub(crate) fn polytone_noise() -> &'static horizon_core::noise::SimplexNoise {
|
||||
static NOISE: std::sync::OnceLock<horizon_core::noise::SimplexNoise> =
|
||||
std::sync::OnceLock::new();
|
||||
NOISE.get_or_init(|| {
|
||||
horizon_core::noise::SimplexNoise::new(&mut horizon_core::random::LegacyRandom::new(0))
|
||||
})
|
||||
}
|
||||
|
||||
impl horizon_polytone::expr::Host for BlockHost<'_> {
|
||||
fn field(
|
||||
&mut self,
|
||||
root: horizon_polytone::expr::Root,
|
||||
field: horizon_polytone::expr::Field,
|
||||
) -> Option<horizon_polytone::expr::Value> {
|
||||
use horizon_polytone::expr::Field as F;
|
||||
if root != horizon_polytone::expr::Root::Object {
|
||||
return None;
|
||||
}
|
||||
Some(horizon_polytone::expr::Value::Num(match field {
|
||||
F::X => f64::from(self.at[0]),
|
||||
F::Y => f64::from(self.at[1]),
|
||||
F::Z => f64::from(self.at[2]),
|
||||
F::Temperature => f64::from(self.biome.temperature),
|
||||
F::Downfall => f64::from(self.biome.downfall),
|
||||
_ => return None,
|
||||
}))
|
||||
}
|
||||
|
||||
fn root(&mut self, root: horizon_polytone::expr::Root) -> horizon_polytone::expr::Value {
|
||||
use horizon_polytone::expr::Root;
|
||||
horizon_polytone::expr::Value::Object(match root {
|
||||
@@ -613,7 +596,7 @@ impl horizon_polytone::expr::Host for BlockHost<'_> {
|
||||
(V::Object(2), "noise") => {
|
||||
let mut coords = args.iter().filter(|v| !matches!(v, V::Str(_))).map(V::num);
|
||||
let (x, y) = (coords.next().unwrap_or(0.0), coords.next().unwrap_or(0.0));
|
||||
num(polytone_noise().get(x, y))
|
||||
num(f64::from(polytone_noise().get2(x, y)))
|
||||
}
|
||||
_ => Err(format!("{name} is not available to block colormaps")),
|
||||
}
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
//! Polytone's `r.noise(x, y)`: 1.18-1.21's `PerlinSimplexNoise` over simplex noises, which 26.3 no
|
||||
//! longer has. Measured on the real mod (Polytone 26.3-6.8.6) with a pack whose grass colormap encodes
|
||||
//! the noise: the built-in noise is seed 0 with octaves `{1}`, read with the noise offsets
|
||||
//! (`getValue(x, y, true)`), and a `polytone/noises` definition with seed 0 or 1 and octaves `{0}`,
|
||||
//! `{1}` or `{0, 1}` matches this construction too.
|
||||
|
||||
use horizon_core::noise::SimplexNoise;
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
|
||||
/// `PerlinSimplexNoise(random, octaves)`: one simplex noise per octave, the highest octave first.
|
||||
pub(crate) struct PerlinSimplex {
|
||||
levels: Vec<Option<SimplexNoise>>,
|
||||
input_factor: f64,
|
||||
value_factor: f64,
|
||||
}
|
||||
|
||||
impl PerlinSimplex {
|
||||
/// The construction of `PerlinSimplexNoise(new LegacyRandomSource(seed), octaves)`: octave 0 takes
|
||||
/// the first noise of `seed`'s random; the positive octaves come from a random seeded by that
|
||||
/// noise's value at its own offsets, the negative ones from `seed`'s random after it.
|
||||
pub(crate) fn new(seed: i64, octaves: &[i32]) -> Self {
|
||||
let mut random = LegacyRandom::new(seed);
|
||||
let lowest = octaves.iter().copied().min().unwrap_or(0);
|
||||
let highest = octaves.iter().copied().max().unwrap_or(0);
|
||||
let count = (highest - lowest + 1) as usize;
|
||||
let first = SimplexNoise::new(&mut random);
|
||||
let top = highest;
|
||||
let wanted = |index: i32| octaves.contains(&(top - index));
|
||||
let mut levels: Vec<Option<SimplexNoise>> = (0..count).map(|_| None).collect();
|
||||
for index in top + 1..count as i32 {
|
||||
if index >= 0 && wanted(index) {
|
||||
levels[index as usize] = Some(SimplexNoise::new(&mut random));
|
||||
} else {
|
||||
random.consume_count(262);
|
||||
}
|
||||
}
|
||||
if highest > 0 {
|
||||
// `(long)(getValue(xo, yo, zo) * 9.223372E18F)`: the noise at its own offsets, which
|
||||
// 26.3's get3 reads at (0, 0, 0). The float constant is 2^63.
|
||||
let value = first.get3_f64(0.0, 0.0, 0.0) * 9.223_372e18_f32 as f64;
|
||||
let mut upper = LegacyRandom::new(value as i64);
|
||||
for index in (0..top).rev() {
|
||||
if (index as usize) < count && wanted(index) {
|
||||
levels[index as usize] = Some(SimplexNoise::new(&mut upper));
|
||||
} else {
|
||||
upper.consume_count(262);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (0..count as i32).contains(&top) && octaves.contains(&0) {
|
||||
levels[top as usize] = Some(first);
|
||||
}
|
||||
Self {
|
||||
levels,
|
||||
input_factor: 2f64.powi(highest),
|
||||
value_factor: 1.0 / (2f64.powi(count as i32) - 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// `getValue(x, y, true)`: each octave read with its offsets, which 26.3's simplex adds itself.
|
||||
pub(crate) fn get(&self, x: f64, y: f64) -> f64 {
|
||||
let (mut input, mut value, mut sum) = (self.input_factor, self.value_factor, 0.0);
|
||||
for level in &self.levels {
|
||||
if let Some(noise) = level {
|
||||
sum += noise.get2_f64(x * input, y * input) * value;
|
||||
}
|
||||
input /= 2.0;
|
||||
value *= 2.0;
|
||||
}
|
||||
sum
|
||||
}
|
||||
}
|
||||
|
||||
/// The built-in `r.noise(x, y)`: seed 0, octaves `{1}`.
|
||||
pub(crate) fn builtin() -> &'static PerlinSimplex {
|
||||
static NOISE: std::sync::OnceLock<PerlinSimplex> = std::sync::OnceLock::new();
|
||||
NOISE.get_or_init(|| PerlinSimplex::new(0, &[1]))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `r.noise(x/8, z/8)` read on the real mod (Polytone 26.3-6.8.6): a grass colormap whose pixel
|
||||
/// encodes the value, decoded to about 0.01 (polytone-work/rnoise in the measurement notes).
|
||||
#[test]
|
||||
fn the_builtin_noise_reads_what_the_mod_draws() {
|
||||
for (x, z, mod_value) in [
|
||||
(0, 10, -0.28373),
|
||||
(3, 10, -0.14683),
|
||||
(10, 0, 0.31833),
|
||||
(24, 14, -0.55941),
|
||||
(40, 25, -0.48358),
|
||||
(7, 3, -0.68202),
|
||||
] {
|
||||
let value = builtin().get(f64::from(x) / 8.0, f64::from(z) / 8.0);
|
||||
assert!(
|
||||
(value - mod_value).abs() < 0.02,
|
||||
"({x}, {z}): {value} against {mod_value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The octaves add as `PerlinSimplexNoise`'s do: `{0, 1}` is a third of `{1}` and two thirds of
|
||||
/// `{0}` (measured: 0.067 = 0.33 x -0.284 + 0.67 x 0.247 at (0, 10) / 8).
|
||||
#[test]
|
||||
fn two_octaves_weigh_a_third_and_two_thirds() {
|
||||
let both = PerlinSimplex::new(0, &[0, 1]);
|
||||
let (low, high) = (PerlinSimplex::new(0, &[0]), PerlinSimplex::new(0, &[1]));
|
||||
for (x, y) in [(0.0, 1.25), (2.5, -3.0), (17.1, 4.4)] {
|
||||
let mixed = high.get(x, y) / 3.0 + low.get(x, y) * 2.0 / 3.0;
|
||||
assert!((both.get(x, y) - mixed).abs() < 1e-12);
|
||||
}
|
||||
assert!((both.get(0.0, 1.25) - 0.067).abs() < 0.02);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
//! settings, kept in `horizon-extras.txt` next to `options.txt` so that file
|
||||
//! stays exactly vanilla's, and the logic behind them. Every extra is off by
|
||||
//! default; see `docs/extras.md`. Horizon's own switches outside the Extras page
|
||||
//! (No AI, Agent Control) are kept here too.
|
||||
//! (No AI, Agent Control, Discord Rich Presence) are kept here too.
|
||||
use std::{collections::VecDeque, fs, path::Path};
|
||||
|
||||
pub const FILE: &str = "horizon-extras.txt";
|
||||
@@ -145,6 +145,8 @@ pub struct Extras {
|
||||
/// 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,
|
||||
@@ -166,6 +168,24 @@ pub struct Extras {
|
||||
/// 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 (no line until chosen:
|
||||
/// `effective_others`), and whether their trails and auras are hidden (off by default).
|
||||
pub cosmetics_mine: bool,
|
||||
pub cosmetics_others: Option<horizon_cosmetics::prefs::Others>,
|
||||
pub cosmetics_hide_fx: bool,
|
||||
/// My cosmetics in single player too (`cosmeticsSolo`, off by default): the window's own server
|
||||
/// names the player by their offline profile, which the service doesn't know, so their own
|
||||
/// cosmetics and name tag style show only with this on.
|
||||
pub cosmetics_solo: bool,
|
||||
/// Whether the signed-in account owns a cosmetic, as the service said (never saved; the game
|
||||
/// sets it): with no choice made, others' cosmetics show only then.
|
||||
pub cosmetics_owner: bool,
|
||||
/// Horizon's mark before the names of other Horizon players (name tags, player list), and
|
||||
/// whether the service may say this player plays Horizon: both on by default.
|
||||
pub horizon_marks: bool,
|
||||
pub horizon_listed: 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`).
|
||||
@@ -213,6 +233,7 @@ impl Default for Extras {
|
||||
no_ai: false,
|
||||
agent_control: false,
|
||||
repro_keys: false,
|
||||
discord_presence: true,
|
||||
splitscreen: false,
|
||||
splitscreen_two: 0,
|
||||
splitscreen_three: 0,
|
||||
@@ -220,6 +241,13 @@ impl Default for Extras {
|
||||
wheel_cycles_settings: false,
|
||||
music_off: Vec::new(),
|
||||
music_page: false,
|
||||
cosmetics_mine: true,
|
||||
cosmetics_others: None,
|
||||
cosmetics_hide_fx: false,
|
||||
cosmetics_solo: false,
|
||||
cosmetics_owner: false,
|
||||
horizon_marks: true,
|
||||
horizon_listed: true,
|
||||
three_d_skin_layers: false,
|
||||
pvp: Default::default(),
|
||||
}
|
||||
@@ -229,6 +257,11 @@ fn bool_text(v: bool) -> &'static str {
|
||||
if v { "true" } else { "false" }
|
||||
}
|
||||
impl Extras {
|
||||
/// How much of other players' cosmetics shows: the player's choice, else all of it for an
|
||||
/// account that owns a cosmetic and none for any other (a vanilla game).
|
||||
pub fn effective_others(&self) -> horizon_cosmetics::prefs::Others {
|
||||
horizon_cosmetics::prefs::Others::effective(self.cosmetics_others, self.cosmetics_owner)
|
||||
}
|
||||
pub fn load(dir: &Path) -> Self {
|
||||
fs::read_to_string(dir.join(FILE))
|
||||
.map(|t| Self::parse(&t))
|
||||
@@ -305,6 +338,7 @@ impl Extras {
|
||||
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),
|
||||
@@ -323,6 +357,15 @@ impl Extras {
|
||||
.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).or(e.cosmetics_others)
|
||||
}
|
||||
"cosmeticsHideFx" => e.cosmetics_hide_fx = flag(e.cosmetics_hide_fx),
|
||||
"cosmeticsSolo" => e.cosmetics_solo = flag(e.cosmetics_solo),
|
||||
horizon_cosmetics::prefs::MARKS => e.horizon_marks = flag(e.horizon_marks),
|
||||
horizon_cosmetics::prefs::LISTED => e.horizon_listed = flag(e.horizon_listed),
|
||||
SKIN_LAYERS_KEY => e.three_d_skin_layers = flag(e.three_d_skin_layers),
|
||||
key => {
|
||||
e.pvp.parse_line(key, value);
|
||||
@@ -333,7 +376,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
let mut text = format!(
|
||||
"packBrowser:{}\npregen:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\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:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\n{}:{}\n",
|
||||
"packBrowser:{}\npregen:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\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:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsHideFx:{}\ncosmeticsSolo:{}\n{}:{}\n{}:{}\n{}:{}\n",
|
||||
bool_text(self.pack_browser),
|
||||
bool_text(self.pregen),
|
||||
bool_text(self.fullbright),
|
||||
@@ -375,6 +418,7 @@ impl Extras {
|
||||
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,
|
||||
@@ -382,6 +426,16 @@ impl Extras {
|
||||
bool_text(self.wheel_cycles_settings),
|
||||
self.music_off.join(","),
|
||||
bool_text(self.music_page),
|
||||
bool_text(self.cosmetics_mine),
|
||||
// Written once the player chose.
|
||||
self.cosmetics_others
|
||||
.map_or(String::new(), |o| format!("cosmeticsOthers:{}\n", o.name())),
|
||||
bool_text(self.cosmetics_hide_fx),
|
||||
bool_text(self.cosmetics_solo),
|
||||
horizon_cosmetics::prefs::MARKS,
|
||||
bool_text(self.horizon_marks),
|
||||
horizon_cosmetics::prefs::LISTED,
|
||||
bool_text(self.horizon_listed),
|
||||
SKIN_LAYERS_KEY,
|
||||
bool_text(self.three_d_skin_layers),
|
||||
);
|
||||
@@ -559,6 +613,8 @@ pub enum Item {
|
||||
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
|
||||
NoAi,
|
||||
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.
|
||||
@@ -567,6 +623,13 @@ pub enum Item {
|
||||
/// The Select Resource Packs screen's Add button (Modrinth).
|
||||
PackBrowser,
|
||||
Pregen,
|
||||
/// Shown only while cosmetics are enabled (`horizon_cosmetics::enabled`).
|
||||
CosmeticsMine,
|
||||
CosmeticsOthers,
|
||||
CosmeticsHideFx,
|
||||
CosmeticsSolo,
|
||||
HorizonMarks,
|
||||
HorizonMe,
|
||||
/// On the PvP & HUD page (`pvp::rows`).
|
||||
Pvp(crate::pvp::Opt),
|
||||
}
|
||||
@@ -574,10 +637,8 @@ pub enum Item {
|
||||
pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
use Item::*;
|
||||
// Fullbright, Dynamic lights and Hide block-break particles change the picture: they
|
||||
// are on the graphics screen's Quality page (`graphics_ui::rows`), not here. The Cosmetics
|
||||
// group comes last, from the cosmetics' own section (`pages::Place::Extras`) while they are
|
||||
// offered.
|
||||
vec![
|
||||
// are on the graphics screen's Quality page (`graphics_ui::rows`), not here.
|
||||
let mut rows = vec![
|
||||
Err("horizon.extras.header.third_person"),
|
||||
Ok(OwnNameTag),
|
||||
Err("horizon.extras.header.inventory"),
|
||||
@@ -598,7 +659,19 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
Err("horizon.extras.header.menus"),
|
||||
Ok(WheelCyclesSettings),
|
||||
Ok(PackBrowser),
|
||||
]
|
||||
];
|
||||
if horizon_cosmetics::enabled() {
|
||||
rows.extend([
|
||||
Err("horizon.extras.header.cosmetics"),
|
||||
Ok(CosmeticsMine),
|
||||
Ok(CosmeticsSolo),
|
||||
Ok(CosmeticsOthers),
|
||||
Ok(CosmeticsHideFx),
|
||||
Ok(HorizonMarks),
|
||||
Ok(HorizonMe),
|
||||
]);
|
||||
}
|
||||
rows
|
||||
}
|
||||
impl Item {
|
||||
/// The language key of the caption (`horizon.*`, assets/horizon/lang).
|
||||
@@ -618,12 +691,19 @@ impl Item {
|
||||
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::Pregen => "horizon.extras.pregen",
|
||||
Item::CosmeticsMine => "horizon.extras.cosmetics_mine",
|
||||
Item::CosmeticsOthers => "horizon.extras.cosmetics_others",
|
||||
Item::CosmeticsHideFx => "horizon.extras.cosmetics_hide_fx",
|
||||
Item::CosmeticsSolo => "horizon.extras.cosmetics_solo",
|
||||
Item::HorizonMarks => "horizon.extras.horizon_marks",
|
||||
Item::HorizonMe => "horizon.extras.horizon_me",
|
||||
Item::Pvp(o) => o.caption_key(),
|
||||
}
|
||||
}
|
||||
@@ -644,12 +724,19 @@ impl Item {
|
||||
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_list_settings.tooltip",
|
||||
Item::PackBrowser => "horizon.extras.pack_browser.tooltip",
|
||||
Item::Pregen => "horizon.extras.pregen.tooltip",
|
||||
Item::CosmeticsMine => "horizon.extras.cosmetics_mine.tooltip",
|
||||
Item::CosmeticsOthers => "horizon.extras.cosmetics_others.tooltip",
|
||||
Item::CosmeticsHideFx => "horizon.extras.cosmetics_hide_fx.tooltip",
|
||||
Item::CosmeticsSolo => "horizon.extras.cosmetics_solo.tooltip",
|
||||
Item::HorizonMarks => "horizon.extras.horizon_marks.tooltip",
|
||||
Item::HorizonMe => "horizon.extras.horizon_me.tooltip",
|
||||
Item::Pvp(o) => o.description_key(),
|
||||
}
|
||||
}
|
||||
@@ -681,10 +768,17 @@ impl Item {
|
||||
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::Pregen => "Chunk pregeneration",
|
||||
Item::CosmeticsMine => "My cosmetics",
|
||||
Item::CosmeticsOthers => "Other players' cosmetics",
|
||||
Item::CosmeticsHideFx => "Hide other players' trails and auras",
|
||||
Item::CosmeticsSolo => "In single player",
|
||||
Item::HorizonMarks => "Horizon logos",
|
||||
Item::HorizonMe => "Show me as Horizon",
|
||||
Item::Pvp(o) => o.caption(),
|
||||
}
|
||||
}
|
||||
@@ -698,8 +792,10 @@ impl Item {
|
||||
ChatIndicator => ChatAnimation,
|
||||
Pvp(o) => return o.parent().map(Pvp),
|
||||
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
|
||||
| AutoReconnect | NoAi | AgentControl | ReproKeys | Splitscreen | ChatAnimation
|
||||
| WheelCyclesSettings | MusicPage | PackBrowser | Pregen => {
|
||||
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
|
||||
| ChatAnimation | WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine
|
||||
| CosmeticsOthers | CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe
|
||||
| Pregen => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -748,6 +844,9 @@ impl Item {
|
||||
Item::NoAi => {
|
||||
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
|
||||
}
|
||||
Item::DiscordPresence => {
|
||||
"Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address."
|
||||
}
|
||||
Item::Splitscreen => {
|
||||
"In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play."
|
||||
}
|
||||
@@ -757,6 +856,24 @@ impl Item {
|
||||
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 unless you own cosmetics, until you choose. Hidden, the game never asks what they wear."
|
||||
}
|
||||
Item::CosmeticsHideFx => {
|
||||
"Other players' trails and auras stay hidden; their other cosmetics still show. Off by default."
|
||||
}
|
||||
Item::CosmeticsSolo => {
|
||||
"Show your cosmetics and your name tag's badge in your single-player worlds too (your name tag with Own name tag). Off: only on servers."
|
||||
}
|
||||
Item::HorizonMarks => {
|
||||
"Show the Horizon logo next to Horizon players: a small logo before the names of other players who use Horizon, above their heads and in the player list. Only Horizon players see it: servers receive nothing."
|
||||
}
|
||||
Item::HorizonMe => {
|
||||
"Show me as a Horizon player: other Horizon players see the logo by your name. On, the service answers anyone who asks about you that you played Horizon in the last 3 hours; off, it never reports you."
|
||||
}
|
||||
Item::Pvp(o) => o.description(),
|
||||
Item::WheelCyclesSettings => {
|
||||
"The scroll wheel over a button in the settings changes its value, as in vanilla. Off, the wheel only scrolls the list."
|
||||
@@ -783,6 +900,12 @@ impl Item {
|
||||
| Item::Splitscreen
|
||||
| Item::WheelCyclesSettings
|
||||
| Item::PackBrowser => Some(Cost::None),
|
||||
Item::CosmeticsMine
|
||||
| Item::CosmeticsOthers
|
||||
| Item::CosmeticsHideFx
|
||||
| Item::CosmeticsSolo
|
||||
| Item::HorizonMarks
|
||||
| Item::HorizonMe => Some(Cost::Low),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -804,10 +927,15 @@ impl Item {
|
||||
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::Pregen => e.pregen,
|
||||
Item::CosmeticsHideFx => e.cosmetics_hide_fx,
|
||||
Item::CosmeticsSolo => e.cosmetics_solo,
|
||||
Item::HorizonMarks => e.horizon_marks,
|
||||
Item::HorizonMe => e.horizon_listed,
|
||||
Item::Pvp(o) => return o.switch(&e.pvp),
|
||||
_ => return None,
|
||||
})
|
||||
@@ -817,14 +945,30 @@ impl Item {
|
||||
match self {
|
||||
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
|
||||
Item::Pvp(o) if o.switch(&e.pvp).is_none() => o.value(&e.pvp).unwrap_or_default(),
|
||||
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.effective_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: a PvP & HUD choice steps back; a switch flips
|
||||
/// either way). The sort key binds instead.
|
||||
/// 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,
|
||||
@@ -842,10 +986,21 @@ impl Item {
|
||||
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::Pregen => e.pregen = !e.pregen,
|
||||
Item::CosmeticsMine => e.cosmetics_mine = !e.cosmetics_mine,
|
||||
Item::CosmeticsOthers => {
|
||||
// From what the button shows; the result is the player's choice from now on.
|
||||
let o = e.effective_others();
|
||||
e.cosmetics_others = Some(if back { o.next().next() } else { o.next() });
|
||||
}
|
||||
Item::CosmeticsHideFx => e.cosmetics_hide_fx = !e.cosmetics_hide_fx,
|
||||
Item::CosmeticsSolo => e.cosmetics_solo = !e.cosmetics_solo,
|
||||
Item::HorizonMarks => e.horizon_marks = !e.horizon_marks,
|
||||
Item::HorizonMe => e.horizon_listed = !e.horizon_listed,
|
||||
Item::Pvp(o) => o.cycle(&mut e.pvp, back),
|
||||
Item::SortKey => {}
|
||||
}
|
||||
@@ -864,6 +1019,73 @@ 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 {
|
||||
cosmetics_owner: true,
|
||||
..Extras::default()
|
||||
};
|
||||
assert_eq!(
|
||||
(e.effective_others(), e.cosmetics_hide_fx, e.cosmetics_mine),
|
||||
(Others::Shown, false, true)
|
||||
);
|
||||
Item::CosmeticsOthers.cycle(&mut e, false);
|
||||
assert_eq!(e.cosmetics_others, Some(Others::NameTags));
|
||||
Item::CosmeticsOthers.cycle(&mut e, false);
|
||||
assert_eq!(e.cosmetics_others, Some(Others::Hidden));
|
||||
// The right button goes back.
|
||||
Item::CosmeticsOthers.cycle(&mut e, true);
|
||||
assert_eq!(e.cosmetics_others, Some(Others::NameTags));
|
||||
// Ownership is the service's, never written.
|
||||
assert_eq!(
|
||||
Extras::parse(&e.serialize()),
|
||||
Extras {
|
||||
cosmetics_owner: false,
|
||||
..e.clone()
|
||||
}
|
||||
);
|
||||
// The launcher's Wardrobe writes the same lines.
|
||||
assert_eq!(
|
||||
horizon_cosmetics::prefs::Prefs::parse(&e.serialize()).others,
|
||||
Some(Others::NameTags)
|
||||
);
|
||||
// Before this, the line was on or off.
|
||||
assert_eq!(
|
||||
Extras::parse("cosmeticsOthers:false").cosmetics_others,
|
||||
Some(Others::Hidden)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn others_cosmetics_are_off_for_a_player_who_owns_none_until_chosen() {
|
||||
use horizon_cosmetics::prefs::Others;
|
||||
let mut e = Extras::default();
|
||||
// Nothing chosen, nothing owned: the button shows Hidden and no line is written.
|
||||
assert_eq!(e.effective_others(), Others::Hidden);
|
||||
assert_eq!(
|
||||
Item::CosmeticsOthers.value(&e),
|
||||
crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden")
|
||||
);
|
||||
assert!(!e.serialize().contains("cosmeticsOthers"));
|
||||
// The service says the account owns one: shown, still not a choice.
|
||||
e.cosmetics_owner = true;
|
||||
assert_eq!(e.effective_others(), Others::Shown);
|
||||
assert!(!e.serialize().contains("cosmeticsOthers"));
|
||||
// A non-owner pressing the button goes on from Hidden, and that choice is kept.
|
||||
e.cosmetics_owner = false;
|
||||
Item::CosmeticsOthers.cycle(&mut e, false);
|
||||
assert_eq!(e.cosmetics_others, Some(Others::Shown));
|
||||
let saved = Extras::parse(&e.serialize());
|
||||
assert_eq!(saved.effective_others(), Others::Shown);
|
||||
// An owner who chose Hidden keeps it.
|
||||
let hidden = Extras {
|
||||
cosmetics_owner: true,
|
||||
..Extras::parse("cosmeticsOthers:hidden\n")
|
||||
};
|
||||
assert_eq!(hidden.effective_others(), Others::Hidden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_wheel_setting_from_before_the_default_changed_is_dropped() {
|
||||
assert!(!Extras::parse("wheelCyclesSettings:true\n").wheel_cycles_settings);
|
||||
@@ -882,7 +1104,7 @@ mod tests {
|
||||
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag && !d.ui_sound_reserve);
|
||||
assert!(!d.hide_block_particles && !d.pregen);
|
||||
assert!(!d.music_page && d.music_off.is_empty());
|
||||
assert!(!d.wheel_cycles_settings);
|
||||
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();
|
||||
@@ -921,6 +1143,7 @@ mod tests {
|
||||
e.sort_key = "key.keyboard.r".into();
|
||||
e.chat_animation = true;
|
||||
e.chat_indicator = false;
|
||||
e.discord_presence = false;
|
||||
assert!(!e.splitscreen);
|
||||
Item::Splitscreen.cycle(&mut e, false);
|
||||
assert_eq!(Item::Splitscreen.switch(&e), Some(true));
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! The Horizon Extras page, on the settings shell (`settings_shell`): which rows it has and what
|
||||
//! pressing them does. Each one's value and what it does stay in `extras::Item`; the sections crates
|
||||
//! add (`horizon_hooks::pages::Place::Extras`) keep theirs.
|
||||
//! pressing them does. Each one's value and what it does stay in `extras::Item`.
|
||||
use crate::{
|
||||
extras::{self, Item},
|
||||
lang,
|
||||
@@ -8,7 +7,6 @@ use crate::{
|
||||
settings::Settings,
|
||||
settings_shell::{self as shell, Binding, Group, Output, Row, Screen, Sheet, Value},
|
||||
};
|
||||
use horizon_hooks::pages::{self as added, Act, Label};
|
||||
|
||||
const TITLE: &str = "horizon.pages.extras";
|
||||
const ABOUT: &str = "horizon.pages.extras.tooltip";
|
||||
@@ -33,125 +31,6 @@ pub(crate) enum ExtraRow {
|
||||
/// It isn't installed: where to get it. A note, not a button: Options > Mods already has the
|
||||
/// one way into the Mods page (`every_setting_and_page_is_reached_from_one_place`).
|
||||
ZoomInstall,
|
||||
/// Row `.1` of the section registered page `.0` adds (`Place::Extras`).
|
||||
Added(usize, usize),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// A test's own pages, instead of the game's registered ones: each test runs on its thread.
|
||||
static TEST_PAGES: std::cell::Cell<Option<&'static [&'static dyn added::Page]>> =
|
||||
const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// The pages crates registered, all places.
|
||||
fn pages() -> &'static [&'static dyn added::Page] {
|
||||
#[cfg(test)]
|
||||
if let Some(pages) = TEST_PAGES.get() {
|
||||
return pages;
|
||||
}
|
||||
added::engine::all()
|
||||
}
|
||||
|
||||
/// The sections of this page, by their index among the registered pages.
|
||||
fn sections() -> impl Iterator<Item = (usize, &'static dyn added::Page)> {
|
||||
let pages = pages().iter().copied().enumerate();
|
||||
pages.filter(|(_, page)| page.place() == added::Place::Extras)
|
||||
}
|
||||
|
||||
fn label_text(label: Label) -> String {
|
||||
match label {
|
||||
Label::Key(key) => lang::get(key),
|
||||
Label::Value(text) => text,
|
||||
}
|
||||
}
|
||||
|
||||
/// The page's group named `name`, made at the end if it has none.
|
||||
fn group_named<'a>(
|
||||
groups: &'a mut Vec<Group<ExtraRow>>,
|
||||
name: &'static str,
|
||||
) -> &'a mut Group<ExtraRow> {
|
||||
let at = groups.iter().position(|group| group.name == name);
|
||||
let at = at.unwrap_or_else(|| {
|
||||
groups.push(Group {
|
||||
name,
|
||||
rows: Vec::new(),
|
||||
});
|
||||
groups.len() - 1
|
||||
});
|
||||
&mut groups[at]
|
||||
}
|
||||
|
||||
/// The sections' rows, in the groups their titles and headings name.
|
||||
fn add_sections(groups: &mut Vec<Group<ExtraRow>>) {
|
||||
for (page, section) in sections() {
|
||||
let mut rows = Vec::new();
|
||||
section.rows(&mut rows);
|
||||
let mut name = section.title();
|
||||
for (r, row) in rows.into_iter().enumerate() {
|
||||
let id = ExtraRow::Added(page, r);
|
||||
let row = match row {
|
||||
added::Row::Heading(key) => {
|
||||
name = key;
|
||||
continue;
|
||||
}
|
||||
added::Row::Note(text) => Row::new(id, text, Value::Note).live(false),
|
||||
added::Row::Toggle { label, on } => {
|
||||
Row::new(id, lang::get(label), Value::Switch(on))
|
||||
}
|
||||
added::Row::Cycle { label, value } => {
|
||||
Row::new(id, lang::get(label), Value::Choice(label_text(value)))
|
||||
}
|
||||
added::Row::Slider { label, value, text } => {
|
||||
let position = value.clamp(0.0, 1.0);
|
||||
let text = label_text(text);
|
||||
let value = Value::Slider {
|
||||
position,
|
||||
steps: None,
|
||||
text,
|
||||
};
|
||||
Row::new(id, lang::get(label), value)
|
||||
}
|
||||
added::Row::Button {
|
||||
label,
|
||||
active: true,
|
||||
..
|
||||
} => Row::new(id, lang::get(label), Value::Press),
|
||||
added::Row::Key { label, key } => {
|
||||
let key = crate::keybinds::display_name(&key).plain();
|
||||
Row::new(id, lang::get(label), Value::Read(key))
|
||||
}
|
||||
// A row kind newer than this engine.
|
||||
_ => continue,
|
||||
};
|
||||
let row = match section.tooltip(r) {
|
||||
Some(about) => row.about(label_text(about)),
|
||||
None => row,
|
||||
};
|
||||
group_named(groups, name).rows.push(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Row `r` of registered page `page` was acted on: the options screen saves when it closes.
|
||||
fn act(page: usize, r: usize, act: Act) -> Vec<Output> {
|
||||
let Some(section) = pages().get(page) else {
|
||||
return Vec::new();
|
||||
};
|
||||
section.act(r, act);
|
||||
vec![Output::Event(Event::Changed)]
|
||||
}
|
||||
|
||||
/// Whether row `r` of registered page `page` steps (a switch or a cycle).
|
||||
fn steps(page: usize, r: usize) -> bool {
|
||||
let mut rows = Vec::new();
|
||||
if let Some(section) = pages().get(page) {
|
||||
section.rows(&mut rows);
|
||||
}
|
||||
matches!(
|
||||
rows.get(r),
|
||||
Some(added::Row::Toggle { .. } | added::Row::Cycle { .. })
|
||||
)
|
||||
}
|
||||
|
||||
fn item_row(item: Item, s: &Settings) -> Row<ExtraRow> {
|
||||
@@ -206,13 +85,9 @@ impl Screen for ExtrasScreen {
|
||||
fn note_lines(&self) -> usize {
|
||||
NOTE_LINES
|
||||
}
|
||||
/// The rows read the extras, the mods and the sections' rows.
|
||||
/// The rows read the extras, the mods and whether cosmetics are offered.
|
||||
fn stamp(&self, s: &Settings) -> u64 {
|
||||
let mut added = Vec::new();
|
||||
for (_, section) in sections() {
|
||||
section.rows(&mut added);
|
||||
}
|
||||
shell::stamp_of(&(&s.extras, &s.mods, added))
|
||||
shell::stamp_of(&(&s.extras, &s.mods, horizon_cosmetics::enabled()))
|
||||
}
|
||||
fn sheets(&self, s: &Settings) -> Vec<Sheet<(), ExtraRow>> {
|
||||
let mut groups = vec![Group {
|
||||
@@ -232,7 +107,6 @@ impl Screen for ExtrasScreen {
|
||||
}
|
||||
}
|
||||
}
|
||||
add_sections(&mut groups);
|
||||
vec![Sheet::new((), lang::get(TITLE), lang::get(ABOUT), groups)]
|
||||
}
|
||||
fn press(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Vec<Output> {
|
||||
@@ -251,29 +125,16 @@ impl Screen for ExtrasScreen {
|
||||
Output::Event(Event::NativeSettings(i)),
|
||||
],
|
||||
ExtraRow::ZoomInstall => Vec::new(),
|
||||
ExtraRow::Added(page, r) => {
|
||||
act(page, r, if back { Act::PressBack } else { Act::Press })
|
||||
}
|
||||
}
|
||||
}
|
||||
fn slide(&mut self, _s: &mut Settings, row: ExtraRow, position: f32) {
|
||||
if let ExtraRow::Added(page, r) = row {
|
||||
act(page, r, Act::Slide(position));
|
||||
}
|
||||
}
|
||||
/// With the wheel extra on, the wheel over an item changes it, without a click; the mod's rows
|
||||
/// let the list scroll.
|
||||
fn wheel_step(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Option<Vec<Output>> {
|
||||
match row {
|
||||
ExtraRow::Item(item) => {
|
||||
item.cycle(&mut s.extras, back);
|
||||
Some(vec![Output::Event(Event::Changed)])
|
||||
}
|
||||
ExtraRow::Added(page, r) if steps(page, r) => {
|
||||
Some(act(page, r, if back { Act::PressBack } else { Act::Press }))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
let ExtraRow::Item(item) = row else {
|
||||
return None;
|
||||
};
|
||||
item.cycle(&mut s.extras, back);
|
||||
Some(vec![Output::Event(Event::Changed)])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,6 +181,22 @@ mod tests {
|
||||
assert_ne!(s.extras.mouse_tweaks_wheel, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_three_way_cosmetics_row_turns_both_ways() {
|
||||
use horizon_cosmetics::prefs::Others;
|
||||
let mut s = Settings::default();
|
||||
let mut screen = ExtrasScreen;
|
||||
let row = ExtraRow::Item(Item::CosmeticsOthers);
|
||||
// Not chosen, nothing owned: it shows Hidden, and forward wraps to Shown.
|
||||
assert_eq!(s.extras.effective_others(), Others::Hidden);
|
||||
screen.press(&mut s, row, false);
|
||||
assert_eq!(s.extras.cosmetics_others, Some(Others::Shown));
|
||||
screen.press(&mut s, row, true);
|
||||
assert_eq!(s.extras.cosmetics_others, Some(Others::Hidden));
|
||||
screen.press(&mut s, row, true);
|
||||
assert_eq!(s.extras.cosmetics_others, Some(Others::NameTags));
|
||||
}
|
||||
|
||||
/// The sort key binds instead of cycling; the wheel leaves it be.
|
||||
#[test]
|
||||
fn the_key_row_waits_for_a_key() {
|
||||
@@ -415,146 +292,4 @@ mod tests {
|
||||
shell.key(&mut s, K::ArrowDown, None, false, size);
|
||||
assert_eq!(shell.focused_row(), Some(first));
|
||||
}
|
||||
|
||||
/// A section with a switch in the page's Multiplayer group, then a heading of its own with a
|
||||
/// number that steps either way, a note and a volume.
|
||||
struct Section {
|
||||
on: std::sync::Mutex<bool>,
|
||||
step: std::sync::Mutex<i32>,
|
||||
volume: std::sync::Mutex<f32>,
|
||||
}
|
||||
impl added::Page for Section {
|
||||
fn id(&self) -> &'static str {
|
||||
"test.section"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"horizon.extras.header.multiplayer"
|
||||
}
|
||||
fn place(&self) -> added::Place {
|
||||
added::Place::Extras
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<added::Row>) {
|
||||
rows.push(added::Row::Toggle {
|
||||
label: "test.section.switch",
|
||||
on: *self.on.lock().unwrap(),
|
||||
});
|
||||
rows.push(added::Row::Heading("test.section.heading"));
|
||||
rows.push(added::Row::Cycle {
|
||||
label: "test.section.step",
|
||||
value: Label::Value(self.step.lock().unwrap().to_string()),
|
||||
});
|
||||
rows.push(added::Row::Note("a note".into()));
|
||||
rows.push(added::Row::Slider {
|
||||
label: "test.section.volume",
|
||||
value: *self.volume.lock().unwrap(),
|
||||
text: Label::Value(String::new()),
|
||||
});
|
||||
}
|
||||
fn act(&self, row: usize, act: Act) {
|
||||
match (row, act) {
|
||||
(0, Act::Press | Act::PressBack) => {
|
||||
let mut on = self.on.lock().unwrap();
|
||||
*on = !*on;
|
||||
}
|
||||
(2, Act::Press) => *self.step.lock().unwrap() += 1,
|
||||
(2, Act::PressBack) => *self.step.lock().unwrap() -= 1,
|
||||
(4, Act::Slide(v)) => *self.volume.lock().unwrap() = v,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A page for Horizon Settings: never on Extras.
|
||||
struct Elsewhere;
|
||||
impl added::Page for Elsewhere {
|
||||
fn id(&self) -> &'static str {
|
||||
"test.elsewhere"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"test.elsewhere"
|
||||
}
|
||||
fn place(&self) -> added::Place {
|
||||
added::Place::HorizonSettings
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<added::Row>) {
|
||||
rows.push(added::Row::button("test.elsewhere.button"));
|
||||
}
|
||||
fn act(&self, _: usize, _: Act) {}
|
||||
}
|
||||
|
||||
fn with_section() -> &'static Section {
|
||||
let section: &'static Section = Box::leak(Box::new(Section {
|
||||
on: false.into(),
|
||||
step: 0.into(),
|
||||
volume: 0.5.into(),
|
||||
}));
|
||||
let pages: &'static [&'static dyn added::Page] = Box::leak(Box::new([
|
||||
&Elsewhere as &dyn added::Page,
|
||||
section as &dyn added::Page,
|
||||
]));
|
||||
TEST_PAGES.set(Some(pages));
|
||||
section
|
||||
}
|
||||
|
||||
/// The section's first row joins the page's own Multiplayer group, after its rows; its heading
|
||||
/// starts a group after the page's; a page for elsewhere adds nothing.
|
||||
#[test]
|
||||
fn a_section_joins_the_group_its_title_names_and_heads_its_own() {
|
||||
with_section();
|
||||
let s = Settings::default();
|
||||
let sheets = ExtrasScreen.sheets(&s);
|
||||
let groups = &sheets[0].groups;
|
||||
let ids = |name: &str| -> Vec<ExtraRow> {
|
||||
let group = groups.iter().find(|g| g.name == name).unwrap();
|
||||
group.rows.iter().map(|r| r.id).collect()
|
||||
};
|
||||
let multiplayer = ids("horizon.extras.header.multiplayer");
|
||||
assert_eq!(multiplayer.last(), Some(&ExtraRow::Added(1, 0)));
|
||||
assert_eq!(groups.last().unwrap().name, "test.section.heading");
|
||||
let own = [
|
||||
ExtraRow::Added(1, 2),
|
||||
ExtraRow::Added(1, 3),
|
||||
ExtraRow::Added(1, 4),
|
||||
];
|
||||
assert_eq!(ids("test.section.heading"), own);
|
||||
let all = rows_of(&s);
|
||||
assert!(!all.iter().any(|r| matches!(r.id, ExtraRow::Added(0, _))));
|
||||
}
|
||||
|
||||
/// A click flips the switch, a Shift click steps the number back, the wheel steps it, and the
|
||||
/// note takes no click; the rows show what the section holds after each.
|
||||
#[test]
|
||||
fn a_sections_rows_act_on_it() {
|
||||
let section = with_section();
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<ExtrasScreen>::default();
|
||||
let switch = ExtraRow::Added(1, 0);
|
||||
let at = shell.point_at(&s, SIZE, switch);
|
||||
assert!(
|
||||
!shell
|
||||
.click(&mut s, at, SIZE, false, false, false)
|
||||
.is_empty()
|
||||
);
|
||||
assert!(*section.on.lock().unwrap());
|
||||
let value = |s: &Settings, id| rows_of(s).into_iter().find(|r| r.id == id).unwrap().value;
|
||||
assert_eq!(value(&s, switch), Value::Switch(true));
|
||||
let step = ExtraRow::Added(1, 2);
|
||||
let mut screen = ExtrasScreen;
|
||||
screen.press(&mut s, step, true);
|
||||
assert_eq!(value(&s, step), Value::Choice("-1".into()));
|
||||
assert!(screen.wheel_step(&mut s, step, false).is_some());
|
||||
assert_eq!(*section.step.lock().unwrap(), 0);
|
||||
assert_eq!(
|
||||
screen.wheel_step(&mut s, ExtraRow::Added(1, 3), false),
|
||||
None
|
||||
);
|
||||
let note = shell.point_at(&s, SIZE, ExtraRow::Added(1, 3));
|
||||
assert!(
|
||||
shell
|
||||
.click(&mut s, note, SIZE, false, false, false)
|
||||
.is_empty()
|
||||
);
|
||||
screen.slide(&mut s, ExtraRow::Added(1, 4), 0.25);
|
||||
assert_eq!(*section.volume.lock().unwrap(), 0.25);
|
||||
}
|
||||
}
|
||||
@@ -231,10 +231,8 @@ pub struct FogInputs<'a> {
|
||||
/// `GameRenderer.nightVisionScale` while Night Vision is on without
|
||||
/// Darkness, else 0 (`FogRenderer.computeFogColor`'s brightenFactor).
|
||||
pub night_vision: f32,
|
||||
/// The packs' `environment` fog settings (Polytone's).
|
||||
pub environment: crate::pack_palette::Environment,
|
||||
/// The packs' fluid modifiers' fog.
|
||||
pub fluid_fog: crate::pack_palette::FluidFogs,
|
||||
/// The packs' Polytone `environment` fog settings.
|
||||
pub environment: horizon_polytone::colors::Environment,
|
||||
}
|
||||
|
||||
/// `FogRenderer.setupFog` + `computeFogColor`.
|
||||
@@ -399,24 +397,6 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
}
|
||||
}
|
||||
}
|
||||
// A pack's fluid modifier over its fluid's fog, after everything else (wiki "Custom Colors").
|
||||
let fluid = match input.fog_type {
|
||||
FogType::Lava => input.fluid_fog.lava,
|
||||
FogType::Water if input.blindness.is_none() && input.darkness.is_none() => {
|
||||
input.fluid_fog.water
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(f) = fluid {
|
||||
if let Some(rgb) = f.color {
|
||||
let [r, g, b] = self::rgb(rgb);
|
||||
fog.color = [r, g, b, 1.0];
|
||||
}
|
||||
let end = fog.environmental_end * f.radius;
|
||||
let start = end - (end - fog.environmental_start * f.radius) * f.fade;
|
||||
(fog.environmental_start, fog.environmental_end) = (start, end);
|
||||
(fog.sky_end, fog.cloud_end) = (end, end);
|
||||
}
|
||||
let span = (rd / 10.0).clamp(4.0, 64.0);
|
||||
fog.render_distance_start = rd - span;
|
||||
fog.render_distance_end = rd;
|
||||
@@ -515,7 +495,6 @@ mod tests {
|
||||
full_water_vision: false,
|
||||
night_vision: 0.0,
|
||||
environment: Default::default(),
|
||||
fluid_fog: Default::default(),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
@@ -602,7 +581,7 @@ mod tests {
|
||||
let tables = FogTables {
|
||||
water_fog_end: vec![1.0],
|
||||
};
|
||||
let environment = crate::pack_palette::Environment {
|
||||
let environment = horizon_polytone::colors::Environment {
|
||||
powder_snow_fog: Some(0xFF0000),
|
||||
blindness_fog_distance: 40.0,
|
||||
water_fog_brightening: 0.0,
|
||||
@@ -632,41 +611,9 @@ mod tests {
|
||||
let kept = setup_fog(&tables, &state, &input).color;
|
||||
assert_eq!(&kept[..3], &[0.1, 0.2, 0.4]);
|
||||
}
|
||||
#[test]
|
||||
fn a_fluid_modifier_scales_and_colours_its_fog_last() {
|
||||
let sky = Sky {
|
||||
fog_start: 10.0,
|
||||
fog_end: 96.0,
|
||||
..Sky::default()
|
||||
};
|
||||
let tables = FogTables {
|
||||
water_fog_end: vec![1.0],
|
||||
};
|
||||
let mut input = inputs(&sky);
|
||||
input.fog_type = FogType::Lava;
|
||||
input.fire_resistance = true;
|
||||
let vanilla = setup_fog(&tables, &FogState::default(), &input);
|
||||
assert_eq!(
|
||||
(vanilla.environmental_start, vanilla.environmental_end),
|
||||
(0.0, 5.0)
|
||||
);
|
||||
input.fluid_fog.lava = Some(crate::pack_palette::FluidFog {
|
||||
color: Some(0x00FF00),
|
||||
radius: 2.0,
|
||||
fade: 0.5,
|
||||
});
|
||||
let fog = setup_fog(&tables, &FogState::default(), &input);
|
||||
assert_eq!(fog.color, [0.0, 1.0, 0.0, 1.0]);
|
||||
// The end doubles; half the fade from 0 to 10 starts it at 5.
|
||||
assert_eq!(
|
||||
(fog.environmental_start, fog.environmental_end),
|
||||
(5.0, 10.0)
|
||||
);
|
||||
assert_eq!((fog.sky_end, fog.cloud_end), (10.0, 10.0));
|
||||
}
|
||||
fn inputs_with<'a>(
|
||||
input: &FogInputs<'a>,
|
||||
environment: crate::pack_palette::Environment,
|
||||
environment: horizon_polytone::colors::Environment,
|
||||
) -> FogInputs<'a> {
|
||||
FogInputs {
|
||||
environment,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -64,7 +64,7 @@ enum Writer {
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) struct SpriteAnimator {
|
||||
pub(super) struct SpriteAnimator {
|
||||
sprites: Vec<Sprite>,
|
||||
groups: Vec<Group>,
|
||||
writer: Writer,
|
||||
@@ -86,9 +86,6 @@ struct AnimationModes {
|
||||
day_time: i64,
|
||||
weather: f32,
|
||||
values: std::collections::HashMap<String, f64>,
|
||||
/// The open screen (an identity the game picks) and the ticks it has been open.
|
||||
screen: Option<u64>,
|
||||
screen_time: i64,
|
||||
}
|
||||
|
||||
impl AnimationModes {
|
||||
@@ -119,15 +116,8 @@ impl super::Renderer {
|
||||
day_time: i64,
|
||||
[rain, thunder]: [f32; 2],
|
||||
frame: Option<&crate::environment::Frame>,
|
||||
screen: Option<u64>,
|
||||
) {
|
||||
let modes = &mut self.animations.modes;
|
||||
// `screen_time`: a tick more while the same screen stays open, from 0 on a new one.
|
||||
modes.screen_time = match (screen, modes.screen) {
|
||||
(Some(open), Some(was)) if open == was => modes.screen_time + 1,
|
||||
_ => 0,
|
||||
};
|
||||
modes.screen = screen;
|
||||
modes.day_time = day_time;
|
||||
modes.weather = rain + thunder;
|
||||
modes.values.clear();
|
||||
@@ -143,16 +133,6 @@ impl super::Renderer {
|
||||
}
|
||||
|
||||
impl SpriteAnimator {
|
||||
/// What the modes read this frame, for the GUI atlas's sprites (their expressions aren't
|
||||
/// compiled: those run on ticks).
|
||||
pub(crate) fn mode_inputs(&self) -> horizon_assets::animation::ModeInputs<'static> {
|
||||
horizon_assets::animation::ModeInputs {
|
||||
day_time: self.modes.day_time,
|
||||
screen_time: self.modes.screen_time,
|
||||
weather: self.modes.weather,
|
||||
expression: &|_| None,
|
||||
}
|
||||
}
|
||||
/// `textures` are the sprite arrays, `split` layers each, `size` pixels
|
||||
/// wide; `animations` hold their frames at that size. `compute`: whether
|
||||
/// the device says it runs compute shaders writing storage textures.
|
||||
@@ -381,7 +361,6 @@ impl SpriteAnimator {
|
||||
let expression = |source: &str| values.get(source).copied();
|
||||
let inputs = horizon_assets::animation::ModeInputs {
|
||||
day_time: self.modes.day_time,
|
||||
screen_time: self.modes.screen_time,
|
||||
weather: self.modes.weather,
|
||||
expression: &expression,
|
||||
};
|
||||
|
||||
@@ -136,8 +136,6 @@ impl Arena {
|
||||
} else {
|
||||
wgpu::BufferUsages::VERTEX
|
||||
};
|
||||
// BLAS builds read sections' vertices here (`ray_scene`).
|
||||
let usage = usage | super::surface_setup::ray_query::blas_input(device);
|
||||
Self::with_usage(device, stride, capacity, usage, PAGE_BYTES)
|
||||
}
|
||||
/// Sorted translucent indices; `storage` when compute passes read them.
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
//! A bind group's validity depends on the identity of its buffers and the
|
||||
//! bound slice, not on the bytes written into those buffers.
|
||||
|
||||
pub(super) struct BindingKey<B, const N: usize> {
|
||||
pub first: u64,
|
||||
pub count: u64,
|
||||
pub buffers: [B; N],
|
||||
}
|
||||
|
||||
impl<B: PartialEq, const N: usize> BindingKey<B, N> {
|
||||
pub fn matches(&self, first: u64, count: u64, buffers: [&B; N]) -> bool {
|
||||
self.first == first
|
||||
&& self.count == count
|
||||
&& self
|
||||
.buffers
|
||||
.iter()
|
||||
.zip(buffers)
|
||||
.all(|(old, new)| old == new)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn changes_to_any_resource_or_range_invalidate_the_binding() {
|
||||
let key = BindingKey {
|
||||
first: 16,
|
||||
count: 8,
|
||||
buffers: [1u64, 2, 3, 4],
|
||||
};
|
||||
assert!(key.matches(16, 8, [&1, &2, &3, &4]));
|
||||
assert!(!key.matches(0, 8, [&1, &2, &3, &4]));
|
||||
assert!(!key.matches(16, 9, [&1, &2, &3, &4]));
|
||||
for i in 0..4 {
|
||||
let mut resources = [1u64, 2, 3, 4];
|
||||
resources[i] += 100;
|
||||
assert!(!key.matches(16, 8, resources.each_ref()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
//! What the renderer holds, for status, soak tests and memory reports (`horizon-ctl memory`,
|
||||
//! `scene_replay --memory`): resident sections and far tiles, and the bytes of its largest
|
||||
//! GPU resources and of its per-section bookkeeping.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::{
|
||||
Renderer, SectionGpu, SectionKey, TerrainVertex, arena, flat, lod, texture_bytes, visbuffer,
|
||||
};
|
||||
|
||||
impl Renderer {
|
||||
/// Sections with GPU buffers, for soak tests watching residency.
|
||||
pub fn resident_sections(&self) -> usize {
|
||||
self.sections.len()
|
||||
}
|
||||
/// Quads of the far terrain tiles with GPU buffers.
|
||||
pub fn lod_quads(&self) -> u64 {
|
||||
self.lods
|
||||
.values()
|
||||
.map(|tile| u64::from(tile.mesh.solid + tile.mesh.translucent))
|
||||
.sum()
|
||||
}
|
||||
/// Tiles and quads of the far terrain tiles with GPU buffers, by level.
|
||||
pub fn lod_levels(&self) -> Vec<[u64; 2]> {
|
||||
let mut levels = Vec::new();
|
||||
for (key, tile) in &self.lods {
|
||||
let level = usize::from(key.level);
|
||||
if levels.len() <= level {
|
||||
levels.resize(level + 1, [0, 0]);
|
||||
}
|
||||
levels[level][0] += 1;
|
||||
levels[level][1] += u64::from(tile.mesh.solid + tile.mesh.translucent);
|
||||
}
|
||||
levels
|
||||
}
|
||||
/// Whether solid quads go to the arena in the compact layout
|
||||
/// (`packed_quads`), and whether far tiles' own records are on
|
||||
/// (`lod::packed::far_packing`).
|
||||
pub fn packed_layout(&self) -> [bool; 2] {
|
||||
[self.packed_quads, crate::lod::packed::far_packing()]
|
||||
}
|
||||
/// Bytes of the arena the resident chunk sections hold, and of the one far
|
||||
/// tiles hold: the same split `gpu_census` reports, for `status`, so the
|
||||
/// compact layouts can be seen working.
|
||||
pub fn vertex_bytes_split(&self) -> [u64; 2] {
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
|
||||
[
|
||||
self.sections.values().map(used).sum(),
|
||||
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
|
||||
]
|
||||
}
|
||||
/// Bytes of the section vertex arenas' buffers, and of the vertices
|
||||
/// resident sections hold in them.
|
||||
pub fn section_vertex_bytes(&self) -> [u64; 2] {
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let vertices: u64 = self
|
||||
.sections
|
||||
.values()
|
||||
.chain(self.lods.values().map(|tile| &*tile.mesh))
|
||||
.map(|s| s.solid_slots + u64::from(s.translucent) * 4)
|
||||
.sum();
|
||||
let translucent = self.translucent_arena.as_ref();
|
||||
let arenas = self.arena.bytes() + translucent.map_or(0, arena::Arena::bytes);
|
||||
[arenas, vertices * stride]
|
||||
}
|
||||
/// Bytes of the renderer's largest GPU resources, by name (`scene_replay --memory`):
|
||||
/// the terrain arenas (their pages, and what resident sections and far tiles use of
|
||||
/// them), the quad indices, the block sprites with their mips, the LabPBR maps, flat
|
||||
/// draws' buffers and the visibility buffer's.
|
||||
pub fn gpu_census(&self) -> Vec<(&'static str, u64)> {
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
|
||||
let textures = |t: &[wgpu::Texture]| t.iter().map(texture_bytes).sum::<u64>();
|
||||
let translucent = self.translucent_arena.as_ref();
|
||||
vec![
|
||||
("section_arena_pages", self.arena.bytes()),
|
||||
(
|
||||
"translucent_arena_pages",
|
||||
translucent.map_or(0, arena::Arena::bytes),
|
||||
),
|
||||
("sorted_index_pages", self.sorted_indices.bytes()),
|
||||
(
|
||||
"near_section_vertices",
|
||||
self.sections.values().map(used).sum(),
|
||||
),
|
||||
(
|
||||
"held_section_vertices",
|
||||
self.hold
|
||||
.sections()
|
||||
.filter_map(|held| held.gpu.as_ref())
|
||||
.map(used)
|
||||
.sum(),
|
||||
),
|
||||
(
|
||||
"far_tile_vertices",
|
||||
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
|
||||
),
|
||||
("quad_indices", self.indices.size()),
|
||||
("block_sprites", textures(&self.sprite_textures)),
|
||||
(
|
||||
"pbr_maps",
|
||||
self.pbr_maps
|
||||
.as_ref()
|
||||
.map_or(0, |maps| maps.iter().flatten().map(texture_bytes).sum()),
|
||||
),
|
||||
(
|
||||
"flat_draws",
|
||||
self.flat.as_ref().map_or(0, flat::Flat::bytes),
|
||||
),
|
||||
(
|
||||
"visibility_buffer",
|
||||
self.visbuf.as_ref().map_or(0, visbuffer::Visbuffer::bytes),
|
||||
),
|
||||
]
|
||||
}
|
||||
/// Device memory the backend's allocator holds, by the label of what holds
|
||||
/// it, largest first, with its allocated and reserved totals (Vulkan and
|
||||
/// DirectX 12; none on Metal and OpenGL) (`horizon-ctl memory`).
|
||||
pub fn gpu_memory_by_label(&self) -> Option<(u64, u64, Vec<(String, u64)>)> {
|
||||
let report = self.device.generate_allocator_report()?;
|
||||
let mut by_label: HashMap<String, u64> = HashMap::new();
|
||||
for a in report.allocations {
|
||||
*by_label.entry(a.name).or_default() += a.size;
|
||||
}
|
||||
let mut labels: Vec<_> = by_label.into_iter().collect();
|
||||
labels.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
Some((
|
||||
report.total_allocated_bytes,
|
||||
report.total_reserved_bytes,
|
||||
labels,
|
||||
))
|
||||
}
|
||||
/// Heap bytes of the renderer's per-section bookkeeping (`horizon-ctl memory`):
|
||||
/// resident sections, far tiles, block entities and light sources by section.
|
||||
pub fn cpu_census(&self) -> [(&'static str, usize); 4] {
|
||||
use std::mem::size_of;
|
||||
let per_section = |map_len: usize, lists: usize| {
|
||||
map_len * (size_of::<SectionKey>() + size_of::<Vec<([i32; 3], u32)>>() + 1)
|
||||
+ lists * size_of::<([i32; 3], u32)>()
|
||||
};
|
||||
let lists = |map: &crate::fast_hash::FxHashMap<SectionKey, Vec<([i32; 3], u32)>>| {
|
||||
per_section(
|
||||
map.capacity(),
|
||||
map.values().map(Vec::capacity).sum::<usize>(),
|
||||
)
|
||||
};
|
||||
let animated: usize = self
|
||||
.sections
|
||||
.values()
|
||||
.map(|s| s.animated_layers.len() * size_of::<u16>())
|
||||
.sum();
|
||||
[
|
||||
("resident_sections", self.sections.heap_bytes() + animated),
|
||||
(
|
||||
"far_tiles",
|
||||
self.lods.capacity()
|
||||
* (size_of::<crate::lod::TileKey>() + size_of::<lod::Tile>() + 1),
|
||||
),
|
||||
("block_entities", lists(&self.block_entities)),
|
||||
("light_sources", lists(&self.light_blocks)),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
//! A resource pack's `core/text` on world text, as vanilla 26.3's `TEXT_POLYGON_OFFSET` and
|
||||
//! `TEXT_SEE_THROUGH` draw text displays, name tags and their backgrounds.
|
||||
|
||||
use crate::core_shader::{CoreProgram, Glue};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The entity vertices' light bytes are sky then block; vanilla's `UV2` is block then sky.
|
||||
pub(crate) const WORLD_GLUE: Glue = Glue {
|
||||
inputs: &[
|
||||
("Position", "vec3"),
|
||||
("Color", "vec4"),
|
||||
("UV0", "vec2"),
|
||||
("UV2", "ivec2"),
|
||||
],
|
||||
prelude: "layout(location = 0) in vec3 hz_pos;\n\
|
||||
layout(location = 1) in vec2 hz_uv;\n\
|
||||
layout(location = 3) in vec4 hz_color;\n\
|
||||
layout(location = 4) in uvec4 hz_light;",
|
||||
setup: "Position = hz_pos; Color = hz_color; UV0 = hz_uv; UV2 = ivec2(hz_light.y, hz_light.x);",
|
||||
finish: "",
|
||||
vertex_id: "int(gl_VertexIndex)",
|
||||
};
|
||||
|
||||
pub(crate) const SEE_THROUGH_GLUE: Glue = Glue {
|
||||
inputs: &[("Position", "vec3"), ("Color", "vec4"), ("UV0", "vec2")],
|
||||
prelude: "layout(location = 0) in vec3 hz_pos;\n\
|
||||
layout(location = 1) in vec2 hz_uv;\n\
|
||||
layout(location = 3) in vec4 hz_color;",
|
||||
setup: "Position = hz_pos; Color = hz_color; UV0 = hz_uv;",
|
||||
finish: "",
|
||||
vertex_id: "int(gl_VertexIndex)",
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct TextShaders {
|
||||
pub gui: Option<Arc<CoreProgram>>,
|
||||
pub world: Option<Arc<CoreProgram>>,
|
||||
pub see_through: Option<Arc<CoreProgram>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct PackBlocks {
|
||||
pub(super) globals: [u8; 48],
|
||||
pub(super) fog: [f32; 6],
|
||||
}
|
||||
|
||||
impl super::Renderer {
|
||||
pub fn set_world_text_shaders(&mut self, shaders: &TextShaders) {
|
||||
self.core_text = CoreText::new(
|
||||
&self.device,
|
||||
self.output.frame.format,
|
||||
shaders.world.as_deref(),
|
||||
shaders.see_through.as_deref(),
|
||||
&self.entity_atlas,
|
||||
(&self.lightmap_view, &self.lightmap_sampler),
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn pack_blocks(
|
||||
&self,
|
||||
frame: &super::Frame,
|
||||
fog: [f32; 4],
|
||||
ends: [f32; 4],
|
||||
) -> PackBlocks {
|
||||
let globals = crate::post_chain::Globals {
|
||||
glint_alpha: frame.glint_alpha,
|
||||
..frame.post_effects.globals
|
||||
};
|
||||
let size = [self.config.width, self.config.height];
|
||||
PackBlocks {
|
||||
globals: globals.bytes(size, frame.post_effects.menu_blur_radius as i32),
|
||||
fog: [fog[0], fog[1], fog[2], fog[3], ends[0], ends[1]],
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn write_core_text(
|
||||
&self,
|
||||
pack: &PackBlocks,
|
||||
view: &super::View,
|
||||
pose: glam::Mat4,
|
||||
jitter: glam::Mat4,
|
||||
fog_color: [f32; 4],
|
||||
) {
|
||||
let Some(core) = &self.core_text else {
|
||||
return;
|
||||
};
|
||||
let data =
|
||||
super::core_lines::uniforms(pack.globals, view, pose, jitter, 1.0, fog_color, pack.fog);
|
||||
self.queue.write_buffer(&core.uniform, 0, &data);
|
||||
}
|
||||
|
||||
pub(super) fn bind_world_text(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
allowed: bool,
|
||||
see_through: bool,
|
||||
) {
|
||||
let pack = self
|
||||
.core_text
|
||||
.as_ref()
|
||||
.filter(|_| allowed)
|
||||
.and_then(|core| {
|
||||
let pipeline = if see_through {
|
||||
core.see_through.as_ref()
|
||||
} else {
|
||||
core.polygon_offset.as_ref()
|
||||
};
|
||||
Some((pipeline?, &core.group))
|
||||
});
|
||||
match pack {
|
||||
Some((pipeline, group)) => {
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
}
|
||||
None => pass.set_bind_group(0, &self.entity_group, &[]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const BLOCK_OFFSETS: [u64; 4] = [0, 256, 512, 768];
|
||||
const BLOCK_SIZES: [u64; 4] = [48, 64, 160, 48];
|
||||
|
||||
pub(super) struct CoreText {
|
||||
pub(super) polygon_offset: Option<wgpu::RenderPipeline>,
|
||||
pub(super) see_through: Option<wgpu::RenderPipeline>,
|
||||
pub(super) group: wgpu::BindGroup,
|
||||
uniform: wgpu::Buffer,
|
||||
}
|
||||
|
||||
impl CoreText {
|
||||
pub(super) fn new(
|
||||
device: &wgpu::Device,
|
||||
format: wgpu::TextureFormat,
|
||||
world: Option<&CoreProgram>,
|
||||
see_through: Option<&CoreProgram>,
|
||||
atlas: &wgpu::TextureView,
|
||||
lightmap: (&wgpu::TextureView, &wgpu::Sampler),
|
||||
) -> Option<Self> {
|
||||
if world.is_none() && see_through.is_none() {
|
||||
return None;
|
||||
}
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
|
||||
let buffer = |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,
|
||||
};
|
||||
let texture = |binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let sampler = |binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
};
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Resource pack world text"),
|
||||
entries: &[
|
||||
buffer(0),
|
||||
buffer(1),
|
||||
buffer(2),
|
||||
buffer(3),
|
||||
texture(4),
|
||||
sampler(5),
|
||||
texture(8),
|
||||
sampler(9),
|
||||
],
|
||||
});
|
||||
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Resource pack world text uniforms"),
|
||||
size: 1024,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let glyphs = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Resource pack world text glyphs"),
|
||||
..Default::default()
|
||||
});
|
||||
let mut 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();
|
||||
resources.extend([
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::TextureView(atlas),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 5,
|
||||
resource: wgpu::BindingResource::Sampler(&glyphs),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 8,
|
||||
resource: wgpu::BindingResource::TextureView(lightmap.0),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 9,
|
||||
resource: wgpu::BindingResource::Sampler(lightmap.1),
|
||||
},
|
||||
]);
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Resource pack world text"),
|
||||
layout: &layout,
|
||||
entries: &resources,
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Resource pack world text"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
|
||||
let buffers = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<crate::mesher::Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &attributes,
|
||||
})];
|
||||
let pipeline = |label, program: &CoreProgram, depth: wgpu::DepthStencilState| {
|
||||
let module = |module: &naga::Module| {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(label),
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module.clone())),
|
||||
})
|
||||
};
|
||||
let (vertex, fragment) = (module(&program.vertex), module(&program.fragment));
|
||||
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: &buffers,
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: Some(wgpu::Face::Back),
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(depth),
|
||||
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 depth = |write, compare, bias| wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(write),
|
||||
depth_compare: Some(compare),
|
||||
stencil: Default::default(),
|
||||
bias,
|
||||
};
|
||||
let offset = wgpu::DepthBiasState {
|
||||
constant: 10,
|
||||
slope_scale: 1.0,
|
||||
clamp: 0.0,
|
||||
};
|
||||
let polygon_offset = world.map(|program| {
|
||||
pipeline(
|
||||
"Resource pack polygon offset text",
|
||||
program,
|
||||
depth(true, wgpu::CompareFunction::GreaterEqual, offset),
|
||||
)
|
||||
});
|
||||
let see_through = see_through.map(|program| {
|
||||
pipeline(
|
||||
"Resource pack see-through text",
|
||||
program,
|
||||
depth(false, wgpu::CompareFunction::Always, Default::default()),
|
||||
)
|
||||
});
|
||||
let internal = pollster::block_on(internal.pop());
|
||||
if let Some(e) = internal.or(pollster::block_on(validation.pop())) {
|
||||
log::warn!("the resource pack's world text shader is not used: {e}");
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
polygon_offset,
|
||||
see_through,
|
||||
group,
|
||||
uniform,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,201 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_text_shader_reads_the_light_as_vanilla_does() {
|
||||
let Some((device, queue)) = device() else {
|
||||
eprintln!("no GPU: skipping");
|
||||
return;
|
||||
};
|
||||
let vsh = "#version 330\n#extension GL_ARB_separate_shader_objects : require\n\
|
||||
layout(location = 0) in vec3 Position;\nlayout(location = 1) in vec4 Color;\n\
|
||||
layout(location = 2) in vec2 UV0;\nlayout(location = 3) in ivec2 UV2;\n\
|
||||
layout(location = 0) flat out ivec2 light;\n\
|
||||
void main() { gl_Position = vec4(Position, 1.0); light = UV2; }\n";
|
||||
let fsh = "#version 330\n#extension GL_ARB_separate_shader_objects : require\n\
|
||||
layout(location = 0) flat in ivec2 light;\nlayout(location = 0) out vec4 fragColor;\n\
|
||||
void main() { fragColor = vec4(vec2(light) / 255.0, 0.0, 1.0); }\n";
|
||||
let program =
|
||||
crate::core_shader::translate_core(vsh, fsh, &[], &|_| None, &WORLD_GLUE).unwrap();
|
||||
const SIZE: u32 = 64;
|
||||
let texture = |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,
|
||||
view_formats: &[],
|
||||
})
|
||||
};
|
||||
let binding = wgpu::TextureUsages::TEXTURE_BINDING;
|
||||
let atlas = texture(wgpu::TextureFormat::Rgba8Unorm, binding).create_view(&Default::default());
|
||||
let sampler = device.create_sampler(&Default::default());
|
||||
let text = CoreText::new(
|
||||
&device,
|
||||
wgpu::TextureFormat::Rgba8Unorm,
|
||||
Some(&program),
|
||||
None,
|
||||
&atlas,
|
||||
(&atlas, &sampler),
|
||||
)
|
||||
.expect("the pack's pipeline");
|
||||
let target = wgpu::TextureUsages::RENDER_ATTACHMENT;
|
||||
let color = texture(
|
||||
wgpu::TextureFormat::Rgba8Unorm,
|
||||
target | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let depth = texture(wgpu::TextureFormat::Depth32Float, target);
|
||||
let light = 0x30 | (0xC0 << 8);
|
||||
let corner = |x: f32, y: f32| crate::mesher::Vertex {
|
||||
pos: [x, y, 0.5],
|
||||
uv: [0.0; 2],
|
||||
layer: 0,
|
||||
color: u32::MAX,
|
||||
light,
|
||||
normal: [0; 4],
|
||||
glint_uv: [0.0; 2],
|
||||
};
|
||||
let quad = [
|
||||
corner(-1.0, -1.0),
|
||||
corner(1.0, -1.0),
|
||||
corner(1.0, 1.0),
|
||||
corner(-1.0, -1.0),
|
||||
corner(1.0, 1.0),
|
||||
corner(-1.0, 1.0),
|
||||
];
|
||||
let vertices = wgpu::util::DeviceExt::create_buffer_init(
|
||||
&device,
|
||||
&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::cast_slice(&quad),
|
||||
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_pipeline(text.polygon_offset.as_ref().unwrap());
|
||||
pass.set_bind_group(0, &text.group, &[]);
|
||||
pass.set_vertex_buffer(0, vertices.slice(..));
|
||||
pass.draw(0..6, 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();
|
||||
let pixels = read.slice(..).get_mapped_range().unwrap().to_vec();
|
||||
let middle = ((SIZE / 2 * SIZE + SIZE / 2) * 4) as usize;
|
||||
assert_eq!(
|
||||
&pixels[middle..middle + 4],
|
||||
&[0xC0, 0x30, 0, 0xFF],
|
||||
"UV2 is (block, sky)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared resources in HORIZON_TEST_RESOURCES or .local/horizon-client/26.3, and a GPU"]
|
||||
fn vanillas_text_shader_makes_both_world_text_pipelines() {
|
||||
let root = std::env::var_os("HORIZON_TEST_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 read = |path: &str| std::fs::read_to_string(root.join(path)).ok();
|
||||
let vertex = read("assets/minecraft/shaders/core/text.vsh").expect("vanilla's text.vsh");
|
||||
let fragment = read("assets/minecraft/shaders/core/text.fsh").expect("vanilla's text.fsh");
|
||||
let include = |id: &str| {
|
||||
let (namespace, path) = id.split_once(':')?;
|
||||
read(&format!("assets/{namespace}/{path}"))
|
||||
};
|
||||
let translate = |defines: &[&str], glue: &Glue| {
|
||||
crate::core_shader::translate_core(&vertex, &fragment, defines, &include, glue)
|
||||
.expect("vanilla's text shader translates")
|
||||
};
|
||||
let world = translate(&[], &WORLD_GLUE);
|
||||
let see_through = translate(&["IS_SEE_THROUGH"], &SEE_THROUGH_GLUE);
|
||||
let (device, _) = device().expect("a GPU");
|
||||
let texture = || {
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 4,
|
||||
height: 4,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default())
|
||||
};
|
||||
let (atlas, lightmap) = (texture(), texture());
|
||||
let sampler = device.create_sampler(&Default::default());
|
||||
let text = CoreText::new(
|
||||
&device,
|
||||
wgpu::TextureFormat::Rgba8Unorm,
|
||||
Some(&world),
|
||||
Some(&see_through),
|
||||
&atlas,
|
||||
(&lightmap, &sampler),
|
||||
)
|
||||
.expect("the pack's world text pipelines");
|
||||
assert!(text.polygon_offset.is_some() && text.see_through.is_some());
|
||||
}
|
||||
@@ -37,9 +37,7 @@
|
||||
//! are drawn in a pass of their own between the phases, which reads phase
|
||||
//! 1's depth as a texture and makes the same comparison in the shader: the
|
||||
//! fragment's depth against the stored one, greater or equal passing. The
|
||||
//! same fragments pass, so the same sections are seen. Adreno's Vulkan driver
|
||||
//! takes that path too: with the early test it now and then leaves sections
|
||||
//! out for a frame.
|
||||
//! same fragments pass, so the same sections are seen.
|
||||
use std::num::NonZeroU64;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
@@ -222,42 +220,17 @@ const RESULTS_USAGE: wgpu::BufferUsages =
|
||||
const READBACK_USAGE: wgpu::BufferUsages =
|
||||
wgpu::BufferUsages::MAP_READ.union(wgpu::BufferUsages::COPY_DST);
|
||||
|
||||
/// The first `n` values of a packed lookup in WGSL `source` (`fn name`, as cull.wgsl's
|
||||
/// `box_triangle_corner`): four bits each, eight a word, its words in the order of its cases.
|
||||
#[cfg(test)]
|
||||
pub(super) fn packed_lookup(source: &str, name: &str, n: usize) -> Vec<u32> {
|
||||
let body = &source[source.find(&format!("fn {name}(")).expect("the lookup")..];
|
||||
let body = &body[..body.find("return").expect("its return")];
|
||||
let words: Vec<u32> = body
|
||||
.split(|c: char| !c.is_ascii_alphanumeric())
|
||||
.filter_map(|t| t.strip_prefix("0x")?.strip_suffix('u'))
|
||||
.map(|hex| u32::from_str_radix(hex, 16).expect("a hex word"))
|
||||
.collect();
|
||||
(0..n)
|
||||
.map(|v| (words[v / 8] >> (v % 8 * 4)) & 0xf)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Qualcomm's PCI vendor id, which wgpu reports for Adreno GPUs.
|
||||
const ADRENO: u32 = 0x5143;
|
||||
|
||||
impl Cull {
|
||||
/// The device feature culling needs, where the adapter has it.
|
||||
pub fn features(adapter: &wgpu::Adapter) -> wgpu::Features {
|
||||
adapter.features() & wgpu::Features::SHADER_EARLY_DEPTH_TEST
|
||||
}
|
||||
/// Where the device can run the test, if it can: Vulkan with early
|
||||
/// depth tests (Adreno's in the shader), or Metal and DirectX 12 with the
|
||||
/// test in the shader, all drawing an indirect multi-draw's entries with
|
||||
/// the instance counts the GPU wrote, with fragment shaders that write
|
||||
/// storage.
|
||||
/// depth tests, or Metal and DirectX 12 with the test in the shader, all
|
||||
/// drawing an indirect multi-draw's entries with the instance counts the
|
||||
/// GPU wrote, with fragment shaders that write storage.
|
||||
pub fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> Option<DepthTest> {
|
||||
let info = adapter.get_info();
|
||||
let depth_test = match info.backend {
|
||||
// Adreno's Vulkan driver (the AYANEO Pocket DS's Adreno 740, build 0676.41) now and
|
||||
// then leaves sections out with the early test, the world flashing black or
|
||||
// see-through as a block breaks; the test in the shader, as on Metal, holds.
|
||||
wgpu::Backend::Vulkan if info.vendor == ADRENO => DepthTest::InShader,
|
||||
let depth_test = match adapter.get_info().backend {
|
||||
wgpu::Backend::Vulkan | wgpu::Backend::Gl
|
||||
if device
|
||||
.features()
|
||||
@@ -799,20 +772,4 @@ mod tests {
|
||||
.unwrap_or_else(|e| panic!("{depth_test:?}: {e:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The box's 12 triangles, as cull.wgsl and gpu_occlusion.wgsl draw them: two per face,
|
||||
/// counter-clockwise seen from outside, corners by bit (x 1, y 2, z 4).
|
||||
#[test]
|
||||
fn the_packed_box_corners_are_the_box_triangles() {
|
||||
let triangles = [
|
||||
0, 6, 2, 0, 4, 6, 1, 3, 7, 1, 7, 5, 0, 1, 5, 0, 5, 4, 2, 7, 3, 2, 6, 7, 0, 3, 1, 0, 2,
|
||||
3, 4, 5, 7, 4, 7, 6,
|
||||
];
|
||||
for source in [
|
||||
include_str!("cull.wgsl"),
|
||||
include_str!("gpu_occlusion.wgsl"),
|
||||
] {
|
||||
assert_eq!(packed_lookup(source, "box_triangle_corner", 36), triangles);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,21 +34,11 @@ struct Test {
|
||||
@group(0) @binding(5) var<uniform> params: Params;
|
||||
|
||||
// The corners (bit 0: x, bit 1: y, bit 2: z) of the box's 12 triangles,
|
||||
// counter-clockwise seen from outside: 0 6 2, 0 4 6, 1 3 7, 1 7 5, 0 1 5, 0 5 4,
|
||||
// 2 7 3, 2 6 7, 0 3 1, 0 2 3, 4 5 7, 4 7 6.
|
||||
// Packed four bits each, eight a word, read through a switch: a constant array indexed by the
|
||||
// vertex would be a private array, which Adreno's Vulkan compiler (build 0676.41) fails to link.
|
||||
fn box_triangle_corner(v: u32) -> u32 {
|
||||
var word: u32;
|
||||
switch v / 8u {
|
||||
case 0u: { word = 0x31640260u; }
|
||||
case 1u: { word = 0x05105717u; }
|
||||
case 2u: { word = 0x76237245u; }
|
||||
case 3u: { word = 0x54320130u; }
|
||||
default: { word = 0x00006747u; }
|
||||
}
|
||||
return (word >> (v % 8u * 4u)) & 0xfu;
|
||||
}
|
||||
// counter-clockwise seen from outside.
|
||||
const CORNERS = array<u32, 36>(
|
||||
0u, 6u, 2u, 0u, 4u, 6u, 1u, 3u, 7u, 1u, 7u, 5u, 0u, 1u, 5u, 0u, 5u, 4u,
|
||||
2u, 7u, 3u, 2u, 6u, 7u, 0u, 3u, 1u, 0u, 2u, 3u, 4u, 5u, 7u, 4u, 7u, 6u,
|
||||
);
|
||||
|
||||
struct Box {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
@@ -105,7 +95,7 @@ fn vs_box(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box
|
||||
out.position = select(vec4(0.0, 0.0, 0.0, 1.0), vec4(p, 1.0, 1.0), v < 3u);
|
||||
return out;
|
||||
}
|
||||
out.position = globals.view_proj * vec4(corner(origin, low, high, box_triangle_corner(v)), 1.0);
|
||||
out.position = globals.view_proj * vec4(corner(origin, low, high, CORNERS[v]), 1.0);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
//! Custom sky layers (`crate::custom_sky`) on the GPU: one texture per layer, kept with the renderer (its pack),
|
||||
//! OptiFine custom sky layers on the GPU: one texture per layer, kept with the renderer (its pack),
|
||||
//! a static skybox mesh of the 3x2 grid's six faces, and one pipeline per blend mode. A frame
|
||||
//! uploads one small uniform per drawn layer ([`crate::custom_sky::Draw`]) and draws 36 vertices
|
||||
//! each, after the vanilla sky's disc and sunrise and before the sun, moon and stars.
|
||||
use glam::Mat4;
|
||||
use horizon_optifine::sky::{Blend, Face};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::custom_sky::{Blend, Draw, SkyLayers};
|
||||
use crate::custom_sky::{CustomSky as Pack, Draw};
|
||||
|
||||
/// The blend modes in the shader's `mode` order (custom_sky.wgsl).
|
||||
const MODES: [Blend; 9] = [
|
||||
@@ -33,7 +34,7 @@ struct Uniform {
|
||||
mode: u32,
|
||||
pad: [u32; 2],
|
||||
hdr_settings: [f32; 4],
|
||||
hdr_gains: [f32; 4],
|
||||
hdr_shares: [f32; 4],
|
||||
hdr_light: [f32; 4],
|
||||
}
|
||||
|
||||
@@ -84,76 +85,6 @@ fn blend(mode: Blend) -> wgpu::BlendComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/// A face of the skybox, as the 3x2 grid of its texture holds them: `Bottom`, `Top`, `South` in
|
||||
/// the top row, `West`, `North`, `East` below.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Face {
|
||||
Bottom,
|
||||
Top,
|
||||
South,
|
||||
West,
|
||||
North,
|
||||
East,
|
||||
}
|
||||
|
||||
impl Face {
|
||||
const ALL: [Self; 6] = [
|
||||
Self::Bottom,
|
||||
Self::Top,
|
||||
Self::South,
|
||||
Self::West,
|
||||
Self::North,
|
||||
Self::East,
|
||||
];
|
||||
|
||||
/// The face's cell of the texture, `[u0, v0, u1, v1]` in 0..1.
|
||||
fn cell(self) -> [f32; 4] {
|
||||
let (col, row) = match self {
|
||||
Self::Bottom => (0, 0),
|
||||
Self::Top => (1, 0),
|
||||
Self::South => (2, 0),
|
||||
Self::West => (0, 1),
|
||||
Self::North => (1, 1),
|
||||
Self::East => (2, 1),
|
||||
};
|
||||
let (u, v) = (col as f32 / 3.0, row as f32 / 2.0);
|
||||
[u, v, u + 1.0 / 3.0, v + 0.5]
|
||||
}
|
||||
|
||||
/// The face's corners (on the unit cube around the camera; x east, y up, z south) in the
|
||||
/// order of its cell's `(u0, v0)`, `(u1, v0)`, `(u1, v1)`, `(u0, v1)`. The side faces are
|
||||
/// seen from inside with up at the top of their cell; the top face has north at the bottom of
|
||||
/// its cell, the bottom face north at the top (the template's `N` marks).
|
||||
fn corners(self) -> [[f32; 3]; 4] {
|
||||
match self {
|
||||
// Seen looking north: west on the left.
|
||||
Self::North => [
|
||||
[-1., 1., -1.],
|
||||
[1., 1., -1.],
|
||||
[1., -1., -1.],
|
||||
[-1., -1., -1.],
|
||||
],
|
||||
Self::South => [[1., 1., 1.], [-1., 1., 1.], [-1., -1., 1.], [1., -1., 1.]],
|
||||
Self::East => [[1., 1., -1.], [1., 1., 1.], [1., -1., 1.], [1., -1., -1.]],
|
||||
Self::West => [
|
||||
[-1., 1., 1.],
|
||||
[-1., 1., -1.],
|
||||
[-1., -1., -1.],
|
||||
[-1., -1., 1.],
|
||||
],
|
||||
// Looking up with north toward the cell's bottom edge: east on the right.
|
||||
Self::Top => [[-1., 1., 1.], [1., 1., 1.], [1., 1., -1.], [-1., 1., -1.]],
|
||||
// Looking down with north toward the cell's top edge: east on the right.
|
||||
Self::Bottom => [
|
||||
[-1., -1., -1.],
|
||||
[1., -1., -1.],
|
||||
[1., -1., 1.],
|
||||
[-1., -1., 1.],
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The skybox's 36 vertices: two triangles per face, each corner with its cell's UV.
|
||||
fn mesh() -> Vec<Vertex> {
|
||||
let mut out = Vec::with_capacity(36);
|
||||
@@ -174,12 +105,7 @@ fn mesh() -> Vec<Vertex> {
|
||||
}
|
||||
|
||||
impl CustomSky {
|
||||
/// The layers' pipelines for a frame of `format`, holding linear light where `linear`
|
||||
/// (`linear_blend`).
|
||||
pub(super) fn new(
|
||||
device: &wgpu::Device,
|
||||
(format, linear): (wgpu::TextureFormat, bool),
|
||||
) -> Self {
|
||||
pub(super) fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Custom sky bindings"),
|
||||
entries: &[
|
||||
@@ -266,7 +192,10 @@ impl CustomSky {
|
||||
"fs_custom_sky"
|
||||
}),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &super::linear_blend::frame_constants(format, linear),
|
||||
constants: &[(
|
||||
"HDR_WRITE_LIGHT",
|
||||
f64::from(u8::from(format == super::LINEAR_FRAME)),
|
||||
)],
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
@@ -327,15 +256,10 @@ impl CustomSky {
|
||||
}
|
||||
|
||||
/// The pack's layer textures, replacing any before.
|
||||
pub(super) fn set_pack(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
pack: &dyn SkyLayers,
|
||||
) {
|
||||
pub(super) fn set_pack(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, pack: &Pack) {
|
||||
let limit = device.limits().max_texture_dimension_2d;
|
||||
self.groups = pack
|
||||
.images()
|
||||
.images
|
||||
.iter()
|
||||
.map(|image| {
|
||||
// Too large for this GPU: a 1x1 transparent texel, so the layer draws nothing.
|
||||
@@ -407,7 +331,7 @@ impl CustomSky {
|
||||
self.written_gain = (globals.hdr_light[3] != 0.0).then(|| {
|
||||
let mark =
|
||||
((1.0 - 0.24 * globals.hdr_light[1]).clamp(0.0, 1.0) * 255.0).round() / 255.0;
|
||||
if globals.hdr_settings[2] > 0.5 {
|
||||
if globals.hdr_settings[2] > 0.5 && globals.hdr_settings[0] > 1.0 {
|
||||
(1.0 + (globals.hdr_settings[1] - 1.0) * (1.0 - mark) / 0.24).powf(1.0 / 2.2)
|
||||
} else {
|
||||
1.0
|
||||
@@ -428,7 +352,7 @@ impl CustomSky {
|
||||
mode: mode as u32,
|
||||
pad: [0; 2],
|
||||
hdr_settings: globals.hdr_settings,
|
||||
hdr_gains: globals.hdr_gains,
|
||||
hdr_shares: globals.hdr_shares,
|
||||
hdr_light: globals.hdr_light,
|
||||
};
|
||||
let offset = STRIDE * self.frame.len() as u64;
|
||||
@@ -489,38 +413,6 @@ impl CustomSky {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_grid_holds_each_face_once_and_faces_point_out() {
|
||||
let mut cells: Vec<_> = Face::ALL
|
||||
.iter()
|
||||
.map(|f| f.cell().map(|x| (x * 6.0) as i32))
|
||||
.collect();
|
||||
cells.dedup();
|
||||
assert_eq!(cells.len(), 6);
|
||||
assert_eq!(Face::East.cell(), [2.0 / 3.0, 0.5, 1.0, 1.0]);
|
||||
for face in Face::ALL {
|
||||
let c = face.corners();
|
||||
let sub = |a: [f32; 3], b: [f32; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||
let (a, b) = (sub(c[1], c[0]), sub(c[3], c[0]));
|
||||
let n = [
|
||||
a[1] * b[2] - a[2] * b[1],
|
||||
a[2] * b[0] - a[0] * b[2],
|
||||
a[0] * b[1] - a[1] * b[0],
|
||||
];
|
||||
let centre: [f32; 3] =
|
||||
std::array::from_fn(|i| c.iter().map(|p| p[i]).sum::<f32>() / 4.0);
|
||||
// Seen from inside, u runs right and v down, so u x v points away from the camera.
|
||||
let dot: f32 = (0..3).map(|i| n[i] * centre[i]).sum();
|
||||
assert!(dot > 0.0, "{face:?} is mirrored");
|
||||
}
|
||||
// The top cell's bottom edge (v1) is north: its corners 2 and 3 have z = -1.
|
||||
assert_eq!(Face::Top.corners()[2][2], -1.0);
|
||||
assert_eq!(Face::Top.corners()[3][2], -1.0);
|
||||
// The bottom cell's top edge (v0) is north.
|
||||
assert_eq!(Face::Bottom.corners()[0][2], -1.0);
|
||||
assert_eq!(Face::Bottom.corners()[1][2], -1.0);
|
||||
}
|
||||
|
||||
/// What a blend leaves a sky texel of `dst` with when the shader outputs `src`
|
||||
/// (custom_sky.wgsl) for a layer texel `t` at brightness `b`, per channel.
|
||||
fn mix(mode: Blend, dst: f32, t: f32, a: f32, b: f32) -> f32 {
|
||||
@@ -619,7 +511,7 @@ mod tests {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Wgsl(source.into()),
|
||||
});
|
||||
let sky = CustomSky::new(&device, (wgpu::TextureFormat::Rgba16Float, false));
|
||||
let sky = CustomSky::new(&device, wgpu::TextureFormat::Rgba16Float);
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: None,
|
||||
bind_group_layouts: &[Some(&sky.layout)],
|
||||
@@ -663,37 +555,19 @@ mod tests {
|
||||
let view = target.create_view(&Default::default());
|
||||
// Independent scalar expectation: a sky mark of 206/255 gives this gamma gain.
|
||||
let hdr_gain = (1.0f32 + 3.0 * (1.0 - 206.0 / 255.0) / 0.24).powf(1.0 / 2.2);
|
||||
// A frame of linear light (`FRAME_LINEAR`, `[frame] blend = "linear"`): the layer stored
|
||||
// decoded, products kept exact.
|
||||
let linear_modes = [
|
||||
Blend::Replace,
|
||||
Blend::Multiply,
|
||||
Blend::Overlay,
|
||||
Blend::Alpha,
|
||||
]
|
||||
.map(|m| {
|
||||
(
|
||||
MODES.iter().position(|&n| n == m).unwrap(),
|
||||
m,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
});
|
||||
for (mode_index, mode, debug, written, linear) in MODES
|
||||
for (mode_index, mode, debug, written) in MODES
|
||||
.iter()
|
||||
.copied()
|
||||
.enumerate()
|
||||
.map(|(i, m)| (i, m, false, true, false))
|
||||
.chain([(5, Blend::Screen, true, true, false)])
|
||||
.map(|(i, m)| (i, m, false, true))
|
||||
.chain([(5, Blend::Screen, true, true)])
|
||||
.chain(
|
||||
MODES
|
||||
.iter()
|
||||
.copied()
|
||||
.enumerate()
|
||||
.map(|(i, m)| (i, m, false, false, false)),
|
||||
.map(|(i, m)| (i, m, false, false)),
|
||||
)
|
||||
.chain(linear_modes)
|
||||
{
|
||||
let gain = if written { hdr_gain } else { 1.0 };
|
||||
let uniform = Uniform {
|
||||
@@ -702,7 +576,7 @@ mod tests {
|
||||
mode: mode_index as u32,
|
||||
pad: [0; 2],
|
||||
hdr_settings: [5.0, 4.0, 1.0, f32::from(u8::from(debug))],
|
||||
hdr_gains: [1.6, 2.4, 2.8, 4.0],
|
||||
hdr_shares: [0.2, 0.5, 0.6, 1.0],
|
||||
hdr_light: [0.8, 0.8, 1.0, f32::from(u8::from(written))],
|
||||
};
|
||||
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
@@ -737,10 +611,7 @@ mod tests {
|
||||
module: &shader,
|
||||
entry_point: Some(entry),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &[
|
||||
("HDR_WRITE_LIGHT", 1.0),
|
||||
("FRAME_LINEAR", f64::from(u8::from(linear))),
|
||||
],
|
||||
constants: &[("HDR_WRITE_LIGHT", 1.0)],
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
@@ -847,17 +718,7 @@ mod tests {
|
||||
(alpha - coverage).abs() < 0.001,
|
||||
"{mode:?}: alpha {alpha}, expected {coverage}"
|
||||
);
|
||||
let decode = |v: f32| v.powf(2.2);
|
||||
let expected = if linear {
|
||||
// The sky under it is linear light, 2.0; the layer is gamma.
|
||||
match mode {
|
||||
Blend::Replace => decode(t),
|
||||
Blend::Multiply => d * decode(1.0 + (t - 1.0) * k),
|
||||
Blend::Overlay => d * decode(2.0 * (0.5 + (t - 0.5) * k)),
|
||||
Blend::Alpha => decode(t) * k + d * (1.0 - k),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
} else if debug {
|
||||
let expected = if debug {
|
||||
let grey = (0.2126f32 * 0.8f32.powf(2.2)
|
||||
+ 0.7152 * 0.4f32.powf(2.2)
|
||||
+ 0.0722 * 0.2f32.powf(2.2))
|
||||
@@ -878,11 +739,11 @@ mod tests {
|
||||
};
|
||||
assert!(
|
||||
(got - expected).abs() < 0.004,
|
||||
"{mode:?} debug {debug} linear {linear}: {got} expected {expected}"
|
||||
"{mode:?} debug {debug}: {got} expected {expected}"
|
||||
);
|
||||
}
|
||||
eprintln!(
|
||||
"HDR custom sky GPU ran all nine modes with and without gain, plus debug Screen and four on linear light, checking RGB and coverage"
|
||||
"HDR custom sky GPU ran all nine modes with and without gain, plus debug Screen, checking RGB and coverage"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,11 @@ struct Layer {
|
||||
// Projection * view (camera-relative) * the layer's rotation.
|
||||
clip_from_sky: mat4x4<f32>,
|
||||
brightness: f32,
|
||||
// crate::custom_sky::Blend, in gfx/custom_sky.rs's MODES order.
|
||||
// horizon_optifine::sky::Blend, in gfx/custom_sky.rs's MODES order.
|
||||
mode: u32,
|
||||
pad: vec2<u32>,
|
||||
hdr_settings: vec4<f32>,
|
||||
hdr_gains: vec4<f32>,
|
||||
hdr_shares: vec4<f32>,
|
||||
hdr_light: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> layer: Layer;
|
||||
@@ -54,27 +54,18 @@ fn custom_sky_color(in: Out) -> vec4<f32> {
|
||||
var color = t.rgb;
|
||||
if HDR_WRITE_LIGHT && layer.hdr_light.w != 0.0 &&
|
||||
(layer.mode == ADD || layer.mode == SUBTRACT || layer.mode == REPLACE || layer.mode == ALPHA) {
|
||||
color = hdr_written(color, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_gains);
|
||||
color = hdr_written(color, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_shares);
|
||||
}
|
||||
// On a frame of linear light (`FRAME_LINEAR`) the layer's colour is decoded as it is stored.
|
||||
switch layer.mode {
|
||||
// dst * src: white leaves the sky alone. Decoded, the product stays exact:
|
||||
// (dst * src)^2.2 = dst^2.2 * src^2.2.
|
||||
case MULTIPLY: { return frame_stored(vec4(mix(vec3(1.0), t.rgb, k), 1.0)); }
|
||||
// 2 * dst * src: grey leaves it alone. Decoded as entity.wgsl's crumbling, (2 src)^2.2 / 2,
|
||||
// so it stays exact too.
|
||||
case OVERLAY: {
|
||||
let factor = mix(vec3(0.5), t.rgb, k);
|
||||
if FRAME_LINEAR { return vec4(pow(2.0 * factor, vec3(2.2)) * 0.5, 1.0); }
|
||||
return vec4(factor, 1.0);
|
||||
}
|
||||
case REPLACE: { return frame_stored(vec4(color, 1.0)); }
|
||||
// mix(dst, src, alpha): alpha is coverage, kept.
|
||||
case ALPHA: { return frame_stored(vec4(color, k)); }
|
||||
// add, subtract, dodge, burn, screen: the layer premultiplied, black leaves it alone. On
|
||||
// linear light add and subtract add and take away light, as `"linear"` means to; dodge,
|
||||
// burn and screen come out near their gamma results, not exactly.
|
||||
default: { return vec4(frame_stored(vec4(color, 1.0)).rgb * k, 1.0); }
|
||||
// dst * src: white leaves the sky alone.
|
||||
case MULTIPLY: { return vec4(mix(vec3(1.0), t.rgb, k), 1.0); }
|
||||
// 2 * dst * src: grey leaves it alone.
|
||||
case OVERLAY: { return vec4(mix(vec3(0.5), t.rgb, k), 1.0); }
|
||||
case REPLACE: { return vec4(color, 1.0); }
|
||||
// mix(dst, src, alpha).
|
||||
case ALPHA: { return vec4(color, k); }
|
||||
// add, subtract, dodge, burn, screen: the layer premultiplied, black leaves it alone.
|
||||
default: { return vec4(color * k, 1.0); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,5 +75,5 @@ fn custom_sky_color(in: Out) -> vec4<f32> {
|
||||
|
||||
@fragment fn fs_custom_sky_screen_light(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = custom_sky_color(in);
|
||||
return vec4(hdr_written(color.rgb, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_gains), color.a);
|
||||
return vec4(hdr_written(color.rgb, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_shares), color.a);
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
//! The hook an external shader pipeline fills (#14035, layer plan M6). A shader pack's runtime
|
||||
//! (a platform crate, later) implements [`ExternalPipeline`]; the renderer calls it at fixed
|
||||
//! points of a world frame and draws vanilla's way wherever it doesn't answer. With none set
|
||||
//! (`Renderer::set_external_pipeline`), each point is one `Option` test.
|
||||
//!
|
||||
//! Workspace-internal: the trait names the engine's frame, its mesh format and its pass order,
|
||||
//! has one implementer in this workspace, carries no stability promise and is never exposed to
|
||||
//! mods. A mesher or pass change updates its implementer in the same PR. HDR output
|
||||
//! (`output.rs`, `frame_policy`) is not an implementer: it stays the one output stage after the
|
||||
//! frame, and a pipeline draws into the frame it gives.
|
||||
//!
|
||||
//! Shadows are an engine frame point, on request: a pipeline that wants a shadow pass answers
|
||||
//! [`ShadowView`], the engine culls the sections from it ([`ShadowCull`]) and hands back the
|
||||
//! terrain seen from the light. Vanilla's path never asks, so it never computes shadow
|
||||
//! visibility.
|
||||
|
||||
use glam::{Mat4, Vec3, Vec4};
|
||||
|
||||
use super::{Cull, EffectiveOutput, Frame, Frustum, Sides, facing_runs, frustum};
|
||||
|
||||
/// What a world frame gives an external pipeline at its points: the frame's own values, and
|
||||
/// the display's from the renderer's one output snapshot (`EffectiveOutput`: whether HDR is on,
|
||||
/// paper white, peak), never worked out again, so each split-screen window keeps its own.
|
||||
pub struct FrameState<'a> {
|
||||
pub frame: &'a Frame<'a>,
|
||||
pub output: &'a EffectiveOutput,
|
||||
}
|
||||
|
||||
/// An external shader pipeline, called by the renderer at fixed points of a world frame (not
|
||||
/// title frames). Every method has a default that leaves vanilla's way in place.
|
||||
pub trait ExternalPipeline: Send {
|
||||
/// A world frame starts, before culling.
|
||||
fn begin_frame(&mut self, _state: &FrameState<'_>) {}
|
||||
/// The shadow pass this frame wants; `None` draws none.
|
||||
fn shadow_request(&self, _state: &FrameState<'_>) -> Option<ShadowView> {
|
||||
None
|
||||
}
|
||||
/// The window's drawable size changed.
|
||||
fn resize(&mut self, _size: [u32; 2]) {}
|
||||
/// Forget what was kept from earlier frames: the next is drawn as a fresh pipeline's first.
|
||||
fn reset_history(&mut self) {}
|
||||
}
|
||||
|
||||
/// What the shadow camera sees of the terrain, for the game's draw lists: its view and
|
||||
/// projection in wgpu's clip space, the direction toward the light, and how far from the camera
|
||||
/// terrain is drawn (`shadowDistanceRenderMul`).
|
||||
pub struct ShadowView {
|
||||
pub cull: glam::Mat4,
|
||||
pub light: glam::Vec3,
|
||||
pub distance: Option<f32>,
|
||||
/// Draw everything, every face, within this many blocks (`shadow.culling=false|reversed`):
|
||||
/// packs that voxelize the world in their shadow pass need all of it.
|
||||
pub unculled: Option<f32>,
|
||||
/// Every face of the drawn sections, not only those that can face the light. Iris 1.11.7's
|
||||
/// shadow map holds the faces turned away from the light too (#9033): under a hill at low sun
|
||||
/// a cave's ceiling, which faces away, is often all that shadows the cave.
|
||||
pub every_face: bool,
|
||||
/// Only the sections whose shadow can fall into the camera's view: those the camera's
|
||||
/// frustum, stretched toward the light, takes in (`shadow_casters`). Our probes' Iris 1.11.7
|
||||
/// maps fit keeping only those with `shadow.culling` unset (#8464): under a noon sun, only
|
||||
/// the terrain above the ground the camera sees; with a low sun ahead, none behind the camera.
|
||||
pub view_casters: bool,
|
||||
/// Whether the projection's near and far planes in `cull` drop sections too. Our probes'
|
||||
/// Iris 1.11.7 maps hold the terrain at every depth we put it along the light (from 535 blocks
|
||||
/// beyond the shadow camera to about 120 before it), whatever `shadowNearPlane` and
|
||||
/// `shadowFarPlane` the pack sets, or none (#10403): only its sides cull.
|
||||
pub depth_planes: bool,
|
||||
}
|
||||
|
||||
/// A shader pack's shadow camera's box, and with `casters` only what can shade the camera's view
|
||||
/// (`shadow_casters`): a box must meet all of them.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ShadowCull {
|
||||
pub light: Frustum,
|
||||
pub casters: Option<[Frustum; 2]>,
|
||||
/// `shadowDistance × shadowDistanceRenderMul`: only what lies within this many blocks of the
|
||||
/// camera along each axis, a cube around it. Our probe's Iris 1.11.7 maps keep the sections
|
||||
/// whose box, grown by `SHADOW_CASTER_MARGIN` as for the casters, meets that cube (#8464):
|
||||
/// out to its corners, and a section 1 block past its side but not one 1.5 blocks past.
|
||||
pub reach: Option<f32>,
|
||||
}
|
||||
impl ShadowCull {
|
||||
/// Whether the camera-relative box `min..max`, grown by the margin, meets (`whole`: lies
|
||||
/// within) `reach`'s cube.
|
||||
fn within_reach(&self, min: Vec3, max: Vec3, whole: bool) -> bool {
|
||||
let (min, max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
|
||||
self.reach.is_none_or(|d| {
|
||||
if whole {
|
||||
min.cmpge(Vec3::splat(-d)).all() && max.cmple(Vec3::splat(d)).all()
|
||||
} else {
|
||||
max.cmpge(Vec3::splat(-d)).all() && min.cmple(Vec3::splat(d)).all()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
/// How far past a section's 16 blocks the shadow pass looks for what can shade the view: our
|
||||
/// probes' Iris 1.11.7 maps keep the sections whose box, grown by this much on every side, meets
|
||||
/// `shadow_casters`' shape. Over 76 scenes at fields of view 30 to 110, Iris keeps every section
|
||||
/// that needs up to 1.1224 blocks and drops every one that needs 1.1252 or more (#8464): one
|
||||
/// growth, whatever the field of view, of 9/8 (a fit, not Iris's code). Without it the edges of
|
||||
/// the wedge lose a column of terrain.
|
||||
const SHADOW_CASTER_MARGIN: f32 = 1.125;
|
||||
impl Cull for ShadowCull {
|
||||
fn intersects(&self, min: Vec3, max: Vec3) -> bool {
|
||||
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
|
||||
self.within_reach(min, max, false)
|
||||
&& self.light.intersects(min, max)
|
||||
&& self
|
||||
.casters
|
||||
.is_none_or(|c| c.iter().all(|f| f.intersects(grown_min, grown_max)))
|
||||
}
|
||||
fn contains(&self, min: Vec3, max: Vec3) -> bool {
|
||||
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
|
||||
self.within_reach(min, max, true)
|
||||
&& self.light.contains(min, max)
|
||||
&& self
|
||||
.casters
|
||||
.is_none_or(|c| c.iter().all(|f| f.contains(grown_min, grown_max)))
|
||||
}
|
||||
/// Our probe's Iris 1.11.7 maps fit testing the casters' whole sections (a guess that matched).
|
||||
fn tight(&self) -> bool {
|
||||
self.casters.is_none()
|
||||
}
|
||||
}
|
||||
/// The shadow camera's box that the shadow pass draws terrain in: the sides of `view.cull`, and
|
||||
/// its near and far planes only with `depth_planes`.
|
||||
pub(super) fn shadow_box(view: &ShadowView) -> Frustum {
|
||||
let mut planes = frustum(view.cull);
|
||||
if !view.depth_planes {
|
||||
// `frustum`'s faces at depth 0 and 1 take in everything.
|
||||
planes[4] = Vec4::W;
|
||||
planes[5] = Vec4::W;
|
||||
}
|
||||
Frustum { planes }
|
||||
}
|
||||
|
||||
/// The runs of a section's solid quads the shadow pass draws: every face within the unculled
|
||||
/// distance, or everywhere for `every_face`; else the quads that can face the light.
|
||||
pub(super) fn shadow_runs(
|
||||
sides: &Sides,
|
||||
view: &ShadowView,
|
||||
origin: [f32; 3],
|
||||
draw: impl FnMut(u32, u32),
|
||||
) {
|
||||
let unculled = view.unculled.is_some_and(|d| {
|
||||
let o = Vec3::from(origin) + 8.0;
|
||||
o.x.abs().max(o.z.abs()) <= d + 8.0
|
||||
});
|
||||
let toward = (!unculled && !view.every_face).then_some(view.light * 1.0e5);
|
||||
facing_runs(sides, toward, origin, draw);
|
||||
}
|
||||
|
||||
/// The camera's view `view_proj` (wgpu depth, reversed or not) stretched without end toward
|
||||
/// `light` (a direction): the boxes whose shadow, cast away from the light, can fall into the
|
||||
/// view. Its planes are the view's faces turned toward the light, and through each edge between
|
||||
/// such a face and one turned away, the plane along the light; unused ones take in everything.
|
||||
pub(super) fn shadow_casters(view_proj: Mat4, light: Vec3) -> [Frustum; 2] {
|
||||
let faces = frustum(view_proj);
|
||||
let toward = faces.map(|p| p.truncate().dot(light) >= 0.0);
|
||||
let inverse = view_proj.inverse();
|
||||
// Corner (x, y, z) of clip space's box; `frustum`'s faces at x -1 and 1 are 0 and 1, at
|
||||
// y -1 and 1 are 2 and 3, at depth 0 and 1 are 4 and 5.
|
||||
let corner = |c: [usize; 3]| {
|
||||
inverse.project_point3(Vec3::new(
|
||||
c[0] as f32 * 2.0 - 1.0,
|
||||
c[1] as f32 * 2.0 - 1.0,
|
||||
c[2] as f32,
|
||||
))
|
||||
};
|
||||
let corners: Vec<[usize; 3]> = (0..8).map(|i| [i & 1, (i >> 1) & 1, i >> 2]).collect();
|
||||
let centre = corners.iter().map(|&c| corner(c)).sum::<Vec3>() / 8.0;
|
||||
let mut planes = [Vec4::W; 12];
|
||||
let mut n = 0;
|
||||
for (k, &face) in faces.iter().enumerate() {
|
||||
if toward[k] {
|
||||
planes[n] = face;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
for a in &corners {
|
||||
for axis in 0..3 {
|
||||
if a[axis] == 1 {
|
||||
continue;
|
||||
}
|
||||
let mut b = *a;
|
||||
b[axis] = 1;
|
||||
// The edge from a to b lies on the faces of the two other axes.
|
||||
let [f, g] = [(axis + 1) % 3, (axis + 2) % 3].map(|o| o * 2 + a[o]);
|
||||
if toward[f] == toward[g] {
|
||||
continue;
|
||||
}
|
||||
let (p, q) = (corner(*a), corner(b));
|
||||
let normal = (q - p).cross(light);
|
||||
if normal.length_squared() < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
let normal = normal.normalize();
|
||||
let side = if normal.dot(centre - p) < 0.0 {
|
||||
-1.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
// At most six such edges with at most four faces toward the light: 12 planes.
|
||||
if let Some(plane) = planes.get_mut(n) {
|
||||
*plane = (normal * side).extend(-normal.dot(p) * side);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let [a, b] = [0, 6].map(|i| Frustum {
|
||||
planes: planes[i..i + 6].try_into().expect("six planes"),
|
||||
});
|
||||
[a, b]
|
||||
}
|
||||
@@ -19,10 +19,7 @@
|
||||
//! start, and the run is drawn indirectly with the count the copy reached.
|
||||
use super::SolidPipeline;
|
||||
use super::arena::GRAIN;
|
||||
|
||||
#[path = "binding_cache.rs"]
|
||||
mod binding_cache;
|
||||
use binding_cache::BindingKey;
|
||||
use smallvec::SmallVec;
|
||||
|
||||
/// One section draw for `flatten.wgsl`: index count, first sorted index (or
|
||||
/// `QUADS` for a solid draw; phase 2: its entry in phase 2's draws), base
|
||||
@@ -88,11 +85,6 @@ pub struct Call {
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
struct CachedGroup<const N: usize> {
|
||||
key: BindingKey<wgpu::Buffer, N>,
|
||||
group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
pub struct Flat {
|
||||
/// The solid draws in one pass (`fs_solid`), where the GPU doesn't hide pixels
|
||||
/// by tiles (`split_solid` in `Renderer::draw_frame`: elsewhere than Metal).
|
||||
@@ -117,9 +109,8 @@ pub struct Flat {
|
||||
phase2_call_data: Vec<[u32; 5]>,
|
||||
/// GPU culling's phase 2 uses indirect draws; flat draws without culling do not.
|
||||
indirect_execution: bool,
|
||||
/// Bindings reused while the range and all GPU buffers are unchanged.
|
||||
phase2_group: Option<CachedGroup<4>>,
|
||||
groups: Vec<CachedGroup<3>>,
|
||||
/// The copy of phase 2's draws, and its ranges.
|
||||
phase2_group: Option<(wgpu::BindGroup, u32)>,
|
||||
/// The layout of a page's bind group 1 (shader packs' terrain pipelines share it).
|
||||
pub(super) draw_layout: wgpu::BindGroupLayout,
|
||||
blocks_per_page: u64,
|
||||
@@ -202,70 +193,34 @@ pub fn is_adreno_dx12(info: &wgpu::AdapterInfo) -> bool {
|
||||
info.backend == wgpu::Backend::Dx12 && matches!(info.vendor, 0x4d4f_4351 | 0x5143)
|
||||
}
|
||||
|
||||
/// A released Mesa version from its driver info, allowing distribution suffixes.
|
||||
/// Development and release-candidate builds do not establish a stable release's safety.
|
||||
fn parse_mesa_version(driver_info: &str) -> Option<(u32, u32, u32)> {
|
||||
let version = driver_info
|
||||
.strip_prefix("Mesa ")?
|
||||
.split_whitespace()
|
||||
.next()?;
|
||||
let (version, suffix) = version.split_once('-').unwrap_or((version, ""));
|
||||
if suffix.starts_with("devel") || suffix.starts_with("rc") {
|
||||
return None;
|
||||
}
|
||||
let mut parts = version.split('.');
|
||||
let version = (
|
||||
parts.next()?.parse().ok()?,
|
||||
parts.next()?.parse().ok()?,
|
||||
parts.next()?.parse().ok()?,
|
||||
);
|
||||
parts.next().is_none().then_some(version)
|
||||
}
|
||||
|
||||
/// Intel Gen11 (Ice Lake) and Gen12 (Tiger Lake, Rocket Lake, Alder Lake,
|
||||
/// Raptor Lake) ANV Vulkan drivers had broken `first_instance` for
|
||||
/// Raptor Lake) ANV Vulkan drivers have broken `first_instance` for
|
||||
/// instance-step vertex attributes (#3529, #4599), causing terrain sections to
|
||||
/// be drawn with another section's origin and flicker.
|
||||
///
|
||||
/// PR #15007 tested Raptor Lake (RPL-U, 0xa7a1) on Mesa 26.0.8 without terrain
|
||||
/// flicker. Allow indirect multi-draw and GPU culling for Raptor Lake from that
|
||||
/// release; earlier or unidentified drivers keep the workaround.
|
||||
pub fn is_broken_intel_vulkan(info: &wgpu::AdapterInfo) -> bool {
|
||||
if info.backend != wgpu::Backend::Vulkan || info.vendor != 0x8086 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let is_raptor_lake = matches!(info.device, 0xa700..=0xa7ff)
|
||||
|| info.name.contains("RPL")
|
||||
|| info.name.contains("Raptor Lake");
|
||||
|
||||
if is_raptor_lake
|
||||
&& parse_mesa_version(&info.driver_info).is_some_and(|version| version >= (26, 0, 8))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
matches!(
|
||||
info.device,
|
||||
0x8a50..=0x8a71 // Gen11: Ice Lake
|
||||
| 0x9a40..=0x9a7f // Gen12: Tiger Lake
|
||||
| 0x4c80..=0x4c9a // Gen12: Rocket Lake
|
||||
| 0x4600..=0x46ff // Gen12: Alder Lake (incl. 0x46a8 in #4599)
|
||||
| 0xa700..=0xa7ff // Gen12: Raptor Lake
|
||||
) || [
|
||||
"ICL",
|
||||
"Ice Lake",
|
||||
"TGL",
|
||||
"Tiger Lake",
|
||||
"RKL",
|
||||
"Rocket Lake",
|
||||
"ADL",
|
||||
"Alder Lake",
|
||||
"RPL",
|
||||
"Raptor Lake",
|
||||
]
|
||||
.iter()
|
||||
.any(|tag| info.name.contains(tag))
|
||||
info.backend == wgpu::Backend::Vulkan
|
||||
&& info.vendor == 0x8086
|
||||
&& (matches!(
|
||||
info.device,
|
||||
0x8a50..=0x8a71 // Gen11: Ice Lake
|
||||
| 0x9a40..=0x9a7f // Gen12: Tiger Lake
|
||||
| 0x4c80..=0x4c9a // Gen12: Rocket Lake
|
||||
| 0x4600..=0x46ff // Gen12: Alder Lake (incl. 0x46a8 in #4599)
|
||||
| 0xa700..=0xa7ff // Gen12: Raptor Lake
|
||||
) || [
|
||||
"ICL",
|
||||
"Ice Lake",
|
||||
"TGL",
|
||||
"Tiger Lake",
|
||||
"RKL",
|
||||
"Rocket Lake",
|
||||
"ADL",
|
||||
"Alder Lake",
|
||||
"RPL",
|
||||
"Raptor Lake",
|
||||
]
|
||||
.iter()
|
||||
.any(|tag| info.name.contains(tag)))
|
||||
}
|
||||
|
||||
fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
|
||||
@@ -560,7 +515,6 @@ impl Flat {
|
||||
phase2_call_data: Vec::new(),
|
||||
indirect_execution,
|
||||
phase2_group: None,
|
||||
groups: Vec::new(),
|
||||
draw_layout,
|
||||
blocks_per_page: page_vertices.div_ceil(GRAIN),
|
||||
translucent_blocks_per_page: page_vertices.div_ceil(GRAIN),
|
||||
@@ -672,8 +626,6 @@ impl Flat {
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.pages.clear();
|
||||
self.groups.clear();
|
||||
self.phase2_group = None;
|
||||
self.free_ids.clear();
|
||||
self.next_id = 0;
|
||||
}
|
||||
@@ -713,6 +665,7 @@ impl Flat {
|
||||
sorted_page: impl Fn(u32) -> wgpu::Buffer,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) -> bool {
|
||||
self.phase2_group = None;
|
||||
let phase2 = phase2.filter(|_| self.indirect_execution);
|
||||
let phase1_draws = if phase2.is_some() {
|
||||
phase1_draws
|
||||
@@ -776,9 +729,6 @@ impl Flat {
|
||||
self.shadow_calls = shadow_calls;
|
||||
self.pass_calls = pass_calls;
|
||||
if total == 0 || total * 4 > device.limits().max_storage_buffer_binding_size {
|
||||
// Do not retain bindings for a frame that cannot use flat draws.
|
||||
self.phase2_group = None;
|
||||
self.groups.clear();
|
||||
self.calls.clear();
|
||||
self.phase1_calls = 0;
|
||||
self.solid_calls = 0;
|
||||
@@ -852,63 +802,39 @@ impl Flat {
|
||||
phase2,
|
||||
self.segments.iter().find(|(s, ..)| *s == Source::Seen),
|
||||
) {
|
||||
let buffers = [&self.ranges, &self.indices, phase2, &self.phase2_calls];
|
||||
if !self
|
||||
.phase2_group
|
||||
.as_ref()
|
||||
.is_some_and(|cached| cached.key.matches(first, count, buffers))
|
||||
{
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Flatten phase 2 section draws"),
|
||||
layout: &self.seen_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: ranges_at(first, count),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: self.indices.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: phase2.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: self.phase2_calls.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
self.phase2_group = Some(CachedGroup {
|
||||
key: BindingKey {
|
||||
first,
|
||||
count,
|
||||
buffers: buffers.map(|b| b.clone()),
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Flatten phase 2 section draws"),
|
||||
layout: &self.seen_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: ranges_at(first, count),
|
||||
},
|
||||
group,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
self.phase2_group = None;
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: self.indices.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: phase2.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: self.phase2_calls.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
self.phase2_group = Some((group, count as u32));
|
||||
}
|
||||
let mut groups = 0;
|
||||
for &(source, first, count) in &self.segments {
|
||||
let sorted = match source {
|
||||
Source::Quads => None,
|
||||
Source::Seen => continue,
|
||||
Source::Sorted(page) => Some(sorted_page(page)),
|
||||
};
|
||||
let buffers = [
|
||||
&self.ranges,
|
||||
sorted.as_ref().unwrap_or(&self.empty),
|
||||
&self.indices,
|
||||
];
|
||||
if self
|
||||
.groups
|
||||
.get(groups)
|
||||
.is_none_or(|cached| !cached.key.matches(first, count, buffers))
|
||||
{
|
||||
let groups: SmallVec<[(wgpu::BindGroup, u32); 8]> = self
|
||||
.segments
|
||||
.iter()
|
||||
.filter_map(|&(source, first, count)| {
|
||||
let sorted = match source {
|
||||
Source::Quads => None,
|
||||
Source::Seen => return None,
|
||||
Source::Sorted(page) => Some(sorted_page(page)),
|
||||
};
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Flatten section draws"),
|
||||
layout: &self.compute_layout,
|
||||
@@ -919,7 +845,7 @@ impl Flat {
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: buffers[1].as_entire_binding(),
|
||||
resource: sorted.as_ref().unwrap_or(&self.empty).as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
@@ -927,32 +853,17 @@ impl Flat {
|
||||
},
|
||||
],
|
||||
});
|
||||
let cached = CachedGroup {
|
||||
key: BindingKey {
|
||||
first,
|
||||
count,
|
||||
buffers: buffers.map(|b| b.clone()),
|
||||
},
|
||||
group,
|
||||
};
|
||||
if groups < self.groups.len() {
|
||||
self.groups[groups] = cached;
|
||||
} else {
|
||||
self.groups.push(cached);
|
||||
}
|
||||
}
|
||||
groups += 1;
|
||||
}
|
||||
self.groups.truncate(groups);
|
||||
Some((group, count as u32))
|
||||
})
|
||||
.collect();
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("Flatten section draws"),
|
||||
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten")),
|
||||
});
|
||||
pass.set_pipeline(&self.flatten);
|
||||
for cached in &self.groups {
|
||||
let count = cached.key.count as u32;
|
||||
pass.set_bind_group(0, &cached.group, &[]);
|
||||
pass.dispatch_workgroups(count.min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
|
||||
for (group, count) in &groups {
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
|
||||
}
|
||||
drop(pass);
|
||||
true
|
||||
@@ -1008,17 +919,16 @@ impl Flat {
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let Some(cached) = &self.phase2_group else {
|
||||
let Some((group, count)) = &self.phase2_group else {
|
||||
return;
|
||||
};
|
||||
let count = cached.key.count as u32;
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("Flatten phase 2 section draws"),
|
||||
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten phase 2")),
|
||||
});
|
||||
pass.set_pipeline(&self.flatten_seen);
|
||||
pass.set_bind_group(0, &cached.group, &[]);
|
||||
pass.dispatch_workgroups(count.min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
|
||||
}
|
||||
|
||||
/// The camera's translucent calls.
|
||||
@@ -1170,70 +1080,6 @@ mod tests {
|
||||
assert!(!is_adreno_dx12(&info));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intel_vulkan_detection_preserves_workaround_on_older_mesa_and_allows_modern_raptor_lake() {
|
||||
let mut info = wgpu::AdapterInfo {
|
||||
name: "Intel(R) Iris(R) Xe Graphics (RPL-U)".into(),
|
||||
vendor: 0x8086,
|
||||
device: 0xa7a1,
|
||||
device_type: wgpu::DeviceType::IntegratedGpu,
|
||||
device_pci_bus_id: String::new(),
|
||||
driver: "Intel open-source Mesa driver".into(),
|
||||
driver_info: "Mesa 26.0.8-1ubuntu0.3".into(),
|
||||
backend: wgpu::Backend::Vulkan,
|
||||
subgroup_min_size: 0,
|
||||
subgroup_max_size: 0,
|
||||
transient_saves_memory: None,
|
||||
limit_bucket: None,
|
||||
};
|
||||
for (driver_info, broken) in [
|
||||
("Mesa 26.0.8-1ubuntu0.3", false),
|
||||
("Mesa 26.0.8", false),
|
||||
("Mesa 26.1.0", false),
|
||||
("Mesa 27.0.0", false),
|
||||
("Mesa 25.2.0", true),
|
||||
("Mesa 26.0.0", true),
|
||||
("Mesa 26.0.7", true),
|
||||
("Mesa 26.0.8-devel", true),
|
||||
("Mesa 27.0.0-rc1", true),
|
||||
("Mesa 27.0.0-devel (git-abcdef)", true),
|
||||
("Mesa 26", true),
|
||||
("Mesa 26.0.8.1", true),
|
||||
("Mesa unknown", true),
|
||||
("26.0.8", true),
|
||||
("", true),
|
||||
] {
|
||||
info.driver_info = driver_info.into();
|
||||
assert_eq!(is_broken_intel_vulkan(&info), broken, "{driver_info}");
|
||||
}
|
||||
|
||||
// PCI identification and the name fallback both allow the tested family.
|
||||
info.driver_info = "Mesa 26.0.8".into();
|
||||
info.device = 0;
|
||||
assert!(!is_broken_intel_vulkan(&info));
|
||||
info.name = "Intel Graphics".into();
|
||||
info.device = 0xa7a1;
|
||||
assert!(!is_broken_intel_vulkan(&info));
|
||||
|
||||
// Earlier families keep their workaround even with a modern Mesa release.
|
||||
for (device, name) in [
|
||||
(0x8a52, "Intel Ice Lake"),
|
||||
(0x9a49, "Intel Tiger Lake"),
|
||||
(0x4c8a, "Intel Rocket Lake"),
|
||||
(0x46a8, "Intel Alder Lake"),
|
||||
] {
|
||||
info.device = device;
|
||||
info.name = name.into();
|
||||
assert!(is_broken_intel_vulkan(&info), "{name}");
|
||||
}
|
||||
// A Mesa-looking version cannot turn on this workaround for other backends/vendors.
|
||||
info.backend = wgpu::Backend::Dx12;
|
||||
assert!(!is_broken_intel_vulkan(&info));
|
||||
info.backend = wgpu::Backend::Vulkan;
|
||||
info.vendor = 0x10de;
|
||||
assert!(!is_broken_intel_vulkan(&info));
|
||||
}
|
||||
|
||||
/// Draws as `render` writes them: solid (page, quads at a base vertex)
|
||||
/// first, then translucent (page, sorted page, count, first sorted index,
|
||||
/// base vertex).
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
//! The frame policy: which frame a renderer draws, what the overlays draw on and whether
|
||||
//! Horizon's tone curve runs (tests in `frame_policy_tests.rs`).
|
||||
|
||||
use super::{LINEAR_FRAME, WIDE_FRAME};
|
||||
|
||||
/// What a renderer's frame is drawn in: `LINEAR_FRAME` under the switch where the device draws
|
||||
/// it, for a 0.2 pack, or for the layered HDR frame where the surface offers HDR output
|
||||
/// (`linear`: `(a 0.2 pack picked, the layered HDR frame, the device draws it)`); else
|
||||
/// `WIDE_FRAME` when it is made with HDR on and Smooth Gradients (`wide_frame`,
|
||||
/// `output::resolve_frame`), the surface offering HDR output (`hdr_offered`) and the device
|
||||
/// drawing it (`draws_wide_frame`): an 8-bit frame's steps band in dark gradients, and HDR
|
||||
/// shows them. Else the swapchain's 8-bit format, as without HDR.
|
||||
pub(super) fn frame_format(
|
||||
wide_frame: bool,
|
||||
hdr_offered: bool,
|
||||
device_draws_it: bool,
|
||||
swapchain: wgpu::TextureFormat,
|
||||
linear: (bool, bool, bool),
|
||||
) -> wgpu::TextureFormat {
|
||||
if linear.2 && (linear.0 || linear.1 && hdr_offered) {
|
||||
LINEAR_FRAME
|
||||
} else if wide_frame && hdr_offered && device_draws_it {
|
||||
WIDE_FRAME
|
||||
} else {
|
||||
swapchain.remove_srgb_suffix()
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame's format, and whether it is the layered HDR frame (HUD coverage): for HDR on
|
||||
/// under the switch (`hdr_float`), with no pack or a 0.2 one, where the device keeps the HUD
|
||||
/// on a layer of its own.
|
||||
pub(super) fn frame_configuration(
|
||||
wide_frame: bool,
|
||||
device: (bool, bool, bool, bool),
|
||||
swapchain: wgpu::TextureFormat,
|
||||
linear_pack: bool,
|
||||
hdr_float: bool,
|
||||
) -> (wgpu::TextureFormat, bool) {
|
||||
let (hdr_offered, wide_ok, linear_ok, coverage_ok) = device;
|
||||
let layered = hdr_float && hdr_offered && linear_ok && coverage_ok;
|
||||
(
|
||||
frame_format(
|
||||
wide_frame,
|
||||
hdr_offered,
|
||||
wide_ok,
|
||||
swapchain,
|
||||
(linear_pack, layered, linear_ok),
|
||||
),
|
||||
layered,
|
||||
)
|
||||
}
|
||||
|
||||
/// What the hand, the camera overlays and the HUD draw on: the frame, except for a 0.2 pack's
|
||||
/// float frame without the HUD's layer, which is SDR: the pack's picture finishes on the
|
||||
/// swapchain's 8-bit format first, so they clip and blend per draw as on vanilla's frame.
|
||||
pub(super) fn overlay_format(
|
||||
frame: wgpu::TextureFormat,
|
||||
layered: bool,
|
||||
swapchain: wgpu::TextureFormat,
|
||||
) -> wgpu::TextureFormat {
|
||||
if frame == LINEAR_FRAME && !layered {
|
||||
swapchain.remove_srgb_suffix()
|
||||
} else {
|
||||
frame
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether Horizon's tone curve (`Hdr::tonemap`) runs on this frame: on the layered frame's
|
||||
/// world, unless a 0.2 pack's `[tonemap]` already mapped it. One tone mapping, never two.
|
||||
pub(super) fn horizon_tone_curve(layered: bool, world: bool, pack_tonemap: bool) -> bool {
|
||||
layered && world && !pack_tonemap
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
//! The frame policy: which frame a renderer draws (`frame_configuration`), and what follows
|
||||
//! from the frame chosen.
|
||||
|
||||
#[test]
|
||||
fn float_frames_never_schedule_light_mark_passes() {
|
||||
use super::{LINEAR_FRAME, WIDE_FRAME, light_marks};
|
||||
use wgpu::TextureFormat::{Bgra8Unorm, Rgb10a2Unorm, Rgba8Unorm};
|
||||
for hdr in [false, true] {
|
||||
assert!(!light_marks(LINEAR_FRAME, hdr));
|
||||
for format in [WIDE_FRAME, Bgra8Unorm, Rgba8Unorm, Rgb10a2Unorm] {
|
||||
assert_eq!(light_marks(format, hdr), hdr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_policy_keeps_compatibility_frames_and_requires_layering_for_float_hdr() {
|
||||
use super::{LINEAR_FRAME, WIDE_FRAME, frame_configuration};
|
||||
use wgpu::TextureFormat::{Bgra8Unorm, Bgra8UnormSrgb};
|
||||
|
||||
for bits in 0..128 {
|
||||
let hdr = bits & 1 != 0;
|
||||
let offered = bits & 2 != 0;
|
||||
let wide_ok = bits & 4 != 0;
|
||||
let float_ok = bits & 8 != 0;
|
||||
let coverage_ok = bits & 16 != 0;
|
||||
let switch = bits & 32 != 0;
|
||||
let smooth = bits & 64 != 0;
|
||||
for pack in ["none", "0.1", "0.2", "shaders"] {
|
||||
let float_hdr = matches!(pack, "none" | "0.2");
|
||||
let expected = if switch && float_ok && float_hdr && hdr && offered && coverage_ok {
|
||||
(LINEAR_FRAME, true)
|
||||
} else if switch && float_ok && pack == "0.2" {
|
||||
(LINEAR_FRAME, false)
|
||||
} else if hdr && smooth && offered && wide_ok {
|
||||
(WIDE_FRAME, false)
|
||||
} else {
|
||||
(Bgra8Unorm, false)
|
||||
};
|
||||
assert_eq!(
|
||||
frame_configuration(
|
||||
hdr && smooth,
|
||||
(offered, wide_ok, float_ok, coverage_ok),
|
||||
Bgra8UnormSrgb,
|
||||
switch && pack == "0.2",
|
||||
switch && hdr && float_hdr,
|
||||
),
|
||||
expected,
|
||||
"bits {bits}, pack {pack}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_hdr_falls_back_for_each_missing_hud_layer_requirement() {
|
||||
use super::{LINEAR_FRAME, WIDE_FRAME, frame_configuration, hud_coverage};
|
||||
use wgpu::{DownlevelFlags as D, TextureFormat as F, TextureFormatFeatureFlags as Fl};
|
||||
use wgpu::{TextureFormatFeatures, TextureUsages as U};
|
||||
|
||||
let usages = U::RENDER_ATTACHMENT | U::TEXTURE_BINDING;
|
||||
for (attachments, flags, allowed_usages, format_flags) in [
|
||||
(1, D::INDEPENDENT_BLEND, usages, Fl::BLENDABLE),
|
||||
(2, D::empty(), usages, Fl::BLENDABLE),
|
||||
(2, D::INDEPENDENT_BLEND, U::TEXTURE_BINDING, Fl::BLENDABLE),
|
||||
(2, D::INDEPENDENT_BLEND, U::RENDER_ATTACHMENT, Fl::BLENDABLE),
|
||||
(2, D::INDEPENDENT_BLEND, usages, Fl::empty()),
|
||||
] {
|
||||
let coverage = hud_coverage::supported(
|
||||
attachments,
|
||||
flags,
|
||||
TextureFormatFeatures {
|
||||
allowed_usages,
|
||||
flags: format_flags,
|
||||
},
|
||||
);
|
||||
let device = (true, true, true, coverage);
|
||||
assert_eq!(
|
||||
frame_configuration(true, device, F::Bgra8Unorm, false, true),
|
||||
(WIDE_FRAME, false)
|
||||
);
|
||||
assert_eq!(
|
||||
frame_configuration(false, device, F::Bgra8Unorm, false, true),
|
||||
(F::Bgra8Unorm, false)
|
||||
);
|
||||
assert_eq!(
|
||||
frame_configuration(true, device, F::Bgra8Unorm, true, false),
|
||||
(LINEAR_FRAME, false)
|
||||
);
|
||||
// A 0.2 pack keeps its float frame, without the layer: SDR.
|
||||
assert_eq!(
|
||||
frame_configuration(true, device, F::Bgra8Unorm, true, true),
|
||||
(LINEAR_FRAME, false)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_0_2_pack_without_the_hud_layer_finishes_on_the_swapchains_8_bits() {
|
||||
use super::{LINEAR_FRAME, WIDE_FRAME, overlay_format};
|
||||
use wgpu::TextureFormat::{Bgra8Unorm, Bgra8UnormSrgb, Rgba8Unorm};
|
||||
assert_eq!(
|
||||
overlay_format(LINEAR_FRAME, false, Bgra8UnormSrgb),
|
||||
Bgra8Unorm
|
||||
);
|
||||
assert_eq!(overlay_format(LINEAR_FRAME, false, Rgba8Unorm), Rgba8Unorm);
|
||||
// Everything else draws its overlays on its frame: the layered frame's light, the
|
||||
// 16-bit frame, the 8-bit frame.
|
||||
for (frame, layered) in [
|
||||
(LINEAR_FRAME, true),
|
||||
(WIDE_FRAME, false),
|
||||
(Bgra8Unorm, false),
|
||||
] {
|
||||
assert_eq!(overlay_format(frame, layered, Bgra8UnormSrgb), frame);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_tone_curve_runs_on_the_layered_world() {
|
||||
use super::horizon_tone_curve;
|
||||
// No pack, or a 0.2 pack without `[tonemap]`: Horizon's curve.
|
||||
assert!(horizon_tone_curve(true, true, false));
|
||||
// A pack's `[tonemap]` mapped it already; never both.
|
||||
assert!(!horizon_tone_curve(true, true, true));
|
||||
// No world (the title screen), or no layered frame (SDR, the compatibility frames).
|
||||
assert!(!horizon_tone_curve(true, false, false));
|
||||
assert!(!horizon_tone_curve(false, true, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_no_pack_layered_world_applies_hdr_gain() {
|
||||
use super::no_pack_hdr_gain;
|
||||
assert!(no_pack_hdr_gain(true, false, true));
|
||||
assert!(!no_pack_hdr_gain(false, false, true));
|
||||
assert!(!no_pack_hdr_gain(true, true, true));
|
||||
assert!(!no_pack_hdr_gain(true, false, false));
|
||||
}
|
||||
@@ -9,20 +9,11 @@
|
||||
@group(0) @binding(12) var<storage, read> box_sections: array<Section>;
|
||||
|
||||
// The corners (bit 0: x, bit 1: y, bit 2: z) of the box's 12 triangles,
|
||||
// counter-clockwise seen from outside (cull.wgsl's `box_triangle_corner`).
|
||||
// Packed four bits each, eight a word, read through a switch: a constant array indexed by the
|
||||
// vertex would be a private array, which Adreno's Vulkan compiler (build 0676.41) fails to link.
|
||||
fn box_triangle_corner(v: u32) -> u32 {
|
||||
var word: u32;
|
||||
switch v / 8u {
|
||||
case 0u: { word = 0x31640260u; }
|
||||
case 1u: { word = 0x05105717u; }
|
||||
case 2u: { word = 0x76237245u; }
|
||||
case 3u: { word = 0x54320130u; }
|
||||
default: { word = 0x00006747u; }
|
||||
}
|
||||
return (word >> (v % 8u * 4u)) & 0xfu;
|
||||
}
|
||||
// counter-clockwise seen from outside (cull.wgsl's).
|
||||
const CORNERS = array<u32, 36>(
|
||||
0u, 6u, 2u, 0u, 4u, 6u, 1u, 3u, 7u, 1u, 7u, 5u, 0u, 1u, 5u, 0u, 5u, 4u,
|
||||
2u, 7u, 3u, 2u, 6u, 7u, 0u, 3u, 1u, 0u, 2u, 3u, 4u, 5u, 7u, 4u, 7u, 6u,
|
||||
);
|
||||
|
||||
struct BoxOut {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
@@ -35,7 +26,7 @@ fn vs_box(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box
|
||||
let o = bitcast<vec3<f32>>(box_origins[id].xyz);
|
||||
var out: BoxOut;
|
||||
out.id = id;
|
||||
out.position = params.view_proj * vec4(box_corner(o, box_sections[id].a.z, box_triangle_corner(v)), 1.0);
|
||||
out.position = params.view_proj * vec4(box_corner(o, box_sections[id].a.z, CORNERS[v]), 1.0);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 1433 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user