mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
121
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7334bebc1b | ||
|
|
791eaca45f | ||
|
|
cb1c9e6935 | ||
|
|
65d7c4b027 | ||
|
|
7becd7fdca | ||
|
|
ff91907057 | ||
|
|
fe128948ba | ||
|
|
33c2512cbd | ||
|
|
2e35028930 | ||
|
|
d0b7c20371 | ||
|
|
afcb4062cb | ||
|
|
7f995af677 | ||
|
|
61e044e67c | ||
|
|
18b63d7acb | ||
|
|
e683cdf322 | ||
|
|
f1ff81e874 | ||
|
|
1b23a14ee7 | ||
|
|
16d49fe660 | ||
|
|
fccac1e744 | ||
|
|
42714deca6 | ||
|
|
1a4a2ec120 | ||
|
|
ac43cdea70 | ||
|
|
6d81b98f18 | ||
|
|
1dffadeea3 | ||
|
|
29c5e65d17 | ||
|
|
83657f3bbf | ||
|
|
094d5c8e16 | ||
|
|
98639d1471 | ||
|
|
0c6816811c | ||
|
|
1be8a4caea | ||
|
|
4ebba25280 | ||
|
|
7fca3c2f97 | ||
|
|
2ad81884f9 | ||
|
|
84bdd1d0b0 | ||
|
|
493e5f8d3a | ||
|
|
ccb827282b | ||
|
|
56aa5f9549 | ||
|
|
d65282fb0f | ||
|
|
6a698c40f0 | ||
|
|
71292d2ed7 | ||
|
|
46f4237b67 | ||
|
|
643f331f8c | ||
|
|
186ab70305 | ||
|
|
cdfcc3d4d4 | ||
|
|
ab8b379097 | ||
|
|
a3b4ed57b2 | ||
|
|
4e7c25bb55 | ||
|
|
ba9b876e3e | ||
|
|
e1052e1bd3 | ||
|
|
afb638b81c | ||
|
|
f1369b4efe | ||
|
|
d49b0eb98b | ||
|
|
68bc9f82b6 | ||
|
|
717acb07af | ||
|
|
e423e570c9 | ||
|
|
06ec15f3a0 | ||
|
|
2a9e0ab28d | ||
|
|
87aa88de54 | ||
|
|
7efdbdb39a | ||
|
|
535f2d2a04 | ||
|
|
7eb7791240 | ||
|
|
ca90a20992 | ||
|
|
923368f4df | ||
|
|
25bdaf5321 | ||
|
|
8a04e18b91 | ||
|
|
5d54f28c42 | ||
|
|
818d1515c6 | ||
|
|
e61e6345bc | ||
|
|
02039fed5b | ||
|
|
976956fc3e | ||
|
|
dfae12cfe2 | ||
|
|
8a8348c93f | ||
|
|
0a3965a7b1 | ||
|
|
704931ed14 | ||
|
|
a713dbd46d | ||
|
|
1868fc39ae | ||
|
|
50f2894ca8 | ||
|
|
4b44399242 | ||
|
|
43e6bfcf98 | ||
|
|
f688726b34 | ||
|
|
826cf8ffe6 | ||
|
|
e2dd6b67df | ||
|
|
ebd25abd0e | ||
|
|
9624e99801 | ||
|
|
6a5c749f7c | ||
|
|
c33acfdd05 | ||
|
|
7c83fbb6df | ||
|
|
c26ced533e | ||
|
|
e0d29dc233 | ||
|
|
6eb34eb868 | ||
|
|
b21423ae01 | ||
|
|
a6e9374f88 | ||
|
|
50aee22996 | ||
|
|
01133fac82 | ||
|
|
4507ded0e4 | ||
|
|
46304cc1a7 | ||
|
|
377d81a329 | ||
|
|
905acfe7cd | ||
|
|
c18f3dbfde | ||
|
|
9c843819b7 | ||
|
|
c466c5ddad | ||
|
|
d5bd35025e | ||
|
|
2cd8b88a5b | ||
|
|
0b581f9ffe | ||
|
|
1b14e0d171 | ||
|
|
13c14563c4 | ||
|
|
82218db860 | ||
|
|
040ac1bec4 | ||
|
|
969bcec6c9 | ||
|
|
d66466db81 | ||
|
|
6b55abd66a | ||
|
|
6ea7167cbb | ||
|
|
51de8afe95 | ||
|
|
3e04df41c8 | ||
|
|
7371b2cccd | ||
|
|
077c4c9214 | ||
|
|
8f081ae460 | ||
|
|
14461c1d96 | ||
|
|
fc9822b241 | ||
|
|
4b1e298a44 | ||
|
|
d7b5cd8c85 |
No files matched your search
@@ -3,3 +3,4 @@
|
||||
docs/launcher-commands.md text eol=lf
|
||||
docs/horizon-ctl-commands.md text eol=lf
|
||||
website/src/tokens.css text eol=lf
|
||||
docs/api/*-reference.md text eol=lf
|
||||
@@ -430,7 +430,7 @@ jobs:
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_float_log_parity.py
|
||||
portable-mod:
|
||||
jfr-analysis:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: [self-hosted, linux, horizon-game]
|
||||
steps:
|
||||
@@ -442,101 +442,7 @@ jobs:
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '25'
|
||||
- name: Compile original mod and test metrics
|
||||
run: python tools/build_mod.py
|
||||
- name: Verify streaming JFR analysis against the JDK exporter
|
||||
run: python tools/test_jfr.py
|
||||
- name: Verify ZGC pause and allocation event interpretation
|
||||
run: python tools/test_jfr.py --gc zgc
|
||||
- name: Build pinned portable packs
|
||||
run: python tools/build_pack.py
|
||||
- name: Measure isolated worker pacing on this platform
|
||||
run: python -c "from tools.build_mod import benchmark_workers; benchmark_workers()"
|
||||
- name: Measure isolated debounce queue polling
|
||||
run: python -c "from tools.build_mod import benchmark_debounce; benchmark_debounce()"
|
||||
- name: Measure wakeable idle queue latency
|
||||
run: python -c "from tools.build_mod import benchmark_idle; benchmark_idle()"
|
||||
- name: Measure object allocations with compact headers off and on
|
||||
run: python -c "from tools.build_mod import benchmark_headers; benchmark_headers()"
|
||||
- name: Test pack integrity and comparisons
|
||||
run: python -m unittest discover -s tests -v
|
||||
- name: Start recommended OpenGL macOS clients and verify framebuffer readback
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
python tools/test_client_graphics.py --native-display
|
||||
cp dist/client-graphics-smoke.json dist/client-graphics-macos-title.json
|
||||
python tools/test_client_graphics.py --native-display --world
|
||||
cp dist/client-graphics-smoke.json dist/client-graphics-macos-world.json
|
||||
- name: Compile the private dispatch fixture
|
||||
run: python -c "from tools.build_mod import build_dispatch_fixture; build_dispatch_fixture()"
|
||||
- name: Compile the private integrated-server graphics fixture
|
||||
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('server-graphics', 'dev.horizonlab.metrics.ServerGraphicsIntegration', fixture_dependencies={})"
|
||||
- name: Compile the private tick-manager state fixture
|
||||
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('tick-state', 'dev.horizonlab.metrics.TickStateIntegration')"
|
||||
- name: Compile the private terrain tick membership fixture
|
||||
run: python -c "from tools.build_mod import build_tick_dedup_fixture; build_tick_dedup_fixture()"
|
||||
- name: Compile the private culling whitelist fixture
|
||||
run: python -c "from tools.build_mod import build_culling_empty_fixture; build_culling_empty_fixture()"
|
||||
- name: Compile the private culling ray geometry fixture
|
||||
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('culling-rays', 'dev.horizonlab.dispatch.CullingRayIntegration', ['entityculling'], ['CullingRayAccess'])"
|
||||
- name: Compile the private terrain position lookup fixture
|
||||
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('terrain-position', 'dev.horizonlab.dispatch.PositionIntegration')"
|
||||
- name: Compile the private terrain quad reuse fixture
|
||||
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('quad-reuse', 'dev.horizonlab.dispatch.QuadReuseIntegration')"
|
||||
- name: Compile the private object-size agent and terrain quad fixture
|
||||
run: python -c "from tools.test_quad_sizes import build_agent; from tools.build_mod import build_integration_fixture; build_agent(); build_integration_fixture('quad-size', 'dev.horizonlab.dispatch.QuadSizeIntegration')"
|
||||
- name: Compile the private terrain read shutdown fixture
|
||||
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('terrain-read', 'dev.horizonlab.dispatch.TerrainReadIntegration')"
|
||||
- name: Install headless graphics libraries for applied-hook checks
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb libgl1 libegl1 libvulkan1 mesa-vulkan-drivers libasound2t64 libxkbcommon0
|
||||
- name: Verify integrated-server graphics report against the actual device
|
||||
run: python tools/test_server_graphics.py ${{ runner.os == 'macOS' && '--native-display' || '' }}
|
||||
- name: Exercise the applied dispatcher hooks and upstream rejection path
|
||||
if: runner.os == 'Linux'
|
||||
run: python tools/test_dispatch.py
|
||||
- name: Verify applied tick membership transitions and allocation
|
||||
if: runner.os == 'Linux'
|
||||
run: python tools/test_tick_dedup.py
|
||||
- name: Verify live culling whitelist registration and empty iteration
|
||||
if: runner.os == 'Linux'
|
||||
run: python tools/test_culling_empty.py
|
||||
- name: Compare applied culling ray geometry and provider queries
|
||||
if: runner.os == 'Linux'
|
||||
run: python tools/test_culling_rays.py
|
||||
- name: Verify actual full-data position lookup equivalence
|
||||
if: runner.os == 'Linux'
|
||||
run: python tools/test_terrain_position.py
|
||||
- name: Verify actual terrain quad geometry and allocation
|
||||
if: runner.os == 'Linux'
|
||||
run: python tools/test_quad_reuse.py
|
||||
- name: Measure actual builder and quad shallow sizes in both header modes
|
||||
if: runner.os == 'Linux'
|
||||
run: python tools/test_quad_sizes.py
|
||||
- name: Verify interrupted DH reads and preserved shared futures
|
||||
if: runner.os == 'Linux'
|
||||
run: python tools/test_terrain_read.py
|
||||
- name: Upload original mod and pack manifests only
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: horizon-linux
|
||||
path: |
|
||||
dist/*.mrpack
|
||||
dist/horizon-*.jar
|
||||
dist/worker-pacing-benchmark.txt
|
||||
dist/debounce-queue-benchmark.txt
|
||||
dist/idle-queue-benchmark.txt
|
||||
dist/object-header-benchmark.txt
|
||||
dist/dispatch-integration.json
|
||||
dist/tick-dedup-integration.json
|
||||
dist/culling-empty-integration.json
|
||||
dist/terrain-read-integration.json
|
||||
dist/culling-rays-integration.json
|
||||
dist/terrain-position-integration.json
|
||||
dist/quad-reuse-integration.json
|
||||
dist/quad-size-integration.json
|
||||
dist/server-graphics-smoke.json
|
||||
dist/client-graphics-smoke.json
|
||||
dist/client-graphics-macos-title.json
|
||||
dist/client-graphics-macos-world.json
|
||||
retention-days: 3
|
||||
@@ -1,8 +1,8 @@
|
||||
name: release
|
||||
# Automatic game releases (docs/release-flow.md). After every green `checks` run on main, every 10 minutes (so a
|
||||
# release waiting for merges to settle goes out without another merge) and by hand, tools/release_train.py decides:
|
||||
# a release when pull requests merged since the last one changed what players run and the newest merged 5 minutes
|
||||
# ago (the oldest 20 minutes ago, if merges never pause), or at once for a pull request labelled release-now.
|
||||
# a release when 5 pull requests merged since the last one changed what players run and the newest merged 5 minutes
|
||||
# ago, or when the oldest has waited 6 hours, or at once for a pull request labelled release-now.
|
||||
# RELEASES_PAUSED=1 (repository variable) stops them; so does an open issue labelled release-blocker, forced runs too.
|
||||
# Builds Linux (an older Ubuntu, so the binary runs on any recent distribution and on NixOS through nix-ld) and
|
||||
# Windows on GitHub, macOS on the Mac mini's runner (MAC_RUNNER=macos-latest sends it to GitHub), then
|
||||
|
||||
@@ -12,17 +12,20 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
|
||||
folders in `HORIZON_HOME` (the game's data), `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and
|
||||
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`. Never write to
|
||||
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
|
||||
- **Mark what an agent posts.** A PR body starts with
|
||||
`> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.` (issues: "Filed by", reviews:
|
||||
"Review by"); every comment starts with `🤖 Agent:`. Never write as Ludovic.
|
||||
- **Attribution.** Commits end with `Co-Authored-By: Claude <model> <[email protected]>`; PR bodies end
|
||||
with `🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
|
||||
- **Mark what an agent posts.** A PR body starts with a line naming whose agent opened it and with what.
|
||||
Ludovic's agents write `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, and a
|
||||
contributor's agent names its own person and tool (issues: "Filed by", reviews: "Review by"). Every
|
||||
comment starts with `🤖 Agent:`. Never write as Ludovic or as anyone else.
|
||||
- **Attribution.** Commits end with a `Co-Authored-By:` line for the agent that wrote them, and PR bodies
|
||||
with that agent's own "Generated with" line. Ludovic's agents use
|
||||
`Co-Authored-By: Claude <model> <[email protected]>` and
|
||||
`🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
|
||||
- Everything players run is Rust. Python/Java only for dev tools and harness drivers in `tools/`.
|
||||
- Horizon-only features follow [docs/design-system.md](docs/design-system.md) and are off by default where
|
||||
they would change the vanilla look.
|
||||
- Small PRs (< ~500 lines). Labels: `feature` (players get something new), `skip-release` (they see no
|
||||
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Every merge to main ships
|
||||
([docs/release-flow.md](docs/release-flow.md)).
|
||||
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Merges ship in batches of 5, or after
|
||||
6 hours ([docs/release-flow.md](docs/release-flow.md)).
|
||||
- Contributors first: `gh pr list --state open --search <area>` before you start; never duplicate theirs.
|
||||
|
||||
## Crates
|
||||
@@ -40,12 +43,11 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-server` | `horizon-server`: `server.toml`, CLI, supervisor of the headless game ([docs/hosting.md](docs/hosting.md)) |
|
||||
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
|
||||
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
|
||||
| `horizon-shaderpack` | OptiFine/Iris packs: preprocessor, GLSL → WGSL ([docs/shader-packs.md](docs/shader-packs.md)) |
|
||||
| `horizon-mods`, `horizon-mod-sdk`, `cargo-horizon-mod` | Sandboxed WebAssembly mods: runtime, SDK + WIT, `cargo horizon-mod` ([docs/mods.md](docs/mods.md)) |
|
||||
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
|
||||
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
|
||||
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
|
||||
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `voxel-kernels`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, occupancy kernels, repo tasks |
|
||||
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, repo tasks |
|
||||
| `launcher/` | GPUI launcher: `main.rs` (UI), `backend.rs` (installs, updates), `cli.rs`, `mcp.rs`, `*_page.rs` |
|
||||
|
||||
Other top-level folders: `tools/` (harnesses and dev scripts), `tests/` (Python unit tests: `python3 -m
|
||||
@@ -123,7 +125,6 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; L: `keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `osk.rs`, `clipboard.rs`; G: `pad.rs`, `pad_menu.rs` |
|
||||
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `lang.rs`; crate `horizon-assets`; G: `packs.rs` |
|
||||
| Shader packs | L: `shader_pack.rs` (Horizon WGSL packs), `java_pack.rs` + crate `horizon-shaderpack` (Iris/OptiFine), `gfx/pack_stages.rs`, `gfx/java_stages.rs`; G: `shader_packs.rs` |
|
||||
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
|
||||
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
|
||||
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
|
||||
@@ -148,19 +149,19 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
|
||||
## The two single-player paths
|
||||
|
||||
1. **In-frame (default since #1428):** the window's `Game` ticks the world itself, one level that follows
|
||||
the player (`fn enter_dimension`). This is what Stable players run and what harnesses test.
|
||||
2. **Server thread (`--server-thread` or `HORIZON_SERVER_THREAD=1`):** `game/integrated.rs` runs a second
|
||||
`Game` (`serving`) on a "Server thread" with one level per dimension; the window joins it as a client
|
||||
through memory (`remote`), exactly as a LAN guest or a dedicated server's player does. Not at parity yet:
|
||||
open `[server-thread]` issues list the gaps.
|
||||
1. **Server thread (default since #1514):** `game/integrated.rs` runs a second `Game` (`serving`) on a
|
||||
"Server thread" with one level per dimension; the window joins it as a client through memory (`remote`),
|
||||
exactly as a LAN guest or a dedicated server's player does. This is what players run. Open
|
||||
`[server-thread]` issues list the gaps left.
|
||||
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`):** the window's `Game` ticks
|
||||
the world itself, one level that follows the player (`fn enter_dimension`). Most harnesses test this path.
|
||||
|
||||
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test both paths
|
||||
when you touch player-facing gameplay:
|
||||
|
||||
```sh
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # in-frame
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --server-thread # server thread
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --in-frame # in-frame
|
||||
python3 tools/lan_e2e.py --check <name> # host in-frame, guest is a server client
|
||||
python3 tools/lan_e2e.py --server-thread --check <name> # host on its server thread (portals, levels, distance, lava need it)
|
||||
python3 tools/parity.py dedicated_server server_entities server_player_damage
|
||||
|
||||
Generated
+51
-404
@@ -155,7 +155,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.60.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
@@ -178,15 +178,6 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
|
||||
|
||||
[[package]]
|
||||
name = "ascii-canvas"
|
||||
version = "4.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891"
|
||||
dependencies = [
|
||||
"term",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ash"
|
||||
version = "0.38.0+1.3.281"
|
||||
@@ -214,30 +205,15 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
||||
dependencies = [
|
||||
"bit-vec 0.8.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
|
||||
dependencies = [
|
||||
"bit-vec 0.9.1",
|
||||
"bit-vec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.9.1"
|
||||
@@ -283,16 +259,6 @@ dependencies = [
|
||||
"objc2 0.6.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
@@ -484,15 +450,6 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coords-hud"
|
||||
version = "0.1.0"
|
||||
@@ -797,41 +754,6 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_sample"
|
||||
version = "0.11.0"
|
||||
@@ -849,29 +771,6 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
|
||||
dependencies = [
|
||||
"derive_more-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more-impl"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"syn 2.0.119",
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -958,15 +857,6 @@ version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
||||
|
||||
[[package]]
|
||||
name = "ena"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1"
|
||||
dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.42"
|
||||
@@ -1230,58 +1120,6 @@ version = "0.30.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9"
|
||||
|
||||
[[package]]
|
||||
name = "glsl-lang"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "983a18d0c2ecad18ac9ff3c94b069d6bd03b47405a200836d9d8d63def8e34de"
|
||||
dependencies = [
|
||||
"glsl-lang-lexer",
|
||||
"glsl-lang-types",
|
||||
"lalrpop",
|
||||
"lalrpop-util",
|
||||
"lang-util",
|
||||
"once_cell",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glsl-lang-lexer"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "abdbc0bdfbc81d557f4865e695457eb23d68031bfb061fda3444f56511b26eee"
|
||||
dependencies = [
|
||||
"glsl-lang-pp",
|
||||
"glsl-lang-types",
|
||||
"lalrpop-util",
|
||||
"lang-util",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glsl-lang-pp"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88677876fb27f116015d0ecebda6d77bdf65f3a6fa3f5dfeed10b8c8f04cb2c5"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"derive_more",
|
||||
"lang-util",
|
||||
"string_cache",
|
||||
"string_cache_codegen",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glsl-lang-types"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd8b1532b424d40df634cf2dc2be56d59f994b9ac07fa7c1e3183fd33db9cb07"
|
||||
dependencies = [
|
||||
"lang-util",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpu-allocator"
|
||||
version = "0.28.0"
|
||||
@@ -1428,7 +1266,6 @@ dependencies = [
|
||||
"horizon-mods",
|
||||
"horizon-protocol",
|
||||
"horizon-server-files",
|
||||
"horizon-shaderpack",
|
||||
"horizon-telemetry",
|
||||
"horizon-theme",
|
||||
"horizon-threads",
|
||||
@@ -1441,9 +1278,9 @@ dependencies = [
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"symphonia",
|
||||
"toml",
|
||||
"ureq",
|
||||
"wgpu",
|
||||
"winit",
|
||||
@@ -1478,7 +1315,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"socket2",
|
||||
"socket2 0.5.10",
|
||||
"spake2",
|
||||
]
|
||||
|
||||
@@ -1541,6 +1378,7 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"horizon-core",
|
||||
"horizon-world",
|
||||
"ipconfig",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
@@ -1594,16 +1432,6 @@ dependencies = [
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-shaderpack"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"glsl-lang",
|
||||
"naga",
|
||||
"pp-rs",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-telemetry"
|
||||
version = "0.1.0"
|
||||
@@ -1755,12 +1583,6 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
@@ -1833,6 +1655,19 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipconfig"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
|
||||
dependencies = [
|
||||
"socket2 0.6.5",
|
||||
"widestring",
|
||||
"windows-registry",
|
||||
"windows-result 0.4.1",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
@@ -1943,73 +1778,6 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keccak"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
|
||||
dependencies = [
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lalrpop"
|
||||
version = "0.22.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501"
|
||||
dependencies = [
|
||||
"ascii-canvas",
|
||||
"bit-set 0.8.0",
|
||||
"ena",
|
||||
"itertools",
|
||||
"lalrpop-util",
|
||||
"petgraph 0.7.1",
|
||||
"pico-args",
|
||||
"regex",
|
||||
"regex-syntax",
|
||||
"sha3",
|
||||
"string_cache",
|
||||
"term",
|
||||
"unicode-xid",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lalrpop-util"
|
||||
version = "0.22.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5baa5e9ff84f1aefd264e6869907646538a52147a755d494517a8007fb48733"
|
||||
dependencies = [
|
||||
"regex-automata",
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lang-util"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1715a1e56998f119cd6d49ac8a69802dcbe4fca96d8291243d6dae87abeb913"
|
||||
dependencies = [
|
||||
"derive_more",
|
||||
"lalrpop-util",
|
||||
"lang-util-derive",
|
||||
"line-span",
|
||||
"smol_str 0.3.6",
|
||||
"text-size",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lang-util-derive"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f2acc66aedf0e445534e0a6b3f0f6ee20ef6ebb9647dbe10735e52ea8d6a096"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.1"
|
||||
@@ -2066,12 +1834,6 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "line-span"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29fc123b2f6600099ca18248f69e3ee02b09c4188c0d98e9a690d90dec3e408a"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.15"
|
||||
@@ -2183,7 +1945,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bit-set 0.10.0",
|
||||
"bit-set",
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
@@ -2196,7 +1958,6 @@ dependencies = [
|
||||
"naga-types",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"pp-rs",
|
||||
"rustc-hash 1.1.0",
|
||||
"spirv",
|
||||
"thiserror 2.0.21",
|
||||
@@ -2245,12 +2006,6 @@ dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.3"
|
||||
@@ -2750,16 +2505,6 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "petgraph"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
|
||||
dependencies = [
|
||||
"fixedbitset",
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "petgraph"
|
||||
version = "0.8.3"
|
||||
@@ -2771,31 +2516,6 @@ dependencies = [
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"rand",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pico-args"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.13"
|
||||
@@ -2914,21 +2634,6 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pp-rs"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb458bb7f6e250e6eb79d5026badc10a3ebb8f9a15d1fff0f13d17c71f4d6dee"
|
||||
dependencies = [
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||
|
||||
[[package]]
|
||||
name = "presser"
|
||||
version = "0.3.1"
|
||||
@@ -3022,15 +2727,6 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
@@ -3369,16 +3065,6 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha3"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"keccak",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
@@ -3407,12 +3093,6 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -3462,16 +3142,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smol_str"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523"
|
||||
dependencies = [
|
||||
"borsh",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.5.10"
|
||||
@@ -3482,6 +3152,16 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spake2"
|
||||
version = "0.4.0"
|
||||
@@ -3521,37 +3201,6 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
"parking_lot",
|
||||
"phf_shared",
|
||||
"precomputed-hash",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string_cache_codegen"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@@ -3668,15 +3317,6 @@ version = "0.13.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
||||
|
||||
[[package]]
|
||||
name = "term"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "1.4.1"
|
||||
@@ -3686,12 +3326,6 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "text-size"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233"
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -3852,7 +3486,7 @@ checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"nom",
|
||||
"petgraph 0.8.3",
|
||||
"petgraph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3968,10 +3602,6 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "voxel-kernels"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
@@ -4573,8 +4203,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bit-set 0.10.0",
|
||||
"bit-vec 0.9.1",
|
||||
"bit-set",
|
||||
"bit-vec",
|
||||
"bitflags 2.13.2",
|
||||
"bytemuck",
|
||||
"cfg_aliases",
|
||||
@@ -4624,7 +4254,7 @@ dependencies = [
|
||||
"android_system_properties",
|
||||
"arrayvec",
|
||||
"ash",
|
||||
"bit-set 0.10.0",
|
||||
"bit-set",
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.6.2",
|
||||
"bytemuck",
|
||||
@@ -4688,6 +4318,12 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "widestring"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
@@ -4820,6 +4456,17 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
@@ -5122,7 +4769,7 @@ dependencies = [
|
||||
"rustix 0.38.44",
|
||||
"sctk-adwaita",
|
||||
"smithay-client-toolkit",
|
||||
"smol_str 0.2.2",
|
||||
"smol_str",
|
||||
"tracing",
|
||||
"unicode-segmentation",
|
||||
"wasm-bindgen",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "third_party/wgpu-hal"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-shaderpack", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -45,7 +45,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
|
||||
|
||||
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
|
||||
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
|
||||
- **Resource packs, shader packs and data packs:** resource packs work, Java shader packs run their full-screen effects (experimental), and data packs load functions, predicates and item modifiers.
|
||||
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates and item modifiers.
|
||||
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining offline-mode servers. Horizon cannot sign in to a Microsoft account yet, so it cannot join servers that check accounts. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Mods:** Horizon has its own mod system (Rust compiled to WebAssembly). Java mod jars do not run. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
|
||||
@@ -57,7 +57,7 @@ Screenshots, captured in the Rust client itself with vanilla assets and no mods
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/media/survival.webp" alt="Survival at dusk: a zombie walks up, with the hotbar, health, hunger and experience"></td>
|
||||
<td width="50%"><img src="docs/media/settings.webp" alt="Vanilla's Video Settings screen with Horizon's own buttons at the top: Horizon Graphics, Advanced Graphics and Shader Packs"></td>
|
||||
<td width="50%"><img src="docs/media/settings.webp" alt="Vanilla's Video Settings screen with Horizon's own buttons at the top: Horizon Graphics and Advanced Graphics"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/media/village.webp" alt="A plains village among savanna hills, with farms, villagers and an iron golem"></td>
|
||||
|
||||
@@ -1,35 +1,5 @@
|
||||
# Third-party notices
|
||||
|
||||
## CullLeaves
|
||||
|
||||
The optional leaf and mangrove-root face-culling behavior is adapted from
|
||||
[CullLeaves](https://github.com/TeamMidnightDust/CullLeaves) by MidnightDust.
|
||||
Its MIT license is reproduced below.
|
||||
|
||||
```text
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 MidnightDust
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
## wgpu-hal
|
||||
|
||||
`third_party/wgpu-hal` is [wgpu-hal](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs
|
||||
|
||||
@@ -131,8 +131,8 @@ average) and reached the ThinkPad over the tailnet.
|
||||
It ran with Paper's defaults except `connection-throttle: -1` in `bukkit.yml`. Every bot connects from one
|
||||
address, and the default throttle (4 s) refused all but the first. Paperclip patched the pinned server jar
|
||||
from its cache. Same Java and heap.
|
||||
- Fabric + Lithium: the pinned server jar on Fabric loader 0.19.5 (`packs/fabric.lock.json`) with Lithium
|
||||
`mc26.3-0.26.2-fabric` (`packs/mods.lock.json`, checked against its SHA-512). Lithium needs no Fabric API.
|
||||
- Fabric + Lithium: the pinned server jar on Fabric loader 0.19.5 (`tools/benchmark-deps/fabric.lock.json`) with Lithium
|
||||
`mc26.3-0.26.2-fabric` (`tools/benchmark-deps/mods.lock.json`, checked against its SHA-512). Lithium needs no Fabric API.
|
||||
Same Java and heap.
|
||||
|
||||
The Paper and Fabric jars are dev-only downloads into `.local/server-bench/jars` and are never committed or
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
//! The machine a report was measured on. CPU, RAM and OS are detected here; the GPU and the display
|
||||
//! come from the program that rendered (the game knows its adapter, the launcher its window).
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
use horizon_telemetry::system::command_output as output;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
@@ -173,12 +172,6 @@ pub fn cpu_seconds(pid: u32) -> Option<f64> {
|
||||
Some(ticks / 100.0)
|
||||
}
|
||||
|
||||
fn output(program: &str, args: &[&str]) -> Option<String> {
|
||||
let out = Command::new(program).args(args).output().ok()?;
|
||||
let text = String::from_utf8(out.stdout).ok()?.trim().to_owned();
|
||||
(out.status.success() && !text.is_empty()).then_some(text)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn os_version() -> Option<String> {
|
||||
output("sw_vers", &["-productVersion"])
|
||||
|
||||
@@ -245,13 +245,19 @@ pub struct Report {
|
||||
impl Report {
|
||||
/// An empty report for this machine, created now.
|
||||
pub fn new(kind: Kind, app: App) -> Report {
|
||||
Self::with_hardware(kind, app, Hardware::detect())
|
||||
}
|
||||
|
||||
/// Starts a report using supplied hardware, without running system probes.
|
||||
/// Interactive apps can collect the remaining machine details after their first frame.
|
||||
pub fn with_hardware(kind: Kind, app: App, hardware: Hardware) -> Report {
|
||||
Report {
|
||||
format: FORMAT.to_owned(),
|
||||
version: VERSION,
|
||||
kind,
|
||||
created: utc_now(),
|
||||
app,
|
||||
hardware: Hardware::detect(),
|
||||
hardware,
|
||||
conditions: Conditions::default(),
|
||||
metrics: BTreeMap::new(),
|
||||
frames: Vec::new(),
|
||||
|
||||
@@ -27,15 +27,13 @@ bytemuck = { version = "1.24", features = ["derive"] }
|
||||
glam = "0.30"
|
||||
png = "0.18"
|
||||
sha2 = "0.10.9"
|
||||
# Server resource packs are checked and cached by their SHA-1, as vanilla does.
|
||||
sha1 = "0.10"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1.0.151", features = ["raw_value"] }
|
||||
# Shader packs: pack.toml, and WGSL checked before it reaches wgpu.
|
||||
toml = "1.1"
|
||||
# hlsl-out: the gfx.rs tests translate every shader as wgpu's DirectX 12 backend does. A normal feature,
|
||||
# not a dev-dependency's, so builds, tests and examples share one build of naga, wgpu and this crate.
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
|
||||
# Java (OptiFine/Iris) shader packs, translated to WGSL.
|
||||
horizon-shaderpack = { path = "../horizon-shaderpack" }
|
||||
# Sound (ALSA on Linux); the `audio` feature.
|
||||
cpal = { version = "0.16", optional = true }
|
||||
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
|
||||
@@ -85,6 +83,11 @@ mods = ["horizon-mods/runtime"]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace"]
|
||||
|
||||
# Its own name, not `probe`: Cargo copies every crate's examples into one target/<profile>/examples.
|
||||
[[example]]
|
||||
name = "client_probe"
|
||||
path = "examples/probe/main.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ pub fn main(names: &[&str], run: fn(&str) -> Option<ExitCode>) -> ExitCode {
|
||||
let name = std::env::args().nth(1).unwrap_or_default();
|
||||
run(&name).unwrap_or_else(|| {
|
||||
eprintln!(
|
||||
"usage: probe <name> [arguments...]\nprobes: {}",
|
||||
"usage: client_probe <name> [arguments...]\nprobes: {}",
|
||||
names.join(" ")
|
||||
);
|
||||
ExitCode::from(2)
|
||||
|
||||
@@ -293,6 +293,47 @@ fn spawn_seed(index: usize) -> i64 {
|
||||
1000 + 7919 * index as i64
|
||||
}
|
||||
|
||||
/// `Villager.spawnGolemIfNeeded` for villager `a`, the game's way: the golem
|
||||
/// draws from the level's random and joins the scenario's mobs next tick,
|
||||
/// seeded as the oracle seeds what a tick spawned.
|
||||
fn summon_golem(
|
||||
mobs: &mut [Mob],
|
||||
a: usize,
|
||||
needed: usize,
|
||||
w: &MobWorld,
|
||||
types: &MobTypes,
|
||||
born: &mut Vec<Mob>,
|
||||
level_random: &mut LegacyRandom,
|
||||
) {
|
||||
use horizon_client::mobs::{villager, villager_brain};
|
||||
let Some(near) = villager::golem_summoners(mobs, a, w.game_time, needed) else {
|
||||
return;
|
||||
};
|
||||
let kind = Arc::new(
|
||||
types
|
||||
.types
|
||||
.get("minecraft:iron_golem")
|
||||
.expect("iron golem")
|
||||
.clone(),
|
||||
);
|
||||
let index = mobs.len() + born.len();
|
||||
let p = mobs[a].position;
|
||||
let start = [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32];
|
||||
let golem = villager::summon_golem(w, start, level_random, |random, at, yaw| {
|
||||
let mut m = Mob::new(100_000 + index as u32, kind.clone(), at, yaw);
|
||||
villager::finalize_summoned_golem(&mut m, random);
|
||||
m
|
||||
});
|
||||
let Some(mut golem) = golem else {
|
||||
return;
|
||||
};
|
||||
golem.random = LegacyRandom::new(spawn_seed(index));
|
||||
born.push(golem);
|
||||
for j in near {
|
||||
villager_brain::golem_detected(&mut mobs[j], w.game_time);
|
||||
}
|
||||
}
|
||||
|
||||
/// Villager events that act at once in vanilla (`TradeWithVillager` and
|
||||
/// `VillagerMakeLove` write the other villager directly).
|
||||
fn villager_events(
|
||||
@@ -304,6 +345,7 @@ fn villager_events(
|
||||
items: &Items,
|
||||
born: &mut Vec<Mob>,
|
||||
day_time: i64,
|
||||
level_random: &mut LegacyRandom,
|
||||
) {
|
||||
use horizon_client::mobs::villager;
|
||||
let index = |mobs: &[Mob], id: u32| {
|
||||
@@ -314,6 +356,7 @@ fn villager_events(
|
||||
while i < events.len() {
|
||||
match events[i] {
|
||||
MobEvent::VillagerMeet { mob, other } => {
|
||||
events.remove(i);
|
||||
if let (Some(a), Some(b)) = (index(mobs, mob), index(mobs, other)) {
|
||||
let meeting = villager::meet(mobs, a, b, w.game_time, |item| {
|
||||
items.get(item).map_or(64, |i| i.max_stack)
|
||||
@@ -322,8 +365,16 @@ fn villager_events(
|
||||
meeting.thrown.is_empty(),
|
||||
"villagers toss items: not modelled here"
|
||||
);
|
||||
if meeting.gossiped {
|
||||
summon_golem(mobs, a, 5, w, types, born, level_random);
|
||||
}
|
||||
}
|
||||
}
|
||||
MobEvent::GolemCheck { mob, needed } => {
|
||||
events.remove(i);
|
||||
if let Some(a) = index(mobs, mob) {
|
||||
summon_golem(mobs, a, needed, w, types, born, level_random);
|
||||
}
|
||||
}
|
||||
MobEvent::VillagerBirth {
|
||||
parent,
|
||||
@@ -1167,6 +1218,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
&items,
|
||||
&mut born,
|
||||
s.time,
|
||||
&mut scratch,
|
||||
);
|
||||
horizon_client::mobs::warden::hear_mob_events(&mut mobs, &events, from, &w);
|
||||
// Melee hits on other mobs land during the attacker's tick
|
||||
@@ -1323,6 +1375,31 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
world.set(at[0], at[1], at[2], registry.resolve(&st)?)?;
|
||||
}
|
||||
}
|
||||
// Doors villagers opened or closed (DoorBlock.setOpen), both halves.
|
||||
let doors: Vec<([i32; 3], bool)> = events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
MobEvent::SetDoorOpen { pos, open } => Some((*pos, *open)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for (at, open) in doors {
|
||||
let Some(half) = registry
|
||||
.state(world.block(at[0], at[1], at[2]))
|
||||
.and_then(|s| s.properties.get("half").cloned())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let other = if half == "lower" { 1 } else { -1 };
|
||||
for y in [at[1], at[1] + other] {
|
||||
if let Some(mut st) = registry.state(world.block(at[0], y, at[2])).cloned()
|
||||
&& st.properties.contains_key("open")
|
||||
{
|
||||
st.properties.insert("open".to_owned(), open.to_string());
|
||||
world.set(at[0], y, at[2], registry.resolve(&st)?)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
mobs.append(&mut born);
|
||||
mobs.append(&mut added);
|
||||
for e in &heart_events {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//!
|
||||
//! usage: pack_screen_fixture RESOURCES CASES OUTPUT
|
||||
use horizon_client::{
|
||||
hud::HudAtlas,
|
||||
hud::{HudAtlas, PACK_ICON_SIZE},
|
||||
lang,
|
||||
pack_screen::{Compat, PackInfo, PackScreen, Source},
|
||||
packs::{self, Position},
|
||||
@@ -13,7 +13,7 @@ use horizon_client::{
|
||||
text::Text,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{fmt::Write as _, fs, path::PathBuf};
|
||||
use std::{fmt::Write as _, fs, path::PathBuf, sync::Arc};
|
||||
|
||||
/// Styled runs: [text, rgb or null, bold, italic, underlined, strikethrough, obfuscated].
|
||||
pub fn runs(t: &Text) -> Value {
|
||||
@@ -105,7 +105,9 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
Some("unknown") => Compat::Unknown,
|
||||
_ => Compat::Compatible,
|
||||
},
|
||||
icon: p["icon"].as_bool().unwrap_or(false),
|
||||
icon: p["icon"].as_bool().unwrap_or(false).then(|| {
|
||||
Arc::from(vec![0; (PACK_ICON_SIZE * PACK_ICON_SIZE * 4) as usize])
|
||||
}),
|
||||
required: p["required"].as_bool().unwrap_or(false),
|
||||
position: if p["position"] == "top" {
|
||||
Position::Top
|
||||
|
||||
@@ -136,7 +136,6 @@ fn scene(
|
||||
blend_radius,
|
||||
smooth_lighting,
|
||||
cutout_leaves,
|
||||
cull_leaves: false,
|
||||
};
|
||||
let section_y = int(&row["section_y"])?;
|
||||
let started = Instant::now();
|
||||
|
||||
@@ -147,7 +147,6 @@ const PAGES: Ty = Ty::Enum(&[
|
||||
"graphics-advanced",
|
||||
"extras",
|
||||
"horizon",
|
||||
"shader-packs",
|
||||
"agent",
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
//! `ConfirmScreen`: a title, a message wrapped to `width - 50` (15 lines at
|
||||
//! most) and two 150-wide buttons 4 apart and 16 below it, the whole column
|
||||
//! centred; Escape answers no.
|
||||
use crate::{
|
||||
font::Font,
|
||||
hud::{HudAtlas, HudBatch},
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
|
||||
pub struct Confirm {
|
||||
/// Each line and where it is drawn.
|
||||
pub lines: Vec<(Text, f32, f32)>,
|
||||
/// The yes and no buttons' corners.
|
||||
pub buttons: [(f32, f32); 2],
|
||||
}
|
||||
impl Confirm {
|
||||
pub fn new(font: &Font, (w, h): (f32, f32), title: &Text, message: &Text) -> Self {
|
||||
let message: Vec<Text> = font
|
||||
.split_lines(message, w - 50.0)
|
||||
.into_iter()
|
||||
.map(|(line, _)| line)
|
||||
.take(15)
|
||||
.collect();
|
||||
let tw = font.width_text(title);
|
||||
let mw = message
|
||||
.iter()
|
||||
.map(|l| font.width_text(l))
|
||||
.fold(0.0, f32::max);
|
||||
let mh = 9.0 * message.len() as f32;
|
||||
let col = tw.max(mw).max(304.0);
|
||||
let total = 9.0 + 8.0 + mh + 8.0 + 36.0;
|
||||
let (lx, ly) = (((w - col) / 2.0).trunc(), ((h - total) / 2.0).trunc());
|
||||
let cell = |cw: f32| lx + ((col - cw) / 2.0).trunc();
|
||||
let mid = cell(mw) + (mw / 2.0).trunc();
|
||||
let mut lines = vec![(title.clone(), cell(tw), ly)];
|
||||
for (i, line) in message.into_iter().enumerate() {
|
||||
let x = mid - (font.width_text(&line) / 2.0).trunc();
|
||||
lines.push((line, x, ly + 17.0 + 9.0 * i as f32));
|
||||
}
|
||||
let (bx, by) = (cell(304.0), ly + 9.0 + 8.0 + mh + 8.0 + 16.0);
|
||||
Self {
|
||||
lines,
|
||||
buttons: [(bx, by), (bx + 154.0, by)],
|
||||
}
|
||||
}
|
||||
/// The answer a click at `cursor` gives: true for yes, false for no.
|
||||
pub fn answer_at(&self, cursor: (f32, f32)) -> Option<bool> {
|
||||
let [yes, no] = self.buttons;
|
||||
let on = |(x, y): (f32, f32)| ui::hovered(cursor, x, y, 150.0, 20.0);
|
||||
if on(yes) {
|
||||
Some(true)
|
||||
} else if on(no) {
|
||||
Some(false)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
/// The lines and the buttons (yes first), over whatever is behind.
|
||||
pub fn draw(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
labels: [&Text; 2],
|
||||
) {
|
||||
for (line, x, y) in &self.lines {
|
||||
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
|
||||
}
|
||||
for ((bx, by), label) in self.buttons.into_iter().zip(labels) {
|
||||
let hover = ui::hovered(cursor, bx, by, 150.0, 20.0);
|
||||
ui::button(batch, atlas, bx, by, 150.0, 20.0, label, true, hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -382,7 +382,7 @@ impl Providers {
|
||||
);
|
||||
Self { ints, floats }
|
||||
}
|
||||
fn eval(&self, v: &Value, fast: bool, table: &HashMap<String, Value>, depth: u32) -> f64 {
|
||||
fn eval(v: &Value, fast: bool, table: &HashMap<String, Value>, depth: u32) -> f64 {
|
||||
if depth > 16 {
|
||||
return 0.0;
|
||||
}
|
||||
@@ -390,10 +390,10 @@ impl Providers {
|
||||
Value::Number(n) => n.as_f64().unwrap_or(0.0),
|
||||
Value::String(name) => table
|
||||
.get(name)
|
||||
.map_or(0.0, |v| self.eval(v, fast, table, depth + 1)),
|
||||
.map_or(0.0, |v| Self::eval(v, fast, table, depth + 1)),
|
||||
Value::Object(o) => {
|
||||
let f =
|
||||
|k: &str| self.eval(o.get(k).unwrap_or(&Value::Null), fast, table, depth + 1);
|
||||
|k: &str| Self::eval(o.get(k).unwrap_or(&Value::Null), fast, table, depth + 1);
|
||||
match o.get("type").and_then(Value::as_str).unwrap_or("") {
|
||||
"minecraft:div" => {
|
||||
let r = f("right");
|
||||
@@ -425,14 +425,14 @@ impl Providers {
|
||||
}
|
||||
/// A plain `context_int_provider` value or reference (no context conditions).
|
||||
pub fn int_value(&self, v: &Value) -> i32 {
|
||||
self.eval(v, false, &self.ints, 0) as i32
|
||||
Self::eval(v, false, &self.ints, 0) as i32
|
||||
}
|
||||
/// A plain `context_float_provider` value or reference; `default` when absent.
|
||||
pub fn float_value(&self, v: &Value, default: f32) -> f32 {
|
||||
if v.is_null() {
|
||||
default
|
||||
} else {
|
||||
self.eval(v, false, &self.floats, 0) as f32
|
||||
Self::eval(v, false, &self.floats, 0) as f32
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
@@ -446,14 +446,14 @@ impl Providers {
|
||||
items
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.fuel.as_ref())
|
||||
.map_or(0, |(time, _)| self.eval(time, fast, &self.ints, 0) as i32)
|
||||
.map_or(0, |(time, _)| Self::eval(time, fast, &self.ints, 0) as i32)
|
||||
}
|
||||
pub fn speed(&self, items: &Items, stack: &ItemStack, fast: bool) -> f32 {
|
||||
items
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.fuel.as_ref())
|
||||
.map_or(1.0, |(_, speed)| {
|
||||
self.eval(speed, fast, &self.floats, 0) as f32
|
||||
Self::eval(speed, fast, &self.floats, 0) as f32
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,8 +137,8 @@ mod scoreboard;
|
||||
mod screens;
|
||||
mod sculk;
|
||||
mod sculk_fixture;
|
||||
mod server_packs;
|
||||
mod server_travel;
|
||||
mod shader_packs;
|
||||
mod shield;
|
||||
mod sign_edit;
|
||||
mod sniffers;
|
||||
@@ -1127,6 +1127,8 @@ struct Game<'a> {
|
||||
pending_entry: Option<PendingWorldEntry>,
|
||||
world_loader: Option<world_loading::WorldLoader>,
|
||||
pending_pack_reload: bool,
|
||||
/// A server's resource packs (game/server_packs.rs).
|
||||
server_packs: server_packs::ServerPackState,
|
||||
world_renderer_ready: bool,
|
||||
collision: &'a BlockCollisionRegistry<'a>,
|
||||
motion: &'a MotionResources,
|
||||
@@ -1199,8 +1201,6 @@ struct Game<'a> {
|
||||
modifiers: winit::keyboard::ModifiersState,
|
||||
/// The window's fullscreen request and reported state.
|
||||
fullscreen: horizon_client::window::FullscreenState,
|
||||
/// The selected Horizon shader pack (`game/shader_packs.rs`).
|
||||
shader_packs: shader_packs::ShaderPacks,
|
||||
/// MouseHandler's accumulated look and wheel state.
|
||||
mouse: MouseLook,
|
||||
/// An agent moved the mouse (`mouse-move`) since the pointer was last
|
||||
@@ -1741,10 +1741,10 @@ impl Game<'_> {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let f = self.res.facts().get(state);
|
||||
if !self.res.facts().is_air(state)
|
||||
&& !(f.fluid != Fluid::None
|
||||
&& !f.waterlogged
|
||||
&& &*f.name != "minecraft:kelp"
|
||||
&& f.render_shape != horizon_client::blocks::RenderShape::Model)
|
||||
&& (f.fluid == Fluid::None
|
||||
|| f.waterlogged
|
||||
|| &*f.name == "minecraft:kelp"
|
||||
|| f.render_shape == horizon_client::blocks::RenderShape::Model)
|
||||
&& !matches!(
|
||||
&*f.name,
|
||||
"minecraft:water" | "minecraft:lava" | "minecraft:bubble_column"
|
||||
@@ -3510,7 +3510,7 @@ impl Game<'_> {
|
||||
return Ok(true);
|
||||
}
|
||||
// Block foods attempt placement before starting consumption.
|
||||
if !on_block && !(info.block.is_some() && block_hit.is_some()) && self.start_eating(&info) {
|
||||
if !on_block && (info.block.is_none() || block_hit.is_none()) && self.start_eating(&info) {
|
||||
return Ok(true);
|
||||
}
|
||||
// BoatItem.use also performs its own fluid/block raycast.
|
||||
@@ -4102,7 +4102,7 @@ impl Game<'_> {
|
||||
self.tick_riding(keys)?;
|
||||
}
|
||||
let riding = self.riding.is_some();
|
||||
let ghast_collisions = self.happy_ghast_player_collisions();
|
||||
let entity_collisions = self.player_entity_collisions();
|
||||
let spin_targets = self.spin_targets();
|
||||
self.sync_player_body();
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
@@ -4149,7 +4149,7 @@ impl Game<'_> {
|
||||
);
|
||||
if !riding {
|
||||
MotionWorld {
|
||||
entities: &ghast_collisions,
|
||||
entities: &entity_collisions,
|
||||
player,
|
||||
scene: &mut self.world,
|
||||
assets: &self.pack.assets,
|
||||
@@ -4774,7 +4774,14 @@ impl Game<'_> {
|
||||
hud_layout::paint(batch, &self.gui.atlas, &draws);
|
||||
}
|
||||
fn hud(&mut self, size: [u32; 2]) -> HudBatch {
|
||||
let mut batch = self.hud_layers(size);
|
||||
// The pack prompt replaces the connecting screen (setScreen), but
|
||||
// in game it opens over the world.
|
||||
let mut batch = if self.server_packs.prompt.is_some() && self.player.is_none() {
|
||||
HudBatch::new(size)
|
||||
} else {
|
||||
self.hud_layers(size)
|
||||
};
|
||||
self.draw_pack_prompt(&mut batch);
|
||||
// Gui.extractRenderState: the debug overlay comes after the screen
|
||||
// and the toasts, so it is over everything, menus included.
|
||||
self.draw_debug_overlay(&mut batch, size);
|
||||
@@ -7434,30 +7441,17 @@ impl Game<'_> {
|
||||
self.tick_player_model();
|
||||
Ok(())
|
||||
}
|
||||
/// What the loaded Java shader pack changes in this dimension's vanilla
|
||||
/// sky (`sunPathRotation`, `clouds`, `sun`, `moon`); `None` without one.
|
||||
fn java_sky(&self) -> Option<(f32, horizon_shaderpack::properties::SkyDirectives)> {
|
||||
self.shader_packs.java_sky(match self.dim {
|
||||
Dim::Overworld => 0,
|
||||
Dim::Nether => -1,
|
||||
Dim::End => 1,
|
||||
})
|
||||
}
|
||||
/// SkyRenderer.renderSunMoonAndStars: bodies on a frame rotated Y -90 then X by angle.
|
||||
/// A Java pack's `sunPathRotation` tilts it about Z in between, and its
|
||||
/// `sun = false` / `moon = false` leave those out, as Iris does. Returns
|
||||
/// the sun and moon quads, and the stars' frame and brightness (the
|
||||
/// renderer keeps their geometry, built once).
|
||||
/// Returns the sun and moon quads, and the stars' frame and brightness
|
||||
/// (the renderer keeps their geometry, built once).
|
||||
fn celestial_geometry(
|
||||
&self,
|
||||
sky: &horizon_client::environment::Sky,
|
||||
) -> (Vec<Vertex>, Option<(glam::Mat4, f32)>) {
|
||||
let mut out = Vec::new();
|
||||
let atlas = &self.gui.atlas;
|
||||
let (tilt, keep) = self.java_sky().unwrap_or_default();
|
||||
let frame = |angle: f32| {
|
||||
glam::Mat4::from_rotation_y((-90.0f32).to_radians())
|
||||
* glam::Mat4::from_rotation_z(tilt.to_radians())
|
||||
* glam::Mat4::from_rotation_x(angle.to_radians())
|
||||
};
|
||||
let mut body = |name: &str, angle: f32, size: f32, color: u32| {
|
||||
@@ -7479,14 +7473,12 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
};
|
||||
if keep.sun {
|
||||
body(
|
||||
"environment/celestial/sun",
|
||||
sky.sun_angle,
|
||||
30.0,
|
||||
0xFFFF_FFFF,
|
||||
);
|
||||
}
|
||||
body(
|
||||
"environment/celestial/sun",
|
||||
sky.sun_angle,
|
||||
30.0,
|
||||
0xFFFF_FFFF,
|
||||
);
|
||||
let phases = [
|
||||
"full_moon",
|
||||
"waning_gibbous",
|
||||
@@ -7498,14 +7490,12 @@ impl Game<'_> {
|
||||
"waxing_gibbous",
|
||||
];
|
||||
let phase = phases[(self.world.day_time / 24000).rem_euclid(8) as usize];
|
||||
if keep.moon {
|
||||
body(
|
||||
&format!("environment/celestial/moon/{phase}"),
|
||||
sky.moon_angle,
|
||||
20.0,
|
||||
0xFFFF_FFFF,
|
||||
);
|
||||
}
|
||||
body(
|
||||
&format!("environment/celestial/moon/{phase}"),
|
||||
sky.moon_angle,
|
||||
20.0,
|
||||
0xFFFF_FFFF,
|
||||
);
|
||||
// SkyRenderer.renderStars: the static star buffer on the same frame.
|
||||
let stars =
|
||||
(sky.star_brightness > 0.0).then(|| (frame(sky.star_angle), sky.star_brightness));
|
||||
@@ -9100,21 +9090,11 @@ impl Game<'_> {
|
||||
// Options.biomeBlendRadius, ambientOcclusion and cutoutLeaves:
|
||||
// LevelExtractor.allChanged when they change.
|
||||
let (s, st) = (&self.settings, &self.streaming);
|
||||
let remesh = (
|
||||
s.biome_blend,
|
||||
s.smooth_lighting,
|
||||
s.cutout_leaves,
|
||||
s.horizon.cull_leaves,
|
||||
) != (
|
||||
st.biome_blend,
|
||||
st.smooth_lighting,
|
||||
st.cutout_leaves,
|
||||
st.cull_leaves,
|
||||
);
|
||||
let remesh = (s.biome_blend, s.smooth_lighting, s.cutout_leaves)
|
||||
!= (st.biome_blend, st.smooth_lighting, st.cutout_leaves);
|
||||
self.streaming.biome_blend = self.settings.biome_blend;
|
||||
self.streaming.smooth_lighting = self.settings.smooth_lighting;
|
||||
self.streaming.cutout_leaves = self.settings.cutout_leaves;
|
||||
self.streaming.cull_leaves = self.settings.horizon.cull_leaves;
|
||||
if remesh {
|
||||
self.all_sections_changed();
|
||||
}
|
||||
@@ -9831,7 +9811,6 @@ impl Game<'_> {
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
self.poll_shader_pack();
|
||||
let Some(size) = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
@@ -10044,18 +10023,6 @@ impl Game<'_> {
|
||||
self.dragon_fireball_geometry(camera, alpha, &mut dragon);
|
||||
entities.extend(dragon);
|
||||
}
|
||||
// A Java pack's `clouds` replaces the cloud option (`renderClouds`:
|
||||
// "false", "fast", "true"), as Iris overrides `getCloudsType`.
|
||||
let clouds_mode = {
|
||||
use horizon_shaderpack::properties::CloudSetting;
|
||||
match self.java_sky().and_then(|(_, sky)| sky.clouds) {
|
||||
Some(CloudSetting::Off) => "false",
|
||||
Some(CloudSetting::Fast) => "fast",
|
||||
Some(CloudSetting::Fancy) => "true",
|
||||
None => self.settings.clouds.as_str(),
|
||||
}
|
||||
.to_owned()
|
||||
};
|
||||
let (celestial, stars) = if title {
|
||||
(Vec::new(), None)
|
||||
} else if self.dim == Dim::Overworld
|
||||
@@ -10099,7 +10066,6 @@ impl Game<'_> {
|
||||
let frame = Frame {
|
||||
weather: &weather,
|
||||
weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32),
|
||||
shader: self.shader_inputs(camera, alpha as f32),
|
||||
nether_lighting: self.dim == Dim::Nether,
|
||||
camera,
|
||||
yaw,
|
||||
@@ -10147,7 +10113,7 @@ impl Game<'_> {
|
||||
hud: &hud,
|
||||
clouds: (!title
|
||||
&& self.dim == Dim::Overworld
|
||||
&& horizon_client::clouds::visible(&clouds_mode, sky.cloud_color[3]))
|
||||
&& horizon_client::clouds::visible(&self.settings.clouds, sky.cloud_color[3]))
|
||||
.then(|| self.gui.atlas.sprites.get("horizon/white"))
|
||||
.flatten()
|
||||
.map(|white| horizon_client::clouds::Input {
|
||||
@@ -10157,7 +10123,7 @@ impl Game<'_> {
|
||||
time: self.world.game_time,
|
||||
partial_tick: alpha,
|
||||
range: self.settings.cloud_range,
|
||||
fast: clouds_mode == "fast",
|
||||
fast: self.settings.clouds == "fast",
|
||||
color: sky.cloud_color,
|
||||
}),
|
||||
entities: &entities,
|
||||
@@ -10875,9 +10841,6 @@ impl ApplicationHandler for Game<'_> {
|
||||
WindowEvent::CloseRequested => {
|
||||
self.pending_save.as_mut().unwrap().quit = true;
|
||||
}
|
||||
WindowEvent::DroppedFile(path) => {
|
||||
self.drop_shader_pack(&path);
|
||||
}
|
||||
WindowEvent::Focused(focused) => {
|
||||
self.focused = focused;
|
||||
if !focused {
|
||||
@@ -11032,6 +10995,13 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.advancements_scroll(scaled(raw_x) as f32, d);
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. }
|
||||
if self.server_packs.prompt.is_some() =>
|
||||
{
|
||||
if state == ElementState::Pressed && button == MouseButton::Left {
|
||||
self.pack_prompt_click()?;
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { .. } if self.screen == Screen::Win => {}
|
||||
WindowEvent::MouseInput { state, button, .. } if self.screen == Screen::Options => {
|
||||
if self.options_waiting_for_key() && state == ElementState::Pressed {
|
||||
@@ -11231,6 +11201,14 @@ impl ApplicationHandler for Game<'_> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// ConfirmScreen.keyPressed: Escape answers no.
|
||||
WindowEvent::KeyboardInput { event, .. } if self.server_packs.prompt.is_some() => {
|
||||
if event.state == ElementState::Pressed
|
||||
&& event.physical_key == PhysicalKey::Code(KeyCode::Escape)
|
||||
{
|
||||
self.answer_pack_prompt(false)?;
|
||||
}
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. }
|
||||
if self.screen == Screen::Chat && self.title.is_none() =>
|
||||
{
|
||||
@@ -11609,6 +11587,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
self.tick_server_packs();
|
||||
self.poll_control(event_loop);
|
||||
if self.pending_save.is_some() {
|
||||
return;
|
||||
@@ -11703,6 +11682,7 @@ impl<'a> Game<'a> {
|
||||
pending_entry: None,
|
||||
world_loader: None,
|
||||
pending_pack_reload: false,
|
||||
server_packs: Default::default(),
|
||||
world_renderer_ready: false,
|
||||
collision: res.collision(),
|
||||
motion: res.motion(),
|
||||
@@ -11760,7 +11740,6 @@ impl<'a> Game<'a> {
|
||||
focused: true,
|
||||
modifiers: winit::keyboard::ModifiersState::empty(),
|
||||
fullscreen: horizon_client::window::FullscreenState::new(false),
|
||||
shader_packs: Default::default(),
|
||||
mouse: MouseLook::default(),
|
||||
agent_pointer: false,
|
||||
smooth_camera: false,
|
||||
@@ -12719,7 +12698,6 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
night_vision: 0.0,
|
||||
block_flicker: 0.0,
|
||||
menu_blur: None,
|
||||
shader: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13293,7 +13271,7 @@ pub fn run() -> Result<()> {
|
||||
return run_loading(options, early_settings, vanilla_root, None, started);
|
||||
}
|
||||
let mut pack_error = None;
|
||||
options.resources = packs::startup_root(&vanilla_root, &early_settings.resource_packs)
|
||||
options.resources = packs::startup_root(&vanilla_root, &early_settings.resource_packs, &[])
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("Horizon: resource packs not applied: {e}");
|
||||
pack_error = Some(e.to_string());
|
||||
|
||||
@@ -9,6 +9,10 @@ use horizon_client::{
|
||||
block_entity_anim::Ticker,
|
||||
chunk::{MIN_Y, SECTIONS},
|
||||
items::ItemStack,
|
||||
mobs::{
|
||||
Species,
|
||||
render::{PROFESSIONS, VILLAGER_TYPES},
|
||||
},
|
||||
widgets,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
@@ -592,10 +596,20 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
for m in self.mobs.iter().filter(|m| m.alive()) {
|
||||
let mut v = json!({ "id": m.id, "kind": "mob", "health": m.health, "baby": m.is_baby(), "hurt_time": m.hurt_time });
|
||||
// VillagerData as this game knows it: on a client, what the
|
||||
// server's synched data says.
|
||||
if matches!(m.species(), Species::Villager | Species::ZombieVillager) {
|
||||
v["villager"] = json!({
|
||||
"type": VILLAGER_TYPES[m.extra.variant as usize % VILLAGER_TYPES.len()],
|
||||
"profession": PROFESSIONS[m.extra.variant2 as usize % PROFESSIONS.len()],
|
||||
"level": m.extra.variant3,
|
||||
});
|
||||
}
|
||||
push(
|
||||
[m.position.x, m.position.y, m.position.z],
|
||||
m.kind.id.as_ref(),
|
||||
json!({ "id": m.id, "kind": "mob", "health": m.health, "baby": m.is_baby(), "hurt_time": m.hurt_time }),
|
||||
v,
|
||||
);
|
||||
}
|
||||
for item in &self.dropped {
|
||||
|
||||
@@ -59,6 +59,30 @@ impl Game<'_> {
|
||||
}
|
||||
/// Block commands; None when `words[0]` is not one of them.
|
||||
pub(super) fn block_command(&mut self, words: &[&str]) -> Result<Option<Value>> {
|
||||
let ours = matches!(
|
||||
words.first(),
|
||||
Some(&("setblock" | "fill" | "getblock" | "container"))
|
||||
);
|
||||
// Single player on its server thread: the world is the server's, so
|
||||
// the command runs there, in the owner's level, and the client hears
|
||||
// of it like any change.
|
||||
if ours && let Some(server) = &self.integrated {
|
||||
let owned: Vec<String> = words.iter().map(|w| (*w).to_owned()).collect();
|
||||
return server
|
||||
.call(move |g| {
|
||||
let words: Vec<&str> = owned.iter().map(String::as_str).collect();
|
||||
g.in_owner_level(|g| g.block_command(&words).map_err(|e| e.to_string()))
|
||||
})
|
||||
.ok_or("the integrated server stopped")?
|
||||
.map_err(Into::into);
|
||||
}
|
||||
// Another server's world changes only through its own commands, which
|
||||
// it checks as the player's; `getblock` and `container` read what this
|
||||
// client has of it.
|
||||
if self.remote.is_some() && matches!(words.first(), Some(&("setblock" | "fill"))) {
|
||||
self.send_chat(&format!("/{}", words.join(" ")));
|
||||
return Ok(Some(json!({ "ok": true, "sent": true })));
|
||||
}
|
||||
let int = |i: usize| -> Result<i32> {
|
||||
Ok(words
|
||||
.get(i)
|
||||
|
||||
@@ -183,13 +183,18 @@ impl BoatModels {
|
||||
}
|
||||
impl Game<'_> {
|
||||
fn fluid_height(&self, x: i32, y: i32, z: i32) -> Option<(f32, bool)> {
|
||||
self.fluid_height_of(x, y, z, Some(Fluid::Water))
|
||||
}
|
||||
/// `FluidState.getHeight` and whether it is a source, for `fluid` or,
|
||||
/// with `None`, any fluid (`ClipContext.Fluid.ANY`).
|
||||
fn fluid_height_of(&self, x: i32, y: i32, z: i32, fluid: Option<Fluid>) -> Option<(f32, bool)> {
|
||||
let f = self.res.facts().get(self.world.block(x, y, z));
|
||||
if f.fluid != Fluid::Water {
|
||||
if f.fluid == Fluid::None || fluid.is_some_and(|want| f.fluid != want) {
|
||||
return None;
|
||||
}
|
||||
let above = self.res.facts().get(self.world.block(x, y + 1, z));
|
||||
// FluidState.getHeight: 1 under the same fluid, else amount / 9.
|
||||
let height = if above.fluid == Fluid::Water {
|
||||
let height = if above.fluid == f.fluid {
|
||||
1.0
|
||||
} else {
|
||||
f32::from(f.fluid_level) / 9.0
|
||||
@@ -461,6 +466,59 @@ impl Game<'_> {
|
||||
let b = self.boats[i].clone();
|
||||
self.boats[i] = self.tick_boat(b, false);
|
||||
}
|
||||
// AbstractBoat.tick's applyEffectsFromBlocks, on the server for every
|
||||
// boat, the client-driven ones too.
|
||||
let mut i = 0;
|
||||
while i < self.boats.len() {
|
||||
let p = self.boats[i].position;
|
||||
let skip = Some(self.boats[i].id) == self.riding
|
||||
|| !ticking(p.x.floor() as i32, p.z.floor() as i32);
|
||||
if skip || !self.boat_lava_tick(i) {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `LavaFluid.entityInside` (`lavaHurt`) from the two
|
||||
/// `applyEffectsFromBlocks` calls in `AbstractBoat.tick`: twice 40
|
||||
/// damage, so lava breaks a boat on its first tick inside, and the fire
|
||||
/// `lavaIgnite` lights never gets to burn it. True when the boat broke.
|
||||
fn boat_lava_tick(&mut self, i: usize) -> bool {
|
||||
if !self.in_lava(self.boats[i].bounds()) {
|
||||
return false;
|
||||
}
|
||||
for _ in 0..2 {
|
||||
let p = self.boats[i].position;
|
||||
let broke = self.hurt_boat(i, 4.0, false);
|
||||
self.lava_hurt_sound([p.x, p.y, p.z]);
|
||||
if broke {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
/// `Entity.lavaHurt`'s sound once the hurt went through.
|
||||
pub(super) fn lava_hurt_sound(&mut self, at: [f64; 3]) {
|
||||
let pitch = 2.0 + self.world.random.next_float() * 0.4;
|
||||
self.audio
|
||||
.play("minecraft:entity.generic.burn", Some(at), 0.4, pitch);
|
||||
}
|
||||
/// Whether a vehicle's box, deflated by 1e-5, reaches into lava below its
|
||||
/// surface (`Entity.collidedWithFluid`).
|
||||
pub(super) fn in_lava(&self, bb: Aabb) -> bool {
|
||||
let (lo, hi) = (bb.min_position(), bb.max_position());
|
||||
let e = 1.0e-5;
|
||||
for x in (lo.x + e).floor() as i32..=(hi.x - e).floor() as i32 {
|
||||
for y in (lo.y + e).floor() as i32..=(hi.y - e).floor() as i32 {
|
||||
for z in (lo.z + e).floor() as i32..=(hi.z - e).floor() as i32 {
|
||||
if let Some((height, _)) = self.fluid_height_of(x, y, z, Some(Fluid::Lava))
|
||||
&& lo.y + e < f64::from(y) + f64::from(height)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
/// The player's tick while riding: steer, tick the boat, sit in it.
|
||||
pub(super) fn tick_riding(&mut self, keys: Keys) -> horizon_client::Result<()> {
|
||||
@@ -489,6 +547,9 @@ impl Game<'_> {
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
self.boats[i] = b;
|
||||
if self.boat_lava_tick(i) {
|
||||
self.riding = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `getDismountLocationForPassenger`: in front of the player if there is
|
||||
@@ -534,7 +595,8 @@ impl Game<'_> {
|
||||
};
|
||||
let dir = view_vector(self.yaw, self.pitch);
|
||||
let reach = self.reach();
|
||||
// ClipContext.Fluid.ANY: the first water surface or block along the ray.
|
||||
// ClipContext.Fluid.ANY: the first fluid surface (water or lava) or
|
||||
// block along the ray.
|
||||
let block = self.pick().map(|h| {
|
||||
(
|
||||
h.point,
|
||||
@@ -544,7 +606,7 @@ impl Game<'_> {
|
||||
.sqrt(),
|
||||
)
|
||||
});
|
||||
let mut water = None;
|
||||
let mut fluid = None;
|
||||
let mut t = 0.0;
|
||||
while t < reach {
|
||||
let p: [f64; 3] = std::array::from_fn(|a| eye[a] + dir[a] * t);
|
||||
@@ -553,15 +615,15 @@ impl Game<'_> {
|
||||
p[1].floor() as i32,
|
||||
p[2].floor() as i32,
|
||||
);
|
||||
if let Some((h, _)) = self.fluid_height(x, y, z)
|
||||
if let Some((h, _)) = self.fluid_height_of(x, y, z, None)
|
||||
&& p[1] <= f64::from(y) + f64::from(h)
|
||||
{
|
||||
water = Some(([p[0], f64::from(y) + f64::from(h), p[2]], t));
|
||||
fluid = Some(([p[0], f64::from(y) + f64::from(h), p[2]], t));
|
||||
break;
|
||||
}
|
||||
t += 0.05;
|
||||
}
|
||||
let hit = match (block, water) {
|
||||
let hit = match (block, fluid) {
|
||||
(Some(b), Some(w)) => {
|
||||
if w.1 < b.1 {
|
||||
w.0
|
||||
@@ -682,15 +744,23 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
self.vehicle_attack_sounds();
|
||||
self.hurt_boat(i, damage, self.creative);
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
/// `VehicleEntity.hurtServer`: wobble, add the damage and break past 40
|
||||
/// (`creative`: a creative player's hit removes it without drops).
|
||||
/// True when the boat was removed.
|
||||
pub(super) fn hurt_boat(&mut self, i: usize, damage: f32, creative: bool) -> bool {
|
||||
let b = &mut self.boats[i];
|
||||
b.hurt_dir = -b.hurt_dir;
|
||||
b.hurt_time = 10;
|
||||
b.damage += damage * 10.0;
|
||||
let destroyed = self.creative || b.damage > 40.0;
|
||||
let destroyed = creative || b.damage > 40.0;
|
||||
if destroyed {
|
||||
let b = self.boats.remove(i);
|
||||
let at = [b.position.x, b.position.y, b.position.z];
|
||||
if !self.creative && self.ui.rules.bool("entity_drops") {
|
||||
if !creative && self.ui.rules.bool("entity_drops") {
|
||||
// ChestBoat.destroy: chestVehicleDestroyed spills the contents.
|
||||
for stack in b.items.iter().flatten() {
|
||||
self.spawn_item(stack.clone(), at, super::dispenser::new_item_motion(), 10);
|
||||
@@ -709,8 +779,7 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
self.swing_arm();
|
||||
true
|
||||
destroyed
|
||||
}
|
||||
/// AbstractBoatRenderer.
|
||||
pub(super) fn boat_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Chat, the command registries and the per-world state behind commands
|
||||
//! (game rules, the world clock, difficulty), bound to the game.
|
||||
use super::{Dim, Game, Screen};
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
chat::{Chat, ChatAction, ChatScreen, Commands, SYSTEM_INDICATOR},
|
||||
command::Registries,
|
||||
@@ -695,10 +695,7 @@ impl Game<'_> {
|
||||
/// Advance the overworld clock (ServerClockManager.tick) when
|
||||
/// `advance_time` allows it.
|
||||
pub(super) fn advance_clock(&mut self) {
|
||||
if !self.ui.rules.bool("advance_time")
|
||||
|| self.ui.clock.paused
|
||||
|| self.dim == Dim::Nether && false
|
||||
{
|
||||
if !self.ui.rules.bool("advance_time") || self.ui.clock.paused {
|
||||
return;
|
||||
}
|
||||
let c = &mut self.ui.clock;
|
||||
|
||||
@@ -1783,8 +1783,11 @@ impl Game<'_> {
|
||||
self.level_set(pos, state)
|
||||
.map_err(|e| CommandError::new(Text::literal(e.to_string())))?;
|
||||
}
|
||||
// LevelChunk.setBlockState creates it when the block, not the state, is still
|
||||
// there after onPlace: a powered hopper turns itself `enabled=false`.
|
||||
let landed = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if old != new
|
||||
&& self.world.block(pos[0], pos[1], pos[2]) == state
|
||||
&& self.res.facts().get(landed).name == new
|
||||
&& let Some(entity) = horizon_client::containers::BlockEntity::for_block(&new)
|
||||
{
|
||||
self.world.block_entities.insert(key, entity);
|
||||
|
||||
@@ -128,7 +128,7 @@ impl Game<'_> {
|
||||
if self.state_in_tag(avoid, ground) {
|
||||
return false;
|
||||
}
|
||||
let entities = self.happy_ghast_player_collisions();
|
||||
let entities = self.player_entity_collisions();
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -589,7 +589,6 @@ impl Game<'_> {
|
||||
"credits" => Page::Credits,
|
||||
"horizon" => Page::Horizon,
|
||||
"agent" => Page::Agent,
|
||||
"shader-packs" => Page::ShaderPacks,
|
||||
other => return Err(format!("unknown options page {other}").into()),
|
||||
};
|
||||
if self.screen == Screen::Playing && self.title.is_none() {
|
||||
|
||||
@@ -1007,6 +1007,11 @@ impl Game<'_> {
|
||||
MobEvent::SetBlock { pos, state } => {
|
||||
self.world.set(pos[0], pos[1], pos[2], state)?
|
||||
}
|
||||
MobEvent::SetDoorOpen { pos, open } => {
|
||||
let pos = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| b.set_door_open(level, ticks, pos, open));
|
||||
self.process_level_events()?;
|
||||
}
|
||||
MobEvent::SetBlockName { pos, name, effect } => {
|
||||
if effect {
|
||||
let old = self.world.block(pos[0], pos[1], pos[2]);
|
||||
@@ -1491,12 +1496,16 @@ impl Game<'_> {
|
||||
o.hurt
|
||||
}
|
||||
{
|
||||
let (xd, zd) = (from[0] - player.position.x, from[2] - player.position.z);
|
||||
self.knock_back_player(f64::from(0.4_f32), xd, zd);
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& let Some(r) = p.hurt_log.last_mut()
|
||||
{
|
||||
r.knockback = Some([p.velocity.x, p.velocity.y, p.velocity.z]);
|
||||
// LivingEntity.hurtServer's dealDefaultKnockback: not inside the
|
||||
// hurt cooldown (a stronger hit there only adds its difference).
|
||||
if o.knockback {
|
||||
let (xd, zd) = (from[0] - player.position.x, from[2] - player.position.z);
|
||||
self.knock_back_player(f64::from(0.4_f32), xd, zd);
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& let Some(r) = p.hurt_log.last_mut()
|
||||
{
|
||||
r.knockback = Some([p.velocity.x, p.velocity.y, p.velocity.z]);
|
||||
}
|
||||
}
|
||||
self.hurt_armor(amount);
|
||||
self.thorns(from);
|
||||
|
||||
@@ -38,7 +38,7 @@ impl DebugHost for Host<'_, '_> {
|
||||
// the menu as it always has (key_pressed).
|
||||
let g = &mut *self.game;
|
||||
if without_menu && g.screen == Screen::Playing {
|
||||
g.pause_menu = !(g.has_singleplayer_server() && !g.is_published());
|
||||
g.pause_menu = !g.has_singleplayer_server() || g.is_published();
|
||||
g.screen = Screen::Paused;
|
||||
g.capture(false);
|
||||
g.mining = None;
|
||||
@@ -82,8 +82,10 @@ impl Game<'_> {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Screens with a text box take every key press first
|
||||
// (EditBox.keyPressed); releases still reach KeyboardHandler.
|
||||
// Vanilla types into a focused EditBox through charTyped, which no
|
||||
// debug key can swallow. Here the character comes with the key press,
|
||||
// so a screen with a focused text box takes every press first;
|
||||
// releases still reach KeyboardHandler.
|
||||
let typing = matches!(
|
||||
self.screen,
|
||||
Screen::Chat | Screen::Options | Screen::WorldOptions | Screen::CommandBlock
|
||||
@@ -91,7 +93,11 @@ impl Game<'_> {
|
||||
|| self.book.is_some()
|
||||
|| (self.screen == Screen::Creative && self.creative_screen.tab().search)
|
||||
|| self.recipe_search_focused()
|
||||
|| self.station_typing();
|
||||
|| self.station_typing()
|
||||
|| self
|
||||
.title
|
||||
.as_mut()
|
||||
.is_some_and(|t| t.focused_field().is_some());
|
||||
// Container screens use Escape, the inventory key and the hotbar and
|
||||
// drop keys first; any other screen closes on Escape.
|
||||
let screen_key = match self.screen {
|
||||
@@ -593,7 +599,6 @@ impl Game<'_> {
|
||||
DebugAction::ReloadResourcePacks => {
|
||||
self.debug_translated("debug.reload_resourcepacks.message");
|
||||
self.reload_packs();
|
||||
self.load_shader_pack();
|
||||
true
|
||||
}
|
||||
DebugAction::CopyLocation if player => {
|
||||
|
||||
@@ -608,6 +608,7 @@ impl Game<'_> {
|
||||
lang: Arc::new(horizon_client::net::load_language(&self.options.resources)),
|
||||
log_ips: props.log_ips,
|
||||
}),
|
||||
resource_pack: props.resource_pack.clone(),
|
||||
};
|
||||
let config = HubConfig {
|
||||
default_game_mode: game_mode,
|
||||
|
||||
@@ -121,11 +121,13 @@ impl Game<'_> {
|
||||
let bonus = if knockback_attack { 0.5 } else { 0.0 };
|
||||
(enchanted / 2.0 + bonus, knockback_attack)
|
||||
}
|
||||
/// `causeExtraKnockback`'s direction, `(sin yRot, -cos yRot)`: the target
|
||||
/// flies where the attacker looks, not away from it.
|
||||
/// `causeExtraKnockback`'s direction, `(sin yRot, -cos yRot)` from Mth's
|
||||
/// sine table: the target flies where the attacker looks, not away from
|
||||
/// it (straight along an axis when the attacker faces one).
|
||||
pub(super) fn extra_knockback_direction(&self) -> (f64, f64) {
|
||||
let yaw = self.yaw.to_radians();
|
||||
(f64::from(yaw.sin()), -f64::from(yaw.cos()))
|
||||
use horizon_core::geometry::trig;
|
||||
let yaw = f64::from(self.yaw * trig::DEG_TO_RAD);
|
||||
(f64::from(trig::sin(yaw)), -f64::from(trig::cos(yaw)))
|
||||
}
|
||||
/// The attacker's side of `causeExtraKnockback` as the attacking client
|
||||
/// sees it: the server's player stops sprinting, and its flag reaches the
|
||||
|
||||
@@ -200,7 +200,7 @@ impl Game<'_> {
|
||||
}
|
||||
Action::Copy => self.friends_copy(),
|
||||
Action::Stop => self.friends_stop(),
|
||||
// The system browser, as the Shader Packs page opens its folder.
|
||||
// The system browser, as the folder buttons open their folders.
|
||||
Action::Help => {
|
||||
if let Some(url) = &view.help {
|
||||
super::packs::open_folder(std::path::Path::new(url));
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! box each, and Done sending the changed ones (ServerboundSetGameRulePacket).
|
||||
//! Leaving with changes asks first (ConfirmScreen).
|
||||
use horizon_client::{
|
||||
confirm_screen::Confirm,
|
||||
font::Font,
|
||||
gamerules::{self, GameRules, RULES, Rule, Value},
|
||||
hud::{HudAtlas, HudBatch},
|
||||
@@ -66,9 +67,6 @@ fn parse(r: &Rule, s: &str) -> Option<i32> {
|
||||
.flatten()
|
||||
.filter(|v| (r.min..=r.max).contains(v))
|
||||
}
|
||||
fn widest(font: &Font, lines: &[Text]) -> f32 {
|
||||
lines.iter().map(|l| font.width_text(l)).fold(0.0, f32::max)
|
||||
}
|
||||
fn lines(font: &Font, t: &Text, width: f32) -> Vec<Text> {
|
||||
font.split_lines(t, width)
|
||||
.into_iter()
|
||||
@@ -95,38 +93,11 @@ fn yellow() -> u32 {
|
||||
named_color("yellow").unwrap_or(0xFFFF55)
|
||||
}
|
||||
|
||||
/// ConfirmScreen: the title, the message (at most `width - 50` wide), and
|
||||
/// Yes / No 16 below, 4 apart, all centred.
|
||||
struct Confirm {
|
||||
lines: Vec<(Text, f32, f32)>,
|
||||
buttons: [(f32, f32); 2],
|
||||
}
|
||||
impl Confirm {
|
||||
fn new(font: &Font, (w, h): (f32, f32)) -> Self {
|
||||
let title = Text::translate("editGamerule.inGame.discardChanges.title", &[]);
|
||||
let message = Text::translate("editGamerule.inGame.discardChanges.message", &[]);
|
||||
let message: Vec<Text> = lines(font, &message, w - 50.0)
|
||||
.into_iter()
|
||||
.take(15)
|
||||
.collect();
|
||||
let (tw, mw) = (font.width_text(&title), widest(font, &message));
|
||||
let mh = 9.0 * message.len() as f32;
|
||||
let col = tw.max(mw).max(304.0);
|
||||
let total = 9.0 + 8.0 + mh + 8.0 + 36.0;
|
||||
let (lx, ly) = (((w - col) / 2.0).trunc(), ((h - total) / 2.0).trunc());
|
||||
let cell = |cw: f32| lx + ((col - cw) / 2.0).trunc();
|
||||
let mid = cell(mw) + (mw / 2.0).trunc();
|
||||
let mut out = vec![(title, cell(tw), ly)];
|
||||
for (i, line) in message.into_iter().enumerate() {
|
||||
let x = mid - (font.width_text(&line) / 2.0).trunc();
|
||||
out.push((line, x, ly + 17.0 + 9.0 * i as f32));
|
||||
}
|
||||
let (bx, by) = (cell(304.0), ly + 9.0 + 8.0 + mh + 8.0 + 16.0);
|
||||
Self {
|
||||
lines: out,
|
||||
buttons: [(bx, by), (bx + 154.0, by)],
|
||||
}
|
||||
}
|
||||
/// ConfirmScreen "Game Rule Changes".
|
||||
fn confirm(font: &Font, size: (f32, f32)) -> Confirm {
|
||||
let title = Text::translate("editGamerule.inGame.discardChanges.title", &[]);
|
||||
let message = Text::translate("editGamerule.inGame.discardChanges.message", &[]);
|
||||
Confirm::new(font, size, &title, &message)
|
||||
}
|
||||
|
||||
impl RulesScreen {
|
||||
@@ -301,13 +272,10 @@ impl RulesScreen {
|
||||
let (w, h) = size;
|
||||
let on = |x: f32, y: f32, bw: f32, bh: f32| ui::hovered(cursor, x, y, bw, bh);
|
||||
if self.confirm {
|
||||
let c = Confirm::new(&atlas.font, size);
|
||||
let [(yx, yy), (nx, ny)] = c.buttons;
|
||||
if on(yx, yy, 150.0, 20.0) {
|
||||
return Some(Exit::Discard);
|
||||
}
|
||||
if on(nx, ny, 150.0, 20.0) {
|
||||
self.confirm = false;
|
||||
match confirm(&atlas.font, size).answer_at(cursor) {
|
||||
Some(true) => return Some(Exit::Discard),
|
||||
Some(false) => self.confirm = false,
|
||||
None => {}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
@@ -367,7 +335,7 @@ impl RulesScreen {
|
||||
let (w, h) = size;
|
||||
let on = |(x, y): (f32, f32)| ui::hovered(cursor, x, y, 150.0, 20.0);
|
||||
if self.confirm {
|
||||
return Confirm::new(&atlas.font, size).buttons.into_iter().any(on);
|
||||
return confirm(&atlas.font, size).answer_at(cursor).is_some();
|
||||
}
|
||||
let [done, cancel] = footer(w, h);
|
||||
if (on(done) && !self.invalid()) || on(cancel) {
|
||||
@@ -384,15 +352,11 @@ impl RulesScreen {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
|
||||
if self.confirm {
|
||||
let c = Confirm::new(&atlas.font, (w, h));
|
||||
for (line, x, y) in &c.lines {
|
||||
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
|
||||
}
|
||||
for ((bx, by), key) in c.buttons.into_iter().zip(["gui.yes", "gui.no"]) {
|
||||
let hover = ui::hovered(cursor, bx, by, 150.0, 20.0);
|
||||
let label = Text::translate(key, &[]);
|
||||
ui::button(batch, atlas, bx, by, 150.0, 20.0, &label, true, hover);
|
||||
}
|
||||
let labels = [
|
||||
Text::translate("gui.yes", &[]),
|
||||
Text::translate("gui.no", &[]),
|
||||
];
|
||||
confirm(&atlas.font, (w, h)).draw(batch, atlas, cursor, [&labels[0], &labels[1]]);
|
||||
return;
|
||||
}
|
||||
let rows = self.rows();
|
||||
|
||||
@@ -10,9 +10,10 @@ use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
impl Game<'_> {
|
||||
/// Client `EntityGetter.getEntityCollisions`: only solid happy ghasts,
|
||||
/// excluding the player's own vehicle.
|
||||
pub(super) fn happy_ghast_player_collisions(&self) -> Vec<Aabb> {
|
||||
/// Client `EntityGetter.getEntityCollisions` for the player: the
|
||||
/// entities that `canBeCollidedWith` it, solid happy ghasts and boats
|
||||
/// (`AbstractBoat.canBeCollidedWith`), except the player's own vehicle.
|
||||
pub(super) fn player_entity_collisions(&self) -> Vec<Aabb> {
|
||||
let Some(p) = &self.player else {
|
||||
return Vec::new();
|
||||
};
|
||||
@@ -24,6 +25,12 @@ impl Game<'_> {
|
||||
&& happy_ghast::collides_with_player(m, p.position.y)
|
||||
})
|
||||
.map(|m| m.bounds())
|
||||
.chain(
|
||||
self.boats
|
||||
.iter()
|
||||
.filter(|b| Some(b.id) != self.riding)
|
||||
.map(super::boats::Boat::bounds),
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
/// `HappyGhast.mobInteract`. Returns whether the click was used.
|
||||
|
||||
@@ -496,6 +496,22 @@ impl Game<'_> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Run `f` with the owner's level in place (the server holds one per
|
||||
/// dimension), then put back the level that was.
|
||||
pub(super) fn in_owner_level<T>(&mut self, f: impl FnOnce(&mut Self) -> T) -> T {
|
||||
let owner = self.serving.as_ref().map(|s| s.owner);
|
||||
let dim = self.lan.as_ref().and_then(|h| {
|
||||
let g = h.hub.guests.iter().find(|g| Some(g.uuid) == owner)?;
|
||||
Some(super::Dim::from_id(&g.dimension))
|
||||
});
|
||||
let back = dim.and_then(|d| self.enter_command_level(d));
|
||||
let out = f(self);
|
||||
if let Some(back) = back {
|
||||
self.enter_command_level(back);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The window's requests, between two ticks.
|
||||
fn run_tasks(&mut self) {
|
||||
let tasks: Vec<Task> = match &self.serving {
|
||||
|
||||
@@ -595,6 +595,11 @@ impl Game<'_> {
|
||||
// A teleport (commands, a pearl) moves the guest; a pearl's is
|
||||
// sent even to where they stand (ServerPlayer.teleport).
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
// A hit with extra knockback stopped the guest's sprint
|
||||
// (Player.causeExtraKnockback's setSprinting(false)).
|
||||
if !m.sprinting {
|
||||
g.sprinting = false;
|
||||
}
|
||||
m.sprinting = false;
|
||||
let teleported = std::mem::take(&mut m.teleported);
|
||||
let moved: f64 = (0..3).map(|k| (at[k] - g.position[k]).powi(2)).sum();
|
||||
@@ -1322,8 +1327,11 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
if let Some(victim) = Self::pvp_victim(host, conn, id) {
|
||||
if let Some(Some(strike)) = self.as_guest(host, conn, |g| g.pvp_strike()) {
|
||||
self.pvp_land(host, victim, &strike);
|
||||
if let Some(Some(strike)) = self.as_guest(host, conn, |g| g.pvp_strike())
|
||||
&& self.pvp_land(host, victim, &strike)
|
||||
&& strike.stops_sprint()
|
||||
{
|
||||
self.as_guest(host, conn, |g| g.stop_sprint_after_extra_knockback());
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1705,6 +1713,17 @@ impl Game<'_> {
|
||||
g.use_delay -= 1;
|
||||
}
|
||||
});
|
||||
// ServerPlayerGameMode.handleBlockBreakAction: a creative
|
||||
// player's START breaks the block once (destroyAndAck) and
|
||||
// leaves nothing being destroyed; its client sends no STOP.
|
||||
// Kept, the dig broke whatever was put there next, every
|
||||
// destroy delay, for as long as the player broke nothing else.
|
||||
if digging.is_some()
|
||||
&& host.hub.guests[gi].creative()
|
||||
&& let Some(play) = host.play.get_mut(&conn)
|
||||
{
|
||||
play.digging = None;
|
||||
}
|
||||
}
|
||||
// Player.tick: a different main-hand item resets the attack
|
||||
// strength, and detectEquipmentUpdates gives the guest its modifiers.
|
||||
@@ -1911,7 +1930,7 @@ impl Game<'_> {
|
||||
let (rules, motion) = (game.world.health_rules, game.motion);
|
||||
let health = &motion.health;
|
||||
let Some(player) = game.player.as_mut() else {
|
||||
return Vec::new();
|
||||
return (Vec::new(), None);
|
||||
};
|
||||
let mut w = MotionWorld {
|
||||
entities: &[],
|
||||
@@ -1987,22 +2006,44 @@ impl Game<'_> {
|
||||
game.border_damage();
|
||||
game.tick_mob_effects();
|
||||
let Some(player) = game.player.as_mut() else {
|
||||
return Vec::new();
|
||||
return (Vec::new(), None);
|
||||
};
|
||||
let _ = player.peaceful_regeneration(rules);
|
||||
let _ = player.food_tick(health, rules);
|
||||
events.append(&mut player.events);
|
||||
events
|
||||
let _ = MotionWorld {
|
||||
entities: &[],
|
||||
player,
|
||||
scene: &mut game.world,
|
||||
assets: &game.pack.assets,
|
||||
collision_binding: game.collision,
|
||||
resources: game.motion,
|
||||
}
|
||||
.server_travel();
|
||||
// A hurt since the last tick marked the velocity (Entity.markHurt):
|
||||
// ServerEntity.sendChanges sends it as it is after this travel.
|
||||
let marked = std::mem::take(&mut player.health.sync_velocity);
|
||||
let sounds = events
|
||||
.into_iter()
|
||||
.filter_map(|e| match e {
|
||||
MotionEvent::HurtSound(sound) => Some(sound),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.collect::<Vec<_>>();
|
||||
(sounds, marked.then_some(player.velocity))
|
||||
});
|
||||
for sound in sounds.into_iter().flatten() {
|
||||
let Some((sounds, marked)) = sounds else {
|
||||
return;
|
||||
};
|
||||
for sound in sounds {
|
||||
self.player_hurt_sound(sound, at);
|
||||
}
|
||||
if let (Some(v), Some(g)) = (marked, host.hub.guest(conn)) {
|
||||
g.send(&C::SetEntityMotion(play::SetEntityMotion {
|
||||
id: g.entity_id,
|
||||
velocity: [v.x, v.y, v.z],
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// ServerGamePacketListenerImpl.handlePlayerCommand START_FALL_FLYING:
|
||||
|
||||
@@ -213,7 +213,9 @@ impl Game<'_> {
|
||||
|
||||
impl Game<'_> {
|
||||
/// Hurt a guest: `f` runs as them, and the knockback it gave reaches
|
||||
/// their client (`hurtMarked`: ClientboundSetEntityMotionPacket).
|
||||
/// their client. A hurt marks the velocity (Entity.markHurt), which goes
|
||||
/// out once the guest's server copy has travelled (`guest_server_tick`);
|
||||
/// a push with no hurt goes out at once.
|
||||
pub(super) fn guest_hit_by<R>(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
@@ -223,16 +225,47 @@ impl Game<'_> {
|
||||
let (result, pushed) = self.as_guest(host, conn, |g| {
|
||||
let before = g.player.as_ref().map(|p| p.velocity);
|
||||
let result = f(g);
|
||||
let after = g.player.as_ref().map(|p| p.velocity);
|
||||
(result, after.filter(|v| Some(*v) != before))
|
||||
let pushed = g
|
||||
.player
|
||||
.as_ref()
|
||||
.filter(|p| !p.health.sync_velocity && Some(p.velocity) != before)
|
||||
.map(|p| p.velocity);
|
||||
(result, pushed)
|
||||
})?;
|
||||
if let (Some(v), Some(g)) = (pushed, host.hub.guest(conn)) {
|
||||
self.send_guest_motion(host, conn, pushed);
|
||||
Some(result)
|
||||
}
|
||||
|
||||
/// A player's melee hit on a guest (Player.causeExtraKnockback on a
|
||||
/// ServerPlayer): a hurt that marked the velocity sends it at once, and
|
||||
/// the server's copy goes back to the velocity it had before the hit.
|
||||
pub(super) fn guest_struck_by<R>(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
f: impl FnOnce(&mut Self) -> R,
|
||||
) -> Option<R> {
|
||||
let (result, sent) = self.as_guest(host, conn, |g| {
|
||||
let before = g.player.as_ref().map(|p| p.velocity);
|
||||
let result = f(g);
|
||||
let sent = g.player.as_mut().zip(before).and_then(|(p, before)| {
|
||||
std::mem::take(&mut p.health.sync_velocity)
|
||||
.then(|| std::mem::replace(&mut p.velocity, before))
|
||||
});
|
||||
(result, sent)
|
||||
})?;
|
||||
self.send_guest_motion(host, conn, sent);
|
||||
Some(result)
|
||||
}
|
||||
|
||||
/// ClientboundSetEntityMotionPacket to a guest about themselves.
|
||||
pub(super) fn send_guest_motion(&self, host: &LanHost, conn: u32, velocity: Option<Vec3>) {
|
||||
if let (Some(v), Some(g)) = (velocity, host.hub.guest(conn)) {
|
||||
g.send(&C::SetEntityMotion(play::SetEntityMotion {
|
||||
id: g.entity_id,
|
||||
velocity: [v.x, v.y, v.z],
|
||||
}));
|
||||
}
|
||||
Some(result)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,14 @@ pub(super) struct Strike {
|
||||
team: Option<horizon_client::scoreboard::Team>,
|
||||
}
|
||||
|
||||
impl Strike {
|
||||
/// Whether it has extra knockback, which stops the attacker's sprint
|
||||
/// once it lands (Player.causeExtraKnockback).
|
||||
pub(super) fn stops_sprint(&self) -> bool {
|
||||
self.knockback.0 > 0.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The guest on the host's crosshair and how far, within reach.
|
||||
pub(super) fn guest_hit(&self) -> Option<(u32, f64)> {
|
||||
@@ -74,7 +82,7 @@ impl Game<'_> {
|
||||
};
|
||||
if let Some(strike) = self.pvp_strike()
|
||||
&& self.pvp_land(&mut host, Victim::Guest(conn), &strike)
|
||||
&& strike.knockback.0 > 0.0
|
||||
&& strike.stops_sprint()
|
||||
{
|
||||
self.stop_sprint_after_extra_knockback();
|
||||
}
|
||||
@@ -240,7 +248,7 @@ impl Game<'_> {
|
||||
&& harm
|
||||
&& match victim {
|
||||
Victim::Host => land(self),
|
||||
Victim::Guest(conn) => self.guest_hit_by(host, conn, land).unwrap_or(false),
|
||||
Victim::Guest(conn) => self.guest_struck_by(host, conn, land).unwrap_or(false),
|
||||
};
|
||||
let sound = match (hurt, s.crit, s.scale > 0.9) {
|
||||
(false, ..) => "minecraft:entity.player.attack.nodamage",
|
||||
|
||||
@@ -205,7 +205,9 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let [x, y, z] = p.position;
|
||||
if !(x.is_finite() && y.is_finite() && z.is_finite())
|
||||
if !x.is_finite()
|
||||
|| !y.is_finite()
|
||||
|| !z.is_finite()
|
||||
|| !p.yaw.is_finite()
|
||||
|| !p.pitch.is_finite()
|
||||
{
|
||||
|
||||
@@ -686,40 +686,53 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `AbstractMinecart.push(player)` when the player walks into a cart.
|
||||
/// `AbstractMinecart.push` from each player walking into a cart
|
||||
/// (`LivingEntity.pushEntities`): the window's player and every server
|
||||
/// player (single player's own on its server thread, LAN guests). A
|
||||
/// player in a vehicle pushes nothing here. The quarter push the player
|
||||
/// gets back only moves the server's copy of it (`Entity.push` marks
|
||||
/// `needsSync`, which `ServerEntity` sends to the other players only),
|
||||
/// so no player feels it.
|
||||
fn push_carts(&mut self) {
|
||||
let player = self.player.as_ref().map(|p| p.position);
|
||||
let riding = self.riding;
|
||||
for i in 0..self.carts.len() {
|
||||
if Some(self.carts[i].id) == riding {
|
||||
continue;
|
||||
if self.carts.is_empty() {
|
||||
return;
|
||||
}
|
||||
// (feet, box)
|
||||
let mut pushers: Vec<(Vec3, Aabb)> = Vec::new();
|
||||
if self.riding.is_none()
|
||||
&& let Some(p) = self.player.as_ref()
|
||||
{
|
||||
pushers.push((p.position, p.bounds()));
|
||||
}
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
for g in host.hub.guests.iter().filter(|g| {
|
||||
host.hub.here(g) && g.loaded && !g.dead && !g.spectator() && g.vehicle.is_none()
|
||||
}) {
|
||||
let [x, y, z] = g.position;
|
||||
let height = if g.sneaking { 1.5 } else { 1.8 };
|
||||
let bb = Aabb::new(
|
||||
Vec3::new(x - 0.3, y, z - 0.3),
|
||||
Vec3::new(x + 0.3, y + height, z + 0.3),
|
||||
);
|
||||
pushers.push((Vec3::new(x, y, z), bb));
|
||||
}
|
||||
if let Some(pp) = player {
|
||||
let bb = self.carts[i].bounds();
|
||||
let (lo, hi) = (bb.min_position(), bb.max_position());
|
||||
let touching = pp.x + 0.3 > lo.x
|
||||
&& pp.x - 0.3 < hi.x
|
||||
&& pp.z + 0.3 > lo.z
|
||||
&& pp.z - 0.3 < hi.z
|
||||
&& pp.y + 1.8 > lo.y
|
||||
&& pp.y < hi.y;
|
||||
if touching && riding.is_none() {
|
||||
let c = &mut self.carts[i];
|
||||
let (mut xa, mut za) = (pp.x - c.position.x, pp.z - c.position.z);
|
||||
let dd = xa * xa + za * za;
|
||||
if dd >= 1.0e-4 {
|
||||
let d = dd.sqrt();
|
||||
let pow = (1.0 / d).min(1.0);
|
||||
xa = xa / d * pow * f64::from(0.1f32) * 0.5;
|
||||
za = za / d * pow * f64::from(0.1f32) * 0.5;
|
||||
c.velocity.x -= xa;
|
||||
c.velocity.z -= za;
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.velocity.x += xa / 4.0;
|
||||
p.velocity.z += za / 4.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (feet, bb) in pushers {
|
||||
for c in &mut self.carts {
|
||||
if !bb.intersects(c.bounds()) {
|
||||
continue;
|
||||
}
|
||||
let (mut xa, mut za) = (feet.x - c.position.x, feet.z - c.position.z);
|
||||
let dd = xa * xa + za * za;
|
||||
if dd < f64::from(1.0e-4f32) {
|
||||
continue;
|
||||
}
|
||||
let d = dd.sqrt();
|
||||
let pow = (1.0 / d).min(1.0);
|
||||
xa = xa / d * pow * f64::from(0.1f32) * 0.5;
|
||||
za = za / d * pow * f64::from(0.1f32) * 0.5;
|
||||
c.velocity.x -= xa;
|
||||
c.velocity.z -= za;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -797,7 +810,7 @@ impl Game<'_> {
|
||||
let ridden = self.guest_vehicles();
|
||||
// By id: an exploding cart can destroy or prime others mid-loop.
|
||||
let ids: Vec<u32> = self.carts.iter().map(|c| c.id).collect();
|
||||
for id in ids {
|
||||
for &id in &ids {
|
||||
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
|
||||
continue;
|
||||
};
|
||||
@@ -823,6 +836,33 @@ impl Game<'_> {
|
||||
self.carts.remove(i);
|
||||
}
|
||||
}
|
||||
// On the server for every cart, the ridden ones too.
|
||||
for id in ids {
|
||||
if let Some(i) = self.carts.iter().position(|c| c.id == id) {
|
||||
let p = self.carts[i].position;
|
||||
if ticking(p.x.floor() as i32, p.z.floor() as i32) {
|
||||
self.cart_lava_tick(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `lavaHurt` twice a tick in lava: from `LavaFluid.entityInside` in the
|
||||
/// behaviour's `applyEffectsFromBlocks`, then from `AbstractMinecart.tick`'s
|
||||
/// own `isInLava`. The second breaks the cart, or primes a TNT minecart
|
||||
/// (`MinecartTNT.destroy` for a fire source). A cart has no `baseTick`,
|
||||
/// so the fire `lavaIgnite` lights never burns it.
|
||||
fn cart_lava_tick(&mut self, id: u32) {
|
||||
for _ in 0..2 {
|
||||
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
|
||||
return;
|
||||
};
|
||||
if !self.in_lava(self.carts[i].bounds()) {
|
||||
return;
|
||||
}
|
||||
let p = self.carts[i].position;
|
||||
self.hurt_cart(i, 4.0, false, true);
|
||||
self.lava_hurt_sound([p.x, p.y, p.z]);
|
||||
}
|
||||
}
|
||||
/// `ClientPacketListener.postAddEntitySoundInstance` for the carts that
|
||||
/// joined the level since the last tick, and `LocalPlayer.startRiding`'s
|
||||
|
||||
@@ -237,15 +237,7 @@ impl Game<'_> {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
super::packs::open_folder(&dir);
|
||||
}
|
||||
Event::OpenShaderPackFolder => {
|
||||
let dir = horizon_client::shader_pack::folder();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
super::packs::open_folder(&dir);
|
||||
}
|
||||
Event::Credits => self.open_win_screen(false, super::win::Finish::Options),
|
||||
Event::ReloadShaderPack => {
|
||||
self.load_shader_pack();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -294,14 +286,6 @@ impl Game<'_> {
|
||||
font.reset_cache();
|
||||
self.ui.chat.rescale(&self.gui.atlas);
|
||||
}
|
||||
// A new renderer (resource reload, first window) takes the loaded pack.
|
||||
let stale = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.shader_pack_id() != self.shader_packs.id());
|
||||
if stale && let Err(e) = self.apply_shader_pack() {
|
||||
log::warn!("Horizon: shader pack: {e}");
|
||||
}
|
||||
}
|
||||
/// `Window.updateFullscreenState`: a fullscreen change made by the window
|
||||
/// system is adopted by the option (`Minecraft.fullscreenStateChanged`).
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::Game;
|
||||
use horizon_client::{
|
||||
hud::HudAtlas,
|
||||
lang,
|
||||
pack_screen::{Compat, PackInfo, Source as PackSource},
|
||||
pack_screen::{self, Compat, PackInfo, Source as PackSource},
|
||||
packs::{self, Pack, Position},
|
||||
text::{Style, Text},
|
||||
};
|
||||
@@ -34,10 +34,16 @@ pub fn open_folder(dir: &Path) {
|
||||
log::warn!("Horizon: could not open {}: {e}", dir.display());
|
||||
}
|
||||
}
|
||||
/// The resource root to load from, given the vanilla root and the selection.
|
||||
pub fn startup_root(vanilla: &Path, selected: &[String]) -> std::io::Result<PathBuf> {
|
||||
/// The resource root to load from, given the vanilla root, the selection and
|
||||
/// a server's packs, which go on top (`Pack.Position.TOP`, fixed).
|
||||
pub fn startup_root(
|
||||
vanilla: &Path,
|
||||
selected: &[String],
|
||||
server: &[Pack],
|
||||
) -> std::io::Result<PathBuf> {
|
||||
let all = packs::discover(vanilla, &packs_dir());
|
||||
let enabled = packs::enabled(&all, selected);
|
||||
let mut enabled = packs::enabled(&all, selected);
|
||||
enabled.extend_from_slice(server);
|
||||
packs::resource_root(vanilla, &enabled, &cache_dir())
|
||||
}
|
||||
fn info(p: &Pack) -> PackInfo {
|
||||
@@ -53,14 +59,15 @@ fn info(p: &Pack) -> PackInfo {
|
||||
PackSource::Default
|
||||
},
|
||||
compat: p.compatibility,
|
||||
icon: p.icon,
|
||||
icon: p.icon_png().as_deref().and_then(pack_screen::load_icon),
|
||||
required: false,
|
||||
position: Position::Top,
|
||||
fixed: false,
|
||||
}
|
||||
}
|
||||
/// The vanilla pack: required, at the bottom (`BuiltInPackSource`).
|
||||
fn vanilla_info() -> PackInfo {
|
||||
/// The vanilla pack: required, at the bottom (`BuiltInPackSource`), with the
|
||||
/// client JAR's `pack.png` that the prepared resources hold.
|
||||
fn vanilla_info(vanilla: &Path) -> PackInfo {
|
||||
PackInfo {
|
||||
id: "vanilla".into(),
|
||||
title: Text::translate("resourcePack.vanilla.name", &[]),
|
||||
@@ -68,7 +75,10 @@ fn vanilla_info() -> PackInfo {
|
||||
description_style: Style::default(),
|
||||
source: PackSource::BuiltIn,
|
||||
compat: Compat::Compatible,
|
||||
icon: true,
|
||||
icon: std::fs::read(vanilla.join("pack.png"))
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(pack_screen::load_icon),
|
||||
required: true,
|
||||
position: Position::Bottom,
|
||||
fixed: false,
|
||||
@@ -80,7 +90,7 @@ pub(super) fn pack_infos(vanilla: &Path) -> Vec<PackInfo> {
|
||||
.iter()
|
||||
.map(info)
|
||||
.collect();
|
||||
infos.push(vanilla_info());
|
||||
infos.push(vanilla_info(vanilla));
|
||||
infos
|
||||
}
|
||||
impl Game<'_> {
|
||||
@@ -113,14 +123,7 @@ impl Game<'_> {
|
||||
self.pending_pack_reload = true;
|
||||
return;
|
||||
}
|
||||
let vanilla = self.vanilla_root();
|
||||
if self.ui.vanilla_root.as_os_str().is_empty() {
|
||||
self.ui.vanilla_root = vanilla.clone();
|
||||
}
|
||||
let result = startup_root(&vanilla, &self.settings.resource_packs)
|
||||
.map_err(Into::into)
|
||||
.and_then(|root| self.reload_resources(&root));
|
||||
if let Err(e) = result {
|
||||
if let Err(e) = self.reload_selected_packs() {
|
||||
log::warn!("Horizon: resource reload failed: {e}");
|
||||
if self.screen != super::Screen::Options {
|
||||
self.open_options_at(
|
||||
@@ -131,6 +134,16 @@ impl Game<'_> {
|
||||
self.ui.options.resource_error = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
/// Reload from the selected packs and the server's.
|
||||
pub(super) fn reload_selected_packs(&mut self) -> horizon_client::Result<()> {
|
||||
let vanilla = self.vanilla_root();
|
||||
if self.ui.vanilla_root.as_os_str().is_empty() {
|
||||
self.ui.vanilla_root = vanilla.clone();
|
||||
}
|
||||
let server = &self.server_packs.active;
|
||||
let root = startup_root(&vanilla, &self.settings.resource_packs, server)?;
|
||||
self.reload_resources(&root)
|
||||
}
|
||||
fn reload_resources(&mut self, root: &Path) -> horizon_client::Result<()> {
|
||||
let render = std::sync::Arc::new(self.pack.assets.reload(root)?);
|
||||
let mut atlas = HudAtlas::build_over(root, &self.vanilla_root())?;
|
||||
|
||||
@@ -305,6 +305,16 @@ impl Game<'_> {
|
||||
// A signed-in account always plays under its own name.
|
||||
let auth = horizon_client::auth::current();
|
||||
let name = auth.as_ref().map_or(name, |a| a.name.as_str());
|
||||
let user = auth.as_ref().map_or_else(
|
||||
|| {
|
||||
(
|
||||
name.to_owned(),
|
||||
horizon_protocol::crypto::offline_uuid(name),
|
||||
)
|
||||
},
|
||||
|a| (a.name.clone(), a.uuid),
|
||||
);
|
||||
self.server_packs_connect(local.is_none().then_some(address), user);
|
||||
let session = match local {
|
||||
Some(pipe) => Session::connect_local(pipe, name, view, lang.clone()),
|
||||
None => {
|
||||
@@ -453,6 +463,7 @@ impl Game<'_> {
|
||||
if let Some(mut r) = self.remote.take() {
|
||||
r.session.disconnect();
|
||||
}
|
||||
self.server_packs_disconnect();
|
||||
// Single player's server thread saves the world before the title shows.
|
||||
let single_player = match self.integrated.take() {
|
||||
Some(server) => {
|
||||
@@ -544,7 +555,7 @@ impl Game<'_> {
|
||||
/// (`LivingEntity.forceAddEffect`): the effect replaces any of its kind.
|
||||
/// Other entities' effects are not shown.
|
||||
fn remote_effect(&mut self, p: &play::UpdateMobEffect) {
|
||||
if !self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
|
||||
if self.remote.as_ref().is_none_or(|r| r.entity_id != p.id) {
|
||||
return;
|
||||
}
|
||||
let Some(id) = self.remote_effect_id(p.effect) else {
|
||||
@@ -827,6 +838,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Event::Reconfigured(registries) => self.set_registries(registries),
|
||||
Event::PackPush(p) => self.handle_pack_push(p),
|
||||
Event::PackPop(id) => self.handle_pack_pop(id),
|
||||
Event::DecodeError(e) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.decode_errors += 1;
|
||||
@@ -1990,6 +2003,15 @@ impl Game<'_> {
|
||||
}
|
||||
self.sleeping = *bed;
|
||||
}
|
||||
// Entity.DATA_SHARED_FLAGS_ID's sprinting bit: the
|
||||
// server stops the sprint after a hit's extra
|
||||
// knockback (the flag only, as assignValues sets
|
||||
// it: the speed modifier and wasSprinting stay).
|
||||
(0, Value::Byte(flags)) => {
|
||||
if let Some(pl) = self.player.as_mut() {
|
||||
pl.sprinting = flags & 0x08 != 0;
|
||||
}
|
||||
}
|
||||
// Entity.DATA_AIR_SUPPLY_ID and
|
||||
// Player.DATA_PLAYER_ABSORPTION_ID: the bubbles
|
||||
// and golden hearts the server's player has.
|
||||
@@ -3072,7 +3094,7 @@ impl Game<'_> {
|
||||
self.sleep_timer = super::tick_sleep_timer(self.sleep_timer, self.sleeping.is_some());
|
||||
// Gui.tick: the titles, the action bar and the held item's name.
|
||||
self.tick_hud();
|
||||
let ghast_collisions = self.happy_ghast_player_collisions();
|
||||
let entity_collisions = self.player_entity_collisions();
|
||||
let pushed = self.remote_pushes();
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return Ok(());
|
||||
@@ -3156,7 +3178,7 @@ impl Game<'_> {
|
||||
} else if !dead && !riding {
|
||||
player.velocity = player.velocity.add_vector(pushed);
|
||||
MotionWorld {
|
||||
entities: &ghast_collisions,
|
||||
entities: &entity_collisions,
|
||||
player,
|
||||
scene: &mut self.world,
|
||||
assets: &self.pack.assets,
|
||||
@@ -4309,8 +4331,7 @@ impl Game<'_> {
|
||||
/// Keys with a multiplayer meaning; returns true when consumed.
|
||||
pub(super) fn remote_key(&mut self, key: KeyCode) -> Result<bool> {
|
||||
if self.screen != Screen::Playing {
|
||||
if key == KeyCode::KeyE && self.screen == Screen::Inventory
|
||||
|| key == KeyCode::Escape && self.screen == Screen::Inventory
|
||||
if (key == KeyCode::KeyE || key == KeyCode::Escape) && self.screen == Screen::Inventory
|
||||
{
|
||||
self.remote_close_container();
|
||||
self.screen = Screen::Playing;
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
//! `onSyncedDataUpdated`): what a client draws from it rather than from its
|
||||
//! own simulation, which a client of a server doesn't run.
|
||||
use super::Game;
|
||||
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
|
||||
use horizon_client::mobs::{Mob, Species, tame};
|
||||
use horizon_protocol::entity_data::Value;
|
||||
use horizon_protocol::registry::Builtin;
|
||||
|
||||
/// The values `apply` takes from one SetEntityData packet, by vanilla's entry
|
||||
/// indices (Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses).
|
||||
fn apply(m: &mut Mob, values: &[(u8, i32, Value)]) {
|
||||
/// `builtin` names the ids of built-in registries.
|
||||
fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin) {
|
||||
let species = m.species();
|
||||
let baby = super::lan::baby_index(&m.kind.id);
|
||||
for (index, _, value) in values {
|
||||
@@ -42,16 +45,45 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)]) {
|
||||
(Species::Slime | Species::MagmaCube, 18, Value::Int(size)) => {
|
||||
m.extra.size = (*size).clamp(1, 127);
|
||||
}
|
||||
// Villager.DATA_VILLAGER_DATA and ZombieVillager's (after
|
||||
// Zombie's three): what VillagerProfessionLayer draws.
|
||||
(Species::Villager, 19, Value::Ids(data))
|
||||
| (Species::ZombieVillager, 20, Value::Ids(data)) => villager_data(m, data, builtin),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// VillagerData as the network sends it: the type and the profession by
|
||||
/// their built-in registry ids, then the level.
|
||||
fn villager_data(m: &mut Mob, data: &[i32], builtin: &Builtin) {
|
||||
let &[kind, profession, level] = data else {
|
||||
return;
|
||||
};
|
||||
let index = |registry: &str, id: i32, names: &[&str]| {
|
||||
let name = builtin
|
||||
.get(registry)?
|
||||
.name(id)?
|
||||
.strip_prefix("minecraft:")?;
|
||||
names.iter().position(|n| *n == name).map(|i| i as u8)
|
||||
};
|
||||
if let Some(t) = index("minecraft:villager_type", kind, &VILLAGER_TYPES) {
|
||||
m.extra.variant = t;
|
||||
}
|
||||
if let Some(p) = index("minecraft:villager_profession", profession, &PROFESSIONS) {
|
||||
m.extra.variant2 = p;
|
||||
}
|
||||
m.extra.variant3 = level.clamp(0, i32::from(u8::MAX)) as u8;
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// SetEntityData for a mob of the server's.
|
||||
pub(super) fn remote_mob_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
|
||||
let Some(r) = &self.remote else {
|
||||
return;
|
||||
};
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
|
||||
apply(m, values);
|
||||
apply(m, values, &r.builtin);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,11 +104,23 @@ mod tests {
|
||||
|
||||
/// A mob of the prepared resources' type `kind`, as a client spawns it
|
||||
/// (None without the resources).
|
||||
fn mob(kind: &str) -> Option<Mob> {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
fn root() -> std::path::PathBuf {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
let types = horizon_client::mobs::MobTypes::load(&root).ok()?;
|
||||
.join(horizon_core::VANILLA_VERSION)
|
||||
}
|
||||
|
||||
/// The prepared resources' built-in registries (empty without them).
|
||||
fn builtin() -> Builtin {
|
||||
Builtin::load(&root().join("registries.json")).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn apply(m: &mut Mob, values: &[(u8, i32, Value)]) {
|
||||
super::apply(m, values, &builtin());
|
||||
}
|
||||
|
||||
fn mob(kind: &str) -> Option<Mob> {
|
||||
let types = horizon_client::mobs::MobTypes::load(&root()).ok()?;
|
||||
let t = std::sync::Arc::new(types.types.get(kind)?.clone());
|
||||
let mut m = Mob::new(1, t, horizon_core::geometry::Vec3::ZERO, 0.0);
|
||||
client_defaults(&mut m);
|
||||
@@ -133,4 +177,45 @@ mod tests {
|
||||
apply(&mut m, &[(0, s::BYTE, Value::Byte(0))]);
|
||||
assert_eq!(m.fire_ticks, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_villager_takes_its_type_profession_and_level() {
|
||||
let Some(mut m) = mob("minecraft:villager") else {
|
||||
return;
|
||||
};
|
||||
let registries = builtin();
|
||||
let id = |registry: &str, name: &str| registries.get(registry)?.id(name);
|
||||
let (Some(desert), Some(farmer), Some(librarian)) = (
|
||||
id("minecraft:villager_type", "minecraft:desert"),
|
||||
id("minecraft:villager_profession", "minecraft:farmer"),
|
||||
id("minecraft:villager_profession", "minecraft:librarian"),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let name = |m: &Mob| {
|
||||
(
|
||||
VILLAGER_TYPES[m.extra.variant as usize],
|
||||
PROFESSIONS[m.extra.variant2 as usize],
|
||||
m.extra.variant3,
|
||||
)
|
||||
};
|
||||
apply(
|
||||
&mut m,
|
||||
&[(19, s::VILLAGER_DATA, Value::Ids(vec![desert, farmer, 2]))],
|
||||
);
|
||||
assert_eq!(name(&m), ("desert", "farmer", 2));
|
||||
// A job taken later: only the profession and the level change.
|
||||
apply(
|
||||
&mut m,
|
||||
&[(19, s::VILLAGER_DATA, Value::Ids(vec![desert, librarian, 5]))],
|
||||
);
|
||||
assert_eq!(name(&m), ("desert", "librarian", 5));
|
||||
// A zombie villager's data comes after Zombie's three entries.
|
||||
let mut z = mob("minecraft:zombie_villager").unwrap();
|
||||
apply(
|
||||
&mut z,
|
||||
&[(20, s::VILLAGER_DATA, Value::Ids(vec![desert, farmer, 3]))],
|
||||
);
|
||||
assert_eq!(name(&z), ("desert", "farmer", 3));
|
||||
}
|
||||
}
|
||||
@@ -104,45 +104,6 @@ impl Game<'_> {
|
||||
},
|
||||
)
|
||||
}
|
||||
/// What a shader pack's `Frame` uniform reads of the game this frame.
|
||||
pub(super) fn shader_inputs(
|
||||
&self,
|
||||
camera: glam::DVec3,
|
||||
partial_tick: f32,
|
||||
) -> horizon_client::gfx::ShaderInputs {
|
||||
let (rain, thunder) = if self.dim == Dim::Overworld {
|
||||
self.world.weather.levels(self.clock.fraction() as f32)
|
||||
} else {
|
||||
(0.0, 0.0)
|
||||
};
|
||||
let at = [camera.x, camera.y, camera.z];
|
||||
let day = self.world.day_time;
|
||||
let has_sky_light = self.dim == Dim::Overworld;
|
||||
// CommonUniforms.getEyeBrightness: the camera entity's eye block.
|
||||
let eye = self.eye_position().unwrap_or(at).map(|v| v.floor() as i32);
|
||||
let light = self.world.light(eye[0], eye[1], eye[2]);
|
||||
let eye_light = [light & 15, if has_sky_light { light >> 4 } else { 0 }];
|
||||
horizon_client::gfx::ShaderInputs {
|
||||
rain,
|
||||
thunder,
|
||||
submerged: match fog_type(&self.world, self.res.facts(), at, self.forward()) {
|
||||
FogType::Atmospheric => 0,
|
||||
FogType::Water => 1,
|
||||
FogType::Lava => 2,
|
||||
FogType::PowderSnow => 3,
|
||||
},
|
||||
dimension: match self.dim {
|
||||
Dim::Overworld => 0,
|
||||
Dim::Nether => -1,
|
||||
Dim::End => 1,
|
||||
},
|
||||
has_sky_light,
|
||||
moon_phase: day.div_euclid(24_000).rem_euclid(8) as u8,
|
||||
day_fraction: (day.rem_euclid(24_000) as f32 + partial_tick) / 24_000.0,
|
||||
partial_tick,
|
||||
eye_light,
|
||||
}
|
||||
}
|
||||
/// `SkyRenderer.shouldRenderDarkDisc`: eye below the horizon (y 63) and not under water.
|
||||
pub(super) fn dark_disc(&self, camera: glam::DVec3) -> bool {
|
||||
camera.y < 63.0
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
//! A server's resource packs bound to the game (`ClientCommonPacketListenerImpl.
|
||||
//! handleResourcePackPush` and `handleResourcePackPop`, its `PackConfirmScreen`,
|
||||
//! and `DownloadedPackSource` as `Minecraft` drives it): the prompt, the
|
||||
//! server list's setting, downloads into `downloads/`, and a resource reload
|
||||
//! with the server's packs on top of the player's own.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
confirm_screen::Confirm,
|
||||
hud::{HudAtlas, HudBatch},
|
||||
multiplayer, packs,
|
||||
scoreboard::{comp_text, tag_to_json},
|
||||
server_packs::{Prompt, ServerPacks, action, download, parse_sha1, valid_url},
|
||||
text::{Text, named_color},
|
||||
ui,
|
||||
};
|
||||
use horizon_protocol::{packets::configuration::ResourcePackPush, servers};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// `ClientCommonPacketListenerImpl.PackConfirmScreen`: the packs it asks
|
||||
/// about, whether the last one is required, and the server's message.
|
||||
pub(super) struct PackPrompt {
|
||||
requests: Vec<(u128, String, String)>,
|
||||
required: bool,
|
||||
prompt: Option<Text>,
|
||||
}
|
||||
impl PackPrompt {
|
||||
fn texts(&self) -> (Text, Text, [Text; 2]) {
|
||||
let t = |key: &str| Text::translate(key, &[]);
|
||||
let (title, line2) = if self.required {
|
||||
let yellow = named_color("yellow").unwrap_or(0xFFFF55);
|
||||
(
|
||||
t("multiplayer.requiredTexturePrompt.line1"),
|
||||
t("multiplayer.requiredTexturePrompt.line2")
|
||||
.with_color(yellow)
|
||||
.bold(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
t("multiplayer.texturePrompt.line1"),
|
||||
t("multiplayer.texturePrompt.line2"),
|
||||
)
|
||||
};
|
||||
// preparePackPrompt.
|
||||
let message = match &self.prompt {
|
||||
Some(prompt) => Text::translate(
|
||||
"multiplayer.texturePrompt.serverPrompt",
|
||||
&[line2, prompt.clone()],
|
||||
),
|
||||
None => line2,
|
||||
};
|
||||
let buttons = if self.required {
|
||||
[t("gui.proceed"), t("menu.disconnect")]
|
||||
} else {
|
||||
[t("gui.yes"), t("gui.no")]
|
||||
};
|
||||
(title, message, buttons)
|
||||
}
|
||||
fn layout(&self, atlas: &HudAtlas, size: (f32, f32)) -> Confirm {
|
||||
let (title, message, _) = self.texts();
|
||||
Confirm::new(&atlas.font, size, &title, &message)
|
||||
}
|
||||
}
|
||||
|
||||
/// The game's `DownloadedPackSource`.
|
||||
#[derive(Default)]
|
||||
pub(super) struct ServerPackState {
|
||||
packs: ServerPacks,
|
||||
/// `downloads/` in the game folder, opened at the first download.
|
||||
queue: Option<download::Queue>,
|
||||
pub(super) prompt: Option<PackPrompt>,
|
||||
/// The server list entry the setting is saved to (none: a direct join).
|
||||
address: Option<String>,
|
||||
/// The account the downloads are made for (the download headers).
|
||||
user: (String, u128),
|
||||
/// The packs loaded on top of the player's own, bottom first.
|
||||
pub(super) active: Vec<packs::Pack>,
|
||||
}
|
||||
|
||||
fn saves() -> PathBuf {
|
||||
std::env::current_dir().unwrap_or_default().join("saves")
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `ConnectScreen`: the server's setting decides what its packs do
|
||||
/// (`configureForServerControl`); single player's own server takes them
|
||||
/// all, quietly (`configureForLocalWorld`).
|
||||
pub(super) fn server_packs_connect(&mut self, address: Option<&str>, user: (String, u128)) {
|
||||
let state = &mut self.server_packs;
|
||||
state.user = user;
|
||||
state.prompt = None;
|
||||
let Some(address) = address else {
|
||||
state.address = None;
|
||||
state.packs.local_world();
|
||||
return;
|
||||
};
|
||||
let file = multiplayer::servers_file(&saves());
|
||||
let entry = servers::load(&file).into_iter().find(|s| s.ip == address);
|
||||
let setting = entry.as_ref().and_then(|e| e.accept_textures);
|
||||
state.address = entry.map(|e| e.ip);
|
||||
state.packs.connect(Prompt::for_server(setting));
|
||||
}
|
||||
/// `Minecraft.clearDownloadedResourcePacks`: every server pack goes at once.
|
||||
pub(super) fn server_packs_disconnect(&mut self) {
|
||||
self.server_packs.prompt = None;
|
||||
self.server_packs.packs.disconnect();
|
||||
self.tick_server_packs();
|
||||
}
|
||||
|
||||
/// `handleResourcePackPush`.
|
||||
pub(super) fn handle_pack_push(&mut self, p: ResourcePackPush) {
|
||||
if !valid_url(&p.url) {
|
||||
self.send_pack_status(p.id, action::INVALID_URL);
|
||||
return;
|
||||
}
|
||||
let setting = self.server_setting();
|
||||
let prompt = Prompt::for_server(setting);
|
||||
if prompt != Prompt::Pending && (!p.required || prompt != Prompt::Declined) {
|
||||
self.server_packs
|
||||
.packs
|
||||
.push(p.id, &p.url, parse_sha1(&p.hash));
|
||||
return;
|
||||
}
|
||||
// addOrUpdatePackPrompt: one prompt for every pack pushed before it is answered.
|
||||
let text = p.prompt.as_ref().map(|t| comp_text(&tag_to_json(t)));
|
||||
let state = &mut self.server_packs;
|
||||
let open = state.prompt.get_or_insert(PackPrompt {
|
||||
requests: Vec::new(),
|
||||
required: p.required,
|
||||
prompt: None,
|
||||
});
|
||||
open.requests.push((p.id, p.url, p.hash));
|
||||
open.required = p.required;
|
||||
open.prompt = text;
|
||||
self.capture(false);
|
||||
}
|
||||
/// `handleResourcePackPop`: one pack, or all of them.
|
||||
pub(super) fn handle_pack_pop(&mut self, id: Option<u128>) {
|
||||
match id {
|
||||
Some(id) => self.server_packs.packs.pop(id),
|
||||
None => self.server_packs.packs.pop_all(),
|
||||
}
|
||||
}
|
||||
/// The joined server's entry in the server list: its `acceptTextures`.
|
||||
fn server_setting(&self) -> Option<bool> {
|
||||
let address = self.server_packs.address.as_deref()?;
|
||||
servers::load(&multiplayer::servers_file(&saves()))
|
||||
.into_iter()
|
||||
.find(|s| s.ip == address)?
|
||||
.accept_textures
|
||||
}
|
||||
fn send_pack_status(&self, id: u128, status: i32) {
|
||||
if let Some(r) = &self.remote {
|
||||
r.session.send_pack_status(id, status);
|
||||
}
|
||||
}
|
||||
|
||||
/// The prompt's answer (`PackConfirmScreen`'s callback): the server's
|
||||
/// setting follows it, declining a required pack disconnects, and every
|
||||
/// pack it asked about is pushed.
|
||||
pub(super) fn answer_pack_prompt(&mut self, yes: bool) -> Result<()> {
|
||||
let Some(prompt) = self.server_packs.prompt.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
if self.player.is_some() && self.screen == super::Screen::Playing {
|
||||
self.capture(true);
|
||||
}
|
||||
let setting = if yes {
|
||||
self.server_packs.packs.allow();
|
||||
Some(true)
|
||||
} else {
|
||||
self.server_packs.packs.reject();
|
||||
if prompt.required {
|
||||
let reason = Text::translate("multiplayer.requiredTexturePrompt.disconnect", &[]);
|
||||
return self.leave_server(Some(reason.plain()));
|
||||
}
|
||||
Some(false)
|
||||
};
|
||||
for (id, url, hash) in prompt.requests {
|
||||
self.server_packs.packs.push(id, &url, parse_sha1(&hash));
|
||||
}
|
||||
// ServerList.saveSingleServer.
|
||||
if let Some(address) = &self.server_packs.address {
|
||||
let file = multiplayer::servers_file(&saves());
|
||||
let mut list = servers::load(&file);
|
||||
for entry in list.iter_mut().filter(|s| &s.ip == address) {
|
||||
entry.accept_textures = setting;
|
||||
}
|
||||
if let Err(e) = servers::save(&file, &list) {
|
||||
log::warn!("Horizon: could not save {}: {e}", file.display());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Runs what the packs need (`createUpdateScheduler`): the downloads
|
||||
/// that finished, the ones to start, a resource reload when the active
|
||||
/// packs change, and the statuses for the server.
|
||||
pub(super) fn tick_server_packs(&mut self) {
|
||||
// A reload waits for the world's resources (`reload_packs`).
|
||||
if self.rest_loader.is_some() || self.world_loader.is_some() {
|
||||
return;
|
||||
}
|
||||
let finished = self
|
||||
.server_packs
|
||||
.queue
|
||||
.as_ref()
|
||||
.map(download::Queue::finished)
|
||||
.unwrap_or_default();
|
||||
for batch in finished {
|
||||
self.server_packs.packs.downloaded(batch);
|
||||
}
|
||||
while self.server_packs.packs.wants_tick() {
|
||||
let tick = self.server_packs.packs.tick();
|
||||
if !tick.download.is_empty() {
|
||||
self.start_pack_downloads(tick.download);
|
||||
}
|
||||
if let Some(mut load) = tick.reload {
|
||||
while let Some(recovery) = {
|
||||
let ok = self.reload_server_packs(&load);
|
||||
self.server_packs.packs.reloaded(ok)
|
||||
} {
|
||||
load = recovery;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (id, status) in self.server_packs.packs.take_replies() {
|
||||
self.send_pack_status(id, status);
|
||||
}
|
||||
}
|
||||
fn start_pack_downloads(&mut self, requests: Vec<horizon_client::server_packs::Request>) {
|
||||
let state = &mut self.server_packs;
|
||||
if state.queue.is_none() {
|
||||
let dir = std::env::current_dir()
|
||||
.unwrap_or_default()
|
||||
.join("downloads");
|
||||
match download::Queue::open(dir) {
|
||||
Ok(queue) => state.queue = Some(queue),
|
||||
Err(e) => {
|
||||
log::error!("Horizon: failed to open the download queue: {e}");
|
||||
let failed = requests.iter().map(|r| r.id).collect();
|
||||
state.packs.downloaded(horizon_client::server_packs::Batch {
|
||||
downloaded: Vec::new(),
|
||||
failed,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let headers = download::headers(&state.user.0, state.user.1);
|
||||
if let Some(queue) = &state.queue {
|
||||
queue.download(requests, headers);
|
||||
}
|
||||
}
|
||||
/// `DownloadedPackSource.startReload`: the packs on top of the player's
|
||||
/// own, then `Minecraft.reloadResourcePacks`. False when a pack has no
|
||||
/// readable `pack.mcmeta` or the reload failed.
|
||||
fn reload_server_packs(&mut self, load: &[(u128, PathBuf)]) -> bool {
|
||||
let packs: Option<Vec<_>> = load
|
||||
.iter()
|
||||
.map(|(id, path)| packs::server_pack(*id, path))
|
||||
.collect();
|
||||
let Some(packs) = packs else {
|
||||
log::warn!("Horizon: invalid pack metadata in a server pack, ignoring all");
|
||||
return false;
|
||||
};
|
||||
self.server_packs.active = packs;
|
||||
match self.reload_selected_packs() {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: server resource pack reload failed: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn draw_pack_prompt(&self, batch: &mut HudBatch) {
|
||||
let Some(prompt) = &self.server_packs.prompt else {
|
||||
return;
|
||||
};
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
if self.player.is_none() {
|
||||
// In place of the connecting screen, on its background.
|
||||
batch.fill(
|
||||
atlas,
|
||||
0.0,
|
||||
0.0,
|
||||
w,
|
||||
h,
|
||||
horizon_client::hud::rgba(20, 20, 20, 255),
|
||||
);
|
||||
}
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.player.is_some());
|
||||
let (_, _, [yes, no]) = prompt.texts();
|
||||
prompt
|
||||
.layout(atlas, (w, h))
|
||||
.draw(batch, atlas, self.cursor, [&yes, &no]);
|
||||
}
|
||||
/// A left click on the prompt: its answer when it hit a button.
|
||||
pub(super) fn pack_prompt_click(&mut self) -> Result<()> {
|
||||
let Some(prompt) = &self.server_packs.prompt else {
|
||||
return Ok(());
|
||||
};
|
||||
let answer = prompt
|
||||
.layout(&self.gui.atlas, self.gui_size())
|
||||
.answer_at(self.cursor);
|
||||
match answer {
|
||||
Some(yes) => {
|
||||
self.audio.play_button_click();
|
||||
self.answer_pack_prompt(yes)
|
||||
}
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
//! The selected Horizon shader pack (`shaderPack` in `horizon-graphics.txt`,
|
||||
//! docs/shader-packs.md): loaded from `shaderpacks/`, given to the renderer,
|
||||
//! and loaded again when the selection changes, on F3 + T, or when its files
|
||||
//! change (checked twice a second). A pack that fails leaves the last good
|
||||
//! one (or the vanilla picture) on and says why in chat.
|
||||
use super::Game;
|
||||
use horizon_client::shader_pack::{self, ShaderPack, Stamp};
|
||||
use horizon_client::text::Text;
|
||||
use horizon_shaderpack::properties::SkyDirectives;
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct ShaderPacks {
|
||||
/// The pack the renderer runs.
|
||||
pack: Option<ShaderPack>,
|
||||
/// The selection and its files at the last load, success or not.
|
||||
selected: String,
|
||||
stamp: Stamp,
|
||||
checked: Option<Instant>,
|
||||
}
|
||||
|
||||
impl ShaderPacks {
|
||||
/// The loaded pack's folder name.
|
||||
pub(super) fn id(&self) -> Option<&str> {
|
||||
self.pack.as_ref().map(|p| p.id.as_str())
|
||||
}
|
||||
/// What a loaded Java pack changes in the vanilla sky of `dimension`
|
||||
/// (0 overworld, -1 the Nether, 1 the End): its `sunPathRotation`, and
|
||||
/// whether it keeps the clouds, sun and moon (Iris's sky mixins).
|
||||
pub(super) fn java_sky(&self, dimension: i8) -> Option<(f32, SkyDirectives)> {
|
||||
let java = self.pack.as_ref()?.java.as_ref()?;
|
||||
let folder = &java.folders[java.folder_for(dimension)?];
|
||||
Some((folder.directives.sun_path_rotation, java.sky))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn dir() -> PathBuf {
|
||||
shader_pack::folder()
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Load the selected pack and give it to the renderer.
|
||||
pub(super) fn load_shader_pack(&mut self) {
|
||||
let id = self.settings.horizon.shader_pack.clone();
|
||||
let dir = dir();
|
||||
let state = &mut self.shader_packs;
|
||||
state.selected = id.clone();
|
||||
state.checked = Some(Instant::now());
|
||||
let pack = if id.is_empty() {
|
||||
state.stamp = Stamp::default();
|
||||
None
|
||||
} else {
|
||||
state.stamp = Stamp::of(&dir.join(&id));
|
||||
// Java packs are told which GPU they run on.
|
||||
let (vendor, renderer) = self.renderer.as_ref().map_or(("Unknown", ""), |r| {
|
||||
let name = r.info.name.as_str();
|
||||
(
|
||||
horizon_client::java_pack::vendor_name(r.info.vendor, name),
|
||||
name,
|
||||
)
|
||||
});
|
||||
let values = shader_pack::load_values(&dir, &id);
|
||||
match ShaderPack::load_any(&dir.join(&id), &values, vendor, renderer) {
|
||||
Ok(pack) => Some(pack),
|
||||
Err(e) => return self.shader_pack_error(&id, &e),
|
||||
}
|
||||
};
|
||||
if let Some(r) = self.renderer.as_mut()
|
||||
&& let Err(e) = r.set_shader_pack(pack.as_ref(), &self.pack.render.sprite_layers)
|
||||
{
|
||||
return self.shader_pack_error(&id, &e.to_string());
|
||||
}
|
||||
self.shader_packs.pack = pack;
|
||||
self.ui.options.shader_pack_error = None;
|
||||
}
|
||||
/// Give the loaded pack to the renderer (also after it is rebuilt).
|
||||
pub(super) fn apply_shader_pack(&mut self) -> horizon_client::Result<()> {
|
||||
match self.renderer.as_mut() {
|
||||
Some(r) => r.set_shader_pack(
|
||||
self.shader_packs.pack.as_ref(),
|
||||
&self.pack.render.sprite_layers,
|
||||
),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
/// Every frame: a new selection or edited pack files load it again.
|
||||
pub(super) fn poll_shader_pack(&mut self) {
|
||||
let state = &mut self.shader_packs;
|
||||
if self.settings.horizon.shader_pack != state.selected {
|
||||
return self.load_shader_pack();
|
||||
}
|
||||
if state.selected.is_empty()
|
||||
|| state
|
||||
.checked
|
||||
.is_some_and(|t| t.elapsed() < Duration::from_millis(500))
|
||||
{
|
||||
return;
|
||||
}
|
||||
state.checked = Some(Instant::now());
|
||||
if Stamp::of(&dir().join(&state.selected)) != state.stamp {
|
||||
self.load_shader_pack();
|
||||
}
|
||||
}
|
||||
/// A file dropped on the window: a shader pack while the Shader Packs
|
||||
/// page shows (`OptionsUi::drop_shader_pack`).
|
||||
pub(super) fn drop_shader_pack(&mut self, path: &std::path::Path) {
|
||||
match self.ui.options.drop_shader_pack(&mut self.settings, path) {
|
||||
Some(Ok(())) => {
|
||||
self.settings.save(&Self::options_path());
|
||||
self.load_shader_pack();
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
log::warn!("Horizon: shader pack {}: {e}", path.display());
|
||||
self.system_message(Text::literal(format!("Shader pack: {e}")));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
fn shader_pack_error(&mut self, id: &str, error: &str) {
|
||||
log::warn!("Horizon: shader pack {id}: {error}");
|
||||
// The Shader Packs page says why on the pack's row: the first line,
|
||||
// since translator errors go on with a drawn source excerpt.
|
||||
let first = error.lines().map(str::trim).find(|l| !l.is_empty());
|
||||
self.ui.options.shader_pack_error =
|
||||
Some((id.to_owned(), first.unwrap_or(error).to_owned()));
|
||||
self.system_message(Text::literal(format!("Shader pack {id}: {error}")));
|
||||
}
|
||||
}
|
||||
@@ -219,12 +219,11 @@ pub(super) fn spawn_loader(
|
||||
.name("resource-loader".into())
|
||||
.spawn(move || {
|
||||
let mut pack_error = None;
|
||||
let root = packs::startup_root(&vanilla_root, &settings.resource_packs).unwrap_or_else(
|
||||
|error| {
|
||||
let root = packs::startup_root(&vanilla_root, &settings.resource_packs, &[])
|
||||
.unwrap_or_else(|error| {
|
||||
pack_error = Some(error.to_string());
|
||||
vanilla_root.clone()
|
||||
},
|
||||
);
|
||||
});
|
||||
let root = mods::content_root(&root, &settings.mods.enabled, &dev_mods);
|
||||
let result = load_resources(
|
||||
root,
|
||||
@@ -367,11 +366,6 @@ impl Loading {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
packs::open_folder(&dir);
|
||||
}
|
||||
Event::OpenShaderPackFolder => {
|
||||
let dir = horizon_client::shader_pack::folder();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
packs::open_folder(&dir);
|
||||
}
|
||||
Event::Credits => {
|
||||
if let Some(gui) = &self.gui {
|
||||
let name = self.options.as_ref().map_or_else(
|
||||
@@ -387,9 +381,6 @@ impl Loading {
|
||||
self.menu.win = Some((screen, Instant::now()));
|
||||
}
|
||||
}
|
||||
Event::ReloadShaderPack => {
|
||||
self.save_menu_settings();
|
||||
}
|
||||
// The startup menu runs before the game opens a control socket.
|
||||
Event::ButtonClick | Event::CopyAgentDetails => {}
|
||||
}
|
||||
@@ -455,17 +446,6 @@ impl Loading {
|
||||
}
|
||||
self.apply_menu_settings();
|
||||
}
|
||||
WindowEvent::DroppedFile(path) => {
|
||||
match self
|
||||
.menu
|
||||
.options
|
||||
.drop_shader_pack(&mut self.settings, &path)
|
||||
{
|
||||
Some(Ok(())) => self.save_menu_settings(),
|
||||
Some(Err(e)) => log::warn!("Horizon: shader pack {}: {e}", path.display()),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
WindowEvent::RedrawRequested => self.draw(),
|
||||
WindowEvent::ModifiersChanged(modifiers) => self.menu.modifiers = modifiers.state(),
|
||||
WindowEvent::Focused(false) => {
|
||||
|
||||
@@ -5,34 +5,13 @@ use super::Game;
|
||||
use horizon_client::{
|
||||
gossip::{self, GossipType},
|
||||
items::ItemStack,
|
||||
mobs::{Mob, Species, spawning, villager},
|
||||
mobs::{Mob, Species, spawning, villager, villager_brain},
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
fn block_of(p: Vec3) -> [i32; 3] {
|
||||
[p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32]
|
||||
}
|
||||
/// `SpawnUtil.Strategy.LEGACY_IRON_GOLEM`: what a golem will not land on.
|
||||
fn golem_floor_ok(name: &str) -> bool {
|
||||
!matches!(
|
||||
name,
|
||||
"minecraft:cobweb"
|
||||
| "minecraft:cactus"
|
||||
| "minecraft:glass_pane"
|
||||
| "minecraft:conduit"
|
||||
| "minecraft:ice"
|
||||
| "minecraft:tnt"
|
||||
| "minecraft:glowstone"
|
||||
| "minecraft:beacon"
|
||||
| "minecraft:sea_lantern"
|
||||
| "minecraft:frosted_ice"
|
||||
| "minecraft:tinted_glass"
|
||||
| "minecraft:glass"
|
||||
) && !name.ends_with("_stained_glass_pane")
|
||||
&& !name.ends_with("_stained_glass")
|
||||
&& !name.ends_with("leaves")
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
fn villager_index(&self, id: u32) -> Option<usize> {
|
||||
self.mobs
|
||||
@@ -76,88 +55,46 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let now = self.world.game_time;
|
||||
if !villager::ext(&self.mobs[i]).is_some_and(|e| e.wants_golem(now)) {
|
||||
let Some(near) = villager::golem_summoners(&self.mobs, i, now, needed) else {
|
||||
return;
|
||||
}
|
||||
let at = self.mobs[i].position;
|
||||
let nearby: Vec<usize> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, m)| {
|
||||
m.species() == Species::Villager
|
||||
&& m.alive()
|
||||
&& (m.position.x - at.x).abs() <= 10.0
|
||||
&& (m.position.y - at.y).abs() <= 10.0
|
||||
&& (m.position.z - at.z).abs() <= 10.0
|
||||
})
|
||||
.map(|(j, _)| j)
|
||||
.collect();
|
||||
let wanting = nearby
|
||||
.iter()
|
||||
.filter(|j| villager::ext(&self.mobs[**j]).is_some_and(|e| e.wants_golem(now)))
|
||||
.take(5)
|
||||
.count();
|
||||
if wanting < needed {
|
||||
return;
|
||||
}
|
||||
if self.try_spawn_iron_golem(block_of(at)) {
|
||||
for j in nearby {
|
||||
villager::ext_mut(&mut self.mobs[j]).golem_until = now + 599;
|
||||
};
|
||||
if self.try_spawn_iron_golem(block_of(self.mobs[i].position)) {
|
||||
for j in near {
|
||||
villager_brain::golem_detected(&mut self.mobs[j], now);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `SpawnUtil.trySpawnMob(IRON_GOLEM, ..., 10 attempts, 8 across, 6 up
|
||||
/// and down, LEGACY_IRON_GOLEM)`.
|
||||
/// `SpawnUtil.trySpawnMob(IRON_GOLEM, ...)`: a golem near `start`, with
|
||||
/// the ambient sound `trySpawnMob` plays.
|
||||
fn try_spawn_iron_golem(&mut self, start: [i32; 3]) -> bool {
|
||||
for _ in 0..10 {
|
||||
let r = &mut self.world.random;
|
||||
let dx = r.next_int_bound(17).unwrap_or(8) - 8;
|
||||
let dz = r.next_int_bound(17).unwrap_or(8) - 8;
|
||||
let (x, z) = (start[0] + dx, start[2] + dz);
|
||||
let mut y = start[1] + 6;
|
||||
let mut above = self.world.block(x, y, z);
|
||||
let mut found = None;
|
||||
for _ in 0..=12 {
|
||||
y -= 1;
|
||||
let here = self.world.block(x, y, z);
|
||||
let f = self.res.facts().get(here);
|
||||
let above_f = self.res.facts().get(above);
|
||||
let air_or_liquid = self.res.facts().is_air(above)
|
||||
|| above_f.fluid != horizon_client::blocks::Fluid::None;
|
||||
let solid =
|
||||
self.res.mob_data().state(here).flags & horizon_client::mobs::data::SOLID != 0
|
||||
|| f.name.as_ref() == "minecraft:powder_snow";
|
||||
if golem_floor_ok(&f.name) && air_or_liquid && solid {
|
||||
found = Some(y + 1);
|
||||
break;
|
||||
}
|
||||
above = here;
|
||||
}
|
||||
let Some(y) = found else { continue };
|
||||
// IronGolem.checkSpawnRules: a floor and three free blocks.
|
||||
let free = |g: &Self, yy: i32| {
|
||||
g.res.mob_data().state(g.world.block(x, yy, z)).flags
|
||||
& horizon_client::mobs::data::VALID_EMPTY_SPAWN
|
||||
!= 0
|
||||
};
|
||||
if !(0..4).all(|k| free(self, y + k)) {
|
||||
continue;
|
||||
}
|
||||
let at = [f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5];
|
||||
if let Some(id) = self.spawn_mob_for("minecraft:iron_golem", at, false, true)
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
|
||||
{
|
||||
let sound = m.kind.sounds[0];
|
||||
if !sound.is_empty() {
|
||||
let at = m.center();
|
||||
self.audio
|
||||
.play(&format!("minecraft:{sound}"), Some(at), 1.0, 1.0);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
let Some(kind) = self.res.mob_types().get("minecraft:iron_golem").cloned() else {
|
||||
return false;
|
||||
};
|
||||
let id = self.next_entity + 1;
|
||||
let mut random = self.world.random.clone();
|
||||
let golem = villager::summon_golem(
|
||||
&self.mob_world_base(&[]),
|
||||
start,
|
||||
&mut random,
|
||||
|random, at, yaw| {
|
||||
let mut m = Mob::new(id, kind.clone(), at, yaw);
|
||||
villager::finalize_summoned_golem(&mut m, random);
|
||||
m
|
||||
},
|
||||
);
|
||||
self.world.random = random;
|
||||
let Some(golem) = golem else {
|
||||
return false;
|
||||
};
|
||||
self.next_entity = id;
|
||||
let sound = golem.kind.sounds[0];
|
||||
if !sound.is_empty() {
|
||||
let at = golem.center();
|
||||
self.audio
|
||||
.play(&format!("minecraft:{sound}"), Some(at), 1.0, 1.0);
|
||||
}
|
||||
false
|
||||
self.mobs.push(golem);
|
||||
true
|
||||
}
|
||||
|
||||
/// `VillagerMakeLove.tryToGiveBirth`: both eat, a free bed within 48
|
||||
|
||||
@@ -245,7 +245,6 @@ pub(super) fn make_streaming(
|
||||
streaming.biome_blend = settings.biome_blend;
|
||||
streaming.smooth_lighting = settings.smooth_lighting;
|
||||
streaming.cutout_leaves = settings.cutout_leaves;
|
||||
streaming.cull_leaves = settings.horizon.cull_leaves;
|
||||
streaming.biome_zoom = BiomeZoom::new(obfuscate_seed(seed));
|
||||
streaming.sky_light = dim != Dim::Nether;
|
||||
streaming.nether_shading = dim == Dim::Nether;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -51,9 +51,6 @@ pub(super) struct SpriteAnimator {
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
/// Edge of the arrays' layers.
|
||||
size: u32,
|
||||
/// The sprite layers (across the arrays) the last `animate` rewrote, at
|
||||
/// every mip level: a Java pack's atlas copies them.
|
||||
pub changed: Vec<u32>,
|
||||
}
|
||||
|
||||
impl SpriteAnimator {
|
||||
@@ -208,7 +205,6 @@ impl SpriteAnimator {
|
||||
jobs,
|
||||
pipeline,
|
||||
size,
|
||||
changed: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,7 +216,6 @@ impl SpriteAnimator {
|
||||
uploads: &mut Uploads,
|
||||
tick: u64,
|
||||
) {
|
||||
self.changed.clear();
|
||||
if self.collect(tick) == 0 {
|
||||
return;
|
||||
}
|
||||
@@ -256,7 +251,6 @@ impl SpriteAnimator {
|
||||
continue;
|
||||
}
|
||||
sprite.shown = Some(blend);
|
||||
self.changed.push(sprite.animation.layer);
|
||||
let job = [
|
||||
sprite.layer,
|
||||
sprite.frames[blend.frame],
|
||||
|
||||
@@ -65,11 +65,8 @@ pub struct Arena {
|
||||
}
|
||||
|
||||
impl Arena {
|
||||
/// Vertices; also storage, which a Java shader pack's terrain programs
|
||||
/// read a quad's corners from (`gfx/java_terrain.rs`).
|
||||
pub fn new(device: &wgpu::Device, stride: u64, capacity: u64) -> Self {
|
||||
let usage = wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::STORAGE;
|
||||
Self::with_usage(device, stride, capacity, usage)
|
||||
Self::with_usage(device, stride, capacity, wgpu::BufferUsages::VERTEX)
|
||||
}
|
||||
/// Sorted translucent indices; `storage` when compute passes read them.
|
||||
pub fn indices(device: &wgpu::Device, capacity: u64, storage: bool) -> Self {
|
||||
@@ -148,10 +145,6 @@ impl Arena {
|
||||
pub fn page_vertices(&self) -> u64 {
|
||||
self.page_limit
|
||||
}
|
||||
/// One past the last page index.
|
||||
pub fn page_count(&self) -> u32 {
|
||||
self.pages.len() as u32
|
||||
}
|
||||
pub fn has_page(&self, page: u32) -> bool {
|
||||
self.pages.get(page as usize).is_some_and(Option::is_some)
|
||||
}
|
||||
|
||||
@@ -108,14 +108,6 @@ fn fs_box_early(in: Box) -> @location(0) vec4<f32> {
|
||||
see(in.test);
|
||||
return vec4(0.0);
|
||||
}
|
||||
|
||||
// fs_box_early against a Java shader pack's depth (`vs_box_gl`), in a pass
|
||||
// without colour targets.
|
||||
@fragment
|
||||
@early_depth_test(force)
|
||||
fn fs_box_early_depth(in: Box) {
|
||||
see(in.test);
|
||||
}
|
||||
";
|
||||
|
||||
/// The test's depth texture binding (`InShader`), for the view it was made with.
|
||||
@@ -126,8 +118,6 @@ struct DepthGroup {
|
||||
|
||||
pub struct Cull {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
/// [`Self::test_gl`]'s: a depth-only pass in OpenGL's layout.
|
||||
gl_pipeline: wgpu::RenderPipeline,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
group: Option<Group>,
|
||||
depth_test: DepthTest,
|
||||
@@ -271,76 +261,55 @@ impl Cull {
|
||||
bind_group_layouts: &[Some(&layout), depth_layout.as_ref()],
|
||||
..Default::default()
|
||||
});
|
||||
// `gl`: a Java shader pack's depth (`test_gl`), in OpenGL's layout
|
||||
// (y flipped, and with it the faces' winding; 0 near), without a
|
||||
// colour target. In the shader (`InShader`): no colour target, and
|
||||
// the depth attached read only (a pass needs an attachment), its
|
||||
// test left to the shader.
|
||||
let pipeline = |label, gl: bool| {
|
||||
let color_target = [Some(wgpu::ColorTargetState {
|
||||
format: color,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::empty(),
|
||||
})];
|
||||
let (fragment_entry, targets, depth_compare) = match (depth_test, gl) {
|
||||
(DepthTest::Early, false) => (
|
||||
"fs_box_early",
|
||||
&color_target[..],
|
||||
wgpu::CompareFunction::GreaterEqual,
|
||||
),
|
||||
(DepthTest::Early, true) => (
|
||||
"fs_box_early_depth",
|
||||
&[][..],
|
||||
wgpu::CompareFunction::LessEqual,
|
||||
),
|
||||
(DepthTest::InShader, false) => ("fs_box", &[][..], wgpu::CompareFunction::Always),
|
||||
(DepthTest::InShader, true) => {
|
||||
("fs_box_gl", &[][..], wgpu::CompareFunction::Always)
|
||||
}
|
||||
};
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some(if gl { "vs_box_gl" } else { "vs_box" }),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: Some(wgpu::Face::Back),
|
||||
front_face: if gl {
|
||||
wgpu::FrontFace::Cw
|
||||
} else {
|
||||
wgpu::FrontFace::Ccw
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
// The pass's depth test (reversed in the world pass, OpenGL's
|
||||
// in a Java pack's), without writes.
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(false),
|
||||
depth_compare: Some(depth_compare),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some(fragment_entry),
|
||||
compilation_options: Default::default(),
|
||||
targets,
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
// In the shader (`InShader`): no colour target, and the depth
|
||||
// attached read only (a pass needs an attachment), its test left to
|
||||
// the shader.
|
||||
let color_target = [Some(wgpu::ColorTargetState {
|
||||
format: color,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::empty(),
|
||||
})];
|
||||
let (fragment_entry, targets, depth_compare) = match depth_test {
|
||||
DepthTest::Early => (
|
||||
"fs_box_early",
|
||||
&color_target[..],
|
||||
wgpu::CompareFunction::GreaterEqual,
|
||||
),
|
||||
DepthTest::InShader => ("fs_box", &[][..], wgpu::CompareFunction::Always),
|
||||
};
|
||||
let gl_pipeline = pipeline("Section culling, OpenGL layout", true);
|
||||
let pipeline = pipeline("Section culling", false);
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Section culling"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs_box"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: Some(wgpu::Face::Back),
|
||||
..Default::default()
|
||||
},
|
||||
// The world pass's reversed depth test, without writes.
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(false),
|
||||
depth_compare: Some(depth_compare),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some(fragment_entry),
|
||||
compilation_options: Default::default(),
|
||||
targets,
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
Self {
|
||||
pipeline,
|
||||
gl_pipeline,
|
||||
layout,
|
||||
group: None,
|
||||
depth_test,
|
||||
@@ -586,51 +555,6 @@ impl Cull {
|
||||
pass.draw(0..36, 0..self.test_data.len() as u32);
|
||||
}
|
||||
|
||||
/// [`Self::test`] for a Java shader pack that draws the terrain
|
||||
/// (`gfx/java_stages.rs`): in a pass of its own against `depth`, the
|
||||
/// pack's depth after phase 1 in OpenGL's layout (y up, 0 near), attached
|
||||
/// read only, and also read by the shader where the test runs there
|
||||
/// (`InShader`).
|
||||
pub fn test_gl(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
depth: &wgpu::TextureView,
|
||||
) {
|
||||
let Some(Group { group, .. }) = &self.group else {
|
||||
return;
|
||||
};
|
||||
if self.test_data.is_empty() {
|
||||
return;
|
||||
}
|
||||
let depth_group = self.depth_layout.as_ref().map(|layout| {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Section culling depth, Java shader pack"),
|
||||
layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(depth),
|
||||
}],
|
||||
})
|
||||
});
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Section culling, Java shader pack"),
|
||||
color_attachments: &[],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: depth,
|
||||
depth_ops: None,
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.gl_pipeline);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
if let Some(depth) = &depth_group {
|
||||
pass.set_bind_group(1, depth, &[]);
|
||||
}
|
||||
pass.draw(0..36, 0..self.test_data.len() as u32);
|
||||
}
|
||||
|
||||
/// After the world passes: copy the results for reading back, when a
|
||||
/// readback buffer is free.
|
||||
pub fn end(&mut self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder) {
|
||||
|
||||
@@ -52,10 +52,6 @@ fn corner(origin: vec3<f32>, c: u32) -> vec3<f32> {
|
||||
// section fail the depth test against the final depth, which is only nearer.
|
||||
@vertex
|
||||
fn vs_box(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box {
|
||||
return box_vertex(v, t);
|
||||
}
|
||||
|
||||
fn box_vertex(v: u32, t: u32) -> Box {
|
||||
let origin = origins[tests[t].x].xyz;
|
||||
var out: Box;
|
||||
out.test = t;
|
||||
@@ -90,15 +86,6 @@ fn box_vertex(v: u32, t: u32) -> Box {
|
||||
return out;
|
||||
}
|
||||
|
||||
// The same box in OpenGL's layout, for a Java shader pack's terrain depth
|
||||
// (gfx/java_stages.rs): y flipped, depth from 0 near to 1 far.
|
||||
@vertex
|
||||
fn vs_box_gl(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box {
|
||||
var out = box_vertex(v, t);
|
||||
out.position = vec4(out.position.x, -out.position.y, out.position.w - out.position.z, out.position.w);
|
||||
return out;
|
||||
}
|
||||
|
||||
// The box is seen at this fragment: store the stamp, and give its phase 2
|
||||
// draws their instance.
|
||||
fn see(t: u32) {
|
||||
@@ -124,12 +111,3 @@ fn fs_box(in: Box) {
|
||||
see(in.test);
|
||||
}
|
||||
}
|
||||
|
||||
// fs_box against a Java shader pack's depth in OpenGL's layout (`vs_box_gl`):
|
||||
// less or equal passing.
|
||||
@fragment
|
||||
fn fs_box_gl(in: Box) {
|
||||
if in.position.z <= textureLoad(depth, vec2<u32>(in.position.xy), 0) {
|
||||
see(in.test);
|
||||
}
|
||||
}
|
||||
@@ -640,36 +640,26 @@ impl Flat {
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
|
||||
}
|
||||
/// The bind group layout of the draws' group 1 (block slots, slot
|
||||
/// origins).
|
||||
pub fn draw_layout(&self) -> &wgpu::BindGroupLayout {
|
||||
&self.draw_layout
|
||||
}
|
||||
|
||||
/// Draws `calls` with a flat pipeline set and `vertices` giving each
|
||||
/// page's vertex buffer; `page` binds what else a page's draws need (a
|
||||
/// Java pack's terrain reads the page's vertices).
|
||||
/// page's vertex buffer.
|
||||
pub fn draw<'a>(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
calls: &[Call],
|
||||
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
|
||||
page: impl Fn(&mut wgpu::RenderPass<'_>, u32),
|
||||
) {
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
for call in calls {
|
||||
self.bind_page(pass, call.page, &vertices);
|
||||
page(pass, call.page);
|
||||
pass.draw_indexed(call.first..call.first + call.count, 0, 0..1);
|
||||
}
|
||||
}
|
||||
/// Draws phase 2's calls, as `flatten_phase2` left them; `page` as in
|
||||
/// [`Self::draw`].
|
||||
/// Draws phase 2's calls, as `flatten_phase2` left them.
|
||||
pub fn draw_phase2<'a>(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
|
||||
page: impl Fn(&mut wgpu::RenderPass<'_>, u32),
|
||||
) {
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
for (k, call) in self.calls[self.phase1_calls..self.solid_calls]
|
||||
@@ -677,7 +667,6 @@ impl Flat {
|
||||
.enumerate()
|
||||
{
|
||||
self.bind_page(pass, call.page, &vertices);
|
||||
page(pass, call.page);
|
||||
pass.draw_indexed_indirect(&self.phase2_calls, k as u64 * 20);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,667 +0,0 @@
|
||||
//! Horizon's terrain drawn with a Java pack's `gbuffers_terrain` and
|
||||
//! `gbuffers_water` (docs/shader-packs.md). The pack's vertex program runs
|
||||
//! after a glue that rebuilds what Iris would feed it from Horizon's
|
||||
//! `TerrainVertex`: the camera-relative position, the quad's normal and
|
||||
//! tangent (from its four corners, read from the arena page as storage),
|
||||
//! the colour without vanilla's directional shade, the lightmap
|
||||
//! coordinates, the sprite's place in a block atlas laid out from Horizon's
|
||||
//! sprite arrays, and `mc_Entity` from the pack's `block.properties`, by
|
||||
//! sprite name.
|
||||
use horizon_shaderpack::translate::Geometry;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// `TerrainVertex` in 32-bit words.
|
||||
const VERTEX_WORDS: u32 = 6;
|
||||
|
||||
/// The glue's declarations and `hz_vertex`; `HZ_ORIGIN_DECL` and
|
||||
/// `HZ_ORIGIN` are the variant's (instanced or flat draws).
|
||||
const GLUE: &str = r#"
|
||||
layout(location = 0) in vec3 hz_position;
|
||||
layout(location = 1) in uvec2 hz_uv;
|
||||
layout(location = 2) in uint hz_layer;
|
||||
layout(location = 3) in vec4 hz_color;
|
||||
layout(location = 4) in uvec2 hz_light;
|
||||
HZ_ORIGIN_DECL
|
||||
layout(std430, set = 3, binding = 0) readonly buffer HzVertices { uint hz_vertices[]; };
|
||||
// [0]: atlas columns, [1]: 1 in the Nether, then each sprite layer's block id.
|
||||
layout(std430, set = 3, binding = 1) readonly buffer HzTable { int hz_table[]; };
|
||||
vec4 hz_pos;
|
||||
vec3 hz_normal;
|
||||
vec4 hz_tangent;
|
||||
vec4 hz_tint;
|
||||
vec2 hz_atlas;
|
||||
vec2 hz_mid;
|
||||
vec2 hz_lightmap;
|
||||
float hz_block;
|
||||
vec3 hz_mid_block;
|
||||
vec3 hz_corner(uint v) {
|
||||
uint b = v * HZ_WORDSu;
|
||||
return vec3(uintBitsToFloat(hz_vertices[b]), uintBitsToFloat(hz_vertices[b + 1u]),
|
||||
uintBitsToFloat(hz_vertices[b + 2u]));
|
||||
}
|
||||
vec2 hz_corner_uv(uint v) {
|
||||
uint w = hz_vertices[v * HZ_WORDSu + 3u];
|
||||
return vec2(float(w & 65535u), float(w >> 16u));
|
||||
}
|
||||
void hz_vertex() {
|
||||
vec4 origin = HZ_ORIGIN;
|
||||
hz_pos = vec4(hz_position + origin.xyz, 1.0);
|
||||
// The quad's corners: quads are four vertices from a multiple of four.
|
||||
uint q = uint(gl_VertexIndex) & 4294967292u;
|
||||
vec3 p0 = hz_corner(q);
|
||||
vec3 e1 = hz_corner(q + 1u) - p0;
|
||||
vec3 e2 = hz_corner(q + 2u) - p0;
|
||||
vec3 n = cross(e1, e2);
|
||||
if (dot(n, n) < 1e-12) {
|
||||
n = cross(e2, hz_corner(q + 3u) - p0);
|
||||
}
|
||||
hz_normal = dot(n, n) > 0.0 ? normalize(n) : vec3(0.0, 1.0, 0.0);
|
||||
vec2 t0 = hz_corner_uv(q);
|
||||
vec2 d1 = hz_corner_uv(q + 1u) - t0;
|
||||
vec2 d2 = hz_corner_uv(q + 2u) - t0;
|
||||
float s = d1.x * d2.y - d2.x * d1.y < 0.0 ? -1.0 : 1.0;
|
||||
vec3 t = (e1 * d2.y - e2 * d1.y) * s;
|
||||
vec3 b = (e2 * d1.x - e1 * d2.x) * s;
|
||||
t = t - hz_normal * dot(hz_normal, t);
|
||||
t = dot(t, t) > 0.0 ? normalize(t) : vec3(1.0, 0.0, 0.0);
|
||||
hz_tangent = vec4(t, dot(cross(hz_normal, t), b) < 0.0 ? -1.0 : 1.0);
|
||||
// Iris turns vanilla's directional shade off: undo it on axis faces.
|
||||
vec3 a = abs(hz_normal);
|
||||
bool nether = hz_table[1] != 0;
|
||||
float shade = 1.0;
|
||||
if (a.y > 0.999) {
|
||||
shade = nether ? 0.9 : (hz_normal.y > 0.0 ? 1.0 : 0.5);
|
||||
} else if (a.z > 0.999) {
|
||||
shade = 0.8;
|
||||
} else if (a.x > 0.999) {
|
||||
shade = 0.6;
|
||||
}
|
||||
hz_tint = vec4(min(hz_color.rgb / shade, vec3(1.0)), hz_color.a);
|
||||
// The sprite's cell in the atlas, columns of square cells.
|
||||
int layer = int(hz_layer & 16383u);
|
||||
int cols = max(hz_table[0], 1);
|
||||
vec2 cell = vec2(float(layer % cols), float(layer / cols));
|
||||
vec2 uv = clamp(vec2(hz_uv) * origin.w, 0.0, 1.0);
|
||||
hz_atlas = (cell + uv) / float(cols);
|
||||
hz_mid = (cell + 0.5) / float(cols);
|
||||
// Vanilla's UV2: block light, then sky light, 0..240.
|
||||
hz_lightmap = vec2(float(hz_light.y), float(hz_light.x));
|
||||
hz_block = float(hz_table[2 + layer]);
|
||||
hz_mid_block = (floor(hz_position - hz_normal * 0.01) + 0.5 - hz_position) * 64.0;
|
||||
}
|
||||
"#;
|
||||
|
||||
/// The glue of one draw path: instanced section draws (the origin is an
|
||||
/// instance attribute) or flat ones (`gfx/flat.rs`: from the block's slot).
|
||||
pub fn glue(flat: bool, alpha_test: Option<f32>) -> Geometry {
|
||||
let (decl, origin) = if flat {
|
||||
(
|
||||
"layout(std430, set = 1, binding = 0) readonly buffer HzBlockSlots { uint hz_block_slots[]; };\n\
|
||||
layout(std430, set = 1, binding = 1) readonly buffer HzSlotOrigins { vec4 hz_slot_origins[]; };",
|
||||
"hz_slot_origins[hz_block_slots[uint(gl_VertexIndex) >> 8u]]",
|
||||
)
|
||||
} else {
|
||||
("layout(location = 5) in vec4 hz_origin;", "hz_origin")
|
||||
};
|
||||
let vertex_glue = GLUE
|
||||
.replace("HZ_ORIGIN_DECL", decl)
|
||||
.replace("HZ_ORIGIN", origin)
|
||||
.replace("HZ_WORDS", &VERTEX_WORDS.to_string());
|
||||
let lightmap = "vec4(hz_lightmap, 0.0, 1.0)";
|
||||
let attributes: BTreeMap<String, String> = [
|
||||
("iris_Vertex", "hz_pos"),
|
||||
("vaPosition", "hz_pos"),
|
||||
("iris_Normal", "vec4(hz_normal, 0.0)"),
|
||||
("vaNormal", "vec4(hz_normal, 0.0)"),
|
||||
("iris_Color", "hz_tint"),
|
||||
("vaColor", "hz_tint"),
|
||||
("iris_MultiTexCoord0", "vec4(hz_atlas, 0.0, 1.0)"),
|
||||
("vaUV0", "vec4(hz_atlas, 0.0, 1.0)"),
|
||||
("iris_MultiTexCoord1", lightmap),
|
||||
("iris_MultiTexCoord2", lightmap),
|
||||
("vaUV2", lightmap),
|
||||
// OverlayTexture.NO_OVERLAY.
|
||||
("vaUV1", "vec4(0.0, 10.0, 0.0, 0.0)"),
|
||||
("mc_Entity", "vec4(hz_block, -1.0, 0.0, 0.0)"),
|
||||
("mc_midTexCoord", "vec4(hz_mid, 0.0, 1.0)"),
|
||||
("at_tangent", "hz_tangent"),
|
||||
("at_midBlock", "vec4(hz_mid_block, 0.0)"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_owned(), v.to_owned()))
|
||||
.collect();
|
||||
Geometry {
|
||||
vertex_glue,
|
||||
attributes,
|
||||
alpha_test,
|
||||
}
|
||||
}
|
||||
|
||||
/// The glue's table: atlas columns, the Nether flag, then each sprite
|
||||
/// layer's block id from `block.properties` (-1 for blocks it doesn't
|
||||
/// list, as Iris).
|
||||
pub fn table(sprite_layers: &BTreeMap<String, u32>, block_ids: &BTreeMap<String, i32>) -> Vec<i32> {
|
||||
let layers = sprite_layers.values().max().map_or(0, |&m| m as usize + 1);
|
||||
let mut out = vec![-1; layers + 2];
|
||||
out[0] = atlas_columns(layers as u32) as i32;
|
||||
out[1] = 0;
|
||||
for (name, &layer) in sprite_layers {
|
||||
if let Some(id) = horizon_shaderpack::properties::sprite_block_id(name, block_ids) {
|
||||
out[layer as usize + 2] = id;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The atlas's columns (and rows) of sprite cells for `layers` sprites.
|
||||
pub fn atlas_columns(layers: u32) -> u32 {
|
||||
(f64::from(layers.max(1)).sqrt().ceil() as u32).max(1)
|
||||
}
|
||||
|
||||
/// Whether `device` renders to and filters the 16-bit normalized formats
|
||||
/// a Java pack's `RGBA16` targets become (`java_stages::target_format`).
|
||||
pub fn norm16_targets(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
|
||||
use wgpu::TextureFormat as F;
|
||||
let wanted = wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
|
||||
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES;
|
||||
device.features().contains(wanted)
|
||||
&& [
|
||||
F::R16Unorm,
|
||||
F::Rg16Unorm,
|
||||
F::Rgba16Unorm,
|
||||
F::R16Snorm,
|
||||
F::Rg16Snorm,
|
||||
F::Rgba16Snorm,
|
||||
]
|
||||
.into_iter()
|
||||
.all(|f| {
|
||||
let features = adapter.get_texture_format_features(f);
|
||||
features
|
||||
.allowed_usages
|
||||
.contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
|
||||
&& features
|
||||
.flags
|
||||
.contains(wgpu::TextureFormatFeatureFlags::FILTERABLE)
|
||||
})
|
||||
}
|
||||
|
||||
/// The bind group layout of a page group: the page's vertices and the glue's
|
||||
/// table, read by the vertex stage.
|
||||
pub fn page_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
|
||||
let storage = |binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Storage { read_only: true },
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Java shader pack terrain page"),
|
||||
entries: &[storage(0), storage(1)],
|
||||
})
|
||||
}
|
||||
|
||||
/// Which of Horizon's terrain draws a pack's program draws.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Layer {
|
||||
/// The solid sections: all of them, or with occlusion culling
|
||||
/// ([`JavaWorld::occlusion`]) the ones the last test saw (phase 1).
|
||||
Solid,
|
||||
/// With occlusion culling, the solid sections the test after phase 1
|
||||
/// found in sight (phase 2).
|
||||
Revealed,
|
||||
Translucent,
|
||||
/// The shadow map's sections ([`super::Renderer`]'s shadow list), solid
|
||||
/// then translucent.
|
||||
ShadowSolid,
|
||||
ShadowTranslucent,
|
||||
}
|
||||
|
||||
/// Draws Horizon's terrain with a pack's pipelines. Set by the renderer,
|
||||
/// which owns the sections.
|
||||
pub type DrawTerrain<'a> = dyn Fn(&mut wgpu::RenderPass<'_>, Layer, &TerrainPipelines<'_>) + 'a;
|
||||
|
||||
/// Occlusion culling's test between [`Layer::Solid`] and
|
||||
/// [`Layer::Revealed`], against the pack's depth (OpenGL's layout).
|
||||
pub type OcclusionTest<'a> = dyn Fn(&mut wgpu::CommandEncoder, &wgpu::TextureView) + 'a;
|
||||
|
||||
/// What the renderer binds to draw with a pack's geometry program: the
|
||||
/// pipeline of its draw path and the pack's group (group 2). Group 1 is the
|
||||
/// flat path's, group 3 the page's ([`page_layout`]). `flat` is `None` when
|
||||
/// the renderer has no flat draws.
|
||||
pub struct TerrainPipelines<'a> {
|
||||
pub instanced: &'a wgpu::RenderPipeline,
|
||||
pub flat: Option<&'a wgpu::RenderPipeline>,
|
||||
pub group: &'a wgpu::BindGroup,
|
||||
}
|
||||
|
||||
/// What a Java pack's geometry reads from the renderer each frame.
|
||||
pub struct JavaWorld<'a> {
|
||||
/// The block sprite arrays, [`super::SPRITE_SPLIT`]-layer each.
|
||||
pub sprites: &'a [wgpu::Texture],
|
||||
pub split: u32,
|
||||
/// Mipmap levels the terrain samples.
|
||||
pub levels: u32,
|
||||
/// The sprite layers and mip levels animation rewrote this frame.
|
||||
pub animated: &'a [(u32, u32)],
|
||||
pub lightmap: &'a wgpu::TextureView,
|
||||
pub draw: &'a DrawTerrain<'a>,
|
||||
/// Set when the frame culls solid sections by occlusion.
|
||||
pub occlusion: Option<&'a OcclusionTest<'a>>,
|
||||
}
|
||||
|
||||
const ATLAS: &str = r#"
|
||||
struct Atlas { cols: u32, layers: u32, split: u32, pad: u32 }
|
||||
@group(0) @binding(0) var<uniform> atlas: Atlas;
|
||||
@group(0) @binding(1) var s0: texture_2d_array<f32>;
|
||||
@group(0) @binding(2) var s1: texture_2d_array<f32>;
|
||||
@group(0) @binding(3) var s2: texture_2d_array<f32>;
|
||||
@group(0) @binding(4) var s3: texture_2d_array<f32>;
|
||||
@group(0) @binding(5) var s4: texture_2d_array<f32>;
|
||||
@group(0) @binding(6) var s5: texture_2d_array<f32>;
|
||||
@group(0) @binding(7) var s6: texture_2d_array<f32>;
|
||||
@group(0) @binding(8) var s7: texture_2d_array<f32>;
|
||||
|
||||
struct Out {
|
||||
@builtin(position) p: vec4f,
|
||||
@location(0) @interpolate(flat) mip: u32,
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs(@builtin(vertex_index) i: u32, @builtin(instance_index) mip: u32) -> Out {
|
||||
let uv = vec2f(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
return Out(vec4f(uv * 2.0 - 1.0, 0.0, 1.0), mip);
|
||||
}
|
||||
|
||||
// Each sprite layer into its cell, at mip level `mip`.
|
||||
@fragment
|
||||
fn fs(in: Out) -> @location(0) vec4f {
|
||||
let cell = max(textureDimensions(s0, in.mip).x, 1u);
|
||||
let px = vec2u(in.p.xy);
|
||||
let c = px / cell;
|
||||
let layer = c.y * atlas.cols + c.x;
|
||||
if c.x >= atlas.cols || layer >= atlas.layers {
|
||||
return vec4f(0.0);
|
||||
}
|
||||
let at = px % cell;
|
||||
let l = i32(layer % atlas.split);
|
||||
let m = i32(in.mip);
|
||||
switch layer / atlas.split {
|
||||
case 0u: { return textureLoad(s0, at, l, m); }
|
||||
case 1u: { return textureLoad(s1, at, l, m); }
|
||||
case 2u: { return textureLoad(s2, at, l, m); }
|
||||
case 3u: { return textureLoad(s3, at, l, m); }
|
||||
case 4u: { return textureLoad(s4, at, l, m); }
|
||||
case 5u: { return textureLoad(s5, at, l, m); }
|
||||
case 6u: { return textureLoad(s6, at, l, m); }
|
||||
default: { return textureLoad(s7, at, l, m); }
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
/// Horizon's block sprites as one 2D atlas, as Iris binds `gtexture`:
|
||||
/// square cells, [`atlas_columns`] to a row. Drawn from the sprite arrays
|
||||
/// once, then animated sprites are copied in as they change.
|
||||
pub struct Atlas {
|
||||
texture: wgpu::Texture,
|
||||
pub view: wgpu::TextureView,
|
||||
mips: Vec<wgpu::TextureView>,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
uniform: wgpu::Buffer,
|
||||
/// Layers, sprite size, mip levels it was made for.
|
||||
key: (u32, u32, u32),
|
||||
/// The first sprite array it was drawn from (resource reloads make
|
||||
/// new ones).
|
||||
source: wgpu::Texture,
|
||||
drawn: bool,
|
||||
}
|
||||
|
||||
impl Atlas {
|
||||
/// The atlas for `world`'s sprites; `None` without any.
|
||||
pub fn new(device: &wgpu::Device, queue: &wgpu::Queue, world: &JavaWorld) -> Option<Self> {
|
||||
let key = Self::key(world)?;
|
||||
let (layers, size, levels) = key;
|
||||
let cols = atlas_columns(layers);
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Java shader pack block atlas"),
|
||||
size: wgpu::Extent3d {
|
||||
width: cols * size,
|
||||
height: cols * size,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: levels,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
let mips = (0..levels)
|
||||
.map(|level| {
|
||||
texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
base_mip_level: level,
|
||||
mip_level_count: Some(1),
|
||||
..Default::default()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut entries = vec![wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
}];
|
||||
entries.extend((1..=8).map(|binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2Array,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
}));
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Java shader pack block atlas"),
|
||||
entries: &entries,
|
||||
});
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Java shader pack block atlas"),
|
||||
source: wgpu::ShaderSource::Wgsl(ATLAS.into()),
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Java shader pack block atlas"),
|
||||
layout: Some(
|
||||
&device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Java shader pack block atlas"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
}),
|
||||
),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::TextureFormat::Rgba8Unorm.into())],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Java shader pack block atlas"),
|
||||
size: 16,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
queue.write_buffer(
|
||||
&uniform,
|
||||
0,
|
||||
bytemuck::cast_slice(&[cols, layers, world.split.max(1), 0]),
|
||||
);
|
||||
Some(Self {
|
||||
view: texture.create_view(&Default::default()),
|
||||
texture,
|
||||
mips,
|
||||
pipeline,
|
||||
layout,
|
||||
uniform,
|
||||
key,
|
||||
source: world.sprites[0].clone(),
|
||||
drawn: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn key(world: &JavaWorld) -> Option<(u32, u32, u32)> {
|
||||
let first = world.sprites.first()?;
|
||||
let layers = world
|
||||
.sprites
|
||||
.iter()
|
||||
.map(|t| t.depth_or_array_layers())
|
||||
.sum();
|
||||
let levels = (world.levels + 1).min(first.mip_level_count()).max(1);
|
||||
Some((layers, first.width(), levels))
|
||||
}
|
||||
|
||||
/// Whether it was made for `world`'s sprites.
|
||||
pub fn fits(&self, world: &JavaWorld) -> bool {
|
||||
Self::key(world) == Some(self.key) && world.sprites.first() == Some(&self.source)
|
||||
}
|
||||
|
||||
/// Bring it up to date: drawn the first time, then the sprites
|
||||
/// animation rewrote copied into their cells.
|
||||
pub fn update(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
world: &JavaWorld,
|
||||
) {
|
||||
if !self.drawn {
|
||||
self.draw(device, encoder, world);
|
||||
self.drawn = true;
|
||||
return;
|
||||
}
|
||||
let (layers, size, levels) = self.key;
|
||||
let cols = atlas_columns(layers);
|
||||
let split = world.split.max(1);
|
||||
for &(layer, level) in world.animated {
|
||||
let Some(array) = world.sprites.get((layer / split) as usize) else {
|
||||
continue;
|
||||
};
|
||||
if level >= levels || layer >= layers {
|
||||
continue;
|
||||
}
|
||||
let edge = (size >> level).max(1);
|
||||
encoder.copy_texture_to_texture(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: array,
|
||||
mip_level: level,
|
||||
origin: wgpu::Origin3d {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: layer % split,
|
||||
},
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: &self.texture,
|
||||
mip_level: level,
|
||||
origin: wgpu::Origin3d {
|
||||
x: (layer % cols) * edge,
|
||||
y: (layer / cols) * edge,
|
||||
z: 0,
|
||||
},
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: edge,
|
||||
height: edge,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The atlas's size in pixels (`atlasSize`).
|
||||
pub fn size(&self) -> [u32; 2] {
|
||||
[self.texture.width(), self.texture.height()]
|
||||
}
|
||||
|
||||
/// Draw the sprites into the atlas, every mip level.
|
||||
fn draw(&self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, world: &JavaWorld) {
|
||||
let views: Vec<wgpu::TextureView> = (0..8)
|
||||
.map(|i| {
|
||||
let t = world.sprites.get(i).unwrap_or(&world.sprites[0]);
|
||||
t.create_view(&wgpu::TextureViewDescriptor {
|
||||
dimension: Some(wgpu::TextureViewDimension::D2Array),
|
||||
mip_level_count: Some(self.key.2),
|
||||
..Default::default()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut entries = vec![wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: self.uniform.as_entire_binding(),
|
||||
}];
|
||||
entries.extend(views.iter().enumerate().map(|(i, v)| wgpu::BindGroupEntry {
|
||||
binding: i as u32 + 1,
|
||||
resource: wgpu::BindingResource::TextureView(v),
|
||||
}));
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Java shader pack block atlas"),
|
||||
layout: &self.layout,
|
||||
entries: &entries,
|
||||
});
|
||||
for (mip, view) in self.mips.iter().enumerate() {
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Java shader pack block atlas"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.draw(0..3, mip as u32..mip as u32 + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `module` validates and translates for Metal, Vulkan and DirectX 12.
|
||||
fn backends(module: &naga::Module, label: &str) {
|
||||
let info = naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(module)
|
||||
.unwrap_or_else(|e| panic!("{label}: {e:?}"));
|
||||
naga::back::msl::write_string(
|
||||
module,
|
||||
&info,
|
||||
// wgpu's Metal backend asks for MSL 2.x.
|
||||
&naga::back::msl::Options {
|
||||
lang_version: (2, 4),
|
||||
..Default::default()
|
||||
},
|
||||
&naga::back::msl::PipelineOptions::default(),
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{label} (MSL): {e}"));
|
||||
naga::back::spv::write_vec(module, &info, &Default::default(), None)
|
||||
.unwrap_or_else(|e| panic!("{label} (SPIR-V): {e}"));
|
||||
for entry in &module.entry_points {
|
||||
let mut hlsl = String::new();
|
||||
naga::back::hlsl::Writer::new(
|
||||
&mut hlsl,
|
||||
&naga::back::hlsl::Options::default(),
|
||||
&naga::back::hlsl::PipelineOptions {
|
||||
entry_point: Some((entry.stage, entry.name.clone())),
|
||||
},
|
||||
)
|
||||
.write(module, &info, None)
|
||||
.unwrap_or_else(|e| panic!("{label} {} (HLSL): {e}", entry.name));
|
||||
}
|
||||
}
|
||||
|
||||
/// A terrain program reading every attribute the glue fills, run after
|
||||
/// each draw path's glue, and the atlas shader, on every backend.
|
||||
#[test]
|
||||
fn the_glue_feeds_a_terrain_program_on_every_backend() {
|
||||
let program = "#version 120
|
||||
attribute vec4 mc_Entity;
|
||||
attribute vec4 mc_midTexCoord;
|
||||
attribute vec4 at_tangent;
|
||||
attribute vec3 at_midBlock;
|
||||
varying vec4 color;
|
||||
varying vec2 uv;
|
||||
varying vec2 lm;
|
||||
varying vec3 normal;
|
||||
varying float id;
|
||||
void main() {
|
||||
vec4 p = gl_ModelViewMatrix * gl_Vertex;
|
||||
p.xyz += at_midBlock / 64.0 * 0.0 + at_tangent.xyz * 0.0;
|
||||
gl_Position = gl_ProjectionMatrix * p;
|
||||
color = gl_Color;
|
||||
uv = (gl_TextureMatrix[0] * gl_MultiTexCoord0).xy + mc_midTexCoord.xy * 0.0;
|
||||
lm = (gl_TextureMatrix[1] * gl_MultiTexCoord1).xy;
|
||||
normal = gl_NormalMatrix * gl_Normal;
|
||||
id = mc_Entity.x;
|
||||
}
|
||||
";
|
||||
for flat in [false, true] {
|
||||
let mut link = horizon_shaderpack::translate::Link {
|
||||
set: 2,
|
||||
geometry: Some(glue(flat, Some(0.1))),
|
||||
..Default::default()
|
||||
};
|
||||
let t = horizon_shaderpack::translate::translate(
|
||||
horizon_shaderpack::Stage::Vertex,
|
||||
program,
|
||||
&mut link,
|
||||
&|l| format!("line {l}"),
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("flat {flat}: {e}"));
|
||||
for wanted in ["hz_vertex();", "mc_Entity = vec4(vec4(hz_block"] {
|
||||
assert!(t.glsl.contains(wanted), "{wanted}\n{}", t.glsl);
|
||||
}
|
||||
// The glue's inputs are Horizon's vertex layout.
|
||||
let inputs = t.module.entry_points[0].function.arguments.len();
|
||||
assert_eq!(inputs, if flat { 6 } else { 7 }, "{}", t.wgsl);
|
||||
let reparsed = naga::front::wgsl::parse_str(&t.wgsl).unwrap();
|
||||
backends(&reparsed, &format!("terrain glue (flat {flat})"));
|
||||
}
|
||||
backends(&naga::front::wgsl::parse_str(ATLAS).unwrap(), "atlas");
|
||||
backends(
|
||||
&naga::front::wgsl::parse_str(super::super::java_stages::BLIT).unwrap(),
|
||||
"Java shader pack blits",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprites_take_their_blocks_ids() {
|
||||
let layers: BTreeMap<String, u32> = [
|
||||
("minecraft:block/oak_leaves", 0),
|
||||
("minecraft:block/water_still", 1),
|
||||
("minecraft:block/stone", 2),
|
||||
("minecraft:block/oak_log_top", 3),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(n, l)| (n.to_owned(), l))
|
||||
.collect();
|
||||
let ids: BTreeMap<String, i32> = [("oak_leaves", 10003), ("water", 8), ("oak_log", 7)]
|
||||
.into_iter()
|
||||
.map(|(n, i)| (n.to_owned(), i))
|
||||
.collect();
|
||||
assert_eq!(table(&layers, &ids), [2, 0, 10003, 8, -1, 7]);
|
||||
assert_eq!(atlas_columns(5), 3);
|
||||
assert_eq!(atlas_columns(0), 1);
|
||||
}
|
||||
}
|
||||
@@ -1,552 +0,0 @@
|
||||
//! A Horizon shader pack on the GPU (docs/shader-packs.md): each stage
|
||||
//! (`shadow`, `gbuffer`, `deferred`, `composite`, `final`) is a full-screen
|
||||
//! pass that reads the world's colour, depth, opaque depth, G-buffer normals
|
||||
//! and materials, shadow map, noise texture and the previous stage's output,
|
||||
//! and writes its own target; the last target goes to the screen through
|
||||
//! `world_post` (copy or FXAA) under the hand and HUD. Only exists while a
|
||||
//! pack is selected.
|
||||
use crate::shader_pack::{Rgba, ShaderPack, VERTEX_ENTRY};
|
||||
use horizon_shaderpack::uniforms::SmoothFloat;
|
||||
|
||||
/// The `Frame` uniform of the prelude (`shader_pack_prelude.wgsl`).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Default, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct FrameUniform {
|
||||
pub view: [[f32; 4]; 4],
|
||||
pub projection: [[f32; 4]; 4],
|
||||
pub inverse_view: [[f32; 4]; 4],
|
||||
pub inverse_projection: [[f32; 4]; 4],
|
||||
pub camera_position: [f32; 4],
|
||||
pub resolution: [f32; 4],
|
||||
pub time: [f32; 4],
|
||||
pub sun: [f32; 4],
|
||||
pub moon: [f32; 4],
|
||||
pub sky_color: [f32; 4],
|
||||
pub fog_color: [f32; 4],
|
||||
pub fog: [f32; 4],
|
||||
pub weather: [f32; 4],
|
||||
pub camera_state: [f32; 4],
|
||||
pub view_params: [f32; 4],
|
||||
pub dimension: [f32; 4],
|
||||
}
|
||||
|
||||
/// The API 2 `FrameExt` uniform of the prelude (`shader_pack_prelude.wgsl`).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Default, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct FrameExtUniform {
|
||||
pub previous_view: [[f32; 4]; 4],
|
||||
pub previous_projection: [[f32; 4]; 4],
|
||||
pub shadow_view: [[f32; 4]; 4],
|
||||
pub shadow_projection: [[f32; 4]; 4],
|
||||
pub shadow_inverse_view: [[f32; 4]; 4],
|
||||
pub shadow_inverse_projection: [[f32; 4]; 4],
|
||||
pub previous_camera: [f32; 4],
|
||||
pub shadow_params: [f32; 4],
|
||||
pub environment: [f32; 4],
|
||||
}
|
||||
|
||||
/// Iris's `noisetex` without a `texture.noise`: 256² random RGB, opaque.
|
||||
/// Horizon packs get the same one unless `[textures] noise` replaces it.
|
||||
pub(super) fn noise() -> Rgba {
|
||||
const SIZE: u32 = 256;
|
||||
let mut seed = 0x2545_f491_u32;
|
||||
let pixels = (0..SIZE * SIZE * 4)
|
||||
.map(|i| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
if i % 4 == 3 { 255 } else { (seed >> 24) as u8 }
|
||||
})
|
||||
.collect();
|
||||
Rgba {
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
pixels,
|
||||
}
|
||||
}
|
||||
|
||||
/// `image` as a sampled RGBA8 texture.
|
||||
pub(super) fn rgba_view(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
label: &str,
|
||||
image: &Rgba,
|
||||
) -> wgpu::TextureView {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: wgpu::Extent3d {
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
queue.write_texture(
|
||||
texture.as_image_copy(),
|
||||
&image.pixels,
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(image.width * 4),
|
||||
rows_per_image: None,
|
||||
},
|
||||
texture.size(),
|
||||
);
|
||||
texture.create_view(&Default::default())
|
||||
}
|
||||
|
||||
struct Pass {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
scale: f32,
|
||||
target_format: wgpu::TextureFormat,
|
||||
uniform: wgpu::Buffer,
|
||||
uniform_ext: wgpu::Buffer,
|
||||
}
|
||||
/// A stage's output and the bindings that read the world into it.
|
||||
struct Target {
|
||||
size: [u32; 2],
|
||||
view: wgpu::TextureView,
|
||||
group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
pub struct PackStages {
|
||||
pub id: String,
|
||||
pub needs_shadow: bool,
|
||||
/// The stages read the G-buffer (`build_gbuffer`) or the depth before
|
||||
/// translucent geometry (`capture_opaque_depth`).
|
||||
pub needs_gbuffer: bool,
|
||||
pub needs_opaque_depth: bool,
|
||||
pub shadow_res: u32,
|
||||
pub shadow_dist: f32,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
linear: wgpu::Sampler,
|
||||
nearest: wgpu::Sampler,
|
||||
repeat: wgpu::Sampler,
|
||||
shadow_sampler: wgpu::Sampler,
|
||||
noise_view: wgpu::TextureView,
|
||||
passes: Vec<Pass>,
|
||||
/// Built for this world target size and `world_post` generation.
|
||||
targets: Vec<Target>,
|
||||
/// `world_post`'s bindings of the last stage's output.
|
||||
output: Option<wgpu::BindGroup>,
|
||||
world_size: [u32; 2],
|
||||
post_generation: u64,
|
||||
started: std::time::Instant,
|
||||
last: Option<std::time::Instant>,
|
||||
frames: u32,
|
||||
previous: Option<([[f32; 4]; 4], [[f32; 4]; 4], [f32; 3])>,
|
||||
wetness: SmoothFloat,
|
||||
}
|
||||
|
||||
fn stage_format(name: Option<&str>, default: wgpu::TextureFormat) -> wgpu::TextureFormat {
|
||||
match name.map(str::to_lowercase).as_deref() {
|
||||
Some("rgba16float" | "rgba16f") => wgpu::TextureFormat::Rgba16Float,
|
||||
Some("rgba8unorm" | "rgba8" | "rgba") => wgpu::TextureFormat::Rgba8Unorm,
|
||||
Some("rgb10a2unorm" | "rgb10_a2") => wgpu::TextureFormat::Rgb10a2Unorm,
|
||||
_ => default,
|
||||
}
|
||||
}
|
||||
|
||||
impl PackStages {
|
||||
/// The pipelines of a loaded (already validated) pack, or wgpu's error.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
format: wgpu::TextureFormat,
|
||||
pack: &ShaderPack,
|
||||
) -> Result<Self, String> {
|
||||
let texture = |binding, sample_type| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let float = wgpu::TextureSampleType::Float { filterable: true };
|
||||
let sampler = |binding, ty| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(ty),
|
||||
count: None,
|
||||
};
|
||||
let uniform_entry = |binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Shader pack stage"),
|
||||
entries: &[
|
||||
texture(0, float),
|
||||
texture(1, wgpu::TextureSampleType::Depth),
|
||||
texture(2, float),
|
||||
sampler(3, wgpu::SamplerBindingType::Filtering),
|
||||
sampler(4, wgpu::SamplerBindingType::NonFiltering),
|
||||
uniform_entry(5),
|
||||
uniform_entry(6),
|
||||
texture(7, float),
|
||||
texture(8, float),
|
||||
texture(9, wgpu::TextureSampleType::Depth),
|
||||
texture(10, wgpu::TextureSampleType::Depth),
|
||||
sampler(11, wgpu::SamplerBindingType::Comparison),
|
||||
texture(12, float),
|
||||
sampler(13, wgpu::SamplerBindingType::Filtering),
|
||||
],
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Shader pack stage"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let constants = pack.constants();
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let stage_count = pack.stages.len();
|
||||
let passes = pack
|
||||
.stages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, stage)| {
|
||||
let is_last = idx + 1 == stage_count;
|
||||
let target_format = if is_last {
|
||||
format
|
||||
} else {
|
||||
stage_format(stage.format.as_deref(), format)
|
||||
};
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(&stage.file),
|
||||
source: wgpu::ShaderSource::Wgsl(stage.source.as_str().into()),
|
||||
});
|
||||
// Only the overrides a stage declares may be passed to it.
|
||||
let constants: Vec<(&str, f64)> = constants
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|(key, _)| declares_override(&stage.source, key))
|
||||
.collect();
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(&stage.file),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some(VERTEX_ENTRY),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some(&stage.entry),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: target_format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
Pass {
|
||||
pipeline,
|
||||
scale: stage.scale,
|
||||
target_format,
|
||||
uniform: device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack frame"),
|
||||
size: std::mem::size_of::<FrameUniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
uniform_ext: device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack frame ext"),
|
||||
size: std::mem::size_of::<FrameExtUniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if let Some(error) = pollster::block_on(scope.pop()) {
|
||||
return Err(error.to_string());
|
||||
}
|
||||
let make_sampler = |filter, address, compare| {
|
||||
device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Shader pack"),
|
||||
address_mode_u: address,
|
||||
address_mode_v: address,
|
||||
address_mode_w: address,
|
||||
mag_filter: filter,
|
||||
min_filter: filter,
|
||||
compare,
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
let noise_view = match &pack.custom_noise {
|
||||
Some(image) => rgba_view(device, queue, "Shader pack noise", image),
|
||||
None => rgba_view(device, queue, "Shader pack noise", &noise()),
|
||||
};
|
||||
let (shadow_res, shadow_dist) = pack.shadow_config();
|
||||
Ok(Self {
|
||||
id: pack.id.clone(),
|
||||
needs_shadow: pack.needs_shadow(),
|
||||
needs_gbuffer: pack.needs_gbuffer(),
|
||||
needs_opaque_depth: pack.needs_opaque_depth(),
|
||||
shadow_res,
|
||||
shadow_dist,
|
||||
layout,
|
||||
linear: make_sampler(
|
||||
wgpu::FilterMode::Linear,
|
||||
wgpu::AddressMode::ClampToEdge,
|
||||
None,
|
||||
),
|
||||
nearest: make_sampler(
|
||||
wgpu::FilterMode::Nearest,
|
||||
wgpu::AddressMode::ClampToEdge,
|
||||
None,
|
||||
),
|
||||
repeat: make_sampler(wgpu::FilterMode::Linear, wgpu::AddressMode::Repeat, None),
|
||||
shadow_sampler: make_sampler(
|
||||
wgpu::FilterMode::Linear,
|
||||
wgpu::AddressMode::ClampToEdge,
|
||||
Some(wgpu::CompareFunction::GreaterEqual),
|
||||
),
|
||||
noise_view,
|
||||
passes,
|
||||
targets: Vec::new(),
|
||||
output: None,
|
||||
world_size: [0, 0],
|
||||
post_generation: u64::MAX,
|
||||
started: std::time::Instant::now(),
|
||||
last: None,
|
||||
frames: 0,
|
||||
previous: None,
|
||||
wetness: SmoothFloat::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Run every stage over the world's `color` and `depth` (`size`) and
|
||||
/// return `post`'s bindings of the last stage's output.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn run(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
color: &wgpu::TextureView,
|
||||
depth: &wgpu::TextureView,
|
||||
size: [u32; 2],
|
||||
post: &super::world_post::Post,
|
||||
mut uniform: FrameUniform,
|
||||
eye_light: [u8; 2],
|
||||
) -> &wgpu::BindGroup {
|
||||
if self.world_size != size
|
||||
|| self.post_generation != post.generation()
|
||||
|| self.targets.len() != self.passes.len()
|
||||
{
|
||||
self.world_size = size;
|
||||
self.post_generation = post.generation();
|
||||
self.targets.clear();
|
||||
for pass in &self.passes {
|
||||
let target_size = size.map(|v| ((v as f32 * pass.scale).round() as u32).max(1));
|
||||
let view = device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Shader pack stage"),
|
||||
size: wgpu::Extent3d {
|
||||
width: target_size[0],
|
||||
height: target_size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: pass.target_format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default());
|
||||
let previous = self.targets.last().map_or(color, |t: &Target| &t.view);
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack stage"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(color),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(depth),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(previous),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::Sampler(&self.linear),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::Sampler(&self.nearest),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 5,
|
||||
resource: pass.uniform.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 6,
|
||||
resource: pass.uniform_ext.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 7,
|
||||
resource: wgpu::BindingResource::TextureView(post.normal()),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 8,
|
||||
resource: wgpu::BindingResource::TextureView(post.material()),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 9,
|
||||
resource: wgpu::BindingResource::TextureView(post.opaque_depth()),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 10,
|
||||
resource: wgpu::BindingResource::TextureView(post.shadow_depth()),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 11,
|
||||
resource: wgpu::BindingResource::Sampler(&self.shadow_sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 12,
|
||||
resource: wgpu::BindingResource::TextureView(&self.noise_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 13,
|
||||
resource: wgpu::BindingResource::Sampler(&self.repeat),
|
||||
},
|
||||
],
|
||||
});
|
||||
self.targets.push(Target {
|
||||
size: target_size,
|
||||
view,
|
||||
group,
|
||||
});
|
||||
}
|
||||
let last = &self.targets.last().expect("a pack has a stage").view;
|
||||
self.output = Some(post.bind(device, last));
|
||||
}
|
||||
let now = std::time::Instant::now();
|
||||
let dt = self.last.map_or(0.0, |t| (now - t).as_secs_f32());
|
||||
self.last = Some(now);
|
||||
let wet = self.wetness.update(uniform.weather[0], 60.0, 120.0, dt);
|
||||
let cam = [
|
||||
uniform.camera_position[0],
|
||||
uniform.camera_position[1],
|
||||
uniform.camera_position[2],
|
||||
];
|
||||
let (prev_view, prev_proj, prev_cam) =
|
||||
self.previous
|
||||
.unwrap_or((uniform.view, uniform.projection, cam));
|
||||
self.previous = Some((uniform.view, uniform.projection, cam));
|
||||
let shadow_view = post.shadow_view;
|
||||
let shadow_proj = post.shadow_proj;
|
||||
let ext = FrameExtUniform {
|
||||
previous_view: prev_view,
|
||||
previous_projection: prev_proj,
|
||||
shadow_view: shadow_view.to_cols_array_2d(),
|
||||
shadow_projection: shadow_proj.to_cols_array_2d(),
|
||||
shadow_inverse_view: shadow_view.inverse().to_cols_array_2d(),
|
||||
shadow_inverse_projection: shadow_proj.inverse().to_cols_array_2d(),
|
||||
previous_camera: [prev_cam[0], prev_cam[1], prev_cam[2], dt],
|
||||
shadow_params: [
|
||||
post.shadow_resolution as f32,
|
||||
post.shadow_distance,
|
||||
0.0,
|
||||
if post.shadow_active { 1.0 } else { 0.0 },
|
||||
],
|
||||
environment: [
|
||||
f32::from(eye_light[0]) * 16.0,
|
||||
f32::from(eye_light[1]) * 16.0,
|
||||
wet,
|
||||
0.0,
|
||||
],
|
||||
};
|
||||
uniform.time[0] = self.started.elapsed().as_secs_f32();
|
||||
uniform.time[1] = self.frames as f32;
|
||||
self.frames = self.frames.wrapping_add(1);
|
||||
for (pass, target) in self.passes.iter().zip(&self.targets) {
|
||||
let [w, h] = target.size.map(|v| v as f32);
|
||||
uniform.resolution = [w, h, 1.0 / w, 1.0 / h];
|
||||
queue.write_buffer(&pass.uniform, 0, bytemuck::bytes_of(&uniform));
|
||||
queue.write_buffer(&pass.uniform_ext, 0, bytemuck::bytes_of(&ext));
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &target.view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut render = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Shader pack stage"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
render.set_pipeline(&pass.pipeline);
|
||||
render.set_bind_group(0, &target.group, &[]);
|
||||
render.draw(0..3, 0..1);
|
||||
}
|
||||
self.output.as_ref().expect("bound with the targets")
|
||||
}
|
||||
}
|
||||
|
||||
/// `override <key>` appears in the source (a declaration, not just a use).
|
||||
fn declares_override(source: &str, key: &str) -> bool {
|
||||
source.lines().any(|line| {
|
||||
line.trim_start()
|
||||
.strip_prefix("override")
|
||||
.and_then(|rest| rest.trim_start().strip_prefix(key))
|
||||
.is_some_and(|rest| rest.trim_start().starts_with([':', '=']))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn the_frame_uniform_matches_the_prelude() {
|
||||
// 4 matrices and 12 vectors, as `struct Frame` in the prelude.
|
||||
assert_eq!(std::mem::size_of::<super::FrameUniform>(), 4 * 64 + 12 * 16);
|
||||
let prelude = crate::shader_pack::PRELUDE;
|
||||
let body = prelude.split("struct Frame {").nth(1).unwrap();
|
||||
let body = &body[..body.find('}').unwrap()];
|
||||
assert_eq!(body.matches("mat4x4<f32>").count(), 4);
|
||||
assert_eq!(body.matches("vec4<f32>").count(), 12);
|
||||
}
|
||||
#[test]
|
||||
fn only_declared_overrides_are_passed() {
|
||||
let source =
|
||||
"override saturation: f32 = 1.0;\noverride vignette = true;\nlet x = saturation_2;";
|
||||
assert!(super::declares_override(source, "saturation"));
|
||||
assert!(super::declares_override(source, "vignette"));
|
||||
assert!(!super::declares_override(source, "saturation_2"));
|
||||
assert!(!super::declares_override(source, "haze"));
|
||||
}
|
||||
}
|
||||
@@ -1,163 +1,25 @@
|
||||
//! Resolution scale, FXAA, and shader-pack G-buffer/shadow targets (Horizon
|
||||
//! settings): the world renders into its own target at the scaled size, then
|
||||
//! one full-screen pass copies or anti-aliases it onto the screen, under the
|
||||
//! hand and HUD. For a shader pack that reads them, `Post` also keeps:
|
||||
//! - `opaque_depth`: the depth before translucent terrain (water, stained
|
||||
//! glass, ice) draws, a Java pack's `depthtex1`/`depthtex2`;
|
||||
//! - `normal` and `material`, an api 2 pack's `scene_normal` and
|
||||
//! `scene_material`: surface normals and material properties
|
||||
//! reconstructed from depth and scene colour;
|
||||
//! - `shadow_depth`, an api 2 pack's: the sun/moon shadow map and its
|
||||
//! light-space `shadow_view` and `shadow_proj` matrices.
|
||||
//!
|
||||
//! With none of them on, the world renders straight to the screen as before.
|
||||
|
||||
use super::pack_stages::FrameUniform;
|
||||
use glam::{Mat4, Vec3};
|
||||
|
||||
const GBUFFER_WGSL: &str = r#"
|
||||
struct GbufferParams {
|
||||
inverse_view: mat4x4<f32>,
|
||||
inverse_projection: mat4x4<f32>,
|
||||
resolution: vec4<f32>,
|
||||
// x: eye block light (0..1), y: eye sky light (0..1), z: wetness (0..1), w: sun elevation
|
||||
env: vec4<f32>,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var world_color: texture_2d<f32>;
|
||||
@group(0) @binding(1) var world_depth: texture_depth_2d;
|
||||
@group(0) @binding(2) var opaque_depth: texture_depth_2d;
|
||||
@group(0) @binding(3) var nearest_sampler: sampler;
|
||||
@group(0) @binding(4) var<uniform> params: GbufferParams;
|
||||
|
||||
struct VsOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs_gbuffer(@builtin(vertex_index) i: u32) -> VsOut {
|
||||
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
var out: VsOut;
|
||||
out.clip = vec4<f32>(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
out.uv = vec2<f32>(p.x, 1.0 - p.y);
|
||||
return out;
|
||||
}
|
||||
|
||||
struct GbufferOut {
|
||||
@location(0) normal: vec4<f32>,
|
||||
@location(1) material: vec4<f32>,
|
||||
}
|
||||
|
||||
fn world_pos(uv: vec2<f32>, d: f32) -> vec3<f32> {
|
||||
let v = params.inverse_projection * vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, d, 1.0);
|
||||
let view = v.xyz / max(v.w, 1e-6);
|
||||
return (params.inverse_view * vec4<f32>(view, 1.0)).xyz;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_gbuffer(in: VsOut) -> GbufferOut {
|
||||
let d_all = textureSampleLevel(world_depth, nearest_sampler, in.uv, 0);
|
||||
let d_opq = textureSampleLevel(opaque_depth, nearest_sampler, in.uv, 0);
|
||||
let color = textureSampleLevel(world_color, nearest_sampler, in.uv, 0.0);
|
||||
var out: GbufferOut;
|
||||
if d_all <= 0.0 && d_opq <= 0.0 {
|
||||
out.normal = vec4<f32>(0.5, 1.0, 0.5, 0.0);
|
||||
out.material = vec4<f32>(0.0, params.env.y, 0.0, 1.0);
|
||||
return out;
|
||||
}
|
||||
let d = select(d_all, d_opq, d_opq > 0.0);
|
||||
let step_uv = params.resolution.zw;
|
||||
let p0 = world_pos(in.uv, d);
|
||||
let uv_r = clamp(in.uv + vec2<f32>(step_uv.x, 0.0), vec2<f32>(0.0), vec2<f32>(1.0));
|
||||
let uv_l = clamp(in.uv - vec2<f32>(step_uv.x, 0.0), vec2<f32>(0.0), vec2<f32>(1.0));
|
||||
let uv_d = clamp(in.uv + vec2<f32>(0.0, step_uv.y), vec2<f32>(0.0), vec2<f32>(1.0));
|
||||
let uv_u = clamp(in.uv - vec2<f32>(0.0, step_uv.y), vec2<f32>(0.0), vec2<f32>(1.0));
|
||||
let dr = textureSampleLevel(opaque_depth, nearest_sampler, uv_r, 0);
|
||||
let dl = textureSampleLevel(opaque_depth, nearest_sampler, uv_l, 0);
|
||||
let dd = textureSampleLevel(opaque_depth, nearest_sampler, uv_d, 0);
|
||||
let du = textureSampleLevel(opaque_depth, nearest_sampler, uv_u, 0);
|
||||
let dx = select(world_pos(uv_r, dr) - p0, p0 - world_pos(uv_l, dl), abs(dl - d) < abs(dr - d));
|
||||
let dy = select(world_pos(uv_d, dd) - p0, p0 - world_pos(uv_u, du), abs(du - d) < abs(dd - d));
|
||||
let raw_n = cross(dy, dx);
|
||||
let n_len = length(raw_n);
|
||||
let n = select(vec3<f32>(0.0, 1.0, 0.0), raw_n / max(n_len, 1e-6), n_len > 1e-6);
|
||||
// Reversed depth: closer translucent geometry has larger depth than opaque geometry.
|
||||
let is_translucent = select(0.0, 1.0, d_all > d_opq + 1e-5);
|
||||
let luma = dot(color.rgb, vec3<f32>(0.2126, 0.7152, 0.0722));
|
||||
let block_light = clamp(max(params.env.x, smoothstep(0.65, 0.98, luma)), 0.0, 1.0);
|
||||
let roughness = select(clamp(0.85 - params.env.z * 0.45, 0.1, 1.0), 0.05, is_translucent > 0.5);
|
||||
out.normal = vec4<f32>(n * 0.5 + 0.5, 1.0);
|
||||
out.material = vec4<f32>(block_light, params.env.y, is_translucent, roughness);
|
||||
return out;
|
||||
}
|
||||
"#;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Default, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct GbufferUniform {
|
||||
inverse_view: [[f32; 4]; 4],
|
||||
inverse_projection: [[f32; 4]; 4],
|
||||
resolution: [f32; 4],
|
||||
env: [f32; 4],
|
||||
}
|
||||
|
||||
/// What the frame's shader pack reads beyond the world's colour and depth:
|
||||
/// `Post` keeps these targets only while one does.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Extras {
|
||||
pub opaque_depth: bool,
|
||||
pub gbuffer: bool,
|
||||
}
|
||||
//! Resolution scale and FXAA (Horizon settings): the world renders into its
|
||||
//! own target at the scaled size, then one full-screen pass copies or
|
||||
//! anti-aliases it onto the screen, under the hand and HUD. With neither on,
|
||||
//! the world renders straight to the screen.
|
||||
|
||||
struct WorldTarget {
|
||||
size: [u32; 2],
|
||||
extras: Extras,
|
||||
color: wgpu::TextureView,
|
||||
depth_tex: wgpu::Texture,
|
||||
depth: wgpu::TextureView,
|
||||
/// With `extras.opaque_depth` or `extras.gbuffer` (which reads it).
|
||||
opaque_depth: Option<(wgpu::Texture, wgpu::TextureView)>,
|
||||
gbuffer: Option<Gbuffer>,
|
||||
group: wgpu::BindGroup,
|
||||
opaque_captured: bool,
|
||||
}
|
||||
|
||||
struct Gbuffer {
|
||||
normal: wgpu::TextureView,
|
||||
material: wgpu::TextureView,
|
||||
group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
struct ShadowTarget {
|
||||
resolution: u32,
|
||||
depth: wgpu::TextureView,
|
||||
}
|
||||
|
||||
pub struct Post {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
sampler: wgpu::Sampler,
|
||||
nearest: wgpu::Sampler,
|
||||
copy: wgpu::RenderPipeline,
|
||||
fxaa: wgpu::RenderPipeline,
|
||||
gbuffer_layout: wgpu::BindGroupLayout,
|
||||
gbuffer_pipeline: wgpu::RenderPipeline,
|
||||
gbuffer_uniform: wgpu::Buffer,
|
||||
format: wgpu::TextureFormat,
|
||||
/// World resolution relative to the window (0.5..=2.0).
|
||||
pub scale: f32,
|
||||
pub antialias: bool,
|
||||
target: Option<WorldTarget>,
|
||||
shadow: Option<ShadowTarget>,
|
||||
/// Bumped whenever a target is made: bindings of the old ones are stale.
|
||||
generation: u64,
|
||||
fallback_shadow: wgpu::TextureView,
|
||||
fallback_color: wgpu::TextureView,
|
||||
pub shadow_view: Mat4,
|
||||
pub shadow_proj: Mat4,
|
||||
pub shadow_distance: f32,
|
||||
pub shadow_resolution: u32,
|
||||
pub shadow_active: bool,
|
||||
}
|
||||
|
||||
impl Post {
|
||||
@@ -225,192 +87,31 @@ impl Post {
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
..Default::default()
|
||||
});
|
||||
let nearest = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Horizon post nearest"),
|
||||
mag_filter: wgpu::FilterMode::Nearest,
|
||||
min_filter: wgpu::FilterMode::Nearest,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let gbuffer_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon gbuffer"),
|
||||
source: wgpu::ShaderSource::Wgsl(GBUFFER_WGSL.into()),
|
||||
});
|
||||
let gbuffer_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Horizon gbuffer layout"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Depth,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Depth,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 3,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering),
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 4,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let gbuffer_pipe_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Horizon gbuffer"),
|
||||
bind_group_layouts: &[Some(&gbuffer_layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let gbuffer_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon gbuffer"),
|
||||
layout: Some(&gbuffer_pipe_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &gbuffer_shader,
|
||||
entry_point: Some("vs_gbuffer"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &gbuffer_shader,
|
||||
entry_point: Some("fs_gbuffer"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[
|
||||
Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
}),
|
||||
Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let gbuffer_uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon gbuffer uniform"),
|
||||
size: std::mem::size_of::<GbufferUniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let fallback_shadow = device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon fallback shadow"),
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default());
|
||||
let fallback_color = device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon fallback gbuffer"),
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default());
|
||||
Self {
|
||||
copy: pipeline("fs_copy"),
|
||||
fxaa: pipeline("fs_fxaa"),
|
||||
layout,
|
||||
sampler,
|
||||
nearest,
|
||||
gbuffer_layout,
|
||||
gbuffer_pipeline,
|
||||
gbuffer_uniform,
|
||||
format,
|
||||
scale: 1.0,
|
||||
antialias: false,
|
||||
target: None,
|
||||
shadow: None,
|
||||
generation: 0,
|
||||
fallback_shadow,
|
||||
fallback_color,
|
||||
shadow_view: Mat4::IDENTITY,
|
||||
shadow_proj: Mat4::IDENTITY,
|
||||
shadow_distance: 128.0,
|
||||
shadow_resolution: 2048,
|
||||
shadow_active: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The world's colour and depth targets this frame, or `None` to draw
|
||||
/// it straight to the screen. `pack`: a shader pack runs this frame,
|
||||
/// reading `extras`.
|
||||
/// it straight to the screen.
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
window: [u32; 2],
|
||||
pack: bool,
|
||||
extras: Extras,
|
||||
) -> Option<(wgpu::TextureView, wgpu::TextureView)> {
|
||||
if self.scale == 1.0 && !self.antialias && !pack {
|
||||
if self.scale == 1.0 && !self.antialias {
|
||||
self.target = None;
|
||||
self.shadow_active = false;
|
||||
return None;
|
||||
}
|
||||
let size = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
|
||||
if self
|
||||
.target
|
||||
.as_ref()
|
||||
.is_none_or(|t| t.size != size || t.extras != extras)
|
||||
{
|
||||
self.generation += 1;
|
||||
if self.target.as_ref().is_none_or(|t| t.size != size) {
|
||||
let extent = wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
@@ -434,273 +135,26 @@ impl Post {
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
)
|
||||
.create_view(&Default::default());
|
||||
let depth_tex = make_tex(
|
||||
// TEXTURE_BINDING: occlusion culling reads it.
|
||||
let depth = make_tex(
|
||||
"Scaled world depth",
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let depth = depth_tex.create_view(&Default::default());
|
||||
let opaque_depth = (extras.opaque_depth || extras.gbuffer).then(|| {
|
||||
let tex = make_tex(
|
||||
"Opaque world depth",
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
);
|
||||
let view = tex.create_view(&Default::default());
|
||||
(tex, view)
|
||||
});
|
||||
let gbuffer = opaque_depth
|
||||
.as_ref()
|
||||
.filter(|_| extras.gbuffer)
|
||||
.map(|(_, opaque)| {
|
||||
let color_target = |label| {
|
||||
make_tex(
|
||||
label,
|
||||
wgpu::TextureFormat::Rgba8Unorm,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
)
|
||||
.create_view(&Default::default())
|
||||
};
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon gbuffer"),
|
||||
layout: &self.gbuffer_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&color),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&depth),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(opaque),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::Sampler(&self.nearest),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: self.gbuffer_uniform.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
Gbuffer {
|
||||
normal: color_target("World G-buffer normal"),
|
||||
material: color_target("World G-buffer material"),
|
||||
group,
|
||||
}
|
||||
});
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
)
|
||||
.create_view(&Default::default());
|
||||
let group = self.bind(device, &color);
|
||||
self.target = Some(WorldTarget {
|
||||
size,
|
||||
extras,
|
||||
color,
|
||||
depth_tex,
|
||||
depth,
|
||||
opaque_depth,
|
||||
gbuffer,
|
||||
group,
|
||||
opaque_captured: false,
|
||||
});
|
||||
}
|
||||
if let Some(t) = &mut self.target {
|
||||
t.opaque_captured = false;
|
||||
}
|
||||
self.target
|
||||
.as_ref()
|
||||
.map(|t| (t.color.clone(), t.depth.clone()))
|
||||
}
|
||||
|
||||
/// Prepare the directional sun/moon shadow map target and compute its
|
||||
/// camera-relative orthographic `shadow_view` and `shadow_proj` matrices.
|
||||
pub fn prepare_shadow(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
resolution: u32,
|
||||
distance: f32,
|
||||
sun_dir: Vec3,
|
||||
moon_dir: Vec3,
|
||||
) -> &wgpu::TextureView {
|
||||
let resolution = resolution.clamp(256, 4096);
|
||||
let distance = distance.clamp(16.0, 512.0);
|
||||
if self
|
||||
.shadow
|
||||
.as_ref()
|
||||
.is_none_or(|s| s.resolution != resolution)
|
||||
{
|
||||
let depth = device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon shadow map"),
|
||||
size: wgpu::Extent3d {
|
||||
width: resolution,
|
||||
height: resolution,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default());
|
||||
self.shadow = Some(ShadowTarget { resolution, depth });
|
||||
self.generation += 1;
|
||||
}
|
||||
let light_dir = if sun_dir.y >= -0.05 {
|
||||
sun_dir.normalize_or_zero()
|
||||
} else {
|
||||
moon_dir.normalize_or_zero()
|
||||
};
|
||||
let dir = if light_dir.length_squared() > 1e-4 {
|
||||
light_dir
|
||||
} else {
|
||||
Vec3::Y
|
||||
};
|
||||
let up = if dir.y.abs() > 0.99 { Vec3::Z } else { Vec3::Y };
|
||||
self.shadow_view = Mat4::look_at_rh(dir * distance, Vec3::ZERO, up);
|
||||
// Reversed-depth orthographic projection for the world's GreaterEqual
|
||||
// pipelines (near maps to 1.0, far maps to 0.0), and standard 0..1
|
||||
// projection for shader sampling.
|
||||
self.shadow_proj = Mat4::orthographic_rh(
|
||||
-distance,
|
||||
distance,
|
||||
-distance,
|
||||
distance,
|
||||
distance * 2.5,
|
||||
0.05,
|
||||
);
|
||||
self.shadow_distance = distance;
|
||||
self.shadow_resolution = resolution;
|
||||
self.shadow_active = true;
|
||||
&self.shadow.as_ref().expect("created above").depth
|
||||
}
|
||||
|
||||
/// Snapshot `world_depth` into `opaque_depth` (`depthtex1`/`depthtex2`)
|
||||
/// right before translucent water and weather draw.
|
||||
pub fn capture_opaque_depth(&mut self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let Some(t) = &mut self.target else { return };
|
||||
let Some((opaque, _)) = &t.opaque_depth else {
|
||||
return;
|
||||
};
|
||||
encoder.copy_texture_to_texture(
|
||||
t.depth_tex.as_image_copy(),
|
||||
opaque.as_image_copy(),
|
||||
t.depth_tex.size(),
|
||||
);
|
||||
t.opaque_captured = true;
|
||||
}
|
||||
|
||||
/// Reconstruct the G-buffer `normal` and `material` textures after the
|
||||
/// world pass finishes (with `Extras::gbuffer`).
|
||||
pub fn build_gbuffer(
|
||||
&mut self,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
frame: &FrameUniform,
|
||||
eye_light: [u8; 2],
|
||||
wetness: f32,
|
||||
) {
|
||||
if !self.target.as_ref().is_some_and(|t| t.opaque_captured) {
|
||||
self.capture_opaque_depth(encoder);
|
||||
}
|
||||
let Some(t) = &self.target else { return };
|
||||
let Some(gbuffer) = &t.gbuffer else { return };
|
||||
let [w, h] = t.size.map(|v| v.max(1) as f32);
|
||||
let uniform = GbufferUniform {
|
||||
inverse_view: frame.inverse_view,
|
||||
inverse_projection: frame.inverse_projection,
|
||||
resolution: [w, h, 1.0 / w, 1.0 / h],
|
||||
env: [
|
||||
f32::from(eye_light[0]) / 15.0,
|
||||
f32::from(eye_light[1]) / 15.0,
|
||||
wetness,
|
||||
frame.sun[1],
|
||||
],
|
||||
};
|
||||
queue.write_buffer(&self.gbuffer_uniform, 0, bytemuck::bytes_of(&uniform));
|
||||
let attachments = [
|
||||
Some(wgpu::RenderPassColorAttachment {
|
||||
view: &gbuffer.normal,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
}),
|
||||
Some(wgpu::RenderPassColorAttachment {
|
||||
view: &gbuffer.material,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
}),
|
||||
];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon gbuffer"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.gbuffer_pipeline);
|
||||
pass.set_bind_group(0, &gbuffer.group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
/// The world target's size, once `prepare` made one.
|
||||
pub fn size(&self) -> Option<[u32; 2]> {
|
||||
self.target.as_ref().map(|t| t.size)
|
||||
}
|
||||
|
||||
/// Changes whenever `prepare` or `prepare_shadow` makes new targets:
|
||||
/// bindings made from the views below must be made again.
|
||||
pub fn generation(&self) -> u64 {
|
||||
self.generation
|
||||
}
|
||||
|
||||
/// The depth before translucent geometry (`capture_opaque_depth`), or
|
||||
/// the world's depth when nothing reads it.
|
||||
pub fn opaque_depth(&self) -> &wgpu::TextureView {
|
||||
match &self.target {
|
||||
Some(WorldTarget {
|
||||
opaque_depth: Some((_, view)),
|
||||
..
|
||||
}) => view,
|
||||
Some(t) => &t.depth,
|
||||
None => &self.fallback_shadow,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstructed world-space normals (`scene_normal`).
|
||||
pub fn normal(&self) -> &wgpu::TextureView {
|
||||
self.gbuffer().map_or(&self.fallback_color, |g| &g.normal)
|
||||
}
|
||||
|
||||
/// Reconstructed material and light (`scene_material`).
|
||||
pub fn material(&self) -> &wgpu::TextureView {
|
||||
self.gbuffer().map_or(&self.fallback_color, |g| &g.material)
|
||||
}
|
||||
|
||||
fn gbuffer(&self) -> Option<&Gbuffer> {
|
||||
self.target.as_ref()?.gbuffer.as_ref()
|
||||
}
|
||||
|
||||
/// The sun/moon shadow map (`shadow_depth` of api 2 packs).
|
||||
pub fn shadow_depth(&self) -> &wgpu::TextureView {
|
||||
self.shadow
|
||||
.as_ref()
|
||||
.map_or(&self.fallback_shadow, |s| &s.depth)
|
||||
}
|
||||
|
||||
/// Bindings that copy (or anti-alias) `view` onto the screen in `finish_with`.
|
||||
pub fn bind(&self, device: &wgpu::Device, view: &wgpu::TextureView) -> wgpu::BindGroup {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
|
||||
@@ -227,8 +227,6 @@ pub struct Graphics {
|
||||
pub far_detail: FarDetail,
|
||||
/// Skip sections hidden behind closed ones (vanilla's occlusion graph).
|
||||
pub cave_culling: bool,
|
||||
/// Hide shared leaf and mangrove-root faces, reducing foliage density.
|
||||
pub cull_leaves: bool,
|
||||
/// Skip drawing entities hidden behind opaque terrain.
|
||||
pub entity_culling: bool,
|
||||
/// Meshed sections uploaded per frame; 0 uploads all that are ready.
|
||||
@@ -252,9 +250,6 @@ pub struct Graphics {
|
||||
/// or behind other windows, and half the worker threads. Off by default;
|
||||
/// it changes when frames are drawn, never what they show or the ticks.
|
||||
pub battery: bool,
|
||||
/// The Horizon shader pack in `shaderpacks/` (its folder name); empty:
|
||||
/// none, the vanilla picture (docs/shader-packs.md).
|
||||
pub shader_pack: String,
|
||||
/// The graphics API (Windows only; see `GraphicsApi`).
|
||||
pub api: GraphicsApi,
|
||||
}
|
||||
@@ -265,7 +260,6 @@ impl Default for Graphics {
|
||||
lod_distance: 0,
|
||||
far_detail: FarDetail::Full,
|
||||
cave_culling: true,
|
||||
cull_leaves: false,
|
||||
entity_culling: false,
|
||||
chunk_updates: 0,
|
||||
upload_budget_kib: 0,
|
||||
@@ -280,7 +274,6 @@ impl Default for Graphics {
|
||||
resolution_scale: 100,
|
||||
fxaa: false,
|
||||
battery: false,
|
||||
shader_pack: String::new(),
|
||||
api: GraphicsApi::Auto,
|
||||
}
|
||||
}
|
||||
@@ -312,7 +305,6 @@ impl Graphics {
|
||||
"lodDistance" => g.lod_distance = number(g.lod_distance).min(1024),
|
||||
"farDetail" => g.far_detail = FarDetail::parse(value),
|
||||
"caveCulling" => g.cave_culling = flag(g.cave_culling),
|
||||
"cullLeaves" => g.cull_leaves = flag(g.cull_leaves),
|
||||
"entityCulling" => g.entity_culling = flag(g.entity_culling),
|
||||
"chunkUpdates" => g.chunk_updates = number(g.chunk_updates).min(512),
|
||||
"uploadBudgetKiB" => g.upload_budget_kib = number(g.upload_budget_kib).min(65536),
|
||||
@@ -332,7 +324,6 @@ impl Graphics {
|
||||
"resolutionScale" => g.resolution_scale = number(g.resolution_scale).clamp(50, 200),
|
||||
"fxaa" => g.fxaa = flag(g.fxaa),
|
||||
"batteryMode" => g.battery = flag(g.battery),
|
||||
"shaderPack" => g.shader_pack = value.to_string(),
|
||||
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
|
||||
"overlay" => {
|
||||
g.overlay = match value {
|
||||
@@ -348,12 +339,11 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\ncullLeaves:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nbatteryMode:{}\nshaderPack:{}\ngraphicsApi:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nbatteryMode:{}\ngraphicsApi:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
bool_text(self.cave_culling),
|
||||
bool_text(self.cull_leaves),
|
||||
bool_text(self.entity_culling),
|
||||
self.chunk_updates,
|
||||
self.upload_budget_kib,
|
||||
@@ -375,7 +365,6 @@ impl Graphics {
|
||||
self.resolution_scale,
|
||||
bool_text(self.fxaa),
|
||||
bool_text(self.battery),
|
||||
self.shader_pack,
|
||||
self.api.name(),
|
||||
)
|
||||
}
|
||||
@@ -454,7 +443,6 @@ mod tests {
|
||||
lod_distance: 256,
|
||||
far_detail: FarDetail::Reduced,
|
||||
cave_culling: false,
|
||||
cull_leaves: true,
|
||||
entity_culling: true,
|
||||
chunk_updates: 12,
|
||||
upload_budget_kib: 4096,
|
||||
@@ -469,15 +457,19 @@ mod tests {
|
||||
resolution_scale: 75,
|
||||
fxaa: true,
|
||||
battery: true,
|
||||
shader_pack: "horizon-sample".into(),
|
||||
api: GraphicsApi::Dx12,
|
||||
};
|
||||
assert_eq!(Graphics::parse(&g.serialize()), g);
|
||||
assert_eq!(Graphics::parse(""), Graphics::default());
|
||||
// The old menus' Simple / Advanced choice is ignored.
|
||||
// The old menus' Simple / Advanced choice is ignored, and so is the
|
||||
// shader pack older versions saved.
|
||||
assert_eq!(Graphics::parse("level:advanced\n"), Graphics::default());
|
||||
assert_eq!(
|
||||
Graphics::parse("workers:abc\nsky:maybe\ncullLeaves:maybe\nentityCulling:maybe\n"),
|
||||
Graphics::parse("shaderPack:Pack.zip\n"),
|
||||
Graphics::default()
|
||||
);
|
||||
assert_eq!(
|
||||
Graphics::parse("workers:abc\nsky:maybe\ncullLeaves:true\nentityCulling:maybe\n"),
|
||||
Graphics::default()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ pub enum H {
|
||||
LodDistance,
|
||||
FarDetail,
|
||||
CaveCulling,
|
||||
CullLeaves,
|
||||
EntityCulling,
|
||||
ChunkUpdates,
|
||||
UploadBudget,
|
||||
@@ -39,7 +38,6 @@ pub enum H {
|
||||
ResolutionScale,
|
||||
Antialiasing,
|
||||
BatteryMode,
|
||||
ShaderPack,
|
||||
GraphicsApi,
|
||||
}
|
||||
|
||||
@@ -112,18 +110,13 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
"Waits for the display's refresh: no tearing, a little latency.",
|
||||
);
|
||||
let gui = v(Opt::GuiScale, None, "Size of menus and the HUD.");
|
||||
let shader_pack = h(
|
||||
H::ShaderPack,
|
||||
High,
|
||||
"Redraws the world with a pack from the shaderpacks folder. Off is vanilla.",
|
||||
);
|
||||
let preset = h(
|
||||
H::Preset,
|
||||
None,
|
||||
"Sets the options below together. Changing one makes it Custom.",
|
||||
);
|
||||
let mut rows = match level {
|
||||
Level::Simple => vec![preset, render, lod, far, fov, fps, vsync, gui, shader_pack],
|
||||
Level::Simple => vec![preset, render, lod, far, fov, fps, vsync, gui],
|
||||
Level::Advanced => vec![
|
||||
Row::Header("Rendering"),
|
||||
render,
|
||||
@@ -144,11 +137,6 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
Saves,
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.",
|
||||
),
|
||||
h(
|
||||
H::CullLeaves,
|
||||
None,
|
||||
"Hides shared leaf and mangrove root faces; foliage can look less dense.",
|
||||
),
|
||||
h(
|
||||
H::EntityCulling,
|
||||
None,
|
||||
@@ -251,7 +239,6 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
None,
|
||||
"Lowers the frame rate when minimised, or idle too.",
|
||||
),
|
||||
shader_pack,
|
||||
Row::Header("Preferences"),
|
||||
v(Opt::ViewBobbing, None, "Sways the view while walking."),
|
||||
v(Opt::ChunkFade, None, "Fades newly loaded chunks in."),
|
||||
@@ -350,7 +337,6 @@ impl H {
|
||||
},
|
||||
H::FarDetail => labelled("Far Detail", g.far_detail.label()),
|
||||
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
|
||||
H::CullLeaves => labelled("Cull Leaves", on_off(g.cull_leaves)),
|
||||
H::EntityCulling => labelled("Entity Culling", on_off(g.entity_culling)),
|
||||
H::ChunkUpdates => match g.chunk_updates {
|
||||
0 => labelled("Chunk Updates", "Unlimited"),
|
||||
@@ -381,10 +367,6 @@ impl H {
|
||||
H::Antialiasing => labelled("Anti-aliasing", if g.fxaa { "FXAA" } else { "Off" }),
|
||||
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
|
||||
H::GraphicsApi => labelled("Graphics API", g.api.label()),
|
||||
H::ShaderPack => match g.shader_pack.as_str() {
|
||||
"" => labelled("Shader Pack", "Off"),
|
||||
id => labelled("Shader Pack", id),
|
||||
},
|
||||
H::Overlay => labelled(
|
||||
"Performance Overlay",
|
||||
match g.overlay {
|
||||
@@ -429,7 +411,6 @@ impl H {
|
||||
g.far_detail = step(&[FarDetail::Full, FarDetail::Reduced], g.far_detail, back)
|
||||
}
|
||||
H::CaveCulling => g.cave_culling = !g.cave_culling,
|
||||
H::CullLeaves => g.cull_leaves = !g.cull_leaves,
|
||||
H::EntityCulling => g.entity_culling = !g.entity_culling,
|
||||
H::Sky => g.sky = !g.sky,
|
||||
H::Fog => g.fog = !g.fog,
|
||||
@@ -466,7 +447,6 @@ impl Item {
|
||||
let g = &s.horizon;
|
||||
Some(match self {
|
||||
Item::Horizon(H::CaveCulling) => g.cave_culling,
|
||||
Item::Horizon(H::CullLeaves) => g.cull_leaves,
|
||||
Item::Horizon(H::EntityCulling) => g.entity_culling,
|
||||
Item::Horizon(H::Sky) => g.sky,
|
||||
Item::Horizon(H::Fog) => g.fog,
|
||||
@@ -527,7 +507,6 @@ impl Item {
|
||||
| H::PresentMode
|
||||
| H::Workers
|
||||
| H::BatteryMode
|
||||
| H::ShaderPack
|
||||
| H::GraphicsApi
|
||||
) | Item::Vanilla(
|
||||
Opt::GuiScale
|
||||
@@ -557,8 +536,6 @@ pub enum Action {
|
||||
Changed,
|
||||
OpenAdvanced,
|
||||
OpenSimple,
|
||||
/// The Shader Pack row: the Shader Packs page (where packs are picked).
|
||||
OpenShaderPacks,
|
||||
Done,
|
||||
}
|
||||
|
||||
@@ -784,9 +761,6 @@ impl GraphicsUi {
|
||||
}
|
||||
return match wd.target {
|
||||
Target::Button(action) => vec![Action::ButtonClick, action],
|
||||
Target::Item(Item::Horizon(H::ShaderPack)) => {
|
||||
vec![Action::ButtonClick, Action::OpenShaderPacks]
|
||||
}
|
||||
Target::Item(item) => {
|
||||
if item.slider(s).is_some() {
|
||||
self.dragging = Some(item);
|
||||
@@ -994,44 +968,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shader_pack_row_is_off_by_default_and_outside_presets() {
|
||||
let mut s = Settings::default();
|
||||
let row = Item::Horizon(H::ShaderPack);
|
||||
assert_eq!(row.label(&s).plain(), "Shader Pack: Off");
|
||||
assert!(
|
||||
rows(Level::Advanced)
|
||||
.iter()
|
||||
.any(|r| matches!(r, Row::Setting(x) if x.item == row))
|
||||
);
|
||||
s.horizon.shader_pack = "horizon-sample".into();
|
||||
assert_eq!(row.label(&s).plain(), "Shader Pack: horizon-sample");
|
||||
// The row opens the Shader Packs page, where packs are picked.
|
||||
let mut ui = GraphicsUi::default();
|
||||
let find = |ui: &mut GraphicsUi| {
|
||||
let (widgets, ..) = ui.layout(Level::Advanced, 854.0, 480.0);
|
||||
widgets
|
||||
.into_iter()
|
||||
.find(|w| matches!(w.target, Target::Item(i) if i == row))
|
||||
.unwrap()
|
||||
};
|
||||
ui.scroll = find(&mut ui).y - GraphicsUi::bounds(480.0).0 - 4.0;
|
||||
let w = find(&mut ui);
|
||||
assert_eq!(
|
||||
ui.click(
|
||||
Level::Advanced,
|
||||
&mut s,
|
||||
(w.x + 1.0, w.y + 1.0),
|
||||
(854.0, 480.0),
|
||||
[854, 480],
|
||||
false,
|
||||
false
|
||||
),
|
||||
[Action::ButtonClick, Action::OpenShaderPacks]
|
||||
);
|
||||
assert_eq!(s.horizon.shader_pack, "horizon-sample");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_set_vanilla_and_horizon_options() {
|
||||
let mut s = Settings::default();
|
||||
@@ -1058,48 +994,6 @@ mod tests {
|
||||
assert_eq!(s.horizon.upload_budget_kib, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cull_leaves_is_opt_in_and_toggles_from_advanced_graphics() {
|
||||
let mut s = Settings::default();
|
||||
let item = Item::Horizon(H::CullLeaves);
|
||||
assert_eq!(item.switch(&s), Some(false));
|
||||
assert_eq!(item.label(&s).plain(), "Cull Leaves: Off");
|
||||
for enabled in [false, true] {
|
||||
s.horizon.cull_leaves = enabled;
|
||||
for preset in Preset::ALL {
|
||||
crate::graphics::apply_preset(preset, &mut s);
|
||||
assert_eq!(s.horizon.cull_leaves, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
let mut ui = GraphicsUi::default();
|
||||
let size = (480.0, 4000.0);
|
||||
let (widgets, _, _) = ui.layout(Level::Advanced, size.0, size.1);
|
||||
let switch = widgets
|
||||
.iter()
|
||||
.find(|w| matches!(w.target, Target::Item(i) if i == item))
|
||||
.unwrap();
|
||||
let point = (switch.x + 1.0, switch.y + 1.0);
|
||||
for enabled in [false, true] {
|
||||
s.horizon.preset = Preset::Balanced;
|
||||
assert_eq!(
|
||||
ui.click(
|
||||
Level::Advanced,
|
||||
&mut s,
|
||||
point,
|
||||
size,
|
||||
[854, 480],
|
||||
false,
|
||||
false
|
||||
),
|
||||
vec![Action::ButtonClick, Action::Changed]
|
||||
);
|
||||
assert_eq!(item.switch(&s), Some(enabled));
|
||||
assert_eq!(s.horizon.preset, Preset::Custom);
|
||||
}
|
||||
assert_eq!(item.label(&s).plain(), "Cull Leaves: On");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_culling_is_opt_in_and_toggles_from_advanced_graphics() {
|
||||
let mut s = Settings::default();
|
||||
|
||||
@@ -298,6 +298,7 @@ impl PlayerMotion {
|
||||
blocking,
|
||||
blocked: 0.0,
|
||||
block_sound: false,
|
||||
knocked: false,
|
||||
totem: totem_ready,
|
||||
totem_fired: false,
|
||||
};
|
||||
@@ -313,6 +314,7 @@ impl PlayerMotion {
|
||||
hurt,
|
||||
blocked: environment.blocked,
|
||||
sound: environment.block_sound,
|
||||
knockback: environment.knocked,
|
||||
};
|
||||
if environment.totem_fired {
|
||||
h.totem_used = true;
|
||||
@@ -373,6 +375,8 @@ pub(crate) struct Environment<'a> {
|
||||
pub(crate) blocking: Option<Blocking>,
|
||||
pub(crate) blocked: f32,
|
||||
pub(crate) block_sound: bool,
|
||||
/// `dealDefaultKnockback` ran: a fresh hit, not fully blocked.
|
||||
pub(crate) knocked: bool,
|
||||
/// LivingEntity.checkTotemDeathProtection: a totem is held / was used.
|
||||
pub(crate) totem: bool,
|
||||
pub(crate) totem_fired: bool,
|
||||
@@ -435,6 +439,9 @@ pub struct BlockOutcome {
|
||||
pub blocked: f32,
|
||||
/// `BlocksAttacks.onBlocked` ran (the block sound plays).
|
||||
pub sound: bool,
|
||||
/// The hit knocks the player back (`dealDefaultKnockback`): not one in
|
||||
/// the hurt cooldown, nor one the shield took whole.
|
||||
pub knockback: bool,
|
||||
}
|
||||
struct MatchedSets;
|
||||
impl horizon_world::blocking_rules::TypeMatchWorld for MatchedSets {
|
||||
@@ -667,9 +674,11 @@ impl HurtWorld for Environment<'_> {
|
||||
*self.sync_velocity = true;
|
||||
Ok(())
|
||||
}
|
||||
/// The caller applies it: these sources carry no entity or position.
|
||||
fn knockback(&mut self, _: &mut State, _: f32, _: bool) -> io::Result<()> {
|
||||
self.knocked = true;
|
||||
Ok(())
|
||||
} // no entity or position on these sources
|
||||
}
|
||||
fn death_protection(&mut self, _: &mut State) -> io::Result<bool> {
|
||||
if !self.totem
|
||||
|| self
|
||||
@@ -761,6 +770,37 @@ impl ServerPlayerHurtWorld for Environment<'_> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn a_hit_in_the_hurt_cooldown_knocks_nobody_back() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let resources = crate::motion::MotionResources::load(&root, &assets).unwrap();
|
||||
let mut player =
|
||||
crate::motion::PlayerMotion::new(&resources, horizon_core::geometry::Vec3::ZERO)
|
||||
.unwrap();
|
||||
let (health, rules) = (&resources.health, super::HealthRules::default());
|
||||
let id = health.damage.id("minecraft:mob_attack").unwrap();
|
||||
let mut hit = |amount| {
|
||||
player
|
||||
.hurt_blocking(health, rules, id, amount, None)
|
||||
.unwrap()
|
||||
};
|
||||
let first = hit(1.0);
|
||||
assert!(first.hurt && first.knockback);
|
||||
// A stronger hit inside the cooldown hurts by the difference only
|
||||
// (LivingEntity.hurtServer), with no dealDefaultKnockback.
|
||||
let second = hit(2.0);
|
||||
assert!(second.hurt && !second.knockback);
|
||||
// A weaker one doesn't hurt at all.
|
||||
let third = hit(1.5);
|
||||
assert!(!third.hurt && !third.knockback);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hurt_sounds_follow_damage_effects() {
|
||||
// DamageEffects: HURT, THORNS, DROWNING, BURNING, POKING, FREEZING.
|
||||
|
||||
@@ -81,6 +81,12 @@ pub const MINIMAP_SPRITE: &str = "horizon/minimap";
|
||||
pub const SKIN_SLOTS: usize = 32;
|
||||
/// Reused by the visible rows of the classic world selector.
|
||||
pub const WORLD_ICON_SLOTS: usize = 32;
|
||||
/// Atlas slots for the pack icons on the pack screen, one per icon drawn
|
||||
/// (`PackSelectionScreen.loadPackIcon` registers a texture per pack): two lists
|
||||
/// of 36-pixel rows at GUI scale 2 on a 4K screen still fit.
|
||||
pub const PACK_ICON_SLOTS: usize = 64;
|
||||
/// A pack icon slot's side: vanilla's own icons are 128x128.
|
||||
pub const PACK_ICON_SIZE: u32 = 128;
|
||||
/// Atlas slots for armour trim textures made at run time (`trims`), each the
|
||||
/// size of the largest trim texture.
|
||||
pub const TRIM_SLOTS: usize = 32;
|
||||
@@ -252,7 +258,7 @@ impl HudAtlas {
|
||||
)
|
||||
}
|
||||
/// Empty slots written at run time: map textures (game/maps.rs), the
|
||||
/// minimap, downloaded skins, world icons and the on-screen keyboard's text.
|
||||
/// minimap, downloaded skins, world and pack icons and the on-screen keyboard's text.
|
||||
fn slots(images: &mut Vec<PackedImage>, trim: (u32, u32)) {
|
||||
for i in 0..MAP_SLOTS {
|
||||
images.push((format!("horizon/map{i}"), 128, 128, vec![0; 128 * 128 * 4]));
|
||||
@@ -284,6 +290,15 @@ impl HudAtlas {
|
||||
vec![0; 64 * 64 * 4],
|
||||
));
|
||||
}
|
||||
let side = PACK_ICON_SIZE as usize;
|
||||
for i in 0..PACK_ICON_SLOTS {
|
||||
images.push((
|
||||
format!("horizon/pack-icon{i}"),
|
||||
PACK_ICON_SIZE,
|
||||
PACK_ICON_SIZE,
|
||||
vec![0; side * side * 4],
|
||||
));
|
||||
}
|
||||
// The on-screen keyboard's text and corner masks (ui_theme), rasterized at run time.
|
||||
images.push((
|
||||
"horizon/ui-text".into(),
|
||||
|
||||
@@ -156,6 +156,7 @@ impl FoodWorld for TickWorld<'_> {
|
||||
blocking: None,
|
||||
blocked: 0.0,
|
||||
block_sound: false,
|
||||
knocked: false,
|
||||
totem: false,
|
||||
totem_fired: false,
|
||||
};
|
||||
|
||||
@@ -271,23 +271,15 @@ fn read_levels(t: Option<&Tag>) -> Levels {
|
||||
return Vec::new();
|
||||
};
|
||||
// Pre-1.21.5 worlds wrap the map in {levels: {...}}.
|
||||
if let Some(Tag::Compound(inner)) = map.get(&JavaString::from("levels")) {
|
||||
return inner
|
||||
.iter()
|
||||
.filter_map(|(k, v)| {
|
||||
Some((
|
||||
Arc::from(k.to_string_lossy().as_str()),
|
||||
int(v)?.clamp(1, 255) as u32,
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
let map = match map.get(&JavaString::from("levels")) {
|
||||
Some(Tag::Compound(inner)) => inner,
|
||||
_ => map,
|
||||
};
|
||||
// The keys are enchantment Identifiers: `knockback` is `minecraft:knockback`.
|
||||
map.iter()
|
||||
.filter_map(|(k, v)| {
|
||||
Some((
|
||||
Arc::from(k.to_string_lossy().as_str()),
|
||||
int(v)?.clamp(1, 255) as u32,
|
||||
))
|
||||
let id = horizon_world::datafix::dynamic::namespaced(&k.to_string_lossy());
|
||||
Some((Arc::from(id), int(v)?.clamp(1, 255) as u32))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -863,6 +855,22 @@ mod tests {
|
||||
assert_eq!(back.custom_name(), Some("Blade"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enchantment_ids_without_a_namespace_are_minecraft_ones() {
|
||||
let levels = Tag::compound([
|
||||
("knockback", Tag::Int(2)),
|
||||
("minecraft:sharpness", Tag::Int(1)),
|
||||
]);
|
||||
let components = Tag::compound([("minecraft:enchantments", levels)]);
|
||||
let (_, extra) = decode_components(Some(&components));
|
||||
let stack = ItemStack {
|
||||
extra,
|
||||
..ItemStack::new("minecraft:wooden_sword", 1)
|
||||
};
|
||||
assert_eq!(stack.enchantment_level("minecraft:knockback"), 2);
|
||||
assert_eq!(stack.enchantment_level("minecraft:sharpness"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_name_reads_the_extra_spans() {
|
||||
let name = Tag::compound([
|
||||
|
||||
@@ -1051,7 +1051,7 @@ impl LootContext<'_> {
|
||||
continue;
|
||||
}
|
||||
// LootPool: rolls.getInt (Mth.randomBetweenInclusive for uniform rolls).
|
||||
let rolls = ctx.int_number(&pool["rolls"], random).max(0) as u32;
|
||||
let rolls = LootContext::int_number(&pool["rolls"], random).max(0) as u32;
|
||||
for _ in 0..rolls {
|
||||
let mut produced = Vec::new();
|
||||
ctx.choose(
|
||||
@@ -1085,7 +1085,7 @@ impl LootContext<'_> {
|
||||
fn enchantment_level(&self, id: &str) -> u32 {
|
||||
self.held.map_or(0, |h| h.enchantment_level(id))
|
||||
}
|
||||
fn number<R: RandomSource>(&self, v: &Value, random: &mut R) -> f32 {
|
||||
fn number<R: RandomSource>(v: &Value, random: &mut R) -> f32 {
|
||||
match v {
|
||||
Value::Number(n) => n.as_f64().unwrap_or(0.0) as f32,
|
||||
Value::Object(o) => match o
|
||||
@@ -1097,8 +1097,8 @@ impl LootContext<'_> {
|
||||
o.get("value").and_then(Value::as_f64).unwrap_or(0.0) as f32
|
||||
}
|
||||
"minecraft:uniform" => {
|
||||
let min = self.number(o.get("min").unwrap_or(&Value::Null), random);
|
||||
let max = self.number(o.get("max").unwrap_or(&Value::Null), random);
|
||||
let min = Self::number(o.get("min").unwrap_or(&Value::Null), random);
|
||||
let max = Self::number(o.get("max").unwrap_or(&Value::Null), random);
|
||||
// UniformGenerator.getFloat / getInt (Mth.nextInt) differ; use int for counts.
|
||||
if max <= min {
|
||||
min
|
||||
@@ -1107,8 +1107,8 @@ impl LootContext<'_> {
|
||||
}
|
||||
}
|
||||
"minecraft:binomial" => {
|
||||
let n = self.number(o.get("n").unwrap_or(&Value::Null), random) as u32;
|
||||
let p = self.number(o.get("p").unwrap_or(&Value::Null), random);
|
||||
let n = Self::number(o.get("n").unwrap_or(&Value::Null), random) as u32;
|
||||
let p = Self::number(o.get("p").unwrap_or(&Value::Null), random);
|
||||
(0..n).filter(|_| random.next_float() < p).count() as f32
|
||||
}
|
||||
_ => 1.0,
|
||||
@@ -1116,22 +1116,22 @@ impl LootContext<'_> {
|
||||
_ => 1.0,
|
||||
}
|
||||
}
|
||||
fn int_number<R: RandomSource>(&self, v: &Value, random: &mut R) -> i32 {
|
||||
fn int_number<R: RandomSource>(v: &Value, random: &mut R) -> i32 {
|
||||
if let Value::Object(o) = v
|
||||
&& o.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("minecraft:uniform")
|
||||
== "minecraft:uniform"
|
||||
{
|
||||
let min = self.int_number(o.get("min").unwrap_or(&Value::Null), random);
|
||||
let max = self.int_number(o.get("max").unwrap_or(&Value::Null), random);
|
||||
let min = Self::int_number(o.get("min").unwrap_or(&Value::Null), random);
|
||||
let max = Self::int_number(o.get("max").unwrap_or(&Value::Null), random);
|
||||
return if max <= min {
|
||||
min
|
||||
} else {
|
||||
min + random.next_int_bound(max - min + 1).unwrap_or(0)
|
||||
};
|
||||
}
|
||||
self.number(v, random).round() as i32
|
||||
Self::number(v, random).round() as i32
|
||||
}
|
||||
fn condition<R: RandomSource>(&self, c: &Value, random: &mut R) -> bool {
|
||||
let (kind, c) = match c {
|
||||
@@ -1157,7 +1157,7 @@ impl LootContext<'_> {
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.all(|t| self.condition(t, random)),
|
||||
"minecraft:random_chance" => random.next_float() < self.number(&c["chance"], random),
|
||||
"minecraft:random_chance" => random.next_float() < Self::number(&c["chance"], random),
|
||||
"minecraft:table_bonus" => {
|
||||
// BonusLevelTableCondition: chances[min(level, len - 1)].
|
||||
let level =
|
||||
@@ -1378,7 +1378,7 @@ impl LootContext<'_> {
|
||||
}
|
||||
match f["type"].as_str().or(f["function"].as_str()).unwrap_or("") {
|
||||
"minecraft:set_count" => {
|
||||
let n = self.int_number(&f["count"], random);
|
||||
let n = Self::int_number(&f["count"], random);
|
||||
let base = if f["add"].as_bool() == Some(true) {
|
||||
stack.count as i32
|
||||
} else {
|
||||
@@ -1421,7 +1421,7 @@ impl LootContext<'_> {
|
||||
// Attacker's level x count, rounded (looting).
|
||||
let level = self.enchantment_level(f["enchantment"].as_str().unwrap_or(""));
|
||||
if level > 0 && self.killed_by_player && self.block.is_empty() {
|
||||
let add = level as f32 * self.number(&f["count"], random);
|
||||
let add = level as f32 * Self::number(&f["count"], random);
|
||||
stack.count = (stack.count as i32 + add.round() as i32).max(0) as u32;
|
||||
if let Some(limit) = f["limit"].as_i64().filter(|l| *l > 0) {
|
||||
stack.count = stack.count.min(limit as u32);
|
||||
|
||||
@@ -1,472 +0,0 @@
|
||||
//! Java (OptiFine/Iris) shader packs in `shaderpacks/` (docs/shader-packs.md):
|
||||
//! a folder holding `shaders/`, or a `.zip`. `horizon-shaderpack` translates
|
||||
//! their full-screen programs (`begin`, `prepare`, `deferred`, `composite`,
|
||||
//! `final`) to WGSL, and `gfx/java_stages.rs` runs them over Horizon's world
|
||||
//! as Iris runs them over its own. Of the geometry programs, the terrain's
|
||||
//! (`gbuffers_terrain`, `gbuffers_water` and their fallbacks) draw Horizon's
|
||||
//! terrain (`gfx/java_terrain.rs`); the rest of the world is Horizon's.
|
||||
use horizon_shaderpack::{
|
||||
JavaPack,
|
||||
pipeline::{Directives, GeometryProgram, Pass, Program, TargetSettings},
|
||||
preprocess::Environment,
|
||||
properties::{Blend, SkyDirectives},
|
||||
uniforms::CustomUniforms,
|
||||
};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
/// The passes of one program folder (`world0`, or `""` for the pack's root).
|
||||
pub struct JavaFolder {
|
||||
pub folder: String,
|
||||
pub targets: Vec<TargetSettings>,
|
||||
/// `begin*`, `prepare*`, `deferred*`, `composite*`, in order.
|
||||
pub passes: Vec<Program>,
|
||||
/// How many of `passes` run before the world (`begin*`, `prepare*`): the
|
||||
/// world's colour and depth enter `world_target` and `depthtex*` after them.
|
||||
pub world_at: usize,
|
||||
/// The colour target the world goes into, where the pack's terrain
|
||||
/// program draws (MakeUp: colortex1).
|
||||
pub world_target: u32,
|
||||
/// The targets whose alternate side the world is written to.
|
||||
pub flipped_after_prepare: BTreeSet<u32>,
|
||||
pub final_pass: Option<Program>,
|
||||
/// The targets whose alternate side holds the frame's last write.
|
||||
pub flipped_at_end: BTreeSet<u32>,
|
||||
/// `sunPathRotation`, `eyeBrightnessHalflife`, `wetnessHalflife` and
|
||||
/// `drynessHalflife`.
|
||||
pub directives: Directives,
|
||||
/// The programs Horizon's solid and translucent terrain are drawn with,
|
||||
/// each translated for instanced and flat draws (`variants[0]`).
|
||||
pub terrain: Option<GeometryProgram>,
|
||||
pub water: Option<GeometryProgram>,
|
||||
/// How many of `passes` run before translucent terrain (`begin*`,
|
||||
/// `prepare*`, `deferred*`).
|
||||
pub translucent_at: usize,
|
||||
pub flipped_after_translucent: BTreeSet<u32>,
|
||||
/// Each draw buffer's blending in `terrain` and `water`.
|
||||
pub terrain_blends: Vec<Option<wgpu::BlendState>>,
|
||||
pub water_blends: Vec<Option<wgpu::BlendState>>,
|
||||
/// The shadow map's programs over Horizon's solid and translucent
|
||||
/// terrain (instanced draws only), drawn after `begin*`, into
|
||||
/// `shadowcolor` targets set by `shadow_targets`.
|
||||
pub shadow: Option<GeometryProgram>,
|
||||
pub shadow_water: Option<GeometryProgram>,
|
||||
pub shadow_targets: Vec<TargetSettings>,
|
||||
pub shadow_blends: Vec<Option<wgpu::BlendState>>,
|
||||
pub shadow_water_blends: Vec<Option<wgpu::BlendState>>,
|
||||
/// How many of `passes` run before the shadow map (`begin*`).
|
||||
pub shadow_at: usize,
|
||||
}
|
||||
|
||||
/// The key of `texture.noise` (`noisetex`) in [`JavaPasses::custom_textures`].
|
||||
pub const NOISE_KEY: &str = "noise";
|
||||
|
||||
/// The key of `texture.<stage>.<sampler>` in [`JavaPasses::custom_textures`]
|
||||
/// for a pass of `program` (`world0/composite2`): Iris's texture stages, where
|
||||
/// `final` reads the `composite` ones.
|
||||
pub fn texture_key(program: &str, sampler: &str) -> String {
|
||||
let name = program.rsplit('/').next().unwrap_or(program);
|
||||
let stage = match name.trim_end_matches(|c: char| c.is_ascii_digit()) {
|
||||
"final" => "composite",
|
||||
stage => stage,
|
||||
};
|
||||
format!("{stage}.{sampler}")
|
||||
}
|
||||
|
||||
/// A loaded Java pack.
|
||||
pub struct JavaPasses {
|
||||
pub folders: Vec<JavaFolder>,
|
||||
/// `uniform.*` / `variable.*` from `shaders.properties`.
|
||||
pub uniforms: CustomUniforms,
|
||||
/// What the pack keeps of the vanilla sky (`clouds`, `sun`, `moon`).
|
||||
pub sky: SkyDirectives,
|
||||
/// `block.properties`: block name → `mc_Entity` id.
|
||||
pub block_ids: BTreeMap<String, i32>,
|
||||
/// `texture.<stage>.<sampler>` and `texture.noise` images, by
|
||||
/// `"<stage>.<sampler>"` and [`NOISE_KEY`].
|
||||
pub custom_textures: BTreeMap<String, crate::shader_pack::Rgba>,
|
||||
}
|
||||
|
||||
impl JavaPasses {
|
||||
/// The folder Iris uses in `dimension` (0 overworld, -1 the Nether, 1 the
|
||||
/// End): `world<N>` when the pack has one, else `world0`, else the root.
|
||||
pub fn folder_for(&self, dimension: i8) -> Option<usize> {
|
||||
let find = |name: &str| self.folders.iter().position(|f| f.folder == name);
|
||||
find(&format!("world{dimension}"))
|
||||
.or_else(|| find("world0"))
|
||||
.or_else(|| find(""))
|
||||
}
|
||||
}
|
||||
|
||||
/// A folder holding `shaders/`, or a zip.
|
||||
pub fn is_java_pack(path: &Path) -> bool {
|
||||
path.join("shaders").is_dir()
|
||||
|| path.is_file()
|
||||
&& path
|
||||
.extension()
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("zip"))
|
||||
}
|
||||
|
||||
/// The `MC_GL_VENDOR_*` name of a wgpu adapter (`AdapterInfo::vendor`, a
|
||||
/// PCI id; Apple's GPUs report 0).
|
||||
pub fn vendor_name(vendor: u32, adapter: &str) -> &'static str {
|
||||
match vendor {
|
||||
0x10de => "NVIDIA Corporation",
|
||||
0x1002 | 0x1022 => "AMD",
|
||||
0x8086 => "Intel",
|
||||
_ if adapter.starts_with("Apple") => "Apple",
|
||||
_ => "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the pack at `path` and translate its full-screen passes with the
|
||||
/// player's `values`; `vendor` and `renderer` name the GPU to the pack.
|
||||
pub fn load(
|
||||
path: &Path,
|
||||
values: &BTreeMap<String, String>,
|
||||
vendor: &str,
|
||||
renderer: &str,
|
||||
) -> Result<JavaPasses, String> {
|
||||
let pack = JavaPack::open(path)?;
|
||||
let values = pack.values(values);
|
||||
let env = Environment::iris(vendor, renderer, 4);
|
||||
let properties = pack.properties(&values, &env)?;
|
||||
let mut names: Vec<&str> = ["world0", "world-1", "world1"]
|
||||
.into_iter()
|
||||
.filter(|d| {
|
||||
pack.programs
|
||||
.iter()
|
||||
.any(|p| p.starts_with(&format!("{d}/")))
|
||||
})
|
||||
.collect();
|
||||
if pack.programs.iter().any(|p| !p.contains('/')) {
|
||||
names.push("");
|
||||
}
|
||||
let mut folders = Vec::new();
|
||||
for name in names {
|
||||
let plan = pack.passes(name, &values, &env, &properties)?;
|
||||
let compile = |pass| {
|
||||
let program = pack.compile(pass, &plan.targets, &values, &env, &properties)?;
|
||||
if let Some((image, _)) = program.fragment.interface.images.first() {
|
||||
return Err(format!(
|
||||
"{}: images ({image}) aren't supported yet",
|
||||
program.pass.program
|
||||
));
|
||||
}
|
||||
Ok::<_, String>(program)
|
||||
};
|
||||
let early = plan.begin.iter().chain(&plan.prepare);
|
||||
let passes = early
|
||||
.clone()
|
||||
.chain(&plan.deferred)
|
||||
.chain(&plan.composite)
|
||||
.map(compile)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let final_pass = plan.final_pass.as_ref().map(compile).transpose()?;
|
||||
if passes.is_empty() && final_pass.is_none() {
|
||||
continue;
|
||||
}
|
||||
// A geometry program that doesn't translate leaves that terrain to
|
||||
// Horizon, with a warning.
|
||||
let geometry_with = |pass: &Option<horizon_shaderpack::pipeline::Pass>,
|
||||
alpha_test,
|
||||
flat: &[bool]| {
|
||||
let pass = pass.as_ref()?;
|
||||
let glues: Vec<_> = flat
|
||||
.iter()
|
||||
.map(|&flat| crate::gfx::java_terrain::glue(flat, alpha_test))
|
||||
.collect();
|
||||
match pack.compile_geometry(pass, &plan.targets, &values, &env, &properties, &glues) {
|
||||
Ok(program) => Some(program),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: shader pack {}: {e}", pass.program);
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
let geometry = |pass: &Option<horizon_shaderpack::pipeline::Pass>, alpha_test| {
|
||||
geometry_with(pass, alpha_test, &[false, true])
|
||||
};
|
||||
// Iris's alpha test of cutout terrain (`AlphaTests.ONE_TENTH_ALPHA`).
|
||||
let terrain = geometry(&plan.terrain, Some(0.1));
|
||||
let water = terrain.as_ref().and_then(|_| geometry(&plan.water, None));
|
||||
// The shadow map draws only with the pack's terrain, and only from
|
||||
// instanced draws (the flat draws hold what the camera sees).
|
||||
let shadow = terrain
|
||||
.as_ref()
|
||||
.and_then(|_| geometry_with(&plan.shadow, Some(0.1), &[false]));
|
||||
let shadow_water = shadow
|
||||
.as_ref()
|
||||
.and_then(|_| geometry_with(&plan.shadow_water, None, &[false]));
|
||||
let translucent_at = early.clone().count() + plan.deferred.len();
|
||||
let blends = |g: &Option<GeometryProgram>, default| {
|
||||
g.as_ref().map_or_else(Vec::new, |g| {
|
||||
pass_blends(&g.program.pass, &g.program.draw_buffers, default)
|
||||
})
|
||||
};
|
||||
let terrain_blends = blends(&terrain, None);
|
||||
// Iris's shadow programs don't blend (`BlendModeOverride.OFF`).
|
||||
let shadow_blends = blends(&shadow, None);
|
||||
let shadow_water_blends = blends(&shadow_water, None);
|
||||
// Vanilla's translucent blending (`BlendFunction.TRANSLUCENT`).
|
||||
let water_blends = blends(
|
||||
&water,
|
||||
Some(wgpu::BlendState {
|
||||
color: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::SrcAlpha,
|
||||
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
alpha: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::One,
|
||||
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
}),
|
||||
);
|
||||
folders.push(JavaFolder {
|
||||
folder: name.to_owned(),
|
||||
targets: plan.targets,
|
||||
world_at: early.count(),
|
||||
world_target: plan.world_target,
|
||||
passes,
|
||||
flipped_after_prepare: plan.flipped_after_prepare,
|
||||
final_pass,
|
||||
flipped_at_end: plan.flipped_at_end,
|
||||
directives: plan.directives,
|
||||
terrain,
|
||||
water,
|
||||
translucent_at,
|
||||
flipped_after_translucent: plan.flipped_after_translucent,
|
||||
terrain_blends,
|
||||
water_blends,
|
||||
shadow,
|
||||
shadow_water,
|
||||
shadow_targets: plan.shadow_targets,
|
||||
shadow_blends,
|
||||
shadow_water_blends,
|
||||
shadow_at: plan.begin.len(),
|
||||
});
|
||||
}
|
||||
if folders.is_empty() {
|
||||
return Err("no deferred, composite or final programs".into());
|
||||
}
|
||||
let mut custom_textures = BTreeMap::new();
|
||||
for tex in properties.custom_textures() {
|
||||
let key = match tex.stage {
|
||||
Some(stage) => format!("{stage}.{}", tex.sampler),
|
||||
None if tex.sampler == NOISE_KEY => NOISE_KEY.to_owned(),
|
||||
None => continue,
|
||||
};
|
||||
// Only PNGs for now: resource locations and raw `.dat` textures are
|
||||
// skipped and the sampler reads what it would without them.
|
||||
let image = pack
|
||||
.files
|
||||
.read_bytes(&tex.path)
|
||||
.ok_or_else(|| "not in the pack".to_owned())
|
||||
.and_then(|bytes| crate::shader_pack::decode_png(&bytes));
|
||||
match image {
|
||||
Ok(image) => {
|
||||
custom_textures.insert(key, image);
|
||||
}
|
||||
Err(e) => log::warn!("Horizon: shader pack texture {}: {e}", tex.path),
|
||||
}
|
||||
}
|
||||
let uniforms = properties.custom_uniforms();
|
||||
for problem in &uniforms.problems {
|
||||
log::warn!("Horizon: shader pack custom uniform {problem}");
|
||||
}
|
||||
let block_ids = pack.block_ids(&values, &env).unwrap_or_else(|e| {
|
||||
log::warn!("Horizon: shader pack {e}");
|
||||
BTreeMap::new()
|
||||
});
|
||||
Ok(JavaPasses {
|
||||
folders,
|
||||
uniforms,
|
||||
sky: properties.sky(),
|
||||
custom_textures,
|
||||
block_ids,
|
||||
})
|
||||
}
|
||||
|
||||
/// Each draw buffer's blending, as Iris's `BlendModeOverride`:
|
||||
/// `blend.<program>.<buffer>`, else `blend.<program>`, else `default`
|
||||
/// (none for full-screen passes, vanilla's for the geometry programs).
|
||||
pub(crate) fn pass_blends(
|
||||
pass: &Pass,
|
||||
draw_buffers: &[u32],
|
||||
default: Option<wgpu::BlendState>,
|
||||
) -> Vec<Option<wgpu::BlendState>> {
|
||||
let all = pass.blend.as_ref().map_or(default, blend_state);
|
||||
draw_buffers
|
||||
.iter()
|
||||
.map(|i| {
|
||||
pass.buffer_blends
|
||||
.iter()
|
||||
.find(|(b, _)| b == i)
|
||||
.map_or(all, |(_, b)| blend_state(b))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A `blend.*` value as wgpu's blending (`off`: none). The factors are the
|
||||
/// GL ones `ShaderProperties` accepts.
|
||||
pub(crate) fn blend_state(blend: &Blend) -> Option<wgpu::BlendState> {
|
||||
let Blend::Factors(factors) = blend else {
|
||||
return None;
|
||||
};
|
||||
let factor = |name: &str| {
|
||||
use wgpu::BlendFactor as F;
|
||||
match name {
|
||||
"ZERO" => F::Zero,
|
||||
"SRC_COLOR" => F::Src,
|
||||
"ONE_MINUS_SRC_COLOR" => F::OneMinusSrc,
|
||||
"DST_COLOR" => F::Dst,
|
||||
"ONE_MINUS_DST_COLOR" => F::OneMinusDst,
|
||||
"SRC_ALPHA" => F::SrcAlpha,
|
||||
"ONE_MINUS_SRC_ALPHA" => F::OneMinusSrcAlpha,
|
||||
"DST_ALPHA" => F::DstAlpha,
|
||||
"ONE_MINUS_DST_ALPHA" => F::OneMinusDstAlpha,
|
||||
"SRC_ALPHA_SATURATE" => F::SrcAlphaSaturated,
|
||||
_ => F::One,
|
||||
}
|
||||
};
|
||||
let component = |src: &str, dst: &str| wgpu::BlendComponent {
|
||||
src_factor: factor(src),
|
||||
dst_factor: factor(dst),
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
};
|
||||
Some(wgpu::BlendState {
|
||||
color: component(&factors[0], &factors[1]),
|
||||
alpha: component(&factors[2], &factors[3]),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn custom_textures_belong_to_iris_s_stages() {
|
||||
assert_eq!(texture_key("world0/composite2", "gaux1"), "composite.gaux1");
|
||||
assert_eq!(texture_key("final", "colortex4"), "composite.colortex4");
|
||||
assert_eq!(
|
||||
texture_key("world-1/deferred10", "noisetex"),
|
||||
"deferred.noisetex"
|
||||
);
|
||||
assert_eq!(texture_key("prepare", "depthtex2"), "prepare.depthtex2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_the_full_screen_passes_of_a_java_pack() {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../horizon-shaderpack/tests/fixtures/passes");
|
||||
assert!(is_java_pack(&dir));
|
||||
let pack = load(&dir, &BTreeMap::new(), "Apple", "Apple M4 Pro").unwrap();
|
||||
let [folder] = &pack.folders[..] else {
|
||||
panic!("one folder, the root");
|
||||
};
|
||||
assert_eq!(folder.folder, "");
|
||||
assert_eq!(
|
||||
folder
|
||||
.passes
|
||||
.iter()
|
||||
.map(|p| p.pass.program.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["deferred", "composite", "composite1"]
|
||||
);
|
||||
assert_eq!(folder.world_at, 0);
|
||||
assert!(folder.final_pass.is_some());
|
||||
// The overworld, the Nether and the End all use the root.
|
||||
assert_eq!([-1, 0, 1].map(|d| pack.folder_for(d)), [Some(0); 3]);
|
||||
assert_eq!(
|
||||
vendor_name(0x10de, "NVIDIA GeForce RTX 4080"),
|
||||
"NVIDIA Corporation"
|
||||
);
|
||||
assert_eq!(vendor_name(0, "Apple M4 Pro"), "Apple");
|
||||
}
|
||||
|
||||
/// wgpu is given the WGSL text: it must parse again.
|
||||
fn reparse_error(pack: &JavaPasses) -> Option<String> {
|
||||
let passes = pack
|
||||
.folders
|
||||
.iter()
|
||||
.flat_map(|f| f.passes.iter().chain(&f.final_pass));
|
||||
let geometry = pack.folders.iter().flat_map(|f| {
|
||||
f.terrain
|
||||
.iter()
|
||||
.chain(&f.water)
|
||||
.chain(&f.shadow)
|
||||
.chain(&f.shadow_water)
|
||||
});
|
||||
passes
|
||||
.flat_map(|p| [(p, &p.vertex, "vsh"), (p, &p.fragment, "fsh")])
|
||||
.chain(geometry.flat_map(|g| {
|
||||
let p = &g.program;
|
||||
[(p, &p.vertex, "vsh"), (p, &p.fragment, "fsh")]
|
||||
.into_iter()
|
||||
.chain(g.variants.iter().map(move |v| (p, v, "vsh (flat)")))
|
||||
}))
|
||||
.find_map(|(p, t, ext)| {
|
||||
let e = naga::front::wgsl::parse_str(&t.wgsl).err()?;
|
||||
Some(format!(
|
||||
"{}.{ext}: {}",
|
||||
p.pass.program,
|
||||
e.emit_to_string(&t.wgsl)
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// The packs in `HORIZON_TEST_SHADERPACKS` (folders or zips) load as the
|
||||
/// game loads them, with their default options: the compatibility table
|
||||
/// of docs/shader-packs.md. Skipped when the variable isn't set.
|
||||
#[test]
|
||||
fn real_packs_load() {
|
||||
let Some(dir) = std::env::var_os("HORIZON_TEST_SHADERPACKS") else {
|
||||
return;
|
||||
};
|
||||
let mut entries: Vec<_> = std::fs::read_dir(dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| is_java_pack(p))
|
||||
.collect();
|
||||
entries.sort();
|
||||
let mut failures = Vec::new();
|
||||
for path in entries {
|
||||
let name = path.file_name().unwrap().to_string_lossy().into_owned();
|
||||
match load(&path, &BTreeMap::new(), "Apple", "Apple M4 Pro") {
|
||||
Ok(pack) if let Some(e) = reparse_error(&pack) => {
|
||||
println!("{name}: {e}");
|
||||
failures.push(name);
|
||||
}
|
||||
Ok(pack) => {
|
||||
let folders: Vec<String> = pack
|
||||
.folders
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let n = f.passes.len() + usize::from(f.final_pass.is_some());
|
||||
let geometry: Vec<&str> = f
|
||||
.terrain
|
||||
.iter()
|
||||
.chain(&f.water)
|
||||
.chain(&f.shadow)
|
||||
.chain(&f.shadow_water)
|
||||
.map(|g| g.program.pass.program.as_str())
|
||||
.collect();
|
||||
format!("{} {n} passes, terrain {geometry:?}", f.folder)
|
||||
})
|
||||
.collect();
|
||||
println!("{name}: loads ({})", folders.join(", "));
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{name}: {e}");
|
||||
failures.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{failures:?}");
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,88 @@ fn component_value(tag: &horizon_world::nbt::Tag) -> crate::server::json::Json {
|
||||
}
|
||||
}
|
||||
|
||||
/// The inverse of [`component_value`]: a JSON component (a server.properties
|
||||
/// prompt) as ComponentSerialization.CODEC writes it with NbtOps. An array is
|
||||
/// its first component with the rest appended (`createFromList`); a list of
|
||||
/// components mixing plain strings and objects has the strings as `{text}`.
|
||||
pub fn component_tag(v: &crate::server::json::Json) -> horizon_world::nbt::Tag {
|
||||
use crate::server::json::Json;
|
||||
use horizon_world::nbt::{JavaString, Tag};
|
||||
fn list(items: &[Json]) -> Tag {
|
||||
same_kind(items.iter().map(component_tag).collect())
|
||||
}
|
||||
fn same_kind(mut tags: Vec<Tag>) -> Tag {
|
||||
if tags.iter().any(|t| matches!(t, Tag::Compound(_))) {
|
||||
for t in &mut tags {
|
||||
if let Tag::String(s) = t {
|
||||
*t = Tag::compound([("text", Tag::String(s.clone()))]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Tag::List(tags)
|
||||
}
|
||||
fn value(v: &Json) -> Tag {
|
||||
match v {
|
||||
Json::Bool(b) => Tag::Byte(i8::from(*b)),
|
||||
Json::Number(n) => n.parse().map_or_else(
|
||||
|_| Tag::Double(n.parse::<f64>().unwrap_or(0.0).to_bits()),
|
||||
Tag::Int,
|
||||
),
|
||||
Json::String(s) => Tag::String(JavaString::from(s.as_str())),
|
||||
Json::Array(items) => list(items),
|
||||
Json::Object(members) => Tag::compound(
|
||||
members
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), value(v)))
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
Json::Null => Tag::String(JavaString::from("")),
|
||||
}
|
||||
}
|
||||
match v {
|
||||
Json::Array(items) if !items.is_empty() => {
|
||||
let mut first = component_tag(&items[0]);
|
||||
if let Tag::String(s) = &first {
|
||||
first = Tag::compound([("text", Tag::String(s.clone()))]);
|
||||
}
|
||||
if let Tag::Compound(map) = &mut first {
|
||||
let mut extra = match map.remove(&JavaString::from("extra")) {
|
||||
Some(Tag::List(e)) => e,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
extra.extend(items[1..].iter().map(component_tag));
|
||||
map.insert(JavaString::from("extra"), same_kind(extra));
|
||||
}
|
||||
first
|
||||
}
|
||||
Json::Object(members) => {
|
||||
if let [(key, Json::String(text))] = members.as_slice()
|
||||
&& key == "text"
|
||||
{
|
||||
return Tag::String(JavaString::from(text.as_str()));
|
||||
}
|
||||
Tag::compound(
|
||||
members
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
let tag = match k.as_str() {
|
||||
"extra" | "with" => match v {
|
||||
Json::Array(items) => list(items),
|
||||
other => value(other),
|
||||
},
|
||||
_ => value(v),
|
||||
};
|
||||
(k.as_str(), tag)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
Json::String(s) | Json::Number(s) => Tag::String(JavaString::from(s.as_str())),
|
||||
Json::Bool(b) => Tag::String(JavaString::from(b.to_string().as_str())),
|
||||
_ => Tag::String(JavaString::from("")),
|
||||
}
|
||||
}
|
||||
|
||||
/// StringUtil.isValidPlayerName.
|
||||
pub fn valid_name(name: &str) -> bool {
|
||||
(1..=16).contains(&name.len()) && name.chars().all(|c| c as u32 > 32 && (c as u32) < 127)
|
||||
@@ -745,6 +827,14 @@ fn login_and_configure(
|
||||
send(c, cfg::Clientbound::RegistryData(packet))?;
|
||||
}
|
||||
send(c, cfg::Clientbound::UpdateTags(sync.tags.clone()))?;
|
||||
if let Some(pack) = &shared.config.resource_pack
|
||||
&& let Some(rejected) = offer_pack(c, pack, &mut info)?
|
||||
{
|
||||
let (reason, text) = pack_rejected(shared, name, rejected);
|
||||
let _ = send(c, cfg::Clientbound::Disconnect(cfg::Disconnect { reason }));
|
||||
lost(shared, &format!("{name} ({})", uuid_string(uuid)), &text);
|
||||
return Ok(None);
|
||||
}
|
||||
send(c, cfg::Clientbound::FinishConfiguration(Empty))?;
|
||||
loop {
|
||||
let (id, payload) = c.recv().map_err(e)?;
|
||||
@@ -763,6 +853,79 @@ fn login_and_configure(
|
||||
}))
|
||||
}
|
||||
|
||||
/// ServerboundResourcePackPacket.Action.DECLINED.
|
||||
const PACK_DECLINED: i32 = 1;
|
||||
|
||||
/// ServerCommonPacketListenerImpl.handleResourcePackResponse: a player who
|
||||
/// declined the required pack is disconnected. The reason and its text.
|
||||
fn pack_rejected(shared: &Shared, name: &str, pack: u128) -> (horizon_world::nbt::Tag, String) {
|
||||
let reason = translatable("multiplayer.requiredTexturePrompt.disconnect", &[]);
|
||||
let Some(log) = &shared.config.log else {
|
||||
return (reason, String::new());
|
||||
};
|
||||
let line = format!(
|
||||
"Disconnecting {name} due to resource pack {} rejection",
|
||||
uuid_string(pack)
|
||||
);
|
||||
(log.write)("Server thread", "INFO", &line);
|
||||
let text = horizon_protocol::text::plain(&reason, &log.lang);
|
||||
(reason, text)
|
||||
}
|
||||
|
||||
/// ServerResourcePackConfigurationTask: offer the server's pack, then wait
|
||||
/// for the client's final answer (`Action.isTerminal`: anything but
|
||||
/// ACCEPTED or DOWNLOADED). The player may take a while to answer the
|
||||
/// prompt, so the connection is kept alive meanwhile, as
|
||||
/// ServerConfigurationPacketListenerImpl.tick does. The id of a required
|
||||
/// pack the client declined, which disconnects it.
|
||||
fn offer_pack(
|
||||
c: &mut Connection,
|
||||
pack: &crate::server::properties::ResourcePackInfo,
|
||||
info: &mut ClientInformation,
|
||||
) -> Result<Option<u128>, String> {
|
||||
let e = |e: horizon_protocol::Error| e.to_string();
|
||||
let push = cfg::ResourcePackPush {
|
||||
id: pack.id,
|
||||
url: pack.url.clone(),
|
||||
hash: pack.hash.clone(),
|
||||
required: pack.required,
|
||||
prompt: pack.prompt.as_ref().map(component_tag),
|
||||
};
|
||||
c.send(&cfg::Clientbound::ResourcePackPush(push).encode())
|
||||
.map_err(e)?;
|
||||
std::thread::scope(|scope| {
|
||||
let (stop, stopped) = channel::<()>();
|
||||
let writer = &mut c.writer;
|
||||
scope.spawn(move || {
|
||||
let start = Instant::now();
|
||||
while stopped.recv_timeout(KEEP_ALIVE_EVERY) == Err(RecvTimeoutError::Timeout) {
|
||||
let id = i64::try_from(start.elapsed().as_millis()).unwrap_or(0);
|
||||
let keep_alive = cfg::Clientbound::KeepAlive(cfg::KeepAlive { id });
|
||||
if writer.send(&keep_alive.encode()).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let answer = loop {
|
||||
let (id, payload) = match c.reader.recv() {
|
||||
Ok(packet) => packet,
|
||||
Err(err) => break Err(err.to_string()),
|
||||
};
|
||||
match cfg::Serverbound::decode(id, &payload) {
|
||||
Ok(cfg::Serverbound::ResourcePack(r)) if !matches!(r.action, 3 | 4) => {
|
||||
let rejected = r.action == PACK_DECLINED && pack.required;
|
||||
break Ok(rejected.then_some(r.id));
|
||||
}
|
||||
Ok(cfg::Serverbound::ClientInformation(i)) => *info = i,
|
||||
Ok(_) => {}
|
||||
Err(err) => break Err(err.to_string()),
|
||||
}
|
||||
};
|
||||
drop(stop);
|
||||
answer
|
||||
})
|
||||
}
|
||||
|
||||
fn play(c: Connection, shared: &Arc<Shared>, parts: Parts, address: std::net::SocketAddr) {
|
||||
let conn_id = shared.next_id();
|
||||
let events = shared.events.clone();
|
||||
@@ -784,6 +947,7 @@ fn play(c: Connection, shared: &Arc<Shared>, parts: Parts, address: std::net::So
|
||||
})
|
||||
.ok()
|
||||
};
|
||||
let name = parts.name.clone();
|
||||
let joined = Joined {
|
||||
conn: conn_id,
|
||||
uuid: parts.uuid,
|
||||
@@ -795,8 +959,20 @@ fn play(c: Connection, shared: &Arc<Shared>, parts: Parts, address: std::net::So
|
||||
latency,
|
||||
address,
|
||||
};
|
||||
let required_pack = shared
|
||||
.config
|
||||
.resource_pack
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.required);
|
||||
let reason = if events.send(NetEvent::Joined(Box::new(joined))).is_ok() {
|
||||
read_loop(&mut reader, conn_id, &events, &alive)
|
||||
match read_loop(&mut reader, conn_id, &events, &alive, required_pack) {
|
||||
Ok(reason) => reason,
|
||||
Err(rejected) => {
|
||||
let (reason, text) = pack_rejected(shared, &name, rejected);
|
||||
let _ = tx.send(Out::Disconnect(reason));
|
||||
text
|
||||
}
|
||||
}
|
||||
} else {
|
||||
"server closed".to_owned()
|
||||
};
|
||||
@@ -808,12 +984,16 @@ fn play(c: Connection, shared: &Arc<Shared>, parts: Parts, address: std::net::So
|
||||
shared.release(parts.uuid);
|
||||
}
|
||||
|
||||
/// Forward the player's packets until the connection ends: why it did, or
|
||||
/// the id of a required resource pack the player declined (with
|
||||
/// `required_pack`), which disconnects them.
|
||||
fn read_loop(
|
||||
reader: &mut ConnReader,
|
||||
conn: u32,
|
||||
events: &Sender<NetEvent>,
|
||||
alive: &AtomicI64,
|
||||
) -> String {
|
||||
required_pack: bool,
|
||||
) -> Result<String, u128> {
|
||||
loop {
|
||||
let (id, payload) = match reader.recv() {
|
||||
Ok(p) => p,
|
||||
@@ -824,19 +1004,24 @@ fn read_loop(
|
||||
|| e.0.contains("reset by peer")
|
||||
|| e.0.contains("aborted") =>
|
||||
{
|
||||
return "Disconnected".to_owned();
|
||||
return Ok("Disconnected".to_owned());
|
||||
}
|
||||
Err(e) => return e.to_string(),
|
||||
Err(e) => return Ok(e.to_string()),
|
||||
};
|
||||
match play::Serverbound::decode(id, &payload) {
|
||||
Ok(play::Serverbound::KeepAlive(k)) => alive.store(k.id, Ordering::Relaxed),
|
||||
Ok(play::Serverbound::ResourcePack(r))
|
||||
if r.action == PACK_DECLINED && required_pack =>
|
||||
{
|
||||
return Err(r.id);
|
||||
}
|
||||
Ok(p) => {
|
||||
if events.send(NetEvent::Packet(conn, Box::new(p))).is_err() {
|
||||
return "server closed".to_owned();
|
||||
return Ok("server closed".to_owned());
|
||||
}
|
||||
}
|
||||
// A malformed packet disconnects like vanilla's decoder exception.
|
||||
Err(e) => return format!("Invalid packet {id}: {e}"),
|
||||
Err(e) => return Ok(format!("Invalid packet {id}: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -988,6 +1173,24 @@ fn write_loop(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prompts_become_the_components_vanilla_sends() {
|
||||
let tag = |json: &str| {
|
||||
let tag = component_tag(&crate::server::json::parse(json, true).unwrap());
|
||||
component_json(&tag)
|
||||
};
|
||||
assert_eq!(tag(r#"{"text":"Hi"}"#), r#""Hi""#);
|
||||
assert_eq!(tag(r#""Hi""#), r#""Hi""#);
|
||||
assert_eq!(
|
||||
tag(r#"["Hi ",{"text":"there","bold":true}]"#),
|
||||
r#"{"text":"Hi ","extra":[{"text":"there","bold":true}]}"#
|
||||
);
|
||||
assert_eq!(
|
||||
tag(r#"{"translate":"a","with":["b",{"text":"c","color":"red"}]}"#),
|
||||
r#"{"translate":"a","with":["b",{"text":"c","color":"red"}]}"#
|
||||
);
|
||||
}
|
||||
|
||||
fn unpack(out: Out) -> Vec<Vec<u8>> {
|
||||
match out {
|
||||
Out::Packet(body) => vec![body],
|
||||
|
||||
@@ -206,6 +206,9 @@ pub struct Guest {
|
||||
/// (Entity.DATA_AIR_SUPPLY_ID, Player.DATA_PLAYER_ABSORPTION_ID); their
|
||||
/// defaults until either changes.
|
||||
pub sent_air: (i32, f32),
|
||||
/// The shared flags (Entity.DATA_SHARED_FLAGS_ID) the player's client was
|
||||
/// last sent about itself.
|
||||
pub sent_shared: i8,
|
||||
/// Where the player last died (Player.lastDeathLocation), sent with the respawn.
|
||||
pub last_death: Option<[i32; 3]>,
|
||||
/// The last action sequence to acknowledge at the next tick (-1: none).
|
||||
@@ -336,6 +339,7 @@ impl Guest {
|
||||
sent_health: None,
|
||||
sent_xp: None,
|
||||
sent_air: (300, 0.0),
|
||||
sent_shared: 0,
|
||||
last_death: None,
|
||||
ack_up_to: std::cell::Cell::new(-1),
|
||||
sent_selected: 0,
|
||||
|
||||
@@ -1477,16 +1477,7 @@ impl Hub {
|
||||
attributes: Vec<play::AttributeSnapshot>,
|
||||
) -> EntityState {
|
||||
let (sneaking, sprinting, gliding, sleeping) = flags;
|
||||
let mut shared = 0i8;
|
||||
if sneaking {
|
||||
shared |= 0x02;
|
||||
}
|
||||
if sprinting {
|
||||
shared |= 0x08;
|
||||
}
|
||||
if gliding {
|
||||
shared |= -128;
|
||||
}
|
||||
let shared = shared_flags(sneaking, sprinting, gliding);
|
||||
let pose = if sleeping {
|
||||
2
|
||||
} else if gliding {
|
||||
@@ -1764,6 +1755,18 @@ impl Hub {
|
||||
}));
|
||||
}
|
||||
}
|
||||
// ServerEntity.sendDirtyEntityData: the shared flags go to the player
|
||||
// too, so the sprint a hit stops (Player.causeExtraKnockback) stops
|
||||
// on its client.
|
||||
let g = &mut self.guests[i];
|
||||
let shared = shared_flags(g.sneaking, g.sprinting, g.fall_flying);
|
||||
if shared != g.sent_shared {
|
||||
g.sent_shared = shared;
|
||||
g.send(&C::SetEntityData(play::SetEntityData {
|
||||
id: g.entity_id,
|
||||
values: vec![(0, serializer::BYTE, Value::Byte(shared))],
|
||||
}));
|
||||
}
|
||||
// Sent when the health or the food level changes, or the saturation
|
||||
// reaches or leaves zero; a dead player's health of 0 too.
|
||||
let g = &mut self.guests[i];
|
||||
@@ -2480,6 +2483,7 @@ impl Hub {
|
||||
g.sent_health = None;
|
||||
g.sent_xp = None;
|
||||
g.sent_air = (300, 0.0);
|
||||
g.sent_shared = 0;
|
||||
let (cx, cz) = g.chunk();
|
||||
g.send(&C::SetChunkCacheCenter(play::SetChunkCacheCenter {
|
||||
x: cx,
|
||||
@@ -2519,6 +2523,21 @@ fn ticking_state(rate: f32, frozen: bool) -> C {
|
||||
raw(ids::TICKING_STATE, w.data)
|
||||
}
|
||||
|
||||
/// Entity.DATA_SHARED_FLAGS_ID of a player: crouching, sprinting, gliding.
|
||||
fn shared_flags(sneaking: bool, sprinting: bool, gliding: bool) -> i8 {
|
||||
let mut shared = 0i8;
|
||||
if sneaking {
|
||||
shared |= 0x02;
|
||||
}
|
||||
if sprinting {
|
||||
shared |= 0x08;
|
||||
}
|
||||
if gliding {
|
||||
shared |= -128;
|
||||
}
|
||||
shared
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -139,6 +139,9 @@ pub struct NetConfig {
|
||||
/// Online mode: players are authenticated (none on a LAN world).
|
||||
pub online: Option<Arc<OnlineMode>>,
|
||||
pub log: Option<NetLog>,
|
||||
/// server.properties `resource-pack`: offered to every player during
|
||||
/// configuration (a dedicated server's; MinecraftServer.getServerResourcePack).
|
||||
pub resource_pack: Option<crate::server::properties::ResourcePackInfo>,
|
||||
}
|
||||
impl NetConfig {
|
||||
/// MinecraftServer.enforceSecureProfile.
|
||||
@@ -164,6 +167,7 @@ impl NetConfig {
|
||||
gate: None,
|
||||
online: None,
|
||||
log: None,
|
||||
resource_pack: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ pub mod command;
|
||||
pub mod command_block;
|
||||
pub mod command_storage;
|
||||
pub mod component;
|
||||
pub mod confirm_screen;
|
||||
pub mod container_screen;
|
||||
pub mod containers;
|
||||
pub mod control;
|
||||
@@ -95,7 +96,6 @@ pub mod item_data;
|
||||
pub mod item_render;
|
||||
pub mod item_tooltip;
|
||||
pub mod items;
|
||||
pub mod java_pack;
|
||||
pub mod java_text;
|
||||
pub mod key_names;
|
||||
pub mod keybinds;
|
||||
@@ -158,8 +158,8 @@ pub mod screen_effects;
|
||||
pub mod screen_layout;
|
||||
pub mod sculk;
|
||||
pub use horizon_server_files as server;
|
||||
pub mod server_packs;
|
||||
pub mod settings;
|
||||
pub mod shader_pack;
|
||||
pub mod shadows;
|
||||
pub mod signs;
|
||||
pub mod skins;
|
||||
|
||||
@@ -230,8 +230,6 @@ fn visibility(pad: &Padded, info: &[Info]) -> u64 {
|
||||
result
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod leaf_culling_tests;
|
||||
#[cfg(test)]
|
||||
mod particle_tests;
|
||||
#[cfg(test)]
|
||||
mod visibility_tests;
|
||||
@@ -277,7 +275,6 @@ struct Info {
|
||||
overlay: bool,
|
||||
/// `LeavesBlock`: with opaque leaves the faces between two leaf blocks are skipped.
|
||||
leaves: bool,
|
||||
mangrove_roots: bool,
|
||||
blocks_flow: bool,
|
||||
ice: bool,
|
||||
sturdy: u8,
|
||||
@@ -440,7 +437,6 @@ impl MeshTables {
|
||||
legacy_solid: s.legacy_solid,
|
||||
overlay: s.has_class("HalfTransparentBlock") || s.has_class("LeavesBlock"),
|
||||
leaves: s.has_class("LeavesBlock"),
|
||||
mangrove_roots: s.has_class("MangroveRootsBlock"),
|
||||
blocks_flow: assets.blocks_fluid_flow.contains(&*s.name),
|
||||
ice: s.has_class("IceBlock"),
|
||||
sturdy: s.sturdy_full,
|
||||
@@ -692,8 +688,6 @@ pub struct Mesher<'a> {
|
||||
pub smooth_lighting: bool,
|
||||
/// The `cutoutLeaves` option (fancy graphics); off makes leaves opaque.
|
||||
pub cutout_leaves: bool,
|
||||
/// Cull shared leaf and mangrove-root faces, including transparent leaves.
|
||||
pub cull_leaves: bool,
|
||||
}
|
||||
impl Mesher<'_> {
|
||||
/// `region[dz][dx]` are the chunks around the meshed column (centre 1,1).
|
||||
@@ -773,12 +767,9 @@ impl Mesher<'_> {
|
||||
let [dx, dy, dz] = d.normal();
|
||||
let neighbor = pad.blocks[pi(x + dx, y + dy, z + dz)];
|
||||
let other = &info[neighbor as usize];
|
||||
// Adapted from CullLeaves (MIT); see THIRD_PARTY_NOTICES.md.
|
||||
let shared_foliage = (state.leaves
|
||||
&& other.leaves
|
||||
&& (!self.cutout_leaves || self.cull_leaves))
|
||||
|| (self.cull_leaves && state.mangrove_roots && other.mangrove_roots);
|
||||
visible[d as usize] = !shared_foliage
|
||||
// LeavesBlock.skipRendering with opaque leaves.
|
||||
let opaque_leaves = !self.cutout_leaves && state.leaves && other.leaves;
|
||||
visible[d as usize] = !opaque_leaves
|
||||
&& self
|
||||
.visibility
|
||||
.should_render_face(
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
use super::*;
|
||||
use horizon_assets::model::Direction as ModelDirection;
|
||||
|
||||
#[test]
|
||||
fn leaf_culling_removes_only_internal_leaf_and_root_faces() {
|
||||
let root =
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
|
||||
if !root.join("horizon-resources.json").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
|
||||
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
|
||||
let biomes = Biomes::load(&root).unwrap();
|
||||
let state = |name| assets.registry.default_state(name).unwrap().0;
|
||||
let oak = state("minecraft:oak_leaves");
|
||||
let birch = state("minecraft:birch_leaves");
|
||||
let roots = state("minecraft:mangrove_roots");
|
||||
let stone = state("minecraft:stone");
|
||||
let mesh = |blocks: &[([i32; 3], u32)], cull_leaves, cutout_leaves| {
|
||||
let mut center = Chunk::empty(0, 0);
|
||||
let mut east = Chunk::empty(1, 0);
|
||||
for &([x, y, z], state) in blocks {
|
||||
let chunk = if x < 16 { &mut center } else { &mut east };
|
||||
chunk.set(x, y, z, state);
|
||||
}
|
||||
let mut region = [[None; 3]; 3];
|
||||
region[1][1] = Some(¢er);
|
||||
region[1][2] = Some(&east);
|
||||
Mesher {
|
||||
nether_shading: false,
|
||||
assets: &assets,
|
||||
render: &render,
|
||||
tables: &tables,
|
||||
biomes: &biomes,
|
||||
visibility: visibility.clone(),
|
||||
zoom: BiomeZoom::new(0),
|
||||
blend_radius: 0,
|
||||
smooth_lighting: false,
|
||||
cutout_leaves,
|
||||
cull_leaves,
|
||||
}
|
||||
.tesselate(®ion, (0 - MIN_Y) / 16)
|
||||
.solid
|
||||
};
|
||||
|
||||
// Native leaves can contain unculled interior geometry as well as their
|
||||
// outer faces. Select the touching cull group from the actual baked model,
|
||||
// preserving every other quad and its full vertex attributes.
|
||||
let without_face = |at: [i32; 3], id, direction| {
|
||||
let vertices = mesh(&[(at, id)], false, true);
|
||||
let models = render
|
||||
.model_catalog
|
||||
.collect(BlockStateId(id), BlockPos::new(at[0], at[1], at[2]))
|
||||
.unwrap();
|
||||
let faces: Vec<_> = models.iter().flat_map(|(_, model)| &model.faces).collect();
|
||||
assert_eq!(vertices.len(), faces.len() * 4);
|
||||
let retained: Vec<_> = vertices
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.zip(faces)
|
||||
.filter(|(_, face)| face.quad.cull != Some(direction))
|
||||
.flat_map(|(quad, _)| quad.iter().copied())
|
||||
.collect();
|
||||
assert!(retained.len() < vertices.len(), "fixture has a cull group");
|
||||
retained
|
||||
};
|
||||
|
||||
// Same-species leaves, mixed leaves and roots hide only their touching
|
||||
// model groups. Disabling the option restores the isolated models.
|
||||
for (a, b) in [(oak, oak), (oak, birch), (roots, roots)] {
|
||||
let blocks = [([7, 7, 7], a), ([8, 7, 7], b)];
|
||||
let off = mesh(&blocks, false, true);
|
||||
let on = mesh(&blocks, true, true);
|
||||
assert_eq!(
|
||||
off.len(),
|
||||
mesh(&[blocks[0]], false, true).len() + mesh(&[blocks[1]], false, true).len()
|
||||
);
|
||||
let mut retained = without_face(blocks[0].0, a, ModelDirection::East);
|
||||
retained.extend(without_face(blocks[1].0, b, ModelDirection::West));
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u8>(&on),
|
||||
bytemuck::cast_slice::<_, u8>(&retained)
|
||||
);
|
||||
}
|
||||
|
||||
// Roots do not join leaves into a single culling group. Ordinary opaque
|
||||
// neighbors also keep their existing occlusion behavior.
|
||||
for (a, b) in [(oak, roots), (roots, oak), (oak, stone)] {
|
||||
let blocks = [([7, 7, 7], a), ([8, 7, 7], b)];
|
||||
let off = mesh(&blocks, false, true);
|
||||
let on = mesh(&blocks, true, true);
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u8>(&on),
|
||||
bytemuck::cast_slice::<_, u8>(&off)
|
||||
);
|
||||
}
|
||||
|
||||
// Opaque leaves already hide internal leaf faces independently of this
|
||||
// option, and a leaf beside air retains its complete baked model.
|
||||
let pair = [([7, 7, 7], oak), ([8, 7, 7], birch)];
|
||||
assert_eq!(
|
||||
mesh(&pair, false, false).len(),
|
||||
mesh(&pair, true, false).len()
|
||||
);
|
||||
let isolated = [([7, 7, 7], oak)];
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u8>(&mesh(&isolated, false, true)),
|
||||
bytemuck::cast_slice::<_, u8>(&mesh(&isolated, true, true))
|
||||
);
|
||||
|
||||
// The padding read must also remove the center chunk's internal face
|
||||
// against a different leaf species in the neighboring chunk.
|
||||
let boundary = [([15, 7, 7], oak), ([16, 7, 7], birch)];
|
||||
assert_eq!(
|
||||
mesh(&boundary, false, true).len(),
|
||||
mesh(&[boundary[0]], false, true).len()
|
||||
);
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u8>(&mesh(&boundary, true, true)),
|
||||
bytemuck::cast_slice::<_, u8>(&without_face(boundary[0].0, oak, ModelDirection::East))
|
||||
);
|
||||
}
|
||||
@@ -87,7 +87,6 @@ fn info(solid: bool) -> Info {
|
||||
legacy_solid: false,
|
||||
overlay: false,
|
||||
leaves: false,
|
||||
mangrove_roots: false,
|
||||
blocks_flow: false,
|
||||
ice: false,
|
||||
sturdy: 0,
|
||||
|
||||
@@ -987,6 +987,12 @@ pub enum MobEvent {
|
||||
pos: [i32; 3],
|
||||
state: horizon_world::registry::BlockStateId,
|
||||
},
|
||||
/// `DoorBlock.setOpen` by a mob (`InteractWithDoor`): the door opens or
|
||||
/// closes, both halves, with its sound and game event.
|
||||
SetDoorOpen {
|
||||
pos: [i32; 3],
|
||||
open: bool,
|
||||
},
|
||||
/// Destroy a block, dropping nothing (sheep eating grass becomes dirt via SetBlock).
|
||||
BreakBlock {
|
||||
pos: [i32; 3],
|
||||
|
||||
@@ -405,7 +405,7 @@ pub fn tick(
|
||||
}
|
||||
// Mob.tickHeadTurn -> BodyRotationControl.clientTick (a frozen
|
||||
// creaking's waits).
|
||||
if m.alive() && !(super::creaking::is_creaking(m) && !super::creaking::can_move(m)) {
|
||||
if m.alive() && (!super::creaking::is_creaking(m) || super::creaking::can_move(m)) {
|
||||
m.body_rotation_tick();
|
||||
}
|
||||
events.append(&mut m.outbox);
|
||||
@@ -752,8 +752,8 @@ impl Mob {
|
||||
pub fn pushable(&self) -> bool {
|
||||
self.alive()
|
||||
&& !matches!(self.species(), Species::Bat | Species::Shulker)
|
||||
&& !(self.kind.flags.climber && self.climbing)
|
||||
&& !(self.species() == Species::Creaking && !super::creaking::can_move(self))
|
||||
&& (!self.kind.flags.climber || !self.climbing)
|
||||
&& (self.species() != Species::Creaking || super::creaking::can_move(self))
|
||||
}
|
||||
}
|
||||
/// LivingEntity.onClimbable for the pusher (Spider.onClimbable: climbing a wall).
|
||||
@@ -780,7 +780,7 @@ fn push_entities(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
let self_pushable = m.alive()
|
||||
&& m.species() != Species::Shulker
|
||||
&& !on_climbable(m, w)
|
||||
&& !(m.species() == Species::Creaking && !super::creaking::can_move(m));
|
||||
&& (m.species() != Species::Creaking || super::creaking::can_move(m));
|
||||
let mut targets: Vec<(Who, Vec3)> = Vec::new();
|
||||
if let Some(p) = &w.player
|
||||
// Entity.push: passengers of the same vehicle never push each other.
|
||||
|
||||
@@ -1046,7 +1046,7 @@ impl Goal for GrowCrop {
|
||||
}
|
||||
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
|
||||
// The 0.3 roll comes only after the crop count: it draws randomness.
|
||||
let usable = !get(m).is_some_and(|b| b.crops_grown >= 10)
|
||||
let usable = get(m).is_none_or(|b| b.crops_grown < 10)
|
||||
&& m.random.next_float() >= 0.3
|
||||
&& has_nectar(m)
|
||||
&& hive_valid(m, cx.w);
|
||||
|
||||
@@ -167,9 +167,9 @@ impl MobWorld<'_> {
|
||||
let Ok(block) = self.collision.collision_block(state) else {
|
||||
continue;
|
||||
};
|
||||
if !block.large_collision_shape
|
||||
&& !block.moving_piston
|
||||
&& !(column && meets(Axis::Y, y))
|
||||
if !(block.large_collision_shape
|
||||
|| block.moving_piston
|
||||
|| (column && meets(Axis::Y, y)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -677,7 +677,7 @@ impl B {
|
||||
.is_some_and(|l| l.contains(&Who::Player));
|
||||
!reachable
|
||||
};
|
||||
if !(can_attack(m, cx.w, target) && !tired && alive && !stop_when) {
|
||||
if !can_attack(m, cx.w, target) || tired || !alive || stop_when {
|
||||
set_attack_target(m, None);
|
||||
}
|
||||
*running = true;
|
||||
|
||||
@@ -426,7 +426,7 @@ impl Goal for MoveThroughVillage {
|
||||
self.visited.remove(0);
|
||||
}
|
||||
let Some(s) = state(m) else { return false };
|
||||
if !(s.active_raid && !s.raid_over) || m.ai.target.is_some() {
|
||||
if !s.active_raid || s.raid_over || m.ai.target.is_some() {
|
||||
return false;
|
||||
}
|
||||
let Some(homes) = s.homes.clone() else {
|
||||
|
||||
@@ -636,11 +636,12 @@ pub fn layers(m: &Mob) -> Vec<Layer> {
|
||||
} else {
|
||||
"entity/zombie_villager"
|
||||
};
|
||||
let baby = if m.is_baby() { "baby/" } else { "" };
|
||||
// VillagerProfessionLayer: a baby's type texture is in `baby/`.
|
||||
let group = if m.is_baby() { "baby" } else { "type" };
|
||||
push(
|
||||
&mut v,
|
||||
&model,
|
||||
&format!("{folder}/type/{baby}{t}"),
|
||||
&format!("{folder}/{group}/{t}"),
|
||||
white,
|
||||
false,
|
||||
);
|
||||
@@ -1098,9 +1099,11 @@ fn render_visible(
|
||||
let mut poses = Poses::new(&part);
|
||||
anim::setup(m, &s, &mut poses, &data.animations);
|
||||
if matches!(m.species(), Species::Villager | Species::ZombieVillager)
|
||||
&& let Some((folder, variant)) = layer.texture.split_once("/type/")
|
||||
&& let Some((folder, variant)) = layer
|
||||
.texture
|
||||
.split_once("/type/")
|
||||
.or_else(|| layer.texture.split_once("/baby/"))
|
||||
{
|
||||
let variant = variant.strip_prefix("baby/").unwrap_or(variant);
|
||||
let profession = PROFESSIONS[m.extra.variant2 as usize % PROFESSIONS.len()];
|
||||
data.villager_hats
|
||||
.read()
|
||||
@@ -1275,6 +1278,44 @@ pub fn held_item_transform(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// VillagerProfessionLayer's textures (type, or baby for a baby;
|
||||
/// profession; level) of every villager and zombie villager are in the
|
||||
/// prepared resources: a missing one draws nothing.
|
||||
#[test]
|
||||
fn villager_layers_name_textures_that_exist() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3");
|
||||
if !root.join("entity-models.json").exists() {
|
||||
return;
|
||||
}
|
||||
let types = super::super::MobTypes::load(&root).unwrap();
|
||||
for kind in ["minecraft:villager", "minecraft:zombie_villager"] {
|
||||
let kind = std::sync::Arc::new(types.types[kind].clone());
|
||||
for baby in [false, true] {
|
||||
for t in 0..VILLAGER_TYPES.len() {
|
||||
for p in 0..PROFESSIONS.len() {
|
||||
let mut m =
|
||||
Mob::new(1, kind.clone(), horizon_core::geometry::Vec3::ZERO, 0.0);
|
||||
m.extra.age = if baby { -24000 } else { 0 };
|
||||
(m.extra.variant, m.extra.variant2, m.extra.variant3) =
|
||||
(t as u8, p as u8, 5);
|
||||
let layers = layers(&m);
|
||||
let group = if baby { "/baby/" } else { "/type/" };
|
||||
assert!(
|
||||
layers.iter().any(|l| l.texture.contains(group)),
|
||||
"{kind:?} baby={baby}: no {group} layer"
|
||||
);
|
||||
for layer in layers {
|
||||
let png = root
|
||||
.join(format!("assets/minecraft/textures/{}.png", layer.texture));
|
||||
assert!(png.is_file(), "missing {}", png.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sulfur_cube_opaque_core_is_submitted_before_its_translucent_shell() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
|
||||
@@ -614,6 +614,144 @@ pub fn meet(
|
||||
gossiped: ready,
|
||||
}
|
||||
}
|
||||
/// `Villager.spawnGolemIfNeeded` up to the spawn: villager `i` wants a golem
|
||||
/// and so do at least `needed` of the villagers whose boxes meet its own grown
|
||||
/// by 10 blocks. Returns all of those villagers: once the golem stands, they
|
||||
/// remember it ([`super::villager_brain::golem_detected`]).
|
||||
pub fn golem_summoners(mobs: &[Mob], i: usize, now: i64, needed: usize) -> Option<Vec<usize>> {
|
||||
if !ext(&mobs[i]).is_some_and(|e| e.wants_golem(now)) {
|
||||
return None;
|
||||
}
|
||||
let area = mobs[i]
|
||||
.bounds()
|
||||
.inflate(horizon_core::geometry::Vec3::new(10.0, 10.0, 10.0));
|
||||
let near: Vec<usize> = mobs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, m)| {
|
||||
m.species() == Species::Villager && !m.removed && m.bounds().intersects(area)
|
||||
})
|
||||
.map(|(j, _)| j)
|
||||
.collect();
|
||||
let wanting = near
|
||||
.iter()
|
||||
.filter(|&&j| ext(&mobs[j]).is_some_and(|e| e.wants_golem(now)))
|
||||
.take(5)
|
||||
.count();
|
||||
(wanting >= needed).then_some(near)
|
||||
}
|
||||
/// `SpawnUtil.Strategy.LEGACY_IRON_GOLEM`'s refused floors.
|
||||
fn golem_floor_ok(name: &str) -> bool {
|
||||
!matches!(
|
||||
name,
|
||||
"minecraft:cobweb"
|
||||
| "minecraft:cactus"
|
||||
| "minecraft:glass_pane"
|
||||
| "minecraft:conduit"
|
||||
| "minecraft:ice"
|
||||
| "minecraft:tnt"
|
||||
| "minecraft:glowstone"
|
||||
| "minecraft:beacon"
|
||||
| "minecraft:sea_lantern"
|
||||
| "minecraft:frosted_ice"
|
||||
| "minecraft:tinted_glass"
|
||||
| "minecraft:glass"
|
||||
) && !name.ends_with("_stained_glass_pane")
|
||||
&& !name.ends_with("_stained_glass")
|
||||
&& !name.ends_with("leaves")
|
||||
}
|
||||
/// One attempt of `SpawnUtil.trySpawnMob(IRON_GOLEM, ..., 8 across, 6 up and
|
||||
/// down, LEGACY_IRON_GOLEM)`: a column drawn near `start`, searched down from
|
||||
/// 6 above for a solid floor (or powder snow) under air or a liquid block.
|
||||
/// Returns the block the golem would stand in.
|
||||
fn golem_attempt<L: super::spawn_rules::SpawnLevel>(
|
||||
l: &L,
|
||||
start: [i32; 3],
|
||||
random: &mut impl RandomSource,
|
||||
) -> Option<[i32; 3]> {
|
||||
let dx = random.next_int_bound(17).unwrap_or(8) - 8;
|
||||
let dz = random.next_int_bound(17).unwrap_or(8) - 8;
|
||||
let (x, z) = (start[0] + dx, start[2] + dz);
|
||||
let name = |y: i32| -> &str { &l.facts().get(l.state(x, y, z)).name };
|
||||
let mut y = start[1] + 6;
|
||||
let mut above = l.state(x, y, z);
|
||||
for _ in 0..=12 {
|
||||
y -= 1;
|
||||
let here = l.state(x, y, z);
|
||||
let above_name = &*l.facts().get(above).name;
|
||||
// BlockState.liquid(): a water or lava block (not a waterlogged one).
|
||||
let open = l.facts().is_air(above)
|
||||
|| above_name == "minecraft:water"
|
||||
|| above_name == "minecraft:lava";
|
||||
let solid = l.mob_data().state(here).flags & super::data::SOLID != 0
|
||||
|| name(y) == "minecraft:powder_snow";
|
||||
if golem_floor_ok(name(y)) && open && solid {
|
||||
return Some([x, y + 1, z]);
|
||||
}
|
||||
above = here;
|
||||
}
|
||||
None
|
||||
}
|
||||
/// `IronGolem.checkSpawnObstruction`: a floor with a full top face, its feet
|
||||
/// free whatever fluid is there, and the two blocks above free.
|
||||
fn golem_fits<L: super::spawn_rules::SpawnLevel>(l: &L, [x, y, z]: [i32; 3]) -> bool {
|
||||
use super::spawn_rules::is_valid_empty_spawn;
|
||||
// entityCanStandOn: Block.isFaceFull(collision shape, UP).
|
||||
let floor = l.mob_data().state(l.state(x, y - 1, z));
|
||||
let stands = floor.flags & super::data::STURDY_TOP != 0 && floor.top >= 1.0;
|
||||
// isValidEmptySpawnBlock with Fluids.EMPTY: open water is free too.
|
||||
let feet = is_valid_empty_spawn(Species::IronGolem, l, x, y, z)
|
||||
|| &*l.facts().get(l.state(x, y, z)).name == "minecraft:water";
|
||||
stands
|
||||
&& feet
|
||||
&& is_valid_empty_spawn(Species::IronGolem, l, x, y + 1, z)
|
||||
&& is_valid_empty_spawn(Species::IronGolem, l, x, y + 2, z)
|
||||
}
|
||||
/// `SpawnUtil.trySpawnMob(IRON_GOLEM, MOB_SUMMONED, level, start, 10, 8, 6,
|
||||
/// LEGACY_IRON_GOLEM, false)`: up to ten columns tried. In each spot found
|
||||
/// `create` makes the golem (`EntityType.create`: the yaw drawn here, then its
|
||||
/// `finalizeSpawn` draws, see [`finalize_summoned_golem`]); it is kept only
|
||||
/// when `IronGolem.checkSpawnObstruction` passes, and discarded otherwise.
|
||||
pub fn summon_golem<L: super::spawn_rules::SpawnLevel, R: RandomSource, T>(
|
||||
l: &L,
|
||||
start: [i32; 3],
|
||||
random: &mut R,
|
||||
mut create: impl FnMut(&mut R, horizon_core::geometry::Vec3, f32) -> T,
|
||||
) -> Option<T> {
|
||||
for _ in 0..10 {
|
||||
let Some([x, y, z]) = golem_attempt(l, start, random) else {
|
||||
continue;
|
||||
};
|
||||
// Mth.wrapDegrees(nextFloat() * 360).
|
||||
let mut yaw = random.next_float() * 360.0;
|
||||
if yaw >= 180.0 {
|
||||
yaw -= 360.0;
|
||||
}
|
||||
let at =
|
||||
horizon_core::geometry::Vec3::new(f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5);
|
||||
let golem = create(random, at, yaw);
|
||||
if golem_fits(l, [x, y, z]) {
|
||||
return Some(golem);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
/// `Mob.finalizeSpawn` for a summoned iron golem, from the level's random:
|
||||
/// the follow range spawn bonus and left-handedness.
|
||||
pub fn finalize_summoned_golem(m: &mut Mob, random: &mut impl RandomSource) {
|
||||
use super::finalize::{Attr, Op, add_modifier, has_modifier};
|
||||
if !has_modifier(m, Attr::FollowRange, "minecraft:random_spawn_bonus") {
|
||||
let bonus = random.triangle(0.0, 0.114_850_000_000_000_01);
|
||||
add_modifier(
|
||||
m,
|
||||
Attr::FollowRange,
|
||||
"minecraft:random_spawn_bonus",
|
||||
bonus,
|
||||
Op::AddMultipliedBase,
|
||||
);
|
||||
}
|
||||
m.extra.left_handed = random.next_float() < 0.05;
|
||||
}
|
||||
/// `PoiManager.take(HOME, ..., 48)`: the nearest bed head not `claimed`
|
||||
/// (`free_head`: a bed head that is not occupied).
|
||||
pub fn take_vacant_bed(
|
||||
|
||||
@@ -779,6 +779,14 @@ fn sense_golem(m: &mut Mob, w: &MobWorld) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `GolemSensor.golemDetected`: a golem was summoned near this villager;
|
||||
/// for 599 ticks it wants no other.
|
||||
pub fn golem_detected(m: &mut Mob, now: i64) {
|
||||
mem(m).golem_detected_recently.set_ttl(true, 599);
|
||||
// What `sync` derives from the memory.
|
||||
super::villager::ext_mut(m).golem_until = now + 599 + 1;
|
||||
}
|
||||
|
||||
// ---- Behaviours ---------------------------------------------------------
|
||||
|
||||
/// The POI types villagers use.
|
||||
@@ -2252,6 +2260,7 @@ impl B {
|
||||
}
|
||||
t.begin(cx.lr, cx.now);
|
||||
if now > *next_ok {
|
||||
close_doors(m, cx, None, None);
|
||||
if super::villager::start_sleeping(m, home, w) {
|
||||
mem(m).last_slept.set(now);
|
||||
}
|
||||
@@ -2768,8 +2777,8 @@ impl B {
|
||||
let on_or_over = jumpable(here) || jumpable([here[0], here[1] - 1, here[2]]);
|
||||
let still = m.is_baby()
|
||||
&& bed.is_some_and(jumpable)
|
||||
&& !(!on_or_over && *time_left <= 0)
|
||||
&& !(on_or_over && *jumps <= 0);
|
||||
&& (on_or_over || *time_left > 0)
|
||||
&& (!on_or_over || *jumps > 0);
|
||||
if !still {
|
||||
self.do_stop(m, cx);
|
||||
return;
|
||||
@@ -3162,7 +3171,7 @@ fn follow_trading_condition(m: &Mob, w: &MobWorld) -> bool {
|
||||
m.extra.flag2
|
||||
&& m.alive()
|
||||
&& !m.fluids.in_water
|
||||
&& !(m.hurt_time > 0)
|
||||
&& m.hurt_time <= 0
|
||||
&& w.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| m.distance_sqr(p.position) <= 16.0)
|
||||
@@ -3492,7 +3501,74 @@ fn walk_to_block_memory(
|
||||
}
|
||||
/// `InteractWithDoor`: open the doors on the path, close the ones passed.
|
||||
fn interact_with_doors(m: &mut Mob, cx: &mut Bx, from: PathNode, to: PathNode) {
|
||||
let _ = (m, cx, from, to);
|
||||
let (from, to) = ([from.x, from.y, from.z], [to.x, to.y, to.z]);
|
||||
// The door being left is remembered even when it is open already; the
|
||||
// one ahead only when this villager opens it.
|
||||
for (p, always) in [(from, true), (to, false)] {
|
||||
let Some(open) = door_open(cx.w, p) else {
|
||||
continue;
|
||||
};
|
||||
if !open {
|
||||
cx.events.push(MobEvent::SetDoorOpen { pos: p, open: true });
|
||||
}
|
||||
if always || !open {
|
||||
remember_door(m, p);
|
||||
}
|
||||
}
|
||||
close_doors(m, cx, Some(from), Some(to));
|
||||
}
|
||||
/// Whether the block at `p` is a door mobs open (`MOB_INTERACTABLE_DOORS`), and
|
||||
/// then whether it is open.
|
||||
fn door_open(w: &MobWorld, p: [i32; 3]) -> Option<bool> {
|
||||
let name = w.block_name(p[0], p[1], p[2]);
|
||||
(name.ends_with("_door") && w.has_tag("minecraft:mob_interactable_doors", name))
|
||||
.then(|| w.prop(p[0], p[1], p[2], "open") == Some("true"))
|
||||
}
|
||||
fn remember_door(m: &mut Mob, p: [i32; 3]) {
|
||||
let doors = &mut mem(m).doors_to_close;
|
||||
match doors.get_mut() {
|
||||
Some(list) if list.contains(&p) => {}
|
||||
Some(list) => list.push(p),
|
||||
None => doors.set(vec![p]),
|
||||
}
|
||||
}
|
||||
/// `InteractWithDoor.closeDoorsThatIHaveOpenedOrPassedThrough`: the remembered
|
||||
/// doors other than the two of the step being taken are forgotten, and closed
|
||||
/// when still open, near (3 blocks) and no other villager within 2 blocks of
|
||||
/// one is passing through it.
|
||||
fn close_doors(m: &mut Mob, cx: &mut Bx, from: Option<[i32; 3]>, to: Option<[i32; 3]>) {
|
||||
let Some(doors) = mem(m).doors_to_close.get().cloned() else {
|
||||
return;
|
||||
};
|
||||
let near = mem(m).mobs.value().unwrap_or_default();
|
||||
let mut kept = Vec::new();
|
||||
for door in doors {
|
||||
if from == Some(door) || to == Some(door) {
|
||||
kept.push(door);
|
||||
continue;
|
||||
}
|
||||
if !closer_to_center(door, m.position, 3.0) || door_open(cx.w, door) != Some(true) {
|
||||
continue;
|
||||
}
|
||||
let passing = near.iter().any(|&who| {
|
||||
let Who::Mob(id) = who else { return false };
|
||||
cx.w.seen(id).is_some_and(|s| {
|
||||
s.species == Species::Villager
|
||||
&& closer_to_center(door, s.position, 2.0)
|
||||
&& s.villager
|
||||
.as_ref()
|
||||
.and_then(|v| v.path_nodes)
|
||||
.is_some_and(|(prev, next)| prev.is_some_and(|p| p == door || next == door))
|
||||
})
|
||||
});
|
||||
if !passing {
|
||||
cx.events.push(MobEvent::SetDoorOpen {
|
||||
pos: door,
|
||||
open: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
mem(m).doors_to_close.set(kept);
|
||||
}
|
||||
/// The scan order of POI candidates (before the nearest-first sort).
|
||||
fn pois_scan_order(_m: &Mob, found: &[(Poi, [i32; 3])]) -> Vec<usize> {
|
||||
|
||||
@@ -457,7 +457,7 @@ pub fn can_receive_vibration(m: &Mob, _now: i64, ctx: &Context) -> bool {
|
||||
&& m.alive()
|
||||
&& !st.brain.mem.vibration_cooldown.present()
|
||||
&& !digging_or_emerging(m)
|
||||
&& !(ctx.living && !ctx.targetable)
|
||||
&& (!ctx.living || ctx.targetable)
|
||||
}
|
||||
/// `VibrationSystem.Listener.handleGameEvent` for the warden: a free
|
||||
/// listener within 16 blocks of its eyes schedules what it can hear.
|
||||
|
||||
@@ -296,6 +296,10 @@ pub struct PlayerMotion {
|
||||
pub freeze_immune: bool,
|
||||
/// `Entity.oldPosition`: where this tick started.
|
||||
pub old_position: Vec3,
|
||||
/// The server's copy of a player another client moves (a LAN guest), while
|
||||
/// it travels on the server: `isLocalInstanceAuthoritative` is false, so
|
||||
/// its moves check no fall damage.
|
||||
pub server_copy: bool,
|
||||
/// `Entity.applyEffectsFromBlocks` runs after travel (always in the game;
|
||||
/// differential fixtures turn it off where their reference does).
|
||||
pub block_effects: bool,
|
||||
@@ -460,6 +464,7 @@ impl PlayerMotion {
|
||||
leather_boots: false,
|
||||
freeze_immune: false,
|
||||
old_position: position,
|
||||
server_copy: false,
|
||||
block_effects: true,
|
||||
powder_snow_entered: Vec::new(),
|
||||
hurt_log: Vec::new(),
|
||||
@@ -467,6 +472,16 @@ impl PlayerMotion {
|
||||
border: None,
|
||||
})
|
||||
}
|
||||
/// LivingEntity.aiStep: speeds too small to matter stop.
|
||||
fn drop_tiny_velocity(&mut self) {
|
||||
let v = &mut self.velocity;
|
||||
if v.x * v.x + v.z * v.z < 9.0e-6 {
|
||||
(v.x, v.z) = (0.0, 0.0);
|
||||
}
|
||||
if v.y.abs() < 0.003 {
|
||||
v.y = 0.0;
|
||||
}
|
||||
}
|
||||
/// `Entity.getTicksRequiredToFreeze`.
|
||||
pub const TICKS_REQUIRED_TO_FREEZE: i32 = 140;
|
||||
/// `Entity.getPercentFrozen`.
|
||||
@@ -920,15 +935,7 @@ impl MotionWorld<'_, '_, '_> {
|
||||
if self.player.no_jump_delay > 0 {
|
||||
self.player.no_jump_delay -= 1;
|
||||
}
|
||||
let mut velocity = self.player.velocity;
|
||||
if velocity.x * velocity.x + velocity.z * velocity.z < 9.0e-6 {
|
||||
velocity.x = 0.0;
|
||||
velocity.z = 0.0;
|
||||
}
|
||||
if velocity.y.abs() < 0.003 {
|
||||
velocity.y = 0.0;
|
||||
}
|
||||
self.player.velocity = velocity;
|
||||
self.player.drop_tiny_velocity();
|
||||
let mut input = match input {
|
||||
TickInput::Living(v) => Vec3::new(
|
||||
f64::from(v[0] * 0.98),
|
||||
@@ -947,9 +954,38 @@ impl MotionWorld<'_, '_, '_> {
|
||||
}
|
||||
self.jump_phase()?;
|
||||
let before_travel = self.player.bounds();
|
||||
// Player.travel: swim steering, then LivingEntity.travel (fluids
|
||||
// first, then gliding, then air), keeping 0.6 of the vertical speed
|
||||
// while flying.
|
||||
self.player_travel(input)?;
|
||||
if self.player.block_effects {
|
||||
self.apply_effects_from_blocks()?;
|
||||
}
|
||||
// LivingEntity.aiStep "freezing" (server).
|
||||
if !self.player.in_powder_snow || !self.player.can_freeze() {
|
||||
self.player.ticks_frozen = (self.player.ticks_frozen - 2).max(0);
|
||||
}
|
||||
self.player.remove_frost()?;
|
||||
let on = self.support(movement_support::LEGACY_SUPPORT_OFFSET)?;
|
||||
let on_air = self.assets.registry.state(self.block(on)).is_none_or(|s| {
|
||||
matches!(
|
||||
s.name.as_str(),
|
||||
"minecraft:air" | "minecraft:cave_air" | "minecraft:void_air"
|
||||
)
|
||||
});
|
||||
self.player.try_add_frost(on_air)?;
|
||||
if self.player.health.tick_count % 40 == 0
|
||||
&& self.player.is_fully_frozen()
|
||||
&& self.player.can_freeze()
|
||||
{
|
||||
self.freeze_damage()?;
|
||||
}
|
||||
if self.player.auto_spin_attack_ticks > 0 {
|
||||
self.player.auto_spin_attack_ticks -= 1;
|
||||
self.check_auto_spin_attack(before_travel);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Player.travel: swim steering, then LivingEntity.travel (fluids first,
|
||||
/// then gliding, then air), keeping 0.6 of the vertical speed while flying.
|
||||
fn player_travel(&mut self, input: Vec3) -> Result<()> {
|
||||
if self.player.swimming {
|
||||
let look_y =
|
||||
horizon_world::view_direction::view_vector(self.player.pitch, self.player.yaw).y;
|
||||
@@ -979,34 +1015,29 @@ impl MotionWorld<'_, '_, '_> {
|
||||
if let Some(y) = flying_y {
|
||||
self.player.velocity.y = y * 0.6;
|
||||
}
|
||||
if self.player.block_effects {
|
||||
self.apply_effects_from_blocks()?;
|
||||
}
|
||||
// LivingEntity.aiStep "freezing" (server).
|
||||
if !self.player.in_powder_snow || !self.player.can_freeze() {
|
||||
self.player.ticks_frozen = (self.player.ticks_frozen - 2).max(0);
|
||||
}
|
||||
self.player.remove_frost()?;
|
||||
let on = self.support(movement_support::LEGACY_SUPPORT_OFFSET)?;
|
||||
let on_air = self.assets.registry.state(self.block(on)).is_none_or(|s| {
|
||||
matches!(
|
||||
s.name.as_str(),
|
||||
"minecraft:air" | "minecraft:cave_air" | "minecraft:void_air"
|
||||
)
|
||||
});
|
||||
self.player.try_add_frost(on_air)?;
|
||||
if self.player.health.tick_count % 40 == 0
|
||||
&& self.player.is_fully_frozen()
|
||||
&& self.player.can_freeze()
|
||||
{
|
||||
self.freeze_damage()?;
|
||||
}
|
||||
if self.player.auto_spin_attack_ticks > 0 {
|
||||
self.player.auto_spin_attack_ticks -= 1;
|
||||
self.check_auto_spin_attack(before_travel);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// ServerPlayer's own travel: the server simulates a player too
|
||||
/// (`MoveSimulationType.AUTHORITATIVE_SIDE_AND_SERVER`), with no input
|
||||
/// (it never sets xxa/zza). Only the delta movement it leaves stays: it
|
||||
/// is what a hurt sends the player (`ServerEntity.sendChanges`), and
|
||||
/// what the next knockback starts from; the client's next move places
|
||||
/// the player.
|
||||
pub fn server_travel(&mut self) -> Result<()> {
|
||||
let p = &mut *self.player;
|
||||
let (position, ground, collision) = (p.position, p.ground, p.collision);
|
||||
let (history, fall_distance, steps) = (p.history.clone(), p.fall_distance, p.steps);
|
||||
let events = p.events.len();
|
||||
p.drop_tiny_velocity();
|
||||
p.server_copy = true;
|
||||
let travelled = self.player_travel(Vec3::ZERO);
|
||||
let p = &mut *self.player;
|
||||
p.server_copy = false;
|
||||
(p.position, p.ground, p.collision) = (position, ground, collision);
|
||||
(p.history, p.fall_distance, p.steps) = (history, fall_distance, steps);
|
||||
p.events.truncate(events);
|
||||
travelled
|
||||
}
|
||||
/// `LivingEntity.checkAutoSpinAttack`: the first living entity in the
|
||||
/// box swept this tick takes the hit and ends the spin, bouncing the
|
||||
/// player back at a fifth of its speed; a wall ends it too.
|
||||
@@ -1678,7 +1709,7 @@ impl EntityMoveWorld for MotionWorld<'_, '_, '_> {
|
||||
&mut self.player.history
|
||||
}
|
||||
fn locally_authoritative(&mut self) -> Result<bool> {
|
||||
Ok(true)
|
||||
Ok(!self.player.server_copy)
|
||||
}
|
||||
fn set_on_ground_with_movement(
|
||||
&mut self,
|
||||
@@ -2327,6 +2358,64 @@ mod tests {
|
||||
assert_eq!(player.position.y, 4.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_travel_moves_only_the_velocity() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let binding = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let resources = MotionResources::load(&root, &assets).unwrap();
|
||||
let mut scene = World::test_scene(&assets, &render, 1).unwrap();
|
||||
let at = Vec3::new(20.5, 4.0, 20.5);
|
||||
let mut player = PlayerMotion::new(&resources, at).unwrap();
|
||||
let mut travel = |player: &mut PlayerMotion| {
|
||||
MotionWorld {
|
||||
entities: &[],
|
||||
player,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
collision_binding: &binding,
|
||||
resources: &resources,
|
||||
}
|
||||
.server_travel()
|
||||
.unwrap()
|
||||
};
|
||||
// A knockback on the ground (vanilla's server sends 0.21840 0.27516
|
||||
// after a zombie's hit): ground friction, then gravity and drag.
|
||||
player.ground.on_ground = true;
|
||||
player.velocity = Vec3::new(-0.4, 0.3608, 0.0);
|
||||
travel(&mut player);
|
||||
assert!(
|
||||
(player.velocity.x + 0.2184).abs() < 1e-6,
|
||||
"{:?}",
|
||||
player.velocity
|
||||
);
|
||||
assert!(
|
||||
(player.velocity.y - 0.275184).abs() < 1e-6,
|
||||
"{:?}",
|
||||
player.velocity
|
||||
);
|
||||
assert_eq!(player.position, at);
|
||||
assert!(player.ground.on_ground);
|
||||
// Falling onto the ground hurts no one on the server's copy, and leaves
|
||||
// the fall to the client's moves.
|
||||
let health = player.health.health();
|
||||
player.position = Vec3::new(20.5, 4.5, 20.5);
|
||||
player.ground.on_ground = false;
|
||||
player.fall_distance = 10.0;
|
||||
player.velocity = Vec3::new(0.0, -1.0, 0.0);
|
||||
travel(&mut player);
|
||||
assert_eq!(player.velocity.y, -0.0784000015258789);
|
||||
assert_eq!(player.health.health(), health);
|
||||
assert_eq!(player.fall_distance, 10.0);
|
||||
assert!(player.events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spectator_scroll_speed_controls_horizontal_and_vertical_flight() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
|
||||
@@ -67,6 +67,9 @@ pub enum Screen {
|
||||
name: String,
|
||||
address: String,
|
||||
focus: u8,
|
||||
/// Server Resource Packs (`acceptTextures`): Enabled, Disabled, or
|
||||
/// none for Prompt.
|
||||
packs: Option<bool>,
|
||||
},
|
||||
Direct {
|
||||
address: String,
|
||||
@@ -224,6 +227,19 @@ fn entry_address(servers: &[ServerEntry], lan: &Scanner, index: usize) -> Option
|
||||
}
|
||||
}
|
||||
|
||||
/// The Server Resource Packs button's label: `CommonComponents.optionNameValue`.
|
||||
fn pack_setting_label(packs: Option<bool>) -> String {
|
||||
let (key, fallback) = match packs {
|
||||
Some(true) => ("manageServer.resourcePack.enabled", "Enabled"),
|
||||
Some(false) => ("manageServer.resourcePack.disabled", "Disabled"),
|
||||
None => ("manageServer.resourcePack.prompt", "Prompt"),
|
||||
};
|
||||
let name = crate::lang::get_or("manageServer.resourcePack", "Server Resource Packs");
|
||||
crate::lang::get_or("options.generic_value", "%s: %s")
|
||||
.replacen("%s", &name, 1)
|
||||
.replacen("%s", &crate::lang::get_or(key, fallback), 1)
|
||||
}
|
||||
|
||||
/// JoinMultiplayerScreen's `HeaderAndFooterLayout(33, 60)`.
|
||||
const HEADER: f32 = 33.0;
|
||||
const FOOTER: f32 = 60.0;
|
||||
@@ -461,7 +477,16 @@ impl Screen {
|
||||
),
|
||||
]
|
||||
}
|
||||
Self::Edit { address, .. } => vec![
|
||||
Self::Edit { address, packs, .. } => vec![
|
||||
// ManageServerScreen's CycleButton, above Done.
|
||||
b(
|
||||
w / 2.0 - 100.0,
|
||||
h / 4.0 + 72.0,
|
||||
200.0,
|
||||
&pack_setting_label(*packs),
|
||||
true,
|
||||
60,
|
||||
),
|
||||
b(
|
||||
w / 2.0 - 100.0,
|
||||
h / 4.0 + 96.0 + 18.0,
|
||||
@@ -847,6 +872,7 @@ impl Screen {
|
||||
name,
|
||||
address,
|
||||
focus,
|
||||
..
|
||||
} => {
|
||||
centered(
|
||||
batch,
|
||||
@@ -996,6 +1022,7 @@ impl Screen {
|
||||
name: "Minecraft Server".into(),
|
||||
address: String::new(),
|
||||
focus: 1,
|
||||
packs: None,
|
||||
}
|
||||
}
|
||||
(
|
||||
@@ -1011,6 +1038,7 @@ impl Screen {
|
||||
name: s.name,
|
||||
address: s.ip,
|
||||
focus: 0,
|
||||
packs: s.accept_textures,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1125,21 +1153,32 @@ impl Screen {
|
||||
*selected = Some(index);
|
||||
}
|
||||
}
|
||||
// CycleButton: Enabled, Disabled, Prompt, and round again.
|
||||
(Self::Edit { packs, .. }, Some(60)) => {
|
||||
*packs = match *packs {
|
||||
Some(true) => Some(false),
|
||||
Some(false) => None,
|
||||
None => Some(true),
|
||||
}
|
||||
}
|
||||
(
|
||||
Self::Edit {
|
||||
index,
|
||||
name,
|
||||
address,
|
||||
packs,
|
||||
..
|
||||
},
|
||||
Some(50),
|
||||
) => {
|
||||
let mut list = servers::load(&servers_file(saves));
|
||||
let entry = ServerEntry::new(name.trim(), address.trim());
|
||||
let mut entry = ServerEntry::new(name.trim(), address.trim());
|
||||
entry.accept_textures = *packs;
|
||||
match index.and_then(|i| list.get_mut(i)) {
|
||||
Some(e) => {
|
||||
e.name = entry.name;
|
||||
e.ip = entry.ip;
|
||||
e.accept_textures = entry.accept_textures;
|
||||
}
|
||||
None => list.push(entry),
|
||||
}
|
||||
@@ -1637,4 +1676,57 @@ mod tests {
|
||||
assert_eq!(names(), ["Keep"]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_cycles_and_saves_the_server_resource_pack_setting() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mp-packs-{}", std::process::id()));
|
||||
let saves = dir.join("saves");
|
||||
std::fs::create_dir_all(&saves).unwrap();
|
||||
let file = servers_file(&saves);
|
||||
let mut entry = ServerEntry::new("Packs", "127.0.0.1:4");
|
||||
entry.accept_textures = Some(false);
|
||||
servers::save(&file, &[entry]).unwrap();
|
||||
let size = (427.0, 240.0);
|
||||
let mut screen = Screen::list(&saves);
|
||||
if let Screen::List { selected, .. } = &mut screen {
|
||||
*selected = Some(0);
|
||||
}
|
||||
screen.click(Some(43), 0.0, 0.0, size, &saves);
|
||||
let cycle = |screen: &Screen| {
|
||||
screen
|
||||
.buttons(size.0, size.1)
|
||||
.into_iter()
|
||||
.find(|b| b.5 == 60)
|
||||
.map(|b| (b.0, b.1, b.2, b.3))
|
||||
};
|
||||
// ManageServerScreen: 200 wide at height / 4 + 72, the saved value first.
|
||||
assert_eq!(
|
||||
cycle(&screen),
|
||||
Some((
|
||||
113.5,
|
||||
132.0,
|
||||
200.0,
|
||||
"Server Resource Packs: Disabled".into()
|
||||
))
|
||||
);
|
||||
// Disabled, Prompt, Enabled: Cancel keeps what was saved.
|
||||
screen.click(Some(60), 0.0, 0.0, size, &saves);
|
||||
assert_eq!(cycle(&screen).unwrap().3, "Server Resource Packs: Prompt");
|
||||
screen.click(Some(51), 0.0, 0.0, size, &saves);
|
||||
assert_eq!(servers::load(&file)[0].accept_textures, Some(false));
|
||||
// Done saves it.
|
||||
if let Screen::List { selected, .. } = &mut screen {
|
||||
*selected = Some(0);
|
||||
}
|
||||
screen.click(Some(43), 0.0, 0.0, size, &saves);
|
||||
screen.click(Some(60), 0.0, 0.0, size, &saves);
|
||||
screen.click(Some(60), 0.0, 0.0, size, &saves);
|
||||
assert_eq!(cycle(&screen).unwrap().3, "Server Resource Packs: Enabled");
|
||||
screen.click(Some(50), 0.0, 0.0, size, &saves);
|
||||
assert_eq!(servers::load(&file)[0].accept_textures, Some(true));
|
||||
// A new server asks.
|
||||
screen.click(Some(42), 0.0, 0.0, size, &saves);
|
||||
assert_eq!(cycle(&screen).unwrap().3, "Server Resource Packs: Prompt");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ use horizon_protocol::{
|
||||
use std::{
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc::{Receiver, Sender, channel},
|
||||
},
|
||||
thread::JoinHandle,
|
||||
@@ -43,6 +44,10 @@ pub enum Event {
|
||||
DecodeError(String),
|
||||
/// The server sent us back to configuration and finished it again.
|
||||
Reconfigured(Arc<Dynamic>),
|
||||
/// ClientboundResourcePackPushPacket, in configuration or in play.
|
||||
PackPush(cfg::ResourcePackPush),
|
||||
/// ClientboundResourcePackPopPacket: one pack, or every pack.
|
||||
PackPop(Option<u128>),
|
||||
/// The connection ended. `from_server`: the server gave the reason (or
|
||||
/// the connection was refused for good), rather than the connection
|
||||
/// failing (auto-reconnect retries only failures and shutdowns).
|
||||
@@ -55,6 +60,9 @@ pub enum Event {
|
||||
pub struct Session {
|
||||
events: Receiver<Event>,
|
||||
writer: Arc<Mutex<Option<ConnWriter>>>,
|
||||
/// The connection is in configuration (else in play). Changed only
|
||||
/// with `writer` locked, as the packet that switches is sent.
|
||||
configuring: Arc<AtomicBool>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
pub address: String,
|
||||
pub name: String,
|
||||
@@ -92,8 +100,10 @@ impl Session {
|
||||
) -> Self {
|
||||
let (send, events) = channel();
|
||||
let writer = Arc::new(Mutex::new(None));
|
||||
let configuring = Arc::new(AtomicBool::new(true));
|
||||
let counters = Arc::new(Mutex::new(None));
|
||||
let (thread_writer, thread_counters) = (writer.clone(), counters.clone());
|
||||
let thread_configuring = configuring.clone();
|
||||
let (address_owned, name_owned) = (address.to_owned(), name.to_owned());
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("horizon-net".into())
|
||||
@@ -107,6 +117,7 @@ impl Session {
|
||||
&lang,
|
||||
&send,
|
||||
&thread_writer,
|
||||
&thread_configuring,
|
||||
&thread_counters,
|
||||
);
|
||||
let from_server = result.is_ok();
|
||||
@@ -124,6 +135,7 @@ impl Session {
|
||||
Self {
|
||||
events,
|
||||
writer,
|
||||
configuring,
|
||||
thread,
|
||||
address: address.to_owned(),
|
||||
name: name.to_owned(),
|
||||
@@ -153,6 +165,21 @@ impl Session {
|
||||
w.shutdown();
|
||||
}
|
||||
}
|
||||
/// ServerboundResourcePackPacket, in whichever phase the connection is.
|
||||
pub fn send_pack_status(&self, id: u128, action: i32) {
|
||||
let status = cfg::ResourcePackResponse { id, action };
|
||||
let mut writer = self.writer.lock().unwrap();
|
||||
let body = if self.configuring.load(Ordering::Relaxed) {
|
||||
cfg::Serverbound::ResourcePack(status).encode()
|
||||
} else {
|
||||
play::Serverbound::ResourcePack(status).encode()
|
||||
};
|
||||
if let Some(w) = writer.as_mut()
|
||||
&& w.send(&body).is_err()
|
||||
{
|
||||
w.shutdown();
|
||||
}
|
||||
}
|
||||
pub fn disconnect(&mut self) {
|
||||
if let Some(w) = self.writer.lock().unwrap().take() {
|
||||
w.shutdown();
|
||||
@@ -253,6 +280,7 @@ fn run(
|
||||
lang: &Language,
|
||||
send: &Sender<Event>,
|
||||
shared_writer: &Mutex<Option<ConnWriter>>,
|
||||
configuring: &AtomicBool,
|
||||
shared_counters: &Mutex<Option<Arc<Counters>>>,
|
||||
) -> Result<String, String> {
|
||||
let (host, port, mut c) = if let Some(pipe) = local {
|
||||
@@ -268,8 +296,13 @@ fn run(
|
||||
target
|
||||
}
|
||||
};
|
||||
let c =
|
||||
Connection::connect(&host, port, Duration::from_secs(10)).map_err(|e| e.to_string())?;
|
||||
let c = if address.starts_with(FRIEND) {
|
||||
// The friends relay supplies a concrete endpoint, not a Minecraft hostname.
|
||||
Connection::connect(&host, port, Duration::from_secs(10))
|
||||
} else {
|
||||
Connection::connect_server(&host, port, Duration::from_secs(10))
|
||||
}
|
||||
.map_err(|e| e.to_string())?;
|
||||
(host, port, c)
|
||||
};
|
||||
*shared_counters.lock().unwrap() = Some(c.reader.counters.clone());
|
||||
@@ -377,18 +410,19 @@ fn run(
|
||||
.map_err(e)?;
|
||||
let (mut reader, writer) = c.split();
|
||||
*shared_writer.lock().unwrap() = Some(writer);
|
||||
let registries = configure(&mut reader, shared_writer, lang, send)?;
|
||||
let registries = configure(&mut reader, shared_writer, configuring, lang, send)?;
|
||||
let _ = send.send(Event::Joined {
|
||||
registries: Arc::new(registries),
|
||||
uuid: profile.uuid,
|
||||
name: profile.name,
|
||||
});
|
||||
receive_play(&mut reader, shared_writer, lang, send)
|
||||
receive_play(&mut reader, shared_writer, configuring, lang, send)
|
||||
}
|
||||
|
||||
fn receive_play(
|
||||
reader: &mut ConnReader,
|
||||
shared_writer: &Mutex<Option<ConnWriter>>,
|
||||
configuring: &AtomicBool,
|
||||
lang: &Language,
|
||||
send: &Sender<Event>,
|
||||
) -> Result<String, String> {
|
||||
@@ -416,15 +450,17 @@ fn receive_play(
|
||||
play::Clientbound::KeepAlive(k) => reply(play::Serverbound::KeepAlive(k)),
|
||||
play::Clientbound::BundleDelimiter(_) => {}
|
||||
play::Clientbound::ResourcePackPush(p) => {
|
||||
// Declined: Horizon has no server resource packs.
|
||||
reply(play::Serverbound::ResourcePack(cfg::ResourcePackResponse {
|
||||
id: p.id,
|
||||
action: 1,
|
||||
}));
|
||||
let _ = send.send(Event::PackPush(p));
|
||||
}
|
||||
play::Clientbound::ResourcePackPop(p) => {
|
||||
let _ = send.send(Event::PackPop(p.id));
|
||||
}
|
||||
play::Clientbound::StartConfiguration(_) => {
|
||||
reply(play::Serverbound::ConfigurationAcknowledged(Empty));
|
||||
let registries = configure(reader, shared_writer, lang, send)?;
|
||||
if let Some(w) = shared_writer.lock().unwrap().as_mut() {
|
||||
let _ = w.send(&play::Serverbound::ConfigurationAcknowledged(Empty).encode());
|
||||
configuring.store(true, Ordering::Relaxed);
|
||||
}
|
||||
let registries = configure(reader, shared_writer, configuring, lang, send)?;
|
||||
let _ = send.send(Event::Reconfigured(Arc::new(registries)));
|
||||
}
|
||||
play::Clientbound::Disconnect(d) => return Ok(plain(&d.reason, lang)),
|
||||
@@ -445,6 +481,7 @@ fn receive_play(
|
||||
fn configure(
|
||||
reader: &mut ConnReader,
|
||||
writer: &Mutex<Option<ConnWriter>>,
|
||||
configuring: &AtomicBool,
|
||||
lang: &Language,
|
||||
send: &Sender<Event>,
|
||||
) -> Result<Dynamic, String> {
|
||||
@@ -471,11 +508,12 @@ fn configure(
|
||||
cfg::Clientbound::CodeOfConduct(_) => {
|
||||
reply(cfg::Serverbound::AcceptCodeOfConduct(Empty))?
|
||||
}
|
||||
// The game answers: it may prompt the player first.
|
||||
cfg::Clientbound::ResourcePackPush(p) => {
|
||||
reply(cfg::Serverbound::ResourcePack(cfg::ResourcePackResponse {
|
||||
id: p.id,
|
||||
action: 1,
|
||||
}))?
|
||||
let _ = send.send(Event::PackPush(p));
|
||||
}
|
||||
cfg::Clientbound::ResourcePackPop(p) => {
|
||||
let _ = send.send(Event::PackPop(p.id));
|
||||
}
|
||||
cfg::Clientbound::CookieRequest(q) => {
|
||||
reply(cfg::Serverbound::CookieResponse(cfg::CookieResponse {
|
||||
@@ -485,7 +523,11 @@ fn configure(
|
||||
}
|
||||
cfg::Clientbound::Disconnect(d) => return Err(plain(&d.reason, lang)),
|
||||
cfg::Clientbound::FinishConfiguration(_) => {
|
||||
reply(cfg::Serverbound::FinishConfiguration(Empty))?;
|
||||
let mut w = writer.lock().unwrap();
|
||||
let w = w.as_mut().ok_or("Disconnected")?;
|
||||
w.send(&cfg::Serverbound::FinishConfiguration(Empty).encode())
|
||||
.map_err(|e| e.to_string())?;
|
||||
configuring.store(false, Ordering::Relaxed);
|
||||
return Ok(registries);
|
||||
}
|
||||
_ => {}
|
||||
@@ -559,7 +601,7 @@ pub fn load_language(resources: &std::path::Path) -> Language {
|
||||
pub fn ping(address: &str, timeout: Duration) -> Result<(serde_json::Value, u128), String> {
|
||||
use horizon_protocol::packets::status;
|
||||
let (host, port) = parse_address(address).ok_or("Invalid address")?;
|
||||
let mut c = Connection::connect(&host, port, timeout).map_err(|e| e.to_string())?;
|
||||
let mut c = Connection::connect_server(&host, port, timeout).map_err(|e| e.to_string())?;
|
||||
c.reader
|
||||
.set_read_timeout(Some(timeout))
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -602,7 +644,13 @@ mod tests {
|
||||
let (send, events) = channel();
|
||||
let thread_writer = writer.clone();
|
||||
let thread = std::thread::spawn(move || {
|
||||
receive_play(&mut reader, &thread_writer, &Language::new(), &send)
|
||||
receive_play(
|
||||
&mut reader,
|
||||
&thread_writer,
|
||||
&AtomicBool::new(false),
|
||||
&Language::new(),
|
||||
&send,
|
||||
)
|
||||
});
|
||||
let mut server = Connection::local(server);
|
||||
let teleport = play::Clientbound::PlayerPosition(play::PlayerPosition {
|
||||
|
||||
@@ -10,7 +10,6 @@ use crate::{
|
||||
keybinds::{self, CATEGORIES, KeyBinds, MAPPINGS},
|
||||
lang::{self, LanguageInfo},
|
||||
settings::{SOUND_SOURCES, Settings},
|
||||
shader_pack::PackConfig,
|
||||
text::{self, Style, Text},
|
||||
ui,
|
||||
};
|
||||
@@ -863,10 +862,6 @@ pub enum Page {
|
||||
Font,
|
||||
Packs,
|
||||
Mods,
|
||||
/// The shader packs in `shaderpacks/`: pick one, open the folder.
|
||||
ShaderPacks,
|
||||
/// Interactive options and profiles for the active shader pack.
|
||||
ShaderPackOptions,
|
||||
/// Horizon's graphics settings (`graphics_ui`).
|
||||
Graphics,
|
||||
GraphicsAdvanced,
|
||||
@@ -898,17 +893,9 @@ enum Act {
|
||||
ResetPadKeys,
|
||||
ToggleMod(usize),
|
||||
OpenModFolder,
|
||||
/// Pick shader pack `i` of `OptionsUi::shader_packs` (again: off).
|
||||
ToggleShaderPack(usize),
|
||||
OpenShaderPackFolder,
|
||||
/// Credits & Attribution's Credits button (WinScreen without the poem).
|
||||
Credits,
|
||||
CopyAgentDetails,
|
||||
ShaderPackProfile,
|
||||
ShaderPackSubscreen(String),
|
||||
ShaderPackBackScreen,
|
||||
ShaderPackOption(usize),
|
||||
ResetShaderPackOptions,
|
||||
}
|
||||
enum WKind {
|
||||
Button {
|
||||
@@ -921,11 +908,6 @@ enum WKind {
|
||||
value: f32,
|
||||
opt: Opt,
|
||||
},
|
||||
ShaderOptionSlider {
|
||||
label: Text,
|
||||
value: f32,
|
||||
index: usize,
|
||||
},
|
||||
Text {
|
||||
text: Text,
|
||||
color: u32,
|
||||
@@ -961,15 +943,10 @@ pub enum Event {
|
||||
Packs(Vec<String>),
|
||||
OpenPackFolder,
|
||||
OpenModFolder,
|
||||
/// Create `shaderpacks/` if needed and open it.
|
||||
OpenShaderPackFolder,
|
||||
/// Roll the credits (`CreditsAndAttributionScreen.openCreditsScreen`).
|
||||
Credits,
|
||||
/// Copy how an agent connects (the AI Agent page).
|
||||
CopyAgentDetails,
|
||||
/// The shader pack settings page closed with changes: they are saved,
|
||||
/// the pack is built again with them.
|
||||
ReloadShaderPack,
|
||||
}
|
||||
/// `SliderableValueSet.applyValueImmediately`: these apply 600 ms after
|
||||
/// the last change (`OptionInstanceSliderButton.delayedApplyAt`).
|
||||
@@ -1021,19 +998,8 @@ pub struct OptionsUi {
|
||||
/// Scroll the selected language into the middle on the next layout.
|
||||
center_language: bool,
|
||||
pub graphics: crate::graphics_ui::GraphicsUi,
|
||||
/// `shaderpacks/` and its packs, read while the Shader Packs page shows.
|
||||
pub shader_packs: crate::shader_pack::Folder,
|
||||
/// The pack that failed to load last and why (set by the game).
|
||||
pub shader_pack_error: Option<(String, String)>,
|
||||
/// The AI Agent page's status lines (set by the game while it shows).
|
||||
pub agent_status: Vec<String>,
|
||||
/// Loaded interactive configuration for the currently selected shader pack.
|
||||
pub shader_pack_config: Option<PackConfig>,
|
||||
/// The shader pack option slider being dragged.
|
||||
pub(crate) dragging_shader_option: Option<usize>,
|
||||
/// `shader_pack_config` changed since the settings page opened: leaving
|
||||
/// it saves the values and reloads the pack.
|
||||
shader_pack_edited: bool,
|
||||
}
|
||||
impl Default for OptionsUi {
|
||||
fn default() -> Self {
|
||||
@@ -1062,12 +1028,7 @@ impl Default for OptionsUi {
|
||||
in_world: false,
|
||||
center_language: false,
|
||||
graphics: Default::default(),
|
||||
shader_packs: Default::default(),
|
||||
shader_pack_error: None,
|
||||
agent_status: Vec::new(),
|
||||
shader_pack_config: None,
|
||||
dragging_shader_option: None,
|
||||
shader_pack_edited: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1114,14 +1075,11 @@ fn option_widget(s: &Settings, opt: Opt, x: f32, y: f32, w: f32) -> Widget {
|
||||
/// What a button opening one of Horizon's pages says on hover.
|
||||
fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
|
||||
Some(match page {
|
||||
Page::Horizon => {
|
||||
"Horizon's own settings: graphics, shader packs, controllers, extras and mods."
|
||||
}
|
||||
Page::Horizon => "Horizon's own settings: graphics, controllers, extras and mods.",
|
||||
Page::Graphics => "Graphics, simply: each setting says what it costs.",
|
||||
Page::GraphicsAdvanced => {
|
||||
"Every graphics setting, Horizon's included, with what each costs."
|
||||
}
|
||||
Page::ShaderPacks => "Packs that redraw the world. Off is the vanilla look.",
|
||||
Page::Controller => "Controllers: their buttons, sticks, vibration and menu navigation.",
|
||||
Page::Extras => {
|
||||
"Optional additions, all off by default: the game stays vanilla until you turn one on."
|
||||
@@ -1196,105 +1154,6 @@ impl OptionsUi {
|
||||
self.graphics.reset();
|
||||
}
|
||||
}
|
||||
/// Pick shader pack `i` of the folder, or turn it off when it is the
|
||||
/// one running.
|
||||
pub(crate) fn toggle_shader_pack(&mut self, s: &mut Settings, i: usize) {
|
||||
let Some((id, Ok(_))) = self.shader_packs.packs.get(i) else {
|
||||
return;
|
||||
};
|
||||
let g = &mut s.horizon;
|
||||
g.shader_pack = if g.shader_pack == *id {
|
||||
String::new()
|
||||
} else {
|
||||
id.clone()
|
||||
};
|
||||
self.sync_shader_pack_config(s);
|
||||
}
|
||||
/// Ensure `shader_pack_config` matches `s.horizon.shader_pack`.
|
||||
pub fn sync_shader_pack_config(&mut self, s: &Settings) {
|
||||
let id = s.horizon.shader_pack.as_str();
|
||||
if id.is_empty() {
|
||||
self.shader_pack_config = None;
|
||||
return;
|
||||
}
|
||||
if self
|
||||
.shader_pack_config
|
||||
.as_ref()
|
||||
.is_some_and(|cfg| cfg.id == id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// A pack whose options can't be read has none, rather than being
|
||||
// read again every frame.
|
||||
let config = PackConfig::load(&self.shader_pack_dir(), id).unwrap_or_else(|e| {
|
||||
log::warn!("Horizon: shader pack {id} options: {e}");
|
||||
PackConfig {
|
||||
id: id.to_owned(),
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
self.shader_pack_config = Some(config);
|
||||
self.shader_pack_edited = false;
|
||||
}
|
||||
/// The picked pack has options or profiles to show.
|
||||
fn shader_pack_has_settings(&self, s: &Settings) -> bool {
|
||||
self.shader_pack_config.as_ref().is_some_and(|c| {
|
||||
!s.horizon.shader_pack.is_empty()
|
||||
&& c.id == s.horizon.shader_pack
|
||||
&& (!c.options.is_empty() || !c.profiles.is_empty())
|
||||
})
|
||||
}
|
||||
fn shader_pack_dir(&self) -> std::path::PathBuf {
|
||||
if self.shader_packs.dir.as_os_str().is_empty() {
|
||||
crate::shader_pack::folder()
|
||||
} else {
|
||||
self.shader_packs.dir.clone()
|
||||
}
|
||||
}
|
||||
/// A file or folder dropped on the window: on the Shader Packs page (as
|
||||
/// Iris's `ShaderPackScreen.onFilesDrop`), a pack copied into
|
||||
/// `shaderpacks/` and picked. `None` elsewhere; the caller saves the
|
||||
/// settings and loads the pack on `Some(Ok)`.
|
||||
pub fn drop_shader_pack(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
path: &std::path::Path,
|
||||
) -> Option<Result<(), String>> {
|
||||
if self.page() != Page::ShaderPacks {
|
||||
return None;
|
||||
}
|
||||
let dir = self.shader_pack_dir();
|
||||
Some(crate::shader_pack::import_pack(path, &dir).map(|id| {
|
||||
self.shader_packs = Default::default();
|
||||
self.shader_packs.poll(dir);
|
||||
s.horizon.shader_pack = id;
|
||||
self.sync_shader_pack_config(s);
|
||||
}))
|
||||
}
|
||||
/// Change the shader pack's options, applied when its page closes.
|
||||
fn edit_shader_pack(&mut self, edit: impl FnOnce(&mut PackConfig)) {
|
||||
if let Some(cfg) = &mut self.shader_pack_config {
|
||||
edit(cfg);
|
||||
self.shader_pack_edited = true;
|
||||
}
|
||||
}
|
||||
/// Save the active shader pack options to `shaderpacks/<id>.txt`.
|
||||
fn save_shader_pack_config(&self) {
|
||||
if let Some(cfg) = &self.shader_pack_config
|
||||
&& let Err(e) = cfg.save(&self.shader_pack_dir())
|
||||
{
|
||||
log::warn!("Horizon: shader pack {} options: {e}", cfg.id);
|
||||
}
|
||||
}
|
||||
pub(crate) fn shader_pack_failed(&self, id: &str) -> bool {
|
||||
self.shader_pack_error
|
||||
.as_ref()
|
||||
.is_some_and(|(e, _)| e == id)
|
||||
}
|
||||
/// Whether the Shader Packs page shows.
|
||||
fn shader_packs_shown(&self) -> bool {
|
||||
self.page() == Page::ShaderPacks
|
||||
}
|
||||
/// The graphics screen level of `page`, if it is one.
|
||||
fn graphics_level(page: Page) -> Option<crate::graphics_ui::Level> {
|
||||
match page {
|
||||
@@ -1317,8 +1176,6 @@ impl OptionsUi {
|
||||
Page::Font => "options.font.title",
|
||||
Page::Packs => "resourcePack.title",
|
||||
Page::Mods => "Mods",
|
||||
Page::ShaderPacks => "Shader Packs",
|
||||
Page::ShaderPackOptions => "Shader Pack Settings",
|
||||
Page::Graphics | Page::GraphicsAdvanced => "options.videoTitle",
|
||||
Page::Controller => "Controller Settings",
|
||||
Page::PadBinds => "Controller Binds",
|
||||
@@ -1434,10 +1291,7 @@ impl OptionsUi {
|
||||
open("Graphics...", Page::Graphics),
|
||||
Some(open("Advanced Graphics...", Page::GraphicsAdvanced)),
|
||||
),
|
||||
Row::Buttons(
|
||||
open("Shader Packs...", Page::ShaderPacks),
|
||||
Some(open("Controller Settings...", Page::Controller)),
|
||||
),
|
||||
Row::Buttons(open("Controller Settings...", Page::Controller), None),
|
||||
Row::Buttons(
|
||||
open("Extras...", Page::Extras),
|
||||
Some(open("Mods...", Page::Mods)),
|
||||
@@ -1485,16 +1339,6 @@ impl OptionsUi {
|
||||
Act::Push(Page::GraphicsAdvanced),
|
||||
)),
|
||||
)];
|
||||
rows.push(Row::Buttons(
|
||||
button(
|
||||
0.0,
|
||||
0.0,
|
||||
310.0,
|
||||
Text::literal("Shader Packs..."),
|
||||
Act::Push(Page::ShaderPacks),
|
||||
),
|
||||
None,
|
||||
));
|
||||
rows.push(Row::Header("options.video.display.header"));
|
||||
rows.extend(pairs(&[
|
||||
Opt::MaxFps,
|
||||
@@ -1667,113 +1511,6 @@ impl OptionsUi {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Page::ShaderPacks => {
|
||||
let folder = &self.shader_packs;
|
||||
let mut rows = vec![Row::Note(folder.dir.display().to_string())];
|
||||
if folder.packs.is_empty() {
|
||||
rows.push(Row::Header(
|
||||
"No shader packs: put a pack's folder or .zip in this folder",
|
||||
));
|
||||
}
|
||||
for (i, (id, info)) in folder.packs.iter().enumerate() {
|
||||
let on = s.horizon.shader_pack == *id;
|
||||
let mut label = Text::literal(format!("{id}: ")).append(on_off(on));
|
||||
if info.is_err() || self.shader_pack_failed(id) {
|
||||
label = label.append(" (Failed)");
|
||||
}
|
||||
let widget = button(0.0, 0.0, 310.0, label, Act::ToggleShaderPack(i));
|
||||
rows.push(Row::Buttons(
|
||||
if info.is_ok() {
|
||||
widget
|
||||
} else {
|
||||
inactive(widget)
|
||||
},
|
||||
None,
|
||||
));
|
||||
}
|
||||
rows
|
||||
}
|
||||
Page::ShaderPackOptions => {
|
||||
let Some(cfg) = &self.shader_pack_config else {
|
||||
return vec![Row::Header("No configurable options for this shader pack")];
|
||||
};
|
||||
let screen = cfg.current_screen();
|
||||
let mut rows = vec![Row::Note(format!("{} — {}", cfg.id, screen.label))];
|
||||
if cfg.active_screen.is_some() {
|
||||
rows.push(Row::Buttons(
|
||||
button(
|
||||
0.0,
|
||||
0.0,
|
||||
310.0,
|
||||
Text::literal("Back to Main Screen"),
|
||||
Act::ShaderPackBackScreen,
|
||||
),
|
||||
None,
|
||||
));
|
||||
}
|
||||
let mut widgets = Vec::new();
|
||||
for item in &screen.items {
|
||||
match item {
|
||||
crate::shader_pack::ScreenItem::Empty => {}
|
||||
crate::shader_pack::ScreenItem::Profile => {
|
||||
let label =
|
||||
Text::literal(format!("Profile: {}", cfg.active_profile_label()));
|
||||
widgets.push(button(0.0, 0.0, 150.0, label, Act::ShaderPackProfile));
|
||||
}
|
||||
crate::shader_pack::ScreenItem::Subscreen(sub, title) => {
|
||||
let label = Text::literal(format!("{title}..."));
|
||||
widgets.push(button(
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
label,
|
||||
Act::ShaderPackSubscreen(sub.clone()),
|
||||
));
|
||||
}
|
||||
crate::shader_pack::ScreenItem::Option(idx) => {
|
||||
if let Some(opt) = cfg.options.get(*idx) {
|
||||
let label = Text::literal(format!(
|
||||
"{}: {}",
|
||||
opt.label,
|
||||
opt.display_value()
|
||||
));
|
||||
if let Some(value) = opt.slider_pos() {
|
||||
widgets.push(Widget {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
w: 150.0,
|
||||
h: 20.0,
|
||||
kind: WKind::ShaderOptionSlider {
|
||||
label,
|
||||
value,
|
||||
index: *idx,
|
||||
},
|
||||
list: false,
|
||||
});
|
||||
} else {
|
||||
widgets.push(button(
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
label,
|
||||
Act::ShaderPackOption(*idx),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut iter = widgets.into_iter();
|
||||
while let Some(mut first) = iter.next() {
|
||||
if let Some(second) = iter.next() {
|
||||
rows.push(Row::Buttons(first, Some(second)));
|
||||
} else {
|
||||
first.w = 310.0;
|
||||
rows.push(Row::Buttons(first, None));
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
_ => {
|
||||
let _ = s;
|
||||
Vec::new()
|
||||
@@ -1784,9 +1521,6 @@ impl OptionsUi {
|
||||
fn layout(&mut self, s: &Settings, w: f32, h: f32) -> (Vec<Widget>, f32, f32, f32) {
|
||||
let mut out = Vec::new();
|
||||
let page = self.page();
|
||||
if matches!(page, Page::ShaderPacks | Page::ShaderPackOptions) {
|
||||
self.sync_shader_pack_config(s);
|
||||
}
|
||||
let cx = (w / 2.0).floor();
|
||||
if matches!(page, Page::Video | Page::Controls | Page::KeyBinds) {
|
||||
self.settings_search.x = cx - 100.0;
|
||||
@@ -2176,10 +1910,6 @@ impl OptionsUi {
|
||||
if matches!(page, Page::Video | Page::Controls) {
|
||||
top = 38.0;
|
||||
}
|
||||
if page == Page::ShaderPacks {
|
||||
// Two footer rows, as Iris's ShaderPackScreen.
|
||||
bottom = h - 57.0;
|
||||
}
|
||||
out.push(Widget {
|
||||
x: 0.0,
|
||||
y: if matches!(page, Page::Video | Page::Controls) {
|
||||
@@ -2266,42 +1996,13 @@ impl OptionsUi {
|
||||
}
|
||||
content = y + self.scroll - top + 2.0;
|
||||
out[list_start..].iter_mut().for_each(|w| w.list = true);
|
||||
let folder = match page {
|
||||
Page::Mods => Some(("Open Mod Folder", Act::OpenModFolder)),
|
||||
Page::ShaderPackOptions => {
|
||||
Some(("Reset to Defaults", Act::ResetShaderPackOptions))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if page == Page::ShaderPacks {
|
||||
// Iris: the folder and the pack's settings above Done.
|
||||
out.push(button(
|
||||
cx - 154.0,
|
||||
h - 51.0,
|
||||
150.0,
|
||||
Text::literal("Open Shader Pack Folder"),
|
||||
Act::OpenShaderPackFolder,
|
||||
));
|
||||
let settings = button(
|
||||
cx + 4.0,
|
||||
h - 51.0,
|
||||
150.0,
|
||||
Text::literal("Shader Pack Settings..."),
|
||||
Act::Push(Page::ShaderPackOptions),
|
||||
);
|
||||
out.push(if self.shader_pack_has_settings(s) {
|
||||
settings
|
||||
} else {
|
||||
inactive(settings)
|
||||
});
|
||||
out.push(done(h - 27.0));
|
||||
} else if let Some((label, act)) = folder {
|
||||
if page == Page::Mods {
|
||||
out.push(button(
|
||||
cx - 154.0,
|
||||
h - 27.0,
|
||||
150.0,
|
||||
Text::literal(label),
|
||||
act,
|
||||
Text::literal("Open Mod Folder"),
|
||||
Act::OpenModFolder,
|
||||
));
|
||||
out.push(button(cx + 4.0, h - 27.0, 150.0, tr("gui.done"), Act::Done));
|
||||
} else {
|
||||
@@ -2331,9 +2032,6 @@ impl OptionsUi {
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
) {
|
||||
if self.shader_packs_shown() {
|
||||
self.shader_packs.poll(crate::shader_pack::folder());
|
||||
}
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
if let Some(error) = &self.resource_error {
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.in_world);
|
||||
@@ -2381,7 +2079,9 @@ impl OptionsUi {
|
||||
let blink = self.pack_focus.elapsed().as_millis() as u64;
|
||||
let m = crate::screen_draw::Measure(&atlas.font);
|
||||
let mouse = (cursor.0.floor() as i32, cursor.1.floor() as i32);
|
||||
let draws = self.packs(atlas, (w, h)).draw(&m, mouse, blink);
|
||||
let packs = self.packs(atlas, (w, h));
|
||||
let mut draws = packs.draw(&m, mouse, blink);
|
||||
packs.bind_icons(&mut draws, atlas, &mut batch.patches);
|
||||
crate::screen_draw::paint(batch, atlas, &draws);
|
||||
return;
|
||||
}
|
||||
@@ -2462,16 +2162,6 @@ impl OptionsUi {
|
||||
170.0,
|
||||
));
|
||||
}
|
||||
if let WKind::Button {
|
||||
act: Act::ShaderPackOption(i),
|
||||
..
|
||||
} = &wd.kind
|
||||
&& let Some(cfg) = &self.shader_pack_config
|
||||
&& let Some(opt) = cfg.options.get(*i)
|
||||
&& !opt.description.is_empty()
|
||||
{
|
||||
tooltip = Some(atlas.font.split(&Text::literal(&opt.description), 170.0));
|
||||
}
|
||||
}
|
||||
match &wd.kind {
|
||||
WKind::Button { label, active, act } => {
|
||||
@@ -2512,31 +2202,6 @@ impl OptionsUi {
|
||||
true,
|
||||
);
|
||||
}
|
||||
WKind::ShaderOptionSlider {
|
||||
label,
|
||||
value,
|
||||
index,
|
||||
} => {
|
||||
let dragging = self.dragging_shader_option == Some(*index);
|
||||
if hover
|
||||
&& let Some(cfg) = &self.shader_pack_config
|
||||
&& let Some(opt) = cfg.options.get(*index)
|
||||
&& !opt.description.is_empty()
|
||||
{
|
||||
tooltip = Some(atlas.font.split(&Text::literal(&opt.description), 170.0));
|
||||
}
|
||||
ui::slider(
|
||||
batch,
|
||||
atlas,
|
||||
wd.x,
|
||||
wd.y,
|
||||
wd.w,
|
||||
label,
|
||||
*value,
|
||||
hover || dragging,
|
||||
true,
|
||||
);
|
||||
}
|
||||
WKind::Text {
|
||||
text,
|
||||
color,
|
||||
@@ -2669,7 +2334,6 @@ impl OptionsUi {
|
||||
Action::ButtonClick => events.push(Event::ButtonClick),
|
||||
Action::Changed => events.push(Event::Changed),
|
||||
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
|
||||
Action::OpenShaderPacks => self.push(Page::ShaderPacks),
|
||||
Action::OpenSimple => {
|
||||
if self.stack.len() >= 2
|
||||
&& self.stack[self.stack.len() - 2] == Page::Graphics
|
||||
@@ -2751,19 +2415,6 @@ impl OptionsUi {
|
||||
set_slider(opt, s, v);
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
if let Some(idx) = self.dragging_shader_option
|
||||
&& let Some(wd) = widgets.iter().find(|wd| {
|
||||
matches!(&wd.kind, WKind::ShaderOptionSlider { index, .. } if *index == idx)
|
||||
})
|
||||
{
|
||||
let v = ui::slider_value(wd.x, wd.w, x);
|
||||
self.edit_shader_pack(|cfg| {
|
||||
if let Some(opt) = cfg.options.get_mut(idx) {
|
||||
opt.set_slider_pos(v);
|
||||
}
|
||||
});
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
// Controller Binds: a click cancels the wait.
|
||||
@@ -2844,41 +2495,8 @@ impl OptionsUi {
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
Act::OpenModFolder => events.push(Event::OpenModFolder),
|
||||
Act::ToggleShaderPack(i) => {
|
||||
self.toggle_shader_pack(s, i);
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
Act::OpenShaderPackFolder => events.push(Event::OpenShaderPackFolder),
|
||||
Act::Credits => events.push(Event::Credits),
|
||||
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
|
||||
Act::ShaderPackProfile => {
|
||||
self.edit_shader_pack(|cfg| cfg.cycle_profile(false));
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
Act::ShaderPackSubscreen(sub) => {
|
||||
if let Some(cfg) = &mut self.shader_pack_config {
|
||||
cfg.active_screen = Some(sub);
|
||||
}
|
||||
self.scroll = 0.0;
|
||||
}
|
||||
Act::ShaderPackBackScreen => {
|
||||
if let Some(cfg) = &mut self.shader_pack_config {
|
||||
cfg.active_screen = None;
|
||||
}
|
||||
self.scroll = 0.0;
|
||||
}
|
||||
Act::ShaderPackOption(i) => {
|
||||
self.edit_shader_pack(|cfg| {
|
||||
if let Some(opt) = cfg.options.get_mut(i) {
|
||||
opt.cycle(false);
|
||||
}
|
||||
});
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
Act::ResetShaderPackOptions => {
|
||||
self.edit_shader_pack(PackConfig::reset_all);
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
Act::None => {}
|
||||
}
|
||||
}
|
||||
@@ -2889,16 +2507,6 @@ impl OptionsUi {
|
||||
set_slider(opt, s, v);
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
WKind::ShaderOptionSlider { index, .. } => {
|
||||
self.dragging_shader_option = Some(index);
|
||||
let v = ui::slider_value(wd.x, wd.w, x);
|
||||
self.edit_shader_pack(|cfg| {
|
||||
if let Some(opt) = cfg.options.get_mut(index) {
|
||||
opt.set_slider_pos(v);
|
||||
}
|
||||
});
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
WKind::LanguageRow { code, .. } => {
|
||||
if self.language_choice.as_deref() == Some(code.as_str()) {
|
||||
events.extend(self.back(s));
|
||||
@@ -2927,7 +2535,7 @@ impl OptionsUi {
|
||||
}
|
||||
// AbstractSliderButton suppresses the press sound and plays once on
|
||||
// release, even if the pointer has left the widget or its value stayed.
|
||||
let slider = self.dragging.take().is_some() || self.dragging_shader_option.take().is_some();
|
||||
let slider = self.dragging.take().is_some();
|
||||
let graphics_slider = self.graphics.release();
|
||||
if slider || graphics_slider {
|
||||
vec![Event::ButtonClick]
|
||||
@@ -2983,14 +2591,6 @@ impl OptionsUi {
|
||||
/// Escape or Done: leave the current page, applying page results.
|
||||
pub fn back(&mut self, s: &mut Settings) -> Vec<Event> {
|
||||
let mut events = Vec::new();
|
||||
if self.page() == Page::ShaderPackOptions
|
||||
&& let Some(cfg) = &mut self.shader_pack_config
|
||||
&& cfg.active_screen.is_some()
|
||||
{
|
||||
cfg.active_screen = None;
|
||||
self.scroll = 0.0;
|
||||
return events;
|
||||
}
|
||||
let page = self.stack.pop().unwrap_or(Page::Main);
|
||||
self.scroll = 0.0;
|
||||
self.scrollbar_drag_y = None;
|
||||
@@ -3007,12 +2607,6 @@ impl OptionsUi {
|
||||
events.push(Event::Language(code));
|
||||
}
|
||||
}
|
||||
// Iris's ShaderPackScreen applies its edits on Done, so the
|
||||
// pack is rebuilt once, not on every click or slider step.
|
||||
Page::ShaderPackOptions if std::mem::take(&mut self.shader_pack_edited) => {
|
||||
self.save_shader_pack_config();
|
||||
events.push(Event::ReloadShaderPack);
|
||||
}
|
||||
Page::Packs => {
|
||||
// PackSelectionScreen.onClose: PackSelectionModel.commit.
|
||||
if let Some(p) = self.pack_screen.take()
|
||||
@@ -3337,177 +2931,6 @@ mod tests {
|
||||
assert!(ui.resource_error.is_none());
|
||||
}
|
||||
|
||||
/// The Shader Packs page (Video Settings > Shader Packs..., or the
|
||||
/// Shader Pack row of Advanced graphics): the folder's path, Open Shader
|
||||
/// Pack Folder, and a switch per pack (#941, #925).
|
||||
#[test]
|
||||
fn classic_shader_packs_page_shows_the_folder_and_picks_a_pack() {
|
||||
let atlas = atlas();
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
let size = (480.0, 240.0);
|
||||
let dir = std::env::temp_dir().join(format!("horizon-shaderpacks-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let find = |ui: &mut OptionsUi, s: &Settings, act: &dyn Fn(&Act) -> bool| {
|
||||
ui.layout(s, size.0, size.1)
|
||||
.0
|
||||
.into_iter()
|
||||
.find(|w| matches!(&w.kind, WKind::Button { act: a, active: true, .. } if act(a)))
|
||||
};
|
||||
ui.show(Page::Video);
|
||||
let open = find(&mut ui, &s, &|a| *a == Act::Push(Page::ShaderPacks)).unwrap();
|
||||
ui.click(
|
||||
&mut s,
|
||||
&atlas,
|
||||
open.x + 1.0,
|
||||
open.y + 1.0,
|
||||
size,
|
||||
[480, 240],
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!(ui.page(), Page::ShaderPacks);
|
||||
|
||||
ui.shader_packs.poll(dir.clone());
|
||||
let empty = ui.layout(&s, size.0, size.1).0;
|
||||
let path = dir.display().to_string();
|
||||
for line in [path.as_str(), "No shader packs"] {
|
||||
assert!(
|
||||
empty.iter().any(
|
||||
|w| matches!(&w.kind, WKind::Text { text, .. } if text.plain().contains(line))
|
||||
),
|
||||
"{line}"
|
||||
);
|
||||
}
|
||||
let folder = find(&mut ui, &s, &|a| *a == Act::OpenShaderPackFolder).unwrap();
|
||||
assert_eq!(
|
||||
ui.click(
|
||||
&mut s,
|
||||
&atlas,
|
||||
folder.x + 1.0,
|
||||
folder.y + 1.0,
|
||||
size,
|
||||
[480, 240],
|
||||
false,
|
||||
false
|
||||
),
|
||||
vec![Event::ButtonClick, Event::OpenShaderPackFolder]
|
||||
);
|
||||
|
||||
std::fs::write(dir.join("Pack.zip"), b"").unwrap();
|
||||
ui.shader_packs = Default::default();
|
||||
ui.shader_packs.poll(dir.clone());
|
||||
let pack = find(&mut ui, &s, &|a| *a == Act::ToggleShaderPack(0)).unwrap();
|
||||
let events = ui.click(
|
||||
&mut s,
|
||||
&atlas,
|
||||
pack.x + 1.0,
|
||||
pack.y + 1.0,
|
||||
size,
|
||||
[480, 240],
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert!(events.contains(&Event::Changed));
|
||||
assert_eq!(s.horizon.shader_pack, "Pack.zip");
|
||||
ui.shader_pack_error = Some(("Pack.zip".into(), "broken".into()));
|
||||
let label = |ui: &mut OptionsUi, s: &Settings| match find(ui, s, &|a| {
|
||||
*a == Act::ToggleShaderPack(0)
|
||||
})
|
||||
.unwrap()
|
||||
.kind
|
||||
{
|
||||
WKind::Button { label, .. } => label.plain(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert!(
|
||||
label(&mut ui, &s).ends_with("(Failed)"),
|
||||
"{}",
|
||||
label(&mut ui, &s)
|
||||
);
|
||||
ui.click(
|
||||
&mut s,
|
||||
&atlas,
|
||||
pack.x + 1.0,
|
||||
pack.y + 1.0,
|
||||
size,
|
||||
[480, 240],
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!(s.horizon.shader_pack, "", "a second click turns it off");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dropped_pack_is_picked_and_its_settings_apply_on_done() {
|
||||
let atlas = atlas();
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
let size = (480.0, 240.0);
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-shaderpack-drop-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
ui.shader_packs.poll(dir.clone());
|
||||
let sample = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../examples/shaderpacks/horizon-sample");
|
||||
assert_eq!(
|
||||
ui.drop_shader_pack(&mut s, &sample),
|
||||
None,
|
||||
"only on its page"
|
||||
);
|
||||
ui.show(Page::ShaderPacks);
|
||||
assert_eq!(ui.drop_shader_pack(&mut s, &sample), Some(Ok(())));
|
||||
assert_eq!(s.horizon.shader_pack, "horizon-sample");
|
||||
assert!(dir.join("horizon-sample/pack.toml").is_file());
|
||||
let again = ui.drop_shader_pack(&mut s, &sample).unwrap().unwrap_err();
|
||||
assert!(again.contains("already"), "{again}");
|
||||
|
||||
let click = |ui: &mut OptionsUi, s: &mut Settings, act: &dyn Fn(&Act) -> bool| {
|
||||
let w = ui
|
||||
.layout(s, size.0, size.1)
|
||||
.0
|
||||
.into_iter()
|
||||
.find(|w| matches!(&w.kind, WKind::Button { act: a, active: true, .. } if act(a)))
|
||||
.unwrap();
|
||||
ui.click(
|
||||
s,
|
||||
&atlas,
|
||||
w.x + 1.0,
|
||||
w.y + 1.0,
|
||||
size,
|
||||
[480, 240],
|
||||
false,
|
||||
false,
|
||||
)
|
||||
};
|
||||
click(&mut ui, &mut s, &|a| {
|
||||
*a == Act::Push(Page::ShaderPackOptions)
|
||||
});
|
||||
assert_eq!(ui.page(), Page::ShaderPackOptions);
|
||||
for _ in 0..3 {
|
||||
let events = click(&mut ui, &mut s, &|a| matches!(a, Act::ShaderPackOption(_)));
|
||||
assert!(!events.contains(&Event::ReloadShaderPack), "{events:?}");
|
||||
}
|
||||
let txt = dir.join("horizon-sample.txt");
|
||||
assert!(!txt.exists(), "nothing is saved before Done");
|
||||
let events = click(&mut ui, &mut s, &|a| *a == Act::Done);
|
||||
assert!(events.contains(&Event::ReloadShaderPack), "{events:?}");
|
||||
assert_eq!(ui.page(), Page::ShaderPacks);
|
||||
assert!(txt.is_file(), "Done saves the values");
|
||||
click(&mut ui, &mut s, &|a| {
|
||||
*a == Act::Push(Page::ShaderPackOptions)
|
||||
});
|
||||
let events = click(&mut ui, &mut s, &|a| *a == Act::Done);
|
||||
assert!(
|
||||
!events.contains(&Event::ReloadShaderPack),
|
||||
"no change: no reload"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_mods_page_toggles_mods_and_keeps_dev_and_invalid_entries_inactive() {
|
||||
let atlas = atlas();
|
||||
@@ -4618,7 +4041,6 @@ mod tests {
|
||||
[
|
||||
Page::Graphics,
|
||||
Page::GraphicsAdvanced,
|
||||
Page::ShaderPacks,
|
||||
Page::Controller,
|
||||
Page::Extras,
|
||||
Page::Mods,
|
||||
@@ -4652,7 +4074,7 @@ mod tests {
|
||||
// Vanilla's own pages keep the Horizon pages they had.
|
||||
ui.stack = vec![Page::Main, Page::Video];
|
||||
let video = pushes(&mut ui);
|
||||
for page in [Page::Graphics, Page::GraphicsAdvanced, Page::ShaderPacks] {
|
||||
for page in [Page::Graphics, Page::GraphicsAdvanced] {
|
||||
assert!(video.contains(&page), "{page:?}");
|
||||
}
|
||||
ui.stack = vec![Page::Main, Page::Controls];
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
//! [`PackScreen::draw`] gives the screen's GUI calls. The game shows it;
|
||||
//! tools/test_pack_screen_parity.py compares it with vanilla.
|
||||
use crate::{
|
||||
font::Patch,
|
||||
hud::{HudAtlas, PACK_ICON_SIZE, PACK_ICON_SLOTS},
|
||||
packs::{Position, Selectable, SelectionModel},
|
||||
screen_draw::{self as d, Draw, Measure, TextField},
|
||||
text::{Style, Text},
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
/// `PackCompatibility`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -50,8 +53,8 @@ pub struct PackInfo {
|
||||
pub description_style: Style,
|
||||
pub source: Source,
|
||||
pub compat: Compat,
|
||||
/// Whether the pack has a `pack.png`.
|
||||
pub icon: bool,
|
||||
/// The pack's `pack.png` ([`load_icon`]); none draws `unknown_pack`.
|
||||
pub icon: Option<Arc<[u8]>>,
|
||||
pub required: bool,
|
||||
pub position: Position,
|
||||
pub fixed: bool,
|
||||
@@ -90,6 +93,27 @@ impl PackInfo {
|
||||
(t, Style::color(d::GRAY))
|
||||
}
|
||||
}
|
||||
/// A `pack.png` as the pack screen draws it: RGBA, stretched to a
|
||||
/// `PACK_ICON_SIZE` square by nearest texels, as vanilla's icon texture is
|
||||
/// sampled into its 32x32 square. None when it does not decode, where
|
||||
/// `loadPackIcon` falls back to `unknown_pack`.
|
||||
pub fn load_icon(png: &[u8]) -> Option<Arc<[u8]>> {
|
||||
let (w, h, rgba) = crate::hud::decode_bytes(png).ok()?;
|
||||
if w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
let size = u64::from(PACK_ICON_SIZE);
|
||||
let mut out = Vec::with_capacity((size * size * 4) as usize);
|
||||
for y in 0..size {
|
||||
let sy = ((2 * y + 1) * u64::from(h) / (2 * size)) as usize;
|
||||
for x in 0..size {
|
||||
let sx = ((2 * x + 1) * u64::from(w) / (2 * size)) as usize;
|
||||
let at = (sy * w as usize + sx) * 4;
|
||||
out.extend_from_slice(&rgba[at..at + 4]);
|
||||
}
|
||||
}
|
||||
Some(out.into())
|
||||
}
|
||||
/// `PackSelectionScreen.loadPackIcon`'s texture for a pack with an icon.
|
||||
pub fn icon_texture(id: &str) -> String {
|
||||
let sanitized: String = id
|
||||
@@ -306,6 +330,8 @@ pub struct PackScreen {
|
||||
confirm: Option<Confirm>,
|
||||
/// The selection changed since the screen opened.
|
||||
pub changed: bool,
|
||||
/// The packs' icons by their [`icon_texture`].
|
||||
icons: HashMap<String, Arc<[u8]>>,
|
||||
}
|
||||
|
||||
fn tr(key: &str) -> Text {
|
||||
@@ -329,11 +355,16 @@ impl PackScreen {
|
||||
italic: Some(true),
|
||||
..Style::default()
|
||||
}));
|
||||
let icons = packs
|
||||
.iter()
|
||||
.filter_map(|p| Some((icon_texture(&p.id), p.icon.clone()?)))
|
||||
.collect();
|
||||
let mut s = Self {
|
||||
width,
|
||||
height,
|
||||
in_world,
|
||||
packs,
|
||||
icons,
|
||||
model,
|
||||
search,
|
||||
focus: Focus::None,
|
||||
@@ -760,6 +791,35 @@ impl PackScreen {
|
||||
pub fn commit(&self) -> Vec<String> {
|
||||
self.model.commit()
|
||||
}
|
||||
/// Points the icon blits of `draws` at the atlas's pack icon slots and
|
||||
/// writes the icons into them, one slot per icon drawn (vanilla registers a
|
||||
/// texture per pack). Icons past the last slot keep `unknown_pack`.
|
||||
pub fn bind_icons(&self, draws: &mut [Draw], atlas: &HudAtlas, patches: &mut Vec<Patch>) {
|
||||
let mut slots = 0..PACK_ICON_SLOTS;
|
||||
for draw in draws {
|
||||
let Draw::Blit { texture, .. } = draw else {
|
||||
continue;
|
||||
};
|
||||
let Some(icon) = self.icons.get(texture.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(slot) = slots.next() else {
|
||||
return;
|
||||
};
|
||||
let sprite = format!("horizon/pack-icon{slot}");
|
||||
let Some(rect) = atlas.sprites.get(&sprite) else {
|
||||
return;
|
||||
};
|
||||
patches.push(Patch {
|
||||
x: (rect.u0 * atlas.width as f32).round() as u32,
|
||||
y: (rect.v0 * atlas.height as f32).round() as u32,
|
||||
width: PACK_ICON_SIZE,
|
||||
height: PACK_ICON_SIZE,
|
||||
rgba: icon.to_vec(),
|
||||
});
|
||||
*texture = sprite;
|
||||
}
|
||||
}
|
||||
|
||||
/// The shown screen's calls (`Screen.extractRenderState`); `blink_ms`
|
||||
/// is the time since the search box took the focus.
|
||||
@@ -891,7 +951,7 @@ impl PackScreen {
|
||||
-8978432,
|
||||
);
|
||||
}
|
||||
let icon = if p.icon {
|
||||
let icon = if p.icon.is_some() {
|
||||
icon_texture(id)
|
||||
} else {
|
||||
"textures/misc/unknown_pack.png".into()
|
||||
@@ -979,6 +1039,106 @@ impl PackScreen {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod icon_tests {
|
||||
use super::*;
|
||||
|
||||
fn png(w: u32, h: u32, rgba: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut encoder = png::Encoder::new(&mut out, w, h);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(rgba)
|
||||
.unwrap();
|
||||
out
|
||||
}
|
||||
fn pack(id: &str, icon: Option<Arc<[u8]>>) -> PackInfo {
|
||||
PackInfo {
|
||||
id: id.into(),
|
||||
title: Text::literal(id),
|
||||
description: Text::literal(""),
|
||||
description_style: Style::default(),
|
||||
source: Source::BuiltIn,
|
||||
compat: Compat::Compatible,
|
||||
icon,
|
||||
required: false,
|
||||
position: Position::Top,
|
||||
fixed: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Any size stretches over the whole square, nearest texels, as the
|
||||
/// icon texture is sampled into its 32x32 blit.
|
||||
#[test]
|
||||
fn icons_stretch_to_the_slot_by_nearest_texels() {
|
||||
let (red, blue) = ([255, 0, 0, 255], [0, 0, 255, 128]);
|
||||
let icon = load_icon(&png(2, 1, &[red, blue].concat())).unwrap();
|
||||
let side = PACK_ICON_SIZE as usize;
|
||||
assert_eq!(icon.len(), side * side * 4);
|
||||
let at = |x: usize, y: usize| &icon[(y * side + x) * 4..][..4];
|
||||
assert_eq!((at(0, 0), at(side / 2 - 1, side - 1)), (&red[..], &red[..]));
|
||||
assert_eq!(
|
||||
(at(side / 2, 0), at(side - 1, side - 1)),
|
||||
(&blue[..], &blue[..])
|
||||
);
|
||||
assert!(load_icon(b"not a png").is_none());
|
||||
}
|
||||
|
||||
/// Programmer Art and High Contrast draw their own `pack.png`; a pack
|
||||
/// without one keeps `unknown_pack`.
|
||||
#[test]
|
||||
fn drawn_icons_take_atlas_slots() {
|
||||
let atlas =
|
||||
crate::hud::HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets"))
|
||||
.unwrap();
|
||||
let m = Measure(&atlas.font);
|
||||
let dirt = load_icon(&png(1, 1, &[134, 96, 67, 255])).unwrap();
|
||||
let contrast = load_icon(&png(1, 1, &[0, 0, 0, 255])).unwrap();
|
||||
let packs = vec![
|
||||
pack("programmer_art", Some(dirt.clone())),
|
||||
pack("high_contrast", Some(contrast.clone())),
|
||||
pack("file/Iconless", None),
|
||||
];
|
||||
let screen = PackScreen::new(&m, 480, 320, false, packs, &[]);
|
||||
let mut draws = screen.draw(&m, (0, 0), 0);
|
||||
let mut patches = Vec::new();
|
||||
screen.bind_icons(&mut draws, &atlas, &mut patches);
|
||||
let icons: Vec<&str> = draws
|
||||
.iter()
|
||||
.filter_map(|d| match d {
|
||||
Draw::Blit { texture, .. } if texture.contains("pack") => Some(texture.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
// The list shows them sorted by id.
|
||||
assert_eq!(
|
||||
icons,
|
||||
[
|
||||
"textures/misc/unknown_pack.png",
|
||||
"horizon/pack-icon0",
|
||||
"horizon/pack-icon1"
|
||||
]
|
||||
);
|
||||
assert_eq!(patches.len(), 2);
|
||||
for (i, (patch, icon)) in patches.iter().zip([&contrast, &dirt]).enumerate() {
|
||||
let rect = atlas.sprites[&format!("horizon/pack-icon{i}")];
|
||||
assert_eq!(
|
||||
(patch.x, patch.y, patch.width, patch.height),
|
||||
(
|
||||
(rect.u0 * atlas.width as f32).round() as u32,
|
||||
(rect.v0 * atlas.height as f32).round() as u32,
|
||||
PACK_ICON_SIZE,
|
||||
PACK_ICON_SIZE
|
||||
)
|
||||
);
|
||||
assert_eq!(&patch.rgba[..], &icon[..]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod click_sound_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -34,8 +34,6 @@ pub struct Pack {
|
||||
pub description_style: Style,
|
||||
pub compatible: bool,
|
||||
pub compatibility: crate::pack_screen::Compat,
|
||||
/// The pack has a `pack.png`.
|
||||
pub icon: bool,
|
||||
/// Overlay directories active for this game version, in `pack.mcmeta` order.
|
||||
pub overlays: Vec<String>,
|
||||
/// `filter.block` entries (namespace and path patterns) hiding resources
|
||||
@@ -260,6 +258,15 @@ fn filter_of(meta: &serde_json::Value) -> Vec<Filter> {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
impl Pack {
|
||||
/// The pack's `pack.png` (`PackResources.getRootResource`), for its icon.
|
||||
pub fn icon_png(&self) -> Option<Vec<u8>> {
|
||||
match &self.source {
|
||||
Source::Dir(d) => fs::read(d.join("pack.png")).ok(),
|
||||
Source::Zip(z) => Zip::open(z).ok()?.read("pack.png").ok(),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn read_meta(source: &Source) -> Option<serde_json::Value> {
|
||||
let bytes = match source {
|
||||
Source::Dir(d) => fs::read(d.join("pack.mcmeta")).ok()?,
|
||||
@@ -270,10 +277,6 @@ fn read_meta(source: &Source) -> Option<serde_json::Value> {
|
||||
fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
|
||||
let meta = read_meta(&source)?;
|
||||
let (raw_description, description_style) = component_root(&meta["pack"]["description"]);
|
||||
let icon = match &source {
|
||||
Source::Dir(d) => d.join("pack.png").is_file(),
|
||||
Source::Zip(z) => Zip::open(z).is_ok_and(|z| z.read("pack.png").is_ok()),
|
||||
};
|
||||
Some(Pack {
|
||||
id,
|
||||
title,
|
||||
@@ -282,7 +285,6 @@ fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
|
||||
description_style,
|
||||
compatible: compatible(&meta),
|
||||
compatibility: compatibility(&meta),
|
||||
icon,
|
||||
overlays: overlays_of(&meta),
|
||||
filter: filter_of(&meta),
|
||||
source,
|
||||
@@ -629,6 +631,17 @@ fn merge(kind: Merge, layers: &[Vec<u8>]) -> Option<Vec<u8>> {
|
||||
};
|
||||
serde_json::to_vec(&out).ok()
|
||||
}
|
||||
/// `DownloadedPackSource.loadRequestedPacks`: a server's downloaded pack,
|
||||
/// "World Specific Resources"; None when its `pack.mcmeta` can't be read. Its
|
||||
/// id names the file, which is named after its hash.
|
||||
pub fn server_pack(id: u128, path: &Path) -> Option<Pack> {
|
||||
let file = path.file_name()?.to_string_lossy();
|
||||
pack(
|
||||
format!("server/{id:032x}/{file}"),
|
||||
Text::translate("resourcePack.server.name", &[]),
|
||||
Source::Zip(path.to_path_buf()),
|
||||
)
|
||||
}
|
||||
/// The packs to apply for an options.txt `resourcePacks` list (bottom first).
|
||||
pub fn enabled(all: &[Pack], ids: &[String]) -> Vec<Pack> {
|
||||
ids.iter()
|
||||
@@ -983,6 +996,45 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
#[test]
|
||||
fn built_in_and_folder_packs_give_their_pack_png() {
|
||||
use std::io::Write as _;
|
||||
let base = std::env::temp_dir().join(format!("horizon-pack-icons-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
let meta = br#"{"pack":{"description":"d","pack_format":97}}"#;
|
||||
let built_in = base.join("vanilla/assets/minecraft/resourcepacks");
|
||||
fs::create_dir_all(&built_in).unwrap();
|
||||
let mut zip =
|
||||
zip::ZipWriter::new(fs::File::create(built_in.join("programmer_art.zip")).unwrap());
|
||||
for (name, data) in [("pack.mcmeta", &meta[..]), ("pack.png", b"dirt")] {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
zip.write_all(data).unwrap();
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
for (name, icon) in [("Iconless", None), ("Mine", Some(&b"mine"[..]))] {
|
||||
let dir = base.join("packs").join(name);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(dir.join("pack.mcmeta"), meta).unwrap();
|
||||
if let Some(icon) = icon {
|
||||
fs::write(dir.join("pack.png"), icon).unwrap();
|
||||
}
|
||||
}
|
||||
let packs = discover(&base.join("vanilla"), &base.join("packs"));
|
||||
let icons: Vec<_> = packs
|
||||
.iter()
|
||||
.map(|p| (p.id.as_str(), p.icon_png()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
icons,
|
||||
[
|
||||
("programmer_art", Some(b"dirt".to_vec())),
|
||||
("file/Iconless", None),
|
||||
("file/Mine", Some(b"mine".to_vec())),
|
||||
]
|
||||
);
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
#[test]
|
||||
fn overlays_and_filters() {
|
||||
let v = |s: &str| serde_json::from_str::<serde_json::Value>(s).unwrap();
|
||||
let meta = v(r#"{"overlays":{"entries":[
|
||||
|
||||
@@ -3233,7 +3233,7 @@ impl ParticleEngine {
|
||||
} => {
|
||||
p.base_tick(level);
|
||||
let (fluid, source) = level.fluid_source(ParticleLevel::block_at(p.position));
|
||||
if !p.removed && !(fluid == Fluid::Water && source) {
|
||||
if !(p.removed || (fluid == Fluid::Water && source)) {
|
||||
p.removed = true;
|
||||
}
|
||||
if !p.removed && p.position[1] >= y_end {
|
||||
|
||||
@@ -0,0 +1,618 @@
|
||||
//! Server resource packs (`ServerPackManager`, `DownloadedPackSource`): the
|
||||
//! packs a server pushes, the player's answer to its prompt, their downloads,
|
||||
//! their activation on top of the player's own packs, and the status each one
|
||||
//! reports back to the server. The game drives it: it shows the prompt,
|
||||
//! starts the downloads [`ServerPacks::tick`] asks for and reloads the
|
||||
//! resources with [`ServerPacks::active`] when it asks for a reload.
|
||||
pub mod download;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// `ServerboundResourcePackPacket.Action`, by its protocol number.
|
||||
pub mod action {
|
||||
pub const SUCCESSFULLY_LOADED: i32 = 0;
|
||||
pub const DECLINED: i32 = 1;
|
||||
pub const FAILED_DOWNLOAD: i32 = 2;
|
||||
pub const ACCEPTED: i32 = 3;
|
||||
pub const DOWNLOADED: i32 = 4;
|
||||
pub const INVALID_URL: i32 = 5;
|
||||
pub const FAILED_RELOAD: i32 = 6;
|
||||
pub const DISCARDED: i32 = 7;
|
||||
}
|
||||
|
||||
/// `ServerPackManager.PackPromptStatus`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Prompt {
|
||||
Pending,
|
||||
Allowed,
|
||||
Declined,
|
||||
}
|
||||
impl Prompt {
|
||||
/// `ConnectScreen.convertPackStatus`: a server list entry's setting
|
||||
/// (servers.dat `acceptTextures`; none is Prompt).
|
||||
pub fn for_server(accept_textures: Option<bool>) -> Self {
|
||||
match accept_textures {
|
||||
Some(true) => Self::Allowed,
|
||||
Some(false) => Self::Declined,
|
||||
None => Self::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ClientCommonPacketListenerImpl.parseResourcePackUrl`: only http and https.
|
||||
pub fn valid_url(url: &str) -> bool {
|
||||
url.split_once(':').is_some_and(|(scheme, _)| {
|
||||
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
|
||||
})
|
||||
}
|
||||
|
||||
/// `DownloadedPackSource.tryParseSha1Hash`: 40 hex digits, else no check.
|
||||
pub fn parse_sha1(hash: &str) -> Option<String> {
|
||||
(hash.len() == 40 && hash.bytes().all(|b| b.is_ascii_hexdigit()))
|
||||
.then(|| hash.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
/// One pack to download (`DownloadQueue.DownloadRequest`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Request {
|
||||
pub id: u128,
|
||||
pub url: String,
|
||||
pub sha1: Option<String>,
|
||||
}
|
||||
|
||||
/// `DownloadQueue.BatchResult`: the file each downloaded pack is in, and the
|
||||
/// packs that failed.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Batch {
|
||||
pub downloaded: Vec<(u128, PathBuf)>,
|
||||
pub failed: Vec<u128>,
|
||||
}
|
||||
|
||||
/// What the game has to do after a [`ServerPacks::tick`].
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub struct Tick {
|
||||
/// Download these, then hand the result to [`ServerPacks::downloaded`].
|
||||
pub download: Vec<Request>,
|
||||
/// Reload the resources with these packs on top, bottom first
|
||||
/// (`PackReloadConfig.scheduleReload`), then call [`ServerPacks::reloaded`].
|
||||
pub reload: Option<Vec<(u128, PathBuf)>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Download {
|
||||
Requested,
|
||||
Pending,
|
||||
Done,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Activation {
|
||||
Inactive,
|
||||
Pending,
|
||||
Active,
|
||||
}
|
||||
/// `ServerPackManager.RemovalReason`, with the status it reports.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Removal {
|
||||
DownloadFailed,
|
||||
ActivationFailed,
|
||||
Declined,
|
||||
Discarded,
|
||||
ServerRemoved,
|
||||
ServerReplaced,
|
||||
}
|
||||
impl Removal {
|
||||
fn reply(self) -> Option<i32> {
|
||||
match self {
|
||||
Self::DownloadFailed => Some(action::FAILED_DOWNLOAD),
|
||||
Self::ActivationFailed => Some(action::FAILED_RELOAD),
|
||||
Self::Declined => Some(action::DECLINED),
|
||||
Self::Discarded => Some(action::DISCARDED),
|
||||
Self::ServerRemoved | Self::ServerReplaced => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerPackManager.ServerPackData`.
|
||||
#[derive(Debug)]
|
||||
struct Pack {
|
||||
id: u128,
|
||||
url: String,
|
||||
sha1: Option<String>,
|
||||
path: Option<PathBuf>,
|
||||
removal: Option<Removal>,
|
||||
download: Download,
|
||||
activation: Activation,
|
||||
accepted: bool,
|
||||
}
|
||||
impl Pack {
|
||||
fn remove(&mut self, reason: Removal) {
|
||||
self.removal.get_or_insert(reason);
|
||||
}
|
||||
fn removed(&self) -> bool {
|
||||
self.removal.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// The reload in progress: the packs it loads and unloads (the callbacks
|
||||
/// `triggerReloadIfNeeded` gives `scheduleReload`).
|
||||
#[derive(Debug)]
|
||||
struct Reload {
|
||||
load: Vec<u128>,
|
||||
unload: Vec<u128>,
|
||||
/// It reloads what was active before a failed one (`isRecovery`).
|
||||
recovery: bool,
|
||||
}
|
||||
|
||||
/// `ServerPackManager` with the feedback `DownloadedPackSource` gives it.
|
||||
#[derive(Debug)]
|
||||
pub struct ServerPacks {
|
||||
packs: Vec<Pack>,
|
||||
prompt: Prompt,
|
||||
reload: Option<Reload>,
|
||||
/// `registerForUpdate`: something changed, [`Self::tick`] again.
|
||||
update: bool,
|
||||
/// Report to the server (`configureForServerControl`); otherwise the
|
||||
/// statuses are only logged, as for a local world.
|
||||
reporting: bool,
|
||||
replies: Vec<(u128, i32)>,
|
||||
}
|
||||
impl Default for ServerPacks {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
packs: Vec::new(),
|
||||
prompt: Prompt::Pending,
|
||||
reload: None,
|
||||
update: false,
|
||||
reporting: false,
|
||||
replies: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl ServerPacks {
|
||||
fn report(&mut self, id: u128, status: i32) {
|
||||
log::debug!("Horizon: server pack {id:032x} changed status to {status}");
|
||||
if self.reporting {
|
||||
self.replies.push((id, status));
|
||||
}
|
||||
}
|
||||
/// The statuses to send the server, oldest first.
|
||||
pub fn take_replies(&mut self) -> Vec<(u128, i32)> {
|
||||
std::mem::take(&mut self.replies)
|
||||
}
|
||||
/// Whether [`Self::tick`] has something to do.
|
||||
pub fn wants_tick(&self) -> bool {
|
||||
self.update
|
||||
}
|
||||
|
||||
/// `DownloadedPackSource.configureForServerControl`.
|
||||
pub fn connect(&mut self, prompt: Prompt) {
|
||||
self.reporting = true;
|
||||
match prompt {
|
||||
Prompt::Allowed => self.allow(),
|
||||
Prompt::Declined => self.reject(),
|
||||
Prompt::Pending => self.prompt = Prompt::Pending,
|
||||
}
|
||||
}
|
||||
/// `DownloadedPackSource.configureForLocalWorld`: single player's own
|
||||
/// server; its packs are taken, and nothing is reported.
|
||||
pub fn local_world(&mut self) {
|
||||
self.reporting = false;
|
||||
self.allow();
|
||||
}
|
||||
/// `DownloadedPackSource.cleanupAfterDisconnect`: every pack goes, and
|
||||
/// the next server starts from its own setting.
|
||||
pub fn disconnect(&mut self) {
|
||||
self.pop_all();
|
||||
self.reporting = false;
|
||||
self.replies.clear();
|
||||
self.prompt = Prompt::Pending;
|
||||
}
|
||||
|
||||
pub fn push(&mut self, id: u128, url: &str, sha1: Option<String>) {
|
||||
if self.prompt == Prompt::Declined {
|
||||
self.report(id, action::DECLINED);
|
||||
return;
|
||||
}
|
||||
for pack in self.packs.iter_mut().filter(|p| p.id == id) {
|
||||
pack.remove(Removal::ServerReplaced);
|
||||
}
|
||||
self.packs.push(Pack {
|
||||
id,
|
||||
url: url.to_owned(),
|
||||
sha1,
|
||||
path: None,
|
||||
removal: None,
|
||||
download: Download::Requested,
|
||||
activation: Activation::Inactive,
|
||||
accepted: false,
|
||||
});
|
||||
if self.prompt == Prompt::Allowed {
|
||||
self.accept(self.packs.len() - 1);
|
||||
}
|
||||
self.update = true;
|
||||
}
|
||||
fn accept(&mut self, i: usize) {
|
||||
self.packs[i].accepted = true;
|
||||
let id = self.packs[i].id;
|
||||
self.report(id, action::ACCEPTED);
|
||||
}
|
||||
pub fn pop(&mut self, id: u128) {
|
||||
if let Some(pack) = self.packs.iter_mut().find(|p| !p.removed() && p.id == id) {
|
||||
pack.remove(Removal::ServerRemoved);
|
||||
self.update = true;
|
||||
}
|
||||
}
|
||||
pub fn pop_all(&mut self) {
|
||||
for pack in &mut self.packs {
|
||||
pack.remove(Removal::ServerRemoved);
|
||||
}
|
||||
self.update = true;
|
||||
}
|
||||
/// `allowServerPacks`: the prompt said yes (or the server is Enabled).
|
||||
pub fn allow(&mut self) {
|
||||
self.prompt = Prompt::Allowed;
|
||||
for i in 0..self.packs.len() {
|
||||
if !self.packs[i].accepted && !self.packs[i].removed() {
|
||||
self.accept(i);
|
||||
}
|
||||
}
|
||||
self.update = true;
|
||||
}
|
||||
/// `rejectServerPacks`: the prompt said no (or the server is Disabled).
|
||||
pub fn reject(&mut self) {
|
||||
self.prompt = Prompt::Declined;
|
||||
for pack in self.packs.iter_mut().filter(|p| !p.accepted) {
|
||||
pack.remove(Removal::Declined);
|
||||
}
|
||||
self.update = true;
|
||||
}
|
||||
|
||||
/// `ServerPackManager.tick`: start the downloads the accepted packs
|
||||
/// need; once none is left, reload if the active packs should change;
|
||||
/// then forget the packs that are gone, reporting why.
|
||||
pub fn tick(&mut self) -> Tick {
|
||||
self.update = false;
|
||||
let mut out = Tick::default();
|
||||
let mut downloading = false;
|
||||
for pack in self.packs.iter_mut().filter(|p| !p.removed() && p.accepted) {
|
||||
downloading |= pack.download != Download::Done;
|
||||
if pack.download == Download::Requested {
|
||||
pack.download = Download::Pending;
|
||||
out.download.push(Request {
|
||||
id: pack.id,
|
||||
url: pack.url.clone(),
|
||||
sha1: pack.sha1.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if !downloading {
|
||||
out.reload = self.reload_if_needed();
|
||||
}
|
||||
let mut gone = Vec::new();
|
||||
self.packs.retain(|p| {
|
||||
let keep = p.activation != Activation::Inactive || p.removal.is_none();
|
||||
if !keep && let Some(reply) = p.removal.and_then(Removal::reply) {
|
||||
gone.push((p.id, reply));
|
||||
}
|
||||
keep
|
||||
});
|
||||
for (id, reply) in gone {
|
||||
self.report(id, reply);
|
||||
}
|
||||
out
|
||||
}
|
||||
fn reload_if_needed(&mut self) -> Option<Vec<(u128, PathBuf)>> {
|
||||
if self.reload.is_some()
|
||||
|| self
|
||||
.packs
|
||||
.iter()
|
||||
.any(|p| p.activation == Activation::Pending)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut changed = false;
|
||||
let (mut load, mut unload) = (Vec::new(), Vec::new());
|
||||
for pack in &self.packs {
|
||||
let wanted = pack.accepted && pack.download == Download::Done && !pack.removed();
|
||||
match pack.activation {
|
||||
Activation::Inactive if wanted => {
|
||||
load.push(pack.id);
|
||||
changed = true;
|
||||
}
|
||||
Activation::Active if wanted => load.push(pack.id),
|
||||
Activation::Active => {
|
||||
unload.push(pack.id);
|
||||
changed = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return None;
|
||||
}
|
||||
for pack in &mut self.packs {
|
||||
let loading = load.contains(&pack.id) && pack.activation != Activation::Active;
|
||||
if loading || unload.contains(&pack.id) {
|
||||
pack.activation = Activation::Pending;
|
||||
}
|
||||
}
|
||||
let paths = self.paths(&load);
|
||||
self.reload = Some(Reload {
|
||||
load,
|
||||
unload,
|
||||
recovery: false,
|
||||
});
|
||||
Some(paths)
|
||||
}
|
||||
/// The packs to load (`PackReloadConfig.Callbacks.packsToLoad`), bottom first.
|
||||
fn paths(&self, ids: &[u128]) -> Vec<(u128, PathBuf)> {
|
||||
ids.iter()
|
||||
.filter_map(|id| {
|
||||
let pack = self
|
||||
.packs
|
||||
.iter()
|
||||
.find(|p| p.id == *id && p.path.is_some())?;
|
||||
Some((*id, pack.path.clone()?))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `ServerPackManager.onDownload`.
|
||||
pub fn downloaded(&mut self, batch: Batch) {
|
||||
if !batch.failed.is_empty() {
|
||||
for pack in self
|
||||
.packs
|
||||
.iter_mut()
|
||||
.filter(|p| p.activation != Activation::Active)
|
||||
{
|
||||
pack.remove(if batch.failed.contains(&pack.id) {
|
||||
Removal::DownloadFailed
|
||||
} else {
|
||||
Removal::Discarded
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut done = Vec::new();
|
||||
for (id, path) in batch.downloaded {
|
||||
if let Some(pack) = self
|
||||
.packs
|
||||
.iter_mut()
|
||||
.find(|p| p.id == id && p.download == Download::Pending)
|
||||
{
|
||||
pack.download = Download::Done;
|
||||
pack.path = Some(path);
|
||||
if !pack.removed() {
|
||||
done.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
for id in done {
|
||||
self.report(id, action::DOWNLOADED);
|
||||
}
|
||||
self.update = true;
|
||||
}
|
||||
|
||||
/// The reload [`Tick::reload`] asked for finished (`Callbacks.onSuccess`),
|
||||
/// or its packs could not be loaded (`onFailure`). After a first failure
|
||||
/// the packs that were active are loaded again: the packs to reload with
|
||||
/// are returned, and this is called again with that reload's outcome.
|
||||
pub fn reloaded(&mut self, ok: bool) -> Option<Vec<(u128, PathBuf)>> {
|
||||
let mut reload = self.reload.take()?;
|
||||
self.update = true;
|
||||
if ok {
|
||||
let mut applied = Vec::new();
|
||||
for pack in &mut self.packs {
|
||||
if reload.load.contains(&pack.id) {
|
||||
pack.activation = Activation::Active;
|
||||
if pack.removal.is_none() {
|
||||
applied.push(pack.id);
|
||||
}
|
||||
} else if reload.unload.contains(&pack.id) {
|
||||
pack.activation = Activation::Inactive;
|
||||
}
|
||||
}
|
||||
for id in applied {
|
||||
self.report(id, action::SUCCESSFULLY_LOADED);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
if reload.recovery {
|
||||
// onFailure(true): nothing of the server's is loaded.
|
||||
for pack in &mut self.packs {
|
||||
if pack.activation == Activation::Pending {
|
||||
pack.activation = Activation::Inactive;
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
reload.load.clear();
|
||||
for pack in &mut self.packs {
|
||||
match pack.activation {
|
||||
Activation::Inactive => pack.remove(Removal::Discarded),
|
||||
Activation::Pending => {
|
||||
pack.activation = Activation::Inactive;
|
||||
pack.remove(Removal::ActivationFailed);
|
||||
}
|
||||
Activation::Active => reload.load.push(pack.id),
|
||||
}
|
||||
}
|
||||
reload.recovery = true;
|
||||
let paths = self.paths(&reload.load);
|
||||
self.reload = Some(reload);
|
||||
Some(paths)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use action::*;
|
||||
|
||||
fn ok(id: u128) -> Batch {
|
||||
Batch {
|
||||
downloaded: vec![(id, PathBuf::from(format!("/p/{id}")))],
|
||||
failed: vec![],
|
||||
}
|
||||
}
|
||||
/// Runs the updates as `createUpdateScheduler` does, downloading and
|
||||
/// reloading with `download` and `reload` as the game would.
|
||||
fn settle(
|
||||
sp: &mut ServerPacks,
|
||||
download: &mut dyn FnMut(&[Request]) -> Batch,
|
||||
reload: &mut dyn FnMut(&[(u128, PathBuf)]) -> bool,
|
||||
) {
|
||||
let mut rounds = 0;
|
||||
while sp.wants_tick() {
|
||||
rounds += 1;
|
||||
assert!(rounds < 50);
|
||||
let t = sp.tick();
|
||||
if !t.download.is_empty() {
|
||||
let batch = download(&t.download);
|
||||
sp.downloaded(batch);
|
||||
}
|
||||
if let Some(mut packs) = t.reload {
|
||||
while let Some(back) = sp.reloaded(reload(&packs)) {
|
||||
packs = back;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_enabled_server_s_pack_is_accepted_downloaded_and_loaded() {
|
||||
let mut sp = ServerPacks::default();
|
||||
sp.connect(Prompt::Allowed);
|
||||
sp.push(1, "http://a/p.zip", None);
|
||||
let mut loaded = Vec::new();
|
||||
settle(&mut sp, &mut |r| ok(r[0].id), &mut |p| {
|
||||
loaded = p.to_vec();
|
||||
true
|
||||
});
|
||||
assert_eq!(
|
||||
sp.take_replies(),
|
||||
[(1, ACCEPTED), (1, DOWNLOADED), (1, SUCCESSFULLY_LOADED)]
|
||||
);
|
||||
assert_eq!(loaded, [(1, PathBuf::from("/p/1"))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_prompt_holds_packs_until_answered() {
|
||||
let mut sp = ServerPacks::default();
|
||||
sp.connect(Prompt::Pending);
|
||||
sp.push(1, "http://a/p.zip", None);
|
||||
settle(&mut sp, &mut |_| unreachable!(), &mut |_| unreachable!());
|
||||
assert!(sp.take_replies().is_empty());
|
||||
sp.reject();
|
||||
settle(&mut sp, &mut |_| unreachable!(), &mut |_| unreachable!());
|
||||
assert_eq!(sp.take_replies(), [(1, DECLINED)]);
|
||||
// Declined: later pushes are declined at once.
|
||||
sp.push(2, "http://a/q.zip", None);
|
||||
assert_eq!(sp.take_replies(), [(2, DECLINED)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_download_discards_the_rest_of_the_batch() {
|
||||
let mut sp = ServerPacks::default();
|
||||
sp.connect(Prompt::Allowed);
|
||||
sp.push(1, "http://a/p.zip", None);
|
||||
sp.push(2, "http://a/dead.zip", None);
|
||||
settle(
|
||||
&mut sp,
|
||||
&mut |_| Batch {
|
||||
downloaded: vec![(1, PathBuf::from("/p/1"))],
|
||||
failed: vec![2],
|
||||
},
|
||||
&mut |_| unreachable!(),
|
||||
);
|
||||
assert_eq!(
|
||||
sp.take_replies(),
|
||||
[
|
||||
(1, ACCEPTED),
|
||||
(2, ACCEPTED),
|
||||
(1, DISCARDED),
|
||||
(2, FAILED_DOWNLOAD)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_that_does_not_load_fails_its_reload() {
|
||||
let mut sp = ServerPacks::default();
|
||||
sp.connect(Prompt::Allowed);
|
||||
sp.push(1, "http://a/p.zip", None);
|
||||
let mut reloads = Vec::new();
|
||||
settle(&mut sp, &mut |r| ok(r[0].id), &mut |p| {
|
||||
reloads.push(p.to_vec());
|
||||
reloads.len() > 1
|
||||
});
|
||||
assert_eq!(
|
||||
sp.take_replies(),
|
||||
[(1, ACCEPTED), (1, DOWNLOADED), (1, FAILED_RELOAD)]
|
||||
);
|
||||
// The recovery reload has none of the server's packs.
|
||||
assert_eq!(reloads[1], []);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn popping_and_disconnecting_unload_the_packs() {
|
||||
let mut sp = ServerPacks::default();
|
||||
sp.connect(Prompt::Allowed);
|
||||
sp.push(1, "http://a/p.zip", None);
|
||||
sp.push(2, "http://a/q.zip", None);
|
||||
let last = std::cell::RefCell::new(Vec::new());
|
||||
let mut reload = |p: &[(u128, PathBuf)]| {
|
||||
*last.borrow_mut() = p.to_vec();
|
||||
true
|
||||
};
|
||||
settle(
|
||||
&mut sp,
|
||||
&mut |r| Batch {
|
||||
downloaded: r
|
||||
.iter()
|
||||
.map(|q| (q.id, PathBuf::from(format!("/p/{}", q.id))))
|
||||
.collect(),
|
||||
failed: vec![],
|
||||
},
|
||||
&mut reload,
|
||||
);
|
||||
sp.take_replies();
|
||||
sp.pop(1);
|
||||
settle(&mut sp, &mut |_| unreachable!(), &mut reload);
|
||||
// Callbacks.onSuccess reports every pack the reload loaded, again.
|
||||
assert_eq!(sp.take_replies(), [(2, SUCCESSFULLY_LOADED)]);
|
||||
assert_eq!(*last.borrow(), [(2, PathBuf::from("/p/2"))]);
|
||||
sp.disconnect();
|
||||
settle(&mut sp, &mut |_| unreachable!(), &mut reload);
|
||||
assert!(last.borrow().is_empty());
|
||||
assert!(sp.packs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pushed_id_replaces_its_pack() {
|
||||
let mut sp = ServerPacks::default();
|
||||
sp.connect(Prompt::Allowed);
|
||||
sp.push(1, "http://a/old.zip", None);
|
||||
sp.push(1, "http://a/new.zip", None);
|
||||
let mut urls = Vec::new();
|
||||
settle(
|
||||
&mut sp,
|
||||
&mut |r| {
|
||||
urls.extend(r.iter().map(|q| q.url.clone()));
|
||||
ok(1)
|
||||
},
|
||||
&mut |_| true,
|
||||
);
|
||||
assert_eq!(urls, ["http://a/new.zip"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urls_and_hashes_are_read_as_vanilla_reads_them() {
|
||||
assert!(valid_url("https://example.com/p.zip"));
|
||||
assert!(valid_url("HTTP://example.com/p.zip"));
|
||||
assert!(!valid_url("ftp://example.com/p.zip"));
|
||||
assert!(!valid_url("example.com/p.zip"));
|
||||
let sha1 = "0123456789ABCDEF0123456789abcdef01234567";
|
||||
assert_eq!(parse_sha1(sha1), Some(sha1.to_ascii_lowercase()));
|
||||
assert_eq!(parse_sha1("abc"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
//! Server pack downloads into the game directory's `downloads/` cache, as
|
||||
//! vanilla keeps it (`DownloadQueue`, `HttpUtil.downloadFile`,
|
||||
//! `DownloadCacheCleaner`): `downloads/<pack id>/<SHA-1 of the file>`, one
|
||||
//! batch at a time on a thread of its own, every download logged in
|
||||
//! `downloads/log.json`, and the cache cut to its 20 newest files on start.
|
||||
use super::{Batch, Request};
|
||||
use sha1::{Digest, Sha1};
|
||||
use std::{
|
||||
fs,
|
||||
io::{self, Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::mpsc::{Receiver, Sender, channel},
|
||||
time::SystemTime,
|
||||
};
|
||||
|
||||
/// `DownloadedPackSource`'s MAX_PACK_SIZE_BYTES (250 MiB).
|
||||
pub const MAX_SIZE: u64 = 262_144_000;
|
||||
/// `DownloadQueue.MAX_KEPT_PACKS`.
|
||||
const MAX_KEPT: usize = 20;
|
||||
|
||||
/// `createDownloadHeaders`: who is downloading, with which game.
|
||||
pub fn headers(name: &str, uuid: u128) -> Vec<(&'static str, String)> {
|
||||
use horizon_core::version::{RESOURCE_PACK_FORMAT, VANILLA_VERSION, VERSION_ID};
|
||||
vec![
|
||||
("X-Minecraft-Username", name.to_owned()),
|
||||
("X-Minecraft-UUID", format!("{uuid:032x}")),
|
||||
("X-Minecraft-Version", VANILLA_VERSION.to_owned()),
|
||||
("X-Minecraft-Version-ID", VERSION_ID.to_owned()),
|
||||
(
|
||||
"X-Minecraft-Pack-Format",
|
||||
format!("{}.{}", RESOURCE_PACK_FORMAT.0, RESOURCE_PACK_FORMAT.1),
|
||||
),
|
||||
("User-Agent", format!("Minecraft Java/{VANILLA_VERSION}")),
|
||||
]
|
||||
}
|
||||
|
||||
type Job = (Vec<Request>, Vec<(&'static str, String)>);
|
||||
|
||||
/// `DownloadQueue`: batches download in order, on one thread.
|
||||
pub struct Queue {
|
||||
jobs: Sender<Job>,
|
||||
done: Receiver<Batch>,
|
||||
}
|
||||
impl Queue {
|
||||
pub fn open(dir: PathBuf) -> io::Result<Self> {
|
||||
fs::create_dir_all(&dir)?;
|
||||
vacuum(&dir, MAX_KEPT);
|
||||
let (jobs, todo) = channel::<Job>();
|
||||
let (finished, done) = channel();
|
||||
std::thread::Builder::new()
|
||||
.name("download-queue".into())
|
||||
.spawn(move || {
|
||||
for (requests, headers) in todo {
|
||||
if finished.send(run(&dir, &requests, &headers)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(Self { jobs, done })
|
||||
}
|
||||
pub fn download(&self, requests: Vec<Request>, headers: Vec<(&'static str, String)>) {
|
||||
let _ = self.jobs.send((requests, headers));
|
||||
}
|
||||
/// The batches finished since the last call.
|
||||
pub fn finished(&self) -> Vec<Batch> {
|
||||
self.done.try_iter().collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// `DownloadQueue.runDownload`: each pack into `<dir>/<id>/`, logged.
|
||||
fn run(dir: &Path, requests: &[Request], headers: &[(&'static str, String)]) -> Batch {
|
||||
let mut batch = Batch::default();
|
||||
for request in requests {
|
||||
let id = horizon_protocol::crypto::uuid_string(request.id);
|
||||
let result = download_file(&dir.join(&id), request, headers, MAX_SIZE);
|
||||
// DownloadQueue.LogEntry, its fields in the codec's order.
|
||||
let mut entry = vec![
|
||||
("id", serde_json::Value::from(id)),
|
||||
("url", request.url.as_str().into()),
|
||||
(
|
||||
"time",
|
||||
chrono::Utc::now()
|
||||
.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true)
|
||||
.into(),
|
||||
),
|
||||
];
|
||||
if let Some(sha1) = &request.sha1 {
|
||||
entry.push(("hash", sha1.as_str().into()));
|
||||
}
|
||||
entry.push(match &result {
|
||||
Ok(path) => match fs::metadata(path) {
|
||||
Ok(meta) => {
|
||||
let name = path.strip_prefix(dir).unwrap_or(path);
|
||||
let name = name.to_string_lossy().replace('\\', "/");
|
||||
(
|
||||
"file",
|
||||
serde_json::json!({"name": name, "size": meta.len()}),
|
||||
)
|
||||
}
|
||||
Err(_) => ("error", "no_access".into()),
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("Horizon: failed to download {}: {e}", request.url);
|
||||
("error", "download_failed".into())
|
||||
}
|
||||
});
|
||||
if let Err(e) = append_log(&dir.join("log.json"), &entry) {
|
||||
log::error!("Horizon: failed to log download of {}: {e}", request.url);
|
||||
}
|
||||
match result {
|
||||
Ok(path) => batch.downloaded.push((request.id, path)),
|
||||
Err(_) => batch.failed.push(request.id),
|
||||
}
|
||||
}
|
||||
batch
|
||||
}
|
||||
|
||||
/// `JsonEventLog.write`: one JSON object a line, as Gson writes it (with
|
||||
/// its HTML escaping).
|
||||
fn append_log(path: &Path, entry: &[(&str, serde_json::Value)]) -> io::Result<()> {
|
||||
let fields: Vec<String> = entry
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}:{v}", serde_json::Value::from(*k)))
|
||||
.collect();
|
||||
let mut line = String::new();
|
||||
for c in format!("{{{}}}", fields.join(",")).chars() {
|
||||
match c {
|
||||
'<' | '>' | '&' | '=' | '\'' => line.push_str(&format!("\\u{:04x}", c as u32)),
|
||||
c => line.push(c),
|
||||
}
|
||||
}
|
||||
line.push('\n');
|
||||
fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)?
|
||||
.write_all(line.as_bytes())
|
||||
}
|
||||
|
||||
fn sha1_of(path: &Path) -> io::Result<String> {
|
||||
let mut hasher = Sha1::new();
|
||||
io::copy(&mut fs::File::open(path)?, &mut hasher)?;
|
||||
Ok(hex(&hasher.finalize()))
|
||||
}
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
/// `checkExistingFile`: the file is there with that hash.
|
||||
fn matches(path: &Path, sha1: &str) -> bool {
|
||||
path.exists() && sha1_of(path).is_ok_and(|actual| actual == sha1)
|
||||
}
|
||||
/// `updateModificationTime`: used now, so the cleaner keeps it longest.
|
||||
fn touch(path: &Path) {
|
||||
let _ = fs::File::options()
|
||||
.append(true)
|
||||
.open(path)
|
||||
.and_then(|f| f.set_modified(SystemTime::now()));
|
||||
}
|
||||
|
||||
/// `HttpUtil.downloadFile`: into `<dir>/<SHA-1>`. With a hash the cached file
|
||||
/// is used when it matches, and a download that does not is an error;
|
||||
/// without one the file is named after the hash of what came.
|
||||
pub fn download_file(
|
||||
dir: &Path,
|
||||
request: &Request,
|
||||
headers: &[(&'static str, String)],
|
||||
max_size: u64,
|
||||
) -> Result<PathBuf, String> {
|
||||
let target = request.sha1.as_ref().map(|sha1| dir.join(sha1));
|
||||
if let (Some(target), Some(sha1)) = (&target, &request.sha1) {
|
||||
if matches(target, sha1) {
|
||||
log::info!("Horizon: returning cached file since actual hash matches requested");
|
||||
touch(target);
|
||||
return Ok(target.clone());
|
||||
}
|
||||
if target.exists() {
|
||||
log::warn!(
|
||||
"Horizon: existing file {} not found or had mismatched hash",
|
||||
target.display()
|
||||
);
|
||||
}
|
||||
fs::remove_file(target)
|
||||
.or_else(|e| {
|
||||
if e.kind() == io::ErrorKind::NotFound {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(e)
|
||||
}
|
||||
})
|
||||
.map_err(|e| format!("Failed to remove existing file {}: {e}", target.display()))?;
|
||||
}
|
||||
let mut call = ureq::AgentBuilder::new().build().get(&request.url);
|
||||
for (name, value) in headers {
|
||||
call = call.set(name, value);
|
||||
}
|
||||
let response = call.call().map_err(|e| e.to_string())?;
|
||||
let size = response
|
||||
.header("Content-Length")
|
||||
.and_then(|v| v.trim().parse::<u64>().ok());
|
||||
fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
if let Some(size) = size
|
||||
&& size > max_size
|
||||
{
|
||||
return Err(format!(
|
||||
"Filesize is bigger than maximum allowed (file is {size}, limit is {max_size})"
|
||||
));
|
||||
}
|
||||
let mut input = response.into_reader();
|
||||
match (&target, &request.sha1) {
|
||||
(Some(target), Some(sha1)) => {
|
||||
let actual = save_hashed(&mut input, target, max_size).map_err(|e| e.to_string())?;
|
||||
if &actual != sha1 {
|
||||
return Err(format!(
|
||||
"Hash of downloaded file ({actual}) did not match requested ({sha1})"
|
||||
));
|
||||
}
|
||||
Ok(target.clone())
|
||||
}
|
||||
_ => {
|
||||
let tmp = dir.join(format!("download{:016x}.tmp", rand_suffix()));
|
||||
let result = save_hashed(&mut input, &tmp, max_size).and_then(|actual| {
|
||||
let path = dir.join(&actual);
|
||||
if matches(&path, &actual) {
|
||||
touch(&path);
|
||||
} else {
|
||||
fs::rename(&tmp, &path)?;
|
||||
}
|
||||
Ok(path)
|
||||
});
|
||||
let _ = fs::remove_file(&tmp);
|
||||
result.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
fn rand_suffix() -> u64 {
|
||||
use std::hash::{BuildHasher, RandomState};
|
||||
RandomState::new().hash_one(std::time::Instant::now())
|
||||
}
|
||||
/// `downloadAndHash`: write what comes, at most `max_size` bytes.
|
||||
fn save_hashed(input: &mut dyn Read, path: &Path, max_size: u64) -> io::Result<String> {
|
||||
let mut out = fs::File::create(path)?;
|
||||
let mut hasher = Sha1::new();
|
||||
let mut buffer = [0u8; 8196];
|
||||
let mut read_so_far = 0u64;
|
||||
loop {
|
||||
let n = input.read(&mut buffer)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
read_so_far += n as u64;
|
||||
if read_so_far > max_size {
|
||||
return Err(io::Error::other(format!(
|
||||
"Filesize was bigger than maximum allowed (got >= {read_so_far}, limit was {max_size})"
|
||||
)));
|
||||
}
|
||||
out.write_all(&buffer[..n])?;
|
||||
hasher.update(&buffer[..n]);
|
||||
}
|
||||
Ok(hex(&hasher.finalize()))
|
||||
}
|
||||
|
||||
/// `DownloadCacheCleaner.vacuumCacheDir`: keep the `max_files` files most
|
||||
/// worth keeping (in the pack folders: the newest, and one per pack before a
|
||||
/// second of any), then drop the folders left empty.
|
||||
pub fn vacuum(dir: &Path, max_files: usize) {
|
||||
let mut files = Vec::new();
|
||||
let mut stack = vec![dir.to_path_buf()];
|
||||
while let Some(d) = stack.pop() {
|
||||
let Ok(entries) = fs::read_dir(&d) else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let path = e.path();
|
||||
let Ok(meta) = e.metadata() else { continue };
|
||||
if meta.is_dir() {
|
||||
stack.push(path);
|
||||
} else if meta.is_file() && path.parent() != Some(dir) {
|
||||
let time = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
|
||||
files.push((path, time));
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(remove) = files.len().checked_sub(max_files).filter(|n| *n > 0) else {
|
||||
return;
|
||||
};
|
||||
// Newest first, then each file's rank in its folder (0 for a folder's
|
||||
// newest), reversed, and stably sorted by rank, highest first.
|
||||
files.sort_by_key(|f| std::cmp::Reverse(f.1));
|
||||
let mut counts = std::collections::HashMap::<PathBuf, usize>::new();
|
||||
let mut ranked: Vec<(PathBuf, usize)> = files
|
||||
.into_iter()
|
||||
.map(|(path, _)| {
|
||||
let count = counts
|
||||
.entry(path.parent().unwrap_or(dir).to_path_buf())
|
||||
.or_default();
|
||||
let rank = *count;
|
||||
*count += 1;
|
||||
(path, rank)
|
||||
})
|
||||
.collect();
|
||||
ranked.reverse();
|
||||
ranked.sort_by_key(|f| std::cmp::Reverse(f.1));
|
||||
let mut emptied = std::collections::HashSet::new();
|
||||
for (path, rank) in ranked.into_iter().take(remove) {
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) if rank == 0 => {
|
||||
emptied.insert(path.parent().unwrap_or(dir).to_path_buf());
|
||||
}
|
||||
Ok(()) => {}
|
||||
Err(e) => log::warn!(
|
||||
"Horizon: failed to delete cache file {}: {e}",
|
||||
path.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
emptied.remove(dir);
|
||||
for d in emptied {
|
||||
let _ = fs::remove_dir(d);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::TcpListener;
|
||||
|
||||
/// A one-shot HTTP server answering `status` with `body`; the request it got.
|
||||
fn serve(status: &str, body: Vec<u8>) -> (String, std::thread::JoinHandle<String>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let url = format!("http://{}/pack.zip", listener.local_addr().unwrap());
|
||||
let status = status.to_owned();
|
||||
let handle = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut request = Vec::new();
|
||||
let mut buf = [0u8; 1024];
|
||||
while !request.ends_with(b"\r\n\r\n") {
|
||||
let n = stream.read(&mut buf).unwrap();
|
||||
request.extend_from_slice(&buf[..n]);
|
||||
}
|
||||
let head = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
stream.write_all(head.as_bytes()).unwrap();
|
||||
stream.write_all(&body).unwrap();
|
||||
String::from_utf8_lossy(&request).into_owned()
|
||||
});
|
||||
(url, handle)
|
||||
}
|
||||
fn sha1(bytes: &[u8]) -> String {
|
||||
hex(&Sha1::digest(bytes))
|
||||
}
|
||||
fn scratch() -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-packs-{:016x}", rand_suffix()));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_lands_under_its_hash_and_is_reused_from_the_cache() {
|
||||
let dir = scratch();
|
||||
let body = b"PK pack".to_vec();
|
||||
let (url, server) = serve("200 OK", body.clone());
|
||||
let request = Request {
|
||||
id: 7,
|
||||
url,
|
||||
sha1: Some(sha1(&body)),
|
||||
};
|
||||
let path = download_file(&dir, &request, &headers("Alex", 0xab), MAX_SIZE).unwrap();
|
||||
assert_eq!(path, dir.join(sha1(&body)));
|
||||
assert_eq!(fs::read(&path).unwrap(), body);
|
||||
let sent = server.join().unwrap();
|
||||
assert!(sent.contains("X-Minecraft-Username: Alex"), "{sent}");
|
||||
assert!(sent.contains("X-Minecraft-Pack-Format: 97.1"), "{sent}");
|
||||
// Cached: no server this time.
|
||||
let cached = Request {
|
||||
url: "http://127.0.0.1:9/gone.zip".into(),
|
||||
..request
|
||||
};
|
||||
assert_eq!(download_file(&dir, &cached, &[], MAX_SIZE).unwrap(), path);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_hash_a_dead_url_and_a_large_pack_fail() {
|
||||
let dir = scratch();
|
||||
let (url, server) = serve("200 OK", b"other bytes".to_vec());
|
||||
let wrong = Request {
|
||||
id: 1,
|
||||
url,
|
||||
sha1: Some("0".repeat(40)),
|
||||
};
|
||||
let err = download_file(&dir, &wrong, &[], MAX_SIZE).unwrap_err();
|
||||
assert!(err.contains("did not match requested"), "{err}");
|
||||
server.join().unwrap();
|
||||
let dead = Request {
|
||||
id: 2,
|
||||
url: "http://127.0.0.1:9/pack.zip".into(),
|
||||
sha1: None,
|
||||
};
|
||||
assert!(download_file(&dir.join("dead"), &dead, &[], MAX_SIZE).is_err());
|
||||
assert!(!dir.join("dead").exists());
|
||||
let (url, server) = serve("200 OK", vec![0; 100]);
|
||||
let large = Request {
|
||||
id: 3,
|
||||
url,
|
||||
sha1: None,
|
||||
};
|
||||
let err = download_file(&dir, &large, &[], 10).unwrap_err();
|
||||
assert!(err.contains("bigger than maximum"), "{err}");
|
||||
let _ = server.join();
|
||||
let (url, server) = serve("404 Not Found", Vec::new());
|
||||
let missing = Request {
|
||||
id: 4,
|
||||
url,
|
||||
sha1: None,
|
||||
};
|
||||
assert!(download_file(&dir, &missing, &[], MAX_SIZE).is_err());
|
||||
server.join().unwrap();
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_queue_logs_each_download() {
|
||||
let dir = scratch();
|
||||
let (url, server) = serve("200 OK", b"zip".to_vec());
|
||||
let queue = Queue::open(dir.clone()).unwrap();
|
||||
let id = 0x5b2c_7a4e_1f3d_4c8b_9a6e_2d4f_8b1c_3e5a;
|
||||
queue.download(
|
||||
vec![Request {
|
||||
id,
|
||||
url,
|
||||
sha1: None,
|
||||
}],
|
||||
Vec::new(),
|
||||
);
|
||||
let batch = loop {
|
||||
if let Some(b) = queue.finished().pop() {
|
||||
break b;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
};
|
||||
server.join().unwrap();
|
||||
let file = dir
|
||||
.join("5b2c7a4e-1f3d-4c8b-9a6e-2d4f8b1c3e5a")
|
||||
.join(sha1(b"zip"));
|
||||
assert_eq!(batch.downloaded, [(id, file)]);
|
||||
let log = fs::read_to_string(dir.join("log.json")).unwrap();
|
||||
let entry: serde_json::Value = serde_json::from_str(log.trim()).unwrap();
|
||||
assert_eq!(entry["id"], "5b2c7a4e-1f3d-4c8b-9a6e-2d4f8b1c3e5a");
|
||||
assert_eq!(entry["file"]["size"], 3);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cleaner_keeps_the_newest_file_of_each_pack_longest() {
|
||||
let dir = scratch();
|
||||
let at = |secs| SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs);
|
||||
for (pack, file, time) in [
|
||||
("a", "1", 10),
|
||||
("a", "2", 20),
|
||||
("a", "3", 30),
|
||||
("b", "1", 5),
|
||||
] {
|
||||
fs::create_dir_all(dir.join(pack)).unwrap();
|
||||
let path = dir.join(pack).join(file);
|
||||
fs::write(&path, b"x").unwrap();
|
||||
fs::File::options()
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.unwrap()
|
||||
.set_modified(at(time))
|
||||
.unwrap();
|
||||
}
|
||||
fs::write(dir.join("log.json"), b"").unwrap();
|
||||
vacuum(&dir, 2);
|
||||
assert!(dir.join("a/3").exists() && dir.join("b/1").exists());
|
||||
assert!(!dir.join("a/1").exists() && !dir.join("a/2").exists());
|
||||
assert!(dir.join("log.json").exists());
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,71 +0,0 @@
|
||||
// Horizon shader pack prelude, api 1 (docs/shader-packs.md): placed before
|
||||
// every stage's source. Bindings, the Frame uniform, the full-screen vertex
|
||||
// shader and helpers that hide the renderer's reversed depth.
|
||||
struct Frame {
|
||||
view: mat4x4<f32>,
|
||||
projection: mat4x4<f32>,
|
||||
inverse_view: mat4x4<f32>,
|
||||
inverse_projection: mat4x4<f32>,
|
||||
camera_position: vec4<f32>,
|
||||
resolution: vec4<f32>,
|
||||
time: vec4<f32>,
|
||||
sun: vec4<f32>,
|
||||
moon: vec4<f32>,
|
||||
sky_color: vec4<f32>,
|
||||
fog_color: vec4<f32>,
|
||||
fog: vec4<f32>,
|
||||
weather: vec4<f32>,
|
||||
camera_state: vec4<f32>,
|
||||
view_params: vec4<f32>,
|
||||
dimension: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var scene_color: texture_2d<f32>;
|
||||
@group(0) @binding(1) var scene_depth: texture_depth_2d;
|
||||
@group(0) @binding(2) var previous: texture_2d<f32>;
|
||||
@group(0) @binding(3) var linear_sampler: sampler;
|
||||
@group(0) @binding(4) var nearest_sampler: sampler;
|
||||
@group(0) @binding(5) var<uniform> frame: Frame;
|
||||
|
||||
struct StageIn {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
}
|
||||
@vertex fn horizon_stage_vertex(@builtin(vertex_index) i: u32) -> StageIn {
|
||||
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
var out: StageIn;
|
||||
out.position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
out.uv = vec2(p.x, 1.0 - p.y);
|
||||
return out;
|
||||
}
|
||||
|
||||
// The world's depth at `uv`: 1 at the near plane, 0 for the sky.
|
||||
fn depth_at(uv: vec2<f32>) -> f32 {
|
||||
return textureSampleLevel(scene_depth, nearest_sampler, uv, 0);
|
||||
}
|
||||
fn is_sky(depth: f32) -> bool {
|
||||
return depth <= 0.0;
|
||||
}
|
||||
// Blocks from the eye along the view axis.
|
||||
fn linear_depth(depth: f32) -> f32 {
|
||||
let near = frame.view_params.x;
|
||||
let far = frame.view_params.y;
|
||||
return near * far / ((far - near) * depth + near);
|
||||
}
|
||||
fn view_position(uv: vec2<f32>, depth: f32) -> vec3<f32> {
|
||||
let v = frame.inverse_projection * vec4(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, depth, 1.0);
|
||||
return v.xyz / v.w;
|
||||
}
|
||||
// World position relative to the eye (add frame.camera_position.xyz for the
|
||||
// absolute one).
|
||||
fn world_offset(uv: vec2<f32>, depth: f32) -> vec3<f32> {
|
||||
return (frame.inverse_view * vec4(view_position(uv, depth), 1.0)).xyz;
|
||||
}
|
||||
fn luminance(c: vec3<f32>) -> f32 {
|
||||
return dot(c, vec3(0.2126, 0.7152, 0.0722));
|
||||
}
|
||||
fn srgb_to_linear(c: vec3<f32>) -> vec3<f32> {
|
||||
return select(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, c <= vec3(0.04045));
|
||||
}
|
||||
fn linear_to_srgb(c: vec3<f32>) -> vec3<f32> {
|
||||
return select(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, c <= vec3(0.0031308));
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
// Horizon shader pack prelude, api 2 additions (docs/shader-packs.md): placed
|
||||
// after the api 1 prelude before the stages of an `api = 2` pack, so api 1
|
||||
// packs keep every name they may have declared themselves.
|
||||
struct FrameExt {
|
||||
previous_view: mat4x4<f32>,
|
||||
previous_projection: mat4x4<f32>,
|
||||
shadow_view: mat4x4<f32>,
|
||||
shadow_projection: mat4x4<f32>,
|
||||
shadow_inverse_view: mat4x4<f32>,
|
||||
shadow_inverse_projection: mat4x4<f32>,
|
||||
// xyz: previous camera world position, w: frame delta time (seconds)
|
||||
previous_camera: vec4<f32>,
|
||||
// x: shadow map resolution, y: shadow distance (blocks), z: 0,
|
||||
// w: 1 while the shadow map is drawn this frame, else 0
|
||||
shadow_params: vec4<f32>,
|
||||
// x: eye block light (0..240), y: eye sky light (0..240), z: smoothed
|
||||
// rain (0..1), w: 0
|
||||
environment: vec4<f32>,
|
||||
}
|
||||
|
||||
@group(0) @binding(6) var<uniform> frame_ext: FrameExt;
|
||||
@group(0) @binding(7) var scene_normal: texture_2d<f32>;
|
||||
@group(0) @binding(8) var scene_material: texture_2d<f32>;
|
||||
@group(0) @binding(9) var opaque_depth: texture_depth_2d;
|
||||
@group(0) @binding(10) var shadow_depth: texture_depth_2d;
|
||||
@group(0) @binding(11) var shadow_sampler: sampler_comparison;
|
||||
@group(0) @binding(12) var noise_texture: texture_2d<f32>;
|
||||
@group(0) @binding(13) var repeat_sampler: sampler;
|
||||
|
||||
// The opaque world's depth at `uv` (before translucent water and stained glass).
|
||||
fn opaque_depth_at(uv: vec2<f32>) -> f32 {
|
||||
return textureSampleLevel(opaque_depth, nearest_sampler, uv, 0);
|
||||
}
|
||||
|
||||
// World-space surface normal at `uv` from the G-buffer (or reconstructed from depth).
|
||||
fn world_normal_at(uv: vec2<f32>) -> vec3<f32> {
|
||||
let encoded = textureSampleLevel(scene_normal, nearest_sampler, uv, 0.0);
|
||||
if encoded.w > 0.001 {
|
||||
return normalize(encoded.xyz * 2.0 - 1.0);
|
||||
}
|
||||
let step_uv = frame.resolution.zw;
|
||||
let d0 = depth_at(uv);
|
||||
let p0 = world_offset(uv, d0);
|
||||
let px = world_offset(uv + vec2(step_uv.x, 0.0), depth_at(uv + vec2(step_uv.x, 0.0))) - p0;
|
||||
let py = world_offset(uv + vec2(0.0, step_uv.y), depth_at(uv + vec2(0.0, step_uv.y))) - p0;
|
||||
let n = cross(py, px);
|
||||
let len = length(n);
|
||||
if len > 1e-6 {
|
||||
return n / len;
|
||||
}
|
||||
return vec3(0.0, 1.0, 0.0);
|
||||
}
|
||||
// G-buffer material data at `uv`: r = block light (0..1), g = sky light (0..1),
|
||||
// b = water/translucent mask (0..1), a = roughness (0..1).
|
||||
fn material_at(uv: vec2<f32>) -> vec4<f32> {
|
||||
return textureSampleLevel(scene_material, nearest_sampler, uv, 0.0);
|
||||
}
|
||||
// Reproject a camera-relative world position into previous-frame UV and clip depth.
|
||||
fn reproject_uv(world_rel: vec3<f32>) -> vec3<f32> {
|
||||
let camera_delta = frame.camera_position.xyz - frame_ext.previous_camera.xyz;
|
||||
let prev_rel = world_rel + camera_delta;
|
||||
let clip = frame_ext.previous_projection * (frame_ext.previous_view * vec4(prev_rel, 1.0));
|
||||
if clip.w <= 1e-5 {
|
||||
return vec3(-1.0);
|
||||
}
|
||||
let ndc = clip.xyz / clip.w;
|
||||
return vec3(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5, ndc.z);
|
||||
}
|
||||
// Sample the directional sun/moon shadow map with 5-tap PCF and normal offset bias.
|
||||
// Returns 1.0 in full light and 0.0 in full shadow.
|
||||
fn sample_shadow(world_rel: vec3<f32>, world_normal: vec3<f32>) -> f32 {
|
||||
if frame_ext.shadow_params.w <= 0.001 {
|
||||
return 1.0;
|
||||
}
|
||||
let dist = length(world_rel);
|
||||
let max_dist = max(frame_ext.shadow_params.y, 16.0);
|
||||
if dist >= max_dist {
|
||||
return 1.0;
|
||||
}
|
||||
let light_dir = normalize(select(frame.moon.xyz, frame.sun.xyz, frame.sun.y >= -0.05));
|
||||
let ndotl = max(dot(world_normal, light_dir), 0.0);
|
||||
let bias_pos = world_rel + world_normal * (0.06 + 0.12 * (1.0 - ndotl));
|
||||
let light_clip = frame_ext.shadow_projection * (frame_ext.shadow_view * vec4(bias_pos, 1.0));
|
||||
let ndc = light_clip.xyz / max(light_clip.w, 1e-5);
|
||||
let shadow_uv = vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
|
||||
if any(shadow_uv < vec2(0.002)) || any(shadow_uv > vec2(0.998)) || ndc.z <= 0.0 || ndc.z >= 1.0 {
|
||||
return 1.0;
|
||||
}
|
||||
let texel = 1.0 / max(frame_ext.shadow_params.x, 512.0);
|
||||
let ref_z = clamp(ndc.z + 0.0008, 0.0, 1.0);
|
||||
var sum = textureSampleCompareLevel(shadow_depth, shadow_sampler, shadow_uv, ref_z);
|
||||
sum += textureSampleCompareLevel(shadow_depth, shadow_sampler, shadow_uv + vec2(texel, 0.0), ref_z);
|
||||
sum += textureSampleCompareLevel(shadow_depth, shadow_sampler, shadow_uv - vec2(texel, 0.0), ref_z);
|
||||
sum += textureSampleCompareLevel(shadow_depth, shadow_sampler, shadow_uv + vec2(0.0, texel), ref_z);
|
||||
sum += textureSampleCompareLevel(shadow_depth, shadow_sampler, shadow_uv - vec2(0.0, texel), ref_z);
|
||||
let fade = smoothstep(max_dist, max_dist * 0.82, dist);
|
||||
return mix(1.0, sum * 0.2, fade * frame_ext.shadow_params.w);
|
||||
}
|
||||
|
||||
// ACES fitted filmic tonemapping curve (linear HDR -> linear 0..1).
|
||||
fn aces_tonemap(x: vec3<f32>) -> vec3<f32> {
|
||||
let a = 2.51;
|
||||
let b = 0.03;
|
||||
let c = 2.43;
|
||||
let d = 0.59;
|
||||
let e = 0.14;
|
||||
return clamp((x * (a * x + b)) / (x * (c * x + d) + e), vec3(0.0), vec3(1.0));
|
||||
}
|
||||
@@ -153,7 +153,6 @@ struct MeshSettings {
|
||||
blend_radius: i32,
|
||||
smooth_lighting: bool,
|
||||
cutout_leaves: bool,
|
||||
cull_leaves: bool,
|
||||
}
|
||||
enum Job {
|
||||
Generate(i32, i32),
|
||||
@@ -258,7 +257,6 @@ pub struct Streaming {
|
||||
/// The `ao` and `cutoutLeaves` options.
|
||||
pub smooth_lighting: bool,
|
||||
pub cutout_leaves: bool,
|
||||
pub cull_leaves: bool,
|
||||
/// Incremental relighting of block edits on lit chunks (see `light::relight`).
|
||||
light_tables: Arc<LightTables>,
|
||||
/// Applied edits by sequence, replayed onto light jobs snapshotted before them.
|
||||
@@ -500,7 +498,6 @@ impl Streaming {
|
||||
blend_radius: settings.blend_radius,
|
||||
smooth_lighting: settings.smooth_lighting,
|
||||
cutout_leaves: settings.cutout_leaves,
|
||||
cull_leaves: settings.cull_leaves,
|
||||
assets: &context.assets,
|
||||
render: &context.render,
|
||||
tables: &context.tables,
|
||||
@@ -556,7 +553,6 @@ impl Streaming {
|
||||
biome_blend: 2,
|
||||
smooth_lighting: true,
|
||||
cutout_leaves: true,
|
||||
cull_leaves: false,
|
||||
light_tables,
|
||||
light_log: Vec::new(),
|
||||
light_edit_sequence: 0,
|
||||
@@ -1177,7 +1173,6 @@ impl Streaming {
|
||||
blend_radius: self.biome_blend,
|
||||
smooth_lighting: self.smooth_lighting,
|
||||
cutout_leaves: self.cutout_leaves,
|
||||
cull_leaves: self.cull_leaves,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -215,7 +215,6 @@ fn relit_columns_keep_every_mesh_current() {
|
||||
blend_radius: 2,
|
||||
smooth_lighting: true,
|
||||
cutout_leaves: true,
|
||||
cull_leaves: false,
|
||||
assets: &context.assets,
|
||||
render: &context.render,
|
||||
tables: &context.tables,
|
||||
|
||||
Loaded 100 of 576 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user