Merge main into shader-packs centerDepthSmooth

#6211's status and End flash uniforms landed in the same list and match:
both sides kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-01 20:15:51 +01:00
commit e5aa08936c
162 files changed
+3619 -563

No files matched your search

+1 -1
View File
@@ -150,7 +150,7 @@ jobs:
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Every program the release folder takes was built
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
+15 -8
View File
@@ -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 |
|---|---|
@@ -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) {
+11 -7
View File
@@ -4080,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);
@@ -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 {
@@ -2743,6 +2743,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",
"centerDepthSmooth",
"isRightHanded",
"is_on_ground",
@@ -439,6 +493,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)),
"centerDepthSmooth" => Value::f(&[center_depth]),
"moonPosition" => v3(moon),
"shadowLightPosition" => v3(if day { sun } else { moon }),
@@ -499,7 +554,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();
@@ -545,6 +600,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 {
@@ -563,7 +658,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,
@@ -592,6 +688,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]
);
}
}
+14 -7
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" }
@@ -104,20 +108,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;
+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),
}
}
}
}
+3 -1
View File
@@ -11208,7 +11208,7 @@ impl Game<'_> {
if landed {
if knockback > 0.0 {
let (xd, zd) = self.extra_knockback_direction();
self.mobs[index].knockback(f64::from(knockback), xd, zd, &source);
self.mobs[index].knockback_effect(f64::from(knockback), xd, zd, &source, amount);
self.stop_sprint_after_extra_knockback();
}
let mob = &self.mobs[index];
@@ -11777,6 +11777,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.
+115 -12
View File
@@ -4,6 +4,7 @@
use super::{Game, creatures::seen};
use horizon_client::{
Result,
chunk_map::{ChunkMap, ChunkMembershipDelta},
containers::{BlockEntity, CreakingHeart},
mobs::{
Mob, MobType,
@@ -302,24 +303,30 @@ impl Game<'_> {
&& self.ui.rules.bool("spawn_monsters")
}
/// Hearts that came with generated or loaded chunks get their block
/// entity (`newBlockEntity`) the first time their chunk is seen.
/// entity (`newBlockEntity`) the first time their chunk is seen lit.
/// Only the chunks that arrived since are looked at: a server holds tens
/// of thousands, and reading each one every tick cost milliseconds.
pub(super) fn scan_for_hearts(&mut self) {
let Some(heart) = self
if self
.pack
.assets
.registry
.default_state("minecraft:creaking_heart")
else {
.is_none()
{
return;
};
let keys: Vec<(i32, i32)> = self
.world
.chunks
}
self.creaking.follow_chunks(&self.world.chunks);
let mut keys: Vec<(i32, i32)> = self
.creaking
.unscanned
.iter()
.filter(|(k, c)| c.lit && !self.creaking.scanned.contains(k))
.map(|(k, _)| *k)
.copied()
.filter(|k| self.world.chunks.get(k).is_some_and(|c| c.lit))
.collect();
keys.sort_unstable();
for key in keys {
self.creaking.unscanned.remove(&key);
self.creaking.scanned.insert(key);
let Some(chunk) = self.world.chunks.get(&key).cloned() else {
continue;
@@ -334,7 +341,6 @@ impl Game<'_> {
.collect();
}
let hearts = &self.creaking.hearts;
let _ = heart;
let is_heart = |id: u32| hearts.get(id as usize).copied().unwrap_or(false);
for (si, section) in chunk.sections.iter().enumerate() {
// Only a section whose palette holds a heart state can have
@@ -359,8 +365,6 @@ impl Game<'_> {
}
}
}
let chunks = &self.world.chunks;
self.creaking.scanned.retain(|k| chunks.contains_key(k));
}
}
@@ -369,6 +373,10 @@ impl Game<'_> {
pub(super) struct CreakingState {
/// Chunks already scanned for heart blocks.
scanned: horizon_client::fast_hash::FxHashSet<(i32, i32)>,
/// Resident chunks not scanned yet (not lit yet, or just arrived).
unscanned: horizon_client::fast_hash::FxHashSet<(i32, i32)>,
/// The chunk map's membership revision the two sets follow.
revision: Option<u64>,
/// Which block states are creaking hearts, by state id.
hearts: Vec<bool>,
}
@@ -378,5 +386,100 @@ impl CreakingState {
/// (game/levels.rs).
pub(super) fn swap_level(&mut self, other: &mut Self) {
std::mem::swap(&mut self.scanned, &mut other.scanned);
std::mem::swap(&mut self.unscanned, &mut other.unscanned);
std::mem::swap(&mut self.revision, &mut other.revision);
}
/// Bring `scanned` and `unscanned` up to the chunks resident now: a
/// chunk that left is forgotten (it is scanned again if it comes back
/// later), one that arrived waits to be scanned. From the map's
/// membership changes, or from all its keys when they can't be replayed.
fn follow_chunks(&mut self, chunks: &ChunkMap) {
let revision = chunks.membership_revision();
if self.revision == Some(revision) {
return;
}
let deltas = self.revision.and_then(|r| chunks.deltas_since(r));
match deltas {
Some(deltas) => {
for (_, delta) in deltas {
match delta {
ChunkMembershipDelta::Added(key) => {
if chunks.contains_key(&key) && !self.scanned.contains(&key) {
self.unscanned.insert(key);
}
}
ChunkMembershipDelta::Removed(key) => {
if !chunks.contains_key(&key) {
self.scanned.remove(&key);
self.unscanned.remove(&key);
}
}
ChunkMembershipDelta::Reset => {}
}
}
}
None => {
self.scanned.retain(|k| chunks.contains_key(k));
self.unscanned = chunks
.keys()
.filter(|k| !self.scanned.contains(k))
.copied()
.collect();
}
}
self.revision = Some(revision);
}
}
#[cfg(test)]
mod tests {
use super::CreakingState;
use horizon_client::{chunk::Chunk, chunk_map::ChunkMap};
use std::sync::Arc;
fn add(chunks: &mut ChunkMap, key: (i32, i32)) {
chunks.insert(key, Arc::new(Chunk::empty(key.0, key.1)));
}
fn sorted(set: &horizon_client::fast_hash::FxHashSet<(i32, i32)>) -> Vec<(i32, i32)> {
let mut keys: Vec<_> = set.iter().copied().collect();
keys.sort_unstable();
keys
}
/// The heart scan follows the chunks that come and go, as the scan of
/// every resident chunk each tick did: an arrival waits, a scanned chunk
/// that stayed is not scanned again, and one that left is forgotten.
#[test]
fn the_heart_scan_follows_arrivals_and_departures() {
let mut chunks = ChunkMap::default();
let mut state = CreakingState::default();
add(&mut chunks, (0, 0));
add(&mut chunks, (1, 0));
state.follow_chunks(&chunks);
assert_eq!(sorted(&state.unscanned), [(0, 0), (1, 0)]);
// Scanned, as scan_for_hearts does once a chunk is lit.
state.unscanned.remove(&(0, 0));
state.scanned.insert((0, 0));
add(&mut chunks, (2, 0));
chunks.remove(&(1, 0));
// Gone and back between two ticks: still scanned, as before.
chunks.remove(&(0, 0));
add(&mut chunks, (0, 0));
state.follow_chunks(&chunks);
assert_eq!(sorted(&state.scanned), [(0, 0)]);
assert_eq!(sorted(&state.unscanned), [(2, 0)]);
// Gone over a tick: forgotten, and scanned again when it returns.
chunks.remove(&(0, 0));
state.follow_chunks(&chunks);
assert!(state.scanned.is_empty());
add(&mut chunks, (0, 0));
state.follow_chunks(&chunks);
assert_eq!(sorted(&state.unscanned), [(0, 0), (2, 0)]);
// A cleared map can't be replayed: rebuilt from its keys.
chunks.clear();
add(&mut chunks, (5, 5));
state.follow_chunks(&chunks);
assert_eq!(sorted(&state.unscanned), [(5, 5)]);
assert!(state.scanned.is_empty());
}
}
+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);
}
}
}
+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");
+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:
+65 -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
@@ -5430,8 +5472,29 @@ mod tests {
}
#[test]
fn loot_table_load_stays_hookable() {
fn native_loot_kinds_stay_hookable() {
let source = include_str!("../../../horizon-world/src/loot.rs");
for hook in [
"pub fn native_loot_function(",
"pub fn native_loot_condition(",
] {
let at = source
.find(hook)
.unwrap_or_else(|| panic!("{hook} not found"));
let start = source[..at].rfind("///").unwrap();
let body = &source[start..start + source[start..].find("\n}\n").unwrap()];
for line in body.lines() {
assert!(!line.contains("cfg"), "a cfg in a loot hook point: {line}");
for mac in ["!(", "![", "! {", "!{"] {
assert!(!line.contains(mac), "a macro in a loot hook point: {line}");
}
}
}
}
#[test]
fn loot_table_load_stays_hookable() {
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, "");
}
+83 -14
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);
@@ -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() {
+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,
}
}
+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();
}
+20 -1
View File
@@ -2666,7 +2666,9 @@ impl Mob {
if self.species() == Species::SulfurCube
&& !sulfur_cube::accepts_damage(self, &source, amount)
{
return false;
// `SulfurCube.hurtServer` returns true for the blows it shrugs off and only knocks
// about, so `Player.attack` goes on with its extra knockback and its aftermath.
return sulfur_cube::immune_to(&source);
}
// LivingEntity.isInvulnerableTo: only #bypasses_invulnerability gets past it.
if !self.alive()
@@ -2967,6 +2969,23 @@ impl Mob {
/// took its share, before the motion changes and the draws of a random
/// direction.
pub fn knockback(&mut self, power: f64, xd: f64, zd: f64, source: &Damage) {
self.knockback_by(power, xd, zd, source, None);
}
/// `LivingEntity.knockback(power, xd, zd, source, damage, comesFromEffect = true)`: an extra
/// push of a blow (sprint, the Knockback enchantment). A sulfur cube with a block turns it
/// into its own launch (`SulfurCube.knockback`), scaled by `power * 0.25` and `sqrt(damage)`.
pub fn knockback_effect(&mut self, power: f64, xd: f64, zd: f64, source: &Damage, damage: f32) {
self.knockback_by(power, xd, zd, source, Some(damage));
}
fn knockback_by(&mut self, power: f64, xd: f64, zd: f64, source: &Damage, effect: Option<f32>) {
if let Some(damage) = effect
&& self.species() == Species::SulfurCube
&& sulfur_cube::has_body(self)
&& source.attacker.is_some()
&& sulfur_cube::knockback(self, (xd, zd), source.from, damage, Some(power))
{
return;
}
if creaking::is_creaking(self) && !creaking::can_move(self) {
return;
}
+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();
+69 -22
View File
@@ -389,7 +389,7 @@ mod immune_tests {
}
/// `#sulfur_cube_with_block_immune_to` has these too: a cube with a block
/// takes none of them, a cube without one does.
/// loses no health to any of them (and `hurtServer` still answers true), a cube without one does.
#[test]
fn a_cube_with_a_block_shrugs_off_the_whole_tag() {
for kind in [
@@ -404,7 +404,8 @@ mod immune_tests {
] {
let mut m = cube(Some("minecraft:dirt"));
let health = m.health;
assert!(!m.damage(1.0, Damage::of(kind)), "{kind:?}");
// `SulfurCube.hurtServer` returns true for these, though no health is lost.
assert!(m.damage(1.0, Damage::of(kind)), "{kind:?}");
assert_eq!(m.health, health, "{kind:?}");
let mut m = cube(None);
assert!(m.damage(1.0, Damage::of(kind)), "{kind:?}");
@@ -412,6 +413,37 @@ mod immune_tests {
}
}
/// `SulfurCube.knockback` with `comesFromEffect`: a sprint hit's extra push (power 0.5) is
/// `0.5 * 0.25` of the plain push, a Knockback II hit's (power 1.0) a quarter of it.
#[test]
fn an_effect_push_is_a_fraction_of_the_blows_own() {
let push = |scale: f32| {
launch(
(1.0, 0.5),
0.0,
4.0,
(1.0, 0.0),
[-2.0, 0.0, 0.0],
[0.0, 0.0, 0.0],
[0.0, 0.5, 0.0],
1.0,
None,
scale,
)
};
let plain = push(1.0);
assert!(plain[0].abs() > 0.01, "the plain push moves it: {plain:?}");
for (power, scale) in [(0.5_f32, 0.125_f64), (1.0, 0.25)] {
let extra = push(power * 0.25);
for axis in 0..3 {
assert!(
(extra[axis] - plain[axis] * scale).abs() < 1.0e-6,
"{power}: {extra:?} against {plain:?}"
);
}
}
}
/// outside the tag (`magic`) a cube with a block is hurt.
#[test]
fn a_cube_with_a_block_takes_magic() {
@@ -518,21 +550,10 @@ pub fn prime(m: &mut Mob, imminent: bool) -> bool {
m.extra.scratch[1] |= 1;
true
}
/// `hurtServer`: fire or `#is_explosion` (a firework rocket's burst too)
/// light an explosive cube; with a block it shrugs off blows, falls and
/// explosions and is only knocked about.
pub fn accepts_damage(m: &mut Mob, source: &Damage, amount: f32) -> bool {
if !has_body(m) {
return true;
}
if can_explode(m) && !primed(m) {
if source.is_fire() {
prime(m, false);
} else if matches!(source.kind, DamageKind::Explosion | DamageKind::Fireworks) {
prime(m, true);
}
}
let immune = matches!(
/// `#sulfur_cube_with_block_immune_to`: the damage a cube with a block shrugs off and is only
/// knocked by.
pub fn immune_to(source: &Damage) -> bool {
matches!(
source.kind,
DamageKind::PlayerAttack
| DamageKind::MaceSmash
@@ -556,8 +577,23 @@ pub fn accepts_damage(m: &mut Mob, source: &Damage, amount: f32) -> bool {
| DamageKind::Stalagmite
| DamageKind::SulfurCubeHot
| DamageKind::WindCharge
);
if immune {
)
}
/// `hurtServer`: fire or `#is_explosion` (a firework rocket's burst too)
/// light an explosive cube; with a block it shrugs off blows, falls and
/// explosions and is only knocked about.
pub fn accepts_damage(m: &mut Mob, source: &Damage, amount: f32) -> bool {
if !has_body(m) {
return true;
}
if can_explode(m) && !primed(m) {
if source.is_fire() {
prime(m, false);
} else if matches!(source.kind, DamageKind::Explosion | DamageKind::Fireworks) {
prime(m, true);
}
}
if immune_to(source) {
// `dealDefaultKnockback`, unless the damage type is tagged no_knockback.
if !source.no_knockback() {
deal_default_knockback(m, source, amount);
@@ -658,6 +694,7 @@ fn launch(
center: [f64; 3],
height: f32,
aim: Option<([f64; 3], [f64; 3])>,
effect_scale: f32,
) -> [f64; 3] {
let (h0, v0) = powers;
let (mut xd, mut zd) = (xd as f32, zd as f32);
@@ -682,7 +719,8 @@ fn launch(
h *= 1.0 / max;
v *= 1.0 / max;
}
let k = damage.sqrt();
// `Mth.sqrt(damage) * (comesFromEffect ? power * 0.25 : 1)`.
let k = damage.sqrt() * effect_scale;
h *= k;
v *= k;
h *= 1.0 - resistance;
@@ -711,14 +749,20 @@ fn deal_default_knockback(m: &mut Mob, source: &Damage, damage: f32) {
.map_or((0.0, 0.0), |f| (f[0] - m.position.x, f[2] - m.position.z)),
};
if source.attacker.is_some() {
knockback(m, (xd, zd), source.from, damage);
knockback(m, (xd, zd), source.from, damage, None);
} else {
m.knockback(f64::from(0.4_f32), xd, zd, source);
}
}
/// `SulfurCube.knockback` with a block and a causing entity: the archetype's powers, turned by
/// where the blow came from and by the entity's aim (`Mob::attacker_aim`, from the game).
pub fn knockback(m: &mut Mob, direction: (f64, f64), from: Option<[f64; 3]>, damage: f32) -> bool {
pub fn knockback(
m: &mut Mob,
direction: (f64, f64),
from: Option<[f64; 3]>,
damage: f32,
effect_power: Option<f64>,
) -> bool {
let Some(a) = archetype(m) else {
return false;
};
@@ -734,6 +778,7 @@ pub fn knockback(m: &mut Mob, direction: (f64, f64), from: Option<[f64; 3]>, dam
m.center(),
m.height(),
aim.map(|a| (a.eye, a.look)),
effect_power.map_or(1.0, |p| p as f32 * 0.25),
);
m.velocity = Vec3::new(
m.velocity.x + delta[0],
@@ -1295,6 +1340,7 @@ mod launch_tests {
center,
height,
c.aim,
1.0,
);
for axis in 0..3 {
assert!(
@@ -1332,6 +1378,7 @@ mod resistance_tests {
[1.0, 64.5, 0.0],
1.0,
None,
1.0,
)
};
let base = push(0.0, 4.0);
+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,
+1
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::{
@@ -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()
);
}
}
+47
View File
@@ -1551,6 +1551,31 @@ impl LootContext<'_> {
// does not have: held true (a double plant's other half is taken
// as there). Unknown types fail, as in loot.rs.
"minecraft:location_check" => true,
// A mod's own kind: the condition hook answers. This engine
// (block drops, entity drops, loot_table, interact_loot) has
// no LootStack, so the function hook isn't here yet.
k if !c.is_null() && k.contains(':') && !k.starts_with("minecraft:") => {
let ctx = horizon_world::loot::NativeLootContext {
entity: None,
damage: None,
player: None,
block: (!self.block.is_empty()).then_some(self.block),
tool: self.held.map(|h| horizon_world::loot::NativeLootTool {
item: h.item.to_string(),
enchantments: h
.enchantments()
.iter()
.map(|(id, lvl)| (id.to_string(), *lvl as i32))
.collect(),
}),
luck: 0.0,
biome: None,
seed: random.next_long(),
};
let mut holds = false;
horizon_world::loot::native_loot_condition(k, c, &ctx, &mut holds);
holds
}
_ => false,
}
}
@@ -2111,6 +2136,28 @@ mod tests {
}
}
/// A mod's kind nobody claims fails; a reference by name isn't a kind,
/// and fails too.
#[test]
fn an_unclaimed_condition_of_a_mods_kind_fails() {
let items = Items::for_tests(HashMap::new());
let props = horizon_world::chunk::BlockProperties::new();
let table = |condition: Value| {
json!({"pools":[{"rolls":1,"entries":[
{"type":"minecraft:item","name":"minecraft:stone","condition":condition}]}]})
};
let mut random = LegacyRandom::new(7);
let mut drops = |condition: Value, block: &str| {
ctx(&items, None, &props, block)
.evaluate(&table(condition), &mut random)
.len()
};
assert_eq!(drops(json!({"type":"mymod:always"}), "minecraft:stone"), 0);
assert_eq!(drops(json!({"type":"mymod:always"}), ""), 0);
assert_eq!(drops(json!("mymod:always"), "minecraft:stone"), 0);
assert_eq!(drops(Value::Null, "minecraft:stone"), 1);
}
#[test]
fn fortune_silk_touch_and_looting() {
let items = Items::for_tests(HashMap::new());
+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]
+184
View File
@@ -60,6 +60,70 @@ impl PackOverlay<'_> {
}
}
/// A loot function of a mod's own kind: the entry named a `type` in a
/// namespace outside `minecraft:`, and the roll kept it for this call.
/// The kind, the JSON, the context and the stack to change are here.
/// API @API:loot_kinds@.
#[inline(never)]
pub fn native_loot_function(
kind: &str,
data: &Value,
ctx: &NativeLootContext<'_>,
stack: &mut LootStack,
) {
let _ = (kind, data, ctx, stack);
}
/// A loot condition of a mod's own kind: the entry named a `type` in a
/// namespace outside `minecraft:`. The kind, the JSON, the context and
/// whether it holds are here (`holds` starts `false`; a mod that claims
/// the kind sets it). API @API:loot_kinds@.
#[inline(never)]
pub fn native_loot_condition(
kind: &str,
data: &Value,
ctx: &NativeLootContext<'_>,
holds: &mut bool,
) {
let _ = (kind, data, ctx, holds);
}
/// The tool a loot context names: its item id and enchantments.
pub struct NativeLootTool {
pub item: String,
pub enchantments: Vec<(String, i32)>,
}
/// What a mod's own loot kinds see of the roll: the entity, the damage,
/// the player who killed it, the tool, the block, the luck, the biome,
/// and a seed from the roll's random. The tool and block are filled by
/// the items engine (block drops, entity drops, loot_table, interact_loot);
/// the entity, damage and player by the main loot engine; each is None in
/// the other. A table without a mod's kinds draws nothing and stays
/// bit-for-bit the same.
pub struct NativeLootContext<'a> {
/// The entity the table names (`this`), if any.
pub entity: Option<&'a LootEntity>,
/// The damage that killed it, if any.
pub damage: Option<&'a LootDamage>,
/// The player whose kill dropped the table (`LootEntity`), if any.
pub player: Option<&'a LootEntity>,
/// The tool used, if any. The items engine fills it (all of its
/// tables: block drops, entity drops, loot_table, interact_loot);
/// the main loot engine has no tool in its context.
pub tool: Option<NativeLootTool>,
/// The block a block-drop table breaks, if any. The items engine
/// fills it; the main loot engine has no block in its context.
pub block: Option<&'a str>,
/// `LootContext.getLuck`.
pub luck: f32,
/// The biome the roll is in, if any.
pub biome: Option<&'a str>,
/// A seed from the roll's random, for a mod's own draws. Drawn only
/// when a mod's kind is in the table.
pub seed: i64,
}
/// One generated item stack.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LootStack {
@@ -1255,6 +1319,13 @@ impl<R: RandomSource> Context<'_, R> {
_ => false,
}
}
// A mod's own kind: the hook answers; a bare type name is
// minecraft's own and an unknown one fails.
k if k.contains(':') && !k.starts_with("minecraft:") => {
let mut holds = false;
native_loot_condition(k, c, &self.native_ctx(), &mut holds);
holds
}
// An unknown type fails, as in items.rs `condition`.
_ => false,
},
@@ -1280,6 +1351,21 @@ impl<R: RandomSource> Context<'_, R> {
_ => stack,
}
}
/// The context a mod's own loot kinds see: drawn lazily, only when
/// the table has a mod's kind, so the random stays untouched without.
fn native_ctx(&mut self) -> NativeLootContext<'_> {
let seed = self.random.next_long();
NativeLootContext {
entity: self.entity.map(|e| &e.this),
damage: self.entity.and_then(|e| e.damage.as_ref()),
player: self.entity.and_then(|e| e.player.as_ref()),
tool: None, // The tool is not in this engine's context.
block: None,
luck: self.luck,
biome: self.biome,
seed,
}
}
fn run(&mut self, f: &Value, mut stack: LootStack) -> LootStack {
let data = self.data;
match f["type"].as_str().unwrap_or("") {
@@ -1487,6 +1573,10 @@ impl<R: RandomSource> Context<'_, R> {
stack.components.push((id.clone(), value.clone()));
}
}
// A mod's own kind: the hook changes the stack.
k if k.contains(':') && !k.starts_with("minecraft:") => {
native_loot_function(k, f, &self.native_ctx(), &mut stack);
}
// Lore and other data without random draws.
_ => {}
}
@@ -1963,6 +2053,100 @@ mod namespace_tests {
}
}
#[cfg(test)]
mod native_kinds_tests {
use super::*;
fn table(name: &str, json: &str) -> LootData {
let root = std::env::temp_dir().join(format!("loot-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let dir = root.join("data/minecraft/loot_table");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("test.json"), json).unwrap();
// LootData::load reads items.json too.
std::fs::write(root.join("items.json"), "{}").unwrap();
let data = LootData::load(&root, &PackOverlay::NONE).unwrap();
let _ = std::fs::remove_dir_all(&root);
data
}
/// An unclaimed condition fails (the pool drops nothing) and an
/// unclaimed function leaves the stack's count as it was.
#[test]
fn unclaimed_kinds_fail_the_condition_and_leave_the_stack() {
let data = table(
"cond",
r#"{"pools":[{"rolls":1,"condition":{"type":"mymod:always_true"},
"entries":[{"type":"minecraft:item","name":"minecraft:stone"}]}]}"#,
);
let mut random = horizon_core::random::LegacyRandom::new(42);
let items = data.roll("minecraft:test", &mut random, 0.0, None, None);
assert!(items.is_empty());
let data = table(
"func",
r#"{"pools":[{"rolls":1,"entries":[{"type":"minecraft:item","name":"minecraft:stone"}],
"modifier":[{"type":"mymod:duplicate","factor":2}]}]}"#,
);
let mut random = horizon_core::random::LegacyRandom::new(42);
let items = data.roll("minecraft:test", &mut random, 0.0, None, None);
assert_eq!(items.len(), 1);
assert_eq!(items[0].count, 1);
}
/// A table without a mod's kind doesn't shift the random; one with a
/// kind draws exactly one `next_long`; the same seed gives the same
/// result.
#[test]
fn a_mod_kind_draws_exactly_one_long_and_a_vanilla_table_none() {
// A vanilla table: the random is untouched.
let vanilla = table(
"seed_vanilla",
r#"{"pools":[{"rolls":1,"entries":[{"type":"minecraft:item","name":"minecraft:stone"}]}]}"#,
);
let mut r1 = horizon_core::random::LegacyRandom::new(100);
let mut r2 = horizon_core::random::LegacyRandom::new(100);
let _ = vanilla.roll("minecraft:test", &mut r1, 0.0, None, None);
let expected: Vec<_> = (0..5).map(|_| r2.next_long()).collect();
let got: Vec<_> = (0..5).map(|_| r1.next_long()).collect();
assert_eq!(got, expected, "vanilla table shifted the random");
// A mod kind: exactly one next_long is drawn.
let modded = table(
"seed_mod",
r#"{"pools":[{"rolls":1,"entries":[{"type":"minecraft:item","name":"minecraft:stone"}],
"modifier":[{"type":"lootmod:x"}]}]}"#,
);
let mut r3 = horizon_core::random::LegacyRandom::new(100);
let mut r4 = horizon_core::random::LegacyRandom::new(100);
let _ = modded.roll("minecraft:test", &mut r3, 0.0, None, None);
let _ = r4.next_long(); // the one draw
let after_mod: Vec<_> = (0..5).map(|_| r4.next_long()).collect();
let after_run: Vec<_> = (0..5).map(|_| r3.next_long()).collect();
assert_eq!(after_run, after_mod, "mod table drew != 1 next_long");
// Same seed, same result.
let mut r5 = horizon_core::random::LegacyRandom::new(42);
let a = modded.roll("minecraft:test", &mut r5, 0.0, None, None);
let mut r6 = horizon_core::random::LegacyRandom::new(42);
let b = modded.roll("minecraft:test", &mut r6, 0.0, None, None);
assert_eq!(a, b, "same seed, different result");
}
#[test]
fn an_unknown_minecraft_function_is_skipped() {
let data = table(
"vanilla",
r#"{"pools":[{"rolls":1,"entries":[{"type":"minecraft:item","name":"minecraft:stone"}],
"modifier":[{"type":"minecraft:future_function"},{"type":"minecraft:set_count","count":5}]}]}"#,
);
let mut random = horizon_core::random::LegacyRandom::new(42);
let items = data.roll("minecraft:test", &mut random, 0.0, None, None);
assert_eq!(items.len(), 1);
assert_eq!(items[0].count, 5);
}
}
#[cfg(test)]
mod recipe_result_tests {
use super::*;
+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).
+13 -3
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` |
+1 -1
View File
@@ -46,7 +46,7 @@ The report is the [benchmark report format](benchmark-report.md): startup to the
## Reproduce it
You need Horizon built (`cargo build --release -p horizon-client --bins`) and its resources prepared from your own copy of 26.3 (which also provides `.local/minecraft/26.3`, the vanilla client the driver starts), Java 25 with `javac`, and Python 3.10 or newer.
You need Horizon built (`cargo build --release -p horizon-client -p horizon-client-lib --bins`) and its resources prepared from your own copy of 26.3 (which also provides `.local/minecraft/26.3`, the vanilla client the driver starts), Java 25 with `javac`, and Python 3.10 or newer.
```sh
python3 tools/perf_bench.py --game horizon --label horizon-mine
+1 -1
View File
@@ -83,7 +83,7 @@ Not implemented: anisotropic filtering (wgpu needs linear magnification for it,
`tools/graphics_bench.py` opens a fresh creative world (seed 1234) on a virtual screen and flies the camera along four fixed 192-block paths, over a village, a forest, the ocean and a mountain, at render distances 8, 16 and 32, one game per distance. Each flight takes 12 seconds and reports average FPS, 1% lows, frame-time percentiles, CPU time per frame (frame time minus waits for the GPU, the display and the limiter), the split of that CPU time (world update, ticks, frame preparation, renderer) for all frames and for the slowest 1%, and GPU time per frame from timestamp queries.
```sh
cargo build --release -p horizon-client --bins
cargo build --release -p horizon-client -p horizon-client-lib --bins
python3 tools/graphics_bench.py --label mine # lavapipe on vscreen: CPU-side numbers
python3 tools/graphics_bench.py --label mine-gpu --offscreen # the desktop's RTX 4080
python3 tools/graphics_bench.py --compare baseline-4080 mine-gpu # markdown table
+7
View File
@@ -85,6 +85,13 @@ A block names its template and what differs from it:
"item": true }
```
`random_ticks` makes a block tick randomly whatever its template does, as vanilla's
`isRandomlyTicking(state)` does for its own blocks, so a crop, sapling or soil on a template that never
ticks still grows: `"random_ticks": true` for every state (or `false`, to stop a template that ticks), or
`{ "property": "grown", "values": ["false"] }` for the states whose own state property (declared in
`state_properties`) is one of the values. The block's native `native_random_tick` hook then fires as it does
for a vanilla block that ticks, and its sections count it.
A block copies its template's states. It can also declare state properties of
its own, which its template does not have — how a pipe says which sides it
connects to, or a machine whether it is running. Its states then become its
+1 -1
View File
@@ -119,7 +119,7 @@ local socket; under control, losing window focus does not pause the game or
release held keys, so a script can play while you use other windows.
```sh
cargo build --release -p horizon-client --bins
cargo build --release -p horizon-client -p horizon-client-lib --bins
C=target/release/horizon-ctl
$C launch --world Survival --seed 5 --wait # start the game, open or create the world, wait for it
$C state # player, world, screen, crosshair target
+22 -4
View File
@@ -415,6 +415,21 @@ dropped it silently — reaches `native_consume_effect(kind, data)` when the ite
thread, for the acting player. The kind and the JSON the file wrote are there; the game's own effects,
sounds, teleports and drops are open to the hook. An unclaimed kind does nothing.
### A mod's own loot functions and conditions
A loot table's function or condition that names a `type` in a namespace outside `minecraft:` — where the
loot roll used to silently skip it — reaches `native_loot_function(kind, data, ctx, stack)` or
`native_loot_condition(kind, data, ctx, holds)` when the table rolls. The function changes the stack; the
condition says whether it holds. The context brings the entity, the damage, the player who killed it, the
tool, the block, the luck, the biome and a seed from the roll's random. An unclaimed function leaves the
stack, an unclaimed condition fails, as unknown kinds do; a bare type name is minecraft's own.
The tables the items engine rolls (block drops, entity drops, `loot_table`, `interact_loot` and the
built-in tables) answer the condition hook, with the tool and the block in the context; the function hook
there follows when the stack types converge. In the items engine's context the `entity`, `damage`, `player`
and `biome` fields are `None` and `luck` is 0; in the main loot engine's context the `tool` and `block`
fields are `None`.
### Data files
A bundle's `data/` folder (any files) is staged as `native-data/<mod>/` at the root of the workspace. A helper or
@@ -765,9 +780,11 @@ Every path the build's tools see — the toolchain, the Cargo home, `native/` an
TMP and TEMP, where gcc, the assembler, `ld` and `cargo install` put their scratch files) — comes from
`backend::build_dir`: the launcher's data folder, resolved (never through a link, which horizon-mixins
refuses). On Windows, when that path is not spelled in ASCII — a Cyrillic user name in `%APPDATA%`, which
MinGW-w64's tools can't open — it is the folder's own short (8.3) name where the volume keeps one, else a
folder under `ProgramData` named for it (`horizon-launcher-<id>`); a data folder that is itself a junction to
a drive with an ASCII path resolves to that and needs neither.
MinGW-w64's tools can't open — it is a real folder under `ProgramData` named for it (`horizon-launcher-<id>`),
and only when that can't be created the data folder's short (8.3) name. A short name alone is not enough
(#6009): rustc canonicalizes its own path to find its sysroot, which spells the long name again for the
linker. A data folder that is itself a junction to a drive with an ASCII path resolves to that and needs
neither.
## Install from GitHub
@@ -954,7 +971,8 @@ For mods that run where the world is simulated: a dedicated server, a LAN host,
- **`native_perform_console_command(command, ran)`**: a registered command typed on the dedicated server's console
(without its slash), run as the server at level 4: no player, so `native_acting_player()` is None and the player-only
helpers give nothing. The mod parses the line and sets `ran`; `native_feedback` answers on the console. A name no mod
registered goes to vanilla's dispatcher as before; the single player owner's console is not covered.
registered goes to vanilla's dispatcher as before. The single player owner's console (`horizon-ctl command`, the MCP
command tool) is a player's: it reaches `native_perform_command` with the owner, at level 4.
- **`native_player_respawned(player, name)`**: a dead player came back, once per respawn, on a server: after
`PlayerList.respawn` reset their health, food and experience and placed them at their bed, anchor or the world spawn
(NeoForge's `PlayerRespawnEvent`). The player's state is swapped in for the call, as for their other actions, so
+1 -1
View File
@@ -3,7 +3,7 @@
Run with the pinned prepared client resources:
```sh
CARGO_BUILD_JOBS=6 cargo run --profile fast -p horizon-client --example portal_scan_bench -- \
CARGO_BUILD_JOBS=6 cargo run --profile fast -p horizon-client-lib --example portal_scan_bench -- \
.local/horizon-client/26.3
```
+1 -1
View File
@@ -5,7 +5,7 @@ Run with the pinned prepared client resources:
```sh
flock /tmp/horizon-optimizations-20261015-heavy.lock env CARGO_BUILD_JOBS=2 \
CARGO_TARGET_DIR=/home/bob/Documents/horizon/target \
cargo run --profile fast -p horizon-client --example random_tick_eligibility_bench -- \
cargo run --profile fast -p horizon-client-lib --example random_tick_eligibility_bench -- \
.local/horizon-client/26.3
```
+1 -1
View File
@@ -19,7 +19,7 @@ From this worktree, with no other benchmark running:
```sh
export CARGO_INCREMENTAL=0
export CARGO_TARGET_DIR=/home/bob/Documents/horizon/target
cargo build --profile fast -p horizon-client --example save_batch_bench
cargo build --profile fast -p horizon-client-lib --example save_batch_bench
taskset -c 2 "$CARGO_TARGET_DIR/fast/examples/save_batch_bench"
cargo test --profile fast -p horizon-client --lib save::tests
cargo clippy --profile fast -p horizon-client --all-targets -- -D warnings
+1 -1
View File
@@ -14,7 +14,7 @@ Use real prepared resources; no game files belong in Git. Build the baseline
at `89b58991bbc02eb79b6965fc4ca56357fe2297d9` with the benchmark example copied in
and `ItemRenderer::load(root)` in its item stage. Save that binary, then build
this branch with `ItemRenderer::load_with_library(root, assets.model_library.clone())`.
Both builds use `cargo build --locked --profile fast -p horizon-client --example
Both builds use `cargo build --locked --profile fast -p horizon-client-lib --example
model_library_bench` with `HORIZON_BENCH_COUNT_ALLOCATIONS` unset (`opt-level=3`, LTO disabled, 16 codegen units). Set
`CARGO_BUILD_JOBS=2`. On the optimization machine, hold
`/tmp/horizon-optimizations-20261015-heavy.lock` for each entire build or paired
+9
View File
@@ -266,6 +266,15 @@ minimize, maximize/restore and close; macOS and Linux keep their system title ba
The launcher looks for a newer Nightly when it starts and every 30 minutes while it is open, and
shows a small "available, Install" line on Play.
Release lists and commits come from GitHub's API, which allows 60 anonymous calls an hour per
address: players behind a shared one (a school, a dorm, a carrier's NAT) spend them together.
So each answer is kept in `cache/github/` in the data folder and used again for 5 minutes without
asking, then revalidated with its ETag. When GitHub answers with its rate limit, or not at all,
Horizon's own releases are read through Kira's read-only mirror
(`https://mcp.playhorizon.dev/github/public`; `HORIZON_GITHUB_MIRROR` replaces it, empty turns it
off); when both fail the last saved answer is used, else the error names both. The release files
themselves come from their download links, which that limit doesn't cover.
It also updates itself from GitHub releases tagged `[email protected]` (the game's are `nightly`, and
`vX.Y.Z` before 2026-10-28; neither is ever taken for the other). Before 0.2 they were `launcher-vX.Y.Z` and `vYYYY.MM.DD`:
every 0.2 tag counts as newer than all of those (docs/versioning.md). It downloads the new build, replaces its
+59 -16
View File
@@ -141,7 +141,7 @@ pub(crate) const CLIENT: &str = if cfg!(windows) {
/// The agent `url` is fetched with: the launcher's own user agent, through the player's proxy when
/// one is set (`proxy`).
fn agent(url: &str) -> ureq::Agent {
pub(crate) fn agent(url: &str) -> ureq::Agent {
crate::proxy::agent(
ureq::AgentBuilder::new()
.user_agent(&format!("horizon-launcher/{VERSION}"))
@@ -175,7 +175,10 @@ pub(crate) fn get(url: &str) -> Result<ureq::Response, String> {
}
}
request.call().map_err(|e| match e {
ureq::Error::Status(403, _) => {
ureq::Error::Status(code, r)
if url.starts_with("https://api.github.com/")
&& crate::github::rate_limited(code, |h| r.header(h).map(str::to_owned)) =>
{
"GitHub's rate limit was reached. Try again in a few minutes.".into()
}
ureq::Error::Status(code, _) => format!("{url} answered {code}"),
@@ -311,10 +314,12 @@ pub fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
release_order(a).cmp(&release_order(b))
}
/// Every published release of the repository, both kinds, as GitHub's JSON.
/// Every published release of the repository, both kinds, as GitHub's JSON (kept on disk with its
/// ETag, and read through Kira's mirror past GitHub's rate limit: [`crate::github`]).
fn release_list() -> Result<serde_json::Value, String> {
let url = format!("https://api.github.com/repos/{REPO}/releases?per_page=100");
get(&url)?.into_json().map_err(|e| e.to_string())
crate::github::json(&format!(
"https://api.github.com/repos/{REPO}/releases?per_page=100"
))
}
/// The releases in `list` whose tag passes `keep`, newest first, with the asset named `asset`.
@@ -391,9 +396,7 @@ pub(crate) fn game_releases(list: &serde_json::Value) -> Vec<Release> {
/// `launcher@` and `launcher-v` tags are looked up one by one (a draft's tag is skipped: it has no
/// public release).
fn launcher_releases_in(list: &serde_json::Value) -> Vec<Release> {
launcher_releases_in_with(list, |url| {
get(&url)?.into_json().map_err(|e| e.to_string())
})
launcher_releases_in_with(list, |url| crate::github::json(&url))
}
fn launcher_releases_in_with(
@@ -572,17 +575,30 @@ pub fn build_dir() -> PathBuf {
real
}
/// [`build_dir`] when `real` isn't [`ascii`]: the folder's own short (8.3) name where the
/// volume keeps one — the same files, spelled in ASCII — else a folder of ProgramData's,
/// named for the path so each user's is its own and can be created there.
/// [`build_dir`] when `real` isn't [`ascii`]: [`ascii_build_dir`], a real folder of
/// ProgramData's. Only when it can't be created, the data folder's short (8.3) name.
///
/// The short name came first until #6009, and wasn't enough: it is only another spelling of the
/// same folder, and rustc canonicalizes its own path to find its sysroot, which spells the long,
/// Cyrillic name again and hands it to the linker (`x86_64-w64-mingw32-gcc`: "No such file or
/// directory" on `C:/Users/\xca\xe8…`). A folder whose real name is ASCII stays ASCII however a
/// tool resolves it.
#[cfg(windows)]
fn windows_build_dir(real: &Path) -> PathBuf {
use sha2::{Digest, Sha256};
if let Some(short) = short_path(real).filter(|p| ascii(p)) {
return short;
let dir = ascii_build_dir(real, std::env::var_os("ProgramData").map(PathBuf::from));
if fs::create_dir_all(&dir).is_ok() {
return dir;
}
let public = std::env::var_os("ProgramData")
.map(PathBuf::from)
short_path(real).filter(|p| ascii(p)).unwrap_or(dir)
}
/// The build folder for a data folder `real` whose path isn't [`ascii`]: in `program_data`
/// (`%ProgramData%`, `C:\ProgramData` when unset, relative or not ASCII), which every user may
/// create folders in, named for `real` so each user's is their own.
#[cfg_attr(not(windows), allow(dead_code))]
fn ascii_build_dir(real: &Path, program_data: Option<PathBuf>) -> PathBuf {
use sha2::{Digest, Sha256};
let public = program_data
.filter(|p| p.is_absolute() && ascii(p))
.unwrap_or_else(|| PathBuf::from(r"C:\ProgramData"));
let key = Sha256::digest(real.as_os_str().as_encoded_bytes());
@@ -3422,6 +3438,33 @@ mod tests {
}
}
/// [#6009](https://github.com/horizon-rust/horizon/issues/6009): with a Cyrillic user name the
/// build folder is a real ASCII folder of ProgramData's (not a short name of the data folder,
/// which rustc resolves back to the Cyrillic one), its own for each data folder.
#[test]
fn a_cyrillic_data_folder_builds_in_an_ascii_folder_of_its_own() {
let pavlo = Path::new(r"C:\Users\Кириченко Павло\AppData\Roaming\horizon-launcher");
let other = Path::new(r"C:\Users\Олена\AppData\Roaming\horizon-launcher");
let public = PathBuf::from(if cfg!(windows) { r"D:\Data" } else { "/data" });
let dir = ascii_build_dir(pavlo, Some(public.clone()));
assert!(ascii(&dir), "{}", dir.display());
assert_eq!(dir.parent(), Some(public.as_path()));
let name = dir.file_name().unwrap().to_str().unwrap();
assert!(
name.starts_with("horizon-launcher-") && name.len() == 25,
"{name}"
);
assert_eq!(dir, ascii_build_dir(pavlo, Some(public.clone())), "stable");
assert_ne!(dir, ascii_build_dir(other, Some(public)), "one per user");
// %ProgramData% unset, relative or itself not ASCII: C:\ProgramData.
let default = PathBuf::from(r"C:\ProgramData");
for pd in [None, Some("ProgramData".into()), Some(r"C:\Дані".into())] {
let dir = ascii_build_dir(pavlo, pd);
assert_eq!(dir.parent(), Some(default.as_path()));
assert!(ascii(&dir));
}
}
/// The game program's mod API level is its mark's; a program from before the mark (0.2.68
/// and older) or none at all serves level 0.
#[test]
+234
View File
@@ -0,0 +1,234 @@
//! GitHub's REST API, as the launcher reads it: release lists, releases by tag, commits.
//!
//! Without a token GitHub allows 60 API calls an hour per address, and players behind a shared one
//! (a school, a dorm, a carrier's NAT) spend them together. So:
//! - every answer is kept on disk (`<data>/cache/github/`) and read again without asking for
//! [`FRESH_SECS`]: a launcher started a few times in a row, or several pages asking for the same
//! list, spend one call. After that the read is a conditional request with the saved ETag. An
//! anonymous 304 still counts against the hour (measured 2026-11-01), so that only saves the
//! download; with `HORIZON_GITHUB_TOKEN` (CI) a 304 is free;
//! - when GitHub answers with its rate limit (or not at all), Horizon's own repository is read
//! through Kira's public mirror ([`MIRROR`], kira's `src/launcher-github.js`), which caches
//! GitHub's answers for everyone;
//! - when both fail, the last answer kept on disk is used, else the error says what happened.
//!
//! Release files are not read here: their `browser_download_url` (github.com, then
//! release-assets.githubusercontent.com) is not part of the API and has no such limit.
use crate::backend;
use sha2::Digest;
use std::fs;
use std::path::{Path, PathBuf};
const API: &str = "https://api.github.com";
/// Kira's read-only mirror of Horizon's release reads. `HORIZON_GITHUB_MIRROR` replaces it (an
/// empty value turns it off).
pub const MIRROR: &str = "https://mcp.playhorizon.dev/github/public";
/// How long a saved answer is used without asking again: the launcher looks for releases every
/// [`backend::CHECK_EVERY`], so a few minutes late at worst.
pub const FRESH_SECS: u64 = 5 * 60;
/// What one source answered.
#[derive(Debug)]
pub(crate) enum Answer {
/// 200, with the ETag GitHub gave (none from the mirror).
Fresh(serde_json::Value, Option<String>),
/// 304: the copy on disk is still current.
NotModified,
NotFound,
/// 429, or 403 with GitHub's rate-limit headers.
RateLimited,
/// Any other status, or no connection: the error to show.
Failed(String),
}
/// GitHub's JSON for `url` (`https://api.github.com/...`). A 404 is the error `<url> answered 404`.
pub fn json(url: &str) -> Result<serde_json::Value, String> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
json_with(url, &cache_dir(), now, github, mirror)
}
/// [`json`] with its cache folder, the time (Unix seconds) and its two sources given (tests).
pub(crate) fn json_with(
url: &str,
cache: &Path,
now: u64,
github: impl Fn(&str, Option<&str>) -> Answer,
mirror: impl Fn(&str) -> Answer,
) -> Result<serde_json::Value, String> {
let file = cache.join(cache_name(url));
let kept = read_kept(&file, url);
if let Some(k) = kept.as_ref() {
// A copy saved "later" than now (the clock was set back) is not trusted as fresh.
if now >= k.saved && now - k.saved < FRESH_SECS {
return Ok(k.body.clone());
}
}
let etag = kept
.as_ref()
.map(|k| k.etag.as_str())
.filter(|e| !e.is_empty());
let why = match github(url, etag) {
Answer::Fresh(body, etag) => {
write_kept(&file, url, etag.as_deref().unwrap_or(""), &body, now);
return Ok(body);
}
Answer::NotModified => match kept {
Some(k) => {
write_kept(&file, url, &k.etag, &k.body, now);
return Ok(k.body);
}
// A 304 to a request that had no ETag: can't happen, but don't trust it.
None => "GitHub answered 304 without a saved copy".to_owned(),
},
Answer::NotFound => return Err(format!("{url} answered 404")),
Answer::RateLimited => RATE_LIMITED.to_owned(),
Answer::Failed(e) => e,
};
let from_mirror = match mirror_url(url) {
Some(m) => match mirror(&m) {
Answer::Fresh(body, _) => {
write_kept(&file, url, "", &body, now);
return Ok(body);
}
Answer::NotFound => return Err(format!("{url} answered 404")),
Answer::RateLimited => Some("too many requests from this network".to_owned()),
Answer::Failed(e) => Some(e),
Answer::NotModified => Some("an unexpected 304".to_owned()),
},
None => None,
};
if let Some(k) = kept {
return Ok(k.body);
}
Err(match from_mirror {
Some(m) => format!(
"{why}, and Horizon's mirror of GitHub couldn't help either ({m}). Try again in a few minutes."
),
None => why,
})
}
const RATE_LIMITED: &str = "GitHub's rate limit was reached for your network";
/// The mirror's URL for an API URL it serves (Horizon's own repository), or None.
pub(crate) fn mirror_url(url: &str) -> Option<String> {
mirror_url_with(url, &mirror_base())
}
fn mirror_url_with(url: &str, base: &str) -> Option<String> {
let path = url.strip_prefix(API)?;
let repo = format!("/repos/{}/", backend::REPO);
(!base.is_empty() && path.starts_with(&repo))
.then(|| format!("{}{path}", base.trim_end_matches('/')))
}
fn mirror_base() -> String {
std::env::var("HORIZON_GITHUB_MIRROR").unwrap_or_else(|_| MIRROR.to_owned())
}
/// GitHub itself, with the saved ETag when there is one.
fn github(url: &str, etag: Option<&str>) -> Answer {
let mut request = backend::agent(url).get(url);
// CI runners share addresses and hit GitHub's anonymous rate limit; they pass their own token.
if let Some(token) = std::env::var("HORIZON_GITHUB_TOKEN")
.ok()
.filter(|t| !t.is_empty())
{
request = request.set("Authorization", &format!("Bearer {token}"));
}
if let Some(etag) = etag {
request = request.set("If-None-Match", etag);
}
match request.call() {
Ok(r) if r.status() == 304 => Answer::NotModified,
Ok(r) => {
let etag = r.header("ETag").map(str::to_owned);
match r.into_json() {
Ok(body) => Answer::Fresh(body, etag),
Err(e) => Answer::Failed(e.to_string()),
}
}
Err(e) => failure(url, e),
}
}
fn mirror(url: &str) -> Answer {
match backend::agent(url).get(url).call() {
Ok(r) => match r.into_json() {
Ok(body) => Answer::Fresh(body, None),
Err(e) => Answer::Failed(e.to_string()),
},
Err(e) => failure(url, e),
}
}
fn failure(url: &str, e: ureq::Error) -> Answer {
match e {
ureq::Error::Status(404, _) => Answer::NotFound,
ureq::Error::Status(code, r) if rate_limited(code, |h| r.header(h).map(str::to_owned)) => {
Answer::RateLimited
}
ureq::Error::Status(code, _) => Answer::Failed(format!("{url} answered {code}")),
ureq::Error::Transport(t) => Answer::Failed(format!("No connection: {t}")),
}
}
/// Whether a status with these headers is GitHub's rate limit: always a 429; a 403 only with
/// `x-ratelimit-remaining: 0` (the hourly limit) or `retry-after` (the secondary one), since a 403
/// can also be a refusal.
pub(crate) fn rate_limited(code: u16, header: impl Fn(&str) -> Option<String>) -> bool {
code == 429
|| (code == 403
&& (header("x-ratelimit-remaining").as_deref() == Some("0")
|| header("retry-after").is_some()))
}
fn cache_dir() -> PathBuf {
backend::data_dir().join("cache").join("github")
}
fn cache_name(url: &str) -> String {
let hash = sha2::Sha256::digest(url.as_bytes());
let hex: String = hash[..12].iter().map(|b| format!("{b:02x}")).collect();
format!("{hex}.json")
}
#[derive(serde::Serialize, serde::Deserialize)]
struct Kept {
url: String,
/// GitHub's ETag, or empty (the mirror's answers, or GitHub gave none).
etag: String,
/// When it was saved or last confirmed (Unix seconds).
saved: u64,
body: serde_json::Value,
}
fn read_kept(file: &Path, url: &str) -> Option<Kept> {
let kept: Kept = serde_json::from_slice(&fs::read(file).ok()?).ok()?;
(kept.url == url).then_some(kept)
}
/// Saves an answer for the next reads; a failure only costs that saving.
fn write_kept(file: &Path, url: &str, etag: &str, body: &serde_json::Value, saved: u64) {
let kept = Kept {
url: url.to_owned(),
etag: etag.to_owned(),
saved,
body: body.clone(),
};
let Some(dir) = file.parent() else { return };
let Ok(text) = serde_json::to_vec(&kept) else {
return;
};
let partial = file.with_extension("partial");
if fs::create_dir_all(dir).is_ok() && fs::write(&partial, text).is_ok() {
let _ = fs::rename(&partial, file);
}
}
#[cfg(test)]
#[path = "github/tests.rs"]
mod tests;
+278
View File
@@ -0,0 +1,278 @@
use super::*;
use std::cell::RefCell;
fn scratch(name: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("horizon-github-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
root
}
const T0: u64 = 1_800_000_000;
const LATER: u64 = T0 + FRESH_SECS;
const RELEASES: &str = "https://api.github.com/repos/horizon-rust/horizon/releases?per_page=100";
fn list(tag: &str) -> serde_json::Value {
serde_json::json!([{ "tag_name": tag }])
}
fn no_mirror(_: &str) -> Answer {
panic!("the mirror is only asked when GitHub can't answer")
}
#[test]
fn mirror_serves_only_horizons_repository() {
let base = "https://mcp.example/github/public";
assert_eq!(
mirror_url_with(RELEASES, base).as_deref(),
Some("https://mcp.example/github/public/repos/horizon-rust/horizon/releases?per_page=100")
);
assert_eq!(
mirror_url_with(
"https://api.github.com/repos/horizon-rust/horizon/releases/tags/[email protected]",
&format!("{base}/")
)
.as_deref(),
Some("https://mcp.example/github/public/repos/horizon-rust/horizon/releases/tags/[email protected]")
);
assert_eq!(
mirror_url_with("https://api.github.com/repos/someone/mod/commits/abc", base),
None
);
assert_eq!(
mirror_url_with("https://github.com/horizon-rust/horizon/releases", base),
None
);
assert_eq!(
mirror_url_with(RELEASES, ""),
None,
"an empty mirror is off"
);
}
#[test]
fn rate_limit_is_429_or_403_with_githubs_headers() {
let none = |_: &str| None;
assert!(rate_limited(429, none));
assert!(!rate_limited(403, none), "a plain 403 is a refusal");
assert!(rate_limited(403, |h| (h == "x-ratelimit-remaining").then(|| "0".into())));
assert!(!rate_limited(403, |h| (h == "x-ratelimit-remaining").then(|| "12".into())));
assert!(rate_limited(403, |h| (h == "retry-after").then(|| "60".into())));
assert!(!rate_limited(500, |h| (h == "retry-after").then(|| "60".into())));
}
#[test]
fn etag_is_kept_and_a_304_reads_the_saved_copy() {
let cache = scratch("etag");
let sent = RefCell::new(Vec::new());
let first = json_with(
RELEASES,
&cache,
T0,
|_, etag| {
sent.borrow_mut().push(etag.map(str::to_owned));
Answer::Fresh(list("nightly"), Some("\"abc\"".into()))
},
no_mirror,
);
assert_eq!(first.unwrap(), list("nightly"));
// Started again within FRESH_SECS: the saved list, without asking.
let again = json_with(
RELEASES,
&cache,
T0 + FRESH_SECS - 1,
|_, _| panic!("a fresh copy is used without asking"),
no_mirror,
);
assert_eq!(again.unwrap(), list("nightly"));
// Later: a conditional request; a 304 gives the saved list and starts a new fresh window.
let second = json_with(
RELEASES,
&cache,
LATER,
|_, etag| {
sent.borrow_mut().push(etag.map(str::to_owned));
Answer::NotModified
},
no_mirror,
);
assert_eq!(second.unwrap(), list("nightly"));
assert_eq!(*sent.borrow(), [None, Some("\"abc\"".to_owned())]);
let third = json_with(
RELEASES,
&cache,
LATER + FRESH_SECS - 1,
|_, _| panic!("confirmed by the 304"),
no_mirror,
);
assert_eq!(third.unwrap(), list("nightly"));
// A clock set back doesn't make the copy fresh forever.
let back = json_with(
RELEASES,
&cache,
T0 - 1,
|_, _| Answer::Fresh(list("v9"), None),
no_mirror,
);
assert_eq!(back.unwrap(), list("v9"));
// Another URL doesn't read this one's copy.
let other = "https://api.github.com/repos/horizon-rust/horizon/releases/latest";
let etag_sent = RefCell::new(Some(String::new()));
let _ = json_with(
other,
&cache,
T0,
|_, etag| {
*etag_sent.borrow_mut() = etag.map(str::to_owned);
Answer::Fresh(list("v1"), None)
},
no_mirror,
);
assert_eq!(*etag_sent.borrow(), None);
let _ = fs::remove_dir_all(cache);
}
#[test]
fn rate_limited_reads_go_through_the_mirror() {
let cache = scratch("mirror");
let asked = RefCell::new(None);
let got = json_with(
RELEASES,
&cache,
T0,
|_, _| Answer::RateLimited,
|url| {
*asked.borrow_mut() = Some(url.to_owned());
Answer::Fresh(list("nightly"), None)
},
);
assert_eq!(got.unwrap(), list("nightly"));
assert!(asked
.borrow()
.as_deref()
.unwrap()
.ends_with("/github/public/repos/horizon-rust/horizon/releases?per_page=100"));
// The mirror's answer is kept like GitHub's.
let kept = json_with(RELEASES, &cache, T0 + 1, |_, _| panic!("fresh"), no_mirror);
assert_eq!(kept.unwrap(), list("nightly"));
// No connection to GitHub (blocked, DNS): the mirror too.
let got = json_with(
RELEASES,
&cache,
LATER,
|_, _| Answer::Failed("No connection: dns".into()),
|_| Answer::Fresh(list("v2"), None),
);
assert_eq!(got.unwrap(), list("v2"));
let _ = fs::remove_dir_all(cache);
}
#[test]
fn a_404_is_not_retried_on_the_mirror() {
let cache = scratch("404");
let err = json_with(RELEASES, &cache, T0, |_, _| Answer::NotFound, no_mirror).unwrap_err();
assert!(err.ends_with("answered 404"), "{err}");
let err = json_with(
RELEASES,
&cache,
T0,
|_, _| Answer::RateLimited,
|_| Answer::NotFound,
)
.unwrap_err();
assert!(err.ends_with("answered 404"), "{err}");
let _ = fs::remove_dir_all(cache);
}
#[test]
fn both_failing_uses_the_saved_copy_else_says_so() {
let cache = scratch("both");
let err = json_with(
RELEASES,
&cache,
T0,
|_, _| Answer::RateLimited,
|_| Answer::Failed("No connection: timed out".into()),
)
.unwrap_err();
assert!(err.contains("rate limit"), "{err}");
assert!(err.contains("mirror"), "{err}");
assert!(err.contains("timed out"), "{err}");
assert!(err.contains("Try again in a few minutes"), "{err}");
// With a copy from an earlier start, that copy is better than nothing.
json_with(
RELEASES,
&cache,
T0,
|_, _| Answer::Fresh(list("nightly"), Some("\"e\"".into())),
no_mirror,
)
.unwrap();
let got = json_with(
RELEASES,
&cache,
LATER,
|_, _| Answer::RateLimited,
|_| Answer::RateLimited,
);
assert_eq!(got.unwrap(), list("nightly"));
let _ = fs::remove_dir_all(cache);
}
#[test]
fn other_repositories_have_no_mirror() {
let cache = scratch("other");
let err = json_with(
"https://api.github.com/repos/someone/mod/commits/abc",
&cache,
T0,
|_, _| Answer::RateLimited,
no_mirror,
)
.unwrap_err();
assert!(err.contains("rate limit"), "{err}");
assert!(!err.contains("mirror"), "{err}");
let _ = fs::remove_dir_all(cache);
}
#[test]
fn a_corrupt_or_foreign_copy_is_ignored() {
let cache = scratch("corrupt");
fs::write(cache.join(cache_name(RELEASES)), b"not json").unwrap();
let got = json_with(
RELEASES,
&cache,
T0,
|_, etag| {
assert_eq!(etag, None);
Answer::Fresh(list("v1"), None)
},
no_mirror,
);
assert_eq!(got.unwrap(), list("v1"));
let foreign = Kept {
url: "https://api.github.com/other".into(),
etag: "\"x\"".into(),
saved: T0,
body: list("x"),
};
fs::write(
cache.join(cache_name(RELEASES)),
serde_json::to_vec(&foreign).unwrap(),
)
.unwrap();
let got = json_with(
RELEASES,
&cache,
T0,
|_, etag| {
assert_eq!(etag, None);
Answer::Fresh(list("v2"), None)
},
no_mirror,
);
assert_eq!(got.unwrap(), list("v2"));
let _ = fs::remove_dir_all(cache);
}
+1
View File
@@ -10,6 +10,7 @@ pub mod export;
#[cfg(unix)]
pub mod game_app;
pub mod game_log;
pub mod github;
pub mod github_mods;
pub mod heads;
pub mod i18n;
+3 -3
View File
@@ -30,7 +30,7 @@ mod crates;
const PREFIX: &str = "native-";
/// horizon-mixins, for bundles that don't name the revision they were tested with.
const MIXINS_GIT: &str = "https://github.com/horizon-rust/horizon-mixins";
pub const MIXINS_REV: &str = "a2af5c0dd4117d51ecd70066ba7a00b4a2c10dab";
pub const MIXINS_REV: &str = "ddd9700b21997c95d95ac6ee5ecafe3830cf622a";
/// The features of the release's own game (game-build.yml).
const FEATURES: &str = "horizon-client/mods";
@@ -540,7 +540,7 @@ fn remembered(
fn ask_github(repo: &str, revision: &str) -> Result<String, String> {
let url = format!("https://api.github.com/repos/{repo}/commits/{revision}");
let commit: serde_json::Value = backend::get(&url)?.into_json().map_err(|e| e.to_string())?;
let commit = crate::github::json(&url)?;
commit["commit"]["committer"]["date"]
.as_str()
.map(str::to_owned)
@@ -691,7 +691,7 @@ pub fn revision_of(version: &str) -> Result<String, String> {
"https://api.github.com/repos/{}/commits/{reference}",
backend::REPO
);
let commit: serde_json::Value = backend::get(&url)?.into_json().map_err(|e| e.to_string())?;
let commit = crate::github::json(&url)?;
commit["sha"]
.as_str()
.filter(|s| is_revision(s))
+1 -2
View File
@@ -1166,8 +1166,7 @@
"plain": "Run Horizon's built-in benchmark and share the report: every new computer makes the performance work more accurate. No coding needed.",
"dev": "`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark <report>` shows what would be sent and uploads it (playhorizon.dev/docs/performance). Results from three installs with the same hardware appear on the Benchmarks page. Example: #1711.",
"links": [
"https://playhorizon.dev/docs/performance",
"#1711"
"https://playhorizon.dev/docs/performance"
]
},
{
+1 -1
View File
@@ -1096,7 +1096,7 @@ more accurate. No coding needed."""
dev = """`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data \
benchmark <report>` shows what would be sent and uploads it (playhorizon.dev/docs/performance). Results from three \
installs with the same hardware appear on the Benchmarks page. Example: #1711."""
links = ["https://playhorizon.dev/docs/performance", "#1711"]
links = ["https://playhorizon.dev/docs/performance"]
[[item]]
id = "end-city-banners"
+22
View File
@@ -0,0 +1,22 @@
from pathlib import Path
import unittest
from unittest import mock
from tools import interaction_lab
class CargoExampleTests(unittest.TestCase):
def test_client_probe_uses_the_reusable_library_package(self):
with mock.patch.object(interaction_lab.subprocess, 'run') as run:
interaction_lab.cargo_example('horizon-client', 'menu_fixture', Path('cases.txt'), profile='fast')
run.assert_called_once_with(
['cargo', 'run', '-q', '-j3', '--profile', 'fast', '-p', 'horizon-client-lib',
'--example', 'client_probe', '--', 'menu_fixture', 'cases.txt'],
check=True, cwd=interaction_lab.lab.ROOT)
def test_other_probe_packages_are_unchanged(self):
with mock.patch.object(interaction_lab.subprocess, 'run') as run:
interaction_lab.cargo_example('horizon-world', 'loot_probe', 42)
run.assert_called_once_with(
['cargo', 'run', '-q', '-j3', '-p', 'horizon-world', '--example', 'world_probe', '--', 'loot_probe', '42'],
check=True, cwd=interaction_lab.lab.ROOT)
+45
View File
@@ -131,6 +131,51 @@ class ReachTests(unittest.TestCase):
self.assertIn("crates/b/src/used.rs", kept)
self.assertNotIn("crates/c/src/lib.rs", kept)
def test_external_library_and_probe_roots_follow_the_manifest(self):
workspace(self.root, {
"crates/b/Cargo.toml": '[package]\nname = "b"\nautobins = false\nautoexamples = false\n'
'[lib]\npath = "../a/src/shared.rs"\n'
'[[example]]\nname = "client_probe"\npath = "../a/examples/probe/main.rs"\n',
"crates/a/src/shared.rs": "pub mod used;\n",
"crates/a/src/lib.rs": "pub fn game_only() {}\n",
"crates/a/Cargo.toml": '[package]\nname = "a"\nautobins = false\nautoexamples = false\n',
"crates/a/examples/probe/main.rs": "extern crate b as legacy;\nfn main() { legacy::used::helper(); }\n",
"crates/b/src/main.rs": "compile_error!(\"disabled auto-bin\");\n",
"crates/b/examples/ignored.rs": "compile_error!(\"disabled auto-example\");\n",
})
graph = parity_reach.SourceGraph(self.root, parity.rust_code)
roots = parity_reach.crate_roots(self.root / "crates/b")
self.assertIn((self.root / "crates/a/src/shared.rs").resolve(), roots)
self.assertIn((self.root / "crates/a/examples/probe/main.rs").resolve(), roots)
self.assertNotIn((self.root / "crates/b/src/main.rs").resolve(), roots)
self.assertTrue(graph.is_library("crates/a/src/shared.rs"))
self.assertEqual(graph.entries([["example", "client_probe", "dev", ["-p", "a"], True]],
{"crates/a/examples/probe/main.rs"}), {"crates/a/examples/probe/main.rs"})
reached = graph.reachable({"crates/a/examples/probe/main.rs"},
{"crates/a/examples/probe/main.rs", "crates/a/src/shared.rs", "crates/a/src/used.rs"})
self.assertIn("crates/a/src/used.rs", reached)
self.assertEqual(graph.targets(self.root / "crates/b"),
{"client_probe": ("example", "crates/a/examples/probe/main.rs")})
def test_old_broad_client_records_find_moved_ctl_and_probes(self):
workspace(self.root, {
"crates/horizon-client/Cargo.toml": '[package]\nname = "horizon-client"\nautobins = false\nautoexamples = false\n'
'[[bin]]\nname = "horizon-client"\npath = "src/main.rs"\n',
"crates/horizon-client/src/main.rs": "fn main() {}\n",
"crates/horizon-client/src/bin/horizon-ctl.rs": "fn main() {}\n",
"crates/horizon-client/examples/probe/main.rs": "fn main() {}\n",
"crates/horizon-client-lib/Cargo.toml": '[package]\nname = "horizon-client-lib"\nautobins = false\nautoexamples = false\n'
'[[bin]]\nname = "horizon-ctl"\npath = "../horizon-client/src/bin/horizon-ctl.rs"\n'
'[[example]]\nname = "client_probe"\npath = "../horizon-client/examples/probe/main.rs"\n',
})
graph = parity_reach.SourceGraph(self.root, parity.rust_code)
game, ctl, probe = ("crates/horizon-client/" + path for path in
["src/main.rs", "src/bin/horizon-ctl.rs", "examples/probe/main.rs"])
self.assertEqual(graph.entries([["bins", "", "dev", ["-p", "horizon-client"], False]], {game, ctl, probe}),
{game, ctl})
self.assertEqual(graph.entries([["examples", "", "dev", ["-p", "horizon-client"], True]], {game, ctl, probe}),
{probe})
def test_unknown_probes_keep_everything(self):
self.assertEqual(set(self.narrowed(artifacts=[])), set(self.dependencies))
self.assertEqual(set(self.narrowed(artifacts=[["example", "missing", "release", ["-p", "a"], True]])),
+36
View File
@@ -30,6 +30,31 @@ class ParityRunnerTests(unittest.TestCase):
self.assertEqual(parity.artifacts_of(["cargo", "build", "--release", "-p", "horizon-world", "--bin=horizon-world"]),
[["bin", "horizon-world", "release", ["-p", "horizon-world"], False]])
def test_prebuild_follows_a_moved_target_without_losing_features(self):
selection = ["-p", "horizon-client", "--no-default-features", "--features", "horizon-client/rng-trace"]
artifacts = [["example", "client_probe", "fast", selection, True],
["bin", "horizon-ctl", "fast", selection, False]]
packages = {("example", "client_probe"): "horizon-client-lib", ("bin", "horizon-ctl"): "horizon-client-lib"}
commands = parity.prebuild_commands(artifacts, packages)
self.assertEqual(len(commands), 2)
for command in commands:
self.assertEqual(command[:5], ["cargo", "build", "--locked", "--profile", "fast"])
self.assertEqual(command[5:5 + len(selection)], selection)
self.assertEqual(command[5 + len(selection):7 + len(selection)], ["-p", "horizon-client-lib"])
self.assertEqual(artifacts[0][3], selection)
def test_old_broad_client_selections_include_the_moved_targets(self):
packages = {("bin", "horizon-client"): "horizon-client", ("bin", "horizon-ctl"): "horizon-client-lib",
("example", "client_probe"): "horizon-client-lib"}
for flag in ["--bins", "--examples"]:
command = ["cargo", "build", "-p", "horizon-client", "--no-default-features", flag]
artifacts = parity.artifacts_of(command)
before = [list(artifact[3]) for artifact in artifacts]
self.assertEqual(parity.prebuild_commands(artifacts, packages),
[["cargo", "build", "--locked", "--profile", "dev", "-p", "horizon-client",
"--no-default-features", "-p", "horizon-client-lib", flag]])
self.assertEqual([artifact[3] for artifact in artifacts], before)
def test_prebuild_repeats_each_harness_selection(self):
artifacts = [
*parity.artifacts_of(["cargo", "build", "--release", "-p", "horizon-world", "--example", "a"]),
@@ -604,5 +629,16 @@ class WorkspaceManifestTests(unittest.TestCase):
optional = {"crates/horizon-client/Cargo.toml": self.CLIENT + '\n[features]\nfast = ["wgpu/x"]\n'}
self.assertEqual(self.verdict({"crates/horizon-client/Cargo.toml": self.CLIENT}, optional), set())
def test_external_feature_sources_are_not_discarded(self):
with tempfile.TemporaryDirectory() as temp, mock.patch.object(parity, "ROOT", Path(temp)):
root = Path(temp)
(root / "crates/core").mkdir(parents=True)
(root / "shared").mkdir()
(root / "crates/core/Cargo.toml").write_text(
'[package]\nname = "core"\n[lib]\npath = "../../shared/lib.rs"\n'
'[features]\nextra = []\n')
(root / "shared/lib.rs").write_text('#[cfg(feature = "extra")]\nfn extra() {}\n')
self.assertFalse(parity.feature_sources_changed("core", "extra", {"crates/core/Cargo.toml"}))
def test_without_a_base_everything_counts(self):
self.assertEqual(parity.workspace_only_manifests({"Cargo.lock"}, None), set())
+11
View File
@@ -31,6 +31,17 @@ class PrReadyTests(unittest.TestCase):
# The workspace manifest and lock touch every crate.
self.assertIsNone(pr_ready.touched_crates({"Cargo.lock", "crates/horizon-core/src/lib.rs"}, PACKAGES))
def test_shared_client_sources_select_the_library_without_gameplay(self):
packages = {**PACKAGES, "horizon-client-lib": "crates/horizon-client-lib"}
for path in ["src/client_lib.rs", "src/health.rs", "src/lan/hub.rs", "src/bin/horizon-ctl/main.rs",
"examples/probe/main.rs", "tests/lan_bot.rs"]:
self.assertEqual(pr_ready.touched_crates({f"crates/horizon-client/{path}"}, packages),
["horizon-client-lib"])
for path in ["src/lib.rs", "src/main.rs", "src/entry.rs", "src/game.rs", "src/game/combat.rs",
"tests/launch_api.rs", "Cargo.toml", "build.rs"]:
self.assertEqual(pr_ready.touched_crates({f"crates/horizon-client/{path}"}, packages),
["horizon-client"])
def test_which_workspace_builds_a_path(self):
self.assertTrue(pr_ready.workspace_rust("crates/horizon-theme/fonts/x.ttf"))
self.assertTrue(pr_ready.workspace_rust("Cargo.toml"))
+1 -1
View File
@@ -963,7 +963,7 @@ def main():
if args.bin_dir:
bins = Path(args.bin_dir).resolve()
else:
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-client', '--bins'],
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-client', '-p', 'horizon-client-lib', '--bins'],
cwd=ROOT, check=True)
meta = json.loads(subprocess.run(['cargo', 'metadata', '--format-version', '1', '--no-deps'], cwd=ROOT,
capture_output=True, text=True, check=True).stdout)
+1 -1
View File
@@ -34,7 +34,7 @@ Checks:
Each server runs in a fresh folder with fresh HORIZON_HOME and XDG folders (lab.game_env), on a free port,
in a flat world (creative unless the check says otherwise: the bots fly). Build first:
cargo build --profile fast -p horizon-client --bin horizon-client --bin horizon-ctl
cargo build --profile fast -p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl
cargo build --profile fast -p horizon-bots
"""
import argparse
+1 -1
View File
@@ -66,7 +66,7 @@ def run_harness(name, oracle, example, inputs_text, extra_sources=(), java_args=
dependencies = os.pathsep.join(map(str, [jars['client'], *libraries]))
run(['javac', '--release', lock['minecraft']['java_major'], '-proc:none', '-sourcepath', lab.ROOT / 'mods/horizon/src/test/java',
'-cp', dependencies, '-d', classes, oracle_path], 'compile-java')
run(['cargo', 'build', '--locked', '-p', 'horizon-client', '--example', 'client_probe'], 'compile-rust')
run(['cargo', 'build', '--locked', '-p', 'horizon-client-lib', '--example', 'client_probe'], 'compile-rust')
metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--locked', '--no-deps', '--format-version', '1'], cwd=lab.ROOT))
binary = Path(metadata['target_directory']) / 'debug/examples' / ('client_probe.exe' if os.name == 'nt' else 'client_probe')
run([binary, example, *rust_args, inputs, build / 'rust.txt'], 'rust')
+14 -10
View File
@@ -1513,24 +1513,28 @@ def piglin_pickups():
def experience_orb_stuck():
"""`ExperienceOrb.tick`'s push out of a block, the one path of the tick that
draws a random: a 3x3 block placed over a settled orb leaves it colliding
with its only free side the top, and with no player within the 8 block
follow radius the server nudges it up, `nextFloat() * 0.2 + 0.1` at a time,
gravity skipped while its box is inside blocks. The draw's magnitude only
decides how many ticks the escape takes, so the settled orb is the same
either way; an orb that is never pushed stays boxed in at y 215 instead of
resting on top of the block at 216. 25 blocks from the player, out of the
follow radius."""
draws a random: a 3x3 block placed over a settled orb leaves it colliding,
and with no player within the 8 block follow radius the server nudges it
towards the nearest free side (`Entity.moveTowardsClosestSpace`),
`nextFloat() * 0.2 + 0.1` at a time, gravity skipped while its box is inside
blocks. The orb's random is unseeded in vanilla (`RandomSource.create()`),
so the outcome must not depend on the draws: a ring of stone one block
higher keeps the top the only free side all the way out. Without it, once
the orb's middle rose past y 216 with its box still in the block, north
tied with south, west and east, won as the first, and some draws slid the
orb off the block (at z 23.25, 22.x or down to y 215 on the floor).
An orb that is never pushed stays boxed in at y 215 instead of resting in
the ring at 216. 25 blocks from the player, out of the follow radius."""
return [
*setup('/fill 30 214 20 36 214 26 minecraft:stone'),
cmd('/summon minecraft:experience_orb 33.5 215 23.5 {Value:1s}'),
{'op': 'tick', 'n': 2},
cmd('/data get entity @e[type=minecraft:experience_orb,limit=1] Pos'),
*setup('/fill 32 215 22 34 215 24 minecraft:stone'),
*setup('/fill 32 215 22 34 216 24 minecraft:stone', '/setblock 33 216 23 minecraft:air'),
{'op': 'tick', 'n': 60},
cmd('/data get entity @e[type=minecraft:experience_orb,limit=1] Pos'),
cmd('/data get entity @e[type=minecraft:experience_orb,limit=1] Motion'),
*setup('/kill @e[type=minecraft:experience_orb]', '/fill 30 214 20 36 215 26 minecraft:air'),
*setup('/kill @e[type=minecraft:experience_orb]', '/fill 30 214 20 36 216 26 minecraft:air'),
{'op': 'tick', 'n': 5},
]
+1 -1
View File
@@ -261,7 +261,7 @@ def main():
if args.bin_dir:
bins = Path(args.bin_dir).resolve()
else:
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-client', '--bins'],
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-client', '-p', 'horizon-client-lib', '--bins'],
cwd=ROOT, check=True)
meta = json.loads(subprocess.run(['cargo', 'metadata', '--format-version', '1', '--no-deps'], cwd=ROOT,
capture_output=True, text=True, check=True).stdout)
+1 -1
View File
@@ -170,7 +170,7 @@ def main():
return
ctl = Path(args.build).resolve() / "horizon-ctl"
if not ctl.exists():
sys.exit(f"{ctl} missing: cargo build --release -p horizon-client --bins")
sys.exit(f"{ctl} missing: cargo build --release -p horizon-client -p horizon-client-lib --bins")
cwd = ROOT / ".local/graphics-bench"
shutil.rmtree(cwd / "saves" / WORLD, ignore_errors=True)
cwd.mkdir(parents=True, exist_ok=True)
+2
View File
@@ -51,6 +51,8 @@ def cargo_example(package, example, *args, profile=None):
if profile:
command += ['--profile', profile]
probe = package.removeprefix('horizon-') + '_probe' # each crate's probe example: client_probe, world_probe
if package == 'horizon-client':
package = 'horizon-client-lib'
command += ['-p', package, '--example', probe, '--', example, *map(str, args)]
subprocess.run(command, check=True, cwd=lab.ROOT)
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff. Show more