Merge branch 'main' into main

This commit is contained in:
kerim authored and GitHub committed 2026-10-30 20:19:03 +01:00
commit 669aa04e69
521 files changed
+38320 -5404

No files matched your search

+3 -1
View File
@@ -22,7 +22,9 @@ jobs:
if: >-
github.event_name == 'issue_comment' && github.event.comment.user.type != 'Bot' && (
startsWith(github.event.comment.body, '/claim') ||
startsWith(github.event.comment.body, '/unclaim'))
startsWith(github.event.comment.body, '/unclaim') ||
startsWith(github.event.comment.body, '🤖 Agent: /claim') ||
startsWith(github.event.comment.body, '🤖 Agent: /unclaim'))
# One command per issue at a time, so two `/claim`s can't both find it free. On the job, not the workflow:
# the skipped runs of ordinary comments must not cancel a waiting one.
concurrency:
+3 -3
View File
@@ -233,9 +233,9 @@ jobs:
# before the Nightly-only switch keep (they don't read Nightlies).
gh release create nightly --prerelease --latest=false --target "$SHA" \
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
"dist/horizon-linux-x86_64.tar.gz#Nightly game files: Linux (horizon-linux-x86_64.tar.gz)" \
"dist/horizon-windows-x64.zip#Nightly game files: Windows (horizon-windows-x64.zip)" \
"dist/horizon-macos-arm64.zip#Nightly game files: macOS (horizon-macos-arm64.zip)" \
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs (Nightly): Linux (horizon-linux-x86_64.tar.gz)" \
"dist/horizon-windows-x64.zip#Game files the launcher installs (Nightly): Windows (horizon-windows-x64.zip)" \
"dist/horizon-macos-arm64.zip#Game files the launcher installs (Nightly): macOS (horizon-macos-arm64.zip)" \
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
"dist/horizon-source.tar.gz#Source of this Nightly, commit ${SHA:0:7} (horizon-source.tar.gz; kept for good as the tag $SOURCE_TAG)" \
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
Generated
+27
View File
@@ -561,6 +561,12 @@ dependencies = [
"typenum",
]
[[package]]
name = "cty"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b365fabc795046672053e29c954733ec3b05e4be654ab130fe8f1f94d7051f35"
[[package]]
name = "cursor-icon"
version = "1.2.0"
@@ -1412,6 +1418,8 @@ name = "horizon-threads"
version = "0.1.0"
dependencies = [
"libc",
"libmimalloc-sys",
"mimalloc",
"windows-sys 0.61.2",
]
@@ -1787,6 +1795,16 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libmimalloc-sys"
version = "0.1.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
dependencies = [
"cc",
"cty",
]
[[package]]
name = "libredox"
version = "0.1.25"
@@ -1878,6 +1896,15 @@ dependencies = [
"libc",
]
[[package]]
name = "mimalloc"
version = "0.1.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
dependencies = [
"libmimalloc-sys",
]
[[package]]
name = "minisign-verify"
version = "0.3.0"
+2 -2
View File
@@ -47,7 +47,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates, item modifiers and world generation (vanilla's heights and biomes for now).
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates, item modifiers and world generation, taller levels included (vanilla's biomes for now).
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
- **Mods:** content mods add blocks, items and data, and native mods change the game's own Rust code, built by the launcher on your copy, with no Rust to install. Horizon's own (Zoom, FPS Counter, Keystrokes, Minimap, Recipes and Coordinates HUD) are native mods. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
@@ -166,7 +166,7 @@ lists every method of vanilla 26.3 next to the Rust functions that port it, with
### Known issues
Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; item tooltips lack attribute lines.
## Licence
+4 -4
View File
@@ -76,9 +76,9 @@ Owner: [levzzz5154](https://github.com/levzzz5154) · Tracking: [#3911](https://
<details><summary>Technical</summary>
One item for all modding work ([#3911](https://github.com/horizon-rust/horizon/issues/3911)), led by levzzz5154 with vladislav_hagen; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)) built by the launcher ([#3388](https://github.com/horizon-rust/horizon/issues/3388), [#3855](https://github.com/horizon-rust/horizon/issues/3855)); server-side hooks ([#3878](https://github.com/horizon-rust/horizon/issues/3878), [#3706](https://github.com/horizon-rust/horizon/issues/3706)); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry ([#1634](https://github.com/horizon-rust/horizon/issues/1634), [#1642](https://github.com/horizon-rust/horizon/issues/1642), [#1659](https://github.com/horizon-rust/horizon/issues/1659)); machines with inventories and menus ([#1189](https://github.com/horizon-rust/horizon/issues/1189)); mods that read and change the world through the server. The WebAssembly runtime is removed ([#3350](https://github.com/horizon-rust/horizon/issues/3350), [#3351](https://github.com/horizon-rust/horizon/issues/3351), [#3353](https://github.com/horizon-rust/horizon/issues/3353)). [docs/native-mods.md](docs/native-mods.md).
One item for all modding work ([#3911](https://github.com/horizon-rust/horizon/issues/3911)), led by levzzz5154 with vladislav_hagen; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)) built by the launcher ([#3388](https://github.com/horizon-rust/horizon/issues/3388), [#3855](https://github.com/horizon-rust/horizon/issues/3855)); server-side hooks ([#3878](https://github.com/horizon-rust/horizon/issues/3878), [#3706](https://github.com/horizon-rust/horizon/issues/3706)); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry ([#1634](https://github.com/horizon-rust/horizon/issues/1634), [#1642](https://github.com/horizon-rust/horizon/issues/1642), [#1659](https://github.com/horizon-rust/horizon/issues/1659)); machines with inventories and menus ([#1189](https://github.com/horizon-rust/horizon/issues/1189)); mods that read and change the world through the server. [docs/native-mods.md](docs/native-mods.md).
More: [#3360](https://github.com/horizon-rust/horizon/issues/3360) · [#1596](https://github.com/horizon-rust/horizon/issues/1596) · [#1635](https://github.com/horizon-rust/horizon/issues/1635)
More: [#3351](https://github.com/horizon-rust/horizon/issues/3351) · [#3353](https://github.com/horizon-rust/horizon/issues/3353) · [#3360](https://github.com/horizon-rust/horizon/issues/3360) · [#1596](https://github.com/horizon-rust/horizon/issues/1596) · [#1635](https://github.com/horizon-rust/horizon/issues/1635)
</details>
@@ -490,7 +490,7 @@ Small mods written as scripts, in the style of KubeJS, with no compiler.
<details><summary>Technical</summary>
Waits for native mods to settle: the earlier plan ran Lua inside the WebAssembly sandbox, which is removed. A script layer over the native hook points is to be designed.
Waits for native mods to settle: a script layer over the native hook points is to be designed.
More: [docs/native-mods.md](docs/native-mods.md)
@@ -718,7 +718,7 @@ Versions of Horizon for Android, iOS and web browsers. Nothing official yet; con
<details><summary>Technical</summary>
No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a WebAssembly build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS ([#1813](https://github.com/horizon-rust/horizon/issues/1813)) and helper-thread limits on Android ([#1751](https://github.com/horizon-rust/horizon/issues/1751)). Preparing without Java ([#1750](https://github.com/horizon-rust/horizon/issues/1750)) matters here too. Horizon ships none of Mojang's files on any platform.
No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS ([#1813](https://github.com/horizon-rust/horizon/issues/1813)) and helper-thread limits on Android ([#1751](https://github.com/horizon-rust/horizon/issues/1751)). Preparing without Java ([#1750](https://github.com/horizon-rust/horizon/issues/1750)) matters here too. Horizon ships none of Mojang's files on any platform.
</details>
+11
View File
@@ -19,3 +19,14 @@ by The Pixelify Sans Project Authors, licensed under the SIL Open Font License 1
[Pixelarticons](https://github.com/halfmage/pixelarticons) by Gerrit Halfmann, under the MIT
license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`). `star.svg` is Horizon's own, drawn on
the same grid.
## AMD FidelityFX SDK (FSR 3.1 frame generation)
`crates/horizon-client-render/src/gfx/frame_gen/optical_flow.wgsl` is a port to WGSL of the
optical flow shaders of the [AMD FidelityFX SDK](https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK)
1.1.4 (`sdk/include/FidelityFX/gpu/opticalflow`, at commit
[c6efa6bf7f20](https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK/tree/c6efa6bf7f2027b3ec94f28578bb5965eabb9e55)),
and `optical_flow.rs` follows the SDK's `sdk/src/components/opticalflow/ffx_opticalflow.cpp`
(its passes, their order and their dispatch sizes). Copyright (C) 2024 Advanced Micro Devices,
Inc., under the MIT license, whose text heads both files. AMD and FidelityFX are AMD's
trademarks; Horizon is not affiliated with AMD.
+71 -1
View File
@@ -13,7 +13,9 @@ const SITE: &str = "https://horizon-rust.github.io/horizon-mods/";
const USAGE: &str = "usage: cargo horizon-mod registry build <checkout> --files <dir> --out <index.json> [--release <url>] [--site <url>] [--generated <time>]
cargo horizon-mod registry review <base checkout> <head checkout> --author <login> [--json]
cargo horizon-mod registry check <base checkout> <head checkout> --files <dir>
cargo horizon-mod registry list <checkout>";
cargo horizon-mod registry list <checkout>
cargo horizon-mod registry release <checkout> <id> <file> --tag <tag> --published <YYYY-MM-DD> [--notes <file>] [--signature <file>] [--game <version>]
cargo horizon-mod registry page <checkout> <id> <horizon-registry.toml>";
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
super::flag(args, name)
@@ -154,6 +156,74 @@ pub fn main(args: &[String]) -> Result<(), String> {
}
Ok(())
}
// A release of a project's repository, downloaded: the checks of a submission, then the
// version as the index lists it (JSON on stdout), for the registry's release sync.
Some("release") => {
let [checkout, id, file] = pos[..] else {
return Err(USAGE.into());
};
let (tag, published) = (
flag(rest, "--tag").ok_or(USAGE)?,
flag(rest, "--published").ok_or(USAGE)?,
);
let read = |name: &str| {
flag(rest, name)
.map(|p| std::fs::read_to_string(p).map_err(|e| format!("{p}: {e}")))
.transpose()
};
let (notes, signature) = (read("--notes")?, read("--signature")?);
let entry = entry_of(checkout, id)?;
let path = Path::new(file);
let cache = registry::Cache::read(Path::new(checkout), id)?;
let entry = entry.resolved(&cache.page);
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
crate::check::check(
path,
entry.kind.clone(),
entry.key.as_deref(),
signature.as_deref(),
)?;
let version = registry::release_version(
&entry,
&registry::Release {
tag,
published,
notes: notes.as_deref().unwrap_or(""),
asset: flag(rest, "--name").unwrap_or(name),
game: flag(rest, "--game").unwrap_or("26.3"),
signature: signature.as_deref(),
},
path,
)?;
println!(
"{}",
serde_json::to_string(&version).map_err(|e| e.to_string())?
);
Ok(())
}
// The page file of a project's repository, checked against its entry: the page as JSON.
Some("page") => {
let [checkout, id, file] = pos[..] else {
return Err(USAGE.into());
};
let entry = entry_of(checkout, id)?;
let text = std::fs::read_to_string(file).map_err(|e| format!("{file}: {e}"))?;
let page = registry::page(&entry, &text)?;
println!(
"{}",
serde_json::to_string(&page).map_err(|e| e.to_string())?
);
Ok(())
}
_ => Err(USAGE.into()),
}
}
/// `mods/<id>.toml` of the checkout, which must name a repository.
fn entry_of(checkout: &str, id: &str) -> Result<registry::Entry, String> {
let (entries, _) = registry::load(Path::new(checkout)).map_err(lines)?;
entries
.into_iter()
.find(|e| e.id == id && e.repo.is_some())
.ok_or_else(|| format!("{id}: no entry with a repo"))
}
+328 -11
View File
@@ -21,18 +21,19 @@ use horizon_protocol::{
connection::{ConnWriter, Connection},
crypto::offline_uuid,
ids,
item::Stack,
packets::{
Empty, configuration,
handshake::{self, Intention},
login::{self, ServerHello},
play,
play, recipes,
},
};
use serde_json::json;
use std::{
collections::HashSet,
sync::{
Arc, Mutex,
Arc, LazyLock, Mutex,
atomic::{AtomicBool, AtomicU64, Ordering},
},
thread,
@@ -44,29 +45,71 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
--server HOST:PORT default 127.0.0.1:25565
--bots N default 10
--behaviour B idle, walk, explore or mixed (60 % walk, 20 % explore, 20 % idle); default mixed
--behaviour B idle, walk, explore or mixed (60 % walk, 20 % explore, 20 % idle); default mixed.
stand: stays on the ground where the server puts it (/tp), for a survival test.
One abusive client, to test a server's limits (run it beside a normal crowd):
flood-move (--flood moves a tick, each 64 to 256 blocks away, ignoring chunks),
flood-acks (--flood chunk batch acks asking for 64 chunks a tick, while exploring),
flood-packets (--flood rotations a tick),
flood-chunks (one move a tick, 9.9 blocks straight ahead: as fast as the
moved-too-quickly check allows, never waiting for chunks),
and --flood a tick of packets that cost the server work:
flood-use (place and break stone around itself), flood-click (inventory
clicks with a stale state), flood-suggest (command suggestions, every
other one 30 000 characters long), flood-book (100 pages of 1024 characters
into a writable book), flood-sign (four 384-character lines on the block
below), flood-recipe (recipe book placements)
--flood N packets a tick for the flood behaviours; default 100
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test);
the report gives each bot's last health
--attack-after S start attacking S seconds after joining; default 5
--no-read stop reading from the server once spawned (a client that lets the
server's replies pile up)
--join-every SECONDS default 1
--seconds N how long to run after the first bot joins; default 60
--view-distance N what the bots ask for; default 10
--seed N default 12345
--explore-speed B blocks a second an explore bot flies; default 22 (as fast as chunks come)
--name PREFIX bot names: PREFIX0, PREFIX1...; default Bot
--brand B the client brand sent in configuration (minecraft:brand); default none
--report FILE a JSON summary at the end (- prints it after the per-second lines)
";
#[derive(Clone, Copy, Debug, PartialEq)]
enum Behaviour {
Idle,
Stand,
Walk,
Explore,
FloodMove,
FloodAcks,
FloodPackets,
FloodChunks,
FloodUse,
FloodClick,
FloodSuggest,
FloodBook,
FloodSign,
FloodRecipe,
}
impl Behaviour {
fn name(self) -> &'static str {
match self {
Self::Idle => "idle",
Self::Stand => "stand",
Self::Walk => "walk",
Self::Explore => "explore",
Self::FloodMove => "flood-move",
Self::FloodAcks => "flood-acks",
Self::FloodPackets => "flood-packets",
Self::FloodChunks => "flood-chunks",
Self::FloodUse => "flood-use",
Self::FloodClick => "flood-click",
Self::FloodSuggest => "flood-suggest",
Self::FloodBook => "flood-book",
Self::FloodSign => "flood-sign",
Self::FloodRecipe => "flood-recipe",
}
}
}
@@ -81,7 +124,14 @@ struct Options {
view_distance: i8,
seed: u64,
explore_speed: f64,
/// The player to attack, and how long after joining to start.
attack: Option<String>,
attack_after: f64,
flood: usize,
no_read: bool,
name: String,
/// The `minecraft:brand` to send, if any.
brand: Option<String>,
report: Option<String>,
}
@@ -96,8 +146,13 @@ fn options() -> Result<Options, String> {
view_distance: 10,
seed: 12345,
explore_speed: 22.0,
attack: None,
attack_after: 5.0,
flood: 100,
no_read: false,
name: "Bot".into(),
report: None,
brand: None,
};
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
@@ -105,6 +160,10 @@ fn options() -> Result<Options, String> {
print!("{HELP}");
std::process::exit(0);
}
if arg == "--no-read" {
o.no_read = true;
continue;
}
let value = args.next().ok_or(format!("{arg} needs a value"))?;
let number = |what: &str| format!("{arg} needs {what}, not {value:?}");
match arg.as_str() {
@@ -122,14 +181,35 @@ fn options() -> Result<Options, String> {
"--explore-speed" => {
o.explore_speed = value.parse().map_err(|_| number("blocks a second"))?;
}
"--flood" => o.flood = value.parse().map_err(|_| number("packets a tick"))?,
"--attack" => o.attack = Some(value),
"--attack-after" => o.attack_after = value.parse().map_err(|_| number("seconds"))?,
"--name" => o.name = value,
"--report" => o.report = Some(value),
"--brand" => o.brand = Some(value),
_ => return Err(format!("unknown option {arg}; --help lists them")),
}
}
if !["idle", "walk", "explore", "mixed"].contains(&o.behaviour.as_str()) {
const BEHAVIOURS: [&str; 15] = [
"idle",
"walk",
"explore",
"mixed",
"stand",
"flood-move",
"flood-acks",
"flood-packets",
"flood-chunks",
"flood-use",
"flood-click",
"flood-suggest",
"flood-book",
"flood-sign",
"flood-recipe",
];
if !BEHAVIOURS.contains(&o.behaviour.as_str()) {
return Err(format!(
"--behaviour {}: use idle, walk, explore or mixed",
"--behaviour {}: use idle, walk, explore, mixed or a flood behaviour (--help lists them)",
o.behaviour
));
}
@@ -156,6 +236,13 @@ struct Bot {
teleport: Mutex<Option<[f64; 3]>>,
error: Mutex<Option<String>>,
joined_after_ms: AtomicU64,
/// The bot stopped reading (--no-read).
deaf: AtomicBool,
/// `--attack`: the target's UUID and, once AddEntity showed it, its entity id.
target: Mutex<(Option<u128>, Option<i32>)>,
attacks: AtomicU64,
/// The last SetHealth.
health: Mutex<Option<f32>>,
}
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
@@ -185,6 +272,89 @@ fn raw(id: i32, write: impl FnOnce(&mut Writer)) -> Vec<u8> {
w.data
}
/// Item ids of the pinned version (26.3's item registry).
const STONE: i32 = 1;
const WRITABLE_BOOK: i32 = 1371;
/// The `i`th packet of a costly flood this tick, the bot at `pos`; none for other behaviours.
fn costly(
behaviour: Behaviour,
i: usize,
sequence: i32,
pos: [f64; 3],
random: &mut Random,
) -> Option<Vec<u8>> {
use ids::play::serverbound as sb;
static LONG_COMMAND: LazyLock<String> =
LazyLock::new(|| format!("/msg {}", "x".repeat(30_000)));
static BOOK: LazyLock<Vec<u8>> = LazyLock::new(|| {
play::Serverbound::EditBook(play::EditBook {
slot: 0,
pages: vec!["a".repeat(1024); 100],
title: None,
})
.encode()
});
let block = pos.map(|v| v.floor() as i32);
let packet = match behaviour {
Behaviour::FloodUse => {
// A block within reach: place stone there, or break what is there.
let at = [0, 1, 2].map(|k| block[k] + (random.unit() * 7.0) as i32 - 3);
if i % 2 == 0 {
play::Serverbound::UseItemOn(play::UseItemOn {
hand: 0,
pos: at,
face: 1,
cursor: [0.5, 1.0, 0.5],
inside: false,
world_border: false,
sequence,
})
} else {
play::Serverbound::PlayerAction(play::PlayerAction {
action: 0,
pos: at,
direction: 1,
sequence,
})
}
}
Behaviour::FloodClick => play::Serverbound::ContainerClick(play::ContainerClick {
container: 0,
state: 0,
slot: 36 + (i % 9) as i16,
button: 0,
input: 0,
changed: Vec::new(),
carried: None,
}),
Behaviour::FloodSuggest => {
let text = if i % 2 == 0 {
"/"
} else {
LONG_COMMAND.as_str()
};
return Some(raw(sb::COMMAND_SUGGESTION, |w| {
w.varint(sequence);
w.string(text);
}));
}
Behaviour::FloodBook => return Some(BOOK.clone()),
Behaviour::FloodSign => play::Serverbound::SignUpdate(play::SignUpdate {
pos: [block[0], block[1] - 1, block[2]],
lines: std::array::from_fn(|_| "a".repeat(384)),
front: true,
}),
Behaviour::FloodRecipe => play::Serverbound::PlaceRecipe(recipes::PlaceRecipe {
container: 0,
recipe: (i % 2042) as i32,
use_max: true,
}),
_ => return None,
};
Some(packet.encode())
}
fn err(e: impl std::fmt::Display) -> String {
e.to_string()
}
@@ -252,6 +422,19 @@ fn join(o: &Options, name: &str) -> Result<Connection, String> {
.encode(),
)
.map_err(err)?;
// ClientBrandRetriever: vanilla's client says "vanilla", Horizon's "horizon".
if let Some(brand) = &o.brand {
let mut w = Writer::new();
w.string(brand);
c.send(
&configuration::Serverbound::CustomPayload(configuration::CustomPayload {
channel: "minecraft:brand".into(),
data: w.data,
})
.encode(),
)
.map_err(err)?;
}
loop {
let (id, body) = c.recv().map_err(err)?;
let reply = match id {
@@ -279,8 +462,9 @@ fn join(o: &Options, name: &str) -> Result<Connection, String> {
payload: None,
})
}
configuration::Clientbound::Disconnect(_) => {
return Err("disconnected during configuration".into());
configuration::Clientbound::Disconnect(p) => {
let reason = horizon_protocol::text::plain(&p.reason, &Default::default());
return Err(format!("disconnected during configuration: {reason}"));
}
configuration::Clientbound::FinishConfiguration(_) => {
c.send(&configuration::Serverbound::FinishConfiguration(Empty).encode())
@@ -339,6 +523,12 @@ fn read(
let mut batch = BatchSize::new();
let mut teleports = 0u64;
loop {
if bot.deaf.load(Ordering::Relaxed) {
while bot.online.load(Ordering::SeqCst) {
thread::sleep(Duration::from_millis(100));
}
return;
}
let (id, body) = match reader.recv() {
Ok(packet) => packet,
Err(e) => {
@@ -379,6 +569,21 @@ fn read(
}
Ok(())
}
cb::ADD_ENTITY => {
if let Ok(play::Clientbound::AddEntity(p)) = play::Clientbound::decode(id, &body) {
let mut target = lock(&bot.target);
if target.0 == Some(p.uuid) {
target.1 = Some(p.id);
}
}
Ok(())
}
cb::SET_HEALTH => {
if let Ok(play::Clientbound::SetHealth(p)) = play::Clientbound::decode(id, &body) {
*lock(&bot.health) = Some(p.health);
}
Ok(())
}
cb::CHUNK_BATCH_START => {
batch.started = Instant::now();
Ok(())
@@ -466,6 +671,7 @@ fn run_bot(
};
let (reader, writer) = connection.split();
let writer = Arc::new(Mutex::new(writer));
lock(&bot.target).0 = o.attack.as_deref().map(offline_uuid);
bot.online.store(true, Ordering::SeqCst);
{
let (writer, bot) = (writer.clone(), bot.clone());
@@ -489,12 +695,31 @@ fn run_bot(
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
send(raw(sb::PLAYER_LOADED, |_| {}));
bot.deaf.store(o.no_read, Ordering::Relaxed);
// What the costly floods need in hand (creative: the server takes the bot's word for it).
let tool = match behaviour {
Behaviour::FloodUse => Some(Stack::new(STONE, 64)),
Behaviour::FloodBook => Some(Stack::new(WRITABLE_BOOK, 1)),
_ => None,
};
if tool.is_some() {
send(
play::Serverbound::SetCreativeModeSlot(play::SetCreativeModeSlot {
slot: 36,
item: tool,
})
.encode(),
);
send(play::Serverbound::SetCarriedItem(play::SetCarriedItem { slot: 0 }).encode());
}
let mut sequence = 0;
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
let mut pos = spawn;
let cruise = spawn[1] + 24.0;
let heading = random.unit() * std::f64::consts::TAU;
let mut target = pos;
let mut next = Instant::now();
let (joined, mut tick) = (Instant::now(), 0u64);
loop {
if lock(&until).is_some_and(|u| Instant::now() >= u) || !bot.online.load(Ordering::SeqCst) {
break;
@@ -504,13 +729,46 @@ fn run_bot(
pos = at;
}
let here = ((pos[0].floor() as i32) >> 4, (pos[2].floor() as i32) >> 4);
if !lock(&bot.held).contains(&here) {
// The abusive client doesn't wait for its chunk, as a real client must.
if behaviour == Behaviour::Stand {
// Where the server put it, on the ground.
} else if behaviour == Behaviour::FloodMove {
// The last of these moves goes out below, with the tick's end.
pos[1] = cruise;
for _ in 1..o.flood.max(1) {
let angle = random.unit() * std::f64::consts::TAU;
let reach = 64.0 + random.unit() * 192.0;
pos[0] += angle.cos() * reach;
pos[2] += angle.sin() * reach;
send(
play::Serverbound::MovePlayerPos(play::MovePlayerPos {
x: pos[0],
y: pos[1],
z: pos[2],
flags: 0,
})
.encode(),
);
}
let angle = random.unit() * std::f64::consts::TAU;
pos[0] += angle.cos() * 128.0;
pos[2] += angle.sin() * 128.0;
} else if behaviour == Behaviour::FloodChunks {
// Up to cruising height first, then just under the server's 10 blocks a tick:
// every tick asks for new chunks.
if pos[1] < cruise {
pos[1] = (pos[1] + 0.5).min(cruise);
} else {
pos[0] += heading.cos() * 9.9;
pos[2] += heading.sin() * 9.9;
}
} else if !lock(&bot.held).contains(&here) {
bot.waited.fetch_add(1, Ordering::Relaxed);
} else if pos[1] < cruise {
pos[1] = (pos[1] + 0.5).min(cruise);
} else {
match behaviour {
Behaviour::Idle => {}
Behaviour::Idle | Behaviour::Stand => {}
Behaviour::Walk => {
let (dx, dz) = (target[0] - pos[0], target[2] - pos[2]);
let distance = dx.hypot(dz);
@@ -529,7 +787,37 @@ fn run_bot(
pos[2] += dz / distance * step;
}
}
Behaviour::Explore => {
Behaviour::FloodMove
| Behaviour::FloodChunks
| Behaviour::FloodUse
| Behaviour::FloodClick
| Behaviour::FloodSuggest
| Behaviour::FloodBook
| Behaviour::FloodSign
| Behaviour::FloodRecipe => {}
Behaviour::FloodPackets => {
for i in 0..o.flood {
send(
play::Serverbound::MovePlayerRot(play::MovePlayerRot {
yaw: (i % 360) as f32,
pitch: 0.0,
flags: 0,
})
.encode(),
);
}
}
Behaviour::FloodAcks | Behaviour::Explore => {
if behaviour == Behaviour::FloodAcks {
for _ in 0..o.flood {
send(
play::Serverbound::ChunkBatchReceived(play::ChunkBatchReceived {
chunks_per_tick: 64.0,
})
.encode(),
);
}
}
// Flying in creative, by default about 22 blocks a second, which keeps new
// chunks coming as fast as the server sends them.
let step = o.explore_speed / 20.0;
@@ -538,12 +826,28 @@ fn run_bot(
}
}
}
// The costly floods go out wherever the bot is, chunks or not (--no-read gets none).
for i in 0..o.flood {
sequence += 1;
if let Some(body) = costly(behaviour, i, sequence, pos, &mut random) {
send(body);
}
}
let target = lock(&bot.target).1;
if let Some(id) = target
&& joined.elapsed().as_secs_f64() >= o.attack_after
&& tick % 10 == 0
{
send(play::Serverbound::Attack(play::Attack { id }).encode());
bot.attacks.fetch_add(1, Ordering::Relaxed);
}
tick += 1;
let ok = send(
play::Serverbound::MovePlayerPos(play::MovePlayerPos {
x: pos[0],
y: pos[1],
z: pos[2],
flags: 0,
flags: u8::from(behaviour == Behaviour::Stand),
})
.encode(),
) && send(raw(sb::CLIENT_TICK_END, |_| {}));
@@ -567,8 +871,19 @@ fn run_bot(
fn behaviour_of(mode: &str, index: usize) -> Behaviour {
match mode {
"idle" => Behaviour::Idle,
"stand" => Behaviour::Stand,
"walk" => Behaviour::Walk,
"explore" => Behaviour::Explore,
"flood-move" => Behaviour::FloodMove,
"flood-acks" => Behaviour::FloodAcks,
"flood-packets" => Behaviour::FloodPackets,
"flood-chunks" => Behaviour::FloodChunks,
"flood-use" => Behaviour::FloodUse,
"flood-click" => Behaviour::FloodClick,
"flood-suggest" => Behaviour::FloodSuggest,
"flood-book" => Behaviour::FloodBook,
"flood-sign" => Behaviour::FloodSign,
"flood-recipe" => Behaviour::FloodRecipe,
// mixed: of every five bots, three walk, one explores, one stands.
_ => match index % 5 {
0 | 2 | 4 => Behaviour::Walk,
@@ -670,6 +985,8 @@ fn main() {
"pulled_back": b.pulled_back.load(Ordering::Relaxed),
"waited_ticks": b.waited.load(Ordering::Relaxed),
"error": *lock(&b.error),
"attacks": b.attacks.load(Ordering::Relaxed),
"health": *lock(&b.health),
})).collect::<Vec<_>>(),
});
if o.report.as_deref() == Some("-") {
+4 -1
View File
@@ -237,7 +237,7 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
_ => None,
};
}
// HoverEvent.ShowText and ShowItem.
// HoverEvent.ShowText, ShowItem and ShowEntity.
if let Some(e) = o.get("hover_event").and_then(Value::as_object) {
match e.get("action").and_then(Value::as_str) {
Some("show_text") => {
@@ -248,6 +248,9 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
Some("show_item") if e.get("id").is_some_and(Value::is_string) => {
style.hover_item = Some(Box::new(Value::Object(e.clone())));
}
Some("show_entity") if e.get("id").is_some_and(Value::is_string) => {
style.hover_entity = Some(Box::new(Value::Object(e.clone())));
}
_ => {}
}
}
@@ -28,6 +28,10 @@ pub struct Registries {
/// Game rule name -> integer-valued.
pub game_rules: Vec<(String, bool)>,
pub players: Vec<String>,
/// `ClientSuggestionProvider.customCompletionSuggestions`: the extra chat
/// completions the server sent (ClientboundCustomChatCompletionsPacket),
/// each once.
pub custom_completions: std::collections::BTreeSet<String>,
/// Scoreboard objective names, team names and tracked score holders.
pub objectives: Vec<String>,
pub teams: Vec<String>,
@@ -87,7 +91,55 @@ pub struct RegistryIds {
pub tags: Vec<String>,
}
const EMPTY: &[String] = &[];
/// ClientboundCustomChatCompletionsPacket.Action.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CustomCompletionAction {
Add,
Remove,
Set,
}
impl CustomCompletionAction {
/// `Action.BY_ID`: `ByIdMap.continuous` with `OutOfBoundsStrategy.ZERO`,
/// so an unknown id is `ADD`.
pub fn from_id(id: i32) -> Self {
match id {
1 => Self::Remove,
2 => Self::Set,
_ => Self::Add,
}
}
}
/// `ClientSuggestionProvider.modifyCustomCompletions`: the server adds,
/// removes or replaces the extra chat completions.
pub fn modify_custom_completions(
set: &mut std::collections::BTreeSet<String>,
action: CustomCompletionAction,
entries: &[String],
) {
match action {
CustomCompletionAction::Add => set.extend(entries.iter().cloned()),
CustomCompletionAction::Remove => {
for e in entries {
set.remove(e);
}
}
CustomCompletionAction::Set => {
set.clear();
set.extend(entries.iter().cloned());
}
}
}
impl Registries {
/// `ClientSuggestionProvider.getCustomTabSuggestions`: what chat (not a
/// command) completes, the online players' names with the server's custom
/// completions, each once.
pub fn custom_tab_suggestions(&self) -> std::collections::BTreeSet<&str> {
self.players
.iter()
.chain(&self.custom_completions)
.map(String::as_str)
.collect()
}
/// Fill what the game did not build itself from vanilla's exported
/// registries (`command-registries.json`, written by
/// `VanillaCommandParseOracle registries`): built-in registries such as
+10 -3
View File
@@ -96,6 +96,8 @@ pub struct Style {
pub hover: Option<Box<Text>>,
/// `HoverEvent.ShowItem`: the event's `{id, count?, components?}` object.
pub hover_item: Option<Box<Value>>,
/// `HoverEvent.ShowEntity`: the event's `{id, uuid, name?}` object.
pub hover_entity: Option<Box<Value>>,
/// `Style.insertion`: the text a Shift-click puts in the chat box.
pub insertion: Option<String>,
/// The font (`FontDescription.Resource`), a full id like
@@ -127,11 +129,12 @@ impl Style {
if self.click.is_none() {
self.click.clone_from(&base.click);
}
if self.hover.is_none() {
// One HoverEvent: its three kinds are inherited together, so a style
// with its own event never picks up another kind from `base`.
if self.hover.is_none() && self.hover_item.is_none() && self.hover_entity.is_none() {
self.hover.clone_from(&base.hover);
}
if self.hover_item.is_none() {
self.hover_item.clone_from(&base.hover_item);
self.hover_entity.clone_from(&base.hover_entity);
}
if self.insertion.is_none() {
self.insertion.clone_from(&base.insertion);
@@ -591,6 +594,10 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
let mut event = item.clone();
event.insert("action".into(), "show_item".into());
out.push(("hover_event".to_owned(), Value::Object(event)));
} else if let Some(Value::Object(entity)) = style.hover_entity.as_deref() {
let mut event = entity.clone();
event.insert("action".into(), "show_entity".into());
out.push(("hover_event".to_owned(), Value::Object(event)));
}
if let Some(insertion) = &style.insertion {
out.push(("insertion".to_owned(), insertion.clone().into()));
+23 -3
View File
@@ -192,16 +192,30 @@ impl Assets {
pub fn reload(&self, root: &Path) -> Result<RenderAssets> {
RenderAssets::load(root, self)
}
/// [`Assets::reload`] for a server, which draws nothing: the item models
/// and the sprites' layers, without the block models or the textures'
/// pixels.
pub fn reload_for_server(&self, root: &Path) -> Result<RenderAssets> {
RenderAssets::load_with(root, self, false)
}
}
impl RenderAssets {
pub fn load(root: &Path, assets: &Assets) -> Result<Self> {
Self::load_with(root, assets, true)
}
fn load_with(root: &Path, assets: &Assets, draws: bool) -> Result<Self> {
let registry = assets.registry;
let mut sprites = crate::sprites::Sprites::with_disk_cache(root);
for name in MATERIALS {
sprites.insert(root, &format!("minecraft:block/{name}"))?;
}
let parsed = crate::model_library::ParsedModels::load(root);
let models = crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?;
// A server draws no block: its models (about 24 MB baked) stay empty.
let models = if draws {
crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?
} else {
crate::models::PreparedModels::default()
};
let model_library = parsed.into_item_library();
// Fluids are drawn by the fluid renderer, not block models; breaking
// progress uses the destroy-stage overlays.
@@ -218,7 +232,11 @@ impl RenderAssets {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
sprites.save_disk_cache();
sprites.finish();
if draws {
sprites.finish();
} else {
sprites.finish_without_pixels();
}
// `finish` grows the buffer by doubling: keep only the bytes in use.
sprites.pixels.shrink_to_fit();
Ok(Self {
@@ -241,6 +259,7 @@ impl RenderAssets {
#[cfg(test)]
mod tests {
use crate::items::ConsumeEffect;
use horizon_world::loot::PackOverlay;
#[test]
fn always_edible_and_consume_effects_loaded() {
@@ -251,7 +270,8 @@ mod tests {
}
let assets = crate::assets::Assets::load(&root).unwrap();
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let items = crate::items::Items::load(&root, assets.registry, &facts).unwrap();
let items =
crate::items::Items::load(&root, assets.registry, &facts, &PackOverlay::NONE).unwrap();
let apple = items.get("minecraft:golden_apple").expect("golden apple");
assert!(apple.can_always_eat);
+10
View File
@@ -165,6 +165,8 @@ pub struct Audio {
/// How many times each selection was stopped ("record *", "* minecraft:…", `*` for
/// any), with or without an audio device, as `heard`.
pub stopped: std::collections::BTreeMap<String, u32>,
/// The volume sliders' `SoundPreviewHandler`.
preview: crate::sound_preview::SoundPreview,
}
fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
use symphonia::core::{
@@ -634,6 +636,7 @@ impl Audio {
capture: None,
heard: Default::default(),
stopped: Default::default(),
preview: Default::default(),
}
}
#[cfg(not(feature = "audio"))]
@@ -817,6 +820,13 @@ impl Audio {
false,
);
}
/// `SoundPreviewHandler.preview`: a category volume slider moved to
/// `volume` on the title screen's options.
pub fn preview(&mut self, source: usize, volume: f32) {
let mut preview = std::mem::take(&mut self.preview);
preview.preview(self, source, volume);
self.preview = preview;
}
/// `play` with the seed a sound packet carries.
pub fn play_seeded(
&mut self,
+6 -6
View File
@@ -52,20 +52,20 @@ impl AuthSession {
/// The session file's JSON: {"name", "uuid", "access_token", "user_type": "msa", "xuid"}.
pub fn parse(text: &str) -> Result<Self, String> {
let v: serde_json::Value =
serde_json::from_str(text).map_err(|_| "the session file is not valid JSON")?;
serde_json::from_str(text).map_err(|_| "the session is not valid JSON")?;
let field = |k: &str| v.get(k).and_then(|s| s.as_str());
let name = field("name").ok_or("the session file has no name")?;
let name = field("name").ok_or("the session has no name")?;
if name.is_empty() || name.chars().count() > 16 {
return Err("the session file's name is not a valid player name".into());
return Err("the session's name is not a valid player name".into());
}
let uuid = field("uuid")
.and_then(parse_uuid)
.ok_or("the session file has no valid uuid")?;
.ok_or("the session has no valid uuid")?;
let access_token = field("access_token")
.filter(|t| !t.is_empty())
.ok_or("the session file has no access token")?;
.ok_or("the session has no access token")?;
if field("user_type").is_some_and(|t| t != "msa") {
return Err("the session file is not a Microsoft account session".into());
return Err("the session is not a Microsoft account session".into());
}
Ok(Self {
name: name.to_owned(),
+245 -55
View File
@@ -3,7 +3,12 @@
use crate::Result;
use horizon_core::{noise::SimplexNoise, position::BlockPos, random::LegacyRandom};
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path};
use std::{
collections::{BTreeMap, HashMap},
fs,
path::Path,
sync::Arc,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GrassModifier {
@@ -102,6 +107,9 @@ pub struct BiomeInfo {
/// The biome's own fog colour attribute; otherwise the dimension's applies.
pub fog: Option<u32>,
pub water_fog: u32,
/// `BiomeGenerationSettings.features`: the placed feature ids of each
/// `GenerationStep.Decoration` step, in order.
pub features: Vec<Vec<String>>,
}
impl BiomeInfo {
/// `Biome.getHeightAdjustedTemperature`: the frozen modifier, then cooler
@@ -130,10 +138,27 @@ impl BiomeInfo {
.then(|| self.temperature_at(BlockPos::new(x, y, z), 63) < 0.15)
}
}
/// The game's biomes, by id. The built-in (vanilla) biomes come first, in
/// file order, and keep their ids whatever a world's data packs do; a pack's
/// new biomes follow them (`with_packs`). Those are the client's own ids:
/// the network registry's are `registry_order`'s.
#[derive(Clone)]
pub struct Biomes {
pub list: Vec<BiomeInfo>,
pub index: HashMap<String, u16>,
info_noise: SimplexNoise,
colormaps: Arc<Colormaps>,
/// The built-in biomes, the first of `list` unless packs replaced them.
builtin: Arc<Vec<BiomeInfo>>,
/// The data packs' biome files (new biomes and replaced built-in ones),
/// for the tables that read more of a biome than `BiomeInfo` keeps.
packs: BTreeMap<String, Arc<Value>>,
}
/// The grass, foliage and dry foliage colormaps.
struct Colormaps {
grass: Vec<u32>,
foliage: Vec<u32>,
dry: Vec<u32>,
}
fn color(v: &Value) -> Option<u32> {
match v {
@@ -166,11 +191,90 @@ fn sample(map: &[u32], temperature: f32, downfall: f32, fallback: u32) -> u32 {
let index = (y << 8 | x) as usize;
map.get(index).copied().unwrap_or(fallback)
}
impl BiomeInfo {
/// A biome file's (`worldgen/biome/<name>.json`) values, the colours it
/// leaves out sampled from the colormaps.
fn from_json(name: String, v: &Value, maps: &Colormaps) -> Self {
let effects = &v["effects"];
let attributes = &v["attributes"];
let temperature = v["temperature"].as_f64().unwrap_or(0.5) as f32;
let downfall = v["downfall"].as_f64().unwrap_or(0.5) as f32;
let modifier = match effects["grass_color_modifier"].as_str() {
Some("dark_forest") => GrassModifier::DarkForest,
Some("swamp") => GrassModifier::Swamp,
_ => GrassModifier::None,
};
let mut spawns: HashMap<String, Vec<SpawnEntry>> = HashMap::new();
let natural = &attributes["minecraft:gameplay/natural_mob_spawns"];
// A modifier object ({argument, modifier: overlay}) or the plain value;
// the overlay over the dimension's empty default is the biome's value.
let natural = if natural["argument"].is_object() {
&natural["argument"]
} else {
natural
};
let categories = &natural["spawns_by_category"];
for (category, entries) in categories.as_object().into_iter().flatten() {
let list = parse_entries(entries);
spawns.insert(category.clone(), list);
}
BiomeInfo {
spawns,
spawn_costs: parse_costs(&natural["spawn_costs"]),
creature_probability:
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
.as_f64()
.unwrap_or(0.1) as f32,
increased_fire_burnout: attributes["minecraft:gameplay/increased_fire_burnout"]
.as_bool()
.unwrap_or(false),
temperature,
frozen: v["temperature_modifier"].as_str() == Some("frozen"),
downfall,
precipitation: v["has_precipitation"].as_bool().unwrap_or(true),
grass: color(&effects["grass_color"])
.unwrap_or_else(|| sample(&maps.grass, temperature, downfall, 0xFF00FF)),
modifier,
foliage: color(&effects["foliage_color"])
.unwrap_or_else(|| sample(&maps.foliage, temperature, downfall, 0x48B518)),
dry_foliage: color(&effects["dry_foliage_color"])
.unwrap_or_else(|| sample(&maps.dry, temperature, downfall, 0x5D4930)),
water: color(&effects["water_color"]).unwrap_or(0x3F76E4),
sky: color(&attributes["minecraft:visual/sky_color"]),
fog: color(&attributes["minecraft:visual/fog_color"]),
water_fog: color(&attributes["minecraft:visual/water_fog_color"]).unwrap_or(0x050533),
features: v["features"]
.as_array()
.into_iter()
.flatten()
.map(|step| {
step.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect()
})
.collect(),
name,
}
}
}
/// `Identifier.compareTo`: the path first, then the namespace.
pub fn identifier_order(a: &str, b: &str) -> std::cmp::Ordering {
fn split(id: &str) -> (&str, &str) {
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
(path, ns)
}
split(a).cmp(&split(b))
}
impl Biomes {
pub fn load(root: &Path) -> Result<Self> {
let grass = colormap(root, "grass")?;
let foliage = colormap(root, "foliage")?;
let dry = colormap(root, "dry_foliage")?;
let maps = Colormaps {
grass: colormap(root, "grass")?,
foliage: colormap(root, "foliage")?,
dry: colormap(root, "dry_foliage")?,
};
let mut list = Vec::new();
let mut names: Vec<_> = fs::read_dir(root.join("data/minecraft/worldgen/biome"))?
.filter_map(|e| e.ok())
@@ -186,57 +290,7 @@ impl Biomes {
.ok_or("bad biome")?
);
let v: Value = serde_json::from_slice(&fs::read(&path)?)?;
let effects = &v["effects"];
let attributes = &v["attributes"];
let temperature = v["temperature"].as_f64().unwrap_or(0.5) as f32;
let downfall = v["downfall"].as_f64().unwrap_or(0.5) as f32;
let modifier = match effects["grass_color_modifier"].as_str() {
Some("dark_forest") => GrassModifier::DarkForest,
Some("swamp") => GrassModifier::Swamp,
_ => GrassModifier::None,
};
let mut spawns: HashMap<String, Vec<SpawnEntry>> = HashMap::new();
let natural = &attributes["minecraft:gameplay/natural_mob_spawns"];
// A modifier object ({argument, modifier: overlay}) or the plain value;
// the overlay over the dimension's empty default is the biome's value.
let natural = if natural["argument"].is_object() {
&natural["argument"]
} else {
natural
};
let categories = &natural["spawns_by_category"];
for (category, entries) in categories.as_object().into_iter().flatten() {
let list = parse_entries(entries);
spawns.insert(category.clone(), list);
}
list.push(BiomeInfo {
spawns,
spawn_costs: parse_costs(&natural["spawn_costs"]),
creature_probability:
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
.as_f64()
.unwrap_or(0.1) as f32,
increased_fire_burnout: attributes["minecraft:gameplay/increased_fire_burnout"]
.as_bool()
.unwrap_or(false),
temperature,
frozen: v["temperature_modifier"].as_str() == Some("frozen"),
downfall,
precipitation: v["has_precipitation"].as_bool().unwrap_or(true),
grass: color(&effects["grass_color"])
.unwrap_or_else(|| sample(&grass, temperature, downfall, 0xFF00FF)),
modifier,
foliage: color(&effects["foliage_color"])
.unwrap_or_else(|| sample(&foliage, temperature, downfall, 0x48B518)),
dry_foliage: color(&effects["dry_foliage_color"])
.unwrap_or_else(|| sample(&dry, temperature, downfall, 0x5D4930)),
water: color(&effects["water_color"]).unwrap_or(0x3F76E4),
sky: color(&attributes["minecraft:visual/sky_color"]),
fog: color(&attributes["minecraft:visual/fog_color"]),
water_fog: color(&attributes["minecraft:visual/water_fog_color"])
.unwrap_or(0x050533),
name,
});
list.push(BiomeInfo::from_json(name, &v, &maps));
}
let index = list
.iter()
@@ -244,11 +298,65 @@ impl Biomes {
.map(|(i, b)| (b.name.clone(), i as u16))
.collect();
Ok(Self {
builtin: Arc::new(list.clone()),
list,
index,
colormaps: Arc::new(maps),
packs: BTreeMap::new(),
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
})
}
/// The built-in biomes with a world's data packs' biome files (id →
/// JSON) over them, as `RegistryDataLoader` builds the registry: a file
/// for a built-in biome replaces it, keeping its id, and new biomes join
/// after the built-in ones, in registry order. Starts from the built-in
/// table whatever packs `self` has.
pub fn with_packs<'a>(&self, files: impl IntoIterator<Item = (&'a str, &'a Value)>) -> Self {
let mut out = self.builtin_only();
let mut files: Vec<(&str, &Value)> = files.into_iter().collect();
files.sort_by(|a, b| identifier_order(a.0, b.0));
for (name, json) in files {
let info = BiomeInfo::from_json(name.to_owned(), json, &out.colormaps);
match out.index.get(name) {
Some(&id) => out.list[usize::from(id)] = info,
None if out.list.len() < usize::from(u16::MAX) => {
out.index.insert(name.to_owned(), out.list.len() as u16);
out.list.push(info);
}
None => continue,
}
out.packs.insert(name.to_owned(), Arc::new(json.clone()));
}
out
}
/// The built-in biomes alone: this table without what packs gave it.
pub fn builtin_only(&self) -> Self {
let mut out = self.clone();
if self.packs.is_empty() {
return out;
}
out.list = self.builtin.to_vec();
let builtin = out.list.len();
out.index.retain(|_, id| usize::from(*id) < builtin);
out.packs.clear();
out
}
/// The data pack file a world's biome `name` came from, if a pack added
/// or replaced it.
pub fn pack_file(&self, name: &str) -> Option<&Value> {
self.packs.get(name).map(|v| &**v)
}
/// Whether data packs added or replaced any biome.
pub fn has_packs(&self) -> bool {
!self.packs.is_empty()
}
/// The biome names in the network registry's order: `RegistryDataLoader`
/// sorts a registry's files by `Identifier` (path, then namespace).
pub fn registry_order(&self) -> Vec<&str> {
let mut names: Vec<&str> = self.list.iter().map(|b| b.name.as_str()).collect();
names.sort_by(|a, b| identifier_order(a, b));
names
}
pub fn id(&self, name: &str) -> u16 {
self.index
.get(name)
@@ -344,6 +452,88 @@ pub fn blend_rgb(weights: &[(u16, f64)], mut value: impl FnMut(u16) -> [f32; 3])
result.unwrap_or([0.0; 3])
}
#[cfg(test)]
mod pack_tests {
use super::*;
use serde_json::json;
/// A built-in table of `names` (no colormaps: sampled colours fall back).
fn builtin(names: &[&str]) -> Biomes {
let maps = Colormaps {
grass: Vec::new(),
foliage: Vec::new(),
dry: Vec::new(),
};
let list: Vec<BiomeInfo> = names
.iter()
.map(|n| BiomeInfo::from_json((*n).to_owned(), &json!({}), &maps))
.collect();
Biomes {
builtin: Arc::new(list.clone()),
index: list
.iter()
.enumerate()
.map(|(i, b)| (b.name.clone(), i as u16))
.collect(),
list,
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
colormaps: Arc::new(maps),
packs: BTreeMap::new(),
}
}
#[test]
fn registry_order_is_path_then_namespace() {
use std::cmp::Ordering::*;
assert_eq!(identifier_order("minecraft:a", "minecraft:b"), Less);
assert_eq!(identifier_order("z:a", "a:b"), Less);
assert_eq!(identifier_order("b:same", "a:same"), Greater);
assert_eq!(identifier_order("minecraft:a", "minecraft:a_b"), Less);
assert_eq!(identifier_order("minecraft:a-b", "minecraft:a"), Greater);
}
#[test]
fn packs_replace_in_place_and_add_after_the_builtin_biomes() {
let base = builtin(&["minecraft:meadow", "minecraft:plains", "minecraft:river"]);
let red = json!({"temperature": 2.0, "effects": {"water_color": "#ff0000"}});
let hot = json!({"effects": {"grass_color": 0x123456}});
let files = [
("pack:zeta", &hot),
("minecraft:molten_peaks", &hot),
("minecraft:river", &red),
];
let world = base.with_packs(files);
// The built-in ids stay; the river takes the pack's values.
assert_eq!(world.id("minecraft:plains"), 1);
assert_eq!(world.id("minecraft:river"), 2);
assert_eq!(world.get(2).water, 0xFF0000);
assert_eq!(world.get(2).temperature, 2.0);
// New biomes follow, in registry order.
assert_eq!(world.id("minecraft:molten_peaks"), 3);
assert_eq!(world.id("pack:zeta"), 4);
assert_eq!(world.get(3).grass, 0x123456);
assert!(world.pack_file("minecraft:river").is_some());
assert!(world.pack_file("minecraft:plains").is_none());
// The network registry sorts every name by path, then namespace.
assert_eq!(
world.registry_order(),
[
"minecraft:meadow",
"minecraft:molten_peaks",
"minecraft:plains",
"minecraft:river",
"pack:zeta"
]
);
// Another world starts again from the built-in biomes.
let other = world.with_packs([]);
assert_eq!(other.list.len(), 3);
assert!(!other.contains("minecraft:molten_peaks"));
assert_eq!(other.get(2).water, 0x3F76E4);
assert!(!other.has_packs());
}
}
#[cfg(test)]
mod blend_tests {
use super::*;
@@ -865,13 +865,23 @@ const VIEW_DISTANCE: f64 = 64.0;
impl BlockEntityRenderer {
pub fn load(root: &Path, registry: &BlockRegistry) -> crate::Result<Self> {
let visibility = crate::models::load_visibility(root, registry)?;
Self::load_with_visibility(root, registry, visibility)
}
/// [`BlockEntityRenderer::load`] over the face visibility the meshes use
/// (`MeshTables::visibility`): a clone shares its shapes and their faces.
pub fn load_with_visibility(
root: &Path,
registry: &BlockRegistry,
visibility: FaceVisibility,
) -> crate::Result<Self> {
let all: Value = serde_json::from_slice(&std::fs::read(root.join("entity-models.json"))?)?;
let models = MODELS
.iter()
.filter_map(|(key, layer)| all.get(*layer).map(|v| (*key, ModelPart::parse(v))))
.collect();
let mut renderer = Self::new(models, registry);
renderer.visibility = Some(Mutex::new(crate::models::load_visibility(root, registry)?));
renderer.visibility = Some(Mutex::new(visibility));
// Sherd items: provides_pottery_pattern -> decorated_pot_pattern asset id.
if let Ok(bytes) = std::fs::read(root.join("items.json")) {
let items: Value = serde_json::from_slice(&bytes)?;
@@ -1104,7 +1114,11 @@ impl BlockEntityRenderer {
pub fn renders(&self, state: u32) -> bool {
!matches!(self.kind(state), Kind::None)
}
/// ChestRenderer and BannerRenderer submit their animated models for crumbling.
/// `submitCrumblingOverlay` of ChestRenderer.submit, BannerRenderer.submit,
/// SkullBlockRenderer.submitSkull, ShulkerBoxRenderer.submit,
/// BellRenderer.submit, LecternRenderer.submit (the book) and
/// CopperGolemStatueBlockRenderer.submit: the model drawn again under the
/// destroy stage.
pub fn render_breaking(
&self,
scene: &Scene,
@@ -1113,7 +1127,16 @@ impl BlockEntityRenderer {
stage: [f32; 2],
out: &mut Vec<Vertex>,
) {
if !matches!(self.kind(state), Kind::Banner { .. } | Kind::Chest { .. }) {
if !matches!(
self.kind(state),
Kind::Banner { .. }
| Kind::Chest { .. }
| Kind::Skull { .. }
| Kind::Shulker { .. }
| Kind::Bell
| Kind::Lectern { .. }
| Kind::CopperGolem { .. }
) {
return;
}
let origin = (DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]))
+51 -16
View File
@@ -7,6 +7,7 @@ use crate::{
items::{ItemStack, Items},
providers::Providers,
};
use horizon_world::loot::PackOverlay;
use serde_json::Value;
use std::{collections::HashSet, fs, path::Path};
@@ -35,6 +36,22 @@ pub fn potion_name_key(item: &str, potion: Option<&str>) -> String {
let suffix = potion.and_then(self::potion).map_or("empty", |p| p.name);
format!("item.minecraft.{prefix}.effect.{suffix}")
}
/// `PotionContents.getAllEffects`: the potion's effects, then the stack's
/// `custom_effects` (id, duration, amplifier). Unknown effect ids are dropped.
pub fn contents_effects(stack: &ItemStack) -> Vec<(&'static str, i32, i32)> {
let potion = stack.potion().and_then(self::potion);
let custom = stack.custom_potion_effects();
potion
.map_or(&[][..], |d| d.effects)
.iter()
.copied()
.chain(
custom
.iter()
.filter_map(|(id, duration, amp)| Some((effect(id)?.id, *duration, *amp))),
)
.collect()
}
/// Roman amplifier suffix (`potion.potency.N`) and duration lines of a potion.
pub fn tooltip(
potion: Option<&str>,
@@ -142,20 +159,41 @@ pub struct Brewing {
pub inputs: HashSet<String>,
}
impl Brewing {
pub fn load(root: &Path, items: &Items) -> Self {
let mut files: Vec<_> = fs::read_dir(root.join("data/minecraft/recipe/brewing"))
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.collect();
files.sort();
let mut recipes = Vec::new();
for p in files {
let Ok(bytes) = fs::read(&p) else { continue };
let Ok(v) = serde_json::from_slice::<Value>(&bytes) else {
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Self {
let mut sources: Vec<(String, Value)> =
fs::read_dir(root.join("data/minecraft/recipe/brewing"))
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
let bytes = fs::read(e.path()).ok()?;
let v = serde_json::from_slice::<Value>(&bytes).ok()?;
Some((
format!(
"minecraft:brewing/{}",
e.file_name().to_string_lossy().strip_suffix(".json")?
),
v,
))
})
.collect();
// The world's packs' brewing recipes over the game's own: the top
// pack's file for each id replaces it (`SimpleJsonResourceReloadListener`).
for (id, files) in overlay.kind("recipe") {
if !id.starts_with("minecraft:brewing/") {
continue;
}
let Ok(v) = serde_json::from_slice::<Value>(PackOverlay::top(files)) else {
continue;
};
match sources.iter_mut().find(|(n, _)| n == id) {
Some(slot) => slot.1 = v,
None => sources.push((id.to_owned(), v)),
}
}
sources.sort_by(|a, b| a.0.cmp(&b.0));
let mut recipes = Vec::new();
for (id, v) in sources {
if v["type"] != "minecraft:brewing" {
continue;
}
@@ -169,10 +207,7 @@ impl Brewing {
output = output.with_potion(potion);
}
recipes.push(BrewingRecipe {
id: format!(
"minecraft:brewing/{}",
p.file_stem().and_then(|s| s.to_str()).unwrap_or("")
),
id,
input: PotionIngredient::parse(&v["input"], items),
reagent: PotionIngredient::parse(&v["reagent"], items),
output,
+121 -10
View File
@@ -21,6 +21,12 @@ const COMMAND_HISTORY_FILE: &str = "command_history.txt";
const MAX_PERSISTED_COMMAND_HISTORY: usize = 50;
/// GuiMessageTag.systemSinglePlayer's indicator colour.
pub const SYSTEM_INDICATOR: u32 = 0xD0D0D0;
/// A player message's signature (MessageSignature), which names it to a
/// server's deletion.
pub type Signature = horizon_protocol::secure_chat::Signature;
/// How long a message shows before a deletion replaces it
/// (`ChatComponent.deleteMessageOrDelay`: `addedTime + 60`).
const DELETION_DELAY: u64 = 60;
/// `ChatVisiblity`: the Chat option's three values.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -165,6 +171,8 @@ struct Message {
content: Text,
indicator: Option<u32>,
source: ChatSource,
/// `GuiMessage.signature`: a signed player message's, for deletion.
signature: Option<Box<Signature>>,
}
/// A wrapped line (`GuiMessage.Line`).
#[derive(Clone, Debug)]
@@ -205,6 +213,9 @@ pub struct Chat {
pub received: u64,
/// The Chat option's `visibleMessageFilter` (`set_abilities`).
abilities: ChatAbilities,
/// `ChatComponent.messageDeletionQueue`: deletions of messages still
/// too new to replace, with the tick from which they may be.
deletions: Vec<(Box<Signature>, u64)>,
}
impl Chat {
pub fn lines_per_page(&self, focused: bool) -> usize {
@@ -227,6 +238,38 @@ impl Chat {
) {
self.add_message(&atlas.font, content, indicator, source, ticks, focused);
}
/// `add` of a player message with its signature, which a server's
/// deletion names it by (`GuiMessage.signature`).
pub fn add_signed(
&mut self,
atlas: &HudAtlas,
content: Text,
signature: Option<Signature>,
ticks: u64,
focused: bool,
) {
self.add_signed_message(&atlas.font, content, signature, ticks, focused);
}
fn add_signed_message(
&mut self,
font: &crate::font::Font,
content: Text,
signature: Option<Signature>,
ticks: u64,
focused: bool,
) {
self.push_message(
font,
Message {
added: ticks,
content,
indicator: None,
source: ChatSource::Player,
signature: signature.map(Box::new),
},
focused,
);
}
fn add_message(
&mut self,
font: &crate::font::Font,
@@ -236,20 +279,28 @@ impl Chat {
ticks: u64,
focused: bool,
) {
if !self.abilities.shows(source) {
self.push_message(
font,
Message {
added: ticks,
content,
indicator,
source,
signature: None,
},
focused,
);
}
fn push_message(&mut self, font: &crate::font::Font, message: Message, focused: bool) {
if !self.abilities.shows(message.source) {
return;
}
// ChatComponent.logChatMessage: every message shown is in the log.
crate::logging::write_only(log::Level::Info, &format!("[CHAT] {}", content.plain()));
let plain = message.content.plain();
crate::logging::write_only(log::Level::Info, &format!("[CHAT] {plain}"));
if self.log {
println!("[CHAT] {}", content.plain());
println!("[CHAT] {plain}");
}
let message = Message {
added: ticks,
content,
indicator,
source,
};
self.add_lines(font, &message, focused);
self.messages.insert(0, message);
self.messages.truncate(100);
@@ -319,8 +370,66 @@ impl Chat {
pub fn clear(&mut self) {
self.messages.clear();
self.lines.clear();
self.deletions.clear();
self.scroll = 0;
}
/// `ChatComponent.deleteMessage`: the server deleted a message. One
/// shown for 60 ticks becomes the grey "deleted" marker now; a newer
/// one waits until then (`messageDeletionQueue`).
pub fn delete_message(&mut self, atlas: &HudAtlas, signature: &Signature, ticks: u64) {
self.delete_message_with(&atlas.font, signature, ticks);
}
fn delete_message_with(&mut self, font: &crate::font::Font, signature: &Signature, ticks: u64) {
if let Some(delayed) = self.delete_or_delay(font, signature, ticks) {
self.deletions.push((Box::new(*signature), delayed));
}
}
/// `ChatComponent.tick` (`processMessageDeletionQueue`): the delayed
/// deletions whose message has shown long enough.
pub fn tick(&mut self, atlas: &HudAtlas, ticks: u64) {
self.process_deletions(&atlas.font, ticks);
}
fn process_deletions(&mut self, font: &crate::font::Font, ticks: u64) {
let mut deletions = std::mem::take(&mut self.deletions);
deletions.retain(|(signature, after)| {
ticks < *after || self.delete_or_delay(font, signature, ticks).is_some()
});
self.deletions = deletions;
}
/// `ChatComponent.deleteMessageOrDelay`: the newest message with this
/// signature, replaced by `createDeletedMarker` once 60 ticks have
/// passed since it was added; otherwise the tick to retry from.
fn delete_or_delay(
&mut self,
font: &crate::font::Font,
signature: &Signature,
ticks: u64,
) -> Option<u64> {
let message = self
.messages
.iter_mut()
.find(|m| m.signature.as_deref() == Some(signature))?;
let deletable = message.added + DELETION_DELAY;
if ticks < deletable {
return Some(deletable);
}
*message = Message {
added: message.added,
content: Self::deleted_marker(),
indicator: Some(SYSTEM_INDICATOR),
source: ChatSource::Server,
signature: None,
};
// refreshTrimmedMessages.
self.rewrap(font);
None
}
/// `ChatComponent.DELETED_CHAT_MESSAGE`: grey, italic.
fn deleted_marker() -> Text {
Text::translate("chat.deleted_marker", &[])
.with_color(text::GRAY)
.italic()
}
/// `new CommandHistory(gameFolder)`: the commands kept from earlier runs
/// start the chat's history (`ChatComponent`'s constructor).
pub fn load_history(&mut self, game_folder: &std::path::Path) {
@@ -929,7 +1038,9 @@ impl ChatScreen {
} else if !command.chars().all(char::is_whitespace) {
let partial = &command[..cursor_b];
let mut builder = SuggestionsBuilder::new(partial, last_word_index(partial));
builder.suggest_matching(c.registries.players.iter().map(String::as_str));
// ClientSuggestionProvider.getCustomTabSuggestions: the players
// and the server's custom chat completions.
builder.suggest_matching(c.registries.custom_tab_suggestions());
let (range, list) = merge_suggestions(partial, builder.out);
self.awaiting = None;
self.pending = Some((range.0, list));
@@ -244,6 +244,58 @@ fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
assert_eq!(screen.value(), "/coords");
}
#[test]
fn chat_completes_the_servers_custom_completions_with_the_players() {
// CommandSuggestions.updateCommandInfo completes chat from
// ClientSuggestionProvider.getCustomTabSuggestions: the online players
// and what ClientboundCustomChatCompletionsPacket added.
use crate::command::{CustomCompletionAction as Action, modify_custom_completions};
let font = font();
let m = Measure(&font);
let mut registries = Registries {
players: vec!["Steve".into()],
..Registries::default()
};
let entries = |e: &[&str]| e.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let set = &mut registries.custom_completions;
modify_custom_completions(set, Action::from_id(0), &entries(&["Alex", "#trade"]));
modify_custom_completions(set, Action::Add, &entries(&["Notch", "Steve"]));
modify_custom_completions(set, Action::Remove, &entries(&["Alex"]));
// An id past the last action is ADD (OutOfBoundsStrategy.ZERO).
modify_custom_completions(set, Action::from_id(7), &entries(&["Zuri"]));
assert_eq!(
registries
.custom_tab_suggestions()
.into_iter()
.collect::<Vec<_>>(),
["#trade", "Notch", "Steve", "Zuri"]
);
let completed = |registries: &Registries, line: &str| {
let dispatcher = crate::command::vanilla::game_dispatcher();
let c = Commands {
dispatcher,
registries,
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
screen.type_text(&m, &c, line);
// Chat shows no list by itself: Tab shows it, Tab again uses it.
for _ in 0..2 {
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Tab, false, false);
}
screen.value().to_owned()
};
assert_eq!(completed(&registries, "hi no"), "hi Notch");
assert_eq!(completed(&registries, "sell #tr"), "sell #trade");
assert_eq!(completed(&registries, "hi st"), "hi Steve");
assert_eq!(completed(&registries, "hi al"), "hi al");
// SET replaces them all; the players stay.
let set = &mut registries.custom_completions;
modify_custom_completions(set, Action::Set, &entries(&["Alex"]));
assert_eq!(completed(&registries, "hi no"), "hi no");
assert_eq!(completed(&registries, "hi al"), "hi Alex");
assert_eq!(completed(&registries, "hi st"), "hi Steve");
}
#[test]
fn ask_server_nodes_wait_for_the_servers_completions() {
// ClientSuggestionProvider.customSuggestion: an `ask_server` node at the
@@ -467,3 +519,62 @@ fn a_guests_line_follows_the_player_message_permission() {
);
}
}
/// `ChatComponent.deleteMessageOrDelay`: a deleted message shows for 60
/// ticks from when it was added, then becomes the grey italic
/// `chat.deleted_marker` line with the system bar; the other lines stay.
#[test]
fn a_deleted_message_shows_60_ticks_then_becomes_the_marker() {
let font = font();
let mut chat = Chat::default();
let secret = [7; 256];
chat.add_signed_message(
&font,
Text::literal("<Steve> secret"),
Some(secret),
100,
false,
);
chat.add_signed_message(
&font,
Text::literal("<Alex> hi"),
Some([8; 256]),
110,
false,
);
let lines =
|chat: &Chat| -> Vec<String> { chat.lines.iter().map(|l| l.content.plain()).collect() };
let marker = Chat::deleted_marker().plain();
chat.delete_message_with(&font, &secret, 130);
assert_eq!(
lines(&chat),
["<Alex> hi", "<Steve> secret"],
"too new: delayed"
);
chat.process_deletions(&font, 159);
assert_eq!(lines(&chat), ["<Alex> hi", "<Steve> secret"]);
chat.process_deletions(&font, 160);
assert_eq!(lines(&chat), ["<Alex> hi".to_owned(), marker.clone()]);
assert!(chat.deletions.is_empty());
let line = &chat.lines[1];
assert_eq!(line.indicator, Some(SYSTEM_INDICATOR));
assert_eq!(line.added, 100, "the marker keeps the message's time");
assert!(
line.content
.spans
.iter()
.all(|s| s.style.color == Some(text::GRAY) && s.style.is_italic())
);
assert_eq!(
chat.messages[1].content.translation_key(),
Some("chat.deleted_marker")
);
// A message shown 60 ticks already is replaced at once; an unknown or
// already deleted signature changes nothing.
chat.delete_message_with(&font, &[8; 256], 170);
assert_eq!(lines(&chat), [marker.clone(), marker.clone()]);
chat.delete_message_with(&font, &secret, 200);
chat.delete_message_with(&font, &[9; 256], 200);
assert_eq!(lines(&chat), [marker.clone(), marker]);
assert!(chat.deletions.is_empty());
}
+356 -20
View File
@@ -8,10 +8,208 @@ use std::sync::{Arc, OnceLock};
pub mod block_entity;
/// The vanilla Overworld's column, which every dimension's chunks hold for
/// now (`LevelShape::column`).
pub const MIN_Y: i32 = -64;
pub const HEIGHT: i32 = 384;
pub const SECTIONS: usize = (HEIGHT / 16) as usize;
/// A level's build range and skylight, from its dimension type (`min_y`,
/// `height`, `logical_height`, `has_skylight`), as `LevelHeightAccessor` and
/// `SerializableChunkData.write` read them.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LevelShape {
pub sky_light: bool,
pub min_y: i32,
pub height: i32,
/// `DimensionType.logicalHeight`: how high portals and chorus fruit reach.
pub logical_height: i32,
}
impl LevelShape {
pub const OVERWORLD: Self = Self {
sky_light: true,
min_y: MIN_Y,
height: HEIGHT,
logical_height: HEIGHT,
};
pub const NETHER: Self = Self {
sky_light: false,
min_y: 0,
height: 256,
logical_height: 128,
};
pub const END: Self = Self {
sky_light: true,
min_y: 0,
height: 256,
logical_height: 256,
};
/// A dimension type's shape, checked as its codec checks it.
pub fn new(
min_y: i32,
height: i32,
logical_height: i32,
sky_light: bool,
) -> Result<Self, String> {
ColumnLayout::new(min_y, height)?;
if !(0..=height).contains(&logical_height) {
return Err(format!(
"logical_height {logical_height} is outside 0..={height}"
));
}
Ok(Self {
sky_light,
min_y,
height,
logical_height,
})
}
/// One above the highest block (`getMaxY() + 1`).
pub fn max_y(self) -> i32 {
self.min_y + self.height
}
pub fn min_section(self) -> i32 {
self.min_y >> 4
}
pub fn section_count(self) -> usize {
(self.height >> 4) as usize
}
/// Whether section `y` (a section coordinate) is inside the level.
pub fn has_section(self, y: i32) -> bool {
(self.min_section()..self.min_section() + self.section_count() as i32).contains(&y)
}
/// The sections a chunk of this level holds: the level's, widened to the
/// vanilla Overworld's `-64..320` so that vanilla's Nether and End keep
/// the 24 sections they have always had.
pub fn column(self) -> ColumnLayout {
ColumnLayout::of_level(self.min_y, self.height)
}
}
/// The shapes of a world's three levels, from its dimension types.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LevelShapes {
pub overworld: LevelShape,
pub nether: LevelShape,
pub end: LevelShape,
}
impl LevelShapes {
pub const VANILLA: Self = Self {
overworld: LevelShape::OVERWORLD,
nether: LevelShape::NETHER,
end: LevelShape::END,
};
/// The shape of the level `id` (`minecraft:overworld`,
/// `minecraft:the_nether`, `minecraft:the_end`); others get the
/// Overworld's.
pub fn get(&self, id: &str) -> LevelShape {
match id {
"minecraft:the_nether" => self.nether,
"minecraft:the_end" => self.end,
_ => self.overworld,
}
}
pub fn get_mut(&mut self, id: &str) -> &mut LevelShape {
match id {
"minecraft:the_nether" => &mut self.nether,
"minecraft:the_end" => &mut self.end,
_ => &mut self.overworld,
}
}
}
impl Default for LevelShapes {
fn default() -> Self {
Self::VANILLA
}
}
/// The span of block Y a chunk holds: from `min_y`, `sections` sections up
/// (`LevelShape::column`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ColumnLayout {
min_y: i32,
sections: usize,
}
impl ColumnLayout {
pub const VANILLA: Self = Self {
min_y: MIN_Y,
sections: SECTIONS,
};
/// The column of a level from `min_y`, `height` high
/// (`LevelShape::column`).
pub fn of_level(min_y: i32, height: i32) -> Self {
let low = min_y.min(MIN_Y);
let high = (min_y + height).max(MIN_Y + HEIGHT);
Self {
min_y: low,
sections: ((high - low) / 16) as usize,
}
}
/// `DimensionType.MIN_Y`, `MAX_Y`: 12 bits of `BlockPos` Y, less 32.
pub const LOWEST_Y: i32 = -2032;
pub const HIGHEST_Y: i32 = 2031;
/// A dimension type's `min_y` and `height`, as its codec accepts them:
/// multiples of 16, at least 16 high, within `LOWEST_Y..=HIGHEST_Y`.
pub fn new(min_y: i32, height: i32) -> Result<Self, String> {
if min_y % 16 != 0 || height % 16 != 0 {
return Err(format!(
"min_y {min_y} and height {height} must be multiples of 16"
));
}
if height < 16 {
return Err(format!("height {height} is below 16"));
}
if min_y < Self::LOWEST_Y
|| i64::from(min_y) + i64::from(height) > i64::from(Self::HIGHEST_Y) + 1
{
return Err(format!(
"min_y {min_y} with height {height} leaves {}..={}",
Self::LOWEST_Y,
Self::HIGHEST_Y
));
}
Ok(Self {
min_y,
sections: (height / 16) as usize,
})
}
pub fn min_y(self) -> i32 {
self.min_y
}
/// One above the highest block.
pub fn max_y(self) -> i32 {
self.min_y + self.height()
}
pub fn height(self) -> i32 {
self.sections as i32 * 16
}
pub fn sections(self) -> usize {
self.sections
}
/// The index of the section holding block `y`, if the column holds it.
pub fn section_index(self, y: i32) -> Option<usize> {
usize::try_from((y - self.min_y) >> 4)
.ok()
.filter(|&s| s < self.sections)
}
/// The minimum block Y of section `index`.
pub fn section_y(self, index: usize) -> i32 {
self.min_y + index as i32 * 16
}
}
/// `true` for each section of `layout`: what callers that keep no
/// per-section record (of fluids, say) pass as "every section may have it".
pub fn every_section(layout: ColumnLayout) -> &'static [bool] {
static ALL: [bool; crate::section_mask::SectionMask::CAPACITY] =
[true; crate::section_mask::SectionMask::CAPACITY];
&ALL[..layout.sections()]
}
impl Default for ColumnLayout {
fn default() -> Self {
Self::VANILLA
}
}
/// Biomes are 4x4x4 containers over the biome table (67 entries: a 7-bit
/// global palette).
pub const BIOME_STRATEGY: Strategy = Strategy::biomes(7);
@@ -45,6 +243,47 @@ pub fn free_columns<T: Send + 'static>(columns: T, count: usize) {
pub fn set_block_states(states: usize) {
let bits = horizon_core::paletted::ceil_log2(states).max(16);
BLOCK_BITS.store(bits, std::sync::atomic::Ordering::Relaxed);
forget_shared();
}
/// One allocation per kind of uniform section (a single block, a single
/// biome and one light value), shared by every column that has it, keyed by
/// (block, biome, light). A tall column is mostly such sections: air with
/// full sky light above the terrain. Edits copy them (`Arc::make_mut`).
type SharedSections = std::collections::HashMap<(u32, u32, u8), Arc<Section>>;
static SHARED: std::sync::Mutex<Option<SharedSections>> = std::sync::Mutex::new(None);
/// Replaces the uniform sections of `sections` with the shared ones.
pub fn share_uniform(sections: &mut [Arc<Section>]) {
let key = |s: &Section| -> Option<(u32, u32, u8)> {
let Light::Uniform(light) = s.light else {
return None;
};
if s.blocks.bits() != 0 || s.biomes.bits() != 0 {
return None;
}
Some((s.blocks.uniform()?, s.biomes.uniform()?, light))
};
let Ok(mut shared) = SHARED.lock() else {
return;
};
let shared = shared.get_or_insert_with(Default::default);
for section in sections {
let Some(k) = key(section) else {
continue;
};
let one = shared.entry(k).or_insert_with(|| section.clone());
if !Arc::ptr_eq(one, section) {
*section = one.clone();
}
}
}
/// Forgets the shared sections, when the palette changes.
fn forget_shared() {
if let Ok(mut shared) = SHARED.lock() {
*shared = None;
}
}
/// Packed light per block: sky light in the high nibble, block light in the low one.
#[derive(Clone)]
@@ -138,9 +377,11 @@ impl Section {
pub struct Chunk {
pub x: i32,
pub z: i32,
/// The minimum block Y of `sections[0]` (the layout's when it was made).
min_y: i32,
pub sections: Vec<Arc<Section>>,
/// `Heightmap.Types.WORLD_SURFACE` per column (`z << 4 | x`): one above
/// the highest non-air block, or `MIN_Y`. Built on the first read; a
/// the highest non-air block, or the column's minimum Y. Built on the first read; a
/// `set` that can move a column's surface drops it.
surface: OnceLock<Box<[i16; 256]>>,
/// `Heightmap.Types.MOTION_BLOCKING` per column, kept like `surface`.
@@ -169,13 +410,19 @@ pub struct Chunk {
pub structure_starts: Vec<Arc<horizon_world::worldgen::structure::start::StructureStart>>,
}
impl Chunk {
/// An all-air chunk in the current `layout`.
pub fn empty(x: i32, z: i32) -> Self {
Self::with_layout(x, z, ColumnLayout::VANILLA)
}
pub fn with_layout(x: i32, z: i32, layout: ColumnLayout) -> Self {
let mut one = [Arc::new(Section::default())];
share_uniform(&mut one);
let [empty] = one;
Self {
x,
z,
sections: (0..SECTIONS)
.map(|_| Arc::new(Section::default()))
.collect(),
min_y: layout.min_y(),
sections: (0..layout.sections()).map(|_| empty.clone()).collect(),
surface: OnceLock::new(),
motion_blocking: OnceLock::new(),
lit: false,
@@ -190,13 +437,36 @@ impl Chunk {
entities: Vec::new(),
}
}
/// The minimum block Y of the column.
#[inline]
pub fn min_y(&self) -> i32 {
self.min_y
}
/// The column the chunk holds.
pub fn layout(&self) -> ColumnLayout {
ColumnLayout {
min_y: self.min_y,
sections: self.sections.len(),
}
}
/// One above the column's highest block.
#[inline]
pub fn max_y(&self) -> i32 {
self.min_y + self.sections.len() as i32 * 16
}
/// The index of the section holding block `y`, if the column holds it.
#[inline]
pub fn section_index(&self, y: i32) -> Option<usize> {
usize::try_from((y - self.min_y) >> 4)
.ok()
.filter(|&s| s < self.sections.len())
}
#[inline]
pub fn get(&self, x: i32, y: i32, z: i32) -> u32 {
let s = (y - MIN_Y) >> 4;
if !(0..SECTIONS as i32).contains(&s) {
return 0;
match self.section_index(y) {
Some(s) => self.sections[s].get(index(x, y, z)),
None => 0,
}
self.sections[s as usize].get(index(x, y, z))
}
#[inline]
pub fn block(&self, x: i32, y: i32, z: i32) -> BlockStateId {
@@ -211,16 +481,15 @@ impl Chunk {
.rev()
.filter(|(_, section)| !section.is_uniform(skip))
.find_map(|(s, _)| {
let base = MIN_Y + s as i32 * 16;
let base = self.min_y + s as i32 * 16;
(base..base + 16).rev().find(|&y| matches(y))
})
}
pub fn set(&mut self, x: i32, y: i32, z: i32, v: u32) -> u32 {
let s = (y - MIN_Y) >> 4;
if !(0..SECTIONS as i32).contains(&s) {
let Some(s) = self.section_index(y) else {
return v;
}
let previous = Arc::make_mut(&mut self.sections[s as usize]).set(index(x, y, z), v);
};
let previous = Arc::make_mut(&mut self.sections[s]).set(index(x, y, z), v);
// `Heightmap.update`: a write two or more below a map's height can't
// move it. Any other change rebuilds that map on the next read.
if previous != v {
@@ -236,7 +505,8 @@ impl Chunk {
previous
}
/// `getHeight(Heightmap.Types.WORLD_SURFACE, x, z)` (`x`, `z` within the
/// chunk): one above the highest block that isn't `air`, or `MIN_Y`.
/// chunk): one above the highest block that isn't `air`, or the column's
/// minimum Y.
/// Sections must change only through `set` once this has been read.
pub fn world_surface(&self, x: i32, z: i32, air: impl Fn(u32) -> bool) -> i32 {
let surface = self.surface.get_or_init(|| self.prime(|v| !air(v)));
@@ -244,7 +514,8 @@ impl Chunk {
}
/// `getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)` (`x`, `z` within
/// the chunk): one above the highest state where `blocks` (a motion
/// blocking block or a fluid), or `MIN_Y`. Kept as `world_surface` is.
/// blocking block or a fluid), or the column's minimum Y. Kept as
/// `world_surface` is.
pub fn motion_blocking(&self, x: i32, z: i32, blocks: impl Fn(u32) -> bool) -> i32 {
let heights = self.motion_blocking.get_or_init(|| self.prime(blocks));
i32::from(heights[column(x, z)])
@@ -252,7 +523,7 @@ impl Chunk {
/// `Heightmap.primeHeightmaps`: each section from the top, skipping
/// those of one state that doesn't count, until every column is found.
fn prime(&self, counts: impl Fn(u32) -> bool) -> Box<[i16; 256]> {
let mut surface = Box::new([MIN_Y as i16; 256]);
let mut surface = Box::new([self.min_y as i16; 256]);
let mut found = [false; 256];
let mut left = 256;
let mut values = [0u32; 4096];
@@ -264,7 +535,7 @@ impl Chunk {
continue;
}
section.unpack(&mut values);
let base = MIN_Y + s as i32 * 16;
let base = self.min_y + s as i32 * 16;
for i in 0..256 {
if found[i] {
continue;
@@ -280,11 +551,11 @@ impl Chunk {
}
#[inline]
pub fn light(&self, x: i32, y: i32, z: i32) -> u8 {
let s = (y - MIN_Y) >> 4;
let s = (y - self.min_y) >> 4;
if s < 0 {
return 0;
}
if s >= SECTIONS as i32 {
if s >= self.sections.len() as i32 {
return 0xF0;
}
self.sections[s as usize].light(index(x, y, z))
@@ -297,7 +568,7 @@ impl Chunk {
/// the quart y clamped to the column).
#[inline]
pub fn biome(&self, x: i32, y: i32, z: i32) -> u16 {
let qy = ((y - MIN_Y) >> 2).clamp(0, HEIGHT / 4 - 1);
let qy = ((y - self.min_y) >> 2).clamp(0, self.sections.len() as i32 * 4 - 1);
self.sections[(qy >> 2) as usize]
.biomes
.get(((qy & 3) << 4 | ((z & 15) >> 2) << 2 | (x & 15) >> 2) as usize) as u16
@@ -504,4 +775,69 @@ mod tests {
}
}
}
#[test]
fn shapes_follow_the_dimension_type_codec() {
assert_eq!(ColumnLayout::new(-64, 384), Ok(ColumnLayout::VANILLA));
assert_eq!(ColumnLayout::default(), ColumnLayout::VANILLA);
// To The Max's Overworld: the highest top vanilla allows.
let tall = ColumnLayout::new(-64, 2096).unwrap();
assert_eq!((tall.sections(), tall.max_y()), (131, 2032));
assert!(ColumnLayout::new(-2032, 4064).is_ok());
for (min_y, height) in [(-64, 2112), (-2048, 64), (-60, 384), (0, 250), (0, 0)] {
assert!(
ColumnLayout::new(min_y, height).is_err(),
"{min_y} {height}"
);
}
assert_eq!(tall.section_index(-65), None);
assert_eq!(tall.section_index(-64), Some(0));
assert_eq!(tall.section_index(2031), Some(130));
assert_eq!(tall.section_index(2032), None);
assert_eq!(tall.section_y(130), 2016);
assert_eq!(
LevelShape::new(-64, 384, 384, true),
Ok(LevelShape::OVERWORLD)
);
assert_eq!(LevelShape::new(0, 256, 128, false), Ok(LevelShape::NETHER));
assert!(LevelShape::new(0, 256, 257, true).is_err());
assert!(LevelShape::new(-60, 384, 384, true).is_err());
}
#[test]
fn vanilla_levels_keep_the_overworld_column() {
for shape in [LevelShape::OVERWORLD, LevelShape::NETHER, LevelShape::END] {
assert_eq!(shape.column(), ColumnLayout::VANILLA, "{shape:?}");
}
let tall = LevelShape::new(-64, 2096, 2096, true).unwrap();
assert_eq!(tall.column(), ColumnLayout::new(-64, 2096).unwrap());
let deep = LevelShape::new(-512, 256, 256, false).unwrap();
assert_eq!(deep.column(), ColumnLayout::new(-512, 832).unwrap());
assert!(LevelShape::NETHER.has_section(15) && !LevelShape::NETHER.has_section(16));
assert!(!LevelShape::NETHER.has_section(-1));
}
#[test]
fn a_tall_column_stores_its_whole_height() {
let layout = ColumnLayout::new(-64, 2096).unwrap();
let mut chunk = Chunk::with_layout(0, 0, layout);
assert_eq!(
(chunk.min_y(), chunk.max_y(), chunk.sections.len()),
(-64, 2032, 131)
);
let stone = 1;
assert_eq!(chunk.set(5, 2031, 6, stone), 0);
assert_eq!(chunk.set(5, 2032, 6, stone), stone, "above the column");
assert_eq!(chunk.get(5, 2031, 6), stone);
assert_eq!(chunk.get(5, 2032, 6), 0);
assert_eq!(chunk.world_surface(5, 6, |v| v == 0), 2032);
assert_eq!(chunk.world_surface(0, 0, |v| v == 0), -64);
assert_eq!(chunk.highest(0, |y| y < 1000), None);
chunk.set(5, 999, 6, stone);
assert_eq!(chunk.highest(0, |y| y < 1000), Some(999));
assert_eq!(chunk.light(0, 2100, 0), 0xF0);
assert_eq!(chunk.light(0, -65, 0), 0);
chunk.set_biome_cell(0, 130 * 4 + 3, 0, 9);
assert_eq!(chunk.biome(0, 5000, 0), 9);
}
}
@@ -82,6 +82,9 @@ impl crate::light::LightWorld for ChunkMap {
fn set_section_light(&mut self, cx: i32, s: usize, cz: i32, light: crate::chunk::Light) {
self.columns.set_section_light(cx, s, cz, light);
}
fn layout(&self) -> crate::chunk::ColumnLayout {
self.columns.layout()
}
}
#[cfg(test)]
@@ -305,7 +305,7 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
/// From the section's palette, without reading its blocks; a palette
/// of the whole registry reads every cell.
fn may_reach_out(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result<bool> {
let blocks = usize::try_from(section_y - (crate::chunk::MIN_Y >> 4))
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
.ok()
.and_then(|s| chunk.sections.get(s))
.map(|section| &section.blocks);
+47 -14
View File
@@ -296,8 +296,9 @@ fn memory() -> String {
}
}
/// The report's details: Horizon's and the machine's, then what the game set as it ran.
pub fn system_details() -> Vec<(String, String)> {
/// The report's details: Horizon's and the machine's, then what the game set as it ran. A dedicated
/// server renders nothing: it leaves out the render settings it read from options.txt.
pub fn system_details(side: &str) -> Vec<(String, String)> {
let mut out = vec![
("Horizon Version".to_owned(), version()),
(
@@ -308,18 +309,30 @@ pub fn system_details() -> Vec<(String, String)> {
("CPU".to_owned(), cpu()),
("Memory".to_owned(), memory()),
];
out.extend(details().into_iter().map(|(k, v)| (k.to_owned(), v)));
out.extend(
details()
.into_iter()
.filter(|(k, _)| side != "server" || *k != "Render Settings")
.map(|(k, v)| (k.to_owned(), v)),
);
out
}
fn thread_name() -> String {
/// The thread's name as vanilla's reports say it: the main thread is the client's "Render thread"
/// and the dedicated server's "Server thread".
fn thread_name(side: &str) -> String {
let current = std::thread::current();
match current.name() {
Some("main") | None if side == "server" => "Server thread".into(),
Some("main") | None => "Render thread".into(),
Some(name) => name.into(),
}
}
/// What starts the stderr line naming a saved report, the path follows: the Launch API's
/// promise to launchers (docs/api/launch.md), so it never changes within a line of that API.
pub const SAVED_LINE: &str = "Horizon: crash report saved to ";
/// Writes the report and tells where it is; the log gets the error too.
fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
if REPORTED.swap(true, Ordering::SeqCst) {
@@ -343,7 +356,7 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
crate::telemetry::keep_crash(home, &report);
match report.save(home) {
Ok(path) => {
eprintln!("Horizon: crash report saved to {}", path.display());
eprintln!("{SAVED_LINE}{}", path.display());
Some(path)
}
Err(e) => {
@@ -365,9 +378,9 @@ pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Opti
description: "Watching Server".into(),
error: format!("{message} (Watching Server)"),
stack: String::new(),
thread: thread_name(),
thread: thread_name(side),
side,
details: system_details(),
details: system_details(side),
log: crate::logging::recent_lines(),
},
)
@@ -476,9 +489,9 @@ pub fn panic_report(info: &std::panic::PanicHookInfo, side: &'static str) -> Rep
&std::backtrace::Backtrace::force_capture().to_string(),
true,
),
thread: thread_name(),
thread: thread_name(side),
side,
details: system_details(),
details: system_details(side),
log: crate::logging::recent_lines(),
}
}
@@ -499,7 +512,7 @@ pub fn install(home: PathBuf, side: &'static str) {
} else {
log::error!(
"Caught previously unhandled exception in thread {}: {}",
thread_name(),
thread_name(side),
panic_message(info)
);
}
@@ -548,9 +561,9 @@ pub fn report_error(
description: DebugCrash::MESSAGE.into(),
error: DebugCrash::MESSAGE.into(),
stack: debug.stack.clone(),
thread: thread_name(),
thread: thread_name(side),
side,
details: system_details(),
details: system_details(side),
log: crate::logging::recent_lines(),
},
);
@@ -568,9 +581,9 @@ pub fn report_error(
description: "Fatal error".into(),
error: message,
stack: String::new(),
thread: thread_name(),
thread: thread_name(side),
side,
details: system_details(),
details: system_details(side),
log: crate::logging::recent_lines(),
},
)
@@ -668,6 +681,26 @@ mod tests {
));
}
#[test]
fn a_dedicated_servers_report_names_its_server_thread_and_no_render_settings() {
let unnamed = |side: &'static str| {
std::thread::spawn(move || thread_name(side))
.join()
.unwrap()
};
assert_eq!(unnamed("server"), "Server thread");
assert_eq!(unnamed("client"), "Render thread");
set_detail("Render Settings", "render distance 12");
let has = |side| {
system_details(side)
.iter()
.any(|(k, _)| k == "Render Settings")
};
assert!(has("client"));
assert!(!has("server"));
clear_detail("Render Settings");
}
#[test]
fn stacks_start_at_the_panicking_frame() {
let bt = format!(
+53 -10
View File
@@ -423,9 +423,31 @@ pub struct DataStore {
pub predicates: BTreeMap<String, serde_json::Value>,
/// `item_modifier` files, for `/item modify`.
pub item_modifiers: BTreeMap<String, serde_json::Value>,
/// The content tables' raw files from the packs after the built-in one:
/// `recipe/`, `loot_table/`, `advancement/` and `tags/…/` files, by
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
/// merge them over what they read from the resource root.
pub files: BTreeMap<String, Vec<Vec<u8>>>,
/// Functions that failed to parse (id, message), for logging.
pub errors: Vec<(String, String)>,
}
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
/// `recipe/`, `loot_table/`, `advancement/` or `tags/<sub>/`, where the id
/// keeps the sub path so a nested tag kind stays addressable.
fn content_id(file: &str) -> Option<String> {
let rest = file.strip_prefix("data/")?;
let (ns, rest) = rest.split_once('/')?;
let (kind, rest) = rest.split_once('/')?;
let kind = match kind {
"recipe" | "loot_table" | "advancement" => kind,
"tags" => {
let (sub, name) = rest.rsplit_once('/')?;
return Some(format!("tags/{sub}/{ns}:{}", name.strip_suffix(".json")?));
}
_ => return None,
};
Some(format!("{kind}/{ns}:{}", rest.strip_suffix(".json")?))
}
/// `ns:path` for a file `data/<ns>/<kind>/<path>.<ext>`.
fn resource_id(file: &str, kind: &str, ext: &str) -> Option<String> {
let rest = file.strip_prefix("data/")?;
@@ -440,7 +462,22 @@ impl DataStore {
pub fn load(packs: &[&DataPack]) -> Self {
let mut s = Self::default();
for pack in packs {
for f in pack.list_kinds(&["function", "item_modifier", "predicate", "tags/function"]) {
// `tags` walks the whole tags tree, so a `tags/function` file is
// listed by both kinds: handle every file once.
let mut seen = std::collections::BTreeSet::new();
let builtin = pack.id == "vanilla";
for f in pack.list_kinds(&[
"advancement",
"function",
"item_modifier",
"loot_table",
"predicate",
"recipe",
"tags",
]) {
if !seen.insert(f.clone()) {
continue;
}
if let Some(id) = resource_id(&f, "function", ".mcfunction")
&& let Some(bytes) = pack.read(&f)
{
@@ -470,6 +507,14 @@ impl DataStore {
&& let Ok(v) = serde_json::from_slice(&bytes)
{
s.item_modifiers.insert(id, v);
} else if !builtin
&& let Some(id) = content_id(&f)
&& let Some(bytes) = pack.read(&f)
{
// The built-in pack's content files are the resource
// root's own, which the tables read from disk; only the
// world's packs come through here.
s.files.entry(id).or_default().push(bytes);
}
}
}
@@ -591,11 +636,6 @@ pub fn worldgen_overrides(
let read = || String::from_utf8(pack.read(&file)?).ok();
match out.add_file(&pack.id, &file, read) {
PackFile::Read | PackFile::Other => {}
PackFile::DimensionType { id, min_y, height } => warnings.push(format!(
"{}: dimension type {id} (min_y {min_y}, height {height}) is not used: \
the dimension keeps its vanilla build height",
pack.id
)),
PackFile::NotRead(kind) => {
*not_read.entry(format!("{}: {kind}", pack.id)).or_default() += 1
}
@@ -668,10 +708,13 @@ mod tests {
);
let tag_packs: Vec<&str> = o.tags("biome_tags").map(|(_, p, _)| p).collect();
assert_eq!(tag_packs, ["file/b", "file/a"]);
assert_eq!(warnings.len(), 3, "{warnings:?}");
assert!(warnings[0].contains("height 2096"));
assert!(warnings[1].contains("minecraft:new_peaks"));
assert!(warnings[2].contains("worldgen/world_preset"));
assert_eq!(warnings.len(), 2, "{warnings:?}");
assert!(warnings[0].contains("minecraft:new_peaks"));
assert!(warnings[1].contains("worldgen/world_preset"));
assert_eq!(
o.level_height("minecraft:overworld").map(|h| h.height),
Some(2096)
);
let _ = std::fs::remove_dir_all(&dir);
}
use horizon_world::nbt::parse_snbt;
+29 -10
View File
@@ -148,6 +148,15 @@ pub fn instant(id: &str, amp: i32, scale: f64) -> Option<EffectAction> {
_ => None,
}
}
/// `MobEffectInstance.withScaledDuration`: max(floor(d * scale), 1), leaving
/// an infinite (-1) or zero duration alone.
pub fn scaled_duration(duration: i32, scale: f32) -> i32 {
if duration == -1 || duration == 0 {
duration
} else {
((duration as f32 * scale).floor() as i32).max(1)
}
}
/// What an omen's `applyEffectTick` decided.
#[derive(Clone, Debug, PartialEq)]
pub enum OmenTick {
@@ -308,19 +317,29 @@ impl PlayerEffects {
pub fn potion_effects(
potion: Option<&str>,
scale: f32,
) -> (Vec<EffectInstance>, Vec<EffectAction>) {
let effects = potion
.and_then(potions::potion)
.map_or(&[][..], |d| d.effects);
Self::contents_effects(effects, scale)
}
/// `PotionContents.forEachEffect`'s effects, as `potion_effects`.
pub fn contents_effects(
effects: &[(&'static str, i32, i32)],
scale: f32,
) -> (Vec<EffectInstance>, Vec<EffectAction>) {
let mut lasting = Vec::new();
let mut now = Vec::new();
if let Some(def) = potion.and_then(potions::potion) {
for (id, duration, amp) in def.effects {
let id = potions::effect(id).map_or("speed", |e| e.id);
if potions::effect(id).is_some_and(|e| e.instant) {
now.extend(instant(id, *amp, 1.0));
} else {
// withScaledDuration: max(floor(d * scale), 1).
let d = ((*duration as f32) * scale).floor() as i32;
lasting.push(EffectInstance::new(id, d.max(1), *amp));
}
for (id, duration, amp) in effects {
let id = potions::effect(id).map_or("speed", |e| e.id);
if potions::effect(id).is_some_and(|e| e.instant) {
now.extend(instant(id, *amp, 1.0));
} else {
lasting.push(EffectInstance::new(
id,
scaled_duration(*duration, scale),
*amp,
));
}
}
(lasting, now)
+8 -4
View File
@@ -311,10 +311,14 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
fog.cloud_end = fog.environmental_end;
}
FogType::PowderSnow => {
fog.environmental_start = 0.0;
fog.environmental_end = 2.0;
fog.sky_end = 2.0;
fog.cloud_end = 2.0;
// PowderedSnowFogEnvironment.setupFog.
(fog.environmental_start, fog.environmental_end) = if input.spectator {
(-8.0, rd * 0.5)
} else {
(0.0, 2.0)
};
fog.sky_end = fog.environmental_end;
fog.cloud_end = fog.environmental_end;
}
_ if input.blindness.is_some() => {
let (duration, infinite) = input.blindness.unwrap_or((0, true));
+247 -109
View File
@@ -7,6 +7,7 @@ use crate::{
graphics::GraphicsApi,
hud::{HudBatch, HudRenderer},
mesher::{SectionMesh, Sides, TerrainVertex, Vertex},
section_mask::SectionMask,
sprites::SpriteAnimation,
};
use glam::{DVec3, Mat4, Vec3, Vec4};
@@ -74,6 +75,7 @@ pub mod compositor;
mod cull;
mod end_sky;
mod flat;
mod frame_gen;
mod hdr;
pub mod lightning;
mod lod;
@@ -105,8 +107,6 @@ mod view;
mod wgsl_prefix;
mod world_post;
/// A column whose every section has its mesh (`Renderer::lod_detail_columns`).
const COMPLETE_COLUMN: u32 = (1 << (crate::chunk::HEIGHT / 16)) - 1;
/// A section's instance: its camera-relative origin and, in `w`, the step of
/// its fixed-point UVs (`terrain.wgsl`'s `terrain_uv`).
/// The world pass again after occlusion culling's test: it loads both
@@ -517,7 +517,7 @@ pub struct Renderer {
lod_retiring: crate::fast_hash::FxHashSet<SectionKey>,
/// Finished tile meshes `upload_lods` has yet to upload.
lod_pending: Option<lod::Pending>,
detail_columns: crate::fast_hash::FxHashMap<(i32, i32), u32>,
detail_columns: crate::fast_hash::FxHashMap<(i32, i32), SectionMask>,
/// Bumped whenever a column becomes or stops being complete.
detail_revision: u64,
arena: arena::Arena,
@@ -556,6 +556,9 @@ pub struct Renderer {
block_entity_revision: u64,
/// VisGraph face connectivity per meshed section (cave culling).
occlusion: occlusion::Occlusion,
/// The column `occlusion`, `sections` and `detail_columns` count in: the
/// level's (`clear_sections`).
layout: crate::chunk::ColumnLayout,
/// Resolution scale, FXAA and TAA (Horizon settings).
world_post: world_post::Post,
/// HDR output (Horizon setting): `Some` while the swapchain is HDR.
@@ -588,6 +591,10 @@ pub struct Renderer {
readback: readback::Readback,
pub info: wgpu::AdapterInfo,
pub suspended: bool,
/// Consecutive frames whose surface acquisition failed validation. The
/// first gets the swapchain a fresh configuration; later ones reattach
/// the window, as a lost surface does.
acquire_validation_failures: u32,
pub drawn_sections: usize,
/// Last frame's section draws (face ranges and translucent sections),
/// and the draw calls the backend issued for them.
@@ -790,9 +797,14 @@ fn mip_chain(
/// pass is also what passes each draw's first instance to shaders reading
/// `instance_index`, and DirectX 12 is the fallback, where working matters
/// more than CPU time.
/// `allow_nonconformant` offers the adapters wgpu hides for reporting no Vulkan
/// conformance version: Mesa's hasvk, which drives Ivy Bridge and Haswell,
/// reports 0.0.0.0 while it works. `open_device` asks for them only on a second
/// try at a backend that had no adapter at all.
fn instance_descriptor(
backends: wgpu::Backends,
window: Option<&Arc<Window>>,
allow_nonconformant: bool,
) -> wgpu::InstanceDescriptor {
let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
if let Some(window) = window {
@@ -805,7 +817,13 @@ fn instance_descriptor(
.flags
.remove(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL);
}
descriptor.with_env()
let mut descriptor = descriptor.with_env();
if allow_nonconformant {
descriptor
.flags
.insert(wgpu::InstanceFlags::ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER);
}
descriptor
}
/// The window's display connection, so the OpenGL backend creates its EGL
/// display from the X11 or Wayland connection the window lives on. Without
@@ -944,96 +962,147 @@ async fn open_device(
if attempt > 0 {
log::warn!("Horizon renderer: trying {name} ({})", failures.join("; "));
}
let instance = wgpu::Instance::new(instance_descriptor(backends, Some(window)));
let surface = if offscreen {
None
} else {
match instance.create_surface(window.clone()) {
Ok(surface) => Some(surface),
Err(e) => {
failures.push(format!("{name} surface: {e}"));
continue;
}
}
};
let adapter = match instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: surface.as_ref(),
force_fallback_adapter: false,
..Default::default()
})
.await
{
Ok(adapter) => adapter,
Err(e) => {
failures.push(format!("{name}: {e}"));
continue;
}
};
describe_adapter(&adapter);
let info = adapter.get_info();
let limits = adapter.limits();
if let Err(e) = check_layers(&limits, layers) {
failures.push(format!("{name}: {e}"));
continue;
}
// Mappable primary buffers: section pages that no longer fit in
// video memory go to system memory. Shader packs' 16-bit normalized buffers (render
// targets with the adapter's own format features) and R11F_G11F_B10F: a float
// stand-in neither clamps nor drops NaN.
let features = timing::GpuTimer::features(&adapter)
| cull::Cull::features(&adapter)
| (adapter.features()
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
| wgpu::Features::MAPPABLE_PRIMARY_BUFFERS
| wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES
| wgpu::Features::RG11B10UFLOAT_RENDERABLE));
let driver = format!("{} {}", info.driver, info.driver_info);
startup_step(format_args!(
"requesting the device (driver {}) with features [{features}] and the adapter's \
limits (buffers up to {} bytes, storage bindings up to {} bytes, {} texture layers)",
match driver.trim() {
"" => "unnamed",
driver => driver,
},
limits.max_buffer_size,
limits.max_storage_buffer_binding_size,
limits.max_texture_array_layers,
));
let desc = wgpu::DeviceDescriptor {
label: Some("Horizon device"),
required_features: features,
required_limits: limits,
..Default::default()
};
#[cfg(not(target_os = "macos"))]
let opened = match info.backend {
wgpu::Backend::Vulkan => horizon_hdr::open_vulkan_device(&adapter, &desc),
_ => None,
};
#[cfg(target_os = "macos")]
let opened = None;
let opened = match opened {
Some(opened) => opened,
None => adapter.request_device(&desc).await,
};
match opened {
Ok((device, queue)) => {
return Ok(Opened {
instance,
surface,
adapter,
device,
queue,
});
}
Err(e) => failures.push(format!("{name} device: {e}")),
match open_with_retry(backends, window, offscreen, layers).await {
Ok(opened) => return Ok(opened),
Err(e) => failures.push(e),
}
}
Err(format!("{NO_GPU_DRIVER} ({})", failures.join("; ")).into())
}
/// Why a backend gave no device: no adapter at all, which a second try with the
/// adapters wgpu hides can still find, or a failure after an adapter.
enum NoDevice {
NoAdapter(String),
Failed(String),
}
/// Opens a device on one backend, and on a backend whose adapters wgpu all hid
/// for reporting no Vulkan conformance version tries it again with them shown:
/// Mesa's hasvk drives Ivy Bridge and Haswell, works, and reports 0.0.0.0, so
/// without this the player is told they have no graphics driver while Vulkan
/// runs (a `gl not compatible with provided surface` fallback, or nothing, is
/// all they get). Returns why the backend gave no device.
async fn open_with_retry(
backends: wgpu::Backends,
window: &Arc<Window>,
offscreen: bool,
layers: u32,
) -> std::result::Result<Opened, String> {
match open_backend(backends, window, offscreen, false, layers).await {
Err(NoDevice::NoAdapter(hidden)) => {
log::warn!(
"Horizon renderer: {} has no conformant adapter ({hidden}); asking again for the \
ones wgpu hides as non-conformant",
backends_name(backends)
);
match open_backend(backends, window, offscreen, true, layers).await {
Ok(opened) => {
log::warn!(
"Horizon renderer: using {} although its driver reports no Vulkan \
conformance version",
opened.adapter.get_info().name
);
Ok(opened)
}
Err(NoDevice::NoAdapter(e)) | Err(NoDevice::Failed(e)) => Err(e),
}
}
Err(NoDevice::Failed(e)) => Err(e),
Ok(opened) => Ok(opened),
}
}
/// Opens a device on `backends` alone: its surface, adapter and device, or what
/// stopped it. `allow_nonconformant` is `open_with_retry`'s second try.
async fn open_backend(
backends: wgpu::Backends,
window: &Arc<Window>,
offscreen: bool,
allow_nonconformant: bool,
layers: u32,
) -> std::result::Result<Opened, NoDevice> {
let name = backends_name(backends);
let instance = wgpu::Instance::new(instance_descriptor(
backends,
Some(window),
allow_nonconformant,
));
let surface = if offscreen {
None
} else {
match instance.create_surface(window.clone()) {
Ok(surface) => Some(surface),
Err(e) => return Err(NoDevice::Failed(format!("{name} surface: {e}"))),
}
};
let adapter = match instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: surface.as_ref(),
force_fallback_adapter: false,
..Default::default()
})
.await
{
Ok(adapter) => adapter,
Err(e) => return Err(NoDevice::NoAdapter(format!("{name}: {e}"))),
};
describe_adapter(&adapter);
let info = adapter.get_info();
let limits = adapter.limits();
if let Err(e) = check_layers(&limits, layers) {
return Err(NoDevice::Failed(format!("{name}: {e}")));
}
// Mappable primary buffers: section pages that no longer fit in
// video memory go to system memory. Shader packs' 16-bit normalized buffers (render
// targets with the adapter's own format features) and R11F_G11F_B10F: a float
// stand-in neither clamps nor drops NaN.
let features = timing::GpuTimer::features(&adapter)
| cull::Cull::features(&adapter)
| (adapter.features()
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
| wgpu::Features::MAPPABLE_PRIMARY_BUFFERS
| wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES
| wgpu::Features::RG11B10UFLOAT_RENDERABLE));
let driver = format!("{} {}", info.driver, info.driver_info);
startup_step(format_args!(
"requesting the device (driver {}) with features [{features}] and the adapter's \
limits (buffers up to {} bytes, storage bindings up to {} bytes, {} texture layers)",
match driver.trim() {
"" => "unnamed",
driver => driver,
},
limits.max_buffer_size,
limits.max_storage_buffer_binding_size,
limits.max_texture_array_layers,
));
let desc = wgpu::DeviceDescriptor {
label: Some("Horizon device"),
required_features: features,
required_limits: limits,
..Default::default()
};
#[cfg(not(target_os = "macos"))]
let opened = match info.backend {
wgpu::Backend::Vulkan => horizon_hdr::open_vulkan_device(&adapter, &desc),
_ => None,
};
#[cfg(target_os = "macos")]
let opened = None;
let opened = match opened {
Some(opened) => opened,
None => adapter.request_device(&desc).await,
};
match opened {
Ok((device, queue)) => Ok(Opened {
instance,
surface,
adapter,
device,
queue,
}),
Err(e) => Err(NoDevice::Failed(format!("{name} device: {e}"))),
}
}
/// What to tell a player whose system offers no usable graphics adapter.
/// Vulkan is loaded at run time (the game links no graphics library), so a
/// missing driver shows here rather than as a missing-DLL dialog.
@@ -2528,10 +2597,19 @@ impl Renderer {
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &TERRAIN_ATTRIBUTES,
})];
let info = adapter.get_info();
let min_sections = if flat::is_broken_intel_vulkan(&info)
|| std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1")
{
0
} else {
flat::MIN_SECTIONS
};
flat::Flat::new(
&device,
&bindings,
arena.page_vertices(),
min_sections,
|layout, label, fragment, blend| {
pipeline_in(
layout,
@@ -2744,6 +2822,7 @@ impl Renderer {
block_entities: Default::default(),
block_entity_revision: next_block_entity_revision(),
occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16),
layout: crate::chunk::ColumnLayout::VANILLA,
world_post,
hdr: None,
hdr_output,
@@ -2761,6 +2840,7 @@ impl Renderer {
readback: readback::Readback::default(),
info,
suspended: !drawable_size(size.width, size.height, max_side),
acquire_validation_failures: 0,
drawn_sections: 0,
section_draws: 0,
section_draw_calls: 0,
@@ -3180,7 +3260,8 @@ impl Renderer {
pub fn block_entity_revision(&self) -> u64 {
self.block_entity_revision
}
pub fn clear_sections(&mut self) {
/// Drops every section, for a level whose chunks hold `layout`.
pub fn clear_sections(&mut self, layout: crate::chunk::ColumnLayout) {
self.block_entities.clear();
self.block_entity_revision = next_block_entity_revision();
self.sections.clear();
@@ -3196,12 +3277,23 @@ impl Renderer {
}
self.sorted_indices.clear();
self.translucency_cursor = 0;
self.occlusion.clear();
if layout == self.layout {
self.occlusion.clear();
} else {
self.layout = layout;
self.occlusion = occlusion::Occlusion::new(layout.min_y(), layout.sections() as i32);
self.sections.set_min_y(layout.min_y());
}
}
/// A column whose every section has its mesh (`lod_detail_columns`).
fn complete_column(&self) -> SectionMask {
SectionMask::below(self.layout.sections())
}
pub fn lod_detail_columns(&self) -> impl Iterator<Item = (i32, i32)> + '_ {
let complete = self.complete_column();
self.detail_columns
.iter()
.filter_map(move |(&column, &mask)| (mask == COMPLETE_COLUMN).then_some(column))
.filter_map(move |(&column, &mask)| (mask == complete).then_some(column))
}
/// Changes whenever `lod_detail_columns` does.
pub fn lod_detail_revision(&self) -> u64 {
@@ -3210,16 +3302,22 @@ impl Renderer {
/// Whether the section at `key` has its mesh, for `lod_detail_columns`.
fn set_detail(&mut self, key: SectionKey, meshed: bool) {
let column = (key.0, key.2);
let mask = self.detail_columns.get(&column).copied().unwrap_or(0);
let next = if meshed {
mask | 1 << key.1
let mask = self
.detail_columns
.get(&column)
.copied()
.unwrap_or_default();
let mut next = mask;
if meshed {
next.insert(key.1 as usize);
} else {
mask & !(1 << key.1)
};
if (mask == COMPLETE_COLUMN) != (next == COMPLETE_COLUMN) {
next.remove(key.1 as usize);
}
let complete = self.complete_column();
if (mask == complete) != (next == complete) {
self.detail_revision += 1;
}
if next == 0 {
if next.is_empty() {
self.detail_columns.remove(&column);
} else {
self.detail_columns.insert(column, next);
@@ -3232,12 +3330,12 @@ impl Renderer {
let mut released = Vec::new();
let mut block_entities = false;
for (x, z) in columns {
if self.detail_columns.remove(&(x, z)) == Some(COMPLETE_COLUMN) {
if self.detail_columns.remove(&(x, z)) == Some(self.complete_column()) {
self.detail_revision += 1;
}
self.occlusion.remove_column(x, z);
let retiring = !self.lod_cutout.is_empty() && self.lod_cutout.contains(&(x, z));
for sy in 0..crate::chunk::SECTIONS as i32 {
for sy in 0..self.layout.sections() as i32 {
let key = (x, sy, z);
block_entities |= self.block_entities.remove(&key).is_some();
if retiring {
@@ -3744,9 +3842,32 @@ impl Renderer {
return Ok(false);
}
Some(wgpu::CurrentSurfaceTexture::Validation) => {
return Err("surface acquisition failed validation".into());
// Acquire caught a validation error: the swapchain cannot be
// used as configured (wgpu reports a configuration raced with
// a submission this way). Give it a fresh configuration like
// an Outdated one, and once that has not helped, reattach the
// window like a Lost one — a fullscreen loading screen then
// skips frames instead of failing every one of them.
if self.acquire_validation_failures == 0 {
if let Some(surface) = &self.surface {
Self::configure(
&self.device,
surface,
&self.surface_config(),
self.hdr.as_ref(),
);
}
} else {
self.recreate_surface()?;
}
self.acquire_validation_failures =
self.acquire_validation_failures.saturating_add(1);
return Ok(false);
}
};
if surface_frame.is_some() {
self.acquire_validation_failures = 0;
}
let size = [self.config.width, self.config.height];
let target_texture = match (&surface_frame, &mut self.hdr) {
(Some(_), Some(hdr)) => hdr.frame(&self.device, size),
@@ -3927,7 +4048,7 @@ impl Renderer {
planes: frustum(cull_proj),
};
let camera = frame.camera;
let camera_section = camera_section(camera);
let camera_section = camera_section(camera, self.layout.min_y());
// Solid quads facing away from the eye are left out: the bob moves
// the eye off `camera`, so it is found from the projection.
let eye = projection_center(cull_proj).filter(|_| self.sides_on);
@@ -4141,7 +4262,7 @@ impl Renderer {
let scheduled = (i + count - self.translucency_cursor) % count < budget;
let origin = [
v.key.0 * 16,
crate::chunk::MIN_Y + v.key.1 * 16,
self.layout.min_y() + v.key.1 * 16,
v.key.2 * 16,
];
if (!s.sort.is_sorted() || nearby || scheduled)
@@ -4244,7 +4365,7 @@ impl Renderer {
{
let origin = [
v.key.0 * 16,
crate::chunk::MIN_Y + v.key.1 * 16,
self.layout.min_y() + v.key.1 * 16,
v.key.2 * 16,
];
if let Some(indices) =
@@ -5745,10 +5866,10 @@ impl Frustum {
/// are GREATER_THAN_OR_EQUAL), which keeps far faces a hair apart, like
/// fire_side's 0.01 pixel in front of its block, from z-fighting.
/// The section the camera is in.
fn camera_section(camera: DVec3) -> SectionKey {
fn camera_section(camera: DVec3, min_y: i32) -> SectionKey {
(
(camera.x.floor() as i32) >> 4,
(camera.y.floor() as i32 - crate::chunk::MIN_Y) >> 4,
(camera.y.floor() as i32 - min_y) >> 4,
(camera.z.floor() as i32) >> 4,
)
}
@@ -5926,6 +6047,23 @@ mod tests {
assert_eq!(order(api, "macos"), [Backends::default()]);
}
}
/// The second try at a backend shows the adapters wgpu hides for reporting
/// no Vulkan conformance version (Mesa's hasvk, which drives Ivy Bridge and
/// Haswell, reports 0.0.0.0 and works).
#[test]
fn the_second_try_at_a_backend_shows_nonconformant_adapters() {
let shown = |allow| {
super::instance_descriptor(wgpu::Backends::VULKAN, None, allow)
.flags
.contains(wgpu::InstanceFlags::ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER)
};
assert!(shown(true));
// The first try keeps wgpu's own filtering, unless `with_env` turns the
// flag on from the environment (WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER).
if std::env::var_os("WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER").is_none() {
assert!(!shown(false));
}
}
/// Every WGSL shader file translates to HLSL as wgpu's DirectX 12 backend
/// writes it for FXC (shader model 5.1), entry point by entry point.
#[test]
@@ -6081,7 +6219,7 @@ mod tests {
#[test]
fn indirect_draws_are_validated_only_in_debug_builds_and_on_directx_12() {
let validated = |backends| {
super::instance_descriptor(backends, None)
super::instance_descriptor(backends, None, false)
.flags
.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL)
};
@@ -147,7 +147,7 @@ impl SpriteAnimator {
group,
});
}
let Some(pipeline) = pipeline else {
let Some((pipeline, layout)) = pipeline else {
let groups = keys
.iter()
.zip(&counts)
@@ -193,7 +193,6 @@ impl SpriteAnimator {
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let layout = pipeline.get_bind_group_layout(0);
let groups = keys
.iter()
.zip(&counts)
@@ -364,11 +363,15 @@ impl SpriteAnimator {
}
}
/// `animate.wgsl`'s pipeline, unless the device fails to make it: an OpenGL
/// driver can list compute shaders among its extensions while wgpu writes
/// the GLSL version of its core version, which has none (Mesa's
/// `MESA_GL_VERSION_OVERRIDE`, older drivers).
fn pipeline(device: &wgpu::Device) -> Option<wgpu::ComputePipeline> {
/// `animate.wgsl`'s pipeline and the bind group layout of its group 0,
/// unless the device fails to make the pipeline: an OpenGL driver can list
/// compute shaders among its extensions while wgpu writes the GLSL version
/// of its core version, which has none (Mesa's `MESA_GL_VERSION_OVERRIDE`,
/// older drivers). A device lost between the scopes' pushes answers pipeline
/// creation with a silent placeholder and reports its invalidity only when
/// the layout is asked for — inside the scopes that error is caught, outside
/// them it is uncaptured and panics (#3866).
fn pipeline(device: &wgpu::Device) -> Option<(wgpu::ComputePipeline, wgpu::BindGroupLayout)> {
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -383,9 +386,10 @@ fn pipeline(device: &wgpu::Device) -> Option<wgpu::ComputePipeline> {
compilation_options: Default::default(),
cache: None,
});
let layout = pipeline.get_bind_group_layout(0);
let internal = pollster::block_on(internal.pop());
match internal.or(pollster::block_on(validation.pop())) {
None => Some(pipeline),
None => Some((pipeline, layout)),
Some(e) => {
log::warn!("Horizon renderer: animated sprites drawn on the CPU ({e})");
None
+17 -7
View File
@@ -115,7 +115,7 @@ pub struct Flat {
/// (9,300 draws) the CPU saves 1.3 ms. The bar was 4,096 draws when they
/// were 2.2 to 2.5 per section; a section's draw count now depends on the
/// sides it shows the eye (`mesher::Sides`), so it counts sections.
const MIN_SECTIONS: usize = 1600;
pub const MIN_SECTIONS: usize = 1600;
/// Whether flat draws apply: the backend emulates multi-draw, and vertex
/// shaders can read storage buffers large enough for an index page.
@@ -126,7 +126,20 @@ pub fn wanted(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
Ok("1") => return supported(adapter, device),
_ => {}
}
adapter.get_info().backend == wgpu::Backend::Metal && supported(adapter, device)
let info = adapter.get_info();
(info.backend == wgpu::Backend::Metal || is_broken_intel_vulkan(&info))
&& supported(adapter, device)
}
/// Intel Gen11 (Ice Lake) ANV Vulkan driver has broken `first_instance` for instance-step
/// vertex attributes (#3529), causing terrain sections to be drawn with another section's origin
/// and flicker.
pub fn is_broken_intel_vulkan(info: &wgpu::AdapterInfo) -> bool {
info.backend == wgpu::Backend::Vulkan
&& info.vendor == 0x8086
&& (info.name.contains("ICL")
|| info.name.contains("Ice Lake")
|| (info.device >= 0x8a50 && info.device <= 0x8a71))
}
fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
@@ -254,6 +267,7 @@ impl Flat {
device: &wgpu::Device,
terrain_layout: &wgpu::BindGroupLayout,
page_vertices: u64,
min_sections: usize,
pipeline: impl Fn(
&wgpu::PipelineLayout,
&str,
@@ -357,11 +371,7 @@ impl Flat {
phase1_calls: 0,
solid_calls: 0,
shadow_calls: [0; 2],
min_sections: if std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1") {
0
} else {
MIN_SECTIONS
},
min_sections,
range_align: (u64::from(device.limits().min_storage_buffer_offset_alignment) / 16)
.max(1),
}
@@ -0,0 +1,7 @@
//! Frame generation: FSR 3.1's frame interpolation will show a frame halfway between each
//! two real ones. So far this holds its optical flow; the interpolation and the setting
//! that runs them come next.
// Unused until the interpolation and the renderer use it.
#[allow(dead_code)]
mod optical_flow;
@@ -0,0 +1,653 @@
// This file is part of the FidelityFX SDK.
//
// Copyright (C) 2024 Advanced Micro Devices, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files(the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and /or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions :
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
//! FSR 3.1's optical flow (AMD FidelityFX SDK 1.1.4, `ffx_opticalflow*`, MIT), ported to
//! WGSL (`optical_flow.wgsl`): block motion between the previous frame's colour and this
//! one's, and scene change detection, for frame interpolation (`interpolation.rs`).
//!
//! As in `ffx_opticalflow.cpp`: luma at the display size and 6 halvings, ping-ponged
//! between frames; a histogram of the luma for scene change detection; then from the
//! coarsest of 7 levels down, a block search seeded by the level above, a 3x3 filter and
//! a scale to the next level. The ported passes differ only where WGSL needs it: the luma
//! levels are `R32Float` holding AMD's 8-bit integers, wave operations are workgroup
//! atomics, and the luma pyramid is one dispatch per level instead of one for all (SPD).
//! The luma textures aren't cleared on a reset (AMD's are): its first 6 frames report a
//! scene change and no motion, and rewrite them.
//!
//! The vectors point from a block of this frame to where it was in the previous one, in
//! display pixels: content moving right by 4 pixels gives (-4, 0).
/// The size in pixels of the square blocks the flow has one vector for.
pub const BLOCK_SIZE: u32 = 8;
/// `OpticalFlowMaxPyramidLevels`: the search's levels.
const LEVELS: usize = 7;
/// `HistogramBins * HistogramsPerDim²`: the scene change histograms.
const HISTOGRAM_WIDTH: u64 = 256 * 9;
/// `optical_flow.wgsl`'s `Params`.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Params {
display: [i32; 2],
level: u32,
level_count: u32,
frame_index: u32,
transfer_function: u32,
min_max_luminance: [f32; 2],
}
struct Pipelines {
prepare_luma: wgpu::ComputePipeline,
luma_pyramid: wgpu::ComputePipeline,
scd_histogram: wgpu::ComputePipeline,
scd_divergence: wgpu::ComputePipeline,
search: wgpu::ComputePipeline,
filter: wgpu::ComputePipeline,
scale: wgpu::ComputePipeline,
}
/// One frame parity's bindings: which luma pyramid is this frame's.
struct Frame {
/// This frame's luma at the display size, where `prepare_luma` writes.
luma0: wgpu::TextureView,
luma_pyramid: [wgpu::BindGroup; LEVELS - 1],
scd_histogram: wgpu::BindGroup,
search: [wgpu::BindGroup; LEVELS],
scale: [wgpu::BindGroup; LEVELS - 1],
}
pub struct OpticalFlow {
display: [u32; 2],
pipelines: Pipelines,
/// One per level, the level's constants (the passes outside the search use level 0's).
params: Vec<wgpu::Buffer>,
/// The bindings of the frames whose luma lands in pyramid 0, and in pyramid 1.
frames: [Frame; 2],
filter: [wgpu::BindGroup; LEVELS],
scd_divergence: wgpu::BindGroup,
scd_histogram: wgpu::Buffer,
scd_previous_histogram: wgpu::Buffer,
scd_temp: wgpu::Buffer,
scd_output: wgpu::Buffer,
vectors: wgpu::TextureView,
flow_sizes: [[u32; 2]; LEVELS],
/// `resourceFrameIndex` and `frameIndex`.
resource_frame: u32,
frame_index: u32,
first: bool,
}
fn texture(
device: &wgpu::Device,
label: &str,
size: [u32; 2],
format: wgpu::TextureFormat,
) -> wgpu::TextureView {
device
.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: size[0].max(1),
height: size[1].max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
})
.create_view(&Default::default())
}
fn buffer(device: &wgpu::Device, label: &str, size: u64) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size,
usage: wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::COPY_DST
| wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
})
}
/// Bind `entries` (binding number, resource) for `pipeline`'s own layout.
fn bind(
device: &wgpu::Device,
pipeline: &wgpu::ComputePipeline,
entries: &[(u32, wgpu::BindingResource)],
) -> wgpu::BindGroup {
let entries: Vec<_> = entries
.iter()
.map(|(binding, resource)| wgpu::BindGroupEntry {
binding: *binding,
resource: resource.clone(),
})
.collect();
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Optical flow"),
layout: &pipeline.get_bind_group_layout(0),
entries: &entries,
})
}
fn view(v: &wgpu::TextureView) -> wgpu::BindingResource<'_> {
wgpu::BindingResource::TextureView(v)
}
fn whole(b: &wgpu::Buffer) -> wgpu::BindingResource<'_> {
b.as_entire_binding()
}
impl OpticalFlow {
/// For frames of `display` size.
pub fn new(device: &wgpu::Device, display: [u32; 2]) -> Self {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Optical flow"),
source: wgpu::ShaderSource::Wgsl(include_str!("optical_flow.wgsl").into()),
});
let pipeline = |entry: &str| {
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some(entry),
layout: None,
module: &module,
entry_point: Some(entry),
compilation_options: Default::default(),
cache: None,
})
};
let pipelines = Pipelines {
prepare_luma: pipeline("prepare_luma"),
luma_pyramid: pipeline("luma_pyramid"),
scd_histogram: pipeline("scd_histogram_pass"),
scd_divergence: pipeline("scd_divergence_pass"),
search: pipeline("search_pass"),
filter: pipeline("filter_flow"),
scale: pipeline("scale_pass"),
};
let params: Vec<_> = (0..LEVELS)
.map(|_| {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Optical flow constants"),
size: std::mem::size_of::<Params>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
})
.collect();
let luma: [Vec<wgpu::TextureView>; 2] = [0, 1].map(|_| {
(0..LEVELS)
.map(|k| {
let size = display.map(|v| (v >> k).max(1));
texture(
device,
"Optical flow luma",
size,
wgpu::TextureFormat::R32Float,
)
})
.collect()
});
let mut flow_sizes = [[0; 2]; LEVELS];
flow_sizes[0] = display.map(|v| v.div_ceil(BLOCK_SIZE));
for l in 1..LEVELS {
flow_sizes[l] = flow_sizes[l - 1].map(|v| v.div_ceil(2));
}
let flow = |label| -> Vec<wgpu::TextureView> {
flow_sizes
.iter()
.map(|&size| texture(device, label, size, wgpu::TextureFormat::Rg32Sint))
.collect()
};
// Per level: the scaled prediction from the level above, the search's vectors and
// the filtered ones (level 0's are the output).
let (predicted, searched, filtered) = (
flow("Optical flow prediction"),
flow("Optical flow search"),
flow("Optical flow"),
);
let scd_histogram = buffer(device, "Scene change histogram", HISTOGRAM_WIDTH * 4);
let scd_previous_histogram = buffer(
device,
"Scene change previous histogram",
HISTOGRAM_WIDTH * 4,
);
let scd_temp = buffer(device, "Scene change temp", 12);
let scd_output = buffer(device, "Scene change detection", 12);
let frame = |current: usize| {
let (cur, prev) = (&luma[current], &luma[1 - current]);
Frame {
luma0: cur[0].clone(),
luma_pyramid: std::array::from_fn(|k| {
bind(
device,
&pipelines.luma_pyramid,
&[(2, view(&cur[k + 1])), (3, view(&cur[k]))],
)
}),
scd_histogram: bind(
device,
&pipelines.scd_histogram,
&[
(0, whole(&params[0])),
(3, view(&cur[0])),
(7, whole(&scd_histogram)),
],
),
search: std::array::from_fn(|l| {
bind(
device,
&pipelines.search,
&[
(0, whole(&params[l])),
(3, view(&cur[l])),
(4, view(&prev[l])),
(5, view(&predicted[l])),
(6, view(&searched[l])),
(10, whole(&scd_output)),
],
)
}),
scale: std::array::from_fn(|i| {
let l = i + 1;
bind(
device,
&pipelines.scale,
&[
(0, whole(&params[l])),
(3, view(&cur[l])),
(4, view(&prev[l])),
(5, view(&filtered[l])),
(6, view(&predicted[l - 1])),
(10, whole(&scd_output)),
],
)
}),
}
};
let frames = [frame(0), frame(1)];
let filter = std::array::from_fn(|l| {
bind(
device,
&pipelines.filter,
&[(5, view(&searched[l])), (6, view(&filtered[l]))],
)
});
let scd_divergence = bind(
device,
&pipelines.scd_divergence,
&[
(7, whole(&scd_histogram)),
(8, whole(&scd_previous_histogram)),
(9, whole(&scd_temp)),
(10, whole(&scd_output)),
],
);
Self {
display,
pipelines,
params,
frames,
filter,
scd_divergence,
scd_histogram,
scd_previous_histogram,
scd_temp,
scd_output,
vectors: filtered[0].clone(),
flow_sizes,
resource_frame: 0,
frame_index: 0,
first: true,
}
}
/// The flow from the previous call's `color` to this one's. `color`: `display` size,
/// a float texture holding Horizon's frame (gamma-encoded RGB, the swapchain's
/// values: AMD's SDR back buffer). `reset`: the previous frame is unrelated (first
/// frame, resize, teleport).
pub fn dispatch(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
color: &wgpu::TextureView,
reset: bool,
) {
let reset = reset || self.first;
self.first = false;
self.frame_index = if reset { 0 } else { self.frame_index + 1 };
if reset {
for b in [
&self.scd_temp,
&self.scd_output,
&self.scd_histogram,
&self.scd_previous_histogram,
] {
encoder.clear_buffer(b, 0, None);
}
}
for (level, buffer) in self.params.iter().enumerate() {
let params = Params {
display: self.display.map(|v| v as i32),
level: level as u32,
level_count: LEVELS as u32,
frame_index: self.frame_index,
transfer_function: 0,
min_max_luminance: [0.0, 1.0],
};
queue.write_buffer(buffer, 0, bytemuck::bytes_of(&params));
}
let frame = &self.frames[(self.resource_frame & 1) as usize];
let prepare = bind(
device,
&self.pipelines.prepare_luma,
&[
(0, whole(&self.params[0])),
(1, view(color)),
(2, view(&frame.luma0)),
],
);
let p = &self.pipelines;
let [w, h] = self.display;
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Optical flow"),
timestamp_writes: None,
});
let mut run = |pipeline: &wgpu::ComputePipeline, group: &wgpu::BindGroup, x, y, z| {
pass.set_pipeline(pipeline);
pass.set_bind_group(0, group, &[]);
pass.dispatch_workgroups(x, y, z);
};
run(
&p.prepare_luma,
&prepare,
(w.div_ceil(2)).div_ceil(16),
(h.div_ceil(2)).div_ceil(16),
1,
);
for (k, group) in frame.luma_pyramid.iter().enumerate() {
let size = self.display.map(|v| (v >> (k + 1)).max(1));
run(
&p.luma_pyramid,
group,
size[0].div_ceil(8),
size[1].div_ceil(8),
1,
);
}
run(
&p.scd_histogram,
&frame.scd_histogram,
((w / 4) / 3).div_ceil(32),
16,
9,
);
run(&p.scd_divergence, &self.scd_divergence, 9, 3, 1);
for level in (0..LEVELS).rev() {
let luma = self.display.map(|v| (v >> level).max(1));
run(
&p.search,
&frame.search[level],
(luma[0].div_ceil(4) * 16).div_ceil(64),
luma[1].div_ceil(16),
1,
);
let size = self.flow_sizes[level];
run(
&p.filter,
&self.filter[level],
size[0].div_ceil(16),
size[1].div_ceil(4),
1,
);
if level > 0 {
let next = self.flow_sizes[level - 1];
run(
&p.scale,
&frame.scale[level - 1],
next[0].div_ceil(4),
next[1].div_ceil(4),
1,
);
}
}
drop(pass);
self.resource_frame = (self.resource_frame + 1) % 16;
}
/// This frame's vectors, one per `BLOCK_SIZE` block: `Rg32Sint`, the display size
/// over `BLOCK_SIZE` rounded up,
/// holding the values FSR's `R16G16_SINT` optical flow vector texture holds.
pub fn vectors(&self) -> &wgpu::TextureView {
&self.vectors
}
/// FSR's scene change detection texture (`R32_UINT`, 3 texels) as a storage
/// buffer of 3 `u32`: element `i` holds texel `i`'s value.
pub fn scene_change(&self) -> &wgpu::Buffer {
&self.scd_output
}
}
#[cfg(test)]
pub(super) mod tests {
use super::*;
#[test]
fn shader_validates() {
let module = naga::front::wgsl::parse_str(include_str!("optical_flow.wgsl")).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::empty(),
)
.validate(&module)
.unwrap();
}
/// A device with WebGPU's default limits (what every GPU wgpu runs on has), or `None`
/// without a GPU.
pub fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
/// Noise in 4x4 pixel cells, moved by `shift` pixels: what was at `p` is at `p + shift`.
/// A `seed` of 100 or more gives cells of one primary each.
pub fn pattern(size: [u32; 2], seed: u32, shift: [i32; 2]) -> Vec<u8> {
let mut pixels = Vec::with_capacity((size[0] * size[1] * 4) as usize);
for y in 0..size[1] as i32 {
for x in 0..size[0] as i32 {
let (cx, cy) = ((x - shift[0]).div_euclid(4), (y - shift[1]).div_euclid(4));
let mut h = (cx as u32).wrapping_mul(0x9e37_79b9)
^ (cy as u32).wrapping_mul(0x85eb_ca6b)
^ seed.wrapping_mul(0xc2b2_ae35);
h ^= h >> 15;
h = h.wrapping_mul(0x2c1b_3c6d);
h ^= h >> 12;
let v = (h & 0xff) as u8;
if seed >= 100 {
// Primaries: no two neighbours' colours point the same way.
let mut c = [0, 0, 0, 255];
c[(h >> 8) as usize % 3] = 128 + v / 2;
pixels.extend_from_slice(&c);
} else {
pixels.extend_from_slice(&[v, v.wrapping_add(40), v / 2, 255]);
}
}
}
pixels
}
pub fn upload(
device: &wgpu::Device,
queue: &wgpu::Queue,
size: [u32; 2],
pixels: &[u8],
) -> wgpu::TextureView {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
queue.write_texture(
texture.as_image_copy(),
pixels,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(size[0] * 4),
rows_per_image: None,
},
texture.size(),
);
texture.create_view(&Default::default())
}
/// `texture`'s texels (`texel` bytes each), row by row.
pub fn read(
device: &wgpu::Device,
queue: &wgpu::Queue,
texture: &wgpu::Texture,
texel: u32,
) -> Vec<u8> {
let size = texture.size();
let row = (size.width * texel).next_multiple_of(256);
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(row * size.height),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_texture_to_buffer(
texture.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(row),
rows_per_image: None,
},
},
size,
);
queue.submit([encoder.finish()]);
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let mapped = buffer.slice(..).get_mapped_range().unwrap();
mapped
.chunks(row as usize)
.flat_map(|r| r[..(size.width * texel) as usize].to_vec())
.collect()
}
fn run(
device: &wgpu::Device,
queue: &wgpu::Queue,
flow: &mut OpticalFlow,
color: &wgpu::TextureView,
reset: bool,
) {
let mut encoder = device.create_command_encoder(&Default::default());
flow.dispatch(device, queue, &mut encoder, color, reset);
queue.submit([encoder.finish()]);
}
fn vectors(device: &wgpu::Device, queue: &wgpu::Queue, flow: &OpticalFlow) -> Vec<[i32; 2]> {
read(device, queue, flow.vectors.texture(), 8)
.chunks(8)
.map(|c| {
[
i32::from_le_bytes(c[0..4].try_into().unwrap()),
i32::from_le_bytes(c[4..8].try_into().unwrap()),
]
})
.collect()
}
/// A frame moved right by 16 and down by 8: the blocks inside report where they were
/// (-16, -8), and nothing reads as a scene change. A much darker frame then does.
#[test]
fn finds_a_shift_and_a_scene_change() {
let Some((device, queue)) = device() else {
return;
};
let size = [512, 288];
let still = upload(&device, &queue, size, &pattern(size, 1, [0, 0]));
let moved = upload(&device, &queue, size, &pattern(size, 1, [16, 8]));
// Scene changes are told by the luma's histogram: a much darker frame.
let dark: Vec<u8> = pattern(size, 2, [0, 0]).iter().map(|v| v / 5).collect();
let other = upload(&device, &queue, size, &dark);
let mut flow = OpticalFlow::new(&device, size);
// The first 6 frames after a reset are scene changes.
run(&device, &queue, &mut flow, &still, true);
for _ in 0..6 {
run(&device, &queue, &mut flow, &still, false);
}
run(&device, &queue, &mut flow, &moved, false);
let [w, h] = flow.flow_sizes[0];
let found = vectors(&device, &queue, &flow);
let inside: Vec<_> = (4..h - 4)
.flat_map(|y| (4..w - 4).map(move |x| (x, y)))
.map(|(x, y)| found[(y * w + x) as usize])
.collect();
let right = inside.iter().filter(|v| **v == [-16, -8]).count();
assert!(
right * 10 >= inside.len() * 9,
"{right} of {} blocks: {:?}",
inside.len(),
&inside[..8]
);
let scd = |device: &wgpu::Device, queue: &wgpu::Queue, flow: &OpticalFlow| {
let mut encoder = device.create_command_encoder(&Default::default());
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 12,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_buffer_to_buffer(flow.scene_change(), 0, &buffer, 0, 12);
queue.submit([encoder.finish()]);
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let bytes = buffer.slice(..).get_mapped_range().unwrap().to_vec();
u32::from_le_bytes(bytes[4..8].try_into().unwrap())
};
assert_eq!(scd(&device, &queue, &flow) & 0xf, 0);
run(&device, &queue, &mut flow, &other, false);
assert_eq!(scd(&device, &queue, &flow) & 1, 1);
assert!(vectors(&device, &queue, &flow).iter().all(|v| *v == [0, 0]));
}
}
@@ -0,0 +1,537 @@
// This file is part of the FidelityFX SDK.
//
// Copyright (C) 2024 Advanced Micro Devices, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files(the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and /or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions :
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
// FidelityFX SDK 1.1.4's optical flow (ffx_opticalflow_*.h, the GLSL path), ported to WGSL
// for Horizon. What differs from AMD's (optical_flow.rs lists why):
// - luma levels are r32float (AMD: r8ui); loads truncate to the same integers;
// - the luma pyramid is one pass per level instead of SPD's single pass;
// - wave sums and minimums are workgroup atomics, and the reductions AMD leaves to a
// wave's lockstep have barriers;
// - the scene change textures are storage buffers;
// - the search reads its prediction from one texture and writes another (rg32sint can't
// be read_write everywhere).
struct Params {
// Display size (cbOF.iInputLumaResolution).
display: vec2<i32>,
level: u32,
level_count: u32,
frame_index: u32,
transfer_function: u32,
min_max_luminance: vec2<f32>,
};
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var input_color: texture_2d<f32>;
@group(0) @binding(2) var luma_out: texture_storage_2d<r32float, write>;
// The current frame's luma at this level, and the previous frame's.
@group(0) @binding(3) var luma_in: texture_2d<f32>;
@group(0) @binding(4) var luma_previous: texture_2d<f32>;
// Search: the prediction from the level above; filter: the search; scale: the filtered.
@group(0) @binding(5) var flow_in: texture_2d<i32>;
@group(0) @binding(6) var flow_out: texture_storage_2d<rg32sint, write>;
@group(0) @binding(7) var<storage, read_write> scd_histogram: array<atomic<u32>>;
@group(0) @binding(8) var<storage, read_write> scd_previous_histogram: array<f32>;
@group(0) @binding(9) var<storage, read_write> scd_temp: array<atomic<u32>, 3>;
@group(0) @binding(10) var<storage, read_write> scd_output: array<atomic<u32>, 3>;
const SCD_OUTPUT_SCENE_CHANGE_SLOT: u32 = 0u;
const SCD_OUTPUT_HISTORY_BITS_SLOT: u32 = 1u;
const SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT: u32 = 2u;
fn load_luma(t: texture_2d<f32>, p: vec2<i32>) -> u32 {
let size = vec2<i32>(textureDimensions(t));
if any(p < vec2(0)) || any(p >= size) {
return 0u;
}
return u32(textureLoad(t, p, 0).x);
}
fn load_flow(p: vec2<i32>) -> vec2<i32> {
let size = vec2<i32>(textureDimensions(flow_in));
if any(p < vec2(0)) || any(p >= size) {
return vec2(0);
}
return textureLoad(flow_in, p, 0).xy;
}
fn store_flow(p: vec2<i32>, v: vec2<i32>) {
let size = vec2<i32>(textureDimensions(flow_out));
if all(p >= vec2(0)) && all(p < size) {
textureStore(flow_out, p, vec4(v, 0, 0));
}
}
fn packed_luma(width: i32, x: i32, luma0: u32, luma1: u32, luma2: u32, luma3: u32) -> u32 {
var packed = luma0 | (luma1 << 8u) | (luma2 << 16u) | (luma3 << 24u);
if x < 0 {
let filler = packed & 0xffu;
if x <= -1 {
packed = (packed << 8u) | filler;
}
if x <= -2 {
packed = (packed << 8u) | filler;
}
if x <= -3 {
packed = (packed << 8u) | filler;
}
} else if x > width - 4 {
let filler = packed & 0xff000000u;
if x >= width - 3 {
packed = (packed >> 8u) | filler;
}
if x >= width - 2 {
packed = (packed >> 8u) | filler;
}
if x >= width - 1 {
packed = (packed >> 8u) | filler;
}
}
return packed;
}
fn load_packed(t: texture_2d<f32>, p: vec2<i32>) -> u32 {
let width = params.display.x >> params.level;
let height = params.display.y >> params.level;
let q = vec2(clamp(p.x, 0, width - 4), clamp(p.y, 0, height - 1));
return packed_luma(
width,
p.x,
load_luma(t, q),
load_luma(t, q + vec2(1, 0)),
load_luma(t, q + vec2(2, 0)),
load_luma(t, q + vec2(3, 0)),
);
}
fn sad(a: u32, b: u32) -> u32 {
return u32(abs(i32(a & 0xffu) - i32(b & 0xffu))
+ abs(i32((a >> 8u) & 0xffu) - i32((b >> 8u) & 0xffu))
+ abs(i32((a >> 16u) & 0xffu) - i32((b >> 16u) & 0xffu))
+ abs(i32((a >> 24u) & 0xffu) - i32((b >> 24u) & 0xffu)));
}
fn qsad(a0_in: u32, a1_in: u32, b: u32) -> vec4<u32> {
var a0 = a0_in;
var a1 = a1_in;
var s: vec4<u32>;
s.x = sad(a0, b);
a0 = (a0 >> 8u) | ((a1 & 0xffu) << 24u);
a1 = a1 >> 8u;
s.y = sad(a0, b);
a0 = (a0 >> 8u) | ((a1 & 0xffu) << 24u);
a1 = a1 >> 8u;
s.z = sad(a0, b);
a0 = (a0 >> 8u) | ((a1 & 0xffu) << 24u);
s.w = sad(a0, b);
return s;
}
fn is_scene_changed() -> bool {
if params.frame_index <= 5u {
return true;
}
return (atomicLoad(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT]) & 0xfu) != 0u;
}
// ffx_opticalflow_prepare_luma.h, for an SDR back buffer (transfer function 0: Horizon's
// frames); the HDR transfer functions follow AMD's.
fn luminance_to_perceived(luminance: f32) -> f32 {
var perceived: f32;
if luminance <= 216.0 / 24389.0 {
perceived = luminance * (24389.0 / 27.0);
} else {
perceived = pow(luminance, 1.0 / 3.0) * 116.0 - 16.0;
}
return perceived * 0.01;
}
fn linear_from_pq(x: vec3<f32>) -> vec3<f32> {
let p = pow(x, vec3(0.0126833));
return pow(clamp(p - vec3(0.835938), vec3(0.0), vec3(1.0)) / (vec3(18.8516) - 18.6875 * p), vec3(6.27739));
}
@compute @workgroup_size(16, 16)
fn prepare_luma(@builtin(global_invocation_id) gid: vec3<u32>) {
for (var y = 0; y < 2; y++) {
for (var x = 0; x < 2; x++) {
let pos = vec2<i32>(gid.xy) * 2 + vec2(x, y);
if any(pos >= params.display) {
continue;
}
let c = textureLoad(input_color, pos, 0).rgb;
var y_lum = 0.0;
if params.transfer_function == 0u {
y_lum = dot(c, vec3(0.2126, 0.7152, 0.0722));
} else if params.transfer_function == 1u {
let linear = linear_from_pq(c) * (10000.0 / params.min_max_luminance.y);
y_lum = luminance_to_perceived(dot(linear, vec3(0.2627, 0.678, 0.0593)));
} else if params.transfer_function == 2u {
let mn = params.min_max_luminance.x;
let mx = params.min_max_luminance.y;
let linear = (c - vec3(mn / 80.0)) / vec3((mx - mn) / 80.0);
y_lum = luminance_to_perceived(dot(linear, vec3(0.2126, 0.7152, 0.0722)));
}
textureStore(luma_out, pos, vec4(f32(u32(y_lum * 255.0)), 0.0, 0.0, 0.0));
}
}
}
// ffx_opticalflow_compute_luminance_pyramid.h: SPD's average of 2x2, a level per pass.
@compute @workgroup_size(8, 8)
fn luma_pyramid(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if any(p >= vec2<i32>(textureDimensions(luma_out))) {
return;
}
let s = p * 2;
let v = (textureLoad(luma_in, s, 0).x + textureLoad(luma_in, s + vec2(1, 0), 0).x
+ textureLoad(luma_in, s + vec2(0, 1), 0).x + textureLoad(luma_in, s + vec2(1, 1), 0).x) * 0.25;
textureStore(luma_out, p, vec4(v, 0.0, 0.0, 0.0));
}
// ffx_opticalflow_generate_scd_histogram.h
const LBASE: u32 = 10u;
var<workgroup> scd_buffer: array<atomic<u32>, 2560>;
@compute @workgroup_size(32, 8)
fn scd_histogram_pass(
@builtin(global_invocation_id) gid: vec3<u32>,
@builtin(local_invocation_index) local_index: u32,
) {
let histograms_per_dim = 3u;
let div = vec2<u32>(params.display) / histograms_per_dim;
let nx = gid.z % histograms_per_dim;
let ny = gid.z / histograms_per_dim;
let start = div * vec2(nx, ny);
let stop = start + div;
let offset = local_index * LBASE;
for (var i = 0u; i < LBASE; i++) {
atomicStore(&scd_buffer[offset + i], 0u);
}
workgroupBarrier();
var coord = vec2<i32>(i32(start.x + 4u * gid.x), i32(start.y + gid.y));
if u32(coord.x) < stop.x {
for (; u32(coord.y) < stop.y; coord.y += 128) {
let color = vec4(
load_luma(luma_in, coord),
load_luma(luma_in, coord + vec2(1, 0)),
load_luma(luma_in, coord + vec2(2, 0)),
load_luma(luma_in, coord + vec2(3, 0)),
) * LBASE;
let scrambling = local_index % LBASE;
atomicAdd(&scd_buffer[color.x + scrambling], 1u);
atomicAdd(&scd_buffer[color.y + scrambling], 1u);
atomicAdd(&scd_buffer[color.z + scrambling], 1u);
atomicAdd(&scd_buffer[color.w + scrambling], 1u);
}
}
workgroupBarrier();
var value = 0u;
for (var i = 0u; i < LBASE; i++) {
value += atomicLoad(&scd_buffer[offset + i]);
}
let histogram_start = 256u * gid.z;
atomicAdd(&scd_histogram[histogram_start + local_index], value);
}
// ffx_opticalflow_compute_scd_divergence.h
var<workgroup> source_histogram: array<f32, 256>;
var<workgroup> filtered_histogram: array<f32, 256>;
var<workgroup> temp_buffer: array<f32, 256>;
var<workgroup> temp_buffer2: array<vec2<f32>, 256>;
@compute @workgroup_size(256)
fn scd_divergence_pass(
@builtin(global_invocation_id) gid: vec3<u32>,
@builtin(local_invocation_index) local_index: u32,
) {
let factor = 1000000.0;
let where_to_stop = 3u * 9u - 1u;
let histogram_count = 9.0;
let kernel = array(0.0088122291, 0.027143577, 0.065114059, 0.12164907, 0.17699835, 0.20056541);
let li = i32(local_index);
source_histogram[li] = f32(atomicLoad(&scd_histogram[gid.x]));
workgroupBarrier();
let read = li - 5;
var val = 0.0;
val += kernel[0] * source_histogram[clamp(read + 0, 0, 255)];
val += kernel[1] * source_histogram[clamp(read + 1, 0, 255)];
val += kernel[2] * source_histogram[clamp(read + 2, 0, 255)];
val += kernel[3] * source_histogram[clamp(read + 3, 0, 255)];
val += kernel[4] * source_histogram[clamp(read + 4, 0, 255)];
val += kernel[5] * source_histogram[clamp(read + 5, 0, 255)];
val += kernel[4] * source_histogram[clamp(read + 6, 0, 255)];
val += kernel[3] * source_histogram[clamp(read + 7, 0, 255)];
val += kernel[2] * source_histogram[clamp(read + 8, 0, 255)];
val += kernel[1] * source_histogram[clamp(read + 9, 0, 255)];
val += kernel[0] * source_histogram[clamp(read + 10, 0, 255)];
val += 1.0;
if gid.y == 0u {
if li == 0 {
filtered_histogram[255] = 1.0;
} else {
filtered_histogram[li - 1] = val;
}
} else if gid.y == 1u {
filtered_histogram[li] = val;
} else if gid.y == 2u {
if li == 255 {
filtered_histogram[0] = 1.0;
} else {
filtered_histogram[li + 1] = val;
}
}
workgroupBarrier();
temp_buffer[li] = filtered_histogram[li];
workgroupBarrier();
for (var stride = 128; stride > 0; stride >>= 1u) {
if li < stride {
temp_buffer[li] += temp_buffer[li + stride];
}
workgroupBarrier();
}
filtered_histogram[li] /= temp_buffer[0];
let current = filtered_histogram[li];
let previous = scd_previous_histogram[gid.x];
temp_buffer2[li] = vec2(current * log(current / previous), previous * log(previous / current));
workgroupBarrier();
for (var stride = 128; stride > 0; stride >>= 1u) {
if li < stride {
temp_buffer2[li] += temp_buffer2[li + stride];
}
workgroupBarrier();
}
if li == 0 {
let sum = temp_buffer2[0];
let res_float = 1.0 - exp(-(abs(sum.x) + abs(sum.y)));
let res_uint = u32((res_float / histogram_count) * factor);
atomicAdd(&scd_temp[gid.y], res_uint);
let finished = atomicAdd(&scd_output[SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT], 1u);
if finished == where_to_stop {
let res0 = atomicLoad(&scd_temp[0]);
let res1 = atomicLoad(&scd_temp[1]);
let res2 = atomicLoad(&scd_temp[2]);
let scene_change = f32(min(res0, min(res1, res2))) / factor;
var history = atomicLoad(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT]) << 1u;
if scene_change > 0.45 {
history |= 1u;
}
atomicStore(&scd_output[SCD_OUTPUT_SCENE_CHANGE_SLOT], bitcast<u32>(scene_change));
atomicStore(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT], history);
atomicStore(&scd_output[SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT], 0u);
atomicStore(&scd_temp[0], 0u);
atomicStore(&scd_temp[1], 0u);
atomicStore(&scd_temp[2], 0u);
}
}
if gid.y == 1u {
scd_previous_histogram[gid.x] = current;
atomicStore(&scd_histogram[gid.x], 0u);
}
}
// ffx_opticalflow_compute_optical_flow_v5.h: block matching of 8x8 blocks over +-8 pixels,
// 2x2 blocks per workgroup of 64.
var<workgroup> pixels: array<array<u32, 2>, 8>;
// SearchBufferSizeY (24) rows of SearchBufferSizeX (6) packed lumas.
var<workgroup> search_buffer: array<u32, 144>;
var<workgroup> sad_map: array<array<array<u32, 5>, 16>, 4>;
var<workgroup> wave_min: atomic<u32>;
var<workgroup> wave_sum: atomic<u32>;
var<workgroup> scene_changed: u32;
fn abs_2(v: vec2<i32>) -> vec2<u32> {
let mask = v >> vec2(31u);
return vec2<u32>((v + mask) ^ mask);
}
// FFX_OPTICALFLOW_FIX_TOP_LEFT_BIAS: the nearest of equal SADs wins.
fn encode_search_coord(coord: vec2<i32>) -> u32 {
let a = abs_2(coord - 8);
return (a.y << 12u) | (a.x << 8u) | (u32(coord.y) << 4u) | u32(coord.x);
}
fn decode_search_coord(bits: u32) -> vec2<i32> {
return vec2(i32(bits & 0xfu) - 8, i32((bits >> 4u) & 0xfu) - 8);
}
fn calculate_qsads2(search_id: vec2<i32>) -> vec4<u32> {
var s = vec4(0u);
for (var dy = 0; dy < 8; dy++) {
let row = (search_id.y + dy) * 6;
let a0 = search_buffer[row + search_id.x];
let a1 = search_buffer[row + search_id.x + 1];
let a2 = search_buffer[row + search_id.x + 2];
s += qsad(a0, a1, pixels[dy][0]);
s += qsad(a1, a2, pixels[dy][1]);
}
return s;
}
@compute @workgroup_size(64)
fn search_pass(
@builtin(workgroup_id) group_id: vec3<u32>,
@builtin(local_invocation_index) local_index: u32,
) {
let li = i32(local_index);
let search_id = vec2(li & 3, (li >> 2u) & 15);
let lane_block = ((li >> 1u) & 1) | (((li >> 5u) & 1) << 1u);
let group = vec2<i32>(group_id.xy);
let px = (group << vec2(4u)) + search_id * vec2(4, 1);
if local_index == 0u {
scene_changed = u32(is_scene_changed());
}
if workgroupUniformLoad(&scene_changed) != 0u {
if (search_id.y & 7) == 0 && (search_id.x & 1) == 0 {
store_flow(px >> vec2(3u), vec2(0));
}
return;
}
let use_prediction = params.level != params.level_count - 1u;
let packed = load_packed(luma_in, px);
let sad_4blocks = sad(packed, load_packed(luma_previous, px));
let of_group = group << vec2(1u);
let pixel_group = group << vec2(4u);
for (var by = 0; by < 2; by++) {
for (var bx = 0; bx < 2; bx++) {
let block = vec2(bx, by);
var current = vec2(0);
if use_prediction {
current = load_flow(of_group + block);
}
if lane_block == by * 2 + bx {
pixels[search_id.y & 7][search_id.x & 1] = packed;
}
// LoadSearchBuffer
let base = pixel_group + block * 8 + current - vec2(8, 8);
for (var id = li; id < 144; id += 64) {
search_buffer[id] = load_packed(luma_previous, vec2(base.x + (id % 6) * 4, base.y + id / 6));
}
if local_index == 0u {
atomicStore(&wave_min, 0xffffffffu);
atomicStore(&wave_sum, 0u);
}
workgroupBarrier();
let q = calculate_qsads2(search_id);
// PrepareSadMap
let sx = search_id.x;
let sy = search_id.y;
sad_map[0][sy][sx] = (q.x << 16u) | encode_search_coord(vec2(sx * 4 + 0, sy));
sad_map[1][sy][sx] = (q.y << 16u) | encode_search_coord(vec2(sx * 4 + 1, sy));
sad_map[2][sy][sx] = (q.z << 16u) | encode_search_coord(vec2(sx * 4 + 2, sy));
sad_map[3][sy][sx] = (q.w << 16u) | encode_search_coord(vec2(sx * 4 + 3, sy));
workgroupBarrier();
// SadMapMinReduction256 and BlockSad64 (FFX_LOCAL_SEARCH_FALLBACK)
let min01 = min(sad_map[0][sy][sx], sad_map[1][sy][sx]);
let min23 = min(sad_map[2][sy][sx], sad_map[3][sy][sx]);
atomicMin(&wave_min, min(min01, min23));
atomicAdd(&wave_sum, select(0u, sad_4blocks, lane_block == bx + by * 2));
workgroupBarrier();
let min_sad = atomicLoad(&wave_min);
let block_sad_sum = atomicLoad(&wave_sum);
var v = current + decode_search_coord(min_sad);
if params.level == 0u && block_sad_sum <= (min_sad >> 16u) {
v = vec2(0);
}
if local_index == 0u {
store_flow(of_group + block, v);
}
// The next block reuses the shared buffers and counters.
workgroupBarrier();
}
}
}
// ffx_opticalflow_filter_optical_flow_v5.h: the vector of the 3x3 nearest to all the others.
@compute @workgroup_size(16, 4)
fn filter_flow(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
var mv: array<vec2<i32>, 9>;
var idx = 0;
for (var xx = -1; xx < 2; xx++) {
for (var yy = -1; yy < 2; yy++) {
mv[idx] = load_flow(p + vec2(xx, yy));
idx++;
}
}
var ret = 0xffffffffu;
for (var i = 0; i < 9; i++) {
var tmp = 0u;
for (var j = 0; j < 9; j++) {
let delta = mv[i] - mv[j];
tmp = u32(delta.x * delta.x) + (u32(delta.y * delta.y) + tmp);
}
ret = min((tmp << 4u) | u32(i), ret);
}
store_flow(p, mv[ret & 0xfu]);
}
// ffx_opticalflow_scale_optical_flow_advanced_v5.h: the next level's vectors, each the
// best of its 4 nearest at this level.
var<workgroup> nearest_vectors: array<array<array<vec2<i32>, 4>, 4>, 4>;
var<workgroup> local_region: array<array<array<u32, 4>, 4>, 4>;
var<workgroup> sads: array<array<array<u32, 4>, 4>, 4>;
@compute @workgroup_size(4, 4, 4)
fn scale_pass(
@builtin(global_invocation_id) gid: vec3<u32>,
@builtin(local_invocation_id) lid: vec3<u32>,
@builtin(local_invocation_index) local_index: u32,
) {
if local_index == 0u {
scene_changed = u32(is_scene_changed());
}
if workgroupUniformLoad(&scene_changed) != 0u {
store_flow(vec2<i32>(gid.xy), vec2(0));
return;
}
let x_offset = i32(lid.z % 2u) - 1 + i32(gid.x % 2u);
let y_offset = i32(lid.z / 2u) - 1 + i32(gid.y % 2u);
let src = vec2(i32(gid.x / 2u) + x_offset, i32(gid.y / 2u) + y_offset);
let nearest = load_flow(src);
nearest_vectors[lid.z][lid.y][lid.x] = nearest * 2;
let max_y = 4u;
for (var n = lid.z; n < max_y; n += 4u) {
let luma_pos = vec2(i32(gid.x) * 4, i32(gid.y * max_y + n));
local_region[n][lid.y][lid.x] = load_packed(luma_in, luma_pos);
}
workgroupBarrier();
var s = 0u;
for (var n = 0u; n < max_y; n++) {
let luma_pos = vec2(i32(gid.x) * 4, i32(gid.y * max_y + n)) + nearest;
s += sad(local_region[n][lid.y][lid.x], load_packed(luma_previous, luma_pos));
}
sads[lid.z][lid.y][lid.x] = s;
workgroupBarrier();
if lid.z == 0u {
var best_sad = 0xffffffffu;
var best = 0u;
for (var n = 0u; n < 4u; n++) {
if sads[n][lid.y][lid.x] < best_sad {
best_sad = sads[n][lid.y][lid.x];
best = n;
}
}
store_flow(vec2<i32>(gid.xy), nearest_vectors[best][lid.y][lid.x]);
}
}
@@ -27,6 +27,8 @@ impl Column {
/// Values of type `T` per section key, in a dense list with stable keys and
/// slots that move only when a section is removed.
pub struct Resident<T> {
/// The minimum Y of section index 0 (the level's column).
min_y: i32,
items: Vec<(SectionKey, T)>,
slots: FxHashMap<SectionKey, u32>,
columns: Vec<Column>,
@@ -36,6 +38,7 @@ pub struct Resident<T> {
impl<T> Default for Resident<T> {
fn default() -> Self {
Self {
min_y: crate::chunk::MIN_Y,
items: Vec::new(),
slots: FxHashMap::default(),
columns: Vec::new(),
@@ -61,6 +64,10 @@ impl<T> Resident<T> {
pub fn get_mut(&mut self, slot: u32) -> &mut T {
&mut self.items[slot as usize].1
}
/// Sections from now on count from `min_y` (a level of another column).
pub fn set_min_y(&mut self, min_y: i32) {
self.min_y = min_y;
}
pub fn clear(&mut self) {
self.items.clear();
self.slots.clear();
@@ -144,10 +151,10 @@ pub struct Visible {
}
/// The camera-relative origin of the section at `key`.
fn origin(key: SectionKey, camera: DVec3) -> DVec3 {
fn origin(key: SectionKey, min_y: i32, camera: DVec3) -> DVec3 {
DVec3::new(
f64::from(key.0 * 16),
f64::from(crate::chunk::MIN_Y + key.1 * 16),
f64::from(min_y + key.1 * 16),
f64::from(key.2 * 16),
) - camera
}
@@ -169,18 +176,19 @@ fn in_view(key: SectionKey, camera: DVec3, radius: i32, frustum: &Frustum) -> Op
if !in_view_distance(camera_column(camera), radius, key.0, key.2) {
return None;
}
in_frustum(key, camera, frustum, false)
in_frustum(key, crate::chunk::MIN_Y, camera, frustum, false)
}
/// `in_view` without the distance test; `inside`: the section's column is
/// entirely inside the frustum, so the section is too.
fn in_frustum(
key: SectionKey,
min_y: i32,
camera: DVec3,
frustum: &Frustum,
inside: bool,
) -> Option<(f64, Vec3)> {
let origin = origin(key, camera);
let origin = origin(key, min_y, camera);
let min = origin.as_vec3();
if !inside && !frustum.intersects(min, min + Vec3::splat(16.0)) {
return None;
@@ -216,8 +224,8 @@ impl<T> Resident<T> {
if !near && !beyond(column.x, column.z) {
continue;
}
let min = origin((column.x, column.low, column.z), camera).as_vec3();
let top = origin((column.x, column.high, column.z), camera).as_vec3();
let min = origin((column.x, column.low, column.z), self.min_y, camera).as_vec3();
let top = origin((column.x, column.high, column.z), self.min_y, camera).as_vec3();
let max = top + Vec3::splat(16.0);
let inside = frustum.contains(min, max);
if !inside && !frustum.intersects(min, max) {
@@ -228,7 +236,7 @@ impl<T> Resident<T> {
if near && !reachable(key) {
continue;
}
if let Some((d, o)) = in_frustum(key, camera, frustum, inside) {
if let Some((d, o)) = in_frustum(key, self.min_y, camera, frustum, inside) {
out.push(Visible {
distance: d.to_bits(),
key,
@@ -286,7 +294,7 @@ mod tests {
if near {
in_view(k, camera, radius, &frustum).filter(|_| reachable(k))
} else if beyond(k.0, k.2) {
in_frustum(k, camera, &frustum, false)
in_frustum(k, crate::chunk::MIN_Y, camera, &frustum, false)
} else {
None
}
@@ -329,8 +337,8 @@ mod tests {
.columns
.iter()
.filter(|c| {
let min = origin((c.x, c.low, c.z), camera).as_vec3();
let top = origin((c.x, c.high, c.z), camera).as_vec3();
let min = origin((c.x, c.low, c.z), crate::chunk::MIN_Y, camera).as_vec3();
let top = origin((c.x, c.high, c.z), crate::chunk::MIN_Y, camera).as_vec3();
frustum.contains(min, top + Vec3::splat(16.0))
})
.count();
+108 -5
View File
@@ -10,19 +10,36 @@ const CHUNK: u64 = 1 << 20;
pub struct Uploads {
belt: wgpu::util::StagingBelt,
chunk: u64,
}
impl Uploads {
pub fn new(device: &wgpu::Device) -> Self {
Self::with_chunk(device, CHUNK)
}
/// A belt whose staging buffers hold `chunk` bytes (larger writes get
/// a buffer of their own).
/// A belt whose staging buffers hold `chunk` bytes (a larger write gets
/// a buffer of its own, rounded up to a power of two).
pub fn with_chunk(device: &wgpu::Device, chunk: u64) -> Self {
Self {
belt: wgpu::util::StagingBelt::new(device.clone(), chunk),
chunk,
}
}
/// A mapped staging slice of at least `size` bytes. The belt gives a
/// write larger than its chunk a buffer of exactly that size, keeps it for
/// good and only reuses it for writes that fit: an upload that grows a
/// little every frame (the entity batch while mobs appear) took a new
/// buffer each frame until the game ran out of memory (#2826). Rounded up
/// to a power of two, the large buffers it keeps are reused, one per
/// doubling.
fn allocate(&mut self, size: NonZeroU64, alignment: NonZeroU64) -> wgpu::BufferSlice<'_> {
let size = if size.get() > self.chunk {
size.checked_next_power_of_two().unwrap_or(size)
} else {
size
};
self.belt.allocate(size, alignment)
}
/// Copy tightly packed RGBA8 `pixels` (`width` by `height`) into one mip
/// level of one array layer, padding rows to the copy alignment.
pub fn texture(
@@ -41,7 +58,7 @@ impl Uploads {
};
let alignment = NonZeroU64::new(u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT))
.expect("non-zero alignment");
let slice = self.belt.allocate(size, alignment);
let slice = self.allocate(size, alignment);
{
let mut view = slice
.get_mapped_range_mut()
@@ -80,8 +97,20 @@ impl Uploads {
let Some(size) = NonZeroU64::new(bytes.len() as u64) else {
return;
};
self.belt
.write_buffer(encoder, target, offset, size)
// The belt's own `write_buffer` checks, kept now that a large write's
// slice is rounded up past them.
assert!(
offset.is_multiple_of(wgpu::COPY_BUFFER_ALIGNMENT)
&& size.get().is_multiple_of(wgpu::COPY_BUFFER_ALIGNMENT),
"buffer upload at {offset} of {size} bytes must be 4-byte aligned"
);
let alignment = NonZeroU64::new(wgpu::COPY_BUFFER_ALIGNMENT).expect("non-zero alignment");
let slice = self.allocate(size, alignment).slice(..size.get());
encoder.copy_buffer_to_buffer(slice.buffer(), slice.offset(), target, offset, size.get());
slice
.get_mapped_range_mut()
.expect("staging belt slices are mapped")
.slice(..)
.copy_from_slice(bytes);
}
/// Close this frame's staging buffers: they come back for reuse once the
@@ -217,6 +246,80 @@ mod tests {
}
}
/// Staging buffers the belt holds, once every submitted frame is done
/// with them. The belt only reports its buffers through `Debug`.
fn kept_buffers(device: &wgpu::Device, uploads: &mut Uploads) -> usize {
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
// Finishing an empty frame takes back every recalled buffer.
uploads.finish(&device.create_command_encoder(&Default::default()));
let belt = format!("{:?}", uploads.belt);
let count = |field: &str| -> usize {
let at = belt.find(&format!("{field}: ")).expect(&belt) + field.len() + 2;
let digits = belt[at..].split(|c: char| !c.is_ascii_digit()).next();
digits.and_then(|d| d.parse().ok()).expect(&belt)
};
count("active_chunks") + count("closed_chunks") + count("free_chunks")
}
/// #2826: a write larger than the chunk that grows a little every frame
/// (the entity batch while mobs appear) got a new exact-size buffer each
/// frame, all of them kept, until the game ran out of memory.
#[test]
fn growing_large_writes_reuse_the_belt_buffers() {
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
const CHUNK: u64 = 1 << 16;
const TARGET: usize = 4 * CHUNK as usize;
let target = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: TARGET as u64,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let readback = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: TARGET as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut uploads = Uploads::with_chunk(&device, CHUNK);
let mut expected = vec![0u8; TARGET];
let frames = 64;
for frame in 0..frames {
let mut encoder = device.create_command_encoder(&Default::default());
// One small write, then the large one, 1.5 to 3.5 chunks long.
uploads.buffer(&mut encoder, &target, TARGET as u64 - 4, &[9; 4]);
expected[TARGET - 4..].fill(9);
let len = (3 * CHUNK as usize / 2 + frame * 2048).next_multiple_of(4);
let bytes: Vec<u8> = (0..len).map(|i| (i + frame) as u8).collect();
uploads.buffer(&mut encoder, &target, 0, &bytes);
expected[..len].copy_from_slice(&bytes);
encoder.copy_buffer_to_buffer(&target, 0, &readback, 0, TARGET as u64);
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
readback
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
// Only the written bytes land: the rest of the target is untouched.
assert!(
*readback.slice(..).get_mapped_range().unwrap() == expected[..],
"frame {frame}"
);
readback.unmap();
}
// A chunk for the small writes and a buffer per doubling of the large
// one (2 and 4 chunks), maybe one more where a small write took the
// start of a buffer the large one needed whole: not one per frame.
let kept = kept_buffers(&device, &mut uploads);
assert!(
kept <= 4,
"the belt kept {kept} buffers over {frames} frames"
);
}
#[test]
fn belt_buffer_writes_land_in_order_at_their_offsets() {
let Some((device, queue)) = device() else {
@@ -131,8 +131,20 @@ impl Style {
value: f32,
hover: bool,
active: bool,
dragging: bool,
) {
ui::slider(batch, atlas, x, y, w, label, value, hover && active, active);
ui::slider(
batch,
atlas,
x,
y,
w,
label,
value,
hover && active,
active,
dragging && active,
);
}
/// The cost tag and the description beside a control, wrapped to two lines.
pub fn description(
@@ -855,6 +855,7 @@ impl GraphicsUi {
value,
hover || self.dragging == Some(item),
live,
self.dragging == Some(item),
),
None => STYLE.button(
batch,
+152 -48
View File
@@ -168,6 +168,31 @@ fn strip_frame(pixels: &[u8], stride: u32, x: u32, y: u32, w: u32, h: u32) -> Ve
}
out
}
/// The `animation` section of a texture's `.mcmeta`.
fn animation_of(mcmeta: Option<&str>) -> Option<AnimationMetadata> {
let meta: serde_json::Value = serde_json::from_str(mcmeta?).ok()?;
AnimationMetadata::from_json(&meta.get("animation")?.to_string()).ok()
}
/// An animated strip's first frame: the region the mcmeta's frame size picks
/// (`AnimationMetadataSection.frameSize`), which is the top-left square unless
/// it names other dimensions or the frames run along a row.
fn first_frame(
(w, h, pixels): (u32, u32, Vec<u8>),
animation: Option<&AnimationMetadata>,
) -> (u32, u32, Vec<u8>) {
let [fw, fh] = animation.map_or([w.min(h) as i32; 2], |meta| {
meta.frame_size(w as i32, h as i32)
});
match (u32::try_from(fw), u32::try_from(fh)) {
(Ok(fw), Ok(fh))
if fw > 0 && fh > 0 && w % fw == 0 && h % fh == 0 && (fw, fh) != (w, h) =>
{
let side = fw.max(fh);
(side, side, strip_frame(&pixels, w, 0, 0, fw, fh))
}
_ => (w, h, pixels),
}
}
/// `image` inside a one-texel border copied from its opposite edges, the
/// texels repeat addressing reads past each edge.
fn wrap_border((name, w, h, data): &PackedImage) -> PackedImage {
@@ -184,8 +209,9 @@ fn wrap_border((name, w, h, data): &PackedImage) -> PackedImage {
}
/// Sprites the atlas definitions (`atlases/*.json`) add beyond the texture
/// files already packed: generated ones (`paletted_permutations` such as the
/// armour trim sprites, `unstitch` regions) and files listed under another id.
fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> Vec<PackedImage> {
/// armour trim sprites, `unstitch` regions) and files listed under another id,
/// such as other namespaces' item textures; and the animated ones' names.
fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> (Vec<PackedImage>, Vec<String>) {
use crate::atlas_sources::{self, SpriteOrigin};
let have: std::collections::HashSet<&str> = packed.iter().map(|i| i.0.as_str()).collect();
let textures = atlas_sources::list_textures(root);
@@ -229,29 +255,45 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> Vec<PackedImage> {
};
let first = |(name, origins): &(String, Vec<(usize, SpriteOrigin)>)| {
origins.iter().find_map(|(order, origin)| {
let mut animated = false;
let image = match origin {
SpriteOrigin::File(texture) => {
let path = texture.strip_prefix("minecraft:").unwrap_or(texture);
load(texture, "textures").map(|(w, h, p)| {
// Animated strips keep their first frame, like the packed files.
if h > w && (path.starts_with("block/") || path.starts_with("item/")) {
(w, w, p[..(w * w * 4) as usize].to_vec())
// Its path under `textures/` in any namespace (a server
// pack's `lobby:item/...` as well as vanilla's).
let path = texture.split_once(':').map_or(texture.as_str(), |(_, p)| p);
let file = atlas_sources::texture_path(root, texture, "textures");
decode(&file).ok().map(|image| {
// Animated strips keep their first frame and animate,
// like the packed files.
if path.starts_with("block/") || path.starts_with("item/") {
let mcmeta = fs::read_to_string(file.with_extension("png.mcmeta")).ok();
let animation = animation_of(mcmeta.as_deref());
animated = animation.is_some();
first_frame(image, animation.as_ref())
} else {
(w, h, p)
image
}
})
}
generated => atlas_sources::generate(generated, &load),
};
image.map(|(w, h, pixels)| (*order, (name.clone(), w, h, pixels)))
image.map(|(w, h, pixels)| (*order, (name.clone(), w, h, pixels), animated))
})
};
let mut out: Vec<_> = crate::par::par_map(&names, first)
.into_iter()
.flatten()
.collect();
out.sort_unstable_by_key(|(order, _)| *order);
out.into_iter().map(|(_, image)| image).collect()
out.sort_unstable_by_key(|(order, ..)| *order);
let animated = out
.iter()
.filter(|(.., animated)| *animated)
.map(|(_, image, _)| image.0.clone())
.collect();
(
out.into_iter().map(|(_, image, _)| image).collect(),
animated,
)
}
impl HudAtlas {
/// Keep a ready profile skin when another source only has its default.
@@ -413,49 +455,40 @@ impl HudAtlas {
for folder in folders {
walk(&textures.join(folder), &mut files);
}
// Content mods' equipment textures, which their equipment assets name as `<ns>:<path>`.
if folders.contains(&"entity") {
for namespace in horizon_items::content_namespaces::namespaces(root) {
walk(
&root.join(format!("assets/{namespace}/textures/entity/equipment")),
&mut files,
);
}
}
let assets = root.join("assets");
files.retain(|p| {
let s = p.to_string_lossy();
!s.contains("title/background") && !s.contains("realms") && !s.contains("presets")
});
type Decoded = (PackedImage, Option<TextureFlags>, Option<(u32, u32)>, bool);
let decode_one = |path: &PathBuf| -> Option<Decoded> {
let name = path
.strip_prefix(&textures)
.ok()?
.with_extension("")
.to_string_lossy()
.replace('\\', "/");
let (mut w, mut h, pixels) = decode(path).ok()?;
let mcmeta = fs::read_to_string(path.with_extension("png.mcmeta")).ok();
let animation = mcmeta
.as_deref()
.and_then(|json| serde_json::from_str::<serde_json::Value>(json).ok())
.and_then(|meta| {
meta.get("animation")
.and_then(|section| AnimationMetadata::from_json(&section.to_string()).ok())
});
// An animated strip keeps its first frame: the region the mcmeta's
// frame size picks (`AnimationMetadataSection.frameSize`), which is
// the top-left square unless it names other dimensions or the
// frames run along a row.
let frames = name.starts_with("block/") || name.starts_with("item/");
let pixels = if frames {
let stride = w;
let [fw, fh] = animation.as_ref().map_or([w.min(h) as i32; 2], |meta| {
meta.frame_size(w as i32, h as i32)
});
match (u32::try_from(fw), u32::try_from(fh)) {
(Ok(fw), Ok(fh))
if fw > 0 && fh > 0 && w % fw == 0 && h % fh == 0 && (fw, fh) != (w, h) =>
{
let side = fw.max(fh);
(w, h) = (side, side);
strip_frame(&pixels, stride, 0, 0, fw, fh)
}
_ => pixels,
let name = match path.strip_prefix(&textures) {
Ok(rel) => rel.with_extension("").to_string_lossy().replace('\\', "/"),
Err(_) => {
let rel = path.strip_prefix(&assets).ok()?.with_extension("");
let rel = rel.to_string_lossy().replace('\\', "/");
let (namespace, rest) = rel.split_once("/textures/")?;
format!("{namespace}:{rest}")
}
};
let image = decode(path).ok()?;
let mcmeta = fs::read_to_string(path.with_extension("png.mcmeta")).ok();
let animation = animation_of(mcmeta.as_deref());
// An animated strip keeps its first frame.
let frames = name.starts_with("block/") || name.starts_with("item/");
let (w, h, pixels) = if frames {
first_frame(image, animation.as_ref())
} else {
pixels
image
};
// The default texture's size, when a pack's differs from it.
let gui_size = default_textures
@@ -507,7 +540,9 @@ impl HudAtlas {
images.push(image);
}
if full {
images.extend(atlas_sprites(root, &images));
let (more, more_animated) = atlas_sprites(root, &images);
images.extend(more);
animated.extend(more_animated);
}
// The title screen's "JAVA EDITION" banner reads "RUST EDITION".
if let Some(edition) = images
@@ -579,7 +614,15 @@ impl HudAtlas {
let Some(rect) = sprites.get(&name).copied() else {
continue;
};
let layer = loaded.insert(root, &format!("minecraft:{name}"))?;
// Another namespace's sprite is named with it (`lobby:item/...`).
let id = if name.contains(':') {
name.clone()
} else {
format!("minecraft:{name}")
};
let Ok(layer) = loaded.insert(root, &id) else {
continue;
};
let Some(animation) = loaded.animations.iter().find(|a| a.layer == layer).cloned()
else {
continue;
@@ -1858,6 +1901,34 @@ mod tests {
atlas.pixels[at..at + 4].try_into().unwrap()
}
#[test]
fn content_mod_equipment_textures_pack_under_their_namespace() {
let root = std::env::temp_dir().join(format!("horizon-atlas-equip-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let equipment = root.join("assets/gems/textures/entity/equipment/humanoid");
fs::create_dir_all(&equipment).unwrap();
write_strip(
&equipment.join("ruby.png"),
64,
32,
&[[200, 0, 0, 255]],
None,
);
let other = root.join("assets/gems/textures/item");
fs::create_dir_all(&other).unwrap();
write_strip(&other.join("ruby.png"), 16, 16, &[[0, 200, 0, 255]], None);
fs::write(
root.join(horizon_items::content_namespaces::MARKER),
br#"["gems"]"#,
)
.unwrap();
let atlas = HudAtlas::build(&root).unwrap();
let rect = atlas.sprites["gems:entity/equipment/humanoid/ruby"];
assert_eq!(texel_at(&atlas, rect, 0, 0), [200, 0, 0, 255]);
// Only equipment: the mods' other textures reach the atlas through atlas definitions.
assert!(!atlas.sprites.contains_key("gems:item/ruby"));
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn animated_block_sprites_register_and_pack_their_first_mcmeta_frame() {
let root = animated_root();
let atlas = HudAtlas::build(&root).unwrap();
@@ -1890,6 +1961,39 @@ mod tests {
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16));
assert_eq!(texel_at(&atlas, rect, 0, 0), [0, 255, 0, 255]);
}
#[test]
fn another_namespaces_animated_item_texture_packs_its_first_frame_and_animates() {
let root =
std::env::temp_dir().join(format!("horizon-atlas-ns-anim-{}", std::process::id()));
let atlases = root.join("assets/minecraft/atlases");
let item = root.join("assets/lobby/textures/item/npc");
fs::create_dir_all(&atlases).unwrap();
fs::create_dir_all(&item).unwrap();
fs::write(
atlases.join("items.json"),
r#"{"sources": [{"type": "directory", "source": "item", "prefix": "item/"}]}"#,
)
.unwrap();
// A server pack's NPC skin: two 16x16 frames, green then red.
write_strip(
&item.join("skin.png"),
16,
32,
&[[0, 255, 0, 255], [255, 0, 0, 255]],
Some(r#"{"animation": {"frametime": 3}}"#),
);
let atlas = HudAtlas::build(&root).unwrap();
let rect = atlas.sprites["lobby:item/npc/skin"];
// One frame, not the whole strip, which a model's UVs would span.
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16));
assert_eq!(texel_at(&atlas, rect, 0, 15), [0, 255, 0, 255]);
let (_, animation) = atlas
.sprite_animations
.iter()
.find(|(r, _)| r.u0 == rect.u0 && r.v0 == rect.v0)
.expect("the namespaced sprite animates");
assert_eq!(animation.frame_count(), 2);
}
/// `count` images of `w x h`; layout reads only their sizes.
fn sized(count: usize, w: u32, h: u32) -> Vec<PackedImage> {
(0..count)
+2
View File
@@ -64,12 +64,14 @@ pub mod providers;
pub mod screen_draw;
pub mod screen_effects;
pub mod sculk;
pub mod section_mask;
pub mod settings;
pub mod shader_pack_list;
pub mod signs;
pub mod skins;
pub mod sound_engine;
pub mod sound_events;
pub mod sound_preview;
pub mod spans;
pub mod split_layout;
mod sprite_cache;
+245 -29
View File
@@ -17,7 +17,7 @@
use crate::fast_hash::FxHashMap;
use crate::{
blocks::BlockFacts,
chunk::{Chunk, HEIGHT, Light, MIN_Y, SECTIONS, Section, index},
chunk::{Chunk, ColumnLayout, Light, Section, index},
};
use horizon_core::geometry::{Direction, ShapeOp, VoxelShape};
use horizon_world::registry::{BlockRegistry, BlockStateId};
@@ -273,7 +273,7 @@ pub fn light_chunk(
tables: &LightTables,
sky_light: bool,
) -> Vec<Light> {
region[1][1].expect("centre chunk");
let sections = region[1][1].expect("centre chunk").sections.len();
// Highest occupied section in the region bounds the work.
let mut top_section = 0;
for chunk in region.iter().flatten().flatten() {
@@ -286,7 +286,7 @@ pub fn light_chunk(
}
// One extra section above the highest occupied one: block light rises
// up to 14 blocks into it (sky light there is always 15).
let h = ((top_section + 1) * 16).min(HEIGHT as usize);
let h = ((top_section + 1) * 16).min(sections * 16);
let at = |x: usize, y: usize, z: usize| (y * W + z) * W + x;
// Packed cells (`type << 4 | dampening`), not state ids: the flood fill
// then never touches the large `LightTables::cells` table.
@@ -422,14 +422,21 @@ pub fn light_chunk(
};
spread(&mut sky, &mut sky_queue);
spread(&mut block, &mut queue);
pack_lighting(h, &sky, &block, sky_light)
pack_lighting(sections, h, &sky, &block, sky_light)
}
/// Extract just the centre column's light; never copy worker snapshot sections.
fn pack_lighting(h: usize, sky: &[u8], block: &[u8], sky_light: bool) -> Vec<Light> {
/// Extract just the centre column's light (`sections` of it); never copy
/// worker snapshot sections.
fn pack_lighting(
sections: usize,
h: usize,
sky: &[u8],
block: &[u8],
sky_light: bool,
) -> Vec<Light> {
let at = |x: usize, y: usize, z: usize| (y * W + z) * W + x;
let open = if sky_light { 0xF0 } else { 0x00 };
(0..SECTIONS)
(0..sections)
.map(|si| {
if si * 16 >= h {
return Light::Uniform(open);
@@ -460,13 +467,21 @@ fn pack_lighting(h: usize, sky: &[u8], block: &[u8], sky_light: bool) -> Vec<Lig
/// edits and immutable snapshots retained by other workers.
pub fn apply_lighting(chunk: &mut Chunk, lights: Vec<Light>) {
for (section, light) in chunk.sections.iter_mut().zip(lights) {
// A shared section already lit so stays as it is.
if let (Light::Uniform(a), Light::Uniform(b)) = (&section.light, &light)
&& a == b
{
continue;
}
Arc::make_mut(section).light = light;
}
crate::chunk::share_uniform(&mut chunk.sections);
}
/// `apply_lighting` for a chunk lit before: writes only the sections whose
/// light changed and adds the cells that changed to `changed`.
pub fn apply_relighting(chunk: &mut Chunk, lights: Vec<Light>, changed: &mut LightChanges) {
changed.keyed(chunk.layout());
for (sy, (section, light)) in chunk.sections.iter_mut().zip(lights).enumerate() {
let old = &section.light;
if let (Light::Uniform(a), Light::Uniform(b)) = (old, &light)
@@ -506,7 +521,8 @@ pub fn light_from_layers<L: AsRef<[u8]>>(
mut above: [u8; 256],
sky_light: bool,
) {
for (si, (sky, block)) in layers.iter().enumerate().take(SECTIONS).rev() {
let sections = chunk.sections.len();
for (si, (sky, block)) in layers.iter().enumerate().take(sections).rev() {
let sky = sky.as_ref().filter(|_| sky_light);
let section = Arc::make_mut(&mut chunk.sections[si]);
if sky.is_none() && block.is_none() && above.iter().all(|&v| v == above[0]) {
@@ -526,6 +542,7 @@ pub fn light_from_layers<L: AsRef<[u8]>>(
section.light = Light::Full(data);
section.compact();
}
crate::chunk::share_uniform(&mut chunk.sections);
chunk.lit = true;
chunk.light_loaded = true;
}
@@ -539,7 +556,8 @@ pub fn update_light_layers<L: AsRef<[u8]>>(
sky_light: bool,
) -> Vec<usize> {
let mut changed = Vec::new();
for (si, (sky, block)) in layers.iter().enumerate().take(SECTIONS) {
let sections = chunk.sections.len();
for (si, (sky, block)) in layers.iter().enumerate().take(sections) {
let sky = sky.as_ref().filter(|_| sky_light);
if sky.is_none() && block.is_none() {
continue;
@@ -569,8 +587,15 @@ pub trait LightWorld {
fn section(&self, cx: i32, s: usize, cz: i32) -> Option<&Arc<Section>>;
/// Replace the light of a section `section` returned.
fn set_section_light(&mut self, cx: i32, s: usize, cz: i32, light: Light);
/// The column the world's chunks hold (its level's).
fn layout(&self) -> ColumnLayout;
}
impl<S: std::hash::BuildHasher> LightWorld for HashMap<(i32, i32), Arc<Chunk>, S> {
fn layout(&self) -> ColumnLayout {
self.values()
.next()
.map_or(ColumnLayout::VANILLA, |c| c.layout())
}
fn section(&self, cx: i32, s: usize, cz: i32) -> Option<&Arc<Section>> {
self.get(&(cx, cz))
.filter(|c| c.lit)
@@ -587,6 +612,7 @@ impl<S: std::hash::BuildHasher> LightWorld for HashMap<(i32, i32), Arc<Chunk>, S
/// gets written is copied out on the first write and stored back by `finish`.
struct Region<'w> {
world: &'w mut dyn LightWorld,
layout: ColumnLayout,
slots: Vec<Slot>,
/// Section key (cx, s, cz) to slot, `NONE` where the chunk isn't lit.
index: FxHashMap<Pos, u32>,
@@ -602,6 +628,7 @@ const NONE: u32 = u32::MAX;
impl<'w> Region<'w> {
fn new(world: &'w mut dyn LightWorld) -> Self {
Self {
layout: world.layout(),
world,
slots: Vec::new(),
index: FxHashMap::default(),
@@ -610,10 +637,8 @@ impl<'w> Region<'w> {
}
#[inline]
fn slot(&mut self, x: i32, y: i32, z: i32) -> Option<usize> {
if !(MIN_Y..MIN_Y + HEIGHT).contains(&y) {
return None;
}
let key = (x >> 4, (y - MIN_Y) >> 4, z >> 4);
let s = self.layout.section_index(y)?;
let key = (x >> 4, s as i32, z >> 4);
if self.last.0 != key {
self.last = (key, self.load(key));
}
@@ -641,6 +666,12 @@ impl<'w> Region<'w> {
let slot = self.slot(x, y, z)?;
Some(self.slots[slot].section.get(index(x, y, z)))
}
/// The one state of the section holding `y`, when it has a single one.
fn uniform_state(&mut self, x: i32, y: i32, z: i32) -> Option<u32> {
let slot = self.slot(x, y, z)?;
let section = &self.slots[slot].section;
(section.blocks.bits() == 0).then(|| section.get(0))
}
/// Packed light (sky << 4 | block), None outside loaded, lit chunks.
fn light(&mut self, x: i32, y: i32, z: i32) -> Option<u8> {
let slot = self.slot(x, y, z)?;
@@ -702,12 +733,20 @@ type Pos = (i32, i32, i32);
#[derive(Clone, Debug, Default, PartialEq)]
pub struct LightChanges {
sections: FxHashMap<Pos, u32>,
/// The column the section indexes count in.
layout: ColumnLayout,
}
impl LightChanges {
/// Changes in chunks of `layout`: what the relighter or a light job adds
/// after this (they all come from one level).
fn keyed(&mut self, layout: ColumnLayout) {
debug_assert!(self.sections.is_empty() || self.layout == layout);
self.layout = layout;
}
/// A cell whose stored light changed.
#[inline]
pub fn push(&mut self, (x, y, z): Pos) {
let key = (x >> 4, (y - MIN_Y) >> 4, z >> 4);
let key = (x >> 4, (y - self.layout.min_y()) >> 4, z >> 4);
*self.sections.entry(key).or_default() |= 1 << Self::class(x, y, z);
}
/// Cells of section `key` (x, section index, z) in the given classes.
@@ -732,7 +771,7 @@ impl LightChanges {
self.sections.keys().copied()
}
/// Every section (x, section index, z) whose cells lie within one block
/// of a changed cell, section indexes outside `0..SECTIONS` left out (a
/// of a changed cell, section indexes outside the column left out (a
/// section next to several changed ones comes once for each).
pub fn dirty_sections(&self, mut out: impl FnMut(Pos)) {
// Per class, the bit set of neighbour offsets (dx, dy, dz in -1..=1,
@@ -759,6 +798,7 @@ impl LightChanges {
}
reach
};
let sections = self.layout.sections() as i32;
for (&(x, sy, z), &classes) in &self.sections {
let mut offsets = 0;
let mut rest = classes;
@@ -770,7 +810,7 @@ impl LightChanges {
let o = offsets.trailing_zeros() as i32;
offsets &= offsets - 1;
let s = sy + o / 3 % 3 - 1;
if (0..SECTIONS as i32).contains(&s) {
if (0..sections).contains(&s) {
out((x + o % 3 - 1, s, z + o / 9 - 1));
}
}
@@ -794,11 +834,21 @@ fn set_level(world: &mut Region, sky: bool, p: Pos, value: u8) {
/// `ChunkSkyLightSources`: the Y of the upper block of the highest occluded
/// edge in the column (every block at or above it is a sky source).
fn lowest_sky_source(world: &mut Region, tables: &LightTables, x: i32, z: i32) -> i32 {
let mut y = MIN_Y + HEIGHT - 1;
let (min_y, max_y) = (world.layout.min_y(), world.layout.max_y());
let mut y = max_y - 1;
let Some(mut top) = world.state(x, y, z) else {
return MIN_Y;
return min_y;
};
while y > MIN_Y {
while y > min_y {
// A section of one state that lets light through itself (the air of
// a tall column) holds no source edge: go to its bottom block.
if let Some(state) = world.uniform_state(x, y - 1, z)
&& state == top
&& !tables.edge_occluded(top as usize, top as usize)
{
y = ((y - 1 - min_y) & !15) + min_y;
continue;
}
let Some(bottom) = world.state(x, y - 1, z) else {
break;
};
@@ -808,7 +858,7 @@ fn lowest_sky_source(world: &mut Region, tables: &LightTables, x: i32, z: i32) -
top = bottom;
y -= 1;
}
MIN_Y
min_y
}
/// Vanilla's `checkBlock` + `runLightUpdates` for changed positions on
/// resident, lit chunks: clear the light that may have come through or from
@@ -825,9 +875,27 @@ pub fn relight(
changed: &mut LightChanges,
) {
let mut region = Region::new(world);
changed.keyed(region.layout);
relight_region(&mut region, tables, sky_light, positions, force, changed);
region.finish();
}
/// The columns `changed` touched that border a loaded column not lit yet
/// (`unlit`). `relight` reads and writes only lit columns, so an edit's
/// darkening can take away light such a neighbour sends in and never put it
/// back, and nothing relights the column when that neighbour's own light
/// lands: an air block at 0 beside one at 6 (#2786). Vanilla's light engine
/// keeps one light storage for every chunk, so its edits and its chunk
/// lighting see each other. These columns need a full light job, which
/// lights them from the blocks of their whole 3x3, that neighbour's included.
pub fn beside_unlit(changed: &LightChanges, unlit: impl Fn(i32, i32) -> bool) -> Vec<(i32, i32)> {
let mut columns: Vec<(i32, i32)> = changed.sections().map(|(x, _, z)| (x, z)).collect();
columns.sort_unstable();
columns.dedup();
columns.retain(|&(x, z)| {
(-1..=1).any(|dz| (-1..=1).any(|dx| (dx, dz) != (0, 0) && unlit(x + dx, z + dz)))
});
columns
}
fn relight_region(
world: &mut Region,
tables: &LightTables,
@@ -840,6 +908,7 @@ fn relight_region(
relight_layer(world, tables, false, positions.to_vec(), force, changed)
});
if sky_light {
let (min_y, max_y) = (world.layout.min_y(), world.layout.max_y());
let mut seeds = positions.to_vec();
let mut columns: FxHashMap<(i32, i32), i32> = FxHashMap::default();
crate::spans::time("lt_columns", || {
@@ -851,13 +920,13 @@ fn relight_region(
columns.insert((x, z), source);
// Cells whose source status changed: non-15 at or above the new
// lowest source, and old 15s (sources) below it.
for y in source..MIN_Y + HEIGHT {
for y in source..max_y {
if level(world, true, (x, y, z)).is_some_and(|l| l != 15) {
seeds.push((x, y, z));
}
}
let mut y = source - 1;
while y >= MIN_Y && level(world, true, (x, y, z)) == Some(15) {
while y >= min_y && level(world, true, (x, y, z)) == Some(15) {
seeds.push((x, y, z));
y -= 1;
}
@@ -977,6 +1046,7 @@ fn relight_layer(
#[cfg(test)]
mod tests {
use super::*;
use crate::chunk::{HEIGHT, MIN_Y, SECTIONS};
const AIR: u32 = 0;
const STONE: u32 = 1;
@@ -1004,11 +1074,18 @@ mod tests {
)
}
fn world(edit: impl Fn(&mut Chunk)) -> HashMap<(i32, i32), Arc<Chunk>> {
world_of(crate::chunk::ColumnLayout::VANILLA, edit)
}
/// Lit chunks around (0, 0) of `layout`, stone up to y 0.
fn world_of(
layout: crate::chunk::ColumnLayout,
edit: impl Fn(&mut Chunk),
) -> HashMap<(i32, i32), Arc<Chunk>> {
let mut chunks = HashMap::new();
for z in -1..=1 {
for x in -1..=1 {
let mut c = Chunk::empty(x, z);
for y in MIN_Y..0 {
let mut c = Chunk::with_layout(x, z, layout);
for y in layout.min_y()..0 {
for i in 0..256 {
c.set(i & 15, y, i >> 4, STONE);
}
@@ -1072,6 +1149,70 @@ mod tests {
// Without the shape test the room would be lit (slabs have no dampening).
assert_eq!(sky(&w, 8, 2, 8), 0, "the full bottom face stops sky light");
}
/// #2786: (1, 0) is loaded but its own light job hasn't landed; (0, 0)
/// was lit from the 3x3's blocks, its torch's light and the far torch's
/// in (1, 0) both. Taking its torch away darkens everything the torch
/// lit, the far torch's light at the border too, and `relight` cannot
/// bring that back from a column it doesn't read. Columns like that are
/// lit again whole; without it the border stays at 0 for good once
/// (1, 0)'s light lands beside it.
#[test]
fn an_edit_beside_a_column_not_lit_yet_lights_the_column_again() {
let tables = tables();
let mut w: HashMap<(i32, i32), Arc<Chunk>> = HashMap::new();
for z in -1..=1 {
for x in -1..=1 {
let mut c = Chunk::empty(x, z);
for y in MIN_Y..0 {
for i in 0..256 {
c.set(i & 15, y, i >> 4, STONE);
}
}
if (x, z) == (0, 0) {
c.set(14, 0, 8, TORCH);
} else if (x, z) == (1, 0) {
c.set(6, 0, 8, TORCH);
}
w.insert((x, z), Arc::new(c));
}
}
let light = |w: &HashMap<(i32, i32), Arc<Chunk>>, (x, z): (i32, i32)| {
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
std::array::from_fn(|dx| {
w.get(&(x + dx as i32 - 1, z + dz as i32 - 1)).map(|c| &**c)
})
});
light_chunk(&region, &tables, true)
};
let keys: Vec<_> = w.keys().copied().collect();
for key in &keys {
let lights = light(&w, *key);
let c = Arc::make_mut(w.get_mut(key).unwrap());
apply_lighting(c, lights);
c.lit = *key != (1, 0);
}
// The far torch at x 22 gives 7 at x 15; (0, 0)'s own torch gives 13.
assert_eq!(block(&w, 15, 0, 8), 13);
Arc::make_mut(w.get_mut(&(0, 0)).unwrap()).set(14, 0, 8, AIR);
let mut changed = LightChanges::default();
relight(&mut w, &tables, true, &[(14, 0, 8)], false, &mut changed);
let again = beside_unlit(&changed, |x, z| w.get(&(x, z)).is_some_and(|c| !c.lit));
for &key in &again {
let lights = light(&w, key);
let c = Arc::make_mut(w.get_mut(&key).unwrap());
apply_relighting(c, lights, &mut LightChanges::default());
}
// (1, 0)'s own job lands.
let lights = light(&w, (1, 0));
let c = Arc::make_mut(w.get_mut(&(1, 0)).unwrap());
apply_lighting(c, lights);
c.lit = true;
assert_eq!(block(&w, 16, 0, 8), 8);
assert_eq!(block(&w, 15, 0, 8), 7, "air beside 8 across the border");
assert_eq!(block(&w, 14, 0, 8), 6);
assert!(again.contains(&(0, 0)), "{again:?}");
}
#[test]
fn relight_keeps_unchanged_and_unlit_sections() {
let mut w = world(|_| {});
@@ -1218,6 +1359,74 @@ mod tests {
}
}
#[test]
fn a_tall_column_is_lit_to_its_top() {
let layout = crate::chunk::ColumnLayout::new(-64, 2096).unwrap();
let mut chunk = Chunk::with_layout(0, 0, layout);
for i in 0..256 {
chunk.set(i & 15, 1600, i >> 4, STONE);
}
chunk.set(8, 1500, 8, TORCH);
let region = [[None; 3], [None, Some(&chunk), None], [None; 3]];
let lights = light_chunk(&region, &tables(), true);
assert_eq!(lights.len(), 131);
apply_lighting(&mut chunk, lights);
assert_eq!(chunk.light(8, 1500, 8) & 15, 14);
assert_eq!(chunk.light(8, 1501, 8) & 15, 13);
assert_eq!(chunk.light(8, 1601, 8) >> 4, 15);
assert_eq!(chunk.light(8, 1599, 8) >> 4, 0, "under the roof");
assert_eq!(chunk.light(8, 2031, 8), 0xF0);
// The empty sections above are one shared allocation.
let top = chunk.sections.len() - 1;
assert!(Arc::ptr_eq(&chunk.sections[top], &chunk.sections[top - 10]));
let other = Chunk::with_layout(1, 0, layout);
apply_lighting(&mut other.clone(), vec![Light::Uniform(0xF0); 131]);
let mut lit = other.clone();
apply_lighting(&mut lit, vec![Light::Uniform(0xF0); 131]);
assert!(Arc::ptr_eq(&chunk.sections[top], &lit.sections[top]));
}
#[test]
fn a_tall_column_relights_as_its_full_light_and_keys_its_sections() {
let layout = crate::chunk::ColumnLayout::new(-64, 2096).unwrap();
let mut w = world_of(layout, |c| {
for i in 0..256 {
c.set(i & 15, 1900, i >> 4, STONE);
}
});
let edits = [
((8, 1800, 8), TORCH),
((8, 1900, 8), AIR),
((3, 2020, 3), TORCH),
];
let mut changed = LightChanges::default();
for ((x, y, z), state) in edits {
Arc::make_mut(w.get_mut(&(0, 0)).unwrap()).set(x, y, z, state);
relight(&mut w, &tables(), true, &[(x, y, z)], false, &mut changed);
}
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
std::array::from_fn(|dx| w.get(&(dx as i32 - 1, dz as i32 - 1)).map(|c| &**c))
});
let full = light_chunk(&region, &tables(), true);
assert_eq!(full.len(), 131);
for (si, s) in full.iter().enumerate() {
for i in 0..4096 {
assert_eq!(
s.get(i),
w[&(0, 0)].sections[si].light(i),
"section {si} index {i}"
);
}
}
// y 2020 is section 130 of the tall column, the top one.
assert!(
changed
.sections()
.any(|(x, sy, z)| (x, sy, z) == (0, 130, 0))
);
let mut dirty = Vec::new();
changed.dirty_sections(|s| dirty.push(s.1));
assert!(dirty.iter().all(|&sy| (0..131).contains(&sy)), "{dirty:?}");
}
#[test]
fn incremental_relight_matches_full_relight() {
let mut w = world(|c| {
c.set(8, 0, 8, TORCH);
@@ -1448,9 +1657,10 @@ mod tests {
.iter()
.all(|v| matches!(v, Light::Uniform(n) if *n == if sky { 0xF0 } else { 0 }))
);
let section = Arc::as_ptr(&chunk.sections[0]);
apply_lighting(&mut chunk, lights);
assert_eq!(section, Arc::as_ptr(&chunk.sections[0]));
// Uniform sections are one shared allocation (`share_uniform`).
let first = &chunk.sections[0];
assert!(chunk.sections.iter().all(|s| Arc::ptr_eq(s, first)));
}
}
@@ -1521,7 +1731,7 @@ mod tests {
let mut old = fixture.clone();
let mut new = fixture.clone();
baseline_apply(&mut old, baseline_pack(&fixture, h, &sky, &block));
apply_lighting(&mut new, pack_lighting(h, &sky, &block, true));
apply_lighting(&mut new, pack_lighting(SECTIONS, h, &sky, &block, true));
for (a, b) in old.sections.iter().zip(&new.sections) {
for i in 0..4096 {
assert_eq!(a.light(i), b.light(i));
@@ -1538,14 +1748,20 @@ mod tests {
let mut current = fixture.clone();
let held = shared.then(|| current.clone());
// Warm up and detach for the uniquely-owned scenario.
apply_lighting(&mut current, pack_lighting(h, &sky, &block, true));
apply_lighting(&mut current, pack_lighting(SECTIONS, h, &sky, &block, true));
let started = Instant::now();
for _ in 0..500 {
let retained = shared.then(|| current.clone());
if optimized {
apply_lighting(
black_box(&mut current),
pack_lighting(h, black_box(&sky), black_box(&block), true),
pack_lighting(
SECTIONS,
h,
black_box(&sky),
black_box(&block),
true,
),
);
} else {
baseline_apply(
+194 -12
View File
@@ -186,8 +186,15 @@ impl Column {
self.push(span);
}
}
/// `kinds`: `state_kinds`, per block state.
fn from_chunk(chunk: &Chunk, kinds: &[u8], x: i32, z: i32) -> Self {
/// `kinds`: `state_kinds`, per block state; `water`: `water_source(kinds)`,
/// what blocks holding water (kelp, seagrass, waterlogged ones) are drawn as.
fn from_chunk(
chunk: &Chunk,
kinds: &[u8],
water: Option<BlockStateId>,
x: i32,
z: i32,
) -> Self {
let kind = |state: BlockStateId| kinds.get(state.0 as usize).copied().unwrap_or(0);
let face_light = |y: i32| {
let mut sky = 0;
@@ -212,7 +219,7 @@ impl Column {
};
let mut column = Self::default();
for (sy, section) in chunk.sections.iter().enumerate() {
let min = MIN_Y + sy as i32 * 16;
let min = chunk.min_y() + sy as i32 * 16;
let uniform = section.blocks.bits() == 0
&& section.biomes.bits() == 0
&& matches!(section.light, crate::chunk::Light::Uniform(_));
@@ -222,11 +229,17 @@ impl Column {
let step = if uniform { 16 } else { 1 };
for offset in (0..16).step_by(step) {
let y = min + offset;
let state = BlockStateId(chunk.get(x, y, z));
let kind = kind(state);
let mut state = BlockStateId(chunk.get(x, y, z));
let mut kind = kind(state);
if kind & KEPT == 0 {
continue;
}
if kind & WATERY != 0
&& let Some(water) = water
{
state = water;
kind = kinds[water.0 as usize];
}
let light = face_light(y);
column.push(Span {
min: y as i16,
@@ -256,7 +269,7 @@ impl Column {
&& let Some((height, state)) = water
{
column.surface = Some(Surface {
height: MIN_Y,
height: chunk.min_y(),
state: BlockStateId(
kinds.iter().position(|&kind| kind & AIR != 0).unwrap_or(0) as u32
),
@@ -269,15 +282,28 @@ impl Column {
}
/// A chunk's 256 columns (`Column::from_chunk`), on the far-terrain worker.
fn summarize(chunk: &Chunk, kinds: &[u8]) -> Arc<[Column; 256]> {
let water = water_source(kinds);
Arc::new(std::array::from_fn(|i| {
Column::from_chunk(chunk, kinds, (i & 15) as i32, (i >> 4) as i32)
Column::from_chunk(chunk, kinds, water, (i & 15) as i32, (i >> 4) as i32)
}))
}
/// `state_kinds` bits: kept in columns, a fluid, the air state; the light
/// emission is in the high nibble.
/// The water source state: the first plain kept fluid without light
/// (`minecraft:water` comes before lava, and its level 0 first).
fn water_source(kinds: &[u8]) -> Option<BlockStateId> {
kinds
.iter()
.position(|&kind| kind == KEPT | FLUID)
.map(|i| BlockStateId(i as u32))
}
/// `state_kinds` bits: kept in columns, a fluid, the air state, a block
/// holding water; the light emission is in the high nibble.
const KEPT: u8 = 1;
const FLUID: u8 = 2;
const AIR: u8 = 4;
/// A block holding water that draws as none (kelp, seagrass, bubble columns,
/// waterlogged blocks that aren't kept): far terrain draws water there, or
/// kelp reaching the surface leaves holes in the sea.
const WATERY: u8 = 8;
/// What `Column::from_chunk` needs of each block state, read from the facts
/// once: matching their names for every block made observing a chunk slow.
fn state_kinds(facts: &BlockFacts) -> Vec<u8> {
@@ -296,14 +322,29 @@ fn state_kinds(facts: &BlockFacts) -> Vec<u8> {
|| f.full_collision
|| f.name.ends_with("_leaves")
|| chorus
|| fluid);
(u8::from(kept) * KEPT)
| (u8::from(fluid) * FLUID)
|| fluid
|| nearly_full(&f.name));
let watery = !kept && !facts.is_air(state) && f.fluid == crate::blocks::Fluid::Water;
(u8::from(kept || watery) * KEPT)
| (u8::from(fluid || watery) * FLUID)
| (u8::from(watery) * WATERY)
| (u8::from(state == facts.air) * AIR)
| (f.emission.min(15) << 4)
})
.collect()
}
/// Blocks that fill their cell but neither draw solid nor collide as a full
/// block: far terrain draws them as blocks, or patches of powder snow leave
/// holes in mountains (and paths and fields dips).
fn nearly_full(name: &str) -> bool {
matches!(
name,
"minecraft:powder_snow"
| "minecraft:dirt_path"
| "minecraft:farmland"
| "minecraft:honey_block"
) || name.ends_with("_stairs")
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct TileKey {
pub x: i32,
@@ -1085,6 +1126,16 @@ impl Lod {
})
.copied()
.collect();
// Retiring fine columns are empty stand-ins, excluded from `active`
// while their normal meshes still cover the gap. Request their detail
// explicitly: neither the active-tile requests nor sampling an already
// ready stand-in can fill it, so its GPU sections would never retire.
keys.extend(retiring_detail(
&self.plan,
&self.retiring,
&self.saved,
&self.detail_masks,
));
keys.sort_unstable_by_key(|key| (key.distance(camera), *key));
let mut chunks = FxHashSet::default();
for key in keys {
@@ -1589,11 +1640,84 @@ impl Drop for Lod {
}
}
}
/// Fine retiring stand-ins need actual column detail before `Plan::replaces`
/// can release their normal meshes. The caller keeps the usual 32-column
/// request budget and waits for the replacement mesh before changing cutout.
fn retiring_detail<'a>(
plan: &'a Plan,
retiring: &'a FxHashSet<(i32, i32)>,
saved: &'a Saved,
masks: &'a FxHashMap<TileKey, [u64; 4]>,
) -> impl Iterator<Item = TileKey> + 'a {
retiring.iter().filter_map(move |&(x, z)| {
let key = TileKey { x, z, level: 0 };
(plan.nodes.contains(&key)
&& !saved.contains_key(&(x, z))
&& masks.get(&key) != Some(&[u64::MAX; 4]))
.then_some(key)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mesher::TerrainVertex;
#[test]
fn retiring_fine_stand_ins_request_detail_before_their_gpu_meshes_can_retire() {
let column = (12, 0);
let fine = TileKey {
x: 12,
z: 0,
level: 0,
};
let mut plan = Plan::default();
plan.nodes.insert(fine);
let ready: FxHashMap<TileKey, ()> = [(fine, ())].into_iter().collect();
let retiring = [column].into_iter().collect();
let saved = Saved::default();
let mut masks = FxHashMap::default();
// A covered stand-in is ready, but empty: ordinary sampling skips it,
// and selection leaves it out of `active`. It cannot yet replace the
// old full-detail column, even though the near world has unloaded it.
assert!(!plan.replaces(column, &ready, |key| masks.get(&key)
== Some(&[u64::MAX; 4])));
assert_eq!(
retiring_detail(&plan, &retiring, &saved, &masks).collect::<Vec<_>>(),
[fine]
);
// The existing RequestDetail -> apply_fine path marks complete detail;
// only then may selection retire it and publish the new mesh cutout.
masks.insert(fine, [u64::MAX; 4]);
assert!(
retiring_detail(&plan, &retiring, &saved, &masks)
.next()
.is_none()
);
assert!(plan.replaces(column, &ready, |key| masks.get(&key)
== Some(&[u64::MAX; 4])));
}
#[test]
fn retiring_detail_requests_only_planned_columns_still_missing_detail() {
let fine = |x| TileKey { x, z: 0, level: 0 };
let mut plan = Plan::default();
plan.nodes.extend((0..4).map(fine));
plan.nodes.insert(fine(5));
let retiring = (0..5).map(|x| (x, 0)).collect();
let saved = [((1, 0), Arc::new(std::array::from_fn(|_| Column::default())))]
.into_iter()
.collect();
let masks = [
(fine(2), [u64::MAX; 4]),
(fine(3), [u64::MAX, u64::MAX, u64::MAX, u64::MAX - 1]),
]
.into_iter()
.collect();
let mut requested: Vec<_> = retiring_detail(&plan, &retiring, &saved, &masks).collect();
requested.sort_unstable();
// Saved or complete detail needs no request; an unplanned retiree and
// a planned column that is not retiring must not consume the budget.
assert_eq!(requested, [fine(0), fine(3)]);
}
#[test]
fn reduced_far_detail_only_merges_tiles_away_from_the_near_terrain() {
let leaves = |plan: &Plan| {
plan.nodes
@@ -1840,6 +1964,40 @@ mod tests {
}
}
#[test]
fn nearly_full_blocks_are_kept() {
assert!(nearly_full("minecraft:powder_snow"));
assert!(nearly_full("minecraft:dirt_path"));
assert!(nearly_full("minecraft:oak_stairs"));
assert!(!nearly_full("minecraft:oak_fence"));
assert!(!nearly_full("minecraft:wheat"));
}
#[test]
fn kelp_to_the_surface_is_drawn_as_water() {
// 0 air, 1 stone, 2 water, 3 kelp (holds water, draws as none).
let kinds = [AIR, KEPT, KEPT | FLUID, KEPT | FLUID | WATERY];
let mut chunk = Chunk::empty(0, 0);
for x in [3, 4] {
for y in MIN_Y..40 {
chunk.set(x, y, 3, 1);
}
}
for y in 40..63 {
chunk.set(3, y, 3, 3);
chunk.set(4, y, 3, 2);
}
let water = water_source(&kinds);
assert_eq!(water, Some(BlockStateId(2)));
let kelp = Column::from_chunk(&chunk, &kinds, water, 3, 3);
let sea = Column::from_chunk(&chunk, &kinds, water, 4, 3);
assert_eq!(kelp.spans, sea.spans);
assert_eq!(
kelp.spans.last().map(|s| (s.min, s.max, s.state)),
Some((40, 63, BlockStateId(2)))
);
assert_eq!(kelp.surface.unwrap().water, Some((63, BlockStateId(2))));
assert_eq!(kelp.surface.unwrap().height, 40);
}
#[test]
fn coarse_voxels_reduce_the_area_instead_of_inflating_one_leaf() {
let span = |min, max, state| Span {
min,
@@ -2035,6 +2193,24 @@ mod tests {
chunk.set(8, 71, 8, chorus.0);
chunk.set(8, 72, 8, chorus.0);
chunk.set(8, 73, 8, flower.0);
// Kelp to the surface is water; powder snow and a waterlogged stair
// are kept as themselves.
let state = |name: &str| assets.registry.default_state(name).unwrap();
let (kelp, powder_snow) = (state("minecraft:kelp"), state("minecraft:powder_snow"));
let stair = facts
.states
.iter()
.position(|f| &*f.name == "minecraft:oak_stairs" && f.waterlogged)
.map(|i| BlockStateId(i as u32))
.unwrap();
chunk.set(2, 54, 2, stone.0);
for y in 55..64 {
chunk.set(2, y, 2, kelp.0);
}
chunk.set(6, 90, 6, stone.0);
chunk.set(6, 91, 6, powder_snow.0);
chunk.set(10, 54, 10, stone.0);
chunk.set(10, 55, 10, stair.0);
lod.observe(Arc::new(chunk.clone()), &facts);
receive_observed(&mut lod);
let observed = &lod.saved[&(-1, -1)];
@@ -2052,6 +2228,12 @@ mod tests {
.collect::<Vec<_>>(),
vec![(70, 71, end_stone), (71, 73, chorus), (73, 74, flower)]
);
let kelp_column = &observed[2 * 16 + 2];
assert_eq!(kelp_column.surface.unwrap().height, 55);
assert_eq!(kelp_column.surface.unwrap().water, Some((64, water)));
assert_eq!(observed[6 * 16 + 6].surface.unwrap().state, powder_snow);
assert_eq!(observed[6 * 16 + 6].surface.unwrap().height, 92);
assert_eq!(observed[10 * 16 + 10].surface.unwrap().state, stair);
let fine = TileKey {
x: -1,
+13 -2
View File
@@ -45,9 +45,20 @@ pub fn data_dir() -> PathBuf {
std::env::temp_dir()
}
/// Where the launcher runs the game and keeps its saves: `HORIZON_HOME`, else `horizon` in the
/// platform data folder (launcher/src/backend.rs `game_home`).
/// `--home DIR`, set once at start; it wins over `HORIZON_HOME`.
static HOME: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
/// Makes `game_home` return `home` for the rest of the run (`--home`).
pub fn set_game_home(home: PathBuf) {
let _ = HOME.set(home);
}
/// Where the launcher runs the game and keeps its saves: `--home`, else `HORIZON_HOME`, else
/// `horizon` in the platform data folder (launcher/src/backend.rs `game_home`).
pub fn game_home() -> PathBuf {
if let Some(home) = HOME.get() {
return home.clone();
}
match std::env::var_os("HORIZON_HOME").filter(|h| !h.is_empty()) {
Some(h) => PathBuf::from(h),
None => data_dir().join("horizon"),
+2 -2
View File
@@ -639,7 +639,7 @@ impl Padded {
fn new(region: &[[Option<&Chunk>; 3]; 3], sy: i32) -> Self {
let mut blocks = vec![0u32; P * P * P];
let mut light = vec![0u8; P * P * P];
let base_y = MIN_Y + sy * 16;
let base_y = region[1][1].map_or(MIN_Y, Chunk::min_y) + sy * 16;
// The section itself is unpacked in one pass; the border cells of the
// neighbouring sections are read one by one.
let mut own = Box::new([0u32; 4096]);
@@ -848,7 +848,7 @@ impl Mesher<'_> {
}
let pad = Padded::new(region, sy);
out.visibility = visibility(&pad, &self.tables.info);
let base_y = MIN_Y + sy * 16;
let base_y = center.min_y() + sy * 16;
let mut tints = Tints::new(
region,
self.biomes,
+8 -7
View File
@@ -8,7 +8,7 @@
//! changed.
use crate::{
blocks::{BlockFacts, Fluid, MAP_COLORS},
chunk::{Chunk, MIN_Y, SECTIONS},
chunk::Chunk,
fast_hash::FxHashMap,
world::World,
};
@@ -113,7 +113,8 @@ fn scan_column(
return (color | WALL, head as i16, 0);
}
}
while y > MIN_Y {
let bottom = chunk.min_y();
while y > bottom {
let (mut color, flags) = palette.get(chunk.get(x, y, z));
if color == 0 {
y -= 1;
@@ -123,7 +124,7 @@ fn scan_column(
let fluid = flags & FLUID;
if fluid != 0 {
let mut below = y - 1;
while below > MIN_Y && palette.get(chunk.get(x, below, z)).1 & FLUID != 0 {
while below > bottom && palette.get(chunk.get(x, below, z)).1 & FLUID != 0 {
depth = depth.saturating_add(1);
below -= 1;
}
@@ -134,11 +135,11 @@ fn scan_column(
}
return (color, y as i16, depth);
}
(0, MIN_Y as i16, 0)
(0, bottom as i16, 0)
}
/// The highest block of a chunk worth scanning from.
fn top_of(chunk: &Chunk, air: u32, layer: Layer) -> i32 {
let surface = (0..SECTIONS)
let surface = (0..chunk.sections.len())
.rev()
.find(|&s| {
chunk
@@ -146,7 +147,7 @@ fn top_of(chunk: &Chunk, air: u32, layer: Layer) -> i32 {
.get(s)
.is_some_and(|sec| !sec.is_uniform(air))
})
.map_or(MIN_Y, |s| MIN_Y + s as i32 * 16 + 15);
.map_or(chunk.min_y(), |s| chunk.min_y() + s as i32 * 16 + 15);
match layer {
Layer::Surface => surface,
Layer::Below(head) => surface.min(head),
@@ -156,7 +157,7 @@ fn scan_tile(chunk: &Chunk, palette: &Palette, air: u32, layer: Layer, revision:
let top = top_of(chunk, air, layer);
let mut tile = Tile {
color: [0; 256],
height: [MIN_Y as i16; 256],
height: [chunk.min_y() as i16; 256],
depth: [0; 256],
revision,
layer,
@@ -45,6 +45,7 @@ pub struct ModelInstance {
pub layer: u32,
pub shade: f32,
}
#[derive(Default)]
pub struct PreparedModels {
pub catalog: StateModels,
pub placements: Vec<(BlockPos, BlockStateId)>,
@@ -42,6 +42,9 @@ pub struct BakedModel {
pub cube: Option<[super::cube::CubeStyle; 6]>,
}
type VariantKey = (String, [u8; 3], bool);
/// Empty by default: no state has a model (a dedicated server, which draws
/// nothing).
#[derive(Default)]
pub struct StateModels {
/// Each definition's choices, a variant standing as its index in `baked`.
choices: Vec<CompiledChoices<u32>>,
+116 -8
View File
@@ -141,9 +141,7 @@ impl Opt {
| Opt::ChunkFade
| Opt::ImprovedTransparency
| Opt::ChunkBuilder
// Saved, but the creative tabs, the quit keys and the mouse
// don't read them yet.
| Opt::OperatorItemsTab
// Saved, but the quit keys and the mouse don't read them yet.
| Opt::QuitShortcuts
| Opt::CtrlClickEmulatesRightClick
)
@@ -952,6 +950,24 @@ pub fn set_slider(opt: Opt, s: &mut Settings, v: f32) {
apply_preset(s);
}
}
/// A slider pressed or dragged to `v`: apply it live, and tell the game when
/// a category volume changed (`Options.createSoundSliderOptionInstance`'s
/// callback, which `OptionInstance.set` runs only for a new value).
fn drag_slider(opt: Opt, s: &mut Settings, v: f32, events: &mut Vec<Event>) {
let volume = |s: &Settings| match opt {
Opt::Volume(0) => Some(s.master_volume),
Opt::Volume(i) => Some(s.volumes[i]),
_ => None,
};
let before = volume(s);
set_slider(opt, s, v);
events.push(Event::SliderDrag);
if let (Opt::Volume(source), Some(after)) = (opt, volume(s))
&& before != Some(after)
{
events.push(Event::Volume(source, after));
}
}
/// GraphicsPreset.apply: the options Fast, Fancy and Fabulous set (texture
/// filtering and anisotropy have no option here yet).
pub fn apply_preset(s: &mut Settings) {
@@ -1106,6 +1122,9 @@ pub enum Event {
Changed,
/// A slider is being dragged: apply settings live but defer saving.
SliderDrag,
/// A category volume slider (`SoundSource` index) moved to a new value,
/// after its `SliderDrag` (the sound slider's `onValueUpdate`).
Volume(usize, f32),
/// The language changed (reload translations).
Language(String),
/// The pack selection changed (reload resources).
@@ -2532,6 +2551,7 @@ impl OptionsUi {
shown,
hover || dragging,
true,
dragging,
);
}
WKind::Text {
@@ -2747,8 +2767,7 @@ impl OptionsUi {
{
let v = ui::slider_value(wd.x, wd.w, x);
self.slider_value = Some((opt, v, Instant::now()));
set_slider(opt, s, v);
events.push(Event::SliderDrag);
drag_slider(opt, s, v, &mut events);
}
return events;
}
@@ -2861,8 +2880,7 @@ impl OptionsUi {
self.dragging = Some(opt);
let v = ui::slider_value(wd.x, wd.w, x);
self.slider_value = Some((opt, v, Instant::now()));
set_slider(opt, s, v);
events.push(Event::SliderDrag);
drag_slider(opt, s, v, &mut events);
}
WKind::LanguageRow { code, .. } => {
if self.language_choice.as_deref() == Some(code.as_str()) {
@@ -3415,6 +3433,16 @@ mod tests {
assert!(loaded.toggle_attack && loaded.toggle_use);
}
#[test]
fn operator_items_tab_option_can_be_switched() {
// The creative screen reads it (CreativeModeInventoryScreen.hasPermissions).
assert!(Opt::OperatorItemsTab.available());
let mut s = Settings::default();
assert!(!s.operator_items_tab);
Opt::OperatorItemsTab.cycle(&mut s, false, [854, 480]);
assert!(s.operator_items_tab);
}
#[test]
fn render_distance_slider_round_trips_through_extended_options_range() {
let mut s = Settings::default();
@@ -3645,6 +3673,57 @@ mod tests {
assert_eq!(ui.page(), Page::NativeMod(0));
}
#[test]
fn a_native_mods_key_shows_and_keeps_the_key_pressed() {
// #3539: Zoom's key set to R showed another key.
let atlas = atlas();
let mut s = Settings::default();
let mut ui = OptionsUi::default();
let size = (480.0, 240.0);
s.mods.installed.push(crate::mod_list::Installed {
id: "zoom".into(),
title: "Zoom 2.0.0".into(),
description: String::new(),
problem: None,
dev: false,
native: Some(crate::mod_list::NativePage {
keys: vec![["zoom".into(), "Zoom key".into(), "key.keyboard.z".into()]],
buttons: Vec::new(),
}),
});
ui.show(Page::NativeMod(0));
let key_label = |ui: &mut OptionsUi, s: &Settings| {
let (widgets, ..) = ui.layout(s, size.0, size.1);
widgets.into_iter().find_map(|w| match w.kind {
WKind::Button {
label,
act: Act::NativeKey(0, 0),
..
} => Some((label.plain(), w.x, w.y)),
_ => None,
})
};
let (label, x, y) = key_label(&mut ui, &s).expect("the key's row");
assert_eq!(label, "Z");
ui.click(
&mut s,
&atlas,
x + 1.0,
y + 1.0,
size,
[480, 240],
false,
false,
);
assert_eq!(
ui.key(&mut s, &atlas, winit::keyboard::KeyCode::KeyR, Some("r")),
vec![Event::Changed]
);
assert_eq!(key_label(&mut ui, &s).unwrap().0, "R");
let saved = crate::mod_list::ModList::parse(&s.mods.serialize());
assert_eq!(saved.keys_of("zoom"), vec![("zoom", "key.keyboard.r")]);
}
#[test]
fn option_tooltip_keys_follow_value_dependent_descriptions() {
let mut s = Settings::default();
@@ -4093,7 +4172,7 @@ mod tests {
for cx in [-100.0, 250.0, 1000.0] {
assert_eq!(
ui.click(&mut s, &atlas, cx, -100.0, size, [854, 480], true, false),
vec![Event::SliderDrag]
vec![Event::SliderDrag, Event::Volume(0, s.master_volume)]
);
}
assert_eq!(ui.release(), vec![Event::ButtonClick, Event::Changed]);
@@ -4103,6 +4182,35 @@ mod tests {
);
}
#[test]
fn a_moved_volume_slider_reports_its_category_and_value() {
let atlas = atlas();
let size = (480.0, 320.0);
let mut s = Settings::default();
let mut ui = OptionsUi::default();
ui.show(Page::Sound);
let hostile = SOUND_SOURCES.iter().position(|n| *n == "hostile").unwrap();
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
let slider = widgets
.iter()
.find(|wd| matches!(wd.kind, WKind::Slider { opt: Opt::Volume(i), .. } if i == hostile))
.unwrap();
let y = slider.y + 1.0;
let x = slider.x + 4.0 + (slider.w - 8.0) * 0.5;
let events = ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
let volume = s.volumes[hostile];
assert!((volume - 0.5).abs() < 1e-3, "{volume}");
assert_eq!(
events,
vec![Event::SliderDrag, Event::Volume(hostile, volume)]
);
// OptionInstance.set: an unchanged value runs no callback.
assert_eq!(
ui.click(&mut s, &atlas, x, -50.0, size, [854, 480], true, false),
vec![Event::SliderDrag]
);
}
#[test]
fn classic_sliders_follow_the_pointer_and_snap_on_release() {
let atlas = atlas();
+21 -1
View File
@@ -278,7 +278,8 @@ fn read_meta(source: &Source) -> Option<serde_json::Value> {
Source::Dir(d) => fs::read(d.join("pack.mcmeta")).ok()?,
Source::Zip(z) => Zip::open(z).ok()?.read("pack.mcmeta").ok()?,
};
serde_json::from_slice(&bytes).ok()
// Gson reads past a UTF-8 byte order mark; serde_json rejects it.
serde_json::from_slice(bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(&bytes)).ok()
}
fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
pack_for(id, title, source, RESOURCE_FORMAT)
@@ -938,6 +939,25 @@ impl SelectionModel {
mod tests {
use super::*;
#[test]
fn mcmeta_with_utf8_bom_is_read() {
let dir = std::env::temp_dir().join(format!("horizon-bom-{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
let mut meta = b"\xEF\xBB\xBF".to_vec();
meta.extend_from_slice(br#"{"pack":{"description":"d","pack_format":97}}"#);
fs::write(dir.join("pack.mcmeta"), &meta).unwrap();
assert!(read_meta(&Source::Dir(dir.clone())).is_some());
// Without a BOM the same JSON still reads; other leading bytes still fail.
fs::write(dir.join("pack.mcmeta"), &meta[3..]).unwrap();
assert!(read_meta(&Source::Dir(dir.clone())).is_some());
fs::write(
dir.join("pack.mcmeta"),
[b"\xEF\xBB".as_slice(), &meta[3..]].concat(),
)
.unwrap();
assert!(read_meta(&Source::Dir(dir.clone())).is_none());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn compatibility_ranges() {
let v = |s: &str| serde_json::from_str::<serde_json::Value>(s).unwrap();
assert!(compatible(&v(
+25 -1
View File
@@ -1042,6 +1042,8 @@ pub struct ParticleEngine {
level_random: LegacyRandom,
/// `SpellParticle.RANDOM`.
spell_random: LegacyRandom,
/// The local player's eye while it looks through a spyglass in first person.
scope_eye: Option<[f64; 3]>,
/// Seeds for each particle's own random (`RandomSource.create()`).
seeds: LegacyRandom,
/// When set, particles ask the level for other particles here instead
@@ -1080,6 +1082,7 @@ impl ParticleEngine {
random: LegacyRandom::new(0x7061_7274),
level_random: LegacyRandom::new(0x6c65_7665),
spell_random: LegacyRandom::new(0x7370_656c),
scope_eye: None,
seeds: LegacyRandom::new(0x7365_6564),
requested: None,
names: Vec::new(),
@@ -1437,6 +1440,16 @@ impl ParticleEngine {
}
}
}
/// Set each frame to the local player's eye while it scopes in first person.
pub fn set_scope_eye(&mut self, eye: Option<[f64; 3]>) {
self.scope_eye = eye;
}
/// `SpellParticle.isCloseToScopingPlayer`: within 3 blocks of that eye.
fn close_to_scoping_player(&self, pos: [f64; 3]) -> bool {
self.scope_eye.is_some_and(|eye| {
(eye[0] - pos[0]).powi(2) + (eye[1] - pos[1]).powi(2) + (eye[2] - pos[2]).powi(2) <= 9.0
})
}
/// `ClientPacketListener.handleParticleEvent` for `ServerLevel.sendParticles`.
pub fn send_particles(
&mut self,
@@ -2636,6 +2649,12 @@ impl ParticleEngine {
q.size *= 0.75;
q.lifetime = (8.0 / (f64::from(r.next_float()) * 0.8 + 0.2)) as i32;
q.has_physics = false;
// SpellParticle's constructor: setAlpha(0.0F), which also sets originalAlpha.
// MobEffectProvider sets the option's alpha after it, so an entity
// effect starts visible and the first tick hides it.
if self.close_to_scoping_player(q.position) {
q.rgba[3] = 0.0;
}
match ty {
T::Witch => {
let b = self.random.next_float() * 0.5 + 0.35;
@@ -3743,7 +3762,12 @@ impl ParticleEngine {
Behaviour::Spell { original_alpha } => {
p.base_tick(level);
self.sprite_from_age(p);
p.rgba[3] += (original_alpha - p.rgba[3]) * 0.05;
// SpellParticle.tick: alpha 0 while close, else eased back to originalAlpha.
if self.close_to_scoping_player(p.position) {
p.rgba[3] = 0.0;
} else {
p.rgba[3] += (original_alpha - p.rgba[3]) * 0.05;
}
}
Behaviour::Cloud => {
p.base_tick(level);
@@ -121,6 +121,10 @@ pub fn warden_can_listen(event: &str) -> bool {
|| event == SHRIEK
|| shrieker_can_listen(event)
}
/// `GameEventTags.ALLAY_CAN_LISTEN`: note block notes.
pub fn allay_can_listen(event: &str) -> bool {
event == "minecraft:note_block_play"
}
/// `GameEventTags.IGNORE_VIBRATIONS_SNEAKING`.
pub fn ignored_when_sneaking(event: &str) -> bool {
matches!(
@@ -0,0 +1,164 @@
//! Sets of section indexes in a chunk column, a bit per section.
//!
//! Vanilla's tallest level has 254 sections (`DimensionType.Y_SIZE / 16`), and
//! light data adds a section below and above it: 256 bits hold every layout a
//! dimension type allows.
/// Section indexes of one column, 0 being the lowest.
#[derive(Clone, Copy, Default, PartialEq, Eq, Hash)]
pub struct SectionMask([u64; 4]);
impl SectionMask {
pub const CAPACITY: usize = 256;
pub const EMPTY: Self = Self([0; 4]);
pub const ALL: Self = Self([u64::MAX; 4]);
/// The indexes `0..n`.
pub const fn below(n: usize) -> Self {
let mut words = [0; 4];
let mut w = 0;
while w < 4 {
let bits = n.saturating_sub(w * 64);
words[w] = if bits >= 64 {
u64::MAX
} else {
(1 << bits) - 1
};
w += 1;
}
Self(words)
}
pub fn single(i: usize) -> Self {
let mut mask = Self::EMPTY;
mask.insert(i);
mask
}
#[inline]
pub fn contains(&self, i: usize) -> bool {
i < Self::CAPACITY && self.0[i >> 6] >> (i & 63) & 1 != 0
}
#[inline]
pub fn insert(&mut self, i: usize) {
self.0[i >> 6] |= 1 << (i & 63);
}
#[inline]
pub fn remove(&mut self, i: usize) {
self.0[i >> 6] &= !(1 << (i & 63));
}
pub fn is_empty(&self) -> bool {
self.0 == [0; 4]
}
pub fn len(&self) -> usize {
self.0.iter().map(|w| w.count_ones() as usize).sum()
}
/// The indexes from `i` up.
pub fn from(self, i: usize) -> Self {
self & !Self::below(i)
}
/// The indexes, lowest first.
pub fn iter(self) -> Indexes {
Indexes(self)
}
}
impl std::ops::BitOr for SectionMask {
type Output = Self;
fn bitor(self, other: Self) -> Self {
Self(std::array::from_fn(|w| self.0[w] | other.0[w]))
}
}
impl std::ops::BitOrAssign for SectionMask {
fn bitor_assign(&mut self, other: Self) {
*self = *self | other;
}
}
impl std::ops::BitAnd for SectionMask {
type Output = Self;
fn bitand(self, other: Self) -> Self {
Self(std::array::from_fn(|w| self.0[w] & other.0[w]))
}
}
impl std::ops::Not for SectionMask {
type Output = Self;
fn not(self) -> Self {
Self(self.0.map(|w| !w))
}
}
impl std::fmt::Debug for SectionMask {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_set().entries(self.iter()).finish()
}
}
impl FromIterator<usize> for SectionMask {
fn from_iter<I: IntoIterator<Item = usize>>(indexes: I) -> Self {
let mut mask = Self::EMPTY;
for i in indexes {
mask.insert(i);
}
mask
}
}
/// `SectionMask::iter`: the set's indexes in ascending order.
#[derive(Clone, Copy, Debug)]
pub struct Indexes(SectionMask);
impl Iterator for Indexes {
type Item = usize;
#[inline]
fn next(&mut self) -> Option<usize> {
let words = &mut (self.0).0;
let w = words.iter().position(|&w| w != 0)?;
let bit = words[w].trailing_zeros() as usize;
words[w] &= words[w] - 1;
Some(w * 64 + bit)
}
fn size_hint(&self) -> (usize, Option<usize>) {
let len = self.0.len();
(len, Some(len))
}
}
impl ExactSizeIterator for Indexes {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sets_across_words() {
let mut mask = SectionMask::EMPTY;
for i in [0, 23, 63, 64, 130, 255] {
mask.insert(i);
}
assert_eq!(mask.iter().collect::<Vec<_>>(), [0, 23, 63, 64, 130, 255]);
assert_eq!(mask.len(), 6);
assert!(mask.contains(130) && !mask.contains(131) && !mask.contains(300));
mask.remove(64);
assert_eq!(mask.from(24).iter().collect::<Vec<_>>(), [63, 130, 255]);
assert_eq!(mask.from(256), SectionMask::EMPTY);
assert_eq!(mask.from(0), mask);
assert!(SectionMask::EMPTY.is_empty() && !mask.is_empty());
}
#[test]
fn below_counts_whole_and_partial_words() {
for n in [0, 1, 24, 63, 64, 65, 131, 254, 256] {
let mask = SectionMask::below(n);
assert_eq!(mask.len(), n);
assert_eq!(mask.iter().collect::<Vec<_>>(), (0..n).collect::<Vec<_>>());
}
assert_eq!(SectionMask::below(1000), SectionMask::ALL);
}
#[test]
fn operators() {
let a: SectionMask = [1, 70, 200].into_iter().collect();
let b: SectionMask = [70, 3].into_iter().collect();
assert_eq!((a | b).iter().collect::<Vec<_>>(), [1, 3, 70, 200]);
assert_eq!((a & b).iter().collect::<Vec<_>>(), [70]);
assert_eq!(
(!a & SectionMask::below(4)).iter().collect::<Vec<_>>(),
[0, 2, 3]
);
assert_eq!(format!("{a:?}"), "{1, 70, 200}");
}
}
@@ -50,6 +50,9 @@ pub fn sound_source(event: &str) -> usize {
index("ambient")
} else if e.starts_with("music_disc.") {
index("record")
} else if e.starts_with("item.goat_horn.") {
// InstrumentItem.play: SoundSource.RECORDS.
index("record")
} else if e.starts_with("music.") {
index("music")
} else if e.starts_with("weather.") || e.starts_with("entity.lightning_bolt") {
@@ -0,0 +1,179 @@
//! `SoundPreviewHandler`: the sound a category volume slider plays while it
//! moves on the title screen's options (`Options.createSoundSliderOptionInstance`
//! calls `preview` when there is no level).
use crate::settings::SOUND_SOURCES;
use crate::sound_engine::{InstanceId, SoundEngine, SoundInstance, Sounds};
/// The sound `SoundPreviewHandler.preview` plays for a `SoundSource` index,
/// or `None` (`SoundEvents.EMPTY`) for master, music and voice.
pub fn preview_event(source: usize) -> Option<&'static str> {
Some(match *SOUND_SOURCES.get(source)? {
"record" => "minecraft:block.note_block.guitar",
"weather" => "minecraft:entity.lightning_bolt.thunder",
"block" => "minecraft:block.grass.place",
"hostile" => "minecraft:entity.zombie.ambient",
// SoundEvents.COW_SOUNDS.get(CowSoundVariants.SoundSet.CLASSIC).ambientSound()
"neutral" => "minecraft:entity.cow.ambient",
"player" => "minecraft:entity.generic.eat",
"ambient" => "minecraft:ambient.cave",
"ui" => "minecraft:ui.button.click",
_ => return None,
})
}
/// `SoundPreviewHandler`'s `activePreview` and `previousCategory`.
#[derive(Debug, Default)]
pub struct SoundPreview {
active: Option<InstanceId>,
previous: Option<usize>,
}
impl SoundPreview {
/// `SoundPreviewHandler.preview(soundManager, category, volume)`: stop the
/// preview of another category, then, unless the last preview still
/// plays, play this category's sound (`SimpleSoundInstance.forUI` at
/// pitch 1 and the slider's volume).
pub fn preview(&mut self, sounds: &mut impl Sounds, source: usize, volume: f32) {
self.stop_other_category_preview(sounds, source);
if !self.can_play_sound(sounds) {
return;
}
let Some(event) = preview_event(source) else {
return;
};
if let Some(old) = self.active.take() {
sounds.release(old);
}
let instance = SoundInstance::for_ui(event, 1.0, volume, SoundEngine::unseeded());
self.active = Some(sounds.play_instance(instance, true).0);
}
/// The preview playing now, if any.
pub fn active(&self) -> Option<InstanceId> {
self.active
}
/// `stopOtherCategoryPreview`.
fn stop_other_category_preview(&mut self, sounds: &mut impl Sounds, source: usize) {
if self.previous != Some(source) {
self.previous = Some(source);
if let Some(active) = self.active {
sounds.stop_instance(active);
}
}
}
/// `canPlaySound`: no preview yet, or the last one has finished.
fn can_play_sound(&self, sounds: &mut impl Sounds) -> bool {
self.active.is_none_or(|active| !sounds.is_active(active))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sound_engine::{Bound, TickContext, UI, test_backend::Null};
use crate::sound_events::SoundRegistry;
fn engine() -> SoundEngine {
let mut registry = SoundRegistry::default();
registry.add_file(
"minecraft",
br#"{"entity.zombie.ambient": {"sounds": ["mob/zombie/say1"]},
"entity.cow.ambient": {"sounds": ["mob/cow/say1"]},
"ui.button.click": {"sounds": ["random/click"]}}"#,
&|_, _| true,
);
SoundEngine::new(registry)
}
fn source(name: &str) -> usize {
SOUND_SOURCES.iter().position(|s| *s == name).unwrap()
}
#[test]
fn each_category_previews_its_vanilla_sound() {
let expected = [
("master", None),
("music", None),
("record", Some("minecraft:block.note_block.guitar")),
("weather", Some("minecraft:entity.lightning_bolt.thunder")),
("block", Some("minecraft:block.grass.place")),
("hostile", Some("minecraft:entity.zombie.ambient")),
("neutral", Some("minecraft:entity.cow.ambient")),
("player", Some("minecraft:entity.generic.eat")),
("ambient", Some("minecraft:ambient.cave")),
("voice", None),
("ui", Some("minecraft:ui.button.click")),
];
for (name, event) in expected {
assert_eq!(preview_event(source(name)), event, "{name}");
}
}
fn slide(
preview: &mut SoundPreview,
engine: &mut SoundEngine,
backend: &mut Null,
name: &str,
volume: f32,
) {
preview.preview(&mut Bound { engine, backend }, source(name), volume);
}
/// Ticks enough for the engine to drop finished or stopped channels.
fn settle(engine: &mut SoundEngine, backend: &mut Null) {
for _ in 0..40 {
engine.tick(false, &TickContext::default(), backend);
}
}
#[test]
fn a_moving_slider_plays_one_preview_at_a_time_and_switching_stops_it() {
let (mut engine, mut backend) = (engine(), Null::default());
let mut preview = SoundPreview::default();
slide(&mut preview, &mut engine, &mut backend, "hostile", 0.4);
let first = preview
.active()
.expect("the hostile slider plays a preview");
let instance = engine.instance(first).unwrap();
assert_eq!(instance.identifier, "minecraft:entity.zombie.ambient");
assert_eq!(instance.source, UI);
assert_eq!((instance.volume, instance.pitch), (0.4, 1.0));
assert!(instance.relative);
assert!(engine.is_active(first));
// canPlaySound: still playing, so dragging further does not restart it.
slide(&mut preview, &mut engine, &mut backend, "hostile", 0.6);
assert_eq!(preview.active(), Some(first));
assert_eq!(engine.active_instances(), vec![first]);
// Once it has finished, the next move plays it again.
for stopped in backend.stopped.values_mut() {
*stopped = true;
}
settle(&mut engine, &mut backend);
assert!(!engine.is_active(first));
slide(&mut preview, &mut engine, &mut backend, "hostile", 0.6);
let second = preview.active().unwrap();
assert_ne!(second, first);
assert!(engine.is_active(second));
// Another category stops the playing preview (SoundManager.stop); the
// engine lets its channel go on the next ticks, then that category's
// own sound plays.
slide(&mut preview, &mut engine, &mut backend, "neutral", 1.0);
assert!(backend.stopped.values().all(|stopped| *stopped));
settle(&mut engine, &mut backend);
assert!(engine.active_instances().is_empty());
slide(&mut preview, &mut engine, &mut backend, "neutral", 1.0);
let third = preview.active().unwrap();
assert_eq!(
engine.instance(third).unwrap().identifier,
"minecraft:entity.cow.ambient"
);
// Master has no sound but still stops the other category's preview.
slide(&mut preview, &mut engine, &mut backend, "master", 1.0);
settle(&mut engine, &mut backend);
assert!(engine.active_instances().is_empty());
slide(&mut preview, &mut engine, &mut backend, "master", 1.0);
assert!(engine.active_instances().is_empty());
}
}
@@ -27,6 +27,10 @@ pub struct Span {
pub fn enable(on: bool) {
ENABLED.store(on, Ordering::Relaxed);
}
/// Whether spans are being recorded.
pub fn enabled() -> bool {
ENABLED.load(Ordering::Relaxed)
}
/// Run `f`, recording its duration under `name` when spans are enabled.
#[inline]
pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
@@ -159,6 +159,14 @@ impl Sprites {
/// up to a power of two and limited to [`MAX_ARRAY_BYTES`]. Smaller
/// textures are scaled up by whole pixels, so UVs stay per layer.
pub fn finish(&mut self) {
self.lay_out(true);
}
/// [`Sprites::finish`] for a server, which draws nothing: the layers'
/// size, but neither their pixels nor the animations' frames.
pub fn finish_without_pixels(&mut self) {
self.lay_out(false);
}
fn lay_out(&mut self, pixels: bool) {
let layers = self.native.len().max(1) as u64;
let largest = self.native.iter().map(|n| n.0).max().unwrap_or(SPRITE_SIZE);
self.minimum_size = self.native.iter().map(|n| n.0).min().unwrap_or(SPRITE_SIZE);
@@ -166,6 +174,12 @@ impl Sprites {
while size > SPRITE_SIZE && layers * u64::from(size * size) * 4 > MAX_ARRAY_BYTES {
size /= 2;
}
if !pixels {
self.pixels = Vec::new();
self.animations = Vec::new();
self.size = size;
return;
}
let mut resized = Vec::with_capacity(self.native.len());
self.pixels.clear();
for (from, frames, initial) in &self.native {
+129 -25
View File
@@ -3,8 +3,9 @@
//! hands immutable `Arc` snapshots to workers; results carry a sequence number
//! so stale work is dropped.
use crate::fast_hash::{FxHashMap, FxHashSet};
use crate::section_mask::SectionMask;
use crate::{
chunk::{Chunk, SECTIONS},
chunk::Chunk,
light::{LightChanges, LightTables, apply_lighting, apply_relighting, light_chunk},
mesher::{MeshTables, Mesher, Region, SectionMesh, borrow},
world::World,
@@ -16,11 +17,15 @@ use std::{
collections::{BinaryHeap, HashSet, VecDeque},
sync::{
Arc, Condvar, Mutex, MutexGuard, RwLock, TryLockError,
atomic::{AtomicBool, Ordering},
atomic::{AtomicBool, AtomicUsize, Ordering},
},
thread,
};
/// How long a worker waits with nothing to do before it gives back its
/// generator's scratch buffers ([`ChunkGenerator::idle`]).
pub const IDLE_RELEASE: std::time::Duration = std::time::Duration::from_secs(5);
/// A chunk under generation, opaque to the streamer.
pub type Proto = Box<dyn std::any::Any + Send>;
/// Chunk generation in vanilla's stages: a neighbour-independent proto chunk
@@ -57,6 +62,9 @@ pub trait ChunkGenerator: Send + Sync + 'static {
/// What a loaded chunk needs from the generator before the game uses it,
/// on the worker that loaded it.
fn prepare_loaded(&self, _worker: &mut Self::Worker, _chunk: &Chunk) {}
/// The worker has had nothing to do for [`IDLE_RELEASE`]: give back what
/// it keeps between jobs only to go faster.
fn idle(&self, _worker: &mut Self::Worker) {}
fn proto(&self, worker: &mut Self::Worker, x: i32, z: i32) -> Proto;
fn decorates(&self) -> bool {
false
@@ -74,6 +82,12 @@ pub trait ChunkGenerator: Send + Sync + 'static {
fn light_view(&self, _proto: &Proto) -> Option<Chunk> {
None
}
/// A decorated proto chunk's column sections (`Chunk::sections`
/// indexes) holding a block other than air: what its neighbours' light
/// layers count once it is at INITIALIZE_LIGHT.
fn non_air_sections(&self, _proto: &Proto) -> Option<SectionMask> {
None
}
fn saved_proto(&self, _proto: &Proto) -> Option<crate::chunk::SavedProto> {
None
}
@@ -220,6 +234,9 @@ struct Shared {
/// packs reload; each mesh job takes the current one.
context: RwLock<Arc<WorkerContext>>,
done: Mailbox,
/// Workers that have freed their buffers ([`IDLE_RELEASE`]) and taken no
/// job since: the last of them gives the freed memory back to the OS.
released: AtomicUsize,
}
/// Finished jobs, from the workers to the game thread, in the order they
/// finished. Not an `mpsc` channel: its receiver waits for a sender it finds
@@ -265,6 +282,7 @@ impl Mailbox {
pub struct Streaming {
shared: Arc<Shared>,
light_view: Arc<dyn Fn(&Proto) -> Option<Chunk> + Send + Sync>,
non_air_sections: Arc<dyn Fn(&Proto) -> Option<SectionMask> + Send + Sync>,
threads: Vec<thread::JoinHandle<()>>,
order: u64,
/// Ticks since the columns and protos that fell out of range were swept.
@@ -310,6 +328,8 @@ pub struct Streaming {
deferred: Vec<Done>,
sequence: u64,
pub center: (i32, i32),
/// The view distance around `center`; below 0, no area there (a dedicated
/// server nobody is on).
pub radius: i32,
/// `/forceload`ed chunks: kept loaded whatever the distance.
pub forced: HashSet<(i32, i32)>,
@@ -513,7 +533,9 @@ impl Streaming {
stop: AtomicBool::new(false),
context: RwLock::new(context.clone()),
done: Mailbox::default(),
released: AtomicUsize::new(0),
});
let workers = threads.max(1);
let light_tables = context.light.clone();
let saves: Arc<dyn Saves> = generator.clone();
let threads = (0..threads.max(1))
@@ -534,14 +556,42 @@ impl Streaming {
loop {
let job = {
let mut queue = shared.queue.lock().unwrap();
let mut released = false;
loop {
if shared.stop.load(Ordering::Relaxed) {
return;
}
if let Some(job) = queue.pop() {
if released {
shared.released.fetch_sub(1, Ordering::Relaxed);
}
break job.job;
}
queue = shared.ready.wait(queue).unwrap();
if released {
queue = shared.ready.wait(queue).unwrap();
continue;
}
let (woken, wait) =
shared.ready.wait_timeout(queue, IDLE_RELEASE).unwrap();
queue = woken;
if wait.timed_out() && queue.is_empty() {
// A server nobody is on, a client standing still:
// free the generator's buffers (and the mesher's
// face cache) until the next job needs them.
drop(queue);
generator.idle(&mut worker);
faces = None;
released = true;
// The allocator keeps freed pages until memory
// runs short: once every worker has freed its own,
// hand them back.
if shared.released.fetch_add(1, Ordering::Relaxed) + 1
== workers
{
horizon_threads::release_free_memory();
}
queue = shared.queue.lock().unwrap();
}
}
};
let done = match job {
@@ -611,9 +661,14 @@ impl Streaming {
let generator = generator.clone();
Arc::new(move |p: &Proto| generator.light_view(p))
};
let non_air_sections = {
let generator = generator.clone();
Arc::new(move |p: &Proto| generator.non_air_sections(p))
};
Self {
shared,
light_view,
non_air_sections,
threads,
order: 0,
scan_age: 0,
@@ -677,7 +732,7 @@ impl Streaming {
self.meshing.clear();
for &(x, z) in world.chunks.keys() {
if self.lit.contains(&(x, z)) {
for sy in 0..SECTIONS as i32 {
for sy in 0..world.layout().sections() as i32 {
world.dirty_sections.insert((x, sy, z));
}
}
@@ -695,7 +750,7 @@ impl Streaming {
self.generated_total += 1;
for dz in -1..=1 {
for dx in -1..=1 {
for sy in 0..SECTIONS as i32 {
for sy in 0..world.layout().sections() as i32 {
world.dirty_sections.insert((key.0 + dx, sy, key.1 + dz));
}
}
@@ -710,7 +765,7 @@ impl Streaming {
for dy in -1..=1 {
for dx in -1..=1 {
let s = sy as i32 + dy;
if (0..SECTIONS as i32).contains(&s) {
if (0..world.layout().sections() as i32).contains(&s) {
world.dirty_sections.insert((x + dx, s, z + dz));
}
}
@@ -736,7 +791,7 @@ impl Streaming {
self.lighting.remove(&(x, z));
// By key, not by a pass over every pending section: a moved or
// shrunk storage forgets thousands of columns in one frame.
for sy in 0..SECTIONS as i32 {
for sy in 0..world.layout().sections() as i32 {
self.meshing.remove(&(x, sy, z));
self.waiting.remove(&(x, sy, z));
}
@@ -854,7 +909,7 @@ impl Streaming {
world.chunks.insert(key, Arc::new(chunk));
if loaded {
self.lit.insert(key);
for sy in 0..SECTIONS as i32 {
for sy in 0..world.layout().sections() as i32 {
world.dirty_sections.insert((key.0, sy, key.1));
}
}
@@ -914,6 +969,15 @@ impl Streaming {
pub fn is_lit(&self, x: i32, z: i32) -> bool {
self.lit.contains(&(x, z))
}
/// The non-air sections of a column that is a decorated proto chunk
/// here (vanilla's INITIALIZE_LIGHT and on), if it is one: a level
/// chunk's light layers count them (#4294).
pub fn proto_sections(&self, x: i32, z: i32) -> Option<SectionMask> {
if !self.decorated.contains(&(x, z)) {
return None;
}
(self.non_air_sections)(self.protos.get(&(x, z))?.as_ref()?)
}
/// `ChunkMap.getLatestStatus` for the loading screen's chunk map: the
/// furthest step this column has reached, `None` before it is asked for.
pub fn latest_status(
@@ -1037,7 +1101,7 @@ impl Streaming {
if first {
for dz in -1..=1 {
for dx in -1..=1 {
for sy in 0..SECTIONS as i32 {
for sy in 0..world.layout().sections() as i32 {
world.dirty_sections.insert((x + dx, sy, z + dz));
}
}
@@ -1105,7 +1169,9 @@ impl Streaming {
.iter()
.filter(|&(&(x, z), p)| {
p.is_some()
&& i64::from(x - center.0).pow(2) + i64::from(z - center.1).pow(2) > r4 * r4
&& (self.radius < 0
|| i64::from(x - center.0).pow(2) + i64::from(z - center.1).pow(2)
> r4 * r4)
&& !ranges.in_extra(x, z, 4)
&& !ranges.in_band(x, z, 4)
&& !ranges.forced.iter().any(|f| {
@@ -1331,7 +1397,7 @@ impl Streaming {
}
if !self.ranges().in_view(x, z) {
world.dirty_sections.take_column(x, z);
for sy in section_indexes(sections) {
for sy in sections.iter().map(|sy| sy as i32) {
self.waiting.insert((x, sy, z));
}
continue;
@@ -1355,7 +1421,7 @@ impl Streaming {
continue;
};
world.dirty_sections.take_column(x, z);
for sy in section_indexes(sections) {
for sy in sections.iter().map(|sy| sy as i32) {
let first = self.meshed.insert((x, z));
self.sequence += 1;
self.meshing.insert((x, sy, z), self.sequence);
@@ -1431,6 +1497,7 @@ impl Streaming {
&mut changed,
);
Self::mark_light_changes(world, &changed);
Self::relight_beside_unlit(world, &changed);
if !self.light_snapshots.is_empty() {
for p in positions {
self.light_edit_sequence += 1;
@@ -1460,6 +1527,7 @@ impl Streaming {
&mut changed,
);
Self::mark_light_changes(world, &changed);
Self::relight_beside_unlit(world, &changed);
}
let oldest = self.light_snapshots.values().copied().min();
match oldest {
@@ -1467,12 +1535,20 @@ impl Streaming {
None => self.light_log.clear(),
}
}
/// A column whose light an edit changed beside a column not lit yet gets
/// a full light job ([`crate::light::beside_unlit`]).
fn relight_beside_unlit(world: &mut World, changed: &LightChanges) {
let columns = crate::light::beside_unlit(changed, |x, z| {
world.chunks.get(&(x, z)).is_some_and(|c| !c.lit)
});
world.dirty_light.extend(columns);
}
/// Sections whose meshes sample a changed light value (faces and corners
/// read light from neighbouring blocks).
fn mark_light_changes(world: &mut World, changed: &LightChanges) {
if let Some(log) = &mut world.light_log {
for (x, sy, z) in changed.sections() {
*log.entry((x, z)).or_default() |= 1 << sy;
log.entry((x, z)).or_default().insert(sy as usize);
}
}
changed.dirty_sections(|section| {
@@ -1556,6 +1632,16 @@ impl Streaming {
fn in_range(&self, x: i32, z: i32, margin: i32) -> bool {
self.ranges().in_range(x, z, margin)
}
/// Rank the 3x3 a chunk loaded to FULL decorates, in one generation
/// task's order (`ChunkGenerationTask`: x-outer, z-inner), after every
/// chunk ranked before.
pub fn rank_full_task(&mut self, x: i32, z: i32) {
for dx in -1..=1 {
for dz in -1..=1 {
self.decoration_order.rank_or_append(x + dx, z + dz);
}
}
}
pub fn band(&self) -> Option<Band> {
self.band
}
@@ -1861,7 +1947,7 @@ impl Ranges<'_> {
let (dx, dz) = (gap(x - self.center.0), gap(z - self.center.1));
let r = i64::from(self.radius + 1);
// The extra areas are squares: their finished square grown by `reach`.
dx * dx + dz * dz <= r * r
(self.radius >= 0 && dx * dx + dz * dz <= r * r)
|| self.in_extra(x, z, 1 + reach)
|| self.in_band(x, z, 1 + reach)
}
@@ -1894,7 +1980,7 @@ impl Ranges<'_> {
fn in_main_range(&self, x: i32, z: i32, margin: i32) -> bool {
let r = i64::from(self.radius + margin);
let (dx, dz) = (i64::from(x - self.center.0), i64::from(z - self.center.1));
if dx * dx + dz * dz <= r * r {
if self.radius >= 0 && dx * dx + dz * dz <= r * r {
return true;
}
// A forced chunk holds a FORCED ticket at level 31
@@ -1931,16 +2017,6 @@ impl Drop for Streaming {
}
}
}
/// The section indexes set in a column's bits, lowest first.
fn section_indexes(mut bits: u32) -> impl Iterator<Item = i32> {
std::iter::from_fn(move || {
(bits != 0).then(|| {
let sy = bits.trailing_zeros() as i32;
bits &= bits - 1;
sy
})
})
}
/// The queued jobs' priorities computed again from each one's column
/// `distance` (`Streaming::submit`).
fn rank_again(queue: &mut BinaryHeap<Queued>, distance: impl Fn(i32, i32) -> i64) {
@@ -2529,6 +2605,34 @@ mod tests {
}
}
/// An idle dedicated server keeps no area around its centre: only a spawn
/// search's chunk (FULL alone: its 3x3 decorated, 5x5 generated) and the
/// forced chunks.
#[test]
fn no_area_around_an_idle_servers_centre() {
let forced = HashSet::from([(30, 30)]);
let extra = [((5, -3), -1)];
let r = Ranges {
center: (5, -3),
radius: -1,
extra: &extra,
forced: &forced,
cover: None,
band: None,
};
for x in -5i32..=15 {
for z in -13i32..=7 {
let d = (x - 5).abs().max((z + 3).abs());
assert_eq!(r.in_range(x, z, 1), d == 0, "({x}, {z})");
assert_eq!(r.chunk_kept(x, z, true), d <= 2, "({x}, {z})");
assert_eq!(r.requested(x, z, true), d <= 2, "({x}, {z})");
}
}
assert!(r.in_range(30, 30, 1));
let idle = Ranges { extra: &[], ..r };
assert!(!idle.requested(5, -3, true) && idle.unloaded(5, -3, true));
}
/// A server player's area (`lan_update_streaming`: the server view
/// distance + 1) finishes vanilla's FULL square: PLAYER_LOADING tickets at
/// level 31 out to the view distance, FULL (33) two chunks past it. Every
+29 -10
View File
@@ -390,6 +390,7 @@ pub fn slider(
value: f32,
hover: bool,
active: bool,
dragging: bool,
) {
let kind = crate::widgets::Kind::Slider {
value,
@@ -401,17 +402,8 @@ pub fn slider(
// A controller's D-pad can focus it (Horizon's controller support).
crate::widgets::target_as([x, y, w, 20.0], kind);
}
let track = if hover && active {
"gui/sprites/widget/slider_highlighted"
} else {
"gui/sprites/widget/slider"
};
let (track, handle) = slider_sprites(hover, active, dragging);
nine_slice(batch, atlas, track, x, y, w, 20.0, u32::MAX);
let handle = if hover && active {
"gui/sprites/widget/slider_handle_highlighted"
} else {
"gui/sprites/widget/slider_handle"
};
nine_slice(
batch,
atlas,
@@ -429,6 +421,21 @@ pub fn slider(
};
centered_text(batch, atlas, label, x + 2.0, y + 6.0, w - 4.0, color);
}
/// Track and handle sprites: the track outline shows only while the slider is
/// dragged, hovering highlights just the handle.
fn slider_sprites(hover: bool, active: bool, dragging: bool) -> (&'static str, &'static str) {
let track = if dragging {
"gui/sprites/widget/slider_highlighted"
} else {
"gui/sprites/widget/slider"
};
let handle = if hover && active {
"gui/sprites/widget/slider_handle_highlighted"
} else {
"gui/sprites/widget/slider_handle"
};
(track, handle)
}
/// Slider value for a click at `cx` (AbstractSliderButton.setValueFromMouse).
pub fn slider_value(x: f32, w: f32, cx: f32) -> f32 {
((cx - (x + 4.0)) / (w - 8.0)).clamp(0.0, 1.0)
@@ -1344,6 +1351,18 @@ mod tests {
parse_nine_slice(&serde_json::from_str(json).unwrap()).unwrap()
}
#[test]
fn slider_track_is_outlined_only_while_dragged() {
let (track, handle) = slider_sprites(true, true, false);
assert_eq!(track, "gui/sprites/widget/slider");
assert_eq!(handle, "gui/sprites/widget/slider_handle_highlighted");
let (track, handle) = slider_sprites(true, true, true);
assert_eq!(track, "gui/sprites/widget/slider_highlighted");
assert_eq!(handle, "gui/sprites/widget/slider_handle_highlighted");
let (track, handle) = slider_sprites(false, true, false);
assert_eq!(track, "gui/sprites/widget/slider");
assert_eq!(handle, "gui/sprites/widget/slider_handle");
}
#[test]
fn separator_tiles_sample_both_rows_and_clip_the_last_column() {
let mut batch = HudBatch::new([640, 480]);
tile(
+53 -27
View File
@@ -1,7 +1,10 @@
//! Resident client world: streamed chunk columns plus the bookkeeping the game
//! loop needs to relight and remesh after edits.
use crate::chunk::{Chunk, MIN_Y, SECTIONS};
use crate::fast_hash::{FxHashMap, FxHashSet};
use crate::{
chunk::{Chunk, ColumnLayout, LevelShape},
fast_hash::{FxHashMap, FxHashSet},
section_mask::SectionMask,
};
use horizon_core::random::LegacyRandom;
use horizon_world::registry::BlockStateId;
use std::sync::Arc;
@@ -11,28 +14,30 @@ use std::sync::Arc;
/// rather than at every section of it.
#[derive(Clone, Debug, Default)]
pub struct DirtySections {
columns: crate::fast_hash::FxHashMap<(i32, i32), u32>,
columns: crate::fast_hash::FxHashMap<(i32, i32), SectionMask>,
}
const _: () = assert!(SECTIONS <= 32);
impl DirtySections {
pub fn insert(&mut self, (x, sy, z): (i32, i32, i32)) {
debug_assert!((0..SECTIONS as i32).contains(&sy));
*self.columns.entry((x, z)).or_default() |= 1 << sy;
debug_assert!((0..SectionMask::CAPACITY as i32).contains(&sy));
self.columns.entry((x, z)).or_default().insert(sy as usize);
}
pub fn contains(&self, &(x, sy, z): &(i32, i32, i32)) -> bool {
(0..SECTIONS as i32).contains(&sy)
&& self.columns.get(&(x, z)).is_some_and(|m| m & 1 << sy != 0)
(0..SectionMask::CAPACITY as i32).contains(&sy)
&& self
.columns
.get(&(x, z))
.is_some_and(|m| m.contains(sy as usize))
}
/// The dirty section indexes of column (x, z), as bits, removed.
pub fn take_column(&mut self, x: i32, z: i32) -> u32 {
self.columns.remove(&(x, z)).unwrap_or(0)
/// The dirty section indexes of column (x, z), removed.
pub fn take_column(&mut self, x: i32, z: i32) -> SectionMask {
self.columns.remove(&(x, z)).unwrap_or_default()
}
/// Keep the columns `keep` accepts.
pub fn retain_columns(&mut self, mut keep: impl FnMut(i32, i32) -> bool) {
self.columns.retain(|&(x, z), _| keep(x, z));
}
/// The columns holding dirty sections, with their section bits.
pub fn columns(&self) -> impl Iterator<Item = ((i32, i32), u32)> + '_ {
/// The columns holding dirty sections, with their section indexes.
pub fn columns(&self) -> impl Iterator<Item = ((i32, i32), SectionMask)> + '_ {
self.columns.iter().map(|(&c, &m)| (c, m))
}
pub fn clear(&mut self) {
@@ -43,10 +48,13 @@ impl DirtySections {
}
/// How many sections are dirty.
pub fn len(&self) -> usize {
self.columns.values().map(|m| m.count_ones() as usize).sum()
self.columns.values().map(SectionMask::len).sum()
}
}
pub struct World {
/// The level's build range and skylight (its dimension type); its
/// chunks hold `shape.column()`.
pub shape: LevelShape,
pub fast_lava: bool,
pub chunks: crate::chunk_map::ChunkMap,
pub random: LegacyRandom,
@@ -88,7 +96,7 @@ pub struct World {
/// The sections whose light changed since the log was last drained, as
/// a bit per section of each column, kept while `block_log` is
/// (`ChunkHolder.sectionLightChanged`).
pub light_log: Option<crate::fast_hash::FxHashMap<(i32, i32), u32>>,
pub light_log: Option<crate::fast_hash::FxHashMap<(i32, i32), SectionMask>>,
/// The villager POIs of each section (`PoiManager`'s sections).
pub pois: crate::poi_sections::PoiSections<crate::poi_sections::Poi>,
/// Moving-piston states removed during the last tick; retained until the
@@ -165,6 +173,7 @@ impl Weather {
impl World {
pub fn new(air: BlockStateId, seed: i64) -> Self {
Self {
shape: LevelShape::OVERWORLD,
chunks: Default::default(),
random: LegacyRandom::new(seed),
revision: 0,
@@ -249,6 +258,18 @@ impl World {
world.revision = 0;
Ok(world)
}
/// The column the level's chunks hold.
pub fn layout(&self) -> ColumnLayout {
self.shape.column()
}
/// The lowest block Y the level's chunks hold.
pub fn bottom_y(&self) -> i32 {
self.layout().min_y()
}
/// One above the highest block Y the level's chunks hold.
pub fn top_y(&self) -> i32 {
self.layout().max_y()
}
/// Resident storage write; marks the touched sections (and neighbours on
/// section borders) for remeshing and the surrounding chunks for relighting.
/// Neighbour updates, drops and block entities belong to the gameplay layer.
@@ -256,7 +277,8 @@ impl World {
let Some(chunk) = self.chunks.get_mut(&(x >> 4, z >> 4)) else {
return Err("write outside resident chunks".into());
};
if y < MIN_Y || y >= MIN_Y + (SECTIONS as i32) * 16 {
let (min_y, sections) = (chunk.min_y(), chunk.sections.len() as i32);
if chunk.section_index(y).is_none() {
return Err("write outside world height".into());
}
let previous = Arc::make_mut(chunk).set(x & 15, y, z & 15, state.0);
@@ -276,14 +298,14 @@ impl World {
// the sections it can reach: this section, the one above/below
// only on a section face, and a neighbour column only on a chunk
// border (the diagonal only where both borders meet).
let sy = (y - MIN_Y) >> 4;
let sy = (y - min_y) >> 4;
let local_y = y & 15;
let mark = |dirty: &mut DirtySections, bx: i32, bz: i32| {
dirty.insert((bx >> 4, sy, bz >> 4));
if local_y == 0 && sy > 0 {
dirty.insert((bx >> 4, sy - 1, bz >> 4));
}
if local_y == 15 && sy + 1 < SECTIONS as i32 {
if local_y == 15 && sy + 1 < sections {
dirty.insert((bx >> 4, sy + 1, bz >> 4));
}
};
@@ -319,17 +341,20 @@ impl World {
Ok(())
}
/// `getHeight(Heightmap.Types.WORLD_SURFACE, x, z)`: one above the highest
/// block that isn't `air`, or `MIN_Y` (also where the chunk isn't loaded).
/// block that isn't `air`, or the column's minimum Y (also where the chunk
/// isn't loaded).
pub fn world_surface(&self, x: i32, z: i32, air: impl Fn(BlockStateId) -> bool) -> i32 {
self.chunks.get(&(x >> 4, z >> 4)).map_or(MIN_Y, |chunk| {
let bottom = self.layout().min_y();
self.chunks.get(&(x >> 4, z >> 4)).map_or(bottom, |chunk| {
chunk.world_surface(x, z, |v| air(BlockStateId(v)))
})
}
/// `getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)`: one above the
/// highest block where `blocks` (motion blocking or holding a fluid),
/// or `MIN_Y` (also where the chunk isn't loaded).
/// or the column's minimum Y (also where the chunk isn't loaded).
pub fn motion_blocking(&self, x: i32, z: i32, blocks: impl Fn(BlockStateId) -> bool) -> i32 {
self.chunks.get(&(x >> 4, z >> 4)).map_or(MIN_Y, |chunk| {
let bottom = self.layout().min_y();
self.chunks.get(&(x >> 4, z >> 4)).map_or(bottom, |chunk| {
chunk.motion_blocking(x & 15, z & 15, |v| blocks(BlockStateId(v)))
})
}
@@ -348,12 +373,13 @@ impl World {
hi: [i32; 3],
mut matches: impl FnMut(BlockStateId) -> bool,
) -> bool {
let top = MIN_Y + SECTIONS as i32 * 16 - 1;
let layout = self.layout();
let (bottom, top) = (layout.min_y(), layout.max_y() - 1);
// Heights outside the sections read as state 0.
if (lo[1] < MIN_Y || hi[1] > top) && matches(BlockStateId(0)) {
if (lo[1] < bottom || hi[1] > top) && matches(BlockStateId(0)) {
return true;
}
let sections = (lo[1].max(MIN_Y) - MIN_Y) >> 4..=(hi[1].min(top) - MIN_Y) >> 4;
let sections = (lo[1].max(bottom) - bottom) >> 4..=(hi[1].min(top) - bottom) >> 4;
for cx in lo[0] >> 4..=hi[0] >> 4 {
for cz in lo[2] >> 4..=hi[2] >> 4 {
let Some(chunk) = self.chunks.get(&(cx, cz)) else {
@@ -465,8 +491,8 @@ mod tests {
}
let mut got: Vec<_> = world.dirty_sections.columns().collect();
let mut want: Vec<_> = expected.columns().collect();
got.sort_unstable();
want.sort_unstable();
got.sort_unstable_by_key(|&(column, _)| column);
want.sort_unstable_by_key(|&(column, _)| column);
assert_eq!(got, want, "write at {x} {y} {z}");
if (x & 15, z & 15) == (3, 3) {
assert_eq!(world.dirty_sections.len(), 1);
+1 -1
View File
@@ -65,7 +65,7 @@ unicode-bidi = "0.3.18"
[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"] }
rustix = { version = "1", default-features = false, features = ["std", "time", "process"] }
[target.'cfg(windows)'.dependencies]
# Use the registered URI handler without routing links through Explorer/cmd.
@@ -12,12 +12,14 @@ use horizon_client::{
assets::Assets,
blocks::BlockFacts,
crafting::{Recipe, Recipes},
enchant::Enchantments,
items::{ItemStack, Items},
mod_content::{self, ContentMod},
stations::StationRecipes,
};
use horizon_core::{geometry::Axis, random::LegacyRandom};
use horizon_world::chunk::BlockState;
use horizon_world::loot::PackOverlay;
use serde_json::{Map, Value, json};
use std::{collections::BTreeMap, fs, path::Path};
@@ -77,9 +79,10 @@ fn main() -> Result<()> {
let assets = Assets::load(&root).map_err(step("assets"))?;
let registry = assets.registry;
let facts = BlockFacts::load(&root, registry).map_err(step("block facts"))?;
let items = Items::load(&root, registry, &facts).map_err(step("items"))?;
let recipes = Recipes::load(&root, &items).map_err(step("recipes"))?;
let stations = StationRecipes::load(&root, &items);
let items = Items::load(&root, registry, &facts, &PackOverlay::NONE).map_err(step("items"))?;
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE).map_err(step("recipes"))?;
let stations = StationRecipes::load(&root, &items, &PackOverlay::NONE);
let enchantments = Enchantments::load(&root);
let collisions =
horizon_client::collisions::load(&root, registry).map_err(step("collisions"))?;
let loaded = began.elapsed().as_secs_f64() - built;
@@ -197,7 +200,35 @@ fn main() -> Result<()> {
item_rows.insert(
name.clone(),
match items.items.get(name.as_str()) {
Some(i) => json!({"max_stack_size": i.max_stack, "block": i.block.map(|b| b.0)}),
Some(i) => {
let props = enchantments.props(name);
let repairable: Vec<String> = props
.into_iter()
.flat_map(|p| &p.repairable)
.flat_map(|r| match r.strip_prefix('#') {
Some(tag) => items.item_tag_order.get(tag).cloned().unwrap_or_default(),
None => vec![r.clone()],
})
.collect();
json!({
"max_stack_size": i.max_stack,
"block": i.block.map(|b| b.0),
"max_damage": i.max_damage,
"modifiers": i.modifiers.iter().map(|(attribute, id, amount, operation, slot)| {
json!({"type": attribute, "id": id, "amount": amount, "operation": operation, "slot": slot})
}).collect::<Vec<_>>(),
"tool": i.tool_damage_per_block.map(|per_block| json!({
"rules": i.tool.iter().map(|r| json!({"blocks": r.blocks, "speed": r.speed, "correct_for_drops": r.correct})).collect::<Vec<_>>(),
"default_mining_speed": i.tool_default_speed,
"damage_per_block": per_block,
})),
"enchantable": props.map_or(0, |p| p.enchantable),
"repairable": repairable,
"equip_slot": i.equip_slot,
"equip_asset": i.equip_asset,
"food": i.food.map(|(nutrition, saturation)| json!({"nutrition": nutrition, "saturation": saturation})),
})
}
None => Value::Null,
},
);
@@ -7,6 +7,7 @@ use horizon_client::{
chunk::{Chunk, MIN_Y, SECTIONS, Section},
fast_hash::FxHashMap,
portal_sections::PortalSections,
section_mask::SectionMask,
};
use horizon_core::{
geometry::{Aabb, Vec3},
@@ -83,17 +84,14 @@ fn may_hold_portal_cached(
if count > 64 {
return true;
}
let mut y_mask = 0u32;
for sy in y0..=y1 {
y_mask |= 1 << sy;
}
let y_mask = SectionMask::below(y1 as usize + 1).from(y0 as usize);
(x0..=x1).any(|cx| {
(z0..=z1).any(|cz| {
world.get(&(cx, cz)).is_some_and(|chunk| {
cache.get((cx, cz), chunk, |v| {
let portals = cache.get((cx, cz), chunk, |v| {
behaviours.portal_axis(BlockStateId(v)).is_some()
}) & y_mask
!= 0
});
!(portals & y_mask).is_empty()
})
})
})
@@ -153,6 +153,9 @@ pub fn main() -> Result<()> {
bits(position),
bits(velocity)
)),
ArrowEvent::PotionPuff { id, color } => {
events.push(format!("potion-puff {id} {color:08x}"))
}
ArrowEvent::Sound {
sound,
volume,
@@ -16,6 +16,7 @@ use horizon_client::{
items::Items,
};
use horizon_core::random::LegacyRandom;
use horizon_world::loot::PackOverlay;
use horizon_world::registry::{BlockRegistry, BlockStateId};
use serde_json::Value;
use std::{
@@ -127,7 +128,7 @@ pub fn main() -> Result<()> {
let registry = assets.registry;
let facts = BlockFacts::load(root, registry)?;
let renderer = BlockEntityRenderer::load(root, registry)?;
let tags = Items::tags_only(root)?;
let tags = Items::tags_only(root, &PackOverlay::NONE)?;
let frames = tags
.block_tags
.get("minecraft:conduit_effect_block")
@@ -9,6 +9,7 @@ use horizon_client::{
items::Items,
};
use horizon_world::chunk::BlockState;
use horizon_world::loot::PackOverlay;
use std::{fs, path::Path};
fn parse(text: &str) -> BlockState {
@@ -58,7 +59,7 @@ pub fn main() -> horizon_client::Result<()> {
.ok_or("usage: block_transform_fixture RESOURCES INPUT OUTPUT")?,
);
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
let items = Items::catalog(root)?;
let items = Items::catalog(root, &PackOverlay::NONE)?;
let assets = Assets::load(root)?;
let transformers = Transformers::load(root, assets.registry)?;
let mut out = String::new();
@@ -10,6 +10,7 @@ use horizon_client::{
menu::{Env, Kind, Menu},
};
use horizon_world::chunk::BlockState;
use horizon_world::loot::PackOverlay;
use serde_json::Value;
use std::{fs, path::Path};
@@ -77,9 +78,9 @@ pub fn main() -> horizon_client::Result<()> {
.ok_or("usage: cauldron_fixture RESOURCES INPUT OUTPUT")?,
);
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
let items = Items::catalog(root)?;
let recipes = Recipes::load(root, &items)?;
let brewing = horizon_client::brewing::Brewing::load(root, &items);
let items = Items::catalog(root, &PackOverlay::NONE)?;
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
let brewing = horizon_client::brewing::Brewing::load(root, &items, &PackOverlay::NONE);
// The inventory menu never reads banner patterns.
let banners = horizon_client::banner::Banners::default();
let station_recipes = horizon_client::stations::StationRecipes::default();
@@ -3,6 +3,7 @@ use horizon_client::{
item_data::StackData,
items::{ItemStack, Items},
};
use horizon_world::loot::PackOverlay;
use horizon_world::nbt::Tag;
use serde_json::Value;
use std::{
@@ -14,7 +15,7 @@ use std::{
pub fn main() -> Result<()> {
let args: Vec<_> = crate::args().collect();
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
let items = Items::catalog(&root)?;
let items = Items::catalog(&root, &PackOverlay::NONE)?;
let mut out = BufWriter::new(File::create(&args[2])?);
for line in BufReader::new(File::open(&args[1])?).lines() {
let row: Value = serde_json::from_str(&line?)?;
@@ -1,9 +1,10 @@
//! The creative inventory, for tools/test_creative_parity.py: loads an
//! exported `creative-inventory.json` into `horizon_client::creative::Creative`
//! and prints the tabs it shows (canonical stacks) and the search tab's
//! results for every query of the vanilla oracle's output.
//! results for every query of the vanilla oracle's output; with `permissions`,
//! as an operator with the Operator Items Tab option on.
//!
//! usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT
//! usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT [permissions]
use horizon_client::{
creative::{Creative, Names},
item_data::canon_stack,
@@ -13,11 +14,13 @@ use std::{fs, path::Path};
pub fn main() -> horizon_client::Result<()> {
let args: Vec<String> = crate::args().collect();
let usage = "usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT";
let usage =
"usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT [permissions]";
let names = Names::load(Path::new(args.get(1).ok_or(usage)?));
let creative = Creative::from_json(&serde_json::from_slice(&fs::read(
let mut creative = Creative::from_json(&serde_json::from_slice(&fs::read(
args.get(2).ok_or(usage)?,
)?)?)?;
creative.set_permissions(args.get(5).is_some_and(|a| a == "permissions"), &names);
let vanilla: Value = serde_json::from_slice(&fs::read(args.get(3).ok_or(usage)?)?)?;
let tabs: Vec<Value> = creative
.tabs
@@ -8,6 +8,7 @@ use horizon_client::{
items::{ItemStack, Items},
stations,
};
use horizon_world::loot::PackOverlay;
use std::{fmt::Write as _, fs, path::Path};
pub fn main() -> horizon_client::Result<()> {
@@ -17,7 +18,7 @@ pub fn main() -> horizon_client::Result<()> {
.ok_or("usage: enchanting_fixture RESOURCES INPUT OUTPUT")?,
);
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
let items = Items::tags_only(root)?;
let items = Items::tags_only(root, &PackOverlay::NONE)?;
let e = Enchantments::load(root);
let mut out = String::new();
for line in input.lines() {
@@ -593,6 +593,7 @@ pub fn main() -> horizon_client::Result<()> {
num("maxCrossbowChageDuration"),
num("ticksUsingItem"),
false,
flag("isRiding"),
);
}
Species::Evoker | Species::Illusioner | Species::Pillager | Species::Vindicator => {
@@ -4,11 +4,16 @@
//!
//! usage: entity_death_loot_fixture RESOURCES INPUT OUTPUT
use horizon_client::{
mobs::{Mob, MobTypes, Species, cube, drops, gear, raider, render::DYE_NAMES, sulfur_cube},
assets::Assets,
blocks::BlockFacts,
items::Items,
mobs::{
Mob, MobTypes, Species, cube, drops, enderman, gear, raider, render::DYE_NAMES, sulfur_cube,
},
random_sequences::RandomSequences,
};
use horizon_core::{geometry::Vec3, random::LegacyRandom};
use horizon_world::loot::{LootDamage, LootData, LootEntity, LootStack};
use horizon_world::loot::{LootDamage, LootData, LootEntity, LootStack, PackOverlay};
use std::{collections::BTreeMap, fmt::Write as _, fs, path::Path, sync::Arc};
/// `id[;enchantment:level...]`.
@@ -64,7 +69,14 @@ pub fn main() -> horizon_client::Result<()> {
);
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
let types = MobTypes::load(root)?;
let loot = LootData::load(root)?;
let loot = LootData::load(root, &PackOverlay::NONE)?;
// Block loot for an enderman's carried block (BlockState.getDrops).
let assets = Assets::load(root)?;
let registry = assets.registry;
let facts = BlockFacts::load(root, registry)?;
let block_items = Items::load(root, registry, &facts, &PackOverlay::NONE)?;
// The stand-in level's random (RandomSource.create(0)), never reset.
let mut level_random = LegacyRandom::new(0);
let mut out = String::new();
for raw in input.lines() {
if raw.trim().is_empty() || raw.starts_with('#') {
@@ -121,6 +133,13 @@ pub fn main() -> horizon_client::Result<()> {
m.extra.color = DYE_NAMES.iter().position(|d| d == color).unwrap_or(0) as u8;
}
m.extra.sheared = get("sheared", "0") == "1";
if let Some(block) = c.get("carried") {
m.extra.carried = Some(
registry
.default_state(block)
.ok_or_else(|| format!("unknown block {block}"))?,
);
}
if get("leader", "0") == "1" {
raider::state_mut(&mut m).patrol_leader = true;
}
@@ -272,6 +291,23 @@ pub fn main() -> horizon_client::Result<()> {
for d in items {
let _ = match d {
drops::Drop::Item(s) => writeln!(out, "{}", line(&s)),
drops::Drop::CarriedBlock(state) => {
let stacks = enderman::carried_block_drops(
&block_items,
registry,
state,
&mut level_random,
Some((&mut sequences, world_seed)),
);
for s in stacks {
let _ = write!(out, "drop {} {}", s.item, s.count);
if s.damage != 0 {
let _ = write!(out, " damage={}", s.damage);
}
let _ = writeln!(out);
}
Ok(())
}
drops::Drop::Orb(v) => writeln!(out, "orb {v}"),
};
}
@@ -23,6 +23,7 @@ use horizon_core::{
geometry::{Aabb, Vec3},
random::{LegacyRandom, RandomSource},
};
use horizon_world::loot::PackOverlay;
use std::{collections::HashMap, fmt::Write as _, fs, path::Path, sync::Arc};
struct Level<'a> {
@@ -142,8 +143,8 @@ const GENES: [&str; 7] = [
"aggressive",
];
const MEMORIES: [&str; 7] = [
"long_jump_cooldown",
"ram_cooldown",
"long_jump_cooling_down",
"ram_cooldown_ticks",
"attack_target_cooldown",
"hunted_recently",
"dig_cooldown",
@@ -399,7 +400,7 @@ pub fn main() -> horizon_client::Result<()> {
let assets = Assets::load(root)?;
let facts = BlockFacts::load(root, assets.registry)?;
let mob_data = MobData::load(root, &facts);
let items = Items::tags_only(root)?;
let items = Items::tags_only(root, &PackOverlay::NONE)?;
let enchantments = Enchantments::load(root);
let types = MobTypes::load(root)?;
let registries = SpawnRegistries::load(root);
@@ -506,6 +507,7 @@ pub fn main() -> horizon_client::Result<()> {
difficulty,
data: &data,
created: Vec::new(),
jockey_rider: None,
};
group = finalize::finalize(&mut cx, &mut mob, reason, group);
let created = std::mem::take(&mut cx.created);
@@ -10,6 +10,7 @@ use horizon_client::{
items::Items,
recipe_book::{Book, BookType, GhostTarget, RecipeIndex},
};
use horizon_world::loot::PackOverlay;
use serde_json::Value;
use std::{fmt::Write as _, fs, path::Path};
@@ -18,9 +19,9 @@ pub fn main() -> horizon_client::Result<()> {
let usage = "usage: ghost_recipe_fixture RESOURCES CASES OUTPUT";
let root = Path::new(args.get(1).ok_or(usage)?);
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
let items = Items::catalog(root)?;
let recipes = Recipes::load(root, &items)?;
let index = RecipeIndex::load(root);
let items = Items::catalog(root, &PackOverlay::NONE)?;
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
let index = RecipeIndex::load(root, &PackOverlay::NONE);
let mut book = Book::build(&recipes, &items);
book.set_display_ids(&index);
let mut out = String::new();
@@ -5,6 +5,7 @@
//!
//! usage: hotbar_fixture RESOURCES CASES OUTPUT
use horizon_client::{hotbar, item_data::canon_stack, items::Items};
use horizon_world::loot::PackOverlay;
use horizon_world::nbt::{Tag, parse_snbt};
use serde_json::Value;
use std::path::Path;
@@ -13,7 +14,10 @@ use std::{fmt::Write as _, fs};
pub fn main() -> horizon_client::Result<()> {
let args: Vec<String> = crate::args().collect();
let usage = "usage: hotbar_fixture RESOURCES CASES OUTPUT";
let items = Items::catalog(Path::new(args.first().and(args.get(1)).ok_or(usage)?))?;
let items = Items::catalog(
Path::new(args.first().and(args.get(1)).ok_or(usage)?),
&PackOverlay::NONE,
)?;
let known = |id: &str| items.get(id).is_some();
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
let mut out = String::new();
@@ -99,6 +99,7 @@ mod toggle_keys_probe;
mod tooltip_display_fixture;
mod trading_fixture;
mod translucency_fixture;
mod trident_probe;
mod weather_fixture;
mod world_border_render_fixture;
mod world_options_fixture;
@@ -191,6 +192,7 @@ probes! {
text_display_fixture,
text_display_render_fixture,
trading_fixture,
trident_probe,
translucency_fixture,
weather_fixture,
world_border_render_fixture,
@@ -76,6 +76,7 @@ pub fn main() -> Result<()> {
raining_at: &|_| false,
height_no_leaves: &|_| 0,
firefly_bush_sounds: false,
creaking_active: false,
sky_darken: 0,
marker: target.map(|name| (name, &options as &dyn Fn(_) -> _)),
model_offset: &|_, _, _| None,
@@ -13,6 +13,7 @@ use horizon_client::{
menu::{Env, Input, Kind, Menu},
};
use horizon_protocol::registry::Ids;
use horizon_world::loot::PackOverlay;
use serde_json::Value;
use std::{
cell::{Cell, RefCell},
@@ -83,11 +84,12 @@ pub fn main() -> horizon_client::Result<()> {
.ok_or("usage: menu_fixture RESOURCES INPUT OUTPUT")?,
);
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
let items = Items::catalog(root)?;
let recipes = Recipes::load(root, &items)?;
let brewing = horizon_client::brewing::Brewing::load(root, &items);
let items = Items::catalog(root, &PackOverlay::NONE)?;
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
let brewing = horizon_client::brewing::Brewing::load(root, &items, &PackOverlay::NONE);
let banners = horizon_client::banner::Banners::load(root);
let station_recipes = horizon_client::stations::StationRecipes::load(root, &items);
let station_recipes =
horizon_client::stations::StationRecipes::load(root, &items, &PackOverlay::NONE);
let enchantments = horizon_client::enchant::Enchantments::load(root);
let names = horizon_client::creative::Names::load(root);
let case_maps = CaseMaps::default();
@@ -27,6 +27,7 @@ use horizon_core::{
geometry::{Aabb, Vec3},
random::LegacyRandom,
};
use horizon_world::loot::PackOverlay;
use horizon_world::{chunk::BlockState, registry::BlockRegistry, registry::BlockStateId};
use std::{
collections::{BTreeMap, HashMap},
@@ -196,6 +197,9 @@ fn who(w: Option<Who>) -> String {
fn goal_name(m: &Mob, name: &str) -> String {
let vanilla = match name {
"Float" => "FloatGoal",
"RandomFloatAround" => "RandomFloatAroundGoal",
"GhastLook" => "GhastLookGoal",
"GhastShoot" => "GhastShootFireballGoal",
"Stroll" => "WaterAvoidingRandomStrollGoal",
"ParrotWander" => "ParrotWanderGoal",
"RandomStroll" => "RandomStrollGoal",
@@ -224,6 +228,8 @@ fn goal_name(m: &Mob, name: &str) -> String {
"MoveThroughVillage" => "MoveThroughVillageGoal",
"RestrictSun" => "RestrictSunGoal",
"FleeSun" => "FleeSunGoal",
// `Cat.CatAvoidEntityGoal`, `Fox.FoxAvoidEntityGoal`... name their subclass.
"AvoidEntity" if m.species() == Species::Cat => "CatAvoidEntityGoal",
"AvoidEntity" => "AvoidEntityGoal",
"BowAttack" => "RangedBowAttackGoal",
"Swell" => "SwellGoal",
@@ -874,7 +880,7 @@ pub fn main() -> horizon_client::Result<()> {
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
.collect();
let biomes = Biomes::load(root)?;
let items = Items::load(root, registry, &facts)?;
let items = Items::load(root, registry, &facts, &PackOverlay::NONE)?;
let tables = LightTables::load(root, registry, &facts)?;
let collision = collisions::load(root, registry)?;
let air = registry.default_state("minecraft:air").ok_or("no air")?;
@@ -53,7 +53,8 @@ use horizon_core::{
random::{LegacyRandom, RandomSource},
};
use horizon_world::{
block_collision::BlockCollisionRegistry, chunk::BlockState, nbt::Tag, registry::BlockStateId,
block_collision::BlockCollisionRegistry, chunk::BlockState, loot::PackOverlay, nbt::Tag,
registry::BlockStateId,
};
use serde_json::Value;
use std::{
@@ -522,11 +523,11 @@ pub fn main() -> horizon_client::Result<()> {
let registry = assets.registry;
let facts = BlockFacts::load(root, registry)?;
let data = MobData::load(root, &facts);
let items = Items::tags_only(root)?;
let items = Items::tags_only(root, &PackOverlay::NONE)?;
// Trial spawner rewards: the loot tables and the configs.
let loot = if input.contains("mode=trial") || input.contains("mode=vault") {
horizon_client::mobs::trial_spawner::load_registry(root);
horizon_client::structure_blocks::loot_data(root)
horizon_client::structure_blocks::loot_data(root, &PackOverlay::NONE)
} else {
None
};
@@ -795,7 +796,7 @@ pub fn main() -> horizon_client::Result<()> {
chunk,
&mut gen_random,
&mut rules,
&mut spawn,
&mut |l, r, _| spawn(l, r).is_some(),
&mut |e| match e {
GenEvent::Column(x, z) => log.push(format!("col {x} {z}")),
GenEvent::Spawn(t, p, yaw) => spawns.push(format!(
@@ -10,6 +10,7 @@ use horizon_client::{
items::Items,
menu::{Env, Kind, Menu, place::PostPlace},
};
use horizon_world::loot::PackOverlay;
use serde_json::Value;
use std::{fs, path::Path};
@@ -34,9 +35,9 @@ pub fn main() -> horizon_client::Result<()> {
let root = Path::new(args.get(1).ok_or(usage)?);
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
let orders = fs::read_to_string(args.get(3).ok_or(usage)?)?;
let items = Items::catalog(root)?;
let recipes = Recipes::load(root, &items)?;
let brewing = horizon_client::brewing::Brewing::load(root, &items);
let items = Items::catalog(root, &PackOverlay::NONE)?;
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
let brewing = horizon_client::brewing::Brewing::load(root, &items, &PackOverlay::NONE);
let banners = horizon_client::banner::Banners::default();
let station_recipes = horizon_client::stations::StationRecipes::default();
let enchantments = horizon_client::enchant::Enchantments::default();
@@ -19,6 +19,7 @@ use horizon_client::{
raid::{self, Action, BarName, RaidLevel, RaidPlayer, RaiderInfo, Raids},
};
use horizon_core::random::{LegacyRandom, RandomSource};
use horizon_world::loot::PackOverlay;
use horizon_world::{chunk::BlockState, nbt::Tag, registry::BlockStateId};
use std::{
cell::RefCell,
@@ -470,7 +471,7 @@ pub fn main() -> horizon_client::Result<()> {
let registry = assets.registry;
let facts = BlockFacts::load(root, registry)?;
let data = MobData::load(root, &facts);
let items = Items::tags_only(root)?;
let items = Items::tags_only(root, &PackOverlay::NONE)?;
let attributes = GameplayAttributes::load(root);
let shared = Shared {
facts: &facts,
@@ -9,6 +9,7 @@ use horizon_protocol::{
buf::{Codec, Reader, Writer},
packets::recipes::RecipeBookChangeSettings,
};
use horizon_world::loot::PackOverlay;
use horizon_world::nbt::Tag;
use serde_json::Value;
use std::{collections::BTreeSet, fs, path::Path};
@@ -66,7 +67,7 @@ pub fn main() -> horizon_client::Result<()> {
let usage = "usage: recipe_book_fixture RESOURCES CASES OUTPUT";
let root = Path::new(args.get(1).ok_or(usage)?);
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
let index = RecipeIndex::load(root);
let index = RecipeIndex::load(root, &PackOverlay::NONE);
let mut out = String::new();
for line in cases.lines() {
let case: Value = serde_json::from_str(line)?;
@@ -16,6 +16,7 @@ use horizon_client::{
TabView, draw, overlay_cells, overlay_origin,
},
};
use horizon_world::loot::PackOverlay;
use serde_json::{Value, json};
use std::{fmt::Write as _, fs, path::Path};
@@ -35,8 +36,8 @@ pub fn main() -> horizon_client::Result<()> {
let usage = "usage: recipe_book_render_fixture RESOURCES CASES OUTPUT";
let root = Path::new(args.get(1).ok_or(usage)?);
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
let items = Items::catalog(root)?;
let recipes = Recipes::load(root, &items)?;
let items = Items::catalog(root, &PackOverlay::NONE)?;
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
let book = Book::build(&recipes, &items);
let atlas = HudAtlas::build(root)?;
let root_buf = root.to_path_buf();
@@ -12,6 +12,7 @@ use horizon_client::{
menu::place::Stacked,
recipe_book::{Book, BookType, ClientBook, Display, RecipeIndex, Tab},
};
use horizon_world::loot::PackOverlay;
use serde_json::Value;
use std::{fmt::Write as _, fs, path::Path};
@@ -20,9 +21,9 @@ pub fn main() -> horizon_client::Result<()> {
let usage = "usage: recipe_book_view_fixture RESOURCES CASES OUTPUT";
let root = Path::new(args.get(1).ok_or(usage)?);
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
let items = Items::catalog(root)?;
let recipes = Recipes::load(root, &items)?;
let index = RecipeIndex::load(root);
let items = Items::catalog(root, &PackOverlay::NONE)?;
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
let index = RecipeIndex::load(root, &PackOverlay::NONE);
let mut book = Book::build(&recipes, &items);
book.set_display_ids(&index);
let max = |s: &horizon_client::items::ItemStack| items.get(&s.item).map_or(64, |i| i.max_stack);
@@ -10,6 +10,7 @@ use horizon_client::{
items::Items,
stations::StationRecipes,
};
use horizon_world::loot::PackOverlay;
use std::{fs, path::Path};
pub fn main() -> horizon_client::Result<()> {
@@ -19,9 +20,9 @@ pub fn main() -> horizon_client::Result<()> {
.ok_or("usage: recipe_fixture RESOURCES INPUT OUTPUT")?,
);
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
let items = Items::catalog(root)?;
let recipes = Recipes::load(root, &items)?;
let stations = StationRecipes::load(root, &items);
let items = Items::catalog(root, &PackOverlay::NONE)?;
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
let stations = StationRecipes::load(root, &items, &PackOverlay::NONE);
let enchantments = Enchantments::load(root);
let mut out = String::new();
for line in input.lines() {
@@ -14,6 +14,7 @@ use horizon_client::{
},
};
use horizon_core::random::{LegacyRandom, RandomSource};
use horizon_world::loot::PackOverlay;
use horizon_world::{chunk::BlockState, registry::BlockStateId};
use std::{
collections::{BTreeMap, HashMap, HashSet},
@@ -147,7 +148,7 @@ pub fn main() -> horizon_client::Result<()> {
let registry = assets.registry;
let facts = BlockFacts::load(root, registry)?;
let data = MobData::load(root, &facts);
let items = Items::tags_only(root)?;
let items = Items::tags_only(root, &PackOverlay::NONE)?;
let types = MobTypes::load(root)?;
let mut out = String::new();
for raw in input.lines() {
@@ -13,6 +13,7 @@ use horizon_client::{
random_sequences::RandomSequences,
};
use horizon_core::random::{LegacyRandom, RandomSource};
use horizon_world::loot::PackOverlay;
use horizon_world::nbt::Tag;
use std::{collections::HashMap, fmt::Write as _, fs, path::Path};
@@ -237,12 +238,12 @@ pub fn main() -> horizon_client::Result<()> {
let root = Path::new(args.get(1).ok_or(usage)?);
let input = fs::read_to_string(args.get(2).ok_or(usage)?)?;
let vanilla = fs::read_to_string(args.get(3).ok_or(usage)?)?;
let items = Items::catalog(root)?;
let items = Items::catalog(root, &PackOverlay::NONE)?;
let enchantments = Enchantments::load(root);
let names = horizon_client::creative::Names::default();
let trades = Trades::load(root);
let recipes = horizon_client::crafting::Recipes::load(root, &items)?;
let brewing = horizon_client::brewing::Brewing::load(root, &items);
let recipes = horizon_client::crafting::Recipes::load(root, &items, &PackOverlay::NONE)?;
let brewing = horizon_client::brewing::Brewing::load(root, &items, &PackOverlay::NONE);
let banners = horizon_client::banner::Banners::load(root);
let station_recipes = horizon_client::stations::StationRecipes::default();
let types: HashMap<_, _> = horizon_client::mobs::MobTypes::load(root)?
@@ -0,0 +1,130 @@
//! Thrown tridents over the client's resident scene (`arrows::clip_block_stop`),
//! for tools/test_trident_parity.py: `AbstractArrow.tick`'s block step and
//! `AbstractArrow.onHitBlock`, one line of state per fixture.
#![forbid(unsafe_code)]
use horizon_client::{
Result, arrows, assets::Assets, blocks::BlockFacts, collisions, world::World,
};
use horizon_core::geometry::Vec3;
use serde_json::Value;
use std::{
fs,
io::{BufRead, Write},
path::PathBuf,
};
fn number(v: &Value) -> f64 {
f64::from_bits(u64::from_str_radix(v.as_str().unwrap(), 16).unwrap())
}
fn vector(v: &Value) -> Vec3 {
Vec3::new(number(&v[0]), number(&v[1]), number(&v[2]))
}
fn bits(v: Vec3) -> String {
format!(
"{:016x},{:016x},{:016x}",
v.x.to_bits(),
v.y.to_bits(),
v.z.to_bits()
)
}
pub fn main() -> Result<()> {
let args: Vec<_> = crate::args().skip(1).collect();
if args.len() != 3 {
return Err("usage: trident_probe RESOURCES FIXTURE_DIRECTORY --describe|REFERENCE".into());
}
let root = PathBuf::from(&args[0]);
let dir = PathBuf::from(&args[1]);
let assets = Assets::load(&root)?;
let render = assets.reload(&root)?;
let mut scene = World::test_scene(&assets, &render, 1)?;
fs::write(
dir.join("scene.json"),
serde_json::to_vec(
&serde_json::json!({"radius":1,"resident_chunks":scene.chunks.len(),"model_gallery":render.model_gallery}),
)?,
)?;
if args[2] == "--describe" {
return Ok(());
}
let binding = collisions::load(&root, assets.registry)?;
let facts = BlockFacts::load(&root, assets.registry)?;
let side = &args[2];
let mut out = std::io::BufWriter::new(fs::File::create(dir.join(format!("rust-{side}.txt")))?);
for line in std::io::BufReader::new(fs::File::open(dir.join("inputs.txt"))?).lines() {
let f: Value = serde_json::from_str(&line?)?;
let mut position = vector(&f["position"]);
let mut velocity = vector(&f["velocity"]);
let mut in_ground = false;
let mut shake = 0u32;
for op in f["ops"].as_array().unwrap() {
match op["mode"].as_str().unwrap() {
"block" => {
let p = &op["position"];
let mut state = assets
.registry
.default_state(op["name"].as_str().unwrap())
.ok_or("missing fixture block")?;
if let Some(properties) = op["properties"].as_object() {
let mut definition = assets.registry.state(state).unwrap().clone();
for (name, value) in properties {
definition
.properties
.insert(name.clone(), value.as_str().unwrap().into());
}
state = assets
.registry
.id(&definition)
.ok_or("missing fixture state")?;
}
scene.set(
p[0].as_i64().unwrap() as i32,
p[1].as_i64().unwrap() as i32,
p[2].as_i64().unwrap() as i32,
state,
)?;
}
// Ops only the vanilla oracle needs.
"level_seed" | "owner" | "pickup" | "touch" | "target" | "velocity" | "ignite" => {}
"tick" => {
// AbstractArrow.tick: shakeTime down, then the block step.
shake = shake.saturating_sub(1);
if in_ground {
continue;
}
if let Some((stop, _)) =
arrows::clip_block_stop(&scene, &facts, &binding, position, velocity)
{
// onHitBlock: pulled back, stopped, stuck, shaking 7 ticks.
position = stop;
velocity = Vec3::ZERO;
in_ground = true;
shake = 7;
} else {
// A MISS moves the whole movement, then air drag and gravity.
position = Vec3::new(
position.x + velocity.x,
position.y + velocity.y,
position.z + velocity.z,
);
velocity = Vec3::new(
velocity.x * f64::from(0.99f32),
velocity.y * f64::from(0.99f32) - 0.05,
velocity.z * f64::from(0.99f32),
);
}
}
other => return Err(format!("unknown operation {other}").into()),
}
}
out.write_all(
format!(
"trident {} {} {in_ground} {shake}\n",
bits(position),
bits(velocity)
)
.as_bytes(),
)?;
}
Ok(())
}
@@ -14,6 +14,7 @@ use horizon_client::{
light::LightTables, mesher::MeshTables, models, motion::MotionResources,
vanillagen::VanillaTerrain,
};
use horizon_world::loot::PackOverlay;
use std::{
alloc::{GlobalAlloc, Layout, System},
sync::atomic::{AtomicUsize, Ordering::Relaxed},
@@ -105,9 +106,9 @@ fn main() -> Result<()> {
// The game decodes gameplay facts once for both tables (BlockFacts::load_pair).
let (facts, behaviours) = BlockFacts::load_pair(&root, assets.registry)?;
step("block facts+behaviours");
let items = Items::load(&root, assets.registry, &facts)?;
let items = Items::load(&root, assets.registry, &facts, &PackOverlay::NONE)?;
step("items");
let recipes = Recipes::load(&root, &items)?;
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE)?;
step("recipes");
let biomes = Biomes::load(&root)?;
step("biomes");
+43 -1
View File
@@ -5,7 +5,8 @@
use serde_json::Value;
use std::{
collections::{BTreeMap, BTreeSet, HashMap},
path::Path,
path::{Path, PathBuf},
sync::{Arc, Mutex, Weak},
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -66,8 +67,36 @@ fn frame(v: &Value) -> Frame {
}
}
impl Tree {
/// [`Tree::load`], shared while anyone holds it: a server's advancement
/// packets (`ServerAdvancements`) and its game's criteria read the same
/// files, and each kept its own copy (7 MB). A world whose data packs bring
/// advancements gets its own tree ([`Tree::load_with`]).
pub fn shared(data: &Path, overlay: &horizon_world::loot::PackOverlay) -> Arc<Tree> {
if overlay.kind("advancement").next().is_some() {
return Arc::new(Tree::load_with(data, overlay));
}
static TREES: Mutex<Vec<(PathBuf, Weak<Tree>)>> = Mutex::new(Vec::new());
// Held while loading: a second reader waits for the first one's tree.
let mut trees = TREES.lock().unwrap_or_else(|e| e.into_inner());
trees.retain(|(_, tree)| tree.strong_count() > 0);
if let Some(tree) = trees
.iter()
.find(|(path, _)| path == data)
.and_then(|(_, tree)| tree.upgrade())
{
return tree;
}
let tree = Arc::new(Tree::load(data));
trees.push((data.to_path_buf(), Arc::downgrade(&tree)));
tree
}
/// Loads `<data>/<namespace>/advancement/**/*.json`.
pub fn load(data: &Path) -> Tree {
Self::load_with(data, &horizon_world::loot::PackOverlay::NONE)
}
/// The tree with a world's data packs' advancement files over the game's
/// own: the top pack's file for each id replaces it.
pub fn load_with(data: &Path, overlay: &horizon_world::loot::PackOverlay) -> Tree {
let mut tree = Tree::default();
let Ok(namespaces) = std::fs::read_dir(data) else {
return tree;
@@ -77,6 +106,8 @@ impl Tree {
let root = ns.path().join("advancement");
collect(&root, &root, &ns.file_name().to_string_lossy(), &mut files);
}
// One JSON per id, the packs' over the game's own.
let mut json: BTreeMap<String, Value> = BTreeMap::new();
files.sort();
for (id, path) in files {
let Ok(text) = std::fs::read_to_string(&path) else {
@@ -85,6 +116,17 @@ impl Tree {
let Ok(v) = serde_json::from_str::<Value>(&text) else {
continue;
};
json.insert(id, v);
}
for (id, files) in overlay.kind("advancement") {
match serde_json::from_slice::<Value>(horizon_world::loot::PackOverlay::top(files)) {
Ok(v) => {
json.insert(id.to_owned(), v);
}
Err(e) => log::warn!("Horizon: pack advancement {id} not loaded: {e}"),
}
}
for (id, v) in json {
if let Some(a) = parse(&id, &v) {
let index = tree.list.len();
for (ci, c) in a.criteria.iter().enumerate() {
+40
View File
@@ -101,6 +101,8 @@ enum Animate {
DeadBush,
/// `ShortDryGrassBlock` and `TallDryGrassBlock`.
DryGrass,
/// A creaking heart that is not uprooted (`CreakingHeartBlock.animateTick`).
CreakingHeart,
}
fn step(facing: &str) -> [i32; 3] {
match facing {
@@ -236,6 +238,9 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
"firefly_bush" => Animate::FireflyBush,
"open_eyeblossom" => Animate::OpenEyeblossom,
"pale_hanging_moss" => Animate::PaleHangingMoss,
"creaking_heart" if prop("creaking_heart_state").as_deref() != Some("uprooted") => {
Animate::CreakingHeart
}
"dead_bush" => Animate::DeadBush,
"short_dry_grass" | "tall_dry_grass" => Animate::DryGrass,
"sculk_sensor" | "calibrated_sculk_sensor"
@@ -442,6 +447,8 @@ pub struct AnimateContext<'a> {
pub height_no_leaves: &'a dyn Fn([i32; 3]) -> i32,
/// `EnvironmentAttributes.FIREFLY_BUSH_SOUNDS` (overworld day timeline).
pub firefly_bush_sounds: bool,
/// `EnvironmentAttributes.CREAKING_ACTIVE` (overworld day timeline).
pub creaking_active: bool,
/// `Level.getSkyDarken`, for `getMaxLocalRawBrightness`.
pub sky_darken: i32,
/// Creative main-hand marker block and a resolver for each placed state's sprite.
@@ -1259,6 +1266,39 @@ fn block_animate_tick(
}
}
}
Animate::CreakingHeart => {
// CreakingHeartBlock.animateTick: the idle creak is drawn only while
// the attribute is on, then needs pale oak logs on all six sides
// (`CreakingHeartBlock.isSurroundedByLogs`).
if ctx.creaking_active
&& next_int(engine.random(), 16) == 0
&& [
[-1, 0, 0],
[1, 0, 0],
[0, -1, 0],
[0, 1, 0],
[0, 0, -1],
[0, 0, 1],
]
.iter()
.all(|d| {
let p = [pos[0] + d[0], pos[1] + d[1], pos[2] + d[2]];
tables
.pale_oak_logs
.get(ctx.state(p).0 as usize)
.copied()
.unwrap_or(false)
})
{
sound(
engine,
"minecraft:block.creaking_heart.idle",
[fx, fy, fz],
1.0,
1.0,
);
}
}
Animate::DeadBush => {
// AmbientDesertBlockSoundsPlayer.playAmbientDeadBushSounds.
if next_int(engine.random(), 130) == 0 {
+90 -4
View File
@@ -94,6 +94,13 @@ pub enum ArrowEvent {
position: Vec3,
velocity: Vec3,
},
/// `Level.broadcastEntityEvent(arrow, 0)`: a tipped arrow stuck for 600
/// ticks. The clients burst its particles (`Arrow.handleEntityEvent`);
/// the arrow itself is a plain one from now on.
PotionPuff {
id: u32,
color: u32,
},
Sound {
sound: ArrowSound,
volume: f32,
@@ -186,6 +193,8 @@ pub struct ArrowState {
/// The arrow's own random (`Entity.random`).
pub random: LegacyRandom,
pub events: Vec<ArrowEvent>,
/// The speed (`deltaMovement.length()`) it hit a block at, before it stuck.
pub hit_speed: f64,
}
impl fmt::Debug for ArrowState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
@@ -230,8 +239,15 @@ impl ArrowState {
removed: false,
random,
events: Vec::new(),
hit_speed: 0.0,
}
}
/// `Arrow.getColor` of the server's own arrow: the colour of the potion on
/// its pickup item, None for a plain arrow (`PotionContents.EMPTY`).
pub fn potion_color(&self) -> Option<u32> {
let potion = self.pickup_item.potion()?;
Some(crate::brewing::potion_color(Some(potion)))
}
/// The `AbstractArrow` constructor's `pickupItemStack`: the drawn ammo
/// (one item). Ammo drawn without being used up (`INTANGIBLE_PROJECTILE`)
/// makes the arrow CREATIVE_ONLY; vanilla takes the component off the
@@ -375,6 +391,61 @@ impl ArrowScene<'_, '_> {
true
}
}
/// `AbstractArrow.tick`'s block phase for a thrown trident: the zero-width
/// `level().clip(new ClipContext(position(), position().add(movement),
/// ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, this))`, then
/// `AbstractArrow.onHitBlock`'s `position().subtract(signum * 0.05)`. Returns
/// the position to stop at (pull-back included) and the block that was hit.
pub fn clip_block_stop(
world: &World,
facts: &BlockFacts,
collision: &BlockCollisionRegistry<'_>,
from: Vec3,
movement: Vec3,
) -> Option<(Vec3, BlockPos)> {
let to = Vec3::new(
from.x + movement.x,
from.y + movement.y,
from.z + movement.z,
);
let mut scene = SceneCollisions {
world,
binding: collision,
subject: None,
};
let hit = horizon_core::geometry::traverse_blocks(from, to, 1024, |p: BlockPos| {
let state = world.block(p.x, p.y, p.z);
if facts.is_air(state) {
return None;
}
let shape = collision
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
.ok()?;
shape.clip(from, to, p)
})
.ok()
.flatten()?;
Some((back_off(hit.location, movement), hit.block))
}
/// `AbstractArrow.onHitBlock`'s back-off: vanilla scales `(double) 0.05f` by
/// `Math.signum` per axis, whose value at zero is zero (not Rust's -1.0).
pub fn back_off(location: Vec3, movement: Vec3) -> Vec3 {
fn signum(v: f64) -> f64 {
if v > 0.0 {
1.0
} else if v < 0.0 {
-1.0
} else {
0.0
}
}
let offset = f64::from(0.05f32);
Vec3::new(
location.x - signum(movement.x) * offset,
location.y - signum(movement.y) * offset,
location.z - signum(movement.z) * offset,
)
}
fn bounds(at: Vec3) -> Aabb {
// EntityDimensions.makeBoundingBox: half the float width.
let w = f64::from(WIDTH / 2.0);
@@ -576,6 +647,19 @@ impl Ticker<'_, '_, '_, '_> {
let at = self.position();
self.apply_effects_from_blocks(at, at);
}
// Arrow.tick on the server: a tipped arrow stuck for 600 ticks sends
// entity event 0 (the clients' burst of particles in its colour,
// `handleEntityEvent`) and turns into a plain arrow. The flight
// and ground particles are the clients' own (`Level.isClientSide`).
if self.st.in_ground_time >= 600
&& let Some(color) = self.st.potion_color()
{
self.st.events.push(ArrowEvent::PotionPuff {
id: self.p.id,
color,
});
self.st.pickup_item = crate::items::ItemStack::new("minecraft:arrow", 1);
}
return Hit::None;
}
self.st.in_ground_time = 0;
@@ -664,7 +748,7 @@ impl Ticker<'_, '_, '_, '_> {
if !self.st.first_tick && self.st.fluids.is_in_fluid(&Tags, LAVA) {
self.st.fall_distance *= 0.5;
}
if self.position().y < f64::from(crate::chunk::MIN_Y - 64) {
if self.position().y < f64::from(self.scene.world.bottom_y() - 64) {
self.st.removed = true;
}
self.st.first_tick = false;
@@ -848,6 +932,8 @@ impl Ticker<'_, '_, '_, '_> {
/// it moved on, stop, and stick.
fn on_hit_block(&mut self, pos: BlockPos, state: BlockStateId) {
self.st.last_state = Some(state);
// BlockState.onProjectileHit still sees the deltaMovement it hit at.
self.st.hit_speed = self.p.velocity.length();
// Projectile.onHitBlock: BlockState.onProjectileHit.
if self.st.on_fire()
&& let Some(reaction) = self.block_reaction(state)
@@ -1159,10 +1245,10 @@ impl FluidInteractionWorld for Ticker<'_, '_, '_, '_> {
Ok(self.scene.world.chunks.contains_key(&(x, z)).then_some(()))
}
fn chunk_sections(&mut self, _: ()) -> Result<FluidSections<'_>, Error> {
static SECTIONS: [bool; crate::chunk::SECTIONS] = [true; crate::chunk::SECTIONS];
let layout = self.scene.world.layout();
Ok(FluidSections {
minimum_section_y: crate::chunk::MIN_Y >> 4,
has_fluid: &SECTIONS,
minimum_section_y: layout.min_y() >> 4,
has_fluid: crate::chunk::every_section(layout),
})
}
fn bounding_box_min_y(&self) -> f64 {
+36 -15
View File
@@ -9,6 +9,7 @@ use crate::{
items::{ItemStack, Items},
particles::{DYE_FIREWORK_COLORS, FireworkExplosion, FireworkShape},
};
use horizon_world::loot::PackOverlay;
use horizon_world::nbt::Tag;
use serde_json::Value;
use std::{
@@ -1180,7 +1181,7 @@ fn matches_all(ingredients: &[Ingredient], stacks: &[&ItemStack]) -> bool {
})
}
impl Recipes {
pub fn load(root: &Path, items: &Items) -> Result<Self> {
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Result<Self> {
let mut crafting = Vec::new();
let mut cooking = Vec::new();
let mut meta = HashMap::new();
@@ -1193,27 +1194,47 @@ impl Recipes {
files.sort();
Ok(files)
};
let stem = |p: &Path| {
p.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_owned()
};
// Vanilla's recipes by bare name, then content mods' as `<ns>:<name>`.
let mut files: Vec<(String, PathBuf)> = json_files(&root.join("data/minecraft/recipe"))?
.into_iter()
.map(|p| (String::new(), p))
.collect();
let mut sources: Vec<(String, Vec<u8>)> = Vec::new();
for p in json_files(&root.join("data/minecraft/recipe"))? {
sources.push((stem(&p), fs::read(&p)?));
}
for namespace in crate::mod_content::namespaces(root) {
let dir = root.join(format!("data/{namespace}/recipe"));
if dir.is_dir() {
files.extend(
json_files(&dir)?
.into_iter()
.map(|p| (format!("{namespace}:"), p)),
);
for p in json_files(&dir)? {
sources.push((format!("{namespace}:{}", stem(&p)), fs::read(&p)?));
}
}
}
for (prefix, path) in files {
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let name = format!("{prefix}{stem}");
// The world's packs' recipes over the game's own: the top pack's
// file for each id replaces it (`SimpleJsonResourceReloadListener`
// sees one resource per id, the resource stack's top).
for (id, files) in overlay.kind("recipe") {
let Some((ns, path)) = id.split_once(':') else {
continue;
};
let name = if ns == "minecraft" {
path.to_owned()
} else {
id.to_owned()
};
let bytes = PackOverlay::top(files).to_vec();
match sources.iter_mut().find(|(n, _)| *n == name) {
Some(slot) => slot.1 = bytes,
None => sources.push((name, bytes)),
}
}
for (name, bytes) in sources {
// Vanilla's RecipeManager logs a file that does not parse and
// goes on ("Couldn't parse data file").
let v: Value = match serde_json::from_slice(&fs::read(&path)?) {
let v: Value = match serde_json::from_slice(&bytes) {
Ok(v) => v,
Err(e) => {
log::warn!("Horizon: recipe {name} not loaded: {e}");
@@ -1420,7 +1441,7 @@ impl Recipes {
)
})
.collect();
let curses = crate::items::load_tags(root, "enchantment")?
let curses = crate::items::load_tags(root, "enchantment", overlay)?
.remove("minecraft:curse")
.unwrap_or_default();
Ok(Self {
+239 -55
View File
@@ -24,6 +24,31 @@ pub struct Tab {
/// tab is selected (`set_saved_hotbars`).
pub hotbar: bool,
}
impl Tab {
/// The tab without its stacks.
fn header(&self) -> Self {
Self {
id: self.id.clone(),
title: self.title.clone(),
icon: self.icon.clone(),
items: Vec::new(),
top: self.top,
column: self.column,
search: self.search,
inventory: self.inventory,
hotbar: self.hotbar,
}
}
}
/// A tab as exported, built with permissions: `operator` marks the stacks a
/// build without them leaves out (the export's `permissions_only`).
#[derive(Clone, Debug)]
struct Exported {
tab: Tab,
/// `CreativeModeTab.Type.CATEGORY`: shown only with a stack (`shouldDisplay`).
category: bool,
operator: Vec<bool>,
}
/// What the client's creative search trees (SessionSearchTrees) index for one
/// stack of the search tab: its creative tooltip lines and its item tags.
#[derive(Clone, Debug, Default)]
@@ -37,6 +62,8 @@ struct SearchEntry {
tags: Vec<(String, String)>,
}
pub struct Creative {
/// The tabs shown (`CreativeModeTabs.tabs()`), with the stacks the current
/// permissions put in them.
pub tabs: Vec<Tab>,
pub selected: usize,
/// Scroll position 0..1 (scrollOffs).
@@ -48,6 +75,13 @@ pub struct Creative {
/// The search tab's stacks, in its display order.
search_items: Vec<ItemStack>,
search_index: Vec<SearchEntry>,
/// Every exported tab, and the search tab's stacks with their entries and
/// whether only permissions list them: `rebuild` keeps what is shown.
exported: Vec<Exported>,
exported_search: Vec<(ItemStack, SearchEntry, bool)>,
/// The `hasPermissions` the tabs were last built with
/// (`CreativeModeTabs.tryRebuildTabContents`).
permissions: bool,
/// ItemPickerMenu.items: what the grid shows, filled when the tab or the
/// search changes (selectTab, refreshSearchResults), not on every frame.
shown: Mutex<Option<Shown>>,
@@ -202,14 +236,22 @@ impl Creative {
}
pub fn from_json(raw: &Value) -> Result<Self> {
let mut tabs = Vec::new();
let mut search_items = Vec::new();
let mut search_index = Vec::new();
let mut search = Vec::new();
for t in raw["tabs"].as_array().into_iter().flatten() {
// The stacks only a build with permissions lists, by index.
let only: Vec<u64> = t["permissions_only"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_u64)
.collect();
let mut items = Vec::new();
for v in t["items"].as_array().into_iter().flatten() {
let mut operator = Vec::new();
for (i, v) in t["items"].as_array().into_iter().flatten().enumerate() {
let snbt = v.as_str().unwrap_or("-");
if let Some(stack) = crate::item_data::parse_stack(snbt)? {
items.push(stack);
operator.push(only.contains(&(i as u64)));
}
}
let kind = t["type"].as_str().unwrap_or("");
@@ -223,34 +265,35 @@ impl Creative {
};
for (i, stack) in items.iter().enumerate() {
let (namespace, path) = split_id(&java_lower(&stack.item));
search_index.push(SearchEntry {
let entry = SearchEntry {
namespace,
path,
lines: strings(&t["lines"][i]),
tags: strings(&t["tags"][i]).iter().map(|t| split_id(t)).collect(),
});
};
search.push((stack.clone(), entry, operator[i]));
}
search_items = items.clone();
}
if t["visible"] == false {
continue;
}
let icon = t["icon"]
.as_str()
.and_then(|s| crate::item_data::parse_stack(s).ok().flatten());
tabs.push(Tab {
id: t["id"].as_str().unwrap_or("").to_owned(),
title: t["title"].as_str().unwrap_or("").to_owned(),
icon: icon.map_or_else(|| "minecraft:stone".to_owned(), |s| s.item.to_string()),
items,
top: t["row"] == "TOP",
column: t["column"].as_u64().unwrap_or(0) as u32,
search: kind == "SEARCH",
inventory: kind == "INVENTORY",
hotbar: kind == "HOTBAR",
tabs.push(Exported {
tab: Tab {
id: t["id"].as_str().unwrap_or("").to_owned(),
title: t["title"].as_str().unwrap_or("").to_owned(),
icon: icon.map_or_else(|| "minecraft:stone".to_owned(), |s| s.item.to_string()),
items,
top: t["row"] == "TOP",
column: t["column"].as_u64().unwrap_or(0) as u32,
search: kind == "SEARCH",
inventory: kind == "INVENTORY",
hotbar: kind == "HOTBAR",
},
category: kind == "CATEGORY",
operator,
});
}
Ok(Self::new(tabs, search_items, search_index))
Ok(Self::new(tabs, search))
}
/// The old export: item ids per tab, no components, tooltips or tags.
fn from_legacy(raw: &Value) -> Result<Self> {
@@ -272,34 +315,39 @@ impl Creative {
}
}
let kind = t["type"].as_str().unwrap_or("");
tabs.push(Tab {
id: t["id"].as_str().unwrap_or("").to_owned(),
title: t["title"].as_str().unwrap_or("").to_owned(),
icon: t["icon"].as_str().unwrap_or("minecraft:stone").to_owned(),
items,
top: t["row"] == "TOP",
column: t["column"].as_u64().unwrap_or(0) as u32,
search: kind == "SEARCH",
inventory: kind == "INVENTORY",
hotbar: false,
tabs.push(Exported {
operator: vec![false; items.len()],
tab: Tab {
id: t["id"].as_str().unwrap_or("").to_owned(),
title: t["title"].as_str().unwrap_or("").to_owned(),
icon: t["icon"].as_str().unwrap_or("minecraft:stone").to_owned(),
items,
top: t["row"] == "TOP",
column: t["column"].as_u64().unwrap_or(0) as u32,
search: kind == "SEARCH",
inventory: kind == "INVENTORY",
hotbar: false,
},
category: false,
});
}
let search_index = search_items
.iter()
let search = search_items
.into_iter()
.map(|s| {
let (namespace, path) = split_id(&java_lower(&s.item));
SearchEntry {
let entry = SearchEntry {
namespace,
path,
..Default::default()
}
};
(s, entry, false)
})
.collect();
Ok(Self::new(tabs, search_items, search_index))
Ok(Self::new(tabs, search))
}
fn new(tabs: Vec<Tab>, search_items: Vec<ItemStack>, search_index: Vec<SearchEntry>) -> Self {
Self {
tabs,
fn new(exported: Vec<Exported>, exported_search: Vec<(ItemStack, SearchEntry, bool)>) -> Self {
let mut creative = Self {
tabs: Vec::new(),
selected: 0,
scroll: 0.0,
scrolling: false,
@@ -310,10 +358,81 @@ impl Creative {
search.text_color = u32::MAX;
search
},
search_items,
search_index,
search_items: Vec::new(),
search_index: Vec::new(),
exported,
exported_search,
permissions: false,
shown: Mutex::new(None),
};
creative.rebuild();
creative
}
/// `CreativeModeTabs.buildAllTabContents` with `permissions`: the operator
/// tab's display items generator lists its stacks only with them
/// (`hasPermissions`), and the search tab with it; a category tab is
/// shown with a stack (`CreativeModeTab.shouldDisplay`).
fn rebuild(&mut self) {
let permissions = self.permissions;
let keep = |operator: bool| permissions || !operator;
self.tabs = self
.exported
.iter()
.filter_map(|e| {
let items: Vec<ItemStack> = (e.tab.items.iter().zip(&e.operator))
.filter(|(_, o)| keep(**o))
.map(|(s, _)| s.clone())
.collect();
(!e.category || !items.is_empty()).then(|| Tab {
items,
..e.tab.header()
})
})
.collect();
(self.search_items, self.search_index) = self
.exported_search
.iter()
.filter(|(_, _, o)| keep(*o))
.map(|(s, e, _)| (s.clone(), e.clone()))
.unzip();
self.forget_items();
}
/// `CreativeModeInventoryScreen.tryRefreshInvalidatedTabs`, which its
/// constructor and `containerTick` run with `hasPermissions`
/// (`Player.canUseGameMasterBlocks` and the Operator Items Tab option):
/// when that changed, the tabs are built again. A selected tab left
/// without stacks (the operator tab) gives way to the default tab; any
/// other keeps its first row (`refreshCurrentTabContents`). True when the
/// tabs were rebuilt.
pub fn set_permissions(&mut self, permissions: bool, names: &Names) -> bool {
if permissions == self.permissions {
return false;
}
let selected = self.tab().id.clone();
let row = self.first_row(names);
self.permissions = permissions;
self.rebuild();
match self.tabs.iter().position(|t| t.id == selected) {
Some(tab) => {
self.selected = tab;
// ItemPickerMenu.getScrollForRowIndex.
let hidden = self.hidden_rows(names);
self.scroll = if hidden > 0 {
(row as f32 / hidden as f32).clamp(0.0, 1.0)
} else {
0.0
};
}
None => {
// CreativeModeTabs.getDefaultTab.
let default = self
.tabs
.iter()
.position(|t| t.id == "minecraft:building_blocks");
self.select(default.unwrap_or(0));
}
}
true
}
/// Where a stack comes in the creative tabs (the search tab lists them
/// all, in tab order): the same stack's place, else its item's first.
@@ -327,20 +446,7 @@ impl Creative {
}
/// The tabs without their stacks: what the screen draws of them each frame.
pub fn tab_headers(&self) -> Vec<Tab> {
self.tabs
.iter()
.map(|t| Tab {
id: t.id.clone(),
title: t.title.clone(),
icon: t.icon.clone(),
items: Vec::new(),
top: t.top,
column: t.column,
search: t.search,
inventory: t.inventory,
hotbar: t.hotbar,
})
.collect()
self.tabs.iter().map(Tab::header).collect()
}
pub fn tab(&self) -> &Tab {
&self.tabs[self.selected.min(self.tabs.len() - 1)]
@@ -601,7 +707,7 @@ impl Creative {
pub fn tab_position(tab: &Tab) -> (f32, f32) {
let mut x = tab.column as f32 * 27.0;
if tab.column >= 5 {
// Right-aligned tabs (search, inventory).
// Right-aligned tabs (search, operator, inventory).
x = Self::WIDTH - 27.0 * (7 - tab.column) as f32 + 1.0;
}
let y = if tab.top { -28.0 } else { Self::HEIGHT - 4.0 };
@@ -1083,6 +1189,84 @@ mod tests {
assert_eq!(c.search.value, "");
}
/// An export built with permissions: the operator tab's stacks, and their
/// place at the end of the search tab, only with them.
fn with_operator_tab() -> Creative {
let stack = |id: &str| format!("{{count:1,id:\"minecraft:{id}\"}}");
let raw = serde_json::json!({"schema": 3, "tabs": [
{"id": "minecraft:building_blocks", "type": "CATEGORY", "row": "TOP", "column": 0, "title": "Building Blocks",
"icon": stack("bricks"), "visible": true, "items": [stack("stone")]},
{"id": "minecraft:op_blocks", "type": "CATEGORY", "row": "BOTTOM", "column": 5, "title": "Operator Utilities",
"icon": stack("command_block"), "visible": false,
"items": [stack("command_block"), stack("barrier")], "permissions_only": [0, 1]},
{"id": "minecraft:search", "type": "SEARCH", "row": "TOP", "column": 6, "title": "Search Items",
"icon": stack("compass"), "visible": true,
"items": [stack("stone"), stack("command_block"), stack("barrier")],
"lines": [["Stone"], ["Command Block"], ["Barrier"]], "tags": [[], [], []],
"permissions_only": [1, 2]},
{"id": "minecraft:inventory", "type": "INVENTORY", "row": "BOTTOM", "column": 6, "title": "Survival Inventory",
"icon": stack("chest"), "visible": true, "items": []}
]});
Creative::from_json(&raw).unwrap()
}
fn tab_ids(c: &Creative) -> Vec<&str> {
c.tabs.iter().map(|t| t.id.as_str()).collect()
}
#[test]
fn operator_stacks_stay_out_without_permissions() {
let c = with_operator_tab();
let names = Names::default();
assert_eq!(
tab_ids(&c),
[
"minecraft:building_blocks",
"minecraft:search",
"minecraft:inventory"
]
);
assert_eq!(c.search_items().len(), 1);
assert!(c.search_results("command", &names).is_empty());
assert_eq!(c.search_results("", &names), [0]);
}
#[test]
fn operator_tab_comes_and_goes_with_permissions() {
let mut c = with_operator_tab();
let names = Names::default();
assert!(!c.set_permissions(false, &names));
assert!(c.set_permissions(true, &names));
assert!(!c.set_permissions(true, &names));
assert_eq!(
tab_ids(&c),
[
"minecraft:building_blocks",
"minecraft:op_blocks",
"minecraft:search",
"minecraft:inventory"
]
);
let op: Vec<&str> = c.tabs[1].items.iter().map(|s| s.item.as_ref()).collect();
assert_eq!(op, ["minecraft:command_block", "minecraft:barrier"]);
assert_eq!(c.search_results("command", &names), [1]);
assert_eq!(c.search_results("", &names), [0, 1, 2]);
// The search tab keeps its place across the rebuild.
c.select(2);
c.search.set_value("block");
assert!(c.set_permissions(false, &names));
assert!(c.tab().search);
assert_eq!(c.search.value, "block");
assert!(c.items(&names).is_empty());
// The selected operator tab is left empty: the default tab instead.
c.set_permissions(true, &names);
c.select(1);
assert_eq!(c.items(&names).len(), 2);
c.set_permissions(false, &names);
assert_eq!(c.tab().id, "minecraft:building_blocks");
assert_eq!(c.items(&names)[0].item.as_ref(), "minecraft:stone");
}
#[test]
fn saved_hotbars_tab_shows_its_rows_and_locks_the_hint() {
let mut c = Creative::from_json(&serde_json::json!({"schema": 2, "tabs": [
+99
View File
@@ -4,6 +4,7 @@
//! (the entity goes through on the next tick), and
//! `TheEndGatewayBlockEntity.getPortalPosition` (where it lands).
use crate::nether_portal::{ENTITY_DELAY, PortalState};
use crate::projectiles::{Projectile, ProjectileKind};
use horizon_core::{geometry::Vec3, position::BlockPos};
/// The End gateway travel of one entity.
@@ -65,6 +66,35 @@ pub fn swept_gateway(
found
}
/// The End gateway a thrown ender pearl passed through on its last move, from
/// where it started the tick to where it is (`Entity.checkInsideBlocks` from
/// `ThrowableProjectile.tick`'s `applyEffectsFromBlocks`). Other projectiles
/// are not looked at.
pub fn pearl_gateway(
pearl: &Projectile,
is_gateway: impl FnMut(BlockPos) -> bool,
) -> Option<BlockPos> {
if !matches!(pearl.kind, ProjectileKind::EnderPearl) {
return None;
}
let size = pearl.kind.size() as f32;
swept_gateway(pearl.previous, pearl.position, size, size, is_gateway)
}
/// A thrown ender pearl at its gateway's exit `at`
/// (`EndGatewayBlock.getPortalDestination` for a `ThrownEnderpearl`: a
/// `TeleportTransition` with no motion, rotation 0 and nothing relative).
/// It falls from there and takes its owner where it lands.
pub fn place_pearl(pearl: &mut Projectile, at: Vec3) {
pearl.position = at;
pearl.previous = at;
pearl.velocity = Vec3::ZERO;
pearl.yaw = 0.0;
pearl.pitch = 0.0;
// A server tells its clients of the jump at once.
pearl.needs_sync = true;
}
/// The bottom centre of a block (`Vec3.atBottomCenterOf`): where a traveller
/// lands at a gateway's exit.
pub fn bottom_center(p: [i32; 3]) -> Vec3 {
@@ -150,6 +180,75 @@ mod tests {
assert_eq!(traveller.travel(false), None);
}
/// A world with no blocks in the way and nothing to hit: the gateway
/// block has no collision shape, so a projectile flies into it.
struct Open;
impl crate::projectiles::ProjectileWorld for Open {
fn clip(&self, _: Vec3, _: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
None
}
fn in_water(&self, _: horizon_core::geometry::Aabb) -> bool {
false
}
fn targets(&self) -> &[(crate::mobs::Who, horizon_core::geometry::Aabb)] {
&[]
}
}
/// The ticks a projectile flies, thrown east at the gateway, until its
/// move goes through one (`None` if it never does in 5 ticks).
fn thrown_at_gateway(kind: ProjectileKind) -> (Projectile, Option<BlockPos>) {
let mut shot = Projectile::new(kind, at(699.5, 100.3, 0.5), None);
shot.velocity = at(1.5, 0.0, 0.0);
for _ in 0..5 {
shot.tick(&Open);
if let Some(g) = pearl_gateway(&shot, is_gateway) {
return (shot, Some(g));
}
}
(shot, None)
}
#[test]
fn a_thrown_pearl_goes_through_the_gateway_and_falls_from_its_exit() {
let (mut pearl, found) = thrown_at_gateway(ProjectileKind::EnderPearl);
let gateway = BlockPos::new(702, 100, 0);
// ThrownEnderpearl in the gateway block: EndGatewayBlock.entityInside
// takes it in, and it goes through on the next tick.
assert_eq!(found, Some(gateway));
let mut traveller = Traveller::default();
traveller.enter(gateway);
assert_eq!(traveller.travel(true), Some(gateway));
// getPortalDestination: at the exit, no motion and rotation 0.
let exit = bottom_center([8, 100, 0]);
place_pearl(&mut pearl, exit);
assert_eq!(pearl.position, exit);
assert_eq!(pearl.previous, exit);
assert_eq!(pearl.velocity, Vec3::ZERO);
assert_eq!((pearl.yaw, pearl.pitch), (0.0, 0.0));
// It falls straight down from the exit (ThrowableProjectile gravity,
// then inertia), where it lands and takes its owner along.
pearl.tick(&Open);
assert_eq!((pearl.position.x, pearl.position.z), (8.5, 0.5));
assert!((pearl.position.y - (100.0 - 0.03 * 0.99)).abs() < 1e-9);
// The pearl is out of the gateway: it does not go back at once.
assert_eq!(traveller.travel(true), None);
assert_eq!(traveller.cooldown(), ENTITY_DELAY - 1);
}
#[test]
fn a_pearl_flying_past_the_gateway_stays_out_of_it() {
// Thrown one block higher, over the gateway block.
let mut pearl = Projectile::new(ProjectileKind::EnderPearl, at(699.5, 101.3, 0.5), None);
pearl.velocity = at(1.5, 0.0, 0.0);
for _ in 0..5 {
pearl.tick(&Open);
assert_eq!(pearl_gateway(&pearl, is_gateway), None);
}
// Only the pearl travels: a snowball on the same path is not taken in.
assert_eq!(thrown_at_gateway(ProjectileKind::Snowball).1, None);
}
#[test]
fn a_traveller_lands_at_the_bottom_centre_of_the_exit() {
// TheEndGatewayBlockEntity.getPortalPosition: the exit's bottom
+138 -2
View File
@@ -1,6 +1,7 @@
//! Non-player entities simulated on the client world. Dropped items follow
//! `ItemEntity.tick`: gravity 0.04, 0.98 drag (block friction on the ground),
//! a ground bounce of -0.5, water buoyancy, pickup delay and 6000-tick despawn.
//! a ground bounce of -0.5, water buoyancy, pickup delay, 6000-tick despawn,
//! and `HoneyBlock.entityInside` when falling against a honey block's side.
use crate::{
blocks::{BlockFacts, Fluid},
collisions::SceneCollisions,
@@ -237,6 +238,8 @@ impl ItemEntity {
if moved.y != wanted.y {
self.velocity.y = 0.0;
}
// Entity.move -> applyEffectsFromBlocks: HoneyBlock.entityInside.
self.apply_honey_slide(world, facts);
let mut friction = 0.98;
if self.on_ground {
let below = world.block(
@@ -260,6 +263,51 @@ impl ItemEntity {
self.update_fluids(world, fluids);
self.age < 6000 && self.position.y > -128.0
}
/// `HoneyBlock.entityInside` for the honey blocks this item's box is in
/// (`Entity.checkInsideBlocks`, deflated by `1.0E-5`). Slide sounds and
/// entity event 53 are skipped (`doesEntityDoHoneyBlockSlideEffects` is
/// false for an item).
fn apply_honey_slide(&mut self, world: &World, facts: &BlockFacts) {
let b = self.bounds().deflate(Vec3::new(1e-5, 1e-5, 1e-5));
let (lo, hi) = (b.min_position(), b.max_position());
for x in lo.x.floor() as i32..=hi.x.floor() as i32 {
for y in lo.y.floor() as i32..=hi.y.floor() as i32 {
for z in lo.z.floor() as i32..=hi.z.floor() as i32 {
if &*facts.get(world.block(x, y, z)).name == "minecraft:honey_block" {
self.honey_block_inside([x, y, z]);
}
}
}
}
}
/// `HoneyBlock.isSlidingDown` then `doSlideMovement`. `getOldDeltaY` /
/// `getNewDeltaY` wrap the stored velocity the same way they do for every
/// entity (`vy / 0.98F + 0.08`).
pub(crate) fn honey_block_inside(&mut self, pos: [i32; 3]) {
let old_delta_y = |dy: f64| dy / f64::from(0.98_f32) + 0.08;
let new_delta_y = |dy: f64| (dy - 0.08) * f64::from(0.98_f32);
let [x, y, z] = pos.map(f64::from);
if self.on_ground
|| self.position.y > y + 0.9375 - 1.0e-7
|| old_delta_y(self.velocity.y) >= -0.08
{
return;
}
let dx = (x + 0.5 - self.position.x).abs();
let dz = (z + 0.5 - self.position.z).abs();
let overlap = 0.4375 + Self::WIDTH / 2.0;
if dx + 1.0e-7 <= overlap && dz + 1.0e-7 <= overlap {
return;
}
let v = self.velocity;
let old = old_delta_y(v.y);
self.velocity = if old < -0.13 {
let factor = -0.05 / old;
Vec3::new(v.x * factor, new_delta_y(-0.05), v.z * factor)
} else {
Vec3::new(v.x, new_delta_y(-0.05), v.z)
};
}
/// `ItemEntity.isMergable` at `age`: a stack below its size whose pickup
/// delay isn't infinite, before its lifetime ends.
fn mergeable(&self, age: u32, max_stack: u32) -> bool {
@@ -980,6 +1028,7 @@ mod experience_tests {
#[cfg(test)]
mod fire_environment_tests {
use super::*;
use horizon_world::loot::PackOverlay;
#[test]
fn ordinary_items_burn_but_native_netherite_resists_lava() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
@@ -991,7 +1040,8 @@ mod fire_environment_tests {
let assets = crate::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let items = crate::items::Items::load(&root, assets.registry, &facts).unwrap();
let items =
crate::items::Items::load(&root, assets.registry, &facts, &PackOverlay::NONE).unwrap();
let mut world = World::test_scene(&assets, &render, 3).unwrap();
let at = Vec3::new(20.5, 4.0, 20.5);
world
@@ -1270,3 +1320,89 @@ mod merge_tests {
assert_eq!(items[1].stack.count, 2);
}
}
#[cfg(test)]
mod honey_slide_tests {
use super::*;
fn falling_item(x: f64, y: f64, z: f64, vx: f64, vy: f64, vz: f64) -> ItemEntity {
let at = Vec3::new(x, y, z);
ItemEntity {
id: 1,
thrower: None,
target: None,
stack: ItemStack {
item: std::sync::Arc::from("minecraft:stone"),
count: 1,
damage: 0,
extra: None,
},
health: 5,
fire_ticks: 0,
position: at,
previous: at,
velocity: Vec3::new(vx, vy, vz),
on_ground: false,
age: 10,
pickup_delay: 0,
bob_offset: 0.0,
ticks: 10,
}
}
fn old_delta_y(dy: f64) -> f64 {
dy / f64::from(0.98_f32) + 0.08
}
fn new_delta_y(dy: f64) -> f64 {
(dy - 0.08) * f64::from(0.98_f32)
}
#[test]
fn a_falling_item_beside_honey_slides_down() {
// Item width 0.25: overlap 0.5625. Honey at (0, 10, 0); centre 0.5.
// x = -0.0625 is against the west face (inset 1/16).
let mut item = falling_item(-0.0625, 10.4, 0.5, 0.12, -0.20, 0.04);
assert!(old_delta_y(-0.20) < -0.08);
assert!(old_delta_y(-0.20) >= -0.13);
item.honey_block_inside([0, 10, 0]);
assert_eq!(item.velocity.x, 0.12);
assert_eq!(item.velocity.z, 0.04);
assert_eq!(item.velocity.y, new_delta_y(-0.05));
}
#[test]
fn a_fast_fall_scales_the_horizontal_speed() {
let mut item = falling_item(-0.0625, 10.4, 0.5, 0.2, -0.30, -0.1);
let old = old_delta_y(-0.30);
assert!(old < -0.13);
item.honey_block_inside([0, 10, 0]);
let factor = -0.05 / old;
assert_eq!(item.velocity.x, 0.2 * factor);
assert_eq!(item.velocity.z, -0.1 * factor);
assert_eq!(item.velocity.y, new_delta_y(-0.05));
}
#[test]
fn an_item_on_the_ground_or_on_top_does_not_slide() {
let mut grounded = falling_item(-0.0625, 10.4, 0.5, 0.0, -0.20, 0.0);
grounded.on_ground = true;
grounded.honey_block_inside([0, 10, 0]);
assert_eq!(grounded.velocity.y, -0.20);
let mut on_top = falling_item(0.5, 10.9375, 0.5, 0.0, -0.20, 0.0);
on_top.honey_block_inside([0, 10, 0]);
assert_eq!(on_top.velocity.y, -0.20, "standing on the honey");
}
#[test]
fn a_centred_or_slow_item_does_not_slide() {
let mut centred = falling_item(0.5, 10.4, 0.5, 0.0, -0.20, 0.0);
centred.honey_block_inside([0, 10, 0]);
assert_eq!(centred.velocity.y, -0.20, "inside, not on a side");
let mut slow = falling_item(-0.0625, 10.4, 0.5, 0.0, -0.04, 0.0);
assert!(old_delta_y(-0.04) >= -0.08);
slow.honey_block_inside([0, 10, 0]);
assert_eq!(slow.velocity.y, -0.04);
}
}
+3 -3
View File
@@ -83,10 +83,10 @@ impl FluidInteractionWorld for ItemFluidWorld<'_> {
Ok(self.world.chunks.contains_key(&(x, z)).then_some(()))
}
fn chunk_sections(&mut self, _: ()) -> Result<FluidSections<'_>, Error> {
static SECTIONS: [bool; crate::chunk::SECTIONS] = [true; crate::chunk::SECTIONS];
let layout = self.world.layout();
Ok(FluidSections {
minimum_section_y: crate::chunk::MIN_Y >> 4,
has_fluid: &SECTIONS,
minimum_section_y: layout.min_y() >> 4,
has_fluid: crate::chunk::every_section(layout),
})
}
fn bounding_box_min_y(&self) -> f64 {
+199 -30
View File
@@ -87,6 +87,11 @@ impl Base<'_> {
}
}
/// The mobs that save `Age` and `AgeLocked`.
fn saves_age(s: crate::mobs::Species) -> bool {
use crate::mobs::{Species, drops::can_be_baby, finalize::ageable};
s == Species::Tadpole || (ageable(s) && can_be_baby(s))
}
/// `LivingEntity` + `Mob` (+ `AgeableMob`, creeper and skeleton specifics).
/// The mobs whose class extends vanilla's `Animal` (and so save `InLove`):
/// not allays, villagers, golems, dolphins or fish.
@@ -409,19 +414,14 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
map.entry(key.into()).or_insert(value);
}
let id = m.kind.id.as_str();
// Only breedable animals have an age; a loaded creature that was saved without
// one (a frog) stays without.
let aged = raw.is_none_or(|r| r.get("Age").is_some());
if (m.kind.category == "creature" || m.species() == crate::mobs::Species::Hoglin) && aged {
// AgeableMob.addAdditionalSaveData, only if canBeABaby (not a frog or a
// parrot); a tadpole saves its own age.
let species = m.species();
if saves_age(species) {
map.insert("Age".into(), Tag::Int(m.extra.age));
map.insert("AgeLocked".into(), Tag::Byte(i8::from(m.extra.age_locked)));
map.insert("ForcedAge".into(), Tag::Int(m.extra.forced_age));
for (key, value) in [
("Age", Tag::Int(0)),
("ForcedAge", Tag::Int(0)),
("AgeLocked", Tag::Byte(0)),
] {
map.entry(key.into()).or_insert(value);
if species != crate::mobs::Species::Tadpole {
map.insert("ForcedAge".into(), Tag::Int(m.extra.forced_age));
}
}
// Animal.addAdditionalSaveData: the love ticks left, whatever the save had.
@@ -447,17 +447,21 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
);
}
match id {
// Fox.addAdditionalSaveData; Horizon models only the type.
// Fox.addAdditionalSaveData; Horizon models only the type, the
// flags and no trusted players yet.
"minecraft:fox" => {
let kind = if m.extra.variant == 1 { "snow" } else { "red" };
map.insert("Type".into(), string(kind));
for (key, value) in [
("Trusted", Tag::List(Vec::new())),
("Sleeping", Tag::Byte(0)),
("Sitting", Tag::Byte(0)),
("Crouching", Tag::Byte(0)),
map.entry("Trusted".into()).or_insert(Tag::List(Vec::new()));
for (key, bit) in [
("Sleeping", crate::mobs::fox::SLEEPING),
("Sitting", crate::mobs::fox::SITTING),
("Crouching", crate::mobs::fox::CROUCHING),
] {
map.entry(key.into()).or_insert(value);
map.insert(
key.into(),
Tag::Byte(i8::from(crate::mobs::fox::flag(m, bit))),
);
}
}
// MushroomCow.addAdditionalSaveData: the Variant codec's name (stew
@@ -660,6 +664,8 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
}
"minecraft:villager" => {
map.insert("VillagerData".into(), villager_data(m));
// A loaded villager with VillagerData is finalized.
map.insert("VillagerDataFinalized".into(), Tag::Byte(1));
let t = m.extra.trade.as_deref().cloned().unwrap_or_default();
map.insert("Xp".into(), Tag::Int(t.xp));
map.insert("LastRestock".into(), Tag::Long(t.last_restock));
@@ -713,25 +719,48 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
]));
}
map.insert("Inventory".into(), Tag::List(inv));
if let Some(st) = &m.extra.allay {
map.insert("listener".into(), st.vibration.to_nbt());
}
map.insert(
"DuplicationCooldown".into(),
Tag::Long(i64::from(m.extra.egg_time)),
);
let mut memories: Vec<(&str, Tag)> = Vec::new();
if m.extra.tame {
map.insert(
"Brain".into(),
memories.push((
"minecraft:liked_player",
Tag::compound([(
"memories",
Tag::compound([(
"minecraft:liked_player",
Tag::compound([(
"value",
Tag::IntArray(m.extra.owner.unwrap_or([0, 0, 0, 1]).to_vec()),
)]),
)]),
"value",
Tag::IntArray(m.extra.owner.unwrap_or([0, 0, 0, 1]).to_vec()),
)]),
);
));
}
// GlobalPos.CODEC, and the cooldown while it runs.
if let Some(liked) = m.extra.allay.as_ref().and_then(|st| st.noteblock) {
memories.push((
"minecraft:liked_noteblock",
Tag::compound([(
"value",
Tag::compound([
("dimension", string(liked.dimension)),
("pos", Tag::IntArray(liked.pos.to_vec())),
]),
)]),
));
}
if m.extra.ints[2] > 0 {
memories.push((
"minecraft:liked_noteblock_cooldown_ticks",
Tag::compound([("value", Tag::Int(m.extra.ints[2]))]),
));
}
// Always written, so a forgotten note block does not come back
// from the compound the allay was loaded with.
map.insert(
"Brain".into(),
Tag::compound([("memories", Tag::compound(memories))]),
);
}
"minecraft:camel" | "minecraft:camel_husk" => {
// LastPoseTick: the pose change time, negative while sitting. The
@@ -865,9 +894,64 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
) {
map.insert("IsBaby".into(), Tag::Byte(i8::from(m.is_baby())));
}
// NeutralMob.addPersistentAngerSaveData; the bee saves its own.
if matches!(
id,
"minecraft:iron_golem"
| "minecraft:wolf"
| "minecraft:polar_bear"
| "minecraft:zombified_piglin"
| "minecraft:enderman"
) {
map.entry("anger_end_time".into()).or_insert(Tag::Long(-1));
}
if let Some(a) = m.extra.attributes.as_deref() {
put_attributes(map, m, a);
}
// The attribute their load makes, which AttributeMap.pack then saves:
// Zombie and Piglin.setBaby (even for an adult) the speed, Rabbit.setVariant
// the attack damage.
use crate::mobs::finalize::Attr;
let made = if crate::mobs::doors::is_zombie(species) || species == crate::mobs::Species::Piglin
{
Some(Attr::MovementSpeed)
} else if species == crate::mobs::Species::Rabbit {
Some(Attr::AttackDamage)
} else {
None
};
if let Some(attr) = made {
let base = crate::mobs::finalize::base_value(m, attr);
let mut list = match map.get(&"attributes".into()) {
Some(Tag::List(l)) => l.clone(),
_ => Vec::new(),
};
let attr_id = format!("minecraft:{}", attr.name());
if !list
.iter()
.any(|t| text(t.get("id")).as_deref() == Some(attr_id.as_str()))
{
list.push(Tag::compound([
("id", string(&attr_id)),
("base", Tag::Double(base.to_bits())),
]));
map.insert("attributes".into(), Tag::List(list));
}
}
// Mob.addAdditionalSaveData: the chances that are not the default.
if let Some(chances) = &m.extra.drop_chances {
let set: Vec<(&str, Tag)> = crate::mobs::drops::SLOTS
.iter()
.zip(chances.iter())
.filter(|(_, c)| **c != crate::mobs::drops::DEFAULT_DROP_CHANCE)
.map(|(slot, c)| (*slot, Tag::Float(c.to_bits())))
.collect();
if set.is_empty() {
map.remove(&"drop_chances".into());
} else {
map.insert("drop_chances".into(), Tag::compound(set));
}
}
crate::mobs::raider::write_nbt(m, map);
crate::mobs::riders::write_nbt(m, map);
// LivingEntity equipment: what the mob holds now.
@@ -1191,7 +1275,16 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.custom_name = custom_name(tag);
m.custom_name_text = tag.get("CustomName").map(name_text);
m.extra.left_handed = flag(tag, "LeftHanded");
if m.kind.category == "creature" || m.species() == crate::mobs::Species::Hoglin {
if let Some(Tag::Compound(c)) = tag.get("drop_chances") {
let mut chances = [crate::mobs::drops::DEFAULT_DROP_CHANCE; 8];
for (chance, slot) in chances.iter_mut().zip(crate::mobs::drops::SLOTS) {
if let Some(Tag::Float(v)) = c.get(&slot.into()) {
*chance = f32::from_bits(*v);
}
}
m.extra.drop_chances = Some(Box::new(chances));
}
if saves_age(m.species()) {
m.extra.age = number(tag.get("Age")).unwrap_or(0) as i32;
m.extra.forced_age = number(tag.get("ForcedAge")).unwrap_or(0) as i32;
m.extra.age_locked = flag(tag, "AgeLocked");
@@ -1322,6 +1415,16 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
match m.species() {
crate::mobs::Species::Fox => {
m.extra.variant = u8::from(text(tag.get("Type")).as_deref() == Some("snow"));
// Fox.readAdditionalSaveData: the sleeping, sitting and crouching flags.
for (key, bit) in [
("Sleeping", crate::mobs::fox::SLEEPING),
("Sitting", crate::mobs::fox::SITTING),
("Crouching", crate::mobs::fox::CROUCHING),
] {
if flag(tag, key) {
m.extra.ints[0] |= bit;
}
}
}
// MushroomCow.readAdditionalSaveData: a missing or unknown name is
// Variant.DEFAULT, red.
@@ -1402,6 +1505,9 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.ints[1] = number(first.get("count")).unwrap_or(1) as i32;
}
m.extra.egg_time = number(tag.get("DuplicationCooldown")).unwrap_or(0) as i32;
if let (Some(st), Some(listener)) = (m.extra.allay.as_mut(), tag.get("listener")) {
st.vibration = crate::sculk::Data::from_nbt(listener);
}
let liked = tag
.get("Brain")
.and_then(|b| b.get("memories"))
@@ -1410,6 +1516,26 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.owner = liked
.and_then(|l| l.get("value"))
.and_then(|v| crate::save::uuid_from_tag(Some(v)));
let memory = |key: &str| {
tag.get("Brain")
.and_then(|b| b.get("memories"))
.and_then(|b| b.get(key))
.and_then(|l| l.get("value"))
};
m.extra.ints[2] =
number(memory("minecraft:liked_noteblock_cooldown_ticks")).unwrap_or(0) as i32;
if let Some(st) = m.extra.allay.as_mut()
&& let Some(noteblock) = memory("minecraft:liked_noteblock")
&& let Some(Tag::IntArray(p)) = noteblock.get("pos")
&& p.len() == 3
{
st.noteblock = Some(crate::mobs::allay::GlobalPos {
dimension: crate::mobs::allay::dimension_id(
&text(noteblock.get("dimension")).unwrap_or_default(),
),
pos: [p[0], p[1], p[2]],
});
}
}
if crate::mobs::camel::is_camel(m.species()) {
// The tick is a game time: `camel::finish_load` puts it on the mob's
@@ -1860,6 +1986,8 @@ pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projecti
power: number(tag.get("ExplosionPower")).unwrap_or(1) as i32,
},
"minecraft:small_fireball" => ProjectileKind::SmallFireball,
"minecraft:wind_charge" => ProjectileKind::WindCharge,
"minecraft:breeze_wind_charge" => ProjectileKind::BreezeWindCharge,
_ => return None,
};
let owner = uuid_from_tag(tag.get("Owner"));
@@ -3170,6 +3298,33 @@ mod sheep_nbt_tests {
}
}
#[test]
fn a_baby_villager_keeps_its_age() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let types: HashMap<String, Arc<MobType>> = data
.types
.iter()
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
.collect();
let saved = Tag::compound([
("id", string("minecraft:villager")),
("Pos", doubles(&[0.5, 64.0, 0.5])),
("Age", Tag::Int(-21359)),
]);
let m = mob_from_nbt(&saved, &types, 1).unwrap();
assert_eq!(m.extra.age, -21359);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
assert_eq!(number(tag.get("Age")), Some(-21359));
assert_eq!(number(tag.get("AgeLocked")), Some(0));
assert_eq!(number(tag.get("VillagerDataFinalized")), Some(1));
}
#[test]
fn foxes_mooshrooms_rabbits_and_goats_keep_their_kind() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
@@ -3266,6 +3421,20 @@ mod fireball_nbt_tests {
(p.kind, p.acceleration),
(ProjectileKind::LargeFireball { power: 1 }, 0.1)
);
// `/summon` and a saved world give back both wind charges.
for (id, kind) in [
("minecraft:wind_charge", ProjectileKind::WindCharge),
(
"minecraft:breeze_wind_charge",
ProjectileKind::BreezeWindCharge,
),
] {
let tag = Tag::compound([
("id", Tag::String(id.into())),
("Pos", doubles(&[0.0, 0.0, 0.0])),
]);
assert_eq!(fireball_from_nbt(&tag, None).unwrap().kind, kind);
}
}
}
+33
View File
@@ -28,6 +28,12 @@ pub enum ItemPose {
Bow {
ticks_used: f32,
},
/// A brush in use (`ItemUseAnimation.BRUSH`): the ticks left of the use
/// and the partial tick.
Brush {
remaining: f32,
partial: f32,
},
/// A spear being charged (`ItemUseAnimation.SPEAR`): the time held and
/// the ticks since its last hit's feedback.
Spear {
@@ -78,6 +84,9 @@ pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) -
if matches!(pose, ItemPose::Using) {
return m;
}
if let ItemPose::Brush { remaining, partial } = pose {
return m * brush_transform(left, remaining, partial);
}
// The spear's own place in the hand, without the equip height.
if let ItemPose::Spear {
kinetic,
@@ -177,6 +186,30 @@ impl MapHold {
}
}
/// `FirstPersonHandsAndItemsRenderer.applyBrushTransform`: the brush in use,
/// swept back and forth by the ticks left of its use (`remaining`).
fn brush_transform(left: bool, remaining: f32, partial: f32) -> glam::Mat4 {
use glam::{Mat4, Vec3};
use horizon_core::geometry::trig::Mth;
let ticks = remaining % 10.0;
let delta = ticks - partial + 1.0;
let scaled = 1.0 - delta / 10.0;
let swipe = -15.0 + 75.0 * (scaled * 2.0 * std::f32::consts::PI).mcos();
let sweep = Mat4::from_rotation_x(swipe.to_radians());
if left {
Mat4::from_translation(Vec3::new(0.1, 0.83, 0.35))
* Mat4::from_rotation_x((-80.0f32).to_radians())
* Mat4::from_rotation_y((-90.0f32).to_radians())
* sweep
* Mat4::from_translation(Vec3::new(-0.3, 0.22, 0.35))
} else {
Mat4::from_translation(Vec3::new(-0.25, 0.22, 0.35))
* Mat4::from_rotation_x((-80.0f32).to_radians())
* Mat4::from_rotation_y(90.0f32.to_radians())
* sweep
}
}
/// `renderPlayerArm` up to `renderPlayerHand`: the pose of the bare `left`
/// or right arm.
pub fn player_arm_pose(left: bool, inverse_height: f32, attack: f32) -> glam::Mat4 {
+210 -15
View File
@@ -1,21 +1,80 @@
//! FlatLevelSource: superflat worlds (`minecraft:flat` generator).
use crate::{chunk::Chunk, streaming::ChunkGenerator};
use horizon_world::{nbt::Tag, registry::BlockRegistry};
use crate::{
chunk::{Chunk, LevelShape},
streaming::ChunkGenerator,
};
use horizon_world::{
nbt::Tag,
registry::{BlockRegistry, BlockStateId},
worldgen::feature::{BasicFeature, FeatureBlocks, FeatureWorld},
};
/// The layers of a flat generator, bottom up from the world's minimum Y.
pub struct FlatTerrain {
/// The Overworld's shape: the layers start at its minimum Y and stop at
/// its top.
shape: LevelShape,
layers: Vec<u32>,
biome: u16,
surface: Option<crate::lod::Surface>,
/// The blocks of `minecraft:void_start_platform`, when the adjusted
/// generation settings place it (the Void preset).
void_platform: Option<FeatureBlocks>,
}
/// `GenerationStep.Decoration` ordinals `adjustGenerationSettings` leaves out.
const LAKES: usize = 1;
const UNDERGROUND_STRUCTURES: usize = 3;
const SURFACE_STRUCTURES: usize = 4;
/// FlatLevelGeneratorSettings.adjustGenerationSettings for the world's own
/// biome: its placed features, by step, when `features` is on and the world
/// isn't only air (`voidGen`) or its biome is the_void. The structure steps
/// are left out, and the lakes step when `lakes` puts the lava lakes there.
fn decoration_features(
biome_features: &[Vec<String>],
features: bool,
lakes: bool,
void_gen: bool,
the_void: bool,
) -> impl Iterator<Item = &str> {
let decorate = (!void_gen || the_void) && features;
biome_features
.iter()
.enumerate()
.filter(move |&(step, _)| {
decorate
&& step != UNDERGROUND_STRUCTURES
&& step != SURFACE_STRUCTURES
&& !(lakes && step == LAKES)
})
.flat_map(|(_, step)| step.iter().map(String::as_str))
}
/// The proto chunk as the world a feature writes in.
struct ChunkWorld<'a>(&'a mut Chunk);
impl FeatureWorld for ChunkWorld<'_> {
type Error = std::convert::Infallible;
fn block(&mut self, [x, y, z]: [i32; 3]) -> Result<BlockStateId, Self::Error> {
Ok(self.0.block(x, y, z))
}
fn set_block(
&mut self,
[x, y, z]: [i32; 3],
state: BlockStateId,
_: i32,
) -> Result<bool, Self::Error> {
self.0.set(x, y, z, state.0);
Ok(true)
}
}
impl FlatTerrain {
/// The overworld generator of `world_gen_settings` (`data.dimensions`), if it
/// is flat. Its `features` and `lakes` decorate the world; a world without
/// them (the default) gets only its layers, as FlatLevelSource places them.
/// is flat. Its layers, as FlatLevelSource places them, and of the features
/// its settings decorate the world with, `void_start_platform`; the other
/// features and the lakes are not placed yet.
pub fn from_settings(
settings: &Tag,
registry: &BlockRegistry,
biomes: &crate::biome::Biomes,
shape: LevelShape,
) -> Option<Self> {
let generator = settings
.get("dimensions")?
@@ -36,14 +95,32 @@ impl FlatTerrain {
}
}
let biome = biomes.id(&biome);
// FlatLevelGeneratorSettings.updateLayers: every layer is Blocks.AIR.
let void_gen = layers.iter().all(|&id| {
registry
.state(BlockStateId(id))
.is_some_and(|s| s.name == "minecraft:air")
});
let flag = |key| crate::save::number(s.get(key)).is_some_and(|v| v != 0);
let info = biomes.get(biome);
let void_platform = if decoration_features(
&info.features,
flag("features"),
flag("lakes"),
void_gen,
info.name == "minecraft:the_void",
)
.any(|f| f == "minecraft:void_start_platform")
{
FeatureBlocks::from_registry(registry)
.inspect_err(|e| log::warn!("Horizon: no void_start_platform: {e}"))
.ok()
} else {
None
};
let mut water = None;
let mut surface = None;
for (i, &id) in layers
.iter()
.take(crate::chunk::HEIGHT as usize)
.enumerate()
.rev()
{
for (i, &id) in layers.iter().take(shape.height as usize).enumerate().rev() {
let state = horizon_world::registry::BlockStateId(id);
let name = registry.state(state)?.name.as_str();
if matches!(
@@ -52,7 +129,7 @@ impl FlatTerrain {
) {
continue;
}
let height = crate::chunk::MIN_Y + i as i32 + 1;
let height = shape.min_y + i as i32 + 1;
if matches!(name, "minecraft:water" | "minecraft:lava") {
water.get_or_insert((height, state));
} else {
@@ -66,14 +143,16 @@ impl FlatTerrain {
}
}
Some(Self {
shape,
layers,
biome,
surface,
void_platform,
})
}
/// FlatLevelSource.getSpawnHeight: the top of the layers.
pub fn spawn_height(&self) -> i32 {
crate::chunk::MIN_Y + self.layers.len().min(crate::chunk::HEIGHT as usize) as i32
self.shape.min_y + self.layers.len().min(self.shape.height as usize) as i32
}
}
/// DimensionType.Y_SIZE: the most layers FlatLevelGeneratorSettings takes.
@@ -190,23 +269,39 @@ impl ChunkGenerator for FlatTerrain {
self.surface
}
fn proto(&self, _: &mut (), cx: i32, cz: i32) -> crate::streaming::Proto {
let mut chunk = Chunk::empty(cx, cz);
let mut chunk = Chunk::with_layout(cx, cz, self.shape.column());
for s in &mut chunk.sections {
std::sync::Arc::make_mut(s).biomes = horizon_core::paletted::PalettedContainer::new(
crate::chunk::BIOME_STRATEGY,
u32::from(self.biome),
);
}
for (i, &block) in self.layers.iter().enumerate().take(384) {
let height = self.shape.height as usize;
for (i, &block) in self.layers.iter().enumerate().take(height) {
if block == 0 {
continue;
}
for z in 0..16 {
for x in 0..16 {
chunk.set(x, -64 + i as i32, z, block);
chunk.set(x, self.shape.min_y + i as i32, z, block);
}
}
}
// ChunkGenerator.applyBiomeDecoration at the chunk's section origin.
// VoidStartPlatformFeature writes only inside its own chunk and draws
// no random numbers, so placing it here gives the FEATURES result.
if let Some(blocks) = self.void_platform {
let origin = [cx * 16, self.shape.min_y, cz * 16];
let mut random = horizon_core::random::LegacyRandom::new(0);
if let Err(e) = BasicFeature::VoidStartPlatform.place(
&mut ChunkWorld(&mut chunk),
&mut random,
origin,
blocks,
) {
log::warn!("Horizon: placing void_start_platform failed: {e}");
}
}
for s in &mut chunk.sections {
std::sync::Arc::make_mut(s).compact();
}
@@ -233,6 +328,106 @@ mod tests {
)
}
/// A world_gen_settings with a flat overworld of `layers` (block, height).
fn flat_world(biome: &str, features: bool, layers: &[(&str, i32)]) -> Tag {
let layers = layers
.iter()
.map(|&(block, height)| {
Tag::compound([
("block", Tag::String(block.into())),
("height", Tag::Int(height)),
])
})
.collect();
let settings = Tag::compound([
("biome", Tag::String(biome.into())),
("features", Tag::Byte(i8::from(features))),
("lakes", Tag::Byte(0)),
("layers", Tag::List(layers)),
]);
let generator = Tag::compound([
("type", Tag::String("minecraft:flat".into())),
("settings", settings),
]);
Tag::compound([(
"dimensions",
Tag::compound([(
"minecraft:overworld",
Tag::compound([("generator", generator)]),
)]),
)])
}
#[test]
fn the_void_preset_places_the_start_platform() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let registry = assets.registry;
let biomes = crate::biome::Biomes::load(&root).unwrap();
let terrain = |biome, features, layers: &[(&str, i32)]| {
FlatTerrain::from_settings(
&flat_world(biome, features, layers),
registry,
&biomes,
LevelShape::OVERWORLD,
)
.unwrap()
};
let block = |terrain: &FlatTerrain, [x, y, z]: [i32; 3]| {
let chunk = terrain.finish(terrain.proto(&mut (), x >> 4, z >> 4));
registry.state(chunk.block(x, y, z)).unwrap().name.clone()
};
// The Void preset (flat_level_generator_preset/the_void): the_void's
// only feature, VoidStartPlatformFeature, at the minimum Y + 3: a
// 33×33 stone square around 8, 8 with cobblestone at its centre, in
// the chunks within one of chunk 0, 0.
let void = terrain("minecraft:the_void", true, &[("minecraft:air", 1)]);
for (pos, expected) in [
([8, -61, 8], "minecraft:cobblestone"),
([0, -61, 0], "minecraft:stone"),
([-8, -61, -8], "minecraft:stone"),
([24, -61, 24], "minecraft:stone"),
([24, -61, -8], "minecraft:stone"),
([-9, -61, 8], "minecraft:air"),
([25, -61, 8], "minecraft:air"),
([8, -60, 8], "minecraft:air"),
([8, -62, 8], "minecraft:air"),
([40, -61, 8], "minecraft:air"),
] {
assert_eq!(block(&void, pos), expected, "{pos:?}");
}
// The decoration is off without `features`.
let plain = terrain("minecraft:the_void", false, &[("minecraft:air", 1)]);
assert_eq!(block(&plain, [8, -61, 8]), "minecraft:air");
// the_void over solid layers still gets its platform.
let floor = terrain("minecraft:the_void", true, &[("minecraft:bedrock", 1)]);
assert_eq!(block(&floor, [8, -64, 8]), "minecraft:bedrock");
assert_eq!(block(&floor, [8, -61, 8]), "minecraft:cobblestone");
}
#[test]
fn adjusted_generation_settings_keep_the_biome_features_outside_structures() {
let steps: Vec<Vec<String>> = (0..11).map(|step| vec![format!("f{step}")]).collect();
let kept = |features, lakes, void_gen, the_void| {
decoration_features(&steps, features, lakes, void_gen, the_void)
.map(str::to_owned)
.collect::<Vec<_>>()
};
let all_but_structures = ["f0", "f1", "f2", "f5", "f6", "f7", "f8", "f9", "f10"];
assert_eq!(kept(true, false, false, false), all_but_structures);
// The lava lakes take the LAKES step's place.
assert_eq!(kept(true, true, false, false)[1..], all_but_structures[2..]);
// An all-air world is decorated only in the_void.
assert!(kept(true, false, true, false).is_empty());
assert_eq!(kept(true, false, true, true), all_but_structures);
assert!(kept(false, false, false, true).is_empty());
}
#[test]
fn generator_settings_read_as_flat_level_generator_settings() {
// server.properties' default: the preset's own layers, and the codec's error.
File diff suppressed because it is too large. Load diff
+24 -7
View File
@@ -115,10 +115,18 @@ pub(super) struct AdvState {
pub(super) announced: Vec<Text>,
/// The tree being read on another thread since the world opened
/// (`prefetch_advancement_tree`), taken by the first `advancement_tree`.
pub(super) pending_tree: Option<std::thread::JoinHandle<Tree>>,
pub(super) pending_tree: Option<std::thread::JoinHandle<Arc<Tree>>>,
/// `inventory_changed`'s criteria by item, built for `tree`.
inventory_index: Option<(Arc<Tree>, Arc<adv::InventoryIndex>)>,
}
impl AdvState {
/// Drop the prefetched tree and the index built for the old one (a
/// `/reload` rebuilt the tree).
pub(super) fn reset_tree(&mut self) {
self.pending_tree = None;
self.inventory_index = None;
}
}
/// The game's world for predicates.
struct View<'a, 'g>(&'a Game<'g>);
impl adv::World for View<'_, '_> {
@@ -261,9 +269,12 @@ impl Game<'_> {
if self.adv.tree.list.is_empty() {
let tree = match self.adv.pending_tree.take().map(|h| h.join()) {
Some(Ok(tree)) => tree,
_ => Tree::load(&self.options.resources.join("data")),
_ => {
let overlay = self.pack_files();
Tree::shared(&self.options.resources.join("data"), &overlay)
}
};
self.adv.tree = Arc::new(tree);
self.adv.tree = tree;
// With the tree, while the world loads: built on the first
// inventory change it cost that tick ~10 ms.
self.inventory_index();
@@ -278,7 +289,7 @@ impl Game<'_> {
{
return index.clone();
}
let items = self.res.items();
let items = self.items();
let index = Arc::new(adv::InventoryIndex::build(&tree, |tag| {
items
.item_tags
@@ -297,9 +308,15 @@ impl Game<'_> {
return;
}
let data = self.options.resources.join("data");
// The world's packs' advancements go over the game's own.
let files = super::functions::content_overlay(
&self.world_root,
&self.options.resources,
self.cmds.vanilla_disabled,
);
self.adv.pending_tree = std::thread::Builder::new()
.name("horizon-advancements".into())
.spawn(move || Tree::load(&data))
.spawn(move || Tree::shared(&data, &horizon_world::loot::PackOverlay { files: &files }))
.ok();
}
pub(super) fn load_advancements(&mut self) {
@@ -537,13 +554,13 @@ impl Game<'_> {
.rewards
.recipes
.iter()
.filter(|r| self.res.stations().recipe_index.contains(r))
.filter(|r| world_stations!(self).recipe_index.contains(r))
.cloned()
.collect();
let (_, unlocked) = self
.level
.recipe_book
.add_recipes(&self.res.stations().recipe_index, &rewards);
.add_recipes(&world_stations!(self).recipe_index, &rewards);
for table in &a.rewards.loot {
let items = self
.res
Loaded 100 of 521 files, more files were not shown because too many files have changed in this diff. Show more