mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge remote-tracking branch 'origin/main' into feat/launcher-night-shell
This commit is contained in:
commit
fe89da2a2c
464 files changed
+20386
-14768
No files matched your search
@@ -51,7 +51,7 @@ jobs:
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
rustup toolchain install stable --profile minimal --component rustfmt --component clippy --target wasm32-unknown-unknown
|
||||
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
@@ -62,7 +62,6 @@ jobs:
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
cargo +stable fmt --all -- --check
|
||||
# With the game's code mods (on in every release build, off by default).
|
||||
cargo +stable clippy --workspace --all-targets -- -D warnings
|
||||
# The server build (released as horizon-engine): the game without sound or controllers.
|
||||
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
|
||||
@@ -71,11 +70,11 @@ jobs:
|
||||
- name: Test Rust core and voxel kernel correctness
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
cargo +stable test --workspace --lib --bins --tests --features horizon-mods/pack
|
||||
cargo +stable test --workspace --lib --bins --tests
|
||||
# The full content-root tests (#[ignore]d: minutes each on a laptop). The same build as above; they need the
|
||||
# prepared vanilla data, so without it they pass at once.
|
||||
cargo +stable test --workspace --lib --features horizon-mods/pack mod_content::tests:: -- --ignored
|
||||
cargo +stable test --workspace --doc --features horizon-mods/pack
|
||||
cargo +stable test --workspace --lib mod_content::tests:: -- --ignored
|
||||
cargo +stable test --workspace --doc
|
||||
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
|
||||
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
|
||||
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
|
||||
|
||||
@@ -22,7 +22,9 @@ jobs:
|
||||
if: >-
|
||||
github.event_name == 'issue_comment' && github.event.comment.user.type != 'Bot' && (
|
||||
startsWith(github.event.comment.body, '/claim') ||
|
||||
startsWith(github.event.comment.body, '/unclaim'))
|
||||
startsWith(github.event.comment.body, '/unclaim') ||
|
||||
startsWith(github.event.comment.body, '🤖 Agent: /claim') ||
|
||||
startsWith(github.event.comment.body, '🤖 Agent: /unclaim'))
|
||||
# One command per issue at a time, so two `/claim`s can't both find it free. On the job, not the workflow:
|
||||
# the skipped runs of ordinary comments must not cancel a waiting one.
|
||||
concurrency:
|
||||
|
||||
@@ -53,7 +53,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-server` | `horizon-server`: `server.toml`, CLI, supervisor of the headless game ([docs/hosting.md](docs/hosting.md)) |
|
||||
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
|
||||
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
|
||||
| `horizon-mods`, `horizon-mod-sdk`, `cargo-horizon-mod` | Sandboxed WebAssembly mods: runtime, SDK + WIT, `cargo horizon-mod` ([docs/mods.md](docs/mods.md)) |
|
||||
| `horizon-mods`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) |
|
||||
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
|
||||
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
|
||||
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
|
||||
@@ -73,7 +73,7 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
|
||||
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 --features horizon-client/mods --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 --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
|
||||
@@ -93,9 +93,6 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
- 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.
|
||||
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
|
||||
- **macOS:** `cargo build/test --workspace` may fail to link the two wasm guest cdylibs (undefined
|
||||
`horizon:mods/events` symbols) until #1446 lands: add `--exclude coords-hud --exclude predator-missile --exclude horizon-mods-test-guest --exclude horizon-mods-test-ui-guest`.
|
||||
Their real check is `cargo test -p horizon-mods` (builds them for `wasm32-unknown-unknown`).
|
||||
- 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):
|
||||
`target/fast/horizon-ctl --socket $PWD/ctl.sock launch --world W --creative --offscreen --wait --resources $PWD/.local/horizon-client/26.3`,
|
||||
|
||||
Generated
+5
-815
File diff suppressed because it is too large.
Load diff
-13
@@ -23,26 +23,13 @@ members = [
|
||||
"crates/horizon-bench",
|
||||
"crates/horizon-friends",
|
||||
"crates/horizon-relay",
|
||||
"crates/horizon-mod-sdk",
|
||||
"crates/horizon-mods",
|
||||
"crates/horizon-mods/tests/guest",
|
||||
"crates/horizon-mods/tests/ui_guest",
|
||||
"crates/cargo-horizon-mod",
|
||||
"crates/horizon-port-pack",
|
||||
"crates/horizon-shaders",
|
||||
"crates/horizon-shader-api",
|
||||
"crates/horizon-release-sign",
|
||||
"crates/horizon-mod-sdk/examples/coords-hud",
|
||||
"crates/horizon-mod-sdk/examples/predator-missile",
|
||||
"crates/horizon-mod-sdk/examples/welcome",
|
||||
"crates/horizon-mod-sdk/examples/beacons",
|
||||
"mods/official/fps-overlay",
|
||||
"mods/official/keystrokes",
|
||||
"mods/official/far-view",
|
||||
"mods/official/minimap",
|
||||
"mods/official/zoom",
|
||||
"xtask",
|
||||
"mods/official/recipes",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<img src="https://img.shields.io/badge/Minecraft-Java%2026.3-5D9C3F" alt="Minecraft Java 26.3">
|
||||
<img src="https://img.shields.io/badge/Rust-stable-B7410E?logo=rust&logoColor=white" alt="Rust">
|
||||
<img src="https://img.shields.io/badge/vanilla%20parity-100%25-F0795A" alt="Vanilla parity 100%">
|
||||
<a href="#project-status"><img src="https://img.shields.io/badge/player--visible%20gaps-833%20open-F0795A" alt="Player-visible gaps open"></a>
|
||||
<img src="https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-2A2470" alt="Linux, macOS and Windows">
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-3B2F7E" alt="MIT license"></a>
|
||||
</p>
|
||||
@@ -48,7 +49,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
|
||||
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
|
||||
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates, item modifiers and world generation (vanilla's heights and biomes for now).
|
||||
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Mods:** Horizon has its own mods, installed from the launcher. The official ones (Zoom, FPS Counter, Keystrokes and Minimap) are native Rust that the launcher builds into your game, with no Rust to install; older WebAssembly mods still run while they move. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
- **Mods:** content mods add blocks, items and data, and native mods change the game's own Rust code, built by the launcher on your copy, with no Rust to install. Horizon's own (Zoom, FPS Counter, Keystrokes, Minimap, Recipes and Coordinates HUD) are native mods. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
|
||||
Screenshots, captured in the Rust client itself with vanilla assets and no mods or shaders:
|
||||
|
||||
@@ -127,6 +128,8 @@ It runs rustfmt, clippy with warnings denied, the tests of the crates you touche
|
||||
|
||||
## Project status
|
||||
|
||||
**Player-visible gaps: 833 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Screens, HUD and client packets [197](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [129](https://github.com/horizon-rust/horizon/issues/3519); Animals [110](https://github.com/horizon-rust/horizon/issues/3512); Server and players on the server [106](https://github.com/horizon-rust/horizon/issues/3520); Blocks, redstone and fluids [86](https://github.com/horizon-rust/horizon/issues/3516); Entities, projectiles, vehicles and decorations [64](https://github.com/horizon-rust/horizon/issues/3514); Monsters and bosses [53](https://github.com/horizon-rust/horizon/issues/3513); Mob AI, villagers and raids [35](https://github.com/horizon-rust/horizon/issues/3521); World, world generation and saves [32](https://github.com/horizon-rust/horizon/issues/3517); Items, enchantments and inventories [21](https://github.com/horizon-rust/horizon/issues/3515). This number goes down as gaps close (`tools/parity_gaps.py`); the harness results below say that what is tested matches.
|
||||
|
||||
The table below is the project's own audit of each subsystem. Every subsystem now matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not proof that nothing else differs. The remaining known differences are listed below.
|
||||
|
||||
### Java parity: 100%
|
||||
|
||||
+9
-9
@@ -76,7 +76,7 @@ Owner: [levzzz5154](https://github.com/levzzz5154) · Tracking: [#3911](https://
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
One item for all modding work ([#3911](https://github.com/horizon-rust/horizon/issues/3911)), led by levzzz5154 with vladislav_hagen; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)) built by the launcher ([#3388](https://github.com/horizon-rust/horizon/issues/3388), [#3855](https://github.com/horizon-rust/horizon/issues/3855)); server-side hooks ([#3878](https://github.com/horizon-rust/horizon/issues/3878), [#3706](https://github.com/horizon-rust/horizon/issues/3706)); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry ([#1634](https://github.com/horizon-rust/horizon/issues/1634), [#1642](https://github.com/horizon-rust/horizon/issues/1642), [#1659](https://github.com/horizon-rust/horizon/issues/1659)); machines with inventories and menus ([#1189](https://github.com/horizon-rust/horizon/issues/1189)); mods that read and change the world through the server. The WebAssembly runtime is being removed ([#3351](https://github.com/horizon-rust/horizon/issues/3351), [#3353](https://github.com/horizon-rust/horizon/issues/3353)). [docs/native-mods.md](docs/native-mods.md).
|
||||
One item for all modding work ([#3911](https://github.com/horizon-rust/horizon/issues/3911)), led by levzzz5154 with vladislav_hagen; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)) built by the launcher ([#3388](https://github.com/horizon-rust/horizon/issues/3388), [#3855](https://github.com/horizon-rust/horizon/issues/3855)); server-side hooks ([#3878](https://github.com/horizon-rust/horizon/issues/3878), [#3706](https://github.com/horizon-rust/horizon/issues/3706)); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry ([#1634](https://github.com/horizon-rust/horizon/issues/1634), [#1642](https://github.com/horizon-rust/horizon/issues/1642), [#1659](https://github.com/horizon-rust/horizon/issues/1659)); machines with inventories and menus ([#1189](https://github.com/horizon-rust/horizon/issues/1189)); mods that read and change the world through the server. The WebAssembly runtime is removed ([#3350](https://github.com/horizon-rust/horizon/issues/3350), [#3351](https://github.com/horizon-rust/horizon/issues/3351), [#3353](https://github.com/horizon-rust/horizon/issues/3353)). [docs/native-mods.md](docs/native-mods.md).
|
||||
|
||||
More: [#3360](https://github.com/horizon-rust/horizon/issues/3360) · [#1596](https://github.com/horizon-rust/horizon/issues/1596) · [#1635](https://github.com/horizon-rust/horizon/issues/1635)
|
||||
|
||||
@@ -350,7 +350,7 @@ Run Java mods that nobody has rewritten for Horizon yet, through a bridge or a c
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Earlier research ([#527](https://github.com/horizon-rust/horizon/issues/527), closed) found that Java mods can't simply run, because they call Minecraft's own classes. Options to look at again: a jar-to-WebAssembly backend, or a bridge for simple Fabric mods. Whatever is chosen has to run inside the same sandbox and capability checks as native mods.
|
||||
Earlier research ([#527](https://github.com/horizon-rust/horizon/issues/527), closed) found that Java mods can't simply run, because they call Minecraft's own classes. Option to look at again: a bridge for simple Fabric mods.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -370,7 +370,7 @@ When a mod, shader pack or modpack hasn't been rewritten for Horizon, one button
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Generalise the mod browser's Port step (`horizon-port-pack`: the data, assets, worldgen and block and item definitions of Java mods, .mrpack and CurseForge zips) to every kind of content: code mods where the API allows it (see 'Mods with code') and shader packs, converting an Iris/OptiFine pack into Horizon's format. The browser says what works before the install. Java code itself does not run ([docs/modpacks.md](docs/modpacks.md), [docs/mod-browser.md](docs/mod-browser.md)).
|
||||
Generalise the mod browser's Port step (`horizon-port-pack`: the data, assets, worldgen and block and item definitions of Java mods, .mrpack and CurseForge zips) to every kind of content: code mods as native mods (see 'Mods with code') and shader packs, converting an Iris/OptiFine pack into Horizon's format. The browser says what works before the install. Java code itself does not run ([docs/modpacks.md](docs/modpacks.md), [docs/mod-browser.md](docs/mod-browser.md)).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -380,7 +380,7 @@ Making mods that add machines, magic and mobs work in Horizon, in steps, startin
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/horizon-rust/horizon/issues/1854) ranks the API gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path and a furnace ported tick for tick as proof. The first API step is a draft ([#1189](https://github.com/horizon-rust/horizon/issues/1189), see 'Machines and blocks with inventories'). The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
|
||||
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/horizon-rust/horizon/issues/1854) ranks the gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path. Code mods are native mods now ([docs/native-mods.md](docs/native-mods.md)): each port is a horizon-mixins bundle. The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -490,7 +490,7 @@ Small mods written as scripts, in the style of KubeJS, with no compiler.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Waits for native mods to settle: the earlier plan ran Lua inside the WebAssembly sandbox, which is being removed. A script layer over the native hook points is to be designed.
|
||||
Waits for native mods to settle: the earlier plan ran Lua inside the WebAssembly sandbox, which is removed. A script layer over the native hook points is to be designed.
|
||||
|
||||
More: [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
@@ -498,11 +498,11 @@ More: [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
#### Orbital Laser on the normal game
|
||||
|
||||
The community mod Orbital Laser, made for a fork of Horizon, running on the normal game and listed in the registry as the showcase of the mod API.
|
||||
The community mod Orbital Laser, made for a fork of Horizon, running on the normal game as a native mod and listed in the registry.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A pull request on [https://github.com/Stink-O/orbital-laser](https://github.com/Stink-O/orbital-laser) (MIT) once the input, ui, render and world capabilities exist (see 'Screens, input and drawing for mods'): today the mod needs them through its author's fork. Tested in the game, and listed in the registry.
|
||||
A native port (a horizon-mixins bundle, docs/native-mods.md) of [https://github.com/Stink-O/orbital-laser](https://github.com/Stink-O/orbital-laser) (MIT), which today runs through its author's fork. Tested in the game, and listed in the registry.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -524,7 +524,7 @@ Contraptions and physics like the Create and Aeronautics mods, as a mod for Hori
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#805](https://github.com/horizon-rust/horizon/issues/805) (porting QOL mods) closed with this answer: it would come as a native Horizon mod on the mod API. It needs server mods, entities and rendering first.
|
||||
Issue [#805](https://github.com/horizon-rust/horizon/issues/805) (porting QOL mods) closed with this answer: it would come as a native Horizon mod.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -534,7 +534,7 @@ Hear nearby players talk, as with the voice chat mods of other Minecraft version
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Needs server mods, mod networking and an audio interface in the mod API before anything can be built ([docs/api/mods.md](docs/api/mods.md)).
|
||||
A native mod ([docs/native-mods.md](docs/native-mods.md)). It needs a way to carry voice between players and an audio path in the game before anything can be built.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@ version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.86"
|
||||
description = "cargo horizon-mod new/build/run/check: make Horizon mods in Rust (docs/mods.md)"
|
||||
description = "cargo horizon-mod new/build/run/check: make Horizon content mods (docs/mods.md)"
|
||||
|
||||
[dependencies]
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["pack", "runtime", "index"] }
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
|
||||
serde_json = "1.0.151"
|
||||
|
||||
[lints]
|
||||
|
||||
@@ -1,35 +1,19 @@
|
||||
//! `cargo horizon-mod check <file>`: what the registry checks of a submission,
|
||||
//! run anywhere: its kind's own checks (docs/api/mod-index.md), its signature
|
||||
//! when given a key, and for a mod a trial run in the sandbox: it must start,
|
||||
//! survive ticks, frames, a chat line and each of its commands (a server mod:
|
||||
//! a player joining, chatting, typing them and leaving), and import no more
|
||||
//! than its capabilities.
|
||||
//! when given a key, and for a `.hmod` that it is content only.
|
||||
use horizon_mods::{
|
||||
Clock, GameMode, Mods, Output, Package, Player, ServerPlayer, Snapshot, Status,
|
||||
Package,
|
||||
index::{self, Facts, Kind},
|
||||
manifest::component_imports,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::Path;
|
||||
|
||||
/// How long the trial run plays: two seconds of game.
|
||||
const TICKS: u64 = 40;
|
||||
const FRAMES_PER_TICK: u64 = 3;
|
||||
|
||||
pub struct Report {
|
||||
pub kind: Kind,
|
||||
pub facts: Facts,
|
||||
pub sha256: String,
|
||||
pub size: u64,
|
||||
pub signed: bool,
|
||||
/// A mod has code to run (a content mod may have none).
|
||||
pub code: bool,
|
||||
/// A mod's component imports.
|
||||
pub imports: Vec<String>,
|
||||
/// A mod's commands, each run once.
|
||||
pub commands: Vec<String>,
|
||||
/// What the mod showed during the trial run.
|
||||
pub output: Vec<String>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
@@ -53,10 +37,6 @@ impl Report {
|
||||
"sha256": self.sha256,
|
||||
"size": self.size,
|
||||
"signed": self.signed,
|
||||
"code": self.code,
|
||||
"imports": self.imports,
|
||||
"commands": self.commands,
|
||||
"output": self.output,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -100,18 +80,8 @@ impl Report {
|
||||
if self.kind == Kind::NativeMod {
|
||||
out += " native source: unsandboxed; not executed by the registry check. Compile and test against the exact pinned Horizon and Mixins revisions before review.\n";
|
||||
}
|
||||
if self.kind == Kind::Mod && !self.code {
|
||||
if self.kind == Kind::Mod {
|
||||
out += " no code: content only, nothing to run\n";
|
||||
} else if self.kind == Kind::Mod {
|
||||
let commands = if self.commands.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(", /{}", self.commands.join(", /"))
|
||||
};
|
||||
out += &format!(
|
||||
" trial run: started, {TICKS} ticks, {} frames, a chat line{commands}: ok\n",
|
||||
TICKS * FRAMES_PER_TICK
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -153,145 +123,26 @@ pub fn check(
|
||||
(None, None) => false,
|
||||
_ => return Err("--key and --signature go together".into()),
|
||||
};
|
||||
let mut report = Report {
|
||||
if kind == Kind::Mod {
|
||||
content_only(path)?;
|
||||
}
|
||||
Ok(Report {
|
||||
kind,
|
||||
facts,
|
||||
sha256: index::sha256_file(path)?,
|
||||
size,
|
||||
signed,
|
||||
code: false,
|
||||
imports: Vec::new(),
|
||||
commands: Vec::new(),
|
||||
output: Vec::new(),
|
||||
};
|
||||
if report.kind == Kind::Mod {
|
||||
trial_run(path, &mut report)?;
|
||||
}
|
||||
Ok(report)
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs a mod in the sandbox, as the game does, from a scratch copy so its
|
||||
/// storage lands in a folder removed afterwards. A content mod has no code to
|
||||
/// run: `inspect` already read its content as the game does.
|
||||
fn trial_run(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
let package = Package::read(path)?;
|
||||
let Some(wasm) = package.wasm.as_deref() else {
|
||||
return Ok(());
|
||||
};
|
||||
report.code = true;
|
||||
report.imports = component_imports(wasm)?;
|
||||
package
|
||||
.manifest
|
||||
.check_imports(report.imports.iter().map(String::as_str))?;
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mod-check-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let copy = dir.join("mod.hmod");
|
||||
let result = std::fs::copy(path, ©)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|_| play(©, report));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
result
|
||||
}
|
||||
|
||||
fn play(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
let mut mods = Mods::load(&[path.to_path_buf()], |_| true, |_| false);
|
||||
let failed = |mods: &Mods| match &mods.entries()[0].status {
|
||||
Status::Failed(reason) => Some(reason.clone()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(reason) = failed(&mods) {
|
||||
return Err(format!("it didn't start: {reason}"));
|
||||
}
|
||||
let client: Vec<String> = mods.commands().map(str::to_owned).collect();
|
||||
let server: Vec<String> = mods.server_commands().map(|c| c.name.clone()).collect();
|
||||
report.commands = [client.clone(), server.clone()].concat();
|
||||
// A server mod plays with one player on, who joins, chats and leaves.
|
||||
let steve = ServerPlayer {
|
||||
id: "00000000-0000-4000-8000-000000000001".into(),
|
||||
name: "Steve".into(),
|
||||
x: 0.5,
|
||||
y: 64.0,
|
||||
z: 0.5,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
game_mode: GameMode::Survival,
|
||||
health: 20.0,
|
||||
};
|
||||
let snapshot = |t: u64| Snapshot {
|
||||
player: Some(Player {
|
||||
x: 0.5 + t as f64 * 0.1,
|
||||
y: 64.0,
|
||||
z: 0.5,
|
||||
yaw: 90.0,
|
||||
pitch: 0.0,
|
||||
health: 20.0,
|
||||
food: 20,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
}),
|
||||
clock: Some(Clock {
|
||||
game_time: t,
|
||||
day_time: 1000 + t,
|
||||
}),
|
||||
players: vec![steve.clone()],
|
||||
// The GUI of a 1280 x 720 window at scale 3.
|
||||
gui_size: (427.0, 240.0),
|
||||
..Snapshot::default()
|
||||
};
|
||||
mods.set_snapshot(snapshot(0));
|
||||
mods.player_joined(&steve);
|
||||
for t in 0..TICKS {
|
||||
mods.set_snapshot(snapshot(t));
|
||||
mods.tick(t);
|
||||
mods.server_tick(t);
|
||||
for _ in 0..FRAMES_PER_TICK {
|
||||
mods.frame(1.0 / 60.0);
|
||||
}
|
||||
}
|
||||
mods.chat("<Steve> hello");
|
||||
mods.player_chat(&steve, "hello");
|
||||
for name in &client {
|
||||
mods.command(name);
|
||||
}
|
||||
for name in &server {
|
||||
mods.server_command(&steve, name);
|
||||
}
|
||||
mods.set_snapshot(Snapshot::default());
|
||||
mods.player_left(&steve);
|
||||
for out in mods.drain() {
|
||||
report.output.push(match out {
|
||||
Output::Chat { text, .. } => format!("chat: {text}"),
|
||||
Output::Tell { text, .. } => format!("tell: {text}"),
|
||||
Output::Broadcast { text, .. } => format!("broadcast: {text}"),
|
||||
Output::Feedback { text, error } | Output::Reply { text, error, .. } => {
|
||||
format!("{}: {text}", if error { "error" } else { "feedback" })
|
||||
}
|
||||
Output::Command { line } => format!("command: /{line}"),
|
||||
Output::QuietCommands { lines, .. } => {
|
||||
format!("{} quiet command(s)", lines.len())
|
||||
}
|
||||
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
|
||||
Output::SetBlocks {
|
||||
dimension, blocks, ..
|
||||
} => {
|
||||
format!("set {} block(s) in {dimension}", blocks.len())
|
||||
}
|
||||
Output::OpenScreen { .. } => "opened a screen".to_string(),
|
||||
Output::CloseScreen { .. } => "closed its screen".to_string(),
|
||||
Output::PlaceRecipe { book_id, all } => {
|
||||
format!("placed recipe {book_id}{}", if all { " (all)" } else { "" })
|
||||
}
|
||||
Output::Give { item, count, .. } => format!("gave {count} {item}"),
|
||||
Output::Shake { strength, seconds } => {
|
||||
format!("shook the camera: {strength:.2} for {seconds:.1} s")
|
||||
}
|
||||
});
|
||||
}
|
||||
let shapes = mods.hud_shapes().count();
|
||||
if shapes > 0 {
|
||||
report.output.push(format!("drew {shapes} HUD shape(s)"));
|
||||
}
|
||||
match failed(&mods) {
|
||||
Some(reason) => Err(format!("it stopped: {reason}")),
|
||||
/// A mod is content only: WebAssembly code mods don't run any more, code mods are native
|
||||
/// (`.hnmod`, docs/native-mods.md). `inspect` already read its content as the game does.
|
||||
fn content_only(path: &Path) -> Result<(), String> {
|
||||
match Package::read(path)?.wasm {
|
||||
Some(_) => Err(
|
||||
"it has WebAssembly code, which Horizon doesn't run any more: make it a native mod (docs/native-mods.md)"
|
||||
.into(),
|
||||
),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
@@ -316,7 +167,7 @@ mod tests {
|
||||
.text()
|
||||
.contains("cozy 1.0.0 (modpack, format 1, for 26.3)")
|
||||
);
|
||||
// The fixture mod's code is a stand-in: its manifest reads, its run fails.
|
||||
// A mod with WebAssembly code is refused: code mods are native.
|
||||
let hmod = fixture("coords_hud-0.1.0.hmod");
|
||||
let key = std::fs::read_to_string(fixture("test.pub")).unwrap();
|
||||
let sig = std::fs::read_to_string(fixture("coords_hud-0.1.0.hmod.minisig")).unwrap();
|
||||
@@ -324,7 +175,7 @@ mod tests {
|
||||
let e = check(&hmod, Kind::Mod, Some(key), Some(&sig))
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(e.contains("didn't start"), "{e}");
|
||||
assert!(e.contains("native mod"), "{e}");
|
||||
let e = check(
|
||||
&hmod,
|
||||
Kind::Mod,
|
||||
|
||||
@@ -1,32 +1,23 @@
|
||||
//! `cargo horizon-mod`: make Horizon mods in Rust (`docs/mods.md`).
|
||||
//! `cargo horizon-mod`: make Horizon content mods (`docs/modpacks.md`): blocks,
|
||||
//! items, data and assets, no code. Code mods are native (docs/native-mods.md).
|
||||
//!
|
||||
//! - `new <path> [--template code|content] [--name "Name"] [--sdk-path <dir>]`:
|
||||
//! a mod from a template: a Rust crate, or a content mod (no code).
|
||||
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`, with the mod's
|
||||
//! `assets/` and `data/`; a folder without `Cargo.toml` is content only.
|
||||
//! - `new <path> [--name "Name"]`: a content mod from the template.
|
||||
//! - `build`: the `.hmod` in `target/horizon-mod/`, with the mod's `assets/`
|
||||
//! and `data/`.
|
||||
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
|
||||
//! the mod; run `build` again and the game reloads it.
|
||||
//! - `test [--debug] [--hmod <file>] [<name>]`: build, then run the mod's
|
||||
//! `tests/*.toml` scripts in the sandbox, with no game.
|
||||
//! the mod.
|
||||
//! - `check <file> [--json]`: what the registry checks of a submission
|
||||
//! (`.hmod`, `.hpack`, resource pack `.zip`), with a trial run for a mod.
|
||||
//! (`.hmod`, `.hnmod`, `.hpack`, resource pack `.zip`, `.hshader`).
|
||||
//! - `registry build|review|check|list`: the registry repository's CI.
|
||||
use horizon_mods::{
|
||||
Capability, Content, Manifest, Package,
|
||||
manifest::{MANIFEST, component_imports, componentize},
|
||||
testing::{Script, TestGame},
|
||||
};
|
||||
use horizon_mods::{Capability, Content, Manifest, Package, manifest::MANIFEST};
|
||||
mod check;
|
||||
mod registry;
|
||||
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, ExitCode, Stdio},
|
||||
process::{Command, ExitCode},
|
||||
};
|
||||
|
||||
const TARGET: &str = "wasm32-unknown-unknown";
|
||||
const SDK_GIT: &str = "{ git = \"https://github.com/horizon-rust/horizon\" }";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
// Cargo runs `cargo-horizon-mod horizon-mod <args>`.
|
||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||
@@ -35,14 +26,13 @@ fn main() -> ExitCode {
|
||||
}
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("new") => new(&args[1..]),
|
||||
Some("build") => build(!args.iter().any(|a| a == "--debug")).map(|p| {
|
||||
Some("build") => build().map(|p| {
|
||||
println!("Built {}", p.display());
|
||||
}),
|
||||
Some("run") => run(&args[1..]),
|
||||
Some("test") => test(&args[1..]),
|
||||
Some("check") => check(&args[1..]),
|
||||
Some("registry") => registry::main(&args[1..]),
|
||||
_ => Err("usage: cargo horizon-mod new <path> [--template code|content] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | test [--debug] [--hmod <file>] [<name>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
|
||||
_ => Err("usage: cargo horizon-mod new <path> [--name \"Name\"] | build | run [--game <horizon-client>] [-- <game args>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
@@ -83,31 +73,9 @@ fn new(args: &[String]) -> Result<(), String> {
|
||||
})
|
||||
.collect();
|
||||
let name = flag(args, "--name").map_or_else(|| base.replace(['-', '_'], " "), str::to_string);
|
||||
let sdk = match flag(args, "--sdk-path") {
|
||||
Some(p) => {
|
||||
let p = std::fs::canonicalize(p).map_err(|e| format!("--sdk-path {p}: {e}"))?;
|
||||
format!("{{ path = {:?} }}", p.display().to_string())
|
||||
}
|
||||
None => SDK_GIT.to_string(),
|
||||
};
|
||||
let fill = |t: &str| {
|
||||
t.replace("{crate}", &id.replace('_', "-"))
|
||||
.replace("{id}", &id)
|
||||
.replace("{name}", &name)
|
||||
.replace("{sdk}", &sdk)
|
||||
};
|
||||
let code = [
|
||||
("Cargo.toml", include_str!("../template/Cargo.toml.in")),
|
||||
(MANIFEST, include_str!("../template/horizon-mod.toml")),
|
||||
("src/lib.rs", include_str!("../template/src/lib.rs")),
|
||||
(".gitignore", include_str!("../template/gitignore")),
|
||||
(
|
||||
"tests/hello.toml",
|
||||
include_str!("../template/tests/hello.toml"),
|
||||
),
|
||||
];
|
||||
let fill = |t: &str| t.replace("{id}", &id).replace("{name}", &name);
|
||||
// A block copying a vanilla one, with its model, name, loot, recipe and tag.
|
||||
let content = [
|
||||
let files = [
|
||||
(
|
||||
MANIFEST,
|
||||
include_str!("../template/content/horizon-mod.toml"),
|
||||
@@ -146,12 +114,12 @@ fn new(args: &[String]) -> Result<(), String> {
|
||||
),
|
||||
(".gitignore", include_str!("../template/gitignore")),
|
||||
];
|
||||
let files: &[(&str, &str)] = match flag(args, "--template").unwrap_or("code") {
|
||||
"code" => &code,
|
||||
"content" => &content,
|
||||
other => return Err(format!("no template {other}: code or content")),
|
||||
};
|
||||
for &(file, text) in files {
|
||||
if flag(args, "--template").is_some_and(|t| t != "content") {
|
||||
return Err(
|
||||
"only content mods are made here: code mods are native (docs/native-mods.md)".into(),
|
||||
);
|
||||
}
|
||||
for (file, text) in files {
|
||||
let file = fill(file);
|
||||
let file = file.as_str();
|
||||
let to = dir.join(file);
|
||||
@@ -169,29 +137,24 @@ fn new(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Builds the mod in the current folder and packs `target/horizon-mod/<id>.hmod`:
|
||||
/// the manifest, the crate's component (none without a `Cargo.toml`: a
|
||||
/// content mod) and the files in `assets/` and `data/`.
|
||||
fn build(release: bool) -> Result<PathBuf, String> {
|
||||
build_in(Path::new("."), release)
|
||||
/// Packs the content mod in the current folder into `target/horizon-mod/<id>.hmod`: the
|
||||
/// manifest and the files in `assets/` and `data/`.
|
||||
fn build() -> Result<PathBuf, String> {
|
||||
build_in(Path::new("."))
|
||||
}
|
||||
|
||||
fn build_in(root: &Path, release: bool) -> Result<PathBuf, String> {
|
||||
fn build_in(root: &Path) -> Result<PathBuf, String> {
|
||||
let manifest_text = std::fs::read_to_string(root.join(MANIFEST))
|
||||
.map_err(|e| format!("{MANIFEST}: {e} (run this in a mod's folder)"))?;
|
||||
let manifest = Manifest::parse(&manifest_text)?;
|
||||
let (wasm, target_dir) = if root.join("Cargo.toml").exists() {
|
||||
let (wasm, target) = compile(root, release)?;
|
||||
// The game refuses a component importing capabilities the manifest lacks.
|
||||
manifest.check_imports(component_imports(&wasm)?.iter().map(String::as_str))?;
|
||||
(Some(wasm), target)
|
||||
} else if manifest.capabilities.contains(&Capability::Registries) {
|
||||
(None, root.join("target"))
|
||||
} else {
|
||||
if root.join("Cargo.toml").exists() {
|
||||
return Err("a Cargo.toml: code mods are native now (docs/native-mods.md)".into());
|
||||
}
|
||||
if !manifest.capabilities.contains(&Capability::Registries) {
|
||||
return Err(format!(
|
||||
"no Cargo.toml: only a content mod (capability registries in {MANIFEST}) has no code"
|
||||
"a content mod has capability registries in {MANIFEST}"
|
||||
));
|
||||
};
|
||||
}
|
||||
// The same checks as the game's, and the files in the order it lists them.
|
||||
let content = Content::read(root, &manifest)?;
|
||||
let mut reader = content.reader()?;
|
||||
@@ -200,53 +163,17 @@ fn build_in(root: &Path, release: bool) -> Result<PathBuf, String> {
|
||||
files.push((name.as_str(), reader.read(name)?));
|
||||
}
|
||||
let files: Vec<(&str, &[u8])> = files.iter().map(|(n, b)| (*n, b.as_slice())).collect();
|
||||
let dir = target_dir.join("horizon-mod");
|
||||
let dir = root.join("target/horizon-mod");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let path = dir.join(format!("{}.hmod", manifest.id));
|
||||
// Write then rename, so a running game never reads half a file.
|
||||
let partial = path.with_extension("hmod.partial");
|
||||
let bytes = Package::write_files(&manifest_text, wasm.as_deref(), &files)?;
|
||||
let bytes = Package::write_files(&manifest_text, None, &files)?;
|
||||
std::fs::write(&partial, bytes).map_err(|e| e.to_string())?;
|
||||
std::fs::rename(&partial, &path).map_err(|e| e.to_string())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Builds the crate for WebAssembly: its component, and Cargo's target folder.
|
||||
fn compile(root: &Path, release: bool) -> Result<(Vec<u8>, PathBuf), String> {
|
||||
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
|
||||
let mut cmd = Command::new(cargo);
|
||||
cmd.current_dir(root).args([
|
||||
"build",
|
||||
"--lib",
|
||||
"--target",
|
||||
TARGET,
|
||||
"--message-format=json-render-diagnostics",
|
||||
]);
|
||||
if release {
|
||||
cmd.arg("--release");
|
||||
}
|
||||
let out = cmd
|
||||
.stderr(Stdio::inherit())
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !out.status.success() {
|
||||
return Err(format!(
|
||||
"cargo build failed (is the target installed? rustup target add {TARGET})"
|
||||
));
|
||||
}
|
||||
let core = String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
|
||||
.filter(|m| m["reason"] == "compiler-artifact")
|
||||
.flat_map(|m| m["filenames"].as_array().cloned().unwrap_or_default())
|
||||
.filter_map(|f| f.as_str().map(PathBuf::from))
|
||||
.rfind(|f| f.extension().is_some_and(|e| e == "wasm"))
|
||||
.ok_or("the build made no .wasm: is the crate a cdylib?")?;
|
||||
let wasm = componentize(&std::fs::read(&core).map_err(|e| e.to_string())?)?;
|
||||
let target = core.ancestors().nth(3).ok_or("unexpected target folder")?;
|
||||
Ok((wasm, target.to_path_buf()))
|
||||
}
|
||||
|
||||
/// `check <file>`: the registry's checks of a submission, printed; fails
|
||||
/// when one does.
|
||||
fn check(args: &[String]) -> Result<(), String> {
|
||||
@@ -278,74 +205,14 @@ fn check(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `test`: every `tests/*.toml` script (those whose name holds `<name>`)
|
||||
/// against the built mod, or the `.hmod` given with `--hmod`.
|
||||
fn test(args: &[String]) -> Result<(), String> {
|
||||
let hmod = match flag(args, "--hmod") {
|
||||
Some(p) => PathBuf::from(p),
|
||||
None => build(!args.iter().any(|a| a == "--debug"))?,
|
||||
};
|
||||
let filter = args
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(i, a)| !a.starts_with("--") && (*i == 0 || args[i - 1] != "--hmod"))
|
||||
.map(|(_, a)| a.as_str());
|
||||
let mut scripts: Vec<PathBuf> = std::fs::read_dir("tests")
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"tests/: {e} (a mod's tests are tests/<name>.toml: docs/mods.md, \"Testing a mod\")"
|
||||
)
|
||||
})?
|
||||
.filter_map(|e| Some(e.ok()?.path()))
|
||||
.filter(|p| p.extension().is_some_and(|e| e == "toml"))
|
||||
.filter(|p| filter.is_none_or(|f| p.to_string_lossy().contains(f)))
|
||||
.collect();
|
||||
scripts.sort();
|
||||
if scripts.is_empty() {
|
||||
return Err("no tests/*.toml to run".into());
|
||||
}
|
||||
println!(
|
||||
"running {} scripts against {}",
|
||||
scripts.len(),
|
||||
hmod.display()
|
||||
);
|
||||
let mut failed = 0;
|
||||
for path in &scripts {
|
||||
let result = std::fs::read_to_string(path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|text| Script::parse(&text))
|
||||
.and_then(|script| {
|
||||
let mut game = TestGame::load(&hmod)?;
|
||||
script.run(&mut game)?;
|
||||
Ok(script.steps.len())
|
||||
});
|
||||
match result {
|
||||
Ok(steps) => println!("test {} ... ok ({steps} steps)", path.display()),
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
println!("test {} ... FAILED\n {e}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
let passed = scripts.len() - failed;
|
||||
if failed > 0 {
|
||||
return Err(format!("{failed} failed, {passed} passed"));
|
||||
}
|
||||
println!("test result: ok. {passed} passed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), String> {
|
||||
let path = build(true)?;
|
||||
let path = build()?;
|
||||
let game = flag(args, "--game")
|
||||
.map(str::to_string)
|
||||
.or_else(|| std::env::var("HORIZON_CLIENT").ok())
|
||||
.unwrap_or_else(|| "horizon-client".into());
|
||||
let extra = args.iter().skip_while(|a| *a != "--").skip(1);
|
||||
println!(
|
||||
"Starting {game} with {} (rebuild with `cargo horizon-mod build` to reload it)",
|
||||
path.display()
|
||||
);
|
||||
println!("Starting {game} with {}", path.display());
|
||||
let status = Command::new(&game)
|
||||
.arg("--dev-mod")
|
||||
.arg(&path)
|
||||
@@ -364,28 +231,6 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_template_is_a_valid_mod() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mod-new-{}", std::process::id()));
|
||||
let path = dir.join("My-Mod");
|
||||
new(&[path.display().to_string(), "--name".into(), "My Mod".into()]).unwrap();
|
||||
let manifest =
|
||||
Manifest::parse(&std::fs::read_to_string(path.join(MANIFEST)).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
(manifest.id.as_str(), manifest.name.as_str()),
|
||||
("my_mod", "My Mod")
|
||||
);
|
||||
let cargo = std::fs::read_to_string(path.join("Cargo.toml")).unwrap();
|
||||
assert!(cargo.contains("name = \"my-mod\"") && cargo.contains(SDK_GIT));
|
||||
let script = std::fs::read_to_string(path.join("tests/hello.toml")).unwrap();
|
||||
assert_eq!(Script::parse(&script).unwrap().steps.len(), 3);
|
||||
assert!(
|
||||
new(&[path.display().to_string()]).is_err(),
|
||||
"never overwrites"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_content_template_builds_and_checks_with_no_code() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mod-content-{}", std::process::id()));
|
||||
@@ -397,7 +242,7 @@ mod tests {
|
||||
];
|
||||
new(&new_args).unwrap();
|
||||
assert!(!path.join("Cargo.toml").exists());
|
||||
let hmod = build_in(&path, true).unwrap();
|
||||
let hmod = build_in(&path).unwrap();
|
||||
assert_eq!(hmod, path.join("target/horizon-mod/gems.hmod"));
|
||||
let package = Package::read(&hmod).unwrap();
|
||||
assert!(package.wasm.is_none());
|
||||
@@ -409,17 +254,14 @@ mod tests {
|
||||
);
|
||||
assert_eq!(content.files.len(), 8, "{:?}", content.files);
|
||||
let report = check::check(&hmod, check::kind_of(&hmod).unwrap(), None, None).unwrap();
|
||||
assert!(!report.code);
|
||||
assert!(
|
||||
report.text().contains("no code: content only"),
|
||||
"{}",
|
||||
report.text()
|
||||
);
|
||||
// Without registries, a mod needs code.
|
||||
let manifest = std::fs::read_to_string(path.join(MANIFEST)).unwrap();
|
||||
let manifest = manifest.replace("[\"registries\"]", "[]");
|
||||
std::fs::write(path.join(MANIFEST), manifest).unwrap();
|
||||
assert!(build_in(&path, true).unwrap_err().contains("no Cargo.toml"));
|
||||
// Code is native now.
|
||||
std::fs::write(path.join("Cargo.toml"), "").unwrap();
|
||||
assert!(build_in(&path).unwrap_err().contains("native"));
|
||||
assert!(
|
||||
new(&[
|
||||
dir.join("x").display().to_string(),
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
[package]
|
||||
name = "{crate}"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
horizon-mod-sdk = {sdk}
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
@@ -1,12 +0,0 @@
|
||||
id = "{id}"
|
||||
name = "{name}"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = []
|
||||
# The Horizon mod API the SDK targets.
|
||||
api = "0.1"
|
||||
# client: runs in the player's game.
|
||||
side = "client"
|
||||
# What the mod may do, shown to players before they turn it on:
|
||||
# game-read (position, health, time), chat (show messages), hud (draw text).
|
||||
capabilities = ["chat"]
|
||||
@@ -1,19 +0,0 @@
|
||||
use horizon_mod_sdk::{Mod, Registration, chat};
|
||||
|
||||
struct MyMod {
|
||||
greeted: u32,
|
||||
}
|
||||
|
||||
impl Mod for MyMod {
|
||||
fn init() -> Result<(Self, Registration), String> {
|
||||
Ok((MyMod { greeted: 0 }, Registration::new().command("hello")))
|
||||
}
|
||||
|
||||
fn command(&mut self, _name: &str, args: &str) -> Result<Option<String>, String> {
|
||||
self.greeted += 1;
|
||||
chat::show(&format!("Hello {args}! ({} so far)", self.greeted));
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
horizon_mod_sdk::export_mod!(MyMod);
|
||||
@@ -1,14 +0,0 @@
|
||||
# `cargo horizon-mod test` runs every tests/*.toml against the built mod, with no game:
|
||||
# steps happen in order, and `expect` checks what the mod showed during the step.
|
||||
# Every key: docs/mods.md, "Testing a mod".
|
||||
|
||||
[[step]]
|
||||
expect.commands = ["hello"]
|
||||
|
||||
[[step]]
|
||||
command = "hello world"
|
||||
expect.chat = ["Hello world! (1 so far)"]
|
||||
|
||||
[[step]]
|
||||
command = "hello again"
|
||||
expect.chat = ["Hello again! (2 so far)"]
|
||||
@@ -21,18 +21,19 @@ use horizon_protocol::{
|
||||
connection::{ConnWriter, Connection},
|
||||
crypto::offline_uuid,
|
||||
ids,
|
||||
item::Stack,
|
||||
packets::{
|
||||
Empty, configuration,
|
||||
handshake::{self, Intention},
|
||||
login::{self, ServerHello},
|
||||
play,
|
||||
play, recipes,
|
||||
},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
Arc, LazyLock, Mutex,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
},
|
||||
thread,
|
||||
@@ -44,7 +45,22 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
|
||||
--server HOST:PORT default 127.0.0.1:25565
|
||||
--bots N default 10
|
||||
--behaviour B idle, walk, explore or mixed (60 % walk, 20 % explore, 20 % idle); default mixed
|
||||
--behaviour B idle, walk, explore or mixed (60 % walk, 20 % explore, 20 % idle); default mixed.
|
||||
One abusive client, to test a server's limits (run it beside a normal crowd):
|
||||
flood-move (--flood moves a tick, each 64 to 256 blocks away, ignoring chunks),
|
||||
flood-acks (--flood chunk batch acks asking for 64 chunks a tick, while exploring),
|
||||
flood-packets (--flood rotations a tick),
|
||||
flood-chunks (one move a tick, 9.9 blocks straight ahead: as fast as the
|
||||
moved-too-quickly check allows, never waiting for chunks),
|
||||
and --flood a tick of packets that cost the server work:
|
||||
flood-use (place and break stone around itself), flood-click (inventory
|
||||
clicks with a stale state), flood-suggest (command suggestions, every
|
||||
other one 30 000 characters long), flood-book (100 pages of 1024 characters
|
||||
into a writable book), flood-sign (four 384-character lines on the block
|
||||
below), flood-recipe (recipe book placements)
|
||||
--flood N packets a tick for the flood behaviours; default 100
|
||||
--no-read stop reading from the server once spawned (a client that lets the
|
||||
server's replies pile up)
|
||||
--join-every SECONDS default 1
|
||||
--seconds N how long to run after the first bot joins; default 60
|
||||
--view-distance N what the bots ask for; default 10
|
||||
@@ -59,6 +75,16 @@ enum Behaviour {
|
||||
Idle,
|
||||
Walk,
|
||||
Explore,
|
||||
FloodMove,
|
||||
FloodAcks,
|
||||
FloodPackets,
|
||||
FloodChunks,
|
||||
FloodUse,
|
||||
FloodClick,
|
||||
FloodSuggest,
|
||||
FloodBook,
|
||||
FloodSign,
|
||||
FloodRecipe,
|
||||
}
|
||||
|
||||
impl Behaviour {
|
||||
@@ -67,6 +93,16 @@ impl Behaviour {
|
||||
Self::Idle => "idle",
|
||||
Self::Walk => "walk",
|
||||
Self::Explore => "explore",
|
||||
Self::FloodMove => "flood-move",
|
||||
Self::FloodAcks => "flood-acks",
|
||||
Self::FloodPackets => "flood-packets",
|
||||
Self::FloodChunks => "flood-chunks",
|
||||
Self::FloodUse => "flood-use",
|
||||
Self::FloodClick => "flood-click",
|
||||
Self::FloodSuggest => "flood-suggest",
|
||||
Self::FloodBook => "flood-book",
|
||||
Self::FloodSign => "flood-sign",
|
||||
Self::FloodRecipe => "flood-recipe",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +117,8 @@ struct Options {
|
||||
view_distance: i8,
|
||||
seed: u64,
|
||||
explore_speed: f64,
|
||||
flood: usize,
|
||||
no_read: bool,
|
||||
name: String,
|
||||
report: Option<String>,
|
||||
}
|
||||
@@ -96,6 +134,8 @@ fn options() -> Result<Options, String> {
|
||||
view_distance: 10,
|
||||
seed: 12345,
|
||||
explore_speed: 22.0,
|
||||
flood: 100,
|
||||
no_read: false,
|
||||
name: "Bot".into(),
|
||||
report: None,
|
||||
};
|
||||
@@ -105,6 +145,10 @@ fn options() -> Result<Options, String> {
|
||||
print!("{HELP}");
|
||||
std::process::exit(0);
|
||||
}
|
||||
if arg == "--no-read" {
|
||||
o.no_read = true;
|
||||
continue;
|
||||
}
|
||||
let value = args.next().ok_or(format!("{arg} needs a value"))?;
|
||||
let number = |what: &str| format!("{arg} needs {what}, not {value:?}");
|
||||
match arg.as_str() {
|
||||
@@ -122,14 +166,31 @@ fn options() -> Result<Options, String> {
|
||||
"--explore-speed" => {
|
||||
o.explore_speed = value.parse().map_err(|_| number("blocks a second"))?;
|
||||
}
|
||||
"--flood" => o.flood = value.parse().map_err(|_| number("packets a tick"))?,
|
||||
"--name" => o.name = value,
|
||||
"--report" => o.report = Some(value),
|
||||
_ => return Err(format!("unknown option {arg}; --help lists them")),
|
||||
}
|
||||
}
|
||||
if !["idle", "walk", "explore", "mixed"].contains(&o.behaviour.as_str()) {
|
||||
const BEHAVIOURS: [&str; 14] = [
|
||||
"idle",
|
||||
"walk",
|
||||
"explore",
|
||||
"mixed",
|
||||
"flood-move",
|
||||
"flood-acks",
|
||||
"flood-packets",
|
||||
"flood-chunks",
|
||||
"flood-use",
|
||||
"flood-click",
|
||||
"flood-suggest",
|
||||
"flood-book",
|
||||
"flood-sign",
|
||||
"flood-recipe",
|
||||
];
|
||||
if !BEHAVIOURS.contains(&o.behaviour.as_str()) {
|
||||
return Err(format!(
|
||||
"--behaviour {}: use idle, walk, explore or mixed",
|
||||
"--behaviour {}: use idle, walk, explore, mixed or a flood behaviour (--help lists them)",
|
||||
o.behaviour
|
||||
));
|
||||
}
|
||||
@@ -156,6 +217,8 @@ struct Bot {
|
||||
teleport: Mutex<Option<[f64; 3]>>,
|
||||
error: Mutex<Option<String>>,
|
||||
joined_after_ms: AtomicU64,
|
||||
/// The bot stopped reading (--no-read).
|
||||
deaf: AtomicBool,
|
||||
}
|
||||
|
||||
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
|
||||
@@ -185,6 +248,89 @@ fn raw(id: i32, write: impl FnOnce(&mut Writer)) -> Vec<u8> {
|
||||
w.data
|
||||
}
|
||||
|
||||
/// Item ids of the pinned version (26.3's item registry).
|
||||
const STONE: i32 = 1;
|
||||
const WRITABLE_BOOK: i32 = 1371;
|
||||
|
||||
/// The `i`th packet of a costly flood this tick, the bot at `pos`; none for other behaviours.
|
||||
fn costly(
|
||||
behaviour: Behaviour,
|
||||
i: usize,
|
||||
sequence: i32,
|
||||
pos: [f64; 3],
|
||||
random: &mut Random,
|
||||
) -> Option<Vec<u8>> {
|
||||
use ids::play::serverbound as sb;
|
||||
static LONG_COMMAND: LazyLock<String> =
|
||||
LazyLock::new(|| format!("/msg {}", "x".repeat(30_000)));
|
||||
static BOOK: LazyLock<Vec<u8>> = LazyLock::new(|| {
|
||||
play::Serverbound::EditBook(play::EditBook {
|
||||
slot: 0,
|
||||
pages: vec!["a".repeat(1024); 100],
|
||||
title: None,
|
||||
})
|
||||
.encode()
|
||||
});
|
||||
let block = pos.map(|v| v.floor() as i32);
|
||||
let packet = match behaviour {
|
||||
Behaviour::FloodUse => {
|
||||
// A block within reach: place stone there, or break what is there.
|
||||
let at = [0, 1, 2].map(|k| block[k] + (random.unit() * 7.0) as i32 - 3);
|
||||
if i % 2 == 0 {
|
||||
play::Serverbound::UseItemOn(play::UseItemOn {
|
||||
hand: 0,
|
||||
pos: at,
|
||||
face: 1,
|
||||
cursor: [0.5, 1.0, 0.5],
|
||||
inside: false,
|
||||
world_border: false,
|
||||
sequence,
|
||||
})
|
||||
} else {
|
||||
play::Serverbound::PlayerAction(play::PlayerAction {
|
||||
action: 0,
|
||||
pos: at,
|
||||
direction: 1,
|
||||
sequence,
|
||||
})
|
||||
}
|
||||
}
|
||||
Behaviour::FloodClick => play::Serverbound::ContainerClick(play::ContainerClick {
|
||||
container: 0,
|
||||
state: 0,
|
||||
slot: 36 + (i % 9) as i16,
|
||||
button: 0,
|
||||
input: 0,
|
||||
changed: Vec::new(),
|
||||
carried: None,
|
||||
}),
|
||||
Behaviour::FloodSuggest => {
|
||||
let text = if i % 2 == 0 {
|
||||
"/"
|
||||
} else {
|
||||
LONG_COMMAND.as_str()
|
||||
};
|
||||
return Some(raw(sb::COMMAND_SUGGESTION, |w| {
|
||||
w.varint(sequence);
|
||||
w.string(text);
|
||||
}));
|
||||
}
|
||||
Behaviour::FloodBook => return Some(BOOK.clone()),
|
||||
Behaviour::FloodSign => play::Serverbound::SignUpdate(play::SignUpdate {
|
||||
pos: [block[0], block[1] - 1, block[2]],
|
||||
lines: std::array::from_fn(|_| "a".repeat(384)),
|
||||
front: true,
|
||||
}),
|
||||
Behaviour::FloodRecipe => play::Serverbound::PlaceRecipe(recipes::PlaceRecipe {
|
||||
container: 0,
|
||||
recipe: (i % 2042) as i32,
|
||||
use_max: true,
|
||||
}),
|
||||
_ => return None,
|
||||
};
|
||||
Some(packet.encode())
|
||||
}
|
||||
|
||||
fn err(e: impl std::fmt::Display) -> String {
|
||||
e.to_string()
|
||||
}
|
||||
@@ -339,6 +485,12 @@ fn read(
|
||||
let mut batch = BatchSize::new();
|
||||
let mut teleports = 0u64;
|
||||
loop {
|
||||
if bot.deaf.load(Ordering::Relaxed) {
|
||||
while bot.online.load(Ordering::SeqCst) {
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (id, body) = match reader.recv() {
|
||||
Ok(packet) => packet,
|
||||
Err(e) => {
|
||||
@@ -489,6 +641,24 @@ fn run_bot(
|
||||
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
|
||||
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
|
||||
send(raw(sb::PLAYER_LOADED, |_| {}));
|
||||
bot.deaf.store(o.no_read, Ordering::Relaxed);
|
||||
// What the costly floods need in hand (creative: the server takes the bot's word for it).
|
||||
let tool = match behaviour {
|
||||
Behaviour::FloodUse => Some(Stack::new(STONE, 64)),
|
||||
Behaviour::FloodBook => Some(Stack::new(WRITABLE_BOOK, 1)),
|
||||
_ => None,
|
||||
};
|
||||
if tool.is_some() {
|
||||
send(
|
||||
play::Serverbound::SetCreativeModeSlot(play::SetCreativeModeSlot {
|
||||
slot: 36,
|
||||
item: tool,
|
||||
})
|
||||
.encode(),
|
||||
);
|
||||
send(play::Serverbound::SetCarriedItem(play::SetCarriedItem { slot: 0 }).encode());
|
||||
}
|
||||
let mut sequence = 0;
|
||||
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
|
||||
let mut pos = spawn;
|
||||
let cruise = spawn[1] + 24.0;
|
||||
@@ -504,7 +674,38 @@ fn run_bot(
|
||||
pos = at;
|
||||
}
|
||||
let here = ((pos[0].floor() as i32) >> 4, (pos[2].floor() as i32) >> 4);
|
||||
if !lock(&bot.held).contains(&here) {
|
||||
// The abusive client doesn't wait for its chunk, as a real client must.
|
||||
if behaviour == Behaviour::FloodMove {
|
||||
// The last of these moves goes out below, with the tick's end.
|
||||
pos[1] = cruise;
|
||||
for _ in 1..o.flood.max(1) {
|
||||
let angle = random.unit() * std::f64::consts::TAU;
|
||||
let reach = 64.0 + random.unit() * 192.0;
|
||||
pos[0] += angle.cos() * reach;
|
||||
pos[2] += angle.sin() * reach;
|
||||
send(
|
||||
play::Serverbound::MovePlayerPos(play::MovePlayerPos {
|
||||
x: pos[0],
|
||||
y: pos[1],
|
||||
z: pos[2],
|
||||
flags: 0,
|
||||
})
|
||||
.encode(),
|
||||
);
|
||||
}
|
||||
let angle = random.unit() * std::f64::consts::TAU;
|
||||
pos[0] += angle.cos() * 128.0;
|
||||
pos[2] += angle.sin() * 128.0;
|
||||
} else if behaviour == Behaviour::FloodChunks {
|
||||
// Up to cruising height first, then just under the server's 10 blocks a tick:
|
||||
// every tick asks for new chunks.
|
||||
if pos[1] < cruise {
|
||||
pos[1] = (pos[1] + 0.5).min(cruise);
|
||||
} else {
|
||||
pos[0] += heading.cos() * 9.9;
|
||||
pos[2] += heading.sin() * 9.9;
|
||||
}
|
||||
} else if !lock(&bot.held).contains(&here) {
|
||||
bot.waited.fetch_add(1, Ordering::Relaxed);
|
||||
} else if pos[1] < cruise {
|
||||
pos[1] = (pos[1] + 0.5).min(cruise);
|
||||
@@ -529,7 +730,37 @@ fn run_bot(
|
||||
pos[2] += dz / distance * step;
|
||||
}
|
||||
}
|
||||
Behaviour::Explore => {
|
||||
Behaviour::FloodMove
|
||||
| Behaviour::FloodChunks
|
||||
| Behaviour::FloodUse
|
||||
| Behaviour::FloodClick
|
||||
| Behaviour::FloodSuggest
|
||||
| Behaviour::FloodBook
|
||||
| Behaviour::FloodSign
|
||||
| Behaviour::FloodRecipe => {}
|
||||
Behaviour::FloodPackets => {
|
||||
for i in 0..o.flood {
|
||||
send(
|
||||
play::Serverbound::MovePlayerRot(play::MovePlayerRot {
|
||||
yaw: (i % 360) as f32,
|
||||
pitch: 0.0,
|
||||
flags: 0,
|
||||
})
|
||||
.encode(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Behaviour::FloodAcks | Behaviour::Explore => {
|
||||
if behaviour == Behaviour::FloodAcks {
|
||||
for _ in 0..o.flood {
|
||||
send(
|
||||
play::Serverbound::ChunkBatchReceived(play::ChunkBatchReceived {
|
||||
chunks_per_tick: 64.0,
|
||||
})
|
||||
.encode(),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Flying in creative, by default about 22 blocks a second, which keeps new
|
||||
// chunks coming as fast as the server sends them.
|
||||
let step = o.explore_speed / 20.0;
|
||||
@@ -538,6 +769,13 @@ fn run_bot(
|
||||
}
|
||||
}
|
||||
}
|
||||
// The costly floods go out wherever the bot is, chunks or not (--no-read gets none).
|
||||
for i in 0..o.flood {
|
||||
sequence += 1;
|
||||
if let Some(body) = costly(behaviour, i, sequence, pos, &mut random) {
|
||||
send(body);
|
||||
}
|
||||
}
|
||||
let ok = send(
|
||||
play::Serverbound::MovePlayerPos(play::MovePlayerPos {
|
||||
x: pos[0],
|
||||
@@ -569,6 +807,16 @@ fn behaviour_of(mode: &str, index: usize) -> Behaviour {
|
||||
"idle" => Behaviour::Idle,
|
||||
"walk" => Behaviour::Walk,
|
||||
"explore" => Behaviour::Explore,
|
||||
"flood-move" => Behaviour::FloodMove,
|
||||
"flood-acks" => Behaviour::FloodAcks,
|
||||
"flood-packets" => Behaviour::FloodPackets,
|
||||
"flood-chunks" => Behaviour::FloodChunks,
|
||||
"flood-use" => Behaviour::FloodUse,
|
||||
"flood-click" => Behaviour::FloodClick,
|
||||
"flood-suggest" => Behaviour::FloodSuggest,
|
||||
"flood-book" => Behaviour::FloodBook,
|
||||
"flood-sign" => Behaviour::FloodSign,
|
||||
"flood-recipe" => Behaviour::FloodRecipe,
|
||||
// mixed: of every five bots, three walk, one explores, one stands.
|
||||
_ => match index % 5 {
|
||||
0 | 2 | 4 => Behaviour::Walk,
|
||||
|
||||
@@ -55,6 +55,12 @@ particle_types! {
|
||||
Rain = "rain",
|
||||
Bubble = "bubble",
|
||||
SulfurBubbles = "sulfur_bubbles",
|
||||
NoxiousGas = "noxious_gas",
|
||||
NoxiousGasCloud = "noxious_gas_cloud",
|
||||
Geyser = "geyser",
|
||||
GeyserBase = "geyser_base",
|
||||
GeyserPoof = "geyser_poof",
|
||||
GeyserPlume = "geyser_plume",
|
||||
BubblePop = "bubble_pop",
|
||||
BubbleColumnUp = "bubble_column_up",
|
||||
CurrentDown = "current_down",
|
||||
@@ -149,6 +155,10 @@ impl ParticleType {
|
||||
matches!(
|
||||
self,
|
||||
Self::BlockMarker
|
||||
| Self::Geyser
|
||||
| Self::GeyserBase
|
||||
| Self::GeyserPoof
|
||||
| Self::GeyserPlume
|
||||
| Self::DamageIndicator
|
||||
| Self::ExplosionEmitter
|
||||
| Self::Explosion
|
||||
@@ -191,6 +201,8 @@ impl ParticleType {
|
||||
// SpellParticle.
|
||||
| Self::Effect | Self::InstantEffect | Self::EntityEffect
|
||||
| Self::Witch | Self::Infested | Self::TrialOmen | Self::RaidOmen
|
||||
// NoxiousGasParticle.
|
||||
| Self::NoxiousGas
|
||||
// EmissiveRisingParticle.
|
||||
| Self::Soul | Self::SculkSoul
|
||||
// SculkChargeParticle, SculkChargePopParticle, ShriekParticle,
|
||||
|
||||
@@ -13,6 +13,12 @@ pub enum Button {
|
||||
}
|
||||
|
||||
pub trait InteractionWorld {
|
||||
/// Minecraft.pick before handleKeybinds: the tick's `hitResult`, which
|
||||
/// every click of the tick reads, even after one of them changed the
|
||||
/// block it names (a creative break, then a use that replaces it).
|
||||
fn pick_hit_result(&mut self);
|
||||
/// The tick's clicks are done: later picks follow the crosshair again.
|
||||
fn release_hit_result(&mut self);
|
||||
fn using_item(&self) -> bool;
|
||||
fn release_using(&mut self);
|
||||
fn start_attack(&mut self) -> bool;
|
||||
@@ -48,6 +54,7 @@ impl InteractionInput {
|
||||
|
||||
pub fn tick(&mut self, world: &mut impl InteractionWorld, attack_down: bool, use_down: bool) {
|
||||
let [attack, use_item, pick] = std::mem::take(&mut self.clicks);
|
||||
world.pick_hit_result();
|
||||
let mut instant = false;
|
||||
if world.using_item() {
|
||||
if !use_down {
|
||||
@@ -70,6 +77,7 @@ impl InteractionInput {
|
||||
world.start_use();
|
||||
}
|
||||
world.continue_attack(!instant && attack_down);
|
||||
world.release_hit_result();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +94,8 @@ mod tests {
|
||||
events: Vec<&'static str>,
|
||||
}
|
||||
impl InteractionWorld for World {
|
||||
fn pick_hit_result(&mut self) {}
|
||||
fn release_hit_result(&mut self) {}
|
||||
fn using_item(&self) -> bool {
|
||||
self.using
|
||||
}
|
||||
@@ -183,4 +193,67 @@ mod tests {
|
||||
input.tick(&mut world, false, false);
|
||||
assert_eq!(world.events, ["release", "continue"]);
|
||||
}
|
||||
|
||||
/// A floating block in front of the crosshair, nothing behind it, and a
|
||||
/// block item in the hand of a creative player.
|
||||
#[derive(Default)]
|
||||
struct Column {
|
||||
block: bool,
|
||||
hit: Option<Option<()>>,
|
||||
placed: bool,
|
||||
}
|
||||
impl Column {
|
||||
/// Minecraft.hitResult: the tick's pick, or a fresh one outside it.
|
||||
fn target(&self) -> Option<()> {
|
||||
self.hit.unwrap_or(self.block.then_some(()))
|
||||
}
|
||||
}
|
||||
impl InteractionWorld for Column {
|
||||
fn pick_hit_result(&mut self) {
|
||||
self.hit = Some(self.target());
|
||||
}
|
||||
fn release_hit_result(&mut self) {
|
||||
self.hit = None;
|
||||
}
|
||||
fn using_item(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn release_using(&mut self) {}
|
||||
fn start_attack(&mut self) -> bool {
|
||||
// MultiPlayerGameMode.startDestroyBlock in creative: the
|
||||
// predicted break leaves air.
|
||||
if self.target().is_some() {
|
||||
self.block = false;
|
||||
}
|
||||
!self.block
|
||||
}
|
||||
fn start_use(&mut self) {
|
||||
// BlockItem.place: the clicked block, now air, can be replaced.
|
||||
if self.target().is_some() && !self.block {
|
||||
self.block = true;
|
||||
self.placed = true;
|
||||
}
|
||||
}
|
||||
fn pick(&mut self) {}
|
||||
fn use_delay(&self) -> u32 {
|
||||
0
|
||||
}
|
||||
fn continue_attack(&mut self, _: bool) {}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attack_and_use_in_one_tick_share_the_hit_result() {
|
||||
// #3006: both buttons at once replace the block the crosshair is on.
|
||||
let mut input = InteractionInput::default();
|
||||
let mut world = Column {
|
||||
block: true,
|
||||
..Default::default()
|
||||
};
|
||||
input.press(Button::Attack);
|
||||
input.press(Button::Use);
|
||||
input.tick(&mut world, false, false);
|
||||
assert!(world.placed, "the use acts on the block the attack broke");
|
||||
assert!(world.block);
|
||||
assert!(world.hit.is_none(), "the next pick follows the crosshair");
|
||||
}
|
||||
}
|
||||
@@ -192,9 +192,17 @@ impl Assets {
|
||||
pub fn reload(&self, root: &Path) -> Result<RenderAssets> {
|
||||
RenderAssets::load(root, self)
|
||||
}
|
||||
/// [`Assets::reload`] for a server, which draws nothing: the models and
|
||||
/// the sprites' layers, without the textures' pixels.
|
||||
pub fn reload_for_server(&self, root: &Path) -> Result<RenderAssets> {
|
||||
RenderAssets::load_with(root, self, false)
|
||||
}
|
||||
}
|
||||
impl RenderAssets {
|
||||
pub fn load(root: &Path, assets: &Assets) -> Result<Self> {
|
||||
Self::load_with(root, assets, true)
|
||||
}
|
||||
fn load_with(root: &Path, assets: &Assets, pixels: bool) -> Result<Self> {
|
||||
let registry = assets.registry;
|
||||
let mut sprites = crate::sprites::Sprites::with_disk_cache(root);
|
||||
for name in MATERIALS {
|
||||
@@ -218,7 +226,11 @@ impl RenderAssets {
|
||||
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
|
||||
}
|
||||
sprites.save_disk_cache();
|
||||
sprites.finish();
|
||||
if pixels {
|
||||
sprites.finish();
|
||||
} else {
|
||||
sprites.finish_without_pixels();
|
||||
}
|
||||
// `finish` grows the buffer by doubling: keep only the bytes in use.
|
||||
sprites.pixels.shrink_to_fit();
|
||||
Ok(Self {
|
||||
@@ -241,6 +253,7 @@ impl RenderAssets {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::items::ConsumeEffect;
|
||||
use horizon_world::loot::PackOverlay;
|
||||
|
||||
#[test]
|
||||
fn always_edible_and_consume_effects_loaded() {
|
||||
@@ -251,7 +264,8 @@ mod tests {
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let items = crate::items::Items::load(&root, assets.registry, &facts).unwrap();
|
||||
let items =
|
||||
crate::items::Items::load(&root, assets.registry, &facts, &PackOverlay::NONE).unwrap();
|
||||
|
||||
let apple = items.get("minecraft:golden_apple").expect("golden apple");
|
||||
assert!(apple.can_always_eat);
|
||||
|
||||
@@ -865,13 +865,23 @@ const VIEW_DISTANCE: f64 = 64.0;
|
||||
|
||||
impl BlockEntityRenderer {
|
||||
pub fn load(root: &Path, registry: &BlockRegistry) -> crate::Result<Self> {
|
||||
let visibility = crate::models::load_visibility(root, registry)?;
|
||||
Self::load_with_visibility(root, registry, visibility)
|
||||
}
|
||||
/// [`BlockEntityRenderer::load`] over the face visibility the meshes use
|
||||
/// (`MeshTables::visibility`): a clone shares its shapes and their faces.
|
||||
pub fn load_with_visibility(
|
||||
root: &Path,
|
||||
registry: &BlockRegistry,
|
||||
visibility: FaceVisibility,
|
||||
) -> crate::Result<Self> {
|
||||
let all: Value = serde_json::from_slice(&std::fs::read(root.join("entity-models.json"))?)?;
|
||||
let models = MODELS
|
||||
.iter()
|
||||
.filter_map(|(key, layer)| all.get(*layer).map(|v| (*key, ModelPart::parse(v))))
|
||||
.collect();
|
||||
let mut renderer = Self::new(models, registry);
|
||||
renderer.visibility = Some(Mutex::new(crate::models::load_visibility(root, registry)?));
|
||||
renderer.visibility = Some(Mutex::new(visibility));
|
||||
// Sherd items: provides_pottery_pattern -> decorated_pot_pattern asset id.
|
||||
if let Ok(bytes) = std::fs::read(root.join("items.json")) {
|
||||
let items: Value = serde_json::from_slice(&bytes)?;
|
||||
@@ -2363,6 +2373,7 @@ mod tests {
|
||||
lines: [line.into(), "two".into(), String::new(), "four".into()],
|
||||
color: 3,
|
||||
glowing,
|
||||
..Default::default()
|
||||
};
|
||||
let first_text = crate::signs::SignData {
|
||||
front: text("one", false),
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::{
|
||||
items::{ItemStack, Items},
|
||||
providers::Providers,
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashSet, fs, path::Path};
|
||||
|
||||
@@ -142,20 +143,41 @@ pub struct Brewing {
|
||||
pub inputs: HashSet<String>,
|
||||
}
|
||||
impl Brewing {
|
||||
pub fn load(root: &Path, items: &Items) -> Self {
|
||||
let mut files: Vec<_> = fs::read_dir(root.join("data/minecraft/recipe/brewing"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
files.sort();
|
||||
let mut recipes = Vec::new();
|
||||
for p in files {
|
||||
let Ok(bytes) = fs::read(&p) else { continue };
|
||||
let Ok(v) = serde_json::from_slice::<Value>(&bytes) else {
|
||||
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Self {
|
||||
let mut sources: Vec<(String, Value)> =
|
||||
fs::read_dir(root.join("data/minecraft/recipe/brewing"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let bytes = fs::read(e.path()).ok()?;
|
||||
let v = serde_json::from_slice::<Value>(&bytes).ok()?;
|
||||
Some((
|
||||
format!(
|
||||
"minecraft:brewing/{}",
|
||||
e.file_name().to_string_lossy().strip_suffix(".json")?
|
||||
),
|
||||
v,
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
// The world's packs' brewing recipes over the game's own: the top
|
||||
// pack's file for each id replaces it (`SimpleJsonResourceReloadListener`).
|
||||
for (id, files) in overlay.kind("recipe") {
|
||||
if !id.starts_with("minecraft:brewing/") {
|
||||
continue;
|
||||
}
|
||||
let Ok(v) = serde_json::from_slice::<Value>(PackOverlay::top(files)) else {
|
||||
continue;
|
||||
};
|
||||
match sources.iter_mut().find(|(n, _)| n == id) {
|
||||
Some(slot) => slot.1 = v,
|
||||
None => sources.push((id.to_owned(), v)),
|
||||
}
|
||||
}
|
||||
sources.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let mut recipes = Vec::new();
|
||||
for (id, v) in sources {
|
||||
if v["type"] != "minecraft:brewing" {
|
||||
continue;
|
||||
}
|
||||
@@ -169,10 +191,7 @@ impl Brewing {
|
||||
output = output.with_potion(potion);
|
||||
}
|
||||
recipes.push(BrewingRecipe {
|
||||
id: format!(
|
||||
"minecraft:brewing/{}",
|
||||
p.file_stem().and_then(|s| s.to_str()).unwrap_or("")
|
||||
),
|
||||
id,
|
||||
input: PotionIngredient::parse(&v["input"], items),
|
||||
reagent: PotionIngredient::parse(&v["reagent"], items),
|
||||
output,
|
||||
|
||||
@@ -8,10 +8,171 @@ use std::sync::{Arc, OnceLock};
|
||||
|
||||
pub mod block_entity;
|
||||
|
||||
/// The vanilla Overworld's column, which every dimension's chunks hold for
|
||||
/// now (`LevelShape::column`).
|
||||
pub const MIN_Y: i32 = -64;
|
||||
pub const HEIGHT: i32 = 384;
|
||||
pub const SECTIONS: usize = (HEIGHT / 16) as usize;
|
||||
|
||||
/// A level's build range and skylight, from its dimension type (`min_y`,
|
||||
/// `height`, `logical_height`, `has_skylight`), as `LevelHeightAccessor` and
|
||||
/// `SerializableChunkData.write` read them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct LevelShape {
|
||||
pub sky_light: bool,
|
||||
pub min_y: i32,
|
||||
pub height: i32,
|
||||
/// `DimensionType.logicalHeight`: how high portals and chorus fruit reach.
|
||||
pub logical_height: i32,
|
||||
}
|
||||
impl LevelShape {
|
||||
pub const OVERWORLD: Self = Self {
|
||||
sky_light: true,
|
||||
min_y: MIN_Y,
|
||||
height: HEIGHT,
|
||||
logical_height: HEIGHT,
|
||||
};
|
||||
pub const NETHER: Self = Self {
|
||||
sky_light: false,
|
||||
min_y: 0,
|
||||
height: 256,
|
||||
logical_height: 128,
|
||||
};
|
||||
pub const END: Self = Self {
|
||||
sky_light: true,
|
||||
min_y: 0,
|
||||
height: 256,
|
||||
logical_height: 256,
|
||||
};
|
||||
/// A dimension type's shape, checked as its codec checks it.
|
||||
pub fn new(
|
||||
min_y: i32,
|
||||
height: i32,
|
||||
logical_height: i32,
|
||||
sky_light: bool,
|
||||
) -> Result<Self, String> {
|
||||
ColumnLayout::new(min_y, height)?;
|
||||
if !(0..=height).contains(&logical_height) {
|
||||
return Err(format!(
|
||||
"logical_height {logical_height} is outside 0..={height}"
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
sky_light,
|
||||
min_y,
|
||||
height,
|
||||
logical_height,
|
||||
})
|
||||
}
|
||||
/// One above the highest block (`getMaxY() + 1`).
|
||||
pub fn max_y(self) -> i32 {
|
||||
self.min_y + self.height
|
||||
}
|
||||
pub fn min_section(self) -> i32 {
|
||||
self.min_y >> 4
|
||||
}
|
||||
pub fn section_count(self) -> usize {
|
||||
(self.height >> 4) as usize
|
||||
}
|
||||
/// Whether section `y` (a section coordinate) is inside the level.
|
||||
pub fn has_section(self, y: i32) -> bool {
|
||||
(self.min_section()..self.min_section() + self.section_count() as i32).contains(&y)
|
||||
}
|
||||
/// The sections a chunk of this level holds: the level's, widened to the
|
||||
/// vanilla Overworld's `-64..320` so that vanilla's Nether and End keep
|
||||
/// the 24 sections they have always had.
|
||||
pub fn column(self) -> ColumnLayout {
|
||||
ColumnLayout::of_level(self.min_y, self.height)
|
||||
}
|
||||
}
|
||||
|
||||
/// The span of block Y a chunk holds: from `min_y`, `sections` sections up
|
||||
/// (`LevelShape::column`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ColumnLayout {
|
||||
min_y: i32,
|
||||
sections: usize,
|
||||
}
|
||||
impl ColumnLayout {
|
||||
pub const VANILLA: Self = Self {
|
||||
min_y: MIN_Y,
|
||||
sections: SECTIONS,
|
||||
};
|
||||
/// The column of a level from `min_y`, `height` high
|
||||
/// (`LevelShape::column`).
|
||||
pub fn of_level(min_y: i32, height: i32) -> Self {
|
||||
let low = min_y.min(MIN_Y);
|
||||
let high = (min_y + height).max(MIN_Y + HEIGHT);
|
||||
Self {
|
||||
min_y: low,
|
||||
sections: ((high - low) / 16) as usize,
|
||||
}
|
||||
}
|
||||
/// `DimensionType.MIN_Y`, `MAX_Y`: 12 bits of `BlockPos` Y, less 32.
|
||||
pub const LOWEST_Y: i32 = -2032;
|
||||
pub const HIGHEST_Y: i32 = 2031;
|
||||
/// A dimension type's `min_y` and `height`, as its codec accepts them:
|
||||
/// multiples of 16, at least 16 high, within `LOWEST_Y..=HIGHEST_Y`.
|
||||
pub fn new(min_y: i32, height: i32) -> Result<Self, String> {
|
||||
if min_y % 16 != 0 || height % 16 != 0 {
|
||||
return Err(format!(
|
||||
"min_y {min_y} and height {height} must be multiples of 16"
|
||||
));
|
||||
}
|
||||
if height < 16 {
|
||||
return Err(format!("height {height} is below 16"));
|
||||
}
|
||||
if min_y < Self::LOWEST_Y
|
||||
|| i64::from(min_y) + i64::from(height) > i64::from(Self::HIGHEST_Y) + 1
|
||||
{
|
||||
return Err(format!(
|
||||
"min_y {min_y} with height {height} leaves {}..={}",
|
||||
Self::LOWEST_Y,
|
||||
Self::HIGHEST_Y
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
min_y,
|
||||
sections: (height / 16) as usize,
|
||||
})
|
||||
}
|
||||
pub fn min_y(self) -> i32 {
|
||||
self.min_y
|
||||
}
|
||||
/// One above the highest block.
|
||||
pub fn max_y(self) -> i32 {
|
||||
self.min_y + self.height()
|
||||
}
|
||||
pub fn height(self) -> i32 {
|
||||
self.sections as i32 * 16
|
||||
}
|
||||
pub fn sections(self) -> usize {
|
||||
self.sections
|
||||
}
|
||||
/// The index of the section holding block `y`, if the column holds it.
|
||||
pub fn section_index(self, y: i32) -> Option<usize> {
|
||||
usize::try_from((y - self.min_y) >> 4)
|
||||
.ok()
|
||||
.filter(|&s| s < self.sections)
|
||||
}
|
||||
/// The minimum block Y of section `index`.
|
||||
pub fn section_y(self, index: usize) -> i32 {
|
||||
self.min_y + index as i32 * 16
|
||||
}
|
||||
}
|
||||
/// `true` for each section of `layout`: what callers that keep no
|
||||
/// per-section record (of fluids, say) pass as "every section may have it".
|
||||
pub fn every_section(layout: ColumnLayout) -> &'static [bool] {
|
||||
static ALL: [bool; crate::section_mask::SectionMask::CAPACITY] =
|
||||
[true; crate::section_mask::SectionMask::CAPACITY];
|
||||
&ALL[..layout.sections()]
|
||||
}
|
||||
impl Default for ColumnLayout {
|
||||
fn default() -> Self {
|
||||
Self::VANILLA
|
||||
}
|
||||
}
|
||||
|
||||
/// Biomes are 4x4x4 containers over the biome table (67 entries: a 7-bit
|
||||
/// global palette).
|
||||
pub const BIOME_STRATEGY: Strategy = Strategy::biomes(7);
|
||||
@@ -138,9 +299,11 @@ impl Section {
|
||||
pub struct Chunk {
|
||||
pub x: i32,
|
||||
pub z: i32,
|
||||
/// The minimum block Y of `sections[0]` (the layout's when it was made).
|
||||
min_y: i32,
|
||||
pub sections: Vec<Arc<Section>>,
|
||||
/// `Heightmap.Types.WORLD_SURFACE` per column (`z << 4 | x`): one above
|
||||
/// the highest non-air block, or `MIN_Y`. Built on the first read; a
|
||||
/// the highest non-air block, or the column's minimum Y. Built on the first read; a
|
||||
/// `set` that can move a column's surface drops it.
|
||||
surface: OnceLock<Box<[i16; 256]>>,
|
||||
/// `Heightmap.Types.MOTION_BLOCKING` per column, kept like `surface`.
|
||||
@@ -169,11 +332,16 @@ pub struct Chunk {
|
||||
pub structure_starts: Vec<Arc<horizon_world::worldgen::structure::start::StructureStart>>,
|
||||
}
|
||||
impl Chunk {
|
||||
/// An all-air chunk in the current `layout`.
|
||||
pub fn empty(x: i32, z: i32) -> Self {
|
||||
Self::with_layout(x, z, ColumnLayout::VANILLA)
|
||||
}
|
||||
pub fn with_layout(x: i32, z: i32, layout: ColumnLayout) -> Self {
|
||||
Self {
|
||||
x,
|
||||
z,
|
||||
sections: (0..SECTIONS)
|
||||
min_y: layout.min_y(),
|
||||
sections: (0..layout.sections())
|
||||
.map(|_| Arc::new(Section::default()))
|
||||
.collect(),
|
||||
surface: OnceLock::new(),
|
||||
@@ -190,13 +358,36 @@ impl Chunk {
|
||||
entities: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// The minimum block Y of the column.
|
||||
#[inline]
|
||||
pub fn min_y(&self) -> i32 {
|
||||
self.min_y
|
||||
}
|
||||
/// The column the chunk holds.
|
||||
pub fn layout(&self) -> ColumnLayout {
|
||||
ColumnLayout {
|
||||
min_y: self.min_y,
|
||||
sections: self.sections.len(),
|
||||
}
|
||||
}
|
||||
/// One above the column's highest block.
|
||||
#[inline]
|
||||
pub fn max_y(&self) -> i32 {
|
||||
self.min_y + self.sections.len() as i32 * 16
|
||||
}
|
||||
/// The index of the section holding block `y`, if the column holds it.
|
||||
#[inline]
|
||||
pub fn section_index(&self, y: i32) -> Option<usize> {
|
||||
usize::try_from((y - self.min_y) >> 4)
|
||||
.ok()
|
||||
.filter(|&s| s < self.sections.len())
|
||||
}
|
||||
#[inline]
|
||||
pub fn get(&self, x: i32, y: i32, z: i32) -> u32 {
|
||||
let s = (y - MIN_Y) >> 4;
|
||||
if !(0..SECTIONS as i32).contains(&s) {
|
||||
return 0;
|
||||
match self.section_index(y) {
|
||||
Some(s) => self.sections[s].get(index(x, y, z)),
|
||||
None => 0,
|
||||
}
|
||||
self.sections[s as usize].get(index(x, y, z))
|
||||
}
|
||||
#[inline]
|
||||
pub fn block(&self, x: i32, y: i32, z: i32) -> BlockStateId {
|
||||
@@ -211,16 +402,15 @@ impl Chunk {
|
||||
.rev()
|
||||
.filter(|(_, section)| !section.is_uniform(skip))
|
||||
.find_map(|(s, _)| {
|
||||
let base = MIN_Y + s as i32 * 16;
|
||||
let base = self.min_y + s as i32 * 16;
|
||||
(base..base + 16).rev().find(|&y| matches(y))
|
||||
})
|
||||
}
|
||||
pub fn set(&mut self, x: i32, y: i32, z: i32, v: u32) -> u32 {
|
||||
let s = (y - MIN_Y) >> 4;
|
||||
if !(0..SECTIONS as i32).contains(&s) {
|
||||
let Some(s) = self.section_index(y) else {
|
||||
return v;
|
||||
}
|
||||
let previous = Arc::make_mut(&mut self.sections[s as usize]).set(index(x, y, z), v);
|
||||
};
|
||||
let previous = Arc::make_mut(&mut self.sections[s]).set(index(x, y, z), v);
|
||||
// `Heightmap.update`: a write two or more below a map's height can't
|
||||
// move it. Any other change rebuilds that map on the next read.
|
||||
if previous != v {
|
||||
@@ -236,7 +426,8 @@ impl Chunk {
|
||||
previous
|
||||
}
|
||||
/// `getHeight(Heightmap.Types.WORLD_SURFACE, x, z)` (`x`, `z` within the
|
||||
/// chunk): one above the highest block that isn't `air`, or `MIN_Y`.
|
||||
/// chunk): one above the highest block that isn't `air`, or the column's
|
||||
/// minimum Y.
|
||||
/// Sections must change only through `set` once this has been read.
|
||||
pub fn world_surface(&self, x: i32, z: i32, air: impl Fn(u32) -> bool) -> i32 {
|
||||
let surface = self.surface.get_or_init(|| self.prime(|v| !air(v)));
|
||||
@@ -244,7 +435,8 @@ impl Chunk {
|
||||
}
|
||||
/// `getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)` (`x`, `z` within
|
||||
/// the chunk): one above the highest state where `blocks` (a motion
|
||||
/// blocking block or a fluid), or `MIN_Y`. Kept as `world_surface` is.
|
||||
/// blocking block or a fluid), or the column's minimum Y. Kept as
|
||||
/// `world_surface` is.
|
||||
pub fn motion_blocking(&self, x: i32, z: i32, blocks: impl Fn(u32) -> bool) -> i32 {
|
||||
let heights = self.motion_blocking.get_or_init(|| self.prime(blocks));
|
||||
i32::from(heights[column(x, z)])
|
||||
@@ -252,7 +444,7 @@ impl Chunk {
|
||||
/// `Heightmap.primeHeightmaps`: each section from the top, skipping
|
||||
/// those of one state that doesn't count, until every column is found.
|
||||
fn prime(&self, counts: impl Fn(u32) -> bool) -> Box<[i16; 256]> {
|
||||
let mut surface = Box::new([MIN_Y as i16; 256]);
|
||||
let mut surface = Box::new([self.min_y as i16; 256]);
|
||||
let mut found = [false; 256];
|
||||
let mut left = 256;
|
||||
let mut values = [0u32; 4096];
|
||||
@@ -264,7 +456,7 @@ impl Chunk {
|
||||
continue;
|
||||
}
|
||||
section.unpack(&mut values);
|
||||
let base = MIN_Y + s as i32 * 16;
|
||||
let base = self.min_y + s as i32 * 16;
|
||||
for i in 0..256 {
|
||||
if found[i] {
|
||||
continue;
|
||||
@@ -280,11 +472,11 @@ impl Chunk {
|
||||
}
|
||||
#[inline]
|
||||
pub fn light(&self, x: i32, y: i32, z: i32) -> u8 {
|
||||
let s = (y - MIN_Y) >> 4;
|
||||
let s = (y - self.min_y) >> 4;
|
||||
if s < 0 {
|
||||
return 0;
|
||||
}
|
||||
if s >= SECTIONS as i32 {
|
||||
if s >= self.sections.len() as i32 {
|
||||
return 0xF0;
|
||||
}
|
||||
self.sections[s as usize].light(index(x, y, z))
|
||||
@@ -297,7 +489,7 @@ impl Chunk {
|
||||
/// the quart y clamped to the column).
|
||||
#[inline]
|
||||
pub fn biome(&self, x: i32, y: i32, z: i32) -> u16 {
|
||||
let qy = ((y - MIN_Y) >> 2).clamp(0, HEIGHT / 4 - 1);
|
||||
let qy = ((y - self.min_y) >> 2).clamp(0, self.sections.len() as i32 * 4 - 1);
|
||||
self.sections[(qy >> 2) as usize]
|
||||
.biomes
|
||||
.get(((qy & 3) << 4 | ((z & 15) >> 2) << 2 | (x & 15) >> 2) as usize) as u16
|
||||
@@ -504,4 +696,69 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapes_follow_the_dimension_type_codec() {
|
||||
assert_eq!(ColumnLayout::new(-64, 384), Ok(ColumnLayout::VANILLA));
|
||||
assert_eq!(ColumnLayout::default(), ColumnLayout::VANILLA);
|
||||
// To The Max's Overworld: the highest top vanilla allows.
|
||||
let tall = ColumnLayout::new(-64, 2096).unwrap();
|
||||
assert_eq!((tall.sections(), tall.max_y()), (131, 2032));
|
||||
assert!(ColumnLayout::new(-2032, 4064).is_ok());
|
||||
for (min_y, height) in [(-64, 2112), (-2048, 64), (-60, 384), (0, 250), (0, 0)] {
|
||||
assert!(
|
||||
ColumnLayout::new(min_y, height).is_err(),
|
||||
"{min_y} {height}"
|
||||
);
|
||||
}
|
||||
assert_eq!(tall.section_index(-65), None);
|
||||
assert_eq!(tall.section_index(-64), Some(0));
|
||||
assert_eq!(tall.section_index(2031), Some(130));
|
||||
assert_eq!(tall.section_index(2032), None);
|
||||
assert_eq!(tall.section_y(130), 2016);
|
||||
assert_eq!(
|
||||
LevelShape::new(-64, 384, 384, true),
|
||||
Ok(LevelShape::OVERWORLD)
|
||||
);
|
||||
assert_eq!(LevelShape::new(0, 256, 128, false), Ok(LevelShape::NETHER));
|
||||
assert!(LevelShape::new(0, 256, 257, true).is_err());
|
||||
assert!(LevelShape::new(-60, 384, 384, true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanilla_levels_keep_the_overworld_column() {
|
||||
for shape in [LevelShape::OVERWORLD, LevelShape::NETHER, LevelShape::END] {
|
||||
assert_eq!(shape.column(), ColumnLayout::VANILLA, "{shape:?}");
|
||||
}
|
||||
let tall = LevelShape::new(-64, 2096, 2096, true).unwrap();
|
||||
assert_eq!(tall.column(), ColumnLayout::new(-64, 2096).unwrap());
|
||||
let deep = LevelShape::new(-512, 256, 256, false).unwrap();
|
||||
assert_eq!(deep.column(), ColumnLayout::new(-512, 832).unwrap());
|
||||
assert!(LevelShape::NETHER.has_section(15) && !LevelShape::NETHER.has_section(16));
|
||||
assert!(!LevelShape::NETHER.has_section(-1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tall_column_stores_its_whole_height() {
|
||||
let layout = ColumnLayout::new(-64, 2096).unwrap();
|
||||
let mut chunk = Chunk::with_layout(0, 0, layout);
|
||||
assert_eq!(
|
||||
(chunk.min_y(), chunk.max_y(), chunk.sections.len()),
|
||||
(-64, 2032, 131)
|
||||
);
|
||||
let stone = 1;
|
||||
assert_eq!(chunk.set(5, 2031, 6, stone), 0);
|
||||
assert_eq!(chunk.set(5, 2032, 6, stone), stone, "above the column");
|
||||
assert_eq!(chunk.get(5, 2031, 6), stone);
|
||||
assert_eq!(chunk.get(5, 2032, 6), 0);
|
||||
assert_eq!(chunk.world_surface(5, 6, |v| v == 0), 2032);
|
||||
assert_eq!(chunk.world_surface(0, 0, |v| v == 0), -64);
|
||||
assert_eq!(chunk.highest(0, |y| y < 1000), None);
|
||||
chunk.set(5, 999, 6, stone);
|
||||
assert_eq!(chunk.highest(0, |y| y < 1000), Some(999));
|
||||
assert_eq!(chunk.light(0, 2100, 0), 0xF0);
|
||||
assert_eq!(chunk.light(0, -65, 0), 0);
|
||||
chunk.set_biome_cell(0, 130 * 4 + 3, 0, 9);
|
||||
assert_eq!(chunk.biome(0, 5000, 0), 9);
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,24 @@ pub enum BlockEntity {
|
||||
Shelf(Box<Shelf>),
|
||||
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
|
||||
ChiseledBookshelf(Box<ChiseledBookshelf>),
|
||||
/// `PotentSulfurBlockEntity` (`game/potent_sulfur.rs`).
|
||||
PotentSulfur(PotentSulfur),
|
||||
}
|
||||
/// `PotentSulfurBlockEntity`: the seconds left to the geyser's next change of
|
||||
/// state (`waitingCountdown`, saved as `countdown`) and the game time of its
|
||||
/// last eruption block event, which the client's plume counts from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct PotentSulfur {
|
||||
pub waiting_countdown: i32,
|
||||
pub eruption_tick: i64,
|
||||
}
|
||||
impl Default for PotentSulfur {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
waiting_countdown: -1,
|
||||
eruption_tick: -1,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `BrushableBlockEntity`: the loot table is resolved on the first brush.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -228,6 +246,8 @@ pub struct SignText {
|
||||
/// `DyeColor` id.
|
||||
pub color: u8,
|
||||
pub glowing: bool,
|
||||
/// The `click_event` in each line's component style, as saved.
|
||||
pub click: [Option<Tag>; 4],
|
||||
}
|
||||
/// `SignBlockEntity` / `HangingSignBlockEntity` data.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
@@ -236,6 +256,8 @@ pub struct SignData {
|
||||
pub front: SignText,
|
||||
pub back: SignText,
|
||||
pub waxed: bool,
|
||||
/// `allow_op_features`: the sign's click events may run commands.
|
||||
pub allow_op_features: bool,
|
||||
}
|
||||
/// `VibrationInfo`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
|
||||
@@ -82,6 +82,9 @@ impl crate::light::LightWorld for ChunkMap {
|
||||
fn set_section_light(&mut self, cx: i32, s: usize, cz: i32, light: crate::chunk::Light) {
|
||||
self.columns.set_section_light(cx, s, cz, light);
|
||||
}
|
||||
fn layout(&self) -> crate::chunk::ColumnLayout {
|
||||
self.columns.layout()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -305,7 +305,7 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
|
||||
/// From the section's palette, without reading its blocks; a palette
|
||||
/// of the whole registry reads every cell.
|
||||
fn may_reach_out(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result<bool> {
|
||||
let blocks = usize::try_from(section_y - (crate::chunk::MIN_Y >> 4))
|
||||
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
|
||||
.ok()
|
||||
.and_then(|s| chunk.sections.get(s))
|
||||
.map(|section| §ion.blocks);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! from the data-driven `context_int_provider`/`context_float_provider` files.
|
||||
pub use crate::chunk::block_entity::{
|
||||
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
|
||||
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, Shelf, Skull, SkullSkin,
|
||||
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, PotentSulfur, Shelf, Skull, SkullSkin,
|
||||
};
|
||||
use crate::items::ItemStack;
|
||||
use std::collections::HashMap;
|
||||
@@ -306,6 +306,9 @@ impl BlockEntity {
|
||||
if name == "minecraft:chiseled_bookshelf" {
|
||||
return Some(Self::ChiseledBookshelf(Box::default()));
|
||||
}
|
||||
if name == "minecraft:potent_sulfur" {
|
||||
return Some(Self::PotentSulfur(PotentSulfur::default()));
|
||||
}
|
||||
let size = match name {
|
||||
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => 27,
|
||||
n if n.ends_with("copper_chest") => 27,
|
||||
@@ -349,6 +352,7 @@ impl BlockEntity {
|
||||
Self::Campfire(_) => "minecraft:campfire",
|
||||
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
|
||||
Self::Shelf(_) => "minecraft:shelf",
|
||||
Self::PotentSulfur(_) => "minecraft:potent_sulfur",
|
||||
}
|
||||
}
|
||||
pub fn contents(&self) -> Vec<ItemStack> {
|
||||
@@ -367,6 +371,7 @@ impl BlockEntity {
|
||||
| Self::CommandBlock(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::PotentSulfur(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => Vec::new(),
|
||||
Self::Lectern(l) => l.book.iter().cloned().collect(),
|
||||
@@ -391,6 +396,7 @@ impl BlockEntity {
|
||||
| Self::Banner(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::PotentSulfur(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => &[],
|
||||
Self::Crafter(c) => &c.items,
|
||||
@@ -419,6 +425,7 @@ impl BlockEntity {
|
||||
| Self::Banner(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::PotentSulfur(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => &mut [],
|
||||
Self::Crafter(c) => &mut c.items,
|
||||
|
||||
@@ -423,9 +423,31 @@ pub struct DataStore {
|
||||
pub predicates: BTreeMap<String, serde_json::Value>,
|
||||
/// `item_modifier` files, for `/item modify`.
|
||||
pub item_modifiers: BTreeMap<String, serde_json::Value>,
|
||||
/// The content tables' raw files from the packs after the built-in one:
|
||||
/// `recipe/`, `loot_table/`, `advancement/` and `tags/…/` files, by
|
||||
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
|
||||
/// merge them over what they read from the resource root.
|
||||
pub files: BTreeMap<String, Vec<Vec<u8>>>,
|
||||
/// Functions that failed to parse (id, message), for logging.
|
||||
pub errors: Vec<(String, String)>,
|
||||
}
|
||||
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
|
||||
/// `recipe/`, `loot_table/`, `advancement/` or `tags/<sub>/`, where the id
|
||||
/// keeps the sub path so a nested tag kind stays addressable.
|
||||
fn content_id(file: &str) -> Option<String> {
|
||||
let rest = file.strip_prefix("data/")?;
|
||||
let (ns, rest) = rest.split_once('/')?;
|
||||
let (kind, rest) = rest.split_once('/')?;
|
||||
let kind = match kind {
|
||||
"recipe" | "loot_table" | "advancement" => kind,
|
||||
"tags" => {
|
||||
let (sub, name) = rest.rsplit_once('/')?;
|
||||
return Some(format!("tags/{sub}/{ns}:{}", name.strip_suffix(".json")?));
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(format!("{kind}/{ns}:{}", rest.strip_suffix(".json")?))
|
||||
}
|
||||
/// `ns:path` for a file `data/<ns>/<kind>/<path>.<ext>`.
|
||||
fn resource_id(file: &str, kind: &str, ext: &str) -> Option<String> {
|
||||
let rest = file.strip_prefix("data/")?;
|
||||
@@ -440,7 +462,22 @@ impl DataStore {
|
||||
pub fn load(packs: &[&DataPack]) -> Self {
|
||||
let mut s = Self::default();
|
||||
for pack in packs {
|
||||
for f in pack.list_kinds(&["function", "item_modifier", "predicate", "tags/function"]) {
|
||||
// `tags` walks the whole tags tree, so a `tags/function` file is
|
||||
// listed by both kinds: handle every file once.
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let builtin = pack.id == "vanilla";
|
||||
for f in pack.list_kinds(&[
|
||||
"advancement",
|
||||
"function",
|
||||
"item_modifier",
|
||||
"loot_table",
|
||||
"predicate",
|
||||
"recipe",
|
||||
"tags",
|
||||
]) {
|
||||
if !seen.insert(f.clone()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(id) = resource_id(&f, "function", ".mcfunction")
|
||||
&& let Some(bytes) = pack.read(&f)
|
||||
{
|
||||
@@ -470,6 +507,14 @@ impl DataStore {
|
||||
&& let Ok(v) = serde_json::from_slice(&bytes)
|
||||
{
|
||||
s.item_modifiers.insert(id, v);
|
||||
} else if !builtin
|
||||
&& let Some(id) = content_id(&f)
|
||||
&& let Some(bytes) = pack.read(&f)
|
||||
{
|
||||
// The built-in pack's content files are the resource
|
||||
// root's own, which the tables read from disk; only the
|
||||
// world's packs come through here.
|
||||
s.files.entry(id).or_default().push(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -479,11 +524,21 @@ impl DataStore {
|
||||
/// included, each function once).
|
||||
pub fn tag_functions(&self, id: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
self.expand(id, &mut out, 0);
|
||||
let mut seen_fns = std::collections::HashSet::new();
|
||||
let mut seen_tags = std::collections::HashSet::new();
|
||||
self.expand(id, &mut out, &mut seen_fns, &mut seen_tags);
|
||||
out
|
||||
}
|
||||
fn expand(&self, id: &str, out: &mut Vec<String>, depth: usize) {
|
||||
if depth > 16 {
|
||||
/// Depth-first walk keeping first-seen order. Each tag is expanded at most
|
||||
/// once, so reference cycles end and nesting depth is not capped.
|
||||
fn expand<'a>(
|
||||
&'a self,
|
||||
id: &'a str,
|
||||
out: &mut Vec<String>,
|
||||
seen_fns: &mut std::collections::HashSet<&'a str>,
|
||||
seen_tags: &mut std::collections::HashSet<&'a str>,
|
||||
) {
|
||||
if !seen_tags.insert(id) {
|
||||
return;
|
||||
}
|
||||
let Some(entries) = self.function_tags.get(id) else {
|
||||
@@ -491,9 +546,9 @@ impl DataStore {
|
||||
};
|
||||
for (e, required) in entries {
|
||||
match e.strip_prefix('#') {
|
||||
Some(inner) => self.expand(inner, out, depth + 1),
|
||||
Some(inner) => self.expand(inner, out, seen_fns, seen_tags),
|
||||
None => {
|
||||
if (self.functions.contains_key(e) || *required) && !out.contains(e) {
|
||||
if (self.functions.contains_key(e) || *required) && seen_fns.insert(e) {
|
||||
out.push(e.clone());
|
||||
}
|
||||
}
|
||||
@@ -767,6 +822,40 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_resolution_dedupes_fast_ends_cycles_and_nests_deeply() {
|
||||
let mut store = DataStore::default();
|
||||
let big: Vec<(String, bool)> = (0..50_000)
|
||||
.map(|i| (format!("t:f{}", i % 25_000), true))
|
||||
.collect();
|
||||
store.function_tags.insert("t:big".into(), big);
|
||||
let start = std::time::Instant::now();
|
||||
let got = store.tag_functions("t:big");
|
||||
assert_eq!(got.len(), 25_000);
|
||||
assert_eq!(got[0], "t:f0");
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(5));
|
||||
// A cycle terminates and keeps first-seen order.
|
||||
store.function_tags.insert(
|
||||
"t:a".into(),
|
||||
vec![("#t:b".into(), true), ("t:x".into(), true)],
|
||||
);
|
||||
store.function_tags.insert(
|
||||
"t:b".into(),
|
||||
vec![("#t:a".into(), true), ("t:y".into(), true)],
|
||||
);
|
||||
assert_eq!(store.tag_functions("t:a"), ["t:y", "t:x"]);
|
||||
// Nesting deeper than the old cap of 16 still resolves.
|
||||
for i in 0..40 {
|
||||
store
|
||||
.function_tags
|
||||
.insert(format!("d:{i}"), vec![(format!("#d:{}", i + 1), true)]);
|
||||
}
|
||||
store
|
||||
.function_tags
|
||||
.insert("d:40".into(), vec![("t:deep".into(), true)]);
|
||||
assert_eq!(store.tag_functions("d:0"), ["t:deep"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_packs_list_the_needed_kinds_in_walk_order() {
|
||||
let dir = std::env::temp_dir().join(format!("hz-datapack-kinds-{}", std::process::id()));
|
||||
|
||||
@@ -53,7 +53,7 @@ struct Out {
|
||||
@location(9) light_color: vec4<f32>,
|
||||
@location(10) @interpolate(flat) weather_rect: vec4<f32>,
|
||||
// The `GLINT_TEXTURING` sample for overlay 7 (item foil), or the model UV
|
||||
// `ARMOR_ENTITY_GLINT_TEXTURING` scrolls for overlay 13 (armour).
|
||||
// `ARMOR_ENTITY_GLINT_TEXTURING` scrolls for overlay 36 (armour).
|
||||
@location(11) glint_uv: vec2<f32>,
|
||||
// 1 drops the quad's back-facing pixels (Vertex::normal w = 1), like a culling render type.
|
||||
@location(12) @interpolate(flat) cull: u32,
|
||||
@@ -254,8 +254,8 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
|
||||
// Enchantment glint: item.fsh's GLINT path, sampled from
|
||||
// enchanted_glint_item at the `GLINT_TEXTURING` UV.
|
||||
color = glint_over(color, textureSample(glint_tex, glint_sampler, in.glint_uv));
|
||||
} else if in.overlay == 13u {
|
||||
// Enchanted armour: enchanted_glint_armor at the scrolling model UV.
|
||||
} else if in.overlay == 36u {
|
||||
// Enchanted armour (mobs::render::ARMOR_GLINT): enchanted_glint_armor at the scrolling model UV.
|
||||
color = glint_over(
|
||||
color,
|
||||
textureSample(glint_armor_tex, glint_sampler, armor_glint_uv(in.glint_uv)),
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::{
|
||||
graphics::GraphicsApi,
|
||||
hud::{HudBatch, HudRenderer},
|
||||
mesher::{SectionMesh, Sides, TerrainVertex, Vertex},
|
||||
section_mask::SectionMask,
|
||||
sprites::SpriteAnimation,
|
||||
};
|
||||
use glam::{DVec3, Mat4, Vec3, Vec4};
|
||||
@@ -105,8 +106,6 @@ mod view;
|
||||
mod wgsl_prefix;
|
||||
mod world_post;
|
||||
|
||||
/// A column whose every section has its mesh (`Renderer::lod_detail_columns`).
|
||||
const COMPLETE_COLUMN: u32 = (1 << (crate::chunk::HEIGHT / 16)) - 1;
|
||||
/// A section's instance: its camera-relative origin and, in `w`, the step of
|
||||
/// its fixed-point UVs (`terrain.wgsl`'s `terrain_uv`).
|
||||
/// The world pass again after occlusion culling's test: it loads both
|
||||
@@ -517,7 +516,7 @@ pub struct Renderer {
|
||||
lod_retiring: crate::fast_hash::FxHashSet<SectionKey>,
|
||||
/// Finished tile meshes `upload_lods` has yet to upload.
|
||||
lod_pending: Option<lod::Pending>,
|
||||
detail_columns: crate::fast_hash::FxHashMap<(i32, i32), u32>,
|
||||
detail_columns: crate::fast_hash::FxHashMap<(i32, i32), SectionMask>,
|
||||
/// Bumped whenever a column becomes or stops being complete.
|
||||
detail_revision: u64,
|
||||
arena: arena::Arena,
|
||||
@@ -556,6 +555,9 @@ pub struct Renderer {
|
||||
block_entity_revision: u64,
|
||||
/// VisGraph face connectivity per meshed section (cave culling).
|
||||
occlusion: occlusion::Occlusion,
|
||||
/// The column `occlusion`, `sections` and `detail_columns` count in: the
|
||||
/// level's (`clear_sections`).
|
||||
layout: crate::chunk::ColumnLayout,
|
||||
/// Resolution scale, FXAA and TAA (Horizon settings).
|
||||
world_post: world_post::Post,
|
||||
/// HDR output (Horizon setting): `Some` while the swapchain is HDR.
|
||||
@@ -2744,6 +2746,7 @@ impl Renderer {
|
||||
block_entities: Default::default(),
|
||||
block_entity_revision: next_block_entity_revision(),
|
||||
occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16),
|
||||
layout: crate::chunk::ColumnLayout::VANILLA,
|
||||
world_post,
|
||||
hdr: None,
|
||||
hdr_output,
|
||||
@@ -3180,7 +3183,8 @@ impl Renderer {
|
||||
pub fn block_entity_revision(&self) -> u64 {
|
||||
self.block_entity_revision
|
||||
}
|
||||
pub fn clear_sections(&mut self) {
|
||||
/// Drops every section, for a level whose chunks hold `layout`.
|
||||
pub fn clear_sections(&mut self, layout: crate::chunk::ColumnLayout) {
|
||||
self.block_entities.clear();
|
||||
self.block_entity_revision = next_block_entity_revision();
|
||||
self.sections.clear();
|
||||
@@ -3196,12 +3200,23 @@ impl Renderer {
|
||||
}
|
||||
self.sorted_indices.clear();
|
||||
self.translucency_cursor = 0;
|
||||
self.occlusion.clear();
|
||||
if layout == self.layout {
|
||||
self.occlusion.clear();
|
||||
} else {
|
||||
self.layout = layout;
|
||||
self.occlusion = occlusion::Occlusion::new(layout.min_y(), layout.sections() as i32);
|
||||
self.sections.set_min_y(layout.min_y());
|
||||
}
|
||||
}
|
||||
/// A column whose every section has its mesh (`lod_detail_columns`).
|
||||
fn complete_column(&self) -> SectionMask {
|
||||
SectionMask::below(self.layout.sections())
|
||||
}
|
||||
pub fn lod_detail_columns(&self) -> impl Iterator<Item = (i32, i32)> + '_ {
|
||||
let complete = self.complete_column();
|
||||
self.detail_columns
|
||||
.iter()
|
||||
.filter_map(move |(&column, &mask)| (mask == COMPLETE_COLUMN).then_some(column))
|
||||
.filter_map(move |(&column, &mask)| (mask == complete).then_some(column))
|
||||
}
|
||||
/// Changes whenever `lod_detail_columns` does.
|
||||
pub fn lod_detail_revision(&self) -> u64 {
|
||||
@@ -3210,16 +3225,22 @@ impl Renderer {
|
||||
/// Whether the section at `key` has its mesh, for `lod_detail_columns`.
|
||||
fn set_detail(&mut self, key: SectionKey, meshed: bool) {
|
||||
let column = (key.0, key.2);
|
||||
let mask = self.detail_columns.get(&column).copied().unwrap_or(0);
|
||||
let next = if meshed {
|
||||
mask | 1 << key.1
|
||||
let mask = self
|
||||
.detail_columns
|
||||
.get(&column)
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
let mut next = mask;
|
||||
if meshed {
|
||||
next.insert(key.1 as usize);
|
||||
} else {
|
||||
mask & !(1 << key.1)
|
||||
};
|
||||
if (mask == COMPLETE_COLUMN) != (next == COMPLETE_COLUMN) {
|
||||
next.remove(key.1 as usize);
|
||||
}
|
||||
let complete = self.complete_column();
|
||||
if (mask == complete) != (next == complete) {
|
||||
self.detail_revision += 1;
|
||||
}
|
||||
if next == 0 {
|
||||
if next.is_empty() {
|
||||
self.detail_columns.remove(&column);
|
||||
} else {
|
||||
self.detail_columns.insert(column, next);
|
||||
@@ -3232,12 +3253,12 @@ impl Renderer {
|
||||
let mut released = Vec::new();
|
||||
let mut block_entities = false;
|
||||
for (x, z) in columns {
|
||||
if self.detail_columns.remove(&(x, z)) == Some(COMPLETE_COLUMN) {
|
||||
if self.detail_columns.remove(&(x, z)) == Some(self.complete_column()) {
|
||||
self.detail_revision += 1;
|
||||
}
|
||||
self.occlusion.remove_column(x, z);
|
||||
let retiring = !self.lod_cutout.is_empty() && self.lod_cutout.contains(&(x, z));
|
||||
for sy in 0..crate::chunk::SECTIONS as i32 {
|
||||
for sy in 0..self.layout.sections() as i32 {
|
||||
let key = (x, sy, z);
|
||||
block_entities |= self.block_entities.remove(&key).is_some();
|
||||
if retiring {
|
||||
@@ -3927,7 +3948,7 @@ impl Renderer {
|
||||
planes: frustum(cull_proj),
|
||||
};
|
||||
let camera = frame.camera;
|
||||
let camera_section = camera_section(camera);
|
||||
let camera_section = camera_section(camera, self.layout.min_y());
|
||||
// Solid quads facing away from the eye are left out: the bob moves
|
||||
// the eye off `camera`, so it is found from the projection.
|
||||
let eye = projection_center(cull_proj).filter(|_| self.sides_on);
|
||||
@@ -4141,7 +4162,7 @@ impl Renderer {
|
||||
let scheduled = (i + count - self.translucency_cursor) % count < budget;
|
||||
let origin = [
|
||||
v.key.0 * 16,
|
||||
crate::chunk::MIN_Y + v.key.1 * 16,
|
||||
self.layout.min_y() + v.key.1 * 16,
|
||||
v.key.2 * 16,
|
||||
];
|
||||
if (!s.sort.is_sorted() || nearby || scheduled)
|
||||
@@ -4244,7 +4265,7 @@ impl Renderer {
|
||||
{
|
||||
let origin = [
|
||||
v.key.0 * 16,
|
||||
crate::chunk::MIN_Y + v.key.1 * 16,
|
||||
self.layout.min_y() + v.key.1 * 16,
|
||||
v.key.2 * 16,
|
||||
];
|
||||
if let Some(indices) =
|
||||
@@ -5420,6 +5441,16 @@ impl Renderer {
|
||||
if let (Some(hdr), Some(f)) = (&self.hdr, &surface_frame) {
|
||||
hdr.present(&self.queue, &mut encoder, &f.texture, !frame.title);
|
||||
}
|
||||
// With HDR on, a full-frame screenshot gets an HDR10 copy beside it.
|
||||
let hdr_readback = readback
|
||||
.as_ref()
|
||||
.filter(|(request, _)| request.size.is_none())
|
||||
.zip(self.hdr.as_ref())
|
||||
.and_then(|(_, hdr)| {
|
||||
let texture = hdr.capture(&self.device, &self.queue, &mut encoder, !frame.title)?;
|
||||
let size = [self.config.width, self.config.height];
|
||||
Some(copy_frame(&self.device, &mut encoder, &texture, size))
|
||||
});
|
||||
let commands = crate::spans::time("encode", || encoder.finish());
|
||||
let first_frame = self.first_frame == FirstFrame::Submit;
|
||||
if first_frame {
|
||||
@@ -5438,6 +5469,16 @@ impl Renderer {
|
||||
timer.submitted(&self.queue);
|
||||
}
|
||||
if let Some((request, buffer)) = readback {
|
||||
if let Some(hdr_buffer) = hdr_readback {
|
||||
let hdr_path = hdr_screenshot_path(&request.path);
|
||||
match self.write_hdr_screenshot(&hdr_path, &hdr_buffer) {
|
||||
Ok(()) => log::info!("Saved HDR screenshot to {}", hdr_path.display()),
|
||||
Err(e) => log::warn!(
|
||||
"Horizon renderer: HDR screenshot {}: {e}",
|
||||
hdr_path.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
self.screenshot_result = Some(
|
||||
self.write_screenshot(&request, &buffer)
|
||||
.map_err(|e| e.to_string()),
|
||||
@@ -5546,11 +5587,8 @@ impl Renderer {
|
||||
None
|
||||
}
|
||||
}
|
||||
fn write_screenshot(
|
||||
&self,
|
||||
request: &ScreenshotRequest,
|
||||
buffer: &wgpu::Buffer,
|
||||
) -> Result<[u32; 2]> {
|
||||
/// The pixels `copy_frame` read back into `buffer`, 4 bytes each, row after row.
|
||||
fn read_frame(&self, buffer: &wgpu::Buffer) -> Result<Vec<[u8; 4]>> {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
|
||||
let _ = sender.send(r);
|
||||
@@ -5563,28 +5601,34 @@ impl Renderer {
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (width, height, row) = (
|
||||
self.config.width,
|
||||
self.config.height,
|
||||
self.config.width as usize,
|
||||
self.config.height as usize,
|
||||
padded_row(self.config.width) as usize,
|
||||
);
|
||||
let mut pixels = Vec::with_capacity(width * height);
|
||||
{
|
||||
let data = buffer.get_mapped_range(..).map_err(|e| e.to_string())?;
|
||||
for y in 0..height {
|
||||
pixels.extend_from_slice(data[y * row..y * row + width * 4].as_chunks::<4>().0);
|
||||
}
|
||||
}
|
||||
buffer.unmap();
|
||||
Ok(pixels)
|
||||
}
|
||||
fn write_screenshot(
|
||||
&self,
|
||||
request: &ScreenshotRequest,
|
||||
buffer: &wgpu::Buffer,
|
||||
) -> Result<[u32; 2]> {
|
||||
let bgra = matches!(
|
||||
self.config.format.remove_srgb_suffix(),
|
||||
wgpu::TextureFormat::Bgra8Unorm
|
||||
);
|
||||
let mut pixels = Vec::with_capacity((width * height * 4) as usize);
|
||||
{
|
||||
let data = buffer.get_mapped_range(..).map_err(|e| e.to_string())?;
|
||||
for y in 0..height as usize {
|
||||
for px in data[y * row..y * row + width as usize * 4]
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
{
|
||||
let (r, b) = if bgra { (px[2], px[0]) } else { (px[0], px[2]) };
|
||||
pixels.extend_from_slice(&[r, px[1], b, 255]);
|
||||
}
|
||||
}
|
||||
let mut pixels = Vec::with_capacity((self.config.width * self.config.height * 4) as usize);
|
||||
for px in self.read_frame(buffer)? {
|
||||
let (r, b) = if bgra { (px[2], px[0]) } else { (px[0], px[2]) };
|
||||
pixels.extend_from_slice(&[r, px[1], b, 255]);
|
||||
}
|
||||
buffer.unmap();
|
||||
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
|
||||
// is the frame's centre square averaged down to its size.
|
||||
let (width, height, pixels) = match request.size {
|
||||
@@ -5605,6 +5649,50 @@ impl Renderer {
|
||||
encoder.write_header()?.write_image_data(&pixels)?;
|
||||
Ok([width, height])
|
||||
}
|
||||
/// The HDR10 copy (`Hdr::capture`) as a 16-bit PNG tagged BT.2020 with PQ (cICP, PNG's
|
||||
/// third edition), with the peak and paper white as its content light levels (cLLI).
|
||||
fn write_hdr_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<()> {
|
||||
let Some(hdr) = &self.hdr else {
|
||||
return Ok(());
|
||||
};
|
||||
let pixels = hdr10_to_rgb16(&self.read_frame(buffer)?);
|
||||
let mut encoder = png::Encoder::new(
|
||||
std::io::BufWriter::new(std::fs::File::create(path)?),
|
||||
self.config.width,
|
||||
self.config.height,
|
||||
);
|
||||
encoder.set_color(png::ColorType::Rgb);
|
||||
encoder.set_depth(png::BitDepth::Sixteen);
|
||||
let mut writer = encoder.write_header()?;
|
||||
// BT.2020 primaries, PQ, RGB, full range.
|
||||
writer.write_chunk(png::chunk::cICP, &[9, 16, 0, 1])?;
|
||||
let nits = |n: f32| ((n * 10_000.0) as u32).to_be_bytes();
|
||||
let peak = hdr.peak.max(hdr.paper_white);
|
||||
writer.write_chunk(
|
||||
png::chunk::cLLI,
|
||||
&[nits(peak), nits(hdr.paper_white.min(peak))].concat(),
|
||||
)?;
|
||||
writer.write_image_data(&pixels)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
/// `screenshots/2026-10-28_12.00.00.png` -> `screenshots/2026-10-28_12.00.00-hdr.png`.
|
||||
fn hdr_screenshot_path(path: &std::path::Path) -> PathBuf {
|
||||
let stem = path.file_stem().unwrap_or_default().to_string_lossy();
|
||||
path.with_file_name(format!("{stem}-hdr.png"))
|
||||
}
|
||||
/// `Rgb10a2Unorm` pixels as 16-bit RGB, big-endian as PNG stores it: each 10-bit value
|
||||
/// with its top bits repeated below, so 0 stays 0 and 1023 becomes 65535.
|
||||
fn hdr10_to_rgb16(pixels: &[[u8; 4]]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(pixels.len() * 6);
|
||||
for &px in pixels {
|
||||
let v = u32::from_le_bytes(px);
|
||||
for shift in [0, 10, 20] {
|
||||
let c = (v >> shift & 0x3ff) as u16;
|
||||
out.extend_from_slice(&(c << 6 | c >> 4).to_be_bytes());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Centre-crop the frame to a square and average it down to `size`.
|
||||
pub(crate) fn square_downscale(pixels: &[u8], width: u32, height: u32, size: [u32; 2]) -> Vec<u8> {
|
||||
@@ -5678,10 +5766,10 @@ impl Frustum {
|
||||
/// are GREATER_THAN_OR_EQUAL), which keeps far faces a hair apart, like
|
||||
/// fire_side's 0.01 pixel in front of its block, from z-fighting.
|
||||
/// The section the camera is in.
|
||||
fn camera_section(camera: DVec3) -> SectionKey {
|
||||
fn camera_section(camera: DVec3, min_y: i32) -> SectionKey {
|
||||
(
|
||||
(camera.x.floor() as i32) >> 4,
|
||||
(camera.y.floor() as i32 - crate::chunk::MIN_Y) >> 4,
|
||||
(camera.y.floor() as i32 - min_y) >> 4,
|
||||
(camera.z.floor() as i32) >> 4,
|
||||
)
|
||||
}
|
||||
@@ -5958,6 +6046,17 @@ mod tests {
|
||||
assert_eq!(blend.alpha.dst_factor, wgpu::BlendFactor::Zero);
|
||||
}
|
||||
#[test]
|
||||
fn hdr_screenshots_keep_every_10_bit_step() {
|
||||
// Rgb10a2Unorm: red in the low 10 bits, then green, then blue.
|
||||
let px = |r: u32, g: u32, b: u32| (r | g << 10 | b << 20 | 3 << 30).to_le_bytes();
|
||||
let out = super::hdr10_to_rgb16(&[px(0, 1023, 512)]);
|
||||
assert_eq!(out, [0, 0, 0xff, 0xff, 0x80, 0x20]);
|
||||
assert_eq!(
|
||||
super::hdr_screenshot_path(std::path::Path::new("screenshots/2026-10-28_12.00.00.png")),
|
||||
std::path::Path::new("screenshots/2026-10-28_12.00.00-hdr.png")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn shader_globals_match_the_rust_layout() {
|
||||
// terrain.wgsl and entity.wgsl bind the same buffer: same fields, same order.
|
||||
let fields = |source: &str| -> Vec<String> {
|
||||
|
||||
@@ -102,6 +102,10 @@ pub struct Hdr {
|
||||
/// output keeps the hand at the world's brightness.
|
||||
hand_mark: wgpu::RenderPipeline,
|
||||
params: wgpu::Buffer,
|
||||
/// The same pass drawing HDR10 for a screenshot (`capture`), with its own parameters,
|
||||
/// so it can differ from the swapchain's in one frame.
|
||||
capture_pipeline: wgpu::RenderPipeline,
|
||||
capture_params: wgpu::Buffer,
|
||||
pub output: Output,
|
||||
/// The frame's format: what every other pipeline draws in.
|
||||
format: wgpu::TextureFormat,
|
||||
@@ -156,31 +160,33 @@ impl Hdr {
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon HDR"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_hdr"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_hdr"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: output.format(),
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let pipeline_for = |format| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon HDR"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_hdr"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_hdr"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let alpha_pass = |label, blend| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
@@ -223,18 +229,22 @@ impl Hdr {
|
||||
alpha: scale_alpha,
|
||||
}),
|
||||
);
|
||||
let params = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 80,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let params = || {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 80,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
};
|
||||
Self {
|
||||
layout,
|
||||
pipeline,
|
||||
pipeline: pipeline_for(output.format()),
|
||||
clear_alpha,
|
||||
hand_mark,
|
||||
params,
|
||||
params: params(),
|
||||
capture_pipeline: pipeline_for(Output::Pq.format()),
|
||||
capture_params: params(),
|
||||
output,
|
||||
format,
|
||||
targets: None,
|
||||
@@ -375,6 +385,64 @@ impl Hdr {
|
||||
let Some(t) = &self.targets else {
|
||||
return;
|
||||
};
|
||||
queue.write_buffer(
|
||||
&self.params,
|
||||
0,
|
||||
bytemuck::cast_slice(&self.params(world, self.output == Output::Pq)),
|
||||
);
|
||||
Self::draw(encoder, &self.pipeline, &t.group, target);
|
||||
}
|
||||
|
||||
/// The frame `present` writes, as HDR10 (BT.2020, PQ, 10 bits a channel) whatever
|
||||
/// the swapchain is, in a texture of its own for a screenshot (`Rgb10a2Unorm`).
|
||||
pub fn capture(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
world: bool,
|
||||
) -> Option<wgpu::Texture> {
|
||||
let t = self.targets.as_ref()?;
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon HDR screenshot"),
|
||||
size: t.frame.size(),
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: Output::Pq.format(),
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon HDR screenshot bindings"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&view(&t.frame)),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&view(&t.world)),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: self.capture_params.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
queue.write_buffer(
|
||||
&self.capture_params,
|
||||
0,
|
||||
bytemuck::cast_slice(&self.params(world, true)),
|
||||
);
|
||||
Self::draw(encoder, &self.capture_pipeline, &group, &texture);
|
||||
Some(texture)
|
||||
}
|
||||
|
||||
/// hdr.wgsl's `Params`.
|
||||
fn params(&self, world: bool, pq: bool) -> [f32; 20] {
|
||||
let paper = self.paper_white.max(1.0);
|
||||
let mut params = [0.0; 20];
|
||||
params[..5].copy_from_slice(&[
|
||||
@@ -382,7 +450,7 @@ impl Hdr {
|
||||
(self.peak / paper).max(1.0),
|
||||
f32::from(u8::from(world)),
|
||||
f32::from(u8::from(self.debug)),
|
||||
f32::from(u8::from(self.output == Output::Pq)),
|
||||
f32::from(u8::from(pq)),
|
||||
]);
|
||||
params[5] = self.black.clamp(0.0, paper);
|
||||
params[6] = self.ui.clamp(0.25, 1.0);
|
||||
@@ -391,7 +459,15 @@ impl Hdr {
|
||||
params[12 + i] = t.start;
|
||||
params[16 + i] = t.full;
|
||||
}
|
||||
queue.write_buffer(&self.params, 0, bytemuck::cast_slice(¶ms));
|
||||
params
|
||||
}
|
||||
|
||||
fn draw(
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
pipeline: &wgpu::RenderPipeline,
|
||||
group: &wgpu::BindGroup,
|
||||
target: &wgpu::Texture,
|
||||
) {
|
||||
let view = target.create_view(&Default::default());
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
@@ -407,8 +483,8 @@ impl Hdr {
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &t.group, &[]);
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ impl Column {
|
||||
/// Values of type `T` per section key, in a dense list with stable keys and
|
||||
/// slots that move only when a section is removed.
|
||||
pub struct Resident<T> {
|
||||
/// The minimum Y of section index 0 (the level's column).
|
||||
min_y: i32,
|
||||
items: Vec<(SectionKey, T)>,
|
||||
slots: FxHashMap<SectionKey, u32>,
|
||||
columns: Vec<Column>,
|
||||
@@ -36,6 +38,7 @@ pub struct Resident<T> {
|
||||
impl<T> Default for Resident<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min_y: crate::chunk::MIN_Y,
|
||||
items: Vec::new(),
|
||||
slots: FxHashMap::default(),
|
||||
columns: Vec::new(),
|
||||
@@ -61,6 +64,10 @@ impl<T> Resident<T> {
|
||||
pub fn get_mut(&mut self, slot: u32) -> &mut T {
|
||||
&mut self.items[slot as usize].1
|
||||
}
|
||||
/// Sections from now on count from `min_y` (a level of another column).
|
||||
pub fn set_min_y(&mut self, min_y: i32) {
|
||||
self.min_y = min_y;
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.items.clear();
|
||||
self.slots.clear();
|
||||
@@ -144,10 +151,10 @@ pub struct Visible {
|
||||
}
|
||||
|
||||
/// The camera-relative origin of the section at `key`.
|
||||
fn origin(key: SectionKey, camera: DVec3) -> DVec3 {
|
||||
fn origin(key: SectionKey, min_y: i32, camera: DVec3) -> DVec3 {
|
||||
DVec3::new(
|
||||
f64::from(key.0 * 16),
|
||||
f64::from(crate::chunk::MIN_Y + key.1 * 16),
|
||||
f64::from(min_y + key.1 * 16),
|
||||
f64::from(key.2 * 16),
|
||||
) - camera
|
||||
}
|
||||
@@ -169,18 +176,19 @@ fn in_view(key: SectionKey, camera: DVec3, radius: i32, frustum: &Frustum) -> Op
|
||||
if !in_view_distance(camera_column(camera), radius, key.0, key.2) {
|
||||
return None;
|
||||
}
|
||||
in_frustum(key, camera, frustum, false)
|
||||
in_frustum(key, crate::chunk::MIN_Y, camera, frustum, false)
|
||||
}
|
||||
|
||||
/// `in_view` without the distance test; `inside`: the section's column is
|
||||
/// entirely inside the frustum, so the section is too.
|
||||
fn in_frustum(
|
||||
key: SectionKey,
|
||||
min_y: i32,
|
||||
camera: DVec3,
|
||||
frustum: &Frustum,
|
||||
inside: bool,
|
||||
) -> Option<(f64, Vec3)> {
|
||||
let origin = origin(key, camera);
|
||||
let origin = origin(key, min_y, camera);
|
||||
let min = origin.as_vec3();
|
||||
if !inside && !frustum.intersects(min, min + Vec3::splat(16.0)) {
|
||||
return None;
|
||||
@@ -216,8 +224,8 @@ impl<T> Resident<T> {
|
||||
if !near && !beyond(column.x, column.z) {
|
||||
continue;
|
||||
}
|
||||
let min = origin((column.x, column.low, column.z), camera).as_vec3();
|
||||
let top = origin((column.x, column.high, column.z), camera).as_vec3();
|
||||
let min = origin((column.x, column.low, column.z), self.min_y, camera).as_vec3();
|
||||
let top = origin((column.x, column.high, column.z), self.min_y, camera).as_vec3();
|
||||
let max = top + Vec3::splat(16.0);
|
||||
let inside = frustum.contains(min, max);
|
||||
if !inside && !frustum.intersects(min, max) {
|
||||
@@ -228,7 +236,7 @@ impl<T> Resident<T> {
|
||||
if near && !reachable(key) {
|
||||
continue;
|
||||
}
|
||||
if let Some((d, o)) = in_frustum(key, camera, frustum, inside) {
|
||||
if let Some((d, o)) = in_frustum(key, self.min_y, camera, frustum, inside) {
|
||||
out.push(Visible {
|
||||
distance: d.to_bits(),
|
||||
key,
|
||||
@@ -286,7 +294,7 @@ mod tests {
|
||||
if near {
|
||||
in_view(k, camera, radius, &frustum).filter(|_| reachable(k))
|
||||
} else if beyond(k.0, k.2) {
|
||||
in_frustum(k, camera, &frustum, false)
|
||||
in_frustum(k, crate::chunk::MIN_Y, camera, &frustum, false)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -329,8 +337,8 @@ mod tests {
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
let min = origin((c.x, c.low, c.z), camera).as_vec3();
|
||||
let top = origin((c.x, c.high, c.z), camera).as_vec3();
|
||||
let min = origin((c.x, c.low, c.z), crate::chunk::MIN_Y, camera).as_vec3();
|
||||
let top = origin((c.x, c.high, c.z), crate::chunk::MIN_Y, camera).as_vec3();
|
||||
frustum.contains(min, top + Vec3::splat(16.0))
|
||||
})
|
||||
.count();
|
||||
|
||||
@@ -92,9 +92,9 @@ pub const MAP_SLOTS: usize = 256;
|
||||
/// The atlas slot the minimap extra draws into.
|
||||
pub const MINIMAP_SPRITE: &str = "horizon/minimap";
|
||||
/// Atlas slots for downloaded player skins (`skins/<sha1>`), 64x64 each.
|
||||
/// Vanilla registers a texture per skin. Here a skin evicted from its slot
|
||||
/// is not stored again, and its player falls back to the default skin, so
|
||||
/// the slots must cover a busy server's player list.
|
||||
/// Vanilla registers a texture per skin. Here the slots are shared
|
||||
/// (`skins::SkinSlots`): skins drawn in the last frame keep theirs, and a
|
||||
/// skin past them shows the default skin until a slot frees up.
|
||||
pub const SKIN_SLOTS: usize = 128;
|
||||
/// Reused by the visible rows of the classic world selector.
|
||||
pub const WORLD_ICON_SLOTS: usize = 32;
|
||||
|
||||
@@ -59,10 +59,12 @@ pub mod packs;
|
||||
pub mod par;
|
||||
pub mod particles;
|
||||
pub mod poi_sections;
|
||||
pub mod potent_sulfur;
|
||||
pub mod providers;
|
||||
pub mod screen_draw;
|
||||
pub mod screen_effects;
|
||||
pub mod sculk;
|
||||
pub mod section_mask;
|
||||
pub mod settings;
|
||||
pub mod shader_pack_list;
|
||||
pub mod signs;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
use crate::fast_hash::FxHashMap;
|
||||
use crate::{
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, HEIGHT, Light, MIN_Y, SECTIONS, Section, index},
|
||||
chunk::{Chunk, ColumnLayout, Light, Section, index},
|
||||
};
|
||||
use horizon_core::geometry::{Direction, ShapeOp, VoxelShape};
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
@@ -273,7 +273,7 @@ pub fn light_chunk(
|
||||
tables: &LightTables,
|
||||
sky_light: bool,
|
||||
) -> Vec<Light> {
|
||||
region[1][1].expect("centre chunk");
|
||||
let sections = region[1][1].expect("centre chunk").sections.len();
|
||||
// Highest occupied section in the region bounds the work.
|
||||
let mut top_section = 0;
|
||||
for chunk in region.iter().flatten().flatten() {
|
||||
@@ -286,7 +286,7 @@ pub fn light_chunk(
|
||||
}
|
||||
// One extra section above the highest occupied one: block light rises
|
||||
// up to 14 blocks into it (sky light there is always 15).
|
||||
let h = ((top_section + 1) * 16).min(HEIGHT as usize);
|
||||
let h = ((top_section + 1) * 16).min(sections * 16);
|
||||
let at = |x: usize, y: usize, z: usize| (y * W + z) * W + x;
|
||||
// Packed cells (`type << 4 | dampening`), not state ids: the flood fill
|
||||
// then never touches the large `LightTables::cells` table.
|
||||
@@ -422,14 +422,21 @@ pub fn light_chunk(
|
||||
};
|
||||
spread(&mut sky, &mut sky_queue);
|
||||
spread(&mut block, &mut queue);
|
||||
pack_lighting(h, &sky, &block, sky_light)
|
||||
pack_lighting(sections, h, &sky, &block, sky_light)
|
||||
}
|
||||
|
||||
/// Extract just the centre column's light; never copy worker snapshot sections.
|
||||
fn pack_lighting(h: usize, sky: &[u8], block: &[u8], sky_light: bool) -> Vec<Light> {
|
||||
/// Extract just the centre column's light (`sections` of it); never copy
|
||||
/// worker snapshot sections.
|
||||
fn pack_lighting(
|
||||
sections: usize,
|
||||
h: usize,
|
||||
sky: &[u8],
|
||||
block: &[u8],
|
||||
sky_light: bool,
|
||||
) -> Vec<Light> {
|
||||
let at = |x: usize, y: usize, z: usize| (y * W + z) * W + x;
|
||||
let open = if sky_light { 0xF0 } else { 0x00 };
|
||||
(0..SECTIONS)
|
||||
(0..sections)
|
||||
.map(|si| {
|
||||
if si * 16 >= h {
|
||||
return Light::Uniform(open);
|
||||
@@ -467,6 +474,7 @@ pub fn apply_lighting(chunk: &mut Chunk, lights: Vec<Light>) {
|
||||
/// `apply_lighting` for a chunk lit before: writes only the sections whose
|
||||
/// light changed and adds the cells that changed to `changed`.
|
||||
pub fn apply_relighting(chunk: &mut Chunk, lights: Vec<Light>, changed: &mut LightChanges) {
|
||||
changed.keyed(chunk.layout());
|
||||
for (sy, (section, light)) in chunk.sections.iter_mut().zip(lights).enumerate() {
|
||||
let old = §ion.light;
|
||||
if let (Light::Uniform(a), Light::Uniform(b)) = (old, &light)
|
||||
@@ -506,7 +514,8 @@ pub fn light_from_layers<L: AsRef<[u8]>>(
|
||||
mut above: [u8; 256],
|
||||
sky_light: bool,
|
||||
) {
|
||||
for (si, (sky, block)) in layers.iter().enumerate().take(SECTIONS).rev() {
|
||||
let sections = chunk.sections.len();
|
||||
for (si, (sky, block)) in layers.iter().enumerate().take(sections).rev() {
|
||||
let sky = sky.as_ref().filter(|_| sky_light);
|
||||
let section = Arc::make_mut(&mut chunk.sections[si]);
|
||||
if sky.is_none() && block.is_none() && above.iter().all(|&v| v == above[0]) {
|
||||
@@ -539,7 +548,8 @@ pub fn update_light_layers<L: AsRef<[u8]>>(
|
||||
sky_light: bool,
|
||||
) -> Vec<usize> {
|
||||
let mut changed = Vec::new();
|
||||
for (si, (sky, block)) in layers.iter().enumerate().take(SECTIONS) {
|
||||
let sections = chunk.sections.len();
|
||||
for (si, (sky, block)) in layers.iter().enumerate().take(sections) {
|
||||
let sky = sky.as_ref().filter(|_| sky_light);
|
||||
if sky.is_none() && block.is_none() {
|
||||
continue;
|
||||
@@ -569,8 +579,15 @@ pub trait LightWorld {
|
||||
fn section(&self, cx: i32, s: usize, cz: i32) -> Option<&Arc<Section>>;
|
||||
/// Replace the light of a section `section` returned.
|
||||
fn set_section_light(&mut self, cx: i32, s: usize, cz: i32, light: Light);
|
||||
/// The column the world's chunks hold (its level's).
|
||||
fn layout(&self) -> ColumnLayout;
|
||||
}
|
||||
impl<S: std::hash::BuildHasher> LightWorld for HashMap<(i32, i32), Arc<Chunk>, S> {
|
||||
fn layout(&self) -> ColumnLayout {
|
||||
self.values()
|
||||
.next()
|
||||
.map_or(ColumnLayout::VANILLA, |c| c.layout())
|
||||
}
|
||||
fn section(&self, cx: i32, s: usize, cz: i32) -> Option<&Arc<Section>> {
|
||||
self.get(&(cx, cz))
|
||||
.filter(|c| c.lit)
|
||||
@@ -587,6 +604,7 @@ impl<S: std::hash::BuildHasher> LightWorld for HashMap<(i32, i32), Arc<Chunk>, S
|
||||
/// gets written is copied out on the first write and stored back by `finish`.
|
||||
struct Region<'w> {
|
||||
world: &'w mut dyn LightWorld,
|
||||
layout: ColumnLayout,
|
||||
slots: Vec<Slot>,
|
||||
/// Section key (cx, s, cz) to slot, `NONE` where the chunk isn't lit.
|
||||
index: FxHashMap<Pos, u32>,
|
||||
@@ -602,6 +620,7 @@ const NONE: u32 = u32::MAX;
|
||||
impl<'w> Region<'w> {
|
||||
fn new(world: &'w mut dyn LightWorld) -> Self {
|
||||
Self {
|
||||
layout: world.layout(),
|
||||
world,
|
||||
slots: Vec::new(),
|
||||
index: FxHashMap::default(),
|
||||
@@ -610,10 +629,8 @@ impl<'w> Region<'w> {
|
||||
}
|
||||
#[inline]
|
||||
fn slot(&mut self, x: i32, y: i32, z: i32) -> Option<usize> {
|
||||
if !(MIN_Y..MIN_Y + HEIGHT).contains(&y) {
|
||||
return None;
|
||||
}
|
||||
let key = (x >> 4, (y - MIN_Y) >> 4, z >> 4);
|
||||
let s = self.layout.section_index(y)?;
|
||||
let key = (x >> 4, s as i32, z >> 4);
|
||||
if self.last.0 != key {
|
||||
self.last = (key, self.load(key));
|
||||
}
|
||||
@@ -702,12 +719,20 @@ type Pos = (i32, i32, i32);
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct LightChanges {
|
||||
sections: FxHashMap<Pos, u32>,
|
||||
/// The column the section indexes count in.
|
||||
layout: ColumnLayout,
|
||||
}
|
||||
impl LightChanges {
|
||||
/// Changes in chunks of `layout`: what the relighter or a light job adds
|
||||
/// after this (they all come from one level).
|
||||
fn keyed(&mut self, layout: ColumnLayout) {
|
||||
debug_assert!(self.sections.is_empty() || self.layout == layout);
|
||||
self.layout = layout;
|
||||
}
|
||||
/// A cell whose stored light changed.
|
||||
#[inline]
|
||||
pub fn push(&mut self, (x, y, z): Pos) {
|
||||
let key = (x >> 4, (y - MIN_Y) >> 4, z >> 4);
|
||||
let key = (x >> 4, (y - self.layout.min_y()) >> 4, z >> 4);
|
||||
*self.sections.entry(key).or_default() |= 1 << Self::class(x, y, z);
|
||||
}
|
||||
/// Cells of section `key` (x, section index, z) in the given classes.
|
||||
@@ -732,7 +757,7 @@ impl LightChanges {
|
||||
self.sections.keys().copied()
|
||||
}
|
||||
/// Every section (x, section index, z) whose cells lie within one block
|
||||
/// of a changed cell, section indexes outside `0..SECTIONS` left out (a
|
||||
/// of a changed cell, section indexes outside the column left out (a
|
||||
/// section next to several changed ones comes once for each).
|
||||
pub fn dirty_sections(&self, mut out: impl FnMut(Pos)) {
|
||||
// Per class, the bit set of neighbour offsets (dx, dy, dz in -1..=1,
|
||||
@@ -759,6 +784,7 @@ impl LightChanges {
|
||||
}
|
||||
reach
|
||||
};
|
||||
let sections = self.layout.sections() as i32;
|
||||
for (&(x, sy, z), &classes) in &self.sections {
|
||||
let mut offsets = 0;
|
||||
let mut rest = classes;
|
||||
@@ -770,7 +796,7 @@ impl LightChanges {
|
||||
let o = offsets.trailing_zeros() as i32;
|
||||
offsets &= offsets - 1;
|
||||
let s = sy + o / 3 % 3 - 1;
|
||||
if (0..SECTIONS as i32).contains(&s) {
|
||||
if (0..sections).contains(&s) {
|
||||
out((x + o % 3 - 1, s, z + o / 9 - 1));
|
||||
}
|
||||
}
|
||||
@@ -794,11 +820,12 @@ fn set_level(world: &mut Region, sky: bool, p: Pos, value: u8) {
|
||||
/// `ChunkSkyLightSources`: the Y of the upper block of the highest occluded
|
||||
/// edge in the column (every block at or above it is a sky source).
|
||||
fn lowest_sky_source(world: &mut Region, tables: &LightTables, x: i32, z: i32) -> i32 {
|
||||
let mut y = MIN_Y + HEIGHT - 1;
|
||||
let (min_y, max_y) = (world.layout.min_y(), world.layout.max_y());
|
||||
let mut y = max_y - 1;
|
||||
let Some(mut top) = world.state(x, y, z) else {
|
||||
return MIN_Y;
|
||||
return min_y;
|
||||
};
|
||||
while y > MIN_Y {
|
||||
while y > min_y {
|
||||
let Some(bottom) = world.state(x, y - 1, z) else {
|
||||
break;
|
||||
};
|
||||
@@ -808,7 +835,7 @@ fn lowest_sky_source(world: &mut Region, tables: &LightTables, x: i32, z: i32) -
|
||||
top = bottom;
|
||||
y -= 1;
|
||||
}
|
||||
MIN_Y
|
||||
min_y
|
||||
}
|
||||
/// Vanilla's `checkBlock` + `runLightUpdates` for changed positions on
|
||||
/// resident, lit chunks: clear the light that may have come through or from
|
||||
@@ -825,6 +852,7 @@ pub fn relight(
|
||||
changed: &mut LightChanges,
|
||||
) {
|
||||
let mut region = Region::new(world);
|
||||
changed.keyed(region.layout);
|
||||
relight_region(&mut region, tables, sky_light, positions, force, changed);
|
||||
region.finish();
|
||||
}
|
||||
@@ -840,6 +868,7 @@ fn relight_region(
|
||||
relight_layer(world, tables, false, positions.to_vec(), force, changed)
|
||||
});
|
||||
if sky_light {
|
||||
let (min_y, max_y) = (world.layout.min_y(), world.layout.max_y());
|
||||
let mut seeds = positions.to_vec();
|
||||
let mut columns: FxHashMap<(i32, i32), i32> = FxHashMap::default();
|
||||
crate::spans::time("lt_columns", || {
|
||||
@@ -851,13 +880,13 @@ fn relight_region(
|
||||
columns.insert((x, z), source);
|
||||
// Cells whose source status changed: non-15 at or above the new
|
||||
// lowest source, and old 15s (sources) below it.
|
||||
for y in source..MIN_Y + HEIGHT {
|
||||
for y in source..max_y {
|
||||
if level(world, true, (x, y, z)).is_some_and(|l| l != 15) {
|
||||
seeds.push((x, y, z));
|
||||
}
|
||||
}
|
||||
let mut y = source - 1;
|
||||
while y >= MIN_Y && level(world, true, (x, y, z)) == Some(15) {
|
||||
while y >= min_y && level(world, true, (x, y, z)) == Some(15) {
|
||||
seeds.push((x, y, z));
|
||||
y -= 1;
|
||||
}
|
||||
@@ -977,6 +1006,7 @@ fn relight_layer(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::chunk::{HEIGHT, MIN_Y, SECTIONS};
|
||||
|
||||
const AIR: u32 = 0;
|
||||
const STONE: u32 = 1;
|
||||
@@ -1004,11 +1034,18 @@ mod tests {
|
||||
)
|
||||
}
|
||||
fn world(edit: impl Fn(&mut Chunk)) -> HashMap<(i32, i32), Arc<Chunk>> {
|
||||
world_of(crate::chunk::ColumnLayout::VANILLA, edit)
|
||||
}
|
||||
/// Lit chunks around (0, 0) of `layout`, stone up to y 0.
|
||||
fn world_of(
|
||||
layout: crate::chunk::ColumnLayout,
|
||||
edit: impl Fn(&mut Chunk),
|
||||
) -> HashMap<(i32, i32), Arc<Chunk>> {
|
||||
let mut chunks = HashMap::new();
|
||||
for z in -1..=1 {
|
||||
for x in -1..=1 {
|
||||
let mut c = Chunk::empty(x, z);
|
||||
for y in MIN_Y..0 {
|
||||
let mut c = Chunk::with_layout(x, z, layout);
|
||||
for y in layout.min_y()..0 {
|
||||
for i in 0..256 {
|
||||
c.set(i & 15, y, i >> 4, STONE);
|
||||
}
|
||||
@@ -1218,6 +1255,66 @@ mod tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn a_tall_column_is_lit_to_its_top() {
|
||||
let layout = crate::chunk::ColumnLayout::new(-64, 2096).unwrap();
|
||||
let mut chunk = Chunk::with_layout(0, 0, layout);
|
||||
for i in 0..256 {
|
||||
chunk.set(i & 15, 1600, i >> 4, STONE);
|
||||
}
|
||||
chunk.set(8, 1500, 8, TORCH);
|
||||
let region = [[None; 3], [None, Some(&chunk), None], [None; 3]];
|
||||
let lights = light_chunk(®ion, &tables(), true);
|
||||
assert_eq!(lights.len(), 131);
|
||||
apply_lighting(&mut chunk, lights);
|
||||
assert_eq!(chunk.light(8, 1500, 8) & 15, 14);
|
||||
assert_eq!(chunk.light(8, 1501, 8) & 15, 13);
|
||||
assert_eq!(chunk.light(8, 1601, 8) >> 4, 15);
|
||||
assert_eq!(chunk.light(8, 1599, 8) >> 4, 0, "under the roof");
|
||||
assert_eq!(chunk.light(8, 2031, 8), 0xF0);
|
||||
}
|
||||
#[test]
|
||||
fn a_tall_column_relights_as_its_full_light_and_keys_its_sections() {
|
||||
let layout = crate::chunk::ColumnLayout::new(-64, 2096).unwrap();
|
||||
let mut w = world_of(layout, |c| {
|
||||
for i in 0..256 {
|
||||
c.set(i & 15, 1900, i >> 4, STONE);
|
||||
}
|
||||
});
|
||||
let edits = [
|
||||
((8, 1800, 8), TORCH),
|
||||
((8, 1900, 8), AIR),
|
||||
((3, 2020, 3), TORCH),
|
||||
];
|
||||
let mut changed = LightChanges::default();
|
||||
for ((x, y, z), state) in edits {
|
||||
Arc::make_mut(w.get_mut(&(0, 0)).unwrap()).set(x, y, z, state);
|
||||
relight(&mut w, &tables(), true, &[(x, y, z)], false, &mut changed);
|
||||
}
|
||||
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
|
||||
std::array::from_fn(|dx| w.get(&(dx as i32 - 1, dz as i32 - 1)).map(|c| &**c))
|
||||
});
|
||||
let full = light_chunk(®ion, &tables(), true);
|
||||
assert_eq!(full.len(), 131);
|
||||
for (si, s) in full.iter().enumerate() {
|
||||
for i in 0..4096 {
|
||||
assert_eq!(
|
||||
s.get(i),
|
||||
w[&(0, 0)].sections[si].light(i),
|
||||
"section {si} index {i}"
|
||||
);
|
||||
}
|
||||
}
|
||||
// y 2020 is section 130 of the tall column, the top one.
|
||||
assert!(
|
||||
changed
|
||||
.sections()
|
||||
.any(|(x, sy, z)| (x, sy, z) == (0, 130, 0))
|
||||
);
|
||||
let mut dirty = Vec::new();
|
||||
changed.dirty_sections(|s| dirty.push(s.1));
|
||||
assert!(dirty.iter().all(|&sy| (0..131).contains(&sy)), "{dirty:?}");
|
||||
}
|
||||
#[test]
|
||||
fn incremental_relight_matches_full_relight() {
|
||||
let mut w = world(|c| {
|
||||
c.set(8, 0, 8, TORCH);
|
||||
@@ -1521,7 +1618,7 @@ mod tests {
|
||||
let mut old = fixture.clone();
|
||||
let mut new = fixture.clone();
|
||||
baseline_apply(&mut old, baseline_pack(&fixture, h, &sky, &block));
|
||||
apply_lighting(&mut new, pack_lighting(h, &sky, &block, true));
|
||||
apply_lighting(&mut new, pack_lighting(SECTIONS, h, &sky, &block, true));
|
||||
for (a, b) in old.sections.iter().zip(&new.sections) {
|
||||
for i in 0..4096 {
|
||||
assert_eq!(a.light(i), b.light(i));
|
||||
@@ -1538,14 +1635,20 @@ mod tests {
|
||||
let mut current = fixture.clone();
|
||||
let held = shared.then(|| current.clone());
|
||||
// Warm up and detach for the uniquely-owned scenario.
|
||||
apply_lighting(&mut current, pack_lighting(h, &sky, &block, true));
|
||||
apply_lighting(&mut current, pack_lighting(SECTIONS, h, &sky, &block, true));
|
||||
let started = Instant::now();
|
||||
for _ in 0..500 {
|
||||
let retained = shared.then(|| current.clone());
|
||||
if optimized {
|
||||
apply_lighting(
|
||||
black_box(&mut current),
|
||||
pack_lighting(h, black_box(&sky), black_box(&block), true),
|
||||
pack_lighting(
|
||||
SECTIONS,
|
||||
h,
|
||||
black_box(&sky),
|
||||
black_box(&block),
|
||||
true,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
baseline_apply(
|
||||
|
||||
@@ -161,6 +161,31 @@ impl Column {
|
||||
self.spans.push(span);
|
||||
}
|
||||
}
|
||||
/// Open water and its floor lit as open sky, as the seed's far columns
|
||||
/// are: a chunk's own light fades a level per block of water, so the
|
||||
/// chunks a player passed drew a darker band through the seed's sea.
|
||||
/// The water is one span again.
|
||||
fn light_open_water(&mut self, kinds: &[u8]) {
|
||||
// Water; lava glows.
|
||||
let water = |span: &Span| {
|
||||
let kind = kinds.get(span.state.0 as usize).copied().unwrap_or(0);
|
||||
kind & FLUID != 0 && kind >> 4 == 0
|
||||
};
|
||||
if !self.spans.last().is_some_and(water) {
|
||||
return;
|
||||
}
|
||||
let floor = self
|
||||
.spans
|
||||
.iter()
|
||||
.rposition(|span| !water(span))
|
||||
.unwrap_or(0);
|
||||
for (i, mut span) in std::mem::take(&mut self.spans).into_iter().enumerate() {
|
||||
if i >= floor {
|
||||
span.light |= 0xf0;
|
||||
}
|
||||
self.push(span);
|
||||
}
|
||||
}
|
||||
/// `kinds`: `state_kinds`, per block state.
|
||||
fn from_chunk(chunk: &Chunk, kinds: &[u8], x: i32, z: i32) -> Self {
|
||||
let kind = |state: BlockStateId| kinds.get(state.0 as usize).copied().unwrap_or(0);
|
||||
@@ -187,7 +212,7 @@ impl Column {
|
||||
};
|
||||
let mut column = Self::default();
|
||||
for (sy, section) in chunk.sections.iter().enumerate() {
|
||||
let min = MIN_Y + sy as i32 * 16;
|
||||
let min = chunk.min_y() + sy as i32 * 16;
|
||||
let uniform = section.blocks.bits() == 0
|
||||
&& section.biomes.bits() == 0
|
||||
&& matches!(section.light, crate::chunk::Light::Uniform(_));
|
||||
@@ -212,6 +237,7 @@ impl Column {
|
||||
});
|
||||
}
|
||||
}
|
||||
column.light_open_water(kinds);
|
||||
let mut water = None;
|
||||
for span in column.spans.iter().rev() {
|
||||
if kind(span.state) & FLUID != 0 {
|
||||
@@ -230,7 +256,7 @@ impl Column {
|
||||
&& let Some((height, state)) = water
|
||||
{
|
||||
column.surface = Some(Surface {
|
||||
height: MIN_Y,
|
||||
height: chunk.min_y(),
|
||||
state: BlockStateId(
|
||||
kinds.iter().position(|&kind| kind & AIR != 0).unwrap_or(0) as u32
|
||||
),
|
||||
@@ -1772,6 +1798,48 @@ mod tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn open_water_and_its_floor_are_lit_as_the_seed_lights_them() {
|
||||
let span = |min, max, state, light| Span {
|
||||
min,
|
||||
max,
|
||||
state: BlockStateId(state),
|
||||
biome: 0,
|
||||
light,
|
||||
};
|
||||
// 0 air, 1 stone, 2 water, 3 lava.
|
||||
let kinds = [AIR, KEPT, KEPT | FLUID, KEPT | FLUID | 15 << 4];
|
||||
// A chunk's own light: a cave, the floor, water fading a level a block.
|
||||
let mut sea = Column {
|
||||
surface: None,
|
||||
spans: vec![
|
||||
span(30, 40, 1, 0),
|
||||
span(40, 48, 1, 0x10 | 2 << 12),
|
||||
span(48, 61, 2, 0x20),
|
||||
span(61, 62, 2, 0xe0),
|
||||
span(62, 63, 2, 0xf0),
|
||||
],
|
||||
};
|
||||
sea.light_open_water(&kinds);
|
||||
assert_eq!(
|
||||
sea.spans,
|
||||
[
|
||||
span(30, 40, 1, 0),
|
||||
span(40, 48, 1, 0xf0 | 2 << 12),
|
||||
span(48, 63, 2, 0xf0)
|
||||
]
|
||||
);
|
||||
// Land, and lava, keep their light.
|
||||
for top in [1, 3] {
|
||||
let mut column = Column {
|
||||
surface: None,
|
||||
spans: vec![span(40, 50, 2, 0x20), span(50, 63, top, 0x80)],
|
||||
};
|
||||
let before = column.spans.clone();
|
||||
column.light_open_water(&kinds);
|
||||
assert_eq!(column.spans, before);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn coarse_voxels_reduce_the_area_instead_of_inflating_one_leaf() {
|
||||
let span = |min, max, state| Span {
|
||||
min,
|
||||
|
||||
@@ -639,7 +639,7 @@ impl Padded {
|
||||
fn new(region: &[[Option<&Chunk>; 3]; 3], sy: i32) -> Self {
|
||||
let mut blocks = vec![0u32; P * P * P];
|
||||
let mut light = vec![0u8; P * P * P];
|
||||
let base_y = MIN_Y + sy * 16;
|
||||
let base_y = region[1][1].map_or(MIN_Y, Chunk::min_y) + sy * 16;
|
||||
// The section itself is unpacked in one pass; the border cells of the
|
||||
// neighbouring sections are read one by one.
|
||||
let mut own = Box::new([0u32; 4096]);
|
||||
@@ -848,7 +848,7 @@ impl Mesher<'_> {
|
||||
}
|
||||
let pad = Padded::new(region, sy);
|
||||
out.visibility = visibility(&pad, &self.tables.info);
|
||||
let base_y = MIN_Y + sy * 16;
|
||||
let base_y = center.min_y() + sy * 16;
|
||||
let mut tints = Tints::new(
|
||||
region,
|
||||
self.biomes,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! changed.
|
||||
use crate::{
|
||||
blocks::{BlockFacts, Fluid, MAP_COLORS},
|
||||
chunk::{Chunk, MIN_Y, SECTIONS},
|
||||
chunk::Chunk,
|
||||
fast_hash::FxHashMap,
|
||||
world::World,
|
||||
};
|
||||
@@ -113,7 +113,8 @@ fn scan_column(
|
||||
return (color | WALL, head as i16, 0);
|
||||
}
|
||||
}
|
||||
while y > MIN_Y {
|
||||
let bottom = chunk.min_y();
|
||||
while y > bottom {
|
||||
let (mut color, flags) = palette.get(chunk.get(x, y, z));
|
||||
if color == 0 {
|
||||
y -= 1;
|
||||
@@ -123,7 +124,7 @@ fn scan_column(
|
||||
let fluid = flags & FLUID;
|
||||
if fluid != 0 {
|
||||
let mut below = y - 1;
|
||||
while below > MIN_Y && palette.get(chunk.get(x, below, z)).1 & FLUID != 0 {
|
||||
while below > bottom && palette.get(chunk.get(x, below, z)).1 & FLUID != 0 {
|
||||
depth = depth.saturating_add(1);
|
||||
below -= 1;
|
||||
}
|
||||
@@ -134,11 +135,11 @@ fn scan_column(
|
||||
}
|
||||
return (color, y as i16, depth);
|
||||
}
|
||||
(0, MIN_Y as i16, 0)
|
||||
(0, bottom as i16, 0)
|
||||
}
|
||||
/// The highest block of a chunk worth scanning from.
|
||||
fn top_of(chunk: &Chunk, air: u32, layer: Layer) -> i32 {
|
||||
let surface = (0..SECTIONS)
|
||||
let surface = (0..chunk.sections.len())
|
||||
.rev()
|
||||
.find(|&s| {
|
||||
chunk
|
||||
@@ -146,7 +147,7 @@ fn top_of(chunk: &Chunk, air: u32, layer: Layer) -> i32 {
|
||||
.get(s)
|
||||
.is_some_and(|sec| !sec.is_uniform(air))
|
||||
})
|
||||
.map_or(MIN_Y, |s| MIN_Y + s as i32 * 16 + 15);
|
||||
.map_or(chunk.min_y(), |s| chunk.min_y() + s as i32 * 16 + 15);
|
||||
match layer {
|
||||
Layer::Surface => surface,
|
||||
Layer::Below(head) => surface.min(head),
|
||||
@@ -156,7 +157,7 @@ fn scan_tile(chunk: &Chunk, palette: &Palette, air: u32, layer: Layer, revision:
|
||||
let top = top_of(chunk, air, layer);
|
||||
let mut tile = Tile {
|
||||
color: [0; 256],
|
||||
height: [MIN_Y as i16; 256],
|
||||
height: [chunk.min_y() as i16; 256],
|
||||
depth: [0; 256],
|
||||
revision,
|
||||
layer,
|
||||
|
||||
@@ -2797,26 +2797,7 @@ impl OptionsUi {
|
||||
}
|
||||
Act::Done => events.extend(self.back(s)),
|
||||
Act::Cycle(opt) => {
|
||||
opt.cycle(s, right, framebuffer);
|
||||
if opt == Opt::Preset {
|
||||
apply_preset(s);
|
||||
}
|
||||
if opt == Opt::HighContrast {
|
||||
// Options.highContrast: the built-in pack follows the option.
|
||||
let on = s.resource_packs.iter().any(|id| id == "high_contrast");
|
||||
if on != s.high_contrast {
|
||||
s.resource_packs.retain(|id| id != "high_contrast");
|
||||
if s.high_contrast {
|
||||
s.resource_packs.push("high_contrast".into());
|
||||
}
|
||||
// PackSelectionScreen reads the repository each time it
|
||||
// opens: the next one starts from this selection.
|
||||
self.pack_selection = s.resource_packs.clone();
|
||||
self.pack_screen = None;
|
||||
events.push(Event::Packs(s.resource_packs.clone()));
|
||||
}
|
||||
}
|
||||
events.push(Event::Changed);
|
||||
events.extend(self.cycle_option(s, opt, right, framebuffer));
|
||||
}
|
||||
Act::Extra(item) => {
|
||||
if let Some(name) = item.key_name() {
|
||||
@@ -2924,6 +2905,77 @@ impl OptionsUi {
|
||||
self.scroll -= amount * 18.0;
|
||||
self.clamp_scroll(top, bottom, content);
|
||||
}
|
||||
/// `CycleButton.cycleValue` on an option, then what the option changes:
|
||||
/// the preset's values, High Contrast's built-in pack.
|
||||
fn cycle_option(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
opt: Opt,
|
||||
back: bool,
|
||||
framebuffer: [i32; 2],
|
||||
) -> Vec<Event> {
|
||||
let mut events = Vec::new();
|
||||
opt.cycle(s, back, framebuffer);
|
||||
if opt == Opt::Preset {
|
||||
apply_preset(s);
|
||||
}
|
||||
if opt == Opt::HighContrast {
|
||||
// Options.highContrast: the built-in pack follows the option.
|
||||
let on = s.resource_packs.iter().any(|id| id == "high_contrast");
|
||||
if on != s.high_contrast {
|
||||
s.resource_packs.retain(|id| id != "high_contrast");
|
||||
if s.high_contrast {
|
||||
s.resource_packs.push("high_contrast".into());
|
||||
}
|
||||
// PackSelectionScreen reads the repository each time it
|
||||
// opens: the next one starts from this selection.
|
||||
self.pack_selection = s.resource_packs.clone();
|
||||
self.pack_screen = None;
|
||||
events.push(Event::Packs(s.resource_packs.clone()));
|
||||
}
|
||||
}
|
||||
events.push(Event::Changed);
|
||||
events
|
||||
}
|
||||
/// The mouse wheel at the cursor. Over a cycle button it takes the step
|
||||
/// (`CycleButton.mouseScrolled`: up goes back) and the list stays put.
|
||||
pub fn wheel(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
size: (f32, f32),
|
||||
cursor: (f32, f32),
|
||||
framebuffer: [i32; 2],
|
||||
amount: f32,
|
||||
) -> Vec<Event> {
|
||||
let plain = self.resource_error.is_none()
|
||||
&& Self::graphics_level(self.page()).is_none()
|
||||
&& !(self.pack_screen.is_some() && self.stack.last() == Some(&Page::Packs));
|
||||
if !plain {
|
||||
self.scroll_at(s, size, cursor, amount);
|
||||
return Vec::new();
|
||||
}
|
||||
let (widgets, top, bottom, _) = self.layout(s, size.0, size.1);
|
||||
let hovered = widgets.iter().find_map(|wd| match &wd.kind {
|
||||
WKind::Button {
|
||||
active: true,
|
||||
act: Act::Cycle(opt),
|
||||
..
|
||||
} if ui::hovered(cursor, wd.x, wd.y, wd.w, wd.h)
|
||||
&& !(wd.list && (cursor.1 < top || cursor.1 >= bottom)) =>
|
||||
{
|
||||
Some(*opt)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
match hovered {
|
||||
Some(opt) if amount != 0.0 => self.cycle_option(s, opt, amount > 0.0, framebuffer),
|
||||
Some(_) => Vec::new(),
|
||||
None => {
|
||||
self.scroll_at(s, size, cursor, amount);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The mouse wheel at the cursor.
|
||||
pub fn scroll_at(&mut self, s: &Settings, size: (f32, f32), cursor: (f32, f32), amount: f32) {
|
||||
if self.resource_error.is_some() {
|
||||
@@ -2940,6 +2992,33 @@ impl OptionsUi {
|
||||
_ => self.scroll(s, size, amount),
|
||||
}
|
||||
}
|
||||
/// `VideoSettingsScreen.mouseScrolled`: with Ctrl held the wheel steps the GUI scale
|
||||
/// (auto counts as one past the maximum) and the list goes back to the top.
|
||||
pub fn video_wheel_gui_scale(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
framebuffer: [i32; 2],
|
||||
amount: f32,
|
||||
) -> Vec<Event> {
|
||||
let max = i64::from(crate::window::calculate_scale(
|
||||
framebuffer,
|
||||
0,
|
||||
s.force_unicode,
|
||||
));
|
||||
let old = if s.gui_scale == 0 {
|
||||
max + 1
|
||||
} else {
|
||||
i64::from(s.gui_scale)
|
||||
};
|
||||
let step = i64::from(amount > 0.0) - i64::from(amount < 0.0);
|
||||
let new = old + step;
|
||||
if new == 0 || new > max {
|
||||
return Vec::new();
|
||||
}
|
||||
s.gui_scale = new as u32;
|
||||
self.scroll = 0.0;
|
||||
vec![Event::Changed]
|
||||
}
|
||||
/// The pack screen at this GUI size, made on first use.
|
||||
fn packs(&mut self, atlas: &HudAtlas, size: (f32, f32)) -> &mut crate::pack_screen::PackScreen {
|
||||
let m = crate::screen_draw::Measure(&atlas.font);
|
||||
|
||||
@@ -581,7 +581,15 @@ fn build_overlay_uncached(
|
||||
Some(p) => link(p, &target)?,
|
||||
None => {
|
||||
if let Some(Some(z)) = zips.get(*owner) {
|
||||
fs::write(&target, z.read(inner)?)?;
|
||||
// One unreadable entry costs that file, not the
|
||||
// pack: an error here failed the build and the
|
||||
// reload dropped every server pack in the stack.
|
||||
match z.read(inner) {
|
||||
Ok(bytes) => fs::write(&target, bytes)?,
|
||||
Err(e) => log::warn!(
|
||||
"Horizon: skipping unreadable pack entry {inner}: {e}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,9 @@ pub struct ParticleOptions {
|
||||
/// `ItemParticleOption`: the particle sprite of each layer of the item's
|
||||
/// model as resolved for the ground.
|
||||
pub item: Option<Vec<String>>,
|
||||
/// `GeyserParticleOptions.waterBlocks` and `GeyserBaseParticleOptions`'
|
||||
/// `waterBlocks` and `burstImpulseBase` (0 where the type has none).
|
||||
pub geyser: Option<(i32, f32)>,
|
||||
}
|
||||
impl ParticleOptions {
|
||||
pub fn simple(ty: ParticleType) -> Self {
|
||||
@@ -55,6 +58,21 @@ impl ParticleOptions {
|
||||
to_color: [1.0, 0.0, 0.0],
|
||||
trail: None,
|
||||
item: None,
|
||||
geyser: None,
|
||||
}
|
||||
}
|
||||
/// `GeyserParticleOptions(type, waterBlocks)`.
|
||||
pub fn geyser(ty: ParticleType, water_blocks: i32) -> Self {
|
||||
Self {
|
||||
geyser: Some((water_blocks, 0.0)),
|
||||
..Self::simple(ty)
|
||||
}
|
||||
}
|
||||
/// `GeyserBaseParticleOptions(type, waterBlocks, burstImpulseBase)`.
|
||||
pub fn geyser_base(ty: ParticleType, water_blocks: i32, burst_impulse_base: f32) -> Self {
|
||||
Self {
|
||||
geyser: Some((water_blocks, burst_impulse_base)),
|
||||
..Self::simple(ty)
|
||||
}
|
||||
}
|
||||
/// `ItemParticleOption`: the stack's layer particle sprites (`BreakingItemParticle`).
|
||||
@@ -189,6 +207,29 @@ enum Behaviour {
|
||||
size_start: f32,
|
||||
y_prev: f64,
|
||||
},
|
||||
/// `NoxiousGasParticle`: fades out from `fade_start` ticks of age.
|
||||
NoxiousGas {
|
||||
fade_start: f32,
|
||||
},
|
||||
/// `NoxiousGasCloudParticle`: every second tick, a gas particle where the
|
||||
/// gas reaches.
|
||||
NoxiousGasCloud,
|
||||
/// `GeyserEruptionParticle`: the base puffs, the plume and the poofs of
|
||||
/// one eruption; `aux` is the speed its provider was given.
|
||||
GeyserEruption {
|
||||
water_blocks: i32,
|
||||
aux: [f64; 3],
|
||||
},
|
||||
/// `GeyserPlumeParticle`: shoots up `plumeHeight` blocks, then falls back.
|
||||
GeyserPlume {
|
||||
start_y: f64,
|
||||
max_y: f64,
|
||||
propulsion: f32,
|
||||
spray: [f32; 2],
|
||||
min_size: f32,
|
||||
max_size: f32,
|
||||
done: bool,
|
||||
},
|
||||
BubblePop,
|
||||
BubbleColumnUp,
|
||||
CurrentDown {
|
||||
@@ -869,6 +910,26 @@ impl Particle {
|
||||
(age / self.lifetime as f32).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
/// `NoxiousGasCloudParticle.pickRandomParticleSpawnPoint`: a point up to three
|
||||
/// blocks from the centre of `center_block` in a random horizontal direction,
|
||||
/// a quarter block lower, from the level's random.
|
||||
fn pick_random_particle_spawn_point(random: &mut LegacyRandom, center_block: [i32; 3]) -> [f64; 3] {
|
||||
let dx = f64::from(random.next_float() - 0.5);
|
||||
let dz = f64::from(random.next_float() - 0.5);
|
||||
// Vec3.normalize.
|
||||
let length = (dx * dx + dz * dz).sqrt();
|
||||
let (dx, dz) = if length < f64::from(1.0e-5f32) {
|
||||
(0.0, 0.0)
|
||||
} else {
|
||||
(dx / length, dz / length)
|
||||
};
|
||||
let distance = f64::from(random.next_float() * 3.0);
|
||||
[
|
||||
f64::from(center_block[0]) + 0.5 + dx * distance,
|
||||
f64::from(center_block[1]) + 0.5 - 0.25,
|
||||
f64::from(center_block[2]) + 0.5 + dz * distance,
|
||||
]
|
||||
}
|
||||
/// `FireflyParticle.getFadeAmount`.
|
||||
fn fade_amount(progress: f32, fade_in: f32, fade_out: f32) -> f32 {
|
||||
if progress >= 1.0 - fade_in {
|
||||
@@ -928,7 +989,7 @@ impl ParticleLevel<'_> {
|
||||
Err(_) => d,
|
||||
}
|
||||
}
|
||||
fn block_at(p: [f64; 3]) -> [i32; 3] {
|
||||
pub(crate) fn block_at(p: [f64; 3]) -> [i32; 3] {
|
||||
[
|
||||
p[0].floor() as i32,
|
||||
p[1].floor() as i32,
|
||||
@@ -950,7 +1011,7 @@ impl ParticleLevel<'_> {
|
||||
(s.fluid, height)
|
||||
}
|
||||
/// The fluid at a block and whether it is a source (`FluidState.isSource`).
|
||||
fn fluid_source(&self, b: [i32; 3]) -> (Fluid, bool) {
|
||||
pub(crate) fn fluid_source(&self, b: [i32; 3]) -> (Fluid, bool) {
|
||||
let s = self.facts.get(self.world.block(b[0], b[1], b[2]));
|
||||
(s.fluid, s.fluid_level == 8 && !s.fluid_falling)
|
||||
}
|
||||
@@ -1165,6 +1226,17 @@ impl ParticleEngine {
|
||||
self.create(options, p, v);
|
||||
}
|
||||
}
|
||||
/// `NoxiousGasCloudParticle.spawnNoxiousGasParticle`: the gas, as an
|
||||
/// always visible particle (`ClientLevel.addAlwaysVisibleParticle`).
|
||||
fn spawn_noxious_gas_particle(&mut self, pos: [f64; 3]) {
|
||||
self.add_with(
|
||||
&ParticleOptions::simple(ParticleType::NoxiousGas),
|
||||
pos,
|
||||
[0.0; 3],
|
||||
false,
|
||||
true,
|
||||
);
|
||||
}
|
||||
/// `ClientLevel.calculateParticleLevel`: minimal still shows one in ten
|
||||
/// always-visible particles as decreased, and decreased drops a third.
|
||||
fn calculate_particle_level(&mut self, always_show: bool) -> u8 {
|
||||
@@ -1562,7 +1634,15 @@ impl ParticleEngine {
|
||||
q.set = set;
|
||||
q
|
||||
}
|
||||
T::Smoke | T::LargeSmoke | T::WhiteSmoke | T::Ash | T::WhiteAsh | T::DustPlume => {
|
||||
T::Smoke
|
||||
| T::LargeSmoke
|
||||
| T::WhiteSmoke
|
||||
| T::Ash
|
||||
| T::WhiteAsh
|
||||
| T::DustPlume
|
||||
| T::NoxiousGas
|
||||
| T::GeyserBase
|
||||
| T::GeyserPoof => {
|
||||
// BaseAshSmokeParticle(dir, xa, scale, colorRandom, maxLifetime, gravity, hasPhysics).
|
||||
let (dir, scale, color_random, max_lifetime, gravity, physics, vel) = match ty {
|
||||
T::Smoke | T::WhiteSmoke => ([0.1, 0.1, 0.1], 1.0, 0.3, 8, -0.1, true, v),
|
||||
@@ -1578,6 +1658,22 @@ impl ParticleEngine {
|
||||
false,
|
||||
[v[0], v[1] + f64::from(0.15f32), v[2]],
|
||||
),
|
||||
// NoxiousGasParticle.Provider: scale 3.
|
||||
T::NoxiousGas => ([0.1, 0.1, 0.1], 3.0, 0.3, 5, -0.02, true, v),
|
||||
// GeyserBaseParticle: the burst impulse scales every axis.
|
||||
T::GeyserBase | T::GeyserPoof => {
|
||||
let (water_blocks, burst_impulse_base) = o.geyser?;
|
||||
let burst_impulse = burst_impulse_base + 0.25 * water_blocks as f32;
|
||||
(
|
||||
[f64::from(burst_impulse); 3],
|
||||
3.0 + 0.125 * water_blocks as f32,
|
||||
0.0,
|
||||
0,
|
||||
0.0,
|
||||
true,
|
||||
v,
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
// WhiteAshParticle.Provider draws the motion.
|
||||
let pr = &mut self.random;
|
||||
@@ -1593,6 +1689,18 @@ impl ParticleEngine {
|
||||
([0.1, -0.1, 0.1], 1.0, 0.0, 20, 0.0125, false, [xa, ya, za])
|
||||
}
|
||||
};
|
||||
// GeyserBaseParticle.Provider: a random spot half a block
|
||||
// around the point, a fifth of a block up (the provider's
|
||||
// random, as float arithmetic added to the double position).
|
||||
let p = if matches!(ty, T::GeyserBase | T::GeyserPoof) {
|
||||
let pr = &mut self.random;
|
||||
let x = p[0] + f64::from((pr.next_float() - 0.5) * 0.5);
|
||||
let y = p[1] + f64::from((pr.next_float() - 0.5) * 0.5) + f64::from(0.2f32);
|
||||
let z = p[2] + f64::from((pr.next_float() - 0.5) * 0.5);
|
||||
[x, y, z]
|
||||
} else {
|
||||
p
|
||||
};
|
||||
let mut q = Particle::moving(r, ty, p, [0.0; 3]);
|
||||
q.friction = 0.96;
|
||||
q.gravity = gravity;
|
||||
@@ -1621,6 +1729,87 @@ impl ParticleEngine {
|
||||
);
|
||||
q.behaviour = Behaviour::DustPlume;
|
||||
}
|
||||
match ty {
|
||||
// NoxiousGasParticle: white, living `6 / (0.5 to 1) * 3`
|
||||
// ticks, fading out over its second half.
|
||||
T::NoxiousGas => {
|
||||
q.set_color(1.0, 1.0, 1.0);
|
||||
q.lifetime = (6.0 / (f64::from(r.next_float()) * 0.5 + 0.5)
|
||||
* f64::from(3.0f32)) as i32;
|
||||
q.behaviour = Behaviour::NoxiousGas {
|
||||
fade_start: q.lifetime as f32 / 2.0,
|
||||
};
|
||||
}
|
||||
// GeyserBaseParticle: white, only ever rising, 20 to 25 ticks.
|
||||
T::GeyserBase | T::GeyserPoof => {
|
||||
q.friction = 0.725;
|
||||
q.set_color(1.0, 1.0, 1.0);
|
||||
q.velocity[1] = q.velocity[1].abs();
|
||||
let lifetime_factor = 0.8 + 0.2 * self.level_random.next_float();
|
||||
q.lifetime = (25.0 * lifetime_factor) as i32;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
q.set = set;
|
||||
q
|
||||
}
|
||||
// NoxiousGasCloudParticle: lives 20 ticks, draws nothing itself.
|
||||
T::NoxiousGasCloud => {
|
||||
let mut q = Particle::base_at(r, ty, p);
|
||||
q.lifetime = 20;
|
||||
q.no_render = true;
|
||||
q.behaviour = Behaviour::NoxiousGasCloud;
|
||||
q
|
||||
}
|
||||
// GeyserEruptionParticle: lives 20 ticks, draws nothing itself.
|
||||
T::Geyser => {
|
||||
let (water_blocks, _) = o.geyser?;
|
||||
let mut q = Particle::base_at(r, ty, p);
|
||||
q.lifetime = 20;
|
||||
q.no_render = true;
|
||||
q.behaviour = Behaviour::GeyserEruption {
|
||||
water_blocks,
|
||||
aux: v,
|
||||
};
|
||||
q
|
||||
}
|
||||
// GeyserPlumeParticle, after its provider's random placement.
|
||||
T::GeyserPlume => {
|
||||
let (water_blocks, _) = o.geyser?;
|
||||
let pr = &mut self.random;
|
||||
let at = [
|
||||
p[0] + f64::from((pr.next_float() - 0.5) * 0.2),
|
||||
p[1] + f64::from(pr.next_float()),
|
||||
p[2] + f64::from((pr.next_float() - 0.5) * 0.2),
|
||||
];
|
||||
let plume_height = 5 * water_blocks.max(1);
|
||||
let mut q = Particle::moving(r, ty, at, v);
|
||||
q.has_physics = true;
|
||||
q.speed_up_when_y_blocked = true;
|
||||
q.lifetime = plume_height * 5;
|
||||
q.velocity[1] = 0.0;
|
||||
let level = &mut self.level_random;
|
||||
let spray = [
|
||||
(level.next_float() - 0.5) * 0.2,
|
||||
(level.next_float() - 0.5) * 0.2,
|
||||
];
|
||||
q.friction = 1.0;
|
||||
let propulsion =
|
||||
(if water_blocks == 1 { 1.5 } else { 1.0 }) * plume_height as f32 * 1.45;
|
||||
q.gravity = -propulsion;
|
||||
let initial_size = q.size * 0.75;
|
||||
let min_size = initial_size * (2.0 + plume_height as f32 / 8.0);
|
||||
let max_size = initial_size * (3.0 + plume_height as f32 / 8.0);
|
||||
q.size = min_size;
|
||||
q.behaviour = Behaviour::GeyserPlume {
|
||||
start_y: at[1],
|
||||
max_y: at[1] + f64::from(plume_height) - 1.0,
|
||||
propulsion,
|
||||
spray,
|
||||
min_size,
|
||||
max_size,
|
||||
done: false,
|
||||
};
|
||||
q.set = set;
|
||||
q
|
||||
}
|
||||
@@ -3198,6 +3387,98 @@ impl ParticleEngine {
|
||||
y_prev: p.position[1],
|
||||
};
|
||||
}
|
||||
Behaviour::NoxiousGas { fade_start } => {
|
||||
p.base_tick(level);
|
||||
self.sprite_from_age(p);
|
||||
if p.age as f32 > fade_start {
|
||||
let frames_since_fade_out_start = p.age as f32 - fade_start;
|
||||
p.rgba[3] =
|
||||
(p.lifetime as f32 - frames_since_fade_out_start) / p.lifetime as f32;
|
||||
}
|
||||
}
|
||||
Behaviour::NoxiousGasCloud => {
|
||||
p.base_tick(level);
|
||||
if p.age % 2 == 0 {
|
||||
let source = ParticleLevel::block_at(p.position);
|
||||
let spawn = pick_random_particle_spawn_point(&mut self.level_random, source);
|
||||
if crate::potent_sulfur::can_be_reached_by_noxious_gas(level, source, spawn) {
|
||||
self.spawn_noxious_gas_particle(spawn);
|
||||
}
|
||||
}
|
||||
}
|
||||
Behaviour::GeyserEruption { water_blocks, aux } => {
|
||||
p.base_tick(level);
|
||||
if p.age % 2 == 0 {
|
||||
for _ in 0..2 {
|
||||
self.add(
|
||||
&ParticleOptions::geyser_base(
|
||||
ParticleType::GeyserBase,
|
||||
water_blocks,
|
||||
1.5,
|
||||
),
|
||||
p.position,
|
||||
aux,
|
||||
);
|
||||
}
|
||||
}
|
||||
for _ in 0..water_blocks + 2 {
|
||||
self.add(
|
||||
&ParticleOptions::geyser(ParticleType::GeyserPlume, water_blocks),
|
||||
p.position,
|
||||
aux,
|
||||
);
|
||||
}
|
||||
if p.age % 10 == 0 {
|
||||
for _ in 0..20 {
|
||||
self.add(
|
||||
&ParticleOptions::geyser_base(
|
||||
ParticleType::GeyserPoof,
|
||||
water_blocks,
|
||||
2.0,
|
||||
),
|
||||
p.position,
|
||||
aux,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Behaviour::GeyserPlume {
|
||||
start_y,
|
||||
max_y,
|
||||
propulsion,
|
||||
spray,
|
||||
min_size,
|
||||
max_size,
|
||||
mut done,
|
||||
} => {
|
||||
p.base_tick(level);
|
||||
if !done
|
||||
&& (p.velocity[1] < 0.0
|
||||
|| p.position[1] > max_y
|
||||
|| p.position[1] == p.previous[1])
|
||||
{
|
||||
p.lifetime = p.lifetime.min(p.age + 5);
|
||||
p.friction = 0.0;
|
||||
done = true;
|
||||
}
|
||||
let y_progress_linear =
|
||||
((p.position[1] - start_y) / (max_y - start_y)).clamp(0.0, 1.0);
|
||||
let y_progress_exponential = horizon_core::jmath::pow(y_progress_linear, 3.0);
|
||||
p.gravity = propulsion * y_progress_exponential as f32 * 0.12;
|
||||
p.velocity[0] = y_progress_linear * f64::from(spray[0]);
|
||||
p.velocity[2] = y_progress_linear * f64::from(spray[1]);
|
||||
self.sprite_from_age(p);
|
||||
p.size = min_size + (y_progress_linear * f64::from(max_size - min_size)) as f32;
|
||||
p.behaviour = Behaviour::GeyserPlume {
|
||||
start_y,
|
||||
max_y,
|
||||
propulsion,
|
||||
spray,
|
||||
min_size,
|
||||
max_size,
|
||||
done,
|
||||
};
|
||||
}
|
||||
Behaviour::BubbleColumnUp => {
|
||||
p.base_tick(level);
|
||||
if !p.removed && level.fluid(ParticleLevel::block_at(p.position)).0 != Fluid::Water
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
//! `PotentSulfurBlockEntity`'s reads of the level: where the noxious gas of a
|
||||
//! potent sulfur block comes out, how far a geyser throws, and whether the gas
|
||||
//! reaches a point. The client's particles and the server's nausea and
|
||||
//! launches read the level the same way, through a [`ParticleLevel`].
|
||||
use crate::{blocks::Fluid, collisions::SceneCollisions, particles::ParticleLevel};
|
||||
use horizon_core::{
|
||||
geometry::{Vec3, traverse_blocks},
|
||||
position::BlockPos,
|
||||
};
|
||||
|
||||
/// `PotentSulfurBlock.ALLOWED_WATER_BLOCKS_ABOVE`.
|
||||
const ALLOWED_WATER_BLOCKS_ABOVE: i32 = 4;
|
||||
|
||||
/// `PotentSulfurBlockEntity.isGeyserPassableBlock`: air, water, or a block
|
||||
/// with no collision shape.
|
||||
pub fn is_geyser_passable_block(level: &ParticleLevel, pos: [i32; 3]) -> bool {
|
||||
let state = level.world.block(pos[0], pos[1], pos[2]);
|
||||
if level.facts.is_air(state) || &*level.facts.get(state).name == "minecraft:water" {
|
||||
return true;
|
||||
}
|
||||
let mut scene = SceneCollisions {
|
||||
world: level.world,
|
||||
binding: level.collision,
|
||||
subject: None,
|
||||
};
|
||||
match level.collision.collision_shape(&mut scene, state, pos) {
|
||||
Ok(shape) => shape.is_empty(),
|
||||
Err(_) => !level.facts.get(state).full_collision,
|
||||
}
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlockEntity.isWater`: the block at `pos` holds a water source
|
||||
/// (`FluidState.isSourceOfType`).
|
||||
fn is_water_source(level: &ParticleLevel, pos: [i32; 3]) -> bool {
|
||||
let (fluid, source) = level.fluid_source(pos);
|
||||
fluid == Fluid::Water && source
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlockEntity.findNoxiousGasSourceBlock`: the first block above
|
||||
/// the sulfur, within the water column's reach, that is not water-filled and
|
||||
/// lets gas through; None when something solid or a deeper column stops it.
|
||||
pub fn find_noxious_gas_source_block(level: &ParticleLevel, origin: [i32; 3]) -> Option<[i32; 3]> {
|
||||
let max_y = origin[1] + ALLOWED_WATER_BLOCKS_ABOVE + 1;
|
||||
let mut pos = [origin[0], origin[1] + 1, origin[2]];
|
||||
while pos[1] <= max_y {
|
||||
let state = level.world.block(pos[0], pos[1], pos[2]);
|
||||
let is_water_block = &*level.facts.get(state).name == "minecraft:water";
|
||||
if !is_water_source(level, pos)
|
||||
|| (!is_water_block && !is_geyser_passable_block(level, pos))
|
||||
{
|
||||
if level.facts.is_air(state) || is_geyser_passable_block(level, pos) {
|
||||
return Some(pos);
|
||||
}
|
||||
break;
|
||||
}
|
||||
pos[1] += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlockEntity.getUnobstructedBlockCount`: how many blocks from
|
||||
/// `pos` up, at most six per water block, a geyser's push goes through.
|
||||
pub fn unobstructed_block_count(level: &ParticleLevel, pos: [i32; 3], water_blocks: i32) -> i32 {
|
||||
let geyser_force_height = 6 * water_blocks;
|
||||
for i in 0..geyser_force_height {
|
||||
if !is_geyser_passable_block(level, [pos[0], pos[1] + i, pos[2]]) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
geyser_force_height
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlockEntity.canBeReachedByNoxiousGas`: `pos` is in a block gas
|
||||
/// can enter, within three blocks of the source block's centre, and above
|
||||
/// water the source's water can be seen from.
|
||||
pub fn can_be_reached_by_noxious_gas(
|
||||
level: &ParticleLevel,
|
||||
source_block: [i32; 3],
|
||||
pos: [f64; 3],
|
||||
) -> bool {
|
||||
let block = ParticleLevel::block_at(pos);
|
||||
if !is_geyser_passable_block(level, block) {
|
||||
return false;
|
||||
}
|
||||
let center = source_block.map(|c| f64::from(c) + 0.5);
|
||||
let distance_sqr =
|
||||
(center[0] - pos[0]).powi(2) + (center[1] - pos[1]).powi(2) + (center[2] - pos[2]).powi(2);
|
||||
if distance_sqr > 9.0 {
|
||||
return false;
|
||||
}
|
||||
let below_source = [center[0], f64::from(source_block[1] - 1) + 0.5, center[2]];
|
||||
let below_pos = [pos[0], pos[1] - 1.0, pos[2]];
|
||||
is_water_source(level, ParticleLevel::block_at(below_pos))
|
||||
&& have_line_of_sight(level, below_source, below_pos)
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlockEntity.haveLineOfSight`: a clip over collider shapes and
|
||||
/// no fluids (as the mobs' line of sight does) finds no block between the
|
||||
/// points.
|
||||
fn have_line_of_sight(level: &ParticleLevel, a: [f64; 3], b: [f64; 3]) -> bool {
|
||||
let (from, to) = (Vec3::new(a[0], a[1], a[2]), Vec3::new(b[0], b[1], b[2]));
|
||||
let mut scene = SceneCollisions {
|
||||
world: level.world,
|
||||
binding: level.collision,
|
||||
subject: None,
|
||||
};
|
||||
let hit = traverse_blocks(from, to, 512, |p: BlockPos| {
|
||||
let state = level.world.block(p.x, p.y, p.z);
|
||||
if level.facts.is_air(state) {
|
||||
return None;
|
||||
}
|
||||
let shape = level
|
||||
.collision
|
||||
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
|
||||
.ok()?;
|
||||
shape.clip(from, to, p).map(|_| ())
|
||||
});
|
||||
!matches!(hit, Ok(Some(())))
|
||||
}
|
||||
@@ -121,6 +121,10 @@ pub fn warden_can_listen(event: &str) -> bool {
|
||||
|| event == SHRIEK
|
||||
|| shrieker_can_listen(event)
|
||||
}
|
||||
/// `GameEventTags.ALLAY_CAN_LISTEN`: note block notes.
|
||||
pub fn allay_can_listen(event: &str) -> bool {
|
||||
event == "minecraft:note_block_play"
|
||||
}
|
||||
/// `GameEventTags.IGNORE_VIBRATIONS_SNEAKING`.
|
||||
pub fn ignored_when_sneaking(event: &str) -> bool {
|
||||
matches!(
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Sets of section indexes in a chunk column, a bit per section.
|
||||
//!
|
||||
//! Vanilla's tallest level has 254 sections (`DimensionType.Y_SIZE / 16`), and
|
||||
//! light data adds a section below and above it: 256 bits hold every layout a
|
||||
//! dimension type allows.
|
||||
|
||||
/// Section indexes of one column, 0 being the lowest.
|
||||
#[derive(Clone, Copy, Default, PartialEq, Eq, Hash)]
|
||||
pub struct SectionMask([u64; 4]);
|
||||
|
||||
impl SectionMask {
|
||||
pub const CAPACITY: usize = 256;
|
||||
pub const EMPTY: Self = Self([0; 4]);
|
||||
pub const ALL: Self = Self([u64::MAX; 4]);
|
||||
|
||||
/// The indexes `0..n`.
|
||||
pub const fn below(n: usize) -> Self {
|
||||
let mut words = [0; 4];
|
||||
let mut w = 0;
|
||||
while w < 4 {
|
||||
let bits = n.saturating_sub(w * 64);
|
||||
words[w] = if bits >= 64 {
|
||||
u64::MAX
|
||||
} else {
|
||||
(1 << bits) - 1
|
||||
};
|
||||
w += 1;
|
||||
}
|
||||
Self(words)
|
||||
}
|
||||
pub fn single(i: usize) -> Self {
|
||||
let mut mask = Self::EMPTY;
|
||||
mask.insert(i);
|
||||
mask
|
||||
}
|
||||
#[inline]
|
||||
pub fn contains(&self, i: usize) -> bool {
|
||||
i < Self::CAPACITY && self.0[i >> 6] >> (i & 63) & 1 != 0
|
||||
}
|
||||
#[inline]
|
||||
pub fn insert(&mut self, i: usize) {
|
||||
self.0[i >> 6] |= 1 << (i & 63);
|
||||
}
|
||||
#[inline]
|
||||
pub fn remove(&mut self, i: usize) {
|
||||
self.0[i >> 6] &= !(1 << (i & 63));
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0 == [0; 4]
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.iter().map(|w| w.count_ones() as usize).sum()
|
||||
}
|
||||
/// The indexes from `i` up.
|
||||
pub fn from(self, i: usize) -> Self {
|
||||
self & !Self::below(i)
|
||||
}
|
||||
/// The indexes, lowest first.
|
||||
pub fn iter(self) -> Indexes {
|
||||
Indexes(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::BitOr for SectionMask {
|
||||
type Output = Self;
|
||||
fn bitor(self, other: Self) -> Self {
|
||||
Self(std::array::from_fn(|w| self.0[w] | other.0[w]))
|
||||
}
|
||||
}
|
||||
impl std::ops::BitOrAssign for SectionMask {
|
||||
fn bitor_assign(&mut self, other: Self) {
|
||||
*self = *self | other;
|
||||
}
|
||||
}
|
||||
impl std::ops::BitAnd for SectionMask {
|
||||
type Output = Self;
|
||||
fn bitand(self, other: Self) -> Self {
|
||||
Self(std::array::from_fn(|w| self.0[w] & other.0[w]))
|
||||
}
|
||||
}
|
||||
impl std::ops::Not for SectionMask {
|
||||
type Output = Self;
|
||||
fn not(self) -> Self {
|
||||
Self(self.0.map(|w| !w))
|
||||
}
|
||||
}
|
||||
impl std::fmt::Debug for SectionMask {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_set().entries(self.iter()).finish()
|
||||
}
|
||||
}
|
||||
impl FromIterator<usize> for SectionMask {
|
||||
fn from_iter<I: IntoIterator<Item = usize>>(indexes: I) -> Self {
|
||||
let mut mask = Self::EMPTY;
|
||||
for i in indexes {
|
||||
mask.insert(i);
|
||||
}
|
||||
mask
|
||||
}
|
||||
}
|
||||
|
||||
/// `SectionMask::iter`: the set's indexes in ascending order.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Indexes(SectionMask);
|
||||
impl Iterator for Indexes {
|
||||
type Item = usize;
|
||||
#[inline]
|
||||
fn next(&mut self) -> Option<usize> {
|
||||
let words = &mut (self.0).0;
|
||||
let w = words.iter().position(|&w| w != 0)?;
|
||||
let bit = words[w].trailing_zeros() as usize;
|
||||
words[w] &= words[w] - 1;
|
||||
Some(w * 64 + bit)
|
||||
}
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
let len = self.0.len();
|
||||
(len, Some(len))
|
||||
}
|
||||
}
|
||||
impl ExactSizeIterator for Indexes {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sets_across_words() {
|
||||
let mut mask = SectionMask::EMPTY;
|
||||
for i in [0, 23, 63, 64, 130, 255] {
|
||||
mask.insert(i);
|
||||
}
|
||||
assert_eq!(mask.iter().collect::<Vec<_>>(), [0, 23, 63, 64, 130, 255]);
|
||||
assert_eq!(mask.len(), 6);
|
||||
assert!(mask.contains(130) && !mask.contains(131) && !mask.contains(300));
|
||||
mask.remove(64);
|
||||
assert_eq!(mask.from(24).iter().collect::<Vec<_>>(), [63, 130, 255]);
|
||||
assert_eq!(mask.from(256), SectionMask::EMPTY);
|
||||
assert_eq!(mask.from(0), mask);
|
||||
assert!(SectionMask::EMPTY.is_empty() && !mask.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn below_counts_whole_and_partial_words() {
|
||||
for n in [0, 1, 24, 63, 64, 65, 131, 254, 256] {
|
||||
let mask = SectionMask::below(n);
|
||||
assert_eq!(mask.len(), n);
|
||||
assert_eq!(mask.iter().collect::<Vec<_>>(), (0..n).collect::<Vec<_>>());
|
||||
}
|
||||
assert_eq!(SectionMask::below(1000), SectionMask::ALL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operators() {
|
||||
let a: SectionMask = [1, 70, 200].into_iter().collect();
|
||||
let b: SectionMask = [70, 3].into_iter().collect();
|
||||
assert_eq!((a | b).iter().collect::<Vec<_>>(), [1, 3, 70, 200]);
|
||||
assert_eq!((a & b).iter().collect::<Vec<_>>(), [70]);
|
||||
assert_eq!(
|
||||
(!a & SectionMask::below(4)).iter().collect::<Vec<_>>(),
|
||||
[0, 2, 3]
|
||||
);
|
||||
assert_eq!(format!("{a:?}"), "{1, 70, 200}");
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,8 @@ pub struct Settings {
|
||||
pub chat_links: bool,
|
||||
pub chat_links_prompt: bool,
|
||||
pub chat_opacity: f32,
|
||||
/// Chat Delay in seconds (`chatDelay`): player chat shows this far apart.
|
||||
pub chat_delay: f64,
|
||||
pub text_background_opacity: f32,
|
||||
pub chat_scale: f32,
|
||||
pub chat_width: f32,
|
||||
@@ -246,6 +248,7 @@ impl Default for Settings {
|
||||
chat_links: true,
|
||||
chat_links_prompt: true,
|
||||
chat_opacity: 1.0,
|
||||
chat_delay: 0.0,
|
||||
text_background_opacity: 0.5,
|
||||
chat_scale: 1.0,
|
||||
chat_width: 1.0,
|
||||
@@ -448,6 +451,7 @@ impl Settings {
|
||||
chat_links: f.bool("chatLinks"),
|
||||
chat_links_prompt: f.bool("chatLinksPrompt"),
|
||||
chat_opacity: f.double("chatOpacity") as f32,
|
||||
chat_delay: f.double("chatDelay"),
|
||||
text_background_opacity: f.double("textBackgroundOpacity") as f32,
|
||||
chat_scale: f.double("chatScale") as f32,
|
||||
chat_width: f.double("chatWidth") as f32,
|
||||
@@ -551,6 +555,9 @@ impl Settings {
|
||||
put_b(&mut f, "chatLinks", self.chat_links);
|
||||
put_b(&mut f, "chatLinksPrompt", self.chat_links_prompt);
|
||||
put_f(&mut f, "chatOpacity", self.chat_opacity);
|
||||
if self.chat_delay != f.double("chatDelay") {
|
||||
f.set("chatDelay", V::Double(self.chat_delay));
|
||||
}
|
||||
put_f(
|
||||
&mut f,
|
||||
"textBackgroundOpacity",
|
||||
|
||||
@@ -31,9 +31,45 @@ impl Default for SignText {
|
||||
lines: Default::default(),
|
||||
color: BLACK,
|
||||
glowing: false,
|
||||
click: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The click events `SignBlockEntity.executeClickCommandsIfPresent` acts on.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum ClickAction<'a> {
|
||||
/// `ClickEvent.RunCommand`.
|
||||
RunCommand(String),
|
||||
/// `ClickEvent.ShowDialog`: a registry id or the dialog itself.
|
||||
ShowDialog(&'a Tag),
|
||||
/// `ClickEvent.Custom`.
|
||||
Custom {
|
||||
id: String,
|
||||
payload: Option<&'a Tag>,
|
||||
},
|
||||
}
|
||||
/// A saved `click_event` as its action: the three a sign acts on, read the
|
||||
/// way `ClickEvent.CODEC` does; any other action or a malformed one is None.
|
||||
pub fn click_action(event: &Tag) -> Option<ClickAction<'_>> {
|
||||
let Some(Tag::String(action)) = event.get("action") else {
|
||||
return None;
|
||||
};
|
||||
match action.to_string_lossy().as_str() {
|
||||
"run_command" => match event.get("command") {
|
||||
Some(Tag::String(command)) => Some(ClickAction::RunCommand(command.to_string_lossy())),
|
||||
_ => None,
|
||||
},
|
||||
"show_dialog" => event.get("dialog").map(ClickAction::ShowDialog),
|
||||
"custom" => match event.get("id") {
|
||||
Some(Tag::String(id)) => Some(ClickAction::Custom {
|
||||
id: id.to_string_lossy(),
|
||||
payload: event.get("payload"),
|
||||
}),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
impl SignText {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.lines.iter().all(String::is_empty)
|
||||
@@ -42,6 +78,14 @@ impl SignText {
|
||||
pub fn text_color(&self) -> u32 {
|
||||
DYE_TEXT[usize::from(self.color.min(15))].1
|
||||
}
|
||||
/// `SignText.hasAnyClickCommands`: a line whose style has a `run_command`
|
||||
/// click event.
|
||||
pub fn has_any_click_commands(&self) -> bool {
|
||||
self.click
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|event| matches!(click_action(event), Some(ClickAction::RunCommand(_))))
|
||||
}
|
||||
/// `AbstractSignRenderer.getDarkColor`.
|
||||
pub fn dark_color(&self) -> u32 {
|
||||
let c = self.text_color();
|
||||
@@ -70,6 +114,13 @@ impl SignData {
|
||||
pub fn line_height(&self) -> f32 {
|
||||
if self.hanging { 9.0 } else { 10.0 }
|
||||
}
|
||||
/// `SignBlockEntity.canExecuteClickCommands`: a waxed sign whose side
|
||||
/// has a `run_command` click event. (Horizon keeps no filtered text, so
|
||||
/// `Player.isTextFilteringEnabled` never selects it.)
|
||||
pub fn can_execute_click_commands(&self, front: bool) -> bool {
|
||||
let side = if front { &self.front } else { &self.back };
|
||||
self.waxed && side.has_any_click_commands()
|
||||
}
|
||||
/// NBT entries after the common block-entity header.
|
||||
pub fn encode(&self) -> Vec<(&'static str, Tag)> {
|
||||
let side = |t: &SignText| {
|
||||
@@ -79,7 +130,18 @@ impl SignData {
|
||||
Tag::List(
|
||||
t.lines
|
||||
.iter()
|
||||
.map(|l| Tag::String(l.as_str().into()))
|
||||
.zip(&t.click)
|
||||
.map(|(line, click)| {
|
||||
let text = Tag::String(line.as_str().into());
|
||||
match click {
|
||||
// The line's style keeps its click event.
|
||||
Some(event) => Tag::compound([
|
||||
("text", text),
|
||||
("click_event", event.clone()),
|
||||
]),
|
||||
None => text,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
),
|
||||
@@ -90,19 +152,25 @@ impl SignData {
|
||||
("has_glowing_text", Tag::Byte(i8::from(t.glowing))),
|
||||
])
|
||||
};
|
||||
vec![
|
||||
let mut out = Vec::new();
|
||||
if self.allow_op_features {
|
||||
out.push(("allow_op_features", Tag::Byte(1)));
|
||||
}
|
||||
out.extend([
|
||||
("front_text", side(&self.front)),
|
||||
("back_text", side(&self.back)),
|
||||
("is_waxed", Tag::Byte(i8::from(self.waxed))),
|
||||
]
|
||||
]);
|
||||
out
|
||||
}
|
||||
pub fn decode(&mut self, tag: &Tag) {
|
||||
let side = |t: Option<&Tag>| -> SignText {
|
||||
let mut out = SignText::default();
|
||||
let Some(t) = t else { return out };
|
||||
if let Some(Tag::List(messages)) = t.get("messages") {
|
||||
for (line, m) in out.lines.iter_mut().zip(messages) {
|
||||
for ((line, click), m) in out.lines.iter_mut().zip(&mut out.click).zip(messages) {
|
||||
*line = component_text(m);
|
||||
*click = click_event(m);
|
||||
}
|
||||
}
|
||||
if let Some(Tag::String(c)) = t.get("color") {
|
||||
@@ -117,6 +185,21 @@ impl SignData {
|
||||
self.front = side(tag.get("front_text"));
|
||||
self.back = side(tag.get("back_text"));
|
||||
self.waxed = matches!(tag.get("is_waxed"), Some(Tag::Byte(b)) if *b != 0);
|
||||
self.allow_op_features =
|
||||
matches!(tag.get("allow_op_features"), Some(Tag::Byte(b)) if *b != 0);
|
||||
}
|
||||
}
|
||||
/// The `click_event` in the style of a stored line: the one of a component
|
||||
/// object, or of the first component of a list (`createFromList` copies it
|
||||
/// and appends the rest as siblings).
|
||||
fn click_event(tag: &Tag) -> Option<Tag> {
|
||||
let root = match tag {
|
||||
Tag::List(list) => list.first()?,
|
||||
other => other,
|
||||
};
|
||||
match root.get("click_event") {
|
||||
Some(event @ Tag::Compound(_)) => Some(event.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Plain text of a stored text component (string, `{text: ...}` or JSON string).
|
||||
|
||||
@@ -468,7 +468,10 @@ pub fn head_skin(
|
||||
{
|
||||
let location = skin_location(&hash);
|
||||
match downloads.state(Kind::Skin, &url) {
|
||||
Download::Ready if atlas(&location) => return location,
|
||||
Download::Ready if atlas(&location) => {
|
||||
downloads.drawn(&location);
|
||||
return location;
|
||||
}
|
||||
// Its slot was evicted: offer the image again.
|
||||
Download::Ready => {
|
||||
downloads.again(&location);
|
||||
@@ -681,6 +684,8 @@ pub struct Downloader {
|
||||
/// atlas uploads again (`again`); the disk cache backs anything dropped.
|
||||
images: std::collections::HashMap<String, Vec<u8>>,
|
||||
image_order: std::collections::VecDeque<String>,
|
||||
/// Skins and capes drawn from the atlas since `take_drawn`.
|
||||
drawn: std::collections::HashSet<String>,
|
||||
}
|
||||
/// `images` is bounded only so a long session on a busy server does not grow
|
||||
/// without end; the disk cache holds everything it drops.
|
||||
@@ -697,6 +702,7 @@ impl Downloader {
|
||||
queued: Default::default(),
|
||||
images: Default::default(),
|
||||
image_order: Default::default(),
|
||||
drawn: Default::default(),
|
||||
}
|
||||
}
|
||||
/// How the download of `url` stands, starting it the first time.
|
||||
@@ -801,6 +807,77 @@ impl Downloader {
|
||||
}
|
||||
std::mem::take(&mut self.ready)
|
||||
}
|
||||
/// A skin or cape in the atlas drawn this frame: `SkinSlots` keeps its slot.
|
||||
pub fn drawn(&mut self, location: &str) {
|
||||
if !self.drawn.contains(location) {
|
||||
self.drawn.insert(location.to_owned());
|
||||
}
|
||||
}
|
||||
/// The skins and capes drawn since the last call.
|
||||
pub fn take_drawn(&mut self) -> std::collections::HashSet<String> {
|
||||
std::mem::take(&mut self.drawn)
|
||||
}
|
||||
}
|
||||
|
||||
/// Which downloaded skins and capes hold the atlas's skin slots
|
||||
/// (`hud::SKIN_SLOTS`). Vanilla's `SkinManager.TextureCache.getOrLoad`
|
||||
/// registers a texture per skin and keeps it. Here the slots are shared: a
|
||||
/// new skin takes a free slot, else the least recently drawn skin's, but
|
||||
/// never one drawn in the last frame. With every slot drawn, the new skin
|
||||
/// waits (its player keeps the default skin) instead of pushing out one in
|
||||
/// use, which then came back by pushing out another, so players vanished
|
||||
/// for a frame and heads swapped textures as the slots went round.
|
||||
pub struct SkinSlots {
|
||||
capacity: usize,
|
||||
/// Stored locations and their slot, the least recently drawn first.
|
||||
slots: Vec<(String, usize)>,
|
||||
/// Drawn in the last frame, or stored since: kept from eviction.
|
||||
kept: std::collections::HashSet<String>,
|
||||
}
|
||||
impl SkinSlots {
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
capacity,
|
||||
slots: Vec::new(),
|
||||
kept: Default::default(),
|
||||
}
|
||||
}
|
||||
/// Whether `location` is in a slot.
|
||||
pub fn contains(&self, location: &str) -> bool {
|
||||
self.slots.iter().any(|(l, _)| l == location)
|
||||
}
|
||||
/// A new frame: the skins `drawn` in the last one
|
||||
/// (`Downloader::take_drawn`) become the most recently drawn, and keep
|
||||
/// their slots through this frame's `place`s.
|
||||
pub fn next_frame(&mut self, drawn: std::collections::HashSet<String>) {
|
||||
let (recent, old): (Vec<_>, Vec<_>) = std::mem::take(&mut self.slots)
|
||||
.into_iter()
|
||||
.partition(|(l, _)| drawn.contains(l));
|
||||
self.slots = old;
|
||||
self.slots.extend(recent);
|
||||
self.kept = drawn;
|
||||
}
|
||||
/// A slot for `location`: its own, a free one, else the least recently
|
||||
/// drawn one not kept, with the location it held. `None` while every
|
||||
/// slot is kept.
|
||||
pub fn place(&mut self, location: String) -> Option<(usize, Option<String>)> {
|
||||
if let Some((_, slot)) = self.slots.iter().find(|(l, _)| *l == location) {
|
||||
return Some((*slot, None));
|
||||
}
|
||||
let (slot, evicted) = if self.slots.len() < self.capacity {
|
||||
(self.slots.len(), None)
|
||||
} else {
|
||||
let i = self
|
||||
.slots
|
||||
.iter()
|
||||
.position(|(l, _)| !self.kept.contains(l))?;
|
||||
let (old, slot) = self.slots.remove(i);
|
||||
(slot, Some(old))
|
||||
};
|
||||
self.kept.insert(location.clone());
|
||||
self.slots.push((location, slot));
|
||||
Some((slot, evicted))
|
||||
}
|
||||
}
|
||||
|
||||
/// A GET over HTTP or HTTPS (textures.minecraft.net serves both; vanilla
|
||||
@@ -999,6 +1076,63 @@ mod tests {
|
||||
assert!(super::resolve_profile(&super::NameOrId::Name("Notch".into()), &doc).is_none());
|
||||
}
|
||||
|
||||
/// More skins drawn every frame than there are slots (#3829): a frame's
|
||||
/// uploads used to push out skins drawn the frame before, which then
|
||||
/// uploaded again and pushed out others, so the drawn skins went missing
|
||||
/// in turn. Those drawn keep their slots; the rest wait.
|
||||
#[test]
|
||||
fn skins_drawn_last_frame_keep_their_slots() {
|
||||
let mut d = super::Downloader::new(std::env::temp_dir().join("horizon-test-skin-slots"));
|
||||
let skins: Vec<String> = (0..6).map(|i| format!("skins/{i}")).collect();
|
||||
for (i, s) in skins.iter().enumerate() {
|
||||
d.remember(s.clone(), vec![i as u8]);
|
||||
}
|
||||
let mut slots = super::SkinSlots::new(4);
|
||||
let mut last: Option<Vec<String>> = None;
|
||||
for frame in 0..8 {
|
||||
// draw_tab_list: the downloads that finished go to the atlas.
|
||||
slots.next_frame(d.take_drawn());
|
||||
let mut uploads = 0;
|
||||
for (location, _) in d.take_ready() {
|
||||
if slots.place(location).is_some() {
|
||||
uploads += 1;
|
||||
}
|
||||
}
|
||||
// update_remote_skins: every skin is drawn, from its slot, or
|
||||
// asked for again while it has none.
|
||||
let mut shown = Vec::new();
|
||||
for s in &skins {
|
||||
if slots.contains(s) {
|
||||
d.drawn(s);
|
||||
shown.push(s.clone());
|
||||
} else {
|
||||
d.again(s);
|
||||
}
|
||||
}
|
||||
if frame >= 2 {
|
||||
assert_eq!(uploads, 0, "frame {frame} uploaded again");
|
||||
assert_eq!(Some(&shown), last.as_ref(), "frame {frame} lost a skin");
|
||||
}
|
||||
// The first frame asks for the skins; from the next, four show.
|
||||
assert_eq!(shown.len(), if frame == 0 { 0 } else { 4 });
|
||||
last = Some(shown);
|
||||
}
|
||||
// A skin no longer drawn gives up its slot to a waiting one.
|
||||
let mut slots = super::SkinSlots::new(2);
|
||||
slots.next_frame(Default::default());
|
||||
assert_eq!(slots.place("skins/a".into()), Some((0, None)));
|
||||
assert_eq!(slots.place("skins/b".into()), Some((1, None)));
|
||||
assert_eq!(slots.place("skins/c".into()), None);
|
||||
slots.next_frame(["skins/b".to_owned()].into());
|
||||
assert_eq!(
|
||||
slots.place("skins/c".into()),
|
||||
Some((0, Some("skins/a".into())))
|
||||
);
|
||||
// Stored this frame, kept through it.
|
||||
assert_eq!(slots.place("skins/d".into()), None);
|
||||
assert!(slots.contains("skins/b") && slots.contains("skins/c"));
|
||||
}
|
||||
|
||||
/// A skin evicted from the atlas re-uploads (`again`), once per ask.
|
||||
#[test]
|
||||
fn evicted_skins_upload_again() {
|
||||
|
||||
@@ -159,6 +159,14 @@ impl Sprites {
|
||||
/// up to a power of two and limited to [`MAX_ARRAY_BYTES`]. Smaller
|
||||
/// textures are scaled up by whole pixels, so UVs stay per layer.
|
||||
pub fn finish(&mut self) {
|
||||
self.lay_out(true);
|
||||
}
|
||||
/// [`Sprites::finish`] for a server, which draws nothing: the layers'
|
||||
/// size, but neither their pixels nor the animations' frames.
|
||||
pub fn finish_without_pixels(&mut self) {
|
||||
self.lay_out(false);
|
||||
}
|
||||
fn lay_out(&mut self, pixels: bool) {
|
||||
let layers = self.native.len().max(1) as u64;
|
||||
let largest = self.native.iter().map(|n| n.0).max().unwrap_or(SPRITE_SIZE);
|
||||
self.minimum_size = self.native.iter().map(|n| n.0).min().unwrap_or(SPRITE_SIZE);
|
||||
@@ -166,6 +174,12 @@ impl Sprites {
|
||||
while size > SPRITE_SIZE && layers * u64::from(size * size) * 4 > MAX_ARRAY_BYTES {
|
||||
size /= 2;
|
||||
}
|
||||
if !pixels {
|
||||
self.pixels = Vec::new();
|
||||
self.animations = Vec::new();
|
||||
self.size = size;
|
||||
return;
|
||||
}
|
||||
let mut resized = Vec::with_capacity(self.native.len());
|
||||
self.pixels.clear();
|
||||
for (from, frames, initial) in &self.native {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! so stale work is dropped.
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use crate::{
|
||||
chunk::{Chunk, SECTIONS},
|
||||
chunk::Chunk,
|
||||
light::{LightChanges, LightTables, apply_lighting, apply_relighting, light_chunk},
|
||||
mesher::{MeshTables, Mesher, Region, SectionMesh, borrow},
|
||||
world::World,
|
||||
@@ -16,11 +16,15 @@ use std::{
|
||||
collections::{BinaryHeap, HashSet, VecDeque},
|
||||
sync::{
|
||||
Arc, Condvar, Mutex, MutexGuard, RwLock, TryLockError,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
},
|
||||
thread,
|
||||
};
|
||||
|
||||
/// How long a worker waits with nothing to do before it gives back its
|
||||
/// generator's scratch buffers ([`ChunkGenerator::idle`]).
|
||||
pub const IDLE_RELEASE: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// A chunk under generation, opaque to the streamer.
|
||||
pub type Proto = Box<dyn std::any::Any + Send>;
|
||||
/// Chunk generation in vanilla's stages: a neighbour-independent proto chunk
|
||||
@@ -57,6 +61,9 @@ pub trait ChunkGenerator: Send + Sync + 'static {
|
||||
/// What a loaded chunk needs from the generator before the game uses it,
|
||||
/// on the worker that loaded it.
|
||||
fn prepare_loaded(&self, _worker: &mut Self::Worker, _chunk: &Chunk) {}
|
||||
/// The worker has had nothing to do for [`IDLE_RELEASE`]: give back what
|
||||
/// it keeps between jobs only to go faster.
|
||||
fn idle(&self, _worker: &mut Self::Worker) {}
|
||||
fn proto(&self, worker: &mut Self::Worker, x: i32, z: i32) -> Proto;
|
||||
fn decorates(&self) -> bool {
|
||||
false
|
||||
@@ -220,6 +227,9 @@ struct Shared {
|
||||
/// packs reload; each mesh job takes the current one.
|
||||
context: RwLock<Arc<WorkerContext>>,
|
||||
done: Mailbox,
|
||||
/// Workers that have freed their buffers ([`IDLE_RELEASE`]) and taken no
|
||||
/// job since: the last of them gives the freed memory back to the OS.
|
||||
released: AtomicUsize,
|
||||
}
|
||||
/// Finished jobs, from the workers to the game thread, in the order they
|
||||
/// finished. Not an `mpsc` channel: its receiver waits for a sender it finds
|
||||
@@ -310,6 +320,8 @@ pub struct Streaming {
|
||||
deferred: Vec<Done>,
|
||||
sequence: u64,
|
||||
pub center: (i32, i32),
|
||||
/// The view distance around `center`; below 0, no area there (a dedicated
|
||||
/// server nobody is on).
|
||||
pub radius: i32,
|
||||
/// `/forceload`ed chunks: kept loaded whatever the distance.
|
||||
pub forced: HashSet<(i32, i32)>,
|
||||
@@ -513,7 +525,9 @@ impl Streaming {
|
||||
stop: AtomicBool::new(false),
|
||||
context: RwLock::new(context.clone()),
|
||||
done: Mailbox::default(),
|
||||
released: AtomicUsize::new(0),
|
||||
});
|
||||
let workers = threads.max(1);
|
||||
let light_tables = context.light.clone();
|
||||
let saves: Arc<dyn Saves> = generator.clone();
|
||||
let threads = (0..threads.max(1))
|
||||
@@ -534,14 +548,42 @@ impl Streaming {
|
||||
loop {
|
||||
let job = {
|
||||
let mut queue = shared.queue.lock().unwrap();
|
||||
let mut released = false;
|
||||
loop {
|
||||
if shared.stop.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
if let Some(job) = queue.pop() {
|
||||
if released {
|
||||
shared.released.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
break job.job;
|
||||
}
|
||||
queue = shared.ready.wait(queue).unwrap();
|
||||
if released {
|
||||
queue = shared.ready.wait(queue).unwrap();
|
||||
continue;
|
||||
}
|
||||
let (woken, wait) =
|
||||
shared.ready.wait_timeout(queue, IDLE_RELEASE).unwrap();
|
||||
queue = woken;
|
||||
if wait.timed_out() && queue.is_empty() {
|
||||
// A server nobody is on, a client standing still:
|
||||
// free the generator's buffers (and the mesher's
|
||||
// face cache) until the next job needs them.
|
||||
drop(queue);
|
||||
generator.idle(&mut worker);
|
||||
faces = None;
|
||||
released = true;
|
||||
// The allocator keeps freed pages until memory
|
||||
// runs short: once every worker has freed its own,
|
||||
// hand them back.
|
||||
if shared.released.fetch_add(1, Ordering::Relaxed) + 1
|
||||
== workers
|
||||
{
|
||||
horizon_threads::release_free_memory();
|
||||
}
|
||||
queue = shared.queue.lock().unwrap();
|
||||
}
|
||||
}
|
||||
};
|
||||
let done = match job {
|
||||
@@ -677,7 +719,7 @@ impl Streaming {
|
||||
self.meshing.clear();
|
||||
for &(x, z) in world.chunks.keys() {
|
||||
if self.lit.contains(&(x, z)) {
|
||||
for sy in 0..SECTIONS as i32 {
|
||||
for sy in 0..world.layout().sections() as i32 {
|
||||
world.dirty_sections.insert((x, sy, z));
|
||||
}
|
||||
}
|
||||
@@ -695,7 +737,7 @@ impl Streaming {
|
||||
self.generated_total += 1;
|
||||
for dz in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
for sy in 0..SECTIONS as i32 {
|
||||
for sy in 0..world.layout().sections() as i32 {
|
||||
world.dirty_sections.insert((key.0 + dx, sy, key.1 + dz));
|
||||
}
|
||||
}
|
||||
@@ -710,7 +752,7 @@ impl Streaming {
|
||||
for dy in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
let s = sy as i32 + dy;
|
||||
if (0..SECTIONS as i32).contains(&s) {
|
||||
if (0..world.layout().sections() as i32).contains(&s) {
|
||||
world.dirty_sections.insert((x + dx, s, z + dz));
|
||||
}
|
||||
}
|
||||
@@ -736,7 +778,7 @@ impl Streaming {
|
||||
self.lighting.remove(&(x, z));
|
||||
// By key, not by a pass over every pending section: a moved or
|
||||
// shrunk storage forgets thousands of columns in one frame.
|
||||
for sy in 0..SECTIONS as i32 {
|
||||
for sy in 0..world.layout().sections() as i32 {
|
||||
self.meshing.remove(&(x, sy, z));
|
||||
self.waiting.remove(&(x, sy, z));
|
||||
}
|
||||
@@ -854,7 +896,7 @@ impl Streaming {
|
||||
world.chunks.insert(key, Arc::new(chunk));
|
||||
if loaded {
|
||||
self.lit.insert(key);
|
||||
for sy in 0..SECTIONS as i32 {
|
||||
for sy in 0..world.layout().sections() as i32 {
|
||||
world.dirty_sections.insert((key.0, sy, key.1));
|
||||
}
|
||||
}
|
||||
@@ -1037,7 +1079,7 @@ impl Streaming {
|
||||
if first {
|
||||
for dz in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
for sy in 0..SECTIONS as i32 {
|
||||
for sy in 0..world.layout().sections() as i32 {
|
||||
world.dirty_sections.insert((x + dx, sy, z + dz));
|
||||
}
|
||||
}
|
||||
@@ -1105,7 +1147,9 @@ impl Streaming {
|
||||
.iter()
|
||||
.filter(|&(&(x, z), p)| {
|
||||
p.is_some()
|
||||
&& i64::from(x - center.0).pow(2) + i64::from(z - center.1).pow(2) > r4 * r4
|
||||
&& (self.radius < 0
|
||||
|| i64::from(x - center.0).pow(2) + i64::from(z - center.1).pow(2)
|
||||
> r4 * r4)
|
||||
&& !ranges.in_extra(x, z, 4)
|
||||
&& !ranges.in_band(x, z, 4)
|
||||
&& !ranges.forced.iter().any(|f| {
|
||||
@@ -1331,7 +1375,7 @@ impl Streaming {
|
||||
}
|
||||
if !self.ranges().in_view(x, z) {
|
||||
world.dirty_sections.take_column(x, z);
|
||||
for sy in section_indexes(sections) {
|
||||
for sy in sections.iter().map(|sy| sy as i32) {
|
||||
self.waiting.insert((x, sy, z));
|
||||
}
|
||||
continue;
|
||||
@@ -1355,7 +1399,7 @@ impl Streaming {
|
||||
continue;
|
||||
};
|
||||
world.dirty_sections.take_column(x, z);
|
||||
for sy in section_indexes(sections) {
|
||||
for sy in sections.iter().map(|sy| sy as i32) {
|
||||
let first = self.meshed.insert((x, z));
|
||||
self.sequence += 1;
|
||||
self.meshing.insert((x, sy, z), self.sequence);
|
||||
@@ -1472,7 +1516,7 @@ impl Streaming {
|
||||
fn mark_light_changes(world: &mut World, changed: &LightChanges) {
|
||||
if let Some(log) = &mut world.light_log {
|
||||
for (x, sy, z) in changed.sections() {
|
||||
*log.entry((x, z)).or_default() |= 1 << sy;
|
||||
log.entry((x, z)).or_default().insert(sy as usize);
|
||||
}
|
||||
}
|
||||
changed.dirty_sections(|section| {
|
||||
@@ -1556,6 +1600,16 @@ impl Streaming {
|
||||
fn in_range(&self, x: i32, z: i32, margin: i32) -> bool {
|
||||
self.ranges().in_range(x, z, margin)
|
||||
}
|
||||
/// Rank the 3x3 a chunk loaded to FULL decorates, in one generation
|
||||
/// task's order (`ChunkGenerationTask`: x-outer, z-inner), after every
|
||||
/// chunk ranked before.
|
||||
pub fn rank_full_task(&mut self, x: i32, z: i32) {
|
||||
for dx in -1..=1 {
|
||||
for dz in -1..=1 {
|
||||
self.decoration_order.rank_or_append(x + dx, z + dz);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn band(&self) -> Option<Band> {
|
||||
self.band
|
||||
}
|
||||
@@ -1861,7 +1915,7 @@ impl Ranges<'_> {
|
||||
let (dx, dz) = (gap(x - self.center.0), gap(z - self.center.1));
|
||||
let r = i64::from(self.radius + 1);
|
||||
// The extra areas are squares: their finished square grown by `reach`.
|
||||
dx * dx + dz * dz <= r * r
|
||||
(self.radius >= 0 && dx * dx + dz * dz <= r * r)
|
||||
|| self.in_extra(x, z, 1 + reach)
|
||||
|| self.in_band(x, z, 1 + reach)
|
||||
}
|
||||
@@ -1894,7 +1948,7 @@ impl Ranges<'_> {
|
||||
fn in_main_range(&self, x: i32, z: i32, margin: i32) -> bool {
|
||||
let r = i64::from(self.radius + margin);
|
||||
let (dx, dz) = (i64::from(x - self.center.0), i64::from(z - self.center.1));
|
||||
if dx * dx + dz * dz <= r * r {
|
||||
if self.radius >= 0 && dx * dx + dz * dz <= r * r {
|
||||
return true;
|
||||
}
|
||||
// A forced chunk holds a FORCED ticket at level 31
|
||||
@@ -1931,16 +1985,6 @@ impl Drop for Streaming {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The section indexes set in a column's bits, lowest first.
|
||||
fn section_indexes(mut bits: u32) -> impl Iterator<Item = i32> {
|
||||
std::iter::from_fn(move || {
|
||||
(bits != 0).then(|| {
|
||||
let sy = bits.trailing_zeros() as i32;
|
||||
bits &= bits - 1;
|
||||
sy
|
||||
})
|
||||
})
|
||||
}
|
||||
/// The queued jobs' priorities computed again from each one's column
|
||||
/// `distance` (`Streaming::submit`).
|
||||
fn rank_again(queue: &mut BinaryHeap<Queued>, distance: impl Fn(i32, i32) -> i64) {
|
||||
@@ -2529,6 +2573,34 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// An idle dedicated server keeps no area around its centre: only a spawn
|
||||
/// search's chunk (FULL alone: its 3x3 decorated, 5x5 generated) and the
|
||||
/// forced chunks.
|
||||
#[test]
|
||||
fn no_area_around_an_idle_servers_centre() {
|
||||
let forced = HashSet::from([(30, 30)]);
|
||||
let extra = [((5, -3), -1)];
|
||||
let r = Ranges {
|
||||
center: (5, -3),
|
||||
radius: -1,
|
||||
extra: &extra,
|
||||
forced: &forced,
|
||||
cover: None,
|
||||
band: None,
|
||||
};
|
||||
for x in -5i32..=15 {
|
||||
for z in -13i32..=7 {
|
||||
let d = (x - 5).abs().max((z + 3).abs());
|
||||
assert_eq!(r.in_range(x, z, 1), d == 0, "({x}, {z})");
|
||||
assert_eq!(r.chunk_kept(x, z, true), d <= 2, "({x}, {z})");
|
||||
assert_eq!(r.requested(x, z, true), d <= 2, "({x}, {z})");
|
||||
}
|
||||
}
|
||||
assert!(r.in_range(30, 30, 1));
|
||||
let idle = Ranges { extra: &[], ..r };
|
||||
assert!(!idle.requested(5, -3, true) && idle.unloaded(5, -3, true));
|
||||
}
|
||||
|
||||
/// A server player's area (`lan_update_streaming`: the server view
|
||||
/// distance + 1) finishes vanilla's FULL square: PLAYER_LOADING tickets at
|
||||
/// level 31 out to the view distance, FULL (33) two chunks past it. Every
|
||||
|
||||
@@ -1078,6 +1078,21 @@ impl EditBox {
|
||||
self.set_cursor(pos, shift);
|
||||
}
|
||||
}
|
||||
/// `MultilineTextField.selectWordAtCursor` on a book page: the word before
|
||||
/// the cursor is selected, its end found past non-whitespace only
|
||||
/// (`getWordEndPosition`).
|
||||
pub fn select_word_at_cursor(&mut self) {
|
||||
let begin = multiline_word_position(&self.value, -1, self.cursor);
|
||||
let end = begin
|
||||
+ self
|
||||
.value
|
||||
.chars()
|
||||
.skip(begin)
|
||||
.take_while(|c| !java_whitespace(*c))
|
||||
.count();
|
||||
self.set_cursor(begin, false);
|
||||
self.set_cursor(end, true);
|
||||
}
|
||||
/// EditBox.onDrag: the selection follows the pointer.
|
||||
pub fn drag_to(&mut self, atlas: &HudAtlas, x: f32) {
|
||||
let pos = self.position_at(atlas, x);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! Resident client world: streamed chunk columns plus the bookkeeping the game
|
||||
//! loop needs to relight and remesh after edits.
|
||||
use crate::chunk::{Chunk, MIN_Y, SECTIONS};
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use crate::{
|
||||
chunk::{Chunk, ColumnLayout, LevelShape},
|
||||
fast_hash::{FxHashMap, FxHashSet},
|
||||
section_mask::SectionMask,
|
||||
};
|
||||
use horizon_core::random::LegacyRandom;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::sync::Arc;
|
||||
@@ -11,28 +14,30 @@ use std::sync::Arc;
|
||||
/// rather than at every section of it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DirtySections {
|
||||
columns: crate::fast_hash::FxHashMap<(i32, i32), u32>,
|
||||
columns: crate::fast_hash::FxHashMap<(i32, i32), SectionMask>,
|
||||
}
|
||||
const _: () = assert!(SECTIONS <= 32);
|
||||
impl DirtySections {
|
||||
pub fn insert(&mut self, (x, sy, z): (i32, i32, i32)) {
|
||||
debug_assert!((0..SECTIONS as i32).contains(&sy));
|
||||
*self.columns.entry((x, z)).or_default() |= 1 << sy;
|
||||
debug_assert!((0..SectionMask::CAPACITY as i32).contains(&sy));
|
||||
self.columns.entry((x, z)).or_default().insert(sy as usize);
|
||||
}
|
||||
pub fn contains(&self, &(x, sy, z): &(i32, i32, i32)) -> bool {
|
||||
(0..SECTIONS as i32).contains(&sy)
|
||||
&& self.columns.get(&(x, z)).is_some_and(|m| m & 1 << sy != 0)
|
||||
(0..SectionMask::CAPACITY as i32).contains(&sy)
|
||||
&& self
|
||||
.columns
|
||||
.get(&(x, z))
|
||||
.is_some_and(|m| m.contains(sy as usize))
|
||||
}
|
||||
/// The dirty section indexes of column (x, z), as bits, removed.
|
||||
pub fn take_column(&mut self, x: i32, z: i32) -> u32 {
|
||||
self.columns.remove(&(x, z)).unwrap_or(0)
|
||||
/// The dirty section indexes of column (x, z), removed.
|
||||
pub fn take_column(&mut self, x: i32, z: i32) -> SectionMask {
|
||||
self.columns.remove(&(x, z)).unwrap_or_default()
|
||||
}
|
||||
/// Keep the columns `keep` accepts.
|
||||
pub fn retain_columns(&mut self, mut keep: impl FnMut(i32, i32) -> bool) {
|
||||
self.columns.retain(|&(x, z), _| keep(x, z));
|
||||
}
|
||||
/// The columns holding dirty sections, with their section bits.
|
||||
pub fn columns(&self) -> impl Iterator<Item = ((i32, i32), u32)> + '_ {
|
||||
/// The columns holding dirty sections, with their section indexes.
|
||||
pub fn columns(&self) -> impl Iterator<Item = ((i32, i32), SectionMask)> + '_ {
|
||||
self.columns.iter().map(|(&c, &m)| (c, m))
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
@@ -43,10 +48,13 @@ impl DirtySections {
|
||||
}
|
||||
/// How many sections are dirty.
|
||||
pub fn len(&self) -> usize {
|
||||
self.columns.values().map(|m| m.count_ones() as usize).sum()
|
||||
self.columns.values().map(SectionMask::len).sum()
|
||||
}
|
||||
}
|
||||
pub struct World {
|
||||
/// The level's build range and skylight (its dimension type); its
|
||||
/// chunks hold `shape.column()`.
|
||||
pub shape: LevelShape,
|
||||
pub fast_lava: bool,
|
||||
pub chunks: crate::chunk_map::ChunkMap,
|
||||
pub random: LegacyRandom,
|
||||
@@ -88,7 +96,7 @@ pub struct World {
|
||||
/// The sections whose light changed since the log was last drained, as
|
||||
/// a bit per section of each column, kept while `block_log` is
|
||||
/// (`ChunkHolder.sectionLightChanged`).
|
||||
pub light_log: Option<crate::fast_hash::FxHashMap<(i32, i32), u32>>,
|
||||
pub light_log: Option<crate::fast_hash::FxHashMap<(i32, i32), SectionMask>>,
|
||||
/// The villager POIs of each section (`PoiManager`'s sections).
|
||||
pub pois: crate::poi_sections::PoiSections<crate::poi_sections::Poi>,
|
||||
/// Moving-piston states removed during the last tick; retained until the
|
||||
@@ -165,6 +173,7 @@ impl Weather {
|
||||
impl World {
|
||||
pub fn new(air: BlockStateId, seed: i64) -> Self {
|
||||
Self {
|
||||
shape: LevelShape::OVERWORLD,
|
||||
chunks: Default::default(),
|
||||
random: LegacyRandom::new(seed),
|
||||
revision: 0,
|
||||
@@ -249,6 +258,18 @@ impl World {
|
||||
world.revision = 0;
|
||||
Ok(world)
|
||||
}
|
||||
/// The column the level's chunks hold.
|
||||
pub fn layout(&self) -> ColumnLayout {
|
||||
self.shape.column()
|
||||
}
|
||||
/// The lowest block Y the level's chunks hold.
|
||||
pub fn bottom_y(&self) -> i32 {
|
||||
self.layout().min_y()
|
||||
}
|
||||
/// One above the highest block Y the level's chunks hold.
|
||||
pub fn top_y(&self) -> i32 {
|
||||
self.layout().max_y()
|
||||
}
|
||||
/// Resident storage write; marks the touched sections (and neighbours on
|
||||
/// section borders) for remeshing and the surrounding chunks for relighting.
|
||||
/// Neighbour updates, drops and block entities belong to the gameplay layer.
|
||||
@@ -256,7 +277,8 @@ impl World {
|
||||
let Some(chunk) = self.chunks.get_mut(&(x >> 4, z >> 4)) else {
|
||||
return Err("write outside resident chunks".into());
|
||||
};
|
||||
if y < MIN_Y || y >= MIN_Y + (SECTIONS as i32) * 16 {
|
||||
let (min_y, sections) = (chunk.min_y(), chunk.sections.len() as i32);
|
||||
if chunk.section_index(y).is_none() {
|
||||
return Err("write outside world height".into());
|
||||
}
|
||||
let previous = Arc::make_mut(chunk).set(x & 15, y, z & 15, state.0);
|
||||
@@ -276,14 +298,14 @@ impl World {
|
||||
// the sections it can reach: this section, the one above/below
|
||||
// only on a section face, and a neighbour column only on a chunk
|
||||
// border (the diagonal only where both borders meet).
|
||||
let sy = (y - MIN_Y) >> 4;
|
||||
let sy = (y - min_y) >> 4;
|
||||
let local_y = y & 15;
|
||||
let mark = |dirty: &mut DirtySections, bx: i32, bz: i32| {
|
||||
dirty.insert((bx >> 4, sy, bz >> 4));
|
||||
if local_y == 0 && sy > 0 {
|
||||
dirty.insert((bx >> 4, sy - 1, bz >> 4));
|
||||
}
|
||||
if local_y == 15 && sy + 1 < SECTIONS as i32 {
|
||||
if local_y == 15 && sy + 1 < sections {
|
||||
dirty.insert((bx >> 4, sy + 1, bz >> 4));
|
||||
}
|
||||
};
|
||||
@@ -319,17 +341,20 @@ impl World {
|
||||
Ok(())
|
||||
}
|
||||
/// `getHeight(Heightmap.Types.WORLD_SURFACE, x, z)`: one above the highest
|
||||
/// block that isn't `air`, or `MIN_Y` (also where the chunk isn't loaded).
|
||||
/// block that isn't `air`, or the column's minimum Y (also where the chunk
|
||||
/// isn't loaded).
|
||||
pub fn world_surface(&self, x: i32, z: i32, air: impl Fn(BlockStateId) -> bool) -> i32 {
|
||||
self.chunks.get(&(x >> 4, z >> 4)).map_or(MIN_Y, |chunk| {
|
||||
let bottom = self.layout().min_y();
|
||||
self.chunks.get(&(x >> 4, z >> 4)).map_or(bottom, |chunk| {
|
||||
chunk.world_surface(x, z, |v| air(BlockStateId(v)))
|
||||
})
|
||||
}
|
||||
/// `getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)`: one above the
|
||||
/// highest block where `blocks` (motion blocking or holding a fluid),
|
||||
/// or `MIN_Y` (also where the chunk isn't loaded).
|
||||
/// or the column's minimum Y (also where the chunk isn't loaded).
|
||||
pub fn motion_blocking(&self, x: i32, z: i32, blocks: impl Fn(BlockStateId) -> bool) -> i32 {
|
||||
self.chunks.get(&(x >> 4, z >> 4)).map_or(MIN_Y, |chunk| {
|
||||
let bottom = self.layout().min_y();
|
||||
self.chunks.get(&(x >> 4, z >> 4)).map_or(bottom, |chunk| {
|
||||
chunk.motion_blocking(x & 15, z & 15, |v| blocks(BlockStateId(v)))
|
||||
})
|
||||
}
|
||||
@@ -348,12 +373,13 @@ impl World {
|
||||
hi: [i32; 3],
|
||||
mut matches: impl FnMut(BlockStateId) -> bool,
|
||||
) -> bool {
|
||||
let top = MIN_Y + SECTIONS as i32 * 16 - 1;
|
||||
let layout = self.layout();
|
||||
let (bottom, top) = (layout.min_y(), layout.max_y() - 1);
|
||||
// Heights outside the sections read as state 0.
|
||||
if (lo[1] < MIN_Y || hi[1] > top) && matches(BlockStateId(0)) {
|
||||
if (lo[1] < bottom || hi[1] > top) && matches(BlockStateId(0)) {
|
||||
return true;
|
||||
}
|
||||
let sections = (lo[1].max(MIN_Y) - MIN_Y) >> 4..=(hi[1].min(top) - MIN_Y) >> 4;
|
||||
let sections = (lo[1].max(bottom) - bottom) >> 4..=(hi[1].min(top) - bottom) >> 4;
|
||||
for cx in lo[0] >> 4..=hi[0] >> 4 {
|
||||
for cz in lo[2] >> 4..=hi[2] >> 4 {
|
||||
let Some(chunk) = self.chunks.get(&(cx, cz)) else {
|
||||
@@ -465,8 +491,8 @@ mod tests {
|
||||
}
|
||||
let mut got: Vec<_> = world.dirty_sections.columns().collect();
|
||||
let mut want: Vec<_> = expected.columns().collect();
|
||||
got.sort_unstable();
|
||||
want.sort_unstable();
|
||||
got.sort_unstable_by_key(|&(column, _)| column);
|
||||
want.sort_unstable_by_key(|&(column, _)| column);
|
||||
assert_eq!(got, want, "write at {x} {y} {z}");
|
||||
if (x & 15, z & 15) == (3, 3) {
|
||||
assert_eq!(world.dirty_sections.len(), 1);
|
||||
|
||||
@@ -77,19 +77,31 @@ impl Zip {
|
||||
// A clone shares the bytes and the central directory: reading needs `&mut`.
|
||||
let mut archive = self.archive.clone();
|
||||
let mut entry = archive.by_index_raw(index).map_err(invalid)?;
|
||||
let (method, size) = (entry.compression(), entry.size() as usize);
|
||||
let method = entry.compression();
|
||||
let mut raw = Vec::with_capacity((entry.compressed_size() as usize).min(self.len));
|
||||
entry.read_to_end(&mut raw)?;
|
||||
match method {
|
||||
CompressionMethod::Stored => Ok(raw),
|
||||
CompressionMethod::Deflated => miniz_oxide::inflate::decompress_to_vec_with_limit(
|
||||
&raw,
|
||||
size.max(1)
|
||||
.saturating_mul(2)
|
||||
.saturating_add(1024)
|
||||
.min(MAX_ENTRY),
|
||||
)
|
||||
.map_err(|_| invalid("corrupt deflate stream")),
|
||||
CompressionMethod::Deflated => {
|
||||
// Java's ZipFile trusts neither size an entry declares: it
|
||||
// inflates the stream to its end and keeps what comes out.
|
||||
// Packs in the wild (a Nexo-generated one served through
|
||||
// Dropbox, for one) carry central-directory sizes that point
|
||||
// at other files, and miniz_oxide, which refuses input past
|
||||
// the stream's end, failed whole entries with "corrupt
|
||||
// deflate stream" and with them the whole pack. Only the
|
||||
// size limit stands between the archive and a zip bomb.
|
||||
let decoder = flate2::read::DeflateDecoder::new(raw.as_slice());
|
||||
let mut decoded = Vec::with_capacity(raw.len().min(MAX_ENTRY));
|
||||
decoder
|
||||
.take(MAX_ENTRY as u64 + 1)
|
||||
.read_to_end(&mut decoded)
|
||||
.map_err(|_| invalid("corrupt deflate stream"))?;
|
||||
if decoded.len() > MAX_ENTRY {
|
||||
return Err(invalid("entry inflates past the size limit"));
|
||||
}
|
||||
Ok(decoded)
|
||||
}
|
||||
_ => Err(invalid("unsupported zip compression")),
|
||||
}
|
||||
}
|
||||
@@ -214,6 +226,70 @@ mod tests {
|
||||
assert!(zip.read("missing").is_err());
|
||||
}
|
||||
|
||||
/// The offset of the central-directory header of `name`.
|
||||
fn central_header(bytes: &[u8], name: &str) -> usize {
|
||||
let mut at = 0;
|
||||
while let Some(i) = bytes[at..]
|
||||
.windows(4)
|
||||
.position(|s| s == [0x50, 0x4b, 0x01, 0x02])
|
||||
{
|
||||
let p = at + i;
|
||||
let nlen = u16::from_le_bytes(bytes[p + 28..p + 30].try_into().unwrap()) as usize;
|
||||
if &bytes[p + 46..p + 46 + nlen] == name.as_bytes() {
|
||||
return p;
|
||||
}
|
||||
at = p + 4;
|
||||
}
|
||||
panic!("no central header for {name}");
|
||||
}
|
||||
|
||||
/// Java's `ZipFile` inflates the stream and keeps what comes out, so a
|
||||
/// central directory that names a size other than the stream's (the
|
||||
/// Nexo pack Dropbox serves for one server does) changes nothing.
|
||||
#[test]
|
||||
fn reads_a_deflate_stream_larger_than_its_declared_size() {
|
||||
use std::io::Write;
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
writer
|
||||
.start_file(
|
||||
"large.txt",
|
||||
SimpleFileOptions::default().compression_method(CompressionMethod::Deflated),
|
||||
)
|
||||
.unwrap();
|
||||
writer.write_all(&vec![b'x'; 4096]).unwrap();
|
||||
let mut bytes = writer.finish().unwrap().into_inner();
|
||||
let central = central_header(&bytes, "large.txt");
|
||||
bytes[central + 24..central + 28].copy_from_slice(&1u32.to_le_bytes());
|
||||
|
||||
let zip = Zip::from_bytes(bytes).unwrap();
|
||||
assert_eq!(zip.read("large.txt").unwrap(), vec![b'x'; 4096]);
|
||||
}
|
||||
|
||||
/// Some pack writers leave an entry's compressed size longer than its
|
||||
/// deflate stream, so the bytes past the stream's end are part of it.
|
||||
/// Java inflates to the stream's end and ignores them; miniz_oxide
|
||||
/// refused the input and failed the entry, and with it the whole pack.
|
||||
#[test]
|
||||
fn reads_past_trailing_bytes_after_the_deflate_stream() {
|
||||
let mut bytes = written();
|
||||
let name = "Pack-main/assets/minecraft/lang/en_us.json";
|
||||
let central = central_header(&bytes, name);
|
||||
let local =
|
||||
u32::from_le_bytes(bytes[central + 42..central + 46].try_into().unwrap()) as usize;
|
||||
for at in [central + 20, local + 18] {
|
||||
let csize = u32::from_le_bytes(bytes[at..at + 4].try_into().unwrap());
|
||||
bytes[at..at + 4].copy_from_slice(&(csize + 8).to_le_bytes());
|
||||
}
|
||||
|
||||
let zip = Zip::from_bytes(bytes).unwrap();
|
||||
assert_eq!(
|
||||
zip.read(name).unwrap(),
|
||||
"{\"a\":\"b\"}".repeat(100).as_bytes()
|
||||
);
|
||||
}
|
||||
|
||||
/// Loka's server pack (#3413) names its metadata `pack.mcmeta/`.
|
||||
/// `ZipFile.getEntry("pack.mcmeta")` falls back to that folder entry and
|
||||
/// vanilla reads its bytes; a file of the exact name still comes first.
|
||||
|
||||
@@ -18,6 +18,7 @@ use horizon_client::{
|
||||
};
|
||||
use horizon_core::{geometry::Axis, random::LegacyRandom};
|
||||
use horizon_world::chunk::BlockState;
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::{collections::BTreeMap, fs, path::Path};
|
||||
|
||||
@@ -77,9 +78,9 @@ fn main() -> Result<()> {
|
||||
let assets = Assets::load(&root).map_err(step("assets"))?;
|
||||
let registry = assets.registry;
|
||||
let facts = BlockFacts::load(&root, registry).map_err(step("block facts"))?;
|
||||
let items = Items::load(&root, registry, &facts).map_err(step("items"))?;
|
||||
let recipes = Recipes::load(&root, &items).map_err(step("recipes"))?;
|
||||
let stations = StationRecipes::load(&root, &items);
|
||||
let items = Items::load(&root, registry, &facts, &PackOverlay::NONE).map_err(step("items"))?;
|
||||
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE).map_err(step("recipes"))?;
|
||||
let stations = StationRecipes::load(&root, &items, &PackOverlay::NONE);
|
||||
let collisions =
|
||||
horizon_client::collisions::load(&root, registry).map_err(step("collisions"))?;
|
||||
let loaded = began.elapsed().as_secs_f64() - built;
|
||||
|
||||
@@ -7,6 +7,7 @@ use horizon_client::{
|
||||
chunk::{Chunk, MIN_Y, SECTIONS, Section},
|
||||
fast_hash::FxHashMap,
|
||||
portal_sections::PortalSections,
|
||||
section_mask::SectionMask,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
@@ -83,17 +84,14 @@ fn may_hold_portal_cached(
|
||||
if count > 64 {
|
||||
return true;
|
||||
}
|
||||
let mut y_mask = 0u32;
|
||||
for sy in y0..=y1 {
|
||||
y_mask |= 1 << sy;
|
||||
}
|
||||
let y_mask = SectionMask::below(y1 as usize + 1).from(y0 as usize);
|
||||
(x0..=x1).any(|cx| {
|
||||
(z0..=z1).any(|cz| {
|
||||
world.get(&(cx, cz)).is_some_and(|chunk| {
|
||||
cache.get((cx, cz), chunk, |v| {
|
||||
let portals = cache.get((cx, cz), chunk, |v| {
|
||||
behaviours.portal_axis(BlockStateId(v)).is_some()
|
||||
}) & y_mask
|
||||
!= 0
|
||||
});
|
||||
!(portals & y_mask).is_empty()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ use horizon_client::{
|
||||
items::Items,
|
||||
};
|
||||
use horizon_core::random::LegacyRandom;
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
@@ -127,7 +128,7 @@ pub fn main() -> Result<()> {
|
||||
let registry = assets.registry;
|
||||
let facts = BlockFacts::load(root, registry)?;
|
||||
let renderer = BlockEntityRenderer::load(root, registry)?;
|
||||
let tags = Items::tags_only(root)?;
|
||||
let tags = Items::tags_only(root, &PackOverlay::NONE)?;
|
||||
let frames = tags
|
||||
.block_tags
|
||||
.get("minecraft:conduit_effect_block")
|
||||
|
||||
@@ -9,6 +9,7 @@ use horizon_client::{
|
||||
items::Items,
|
||||
};
|
||||
use horizon_world::chunk::BlockState;
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use std::{fs, path::Path};
|
||||
|
||||
fn parse(text: &str) -> BlockState {
|
||||
@@ -58,7 +59,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
.ok_or("usage: block_transform_fixture RESOURCES INPUT OUTPUT")?,
|
||||
);
|
||||
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
|
||||
let items = Items::catalog(root)?;
|
||||
let items = Items::catalog(root, &PackOverlay::NONE)?;
|
||||
let assets = Assets::load(root)?;
|
||||
let transformers = Transformers::load(root, assets.registry)?;
|
||||
let mut out = String::new();
|
||||
|
||||
@@ -10,6 +10,7 @@ use horizon_client::{
|
||||
menu::{Env, Kind, Menu},
|
||||
};
|
||||
use horizon_world::chunk::BlockState;
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use serde_json::Value;
|
||||
use std::{fs, path::Path};
|
||||
|
||||
@@ -77,9 +78,9 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
.ok_or("usage: cauldron_fixture RESOURCES INPUT OUTPUT")?,
|
||||
);
|
||||
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
|
||||
let items = Items::catalog(root)?;
|
||||
let recipes = Recipes::load(root, &items)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items);
|
||||
let items = Items::catalog(root, &PackOverlay::NONE)?;
|
||||
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items, &PackOverlay::NONE);
|
||||
// The inventory menu never reads banner patterns.
|
||||
let banners = horizon_client::banner::Banners::default();
|
||||
let station_recipes = horizon_client::stations::StationRecipes::default();
|
||||
|
||||
@@ -3,6 +3,7 @@ use horizon_client::{
|
||||
item_data::StackData,
|
||||
items::{ItemStack, Items},
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
@@ -14,7 +15,7 @@ use std::{
|
||||
pub fn main() -> Result<()> {
|
||||
let args: Vec<_> = crate::args().collect();
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
|
||||
let items = Items::catalog(&root)?;
|
||||
let items = Items::catalog(&root, &PackOverlay::NONE)?;
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for line in BufReader::new(File::open(&args[1])?).lines() {
|
||||
let row: Value = serde_json::from_str(&line?)?;
|
||||
|
||||
@@ -8,6 +8,7 @@ use horizon_client::{
|
||||
items::{ItemStack, Items},
|
||||
stations,
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use std::{fmt::Write as _, fs, path::Path};
|
||||
|
||||
pub fn main() -> horizon_client::Result<()> {
|
||||
@@ -17,7 +18,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
.ok_or("usage: enchanting_fixture RESOURCES INPUT OUTPUT")?,
|
||||
);
|
||||
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
|
||||
let items = Items::tags_only(root)?;
|
||||
let items = Items::tags_only(root, &PackOverlay::NONE)?;
|
||||
let e = Enchantments::load(root);
|
||||
let mut out = String::new();
|
||||
for line in input.lines() {
|
||||
|
||||
@@ -593,6 +593,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
num("maxCrossbowChageDuration"),
|
||||
num("ticksUsingItem"),
|
||||
false,
|
||||
flag("isRiding"),
|
||||
);
|
||||
}
|
||||
Species::Evoker | Species::Illusioner | Species::Pillager | Species::Vindicator => {
|
||||
|
||||
@@ -8,7 +8,7 @@ use horizon_client::{
|
||||
random_sequences::RandomSequences,
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, random::LegacyRandom};
|
||||
use horizon_world::loot::{LootDamage, LootData, LootEntity, LootStack};
|
||||
use horizon_world::loot::{LootDamage, LootData, LootEntity, LootStack, PackOverlay};
|
||||
use std::{collections::BTreeMap, fmt::Write as _, fs, path::Path, sync::Arc};
|
||||
|
||||
/// `id[;enchantment:level...]`.
|
||||
@@ -64,7 +64,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
);
|
||||
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
|
||||
let types = MobTypes::load(root)?;
|
||||
let loot = LootData::load(root)?;
|
||||
let loot = LootData::load(root, &PackOverlay::NONE)?;
|
||||
let mut out = String::new();
|
||||
for raw in input.lines() {
|
||||
if raw.trim().is_empty() || raw.starts_with('#') {
|
||||
|
||||
@@ -23,6 +23,7 @@ use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use std::{collections::HashMap, fmt::Write as _, fs, path::Path, sync::Arc};
|
||||
|
||||
struct Level<'a> {
|
||||
@@ -142,8 +143,8 @@ const GENES: [&str; 7] = [
|
||||
"aggressive",
|
||||
];
|
||||
const MEMORIES: [&str; 7] = [
|
||||
"long_jump_cooldown",
|
||||
"ram_cooldown",
|
||||
"long_jump_cooling_down",
|
||||
"ram_cooldown_ticks",
|
||||
"attack_target_cooldown",
|
||||
"hunted_recently",
|
||||
"dig_cooldown",
|
||||
@@ -399,7 +400,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let assets = Assets::load(root)?;
|
||||
let facts = BlockFacts::load(root, assets.registry)?;
|
||||
let mob_data = MobData::load(root, &facts);
|
||||
let items = Items::tags_only(root)?;
|
||||
let items = Items::tags_only(root, &PackOverlay::NONE)?;
|
||||
let enchantments = Enchantments::load(root);
|
||||
let types = MobTypes::load(root)?;
|
||||
let registries = SpawnRegistries::load(root);
|
||||
@@ -506,6 +507,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
difficulty,
|
||||
data: &data,
|
||||
created: Vec::new(),
|
||||
jockey_rider: None,
|
||||
};
|
||||
group = finalize::finalize(&mut cx, &mut mob, reason, group);
|
||||
let created = std::mem::take(&mut cx.created);
|
||||
|
||||
@@ -10,6 +10,7 @@ use horizon_client::{
|
||||
items::Items,
|
||||
recipe_book::{Book, BookType, GhostTarget, RecipeIndex},
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use serde_json::Value;
|
||||
use std::{fmt::Write as _, fs, path::Path};
|
||||
|
||||
@@ -18,9 +19,9 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let usage = "usage: ghost_recipe_fixture RESOURCES CASES OUTPUT";
|
||||
let root = Path::new(args.get(1).ok_or(usage)?);
|
||||
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
|
||||
let items = Items::catalog(root)?;
|
||||
let recipes = Recipes::load(root, &items)?;
|
||||
let index = RecipeIndex::load(root);
|
||||
let items = Items::catalog(root, &PackOverlay::NONE)?;
|
||||
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
|
||||
let index = RecipeIndex::load(root, &PackOverlay::NONE);
|
||||
let mut book = Book::build(&recipes, &items);
|
||||
book.set_display_ids(&index);
|
||||
let mut out = String::new();
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//!
|
||||
//! usage: hotbar_fixture RESOURCES CASES OUTPUT
|
||||
use horizon_client::{hotbar, item_data::canon_stack, items::Items};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use horizon_world::nbt::{Tag, parse_snbt};
|
||||
use serde_json::Value;
|
||||
use std::path::Path;
|
||||
@@ -13,7 +14,10 @@ use std::{fmt::Write as _, fs};
|
||||
pub fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let usage = "usage: hotbar_fixture RESOURCES CASES OUTPUT";
|
||||
let items = Items::catalog(Path::new(args.first().and(args.get(1)).ok_or(usage)?))?;
|
||||
let items = Items::catalog(
|
||||
Path::new(args.first().and(args.get(1)).ok_or(usage)?),
|
||||
&PackOverlay::NONE,
|
||||
)?;
|
||||
let known = |id: &str| items.get(id).is_some();
|
||||
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
|
||||
let mut out = String::new();
|
||||
|
||||
@@ -15,6 +15,9 @@ struct World {
|
||||
passing: bool,
|
||||
}
|
||||
impl InteractionWorld for World {
|
||||
// The fixture's targets don't change within a tick.
|
||||
fn pick_hit_result(&mut self) {}
|
||||
fn release_hit_result(&mut self) {}
|
||||
fn using_item(&self) -> bool {
|
||||
self.using
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use horizon_client::{
|
||||
menu::{Env, Input, Kind, Menu},
|
||||
};
|
||||
use horizon_protocol::registry::Ids;
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
cell::{Cell, RefCell},
|
||||
@@ -83,11 +84,12 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
.ok_or("usage: menu_fixture RESOURCES INPUT OUTPUT")?,
|
||||
);
|
||||
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
|
||||
let items = Items::catalog(root)?;
|
||||
let recipes = Recipes::load(root, &items)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items);
|
||||
let items = Items::catalog(root, &PackOverlay::NONE)?;
|
||||
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items, &PackOverlay::NONE);
|
||||
let banners = horizon_client::banner::Banners::load(root);
|
||||
let station_recipes = horizon_client::stations::StationRecipes::load(root, &items);
|
||||
let station_recipes =
|
||||
horizon_client::stations::StationRecipes::load(root, &items, &PackOverlay::NONE);
|
||||
let enchantments = horizon_client::enchant::Enchantments::load(root);
|
||||
let names = horizon_client::creative::Names::load(root);
|
||||
let case_maps = CaseMaps::default();
|
||||
|
||||
@@ -27,6 +27,7 @@ use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
random::LegacyRandom,
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use horizon_world::{chunk::BlockState, registry::BlockRegistry, registry::BlockStateId};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
@@ -196,7 +197,11 @@ fn who(w: Option<Who>) -> String {
|
||||
fn goal_name(m: &Mob, name: &str) -> String {
|
||||
let vanilla = match name {
|
||||
"Float" => "FloatGoal",
|
||||
"RandomFloatAround" => "RandomFloatAroundGoal",
|
||||
"GhastLook" => "GhastLookGoal",
|
||||
"GhastShoot" => "GhastShootFireballGoal",
|
||||
"Stroll" => "WaterAvoidingRandomStrollGoal",
|
||||
"ParrotWander" => "ParrotWanderGoal",
|
||||
"RandomStroll" => "RandomStrollGoal",
|
||||
// AbstractFish's private subclass of RandomSwimmingGoal.
|
||||
"RandomSwimming"
|
||||
@@ -223,6 +228,8 @@ fn goal_name(m: &Mob, name: &str) -> String {
|
||||
"MoveThroughVillage" => "MoveThroughVillageGoal",
|
||||
"RestrictSun" => "RestrictSunGoal",
|
||||
"FleeSun" => "FleeSunGoal",
|
||||
// `Cat.CatAvoidEntityGoal`, `Fox.FoxAvoidEntityGoal`... name their subclass.
|
||||
"AvoidEntity" if m.species() == Species::Cat => "CatAvoidEntityGoal",
|
||||
"AvoidEntity" => "AvoidEntityGoal",
|
||||
"BowAttack" => "RangedBowAttackGoal",
|
||||
"Swell" => "SwellGoal",
|
||||
@@ -873,7 +880,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let biomes = Biomes::load(root)?;
|
||||
let items = Items::load(root, registry, &facts)?;
|
||||
let items = Items::load(root, registry, &facts, &PackOverlay::NONE)?;
|
||||
let tables = LightTables::load(root, registry, &facts)?;
|
||||
let collision = collisions::load(root, registry)?;
|
||||
let air = registry.default_state("minecraft:air").ok_or("no air")?;
|
||||
|
||||
@@ -53,7 +53,8 @@ use horizon_core::{
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use horizon_world::{
|
||||
block_collision::BlockCollisionRegistry, chunk::BlockState, nbt::Tag, registry::BlockStateId,
|
||||
block_collision::BlockCollisionRegistry, chunk::BlockState, loot::PackOverlay, nbt::Tag,
|
||||
registry::BlockStateId,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
@@ -522,11 +523,11 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let registry = assets.registry;
|
||||
let facts = BlockFacts::load(root, registry)?;
|
||||
let data = MobData::load(root, &facts);
|
||||
let items = Items::tags_only(root)?;
|
||||
let items = Items::tags_only(root, &PackOverlay::NONE)?;
|
||||
// Trial spawner rewards: the loot tables and the configs.
|
||||
let loot = if input.contains("mode=trial") || input.contains("mode=vault") {
|
||||
horizon_client::mobs::trial_spawner::load_registry(root);
|
||||
horizon_client::structure_blocks::loot_data(root)
|
||||
horizon_client::structure_blocks::loot_data(root, &PackOverlay::NONE)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -795,7 +796,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
chunk,
|
||||
&mut gen_random,
|
||||
&mut rules,
|
||||
&mut spawn,
|
||||
&mut |l, r, _| spawn(l, r).is_some(),
|
||||
&mut |e| match e {
|
||||
GenEvent::Column(x, z) => log.push(format!("col {x} {z}")),
|
||||
GenEvent::Spawn(t, p, yaw) => spawns.push(format!(
|
||||
|
||||
@@ -76,6 +76,14 @@ fn options(case: &Value) -> Option<ParticleOptions> {
|
||||
..ParticleOptions::simple(ty)
|
||||
},
|
||||
ParticleType::SculkCharge => ParticleOptions::sculk_charge(scale),
|
||||
ParticleType::Geyser | ParticleType::GeyserPlume => {
|
||||
ParticleOptions::geyser(ty, o["water_blocks"].as_i64()? as i32)
|
||||
}
|
||||
ParticleType::GeyserBase | ParticleType::GeyserPoof => ParticleOptions::geyser_base(
|
||||
ty,
|
||||
o["water_blocks"].as_i64()? as i32,
|
||||
o["burst_impulse_base"].as_f64()? as f32,
|
||||
),
|
||||
ParticleType::Shriek => ParticleOptions::shriek(scale as i32),
|
||||
ParticleType::Trail => {
|
||||
let t = &o["target"];
|
||||
|
||||
@@ -10,6 +10,7 @@ use horizon_client::{
|
||||
items::Items,
|
||||
menu::{Env, Kind, Menu, place::PostPlace},
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use serde_json::Value;
|
||||
use std::{fs, path::Path};
|
||||
|
||||
@@ -34,9 +35,9 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let root = Path::new(args.get(1).ok_or(usage)?);
|
||||
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
|
||||
let orders = fs::read_to_string(args.get(3).ok_or(usage)?)?;
|
||||
let items = Items::catalog(root)?;
|
||||
let recipes = Recipes::load(root, &items)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items);
|
||||
let items = Items::catalog(root, &PackOverlay::NONE)?;
|
||||
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items, &PackOverlay::NONE);
|
||||
let banners = horizon_client::banner::Banners::default();
|
||||
let station_recipes = horizon_client::stations::StationRecipes::default();
|
||||
let enchantments = horizon_client::enchant::Enchantments::default();
|
||||
|
||||
@@ -19,6 +19,7 @@ use horizon_client::{
|
||||
raid::{self, Action, BarName, RaidLevel, RaidPlayer, RaiderInfo, Raids},
|
||||
};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use horizon_world::{chunk::BlockState, nbt::Tag, registry::BlockStateId};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
@@ -470,7 +471,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let registry = assets.registry;
|
||||
let facts = BlockFacts::load(root, registry)?;
|
||||
let data = MobData::load(root, &facts);
|
||||
let items = Items::tags_only(root)?;
|
||||
let items = Items::tags_only(root, &PackOverlay::NONE)?;
|
||||
let attributes = GameplayAttributes::load(root);
|
||||
let shared = Shared {
|
||||
facts: &facts,
|
||||
|
||||
@@ -9,6 +9,7 @@ use horizon_protocol::{
|
||||
buf::{Codec, Reader, Writer},
|
||||
packets::recipes::RecipeBookChangeSettings,
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::Value;
|
||||
use std::{collections::BTreeSet, fs, path::Path};
|
||||
@@ -66,7 +67,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let usage = "usage: recipe_book_fixture RESOURCES CASES OUTPUT";
|
||||
let root = Path::new(args.get(1).ok_or(usage)?);
|
||||
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
|
||||
let index = RecipeIndex::load(root);
|
||||
let index = RecipeIndex::load(root, &PackOverlay::NONE);
|
||||
let mut out = String::new();
|
||||
for line in cases.lines() {
|
||||
let case: Value = serde_json::from_str(line)?;
|
||||
|
||||
@@ -16,6 +16,7 @@ use horizon_client::{
|
||||
TabView, draw, overlay_cells, overlay_origin,
|
||||
},
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use serde_json::{Value, json};
|
||||
use std::{fmt::Write as _, fs, path::Path};
|
||||
|
||||
@@ -35,8 +36,8 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let usage = "usage: recipe_book_render_fixture RESOURCES CASES OUTPUT";
|
||||
let root = Path::new(args.get(1).ok_or(usage)?);
|
||||
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
|
||||
let items = Items::catalog(root)?;
|
||||
let recipes = Recipes::load(root, &items)?;
|
||||
let items = Items::catalog(root, &PackOverlay::NONE)?;
|
||||
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
|
||||
let book = Book::build(&recipes, &items);
|
||||
let atlas = HudAtlas::build(root)?;
|
||||
let root_buf = root.to_path_buf();
|
||||
|
||||
@@ -12,6 +12,7 @@ use horizon_client::{
|
||||
menu::place::Stacked,
|
||||
recipe_book::{Book, BookType, ClientBook, Display, RecipeIndex, Tab},
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use serde_json::Value;
|
||||
use std::{fmt::Write as _, fs, path::Path};
|
||||
|
||||
@@ -20,9 +21,9 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let usage = "usage: recipe_book_view_fixture RESOURCES CASES OUTPUT";
|
||||
let root = Path::new(args.get(1).ok_or(usage)?);
|
||||
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
|
||||
let items = Items::catalog(root)?;
|
||||
let recipes = Recipes::load(root, &items)?;
|
||||
let index = RecipeIndex::load(root);
|
||||
let items = Items::catalog(root, &PackOverlay::NONE)?;
|
||||
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
|
||||
let index = RecipeIndex::load(root, &PackOverlay::NONE);
|
||||
let mut book = Book::build(&recipes, &items);
|
||||
book.set_display_ids(&index);
|
||||
let max = |s: &horizon_client::items::ItemStack| items.get(&s.item).map_or(64, |i| i.max_stack);
|
||||
|
||||
@@ -10,6 +10,7 @@ use horizon_client::{
|
||||
items::Items,
|
||||
stations::StationRecipes,
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use std::{fs, path::Path};
|
||||
|
||||
pub fn main() -> horizon_client::Result<()> {
|
||||
@@ -19,9 +20,9 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
.ok_or("usage: recipe_fixture RESOURCES INPUT OUTPUT")?,
|
||||
);
|
||||
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
|
||||
let items = Items::catalog(root)?;
|
||||
let recipes = Recipes::load(root, &items)?;
|
||||
let stations = StationRecipes::load(root, &items);
|
||||
let items = Items::catalog(root, &PackOverlay::NONE)?;
|
||||
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
|
||||
let stations = StationRecipes::load(root, &items, &PackOverlay::NONE);
|
||||
let enchantments = Enchantments::load(root);
|
||||
let mut out = String::new();
|
||||
for line in input.lines() {
|
||||
|
||||
@@ -14,6 +14,7 @@ use horizon_client::{
|
||||
},
|
||||
};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use horizon_world::{chunk::BlockState, registry::BlockStateId};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap, HashSet},
|
||||
@@ -147,7 +148,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let registry = assets.registry;
|
||||
let facts = BlockFacts::load(root, registry)?;
|
||||
let data = MobData::load(root, &facts);
|
||||
let items = Items::tags_only(root)?;
|
||||
let items = Items::tags_only(root, &PackOverlay::NONE)?;
|
||||
let types = MobTypes::load(root)?;
|
||||
let mut out = String::new();
|
||||
for raw in input.lines() {
|
||||
|
||||
@@ -36,7 +36,7 @@ pub fn main() -> Result<()> {
|
||||
aquatic::squid_cycle_event(&mut mob);
|
||||
}
|
||||
let velocity = mob.velocity;
|
||||
aquatic::squid_client_step(&mut mob);
|
||||
aquatic::squid_client_step(&mut mob, &mut Vec::new());
|
||||
assert_eq!(
|
||||
velocity, mob.velocity,
|
||||
"a remote animation must preserve packet velocity"
|
||||
|
||||
@@ -13,6 +13,7 @@ use horizon_client::{
|
||||
random_sequences::RandomSequences,
|
||||
};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::{collections::HashMap, fmt::Write as _, fs, path::Path};
|
||||
|
||||
@@ -237,12 +238,12 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let root = Path::new(args.get(1).ok_or(usage)?);
|
||||
let input = fs::read_to_string(args.get(2).ok_or(usage)?)?;
|
||||
let vanilla = fs::read_to_string(args.get(3).ok_or(usage)?)?;
|
||||
let items = Items::catalog(root)?;
|
||||
let items = Items::catalog(root, &PackOverlay::NONE)?;
|
||||
let enchantments = Enchantments::load(root);
|
||||
let names = horizon_client::creative::Names::default();
|
||||
let trades = Trades::load(root);
|
||||
let recipes = horizon_client::crafting::Recipes::load(root, &items)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items);
|
||||
let recipes = horizon_client::crafting::Recipes::load(root, &items, &PackOverlay::NONE)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items, &PackOverlay::NONE);
|
||||
let banners = horizon_client::banner::Banners::load(root);
|
||||
let station_recipes = horizon_client::stations::StationRecipes::default();
|
||||
let types: HashMap<_, _> = horizon_client::mobs::MobTypes::load(root)?
|
||||
|
||||
@@ -14,6 +14,7 @@ use horizon_client::{
|
||||
light::LightTables, mesher::MeshTables, models, motion::MotionResources,
|
||||
vanillagen::VanillaTerrain,
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use std::{
|
||||
alloc::{GlobalAlloc, Layout, System},
|
||||
sync::atomic::{AtomicUsize, Ordering::Relaxed},
|
||||
@@ -105,9 +106,9 @@ fn main() -> Result<()> {
|
||||
// The game decodes gameplay facts once for both tables (BlockFacts::load_pair).
|
||||
let (facts, behaviours) = BlockFacts::load_pair(&root, assets.registry)?;
|
||||
step("block facts+behaviours");
|
||||
let items = Items::load(&root, assets.registry, &facts)?;
|
||||
let items = Items::load(&root, assets.registry, &facts, &PackOverlay::NONE)?;
|
||||
step("items");
|
||||
let recipes = Recipes::load(&root, &items)?;
|
||||
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE)?;
|
||||
step("recipes");
|
||||
let biomes = Biomes::load(&root)?;
|
||||
step("biomes");
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet, HashMap},
|
||||
path::Path,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex, Weak},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -66,8 +67,36 @@ fn frame(v: &Value) -> Frame {
|
||||
}
|
||||
}
|
||||
impl Tree {
|
||||
/// [`Tree::load`], shared while anyone holds it: a server's advancement
|
||||
/// packets (`ServerAdvancements`) and its game's criteria read the same
|
||||
/// files, and each kept its own copy (7 MB). A world whose data packs bring
|
||||
/// advancements gets its own tree ([`Tree::load_with`]).
|
||||
pub fn shared(data: &Path, overlay: &horizon_world::loot::PackOverlay) -> Arc<Tree> {
|
||||
if overlay.kind("advancement").next().is_some() {
|
||||
return Arc::new(Tree::load_with(data, overlay));
|
||||
}
|
||||
static TREES: Mutex<Vec<(PathBuf, Weak<Tree>)>> = Mutex::new(Vec::new());
|
||||
// Held while loading: a second reader waits for the first one's tree.
|
||||
let mut trees = TREES.lock().unwrap_or_else(|e| e.into_inner());
|
||||
trees.retain(|(_, tree)| tree.strong_count() > 0);
|
||||
if let Some(tree) = trees
|
||||
.iter()
|
||||
.find(|(path, _)| path == data)
|
||||
.and_then(|(_, tree)| tree.upgrade())
|
||||
{
|
||||
return tree;
|
||||
}
|
||||
let tree = Arc::new(Tree::load(data));
|
||||
trees.push((data.to_path_buf(), Arc::downgrade(&tree)));
|
||||
tree
|
||||
}
|
||||
/// Loads `<data>/<namespace>/advancement/**/*.json`.
|
||||
pub fn load(data: &Path) -> Tree {
|
||||
Self::load_with(data, &horizon_world::loot::PackOverlay::NONE)
|
||||
}
|
||||
/// The tree with a world's data packs' advancement files over the game's
|
||||
/// own: the top pack's file for each id replaces it.
|
||||
pub fn load_with(data: &Path, overlay: &horizon_world::loot::PackOverlay) -> Tree {
|
||||
let mut tree = Tree::default();
|
||||
let Ok(namespaces) = std::fs::read_dir(data) else {
|
||||
return tree;
|
||||
@@ -77,6 +106,8 @@ impl Tree {
|
||||
let root = ns.path().join("advancement");
|
||||
collect(&root, &root, &ns.file_name().to_string_lossy(), &mut files);
|
||||
}
|
||||
// One JSON per id, the packs' over the game's own.
|
||||
let mut json: BTreeMap<String, Value> = BTreeMap::new();
|
||||
files.sort();
|
||||
for (id, path) in files {
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
@@ -85,6 +116,17 @@ impl Tree {
|
||||
let Ok(v) = serde_json::from_str::<Value>(&text) else {
|
||||
continue;
|
||||
};
|
||||
json.insert(id, v);
|
||||
}
|
||||
for (id, files) in overlay.kind("advancement") {
|
||||
match serde_json::from_slice::<Value>(horizon_world::loot::PackOverlay::top(files)) {
|
||||
Ok(v) => {
|
||||
json.insert(id.to_owned(), v);
|
||||
}
|
||||
Err(e) => log::warn!("Horizon: pack advancement {id} not loaded: {e}"),
|
||||
}
|
||||
}
|
||||
for (id, v) in json {
|
||||
if let Some(a) = parse(&id, &v) {
|
||||
let index = tree.list.len();
|
||||
for (ci, c) in a.criteria.iter().enumerate() {
|
||||
|
||||
@@ -461,6 +461,10 @@ pub enum EntityHit {
|
||||
Refused,
|
||||
/// The entity turned it first (`Entity.deflection`: breezes).
|
||||
Deflected,
|
||||
/// The target teleported away from it (`EnderMan.hurtServer` returns true,
|
||||
/// `onHitEntity` returns before the hit sound and the discard): the arrow
|
||||
/// flies on and falls to the ground.
|
||||
Dodged,
|
||||
}
|
||||
/// The rest of the tick of an arrow that hit an entity: whether to keep it.
|
||||
pub fn finish_entity_hit(p: &mut Projectile, scene: &ArrowScene, outcome: EntityHit) -> bool {
|
||||
@@ -483,6 +487,7 @@ pub fn finish_entity_hit(p: &mut Projectile, scene: &ArrowScene, outcome: Entity
|
||||
}
|
||||
}
|
||||
EntityHit::Refused => ticker.turn_back(),
|
||||
EntityHit::Dodged => {}
|
||||
EntityHit::Deflected => {
|
||||
// The deflection comes before `onHitEntity` adds the entity to
|
||||
// `piercingIgnoreEntityIds`.
|
||||
@@ -659,7 +664,7 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
if !self.st.first_tick && self.st.fluids.is_in_fluid(&Tags, LAVA) {
|
||||
self.st.fall_distance *= 0.5;
|
||||
}
|
||||
if self.position().y < f64::from(crate::chunk::MIN_Y - 64) {
|
||||
if self.position().y < f64::from(self.scene.world.bottom_y() - 64) {
|
||||
self.st.removed = true;
|
||||
}
|
||||
self.st.first_tick = false;
|
||||
@@ -1154,10 +1159,10 @@ impl FluidInteractionWorld for Ticker<'_, '_, '_, '_> {
|
||||
Ok(self.scene.world.chunks.contains_key(&(x, z)).then_some(()))
|
||||
}
|
||||
fn chunk_sections(&mut self, _: ()) -> Result<FluidSections<'_>, Error> {
|
||||
static SECTIONS: [bool; crate::chunk::SECTIONS] = [true; crate::chunk::SECTIONS];
|
||||
let layout = self.scene.world.layout();
|
||||
Ok(FluidSections {
|
||||
minimum_section_y: crate::chunk::MIN_Y >> 4,
|
||||
has_fluid: &SECTIONS,
|
||||
minimum_section_y: layout.min_y() >> 4,
|
||||
has_fluid: crate::chunk::every_section(layout),
|
||||
})
|
||||
}
|
||||
fn bounding_box_min_y(&self) -> f64 {
|
||||
|
||||
@@ -54,6 +54,28 @@ pub fn spawn_egg_op_warning(stack: &crate::items::ItemStack) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// `SpawnEggItem.useOn` on a spawner: the type it sets (`getType`: the egg's
|
||||
/// entity data id, else the egg's own type) and, for an op
|
||||
/// (`PlayerList.isOp`), that entity data for `Spawner.setEntityData`; anyone
|
||||
/// else sets only the type (`setEntityId`).
|
||||
pub fn spawn_egg_on_spawner(
|
||||
stack: &crate::items::ItemStack,
|
||||
op: bool,
|
||||
) -> (String, Option<Compound>) {
|
||||
let data = match stack.data().and_then(|d| d.other("minecraft:entity_data")) {
|
||||
Some(Tag::Compound(c)) => Some(c),
|
||||
_ => None,
|
||||
};
|
||||
let id = match data.and_then(|c| c.get(&JavaString::from("id"))) {
|
||||
Some(Tag::String(id)) => id.to_string_lossy(),
|
||||
_ => {
|
||||
let kind = stack.item.trim_end_matches("_spawn_egg");
|
||||
format!("minecraft:{}", kind.trim_start_matches("minecraft:"))
|
||||
}
|
||||
};
|
||||
(id, data.filter(|_| op).cloned())
|
||||
}
|
||||
|
||||
/// `Spawner.getSpawnEntityDisplayName` (`SpawnData.entity.id` of the item's
|
||||
/// data, which `ItemStack.addDetailsToTooltip` reads for spawners and trial
|
||||
/// spawners alike): the entity type a spawner item spawns.
|
||||
@@ -466,6 +488,35 @@ mod tests {
|
||||
assert_eq!(place(&chest, &patch, true), chest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spawn_egg_sets_its_data_type_and_an_ops_data() {
|
||||
let egg = |entity_data: Option<&str>| {
|
||||
let mut stack = crate::items::ItemStack::new("minecraft:zombie_spawn_egg", 1);
|
||||
if let Some(text) = entity_data {
|
||||
let components = snbt(&format!("{{\"minecraft:entity_data\":{text}}}"));
|
||||
(stack.damage, stack.extra) =
|
||||
crate::item_data::decode_components(Some(&components));
|
||||
}
|
||||
stack
|
||||
};
|
||||
// A plain egg: its own type, no data, op or not.
|
||||
assert_eq!(
|
||||
spawn_egg_on_spawner(&egg(None), true),
|
||||
("minecraft:zombie".into(), None)
|
||||
);
|
||||
// Entity data: its type for anyone (getType), its data only for an op.
|
||||
let custom = egg(Some("{id:\"minecraft:skeleton\",CustomName:\"Guard\"}"));
|
||||
let (id, data) = spawn_egg_on_spawner(&custom, false);
|
||||
assert_eq!((id.as_str(), data), ("minecraft:skeleton", None));
|
||||
let (id, data) = spawn_egg_on_spawner(&custom, true);
|
||||
assert_eq!(id, "minecraft:skeleton");
|
||||
let data = data.expect("an op's egg carries its data");
|
||||
assert_eq!(
|
||||
data.get(&JavaString::from("CustomName")),
|
||||
Some(&Tag::String("Guard".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_data_names_its_spawn_and_op_only_types() {
|
||||
let item = |text: &str| {
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
//! `BlockPattern`: a grid of block predicates found in the world in any of
|
||||
//! its 24 orientations (upright, upside down, lying flat, turned sideways).
|
||||
//! The wither is built from these.
|
||||
use horizon_core::geometry::Direction;
|
||||
|
||||
/// A grid of predicate keys: `aisles[z][y]` is a row whose chars are the `x`
|
||||
/// cells (`BlockPatternBuilder.aisle`). A space matches anything.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct BlockPattern {
|
||||
pub aisles: &'static [&'static [&'static str]],
|
||||
}
|
||||
|
||||
/// `WitherSkullBlock.getOrCreateWitherFull`: '^' a wither skeleton skull,
|
||||
/// '#' a wither summon base block, '~' air.
|
||||
pub const WITHER_FULL: BlockPattern = BlockPattern {
|
||||
aisles: &[&["^^^", "###", "~#~"]],
|
||||
};
|
||||
/// `WitherSkullBlock.getOrCreateWitherBase`.
|
||||
pub const WITHER_BASE: BlockPattern = BlockPattern {
|
||||
aisles: &[&[" ", "###", "~#~"]],
|
||||
};
|
||||
/// `BlockPattern.BlockPatternMatch`: where a pattern was found and how it is turned.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct PatternMatch {
|
||||
pub front_top_left: [i32; 3],
|
||||
pub forwards: Direction,
|
||||
pub up: Direction,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
pub depth: i32,
|
||||
}
|
||||
|
||||
impl PatternMatch {
|
||||
/// `BlockPatternMatch.getBlock(x, y, z)`'s position.
|
||||
pub fn block(&self, x: i32, y: i32, z: i32) -> [i32; 3] {
|
||||
translate_and_rotate(self.front_top_left, self.forwards, self.up, x, y, z)
|
||||
}
|
||||
/// The front layer, column by column (`CarvedPumpkinBlock.clearPatternBlocks`
|
||||
/// and `updatePatternBlocks`).
|
||||
pub fn front_cells(&self) -> Vec<[i32; 3]> {
|
||||
(0..self.width)
|
||||
.flat_map(|x| (0..self.height).map(move |y| (x, y)))
|
||||
.map(|(x, y)| self.block(x, y, 0))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// `BlockPattern.translateAndRotate`: `x` runs along forwards × up, `y` down
|
||||
/// against up, `z` along forwards.
|
||||
pub fn translate_and_rotate(
|
||||
pos: [i32; 3],
|
||||
forwards: Direction,
|
||||
up: Direction,
|
||||
x: i32,
|
||||
y: i32,
|
||||
z: i32,
|
||||
) -> [i32; 3] {
|
||||
let f = forwards.normal();
|
||||
let u = up.normal();
|
||||
let right = [
|
||||
f[1] * u[2] - f[2] * u[1],
|
||||
f[2] * u[0] - f[0] * u[2],
|
||||
f[0] * u[1] - f[1] * u[0],
|
||||
];
|
||||
std::array::from_fn(|i| pos[i] - u[i] * y + right[i] * x + f[i] * z)
|
||||
}
|
||||
|
||||
impl BlockPattern {
|
||||
pub fn width(&self) -> i32 {
|
||||
self.aisles
|
||||
.first()
|
||||
.and_then(|a| a.first())
|
||||
.map_or(0, |row| row.len() as i32)
|
||||
}
|
||||
pub fn height(&self) -> i32 {
|
||||
self.aisles.first().map_or(0, |a| a.len() as i32)
|
||||
}
|
||||
pub fn depth(&self) -> i32 {
|
||||
self.aisles.len() as i32
|
||||
}
|
||||
/// `BlockPattern.matches`: every cell of the pattern with its front top
|
||||
/// left at `corner`, turned by `forwards` and `up`, passes `test(key, pos)`.
|
||||
pub fn matches(
|
||||
&self,
|
||||
corner: [i32; 3],
|
||||
forwards: Direction,
|
||||
up: Direction,
|
||||
test: &mut impl FnMut(char, [i32; 3]) -> bool,
|
||||
) -> Option<PatternMatch> {
|
||||
for x in 0..self.width() {
|
||||
for y in 0..self.height() {
|
||||
for z in 0..self.depth() {
|
||||
let key = self.aisles[z as usize][y as usize]
|
||||
.as_bytes()
|
||||
.get(x as usize)
|
||||
.map_or(' ', |&b| b as char);
|
||||
if key != ' ' && !test(key, translate_and_rotate(corner, forwards, up, x, y, z))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(PatternMatch {
|
||||
front_top_left: corner,
|
||||
forwards,
|
||||
up,
|
||||
width: self.width(),
|
||||
height: self.height(),
|
||||
depth: self.depth(),
|
||||
})
|
||||
}
|
||||
/// `BlockPattern.find`: the first match whose front top left corner lies
|
||||
/// in the cube from `pos` to `pos + size - 1` (x fastest, then y, then z),
|
||||
/// trying every forwards and up pair (`Direction.values()` order) that is
|
||||
/// not along one axis.
|
||||
pub fn find(
|
||||
&self,
|
||||
pos: [i32; 3],
|
||||
mut test: impl FnMut(char, [i32; 3]) -> bool,
|
||||
) -> Option<PatternMatch> {
|
||||
let size = self.width().max(self.height()).max(self.depth());
|
||||
for dz in 0..size {
|
||||
for dy in 0..size {
|
||||
for dx in 0..size {
|
||||
let corner = [pos[0] + dx, pos[1] + dy, pos[2] + dz];
|
||||
for forwards in Direction::ALL {
|
||||
for up in Direction::ALL {
|
||||
if up == forwards || up == forwards.opposite() {
|
||||
continue;
|
||||
}
|
||||
if let Some(m) = self.matches(corner, forwards, up, &mut test) {
|
||||
return Some(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A world of named blocks; anything not listed is air.
|
||||
fn world(blocks: &[([i32; 3], char)]) -> HashMap<[i32; 3], char> {
|
||||
blocks.iter().copied().collect()
|
||||
}
|
||||
/// Each key tests for its own char in the fixture, '~' for air.
|
||||
fn finder(w: &HashMap<[i32; 3], char>) -> impl FnMut(char, [i32; 3]) -> bool + '_ {
|
||||
move |key, p| match key {
|
||||
'~' => !w.contains_key(&p),
|
||||
k => w.get(&p) == Some(&k),
|
||||
}
|
||||
}
|
||||
/// The wither T laid out along `right` and `down` from `top_left`.
|
||||
fn wither(top_left: [i32; 3], right: [i32; 3], down: [i32; 3]) -> Vec<([i32; 3], char)> {
|
||||
let at = |x: i32, y: i32| std::array::from_fn(|i| top_left[i] + right[i] * x + down[i] * y);
|
||||
let mut v: Vec<_> = (0..3).map(|x| (at(x, 0), '^')).collect();
|
||||
v.extend((0..3).map(|x| (at(x, 1), '#')));
|
||||
v.push((at(1, 2), '#'));
|
||||
v
|
||||
}
|
||||
fn found(blocks: &[([i32; 3], char)], placed: [i32; 3]) -> Option<PatternMatch> {
|
||||
let w = world(blocks);
|
||||
WITHER_FULL.find(placed, finder(&w))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_axis_of_rotation_is_tried() {
|
||||
// Each forwards/up pair puts x, y and z along three different axes.
|
||||
for f in Direction::ALL {
|
||||
for u in Direction::ALL {
|
||||
if u == f || u == f.opposite() {
|
||||
continue;
|
||||
}
|
||||
let cells: Vec<_> = (0..2)
|
||||
.flat_map(|x| (0..2).flat_map(move |y| (0..2).map(move |z| (x, y, z))))
|
||||
.map(|(x, y, z)| translate_and_rotate([0; 3], f, u, x, y, z))
|
||||
.collect();
|
||||
let mut unique = cells.clone();
|
||||
unique.sort();
|
||||
unique.dedup();
|
||||
assert_eq!(unique.len(), 8, "{f:?} {u:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wither_builds_in_any_orientation() {
|
||||
let x = [1, 0, 0];
|
||||
let y = [0, 1, 0];
|
||||
let z = [0, 0, 1];
|
||||
let neg = |v: [i32; 3]| v.map(|c| -c);
|
||||
// (right, down) for each layout: upright along X and Z, upside
|
||||
// down, lying flat both ways, and the T turned on its side.
|
||||
let layouts = [
|
||||
("upright along x", x, neg(y)),
|
||||
("upright along z", z, neg(y)),
|
||||
("upside down", x, y),
|
||||
("flat, skulls north", x, z),
|
||||
("flat, skulls east", z, neg(x)),
|
||||
("sideways, skulls west", y, x),
|
||||
("sideways, skulls south", y, neg(z)),
|
||||
];
|
||||
for (name, right, down) in layouts {
|
||||
let top_left = [10, 64, -5];
|
||||
let blocks = wither(top_left, right, down);
|
||||
for skull in 0..3 {
|
||||
let placed: [i32; 3] = std::array::from_fn(|i| top_left[i] + right[i] * skull);
|
||||
let m = found(&blocks, placed).unwrap_or_else(|| panic!("{name}, skull {skull}"));
|
||||
// The wither stands on the middle of the bottom row.
|
||||
let middle: [i32; 3] =
|
||||
std::array::from_fn(|i| top_left[i] + right[i] + down[i] * 2);
|
||||
assert_eq!(m.block(1, 2, 0), middle, "{name}");
|
||||
let mut cells = m.front_cells();
|
||||
cells.sort();
|
||||
let mut expected: Vec<[i32; 3]> = (0..3)
|
||||
.flat_map(|c| (0..3).map(move |r| (c, r)))
|
||||
.map(|(c, r)| std::array::from_fn(|i| top_left[i] + right[i] * c + down[i] * r))
|
||||
.collect();
|
||||
expected.sort();
|
||||
assert_eq!(cells, expected, "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wither_faces_along_the_pattern_normal() {
|
||||
// WitherSkullBlock.checkSpawn turns the wither by forwards' axis,
|
||||
// the normal of the T: an upright T along X faces along Z.
|
||||
let along_x = wither([0, 66, 0], [1, 0, 0], [0, -1, 0]);
|
||||
let m = found(&along_x, [0, 66, 0]).unwrap();
|
||||
assert!(matches!(m.forwards, Direction::North | Direction::South));
|
||||
let along_z = wither([0, 66, 0], [0, 0, 1], [0, -1, 0]);
|
||||
let m = found(&along_z, [0, 66, 0]).unwrap();
|
||||
assert!(matches!(m.forwards, Direction::West | Direction::East));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wither_needs_air_beside_the_leg() {
|
||||
let mut blocks = wither([0, 66, 0], [1, 0, 0], [0, -1, 0]);
|
||||
blocks.push(([0, 64, 0], 's'));
|
||||
assert_eq!(found(&blocks, [1, 66, 0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_base_pattern_ignores_the_skull_row() {
|
||||
let base = world(&[
|
||||
([0, 64, 0], '#'),
|
||||
([1, 64, 0], '#'),
|
||||
([2, 64, 0], '#'),
|
||||
([1, 63, 0], '#'),
|
||||
]);
|
||||
assert!(WITHER_BASE.find([0, 65, 0], finder(&base)).is_some());
|
||||
assert!(WITHER_FULL.find([0, 65, 0], finder(&base)).is_none());
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use crate::{
|
||||
items::{ItemStack, Items},
|
||||
particles::{DYE_FIREWORK_COLORS, FireworkExplosion, FireworkShape},
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
@@ -93,6 +94,18 @@ pub struct Template {
|
||||
pub damage: u32,
|
||||
pub extra: Option<Box<StackData>>,
|
||||
}
|
||||
/// `ItemStackTemplate.CODEC` count: absent means 1, present must be an
|
||||
/// integer in `1..=99` (`ExtraCodecs.intRange(1, 99)`), else the recipe is
|
||||
/// not loaded.
|
||||
pub(crate) fn template_count(v: &Value) -> Option<u32> {
|
||||
match v.get("count") {
|
||||
None => Some(1),
|
||||
Some(c) => c
|
||||
.as_u64()
|
||||
.filter(|n| (1..=99).contains(n))
|
||||
.map(|n| n as u32),
|
||||
}
|
||||
}
|
||||
impl Template {
|
||||
fn parse(v: &Value) -> Option<Self> {
|
||||
let (damage, extra) = match v.get("components") {
|
||||
@@ -101,7 +114,7 @@ impl Template {
|
||||
};
|
||||
Some(Self {
|
||||
item: v["id"].as_str().map(Arc::from),
|
||||
count: v["count"].as_u64().unwrap_or(1) as u32,
|
||||
count: template_count(v)?,
|
||||
damage,
|
||||
extra,
|
||||
})
|
||||
@@ -1168,7 +1181,7 @@ fn matches_all(ingredients: &[Ingredient], stacks: &[&ItemStack]) -> bool {
|
||||
})
|
||||
}
|
||||
impl Recipes {
|
||||
pub fn load(root: &Path, items: &Items) -> Result<Self> {
|
||||
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Result<Self> {
|
||||
let mut crafting = Vec::new();
|
||||
let mut cooking = Vec::new();
|
||||
let mut meta = HashMap::new();
|
||||
@@ -1181,27 +1194,47 @@ impl Recipes {
|
||||
files.sort();
|
||||
Ok(files)
|
||||
};
|
||||
let stem = |p: &Path| {
|
||||
p.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("")
|
||||
.to_owned()
|
||||
};
|
||||
// Vanilla's recipes by bare name, then content mods' as `<ns>:<name>`.
|
||||
let mut files: Vec<(String, PathBuf)> = json_files(&root.join("data/minecraft/recipe"))?
|
||||
.into_iter()
|
||||
.map(|p| (String::new(), p))
|
||||
.collect();
|
||||
let mut sources: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
for p in json_files(&root.join("data/minecraft/recipe"))? {
|
||||
sources.push((stem(&p), fs::read(&p)?));
|
||||
}
|
||||
for namespace in crate::mod_content::namespaces(root) {
|
||||
let dir = root.join(format!("data/{namespace}/recipe"));
|
||||
if dir.is_dir() {
|
||||
files.extend(
|
||||
json_files(&dir)?
|
||||
.into_iter()
|
||||
.map(|p| (format!("{namespace}:"), p)),
|
||||
);
|
||||
for p in json_files(&dir)? {
|
||||
sources.push((format!("{namespace}:{}", stem(&p)), fs::read(&p)?));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (prefix, path) in files {
|
||||
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
|
||||
let name = format!("{prefix}{stem}");
|
||||
// The world's packs' recipes over the game's own: the top pack's
|
||||
// file for each id replaces it (`SimpleJsonResourceReloadListener`
|
||||
// sees one resource per id, the resource stack's top).
|
||||
for (id, files) in overlay.kind("recipe") {
|
||||
let Some((ns, path)) = id.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let name = if ns == "minecraft" {
|
||||
path.to_owned()
|
||||
} else {
|
||||
id.to_owned()
|
||||
};
|
||||
let bytes = PackOverlay::top(files).to_vec();
|
||||
match sources.iter_mut().find(|(n, _)| *n == name) {
|
||||
Some(slot) => slot.1 = bytes,
|
||||
None => sources.push((name, bytes)),
|
||||
}
|
||||
}
|
||||
for (name, bytes) in sources {
|
||||
// Vanilla's RecipeManager logs a file that does not parse and
|
||||
// goes on ("Couldn't parse data file").
|
||||
let v: Value = match serde_json::from_slice(&fs::read(&path)?) {
|
||||
let v: Value = match serde_json::from_slice(&bytes) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: recipe {name} not loaded: {e}");
|
||||
@@ -1408,7 +1441,7 @@ impl Recipes {
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let curses = crate::items::load_tags(root, "enchantment")?
|
||||
let curses = crate::items::load_tags(root, "enchantment", overlay)?
|
||||
.remove("minecraft:curse")
|
||||
.unwrap_or_default();
|
||||
Ok(Self {
|
||||
@@ -1640,6 +1673,27 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::banner::BannerLayer;
|
||||
|
||||
#[test]
|
||||
fn result_count_is_bounded_like_vanilla() {
|
||||
let parse = |s: &str| Template::parse(&serde_json::from_str(s).unwrap());
|
||||
assert_eq!(parse(r#"{"id":"minecraft:stone"}"#).unwrap().count, 1);
|
||||
let top = parse(r#"{"id":"minecraft:stone","count":99}"#);
|
||||
assert_eq!(top.unwrap().count, 99);
|
||||
for bad in [
|
||||
"0",
|
||||
"100",
|
||||
"2147483648",
|
||||
"4294967295",
|
||||
"4294967296",
|
||||
"-1",
|
||||
"1.5",
|
||||
"\"2\"",
|
||||
] {
|
||||
let json = format!(r#"{{"id":"minecraft:stone","count":{bad}}}"#);
|
||||
assert!(parse(&json).is_none(), "count {bad} must be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
fn ingredient(items: &[&str]) -> Ingredient {
|
||||
Ingredient::of(items.iter().map(|s| (*s).to_owned()).collect())
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ pub struct Tab {
|
||||
pub column: u32,
|
||||
pub search: bool,
|
||||
pub inventory: bool,
|
||||
/// `CreativeModeTab.Type.HOTBAR`: the saved toolbars, filled when the
|
||||
/// tab is selected (`set_saved_hotbars`).
|
||||
pub hotbar: bool,
|
||||
}
|
||||
/// What the client's creative search trees (SessionSearchTrees) index for one
|
||||
/// stack of the search tab: its creative tooltip lines and its item tags.
|
||||
@@ -229,7 +232,7 @@ impl Creative {
|
||||
}
|
||||
search_items = items.clone();
|
||||
}
|
||||
if kind == "HOTBAR" || t["visible"] == false {
|
||||
if t["visible"] == false {
|
||||
continue;
|
||||
}
|
||||
let icon = t["icon"]
|
||||
@@ -244,6 +247,7 @@ impl Creative {
|
||||
column: t["column"].as_u64().unwrap_or(0) as u32,
|
||||
search: kind == "SEARCH",
|
||||
inventory: kind == "INVENTORY",
|
||||
hotbar: kind == "HOTBAR",
|
||||
});
|
||||
}
|
||||
Ok(Self::new(tabs, search_items, search_index))
|
||||
@@ -277,6 +281,7 @@ impl Creative {
|
||||
column: t["column"].as_u64().unwrap_or(0) as u32,
|
||||
search: kind == "SEARCH",
|
||||
inventory: kind == "INVENTORY",
|
||||
hotbar: false,
|
||||
});
|
||||
}
|
||||
let search_index = search_items
|
||||
@@ -333,6 +338,7 @@ impl Creative {
|
||||
column: t.column,
|
||||
search: t.search,
|
||||
inventory: t.inventory,
|
||||
hotbar: t.hotbar,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -464,6 +470,23 @@ impl Creative {
|
||||
});
|
||||
items
|
||||
}
|
||||
/// The stack in grid cell `index` (row-major from the tab's top), None
|
||||
/// for an empty cell: a saved toolbar's empty slot, or past the end.
|
||||
pub fn shown(&self, names: &Names, index: usize) -> Option<ItemStack> {
|
||||
self.items(names)
|
||||
.get(index)
|
||||
.filter(|s| s.count > 0)
|
||||
.cloned()
|
||||
}
|
||||
/// `CreativeModeInventoryScreen.selectTab` for the HOTBAR tab: the nine
|
||||
/// saved rows of `HotbarManager` as the tab's items, an empty stack (count
|
||||
/// 0) for an empty slot.
|
||||
pub fn set_saved_hotbars(&mut self, items: Vec<ItemStack>) {
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|t| t.hotbar) {
|
||||
tab.items = items;
|
||||
}
|
||||
self.forget_items();
|
||||
}
|
||||
/// Drop the shown items, so the next `items` works them out again: a
|
||||
/// reload can rename items without changing the Search label.
|
||||
pub fn forget_items(&self) {
|
||||
@@ -664,6 +687,13 @@ pub enum DisplayClick {
|
||||
Drop(ItemStack),
|
||||
}
|
||||
|
||||
/// Whether a stack has `minecraft:creative_slot_lock`.
|
||||
pub fn slot_locked(stack: &ItemStack) -> bool {
|
||||
stack
|
||||
.data()
|
||||
.is_some_and(|d| d.other("minecraft:creative_slot_lock").is_some())
|
||||
}
|
||||
|
||||
/// `CreativeModeInventoryScreen.slotClicked` on a display slot
|
||||
/// (`slot.container == CONTAINER`): `shown` is the slot's item (None past
|
||||
/// the tab's end) and `max` gives an item's max stack size. The tab's items
|
||||
@@ -676,6 +706,11 @@ pub fn click_display(
|
||||
input: crate::menu::Input,
|
||||
) -> DisplayClick {
|
||||
use crate::menu::Input;
|
||||
// CustomCreativeSlot.mayPickup: a `creative_slot_lock` stack (the saved
|
||||
// toolbars' hint) can't be clicked at all.
|
||||
if shown.is_some_and(slot_locked) {
|
||||
return DisplayClick::Nothing;
|
||||
}
|
||||
let copy = |s: &ItemStack, count: u32| ItemStack { count, ..s.clone() };
|
||||
let quick_key = input == Input::QuickMove;
|
||||
match input {
|
||||
@@ -738,6 +773,7 @@ pub fn click_display(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
fn stack(item: &str, count: u32) -> ItemStack {
|
||||
ItemStack {
|
||||
@@ -1047,6 +1083,53 @@ mod tests {
|
||||
assert_eq!(c.search.value, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_hotbars_tab_shows_its_rows_and_locks_the_hint() {
|
||||
let mut c = Creative::from_json(&serde_json::json!({"schema": 2, "tabs": [
|
||||
{"id": "minecraft:hotbar", "type": "HOTBAR", "row": "TOP", "column": 5, "title": "Saved Hotbars",
|
||||
"icon": "{count:1,id:\"minecraft:bookshelf\"}", "visible": true, "items": []},
|
||||
{"id": "minecraft:inventory", "type": "INVENTORY", "row": "BOTTOM", "column": 6, "title": "Survival Inventory",
|
||||
"icon": "{count:1,id:\"minecraft:chest\"}", "visible": true, "items": []}
|
||||
]}))
|
||||
.unwrap();
|
||||
assert!(c.tab().hotbar);
|
||||
assert!(!c.tab_headers()[1].hotbar);
|
||||
let mut hint = stack("paper", 1);
|
||||
hint.data_mut().set_other(
|
||||
"minecraft:creative_slot_lock",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
// A saved sword in slot 0, an empty slot, then the hint.
|
||||
c.set_saved_hotbars(vec![stack("diamond_sword", 1), stack("air", 0), hint]);
|
||||
let names = Names {
|
||||
map: HashMap::new(),
|
||||
};
|
||||
assert_eq!(c.shown(&names, 0), Some(stack("diamond_sword", 1)));
|
||||
assert_eq!(c.shown(&names, 1), None);
|
||||
assert_eq!(c.shown(&names, 3), None);
|
||||
let hint = c.shown(&names, 2).unwrap();
|
||||
assert!(slot_locked(&hint));
|
||||
assert!(!slot_locked(&stack("diamond_sword", 1)));
|
||||
// CustomCreativeSlot.mayPickup: the hint is never taken, and a carried
|
||||
// stack stays as it is.
|
||||
use crate::menu::Input::*;
|
||||
for (carried, button, input) in [
|
||||
(None, 0, Pickup),
|
||||
(None, 0, QuickMove),
|
||||
(None, 2, Clone),
|
||||
(None, 3, Swap),
|
||||
(None, 0, Throw),
|
||||
(Some(stack("dirt", 2)), 0, Pickup),
|
||||
(Some(stack("dirt", 2)), 1, Pickup),
|
||||
] {
|
||||
let mut carried = carried;
|
||||
let before = carried.clone();
|
||||
let effect = click_display(&mut carried, Some(&hint), |_| 64, button, input);
|
||||
assert_eq!(effect, DisplayClick::Nothing);
|
||||
assert_eq!(carried, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_matches_vanilla_trees() {
|
||||
let c = creative(0);
|
||||
@@ -1191,6 +1274,7 @@ mod tests {
|
||||
column,
|
||||
search: false,
|
||||
inventory: false,
|
||||
hotbar: false,
|
||||
};
|
||||
// Top row, second column: the button spans x 27..53, y -28..4.
|
||||
let t = tab(true, 1);
|
||||
|
||||
@@ -179,12 +179,17 @@ fn input(font: &Font, i: usize, control: &Control) -> Element {
|
||||
};
|
||||
}
|
||||
// CommonLayouts.labeledElement: the label, 4 below it the box,
|
||||
// both on the left.
|
||||
// both on the left. The label is a plain StringWidget: it has no
|
||||
// click handler, so a click event in it does nothing.
|
||||
let mut shown = label.clone();
|
||||
for span in &mut shown.spans {
|
||||
span.style.click = None;
|
||||
}
|
||||
Element {
|
||||
w: w.max(font.width_text(label)),
|
||||
h: 9.0 + 4.0 + 20.0,
|
||||
parts: vec![
|
||||
Part::Line(label.clone(), 0.0, 0.0),
|
||||
Part::Line(shown, 0.0, 0.0),
|
||||
Part::Field {
|
||||
input: i,
|
||||
rect: [0.0, 13.0, w, 20.0],
|
||||
|
||||
@@ -980,6 +980,7 @@ mod experience_tests {
|
||||
#[cfg(test)]
|
||||
mod fire_environment_tests {
|
||||
use super::*;
|
||||
use horizon_world::loot::PackOverlay;
|
||||
#[test]
|
||||
fn ordinary_items_burn_but_native_netherite_resists_lava() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
@@ -991,7 +992,8 @@ mod fire_environment_tests {
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let items = crate::items::Items::load(&root, assets.registry, &facts).unwrap();
|
||||
let items =
|
||||
crate::items::Items::load(&root, assets.registry, &facts, &PackOverlay::NONE).unwrap();
|
||||
let mut world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let at = Vec3::new(20.5, 4.0, 20.5);
|
||||
world
|
||||
|
||||
@@ -83,10 +83,10 @@ impl FluidInteractionWorld for ItemFluidWorld<'_> {
|
||||
Ok(self.world.chunks.contains_key(&(x, z)).then_some(()))
|
||||
}
|
||||
fn chunk_sections(&mut self, _: ()) -> Result<FluidSections<'_>, Error> {
|
||||
static SECTIONS: [bool; crate::chunk::SECTIONS] = [true; crate::chunk::SECTIONS];
|
||||
let layout = self.world.layout();
|
||||
Ok(FluidSections {
|
||||
minimum_section_y: crate::chunk::MIN_Y >> 4,
|
||||
has_fluid: &SECTIONS,
|
||||
minimum_section_y: layout.min_y() >> 4,
|
||||
has_fluid: crate::chunk::every_section(layout),
|
||||
})
|
||||
}
|
||||
fn bounding_box_min_y(&self) -> f64 {
|
||||
|
||||
@@ -87,6 +87,11 @@ impl Base<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The mobs that save `Age` and `AgeLocked`.
|
||||
fn saves_age(s: crate::mobs::Species) -> bool {
|
||||
use crate::mobs::{Species, drops::can_be_baby, finalize::ageable};
|
||||
s == Species::Tadpole || (ageable(s) && can_be_baby(s))
|
||||
}
|
||||
/// `LivingEntity` + `Mob` (+ `AgeableMob`, creeper and skeleton specifics).
|
||||
/// The mobs whose class extends vanilla's `Animal` (and so save `InLove`):
|
||||
/// not allays, villagers, golems, dolphins or fish.
|
||||
@@ -150,6 +155,123 @@ fn player_uuid() -> [i32; 4] {
|
||||
u as u32 as i32,
|
||||
]
|
||||
}
|
||||
/// `ResolvableProfile.CODEC`: the name, id and properties of a profile, then the
|
||||
/// skin's texture ids and model (`PlayerSkin.Patch`: "texture", "cape", "elytra", "model").
|
||||
fn profile_nbt(p: &horizon_protocol::entity_data::Profile) -> Tag {
|
||||
let mut entries: Vec<(&'static str, Tag)> = Vec::new();
|
||||
if let Some(name) = &p.name {
|
||||
entries.push(("name", string(name)));
|
||||
}
|
||||
if let Some(id) = p.uuid {
|
||||
entries.push((
|
||||
"id",
|
||||
Tag::IntArray(vec![
|
||||
(id >> 96) as i32,
|
||||
(id >> 64) as i32,
|
||||
(id >> 32) as i32,
|
||||
id as i32,
|
||||
]),
|
||||
));
|
||||
}
|
||||
if !p.properties.is_empty() {
|
||||
let properties = p
|
||||
.properties
|
||||
.iter()
|
||||
.map(|property| {
|
||||
let mut entries: Vec<(&'static str, Tag)> = vec![
|
||||
("name", string(&property.name)),
|
||||
("value", string(&property.value)),
|
||||
];
|
||||
if let Some(signature) = &property.signature {
|
||||
entries.push(("signature", string(signature)));
|
||||
}
|
||||
Tag::compound(entries)
|
||||
})
|
||||
.collect();
|
||||
entries.push(("properties", Tag::List(properties)));
|
||||
}
|
||||
for (key, texture) in [
|
||||
("texture", &p.body),
|
||||
("cape", &p.cape),
|
||||
("elytra", &p.elytra),
|
||||
] {
|
||||
if let Some(texture) = texture {
|
||||
entries.push((key, string(texture)));
|
||||
}
|
||||
}
|
||||
if let Some(slim) = p.slim {
|
||||
entries.push(("model", string(if slim { "slim" } else { "wide" })));
|
||||
}
|
||||
Tag::compound(entries)
|
||||
}
|
||||
/// `ResolvableProfile.CODEC` read back: the compound `profile_nbt` writes, or a
|
||||
/// bare player name (`Codec.withAlternative(FULL_CODEC, PLAYER_NAME, ...)`).
|
||||
fn profile_from_nbt(tag: &Tag) -> Option<horizon_protocol::entity_data::Profile> {
|
||||
if let Tag::String(name) = tag {
|
||||
return Some(horizon_protocol::entity_data::Profile {
|
||||
name: Some(name.to_string_lossy()),
|
||||
uuid: None,
|
||||
properties: Vec::new(),
|
||||
body: None,
|
||||
cape: None,
|
||||
elytra: None,
|
||||
slim: None,
|
||||
});
|
||||
}
|
||||
let property = |name: String, value: &Tag, signature: Option<String>| match value {
|
||||
Tag::String(v) => Some(horizon_protocol::packets::login::Property {
|
||||
name,
|
||||
value: v.to_string_lossy(),
|
||||
signature,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
let properties: Vec<horizon_protocol::packets::login::Property> = match tag.get("properties") {
|
||||
// `ExtraCodecs.PROPERTY_MAP`: a list of properties ...
|
||||
Some(Tag::List(list)) => list
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
let name = match p.get("name")? {
|
||||
Tag::String(s) => s.to_string_lossy(),
|
||||
_ => return None,
|
||||
};
|
||||
let signature = match p.get("signature") {
|
||||
Some(Tag::String(s)) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
};
|
||||
property(name, p.get("value")?, signature)
|
||||
})
|
||||
.collect(),
|
||||
// ... or a map from a name to its values.
|
||||
Some(Tag::Compound(map)) => map
|
||||
.iter()
|
||||
.flat_map(|(name, values)| match values {
|
||||
Tag::List(values) => values
|
||||
.iter()
|
||||
.filter_map(|v| property(name.to_string_lossy(), v, None))
|
||||
.collect::<Vec<_>>(),
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let text = |key: &str| match tag.get(key) {
|
||||
Some(Tag::String(s)) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
};
|
||||
Some(horizon_protocol::entity_data::Profile {
|
||||
name: text("name"),
|
||||
uuid: uuid_from_tag(tag.get("id")).map(|a| {
|
||||
a.iter()
|
||||
.fold(0u128, |u, &x| (u << 32) | u128::from(x as u32))
|
||||
}),
|
||||
properties,
|
||||
body: text("texture"),
|
||||
cape: text("cape"),
|
||||
elytra: text("elytra"),
|
||||
slim: text("model").map(|model| model == "slim"),
|
||||
})
|
||||
}
|
||||
pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
let base = Base {
|
||||
id: &m.kind.id,
|
||||
@@ -196,6 +318,12 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
put(&mut tag, "angry_at", Tag::IntArray(player_uuid().to_vec()));
|
||||
}
|
||||
}
|
||||
// `Mannequin.addAdditionalSaveData`: the profile whose skin it wears.
|
||||
if m.species() == crate::mobs::Species::Mannequin
|
||||
&& let Some(profile) = &m.profile
|
||||
{
|
||||
put(&mut tag, "profile", profile_nbt(profile));
|
||||
}
|
||||
// `Entity.saveWithoutId` writes the flag only when set.
|
||||
if m.name_visible {
|
||||
put(&mut tag, "CustomNameVisible", Tag::Byte(1));
|
||||
@@ -286,19 +414,14 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
}
|
||||
let id = m.kind.id.as_str();
|
||||
// Only breedable animals have an age; a loaded creature that was saved without
|
||||
// one (a frog) stays without.
|
||||
let aged = raw.is_none_or(|r| r.get("Age").is_some());
|
||||
if (m.kind.category == "creature" || m.species() == crate::mobs::Species::Hoglin) && aged {
|
||||
// AgeableMob.addAdditionalSaveData, only if canBeABaby (not a frog or a
|
||||
// parrot); a tadpole saves its own age.
|
||||
let species = m.species();
|
||||
if saves_age(species) {
|
||||
map.insert("Age".into(), Tag::Int(m.extra.age));
|
||||
map.insert("AgeLocked".into(), Tag::Byte(i8::from(m.extra.age_locked)));
|
||||
map.insert("ForcedAge".into(), Tag::Int(m.extra.forced_age));
|
||||
for (key, value) in [
|
||||
("Age", Tag::Int(0)),
|
||||
("ForcedAge", Tag::Int(0)),
|
||||
("AgeLocked", Tag::Byte(0)),
|
||||
] {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
if species != crate::mobs::Species::Tadpole {
|
||||
map.insert("ForcedAge".into(), Tag::Int(m.extra.forced_age));
|
||||
}
|
||||
}
|
||||
// Animal.addAdditionalSaveData: the love ticks left, whatever the save had.
|
||||
@@ -324,19 +447,29 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
);
|
||||
}
|
||||
match id {
|
||||
// Fox.addAdditionalSaveData; Horizon models only the type.
|
||||
// Fox.addAdditionalSaveData; Horizon models only the type, the
|
||||
// flags and no trusted players yet.
|
||||
"minecraft:fox" => {
|
||||
let kind = if m.extra.variant == 1 { "snow" } else { "red" };
|
||||
map.insert("Type".into(), string(kind));
|
||||
for (key, value) in [
|
||||
("Trusted", Tag::List(Vec::new())),
|
||||
("Sleeping", Tag::Byte(0)),
|
||||
("Sitting", Tag::Byte(0)),
|
||||
("Crouching", Tag::Byte(0)),
|
||||
map.entry("Trusted".into()).or_insert(Tag::List(Vec::new()));
|
||||
for (key, bit) in [
|
||||
("Sleeping", crate::mobs::fox::SLEEPING),
|
||||
("Sitting", crate::mobs::fox::SITTING),
|
||||
("Crouching", crate::mobs::fox::CROUCHING),
|
||||
] {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
map.insert(
|
||||
key.into(),
|
||||
Tag::Byte(i8::from(crate::mobs::fox::flag(m, bit))),
|
||||
);
|
||||
}
|
||||
}
|
||||
// MushroomCow.addAdditionalSaveData: the Variant codec's name (stew
|
||||
// effects are not modelled).
|
||||
"minecraft:mooshroom" => {
|
||||
let kind = if m.extra.variant == 1 { "brown" } else { "red" };
|
||||
map.insert("Type".into(), string(kind));
|
||||
}
|
||||
// Rabbit.addAdditionalSaveData (the legacy id; 99 is the killer bunny).
|
||||
"minecraft:rabbit" => {
|
||||
map.insert("RabbitType".into(), Tag::Int(i32::from(m.extra.variant)));
|
||||
@@ -531,6 +664,8 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
}
|
||||
"minecraft:villager" => {
|
||||
map.insert("VillagerData".into(), villager_data(m));
|
||||
// A loaded villager with VillagerData is finalized.
|
||||
map.insert("VillagerDataFinalized".into(), Tag::Byte(1));
|
||||
let t = m.extra.trade.as_deref().cloned().unwrap_or_default();
|
||||
map.insert("Xp".into(), Tag::Int(t.xp));
|
||||
map.insert("LastRestock".into(), Tag::Long(t.last_restock));
|
||||
@@ -584,32 +719,56 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
]));
|
||||
}
|
||||
map.insert("Inventory".into(), Tag::List(inv));
|
||||
if let Some(st) = &m.extra.allay {
|
||||
map.insert("listener".into(), st.vibration.to_nbt());
|
||||
}
|
||||
map.insert(
|
||||
"DuplicationCooldown".into(),
|
||||
Tag::Long(i64::from(m.extra.egg_time)),
|
||||
);
|
||||
let mut memories: Vec<(&str, Tag)> = Vec::new();
|
||||
if m.extra.tame {
|
||||
map.insert(
|
||||
"Brain".into(),
|
||||
memories.push((
|
||||
"minecraft:liked_player",
|
||||
Tag::compound([(
|
||||
"memories",
|
||||
Tag::compound([(
|
||||
"minecraft:liked_player",
|
||||
Tag::compound([(
|
||||
"value",
|
||||
Tag::IntArray(m.extra.owner.unwrap_or([0, 0, 0, 1]).to_vec()),
|
||||
)]),
|
||||
)]),
|
||||
"value",
|
||||
Tag::IntArray(m.extra.owner.unwrap_or([0, 0, 0, 1]).to_vec()),
|
||||
)]),
|
||||
);
|
||||
));
|
||||
}
|
||||
// GlobalPos.CODEC, and the cooldown while it runs.
|
||||
if let Some(liked) = m.extra.allay.as_ref().and_then(|st| st.noteblock) {
|
||||
memories.push((
|
||||
"minecraft:liked_noteblock",
|
||||
Tag::compound([(
|
||||
"value",
|
||||
Tag::compound([
|
||||
("dimension", string(liked.dimension)),
|
||||
("pos", Tag::IntArray(liked.pos.to_vec())),
|
||||
]),
|
||||
)]),
|
||||
));
|
||||
}
|
||||
if m.extra.ints[2] > 0 {
|
||||
memories.push((
|
||||
"minecraft:liked_noteblock_cooldown_ticks",
|
||||
Tag::compound([("value", Tag::Int(m.extra.ints[2]))]),
|
||||
));
|
||||
}
|
||||
// Always written, so a forgotten note block does not come back
|
||||
// from the compound the allay was loaded with.
|
||||
map.insert(
|
||||
"Brain".into(),
|
||||
Tag::compound([("memories", Tag::compound(memories))]),
|
||||
);
|
||||
}
|
||||
"minecraft:camel" | "minecraft:camel_husk" => {
|
||||
// LastPoseTick: negative while sitting.
|
||||
let sitting = crate::mobs::camel::sitting(m);
|
||||
// LastPoseTick: the pose change time, negative while sitting. The
|
||||
// game time is not known here; the world's save overwrites it
|
||||
// with the real one (`camel::synced_pose_tick`).
|
||||
map.insert(
|
||||
"LastPoseTick".into(),
|
||||
Tag::Long(if sitting { -1 } else { 0 }),
|
||||
Tag::Long(crate::mobs::camel::synced_pose_tick(m, 0)),
|
||||
);
|
||||
if let Some(saddle) = m.saddle_item() {
|
||||
map.insert(
|
||||
@@ -667,6 +826,13 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
map.insert("Peek".into(), Tag::Byte(m.extra.counter as i8));
|
||||
map.insert("Color".into(), Tag::Byte(m.extra.color as i8));
|
||||
}
|
||||
// GlowSquid.addAdditionalSaveData.
|
||||
"minecraft:glow_squid" => {
|
||||
map.insert(
|
||||
"DarkTicksRemaining".into(),
|
||||
Tag::Int(crate::mobs::aquatic::dark_ticks(m)),
|
||||
);
|
||||
}
|
||||
"minecraft:slime" | "minecraft:magma_cube" => {
|
||||
map.insert("Size".into(), Tag::Int(m.extra.size.max(1) - 1));
|
||||
map.insert("wasOnGround".into(), Tag::Byte(i8::from(m.extra.flag2)));
|
||||
@@ -728,9 +894,64 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
) {
|
||||
map.insert("IsBaby".into(), Tag::Byte(i8::from(m.is_baby())));
|
||||
}
|
||||
// NeutralMob.addPersistentAngerSaveData; the bee saves its own.
|
||||
if matches!(
|
||||
id,
|
||||
"minecraft:iron_golem"
|
||||
| "minecraft:wolf"
|
||||
| "minecraft:polar_bear"
|
||||
| "minecraft:zombified_piglin"
|
||||
| "minecraft:enderman"
|
||||
) {
|
||||
map.entry("anger_end_time".into()).or_insert(Tag::Long(-1));
|
||||
}
|
||||
if let Some(a) = m.extra.attributes.as_deref() {
|
||||
put_attributes(map, m, a);
|
||||
}
|
||||
// The attribute their load makes, which AttributeMap.pack then saves:
|
||||
// Zombie and Piglin.setBaby (even for an adult) the speed, Rabbit.setVariant
|
||||
// the attack damage.
|
||||
use crate::mobs::finalize::Attr;
|
||||
let made = if crate::mobs::doors::is_zombie(species) || species == crate::mobs::Species::Piglin
|
||||
{
|
||||
Some(Attr::MovementSpeed)
|
||||
} else if species == crate::mobs::Species::Rabbit {
|
||||
Some(Attr::AttackDamage)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(attr) = made {
|
||||
let base = crate::mobs::finalize::base_value(m, attr);
|
||||
let mut list = match map.get(&"attributes".into()) {
|
||||
Some(Tag::List(l)) => l.clone(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let attr_id = format!("minecraft:{}", attr.name());
|
||||
if !list
|
||||
.iter()
|
||||
.any(|t| text(t.get("id")).as_deref() == Some(attr_id.as_str()))
|
||||
{
|
||||
list.push(Tag::compound([
|
||||
("id", string(&attr_id)),
|
||||
("base", Tag::Double(base.to_bits())),
|
||||
]));
|
||||
map.insert("attributes".into(), Tag::List(list));
|
||||
}
|
||||
}
|
||||
// Mob.addAdditionalSaveData: the chances that are not the default.
|
||||
if let Some(chances) = &m.extra.drop_chances {
|
||||
let set: Vec<(&str, Tag)> = crate::mobs::drops::SLOTS
|
||||
.iter()
|
||||
.zip(chances.iter())
|
||||
.filter(|(_, c)| **c != crate::mobs::drops::DEFAULT_DROP_CHANCE)
|
||||
.map(|(slot, c)| (*slot, Tag::Float(c.to_bits())))
|
||||
.collect();
|
||||
if set.is_empty() {
|
||||
map.remove(&"drop_chances".into());
|
||||
} else {
|
||||
map.insert("drop_chances".into(), Tag::compound(set));
|
||||
}
|
||||
}
|
||||
crate::mobs::raider::write_nbt(m, map);
|
||||
crate::mobs::riders::write_nbt(m, map);
|
||||
// LivingEntity equipment: what the mob holds now.
|
||||
@@ -1046,11 +1267,24 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.angry_at = Some(crate::mobs::Who::Player);
|
||||
}
|
||||
}
|
||||
// `Mannequin.readAdditionalSaveData`: the profile comes back with the skin.
|
||||
if m.species() == crate::mobs::Species::Mannequin {
|
||||
m.profile = tag.get("profile").and_then(profile_from_nbt);
|
||||
}
|
||||
m.extra.pick_up_loot = flag(tag, "CanPickUpLoot");
|
||||
m.custom_name = custom_name(tag);
|
||||
m.custom_name_text = tag.get("CustomName").map(name_text);
|
||||
m.extra.left_handed = flag(tag, "LeftHanded");
|
||||
if m.kind.category == "creature" || m.species() == crate::mobs::Species::Hoglin {
|
||||
if let Some(Tag::Compound(c)) = tag.get("drop_chances") {
|
||||
let mut chances = [crate::mobs::drops::DEFAULT_DROP_CHANCE; 8];
|
||||
for (chance, slot) in chances.iter_mut().zip(crate::mobs::drops::SLOTS) {
|
||||
if let Some(Tag::Float(v)) = c.get(&slot.into()) {
|
||||
*chance = f32::from_bits(*v);
|
||||
}
|
||||
}
|
||||
m.extra.drop_chances = Some(Box::new(chances));
|
||||
}
|
||||
if saves_age(m.species()) {
|
||||
m.extra.age = number(tag.get("Age")).unwrap_or(0) as i32;
|
||||
m.extra.forced_age = number(tag.get("ForcedAge")).unwrap_or(0) as i32;
|
||||
m.extra.age_locked = flag(tag, "AgeLocked");
|
||||
@@ -1181,6 +1415,21 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
match m.species() {
|
||||
crate::mobs::Species::Fox => {
|
||||
m.extra.variant = u8::from(text(tag.get("Type")).as_deref() == Some("snow"));
|
||||
// Fox.readAdditionalSaveData: the sleeping, sitting and crouching flags.
|
||||
for (key, bit) in [
|
||||
("Sleeping", crate::mobs::fox::SLEEPING),
|
||||
("Sitting", crate::mobs::fox::SITTING),
|
||||
("Crouching", crate::mobs::fox::CROUCHING),
|
||||
] {
|
||||
if flag(tag, key) {
|
||||
m.extra.ints[0] |= bit;
|
||||
}
|
||||
}
|
||||
}
|
||||
// MushroomCow.readAdditionalSaveData: a missing or unknown name is
|
||||
// Variant.DEFAULT, red.
|
||||
crate::mobs::Species::Mooshroom => {
|
||||
m.extra.variant = u8::from(text(tag.get("Type")).as_deref() == Some("brown"));
|
||||
}
|
||||
crate::mobs::Species::Rabbit => {
|
||||
// Rabbit.Variant.byId: an unknown id is brown.
|
||||
@@ -1256,6 +1505,9 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.ints[1] = number(first.get("count")).unwrap_or(1) as i32;
|
||||
}
|
||||
m.extra.egg_time = number(tag.get("DuplicationCooldown")).unwrap_or(0) as i32;
|
||||
if let (Some(st), Some(listener)) = (m.extra.allay.as_mut(), tag.get("listener")) {
|
||||
st.vibration = crate::sculk::Data::from_nbt(listener);
|
||||
}
|
||||
let liked = tag
|
||||
.get("Brain")
|
||||
.and_then(|b| b.get("memories"))
|
||||
@@ -1264,13 +1516,37 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.owner = liked
|
||||
.and_then(|l| l.get("value"))
|
||||
.and_then(|v| crate::save::uuid_from_tag(Some(v)));
|
||||
let memory = |key: &str| {
|
||||
tag.get("Brain")
|
||||
.and_then(|b| b.get("memories"))
|
||||
.and_then(|b| b.get(key))
|
||||
.and_then(|l| l.get("value"))
|
||||
};
|
||||
m.extra.ints[2] =
|
||||
number(memory("minecraft:liked_noteblock_cooldown_ticks")).unwrap_or(0) as i32;
|
||||
if let Some(st) = m.extra.allay.as_mut()
|
||||
&& let Some(noteblock) = memory("minecraft:liked_noteblock")
|
||||
&& let Some(Tag::IntArray(p)) = noteblock.get("pos")
|
||||
&& p.len() == 3
|
||||
{
|
||||
st.noteblock = Some(crate::mobs::allay::GlobalPos {
|
||||
dimension: crate::mobs::allay::dimension_id(
|
||||
&text(noteblock.get("dimension")).unwrap_or_default(),
|
||||
),
|
||||
pos: [p[0], p[1], p[2]],
|
||||
});
|
||||
}
|
||||
}
|
||||
if crate::mobs::camel::is_camel(m.species()) {
|
||||
let sitting = matches!(tag.get("LastPoseTick"), Some(Tag::Long(v)) if *v < 0);
|
||||
// The tick is a game time: `camel::finish_load` puts it on the mob's
|
||||
// clock once the world's time is known.
|
||||
let tick = match tag.get("LastPoseTick") {
|
||||
Some(Tag::Long(v)) => *v,
|
||||
_ => 0,
|
||||
};
|
||||
crate::mobs::camel::stand_up_instantly(&mut m);
|
||||
if sitting {
|
||||
m.extra.egg_time = -1 - 400;
|
||||
}
|
||||
m.extra.pose_change_tick = tick;
|
||||
m.extra.camel_sitting = tick < 0;
|
||||
// Camels are always tame (`Camel.register` in Horizon).
|
||||
m.extra.tame = true;
|
||||
}
|
||||
@@ -1357,6 +1633,13 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.color = number(tag.get("Color")).map_or(0, |c| (c as u8) & 15);
|
||||
m.extra.sheared = flag(tag, "Sheared");
|
||||
}
|
||||
// GlowSquid.readAdditionalSaveData: not dimmed when absent.
|
||||
if m.species() == crate::mobs::Species::GlowSquid {
|
||||
crate::mobs::aquatic::set_dark_ticks(
|
||||
&mut m,
|
||||
number(tag.get("DarkTicksRemaining")).unwrap_or(0) as i32,
|
||||
);
|
||||
}
|
||||
// IronGolem.readAdditionalSaveData: not player created when absent.
|
||||
if m.species() == crate::mobs::Species::IronGolem {
|
||||
m.extra.player_created = flag(tag, "PlayerCreated");
|
||||
@@ -2830,7 +3113,7 @@ mod axolotl_nbt_tests {
|
||||
assert_eq!(restored.health, 5.0);
|
||||
m.glowing = false;
|
||||
m.add_effect("glowing", 200, 0);
|
||||
assert!(m.has_effect("glowing"));
|
||||
assert!(m.has_effect("minecraft:glowing"));
|
||||
assert!(bucket_data(&m).get("Glowing").is_none());
|
||||
m.glowing = true;
|
||||
assert_eq!(number(bucket_data(&m).get("Glowing")), Some(1));
|
||||
@@ -3014,7 +3297,34 @@ mod sheep_nbt_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foxes_rabbits_and_goats_keep_their_kind() {
|
||||
fn a_baby_villager_keeps_its_age() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let types: HashMap<String, Arc<MobType>> = data
|
||||
.types
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let saved = Tag::compound([
|
||||
("id", string("minecraft:villager")),
|
||||
("Pos", doubles(&[0.5, 64.0, 0.5])),
|
||||
("Age", Tag::Int(-21359)),
|
||||
]);
|
||||
let m = mob_from_nbt(&saved, &types, 1).unwrap();
|
||||
assert_eq!(m.extra.age, -21359);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
|
||||
assert_eq!(number(tag.get("Age")), Some(-21359));
|
||||
assert_eq!(number(tag.get("AgeLocked")), Some(0));
|
||||
assert_eq!(number(tag.get("VillagerDataFinalized")), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foxes_mooshrooms_rabbits_and_goats_keep_their_kind() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
@@ -3038,6 +3348,17 @@ mod sheep_nbt_tests {
|
||||
assert_eq!(text(tag.get("Type")).as_deref(), Some("snow"));
|
||||
assert_eq!(number(tag.get("Sleeping")), Some(0));
|
||||
assert_eq!(back.extra.variant, 1);
|
||||
// MushroomCow: a brown mooshroom stays brown, and a save without (or
|
||||
// with an unknown) Type reads as red.
|
||||
let mut mooshroom = new("minecraft:mooshroom");
|
||||
mooshroom.extra.variant = 1;
|
||||
let (back, tag) = round_trip(&mooshroom);
|
||||
assert_eq!(text(tag.get("Type")).as_deref(), Some("brown"));
|
||||
assert_eq!(back.extra.variant, 1);
|
||||
mooshroom.extra.variant = 0;
|
||||
let (back, tag) = round_trip(&mooshroom);
|
||||
assert_eq!(text(tag.get("Type")).as_deref(), Some("red"));
|
||||
assert_eq!(back.extra.variant, 0);
|
||||
let mut rabbit = new("minecraft:rabbit");
|
||||
rabbit.extra.variant = 4;
|
||||
let (back, tag) = round_trip(&rabbit);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! FallingBlockEntity's impact damage, anvil wear, saved impact settings and
|
||||
//! the cobwebs that hold it back.
|
||||
//! the blocks it moves through (cobwebs, bubble columns).
|
||||
use crate::motion::BubbleColumn;
|
||||
use crate::{
|
||||
mobs::{Damage as MobDamage, DamageKind, Mob},
|
||||
save::{float, number, put},
|
||||
@@ -118,30 +119,76 @@ pub fn bounds(position: Vec3) -> Aabb {
|
||||
pub fn web_multiplier() -> Vec3 {
|
||||
Vec3::new(0.25, f64::from(0.05_f32), 0.25)
|
||||
}
|
||||
/// What a block a falling block moves through does to it in `entityInside`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Inside {
|
||||
/// `WebBlock`: `Entity.makeStuckInBlock`.
|
||||
Cobweb,
|
||||
/// `BubbleColumnBlock`: lifts or drags it.
|
||||
BubbleColumn(BubbleColumn),
|
||||
Nothing,
|
||||
}
|
||||
/// `Entity.applyEffectsFromBlocks` after a falling block's move this tick
|
||||
/// (`from` to `to`; `original` the move it asked for, when the move was
|
||||
/// recorded): whether `checkInsideBlocks` reaches a cobweb (`is_web`), whose
|
||||
/// `entityInside` makes it stuck. Each movement sweeps its axes in
|
||||
/// `Direction.axisStepOrder` within 16 iterations, then the end alone.
|
||||
pub fn passes_cobweb(
|
||||
/// recorded; `velocity` already zeroed on the axes `Entity.move` collided on):
|
||||
/// the blocks it went through (`block` says what each is) push it and reset
|
||||
/// its fall. Returns whether a cobweb made it stuck.
|
||||
pub fn apply_effects_from_blocks(
|
||||
from: Vec3,
|
||||
to: Vec3,
|
||||
original: Option<Vec3>,
|
||||
mut is_web: impl FnMut(BlockPos) -> bool,
|
||||
velocity: &mut Vec3,
|
||||
fall_distance: &mut f64,
|
||||
mut block: impl FnMut(BlockPos) -> Inside,
|
||||
) -> bool {
|
||||
let mut stuck = false;
|
||||
inside_blocks(from, to, original, |p, intersects| match block(p) {
|
||||
Inside::Cobweb => {
|
||||
// makeStuckInBlock forgets the fall.
|
||||
*fall_distance = 0.0;
|
||||
stuck = true;
|
||||
}
|
||||
// BubbleColumnBlock.entityInside acts only when intersecting.
|
||||
Inside::BubbleColumn(column) if intersects => {
|
||||
if column.push(velocity) {
|
||||
*fall_distance = 0.0;
|
||||
}
|
||||
}
|
||||
Inside::BubbleColumn(_) | Inside::Nothing => {}
|
||||
});
|
||||
stuck
|
||||
}
|
||||
/// Each block `checkInsideBlocks` reaches, once, in order, with
|
||||
/// whether its `entityInside` counts the block as intersected (every block of a
|
||||
/// movement a block or longer, else only those the box ends in). Each movement
|
||||
/// sweeps its axes in `Direction.axisStepOrder` within 16 iterations, then the
|
||||
/// end alone. The blocks the caller acts on (cobwebs, bubble columns) have a
|
||||
/// whole-block `getEntityInsideCollisionShape`, so every one visited counts.
|
||||
fn inside_blocks(
|
||||
from: Vec3,
|
||||
to: Vec3,
|
||||
original: Option<Vec3>,
|
||||
mut visit: impl FnMut(BlockPos, bool),
|
||||
) {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
// checkInsideBlocks(from, to, ..): the iterations used plus one.
|
||||
let mut check = |from: Vec3, to: Vec3, max: i32| {
|
||||
let d = f64::from(1.0e-5_f32);
|
||||
let target = bounds(to).deflate(Vec3::new(d, d, d));
|
||||
// The move spans a block or more: Mth.square(1.0 - 1.0E-5F).
|
||||
let long = from.distance_squared(to) > (1.0 - d) * (1.0 - d);
|
||||
let mut iterations = 0;
|
||||
let _ = sweep_intersected_blocks(from, to, target, 1_000_000, |p, iteration| {
|
||||
if iteration >= max {
|
||||
return false;
|
||||
}
|
||||
iterations = iteration;
|
||||
// getEntityInsideCollisionShape is the whole block for cobwebs.
|
||||
stuck |= is_web(p);
|
||||
if seen.insert(p) {
|
||||
let corner = Vec3::new(f64::from(p.x), f64::from(p.y), f64::from(p.z));
|
||||
let inside =
|
||||
target.intersects_corners(corner, corner.add_vector(Vec3::new(1.0, 1.0, 1.0)));
|
||||
visit(p, long || inside);
|
||||
}
|
||||
true
|
||||
});
|
||||
iterations + 1
|
||||
@@ -170,7 +217,6 @@ pub fn passes_cobweb(
|
||||
if budget <= 0 {
|
||||
check(to, to, 1);
|
||||
}
|
||||
stuck
|
||||
}
|
||||
pub fn hurt_mobs(mobs: &mut [Mob], position: Vec3, amount: f32) {
|
||||
hurt_mobs_with_kind(mobs, position, amount, DamageKind::FallingBlock);
|
||||
@@ -249,6 +295,23 @@ mod tests {
|
||||
assert!(!wear_anvil(&mut state));
|
||||
}
|
||||
}
|
||||
fn passes_cobweb(
|
||||
from: Vec3,
|
||||
to: Vec3,
|
||||
original: Option<Vec3>,
|
||||
is_web: impl Fn(BlockPos) -> bool,
|
||||
) -> bool {
|
||||
let (mut velocity, mut fall) = (Vec3::ZERO, 3.0);
|
||||
let stuck = apply_effects_from_blocks(from, to, original, &mut velocity, &mut fall, |p| {
|
||||
if is_web(p) {
|
||||
Inside::Cobweb
|
||||
} else {
|
||||
Inside::Nothing
|
||||
}
|
||||
});
|
||||
assert_eq!(fall == 0.0, stuck);
|
||||
stuck
|
||||
}
|
||||
#[test]
|
||||
fn a_cobweb_on_the_path_or_at_the_end_holds_a_falling_block() {
|
||||
let web = BlockPos::new(0, 5, 0);
|
||||
@@ -285,6 +348,50 @@ mod tests {
|
||||
assert!(!passes_cobweb(beside, beside, None, is_web));
|
||||
}
|
||||
#[test]
|
||||
fn bubble_columns_lift_or_drag_a_falling_block() {
|
||||
// Soul sand under x 0 lifts, magma under x 3 drags, both up to y 9.
|
||||
let block = |p: BlockPos| match (p.x, p.y, p.z) {
|
||||
(0 | 3, 0..=9, 0) => Inside::BubbleColumn(BubbleColumn {
|
||||
drag: p.x == 3,
|
||||
surface: p.y == 9,
|
||||
}),
|
||||
_ => Inside::Nothing,
|
||||
};
|
||||
let step = |x: f64, from: f64, to: f64, vy: f64| {
|
||||
let (from, to) = (Vec3::new(x, from, 0.5), Vec3::new(x, to, 0.5));
|
||||
let mut velocity = Vec3::new(0.0, vy, 0.0);
|
||||
let mut fall = 2.0;
|
||||
let original = Some(to.subtract(from));
|
||||
let stuck =
|
||||
apply_effects_from_blocks(from, to, original, &mut velocity, &mut fall, block);
|
||||
assert!(!stuck);
|
||||
(velocity.y, fall)
|
||||
};
|
||||
let near = |a: f64, b: f64| (a - b).abs() < 1.0e-9;
|
||||
// Released in the column: its box ends in blocks 5 and 6, each lifts
|
||||
// it by 0.06 (handleOnInsideBubbleColumn), and its fall is forgotten.
|
||||
let (vy, fall) = step(0.5, 5.5, 5.46, -0.04);
|
||||
assert!(near(vy, 0.08), "{vy}");
|
||||
assert_eq!(fall, 0.0);
|
||||
// The lift is capped at 0.7.
|
||||
assert!(near(step(0.5, 5.0, 5.3, 0.68).0, 0.7));
|
||||
// Falling a block or more into the surface block: every column
|
||||
// block on the way counts, the surface throws it up by 0.1
|
||||
// (handleOnAboveBubbleColumn), which keeps its fall.
|
||||
let (vy, fall) = step(0.5, 11.0, 9.5, -1.5);
|
||||
assert!(near(vy, -1.5 + 0.1), "{vy}");
|
||||
assert_eq!(fall, 2.0);
|
||||
// Magma drags down by 0.03, at most to -0.3 inside and -0.9 at the surface.
|
||||
assert!(near(step(3.5, 5.5, 5.4, -0.1).0, -0.16));
|
||||
assert!(near(step(3.5, 5.5, 5.4, -0.29).0, -0.3));
|
||||
assert!(near(step(3.5, 9.8, 9.6, -0.89).0, -0.9));
|
||||
// A short move that leaves the column behind: the block it went
|
||||
// through no longer intersects its box, so nothing pushes it.
|
||||
assert_eq!(step(0.5, 9.95, 10.5, 0.55), (0.55, 2.0));
|
||||
// Beside the column.
|
||||
assert_eq!(step(1.5, 5.5, 5.46, -0.04), (-0.04, 2.0));
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "requires prepared entity models/data in HORIZON_TEST_RESOURCES"]
|
||||
fn impact_hurts_only_living_intersecting_noninvulnerable_mobs() {
|
||||
use crate::mobs::{Mob, MobTypes};
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
//! FlatLevelSource: superflat worlds (`minecraft:flat` generator).
|
||||
use crate::{chunk::Chunk, streaming::ChunkGenerator};
|
||||
use crate::{
|
||||
chunk::{Chunk, LevelShape},
|
||||
streaming::ChunkGenerator,
|
||||
};
|
||||
use horizon_world::{nbt::Tag, registry::BlockRegistry};
|
||||
|
||||
/// The layers of a flat generator, bottom up from the world's minimum Y.
|
||||
pub struct FlatTerrain {
|
||||
/// The Overworld's shape: the layers start at its minimum Y and stop at
|
||||
/// its top.
|
||||
shape: LevelShape,
|
||||
layers: Vec<u32>,
|
||||
biome: u16,
|
||||
surface: Option<crate::lod::Surface>,
|
||||
@@ -16,6 +22,7 @@ impl FlatTerrain {
|
||||
settings: &Tag,
|
||||
registry: &BlockRegistry,
|
||||
biomes: &crate::biome::Biomes,
|
||||
shape: LevelShape,
|
||||
) -> Option<Self> {
|
||||
let generator = settings
|
||||
.get("dimensions")?
|
||||
@@ -38,12 +45,7 @@ impl FlatTerrain {
|
||||
let biome = biomes.id(&biome);
|
||||
let mut water = None;
|
||||
let mut surface = None;
|
||||
for (i, &id) in layers
|
||||
.iter()
|
||||
.take(crate::chunk::HEIGHT as usize)
|
||||
.enumerate()
|
||||
.rev()
|
||||
{
|
||||
for (i, &id) in layers.iter().take(shape.height as usize).enumerate().rev() {
|
||||
let state = horizon_world::registry::BlockStateId(id);
|
||||
let name = registry.state(state)?.name.as_str();
|
||||
if matches!(
|
||||
@@ -52,7 +54,7 @@ impl FlatTerrain {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let height = crate::chunk::MIN_Y + i as i32 + 1;
|
||||
let height = shape.min_y + i as i32 + 1;
|
||||
if matches!(name, "minecraft:water" | "minecraft:lava") {
|
||||
water.get_or_insert((height, state));
|
||||
} else {
|
||||
@@ -66,6 +68,7 @@ impl FlatTerrain {
|
||||
}
|
||||
}
|
||||
Some(Self {
|
||||
shape,
|
||||
layers,
|
||||
biome,
|
||||
surface,
|
||||
@@ -73,7 +76,7 @@ impl FlatTerrain {
|
||||
}
|
||||
/// FlatLevelSource.getSpawnHeight: the top of the layers.
|
||||
pub fn spawn_height(&self) -> i32 {
|
||||
crate::chunk::MIN_Y + self.layers.len().min(crate::chunk::HEIGHT as usize) as i32
|
||||
self.shape.min_y + self.layers.len().min(self.shape.height as usize) as i32
|
||||
}
|
||||
}
|
||||
/// DimensionType.Y_SIZE: the most layers FlatLevelGeneratorSettings takes.
|
||||
@@ -190,20 +193,21 @@ impl ChunkGenerator for FlatTerrain {
|
||||
self.surface
|
||||
}
|
||||
fn proto(&self, _: &mut (), cx: i32, cz: i32) -> crate::streaming::Proto {
|
||||
let mut chunk = Chunk::empty(cx, cz);
|
||||
let mut chunk = Chunk::with_layout(cx, cz, self.shape.column());
|
||||
for s in &mut chunk.sections {
|
||||
std::sync::Arc::make_mut(s).biomes = horizon_core::paletted::PalettedContainer::new(
|
||||
crate::chunk::BIOME_STRATEGY,
|
||||
u32::from(self.biome),
|
||||
);
|
||||
}
|
||||
for (i, &block) in self.layers.iter().enumerate().take(384) {
|
||||
let height = self.shape.height as usize;
|
||||
for (i, &block) in self.layers.iter().enumerate().take(height) {
|
||||
if block == 0 {
|
||||
continue;
|
||||
}
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
chunk.set(x, -64 + i as i32, z, block);
|
||||
chunk.set(x, self.shape.min_y + i as i32, z, block);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+770
-311
File diff suppressed because it is too large.
Load diff
@@ -115,10 +115,18 @@ pub(super) struct AdvState {
|
||||
pub(super) announced: Vec<Text>,
|
||||
/// The tree being read on another thread since the world opened
|
||||
/// (`prefetch_advancement_tree`), taken by the first `advancement_tree`.
|
||||
pub(super) pending_tree: Option<std::thread::JoinHandle<Tree>>,
|
||||
pub(super) pending_tree: Option<std::thread::JoinHandle<Arc<Tree>>>,
|
||||
/// `inventory_changed`'s criteria by item, built for `tree`.
|
||||
inventory_index: Option<(Arc<Tree>, Arc<adv::InventoryIndex>)>,
|
||||
}
|
||||
impl AdvState {
|
||||
/// Drop the prefetched tree and the index built for the old one (a
|
||||
/// `/reload` rebuilt the tree).
|
||||
pub(super) fn reset_tree(&mut self) {
|
||||
self.pending_tree = None;
|
||||
self.inventory_index = None;
|
||||
}
|
||||
}
|
||||
/// The game's world for predicates.
|
||||
struct View<'a, 'g>(&'a Game<'g>);
|
||||
impl adv::World for View<'_, '_> {
|
||||
@@ -261,9 +269,12 @@ impl Game<'_> {
|
||||
if self.adv.tree.list.is_empty() {
|
||||
let tree = match self.adv.pending_tree.take().map(|h| h.join()) {
|
||||
Some(Ok(tree)) => tree,
|
||||
_ => Tree::load(&self.options.resources.join("data")),
|
||||
_ => {
|
||||
let overlay = self.pack_files();
|
||||
Tree::shared(&self.options.resources.join("data"), &overlay)
|
||||
}
|
||||
};
|
||||
self.adv.tree = Arc::new(tree);
|
||||
self.adv.tree = tree;
|
||||
// With the tree, while the world loads: built on the first
|
||||
// inventory change it cost that tick ~10 ms.
|
||||
self.inventory_index();
|
||||
@@ -278,7 +289,7 @@ impl Game<'_> {
|
||||
{
|
||||
return index.clone();
|
||||
}
|
||||
let items = self.res.items();
|
||||
let items = self.items();
|
||||
let index = Arc::new(adv::InventoryIndex::build(&tree, |tag| {
|
||||
items
|
||||
.item_tags
|
||||
@@ -297,9 +308,15 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let data = self.options.resources.join("data");
|
||||
// The world's packs' advancements go over the game's own.
|
||||
let files = super::functions::content_overlay(
|
||||
&self.world_root,
|
||||
&self.options.resources,
|
||||
self.cmds.vanilla_disabled,
|
||||
);
|
||||
self.adv.pending_tree = std::thread::Builder::new()
|
||||
.name("horizon-advancements".into())
|
||||
.spawn(move || Tree::load(&data))
|
||||
.spawn(move || Tree::shared(&data, &horizon_world::loot::PackOverlay { files: &files }))
|
||||
.ok();
|
||||
}
|
||||
pub(super) fn load_advancements(&mut self) {
|
||||
@@ -537,13 +554,13 @@ impl Game<'_> {
|
||||
.rewards
|
||||
.recipes
|
||||
.iter()
|
||||
.filter(|r| self.res.stations().recipe_index.contains(r))
|
||||
.filter(|r| world_stations!(self).recipe_index.contains(r))
|
||||
.cloned()
|
||||
.collect();
|
||||
let (_, unlocked) = self
|
||||
.level
|
||||
.recipe_book
|
||||
.add_recipes(&self.res.stations().recipe_index, &rewards);
|
||||
.add_recipes(&world_stations!(self).recipe_index, &rewards);
|
||||
for table in &a.rewards.loot {
|
||||
let items = self
|
||||
.res
|
||||
@@ -1089,6 +1106,31 @@ impl Game<'_> {
|
||||
let it = item_view(stack);
|
||||
self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w));
|
||||
}
|
||||
/// `ServerPlayer.onItemPickup`: the player in place (the host, or the
|
||||
/// guest `as_guest` runs) picked up an item entity whose owner
|
||||
/// (`ItemEntity.getOwner`, the thrower) is `thrower`: the whole stack, or
|
||||
/// what is left after a partial take. An owner that is that player also
|
||||
/// fires `LivingEntity.onItemPickup` (the by-entity trigger); one that is
|
||||
/// gone, or another player, fires nothing here.
|
||||
pub(super) fn trigger_thrown_picked_up_by_player(
|
||||
&mut self,
|
||||
stack: &ItemStack,
|
||||
thrower: [i32; 4],
|
||||
) {
|
||||
let target = if thrower == self.acting_uuid() {
|
||||
let me = self.player_entity();
|
||||
self.trigger_item_entity("minecraft:thrown_item_picked_up_by_entity", stack, &me);
|
||||
me
|
||||
} else if let Some(id) = self.mob_by_uuid(thrower) {
|
||||
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
self.mob_entity(m)
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
self.trigger_item_entity("minecraft:thrown_item_picked_up_by_player", stack, &target);
|
||||
}
|
||||
/// `ConstructBeaconTrigger`: the pyramid levels of the new beacon.
|
||||
pub(super) fn trigger_construct_beacon(&mut self, levels: i32) {
|
||||
let me = self.player_entity();
|
||||
|
||||
@@ -7,7 +7,6 @@ use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
block_entity_anim::Ticker,
|
||||
chunk::{MIN_Y, SECTIONS},
|
||||
items::ItemStack,
|
||||
mobs::{
|
||||
Species,
|
||||
@@ -486,6 +485,8 @@ impl Game<'_> {
|
||||
// LivingEntity.deathTime while dead (the camera's death roll).
|
||||
"death_time": self.death_time(),
|
||||
"air": p.air,
|
||||
// LocalPlayer.portalEffectIntensity: the Nether portal overlay.
|
||||
"portal_effect": self.screen_fx.portal,
|
||||
"food": p.health.food.food,
|
||||
"saturation": p.health.food.saturation,
|
||||
"shield_cooldown": self.shield_cooldown,
|
||||
@@ -598,7 +599,7 @@ impl Game<'_> {
|
||||
Mode::Delete { world } => {
|
||||
json!({ "mode": "delete-world", "folder": world.folder, "name": world.name })
|
||||
}
|
||||
Mode::BackupBeforeUpgrade { world } => {
|
||||
Mode::BackupBeforeUpgrade { world, .. } => {
|
||||
json!({ "mode": "backup-before-upgrade", "folder": world.folder, "name": world.name })
|
||||
}
|
||||
Mode::Error { message, .. } => {
|
||||
@@ -1086,7 +1087,9 @@ impl Game<'_> {
|
||||
.world
|
||||
.chunks
|
||||
.get(&(x >> 4, z >> 4))
|
||||
.filter(|_| (MIN_Y..MIN_Y + SECTIONS as i32 * 16).contains(&y))
|
||||
.filter(|_| {
|
||||
(self.world.bottom_y()..self.world.top_y()).contains(&y)
|
||||
})
|
||||
.map(|c| c.light(x & 15, y, z & 15));
|
||||
let digit = |v: u8| char::from_digit(u32::from(v), 16).unwrap_or('.');
|
||||
sky.push(packed.map_or('.', |v| digit(v >> 4)));
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
//! throwing their load to the player or note block, dancing to jukeboxes,
|
||||
//! and the hand-over interaction (`Allay.mobInteract`).
|
||||
use super::Game;
|
||||
use horizon_client::{containers::BlockEntity, items::ItemStack, mobs::Species};
|
||||
use horizon_client::{
|
||||
containers::BlockEntity,
|
||||
items::ItemStack,
|
||||
mobs::{Species, allay},
|
||||
};
|
||||
use horizon_core::random::RandomSource;
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -131,14 +135,20 @@ impl Game<'_> {
|
||||
if m.extra.ints[1] <= 0 {
|
||||
return;
|
||||
}
|
||||
// AllayAi.getItemDepositPosition: the liked note block, else the player.
|
||||
let dimension = self.dim.id();
|
||||
let noteblock = allay::noteblock_deposit(&mut self.mobs[i], dimension, |p| {
|
||||
&*self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(p[0], p[1], p[2]))
|
||||
.name
|
||||
== "minecraft:note_block"
|
||||
});
|
||||
let player = self.allay_liked_player(i);
|
||||
let m = &self.mobs[i];
|
||||
let target = match m.extra.home.filter(|_| m.extra.ints[2] > 0) {
|
||||
Some(h) => horizon_core::geometry::Vec3::new(
|
||||
f64::from(h[0]) + 0.5,
|
||||
f64::from(h[1]) + 1.0,
|
||||
f64::from(h[2]) + 0.5,
|
||||
),
|
||||
let target = match noteblock {
|
||||
Some(p) => horizon_core::geometry::Vec3::new(p[0], p[1], p[2]),
|
||||
None => match player.filter(|_| m.extra.tame) {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
@@ -181,6 +191,11 @@ impl Game<'_> {
|
||||
m.extra.ints[3] = 60;
|
||||
// ItemEntity.setDefaultPickUpDelay.
|
||||
self.spawn_item(ItemStack::new(&item, 1), from, v, 10);
|
||||
// BehaviorUtils.throwItem: ItemEntity.setThrower(the allay).
|
||||
let allay = self.mob_uuid(self.mobs[i].id);
|
||||
if let Some(thrown) = self.dropped.last_mut() {
|
||||
thrown.thrower = Some(allay);
|
||||
}
|
||||
if self.world.game_time % 7 == 0 && self.world.random.next_double() < 0.9 {
|
||||
const PITCHES: [f32; 16] = [
|
||||
0.5625, 0.625, 0.75, 0.9375, 1.0, 1.0, 1.125, 1.25, 1.5, 1.875, 2.0, 2.25, 2.5,
|
||||
|
||||
@@ -65,8 +65,13 @@ impl ArmorStandAssets {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(part) = load("minecraft:elytra#main") {
|
||||
player.insert("elytra", part);
|
||||
for (key, name) in [
|
||||
("minecraft:elytra#main", "elytra"),
|
||||
("minecraft:elytra_baby#main", "elytra_baby"),
|
||||
] {
|
||||
if let Some(part) = load(key) {
|
||||
player.insert(name, part);
|
||||
}
|
||||
}
|
||||
let mut equipment = HashMap::new();
|
||||
let mut trim_overrides = HashMap::new();
|
||||
@@ -132,16 +137,16 @@ impl ArmorStandAssets {
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `HumanoidArmorLayer` for the player: (model, texture rect, colour) of
|
||||
/// each worn piece, head to feet.
|
||||
pub(super) fn player_armor_layers(&self) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
/// `HumanoidArmorLayer` for the player: (model, texture rect, colour,
|
||||
/// overlay) of each worn piece, head to feet.
|
||||
pub(super) fn player_armor_layers(&self) -> Vec<(&Part, [f32; 4], u32, u32)> {
|
||||
self.stack_armor_layers(&self.armor)
|
||||
}
|
||||
/// `HumanoidArmorLayer` for worn stacks (head to feet), trims included.
|
||||
pub(super) fn stack_armor_layers(
|
||||
&self,
|
||||
armor: &[Option<ItemStack>; 4],
|
||||
) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
) -> Vec<(&Part, [f32; 4], u32, u32)> {
|
||||
self.armor_layers(
|
||||
armor
|
||||
.each_ref()
|
||||
@@ -150,7 +155,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `HumanoidArmorLayer` for another player's model: the armour it wears
|
||||
/// (`extra.head` .. `extra.feet`).
|
||||
pub(super) fn worn_armor_layers(&self, m: &Mob) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
pub(super) fn worn_armor_layers(&self, m: &Mob) -> Vec<(&Part, [f32; 4], u32, u32)> {
|
||||
let e = &m.extra;
|
||||
self.armor_layers(
|
||||
[
|
||||
@@ -167,7 +172,7 @@ impl Game<'_> {
|
||||
fn armor_layers(
|
||||
&self,
|
||||
worn: [Option<(&str, Option<&ItemStack>)>; 4],
|
||||
) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
) -> Vec<(&Part, [f32; 4], u32, u32)> {
|
||||
let assets = &self.armor_stand_assets;
|
||||
let mut out = Vec::new();
|
||||
for (item, layer) in worn
|
||||
@@ -201,13 +206,27 @@ impl Game<'_> {
|
||||
continue;
|
||||
};
|
||||
let color = layer_color(*undyed, stack);
|
||||
out.push((part, [r.u0, r.v0, r.u1, r.v1], color));
|
||||
// EquipmentLayerRenderer.renderLayers: NO_OVERLAY, the armour
|
||||
// glint on the layer and its trim when `stack.hasFoil()`.
|
||||
let item_glint = self
|
||||
.res
|
||||
.items()
|
||||
.enchantments
|
||||
.props
|
||||
.get(item)
|
||||
.is_some_and(|p| p.glint);
|
||||
let overlay = if stack.is_some_and(|s| s.has_foil(item_glint)) {
|
||||
horizon_client::mobs::render::ARMOR_GLINT
|
||||
} else {
|
||||
0
|
||||
};
|
||||
out.push((part, [r.u0, r.v0, r.u1, r.v1], color, overlay));
|
||||
// EquipmentLayerRenderer: the trim layer over the armour.
|
||||
if let Some(r) = stack
|
||||
.and_then(|s| assets.trim_texture(&asset, kind, s))
|
||||
.and_then(|t| self.gui.atlas.sprites.get(&t.sprite()))
|
||||
{
|
||||
out.push((part, [r.u0, r.v0, r.u1, r.v1], 0xFFFF_FFFF));
|
||||
out.push((part, [r.u0, r.v0, r.u1, r.v1], 0xFFFF_FFFF, overlay));
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -219,7 +238,7 @@ impl Game<'_> {
|
||||
pub(super) fn prepare_trims(&mut self) {
|
||||
use horizon_client::hud::TRIM_SLOTS;
|
||||
let assets = &self.armor_stand_assets;
|
||||
let items = self.res.items();
|
||||
let items = self.items();
|
||||
let mut wanted: Vec<TrimTexture> = Vec::new();
|
||||
let guests = self.lan.iter().flat_map(|host| host.hub.guests.iter());
|
||||
let stacks = self
|
||||
@@ -315,9 +334,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `ArmorStandItem.useOn`.
|
||||
pub(super) fn place_armor_stand(&mut self, hit: &super::Hit, stack: &ItemStack) -> bool {
|
||||
if &*stack.item != "minecraft:armor_stand" || hit.face == [0, -1, 0] {
|
||||
return false;
|
||||
/// `ArmorStandItem.useOn` up to its `FAIL`s, which a client checks too:
|
||||
/// not on a bottom face, and nothing in the new stand's box. The block it
|
||||
/// stands in and the stand.
|
||||
pub(super) fn armor_stand_spawn(&self, hit: &super::Hit) -> Option<([i32; 3], ArmorStand)> {
|
||||
if hit.face == [0, -1, 0] {
|
||||
return None;
|
||||
}
|
||||
// BlockPlaceContext: a replaceable clicked block is replaced.
|
||||
let clicked = self
|
||||
@@ -356,8 +378,18 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.any(|o| overlaps(o.bounds(), bounds))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some((pos, stand))
|
||||
}
|
||||
pub(super) fn place_armor_stand(&mut self, hit: &super::Hit, stack: &ItemStack) -> bool {
|
||||
if &*stack.item != "minecraft:armor_stand" {
|
||||
return false;
|
||||
}
|
||||
let Some((pos, stand)) = self.armor_stand_spawn(hit) else {
|
||||
return false;
|
||||
};
|
||||
let foot = stand.position;
|
||||
self.next_entity += 1;
|
||||
self.audio.play(
|
||||
"minecraft:entity.armor_stand.place",
|
||||
@@ -370,6 +402,8 @@ impl Game<'_> {
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
// ArmorStandItem.useOn returns SUCCESS: the hand swings.
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
/// The armor stand under the crosshair, its distance and the hit point.
|
||||
@@ -790,6 +824,19 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
// WingsLayer: ArmorStandRenderer adds it after the armour layers. A stand
|
||||
// never crouches or glides, so its wings rest folded.
|
||||
self.wings_geometry(
|
||||
a.equipment[4].as_ref().map(|s| &*s.item),
|
||||
None,
|
||||
a.small,
|
||||
base,
|
||||
horizon_client::mobs::ElytraAnimation::RESTING,
|
||||
false,
|
||||
packed,
|
||||
self.wings_overlay(a.equipment[4].as_ref()),
|
||||
out,
|
||||
);
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
}
|
||||
}
|
||||
@@ -834,7 +881,7 @@ impl Game<'_> {
|
||||
/// `Equippable.swapWithEquipmentSlot`: right-clicking armor swaps it into
|
||||
/// its slot (the worn piece comes back to the hand).
|
||||
pub(super) fn equip_from_hand(&mut self, stack: &ItemStack) -> bool {
|
||||
let Some(info) = self.res.items().get(&stack.item) else {
|
||||
let Some(info) = self.items().get(&stack.item) else {
|
||||
return false;
|
||||
};
|
||||
let (index, slot) = match info.equip_slot.as_deref() {
|
||||
|
||||
@@ -14,7 +14,7 @@ impl Game<'_> {
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
layer_textures(&self.res.stations().banners, &b.layers)
|
||||
layer_textures(&self.stations().banners, &b.layers)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if b.last_check_y >= surface {
|
||||
b.last_check_y = super::MIN_Y - 1;
|
||||
b.last_check_y = self.world.bottom_y() - 1;
|
||||
b.sections = std::mem::take(&mut b.checking);
|
||||
let (was, is) = (previous > 0, b.levels > 0);
|
||||
if !was && is {
|
||||
@@ -231,7 +231,7 @@ impl Game<'_> {
|
||||
let mut levels = 0;
|
||||
for step in 1..=4 {
|
||||
let ly = y - step;
|
||||
if ly < super::MIN_Y {
|
||||
if ly < self.world.bottom_y() {
|
||||
break;
|
||||
}
|
||||
let ok = (x - step..=x + step).all(|lx| {
|
||||
|
||||
@@ -67,6 +67,16 @@ impl Game<'_> {
|
||||
for head in left {
|
||||
self.player_beds.retain(|b| *b != (dim, head));
|
||||
self.set_bed_occupied(head, false);
|
||||
// AbstractBedBlock.onStopSleeping: the Overworld's straw_bed_rule
|
||||
// has destroy_on_leave, so a straw bed breaks when its sleeper gets up.
|
||||
let bed = self.world.block(head[0], head[1], head[2]);
|
||||
let straw = &*self.res.facts().get(bed).name == "minecraft:straw_bed";
|
||||
if dim == Dim::Overworld
|
||||
&& straw
|
||||
&& let Err(e) = self.destroy_straw_bed(head)
|
||||
{
|
||||
log::warn!("could not break the straw bed at {head:?}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,10 @@ impl Game<'_> {
|
||||
pub(super) fn block_entity_event(&mut self, pos: [i32; 3], a: i32, b: i32) {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]).0;
|
||||
let time = self.world.game_time;
|
||||
// PotentSulfurBlock.triggerEvent.
|
||||
if self.potent_sulfur_eruption_event((pos[0], pos[1], pos[2])) {
|
||||
return;
|
||||
}
|
||||
match self.be_anims.ticker(state) {
|
||||
Some(Ticker::Lid) => {
|
||||
self.be_anims
|
||||
@@ -457,7 +461,7 @@ impl Game<'_> {
|
||||
if is_conduit(section.get(i)) {
|
||||
found.push([
|
||||
cx * 16 + (i & 15) as i32,
|
||||
horizon_client::chunk::MIN_Y + si as i32 * 16 + (i >> 8) as i32,
|
||||
self.world.bottom_y() + si as i32 * 16 + (i >> 8) as i32,
|
||||
cz * 16 + ((i >> 4) & 15) as i32,
|
||||
]);
|
||||
}
|
||||
@@ -586,6 +590,11 @@ impl Game<'_> {
|
||||
at.z.floor() as i32,
|
||||
);
|
||||
let up = BlockPos::new(p.x, top.floor() as i32, p.z);
|
||||
// A server's client has no live biome features to ask: it answers from
|
||||
// the chunks and the weather it was sent.
|
||||
if self.remote.is_some() {
|
||||
return self.raining_at(p) || self.raining_at(up);
|
||||
}
|
||||
self.with_level(|b, level, _| b.is_raining_at(level, p) || b.is_raining_at(level, up))
|
||||
}
|
||||
/// `ConduitBlockEntity.updateAndAttackTarget`: while the conduit hunts
|
||||
@@ -782,6 +791,7 @@ impl Game<'_> {
|
||||
let mut downloads = self.skin_downloads.borrow_mut();
|
||||
if self.gui.atlas.sprites.contains_key(&location) {
|
||||
let _ = downloads.state(skins::Kind::Skin, &url);
|
||||
downloads.drawn(&location);
|
||||
return Some(location);
|
||||
}
|
||||
// Its slot was evicted: offer the image again.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! `Level.tickBlockEntities` for the block entities ticked together here
|
||||
//! (hoppers, crafters, jukeboxes, beacons, creaking hearts, spawners, trial
|
||||
//! spawners, vaults, beehives and furnaces): only those whose state has a
|
||||
//! spawners, vaults, beehives, furnaces and potent sulfur): only those whose state has a
|
||||
//! ticker, in vanilla's list order (`horizon_client::block_entities`), in
|
||||
//! chunks that tick blocks. Sculk, brewing stands and pistons tick in their
|
||||
//! own passes.
|
||||
@@ -64,6 +64,14 @@ impl Game<'_> {
|
||||
Some(BlockEntity::Brewing(_)) => self.tick_brewing_stand(pos)?,
|
||||
Some(BlockEntity::DaylightDetector) => self.tick_daylight_detector(pos),
|
||||
Some(BlockEntity::Campfire(_)) => self.tick_campfire(pos)?,
|
||||
Some(BlockEntity::PotentSulfur(_)) => {
|
||||
self.tick_potent_sulfur(pos)?;
|
||||
// A window that simulates the world itself is its own
|
||||
// client; a server's clients tick it on their own.
|
||||
if self.options.execution_role.is_client() {
|
||||
self.client_tick_potent_sulfur(pos);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -105,6 +113,14 @@ impl Game<'_> {
|
||||
// Lit, cookTick; unlit, cooldownTick.
|
||||
| "minecraft:campfire"
|
||||
| "minecraft:soul_campfire" => Ticking::Yes,
|
||||
// Every state but dry has a ticker.
|
||||
"minecraft:potent_sulfur" => {
|
||||
let dry = registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("potent_sulfur_state"))
|
||||
.is_some_and(|v| v == "dry");
|
||||
if dry { Ticking::Idle } else { Ticking::Yes }
|
||||
}
|
||||
"minecraft:jukebox" => {
|
||||
let record = registry
|
||||
.state(state)
|
||||
@@ -141,7 +157,11 @@ impl Game<'_> {
|
||||
let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get_mut(&pos) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(lit) = f.tick(self.res.recipes(), self.res.items(), self.res.providers()) else {
|
||||
let Some(lit) = f.tick(
|
||||
world_recipes!(self),
|
||||
world_items!(self),
|
||||
self.res.providers(),
|
||||
) else {
|
||||
return Ok(());
|
||||
};
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
|
||||
@@ -601,9 +601,10 @@ impl Game<'_> {
|
||||
self.dismount(i);
|
||||
return Ok(());
|
||||
}
|
||||
let mut b = self.boats[i].clone();
|
||||
// The boat steers with the input `LocalPlayer.rideTick` latched on the
|
||||
// previous tick; this tick's keys are latched after it ticked.
|
||||
let mut b = self.tick_boat(self.boats[i].clone(), true);
|
||||
b.input = [keys.left, keys.right, keys.forward, keys.backward];
|
||||
let b = self.tick_boat(b, true);
|
||||
// positionRider: the seat, turned with the boat; clampRotation to 105 degrees.
|
||||
self.yaw += b.delta_rotation;
|
||||
let delta = (self.yaw - b.yaw + 180.0).rem_euclid(360.0) - 180.0;
|
||||
|
||||
Loaded 100 of 464 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user