Compare commits

..
Author SHA1 Message Date
levzzz ddb317c771 Merge branch 'pr-5909-new' into mod-api-rework 2026-11-01 20:40:22 +05:00
[email protected]andClaude Opus 5.5 aef97f1e43 Merge upstream/main into staging/unified-hmod
Upstream added --player-dir handling to the startup code in main.rs. With the
library split that code lives in entry.rs, so the change is applied there and
main.rs stays the thin launcher.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:35:47 +01:00
levzzz 3f3bdfa663 Merge branch 'pr-5909' into mod-api-rework 2026-11-01 20:24:21 +05:00
levzzzandClaude Opus 5.5 5482b975fb Merge hr/main into the mod API rework (#5446)
main.rs moved to entry.rs on this branch; carry main's --player-dir
log/crash folder handling over to entry.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:18:53 +05:00
[email protected]andClaude Opus 5.5 18a02fe574 Merge native/api-mods (#5446) into staging/unified-hmod
Combined staging branch: the game library split (#5446) and the single mod
file format (#5909) together, as agreed to land in one release. No conflicts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:17:21 +01:00
[email protected]andClaude Opus 5.5 8421d34846 Merge upstream/main into native/api-mods
Upstream added `pub mod lod_net;` to the old lib.rs module list, which this PR
moved to client_lib.rs: add it there. Upstream's new horizon-optifine
dependency is used by mobs/cem.rs, a reusable-client module, so it goes in
horizon-client-lib's manifest instead of horizon-client's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:03:03 +01:00
[email protected]andCodex 9e18079acc fix(parity): migrate broad saved client target selections too
Co-Authored-By: Codex <[email protected]>
2026-11-01 14:41:20 +01:00
[email protected]andClaude Opus 5.5 cb11c1d221 Block states: drop a needless borrow clippy 1.99 refuses
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:45:12 +01:00
[email protected] 2aa6aa28cd Merge remote-tracking branch 'upstream/main' into staging/unified-hmod 2026-11-01 13:43:44 +01:00
[email protected]andClaude Opus 5.5 df6e0f0547 Mods: keep publishing .hnmod, write the mod file format down
Review on #5909: native versions published as .hmod would reach
launchers that drop their content or refuse them. The registry keeps
.hnmod until launchers and the game read native .hmods.

docs/api/mod-files.md holds every rule of a .hmod/.hnmod in one
place: zip and path rules, top-level entries, the horizon-native.json
schema, what mixins/<id>/ may hold and the size limits.

The registry's Cargo.lock catches up with horizon-mods' png
dependency so its --locked check passes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:35:43 +01:00
[email protected]andClaude Opus 5.5 f2b508c4f2 test(client): read the loot hook source independent of line endings
loot_table_load_stays_hookable searched for "\n}\n", which a CRLF checkout
(core.autocrlf=true on Windows) never contains. Normalize as the other
source-reading test in this file does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:11:46 +01:00
[email protected]andClaude Opus 5.5 02122e2532 Mods: one .hmod for content and native code (WIP, #5865)
A .hmod's contents decide what it is: content only, native code
(horizon-native.json + mixins/<id>/), or both in one file. Native
bundle reading skips a .hmod's content entries, checks both
manifests name one mod, and refuses mod.wasm with a clear error.
The registry publishes native-mod versions as .hmod and still
accepts .hnmod; .hnmod keeps loading unchanged.

Launcher install, game content loading of native .hmods and the
GitHub install path are not done yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:04:52 +01:00
[email protected]andCodex 452910ffce fix(parity): keep saved probe selections compatible with the library split
Co-Authored-By: Codex <[email protected]>
2026-11-01 12:17:53 +01:00
[email protected]andCodex 42a5981e94 refactor(client): isolate gameplay from the reusable client and probes
Keep native hook targets and source paths stable. Run gameplay tests in the gameplay library, and build ctl/probes from horizon-client-lib without gameplay. Update build/test selection and the fixed mixin-tool pin.

Co-Authored-By: Codex <[email protected]>
2026-11-01 12:05:47 +01:00
[email protected]andCodex 2c78a5f1b0 Merge upstream/main before revising the client library layout
Co-Authored-By: Codex <[email protected]>
2026-11-01 11:50:56 +01:00
[email protected]andCodex a303d7d105 Merge upstream/main into the client library split
Retain upstream block_contact and the split entry/game exports with test isolation.

Co-Authored-By: Codex <[email protected]>
2026-11-01 10:15:44 +01:00
[email protected]andGPT-6 cd85269d59 docs: correct parity selection costs after the client split
Document refreshed probe dep-info, conservative selection growth, and existing reachability narrowing without promising the old harness counts.

Co-Authored-By: GPT-6 <[email protected]>
2026-10-31 23:31:27 +01:00
[email protected]andGPT-6 022306f5d8 refactor(client): keep the library split without the separate mod API
Co-Authored-By: GPT-6 <[email protected]>
2026-10-31 23:03:46 +01:00
rdbtCVSandClaude Opus 5.5 60851159b2 Merge main into #5446
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:50:15 +00:00
[email protected]andClaude Opus 5.5 1e0fe9bb79 Merge upstream/main; the game's unit tests keep their own process
docs/native-mods.md keeps main's API 58 notes, then the API mods section.

With the game module in the horizon-client library, its unit tests ran in the
library's test process and shared process-wide state with the library's tests
(health::HURT_SERVER, the sulfur cube archetypes): six tests failed. The game
module now builds into the library only outside tests; its unit tests stay in the
executable's test build, as before the move.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:36:47 +01:00
[email protected]andClaude Opus 5.5 af806fd1c3 Merge upstream/main into native/api-mods
Conflicts: game.rs keeps the api_mods export beside main's narrator module;
horizon-threads takes main's windows-sys features (the same Windows fix);
native-mods.md keeps main's API 47-57 notes, then the API mods section.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:09:44 +01:00
[email protected] 0fb25759e1 boot_paint: AddFontMemResourceEx only reads the font count
Clippy (unnecessary_mut_passed) now that boot_paint compiles on Windows.
2026-10-31 20:25:15 +01:00
[email protected] 3888ac4613 Native mods without mixins: API mods that never rebuild horizon-client
A native bundle with [api] (crate and entry) and no [[injection]] is an API mod: its
crate implements horizon_mod_api::Mod and the game asks it right after the item and
block hook points it is named after, once the mixin mods there are done.

- horizon-mod-api: the Mod trait (use_item, use_item_on, right_click_block,
  left_click_block, destroy_block, place_block, tooltip), Action and Host.
- horizon-client: the game module moves into the library and the executable only adds
  the allocator, so a build can link the game from another package; game/api_mods.rs
  dispatches, and a mod turned off in Options > Mods is skipped with no build.
- launcher: API mods skip horizon-mixins; their crate and a generated native-game
  package build the executable, so horizon-client never depends on a mod.
- horizon-mods: the manifest's optional api field and its checks.
- docs: native-mods.md "API mods (no mixins)", stability.md; example in
  launcher/tests/fixtures/native-api-demo.
2026-10-31 20:25:15 +01:00
[email protected] e2d852088b Windows build: the windows-sys features boot_paint uses
boot_paint.rs draws the boot screen with GDI (Win32_Graphics_Gdi) and reads the
window's size (Win32_UI_WindowsAndMessaging), which horizon-threads didn't turn on.
2026-10-31 19:47:15 +01:00
313 changed files with 1728 additions and 17527 deletions

No files matched your search

+1 -1
View File
@@ -150,7 +150,7 @@ jobs:
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Every program the release folder takes was built
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
+15 -8
View File
@@ -39,7 +39,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| Crate | Owns |
|---|---|
| `horizon-client` | The game: a lib (`src/lib.rs`, ~100 flat modules: gameplay, networking, title screens) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-client` | Gameplay library (`src/lib.rs`: `game.rs` + `game/`, `entry.rs`) and thin executable (`src/main.rs`). Also the dedicated server (`--dedicated`). Re-exports `horizon-client-lib` for existing mod paths. |
| `horizon-client-lib` | Reusable client systems, `horizon-ctl`, probes and their tests. Its manifest points to the existing sources under `crates/horizon-client/`; the library root is `src/client_lib.rs`. It never depends on gameplay. |
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
@@ -84,8 +85,8 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
```sh
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
python3 tools/pr_ready.py --quick # same without harnesses
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
cargo test -p horizon-client --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo test -p horizon-client -p horizon-client-lib --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
cargo test -p horizon-client-lib --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
@@ -102,8 +103,11 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
or the game binary with a fixture env var (`game/dispense_fixture.rs`, `game/sculk_fixture.rs`,
`game/command_harness.rs`, `game/net_harness.rs`). Copy a nearby harness for a new one.
- Probes build into `.local/parity-target/`; harnesses find binaries via `lab.cargo_target()`, never `target/`.
- What a change reruns (of 329 harnesses, measured 2026-10-23): a file of the game binary (`game.rs`, `game/`)
~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it.
- Harness selection uses Cargo dep-info and reachable-code narrowing (`tools/parity_reach.py`).
`horizon-client-lib` builds the control tool and client probes without gameplay; their dep-info
excludes `game.rs` and `game/`, so gameplay edits do not rebuild or select those probes.
Source paths remain in `crates/horizon-client/`. Measure actual harness selection on refreshed
records with `tools/parity.py --changed-files paths.txt --list`; historical counts are not a guarantee.
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
@@ -128,8 +132,11 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
## horizon-client: where things live
`L:` = lib (`crates/horizon-client/src/`, or `crates/horizon-client-render/src/` for the modules that crate holds, under
the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/src/game/`). The lib can't see
`Game`; bin code reaches the lib as `horizon_client::<module>`.
the same `horizon_client::<module>` paths), `G:` = gameplay (`crates/horizon-client/src/game/`), also in the lib in normal builds.
Gameplay keeps its `horizon_client::<module>` imports through a self-crate alias. The executable
contains only the allocator, mod API marker and a call to `entry::main`. For unit tests, the lib
excludes `entry` and `game`, and the executable test target owns `game` so its process-wide test
state stays separate from the library tests. This compiles gameplay twice for executable tests.
| Subsystem | Files |
|---|---|
@@ -160,7 +167,7 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
- `struct Game` (game.rs, ~290 fields) holds everything. Its methods are spread over ~180 files: game.rs keeps
the core (tick, render, input, inventory, world open/close) and each `game/<feature>.rs` adds an
`impl Game<'_>` block for one feature, with its own state types. Fields are private to the bin.
`impl Game<'_>` block for one feature, with its own state types. Fields stay private to the gameplay module and its descendants. Gameplay tests run in the gameplay library, separate from reusable-library tests.
- Find a feature's code: `grep -rn "fn tick_<thing>\|fn <verb>_<thing>" crates/horizon-client/src/game*`;
the file is usually `game/<thing>s.rs`. Who calls it: grep the name in game.rs's `fn tick` / `fn tick_world`.
- Fields that say which mode runs: `remote` (this window is a client of a server: multiplayer, LAN guest, or
Generated
+57 -3
View File
@@ -1314,6 +1314,7 @@ dependencies = [
"horizon-chat-filter",
"horizon-client-core",
"horizon-client-input",
"horizon-client-lib",
"horizon-client-render",
"horizon-command",
"horizon-core",
@@ -1323,7 +1324,6 @@ dependencies = [
"horizon-items",
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
"horizon-protocol",
"horizon-server-files",
"horizon-shader-api",
@@ -1374,6 +1374,62 @@ dependencies = [
"winit",
]
[[package]]
name = "horizon-client-lib"
version = "0.1.0"
dependencies = [
"ab_glyph",
"bytemuck",
"chrono",
"flate2",
"fs4",
"gilrs",
"glam",
"horizon-anticheat",
"horizon-assets",
"horizon-bench",
"horizon-chat-filter",
"horizon-client-core",
"horizon-client-input",
"horizon-client-render",
"horizon-command",
"horizon-core",
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-items",
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
"horizon-protocol",
"horizon-server-files",
"horizon-shader-api",
"horizon-shaders",
"horizon-telemetry",
"horizon-theme",
"horizon-threads",
"horizon-world",
"log",
"miniz_oxide 0.8.9",
"naga",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"open",
"png",
"pollster",
"rustix 1.1.5",
"serde",
"serde_json",
"sha1",
"sha2",
"unicode-bidi",
"ureq",
"wgpu",
"winit",
"zip",
]
[[package]]
name = "horizon-client-render"
version = "0.1.0"
@@ -1395,7 +1451,6 @@ dependencies = [
"horizon-hdr",
"horizon-items",
"horizon-narrator",
"horizon-optifine",
"horizon-protocol",
"horizon-shader-api",
"horizon-shaders",
@@ -1517,7 +1572,6 @@ name = "horizon-optifine"
version = "0.1.0"
dependencies = [
"horizon-bench",
"log",
"regex",
]
+1
View File
@@ -8,6 +8,7 @@ members = [
"crates/horizon-hdr",
"crates/horizon-world",
"crates/horizon-client",
"crates/horizon-client-lib",
"crates/horizon-client-core",
"crates/horizon-client-input",
"crates/horizon-client-render",
-25
View File
@@ -66,28 +66,3 @@ with Microsoft accounts
The four-square Microsoft symbol in `launcher/src/microsoft.rs` reproduces the squares, positions and
colours of Microsoft's official `ms-symbollockup_mssymbol_19.svg` from that page, unmodified. Microsoft
and its logo are trademarks of the Microsoft group of companies; Horizon is not endorsed by Microsoft.
## CIT Resewn
`crates/horizon-optifine/src/cit.rs` and `crates/horizon-client-render/src/cit.rs` follow the
condition semantics of [CIT Resewn Continuation](https://github.com/EthanVisagie/CIT-Resewn-Continuation-1.21.11)
(at `b792f9a`), a continuation of [CIT Resewn](https://github.com/SHsuperCM/CITResewn) by SHsuperCM: the
1.21 component paths, enchantment levels, percent damage, text matching and rule ordering. It is
licensed under the MIT license:
> Copyright (c) 2021 SHsuperCM
>
> 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.
-7
View File
@@ -281,13 +281,6 @@ pub struct Player {
pub ground: Option<(u64, f64)>,
/// The client ticks the prediction plays again (only while it runs).
pub record: Option<Box<prediction::Record>>,
/// Sprinting as the client's own commands left it (ServerboundPlayerCommandPacket's
/// START_SPRINTING and STOP_SPRINTING: LocalPlayer.wasSprinting), which the record's ticks take.
/// Not the server's player: a hit with extra knockback stops its sprint
/// (Player.causeExtraKnockback), the client's own `Player.attack` doesn't (a mob's `hurtClient`
/// is false), and once the server's flag reaches it LocalPlayer.aiStep starts the sprint again
/// while the sprint key is held, with nothing to say: wasSprinting never changed.
pub sprinting: bool,
/// The attacks of the client tick being heard.
pub attacks: combat::Attacks,
}
+221
View File
@@ -0,0 +1,221 @@
[package]
name = "horizon-client-lib"
autobins = false
autoexamples = false
autotests = false
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
description = "Reusable client systems, control tool and parity probes"
[lib]
path = "../horizon-client/src/client_lib.rs"
[dependencies]
horizon-assets = { path = "../horizon-assets" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
horizon-client-render = { path = "../horizon-client-render" }
horizon-command = { path = "../horizon-command" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-optifine = { path = "../horizon-optifine" }
horizon-threads = { path = "../horizon-threads" }
horizon-hdr = { path = "../horizon-hdr" }
horizon-world = { path = "../horizon-world" }
horizon-protocol = { path = "../horizon-protocol" }
# server.properties, eula.txt and the user lists, shared with horizon-server.
horizon-server-files = { path = "../horizon-server-files" }
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md).
horizon-anticheat = { path = "../horizon-anticheat" }
# The dedicated server's chat filter: [chat_filter], the slur list, mutes (docs/chat-filter.md).
horizon-chat-filter = { path = "../horizon-chat-filter" }
# Shader packs: reading them and turning their GLSL into naga modules.
horizon-shaders = { path = "../horizon-shaders" }
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-friends = { path = "../horizon-friends" }
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
horizon-narrator = { path = "../horizon-narrator" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
# Their runtime is the `mods` feature.
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
winit = "0.30.13"
pollster = "0.4"
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"] }
# 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"] }
miniz_oxide = "0.8"
ab_glyph = "0.2.32"
log = "0.4"
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
horizon-theme = { path = "../horizon-theme" }
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
horizon-bench = { path = "../horizon-bench" }
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
gilrs = { version = "0.11", optional = true }
# The DualSense's rumble, adaptive triggers and lights over HID (gilrs only reads it).
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
# HTTPS to the Minecraft services (online-mode session join).
ureq = { version = "2", default-features = false, features = ["tls"] }
unicode-bidi = "0.3.18"
# `LevelStorageAccess.estimateDiskSpace`: usable disk space, without unsafe code on Windows.
fs4 = { version = "1.1.0", default-features = false, features = ["sync"] }
[target.'cfg(unix)'.dependencies]
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
rustix = { version = "1", default-features = false, features = ["std", "time", "process", "fs"] }
[target.'cfg(windows)'.dependencies]
# Use the registered URI handler without routing links through Explorer/cmd.
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
# on the PATH.
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
[target.'cfg(target_os = "macos")'.dependencies]
# Splitscreen: a window filling a monitor goes over its menu bar (game/split/windows.rs
# `above_menu_bar`); all already in the tree through winit and wgpu.
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow", "NSScreen"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSString"] }
[target.'cfg(not(target_os = "macos"))'.dependencies]
# OpenGL (WGL on Windows, EGL on Linux), the last fallback, for GPUs with no Vulkan or
# DirectX 12 driver (`graphicsApi`, graphics.rs).
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[features]
default = ["audio", "controllers"]
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
audio = ["horizon-client-render/audio"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
# Compatibility with earlier experiment commands: main already uses mimalloc.
windows-mimalloc = []
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
windows-segment-heap = []
[lints]
workspace = true
[dev-dependencies]
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
horizon-items = { path = "../horizon-items", features = ["test-support"] }
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
[[bin]]
name = "horizon-ctl"
path = "../horizon-client/src/bin/horizon-ctl/main.rs"
[[example]]
name = "chunk_load_bench"
path = "../horizon-client/examples/chunk_load_bench.rs"
[[example]]
name = "gameplay_decode_profile"
path = "../horizon-client/examples/gameplay_decode_profile.rs"
[[example]]
name = "json_load_bench"
path = "../horizon-client/examples/json_load_bench.rs"
[[example]]
name = "mod_port_probe"
path = "../horizon-client/examples/mod_port_probe.rs"
[[example]]
name = "model_library_bench"
path = "../horizon-client/examples/model_library_bench.rs"
[[example]]
name = "model_states_bench"
path = "../horizon-client/examples/model_states_bench.rs"
[[example]]
name = "old_world_fixture"
path = "../horizon-client/examples/old_world_fixture.rs"
[[example]]
name = "portal_scan_bench"
path = "../horizon-client/examples/portal_scan_bench.rs"
[[example]]
name = "random_tick_eligibility_bench"
path = "../horizon-client/examples/random_tick_eligibility_bench.rs"
[[example]]
name = "resource_memory"
path = "../horizon-client/examples/resource_memory.rs"
[[example]]
name = "save_batch_bench"
path = "../horizon-client/examples/save_batch_bench.rs"
[[example]]
name = "shared_json_startup_profile"
path = "../horizon-client/examples/shared_json_startup_profile.rs"
[[example]]
name = "sprite_cache_parity"
path = "../horizon-client/examples/sprite_cache_parity.rs"
[[example]]
name = "client_probe"
path = "../horizon-client/examples/probe/main.rs"
[[test]]
name = "custom_component_api"
path = "../horizon-client/tests/custom_component_api.rs"
[[test]]
name = "custom_component_unregistered"
path = "../horizon-client/tests/custom_component_unregistered.rs"
[[test]]
name = "custom_component_wire"
path = "../horizon-client/tests/custom_component_wire.rs"
[[test]]
name = "forced_chunks"
path = "../horizon-client/tests/forced_chunks.rs"
[[test]]
name = "generation_saves"
path = "../horizon-client/tests/generation_saves.rs"
[[test]]
name = "lan_bot"
path = "../horizon-client/tests/lan_bot.rs"
[[test]]
name = "streaming_meshes"
path = "../horizon-client/tests/streaming_meshes.rs"
[[test]]
name = "tall_column"
path = "../horizon-client/tests/tall_column.rs"
[[test]]
name = "wide_states"
path = "../horizon-client/tests/wide_states.rs"
-3
View File
@@ -17,9 +17,6 @@ horizon-hdr = { path = "../horizon-hdr" }
horizon-items = { path = "../horizon-items" }
# The narrator option's status and whether the narrator is there (Options.narrator).
horizon-narrator = { path = "../horizon-narrator" }
# OptiFine-format pack files: random entity textures (hud.rs), connected textures
# (connected.rs) and custom item texture rules (cit.rs).
horizon-optifine = { path = "../horizon-optifine" }
horizon-protocol = { path = "../horizon-protocol" }
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-shaders = { path = "../horizon-shaders" }
+2 -30
View File
@@ -101,8 +101,6 @@ pub struct RenderAssets {
pub pbr_normals: Vec<u8>,
pub pbr_specular: Vec<u8>,
pub pbr_size: u32,
/// OptiFine connected textures (empty without `optifine/ctm` files).
pub connected: crate::connected::ConnectedTextures,
}
fn invalid(message: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
@@ -242,34 +240,9 @@ impl RenderAssets {
for stage in 0..10 {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
let connected = if draws {
if draws {
crate::native_emissive::load(root, &mut sprites);
// A side's sprite, for rules that connect by tile: the first quad facing it.
let catalog = &models.catalog;
let mut found = Vec::new();
crate::connected::ConnectedTextures::load(
root,
registry,
&mut sprites,
|state, face| {
let side = horizon_assets::model::Direction::ALL[face as usize];
catalog
.models_at(
state,
horizon_core::position::BlockPos::new(0, 0, 0),
&mut found,
)
.ok()?;
found
.iter()
.flat_map(|m| &m.faces)
.find(|f| f.quad.facing == side)
.map(|f| f.layer)
},
)
} else {
Default::default()
};
}
sprites.save_disk_cache();
if draws {
sprites.finish();
@@ -294,7 +267,6 @@ impl RenderAssets {
pbr_normals: sprites.normals,
pbr_specular: sprites.specular,
pbr_size: sprites.pbr_size,
connected,
})
}
}
+17 -50
View File
@@ -29,10 +29,6 @@ pub type Signature = horizon_protocol::secure_chat::Signature;
/// How long a message shows before a deletion replaces it
/// (`ChatComponent.deleteMessageOrDelay`: `addedTime + 60`).
const DELETION_DELAY: u64 = 60;
/// The smooth chat extra: the ticks a new line takes to slide in and fade in,
/// and how far left of its place it starts (GUI pixels).
const INTRO_TICKS: f32 = 5.0;
const INTRO_WIDTH: f32 = 12.0;
/// `ChatVisiblity`: the Chat option's three values.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -237,10 +233,14 @@ impl Tag {
/// `ChatComponent.AlphaCalculator.timeBased`: opaque for 180 ticks, then
/// fading out quadratically over 20.
fn time_alpha(ticks: f32, added: u64) -> f32 {
let mut t = 1.0 - (ticks - added as f32) / 200.0;
t = (t * 10.0).clamp(0.0, 1.0);
t * t
fn time_alpha(ticks: u64, added: u64) -> f32 {
let delta = ticks as i64 - added as i64;
let mut t = delta as f64 / 200.0;
t = 1.0 - t;
t *= 10.0;
t = t.clamp(0.0, 1.0);
t *= t;
t as f32
}
#[derive(Default)]
pub struct Chat {
@@ -256,11 +256,6 @@ pub struct Chat {
scroll: usize,
new_since_scroll: bool,
pub options: ChatOptions,
/// The smooth chat extra: a new line slides in from the left while it
/// fades in, instead of appearing whole.
pub smooth: bool,
/// The smooth chat extra: no indicator bar at the left of a line.
pub hide_indicator: bool,
/// Messages printed to stdout as `[CHAT]` (vanilla logs them).
pub log: bool,
/// Messages added since the start, for the control socket's events.
@@ -560,7 +555,7 @@ impl Chat {
}
/// `ChatComponent.extractRenderState` (background mode unless `focused`)
/// on a screen `height` GUI pixels tall at `Hud.tickCount` `ticks`.
pub fn layout(&self, height: i32, ticks: f32, focused: bool) -> Vec<GuiDraw> {
pub fn layout(&self, height: i32, ticks: u64, focused: bool) -> Vec<GuiDraw> {
let total = self.lines.len();
if total == 0 {
return Vec::new();
@@ -574,47 +569,28 @@ impl Chat {
let entry_height = o.line_height();
let bottom_to_text =
(8.0 * (o.line_spacing + 1.0) - 4.0 * o.line_spacing + 0.5).floor() as i32;
// Vanilla's 200-tick fade reads the whole GUI ticks
// (`Gui.getGuiTicks`); only the smooth chat extra eases it per frame.
let fade_ticks = if self.smooth { ticks } else { ticks.floor() };
let alpha_of = |line: &Line| {
if focused {
1.0
} else {
time_alpha(fade_ticks, line.added)
time_alpha(ticks, line.added)
}
};
// The smooth chat extra: how far into its intro a line is, 0 at the
// tick it was added and 1 once it has arrived, as (alpha, offset x).
let intro_of = |line: &Line| {
if focused || !self.smooth {
return (1.0, 0.0);
}
let t = ((ticks - line.added as f32) / INTRO_TICKS).clamp(0.0, 1.0);
let eased = 1.0 - (1.0 - t) * (1.0 - t);
(eased, (eased - 1.0) * INTRO_WIDTH)
};
let visible = total
.saturating_sub(self.scroll)
.min(self.lines_per_page(focused));
let lines: Vec<(usize, &Line, f32, f32)> = (0..visible)
let lines: Vec<(usize, &Line, f32)> = (0..visible)
.rev()
.map(|i| (i, &self.lines[i + self.scroll]))
.map(|(i, line)| {
let (eased, slide) = intro_of(line);
(i, line, alpha_of(line) * eased, slide)
})
.filter(|(_, _, alpha, _)| *alpha > 1.0e-5)
.map(|(i, line)| (i, line, alpha_of(line)))
.filter(|(_, _, alpha)| *alpha > 1.0e-5)
.collect();
let mut out = vec![GuiDraw::Push(vec![
PoseOp::Scale(scale, scale),
PoseOp::Translate(4.0, 0.0),
])];
for &(i, _, alpha, slide) in &lines {
for &(i, _, alpha) in &lines {
let bottom = chat_bottom - i as i32 * entry_height;
if slide != 0.0 {
out.push(GuiDraw::Push(vec![PoseOp::Translate(slide, 0.0)]));
}
out.push(hud_layout::fill(
-4,
bottom - entry_height,
@@ -622,15 +598,9 @@ impl Chat {
bottom,
hud_layout::as8(alpha * background_opacity) << 24,
));
if slide != 0.0 {
out.push(GuiDraw::Pop);
}
}
for &(i, line, alpha, slide) in &lines {
for &(i, line, alpha) in &lines {
let bottom = chat_bottom - i as i32 * entry_height;
if slide != 0.0 {
out.push(GuiDraw::Push(vec![PoseOp::Translate(slide, 0.0)]));
}
let opacity = alpha * text_opacity;
let color = hud_layout::white(opacity);
if color >> 24 != 0 {
@@ -642,7 +612,7 @@ impl Chat {
shadow: true,
});
}
if let Some(tag) = line.tag.as_ref().filter(|_| !self.hide_indicator) {
if let Some(tag) = &line.tag {
let color = hud_layout::as8(opacity) << 24 | tag.color & 0xFF_FFFF;
out.push(hud_layout::fill(
-4,
@@ -652,9 +622,6 @@ impl Chat {
color,
));
}
if slide != 0.0 {
out.push(GuiDraw::Pop);
}
}
if focused {
let count = lines.len() as i32;
@@ -691,7 +658,7 @@ impl Chat {
}
/// Draws [`Chat::layout`] into the HUD batch, and with the chat screen
/// open the restricted prompt above the lines.
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: f32, focused: bool) {
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: u64, focused: bool) {
let draws = self.layout(batch.height as i32, ticks, focused);
hud_layout::paint(batch, atlas, &draws);
if focused && self.abilities.any_restriction() {
+1 -56
View File
@@ -39,61 +39,6 @@ fn add(chat: &mut Chat, font: &Font, content: Text) {
}
}
#[test]
fn smooth_chat_slides_a_line_in_and_can_hide_the_indicator() {
let mut chat = Chat {
smooth: true,
..Chat::default()
};
chat.lines.push(Line {
added: 0,
content: Text::literal("hi"),
tag: Some(std::sync::Arc::new(Tag::system_single_player(
SYSTEM_INDICATOR,
))),
});
let text_alpha = |draws: &[GuiDraw]| {
draws.iter().find_map(|d| match d {
GuiDraw::Text { color, .. } => Some(color >> 24),
_ => None,
})
};
// The slide is the negative translate the line carries while it moves.
let slide = |draws: &[GuiDraw]| {
draws.iter().find_map(|d| match d {
GuiDraw::Push(ops) => ops.iter().find_map(|op| match op {
PoseOp::Translate(x, _) if *x < 0.0 => Some(*x),
_ => None,
}),
_ => None,
})
};
// The tick it arrives: fully faded out, so nothing is drawn.
assert_eq!(text_alpha(&chat.layout(361, 0.0, false)), None);
// Halfway through the intro: left of its place and still faint.
let halfway = chat.layout(361, INTRO_TICKS / 2.0, false);
assert!(slide(&halfway).is_some_and(|x| (-INTRO_WIDTH..0.0).contains(&x)));
let faded = text_alpha(&halfway).expect("the line shows halfway through");
// Settled: in place and as opaque as vanilla's line.
let settled = chat.layout(361, INTRO_TICKS, false);
assert_eq!(slide(&settled), None);
assert!(text_alpha(&settled).expect("settled") > faded);
// The extra off draws the line whole on the tick it arrives, as vanilla.
chat.smooth = false;
let plain = chat.layout(361, 0.0, false);
assert_eq!(slide(&plain), None);
assert_eq!(text_alpha(&plain), text_alpha(&settled));
// The indicator bar is the -4 to -2 fill at the left of the line.
let bar = |draws: &[GuiDraw]| {
draws
.iter()
.any(|d| matches!(d, GuiDraw::Fill { x0: -4, x1: -2, .. }))
};
assert!(bar(&plain));
chat.hide_indicator = true;
assert!(!bar(&chat.layout(361, 0.0, false)));
}
#[test]
fn clicking_seed_copies_the_raw_value_only_inside_the_link() {
let font = font();
@@ -150,7 +95,7 @@ fn click_targets_match_rendered_spans_when_scaled_wrapped_and_scrolled() {
for scroll in [0, 3, 12] {
chat.scroll = scroll;
let mut links = 0;
for draw in chat.layout(361, 0.0, true) {
for draw in chat.layout(361, 0, true) {
if let GuiDraw::Text { text, x, y, .. } = draw {
let mut at = x as f64;
for (c, style) in text.chars() {
-662
View File
@@ -1,662 +0,0 @@
//! OptiFine's Custom Item Textures (and CIT Resewn's): the rules of the enabled packs'
//! `optifine/cit/**/*.properties`, read from the overlay resource root. The rules themselves are
//! `horizon_optifine::cit`'s.
pub use horizon_optifine::cit::{Hand, Kind, Rule};
use horizon_client_core::component::name_text;
use horizon_client_core::scoreboard::comp_text;
use horizon_items::item_data::{canon_tag, encode_components};
use horizon_items::items::ItemStack;
use horizon_optifine::cit::{ComponentTest, Source, ValueTest};
use horizon_optifine::{Properties, properties_files_in};
use horizon_world::nbt::{JavaString, Tag, parse_snbt};
use std::borrow::Cow;
use std::collections::HashMap;
use std::fs;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::Path;
use std::sync::Mutex;
/// Every rule under the root's `optifine/cit/`, `mcpatcher/cit/` and CIT Resewn's
/// `citresewn/cit/` folders, the one chosen first when several match first (`Rule::priority`).
/// The pack's global `cit.properties` is not a rule.
pub fn load_rules(root: &Path) -> Vec<Rule> {
let tops = ["optifine", "mcpatcher", "citresewn"];
let mut rules: Vec<Rule> = properties_files_in(root, &tops, "cit")
.into_iter()
.filter(|path| !path.ends_with("/cit.properties"))
.filter_map(|path| {
// Java reads `.properties` as Latin-1: a pack saved that way still loads.
let bytes = fs::read(root.join(&path)).ok()?;
let text = String::from_utf8(bytes)
.unwrap_or_else(|e| e.into_bytes().iter().map(|&b| char::from(b)).collect());
let rule = Rule::parse(&path, &Properties::parse_equals_first(&text), |p| {
root.join(p).is_file()
});
if rule.is_none() {
log::warn!("custom item textures: {path} is not a rule Horizon can read");
}
rule
})
.collect();
rules.sort_by(Rule::priority);
rules
}
/// The rules with an index by item and a cache of what each stack matched.
#[derive(Debug, Default)]
pub struct CitRules {
pub rules: Vec<Rule>,
/// Per kind (`Kind as usize`), the rules naming each item, in priority order.
by_item: [HashMap<String, Vec<u32>>; 4],
/// Enchantment rules without `items`, in priority order.
any_item: Vec<u32>,
/// Per rule, per component test: its literal read as SNBT and as a JSON text component.
literals: Vec<Vec<Prepared>>,
selected: Mutex<HashMap<u64, Option<u32>>>,
enchanted: Mutex<HashMap<u64, Vec<u32>>>,
}
#[derive(Debug, Default)]
struct Prepared {
snbt: Option<Tag>,
json: Option<String>,
}
/// Cache entries kept before the cache starts over.
const CACHE_LIMIT: usize = 4096;
impl CitRules {
/// The enabled packs' rules under the overlay resource root.
pub fn load(root: &Path) -> Self {
Self::new(load_rules(root))
}
/// `rules` in priority order, the winner first.
pub fn new(rules: Vec<Rule>) -> Self {
let mut by_item: [HashMap<String, Vec<u32>>; 4] = Default::default();
let mut any_item = Vec::new();
for (i, rule) in rules.iter().enumerate() {
let i = i as u32;
if rule.kind == Kind::Enchantment && rule.items.is_empty() {
any_item.push(i);
}
for item in &rule.items {
let list = by_item[rule.kind as usize].entry(item.clone()).or_default();
if list.last() != Some(&i) {
list.push(i);
}
}
}
let literals = rules
.iter()
.map(|r| r.components.iter().map(prepare).collect())
.collect();
Self {
rules,
by_item,
any_item,
literals,
..Default::default()
}
}
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
/// The winning rule of `kind` (Item, Armor or Elytra) for this stack, as an index into `rules`.
pub fn select(
&self,
kind: Kind,
stack: &ItemStack,
hand: Hand,
max_damage: u32,
) -> Option<usize> {
let candidates = self.by_item[kind as usize].get(&*stack.item)?;
let comps = encode_components(stack);
let key = cache_key((0, kind, hand), stack, max_damage, comps.as_ref());
if let Some(hit) = lock(&self.selected).get(&key) {
return hit.map(|i| i as usize);
}
let found = candidates
.iter()
.copied()
.find(|&i| self.matches(i, stack, Some(hand), max_damage, comps.as_ref()));
remember(&self.selected, key, found);
found.map(|i| i as usize)
}
/// Every matching Enchantment rule, in priority order (layers are the glint PR's business).
pub fn enchantments(&self, stack: &ItemStack, max_damage: u32) -> Vec<usize> {
let named = self.by_item[Kind::Enchantment as usize]
.get(&*stack.item)
.map_or(&[][..], Vec::as_slice);
if named.is_empty() && self.any_item.is_empty() {
return Vec::new();
}
let comps = encode_components(stack);
let key = cache_key(
(1, Kind::Enchantment, Hand::Any),
stack,
max_damage,
comps.as_ref(),
);
let hit = lock(&self.enchanted).get(&key).cloned();
let found = hit.unwrap_or_else(|| {
let mut all: Vec<u32> = named.iter().chain(&self.any_item).copied().collect();
all.sort_unstable();
all.retain(|&i| self.matches(i, stack, None, max_damage, comps.as_ref()));
remember(&self.enchanted, key, all.clone());
all
});
found.into_iter().map(|i| i as usize).collect()
}
/// Whether rule `i` matches; `hand` None skips the hand test (enchantment glints).
fn matches(
&self,
i: u32,
stack: &ItemStack,
hand: Option<Hand>,
max_damage: u32,
comps: Option<&Tag>,
) -> bool {
let rule = &self.rules[i as usize];
if hand.is_some_and(|h| rule.hand != Hand::Any && rule.hand != h) {
return false;
}
let count = i32::try_from(stack.count).unwrap_or(i32::MAX);
if rule.stack_size.as_ref().is_some_and(|r| !r.contains(count)) {
return false;
}
// CIT Resewn: only damageable items have damage.
let damage = if max_damage > 0 { stack.damage } else { 0 };
if rule
.damage
.as_ref()
.is_some_and(|d| !d.matches(damage, max_damage))
{
return false;
}
let enchantments = stack.crafting_enchantments();
let listed = |id: &str| rule.enchantments.iter().any(|e| e == id);
if !rule.enchantments.is_empty() && !enchantments.iter().any(|(id, _)| listed(id)) {
return false;
}
if let Some(levels) = &rule.enchantment_levels {
let level_in_range = enchantments.iter().any(|(id, level)| {
(rule.enchantments.is_empty() || listed(id))
&& levels.contains(i32::try_from(*level).unwrap_or(i32::MAX))
});
if !level_in_range {
return false;
}
}
let literals = &self.literals[i as usize];
rule.components
.iter()
.zip(literals)
.all(|(test, lit)| component_test(test, lit, stack, comps) != test.negate)
}
}
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
fn remember<V>(cache: &Mutex<HashMap<u64, V>>, key: u64, value: V) {
let mut cache = lock(cache);
if cache.len() >= CACHE_LIMIT {
cache.clear();
}
cache.insert(key, value);
}
/// What a cached answer depends on; `query` tells `select` from `enchantments`.
fn cache_key(query: (u8, Kind, Hand), stack: &ItemStack, max: u32, comps: Option<&Tag>) -> u64 {
let mut h = DefaultHasher::new();
(query.0, query.1 as u8, query.2 as u8, &*stack.item).hash(&mut h);
(stack.count, stack.damage, max, comps).hash(&mut h);
h.finish()
}
fn prepare(test: &ComponentTest) -> Prepared {
let ValueTest::Literal(lit) = &test.value else {
return Prepared::default();
};
let text = lit.text.trim();
let snbt = match parse_snbt(text) {
Ok((tag, read)) if read == text.len() && text.starts_with(['{', '[']) => Some(tag),
_ => None,
};
let json = json_plain(text);
Prepared { snbt, json }
}
/// The plain text of `s` read as a JSON text component.
fn json_plain(s: &str) -> Option<String> {
if !s.starts_with(['{', '[', '"']) {
return None;
}
let value: serde_json::Value = serde_json::from_str(s).ok()?;
Some(comp_text(&value).plain())
}
/// Whether one test passes, before its `negate`.
fn component_test(
test: &ComponentTest,
lit: &Prepared,
stack: &ItemStack,
comps: Option<&Tag>,
) -> bool {
let legacy;
let (root, text_depth) = match &test.source {
Source::Component(id) => {
let depth = match id.as_str() {
"minecraft:custom_name" | "minecraft:item_name" => Some(0),
"minecraft:lore" => Some(1),
_ => None,
};
(comps.and_then(|c| c.get(id)), depth)
}
Source::LegacyEnchantments { stored } => {
let levels = if *stored {
stack.stored_enchantments()
} else {
stack.enchantments()
};
legacy = Tag::List(
levels
.iter()
.map(|(id, lvl)| {
let lvl = i16::try_from(*lvl).unwrap_or(i16::MAX);
Tag::compound([
("id", Tag::String((&**id).into())),
("lvl", Tag::Short(lvl)),
])
})
.collect(),
);
(Some(&legacy), None)
}
};
// Fields the component leaves out when 0, which pre-1.20.5 items always wrote.
let defaulted;
let root = match (&test.source, root, test.path.first()) {
(Source::Component(id), Some(Tag::Compound(map)), Some(field))
if matches!(
(id.as_str(), field.as_str()),
("minecraft:written_book_content", "generation")
| ("minecraft:fireworks", "flight_duration")
) && !map.contains_key(&JavaString::from(field.as_str())) =>
{
let mut map = map.clone();
map.insert(JavaString::from(field.as_str()), Tag::Int(0));
defaulted = Tag::Compound(map);
Some(&defaulted)
}
(_, root, _) => root,
};
let Some(root) = root else {
return matches!(test.value, ValueTest::Exists(false));
};
if let ValueTest::Exists(want) = test.value {
return walk(root, &test.path, 0, &mut |_, _| true) == want;
}
walk(root, &test.path, 0, &mut |tag, depth| {
value_test(&test.value, lit, tag, text_depth == Some(depth))
})
}
/// Visits what `path` reaches from `tag`, true once `visit` is.
fn walk(
tag: &Tag,
path: &[String],
depth: usize,
visit: &mut dyn FnMut(&Tag, usize) -> bool,
) -> bool {
let Some((seg, rest)) = path.split_first() else {
return visit(tag, depth);
};
let mut next = |t: &Tag| walk(t, rest, depth + 1, visit);
let len = match tag {
Tag::Compound(map) if seg == "*" => return map.values().any(&mut next),
Tag::Compound(map) => return map.get(&JavaString::from(seg.as_str())).is_some_and(next),
Tag::List(v) => v.len(),
Tag::ByteArray(v) => v.len(),
Tag::IntArray(v) => v.len(),
Tag::LongArray(v) => v.len(),
_ => return false,
};
let element = |i: usize| match tag {
Tag::List(v) => Cow::Borrowed(&v[i]),
Tag::ByteArray(v) => Cow::Owned(Tag::Byte(v[i] as i8)),
Tag::IntArray(v) => Cow::Owned(Tag::Int(v[i])),
Tag::LongArray(v) => Cow::Owned(Tag::Long(v[i])),
_ => Cow::Owned(Tag::End),
};
if seg == "count" {
return next(&Tag::Int(i32::try_from(len).unwrap_or(i32::MAX)));
}
if seg == "*" {
return (0..len).any(|i| next(&element(i)));
}
seg.parse::<usize>()
.ok()
.filter(|&i| i < len)
.is_some_and(|i| next(&element(i)))
}
fn int_value(tag: &Tag) -> Option<i64> {
match *tag {
Tag::Byte(v) => Some(v.into()),
Tag::Short(v) => Some(v.into()),
Tag::Int(v) => Some(v.into()),
Tag::Long(v) => Some(v),
_ => None,
}
}
/// A number as Java's `String.valueOf` writes it (`3`, `3.0`).
fn decimal(tag: &Tag) -> Option<String> {
let s = match *tag {
Tag::Float(b) => f32::from_bits(b).to_string(),
Tag::Double(b) => f64::from_bits(b).to_string(),
_ => return int_value(tag).map(|v| v.to_string()),
};
Some(if s.contains(['.', 'e', 'N', 'i']) {
s
} else {
s + ".0"
})
}
/// `text` against a string, its JSON text, or the plain text of a text component.
fn text_matches(tag: &Tag, is_text: bool, text: impl Fn(&str) -> bool) -> bool {
match tag {
Tag::String(s) => {
let s = s.to_string_lossy();
text(&s)
|| (is_text && text(&name_text(tag).plain()))
|| json_plain(&s).is_some_and(|p| text(&p))
}
Tag::Compound(_) | Tag::List(_) => is_text && text(&name_text(tag).plain()),
_ => false,
}
}
fn value_test(test: &ValueTest, lit: &Prepared, tag: &Tag, is_text: bool) -> bool {
match test {
ValueTest::Exists(_) => true,
ValueTest::Range(r) => {
int_value(tag).is_some_and(|v| i32::try_from(v).is_ok_and(|v| r.contains(v)))
}
ValueTest::Raw(name) => name.matches(&canon_tag(tag)),
ValueTest::Text(name) => match decimal(tag) {
Some(n) => name.matches(&n),
None => text_matches(tag, is_text, |s| name.matches(s)),
},
ValueTest::Literal(l) => match tag {
Tag::Float(b) => l.text.trim().parse::<f32>().ok() == Some(f32::from_bits(*b)),
Tag::Double(b) => l.float == Some(f64::from_bits(*b)),
Tag::Compound(_) | Tag::List(_)
if lit.snbt.as_ref().is_some_and(|e| contains(e, tag)) =>
{
true
}
_ => match int_value(tag) {
Some(v) => l.int == Some(v),
None => text_matches(tag, is_text, |s| {
s == l.text || lit.json.as_deref() == Some(s)
}),
},
},
}
}
/// `NbtUtils.compareNbt(expected, actual, true)`: every key and element of `expected` found in
/// `actual`.
fn contains(expected: &Tag, actual: &Tag) -> bool {
match (expected, actual) {
(Tag::Compound(e), Tag::Compound(a)) => e
.iter()
.all(|(k, v)| a.get(k).is_some_and(|a| contains(v, a))),
(Tag::List(e), Tag::List(a)) if e.is_empty() => a.is_empty(),
(Tag::List(e), Tag::List(a)) => e.iter().all(|e| a.iter().any(|a| contains(e, a))),
_ => expected == actual,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
/// Rules from (file stem, properties), sorted as `load_rules` sorts them.
fn rules(files: &[(&str, &str)]) -> CitRules {
let mut rules: Vec<Rule> = files
.iter()
.map(|(stem, text)| {
let path = format!("assets/minecraft/optifine/cit/{stem}.properties");
let text = format!("texture=x\n{text}");
Rule::parse(&path, &Properties::parse_equals_first(&text), |_| true).unwrap()
})
.collect();
rules.sort_by(Rule::priority);
CitRules::new(rules)
}
fn stem(cit: &CitRules, i: Option<usize>) -> Option<&str> {
let path = &cit.rules[i?].path;
path.rsplit('/').next()?.strip_suffix(".properties")
}
fn item(cit: &CitRules, stack: ItemStack) -> Option<&str> {
stem(cit, cit.select(Kind::Item, &stack, Hand::Main, 0))
}
fn new(id: &str) -> ItemStack {
ItemStack::new(&format!("minecraft:{id}"), 1)
}
fn string(s: &str) -> Tag {
Tag::String(s.into())
}
fn with(id: &str, component: &str, tag: Tag) -> ItemStack {
let mut stack = new(id);
let component = format!("minecraft:{component}");
stack.data_mut().other.push((component, tag));
stack
}
fn enchanted(id: &str, levels: &[(&str, u32)]) -> ItemStack {
let mut stack = new(id);
let levels = levels
.iter()
.map(|(e, l)| (Arc::from(format!("minecraft:{e}")), *l));
stack.data_mut().enchantments = levels.collect();
stack
}
#[test]
fn heavier_rules_win_and_kinds_stay_apart() {
let cit = rules(&[
("plain", "items=stick"),
("heavy", "items=stick\nweight=2\nstackSize=2-"),
("offhand", "items=stick\nweight=5\nhand=off"),
("armor", "type=armor\nitems=stick"),
("wings", "type=elytra"),
]);
assert_eq!(item(&cit, new("stick")), Some("plain"));
assert_eq!(
item(&cit, ItemStack::new("minecraft:stick", 3)),
Some("heavy")
);
let pick = |kind, id, hand, max| stem(&cit, cit.select(kind, &new(id), hand, max));
assert_eq!(pick(Kind::Item, "stick", Hand::Off, 0), Some("offhand"));
assert_eq!(item(&cit, new("stone")), None);
assert_eq!(item(&cit, new("elytra")), None);
assert_eq!(pick(Kind::Elytra, "elytra", Hand::Main, 432), Some("wings"));
assert_eq!(pick(Kind::Armor, "stick", Hand::Main, 0), Some("armor"));
}
#[test]
fn names_match_by_their_plain_text() {
let cit = rules(&[
("glob", "items=stick\nnbt.display.Name=ipattern:*sword"),
("exact", "items=stone\ncomponents.custom_name=My Sword"),
("json", "items=dirt\nnbt.display.Name=[\"My \",\"Sword\"]"),
]);
let named = |id: &str, name: Tag| {
let mut stack = new(id);
stack.data_mut().custom_name = Some(name);
stack
};
let styled = Tag::compound([("text", string("My Sword")), ("color", string("red"))]);
assert_eq!(item(&cit, named("stick", styled.clone())), Some("glob"));
assert_eq!(item(&cit, named("stick", string("Blade"))), None);
assert_eq!(item(&cit, new("stick")), None);
assert_eq!(item(&cit, named("stone", styled.clone())), Some("exact"));
assert_eq!(item(&cit, named("dirt", styled)), Some("json"));
}
#[test]
fn lore_lines_and_list_counts() {
let cit = rules(&[
(
"any_line",
"items=stick\nnbt.display.Lore.*=ipattern:*legendary*",
),
("two_lines", "items=stone\ncomponents.lore.count=2"),
]);
let bold = Tag::compound([("text", string("A LEGENDARY item")), ("bold", Tag::Byte(1))]);
let lore = Tag::List(vec![string("Common"), bold]);
assert_eq!(
item(&cit, with("stick", "lore", lore.clone())),
Some("any_line")
);
assert_eq!(item(&cit, with("stone", "lore", lore)), Some("two_lines"));
let one = Tag::List(vec![string("A legendary item")]);
assert_eq!(item(&cit, with("stone", "lore", one)), None);
}
#[test]
fn custom_data_existence_and_negation() {
let cit = rules(&[
(
"aote",
"items=stick\nweight=2\nnbt.ExtraAttributes.id=ASPECT_OF_THE_END",
),
(
"not_hyp",
"items=stick\nweight=1\ncomponent.custom_data.ExtraAttributes.id=!HYP",
),
("unnamed", "items=stick\nnbt.display.Name=exists:false"),
]);
let data = |id: &str| {
let attributes =
Tag::compound([("ExtraAttributes", Tag::compound([("id", string(id))]))]);
with("stick", "custom_data", attributes)
};
assert_eq!(item(&cit, data("ASPECT_OF_THE_END")), Some("aote"));
assert_eq!(item(&cit, data("MIDAS")), Some("not_hyp"));
assert_eq!(item(&cit, data("HYP")), Some("unnamed"));
let mut named = data("HYP");
named.data_mut().custom_name = Some(string("Hyperion"));
assert_eq!(item(&cit, named), None);
}
#[test]
fn legacy_enchantment_lists_and_model_data() {
let cit = rules(&[
(
"sharp",
"items=stick\nnbt.Enchantments.*.id=minecraft:sharpness\nnbt.Enchantments.0.lvl=5",
),
("cmd", "items=stone\nnbt.CustomModelData=3"),
(
"cmd_snbt",
"items=dirt\ncomponents.custom_model_data={floats:[3.0f]}",
),
]);
assert_eq!(
item(&cit, enchanted("stick", &[("sharpness", 5)])),
Some("sharp")
);
assert_eq!(item(&cit, enchanted("stick", &[("sharpness", 4)])), None);
let model = |id: &str, f: f32| {
let floats = Tag::List(vec![Tag::Float(f.to_bits()), Tag::Float(7f32.to_bits())]);
with(id, "custom_model_data", Tag::compound([("floats", floats)]))
};
assert_eq!(item(&cit, model("stone", 3.0)), Some("cmd"));
assert_eq!(item(&cit, model("stone", 3.5)), None);
assert_eq!(item(&cit, model("dirt", 3.0)), Some("cmd_snbt"));
assert_eq!(item(&cit, model("dirt", 2.0)), None);
}
#[test]
fn fields_left_out_at_zero_read_as_zero() {
let cit = rules(&[("zero", "items=firework_rocket\nnbt.Fireworks.Flight=0")]);
let mut rocket = new("firework_rocket");
rocket.data_mut().fireworks = Some((0, Vec::new()));
assert_eq!(item(&cit, rocket.clone()), Some("zero"));
rocket.data_mut().fireworks = Some((2, Vec::new()));
assert_eq!(item(&cit, rocket), None);
}
#[test]
fn damage_counts_only_for_damageable_items() {
let cit = rules(&[
("worn", "items=diamond_sword\ndamage=50-100%"),
("new", "items=stick\ndamage=0"),
]);
let damaged = |id: &str, damage: u32, max: u32| {
let mut stack = new(id);
stack.damage = damage;
stem(&cit, cit.select(Kind::Item, &stack, Hand::Main, max))
};
// Half of 1561 is 780.5.
assert_eq!(damaged("diamond_sword", 780, 1561), None);
assert_eq!(damaged("diamond_sword", 781, 1561), Some("worn"));
assert_eq!(damaged("stick", 5, 0), Some("new"));
}
#[test]
fn enchantment_levels_and_rules_for_any_item() {
let cit = rules(&[
(
"sharp3",
"items=stick\nenchantments=sharpness\nenchantmentLevels=3-",
),
("any3", "items=stone\nenchantmentLevels=3-"),
("glint", "type=enchantment\nenchantments=sharpness"),
(
"sword_glint",
"type=enchantment\nitems=diamond_sword\nweight=2",
),
]);
let mixed = [("sharpness", 2), ("unbreaking", 3)];
assert_eq!(item(&cit, enchanted("stick", &mixed)), None);
assert_eq!(
item(&cit, enchanted("stick", &[("sharpness", 4)])),
Some("sharp3")
);
assert_eq!(item(&cit, enchanted("stone", &mixed)), Some("any3"));
let glints = |stack: ItemStack| -> Vec<&str> {
let found = cit.enchantments(&stack, 0).into_iter();
found.map(|i| stem(&cit, Some(i)).unwrap()).collect()
};
let sharp = [("sharpness", 1)];
assert_eq!(
glints(enchanted("diamond_sword", &sharp)),
["sword_glint", "glint"]
);
assert_eq!(glints(enchanted("stick", &sharp)), ["glint"]);
assert!(glints(new("stick")).is_empty());
// Glints are never an item's texture.
assert_eq!(item(&cit, enchanted("diamond_sword", &sharp)), None);
}
}
@@ -1,724 +0,0 @@
//! OptiFine connected textures (`optifine/ctm`) at mesh time. The active packs' rules are read
//! once at load, their tiles become sprite layers, and each block state and sprite layer gets
//! the short list of rules that can touch it; meshing a face then walks only those lists
//! ([`ConnectedTextures::pick`]). Without CTM files there are no rules and nothing changes.
//!
//! The rules and the tile geometry are `horizon_optifine::ctm`'s.
use crate::sprites::Sprites;
use horizon_optifine::ctm::{
self, Connect, Method, Orient, Rule, Tile,
connect::{self as geometry, Axes, Axis, Face},
};
use horizon_world::block_state_properties::BlockProperties;
use horizon_world::registry::{BlockRegistry, BlockStateId};
use std::{collections::HashMap, path::Path};
/// A tile as the mesher draws it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TileLayer {
Layer {
layer: u16,
/// The sprite has fractional alpha (drawn in the translucent pass).
translucent: bool,
/// Every texel is opaque (drawn without an alpha test).
opaque: bool,
},
/// Try the next rule.
Skip,
/// The face's own sprite.
Default,
}
/// What a face draws.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Pick {
/// Its own sprite.
Keep,
Tile(TileLayer),
/// `ctm_compact`: a tile per quarter of the sprite (top-left, top-right, bottom-left,
/// bottom-right).
Quarters([TileLayer; 4]),
}
struct LoadedRule {
rule: Rule,
tiles: Vec<TileLayer>,
}
#[derive(Clone, Copy, Default)]
struct StateInfo {
/// Index of the state's block (`connect=block`).
block: u32,
axis: Option<Axis>,
/// `half=upper`: `linked` random tiles follow the lower half.
upper: bool,
}
#[derive(Default)]
pub struct ConnectedTextures {
rules: Vec<LoadedRule>,
/// By block state, then by sprite layer: ranges of `lists` (rule indices, in rule order).
by_state: Vec<(u32, u32)>,
by_layer: Vec<(u32, u32)>,
lists: Vec<u16>,
states: Vec<StateInfo>,
/// By block state: the sprite of its model's face on each side (`connect=tile`); empty
/// when no rule connects by tile.
face_layers: Vec<[u16; 6]>,
}
/// `list` appended to `lists`, as its range there.
fn push_list(lists: &mut Vec<u16>, list: &[u16]) -> (u32, u32) {
let start = lists.len() as u32;
lists.extend_from_slice(list);
(start, list.len() as u32)
}
/// The sprite name of a texture resource path: `ns:path` under `textures/`, else the path
/// itself (as [`Sprites::insert_file`] names a tile).
fn sprite_name(resource: &str) -> String {
let parts = resource
.strip_prefix("assets/")
.and_then(|p| p.split_once('/'))
.and_then(|(ns, p)| Some((ns, p.strip_prefix("textures/")?.strip_suffix(".png")?)));
match parts {
Some((ns, path)) => format!("{ns}:{path}"),
None => resource.to_owned(),
}
}
impl ConnectedTextures {
/// The rules under `root`, with their tiles inserted into `sprites` (before it finishes).
/// `face_layer` answers a block state's sprite on a side, for rules that connect by tile.
pub fn load(
root: &Path,
registry: &BlockRegistry,
sprites: &mut Sprites,
mut face_layer: impl FnMut(BlockStateId, Face) -> Option<u32>,
) -> Self {
let mut rules = ctm::load(root);
// Rules no face can use load no tiles: overlays (not drawn yet), and custom names (section
// meshes see no block entity).
rules.retain(|r| !r.method.is_overlay() && r.name.is_none());
// Rule indices are `u16`.
rules.truncate(usize::from(u16::MAX));
if rules.is_empty() {
return Self::default();
}
let loaded = rules
.into_iter()
.filter_map(|rule| {
let tiles = rule
.tiles
.iter()
.map(|tile| match tile {
Tile::Skip => Ok(TileLayer::Skip),
Tile::Default => Ok(TileLayer::Default),
Tile::Texture(path) => {
// A tile among the textures is that sprite, under its own name.
let layer = match sprite_name(path) {
name if name != *path => sprites.insert(root, &name)?,
_ => sprites.insert_file(root, path)?,
};
Ok(TileLayer::Layer {
layer: u16::try_from(layer)?,
translucent: sprites.translucent[layer as usize],
opaque: sprites.opaque[layer as usize],
})
}
})
.collect::<crate::Result<Vec<_>>>();
match tiles {
Ok(tiles) => Some(LoadedRule { rule, tiles }),
Err(e) => {
log::warn!("Horizon: connected textures {}: a tile: {e}", rule.path);
None
}
}
})
.collect();
let states = (0..registry.len() as u32).map(|id| {
registry
.state(BlockStateId(id))
.map(|s| (s.name.as_str(), &s.properties))
});
let mut out = Self::build(loaded, states, |name| sprites.layers.get(name).copied());
if out.rules.iter().any(|r| r.rule.connect == Connect::Tile) {
out.face_layers = (0..registry.len() as u32)
.map(|id| {
Face::ALL
.map(|f| face_layer(BlockStateId(id), f).map_or(u16::MAX, |l| l as u16))
})
.collect();
}
out
}
/// The lookups for `rules` (already ordered) over every block state (`None` for an id the
/// registry skips), `layer` answering a sprite name's layer.
fn build<'a>(
rules: Vec<LoadedRule>,
states: impl Iterator<Item = Option<(&'a str, &'a BlockProperties)>>,
layer: impl Fn(&str) -> Option<u32>,
) -> Self {
let mut out = Self {
rules,
..Self::default()
};
let mut blocks = HashMap::new();
let mut by_layer: Vec<Vec<u16>> = Vec::new();
// Block rules by the block each entry names, so a state tests only its own.
let mut by_block: HashMap<&str, Vec<u16>> = HashMap::new();
for (i, r) in out.rules.iter().enumerate() {
let i = i as u16;
for tile in &r.rule.match_tiles {
if let Some(l) = layer(&sprite_name(tile)) {
let l = l as usize;
if by_layer.len() <= l {
by_layer.resize(l + 1, Vec::new());
}
if by_layer[l].last() != Some(&i) {
by_layer[l].push(i);
}
}
}
for m in &r.rule.match_blocks {
let list = by_block.entry(m.id.as_str()).or_default();
if list.last() != Some(&i) {
list.push(i);
}
}
}
for list in by_layer {
let range = push_list(&mut out.lists, &list);
out.by_layer.push(range);
}
let mut list = Vec::new();
for state in states {
let Some((name, props)) = state else {
out.by_state.push((0, 0));
out.states.push(StateInfo::default());
continue;
};
let next = blocks.len() as u32;
let block = *blocks.entry(name).or_insert(next);
let prop = |k: &str| props.get(k).map(String::as_str);
out.states.push(StateInfo {
block,
axis: match prop("axis") {
Some("x") => Some(Axis::X),
Some("y") => Some(Axis::Y),
Some("z") => Some(Axis::Z),
_ => None,
},
upper: prop("half") == Some("upper"),
});
list.clear();
if let Some(candidates) = by_block.get(name) {
list.extend(
candidates
.iter()
.filter(|&&i| out.rules[usize::from(i)].rule.matches_block(name, prop)),
);
}
let range = push_list(&mut out.lists, &list);
out.by_state.push(range);
}
out
}
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
fn list(&self, ranges: &[(u32, u32)], i: usize) -> &[u16] {
ranges.get(i).map_or(&[], |&(start, len)| {
&self.lists[start as usize..(start + len) as usize]
})
}
/// What the `face` of the block `state` at `pos` draws, for a quad with sprite `layer`.
/// `neighbour` answers the state at an offset from the block; `biome` its biome, asked only
/// by rules that test one.
pub fn pick<'b>(
&self,
state: u32,
layer: u16,
face: Face,
pos: [i32; 3],
neighbour: impl Fn([i32; 3]) -> u32,
biome: impl Fn() -> Option<&'b str>,
) -> Pick {
let (mut by_state, mut by_layer) = (
self.list(&self.by_state, state as usize),
self.list(&self.by_layer, layer as usize),
);
// Both lists are in rule order: walk them as one. A rule naming blocks and tiles needs
// both (it is in both lists); one naming either needs that one.
loop {
let (i, block, tile) = match (by_state.split_first(), by_layer.split_first()) {
(None, None) => return Pick::Keep,
(Some((&x, rest)), Some((&y, other))) if x == y => {
(by_state, by_layer) = (rest, other);
(x, true, true)
}
(Some((&x, rest)), Some((&y, _))) if x < y => {
by_state = rest;
(x, true, false)
}
(Some((&x, rest)), None) => {
by_state = rest;
(x, true, false)
}
(_, Some((&y, rest))) => {
by_layer = rest;
(y, false, true)
}
};
let r = &self.rules[usize::from(i)];
if (block || r.rule.match_blocks.is_empty())
&& (tile || r.rule.match_tiles.is_empty())
&& let Some(pick) = self.apply(r, state, layer, face, pos, &neighbour, &biome)
{
return pick;
}
}
}
/// One rule on a face: `None` when it doesn't apply or picks `<skip>`.
#[allow(clippy::too_many_arguments)]
fn apply<'b>(
&self,
r: &LoadedRule,
state: u32,
layer: u16,
face: Face,
pos: [i32; 3],
neighbour: &impl Fn([i32; 3]) -> u32,
biome: &impl Fn() -> Option<&'b str>,
) -> Option<Pick> {
let rule = &r.rule;
// `Rule::applies_at`, with the biome looked up only for rules that test one. Rules with
// a custom name are not loaded.
if rule.faces & face.bit() == 0
|| rule.heights.as_ref().is_some_and(|h| !h.contains(pos[1]))
{
return None;
}
if let Some(biomes) = &rule.biomes
&& !biome().is_some_and(|b| biomes.matches(b))
{
return None;
}
let info = self.states.get(state as usize).copied().unwrap_or_default();
let joins = |offset: [i32; 3]| {
let other = neighbour(offset);
match rule.connect {
Connect::Block => {
self.states.get(other as usize).map(|s| s.block) == Some(info.block)
}
Connect::State => other == state,
Connect::Tile => self
.face_layers
.get(other as usize)
.is_some_and(|f| f[face as usize] == layer),
}
};
let axes = |face| match (rule.orient, info.axis) {
(Orient::StateAxis, Some(axis)) => Axes::column(face, axis),
// `orient=texture` follows the quad's own UVs; vanilla cubes are what it is used on.
_ => Axes::cube(face),
};
let mask = || geometry::neighbours(axes(face), face.normal(), rule.inner_seams, joins);
let tile = |i: usize| r.tiles.get(i).copied().unwrap_or(TileLayer::Default);
let index = match rule.method {
Method::Ctm => geometry::ctm_index(mask()),
Method::CtmCompact => {
let mask = mask();
let case = geometry::ctm_index(mask) as u8;
match rule.compact_overrides.iter().find(|&&(c, _)| c == case) {
Some(&(_, t)) => t,
None => {
let quarters = geometry::compact_quarters(mask).map(|q| tile(q.into()));
return if quarters.contains(&TileLayer::Skip) {
None
} else {
Some(Pick::Quarters(quarters))
};
}
}
}
Method::Horizontal => geometry::horizontal(mask()),
Method::Vertical => geometry::vertical(mask()),
Method::HorizontalVertical => geometry::horizontal_vertical(mask()),
Method::VerticalHorizontal => geometry::vertical_horizontal(mask()),
Method::Top => {
// Only sides join the block above: the top and bottom go on to the next rule.
if matches!(face, Face::Up | Face::Down) {
return None;
}
if !joins([0, 1, 0]) {
return Some(Pick::Keep);
}
0
}
Method::Fixed => 0,
Method::Random => {
let face = geometry::symmetric_face(face, rule.symmetry);
let pos = if rule.linked && info.upper {
[pos[0], pos[1] - 1, pos[2]]
} else {
pos
};
geometry::random_index(pos, face, rule.random_loops, &rule.weights, r.tiles.len())
}
Method::Repeat => {
let face = geometry::symmetric_face(face, rule.symmetry);
geometry::repeat_index(pos, axes(face), rule.width, rule.height)
}
// Overlays are not loaded yet.
_ => return None,
};
match tile(index) {
TileLayer::Skip => None,
TileLayer::Default => Some(Pick::Keep),
t => Some(Pick::Tile(t)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_optifine::Properties;
/// A tile whose layer says which tile of its rule it is: rule `r`'s tile `i` is layer
/// `1000 * (r + 1) + i`.
fn rules(files: &[(&str, &str)]) -> Vec<LoadedRule> {
let mut rules: Vec<Rule> = files
.iter()
.map(|(path, text)| {
Rule::parse(
&format!("assets/minecraft/optifine/ctm/{path}"),
&Properties::parse(text),
)
.unwrap()
.unwrap()
})
.collect();
ctm::sort(&mut rules);
rules
.into_iter()
.enumerate()
.map(|(n, rule)| LoadedRule {
tiles: rule
.tiles
.iter()
.enumerate()
.map(|(i, t)| match t {
Tile::Skip => TileLayer::Skip,
Tile::Default => TileLayer::Default,
Tile::Texture(_) => TileLayer::Layer {
layer: (1000 * (n + 1) + i) as u16,
translucent: false,
opaque: false,
},
})
.collect(),
rule,
})
.collect()
}
/// State 0 air, 1 glass, 2 stone, 3 and 4 oak logs (axis y, x), 5 and 6 a door (lower,
/// upper); the stone sprite is layer 7.
fn world(files: &[(&str, &str)]) -> ConnectedTextures {
let props = |pairs: &[(&str, &str)]| -> BlockProperties {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
};
let states = [
("minecraft:air", props(&[])),
("minecraft:glass", props(&[])),
("minecraft:stone", props(&[])),
("minecraft:oak_log", props(&[("axis", "y")])),
("minecraft:oak_log", props(&[("axis", "x")])),
("minecraft:oak_door", props(&[("half", "lower")])),
("minecraft:oak_door", props(&[("half", "upper")])),
];
ConnectedTextures::build(
rules(files),
states.iter().map(|(n, p)| Some((*n, p))),
|name| (name == "minecraft:block/stone").then_some(7),
)
}
fn tile(layer: u16) -> Pick {
Pick::Tile(TileLayer::Layer {
layer,
translucent: false,
opaque: false,
})
}
/// `pick` on `face` of the block at the origin among `blocks` (offset, state).
fn pick_in(
ctm: &ConnectedTextures,
state: u32,
layer: u16,
face: Face,
blocks: &[([i32; 3], u32)],
) -> Pick {
let at = |o: [i32; 3]| blocks.iter().find(|(p, _)| *p == o).map_or(0, |b| b.1);
ctm.pick(state, layer, face, [0, 64, 0], at, || None)
}
const GLASS: &str = "matchBlocks=glass\nmethod=ctm\ntiles=0-46";
#[test]
fn glass_joins_its_neighbours() {
let ctm = world(&[("glass.properties", GLASS)]);
// Alone: tile 0. A north face's right is west: glass there is the left end of a row.
assert_eq!(pick_in(&ctm, 1, 3, Face::North, &[]), tile(1000));
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 1)]),
tile(1001)
);
// Glass all around in the face's plane: the middle tile, 26.
let ring: Vec<_> = [-1, 0, 1]
.into_iter()
.flat_map(|x| [-1, 0, 1].map(|y| ([x, y, 0], 1)))
.collect();
assert_eq!(pick_in(&ctm, 1, 3, Face::North, &ring), tile(1026));
// Stone beside it doesn't join; stone has no rule.
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 2)]),
tile(1000)
);
assert_eq!(pick_in(&ctm, 2, 7, Face::North, &[]), Pick::Keep);
}
#[test]
fn rule_order_faces_and_skips() {
let ctm = world(&[
(
"a_sides.properties",
"matchBlocks=stone\nmethod=fixed\ntiles=<skip>\nfaces=sides",
),
("b_glass.properties", GLASS),
("stone.properties", "method=fixed\ntiles=x\nfaces=top"),
("z.properties", "matchBlocks=stone\nmethod=fixed\ntiles=y"),
]);
// The tile rule (stone.properties, by its file name) comes first, on the top face only.
assert_eq!(pick_in(&ctm, 2, 7, Face::Up, &[]), tile(1000));
// Sides: a_sides skips, so z draws.
assert_eq!(pick_in(&ctm, 2, 7, Face::North, &[]), tile(4000));
// The tile rule follows the sprite, whatever the block: glass with the stone sprite.
assert_eq!(pick_in(&ctm, 1, 7, Face::Up, &[]), tile(1000));
}
#[test]
fn compact_quarters_and_overrides() {
let ctm = world(&[(
"glass.properties",
"matchBlocks=glass\nmethod=ctm_compact\ntiles=0-5\nctm.1=5",
)]);
let layer = |i: u16| TileLayer::Layer {
layer: 1000 + i,
translucent: false,
opaque: false,
};
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[]),
Pick::Quarters([layer(0); 4])
);
// Joined right only is case 1, replaced whole by tile 5.
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 1)]),
tile(1005)
);
// Joined left only (east of a north face): quarters [3, 0, 3, 0].
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[([1, 0, 0], 1)]),
Pick::Quarters([layer(3), layer(0), layer(3), layer(0)])
);
}
#[test]
fn logs_turn_with_their_axis() {
let ctm = world(&[(
"log.properties",
"matchBlocks=oak_log\nmethod=vertical\ntiles=0-3",
)]);
// An upright log with a log above: the bottom of a column.
assert_eq!(
pick_in(&ctm, 3, 3, Face::North, &[([0, 1, 0], 3)]),
tile(1000)
);
// A log lying along x: on its north face the texture's up points east, so a log to the
// east (same block, other axis state) is above it.
assert_eq!(
pick_in(&ctm, 4, 3, Face::North, &[([1, 0, 0], 4)]),
tile(1000)
);
assert_eq!(
pick_in(&ctm, 4, 3, Face::North, &[([0, 1, 0], 4)]),
tile(1003)
);
}
#[test]
fn top_and_connect_state() {
let ctm = world(&[(
"block_oak_log.properties",
"method=top\ntiles=0\nconnect=state",
)]);
assert_eq!(
pick_in(&ctm, 3, 3, Face::North, &[([0, 1, 0], 3)]),
tile(1000)
);
// The other axis is another state.
assert_eq!(
pick_in(&ctm, 3, 3, Face::North, &[([0, 1, 0], 4)]),
Pick::Keep
);
assert_eq!(pick_in(&ctm, 3, 3, Face::Up, &[([0, 1, 0], 3)]), Pick::Keep);
}
#[test]
fn linked_random_follows_the_lower_half() {
let ctm = world(&[(
"door.properties",
"matchBlocks=oak_door\nmethod=random\ntiles=0-7\nlinked=true",
)]);
let mut tiles = std::collections::HashSet::new();
for x in 0..20 {
let lower = ctm.pick(5, 3, Face::North, [x, 64, 0], |_| 0, || None);
let upper = ctm.pick(6, 3, Face::North, [x, 65, 0], |_| 0, || None);
assert_eq!(lower, upper, "{x}");
tiles.insert(format!("{lower:?}"));
}
assert!(tiles.len() > 3, "the doors are not all alike");
}
#[test]
fn heights_and_biomes() {
let ctm = world(&[(
"glass.properties",
"matchBlocks=glass\nmethod=fixed\ntiles=0\nheights=0-63\nbiomes=desert",
)]);
let at =
|y, biome: &'static str| ctm.pick(1, 3, Face::Up, [0, y, 0], |_| 0, || Some(biome));
assert_eq!(at(10, "minecraft:desert"), tile(1000));
assert_eq!(at(64, "minecraft:desert"), Pick::Keep);
assert_eq!(at(10, "minecraft:plains"), Pick::Keep);
}
#[test]
fn connect_by_tile_reads_the_neighbours_same_side() {
let mut ctm = world(&[(
"glass.properties",
"matchBlocks=glass\nmethod=horizontal\nconnect=tile\ntiles=0-3",
)]);
// Stone shows the glass sprite (3) on its north side; the door only on its south.
let mut faces = vec![[9u16; 6]; 7];
faces[1][Face::North as usize] = 3;
faces[2][Face::North as usize] = 3;
faces[5][Face::South as usize] = 3;
ctm.face_layers = faces;
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 2)]),
tile(1000)
);
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 5)]),
tile(1003)
);
}
#[test]
fn repeat_and_rows_then_columns() {
let ctm = world(&[(
"glass.properties",
"matchBlocks=glass\nmethod=repeat\nwidth=2\nheight=2\ntiles=0-3",
)]);
let at = |pos| ctm.pick(1, 3, Face::North, pos, |_| 0, || None);
assert_eq!(at([0, 64, 0]), tile(1000));
// The north face's right is west; rows run down.
assert_eq!(at([-1, 64, 0]), tile(1001));
assert_eq!(at([0, 63, 0]), tile(1002));
let hv = world(&[(
"glass.properties",
"matchBlocks=glass\nmethod=horizontal+vertical\ntiles=0-6",
)]);
// Alone in its row, glass above: the column's bottom, tile 4.
assert_eq!(
pick_in(&hv, 1, 3, Face::North, &[([0, 1, 0], 1)]),
tile(1004)
);
assert_eq!(
pick_in(&hv, 1, 3, Face::North, &[([-1, 0, 0], 1)]),
tile(1000)
);
}
#[test]
fn skips_defaults_and_narrowed_rules() {
let ctm = world(&[
(
"a.properties",
"matchBlocks=glass\nmethod=ctm_compact\ntiles=<skip> 1-4",
),
(
"b.properties",
"matchBlocks=glass\nmethod=fixed\ntiles=0\nfaces=sides",
),
(
"c.properties",
"matchBlocks=glass\nmethod=fixed\ntiles=<default>",
),
("d.properties", "matchBlocks=glass\nmethod=fixed\ntiles=0"),
(
"e.properties",
"matchBlocks=stone\nmatchTiles=glass_pane_top\nmethod=fixed\ntiles=0",
),
]);
// e names a tile, so it is tried first (tile 1000s), but needs a stone with the pane
// sprite. a's skipped quarter drops it; b (3000s) draws the sides; on top c draws the
// glass itself, which stops d.
assert_eq!(pick_in(&ctm, 1, 3, Face::North, &[]), tile(3000));
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), Pick::Keep);
assert_eq!(pick_in(&ctm, 2, 7, Face::Up, &[]), Pick::Keep);
let both = world(&[(
"e.properties",
"matchBlocks=glass\nmatchTiles=stone\nmethod=fixed\ntiles=0",
)]);
assert_eq!(pick_in(&both, 1, 7, Face::Up, &[]), tile(1000));
assert_eq!(pick_in(&both, 1, 3, Face::Up, &[]), Pick::Keep);
assert_eq!(pick_in(&both, 2, 7, Face::Up, &[]), Pick::Keep);
// `top` passes the top face on.
let top = world(&[
("block_glass.properties", "method=top\ntiles=0"),
(
"z.properties",
"matchBlocks=glass\nmethod=fixed\ntiles=0\nfaces=top",
),
]);
assert_eq!(pick_in(&top, 1, 3, Face::Up, &[]), tile(2000));
}
#[test]
fn sprite_names() {
assert_eq!(
sprite_name("assets/minecraft/textures/block/dirt.png"),
"minecraft:block/dirt"
);
assert_eq!(
sprite_name("assets/minecraft/optifine/ctm/glass/0.png"),
"assets/minecraft/optifine/ctm/glass/0.png"
);
}
}
-24
View File
@@ -407,30 +407,6 @@ pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Opti
)
}
/// The report for a game that went over the player's RAM limit (`ram_limit`), written by the
/// watcher thread: it says nothing about where the memory went, so it carries no stack. The player
/// chose this limit, so it is no bug to send (`telemetry::keep_crash`), and leaving that out ends
/// the game seconds sooner: it keeps running, over its limit, until the report is written.
pub fn report_memory_limit(home: &Path, side: &'static str, message: String) {
if REPORTED.swap(true, Ordering::SeqCst) {
return;
}
let report = Report {
time: Local::now(),
description: "RAM limit reached".into(),
error: message,
stack: String::new(),
thread: thread_name(side),
side,
details: system_details(side),
log: crate::logging::recent_lines(),
};
match report.save(home) {
Ok(path) => eprintln!("{SAVED_LINE}{}", path.display()),
Err(e) => eprintln!("Horizon: could not save a crash report: {e}"),
}
}
fn payload_text(payload: &(dyn std::any::Any + Send)) -> String {
payload
.downcast_ref::<&str>()
+2 -34
View File
@@ -111,10 +111,6 @@ pub struct Extras {
pub sort: bool,
/// A vanilla key name: sorts the part under the cursor.
pub sort_key: String,
/// A new chat line slides in from the left while it fades in (`Chat::layout`).
pub chat_animation: bool,
/// The indicator bar at the left of a chat line (on by default, as vanilla).
pub chat_indicator: bool,
/// No AI: every AI feature hidden and off (`horizon_core::no_ai`; the launcher writes it too).
pub no_ai: bool,
/// Agents may control the game the player starts: it opens its control socket for
@@ -156,8 +152,6 @@ impl Default for Extras {
mouse_tweaks_wheel: true,
sort: false,
sort_key: "key.keyboard.unknown".into(),
chat_animation: false,
chat_indicator: true,
no_ai: false,
agent_control: false,
discord_presence: true,
@@ -236,8 +230,6 @@ impl Extras {
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
"sort" => e.sort = flag(e.sort),
"sortKey" if value.starts_with("key.") => e.sort_key = value.to_owned(),
"chatAnimation" => e.chat_animation = flag(e.chat_animation),
"chatIndicator" => e.chat_indicator = flag(e.chat_indicator),
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
"agentControl" => e.agent_control = flag(e.agent_control),
"discordPresence" => e.discord_presence = flag(e.discord_presence),
@@ -249,7 +241,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
format!(
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
bool_text(self.zoom),
@@ -282,8 +274,6 @@ impl Extras {
bool_text(self.mouse_tweaks_wheel),
bool_text(self.sort),
self.sort_key,
bool_text(self.chat_animation),
bool_text(self.chat_indicator),
horizon_core::no_ai::KEY,
bool_text(self.no_ai),
bool_text(self.agent_control),
@@ -416,10 +406,6 @@ pub enum Item {
MouseTweaksWheel,
Sort,
SortKey,
/// A new chat line slides in and fades in (`Chat::layout`).
ChatAnimation,
/// The coloured bar at the left of a chat line.
ChatIndicator,
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
NoAi,
AgentControl,
@@ -444,9 +430,6 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Ok(MouseTweaksWheel),
Ok(Sort),
Ok(SortKey),
Err("Chat"),
Ok(ChatAnimation),
Ok(ChatIndicator),
Err("Multiplayer"),
Ok(AutoReconnect),
Err("Sound"),
@@ -466,8 +449,6 @@ impl Item {
Item::MouseTweaksWheel => "Scroll wheel moves items",
Item::Sort => "Sort buttons",
Item::SortKey => "Sort key",
Item::ChatAnimation => "Smooth chat",
Item::ChatIndicator => "Message indicator",
Item::NoAi => "No AI",
Item::AgentControl => "Agent Control",
Item::DiscordPresence => "Discord Rich Presence",
@@ -481,10 +462,8 @@ impl Item {
UiSoundReserve => return None,
MouseTweaksWheel => MouseTweaks,
SortKey => Sort,
ChatIndicator => ChatAnimation,
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | DiscordPresence | Splitscreen
| ChatAnimation => {
| AutoReconnect | NoAi | AgentControl | DiscordPresence | Splitscreen => {
return None;
}
})
@@ -520,10 +499,6 @@ impl Item {
Item::SortKey => {
"Sorts the part under the cursor. Click, then press a key; Escape unbinds it."
}
Item::ChatAnimation => {
"A new chat line slides in from the left while it fades in, instead of appearing whole."
}
Item::ChatIndicator => "The coloured bar at the left of a chat line.",
Item::NoAi => {
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
}
@@ -545,7 +520,6 @@ impl Item {
Item::Fullbright => Some(Cost::None),
Item::UiSoundReserve => Some(Cost::None),
Item::DynamicLights => Some(Cost::Low),
Item::ChatAnimation => Some(Cost::None),
Item::OwnNameTag
| Item::HideBlockParticles
| Item::AutoReconnect
@@ -567,8 +541,6 @@ impl Item {
Item::MouseTweaks => e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
Item::Sort => e.sort,
Item::ChatAnimation => e.chat_animation,
Item::ChatIndicator => e.chat_indicator,
Item::NoAi => e.no_ai,
Item::AgentControl => e.agent_control,
Item::DiscordPresence => e.discord_presence,
@@ -599,8 +571,6 @@ impl Item {
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
Item::Sort => e.sort = !e.sort,
Item::ChatAnimation => e.chat_animation = !e.chat_animation,
Item::ChatIndicator => e.chat_indicator = !e.chat_indicator,
Item::NoAi => e.no_ai = !e.no_ai,
Item::AgentControl => e.agent_control = !e.agent_control,
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
@@ -663,8 +633,6 @@ mod tests {
e.mouse_tweaks_wheel = false;
e.sort = true;
e.sort_key = "key.keyboard.r".into();
e.chat_animation = true;
e.chat_indicator = false;
e.discord_presence = false;
assert!(!e.splitscreen);
Item::Splitscreen.cycle(&mut e, false);
+32 -265
View File
@@ -142,7 +142,6 @@ pub mod panorama;
mod readback;
mod resident;
mod stars;
pub mod submit;
mod sunrise_fan;
mod timing;
pub mod translucency;
@@ -165,50 +164,6 @@ fn sky_visible(frame: &Frame) -> bool {
!frame.title && frame.skybox && sky_open(frame)
}
/// The world pass's part that draws a shader pack's solid terrain: the world's colour and
/// depth loaded, and its `surface` target, cleared on the first of these passes.
fn surface_pass<'e>(
encoder: &'e mut wgpu::CommandEncoder,
view: &wgpu::TextureView,
surface: &wgpu::TextureView,
depth: &wgpu::TextureView,
clear: bool,
) -> wgpu::RenderPass<'e> {
let keep = |load| wgpu::Operations {
load,
store: wgpu::StoreOp::Store,
};
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon world, pack terrain"),
color_attachments: &[
Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: keep(wgpu::LoadOp::Load),
}),
Some(wgpu::RenderPassColorAttachment {
view: surface,
resolve_target: None,
depth_slice: None,
ops: keep(if clear {
wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT)
} else {
wgpu::LoadOp::Load
}),
}),
],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
})
}
fn world_pass_after_culling<'e>(
encoder: &'e mut wgpu::CommandEncoder,
view: &wgpu::TextureView,
@@ -663,11 +618,7 @@ pub struct Renderer {
display_fresh: bool,
window: Arc<Window>,
pub device: wgpu::Device,
/// Frames are finished and submitted on its submit thread ([`submit`]).
pub queue: submit::Queue,
/// The last frame went to the submit thread; whether it had no window surface.
/// The next frame takes it in ([`Self::land_frame`]).
deferred: Option<bool>,
pub queue: wgpu::Queue,
/// Set by the device's lost callback. wgpu reports nothing else when an
/// operation fails on a lost device (no error scope sees it).
device_lost: Arc<std::sync::atomic::AtomicBool>,
@@ -689,9 +640,6 @@ pub struct Renderer {
sides_on: bool,
sky: wgpu::RenderPipeline,
dark_disc: wgpu::RenderPipeline,
/// Clears the world pass's depth to the far plane so the first-person hand draws in the
/// same pass (`hand_in_world`), as the hand's own pass would clear it.
depth_reset: wgpu::RenderPipeline,
lines: wgpu::RenderPipeline,
lines_secondary: wgpu::RenderPipeline,
/// Gizmo lines (`RenderTypes.linesTranslucentNoDepthWrite`) and the
@@ -2091,40 +2039,6 @@ impl Renderer {
false,
Some(alpha_blend),
);
let depth_reset = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Hand depth reset"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_sky"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_depth_reset"),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &sprite_constants,
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format: config.format.remove_srgb_suffix(),
blend: None,
write_mask: wgpu::ColorWrites::empty(),
})],
}),
multiview_mask: None,
cache: None,
});
let lines = pipeline(
"Block outline",
"vs_line",
@@ -3444,8 +3358,7 @@ impl Renderer {
last_encode: std::time::Duration::ZERO,
window,
device,
queue: submit::Queue::new(queue, submit::wanted()),
deferred: None,
queue,
device_lost,
config,
solid,
@@ -3459,7 +3372,6 @@ impl Renderer {
flat,
sky,
dark_disc,
depth_reset,
lines,
lines_secondary,
debug_lines,
@@ -3619,14 +3531,6 @@ impl Renderer {
present_modes,
})
}
/// The first-person hand's quads, which follow `first` quads of the entity buffer.
fn draw_hand(&self, pass: &mut wgpu::RenderPass<'_>, quads: u32, first: u32) {
pass.set_pipeline(&self.entity_pipeline);
pass.set_bind_group(0, &self.hand_group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(0..quads * 6, (first * 4) as i32, 0..1);
}
/// LevelRenderer.submitBlockOutline: the buffer holds `edges` per stroke, the black
/// secondary stroke first when high contrast is on.
fn draw_outline(&self, pass: &mut wgpu::RenderPass<'_>, edges: u32, high_contrast: bool) {
@@ -3674,7 +3578,6 @@ impl Renderer {
/// Hand the window surface over to the renderer that replaces this one on
/// the same device (it takes `spare_surface`).
pub fn release_surface(&mut self) {
self.queue.wait();
*self
.spare_surface
.get_mut()
@@ -3682,7 +3585,6 @@ impl Renderer {
}
/// Reattach a window after surface loss or a failed renderer replacement.
pub fn recreate_surface(&mut self) -> Result<()> {
self.queue.wait();
if self.offscreen.is_some() {
return Ok(());
}
@@ -4403,8 +4305,6 @@ impl Renderer {
/// Configures the window surface again. One that fails is still the one
/// wgpu configured before, which acquisition reports as a validation error.
fn configure_surface(&self) {
// The submit thread may still present to the old swapchain.
self.queue.wait();
if let Some(surface) = &self.surface
&& let Err(e) = Self::configure(
&self.device,
@@ -5044,7 +4944,6 @@ impl Renderer {
}
/// `checks`: the error scopes of a scaled screenshot's frame, checked before its PNG is written.
fn draw_frame(&mut self, frame: &Frame, checks: Option<GpuChecks>) -> Result<bool> {
self.land_frame();
// World frames alone follow the shader pack: the game loads the picked one
// before it draws one. Title frames draw no terrain and keep the layout
// through a pack dropped (Shader Options) or not loaded yet (`Renderer::new`),
@@ -6292,14 +6191,6 @@ impl Renderer {
if culling && let Some(cull) = &mut self.cull {
cull.bind_depth(&self.device, world_depth);
}
// The hand draws at the end of the world pass, after a depth reset, when nothing runs
// between them (a scaled world's post pass, HDR's world copy, a native mod's
// `native_world_drawn`): one render pass fewer, ~10 µs of wgpu's encoding a frame.
let hand_in_world = (frame.hand.len() / 4) > 0
&& !pack_draws_hand
&& scaled.is_none()
&& self.hdr.is_none()
&& !native_render::world_drawn_hooked();
if oit_on {
let size = self
.world_post
@@ -6628,16 +6519,6 @@ impl Renderer {
.frame
.fit_opaque(&self.device, world_depth.texture(), color);
}
if let Some(native) = self.native.as_mut().filter(|_| !frame.title) {
let writes = native.material.as_ref().is_some_and(|m| m.writes_surface);
let depth = world_depth.texture();
let size = if writes {
[depth.width(), depth.height()]
} else {
[1, 1]
};
native.frame.fit_surface(&self.device, size);
}
// One of Horizon's own packs with a material stage: terrain draws with its pipelines,
// which read the pack's frame at group 3.
let material = self
@@ -6660,17 +6541,6 @@ impl Renderer {
pass.set_bind_group(horizon_shader_api::stages::GROUP, group, &[]);
}
};
// A pack's solid terrain that calls `set_surface` also writes the surface target, in
// passes of its own between the sky and everything else.
let surface = self
.native
.as_ref()
.filter(|n| {
!frame.title
&& !pack_draws_terrain
&& n.material.as_ref().is_some_and(|m| m.writes_surface)
})
.map(|n| n.frame.surface_view.clone());
{
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: world_view,
@@ -6756,28 +6626,6 @@ impl Renderer {
pass.draw(0..3, 0..1);
}
}
let terrain_pass =
|encoder: &mut wgpu::CommandEncoder, surface: &wgpu::TextureView, clear: bool| {
// Its lifetime forgotten: a closure cannot tie it to `encoder`'s.
let mut pass = surface_pass(encoder, world_view, surface, world_depth, clear)
.forget_lifetime();
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
pass
};
// The occlusion test's pipeline draws into the world's colour alone.
let plain_pass = |encoder: &mut wgpu::CommandEncoder| {
let mut pass =
world_pass_after_culling(encoder, world_view, world_depth, true, None, "")
.forget_lifetime();
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
pass
};
if let Some(surface) = &surface {
drop(pass);
pass = terrain_pass(&mut encoder, surface, true);
}
match self
.flat
.as_ref()
@@ -6806,25 +6654,18 @@ impl Renderer {
// that the test saw, copied after it.
Some(cull) => {
draw(&mut pass, &flat.calls[..flat.phase1_calls]);
if surface.is_some() {
drop(pass);
pass = plain_pass(&mut encoder);
}
cull.test(&mut pass);
drop(pass);
cull.test_pass(&mut encoder, self.own_timing.as_mut());
flat.flatten_phase2(&mut encoder, self.own_timing.as_mut());
pass = match &surface {
Some(surface) => terrain_pass(&mut encoder, surface, false),
None => world_pass_after_culling(
&mut encoder,
world_view,
world_depth,
keep_depth,
self.own_timing.as_mut(),
"World after culling",
),
};
pass = world_pass_after_culling(
&mut encoder,
world_view,
world_depth,
keep_depth,
self.own_timing.as_mut(),
"World after culling",
);
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
for &pipeline in passes {
@@ -6856,24 +6697,17 @@ impl Renderer {
pass.set_pipeline(pipeline);
self.draw_sections(&mut pass, &self.indirect, 0, 0, phase1_draws);
}
if surface.is_some() {
drop(pass);
pass = plain_pass(&mut encoder);
}
cull.test(&mut pass);
drop(pass);
cull.test_pass(&mut encoder, self.own_timing.as_mut());
pass = match &surface {
Some(surface) => terrain_pass(&mut encoder, surface, false),
None => world_pass_after_culling(
&mut encoder,
world_view,
world_depth,
keep_depth,
self.own_timing.as_mut(),
"World after culling",
),
};
pass = world_pass_after_culling(
&mut encoder,
world_view,
world_depth,
keep_depth,
self.own_timing.as_mut(),
"World after culling",
);
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
pass.set_index_buffer(
@@ -6901,11 +6735,6 @@ impl Renderer {
}
}
}
if surface.is_some() {
drop(pass);
pass = plain_pass(&mut encoder);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
}
if entity_quads > 0 {
pass.set_pipeline(&self.entity_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
@@ -7368,12 +7197,6 @@ impl Renderer {
0..1,
);
}
if hand_in_world {
pass.set_pipeline(&self.depth_reset);
pass.set_bind_group(0, &self.group, &[]);
pass.draw(0..3, 0..1);
self.draw_hand(&mut pass, hand_quads, world_quads + additive_quads);
}
}
if let Some((color, depth)) = &scaled
&& pack_post
@@ -7494,7 +7317,7 @@ impl Renderer {
post.run(
&self.device,
&mut encoder,
(color, depth, &native.frame.surface_view),
(color, depth),
&view_target,
size,
history_reset,
@@ -7535,7 +7358,7 @@ impl Renderer {
if let Some(hdr) = &self.hdr {
hdr.keep_world(&mut encoder, self.own_timing.as_mut());
}
if hand_quads > 0 && !pack_draws_hand && !hand_in_world {
if hand_quads > 0 && !pack_draws_hand {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &view_target,
resolve_target: None,
@@ -7559,7 +7382,15 @@ impl Renderer {
}),
..Default::default()
});
self.draw_hand(&mut pass, hand_quads, world_quads + additive_quads);
pass.set_pipeline(&self.entity_pipeline);
pass.set_bind_group(0, &self.hand_group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..hand_quads * 6,
((world_quads + additive_quads) * 4) as i32,
0..1,
);
drop(pass);
if let Some(hdr) = &self.hdr {
hdr.mark_hand(&mut encoder, self.own_timing.as_mut());
@@ -7747,54 +7578,15 @@ impl Renderer {
let size = [self.config.width, self.config.height];
Some(copy_frame(&self.device, &mut encoder, &texture, size))
});
let first_frame = self.first_frame == FirstFrame::Submit;
// Finished, submitted and presented on the submit thread when nothing after the
// submission needs this one: screenshots' readbacks, the error scopes (one per
// thread), the first frame's startup log, VSync's frame callback, a reconfigure.
let (encoder, surface_frame, after) = if readback.is_none()
&& hdr_readback.is_none()
&& checks.is_none()
&& !first_frame
&& !reconfigure
&& self.hdr_marker.is_none()
&& (surface_frame.is_none() || self.config.present_mode != wgpu::PresentMode::Fifo)
{
let offscreen = surface_frame.is_none();
// Culling reads its results back as soon as the frame is submitted, as here.
let after = self
.cull
.as_mut()
.and_then(|c| c.submitted())
.map(|f| Box::new(f) as submit::AfterSubmit);
let job = submit::Job {
encoder,
surface: surface_frame,
after,
};
match self.queue.submit_later(job) {
Ok(()) => {
self.deferred = Some(offscreen);
self.last_encode = started.elapsed().saturating_sub(self.last_wait);
self.visible = visible;
self.visible_lods = visible_lods;
self.shadow_visible = shadow_visible;
return Ok(true);
}
Err(back) => (back.encoder, back.surface, back.after),
}
} else {
(encoder, surface_frame, None)
};
let commands = crate::spans::time("encode", || encoder.finish());
let first_frame = self.first_frame == FirstFrame::Submit;
if first_frame {
startup_step(format_args!("submitting the first frame"));
self.first_frame = FirstFrame::Drawn;
}
let submission = crate::spans::time("submit", || self.queue.submit([commands]));
if let Some(read) = after {
read();
} else if let Some(read) = self.cull.as_mut().and_then(|c| c.submitted()) {
read();
if let Some(cull) = &mut self.cull {
cull.submitted();
}
if let Some(timer) = &mut self.gpu_timer {
timer.submitted(&self.queue);
@@ -7942,33 +7734,8 @@ pub(crate) fn copy_frame(
buffer
}
impl Renderer {
/// Take in the frame the submit thread submitted: start its readbacks and, offscreen,
/// keep at most two frames ahead of the GPU, as a frame submitted in place does.
fn land_frame(&mut self) {
let Some(offscreen) = self.deferred.take() else {
return;
};
let submission = self.queue.landed();
if let Some(timer) = &mut self.gpu_timer {
timer.submitted(&self.queue);
timer.collect(&self.device, &mut self.gpu_times);
}
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
timer.submitted(&self.queue);
}
if offscreen && let Some(submission) = submission {
self.in_flight.push_back(submission);
if !self.paced_elsewhere {
self.wait_frames();
}
}
}
/// No swapchain paces offscreen frames: keep two on the GPU at most.
pub fn wait_in_flight(&mut self) {
self.land_frame();
self.wait_frames();
}
fn wait_frames(&mut self) {
while self.in_flight.len() > 2 {
let index = self.in_flight.pop_front();
let _ = self.device.poll(wgpu::PollType::Wait {
+13 -15
View File
@@ -585,25 +585,23 @@ impl Cull {
self.current = Some(self.next);
}
/// The frame's results to read back, started by calling it right after the frame's
/// submission (on the submit thread, when the frame goes there).
pub fn submitted(&mut self) -> Option<impl FnOnce() + Send + 'static> {
let i = self.current.take()?;
/// After the frame's submission: start reading its results back.
pub fn submitted(&mut self) {
let Some(i) = self.current.take() else {
return;
};
let r = &self.readbacks[i];
let state = r.state.clone();
state.store(PENDING, Ordering::Release);
let (buffer, bytes) = (r.buffer.clone(), r.slots.len() as u64 * 4);
r.buffer
.slice(..r.slots.len() as u64 * 4)
.map_async(wgpu::MapMode::Read, move |result| {
state.store(
if result.is_ok() { MAPPED } else { FREE },
Ordering::Release,
);
});
self.next = (i + 1) % READBACKS;
Some(move || {
buffer
.slice(..bytes)
.map_async(wgpu::MapMode::Read, move |result| {
state.store(
if result.is_ok() { MAPPED } else { FREE },
Ordering::Release,
);
});
})
}
fn make_group(
@@ -139,32 +139,17 @@ fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32
// Water draws translucent and lava solid, so the pass resolves the fluid kind.
let fluid = select(0u, select(2u, 1u, translucent), in.fluid_quad != 0u);
let normal = horizon_material_normal(in.position);
horizon_material_face_normal = normal;
horizon_surface = vec4<f32>(0.0);
let tangent = horizon_material_tangent(in.position, in.uv, normal);
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
translucent, horizon_sprite_tags(in.layer & 0x7fffu),
horizon_sprite_material(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
in.face, fluid, in.emission, normal, tangent)).color;
}
var<private> horizon_material_face_normal: vec3<f32>;
struct HorizonMaterialSolid {
@location(0) color: vec4<f32>,
// The surface target's data: `set_surface`'s, then the fragment's depth, which the post
// stage's `surface_at` compares with the final depth.
@location(1) surface: vec4<f32>,
}
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
let color = horizon_material_color(in, false);
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
if horizon_surface.w == 0.0 {
set_surface(horizon_material_face_normal, 0.0, 0.0);
}
var out: HorizonMaterialSolid;
out.color = horizon_material_fogged(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
out.surface = vec4(horizon_surface.xyz, in.clip.z);
return out;
return horizon_material_fogged(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
}
@fragment fn horizon_material_fs_translucent(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
let color = horizon_material_color(in, true);
@@ -204,8 +189,6 @@ pub struct NativeMaterial {
pub solid: wgpu::RenderPipeline,
pub translucent: wgpu::RenderPipeline,
pub flat: Option<(wgpu::RenderPipeline, wgpu::RenderPipeline)>,
/// The stage calls `set_surface`: its solid pipelines also write the surface target.
pub writes_surface: bool,
}
/// terrain.wgsl's terrain section (its globals, bindings, sampling, lighting and fog), with
@@ -237,10 +220,6 @@ fn layer_table(name: &str, table: &[(u32, u32)]) -> String {
/// The terrain shader for the pack's material stage `source`, with its sprite `tags` and
/// `materials`.
/// The surface target (the post stage's `scene_surface`): x the normal (two snorm16 in its
/// bits), y smoothness, z the pack's data, w the fragment's depth.
pub const SURFACE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
fn shader_source(source: &str, tags: &[(u32, u32)], materials: &[(u32, u32)]) -> String {
format!(
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{ENTRIES}",
@@ -271,7 +250,6 @@ impl NativeMaterial {
constants: &[(String, f64)],
fog: bool,
) -> Result<Self, String> {
let writes_surface = horizon_shader_api::stages::calls_set_surface(&stage.source);
let module = module(stage, shape.sprite_tags, shape.sprite_materials)?;
let mut constants = present_constants(&module, constants);
let sprite_names = [
@@ -318,18 +296,6 @@ impl NativeMaterial {
constants: &constants,
..Default::default()
};
let solid_targets = [
Some(wgpu::ColorTargetState {
format: shape.format,
blend,
write_mask: wgpu::ColorWrites::ALL,
}),
Some(wgpu::ColorTargetState {
format: SURFACE_FORMAT,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
}),
];
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(&stage.label),
layout: Some(layout),
@@ -355,11 +321,11 @@ impl NativeMaterial {
module: &module,
entry_point: Some(fragment),
compilation_options: options,
targets: if fragment == "horizon_material_fs_solid" && writes_surface {
&solid_targets
} else {
&solid_targets[..1]
},
targets: &[Some(wgpu::ColorTargetState {
format: shape.format,
blend,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
@@ -404,7 +370,6 @@ impl NativeMaterial {
solid,
translucent,
flat,
writes_surface,
})
}
}
@@ -14,7 +14,7 @@ use super::Frame;
const INTERMEDIATE: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
/// The world's colour and depth views a set of bind groups was made for.
type WorldViews = (wgpu::TextureView, wgpu::TextureView, wgpu::TextureView);
type WorldViews = (wgpu::TextureView, wgpu::TextureView);
/// Where a post pass draws.
#[derive(Clone, Copy, PartialEq)]
@@ -103,9 +103,6 @@ pub struct NativePost {
/// The `frame` every stage of a pack reads (group 3, binding 0), written once a frame, and
/// what the material stage reads besides.
pub struct NativeFrame {
/// The solid terrain's second target, the post stage's `scene_surface`.
surface: wgpu::Texture,
pub surface_view: wgpu::TextureView,
pub(super) buffer: wgpu::Buffer,
/// Group 3 of the sky and material stages.
pub layout: wgpu::BindGroupLayout,
@@ -273,8 +270,6 @@ impl NativeFrame {
label: Some("Native shader pack frame"),
entries: &entries,
});
let surface = surface_target(device, [1, 1]);
let surface_view = surface.create_view(&Default::default());
let opaque_depth = depth_copy(device, [1, 1]);
let opaque_color = color_copy(device, [1, 1], wgpu::TextureFormat::Rgba8Unorm);
let blank = copy_target(
@@ -337,8 +332,6 @@ impl NativeFrame {
buffer,
layout,
group,
surface,
surface_view,
opaque_depth,
opaque_color,
reflection: blank.clone(),
@@ -475,14 +468,6 @@ impl NativeFrame {
self.regroup(device);
}
/// Sizes the surface target: the world's while a material stage writes it, else a blank.
pub fn fit_surface(&mut self, device: &wgpu::Device, size: [u32; 2]) {
if [self.surface.width(), self.surface.height()] != size {
self.surface = surface_target(device, size);
self.surface_view = self.surface.create_view(&Default::default());
}
}
/// Binds `view` as this frame's `reflection` (`None`: the blank). The mirror is drawn
/// before the stages read it.
pub fn set_reflection(&mut self, device: &wgpu::Device, view: Option<&wgpu::TextureView>) {
@@ -588,24 +573,6 @@ pub fn present_constants<'a>(
.collect()
}
/// The solid terrain's surface target of `size`, drawn and read.
fn surface_target(device: &wgpu::Device, size: [u32; 2]) -> wgpu::Texture {
device.create_texture(&wgpu::TextureDescriptor {
label: Some("Native shader pack surface"),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: super::native_material::SURFACE_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
}
fn texture_entry(binding: u32, sample_type: wgpu::TextureSampleType) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
@@ -685,7 +652,6 @@ impl NativePost {
texture_entry(8, float),
texture_entry(9, wgpu::TextureSampleType::Depth),
texture_entry(10, float),
texture_entry(11, wgpu::TextureSampleType::Float { filterable: false }),
];
entries.extend(pack_texture_entries(frame.textures.len()));
entries
@@ -791,7 +757,7 @@ impl NativePost {
fn prepare(
&mut self,
device: &wgpu::Device,
world: (&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView),
world: (&wgpu::TextureView, &wgpu::TextureView),
size: [u32; 2],
history_reset: bool,
) {
@@ -864,11 +830,8 @@ impl NativePost {
if self
.groups
.as_ref()
.is_some_and(|((color, depth, surface), blank, _)| {
color == world.0
&& depth == world.1
&& surface == world.2
&& *blank == blank_history
.is_some_and(|((color, depth), blank, _)| {
color == world.0 && depth == world.1 && *blank == blank_history
})
{
return;
@@ -930,10 +893,6 @@ impl NativePost {
binding: 9,
resource: wgpu::BindingResource::TextureView(&self.shadow.2),
},
wgpu::BindGroupEntry {
binding: 11,
resource: wgpu::BindingResource::TextureView(world.2),
},
wgpu::BindGroupEntry {
binding: 10,
resource: wgpu::BindingResource::TextureView(&self.shadow.3),
@@ -961,11 +920,7 @@ impl NativePost {
})
})
.collect();
self.groups = Some((
(world.0.clone(), world.1.clone(), world.2.clone()),
blank_history,
groups,
));
self.groups = Some(((world.0.clone(), world.1.clone()), blank_history, groups));
}
/// Runs the passes over the world target `world` (colour, depth) of `size`, the last onto
@@ -974,7 +929,7 @@ impl NativePost {
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
world: (&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView),
world: (&wgpu::TextureView, &wgpu::TextureView),
screen: &wgpu::TextureView,
size: [u32; 2],
history_reset: bool,
@@ -1394,7 +1349,7 @@ mod tests {
post.run(
&device,
&mut encoder,
(&color, &depth, &frame.surface_view),
(&color, &depth),
&view,
[4, 4],
true,
@@ -1501,7 +1456,7 @@ file = \"b.wgsl\"
post.run(
&device,
&mut encoder,
(&color, &depth, &frame.surface_view),
(&color, &depth),
&view,
[4, 4],
history_reset,
@@ -6,19 +6,6 @@
use super::Vertex;
use super::pack_draw::EntitySpan;
use glam::{DVec3, Mat4};
use std::sync::atomic::{AtomicBool, Ordering};
static MODS_BUILT_IN: AtomicBool = AtomicBool::new(false);
/// The game was built with native mods (the game sets it at start): one of them may draw in
/// [`native_world_drawn`], so the first-person hand keeps its own pass after it.
pub fn set_mods_built_in(on: bool) {
MODS_BUILT_IN.store(on, Ordering::Relaxed);
}
/// Whether [`native_world_drawn`] may draw ([`set_mods_built_in`]).
pub fn world_drawn_hooked() -> bool {
MODS_BUILT_IN.load(Ordering::Relaxed)
}
/// What a mod can draw with once the world is drawn: after the sky, terrain, entities,
/// particles, weather and the world post pass, before the first-person hand, the HUD and the
@@ -1,228 +0,0 @@
//! The frame's commands finished, submitted and presented on a thread of their own.
//!
//! wgpu records a pass's commands lazily: `CommandEncoder::finish` validates and
//! encodes the whole frame (about 0.1 ms with the submit), work the window's thread
//! would otherwise wait for before building the next frame. The renderer hands the
//! frame's encoder to [`Queue::submit_later`] and goes on; every use of the queue
//! ([`Deref`]) first waits for that frame to be submitted, so writes, copies and
//! submissions keep the order they had on one thread.
use std::ops::Deref;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, mpsc};
/// Whether frames go to the submit thread, unless `HORIZON_SUBMIT_THREAD` says (`0` off, `1` on):
/// on Linux, where it was measured faster (+32% FPS in a CPU-bound view on Vulkan, #5779). On
/// macOS's Metal it stalled: offscreen at render distance 12, a tenth to a half of the frame rate
/// with 20-180 ms p99 frames, against the same binary without it. Windows is not measured yet.
pub fn wanted() -> bool {
match std::env::var("HORIZON_SUBMIT_THREAD").as_deref() {
Ok("0") => false,
Ok("1") => true,
_ => cfg!(target_os = "linux"),
}
}
/// Work to start right after a frame's submission (a readback's `map_async`).
pub type AfterSubmit = Box<dyn FnOnce() + Send>;
/// A frame for the submit thread: its commands and, in a window, the surface to present.
pub struct Job {
pub encoder: wgpu::CommandEncoder,
pub surface: Option<wgpu::SurfaceTexture>,
pub after: Option<AfterSubmit>,
}
struct Thread {
jobs: Option<mpsc::Sender<Job>>,
handle: Option<std::thread::JoinHandle<()>>,
}
/// What the submit thread sent back, once it is taken in by [`Queue::wait`].
struct Landing {
done: mpsc::Receiver<wgpu::SubmissionIndex>,
landed: Option<wgpu::SubmissionIndex>,
}
/// The device's queue, with frames submitted on the submit thread when it runs.
pub struct Queue {
queue: wgpu::Queue,
thread: Option<Thread>,
/// A frame went to the submit thread and has not been waited for.
pending: AtomicBool,
landing: Mutex<Option<Landing>>,
}
impl Queue {
/// `threaded`: frames may go to a submit thread (the renderer asks [`wanted`]).
pub fn new(queue: wgpu::Queue, threaded: bool) -> Self {
let mut this = Self {
queue,
thread: None,
pending: AtomicBool::new(false),
landing: Mutex::new(None),
};
if !threaded {
return this;
}
let (jobs, job_rx) = mpsc::channel::<Job>();
let (done_tx, done) = mpsc::channel();
let queue = this.queue.clone();
let spawned = std::thread::Builder::new()
.name("Render submit".into())
.spawn(move || {
for job in job_rx {
let commands = job.encoder.finish();
let submission = queue.submit([commands]);
if let Some(after) = job.after {
after();
}
if let Some(surface) = job.surface {
queue.present(surface);
}
if done_tx.send(submission).is_err() {
break;
}
}
});
match spawned {
Ok(handle) => {
this.thread = Some(Thread {
jobs: Some(jobs),
handle: Some(handle),
});
*this.lock() = Some(Landing { done, landed: None });
}
Err(e) => log::warn!("Horizon renderer: no submit thread ({e}); submitting in place"),
}
this
}
fn lock(&self) -> std::sync::MutexGuard<'_, Option<Landing>> {
self.landing
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Whether frames can go to the submit thread.
pub fn threaded(&self) -> bool {
self.thread.is_some()
}
/// Finish, submit and present the frame on the submit thread, then run its `after`;
/// given back, with nothing done, when there is none.
pub fn submit_later(&self, job: Job) -> Result<(), Box<Job>> {
let Some(jobs) = self.thread.as_ref().and_then(|t| t.jobs.as_ref()) else {
return Err(Box::new(job));
};
self.wait();
match jobs.send(job) {
Ok(()) => {
self.pending.store(true, Ordering::Release);
Ok(())
}
Err(mpsc::SendError(job)) => Err(Box::new(job)),
}
}
/// Wait until the frame on the submit thread is submitted and presented.
pub fn wait(&self) {
if !self.pending.load(Ordering::Acquire) {
return;
}
let mut landing = self.lock();
if !self.pending.load(Ordering::Acquire) {
return;
}
if let Some(l) = landing.as_mut() {
// A thread that died (a wgpu panic on it) panics here, as it would have in place.
let submission = l
.done
.recv()
.expect("Horizon renderer: the submit thread stopped");
l.landed = Some(submission);
}
self.pending.store(false, Ordering::Release);
}
/// The submission of the frame [`submit_later`](Self::submit_later) took, once it is
/// made; `None` if it was taken already.
pub fn landed(&self) -> Option<wgpu::SubmissionIndex> {
self.wait();
self.lock().as_mut().and_then(|l| l.landed.take())
}
}
impl Deref for Queue {
type Target = wgpu::Queue;
fn deref(&self) -> &wgpu::Queue {
self.wait();
&self.queue
}
}
impl Drop for Queue {
fn drop(&mut self) {
self.wait();
if let Some(thread) = &mut self.thread {
thread.jobs.take();
if let Some(handle) = thread.handle.take() {
let _ = handle.join();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
fn buffer(device: &wgpu::Device, usage: wgpu::BufferUsages) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 4,
usage,
mapped_at_creation: false,
})
}
#[test]
fn writes_after_a_frame_on_the_thread_land_after_it() {
let Some((device, queue)) = device() else {
return;
};
let queue = Queue::new(queue, true);
assert!(queue.threaded());
use wgpu::BufferUsages as U;
let source = buffer(&device, U::COPY_SRC | U::COPY_DST);
let copy = buffer(&device, U::COPY_SRC | U::COPY_DST);
let read = buffer(&device, U::MAP_READ | U::COPY_DST);
queue.write_buffer(&source, 0, &[1, 2, 3, 4]);
for frame in 0..3u8 {
// The frame copies `source`; the next frame's write must not reach that copy.
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_buffer_to_buffer(&source, 0, &copy, 0, 4);
let job = Job {
encoder,
surface: None,
after: None,
};
assert!(queue.submit_later(job).is_ok());
queue.write_buffer(&source, 0, &[frame + 10; 4]);
assert!(queue.landed().is_some(), "frame {frame} submitted");
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_buffer_to_buffer(&copy, 0, &read, 0, 4);
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let expected = if frame == 0 {
[1, 2, 3, 4]
} else {
[frame + 9; 4]
};
assert_eq!(*read.slice(..).get_mapped_range().unwrap(), expected);
read.unmap();
}
}
}
+8 -252
View File
@@ -79,47 +79,6 @@ impl Preset {
}
}
/// Entity occlusion strategy. The synchronous mode preserves the original
/// same-frame CPU test; asynchronous results only hide a mesh when their
/// camera, bounds and occluder evidence still match.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum EntityCullingMode {
#[default]
Off,
Synchronous,
Asynchronous,
}
impl EntityCullingMode {
pub const ALL: [EntityCullingMode; 3] = [
EntityCullingMode::Off,
EntityCullingMode::Synchronous,
EntityCullingMode::Asynchronous,
];
pub fn name(self) -> &'static str {
match self {
Self::Off => "off",
Self::Synchronous => "sync",
Self::Asynchronous => "async",
}
}
pub fn label(self) -> &'static str {
match self {
Self::Off => "Off",
Self::Synchronous => "Synchronous",
Self::Asynchronous => "Asynchronous",
}
}
fn parse(s: &str) -> Self {
match s {
// Keep graphics files written before the mode selector readable.
"true" | "synchronous" | "sync" => Self::Synchronous,
"false" | "off" => Self::Off,
"asynchronous" | "async" => Self::Asynchronous,
_ => Self::default(),
}
}
}
/// How the swapchain presents frames.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum PresentMode {
@@ -287,61 +246,6 @@ impl FarDetail {
}
}
/// What the section mesher does with leaf faces inside a canopy. Terrain
/// geometry only: entities, particles and block entities are untouched.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum LeafCullingMode {
/// Every leaf face, as vanilla's fancy leaves draw them.
#[default]
Off,
/// Leaves surrounded on all six sides by leaves or full opaque cubes draw
/// in the opaque layer, so their cutouts no longer show the layers behind.
Solid,
/// Like `Solid`, but a leaf with one open side draws opaque too: the
/// exposed shell thins by a layer and the canopy looks chunkier.
SolidAggressive,
/// Drops the faces two leaves share. Faces toward air, cavities, partial
/// models, unknown neighbours and section boundaries stay.
Hollow,
}
impl LeafCullingMode {
pub const ALL: [LeafCullingMode; 4] = [
LeafCullingMode::Off,
LeafCullingMode::Solid,
LeafCullingMode::SolidAggressive,
LeafCullingMode::Hollow,
];
pub fn name(self) -> &'static str {
match self {
LeafCullingMode::Off => "off",
LeafCullingMode::Solid => "solid",
LeafCullingMode::SolidAggressive => "solid_aggressive",
LeafCullingMode::Hollow => "hollow",
}
}
pub fn label(self) -> &'static str {
match self {
LeafCullingMode::Off => "Off",
LeafCullingMode::Solid => "Solid",
LeafCullingMode::SolidAggressive => "Solid Aggressive",
LeafCullingMode::Hollow => "Hollow",
}
}
/// Whether this mode draws interior leaf faces in the solid layer.
pub fn is_interior(self) -> bool {
matches!(
self,
LeafCullingMode::Solid | LeafCullingMode::SolidAggressive
)
}
fn parse(s: &str) -> Self {
Self::ALL
.into_iter()
.find(|mode| mode.name() == s)
.unwrap_or_default()
}
}
/// TAA upscaling: the world renders this much smaller than the resolution scale's size,
/// and TAA builds the full-size picture from the jittered frames (FSR's and DLSS's ratios).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
@@ -457,46 +361,6 @@ impl Graphics {
pub fn lod_distance_on(&self, info: &wgpu::AdapterInfo) -> u32 {
lod_distance_capped(self.lod_distance, info)
}
/// Whether the selected packs leave vanilla's block models in place.
/// Horizon's occlusion assumptions (entity and leaf culling) hold only
/// for those: a pack can replace an opaque cube's model or a leaf's.
pub fn vanilla_models(&self, resource_packs: &[String]) -> bool {
resource_packs.iter().all(|id| id == "vanilla")
}
/// The same check including packs overlaid by the connected server.
/// Their ids are namespaced as `server/...`, so an active server pack
/// disables model-dependent occlusion assumptions.
pub fn vanilla_models_with_server_packs<'a>(
&self,
resource_packs: impl IntoIterator<Item = &'a str>,
server_packs: impl IntoIterator<Item = &'a str>,
) -> bool {
resource_packs
.into_iter()
.chain(server_packs)
.all(|id| id == "vanilla")
}
/// The leaf culling the mesher may apply. A pack that can replace leaf
/// models falls back to `Off`, which draws exactly vanilla's leaves.
pub fn leaf_culling_effective(&self, resource_packs: &[String]) -> LeafCullingMode {
if self.vanilla_models(resource_packs) {
self.leaf_culling
} else {
LeafCullingMode::Off
}
}
/// The effective mode when local and server resource packs are active.
pub fn leaf_culling_effective_with_server_packs<'a>(
&self,
resource_packs: impl IntoIterator<Item = &'a str>,
server_packs: impl IntoIterator<Item = &'a str>,
) -> LeafCullingMode {
if self.vanilla_models_with_server_packs(resource_packs, server_packs) {
self.leaf_culling
} else {
LeafCullingMode::Off
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Graphics {
@@ -507,12 +371,8 @@ pub struct Graphics {
pub far_detail: FarDetail,
/// Skip sections hidden behind closed ones (vanilla's occlusion graph).
pub cave_culling: bool,
/// Skip drawing entities hidden behind opaque terrain, synchronously or asynchronously.
pub entity_culling: EntityCullingMode,
/// Terrain leaf faces inside a canopy (see [`LeafCullingMode`]); `Off` draws
/// vanilla's fancy leaves. Ignored with a resource pack that may replace
/// the vanilla leaf models ([`Graphics::leaf_culling_effective`]).
pub leaf_culling: LeafCullingMode,
/// Skip drawing entities hidden behind opaque terrain.
pub entity_culling: bool,
/// Meshed sections uploaded per frame; 0 uploads all that are ready.
pub chunk_updates: u32,
/// Section mesh bytes uploaded per frame, in KiB; 0 is unlimited.
@@ -585,8 +445,7 @@ impl Default for Graphics {
lod_distance: 0,
far_detail: FarDetail::Full,
cave_culling: true,
entity_culling: EntityCullingMode::Off,
leaf_culling: LeafCullingMode::Off,
entity_culling: false,
chunk_updates: 0,
upload_budget_kib: 0,
texture_budget_mib: 0,
@@ -655,8 +514,7 @@ impl Graphics {
}
"farDetail" => g.far_detail = FarDetail::parse(value),
"caveCulling" => g.cave_culling = flag(g.cave_culling),
"entityCulling" => g.entity_culling = EntityCullingMode::parse(value),
"leafCulling" => g.leaf_culling = LeafCullingMode::parse(value),
"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),
"textureBudgetMiB" => {
@@ -722,13 +580,12 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\ntranslucentBatching:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\ntranslucentBatching:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
bool_text(self.cave_culling),
self.entity_culling.name(),
self.leaf_culling.name(),
bool_text(self.entity_culling),
self.chunk_updates,
self.upload_budget_kib,
self.texture_budget_mib,
@@ -884,8 +741,7 @@ mod tests {
lod_distance: 256,
far_detail: FarDetail::Reduced,
cave_culling: false,
entity_culling: EntityCullingMode::Synchronous,
leaf_culling: LeafCullingMode::SolidAggressive,
entity_culling: true,
chunk_updates: 12,
upload_budget_kib: 4096,
texture_budget_mib: 1536,
@@ -916,21 +772,6 @@ mod tests {
shader_pack: "Golden Hour".into(),
};
assert_eq!(Graphics::parse(&g.serialize()), g);
for mode in EntityCullingMode::ALL {
let graphics = Graphics {
entity_culling: mode,
..Graphics::default()
};
assert_eq!(Graphics::parse(&graphics.serialize()), graphics);
}
assert_eq!(
Graphics::parse("entityCulling:true\n").entity_culling,
EntityCullingMode::Synchronous
);
assert_eq!(
Graphics::parse("entityCulling:false\n").entity_culling,
EntityCullingMode::Off
);
assert_eq!(Graphics::parse(""), Graphics::default());
// Translucent Batching is opt-in for now: only the file turns it on.
assert!(!Graphics::default().translucent_batching);
@@ -960,96 +801,11 @@ mod tests {
Graphics::default()
);
assert_eq!(
Graphics::parse(
"workers:abc\nsky:maybe\ncullLeaves:true\nentityCulling:maybe\nleafCulling:maybe\n"
),
Graphics::parse("workers:abc\nsky:maybe\ncullLeaves:true\nentityCulling:maybe\n"),
Graphics::default()
);
}
#[test]
fn leaf_culling_round_trips_its_stable_names() {
assert_eq!(Graphics::default().leaf_culling, LeafCullingMode::Off);
for mode in LeafCullingMode::ALL {
let g = Graphics {
leaf_culling: mode,
..Graphics::default()
};
assert_eq!(Graphics::parse(&g.serialize()), g, "{mode:?}");
assert_eq!(
Graphics::parse(&format!("leafCulling:{}\n", mode.name())).leaf_culling,
mode
);
}
for text in [
"leafCulling:other\n",
"leafCulling:\n",
"leafCulling:SOLID\n",
] {
assert_eq!(
Graphics::parse(text).leaf_culling,
LeafCullingMode::Off,
"{text:?}"
);
}
}
#[test]
fn presets_keep_the_leaf_culling_choice() {
for mode in LeafCullingMode::ALL {
for preset in Preset::ALL {
let mut s = crate::settings::Settings::default();
s.horizon.leaf_culling = mode;
apply_preset(preset, &mut s);
assert_eq!(s.horizon.leaf_culling, mode, "{preset:?}");
assert_eq!(Graphics::parse(&s.horizon.serialize()), s.horizon);
}
}
}
#[test]
fn custom_packs_turn_leaf_culling_off() {
let g = Graphics {
leaf_culling: LeafCullingMode::Hollow,
..Graphics::default()
};
assert!(g.vanilla_models(&["vanilla".into()]));
assert_eq!(
g.leaf_culling_effective(&["vanilla".into()]),
LeafCullingMode::Hollow
);
assert!(!g.vanilla_models(&["vanilla".into(), "My Pack".into()]));
assert_eq!(
g.leaf_culling_effective(&["vanilla".into(), "My Pack".into()]),
LeafCullingMode::Off
);
assert_eq!(g.leaf_culling_effective(&[]), LeafCullingMode::Hollow);
}
#[test]
fn server_pack_apply_disables_leaf_culling_and_removal_restores_it() {
let g = Graphics {
leaf_culling: LeafCullingMode::Solid,
..Graphics::default()
};
let selected = ["vanilla"];
let applied_server_pack = ["server/0123456789abcdef/model-pack.zip"];
assert_eq!(
g.leaf_culling_effective_with_server_packs(selected, applied_server_pack),
LeafCullingMode::Off,
"an applied server pack can replace block models"
);
assert!(!g.vanilla_models_with_server_packs(selected, applied_server_pack));
assert_eq!(
g.leaf_culling_effective_with_server_packs(selected, []),
LeafCullingMode::Solid,
"removing the server pack restores the selected local mode"
);
assert!(g.vanilla_models_with_server_packs(selected, []));
}
#[test]
fn integrated_gpus_other_than_apple_cap_the_lod_distance() {
let adapter = |name: &str, device_type| wgpu::AdapterInfo {
+16 -121
View File
@@ -2,10 +2,7 @@
//! they show, in what groups, with what description and cost, and their
//! input. How they look comes from `graphics_style`.
use crate::{
graphics::{
EntityCullingMode, FarDetail, GraphicsApi, LeafCullingMode, Overlay, Pacing, PresentMode,
Preset, Upscaling,
},
graphics::{FarDetail, GraphicsApi, Overlay, Pacing, PresentMode, Preset, Upscaling},
graphics_style::{Cost, STYLE},
hud::{HudAtlas, HudBatch},
lang,
@@ -30,7 +27,6 @@ pub enum H {
FarDetail,
CaveCulling,
EntityCulling,
LeafCulling,
ChunkUpdates,
UploadBudget,
Sky,
@@ -191,12 +187,6 @@ pub fn rows(level: Level) -> Vec<Row> {
Medium,
"How far away mobs and items are drawn, as a share of normal.",
),
h(
H::ChunkUpdates,
Low,
"Meshed sections uploaded per frame. Lower is smoother while loading.",
),
Row::Header("Culling"),
h(
H::CaveCulling,
Saves,
@@ -205,12 +195,12 @@ pub fn rows(level: Level) -> Vec<Row> {
h(
H::EntityCulling,
None,
"Hides models behind opaque blocks. Async checks off-thread; entities tick.",
"Hides models behind opaque blocks. Entities update. Off with custom packs.",
),
h(
H::LeafCulling,
Saves,
"Draws or drops leaf faces inside canopies. Off keeps vanilla's fancy leaves.",
H::ChunkUpdates,
Low,
"Meshed sections uploaded per frame. Lower is smoother while loading.",
),
Row::Header("Quality"),
v(
@@ -456,16 +446,6 @@ fn description(item: Item, s: &Settings, text: &'static str) -> &'static str {
(Item::Horizon(H::HdrPeak | H::HdrPaperWhite), Setup::Automatic | Setup::Changed) => {
"Set up for your display. Leave on Auto unless it looks wrong."
}
(Item::Horizon(H::LeafCulling), _) => match s.horizon.leaf_culling {
LeafCullingMode::Off => "Every leaf face. Vanilla's fancy leaves.",
LeafCullingMode::Solid => {
"Leaves fully inside a canopy draw solid: fewer see-through layers."
}
LeafCullingMode::SolidAggressive => {
"Leaves with one open side draw solid too: faster, chunkier edges."
}
LeafCullingMode::Hollow => "Drops faces between leaves: faster, but gaps look through.",
},
_ => text,
}
}
@@ -535,8 +515,7 @@ impl H {
},
H::FarDetail => labelled("Far Detail", g.far_detail.label()),
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
H::EntityCulling => labelled("Entity Culling", g.entity_culling.label()),
H::LeafCulling => labelled("Leaf Culling", g.leaf_culling.label()),
H::EntityCulling => labelled("Entity Culling", on_off(g.entity_culling)),
H::ChunkUpdates => match g.chunk_updates {
0 => labelled("Chunk Updates", "Unlimited"),
n => labelled("Chunk Updates", lang::tr("%s per frame", &[&n])),
@@ -669,12 +648,7 @@ impl H {
g.far_detail = step(&[FarDetail::Full, FarDetail::Reduced], g.far_detail, back)
}
H::CaveCulling => g.cave_culling = !g.cave_culling,
H::EntityCulling => {
g.entity_culling = step(&EntityCullingMode::ALL, g.entity_culling, back)
}
H::LeafCulling => {
g.leaf_culling = step(&LeafCullingMode::ALL, g.leaf_culling, back);
}
H::EntityCulling => g.entity_culling = !g.entity_culling,
H::Sky => g.sky = !g.sky,
H::Fog => g.fog = !g.fog,
H::Weather => g.weather = !g.weather,
@@ -762,6 +736,7 @@ impl Item {
let g = &s.horizon;
Some(match self {
Item::Horizon(H::CaveCulling) => g.cave_culling,
Item::Horizon(H::EntityCulling) => g.entity_culling,
Item::Horizon(H::Sky) => g.sky,
Item::Horizon(H::Fog) => g.fog,
Item::Horizon(H::Weather) => g.weather,
@@ -1851,92 +1826,16 @@ mod tests {
}
#[test]
fn leaf_culling_cycles_modes_in_the_advanced_culling_group() {
let mut s = Settings::default();
let item = Item::Horizon(H::LeafCulling);
assert_eq!(item.switch(&s), None);
assert_eq!(item.label(&s).plain(), "Leaf Culling: Off");
for label in ["Solid", "Solid Aggressive", "Hollow", "Off"] {
item.cycle(&mut s, false, [854, 480]);
assert_eq!(item.label(&s).plain(), format!("Leaf Culling: {label}"));
assert_eq!(
crate::graphics::Graphics::parse(&s.horizon.serialize()).leaf_culling,
s.horizon.leaf_culling
);
}
item.cycle(&mut s, true, [854, 480]);
assert_eq!(s.horizon.leaf_culling, LeafCullingMode::Hollow);
// The three culling settings share one group, below Rendering's chunk
// updates and above Quality.
let rows = rows(Level::Advanced);
let culling = rows
.iter()
.position(|row| matches!(row, Row::Header("Culling")))
.unwrap();
assert!(matches!(
rows.get(culling + 1),
Some(Row::Setting(setting)) if setting.item == Item::Horizon(H::CaveCulling)
));
assert!(matches!(
rows.get(culling + 2),
Some(Row::Setting(setting)) if setting.item == Item::Horizon(H::EntityCulling)
));
assert!(matches!(
rows.get(culling + 3),
Some(Row::Setting(setting)) if setting.item == item
));
assert!(matches!(
rows.get(culling + 4),
Some(Row::Header("Quality"))
));
// The description names what the picked mode does.
for mode in LeafCullingMode::ALL {
s.horizon.leaf_culling = mode;
let text = description(item, &s, "fallback");
assert_ne!(text, "fallback", "{mode:?}");
assert!(text.len() < 80, "{mode:?}: {text}");
}
// A click cycles it and leaves the preset, as the other quality
// settings do.
let mut ui = GraphicsUi::default();
let size = (480.0, 4000.0);
let (widgets, _, _) = ui.layout(Level::Advanced, &s, size.0, size.1);
let control = widgets
.iter()
.find(|w| matches!(w.target, Target::Item(i) if i == item))
.unwrap();
s.horizon.preset = Preset::Balanced;
s.horizon.leaf_culling = LeafCullingMode::Off;
assert_eq!(
ui.click(
Level::Advanced,
&mut s,
(control.x + 1.0, control.y + 1.0),
size,
[854, 480],
false,
false
),
vec![Action::ButtonClick, Action::Changed]
);
assert_eq!(s.horizon.leaf_culling, LeafCullingMode::Solid);
assert_eq!(s.horizon.preset, Preset::Custom);
}
#[test]
fn entity_culling_modes_are_opt_in_and_cycle_from_advanced_graphics() {
fn entity_culling_is_opt_in_and_toggles_from_advanced_graphics() {
let mut s = Settings::default();
let item = Item::Horizon(H::EntityCulling);
assert_eq!(s.horizon.entity_culling, EntityCullingMode::Off);
assert_eq!(item.switch(&s), Some(false));
assert_eq!(item.label(&s).plain(), "Entity Culling: Off");
for mode in EntityCullingMode::ALL {
s.horizon.entity_culling = mode;
for enabled in [false, true] {
s.horizon.entity_culling = enabled;
for preset in Preset::ALL {
crate::graphics::apply_preset(preset, &mut s);
assert_eq!(s.horizon.entity_culling, mode);
assert_eq!(s.horizon.entity_culling, enabled);
}
}
@@ -1948,11 +1847,7 @@ mod tests {
.find(|w| matches!(w.target, Target::Item(i) if i == item))
.unwrap();
let point = (switch.x + 1.0, switch.y + 1.0);
for mode in [
EntityCullingMode::Off,
EntityCullingMode::Synchronous,
EntityCullingMode::Asynchronous,
] {
for enabled in [false, true] {
s.horizon.preset = Preset::Balanced;
assert_eq!(
ui.click(
@@ -1966,10 +1861,10 @@ mod tests {
),
vec![Action::ButtonClick, Action::Changed]
);
assert_eq!(s.horizon.entity_culling, mode);
assert_eq!(item.switch(&s), Some(enabled));
assert_eq!(s.horizon.preset, Preset::Custom);
}
assert_eq!(item.label(&s).plain(), "Entity Culling: Asynchronous");
assert_eq!(item.label(&s).plain(), "Entity Culling: On");
}
#[test]
+7 -37
View File
@@ -83,8 +83,6 @@ pub struct HudAtlas {
/// A resource pack's own `core/text` shader for the GUI's text, translated;
/// none draws the text natively.
pub text_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
/// The packs' OptiFine random entity textures, whose variants this atlas packs.
pub random_textures: horizon_optifine::random_textures::RandomTextures,
white: Rect,
}
/// Atlas rows reserved for glyphs rasterized at run time.
@@ -481,7 +479,6 @@ impl HudAtlas {
glint_armor: (vec![0; 4], 1, 1),
white,
text_shader: None,
random_textures: Default::default(),
}
}
/// Shelf placements of `images` (tallest first) in the narrowest atlas
@@ -570,41 +567,15 @@ impl HudAtlas {
let s = p.to_string_lossy();
!s.contains("title/background") && !s.contains("realms") && !s.contains("presets")
});
// OptiFine random entity variants, under the names their scan gives them (those
// outside `textures/` are `<ns>:optifine/random/...`).
let random_textures = if folders.contains(&"entity") {
horizon_optifine::random_textures::RandomTextures::scan(root)
} else {
Default::default()
};
let mut named: HashMap<PathBuf, String> = HashMap::new();
for (file, name) in random_textures.files() {
named.insert(root.join(file), name.to_owned());
}
if !named.is_empty() {
// Those beside their texture are walked already.
let walked: HashSet<&PathBuf> = files.iter().collect();
let mut extra: Vec<PathBuf> = named
.keys()
.filter(|p| !walked.contains(p))
.cloned()
.collect();
extra.sort();
files.extend(extra);
}
type Decoded = (PackedImage, Option<TextureFlags>, Option<(u32, u32)>, bool);
let decode_one = |path: &PathBuf| -> Option<Decoded> {
let name = if let Some(name) = named.get(path) {
name.clone()
} else {
match path.strip_prefix(&textures) {
Ok(rel) => rel.with_extension("").to_string_lossy().replace('\\', "/"),
Err(_) => {
let rel = path.strip_prefix(&assets).ok()?.with_extension("");
let rel = rel.to_string_lossy().replace('\\', "/");
let (namespace, rest) = rel.split_once("/textures/")?;
format!("{namespace}:{rest}")
}
let name = match path.strip_prefix(&textures) {
Ok(rel) => rel.with_extension("").to_string_lossy().replace('\\', "/"),
Err(_) => {
let rel = path.strip_prefix(&assets).ok()?.with_extension("");
let rel = rel.to_string_lossy().replace('\\', "/");
let (namespace, rest) = rel.split_once("/textures/")?;
format!("{namespace}:{rest}")
}
};
// LabPBR's maps beside a texture are the world's (sprites.rs), not images to draw.
@@ -806,7 +777,6 @@ impl HudAtlas {
glint,
glint_armor,
text_shader: None,
random_textures,
font,
white,
})
-2
View File
@@ -18,11 +18,9 @@ pub mod camera;
pub mod chat;
pub mod chunk;
pub mod chunk_map;
pub mod cit;
pub mod clouds;
pub mod collisions;
pub mod command_block;
pub mod connected;
pub mod containers;
pub mod crash;
pub mod datapack;
+8 -53
View File
@@ -4,7 +4,6 @@
//! cardinal shading, tint sources, block offsets; see `lighting`). Fluids
//! follow `FluidRenderer` (see `fluid`).
mod fluid;
mod leaf_culling;
mod lighting;
mod sides;
use crate::{
@@ -12,7 +11,6 @@ use crate::{
biome::Biomes,
blocks::{BlockFacts, RenderShape},
chunk::{Chunk, MIN_Y},
graphics::LeafCullingMode,
models::catalog::BakedFace,
};
use horizon_core::{
@@ -472,9 +470,6 @@ struct Info {
overlay: bool,
/// `LeavesBlock`: with opaque leaves the faces between two leaf blocks are skipped.
leaves: bool,
/// A full opaque cube: `RenderShape::Model`, `can_occlude`, `solid_render`
/// and a full collision shape. The only block leaf culling treats as solid.
opaque_cube: bool,
blocks_flow: bool,
ice: bool,
sturdy: u8,
@@ -652,12 +647,6 @@ impl MeshTables {
legacy_solid: s.legacy_solid,
overlay: s.has_class("HalfTransparentBlock") || s.has_class("LeavesBlock"),
leaves: s.has_class("LeavesBlock"),
opaque_cube: leaf_culling::opaque_cube(
s.render_shape,
s.can_occlude,
s.solid_render,
s.full_collision,
),
blocks_flow: assets.blocks_fluid_flow.contains(&*s.name),
ice: s.has_class("IceBlock"),
sturdy: s.sturdy_full,
@@ -697,14 +686,6 @@ impl MeshTables {
tint_sources: i.tint_count,
})
}
/// Whether `state` is a full opaque cube: the only block leaf culling
/// treats as a solid neighbour. Glass, fluids, slabs, stairs and models
/// that only partly fill their block are not.
pub fn opaque_cube(&self, state: BlockStateId) -> bool {
self.info
.get(state.0 as usize)
.is_some_and(|i| i.opaque_cube)
}
/// `BlockTintSource.color(state)`: block-model colour without a level.
/// `grass` is `GrassColor.getDefaultColor()` from the active pack's colormap.
/// Untinted faces, blank sources and invalid tint indices return `None`.
@@ -879,18 +860,14 @@ impl Padded {
}
}
/// Per-worker temporary arrays. They never hold resource-pack references or
/// geometry still owned by the renderer, and total about 150 KiB per worker
/// (the leaf mask adds 8,000 cells, depths and a queue).
/// geometry still owned by the renderer, and total about 88 KiB per worker.
#[derive(Default)]
pub(crate) struct MeshScratch {
padded: Padded,
own: Option<Box<[u32; 4096]>>,
visibility: Vec<usize>,
leaf: leaf_culling::LeafMask,
}
#[cfg(test)]
mod leaf_culling_tests;
#[cfg(test)]
mod reuse_tests;
/// Set on a terrain vertex's sprite layer when its texture alpha must be ignored.
pub const OPAQUE_LAYER: u16 = 1 << 15;
@@ -1102,8 +1079,6 @@ pub struct Mesher<'a> {
pub smooth_lighting: bool,
/// The `cutoutLeaves` option (fancy graphics); off makes leaves opaque.
pub cutout_leaves: bool,
/// What the worker does with leaf faces inside a canopy.
pub leaf_culling: LeafCullingMode,
}
impl Mesher<'_> {
/// `region[dz][dx]` are the chunks around the meshed column (centre 1,1).
@@ -1133,11 +1108,6 @@ impl Mesher<'_> {
let pad = &scratch.padded;
out.visibility = visibility_reusing(pad, &self.tables.info, &mut scratch.visibility);
let base_y = center.min_y() + sy * 16;
if self.leaf_culling.is_interior() {
scratch
.leaf
.prepare(self.leaf_culling, pad, &self.tables.info, region, base_y);
}
let mut tints = Tints::new(
region,
self.biomes,
@@ -1202,21 +1172,13 @@ impl Mesher<'_> {
None => [x as f32, y as f32, z as f32],
};
let mut visible = [false; 6];
let interior_leaf = state.leaves
&& self.leaf_culling.is_interior()
&& scratch.leaf.interior(self.leaf_culling, x, y, z);
for d in Direction::ALL {
let [dx, dy, dz] = d.normal();
let neighbor = pad.blocks[pi(x + dx, y + dy, z + dz)];
let other = &info[neighbor as usize];
// LeavesBlock.skipRendering with opaque leaves.
let opaque_leaves = !self.cutout_leaves && state.leaves && other.leaves;
let hollow_leaf = self.leaf_culling == LeafCullingMode::Hollow
&& state.leaves
&& other.leaves
&& leaf_culling::in_section(x + dx, y + dy, z + dz);
visible[d as usize] = !opaque_leaves
&& !hollow_leaf
&& self
.visibility
.should_render_face(
@@ -1241,7 +1203,6 @@ impl Mesher<'_> {
face,
ao,
state,
interior_leaf,
);
}
}
@@ -1280,7 +1241,6 @@ impl Mesher<'_> {
face: &BakedFace,
ao: bool,
state: &Info,
interior_leaf: bool,
) {
let q = &face.quad;
// A quad's own light (a model's glowing part) may raise the tier; whether it
@@ -1315,17 +1275,6 @@ impl Mesher<'_> {
} else {
&mut out.solid
};
// Opaque leaves (forceOpaque), quads over opaque texels and leaves a
// leaf-culling mode proved internal draw in the solid layer: no alpha
// cutout. All four vertices of the quad share the layer.
let layer = if face.opaque && !face.translucent
|| !self.cutout_leaves && state.leaves
|| interior_leaf
{
face.layer as u16 | OPAQUE_LAYER
} else {
face.layer as u16
};
for i in 0..4 {
let g = gray[i] as u32;
let rgb = match tint {
@@ -1336,7 +1285,13 @@ impl Mesher<'_> {
target.push(TerrainVertex::new(
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
q.uv[i],
layer,
// Opaque leaves (forceOpaque) and quads over opaque texels draw in the
// solid layer: no alpha cutout (a dirt path's side skips its clear top row).
if face.opaque && !face.translucent || !self.cutout_leaves && state.leaves {
face.layer as u16 | OPAQUE_LAYER
} else {
face.layer as u16
},
emitting(pack_rgb(rgb), tier),
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
));
@@ -1,656 +0,0 @@
//! Leaf culling: decides which leaf faces a section may draw solid or drop.
//!
//! The mask is built on the mesh worker from the same padded block snapshot the
//! rest of the mesher reads, so a decision never needs a chunk lookup on the
//! render thread. It classifies each padded cell as a leaf, a full opaque cube,
//! open (air, fluids, partial models, anything else) or unknown (a missing
//! chunk, a Y outside the column, or the mask's outer shell). Only leaves can
//! hide or solidify anything; unknown and open cells always expose a face.
//!
//! `Solid` performs an immediate 6-neighbour occlusion check, solidifying fully
//! enclosed leaves into the opaque layer without cutout testing. `Solid
//! Aggressive` runs a multi-source breadth-first search from leaves touching
//! openings, solidifying leaves with a single open face to thin the canopy shell
//! by one layer. `Hollow` directly drops faces shared between two leaves within
//! the section, preserving boundaries and cavity faces.
use super::{B, Info, P, Padded, pi};
use crate::{blocks::RenderShape, chunk::Chunk, graphics::LeafCullingMode};
/// A cell of the padded snapshot, as far as leaf culling cares.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Cell {
/// Air, a fluid, a partial model, glass or any other non-occluding block.
Open,
/// `LeavesBlock`.
Leaf,
/// A full opaque cube (see [`opaque_cube`]).
Opaque,
/// Missing chunk, missing section or the mask's outer shell.
Unknown,
}
/// Whether a state is a full opaque cube: `RenderShape::Model`, `can_occlude`,
/// `solid_render` and a full collision shape. Glass, fluids, slabs, stairs,
/// chests and models that only partly fill their block are not.
pub(super) fn opaque_cube(
shape: RenderShape,
can_occlude: bool,
solid_render: bool,
full_collision: bool,
) -> bool {
shape == RenderShape::Model && can_occlude && solid_render && full_collision
}
const UNVISITED: u8 = u8::MAX;
const STRIDE_X: isize = 1;
const STRIDE_Z: isize = P as isize;
const STRIDE_Y: isize = (P * P) as isize;
const ADJACENT_STRIDES: [isize; 6] = [
STRIDE_X, -STRIDE_X, STRIDE_Z, -STRIDE_Z, STRIDE_Y, -STRIDE_Y,
];
/// Per-worker leaf mask and BFS scratch. The vectors keep their capacity
/// between sections and modes; `Off` does no work at all.
#[derive(Default)]
pub(super) struct LeafMask {
cells: Vec<Cell>,
depth: Vec<u8>,
queue: Vec<u16>,
active: bool,
}
impl LeafMask {
/// Classify the padded snapshot and, for the interior modes, measure how
/// deep each leaf sits below the canopy surface.
pub(super) fn prepare(
&mut self,
mode: LeafCullingMode,
pad: &Padded,
info: &[Info],
region: &[[Option<&Chunk>; 3]; 3],
base_y: i32,
) {
self.active = mode != LeafCullingMode::Off;
if !self.active {
return;
}
// Most sections hold no leaves at all; do not classify 8,000 cells to
// learn that. The centre is the only part this mesh can change.
let mut leaves = false;
'scan: for y in 0..16 {
for z in 0..16 {
for x in 0..16 {
if info
.get(pad.blocks[pi(x, y, z)] as usize)
.is_some_and(|state| state.leaves)
{
leaves = true;
break 'scan;
}
}
}
}
if !leaves {
self.active = false;
return;
}
if self.cells.len() != P * P * P {
self.cells.resize(P * P * P, Cell::Open);
}
for y in -B..16 + B {
for z in -B..16 + B {
for x in -B..16 + B {
let i = pi(x, y, z);
self.cells[i] = if !known(region, base_y, x, y, z) {
Cell::Unknown
} else {
match info.get(pad.blocks[i] as usize) {
None => Cell::Unknown,
Some(state) if state.leaves => Cell::Leaf,
Some(state) if state.opaque_cube => Cell::Opaque,
Some(_) => Cell::Open,
}
};
}
}
}
// Solid is a purely local six-neighbour test. Reserve the breadth-first
// search for Solid Aggressive, whose one-open-side rule needs to know
// whether a leaf is connected to the canopy surface through other
// leaves.
if mode == LeafCullingMode::SolidAggressive {
self.depths();
}
}
/// Multi-source BFS from leaves that touch an opening. Opaque neighbours do
/// not expose a leaf: a leaf fully enclosed by stone is interior too.
fn depths(&mut self) {
if self.depth.len() != self.cells.len() {
self.depth.resize(self.cells.len(), UNVISITED);
} else {
self.depth.fill(UNVISITED);
}
self.queue.clear();
for y in -B..16 + B {
for z in -B..16 + B {
for x in -B..16 + B {
let i = pi(x, y, z);
if self.cells[i] != Cell::Leaf {
continue;
}
let touches_open = ADJACENT_STRIDES.iter().any(|&stride| {
let j = (i as isize + stride) as usize;
matches!(self.cells[j], Cell::Open | Cell::Unknown)
});
if touches_open {
self.depth[i] = 0;
self.queue.push(i as u16);
}
}
}
}
let mut head = 0;
while head < self.queue.len() {
let i = self.queue[head] as usize;
head += 1;
let next_depth = self.depth[i].saturating_add(1);
for stride in ADJACENT_STRIDES {
let j = (i as isize + stride) as usize;
if self.cells[j] == Cell::Leaf && self.depth[j] == UNVISITED {
self.depth[j] = next_depth;
self.queue.push(j as u16);
}
}
}
}
/// Whether the leaf at `(x, y, z)` may draw in the opaque layer.
///
/// `Solid` wants a leaf with no open neighbour, which is exactly depth 1 or
/// more. `Solid Aggressive` also takes the next layer of the shell: a leaf
/// with a single open side joins the opaque core, so the cutout shell is
/// one layer thinner. Unknown neighbours never qualify, however many sides
/// they leave open. A leaf the search never reached sits inside a closed
/// volume: interior for both.
pub(super) fn interior(&self, mode: LeafCullingMode, x: i32, y: i32, z: i32) -> bool {
if !self.active {
return false;
}
let aggressive = match mode {
LeafCullingMode::Solid => false,
LeafCullingMode::SolidAggressive => true,
_ => return false,
};
// A section boundary is not proof of a closed volume: the neighbouring
// section may not be drawn yet, and its data is not ours to assume.
if !(1..15).contains(&x) || !(1..15).contains(&y) || !(1..15).contains(&z) {
return false;
}
let i = pi(x, y, z);
if self.cells.get(i) != Some(&Cell::Leaf) {
return false;
}
if !aggressive {
return ADJACENT_STRIDES.iter().all(|&stride| {
let j = (i as isize + stride) as usize;
matches!(self.cells[j], Cell::Leaf | Cell::Opaque)
});
}
let depth = self.depth[i];
if depth == UNVISITED || depth >= 1 {
return true;
}
let mut open = 0;
for stride in ADJACENT_STRIDES {
let j = (i as isize + stride) as usize;
match self.cells[j] {
Cell::Unknown => return false,
Cell::Open => {
open += 1;
if open > 1 {
return false;
}
}
Cell::Leaf | Cell::Opaque => {}
}
}
open == 1
}
}
/// The cells this section's mesh owns. A face on this boundary belongs to the
/// neighbouring section as much as to this one; leaf culling leaves it alone.
#[inline]
pub(super) fn in_section(x: i32, y: i32, z: i32) -> bool {
(0..16).contains(&x) && (0..16).contains(&y) && (0..16).contains(&z)
}
/// Whether the padded cell `(x, y, z)` has real block data. A missing chunk
/// does not count: `Padded::fill` copies the centre's edge column into it, which
/// would make a canopy look enclosed. A Y outside the column counts as unknown
/// for the same reason. The mask's outer shell is unknown too, so nothing is
/// inferred past the data the worker snapshotted.
fn known(region: &[[Option<&Chunk>; 3]; 3], base_y: i32, x: i32, y: i32, z: i32) -> bool {
if x <= -B || x >= 16 + B - 1 || y <= -B || y >= 16 + B - 1 || z <= -B || z >= 16 + B - 1 {
return false;
}
let chunk = region[((z + 16) >> 4) as usize][((x + 16) >> 4) as usize];
chunk.is_some_and(|chunk| chunk.section_index(base_y + y).is_some())
}
#[cfg(test)]
impl LeafMask {
/// Whether `Hollow` drops the face of the leaf at `(x, y, z)` toward `d`.
/// Only a known leaf on the other side counts: open cells, partial models,
/// missing neighbours and the mask's edge stay visible.
pub(super) fn hides_face(
&self,
mode: LeafCullingMode,
x: i32,
y: i32,
z: i32,
d: horizon_core::geometry::Direction,
) -> bool {
if !self.active || mode != LeafCullingMode::Hollow {
return false;
}
let [dx, dy, dz] = d.normal();
let (nx, ny, nz) = (x + dx, y + dy, z + dz);
if !in_section(x, y, z) || !in_section(nx, ny, nz) {
return false;
}
let i = pi(x, y, z);
let ni = pi(nx, ny, nz);
self.cells.get(i) == Some(&Cell::Leaf) && self.cells.get(ni) == Some(&Cell::Leaf)
}
#[inline]
fn cell(&self, x: i32, y: i32, z: i32) -> Cell {
checked(x, y, z)
.and_then(|i| self.cells.get(i))
.copied()
.unwrap_or(Cell::Unknown)
}
}
#[cfg(test)]
#[inline]
fn checked(x: i32, y: i32, z: i32) -> Option<usize> {
if (-B..16 + B).contains(&x) && (-B..16 + B).contains(&y) && (-B..16 + B).contains(&z) {
Some(pi(x, y, z))
} else {
None
}
}
#[cfg(test)]
#[inline]
fn unpi(i: usize) -> (i32, i32, i32) {
let x = (i % P) as i32 - B;
let z = (i / P % P) as i32 - B;
let y = (i / (P * P)) as i32 - B;
(x, y, z)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{blocks::RenderShape, graphics::LeafCullingMode};
use horizon_core::geometry::Direction;
use horizon_world::fluid::FluidState;
use std::time::Instant;
fn info(leaves: bool, opaque: bool) -> Info {
Info {
solid: opaque,
full: opaque,
permeable: false,
shade: 1.0,
emission: 0,
tier: 0,
emissive: false,
fluid: FluidState::EMPTY,
model: true,
block_entity: false,
tints: [super::super::Tint::None; 2],
tint_count: 0,
offset: None,
legacy_solid: false,
overlay: leaves,
leaves,
opaque_cube: opaque,
blocks_flow: false,
ice: false,
sturdy: 0,
face: [0; 6],
cover: [0.0; 6],
}
}
/// State 0 is air, 1 leaves, 2 a full opaque cube, 3 a partial model.
fn states() -> Vec<Info> {
vec![
info(false, false),
info(true, false),
info(false, true),
info(false, false),
]
}
fn padded(fill: impl Fn(i32, i32, i32) -> u32) -> Padded {
Padded {
blocks: (0..P * P * P)
.map(|i| {
let (x, y, z) = unpi(i);
fill(x, y, z)
})
.collect(),
light: vec![0; P * P * P],
}
}
fn cube(pad: &mut Padded, min: i32, max: i32, state: u32) {
for y in min..=max {
for z in min..=max {
for x in min..=max {
pad.blocks[pi(x, y, z)] = state;
}
}
}
}
fn loaded() -> Chunk {
Chunk::empty(0, 0)
}
fn region(chunk: &Chunk) -> [[Option<&Chunk>; 3]; 3] {
[[Some(chunk); 3]; 3]
}
fn built(mode: LeafCullingMode, pad: &Padded, states: &[Info], chunk: &Chunk) -> LeafMask {
let mut mask = LeafMask::default();
mask.prepare(mode, pad, states, &region(chunk), 0);
mask
}
#[test]
fn only_a_full_model_cube_is_opaque() {
let combinations = [
(RenderShape::Model, true, true, true),
(RenderShape::Model, true, true, false),
(RenderShape::Model, true, false, true),
(RenderShape::Model, false, true, true),
(RenderShape::Model, false, false, false),
(RenderShape::Invisible, true, true, true),
(RenderShape::Animated, true, true, true),
];
let (shape, can_occlude, solid_render, full_collision) = combinations[0];
assert!(opaque_cube(
shape,
can_occlude,
solid_render,
full_collision
));
for &(shape, can_occlude, solid_render, full_collision) in &combinations[1..] {
assert!(
!opaque_cube(shape, can_occlude, solid_render, full_collision),
"{shape:?} {can_occlude} {solid_render} {full_collision}"
);
}
}
#[test]
fn solid_marks_the_cell_inside_a_three_cube() {
let states = states();
let mut pad = padded(|_, _, _| 0);
cube(&mut pad, 7, 9, 1);
let chunk = loaded();
let solid = built(LeafCullingMode::Solid, &pad, &states, &chunk);
assert!(!solid.interior(LeafCullingMode::Solid, 7, 8, 8));
assert!(solid.interior(LeafCullingMode::Solid, 8, 8, 8));
assert!(!solid.interior(LeafCullingMode::Solid, 8, 9, 8));
// Aggressive also takes the six face-centre leaves, whose only open
// side faces out of the cube. Corners and edges keep their cutout.
let aggressive = built(LeafCullingMode::SolidAggressive, &pad, &states, &chunk);
assert!(aggressive.interior(LeafCullingMode::SolidAggressive, 8, 8, 8));
assert!(aggressive.interior(LeafCullingMode::SolidAggressive, 7, 8, 8));
assert!(aggressive.interior(LeafCullingMode::SolidAggressive, 8, 8, 9));
assert!(!aggressive.interior(LeafCullingMode::SolidAggressive, 7, 7, 8));
assert!(!aggressive.interior(LeafCullingMode::SolidAggressive, 7, 7, 7));
}
#[test]
fn aggressive_takes_the_next_shell_layer() {
let states = states();
let mut pad = padded(|_, _, _| 0);
cube(&mut pad, 6, 10, 1);
let chunk = loaded();
let solid = built(LeafCullingMode::Solid, &pad, &states, &chunk);
let aggressive = built(LeafCullingMode::SolidAggressive, &pad, &states, &chunk);
let surface = |v: i32| v == 6 || v == 10;
for [x, y, z] in [
[6, 8, 8],
[10, 8, 8],
[6, 6, 8],
[6, 6, 6],
[7, 8, 8],
[8, 8, 8],
[7, 7, 7],
[8, 8, 7],
] {
let expected = !surface(x) && !surface(y) && !surface(z);
assert_eq!(
solid.interior(LeafCullingMode::Solid, x, y, z),
expected,
"solid {x} {y} {z}"
);
let open = [x, y, z].iter().filter(|v| surface(**v)).count();
let expected = open <= 1;
assert_eq!(
aggressive.interior(LeafCullingMode::SolidAggressive, x, y, z),
expected,
"aggressive {x} {y} {z}"
);
}
}
#[test]
fn hollow_hides_only_leaf_to_leaf_faces() {
let states = states();
let mut pad = padded(|_, _, _| 0);
cube(&mut pad, 7, 9, 1);
// Glass (3, a partial model), water and a slab are open: their leaves
// must keep the face.
pad.blocks[pi(10, 8, 8)] = 3;
let chunk = loaded();
let hollow = built(LeafCullingMode::Hollow, &pad, &states, &chunk);
for d in Direction::ALL {
assert!(
hollow.hides_face(LeafCullingMode::Hollow, 8, 8, 8, d),
"{d:?}"
);
}
assert!(!hollow.hides_face(LeafCullingMode::Hollow, 9, 8, 8, Direction::East));
assert!(!hollow.hides_face(LeafCullingMode::Hollow, 7, 8, 8, Direction::West));
// The other modes never hide a quad.
for mode in [
LeafCullingMode::Off,
LeafCullingMode::Solid,
LeafCullingMode::SolidAggressive,
] {
let mask = built(mode, &pad, &states, &chunk);
assert!(!mask.hides_face(mode, 8, 8, 8, Direction::Up));
}
}
#[test]
fn a_cavity_keeps_its_faces_and_its_rim_visible() {
let states = states();
let mut pad = padded(|_, _, _| 0);
cube(&mut pad, 6, 10, 1);
pad.blocks[pi(8, 8, 8)] = 0;
let chunk = loaded();
let hollow = built(LeafCullingMode::Hollow, &pad, &states, &chunk);
assert!(!hollow.hides_face(LeafCullingMode::Hollow, 7, 8, 8, Direction::East));
assert!(!hollow.hides_face(LeafCullingMode::Hollow, 8, 8, 7, Direction::South));
let solid = built(LeafCullingMode::Solid, &pad, &states, &chunk);
assert!(!solid.interior(LeafCullingMode::Solid, 7, 8, 8));
assert!(!solid.interior(LeafCullingMode::Solid, 6, 8, 8));
// The corner away from the cavity is still two layers in.
assert!(solid.interior(LeafCullingMode::Solid, 9, 9, 9));
}
#[test]
fn an_opaque_neighbour_does_not_expose_a_leaf() {
let states = states();
let mut pad = padded(|_, _, _| 0);
for (dx, dy, dz) in [
(1, 0, 0),
(-1, 0, 0),
(0, 1, 0),
(0, -1, 0),
(0, 0, 1),
(0, 0, -1),
] {
pad.blocks[pi(8 + dx, 8 + dy, 8 + dz)] = 2;
}
pad.blocks[pi(8, 8, 8)] = 1;
let chunk = loaded();
for mode in [LeafCullingMode::Solid, LeafCullingMode::SolidAggressive] {
let mask = built(mode, &pad, &states, &chunk);
assert!(mask.interior(mode, 8, 8, 8), "{mode:?}");
}
let hollow = built(LeafCullingMode::Hollow, &pad, &states, &chunk);
for d in Direction::ALL {
assert!(
!hollow.hides_face(LeafCullingMode::Hollow, 8, 8, 8, d),
"{d:?}"
);
}
}
#[test]
fn a_leaf_inside_a_closed_volume_is_interior() {
let states = states();
// A leaf cube inside opaque rock: the search has no opening to start
// from, so every leaf stays unvisited and counts as interior.
let mut pad = padded(|_, _, _| 2);
cube(&mut pad, 7, 9, 1);
let chunk = loaded();
for mode in [LeafCullingMode::Solid, LeafCullingMode::SolidAggressive] {
let mask = built(mode, &pad, &states, &chunk);
if mode == LeafCullingMode::SolidAggressive {
assert_eq!(mask.depth[pi(8, 8, 8)], UNVISITED);
}
assert!(mask.interior(mode, 8, 8, 8), "{mode:?}");
}
}
#[test]
fn missing_chunks_and_sections_are_unknown() {
let states = states();
let pad = padded(|_, _, _| 1);
let chunk = loaded();
let mut around = [[Some(&chunk); 3]; 3];
around[1][2] = None; // east chunk
let mut mask = LeafMask::default();
mask.prepare(LeafCullingMode::Hollow, &pad, &states, &around, 0);
// The leaf at the edge keeps its face toward the missing chunk, even
// though `Padded::fill` copied the centre's edge leaf there.
assert!(!mask.hides_face(LeafCullingMode::Hollow, 15, 8, 8, Direction::East));
// A leaf one block inside is still between two known leaves.
assert!(mask.hides_face(LeafCullingMode::Hollow, 14, 8, 8, Direction::East));
// A section above the column is unknown too: the same leaf one
// section up has no data, so it can never be interior.
let short = Chunk::with_layout(0, 0, crate::chunk::ColumnLayout::new(0, 16).unwrap());
let mut mask = LeafMask::default();
mask.prepare(LeafCullingMode::Solid, &pad, &states, &region(&short), 16);
assert!(!mask.interior(LeafCullingMode::Solid, 8, 8, 8));
// The outer shell has no data beyond it.
let mut mask = LeafMask::default();
mask.prepare(LeafCullingMode::Hollow, &pad, &states, &region(&chunk), 0);
assert_eq!(mask.cell(-B, 0, 0), Cell::Unknown);
assert_eq!(mask.cell(16 + B - 1, 0, 0), Cell::Unknown);
assert_eq!(mask.cell(0, 0, 0), Cell::Leaf);
// A section boundary is never enclosed, even with every chunk loaded:
// the neighbouring section may not have been meshed yet.
let mut mask = LeafMask::default();
mask.prepare(LeafCullingMode::Solid, &pad, &states, &region(&chunk), 0);
assert!(!mask.interior(LeafCullingMode::Solid, 15, 8, 8));
assert!(mask.interior(LeafCullingMode::Solid, 14, 8, 8));
}
#[test]
fn off_does_no_work_and_hides_nothing() {
let states = states();
let mut pad = padded(|_, _, _| 0);
cube(&mut pad, 7, 9, 1);
let chunk = loaded();
let mut mask = LeafMask::default();
mask.prepare(LeafCullingMode::Off, &pad, &states, &region(&chunk), 0);
assert!(!mask.active);
assert!(mask.cells.is_empty());
assert!(!mask.interior(LeafCullingMode::Off, 8, 8, 8));
assert!(!mask.hides_face(LeafCullingMode::Off, 8, 8, 8, Direction::Up));
}
#[test]
fn a_reused_mask_reclassifies_every_cell() {
let states = states();
let chunk = loaded();
let mut mask = LeafMask::default();
let mut leaves = padded(|_, _, _| 0);
cube(&mut leaves, 7, 9, 1);
mask.prepare(
LeafCullingMode::Hollow,
&leaves,
&states,
&region(&chunk),
0,
);
assert_eq!(mask.cell(8, 8, 8), Cell::Leaf);
let mut other = padded(|_, _, _| 0);
cube(&mut other, 10, 12, 1);
mask.prepare(LeafCullingMode::Hollow, &other, &states, &region(&chunk), 0);
assert_eq!(mask.cell(8, 8, 8), Cell::Open);
assert_eq!(mask.cell(11, 11, 11), Cell::Leaf);
}
#[test]
#[ignore = "microbenchmark; run with --ignored --nocapture"]
fn mask_build_benchmark() {
let states = states();
let mut pad = padded(|x, y, z| {
// Alternating leaf slabs make the search visit most of the volume.
if (x / 2 + y / 2 + z / 2) % 3 == 0 {
1
} else {
0
}
});
cube(&mut pad, 6, 10, 1);
let chunk = loaded();
let region = region(&chunk);
let iterations = 1000;
for mode in [LeafCullingMode::Solid, LeafCullingMode::SolidAggressive] {
let mut mask = LeafMask::default();
let started = Instant::now();
for _ in 0..iterations {
mask.prepare(mode, &pad, &states, &region, 0);
}
let per = started.elapsed() / iterations;
println!("leaf mask {mode:?}: {per:?} per section ({iterations} iterations)");
assert!(mask.active);
if mode == LeafCullingMode::SolidAggressive {
assert_ne!(mask.depth[pi(8, 8, 8)], UNVISITED);
}
}
}
}
@@ -1,293 +0,0 @@
//! Leaf culling through the real mesher: face counts, the opaque layer and the
//! conservative cases (cavities, partial neighbours, missing chunks, column
//! ends). Needs prepared resources, like the other mesher geometry tests.
use super::*;
use crate::{
assets::{Assets, RenderAssets},
biome::Biomes,
blocks::BlockFacts,
chunk::{Chunk, ColumnLayout},
graphics::LeafCullingMode,
models,
};
struct Fixture {
assets: Assets,
biomes: Biomes,
render: RenderAssets,
visibility: horizon_world::block_render::FaceVisibility,
tables: MeshTables,
}
fn fixture() -> Fixture {
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3")
});
let assets = Assets::load(&root).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let biomes = Biomes::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let visibility = models::load_visibility(&root, assets.registry).unwrap();
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
Fixture {
assets,
biomes,
render,
visibility,
tables,
}
}
fn state(f: &Fixture, name: &str) -> u32 {
f.assets.registry.default_state(name).unwrap().0
}
fn leaves_cube(chunk: &mut Chunk, min: i32, max: i32, y: i32, state: u32) {
for z in min..=max {
for x in min..=max {
chunk.set(x, y, z, state);
}
}
}
fn leaf_cube(f: &Fixture, min: i32, max: i32, base: i32, name: &str) -> Chunk {
let mut chunk = Chunk::empty(0, 0);
let state = state(f, name);
for y in base..base + (max - min + 1) {
leaves_cube(&mut chunk, min, max, y, state);
}
chunk
}
fn mesh(
f: &Fixture,
_chunk: &Chunk,
region: [[Option<&Chunk>; 3]; 3],
sy: i32,
mode: LeafCullingMode,
) -> SectionMesh {
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting: false,
assets: &f.assets,
render: &f.render,
tables: &f.tables,
biomes: &f.biomes,
visibility: f.visibility.clone(),
zoom: BiomeZoom::new(0),
blend_radius: 0,
smooth_lighting: false,
cutout_leaves: true,
leaf_culling: mode,
};
mesher.mesh(&region, sy)
}
fn around(chunk: &Chunk) -> [[Option<&Chunk>; 3]; 3] {
[[Some(chunk); 3]; 3]
}
/// (opaque, cutout) leaf quads, rejecting a quad whose four vertices disagree.
fn quads(mesh: &SectionMesh) -> (usize, usize) {
let mut opaque = 0;
let mut cutout = 0;
for quad in mesh.solid.as_chunks::<4>().0 {
let flags = quad
.iter()
.map(|v| v.layer & OPAQUE_LAYER != 0)
.collect::<Vec<_>>();
assert!(
flags.iter().all(|flag| *flag == flags[0]),
"a quad mixed opaque and cutout vertices: {flags:?}"
);
if flags[0] {
opaque += 1;
} else {
cutout += 1;
}
}
assert_eq!(mesh.solid.len() % 4, 0);
(opaque, cutout)
}
const BASE: i32 = MIN_Y + 4;
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn off_draws_every_fancy_leaf_face() {
let f = fixture();
let chunk = leaf_cube(&f, 6, 8, BASE, "minecraft:oak_leaves");
let mesh = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Off);
// 27 blocks, six quads each: vanilla's fancy leaves keep the inside.
assert_eq!(quads(&mesh), (0, 162));
assert_eq!(mesh.translucent.len(), 0);
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn solid_draws_the_fully_enclosed_leaf_opaque() {
let f = fixture();
let chunk = leaf_cube(&f, 6, 8, BASE, "minecraft:oak_leaves");
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
// Only the centre has all six sides covered: its six quads go opaque.
assert_eq!(quads(&solid), (6, 156));
let aggressive = mesh(
&f,
&chunk,
around(&chunk),
0,
LeafCullingMode::SolidAggressive,
);
// Aggressive also takes the six face-centre leaves: seven blocks opaque.
assert_eq!(quads(&aggressive), (42, 120));
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn aggressive_solidifies_the_next_shell_layer_of_a_five_cube() {
let f = fixture();
let chunk = leaf_cube(&f, 5, 9, BASE, "minecraft:oak_leaves");
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
// The 3x3x3 inside the surface: 27 blocks, six quads each.
assert_eq!(quads(&solid), (162, 588));
let aggressive = mesh(
&f,
&chunk,
around(&chunk),
0,
LeafCullingMode::SolidAggressive,
);
// The 3x3x3 inside plus the 3x3 face centre of each of the six sides.
assert_eq!(quads(&aggressive), (486, 264));
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn hollow_drops_only_the_faces_two_leaves_share() {
let f = fixture();
let chunk = leaf_cube(&f, 6, 8, BASE, "minecraft:oak_leaves");
let hollow = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Hollow);
// 162 quads minus the 54 shared pairs' 108 faces: the 3x3x3 shell.
assert_eq!(quads(&hollow), (0, 54));
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn mixed_species_count_as_one_leaf_volume() {
let f = fixture();
let oak = state(&f, "minecraft:oak_leaves");
let birch = state(&f, "minecraft:birch_leaves");
let mut chunk = Chunk::empty(0, 0);
for y in BASE..BASE + 3 {
for z in 6..=8 {
for x in 6..=8 {
chunk.set(x, y, z, if (x + y + z) % 2 == 0 { oak } else { birch });
}
}
}
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
assert_eq!(quads(&solid), (6, 156));
let hollow = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Hollow);
assert_eq!(quads(&hollow), (0, 54));
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn a_cavity_keeps_its_six_faces_and_its_rim_stays_cutout() {
let f = fixture();
let mut chunk = leaf_cube(&f, 5, 9, BASE, "minecraft:oak_leaves");
chunk.set(7, BASE + 2, 7, 0);
let hollow = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Hollow);
// Five-cube shell (150) plus the six faces around the one-block cavity.
assert_eq!(quads(&hollow), (0, 156));
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
// The six leaves around the cavity are exposed, so they stay cutout; the
// other 20 inner leaves draw solid. The cavity block itself has no quads.
assert_eq!(quads(&solid), (120, 624));
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn section_boundary_faces_stay_with_a_loaded_neighbour() {
let f = fixture();
let leaf = state(&f, "minecraft:oak_leaves");
let chunk = leaf_cube(&f, 12, 15, BASE, "minecraft:oak_leaves");
// A loaded east chunk whose x=0 column continues the canopy.
let mut east = Chunk::empty(1, 0);
for y in BASE..BASE + 4 {
for z in 12..=15 {
east.set(0, y, z, leaf);
}
}
let mut region = around(&chunk);
region[1][2] = Some(&east);
let joined = mesh(&f, &chunk, region, 0, LeafCullingMode::Hollow);
let mut region = around(&chunk);
region[1][2] = None;
let missing = mesh(&f, &chunk, region, 0, LeafCullingMode::Hollow);
// The east faces of the x=15 leaves are on the section boundary: a loaded
// neighbour does not let Hollow drop them, and a missing one does not
// change the mesh either.
assert_eq!(joined.solid.len(), missing.solid.len());
let off = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Off);
assert!(joined.solid.len() < off.solid.len());
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn the_top_of_a_one_section_column_stays_visible() {
let f = fixture();
let leaf = state(&f, "minecraft:oak_leaves");
let layout = ColumnLayout::new(0, 16).unwrap();
let mut chunk = Chunk::with_layout(0, 0, layout);
for y in 13..16 {
leaves_cube(&mut chunk, 5, 7, y, leaf);
}
let hollow = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Hollow);
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
// y=15 is the column's top: the faces toward y=16 are unknown, so they
// stay, and the y=15 leaves are not enclosed.
assert!(!hollow.solid.is_empty());
assert!(solid.solid.iter().any(|v| v.pos[1] == 16.0));
assert!(
solid
.solid
.iter()
.filter(|v| v.pos[1] == 16.0)
.all(|v| v.layer & OPAQUE_LAYER == 0)
);
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn a_world_without_leaves_meshes_identically_in_every_mode() {
let f = fixture();
let mut chunk = Chunk::empty(0, 0);
for y in BASE..BASE + 3 {
leaves_cube(&mut chunk, 6, 8, y, state(&f, "minecraft:stone"));
}
let off = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Off);
for mode in LeafCullingMode::ALL {
let other = mesh(&f, &chunk, around(&chunk), 0, mode);
assert_eq!(off.solid.len(), other.solid.len(), "{mode:?}");
assert_eq!(off.translucent.len(), other.translucent.len(), "{mode:?}");
for (a, b) in off.solid.iter().zip(&other.solid) {
assert_eq!(
(a.pos, a.uv, a.layer, a.light, a.color),
(b.pos, b.uv, b.layer, b.light, b.color),
"{mode:?}"
);
}
for (a, b) in off.translucent.iter().zip(&other.translucent) {
assert_eq!(
(a.pos, a.uv, a.layer, a.light, a.color),
(b.pos, b.uv, b.layer, b.light, b.color),
"{mode:?}"
);
}
}
}
@@ -81,7 +81,6 @@ fn a_shader_pack_lights_the_faces_itself() {
blend_radius: 0,
smooth_lighting: false,
cutout_leaves: true,
leaf_culling: LeafCullingMode::Off,
};
let mut colours: Vec<u32> = mesher
.mesh(&region, 0)
@@ -157,7 +156,6 @@ fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
blend_radius: 2,
smooth_lighting: reload == 0,
cutout_leaves: reload == 0,
leaf_culling: LeafCullingMode::Off,
};
for (case, (center, east)) in [
(&empty, None),
@@ -88,7 +88,6 @@ fn info(solid: bool) -> Info {
legacy_solid: false,
overlay: false,
leaves: false,
opaque_cube: solid,
blocks_flow: false,
ice: false,
sturdy: 0,
@@ -68,9 +68,6 @@ pub enum Ticking {
/// `RidingEntitySoundInstance` for the player riding a happy ghast
/// (`underwater_sound` false) or a nautilus (true).
RidingEntity { entity: u32, underwater_sound: bool },
/// A native mod's music layer (`MusicLayers`), whose volume its owner
/// sets each tick.
MusicLayer,
}
/// What a minecart's sound instances read about it.
#[derive(Clone, Copy, Debug)]
@@ -236,25 +233,6 @@ impl SoundInstance {
true,
)
}
/// A native mod's music layer: a looping, unattenuated track on the music
/// source that starts silent (`MusicLayers` fades it in).
pub fn for_music_layer(id: &str, random: LegacyRandom) -> Self {
let mut s = Self::new(
id,
MUSIC,
0.0,
1.0,
random,
true,
0,
Attenuation::None,
[0.0; 3],
true,
);
s.can_start_silent = true;
s.ticking = Ticking::MusicLayer;
s
}
/// `SimpleSoundInstance.forJukeboxSong`.
pub fn for_jukebox_song(id: &str, at: [f64; 3], random: LegacyRandom) -> Self {
Self::new(
@@ -544,7 +522,7 @@ impl SoundInstance {
/// `TickableSoundInstance.tick`.
pub fn tick(&mut self, ctx: &TickContext) {
match &mut self.ticking {
Ticking::No | Ticking::MusicLayer => {}
Ticking::No => {}
Ticking::BiomeLoop { fade, direction } => {
let stop = *fade < 0;
*fade += *direction;
+2 -45
View File
@@ -349,26 +349,10 @@ impl Sprites {
};
self.register(root, name, decoded)
}
/// The layer of a texture outside `textures/` (an OptiFine pack's connected-texture tile),
/// named by its resource path and added on first use. Unlike [`Sprites::insert`], a texture
/// that does not load is an error: the caller drops what needed it. Such files bypass the
/// disk cache, whose fingerprint covers only `textures/`.
pub fn insert_file(&mut self, root: &Path, resource: &str) -> Result<u32> {
if let Some(layer) = self.layers.get(resource) {
return Ok(*layer);
}
let decoded = Self::decode_file(&root.join(resource), resource)?;
self.register(root, resource, decoded)
}
/// Read and decode one texture and its `.mcmeta`.
fn decode(root: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
let (namespace, path) = name.split_once(':').ok_or("invalid sprite identifier")?;
Self::decode_file(
&root.join(format!("assets/{namespace}/textures/{path}.png")),
name,
)
}
fn decode_file(path: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
let path = root.join(format!("assets/{namespace}/textures/{path}.png"));
let metadata = match fs::read_to_string(path.with_extension("png.mcmeta")) {
Ok(text) => Some(serde_json::from_str::<
BTreeMap<String, Box<serde_json::value::RawValue>>,
@@ -384,7 +368,7 @@ impl Sprites {
.as_deref()
.map(AnimationMetadata::from_json)
.transpose()?;
let (png_width, png_height, pixels) = read_rgba(path)?;
let (png_width, png_height, pixels) = read_rgba(&path)?;
let (width, height) = if let Some(animation) = &animation {
let [width, height] =
animation.frame_size(i32::try_from(png_width)?, i32::try_from(png_height)?);
@@ -678,33 +662,6 @@ mod tests {
}
}
#[test]
fn files_outside_textures_load_by_path() {
let root = std::env::temp_dir().join(format!("horizon-sprite-file-{}", std::process::id()));
let tile = "assets/minecraft/optifine/ctm/glass/0.png";
fs::create_dir_all(root.join(tile).parent().unwrap()).unwrap();
let mut encoder = png::Encoder::new(File::create(root.join(tile)).unwrap(), 16, 16);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let pixels: Vec<u8> = (0..256).flat_map(|i| [i as u8, 0, 0, 128]).collect();
encoder
.write_header()
.unwrap()
.write_image_data(&pixels)
.unwrap();
let mut sprites = Sprites::default();
let layer = sprites.insert_file(&root, tile).unwrap();
assert_eq!(sprites.insert_file(&root, tile).unwrap(), layer);
assert!(sprites.translucent[layer as usize]);
// A missing tile is an error, not the missing texture.
assert!(
sprites
.insert_file(&root, "assets/minecraft/optifine/ctm/glass/1.png")
.is_err()
);
assert_eq!(sprites.count(), 1);
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn identical_still_textures_share_a_layer() {
let root =
std::env::temp_dir().join(format!("horizon-sprite-share-{}", std::process::id()));
@@ -6,7 +6,6 @@ use crate::fast_hash::{FxHashMap, FxHashSet};
use crate::section_mask::SectionMask;
use crate::{
chunk::Chunk,
graphics::LeafCullingMode,
light::{LightChanges, LightTables, apply_lighting, apply_relighting, light_chunk},
mesher::{MeshScratch, MeshTables, Mesher, Region, SectionMesh, borrow},
world::World,
@@ -205,7 +204,6 @@ struct MeshSettings {
blend_radius: i32,
smooth_lighting: bool,
cutout_leaves: bool,
leaf_culling: LeafCullingMode,
}
enum Job {
Generate(i32, i32),
@@ -440,8 +438,6 @@ pub struct Streaming {
/// The `ao` and `cutoutLeaves` options.
pub smooth_lighting: bool,
pub cutout_leaves: bool,
/// What the mesher does with leaf faces inside a canopy (`LeafCullingMode`).
pub leaf_culling: LeafCullingMode,
/// 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.
@@ -776,7 +772,6 @@ impl Streaming {
blend_radius: settings.blend_radius,
smooth_lighting: settings.smooth_lighting,
cutout_leaves: settings.cutout_leaves,
leaf_culling: settings.leaf_culling,
assets: &context.assets,
render: &context.render,
tables: &context.tables,
@@ -858,7 +853,6 @@ impl Streaming {
biome_blend: 2,
smooth_lighting: true,
cutout_leaves: true,
leaf_culling: LeafCullingMode::Off,
light_tables,
light_log: Vec::new(),
light_edit_sequence: 0,
@@ -1718,7 +1712,6 @@ impl Streaming {
blend_radius: self.biome_blend,
smooth_lighting: self.smooth_lighting,
cutout_leaves: self.cutout_leaves,
leaf_culling: self.leaf_culling,
},
),
);
@@ -591,11 +591,6 @@ fn sky_along_ray(d: vec3<f32>) -> vec3<f32> {
@fragment fn fs_sky(in: SkyOut) -> @location(0) vec4<f32> {
return vec4(sky_along_ray(view_ray(in.ndc)), 1.0);
}
// The first-person hand's depth clear inside the world pass: `vs_sky`'s triangle lies at the
// far plane, written over the whole depth target with no colour.
@fragment fn fs_depth_reset(in: SkyOut) -> @location(0) vec4<f32> {
return vec4(0.0);
}
@fragment fn fs_dark_disc(in: SkyOut) -> @location(0) vec4<f32> {
let d = view_ray(in.ndc);
let dist = disc_distance(d, -4.0);
+14 -8
View File
@@ -6,8 +6,12 @@ edition.workspace = true
license.workspace = true
rust-version = "1.90"
description = "Horizon native development client"
autobins = false
autoexamples = false
autotests = false
[dependencies]
horizon-client-lib = { path = "../horizon-client-lib", default-features = false }
horizon-assets = { path = "../horizon-assets" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
@@ -36,7 +40,6 @@ horizon-narrator = { path = "../horizon-narrator" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
# Their runtime is the `mods` feature.
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
horizon-optifine = { path = "../horizon-optifine" }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
winit = "0.30.13"
pollster = "0.4"
@@ -104,20 +107,23 @@ winresource = "0.1"
default = ["audio", "controllers"]
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
audio = ["horizon-client-render/audio"]
audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
rng-trace = ["horizon-core/rng-trace", "horizon-client-lib/rng-trace"]
# Compatibility with earlier experiment commands: main already uses mimalloc.
windows-mimalloc = []
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
windows-segment-heap = []
# 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"
[[bin]]
name = "horizon-client"
path = "src/main.rs"
[[test]]
name = "launch_api"
path = "tests/launch_api.rs"
[lints]
workspace = true
@@ -203,11 +203,5 @@
"horizon.multiplayer.not_signed_in": "Este servidor está en modo en línea. Inicia sesión con tu cuenta de Microsoft en el launcher de Horizon para unirte.",
"horizon.multiplayer.invalid_address": "Dirección de servidor no válida: %s",
"horizon.multiplayer.connected_friend_direct": "Conectado directamente con tu amigo…",
"horizon.multiplayer.connected_friend_relay": "Conectado con tu amigo a través del relé…",
"horizon.pages.shader_options": "Opciones de sombreadores",
"horizon.pages.split_profiles": "¿Quién juega?",
"horizon.pages.split_profiles.tooltip": "Quién juega: un perfil guardado en esta carpeta del juego.",
"horizon.shader_options.open": "Opciones de sombreadores…",
"horizon.shader_options.profile": "Perfil",
"horizon.shader_options.loading": "Leyendo las opciones del paquete de sombreadores…"
"horizon.multiplayer.connected_friend_relay": "Conectado con tu amigo a través del relé…"
}
@@ -18,11 +18,5 @@
"horizon.multiplayer.not_signed_in": "Ten serwer działa w trybie online. Zaloguj się kontem Microsoft w launcherze Horizon, aby dołączyć.",
"horizon.multiplayer.invalid_address": "Nieprawidłowy adres serwera: %s",
"horizon.multiplayer.connected_friend_direct": "Połączono bezpośrednio ze znajomym…",
"horizon.multiplayer.connected_friend_relay": "Połączono ze znajomym przez przekaźnik…",
"horizon.pages.shader_options": "Ustawienia shaderów",
"horizon.pages.split_profiles": "Kto gra?",
"horizon.pages.split_profiles.tooltip": "Kto gra: profil przechowywany w tym folderze gry.",
"horizon.shader_options.open": "Ustawienia shaderów…",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "Wczytywanie ustawień paczki shaderów…"
"horizon.multiplayer.connected_friend_relay": "Połączono ze znajomym przez przekaźnik…"
}
@@ -202,11 +202,5 @@
"horizon.multiplayer.not_signed_in": "Этот сервер работает в онлайн-режиме. Войдите с помощью аккаунта Microsoft в лаунчере Horizon, чтобы присоединиться.",
"horizon.multiplayer.invalid_address": "Недействительный адрес сервера: %s",
"horizon.multiplayer.connected_friend_direct": "Установлено прямое соединение с другом…",
"horizon.multiplayer.connected_friend_relay": "Соединение с другом через релей…",
"horizon.pages.shader_options": "Настройки шейдеров",
"horizon.pages.split_profiles": "Кто играет?",
"horizon.pages.split_profiles.tooltip": "Кто играет: профиль, хранимый в этой папке игры.",
"horizon.shader_options.open": "Настройки шейдеров…",
"horizon.shader_options.profile": "Профиль",
"horizon.shader_options.loading": "Чтение настроек набора шейдеров…"
"horizon.multiplayer.connected_friend_relay": "Соединение с другом через релей…"
}
@@ -196,11 +196,5 @@
"Threads that generate, light and mesh chunks. Next start.": "Parçaları oluşturan, aydınlatan ve ağ verilerini hazırlayan iş parçacıklarının sayısı. Değişiklikler sonraki başlatmada uygulanır.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Önce Vulkan kullanılır. Çalışmazsa Windows'ta DirectX 12, Linux'ta OpenGL denenir. Değişiklikler sonraki başlatmada uygulanır.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Her karede GPU'ya gönderilen ağ verisi miktarı. Daha düşük değerler yükleme sırasında daha akıcı bir deneyim sağlar.",
"Frame rate and frame times over the game, with a graph.": "Oyun ekranında FPS değerini ve kare sürelerini grafikle birlikte gösterir.",
"horizon.pages.shader_options": "Gölgelendirici Seçenekleri",
"horizon.pages.split_profiles": "Kim oynuyor?",
"horizon.pages.split_profiles.tooltip": "Kim oynuyor: bu oyun klasöründe tutulan bir profil.",
"horizon.shader_options.open": "Gölgelendirici Seçenekleri…",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "Gölgelendirici paketinin seçenekleri okunuyor…"
"Frame rate and frame times over the game, with a graph.": "Oyun ekranında FPS değerini ve kare sürelerini grafikle birlikte gösterir."
}
@@ -18,11 +18,5 @@
"horizon.multiplayer.not_signed_in": "Цей сервер працює в онлайн-режимі. Увійдіть за допомогою акаунта Microsoft у лаунчері Horizon, щоб приєднатися.",
"horizon.multiplayer.invalid_address": "Недійсна адреса сервера: %s",
"horizon.multiplayer.connected_friend_direct": "Пряме з'єднання з другом встановлено…",
"horizon.multiplayer.connected_friend_relay": "З'єднання з другом через ретранслятор…",
"horizon.pages.shader_options": "Налаштування шейдерів",
"horizon.pages.split_profiles": "Хто грає?",
"horizon.pages.split_profiles.tooltip": "Хто грає: профіль, що зберігається в цій теці гри.",
"horizon.shader_options.open": "Налаштування шейдерів…",
"horizon.shader_options.profile": "Профіль",
"horizon.shader_options.loading": "Читання налаштувань набору шейдерів…"
"horizon.multiplayer.connected_friend_relay": "З'єднання з другом через ретранслятор…"
}
@@ -203,11 +203,5 @@
"horizon.multiplayer.not_signed_in": "该服务器为在线模式服务器。请先在 Horizon 启动器中登录 Microsoft 账户再加入。",
"horizon.multiplayer.invalid_address": "无效的服务器地址:%s",
"horizon.multiplayer.connected_friend_direct": "已直连好友的世界…",
"horizon.multiplayer.connected_friend_relay": "已通过中继连接好友的世界…",
"horizon.pages.shader_options": "光影选项",
"horizon.pages.split_profiles": "谁在玩?",
"horizon.pages.split_profiles.tooltip": "谁在玩:保存在此游戏文件夹中的一份档案。",
"horizon.shader_options.open": "光影选项…",
"horizon.shader_options.profile": "档案",
"horizon.shader_options.loading": "正在读取光影包的选项…"
"horizon.multiplayer.connected_friend_relay": "已通过中继连接好友的世界…"
}
@@ -12,12 +12,12 @@
//! this thread's CPU time in `Mesher::mesh`, the best of `--rounds`, so other
//! work on the machine changes it less. The checksum of the meshes shows that an
//! A/B pair of builds made the same ones.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
assets::Assets,
biome::Biomes,
blocks::BlockFacts,
chunk::HEIGHT,
graphics::LeafCullingMode,
light::LightTables,
mesher::{MeshTables, Mesher, Region, borrow},
models,
@@ -140,7 +140,6 @@ fn main() -> Result<(), Error> {
blend_radius: 2,
smooth_lighting: true,
cutout_leaves: true,
leaf_culling: LeafCullingMode::Off,
assets: &context.assets,
render: &context.render,
tables: &context.tables,
@@ -1,6 +1,7 @@
//! Profile the two mandatory passes over gameplay-facts.json independently.
//! Build with `cargo build --release -p horizon-client --example gameplay_decode_profile`.
//! Each invocation runs one phase in a separate process for CPU/RSS measurements.
extern crate horizon_client_lib as horizon_client;
#[allow(dead_code)]
#[path = "../../horizon-items/src/blocks/decode.rs"]
mod decode;
@@ -1,5 +1,6 @@
//! Compare the read + Value parse stages in BlockFacts::load and Assets::load.
//! Usage: json_load_bench RESOURCE_ROOT [REPETITIONS]; see tools/json_load_fixtures.py.
extern crate horizon_client_lib as horizon_client;
use serde_json::Value;
use std::{
fs::{self, File},
@@ -7,6 +7,7 @@
//!
//! WORK is a `horizon-port-pack` work folder: `oracle/facts.json` and
//! `mods/<id>/carry/`. The root is built in `WORK/horizon-root`.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
Result,
assets::Assets,
@@ -2,6 +2,7 @@
//! for live requested heap bytes; leave it unset for latency measurements.
//! cargo run --profile fast -p horizon-client --example model_library_bench -- ROOT [--parity]
//! Run fresh processes, warm up twice, then compare multiple alternating baseline/candidate runs.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{assets::Assets, item_render::ItemRenderer};
use std::{
alloc::{GlobalAlloc, Layout, System},
@@ -1,5 +1,6 @@
//! Compare the old two-parse loading path with catalog construction from shared facts.
//! Run: cargo run -p horizon-client --profile fast --example model_states_bench -- 15
extern crate horizon_client_lib as horizon_client;
use horizon_client::{Result, models::StateModels, sprites::Sprites};
use horizon_core::position::BlockPos;
use horizon_world::{
@@ -5,6 +5,7 @@
//! generated around them blend with them (`Blender.of`).
//!
//! old_world_fixture <source world> <destination world> <x0,z0,x1,z1>
extern crate horizon_client_lib as horizon_client;
use horizon_core::position::ChunkPos;
use horizon_world::{
nbt::{Limits, Tag},
@@ -2,6 +2,7 @@
//! entity reaches, with the per-tick palette map against the resident cache.
//! Run with prepared client resources:
//! cargo run --profile fast -p horizon-client --example portal_scan_bench -- RESOURCES
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
blocks::BlockFacts,
chunk::{Chunk, MIN_Y, SECTIONS, Section},
@@ -328,5 +328,5 @@ fn chat(c: &Value, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
if let Some(n) = c["scroll"].as_i64() {
chat.scroll_by(n as i32, focused);
}
chat.layout(h, c["now"].as_f64().unwrap_or(0.0) as f32, focused)
chat.layout(h, c["now"].as_u64().unwrap_or(0), focused)
}
@@ -4,6 +4,7 @@
//!
//! This file only lists the probes, so that adding one reruns no harness: tools/parity.py leaves it out of
//! their dependencies. How a probe runs is in dispatch.rs.
extern crate horizon_client_lib as horizon_client;
#[macro_use]
mod dispatch;
@@ -1182,7 +1182,6 @@ pub fn main() -> horizon_client::Result<()> {
thundering: s.thunder,
nether: false,
block_tags: &items.block_tags,
landing: &motion.landing,
biomes: &biomes,
mob_griefing: true,
universal_anger: false,
@@ -7,7 +7,6 @@ use horizon_client::{
biome::{BiomeInfo, Biomes, GrassModifier},
blocks::BlockFacts,
chunk::{Chunk, Light, MIN_Y, index},
graphics::LeafCullingMode,
mesher::{EMISSION_BITS, FLUID_FACE_BITS, MeshTables, Mesher, OPAQUE_LAYER, TerrainVertex},
models,
};
@@ -138,7 +137,6 @@ fn scene(
blend_radius,
smooth_lighting,
cutout_leaves,
leaf_culling: LeafCullingMode::Off,
};
let section_y = int(&row["section_y"])?;
let started = Instant::now();
@@ -1,5 +1,6 @@
//! Selection cost only (not block simulation). Run with prepared client resources.
//! cargo run --profile fast -p horizon-client --example random_tick_eligibility_bench -- RESOURCES
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
blocks::BlockFacts,
chunk::{Chunk, MIN_Y, Section},
@@ -9,6 +9,7 @@
//! and the process RSS (what the allocator holds on to). On macOS it ends
//! with `footprint`'s physical footprint, the number the OS charges: RSS also
//! counts freed pages the allocator marked reusable.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
assets::Assets, biome::Biomes, blocks::BlockFacts, collisions, crafting::Recipes, items::Items,
light::LightTables, mesher::MeshTables, models, motion::MotionResources,
@@ -2,6 +2,7 @@
//! full chunk NBT encoder. Run with `cargo run --profile fast -p horizon-client
//! --example save_batch_bench`. Add `-- --nbt` to also time serialization.
//! Exact NBT equality is always validated. No GPU, compression or disk I/O timed.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
biome::Biomes,
chunk::{Chunk, block_strategy},
@@ -2,6 +2,7 @@
//! Run each mode in a separate process to capture CPU usage and peak RSS.
//! The deferred mode evaluates both phases contiguously for fair loader comparison;
//! actual client startup performs other initialization between these phases.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{assets::Assets, blocks::BlockFacts};
use horizon_world::level::Behaviours;
use std::{path::Path, time::Instant};
@@ -2,6 +2,7 @@
//! to the ones decoded from the PNGs. Deletes the cache file in ROOT, loads (decodes
//! and writes it), loads again (reads it) and compares the sprite arrays.
//! cargo run --profile fast -p horizon-client --example sprite_cache_parity -- ROOT
extern crate horizon_client_lib as horizon_client;
use horizon_client::assets::{Assets, RenderAssets};
use std::{
hash::{DefaultHasher, Hash, Hasher},
+6 -11
View File
@@ -2,7 +2,6 @@
//! progress (`PlayerAdvancements`) and the predicates criteria test
//! (`EntityPredicate`, `ItemPredicate`, `LocationPredicate`, the loot
//! conditions advancements use).
use horizon_world::lazy_json::LazyJson;
use serde_json::Value;
use std::{
collections::{BTreeMap, BTreeSet, HashMap},
@@ -31,9 +30,7 @@ pub struct Display {
pub struct Criterion {
pub name: String,
pub trigger: String,
/// Read on first test: ~1,300 recipe advancements' criteria, most never
/// tested in a game, took ~4 MB as `Value`s.
pub conditions: LazyJson,
pub conditions: Value,
}
#[derive(Clone, Debug, Default)]
pub struct Rewards {
@@ -157,8 +154,7 @@ impl Tree {
.into_iter()
.flatten()
{
// `read`: the index alone keeps none of the criteria read.
match self.list[ai].criteria[ci].conditions.read()["recipes"].as_str() {
match self.list[ai].criteria[ci].conditions["recipes"].as_str() {
Some(r) => self
.by_recipe
.entry(r.to_owned())
@@ -212,8 +208,7 @@ impl InventoryIndex {
return index;
};
for &(ai, ci) in list {
let conditions = tree.list[ai].criteria[ci].conditions.read();
let items = &conditions["items"];
let items = &tree.list[ai].criteria[ci].conditions["items"];
// `id_matches`: only a string or a list limits the items.
let limited = items.as_array().and_then(|a| {
a.iter()
@@ -271,7 +266,7 @@ fn parse(id: &str, v: &Value) -> Option<Advancement> {
.map(|(name, c)| Criterion {
name: name.clone(),
trigger: c["trigger"].as_str().unwrap_or("").to_owned(),
conditions: LazyJson::new(&c["conditions"]),
conditions: c["conditions"].clone(),
})
.collect();
let requirements = match v["requirements"].as_array() {
@@ -1611,7 +1606,7 @@ mod tests {
criteria: vec![Criterion {
name: "c".into(),
trigger: "minecraft:inventory_changed".into(),
conditions: LazyJson::new(&conditions),
conditions,
}],
requirements: vec![vec!["c".into()]],
rewards: Rewards::default(),
@@ -1660,7 +1655,7 @@ mod tests {
criteria: vec![Criterion {
name: "c".into(),
trigger: "minecraft:recipe_unlocked".into(),
conditions: LazyJson::new(&conditions),
conditions,
}],
requirements: vec![vec!["c".into()]],
rewards: Rewards::default(),
@@ -1,6 +1,7 @@
//! horizon-ctl: play and script a running Horizon client through its control
//! socket, from a shell, a script or an AI agent (`horizon-ctl mcp`).
#![forbid(unsafe_code)]
extern crate horizon_client_lib as horizon_client;
mod commands;
mod launch;
mod mcp;
+160
View File
@@ -0,0 +1,160 @@
//! Horizon native client: resources, world streaming, rendering and the
//! bindings between the validated simulation pieces and the game loop.
#![forbid(unsafe_code)]
extern crate self as horizon_client;
pub mod advancements;
pub mod ambience;
pub mod animate_tick;
pub mod arrows;
pub mod block_contact;
pub use horizon_client_render::{
assets, atlas_sources, audio, auth, ban_notice, bidi, biome, block_entities, block_entity_anim,
block_entity_render, border_render, breaking, brewing, camera, chat, chunk, chunk_map, clouds,
collisions, command_block, containers, crash, datapack, dynamic_light, effects, environment,
extras, fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui, hdr_calibration, hud,
hud_layout, hud_overlays, inventory_effects, java_text, jigsaw_block, level_loading, light,
lod, logging, menu_blur, mesher, minimap, mipmap, mod_list, model_library, models,
multiline_edit, native_emissive, options_file, options_fixes, options_ui, overlay, pack_screen,
packs, par, particles, poi_sections, post_chain, potent_sulfur, preedit, providers,
screen_draw, screen_effects, sculk, section_mask, settings, shader_options, shader_pack_list,
signs, skins, sound_engine, sound_events, sound_preview, spans, split_layout, split_profiles,
sprites, streaming, structure_block, tab_list, telemetry, test_block, test_instance_block,
testgen, title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
};
pub mod attack_strength;
pub mod attribute_updates;
pub mod auto_jump;
pub mod banner;
pub mod beehive;
pub mod benchmark;
pub mod block_entity_item;
pub mod block_pattern;
pub mod block_transform;
pub mod boss_overlay;
pub mod cauldron;
pub mod chunk_io;
pub mod clock;
pub mod command_storage;
pub mod confirm_screen;
pub mod container_screen;
pub mod content_identity;
pub mod control;
pub mod crafting;
pub mod creative;
pub mod cushion;
pub mod data_cmd;
pub mod debug_chart;
pub mod debug_entries;
pub mod debug_keys;
pub mod debug_overlay;
pub mod debugworld;
pub mod dialog;
pub mod dialog_screen;
pub mod discord_presence;
pub mod end_crystal;
pub mod end_gateway;
pub mod entities;
pub mod entity_culling;
pub mod entity_nbt;
pub mod extras_hud;
pub mod falling_block;
pub mod fire_render;
pub mod first_person;
pub mod flatgen;
pub mod friends_screen;
pub mod furnace;
pub mod game_mode_switcher;
pub mod game_rules_screen;
pub mod gizmos;
pub mod gossip;
pub mod health;
pub mod heartbeat;
pub mod hotbar;
pub mod hunger;
pub mod inv_sort;
pub mod item_activation;
pub mod item_render;
pub mod item_sync;
pub mod item_tooltip;
pub mod lan;
pub mod landing;
pub mod locator_bar;
pub mod lod_net;
pub mod menu;
pub mod merchant;
pub mod mobs;
pub mod mod_content;
pub mod motion;
pub mod multiplayer;
pub mod music;
pub mod native_data;
pub mod net;
pub mod nether_portal;
pub mod no_resources;
pub mod offline_name;
pub mod old_world;
pub mod osk;
pub mod patrol;
pub mod perf_overlay;
pub mod pick_item;
pub mod player_sync;
pub mod portal_sections;
pub mod pose;
pub mod pregen;
pub mod projectiles;
pub mod raid;
pub mod random_tick_sections;
pub mod recipe_book;
pub mod recipe_book_screen;
pub mod reconnect;
pub mod save;
pub mod scoreboard_cmd;
pub mod screen_layout;
pub mod screen_narrator;
pub use horizon_server_files as server;
pub mod server_packs;
pub mod shadows;
pub mod spear;
pub mod spectator_menu;
pub mod sprint;
pub mod stations;
pub mod stopwatches;
pub mod structure_blocks;
pub mod text_display;
pub mod tick_chunk_membership;
pub mod ticket_levels;
pub mod title;
pub mod toasts;
pub mod ui_theme;
pub mod vanillagen;
pub mod win_screen;
pub mod world_spawn;
pub use horizon_client_core::Result;
pub use horizon_client_core::block_entity_nbt;
pub use horizon_client_core::bossbar;
pub use horizon_client_core::component;
pub use horizon_client_core::fastutil_order;
pub use horizon_client_core::gamerules;
pub use horizon_client_core::lang;
pub use horizon_client_core::nbt_path;
pub use horizon_client_core::scoreboard;
pub use horizon_client_core::snbt;
pub use horizon_client_core::text;
pub use horizon_client_input::clipboard;
pub use horizon_client_input::gamepad;
pub use horizon_client_input::input;
pub use horizon_client_input::interaction_input;
pub use horizon_client_input::key_names;
pub use horizon_client_input::keybinds;
pub use horizon_client_input::keymapping;
pub use horizon_client_input::mouse;
pub use horizon_client_input::window;
pub use horizon_command as command;
pub use horizon_items::blocks;
pub use horizon_items::bundle;
pub use horizon_items::cooldowns;
pub use horizon_items::enchant;
pub use horizon_items::flowers;
pub use horizon_items::item_data;
pub use horizon_items::items;
pub use horizon_items::random_sequences;
+9 -36
View File
@@ -1219,15 +1219,6 @@ pub(crate) fn recipe_files(dir: &Path) -> Result<Vec<(String, PathBuf)>> {
Ok(named)
}
/// The type of a recipe the game doesn't craft itself: one outside `minecraft`.
/// A recipe's whole id from its name in the tables: vanilla's are kept bare
/// (`oak_planks`), everyone else's whole (`mymod:crusher/iron`).
pub fn recipe_id(name: &str) -> String {
if name.contains(':') {
name.to_owned()
} else {
format!("minecraft:{name}")
}
}
fn custom_type(v: &Value) -> Option<&str> {
v["type"]
.as_str()
@@ -1318,14 +1309,10 @@ fn matches_all(ingredients: &[Ingredient], stacks: &[&ItemStack]) -> bool {
}
impl Recipes {
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Result<Self> {
let sources = Self::sources(root, overlay)?;
Self::from_sources(root, items, overlay, sources)
}
/// The recipe files the tables are built from, each id once: vanilla's by
/// bare name, then the content mods' as `<ns>:<name>`, the world's packs'
/// over them (`overlay`), parsed, and only those whose load conditions
/// hold. A server sends some of them to its clients (`recipe_content`).
pub fn sources(root: &Path, overlay: &PackOverlay) -> Result<Vec<(String, Value)>> {
let mut crafting = Vec::new();
let mut cooking = Vec::new();
let mut custom: HashMap<String, Vec<CustomRecipe>> = HashMap::new();
let mut meta = HashMap::new();
// Vanilla's recipes by bare name, then content mods' as `<ns>:<name>`.
let mut sources: Vec<(String, Vec<u8>)> = Vec::new();
// NeoForge's load conditions: `mod_loaded` asks which mods' data is
@@ -1370,7 +1357,6 @@ impl Recipes {
}
}
let filters = horizon_items::recipe_filters::RecipeFilters::load(root, overlay);
let mut parsed = Vec::with_capacity(sources.len());
for (name, bytes) in sources {
// Vanilla's RecipeManager logs a file that does not parse and
// goes on ("Couldn't parse data file").
@@ -1389,26 +1375,13 @@ impl Recipes {
continue;
}
// The data's recipe filters, over the final set.
if !filters.apply(&recipe_id(&name), &mut v) {
let full_id = match name.contains(':') {
true => name.clone(),
false => format!("minecraft:{name}"),
};
if !filters.apply(&full_id, &mut v) {
continue;
}
parsed.push((name, v));
}
Ok(parsed)
}
/// The tables from `sources` (`Recipes::sources`' shape); `root` and
/// `overlay` give the enchantment tags the tables keep.
pub fn from_sources(
root: &Path,
items: &Items,
overlay: &PackOverlay,
sources: Vec<(String, Value)>,
) -> Result<Self> {
let mut crafting = Vec::new();
let mut cooking = Vec::new();
let mut custom: HashMap<String, Vec<CustomRecipe>> = HashMap::new();
let mut meta = HashMap::new();
for (name, v) in sources {
if let Some(kind) = custom_type(&v) {
let id = if name.contains(':') {
name
+6 -237
View File
@@ -201,7 +201,6 @@ impl ItemEntity {
collision: &BlockCollisionRegistry<'_>,
no_physics: bool,
fluids: &crate::motion::FluidResources,
entity_boxes: &[Aabb],
) -> bool {
self.ticks += 1;
self.previous = self.position;
@@ -242,20 +241,10 @@ impl ItemEntity {
// Entity.move with noPhysics: straight through, the ground as it was.
let moved = if no_physics {
wanted
} else if entity_boxes.is_empty() {
} else {
scene
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
.unwrap_or(Vec3::ZERO)
} else {
// `Entity.collide`: the entities that `canBeCollidedWith` the item stop it
// too (a boat, a minecart, a shulker, a happy ghast held still).
crate::collisions::EntityCollisions {
blocks: scene,
entities: entity_boxes,
border: None,
}
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
.unwrap_or(Vec3::ZERO)
};
self.position = Vec3::new(
self.position.x + moved.x,
@@ -416,71 +405,6 @@ impl ItemEntity {
self.pickup_delay != u32::MAX && age < 6000 && self.stack.count < max_stack
}
}
/// The boxes that stop a dropped item (boats, minecarts, shulkers, a held-still happy ghast),
/// binned by 8-block cell, so an item looks only at the ones near it: a farm with many drops and
/// many vehicles checks a handful of boxes per item, not every box in the level.
#[derive(Default)]
pub struct ObstacleGrid {
cells: crate::fast_hash::FxHashMap<(i32, i32, i32), Vec<u32>>,
boxes: Vec<Aabb>,
}
impl ObstacleGrid {
const CELL: f64 = 8.0;
pub fn new(boxes: Vec<Aabb>) -> Self {
let mut cells: crate::fast_hash::FxHashMap<(i32, i32, i32), Vec<u32>> = Default::default();
for (i, b) in boxes.iter().enumerate() {
let (lo, hi) = Self::span(*b);
for x in lo.0..=hi.0 {
for y in lo.1..=hi.1 {
for z in lo.2..=hi.2 {
cells.entry((x, y, z)).or_default().push(i as u32);
}
}
}
}
Self { cells, boxes }
}
fn span(b: Aabb) -> ((i32, i32, i32), (i32, i32, i32)) {
let cell = |v: Vec3| {
(
(v.x / Self::CELL).floor() as i32,
(v.y / Self::CELL).floor() as i32,
(v.z / Self::CELL).floor() as i32,
)
};
(cell(b.min_position()), cell(b.max_position()))
}
pub fn is_empty(&self) -> bool {
self.boxes.is_empty()
}
/// The boxes meeting `area` (`getEntityCollisions`'s query box), in the order they were given.
pub fn near(&self, area: Aabb, out: &mut Vec<Aabb>) {
out.clear();
if self.boxes.is_empty() {
return;
}
let (lo, hi) = Self::span(area);
let mut found: Vec<u32> = Vec::new();
for x in lo.0..=hi.0 {
for y in lo.1..=hi.1 {
for z in lo.2..=hi.2 {
if let Some(list) = self.cells.get(&(x, y, z)) {
found.extend(list);
}
}
}
}
found.sort_unstable();
found.dedup();
out.extend(
found
.into_iter()
.map(|i| self.boxes[i as usize])
.filter(|b| b.intersects(area)),
);
}
}
/// Dropped items by block cell, for `ItemEntity.mergeWithNeighbours`'s
/// search around an item. Holds indexes into the tick's item list.
#[derive(Default)]
@@ -1157,12 +1081,6 @@ pub struct ArmorStand {
/// `ArmorStand.disabledSlots`: bit `filterFlag` locks a slot, bit
/// `filterFlag + 8` takes from it and bit `filterFlag + 16` puts into it.
pub disabled_slots: i32,
/// Server-only `LivingEntity.lastPos` for location-based enchantments.
pub enchantment_block: Option<BlockPos>,
/// Travel accumulated for `LivingEntity.checkFallDamage`'s landing callback.
pub enchantment_fall_distance: f64,
/// Last equipment seen by `collectEquipmentChanges`; not saved to NBT.
pub enchantment_equipment: [Option<ItemStack>; 6],
}
impl ArmorStand {
pub const SLOTS: [&'static str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
@@ -1234,9 +1152,6 @@ impl ArmorStand {
pose: Self::DEFAULT_POSE,
vehicle: None,
disabled_slots: 0,
enchantment_block: None,
enchantment_fall_distance: 0.0,
enchantment_equipment: Default::default(),
}
}
/// `EquipmentSlot.getFilterFlag` of an `equipment` index (main hand, off
@@ -1806,15 +1721,7 @@ mod honey_slide_tests {
item.id = 1;
let mut ys = Vec::new();
for _ in 0..8 {
item.tick(
&world,
assets.registry,
&facts,
&collision,
false,
&fluids,
&[],
);
item.tick(&world, assets.registry, &facts, &collision, false, &fluids);
ys.push(item.velocity.y);
}
// Idle ticks keep gravity; the 4th tick's move hits the floor and
@@ -1870,15 +1777,7 @@ mod honey_slide_tests {
item.id = 1;
let before = item.position.y;
for _ in 0..16 {
item.tick(
&world,
assets.registry,
&facts,
&collision,
false,
&fluids,
&[],
);
item.tick(&world, assets.registry, &facts, &collision, false, &fluids);
}
assert!(
item.position.y > before + 0.5,
@@ -1888,7 +1787,7 @@ mod honey_slide_tests {
);
}
pub(super) fn falling_item(x: f64, y: f64, z: f64, vx: f64, vy: f64, vz: f64) -> ItemEntity {
fn falling_item(x: f64, y: f64, z: f64, vx: f64, vy: f64, vz: f64) -> ItemEntity {
let at = Vec3::new(x, y, z);
ItemEntity {
id: 1,
@@ -2096,15 +1995,7 @@ mod slime_bounce_tests {
falling.on_ground = false;
falling.age = 0;
falling.id = 1;
falling.tick(
&world,
assets.registry,
&facts,
&collision,
false,
&fluids,
&[],
);
falling.tick(&world, assets.registry, &facts, &collision, false, &fluids);
assert_eq!(falling.position.y, 4.0);
assert!(falling.velocity.y > 0.0);
assert!(falling.on_ground);
@@ -2114,15 +2005,7 @@ mod slime_bounce_tests {
// does not set needs_sync.
falling.velocity = Vec3::ZERO;
falling.needs_sync = false;
falling.tick(
&world,
assets.registry,
&facts,
&collision,
false,
&fluids,
&[],
);
falling.tick(&world, assets.registry, &facts, &collision, false, &fluids);
assert!(!falling.needs_sync);
}
}
@@ -2177,117 +2060,3 @@ mod unstuck_tests {
assert_eq!(ExperienceOrb::unstuck(at, &BORDER, shapes(&solid)), None);
}
}
#[cfg(test)]
mod entity_collision_tests {
use super::honey_slide_tests::falling_item;
use super::*;
/// `Entity.collide` stops an item at the entities that `canBeCollidedWith` it: dropped
/// above a boat's box (or a still happy ghast's) it lands on the box's top and rests,
/// where with no box it falls to the floor of the scene.
#[test]
fn an_item_lands_on_an_entity_box_under_it() {
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 render = assets.reload(&root).unwrap();
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let fluids = crate::motion::FluidResources::new(&assets, &facts).unwrap();
let collision = crate::collisions::load(&root, assets.registry).unwrap();
let mut world = World::test_scene(&assets, &render, 3).unwrap();
let stone = assets.registry.default_state("minecraft:stone").unwrap();
world.set(20, 3, 20, stone).unwrap();
let rest = |boxes: &[Aabb]| {
let mut item = falling_item(20.5, 12.0, 20.5, 0.0, 0.0, 0.0);
item.id = 1;
for _ in 0..80 {
item.tick(
&world,
assets.registry,
&facts,
&collision,
false,
&fluids,
boxes,
);
}
item.position.y
};
// The stone's top is at 4.0; a box from 5.0 to 9.0 is in the way.
let platform = Aabb::new(Vec3::new(19.0, 5.0, 19.0), Vec3::new(22.0, 9.0, 22.0));
assert!((rest(&[]) - 4.0).abs() < 1.0e-6, "on the stone with no box");
assert!(
(rest(&[platform]) - 9.0).abs() < 1.0e-6,
"on top of the box, at its top"
);
}
#[test]
fn obstacle_grid_returns_only_the_boxes_near_an_item() {
// A boat every 100 blocks along a line, 1000 of them.
let boat = |x: f64| {
Aabb::new(
Vec3::new(x, 64.0, 0.0),
Vec3::new(x + 1.375, 64.5625, 1.375),
)
};
let grid = ObstacleGrid::new((0..1000).map(|i| boat(f64::from(i) * 100.0)).collect());
let mut out = Vec::new();
// Around the 7th boat: only it.
let area = Aabb::new(Vec3::new(699.0, 63.0, -1.0), Vec3::new(701.0, 66.0, 1.0));
grid.near(area, &mut out);
assert_eq!(out, vec![boat(700.0)]);
// Between two boats: none.
let area = Aabb::new(Vec3::new(750.0, 63.0, 0.0), Vec3::new(751.0, 66.0, 1.0));
grid.near(area, &mut out);
assert!(out.is_empty());
// A box spanning cells is found from any of them, once.
let wide = Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(20.0, 1.0, 1.0));
let grid = ObstacleGrid::new(vec![wide]);
let area = Aabb::new(Vec3::new(17.0, 0.0, 0.0), Vec3::new(18.0, 1.0, 1.0));
grid.near(area, &mut out);
assert_eq!(out, vec![wide]);
}
/// A farm: 5000 drops and 1000 separated vehicles. Run with `-- --ignored --nocapture`.
#[test]
#[ignore = "timing"]
fn obstacle_grid_beats_scanning_every_box_for_a_big_farm() {
let boat = |i: u32| {
let (x, z) = (f64::from(i % 40) * 6.0, f64::from(i / 40) * 6.0);
Aabb::new(
Vec3::new(x, 64.0, z),
Vec3::new(x + 1.375, 64.5625, z + 1.375),
)
};
let boxes: Vec<Aabb> = (0..1000).map(boat).collect();
let items: Vec<Aabb> = (0..5000)
.map(|i| {
let (x, z) = (f64::from(i % 70) * 3.4, f64::from(i / 70) * 3.4);
Aabb::new(Vec3::new(x, 64.0, z), Vec3::new(x + 0.25, 64.25, z + 0.25))
})
.collect();
let start = std::time::Instant::now();
let mut hits = 0;
for item in &items {
let area = item.inflate(Vec3::new(1.0, 1.0, 1.0));
hits += boxes.iter().filter(|b| b.intersects(area)).count();
}
let scan = start.elapsed();
let grid = ObstacleGrid::new(boxes);
let start = std::time::Instant::now();
let (mut out, mut found) = (Vec::new(), 0);
for item in &items {
grid.near(item.inflate(Vec3::new(1.0, 1.0, 1.0)), &mut out);
found += out.len();
}
let binned = start.elapsed();
assert_eq!(hits, found);
println!("scan {scan:?}, grid {binned:?}");
}
}
+3 -27
View File
@@ -246,41 +246,17 @@ mod tests {
needs_sync: false,
};
let mut still = item.clone();
assert!(still.tick(
&world,
assets.registry,
&facts,
&collision,
false,
&fluids,
&[]
));
assert!(still.tick(&world, assets.registry, &facts, &collision, false, &fluids));
assert_eq!(still.position.x, at.x);
assert_eq!(still.velocity.x, 0.0);
world.set(21, 4, 20, flowing).unwrap();
let mut carried = item;
assert!(carried.tick(
&world,
assets.registry,
&facts,
&collision,
false,
&fluids,
&[]
));
assert!(carried.tick(&world, assets.registry, &facts, &collision, false, &fluids));
// First push is damped by buoyancy before motion, second follows drag.
assert!((carried.position.x - at.x - 0.014 * 0.99).abs() < 1e-12);
assert!((carried.velocity.x - (0.014 * 0.99 * 0.98 + 0.014)).abs() < 1e-12);
for _ in 0..20 {
carried.tick(
&world,
assets.registry,
&facts,
&collision,
false,
&fluids,
&[],
);
carried.tick(&world, assets.registry, &facts, &collision, false, &fluids);
}
assert!(carried.position.x > at.x + 0.5);
}
+4 -20
View File
@@ -40,39 +40,23 @@ impl Culler {
start: usize,
opaque: impl FnMut([i32; 3]) -> bool,
) -> bool {
if self.remaining.get() == 0 {
if start >= vertices.len() || self.remaining.get() == 0 {
return false;
}
let Some((min, max)) = Self::bounds(self.camera, vertices, start) else {
return false;
};
self.occluded(min, max, opaque)
}
/// Camera-relative bounds grown to include floating-point and rasterization
/// edges. The asynchronous path sends these small queries to its worker.
pub fn bounds(
camera: [f64; 3],
vertices: &[crate::mesher::Vertex],
start: usize,
) -> Option<([f64; 3], [f64; 3])> {
if start >= vertices.len() {
return None;
}
let mut min = [f64::INFINITY; 3];
let mut max = [f64::NEG_INFINITY; 3];
for vertex in &vertices[start..] {
for a in 0..3 {
let value = f64::from(vertex.pos[a]) + camera[a];
let value = f64::from(vertex.pos[a]) + self.camera[a];
if !value.is_finite() {
return None;
return false;
}
min[a] = min[a].min(value);
max[a] = max[a].max(value);
}
}
// Cover floating-point and rasterization edges as well as the vertices.
Some((min.map(|v| v - 0.05), max.map(|v| v + 0.05)))
self.occluded(min.map(|v| v - 0.05), max.map(|v| v + 0.05), opaque)
}
/// Returns true only when a complete opaque block plane covers the bounds.
+107
View File
@@ -0,0 +1,107 @@
//! Startup shared by the gameplay library and its executable.
use crate::{crash, game, logging};
/// Start the game. The executable supplies the allocator and retains the mod API marker.
pub fn main() {
horizon_threads::configure_allocator();
horizon_client::telemetry::mark_start();
let args: Vec<String> = std::env::args().skip(1).collect();
let game_args = args.iter().take_while(|a| *a != "--dedicated");
let help = game_args
.clone()
.any(|a| a == "--help" || a == "-h" || a == "--launch-api");
// --home before the log opens in it.
if let Some(dir) = game_args.clone().skip_while(|a| *a != "--home").nth(1) {
logging::set_game_home(std::path::absolute(dir).unwrap_or_else(|_| dir.into()));
}
// The dedicated server logs in its own folder (--game-dir's), as vanilla's does; the game in its home.
let (home, side, thread) = if args.iter().any(|a| a == "--dedicated") {
let folder = game_args
.clone()
.skip_while(|a| *a != "--game-dir" && *a != "--gameDir")
.nth(1)
.and_then(|dir| std::path::absolute(dir).ok())
.unwrap_or_else(|| ".".into());
(folder, "server", "Server thread")
} else {
// --player-dir too (a game's, not a server's): a player of a shared game folder keeps
// its log and crash reports in its own; crash telemetry stays with the home's consent.
if let Some(dir) = game_args
.clone()
.skip_while(|a| *a != "--player-dir")
.nth(1)
{
logging::set_player_dir(std::path::absolute(dir).unwrap_or_else(|_| dir.into()));
}
(logging::game_home(), "client", "Render thread")
};
if !help {
logging::init(
logging::player_dir_override().unwrap_or(home.as_path()),
thread,
);
// The log's first line names the build, so a bug report that carries the log says which one it was.
log::info!("Horizon {}", crash::version());
crash::install(home.clone(), side);
}
raise_open_file_limit();
if let Err(error) = game::run() {
// A bad command line is the caller's mistake, not a crash: no report.
let usage = error.is::<game::UsageError>();
log::error!("Horizon: {error}");
logging::flush();
// The report's path is the last line on stderr (crash::SAVED_LINE).
if !help && !usage {
crash::report_error(&home, side, &*error);
}
// Started from Explorer, the console window closes with the game:
// keep the message on screen until it is read.
#[cfg(windows)]
if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
eprintln!("Press Enter to close.");
let _ = std::io::stdin().read_line(&mut String::new());
}
std::process::exit(if usage {
game::exit_code::USAGE
} else {
game::exit_code::ERROR
});
}
logging::flush();
}
/// The JVM raises its open-file limit to the hard limit as it starts
/// (`-XX:+MaxFDLimit`, on by default), so a vanilla server may hold as many
/// sockets and region files as the host allows, not the soft default (often
/// 1024 in a container). The same here; macOS caps it at `OPEN_MAX`, as the
/// JVM does there.
fn raise_open_file_limit() {
#[cfg(unix)]
{
use rustix::process::{Resource, Rlimit, getrlimit, setrlimit};
let limit = getrlimit(Resource::Nofile);
let hard = if cfg!(target_os = "macos") {
Some(limit.maximum.map_or(10_240, |h| h.min(10_240)))
} else {
limit.maximum
};
let raised = match (limit.current, hard) {
(Some(soft), Some(hard)) => hard > soft,
(Some(_), None) => true,
(None, _) => false,
};
if raised {
let to = Rlimit {
current: hard,
maximum: limit.maximum,
};
match setrlimit(Resource::Nofile, to) {
Ok(()) => log::info!(
"Open file limit raised from {:?} to {hard:?}",
limit.current
),
Err(e) => log::warn!("Open file limit stays at {:?}: {e}", limit.current),
}
}
}
}
+63 -196
View File
@@ -190,7 +190,6 @@ mod names;
mod narrator;
mod native_biome_colors;
mod native_block_entity_draw;
mod native_claimed_items;
mod native_components;
mod native_criteria;
mod native_entities;
@@ -238,8 +237,6 @@ mod prediction;
mod pumpkin;
mod quick_actions;
mod raids;
mod ram_limit;
mod random_textures;
mod recipe_book;
mod remote;
mod remote_camera;
@@ -271,7 +268,6 @@ mod shield;
mod shoulders;
mod sign_edit;
mod sniffers;
mod social;
mod spawner;
mod spear;
mod spectator;
@@ -764,8 +760,6 @@ enum Screen {
/// Play with Friends, from the pause menu's Friends button
/// (`friends_screen`, game/friends.rs).
Friends,
/// `SocialInteractionsScreen` (`social_screen`, game/social.rs).
Social,
/// CommandBlockEditScreen (game/command_blocks.rs).
CommandBlock,
/// A game master block's edit screen (game/block_editor.rs).
@@ -802,7 +796,6 @@ impl Screen {
Screen::Advancements => "advancements",
Screen::WorldOptions => "world_options",
Screen::Friends => "friends",
Screen::Social => "social",
Screen::CommandBlock => "command_block",
Screen::BlockEditor => "block_editor",
Screen::ServerLinks => "server_links",
@@ -2205,7 +2198,6 @@ struct Game<'a> {
// Physical input is separate from remapped and toggled movement keys.
physical_keys: HashSet<KeyCode>,
physical_buttons: HashSet<MouseButton>,
drag_button: Option<MouseButton>,
/// Control + left click as a right click (macOS).
right_click_emulation: horizon_client_input::mouse::RightClickEmulation,
left: bool,
@@ -2309,8 +2301,6 @@ struct Game<'a> {
session_lock: Option<horizon_client::save::SessionLock>,
audio: Audio,
music: horizon_client::music::MusicManager,
/// The tracks native mods play over the music (`native_music_layers`).
music_layers: horizon_client::music::MusicLayers,
/// `ToastManager.nowPlayingToast`: there while Music Toast is not Never.
now_playing: Option<horizon_client::toasts::NowPlayingToast>,
/// The Music Toast option the toast was last set up for.
@@ -2628,8 +2618,6 @@ struct Game<'a> {
hoppers: HashMap<(i32, i32, i32), machines::HopperState>,
/// The screen of a mod block's menu open on this client (`native_screen`).
mod_screen: Option<native_hooks::OpenNativeScreen>,
/// The players with each mod container open, by level (`tick_mod_container_viewers`).
mod_container_viewers: HashMap<(&'static str, (i32, i32, i32)), u32>,
/// Where item entities are, while the block entities tick (hoppers).
item_columns: Option<machines::ItemColumns>,
creative_screen: Creative,
@@ -2656,8 +2644,6 @@ struct Game<'a> {
panel: native_panel::Panel,
/// Horizon's own shader pack, while one is picked (`game/native_shaders.rs`).
native_shaders: native_shaders::NativeShaders,
/// The items native client mods claim and the events they haven't polled (game/native_claimed_items.rs).
native_input: native_claimed_items::ModInput,
/// The native lifecycle bookkeeping.
native_lifecycle: native_lifecycle::NativeLifecycle,
/// Native mods' messages waiting (`game/native_net.rs`).
@@ -2669,8 +2655,6 @@ struct Game<'a> {
/// This frame's view frustum, for culling entities and block entities.
view_frustum: Option<horizon_client::gfx::Frustum>,
entity_culler: Option<horizon_client::entity_culling::Culler>,
async_entity_culler: Option<entity_culling::AsyncCuller>,
async_entity_culling_failed: bool,
/// `Renderer::pack_shadow_casters` for this frame's entities: (keep what the culler hides,
/// build the player's model in first person); (false, false) without a shader pack.
pack_shadow_casters: (bool, bool),
@@ -2701,8 +2685,6 @@ struct Game<'a> {
serving: Option<integrated::Serving>,
/// The open world is shared behind an invite code (game/friends.rs).
friends: Option<friends::Sharing>,
/// The Social Interactions screen and the players whose chat is hidden (game/social.rs).
social: social::Social,
world_options: Option<lan_screen::WorldOptions>,
/// Chat Settings opened from RestrictionsScreen: Done returns to World Options.
options_to_world_options: bool,
@@ -2731,8 +2713,6 @@ struct Game<'a> {
debug_options: Option<debug_options::DebugOptionsUi>,
/// `DebugEntryMemory`'s readings (game/debug_lines.rs).
memory_meter: std::cell::RefCell<horizon_client::debug_overlay::MemoryMeter>,
/// How far memory holds the game below the player's settings (`game/ram_limit.rs`).
ram_limit: horizon_client::ram_limit::Governor,
/// `Minecraft.gpuUtilization`: the last timed frame's GPU time over its
/// CPU time, in percent.
gpu_utilization: f64,
@@ -3707,8 +3687,6 @@ impl Game<'_> {
|stack: &ItemStack| catalog.get(&stack.item).map_or(64, |i| i.max_stack).max(1);
// A LAN or server guest only shows the host's items.
let merging = self.remote.is_none();
let obstacles = horizon_client::entities::ObstacleGrid::new(self.item_entity_collisions());
let mut nearby = Vec::new();
for i in 0..items.len() {
if removed[i] {
continue;
@@ -3733,14 +3711,6 @@ impl Game<'_> {
let speed = self.world.random.next_float() * 0.2 + 0.1;
item.move_towards_closest_space(&self.world, self.res.facts(), speed);
}
// `getEntityCollisions(entity, bounds.expandTowards(movement))`: the boxes around
// where the item can reach this tick (a block of slack for the next velocity).
let reach = Vec3::new(
item.velocity.x.abs() + 1.0,
item.velocity.y.abs() + 1.0,
item.velocity.z.abs() + 1.0,
);
obstacles.near(item.bounds().inflate(reach), &mut nearby);
let alive = item.tick(
&self.world,
self.pack.assets.registry,
@@ -3748,7 +3718,6 @@ impl Game<'_> {
self.collision,
no_physics,
&self.res.motion().fluids,
&nearby,
);
cells.moved(i, item.previous, item.position);
// ItemEntity.tick: merge every 2 ticks while moving between
@@ -5607,11 +5576,10 @@ impl Game<'_> {
return Ok(Some(Hand::Main));
}
// An EntityHitResult may fall through to Item.use, never to useItemOn.
let block_hit = match guest {
Some(GuestUse::OnBlock(_)) => self.pick(),
Some(GuestUse::Item(_)) => None,
_ if self.entity_targeted() => None,
_ => self.pick(),
let block_hit = if matches!(guest, Some(GuestUse::Item(_))) || self.entity_targeted() {
None
} else {
self.pick()
};
let hands = match guest {
Some(GuestUse::OnBlock(hand) | GuestUse::Item(hand) | GuestUse::Entity(hand, _)) => {
@@ -5890,9 +5858,7 @@ impl Game<'_> {
self.trigger_default_block_use(hit.pos);
}
}
if result.should_swing() {
self.swing_arm();
}
self.swing_arm();
return Ok(true);
}
}
@@ -5990,7 +5956,9 @@ impl Game<'_> {
return Ok(true);
}
if (!on_block && self.use_potion_item(&stack)?)
|| ((!on_block || (spawn_egg && block_hit.is_some())) && self.throw_item(&stack)?)
|| ((block_hit.is_some() || !spawn_egg)
&& (!on_block || spawn_egg)
&& self.throw_item(&stack)?)
{
return Ok(true);
}
@@ -7406,36 +7374,6 @@ impl Game<'_> {
}
}
}
/// Apply a physical key to every mapping bound to it.
fn mapped_key_event(&mut self, key: KeyCode, pressed: bool, repeat: bool) -> Result<()> {
for mapped in self.settings.keys.translate_all(key) {
match mapped {
Ok(k) => {
self.mapped_key(k, pressed, repeat)?;
}
Err(button) => {
let action = match (pressed, repeat) {
(false, _) => KeyAction::Release,
(_, false) => KeyAction::Press,
(_, true) => KeyAction::Repeat,
};
if let Some(pressed) = keyboard_button(
gameplay_has_keyboard(
self.screen,
self.title.is_some(),
self.dialog_open(),
),
action,
) {
self.play_mouse(button, pressed)?;
} else if container_key_clones(button, action) {
self.inventory_pick_key();
}
}
}
}
Ok(())
}
/// A key event after rebinding, as the default key of its mapping (the
/// keyboard and controllers both end here).
fn mapped_key(&mut self, key: KeyCode, pressed: bool, repeat: bool) -> Result<()> {
@@ -7541,7 +7479,6 @@ impl Game<'_> {
| Screen::Advancements
| Screen::WorldOptions
| Screen::Friends
| Screen::Social
| Screen::ServerLinks
| Screen::ConfirmLink
| Screen::DialogWarning
@@ -8241,7 +8178,6 @@ impl Game<'_> {
Screen::Mod => {}
Screen::WorldOptions => self.draw_world_options(&mut batch),
Screen::Friends => self.draw_friends_screen(&mut batch),
Screen::Social => self.draw_social_screen(&mut batch),
Screen::ServerLinks => self.draw_server_links(&mut batch),
Screen::ConfirmLink => self.draw_confirm_link(&mut batch),
Screen::DialogWarning => self.draw_dialog_warning(&mut batch),
@@ -10508,19 +10444,14 @@ impl Game<'_> {
self.res.block_entity_render().renders(*state)
&& (off_screen(*state) || in_view(p))
});
if self.entity_culler.is_some()
|| self.async_entity_culler.is_some()
|| self.pack_ids.is_some()
{
if self.entity_culler.is_some() || self.pack_ids.is_some() {
for block in visible {
let start = out.len();
self.mark_block_entity(start, block.1);
self.res
.block_entity_render()
.render(&scene, std::iter::once(block), &mut out);
if !off_screen(block.1)
&& (self.entity_culler.is_some() || self.async_entity_culler.is_some())
{
if !off_screen(block.1) && self.entity_culler.is_some() {
self.cull_entity_vertices(&mut out, start);
}
}
@@ -10717,7 +10648,6 @@ impl Game<'_> {
}
}
}
self.finish_async_entity_culling();
out
}
@@ -11534,7 +11464,6 @@ impl Game<'_> {
// TagsUpdatedEvent (SERVER_DATA_LOAD): the world's data is in, before
// its chunks and players are.
self.native_server_data_loaded(false);
self.sync_recipe_content();
self.bosses = Default::default();
reset_world_progress(&mut self.adv, &mut self.stats);
self.prefetch_advancement_tree();
@@ -13409,26 +13338,15 @@ impl Game<'_> {
// LocalPlayer.refreshChatAbilities: the chat shows what the Chat option allows.
let abilities = self.chat_abilities();
self.ui.chat.set_abilities(&self.gui.atlas.font, abilities);
self.streaming.radius = self.render_distance_in_use();
// Options.biomeBlendRadius, ambientOcclusion, cutoutLeaves and the
// leaf-culling mode: LevelExtractor.allChanged when they change.
let leaf_culling = self.effective_leaf_culling();
self.streaming.radius = self.settings.render_distance;
// 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,
leaf_culling,
) != (
st.biome_blend,
st.smooth_lighting,
st.cutout_leaves,
st.leaf_culling,
);
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.leaf_culling = leaf_culling;
if remesh {
self.all_sections_changed();
}
@@ -13881,8 +13799,6 @@ impl Game<'_> {
}
lines
}
/// `CreativeModeInventoryScreen.extractRenderState`: item slots share the
/// container screen's hover target for their highlight and tooltip.
fn draw_creative(&mut self, batch: &mut HudBatch) {
// CreativeModeInventoryScreen: `CommonComponents.EMPTY` for a title.
screen_info::begin_container("creative");
@@ -13978,9 +13894,6 @@ impl Game<'_> {
}
// The hovered stack, and whether it is a grid slot (`CustomCreativeSlot`).
let mut hovered: Option<(ItemStack, bool)> = None;
// Use the same padded slot bounds as clicks and the other menus, so
// highlights and tooltips remain visible between adjacent items.
let hovered_slot = self.slot_at(batch, self.cursor.0, self.cursor.1);
if tab.inventory {
self.draw_player_preview(
batch,
@@ -14011,7 +13924,10 @@ impl Game<'_> {
continue;
};
let (x, y) = (ox + 9.0 + c as f32 * 18.0, oy + 18.0 + r as f32 * 18.0);
let over = hovered_slot == Some(Creative::DISPLAY + r * 9 + c);
let over = self.cursor.0 >= x
&& self.cursor.0 < x + 16.0
&& self.cursor.1 >= y
&& self.cursor.1 < y + 16.0;
if over {
self.draw_slot_highlight(batch, false, x, y);
}
@@ -14025,7 +13941,10 @@ impl Game<'_> {
let drag = self.drag_view();
for c in 0..9 {
let (x, y) = (ox + 9.0 + c as f32 * 18.0, oy + 112.0);
let over = hovered_slot == Some(c);
let over = self.cursor.0 >= x
&& self.cursor.0 < x + 16.0
&& self.cursor.1 >= y
&& self.cursor.1 < y + 16.0;
if over {
self.draw_slot_highlight(batch, false, x, y);
}
@@ -15324,7 +15243,6 @@ impl Game<'_> {
}
}
Screen::Stats | Screen::Friends => self.screen = Screen::Paused,
Screen::Social => self.close_social(),
Screen::ServerLinks => self.screen = Screen::Paused,
// ConfirmScreen.keyPressed: Escape answers no; both return to
// the Server Links screen they came from.
@@ -15367,11 +15285,6 @@ impl Game<'_> {
// an active use in the same tick.
self.release_use_for_keybind_screen();
}
// key.socialInteractions.
KeyCode::KeyP if self.screen == Screen::Playing => {
self.open_social(false);
self.release_use_for_keybind_screen();
}
KeyCode::KeyL if self.screen == Screen::Advancements => self.close_advancements(),
// key.quickActions: only open if a datapack provides dialog actions.
KeyCode::KeyG if self.screen == Screen::Playing => {
@@ -15564,13 +15477,9 @@ impl Game<'_> {
fn far_terrain_choice(&self) -> (u32, bool) {
let g = &self.settings.horizon;
let reduced = g.far_detail == horizon_client::graphics::FarDetail::Reduced;
let distance = if self.ram_limit.far_off() {
0
} else {
match self.server_far_terrain() {
Some(offer) => g.lod_distance.min(offer.distance),
None => g.lod_distance,
}
let distance = match self.server_far_terrain() {
Some(offer) => g.lod_distance.min(offer.distance),
None => g.lod_distance,
};
(distance, reduced)
}
@@ -15910,21 +15819,7 @@ impl ApplicationHandler for Game<'_> {
self.previous = Instant::now();
}
fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, event: WindowEvent) {
let key = if self.window.as_ref().is_some_and(|w| w.id() == id)
&& let WindowEvent::KeyboardInput {
event,
is_synthetic: false,
..
} = &event
{
Some((event.clone(), glfw_modifiers(self.modifiers)))
} else {
None
};
self.handle_window_event(event_loop, id, event);
if let Some((event, modifiers)) = key {
self.native_input_key(&event, modifiers);
}
}
fn device_event(&mut self, _: &ActiveEventLoop, _: DeviceId, event: DeviceEvent) {
// The real mouse never steers an offscreen game: its pointer is not grabbed.
@@ -16027,7 +15922,6 @@ impl Game<'_> {
if let WindowEvent::Focused(false) = &event {
self.physical_keys.clear();
self.physical_buttons.clear();
self.drag_button = None;
}
if let WindowEvent::ModifiersChanged(m) = &event {
self.modifiers = m.state();
@@ -16294,21 +16188,6 @@ impl Game<'_> {
return;
}
let result: Result<()> = (|| {
if let WindowEvent::MouseInput { state, button, .. } = &event
&& !self.pad_synthetic_input()
{
self.track_drag_button(*button, *state == ElementState::Pressed);
}
if let WindowEvent::KeyboardInput { event, .. } = &event
&& event.state == ElementState::Released
&& let PhysicalKey::Code(key) = event.physical_key
&& self.screen_key_released(key)
{
// Screen cancellation must not leave a held keyboard mapping down.
self.keys.remove(&key);
self.mapped_key_event(key, false, false)?;
return Ok(());
}
// ScreenEvent.MouseButtonPressed.Pre / MouseButtonReleased.Pre:
// the open screen hears of the press before anything reads it, and
// a cancelled one goes no further — no hotbar key, no slot, no
@@ -16401,11 +16280,7 @@ impl Game<'_> {
WindowEvent::CursorMoved { position, .. } => {
if let Some(r) = &self.renderer {
let b = HudBatch::new([r.config.width, r.config.height]);
let previous = self.cursor;
self.cursor = (position.x as f32 / b.scale, position.y as f32 / b.scale);
if self.screen_mouse_dragged(previous) {
return Ok(());
}
if self.dialog_open() {
self.dialog_drag();
}
@@ -16505,28 +16380,10 @@ impl Game<'_> {
&& self.sleeping.is_none()
&& self.player.is_some()
&& !self.native_wheel_event(d)
&& let Some((wheel, (horizontal_steps, vertical_steps))) = self
.mouse
.on_scroll(raw_x, raw_y, discrete, wheel_sensitivity)
&& let Some((wheel, (_, vertical_steps))) =
self.mouse
.on_scroll(raw_x, raw_y, discrete, wheel_sensitivity)
{
if !self.pad_synthetic_input() {
let mut canceled = false;
self.native_mouse_scrolling(
[scaled(raw_x), scaled(raw_y)],
[horizontal_steps, vertical_steps],
[
self.physical_buttons.contains(&MouseButton::Left),
self.physical_buttons.contains(&MouseButton::Middle),
self.physical_buttons.contains(&MouseButton::Right),
],
[self.cursor.0, self.cursor.1],
glfw_modifiers(self.modifiers),
&mut canceled,
);
if canceled {
return Ok(());
}
}
if self.spectator() && self.spectator_gui.is_menu_active() {
self.spectator_scroll(-wheel);
} else if self.spectator() {
@@ -16548,9 +16405,6 @@ impl Game<'_> {
if self.screen == Screen::Chat && d != 0.0 {
self.chat_scroll(d);
}
if self.screen == Screen::Social && d != 0.0 {
self.social_scroll(d);
}
if self.screen == Screen::CommandBlock && d != 0.0 {
self.command_block_scroll(d);
}
@@ -16800,9 +16654,6 @@ impl Game<'_> {
Screen::Friends if pressed && button == MouseButton::Left => {
self.friends_screen_click()
}
Screen::Social if pressed && button == MouseButton::Left => {
self.social_click()
}
Screen::CommandBlock if pressed && button == MouseButton::Left => {
self.command_block_click()
}
@@ -16960,9 +16811,6 @@ impl Game<'_> {
WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::Win => {
self.win_key(&event)?
}
WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::Social => {
self.social_key(&event)
}
WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::CommandBlock => {
self.command_block_key(&event)
}
@@ -17103,7 +16951,38 @@ impl Game<'_> {
{
self.smooth_camera = !self.smooth_camera;
}
self.mapped_key_event(key, event.state == ElementState::Pressed, event.repeat)?;
// Key binds: act as the default key of every mapping bound
// to it (KeyMapping.set drives them all).
for mapped in self.settings.keys.translate_all(key) {
match mapped {
Ok(k) => {
self.mapped_key(
k,
event.state == ElementState::Pressed,
event.repeat,
)?;
}
Err(button) => {
let action = match (event.state, event.repeat) {
(ElementState::Released, _) => KeyAction::Release,
(_, false) => KeyAction::Press,
(_, true) => KeyAction::Repeat,
};
if let Some(pressed) = keyboard_button(
gameplay_has_keyboard(
self.screen,
self.title.is_some(),
self.dialog_open(),
),
action,
) {
self.play_mouse(button, pressed)?;
} else if container_key_clones(button, action) {
self.inventory_pick_key();
}
}
}
}
}
WindowEvent::RedrawRequested => {
self.observe_fullscreen();
@@ -17234,13 +17113,12 @@ impl Game<'_> {
self.frame_stats.record_gpu(&r.gpu_times);
r.gpu_times.clear();
}
horizon_client::ram_limit::frame();
self.limit_frame();
self.pause = self.pauses_game();
if let Some(server) = &self.integrated {
server.set_paused(self.pause);
server.set_distances(
self.render_distance_in_use(),
self.settings.render_distance,
self.settings.simulation_distance,
self.settings.entity_distance,
);
@@ -17543,7 +17421,6 @@ impl<'a> Game<'a> {
keys: HashSet::new(),
physical_keys: HashSet::new(),
physical_buttons: HashSet::new(),
drag_button: None,
right_click_emulation: Default::default(),
left: false,
right: false,
@@ -17609,7 +17486,6 @@ impl<'a> Game<'a> {
);
music
},
music_layers: Default::default(),
// ToastManager.initializeMusicToast: Pause and Pause and Toast make the toast.
now_playing: (settings.file.int("musicToast").clamp(0, 2) != 0).then(Default::default),
music_toast_state: settings.file.int("musicToast").clamp(0, 2) as u8,
@@ -17798,7 +17674,6 @@ impl<'a> Game<'a> {
tnts: Vec::new(),
hoppers: HashMap::new(),
mod_screen: None,
mod_container_viewers: HashMap::new(),
item_columns: None,
creative_screen: prepared.creative_screen,
control: None,
@@ -17814,8 +17689,6 @@ impl<'a> Game<'a> {
agent: agent::Agent::default(),
view_frustum: None,
entity_culler: None,
async_entity_culler: None,
async_entity_culling_failed: false,
pack_shadow_casters: (false, false),
pending_uploads: Vec::new(),
frame_graph: Default::default(),
@@ -17824,7 +17697,6 @@ impl<'a> Game<'a> {
recipe_tables: Default::default(),
panel: Default::default(),
native_shaders: Default::default(),
native_input: Default::default(),
native_lifecycle: Default::default(),
native_net: Default::default(),
native_mod_screen: None,
@@ -17847,7 +17719,6 @@ impl<'a> Game<'a> {
test_highlights: HashMap::new(),
game_tests: game_tests::GameTests::default(),
friends: None,
social: Default::default(),
debug_keys: Default::default(),
debug_charts: Default::default(),
debug_entries: horizon_client::debug_entries::DebugEntries::load(
@@ -17855,7 +17726,6 @@ impl<'a> Game<'a> {
),
debug_options: None,
memory_meter: Default::default(),
ram_limit: Default::default(),
gpu_utilization: 0.0,
rendered_mobs: Default::default(),
spawn_counts: HashMap::new(),
@@ -18213,7 +18083,7 @@ impl Game<'_> {
fn recipes(&self) -> &Recipes {
match &self.world_tables {
Some(t) => &t.recipes,
None => self.synced_recipes().unwrap_or_else(|| self.res.recipes()),
None => self.res.recipes(),
}
}
fn stations(&self) -> &horizon_client::stations::StationData {
@@ -18286,8 +18156,6 @@ impl Game<'_> {
}
self.world_tables = self.build_world_tables();
horizon_client::structure_blocks::forget_loot_data();
// Before the players are sent the reloaded data below.
self.sync_recipe_content();
// PlayerList.reloadResources: the tags and recipes go to the players
// and their advancements reload. The synchronized registries (the
// dialogs, and the commands' ids of them) stay the hub's.
@@ -20257,7 +20125,6 @@ mod tests {
Screen::Advancements,
Screen::WorldOptions,
Screen::Friends,
Screen::Social,
Screen::CommandBlock,
Screen::BlockEditor,
Screen::ServerLinks,
@@ -2006,7 +2006,7 @@ impl Game<'_> {
criteria.push(adv::Criterion {
name: name.clone(),
trigger: String::new(),
conditions: Value::Null.into(),
conditions: Value::Null,
});
}
}
-1
View File
@@ -417,7 +417,6 @@ impl Game<'_> {
Screen::Advancements => "advancements",
Screen::WorldOptions => "world-options",
Screen::Friends => "friends",
Screen::Social => "social",
Screen::ServerLinks => "server-links",
Screen::ConfirmLink => "confirm-link",
Screen::DialogWarning => "dialog-warning",
+9 -26
View File
@@ -727,20 +727,20 @@ impl Game<'_> {
true
}
pub(super) fn tick_armor_stands(&mut self) {
let mut scene = horizon_client::collisions::SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
let mut dead = Vec::new();
// ServerLevel.tick: only entities in entity-ticking chunks tick.
let ticking = self.entity_ticking_area();
for i in 0..self.armor_stands.len() {
let at = self.armor_stands[i].position;
if !ticking(at.x.floor() as i32, at.z.floor() as i32) {
self.armor_stands[i].previous = at;
self.armor_stands[i].previous_yaw = self.armor_stands[i].yaw;
continue;
}
self.armor_stand_location_effects(i, false);
let a = &mut self.armor_stands[i];
for (i, a) in self.armor_stands.iter_mut().enumerate() {
a.previous = a.position;
a.previous_yaw = a.yaw;
if !ticking(a.position.x.floor() as i32, a.position.z.floor() as i32) {
continue;
}
let in_water = a.update_fluids(&self.world, &self.res.motion().fluids);
let (world, registry, facts, collision) = (
&self.world,
@@ -748,31 +748,14 @@ impl Game<'_> {
self.res.facts(),
self.collision,
);
let mut scene = horizon_client::collisions::SceneCollisions {
world,
binding: collision,
subject: None,
};
a.tick(&mut scene, facts, in_water, &mut |bounds, velocity| {
horizon_client::motion::bubble_column_push(
world, registry, facts, collision, bounds, velocity,
);
});
if in_water {
a.enchantment_fall_distance = 0.0;
} else {
a.enchantment_fall_distance += (a.previous.y - a.position.y).max(0.0);
}
let landed = a.on_ground && a.enchantment_fall_distance > 0.0;
if a.on_ground {
a.enchantment_fall_distance = 0.0;
}
if !a.environment_tick(&self.world, self.res.facts(), self.pack.assets.registry) {
dead.push(i);
}
if landed {
self.armor_stand_location_effects(i, true);
}
}
for i in dead.into_iter().rev() {
self.break_armor_stand_by_anything(i);
+14 -23
View File
@@ -57,8 +57,8 @@ pub(super) struct Boat {
above_bubble_column: bool,
/// `AbstractBoat.bubbleColumnDirectionIsDown`.
bubble_down: bool,
/// The server asked a rider whose client moves it (a player) to get out:
/// `guest_vehicle_tick` honors it (`Entity.ejectPassengers`).
/// A bubble column's drag asked a rider the server can't move itself (a
/// player) to get out: `guest_vehicle_tick` takes it (`ejectPassengers`).
pub eject_riders: bool,
/// `Entity.tickCount`, counted where the wobble is (a client's tick).
tick_count: i32,
@@ -101,17 +101,6 @@ pub(super) const BUBBLE_UP_EVENT: i8 = 72;
pub(super) const WIDTH: f64 = 1.375;
pub(super) const HEIGHT: f64 = 0.5625;
impl Boat {
/// `AbstractBoat.tick`: passengers lose control after 60 consecutive
/// underwater ticks; any other status resets the counter.
fn tick_submersion(&mut self) {
self.out_of_control =
if matches!(self.status, Status::UnderWater | Status::UnderFlowingWater) {
self.out_of_control + 1.0
} else {
0.0
};
}
pub fn new(id: u32, wood: Arc<str>, position: Vec3, yaw: f32) -> Self {
Self {
id,
@@ -570,7 +559,11 @@ impl Game<'_> {
}
b.old_status = b.status;
b.status = self.boat_status(&mut b);
b.tick_submersion();
b.out_of_control = if matches!(b.status, Status::UnderWater | Status::UnderFlowingWater) {
b.out_of_control + 1.0
} else {
0.0
};
b.hurt_time = (b.hurt_time - 1).max(0);
b.damage = (b.damage - 1.0).max(0.0);
if !controlled {
@@ -758,13 +751,8 @@ impl Game<'_> {
if !ticking(p.x.floor() as i32, p.z.floor() as i32) {
continue;
}
// `AbstractBoat.tick` counts submersion on the server even when
// its controlling client owns the boat's movement.
if driven.contains(&self.boats[i].id) {
self.refresh_boat_status(i);
}
if self.boats[i].out_of_control >= 60.0 {
self.eject_boat_riders(i);
self.eject_boat_mobs(i, false);
}
self.boat_bubble_tick(i);
self.boat_contacts(i);
@@ -788,7 +776,6 @@ impl Game<'_> {
let mut probe = Boat::new(b.id, b.wood.clone(), b.position, b.yaw);
let status = self.boat_status(&mut probe);
self.boats[i].status = status;
self.boats[i].tick_submersion();
}
/// `BubbleColumnBlock.entityInside`: the bubble columns boat `i` touches
/// (its box deflated by 1e-5 as `Entity.checkInsideBlocks` does), in x, y,
@@ -895,8 +882,8 @@ impl Game<'_> {
v.y = 0.6;
}
}
/// `Entity.ejectPassengers`: the mobs get out here, this window's player
/// gets out, and a server's player is asked to
/// `Entity.ejectPassengers`: for a down column the mobs get out here, this
/// window's player gets out, and a server's player is asked to
/// (`guest_vehicle_tick` lets it out through its client).
fn eject_boat_riders(&mut self, i: usize) {
self.eject_boat_mobs(i, false);
@@ -931,6 +918,10 @@ impl Game<'_> {
/// window is its own client.
fn boat_bubble_tick(&mut self, i: usize) {
let id = self.boats[i].id;
// The boat a server's player drives is not ticked here: its status is.
if self.guest_vehicles().contains(&id) {
self.refresh_boat_status(i);
}
// An ejection nobody took (its player left the boat first) is void.
if !self.guest_rides(id) {
self.boats[i].eject_riders = false;
+3 -19
View File
@@ -63,8 +63,6 @@ impl Game<'_> {
) -> horizon_client::command::Dispatcher {
let mut names = Vec::new();
self.native_commands(&mut names);
// The window answers this one itself, as it does a client mod's.
names.push(horizon_client::ram_limit::COMMAND);
let base = match base {
Some(tree) => tree,
None => horizon_client::command::vanilla::game_dispatcher(),
@@ -1094,12 +1092,9 @@ impl Game<'_> {
if self.ui.hide_gui && !focused {
return;
}
self.ui.chat.smooth = self.settings.extras.chat_animation;
self.ui.chat.hide_indicator = !self.settings.extras.chat_indicator;
// The fractional tick makes the smooth chat extra's slide move every
// frame; vanilla's fade reads it too.
let now = self.ticks as f32 + self.clock.fraction() as f32;
self.ui.chat.draw(batch, &self.gui.atlas, now, focused);
self.ui
.chat
.draw(batch, &self.gui.atlas, self.ticks, focused);
}
/// `HoverEvent.EntityTooltipInfo.getTooltipLines`: the name when the
/// event has one, the entity type and the UUID.
@@ -1560,17 +1555,6 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_ram_limit_command_is_known_to_the_chat_box() {
let reg = Registries::default();
let vanilla = horizon_client::command::vanilla::game_dispatcher();
assert!(vanilla.find(horizon_client::ram_limit::COMMAND).is_none());
let tree = with_native_commands(vanilla, &[horizon_client::ram_limit::COMMAND]);
for typed in ["ramlimit", "ramlimit 2048", "ramlimit off"] {
assert!(tree.parse(typed, &reg).executable().is_ok(), "{typed}");
}
}
/// A native mod's command is in the tree the chat box uses (#456): known
/// rather than red, and offered as it types. With no mod claiming one the
/// tree is the game's own, as it was before the mods were asked.
@@ -183,10 +183,6 @@ impl Game<'_> {
self.mod_browser_text(None, true);
return Ok(());
}
Screen::Social => {
self.social_backspace();
return Ok(());
}
_ => {}
}
}
@@ -209,7 +205,6 @@ impl Game<'_> {
match self.screen {
Screen::Chat => self.chat_type(text),
Screen::CommandBlock => self.command_block_type(text),
Screen::Social => self.social_type(text),
Screen::BlockEditor => self.block_editor_type(text),
Screen::DebugOptions => self.debug_options_type(text),
Screen::Options => self.ui.options.type_text(&self.gui.atlas, text),
@@ -1057,7 +1052,6 @@ impl Game<'_> {
Screen::Advancements => "advancements",
Screen::WorldOptions => "world-options",
Screen::Friends => "friends",
Screen::Social => "social",
Screen::ServerLinks => "server-links",
Screen::ConfirmLink => "confirm-link",
Screen::DialogWarning => "dialog-warning",
+13 -118
View File
@@ -771,7 +771,6 @@ impl Game<'_> {
thundering: weather && thunder > 0.9,
nether: self.dim == Dim::Nether,
block_tags: &self.items().block_tags,
landing: &self.motion.landing,
biomes: world_biomes!(self),
mob_griefing: self.ui.rules.bool("mob_griefing"),
universal_anger: self.ui.rules.bool("universal_anger"),
@@ -1775,40 +1774,6 @@ impl Game<'_> {
self.mob_hits_player(amount, from, kind);
}
MobEvent::ElderCurse { at } => self.elder_curse(at),
MobEvent::SetVelocity { target, velocity } => {
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
match target {
Who::Player => {
if let Some(p) = self.player.as_mut() {
p.velocity = v;
}
}
Who::Mob(id) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.velocity = v;
}
}
}
}
MobEvent::RemoveKilled { target } => {
// The bite that killed it ran `LivingEntity.die` (loot, such
// as a magma cube's froglight, and experience) before
// `eatEntity` removes it: a removed mob never reaches the
// next tick's deaths, so it dies here first.
if let Some(m) = self
.mobs
.iter()
.find(|m| m.id == target && !m.alive() && !m.removed)
.cloned()
{
if self.died.insert(target) {
self.mob_died(&m);
}
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == target) {
m.removed = true;
}
}
}
MobEvent::Push { target, velocity } => {
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
match target {
@@ -5765,29 +5730,13 @@ impl Game<'_> {
);
let (species, variant, color) =
(parent.species(), parent.extra.variant, parent.extra.color);
let mob_type = parent.kind.clone();
let target = self.mob_entity(&self.mobs[index]);
let id = if species == Species::Piglin {
// SpawnEggItem.spawnOffspringFromSpawnEgg: a non-AgeableMob is
// constructed, then setBaby(true), without finalizeSpawn. Giving
// it adult spawn gear first leaves the baby holding a weapon.
self.next_entity += 1;
let id = self.next_entity;
self.mobs
.push(Mob::new(id, mob_type, Vec3::new(at[0], at[1], at[2]), 0.0));
id
} else {
let Some(id) = self.spawn_mob_spawned(&kind, at) else {
return false;
};
id
let Some(id) = self.spawn_mob_spawned(&kind, at) else {
return false;
};
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::mobs::finalize::spawn_egg_offspring(child, species, variant, color);
}
if species == Species::Piglin {
self.native_mobs_joined(self.mobs.len() - 1, false);
}
// `Mob.checkAndHandleImportantInteractions` then calls
// `onOffspringSpawnedFromEgg`: `Fox`'s makes the baby trust the player
// who used the egg (a fox egg used on a block trusts nobody).
@@ -5801,49 +5750,17 @@ impl Game<'_> {
self.consume_held();
true
}
/// `SpawnEggItem.use`: the SOURCE_ONLY ray must hit a liquid block.
pub(super) fn spawn_egg_fluid_hit(&self) -> Option<super::Hit> {
let hit = self.bucket_hit()?;
matches!(
&*self.block_name(hit.pos),
"minecraft:water" | "minecraft:lava"
)
.then_some(hit)
}
/// Shared peaceful-mode eligibility for spawn eggs.
pub(super) fn spawn_egg_allowed(&self, item: &str) -> bool {
let kind = item.trim_end_matches("_spawn_egg");
self.ui.difficulty != 0
|| self
.res
.mob_types()
.get(kind)
.is_none_or(|t| horizon_client::mobs::spawn_rules::allowed_in_peaceful(t.species))
}
/// `SpawnEggItem.useOn` for a block, or `use` for a fluid source.
fn use_spawn_egg(&mut self, stack: &ItemStack) -> Result<bool> {
let fluid = matches!(self.guest_use, Some(super::GuestUse::Item(_)));
let Some(hit) = (if fluid {
self.spawn_egg_fluid_hit()
} else {
self.pick()
}) else {
let Some(hit) = self.pick() else {
return Ok(false);
};
let kind = stack.item.trim_end_matches("_spawn_egg").to_owned();
if !self.spawn_egg_allowed(&stack.item) {
self.use_failed = true;
return Ok(true);
}
if fluid
&& !self.may_build()
&& !self.can_place_on_in_adventure([
hit.pos[0] - hit.face[0],
hit.pos[1] - hit.face[1],
hit.pos[2] - hit.face[2],
])
// EntityType.canSpawn: no monsters in peaceful (FAIL ends the click).
let id = format!("minecraft:{}", kind.trim_start_matches("minecraft:"));
if self.ui.difficulty == 0
&& let Some(t) = self.res.mob_types().get(&id)
&& !horizon_client::mobs::spawn_rules::allowed_in_peaceful(t.species)
{
self.use_failed = true;
return Ok(true);
}
// SpawnEggItem.useOn on a spawner sets its mob (and an op's egg data).
@@ -5859,15 +5776,11 @@ impl Game<'_> {
}
return Ok(true);
}
let p = if fluid {
hit.pos
} else {
[
hit.pos[0] + hit.face[0],
hit.pos[1] + hit.face[1],
hit.pos[2] + hit.face[2],
]
};
let p = [
hit.pos[0] + hit.face[0],
hit.pos[1] + hit.face[1],
hit.pos[2] + hit.face[2],
];
let at = [
f64::from(p[0]) + 0.5,
f64::from(p[1]),
@@ -5882,8 +5795,6 @@ impl Game<'_> {
horizon_client::entity_nbt::apply_item_components(m, stack);
}
self.apply_spawn_egg_entity_data(id, stack);
self.post_block_game_event("minecraft:entity_place", p, Some(Who::Player), None);
self.stats.add("used", &stack.item, 1);
self.swing_arm();
if !self.creative
&& let Some(s) = self.held_mut().as_mut()
@@ -5998,21 +5909,6 @@ impl Game<'_> {
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
let scale = self.view_scale();
self.rendered_mobs.set(0);
// What custom entity model animations read of the frame.
horizon_client::mobs::cem_anim::set_frame(horizon_client::mobs::cem_anim::Frame {
time: self.world.game_time as f64 + alpha,
day_time: self.world.day_time as f64 + alpha,
dimension: match self.dim {
Dim::Overworld => 0.0,
Dim::Nether => -1.0,
Dim::End => 1.0,
},
player: self.player.as_ref().map_or(camera.to_array(), |p| {
[p.position.x, p.position.y, p.position.z]
}),
player_rot: [self.pitch, self.yaw],
..Default::default()
});
for mob in &self.mobs {
let entity_start = out.len();
self.mark_motion(entity_start, (1 << 32) | u64::from(mob.id));
@@ -6897,7 +6793,6 @@ mod pearl_tests {
thundering: false,
nether: false,
block_tags: &items.block_tags,
landing: &motion.landing,
biomes: &biomes,
mob_griefing: true,
universal_anger: false,
+10 -106
View File
@@ -990,8 +990,6 @@ impl Game<'_> {
let pause_after = props.pause_when_empty_seconds * 20;
let idle_timeout = props.player_idle_timeout;
let mut next = Instant::now();
// The panic that ended the tick loop, when one did.
let mut crashed: Option<String> = None;
self.fns.permission = Some(props.function_permission_level.clamp(0, 4) as u8);
let watchdog = Watchdog::start(props.max_tick_time);
let mut braked = Instant::now();
@@ -1000,18 +998,10 @@ impl Game<'_> {
if let Some(watchdog) = &watchdog {
watchdog.tick_due(next, self.clock.tick_duration());
}
// The console's lines and the control socket run in the tick too
// (`handleConsoleInputs`): a panic there is the same crash.
let prelude = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
while let Ok(line) = state.console.try_recv() {
self.console_line(&mut state, &line);
}
self.poll_server_control(&mut state);
}));
if let Err(payload) = prelude {
crashed = Some(panic_message(payload.as_ref()));
break;
while let Ok(line) = state.console.try_recv() {
self.console_line(&mut state, &line);
}
self.poll_server_control(&mut state);
let players = self.lan.as_ref().map_or(0, |h| h.hub.guests.len());
if pause_after > 0 {
// A `/pregen` task keeps the server busy as a player would:
@@ -1039,23 +1029,11 @@ impl Game<'_> {
self.ticks = self.ticks.max(1);
}
// MinecraftServer.tickServer: while paused only the connections run.
// `MinecraftServer.runServer` catches what the tick throws, writes the crash
// report and still stops the server (saves). A panic here does the same.
let ticked =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
match &mut state.report {
Some(recording) => self.tick_server_spans(paused, |wall, cpu, spans| {
recording.add(wall, cpu, spans)
}),
None => self.tick_server_timed(paused),
}
}));
match ticked {
Ok(result) => result?,
Err(payload) => {
crashed = Some(panic_message(payload.as_ref()));
break;
}
match &mut state.report {
Some(recording) => self.tick_server_spans(paused, |wall, cpu, spans| {
recording.add(wall, cpu, spans)
})?,
None => self.tick_server_timed(paused)?,
}
if state.report.as_ref().is_some_and(report::Recording::done)
&& let Some(recording) = state.report.take()
@@ -1089,10 +1067,7 @@ impl Game<'_> {
if let Some(watchdog) = &watchdog {
watchdog.running.store(false, Ordering::Relaxed);
}
match crashed {
Some(message) => self.stop_after_crash(&message),
None => self.stop_dedicated(),
}
self.stop_dedicated()
}
/// A dedicated server of its own, not single player's server thread
@@ -1262,7 +1237,6 @@ impl Game<'_> {
self.console_command(state, line);
self.console_broadcast = None;
if let Some(mut host) = self.lan.take() {
crash_fixture("hub", || host.hub.guests.iter().any(|g| g.placed));
self.run_level_teleports(&mut host);
self.send_admin_feedback(&mut host, None);
self.lan = Some(host);
@@ -1844,41 +1818,6 @@ impl Game<'_> {
}
}
/// `MinecraftServer.runServer`'s `finally`: a tick that panicked still saves. What the panic
/// interrupted is not trusted: a guest's turn (`as_guest`) holds the game's player state
/// swapped in, so its record is older than what the game holds, and writing it would put
/// back older data than the last autosave; that guest keeps the file it has. A hub that the
/// panic took with it (`self.lan.take()` was in progress) saves no players at all. The
/// levels, the scoreboard and the other state are saved as in a normal stop.
fn stop_after_crash(&mut self, message: &str) -> Result<()> {
log("ERROR", &format!("The server thread panicked: {message}"));
let acting = self.lan_acting;
let hub_lost = self.lan.is_none();
if let (Some(conn), Some(host)) = (acting, self.lan.as_mut())
&& let Some(guest) = host.hub.guests.iter_mut().find(|g| g.conn == conn)
{
guest.placed = false;
}
if hub_lost {
log(
"WARN",
"The panic interrupted the network tick: players are not saved, only the world",
);
} else if acting.is_some() {
log(
"WARN",
"The panic interrupted a player's turn: that player keeps the last saved file",
);
}
self.native_lifecycle.begin_close();
log("INFO", "Saving worlds");
self.save_game()?;
self.log_saved_levels();
self.native_lifecycle_world_closed();
self.lan_close(false);
Err(format!("the server thread panicked: {message}").into())
}
fn stop_dedicated(&mut self) -> Result<()> {
// The close request starts before player and world saves.
self.native_lifecycle.begin_close();
@@ -1916,28 +1855,6 @@ impl Game<'_> {
}
}
/// `tools/test_dedicated_crash_save.py`'s fixture: with `HORIZON_CRASH_FIXTURE` naming `point`
/// (`guest`: during a placed guest's turn, `hub`: while a console line has the hub out and a
/// player is in), the tick panics there, to see what `stop_after_crash` keeps.
/// `armed` is asked only when the variable names `point`, so the tick pays one
/// load of a `OnceLock` without it.
pub(super) fn crash_fixture(point: &str, armed: impl FnOnce() -> bool) {
static FIXTURE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
let fixture = FIXTURE.get_or_init(|| std::env::var("HORIZON_CRASH_FIXTURE").ok());
if fixture.as_deref() == Some(point) && armed() {
panic!("crash fixture at {point}");
}
}
/// The text of a panic payload (`panic!("..")` is a `&str`, a formatted one a `String`).
fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
payload
.downcast_ref::<&str>()
.map(|s| (*s).to_owned())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "unknown panic".to_owned())
}
/// The width and height of a PNG (its IHDR chunk).
fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
if bytes.len() < 24 || &bytes[..8] != b"\x89PNG\r\n\x1a\n" || &bytes[12..16] != b"IHDR" {
@@ -1950,7 +1867,7 @@ fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
#[cfg(test)]
mod tests {
use super::{capture_log, log, log_from, panic_message};
use super::{capture_log, log, log_from};
use std::{
fs,
io::{self, BufRead, BufReader, Read, Write},
@@ -1958,19 +1875,6 @@ mod tests {
time::{SystemTime, UNIX_EPOCH},
};
/// A `panic!("literal")` carries a `&str`, a formatted one a `String`; anything else is
/// named as unknown, so the crash line is never empty.
#[test]
fn a_panic_payload_is_read_as_text() {
let literal = std::panic::catch_unwind(|| panic!("a literal")).unwrap_err();
assert_eq!(panic_message(literal.as_ref()), "a literal");
let n = 3;
let formatted = std::panic::catch_unwind(|| panic!("tick {n}")).unwrap_err();
assert_eq!(panic_message(formatted.as_ref()), "tick 3");
let other = std::panic::catch_unwind(|| std::panic::panic_any(7_u8)).unwrap_err();
assert_eq!(panic_message(other.as_ref()), "unknown panic");
}
const LOGIN: &str = "Player[/127.0.0.1:0] logged in with entity id 2 at (0.5, 115.0, 0.5)";
const WARNING: &str = "Authentication warning";
const READY: &str = "horizon-console-log-ready\n";
+29 -100
View File
@@ -790,73 +790,40 @@ impl Game<'_> {
return;
}
let riding = self.riding.is_some();
let disks = location_disks(
&self.stations().enchantments,
self.armor.iter().zip(["head", "chest", "legs", "feet"]),
on_ground,
riding,
);
for (effect, level) in disks {
self.replace_disk(&effect, level, at, Who::Player);
let mut disks = Vec::new();
for (stack, slot) in self.armor.iter().zip(["head", "chest", "legs", "feet"]) {
let Some(stack) = stack else { continue };
for (id, level) in stack.enchantments() {
let Some(def) = self.stations().enchantments.get(id) else {
continue;
};
if !def
.slots
.iter()
.any(|s| s == slot || s == "armor" || s == "any")
{
continue;
}
for effect in def.effects["minecraft:location_changed"]
.as_array()
.into_iter()
.flatten()
{
if effect["effect"]["type"] == "minecraft:replace_disk"
&& location_requirements(&effect["requirements"], on_ground, riding)
{
disks.push((effect["effect"].clone(), *level));
}
}
}
}
}
/// `LivingEntity.onChangedBlock`, landing and `collectEquipmentChanges`
/// for an armor stand: run location effects before travel (including a
/// piston push), on landing, and for newly equipped items.
pub(super) fn armor_stand_location_effects(&mut self, index: usize, landed: bool) {
let stand = &mut self.armor_stands[index];
let block = BlockPos::containing(stand.position);
let moved = !landed && stand.enchantment_block.replace(block) != Some(block);
let changed: [bool; 6] =
std::array::from_fn(|i| stand.equipment[i] != stand.enchantment_equipment[i]);
if !moved && !landed && !changed.iter().any(|c| *c) {
return;
}
stand.enchantment_equipment.clone_from(&stand.equipment);
let stand = &self.armor_stands[index];
let at = [stand.position.x, stand.position.y, stand.position.z];
let who = Who::Mob(stand.id);
let equipment = stand
.equipment
.iter()
.zip(horizon_client::entities::ArmorStand::SLOTS)
.enumerate()
.filter(|(i, _)| moved || landed || changed[*i])
.filter(|(_, (stack, _))| {
moved
|| landed
|| !stack.as_ref().is_some_and(|stack| {
let data = stack.data();
let component = |id| data.and_then(|data| data.other(id));
let max_damage = match component("minecraft:max_damage") {
Some(horizon_world::nbt::Tag::Int(value)) => *value as u32,
_ => self
.res
.items()
.get(&stack.item)
.map_or(0, |i| i.max_damage),
};
max_damage > 0
&& component("minecraft:unbreakable").is_none()
&& component("!minecraft:max_damage").is_none()
&& component("!minecraft:damage").is_none()
&& stack.damage >= max_damage
})
})
.map(|(_, item)| item);
let disks = location_disks(
&self.stations().enchantments,
equipment,
stand.on_ground,
stand.vehicle.is_some(),
);
for (effect, level) in disks {
self.replace_disk(&effect, level, at, who);
self.replace_disk(&effect, level, at);
}
}
/// `ReplaceDisk.apply`: the blocks of the disk around `at` that pass the
/// predicate become the effect's state, and each placement is a game event.
fn replace_disk(&mut self, effect: &Value, level: u32, at: [f64; 3], who: Who) {
fn replace_disk(&mut self, effect: &Value, level: u32, at: [f64; 3]) {
let registry = self.pack.assets.registry;
let spec = &effect["block_state"];
let Some(default) = spec["id"].as_str().and_then(|n| registry.default_state(n)) else {
@@ -894,7 +861,7 @@ impl Game<'_> {
self.post_block_game_event(
"minecraft:block_place",
pos,
Some(who),
Some(Who::Player),
Some(new),
);
}
@@ -999,44 +966,6 @@ impl Game<'_> {
}
}
/// `EnchantmentHelper.runLocationChangedEffects`: the replace-disk effects of
/// active equipment slots, with the wearer's ground/passenger requirements.
fn location_disks<'a>(
enchantments: &Enchantments,
equipment: impl IntoIterator<Item = (&'a Option<ItemStack>, &'static str)>,
on_ground: bool,
riding: bool,
) -> Vec<(Value, u32)> {
let mut disks = Vec::new();
for (stack, slot) in equipment {
let Some(stack) = stack else { continue };
for (id, level) in stack.enchantments() {
let Some(def) = enchantments.get(id) else {
continue;
};
let armor = matches!(slot, "head" | "chest" | "legs" | "feet");
let hand = matches!(slot, "mainhand" | "offhand");
if !def.slots.iter().any(|s| {
s == slot || s == "any" || (s == "armor" && armor) || (s == "hand" && hand)
}) {
continue;
}
for effect in def.effects["minecraft:location_changed"]
.as_array()
.into_iter()
.flatten()
{
if effect["effect"]["type"] == "minecraft:replace_disk"
&& location_requirements(&effect["requirements"], on_ground, riding)
{
disks.push((effect["effect"].clone(), *level));
}
}
}
}
disks
}
/// Soul Speed's `attribute` effects at `level` (attribute, id, amount, operation),
/// the id taking the boots slot like `EnchantmentAttributeEffect.idForSlot`.
fn soul_speed_modifiers(
+19 -559
View File
@@ -1,335 +1,9 @@
//! Entity occlusion affects mesh submission, never entity ticking or interaction.
use super::{Game, Vertex};
use horizon_client::{
blocks::{BlockFacts, RenderShape, StateFacts},
chunk::Chunk,
blocks::{RenderShape, StateFacts},
entity_culling::Culler,
world::World,
};
use std::{
cell::RefCell,
sync::{
Arc, Weak,
mpsc::{self, Receiver, SyncSender, TrySendError},
},
thread::JoinHandle,
};
type ChunkKey = (i32, i32);
type SectionKey = (i32, i32, i32);
const MAX_SNAPSHOT_CHUNKS: i64 = 4_096;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
struct CullKey {
camera: [u64; 3],
min: [u64; 3],
max: [u64; 3],
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct SnapshotKey {
world_revision: u64,
chunks_revision: u64,
bottom_y: i32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct ChunkBounds {
min_x: i32,
max_x: i32,
min_z: i32,
max_z: i32,
}
/// Sorted weak chunk references avoid holding gameplay chunks or forcing
/// copy-on-write. A worker keeps only its current chunk strongly referenced.
#[derive(Clone)]
struct OcclusionSnapshot {
chunks: Vec<(ChunkKey, Weak<Chunk>)>,
opaque_states: Arc<[bool]>,
}
impl OcclusionSnapshot {
fn chunk(&self, key: ChunkKey) -> Option<&Weak<Chunk>> {
self.chunks
.binary_search_by_key(&key, |(key, _)| *key)
.ok()
.map(|index| &self.chunks[index].1)
}
fn refresh_chunks(&mut self, world: &World, bounds: ChunkBounds) {
self.chunks.clear();
for x in bounds.min_x..=bounds.max_x {
for z in bounds.min_z..=bounds.max_z {
if let Some(chunk) = world.chunks.get(&(x, z)) {
self.chunks.push(((x, z), Arc::downgrade(chunk)));
}
}
}
}
fn chunks_match(&self, world: &World) -> bool {
self.chunks.iter().all(|(key, expected)| {
world
.chunks
.get(key)
.is_some_and(|current| std::ptr::eq(expected.as_ptr(), Arc::as_ptr(current)))
})
}
fn opaque(&self, pos: [i32; 3], last_chunk: &mut LastChunk) -> bool {
let key = (pos[0] >> 4, pos[2] >> 4);
let Some(chunk) = last_chunk.get(self, key) else {
return false;
};
let state = chunk.block(pos[0] & 15, pos[1], pos[2] & 15);
self.opaque_states
.get(state.0 as usize)
.copied()
.unwrap_or(false)
}
}
#[derive(Default)]
struct LastChunk {
key: Option<ChunkKey>,
value: Option<Arc<Chunk>>,
}
impl LastChunk {
fn get(&mut self, snapshot: &OcclusionSnapshot, key: ChunkKey) -> Option<&Arc<Chunk>> {
if self.key != Some(key) {
self.key = Some(key);
self.value = snapshot.chunk(key).and_then(Weak::upgrade);
}
self.value.as_ref()
}
}
struct OcclusionProof {
sections: Vec<SectionKey>,
chunks: Vec<(ChunkKey, Weak<Chunk>)>,
}
struct CullResults {
stamp: SnapshotKey,
hidden: Vec<(CullKey, OcclusionProof)>,
scratch_queries: Vec<CullKey>,
}
struct CullJob {
stamp: SnapshotKey,
snapshot: Arc<OcclusionSnapshot>,
queries: Vec<CullKey>,
}
/// Non-blocking entity occlusion. Results are reused only for byte-identical
/// camera and mesh bounds, with the block chunks and rendered sections that
/// proved occlusion revalidated before hiding anything.
pub(super) struct AsyncCuller {
jobs: SyncSender<CullJob>,
results_rx: Receiver<CullResults>,
_thread: JoinHandle<()>,
snapshot: Option<(SnapshotKey, ChunkBounds, Arc<OcclusionSnapshot>)>,
opaque_states: Option<Arc<[bool]>>,
results: Option<CullResults>,
queries: RefCell<Vec<CullKey>>,
camera: [f64; 3],
stamp: SnapshotKey,
pending: bool,
}
impl AsyncCuller {
pub(super) fn new() -> std::io::Result<Self> {
let (job_tx, job_rx) = mpsc::sync_channel::<CullJob>(1);
let (result_tx, result_rx) = mpsc::channel::<CullResults>();
let thread = std::thread::Builder::new()
.name("entity-culling".into())
.spawn(move || {
while let Ok(job) = job_rx.recv() {
if result_tx.send(cull_job(job)).is_err() {
break;
}
}
})?;
Ok(Self {
jobs: job_tx,
results_rx: result_rx,
_thread: thread,
snapshot: None,
opaque_states: None,
results: None,
queries: RefCell::new(Vec::new()),
camera: [0.0; 3],
stamp: SnapshotKey {
world_revision: 0,
chunks_revision: 0,
bottom_y: 0,
},
pending: false,
})
}
fn begin_frame(&mut self, camera: glam::DVec3, world: &World) {
while let Ok(mut results) = self.results_rx.try_recv() {
self.pending = false;
let scratch = std::mem::take(&mut results.scratch_queries);
if scratch.capacity() > self.queries.get_mut().capacity() {
*self.queries.get_mut() = scratch;
}
self.results = Some(results);
}
self.camera = camera.to_array();
self.stamp = SnapshotKey {
world_revision: world.revision,
chunks_revision: world.chunks.membership_revision(),
bottom_y: world.bottom_y(),
};
if self
.results
.as_ref()
.is_some_and(|result| result.stamp != self.stamp)
{
self.results = None;
}
self.queries.get_mut().clear();
}
fn query(&self, vertices: &[Vertex], start: usize) -> Option<CullKey> {
let (min, max) = Culler::bounds(self.camera, vertices, start)?;
Some(CullKey {
camera: self.camera.map(f64::to_bits),
min: min.map(f64::to_bits),
max: max.map(f64::to_bits),
})
}
fn proof(&self, key: &CullKey) -> Option<&OcclusionProof> {
let hidden = &self.results.as_ref()?.hidden;
let index = hidden.binary_search_by_key(key, |(key, _)| *key).ok()?;
Some(&hidden[index].1)
}
fn queue(&self, key: CullKey) {
self.queries.borrow_mut().push(key);
}
fn finish_frame(&mut self, world: &World, facts: &BlockFacts) {
let queries = self.queries.get_mut();
if self.pending {
queries.clear();
return;
}
if queries.is_empty() {
return;
}
queries.sort_unstable();
queries.dedup();
let Some(bounds) = snapshot_bounds(self.camera, queries) else {
queries.clear();
return;
};
let opaque_states = Arc::clone(
self.opaque_states
.get_or_insert_with(|| facts.states.iter().map(opaque).collect::<Vec<_>>().into()),
);
let snapshot = match &mut self.snapshot {
Some((stamp, cached_bounds, snapshot)) => {
if stamp != &self.stamp || *cached_bounds != bounds || !snapshot.chunks_match(world)
{
let snapshot = Arc::make_mut(snapshot);
snapshot.opaque_states = Arc::clone(&opaque_states);
snapshot.refresh_chunks(world, bounds);
*stamp = self.stamp;
*cached_bounds = bounds;
}
Arc::clone(snapshot)
}
None => {
let mut snapshot = OcclusionSnapshot {
chunks: Vec::new(),
opaque_states,
};
snapshot.refresh_chunks(world, bounds);
let snapshot = Arc::new(snapshot);
self.snapshot = Some((self.stamp, bounds, Arc::clone(&snapshot)));
snapshot
}
};
let queries = std::mem::take(queries);
let job = CullJob {
stamp: self.stamp,
snapshot,
queries,
};
match self.jobs.try_send(job) {
Ok(()) => self.pending = true,
Err(TrySendError::Full(job) | TrySendError::Disconnected(job)) => {
*self.queries.get_mut() = job.queries;
}
}
}
}
fn cull_job(mut job: CullJob) -> CullResults {
let camera = job.queries[0].camera.map(f64::from_bits);
let culler = Culler::new(camera, 8_192);
let mut last_chunk = LastChunk::default();
let mut hidden = Vec::with_capacity(job.queries.len());
let mut covered = Vec::new();
for &key in &job.queries {
covered.clear();
let min = key.min.map(f64::from_bits);
let max = key.max.map(f64::from_bits);
if culler.occluded(min, max, |pos| {
let opaque = job.snapshot.opaque(pos, &mut last_chunk);
if opaque {
covered.push(pos);
}
opaque
}) {
let mut sections = Vec::with_capacity(covered.len());
let mut proof_chunks: Vec<(ChunkKey, Weak<Chunk>)> = Vec::new();
let mut valid = true;
for &pos in &covered {
let chunk_key = (pos[0] >> 4, pos[2] >> 4);
let Some(y) = pos[1].checked_sub(job.stamp.bottom_y) else {
valid = false;
break;
};
sections.push((chunk_key.0, y.div_euclid(16), chunk_key.1));
if let Err(index) = proof_chunks.binary_search_by_key(&chunk_key, |(key, _)| *key) {
let Some(chunk) = job.snapshot.chunk(chunk_key) else {
valid = false;
break;
};
if chunk.strong_count() == 0 {
valid = false;
break;
}
proof_chunks.insert(index, (chunk_key, chunk.clone()));
}
}
if valid {
sections.sort_unstable();
sections.dedup();
hidden.push((
key,
OcclusionProof {
sections,
chunks: proof_chunks,
},
));
}
}
}
job.queries.clear();
CullResults {
stamp: job.stamp,
hidden,
scratch_queries: job.queries,
}
}
fn opaque(facts: &StateFacts) -> bool {
facts.render_shape == RenderShape::Model
@@ -338,44 +12,6 @@ fn opaque(facts: &StateFacts) -> bool {
&& facts.full_collision
}
fn snapshot_bounds(camera: [f64; 3], queries: &[CullKey]) -> Option<ChunkBounds> {
let mut min_x = camera[0];
let mut max_x = camera[0];
let mut min_z = camera[2];
let mut max_z = camera[2];
for query in queries {
let min = query.min.map(f64::from_bits);
let max = query.max.map(f64::from_bits);
min_x = min_x.min(min[0]);
max_x = max_x.max(max[0]);
min_z = min_z.min(min[2]);
max_z = max_z.max(max[2]);
}
// Culler::occluded expands projected cells by EPSILON. One block of slack
// also covers the camera's first cell when it lies exactly on a boundary.
let min_x = snapshot_cell(min_x - 1.0)? >> 4;
let max_x = snapshot_cell(max_x + 1.0)? >> 4;
let min_z = snapshot_cell(min_z - 1.0)? >> 4;
let max_z = snapshot_cell(max_z + 1.0)? >> 4;
let width = i64::from(max_x) - i64::from(min_x) + 1;
let depth = i64::from(max_z) - i64::from(min_z) + 1;
if width <= 0 || depth <= 0 || width.checked_mul(depth)? > MAX_SNAPSHOT_CHUNKS {
return None;
}
Some(ChunkBounds {
min_x,
max_x,
min_z,
max_z,
})
}
fn snapshot_cell(value: f64) -> Option<i32> {
let cell = value.floor();
(cell.is_finite() && cell >= f64::from(i32::MIN) && cell <= f64::from(i32::MAX))
.then_some(cell as i32)
}
impl Game<'_> {
pub(super) fn prepare_entity_culling(&mut self, camera: glam::DVec3) {
// Spans tell the pack's passes which entities are hidden; without them, nothing changes.
@@ -387,124 +23,41 @@ impl Game<'_> {
_ => (false, false),
};
let at = camera.to_array().map(|v| v.floor() as i32);
let eligible = self.settings.horizon.entity_culling
!= horizon_client::graphics::EntityCullingMode::Off
self.entity_culler = (self.settings.horizon.entity_culling
// Packs can replace an opaque block's model or texture.
&& self.model_packs_are_vanilla()
&& self.settings.resource_packs.iter().all(|id| id == "vanilla")
&& self.player.is_some()
&& !self.spectator()
&& !opaque(self.res.facts().get(self.world.block(at[0], at[1], at[2])));
if !eligible {
self.entity_culler = None;
self.async_entity_culler = None;
self.async_entity_culling_failed = false;
return;
}
match self.settings.horizon.entity_culling {
horizon_client::graphics::EntityCullingMode::Off => {
self.entity_culler = None;
self.async_entity_culler = None;
self.async_entity_culling_failed = false;
}
horizon_client::graphics::EntityCullingMode::Synchronous => {
self.entity_culler = Some(Culler::new(camera.to_array(), 8_192));
self.async_entity_culler = None;
self.async_entity_culling_failed = false;
}
horizon_client::graphics::EntityCullingMode::Asynchronous => {
self.entity_culler = None;
if self.async_entity_culler.is_none() && !self.async_entity_culling_failed {
match AsyncCuller::new() {
Ok(culler) => self.async_entity_culler = Some(culler),
Err(error) => {
log::warn!("Horizon: cannot start entity-culling worker: {error}");
self.async_entity_culling_failed = true;
}
}
}
if let Some(culler) = &mut self.async_entity_culler {
culler.begin_frame(camera, &self.world);
} else {
// If the worker cannot start, use the existing same-frame path.
self.entity_culler = Some(Culler::new(camera.to_array(), 8_192));
}
}
}
&& !opaque(self.res.facts().get(self.world.block(at[0], at[1], at[2]))))
.then(|| Culler::new(camera.to_array(), 8_192));
}
/// Use complete generated geometry so equipment, animation and custom
/// models cannot stick out of an assumed collision-sized culling box.
pub(super) fn cull_entity_vertices(&self, vertices: &mut Vec<Vertex>, start: usize) {
if let Some(culler) = &self.entity_culler {
let hidden = culler.hidden(vertices, start, |pos| self.occluder_ready(pos));
// A shader pack's shadow pass still draws what the camera cannot see.
if hidden
&& !(self.pack_shadow_casters.0 && self.hide_spans_from(start, vertices.len()))
{
vertices.truncate(start);
let Some(culler) = &self.entity_culler else {
return;
};
let hidden = culler.hidden(vertices, start, |pos| {
let state = self.world.block(pos[0], pos[1], pos[2]);
if !opaque(self.res.facts().get(state)) {
return false;
}
return;
}
let Some(culler) = &self.async_entity_culler else {
return;
};
let Some(query) = culler.query(vertices, start) else {
return;
};
let hidden = self
.async_entity_culler
.as_ref()
.and_then(|culler| culler.proof(&query))
.is_some_and(|proof| self.async_proof_is_current(proof));
if hidden && !(self.pack_shadow_casters.0 && self.hide_spans_from(start, vertices.len())) {
vertices.truncate(start);
}
if let Some(culler) = &self.async_entity_culler {
culler.queue(query);
}
}
fn async_proof_is_current(&self, proof: &OcclusionProof) -> bool {
for (key, expected) in &proof.chunks {
let Some(current) = self.world.chunks.get(key) else {
let Some(y) = pos[1].checked_sub(self.world.bottom_y()) else {
return false;
};
if !std::ptr::eq(Arc::as_ptr(current), expected.as_ptr()) {
return false;
}
}
proof.sections.iter().all(|&section| {
let section = (pos[0] >> 4, y.div_euclid(16), pos[2] >> 4);
// Newly edited or not-yet-uploaded terrain is not an occluder.
self.renderer
.as_ref()
.is_some_and(|r| r.has_section(section))
&& !self.world.dirty_sections.contains(&section)
&& !self.streaming.is_meshing(section)
&& !self.pending_uploads.iter().any(|(key, _)| *key == section)
})
}
fn occluder_ready(&self, pos: [i32; 3]) -> bool {
let state = self.world.block(pos[0], pos[1], pos[2]);
if !opaque(self.res.facts().get(state)) {
return false;
}
let Some(y) = pos[1].checked_sub(self.world.bottom_y()) else {
return false;
};
let section = (pos[0] >> 4, y.div_euclid(16), pos[2] >> 4);
self.renderer
.as_ref()
.is_some_and(|r| r.has_section(section))
&& !self.world.dirty_sections.contains(&section)
&& !self.streaming.is_meshing(section)
&& !self.pending_uploads.iter().any(|(key, _)| *key == section)
}
pub(super) fn finish_async_entity_culling(&mut self) {
if let Some(culler) = &mut self.async_entity_culler {
culler.finish_frame(&self.world, self.res.facts());
});
// A shader pack's shadow pass still draws what the camera cannot see.
if hidden && !(self.pack_shadow_casters.0 && self.hide_spans_from(start, vertices.len())) {
vertices.truncate(start);
}
}
}
@@ -564,99 +117,6 @@ impl Game<'_> {
}
}
#[cfg(test)]
mod async_tests {
use super::*;
use horizon_client::chunk::ColumnLayout;
fn wall(hole: Option<[i32; 3]>) -> (Arc<OcclusionSnapshot>, Vec<Arc<Chunk>>) {
let mut live = Vec::new();
let mut chunks = Vec::new();
for chunk_z in [-1, 0] {
let mut chunk = Chunk::with_layout(0, chunk_z, ColumnLayout::VANILLA);
for y in -1..=0 {
for z in -1..=0 {
let pos = [4, y, z];
if hole != Some(pos) {
chunk.set(pos[0] & 15, pos[1], pos[2] & 15, 1);
}
}
}
let chunk = Arc::new(chunk);
chunks.push(((0, chunk_z), Arc::downgrade(&chunk)));
live.push(chunk);
}
chunks.sort_unstable_by_key(|(key, _)| *key);
(
Arc::new(OcclusionSnapshot {
chunks,
opaque_states: Arc::from([false, true]),
}),
live,
)
}
fn job(snapshot: Arc<OcclusionSnapshot>) -> CullJob {
let camera = [0.0; 3];
let min = [7.95, -0.55, -0.55];
let max = [10.05, 0.55, 0.55];
CullJob {
stamp: SnapshotKey {
world_revision: 1,
chunks_revision: 1,
bottom_y: ColumnLayout::VANILLA.min_y(),
},
snapshot,
queries: vec![CullKey {
camera: camera.map(f64::to_bits),
min: min.map(f64::to_bits),
max: max.map(f64::to_bits),
}],
}
}
#[test]
fn async_snapshot_is_bounded_and_handles_negative_chunk_edges() {
let camera = [-16.0, 0.0, -16.0];
let key = CullKey {
camera: camera.map(f64::to_bits),
min: [-8.0, -1.0, -8.0].map(f64::to_bits),
max: [8.0, 1.0, 8.0].map(f64::to_bits),
};
assert_eq!(
snapshot_bounds(camera, &[key]),
Some(ChunkBounds {
min_x: -2,
max_x: 0,
min_z: -2,
max_z: 0,
})
);
let huge = CullKey {
camera: [0.0; 3].map(f64::to_bits),
min: [5_000.0, 0.0, 5_000.0].map(f64::to_bits),
max: [5_001.0, 1.0, 5_001.0].map(f64::to_bits),
};
assert_eq!(snapshot_bounds([0.0; 3], &[huge]), None);
}
#[test]
fn async_worker_only_hides_bounds_behind_a_complete_opaque_plane() {
let (snapshot, live) = wall(None);
let results = cull_job(job(snapshot));
assert_eq!(results.hidden.len(), 1);
assert_eq!(results.hidden[0].1.sections.len(), 4);
assert_eq!(results.hidden[0].1.chunks.len(), 2);
drop(live);
let (snapshot, live) = wall(Some([4, 0, 0]));
let results = cull_job(job(snapshot));
assert!(results.hidden.is_empty());
drop(live);
}
}
/// An entity's position as a `DVec3`.
pub(super) fn at(p: horizon_core::geometry::Vec3) -> glam::DVec3 {
glam::DVec3::new(p.x, p.y, p.z)
@@ -698,74 +698,6 @@ impl Game<'_> {
c.velocity.z + dir[2] * power,
);
}
// Arrows, fire charges, snowballs and the rest: `Entity.pushFromExplosion`
// (`push`) from their eyes (0.85 of their height). `AbstractArrow.push`
// does nothing while it is in the ground; `hurtServer` never hurts them.
let mut projectiles = std::mem::take(&mut self.creatures.projectiles);
for p in projectiles.iter_mut().filter(|p| !p.stuck) {
let (lo, hi) = (p.bounds().min_position(), p.bounds().max_position());
let dist = distance([p.position.x, p.position.y, p.position.z]) / diameter;
if dist > 1.0 {
continue;
}
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
let eye_height = p.kind.eye_height();
let dir = direction([p.position.x, p.position.y + eye_height, p.position.z]);
let power = (1.0 - dist) * f64::from(exposure);
p.velocity = Vec3::new(
p.velocity.x + dir[0] * power,
p.velocity.y + dir[1] * power,
p.velocity.z + dir[2] * power,
);
p.needs_sync = true;
}
self.creatures.projectiles = projectiles;
// Firework rockets (0.25 wide, so 0.2125 to the eyes).
let mut rockets = std::mem::take(&mut self.rockets);
for r in &mut rockets {
let feet = r.position;
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
if dist > 1.0 {
continue;
}
let exposure = self.seen_percent(
center,
[feet.x - 0.125, feet.y, feet.z - 0.125],
[feet.x + 0.125, feet.y + 0.25, feet.z + 0.125],
);
let dir = direction([feet.x, feet.y + 0.2125, feet.z]);
let power = (1.0 - dist) * f64::from(exposure);
r.velocity = Vec3::new(
r.velocity.x + dir[0] * power,
r.velocity.y + dir[1] * power,
r.velocity.z + dir[2] * power,
);
}
self.rockets = rockets;
// Thrown tridents live apart from the other projectiles (`game/trident.rs`): 0.5
// wide and high, eyes at 0.13 (`EntityType.TRIDENT`), and like any arrow
// (`AbstractArrow.push`) not pushed while in the ground.
let mut tridents = std::mem::take(&mut self.tridents);
for t in tridents.iter_mut().filter(|t| !t.in_ground) {
let feet = t.position;
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
if dist > 1.0 {
continue;
}
let exposure = self.seen_percent(
center,
[feet.x - 0.25, feet.y, feet.z - 0.25],
[feet.x + 0.25, feet.y + 0.5, feet.z + 0.25],
);
let dir = direction([feet.x, feet.y + f64::from(0.13_f32), feet.z]);
let power = (1.0 - dist) * f64::from(exposure);
t.velocity = Vec3::new(
t.velocity.x + dir[0] * power,
t.velocity.y + dir[1] * power,
t.velocity.z + dir[2] * power,
);
}
self.tridents = tridents;
// Frames, paintings, cushions and armour stands last: vanilla lists
// the entities in reach before hurting any, so the items they drop
// here are neither destroyed nor pushed by the same blast.
-1
View File
@@ -62,7 +62,6 @@ impl Game<'_> {
let now = self.extras_now();
self.extras.fps.record(now, elapsed.as_secs_f32() * 1000.0);
self.native_frame(elapsed);
self.recipes_received_now();
}
/// The frame counter for mods: fps, min, 1% low.
pub(super) fn frame_reading(&self) -> (u32, u32, u32) {
-12
View File
@@ -580,12 +580,6 @@ impl Game<'_> {
.map(|i| i.id)
});
if let Some(id) = hit {
// Projectile.onHit on an entity: PROJECTILE_LAND.
self.projectile_event(
"minecraft:projectile_land",
Some(horizon_client::mobs::Who::Player),
[h.position.x, h.position.y, h.position.z],
);
h.state = State::Hooked(id);
h.velocity = Vec3::ZERO;
self.hook = Some(h);
@@ -611,12 +605,6 @@ impl Game<'_> {
shape.clip(h.position, to, p)
});
if let Some(hit) = hit.ok().flatten() {
// Projectile.onHit on a block: PROJECTILE_LAND.
self.projectile_event(
"minecraft:projectile_land",
Some(horizon_client::mobs::Who::Player),
[hit.location.x, hit.location.y, hit.location.z],
);
h.velocity = rebound(h.velocity, hit.location, h.position);
}
}
-19
View File
@@ -35,25 +35,6 @@ impl Game<'_> {
)
.collect()
}
/// `EntityGetter.getEntityCollisions` for a dropped item: the entities whose
/// `canBeCollidedWith(item)` is true. A boat and a minecart always, a living shulker,
/// and a happy ghast only while it is held still (`isOnStillTimeout`): a player on its
/// back keeps it still, so an item dropped there stays on top.
pub(super) fn item_entity_collisions(&self) -> Vec<Aabb> {
self.mobs
.iter()
.filter(|m| {
(m.species() == horizon_client::mobs::Species::HappyGhast
&& m.alive()
&& !m.is_baby()
&& happy_ghast::on_still_timeout(m))
|| (m.species() == horizon_client::mobs::Species::Shulker && m.alive())
})
.map(|m| m.bounds())
.chain(self.boats.iter().map(super::boats::Boat::bounds))
.chain(self.carts.iter().map(|c| c.bounds()))
.collect()
}
/// `HappyGhast.mobInteract`. Returns whether the click was used.
pub(super) fn interact_happy_ghast(&mut self, index: usize, item: &str) -> bool {
let m = &self.mobs[index];
+1 -2
View File
@@ -21,7 +21,7 @@ impl Game<'_> {
return true;
}
match self.screen {
Screen::Chat | Screen::CommandBlock | Screen::Social => true,
Screen::Chat | Screen::CommandBlock => true,
Screen::DebugOptions => self
.debug_options
.as_ref()
@@ -117,7 +117,6 @@ impl Game<'_> {
match self.screen {
Screen::Chat => self.chat_type(&text),
Screen::CommandBlock => self.command_block_type(&text),
Screen::Social => self.social_type(&text),
Screen::DebugOptions => self.debug_options_type(&text),
Screen::WorldOptions => self.world_options_text(Some(&text)),
Screen::Creative => self.creative_search_edit(Some(&text)),
@@ -32,37 +32,8 @@ use horizon_protocol::{
text::literal,
};
use horizon_world::entity_move::EntityMoveWorld;
use std::collections::HashMap;
use winit::keyboard::KeyCode;
/// Sets the viewer counts `known` holds for `dim` to `now` and returns the
/// changes as `(pos, previous, viewers)`, in position order. A container that
/// is no longer counted goes to 0 and leaves `known`.
pub(super) fn viewer_changes(
known: &mut HashMap<(&'static str, (i32, i32, i32)), u32>,
dim: &'static str,
mut now: HashMap<(i32, i32, i32), u32>,
) -> Vec<((i32, i32, i32), u32, u32)> {
let mut changes = Vec::new();
known.retain(|&(d, pos), previous| {
if d != dim {
return true;
}
let viewers = now.remove(&pos).unwrap_or(0);
if viewers != *previous {
changes.push((pos, *previous, viewers));
*previous = viewers;
}
viewers > 0
});
for (pos, viewers) in now {
known.insert((dim, pos), viewers);
changes.push((pos, 0, viewers));
}
changes.sort_unstable();
changes
}
/// Per-guest state of the gameplay code that lives outside `Guest`.
#[derive(Default)]
pub(super) struct GuestPlay {
@@ -326,9 +297,6 @@ impl Game<'_> {
f: impl FnOnce(&mut Self) -> R,
) -> Option<R> {
let swap = self.enter_guest(host, conn)?;
super::dedicated::crash_fixture("guest", || {
host.hub.guests.iter().any(|g| g.conn == conn && g.placed)
});
let dimension =
(self.serving.is_some() || self.options.dedicated.is_some()).then_some(self.dim.id());
let result = horizon_client::block_contact::in_level(dimension, || f(self));
@@ -1395,27 +1363,6 @@ impl Game<'_> {
}
}
/// `ContainerOpenersCounter` for the mod containers of the level in place:
/// the players with each one open, recounted from their menus, so a
/// change is heard whichever way a player left (`native_mod_container_viewers`).
fn tick_mod_container_viewers(&mut self, host: &LanHost) {
let mut now: HashMap<(i32, i32, i32), u32> = HashMap::new();
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
// `BarrelBlockEntity.startOpen`: a spectator does not open it.
if !g.spectator()
&& let Some(m) = &g.menu
&& let Slots::Block(pos) = m.slots
&& self.mod_menu_at(pos).is_some()
{
*now.entry(pos).or_default() += 1;
}
}
let dim = self.dim.id();
for (pos, previous, viewers) in viewer_changes(&mut self.mod_container_viewers, dim, now) {
self.native_mod_container_viewers(dim, [pos.0, pos.1, pos.2], viewers, previous);
}
}
/// `stillValid` of the guest's open container: its blocks are still there
/// and in reach, or its vehicle exists and is in reach. True with none open.
fn container_menu_valid(&self, g: &horizon_client::lan::guest::Guest) -> bool {
@@ -1922,23 +1869,6 @@ impl Game<'_> {
let hand = if p.hand == 1 { Hand::Off } else { Hand::Main };
let g = &mut host.hub.guests[gi];
(g.yaw, g.pitch) = (p.yaw, p.pitch);
// SpawnEggItem.use calls ServerLevel.mayInteract at its own
// fluid ray's position, unlike useOn's packet-supplied block.
let held = if hand == Hand::Off {
g.offhand.as_ref()
} else {
g.inventory.get(g.selected).and_then(Option::as_ref)
};
if held.is_some_and(|s| s.item.ends_with("_spawn_egg")) {
let hit = self
.as_guest(host, conn, |g| {
g.with_hand(hand, |g| g.spawn_egg_fluid_hit())
})
.flatten();
if hit.is_some_and(|h| self.under_spawn_protection(host, gi, h.pos)) {
return;
}
}
let heard = self.audio.capture.as_ref().map_or(0, Vec::len);
let used = self.as_guest(host, conn, |g| {
// A horn's sound, inline instruments included.
@@ -3305,7 +3235,6 @@ impl Game<'_> {
self.lan_stations_tick(host);
self.lan_tables_tick(host);
self.lan_containers_tick(host);
self.tick_mod_container_viewers(host);
self.tick_lan_mount_menus(host);
self.announce_guest_advancements(host);
let conns: Vec<u32> = host
@@ -133,9 +133,8 @@ impl Game<'_> {
let b = &mut self.boats[i];
b.hurt_time = (b.hurt_time - 1).max(0);
b.damage = (b.damage - 1.0).max(0.0);
// `Entity.ejectPassengers`: every player in the boat must see
// the request; the common boat tick clears it once nobody rides.
ejected = b.eject_riders;
// AbstractBoat.tickBubbleColumn: a down column ejects the rider.
ejected = std::mem::take(&mut b.eject_riders);
}
// ServerGamePacketListenerImpl.tick: the vehicle's last good position
// is where it is now, so a move the server made (a piston, a push)
-2
View File
@@ -549,8 +549,6 @@ impl Game<'_> {
.is_some_and(|p| !p.streaming.forced.is_empty())
};
guests.iter().any(|g| g.dimension == d.id())
// A viewer count not yet told to 0 (`tick_mod_container_viewers`).
|| self.mod_container_viewers.keys().any(|(k, _)| *k == d.id())
|| self.player_bed_levels().any(|b| b == *d)
|| forced
// A game test runs in its own level, players or not.
+12 -67
View File
@@ -7,11 +7,7 @@
use super::{Game, commands::Target};
use horizon_client::advancements::EntityView;
use horizon_client::mobs::{Damage, DamageKind, MobEvent, SpawnSetup, Species};
use horizon_core::{
geometry::{Aabb, Vec3},
position::BlockPos,
random::RandomSource,
};
use horizon_core::{position::BlockPos, random::RandomSource};
pub(super) struct Bolt {
/// The entity id a server's players know it by (0 until it has one).
@@ -456,26 +452,10 @@ impl Game<'_> {
}
});
}
/// `thunderHit` for entities whose box meets the strike box, 3 blocks
/// around and up to 9 above (`getEntities(this, AABB)` tests boxes, not
/// positions). Returns the mobs in that box before the hit, as
/// `CHANNELED_LIGHTNING` counts them. Boats, minecarts and experience
/// orbs are hit too (`VehicleEntity.hurtServer` adds 5 x 10 > 40 and
/// destroys them; an orb has 5 health).
/// `thunderHit` for entities within 3 blocks (up to 9 above). Returns the
/// mobs in that box before the hit, as `CHANNELED_LIGHTNING` counts them.
fn thunder_hit(&mut self, b: &mut Bolt) -> Vec<EntityView> {
let at = b.at;
let strike = Aabb::new(
Vec3::new(at[0] - 3.0, at[1] - 3.0, at[2] - 3.0),
Vec3::new(at[0] + 3.0, at[1] + 9.0, at[2] + 3.0),
);
// Whether the box of an entity standing at `p` (half width `w`,
// height `h`) meets the strike box.
let meets = move |p: [f64; 3], w: f64, h: f64| {
strike.intersects(Aabb::new(
Vec3::new(p[0] - w, p[1], p[2] - w),
Vec3::new(p[0] + w, p[1] + h, p[2] + w),
))
};
let inside = move |p: [f64; 3]| {
(p[0] - at[0]).abs() <= 3.0
&& (p[2] - at[2]).abs() <= 3.0
@@ -485,14 +465,7 @@ impl Game<'_> {
let struck: Vec<(u32, EntityView)> = self
.mobs
.iter()
.filter(|m| {
m.alive()
&& meets(
[m.position.x, m.position.y, m.position.z],
m.kind.width / 2.0,
m.kind.height,
)
})
.filter(|m| m.alive() && inside([m.position.x, m.position.y, m.position.z]))
.map(|m| (m.id, self.mob_entity(m)))
.collect();
b.hit.extend(struck.iter().map(|(id, _)| *id));
@@ -504,11 +477,7 @@ impl Game<'_> {
let mut events = Vec::new();
let peaceful = self.ui.difficulty == 0;
for m in self.mobs.iter_mut().filter(|m| m.alive()) {
if !meets(
[m.position.x, m.position.y, m.position.z],
m.kind.width / 2.0,
m.kind.height,
) {
if !inside([m.position.x, m.position.y, m.position.z]) {
continue;
}
let setup = |m: &horizon_client::mobs::Mob| SpawnSetup {
@@ -585,34 +554,10 @@ impl Game<'_> {
b.spared.extend(self.dropped[before..].iter().map(|e| e.id));
}
}
// Boats, then minecarts: `VehicleEntity.hurtServer` for 5 damage
// (x 10 over the 40 a vehicle takes) destroys them. Lightning is no
// fire source, so a TNT cart does not prime.
let mut i = self.boats.len();
while i > 0 {
i -= 1;
let bb = self.boats[i].bounds();
if strike.intersects(bb) {
self.hurt_boat(i, 5.0, false);
}
}
let mut i = self.carts.len();
while i > 0 {
i -= 1;
let bb = self.carts[i].bounds();
if strike.intersects(bb) {
self.hurt_cart(i, 5.0, false, false);
}
}
// `ExperienceOrb.hurtServer`: 5 damage off 5 health.
let orbs_before = self.orb_ids();
self.orbs
.retain_mut(|o| !(strike.intersects(o.bounds()) && o.hurt(5.0)));
self.native_orbs_left_since(&orbs_before);
// Every player in the box, the server's players too (0.6 x 1.8).
let strike_players = move |g: &mut Self| {
// Every player in the box, the server's players too.
let strike = move |g: &mut Self| {
if let Some(p) = g.player.as_ref().map(|p| p.position)
&& meets([p.x, p.y, p.z], 0.3, 1.8)
&& inside([p.x, p.y, p.z])
{
if let Some(p) = g.player.as_mut() {
p.fire_ticks += 1;
@@ -625,18 +570,18 @@ impl Game<'_> {
g.hurt_player_by("minecraft:lightning_bolt", 5.0);
}
};
strike_players(self);
self.each_guest_hit(strike_players);
strike(self);
self.each_guest_hit(strike);
b.hit_items.extend(
self.dropped
.iter()
.filter(|e| meets([e.position.x, e.position.y, e.position.z], 0.125, 0.25))
.filter(|e| inside([e.position.x, e.position.y, e.position.z]))
.map(|e| e.id),
);
// Dropped items burn up (ItemEntity health 5), save fire-resistant ones.
let before = self.dropped_ids();
self.dropped.retain(|e| {
!meets([e.position.x, e.position.y, e.position.z], 0.125, 0.25)
!inside([e.position.x, e.position.y, e.position.z])
|| e.stack.item.contains("netherite")
|| (e.age < 20 && b.spared.contains(&e.id))
});
+1 -56
View File
@@ -414,68 +414,13 @@ impl Game<'_> {
}
self.rail_pos(x, y, z)
}
/// `AbstractMinecart.canCollideWith`, for `Entity.collide`'s `getEntityCollisions`:
/// `AbstractBoat.canVehicleCollide` takes any other cart, boat, shulker or pushable mob and
/// player, except one riding this cart. A cart that runs into one has a horizontal
/// collision, which sets a TNT minecart off.
fn cart_entity_collisions(&self, c: &Minecart) -> Vec<Aabb> {
let near = c.bounds().inflate(Vec3::new(2.0, 2.0, 2.0));
let mut boxes: Vec<Aabb> = self
.carts
.iter()
.filter(|o| o.id != c.id)
.map(Minecart::bounds)
.chain(self.boats.iter().map(super::boats::Boat::bounds))
.chain(
self.mobs
.iter()
.filter(|m| {
!m.removed
&& m.extra.mount != Some(c.id)
&& (m.pushable()
|| m.species() == horizon_client::mobs::Species::Shulker
&& m.alive())
})
.map(|m| m.bounds()),
)
.collect();
if self.riding != Some(c.id)
&& let Some(p) = self.player.as_ref()
&& !self.spectator()
{
boxes.push(p.bounds());
}
if let Some(host) = self.lan.as_ref() {
boxes.extend(
host.hub
.guests
.iter()
.filter(|g| {
host.hub.here(g)
&& g.loaded
&& !g.dead
&& !g.spectator()
&& g.vehicle != Some(c.id)
})
.map(|g| g.bounds()),
);
}
boxes.retain(|b| b.intersects(near));
boxes
}
/// Entity.move for a cart: collisions zero the blocked components.
fn move_cart(&self, c: &mut Minecart, delta: Vec3) {
let scene = SceneCollisions {
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
let entities = self.cart_entity_collisions(c);
let mut scene = horizon_client::collisions::EntityCollisions {
blocks: scene,
entities: &entities,
border: None,
};
if let Ok(moved) = scene.resolve(c.bounds(), delta, 0.0, c.on_ground) {
c.position = Vec3::new(
c.position.x + moved.x,
-15
View File
@@ -185,12 +185,6 @@ fn register_menu_types(mods: &[horizon_client::mod_content::ContentMod]) {
/// What the launcher writes beside a game it built with native mods: their ids under `mods`.
const BUILD_INFO: &str = "native-mods.json";
/// Whether the launcher built this game with native mods (`built`: its [`BUILD_INFO`]).
fn built_with_mods(built: &str) -> bool {
let info: serde_json::Value = serde_json::from_str(built).unwrap_or_default();
info["mods"].as_array().is_some_and(|mods| !mods.is_empty())
}
/// A row for each native mod the launcher installed in `dir` (`<id>.hnmod`) that this game wasn't
/// built with (`built`: its [`BUILD_INFO`], empty for a release or Nightly) and that has no row
/// yet. The page said "No mods" before, and players copied the files into `horizon-mods` (#4770).
@@ -278,15 +272,6 @@ impl Game<'_> {
.ok()
.and_then(|exe| std::fs::read_to_string(exe.with_file_name(BUILD_INFO)).ok())
.unwrap_or_default();
horizon_client::gfx::native_render::set_mods_built_in(
built_with_mods(&built)
|| self
.settings
.mods
.installed
.iter()
.any(|m| m.native.is_some()),
);
let native_dir = Self::game_dir().join(mod_list::NATIVE_DIR);
let unbuilt = not_built(&native_dir, &built, &self.settings.mods.installed);
self.settings.mods.installed.extend(unbuilt);
+1 -6
View File
@@ -14,12 +14,7 @@ impl Game<'_> {
if self.options.split_guest {
return;
}
let (mut music, mut volume) = self.situational_music();
// A native mod's own choice of track and volume, then its layers.
self.native_music(&mut music, &mut volume);
let mut layers = Vec::new();
self.native_music_layers(&mut layers);
self.music_layers.tick(&layers, &mut self.audio);
let (music, volume) = self.situational_music();
// Options.musicFrequency's listener: MusicManager.setMinutesBetweenSongs.
let frequency = MusicFrequency::parse(&self.settings.file.name("musicFrequency"));
if frequency != self.music.frequency {
+1 -1
View File
@@ -260,7 +260,7 @@ impl Game<'_> {
Screen::Chat if self.title.is_none() => true,
Screen::Options => self.ui.options.typing(),
Screen::WorldOptions => self.world_options_typing(),
Screen::CommandBlock | Screen::Social => true,
Screen::CommandBlock => true,
Screen::Inventory => self.station_typing() || self.mod_browser_typing(),
_ => self
.title
@@ -1,346 +0,0 @@
//! A client mod's view and its own items (docs/native-mods.md, "A client mod's view and items"): the
//! first block along the view, across the distant terrain past the loaded chunks, and items whose
//! right-click is the mod's alone, heard as use events the mod polls.
use super::Game;
use std::collections::BTreeMap;
/// A stack as a client mod's use events carry it.
#[allow(dead_code)]
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct NativeHeldItem {
pub(crate) id: String,
pub(crate) count: u32,
/// The inventory slot: 0 to 8, the hotbar (a mod use is the main hand's).
pub(crate) slot: u32,
/// `minecraft:custom_name` as SNBT, when the stack has one.
pub(crate) custom_name: Option<String>,
/// `minecraft:custom_data` as SNBT, when the stack has some.
pub(crate) custom_data: Option<String>,
}
/// The block a ray along the view meets.
#[allow(dead_code)]
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct NativeHit {
pub(crate) block_x: i32,
pub(crate) block_y: i32,
pub(crate) block_z: i32,
/// Where the ray meets the block.
pub(crate) x: f64,
pub(crate) y: f64,
pub(crate) z: f64,
/// The block's id (`minecraft:stone`).
pub(crate) block: String,
}
/// What happened to a mod's claimed item, oldest first.
#[allow(dead_code)]
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum NativeModEvent {
/// A right-click with the item began its use: the stack, and 0 ticks.
UseStart(NativeHeldItem, u32),
/// The use ended: the stack it began with, and the ticks it was held. It
/// ends when the button comes up, the hand's item changes or leaves the
/// claim, the player respawns, or the game joins a server.
UseStop(NativeHeldItem, u32),
}
/// The farthest a ray along the view reaches, in blocks.
pub(crate) const RAYCAST_MAX: f32 = 1024.0;
/// The ids a mod may claim.
const CLAIMS_MAX: usize = 64;
/// Events kept for a mod between two polls.
const EVENTS_MAX: usize = 256;
/// A claimed item's use under way.
struct ModUse {
mod_id: String,
item: NativeHeldItem,
/// The player's `using` while it is this use: its slot and ticks.
slot: usize,
ticks: u32,
}
/// The items client mods claim, their events waiting for a poll, and the use under way.
#[derive(Default)]
pub(super) struct ModInput {
claims: BTreeMap<String, Vec<String>>,
events: BTreeMap<String, Vec<NativeModEvent>>,
using: Option<ModUse>,
}
impl ModInput {
fn claim(&mut self, mod_id: &str, ids: &[String]) {
let ids = ids.iter().take(CLAIMS_MAX).cloned().collect();
self.claims.insert(mod_id.to_owned(), ids);
}
/// The mod that claims `item`, the first in id order.
pub(super) fn claimant(&self, item: &str) -> Option<&str> {
self.claims
.iter()
.find(|(_, ids)| ids.iter().any(|i| i == item))
.map(|(id, _)| id.as_str())
}
fn push(&mut self, mod_id: &str, event: NativeModEvent) {
let events = self.events.entry(mod_id.to_owned()).or_default();
if events.len() < EVENTS_MAX {
events.push(event);
}
}
fn poll(&mut self, mod_id: &str) -> Vec<NativeModEvent> {
self.events.remove(mod_id).unwrap_or_default()
}
/// Mod `mod_id`'s use of `item` in `slot` begins, ending any other first.
fn start(&mut self, mod_id: &str, item: NativeHeldItem, slot: usize) {
self.end();
self.push(mod_id, NativeModEvent::UseStart(item.clone(), 0));
self.using = Some(ModUse {
mod_id: mod_id.to_owned(),
item,
slot,
ticks: 0,
});
}
/// Whether the player's use (`Game.using`) is the mod use under way. A
/// use the game reset and another item's begun since are not.
fn owns(&self, using: Option<(usize, u32)>) -> bool {
self.using
.as_ref()
.is_some_and(|u| using == Some((u.slot, u.ticks)))
}
fn tick(&mut self) {
if let Some(u) = &mut self.using {
u.ticks += 1;
}
}
/// The use under way ends: its mod hears `UseStop`.
fn end(&mut self) {
if let Some(u) = self.using.take() {
self.push(&u.mod_id, NativeModEvent::UseStop(u.item, u.ticks));
}
}
}
#[allow(dead_code)]
impl Game<'_> {
/// The stack in inventory slot `slot` as a mod's events carry it.
fn mod_held(&self, slot: usize) -> Option<NativeHeldItem> {
let stack = self.stack_in(slot)?;
let extra = stack.extra.as_deref();
Some(NativeHeldItem {
id: stack.item.to_string(),
count: stack.count,
slot: slot as u32,
custom_name: extra
.and_then(|e| e.custom_name.as_ref())
.map(horizon_client::snbt::to_snbt),
custom_data: extra
.and_then(|e| e.other("minecraft:custom_data"))
.map(horizon_client::snbt::to_snbt),
})
}
/// The first block along the view within `distance` (at most 1024), past the loaded chunks over the distant terrain. API @API:native_claimed_items@.
pub(super) fn native_raycast(&self, distance: f32) -> Option<NativeHit> {
let (d, hit) = self.native_view_hit()?;
(d <= distance.clamp(0.0, RAYCAST_MAX)).then_some(hit)
}
/// The first block along the view within `RAYCAST_MAX`, and its distance. API @API:native_claimed_items@.
pub(super) fn native_view_hit(&self) -> Option<(f32, NativeHit)> {
self.player.as_ref()?;
let eye = self.eye_position()?;
let dir = super::view_vector(self.yaw, self.pitch);
let reach = f64::from(RAYCAST_MAX);
let (pos, point, state) = match self.clip_ray(eye, dir, reach) {
Some(hit) => (
hit.pos,
hit.point,
self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]),
),
None => self.lod_ray(eye, dir, reach)?,
};
let distance = (0..3)
.map(|i| (point[i] - eye[i]).powi(2))
.sum::<f64>()
.sqrt();
Some((
distance as f32,
NativeHit {
block_x: pos[0],
block_y: pos[1],
block_z: pos[2],
x: point[0],
y: point[1],
z: point[2],
block: self.res.facts().get(state).name.to_string(),
},
))
}
/// A ray over the distant terrain (`Lod::ground`) past the loaded chunks, a block at a time.
fn lod_ray(
&self,
eye: [f64; 3],
dir: [f64; 3],
reach: f64,
) -> Option<([i32; 3], [f64; 3], horizon_world::registry::BlockStateId)> {
let lod = self.lod.as_ref()?;
let mut t = 0.0;
while t <= reach {
let p = [
eye[0] + dir[0] * t,
eye[1] + dir[1] * t,
eye[2] + dir[2] * t,
];
let (x, z) = (p[0].floor() as i32, p[2].floor() as i32);
if !self.world.loaded(x, z)
&& let Some((height, state)) = lod.ground(x, z)
&& p[1] <= f64::from(height)
{
return Some(([x, height - 1, z], [p[0], f64::from(height), p[2]], state));
}
t += 1.0;
}
None
}
/// Right-clicks with these items (at most 64 ids) are mod `mod_id`'s; a later call replaces the list. API @API:native_claimed_items@.
pub(super) fn native_claim_items(&mut self, mod_id: &str, ids: &[String]) {
self.native_input.claim(mod_id, ids);
}
/// Mod `mod_id`'s events since its last poll (at most 256 wait). API @API:native_claimed_items@.
pub(super) fn native_poll(&mut self, mod_id: &str) -> Vec<NativeModEvent> {
self.native_input.poll(mod_id)
}
/// `use_hand`: a right-click with an item a mod claims starts its use for
/// that mod and nothing else happens. True when a mod took it.
pub(super) fn mod_use_start(&mut self) -> bool {
if self.guest_use.is_some() || self.using.is_some() {
return false;
}
// With no use, a mod use the game reset without telling it is over.
self.native_input.end();
let slot = self.held_slot();
let Some(item) = self.mod_held(slot) else {
return false;
};
let Some(id) = self.native_input.claimant(&item.id).map(str::to_owned) else {
return false;
};
self.native_input.start(&id, item, slot);
self.using = Some((slot, 0));
true
}
/// `tick_used_item` for a claimed item: counts the ticks held and ends
/// the use when the button comes up. True when the player's use is a
/// mod's; a mod use the game reset ends, and the player's own use goes on.
pub(super) fn mod_use_tick(&mut self, slot: usize, ticks: u32, released: bool) -> bool {
if !self.native_input.owns(Some((slot, ticks))) {
self.native_input.end();
return false;
}
let still_claimed = self.native_input.using.as_ref().is_some_and(|u| {
self.stack_in(slot)
.is_some_and(|s| self.native_input.claimant(&s.item) == Some(u.mod_id.as_str()))
});
if released || !still_claimed {
self.native_input.end();
self.using = None;
} else {
self.native_input.tick();
self.using = Some((slot, ticks + 1));
}
true
}
/// The player's use was reset (a respawn, a join, the hand's item
/// changed): a mod use under way ends and its mod hears `UseStop`.
pub(super) fn end_mod_use(&mut self) {
self.native_input.end();
}
}
#[cfg(test)]
mod input_tests {
use super::*;
fn stick(slot: u32) -> NativeHeldItem {
NativeHeldItem {
id: "minecraft:stick".into(),
count: 1,
slot,
custom_name: None,
custom_data: Some("{orbital:1b}".into()),
}
}
#[test]
fn items_are_claimed_by_the_first_mod_in_id_order() {
let mut input = ModInput::default();
let ids = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
input.claim("zeta", &ids(&["orbital:remote", "minecraft:stick"]));
input.claim("alpha", &ids(&["minecraft:stick"]));
assert_eq!(input.claimant("minecraft:stick"), Some("alpha"));
assert_eq!(input.claimant("orbital:remote"), Some("zeta"));
assert_eq!(input.claimant("minecraft:dirt"), None);
input.claim("alpha", &[]);
assert_eq!(input.claimant("minecraft:stick"), Some("zeta"));
let many: Vec<String> = (0..100).map(|i| format!("m:item{i}")).collect();
input.claim("alpha", &many);
assert_eq!(input.claimant("m:item63"), Some("alpha"));
assert_eq!(input.claimant("m:item64"), None, "64 at most");
}
#[test]
fn events_wait_for_their_mod_up_to_a_limit() {
let mut input = ModInput::default();
for _ in 0..EVENTS_MAX + 10 {
input.push("a", NativeModEvent::UseStart(stick(0), 0));
}
input.push("b", NativeModEvent::UseStop(stick(0), 3));
assert_eq!(input.poll("a").len(), EVENTS_MAX);
assert!(input.poll("a").is_empty());
assert_eq!(input.poll("b"), vec![NativeModEvent::UseStop(stick(0), 3)]);
}
#[test]
fn a_claimed_use_is_the_mods_while_it_runs() {
let mut input = ModInput::default();
input.start("orbital", stick(2), 2);
assert!(input.owns(Some((2, 0))));
input.tick();
input.tick();
assert!(input.owns(Some((2, 2))));
input.end();
assert!(!input.owns(Some((2, 2))));
assert_eq!(
input.poll("orbital"),
vec![
NativeModEvent::UseStart(stick(2), 0),
NativeModEvent::UseStop(stick(2), 2),
]
);
}
#[test]
fn an_interrupted_claimed_use_leaves_the_next_use_alone() {
// A respawn ends the use: the mod hears it stop, and a vanilla use
// begun next in the same slot is not taken for the mod's.
let mut input = ModInput::default();
input.start("orbital", stick(0), 0);
input.tick();
input.end();
assert!(!input.owns(Some((0, 0))));
assert_eq!(
input.poll("orbital").last(),
Some(&NativeModEvent::UseStop(stick(0), 1))
);
// A reset the mod use never heard of (`Game.using` cleared alone):
// the player's next use, from 0 ticks or in another slot, is still
// not the mod's, and ending it then tells the mod once.
input.start("orbital", stick(0), 0);
input.tick();
input.tick();
assert!(!input.owns(None));
assert!(!input.owns(Some((0, 0))), "a vanilla use from 0 ticks");
assert!(!input.owns(Some((1, 2))), "another slot");
input.end();
input.end();
let events = input.poll("orbital");
assert_eq!(events.len(), 2);
assert_eq!(events[1], NativeModEvent::UseStop(stick(0), 2));
}
}
+12 -186
View File
@@ -298,7 +298,6 @@ use horizon_client::{
keybinds,
mobs::{Damage, Who},
mod_list::{self, Installed, NativePage},
music::{Music, MusicLayer},
particles::{ParticleOptions, ParticleType},
text::Text,
};
@@ -1186,35 +1185,6 @@ impl Game<'_> {
) {
let _ = (context, phase, error_text);
}
/// The server is about to send its data to the players (NeoForge's
/// `OnDatapackSyncEvent`, its `sendRecipes`): push the recipe types
/// (`minecraft:smelting`, `mymod:crusher`) whose recipes your client half
/// needs. Horizon sends their final recipes — the world's data packs
/// merged, load conditions held — to every Horizon client when it joins
/// and again after each `/reload`; a vanilla client is sent nothing new.
/// Asked once per load, when the server's data tables are built, and the
/// same types go to every player: unlike NeoForge, which asks again for
/// each joining player, it can't differ between players. Push nothing and
/// nothing is sent; push nothing after a load that pushed some and the
/// clients go back to their own recipes. Server-side (the single
/// player's integrated server included). API @API:recipe_sync@.
pub(super) fn native_datapack_sync(&mut self, recipe_types: &mut Vec<String>) {
let _ = recipe_types;
}
/// The server's recipes of `recipe_types` arrived (NeoForge's
/// `RecipesReceivedEvent`): from now on the recipe helpers
/// (`native_recipes`, `native_recipes_of_type`) give the server's recipes
/// of those types in place of the window's own, so rebuild what you made
/// of them. It comes after a join, once the window has its player, and
/// after every `/reload`, on a server whose mods asked for them
/// (`native_datapack_sync`), single player included; with no types when
/// the server stopped sending them and the window is back on its own
/// recipes. A recipe the
/// window's own recipe book doesn't know has `book_id` -1. Client-side.
/// API @API:recipe_sync@.
pub(super) fn native_recipes_received(&mut self, recipe_types: &[String]) {
let _ = recipe_types;
}
/// The server's data tables are built (NeoForge's `TagsUpdatedEvent`
/// with `SERVER_DATA_LOAD`): the recipes, tags, loot and advancements of
/// the world's data packs, and the files mods added through
@@ -1353,12 +1323,6 @@ impl Game<'_> {
pub(super) fn native_key(&mut self, key: &str, pressed: bool) {
let _ = (key, pressed);
}
/// A real window key event after handling, including repeats and consumed shortcuts.
/// Receives the original physical/logical key, text, location, state and repeat flag.
/// Modifier bits use the screen hooks' GLFW encoding. Not cancellable.
pub(super) fn native_input_key(&mut self, event: &winit::event::KeyEvent, modifiers: u32) {
let _ = (event, modifiers);
}
/// Raw mouse button input on any screen, before dispatch; cancel skips dispatch and Post.
/// Button/modifiers use the same GLFW encoding as screen hooks. Synthetic clicks skip it.
pub(super) fn native_mouse_button_pre(
@@ -1378,20 +1342,6 @@ impl Game<'_> {
pub(super) fn native_wheel(&mut self, steps: f32, claimed: &mut bool) {
let _ = (steps, claimed);
}
/// Gameplay scrolling after whole steps accumulate, before hotbar/spectator handling.
/// `scroll` is scaled x/y, `accumulated` whole x/y steps, `buttons` left/middle/right,
/// and `cursor` the GUI position. Cancel skips gameplay scrolling; synthetic input skips it.
pub(super) fn native_mouse_scrolling(
&mut self,
scroll: [f64; 2],
accumulated: [i32; 2],
buttons: [bool; 3],
cursor: [f32; 2],
modifiers: u32,
canceled: &mut bool,
) {
let _ = (scroll, accumulated, buttons, cursor, modifiers, canceled);
}
/// A command typed or clicked in chat, without its slash, before the
/// server and Horizon's commands see it: set `ran` when the mod ran it.
pub(super) fn native_command(&mut self, command: &str, ran: &mut bool) {
@@ -1427,18 +1377,6 @@ impl Game<'_> {
) {
let _ = (partial, camera, fog_type, rgb);
}
/// Final world fog, after the computed color hook, ordinary calculations and Horizon's fog
/// option, before upload. Title panoramas skip it. Invalid color or distance values retain the
/// original whole fog.
pub(super) fn native_render_fog(
&self,
partial: f32,
camera: [f64; 3],
fog_type: horizon_client::fog::FogType,
fog: &mut horizon_client::fog::FogData,
) {
let _ = (partial, camera, fog_type, fog);
}
/// The camera shake native mods ask for in this frame, `partial` ticks
/// past the last tick (0 to 1): add your pitch, yaw and roll in degrees
/// and your offset in blocks, along the camera's right, up and back, to
@@ -1476,6 +1414,13 @@ impl Game<'_> {
) {
let _ = (partial, mirrored, entity, distance);
}
/// The values native mods hand their shader packs in this frame,
/// `partial` ticks past the last tick (0 to 1): a pack's WGSL reads
/// them as `frame.mods` (eight `vec4<f32>`). Write the slots your own
/// pack reads; a mod that bundles its pack as `native-<mod id>` is the
/// one that pack is for. Asked once a frame while a pack is on; a value
/// that isn't finite reads as 0 (`native_shader_params_now`). API
/// @API:native_shader_params@.
/// Change the colours of `biome` (`minecraft:plains`) on this client: the
/// grass, foliage (leaves, vines), dry foliage (leaf litter) and water
/// tints, 0xRRGGBB, as its biome file and the colormaps set them. Asked
@@ -1487,36 +1432,6 @@ impl Game<'_> {
pub(super) fn native_biome_colors(&self, biome: &str, colors: &mut BiomeColors) {
let _ = (biome, colors);
}
/// Choose this client's music, once a client tick: `music` and `volume`
/// hold what vanilla chose (`Minecraft.getSituationalMusic` and
/// `getMusicVolume`: the biome's track, the creative or underwater one, a
/// boss's, the menu's; None for silence). Set `music` to a track of your
/// own, a sound event of your content mod's `sounds.json` such as
/// `Music::new("mymod:music.spring", 1200, 6000, false)` (the delays in
/// ticks between songs, and whether it cuts off a different song playing),
/// None to keep vanilla's quiet, or change `volume` (0 to 1) to fade it. API
/// @API:music@.
pub(super) fn native_music(&self, music: &mut Option<Music>, volume: &mut f32) {
let _ = (music, volume);
}
/// The tracks to play over (or instead of) the music on this client,
/// asked once a client tick: push a `MusicLayer` (a sound event, looped,
/// and its volume, 0 to 1) for each. A layer eases to a new volume over up
/// to two seconds, and one left out fades out and stops; layers that
/// first appear in the same tick start together, so list every stem of a
/// piece from the start, the silent ones at volume 0, and raise them (a
/// fuller arrangement as a village grows). Their sound events should be
/// `stream: true` in `sounds.json`. API @API:music@.
pub(super) fn native_music_layers(&self, layers: &mut Vec<MusicLayer>) {
let _ = layers;
}
/// The values native mods hand their shader packs in this frame,
/// `partial` ticks past the last tick (0 to 1): a pack's WGSL reads
/// them as `frame.mods` (eight `vec4<f32>`). Write the slots your own
/// pack reads; a mod that bundles its pack as `native-<mod id>` is the
/// one that pack is for. Asked once a frame while a pack is on; a value
/// that isn't finite reads as 0 (`native_shader_params_now`). API
/// @API:native_shader_params@.
pub(super) fn native_shader_params(&self, partial: f32, params: &mut [[f32; 4]; 8]) {
let _ = (partial, params);
}
@@ -1810,19 +1725,6 @@ impl Game<'_> {
) {
let _ = (screen, button, modifiers, canceled);
}
/// Before a real held-button movement reaches screen drag handlers.
/// `screen.mouse` is the current GUI position; `delta` is its GUI-space movement.
/// Cancel keeps the new cursor position but skips all screen drag handlers.
pub(super) fn native_screen_mouse_dragged_pre(
&self,
screen: &NativeScreenView<'_>,
button: i32,
delta: [f32; 2],
modifiers: u32,
canceled: &mut bool,
) {
let _ = (screen, button, delta, modifiers, canceled);
}
/// The wheel turned over the open screen (`ScreenEvent.MouseScrolled.Pre`,
/// vanilla's `Screen.mouseScrolled`): `screen` is the screen it is,
/// `screen.mouse` where the pointer is (so there is no position of its
@@ -1890,17 +1792,6 @@ impl Game<'_> {
) {
let _ = (screen, key, repeat, modifiers, canceled);
}
/// Before an open screen handles a named key's release (same screen gate as key presses).
/// Cancel bypasses screen handling; physical keys and mapped keyboard hold/toggle state release.
pub(super) fn native_screen_key_released_pre(
&self,
screen: &NativeScreenView<'_>,
key: &str,
modifiers: u32,
canceled: &mut bool,
) {
let _ = (screen, key, modifiers, canceled);
}
/// A character was typed at the open screen
/// (`ScreenEvent.CharacterTyped.Pre`, vanilla's `Screen.charTyped`):
/// `screen` is the screen it is, `c` the character, and `modifiers` GLFW's
@@ -1966,34 +1857,6 @@ impl Game<'_> {
image.height as f32,
]
}
pub(super) fn track_drag_button(&mut self, button: MouseButton, pressed: bool) {
// Any release ends the drag, as vanilla's `MouseHandler.onButton` clears `activeButton`:
// left down, right down, left up sends no drag while right is still held.
self.drag_button = pressed.then_some(button);
}
pub(super) fn screen_mouse_dragged(&self, previous: (f32, f32)) -> bool {
if self.pad_synthetic_input() || self.cursor == previous {
return false;
}
let Some(button) = self
.drag_button
.filter(|b| self.physical_buttons.contains(b))
else {
return false;
};
let Some(screen) = self.screen_view_now() else {
return false;
};
let mut canceled = false;
self.native_screen_mouse_dragged_pre(
&screen,
super::glfw_button(button),
[self.cursor.0 - previous.0, self.cursor.1 - previous.1],
super::glfw_modifiers(self.modifiers),
&mut canceled,
);
canceled
}
/// A screen the native mods' input hooks are for is open: the window has a
/// renderer, `Screen::Playing` is behind it (so not the title screen, nor a
/// book, a sign editor, a server dialog or a prompt drawn over gameplay)
@@ -2041,22 +1904,6 @@ impl Game<'_> {
);
canceled
}
pub(super) fn screen_key_released(&self, key: KeyCode) -> bool {
let Some(name) = keybinds::key_name(key) else {
return false;
};
let Some(screen) = self.screen_view_now() else {
return false;
};
let mut canceled = false;
self.native_screen_key_released_pre(
&screen,
name,
super::glfw_modifiers(self.modifiers),
&mut canceled,
);
canceled
}
/// Text on its way to a text box after the open screen's character hooks
/// (`native_screen_char_typed_pre` per printable character): the cancelled
/// characters are gone and every other one stays, control characters
@@ -2539,25 +2386,6 @@ impl Game<'_> {
pub(super) fn native_player_respawned(&mut self, player: u128, name: &str) {
let _ = (player, name);
}
/// The number of players with the mod container at `pos` of `dimension`
/// open went from `previous` to `viewers` (vanilla's
/// `ContainerOpenersCounter`, as a barrel keeps it): 0 to more than 0 is
/// `onOpen`, back to 0 is `onClose`, any change `openerCountChanged`, so a
/// block can show itself open while `viewers` is not 0. Counted every
/// tick from the players' open menus, so a player who leaves without
/// closing (a lost connection, death, another dimension, out of reach, the
/// block gone) is heard too (`recheckOpeners`). Once for each change, with
/// no player's state swapped in. Server-side.
/// @API:mod_container_viewers@.
pub(super) fn native_mod_container_viewers(
&mut self,
dimension: &str,
pos: [i32; 3],
viewers: u32,
previous: u32,
) {
let _ = (dimension, pos, viewers, previous);
}
/// `player` died: where `ServerPlayer.die` starts, with the death
/// message's key when the game knows it (`death.attack.lava`; none for a
/// message it builds from the combat). It only hears. Server-side, for
@@ -3856,8 +3684,7 @@ impl Game<'_> {
/// nobody uses and outside a world. These are the server's table on the
/// server thread, a dedicated server or a LAN host; a client, the
/// single-player window included, sees only the content mods' recipes, not
/// the world's data packs', unless a mod had the server send it the type
/// (`native_datapack_sync`). API 36.
/// the world's data packs'. API 36.
pub(super) fn native_recipes_of_type(&self, kind: &str) -> &[CustomRecipe] {
if self.outside_world() {
return &[];
@@ -3916,10 +3743,9 @@ impl Game<'_> {
self.player.is_none() && self.options.execution_role.is_client()
}
/// Every item and recipe the game knows, made once (the same for every
/// call); none outside a world. They are the window's own (the content
/// mods' and vanilla's), with the server's recipes of the types a mod had
/// it send (`native_datapack_sync`) in their place; made again when those
/// arrive (`native_recipes_received`).
/// call); none outside a world. They are the window's own: the world's
/// in single player, vanilla's on a server, which sends a client no
/// others.
pub(super) fn native_recipes(&mut self) -> Option<Arc<NativeRecipes>> {
self.player.as_ref()?;
self.made_recipes()
@@ -5175,7 +5001,7 @@ mod tests {
#[test]
fn loot_table_load_stays_hookable() {
let source = include_str!("../../../horizon-world/src/loot.rs");
let source = include_str!("../../../horizon-world/src/loot.rs").replace("\r\n", "\n");
// From its doc comment's last line, so an attribute above it counts.
let hook = source.find("pub fn native_loot_table_load(").unwrap();
let start = source[..hook].rfind("///").unwrap();
@@ -1,10 +1,10 @@
//! Every item and recipe the game knows, made once for the mods that read
//! them (native mods' `native_recipes`, capability `recipes` of WebAssembly
//! mods, docs/mods.md), and the recipe book's known displays; placing a
//! recipe and creative gifts. The recipes are the window's own data — the
//! content root's — with the server's recipes of the types its native mods
//! had it send (`horizon:recipe_content`) in their place, while the known
//! displays follow the server's book as the recipe book does.
//! recipe and creative gifts. The recipes are the window's own data: the
//! world's in single player, vanilla's on a server (a client is sent no
//! others), while the known displays follow the server's book as the recipe
//! book does.
use super::{
Game,
native_hooks::{
@@ -13,7 +13,7 @@ use super::{
},
};
use horizon_client::{
crafting::{Ingredient, recipe_id},
crafting::Ingredient,
items::ItemStack,
recipe_book::{Category, Display},
stations::Smithing,
@@ -22,22 +22,10 @@ use std::{collections::HashMap, sync::Arc};
/// The tables, made once, and the recipe book's known displays with the
/// book's version they are of.
/// The most recipes a server's recipe content may carry.
const MAX_SYNCED_RECIPES: usize = 1_000_000;
#[derive(Default)]
pub(super) struct RecipeTables {
tables: Option<Arc<Recipes>>,
known: (Option<u64>, Arc<[i32]>),
/// The server's recipes of the types its native mods sent (`horizon:
/// recipe_content`) over the window's own: what `Game::recipes` reads on
/// a client once they came.
synced: Option<horizon_client::crafting::Recipes>,
/// The recipe content's parts so far, until its last.
incoming: Vec<(String, serde_json::Value)>,
/// The types that came before the player did: the mods hear of them
/// once the helpers can answer (`recipes_received_now`).
received: Option<Vec<String>>,
}
/// Item id -> its index in [`Recipes::items`].
@@ -199,10 +187,11 @@ impl Game<'_> {
);
let mut out = Vec::new();
let cooking: HashMap<String, (u32, f32)> = self
.res
.recipes()
.cooking
.iter()
.map(|c| (recipe_id(&c.id), (c.time, c.experience)))
.map(|c| (format!("minecraft:{}", c.id), (c.time, c.experience)))
.collect();
for e in self.recipe_book_entries() {
let furnace = |c: Category| match c.name().split('_').next() {
@@ -254,7 +243,7 @@ impl Game<'_> {
}
for c in &self.recipes().cooking {
if c.kind == "minecraft:campfire_cooking" {
let mut r = recipe(recipe_id(&c.id), RecipeKind::Campfire);
let mut r = recipe(format!("minecraft:{}", c.id), RecipeKind::Campfire);
r.group = c.group.clone();
r.slots = vec![index.any(&c.ingredient)];
r.results = index.of(&c.result).into_iter().collect();
@@ -331,107 +320,3 @@ impl Game<'_> {
}
}
}
impl Game<'_> {
/// The server's recipes a client was sent, over its own; None on a
/// server and until a server sends any.
pub(super) fn synced_recipes(&self) -> Option<&horizon_client::crafting::Recipes> {
self.recipe_tables.synced.as_ref()
}
/// A new play session (`Login`): what the last server sent is gone.
pub(super) fn forget_synced_recipes(&mut self) {
self.recipe_tables.synced = None;
self.recipe_tables.incoming.clear();
self.recipe_tables.received = None;
self.recipe_tables.tables = None;
self.forget_book();
}
/// `native_recipes_received` for the types that came, once the window
/// has its player: the recipes of a join come before it, and the helpers
/// answer nothing until then. Once a frame.
pub(super) fn recipes_received_now(&mut self) {
if self.player.is_some()
&& let Some(types) = self.recipe_tables.received.take()
{
self.native_recipes_received(&types);
}
}
/// `OnDatapackSyncEvent.sendRecipes`: the recipe types the native mods
/// want their clients to have, from this load's tables, become what the
/// server sends its Horizon clients at a join and after `/reload`. Called
/// once a load's tables are built, before any player hears of them.
pub(super) fn sync_recipe_content(&mut self) {
use horizon_client::lan::recipes;
let mut types = Vec::new();
self.native_datapack_sync(&mut types);
types.sort();
types.dedup();
let sources = if types.is_empty() {
Vec::new()
} else {
horizon_client::crafting::Recipes::sources(&self.options.resources, &self.pack_files())
.unwrap_or_else(|e| {
log::warn!("Horizon: the recipes for the clients were not read: {e}");
Vec::new()
})
};
recipes::set_content(recipes::encode_content(
&types,
&sources,
recipes::sent_before(),
));
}
/// A `horizon:recipe_content` part the server sent the window. On the
/// last, the window's tables take the server's recipes of those types in
/// place of its own, and the mods hear of it (`RecipesReceivedEvent`).
pub(super) fn remote_recipe_content(&mut self, data: &[u8]) {
let part: horizon_client::lan::recipes::ContentPart = match serde_json::from_slice(data) {
Ok(part) => part,
Err(e) => {
log::warn!("Horizon: bad recipe content from the server: {e}");
return;
}
};
self.recipe_tables.incoming.extend(part.recipes);
// The server is trusted to end its parts; one that never does is
// dropped, not kept growing.
if self.recipe_tables.incoming.len() > MAX_SYNCED_RECIPES {
log::warn!("Horizon: the server sent more than {MAX_SYNCED_RECIPES} recipes; dropped");
self.recipe_tables.incoming = Vec::new();
return;
}
let Some(types) = part.last else {
return;
};
let received = std::mem::take(&mut self.recipe_tables.incoming);
// No type any more: back on the window's own recipes.
if types.is_empty() {
if self.recipe_tables.synced.take().is_some() {
self.recipe_tables.tables = None;
self.forget_book();
self.recipe_tables.received = Some(types);
self.recipes_received_now();
}
return;
}
let root = &self.options.resources;
let overlay = horizon_world::loot::PackOverlay::NONE;
let built = horizon_client::crafting::Recipes::sources(root, &overlay).and_then(|own| {
let of = |v: &serde_json::Value| types.iter().any(|t| v["type"].as_str() == Some(t));
let mut all: Vec<_> = own.into_iter().filter(|(_, v)| !of(v)).collect();
all.extend(received);
horizon_client::crafting::Recipes::from_sources(root, self.items(), &overlay, all)
});
match built {
Ok(recipes) => {
self.recipe_tables.synced = Some(recipes);
// What was made of the window's own recipes is made again.
self.recipe_tables.tables = None;
self.forget_book();
self.recipe_tables.received = Some(types);
self.recipes_received_now();
}
Err(e) => log::warn!("Horizon: the server's recipes were not taken: {e}"),
}
}
}
@@ -628,8 +628,7 @@ fn add_to(
/// screen as any change to their inventory does.
#[allow(dead_code)]
impl Game<'_> {
/// The main hand's stack: its item, count, and whether it is plain. On a
/// client, the player's own.
/// The main hand's stack: its item, count, and whether it is plain.
pub(super) fn native_held(&self) -> Option<(String, u32, bool)> {
let s = self.inventory.get(self.selected)?.as_ref()?;
Some((s.item.to_string(), s.count, plain(s)))
-31
View File
@@ -148,23 +148,6 @@ impl Game<'_> {
self.ui.vanilla_root.clone()
}
}
/// Whether all active packs preserve vanilla's block models. The server
/// overlay sits above the local selection and must be included too.
pub(super) fn model_packs_are_vanilla(&self) -> bool {
self.settings.horizon.vanilla_models_with_server_packs(
self.settings.resource_packs.iter().map(String::as_str),
self.server_packs.active.iter().map(|pack| pack.id.as_str()),
)
}
/// The leaf mode that is safe for the current local and server packs.
pub(super) fn effective_leaf_culling(&self) -> horizon_client::graphics::LeafCullingMode {
self.settings
.horizon
.leaf_culling_effective_with_server_packs(
self.settings.resource_packs.iter().map(String::as_str),
self.server_packs.active.iter().map(|pack| pack.id.as_str()),
)
}
/// The packs the pack screen offers and the current selection
/// (`PackSelectionScreen` makes its `PackSelectionModel` from them).
pub(super) fn refresh_pack_lists(&mut self) {
@@ -372,25 +355,11 @@ impl Game<'_> {
.cem
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = cem;
// The random entity textures the new atlas packs: every mob chooses again.
*self
.res
.mob_data()
.random_textures
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) =
std::mem::take(&mut self.gui.atlas.random_textures);
for m in &mut self.mobs {
m.random_textures.forget_picks();
}
horizon_client::ui::load_metadata(&root);
lang::set(lang::Language::load(&[&root], &lang::code()));
self.refresh_remote_language();
self.audio.reload(&root);
self.clear_lod();
// A pack that may replace leaf models falls back to `Off` before the
// reload marks every section dirty.
self.streaming.leaf_culling = self.effective_leaf_culling();
self.streaming.reload_resources(&mut self.world, context);
self.pending_uploads.clear();
// New GPU textures: a renderer built from the reloaded resources; the
@@ -1416,7 +1416,6 @@ mod preview_tests {
entity_tags: Default::default(),
villager_hats: Default::default(),
cem: Default::default(),
random_textures: Default::default(),
pois: Default::default(),
species_placements: Default::default(),
};
+22 -42
View File
@@ -7,8 +7,8 @@ use horizon_client::{
mobs::{
Mob, Species, Who,
finalize::{
SLOT_NAMES, item_stack, set_drop_chance, set_item, set_item_stack,
set_slot_enchantments, slot_enchantments, stored_drop_chance,
SLOT_NAMES, set_drop_chance, set_item, set_slot_enchantments, slot_enchantments,
stored_drop_chance,
},
piglin::{ADMIRE, ATE_RECENTLY},
},
@@ -27,9 +27,8 @@ impl Game<'_> {
self.item_in_tag("minecraft:piglin_loved", item)
}
/// `PiglinAi.isLovedItem` or a preferred weapon (`Piglin.canReplaceCurrentItem`).
fn piglin_wanted(&self, m: &Mob, item: &str) -> bool {
self.piglin_loved(item)
|| !m.is_baby() && self.item_in_tag("minecraft:piglin_preferred_weapons", item)
fn piglin_wanted(&self, item: &str) -> bool {
self.piglin_loved(item) || self.item_in_tag("minecraft:piglin_preferred_weapons", item)
}
/// The `SLOT_NAMES` index `getEquipmentSlotForItem` picks: armour goes
/// to its slot, anything else to the main hand.
@@ -51,7 +50,7 @@ impl Game<'_> {
let Some(current) = horizon_client::mobs::finalize::item(m, slot) else {
return true;
};
self.piglin_wanted(m, item) && !self.piglin_wanted(m, &current)
self.piglin_wanted(item) && !self.piglin_wanted(&current)
}
/// `SimpleContainer.canAddItem` on the piglin's eight slots.
fn piglin_can_add(&self, m: &Mob, stack: &ItemStack) -> bool {
@@ -63,13 +62,11 @@ impl Game<'_> {
fn piglin_max_stack(&self, item: &str) -> u32 {
self.items().get(item).map_or(64, |i| i.max_stack)
}
/// `PiglinAi.wantsToPickup`: babies ignore their own item tag, but can
/// pick up currency and other loved items just like adults.
/// `PiglinAi.wantsToPickup` (an adult that is not hurt-shy), without
/// the baby rule, which babies skip entirely here.
fn piglin_wants(&self, m: &Mob, stack: &ItemStack) -> bool {
let item = &*stack.item;
if m.is_baby() && self.item_in_tag("minecraft:ignored_by_piglin_babies", item)
|| self.item_in_tag("minecraft:piglin_repellents", item)
{
if self.item_in_tag("minecraft:piglin_repellents", item) {
return false;
}
let holds_loved = m
@@ -194,7 +191,8 @@ impl Game<'_> {
}
for i in 0..self.mobs.len() {
let m = &self.mobs[i];
if m.species() != Species::Piglin || !m.alive() || m.extra.using_item > 0 {
if m.species() != Species::Piglin || !m.alive() || m.is_baby() || m.extra.using_item > 0
{
continue;
}
let pos = m.position;
@@ -304,8 +302,8 @@ impl Game<'_> {
true
}
/// `PiglinAi.stopHoldingOffHandItem` after admiring: gold ingots buy a
/// roll of the bartering table for adults. Babies equip the admired item,
/// or hold it in the main hand after stashing or throwing the old item.
/// roll of the bartering table, thrown toward the player; any other loved
/// item it picked up is kept (worn if it can be, else stored).
pub(super) fn finish_admiring(&mut self, mob: u32) {
let Some(i) = self.mobs.iter().position(|m| m.id == mob) else {
return;
@@ -313,40 +311,22 @@ impl Game<'_> {
let Some(item) = self.mobs[i].extra.off_hand.take() else {
return;
};
if self.mobs[i].is_baby() {
let stack = ItemStack::new(&item, 1);
if !self.piglin_equip(i, &stack) {
if let Some(old) = item_stack(&self.mobs[i], 0) {
if self.piglin_loved(&old.item) {
self.piglin_stash(i, old);
} else {
self.piglin_throw_items(i, vec![old]);
}
}
let m = &mut self.mobs[i];
set_item_stack(m, 0, stack);
set_drop_chance(m, 0, 2.0);
m.persistent = true;
}
return;
}
if &*item != "minecraft:gold_ingot" {
self.piglin_keep(i, ItemStack::new(&item, 1));
return;
}
let items = world_items!(self).loot_table(
"minecraft:gameplay/piglin_bartering",
false,
None,
&mut self.world.random,
);
self.piglin_throw_items(i, items);
}
/// `PiglinAi.throwItems`: barters and a baby's unwanted main-hand item
/// use the same player-directed throw.
fn piglin_throw_items(&mut self, i: usize, items: Vec<ItemStack>) {
let m = &self.mobs[i];
let from = [m.position.x, m.position.y + 1.3, m.position.z];
let items = if !m.is_baby() {
world_items!(self).loot_table(
"minecraft:gameplay/piglin_bartering",
false,
None,
&mut self.world.random,
)
} else {
vec![ItemStack::new(&item, 1)]
};
// BehaviorUtils.throwItem toward the nearest visible player
// (NEAREST_VISIBLE_PLAYER: within 16, alive, no spectators; the
// guests count as much as the host), else nearby.
@@ -507,12 +507,6 @@ impl Game<'_> {
)
.unwrap_or(Vec3::ZERO)
});
// Entity.move updates an authoritative living entity's ground flag
// even for a horizontal piston push. Keeping the old support flag
// would run Frost Walker while the stand is moving over open water.
if let Target::ArmorStand(i) = target {
self.armor_stands[i].on_ground = limited.y < 0.0 && limited.y != resolved.y;
}
if resolved.length_squared() <= 1.0e-7
&& limited.length_squared() - resolved.length_squared() >= 1.0e-7
{
@@ -1140,15 +1140,6 @@ impl Game<'_> {
});
self.potions.thrown = thrown;
for (stack, at, mob, block, age, owner) in landed {
// Projectile.onHit (super.onHit of AbstractThrownPotion): a block
// is heard at its centre, an entity at the hit.
let heard = if mob.is_some() {
[at.x, at.y, at.z]
} else {
[at.x.floor() + 0.5, at.y.floor() + 0.5, at.z.floor() + 0.5]
};
let thrower = owner.is_some().then_some(Who::Player);
self.projectile_event("minecraft:projectile_land", thrower, heard);
self.splash(&stack, at, mob, block, age, owner);
}
}
@@ -1,75 +0,0 @@
//! The window's side of the RAM limit (`horizon_client::ram_limit`): a look at the memory once a
//! second, the render distance and far terrain in use, and `/ramlimit`'s answer.
use super::Game;
use horizon_client::ram_limit::{self, Step};
use horizon_client::text::Text;
use std::time::Instant;
impl Game<'_> {
/// The render distance in use: the player's, unless memory holds it lower.
pub(super) fn render_distance_in_use(&self) -> i32 {
self.ram_limit.distance(self.settings.render_distance)
}
/// Once a second, on the window's game while a world is open.
pub(super) fn tick_ram_limit(&mut self) {
if !self.ticks.is_multiple_of(20) {
return;
}
let chosen = self.settings.render_distance;
let Some(limit) = ram_limit::limit() else {
if self.ram_limit.reset() {
self.apply_render_distance_in_use();
}
return;
};
let has_far = self.settings.horizon.lod_distance > 0;
let pressure = ram_limit::pressure(limit, self.fps);
let step = self
.ram_limit
.step(Instant::now(), pressure, chosen, has_far);
if !self.ram_limit.at_lowest(chosen, has_far) {
ram_limit::can_shrink();
}
let Some(step) = step else {
return;
};
self.apply_render_distance_in_use();
let said = match step {
Step::FarTerrainOff => "Far terrain is off to stay under the RAM limit.".to_owned(),
Step::FarTerrainBack => "Far terrain is back on.".to_owned(),
Step::Lowered(d) => {
format!("Render distance lowered to {d} to stay under the RAM limit.")
}
Step::Raised(d) if d >= chosen => format!("Render distance is back to {d}."),
// Going back up step by step needs no line for each step.
Step::Raised(_) => return,
};
log::info!("Horizon: {said} {pressure:?}");
self.system_message(Text::literal(said.as_str()));
}
/// The streaming radius follows the distance in use, within what the server sends; the server
/// hears of it with the next `ClientInformation` or `set_distances`.
fn apply_render_distance_in_use(&mut self) {
let distance = self.render_distance_in_use();
self.streaming.radius = match &self.remote {
Some(r) if r.logged_in => distance.min(r.view_distance + 1).max(2),
_ => distance,
};
}
/// `/ramlimit`, answered here on any server. False: another command.
pub(super) fn ram_limit_command(&mut self, command: &str) -> bool {
let Some(answer) = ram_limit::run(command) else {
return false;
};
self.system_message(Text::literal(answer.as_str()));
let (in_use, chosen) = (self.render_distance_in_use(), self.settings.render_distance);
if in_use < chosen {
let line = format!("Render distance is {in_use} for now; yours is {chosen}.");
self.system_message(Text::literal(line.as_str()));
}
true
}
}
Loaded 100 of 313 files, more files were not shown because too many files have changed in this diff. Show more