mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-11 23:55:37 -10:00
Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e7525e804 | ||
|
|
fe9b82eb39 | ||
|
|
ec9c65be87 | ||
|
|
cdfbac97bd | ||
|
|
d237f961f8 | ||
|
|
8bdb4520ff | ||
|
|
c8701964f4 | ||
|
|
6a8d226dde | ||
|
|
2c8ae8f2ef | ||
|
|
646a049d2d | ||
|
|
717fe15087 | ||
|
|
6debbf2f0e | ||
|
|
c7ba820eac | ||
|
|
2ebefd515c | ||
|
|
992b1fff77 | ||
|
|
8ca8cf121b | ||
|
|
9eff1ee00a | ||
|
|
26d9efa842 | ||
|
|
48aae09c66 | ||
|
|
4993e49715 | ||
|
|
f70fb7da5d | ||
|
|
99c9f7eaa5 | ||
|
|
6ddd66576d | ||
|
|
89564ffce8 | ||
|
|
fb9b6d4299 | ||
|
|
87d4324eb6 | ||
|
|
25aef7173b | ||
|
|
16d2f19957 | ||
|
|
bb090e827e | ||
|
|
8426465800 | ||
|
|
427729f784 | ||
|
|
6a495df673 | ||
|
|
9eb3dc457b | ||
|
|
975b29a641 | ||
|
|
23f1b63e9c | ||
|
|
8aec64cc36 | ||
|
|
0aa44ff207 | ||
|
|
8a00ab490a | ||
|
|
d486e88b5b | ||
|
|
b6c527fb8c | ||
|
|
88cbb484fd | ||
|
|
d3e7582474 | ||
|
|
08a3a5e8ef | ||
|
|
32bd0123e2 |
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). -->
|
||||
|
||||
+360
-10
@@ -40,7 +40,7 @@ jobs:
|
||||
${{ 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
|
||||
@@ -119,22 +119,22 @@ 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).
|
||||
@@ -143,7 +143,7 @@ jobs:
|
||||
${{ 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
|
||||
@@ -313,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]
|
||||
|
||||
@@ -58,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:
|
||||
@@ -77,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
|
||||
@@ -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' || '' }}
|
||||
@@ -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.
|
||||
@@ -99,6 +75,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
|
||||
| `horizon-optifine` | OptiFine-format pack files below every feature that reads them: `.properties`, the name/range/block matchers, paths and `optifine/` folders, and custom entity models (`cem/`: `.jem`/`.jpm` parsing and baking) |
|
||||
| `horizon-polytone` | Polytone-format pack files (`assets/*/polytone/`), read clean-room from the public wiki: targets, biome and dimension modifiers and their environment attribute layers (#11486) |
|
||||
| `horizon-pack-options` | A resource pack's own options as the game lists and keeps them (entries, controls, values, presets), whatever pack format declares them; engine, no dependencies |
|
||||
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
|
||||
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
|
||||
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
|
||||
@@ -120,7 +97,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 +104,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 +114,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 +153,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 +216,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
-30
@@ -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.
|
||||
@@ -24,7 +23,7 @@ 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 +36,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 +49,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 +67,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 +117,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
|
||||
|
||||
Generated
+6
-1
@@ -1590,6 +1590,7 @@ dependencies = [
|
||||
"horizon-items",
|
||||
"horizon-narrator",
|
||||
"horizon-optifine",
|
||||
"horizon-pack-options",
|
||||
"horizon-polytone",
|
||||
"horizon-protocol",
|
||||
"horizon-shader-api",
|
||||
@@ -1831,6 +1832,10 @@ dependencies = [
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-pack-options"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "horizon-platform"
|
||||
version = "0.1.0"
|
||||
@@ -1848,6 +1853,7 @@ dependencies = [
|
||||
name = "horizon-polytone"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-pack-options",
|
||||
"log",
|
||||
"regex",
|
||||
"serde_json",
|
||||
@@ -1947,7 +1953,6 @@ name = "horizon-server-files"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"getrandom 0.3.4",
|
||||
"horizon-core",
|
||||
"horizon-protocol",
|
||||
"horizon-world",
|
||||
|
||||
@@ -47,6 +47,7 @@ members = [
|
||||
"crates/horizon-shaders",
|
||||
"crates/horizon-optifine",
|
||||
"crates/horizon-polytone",
|
||||
"crates/horizon-pack-options",
|
||||
"crates/horizon-shader-api",
|
||||
"crates/horizon-release-sign",
|
||||
"crates/horizon-mod-deps",
|
||||
|
||||
+46
-146
@@ -2,9 +2,6 @@
|
||||
|
||||
> 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
|
||||
@@ -53,13 +50,13 @@ 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** |
|
||||
| **Contributor** (⛏️ Iron, 🥇 Gold, 💎 Diamond, 🟪 Netherite ranks) | Write in #contributors; `/claim` issues; nominate people for group lead (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; take part in the group's advisory poll on its lead | Pick the group's role in Channels & Roles, or with `/groups` | Remove the role | No |
|
||||
| **🤝 Support** | Answer and close posts in #horizon-support | `/apply`; vote of the mods and the Team (as today) | Resign, 30 days inactive, or removed by the Chief of Mods or the council | No |
|
||||
| **🛡️ Moderator** | Time out, delete messages, manage threads, quarantine. No bans | `/apply`, then the selection filter; mods' vote; the council may object within 24 h | Resign, 30 days inactive, suspended by the Chief of 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 | Council poll (simple majority), from the mods with at least 30 days | Resign, 30 days inactive, 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 | Nomination, the filter, a 24 h public discussion with an advisory group poll, then a council poll (simple majority); 30 days probation | Resign, 30 days inactive (automatic), 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 | Nomination, the filter, a 24 h public discussion, then a council poll (2/3); 30 days probation | Resign, 60 days without a merge or review (automatic), 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
|
||||
@@ -67,55 +64,6 @@ owner, Discord server owner, the Microsoft app approved by Mojang, the domains,
|
||||
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
|
||||
@@ -136,13 +84,13 @@ an issue or a pull request on GitHub, or a request for a council poll (any counc
|
||||
|
||||
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).
|
||||
least one counted action in the group's area (section 5.1) in the last 60 days. Only active group members can
|
||||
nominate a lead or vote in the group's advisory poll.
|
||||
|
||||
### 🤝 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.
|
||||
The role carries no permission, so it has no age rule. They join through `/apply`, and the mods and the Team vote.
|
||||
|
||||
### 🛡️ Moderators and the Chief of Mods
|
||||
|
||||
@@ -203,7 +151,7 @@ don't go through the filter or a probation. They do make the 18+ declaration (se
|
||||
| --- | --- |
|
||||
| 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 |
|
||||
| How long | 24 hours. 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 |
|
||||
@@ -217,20 +165,20 @@ don't go through the filter or a probation. They do make the 18+ declaration (se
|
||||
|
||||
| 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 |
|
||||
| Day-to-day project decisions: direction, priorities, settling disputes between groups, a public statement | Adding council members and Horizon Team members |
|
||||
| Group leads and co-leads: appointing, removing, confirming every 6 months | Removing a council member or a Team member (without the person concerned) |
|
||||
| Moderators, the Chief of Mods, Support: confirming, removing | 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) |
|
||||
| Second-level moderation appeals | 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
|
||||
Polls that add someone to the council, the Team or a lead post start with a **24-hour public discussion** (section
|
||||
5.3). Other polls 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
|
||||
@@ -272,7 +220,7 @@ false age declaration (section 9).
|
||||
## 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).
|
||||
measures the work, and only people above a floor can be nominated. The council then decides among them.
|
||||
|
||||
### 5.1 What Kira measures
|
||||
|
||||
@@ -306,63 +254,25 @@ A nomination is refused below these floors.
|
||||
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
|
||||
### 5.3 The path to a role
|
||||
|
||||
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.
|
||||
1. **Nomination.** Run `/nominate member: role: [group:]` in Kira.
|
||||
- A **group lead** can be nominated by an active member of that group or by a council member. A self-nomination
|
||||
needs a second from another active group member or a council member.
|
||||
- A **Team member** can be nominated by a council or Team member. Someone can also nominate themselves with
|
||||
`/apply`, if a council or Team member sponsors them.
|
||||
- A **council member** can only be nominated by a council member.
|
||||
- **Moderators** apply with `/apply`.
|
||||
2. **The filter.** Kira checks the floor and the 18+ declaration. Below the floor, Kira refuses and says which line
|
||||
is short.
|
||||
3. **Public discussion, 24 hours.** Kira posts the nomination and the evidence card in a thread of
|
||||
#polls-and-decisions. Contributors and the council can comment. For a lead, Kira also runs an **advisory poll** in
|
||||
the group's channel for the same 24 hours. Only active group members vote in it, and the council sees the result.
|
||||
4. **Council poll, 24 hours.** Simple majority for a lead, 2/3 for the Team and the council. For moderators, the mods'
|
||||
vote replaces this step, and any two council members can object within 24 hours; the council then decides by poll.
|
||||
5. **Probation, 30 days.** The role is held in full, except for the Team limits in section 3. At day 25, Kira asks the
|
||||
council in #council whether anything is wrong. Probation ends by itself at day 30, unless a council poll (simple
|
||||
majority) ended it earlier.
|
||||
|
||||
### 5.4 Exceptions
|
||||
|
||||
@@ -389,8 +299,8 @@ holder or an org owner removes GitHub access when the Team role ends.
|
||||
|
||||
### 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.
|
||||
- **Group leads** are confirmed every 6 months by a simple-majority poll. If someone else in the group passes the
|
||||
floor and is nominated, both go on the same poll.
|
||||
- **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).
|
||||
|
||||
@@ -429,9 +339,8 @@ the design system:
|
||||
- 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.
|
||||
- asking the Team for reviews, adding `needs-proof`, and nominating people.
|
||||
|
||||
### 6.4 What goes to the council
|
||||
|
||||
@@ -447,28 +356,21 @@ the design system:
|
||||
|
||||
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:**
|
||||
channel. Kira pins a "lead wanted" note with the floor. **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.
|
||||
- **Second appeal:** for bans, timeouts over 24 hours and quarantines, a council poll in #council (simple majority).
|
||||
Members who were involved don't vote. 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.
|
||||
- **Removing a moderator:** the Chief of Mods can suspend a moderator at once, and the council decides within 72 hours
|
||||
(simple majority). The council removes the Chief of Mods the same way.
|
||||
- **Staff are moderated like everyone.** When a council member is involved, the council decides without them.
|
||||
|
||||
## 8. Conflicts and hard cases
|
||||
@@ -530,7 +432,7 @@ unable to act, and never puts an account that is tied to a person into anyone el
|
||||
|
||||
| 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 |
|
||||
| **GitHub organisation** (horizon-rust) | One owner (the founder) | 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 |
|
||||
@@ -556,9 +458,7 @@ and the founder carries it out.
|
||||
## 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.
|
||||
For polls that aren't about a person, each member's vote is shown once the poll closes.
|
||||
- **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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
```
|
||||
@@ -23,10 +23,6 @@ pub const WITHDRAWN_PARTNER_SERVER: &str = "afri-network.co.za";
|
||||
/// (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.
@@ -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);
|
||||
|
||||
@@ -271,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)),
|
||||
(
|
||||
@@ -973,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 [
|
||||
(
|
||||
|
||||
@@ -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,14 +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 {
|
||||
@@ -1592,7 +1459,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 +1466,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 +1492,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 +1513,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 +1873,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"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -16,6 +16,5 @@ 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
@@ -24,6 +24,7 @@ horizon-narrator = { path = "../horizon-narrator" }
|
||||
# OptiFine-format pack files: random entity textures (hud.rs), connected textures
|
||||
# (connected.rs) and custom item texture rules (cit.rs).
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-pack-options = { path = "../horizon-pack-options" }
|
||||
horizon-polytone = { path = "../horizon-polytone" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
|
||||
@@ -288,6 +288,8 @@ fn zip_pack(id: &str, path: PathBuf) -> Pack {
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// The pack formats the game installs at startup, so their loading is measured too.
|
||||
horizon_client_render::pack_formats::install();
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let scale = args
|
||||
.iter()
|
||||
|
||||
@@ -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)]
|
||||
@@ -104,10 +99,8 @@ pub struct RenderAssets {
|
||||
pub pbr_normals: Vec<u8>,
|
||||
pub pbr_specular: Vec<u8>,
|
||||
pub pbr_size: u32,
|
||||
/// OptiFine connected textures (empty without `optifine/ctm` files).
|
||||
pub connected: crate::connected::ConnectedTextures,
|
||||
/// Fusion connected textures (empty without Fusion's connecting textures and models).
|
||||
pub fusion: crate::fusion::FusionTextures,
|
||||
/// The packs' connected textures (OptiFine's, Fusion's), when they have some.
|
||||
pub connected: Option<crate::connected::Connected>,
|
||||
/// Counts up each time resources load, from 1: a native mod asks again where blocks'
|
||||
/// sprites are when it changes (`native_block_sprites_version`).
|
||||
pub generation: u64,
|
||||
@@ -187,23 +180,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 +254,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 +318,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)?
|
||||
@@ -377,41 +343,27 @@ impl RenderAssets {
|
||||
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
|
||||
sprites.insert(root, &format!("minecraft:block/destroy_oit_stage_{stage}"))?;
|
||||
}
|
||||
let fusion = if draws {
|
||||
let _step = crate::spans::startup::step("core.fusion");
|
||||
crate::fusion::FusionTextures::load(root, registry, &mut sprites, &mut models.catalog)
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let mut emissive = Vec::new();
|
||||
let connected = if draws {
|
||||
crate::native_emissive::load(root, &mut sprites);
|
||||
// A side's sprite, for rules that connect by tile: the first quad facing it.
|
||||
let catalog = &models.catalog;
|
||||
let mut found = Vec::new();
|
||||
crate::connected::ConnectedTextures::load(
|
||||
// Native mods' emissive textures go where the pack format puts them (after its
|
||||
// models' tiles, before its rules' tiles), else after both.
|
||||
let mut native_loaded = false;
|
||||
let mut native = |sprites: &mut crate::sprites::Sprites| {
|
||||
if !std::mem::replace(&mut native_loaded, true) {
|
||||
crate::native_emissive::load(root, sprites);
|
||||
}
|
||||
};
|
||||
let connected = crate::pack_hooks::formats().connected_textures(
|
||||
root,
|
||||
ctm_rules,
|
||||
registry,
|
||||
&mut sprites,
|
||||
|state, face| {
|
||||
let side = horizon_assets::model::Direction::ALL[face as usize];
|
||||
catalog
|
||||
.models_at(
|
||||
state,
|
||||
horizon_core::position::BlockPos::new(0, 0, 0),
|
||||
&mut found,
|
||||
)
|
||||
.ok()?;
|
||||
found
|
||||
.iter()
|
||||
.flat_map(|m| &m.faces)
|
||||
.find(|f| f.quad.facing == side)
|
||||
.map(|f| f.layer)
|
||||
},
|
||||
)
|
||||
&mut models.catalog,
|
||||
&mut native,
|
||||
);
|
||||
native(&mut sprites);
|
||||
connected
|
||||
} else {
|
||||
Default::default()
|
||||
None
|
||||
};
|
||||
// After the connected tiles: a tile identical to a block shares its layer, and the
|
||||
// overlays skip shared layers.
|
||||
@@ -444,7 +396,6 @@ impl RenderAssets {
|
||||
pbr_specular: sprites.specular,
|
||||
pbr_size: sprites.pbr_size,
|
||||
connected,
|
||||
fusion,
|
||||
generation: GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1,
|
||||
})
|
||||
}
|
||||
@@ -529,29 +480,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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
@@ -960,7 +960,7 @@ pub struct Scene<'a> {
|
||||
pub world: &'a World,
|
||||
pub sprites: &'a HashMap<String, Rect>,
|
||||
/// The packs' OptiFine emissive overlays of the sprites.
|
||||
pub emissive: &'a horizon_optifine::emissive::Emissive,
|
||||
pub emissive: &'a crate::entity_textures::Emissive,
|
||||
/// Atlas sprites that contain a texel with fractional alpha. Static
|
||||
/// geometry stays on the cutout pipeline; these models keep the blended
|
||||
/// entity path so resource packs retain their alpha behavior.
|
||||
@@ -1039,7 +1039,7 @@ impl CacheOptions {
|
||||
}
|
||||
}
|
||||
/// The block-entity models and what is baked from them: vanilla's, or the packs' custom entity
|
||||
/// models over them ([`crate::block_entity_cem`]).
|
||||
/// models over them ([`crate::pack_hooks::PackFormats::block_entity_models`]).
|
||||
#[derive(Default)]
|
||||
pub struct BlockEntityShapes {
|
||||
models: HashMap<&'static str, ModelPart>,
|
||||
@@ -1555,7 +1555,7 @@ impl BlockEntityRenderer {
|
||||
/// The models the packs under `root` give, their `.jem` files over vanilla's, for
|
||||
/// [`Self::set_shapes`] (a pack reload): vanilla's own, shared, without any.
|
||||
pub fn shapes_for(&self, root: &Path) -> Arc<BlockEntityShapes> {
|
||||
match crate::block_entity_cem::reshape(root, &self.vanilla.models) {
|
||||
match crate::pack_hooks::formats().block_entity_models(root, &self.vanilla.models) {
|
||||
Some((models, textures)) => Arc::new(BlockEntityShapes::new(models, textures)),
|
||||
None => self.vanilla.clone(),
|
||||
}
|
||||
@@ -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,18 +4760,16 @@ 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"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chest_jem_reshapes_the_closed_chest_and_paints_its_texture() {
|
||||
use crate::block_entity_cem::tests::{BOX, chest, pack};
|
||||
use crate::pack_formats::block_entity_cem::tests::{BOX, chest, pack};
|
||||
crate::pack_formats::install();
|
||||
let vanilla = Arc::new(BlockEntityShapes::new(
|
||||
HashMap::from([("chest", chest())]),
|
||||
HashMap::new(),
|
||||
@@ -4867,8 +4865,8 @@ mod tests {
|
||||
};
|
||||
// The chest's three faces become the bottom's, the lock's and the custom lid's six, in
|
||||
// the `.jem`'s texture. Ender and copper chests have no file of their own: vanilla's
|
||||
// three faces in their own textures. (Trapped chests: `block_entity_cem`'s tests; their
|
||||
// texture turns festive in late December.)
|
||||
// three faces in their own textures. (Trapped chests: `block_entity_cem`'s tests in
|
||||
// `pack_formats`; their texture turns festive in late December.)
|
||||
for (block, texture, u0, faces) in [
|
||||
("chest", "entity/chest/normal", 0.4, 8),
|
||||
("ender_chest", "entity/chest/ender", 0.2, 3),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1974,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
|
||||
@@ -2090,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,32 +1066,3 @@ fn a_server_filter_mask_hides_or_masks_the_message() {
|
||||
.all(|s| s.style.color.is_none())
|
||||
);
|
||||
}
|
||||
|
||||
/// `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;
|
||||
@@ -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,
|
||||
@@ -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 {
|
||||
@@ -1085,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]
|
||||
|
||||
@@ -539,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)]
|
||||
|
||||
@@ -1,116 +1,157 @@
|
||||
//! OptiFine's Custom Item Textures (and CIT Resewn's): the rules of the enabled packs'
|
||||
//! `optifine/cit/**/*.properties`, read from the overlay resource root. The rules themselves are
|
||||
//! `horizon_optifine::cit`'s.
|
||||
//! Custom item textures: rules a pack format vanilla doesn't read (OptiFine's and CIT Resewn's
|
||||
//! `cit/` folders) gives, which change a stack's texture, model, worn armour layers or
|
||||
//! enchantment glint. The platform's pack formats read and match them ([`crate::pack_hooks`]);
|
||||
//! the engine draws what the matching rule says ([`Rule`]).
|
||||
|
||||
pub use horizon_optifine::cit::{Hand, Kind, Rule};
|
||||
|
||||
use crate::cit_glint::{Global, StackGlint};
|
||||
use horizon_client_core::component::name_text;
|
||||
use horizon_client_core::scoreboard::comp_text;
|
||||
use horizon_items::item_data::{canon_tag, encode_components};
|
||||
use horizon_items::items::ItemStack;
|
||||
use horizon_optifine::cit::{ComponentTest, Source, ValueTest};
|
||||
use horizon_optifine::{Properties, properties_files_in};
|
||||
use horizon_world::nbt::{JavaString, Tag, parse_snbt};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Every rule under the root's `optifine/cit/`, `mcpatcher/cit/` and CIT Resewn's
|
||||
/// `citresewn/cit/` folders, the one chosen first when several match first (`Rule::priority`).
|
||||
/// The pack's global `cit.properties` is not a rule.
|
||||
pub fn load_rules(root: &Path) -> Vec<Rule> {
|
||||
let tops = ["optifine", "mcpatcher", "citresewn"];
|
||||
let mut rules: Vec<Rule> = properties_files_in(root, &tops, "cit")
|
||||
.into_iter()
|
||||
.filter(|path| !path.ends_with("/cit.properties"))
|
||||
.filter_map(|path| {
|
||||
let props = read_properties(&root.join(&path))?;
|
||||
let rule = Rule::parse(&path, &props, |p| root.join(p).is_file());
|
||||
if rule.is_none() {
|
||||
log::warn!("custom item textures: {path} is not a rule Horizon can read");
|
||||
}
|
||||
rule
|
||||
})
|
||||
.collect();
|
||||
rules.sort_by(Rule::priority);
|
||||
rules
|
||||
use horizon_items::items::ItemStack;
|
||||
|
||||
use crate::cit_glint::StackGlint;
|
||||
|
||||
/// What a rule changes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
Item,
|
||||
Armor,
|
||||
Elytra,
|
||||
Enchantment,
|
||||
}
|
||||
|
||||
/// A `.properties` file as CIT Resewn reads them (`Properties::parse_equals_first`): UTF-8,
|
||||
/// else Latin-1 as Java reads them, so a pack saved that way still loads. `None` when it can't
|
||||
/// be read.
|
||||
pub fn read_properties(path: &Path) -> Option<Properties> {
|
||||
let bytes = fs::read(path).ok()?;
|
||||
let text = String::from_utf8(bytes)
|
||||
.unwrap_or_else(|e| e.into_bytes().iter().map(|&b| char::from(b)).collect());
|
||||
Some(Properties::parse_equals_first(&text))
|
||||
/// The hand a stack is drawn in; GUI items count as the main hand. `Any` is a rule's "either".
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Hand {
|
||||
Any,
|
||||
Main,
|
||||
Off,
|
||||
}
|
||||
|
||||
/// The rules with an index by item and a cache of what each stack matched.
|
||||
#[derive(Debug, Default)]
|
||||
/// An OpenGL blend factor (`GL_ONE`, `GL_SRC_ALPHA`...), as `glBlendFuncSeparate` takes them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Factor {
|
||||
Zero,
|
||||
One,
|
||||
SrcColor,
|
||||
OneMinusSrcColor,
|
||||
SrcAlpha,
|
||||
OneMinusSrcAlpha,
|
||||
DstAlpha,
|
||||
OneMinusDstAlpha,
|
||||
DstColor,
|
||||
OneMinusDstColor,
|
||||
SrcAlphaSaturate,
|
||||
}
|
||||
|
||||
/// A glint layer's blend factors for colour and alpha.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Blend {
|
||||
pub src: Factor,
|
||||
pub dst: Factor,
|
||||
pub src_alpha: Factor,
|
||||
pub dst_alpha: Factor,
|
||||
}
|
||||
|
||||
/// An enchantment rule's glint layer.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Glint {
|
||||
pub layer: i32,
|
||||
/// `None` draws without blending (replace).
|
||||
pub blend: Option<Blend>,
|
||||
/// Scroll speed: the texture runs through vanilla's glint periods at `speed` times the
|
||||
/// clock in milliseconds.
|
||||
pub speed: f32,
|
||||
/// Degrees the texture turns by.
|
||||
pub rotation: f32,
|
||||
/// Seconds on this layer when the layers take turns.
|
||||
pub duration: f32,
|
||||
/// Multipliers of the texture's colour, never negative.
|
||||
pub rgba: [f32; 4],
|
||||
/// Whether the default glint still draws under this layer.
|
||||
pub use_glint: bool,
|
||||
/// Linear filtering, else nearest.
|
||||
pub blur: bool,
|
||||
}
|
||||
|
||||
/// A rule as the engine draws it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Rule {
|
||||
/// The rule's file, as a resource path.
|
||||
pub path: String,
|
||||
pub kind: Kind,
|
||||
/// Its texture, as a resource path to a `.png`.
|
||||
pub texture: Option<String>,
|
||||
/// Its named textures: (name, resource path).
|
||||
pub textures: Vec<(String, String)>,
|
||||
/// Its model, as a resource path to a `.json`.
|
||||
pub model: Option<String>,
|
||||
/// Its named models.
|
||||
pub models: Vec<(String, String)>,
|
||||
/// What an Enchantment rule draws.
|
||||
pub glint: Glint,
|
||||
}
|
||||
|
||||
/// Which rules a stack matches: the pack format's own conditions.
|
||||
pub trait Matcher: Send + Sync {
|
||||
/// The winning rule of `kind` (Item, Armor or Elytra) for this stack, as an index into
|
||||
/// [`CitRules::rules`].
|
||||
fn select(&self, kind: Kind, stack: &ItemStack, hand: Hand, max_damage: u32) -> Option<usize>;
|
||||
/// Every matching Enchantment rule for the stack drawn in `hand`, in priority order.
|
||||
fn enchantments(&self, stack: &ItemStack, hand: Hand, max_damage: u32) -> Vec<usize>;
|
||||
/// The glint layers this stack draws in `hand`; `None` when the pack changes no glint.
|
||||
/// `foil` is whether the stack draws a glint at all.
|
||||
fn glint(
|
||||
&self,
|
||||
stack: &ItemStack,
|
||||
hand: Hand,
|
||||
max_damage: u32,
|
||||
foil: bool,
|
||||
) -> Option<Arc<StackGlint>>;
|
||||
/// Leave out the Enchantment rules `keep` refuses: they match no stack.
|
||||
fn keep_glints(&mut self, keep: &dyn Fn(usize) -> bool);
|
||||
}
|
||||
|
||||
/// The packs' custom item texture rules, the winner first, and their matcher.
|
||||
pub struct CitRules {
|
||||
pub rules: Vec<Rule>,
|
||||
/// The pack's `cit.properties`.
|
||||
pub global: Global,
|
||||
/// Per kind (`Kind as usize`), the rules naming each item, in priority order.
|
||||
by_item: [HashMap<String, Vec<u32>>; 4],
|
||||
/// Enchantment rules without `items`, in priority order.
|
||||
any_item: Vec<u32>,
|
||||
/// Per rule, per component test: its literal read as SNBT and as a JSON text component.
|
||||
literals: Vec<Vec<Prepared>>,
|
||||
selected: Mutex<HashMap<u64, Option<u32>>>,
|
||||
enchanted: Mutex<HashMap<u64, Vec<u32>>>,
|
||||
glints: Mutex<HashMap<u64, Arc<StackGlint>>>,
|
||||
/// Whether the default glint draws on items no enchantment rule matches.
|
||||
pub use_glint: bool,
|
||||
matcher: Option<Box<dyn Matcher>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Prepared {
|
||||
snbt: Option<Tag>,
|
||||
json: Option<String>,
|
||||
}
|
||||
|
||||
/// Cache entries kept before the cache starts over.
|
||||
const CACHE_LIMIT: usize = 4096;
|
||||
|
||||
impl CitRules {
|
||||
/// The enabled packs' rules under the overlay resource root.
|
||||
pub fn load(root: &Path) -> Self {
|
||||
impl Default for CitRules {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
global: crate::cit_glint::load_global(root),
|
||||
..Self::new(load_rules(root))
|
||||
rules: Vec::new(),
|
||||
use_glint: true,
|
||||
matcher: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `rules` in priority order, the winner first.
|
||||
pub fn new(rules: Vec<Rule>) -> Self {
|
||||
let mut by_item: [HashMap<String, Vec<u32>>; 4] = Default::default();
|
||||
let mut any_item = Vec::new();
|
||||
for (i, rule) in rules.iter().enumerate() {
|
||||
let i = i as u32;
|
||||
if rule.kind == Kind::Enchantment && rule.items.is_empty() {
|
||||
any_item.push(i);
|
||||
}
|
||||
for item in &rule.items {
|
||||
let list = by_item[rule.kind as usize].entry(item.clone()).or_default();
|
||||
if list.last() != Some(&i) {
|
||||
list.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
let literals = rules
|
||||
.iter()
|
||||
.map(|r| r.components.iter().map(prepare).collect())
|
||||
.collect();
|
||||
impl std::fmt::Debug for CitRules {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("CitRules")
|
||||
.field("rules", &self.rules)
|
||||
.field("use_glint", &self.use_glint)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl CitRules {
|
||||
/// The rules of the overlay at `root`, from the installed pack formats.
|
||||
pub fn load(root: &Path) -> Self {
|
||||
crate::pack_hooks::formats()
|
||||
.item_textures(root)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `rules`, in priority order, matched by `matcher`.
|
||||
pub fn new(rules: Vec<Rule>, use_glint: bool, matcher: Box<dyn Matcher>) -> Self {
|
||||
Self {
|
||||
rules,
|
||||
by_item,
|
||||
any_item,
|
||||
literals,
|
||||
..Default::default()
|
||||
use_glint,
|
||||
matcher: Some(matcher),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,17 +162,12 @@ impl CitRules {
|
||||
/// Leave out the Enchantment rules `keep` refuses (those without a drawable texture): they
|
||||
/// match no stack, so vanilla's glint and the other rules of their layer stay.
|
||||
pub fn keep_glints(&mut self, keep: impl Fn(usize) -> bool) {
|
||||
self.any_item.retain(|&i| keep(i as usize));
|
||||
let named = &mut self.by_item[Kind::Enchantment as usize];
|
||||
for list in named.values_mut() {
|
||||
list.retain(|&i| keep(i as usize));
|
||||
if let Some(matcher) = &mut self.matcher {
|
||||
matcher.keep_glints(&keep);
|
||||
}
|
||||
named.retain(|_, list| !list.is_empty());
|
||||
lock(&self.enchanted).clear();
|
||||
lock(&self.glints).clear();
|
||||
}
|
||||
|
||||
/// The winning rule of `kind` (Item, Armor or Elytra) for this stack, as an index into `rules`.
|
||||
/// [`Matcher::select`].
|
||||
pub fn select(
|
||||
&self,
|
||||
kind: Kind,
|
||||
@@ -139,51 +175,17 @@ impl CitRules {
|
||||
hand: Hand,
|
||||
max_damage: u32,
|
||||
) -> Option<usize> {
|
||||
let candidates = self.by_item[kind as usize].get(&*stack.item)?;
|
||||
let comps = encode_components(stack);
|
||||
let key = cache_key((0, kind, hand), stack, max_damage, comps.as_ref());
|
||||
if let Some(hit) = lock(&self.selected).get(&key) {
|
||||
return hit.map(|i| i as usize);
|
||||
}
|
||||
let found = candidates
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|&i| self.matches(i, stack, hand, max_damage, comps.as_ref()));
|
||||
remember(&self.selected, key, found);
|
||||
found.map(|i| i as usize)
|
||||
self.matcher.as_ref()?.select(kind, stack, hand, max_damage)
|
||||
}
|
||||
|
||||
/// Every matching Enchantment rule for the stack drawn in `hand` (`Hand::Main` for one in a
|
||||
/// GUI), in priority order.
|
||||
/// [`Matcher::enchantments`].
|
||||
pub fn enchantments(&self, stack: &ItemStack, hand: Hand, max_damage: u32) -> Vec<usize> {
|
||||
let named = self.by_item[Kind::Enchantment as usize]
|
||||
.get(&*stack.item)
|
||||
.map_or(&[][..], Vec::as_slice);
|
||||
if named.is_empty() && self.any_item.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let comps = encode_components(stack);
|
||||
let key = cache_key(
|
||||
(1, Kind::Enchantment, hand),
|
||||
stack,
|
||||
max_damage,
|
||||
comps.as_ref(),
|
||||
);
|
||||
let hit = lock(&self.enchanted).get(&key).cloned();
|
||||
let found = hit.unwrap_or_else(|| {
|
||||
let mut all: Vec<u32> = named.iter().chain(&self.any_item).copied().collect();
|
||||
all.sort_unstable();
|
||||
all.retain(|&i| self.matches(i, stack, hand, max_damage, comps.as_ref()));
|
||||
remember(&self.enchanted, key, all.clone());
|
||||
all
|
||||
});
|
||||
found.into_iter().map(|i| i as usize).collect()
|
||||
self.matcher
|
||||
.as_ref()
|
||||
.map_or_else(Vec::new, |m| m.enchantments(stack, hand, max_damage))
|
||||
}
|
||||
|
||||
/// The glint layers this stack draws in `hand` (`Hand::Main` in a GUI) and whether
|
||||
/// vanilla's still does (`StackGlint`), worked out once per stack and hand; `None` when the
|
||||
/// pack changes no glint. `foil` is whether the stack draws a glint at all
|
||||
/// (`ItemStack::has_foil`): one that doesn't gets no layers.
|
||||
/// [`Matcher::glint`].
|
||||
pub fn glint(
|
||||
&self,
|
||||
stack: &ItemStack,
|
||||
@@ -191,659 +193,6 @@ impl CitRules {
|
||||
max_damage: u32,
|
||||
foil: bool,
|
||||
) -> Option<Arc<StackGlint>> {
|
||||
let no_rules =
|
||||
self.any_item.is_empty() && self.by_item[Kind::Enchantment as usize].is_empty();
|
||||
// Before the cache, which `foil` is not part of.
|
||||
if !foil || (no_rules && self.global.use_glint) {
|
||||
return None;
|
||||
}
|
||||
let comps = encode_components(stack);
|
||||
let key = cache_key(
|
||||
(2, Kind::Enchantment, hand),
|
||||
stack,
|
||||
max_damage,
|
||||
comps.as_ref(),
|
||||
);
|
||||
if let Some(hit) = lock(&self.glints).get(&key) {
|
||||
return Some(hit.clone());
|
||||
}
|
||||
let matched = self.enchantments(stack, hand, max_damage);
|
||||
let glint = Arc::new(StackGlint::new(
|
||||
&self.rules,
|
||||
&matched,
|
||||
stack.crafting_enchantments(),
|
||||
&self.global,
|
||||
));
|
||||
remember(&self.glints, key, glint.clone());
|
||||
Some(glint)
|
||||
}
|
||||
|
||||
/// Whether rule `i` matches the stack drawn in `hand`.
|
||||
fn matches(
|
||||
&self,
|
||||
i: u32,
|
||||
stack: &ItemStack,
|
||||
hand: Hand,
|
||||
max_damage: u32,
|
||||
comps: Option<&Tag>,
|
||||
) -> bool {
|
||||
let rule = &self.rules[i as usize];
|
||||
if rule.hand != Hand::Any && rule.hand != hand {
|
||||
return false;
|
||||
}
|
||||
let count = i32::try_from(stack.count).unwrap_or(i32::MAX);
|
||||
if rule.stack_size.as_ref().is_some_and(|r| !r.contains(count)) {
|
||||
return false;
|
||||
}
|
||||
// CIT Resewn: only damageable items have damage.
|
||||
let damage = if max_damage > 0 { stack.damage } else { 0 };
|
||||
if rule
|
||||
.damage
|
||||
.as_ref()
|
||||
.is_some_and(|d| !d.matches(damage, max_damage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let enchantments = stack.crafting_enchantments();
|
||||
let listed = |id: &str| rule.enchantments.iter().any(|e| e == id);
|
||||
if !rule.enchantments.is_empty() && !enchantments.iter().any(|(id, _)| listed(id)) {
|
||||
return false;
|
||||
}
|
||||
if let Some(levels) = &rule.enchantment_levels {
|
||||
let level_in_range = enchantments.iter().any(|(id, level)| {
|
||||
(rule.enchantments.is_empty() || listed(id))
|
||||
&& levels.contains(i32::try_from(*level).unwrap_or(i32::MAX))
|
||||
});
|
||||
if !level_in_range {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let literals = &self.literals[i as usize];
|
||||
rule.components
|
||||
.iter()
|
||||
.zip(literals)
|
||||
.all(|(test, lit)| component_test(test, lit, stack, comps) != test.negate)
|
||||
}
|
||||
}
|
||||
|
||||
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn remember<V>(cache: &Mutex<HashMap<u64, V>>, key: u64, value: V) {
|
||||
let mut cache = lock(cache);
|
||||
if cache.len() >= CACHE_LIMIT {
|
||||
cache.clear();
|
||||
}
|
||||
cache.insert(key, value);
|
||||
}
|
||||
|
||||
/// What a cached answer depends on; `query` tells `select`, `enchantments` and `glint` apart.
|
||||
fn cache_key(query: (u8, Kind, Hand), stack: &ItemStack, max: u32, comps: Option<&Tag>) -> u64 {
|
||||
let mut h = DefaultHasher::new();
|
||||
(query.0, query.1 as u8, query.2 as u8, &*stack.item).hash(&mut h);
|
||||
(stack.count, stack.damage, max, comps).hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
fn prepare(test: &ComponentTest) -> Prepared {
|
||||
let ValueTest::Literal(lit) = &test.value else {
|
||||
return Prepared::default();
|
||||
};
|
||||
let text = lit.text.trim();
|
||||
let snbt = match parse_snbt(text) {
|
||||
Ok((tag, read)) if read == text.len() && text.starts_with(['{', '[']) => Some(tag),
|
||||
_ => None,
|
||||
};
|
||||
let json = json_plain(text);
|
||||
Prepared { snbt, json }
|
||||
}
|
||||
|
||||
/// The plain text of `s` read as a JSON text component.
|
||||
fn json_plain(s: &str) -> Option<String> {
|
||||
if !s.starts_with(['{', '[', '"']) {
|
||||
return None;
|
||||
}
|
||||
let value: serde_json::Value = serde_json::from_str(s).ok()?;
|
||||
Some(comp_text(&value).plain())
|
||||
}
|
||||
|
||||
/// Whether one test passes, before its `negate`.
|
||||
fn component_test(
|
||||
test: &ComponentTest,
|
||||
lit: &Prepared,
|
||||
stack: &ItemStack,
|
||||
comps: Option<&Tag>,
|
||||
) -> bool {
|
||||
let legacy;
|
||||
let (root, text_depth) = match &test.source {
|
||||
Source::Component(id) => {
|
||||
let depth = match id.as_str() {
|
||||
"minecraft:custom_name" | "minecraft:item_name" => Some(0),
|
||||
"minecraft:lore" => Some(1),
|
||||
_ => None,
|
||||
};
|
||||
(comps.and_then(|c| c.get(id)), depth)
|
||||
}
|
||||
Source::LegacyEnchantments { stored } => {
|
||||
let levels = if *stored {
|
||||
stack.stored_enchantments()
|
||||
} else {
|
||||
stack.enchantments()
|
||||
};
|
||||
legacy = Tag::List(
|
||||
levels
|
||||
.iter()
|
||||
.map(|(id, lvl)| {
|
||||
let lvl = i16::try_from(*lvl).unwrap_or(i16::MAX);
|
||||
Tag::compound([
|
||||
("id", Tag::String((&**id).into())),
|
||||
("lvl", Tag::Short(lvl)),
|
||||
])
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
(Some(&legacy), None)
|
||||
}
|
||||
};
|
||||
// Fields the component leaves out when 0, which pre-1.20.5 items always wrote.
|
||||
let defaulted;
|
||||
let root = match (&test.source, root, test.path.first()) {
|
||||
(Source::Component(id), Some(Tag::Compound(map)), Some(field))
|
||||
if matches!(
|
||||
(id.as_str(), field.as_str()),
|
||||
("minecraft:written_book_content", "generation")
|
||||
| ("minecraft:fireworks", "flight_duration")
|
||||
) && !map.contains_key(&JavaString::from(field.as_str())) =>
|
||||
{
|
||||
let mut map = map.clone();
|
||||
map.insert(JavaString::from(field.as_str()), Tag::Int(0));
|
||||
defaulted = Tag::Compound(map);
|
||||
Some(&defaulted)
|
||||
}
|
||||
(_, root, _) => root,
|
||||
};
|
||||
let Some(root) = root else {
|
||||
return matches!(test.value, ValueTest::Exists(false));
|
||||
};
|
||||
if let ValueTest::Exists(want) = test.value {
|
||||
return walk(root, &test.path, 0, &mut |_, _| true) == want;
|
||||
}
|
||||
walk(root, &test.path, 0, &mut |tag, depth| {
|
||||
value_test(&test.value, lit, tag, text_depth == Some(depth))
|
||||
})
|
||||
}
|
||||
|
||||
/// Visits what `path` reaches from `tag`, true once `visit` is.
|
||||
fn walk(
|
||||
tag: &Tag,
|
||||
path: &[String],
|
||||
depth: usize,
|
||||
visit: &mut dyn FnMut(&Tag, usize) -> bool,
|
||||
) -> bool {
|
||||
let Some((seg, rest)) = path.split_first() else {
|
||||
return visit(tag, depth);
|
||||
};
|
||||
let mut next = |t: &Tag| walk(t, rest, depth + 1, visit);
|
||||
let len = match tag {
|
||||
Tag::Compound(map) if seg == "*" => return map.values().any(&mut next),
|
||||
Tag::Compound(map) => return map.get(&JavaString::from(seg.as_str())).is_some_and(next),
|
||||
Tag::List(v) => v.len(),
|
||||
Tag::ByteArray(v) => v.len(),
|
||||
Tag::IntArray(v) => v.len(),
|
||||
Tag::LongArray(v) => v.len(),
|
||||
_ => return false,
|
||||
};
|
||||
let element = |i: usize| match tag {
|
||||
Tag::List(v) => Cow::Borrowed(&v[i]),
|
||||
Tag::ByteArray(v) => Cow::Owned(Tag::Byte(v[i] as i8)),
|
||||
Tag::IntArray(v) => Cow::Owned(Tag::Int(v[i])),
|
||||
Tag::LongArray(v) => Cow::Owned(Tag::Long(v[i])),
|
||||
_ => Cow::Owned(Tag::End),
|
||||
};
|
||||
if seg == "count" {
|
||||
return next(&Tag::Int(i32::try_from(len).unwrap_or(i32::MAX)));
|
||||
}
|
||||
if seg == "*" {
|
||||
return (0..len).any(|i| next(&element(i)));
|
||||
}
|
||||
seg.parse::<usize>()
|
||||
.ok()
|
||||
.filter(|&i| i < len)
|
||||
.is_some_and(|i| next(&element(i)))
|
||||
}
|
||||
|
||||
fn int_value(tag: &Tag) -> Option<i64> {
|
||||
match *tag {
|
||||
Tag::Byte(v) => Some(v.into()),
|
||||
Tag::Short(v) => Some(v.into()),
|
||||
Tag::Int(v) => Some(v.into()),
|
||||
Tag::Long(v) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A number as Java's `String.valueOf` writes it (`3`, `3.0`).
|
||||
fn decimal(tag: &Tag) -> Option<String> {
|
||||
let s = match *tag {
|
||||
Tag::Float(b) => f32::from_bits(b).to_string(),
|
||||
Tag::Double(b) => f64::from_bits(b).to_string(),
|
||||
_ => return int_value(tag).map(|v| v.to_string()),
|
||||
};
|
||||
Some(if s.contains(['.', 'e', 'N', 'i']) {
|
||||
s
|
||||
} else {
|
||||
s + ".0"
|
||||
})
|
||||
}
|
||||
|
||||
/// `text` against a string, its JSON text, or the plain text of a text component.
|
||||
fn text_matches(tag: &Tag, is_text: bool, text: impl Fn(&str) -> bool) -> bool {
|
||||
match tag {
|
||||
Tag::String(s) => {
|
||||
let s = s.to_string_lossy();
|
||||
text(&s)
|
||||
|| (is_text && text(&name_text(tag).plain()))
|
||||
|| json_plain(&s).is_some_and(|p| text(&p))
|
||||
}
|
||||
Tag::Compound(_) | Tag::List(_) => is_text && text(&name_text(tag).plain()),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn value_test(test: &ValueTest, lit: &Prepared, tag: &Tag, is_text: bool) -> bool {
|
||||
match test {
|
||||
ValueTest::Exists(_) => true,
|
||||
ValueTest::Range(r) => {
|
||||
int_value(tag).is_some_and(|v| i32::try_from(v).is_ok_and(|v| r.contains(v)))
|
||||
}
|
||||
ValueTest::Raw(name) => name.matches(&canon_tag(tag)),
|
||||
ValueTest::Text(name) => match decimal(tag) {
|
||||
Some(n) => name.matches(&n),
|
||||
None => text_matches(tag, is_text, |s| name.matches(s)),
|
||||
},
|
||||
ValueTest::Literal(l) => match tag {
|
||||
Tag::Float(b) => l.text.trim().parse::<f32>().ok() == Some(f32::from_bits(*b)),
|
||||
Tag::Double(b) => l.float == Some(f64::from_bits(*b)),
|
||||
Tag::Compound(_) | Tag::List(_)
|
||||
if lit.snbt.as_ref().is_some_and(|e| contains(e, tag)) =>
|
||||
{
|
||||
true
|
||||
}
|
||||
_ => match int_value(tag) {
|
||||
Some(v) => l.int == Some(v),
|
||||
None => text_matches(tag, is_text, |s| {
|
||||
s == l.text || lit.json.as_deref() == Some(s)
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// `NbtUtils.compareNbt(expected, actual, true)`: every key and element of `expected` found in
|
||||
/// `actual`.
|
||||
fn contains(expected: &Tag, actual: &Tag) -> bool {
|
||||
match (expected, actual) {
|
||||
(Tag::Compound(e), Tag::Compound(a)) => e
|
||||
.iter()
|
||||
.all(|(k, v)| a.get(k).is_some_and(|a| contains(v, a))),
|
||||
(Tag::List(e), Tag::List(a)) if e.is_empty() => a.is_empty(),
|
||||
(Tag::List(e), Tag::List(a)) => e.iter().all(|e| a.iter().any(|a| contains(e, a))),
|
||||
_ => expected == actual,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Rules from (file stem, properties), sorted as `load_rules` sorts them.
|
||||
fn rules(files: &[(&str, &str)]) -> CitRules {
|
||||
let mut rules: Vec<Rule> = files
|
||||
.iter()
|
||||
.map(|(stem, text)| {
|
||||
let path = format!("assets/minecraft/optifine/cit/{stem}.properties");
|
||||
let text = format!("texture=x\n{text}");
|
||||
Rule::parse(&path, &Properties::parse_equals_first(&text), |_| true).unwrap()
|
||||
})
|
||||
.collect();
|
||||
rules.sort_by(Rule::priority);
|
||||
CitRules::new(rules)
|
||||
}
|
||||
|
||||
fn stem(cit: &CitRules, i: Option<usize>) -> Option<&str> {
|
||||
let path = &cit.rules[i?].path;
|
||||
path.rsplit('/').next()?.strip_suffix(".properties")
|
||||
}
|
||||
|
||||
fn item(cit: &CitRules, stack: ItemStack) -> Option<&str> {
|
||||
stem(cit, cit.select(Kind::Item, &stack, Hand::Main, 0))
|
||||
}
|
||||
|
||||
fn new(id: &str) -> ItemStack {
|
||||
ItemStack::new(&format!("minecraft:{id}"), 1)
|
||||
}
|
||||
|
||||
fn string(s: &str) -> Tag {
|
||||
Tag::String(s.into())
|
||||
}
|
||||
|
||||
fn with(id: &str, component: &str, tag: Tag) -> ItemStack {
|
||||
let mut stack = new(id);
|
||||
let component = format!("minecraft:{component}");
|
||||
stack.data_mut().other.push((component, tag));
|
||||
stack
|
||||
}
|
||||
|
||||
fn enchanted(id: &str, levels: &[(&str, u32)]) -> ItemStack {
|
||||
let mut stack = new(id);
|
||||
let levels = levels
|
||||
.iter()
|
||||
.map(|(e, l)| (Arc::from(format!("minecraft:{e}")), *l));
|
||||
stack.data_mut().enchantments = levels.collect();
|
||||
stack
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heavier_rules_win_and_kinds_stay_apart() {
|
||||
let cit = rules(&[
|
||||
("plain", "items=stick"),
|
||||
("heavy", "items=stick\nweight=2\nstackSize=2-"),
|
||||
("offhand", "items=stick\nweight=5\nhand=off"),
|
||||
("armor", "type=armor\nitems=stick"),
|
||||
("wings", "type=elytra"),
|
||||
]);
|
||||
assert_eq!(item(&cit, new("stick")), Some("plain"));
|
||||
assert_eq!(
|
||||
item(&cit, ItemStack::new("minecraft:stick", 3)),
|
||||
Some("heavy")
|
||||
);
|
||||
let pick = |kind, id, hand, max| stem(&cit, cit.select(kind, &new(id), hand, max));
|
||||
assert_eq!(pick(Kind::Item, "stick", Hand::Off, 0), Some("offhand"));
|
||||
assert_eq!(item(&cit, new("stone")), None);
|
||||
assert_eq!(item(&cit, new("elytra")), None);
|
||||
assert_eq!(pick(Kind::Elytra, "elytra", Hand::Main, 432), Some("wings"));
|
||||
assert_eq!(pick(Kind::Armor, "stick", Hand::Main, 0), Some("armor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_match_by_their_plain_text() {
|
||||
let cit = rules(&[
|
||||
("glob", "items=stick\nnbt.display.Name=ipattern:*sword"),
|
||||
("exact", "items=stone\ncomponents.custom_name=My Sword"),
|
||||
("json", "items=dirt\nnbt.display.Name=[\"My \",\"Sword\"]"),
|
||||
]);
|
||||
let named = |id: &str, name: Tag| {
|
||||
let mut stack = new(id);
|
||||
stack.data_mut().custom_name = Some(name);
|
||||
stack
|
||||
};
|
||||
let styled = Tag::compound([("text", string("My Sword")), ("color", string("red"))]);
|
||||
assert_eq!(item(&cit, named("stick", styled.clone())), Some("glob"));
|
||||
assert_eq!(item(&cit, named("stick", string("Blade"))), None);
|
||||
assert_eq!(item(&cit, new("stick")), None);
|
||||
assert_eq!(item(&cit, named("stone", styled.clone())), Some("exact"));
|
||||
assert_eq!(item(&cit, named("dirt", styled)), Some("json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lore_lines_and_list_counts() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"any_line",
|
||||
"items=stick\nnbt.display.Lore.*=ipattern:*legendary*",
|
||||
),
|
||||
("two_lines", "items=stone\ncomponents.lore.count=2"),
|
||||
]);
|
||||
let bold = Tag::compound([("text", string("A LEGENDARY item")), ("bold", Tag::Byte(1))]);
|
||||
let lore = Tag::List(vec![string("Common"), bold]);
|
||||
assert_eq!(
|
||||
item(&cit, with("stick", "lore", lore.clone())),
|
||||
Some("any_line")
|
||||
);
|
||||
assert_eq!(item(&cit, with("stone", "lore", lore)), Some("two_lines"));
|
||||
let one = Tag::List(vec![string("A legendary item")]);
|
||||
assert_eq!(item(&cit, with("stone", "lore", one)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_data_existence_and_negation() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"aote",
|
||||
"items=stick\nweight=2\nnbt.ExtraAttributes.id=ASPECT_OF_THE_END",
|
||||
),
|
||||
(
|
||||
"not_hyp",
|
||||
"items=stick\nweight=1\ncomponent.custom_data.ExtraAttributes.id=!HYP",
|
||||
),
|
||||
("unnamed", "items=stick\nnbt.display.Name=exists:false"),
|
||||
]);
|
||||
let data = |id: &str| {
|
||||
let attributes =
|
||||
Tag::compound([("ExtraAttributes", Tag::compound([("id", string(id))]))]);
|
||||
with("stick", "custom_data", attributes)
|
||||
};
|
||||
assert_eq!(item(&cit, data("ASPECT_OF_THE_END")), Some("aote"));
|
||||
assert_eq!(item(&cit, data("MIDAS")), Some("not_hyp"));
|
||||
assert_eq!(item(&cit, data("HYP")), Some("unnamed"));
|
||||
let mut named = data("HYP");
|
||||
named.data_mut().custom_name = Some(string("Hyperion"));
|
||||
assert_eq!(item(&cit, named), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_enchantment_lists_and_model_data() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"sharp",
|
||||
"items=stick\nnbt.Enchantments.*.id=minecraft:sharpness\nnbt.Enchantments.0.lvl=5",
|
||||
),
|
||||
("cmd", "items=stone\nnbt.CustomModelData=3"),
|
||||
(
|
||||
"cmd_snbt",
|
||||
"items=dirt\ncomponents.custom_model_data={floats:[3.0f]}",
|
||||
),
|
||||
]);
|
||||
assert_eq!(
|
||||
item(&cit, enchanted("stick", &[("sharpness", 5)])),
|
||||
Some("sharp")
|
||||
);
|
||||
assert_eq!(item(&cit, enchanted("stick", &[("sharpness", 4)])), None);
|
||||
let model = |id: &str, f: f32| {
|
||||
let floats = Tag::List(vec![Tag::Float(f.to_bits()), Tag::Float(7f32.to_bits())]);
|
||||
with(id, "custom_model_data", Tag::compound([("floats", floats)]))
|
||||
};
|
||||
assert_eq!(item(&cit, model("stone", 3.0)), Some("cmd"));
|
||||
assert_eq!(item(&cit, model("stone", 3.5)), None);
|
||||
assert_eq!(item(&cit, model("dirt", 3.0)), Some("cmd_snbt"));
|
||||
assert_eq!(item(&cit, model("dirt", 2.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fields_left_out_at_zero_read_as_zero() {
|
||||
let cit = rules(&[("zero", "items=firework_rocket\nnbt.Fireworks.Flight=0")]);
|
||||
let mut rocket = new("firework_rocket");
|
||||
rocket.data_mut().fireworks = Some((0, Vec::new()));
|
||||
assert_eq!(item(&cit, rocket.clone()), Some("zero"));
|
||||
rocket.data_mut().fireworks = Some((2, Vec::new()));
|
||||
assert_eq!(item(&cit, rocket), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damage_counts_only_for_damageable_items() {
|
||||
let cit = rules(&[
|
||||
("worn", "items=diamond_sword\ndamage=50-100%"),
|
||||
("new", "items=stick\ndamage=0"),
|
||||
]);
|
||||
let damaged = |id: &str, damage: u32, max: u32| {
|
||||
let mut stack = new(id);
|
||||
stack.damage = damage;
|
||||
stem(&cit, cit.select(Kind::Item, &stack, Hand::Main, max))
|
||||
};
|
||||
// Half of 1561 is 780.5.
|
||||
assert_eq!(damaged("diamond_sword", 780, 1561), None);
|
||||
assert_eq!(damaged("diamond_sword", 781, 1561), Some("worn"));
|
||||
assert_eq!(damaged("stick", 5, 0), Some("new"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enchantment_levels_and_rules_for_any_item() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"sharp3",
|
||||
"items=stick\nenchantments=sharpness\nenchantmentLevels=3-",
|
||||
),
|
||||
("any3", "items=stone\nenchantmentLevels=3-"),
|
||||
("glint", "type=enchantment\nenchantments=sharpness"),
|
||||
(
|
||||
"sword_glint",
|
||||
"type=enchantment\nitems=diamond_sword\nweight=2",
|
||||
),
|
||||
]);
|
||||
let mixed = [("sharpness", 2), ("unbreaking", 3)];
|
||||
assert_eq!(item(&cit, enchanted("stick", &mixed)), None);
|
||||
assert_eq!(
|
||||
item(&cit, enchanted("stick", &[("sharpness", 4)])),
|
||||
Some("sharp3")
|
||||
);
|
||||
assert_eq!(item(&cit, enchanted("stone", &mixed)), Some("any3"));
|
||||
let glints = |stack: ItemStack| -> Vec<&str> {
|
||||
let found = cit.enchantments(&stack, Hand::Main, 0).into_iter();
|
||||
found.map(|i| stem(&cit, Some(i)).unwrap()).collect()
|
||||
};
|
||||
let sharp = [("sharpness", 1)];
|
||||
assert_eq!(
|
||||
glints(enchanted("diamond_sword", &sharp)),
|
||||
["sword_glint", "glint"]
|
||||
);
|
||||
assert_eq!(glints(enchanted("stick", &sharp)), ["glint"]);
|
||||
assert!(glints(new("stick")).is_empty());
|
||||
// Glints are never an item's texture.
|
||||
assert_eq!(item(&cit, enchanted("diamond_sword", &sharp)), None);
|
||||
}
|
||||
|
||||
/// `CitRules::glint` in the main hand, with the stack's own `has_foil` as callers pass it.
|
||||
fn glint(cit: &CitRules, stack: &ItemStack, item_glint: bool) -> Option<Arc<StackGlint>> {
|
||||
cit.glint(stack, Hand::Main, 0, stack.has_foil(item_glint))
|
||||
}
|
||||
|
||||
/// `stack` with its `enchantment_glint_override` set to `on`.
|
||||
fn overridden(mut stack: ItemStack, on: bool) -> ItemStack {
|
||||
let flag = (
|
||||
"minecraft:enchantment_glint_override".to_owned(),
|
||||
Tag::Byte(on.into()),
|
||||
);
|
||||
stack.data_mut().other.push(flag);
|
||||
stack
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stack_glint_reads_the_book_and_the_pack_file() {
|
||||
let mut cit = rules(&[
|
||||
("sharp", "type=enchantment\nenchantments=sharpness"),
|
||||
(
|
||||
"keep",
|
||||
"type=enchantment\nlayer=1\nuseGlint=true\nenchantments=mending",
|
||||
),
|
||||
]);
|
||||
// An enchanted book's glint follows its stored enchantments.
|
||||
let mut book = new("enchanted_book");
|
||||
book.data_mut().stored_enchantments = vec![
|
||||
(Arc::from("minecraft:sharpness"), 3),
|
||||
(Arc::from("minecraft:mending"), 1),
|
||||
];
|
||||
let book = glint(&cit, &book, true).unwrap();
|
||||
let weights: Vec<_> = book.layers.iter().map(|l| l.weight).collect();
|
||||
assert_eq!(weights, [0.75, 0.25]);
|
||||
assert!(book.vanilla);
|
||||
let plain = glint(&cit, &enchanted("stick", &[("sharpness", 1)]), false).unwrap();
|
||||
assert_eq!(plain.layers.len(), 1);
|
||||
assert!(!plain.vanilla);
|
||||
// No enchantment rules: nothing changes, unless the pack turns vanilla's glint off.
|
||||
let mut bare = rules(&[("stick", "items=stick")]);
|
||||
assert!(glint(&bare, &overridden(new("stick"), true), false).is_none());
|
||||
bare.global.use_glint = false;
|
||||
assert!(
|
||||
!glint(&bare, &overridden(new("stick"), true), false)
|
||||
.unwrap()
|
||||
.vanilla
|
||||
);
|
||||
cit.global.use_glint = false;
|
||||
assert!(
|
||||
glint(&cit, &overridden(new("stick"), true), false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_stack_with_a_glint_gets_layers() {
|
||||
let cit = rules(&[("any", "type=enchantment")]);
|
||||
assert!(glint(&cit, &new("stick"), false).is_none());
|
||||
// The override turns an enchanted stack's glint off, and an unenchanted one's on.
|
||||
let off = overridden(enchanted("stick", &[("sharpness", 1)]), false);
|
||||
assert!(glint(&cit, &off, false).is_none());
|
||||
let on = glint(&cit, &overridden(new("stick"), true), false).unwrap();
|
||||
let weights: Vec<_> = on.layers.iter().map(|l| l.weight).collect();
|
||||
assert_eq!(
|
||||
weights,
|
||||
[1.0],
|
||||
"nothing to share: the rule at full strength"
|
||||
);
|
||||
// The same stack, asked again without its glint, is not answered from the cache.
|
||||
assert!(
|
||||
cit.glint(&overridden(new("stick"), true), Hand::Main, 0, false)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rule_left_out_of_the_glints_matches_nothing() {
|
||||
let mut cit = rules(&[
|
||||
("broken", "type=enchantment\nitems=diamond_sword\nweight=1"),
|
||||
("shown", "type=enchantment"),
|
||||
]);
|
||||
let sword = enchanted("diamond_sword", &[("sharpness", 1)]);
|
||||
let layers = |cit: &CitRules| -> Vec<String> {
|
||||
let glint = glint(cit, &sword, false).unwrap();
|
||||
let stems = glint
|
||||
.layers
|
||||
.iter()
|
||||
.map(|l| stem(cit, Some(l.rule)).unwrap());
|
||||
stems.map(str::to_owned).collect()
|
||||
};
|
||||
// The heavier rule wins the layer, until its texture is missing.
|
||||
assert_eq!(layers(&cit), ["broken"]);
|
||||
let broken = (0..cit.rules.len())
|
||||
.find(|&i| stem(&cit, Some(i)) == Some("broken"))
|
||||
.unwrap();
|
||||
cit.keep_glints(|i| i != broken);
|
||||
assert_eq!(layers(&cit), ["shown"]);
|
||||
assert_eq!(cit.enchantments(&sword, Hand::Main, 0).len(), 1);
|
||||
// None left: vanilla's glint stays.
|
||||
cit.keep_glints(|_| false);
|
||||
assert!(glint(&cit, &sword, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_off_hand_layer_draws_only_in_the_off_hand() {
|
||||
let cit = rules(&[
|
||||
("left", "type=enchantment\nlayer=1\nhand=off"),
|
||||
("both", "type=enchantment"),
|
||||
]);
|
||||
let sword = enchanted("diamond_sword", &[("sharpness", 1)]);
|
||||
let layers = |hand| -> Vec<&str> {
|
||||
let glint = cit.glint(&sword, hand, 0, true).unwrap();
|
||||
glint
|
||||
.layers
|
||||
.iter()
|
||||
.map(|l| stem(&cit, Some(l.rule)).unwrap())
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(layers(Hand::Off), ["both", "left"]);
|
||||
// Asked in this order, a cache keyed without the hand would answer as for the off hand.
|
||||
assert_eq!(layers(Hand::Main), ["both"]);
|
||||
self.matcher.as_ref()?.glint(stack, hand, max_damage, foil)
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,8 @@
|
||||
//! chestplate, boots) and `entity/equipment/humanoid_leggings/diamond` (layer 2), so the layers
|
||||
//! are mapped back to the old names here. An elytra rule's `texture` replaces the wings'.
|
||||
|
||||
use crate::cit::{Kind, Rule};
|
||||
use crate::cit_assets::{rule_sprites, sprite_id};
|
||||
use horizon_optifine::cit::{Kind, Rule};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// The pre-1.21.2 armour texture name of an equipment asset's layer: `asset` (`minecraft:gold`
|
||||
@@ -114,10 +114,9 @@ pub fn armor_sprites(rules: &[Rule]) -> BTreeSet<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_optifine::Properties;
|
||||
|
||||
fn rule(path: &str, text: &str) -> Rule {
|
||||
Rule::parse(path, &Properties::parse_equals_first(text), |_| true).unwrap()
|
||||
crate::pack_formats::cit::parse_rule(path, text).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
//! `horizon_cit:minecraft/optifine/cit/swords/flame`). A file already under `textures/` (or
|
||||
//! `models/`) keeps its vanilla id, so it is not packed twice.
|
||||
|
||||
use crate::cit::{Kind, Rule};
|
||||
use horizon_assets::model::{CuboidModel, TextureSlot};
|
||||
use horizon_optifine::cit::{Kind, Rule};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
@@ -55,7 +55,7 @@ pub fn model_id(resource: &str) -> Option<String> {
|
||||
/// and for one that would leave the resource root (a pack model may write any id).
|
||||
pub fn resource(id: &str, ext: &str) -> Option<String> {
|
||||
let rest = id.strip_prefix(NAMESPACE)?.strip_prefix(':')?;
|
||||
horizon_optifine::paths::valid(rest).then(|| format!("assets/{rest}.{ext}"))
|
||||
stays_in_root(rest).then(|| format!("assets/{rest}.{ext}"))
|
||||
}
|
||||
|
||||
/// Sprite ids the rules whose kind passes `keep` draw: their `texture` and `texture.<name>`
|
||||
@@ -102,7 +102,14 @@ fn relative(file: &str, value: &str, ext: &str) -> Option<String> {
|
||||
.and_then(|r| r.strip_suffix('.'))
|
||||
.unwrap_or(rest);
|
||||
let path = format!("{dir}/{rest}.{ext}");
|
||||
horizon_optifine::paths::valid(&path).then_some(path)
|
||||
stays_in_root(&path).then_some(path)
|
||||
}
|
||||
|
||||
/// Whether a resource path stays in the root: no `.`, `..` or empty segment, and no `\` (a
|
||||
/// separator on Windows, where `..\..` would climb).
|
||||
fn stays_in_root(path: &str) -> bool {
|
||||
path.split('/')
|
||||
.all(|segment| !matches!(segment, "" | "." | "..") && !segment.contains('\\'))
|
||||
}
|
||||
|
||||
/// The id a relative value of the model at `file` names, as `CuboidModel::from_json` read it
|
||||
@@ -294,11 +301,11 @@ mod tests {
|
||||
"base.json",
|
||||
r#"{"parent":"item/handheld","textures":{"layer2":"./glow.png"}}"#,
|
||||
);
|
||||
let props =
|
||||
horizon_optifine::Properties::parse_equals_first("items=stick\nmodel=./swords/flame");
|
||||
let rule = Rule::parse("assets/minecraft/optifine/cit/a.properties", &props, |p| {
|
||||
root.join(p).is_file()
|
||||
})
|
||||
let rule = crate::pack_formats::cit::parse_rule_in(
|
||||
&root,
|
||||
"assets/minecraft/optifine/cit/a.properties",
|
||||
"items=stick\nmodel=./swords/flame",
|
||||
)
|
||||
.unwrap();
|
||||
let models = load_models(&root, &[rule]);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
|
||||
@@ -1,26 +1,6 @@
|
||||
//! Custom Item Textures' enchantment glints (`type=enchantment`): which of a stack's matching
|
||||
//! rules draw, in which order and how strongly, and whether vanilla's glint still draws under
|
||||
//! them. The pack's `cit.properties` (`Global`) says how layers share an item.
|
||||
//!
|
||||
//! Written from OptiFine's `cit.properties` doc and CIT Resewn's (MIT) `TypeEnchantment` of
|
||||
//! before its 1.21.11 port, which implemented `method`, `cap` and `fade`: the sums and the
|
||||
//! cycle's fading follow it, except where noted.
|
||||
|
||||
pub use horizon_optifine::cit::{Global, Method};
|
||||
|
||||
use crate::cit::read_properties;
|
||||
use horizon_optifine::cit::Rule;
|
||||
use std::path::Path;
|
||||
|
||||
/// The pack's global `cit.properties` under the overlay resource root: the first of
|
||||
/// `Global::PATHS` that exists, else the defaults.
|
||||
pub fn load_global(root: &Path) -> Global {
|
||||
Global::PATHS
|
||||
.iter()
|
||||
.find_map(|path| read_properties(&root.join(path)))
|
||||
.map(|props| Global::parse(&props))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
//! Custom item textures' enchantment glints: the layers a stack draws over (or instead of)
|
||||
//! vanilla's glint, and how strongly each draws over time. Which layers a stack gets is the pack
|
||||
//! format's ([`crate::cit::Matcher::glint`]); a layer's look is its rule's [`crate::cit::Glint`].
|
||||
|
||||
/// One glint layer a stack draws.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -33,6 +13,17 @@ pub struct GlintLayer {
|
||||
duration: f32,
|
||||
}
|
||||
|
||||
impl GlintLayer {
|
||||
/// Rule `rule`'s layer at `weight`, on for `duration` seconds when the layers take turns.
|
||||
pub fn new(rule: usize, weight: f32, duration: f32) -> Self {
|
||||
Self {
|
||||
rule,
|
||||
weight,
|
||||
duration,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a stack's glint is made of, worked out when the stack changes.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct StackGlint {
|
||||
@@ -45,84 +36,14 @@ pub struct StackGlint {
|
||||
}
|
||||
|
||||
impl StackGlint {
|
||||
/// The layers among `matched` (the stack's matching enchantment rules, in priority order,
|
||||
/// as `CitRules::enchantments` gives them) that draw, for a stack with `enchantments`
|
||||
/// (`ItemStack::crafting_enchantments`).
|
||||
pub fn new(
|
||||
rules: &[Rule],
|
||||
matched: &[usize],
|
||||
enchantments: &[(impl AsRef<str>, u32)],
|
||||
global: &Global,
|
||||
) -> Self {
|
||||
// The highest priority rule of each layer.
|
||||
let mut layers: Vec<usize> = Vec::new();
|
||||
for &i in matched {
|
||||
let layer = rules[i].glint.layer;
|
||||
if !layers.iter().any(|&j| rules[j].glint.layer == layer) {
|
||||
layers.push(i);
|
||||
}
|
||||
}
|
||||
layers.sort_by_key(|&i| rules[i].glint.layer);
|
||||
let cycle = global.method == Method::Cycle;
|
||||
// OptiFine's doc: `cap` is for `average` and `layered`, and keeps the topmost layers.
|
||||
// `none`, which OptiFine lacks, is capped too, as CIT Resewn caps every method (it keeps
|
||||
// the bottom layers, though, and caps cycles as well).
|
||||
if let Some(cap) = global.cap.filter(|_| !cycle) {
|
||||
let cap = usize::try_from(cap).unwrap_or(usize::MAX);
|
||||
layers = layers.split_off(layers.len().saturating_sub(cap));
|
||||
}
|
||||
// CIT Resewn: any layer left asking for vanilla's glint keeps it, and the global
|
||||
// `useGlint=false` removes it everywhere, as OptiFine's doc says too. `cap=0` leaves no
|
||||
// custom layer, and vanilla's glint only if the pack's `useGlint` allows it.
|
||||
let vanilla = global.use_glint
|
||||
&& (layers.is_empty() || layers.iter().any(|&i| rules[i].glint.use_glint));
|
||||
// CIT Resewn: shares are against every enchantment of the stack, not only those the
|
||||
// layers name.
|
||||
let levels = enchantments.iter().map(|(_, level)| *level as f32);
|
||||
let total = match global.method {
|
||||
Method::Average => levels.sum::<f32>(),
|
||||
Method::Layered => levels.fold(0.0, f32::max),
|
||||
Method::None | Method::Cycle => 0.0,
|
||||
};
|
||||
let shared = matches!(global.method, Method::Average | Method::Layered);
|
||||
let layers = layers
|
||||
.into_iter()
|
||||
.map(|i| {
|
||||
let rule = &rules[i];
|
||||
let named = &rule.enchantments;
|
||||
let weight = if !shared || named.is_empty() {
|
||||
1.0
|
||||
} else {
|
||||
// The highest level among the enchantments the rule names whose level also
|
||||
// passes its `enchantmentLevels` (CIT Resewn takes the first named one the
|
||||
// stack has, whatever its level).
|
||||
let in_range = |level: u32| {
|
||||
let level = i32::try_from(level).unwrap_or(i32::MAX);
|
||||
rule.enchantment_levels
|
||||
.as_ref()
|
||||
.is_none_or(|r| r.contains(level))
|
||||
};
|
||||
let level = enchantments
|
||||
.iter()
|
||||
.filter(|(id, level)| {
|
||||
let id: &str = id.as_ref();
|
||||
named.iter().any(|n| n == id) && in_range(*level)
|
||||
})
|
||||
.map(|(_, level)| *level as f32)
|
||||
.fold(0.0, f32::max);
|
||||
if total > 0.0 { level / total } else { 0.0 }
|
||||
};
|
||||
GlintLayer {
|
||||
rule: i,
|
||||
weight,
|
||||
duration: rule.glint.duration,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
/// `layers`, bottom first; `vanilla` whether vanilla's glint draws under them; `cycle_fade`
|
||||
/// the seconds each layer fades into the next when they take turns, else `None` (each
|
||||
/// draws at its weight).
|
||||
pub fn from_parts(layers: Vec<GlintLayer>, vanilla: bool, cycle_fade: Option<f32>) -> Self {
|
||||
Self {
|
||||
layers,
|
||||
vanilla,
|
||||
cycle_fade: cycle.then_some(global.fade),
|
||||
cycle_fade,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,208 +100,3 @@ fn cycle_intensity(layers: &[GlintLayer], fade: f32, index: usize, seconds: f64)
|
||||
// Not reached, as the last layer returns above; no layer rather than a panic.
|
||||
0.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_optifine::Properties;
|
||||
|
||||
const DIR: &str = "assets/minecraft/optifine/cit";
|
||||
|
||||
/// An enchantment rule named `name` with `text` after `type` and `texture`.
|
||||
fn rule(name: &str, text: &str) -> Rule {
|
||||
let text = format!("type=enchantment\ntexture=glint\n{text}");
|
||||
let path = format!("{DIR}/{name}.properties");
|
||||
Rule::parse(&path, &Properties::parse_equals_first(&text), |_| true).unwrap()
|
||||
}
|
||||
|
||||
fn global(text: &str) -> Global {
|
||||
Global::parse(&Properties::parse_equals_first(text))
|
||||
}
|
||||
|
||||
fn rules_of(layers: &StackGlint) -> Vec<usize> {
|
||||
layers.layers.iter().map(|l| l.rule).collect()
|
||||
}
|
||||
|
||||
const SWORD: [(&str, u32); 2] = [("minecraft:sharpness", 3), ("minecraft:unbreaking", 1)];
|
||||
|
||||
#[test]
|
||||
fn one_rule_per_layer_bottom_first() {
|
||||
let rules = [
|
||||
rule("a", "layer=2"),
|
||||
rule("b", "layer=0"),
|
||||
rule("c", "layer=2"),
|
||||
rule("d", "layer=-1"),
|
||||
];
|
||||
// Priority order a, b, c, d: c loses layer 2 to a.
|
||||
let g = StackGlint::new(&rules, &[0, 1, 2, 3], &SWORD, &Global::default());
|
||||
assert_eq!(rules_of(&g), [3, 1, 0]);
|
||||
let g = StackGlint::new(&rules, &[2, 0], &SWORD, &Global::default());
|
||||
assert_eq!(rules_of(&g), [2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn average_and_layered_shares() {
|
||||
let rules = [
|
||||
rule("sharp", "enchantments=sharpness smite"),
|
||||
rule("unb", "layer=1\nenchantments=unbreaking"),
|
||||
rule("any", "layer=2"),
|
||||
rule("fire", "layer=3\nenchantments=fire_aspect"),
|
||||
];
|
||||
let weights = |method: &str| {
|
||||
let g = StackGlint::new(&rules, &[0, 1, 2, 3], &SWORD, &global(method));
|
||||
g.layers.iter().map(|l| l.weight).collect::<Vec<_>>()
|
||||
};
|
||||
// Sharpness III and Unbreaking I: 3 / 4 and 1 / 4; against the highest, 3 / 3 and 1 / 3.
|
||||
// A rule naming no enchantment is at full strength, one the stack lacks at none.
|
||||
assert_eq!(weights("method=average"), [0.75, 0.25, 1.0, 0.0]);
|
||||
assert_eq!(weights("method=layered"), [1.0, 1.0 / 3.0, 1.0, 0.0]);
|
||||
assert_eq!(weights("method=cycle"), [1.0; 4]);
|
||||
assert_eq!(weights("method=none"), [1.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_level_is_one_the_rules_levels_allow() {
|
||||
let rules = [rule(
|
||||
"holy",
|
||||
"enchantments=sharpness smite\nenchantmentLevels=1-2",
|
||||
)];
|
||||
// Sharpness V is out of range, so Smite I sets the share: 1 / 5 of the highest level.
|
||||
let stack = [("minecraft:sharpness", 5), ("minecraft:smite", 1)];
|
||||
let g = StackGlint::new(&rules, &[0], &stack, &global("method=layered"));
|
||||
assert_eq!(g.layers[0].weight, 1.0 / 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cap_keeps_the_topmost_layers() {
|
||||
let rules = [
|
||||
rule("a", "layer=0"),
|
||||
rule("b", "layer=1"),
|
||||
rule("c", "layer=2"),
|
||||
];
|
||||
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=2"));
|
||||
assert_eq!(rules_of(&g), [1, 2]);
|
||||
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=0"));
|
||||
assert!(g.is_empty());
|
||||
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=1\nmethod=none"));
|
||||
assert_eq!(rules_of(&g), [2]);
|
||||
// Cycles show every layer in turn.
|
||||
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=1\nmethod=cycle"));
|
||||
assert_eq!(rules_of(&g), [0, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanilla_glint() {
|
||||
let plain = rule("plain", "");
|
||||
let keep = rule("keep", "layer=1\nuseGlint=true");
|
||||
let rules = [plain, keep];
|
||||
let vanilla = |matched: &[usize], text: &str| {
|
||||
StackGlint::new(&rules, matched, &SWORD, &global(text)).vanilla
|
||||
};
|
||||
assert!(vanilla(&[], ""), "no rule: vanilla's glint");
|
||||
assert!(!vanilla(&[0], ""), "a rule replaces it");
|
||||
assert!(vanilla(&[0, 1], ""), "unless one keeps it");
|
||||
assert!(!vanilla(&[], "useGlint=false"));
|
||||
assert!(!vanilla(&[1], "useGlint=false"), "the pack's word is final");
|
||||
// Only the layers `cap` keeps count.
|
||||
let rules = [rule("keep", "useGlint=true"), rule("top", "layer=1")];
|
||||
let g = StackGlint::new(&rules, &[0, 1], &SWORD, &global("cap=1"));
|
||||
assert!(!g.vanilla);
|
||||
let g = StackGlint::new(&rules, &[0, 1], &SWORD, &global("cap=0"));
|
||||
assert!(g.is_empty() && g.vanilla);
|
||||
let g = StackGlint::new(&rules, &[0, 1], &SWORD, &global("cap=0\nuseGlint=false"));
|
||||
assert!(!g.vanilla);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycles_take_turns_and_fade() {
|
||||
let rules = [
|
||||
rule("a", "duration=2"),
|
||||
rule("b", "layer=1\nduration=1"),
|
||||
rule("c", "layer=2\nduration=1"),
|
||||
];
|
||||
let g = StackGlint::new(
|
||||
&rules,
|
||||
&[0, 1, 2],
|
||||
&SWORD,
|
||||
&global("method=cycle\nfade=0.5"),
|
||||
);
|
||||
// a 0-2, a→b 2-2.5, b 2.5-3.5, b→c 3.5-4, c 4-5, c→a 5-5.5, then again.
|
||||
let at = |t: f64| [0, 1, 2].map(|i| g.intensity(i, t));
|
||||
assert_eq!(at(1.0), [1.0, 0.0, 0.0]);
|
||||
assert_eq!(at(2.125), [0.75, 0.25, 0.0]);
|
||||
assert_eq!(at(3.0), [0.0, 1.0, 0.0]);
|
||||
assert_eq!(at(3.75), [0.0, 0.5, 0.5]);
|
||||
assert_eq!(at(4.5), [0.0, 0.0, 1.0]);
|
||||
assert_eq!(at(5.25), [0.5, 0.0, 0.5]);
|
||||
assert_eq!(at(5.5 + 3.0), [0.0, 1.0, 0.0]);
|
||||
// One layer always shows.
|
||||
let one = StackGlint::new(&rules, &[1], &SWORD, &global("method=cycle"));
|
||||
assert_eq!(one.intensity(0, 0.7), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cycle_always_shows_a_whole_layer() {
|
||||
let rules = [
|
||||
rule("a", "duration=0.1"),
|
||||
rule("b", "layer=1\nduration=0.7"),
|
||||
rule("c", "layer=2\nduration=0.2"),
|
||||
];
|
||||
let g = StackGlint::new(
|
||||
&rules,
|
||||
&[0, 1, 2],
|
||||
&SWORD,
|
||||
&global("method=cycle\nfade=0.3"),
|
||||
);
|
||||
// Every millisecond of the first two seconds, and just under a period, the last fade's
|
||||
// end. (Rounding has not been seen to leave time past it; the last fade's clamp is
|
||||
// defensive.)
|
||||
let period: f64 = [0.1f32, 0.7, 0.2]
|
||||
.map(|d| f64::from(d) + f64::from(0.3f32))
|
||||
.iter()
|
||||
.sum();
|
||||
let last = f64::from_bits(period.to_bits() - 1);
|
||||
for t in (0..2000_i32)
|
||||
.map(|i| f64::from(i) * 0.001)
|
||||
.chain([last, period + last])
|
||||
{
|
||||
let sum: f32 = (0..3).map(|i| g.intensity(i, t)).sum();
|
||||
assert!((sum - 1.0).abs() < 1e-5, "{sum} at {t}");
|
||||
}
|
||||
// No time at all: the bottom layer alone, never NaN.
|
||||
let still = [rule("a", "duration=0"), rule("b", "layer=1\nduration=0")];
|
||||
let g = StackGlint::new(&still, &[0, 1], &SWORD, &global("method=cycle\nfade=0"));
|
||||
for t in [0.0, 0.5, 7.25] {
|
||||
assert_eq!([0, 1].map(|i| g.intensity(i, t)), [1.0, 0.0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_global_file_wins() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-cit-glint-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
assert_eq!(load_global(&root), Global::default());
|
||||
let write = |path: &str, text: &[u8]| {
|
||||
let file = root.join(path);
|
||||
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
std::fs::write(file, text).unwrap();
|
||||
};
|
||||
// Saved as Latin-1 (`é` is not UTF-8 alone), in the OptiFine doc's `cit/` folder.
|
||||
write(
|
||||
"assets/minecraft/optifine/cit/cit.properties",
|
||||
b"# caf\xE9\nmethod=cycle",
|
||||
);
|
||||
assert_eq!(load_global(&root).method, Method::Cycle);
|
||||
write("assets/minecraft/mcpatcher/cit.properties", b"method=none");
|
||||
assert_eq!(load_global(&root).method, Method::Cycle);
|
||||
write(
|
||||
"assets/minecraft/optifine/cit.properties",
|
||||
b"method=layered",
|
||||
);
|
||||
assert_eq!(load_global(&root).method, Method::Layered);
|
||||
write("assets/minecraft/citresewn/cit.properties", b"cap=1");
|
||||
let g = load_global(&root);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
assert_eq!((g.method, g.cap), (Method::Average, Some(1)));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
}
|
||||
|
||||
@@ -68,8 +64,8 @@ pub fn translate_core(
|
||||
glue: &Glue,
|
||||
) -> 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}"))?;
|
||||
@@ -341,8 +337,6 @@ 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();
|
||||
for source_line in text.lines() {
|
||||
for line in declaration_lines(source_line) {
|
||||
@@ -468,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",
|
||||
@@ -646,43 +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\
|
||||
in vec3 Position;\nin vec4 Color;\nin vec2 UV0;\nout vec4 vertexColor;\nout 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]
|
||||
|
||||
@@ -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,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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
//! Entity textures a pack format vanilla doesn't read changes (OptiFine's random entities, ETF's
|
||||
//! blinking and emissive textures): the variants of each texture and how an entity picks one,
|
||||
//! the blink frames a texture shows now and then, and the overlays that glow over a texture.
|
||||
//! The platform's pack formats fill them once per pack load ([`crate::pack_hooks`]); drawing
|
||||
//! only looks them up in these tables.
|
||||
//!
|
||||
//! Textures have the names the entity atlas gives them (`entity/zombie/zombie`,
|
||||
//! `<ns>:<path>`); files outside `textures/` are listed with the name they take
|
||||
//! ([`RandomTextures::files`], [`Blinks::files`], [`Emissive::files`]) for the atlas to pack.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::Path;
|
||||
|
||||
/// A pack's entity texture changes: variants, blinks and emissive overlays.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct EntityTextures {
|
||||
pub random: RandomTextures,
|
||||
pub blinks: Blinks,
|
||||
pub emissive: Emissive,
|
||||
}
|
||||
|
||||
impl EntityTextures {
|
||||
/// The changes of the overlay at `root`, from the installed pack formats.
|
||||
pub fn load(root: &Path) -> Self {
|
||||
crate::pack_hooks::formats()
|
||||
.entity_textures(root)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// What a variant rule can ask about an entity. `None` is a fact the entity doesn't have (a
|
||||
/// creeper's profession): a rule asking for it doesn't match.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Facts<'a> {
|
||||
/// Where the entity was first seen: its biome (`minecraft:plains`) and block height.
|
||||
pub biome: Option<&'a str>,
|
||||
pub y: Option<i32>,
|
||||
/// The block it stood on and the block it stood in when first seen; empty when unknown.
|
||||
pub blocks: &'a [Block<'a>],
|
||||
/// Its custom name, with `§` formatting codes where the name has them.
|
||||
pub name: Option<&'a str>,
|
||||
/// Villager profession (`minecraft:farmer`) and level.
|
||||
pub profession: Option<(&'a str, i32)>,
|
||||
/// Collar, wool, carpet or shell colour, as a dye name (`light_blue`).
|
||||
pub color: Option<&'a str>,
|
||||
pub baby: Option<bool>,
|
||||
/// Health and max health.
|
||||
pub health: Option<(f32, f32)>,
|
||||
/// When first seen: moon phase (0-7), time of day (0-23999) and weather.
|
||||
pub moon_phase: Option<i32>,
|
||||
pub day_time: Option<i32>,
|
||||
pub weather: Option<Weather>,
|
||||
/// Slime, magma cube or phantom size: 0 small, 1 medium, 3 big.
|
||||
pub size: Option<i32>,
|
||||
}
|
||||
|
||||
/// A block and the values of its state properties.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Block<'a> {
|
||||
pub id: &'a str,
|
||||
pub states: &'a [(&'a str, &'a str)],
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Weather {
|
||||
Clear,
|
||||
Rain,
|
||||
Thunder,
|
||||
}
|
||||
|
||||
/// How a pack format's rule files choose a variant.
|
||||
pub trait VariantRules: Send + Sync {
|
||||
/// The variant rule file `rules` ([`Entry::rules`]) gives an entity with these facts and
|
||||
/// seed, `None` when no rule matches.
|
||||
fn matching(&self, rules: usize, facts: &Facts, seed: i32) -> Option<u32>;
|
||||
}
|
||||
|
||||
/// Every texture a pack varies, by its atlas name.
|
||||
#[derive(Default)]
|
||||
pub struct RandomTextures {
|
||||
entries: HashMap<String, Entry>,
|
||||
/// Every variant's file (from the resource root) and atlas name.
|
||||
files: Vec<(String, String)>,
|
||||
rules: Option<Box<dyn VariantRules>>,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RandomTextures {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("RandomTextures")
|
||||
.field("entries", &self.entries)
|
||||
.field("generation", &self.generation)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// One texture's variants.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Entry {
|
||||
/// The rule file that chooses this texture's variant ([`RandomTextures::matching`]).
|
||||
pub rules: Option<usize>,
|
||||
/// The atlas name of each variant, by its number.
|
||||
pub variants: BTreeMap<u32, String>,
|
||||
}
|
||||
|
||||
impl RandomTextures {
|
||||
/// `entries` by atlas name, their variants' `files` (file, atlas name), the rule files
|
||||
/// `rules` that choose, and `generation`: different for every load (never 0), so choices
|
||||
/// made from another load's rules are known to be stale.
|
||||
pub fn new(
|
||||
entries: HashMap<String, Entry>,
|
||||
files: Vec<(String, String)>,
|
||||
rules: Box<dyn VariantRules>,
|
||||
generation: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
entries,
|
||||
files,
|
||||
rules: Some(rules),
|
||||
generation,
|
||||
}
|
||||
}
|
||||
|
||||
/// This load's number; 0 for the empty set.
|
||||
pub fn generation(&self) -> u64 {
|
||||
self.generation
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// The variants of the texture with this atlas name.
|
||||
pub fn get(&self, texture: &str) -> Option<&Entry> {
|
||||
self.entries.get(texture)
|
||||
}
|
||||
|
||||
/// [`VariantRules::matching`].
|
||||
pub fn matching(&self, rules: usize, facts: &Facts, seed: i32) -> Option<u32> {
|
||||
self.rules.as_ref()?.matching(rules, facts, seed)
|
||||
}
|
||||
|
||||
/// Every variant's file (its path from the resource root) and atlas name, for the entity
|
||||
/// atlas to pack.
|
||||
pub fn files(&self) -> impl Iterator<Item = (&str, &str)> {
|
||||
self.files.iter().map(|(f, n)| (f.as_str(), n.as_str()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
/// The atlas name of variant `index`, or `None` for the texture itself.
|
||||
pub fn texture(&self, index: u32) -> Option<&str> {
|
||||
self.variants.get(&index).map(String::as_str)
|
||||
}
|
||||
|
||||
/// The even choice of an entity with this seed when no rules apply: the texture and its
|
||||
/// variants 2, 3... up to the first missing one.
|
||||
pub fn even(&self, seed: i32) -> u32 {
|
||||
let mut count = 1;
|
||||
while self.variants.contains_key(&(count + 1)) {
|
||||
count += 1;
|
||||
}
|
||||
seed.unsigned_abs() % count + 1
|
||||
}
|
||||
}
|
||||
|
||||
/// An entity's seed from its UUID (most then least significant 64 bits): Java's
|
||||
/// `UUID.hashCode`, so it is fixed for the entity's whole life, on every client.
|
||||
pub fn seed(most: i64, least: i64) -> i32 {
|
||||
let x = most ^ least;
|
||||
((x >> 32) ^ x) as i32
|
||||
}
|
||||
|
||||
/// The pack's blinking textures, by the atlas name of the texture they replace.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Blinks {
|
||||
blinks: HashMap<String, Blink>,
|
||||
/// The blink frames outside `textures/`: file and atlas name.
|
||||
files: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// A texture's blink.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Blink {
|
||||
/// The atlas names of the shut and half-shut frames.
|
||||
pub shut: String,
|
||||
pub half: Option<String>,
|
||||
/// Ticks a blink lasts (at least 1) and ticks from one blink to the next (more).
|
||||
pub length: u32,
|
||||
pub frequency: u32,
|
||||
}
|
||||
|
||||
impl Blinks {
|
||||
/// `blinks` by the atlas name of the texture they replace, and the frames outside
|
||||
/// `textures/` (file, atlas name).
|
||||
pub fn new(blinks: HashMap<String, Blink>, files: Vec<(String, String)>) -> Self {
|
||||
Self { blinks, files }
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.blinks.is_empty()
|
||||
}
|
||||
|
||||
pub fn get(&self, texture: &str) -> Option<&Blink> {
|
||||
self.blinks.get(texture)
|
||||
}
|
||||
|
||||
/// The frames outside `textures/`: file and atlas name, for the entity atlas to pack (it
|
||||
/// walks `textures/` itself).
|
||||
pub fn files(&self) -> impl Iterator<Item = (&str, &str)> {
|
||||
self.files.iter().map(|(f, n)| (f.as_str(), n.as_str()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Blink {
|
||||
/// The frame an entity shows `tick` ticks into its life: `None` with its eyes open. `phase`
|
||||
/// spreads entities' blinks apart. With a half-shut frame, the first and last ticks of a
|
||||
/// blink of 3 or more show it.
|
||||
pub fn frame(&self, tick: u32, phase: u32) -> Option<&str> {
|
||||
let t = (u64::from(tick) + u64::from(phase)) % u64::from(self.frequency);
|
||||
let t = t as u32;
|
||||
if t >= self.length {
|
||||
return None;
|
||||
}
|
||||
match &self.half {
|
||||
Some(half) if self.length >= 3 && (t == 0 || t == self.length - 1) => Some(half),
|
||||
_ => Some(&self.shut),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The pack's emissive overlays, by the atlas name of the texture they glow over.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Emissive {
|
||||
/// The name ending of the pack's emissive textures (`_e`).
|
||||
pub suffix: String,
|
||||
overlays: HashMap<String, String>,
|
||||
/// Overlays outside `textures/`: file and atlas name.
|
||||
files: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Emissive {
|
||||
/// `overlays` (texture → overlay, atlas names) with name ending `suffix`, and those outside
|
||||
/// `textures/` (file, atlas name).
|
||||
pub fn new(
|
||||
suffix: String,
|
||||
overlays: HashMap<String, String>,
|
||||
files: Vec<(String, String)>,
|
||||
) -> Self {
|
||||
Self {
|
||||
suffix,
|
||||
overlays,
|
||||
files,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.overlays.is_empty()
|
||||
}
|
||||
|
||||
/// The atlas name of the overlay that glows over `texture`.
|
||||
pub fn overlay(&self, texture: &str) -> Option<&str> {
|
||||
self.overlays.get(texture).map(String::as_str)
|
||||
}
|
||||
|
||||
/// The overlays outside `textures/`: file (from the resource root) and atlas name, for the
|
||||
/// entity atlas to pack (it walks `textures/` itself).
|
||||
pub fn files(&self) -> impl Iterator<Item = (&str, &str)> {
|
||||
self.files.iter().map(|(f, n)| (f.as_str(), n.as_str()))
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,7 @@
|
||||
//! dimension type's attributes, the biome layer, the dimension's timelines and, where it can
|
||||
//! have weather, the rain and thunder layers, sampled into the sky state the renderer draws.
|
||||
use crate::Result;
|
||||
use horizon_world::environment_attributes::{
|
||||
Attribute, AttributeMap, Entry, Kind, Value, attribute,
|
||||
};
|
||||
use horizon_world::environment_attributes::{AttributeMap, Value, attribute};
|
||||
use horizon_world::environment_system::{BiomeBlend, Builder, Conditions, System};
|
||||
use horizon_world::environment_timeline::Timeline;
|
||||
use serde_json::Value as Json;
|
||||
@@ -12,8 +10,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.
|
||||
@@ -67,82 +63,101 @@ pub struct Sample<'a> {
|
||||
pub post_biome: Option<&'a AttributeMap>,
|
||||
}
|
||||
|
||||
/// Resource packs' Polytone biome and dimension modifiers (#11486): layers they add to, change
|
||||
/// in or remove from the attribute stack.
|
||||
/// What resource packs change in the attribute stacks (Polytone's biome and dimension
|
||||
/// modifiers, #11486), read by the platform's pack formats once per pack load. Without
|
||||
/// installed formats, or packs with no modifiers, it changes nothing.
|
||||
#[derive(Default)]
|
||||
pub struct PackModifiers {
|
||||
biomes: Vec<horizon_polytone::BiomeModifier>,
|
||||
dimensions: Vec<horizon_polytone::DimensionModifier>,
|
||||
/// The packs' config values, for `config(id)` in expressions.
|
||||
configs: horizon_polytone::pack::Configs,
|
||||
/// The pack overlay root they were read from, where their colormaps are.
|
||||
root: Option<std::path::PathBuf>,
|
||||
colormaps: ColormapCache,
|
||||
}
|
||||
pub struct PackModifiers(Option<Box<dyn EnvironmentModifiers>>);
|
||||
|
||||
impl PackModifiers {
|
||||
/// Every `assets/<namespace>/polytone/{biome,dimension}_modifiers/**.json` under a pack
|
||||
/// overlay root. A file that fails to parse is skipped with a warning.
|
||||
/// The modifiers of the packs merged at the overlay root `pack_root`.
|
||||
pub fn read(pack_root: &Path) -> Self {
|
||||
let mut out = Self {
|
||||
root: Some(pack_root.to_path_buf()),
|
||||
..Self::default()
|
||||
};
|
||||
let Ok(namespaces) = std::fs::read_dir(pack_root.join("assets")) else {
|
||||
return out;
|
||||
};
|
||||
for namespace in namespaces.flatten() {
|
||||
for kind in ["biome_modifiers", "dimension_modifiers"] {
|
||||
let mut stack = vec![namespace.path().join("polytone").join(kind)];
|
||||
while let Some(dir) = stack.pop() {
|
||||
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
continue;
|
||||
}
|
||||
let Ok(relative) = path.strip_prefix(pack_root) else {
|
||||
continue;
|
||||
};
|
||||
let relative = relative.to_string_lossy();
|
||||
let implicit = horizon_polytone::implicit_id(&relative, kind);
|
||||
let json = match horizon_world::json_file::read::<Json>(&path) {
|
||||
Ok(json) => json,
|
||||
Err(e) => {
|
||||
log::warn!("{relative}: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let parsed = if kind == "biome_modifiers" {
|
||||
horizon_polytone::BiomeModifier::parse(&json, implicit.as_deref())
|
||||
.map(|m| m.map(|m| out.biomes.push(m)))
|
||||
} else {
|
||||
horizon_polytone::DimensionModifier::parse(&json, implicit.as_deref())
|
||||
.map(|m| m.map(|m| out.dimensions.push(m)))
|
||||
};
|
||||
if let Err(e) = parsed {
|
||||
log::warn!("{relative}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.biomes.sort_by_key(|m| m.priority);
|
||||
out.dimensions.sort_by_key(|m| m.priority);
|
||||
out
|
||||
Self(crate::pack_hooks::formats().environment_modifiers(pack_root))
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.biomes.is_empty() && self.dimensions.is_empty()
|
||||
self.0.as_ref().is_none_or(|m| m.is_empty())
|
||||
}
|
||||
|
||||
/// The config values expressions read.
|
||||
pub fn with_configs(mut self, configs: horizon_polytone::pack::Configs) -> Self {
|
||||
self.configs = configs;
|
||||
/// The packs' option values, which the modifiers' expressions read.
|
||||
pub fn with_configs(mut self, configs: horizon_pack_options::Configs) -> Self {
|
||||
if let Some(m) = &mut self.0 {
|
||||
m.set_options(configs);
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A pack format's changes to the attribute stacks: entries set, replaced (maybe by a
|
||||
/// [`horizon_world::environment_attributes::Dynamic`] reading [`Frame`]) or removed.
|
||||
pub trait EnvironmentModifiers: Send + Sync {
|
||||
fn is_empty(&self) -> bool;
|
||||
|
||||
/// The packs' option values.
|
||||
fn set_options(&mut self, options: horizon_pack_options::Configs);
|
||||
|
||||
/// Changes the maps of dimension type `id` (`in_tag`: whether it is in a
|
||||
/// `dimension_type` tag). Dynamic entries read `frame`.
|
||||
fn dimension(
|
||||
&self,
|
||||
id: &str,
|
||||
in_tag: &dyn Fn(&str) -> bool,
|
||||
frame: &Arc<RwLock<Frame>>,
|
||||
maps: DimensionMaps<'_>,
|
||||
);
|
||||
|
||||
/// Changes biome `id`'s map (`in_tag`: whether it is in a `worldgen/biome` tag), and fills
|
||||
/// its post-process map, a biome layer after the weather.
|
||||
fn biome(
|
||||
&self,
|
||||
id: &str,
|
||||
in_tag: &dyn Fn(&str) -> bool,
|
||||
frame: &Arc<RwLock<Frame>>,
|
||||
base: &mut AttributeMap,
|
||||
post: &mut AttributeMap,
|
||||
);
|
||||
}
|
||||
|
||||
/// A dimension's maps the modifiers change: its own entries, the weather's, and a layer
|
||||
/// after all others.
|
||||
pub struct DimensionMaps<'a> {
|
||||
pub base: &'a mut AttributeMap,
|
||||
pub rain: &'a mut AttributeMap,
|
||||
pub thunder: &'a mut AttributeMap,
|
||||
pub post: &'a mut AttributeMap,
|
||||
}
|
||||
|
||||
/// A pack's colormap over blocks (Polytone's block and fluid colormaps, wiki "Colormaps").
|
||||
pub trait BlockColormap: Send + Sync {
|
||||
/// Whether blocks take the mean of the colours around them, over the Biome Blend square.
|
||||
fn biome_blend(&self) -> bool;
|
||||
|
||||
/// Whether its colour stays the same up a column of one biome (it reads no height): a
|
||||
/// section of one biome samples it once per column.
|
||||
fn column(&self) -> bool;
|
||||
|
||||
/// The colour at block `at`, in `biome`, ARGB; `None` where the pixel is transparent or
|
||||
/// an axis fails (the block keeps its own tint).
|
||||
fn sample(&self, at: [i32; 3], biome: &crate::biome::BiomeInfo) -> Option<u32>;
|
||||
}
|
||||
|
||||
/// Vanilla's biome colour functions (`BiomeColors`), which the packs may replace.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum BiomeFunction {
|
||||
Grass,
|
||||
Foliage,
|
||||
DryFoliage,
|
||||
Water,
|
||||
}
|
||||
|
||||
/// A block or fluid modifier with a colormap: the blocks it targets (by name) and the
|
||||
/// colormap's id.
|
||||
pub struct ColormapTarget {
|
||||
pub fluid: bool,
|
||||
pub targets: Box<dyn Fn(&str) -> bool + Send + Sync>,
|
||||
pub colormap: String,
|
||||
}
|
||||
|
||||
/// What a pack's expression can ask about the frame (wiki "Scripting Expressions": the global,
|
||||
/// player, camera and positional contexts), set before the frame samples the sky.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -179,626 +194,6 @@ impl Frame {
|
||||
}
|
||||
}
|
||||
|
||||
/// The game side of a pack expression in an attribute value: `g`, `p`, `c`, `o` (the camera's
|
||||
/// position) and `r`, `v` (the value so far) and the pack's `config`.
|
||||
struct FrameHost<'a> {
|
||||
frame: &'a Frame,
|
||||
configs: &'a horizon_polytone::pack::Configs,
|
||||
value: f64,
|
||||
seed: u64,
|
||||
}
|
||||
|
||||
/// `program`'s value for the frame (a pack's expression outside attribute layers: its `v` is 0).
|
||||
pub(crate) fn eval_frame(
|
||||
program: &horizon_polytone::expr::Program,
|
||||
frame: &Frame,
|
||||
configs: &horizon_polytone::pack::Configs,
|
||||
) -> Option<horizon_polytone::expr::Value> {
|
||||
let mut host = FrameHost {
|
||||
frame,
|
||||
configs,
|
||||
value: 0.0,
|
||||
seed: frame.game_time as u64,
|
||||
};
|
||||
program.eval(&mut host).ok()
|
||||
}
|
||||
|
||||
impl horizon_polytone::expr::Host for FrameHost<'_> {
|
||||
fn root(&mut self, root: horizon_polytone::expr::Root) -> horizon_polytone::expr::Value {
|
||||
use horizon_polytone::expr::{Root, Value as V};
|
||||
match root {
|
||||
Root::Value => V::Num(self.value),
|
||||
other => V::Object(other as u32),
|
||||
}
|
||||
}
|
||||
fn method(
|
||||
&mut self,
|
||||
target: &horizon_polytone::expr::Value,
|
||||
name: &str,
|
||||
_args: &[horizon_polytone::expr::Value],
|
||||
) -> std::result::Result<horizon_polytone::expr::Value, String> {
|
||||
use horizon_polytone::expr::{Root, Value as V};
|
||||
let f = self.frame;
|
||||
let on = |root: Root| *target == V::Object(root as u32);
|
||||
let num = |n: f64| Ok(V::Num(n));
|
||||
match name {
|
||||
"rain" if on(Root::Global) => num(f.global_rain()),
|
||||
"time" if on(Root::Global) => num(f.game_time as f64),
|
||||
"dayTime" if on(Root::Global) => num(f.day_time as f64),
|
||||
"skyType" if on(Root::Global) => num(f64::from(f.sky_type)),
|
||||
"dimensionType" if on(Root::Global) => Ok(V::Str(f.dimension.as_str().into())),
|
||||
"renderDistance" if on(Root::Global) => num(f64::from(f.render_distance)),
|
||||
"moonPhase" if on(Root::Global) => {
|
||||
num(f.day_time.div_euclid(24_000).rem_euclid(8) as f64)
|
||||
}
|
||||
"seaLevel" if on(Root::Global) => num(63.0),
|
||||
"viewDistance" if on(Root::Camera) => num(f64::from(f.render_distance * 16)),
|
||||
"x" | "y" | "z" if on(Root::Player) || on(Root::Camera) || on(Root::Object) => {
|
||||
let at = if on(Root::Player) { f.player } else { f.camera };
|
||||
num(at[usize::from(name.as_bytes()[0] - b'x')])
|
||||
}
|
||||
"inWater" if on(Root::Player) => Ok(V::Bool(f.in_water)),
|
||||
"skyLight" if on(Root::Object) || on(Root::Player) => num(f64::from(f.sky_light)),
|
||||
"blockLight" if on(Root::Object) || on(Root::Player) => num(f64::from(f.block_light)),
|
||||
"temperature" if on(Root::Object) => num(f64::from(f.temperature)),
|
||||
"downfall" if on(Root::Object) => num(f64::from(f.downfall)),
|
||||
"biome" if on(Root::Object) || on(Root::Player) || on(Root::Camera) => {
|
||||
Ok(V::Str(f.biome.as_str().into()))
|
||||
}
|
||||
"rand" if on(Root::Random) => {
|
||||
// A splitmix64 step: the frame's own sequence.
|
||||
self.seed = self.seed.wrapping_add(0x9e37_79b9_7f4a_7c15);
|
||||
let mut z = self.seed;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
|
||||
num(((z ^ (z >> 31)) >> 11) as f64 / (1u64 << 53) as f64)
|
||||
}
|
||||
_ => Err(format!(
|
||||
"{name} is not available in an attribute expression"
|
||||
)),
|
||||
}
|
||||
}
|
||||
fn function(
|
||||
&mut self,
|
||||
name: &str,
|
||||
args: &[horizon_polytone::expr::Value],
|
||||
) -> std::result::Result<horizon_polytone::expr::Value, String> {
|
||||
match (name, args) {
|
||||
("config", [horizon_polytone::expr::Value::Str(id)]) => self
|
||||
.configs
|
||||
.get(id)
|
||||
.map(horizon_polytone::pack::ConfigValue::to_value)
|
||||
.ok_or_else(|| format!("no config {id}")),
|
||||
_ => Err(format!(
|
||||
"{name} is not available in an attribute expression"
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A value as an expression's `v`: floats as they are, colours packed ARGB (`ARGB.as8BitChannel`).
|
||||
fn as_number(value: &Value) -> f64 {
|
||||
let byte = |c: f32| ((c.clamp(0.0, 1.0) * 255.0).floor() as u32) & 255;
|
||||
match value {
|
||||
Value::Float(f) => f64::from(*f),
|
||||
Value::Bool(b) => f64::from(u8::from(*b)),
|
||||
Value::Rgb([r, g, b]) => {
|
||||
f64::from((0xff00_0000 | byte(*r) << 16 | byte(*g) << 8 | byte(*b)) as i32)
|
||||
}
|
||||
Value::Argb([r, g, b, a]) => {
|
||||
f64::from((byte(*a) << 24 | byte(*r) << 16 | byte(*g) << 8 | byte(*b)) as i32)
|
||||
}
|
||||
Value::Other(_) => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A Polytone colormap used as an attribute value (wiki "Colormaps"): an image read at the
|
||||
/// column and row its two expressions pick, each 0 to 1 with 1 the top-left corner.
|
||||
pub(crate) struct Colormap {
|
||||
x: Axis,
|
||||
y: Axis,
|
||||
/// Only the lower-left triangle: the row value is scaled by the column value, as vanilla's
|
||||
/// grass colormap scales downfall by temperature.
|
||||
triangular: bool,
|
||||
/// Rounds to the nearest pixel (the default) instead of the one below.
|
||||
rounds: bool,
|
||||
/// `biome_blend`: blocks take the mean of the colours around them, over the Biome Blend
|
||||
/// square, as vanilla's biome tints do. Without the field, maps that sample the biome
|
||||
/// (`temperature`, `downfall`, `biome_id`) blend: Polytone 6.8.6's water by `biome_id`
|
||||
/// fades over a biome edge.
|
||||
pub(crate) biome_blend: bool,
|
||||
/// Its axes read nothing that changes up a column of one biome (no `o.y`): a section of
|
||||
/// one biome can sample it once per column.
|
||||
pub(crate) column: bool,
|
||||
width: u32,
|
||||
height: u32,
|
||||
rgba: Vec<u8>,
|
||||
}
|
||||
|
||||
/// A colormap axis: an expression (or a named sampler, wiki "Default Samplers"), or the
|
||||
/// `biome_id` sampler with its mapper.
|
||||
enum Axis {
|
||||
Expr(horizon_polytone::expr::Program),
|
||||
/// `biome_id`: the value the mapper gives the biome where it samples (0 for the others),
|
||||
/// and the expression naming that biome. Unlike the other axes it counts from the left (or
|
||||
/// top) edge, in pixel coordinates over the mapper's `texture_size`: the column (or row) is
|
||||
/// `floor(value * size)`, so `1` over a `texture_size` of 16 is the second pixel, and the
|
||||
/// biomes left out are the first, as the wiki's "start your mapper at 1" advice expects.
|
||||
BiomeId(HashMap<String, f64>, horizon_polytone::expr::Program),
|
||||
}
|
||||
|
||||
impl Axis {
|
||||
/// Whether the axis reads only what stays the same up a column of one biome.
|
||||
fn column(&self) -> bool {
|
||||
use horizon_polytone::expr::Root;
|
||||
let flat = |root: Root, name: &str| match root {
|
||||
Root::Object => matches!(name, "x" | "z" | "biome" | "temperature" | "downfall"),
|
||||
Root::Random => name == "noise",
|
||||
_ => false,
|
||||
};
|
||||
match self {
|
||||
Self::Expr(program) | Self::BiomeId(_, program) => program.reads_only(&flat),
|
||||
}
|
||||
}
|
||||
fn value(&self, host: &mut dyn horizon_polytone::expr::Host) -> Option<f64> {
|
||||
match self {
|
||||
Self::Expr(program) => Some(program.eval(host).ok()?.num()),
|
||||
Self::BiomeId(mapper, biome) => {
|
||||
let biome = match biome.eval(host).ok()? {
|
||||
horizon_polytone::expr::Value::Str(s) => s,
|
||||
_ => return Some(0.0),
|
||||
};
|
||||
Some(mapper.get(biome.as_ref()).copied().unwrap_or(0.0))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `biome_id_mapper`: inline, or an id of `polytone/biome_id_mappers/<path>.json`. Its values
|
||||
/// are divided by its `texture_size`, when it has one (pixel coordinates).
|
||||
fn biome_id_mapper(pack_root: &Path, json: &Json) -> HashMap<String, f64> {
|
||||
let object = match json {
|
||||
Json::String(id) => {
|
||||
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
let file = pack_root.join(format!(
|
||||
"assets/{namespace}/polytone/biome_id_mappers/{path}.json"
|
||||
));
|
||||
horizon_world::json_file::read::<Json>(&file).unwrap_or(Json::Null)
|
||||
}
|
||||
other => other.clone(),
|
||||
};
|
||||
let Some(object) = object.as_object() else {
|
||||
return HashMap::new();
|
||||
};
|
||||
let size = object
|
||||
.get("texture_size")
|
||||
.and_then(|v| v.as_f64().or_else(|| v.as_str()?.parse().ok()))
|
||||
.filter(|s| *s > 0.0)
|
||||
.unwrap_or(1.0);
|
||||
object
|
||||
.iter()
|
||||
.filter(|(k, _)| *k != "texture_size")
|
||||
.filter_map(|(k, v)| {
|
||||
let id = if k.contains(':') {
|
||||
k.clone()
|
||||
} else {
|
||||
format!("minecraft:{k}")
|
||||
};
|
||||
Some((id, v.as_f64()? / size))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Colormap {
|
||||
/// One colour wherever it samples (a `biome_colors` flat colour, RGB).
|
||||
pub(crate) fn flat(rgb: u32) -> Self {
|
||||
let zero = || Axis::Expr(horizon_polytone::expr::Program::compile("0").expect("a number"));
|
||||
let [_, r, g, b] = rgb.to_be_bytes();
|
||||
Self {
|
||||
x: zero(),
|
||||
y: zero(),
|
||||
triangular: false,
|
||||
rounds: true,
|
||||
biome_blend: false,
|
||||
column: true,
|
||||
width: 1,
|
||||
height: 1,
|
||||
rgba: vec![r, g, b, 255],
|
||||
}
|
||||
}
|
||||
|
||||
/// `assets/<namespace>/polytone/colormaps/<path>.png` and its optional `.json` (without
|
||||
/// one, the axes are the biome's temperature and downfall).
|
||||
pub(crate) fn load(pack_root: &Path, id: &str) -> Option<Self> {
|
||||
let (namespace, path) = id.split_once(':')?;
|
||||
let base = pack_root.join(format!("assets/{namespace}/polytone/colormaps/{path}"));
|
||||
let png = base.with_extension("png");
|
||||
let (width, height, rgba) = match crate::sprites::read_rgba(&png) {
|
||||
Ok(image) => image,
|
||||
Err(e) => {
|
||||
log::warn!("colormap {id}: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let json: Json =
|
||||
horizon_world::json_file::read(&base.with_extension("json")).unwrap_or(Json::Null);
|
||||
let compile = horizon_polytone::expr::Program::compile;
|
||||
let mut by_biome = false;
|
||||
let mut axis = |key: &str, default: &str| -> Option<Axis> {
|
||||
let src = match &json[key] {
|
||||
Json::String(s) => s.clone(),
|
||||
Json::Number(n) => n.to_string(),
|
||||
_ => default.to_owned(),
|
||||
};
|
||||
by_biome |= matches!(
|
||||
src.trim(),
|
||||
"biome_id" | "temperature" | "legacy_temperature" | "downfall"
|
||||
) || matches!(src.trim(), "o.temperature" | "o.downfall");
|
||||
// The named samplers (wiki "Default Samplers") as the expressions they stand for.
|
||||
let src = match src.trim() {
|
||||
"biome_id" => {
|
||||
let mapper = biome_id_mapper(pack_root, &json["biome_id_mapper"]);
|
||||
return Some(Axis::BiomeId(mapper, compile("o.biome()").ok()?));
|
||||
}
|
||||
"temperature" | "legacy_temperature" => "o.temperature".to_owned(),
|
||||
"downfall" => "o.downfall".to_owned(),
|
||||
"y_level" => "o.y / 128".to_owned(),
|
||||
"day_time" => "1 - (g.dayTime % 24000) / 24000".to_owned(),
|
||||
_ => src,
|
||||
};
|
||||
match compile(&src).map(Axis::Expr) {
|
||||
Ok(program) => Some(program),
|
||||
Err(e) => {
|
||||
log::warn!("colormap {id} {key}: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
let (x, y) = (
|
||||
axis("x_axis", "o.temperature")?,
|
||||
axis("y_axis", "o.downfall")?,
|
||||
);
|
||||
let column = [&x, &y].iter().all(|axis| axis.column());
|
||||
Some(Self {
|
||||
x,
|
||||
y,
|
||||
column,
|
||||
biome_blend: json["biome_blend"].as_bool().unwrap_or(by_biome),
|
||||
triangular: json["triangular"].as_bool() == Some(true),
|
||||
rounds: json["rounds"].as_bool() != Some(false),
|
||||
width,
|
||||
height,
|
||||
rgba,
|
||||
})
|
||||
}
|
||||
|
||||
/// The colour the axes pick, ARGB; `None` for a transparent pixel (the value stays what
|
||||
/// it was, the default `fallback_colormap: original`) or an axis that fails.
|
||||
pub(crate) fn sample(&self, host: &mut dyn horizon_polytone::expr::Host) -> Option<u32> {
|
||||
let x = self.x.value(host)?.clamp(0.0, 1.0);
|
||||
let mut y = self.y.value(host)?.clamp(0.0, 1.0);
|
||||
if self.triangular {
|
||||
y *= x;
|
||||
}
|
||||
let pixel = |axis: &Axis, v: f64, size: u32| {
|
||||
let last = size.saturating_sub(1);
|
||||
if let Axis::BiomeId(..) = axis {
|
||||
return ((v * f64::from(size)).floor() as u32).min(last);
|
||||
}
|
||||
let p = (1.0 - v) * f64::from(last);
|
||||
let p = if self.rounds { p.round() } else { p.floor() };
|
||||
(p as u32).min(last)
|
||||
};
|
||||
let (px, py) = (
|
||||
pixel(&self.x, x, self.width),
|
||||
pixel(&self.y, y, self.height),
|
||||
);
|
||||
let i = (py as usize * self.width as usize + px as usize) * 4;
|
||||
let [r, g, b, a] = self.rgba.get(i..i + 4)?.try_into().ok()?;
|
||||
(a != 0).then(|| u32::from(a) << 24 | u32::from(r) << 16 | u32::from(g) << 8 | u32::from(b))
|
||||
}
|
||||
}
|
||||
|
||||
/// The colormaps of the Polytone block and fluid modifiers of the packs merged at `pack_root`:
|
||||
/// (a fluid modifier, its targets, the colormap's id). Inline colormaps aren't read.
|
||||
pub(crate) fn polytone_colormap_modifiers(
|
||||
pack_root: &Path,
|
||||
) -> Vec<(bool, horizon_polytone::Targets, String)> {
|
||||
let mut out = Vec::new();
|
||||
let namespaces = std::fs::read_dir(pack_root.join("assets"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten();
|
||||
for namespace in namespaces {
|
||||
for (kind, fluid) in [("block_modifiers", false), ("fluid_modifiers", true)] {
|
||||
let mut stack = vec![namespace.path().join("polytone").join(kind)];
|
||||
while let Some(dir) = stack.pop() {
|
||||
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
continue;
|
||||
}
|
||||
let Ok(json) = horizon_world::json_file::read::<Json>(&path) else {
|
||||
continue;
|
||||
};
|
||||
let Some(id) = json.get("colormap").and_then(Json::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let id = if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
};
|
||||
let relative = path
|
||||
.strip_prefix(pack_root)
|
||||
.unwrap_or(&path)
|
||||
.to_string_lossy();
|
||||
let implicit = horizon_polytone::implicit_id(&relative, kind);
|
||||
match horizon_polytone::Targets::parse(json.get("targets"), implicit.as_deref())
|
||||
{
|
||||
Ok(targets) => out.push((fluid, targets, id)),
|
||||
Err(e) => log::warn!("{relative}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// What a block colormap's axes read where it samples: the block (`o.x`, `o.biome()`, its
|
||||
/// biome's climate) and `r.noise`. The world isn't reachable from the mesher, so the rest fails
|
||||
/// and the block keeps its own tint.
|
||||
pub(crate) struct BlockHost<'a> {
|
||||
at: [i32; 3],
|
||||
biome: &'a crate::biome::BiomeInfo,
|
||||
}
|
||||
|
||||
impl<'a> BlockHost<'a> {
|
||||
pub(crate) fn new(at: [i32; 3], biome: &'a crate::biome::BiomeInfo) -> Self {
|
||||
Self { at, biome }
|
||||
}
|
||||
}
|
||||
|
||||
/// The noise `r.noise(x, y)` reads, the same for blocks and particles.
|
||||
pub(crate) fn polytone_noise() -> &'static noise::PerlinSimplex {
|
||||
noise::builtin()
|
||||
}
|
||||
|
||||
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 {
|
||||
Root::Object => 1,
|
||||
Root::Random => 2,
|
||||
_ => 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn method(
|
||||
&mut self,
|
||||
target: &horizon_polytone::expr::Value,
|
||||
name: &str,
|
||||
args: &[horizon_polytone::expr::Value],
|
||||
) -> std::result::Result<horizon_polytone::expr::Value, String> {
|
||||
use horizon_polytone::expr::Value as V;
|
||||
let num = |n: f64| Ok(V::Num(n));
|
||||
match (target, name) {
|
||||
(V::Object(1), "x" | "y" | "z") => {
|
||||
num(f64::from(self.at[usize::from(name.as_bytes()[0] - b'x')]))
|
||||
}
|
||||
(V::Object(1), "biome") => Ok(V::Str(self.biome.name.as_str().into())),
|
||||
(V::Object(1), "temperature") => num(f64::from(self.biome.temperature)),
|
||||
(V::Object(1), "downfall") => num(f64::from(self.biome.downfall)),
|
||||
(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))
|
||||
}
|
||||
_ => Err(format!("{name} is not available to block colormaps")),
|
||||
}
|
||||
}
|
||||
|
||||
fn function(
|
||||
&mut self,
|
||||
name: &str,
|
||||
_: &[horizon_polytone::expr::Value],
|
||||
) -> std::result::Result<horizon_polytone::expr::Value, String> {
|
||||
Err(format!("{name} is not available to block colormaps"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns Polytone layers into vanilla entries, compiling expressions against the frame.
|
||||
struct Resolver {
|
||||
frame: Arc<RwLock<Frame>>,
|
||||
configs: Arc<horizon_polytone::pack::Configs>,
|
||||
/// Where the packs' colormaps are, and the ones loaded so far (`None`: missing).
|
||||
pack_root: Option<std::path::PathBuf>,
|
||||
colormaps: ColormapCache,
|
||||
}
|
||||
|
||||
/// Colormaps by id, loaded once for all three dimensions' stacks.
|
||||
type ColormapCache = Arc<std::sync::Mutex<HashMap<String, Option<Arc<Colormap>>>>>;
|
||||
|
||||
impl Resolver {
|
||||
/// A Polytone layer as a vanilla entry: `Some(None)` removes the attribute's entry, `None`
|
||||
/// is a value not supported yet (colormaps) or a broken one.
|
||||
fn entry(
|
||||
&self,
|
||||
attribute: &'static Attribute,
|
||||
layer: &horizon_polytone::Layer,
|
||||
) -> Option<Option<Entry>> {
|
||||
use horizon_polytone::{Argument, Layer, Modifier};
|
||||
let (modifier, argument) = match layer {
|
||||
Layer::Remove => return Some(None),
|
||||
Layer::Apply {
|
||||
modifier, argument, ..
|
||||
} => (modifier, argument),
|
||||
};
|
||||
let op = match modifier {
|
||||
Modifier::Override => "override",
|
||||
Modifier::Add => "add",
|
||||
Modifier::Subtract => "subtract",
|
||||
Modifier::Multiply => "multiply",
|
||||
Modifier::Minimum => "minimum",
|
||||
Modifier::Maximum => "maximum",
|
||||
Modifier::AlphaBlend => "alpha_blend",
|
||||
Modifier::BlendToGray => "blend_to_gray",
|
||||
Modifier::And => "and",
|
||||
Modifier::Nand => "nand",
|
||||
Modifier::Or => "or",
|
||||
Modifier::Nor => "nor",
|
||||
Modifier::Xor => "xor",
|
||||
Modifier::Xnor => "xnor",
|
||||
};
|
||||
let json = match argument {
|
||||
Argument::Number(n) => Json::from(*n),
|
||||
Argument::Bool(b) => Json::Bool(*b),
|
||||
// Polytone colours may leave out the `#`; vanilla's codec wants it, 6 digits for RGB.
|
||||
Argument::Color(c) if attribute.kind == Kind::RgbColor => {
|
||||
Json::String(format!("#{:06x}", c & 0x00ff_ffff))
|
||||
}
|
||||
Argument::Color(c) => Json::String(format!("#{c:08x}")),
|
||||
Argument::Object(o) => Json::Object(o.clone()),
|
||||
Argument::Expression(src) => return self.expression(attribute, op, src).map(Some),
|
||||
Argument::Colormap(id) => return self.colormap(attribute, op, id).map(Some),
|
||||
};
|
||||
match Entry::with_modifier(attribute, op, &json) {
|
||||
Ok(entry) => Some(Some(entry)),
|
||||
Err(e) => {
|
||||
log::warn!("{}: {e}", attribute.id);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An expression value: compiled once, evaluated against the frame each time the layer
|
||||
/// applies, its result read as the modifier's argument (a number, a boolean, or a packed
|
||||
/// colour for colour attributes).
|
||||
fn expression(
|
||||
&self,
|
||||
attribute: &'static Attribute,
|
||||
op: &'static str,
|
||||
src: &str,
|
||||
) -> Option<Entry> {
|
||||
let program = match horizon_polytone::expr::Program::compile(src) {
|
||||
Ok(program) => program,
|
||||
Err(e) => {
|
||||
log::warn!("{}: {e}", attribute.id);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let (frame, configs) = (self.frame.clone(), self.configs.clone());
|
||||
let f = move |value: &Value| -> Option<Entry> {
|
||||
let frame = frame.read().ok()?;
|
||||
let mut host = FrameHost {
|
||||
frame: &frame,
|
||||
configs: &configs,
|
||||
value: as_number(value),
|
||||
seed: 0,
|
||||
};
|
||||
let json = match program.eval(&mut host).ok()? {
|
||||
horizon_polytone::expr::Value::Bool(b) => Json::Bool(b),
|
||||
result => Json::from(result.num()),
|
||||
};
|
||||
// A number out of the attribute's range is clamped into it, as the sampled value is
|
||||
// (`AttributeRange.sanitize`), not dropped: a pack's fog end that goes negative deep
|
||||
// underground is the thickest fog, not the default.
|
||||
Entry::with_modifier(attribute, op, &json).ok().or_else(|| {
|
||||
let n = json.as_f64()? as f32;
|
||||
let Value::Float(clamped) = attribute.sanitize(Value::Float(n)) else {
|
||||
return None;
|
||||
};
|
||||
Entry::with_modifier(attribute, op, &Json::from(clamped)).ok()
|
||||
})
|
||||
};
|
||||
Some(Entry::Dynamic(
|
||||
horizon_world::environment_attributes::Dynamic(Arc::new(f)),
|
||||
))
|
||||
}
|
||||
|
||||
/// A colormap value: sampled whenever its layer applies, its colour the modifier's argument
|
||||
/// (`#rrggbb` on RGB attributes, `#aarrggbb` on ARGB ones).
|
||||
fn colormap(&self, attribute: &'static Attribute, op: &'static str, id: &str) -> Option<Entry> {
|
||||
let root = self.pack_root.as_deref()?;
|
||||
let colormap = self
|
||||
.colormaps
|
||||
.lock()
|
||||
.ok()?
|
||||
.entry(id.to_owned())
|
||||
.or_insert_with(|| Colormap::load(root, id).map(Arc::new))
|
||||
.clone()?;
|
||||
let (frame, configs) = (self.frame.clone(), self.configs.clone());
|
||||
let rgb = attribute.kind == Kind::RgbColor;
|
||||
let f = move |value: &Value| -> Option<Entry> {
|
||||
let frame = frame.read().ok()?;
|
||||
let mut host = FrameHost {
|
||||
frame: &frame,
|
||||
configs: &configs,
|
||||
value: as_number(value),
|
||||
seed: 0,
|
||||
};
|
||||
let color = colormap.sample(&mut host)?;
|
||||
let text = if rgb {
|
||||
format!("#{:06x}", color & 0x00ff_ffff)
|
||||
} else {
|
||||
format!("#{color:08x}")
|
||||
};
|
||||
Entry::with_modifier(attribute, op, &Json::String(text)).ok()
|
||||
};
|
||||
Some(Entry::Dynamic(
|
||||
horizon_world::environment_attributes::Dynamic(Arc::new(f)),
|
||||
))
|
||||
}
|
||||
|
||||
/// Applies a group of Polytone layers to `map`: entries set or replaced, `REMOVE` drops
|
||||
/// them.
|
||||
fn apply(&self, map: &mut AttributeMap, group: &horizon_polytone::Attributes) {
|
||||
for (id, layer) in group {
|
||||
let Some(attribute) = attribute(id) else {
|
||||
log::warn!("unknown attribute {id}");
|
||||
continue;
|
||||
};
|
||||
match self.entry(attribute, layer) {
|
||||
// A computed value that can't decide (a transparent colormap pixel, a failing
|
||||
// expression) falls back to the entry it replaces: the value before the pack.
|
||||
Some(Some(Entry::Dynamic(f))) => match map.get(attribute).cloned() {
|
||||
Some(original) => {
|
||||
let fallback = move |v: &Value| (f.0)(v).or_else(|| Some(original.clone()));
|
||||
let entry =
|
||||
horizon_world::environment_attributes::Dynamic(Arc::new(fallback));
|
||||
map.set(attribute, Entry::Dynamic(entry));
|
||||
}
|
||||
None => map.set(attribute, Entry::Dynamic(f)),
|
||||
},
|
||||
Some(Some(entry)) => map.set(attribute, entry),
|
||||
Some(None) => map.remove(attribute),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The timeline ids a dimension type's `timelines` names: an id, a `#tag` or a list,
|
||||
/// tags resolved through `tags/timeline/` (their entries may be `{"id", "required"}`).
|
||||
fn timeline_ids(root: &Path, json: &Json, out: &mut Vec<String>, depth: u32) {
|
||||
@@ -853,29 +248,19 @@ impl Environment {
|
||||
)?;
|
||||
let id = format!("minecraft:{dimension}");
|
||||
let tags = crate::pack_tags::Tags::new(root);
|
||||
let mine: Vec<_> = packs
|
||||
.dimensions
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
m.targets
|
||||
.matches(&id, |tag| tags.contains("dimension_type", tag, &id))
|
||||
})
|
||||
.collect();
|
||||
let frame = Arc::new(RwLock::new(Frame::default()));
|
||||
let resolver = Resolver {
|
||||
frame: frame.clone(),
|
||||
configs: Arc::new(packs.configs.clone()),
|
||||
pack_root: packs.root.clone(),
|
||||
colormaps: packs.colormaps.clone(),
|
||||
};
|
||||
let mut dimension_map = AttributeMap::parse(&json["attributes"])?;
|
||||
let (mut rain, mut thunder) = horizon_world::environment_system::weather_maps();
|
||||
let mut dimension_post = AttributeMap::default();
|
||||
for m in &mine {
|
||||
resolver.apply(&mut dimension_map, &m.base);
|
||||
resolver.apply(&mut rain, &m.rain);
|
||||
resolver.apply(&mut thunder, &m.thunder);
|
||||
resolver.apply(&mut dimension_post, &m.post_process);
|
||||
if let Some(m) = &packs.0 {
|
||||
let in_tag = |tag: &str| tags.contains("dimension_type", tag, &id);
|
||||
let maps = DimensionMaps {
|
||||
base: &mut dimension_map,
|
||||
rain: &mut rain,
|
||||
thunder: &mut thunder,
|
||||
post: &mut dimension_post,
|
||||
};
|
||||
m.dimension(&id, &in_tag, &frame, maps);
|
||||
}
|
||||
let mut builder = Builder::default();
|
||||
builder.constant_layer(&dimension_map);
|
||||
@@ -896,12 +281,9 @@ impl Environment {
|
||||
}
|
||||
};
|
||||
let mut post_map = AttributeMap::default();
|
||||
for m in &packs.biomes {
|
||||
if let Some(m) = &packs.0 {
|
||||
let in_tag = |tag: &str| tags.contains("worldgen/biome", tag, &name);
|
||||
if m.targets.matches(&name, in_tag) {
|
||||
resolver.apply(&mut map, &m.base);
|
||||
resolver.apply(&mut post_map, &m.post_process);
|
||||
}
|
||||
m.biome(&name, &in_tag, &frame, &mut map, &mut post_map);
|
||||
}
|
||||
if !post_map.is_empty() {
|
||||
post.insert(name.clone(), post_map);
|
||||
@@ -1101,6 +483,7 @@ mod tests {
|
||||
for (path, text) in pack {
|
||||
write(&format!("pack/{path}"), text);
|
||||
}
|
||||
crate::pack_formats::install();
|
||||
let modifiers = PackModifiers::read(&dir.join("pack")).with_configs(
|
||||
crate::packs::polytone_configs(&crate::packs::Source::Dir(dir.join("pack"))),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
@@ -231,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,
|
||||
@@ -335,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),
|
||||
@@ -372,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:{}\ncosmeticsMine:{}\n{}cosmeticsHideFx:{}\ncosmeticsSolo:{}\n{}:{}\n{}:{}\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),
|
||||
@@ -414,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,
|
||||
@@ -608,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.
|
||||
@@ -684,6 +691,7 @@ 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",
|
||||
@@ -716,6 +724,7 @@ 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",
|
||||
@@ -759,6 +768,7 @@ 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",
|
||||
@@ -782,9 +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 | CosmeticsMine | CosmeticsOthers
|
||||
| CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe | Pregen => {
|
||||
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
|
||||
| ChatAnimation | WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine
|
||||
| CosmeticsOthers | CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe
|
||||
| Pregen => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -833,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."
|
||||
}
|
||||
@@ -913,6 +927,7 @@ 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,
|
||||
@@ -971,6 +986,7 @@ 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,
|
||||
@@ -1088,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();
|
||||
@@ -1127,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));
|
||||
|
||||
@@ -60,7 +60,6 @@ mod entity_outline;
|
||||
mod fluid;
|
||||
#[cfg(test)]
|
||||
mod inter_stage_tests;
|
||||
mod section_fade;
|
||||
mod terrain_model;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_gpu;
|
||||
@@ -75,11 +74,6 @@ pub use entity_batch::{
|
||||
WATER_MASK_OVERLAY, WORLD_TEXT_OVERLAY, WORLD_TEXT_POLYGON_OFFSET_OVERLAY, is_native_terrain,
|
||||
mark_oit,
|
||||
};
|
||||
use external::{ShadowCull, shadow_box, shadow_casters, shadow_runs};
|
||||
use section_fade::{
|
||||
CHUNK_VISIBILITY_THRESHOLD, FadeInputs, chunk_visibility, fade_millis, section_instance,
|
||||
section_visibility,
|
||||
};
|
||||
pub use view::{View, ViewSet};
|
||||
|
||||
/// Window.getAppropriateLineWidth, in framebuffer pixels.
|
||||
@@ -195,7 +189,6 @@ mod cull;
|
||||
mod dx12_compiler;
|
||||
mod end_sky;
|
||||
pub mod entity_skin;
|
||||
pub mod external;
|
||||
mod flat;
|
||||
mod frame_gen;
|
||||
mod gpu_culling;
|
||||
@@ -372,6 +365,62 @@ fn pack_terrain_camera(camera: DVec3) -> DVec3 {
|
||||
let whole = camera.floor();
|
||||
whole + ((camera - whole) * 65536.0).round() / 65536.0
|
||||
}
|
||||
/// A section's instance: its camera-relative origin and its UV step, negated
|
||||
/// past its fade code while it fades in over far terrain (`lod::instance`).
|
||||
fn section_instance(origin: [f32; 3], fade: u32, visibility: f32) -> [f32; 4] {
|
||||
let step = 1.0 / crate::mesher::UV_STEPS;
|
||||
[
|
||||
origin[0],
|
||||
origin[1],
|
||||
origin[2],
|
||||
if fade == 0 {
|
||||
// The step is a power of two, so its mantissa is free: it holds the vanilla
|
||||
// chunk fade-in's `1 - visibility` in 23 bits (terrain.wgsl's `instance_step` and
|
||||
// `instance_visibility` read it back). A whole section is the bare step.
|
||||
let faded = ((1.0 - visibility) * 8_388_608.0)
|
||||
.round()
|
||||
.clamp(0.0, 8_388_607.0) as u32;
|
||||
f32::from_bits(step.to_bits() | faded)
|
||||
} else {
|
||||
-(fade as f32 + step)
|
||||
},
|
||||
]
|
||||
}
|
||||
/// `SectionRenderDispatcher.RenderSection.getVisibility`: how far through its fade-in a section
|
||||
/// uploaded `elapsed_ms` ago is.
|
||||
fn chunk_visibility(elapsed_ms: u64, fade_ms: u64) -> f32 {
|
||||
if elapsed_ms >= fade_ms {
|
||||
1.0
|
||||
} else {
|
||||
elapsed_ms as f32 / fade_ms as f32
|
||||
}
|
||||
}
|
||||
/// `Util.toMillis`: `Mth.floor(seconds * 1000.0)`.
|
||||
fn fade_millis(seconds: f32) -> u64 {
|
||||
(f64::from(seconds) * 1000.0).floor().max(0.0) as u64
|
||||
}
|
||||
/// `chunk_visibility` at `now` of a section first uploaded at `uploaded`
|
||||
/// (`Renderer::section_uploaded`; none: one that fades in over far terrain, or
|
||||
/// whose column left), with the chunk fade-in `fade_ms`.
|
||||
fn section_visibility(uploaded: Option<&Instant>, fade_ms: u64, now: Instant) -> f32 {
|
||||
uploaded.map_or(1.0, |&t| {
|
||||
let elapsed = now.saturating_duration_since(t).as_millis() as u64;
|
||||
chunk_visibility(elapsed, fade_ms)
|
||||
})
|
||||
}
|
||||
/// What a section's fade depends on besides the section itself
|
||||
/// (`SectionGpu::drawn_faded`): the time, the chunk fade-in time, the sections'
|
||||
/// first uploads (`Renderer::section_uploaded`) and those held while far
|
||||
/// terrain comes in (`Renderer::lod_retiring`).
|
||||
#[derive(Clone, Copy)]
|
||||
struct FadeInputs<'a> {
|
||||
now: Instant,
|
||||
fade_ms: u64,
|
||||
uploaded: &'a FxHashMap<SectionKey, Instant>,
|
||||
retiring: &'a crate::fast_hash::FxHashSet<SectionKey>,
|
||||
}
|
||||
/// `LevelRenderer.CHUNK_VISIBILITY_THRESHOLD`: a section's block entities wait for it.
|
||||
const CHUNK_VISIBILITY_THRESHOLD: f32 = 0.3;
|
||||
/// A new value for `Renderer::block_entity_revision`.
|
||||
fn next_block_entity_revision() -> u64 {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
@@ -1127,8 +1176,6 @@ pub struct Renderer {
|
||||
/// Sections upload their solid quads in the compact layout (`mesher::packed`): the solid
|
||||
/// passes read the arena themselves (`flat`) and no shader pack reads it as vertices.
|
||||
packed_quads: bool,
|
||||
/// The compact layout is allowed (`HORIZON_PACKED_QUADS`, `set_packed_layout`).
|
||||
packed_on: bool,
|
||||
/// The visibility buffer for flat frames' solid terrain (`visbuffer`,
|
||||
/// `HORIZON_VISBUFFER=1`).
|
||||
visbuf: Option<visbuffer::Visbuffer>,
|
||||
@@ -1371,10 +1418,8 @@ pub struct Renderer {
|
||||
/// Per-frame scratch, kept to avoid allocating every frame.
|
||||
visible: Vec<resident::Visible>,
|
||||
visible_lods: Vec<lod::Visible>,
|
||||
/// The sections a shader pack's shadow camera sees (`external::ShadowView`).
|
||||
/// The sections a shader pack's shadow camera sees (`pack_post::ShadowView`).
|
||||
shadow_visible: Vec<resident::Visible>,
|
||||
/// The external shader pipeline world frames call (`external`); `None` draws vanilla's way.
|
||||
external: Option<Box<dyn external::ExternalPipeline>>,
|
||||
translucent_draws: Vec<(u64, usize, [u32; 5], (u32, Option<u32>))>,
|
||||
origins: Vec<[f32; 4]>,
|
||||
/// `DrawIndexedIndirectArgs` words: index count, instance count, first
|
||||
@@ -1500,7 +1545,7 @@ pub struct Renderer {
|
||||
shadow_seen_scratch: crate::fast_hash::FxHashSet<SectionKey>,
|
||||
/// Shadow translucent section draws, retained between frames.
|
||||
shadow_translucent_scratch: Vec<(u64, [u32; 5], (u32, Option<u32>))>,
|
||||
/// Lava's sprite layers, which a pack's solid terrain draws (`pack_atlas::game_terrain`).
|
||||
/// Lava's sprite layers, which a pack's solid terrain draws (`pack_atlas::terrain_parts`).
|
||||
pack_lava_layers: Vec<u16>,
|
||||
pub hud: HudRenderer,
|
||||
/// Vanilla's menu background blur, made on first use.
|
||||
@@ -1581,9 +1626,9 @@ fn requested_split() -> Option<u32> {
|
||||
.and_then(|v| v.parse().ok())
|
||||
.filter(|&n| (1..=SPRITE_SPLIT).contains(&n))
|
||||
}
|
||||
fn adapter_requested_split(info: &wgpu::AdapterInfo, asked: Option<u32>, n: u32) -> Option<u32> {
|
||||
// Metal can fail on big mipmapped arrays (#8471): 512 layers each, more only for n > 8 x 512.
|
||||
asked.or((info.backend == wgpu::Backend::Metal).then(|| n.div_ceil(SPRITE_ARRAYS).max(512)))
|
||||
fn adapter_requested_split(info: &wgpu::AdapterInfo, requested: Option<u32>) -> Option<u32> {
|
||||
// Metal can fail to sample large mipmapped array textures despite advertising the layer limit.
|
||||
requested.or_else(|| (info.backend == wgpu::Backend::Metal).then_some(512))
|
||||
}
|
||||
/// The split the CPU mip chains are cut by before the adapter is known.
|
||||
fn sprite_split() -> u32 {
|
||||
@@ -2112,7 +2157,7 @@ fn check_layers(adapter: &wgpu::Adapter, layers: u32) -> Result<u32> {
|
||||
Ok(layer_split(
|
||||
adapter.limits().max_texture_array_layers,
|
||||
info.backend == wgpu::Backend::Gl,
|
||||
adapter_requested_split(&info, requested_split(), layers),
|
||||
adapter_requested_split(&info, requested_split()),
|
||||
layers,
|
||||
)?)
|
||||
}
|
||||
@@ -2827,8 +2872,10 @@ impl Renderer {
|
||||
// Computed once: the sprite arrays, the shaders' constants and the
|
||||
// shader pack atlas all cut the sprites by it.
|
||||
let split = check_layers(&adapter, layers)?;
|
||||
if split < sprite_split() {
|
||||
log::info!("Horizon: {split} block textures per array (the GPU's limit)");
|
||||
}
|
||||
let info = adapter.get_info();
|
||||
surface_setup::log_sprite_split(&info, split, layers);
|
||||
crate::graphics::suggest_reduced_far_detail(crate::graphics::lod_capped(&info));
|
||||
let size = window.inner_size();
|
||||
let max_side = device.limits().max_texture_dimension_2d;
|
||||
@@ -4906,8 +4953,8 @@ impl Renderer {
|
||||
}
|
||||
None => (solid, solid_opaque, solid_cutout, solid_mirror),
|
||||
};
|
||||
// The solid quads in the compact layout where every pass reads it; the first world
|
||||
// frames settle it for the picked pack (`follow_packed_layout`).
|
||||
// The solid quads in the compact layout, while no shader pack reads the arena
|
||||
// (`follow_packed_layout`).
|
||||
let packed_quads = flat.is_some() && !shader_pack && packed_wanted();
|
||||
packed::pack_quads(packed_quads);
|
||||
// A native mod's world warp builds its pipelines with these layouts once it is given one.
|
||||
@@ -5075,7 +5122,6 @@ impl Renderer {
|
||||
world_warps,
|
||||
flat_on: flat.is_some(),
|
||||
packed_quads,
|
||||
packed_on: packed_wanted(),
|
||||
cull_on: cull.is_some(),
|
||||
sides_on: true,
|
||||
gpu_culling: None,
|
||||
@@ -5244,7 +5290,6 @@ impl Renderer {
|
||||
solid_runs: page_runs::PageRuns::default(),
|
||||
phase2_runs: page_runs::PageRuns::default(),
|
||||
shadow_runs: page_runs::PageRuns::default(),
|
||||
external: None,
|
||||
block_entities: Default::default(),
|
||||
block_entity_section_revisions: Default::default(),
|
||||
block_entity_membership_revisions: Default::default(),
|
||||
@@ -5628,9 +5673,6 @@ impl Renderer {
|
||||
self.offscreen = Some(Self::offscreen_target(&self.device, &self.config));
|
||||
}
|
||||
self.depth = Self::depth(&self.device, &self.config);
|
||||
if let Some(pipeline) = &mut self.external {
|
||||
pipeline.resize([width, height]);
|
||||
}
|
||||
}
|
||||
/// Whether sections are drawn as one index list per frame (`flat`).
|
||||
pub fn flat_draws(&self) -> bool {
|
||||
@@ -6716,17 +6758,13 @@ impl Renderer {
|
||||
flat.set_translucent_page_vertices(arena.page_vertices());
|
||||
}
|
||||
}
|
||||
/// Uploads solid quads in the compact layout (`packed_quads`) unless a pack reads the
|
||||
/// arena's vertices as `TerrainVertex`es: Horizon's own packs' stages, and a `shaders/`
|
||||
/// pack's terrain and shadow programs outside flat draws (flat ones unpack the records,
|
||||
/// `pack_atlas::PULL_FLAT`). Leaving it drops every section and far tile for the game to
|
||||
/// mesh again, as `follow_translucent_layout` does.
|
||||
/// Uploads solid quads in the compact layout (`packed_quads`) while no shader pack runs:
|
||||
/// a pack's terrain and shadow programs, and Horizon's own packs' stages, read the
|
||||
/// arena's vertices as `TerrainVertex`es. Leaving it drops every section and far tile
|
||||
/// for the game to mesh again, as `follow_translucent_layout` does.
|
||||
fn follow_packed_layout(&mut self) {
|
||||
let pack = self.pack_post.as_ref().and_then(|p| p.1.as_ref()).is_some();
|
||||
let want = self.flat.is_some()
|
||||
&& (!pack || self.pack_terrain_flat)
|
||||
&& self.native.is_none()
|
||||
&& self.packed_on;
|
||||
let want = self.flat.is_some() && !pack && self.native.is_none() && packed_wanted();
|
||||
if want == self.packed_quads {
|
||||
return;
|
||||
}
|
||||
@@ -6737,10 +6775,6 @@ impl Renderer {
|
||||
let waiting = self.hold.take_all();
|
||||
self.place_held(waiting);
|
||||
}
|
||||
log::info!(
|
||||
"Horizon: compact solid terrain {}",
|
||||
if want { "on" } else { "off" }
|
||||
);
|
||||
self.packed_quads = want;
|
||||
packed::pack_quads(want);
|
||||
let laid_out = self.sections.values().any(|s| s.packed)
|
||||
@@ -6750,17 +6784,6 @@ impl Renderer {
|
||||
self.clear_sections(self.layout);
|
||||
}
|
||||
}
|
||||
/// Allows the compact layout of solid quads or not, for A/B checks: every section and far
|
||||
/// tile is meshed again in the layout that follows. Replies whether sections now use it.
|
||||
pub fn set_packed_layout(&mut self, on: bool) -> bool {
|
||||
if on != self.packed_on {
|
||||
self.packed_on = on;
|
||||
self.follow_packed_layout();
|
||||
self.sections_dropped = true;
|
||||
self.clear_sections(self.layout);
|
||||
}
|
||||
self.packed_quads
|
||||
}
|
||||
/// Whether every section and far tile was dropped since the last call, to be
|
||||
/// laid out again (`follow_translucent_layout`): the game meshes them again,
|
||||
/// as for a new renderer.
|
||||
@@ -7203,8 +7226,7 @@ impl Renderer {
|
||||
.as_ref()
|
||||
.map_or([1, 1], |t| [t.width(), t.height()]),
|
||||
terrain: self.pack_atlas.as_ref().map(|_| pack_post::Geometry {
|
||||
// Flat terrain programs pull their vertices (`pack_atlas::PULL_FLAT`).
|
||||
buffers: if flat { &[] } else { &buffers },
|
||||
buffers: if flat { &buffers[..1] } else { &buffers },
|
||||
table: self.flat.as_ref().filter(|_| flat).map(|f| &f.draw_layout),
|
||||
depth_write: true,
|
||||
atlas: None,
|
||||
@@ -7267,19 +7289,12 @@ impl Renderer {
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
/// Sets the external shader pipeline world frames call (`external`), or none.
|
||||
pub fn set_external_pipeline(&mut self, pipeline: Option<Box<dyn external::ExternalPipeline>>) {
|
||||
self.external = pipeline;
|
||||
}
|
||||
/// Makes the running shader pack, of either kind, forget what it kept from earlier frames:
|
||||
/// the next frame draws as a freshly loaded pack's first. Nothing to do without a pack.
|
||||
pub fn reset_shader_pack_history(&mut self) {
|
||||
// A frame still on the submit thread uses the targets and readbacks replaced
|
||||
// here.
|
||||
self.land_frame();
|
||||
if let Some(pipeline) = &mut self.external {
|
||||
pipeline.reset_history();
|
||||
}
|
||||
if let Some((_, Some(post))) = &mut self.pack_post {
|
||||
post.reset_history(&self.device);
|
||||
}
|
||||
@@ -7381,8 +7396,7 @@ impl Renderer {
|
||||
lightmap,
|
||||
gui_size,
|
||||
terrain: terrain.then(|| pack_post::Geometry {
|
||||
// Flat terrain programs pull their vertices (`pack_atlas::PULL_FLAT`).
|
||||
buffers: if flat { &[] } else { &buffers },
|
||||
buffers: if flat { &buffers[..1] } else { &buffers },
|
||||
table: table.as_ref(),
|
||||
depth_write: true,
|
||||
atlas: None,
|
||||
@@ -7547,12 +7561,15 @@ impl Renderer {
|
||||
);
|
||||
horizon_shaders::GeometryInputs {
|
||||
inputs,
|
||||
// Flat terrain inputs pull their own vertex (`PULL_FLAT`, `gl_VertexIndex` → `hz_vertex_index`).
|
||||
pull: String::new(),
|
||||
pull: pack_atlas::PULL.to_owned(),
|
||||
index: "hz_indices[@]".to_owned(),
|
||||
}
|
||||
});
|
||||
horizon_shaders::GameInputs {
|
||||
terrain_parts: terrain
|
||||
.as_ref()
|
||||
.map(|t| pack_atlas::terrain_parts(t, &self.pack_lava_layers)),
|
||||
terrain,
|
||||
terrain_geometry,
|
||||
entity_geometry: Some(pack_entities::geometry_inputs()),
|
||||
sky: Some(pack_sky::inputs()),
|
||||
@@ -7562,7 +7579,6 @@ impl Renderer {
|
||||
weather: Some(pack_weather::inputs()),
|
||||
lines: Some(pack_lines::inputs()),
|
||||
hand: Some(pack_hand::inputs()),
|
||||
..pack_atlas::game_terrain(terrain, &self.pack_lava_layers)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8613,13 +8629,6 @@ impl Renderer {
|
||||
// A shader pack's shadow pass draws the sections in its camera's box, all of them (the
|
||||
// occlusion graph follows the eye, not the light), within shadowDistanceRenderMul; for
|
||||
// an Iris-format pack, only those whose shadow can fall into the camera's view.
|
||||
let state = external::FrameState {
|
||||
frame,
|
||||
output: &output,
|
||||
};
|
||||
if let Some(pipeline) = self.external.as_mut().filter(|_| !frame.title) {
|
||||
pipeline.begin_frame(&state);
|
||||
}
|
||||
let shadow_view = self
|
||||
.pack_post
|
||||
.as_ref()
|
||||
@@ -8631,12 +8640,6 @@ impl Renderer {
|
||||
let native = self.native.as_ref().filter(|_| !frame.title)?;
|
||||
native.shadow.as_ref()?;
|
||||
native.frame.shadow_view(frame)
|
||||
})
|
||||
.or_else(|| {
|
||||
self.external
|
||||
.as_ref()
|
||||
.filter(|_| !frame.title)?
|
||||
.shadow_request(&state)
|
||||
});
|
||||
// `LevelRenderer.extractSectionDrawGroups`' fade-in time, for the instances.
|
||||
let fade_ms = fade_millis(frame.chunk_fade);
|
||||
@@ -11926,12 +11929,13 @@ impl Renderer {
|
||||
{
|
||||
self.readback.present(&self.device, &mut encoder, image);
|
||||
}
|
||||
let lifts_world = output::lifts_world(
|
||||
frame.title,
|
||||
self.output.frame.format == LINEAR_FRAME,
|
||||
self.shader_pack_kind() == Some("iris"),
|
||||
self.native.as_ref().map(|n| n.frame.api),
|
||||
);
|
||||
// A 0.2 pack is shown at paper white: HDR output lifts nothing of its world.
|
||||
let lifts_world = !frame.title
|
||||
&& self.output.frame.format != LINEAR_FRAME
|
||||
&& self
|
||||
.native
|
||||
.as_ref()
|
||||
.is_none_or(|n| n.frame.api == horizon_shader_api::Api::V0_1);
|
||||
match (&self.hdr, &surface_frame) {
|
||||
(Some(hdr), Some(f)) => hdr.present(
|
||||
&self.queue,
|
||||
@@ -12767,6 +12771,61 @@ impl Cull for Frusta {
|
||||
self.0.contains(min, max) || self.1.is_some_and(|f| f.contains(min, max))
|
||||
}
|
||||
}
|
||||
/// 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 block entities of `sections` whose box, a block wider on every side, comes within the
|
||||
/// 64 blocks block entities are drawn to (`Renderer::visible_block_entities_in_frustum`).
|
||||
fn block_entities_within_reach<'a>(
|
||||
@@ -12789,6 +12848,17 @@ fn block_entities_within_reach<'a>(
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
/// 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`.
|
||||
fn shadow_box(view: &pack_post::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 }
|
||||
}
|
||||
/// Camera-relative clip planes (`Frustum`).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Frustum {
|
||||
@@ -12912,6 +12982,21 @@ fn projection_center(view_proj: Mat4) -> Option<Vec3> {
|
||||
let b = DVec3::new(rows[0].w, rows[1].w, rows[2].w);
|
||||
Some((a.inverse() * -b).as_vec3())
|
||||
}
|
||||
/// 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.
|
||||
fn shadow_runs(
|
||||
sides: &Sides,
|
||||
view: &pack_post::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);
|
||||
}
|
||||
|
||||
/// Calls `draw(first, quads)` for each run of a mesh's solid quads that can
|
||||
/// face `eye` (`projection_center`), the mesh at the camera-relative
|
||||
@@ -12938,6 +13023,68 @@ fn facing_runs_from(
|
||||
None => sides.every(part, 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.
|
||||
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]
|
||||
}
|
||||
fn taa_frustum(view_proj: Mat4, rendered_size: [u32; 2]) -> Frustum {
|
||||
// TAA shifts clip x/y by at most half a rendered pixel times clip w.
|
||||
// Widen x/y before extracting the planes: this includes every such shifted
|
||||
@@ -14947,21 +15094,11 @@ mod sprite_upload_tests {
|
||||
limit_bucket: None,
|
||||
};
|
||||
info.backend = wgpu::Backend::Metal;
|
||||
assert_eq!(adapter_requested_split(&info, None, 1200), Some(512));
|
||||
assert_eq!(
|
||||
adapter_requested_split(&info, None, 512 * SPRITE_ARRAYS),
|
||||
Some(512)
|
||||
);
|
||||
assert_eq!(adapter_requested_split(&info, Some(1024), 1200), Some(1024));
|
||||
// More sprites than the arrays hold at 512: the arrays grow, up to the GPU's layers.
|
||||
let split = adapter_requested_split(&info, None, 10_271);
|
||||
assert_eq!(split, Some(1284));
|
||||
assert_eq!(layer_split(2048, false, split, 10_271), Ok(1284));
|
||||
let split = adapter_requested_split(&info, None, 2048 * SPRITE_ARRAYS + 1);
|
||||
assert!(layer_split(2048, false, split, 2048 * SPRITE_ARRAYS + 1).is_err());
|
||||
assert_eq!(adapter_requested_split(&info, None), Some(512));
|
||||
assert_eq!(adapter_requested_split(&info, Some(1024)), Some(1024));
|
||||
|
||||
info.backend = wgpu::Backend::Vulkan;
|
||||
assert_eq!(adapter_requested_split(&info, None, 10_271), None);
|
||||
assert_eq!(adapter_requested_split(&info, None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -15169,8 +15306,8 @@ fn log_os_reported(reported: Option<crate::graphics::Reported>) {
|
||||
});
|
||||
}
|
||||
/// What the OS reports in absolute nits, HDR output's scale (Windows: DXGI's SDR white level,
|
||||
/// the brightness slider's, as Paper White, and its `MaxLuminance`, the display's or a calibration
|
||||
/// profile's, as Peak Brightness), kept within the settings' ranges. The player's values win.
|
||||
/// the brightness slider's, as Paper White, and its `MaxLuminance`, mostly the display's EDID
|
||||
/// guess, as Peak Brightness), kept within the settings' ranges. The player's values win.
|
||||
fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option<crate::graphics::Reported> {
|
||||
let luminance = info.luminance?;
|
||||
let usable = |n: f32| n.is_finite() && n > 0.0;
|
||||
@@ -15246,7 +15383,7 @@ mod shadow_face_tests {
|
||||
}
|
||||
|
||||
/// The layers (faces) of the quads the shadow pass draws of `solid`.
|
||||
fn drawn(view: &external::ShadowView, origin: [f32; 3]) -> Vec<u16> {
|
||||
fn drawn(view: &pack_post::ShadowView, origin: [f32; 3]) -> Vec<u16> {
|
||||
let mut solid = block([7.0, 7.0, 7.0]);
|
||||
let sides = crate::mesher::sort_tinted(&mut solid, &mut Vec::new());
|
||||
let mut layers = Vec::new();
|
||||
@@ -15259,8 +15396,8 @@ mod shadow_face_tests {
|
||||
layers
|
||||
}
|
||||
|
||||
fn view(every_face: bool) -> external::ShadowView {
|
||||
external::ShadowView {
|
||||
fn view(every_face: bool) -> pack_post::ShadowView {
|
||||
pack_post::ShadowView {
|
||||
cull: Mat4::IDENTITY,
|
||||
light: Vec3::Y,
|
||||
distance: None,
|
||||
@@ -15474,7 +15611,7 @@ mod shadow_caster_tests {
|
||||
..horizon_shaders::Shadow::default()
|
||||
};
|
||||
let camera = DVec3::new(13.5, y + 1.62, -13.5);
|
||||
let view = external::ShadowView {
|
||||
let view = pack_post::ShadowView {
|
||||
cull: pack_uniforms::shadow_culling_at(Vec3::Y, camera, &shadow),
|
||||
light: Vec3::Y,
|
||||
distance: None,
|
||||
|
||||
@@ -134,7 +134,9 @@ impl super::Renderer {
|
||||
if let Some(frame) = frame {
|
||||
for (source, program) in &modes.programs {
|
||||
let configs = Default::default();
|
||||
if let Some(value) = crate::environment::eval_frame(program, frame, &configs) {
|
||||
if let Some(value) =
|
||||
crate::pack_formats::environment::eval_frame(program, frame, &configs)
|
||||
{
|
||||
modes.values.insert(source.clone(), value.num());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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,
|
||||
@@ -524,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),
|
||||
@@ -636,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;
|
||||
}
|
||||
@@ -677,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
|
||||
@@ -740,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;
|
||||
@@ -816,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,
|
||||
@@ -883,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,
|
||||
@@ -891,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
|
||||
@@ -972,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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -169,8 +169,7 @@ pub struct Hdr {
|
||||
hand_mark: wgpu::RenderPipeline,
|
||||
params: wgpu::Buffer,
|
||||
/// The same pass drawing HDR10 for a screenshot (`capture`), with its own parameters,
|
||||
/// so it can differ from the swapchain's in one frame, and without the swapchain's dither:
|
||||
/// a screenshot keeps the exact codes.
|
||||
/// so it can differ from the swapchain's in one frame.
|
||||
capture_pipeline: wgpu::RenderPipeline,
|
||||
capture_params: wgpu::Buffer,
|
||||
pub output: Output,
|
||||
@@ -287,9 +286,8 @@ impl Hdr {
|
||||
entries: &entries,
|
||||
})
|
||||
};
|
||||
// One full-screen pass of hdr.wgsl's `entry` into `format`, reading `layout`; `dither`
|
||||
// for the last pass into the HDR10 swapchain (`pq_dither`).
|
||||
let pass = |label, layout: &wgpu::BindGroupLayout, entry, format, dither: bool| {
|
||||
// One full-screen pass of hdr.wgsl's `entry` into `format`, reading `layout`.
|
||||
let pass = |label, layout: &wgpu::BindGroupLayout, entry, format| {
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some(label),
|
||||
bind_group_layouts: &[Some(layout)],
|
||||
@@ -310,10 +308,7 @@ impl Hdr {
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some(entry),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &[("pq_dither", f64::from(u8::from(dither)))],
|
||||
..Default::default()
|
||||
},
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
@@ -326,8 +321,7 @@ impl Hdr {
|
||||
};
|
||||
let layout_bindings: &[u32] = if coverage { &[0, 3, 4, 5] } else { &[0, 1, 3] };
|
||||
let display = layout("Horizon HDR bindings", layout_bindings, true);
|
||||
let pipeline_for =
|
||||
|format, dither| pass("Horizon HDR", &display, entry_point, format, dither);
|
||||
let pipeline_for = |format| pass("Horizon HDR", &display, entry_point, format);
|
||||
let params = || {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
@@ -339,7 +333,7 @@ impl Hdr {
|
||||
let layered = coverage.then(|| {
|
||||
let with = |label, textures: &[u32], params, entry, format| {
|
||||
let layout = layout(label, textures, params);
|
||||
let pipeline = pass(label, &layout, entry, format, false);
|
||||
let pipeline = pass(label, &layout, entry, format);
|
||||
(layout, pipeline)
|
||||
};
|
||||
let hud = super::hud_coverage::HUD_FORMAT;
|
||||
@@ -401,8 +395,8 @@ impl Hdr {
|
||||
);
|
||||
Self {
|
||||
logo: logo_mask(device, queue),
|
||||
pipeline: pipeline_for(output.format(), output == Output::Pq),
|
||||
capture_pipeline: pipeline_for(Output::Pq.format(), false),
|
||||
pipeline: pipeline_for(output.format()),
|
||||
capture_pipeline: pipeline_for(Output::Pq.format()),
|
||||
layout: display,
|
||||
clear_alpha,
|
||||
hand_mark,
|
||||
@@ -1715,32 +1709,19 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
|
||||
hdr.begin(&queue, true);
|
||||
hdr.present(&queue, &mut encoder, &target, true, None);
|
||||
// HDR10's exact codes are a screenshot's copy: the swapchain's own get a
|
||||
// code of dither either way.
|
||||
let exact = (output == Output::Pq)
|
||||
.then(|| hdr.capture(&device, &queue, &mut encoder, true).unwrap());
|
||||
let presented = texel(&device, &queue, encoder, &target);
|
||||
let exact = exact.map(|copy| {
|
||||
let encoder = device.create_command_encoder(&Default::default());
|
||||
texel(&device, &queue, encoder, ©)
|
||||
});
|
||||
for (got, codes) in [(Some(presented), 2.0), (exact, 1.0)] {
|
||||
let Some(got) = got else {
|
||||
continue;
|
||||
let got = texel(&device, &queue, encoder, &target);
|
||||
for c in &got[..3] {
|
||||
let c = f64::from(*c);
|
||||
let ok = match output {
|
||||
// 10-bit codes, a step either way for rounding.
|
||||
Output::Pq => (c - 1023.0 * pq(want)).abs() <= 1.0,
|
||||
// 80 nits a unit, in half floats.
|
||||
Output::ScRgb => (c * 80.0 - want).abs() <= 2e-3 * want + 1e-4,
|
||||
};
|
||||
for c in &got[..3] {
|
||||
let c = f64::from(*c);
|
||||
let ok = match output {
|
||||
// 10-bit codes, a step either way for rounding.
|
||||
Output::Pq => (c - 1023.0 * pq(want)).abs() <= codes,
|
||||
// 80 nits a unit, in half floats.
|
||||
Output::ScRgb => (c * 80.0 - want).abs() <= 2e-3 * want + 1e-4,
|
||||
};
|
||||
assert!(
|
||||
ok,
|
||||
"{output:?} black {black}, light {light}: {got:?}, not {want} nits"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
ok,
|
||||
"{output:?} black {black}, light {light}: {got:?}, not {want} nits"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2097,7 +2078,3 @@ mod lighting_tests {
|
||||
#[cfg(test)]
|
||||
#[path = "hdr_written_tests.rs"]
|
||||
mod written_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "hdr_dither_tests.rs"]
|
||||
mod dither_tests;
|
||||
@@ -1,313 +0,0 @@
|
||||
//! HDR10's dither (`hdr.wgsl`, `dithered`): the last pass into the 10-bit swapchain, and what
|
||||
//! must stay without it.
|
||||
use super::{
|
||||
Hdr, Output,
|
||||
tests::{fill, gpu},
|
||||
};
|
||||
use wgpu::TextureFormat as F;
|
||||
|
||||
const SIZE: [u32; 2] = [64, 1024];
|
||||
/// The ramp: grey rising by one half-float step every `ROWS` rows from `FIRST / 2048`. That is
|
||||
/// three PQ codes down the picture around 91 nits: as slow as a clear sky, and as fine as the
|
||||
/// float frame holds.
|
||||
const FIRST: u32 = 1434;
|
||||
const ROWS: u32 = 52;
|
||||
|
||||
/// SMPTE ST 2084: nits as a 10-bit PQ code, before rounding.
|
||||
fn code(nits: f64) -> f64 {
|
||||
let y = (nits / 10_000.0).powf(0.1593017578125);
|
||||
1023.0 * ((0.8359375 + 18.8515625 * y) / (1.0 + 18.6875 * y)).powf(78.84375)
|
||||
}
|
||||
|
||||
/// The ramp's row `y` as a PQ code, at the 200-nit paper white `Hdr` starts with.
|
||||
fn ramp_code(y: u32) -> f64 {
|
||||
code(200.0 * (f64::from(FIRST + y / ROWS) / 2048.0).powf(2.2))
|
||||
}
|
||||
|
||||
fn ramp(device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, frame: &wgpu::Texture) {
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
format!(
|
||||
r#"
|
||||
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {{
|
||||
return vec4(vec2(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
|
||||
}}
|
||||
@fragment fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {{
|
||||
return vec4(vec3(f32({FIRST}u + u32(pos.y) / {ROWS}u) / 2048.0), 1.0);
|
||||
}}
|
||||
"#
|
||||
)
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(frame.format().into())],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let view = frame.create_view(&Default::default());
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
color_attachments: &[Some(super::super::hud_coverage::attachment(&view))],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
/// What `hdr` writes for the float frame `paint` fills, with no HUD over it: its last pass into
|
||||
/// a swapchain image of its output (`Hdr::present`), or a screenshot's HDR10 copy
|
||||
/// (`Hdr::capture`). Every pixel's bytes, row by row.
|
||||
fn shown(
|
||||
(device, queue): &(wgpu::Device, wgpu::Queue),
|
||||
hdr: &mut Hdr,
|
||||
screenshot: bool,
|
||||
paint: impl Fn(&mut wgpu::CommandEncoder, &wgpu::Texture),
|
||||
) -> Vec<u8> {
|
||||
let frame = hdr.frame(device, SIZE);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
paint(&mut encoder, &frame);
|
||||
hdr.keep_world(&mut encoder, None);
|
||||
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
|
||||
let image = if screenshot {
|
||||
hdr.capture(device, queue, &mut encoder, true).unwrap()
|
||||
} else {
|
||||
let image = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: frame.size(),
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: hdr.output.format(),
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
hdr.present(queue, &mut encoder, &image, true, None);
|
||||
image
|
||||
};
|
||||
let row = SIZE[0] * image.format().block_copy_size(None).unwrap();
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(row * SIZE[1]),
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
image.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(row),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
image.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
buffer.get_mapped_range(..).unwrap().to_vec()
|
||||
}
|
||||
|
||||
fn float_hdr((device, queue): &(wgpu::Device, wgpu::Queue), output: Output) -> Hdr {
|
||||
Hdr::with_debug(device, queue, F::Rgba16Float, output, true, None)
|
||||
}
|
||||
|
||||
/// An HDR10 picture's 10-bit codes, red, green and blue.
|
||||
fn codes(bytes: &[u8]) -> Vec<[u32; 3]> {
|
||||
let pixels = bytes.as_chunks::<4>().0.iter();
|
||||
pixels
|
||||
.map(|p| [0, 10, 20].map(|shift| (u32::from_le_bytes(*p) >> shift) & 0x3ff))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Down every column of a channel: the longest run of rows with one code, and the runs' mean
|
||||
/// length.
|
||||
fn runs(codes: &[[u32; 3]], channel: usize) -> (u32, f64) {
|
||||
let (mut longest, mut count) = (0, 0);
|
||||
for x in 0..SIZE[0] {
|
||||
let mut run = 0;
|
||||
for y in 0..SIZE[1] {
|
||||
let at = |y: u32| codes[(y * SIZE[0] + x) as usize][channel];
|
||||
if y > 0 && at(y) != at(y - 1) {
|
||||
(longest, count, run) = (longest.max(run), count + 1, 0);
|
||||
}
|
||||
run += 1;
|
||||
}
|
||||
(longest, count) = (longest.max(run), count + 1);
|
||||
}
|
||||
(longest, f64::from(SIZE[0] * SIZE[1]) / f64::from(count))
|
||||
}
|
||||
|
||||
/// Whether every row of a picture is one value from side to side: no noise in it.
|
||||
fn rows_are_flat(bytes: &[u8]) -> bool {
|
||||
let pixel = bytes.len() / (SIZE[0] * SIZE[1]) as usize;
|
||||
bytes
|
||||
.chunks(pixel * SIZE[0] as usize)
|
||||
.all(|row| row.chunks(pixel).all(|p| p == &row[..pixel]))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slow_gradient_gets_grain_instead_of_bands_on_hdr10() {
|
||||
let Some(gpu) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let ramp = |encoder: &mut wgpu::CommandEncoder, frame: &wgpu::Texture| {
|
||||
ramp(&gpu.0, encoder, frame);
|
||||
};
|
||||
let mut hdr = float_hdr(&gpu, Output::Pq);
|
||||
// The ramp is what the test says: three codes, top to bottom.
|
||||
let span = ramp_code(SIZE[1] - 1) - ramp_code(0);
|
||||
assert!((2.9..3.2).contains(&span), "{span}");
|
||||
// Without dither, as a screenshot's copy stays: each row its nearest code, in bands hundreds
|
||||
// of rows tall. A GPU need not round at exactly half a code: an NVIDIA one rounds up from
|
||||
// 0.55 here, which shows as 0.05 of a code less, so the bounds below leave a quarter code.
|
||||
let plain = codes(&shown(&gpu, &mut hdr, true, ramp));
|
||||
let dithered = codes(&shown(&gpu, &mut hdr, false, ramp));
|
||||
let want = |i: usize| ramp_code(i as u32 / SIZE[0]);
|
||||
for channel in 0..3 {
|
||||
for (i, p) in plain.iter().enumerate() {
|
||||
let off = (f64::from(p[channel]) - want(i)).abs();
|
||||
assert!(
|
||||
off <= 0.75,
|
||||
"plain {channel} at {i}: {p:?}, not {}",
|
||||
want(i)
|
||||
);
|
||||
}
|
||||
let (longest, _) = runs(&plain, channel);
|
||||
assert!(longest >= 250, "plain {channel}: bands of {longest} rows");
|
||||
// With it: each pixel within the noise's one code of its light (and half a code of
|
||||
// rounding), no column holds a code for long, and a run is a few rows on average.
|
||||
for (i, d) in dithered.iter().enumerate() {
|
||||
let off = (f64::from(d[channel]) - want(i)).abs();
|
||||
assert!(
|
||||
off <= 1.75,
|
||||
"dithered {channel} at {i}: {d:?}, not {}",
|
||||
want(i)
|
||||
);
|
||||
}
|
||||
let (longest, mean) = runs(&dithered, channel);
|
||||
eprintln!(
|
||||
"HDR10 dither GPU, channel {channel}: longest run {longest} rows, mean {mean:.2}"
|
||||
);
|
||||
assert!(longest <= 64, "dithered {channel}: a run of {longest} rows");
|
||||
assert!(mean < 4.0, "dithered {channel}: runs of {mean} rows");
|
||||
// The mean of each 64-row block is the light meant.
|
||||
let mut worst = 0.0_f64;
|
||||
for block in 0..SIZE[1] / 64 {
|
||||
let rows = (block * 64 * SIZE[0]) as usize..((block + 1) * 64 * SIZE[0]) as usize;
|
||||
let n = rows.len() as f64;
|
||||
let got = dithered[rows.clone()]
|
||||
.iter()
|
||||
.map(|d| f64::from(d[channel]))
|
||||
.sum::<f64>()
|
||||
/ n;
|
||||
let meant = rows.map(want).sum::<f64>() / n;
|
||||
worst = worst.max((got - meant).abs());
|
||||
assert!(
|
||||
(got - meant).abs() < 0.25,
|
||||
"block {block} of {channel}: {got}, not {meant}"
|
||||
);
|
||||
}
|
||||
eprintln!("HDR10 dither GPU, channel {channel}: block means within {worst:.3} of a code");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr10s_dither_keeps_both_ends_exact_and_leans_to_them_in_their_last_code() {
|
||||
let Some(gpu) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let mut hdr = float_hdr(&gpu, Output::Pq);
|
||||
let grey = |g: f64| {
|
||||
move |encoder: &mut wgpu::CommandEncoder, frame: &wgpu::Texture| {
|
||||
fill(encoder, frame, [g, g, g, 1.0]);
|
||||
}
|
||||
};
|
||||
// How far the picture's mean code is from `end`.
|
||||
let mean_from = |picture: &[[u32; 3]], end: f64| {
|
||||
let sum: f64 = picture.iter().flatten().map(|c| f64::from(*c)).sum();
|
||||
(sum / (picture.len() * 3) as f64 - end).abs()
|
||||
};
|
||||
// Within a code of an end the noise shrinks to the distance to it. A third of a code from
|
||||
// the end, the signal plus that noise passes half a code only where the noise is over half
|
||||
// its range, an eighth of a triangle: so the picture's mean is an eighth of a code from the
|
||||
// end, not a third. That lean to the end, in the last code alone, is the price of keeping
|
||||
// the end itself exact. Bound: a GPU that rounds a tenth of a code off the half (as above)
|
||||
// shows between 0.02 and 0.32 of a code, always short of the third.
|
||||
let leans = |mean: f64| (0.015..0.325).contains(&mean);
|
||||
|
||||
// Black gets no noise: every pixel stays off.
|
||||
let black = codes(&shown(&gpu, &mut hdr, false, grey(0.0)));
|
||||
assert!(black.iter().all(|p| *p == [0; 3]));
|
||||
// A third of a code above black (this grey is a half float): 0 or 1, never lifted past it.
|
||||
let dim = 0.00038433074951171875;
|
||||
let meant = code(200.0 * f64::powf(dim, 2.2));
|
||||
assert!((meant - 1.0 / 3.0).abs() < 0.001, "{meant}");
|
||||
let near_black = codes(&shown(&gpu, &mut hdr, false, grey(dim)));
|
||||
assert!(near_black.iter().flatten().all(|c| *c <= 1));
|
||||
let mean = mean_from(&near_black, 0.0);
|
||||
eprintln!("HDR10 dither GPU: a third of a code above black shows as {mean:.3}");
|
||||
assert!(leans(mean), "{mean} above black");
|
||||
|
||||
// The top of the range, PQ's 10 000 nits, stays the top: nothing wraps or dims. Light past
|
||||
// the peak is held at it, so the Peak setting is the signal here.
|
||||
hdr.peak = 10_000.0;
|
||||
let top = codes(&shown(&gpu, &mut hdr, false, grey(20.0)));
|
||||
assert!(top.iter().all(|p| *p == [1023; 3]));
|
||||
// A third of a code under the top, mirrored: 1022 or 1023.
|
||||
let (mut under, mut over) = (9000.0, 10_000.0);
|
||||
for _ in 0..60 {
|
||||
let middle = (under + over) / 2.0;
|
||||
if code(middle) < 1023.0 - 1.0 / 3.0 {
|
||||
under = middle;
|
||||
} else {
|
||||
over = middle;
|
||||
}
|
||||
}
|
||||
hdr.peak = under as f32;
|
||||
let meant = code(f64::from(hdr.peak));
|
||||
assert!((1023.0 - meant - 1.0 / 3.0).abs() < 0.001, "{meant}");
|
||||
let near_top = codes(&shown(&gpu, &mut hdr, false, grey(20.0)));
|
||||
assert!(near_top.iter().flatten().all(|c| *c >= 1022));
|
||||
let mean = mean_from(&near_top, 1023.0);
|
||||
eprintln!("HDR10 dither GPU: a third of a code under the top shows as {mean:.3} under");
|
||||
assert!(leans(mean), "{mean} under the top");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrgb_and_screenshots_get_no_dither() {
|
||||
let Some(gpu) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let ramp = |encoder: &mut wgpu::CommandEncoder, frame: &wgpu::Texture| {
|
||||
ramp(&gpu.0, encoder, frame);
|
||||
};
|
||||
let (mut hdr10, mut scrgb) = (float_hdr(&gpu, Output::Pq), float_hdr(&gpu, Output::ScRgb));
|
||||
// scRGB's swapchain is float: every row is one value from side to side.
|
||||
assert!(rows_are_flat(&shown(&gpu, &mut scrgb, false, ramp)));
|
||||
// A screenshot's HDR10 copy is the same bytes whichever swapchain is presented, the
|
||||
// dithered one included, and has no noise in it.
|
||||
let screenshot = shown(&gpu, &mut hdr10, true, ramp);
|
||||
assert!(rows_are_flat(&screenshot));
|
||||
assert_eq!(screenshot, shown(&gpu, &mut scrgb, true, ramp));
|
||||
// The swapchain's own picture differs from it: the dither is on there.
|
||||
assert_ne!(screenshot, shown(&gpu, &mut hdr10, false, ramp));
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
use glam::{Mat4, Vec3};
|
||||
use horizon_shader_api::{
|
||||
Api, Frame as FrameInputs, Primaries, StageKind, check::CheckedStage, stages::GROUP,
|
||||
Api, Frame as FrameInputs, StageKind, check::CheckedStage, stages::GROUP,
|
||||
};
|
||||
|
||||
use super::Frame;
|
||||
@@ -1596,11 +1596,7 @@ pub fn frame_inputs(
|
||||
paper_white: 80.0,
|
||||
peak: 80.0,
|
||||
black_level: 0.0,
|
||||
// No pack is served another space yet (`[frame] primaries`).
|
||||
primaries: Primaries::Bt709 as u32,
|
||||
luminance_gap: [0; 2],
|
||||
luminance: Primaries::Bt709.luminance(),
|
||||
padding: 0,
|
||||
padding: [0; 3],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,8 +42,6 @@ const DISTANT_SQR: f64 = 3600.0;
|
||||
const SECTION_DIAGONAL: f64 = 28.0;
|
||||
/// In `Graph::rays`: the line from this corner stays in reached sections.
|
||||
const SEES: u8 = u8::MAX;
|
||||
/// Edits kept before a full resend.
|
||||
const EDIT_CAP: usize = 1 << 16;
|
||||
|
||||
/// VisGraph connectivity per column, one entry per section (bottom first).
|
||||
/// A missing column or section connects every face.
|
||||
@@ -613,8 +611,6 @@ pub struct Occlusion {
|
||||
queue: VecDeque<u32>,
|
||||
/// Changes whenever `contains` may answer differently (`generation`).
|
||||
generation: u64,
|
||||
/// Set when edits were dropped; the next search resends every column.
|
||||
resync: bool,
|
||||
}
|
||||
|
||||
impl Occlusion {
|
||||
@@ -635,7 +631,6 @@ impl Occlusion {
|
||||
vis: Vec::new(),
|
||||
queue: VecDeque::new(),
|
||||
generation: next_generation(),
|
||||
resync: false,
|
||||
}
|
||||
}
|
||||
pub fn set(&mut self, key: SectionKey, vis: u64) {
|
||||
@@ -644,35 +639,13 @@ impl Occlusion {
|
||||
}
|
||||
}
|
||||
fn changed(&mut self, key: SectionKey, vis: u64) {
|
||||
self.dirty = true;
|
||||
if self.edits.len() >= EDIT_CAP || self.changed.len() >= EDIT_CAP {
|
||||
// nobody drains these with cave culling off
|
||||
self.resync = true;
|
||||
self.edits.clear();
|
||||
self.changed.clear();
|
||||
self.changed_late.clear();
|
||||
}
|
||||
if self.resync {
|
||||
return;
|
||||
}
|
||||
self.edits.push(Edit::Set(key, vis));
|
||||
self.dirty = true;
|
||||
self.changed.push(key);
|
||||
if self.running.is_some() {
|
||||
self.changed_late.push(key);
|
||||
}
|
||||
}
|
||||
fn resync_edits(&mut self) {
|
||||
self.resync = false;
|
||||
self.edits.clear();
|
||||
self.edits.push(Edit::Clear);
|
||||
for (&(x, z), column) in &self.columns.map {
|
||||
for (y, &vis) in column.iter().enumerate() {
|
||||
if vis != ALL_VISIBLE {
|
||||
self.edits.push(Edit::Set((x, y as i32, z), vis));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Every section of the column `x`, `z` goes (sees through every face).
|
||||
pub fn remove_column(&mut self, x: i32, z: i32) {
|
||||
let Some(column) = self.columns.map.remove(&(x, z)) else {
|
||||
@@ -701,7 +674,6 @@ impl Occlusion {
|
||||
self.columns.map.clear();
|
||||
self.edits.clear();
|
||||
self.edits.push(Edit::Clear);
|
||||
self.resync = false;
|
||||
self.dirty = true;
|
||||
// Another world: the old graph says nothing about it. Without one, every
|
||||
// section was visible already, and stays so.
|
||||
@@ -797,9 +769,6 @@ impl Occlusion {
|
||||
let from = self.columns.section_of(camera);
|
||||
self.dirty = false;
|
||||
self.searched = Some((from, radius));
|
||||
if self.resync {
|
||||
self.resync_edits();
|
||||
}
|
||||
let graph = std::mem::take(&mut self.spare);
|
||||
if let Some(worker) = &self.worker {
|
||||
let job = Job {
|
||||
@@ -1824,36 +1793,4 @@ mod tests {
|
||||
assert_ne!(answers(&o, around, spread + radius), walled.1);
|
||||
assert_ne!(o.generation(), walled.0, "the graph went");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_queue_stays_bounded_without_searches() {
|
||||
let mut o = Occlusion::new(0, 4);
|
||||
let total = (1usize << 16) + 5000;
|
||||
for i in 0..total {
|
||||
o.set(((i % 700) as i32, 1, (i / 700) as i32), 1 + (i as u64 % 5));
|
||||
}
|
||||
assert!(o.edits.len() <= 1 << 16, "{} edits queued", o.edits.len());
|
||||
assert!(
|
||||
o.changed.len() <= 1 << 16,
|
||||
"{} changed queued",
|
||||
o.changed.len()
|
||||
);
|
||||
|
||||
let mut fresh = Occlusion::new(0, 4);
|
||||
for (&(x, z), column) in &o.columns.map {
|
||||
for (y, &vis) in column.iter().enumerate() {
|
||||
fresh.set((x, y as i32, z), vis);
|
||||
}
|
||||
}
|
||||
let camera = at((0, 1, 0));
|
||||
for _ in 0..2 {
|
||||
o.settle(camera, 6);
|
||||
fresh.settle(camera, 6);
|
||||
}
|
||||
assert_eq!(
|
||||
reached(&o),
|
||||
reached(&fresh),
|
||||
"the worker saw the same world"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -295,22 +295,6 @@ pub(crate) fn resolve_output(
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether HDR output lifts the world's light-source marks above paper white. Not on the title
|
||||
/// screen or the float frame, and not under a pack that lights its world itself: a 0.2 pack is
|
||||
/// shown at paper white, and a `shaders/` pack's `final` pass leaves its own alpha in the
|
||||
/// frame, which isn't marks (HDR lighting is off under it too).
|
||||
pub(crate) fn lifts_world(
|
||||
title: bool,
|
||||
float_frame: bool,
|
||||
shaders_pack: bool,
|
||||
native_api: Option<horizon_shader_api::Api>,
|
||||
) -> bool {
|
||||
!title
|
||||
&& !float_frame
|
||||
&& !shaders_pack
|
||||
&& native_api.is_none_or(|api| api == horizon_shader_api::Api::V0_1)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
@@ -353,29 +337,6 @@ pub(crate) mod tests {
|
||||
resolve_output(settings, caps, reading, frame, true)
|
||||
}
|
||||
|
||||
/// HDR output lifts light-source marks only on the 8/16-bit frame's world with no pack or
|
||||
/// a 0.1 pack: a `shaders/` pack's alpha isn't marks, and a 0.2 pack is shown at paper white.
|
||||
#[test]
|
||||
fn only_horizons_own_gamma_world_is_lifted() {
|
||||
use horizon_shader_api::Api::{V0_1, V0_2};
|
||||
// (title, float frame, `shaders/` pack, Horizon pack) -> lifts
|
||||
let cases = [
|
||||
((false, false, false, None), true),
|
||||
((false, false, false, Some(V0_1)), true),
|
||||
((false, false, true, None), false),
|
||||
((false, false, false, Some(V0_2)), false),
|
||||
((false, true, false, None), false),
|
||||
((true, false, false, None), false),
|
||||
];
|
||||
for ((title, float, shaders, api), lifts) in cases {
|
||||
assert_eq!(
|
||||
lifts_world(title, float, shaders, api),
|
||||
lifts,
|
||||
"title {title}, float {float}, shaders/ pack {shaders}, pack {api:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every mode against every offer, system report, pack and device: HDR output only where
|
||||
/// the mode asks for it and the destination offers it, and the frame each combination
|
||||
/// draws in, as `Renderer::new` picked it before the snapshot.
|
||||
|
||||
@@ -548,107 +548,27 @@ impl PackAtlas {
|
||||
}
|
||||
}
|
||||
|
||||
/// GLSL: terrain.wgsl's `section_corner` over the arena page (`hz_vertices`), by quad: a
|
||||
/// vertex's words as `TerrainVertex` holds them (position, then UV, layer with light, colour),
|
||||
/// from vertices below `PACKED_BASE` or a compact record (`mesher/packed.rs`) past it. Records
|
||||
/// give back their quad's vertices bit for bit, so packs read either layout alike.
|
||||
/// `hz_arena_corner`: a vertex's position and UV words; `hz_record_corner`: corner `k`'s of the
|
||||
/// record `lo`, `hi`; `hz_record_rest`: its layer-with-light and colour words.
|
||||
const CORNER_FN: &str = "void hz_arena_corner(uint hz_av, out uvec3 hz_p, out uint hz_uv) {\n \
|
||||
uint hz_w = hz_av * 6u;\n \
|
||||
hz_p = uvec3(hz_vertices[hz_w], hz_vertices[hz_w + 1u], hz_vertices[hz_w + 2u]);\n \
|
||||
hz_uv = hz_vertices[hz_w + 3u];\n\
|
||||
}\n\
|
||||
uint hz_corner_offset(uint hz_rel, uint hz_side, uint hz_du, uint hz_dw) {\n \
|
||||
return hz_rel == 0u ? hz_side : (hz_rel == 1u ? hz_du : hz_dw);\n\
|
||||
}\n\
|
||||
void hz_record_corner(uvec3 hz_lo, uvec3 hz_hi, uint hz_k, out uvec3 hz_p, out uint hz_uv) {\n \
|
||||
uint hz_o = (hz_hi.z >> 12u) & 0x3ffffu;\n \
|
||||
uvec3 hz_block = uvec3(hz_o & 15u, (hz_o >> 4u) & 15u, (hz_o >> 8u) & 15u);\n \
|
||||
uint hz_axis = (hz_o >> 12u) & 3u;\n \
|
||||
uint hz_side = (hz_o >> 14u) & 1u;\n \
|
||||
uint hz_step = (((hz_o >> 15u) & 3u) + (((hz_o >> 17u) & 1u) == 1u ? 4u - hz_k : hz_k)) & 3u;\n \
|
||||
uint hz_du = (hz_step == 1u || hz_step == 2u) ? 1u : 0u;\n \
|
||||
uint hz_dw = hz_step >= 2u ? 1u : 0u;\n \
|
||||
uvec3 hz_pos = hz_block + uvec3(hz_corner_offset((3u - hz_axis) % 3u, hz_side, hz_du, hz_dw),\n \
|
||||
hz_corner_offset((4u - hz_axis) % 3u, hz_side, hz_du, hz_dw),\n \
|
||||
hz_corner_offset((5u - hz_axis) % 3u, hz_side, hz_du, hz_dw));\n \
|
||||
uint hz_sel = hz_lo.z >> 24u;\n \
|
||||
uint hz_u = (hz_lo.z >> (6u * ((hz_sel >> (2u * hz_k)) & 1u))) & 63u;\n \
|
||||
uint hz_v = (hz_lo.z >> (12u + 6u * ((hz_sel >> (2u * hz_k + 1u)) & 1u))) & 63u;\n \
|
||||
hz_p = floatBitsToUint(vec3(hz_pos));\n \
|
||||
hz_uv = (hz_u << 10u) | (hz_v << 26u);\n\
|
||||
}\n\
|
||||
uvec2 hz_record_rest(uvec3 hz_lo, uvec3 hz_hi, uint hz_k) {\n \
|
||||
uint hz_l = hz_k == 0u ? hz_hi.x >> 16u : (hz_k == 1u ? hz_hi.y : (hz_k == 2u ? hz_hi.y >> 12u : hz_hi.z));\n \
|
||||
hz_l &= 0xfffu;\n \
|
||||
uint hz_pl = ((hz_l & 63u) << 2u) | (((hz_l >> 6u) & 63u) << 10u);\n \
|
||||
uint hz_g = (hz_lo.y >> (8u * hz_k)) & 255u;\n \
|
||||
uint hz_t = hz_lo.x;\n \
|
||||
uint hz_code = hz_k == 1u ? (hz_hi.x >> 28u) & 7u : (hz_k == 2u ? (hz_hi.y >> 24u) & 7u\n \
|
||||
: (hz_k == 3u ? (hz_hi.y >> 27u) & 7u : 0u));\n \
|
||||
uint hz_alpha = hz_code == 0u ? hz_t & 0xff000000u : (hz_code * 51u) << 24u;\n \
|
||||
uint hz_c = (hz_g * ((hz_t >> 16u) & 255u) / 255u) | ((hz_g * ((hz_t >> 8u) & 255u) / 255u) << 8u)\n \
|
||||
| ((hz_g * (hz_t & 255u) / 255u) << 16u) | hz_alpha;\n \
|
||||
return uvec2((hz_hi.x & 0xffffu) | (hz_pl << 16u), hz_c);\n\
|
||||
}\n";
|
||||
|
||||
/// GLSL: a flat-drawn vertex's inputs, and its quad's four corners' positions (`hz_cp0`..) and
|
||||
/// UV words (`hz_uv0`..), pulled from the arena page as terrain.wgsl's `vs_section_flat` pulls
|
||||
/// them (`CORNER_FN`): a record is read once for all four, and only the vertex's own light and
|
||||
/// colour are worked out. `hz_slot`: the slot its block is found by (`hz_block_slots`, 256 a
|
||||
/// block). The colour as the vertex fetch converts a Unorm8x4 (terrain.wgsl's `unorm8x4`).
|
||||
const PULL_FLAT: &str = " uint hz_qv = uint(gl_VertexIndex);\n \
|
||||
uint hz_own = hz_qv & 3u;\n \
|
||||
uint hz_av = hz_qv & ~3u;\n \
|
||||
uint hz_slot = hz_qv;\n \
|
||||
bool hz_rec = false;\n \
|
||||
uvec3 hz_lo = uvec3(0u);\n \
|
||||
uvec3 hz_hi = uvec3(0u);\n \
|
||||
if (hz_av >= 0x10000000u) {\n \
|
||||
hz_slot = (hz_av - 0x10000000u) >> 2u;\n \
|
||||
uint hz_rw = hz_slot * 6u;\n \
|
||||
hz_lo = uvec3(hz_vertices[hz_rw], hz_vertices[hz_rw + 1u], hz_vertices[hz_rw + 2u]);\n \
|
||||
hz_hi = uvec3(hz_vertices[hz_rw + 3u], hz_vertices[hz_rw + 4u], hz_vertices[hz_rw + 5u]);\n \
|
||||
hz_rec = (hz_hi.z & 0x80000000u) == 0u;\n \
|
||||
hz_av = hz_slot + hz_lo.x;\n \
|
||||
}\n \
|
||||
uvec3 hz_cp0; uvec3 hz_cp1; uvec3 hz_cp2; uvec3 hz_cp3;\n \
|
||||
uint hz_uv0; uint hz_uv1; uint hz_uv2; uint hz_uv3;\n \
|
||||
uvec2 hz_rest;\n \
|
||||
if (hz_rec) {\n \
|
||||
hz_record_corner(hz_lo, hz_hi, 0u, hz_cp0, hz_uv0);\n \
|
||||
hz_record_corner(hz_lo, hz_hi, 1u, hz_cp1, hz_uv1);\n \
|
||||
hz_record_corner(hz_lo, hz_hi, 2u, hz_cp2, hz_uv2);\n \
|
||||
hz_record_corner(hz_lo, hz_hi, 3u, hz_cp3, hz_uv3);\n \
|
||||
hz_rest = hz_record_rest(hz_lo, hz_hi, hz_own);\n \
|
||||
} else {\n \
|
||||
hz_arena_corner(hz_av, hz_cp0, hz_uv0);\n \
|
||||
hz_arena_corner(hz_av + 1u, hz_cp1, hz_uv1);\n \
|
||||
hz_arena_corner(hz_av + 2u, hz_cp2, hz_uv2);\n \
|
||||
hz_arena_corner(hz_av + 3u, hz_cp3, hz_uv3);\n \
|
||||
uint hz_ow = (hz_av + hz_own) * 6u;\n \
|
||||
hz_rest = uvec2(hz_vertices[hz_ow + 4u], hz_vertices[hz_ow + 5u]);\n \
|
||||
}\n \
|
||||
uvec3 hz_vp = hz_own == 0u ? hz_cp0 : (hz_own == 1u ? hz_cp1 : (hz_own == 2u ? hz_cp2 : hz_cp3));\n \
|
||||
uvec3 hz_vr = uvec3(hz_own == 0u ? hz_uv0 : (hz_own == 1u ? hz_uv1 : (hz_own == 2u ? hz_uv2 : hz_uv3)), hz_rest);\n \
|
||||
hz_v_pos = uintBitsToFloat(hz_vp);\n \
|
||||
hz_v_uv = uvec2(hz_vr.x & 65535u, hz_vr.x >> 16u);\n \
|
||||
hz_v_layer = hz_vr.y & 65535u;\n \
|
||||
hz_v_light = uvec2((hz_vr.y >> 16u) & 255u, hz_vr.y >> 24u);\n \
|
||||
hz_v_color = vec4(float(hz_vr.z & 255u), float((hz_vr.z >> 8u) & 255u), float((hz_vr.z >> 16u) & 255u),\n \
|
||||
float(hz_vr.z >> 24u)) * (1.0 / 255.0);\n";
|
||||
/// A `TerrainVertex` of flat draws' arena page (`hz_vertices`, six words each) into the vertex
|
||||
/// inputs, for vertex `hz_vertex_index`.
|
||||
pub const PULL: &str = " uint hz_pw = hz_vertex_index * 6u;\n \
|
||||
hz_v_pos = uintBitsToFloat(uvec3(hz_vertices[hz_pw], hz_vertices[hz_pw + 1u], hz_vertices[hz_pw + 2u]));\n \
|
||||
hz_v_uv = uvec2(hz_vertices[hz_pw + 3u] & 65535u, hz_vertices[hz_pw + 3u] >> 16u);\n \
|
||||
hz_v_layer = hz_vertices[hz_pw + 4u] & 65535u;\n \
|
||||
uint hz_pl = hz_vertices[hz_pw + 4u] >> 16u;\n \
|
||||
hz_v_light = uvec2(hz_pl & 255u, hz_pl >> 8u);\n \
|
||||
hz_v_color = unpackUnorm4x8(hz_vertices[hz_pw + 5u]);\n";
|
||||
|
||||
/// GLSL: the face normal and tangent of a flat-drawn vertex, from its quad's first three corners
|
||||
/// (`PULL_FLAT`'s corners: four consecutive vertex indices, in either layout).
|
||||
const FACE: &str = " vec3 hz_p0 = uintBitsToFloat(hz_cp0);\n \
|
||||
vec3 hz_p1 = uintBitsToFloat(hz_cp1);\n \
|
||||
vec3 hz_p2 = uintBitsToFloat(hz_cp2);\n \
|
||||
uvec3 hz_w = uvec3(hz_uv0, hz_uv1, hz_uv2);\n \
|
||||
/// (quads are four consecutive vertices of the page; position words 0-2, UV word 3).
|
||||
const FACE: &str = " uint hz_q = (uint(gl_VertexIndex) & ~3u) * 6u;\n \
|
||||
vec3 hz_p0 = uintBitsToFloat(uvec3(hz_vertices[hz_q], hz_vertices[hz_q + 1u], hz_vertices[hz_q + 2u]));\n \
|
||||
vec3 hz_p1 = uintBitsToFloat(uvec3(hz_vertices[hz_q + 6u], hz_vertices[hz_q + 7u], hz_vertices[hz_q + 8u]));\n \
|
||||
vec3 hz_p2 = uintBitsToFloat(uvec3(hz_vertices[hz_q + 12u], hz_vertices[hz_q + 13u], hz_vertices[hz_q + 14u]));\n \
|
||||
uvec3 hz_w = uvec3(hz_vertices[hz_q + 3u], hz_vertices[hz_q + 9u], hz_vertices[hz_q + 15u]);\n \
|
||||
vec2 hz_t0 = vec2(hz_w.x & 65535u, hz_w.x >> 16u);\n \
|
||||
vec2 hz_d1 = vec2(hz_w.y & 65535u, hz_w.y >> 16u) - hz_t0;\n \
|
||||
vec2 hz_d2 = vec2(hz_w.z & 65535u, hz_w.z >> 16u) - hz_t0;\n \
|
||||
uint hz_w3 = hz_uv3;\n \
|
||||
uint hz_w3 = hz_vertices[hz_q + 21u];\n \
|
||||
vec2 hz_t3 = vec2(hz_w3 & 65535u, hz_w3 >> 16u);\n \
|
||||
hz_mid = (clamp(hz_t0 * hz_uv_scale, 0.0, 1.0) + clamp((hz_t0 + hz_d1) * hz_uv_scale, 0.0, 1.0)\n \
|
||||
+ clamp((hz_t0 + hz_d2) * hz_uv_scale, 0.0, 1.0) + clamp(hz_t3 * hz_uv_scale, 0.0, 1.0)) * 0.25;\n \
|
||||
@@ -666,71 +586,45 @@ const FACE: &str = " vec3 hz_p0 = uintBitsToFloat(hz_cp0);\n \
|
||||
}\n \
|
||||
}\n";
|
||||
|
||||
/// GLSL: whether a terrain vertex's quad is solid terrain rather than cutout. Solid: the quads
|
||||
/// over opaque texels and opaque leaves (`mesher::OPAQUE_LAYER`), as vanilla picks
|
||||
/// `ChunkSectionLayer.SOLID` per quad (`FaceBakery.computeMaterialTransparency`,
|
||||
/// `SectionCompiler.compile`), and lava's faces (sprite layers `lava`, its still and flowing
|
||||
/// sprites), which Iris 1.11.7 drew with `gbuffers_terrain_solid` in our probes, with and
|
||||
/// without `separateAo`.
|
||||
/// `terrain` (`PackAtlas::terrain_inputs`) drawing only solid terrain, then only cutout terrain:
|
||||
/// each leaves the other part's quads outside the clip volume. Solid: the quads over opaque
|
||||
/// texels and opaque leaves (`mesher::OPAQUE_LAYER`), as vanilla picks `ChunkSectionLayer.SOLID`
|
||||
/// per quad (`FaceBakery.computeMaterialTransparency`, `SectionCompiler.compile`), and lava's
|
||||
/// faces (sprite layers `lava`, its still and flowing sprites), which Iris 1.11.7 drew with
|
||||
/// `gbuffers_terrain_solid` in our probes, with and without `separateAo`.
|
||||
///
|
||||
/// The test reads only the vertex's sprite layer, which all four vertices of a quad share: a
|
||||
/// quad lands whole in one part. The colour's alpha can't decide it, as with `separateAo` it
|
||||
/// holds each vertex's own ambient occlusion.
|
||||
fn solid(lava: &[u16]) -> String {
|
||||
pub fn terrain_parts(
|
||||
terrain: &horizon_shaders::GbuffersInputs,
|
||||
lava: &[u16],
|
||||
) -> [horizon_shaders::GbuffersInputs; 2] {
|
||||
let opaque = crate::mesher::OPAQUE_LAYER;
|
||||
let lava: String = lava
|
||||
.iter()
|
||||
.map(|l| format!(" || hz_v_layer == {l}u"))
|
||||
.collect();
|
||||
format!("(hz_v_layer & {opaque}u) != 0u{lava}")
|
||||
}
|
||||
|
||||
/// The game's `terrain` inputs (`PackAtlas::terrain_inputs`) for a pack: whole; drawing only
|
||||
/// solid terrain, then only cutout terrain (`solid`), each leaving the other part's quads outside
|
||||
/// the clip volume; and drawing both in one pass, each quad with its part's alpha test.
|
||||
pub fn game_terrain(
|
||||
terrain: Option<horizon_shaders::GbuffersInputs>,
|
||||
lava: &[u16],
|
||||
) -> horizon_shaders::GameInputs {
|
||||
let solid = solid(lava);
|
||||
let part = |terrain: &horizon_shaders::GbuffersInputs, part: bool| {
|
||||
let part = |solid: bool| {
|
||||
let mut inputs = terrain.clone();
|
||||
inputs.after.push_str(&format!(
|
||||
" if (({solid}) != {part}) gl_Position = vec4(2.0, 2.0, 2.0, 1.0);\n"
|
||||
" bool hz_solid = (hz_v_layer & {opaque}u) != 0u{lava};\n \
|
||||
if (hz_solid != {solid}) gl_Position = vec4(2.0, 2.0, 2.0, 1.0);\n"
|
||||
));
|
||||
inputs
|
||||
};
|
||||
horizon_shaders::GameInputs {
|
||||
terrain_parts: terrain.as_ref().map(|t| [part(t, true), part(t, false)]),
|
||||
terrain_one_pass: terrain.as_ref().map(|t| horizon_shaders::GbuffersInputs {
|
||||
parts: Some(horizon_shaders::Parts {
|
||||
solid: solid.clone(),
|
||||
cutout_alpha_test: None,
|
||||
}),
|
||||
..t.clone()
|
||||
}),
|
||||
terrain,
|
||||
..Default::default()
|
||||
}
|
||||
[part(true), part(false)]
|
||||
}
|
||||
|
||||
impl PackAtlas {
|
||||
/// The vertex inputs of a gbuffers program drawing terrain (`TerrainVertex`, terrain.wgsl's
|
||||
/// `vs_terrain` or, for `flat` draws, `vs_terrain_flat`), in atlas coordinates.
|
||||
pub fn terrain_inputs(&self, flat: bool) -> horizon_shaders::GbuffersInputs {
|
||||
// Flat draws pull their vertices from the arena page (`PULL_FLAT`), which may hold
|
||||
// solid quads in the compact layout; others read them as vertex attributes.
|
||||
let mut declarations = if flat {
|
||||
String::from(
|
||||
"vec3 hz_v_pos; uvec2 hz_v_uv; uint hz_v_layer; vec4 hz_v_color; uvec2 hz_v_light;\n",
|
||||
)
|
||||
} else {
|
||||
String::from(
|
||||
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in uvec2 hz_v_uv;\n\
|
||||
layout(location = 2) in uint hz_v_layer;\nlayout(location = 3) in vec4 hz_v_color;\n\
|
||||
layout(location = 4) in uvec2 hz_v_light;\n",
|
||||
)
|
||||
};
|
||||
let mut declarations = String::from(
|
||||
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in uvec2 hz_v_uv;\n\
|
||||
layout(location = 2) in uint hz_v_layer;\nlayout(location = 3) in vec4 hz_v_color;\n\
|
||||
layout(location = 4) in uvec2 hz_v_light;\n",
|
||||
);
|
||||
let origin = if flat {
|
||||
declarations.push_str(
|
||||
"layout(set = 1, binding = 0) readonly buffer HzBlockSlots { uint hz_block_slots[]; };\n\
|
||||
@@ -743,8 +637,7 @@ impl PackAtlas {
|
||||
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\n\
|
||||
layout(set = 3, binding = 2) readonly buffer HzTerrainTables { int hz_tables[]; };\n",
|
||||
);
|
||||
declarations.push_str(CORNER_FN);
|
||||
"hz_slot_origins[hz_block_slots[hz_slot >> 8u]]"
|
||||
"hz_slot_origins[hz_block_slots[uint(gl_VertexIndex) >> 8u]]"
|
||||
} else {
|
||||
declarations.push_str("layout(location = 5) in vec4 hz_v_origin;\n");
|
||||
"hz_v_origin"
|
||||
@@ -807,7 +700,6 @@ impl PackAtlas {
|
||||
);
|
||||
let mut body = body;
|
||||
if flat {
|
||||
body.insert_str(0, PULL_FLAT);
|
||||
body.push_str(FACE);
|
||||
body.push_str(
|
||||
" uint hz_quad = uint(gl_VertexIndex) / 4u;\n \
|
||||
@@ -870,7 +762,6 @@ impl PackAtlas {
|
||||
.collect(),
|
||||
after: String::new(),
|
||||
alpha_test: None,
|
||||
parts: None,
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
|
||||
@@ -125,7 +125,7 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
|
||||
terrain: Some(atlas.terrain_inputs(flat)),
|
||||
terrain_geometry: geometry.then(|| horizon_shaders::GeometryInputs {
|
||||
inputs: atlas.terrain_inputs(true),
|
||||
pull: String::new(),
|
||||
pull: super::PULL.to_owned(),
|
||||
index: "@".to_owned(),
|
||||
}),
|
||||
..Default::default()
|
||||
|
||||
@@ -245,135 +245,6 @@ fn terrain_vertices_go_down_to_the_32768th_below_them() {
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
/// A shader pack's flat terrain inputs read a compact record (`mesher::packed`) as the four
|
||||
/// vertices it holds: a whole tinted face with its own ambient occlusion per corner
|
||||
/// (`separateAo`), two more, and a slab's top kept as its vertices (`packed::FULL`).
|
||||
#[test]
|
||||
fn compact_records_feed_a_pack_the_vertices_they_hold() {
|
||||
use crate::mesher::packed;
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 16,
|
||||
height: 16,
|
||||
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 | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let mut atlas = PackAtlas::new(&device, &mut encoder, &[texture], 1, 1, 16).unwrap();
|
||||
queue.submit([encoder.finish()]);
|
||||
atlas.separate_ao = true;
|
||||
let tint = 0x7F_B238u32;
|
||||
let ao = [255u32, 153, 102, 204];
|
||||
let face = |x: f32, y: f32| {
|
||||
let pos = [
|
||||
[x, y, 5.0],
|
||||
[x, y, 6.0],
|
||||
[x + 1.0, y, 6.0],
|
||||
[x + 1.0, y, 5.0],
|
||||
];
|
||||
let uv = [[0, 0], [16 << 10, 0], [16 << 10, 16 << 10], [0, 16 << 10]];
|
||||
(0..4).map(move |i| {
|
||||
let [r, g, b] = [16, 8, 0].map(|s| 200 * (tint >> s & 255) / 255);
|
||||
TerrainVertex {
|
||||
pos: pos[i],
|
||||
uv: uv[i],
|
||||
layer: 0x8000,
|
||||
light: 0xF0 | 0x40 << 8,
|
||||
color: r | g << 8 | b << 16 | ao[i] << 24,
|
||||
}
|
||||
})
|
||||
};
|
||||
let solid: Vec<_> = [(3.0, 16.0), (5.0, 16.0), (7.0, 16.0), (3.0, 8.5)]
|
||||
.into_iter()
|
||||
.flat_map(|(x, y)| face(x, y))
|
||||
.collect();
|
||||
let records = packed::layout(&solid, &[tint; 4]);
|
||||
assert_eq!(
|
||||
packed::packed_quads(&records, 4),
|
||||
3,
|
||||
"whole faces pack, the slab doesn't"
|
||||
);
|
||||
let page = bytemuck::cast_slice(&records);
|
||||
let packed_range = packed::PACKED_BASE..packed::PACKED_BASE + 16;
|
||||
let origin = f32::from_bits((1. / UV_STEPS).to_bits());
|
||||
let mut failures = Vec::new();
|
||||
for expression in [
|
||||
"gl_Vertex",
|
||||
"gl_Color",
|
||||
"vec4(gl_MultiTexCoord0.xy, gl_MultiTexCoord1.xy)",
|
||||
"vec4(gl_Normal, 0.0)",
|
||||
"at_tangent",
|
||||
"at_midBlock",
|
||||
"vec4(mc_midTexCoord, mc_Entity.xy)",
|
||||
] {
|
||||
for shadow in [false, true] {
|
||||
let flags = [true, shadow];
|
||||
let wide = sample_probe(&device, &queue, &atlas, &solid, origin, flags, expression);
|
||||
let compact = sample_page(
|
||||
&device,
|
||||
&queue,
|
||||
&atlas,
|
||||
page,
|
||||
packed_range.clone(),
|
||||
origin,
|
||||
flags,
|
||||
expression,
|
||||
);
|
||||
let bits = |v: &[[f32; 4]]| v.iter().flatten().map(|f| f.to_bits()).collect::<Vec<_>>();
|
||||
if bits(&wide) != bits(&compact) {
|
||||
failures.push(format!(
|
||||
"{expression} shadow={shadow}: {wide:?} != {compact:?}"
|
||||
));
|
||||
}
|
||||
if expression == "gl_Color" {
|
||||
for (corner, want) in ao.iter().enumerate() {
|
||||
let got = compact[corner][3];
|
||||
if (got - *want as f32 / 255.0).abs() > 1e-6 {
|
||||
failures.push(format!("corner {corner}'s alpha {got} != {want}/255"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A pack's frames keep the translucent quads in the section arena after the solid range: three
|
||||
// records and no `FULL` quad, then a water quad, whose corners must read as its own.
|
||||
let mut page: Vec<[u32; 6]> = packed::layout(&solid[..12], &[tint; 3]);
|
||||
let water: Vec<_> = face(9.0, 15.0).collect();
|
||||
let at = page.len() as u32;
|
||||
page.extend(water.iter().map(|v| bytemuck::cast::<_, [u32; 6]>(*v)));
|
||||
for expression in ["vec4(gl_Normal, 0.0)", "at_tangent", "at_midBlock"] {
|
||||
let flags = [true, false];
|
||||
let wide = sample_probe(&device, &queue, &atlas, &water, origin, flags, expression);
|
||||
let page = bytemuck::cast_slice(&page);
|
||||
let after = sample_page(
|
||||
&device,
|
||||
&queue,
|
||||
&atlas,
|
||||
page,
|
||||
at..at + 4,
|
||||
origin,
|
||||
flags,
|
||||
expression,
|
||||
);
|
||||
if wide != after {
|
||||
failures.push(format!("water {expression}: {wide:?} != {after:?}"));
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
fn sample(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
@@ -400,38 +271,18 @@ fn sample_probe(
|
||||
atlas: &PackAtlas,
|
||||
vertices: &[TerrainVertex],
|
||||
scale: f32,
|
||||
flags: [bool; 2],
|
||||
expression: &str,
|
||||
) -> Vec<[f32; 4]> {
|
||||
let page = bytemuck::cast_slice(vertices);
|
||||
let drawn = 0..vertices.len() as u32;
|
||||
sample_page(device, queue, atlas, page, drawn, scale, flags, expression)
|
||||
}
|
||||
|
||||
/// `sample_probe` of the vertex indices `drawn` of an arena page holding `page`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn sample_page(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
atlas: &PackAtlas,
|
||||
page: &[u8],
|
||||
drawn: std::ops::Range<u32>,
|
||||
scale: f32,
|
||||
[flat, shadow]: [bool; 2],
|
||||
expression: &str,
|
||||
) -> Vec<[f32; 4]> {
|
||||
let count = drawn.len() as u32;
|
||||
let pp = |code| horizon_shaders::Preprocessed {
|
||||
code,
|
||||
..Default::default()
|
||||
};
|
||||
let vertex = pp(format!(
|
||||
"attribute vec2 mc_midTexCoord;\nattribute vec4 at_tangent;\n\
|
||||
attribute vec4 at_midBlock;\nattribute vec4 mc_Entity;\nvarying vec4 probe;\nvoid main() {{\n\
|
||||
"attribute vec2 mc_midTexCoord;\nvarying vec4 probe;\nvoid main() {{\n\
|
||||
probe = {expression};\n\
|
||||
gl_Position = vec4((float(gl_VertexID - {}) + 0.5) * {} - 1.0, 0.0, 0.0, 1.0);\n}}",
|
||||
drawn.start,
|
||||
2. / count as f32,
|
||||
gl_Position = vec4((float(gl_VertexID) + 0.5) * {} - 1.0, 0.0, 0.0, 1.0);\n}}",
|
||||
2. / vertices.len() as f32,
|
||||
));
|
||||
// Integer attachments retain the float bits without requiring float-render-target
|
||||
// capabilities on the adapter that the test runner picks.
|
||||
@@ -518,8 +369,7 @@ fn sample_page(
|
||||
module: &vs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
// Flat draws pull their vertices from the page (`PULL_FLAT`), as in the game.
|
||||
buffers: if flat { &[] } else { &vertex_layouts[..] },
|
||||
buffers: &vertex_layouts[..if flat { 1 } else { 2 }],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::PointList,
|
||||
@@ -558,7 +408,7 @@ fn sample_page(
|
||||
);
|
||||
let ids = buffer(bytemuck::bytes_of(&[0u32; 2]), wgpu::BufferUsages::STORAGE);
|
||||
let data = buffer(
|
||||
page,
|
||||
bytemuck::cast_slice(vertices),
|
||||
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
|
||||
);
|
||||
let rects = buffer(
|
||||
@@ -594,7 +444,7 @@ fn sample_page(
|
||||
let output = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: count,
|
||||
width: vertices.len() as u32,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
@@ -607,7 +457,7 @@ fn sample_page(
|
||||
});
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(count) * 16,
|
||||
size: vertices.len() as u64 * 16,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
@@ -631,11 +481,11 @@ fn sample_page(
|
||||
for (k, group) in groups.iter().enumerate() {
|
||||
pass.set_bind_group(k as u32, group, &[]);
|
||||
}
|
||||
pass.set_vertex_buffer(0, data.slice(..));
|
||||
if !flat {
|
||||
pass.set_vertex_buffer(0, data.slice(..));
|
||||
pass.set_vertex_buffer(1, origin.slice(..));
|
||||
}
|
||||
pass.draw(drawn, 0..1);
|
||||
pass.draw(0..vertices.len() as u32, 0..1);
|
||||
}
|
||||
encoder.copy_texture_to_buffer(
|
||||
output.as_image_copy(),
|
||||
|
||||
@@ -125,7 +125,6 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
op: ">",
|
||||
reference: 0.1,
|
||||
}),
|
||||
parts: None,
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
// Entities, items and block entities read the shared atlas: its own maps (paged_maps.rs).
|
||||
@@ -193,7 +192,6 @@ pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
|
||||
.collect(),
|
||||
after: String::new(),
|
||||
alpha_test: None,
|
||||
parts: None,
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use horizon_shaders::{BufferObject, CustomImage, Format, Kind};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
pub(super) struct PackImage {
|
||||
pub(super) def: CustomImage,
|
||||
@@ -469,33 +468,19 @@ impl Buffers {
|
||||
.iter()
|
||||
.map(|(binding, o)| {
|
||||
let least = self.minimums.get(binding).copied().unwrap_or(0);
|
||||
let wanted = buffer_size(o, size, least);
|
||||
let wanted = buffer_size(*o, size, least);
|
||||
if wanted > largest {
|
||||
log::warn!(
|
||||
"shader pack: bufferObject.{binding} wants {wanted} bytes, this device \
|
||||
binds at most {largest}"
|
||||
);
|
||||
}
|
||||
let size = wanted.min(largest);
|
||||
// A buffer the pack initializes from a file starts with the file's bytes, the
|
||||
// rest of it zero.
|
||||
match o.initial_data() {
|
||||
Some(data) => {
|
||||
let mut contents = data.to_vec();
|
||||
contents.resize(size as usize, 0);
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Shader pack buffer"),
|
||||
contents: &contents,
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
})
|
||||
}
|
||||
None => device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack buffer"),
|
||||
size,
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
}
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack buffer"),
|
||||
size: wanted.min(largest),
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let entries: Vec<_> = self
|
||||
@@ -524,7 +509,7 @@ impl Buffers {
|
||||
/// The bytes a storage buffer is made with for a render size of `render`: what the pack
|
||||
/// declares, or more when a shader binds it as a larger block (`least`), which wgpu needs where
|
||||
/// OpenGL binds the smaller buffer; a multiple of 4.
|
||||
fn buffer_size(object: &BufferObject, render: [u32; 2], least: u64) -> u64 {
|
||||
fn buffer_size(object: BufferObject, render: [u32; 2], least: u64) -> u64 {
|
||||
object.size(render).max(least).next_multiple_of(4)
|
||||
}
|
||||
|
||||
@@ -627,18 +612,18 @@ mod tests {
|
||||
/// bound as a 48-byte block failed wgpu's validation, #7178); a larger one keeps its size.
|
||||
#[test]
|
||||
fn a_buffer_is_at_least_its_shaders_block() {
|
||||
assert_eq!(buffer_size(&BufferObject::Bytes(40), [1, 1], 48), 48);
|
||||
assert_eq!(buffer_size(BufferObject::Bytes(40), [1, 1], 48), 48);
|
||||
assert_eq!(
|
||||
buffer_size(&BufferObject::Bytes(40980), [1, 1], 40992),
|
||||
buffer_size(BufferObject::Bytes(40980), [1, 1], 40992),
|
||||
40992
|
||||
);
|
||||
assert_eq!(buffer_size(&BufferObject::Bytes(100), [1, 1], 48), 100);
|
||||
assert_eq!(buffer_size(&BufferObject::Bytes(41), [1, 1], 0), 44);
|
||||
assert_eq!(buffer_size(BufferObject::Bytes(100), [1, 1], 48), 100);
|
||||
assert_eq!(buffer_size(BufferObject::Bytes(41), [1, 1], 0), 44);
|
||||
let per_pixel = BufferObject::PerPixel {
|
||||
bytes: 4,
|
||||
scale: [0.5, 0.5],
|
||||
};
|
||||
assert_eq!(buffer_size(&per_pixel, [10, 10], 64), 100);
|
||||
assert_eq!(buffer_size(&per_pixel, [4, 4], 64), 64);
|
||||
assert_eq!(buffer_size(per_pixel, [10, 10], 64), 100);
|
||||
assert_eq!(buffer_size(per_pixel, [4, 4], 64), 64);
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,6 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
.collect(),
|
||||
after,
|
||||
alpha_test: None,
|
||||
parts: None,
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
|
||||
@@ -1201,7 +1201,31 @@ pub(super) type TerrainDraw<'a> = dyn FnMut(
|
||||
Option<(u32, &wgpu::BindGroupLayout)>,
|
||||
) + 'a;
|
||||
|
||||
pub(super) use super::external::ShadowView;
|
||||
/// 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(super) 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,
|
||||
}
|
||||
|
||||
/// An arena page's key, native vertices and optional per-quad source records.
|
||||
pub(super) type TerrainPage = (u32, wgpu::Buffer, Option<wgpu::Buffer>);
|
||||
@@ -2940,13 +2964,17 @@ impl PackPost {
|
||||
p.name
|
||||
));
|
||||
}
|
||||
match (&terrain, &terrain_cutout) {
|
||||
match (&terrain, &mut terrain_cutout) {
|
||||
(Some(t), None) => notes.push(format!("terrain draws with {}", t.name)),
|
||||
(t, Some(c)) => {
|
||||
if let Some(t) = t {
|
||||
notes.push(format!("solid terrain draws with {}", t.name));
|
||||
}
|
||||
notes.push(format!("cutout terrain draws with {}", c.name));
|
||||
// Timed (and labelled) apart from the solid part when it runs the same program.
|
||||
if t.as_ref().is_some_and(|t| t.name == c.name) {
|
||||
c.name.push_str(" (cutout)");
|
||||
}
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
@@ -3807,7 +3835,7 @@ impl PackPost {
|
||||
timestamp_writes: self
|
||||
.timing
|
||||
.as_mut()
|
||||
.and_then(|t| t.compute_writes(&format!("{} (compute)", pass.name))),
|
||||
.and_then(|t| t.compute_writes(&pass.name)),
|
||||
});
|
||||
cp.set_pipeline(&pipeline);
|
||||
cp.set_bind_group(0, &group, &[]);
|
||||
@@ -3883,8 +3911,8 @@ impl PackPost {
|
||||
}
|
||||
|
||||
/// Terrain (or, `translucent`, translucent terrain) with the pack's program; `draw` issues
|
||||
/// the game's draws. Solid terrain in two parts (a pack's `gbuffers_terrain_solid` and
|
||||
/// `_cutout`) has its cutout part drawn second, from the same draws.
|
||||
/// the game's draws. Solid terrain in two parts has its cutout part drawn second, from the
|
||||
/// same draws.
|
||||
fn draw_terrain(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
|
||||
@@ -322,7 +322,7 @@ fn check(sampler: &str, stage: Stage, scene: Scene, expected: [u8; 4]) {
|
||||
let hand = matches!(scene, Scene::Terrain { hand: true, .. });
|
||||
let water = matches!(scene, Scene::Terrain { water: true, .. } | Scene::WaterOnly);
|
||||
let inputs=horizon_shaders::GameInputs {
|
||||
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,parts:None,item_id_declared:false,far_depth:false,paged_maps:false,water_mask:false,water_patches:false }),
|
||||
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,item_id_declared:false,far_depth:false,paged_maps:false,water_mask:false,water_patches:false }),
|
||||
hand:hand.then(crate::gfx::pack_hand::inputs),..Default::default()
|
||||
};
|
||||
let mut post = gpu.load(
|
||||
@@ -717,7 +717,6 @@ fn shadow_depth_map_reads(
|
||||
attributes: vec![],
|
||||
after: String::new(),
|
||||
alpha_test: None,
|
||||
parts: None,
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
|
||||
@@ -48,7 +48,6 @@ fn pack(outputs: u32) -> Post {
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let inputs = horizon_shaders::GameInputs {
|
||||
terrain: Some(horizon_shaders::GbuffersInputs {
|
||||
parts: None,
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
|
||||
@@ -44,7 +44,6 @@ fn pack() -> Post {
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let inputs = horizon_shaders::GameInputs {
|
||||
terrain: Some(horizon_shaders::GbuffersInputs {
|
||||
parts: None,
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
|
||||
@@ -89,7 +89,14 @@ fn strips_of(
|
||||
.into(),
|
||||
..Default::default()
|
||||
};
|
||||
let inputs = super::super::pack_atlas::game_terrain(Some(terrain), &[LAVA, LAVA + 1]);
|
||||
let inputs = horizon_shaders::GameInputs {
|
||||
terrain_parts: Some(super::super::pack_atlas::terrain_parts(
|
||||
&terrain,
|
||||
&[LAVA, LAVA + 1],
|
||||
)),
|
||||
terrain: Some(terrain),
|
||||
..Default::default()
|
||||
};
|
||||
const ATTRS: [wgpu::VertexAttribute; 3] =
|
||||
wgpu::vertex_attr_array![0=>Float32x3,2=>Uint32,3=>Unorm8x4];
|
||||
let layouts = [Some(wgpu::VertexBufferLayout {
|
||||
@@ -339,64 +346,3 @@ fn alpha_test_ref_is_0_for_solid_terrain_and_0_5_for_cutout() {
|
||||
assert_eq!(got, [Some(RED), Some(YELLOW), Some(RED)], "{names:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `alphaTest.gbuffers_terrain` replaces both parts' tests when one program draws them, as it
|
||||
/// did when they drew in two passes: over `GREATER 0.25`, alpha 0.3 keeps cutout terrain too
|
||||
/// and 0.2 loses solid terrain too, and `alphaTestRef` reads 0.25 in both.
|
||||
#[test]
|
||||
fn a_packs_terrain_alpha_test_applies_to_both_parts() {
|
||||
let properties = || {
|
||||
(
|
||||
"shaders.properties".to_owned(),
|
||||
"alphaTest.gbuffers_terrain=GREATER 0.25\n".to_owned(),
|
||||
)
|
||||
};
|
||||
for (alpha, kept) in [(0.3, true), (0.2, false)] {
|
||||
let mut files = program("gbuffers_terrain", RED, alpha).to_vec();
|
||||
files.push(properties());
|
||||
let Some((got, _)) = strips_of(files, [255; 4]) else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(got, [kept.then_some(RED); 3], "alpha {alpha}");
|
||||
}
|
||||
let files = vec![
|
||||
(
|
||||
"gbuffers_terrain.vsh".to_owned(),
|
||||
"#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,0.0,1.0);}".to_owned(),
|
||||
),
|
||||
(
|
||||
"gbuffers_terrain.fsh".to_owned(),
|
||||
"#version 120\nuniform float alphaTestRef;\n/* DRAWBUFFERS:0 */\n\
|
||||
void main(){gl_FragData[0]=vec4(1.0,alphaTestRef*4.0,0.0,1.0);}"
|
||||
.to_owned(),
|
||||
),
|
||||
properties(),
|
||||
];
|
||||
let Some((got, _)) = strips_of(files, [255; 4]) else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(got, [Some([255, 255, 0]); 3]);
|
||||
}
|
||||
|
||||
/// The vertex stage reads its own quad's `alphaTestRef` too: 0 for solid, 0.5 for cutout.
|
||||
#[test]
|
||||
fn the_vertex_stage_reads_its_parts_alpha_test_ref() {
|
||||
let files = vec![
|
||||
(
|
||||
"gbuffers_terrain.vsh".to_owned(),
|
||||
"#version 120\nuniform float alphaTestRef;\nvarying float ref;\n\
|
||||
void main(){ref=alphaTestRef;gl_Position=vec4(gl_Vertex.xy,0.0,1.0);}"
|
||||
.to_owned(),
|
||||
),
|
||||
(
|
||||
"gbuffers_terrain.fsh".to_owned(),
|
||||
"#version 120\nvarying float ref;\n/* DRAWBUFFERS:0 */\n\
|
||||
void main(){gl_FragData[0]=vec4(1.0,ref*2.0,0.0,1.0);}"
|
||||
.to_owned(),
|
||||
),
|
||||
];
|
||||
let Some((got, _)) = strips_of(files, [255; 4]) else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(got, [Some(RED), Some([255, 255, 0]), Some(RED)]);
|
||||
}
|
||||
@@ -102,7 +102,6 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
// depth. Its fragments read their own depth, as in Iris.
|
||||
after: String::new(),
|
||||
alpha_test: None,
|
||||
parts: None,
|
||||
item_id_declared: false,
|
||||
far_depth: true,
|
||||
paged_maps: false,
|
||||
@@ -133,7 +132,6 @@ pub fn celestial_inputs() -> horizon_shaders::GbuffersInputs {
|
||||
],
|
||||
after: String::new(),
|
||||
alpha_test: None,
|
||||
parts: None,
|
||||
item_id_declared: false,
|
||||
far_depth: true,
|
||||
paged_maps: false,
|
||||
|
||||
@@ -151,9 +151,7 @@ impl PassTiming {
|
||||
Some(stamps(&self.queries, i))
|
||||
}
|
||||
|
||||
/// Timestamp writes for the compute pass `name`, in a timed frame. Shader packs' compute
|
||||
/// passes come as `<program> (compute)`, apart from the fragment program of the same name
|
||||
/// (`deferred2.csh` beside `deferred2.fsh`), whose time theirs would otherwise add to.
|
||||
/// Timestamp writes for the compute pass `name`, in a timed frame.
|
||||
pub fn compute_writes(&mut self, name: &str) -> Option<wgpu::ComputePassTimestampWrites<'_>> {
|
||||
let i = self.push(name)?;
|
||||
Some(compute_stamps(&self.queries, i))
|
||||
@@ -238,7 +236,23 @@ impl PassTiming {
|
||||
drop(data);
|
||||
self.read.unmap();
|
||||
let times = pass_times(&stamps, self.period_ns);
|
||||
record(&mut self.samples, self.names.iter().zip(times));
|
||||
for (name, ms) in self.names.iter().zip(times) {
|
||||
let Some(ms) = ms.filter(|_| !name.is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
let i = match self.samples.iter().position(|s| s.0 == *name) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
self.samples.push((name.clone(), VecDeque::new()));
|
||||
self.samples.len() - 1
|
||||
}
|
||||
};
|
||||
let window = &mut self.samples[i].1;
|
||||
if window.len() == WINDOW {
|
||||
window.pop_front();
|
||||
}
|
||||
window.push_back(ms);
|
||||
}
|
||||
self.reports += 1;
|
||||
if self.log && self.reports.is_multiple_of(REPORT) {
|
||||
let medians = self.medians();
|
||||
@@ -269,40 +283,6 @@ impl PassTiming {
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds one timed frame's pass times to `samples`, each pass's window of its last `WINDOW`
|
||||
/// frames. Passes of one name in a frame (a program drawn over several render passes) count as
|
||||
/// one sample, their sum: as separate samples they shared one window, and its median was the
|
||||
/// time of neither. Unnamed and unstamped passes are left out.
|
||||
fn record<'a>(
|
||||
samples: &mut Vec<(String, VecDeque<f32>)>,
|
||||
frame: impl Iterator<Item = (&'a String, Option<f32>)>,
|
||||
) {
|
||||
let mut sums: Vec<(&str, f32)> = Vec::new();
|
||||
for (name, ms) in frame {
|
||||
let Some(ms) = ms.filter(|_| !name.is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
match sums.iter_mut().find(|s| s.0 == name) {
|
||||
Some(s) => s.1 += ms,
|
||||
None => sums.push((name, ms)),
|
||||
}
|
||||
}
|
||||
for (name, ms) in sums {
|
||||
let i = match samples.iter().position(|s| s.0 == name) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
samples.push((name.to_owned(), VecDeque::new()));
|
||||
samples.len() - 1
|
||||
}
|
||||
};
|
||||
let window = &mut samples[i].1;
|
||||
if window.len() == WINDOW {
|
||||
window.pop_front();
|
||||
}
|
||||
window.push_back(ms);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pass `i`'s beginning and end timestamps.
|
||||
fn stamps(queries: &wgpu::QuerySet, i: u32) -> wgpu::RenderPassTimestampWrites<'_> {
|
||||
wgpu::RenderPassTimestampWrites {
|
||||
@@ -343,21 +323,7 @@ fn pass_times(stamps: &[u64], period_ns: f32) -> Vec<Option<f32>> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{pass_times, record};
|
||||
use std::collections::VecDeque;
|
||||
|
||||
#[test]
|
||||
fn passes_of_one_name_count_as_their_sum() {
|
||||
let names = ["terrain", "sky", "terrain", "", "water"].map(String::from);
|
||||
let times = [Some(1.0), Some(2.0), Some(3.0), Some(9.0), None];
|
||||
let mut samples: Vec<(String, VecDeque<f32>)> = Vec::new();
|
||||
record(&mut samples, names.iter().zip(times));
|
||||
let got: Vec<(&str, Vec<f32>)> = samples
|
||||
.iter()
|
||||
.map(|(n, w)| (n.as_str(), w.iter().copied().collect()))
|
||||
.collect();
|
||||
assert_eq!(got, [("terrain", vec![4.0]), ("sky", vec![2.0])]);
|
||||
}
|
||||
use super::pass_times;
|
||||
|
||||
#[test]
|
||||
fn overlapping_passes_cost_from_the_previous_end() {
|
||||
|
||||
@@ -1137,10 +1137,6 @@ fn components(name: &str) -> usize {
|
||||
| "endFlashPosition" => 3,
|
||||
"eyeBrightness" | "eyeBrightnessSmooth" | "atlasSize" => 2,
|
||||
"entityColor" => 4,
|
||||
// Horizon's own vec4s, a dynamic light and half a boat's water patch box; their counts
|
||||
// are ints.
|
||||
n if n.starts_with("horizonDynamicLight") && n != "horizonDynamicLightCount" => 4,
|
||||
n if n.starts_with("horizonWaterPatch") && n != "horizonWaterPatchCount" => 4,
|
||||
n if n.starts_with("gbuffer") || n.starts_with("shadow") && n != "shadowAngle" => 16,
|
||||
_ => 1,
|
||||
}
|
||||
@@ -1212,28 +1208,6 @@ mod tests {
|
||||
(&matrix, "gbufferModelView", "mat4", true),
|
||||
(&matrix, "gbufferModelView", "mat3", false),
|
||||
(&Value::i(&[1]), GBUFFERS_FOG_MODE, "int", true),
|
||||
// Horizon's own: a dynamic light and a water patch box half are vec4s, their counts
|
||||
// ints.
|
||||
(
|
||||
&Value::f(&[1.0, 64.0, 2.0, 14.0]),
|
||||
"horizonDynamicLight0",
|
||||
"vec4",
|
||||
true,
|
||||
),
|
||||
(
|
||||
&Value::f(&[1.0, 64.0, 2.0, 14.0]),
|
||||
"horizonDynamicLight0",
|
||||
"float",
|
||||
false,
|
||||
),
|
||||
(
|
||||
&Value::f(&[0.5, -1.0, 62.0, 62.5]),
|
||||
"horizonWaterPatch0",
|
||||
"vec4",
|
||||
true,
|
||||
),
|
||||
(&Value::i(&[3]), "horizonDynamicLightCount", "int", true),
|
||||
(&Value::i(&[1]), "horizonWaterPatchCount", "int", true),
|
||||
] {
|
||||
assert_eq!(value.read_as(name, ty), kept, "{ty} {name}");
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
op: ">",
|
||||
reference: 0.1,
|
||||
}),
|
||||
parts: None,
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
|
||||
@@ -399,20 +399,6 @@ impl Rotation {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// CubeMap's cube: its corners' indices, six faces of two triangles.
|
||||
#[test]
|
||||
fn the_packed_cube_corners_are_cubemaps_indices() {
|
||||
let indices = [
|
||||
4, 7, 6, 4, 6, 5, 5, 6, 2, 5, 2, 1, 1, 2, 3, 1, 3, 0, 0, 3, 7, 0, 7, 4, 7, 3, 2, 7, 2,
|
||||
6, 0, 4, 5, 0, 5, 1,
|
||||
];
|
||||
let source = include_str!("panorama.wgsl");
|
||||
assert_eq!(
|
||||
super::super::cull::packed_lookup(source, "cube_corner", 36),
|
||||
indices
|
||||
);
|
||||
}
|
||||
use std::{
|
||||
fs,
|
||||
sync::atomic::{AtomicU32, Ordering},
|
||||
|
||||
@@ -8,27 +8,19 @@ struct Out {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) direction: vec3<f32>,
|
||||
}
|
||||
// The cube's corner of each of its 36 vertices (CubeMap's indices): 4 7 6, 4 6 5, 5 6 2,
|
||||
// 5 2 1, 1 2 3, 1 3 0, 0 3 7, 0 7 4, 7 3 2, 7 2 6, 0 4 5, 0 5 1.
|
||||
// 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 cube_corner(v: u32) -> u32 {
|
||||
var word: u32;
|
||||
switch v / 8u {
|
||||
case 0u: { word = 0x65564674u; }
|
||||
case 1u: { word = 0x13211252u; }
|
||||
case 2u: { word = 0x47073003u; }
|
||||
case 3u: { word = 0x40627237u; }
|
||||
default: { word = 0x00001505u; }
|
||||
}
|
||||
return (word >> (v % 8u * 4u)) & 0xfu;
|
||||
}
|
||||
@vertex fn vs_panorama(@builtin(vertex_index) index: u32) -> Out {
|
||||
// CubeMap's corners: -1 or 1 on each axis, x 1 at corners 1, 2, 5 and 6, y at 2, 3, 6
|
||||
// and 7, z at 4 to 7.
|
||||
let c = cube_corner(index);
|
||||
let high = vec3((c ^ (c >> 1u)) & 1u, c & 2u, c & 4u) != vec3(0u);
|
||||
let position = select(vec3(-1.0), vec3(1.0), high);
|
||||
let corners = array<vec3<f32>, 8>(
|
||||
vec3(-1.0, -1.0, -1.0), vec3(1.0, -1.0, -1.0),
|
||||
vec3(1.0, 1.0, -1.0), vec3(-1.0, 1.0, -1.0),
|
||||
vec3(-1.0, -1.0, 1.0), vec3(1.0, -1.0, 1.0),
|
||||
vec3(1.0, 1.0, 1.0), vec3(-1.0, 1.0, 1.0),
|
||||
);
|
||||
let indices = array<u32, 36>(
|
||||
4, 7, 6, 4, 6, 5, 5, 6, 2, 5, 2, 1,
|
||||
1, 2, 3, 1, 3, 0, 0, 3, 7, 0, 7, 4,
|
||||
7, 3, 2, 7, 2, 6, 0, 4, 5, 0, 5, 1,
|
||||
);
|
||||
let position = corners[indices[index]];
|
||||
var out: Out;
|
||||
out.clip = projection.view_proj * vec4(position, 1.0);
|
||||
out.direction = position;
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
//! How far through vanilla's chunk fade-in a section is, and the instance that carries it to `terrain.wgsl`.
|
||||
use crate::fast_hash::FxHashMap;
|
||||
use std::time::Instant;
|
||||
|
||||
/// A section's instance: its camera-relative origin and its UV step, negated
|
||||
/// past its fade code while it fades in over far terrain (`lod::instance`).
|
||||
pub(super) fn section_instance(origin: [f32; 3], fade: u32, visibility: f32) -> [f32; 4] {
|
||||
let step = 1.0 / crate::mesher::UV_STEPS;
|
||||
[
|
||||
origin[0],
|
||||
origin[1],
|
||||
origin[2],
|
||||
if fade == 0 {
|
||||
// The step is a power of two, so its mantissa is free: it holds the vanilla
|
||||
// chunk fade-in's `1 - visibility` in 23 bits (terrain.wgsl's `instance_step` and
|
||||
// `instance_visibility` read it back). A whole section is the bare step.
|
||||
let faded = ((1.0 - visibility) * 8_388_608.0)
|
||||
.round()
|
||||
.clamp(0.0, 8_388_607.0) as u32;
|
||||
f32::from_bits(step.to_bits() | faded)
|
||||
} else {
|
||||
-(fade as f32 + step)
|
||||
},
|
||||
]
|
||||
}
|
||||
/// `SectionRenderDispatcher.RenderSection.getVisibility`: how far through its fade-in a section
|
||||
/// uploaded `elapsed_ms` ago is.
|
||||
pub(super) fn chunk_visibility(elapsed_ms: u64, fade_ms: u64) -> f32 {
|
||||
if elapsed_ms >= fade_ms {
|
||||
1.0
|
||||
} else {
|
||||
elapsed_ms as f32 / fade_ms as f32
|
||||
}
|
||||
}
|
||||
/// `Util.toMillis`: `Mth.floor(seconds * 1000.0)`.
|
||||
pub(super) fn fade_millis(seconds: f32) -> u64 {
|
||||
(f64::from(seconds) * 1000.0).floor().max(0.0) as u64
|
||||
}
|
||||
/// `chunk_visibility` at `now` of a section first uploaded at `uploaded`
|
||||
/// (`Renderer::section_uploaded`; none: one that fades in over far terrain, or
|
||||
/// whose column left), with the chunk fade-in `fade_ms`.
|
||||
pub(super) fn section_visibility(uploaded: Option<&Instant>, fade_ms: u64, now: Instant) -> f32 {
|
||||
uploaded.map_or(1.0, |&t| {
|
||||
let elapsed = now.saturating_duration_since(t).as_millis() as u64;
|
||||
chunk_visibility(elapsed, fade_ms)
|
||||
})
|
||||
}
|
||||
/// What a section's fade depends on besides the section itself
|
||||
/// (`SectionGpu::drawn_faded`): the time, the chunk fade-in time, the sections'
|
||||
/// first uploads (`Renderer::section_uploaded`) and those held while far
|
||||
/// terrain comes in (`Renderer::lod_retiring`).
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct FadeInputs<'a> {
|
||||
pub(super) now: Instant,
|
||||
pub(super) fade_ms: u64,
|
||||
pub(super) uploaded: &'a FxHashMap<super::SectionKey, Instant>,
|
||||
pub(super) retiring: &'a crate::fast_hash::FxHashSet<super::SectionKey>,
|
||||
}
|
||||
/// `LevelRenderer.CHUNK_VISIBILITY_THRESHOLD`: a section's block entities wait for it.
|
||||
pub(super) const CHUNK_VISIBILITY_THRESHOLD: f32 = 0.3;
|
||||
@@ -221,18 +221,6 @@ pub(super) async fn open_adapter(
|
||||
configured,
|
||||
})
|
||||
}
|
||||
/// Logs a sprite split below the CPU's (the GPU's limit), and warns when Metal's arrays grew
|
||||
/// past 512 layers (#12155), so a big pack's texture-corruption report ties to it (#8471).
|
||||
pub(super) fn log_sprite_split(info: &wgpu::AdapterInfo, split: u32, layers: u32) {
|
||||
if info.backend == wgpu::Backend::Metal && split > 512 {
|
||||
log::warn!(
|
||||
"Horizon: {split}-layer Metal sprite arrays for {layers} on {}",
|
||||
info.name
|
||||
);
|
||||
} else if split < super::sprite_split() {
|
||||
log::info!("Horizon: {split} block textures per array (the GPU's limit)");
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{AdapterChoice, pick_adapter};
|
||||
|
||||
@@ -30,9 +30,8 @@ fn marker_driver(text: &str) -> &str {
|
||||
pub struct Reported {
|
||||
pub paper_white: u32,
|
||||
pub peak: u32,
|
||||
/// The peak is the system's own figure (DXGI's `MaxLuminance`): the display's, or a
|
||||
/// calibration profile's (Windows HDR Calibration), which Horizon can't tell apart. Auto
|
||||
/// uses it, and the HDR page offers calibration.
|
||||
/// The peak is the display's own claim (DXGI's `MaxLuminance`, mostly its EDID figure),
|
||||
/// which often runs high: Auto still uses it, but the HDR page asks to calibrate.
|
||||
pub peak_guessed: bool,
|
||||
}
|
||||
|
||||
@@ -41,7 +40,7 @@ pub struct Reported {
|
||||
pub enum HdrSetup {
|
||||
/// The system says nothing: the player calibrates.
|
||||
Unknown,
|
||||
/// The system says, but not where its peak comes from: the page offers calibration.
|
||||
/// The system says, but only guesses the peak: the player calibrates.
|
||||
Guessed,
|
||||
/// Peak Brightness and Paper White follow the system.
|
||||
Automatic,
|
||||
@@ -1356,7 +1355,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_setup_offers_calibration_where_the_peaks_source_is_unknown() {
|
||||
fn hdr_setup_asks_to_calibrate_where_the_peak_is_guessed() {
|
||||
let wayland = Reported {
|
||||
paper_white: 203,
|
||||
peak: 1015,
|
||||
@@ -1371,7 +1370,7 @@ mod tests {
|
||||
assert_eq!(g.hdr_setup(None), HdrSetup::Unknown);
|
||||
assert_eq!(g.hdr_setup(Some(wayland)), HdrSetup::Automatic);
|
||||
assert_eq!(g.hdr_setup(Some(windows)), HdrSetup::Guessed);
|
||||
// Auto still takes the reported peak, over the 1000-nit default.
|
||||
// Auto still takes the guess, over the 1000-nit default.
|
||||
assert_eq!(g.peak_nits(Some(windows)), 1000);
|
||||
assert_eq!(g.paper_white_nits(Some(windows)), 400);
|
||||
for (peak, paper_white) in [(800, 0), (0, 240)] {
|
||||
@@ -1897,12 +1896,10 @@ mod tests {
|
||||
let g = Graphics::default();
|
||||
assert_eq!(g.workers, 0);
|
||||
let cores = horizon_threads::worker_count();
|
||||
// Vanilla's bound: one core is always left for the render and server threads.
|
||||
assert!((1..=cores.saturating_sub(1).max(1)).contains(&g.worker_threads()));
|
||||
// The count itself is `horizon_threads::default_workers`, which is one less than
|
||||
// the *smaller* of the machine's cores and its performance cores (a Linux hybrid
|
||||
// CPU reports fewer of the latter), so it is not always `cores - 1`.
|
||||
assert_eq!(g.worker_threads(), horizon_threads::default_workers());
|
||||
if !cfg!(target_vendor = "apple") {
|
||||
assert_eq!(g.worker_threads(), cores.saturating_sub(1).max(1));
|
||||
}
|
||||
// A manual count is the player's own choice, kept as asked.
|
||||
let manual = Graphics { workers: 3, ..g };
|
||||
assert_eq!(manual.worker_threads(), 3);
|
||||
|
||||
@@ -697,7 +697,7 @@ fn description(
|
||||
"Your display didn't say how bright it gets: calibrate to set it up."
|
||||
}
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Guessed) => {
|
||||
"Auto uses the peak Windows reports. Calibrate if highlights look clipped."
|
||||
"Your display's reported peak is often too high: calibrate to set it up."
|
||||
}
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Automatic) => {
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong."
|
||||
@@ -705,7 +705,7 @@ fn description(
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Changed) => {
|
||||
"Puts Peak Brightness and Paper White back to your display's setup."
|
||||
}
|
||||
// A peak of unknown source isn't said to be set up; paper white is the player's own.
|
||||
// A guessed peak's Auto is what calibration replaced; paper white is the player's own.
|
||||
(Item::Horizon(h @ (H::HdrPeak | H::HdrPaperWhite)), Setup::Automatic | Setup::Changed)
|
||||
if h == H::HdrPaperWhite || out.reported.is_some_and(|r| !r.peak_guessed) =>
|
||||
{
|
||||
@@ -1219,13 +1219,10 @@ impl Item {
|
||||
}
|
||||
pub fn label(self, s: &Settings, out: &EffectiveOutput) -> Text {
|
||||
match self {
|
||||
// Far terrain renames the row; its value stays vanilla's `options.chunks`.
|
||||
Item::Vanilla(Opt::RenderDistance) if s.horizon.lod_distance > 0 => {
|
||||
Text::option_name_value(
|
||||
Text::literal(lang::get("Full Detail Distance")),
|
||||
Text::tr("options.chunks", &[&s.render_distance]),
|
||||
)
|
||||
}
|
||||
Item::Vanilla(Opt::RenderDistance) if s.horizon.lod_distance > 0 => labelled(
|
||||
"Full Detail Distance",
|
||||
lang::tr("%s chunks", &[&s.render_distance]),
|
||||
),
|
||||
Item::Vanilla(o) => o.label(s),
|
||||
Item::Horizon(h) => h.label(s, out),
|
||||
Item::Extra(e) => Text::option_name_value(
|
||||
@@ -1321,11 +1318,7 @@ impl Item {
|
||||
|
||||
/// Whether a setting is on the screen now: Upscaling only while TAA is, which does it.
|
||||
fn shown(item: Item, s: &Settings) -> bool {
|
||||
match item {
|
||||
Item::Horizon(H::Upscaling) => s.horizon.taa,
|
||||
Item::Extra(crate::extras::Item::Fullbright) => !s.fullbright_held,
|
||||
_ => true,
|
||||
}
|
||||
item != Item::Horizon(H::Upscaling) || s.horizon.taa
|
||||
}
|
||||
/// A control's label as its name and its value ("Render Distance" and "12 chunks").
|
||||
fn parts(item: Item, s: &Settings, out: &EffectiveOutput) -> (String, Option<String>) {
|
||||
@@ -1437,7 +1430,7 @@ impl Screen for GraphicsScreen {
|
||||
g::gpu_culling_offer(),
|
||||
g::reduced_far_detail_suggested(),
|
||||
);
|
||||
shell::stamp_of(&(read(s), s.fullbright_held, offers))
|
||||
shell::stamp_of(&(read(s), offers))
|
||||
}
|
||||
fn sheets(&self, s: &Settings) -> Vec<Sheet<Page, Item>> {
|
||||
let sheet = |(page, groups): (Page, Vec<(&'static str, Vec<Setting>)>)| Sheet {
|
||||
@@ -1997,31 +1990,6 @@ mod tests {
|
||||
assert_eq!(key(&mut ui, &mut s, K::Escape), [Output::Done]);
|
||||
}
|
||||
|
||||
/// On another's server Fullbright's row is gone and the screen draws again; its choice is
|
||||
/// kept for the next world that allows it (#3823).
|
||||
#[test]
|
||||
fn fullbright_row_is_hidden_while_held_and_keeps_its_choice() {
|
||||
let mut s = Settings::default();
|
||||
s.extras.fullbright = true;
|
||||
let screen = GraphicsScreen::default();
|
||||
let listed = |s: &Settings, item| {
|
||||
let sheets = screen.sheets(s);
|
||||
let groups = sheets.into_iter().flat_map(|sheet| sheet.groups);
|
||||
groups.flat_map(|group| group.rows).any(|r| r.id == item)
|
||||
};
|
||||
let fullbright = Item::Extra(crate::extras::Item::Fullbright);
|
||||
let lights = Item::Extra(crate::extras::Item::DynamicLights);
|
||||
assert!(listed(&s, fullbright));
|
||||
let free = screen.stamp(&s);
|
||||
s.fullbright_held = true;
|
||||
assert!(!listed(&s, fullbright));
|
||||
assert!(listed(&s, lights));
|
||||
assert_ne!(screen.stamp(&s), free);
|
||||
assert!(s.extras.fullbright);
|
||||
s.fullbright_held = false;
|
||||
assert!(listed(&s, fullbright));
|
||||
}
|
||||
|
||||
/// The strip says what a row does, what its chosen value does where that differs, and why a
|
||||
/// greyed row can't be used; the search finds a row by what it does.
|
||||
#[test]
|
||||
@@ -2142,36 +2110,10 @@ mod tests {
|
||||
assert_eq!(item.slider(&s), Some(13.0 / 23.0));
|
||||
s.horizon.lod_distance = 12;
|
||||
assert_eq!(item.slider(&s), Some(1.0 / 23.0));
|
||||
}
|
||||
|
||||
/// With far terrain on, Render Distance reads "Full Detail Distance" but keeps vanilla's
|
||||
/// `options.chunks` value: it was Horizon's "%s chunks", so "12 Chunks" became "12 chunks"
|
||||
/// (#14880).
|
||||
#[test]
|
||||
fn full_detail_distance_keeps_the_vanilla_chunk_count() {
|
||||
struct Vanilla;
|
||||
impl lang::ResourceSource for Vanilla {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
(path == "assets/minecraft/lang/en_us.json").then(|| {
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"options.renderDistance": "Render Distance",
|
||||
"options.chunks": "%s Chunks",
|
||||
"options.generic_value": "%s: %s"
|
||||
}))
|
||||
.unwrap()
|
||||
})
|
||||
}
|
||||
}
|
||||
let mut s = Settings::default();
|
||||
let near = s.render_distance;
|
||||
let render = Item::Vanilla(Opt::RenderDistance);
|
||||
let ((off, on), _) = lang::scoped(lang::Language::load(&[&Vanilla], "en_us"), || {
|
||||
let off = render.label(&s, &NONE).plain();
|
||||
s.horizon.lod_distance = 256;
|
||||
(off, render.label(&s, &NONE).plain())
|
||||
});
|
||||
assert_eq!(off, format!("Render Distance: {near} Chunks"));
|
||||
assert_eq!(on, format!("Full Detail Distance: {near} Chunks"));
|
||||
assert_eq!(
|
||||
Item::Vanilla(Opt::RenderDistance).label(&s, &NONE).plain(),
|
||||
format!("Full Detail Distance: {near} chunks")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -164,7 +164,7 @@ fn auto_levels_and_setup_read_their_own_renderers_report() {
|
||||
}
|
||||
|
||||
/// The setup rows' label and description for each kind of report: none, a compositor's
|
||||
/// (Wayland), and Windows', whose peak may be the display's or a calibration profile's.
|
||||
/// (Wayland), and Windows', whose peak is the display's own guess.
|
||||
#[test]
|
||||
fn the_setup_rows_ask_to_calibrate_unless_the_system_set_the_display_up() {
|
||||
let trusted = offering(true, Some(true));
|
||||
@@ -200,7 +200,7 @@ fn the_setup_rows_ask_to_calibrate_unless_the_system_set_the_display_up() {
|
||||
[
|
||||
(
|
||||
calibrate.into(),
|
||||
"Auto uses the peak Windows reports. Calibrate if highlights look clipped."
|
||||
"Your display's reported peak is often too high: calibrate to set it up."
|
||||
),
|
||||
("Peak Brightness: Auto (1000)".into(), "-"),
|
||||
("Paper White: Auto (400)".into(), "-"),
|
||||
@@ -219,7 +219,7 @@ fn the_setup_rows_ask_to_calibrate_unless_the_system_set_the_display_up() {
|
||||
);
|
||||
// Calibrated or set by hand, either report offers its automatic setup back.
|
||||
s.horizon.hdr_peak = 800;
|
||||
// Peak's Auto has no known source on Windows: only Paper White is said to be set up there.
|
||||
// Peak's Auto stays a guess on Windows: only Paper White is said to be set up there.
|
||||
let changed = |peak| {
|
||||
[
|
||||
(
|
||||
@@ -348,30 +348,3 @@ fn undo_never_restores_a_held_hdr_start() {
|
||||
screen.undo(&mut s, &opened);
|
||||
assert_eq!(s.horizon, opened.horizon);
|
||||
}
|
||||
|
||||
/// The Calibrate row's descriptions fit the strip's two lines at vanilla's 427-wide GUI (a
|
||||
/// 1080p window at scale 4 is wider): a longer one would end in "…".
|
||||
#[test]
|
||||
fn the_calibrate_descriptions_fit_the_strip() {
|
||||
let atlas = crate::hud::HudAtlas::build(std::path::Path::new("/nonexistent")).unwrap();
|
||||
let mut guessed = offering(true, Some(true));
|
||||
guessed.reported = guessed.reported.map(|r| crate::graphics::Reported {
|
||||
peak_guessed: true,
|
||||
..r
|
||||
});
|
||||
let mut changed = Settings::default();
|
||||
changed.horizon.hdr_peak = 800;
|
||||
let item = Item::Horizon(H::HdrCalibrate);
|
||||
let width = Columns::of(427.0, 240.0).dock[2] - 24.0;
|
||||
for (s, out) in [
|
||||
(Settings::default(), NONE),
|
||||
(Settings::default(), guessed),
|
||||
(Settings::default(), offering(true, Some(true))),
|
||||
(changed, guessed),
|
||||
] {
|
||||
let text = description(item, &s, &out, "-");
|
||||
assert_ne!(text, "-");
|
||||
let lines = atlas.font.split(&Text::literal(text.to_owned()), width);
|
||||
assert!(lines.len() <= 2, "{text}");
|
||||
}
|
||||
}
|
||||
@@ -173,52 +173,13 @@ fn calibration_picture(pos: vec4<f32>) -> vec4<f32> {
|
||||
return vec4(nits / 80.0, 1.0);
|
||||
}
|
||||
|
||||
// On for the HDR10 swapchain alone (Hdr::new): its 10 bits get dither. A screenshot's HDR10 copy
|
||||
// (Hdr::capture) keeps the exact codes, and scRGB's floats need none.
|
||||
override pq_dither: bool = false;
|
||||
|
||||
// Three even random values in [0, 1) for `at`: PCG's 3D hash (Jarzynski and Olano, "Hash Functions
|
||||
// for GPU Rendering", JCGT 2020). Integers only, so it is the same on every GPU and at every
|
||||
// resolution, where hashes built on `fract(sin())` repeat or streak at 4K's coordinates.
|
||||
// Copyright 2020 Mark Jarzynski & Marc Olano, MIT (github.com/markjarzynski/PCG3D; the license is in
|
||||
// THIRD_PARTY_NOTICES.md).
|
||||
fn hash3(at: vec3<u32>) -> vec3<f32> {
|
||||
var v = at * 1664525u + 1013904223u;
|
||||
v.x += v.y * v.z;
|
||||
v.y += v.z * v.x;
|
||||
v.z += v.x * v.y;
|
||||
v ^= v >> vec3(16u);
|
||||
v.x += v.y * v.z;
|
||||
v.y += v.z * v.x;
|
||||
v.z += v.x * v.y;
|
||||
return vec3<f32>(v >> vec3(8u)) / 16777216.0;
|
||||
}
|
||||
|
||||
// The PQ signal `e` with dither for its 10 bits: triangular noise of one code either way, each
|
||||
// channel's own, so a slow gradient (a clear sky is a few codes tall) shows as grain a code deep
|
||||
// instead of bands, and its mean is the light meant. The noise is the pixel's alone: no frame
|
||||
// counter, so a still picture stays still. Within a code of black or of the top it shrinks to
|
||||
// what fits: the signal never leaves 0..1, and black stays exactly black (an OLED's pixel off).
|
||||
// The price is in that last code alone: the smaller noise keeps the signal's mean but not the
|
||||
// rounded one, which leans to the end (a third of a code from it shows as an eighth). Under
|
||||
// 0.00004 nits at black, that is the accepted cost of a true 0.
|
||||
fn dithered(e: vec3<f32>, pixel: vec2<f32>) -> vec3<f32> {
|
||||
let at = vec2<u32>(pixel);
|
||||
let noise = hash3(vec3(at, 0u)) + hash3(vec3(at, 1u)) - 1.0;
|
||||
return e + noise * min(min(e, 1.0 - e), vec3(1.0 / 1023.0));
|
||||
}
|
||||
|
||||
// Light in units of paper white at the pixel `pos`, as the swapchain takes it: HDR10 or scRGB,
|
||||
// the black level lifted.
|
||||
fn display(light: vec3<f32>, pos: vec4<f32>) -> vec4<f32> {
|
||||
// Light in units of paper white, as the swapchain takes it: HDR10 or scRGB, the black level
|
||||
// lifted.
|
||||
fn display(light: vec3<f32>) -> vec4<f32> {
|
||||
let scrgb = light * params.paper;
|
||||
if params.pq > 0.5 {
|
||||
let e = pq(BT709_TO_BT2020 * scrgb * 80.0);
|
||||
let lifted = select(e, lift_black(e), params.black > 0.0);
|
||||
if pq_dither {
|
||||
return vec4(dithered(lifted, pos.xy), 1.0);
|
||||
}
|
||||
return vec4(lifted, 1.0);
|
||||
return vec4(select(e, lift_black(e), params.black > 0.0), 1.0);
|
||||
}
|
||||
if params.black > 0.0 {
|
||||
return vec4(pq_nits(lift_black(pq(scrgb * 80.0))) / 80.0, 1.0);
|
||||
@@ -248,7 +209,7 @@ fn display(light: vec3<f32>, pos: vec4<f32>) -> vec4<f32> {
|
||||
// The HUD and menus at their own brightness, by how much they cover, no brighter than the
|
||||
// peak (as `fs_hdr_layered`): UI Brightness can pass paper white.
|
||||
light = min(light * mix(1.0, params.ui, ui), vec3(params.peak));
|
||||
return display(light, pos);
|
||||
return display(light);
|
||||
}
|
||||
|
||||
// The tone curve's knee: up to it light stays as it is. Where HDR lighting eased into the peak
|
||||
@@ -367,5 +328,5 @@ override screenshot_shoulder: bool = true;
|
||||
if calibrating(pos) {
|
||||
return calibration_picture(pos);
|
||||
}
|
||||
return display(min(pow(layered(vec2<i32>(pos.xy)), vec3(2.2)), vec3(params.peak)), pos);
|
||||
return display(min(pow(layered(vec2<i32>(pos.xy)), vec3(2.2)), vec3(params.peak)));
|
||||
}
|
||||
@@ -12,11 +12,8 @@ use std::{
|
||||
};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use model_reducing::reducible_model_sprites;
|
||||
|
||||
mod animate;
|
||||
pub mod cit_glint;
|
||||
mod model_reducing;
|
||||
mod pack_text;
|
||||
pub mod pictures;
|
||||
pub(crate) mod sprite_modifiers;
|
||||
@@ -55,7 +52,7 @@ pub struct TextureFlags {
|
||||
impl TextureFlags {
|
||||
/// The `texture` section of a `.png.mcmeta` file, if it sets either flag.
|
||||
pub fn from_mcmeta(json: &str) -> Option<Self> {
|
||||
let meta = crate::packs::first_json(json.as_bytes())?;
|
||||
let meta: serde_json::Value = serde_json::from_str(json).ok()?;
|
||||
let section = meta.get("texture")?;
|
||||
let flag = |key| section.get(key).and_then(serde_json::Value::as_bool) == Some(true);
|
||||
let flags = Self {
|
||||
@@ -114,11 +111,11 @@ pub struct HudAtlas {
|
||||
/// The packs' Polytone screen sprite modifiers.
|
||||
pub sprite_modifiers: std::sync::Arc<sprite_modifiers::SpriteModifiers>,
|
||||
/// The packs' OptiFine random entity textures, whose variants this atlas packs.
|
||||
pub random_textures: horizon_optifine::random_textures::RandomTextures,
|
||||
pub random_textures: crate::entity_textures::RandomTextures,
|
||||
/// Their emissive overlays (`_e`), packed here too.
|
||||
pub emissive: std::sync::Arc<horizon_optifine::emissive::Emissive>,
|
||||
pub emissive: std::sync::Arc<crate::entity_textures::Emissive>,
|
||||
/// ETF's blinking frames (`_blink`), packed here too.
|
||||
pub blinks: horizon_optifine::blink::Blinks,
|
||||
pub blinks: crate::entity_textures::Blinks,
|
||||
/// Of the shared atlas's textures, their LabPBR normal and specular maps as paged maps
|
||||
/// (`paged_maps.rs`), read while loading for shader packs (`sprites::labpbr_maps`).
|
||||
pub material_maps: std::sync::Arc<[Option<crate::paged_maps::PagedMap>; 2]>,
|
||||
@@ -390,7 +387,7 @@ fn strip_frame(pixels: &[u8], stride: u32, x: u32, y: u32, w: u32, h: u32) -> Ve
|
||||
}
|
||||
/// The `animation` section of a texture's `.mcmeta`.
|
||||
fn animation_of(mcmeta: Option<&str>) -> Option<AnimationMetadata> {
|
||||
let meta = crate::packs::first_json(mcmeta?.as_bytes())?;
|
||||
let meta: serde_json::Value = serde_json::from_str(mcmeta?).ok()?;
|
||||
AnimationMetadata::from_json(&meta.get("animation")?.to_string()).ok()
|
||||
}
|
||||
/// An animated strip's first frame: the region the mcmeta's frame size picks
|
||||
@@ -436,7 +433,7 @@ fn cit_sprites(
|
||||
packed: &[PackedImage],
|
||||
) -> Vec<(PackedImage, Option<TextureFlags>, bool)> {
|
||||
let have: HashSet<&str> = packed.iter().map(|i| i.0.as_str()).collect();
|
||||
let rules = crate::cit::load_rules(root);
|
||||
let rules = crate::cit::CitRules::load(root).rules;
|
||||
let mut ids = crate::cit_assets::item_sprites(&rules);
|
||||
if !rules.is_empty() {
|
||||
let models = crate::cit_assets::load_models(root, &rules);
|
||||
@@ -1060,6 +1057,25 @@ impl HudAtlas {
|
||||
blinks: Default::default(),
|
||||
}
|
||||
}
|
||||
/// `layout`, but when `extras` (Custom Item Textures) make the images more than `max`
|
||||
/// holds, without them: they are left out, not the packs.
|
||||
fn layout_leaving_out(
|
||||
images: &mut Vec<PackedImage>,
|
||||
animated: &mut Vec<String>,
|
||||
extras: &HashSet<String>,
|
||||
max: u32,
|
||||
) -> Result<(u32, Vec<(u32, u32)>, u32)> {
|
||||
match Self::layout(images, max) {
|
||||
Err(e) if !extras.is_empty() => {
|
||||
images.retain(|i| !extras.contains(&i.0));
|
||||
animated.retain(|n| !extras.contains(n));
|
||||
let layout = Self::layout(images, max)?;
|
||||
log::warn!("left out the custom item textures: {e}");
|
||||
Ok(layout)
|
||||
}
|
||||
layout => layout,
|
||||
}
|
||||
}
|
||||
/// Shelf placements of `images` (tallest first) in the narrowest atlas
|
||||
/// width from 4096 up that keeps the atlas, glyph rows included, within
|
||||
/// `max` texels a side; and the first row free for glyphs. High-resolution
|
||||
@@ -1188,25 +1204,18 @@ impl HudAtlas {
|
||||
let s = p.to_string_lossy().replace('\\', "/");
|
||||
!s.contains("title/background") && !s.contains("presets")
|
||||
});
|
||||
// OptiFine random entity variants, under the names their scan gives them (those
|
||||
// outside `textures/` are `<ns>:optifine/random/...`).
|
||||
let random_textures = if folders.contains(&"entity") {
|
||||
horizon_optifine::random_textures::RandomTextures::scan(root)
|
||||
// Random entity variants, under the names their scan gives them (those outside
|
||||
// `textures/` are `<ns>:optifine/random/...`), blink frames and emissive overlays.
|
||||
let crate::entity_textures::EntityTextures {
|
||||
random: random_textures,
|
||||
blinks,
|
||||
emissive,
|
||||
} = if folders.contains(&"entity") {
|
||||
crate::entity_textures::EntityTextures::load(root)
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let blinks = if folders.contains(&"entity") {
|
||||
horizon_optifine::blink::Blinks::scan(root, &random_textures)
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let emissive = std::sync::Arc::new(if folders.contains(&"entity") {
|
||||
let mut e = horizon_optifine::emissive::Emissive::scan(root, &random_textures);
|
||||
e.pair_beside(root, blinks.files());
|
||||
e
|
||||
} else {
|
||||
Default::default()
|
||||
});
|
||||
let emissive = std::sync::Arc::new(emissive);
|
||||
let mut named: HashMap<PathBuf, String> = HashMap::new();
|
||||
let extra = random_textures
|
||||
.files()
|
||||
@@ -1444,20 +1453,7 @@ impl HudAtlas {
|
||||
}
|
||||
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
|
||||
let (width, placements, glyph_region) =
|
||||
Self::layout_leaving_out(&mut images, &mut animated, &cit_names, MAX_ATLAS_SIZE)
|
||||
.or_else(|error| -> Result<_> {
|
||||
let models = reducible_model_sprites(root, &images, &bordered);
|
||||
let layout = Self::layout_reducing_models(
|
||||
&mut images,
|
||||
&mut gui_sizes,
|
||||
&models,
|
||||
MAX_ATLAS_SIZE,
|
||||
)?;
|
||||
log::warn!(
|
||||
"reduced HUD model texture resolution to keep the resource pack: {error}"
|
||||
);
|
||||
Ok(layout)
|
||||
})?;
|
||||
Self::layout_leaving_out(&mut images, &mut animated, &cit_names, MAX_ATLAS_SIZE)?;
|
||||
// A multiple of 1024 rather than a power of two: the default resources
|
||||
// need 4096x5120 (8192 rows before), Faithful 32x 4096x12288 (16384).
|
||||
let height = (glyph_region + GLYPH_ROWS).next_multiple_of(1024);
|
||||
@@ -3107,18 +3103,6 @@ mod coverage_tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn animated_texture_metadata_ignores_trailing_pack_signature() {
|
||||
let meta = r#"{"animation":{"frametime":2,"width":2,"height":2}}
|
||||
// High Contrast pack signature"#;
|
||||
let animation = animation_of(Some(meta)).expect("the first JSON value is metadata");
|
||||
assert_eq!(animation.frame_size(8, 4), [2, 2]);
|
||||
let pixels = (0..8 * 4).flat_map(|i| [i as u8, 0, 0, 255]).collect();
|
||||
let (_, height, first) = first_frame((8, 4, pixels), Some(&animation));
|
||||
assert_eq!(height, 2);
|
||||
assert_eq!(first.len(), 2 * 2 * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_skin_revision_tracks_changes_and_capacity_clear() {
|
||||
let mut atlas = HudAtlas::boot();
|
||||
@@ -3483,6 +3467,7 @@ mod tests {
|
||||
/// `RandomTextures` gives them; one beside its texture packs once, under its own.
|
||||
#[test]
|
||||
fn random_entity_variants_pack_under_their_scan_names() {
|
||||
crate::pack_formats::install();
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-atlas-random-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
@@ -3505,6 +3490,7 @@ mod tests {
|
||||
/// A random variant's emissive overlay packs beside the variant, under its name.
|
||||
#[test]
|
||||
fn variant_overlays_pack_under_their_names() {
|
||||
crate::pack_formats::install();
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-atlas-overlay-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
@@ -3775,47 +3761,6 @@ mod tests {
|
||||
assert_eq!(banner.texels((atlas.width, atlas.height)), (16, 32));
|
||||
}
|
||||
#[test]
|
||||
fn a_root_single_sprite_strip_taller_than_the_atlas_packs_one_frame() {
|
||||
let root = animated_root();
|
||||
let atlases = root.join("assets/minecraft/atlases");
|
||||
fs::create_dir_all(&atlases).unwrap();
|
||||
fs::write(
|
||||
atlases.join("items.json"),
|
||||
r#"{"sources":[{"type":"single","resource":"nexo:abc123"},
|
||||
{"type":"single","resource":"nexo:still"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
// Plugin-generated names need no item/ prefix. Keep the production
|
||||
// strip's height, but use narrow frames to keep this fixture cheap.
|
||||
let textures = root.join("assets/nexo/textures");
|
||||
fs::create_dir_all(&textures).unwrap();
|
||||
write_strip(
|
||||
&textures.join("abc123.png"),
|
||||
16,
|
||||
32768,
|
||||
&[[255, 0, 0, 255], [0, 0, 255, 255]],
|
||||
Some(r#"{"animation":{"frames":[1024,0],"frametime":3}}"#),
|
||||
);
|
||||
write_strip(&textures.join("still.png"), 16, 32, &[[7, 8, 9, 255]], None);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let rect = atlas.sprites["nexo:abc123"];
|
||||
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16));
|
||||
assert_eq!(texel_at(&atlas, rect, 0, 15), [0, 0, 255, 255]);
|
||||
let (_, animation) = atlas
|
||||
.sprite_animations
|
||||
.iter()
|
||||
.find(|(r, _)| r.u0 == rect.u0 && r.v0 == rect.v0)
|
||||
.expect("the root-level single sprite animates");
|
||||
assert_eq!(animation.frame(0), 1024);
|
||||
assert_eq!(animation.frame(3), 0);
|
||||
assert_eq!(animation.frame_count(), 2048);
|
||||
assert_eq!(
|
||||
atlas.sprites["nexo:still"].texels((atlas.width, atlas.height)),
|
||||
(16, 32)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn an_animated_gui_sprite_packs_its_first_frame_and_animates() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-atlas-gui-anim-{}", std::process::id()));
|
||||
@@ -3860,6 +3805,7 @@ mod tests {
|
||||
}
|
||||
/// A resource root with one plain item texture and these CIT files (relative path, contents).
|
||||
fn cit_root(name: &str, files: &[(&str, &str)]) -> PathBuf {
|
||||
crate::pack_formats::install();
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-atlas-cit-{name}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
@@ -3949,7 +3895,7 @@ mod tests {
|
||||
);
|
||||
let cit = root.join("assets/minecraft/optifine/cit");
|
||||
write_strip(&cit.join("a.png"), 64, 32, &[[1, 2, 3, 255]], None);
|
||||
let rules = crate::cit::load_rules(&root);
|
||||
let rules = crate::cit::CitRules::load(&root).rules;
|
||||
assert_eq!(rules.len(), 1);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
@@ -4222,93 +4168,6 @@ texture=wide",
|
||||
assert_inside(&images, width, &placements, free);
|
||||
}
|
||||
#[test]
|
||||
fn model_resolution_fallback_protects_font_sources_and_gui_aliases() {
|
||||
let root = animated_root();
|
||||
write_png(&root, "item/font_sheet", 32, 32);
|
||||
write_png(&root, "gui/panel", 32, 32);
|
||||
write_png(&root, "hash", 32, 32);
|
||||
let font = root.join("assets/minecraft/font/default.json");
|
||||
fs::create_dir_all(font.parent().unwrap()).unwrap();
|
||||
fs::write(
|
||||
font,
|
||||
r#"{"providers":[{"type":"bitmap","file":"minecraft:item/font_sheet.png","ascent":7,"chars":["a"]}]}"#,
|
||||
).unwrap();
|
||||
let atlases = root.join("assets/minecraft/atlases");
|
||||
fs::create_dir_all(&atlases).unwrap();
|
||||
fs::write(
|
||||
atlases.join("items.json"),
|
||||
r#"{"sources":[
|
||||
{"type":"single","resource":"item/font_sheet","sprite":"font_alias"},
|
||||
{"type":"single","resource":"gui/panel","sprite":"gui_alias"},
|
||||
{"type":"single","resource":"hash"}
|
||||
]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let images = vec![
|
||||
("item/font_sheet".to_owned(), 32, 32, Vec::new()),
|
||||
("item/blurred".to_owned(), 32, 32, Vec::new()),
|
||||
("block/stone".to_owned(), 32, 32, Vec::new()),
|
||||
];
|
||||
let models =
|
||||
reducible_model_sprites(&root, &images, &HashSet::from(["item/blurred".to_owned()]));
|
||||
let _ = fs::remove_dir_all(root);
|
||||
assert_eq!(
|
||||
models,
|
||||
HashSet::from(["hash".to_owned(), "block/stone".to_owned()])
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn overflowing_model_textures_shrink_without_changing_gui_sizes_or_slots() {
|
||||
let mut images = vec![
|
||||
(
|
||||
"nexo:hash".into(),
|
||||
1024,
|
||||
1024,
|
||||
[10, 20, 30, 255].repeat(1024 * 1024),
|
||||
),
|
||||
(
|
||||
"gui/panel".into(),
|
||||
32,
|
||||
32,
|
||||
[40, 50, 60, 255].repeat(32 * 32),
|
||||
),
|
||||
("horizon/map".into(), 128, 128, Vec::new()),
|
||||
];
|
||||
let models = HashSet::from(["nexo:hash".to_owned(), "horizon/map".to_owned()]);
|
||||
let mut sizes = HashMap::new();
|
||||
assert!(HudAtlas::layout(&images, 1024).is_err());
|
||||
let (width, placements, free) =
|
||||
HudAtlas::layout_reducing_models(&mut images, &mut sizes, &models, 1024).unwrap();
|
||||
assert_inside(&images, width, &placements, free);
|
||||
assert!(free + GLYPH_ROWS <= 1024);
|
||||
let model = images.iter().find(|i| i.0 == "nexo:hash").unwrap();
|
||||
assert_eq!((model.1, model.2), (256, 256));
|
||||
assert_eq!(model.3, [10, 20, 30, 255].repeat(256 * 256));
|
||||
assert_eq!(sizes["nexo:hash"], (1024, 1024));
|
||||
let gui = images.iter().find(|i| i.0 == "gui/panel").unwrap();
|
||||
assert_eq!((gui.1, gui.2), (32, 32));
|
||||
assert_eq!(gui.3, [40, 50, 60, 255].repeat(32 * 32));
|
||||
let slot = images.iter().find(|i| i.0 == "horizon/map").unwrap();
|
||||
assert_eq!((slot.1, slot.2), (128, 128));
|
||||
assert!(slot.3.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn fitting_models_keep_their_pixels_and_existing_logical_sizes() {
|
||||
let mut images = vec![("item/gem".into(), 32, 32, [1, 2, 3, 255].repeat(32 * 32))];
|
||||
let original = images.clone();
|
||||
let mut sizes = HashMap::from([("item/gem".to_owned(), (16, 16))]);
|
||||
let models = HashSet::from(["item/gem".to_owned()]);
|
||||
HudAtlas::layout_reducing_models(&mut images, &mut sizes, &models, 1024).unwrap();
|
||||
assert_eq!(images, original);
|
||||
assert_eq!(sizes["item/gem"], (16, 16));
|
||||
// Protected oversized images remain an error; no unbounded retry or
|
||||
// silent removal of GUI/font resources when shrinking cannot help.
|
||||
images.push(("font/sheet".into(), 1024, 1024, Vec::new()));
|
||||
assert!(HudAtlas::layout_reducing_models(&mut images, &mut sizes, &models, 1024).is_err());
|
||||
assert_eq!((images[0].1, images[0].2), (1024, 1024));
|
||||
assert_eq!(sizes["item/gem"], (16, 16));
|
||||
}
|
||||
#[test]
|
||||
fn textures_that_cannot_fit_are_an_error_not_a_gpu_panic() {
|
||||
let error = HudAtlas::layout(&sized(300, 1024, 1024), MAX_ATLAS_SIZE).unwrap_err();
|
||||
assert!(error.to_string().contains("do not fit a 16384x16384 atlas"));
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
//! OptiFine CIT enchantment glints on GUI items (`type=enchantment`, see `crate::cit_glint` for
|
||||
//! Custom item texture enchantment glints on GUI items (`Kind::Enchantment`, see `crate::cit_glint` for
|
||||
//! which layers a stack draws): every rule's texture in one texture array, each layer an extra
|
||||
//! pass over the item's quads with its rule's blend. A layer's vertices carry its texture slot
|
||||
//! (red) and its intensity (alpha) in their colour; its scrolled UV is worked out on the CPU, as
|
||||
//! vanilla's glint's is. Without enchantment rules nothing here is made.
|
||||
|
||||
use super::HudVertex;
|
||||
use crate::cit::{Blend, Factor, Kind, Rule};
|
||||
use crate::gfx::hud_coverage::Pipeline;
|
||||
use horizon_optifine::cit::{Blend, Factor, Kind, Rule};
|
||||
use std::path::Path;
|
||||
|
||||
/// Slots the shader's table holds; rules past it draw no layer (and match no stack).
|
||||
@@ -438,13 +438,11 @@ impl CitGlintPass {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_optifine::Properties;
|
||||
use horizon_optifine::cit::parse_blend;
|
||||
use crate::pack_formats::cit::{parse_blend, parse_rule};
|
||||
|
||||
fn rule(name: &str, text: &str) -> Rule {
|
||||
let path = format!("assets/minecraft/optifine/cit/{name}.properties");
|
||||
let text = format!("texture=glint\n{text}");
|
||||
Rule::parse(&path, &Properties::parse_equals_first(&text), |_| true).unwrap()
|
||||
parse_rule(&path, &format!("texture=glint\n{text}")).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -338,7 +338,7 @@ fn coverage_preserves_each_cit_blend_and_its_destination_alpha() {
|
||||
let Some(mut gpu) = Gpu::new() else {
|
||||
return;
|
||||
};
|
||||
use horizon_optifine::cit::parse_blend;
|
||||
use crate::pack_formats::cit::parse_blend;
|
||||
for (name, expected) in [
|
||||
("alpha", 128),
|
||||
("add", 0),
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
//! Resolution fallback for model textures that exceed the shared HUD atlas.
|
||||
use super::{HashMap, HashSet, HudAtlas, PackedImage, Path, Result, bitmap_font_textures};
|
||||
|
||||
/// Only model atlas sprites may lose resolution. Font sheets can also be
|
||||
/// model textures, so protect both their file names and renamed sources.
|
||||
pub(super) fn reducible_model_sprites(
|
||||
root: &Path,
|
||||
images: &[PackedImage],
|
||||
bordered: &HashSet<String>,
|
||||
) -> HashSet<String> {
|
||||
use crate::atlas_sources::{self, SpriteOrigin};
|
||||
let fonts: HashSet<_> = bitmap_font_textures(root).into_iter().collect();
|
||||
let protected = |name: &str| {
|
||||
let path = name.split_once(':').map_or(name, |(_, path)| path);
|
||||
path.starts_with("gui/")
|
||||
|| path.starts_with("font/")
|
||||
|| fonts.contains(&atlas_sources::texture_path(root, name, "textures"))
|
||||
};
|
||||
let mut models: HashSet<String> = images
|
||||
.iter()
|
||||
.filter(|(name, ..)| name.starts_with("item/") || name.starts_with("block/"))
|
||||
.map(|(name, ..)| name.clone())
|
||||
.collect();
|
||||
let textures = atlas_sources::list_textures(root);
|
||||
let mut protected_aliases = HashSet::new();
|
||||
for (atlas, sources) in atlas_sources::atlases(root) {
|
||||
if atlas != "minecraft:blocks" && atlas != "minecraft:items" {
|
||||
continue;
|
||||
}
|
||||
for (id, origin) in atlas_sources::list(&sources, &textures) {
|
||||
let name = id.strip_prefix("minecraft:").unwrap_or(&id).to_owned();
|
||||
if let SpriteOrigin::File(texture) = origin {
|
||||
if protected(&texture) {
|
||||
protected_aliases.insert(name);
|
||||
} else {
|
||||
models.insert(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
models.retain(|name| {
|
||||
!bordered.contains(name) && !protected(name) && !protected_aliases.contains(name)
|
||||
});
|
||||
models
|
||||
}
|
||||
|
||||
impl HudAtlas {
|
||||
/// `layout`, but when `extras` (Custom Item Textures) make the images more than `max`
|
||||
/// holds, without them: they are left out, not the packs.
|
||||
pub(super) fn layout_leaving_out(
|
||||
images: &mut Vec<PackedImage>,
|
||||
animated: &mut Vec<String>,
|
||||
extras: &HashSet<String>,
|
||||
max: u32,
|
||||
) -> Result<(u32, Vec<(u32, u32)>, u32)> {
|
||||
match Self::layout(images, max) {
|
||||
Err(e) if !extras.is_empty() => {
|
||||
images.retain(|i| !extras.contains(&i.0));
|
||||
animated.retain(|n| !extras.contains(n));
|
||||
let layout = Self::layout(images, max)?;
|
||||
log::warn!("left out the custom item textures: {e}");
|
||||
Ok(layout)
|
||||
}
|
||||
layout => layout,
|
||||
}
|
||||
}
|
||||
/// Last resort for a pack that exceeds the shared atlas: halve model
|
||||
/// textures until they fit, retaining their logical GUI sizes. Never
|
||||
/// shrink run-time slots (empty pixels), or below the vanilla 16 texels.
|
||||
pub(super) fn layout_reducing_models(
|
||||
images: &mut [PackedImage],
|
||||
gui_sizes: &mut HashMap<String, (u32, u32)>,
|
||||
models: &HashSet<String>,
|
||||
max: u32,
|
||||
) -> Result<(u32, Vec<(u32, u32)>, u32)> {
|
||||
loop {
|
||||
let error = match Self::layout(images, max) {
|
||||
Ok(layout) => return Ok(layout),
|
||||
Err(error) => error,
|
||||
};
|
||||
let mut changed = false;
|
||||
for (name, w, h, pixels) in images.iter_mut() {
|
||||
if !models.contains(name) || pixels.is_empty() || (*w <= 16 && *h <= 16) {
|
||||
continue;
|
||||
}
|
||||
let (nw, nh) = ((*w / 2).max((*w).min(16)), (*h / 2).max((*h).min(16)));
|
||||
let mut scaled = Vec::with_capacity((nw * nh * 4) as usize);
|
||||
for y in 0..nh {
|
||||
for x in 0..nw {
|
||||
let at = (((y * *h / nh) * *w + x * *w / nw) * 4) as usize;
|
||||
scaled.extend_from_slice(&pixels[at..at + 4]);
|
||||
}
|
||||
}
|
||||
gui_sizes.entry(name.clone()).or_insert((*w, *h));
|
||||
*pixels = scaled;
|
||||
(*w, *h) = (nw, nh);
|
||||
changed = true;
|
||||
}
|
||||
if !changed {
|
||||
return Err(error);
|
||||
}
|
||||
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
//! Room: what of a view Horizon's own HUD overlays may use. Vanilla's HUD elements are
|
||||
//! keep-outs; an overlay asks the room where it fits instead of assuming a fixed spot, so it
|
||||
//! doesn't cover them. The keep-outs: the boss bars at the top centre, and the status rows above
|
||||
//! the hotbar at the bottom centre.
|
||||
//! doesn't cover them. Today the only keep-out is the boss bars at the top centre.
|
||||
//!
|
||||
//! The room is built once a frame from what the HUD shows (`Game::hud_room` in gameplay). With
|
||||
//! nothing showing it is empty, and every overlay stays exactly where it always was.
|
||||
@@ -35,16 +34,6 @@ pub const GAP: f32 = 2.0;
|
||||
pub enum Keep {
|
||||
/// The boss bars and their names at the top centre (`hud_overlays::boss_bars_bottom`).
|
||||
BossBars,
|
||||
/// The survival rows above the hotbar at the bottom centre: health with its absorption rows,
|
||||
/// armour, food or the mount's hearts, and air; from the highest row's top to the view's
|
||||
/// bottom.
|
||||
StatusRows,
|
||||
}
|
||||
impl Keep {
|
||||
/// It sits at the top of the view, not the bottom.
|
||||
fn at_top(self) -> bool {
|
||||
matches!(self, Self::BossBars)
|
||||
}
|
||||
}
|
||||
|
||||
/// A view and the keep-outs its HUD shows this frame.
|
||||
@@ -101,20 +90,10 @@ impl Room {
|
||||
/// bars' bottom, or 0 with none.
|
||||
pub fn top(&self) -> f32 {
|
||||
self.keeps()
|
||||
.filter(|(keep, _)| keep.at_top())
|
||||
.map(|(_, [_, y, _, h])| y + h)
|
||||
.map(|(Keep::BossBars, [_, y, _, h])| y + h)
|
||||
.fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
/// How far, in GUI px from the view's top, the keep-outs at the bottom reach up: the status
|
||||
/// rows' top, or the view's height with none.
|
||||
pub fn bottom(&self) -> f32 {
|
||||
self.keeps()
|
||||
.filter(|(keep, _)| !keep.at_top())
|
||||
.map(|(_, [_, y, _, _])| y)
|
||||
.fold(self.height, f32::min)
|
||||
}
|
||||
|
||||
/// Where a centred overlay that wants its top at `wanted` (GUI px) goes: at `wanted` unless
|
||||
/// a keep-out reaches past it (`top() > wanted`, a real overlap; touching isn't one), else
|
||||
/// `GAP` below that keep-out.
|
||||
@@ -155,15 +134,4 @@ mod tests {
|
||||
assert_eq!(bars(36.0).below_top(18.0), 38.0);
|
||||
assert_eq!(bars(36.0).usable(), [0.0, 38.0, 500.0, 262.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_status_rows_are_kept_at_the_bottom_and_leave_the_top_alone() {
|
||||
let room = Room::new(427.0, 240.0);
|
||||
assert_eq!(room.bottom(), 240.0);
|
||||
// Hearts and armour from y 191 down.
|
||||
let rows = room.with(Keep::StatusRows, [122.0, 191.0, 182.0, 49.0]);
|
||||
assert_eq!(rows.bottom(), 191.0);
|
||||
assert_eq!(rows.top(), 0.0);
|
||||
assert_eq!(rows.below_top(18.0), 18.0);
|
||||
}
|
||||
}
|
||||
@@ -131,23 +131,6 @@ pub enum GuiDraw {
|
||||
|
||||
pub const WHITE: u32 = 0xFFFF_FFFF;
|
||||
|
||||
/// The highest top, in GUI px, of the draws that sit on the view (`None` for none): where a row
|
||||
/// of the HUD begins.
|
||||
pub fn draws_top(draws: &[GuiDraw]) -> Option<i32> {
|
||||
draws
|
||||
.iter()
|
||||
.filter_map(|d| match *d {
|
||||
GuiDraw::Sprite { y, .. }
|
||||
| GuiDraw::AtlasSprite { y, .. }
|
||||
| GuiDraw::Blit { y, .. }
|
||||
| GuiDraw::Text { y, .. }
|
||||
| GuiDraw::Item { y, .. } => Some(y),
|
||||
GuiDraw::Fill { y0, .. } => Some(y0),
|
||||
GuiDraw::Push(_) | GuiDraw::Pop => None,
|
||||
})
|
||||
.min()
|
||||
}
|
||||
|
||||
fn sprite(sprite: &'static str, x: i32, y: i32, w: i32, h: i32) -> GuiDraw {
|
||||
GuiDraw::Sprite {
|
||||
sprite: Cow::Borrowed(sprite),
|
||||
|
||||
@@ -39,14 +39,11 @@ pub fn text_with_backdrop(
|
||||
background: u32,
|
||||
) {
|
||||
if background != 0 {
|
||||
// Font.PreparedTextBuilder.markBackground: one pixel above and left of
|
||||
// the glyphs, the glyph advance to the right and nine high: not a
|
||||
// fixed two-pixel frame.
|
||||
out.push(fill(
|
||||
x - 1,
|
||||
y - 1,
|
||||
x + w,
|
||||
y + 9,
|
||||
x - 2,
|
||||
y - 2,
|
||||
x + w + 2,
|
||||
y + 9 + 2,
|
||||
multiply(background, color),
|
||||
));
|
||||
}
|
||||
@@ -586,9 +583,6 @@ pub const BOSS_BAR_STEP: i32 = 10 + 9;
|
||||
pub const BOSS_BAR_HEIGHT: i32 = 5;
|
||||
/// A boss bar's sprite width, in GUI px; it is centred on the view.
|
||||
pub const BOSS_BAR_WIDTH: i32 = 182;
|
||||
/// Half the hotbar's width, in GUI px (`Hud`'s `screenWidth / 2 - 91`): the status rows above it
|
||||
/// span the same.
|
||||
pub const HOTBAR_HALF_WIDTH: i32 = 91;
|
||||
|
||||
/// How many of `n` boss bars `BossHealthOverlay.extractRenderState` draws on a view `h` GUI px
|
||||
/// tall: it stops once the next bar's top reaches a third of the height (integer division,
|
||||
@@ -1487,27 +1481,3 @@ mod boss_bar_room_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod backdrop_tests {
|
||||
use super::*;
|
||||
|
||||
/// `markBackground`: the backdrop reaches one pixel above and left of the
|
||||
/// glyphs, their advance to the right and nine down, not 2 on every side.
|
||||
#[test]
|
||||
fn the_backdrop_frames_the_glyphs_by_one_pixel() {
|
||||
let mut out = Vec::new();
|
||||
text_with_backdrop(
|
||||
&mut out,
|
||||
Text::literal("X"),
|
||||
10,
|
||||
20,
|
||||
6,
|
||||
u32::MAX,
|
||||
0xFF00_0000,
|
||||
);
|
||||
let Some(GuiDraw::Fill { x0, y0, x1, y1, .. }) = out.first() else {
|
||||
panic!("a backdrop is a fill first");
|
||||
};
|
||||
assert_eq!((*x0, *y0, *x1, *y1), (9, 19, 16, 29));
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ pub mod biome;
|
||||
pub mod block_edit;
|
||||
pub mod block_entities;
|
||||
pub mod block_entity_anim;
|
||||
pub mod block_entity_cem;
|
||||
pub mod block_entity_render;
|
||||
pub mod block_sprites;
|
||||
pub mod block_states;
|
||||
@@ -44,6 +43,7 @@ pub mod custom_sky;
|
||||
pub mod datapack;
|
||||
pub mod dynamic_light;
|
||||
pub mod effects;
|
||||
pub mod entity_textures;
|
||||
pub mod environment;
|
||||
pub mod extras;
|
||||
pub(crate) mod extras_ui;
|
||||
@@ -51,7 +51,6 @@ pub mod fast_hash;
|
||||
pub mod fluid_render;
|
||||
pub mod fog;
|
||||
pub mod font;
|
||||
pub mod fusion;
|
||||
pub mod gfx;
|
||||
pub mod graphics;
|
||||
pub mod graphics_style;
|
||||
@@ -110,8 +109,6 @@ pub mod preedit;
|
||||
pub mod providers;
|
||||
pub mod pvp;
|
||||
pub mod pvp_hud;
|
||||
pub mod radial_editor;
|
||||
pub mod radial_overlay;
|
||||
pub mod random_source;
|
||||
pub mod screen_draw;
|
||||
pub mod screen_effects;
|
||||
@@ -172,7 +169,6 @@ pub use horizon_client_input::key_names;
|
||||
pub use horizon_client_input::keybinds;
|
||||
pub use horizon_client_input::keymapping;
|
||||
pub use horizon_client_input::mouse;
|
||||
pub use horizon_client_input::radial;
|
||||
pub use horizon_client_input::window;
|
||||
pub use horizon_command as command;
|
||||
pub use horizon_items::blocks;
|
||||
|
||||
Loaded 100 of 795 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user