Merge remote-tracking branch 'origin/main' into fix-6252

# Conflicts:
#	crates/horizon-client/src/game/explosion.rs
This commit is contained in:
Ludovic Marie committed 2026-11-01 20:41:08 +01:00
commit ea514b4c6b
185 files changed
+4335 -691

No files matched your search

+1 -1
View File
@@ -150,7 +150,7 @@ jobs:
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Every program the release folder takes was built
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
+16 -9
View File
@@ -40,7 +40,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| Crate | Owns |
|---|---|
| `horizon-client` | The game: a lib (`src/lib.rs`, ~100 flat modules: gameplay, networking, title screens) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-client` | Gameplay library (`src/lib.rs`: `game.rs` + `game/`, `entry.rs`) and thin executable (`src/main.rs`). Also the dedicated server (`--dedicated`). Re-exports `horizon-client-lib` for existing mod paths. |
| `horizon-client-lib` | Reusable client systems, `horizon-ctl`, probes and their tests. Its manifest points to the existing sources under `crates/horizon-client/`; the library root is `src/client_lib.rs`. It never depends on gameplay. |
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
@@ -85,8 +86,8 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
```sh
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
python3 tools/pr_ready.py --quick # same without harnesses
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
cargo test -p horizon-client --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo test -p horizon-client -p horizon-client-lib --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
cargo test -p horizon-client-lib --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
@@ -103,8 +104,11 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
or the game binary with a fixture env var (`game/dispense_fixture.rs`, `game/sculk_fixture.rs`,
`game/command_harness.rs`, `game/net_harness.rs`). Copy a nearby harness for a new one.
- Probes build into `.local/parity-target/`; harnesses find binaries via `lab.cargo_target()`, never `target/`.
- What a change reruns (of 329 harnesses, measured 2026-10-23): a file of the game binary (`game.rs`, `game/`)
~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it.
- Harness selection uses Cargo dep-info and reachable-code narrowing (`tools/parity_reach.py`).
`horizon-client-lib` builds the control tool and client probes without gameplay; their dep-info
excludes `game.rs` and `game/`, so gameplay edits do not rebuild or select those probes.
Source paths remain in `crates/horizon-client/`. Measure actual harness selection on refreshed
records with `tools/parity.py --changed-files paths.txt --list`; historical counts are not a guarantee.
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
@@ -129,8 +133,11 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
## horizon-client: where things live
`L:` = lib (`crates/horizon-client/src/`, or `crates/horizon-client-render/src/` for the modules that crate holds, under
the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/src/game/`). The lib can't see
`Game`; bin code reaches the lib as `horizon_client::<module>`.
the same `horizon_client::<module>` paths), `G:` = gameplay (`crates/horizon-client/src/game/`), also in the lib in normal builds.
Gameplay keeps its `horizon_client::<module>` imports through a self-crate alias. The executable
contains only the allocator, mod API marker and a call to `entry::main`. For unit tests, the lib
excludes `entry` and `game`, and the executable test target owns `game` so its process-wide test
state stays separate from the library tests. This compiles gameplay twice for executable tests.
| Subsystem | Files |
|---|---|
@@ -152,7 +159,7 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs` |
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`; `horizon-client-core/src/lang.rs`; crate `horizon-assets`; G: `packs.rs` |
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` |
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
@@ -161,7 +168,7 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
- `struct Game` (game.rs, ~290 fields) holds everything. Its methods are spread over ~180 files: game.rs keeps
the core (tick, render, input, inventory, world open/close) and each `game/<feature>.rs` adds an
`impl Game<'_>` block for one feature, with its own state types. Fields are private to the bin.
`impl Game<'_>` block for one feature, with its own state types. Fields stay private to the gameplay module and its descendants. Gameplay tests run in the gameplay library, separate from reusable-library tests.
- Find a feature's code: `grep -rn "fn tick_<thing>\|fn <verb>_<thing>" crates/horizon-client/src/game*`;
the file is usually `game/<thing>s.rs`. Who calls it: grep the name in game.rs's `fn tick` / `fn tick_world`.
- Fields that say which mode runs: `remote` (this window is a client of a server: multiplayer, LAN guest, or
Generated
+57
View File
@@ -1314,6 +1314,7 @@ dependencies = [
"horizon-chat-filter",
"horizon-client-core",
"horizon-client-input",
"horizon-client-lib",
"horizon-client-render",
"horizon-command",
"horizon-core",
@@ -1374,6 +1375,62 @@ dependencies = [
"winit",
]
[[package]]
name = "horizon-client-lib"
version = "0.1.0"
dependencies = [
"ab_glyph",
"bytemuck",
"chrono",
"flate2",
"fs4",
"gilrs",
"glam",
"horizon-anticheat",
"horizon-assets",
"horizon-bench",
"horizon-chat-filter",
"horizon-client-core",
"horizon-client-input",
"horizon-client-render",
"horizon-command",
"horizon-core",
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-items",
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
"horizon-protocol",
"horizon-server-files",
"horizon-shader-api",
"horizon-shaders",
"horizon-telemetry",
"horizon-theme",
"horizon-threads",
"horizon-world",
"log",
"miniz_oxide 0.8.9",
"naga",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"open",
"png",
"pollster",
"rustix 1.1.5",
"serde",
"serde_json",
"sha1",
"sha2",
"unicode-bidi",
"ureq",
"wgpu",
"winit",
"zip",
]
[[package]]
name = "horizon-client-render"
version = "0.1.0"
+1
View File
@@ -8,6 +8,7 @@ members = [
"crates/horizon-hdr",
"crates/horizon-world",
"crates/horizon-client",
"crates/horizon-client-lib",
"crates/horizon-client-core",
"crates/horizon-client-input",
"crates/horizon-client-render",
+2 -4
View File
@@ -202,10 +202,8 @@ fn os_version() -> Option<String> {
#[cfg(target_os = "linux")]
fn cpu() -> Option<String> {
let info = std::fs::read_to_string("/proc/cpuinfo").ok()?;
// x86 names the CPU per core; ARM boards name the machine once.
key_value(&info, "model name", ':')
.or_else(|| key_value(&info, "Model", ':'))
.map(str::to_owned)
// x86 names the CPU per core; arm64 only gives the core's implementer and part.
horizon_telemetry::system::linux_cpu_name(&info)
}
#[cfg(target_os = "linux")]
+221
View File
@@ -0,0 +1,221 @@
[package]
name = "horizon-client-lib"
autobins = false
autoexamples = false
autotests = false
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
description = "Reusable client systems, control tool and parity probes"
[lib]
path = "../horizon-client/src/client_lib.rs"
[dependencies]
horizon-assets = { path = "../horizon-assets" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
horizon-client-render = { path = "../horizon-client-render" }
horizon-command = { path = "../horizon-command" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-optifine = { path = "../horizon-optifine" }
horizon-threads = { path = "../horizon-threads" }
horizon-hdr = { path = "../horizon-hdr" }
horizon-world = { path = "../horizon-world" }
horizon-protocol = { path = "../horizon-protocol" }
# server.properties, eula.txt and the user lists, shared with horizon-server.
horizon-server-files = { path = "../horizon-server-files" }
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md).
horizon-anticheat = { path = "../horizon-anticheat" }
# The dedicated server's chat filter: [chat_filter], the slur list, mutes (docs/chat-filter.md).
horizon-chat-filter = { path = "../horizon-chat-filter" }
# Shader packs: reading them and turning their GLSL into naga modules.
horizon-shaders = { path = "../horizon-shaders" }
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-friends = { path = "../horizon-friends" }
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
horizon-narrator = { path = "../horizon-narrator" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
# Their runtime is the `mods` feature.
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
winit = "0.30.13"
pollster = "0.4"
bytemuck = { version = "1.24", features = ["derive"] }
glam = "0.30"
png = "0.18"
sha2 = "0.10.9"
# Server resource packs are checked and cached by their SHA-1, as vanilla does.
sha1 = "0.10"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1.0.151", features = ["raw_value"] }
# hlsl-out: the gfx.rs tests translate every shader as wgpu's DirectX 12 backend does. A normal feature,
# not a dev-dependency's, so builds, tests and examples share one build of naga, wgpu and this crate.
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
miniz_oxide = "0.8"
ab_glyph = "0.2.32"
log = "0.4"
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
horizon-theme = { path = "../horizon-theme" }
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
horizon-bench = { path = "../horizon-bench" }
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
gilrs = { version = "0.11", optional = true }
# The DualSense's rumble, adaptive triggers and lights over HID (gilrs only reads it).
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
# HTTPS to the Minecraft services (online-mode session join).
ureq = { version = "2", default-features = false, features = ["tls"] }
unicode-bidi = "0.3.18"
# `LevelStorageAccess.estimateDiskSpace`: usable disk space, without unsafe code on Windows.
fs4 = { version = "1.1.0", default-features = false, features = ["sync"] }
[target.'cfg(unix)'.dependencies]
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
rustix = { version = "1", default-features = false, features = ["std", "time", "process", "fs"] }
[target.'cfg(windows)'.dependencies]
# Use the registered URI handler without routing links through Explorer/cmd.
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
# on the PATH.
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
[target.'cfg(target_os = "macos")'.dependencies]
# Splitscreen: a window filling a monitor goes over its menu bar (game/split/windows.rs
# `above_menu_bar`); all already in the tree through winit and wgpu.
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow", "NSScreen"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSString"] }
[target.'cfg(not(target_os = "macos"))'.dependencies]
# OpenGL (WGL on Windows, EGL on Linux), the last fallback, for GPUs with no Vulkan or
# DirectX 12 driver (`graphicsApi`, graphics.rs).
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[features]
default = ["audio", "controllers"]
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
audio = ["horizon-client-render/audio"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
# Compatibility with earlier experiment commands: main already uses mimalloc.
windows-mimalloc = []
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
windows-segment-heap = []
[lints]
workspace = true
[dev-dependencies]
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
horizon-items = { path = "../horizon-items", features = ["test-support"] }
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
[[bin]]
name = "horizon-ctl"
path = "../horizon-client/src/bin/horizon-ctl/main.rs"
[[example]]
name = "chunk_load_bench"
path = "../horizon-client/examples/chunk_load_bench.rs"
[[example]]
name = "gameplay_decode_profile"
path = "../horizon-client/examples/gameplay_decode_profile.rs"
[[example]]
name = "json_load_bench"
path = "../horizon-client/examples/json_load_bench.rs"
[[example]]
name = "mod_port_probe"
path = "../horizon-client/examples/mod_port_probe.rs"
[[example]]
name = "model_library_bench"
path = "../horizon-client/examples/model_library_bench.rs"
[[example]]
name = "model_states_bench"
path = "../horizon-client/examples/model_states_bench.rs"
[[example]]
name = "old_world_fixture"
path = "../horizon-client/examples/old_world_fixture.rs"
[[example]]
name = "portal_scan_bench"
path = "../horizon-client/examples/portal_scan_bench.rs"
[[example]]
name = "random_tick_eligibility_bench"
path = "../horizon-client/examples/random_tick_eligibility_bench.rs"
[[example]]
name = "resource_memory"
path = "../horizon-client/examples/resource_memory.rs"
[[example]]
name = "save_batch_bench"
path = "../horizon-client/examples/save_batch_bench.rs"
[[example]]
name = "shared_json_startup_profile"
path = "../horizon-client/examples/shared_json_startup_profile.rs"
[[example]]
name = "sprite_cache_parity"
path = "../horizon-client/examples/sprite_cache_parity.rs"
[[example]]
name = "client_probe"
path = "../horizon-client/examples/probe/main.rs"
[[test]]
name = "custom_component_api"
path = "../horizon-client/tests/custom_component_api.rs"
[[test]]
name = "custom_component_unregistered"
path = "../horizon-client/tests/custom_component_unregistered.rs"
[[test]]
name = "custom_component_wire"
path = "../horizon-client/tests/custom_component_wire.rs"
[[test]]
name = "forced_chunks"
path = "../horizon-client/tests/forced_chunks.rs"
[[test]]
name = "generation_saves"
path = "../horizon-client/tests/generation_saves.rs"
[[test]]
name = "lan_bot"
path = "../horizon-client/tests/lan_bot.rs"
[[test]]
name = "streaming_meshes"
path = "../horizon-client/tests/streaming_meshes.rs"
[[test]]
name = "tall_column"
path = "../horizon-client/tests/tall_column.rs"
[[test]]
name = "wide_states"
path = "../horizon-client/tests/wide_states.rs"
+1 -6
View File
@@ -227,12 +227,7 @@ fn operating_system() -> String {
pub fn cpu() -> String {
let threads = std::thread::available_parallelism().map_or(0, |n| n.get());
let name = if cfg!(target_os = "linux") {
read("/proc/cpuinfo").and_then(|t| {
t.lines()
.find(|l| l.starts_with("model name"))
.and_then(|l| l.split_once(':'))
.map(|(_, v)| v.trim().to_owned())
})
read("/proc/cpuinfo").and_then(|t| horizon_telemetry::system::linux_cpu_name(&t))
} else if cfg!(target_os = "macos") {
command_output("sysctl", &["-n", "machdep.cpu.brand_string"])
} else if cfg!(windows) {
+20 -15
View File
@@ -111,6 +111,7 @@ mod end_sky;
mod flat;
mod frame_gen;
mod hdr;
mod hdr_icc;
mod hdr_stats;
pub use hdr::Calibration as HdrCalibration;
pub mod block_light;
@@ -4079,14 +4080,18 @@ impl Renderer {
.iter()
.take_while(|&&p| p == (page, index_page))
.count();
// Translucent draws, and only they, have sorted indices.
let vertices = match &self.translucent_arena {
Some(arena) if index_page.is_some() => arena.buffer(page),
_ => self.arena.buffer(page),
// Translucent draws, and only they, have sorted indices; split, their large
// pages are bound up to their last range in use (`Arena::binding`).
let (vertices, indices) = match (&self.translucent_arena, index_page) {
(Some(arena), Some(p)) => (arena.binding(page), self.sorted_indices.binding(p)),
(_, p) => (
self.arena.buffer(page).slice(..),
p.map_or(&self.indices, |p| self.sorted_indices.buffer(p))
.slice(..),
),
};
pass.set_vertex_buffer(0, vertices.slice(..));
let indices = index_page.map_or(&self.indices, |p| self.sorted_indices.buffer(p));
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, vertices);
pass.set_index_buffer(indices, wgpu::IndexFormat::Uint32);
if self.multi_draw {
let offset = (i - base as usize) as u64 * 20;
pass.multi_draw_indexed_indirect(indirect, offset, run as u32);
@@ -8246,19 +8251,19 @@ impl Renderer {
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).
/// third edition), with the peak and paper white as its content light levels (cLLI), and
/// an ICC profile for the programs that don't read cICP (`hdr_icc`).
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 info = png::Info::with_size(self.config.width, self.config.height);
info.color_type = png::ColorType::Rgb;
info.bit_depth = png::BitDepth::Sixteen;
info.icc_profile = Some(hdr_icc::profile().into());
let encoder =
png::Encoder::with_info(std::io::BufWriter::new(std::fs::File::create(path)?), info)?;
let mut writer = encoder.write_header()?;
// BT.2020 primaries, PQ, RGB, full range.
writer.write_chunk(png::chunk::cICP, &[9, 16, 0, 1])?;
@@ -252,6 +252,18 @@ impl Arena {
pub fn page_vertices(&self) -> u64 {
self.page_limit
}
/// `page`'s buffer up to the end of its last range in use, as draws bind it:
/// wgpu clears what a binding covers and nothing wrote yet when the frame is
/// submitted, most of a page just made or grown.
pub fn binding(&self, page: u32) -> wgpu::BufferSlice<'_> {
let p = self.pages[page as usize].as_ref().expect("live arena page");
let end = match p.free.0.last_key_value() {
Some((&start, &len)) if start + len == p.capacity => start,
_ => p.capacity,
};
// A page a draw names holds a range; wgpu refuses an empty slice.
p.buffer.slice(..end.max(GRAIN) * self.stride)
}
pub fn has_page(&self, page: u32) -> bool {
self.pages.get(page as usize).is_some_and(Option::is_some)
}
@@ -784,6 +796,32 @@ mod tests {
}
}
/// Draws bind a large page up to the end of its last range in use, a grain at
/// least: wgpu clears what a binding covers and nothing wrote when the frame is
/// submitted, so a grown page bound whole had its new tail cleared on the GPU.
#[test]
fn large_pages_are_bound_up_to_their_last_range_in_use() {
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let mut arena = Arena::translucent(&device, 24, GRAIN);
let ranges: Vec<_> = (0..3)
.map(|_| alloc(&mut arena, &device, &queue, GRAIN).unwrap())
.collect();
// The third grain grew the page to four.
assert_eq!(arena.buffer(0).size(), 4 * GRAIN * 24);
assert_eq!(arena.binding(0).size(), 3 * GRAIN * 24);
// A free range before the last in use stays bound, none after it; an empty
// page binds a grain (wgpu refuses an empty slice).
arena.release(0, ranges[1].1, GRAIN);
assert_eq!(arena.binding(0).size(), 3 * GRAIN * 24);
arena.release(0, ranges[2].1, GRAIN);
assert_eq!(arena.binding(0).size(), GRAIN * 24);
arena.release(0, ranges[0].1, GRAIN);
assert_eq!(arena.binding(0).size(), GRAIN * 24);
}
#[test]
fn empty_pages_are_reclaimed_without_moving_live_ranges() {
let Some((device, queue)) = device() else {
@@ -0,0 +1,159 @@
//! The ICC profile of HDR screenshots, for programs that don't read PNG's `cICP`: BT.2020
//! primaries and the PQ curve, with HDR reference white (203 nits) as the profile's white.
//! An ICC profile has no values above white, so those programs show a correct SDR picture
//! with its highlights clipped, where without it they took PQ's values for sRGB's and showed
//! them washed out. Programs that read `cICP` ignore this (PNG's third edition).
/// HDR reference white in nits (ITU-R BT.2408): where the curve reaches the profile's white.
const WHITE: f64 = 203.0;
/// PQ's values from 0 to 1, in the curve's steps.
const STEPS: usize = 4096;
/// BT.2020's primaries, adapted to the profile connection space's D50 (Bradford).
const RED: [f64; 3] = [0.67348, 0.27904, -0.00193];
const GREEN: [f64; 3] = [0.16567, 0.67534, 0.02998];
const BLUE: [f64; 3] = [0.12505, 0.04561, 0.79685];
const D50: [f64; 3] = [0.9642, 1.0, 0.8249];
/// PQ's EOTF (SMPTE ST 2084): a signal from 0 to 1 in nits.
fn pq_nits(signal: f64) -> f64 {
let (m1, m2) = (2610.0 / 16384.0, 2523.0 / 32.0);
let (c1, c2, c3) = (3424.0 / 4096.0, 2413.0 / 128.0, 2392.0 / 128.0);
let p = signal.powf(1.0 / m2);
10_000.0 * ((p - c1).max(0.0) / (c2 - c3 * p)).powf(1.0 / m1)
}
fn s15_fixed16(v: f64) -> [u8; 4] {
((v * 65536.0).round() as i32).to_be_bytes()
}
fn xyz(v: [f64; 3]) -> Vec<u8> {
let mut tag = b"XYZ \0\0\0\0".to_vec();
tag.extend(v.iter().flat_map(|&c| s15_fixed16(c)));
tag
}
fn curve() -> Vec<u8> {
let mut tag = b"curv\0\0\0\0".to_vec();
tag.extend((STEPS as u32).to_be_bytes());
for i in 0..STEPS {
let y = (pq_nits(i as f64 / (STEPS - 1) as f64) / WHITE).min(1.0);
tag.extend(((y * 65535.0).round() as u16).to_be_bytes());
}
tag
}
/// `textDescriptionType` (ICC v2), with its empty Unicode and ScriptCode parts.
fn description(text: &str) -> Vec<u8> {
let mut tag = b"desc\0\0\0\0".to_vec();
tag.extend((text.len() as u32 + 1).to_be_bytes());
tag.extend(text.as_bytes());
tag.extend([0; 1 + 4 + 4 + 2 + 1 + 67]);
tag
}
/// An ICC v2 matrix-shaper display profile.
pub(super) fn profile() -> Vec<u8> {
let trc = curve();
let copyright = b"text\0\0\0\0Public domain\0".to_vec();
let tags: [(&[u8; 4], &[u8]); 9] = [
(b"desc", &description("BT.2020 PQ, 203 nits = white")),
(b"cprt", &copyright),
(b"wtpt", &xyz(D50)),
(b"rXYZ", &xyz(RED)),
(b"gXYZ", &xyz(GREEN)),
(b"bXYZ", &xyz(BLUE)),
(b"rTRC", &trc),
(b"gTRC", &trc),
(b"bTRC", &trc),
];
let data_start = 128 + 4 + 12 * tags.len();
let mut table = (tags.len() as u32).to_be_bytes().to_vec();
let mut data = Vec::new();
let mut trc_at = None;
for (name, tag) in tags {
// The three channels share one curve.
let is_trc = tag == &trc[..];
let at = match trc_at.filter(|_| is_trc) {
Some(at) => at,
None => {
let at = data_start + data.len();
data.extend(tag);
data.resize(data.len().next_multiple_of(4), 0);
at
}
};
if is_trc {
trc_at = Some(at);
}
table.extend(name);
table.extend((at as u32).to_be_bytes());
table.extend((tag.len() as u32).to_be_bytes());
}
let mut out = vec![0; 128];
let size = data_start + data.len();
out[0..4].copy_from_slice(&(size as u32).to_be_bytes());
out[8..12].copy_from_slice(&[2, 0x40, 0, 0]);
out[12..16].copy_from_slice(b"mntr");
out[16..20].copy_from_slice(b"RGB ");
out[20..24].copy_from_slice(b"XYZ ");
out[36..40].copy_from_slice(b"acsp");
// Relative colorimetric, and the connection space's illuminant.
out[64..68].copy_from_slice(&1u32.to_be_bytes());
for (i, &c) in D50.iter().enumerate() {
out[68 + 4 * i..72 + 4 * i].copy_from_slice(&s15_fixed16(c));
}
out.extend(table);
out.extend(data);
out
}
#[cfg(test)]
mod tests {
use super::*;
fn tag<'a>(profile: &'a [u8], name: &[u8; 4]) -> &'a [u8] {
let u32_at = |i: usize| u32::from_be_bytes(profile[i..i + 4].try_into().unwrap()) as usize;
let at = (0..u32_at(128))
.map(|i| 132 + 12 * i)
.find(|&e| &profile[e..e + 4] == name)
.unwrap();
&profile[u32_at(at + 4)..u32_at(at + 4) + u32_at(at + 8)]
}
#[test]
fn the_profile_is_whole() {
let p = profile();
assert_eq!(
u32::from_be_bytes(p[0..4].try_into().unwrap()) as usize,
p.len()
);
assert_eq!(&p[36..40], b"acsp");
assert_eq!(p.len() % 4, 0);
assert_eq!(&tag(&p, b"desc")[0..4], b"desc");
// One curve, shared: the profile stays small.
assert_eq!(tag(&p, b"rTRC").as_ptr(), tag(&p, b"bTRC").as_ptr());
assert!(p.len() < 10_000, "{}", p.len());
}
#[test]
fn the_primaries_add_up_to_white() {
for i in 0..3 {
assert!((RED[i] + GREEN[i] + BLUE[i] - D50[i]).abs() < 0.0001);
}
}
#[test]
fn the_curve_reaches_white_at_reference_white_and_stays() {
let p = profile();
let trc = tag(&p, b"rTRC");
let value = |signal: f64| {
let i = 12 + 2 * (signal * (STEPS - 1) as f64).round() as usize;
u16::from_be_bytes([trc[i], trc[i + 1]])
};
assert_eq!(value(0.0), 0);
// 203 nits in PQ is a signal of 0.5807.
assert!(value(0.57) < 65535);
assert_eq!(value(0.59), 65535);
assert_eq!(value(1.0), 65535);
}
}
@@ -2740,6 +2740,13 @@ impl PackPost {
let rp = &mut open.as_mut().expect("open").0;
rp.set_pipeline(pass.render());
rp.set_bind_group(0, group, &[]);
// A sky program that reads the pack's SSBOs (Noble's `gbuffers_textured`, the
// sun's fallback) binds them, as the other passes do.
if pass.buffers
&& let Some(g) = &self.buffers.group
{
rp.set_bind_group(4, g, &[]);
}
sky.geometry.bind(rp, part.textured);
open.as_mut().expect("open").1 = i;
}
@@ -51,6 +51,12 @@ pub struct PackInputs {
pub cloud_height: f32,
/// The values native mods hand their packs (`frame.mods`, `native_shader_params`).
pub mod_params: [[f32; 4]; 8],
/// The player's state, for Iris's status uniforms.
pub status: PlayerStatus,
/// The End flash: its intensity, 0..1 (0 outside the End), and its direction from the
/// camera in world space, of length 100 (`endFlashIntensity`, `endFlashPosition`).
pub end_flash: f32,
pub end_flash_direction: Vec3,
}
/// The `cloudHeight` uniform. Vanilla reads `visual/cloud_height` only while the cloud color
@@ -64,6 +70,35 @@ pub fn cloud_height(cloud_color_alpha: f32, height: f32) -> f32 {
}
}
/// The local player's state behind Iris's status uniforms (`currentPlayerHealth`,
/// `isSpectator`, `is_burning`...).
#[derive(Clone, Copy, Debug, Default)]
pub struct PlayerStatus {
/// Survival or adventure: the vitals are -1 in creative and spectator.
pub survival: bool,
pub health: f32,
pub max_health: f32,
pub food: f32,
/// Air supply ticks, of 300.
pub air: f32,
/// Armor points (the `armor` attribute).
pub armor: f32,
pub spectator: bool,
pub riding: bool,
pub feet_in_water: bool,
pub swimming: bool,
pub burning: bool,
pub hurt: bool,
pub invisible: bool,
pub sneaking: bool,
pub sprinting: bool,
}
/// `maxPlayerHunger`, `maxPlayerAir` and `maxPlayerArmor`: Minecraft's own maximums.
const MAX_HUNGER: f32 = 20.0;
const MAX_AIR: f32 = 300.0;
const MAX_ARMOR: f32 = 50.0;
/// One uniform's value: up to 16 floats or 3 integers.
#[derive(Clone, Copy, Debug)]
pub enum Value {
@@ -198,6 +233,25 @@ pub const NAMES: &[&str] = &[
"playerLookVector",
"playerBodyVector",
"firstPersonCamera",
"currentPlayerHealth",
"maxPlayerHealth",
"currentPlayerHunger",
"maxPlayerHunger",
"currentPlayerAir",
"maxPlayerAir",
"currentPlayerArmor",
"maxPlayerArmor",
"isSpectator",
"isRiding",
"feetInWater",
"inSwimmingAnimation",
"is_burning",
"is_hurt",
"is_invisible",
"is_sneaking",
"is_sprinting",
"endFlashIntensity",
"endFlashPosition",
"isRightHanded",
"is_on_ground",
"bedrockLevel",
@@ -424,6 +478,7 @@ impl FrameUniforms {
"eyeAltitude" => Value::f(&[frame.camera.y as f32]),
"upPosition" => v3(to_view(Vec3::new(0.0, 100.0, 0.0))),
"sunPosition" => v3(sun),
"endFlashPosition" => v3(to_view(inputs.end_flash_direction)),
"moonPosition" => v3(moon),
"shadowLightPosition" => v3(if day { sun } else { moon }),
"sunAngle" => Value::f(&[sun_angle]),
@@ -483,7 +538,7 @@ impl FrameUniforms {
"isElytraFlying" => Value::i(&[inputs.elytra_flying as i32]),
"cloudHeight" => Value::f(&[inputs.cloud_height]),
// renderStage is NONE outside gbuffers draws.
_ => Value::i(&[0]),
_ => status_value(name, inputs).unwrap_or(Value::i(&[0])),
}
};
self.values.clear();
@@ -529,6 +584,46 @@ impl FrameUniforms {
}
}
/// Iris's player status and dimension uniforms, from `inputs`: the vitals are fractions of
/// their maximum, and -1 (with the maximums of health and air) in creative and spectator.
fn status_value(name: &str, inputs: &PackInputs) -> Option<Value> {
let status = inputs.status;
let vital = |v: f32| {
Value::f(&[if status.survival {
v.clamp(0.0, 1.0)
} else {
-1.0
}])
};
let flag = |b: bool| Value::i(&[i32::from(b)]);
Some(match name {
"currentPlayerHealth" => vital(status.health / status.max_health.max(1.0)),
"maxPlayerHealth" => Value::f(&[if status.survival {
status.max_health
} else {
-1.0
}]),
"currentPlayerHunger" => vital(status.food / MAX_HUNGER),
"maxPlayerHunger" => Value::f(&[MAX_HUNGER]),
"currentPlayerAir" => vital(status.air / MAX_AIR),
"maxPlayerAir" => Value::f(&[if status.survival { MAX_AIR } else { -1.0 }]),
"currentPlayerArmor" => vital(status.armor / MAX_ARMOR),
"maxPlayerArmor" => Value::f(&[MAX_ARMOR]),
"isSpectator" => flag(status.spectator),
"isRiding" => flag(status.riding),
"feetInWater" => flag(status.feet_in_water),
"inSwimmingAnimation" => flag(status.swimming),
"is_burning" => flag(status.burning),
"is_hurt" => flag(status.hurt),
// Spectators count as invisible.
"is_invisible" => flag(status.invisible || status.spectator),
"is_sneaking" => flag(status.sneaking),
"is_sprinting" => flag(status.sprinting),
"endFlashIntensity" => Value::f(&[inputs.end_flash]),
_ => return None,
})
}
/// How many components the uniform `name` has (16 for a matrix).
fn components(name: &str) -> usize {
match name {
@@ -547,7 +642,8 @@ fn components(name: &str) -> usize {
| "moonPosition"
| "shadowLightPosition"
| "fogColor"
| "skyColor" => 3,
| "skyColor"
| "endFlashPosition" => 3,
"eyeBrightness" | "eyeBrightnessSmooth" | "atlasSize" => 2,
n if n.starts_with("gbuffer") || n.starts_with("shadow") && n != "shadowAngle" => 16,
_ => 1,
@@ -576,6 +672,77 @@ mod tests {
assert_eq!(out[60..64], [0; 4], "a value past the block is dropped");
}
/// Iris's status uniforms as its documentation defines them (shaders.properties, "Status").
#[test]
fn status_uniforms_follow_iris() {
let float = |name: &str, inputs: &PackInputs| match status_value(name, inputs) {
Some(Value::F(f)) => f[0],
other => panic!("{name}: {other:?}"),
};
let int = |name: &str, inputs: &PackInputs| match status_value(name, inputs) {
Some(Value::I(i)) => i[0],
other => panic!("{name}: {other:?}"),
};
let mut inputs = PackInputs {
status: PlayerStatus {
survival: true,
health: 15.0,
max_health: 20.0,
food: 10.0,
air: 150.0,
armor: 20.0,
sneaking: true,
..Default::default()
},
..Default::default()
};
// The vitals are fractions of their maximums.
assert_eq!(float("currentPlayerHealth", &inputs), 0.75);
assert_eq!(float("maxPlayerHealth", &inputs), 20.0);
assert_eq!(float("currentPlayerHunger", &inputs), 0.5);
assert_eq!(float("maxPlayerHunger", &inputs), 20.0);
assert_eq!(float("currentPlayerAir", &inputs), 0.5);
assert_eq!(float("maxPlayerAir", &inputs), 300.0);
assert_eq!(float("currentPlayerArmor", &inputs), 0.4);
assert_eq!(float("maxPlayerArmor", &inputs), 50.0);
assert_eq!(int("is_sneaking", &inputs), 1);
assert_eq!(int("is_invisible", &inputs), 0);
// Creative and spectator: -1 for the vitals and the maximums of health and air;
// spectators are invisible.
inputs.status.survival = false;
inputs.status.spectator = true;
for name in [
"currentPlayerHealth",
"maxPlayerHealth",
"currentPlayerHunger",
"currentPlayerAir",
"maxPlayerAir",
"currentPlayerArmor",
] {
assert_eq!(float(name, &inputs), -1.0, "{name}");
}
assert_eq!(float("maxPlayerHunger", &inputs), 20.0);
assert_eq!(float("maxPlayerArmor", &inputs), 50.0);
assert_eq!(int("isSpectator", &inputs), 1);
assert_eq!(int("is_invisible", &inputs), 1);
// Each is provided, not left at zero for the pack.
for name in [
"currentPlayerHealth",
"isSpectator",
"is_sprinting",
"bedrockLevel",
] {
assert!(index(name).is_some(), "{name} is not provided");
}
assert!(
NAMES
.iter()
.filter(|n| status_value(n, &inputs).is_some())
.count()
== 18
);
}
#[test]
fn cloud_height_needs_a_cloud_color() {
assert_eq!(cloud_height(0.8, 192.33), 192.33);
+285 -46
View File
@@ -7,6 +7,7 @@ mod fluid;
mod leaf_culling;
mod lighting;
mod sides;
use crate::connected::{Pick, TileLayer};
use crate::{
assets::{Assets, RenderAssets},
biome::Biomes,
@@ -18,6 +19,7 @@ use crate::{
use horizon_core::{
biome::BiomeZoom, geometry::Direction, position::BlockPos, random::position_seed,
};
use horizon_optifine::ctm::connect::Face as CtmFace;
use horizon_world::{
block_render::FaceVisibility,
fluid::{FluidKind, FluidState},
@@ -1017,6 +1019,32 @@ impl<'a> Tints<'a> {
layers: Default::default(),
}
}
/// The biome of block `(x, y, z)` (section-local x/z, y in 0..16), as `BiomeManager.getBiome`
/// picks it.
fn biome(&self, x: i32, y: i32, z: i32) -> &'a str {
let id = Self::biome_id(&self.zoom, self.region, self.plains, self.origin, [x, y, z]);
&self.biomes.get(id).name
}
/// `BiomeManager.getBiome` over the loaded chunks (plains where there is none), for block
/// `(x, y, z)` of the section at `origin`. Takes the fields it reads, so `get` can call it
/// while it holds a layer.
fn biome_id(
zoom: &BiomeZoom,
region: &[[Option<&Chunk>; 3]; 3],
plains: u16,
[cx, cz, base_y]: [i32; 3],
[x, y, z]: [i32; 3],
) -> u16 {
let q = zoom.quart_at_block(BlockPos::new(cx * 16 + x, base_y + y, cz * 16 + z));
let (rx, rz) = ((q[0] >> 2) - cx + 1, (q[2] >> 2) - cz + 1);
if (0..3).contains(&rx) && (0..3).contains(&rz) {
region[rz as usize][rx as usize].map_or(plains, |c| {
c.biome((q[0] & 3) * 4, q[1] * 4, (q[2] & 3) * 4)
})
} else {
plains
}
}
/// The blended tint of block `(x, y, z)` (section-local x/z, y in 0..16).
fn get(&mut self, resolver: Resolver, x: i32, y: i32, z: i32) -> u32 {
let r = self.radius;
@@ -1032,27 +1060,21 @@ impl<'a> Tints<'a> {
if cached != UNSET {
return cached;
}
let [cx, cz, base_y] = self.origin;
let (bx, by, bz) = (cx * 16, base_y + y, cz * 16);
let [cx, cz, _] = self.origin;
let (bx, bz) = (cx * 16, cz * 16);
let (mut red, mut green, mut blue) = (0, 0, 0);
for dz in -r..=r {
for dx in -r..=r {
let cell = ((z + dz + r) as usize) * dim + (x + dx + r) as usize;
let mut biome = layer.zoomed[cell];
if biome == NO_BIOME {
// BiomeManager.getBiome over the loaded chunks (plains
// where there is none).
let q = self
.zoom
.quart_at_block(BlockPos::new(bx + x + dx, by, bz + z + dz));
let (rx, rz) = ((q[0] >> 2) - cx + 1, (q[2] >> 2) - cz + 1);
biome = if (0..3).contains(&rx) && (0..3).contains(&rz) {
self.region[rz as usize][rx as usize].map_or(self.plains, |c| {
c.biome((q[0] & 3) * 4, q[1] * 4, (q[2] & 3) * 4)
})
} else {
self.plains
};
biome = Self::biome_id(
&self.zoom,
self.region,
self.plains,
self.origin,
[x + dx, y, z + dz],
);
layer.zoomed[cell] = biome;
}
let c = match resolver {
@@ -1242,7 +1264,8 @@ impl Mesher<'_> {
origin,
face,
ao,
state,
(id, state),
world,
interior_leaf,
);
}
@@ -1281,7 +1304,8 @@ impl Mesher<'_> {
origin: [f32; 3],
face: &BakedFace,
ao: bool,
state: &Info,
(id, state): (u32, &Info),
world: BlockPos,
interior_leaf: bool,
) {
let q = &face.quad;
@@ -1309,50 +1333,265 @@ impl Mesher<'_> {
([lighting::gray(directional); 4], [coords; 4])
};
let tint = Self::tint(state, q.tint_index, tints, pos);
if self.tables.is_animated(face.layer as u16) {
out.note_sprite_layer(face.layer as u16);
}
let translucent = face.translucent
|| self
.translucent_layers
.get(face.layer as usize)
.copied()
.unwrap_or(false);
let target = if translucent {
&mut out.translucent
// OptiFine connected textures: another sprite, or one per quarter.
let connected = &self.render.connected;
let pick = if connected.is_empty() {
Pick::Keep
} else {
&mut out.solid
let tints = &*tints;
connected.pick(
id,
face.layer as u16,
CtmFace::ALL[q.facing as usize],
[world.x, world.y, world.z],
|[dx, dy, dz]| pad.blocks[pi(pos[0] + dx, pos[1] + dy, pos[2] + dz)],
|| Some(tints.biome(pos[0], pos[1], pos[2])),
)
};
// Opaque leaves (forceOpaque), quads over opaque texels and leaves a
// leaf-culling mode proved internal draw in the solid layer: no alpha
// cutout. All four vertices of the quad share the layer.
let layer = if face.opaque && !translucent
|| !self.cutout_leaves && state.leaves
|| interior_leaf
{
face.layer as u16 | OPAQUE_LAYER
} else {
face.layer as u16
};
for i in 0..4 {
let mut corners = [(TerrainVertex::new([0.0; 3], [0.0; 2], 0, 0, 0), [0.0; 2]); 4];
for (i, corner) in corners.iter_mut().enumerate() {
let g = gray[i] as u32;
let rgb = match tint {
Some(t) => [16, 8, 0].map(|shift| g * (t >> shift & 255) / 255),
None => [g; 3],
};
let p = q.positions[i];
target.push(TerrainVertex::new(
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
*corner = (
TerrainVertex::new(
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
q.uv[i],
0,
emitting(pack_rgb(rgb), tier),
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
),
q.uv[i],
);
}
// Opaque leaves (forceOpaque) and leaves a leaf-culling mode proved internal draw in the
// solid layer whatever their sprite.
let solid_leaves = !self.cutout_leaves && state.leaves || interior_leaf;
let own = TileLayer::Layer {
layer: face.layer as u16,
translucent: face.translucent,
opaque: face.opaque,
};
// A tile keeps the quad's forced translucency; `<skip>` and `<default>` are its own.
let tile = |t: TileLayer| match t {
TileLayer::Layer {
layer,
emitting(pack_rgb(rgb), tier),
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
));
translucent,
opaque,
} => TileLayer::Layer {
layer,
translucent: translucent || q.material.force_translucent,
opaque,
},
TileLayer::Skip | TileLayer::Default => own,
};
match pick {
Pick::Keep => self.quad(out, solid_leaves, own, corners.map(|c| c.0)),
Pick::Tile(t) => self.quad(out, solid_leaves, tile(t), corners.map(|c| c.0)),
Pick::Quarters(tiles) => {
let cuts = cuts(&corners);
for s in cuts.windows(2) {
for t in cuts.windows(2) {
let piece = [(s[0], t[0]), (s[1], t[0]), (s[1], t[1]), (s[0], t[1])]
.map(|(s, t)| bilinear(&corners, s, t));
let quarter = quarter_of(&piece);
self.quad(out, solid_leaves, tile(tiles[quarter]), piece.map(|c| c.0));
}
}
}
}
}
/// One terrain quad with sprite `tile`, in the pass its alpha needs (`solid_leaves`: no
/// alpha cutout whatever the sprite).
fn quad(
&self,
out: &mut SectionMesh,
solid_leaves: bool,
tile: TileLayer,
mut v: [TerrainVertex; 4],
) {
let TileLayer::Layer {
layer,
translucent,
opaque,
} = tile
else {
return;
};
if self.tables.is_animated(layer) {
out.note_sprite_layer(layer);
}
let translucent = translucent
|| self
.translucent_layers
.get(usize::from(layer))
.copied()
.unwrap_or(false);
// Quads over opaque texels draw in the solid layer: no alpha cutout (a dirt path's side
// skips its clear top row). All four vertices share the layer.
let layer = if opaque && !translucent || solid_leaves {
layer | OPAQUE_LAYER
} else {
layer
};
let target = if translucent {
&mut out.translucent
} else {
&mut out.solid
};
for vertex in &mut v {
vertex.layer = layer;
}
target.extend(v);
}
}
/// The cuts of a `ctm_compact` face, in quad parameters, without allocating: the first `.1`
/// of `.0`.
struct Cuts([f32; 4], usize);
impl std::ops::Deref for Cuts {
type Target = [f32];
fn deref(&self) -> &[f32] {
&self.0[..self.1]
}
}
/// Where a quad's edge from corner 0 to corner `to` (1 for `s`, 3 for `t`) crosses the sprite's
/// middle line, the line its quarters meet on; `None` for an edge that stays on one side (a
/// slab's side).
fn crossing(c: &[(TerrainVertex, [f32; 2]); 4], to: usize) -> Option<f32> {
let (a, b) = (c[0].1, c[to].1);
// The UV the edge runs along.
let k = usize::from((b[1] - a[1]).abs() > (b[0] - a[0]).abs());
let at = (0.5 - a[k]) / (b[k] - a[k]);
(at > 0.0 && at < 1.0).then_some(at)
}
/// The cuts of a `ctm_compact` face, the same along `s` and `t`: both edges' crossings. Pieces
/// on the quad's 0-2 diagonal are then square, so their own triangles lie on the quad's and a
/// split face shades exactly like the whole one; the others lie on one side of it.
fn cuts(c: &[(TerrainVertex, [f32; 2]); 4]) -> Cuts {
let mut out = Cuts([0.0; 4], 1);
let (mut s, mut t) = (crossing(c, 1), crossing(c, 3));
if s > t {
std::mem::swap(&mut s, &mut t);
}
for at in [s, t].into_iter().flatten().chain([1.0]) {
if out.0[out.1 - 1] < at {
out.0[out.1] = at;
out.1 += 1;
}
}
out
}
/// The quarter of the sprite (top-left, top-right, bottom-left, bottom-right) a piece of a
/// quad lies in, by its UV centre (v grows downward).
fn quarter_of(piece: &[(TerrainVertex, [f32; 2]); 4]) -> usize {
let [u, v] = [0, 1].map(|k| piece.iter().map(|c| c.1[k]).sum::<f32>() / 4.0);
usize::from(v >= 0.5) * 2 + usize::from(u >= 0.5)
}
/// The vertex at `(s, t)` across a quad's corners (`s` from corner 0 to 1, `t` from 0 to 3),
/// with its UV in sprite units: a quarter of a `ctm_compact` face. Blended within the quad's
/// two triangles (0-1-2 and 0-2-3), as the GPU blends the whole quad, so a split face shades
/// like an unsplit one.
fn bilinear(c: &[(TerrainVertex, [f32; 2]); 4], s: f32, t: f32) -> (TerrainVertex, [f32; 2]) {
let w = if s >= t {
[1.0 - s, s - t, t, 0.0]
} else {
[1.0 - t, 0.0, s, t - s]
};
let mix = |f: &dyn Fn(&(TerrainVertex, [f32; 2])) -> f32| {
(0..4).map(|i| w[i] * f(&c[i])).sum::<f32>()
};
// Packed bytes (colour, light) blend one by one.
let bytes = |field: fn(&TerrainVertex) -> u32, count: u32| {
(0..count)
.map(|k| (mix(&|c| (field(&c.0) >> (8 * k) & 255) as f32).round() as u32) << (8 * k))
.sum::<u32>()
};
let color = bytes(|v| v.color, 4);
let light = bytes(|v| u32::from(v.light), 2) as u16;
let pos = [0, 1, 2].map(|k| mix(&|c| c.0.pos[k]));
let uv = [0, 1].map(|k| mix(&|c| c.1[k]));
(TerrainVertex::new(pos, uv, 0, color, light), uv)
}
/// Snapshot of the 3x3 chunk neighbourhood used by a worker job.
pub type Region = [[Option<Arc<Chunk>>; 3]; 3];
pub fn borrow(region: &Region) -> [[Option<&Chunk>; 3]; 3] {
std::array::from_fn(|z| std::array::from_fn(|x| region[z][x].as_deref()))
}
#[cfg(test)]
mod quarter_tests {
use super::*;
fn corner(pos: [f32; 3], uv: [f32; 2], red: u32, light: u16) -> (TerrainVertex, [f32; 2]) {
(TerrainVertex::new(pos, uv, 0, 0xFF00_0000 | red, light), uv)
}
#[test]
fn quarters_blend_the_corners_by_triangle() {
// Only corner 2 is lit: the GPU's triangles (0-1-2, 0-2-3) put half of it in the
// middle, where a bilinear blend would put a quarter and the other diagonal none.
let c = [
corner([0.0, 0.0, 0.0], [0.0, 0.0], 0, 0),
corner([1.0, 0.0, 0.0], [1.0, 0.0], 0, 0),
corner([1.0, 1.0, 0.0], [1.0, 1.0], 200, 0x2010),
corner([0.0, 1.0, 0.0], [0.0, 1.0], 0, 0),
];
let (centre, uv) = bilinear(&c, 0.5, 0.5);
assert_eq!(centre.pos, [0.5, 0.5, 0.0]);
assert_eq!(uv, [0.5, 0.5]);
assert_eq!(centre.color, 0xFF00_0000 | 100);
assert_eq!(centre.light, 0x1008, "sky and block light blend apart");
// Off the diagonal, on the 0-1-2 triangle: corner 2 weighs t.
assert_eq!(bilinear(&c, 0.75, 0.25).0.color & 255, 50);
assert_eq!(bilinear(&c, 0.5, 0.0).0.pos, [0.5, 0.0, 0.0]);
}
#[test]
fn compact_faces_cut_where_the_sprite_does() {
// A full face: cut in the middle both ways.
let full = [
corner([0.0, 1.0, 0.0], [0.0, 0.0], 0, 0),
corner([1.0, 1.0, 0.0], [1.0, 0.0], 0, 0),
corner([1.0, 0.0, 0.0], [1.0, 1.0], 0, 0),
corner([0.0, 0.0, 0.0], [0.0, 1.0], 0, 0),
];
assert_eq!(*cuts(&full), [0.0, 0.5, 1.0]);
// A dirt path's side, v from 1/16: the sprite's middle is 7/15 of the way down; both
// directions are cut there and at the middle of u.
let mut path = full;
path[0].1 = [0.0, 1.0 / 16.0];
path[1].1 = [1.0, 1.0 / 16.0];
assert_eq!(
*cuts(&path),
[0.0, (0.5 - 1.0 / 16.0) / (15.0 / 16.0), 0.5, 1.0]
);
// A slab's lower side (v 0.5..1) crosses the middle only along u.
let mut slab = full;
slab[0].1 = [0.0, 0.5];
slab[1].1 = [1.0, 0.5];
assert_eq!(crossing(&slab, 3), None);
assert_eq!(*cuts(&slab), [0.0, 0.5, 1.0]);
// Quarters by UV centre: top-left, top-right, bottom-left, bottom-right.
let piece = |s0: f32, t0: f32| {
[
(s0, t0),
(s0 + 0.5, t0),
(s0 + 0.5, t0 + 0.5),
(s0, t0 + 0.5),
]
.map(|(s, t)| bilinear(&full, s, t))
};
assert_eq!(
[
piece(0.0, 0.0),
piece(0.5, 0.0),
piece(0.0, 0.5),
piece(0.5, 0.5)
]
.map(|p| quarter_of(&p)),
[0, 1, 2, 3]
);
}
}
+16 -8
View File
@@ -6,8 +6,12 @@ edition.workspace = true
license.workspace = true
rust-version = "1.90"
description = "Horizon native development client"
autobins = false
autoexamples = false
autotests = false
[dependencies]
horizon-client-lib = { path = "../horizon-client-lib", default-features = false }
horizon-assets = { path = "../horizon-assets" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
@@ -15,6 +19,8 @@ horizon-client-render = { path = "../horizon-client-render" }
horizon-command = { path = "../horizon-command" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# OptiFine-format pack files: custom entity models, random entity textures.
horizon-optifine = { path = "../horizon-optifine" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-threads = { path = "../horizon-threads" }
horizon-hdr = { path = "../horizon-hdr" }
@@ -36,7 +42,6 @@ horizon-narrator = { path = "../horizon-narrator" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
# Their runtime is the `mods` feature.
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
horizon-optifine = { path = "../horizon-optifine" }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
winit = "0.30.13"
pollster = "0.4"
@@ -104,20 +109,23 @@ winresource = "0.1"
default = ["audio", "controllers"]
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
audio = ["horizon-client-render/audio"]
audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
rng-trace = ["horizon-core/rng-trace", "horizon-client-lib/rng-trace"]
# Compatibility with earlier experiment commands: main already uses mimalloc.
windows-mimalloc = []
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
windows-segment-heap = []
# Its own name, not `probe`: Cargo copies every crate's examples into one target/<profile>/examples.
[[example]]
name = "client_probe"
path = "examples/probe/main.rs"
[[bin]]
name = "horizon-client"
path = "src/main.rs"
[[test]]
name = "launch_api"
path = "tests/launch_api.rs"
[lints]
workspace = true
@@ -12,6 +12,7 @@
//! this thread's CPU time in `Mesher::mesh`, the best of `--rounds`, so other
//! work on the machine changes it less. The checksum of the meshes shows that an
//! A/B pair of builds made the same ones.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
assets::Assets,
biome::Biomes,
@@ -1,6 +1,7 @@
//! Profile the two mandatory passes over gameplay-facts.json independently.
//! Build with `cargo build --release -p horizon-client --example gameplay_decode_profile`.
//! Each invocation runs one phase in a separate process for CPU/RSS measurements.
extern crate horizon_client_lib as horizon_client;
#[allow(dead_code)]
#[path = "../../horizon-items/src/blocks/decode.rs"]
mod decode;
@@ -1,5 +1,6 @@
//! Compare the read + Value parse stages in BlockFacts::load and Assets::load.
//! Usage: json_load_bench RESOURCE_ROOT [REPETITIONS]; see tools/json_load_fixtures.py.
extern crate horizon_client_lib as horizon_client;
use serde_json::Value;
use std::{
fs::{self, File},
@@ -7,6 +7,7 @@
//!
//! WORK is a `horizon-port-pack` work folder: `oracle/facts.json` and
//! `mods/<id>/carry/`. The root is built in `WORK/horizon-root`.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
Result,
assets::Assets,
@@ -2,6 +2,7 @@
//! for live requested heap bytes; leave it unset for latency measurements.
//! cargo run --profile fast -p horizon-client --example model_library_bench -- ROOT [--parity]
//! Run fresh processes, warm up twice, then compare multiple alternating baseline/candidate runs.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{assets::Assets, item_render::ItemRenderer};
use std::{
alloc::{GlobalAlloc, Layout, System},
@@ -1,5 +1,6 @@
//! Compare the old two-parse loading path with catalog construction from shared facts.
//! Run: cargo run -p horizon-client --profile fast --example model_states_bench -- 15
extern crate horizon_client_lib as horizon_client;
use horizon_client::{Result, models::StateModels, sprites::Sprites};
use horizon_core::position::BlockPos;
use horizon_world::{
@@ -5,6 +5,7 @@
//! generated around them blend with them (`Blender.of`).
//!
//! old_world_fixture <source world> <destination world> <x0,z0,x1,z1>
extern crate horizon_client_lib as horizon_client;
use horizon_core::position::ChunkPos;
use horizon_world::{
nbt::{Limits, Tag},
@@ -2,6 +2,7 @@
//! entity reaches, with the per-tick palette map against the resident cache.
//! Run with prepared client resources:
//! cargo run --profile fast -p horizon-client --example portal_scan_bench -- RESOURCES
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
blocks::BlockFacts,
chunk::{Chunk, MIN_Y, SECTIONS, Section},
@@ -4,6 +4,7 @@
//!
//! This file only lists the probes, so that adding one reruns no harness: tools/parity.py leaves it out of
//! their dependencies. How a probe runs is in dispatch.rs.
extern crate horizon_client_lib as horizon_client;
#[macro_use]
mod dispatch;
@@ -1,5 +1,6 @@
//! Selection cost only (not block simulation). Run with prepared client resources.
//! cargo run --profile fast -p horizon-client --example random_tick_eligibility_bench -- RESOURCES
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
blocks::BlockFacts,
chunk::{Chunk, MIN_Y, Section},
@@ -9,6 +9,7 @@
//! and the process RSS (what the allocator holds on to). On macOS it ends
//! with `footprint`'s physical footprint, the number the OS charges: RSS also
//! counts freed pages the allocator marked reusable.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
assets::Assets, biome::Biomes, blocks::BlockFacts, collisions, crafting::Recipes, items::Items,
light::LightTables, mesher::MeshTables, models, motion::MotionResources,
@@ -2,6 +2,7 @@
//! full chunk NBT encoder. Run with `cargo run --profile fast -p horizon-client
//! --example save_batch_bench`. Add `-- --nbt` to also time serialization.
//! Exact NBT equality is always validated. No GPU, compression or disk I/O timed.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
biome::Biomes,
chunk::{Chunk, block_strategy},
@@ -2,6 +2,7 @@
//! Run each mode in a separate process to capture CPU usage and peak RSS.
//! The deferred mode evaluates both phases contiguously for fair loader comparison;
//! actual client startup performs other initialization between these phases.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{assets::Assets, blocks::BlockFacts};
use horizon_world::level::Behaviours;
use std::{path::Path, time::Instant};
@@ -2,6 +2,7 @@
//! to the ones decoded from the PNGs. Deletes the cache file in ROOT, loads (decodes
//! and writes it), loads again (reads it) and compares the sprite arrays.
//! cargo run --profile fast -p horizon-client --example sprite_cache_parity -- ROOT
extern crate horizon_client_lib as horizon_client;
use horizon_client::assets::{Assets, RenderAssets};
use std::{
hash::{DefaultHasher, Hash, Hasher},
@@ -1,6 +1,7 @@
//! horizon-ctl: play and script a running Horizon client through its control
//! socket, from a shell, a script or an AI agent (`horizon-ctl mcp`).
#![forbid(unsafe_code)]
extern crate horizon_client_lib as horizon_client;
mod commands;
mod launch;
mod mcp;
+163
View File
@@ -0,0 +1,163 @@
//! Horizon native client: resources, world streaming, rendering and the
//! bindings between the validated simulation pieces and the game loop.
#![forbid(unsafe_code)]
extern crate self as horizon_client;
pub mod advancements;
pub mod ambience;
pub mod animate_tick;
pub mod arrows;
pub mod block_contact;
pub use horizon_client_render::{
assets, atlas_sources, audio, auth, ban_notice, bidi, biome, block_edit, block_entities,
block_entity_anim, block_entity_render, border_render, breaking, brewing, camera, chat, chunk,
chunk_map, cit, clouds, collisions, command_block, containers, crash, datapack, dynamic_light,
effects, environment, extras, fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui,
hdr_calibration, hud, hud_layout, hud_overlays, inventory_effects, java_text, jigsaw_block,
level_loading, light, lod, logging, menu_blur, mesher, minimap, mipmap, mod_list,
model_library, models, multiline_edit, native_emissive, options_file, options_fixes,
options_ui, overlay, pack_screen, packs, par, particles, poi_sections, post_chain,
potent_sulfur, preedit, providers, screen_draw, screen_effects, sculk, section_mask, settings,
shader_options, shader_pack_list, signs, skins, sound_engine, sound_events, sound_preview,
spans, split_layout, split_profiles, sprites, streaming, structure_block, tab_list, telemetry,
test_block, test_instance_block, testgen, title_edition, trims, ui, vault, weather, widgets,
world, worldborder, zip,
};
pub mod attack_strength;
pub mod attribute_updates;
pub mod auto_jump;
pub mod banner;
pub mod beehive;
pub mod benchmark;
pub mod block_entity_item;
pub mod block_pattern;
pub mod block_transform;
pub mod boss_overlay;
pub mod cauldron;
pub mod chunk_io;
pub mod clock;
pub mod command_storage;
pub mod confirm_screen;
pub mod container_screen;
pub mod content_identity;
pub mod control;
pub mod crafting;
pub mod creative;
pub mod cushion;
pub mod data_cmd;
pub mod debug_chart;
pub mod debug_entries;
pub mod debug_keys;
pub mod debug_overlay;
pub mod debugworld;
pub mod dialog;
pub mod dialog_screen;
pub mod discord_presence;
pub mod end_crystal;
pub mod end_gateway;
pub mod entities;
pub mod entity_culling;
pub mod entity_nbt;
pub mod extras_hud;
pub mod falling_block;
pub mod fire_render;
pub mod first_person;
pub mod flatgen;
pub mod friends_screen;
pub mod furnace;
pub mod game_mode_switcher;
pub mod game_rules_screen;
pub mod gizmos;
pub mod gossip;
pub mod health;
pub mod heartbeat;
pub mod hotbar;
pub mod hunger;
pub mod inv_sort;
pub mod item_activation;
pub mod item_render;
pub mod item_sync;
pub mod item_tooltip;
pub mod lan;
pub mod landing;
pub mod locator_bar;
pub mod lod_net;
pub mod menu;
pub mod merchant;
pub mod mobs;
pub mod mod_content;
pub mod motion;
pub mod multiplayer;
pub mod music;
pub mod native_data;
pub mod net;
pub mod nether_portal;
pub mod no_resources;
pub mod offline_name;
pub mod old_world;
pub mod osk;
pub mod patrol;
pub mod perf_overlay;
pub mod pick_item;
pub mod player_sync;
pub mod portal_sections;
pub mod pose;
pub mod pregen;
pub mod projectiles;
pub mod raid;
pub mod ram_limit;
pub mod random_tick_sections;
pub mod recipe_book;
pub mod recipe_book_screen;
pub mod reconnect;
pub mod save;
pub mod scoreboard_cmd;
pub mod screen_layout;
pub mod screen_narrator;
pub mod social_screen;
pub use horizon_server_files as server;
pub mod server_packs;
pub mod shadows;
pub mod spear;
pub mod spectator_menu;
pub mod sprint;
pub mod stations;
pub mod stopwatches;
pub mod structure_blocks;
pub mod text_display;
pub mod tick_chunk_membership;
pub mod ticket_levels;
pub mod title;
pub mod toasts;
pub mod ui_theme;
pub mod vanillagen;
pub mod win_screen;
pub mod world_spawn;
pub use horizon_client_core::Result;
pub use horizon_client_core::block_entity_nbt;
pub use horizon_client_core::bossbar;
pub use horizon_client_core::component;
pub use horizon_client_core::fastutil_order;
pub use horizon_client_core::gamerules;
pub use horizon_client_core::lang;
pub use horizon_client_core::nbt_path;
pub use horizon_client_core::scoreboard;
pub use horizon_client_core::snbt;
pub use horizon_client_core::text;
pub use horizon_client_input::clipboard;
pub use horizon_client_input::gamepad;
pub use horizon_client_input::input;
pub use horizon_client_input::interaction_input;
pub use horizon_client_input::key_names;
pub use horizon_client_input::keybinds;
pub use horizon_client_input::keymapping;
pub use horizon_client_input::mouse;
pub use horizon_client_input::window;
pub use horizon_command as command;
pub use horizon_items::blocks;
pub use horizon_items::bundle;
pub use horizon_items::cooldowns;
pub use horizon_items::enchant;
pub use horizon_items::flowers;
pub use horizon_items::item_data;
pub use horizon_items::items;
pub use horizon_items::random_sequences;
+6
View File
@@ -2322,6 +2322,12 @@ mod armor_stand_motion_tests {
let start = Vec3::new(20.5, 20.0, 20.5);
let mut stand = ArmorStand::new(1, start, 0.0);
stand.on_ground = false;
// A summoned stand has no motion yet: the first tick only builds it
// (vanilla moves by the velocity, then applies gravity), so it falls
// from the second tick on (measured on the real server: drop 0, 0, 0.0784 blocks).
tick_stand(&mut stand, &world, &facts, &collision);
assert_eq!(stand.position, start);
assert!((stand.velocity.y + 0.0784).abs() < 1e-6);
tick_stand(&mut stand, &world, &facts, &collision);
assert_eq!(stand.previous, start);
assert!(
+83 -1
View File
@@ -796,7 +796,24 @@ pub fn mob_to_nbt(
put_memories(map, m);
crate::mobs::villager::write_nbt(m, map);
}
"minecraft:wandering_trader" => put_offers(map, m),
"minecraft:wandering_trader" => {
put_offers(map, m);
// WanderingTrader.addAdditionalSaveData: the visit's countdown and
// the place it is walking to, a BlockPos codec (an int array).
map.insert("DespawnDelay".into(), Tag::Int(m.extra.ints[5]));
if let Some(p) = m.extra.point {
// The trader keeps its target as a position; the tag is a
// BlockPos, so only the block it is in is saved.
map.insert(
"wander_target".into(),
Tag::IntArray(vec![
p[0].floor() as i32,
p[1].floor() as i32,
p[2].floor() as i32,
]),
);
}
}
"minecraft:axolotl" => {
write_axolotl_memories(m, map);
map.insert(
@@ -2986,6 +3003,16 @@ fn read_villager(m: &mut Mob, tag: &Tag) {
if m.species() == crate::mobs::Species::Villager {
crate::mobs::villager::read_nbt(m, tag);
}
if m.species() == crate::mobs::Species::WanderingTrader {
// WanderingTrader.readAdditionalSaveData: both default to idle.
m.extra.ints[5] = number(tag.get("DespawnDelay")).unwrap_or(0) as i32;
m.extra.point = match tag.get("wander_target") {
Some(Tag::IntArray(a)) if a.len() == 3 => {
Some([f64::from(a[0]), f64::from(a[1]), f64::from(a[2])])
}
_ => None,
};
}
let offers_tag = tag.get("Offers");
let xp = number(tag.get("Xp")).unwrap_or(0) as i32;
if offers_tag.is_none() && xp == 0 && tag.get("LastRestock").is_none() {
@@ -3461,6 +3488,8 @@ pub fn apply_item_components(m: &mut Mob, stack: &crate::items::ItemStack) {
Some("minecraft:pig/variant"),
Some("minecraft:pig/sound_variant"),
),
// ZombieNautilus.applyImplicitComponents: the variant only.
crate::mobs::Species::ZombieNautilus => (Some("minecraft:zombie_nautilus/variant"), None),
_ => (None, None),
};
if let Some(name) = variant.and_then(|id| text(components.other(id))) {
@@ -3936,6 +3965,49 @@ mod sheep_nbt_tests {
assert_eq!(number(tag.get("VillagerDataFinalized")), Some(1));
}
/// `WanderingTrader.addAdditionalSaveData`: the visit's countdown and the
/// place it is walking to (a `BlockPos` codec, so an int array), which the
/// despawn and stroll goals read back after a reload.
#[test]
fn a_wandering_traders_countdown_and_target_survive_a_save() {
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 mut m = Mob::new(
1,
types["minecraft:wandering_trader"].clone(),
Vec3::new(0.5, 64.0, 0.5),
0.0,
);
m.extra.ints[5] = 48000;
m.extra.point = Some([12.0, 65.0, -30.0]);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
assert_eq!(number(tag.get("DespawnDelay")), Some(48000));
let Some(Tag::IntArray(target)) = tag.get("wander_target") else {
panic!("wander_target missing");
};
assert_eq!(target, &vec![12, 65, -30]);
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
assert_eq!(back.extra.ints[5], 48000);
assert_eq!(back.extra.point, Some([12.0, 65.0, -30.0]));
// A save with neither field: idle, and nothing to walk to.
let bare = Tag::compound([
("id", string("minecraft:wandering_trader")),
("Pos", doubles(&[0.5, 64.0, 0.5])),
]);
let back = mob_from_nbt(&bare, &types, 3, None).unwrap();
assert_eq!((back.extra.ints[5], back.extra.point), (0, None));
}
#[test]
fn mobs_keep_their_variant_puff_state_carried_block_and_conversion_times() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
@@ -4186,6 +4258,16 @@ mod item_component_tests {
let mut sheep = spawn("minecraft:sheep", 2);
apply_item_components(&mut sheep, &egg);
assert_eq!(sheep.extra.color, 5, "lime, not the white it started as");
// ZombieNautilus.applyImplicitComponents: the egg carries the coral
// variant over the temperate one the biome picked.
let mut egg = crate::items::ItemStack::new("minecraft:zombie_nautilus_spawn_egg", 1);
egg.data_mut().set_other(
"minecraft:zombie_nautilus/variant",
Some(string("minecraft:warm")),
);
let mut nautilus = spawn("minecraft:zombie_nautilus", 4);
apply_item_components(&mut nautilus, &egg);
assert_eq!(nautilus.extra.variant, 1, "minecraft:warm, not temperate");
}
/// `EntityType.updateCustomEntityTag`: the egg's `entity_data` on the mob it
+108
View File
@@ -0,0 +1,108 @@
//! Startup shared by the gameplay library and its executable.
use crate::{crash, game, logging, ram_limit};
/// Start the game. The executable supplies the allocator and retains the mod API marker.
pub fn main() {
horizon_threads::configure_allocator();
horizon_client::telemetry::mark_start();
let args: Vec<String> = std::env::args().skip(1).collect();
let game_args = args.iter().take_while(|a| *a != "--dedicated");
let help = game_args
.clone()
.any(|a| a == "--help" || a == "-h" || a == "--launch-api");
// --home before the log opens in it.
if let Some(dir) = game_args.clone().skip_while(|a| *a != "--home").nth(1) {
logging::set_game_home(std::path::absolute(dir).unwrap_or_else(|_| dir.into()));
}
// The dedicated server logs in its own folder (--game-dir's), as vanilla's does; the game in its home.
let (home, side, thread) = if args.iter().any(|a| a == "--dedicated") {
let folder = game_args
.clone()
.skip_while(|a| *a != "--game-dir" && *a != "--gameDir")
.nth(1)
.and_then(|dir| std::path::absolute(dir).ok())
.unwrap_or_else(|| ".".into());
(folder, "server", "Server thread")
} else {
// --player-dir too (a game's, not a server's): a player of a shared game folder keeps
// its log and crash reports in its own; crash telemetry stays with the home's consent.
if let Some(dir) = game_args
.clone()
.skip_while(|a| *a != "--player-dir")
.nth(1)
{
logging::set_player_dir(std::path::absolute(dir).unwrap_or_else(|_| dir.into()));
}
(logging::game_home(), "client", "Render thread")
};
if !help {
logging::init(
logging::player_dir_override().unwrap_or(home.as_path()),
thread,
);
// The log's first line names the build, so a bug report that carries the log says which one it was.
log::info!("Horizon {}", crash::version());
crash::install(home.clone(), side);
ram_limit::start(&home, side);
}
raise_open_file_limit();
if let Err(error) = game::run() {
// A bad command line is the caller's mistake, not a crash: no report.
let usage = error.is::<game::UsageError>();
log::error!("Horizon: {error}");
logging::flush();
// The report's path is the last line on stderr (crash::SAVED_LINE).
if !help && !usage {
crash::report_error(&home, side, &*error);
}
// Started from Explorer, the console window closes with the game:
// keep the message on screen until it is read.
#[cfg(windows)]
if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
eprintln!("Press Enter to close.");
let _ = std::io::stdin().read_line(&mut String::new());
}
std::process::exit(if usage {
game::exit_code::USAGE
} else {
game::exit_code::ERROR
});
}
logging::flush();
}
/// The JVM raises its open-file limit to the hard limit as it starts
/// (`-XX:+MaxFDLimit`, on by default), so a vanilla server may hold as many
/// sockets and region files as the host allows, not the soft default (often
/// 1024 in a container). The same here; macOS caps it at `OPEN_MAX`, as the
/// JVM does there.
fn raise_open_file_limit() {
#[cfg(unix)]
{
use rustix::process::{Resource, Rlimit, getrlimit, setrlimit};
let limit = getrlimit(Resource::Nofile);
let hard = if cfg!(target_os = "macos") {
Some(limit.maximum.map_or(10_240, |h| h.min(10_240)))
} else {
limit.maximum
};
let raised = match (limit.current, hard) {
(Some(soft), Some(hard)) => hard > soft,
(Some(_), None) => true,
(None, _) => false,
};
if raised {
let to = Rlimit {
current: hard,
maximum: limit.maximum,
};
match setrlimit(Resource::Nofile, to) {
Ok(()) => log::info!(
"Open file limit raised from {:?} to {hard:?}",
limit.current
),
Err(e) => log::warn!("Open file limit stays at {:?}: {e}", limit.current),
}
}
}
}
+7
View File
@@ -2415,6 +2415,10 @@ struct Game<'a> {
use_duration: i32,
/// A mod's `Start` or `Tick` set that use's ticks left (`game/item_use.rs`).
use_ticks_set: bool,
/// The crossbow charge's start and middle loading sounds played so far
/// (`game/crossbow.rs`): each charge plays each of them once, however
/// far a mod's tick moved the charge at a time.
crossbow_sounds: [bool; 2],
/// Camera.oldFovModifier / fovModifier.
/// `Camera` (eye height and field-of-view easing, view placement).
view: horizon_client::camera::Camera,
@@ -11777,6 +11781,8 @@ impl Game<'_> {
self.streaming.sky_light = dim != Dim::Nether;
self.streaming.nether_shading = dim == Dim::Nether;
self.load_world_state();
// The level the world opens in, before any of its chunks.
self.native_level_opened(dim);
Ok(())
}
/// Streaming and region storage for one dimension of a world.
@@ -17975,6 +17981,7 @@ impl<'a> Game<'a> {
using: None,
use_duration: 0,
use_ticks_set: false,
crossbow_sounds: [false; 2],
view: Default::default(),
limiter: horizon_client::window::FramerateLimiter::new(0),
frame_clock: Default::default(),
+7
View File
@@ -1047,6 +1047,13 @@ impl Game<'_> {
/// floor there, else on top of the boat.
pub(super) fn dismount(&mut self, i: usize) {
self.riding = None;
// `AbstractBoat.tick`'s non-authoritative branch: the copy of a boat
// that no player of this side drives drops its cached motion
// (`setDeltaMovement(Vec3.ZERO)`, `deltaRotation = 0`) and is not
// moved, so a boat is at a standstill when its rider gets out instead
// of coasting on the velocity of the ride that just ended.
self.boats[i].velocity = Vec3::ZERO;
self.boats[i].delta_rotation = 0.0;
let target = self.boat_dismount_target(i, 0.6, self.yaw);
if let Some(p) = self.player.as_mut() {
p.position = target;
+3 -1
View File
@@ -5908,7 +5908,9 @@ impl Game<'_> {
}
return Ok(true);
}
let p = if fluid {
// useOn: a clicked block without a collision shape (vines, grass)
// takes the mob itself, any other the one on the clicked face.
let p = if fluid || self.collision_empty(hit.pos) {
hit.pos
} else {
[
+68 -5
View File
@@ -43,6 +43,29 @@ fn projectile_slot(
})
}
/// `CrossbowItem.onUseTick`'s loading sounds for a charge now at `charge`
/// (of `getChargeDuration`) that has played `played` so far: the start and
/// middle sounds each play once the charge reaches their point, and both
/// count as unheard again while the charge is under the first one. A tick
/// that jumped the charge past a point still plays that point's sound, as
/// vanilla's two flags do. Returns the flags for the next tick and the
/// sounds to play now.
fn charge_sounds(
charge: f32,
played: [bool; 2],
sounds: [&'static str; 3],
) -> ([bool; 2], Option<&'static str>, Option<&'static str>) {
let mut played = if charge < 0.2 { [false; 2] } else { played };
let mut play = [None; 2];
for (i, point) in [0.2, 0.5].into_iter().enumerate() {
if charge >= point && !played[i] && !sounds[i].is_empty() {
played[i] = true;
play[i] = Some(sounds[i]);
}
}
(played, play[0], play[1])
}
impl Game<'_> {
pub(super) fn charge_ticks(stack: &ItemStack) -> u32 {
// crossbow_charge_time: 1.25 s, minus 0.25 s per Quick Charge level.
@@ -100,17 +123,19 @@ impl Game<'_> {
let held = self.ticks_held(stack, ticks);
self.using = Some((slot, ticks + 1));
let charge = Self::charge_ticks(stack).max(1) as f32;
let (before, now) = (held as f32 / charge, (held + 1) as f32 / charge);
let now = (held + 1) as f32 / charge;
let sounds = Self::charging_sounds(stack);
let at = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]);
if before < 0.2 && now >= 0.2 && !sounds[0].is_empty() {
self.audio.play(sounds[0], at, 0.5, 1.0);
let (played, start, middle) = charge_sounds(now, self.crossbow_sounds, sounds);
self.crossbow_sounds = played;
if let Some(sound) = start {
self.audio.play(sound, at, 0.5, 1.0);
}
if before < 0.5 && now >= 0.5 && !sounds[1].is_empty() {
self.audio.play(sounds[1], at, 0.5, 1.0);
if let Some(sound) = middle {
self.audio.play(sound, at, 0.5, 1.0);
}
let charged = stack.data().is_some_and(|d| !d.charged.is_empty());
if now >= 1.0 && !charged {
@@ -254,6 +279,44 @@ impl Game<'_> {
mod tests {
use super::*;
/// A charge that crosses a point between two ticks plays that point's
/// sound, and one a mod's tick jumps over (`native_living_entity_use_item_tick`
/// cutting the ticks left) still plays every point it passed, as
/// `CrossbowItem.onUseTick`'s two flags do.
#[test]
fn a_jumped_charge_still_plays_the_loading_sounds_it_passed() {
let sounds = [
"minecraft:item.crossbow.loading_start",
"minecraft:item.crossbow.loading_middle",
"minecraft:item.crossbow.loading_end",
];
// Each step: the charge and the sounds it plays, from the flags the
// step before left.
let mut played = [false; 2];
let mut step = |charge: f32| {
let (next, start, middle) = charge_sounds(charge, played, sounds);
played = next;
[start, middle]
};
assert_eq!(step(0.1), [None, None]);
assert_eq!(step(0.25), [Some(sounds[0]), None]);
assert_eq!(step(0.25), [None, None]);
assert_eq!(step(0.55), [None, Some(sounds[1])]);
assert_eq!(step(1.0), [None, None]);
// A charge below the first point is a new charge: both sound again.
assert_eq!(step(0.1), [None, None]);
assert_eq!(step(0.6), [Some(sounds[0]), Some(sounds[1])]);
// One tick from the start past every point, as a mod's tick makes it.
assert_eq!(step(0.1), [None, None]);
assert_eq!(step(0.9), [Some(sounds[0]), Some(sounds[1])]);
// Quick Charge 1 and up have no middle sound.
let quick = [sounds[0], "", sounds[2]];
assert_eq!(
charge_sounds(0.9, [false; 2], quick),
([true, false], Some(sounds[0]), None)
);
}
#[test]
fn held_projectiles_come_before_the_inventory() {
let stack = |item: &str| Some(ItemStack::new(item, 1));
+38 -3
View File
@@ -177,6 +177,15 @@ impl EndFlash {
fn intensity(&self, partial: f32) -> f32 {
self.old_intensity + (self.intensity - self.old_intensity) * partial
}
/// SkyRenderer.renderEndFlash's pose: the sprite at height 100 turned to the flash.
fn pose(&self) -> glam::Mat4 {
flash_pose(self.x_angle, self.y_angle)
}
}
fn flash_pose(x_angle: f32, y_angle: f32) -> glam::Mat4 {
glam::Mat4::from_rotation_y((180.0 - y_angle).to_radians())
* glam::Mat4::from_rotation_x((-90.0 - x_angle).to_radians())
}
impl EndState {
pub(super) fn locator(&self) -> Option<Arc<VanillaTerrain>> {
@@ -316,9 +325,7 @@ impl Game<'_> {
.get("environment/celestial/end_flash")
.copied()
{
let flash = &self.end.flash;
let pose = glam::Mat4::from_rotation_y((180.0 - flash.y_angle).to_radians())
* glam::Mat4::from_rotation_x((-90.0 - flash.x_angle).to_radians());
let pose = self.end.flash.pose();
let level = (intensity.clamp(0.0, 1.0) * 255.0) as u32;
let color = level << 24 | level << 16 | level << 8 | level;
let corners = [
@@ -344,6 +351,21 @@ impl Game<'_> {
out
}
/// Iris's `endFlashIntensity` and the flash's direction (camera-relative, length 100: the
/// sprite's centre), for shader packs; no flash outside the End.
pub(super) fn end_flash_for_pack(&self, partial: f32) -> (f32, glam::Vec3) {
if self.dim != Dim::End {
return (0.0, glam::Vec3::ZERO);
}
let flash = &self.end.flash;
(
flash.intensity(partial).clamp(0.0, 1.0),
flash
.pose()
.transform_point3(glam::Vec3::new(0.0, 100.0, 0.0)),
)
}
/// LightmapRenderStateExtractor: End flashes add to the sky light factor
/// unless Hide Lightning Flashes is on (the sky sprite is not gated), a
/// third of it under a boss bar's world fog.
@@ -1859,4 +1881,17 @@ mod tests {
let centre = corners.iter().copied().sum::<glam::Vec3>() / 4.0;
assert!((centre - at).abs_diff_eq(up_dir * 0.125, 1e-5), "{centre}");
}
/// `endFlashPosition`'s direction: the flash sprite's centre, 100 blocks away.
#[test]
fn the_end_flash_is_where_its_sprite_is() {
let at =
|x: f32, y: f32| flash_pose(x, y).transform_point3(glam::Vec3::new(0.0, 100.0, 0.0));
// Straight up whatever the turn, and level toward the north at y_angle 180.
assert!(at(-90.0, 37.0).abs_diff_eq(glam::Vec3::new(0.0, 100.0, 0.0), 1e-3));
assert!(at(0.0, 180.0).abs_diff_eq(glam::Vec3::new(0.0, 0.0, -100.0), 1e-3));
for (x, y) in [(-60.0, -180.0), (10.0, 95.0), (-25.0, 3.0)] {
assert!((at(x, y).length() - 100.0).abs() < 1e-3);
}
}
}
@@ -100,6 +100,15 @@ pub(super) fn blast_reaches(center: [f64; 3], diameter: f64, at: [f64; 3]) -> bo
d / diameter <= 1.0
}
/// `ItemEntity.ignoreExplosion` through
/// `ServerExplosion.shouldAffectBlocklikeEntities`: an explosion that does not
/// affect blocklike entities is ignored entirely by a dropped item. With
/// `mobGriefing` off that is every blast that keeps blocks; with it on, none
/// (no wind charge goes through `server_explode`).
fn items_ignore_explosion(mob_griefing: bool, interaction: BlockInteraction) -> bool {
!mob_griefing && interaction == BlockInteraction::Keep
}
/// Whether a blast leaves item frames, paintings, cushions and armour stands
/// alone. Without `mobGriefing`, a blast that keeps blocks is ignored
/// (`ServerExplosion.shouldAffectBlocklikeEntities` through their
@@ -605,9 +614,15 @@ impl Game<'_> {
}
self.mobs = mobs;
// Dropped items: 5 health, destroyed by the explosion damage (nether stars resist).
// `ItemEntity.ignoreExplosion` asks `shouldAffectBlocklikeEntities`
// first, so a blast that keeps blocks leaves every item untouched.
let items_ignore = items_ignore_explosion(self.ui.rules.bool("mob_griefing"), interaction);
let before = self.dropped_ids();
let mut items = std::mem::take(&mut self.dropped);
items.retain_mut(|item| {
if items_ignore {
return true;
}
let feet = [item.position.x, item.position.y, item.position.z];
let dist = distance(feet) / diameter;
if dist > 1.0 {
@@ -1201,6 +1216,20 @@ mod tests {
assert_eq!(blast_resistance(4.0, None, false), 4.0, "another blast");
}
/// A dropped item survives a mob's blast untouched while `mobGriefing` is
/// off, and is thrown and hurt as usual by one that destroys blocks or by
/// any blast with the rule back on.
#[test]
fn items_ignore_a_blast_that_keeps_blocks_with_griefing_off() {
assert!(items_ignore_explosion(false, BlockInteraction::Keep));
assert!(!items_ignore_explosion(false, BlockInteraction::Destroy));
assert!(!items_ignore_explosion(
false,
BlockInteraction::DestroyWithDecay
));
assert!(!items_ignore_explosion(true, BlockInteraction::Keep));
}
#[test]
fn blasts_reach_entities_within_their_diameter() {
// A TNT blast: radius 4, so the entity pass reaches 8 blocks out.
@@ -207,7 +207,7 @@ impl Game<'_> {
self.process_level_events()
}
/// The block at `pos` has no collision shape.
fn collision_empty(&self, pos: [i32; 3]) -> bool {
pub(super) fn collision_empty(&self, pos: [i32; 3]) -> bool {
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
+6 -1
View File
@@ -869,7 +869,12 @@ impl Game<'_> {
self.lan = Some(host);
let lines = self.as_console(|g| {
g.chat_capture = Some(Vec::new());
g.run_command(&command);
// A command a native mod registered goes to the mods
// first, as the owner's own chat does (the console is
// level 4, so every registered usage is allowed).
if !g.perform_native_command(conn, 4, command.trim()) {
g.run_command(&command);
}
g.chat_capture.take().unwrap_or_default()
});
host = self.lan.take().expect("a command puts the hub back");
@@ -32,6 +32,9 @@ impl Game<'_> {
self.using = Some((self.held_slot(), 0));
self.use_duration = duration;
self.use_ticks_set = ticks_set;
// A new charge hears the crossbow's loading sounds again
// (`game/crossbow.rs`).
self.crossbow_sounds = [false; 2];
true
}
/// `ItemStack.getUseDuration`: a consumable's time, 72000 for what is
+27 -2
View File
@@ -11,8 +11,9 @@
//! is teleported where the player lands (`ServerPlayer.dismountTo`).
use super::{Game, Keys, lan::LanHost, minecarts::SEAT};
use horizon_client::collisions::SceneCollisions;
use horizon_client::lan::hub::no_blocks_in;
use horizon_client::mobs::equine;
use horizon_core::geometry::{Aabb, Vec3};
use horizon_core::geometry::{Aabb, Axis, Vec3};
use horizon_protocol::{
packets::play::{self, Clientbound as C},
text::translatable,
@@ -367,7 +368,31 @@ impl Game<'_> {
back(host, old, yaw);
return;
}
host.hub.guests[gi].vehicle_good = Some([target.x, target.y, target.z]);
// clientVehicleIsFloating: the move didn't go down a step, the vehicle hadn't come down onto
// something on its last move (`verticalCollisionBelow` before this one), and nothing but air is
// around its box grown by 0.0625 and stretched 0.55 down (water holds a boat up). A flying or
// no-gravity mount, and a server that allows flight, never float.
let (gravity, no_gravity) = match mount {
Some(i) => (0.08, self.mobs[i].no_gravity),
None => (0.04, false),
};
let around = moved_box
.inflate(Vec3::new(0.0625, 0.0625, 0.0625))
.expand_towards(Vec3::new(0.0, -0.55, 0.0));
let corner = |max: bool| {
[Axis::X, Axis::Y, Axis::Z].map(|a| if max { around.max(a) } else { around.min(a) })
};
let no_blocks = no_blocks_in(&self.world, corner(false), corner(true));
let allow_flight = host.hub.config.allow_flight;
let g = &mut host.hub.guests[gi];
g.vehicle_good = Some([target.x, target.y, target.z]);
let rested = std::mem::replace(
&mut g.vehicle_rests,
wanted.y < 0.0 && resolved.y != wanted.y,
);
g.vehicle_floating =
wanted.y >= -0.03125 && !rested && !allow_flight && !no_gravity && no_blocks;
g.vehicle_gravity = gravity;
if let Some(i) = mount {
// absSnapTo: where and how its rider's client turned it; the
// rider sits on it from its own ride tick.
+3 -3
View File
@@ -1,6 +1,6 @@
//! The game's side of the Launch API (docs/api/launch.md): the options another launcher may pass,
//! `--launch-api`'s reply, the exit codes, and docs/api/launch-reference.md, generated from them.
//! Update it with: `HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --bin horizon-client launch_api`.
//! Update it with: `HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --lib launch_api`.
/// The Launch API's version (docs/api/stability.md): an addition makes the next patch.
pub const VERSION: &str = "0.1.0";
@@ -155,7 +155,7 @@ pub fn markdown() -> String {
let w = &mut out;
let _ = writeln!(
w,
"<!-- Generated from crates/horizon-client/src/game/launch_api.rs; a test fails when this file is out of date. Update it with: HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --bin horizon-client launch_api -->"
"<!-- Generated from crates/horizon-client/src/game/launch_api.rs; a test fails when this file is out of date. Update it with: HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --lib launch_api -->"
);
let _ = writeln!(w, "# Launch API reference\n");
let _ = writeln!(
@@ -265,7 +265,7 @@ mod tests {
.replace("\r\n", "\n");
assert!(
have == want,
"docs/api/launch-reference.md is out of date: HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --bin horizon-client launch_api"
"docs/api/launch-reference.md is out of date: HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --lib launch_api"
);
}
}
+55 -1
View File
@@ -80,6 +80,9 @@ pub(super) struct Parked {
lan_block_events: Vec<([i32; 3], i32, i32, u32)>,
lan_entity_events: Vec<(i32, i8, [f64; 3])>,
lan_critical_hits: Vec<(Option<u32>, i32, bool)>,
/// The hub's tracked entities of this level (`LanHost::states`), as its last tick described
/// them: what its players' moves are checked against before this tick describes them again.
lan_states: horizon_client::lan::entity::States,
/// Only the per-level parts of these are used (`swap_level`).
anims: BlockEntityAnims,
raiding: Raiding,
@@ -118,6 +121,9 @@ impl Default for Pass {
#[derive(Default)]
pub(super) struct Levels {
parked: HashMap<Dim, Parked>,
/// The levels whose open the mods heard (`native_level_load`), each to
/// be told closed once.
heard_open: Vec<Dim>,
}
impl Levels {
@@ -213,6 +219,7 @@ impl Game<'_> {
lan_block_events: Vec::new(),
lan_entity_events: Vec::new(),
lan_critical_hits: Vec::new(),
lan_states: Default::default(),
anims: Default::default(),
raiding: Default::default(),
sculk: Default::default(),
@@ -291,7 +298,11 @@ impl Game<'_> {
/// The level that was in place is kept with the others; the shared
/// clock and weather go with the swap. `host` is the hub when the caller
/// holds it (`self.lan` is taken during the network tick).
pub(super) fn enter_level(&mut self, host: Option<&mut LanHost>, target: Dim) -> Result<()> {
pub(super) fn enter_level(
&mut self,
mut host: Option<&mut LanHost>,
target: Dim,
) -> Result<()> {
// The in-frame game keeps its one level (`enter_dimension`).
if self.options.execution_role.is_client() {
return Ok(());
@@ -310,12 +321,23 @@ impl Game<'_> {
// The sculk tables' watched blocks are built once per game.
p.world.watched = std::mem::take(&mut self.world.watched);
self.swap_level(&mut p);
// The tracked entities go with their level: a level's moves, read before its tick
// describes its entities again, would otherwise meet the last level's (the anticheat
// missed the boat under a player while anyone was in another dimension).
let states = match host.as_deref_mut() {
Some(h) => Some(&mut h.states),
None => self.lan.as_mut().map(|h| &mut h.states),
};
if let Some(states) = states {
std::mem::swap(states, &mut p.lan_states);
}
p.idle = 0;
let left = std::mem::replace(&mut self.dim, target);
self.levels.parked.insert(left, p);
if opened {
// The new streaming's radius and meshing options.
self.apply_settings();
self.native_level_opened(target);
}
}
let host = match host {
@@ -617,10 +639,42 @@ impl Game<'_> {
self.save_level(true)?;
self.enter_level(None, back)?;
self.levels.parked.remove(&dim);
self.native_level_closed(dim);
}
Ok(())
}
/// The mods hear `dim`'s level open (`LevelEvent.Load`), on a server.
pub(super) fn native_level_opened(&mut self, dim: Dim) {
if self.remote.is_some()
|| self.options.execution_role.is_client()
|| self.levels.heard_open.contains(&dim)
{
return;
}
self.levels.heard_open.push(dim);
self.native_level_load(dim.id());
}
/// The mods hear `dim`'s level close (`LevelEvent.Unload`), once for
/// each open they heard.
fn native_level_closed(&mut self, dim: Dim) {
let Some(i) = self.levels.heard_open.iter().position(|d| *d == dim) else {
return;
};
self.levels.heard_open.remove(i);
self.native_level_unload(dim.id());
}
/// Every level still open closes with the server, however it ends
/// (`MinecraftServer.stopServer`'s `level.close()` loop), in vanilla's
/// order of its levels: the Overworld, the Nether, the End.
pub(super) fn native_levels_closing(&mut self) {
for dim in [Dim::Overworld, Dim::Nether, Dim::End] {
self.native_level_closed(dim);
}
}
/// Save every parked level (`MinecraftServer.saveAllChunks`); the level
/// in place is the caller's to save.
pub(super) fn save_parked_levels(&mut self, wait: bool) -> Result<()> {
+61 -1
View File
@@ -631,7 +631,15 @@ impl Game<'_> {
horizon_assets::item_model::SpecialKind::Head {
kind: kind.to_owned(),
texture: None,
animation: 0.0,
animation: worn_head_animation(
mob,
// The local player's synthetic mob carries no `mount`.
mob.extra
.mount
.or(if local_player { self.riding } else { None }),
&self.mobs,
alpha as f32,
),
}
},
};
@@ -685,11 +693,63 @@ impl Game<'_> {
}
}
/// `LivingEntityRenderer.extractRenderState`'s `wornHeadAnimationPos`: the
/// walk position of the living mob `mount` carries the wearer on (a boat or
/// minecart is no mob, so it is not found), else the wearer's own.
fn worn_head_animation(wearer: &Mob, mount: Option<u32>, mobs: &[Mob], alpha: f32) -> f32 {
mount
.and_then(|id| mobs.iter().find(|m| m.id == id))
.map_or_else(
|| horizon_client::mobs::anim::state(wearer, alpha).pos,
|v| horizon_client::mobs::anim::walk_position(v, alpha),
)
}
#[cfg(test)]
mod tests {
use super::*;
use glam::{Mat4, Vec3};
/// A mob of the prepared resources' type `kind` (None without them).
fn mob(id: u32, kind: &str) -> Option<horizon_client::mobs::Mob> {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
let types = horizon_client::mobs::MobTypes::load(&root).ok()?;
let t = std::sync::Arc::new(types.types.get(kind)?.clone());
Some(horizon_client::mobs::Mob::new(
id,
t,
horizon_core::geometry::Vec3::new(0.0, 0.0, 0.0),
0.0,
))
}
#[test]
fn a_worn_head_follows_the_living_vehicle() {
let (Some(mut wearer), Some(mut horse)) =
(mob(1, "minecraft:zombie"), mob(2, "minecraft:horse"))
else {
return;
};
wearer.limb_position = 1.0;
wearer.limb_speed = 0.5;
horse.limb_position = 4.0;
horse.limb_speed = 1.0;
let mobs = [horse];
// Walking on its own: its own position (1 - 0.5 * 0.5).
assert_eq!(worn_head_animation(&wearer, None, &mobs, 0.5), 0.75);
// A mount that is no mob (boat, minecart) or unknown: still its own.
assert_eq!(worn_head_animation(&wearer, Some(99), &mobs, 0.5), 0.75);
// Riding the horse (a passenger's own position is 0): the horse's.
wearer.extra.passenger = true;
assert_eq!(worn_head_animation(&wearer, None, &mobs, 0.5), 0.0);
assert_eq!(worn_head_animation(&wearer, Some(2), &mobs, 0.5), 3.5);
// Dead wearer, living vehicle: the vehicle's.
wearer.health = 0.0;
assert_eq!(worn_head_animation(&wearer, Some(2), &mobs, 0.5), 3.5);
}
#[test]
fn carried_block_vertices_match_vanilla_block_coordinates() {
// Captured from 26.3 CarriedBlockLayer.submit with an identity PoseStack:
+44 -2
View File
@@ -1146,6 +1146,24 @@ impl Game<'_> {
/// Atlas, models, renderer and client caches already refer to the new resources.
/// Selection/F3+T and server-pack reloads share this point; initial startup does not.
pub(super) fn native_client_resources_reloaded(&mut self) {}
/// The client made a new level for `dimension` (NeoForge's
/// `LevelEvent.Load` from the `ClientLevel` constructor): on joining a
/// server, single player's own included, and on changing dimension.
/// Its chunks come after. When the dimension changes, the old level's
/// `native_client_level_unload` follows this, as `Minecraft.setLevel`
/// tells it after the new level is made. Client-side, on the window; the
/// server's levels have `native_level_load`. API @API:client_level_events@.
pub(super) fn native_client_level_load(&mut self, dimension: &str) {
let _ = dimension;
}
/// The client dropped its level for `dimension` (`LevelEvent.Unload`
/// from `Minecraft.setLevel` and `disconnect`): replaced by another
/// dimension's, or on leaving the server. Once for each
/// `native_client_level_load`. Not told when the game itself quits.
/// Client-side. API @API:client_level_events@.
pub(super) fn native_client_level_unload(&mut self, dimension: &str) {
let _ = dimension;
}
/// Once a tick, in every `Game`: the window's (in single player, on LAN
/// and on servers), and the server's own (`native_window` is false
/// there: the single player server thread, a dedicated server).
@@ -2173,7 +2191,9 @@ impl Game<'_> {
/// without its slash, for a command a mod registered and the player's
/// level allows: set `ran` when it is yours. The player's state is
/// swapped in for the call, so `native_player` and the other helpers
/// read that player; `native_feedback` answers them. API 8.
/// read that player; `native_feedback` answers them. A single player's
/// owner console (`horizon-ctl command`) reaches it too, as the owner at
/// level 4. API 8.
pub(super) fn native_perform_command(
&mut self,
player: &NativeServerPlayer,
@@ -2583,6 +2603,28 @@ impl Game<'_> {
pub(super) fn native_net_from_server(&mut self, channel: &str, data: &[u8]) {
let _ = (channel, data);
}
/// `dimension`'s level opened on the server (NeoForge's `LevelEvent.Load`,
/// posted in `MinecraftServer.createLevels`): before any of its chunks
/// (`native_chunk_load`), so a mod sets up what it keeps for the level
/// here. The level the world opens in comes before `WorldOpenReady`; the
/// others open when someone first goes there (a player, a portal, an
/// `execute in` command), with the level in place. Once for each open:
/// `native_level_unload` follows. Server-side. API @API:level_events@.
pub(super) fn native_level_load(&mut self, dimension: &str) {
let _ = dimension;
}
/// `dimension`'s level closed (NeoForge's `LevelEvent.Unload`, posted after
/// `level.close()` in `MinecraftServer.stopServer`), after its last save:
/// drop what the mod kept for it. A level other than the Overworld closes
/// once nobody has been in it for 300 ticks, after its chunks' unloads;
/// every level still open closes when the server stops, before
/// `WorldCloseFinal`, and when its open or its loop fails, before
/// `WorldOpenFailed` or `WorldCloseFailed`. Once for each
/// `native_level_load`. Don't read its blocks. Server-side. API
/// @API:level_events@.
pub(super) fn native_level_unload(&mut self, dimension: &str) {
let _ = dimension;
}
/// Chunk `chunk` (`[x, z]`, in chunks) of `dimension` came into the world
/// (NeoForge's `ChunkEvent.Load`): `new` when it was just generated, false
/// when read from the save. A flat world's chunk that leaves before it's
@@ -5452,7 +5494,7 @@ mod tests {
#[test]
fn loot_table_load_stays_hookable() {
let source = include_str!("../../../horizon-world/src/loot.rs");
let source = include_str!("../../../horizon-world/src/loot.rs").replace("\r\n", "\n");
// From its doc comment's last line, so an attribute above it counts.
let hook = source.find("pub fn native_loot_table_load(").unwrap();
let start = source[..hook].rfind("///").unwrap();
@@ -239,6 +239,8 @@ impl Game<'_> {
/// `error_text` is the engine's own error text, unchanged.
pub(super) fn native_lifecycle_world_failed(&mut self, error_text: &str) {
// The levels that opened before the failure close first.
self.native_levels_closing();
if let Some(context) = self.native_lifecycle.world_open_failed() {
self.native_world_lifecycle(&context, WorldPhase::WorldOpenFailed, error_text);
}
@@ -246,6 +248,7 @@ impl Game<'_> {
/// Report a server-loop or shutdown failure once and retire its epoch.
pub(super) fn native_lifecycle_close_failed(&mut self, error_text: &str) {
self.native_levels_closing();
if let Some(context) = self.native_lifecycle.world_close_failed() {
self.native_world_lifecycle(&context, WorldPhase::WorldCloseFailed, error_text);
}
@@ -256,6 +259,8 @@ impl Game<'_> {
if self.remote.is_some() {
return;
}
// Each level closes after the final save (`LevelEvent.Unload`).
self.native_levels_closing();
if let Some(context) = self.native_lifecycle.world_close_final() {
self.native_world_lifecycle(&context, WorldPhase::WorldCloseFinal, "");
}
@@ -709,7 +709,9 @@ impl Game<'_> {
if let Some(search) = self.recipe_ui.search.as_mut() {
search.x = xo + 25.0;
search.y = yo + 13.0;
let magnifier = inside(cursor, xo + 8.0, yo + 13.0, 17.0, 14.0);
// RecipeBookComponent's magnifierIconPlacement: from the book's
// left edge plus eight to the search box's own left edge.
let magnifier = inside(cursor, xo + 8.0, yo + 13.0, search.x - xo, 14.0);
if magnifier || inside(cursor, search.x, search.y, search.width, search.height) {
self.recipe_ui.search_drag = true;
search.focused = true;
+94 -17
View File
@@ -471,6 +471,9 @@ pub(super) struct Remote {
/// the first teleport, which takes them then.
abilities: Option<play::PlayerAbilities>,
loaded_sent: bool,
/// The dimension of the client level the mods heard made
/// (`native_client_level_load`), to be told dropped once.
heard_level: Option<String>,
/// `ClientPacketListener.levelLoadTracker`: the loading screen after a login
/// or respawn, until the player's section is shown.
pub(super) level_load: horizon_client::level_loading::LevelLoadTracker,
@@ -967,6 +970,7 @@ impl Game<'_> {
carried: None,
abilities: None,
loaded_sent: false,
heard_level: None,
level_load: horizon_client::level_loading::LevelLoadTracker::new(0),
sent_info: info,
predictions: HashMap::new(),
@@ -1073,6 +1077,10 @@ impl Game<'_> {
self.clear_narrator();
// A world is leaving either way: its icon attempt goes with it.
self.world_icon = None;
// Minecraft.disconnect: the client level goes.
if let Some(old) = self.remote.as_mut().and_then(|r| r.heard_level.take()) {
self.native_client_level_unload(&old);
}
if let Some(mut r) = self.remote.take() {
r.session.disconnect();
}
@@ -2069,6 +2077,15 @@ impl Game<'_> {
if let Some(renderer) = self.renderer.as_mut() {
renderer.clear_sections(self.world.layout());
}
// The new ClientLevel is told before setLevel drops the old one.
let old = self
.remote
.as_mut()
.and_then(|r| r.heard_level.replace(spawn.dimension.clone()));
self.native_client_level_load(&spawn.dimension);
if let Some(old) = old {
self.native_client_level_unload(&old);
}
}
let has_sky = self
.remote
@@ -2176,19 +2193,7 @@ impl Game<'_> {
if let Some((_, mut loaded)) =
horizon_client::save::decode_block_entity(&Tag::Compound(full))
{
// The turn of a vault's item and a trial spawner's mob is the client's
// own, not in the tag: an update keeps it (VaultClientData).
match (self.world.block_entities.get(&key), &mut loaded) {
(Some(BlockEntity::Vault(old)), BlockEntity::Vault(new)) => {
new.spin = old.spin;
new.o_spin = old.o_spin;
}
(Some(BlockEntity::TrialSpawner(old)), BlockEntity::TrialSpawner(new)) => {
new.spin = old.spin;
new.o_spin = old.o_spin;
}
_ => {}
}
keep_client_state(self.world.block_entities.get(&key), &mut loaded);
let command_block = matches!(loaded, BlockEntity::CommandBlock(_));
self.world.block_entities.insert(key, loaded);
self.update_spawner_display(key);
@@ -9567,7 +9572,9 @@ fn use_ends_click(info: &horizon_client::items::ItemInfo, fall_flying: bool) ->
/// Whether `Item.use` usually acts on this item (eats, drinks, equips, raises,
/// throws, shoots, fills or places), which ends `Minecraft.startUseItem` at
/// its hand. Tools and materials pass, and the off hand gets its turn. The
/// server decides; this is the client's guess of whether to go on.
/// server decides; this is the client's guess of whether to go on. A carrot or warped fungus
/// on a stick is not here: `FoodOnAStickItem.use` passes on a client, and only the server's
/// answer to a boost that can run swings the hand.
fn item_has_use(info: &horizon_client::items::ItemInfo) -> bool {
let id = info.id.trim_start_matches("minecraft:");
id.ends_with("_spear")
@@ -9603,8 +9610,6 @@ fn item_has_use(info: &horizon_client::items::ItemInfo) -> bool {
| "goat_horn"
| "bundle"
| "brush"
| "carrot_on_a_stick"
| "warped_fungus_on_a_stick"
)
}
@@ -9614,6 +9619,31 @@ fn client_view_distance(render_distance: i32) -> i8 {
render_distance.clamp(2, i32::from(i8::MAX)) as i8
}
/// `ClientPacketListener.handleBlockEntityData` loads the tag into the existing
/// block entity, so the state the client builds itself stays: copied from `old`
/// into `new`, the block entity decoded from the tag.
fn keep_client_state(old: Option<&BlockEntity>, new: &mut BlockEntity) {
match (old, new) {
// The turn of a vault's item and a trial spawner's mob (VaultClientData).
(Some(BlockEntity::Vault(old)), BlockEntity::Vault(new)) => {
new.spin = old.spin;
new.o_spin = old.o_spin;
}
(Some(BlockEntity::TrialSpawner(old)), BlockEntity::TrialSpawner(new)) => {
new.spin = old.spin;
new.o_spin = old.o_spin;
}
// A beacon's beam (`BeaconBlockEntity.loadAdditional` reads only the
// powers): a fresh one draws nothing until the next tick walks it again.
(Some(BlockEntity::Beacon(old)), BlockEntity::Beacon(new)) => {
new.sections.clone_from(&old.sections);
new.checking.clone_from(&old.checking);
new.last_check_y = old.last_check_y;
}
_ => {}
}
}
/// `LocalPlayer.onSyncedDataUpdated(DATA_LIVING_ENTITY_FLAGS)`: the server's
/// flags (1 using, 2 the off hand) start a use the client is not running
/// (`startUsingItem`, which needs an item in that hand), and stop one it is
@@ -9847,7 +9877,46 @@ fn client_millis() -> u64 {
#[cfg(test)]
mod tests {
use super::synced_local_use;
use super::{keep_client_state, synced_local_use};
use horizon_client::containers::{Beacon, BlockEntity};
use horizon_world::nbt::Tag;
#[test]
fn a_beacon_update_tag_keeps_the_beam_the_client_walked() {
let old = BlockEntity::Beacon(Box::new(Beacon {
levels: 1,
primary: Some("minecraft:speed".into()),
sections: vec![(0xF9FFFE, 2)],
checking: vec![(0xF9FFFE, 1)],
last_check_y: 70,
..Default::default()
}));
// BeaconBlockEntity.getUpdateTag: saveCustomOnly.
let tag = Tag::compound([
("id", Tag::String("minecraft:beacon".into())),
("x", Tag::Int(0)),
("y", Tag::Int(64)),
("z", Tag::Int(0)),
("primary_effect", Tag::String("minecraft:haste".into())),
(
"secondary_effect",
Tag::String("minecraft:regeneration".into()),
),
("Levels", Tag::Int(4)),
]);
let (_, mut new) = horizon_client::save::decode_block_entity(&tag).unwrap();
keep_client_state(Some(&old), &mut new);
let BlockEntity::Beacon(b) = new else {
panic!("not a beacon");
};
assert_eq!(b.sections, vec![(0xF9FFFE, 2)]);
assert_eq!(b.checking, vec![(0xF9FFFE, 1)]);
assert_eq!(b.last_check_y, 70);
// The powers and the levels are the tag's.
assert_eq!(b.primary.as_deref(), Some("minecraft:haste"));
assert_eq!(b.secondary.as_deref(), Some("minecraft:regeneration"));
assert_eq!(b.levels, 4);
}
#[test]
fn the_server_stopping_the_use_lowers_the_shield() {
@@ -10518,6 +10587,14 @@ mod tests {
for gliding in [false, true] {
assert!(super::use_ends_click(shield, gliding));
assert!(!super::use_ends_click(stick, gliding));
// FoodOnAStickItem.use passes on a client: no swing while the boost cools down.
for rod in [
"minecraft:carrot_on_a_stick",
"minecraft:warped_fungus_on_a_stick",
] {
let rod = items.get(rod).unwrap();
assert!(!super::use_ends_click(rod, gliding), "{}", rod.id);
}
}
}
}
+32 -5
View File
@@ -1713,6 +1713,7 @@ impl Game<'_> {
Option<i64>,
Option<u32>,
u32,
bool,
)> = self
.mobs
.iter()
@@ -1727,10 +1728,11 @@ impl Game<'_> {
m.last_damage.map(|(_, t)| t),
m.extra.mount,
m.age,
m.species() == Species::Bat && horizon_client::mobs::bat::resting(m),
)
})
.collect();
for (id, at, on_ground, in_water, species, damaged, vehicle, age) in snapshot {
for (id, at, on_ground, in_water, species, damaged, vehicle, age, resting) in snapshot {
if on_ground {
self.step_on_redstone_ore(at, false, false);
}
@@ -1749,10 +1751,10 @@ impl Game<'_> {
false,
warden,
);
// Entity.processFlappingMovement with Bee.isFlapping: a bee off
// the ground over air flaps on every other tick (the mob's own
// `MobEvent::GameEvent` is not read here).
if species == Species::Bee && !on_ground && age.is_multiple_of(2) {
// Entity.processFlappingMovement: what flaps this move flaps
// over air only (the mob's own `MobEvent::GameEvent` is not
// read here).
if flaps(species, resting, on_ground, age) {
let below = self.world.block(
at[0].floor() as i32,
(at[1] - 1.0e-5).floor() as i32,
@@ -1954,6 +1956,17 @@ pub(super) fn darkness_blend_added(e: &EffectInstance, blend: bool) -> (f32, f32
(factor, factor)
}
/// `Entity.processFlappingMovement`'s per-tick gate, before its air-below
/// test: `Bee.isFlapping` (flying, every other tick) and `Bat.isFlapping`
/// (awake, every tenth tick).
fn flaps(species: Species, resting: bool, on_ground: bool, age: u32) -> bool {
match species {
Species::Bee => !on_ground && age.is_multiple_of(2),
Species::Bat => !resting && age.is_multiple_of(10),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1973,6 +1986,20 @@ mod tests {
assert_eq!(index.iter().last(), Some(&(41, 4, 0)));
}
/// A bat flaps every tenth tick while it is not resting, whether it is on
/// the ground or not; a resting bat never flaps and nothing else does.
#[test]
fn a_bat_flaps_every_tenth_tick_unless_it_is_resting() {
assert!(flaps(Species::Bat, false, false, 0));
assert!(flaps(Species::Bat, false, true, 10));
assert!(!flaps(Species::Bat, false, false, 11));
assert!(!flaps(Species::Bat, true, false, 20), "resting");
assert!(flaps(Species::Bee, false, false, 4));
assert!(!flaps(Species::Bee, false, true, 4), "a bee on the ground");
assert!(!flaps(Species::Bat, false, false, 5));
assert!(!flaps(Species::Cow, false, false, 0));
}
#[test]
fn centres_and_blocks_agree() {
assert_eq!(center((1, 2, 3)), [1.5, 2.5, 3.5]);
+37 -1
View File
@@ -5,7 +5,7 @@
//! dimension changes (spec 1.2).
use horizon_client::camera::CameraFluid;
use horizon_client::gfx::pack_uniforms::PackInputs;
use horizon_client::gfx::pack_uniforms::{PackInputs, PlayerStatus};
use horizon_client::shader_options::ShaderOptions;
use horizon_client::text::Text;
use horizon_shaders::{BlockIds, Macros, Pack, Properties, dimension_folder, load_post};
@@ -294,6 +294,8 @@ impl Game<'_> {
.get(&(at.x >> 4, at.z >> 4))
.map(|c| c.biome(at.x, at.y, at.z));
let (held_items, held_light) = self.held_for_pack();
let status = self.pack_player_status();
let (end_flash, end_flash_direction) = self.end_flash_for_pack(partial);
let info = biome.map(|b| world_biomes!(self).get(b));
PackInputs {
day_time: self.world.day_time,
@@ -332,6 +334,40 @@ impl Game<'_> {
sky.cloud_height,
),
mod_params: self.native_shader_params_now(partial),
status,
end_flash,
end_flash_direction,
}
}
/// The local player's state for Iris's status uniforms, read as the HUD reads it.
fn pack_player_status(&mut self) -> PlayerStatus {
let spectator = self.spectator();
let survival = !self.creative && !spectator;
let invisible = self.own_invisible();
let riding = self.riding.is_some();
let Some(p) = &mut self.player else {
return PlayerStatus::default();
};
PlayerStatus {
survival,
health: p.health.health(),
max_health: p.attribute("minecraft:max_health").unwrap_or(20.0) as f32,
food: p.health.food.food as f32,
air: p.air as f32,
armor: p
.attribute("minecraft:armor")
.or_else(|_| p.attribute("armor"))
.unwrap_or(0.0) as f32,
spectator,
riding,
feet_in_water: p.fluids.was_touching_water,
swimming: p.pose == horizon_client::pose::Pose::Swimming,
burning: p.fire_ticks > 0 && !spectator,
hurt: p.health.hurt_animation_time > 0,
invisible,
sneaking: p.pose == horizon_client::pose::Pose::Crouching,
sprinting: p.sprinting,
}
}
+11
View File
@@ -276,6 +276,17 @@ impl Game<'_> {
0.2,
(actual * 0.5) as u32,
);
// The players a server serves are sent them too (the window of the server thread).
self.lan_send_particles(super::lan_particles::QueuedParticle {
dimension: self.dim.id(),
options: indicator,
state: None,
at: [feet[0], feet[1] + height * 0.5, feet[2]],
spread: [0.1, 0.0, 0.1],
speed: 0.2,
count: (actual * 0.5) as u32 as i32,
randomization: 0,
});
}
if hurt {
self.last_hurt_by_player.insert(id, self.lan_acting);
+7 -2
View File
@@ -624,7 +624,7 @@ mod tests {
}
world.set(20, 4, 20, water).unwrap();
world.set(21, 4, 20, flowing).unwrap();
let at = Vec3::new(20.5, 6.0, 20.5);
let at = Vec3::new(20.5, 4.0, 20.5);
let mut tnt = PrimedTnt {
id: 0,
position: at,
@@ -702,10 +702,15 @@ mod tests {
.registry
.default_state("minecraft:soul_sand")
.unwrap();
let column = assets
// Soul sand makes an up-pushing column (`drag=false`); the block's
// default state is the down-dragging one.
let drag_down = assets
.registry
.default_state("minecraft:bubble_column")
.unwrap();
let mut up = assets.registry.state(drag_down).unwrap().clone();
up.properties.insert("drag".into(), "false".into());
let column = assets.registry.resolve(&up).unwrap();
for y in 3..=8 {
world.set(20, y, 20, column).unwrap();
}
+21 -1
View File
@@ -632,7 +632,7 @@ impl Game<'_> {
};
if m.session.offers.len() > 7 {
let max = m.session.offers.len() - 7;
m.scroll = (m.scroll as i32 - d.signum() as i32).clamp(0, max as i32) as usize;
m.scroll = scrolled(m.scroll, max, d);
}
}
/// `MerchantScreen` rendering.
@@ -930,10 +930,30 @@ fn drag_scroll(y: f32, offers: usize) -> usize {
((offs * max as f32 + 0.5) as i32).clamp(0, max) as usize
}
/// `MerchantScreen.mouseScrolled`: `Mth.clamp((int)(scrollOff - scrollY), 0, max)`,
/// so a fractional wheel step that truncates to zero does not move the list.
fn scrolled(scroll: usize, max: usize, d: f32) -> usize {
((scroll as f64 - f64::from(d)) as i32).clamp(0, max as i32) as usize
}
#[cfg(test)]
mod tests {
use super::*;
/// `MerchantScreen.mouseScrolled`: the offset is `(int)(scrollOff - scrollY)`
/// clamped, so the list moves by as many rows as the event's delta and a
/// fractional delta still moves one row (the truncation is after the
/// subtraction). Horizon moved exactly one row per event.
#[test]
fn the_offers_scroll_by_the_wheel_delta() {
assert_eq!(scrolled(3, 5, 1.0), 2, "one notch down");
assert_eq!(scrolled(3, 5, -1.0), 4, "one notch up");
assert_eq!(scrolled(3, 5, 0.25), 2, "a fraction still moves a row");
assert_eq!(scrolled(3, 5, 2.5), 0, "a two-and-a-half notch event");
assert_eq!(scrolled(0, 5, 1.0), 0, "clamped at the top");
assert_eq!(scrolled(5, 5, -1.0), 5, "clamped at the bottom");
}
#[test]
fn scroller_takes_presses_only_with_more_than_seven_offers() {
let x = SHIFT + 97.0;
+12
View File
@@ -159,6 +159,14 @@ pub struct Guest {
/// (`clientIsFloating`), and the ticks it has (`aboveGroundTickCount`).
pub client_is_floating: bool,
pub above_ground_ticks: i32,
/// The same for the vehicle the player drives (`clientVehicleIsFloating`,
/// `aboveGroundVehicleTickCount`), with that vehicle's gravity
/// (`getMaximumFlyingTicks(lastVehicle)`) and whether its last accepted
/// move came down onto something (`verticalCollisionBelow`).
pub vehicle_floating: bool,
pub above_ground_vehicle_ticks: i32,
pub vehicle_gravity: f64,
pub vehicle_rests: bool,
/// A position move arrived since the client's last ClientTickEnd
/// (receivedPositionThisTick): a second one is a protocol error.
pub position_this_tick: bool,
@@ -371,6 +379,10 @@ impl Guest {
known_moves: 0,
client_is_floating: false,
above_ground_ticks: 0,
vehicle_floating: false,
above_ground_vehicle_ticks: 0,
vehicle_gravity: 0.0,
vehicle_rests: false,
position_this_tick: false,
anticheat: Default::default(),
replay: None,
+28 -9
View File
@@ -3177,8 +3177,7 @@ impl Hub {
/// move after joining, before the spawn chunks are generated) is not
/// known to be air, so it never makes the player float.
fn no_blocks_around(&self, i: usize, at: [f64; 3], world: &World) -> bool {
let g = &self.guests[i];
let d = g.dimensions();
let d = self.guests[i].dimensions();
let half = f64::from(d.width) / 2.0 + 0.0625;
let lo = [at[0] - half, at[1] - 0.0625 - 0.55, at[2] - half];
let hi = [
@@ -3186,13 +3185,7 @@ impl Hub {
at[1] + f64::from(d.height) + 0.0625,
at[2] + half,
];
let cell = |v: f64| v.floor() as i32;
(cell(lo[0])..=cell(hi[0])).all(|x| {
(cell(lo[2])..=cell(hi[2])).all(|z| {
world.loaded(x, z)
&& (cell(lo[1])..=cell(hi[1])).all(|y| world.is_air(world.block(x, y, z)))
})
})
no_blocks_in(world, lo, hi)
}
/// `ServerGamePacketListenerImpl.tickPlayer`'s flying check: a player the
@@ -3212,6 +3205,20 @@ impl Hub {
g.client_is_floating = false;
g.above_ground_ticks = 0;
}
// The vehicle the player drives, by its own gravity: a boat may hover 160 ticks, a horse 80.
let g = &mut self.guests[i];
if g.vehicle_floating && g.vehicle.is_some() {
g.above_ground_vehicle_ticks += 1;
if g.above_ground_vehicle_ticks > maximum_flying_ticks(g.vehicle_gravity) {
let line = format!("{} was kicked for floating a vehicle too long!", g.name);
let conn = g.conn;
events.push(HostEvent::Warn(line));
self.kick(conn, translatable("multiplayer.disconnect.flying", &[]));
}
} else {
g.vehicle_floating = false;
g.above_ground_vehicle_ticks = 0;
}
}
/// The collision part of handlePlayerPositionChange: whether the move
@@ -3703,6 +3710,18 @@ const PLAYER_GRAVITY: f64 = 0.08;
/// `ServerGamePacketListenerImpl.getMaximumFlyingTicks`: 80 ticks, more where
/// gravity is weaker, none at all (never kicked) without gravity.
/// `ServerGamePacketListenerImpl.noBlocksAround` over a box: every block from `lo` to `hi` is air, in
/// loaded chunks (a chunk not loaded yet is not known to be air: nothing floats there).
pub fn no_blocks_in(world: &World, lo: [f64; 3], hi: [f64; 3]) -> bool {
let cell = |v: f64| v.floor() as i32;
(cell(lo[0])..=cell(hi[0])).all(|x| {
(cell(lo[2])..=cell(hi[2])).all(|z| {
world.loaded(x, z)
&& (cell(lo[1])..=cell(hi[1])).all(|y| world.is_air(world.block(x, y, z)))
})
})
}
fn maximum_flying_ticks(gravity: f64) -> i32 {
if gravity < f64::from(1.0E-5_f32) {
return i32::MAX;
+6 -2
View File
@@ -89,7 +89,9 @@ pub fn save_owner(guest: &Guest, level: &mut Level, world: &Path) {
// never another player's file the world happened to hold.
level.player_uuid.get_or_insert(uuid_ints(guest.uuid));
store(guest, level, None);
let _ = level.write_player(world);
if let Err(e) = level.write_player(world) {
log::warn!("Horizon: the owner's playerdata is not saved: {e}");
}
save(guest, world);
}
@@ -181,7 +183,9 @@ pub fn save(guest: &Guest, world: &Path) {
if let Some(dir) = file.parent() {
let _ = std::fs::create_dir_all(dir);
}
let _ = write_dat(&file, level.player_tag());
if let Err(e) = write_dat(&file, level.player_tag()) {
log::warn!("Horizon: {} is not saved: {e}", file.display());
}
}
/// Put a guest's state in `level`'s player fields; `vehicle` is the
+5 -161
View File
@@ -1,162 +1,6 @@
//! Horizon native client: resources, world streaming, rendering and the
//! bindings between the validated simulation pieces and the game loop.
//! Gameplay and startup, above the reusable client library.
#![forbid(unsafe_code)]
pub mod advancements;
pub mod ambience;
pub mod animate_tick;
pub mod arrows;
pub mod block_contact;
pub use horizon_client_render::{
assets, atlas_sources, audio, auth, ban_notice, bidi, biome, block_edit, block_entities,
block_entity_anim, block_entity_render, border_render, breaking, brewing, camera, chat, chunk,
chunk_map, cit, clouds, collisions, command_block, containers, crash, datapack, dynamic_light,
effects, environment, extras, fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui,
hdr_calibration, hud, hud_layout, hud_overlays, inventory_effects, java_text, jigsaw_block,
level_loading, light, lod, logging, menu_blur, mesher, minimap, mipmap, mod_list,
model_library, models, multiline_edit, native_emissive, options_file, options_fixes,
options_ui, overlay, pack_screen, packs, par, particles, poi_sections, post_chain,
potent_sulfur, preedit, providers, screen_draw, screen_effects, sculk, section_mask, settings,
shader_options, shader_pack_list, signs, skins, sound_engine, sound_events, sound_preview,
spans, split_layout, split_profiles, sprites, streaming, structure_block, tab_list, telemetry,
test_block, test_instance_block, testgen, title_edition, trims, ui, vault, weather, widgets,
world, worldborder, zip,
};
pub mod attack_strength;
pub mod attribute_updates;
pub mod auto_jump;
pub mod banner;
pub mod beehive;
pub mod benchmark;
pub mod block_entity_item;
pub mod block_pattern;
pub mod block_transform;
pub mod boss_overlay;
pub mod cauldron;
pub mod chunk_io;
pub mod clock;
pub mod command_storage;
pub mod confirm_screen;
pub mod container_screen;
pub mod content_identity;
pub mod control;
pub mod crafting;
pub mod creative;
pub mod cushion;
pub mod data_cmd;
pub mod debug_chart;
pub mod debug_entries;
pub mod debug_keys;
pub mod debug_overlay;
pub mod debugworld;
pub mod dialog;
pub mod dialog_screen;
pub mod discord_presence;
pub mod end_crystal;
pub mod end_gateway;
pub mod entities;
pub mod entity_culling;
pub mod entity_nbt;
pub mod extras_hud;
pub mod falling_block;
pub mod fire_render;
pub mod first_person;
pub mod flatgen;
pub mod friends_screen;
pub mod furnace;
pub mod game_mode_switcher;
pub mod game_rules_screen;
pub mod gizmos;
pub mod gossip;
pub mod health;
pub mod heartbeat;
pub mod hotbar;
pub mod hunger;
pub mod inv_sort;
pub mod item_activation;
pub mod item_render;
pub mod item_sync;
pub mod item_tooltip;
pub mod lan;
pub mod landing;
pub mod locator_bar;
pub mod lod_net;
pub mod menu;
pub mod merchant;
pub mod mobs;
pub mod mod_content;
pub mod motion;
pub mod multiplayer;
pub mod music;
pub mod native_data;
pub mod net;
pub mod nether_portal;
pub mod no_resources;
pub mod offline_name;
pub mod old_world;
pub mod osk;
pub mod patrol;
pub mod perf_overlay;
pub mod pick_item;
pub mod player_sync;
pub mod portal_sections;
pub mod pose;
pub mod pregen;
pub mod projectiles;
pub mod raid;
pub mod ram_limit;
pub mod random_tick_sections;
pub mod recipe_book;
pub mod recipe_book_screen;
pub mod reconnect;
pub mod save;
pub mod scoreboard_cmd;
pub mod screen_layout;
pub mod screen_narrator;
pub mod social_screen;
pub use horizon_server_files as server;
pub mod server_packs;
pub mod shadows;
pub mod spear;
pub mod spectator_menu;
pub mod sprint;
pub mod stations;
pub mod stopwatches;
pub mod structure_blocks;
pub mod text_display;
pub mod tick_chunk_membership;
pub mod ticket_levels;
pub mod title;
pub mod toasts;
pub mod ui_theme;
pub mod vanillagen;
pub mod win_screen;
pub mod world_spawn;
pub use horizon_client_core::Result;
pub use horizon_client_core::block_entity_nbt;
pub use horizon_client_core::bossbar;
pub use horizon_client_core::component;
pub use horizon_client_core::fastutil_order;
pub use horizon_client_core::gamerules;
pub use horizon_client_core::lang;
pub use horizon_client_core::nbt_path;
pub use horizon_client_core::scoreboard;
pub use horizon_client_core::snbt;
pub use horizon_client_core::text;
pub use horizon_client_input::clipboard;
pub use horizon_client_input::gamepad;
pub use horizon_client_input::input;
pub use horizon_client_input::interaction_input;
pub use horizon_client_input::key_names;
pub use horizon_client_input::keybinds;
pub use horizon_client_input::keymapping;
pub use horizon_client_input::mouse;
pub use horizon_client_input::window;
pub use horizon_command as command;
pub use horizon_items::blocks;
pub use horizon_items::bundle;
pub use horizon_items::cooldowns;
pub use horizon_items::enchant;
pub use horizon_items::flowers;
pub use horizon_items::item_data;
pub use horizon_items::items;
pub use horizon_items::random_sequences;
extern crate self as horizon_client;
pub use horizon_client_lib::*;
pub mod entry;
pub mod game;
+1 -105
View File
@@ -1,8 +1,5 @@
//! Horizon: native Rust client for Minecraft Java Edition 26.3 gameplay.
#![forbid(unsafe_code)]
mod game;
use horizon_client::{crash, logging, ram_limit};
#[global_allocator]
static ALLOCATOR: horizon_threads::Allocator = horizon_threads::Allocator;
@@ -13,107 +10,6 @@ static ALLOCATOR: horizon_threads::Allocator = horizon_threads::Allocator;
static MOD_API_LEVEL: [u8; 27] = horizon_mods::api_level::MARK;
fn main() {
horizon_threads::configure_allocator();
std::hint::black_box(&MOD_API_LEVEL);
horizon_client::telemetry::mark_start();
let args: Vec<String> = std::env::args().skip(1).collect();
let game_args = args.iter().take_while(|a| *a != "--dedicated");
let help = game_args
.clone()
.any(|a| a == "--help" || a == "-h" || a == "--launch-api");
// --home before the log opens in it.
if let Some(dir) = game_args.clone().skip_while(|a| *a != "--home").nth(1) {
logging::set_game_home(std::path::absolute(dir).unwrap_or_else(|_| dir.into()));
}
// The dedicated server logs in its own folder (--game-dir's), as vanilla's does; the game in its home.
let (home, side, thread) = if args.iter().any(|a| a == "--dedicated") {
let folder = game_args
.clone()
.skip_while(|a| *a != "--game-dir" && *a != "--gameDir")
.nth(1)
.and_then(|dir| std::path::absolute(dir).ok())
.unwrap_or_else(|| ".".into());
(folder, "server", "Server thread")
} else {
// --player-dir too (a game's, not a server's): a player of a shared game folder keeps
// its log and crash reports in its own; crash telemetry stays with the home's consent.
if let Some(dir) = game_args
.clone()
.skip_while(|a| *a != "--player-dir")
.nth(1)
{
logging::set_player_dir(std::path::absolute(dir).unwrap_or_else(|_| dir.into()));
}
(logging::game_home(), "client", "Render thread")
};
if !help {
logging::init(
logging::player_dir_override().unwrap_or(home.as_path()),
thread,
);
// The log's first line names the build, so a bug report that carries the log says which one it was.
log::info!("Horizon {}", crash::version());
crash::install(home.clone(), side);
ram_limit::start(&home, side);
}
raise_open_file_limit();
if let Err(error) = game::run() {
// A bad command line is the caller's mistake, not a crash: no report.
let usage = error.is::<game::UsageError>();
log::error!("Horizon: {error}");
logging::flush();
// The report's path is the last line on stderr (crash::SAVED_LINE).
if !help && !usage {
crash::report_error(&home, side, &*error);
}
// Started from Explorer, the console window closes with the game:
// keep the message on screen until it is read.
#[cfg(windows)]
if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
eprintln!("Press Enter to close.");
let _ = std::io::stdin().read_line(&mut String::new());
}
std::process::exit(if usage {
game::exit_code::USAGE
} else {
game::exit_code::ERROR
});
}
logging::flush();
}
/// The JVM raises its open-file limit to the hard limit as it starts
/// (`-XX:+MaxFDLimit`, on by default), so a vanilla server may hold as many
/// sockets and region files as the host allows, not the soft default (often
/// 1024 in a container). The same here; macOS caps it at `OPEN_MAX`, as the
/// JVM does there.
fn raise_open_file_limit() {
#[cfg(unix)]
{
use rustix::process::{Resource, Rlimit, getrlimit, setrlimit};
let limit = getrlimit(Resource::Nofile);
let hard = if cfg!(target_os = "macos") {
Some(limit.maximum.map_or(10_240, |h| h.min(10_240)))
} else {
limit.maximum
};
let raised = match (limit.current, hard) {
(Some(soft), Some(hard)) => hard > soft,
(Some(_), None) => true,
(None, _) => false,
};
if raised {
let to = Rlimit {
current: hard,
maximum: limit.maximum,
};
match setrlimit(Resource::Nofile, to) {
Ok(()) => log::info!(
"Open file limit raised from {:?} to {hard:?}",
limit.current
),
Err(e) => log::warn!("Open file limit stays at {:?}: {e}", limit.current),
}
}
}
horizon_client::entry::main();
}
+32 -1
View File
@@ -193,7 +193,7 @@ pub fn family(s: Species) -> Family {
| S::Parched
| S::WitherSkeleton
| S::ZombifiedPiglin => Family::Undead,
S::Creeper | S::Spider | S::CaveSpider | S::Silverfish => Family::Hostile,
S::Creeper | S::Spider | S::CaveSpider | S::Silverfish | S::Endermite => Family::Hostile,
S::Squid
| S::GlowSquid
| S::Cod
@@ -3122,6 +3122,37 @@ mod forgiveness_tests {
}
}
#[cfg(test)]
mod endermite_goal_tests {
use super::*;
use crate::mobs::world::with_test_world;
/// `Endermite.registerGoals` is the silverfish's family list without the
/// stone tricks: a powder-snow climb, a stroll at 1.0 and a
/// `HurtByTargetGoal` that alerts the others. The generic monster block
/// an unlisted species gets has none of those (stroll 0.8, no alert).
#[test]
fn an_endermite_takes_the_hostile_goal_list() {
let ran = with_test_world(&[], |_, types| {
let m = Mob::new(
1,
std::sync::Arc::new(types.types["minecraft:endermite"].clone()),
Vec3::ZERO,
0.0,
);
assert_eq!(family(m.species()), Family::Hostile);
assert!(
m.ai.goals.has("ClimbOnTopOfPowderSnow"),
"ClimbOnTopOfPowderSnowGoal"
);
assert!(m.ai.goals.has("Stroll"), "WaterAvoidingRandomStrollGoal");
assert!(m.ai.targets.has("HurtByTarget"));
assert!(m.ai.targets.has("NearestTarget"));
});
let _ = ran;
}
}
#[cfg(test)]
mod fox_screech_tests {
use super::*;
+18 -2
View File
@@ -186,6 +186,10 @@ pub struct State {
pub baby: bool,
pub partial: f32,
}
/// `WalkAnimationState.position(partial)`: babies' legs move 3 times as far.
pub fn walk_position(m: &Mob, alpha: f32) -> f32 {
(m.limb_position - m.limb_speed * (1.0 - alpha)) * if m.is_baby() { 3.0 } else { 1.0 }
}
pub fn state(m: &Mob, alpha: f32) -> State {
let body = m.previous_body_yaw + wrap(m.body_yaw - m.previous_body_yaw) * alpha;
let head = m.previous_head_yaw + wrap(m.head_yaw - m.previous_head_yaw) * alpha;
@@ -198,8 +202,7 @@ pub fn state(m: &Mob, alpha: f32) -> State {
let walking = m.alive() && !m.extra.passenger;
State {
pos: if walking {
// WalkAnimationState.position(partial): babies' legs move 3 times as far.
(m.limb_position - m.limb_speed * (1.0 - alpha)) * if m.is_baby() { 3.0 } else { 1.0 }
walk_position(m, alpha)
} else {
0.0
},
@@ -1238,6 +1241,19 @@ mod flight_pose_tests {
assert_eq!(dry.get("head")[3], 30.0f32.to_radians());
}
#[test]
fn walk_position_triples_for_babies() {
// WalkAnimationState.position(partial) = (position - speed * (1 - partial)) * scale.
let mut m = player();
m.limb_position = 2.0;
m.limb_speed = 0.5;
assert!(!m.is_baby());
assert_eq!(walk_position(&m, 0.5), 1.75);
m.extra.age = -100;
assert!(m.is_baby());
assert_eq!(walk_position(&m, 0.5), 5.25);
}
/// A player: a humanoid model with a shield in its main hand.
fn player() -> Mob {
let mut kind = crate::mobs::sensor_scan_tests::kind();
+11 -4
View File
@@ -399,10 +399,16 @@ fn animation_states(m: &mut Mob, pose_time: i64, sitting: bool) {
st[2].animate_when(transition, age);
}
}
/// `Camel.executeRidersJump`: the dash along the look, once cooled down.
pub fn dash(m: &mut Mob, amount: f32, events: &mut Vec<MobEvent>) {
if m.extra.anim_ticks > 0 || !m.on_ground || refuse_to_move(m) {
return;
/// `Camel.executeRidersJump`: the dash along the look, once cooled down. Returns whether the
/// jump is used up: it is, by a dash or when the camel cannot dash at all (cooling down,
/// refusing to move: `onPlayerJump` would not have taken it); it waits while the camel is off
/// the ground (`AbstractHorse.tickRidden` runs a pending jump when it lands).
pub fn dash(m: &mut Mob, amount: f32, events: &mut Vec<MobEvent>) -> bool {
if m.extra.anim_ticks > 0 || refuse_to_move(m) {
return true;
}
if !m.on_ground {
return false;
}
let yaw = m.yaw.to_radians();
let (dx, dz) = (f64::from(-yaw.msin()), f64::from(yaw.mcos()));
@@ -414,6 +420,7 @@ pub fn dash(m: &mut Mob, amount: f32, events: &mut Vec<MobEvent>) {
m.extra.anim_ticks = DASH_COOLDOWN;
m.extra.flag2 = true;
sound(m, voice(m, 3), events);
true
}
/// `CamelModel.setupAnim`.
pub fn anim(
+732 -88
View File
@@ -1,18 +1,30 @@
//! OptiFine custom entity models over Horizon's mob model layers: `optifine/cem/<name>.jem` (or
//! `cem/<name>/<name>.jem`, and Entity Model Features' `emf/cem/`, which wins) maps to one layer
//! (`zombie.jem` to `zombie#main`, `sheep_wool.jem` to `sheep#wool`), its parts to the layer's
//! vanilla parts, and its boxes join or replace theirs. A baby layer without its own model
//! takes the adult's (OptiFine's fallbacks, and Entity Model Features' for horses too).
//! vanilla parts, and its boxes join or replace theirs. A layer without its own model takes the
//! one it falls back to: a baby's adult (OptiFine's fallbacks, and Entity Model Features' for
//! horses too), and the few of [`FALLBACKS`], following their chains (`cat_baby_collar` to
//! `cat_collar` to `cat`).
//!
//! Built when the packs load; a mob's draw only swaps the layer's part tree. Without CEM files
//! nothing is built and every model stays vanilla's.
//! nothing is built and every model stays vanilla's, and a file no layer uses isn't parsed.
//!
//! The seam with random entity textures (ETF): a `.jem`'s own `texture` replaces the layer's
//! vanilla texture name, and the texture selection then works on that name as on vanilla's.
//! vanilla texture name, and the random texture lookup then gets that name. Variants without
//! rules (`green2.png` beside `green.png`) follow it; a `.properties` with rules for it is not
//! matched yet, because the random-texture picks are made for the vanilla names. That half
//! belongs with random textures. The `.jem` texture paints the layer the file is for, not the overlays drawn on that layer's model (a wolf's
//! collar, a spider's eyes, the cracks on a wolf's armour): those keep their textures over the
//! new geometry.
use super::{MobType, Part, Polygon};
use horizon_optifine::cem::model::{Bone, EntityModel, MAX_BYTES, parse_jem};
use std::{collections::HashMap, fs, path::Path, sync::Arc};
use std::{
collections::{BTreeMap, BTreeSet, HashMap},
fs,
path::Path,
sync::Arc,
};
/// The CEM models of the enabled packs, by model layer (`zombie#main`).
#[derive(Default)]
@@ -30,62 +42,85 @@ pub struct CemLayer {
pub texture: Option<String>,
}
/// How many fallbacks a chain follows (`cat_baby_collar`, `cat_collar`, `cat` takes two).
const FALLBACK_STEPS: usize = 3;
impl CemModels {
/// The `.jem` files under `root` applied to the layers of `types`.
pub fn load(root: &Path, types: &HashMap<String, Arc<MobType>>) -> Self {
let vanilla: HashMap<&str, &Arc<Part>> = types
.values()
.flat_map(|t| t.layers.iter().map(|(k, v)| (k.as_str(), v)))
.collect();
Self::over(root, &vanilla)
}
/// The `.jem` files under `root` applied to the `vanilla` layers.
fn over(root: &Path, vanilla: &HashMap<&str, &Arc<Part>>) -> Self {
let files = jem_files(root);
if files.is_empty() {
return Self::default();
}
let mut read = |path: &str| read_small(&root.join(path));
let mut layers = HashMap::new();
// A baby layer without its own file takes the adult's: those go after every own file.
let mut fallbacks = Vec::new();
for (name, path) in &files {
let Some(text) = read_small(&root.join(path)) else {
log::warn!(
"Horizon: custom entity model {path}: unreadable or over {MAX_BYTES} bytes"
);
continue;
};
let jem = match parse_jem(path, &text, &mut read) {
Ok(jem) => jem,
Err(e) => {
log::warn!("Horizon: custom entity model {e}");
continue;
}
};
for warning in &jem.warnings {
log::warn!("Horizon: custom entity model {warning}");
}
let jem = Arc::new(jem);
Self::from_files(root, &files, &vanilla_layers(types))
}
/// `files` ([`jem_files`]) applied to the `vanilla` layers.
fn from_files(
root: &Path,
files: &BTreeMap<String, Vec<String>>,
vanilla: &HashMap<&str, &Arc<Part>>,
) -> Self {
// Every model name a file can shape: the files' own, and the names that fall back to
// them, a chain of fallbacks deep.
let mut names: BTreeSet<String> = files.keys().cloned().collect();
let mut frontier: Vec<String> = names.iter().cloned().collect();
for _ in 0..FALLBACK_STEPS {
frontier = frontier
.iter()
.flat_map(|name| fallback_names(name))
.filter(|name| names.insert(name.clone()))
.collect();
}
// The files each layer may take, best first: the first that parses wins. Every name of
// the layer's own file in name order, then the files its names fall back to, nearest
// first. Two names of one layer (`llama_decor` and `trader_llama_decor` are both
// `llama#decor`, `skeleton_horse_armor` and `zombie_horse_armor` both
// `undead_horse_armor#main`) give it the first's, and the second's if the first is
// broken; within one name, EMF's file before OptiFine's.
let mut plan: HashMap<String, Vec<(usize, &str)>> = HashMap::new();
for name in &names {
let key = layer_key(name);
if let Some(part) = vanilla.get(key.as_str()) {
layers.insert(key, CemLayer::build(part, &jem, name));
if !vanilla.contains_key(key.as_str()) {
continue;
}
if !name.contains("_baby") {
fallbacks.extend(baby_names(name).map(|baby| (baby, name, jem.clone())));
let chain = std::iter::successors(Some(name.clone()), |n| falls_back_to(n));
let candidates = plan.entry(key).or_default();
for (depth, target) in chain.take(FALLBACK_STEPS + 1).enumerate() {
if let Some((file, _)) = files.get_key_value(&target) {
candidates.push((depth, file.as_str()));
}
}
}
for (baby, adult, jem) in fallbacks {
if files.contains_key(&baby) {
// Each file parsed once, when a layer first tries it.
let mut parsed: HashMap<&str, Option<EntityModel>> = HashMap::new();
let mut layers = HashMap::new();
for (key, mut candidates) in plan {
candidates.sort_by_key(|&(depth, _)| depth);
let found = candidates
.iter()
.flat_map(move |&(_, name)| {
files[name].iter().map(move |path| (name, path.as_str()))
})
.find(|&(_, path)| {
parsed
.entry(path)
.or_insert_with(|| load_jem(root, path))
.is_some()
});
let Some((name, path)) = found else {
continue;
};
let Some(Some(jem)) = parsed.get(path) else {
continue;
};
let mut layer = CemLayer::build(vanilla[key.as_str()], jem, name);
// A fallback onto another layer (the cat's collar from `cat`) takes the geometry
// only: the layer keeps its own texture.
if layer_suffix(&key) != layer_suffix(&layer_key(name)) {
layer.texture = None;
}
let key = layer_key(&baby);
if let Some(part) = vanilla.get(key.as_str()) {
layers
.entry(key)
.or_insert_with(|| CemLayer::build(part, &jem, adult));
}
layers.insert(key, layer);
}
Self { layers }
}
@@ -95,6 +130,47 @@ impl CemModels {
}
}
/// The `.jem` at `path`, its warnings logged; None, with a warning, when it can't be read or
/// parsed.
fn load_jem(root: &Path, path: &str) -> Option<EntityModel> {
let Some(text) = read_small(&root.join(path)) else {
log::warn!("Horizon: custom entity model {path}: unreadable or over {MAX_BYTES} bytes");
return None;
};
let jem = match parse_jem(path, &text, &mut |p: &str| read_small(&root.join(p))) {
Ok(jem) => jem,
Err(e) => {
log::warn!("Horizon: custom entity model {e}");
return None;
}
};
for warning in &jem.warnings {
log::warn!("Horizon: custom entity model {warning}");
}
if let Some(texture) = &jem.texture {
if atlas_name(texture).is_none() {
log::warn!(
"Horizon: custom entity model {path}: its texture {texture} is outside \
assets/minecraft/textures/, so not in the atlas: the vanilla one draws"
);
} else if !root.join(texture).is_file() {
log::warn!(
"Horizon: custom entity model {path}: its texture {texture} doesn't exist, \
so not in the atlas: the vanilla one draws"
);
}
}
Some(jem)
}
/// Every model layer of `types` by its key.
fn vanilla_layers(types: &HashMap<String, Arc<MobType>>) -> HashMap<&str, &Arc<Part>> {
types
.values()
.flat_map(|t| t.layers.iter().map(|(k, v)| (k.as_str(), v)))
.collect()
}
/// A pack file's text, None when it's missing or larger than a model may read: its size is
/// checked before anything is read.
fn read_small(path: &Path) -> Option<String> {
@@ -105,26 +181,65 @@ fn read_small(path: &Path) -> Option<String> {
fs::read_to_string(path).ok()
}
/// Every `.jem` of the CEM folders by model name, `emf/cem` over `optifine/cem`.
fn jem_files(root: &Path) -> HashMap<String, String> {
let mut out = HashMap::new();
for base in ["assets/minecraft/optifine/cem", "assets/minecraft/emf/cem"] {
/// Every `.jem` of the CEM folders by model name, sorted, each name's files best first:
/// `emf/cem` before `optifine/cem` whichever form either uses, and within one folder the flat
/// `<name>.jem` before `<name>/<name>.jem`.
fn jem_files(root: &Path) -> BTreeMap<String, Vec<String>> {
let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
for base in ["assets/minecraft/emf/cem", "assets/minecraft/optifine/cem"] {
let Ok(entries) = fs::read_dir(root.join(base)) else {
continue;
};
let (mut flat, mut nested) = (Vec::new(), Vec::new());
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if let Some(model) = name.strip_suffix(".jem") {
out.insert(model.to_owned(), format!("{base}/{name}"));
} else if root.join(format!("{base}/{name}/{name}.jem")).is_file() {
out.entry(name.clone())
.or_insert_with(|| format!("{base}/{name}/{name}.jem"));
// A directory named `x.jem` isn't a model.
if e.path().is_file() {
flat.push((model.to_owned(), format!("{base}/{name}")));
}
} else {
let path = format!("{base}/{name}/{name}.jem");
if root.join(&path).is_file() {
nested.push((name, path));
}
}
}
for (model, path) in flat.into_iter().chain(nested) {
out.entry(model).or_default().push(path);
}
}
out
}
/// The CEM model names with a layer key of their own; any other name `n` is `n#main`.
const LAYERS: &[(&str, &str)] = &[
("sheep_wool", "sheep#wool"),
("creeper_charge", "creeper#armor"),
("slime_outer", "slime#outer"),
("drowned_outer", "drowned#outer"),
("stray_outer", "stray#outer"),
("bogged_outer", "bogged#outer"),
("wither_armor", "wither#armor"),
("breeze_wind", "breeze#wind"),
// Horizon draws both llamas' decor on one layer, and both undead horses' armour.
("llama_decor", "llama#decor"),
("trader_llama_decor", "llama#decor"),
("cat_collar", "cat#collar"),
("pig_saddle", "pig#saddle"),
("strider_saddle", "strider#saddle"),
("nautilus_saddle", "nautilus#saddle"),
("tropical_fish_a", "tropical_fish_small#main"),
("tropical_fish_b", "tropical_fish_large#main"),
("tropical_fish_pattern_a", "tropical_fish_small#pattern"),
("tropical_fish_pattern_b", "tropical_fish_large#pattern"),
("puffer_fish_big", "pufferfish_big#main"),
("puffer_fish_medium", "pufferfish_medium#main"),
("puffer_fish_small", "pufferfish_small#main"),
("skeleton_horse_armor", "undead_horse_armor#main"),
("zombie_horse_armor", "undead_horse_armor#main"),
];
/// The model layer a CEM model name reshapes.
fn layer_key(name: &str) -> String {
// `<model>_baby[_<layer>]`: the adult's layer on the baby model.
@@ -133,31 +248,49 @@ fn layer_key(name: &str) -> String {
let (model, layer) = adult.split_once('#').unwrap_or((&adult, "main"));
return format!("{model}_baby#{layer}");
}
match name {
"sheep_wool" => "sheep#wool",
"creeper_charge" => "creeper#armor",
"slime_outer" => "slime#outer",
"drowned_outer" => "drowned#outer",
"stray_outer" => "stray#outer",
"bogged_outer" => "bogged#outer",
"wither_armor" => "wither#armor",
"breeze_wind" => "breeze#wind",
"llama_decor" | "trader_llama_decor" => "llama#decor",
"cat_collar" => "cat#collar",
"pig_saddle" => "pig#saddle",
"strider_saddle" => "strider#saddle",
"nautilus_saddle" => "nautilus#saddle",
"tropical_fish_a" => "tropical_fish_small#main",
"tropical_fish_b" => "tropical_fish_large#main",
"tropical_fish_pattern_a" => "tropical_fish_small#pattern",
"tropical_fish_pattern_b" => "tropical_fish_large#pattern",
"puffer_fish_big" => "pufferfish_big#main",
"puffer_fish_medium" => "pufferfish_medium#main",
"puffer_fish_small" => "pufferfish_small#main",
"zombie_horse_armor" | "skeleton_horse_armor" => "undead_horse_armor#main",
_ => return format!("{name}#main"),
match LAYERS.iter().find(|(n, _)| *n == name) {
Some((_, key)) => (*key).to_owned(),
None => format!("{name}#main"),
}
.to_owned()
}
/// The layer of a model layer key: `wool` of `sheep#wool`.
fn layer_suffix(key: &str) -> &str {
key.split_once('#').map_or(key, |(_, layer)| layer)
}
/// Models without a file of their own that take another's (OptiFine's and Entity Model
/// Features' tables), besides the babies: (model, the model it takes).
const FALLBACKS: &[(&str, &str)] = &[
("cat_collar", "cat"),
("player_slim", "player"),
("salmon_large", "salmon"),
("salmon_small", "salmon"),
];
/// The model `name` takes when it has no file: [`FALLBACKS`], or a baby's adult (`sheep_wool`
/// for `sheep_baby_wool`). The baby nautilus has none (OptiFine's and Entity Model Features'
/// tables).
fn falls_back_to(name: &str) -> Option<String> {
if let Some((_, to)) = FALLBACKS.iter().find(|(from, _)| *from == name) {
return Some((*to).to_owned());
}
(name.contains("_baby") && !name.starts_with("nautilus_baby"))
.then(|| name.replacen("_baby", "", 1))
}
/// The model names that take `name`'s file when they have none ([`falls_back_to`] the other
/// way): the babies of an adult and [`FALLBACKS`].
fn fallback_names(name: &str) -> Vec<String> {
let mut out: Vec<String> = FALLBACKS
.iter()
.filter(|(_, to)| *to == name)
.map(|(from, _)| (*from).to_owned())
.collect();
if !name.contains("_baby") {
out.extend(baby_names(name).filter(|baby| !baby.starts_with("nautilus_baby")));
}
out
}
/// The baby model names that fall back to the adult `name`: `_baby` before any of its
@@ -286,8 +419,13 @@ fn vanilla_part(model: &str, part: &str) -> String {
("cod" | "tropical_fish_a" | "tropical_fish_b", "fin_back" | "fin_top") => {
Some("top_fin".into())
}
(_, "fin_right") if model.starts_with("puffer") => Some("right_blue_fin".into()),
(_, "fin_left") if model.starts_with("puffer") => Some("left_blue_fin".into()),
// The big and medium pufferfish have blue fins; the small one has plain `right_fin`s
// (the fin animation in aquatic.rs).
("puffer_fish_big" | "puffer_fish_medium", "fin_right") => Some("right_blue_fin".into()),
("puffer_fish_big" | "puffer_fish_medium", "fin_left") => Some("left_blue_fin".into()),
// RabbitModel's hind feet (rabbit.rs).
("rabbit", "left_foot") => Some("left_hind_foot".into()),
("rabbit", "right_foot") => Some("right_hind_foot".into()),
(_, "fin_right") => Some("right_fin".into()),
(_, "fin_left") => Some("left_fin".into()),
(_, "fin_bottom") => Some("bottom_fin".into()),
@@ -325,7 +463,7 @@ impl CemLayer {
let mut entries = Vec::new();
for p in &jem.parts {
let target = vanilla_part(&model, &p.part);
let target = if find(&out, &target) {
let target = if super::render::find_part(&out, &target).is_some() {
target
} else {
p.part.clone()
@@ -369,10 +507,6 @@ fn atlas_name(path: &str) -> Option<String> {
Some(rest.strip_suffix(".png").unwrap_or(rest).to_owned())
}
fn find(part: &Part, name: &str) -> bool {
part.name == name || part.children.iter().any(|c| find(c, name))
}
/// The first part named `name`, depth first (`ModelPart.getChild` through the tree).
fn find_mut<'a>(part: &'a mut Part, name: &str) -> Option<&'a mut Part> {
if part.name == name {
@@ -468,6 +602,9 @@ mod tests {
("strider", "hair_left_top", "left_top_bristle"),
("puffer_fish_big", "spikes_front_top", "top_front_fin"),
("squid", "tentacle0", "tentacle0"),
("puffer_fish_medium", "fin_left", "left_blue_fin"),
("puffer_fish_small", "fin_right", "right_fin"),
("rabbit", "left_foot", "left_hind_foot"),
] {
assert_eq!(vanilla_part(model, part), vanilla, "{model} {part}");
}
@@ -510,7 +647,7 @@ mod tests {
]
.into_iter()
.collect();
let models = CemModels::over(&root, &vanilla);
let models = CemModels::from_files(&root, &jem_files(&root), &vanilla);
fs::remove_dir_all(&root).unwrap();
let adult = models.layer("zombie#main").unwrap();
@@ -530,4 +667,511 @@ mod tests {
assert_eq!((leg.polygons.len(), leg.children.len()), (0, 1));
assert!(models.layer("sheep#main").is_none());
}
const BOX: &str = r#""boxes": [{"coordinates": [0, 0, 0, 1, 1, 1], "textureOffset": [0, 0]}]"#;
/// A `.jem` with one entry on `part`, attached or replacing it.
fn jem(part: &str, attach: bool) -> String {
format!(r#"{{"models": [{{"part": "{part}", "attach": {attach}, {BOX}}}]}}"#)
}
/// A fresh pack folder for the test `test` holding `files` (paths under `assets/minecraft/`).
fn pack(test: &str, files: &[(&str, &str)]) -> std::path::PathBuf {
let root = std::env::temp_dir().join(format!("horizon-cem-{test}-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
for (path, text) in files {
let path = root.join("assets/minecraft").join(path);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, text).unwrap();
}
root
}
/// The models of the pack `files` over the `layers`, the same tree for each.
fn models(test: &str, files: &[(&str, &str)], layers: &[&str], tree: &Part) -> CemModels {
let root = pack(test, files);
let tree = Arc::new(tree.clone());
let vanilla: HashMap<&str, &Arc<Part>> = layers.iter().map(|k| (*k, &tree)).collect();
let models = CemModels::from_files(&root, &jem_files(&root), &vanilla);
fs::remove_dir_all(&root).unwrap();
models
}
/// Whether the layer `key`'s part `name` was replaced (lost its own face) or had a box
/// attached (kept it); None without the layer. Either way the box joins as a child.
fn attached(models: &CemModels, key: &str, name: &str) -> Option<bool> {
let part = get(&models.layer(key)?.model, name);
assert_eq!(part.children.len(), 1, "{key} {name}: one custom box");
Some(part.polygons.len() == 1)
}
#[test]
fn emf_files_win_and_a_flat_file_beats_its_folder() {
let (replace, attach) = (jem("leg1", false), jem("leg1", true));
let files = [
// EMF's folder form over OptiFine's flat file.
("optifine/cem/cow.jem", attach.as_str()),
("emf/cem/cow/cow.jem", replace.as_str()),
// One folder: the flat file over the folder form.
("optifine/cem/pig.jem", replace.as_str()),
("optifine/cem/pig/pig.jem", attach.as_str()),
// A directory named `sheep.jem` is no file: the folder form stands.
("optifine/cem/sheep.jem/readme.txt", ""),
("optifine/cem/sheep/sheep.jem", attach.as_str()),
];
let tree = plane("root", vec![plane("right_hind_leg", vec![])]);
let models = models(
"precedence",
&files,
&["cow#main", "pig#main", "sheep#main"],
&tree,
);
assert_eq!(attached(&models, "cow#main", "right_hind_leg"), Some(false));
assert_eq!(attached(&models, "pig#main", "right_hind_leg"), Some(false));
assert_eq!(
attached(&models, "sheep#main", "right_hind_leg"),
Some(true)
);
}
#[test]
fn a_baby_with_its_own_file_takes_it_not_the_adults() {
let (adult, baby) = (jem("head", false), jem("head", true));
let files = [
("optifine/cem/zombie.jem", adult.as_str()),
("optifine/cem/zombie_baby.jem", baby.as_str()),
];
let tree = plane("root", vec![plane("head", vec![])]);
let models = models("baby", &files, &["zombie#main", "zombie_baby#main"], &tree);
assert_eq!(attached(&models, "zombie#main", "head"), Some(false));
assert_eq!(attached(&models, "zombie_baby#main", "head"), Some(true));
}
#[test]
fn two_names_of_one_layer_give_it_the_first_by_name() {
let (llama, trader) = (jem("body", false), jem("body", true));
let files = [
("optifine/cem/trader_llama_decor.jem", trader.as_str()),
("optifine/cem/llama_decor.jem", llama.as_str()),
];
let tree = plane("root", vec![plane("body", vec![])]);
// Written trader first, `llama_decor` still shapes the layer, and the baby's (both
// fall back to the adult files).
let models = models("decor", &files, &["llama#decor", "llama_baby#decor"], &tree);
assert_eq!(attached(&models, "llama#decor", "body"), Some(false));
assert_eq!(attached(&models, "llama_baby#decor", "body"), Some(false));
}
#[test]
fn other_models_fall_back_but_the_baby_nautilus_does_not() {
let (replace, attach) = (jem("body", false), jem("body", true));
let cat = format!(
r#"{{"texture": "textures/entity/cat/custom.png", "models": [
{{"part": "body", {BOX}}}]}}"#
);
let files = [
("optifine/cem/salmon.jem", replace.as_str()),
("optifine/cem/salmon_large.jem", attach.as_str()),
("optifine/cem/nautilus.jem", replace.as_str()),
("optifine/cem/cat.jem", cat.as_str()),
];
let tree = plane("root", vec![plane("body", vec![])]);
let models = models(
"fallbacks",
&files,
&[
"salmon#main",
"salmon_small#main",
"salmon_large#main",
"nautilus#main",
"nautilus_baby#main",
"cat#main",
"cat#collar",
"cat_baby#collar",
],
&tree,
);
// The small salmon has no file: salmon.jem's. The large one has its own.
assert_eq!(attached(&models, "salmon_small#main", "body"), Some(false));
assert_eq!(attached(&models, "salmon_large#main", "body"), Some(true));
assert_eq!(attached(&models, "nautilus#main", "body"), Some(false));
assert!(models.layer("nautilus_baby#main").is_none());
// The collar takes the cat's geometry, but its own collar texture stays.
let texture = |key: &str| models.layer(key).unwrap().texture.clone();
assert_eq!(texture("cat#main").as_deref(), Some("entity/cat/custom"));
assert_eq!(attached(&models, "cat#collar", "body"), Some(false));
assert_eq!(texture("cat#collar"), None);
// So does the baby's, two fallbacks down: `cat_baby_collar`, `cat_collar`, `cat`.
assert_eq!(attached(&models, "cat_baby#collar", "body"), Some(false));
assert_eq!(texture("cat_baby#collar"), None);
}
#[test]
fn a_broken_file_gives_way_to_the_next() {
let (replace, attach) = (jem("body", false), jem("body", true));
let files = [
// The first name of the layer is broken: the second shapes it.
("optifine/cem/llama_decor.jem", "{"),
("optifine/cem/trader_llama_decor.jem", attach.as_str()),
// EMF's file is broken: OptiFine's stands.
("emf/cem/cow.jem", "{"),
("optifine/cem/cow.jem", replace.as_str()),
// The baby's own file is broken: the adult's falls back onto it.
("optifine/cem/zombie_baby.jem", "{"),
("optifine/cem/zombie.jem", attach.as_str()),
];
let tree = plane("root", vec![plane("body", vec![])]);
let models = models(
"broken",
&files,
&["llama#decor", "cow#main", "zombie_baby#main"],
&tree,
);
assert_eq!(attached(&models, "llama#decor", "body"), Some(true));
assert_eq!(attached(&models, "cow#main", "body"), Some(false));
assert_eq!(attached(&models, "zombie_baby#main", "body"), Some(true));
}
#[test]
fn an_unknown_part_is_skipped_and_the_rest_applies() {
let file =
format!(r#"{{"models": [{{"part": "wings", {BOX}}}, {{"part": "head", {BOX}}}]}}"#);
let tree = plane("root", vec![plane("head", vec![])]);
let models = models(
"unknown",
&[("optifine/cem/zombie.jem", file.as_str())],
&["zombie#main"],
&tree,
);
assert_eq!(attached(&models, "zombie#main", "head"), Some(false));
fn custom(p: &Part) -> usize {
usize::from(p.name.starts_with("cem#")) + p.children.iter().map(custom).sum::<usize>()
}
assert_eq!(custom(&models.layer("zombie#main").unwrap().model), 1);
}
/// A mob type `id` of `species` whose body and `layers` are all `tree`, with the vanilla
/// `texture`.
fn mob_kind(
id: &str,
species: super::super::Species,
anim: super::super::Anim,
texture: &str,
layers: &[&str],
tree: &Part,
) -> Arc<MobType> {
let mut kind = (*super::super::sensor_scan_tests::kind()).clone();
kind.id = id.into();
kind.texture = texture.into();
kind.species = species;
kind.anim = anim;
kind.model = Arc::new(tree.clone());
kind.layers = layers
.iter()
.map(|k| ((*k).to_owned(), Arc::new(tree.clone())))
.collect();
Arc::new(kind)
}
/// How many vertices `render` draws of `mob` in each of `sprites`, laid far apart in the
/// atlas: the sprite a vertex's UV falls in says what it drew with. `sprites[0]`, the
/// `.jem`'s texture, is in the atlas only with `custom`.
fn sprite_counts(
mob: &super::super::Mob,
models: &Arc<CemModels>,
sprites: &[&str],
custom: bool,
) -> Vec<usize> {
let data = super::super::MobData {
states: Vec::new(),
placements: Default::default(),
heightmaps: Default::default(),
animations: Default::default(),
biome_tags: Default::default(),
entity_tags: Default::default(),
villager_hats: Default::default(),
cem: std::sync::RwLock::new(models.clone()),
random_textures: Default::default(),
pois: Default::default(),
species_placements: Default::default(),
};
// Sprite i spans u and v from 0.2 i to 0.2 i + 0.1.
let atlas = |name: &str| -> Option<[f32; 4]> {
let i = sprites.iter().position(|s| *s == name)?;
let lo = i as f32 * 0.2;
(custom || i != 0).then_some([lo, lo, lo + 0.1, lo + 0.1])
};
let mut vertices = Vec::new();
super::super::render::render(
mob,
0.0,
glam::DVec3::ZERO,
&atlas,
0xf0f0,
&data,
&mut vertices,
);
let mut counts = vec![0; sprites.len()];
for v in &vertices {
let u = v.uv[0];
let i = ((u + 0.05) / 0.2).floor() as usize;
assert!(
i < sprites.len() && u - i as f32 * 0.2 <= 0.1 + 1e-4,
"a UV between the sprites: {u}"
);
counts[i] += 1;
}
counts
}
/// A spider whose `.jem` replaces its body: the body draws with the `.jem`'s texture, the
/// eyes (an overlay on the body's model) with theirs on the new geometry, and a texture the
/// atlas lacks leaves the vanilla one. A sheep's wool, a layer with a model of its own,
/// takes its `.jem`'s texture.
#[test]
fn a_jem_texture_paints_its_layer_and_leaves_the_overlays() {
use super::super::{Anim, Mob, Species};
let tree = |part: &str| Part {
name: "root".into(),
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
polygons: Vec::new(),
children: vec![plane(part, vec![])],
};
let textured = |texture: &str| {
format!(
r#"{{"texture": "textures/{texture}.png", "models": [
{{"part": "body", {BOX}}}]}}"#
)
};
let spider = textured("entity/spider/custom");
let cem = Arc::new(models(
"render",
&[("optifine/cem/spider.jem", spider.as_str())],
&["spider#main"],
&tree("body1"),
));
let mob = Mob::new(
1,
mob_kind(
"minecraft:spider",
Species::Spider,
Anim::Spider,
"entity/spider/spider",
&["spider#main"],
&tree("body1"),
),
horizon_core::geometry::Vec3::ZERO,
0.0,
);
let sprites = [
"entity/spider/custom",
"entity/spider/spider_eyes",
"entity/spider/spider",
];
// The custom box's six faces, four corners each, for the body and again for the eyes;
// the replaced body part's own face is gone.
assert_eq!(sprite_counts(&mob, &cem, &sprites, true), [24, 24, 0]);
assert_eq!(sprite_counts(&mob, &cem, &sprites, false), [0, 24, 24]);
let wool = textured("entity/sheep/custom_wool");
let cem = Arc::new(models(
"render-sheep",
&[("optifine/cem/sheep_wool.jem", wool.as_str())],
&["sheep#main", "sheep#wool"],
&tree("body"),
));
let mob = Mob::new(
1,
mob_kind(
"minecraft:sheep",
Species::Sheep,
Anim::Sheep,
"entity/sheep/sheep",
&["sheep#main", "sheep#wool"],
&tree("body"),
),
horizon_core::geometry::Vec3::ZERO,
0.0,
);
let sprites = [
"entity/sheep/custom_wool",
"entity/sheep/sheep_wool",
"entity/sheep/sheep",
];
// The wool's custom box in the `.jem`'s texture; the vanilla body's one face.
assert_eq!(sprite_counts(&mob, &cem, &sprites, true), [24, 0, 4]);
assert_eq!(sprite_counts(&mob, &cem, &sprites, false), [0, 24, 4]);
}
/// A wolf armour `.jem`'s texture paints the armour, the first layer on its model, and
/// not the cracks drawn over it on the same model.
#[test]
fn armour_cracks_keep_their_texture() {
use super::super::{Anim, Mob, Species, WOLF_ARMOR_DURABILITY};
let tree = Part {
name: "root".into(),
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
polygons: Vec::new(),
children: vec![plane("body", vec![])],
};
let file = format!(
r#"{{"texture": "textures/entity/wolf/custom_armor.png", "models": [
{{"part": "body", {BOX}}}]}}"#
);
let layers = ["wolf#main", "wolf_armor#main"];
let cem = Arc::new(models(
"cracks",
&[("optifine/cem/wolf_armor.jem", file.as_str())],
&layers,
&tree,
));
let mut mob = Mob::new(
1,
mob_kind(
"minecraft:wolf",
Species::Wolf,
Anim::Wolf,
"entity/wolf/wolf",
&layers,
&tree,
),
horizon_core::geometry::Vec3::ZERO,
0.0,
);
mob.extra.tame = true;
mob.extra.head = Some("minecraft:wolf_armor".into());
// Worn through: the high cracks.
mob.extra.body_damage = WOLF_ARMOR_DURABILITY;
let sprites = [
"entity/wolf/custom_armor",
"entity/equipment/wolf_body/armadillo_scute",
"entity/wolf/wolf_armor_crackiness_high",
"entity/wolf/wolf_collar",
];
// The armour's custom box in the `.jem`'s texture, the cracks' over the same box in
// theirs, and the collar on the vanilla body (whose own texture isn't in the atlas).
assert_eq!(sprite_counts(&mob, &cem, &sprites, true), [24, 0, 24, 4]);
assert_eq!(sprite_counts(&mob, &cem, &sprites, false), [0, 24, 24, 4]);
}
/// Every layer of [`LAYERS`] and [`FALLBACKS`] is in Horizon's own models, every baby layer
/// there is one a baby model name maps to, and so are the parts a sample of part names is
/// renamed to.
#[test]
#[ignore = "requires prepared entity models in HORIZON_TEST_RESOURCES"]
fn names_reach_horizons_models() {
let root = std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES");
let types: HashMap<String, Arc<MobType>> = super::super::MobTypes::load(Path::new(&root))
.unwrap()
.types
.into_iter()
.map(|(k, v)| (k, Arc::new(v)))
.collect();
let vanilla = vanilla_layers(&types);
let mut missing = Vec::new();
for (name, key) in LAYERS {
if !vanilla.contains_key(key) {
missing.push(format!("{name}: no layer {key}"));
}
}
for name in FALLBACKS.iter().flat_map(|(name, takes)| [*name, *takes]) {
let key = layer_key(name);
if !vanilla.contains_key(key.as_str()) {
missing.push(format!("{name}: no layer {key}"));
}
}
// `sheep_baby#wool` is `sheep_baby_wool`'s: the baby name of a name of `sheep#wool`.
for key in vanilla.keys() {
let Some((model, layer)) = key.split_once('#') else {
continue;
};
let Some(adult) = model.strip_suffix("_baby") else {
continue;
};
let adult_key = format!("{adult}#{layer}");
let reached = LAYERS
.iter()
.map(|(name, _)| *name)
.chain([adult])
.filter(|name| layer_key(name) == adult_key)
.flat_map(baby_names)
.any(|baby| layer_key(&baby) == *key);
if !reached {
missing.push(format!("{key}: no baby model name maps to it"));
}
}
// A sample of the renamed parts.
for (model, part) in [
("zombie", "headwear"),
("cow", "leg1"),
("cow", "leg4"),
("camel", "back_left_leg"),
("camel", "front_right_leg"),
("spider", "leg1"),
("spider", "leg8"),
("spider", "neck"),
("spider", "body"),
("villager", "headwear2"),
("villager", "bodywear"),
("witch", "headwear2"),
("witch", "bodywear"),
("chicken", "bill"),
("chicken", "chin"),
("llama", "chest_right"),
("llama", "chest_left"),
("turtle", "body2"),
("ravager", "jaw"),
("bee", "body"),
("bee", "torso"),
("warden", "body"),
("warden", "torso"),
("snow_golem", "body"),
("snow_golem", "body_bottom"),
("snow_golem", "right_hand"),
("snow_golem", "left_hand"),
("slime", "body"),
("magma_cube", "core"),
("magma_cube", "segment1"),
("blaze", "stick1"),
("ghast", "tentacle1"),
("squid", "tentacle1"),
("silverfish", "body1"),
("silverfish", "wing1"),
("endermite", "body1"),
("guardian", "spine1"),
("guardian", "tail1"),
("wither", "body1"),
("wither", "head1"),
("phantom", "left_wing"),
("phantom", "tail2"),
("cat", "tail"),
("ocelot", "tail"),
("bat", "outer_right_wing"),
("cod", "fin_back"),
("cod", "fin_right"),
("cod", "tail"),
("tropical_fish_b", "fin_top"),
("tropical_fish_b", "fin_bottom"),
("puffer_fish_big", "fin_right"),
("puffer_fish_medium", "fin_left"),
("puffer_fish_small", "fin_right"),
("puffer_fish_big", "spikes_front_top"),
("rabbit", "left_foot"),
("rabbit", "right_foot"),
("sniffer", "middle_left_leg"),
("strider", "hair_left_top"),
("horse", "neck"),
("horse", "mouth"),
] {
let key = layer_key(model);
let target = vanilla_part(model, part);
let found = vanilla
.get(key.as_str())
.is_some_and(|tree| super::super::render::find_part(tree, &target).is_some());
if !found {
missing.push(format!("{model} {part}: no part {target} in {key}"));
}
}
assert!(missing.is_empty(), "{missing:#?}");
}
}
+3 -2
View File
@@ -643,8 +643,9 @@ pub fn ridden_step(m: &mut Mob) {
m.ai.zza = forward;
if r.jump > 0.0 {
let mut ev = Vec::new();
super::camel::dash(m, r.jump, &mut ev);
if let Some(rider) = m.rider.as_mut() {
if super::camel::dash(m, r.jump, &mut ev)
&& let Some(rider) = m.rider.as_mut()
{
rider.jump = 0.0;
}
}
+126 -10
View File
@@ -2590,19 +2590,27 @@ impl Goal for EatBlock {
m.position.z.floor() as i32,
);
let here = cx.w.block_name(x, y, z).to_owned();
// EatBlockGoal.tick: both halves of the world change sit behind
// MOB_GRIEFING (`ifeq 89`, `ifeq 182` in the bytecode); `Mob.ate`
// runs either way, so a sheep still sheds its wool and ages up.
if cx.w.has_tag("minecraft:edible_for_sheep", &here) {
cx.events.push(MobEvent::BreakBlock {
pos: [x, y, z],
drop: false,
});
if cx.w.mob_griefing {
cx.events.push(MobEvent::BreakBlock {
pos: [x, y, z],
drop: false,
});
}
super::ai::ate(m);
} else if cx.w.block_name(x, y - 1, z) == "minecraft:grass_block" {
cx.events.push(MobEvent::SetBlockName {
pos: [x, y - 1, z],
name: "minecraft:dirt",
effect: true,
flags: horizon_world::level::flags::UPDATE_CLIENTS,
});
// The particles and the dirt `setBlock` are the same gate.
if cx.w.mob_griefing {
cx.events.push(MobEvent::SetBlockName {
pos: [x, y - 1, z],
name: "minecraft:dirt",
effect: true,
flags: horizon_world::level::flags::UPDATE_CLIENTS,
});
}
super::ai::ate(m);
}
}
@@ -2821,6 +2829,114 @@ impl MoveToBlock {
}
}
#[cfg(test)]
mod eat_block_tests {
use super::*;
use crate::mobs::world::with_built_test_world_rules;
/// A sheep on a wide grass shelf, ticked long enough for its `EatBlockGoal`
/// (1 in 1000 a tick) to fire several times: every event it pushed, with
/// the `mob_griefing` game rule at `griefing`. `tufts` puts short grass at
/// the sheep's own feet, so the goal takes the `destroyBlock` half; without
/// it the sheep stands on the grass and takes the grass-to-dirt half.
fn sheep_events(griefing: bool, tufts: bool) -> Option<Vec<MobEvent>> {
let mut out = None;
let build = |world: &mut crate::world::World,
registry: &horizon_world::registry::BlockRegistry| {
let grass = registry.default_state("minecraft:grass_block").unwrap();
let tuft = registry.default_state("minecraft:short_grass").unwrap();
for cx in -1..1 {
for cz in -1..1 {
world
.chunks
.insert((cx, cz), Arc::new(crate::chunk::Chunk::empty(cx, cz)));
}
}
for x in -8..=8 {
for z in -8..=8 {
world.set(x, 63, z, grass).unwrap();
if tufts {
world.set(x, 64, z, tuft).unwrap();
}
}
}
};
with_built_test_world_rules(&[], griefing, build, |w, types| {
let kind = Arc::new(types.types["minecraft:sheep"].clone());
let mut m = Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0);
let mut random = LegacyRandom::new(0);
let mut events = Vec::new();
for _ in 0..6000 {
crate::mobs::ai::tick(&mut m, w, &mut random, &mut events);
}
out = Some(events);
});
out
}
/// `EatBlockGoal.tick` puts the `levelEvent` + `setBlock` behind
/// `MOB_GRIEFING` and leaves `Mob.ate` outside it, so with the rule off the
/// goal still runs (`EntityEvent` 10) and still shears the coat, and the
/// grass stays.
#[test]
fn the_eat_goal_turns_grass_to_dirt_only_with_mob_griefing() {
let Some(on) = sheep_events(true, false) else {
return;
};
let Some(off) = sheep_events(false, false) else {
return;
};
let dirt = |events: &[MobEvent]| {
events
.iter()
.filter(|e| matches!(e, MobEvent::SetBlockName { name, .. } if *name == "minecraft:dirt"))
.count()
};
let ate = |events: &[MobEvent]| {
events
.iter()
.filter(|e| matches!(e, MobEvent::EntityEvent { event: 10, .. }))
.count()
};
assert!(dirt(&on) > 0, "mob_griefing on: {}", dirt(&on));
assert_eq!(dirt(&off), 0, "mob_griefing off must leave the grass alone");
assert!(ate(&off) > 0, "the goal still runs with the rule off");
}
/// The edible half of the same gate (`ifeq 89` skips
/// `Level.destroyBlock(pos, false)`): short grass at the sheep's feet
/// disappears with the rule on and stays with it off, while the goal eats
/// either way.
#[test]
fn the_eat_goal_breaks_short_grass_only_with_mob_griefing() {
let Some(on) = sheep_events(true, true) else {
return;
};
let Some(off) = sheep_events(false, true) else {
return;
};
let bitten = |events: &[MobEvent]| {
events
.iter()
.filter(|e| matches!(e, MobEvent::BreakBlock { drop: false, .. }))
.count()
};
let ate = |events: &[MobEvent]| {
events
.iter()
.filter(|e| matches!(e, MobEvent::EntityEvent { event: 10, .. }))
.count()
};
assert!(bitten(&on) > 0, "mob_griefing on: {}", bitten(&on));
assert_eq!(
bitten(&off),
0,
"mob_griefing off must leave the tufts alone"
);
assert!(ate(&off) > 0, "the goal still runs with the rule off");
}
}
#[cfg(test)]
mod built_by_player_tests {
use super::*;
+13
View File
@@ -64,6 +64,19 @@ pub fn register(m: &mut Mob) {
m.ai.targets.add(1, HurtByTarget::new().alert());
m.ai.targets.add(2, NearestTarget::player(true));
}
// Endermite.registerGoals: the silverfish's list without the stone
// tricks, at vanilla's priorities (stroll 1.0, alert others on hurt).
S::Endermite => {
let g = &mut m.ai.goals;
g.add(1, Float);
g.add(1, ClimbOnTopOfPowderSnow);
g.add(2, MeleeAttack::new(1.0, false));
g.add(3, Stroll::avoiding_water(1.0));
g.add(7, LookAtPlayer::new(8.0));
g.add(8, RandomLookAround::default());
m.ai.targets.add(1, HurtByTarget::new().alert());
m.ai.targets.add(2, NearestTarget::player(true));
}
_ => {}
}
}
+39 -4
View File
@@ -388,14 +388,18 @@ fn cast_fangs(m: &mut Mob, cx: &mut Cx) {
}
}
}
/// `createSpellEntity`: the first sturdy floor going down from `max_y`,
/// standing on the top of any block in the way.
/// `createSpellEntity`: the first block below `max_y` whose up face is
/// sturdy, standing on the top of whatever collision shape is in the way.
///
/// The floor test is `BlockState.isFaceSturdy(.., UP)`, not a full cube: a
/// soul sand or mud block is 14/16 high and still supports a fang, where
/// requiring a full collision shape drops the fang into the block.
fn fang_floor(w: &super::MobWorld, x: f64, z: f64, min_y: f64, max_y: f64) -> Option<f64> {
let (bx, bz) = (x.floor() as i32, z.floor() as i32);
let mut y = max_y.floor() as i32;
while y >= min_y.floor() as i32 - 1 {
let below = w.facts.get(w.world.block(bx, y - 1, bz));
if below.full_collision {
let below = w.world.block(bx, y - 1, bz);
if w.data.state(below).flags & super::data::STURDY_TOP != 0 {
let here = w.world.block(bx, y, bz);
let top = if w.facts.is_air(here) {
0.0
@@ -932,4 +936,35 @@ mod tests {
.is_none_or(|d| d.charged.is_empty())
);
}
/// `EvokerAttackSpellGoal.createSpellEntity` asks the block below for a
/// sturdy up face, not for a full cube. Soul sand is 14/16 high and still
/// supports a fang, which a full-collision test drops into the block.
#[test]
fn a_fang_stands_on_a_soul_sand_floor() {
let build = |world: &mut crate::world::World,
registry: &horizon_world::registry::BlockRegistry| {
let stone = registry.default_state("minecraft:stone").unwrap();
let soul_sand = registry.default_state("minecraft:soul_sand").unwrap();
for cx in -1..1 {
for cz in -1..1 {
world
.chunks
.insert((cx, cz), Arc::new(crate::chunk::Chunk::empty(cx, cz)));
}
}
for x in -4..4 {
for z in -4..4 {
world.set(x, 63, z, stone).unwrap();
}
}
world.set(0, 64, 0, soul_sand).unwrap();
};
let ran = crate::mobs::world::with_built_test_world(&[], build, |w, _| {
// An evoker standing on the soul sand has its feet at 64.875, so
// the cast asks for a floor from 65 down: the soul sand at 64 is
// the first sturdy block and the fang sits at 65.
assert_eq!(fang_floor(w, 0.5, 0.5, 64.0, 65.875), Some(65.0));
});
let _ = ran;
}
}
+35
View File
@@ -290,6 +290,11 @@ pub fn can_be_leashed(m: &Mob) -> bool {
| S::Pufferfish
| S::Tadpole => false,
S::Wolf => !super::tame::angry(m),
// Nautilus.canBeLeashed and ZombieNautilus.canBeLeashed: never while
// it is aggravated. The zombie also refuses while a mob is riding it
// (`isMobControlled`), which a mob alone cannot see.
// shortcut: isMobControlled, when a mob can ride a zombie nautilus.
S::Nautilus | S::ZombieNautilus => !super::nautilus::aggravated(m),
_ => !m.hostile() && !m.removed,
}
}
@@ -574,6 +579,36 @@ mod tests {
assert!(near(p[2], [lr, y, -fb + f]));
assert!(near(p[3], [lr, y, fb + f]));
}
/// `Nautilus.canBeLeashed` and `ZombieNautilus.canBeLeashed`: an
/// aggravated nautilus, one holding an `ANGRY_AT` or an `ATTACK_TARGET`
/// memory, cannot be put on a lead.
#[test]
fn an_aggravated_nautilus_cannot_be_leashed() {
let ran = crate::mobs::world::with_test_world(&[], |_, types| {
let new = |kind: &str| {
Mob::new(
1,
std::sync::Arc::new(types.types[kind].clone()),
horizon_core::geometry::Vec3::ZERO,
0.0,
)
};
let calm = new("minecraft:nautilus");
assert!(can_be_leashed(&calm), "a calm nautilus");
let mut angry = new("minecraft:nautilus");
angry.extra.anger = 400;
assert!(!can_be_leashed(&angry), "ANGRY_AT");
let mut charging = new("minecraft:zombie_nautilus");
charging.ai.target = Some(crate::mobs::Who::Player);
assert!(!can_be_leashed(&charging), "ATTACK_TARGET");
assert!(
can_be_leashed(&new("minecraft:zombie_nautilus")),
"a calm zombie nautilus"
);
});
let _ = ran;
}
#[test]
fn only_horses_camels_and_sniffers_support_a_quad_lead() {
assert!(supports_quad_leash(Species::Horse));
@@ -350,6 +350,12 @@ pub fn client_tick(m: &mut Mob, events: &mut Vec<MobEvent>) {
fn in_water_at(w: &MobWorld, p: Vec3) -> bool {
w.fluid(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32) == Fluid::Water
}
/// `AbstractNautilus.isAggravated`: the brain holds an `ANGRY_AT` or an
/// `ATTACK_TARGET` memory. Horizon keeps those as the anger timer (whose
/// target is `last_hurt_by`) and the current attack target.
pub fn aggravated(m: &Mob) -> bool {
m.extra.anger > 0 || m.ai.target.is_some()
}
/// `NautilusAi.findNearestValidAttackTarget`: whoever it is angry at, else
/// now and then (a coin flip every 2400 to 3600 ticks) the nearest pufferfish.
fn find_target(m: &mut Mob, w: &MobWorld) -> Option<Who> {
+32 -3
View File
@@ -122,6 +122,11 @@ impl Navigation {
)
}
}
/// `LivingEntity.getComfortableFallDistance`: the drop an entity accepts on
/// top of what it was asked for.
fn comfortable_fall_distance(f: f32) -> i32 {
(f + 3.0).floor() as i32
}
impl Mob {
pub fn path_mob(&self) -> PathMob {
PathMob {
@@ -140,14 +145,19 @@ impl Mob {
stands_on_lava: self.kind.flags.lava_walker,
}
}
/// Mob.getMaxFallDistance (normal difficulty).
/// `Mob.getMaxFallDistance` (normal difficulty), with `Creeper`'s
/// override: a creeper that has a target falls by its own health, so a
/// full-health one walks off a 22-block drop instead of 3.
pub fn max_fall_distance(&self) -> i32 {
if self.ai.target.is_none() {
return 3;
return comfortable_fall_distance(0.0);
}
if self.species() == super::Species::Creeper {
return comfortable_fall_distance(self.health - 1.0);
}
let mut sacrifice = (self.health - self.attrs.max_health * 0.33) as i32;
sacrifice -= 4;
sacrifice.max(0) + 3
comfortable_fall_distance(sacrifice.max(0) as f32)
}
pub fn malus(&self, t: PathType) -> f32 {
self.ai.malus[t as usize].unwrap_or_else(|| t.malus())
@@ -744,4 +754,23 @@ mod tests {
}
));
}
/// `Creeper.getMaxFallDistance` replaces the shared
/// `health - a third` formula with its own `health - 1`: a creeper
/// chasing something drops 22 blocks at full health and 6 at four,
/// where the shared formula would have given 3.
#[test]
fn a_creeper_falls_by_its_own_health_while_it_has_a_target() {
// Skipped without the extracted vanilla data (CI's verify has none).
crate::mobs::world::with_test_world(&[], |_, types| {
let kind = std::sync::Arc::new(types.types["minecraft:creeper"].clone());
let mut m = Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0);
assert_eq!(m.max_fall_distance(), 3, "no target");
m.ai.target = Some(crate::mobs::Who::Player);
m.health = 20.0;
assert_eq!(m.max_fall_distance(), 22, "full health, has a target");
m.health = 4.0;
assert_eq!(m.max_fall_distance(), 6, "hurt, has a target");
});
}
}
+59
View File
@@ -1703,6 +1703,21 @@ pub fn ai_step(
if !w.nether && !m.extra.flag2 && m.alive() {
m.extra.counter2 += 1;
if m.extra.counter2 > CONVERSION {
// `Piglin.finishConversion` runs `PiglinAi.cancelAdmiring` first:
// the item the piglin is admiring leaves the off hand and lands
// where it stands, so the zombified piglin never carries it.
if m.species() == Species::Piglin {
let admiring = (m.extra.off_hand.is_some() && m.extra.using_item > 0)
|| m.extra.piglin.seen_admire > 0;
if admiring && let Some(item) = m.extra.off_hand.take() {
events.push(MobEvent::DropItem {
item,
count: 1,
at: [m.position.x, m.position.y, m.position.z],
});
m.extra.using_item = 0;
}
}
// `Hoglin.finishConversion` is `ConversionParams.single(this, true,
// false)`, `AbstractPiglin.finishConversion` `(this, true, true)`;
// both leave the new mob nauseous (the game adds it).
@@ -2172,3 +2187,47 @@ mod crossbow_tests {
assert_eq!(f.seen_admire, 40);
}
}
#[cfg(test)]
mod conversion_tests {
use super::*;
use crate::mobs::world::with_test_world;
/// `Piglin.finishConversion` cancels the admire before it converts: the
/// item in the off hand is dropped at the piglin's feet and the zombified
/// piglin does not carry it, where Horizon used to hand it over.
#[test]
fn a_converted_piglin_drops_the_item_it_was_admiring() {
let ran = with_test_world(&[], |w, types| {
let mut m = Mob::new(
1,
std::sync::Arc::new(types.types["minecraft:piglin"].clone()),
Vec3::new(0.5, 64.0, 0.5),
0.0,
);
m.extra.off_hand = Some("minecraft:gold_ingot".into());
m.extra.using_item = 60;
m.extra.counter2 = CONVERSION + 1;
let mut lr = LegacyRandom::new(0);
let mut events = Vec::new();
ai_step(&mut m, w, &mut lr, &mut events);
assert!(
events.iter().any(|e| matches!(
e,
MobEvent::DropItem { item, count, .. }
if &**item == "minecraft:gold_ingot" && *count == 1
)),
"the admired item is dropped"
);
let from = events
.iter()
.find_map(|e| match e {
MobEvent::Convert { from, .. } => Some(from),
_ => None,
})
.expect("the piglin converts");
assert_eq!(from.extra.off_hand, None, "the new mob carries nothing");
});
let _ = ran;
}
}
+12 -9
View File
@@ -86,17 +86,20 @@ impl State {
}
}
/// `Raider.class`: the species a goal that takes `Raider.class` filters out
/// (`HurtByTargetGoal(this, Raider.class)`). A vex is not a raider.
pub const RAIDERS: [Species; 6] = [
Species::Pillager,
Species::Vindicator,
Species::Evoker,
Species::Illusioner,
Species::Witch,
Species::Ravager,
];
/// The raider species (`EntityTypeTags.RAIDERS`).
pub fn is_raider(s: Species) -> bool {
matches!(
s,
Species::Pillager
| Species::Vindicator
| Species::Evoker
| Species::Illusioner
| Species::Witch
| Species::Ravager
)
RAIDERS.contains(&s)
}
/// `canBeLeader`: witches and ravagers never lead.
pub fn can_be_leader(s: Species) -> bool {
+46 -24
View File
@@ -1646,6 +1646,27 @@ pub fn render_posed(
let base = base_transform_at(m, alpha, origin);
render_visible(m, alpha, base, atlas, light, data, &[], true, out);
}
/// The texture a model drawn as `custom` takes: the `.jem`'s when the atlas has it, else
/// `vanilla` (a texture the atlas lacks would hide the model).
fn cem_texture<'a>(
custom: Option<&'a super::cem::CemLayer>,
vanilla: &'a str,
atlas: Atlas<'_>,
) -> &'a str {
custom
.and_then(|c| c.texture.as_deref())
.filter(|t| atlas(t).is_some())
.unwrap_or(vanilla)
}
/// Whether `layers[i]` is the layer its model's `.jem` was made for, so the `.jem`'s texture
/// paints it: the first drawn with that model (`sheep#wool`, a wolf's armour). Later layers
/// on the same model are overlays (the armour's cracks), like those on the body's model
/// (`body`: eyes, collars, a villager's type and profession), and keep their own textures
/// over the new geometry.
fn paints_with_jem(layers: &[Layer], i: usize, body: [&str; 2]) -> bool {
let key = layers[i].model.as_str();
!body.contains(&key) && layers[..i].iter().all(|l| l.model != key)
}
#[allow(clippy::too_many_arguments)]
fn render_visible(
m: &Mob,
@@ -1673,20 +1694,17 @@ fn render_visible(
} else {
model_key.as_str()
};
let body_animation = cem.layer(cem_key).and_then(|c| c.animation.as_ref());
let (model, texture) = match cem.layer(cem_key) {
Some(custom) => (
custom.model.clone(),
custom.texture.clone().unwrap_or(texture),
),
None => (
m.kind
.layer(&model_key)
.cloned()
.unwrap_or_else(|| m.kind.model.clone()),
texture,
),
let custom_body = cem.layer(cem_key);
let body_animation = custom_body.and_then(|c| c.animation.as_ref());
let model = match custom_body {
Some(custom) => custom.model.clone(),
None => m
.kind
.layer(&model_key)
.cloned()
.unwrap_or_else(|| m.kind.model.clone()),
};
let texture = cem_texture(custom_body, &texture, atlas);
// OptiFine random textures: the sprite of the variant the mob wears for a texture, when
// the atlas has it. The texture keeps its name for everything else.
let random = data
@@ -1707,7 +1725,7 @@ fn render_visible(
let draw_body = |out: &mut Vec<Vertex>| {
// A player or mannequin whose skin has no atlas sprite (an evicted or
// never stored download) draws with its default skin, not invisibly.
let uv = random_uv(&texture).or_else(|| atlas(&texture)).or_else(|| {
let uv = random_uv(texture).or_else(|| atlas(texture)).or_else(|| {
if avatar(m) {
atlas(&m.kind.texture)
} else {
@@ -1757,21 +1775,24 @@ fn render_visible(
// One Poses refilled for every layer (armour draws up to seven): it keeps
// its maps' capacity.
let mut layer_poses = Poses::default();
for mut layer in all_layers {
let body_keys = [model_key.as_str(), cem_key];
for (i, layer) in all_layers.iter().enumerate() {
let custom = cem.layer(&layer.model);
// `layer.texture` stays the vanilla name (the villager hats below look for its
// `/type/` folder).
let texture = cem_texture(
custom.filter(|_| paints_with_jem(&all_layers, i, body_keys)),
&layer.texture,
atlas,
);
let part = match custom {
Some(custom) => {
if let Some(texture) = &custom.texture {
layer.texture.clone_from(texture);
}
custom.model.clone()
}
Some(custom) => custom.model.clone(),
None => match m.kind.layer(&layer.model) {
Some(part) => part.clone(),
None => continue,
},
};
let Some(uv) = random_uv(&layer.texture).or_else(|| atlas(&layer.texture)) else {
let Some(uv) = random_uv(texture).or_else(|| atlas(texture)) else {
continue;
};
layer_poses.reset(&part);
@@ -1815,7 +1836,7 @@ fn render_visible(
overlay,
out,
);
if translucent_layer(m, &layer) {
if translucent_layer(m, layer) {
mark_oit(&mut out[start..]);
}
// The eyes render type's glowing eyes also shine in HDR output, at
@@ -1927,7 +1948,8 @@ pub fn part_matrix(
.iter()
.find_map(|c| part_matrix(c, m, poses, name, x_offset))
}
fn find_part<'a>(part: &'a Part, name: &str) -> Option<&'a Part> {
/// The first part named `name`, depth first.
pub(super) fn find_part<'a>(part: &'a Part, name: &str) -> Option<&'a Part> {
if part.name == name {
return Some(part);
}
+24 -1
View File
@@ -262,6 +262,13 @@ pub fn drowned_travel(
m.velocity = Vec3::new(m.velocity.x * 0.9, m.velocity.y * 0.9, m.velocity.z * 0.9);
true
}
/// `RangedAttackMob.rangedAttackUncertainty`: 14 on peaceful, four less per
/// difficulty step, so a hard drowned throws its trident almost straight.
/// `Drowned` overrides it to return its own field when that is not negative,
/// but nothing in the game sets that field, so the default always applies.
fn ranged_attack_uncertainty(difficulty: u8) -> f32 {
14.0 - 4.0 * f32::from(difficulty)
}
/// The trident throw of `Drowned.performRangedAttack`: a `ThrownTrident` of
/// the held trident (a plain one when it holds none), owned by the drowned,
/// which cannot be picked up and does not return (`AbstractArrow` constructor:
@@ -291,7 +298,7 @@ fn drowned_throw(m: &mut Mob, who: Who, _power: f32, cx: &mut Cx) {
&mut crate::projectiles::own_random(m),
[xd, yd + dist * f64::from(0.2_f32), zd],
1.6,
(14 - 2 * 4) as f32,
ranged_attack_uncertainty(cx.w.difficulty.id),
);
cx.events.push(MobEvent::Projectile(p));
let pitch = 1.0 / (m.random.next_float() * 0.4 + 0.8);
@@ -1353,3 +1360,19 @@ mod conversion_tests {
assert_eq!(z.extra.feet.as_deref(), Some("minecraft:golden_boots"));
}
}
#[cfg(test)]
mod trident_tests {
use super::ranged_attack_uncertainty;
/// `RangedAttackMob.rangedAttackUncertainty` is 14 minus four per
/// difficulty step, so a drowned's trident spread shrinks from 14 on
/// peaceful to 2 on hard. Horizon used to hard-code the normal value.
#[test]
fn the_trident_spread_follows_the_difficulty() {
assert_eq!(ranged_attack_uncertainty(0), 14.0, "peaceful");
assert_eq!(ranged_attack_uncertainty(1), 10.0, "easy");
assert_eq!(ranged_attack_uncertainty(2), 6.0, "normal");
assert_eq!(ranged_attack_uncertainty(3), 2.0, "hard");
}
}
+46 -1
View File
@@ -19,7 +19,9 @@ pub fn register(m: &mut Mob) {
g.add(2, Stroll::avoiding_water(1.0));
g.add(3, LookAtPlayer::new(8.0));
g.add(3, RandomLookAround::default());
m.ai.targets.add(1, HurtByTarget::new());
// HurtByTargetGoal(this, Raider.class): a witch ignores the raider that
// hit it, so raid friendly fire never turns the raid on itself.
m.ai.targets.add(1, hurt_by());
m.ai.targets.add(2, HealRaiders::default());
// NearestAttackableWitchTargetGoal: only while not healing raiders.
m.ai.targets.add(
@@ -30,6 +32,10 @@ pub fn register(m: &mut Mob) {
.named("NearestAttackableWitchTargetGoal"),
);
}
/// `Witch.registerGoals`' `HurtByTargetGoal(this, Raider.class)`.
fn hurt_by() -> HurtByTarget {
HurtByTarget::new().ignoring(&super::raider::RAIDERS)
}
/// `NearestHealableRaiderTargetGoal`: in a raid, target another raider to
/// heal, then wait 10 seconds (the cooldown lives in `extra.counter2`).
#[derive(Clone, Debug)]
@@ -361,3 +367,42 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses) {
}
let _ = Species::Witch;
}
#[cfg(test)]
mod tests {
use super::*;
/// `Witch.registerGoals` passes `Raider.class` to `HurtByTargetGoal`, so a
/// raider that hits a witch is not taken as its target. The list is the
/// `Raider` class and nothing else: a vex is not a raider.
#[test]
fn a_witch_ignores_the_raider_that_hit_it() {
let goal = hurt_by();
assert_eq!(
goal.ignore,
vec![
Species::Pillager,
Species::Vindicator,
Species::Evoker,
Species::Illusioner,
Species::Witch,
Species::Ravager,
]
);
assert!(!goal.ignore.contains(&Species::Vex));
assert!(!goal.ignore.contains(&Species::Zombie));
}
/// The hoisted list is exactly the set `is_raider` answers yes for.
#[test]
fn the_raider_list_is_the_raider_class() {
for def in crate::mobs::species::ALL {
assert_eq!(
crate::mobs::raider::is_raider(def.species),
super::super::raider::RAIDERS.contains(&def.species),
"{:?}",
def.species
);
}
}
}
+11 -1
View File
@@ -403,6 +403,16 @@ pub(crate) fn with_built_test_world(
others: &[Seen],
build: impl FnOnce(&mut crate::world::World, &horizon_world::registry::BlockRegistry),
f: impl FnOnce(&MobWorld<'_>, &super::MobTypes),
) -> bool {
with_built_test_world_rules(others, true, build, f)
}
/// `with_built_test_world` with the `mob_griefing` game rule the world reports.
#[cfg(test)]
pub(crate) fn with_built_test_world_rules(
others: &[Seen],
mob_griefing: bool,
build: impl FnOnce(&mut crate::world::World, &horizon_world::registry::BlockRegistry),
f: impl FnOnce(&MobWorld<'_>, &super::MobTypes),
) -> bool {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
@@ -454,7 +464,7 @@ pub(crate) fn with_built_test_world(
block_tags: &items.block_tags,
landing: &motion.landing,
biomes: &biomes,
mob_griefing: true,
mob_griefing,
universal_anger: false,
mob_drops: true,
max_entity_cramming: 24,
+24 -2
View File
@@ -764,6 +764,25 @@ impl<'a> Plan<'a> {
.into());
}
}
// A block's states are its template's times the declared combinations, the
// template's properties varying slowest, but the game reads a state table the
// way vanilla's `StateDefinition` does: sorted by property name, the first name
// slowest. The two agree only when every declared name sorts after the template's,
// and a table that disagrees stops the game at load, so such a block is refused.
let last = match properties(default) {
Some(J::Obj(own)) => own.iter().map(|(k, _)| k.as_str()).max(),
_ => None,
};
if let Some(last) = last
&& let Some(early) = decl.state_properties.keys().find(|k| k.as_str() < last)
{
return Err(format!(
"{}: state property {early} sorts before {last}, a property of {}: the \
game orders states by property name, so name it after {last}",
decl.id, decl.template
)
.into());
}
let combos = combinations(&decl.state_properties, &decl.default_state)
.map_err(|e| format!("{}: {e}", decl.id))?;
// A `default_state` key the block itself declares is its own,
@@ -966,7 +985,7 @@ impl<'a> Plan<'a> {
if !b.decl.item {
row.set("item", J::Str("minecraft:air".into()));
}
self.override_gameplay(b, &mut row);
self.override_gameplay(b, combo, &mut row);
if let Some(shapes) = &b.shapes {
let shape = &shapes[t];
let boxes = shape
@@ -1003,8 +1022,11 @@ impl<'a> Plan<'a> {
}
/// The declared properties, on a gameplay row.
fn override_gameplay(&self, b: &NewBlock, row: &mut J) {
fn override_gameplay(&self, b: &NewBlock, combo: &[(String, String)], row: &mut J) {
let p = &b.decl.properties;
if let Some(ticks) = &p.random_ticks {
row.set("random_ticks", J::Bool(ticks.applies(combo)));
}
if let Some(v) = p.hardness {
row.set("hardness", J::f64(v as f64));
}
+26 -1
View File
@@ -389,7 +389,8 @@ fn ruby_mod(dir: &Path) -> ContentMod {
(
"data/ruby/horizon/block/ruby_block.json",
r##"{"template": "minecraft:iron_block", "properties": {"hardness": 4.0, "light": 3,
"sounds": "minecraft:amethyst_block", "map_color": 28, "friction": 0.8},
"sounds": "minecraft:amethyst_block", "map_color": 28, "friction": 0.8,
"random_ticks": true},
"components": {"minecraft:max_stack_size": 16,
"minecraft:damage_resistant": {"types": "#minecraft:is_fire"}}}"##,
),
@@ -533,6 +534,12 @@ fn content_root_loads_with_every_table() {
horizon_world::level::Behaviours::load_with_gameplay(new, &root, &shared_json, &gameplay)
.unwrap();
assert_eq!(shared_facts.states.len(), facts.states.len());
// `random_ticks` makes the block tick randomly although its template (iron) never does; the
// sections' counts read the same flag (`random_tick_candidate`).
assert!(independent_behaviours.is_randomly_ticking(ruby));
assert!(independent_behaviours.random_tick_candidate(ruby));
assert!(!independent_behaviours.is_randomly_ticking(iron));
assert!(deferred_behaviours.is_randomly_ticking(ruby));
for state in [ruby, ruby_door, BlockStateId(n + 65)] {
assert_eq!(shared_facts.get(state).hardness, facts.get(state).hardness);
assert_eq!(
@@ -2670,6 +2677,24 @@ fn content_block_state_properties_are_validated() {
fs::remove_dir_all(&base).unwrap();
fs::remove_dir_all(base.parent().unwrap().join("mod0")).ok();
// A declared name that sorts before the template's `axis` would make the state table
// disagree with vanilla's order and stop the game at load: refused, naming the block.
let (base, mods) = block_base(
"block-name-order",
&[r#"{"template":"minecraft:test","state_properties":{"alpha":{"type":"boolean"}}}"#],
);
let error = match Plan::new(&base, &mods) {
Err(e) => e.to_string(),
Ok(_) => panic!("a property sorting before the template's was accepted"),
};
assert!(
error.contains("testmod:") && error.contains("alpha sorts before axis, a property of"),
"{error}"
);
assert!(!error.contains(" "), "one space between words: {error}");
fs::remove_dir_all(&base).unwrap();
fs::remove_dir_all(base.parent().unwrap().join("mod0")).ok();
// A default is one of the values the block declared.
let (base, mods) = block_base(
"block-bad-default",
+46 -10
View File
@@ -9,7 +9,7 @@ use crate::{
text::{Text, format_translation},
ui,
};
use horizon_assets::item_model::{DisplayContext, NoEnv};
use horizon_assets::item_model::{DisplayContext, ItemEnv};
use horizon_world::nbt::Tag;
use serde_json::Value;
use std::{collections::BTreeSet, path::Path};
@@ -889,6 +889,26 @@ fn draw_presets(
);
}
/// The item environment of the layer icons: only the grass colormap is needed
/// (`minecraft:grass` tint of the grass block), as the inventory draws it.
struct GrassEnv<'a>(&'a ItemRenderer);
impl ItemEnv for GrassEnv<'_> {
fn grass_color(&self, temperature: f64, downfall: f64) -> i32 {
self.0.grass_color(temperature, downfall)
}
}
/// A layer's item model for the GUI, with the grass colormap in its environment.
fn layer_state(renderer: &ItemRenderer, id: &str) -> ItemRenderState {
let stack = ItemStack::new(&layer_item(id), 1);
renderer.resolve(
&item_render::stack_view(&stack),
DisplayContext::Gui,
None,
None,
0,
&GrassEnv(renderer),
)
}
/// The 18x18 slot behind a layer's item icon (`container/slot`), then the item
/// (`GuiGraphics.renderItem`): its GUI model, so a block shows as the isometric
/// block. Without item models, or for an id with none, the flat sprite stands in.
@@ -908,15 +928,7 @@ fn slot(
batch.fill(atlas, x + 1.0, y + 1.0, 16.0, 16.0, rgba(55, 55, 55, 255));
}
if let Some(renderer) = items {
let stack = ItemStack::new(&layer_item(id), 1);
let state = renderer.resolve(
&item_render::stack_view(&stack),
DisplayContext::Gui,
None,
None,
0,
&NoEnv,
);
let state = layer_state(renderer, id);
if !state.is_empty() {
pending.push((state, x + 1.0, y + 1.0));
return;
@@ -1016,6 +1028,30 @@ fn pretty(name: &str) -> String {
#[cfg(test)]
mod tests {
use super::layer_item;
use crate::item_render::ItemRenderer;
use std::path::Path;
/// The grass block icon of a Superflat layer is tinted by the grass
/// colormap (`minecraft:grass` tint source), not the missing-tint magenta.
#[test]
fn the_grass_block_layer_icon_is_tinted_by_the_colormap() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("entity-models.json").exists() {
return;
}
let renderer = ItemRenderer::load(&root).unwrap();
let tinted: Vec<i32> = super::layer_state(&renderer, "minecraft:grass_block")
.layers
.iter()
.flat_map(|layer| layer.tints.clone())
.collect();
assert!(
!tinted.is_empty() && !tinted.contains(&-65281),
"{tinted:?}"
);
}
#[test]
fn a_layer_shows_the_item_of_its_block() {
@@ -1,4 +1,5 @@
//! Native component registration, value limits and content identity.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
content_identity,
mod_content::{ContentMod, accept_mod, build, known_components},
@@ -1,3 +1,4 @@
extern crate horizon_client_lib as horizon_client;
use horizon_protocol::{
buf::{Codec, Reader, Writer},
item::Slot,
@@ -1,3 +1,4 @@
extern crate horizon_client_lib as horizon_client;
use horizon_client::{item_sync, items::ItemStack, lan::guest::stack_to_network};
use horizon_items::item_nbt::{item_from_nbt, item_to_nbt};
use horizon_protocol::{
@@ -4,6 +4,7 @@
//! column's status (`ChunkLevel.generationStatus`). The chunk itself and the
//! two rings around it are level chunks with their light, the third ring has
//! its features and the fourth only its terrain.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
assets::Assets,
biome::Biomes,
@@ -3,6 +3,7 @@
//! after its own decoration and its eight neighbours', and chunks that
//! decoration wrote to are saved rather than generated again without those
//! writes. Bastions lost whole chunks of pieces this way.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
assets::Assets,
biome::Biomes,
+44
View File
@@ -2,6 +2,7 @@
//! handshake, status, offline login, configuration (registries and tags),
//! then the play state (login, chunks with light, player list, entities,
//! block changes). Needs the prepared resources; skipped without them.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
chunk::{Chunk, MIN_Y},
lan::{
@@ -1444,3 +1445,46 @@ fn a_respawned_guest_is_immune_until_its_client_loads_the_world() {
"the packet marks it loaded"
);
}
/// `tickPlayer`'s vehicle check (BoatFly): the driver of a boat its last accepted move left hovering
/// over air is kicked once the boat has floated longer than `getMaximumFlyingTicks` (160 ticks at a
/// boat's gravity), and a move that doesn't float starts the count again.
#[test]
fn a_boat_floating_too_long_kicks_its_driver() {
let Some(resources) = resources() else {
return;
};
let mut s = server(&resources);
let port = s.hub.lan.port;
let _bot = run(&mut s, move || connect(port, "Boater", true));
let deadline = Instant::now() + Duration::from_secs(5);
while s.hub.guests.is_empty() {
s.tick(&[]);
assert!(Instant::now() < deadline, "guest did not join");
thread::yield_now();
}
let g = &mut s.hub.guests[0];
(g.vehicle, g.vehicle_floating, g.vehicle_gravity) = (Some(7), true, 0.04);
for _ in 0..100 {
s.tick(&[]);
}
assert_eq!(s.hub.guests[0].above_ground_vehicle_ticks, 100);
// The boat touched water: the next floating move counts from zero.
s.hub.guests[0].vehicle_floating = false;
s.tick(&[]);
assert_eq!(s.hub.guests[0].above_ground_vehicle_ticks, 0);
s.hub.guests[0].vehicle_floating = true;
for _ in 0..160 {
s.tick(&[]);
}
assert!(
s.hub.guests[0].left_reason.is_empty(),
"160 ticks are a boat's allowance: {}",
s.hub.guests[0].left_reason
);
s.tick(&[]);
assert!(
!s.hub.guests[0].left_reason.is_empty(),
"the 161st floating tick kicks the driver"
);
}
@@ -4,6 +4,7 @@
//! came closer, so they stayed invisible until a block in them changed.
//! Relit columns remesh only where their light changed (#270), and every
//! mesh still matches the world.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
assets::Assets,
biome::Biomes,
@@ -1,6 +1,7 @@
//! A level taller than vanilla's (To The Max's Overworld: -64..2032, 131
//! sections) streams, lights and meshes to its top, and edits near the top
//! relight and remesh it.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
assets::Assets,
biome::Biomes,
@@ -2,6 +2,7 @@
//! content mods can take the registry past 65,536 states. Its own test binary,
//! since the palette width is set once for the whole process.
extern crate horizon_client_lib as horizon_client;
use horizon_client::chunk::{Chunk, Section, block_strategy, set_block_states};
#[test]
+42 -2
View File
@@ -176,9 +176,21 @@ mod tests {
let (mut ended, _) = l.accept().unwrap();
drop(deadline(&kept, Duration::from_millis(100)).unwrap());
let _running = deadline(&ended, Duration::from_millis(100)).unwrap();
// Read once the limit has passed: a read already blocked when it
// passes is not woken on Windows (shutdown doesn't cancel it there).
std::thread::sleep(Duration::from_millis(300));
ended
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let started = Instant::now();
assert_eq!(ended.read(&mut [0u8; 1]).unwrap(), 0);
assert!(started.elapsed() < Duration::from_secs(2));
match ended.read(&mut [0u8; 1]) {
Ok(n) => assert_eq!(n, 0),
Err(e) => assert!(!matches!(
e.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
)),
}
assert!(started.elapsed() < Duration::from_secs(1));
kept.set_read_timeout(Some(Duration::from_millis(300)))
.unwrap();
let e = kept.read(&mut [0u8; 1]).unwrap_err();
@@ -187,4 +199,32 @@ mod tests {
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
));
}
#[test]
fn a_deadline_ends_a_peer_that_sends_one_byte_at_a_time() {
use std::io::Write;
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let mut peer = TcpStream::connect(l.local_addr().unwrap()).unwrap();
let (mut s, _) = l.accept().unwrap();
std::thread::spawn(move || {
for _ in 0..100 {
if peer.write_all(b"x").is_err() {
break;
}
std::thread::sleep(Duration::from_millis(50));
}
});
// What the callers do: a read timeout that restarts with every byte.
s.set_read_timeout(Some(Duration::from_secs(1))).unwrap();
let _running = deadline(&s, Duration::from_millis(300)).unwrap();
let started = Instant::now();
while let Ok(n) = s.read(&mut [0u8; 1])
&& n > 0
{}
assert!(
started.elapsed() < Duration::from_secs(2),
"{:?}",
started.elapsed()
);
}
}
+34 -1
View File
@@ -306,6 +306,15 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.15"
@@ -319,7 +328,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide",
"miniz_oxide 0.9.1",
"zlib-rs",
]
@@ -506,6 +515,7 @@ version = "0.1.0"
dependencies = [
"bytemuck",
"naga",
"png",
"serde",
"serde_json",
"sha2",
@@ -783,6 +793,16 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "miniz_oxide"
version = "0.9.1"
@@ -911,6 +931,19 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide 0.8.9",
]
[[package]]
name = "potential_utf"
version = "0.1.6"
+78
View File
@@ -86,6 +86,35 @@ pub struct BlockProperties {
pub map_color: Option<u8>,
/// A vanilla block whose sounds this block uses.
pub sounds: Option<String>,
/// Which states tick randomly, whatever the template does (`isRandomlyTicking`).
pub random_ticks: Option<RandomTicks>,
}
/// Which of a block's states get random ticks (`BlockBehaviour.isRandomlyTicking(state)`), so a
/// crop, sapling or soil on a template that doesn't tick still grows. The sections' random tick
/// counts follow it, and `native_random_tick` fires for the states it picks.
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum RandomTicks {
/// Every state (`true`), or none (`false`, for a template that ticks).
All(bool),
/// The states whose own state property `property` is one of `values`.
States {
property: String,
values: Vec<String>,
},
}
impl RandomTicks {
/// Whether a state ticks, `combo` being its values of the block's own state properties.
pub fn applies(&self, combo: &[(String, String)]) -> bool {
match self {
RandomTicks::All(all) => *all,
RandomTicks::States { property, values } => combo
.iter()
.any(|(name, value)| name == property && values.contains(value)),
}
}
}
/// Per-state outline and collision boxes, selected by one state property.
@@ -734,6 +763,19 @@ fn check_block(file: &BlockFile) -> Result<(), String> {
}
}
let p = &file.properties;
if let Some(RandomTicks::States { property, values }) = &p.random_ticks {
let declared = file
.state_properties
.get(property)
.map(StateProperty::values);
if declared.is_none_or(|all| {
values.is_empty() || values.iter().any(|v| !all.contains(&v.as_str()))
}) {
return Err(format!(
"random_ticks needs values of the block's own state property {property:?} (state_properties)"
));
}
}
let finite = |v: Option<f32>| v.is_none_or(f32::is_finite);
if !finite(p.hardness) || !finite(p.explosion_resistance) || !finite(p.friction) {
return Err("hardness, explosion_resistance and friction are numbers".into());
@@ -1118,6 +1160,42 @@ capabilities = ["registries"]
assert!(e.contains("already throws another entity type"), "{e}");
}
#[test]
fn blocks_pick_the_states_that_tick_randomly() {
let own = r#""state_properties": {"grown": {"type": "boolean"}}"#;
let block = |ticks: &str| {
let text = format!(
r#"{{"template": "minecraft:dirt", {own}, "properties": {{"random_ticks": {ticks}}}}}"#
);
let files: &[(&str, &[u8])] = &[("data/ruby/horizon/block/crop.json", text.as_bytes())];
hmod("random-ticks", MANIFEST_TEXT, files)
};
let ticks =
|c: Result<Content, String>| c.unwrap().blocks[0].properties.random_ticks.clone();
let state = |v: &str| [("grown".to_owned(), v.to_owned())];
// Every state, whatever the template does; `false` stops a template that ticks.
let all = ticks(block("true")).unwrap();
assert!(all.applies(&state("true")) && all.applies(&[]));
assert!(!ticks(block("false")).unwrap().applies(&state("false")));
// Chosen states: a crop that stops ticking once it is grown.
let chosen = ticks(block(r#"{"property": "grown", "values": ["false"]}"#)).unwrap();
assert!(chosen.applies(&state("false")) && !chosen.applies(&state("true")));
assert!(
!chosen.applies(&[]),
"a state without the property does not tick"
);
// Not the block's own property, no values, or a value it doesn't have.
for bad in [
r#"{"property": "age", "values": ["0"]}"#,
r#"{"property": "grown", "values": []}"#,
r#"{"property": "grown", "values": ["maybe"]}"#,
] {
let e = block(bad).err().unwrap();
assert!(e.contains("random_ticks"), "{bad}: {e}");
}
assert!(block("3").is_err());
}
#[test]
fn block_items_patch_their_components() {
let block = br#"{"template": "minecraft:iron_block", "components": {
+172
View File
@@ -1696,6 +1696,178 @@ mod tests {
}
}
/// `textureGatherOffsets` (GLSL 4.60, 8.9.4), which naga lacks: offsets from a `const`
/// array or a constructor, a component, an unsigned texture and the depth compare form, in a
/// pack function that takes the sampler too; every form must reach SPIR-V.
#[test]
fn texture_gather_offsets_translate() {
let fsh = "#version 430\nuniform sampler2D colortex0;\nuniform usampler2D colortex5;\n\
uniform sampler2DShadow shadowtex0;\nout vec4 c;\n\
const ivec2 ring[4] = ivec2[4](ivec2(-1, 0), ivec2(1, 0), ivec2(0, -1), ivec2(0, 1));\n\
float edge(sampler2D s, vec2 uv) { return textureGatherOffsets(s, uv, ring, 3).x; }\n\
void main() {\n vec2 uv = gl_FragCoord.xy / 64.0;\n \
vec4 a = textureGatherOffsets(colortex0, uv, ring);\n \
uvec4 b = textureGatherOffsets(colortex5, uv,\n \
ivec2[4](ivec2(0), ivec2(2, 0), ivec2(0, 2), ivec2(2)), 1);\n \
vec4 d = textureGatherOffsets(shadowtex0, uv, 0.5, ring);\n \
c = a + vec4(b) + d + edge(colortex0, uv);\n}\n";
let p = program(
&[("composite.vsh", VSH_120), ("composite.fsh", fsh)],
Target::Buffers,
)
.unwrap_or_else(|e| panic!("{e:?}"));
let info = Validator::new(ValidationFlags::all(), Capabilities::all())
.validate(&p.fragment)
.unwrap();
let entry = &p.fragment.entry_points[0];
let pipeline = naga::back::spv::PipelineOptions {
shader_stage: entry.stage,
entry_point: entry.name.clone(),
};
naga::back::spv::write_vec(&p.fragment, &info, &Default::default(), Some(&pipeline))
.unwrap();
// A program that doesn't call it gets none of it.
let plain = program(
&[
("composite.vsh", VSH_120),
(
"composite.fsh",
"#version 120\nvoid main() { gl_FragColor = vec4(1.0); }\n",
),
],
Target::Buffers,
)
.unwrap();
assert!(
!plain
.fragment
.functions
.iter()
.any(|(_, f)| f.name.as_deref() == Some("hz_textureGatherOffsets"))
);
}
/// A `return` with code after it in its block (Miniature's hand: `#ifdef GBUFFERS_HAND
/// return 0.5 * diffuse; #endif` and the rest of the function), which naga's frontend
/// leaves half-built.
#[test]
fn early_returns_with_values_translate() {
let fsh = "#version 120\nuniform sampler2D shadowtex1;\nuniform mat4 shadowModelView;\n\
float len2(vec3 v) { return dot(v, v); }\n\
float f(float d, vec3 p) {\n d *= 2.0;\n return 0.5 * d;\n \
vec3 q = p * len2(p);\n vec4 v = shadowModelView * vec4(q, 1.0);\n \
if (dot(q, q) < 4.0 && d > 0.0 && v.z < 1.0) {\n \
float s = texture2D(shadowtex1, v.st).x;\n return d * s / shadowModelView[2].z;\n }\n \
return d;\n}\nvoid main() { gl_FragColor = vec4(f(0.5, vec3(1.0))); }\n";
program(
&[("composite.vsh", VSH_120), ("composite.fsh", fsh)],
Target::Buffers,
)
.unwrap_or_else(|e| panic!("{e:?}"));
}
/// GLSL's `dot` and `normalize` take a `float` too (genType); naga's take vectors only. A
/// pack's own `dot` is kept.
#[test]
fn scalar_dot_and_normalize_translate() {
let fsh = "#version 330\nuniform float k;\nout vec4 c;\n\
void main() { c = vec4(dot(k, 2.0), normalize(k - 0.5), dot(vec2(k), vec2(1.0)), 1.0); }\n";
let own = "#version 330\nuniform float k;\nout vec4 c;\nfloat dot(float a, float b) { return a + b; }\n\
void main() { c = vec4(dot(k, 2.0)); }\n";
for fsh in [fsh, own] {
program(
&[("composite.vsh", VSH_120), ("composite.fsh", fsh)],
Target::Buffers,
)
.unwrap_or_else(|e| panic!("{fsh}: {e:?}"));
}
}
/// GLSL converts a returned value to the function's type (Kappa's `GetEmissionScale`):
/// `return 1;` in a `float` function, an `int` or `ivec2` returned as `float` or `vec2`.
#[test]
fn returns_convert_to_the_function_type() {
let fsh = "#version 330\nuniform int k;\nout vec4 c;\n\
float one() { return 1; }\nfloat twice(int a) { return a * 2 + 1; }\n\
vec2 cell() { ivec2 p = ivec2(k, 3); return p; }\n\
float unsigned_value() { uint u = 3u; return u; }\n\
float early(float x) {\n if (x > 0.5) return 2;\n return x;\n}\n\
void main() { c = vec4(one() + twice(k), cell(), unsigned_value() + early(0.7)); }\n";
program(
&[("composite.vsh", VSH_120), ("composite.fsh", fsh)],
Target::Buffers,
)
.unwrap_or_else(|e| panic!("{e:?}"));
}
/// Returns that are an `if`'s branches, last in a function that returns a value (TAA's
/// `ClipAABB` in BSL, Complementary and Solas): naga ends such tail blocks with a `return`
/// of its own, which must not land in a guard of ours.
#[test]
fn returns_as_branches_stay_plain() {
let fsh = "#version 120\nuniform float k;\n\
vec3 clip(vec3 a, vec3 b) {\n vec3 d = b - a;\n \
if (k > 1.0) return a + d / k;\n else return b;\n}\n\
float pick(float x) {\n if (x > 0.5) { return 1.0; }\n return x;\n}\n\
void main() { gl_FragColor = vec4(clip(vec3(0.0), vec3(1.0)), pick(k)); }\n";
program(
&[("composite.vsh", VSH_120), ("composite.fsh", fsh)],
Target::Buffers,
)
.unwrap_or_else(|e| panic!("{e:?}"));
}
/// `if (c) return; else ...` keeps its `else`: the statements the translation guards with
/// `if (true)` must not take an `else` that belongs to the pack's `if`.
#[test]
fn else_stays_with_its_if() {
let fsh = "#version 120\nuniform float k;\n\
void g(inout float x) { if (k > 0.5) return; else x = 3.0; }\n\
float h(float x) { if (k > 0.5) return 1.0; else x = 2.0; return x; }\n\
void main() {\n float x = 0.0;\n g(x);\n \
if (k > 0.9) discard; else x += h(x);\n gl_FragColor = vec4(x);\n}\n";
let p = program(
&[("composite.vsh", VSH_120), ("composite.fsh", fsh)],
Target::Buffers,
)
.unwrap_or_else(|e| panic!("{e:?}"));
// Our guards are `if (true)`: one with an `else` took the pack's.
fn guard_with_else(f: &naga::Function, body: &naga::Block) -> bool {
body.iter().any(|s| match s {
naga::Statement::If {
condition,
accept,
reject,
} => {
let ours = matches!(
f.expressions[*condition],
naga::Expression::Literal(naga::Literal::Bool(true))
);
// naga ends a void function's tail blocks with a `return` of its own.
let pack_code = reject.iter().any(|s| {
!matches!(s, naga::Statement::Return { .. } | naga::Statement::Emit(_))
});
(ours && pack_code) || guard_with_else(f, accept) || guard_with_else(f, reject)
}
naga::Statement::Block(b) => guard_with_else(f, b),
_ => false,
})
}
for name in ["g", "h", "hz_main"] {
let f = p
.fragment
.functions
.iter()
.find(|(_, f)| f.name.as_deref() == Some(name))
.map(|(_, f)| f)
.unwrap_or_else(|| panic!("{name} missing"));
assert!(
!guard_with_else(f, &f.body),
"{name}: the pack's else moved into our if (true)"
);
}
}
#[test]
fn a_pack_keeps_its_own_inverse() {
let fsh = "#version 120\nuniform mat4 gbufferProjection;\n\
+171 -9
View File
@@ -43,6 +43,16 @@ impl Stage<'_> {
if self.inverse {
p.push_str(INVERSE);
}
if self.gather_offsets {
p.push_str(&gather_offsets());
}
// GLSL's scalar `dot` and `normalize` (genType includes `float`); naga's are vector only.
if self.scalar_dot {
p.push_str("float dot(float a, float b) { return a * b; }\n");
}
if self.scalar_normalize {
p.push_str("float normalize(float x) { return sign(x); }\n");
}
if self.gbuffers {
// Set by our `main` from the frame's matrices (gbuffers.rs).
p.push_str(
@@ -100,6 +110,10 @@ impl Stage<'_> {
let mut hidden: Vec<(&str, usize)> = Vec::new();
let mut depth = 0;
let mut skip_until = 0;
// The `;` that closes a statement we rewrite, and what is written in its place.
let mut statement_close: Option<(usize, &str)> = None;
// The type the function being written returns, when GLSL converts to it implicitly.
let mut returns: Option<&str> = None;
let next = |i: usize| (i + 1..self.tokens.len()).find(|&j| significant(self.tokens[j]));
let prev = |i: usize| (0..i).rev().find(|&j| significant(self.tokens[j]));
let structs: Vec<&str> = (self.tokens.iter().enumerate())
@@ -110,6 +124,13 @@ impl Stage<'_> {
if i < skip_until {
continue;
}
if let Some((end, close)) = statement_close
&& end == i
{
out.push_str(close);
statement_close = None;
continue;
}
if let Some(text) = self.replaced.get(&i) {
out.push_str(text);
continue;
@@ -158,14 +179,56 @@ impl Stage<'_> {
skip_until = j + 1;
continue;
}
// naga's frontend leaves the code after an unconditional `discard` or `return`
// unreachable but half-built (a module that fails validation): behind an `if` it
// stays reachable as far as naga knows. The braces keep a pack's `else` with the
// pack's `if` (`if (c) discard; else ...`). A `return` with a value is guarded only
// as a statement of its own with code after it in its block: as an `if`'s branch
// the code after it is reachable already, and naga would end our guard's empty
// `else` with a `return;` of its own where the function returns a value.
// GLSL converts a returned value to the function's type (`return 1;` in a `float`
// function); naga does not, so the conversion is written out: `return float(1);`.
if matches!(t, "discard" | "return")
&& let Some(end) = self.statement_end(i)
{
let value = t == "return" && next(i) != Some(end);
let guard = !value
|| (prev(i).is_none_or(|j| matches!(self.tokens[j], ";" | "{" | "}"))
&& next(end).is_some_and(|j| self.tokens[j] != "}"));
let wrap = returns.filter(|_| value && !self.top_level_comma(i, end));
if guard || wrap.is_some() {
if guard {
out.push_str("{ if (true) ");
}
out.push_str(t);
if let Some(ty) = wrap {
write!(out, " {ty}(").unwrap();
}
statement_close = Some((
end,
match (wrap.is_some(), guard) {
(true, true) => "); }",
(true, false) => ");",
(false, _) => "; }",
},
));
continue;
}
}
let called = next(i).is_some_and(|j| self.tokens[j] == "(");
match t {
"{" => depth += 1,
"{" => {
if depth == 0 {
returns = self.converted_return(i);
}
depth += 1;
}
"}" => {
depth -= 1;
if depth == 0 {
params.clear();
hidden.clear();
returns = None;
}
}
"(" => {
@@ -267,15 +330,9 @@ impl Stage<'_> {
}
let replaced: Option<String> = match t {
"main" if depth == 0 && called => Some("hz_main".into()),
// naga's frontend leaves the code after an unconditional `discard` or `return;`
// unreachable but half-built (a module that fails validation): behind an `if`
// it stays reachable as far as naga knows.
"discard" => Some("if (true) discard".into()),
"return" if next(i).is_some_and(|j| self.tokens[j] == ";") => {
Some("if (true) return".into())
}
// GLSL's `modf`, which naga's frontend lacks.
"modf" if called => Some("hz_modf".into()),
"textureGatherOffsets" if called => Some("hz_textureGatherOffsets".into()),
// Matrix inverse, which naga writes for neither Metal nor WGSL.
"inverse" if called && self.inverse => Some("hz_inverse".into()),
"texture2D" | "texture1D" | "texture3D" | "textureCube" if called => {
@@ -316,8 +373,8 @@ impl Stage<'_> {
"gl_FragCoord" if !fragment => None,
"gl_FragCoord" => Some("hz_FragCoord".into()),
"gl_FrontColor" => Some("hz_FrontColor".into()),
"gl_Fog" => Some("hz_Fog".into()),
"gl_FogFragCoord" => Some("hz_FogFragCoord".into()),
"gl_Fog" => Some("hz_Fog".into()),
// Before the compatibility inputs, of which `gl_Vertex` is a prefix.
"gl_VertexID" => Some("gl_VertexIndex".into()),
"gl_InstanceID" => Some("gl_InstanceIndex".into()),
@@ -339,6 +396,74 @@ impl Stage<'_> {
impl Stage<'_> {
/// Whether the declaration after token `i` calls a built-in in `NOT_CONSTANT` before its `;`.
/// The return type of the function whose body opens at token `brace`, when it is one GLSL
/// converts returned values to (a scalar or vector of `float`, `int` or `uint`).
pub(super) fn converted_return(&self, brace: usize) -> Option<&str> {
let prev = |i: usize| (0..i).rev().find(|&j| significant(self.tokens[j]));
let close = prev(brace).filter(|&j| self.tokens[j] == ")")?;
let mut depth = 0usize;
let open = (0..=close).rev().find(|&j| match self.tokens[j] {
")" => {
depth += 1;
false
}
"(" => {
depth -= 1;
depth == 0
}
_ => false,
})?;
let ty = self.tokens[prev(prev(open)?)?];
matches!(
ty,
"float"
| "vec2"
| "vec3"
| "vec4"
| "int"
| "ivec2"
| "ivec3"
| "ivec4"
| "uint"
| "uvec2"
| "uvec3"
| "uvec4"
)
.then_some(ty)
}
/// Whether tokens `i..end` hold a comma outside brackets (GLSL's comma operator).
pub(super) fn top_level_comma(&self, i: usize, end: usize) -> bool {
let mut depth = 0usize;
self.tokens[i..end].iter().any(|&t| match t {
"(" | "[" => {
depth += 1;
false
}
")" | "]" => {
depth = depth.saturating_sub(1);
false
}
"," => depth == 0,
_ => false,
})
}
/// The `;` that ends the statement starting at token `i` (outside brackets).
pub(super) fn statement_end(&self, i: usize) -> Option<usize> {
let mut depth = 0usize;
for j in i + 1..self.tokens.len() {
match self.tokens[j] {
"(" | "[" => depth += 1,
")" | "]" => depth = depth.saturating_sub(1),
";" if depth == 0 => return Some(j),
"{" | "}" => return None,
_ => {}
}
}
None
}
pub(super) fn calls_not_constant(&self, i: usize) -> bool {
for (j, &t) in self.tokens.iter().enumerate().skip(i + 1) {
if matches!(t, ";" | "{" | "}") {
@@ -455,6 +580,43 @@ const TYPE_NAMES: [&str; 35] = [
];
/// `0.0`, `0.`, `.0`, `0.0f`: a floating-point literal zero.
/// `textureGatherOffsets` (GLSL 4.60, 8.9.4), which naga's frontend lacks: texel `i` of the
/// result is texel `i` of the footprint `offsets[i]` texels away. WGSL takes only constant
/// offsets, so each footprint is gathered at `P` moved by its offset over the texture's size,
/// which picks the same four texels; the component, a constant in GLSL, picks one of four
/// gathers.
fn gather_offsets() -> String {
let mut p = String::new();
for prefix in ["", "i", "u"] {
let (tex, smp) = (format!("{prefix}texture2D"), format!("{prefix}sampler2D"));
let vec = format!("{prefix}vec4");
write!(
p,
"{vec} hz_gather({tex} t, sampler s, vec2 p, int c) {{\n\
\x20 if (c == 1) return textureGather({smp}(t, s), p, 1);\n\
\x20 if (c == 2) return textureGather({smp}(t, s), p, 2);\n\
\x20 if (c == 3) return textureGather({smp}(t, s), p, 3);\n\
\x20 return textureGather({smp}(t, s), p, 0);\n}}\n\
{vec} hz_textureGatherOffsets({tex} t, sampler s, vec2 p, ivec2 o[4], int c) {{\n\
\x20 vec2 px = 1.0 / vec2(textureSize({smp}(t, s), 0));\n\
\x20 return {vec}(hz_gather(t, s, p + vec2(o[0]) * px, c).x, hz_gather(t, s, p + vec2(o[1]) * px, c).y,\n\
\x20 hz_gather(t, s, p + vec2(o[2]) * px, c).z, hz_gather(t, s, p + vec2(o[3]) * px, c).w);\n}}\n\
{vec} hz_textureGatherOffsets({tex} t, sampler s, vec2 p, ivec2 o[4]) {{\n\
\x20 return hz_textureGatherOffsets(t, s, p, o, 0);\n}}\n"
)
.unwrap();
}
p.push_str(
"vec4 hz_textureGatherOffsets(texture2D t, samplerShadow s, vec2 p, float r, ivec2 o[4]) {\n\
\x20 vec2 px = 1.0 / vec2(textureSize(sampler2DShadow(t, s), 0));\n\
\x20 return vec4(textureGather(sampler2DShadow(t, s), p + vec2(o[0]) * px, r).x,\n\
\x20 textureGather(sampler2DShadow(t, s), p + vec2(o[1]) * px, r).y,\n\
\x20 textureGather(sampler2DShadow(t, s), p + vec2(o[2]) * px, r).z,\n\
\x20 textureGather(sampler2DShadow(t, s), p + vec2(o[3]) * px, r).w);\n}\n",
);
p
}
fn is_zero_literal(t: &str) -> bool {
let t = t.trim_end_matches(['f', 'F']);
t.contains('.') && !t.contains(['e', 'E']) && t.chars().all(|c| c == '0' || c == '.')
@@ -57,6 +57,12 @@ pub(super) struct Stage<'p> {
pub(super) out_copies: Vec<String>,
/// The stage calls `inverse`, which ours replaces, and doesn't define its own.
pub(super) inverse: bool,
/// The stage calls `textureGatherOffsets`, which naga lacks: ours is in the prelude.
pub(super) gather_offsets: bool,
/// The stage calls `dot` / `normalize` and defines neither: the scalar overloads GLSL has
/// (`dot(float, float)`, `normalize(float)`) and naga lacks go in the prelude.
pub(super) scalar_dot: bool,
pub(super) scalar_normalize: bool,
/// The pack's functions that take samplers: which parameters are samplers.
pub(super) sampler_functions: HashMap<String, Vec<bool>>,
/// The pack's structs: each member's type and name with its array suffix.
@@ -254,6 +260,9 @@ impl<'p> Stage<'p> {
frag_outs: Vec::new(),
out_copies: Vec::new(),
inverse: false,
gather_offsets: false,
scalar_dot: false,
scalar_normalize: false,
sampler_functions: HashMap::new(),
structs: HashMap::new(),
outputs: BTreeSet::new(),
@@ -266,6 +275,7 @@ impl<'p> Stage<'p> {
s.rename_vulkan_types();
let (mut start, mut depth, mut parens) = (0, 0usize, 0usize);
let mut own_inverse = false;
let (mut own_dot, mut own_normalize) = (false, false);
for i in 0..s.tokens.len() {
match s.tokens[i] {
"(" => parens += 1,
@@ -300,6 +310,22 @@ impl<'p> Stage<'p> {
}
}
"gl_FragCoord" => s.frag_coord = true,
"textureGatherOffsets" => s.gather_offsets = true,
"dot" | "normalize" => {
// A definition at the top level: `float dot(float a, ...)`.
let defines = depth == 0
&& s.tokens[..i]
.iter()
.rfind(|t| significant(t))
.is_some_and(|t| is_ident(t));
if s.tokens[i] == "dot" {
s.scalar_dot = true;
own_dot |= defines;
} else {
s.scalar_normalize = true;
own_normalize |= defines;
}
}
"inverse" => {
s.inverse = true;
// `mat4 inverse(mat4 m)`: a pack for GLSL 1.20, which has none, wrote its own.
@@ -323,6 +349,14 @@ impl<'p> Stage<'p> {
}
}
s.inverse &= !own_inverse;
s.scalar_dot &= !own_dot;
s.scalar_normalize &= !own_normalize;
if s.gather_offsets {
// Its sampler is passed as a texture and a sampler, as to the pack's own functions.
s.sampler_functions
.entry("textureGatherOffsets".into())
.or_insert_with(|| vec![true, false, false, false, false]);
}
s
}
+97 -1
View File
@@ -64,6 +64,73 @@ fn number(text: &str) -> Option<u32> {
}
}
/// The CPU's name from a Linux `/proc/cpuinfo`: x86's `model name`; else an ARM core named by
/// its implementer and part (an arm64 kernel prints only `CPU implementer: 0x41` and `CPU part:
/// 0xd0c`, as `lscpu` maps them: `ARM Neoverse-N1`); else the board's `Model` (a Raspberry Pi's
/// downstream kernel).
pub fn linux_cpu_name(cpuinfo: &str) -> Option<String> {
if let Some(name) = field(cpuinfo, "model name").filter(|n| !n.is_empty()) {
return Some(name);
}
let hex = |key: &str| {
let text = field(cpuinfo, key)?;
u32::from_str_radix(text.strip_prefix("0x")?, 16).ok()
};
if let (Some(implementer), Some(part)) = (hex("CPU implementer"), hex("CPU part")) {
return Some(arm_core_name(implementer, part));
}
field(cpuinfo, "Model").filter(|n| !n.is_empty())
}
/// `lscpu`'s `hw_implementer` and `arm_part` tables, for the cores that run Horizon.
fn arm_core_name(implementer: u32, part: u32) -> String {
let (vendor, core) = match (implementer, part) {
(0x41, 0xd01) => ("ARM", "Cortex-A32"),
(0x41, 0xd03) => ("ARM", "Cortex-A53"),
(0x41, 0xd04) => ("ARM", "Cortex-A35"),
(0x41, 0xd05) => ("ARM", "Cortex-A55"),
(0x41, 0xd07) => ("ARM", "Cortex-A57"),
(0x41, 0xd08) => ("ARM", "Cortex-A72"),
(0x41, 0xd09) => ("ARM", "Cortex-A73"),
(0x41, 0xd0a) => ("ARM", "Cortex-A75"),
(0x41, 0xd0b) => ("ARM", "Cortex-A76"),
(0x41, 0xd0c) => ("ARM", "Neoverse-N1"),
(0x41, 0xd0d) => ("ARM", "Cortex-A77"),
(0x41, 0xd40) => ("ARM", "Neoverse-V1"),
(0x41, 0xd41) => ("ARM", "Cortex-A78"),
(0x41, 0xd44) => ("ARM", "Cortex-X1"),
(0x41, 0xd46) => ("ARM", "Cortex-A510"),
(0x41, 0xd47) => ("ARM", "Cortex-A710"),
(0x41, 0xd48) => ("ARM", "Cortex-X2"),
(0x41, 0xd49) => ("ARM", "Neoverse-N2"),
(0x41, 0xd4d) => ("ARM", "Cortex-A715"),
(0x41, 0xd4e) => ("ARM", "Cortex-X3"),
(0x41, 0xd4f) => ("ARM", "Neoverse-V2"),
(0x41, 0xd80) => ("ARM", "Cortex-A520"),
(0x41, 0xd81) => ("ARM", "Cortex-A720"),
(0x41, 0xd82) => ("ARM", "Cortex-X4"),
(0x41, 0xd84) => ("ARM", "Neoverse-V3"),
(0x41, 0xd8e) => ("ARM", "Neoverse-N3"),
(0x41, _) => ("ARM", ""),
(0x42, _) => ("Broadcom", ""),
(0x43, _) => ("Cavium", ""),
(0x46, _) => ("Fujitsu", ""),
(0x48, _) => ("HiSilicon", ""),
(0x4e, _) => ("NVIDIA", ""),
(0x50, _) => ("APM", ""),
(0x51, _) => ("Qualcomm", ""),
(0x53, _) => ("Samsung", ""),
(0x61, _) => ("Apple", ""),
(0xc0, _) => ("Ampere", ""),
_ => ("ARM-compatible", ""),
};
if core.is_empty() {
format!("{vendor} (CPU part {part:#05x})")
} else {
format!("{vendor} {core}")
}
}
fn read(path: &str) -> Option<String> {
std::fs::read_to_string(path).ok()
}
@@ -91,7 +158,7 @@ fn os_version() -> Option<String> {
fn cpu() -> Option<String> {
if cfg!(target_os = "linux") {
read("/proc/cpuinfo").and_then(|t| field(&t, "model name"))
read("/proc/cpuinfo").and_then(|t| linux_cpu_name(&t))
} else if cfg!(target_os = "macos") {
command_output("sysctl", &["-n", "machdep.cpu.brand_string"])
} else if cfg!(windows) {
@@ -608,6 +675,35 @@ pub fn gpu_vendor(name: &str, vendor_id: u32) -> &'static str {
#[cfg(test)]
mod tests {
#[test]
fn an_arm64_kernels_cpuinfo_names_the_core_by_implementer_and_part() {
// An arm64 kernel prints no `model name` (a Neoverse-N1 and a Cortex-A72 Pi 4).
let neoverse = "processor\t: 0\nBogoMIPS\t: 50.00\nFeatures\t: fp asimd\nCPU implementer\t: 0x41\nCPU architecture: 8\nCPU variant\t: 0x3\nCPU part\t: 0xd0c\nCPU revision\t: 1\n";
assert_eq!(linux_cpu_name(neoverse).as_deref(), Some("ARM Neoverse-N1"));
let pi = "processor\t: 3\nCPU implementer\t: 0x41\nCPU part\t: 0xd08\nCPU revision\t: 3\n\nRevision\t: c03114\nModel\t\t: Raspberry Pi 4 Model B Rev 1.4\n";
assert_eq!(linux_cpu_name(pi).as_deref(), Some("ARM Cortex-A72"));
// An implementer the table has, a part it does not: the part is kept.
let apple = "CPU implementer\t: 0x61\nCPU part\t: 0x023\n";
assert_eq!(
linux_cpu_name(apple).as_deref(),
Some("Apple (CPU part 0x023)")
);
}
#[test]
fn x86_keeps_its_model_name_and_a_board_without_either_its_model() {
let ryzen = "processor\t: 0\nmodel name\t: AMD Ryzen 9 7950X 16-Core Processor\n";
assert_eq!(
linux_cpu_name(ryzen).as_deref(),
Some("AMD Ryzen 9 7950X 16-Core Processor")
);
assert_eq!(
linux_cpu_name("Model\t: Some Board\n").as_deref(),
Some("Some Board")
);
assert_eq!(linux_cpu_name("processor\t: 0\n"), None);
}
use super::*;
#[test]
+1 -1
View File
@@ -42,7 +42,7 @@ A reference page is written by code from the source it describes, and a test fai
| [server-reference.md](server-reference.md) | `horizon-server`'s help, `config::TEMPLATE`, `api::ROUTES` | `HORIZON_WRITE_DOCS=1 cargo test -p horizon-server reference` |
| [horizon-ctl-commands.md](../horizon-ctl-commands.md) | the command table in `crates/horizon-client/src/bin/horizon-ctl/commands.rs` | `horizon-ctl help --markdown > docs/horizon-ctl-commands.md` |
| [launcher-commands.md](../launcher-commands.md) | `launcher/src/cli.rs` | `HORIZON_WRITE_DOCS=1 cargo test --manifest-path launcher/Cargo.toml the_launcher_reference` |
| [launch-reference.md](launch-reference.md) | `crates/horizon-client/src/game/launch_api.rs` | `HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --bin horizon-client launch_api` |
| [launch-reference.md](launch-reference.md) | `crates/horizon-client/src/game/launch_api.rs` | `HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --lib launch_api` |
So a pull request that changes an API also changes its reference, and the reviewer sees the API's diff in the
reference's diff. The guides (overview, how-to, examples) are written by hand.
+1 -1
View File
@@ -19,7 +19,7 @@ serves the same commands as MCP tools. The launcher's MCP server offers them too
## Guide
1. Build `horizon-ctl` (`cargo build --profile fast -p horizon-client`), then
1. Build `horizon-ctl` (`cargo build --profile fast -p horizon-client-lib --bin horizon-ctl`), then
`horizon-ctl --socket $PWD/ctl.sock launch --world W --creative --wait --resources <prepared files>`.
2. `horizon-ctl --socket … state`, `keys`, `turn`, `command`, `screenshot`, `quit`.
3. For an agent: `horizon-ctl mcp`, or the launcher's `horizon-launcher mcp` with `agent-config` to connect a client
+1 -1
View File
@@ -1,4 +1,4 @@
<!-- Generated from crates/horizon-client/src/game/launch_api.rs; a test fails when this file is out of date. Update it with: HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --bin horizon-client launch_api -->
<!-- Generated from crates/horizon-client/src/game/launch_api.rs; a test fails when this file is out of date. Update it with: HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --lib launch_api -->
# Launch API reference
What `horizon-client` promises a launcher that starts it, Launch API 0.1.0. The guide: [launch.md](launch.md); stability and versions: [stability.md](stability.md).
+14 -4
View File
@@ -4,6 +4,14 @@ The 40 areas changed most often in the 30 days to 2026-10-23 (commits on main to
counted only for the game loop because nearly every gameplay change touches it). The crate map, how `Game` is
organised and how to build and test are in [AGENTS.md](../AGENTS.md).
Gameplay lives in the `horizon-client` library; `main.rs` is a thin executable calling
`entry::main`. The reusable modules, control tool and probes belong to `horizon-client-lib`,
whose library root is `crates/horizon-client/src/client_lib.rs`. Source paths stay in place.
The gameplay library re-exports the reusable API, keeping existing mod targets unchanged.
Gameplay tests run in its library test target, separately from the reusable library's tests.
There is no copy of gameplay in the executable's test target. Tools and probes depend only
on the reusable library, so gameplay edits do not invalidate their builds.
Paths: `L/` = `crates/horizon-client/src/`, `G/` = `crates/horizon-client/src/game/`, `W/` =
`crates/horizon-world/src/`, `K/` = `crates/horizon-client-core/src/`, `C/` = `crates/horizon-command/src/`,
`I/` = `crates/horizon-items/src/`, `R/` = `crates/horizon-client-render/src/`, `N/` = `crates/horizon-client-input/src/`.
@@ -12,8 +20,10 @@ Checks: a bare name is a harness (`python3 tools/parity.py <name>`, script `tool
For the full set of harnesses a file can affect, ask the runner:
`printf '%s\n' <paths> > /tmp/p.txt && python3 tools/parity.py --changed-files /tmp/p.txt --list`.
Any file of the game binary (`game.rs`, `game/`) selects the ~25 harnesses that run the binary; a client lib
file selects ~103.
Client probes are built with `-p horizon-client-lib`; the game with `-p horizon-client`.
Because the probe library does not compile gameplay, its dep-info does not include `game.rs`
or `game/`. Exact harness counts still depend on refreshed records and reachability; use the
command above to inspect the selection rather than relying on historical counts.
| # | Area (commits) | Files | Checks |
|---|---|---|---|
@@ -22,7 +32,7 @@ file selects ~103.
| 3 | World save and load (163) | `L/save.rs`, `G/saving.rs`, `G/persist.rs` (entities), `L/entity_nbt.rs`, `K/block_entity_nbt.rs`, `W/region.rs`, `W/nbt.rs` | save_roundtrip, world_reopen, world, datafix |
| 4 | World generation (153) | `W/worldgen.rs`, `W/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `placement/`), `L/vanillagen.rs` | chunk_generation, worldgen, server_world_gen, world_spawn, feature_graph, feature_placement, decoration and the other worldgen harnesses |
| 5 | Multiplayer client, packets in (145) | `G/remote.rs` (`fn handle_packet`, `fn tick_remote`), `G/remote_*.rs`, `L/net.rs` | dedicated_server, server_entities, lan_e2e (all) |
| 6 | Agent control, horizon-ctl (145) | `L/control.rs`, `L/bin/horizon-ctl/`, `G/control.rs`, `G/agent.rs`, `G/events.rs`, `G/ui_control.rs` | `python3 tools/agent_control_e2e.py`; `cargo test -p horizon-client --bin horizon-ctl` |
| 6 | Agent control, horizon-ctl (145) | `L/control.rs`, `L/bin/horizon-ctl/`, `G/control.rs`, `G/agent.rs`, `G/events.rs`, `G/ui_control.rs` | `python3 tools/agent_control_e2e.py`; `cargo test -p horizon-client-lib --bin horizon-ctl` |
| 7 | CI and release (134) | `.github/workflows/` (`ci.yml`, `parity.yml`, `release.yml`, `launcher.yml`), `tools/ci/`, `tools/parity.py`, `tools/pr_ready.py` | `python3 -m unittest discover -s tests`; actionlint (`.github/actionlint.yaml`) |
| 8 | LAN host: connections, play state, tracking (133) | `L/lan/` (`conn.rs`, `hub.rs` + `hub/`, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`), `G/lan.rs` | lan, lan_e2e (all), `cargo test -p horizon-client --test lan_bot` |
| 9 | Renderer core (132) | `R/gfx.rs` (`Renderer::new`, `Renderer::render`), `R/gfx/` (cull, occlusion, translucency, upload, timing, lod) | client_graphics, culling_rays, translucency; `horizon-client --benchmark standard` |
@@ -39,7 +49,7 @@ file selects ~103.
| 20 | Mob models and animation (78) | `L/mobs/render.rs`, `L/mobs/anim.rs`, `L/mobs/body_anim.rs`, `L/mobs/keyframes.rs`, `R/entity.wgsl` | entity_model |
| 21 | Block behaviour, server side (78) | `W/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `data.rs`, `redstone.rs`, `place.rs`), `G/block_entity_ticks.rs`, `G/moving_pistons.rs` | block_update, redstone, random_tick, simple_block, piston_movement, block_entity_tickers |
| 22 | Player movement and collision (75) | `L/motion.rs` + `L/motion/`, `R/collisions.rs`, `W/entity_move.rs`, `W/player_travel.rs`, `W/collision.rs` | collision, entity_move, player_travel, living_motion, climbing, swimming; `python3 tools/test_native_scene_motion.py` |
| 23 | Resource packs and models (65) | `R/packs.rs` + `R/packs/`, `R/pack_screen.rs`, `R/atlas_sources.rs`, `R/sprites.rs`, `R/model_library.rs`, `G/packs.rs`, `crates/horizon-assets/` | resource_pack, pack_selection, pack_screen, model_resolution, item_model, mipmap |
| 23 | Resource packs and models (65) | `R/packs.rs` + `R/packs/`, `R/pack_screen.rs`, `R/atlas_sources.rs`, `R/sprites.rs`, `R/model_library.rs`, `G/packs.rs`, `L/mobs/cem.rs`, `L/mobs/cem_anim.rs`, `crates/horizon-assets/`, `crates/horizon-optifine/` | resource_pack, pack_selection, pack_screen, model_resolution, item_model, mipmap |
| 24 | Container menus and inventory (61) | `L/menu.rs`, `L/container_screen.rs`, `R/containers.rs`, `L/creative.rs`; game.rs `fn menu_ops`, `fn click_inventory` | menu_click, container_screen, creative, lan_e2e containers clicks |
| 25 | Workstations (60) | `L/stations.rs`, `G/stations.rs`, `I/enchant.rs`, `R/brewing.rs` | enchanting, screen_layout, lan_e2e brewing loom stonecutter grindstone anvil smithing |
| 26 | Crafting and the recipe book (59) | `L/crafting.rs`, `L/recipe_book.rs`, `L/recipe_book_screen.rs`, `G/recipe_book.rs`, `G/machines.rs`, `G/machines/crafter.rs` | recipe, recipe_book, recipe_book_view, place_recipe, ghost_recipe, crafter |
Loaded 100 of 185 files, more files were not shown because too many files have changed in this diff. Show more