diff --git a/.dockerignore b/.dockerignore
index 417a1867c..458330c93 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -2,7 +2,6 @@
target/
.local/
**/node_modules/
-website/
benchmarks/
packs/
mods/
diff --git a/.gitattributes b/.gitattributes
index 358aeae7a..f9e1922d7 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -2,5 +2,5 @@
# where Git for Windows' core.autocrlf would check them out with CRLF.
docs/launcher-commands.md text eol=lf
docs/horizon-ctl-commands.md text eol=lf
-website/src/tokens.css text eol=lf
+crates/horizon-theme/tokens.css text eol=lf
docs/api/*-reference.md text eol=lf
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index eff82fa99..39ea920da 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -73,6 +73,42 @@ jobs:
run: |
cargo +stable test --workspace --lib --bins --tests --features horizon-client/mods,horizon-mods/pack
cargo +stable test --workspace --doc --features horizon-client/mods,horizon-mods/pack
+ # The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
+ # changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
+ # alone and break it together (#2059 and the registry ratings did, 2026-10-26).
+ launcher:
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - name: Does this pull request change the launcher or a crate it builds?
+ if: github.event_name == 'pull_request'
+ id: launcher
+ env:
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ run: python3 tools/ci/rust_changes.py --launcher "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
+ - name: Install Rust and the headers GPUI links against
+ if: steps.launcher.outputs.required != 'false'
+ run: |
+ sudo apt-get update && sudo apt-get install -y --no-install-recommends libxkbcommon-x11-dev libwayland-dev \
+ libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev libudev-dev
+ rustup toolchain install stable --profile minimal --component rustfmt --component clippy
+ - uses: Swatinem/rust-cache@v2
+ if: steps.launcher.outputs.required != 'false'
+ with:
+ workspaces: launcher
+ shared-key: launcher
+ save-if: ${{ github.ref == 'refs/heads/main' }}
+ - name: Check the launcher's formatting, lint and tests
+ if: steps.launcher.outputs.required != 'false'
+ working-directory: launcher
+ run: |
+ cargo +stable fmt --check
+ cargo +stable clippy --all-targets --locked -- -D warnings
+ cargo +stable test --locked --lib
# Most pull requests leave the harnesses to parity-main, which runs them on main a batch of merges at a time
# (.github/workflows/parity-main.yml; releases wait for it). A pull request still runs them itself when it changes
# a harness, an oracle, a probe or the runner, or carries the run-parity label (run-parity.yml, when it is added).
diff --git a/.github/workflows/parity-main.yml b/.github/workflows/parity-main.yml
index c47005ab0..70fb33796 100644
--- a/.github/workflows/parity-main.yml
+++ b/.github/workflows/parity-main.yml
@@ -48,6 +48,9 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: echo "sha=$(python3 tools/parity_main.py base)" >> "$GITHUB_OUTPUT"
- name: The harnesses the merges since then can affect
+ id: harnesses
+ # A failure here is judged by the next step: the run passes when every failed harness passes again at HEAD.
+ continue-on-error: true
env:
PARITY_HOSTS: ''
BASE_SHA: ${{ steps.base.outputs.sha }}
@@ -55,8 +58,8 @@ jobs:
echo "Merges since ${BASE_SHA}:"
git log --first-parent --format=' %h %s' "$BASE_SHA..HEAD"
python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400
- - name: The merge that broke them
- if: failure()
+ - name: A flake, or the merge that broke them
+ if: steps.harnesses.outcome == 'failure'
env:
GH_TOKEN: ${{ github.token }}
PARITY_HOSTS: ''
@@ -65,7 +68,7 @@ jobs:
rm -rf .local/parity-main-logs && cp -R .local/parity-runner/logs .local/parity-main-logs 2>/dev/null || true
python3 tools/parity_main.py bisect "$BASE_SHA" --comment
- name: Logs of the failed harnesses
- if: failure()
+ if: steps.harnesses.outcome == 'failure'
uses: actions/upload-artifact@v4
with:
name: parity-main-logs
diff --git a/AGENTS.md b/AGENTS.md
index dcd4ca595..e1fa9e7fe 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -54,7 +54,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
Other top-level folders: `tools/` (harnesses and dev scripts), `tests/` (Python unit tests: `python3 -m
unittest discover -s tests`), `mods/horizon/src/test/java/dev/horizonlab/parity/` (Java oracles),
-`website/` (playhorizon.dev; its paths are relative to `website/`), `docs/`, `.github/workflows/`.
+`docs/`, `.github/workflows/`. The website (playhorizon.dev) is the private ludovic111/horizon-site: its build
+reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` and `crates/horizon-theme/tokens.css` from main.
## Build and test fast
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 21951c083..03f3bd97b 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -4,8 +4,8 @@ Thanks for helping. Horizon is Minecraft: Java Edition 26.3 rewritten in Rust, a
everything is that **it plays exactly like vanilla**. This page is the short version of how we work; questions are
welcome on [Discord](https://discord.gg/HcTeMZxe5E).
-This repository holds the game, the launcher and the website, plus the tools, tests and docs that build, check and
-release them. Anything else (videos, Discord and other community material) lives elsewhere.
+This repository holds the game and the launcher, plus the tools, tests and docs that build, check and release them.
+The website (playhorizon.dev) is built in a separate repository from this one's README, `docs/api/` and roadmap. Anything else (videos, Discord and other community material) lives elsewhere.
**Where to start: [playhorizon.dev/hub](https://playhorizon.dev/hub).** It shows what is being done now and next
([ROADMAP.md](ROADMAP.md) in the repository), the work board, good first tasks, and the forms to report a bug or
diff --git a/Cargo.lock b/Cargo.lock
index 0e93bba0e..bbbac4b2c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -216,6 +216,13 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+[[package]]
+name = "beacons"
+version = "0.1.0"
+dependencies = [
+ "horizon-mod-sdk",
+]
+
[[package]]
name = "bit-set"
version = "0.10.0"
@@ -1368,6 +1375,7 @@ dependencies = [
"horizon-core",
"horizon-dualsense",
"horizon-friends",
+ "horizon-hdr",
"horizon-mods",
"horizon-protocol",
"horizon-server-files",
@@ -1426,6 +1434,14 @@ dependencies = [
"spake2",
]
+[[package]]
+name = "horizon-hdr"
+version = "0.1.0"
+dependencies = [
+ "ash",
+ "wgpu",
+]
+
[[package]]
name = "horizon-mod-sdk"
version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index e4628f962..ac1a19865 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[workspace]
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
-members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask"]
+members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-hdr", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "crates/horizon-mod-sdk/examples/beacons", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask"]
resolver = "2"
[workspace.package]
diff --git a/ROADMAP.md b/ROADMAP.md
index a04eca2ce..9b82f4fac 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -134,7 +134,7 @@ Better tools for mod makers: a test runner that needs no game window, templates
`cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot ([#1816](https://github.com/ludovic111/horizon/issues/1816)) and, for server mods, scripted server events ([#1837](https://github.com/ludovic111/horizon/issues/1837)); the content template and the guides are merged ([#1859](https://github.com/ludovic111/horizon/issues/1859), [#1810](https://github.com/ludovic111/horizon/issues/1810), [docs/api/stability.md](docs/api/stability.md)). Left: templates for hud, server and machine mods, typed wrappers in the SDK, a frozen 0.1 compatibility mod that every runtime must load, a CI check that fails when a stable reference entry is removed without a new major version, and the SDK on crates.io once the API is stable.
-More: [website/docs/test-a-mod.md](website/docs/test-a-mod.md)
+More: [https://playhorizon.dev/docs/test-a-mod](https://playhorizon.dev/docs/test-a-mod)
@@ -160,6 +160,66 @@ Translatable components resolved through the selected language for items, contai
+#### Cooldowns drawn on items
+
+After you throw an ender pearl, its slot should show the cooldown sweeping away, as in vanilla.
+
+Technical
+
+Vanilla draws `ItemCooldowns`' percent as a white overlay over the slot (`GuiGraphicsExtractor.itemDecorations` and `itemCooldown`) in the hotbar and in inventories. Issue [#1763](https://github.com/ludovic111/horizon/issues/1763).
+
+More: [#1933](https://github.com/ludovic111/horizon/issues/1933)
+
+
+
+#### Chat settings and team prefixes
+
+The Hidden and Commands Only chat settings should hide chat, and team prefixes should appear before names in chat.
+
+Technical
+
+Vanilla checks `ChatVisiblity` before it delivers chat and system messages ([#1376](https://github.com/ludovic111/horizon/issues/1376)), and chat uses the team-formatted display name ([#1378](https://github.com/ludovic111/horizon/issues/1378)). Both issues have exact steps to reproduce.
+
+More: [#1932](https://github.com/ludovic111/horizon/issues/1932) · [#1841](https://github.com/ludovic111/horizon/issues/1841)
+
+
+
+#### A pointing hand over buttons
+
+The mouse pointer should turn into a pointing hand over menu buttons, as in vanilla 26.3.
+
+Technical
+
+26.3's `AbstractWidget` asks for a cursor from `CursorTypes` when it is hovered; winit's `CursorIcon::Pointer` gives the same shape. Issue [#1347](https://github.com/ludovic111/horizon/issues/1347).
+
+More: [#1897](https://github.com/ludovic111/horizon/issues/1897)
+
+
+
+#### Ctrl+Backspace and scroll bars in menus
+
+Ctrl+Backspace should delete a whole word in the creative search, and every list's scroll bar should be draggable.
+
+Technical
+
+`EditBox` deletes by word with Ctrl ([#1764](https://github.com/ludovic111/horizon/issues/1764)); the Game Rules and Statistics lists' scroll bars can't be dragged ([#1782](https://github.com/ludovic111/horizon/issues/1782), after [#978](https://github.com/ludovic111/horizon/issues/978)).
+
+More: [#1884](https://github.com/ludovic111/horizon/issues/1884) · [#1931](https://github.com/ludovic111/horizon/issues/1931)
+
+
+
+#### Chickens flap while they fall
+
+Chickens should flap their wings while they fall, not stand still in the air.
+
+Technical
+
+Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't on the ground, and the model's wings follow them. Issue [#1706](https://github.com/ludovic111/horizon/issues/1706).
+
+More: [#1892](https://github.com/ludovic111/horizon/issues/1892)
+
+
+
### Rendering
#### Shader packs, written again from scratch
@@ -250,7 +310,7 @@ Horizon should run well on small machines: integrated graphics, the Steam Deck,
Partly done: an opt-in 60 FPS cap with fewer workers on battery, Far Detail Reduced for integrated GPUs and animated textures on the GPU (both shipped). Left: measured targets and fixes per class of machine (memory use, worker counts, startup) on a Steam Deck, old Intel and Apple Silicon Macs and 8 GB computers, reported on the Benchmarks page.
-More: [docs/benchmark.md](docs/benchmark.md) · [website/docs/performance.md](website/docs/performance.md)
+More: [docs/benchmark.md](docs/benchmark.md) · [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/performance)
@@ -540,7 +600,9 @@ You can port a mod to Horizon with your own AI agent on your own computer. Horiz
Technical
-A player runs their own agent locally with `horizon-ctl` and the MCP servers ([docs/agent-control.md](docs/agent-control.md)), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide ([website/docs/agents.md](website/docs/agents.md), [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off.
+A player runs their own agent locally with `horizon-ctl` and the MCP servers ([docs/agent-control.md](docs/agent-control.md)), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide (playhorizon.dev/docs/agents, [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off.
+
+More: [https://playhorizon.dev/docs/agents](https://playhorizon.dev/docs/agents)
@@ -876,7 +938,9 @@ Run Horizon's built-in benchmark and share the report: every new computer makes
Technical
-`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark ` shows what would be sent and uploads it ([website/docs/performance.md](website/docs/performance.md)). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/ludovic111/horizon/issues/1711).
+`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark ` shows what would be sent and uploads it (playhorizon.dev/docs/performance). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/ludovic111/horizon/issues/1711).
+
+More: [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/performance)
@@ -902,56 +966,6 @@ Armour and tools in End city chests should come enchanted, as in vanilla.
-#### Cooldowns drawn on items
-
-After you throw an ender pearl, its slot should show the cooldown sweeping away, as in vanilla.
-
-Technical
-
-Vanilla draws `ItemCooldowns`' percent as a white overlay over the slot (`GuiGraphicsExtractor.itemDecorations` and `itemCooldown`) in the hotbar and in inventories. Issue [#1763](https://github.com/ludovic111/horizon/issues/1763).
-
-
-
-#### Chat settings and team prefixes
-
-The Hidden and Commands Only chat settings should hide chat, and team prefixes should appear before names in chat.
-
-Technical
-
-Vanilla checks `ChatVisiblity` before it delivers chat and system messages ([#1376](https://github.com/ludovic111/horizon/issues/1376)), and chat uses the team-formatted display name ([#1378](https://github.com/ludovic111/horizon/issues/1378)). Both issues have exact steps to reproduce.
-
-
-
-#### A pointing hand over buttons
-
-The mouse pointer should turn into a pointing hand over menu buttons, as in vanilla 26.3.
-
-Technical
-
-26.3's `AbstractWidget` asks for a cursor from `CursorTypes` when it is hovered; winit's `CursorIcon::Pointer` gives the same shape. Issue [#1347](https://github.com/ludovic111/horizon/issues/1347).
-
-
-
-#### Ctrl+Backspace and scroll bars in menus
-
-Ctrl+Backspace should delete a whole word in the creative search, and every list's scroll bar should be draggable.
-
-Technical
-
-`EditBox` deletes by word with Ctrl ([#1764](https://github.com/ludovic111/horizon/issues/1764)); the Game Rules and Statistics lists' scroll bars can't be dragged ([#1782](https://github.com/ludovic111/horizon/issues/1782), after [#978](https://github.com/ludovic111/horizon/issues/978)).
-
-
-
-#### Chickens flap while they fall
-
-Chickens should flap their wings while they fall, not stand still in the air.
-
-Technical
-
-Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't on the ground, and the model's wings follow them. Issue [#1706](https://github.com/ludovic111/horizon/issues/1706).
-
-
-
## Recently shipped
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/ludovic111/horizon/issues/1917) · [#1920](https://github.com/ludovic111/horizon/issues/1920) · [#1936](https://github.com/ludovic111/horizon/issues/1936)
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 63a1f7da3..e83ccd840 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -17,4 +17,5 @@ by The Pixelify Sans Project Authors, licensed under the SIL Open Font License 1
`crates/horizon-theme/icons/pixel/*.svg` are icons from
[Pixelarticons](https://github.com/halfmage/pixelarticons) by Gerrit Halfmann, under the MIT
-license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`).
+license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`). `star.svg` is Horizon's own, drawn on
+the same grid.
diff --git a/crates/cargo-horizon-mod/src/check.rs b/crates/cargo-horizon-mod/src/check.rs
index 33095a115..ae20f76e7 100644
--- a/crates/cargo-horizon-mod/src/check.rs
+++ b/crates/cargo-horizon-mod/src/check.rs
@@ -266,6 +266,11 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
format!("{} quiet command(s)", lines.len())
}
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
+ Output::SetBlocks {
+ dimension, blocks, ..
+ } => {
+ format!("set {} block(s) in {dimension}", blocks.len())
+ }
Output::OpenScreen { .. } => "opened a screen".to_string(),
Output::CloseScreen { .. } => "closed its screen".to_string(),
Output::Shake { strength, seconds } => {
diff --git a/crates/horizon-assets/src/item_model.rs b/crates/horizon-assets/src/item_model.rs
index 5999805eb..d567bc6ac 100644
--- a/crates/horizon-assets/src/item_model.rs
+++ b/crates/horizon-assets/src/item_model.rs
@@ -1031,19 +1031,22 @@ pub struct ItemModels {
}
impl ItemModels {
- pub fn components(&self, stack: &StackView) -> Components {
- let mut out = self
- .prototypes
- .get(&stack.item)
- .cloned()
- .unwrap_or_default();
+ /// The stack's components: its item's defaults with the stack's patch on top. A stack
+ /// with no patch (most stacks a screen draws) borrows the defaults instead of copying them.
+ pub fn components(&self, stack: &StackView) -> std::borrow::Cow<'_, Components> {
+ static NONE: Components = BTreeMap::new();
+ let prototype = self.prototypes.get(&stack.item).unwrap_or(&NONE);
+ if stack.patch.is_empty() {
+ return std::borrow::Cow::Borrowed(prototype);
+ }
+ let mut out = prototype.clone();
for (key, value) in &stack.patch {
match value {
Some(v) => out.insert(key.clone(), v.clone()),
None => out.remove(key),
};
}
- out
+ std::borrow::Cow::Owned(out)
}
/// `ItemModelResolver.updateForTopItem`.
pub fn resolve(&self, stack: &StackView, ctx: &Context, env: &dyn ItemEnv) -> ResolvedItem {
diff --git a/crates/horizon-client/Cargo.toml b/crates/horizon-client/Cargo.toml
index d7c6d812b..f1011c534 100644
--- a/crates/horizon-client/Cargo.toml
+++ b/crates/horizon-client/Cargo.toml
@@ -12,6 +12,7 @@ horizon-assets = { path = "../horizon-assets" }
horizon-core = { path = "../horizon-core" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-threads = { path = "../horizon-threads" }
+horizon-hdr = { path = "../horizon-hdr" }
horizon-world = { path = "../horizon-world" }
horizon-protocol = { path = "../horizon-protocol" }
# server.properties, eula.txt and the user lists, shared with horizon-server.
diff --git a/crates/horizon-client/examples/probe/creative_mining_probe.rs b/crates/horizon-client/examples/probe/creative_mining_probe.rs
new file mode 100644
index 000000000..897e4bf81
--- /dev/null
+++ b/crates/horizon-client/examples/probe/creative_mining_probe.rs
@@ -0,0 +1,48 @@
+use horizon_client::{
+ Result,
+ item_data::StackData,
+ items::{ItemStack, Items},
+};
+use horizon_world::nbt::Tag;
+use serde_json::Value;
+use std::{
+ fs::File,
+ io::{BufRead, BufReader, BufWriter, Write},
+ path::Path,
+};
+
+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 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?)?;
+ let mut stack = ItemStack::new(
+ row["item"].as_str().unwrap(),
+ row["count"].as_u64().unwrap() as u32,
+ );
+ match row["tool"].as_str().unwrap() {
+ "default" => {}
+ mode => {
+ let mut data = StackData::default();
+ let tool = if mode == "implicit" {
+ Tag::compound([] as [(&str, Tag); 0])
+ } else {
+ Tag::compound([(
+ "can_destroy_blocks_in_creative",
+ Tag::Byte(i8::from(mode == "allow")),
+ )])
+ };
+ data.set_other("minecraft:tool", Some(tool));
+ stack.extra = Some(Box::new(data));
+ }
+ }
+ writeln!(
+ out,
+ "{}",
+ items.can_destroy_block(Some(&stack), row["creative"].as_bool().unwrap())
+ )?;
+ }
+ Ok(())
+}
diff --git a/crates/horizon-client/examples/probe/cushion_placement_probe.rs b/crates/horizon-client/examples/probe/cushion_placement_probe.rs
new file mode 100644
index 000000000..45fbdbd72
--- /dev/null
+++ b/crates/horizon-client/examples/probe/cushion_placement_probe.rs
@@ -0,0 +1,89 @@
+use horizon_client::{
+ Result,
+ cushion::{self, World},
+};
+use horizon_core::geometry::{Aabb, ShapeOp, Vec3, VoxelShape};
+use serde_json::Value;
+use std::{
+ collections::HashMap,
+ fs::File,
+ io::{BufRead, BufReader, BufWriter, Write},
+};
+
+struct Cell {
+ outline: VoxelShape,
+ collision: VoxelShape,
+ suffocating: bool,
+}
+struct Fixture(HashMap<[i32; 3], Cell>);
+impl World for Fixture {
+ fn outline_bounds(&self, p: [i32; 3]) -> Option {
+ self.0
+ .get(&p)?
+ .outline
+ .bounds()
+ .map(|b| b.move_by(Vec3::new(p[0] as f64, p[1] as f64, p[2] as f64)))
+ }
+ fn collision_shapes(&self, _: Aabb) -> Vec {
+ self.0
+ .iter()
+ .map(|(p, c)| {
+ c.collision
+ .moved(Vec3::new(p[0] as f64, p[1] as f64, p[2] as f64))
+ })
+ .collect()
+ }
+ fn suffocating(&self, p: [i32; 3]) -> bool {
+ self.0.get(&p).is_some_and(|c| c.suffocating)
+ }
+}
+fn shape(value: &Value) -> Result {
+ let mut shape = VoxelShape::empty();
+ for b in value.as_array().unwrap() {
+ let f = |i: usize| b[i].as_f64().unwrap();
+ shape = shape.join(
+ &VoxelShape::from_box(Aabb::new(
+ Vec3::new(f(0), f(1), f(2)),
+ Vec3::new(f(3), f(4), f(5)),
+ ))?,
+ ShapeOp::OR,
+ )?;
+ }
+ Ok(shape)
+}
+pub fn main() -> Result<()> {
+ let args: Vec<_> = crate::args().collect();
+ 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?)?;
+ let mut world = Fixture(HashMap::new());
+ for c in row["cells"].as_array().unwrap() {
+ let p = std::array::from_fn(|i| c[i].as_i64().unwrap() as i32);
+ world.0.insert(
+ p,
+ Cell {
+ outline: shape(&c[3])?,
+ collision: shape(&c[4])?,
+ suffocating: c[5].as_bool().unwrap(),
+ },
+ );
+ }
+ let p = &row["pos"];
+ let (x, y, z) = (
+ p[0].as_f64().unwrap(),
+ p[1].as_f64().unwrap(),
+ p[2].as_f64().unwrap(),
+ );
+ let bounds = Aabb::new(
+ Vec3::new(x - 0.5, y, z - 0.5),
+ Vec3::new(x + 0.5, y + 0.25, z + 0.5),
+ );
+ writeln!(
+ out,
+ "{} {}",
+ cushion::can_place(&world, bounds),
+ cushion::survives(&world, bounds)
+ )?;
+ }
+ Ok(())
+}
diff --git a/crates/horizon-client/examples/probe/entity_anim_fixture.rs b/crates/horizon-client/examples/probe/entity_anim_fixture.rs
index 0a2e2951f..4aceb9f6f 100644
--- a/crates/horizon-client/examples/probe/entity_anim_fixture.rs
+++ b/crates/horizon-client/examples/probe/entity_anim_fixture.rs
@@ -9,7 +9,7 @@ use horizon_client::mesher::Vertex;
use horizon_client::mobs::{
Mob, MobData, MobTypes, Part, Species, allay,
anim::{self, Poses, State},
- aquatic, body_anim as b_anim, equine, guardian,
+ aquatic, bat, body_anim as b_anim, equine, guardian,
keyframes::AnimationState,
render, shulker, sniffer, species, tame,
};
@@ -108,6 +108,22 @@ fn entity_state(mob: &mut Mob, species: Species, case: &Value) {
let flag = |k: &str| case[k].as_bool().unwrap_or(false);
let num = |k: &str| case[k].as_f64().unwrap_or(0.0) as f32;
match species {
+ Species::Bat => {
+ mob.extra.flag = flag("isResting");
+ mob.anim.states[0].started = case["flyAnimationState"].as_f64().map(|v| v as f32);
+ mob.anim.states[1].started = case["restAnimationState"].as_f64().map(|v| v as f32);
+ if let Some(ticks) = case["@batTicks"].as_array() {
+ for (i, flags) in ticks.iter().enumerate() {
+ mob.age = i as u32 + 1;
+ bat::set_resting(mob, flags.as_i64().unwrap_or(0) & 1 != 0);
+ if flag("@batInFrame") {
+ bat::tick(mob);
+ } else {
+ bat::setup_animation_states(mob);
+ }
+ }
+ }
+ }
Species::Sheep => mob.extra.anim_ticks = num("eatTick") as i32,
Species::Guardian | Species::ElderGuardian => {
mob.anim.v[guardian::TAIL] = num("tailAnimation");
@@ -377,7 +393,7 @@ pub fn main() -> horizon_client::Result<()> {
mob.extra.age = -24000;
}
mob.age = f("ageInTicks") as u32;
- let state = State {
+ let mut state = State {
pos: f("walkAnimationPos"),
speed: f("walkAnimationSpeed"),
age: f("ageInTicks"),
@@ -389,6 +405,20 @@ pub fn main() -> horizon_client::Result<()> {
};
let mut poses = Poses::new(&model);
entity_state(&mut mob, def.species, case);
+ if let Some(ticks) = case["@walkTicks"].as_array() {
+ for speed in ticks {
+ mob.previous_limb_speed = mob.limb_speed;
+ mob.limb_speed += (speed.as_f64().unwrap() as f32 - mob.limb_speed) * 0.4;
+ mob.limb_position += mob.limb_speed;
+ }
+ if !case["@alive"].as_bool().unwrap() {
+ mob.health = 0.0;
+ }
+ let extracted = anim::state(&mob, f("@partial"));
+ state.pos = extracted.pos;
+ state.speed = extracted.speed;
+ }
+
let flag = |k: &str| case[k].as_bool().unwrap_or(false);
let num = |k: &str| case[k].as_f64().unwrap_or(0.0) as f32;
let started = |k: &str| AnimationState {
@@ -456,14 +486,6 @@ pub fn main() -> horizon_client::Result<()> {
roll_amount: num("rollAmount"),
},
),
- Species::Bat => b_anim::bat(
- p,
- s,
- flag("isResting"),
- &started("flyAnimationState"),
- &started("restAnimationState"),
- &animations,
- ),
Species::Armadillo => b_anim::armadillo(
p,
s,
@@ -590,7 +612,11 @@ pub fn main() -> horizon_client::Result<()> {
}
_ => anim::setup(&mob, s, p, &animations),
}
- out.push_str(&format!("{}\n", emit(&model, &mut poses, case, layer)));
+ let mut result = emit(&model, &mut poses, case, layer);
+ if case["@walkTicks"].is_array() {
+ result["walk"] = json!([state.pos, state.speed]);
+ }
+ out.push_str(&format!("{result}\n"));
}
fs::write(&args[2], out)?;
Ok(())
diff --git a/crates/horizon-client/examples/probe/entity_death_loot_fixture.rs b/crates/horizon-client/examples/probe/entity_death_loot_fixture.rs
index 86ef275d9..3f47ba787 100644
--- a/crates/horizon-client/examples/probe/entity_death_loot_fixture.rs
+++ b/crates/horizon-client/examples/probe/entity_death_loot_fixture.rs
@@ -23,6 +23,7 @@ fn stack(spec: &str) -> LootStack {
map: false,
exploration: None,
ominous_amplifier: None,
+ stew_effects: Vec::new(),
};
for e in parts {
let (name, level) = e.rsplit_once(':').unwrap_or((e, "1"));
diff --git a/crates/horizon-client/examples/probe/main.rs b/crates/horizon-client/examples/probe/main.rs
index e80088fd0..6b4c3d7dd 100644
--- a/crates/horizon-client/examples/probe/main.rs
+++ b/crates/horizon-client/examples/probe/main.rs
@@ -27,7 +27,9 @@ mod chat_screen_fixture;
mod cloud_fixture;
mod command_parse_fixture;
mod container_screen_probe;
+mod creative_mining_probe;
mod creative_search_fixture;
+mod cushion_placement_probe;
mod emitter_particles_probe;
mod enchanting_fixture;
mod entity_anim_fixture;
@@ -83,6 +85,7 @@ mod sound_engine_fixture;
mod sound_event_fixture;
mod spawn_rules_fixture;
mod sprint_probe;
+mod squid_client_probe;
mod subtitle_fixture;
mod tab_list_fixture;
mod team_rules_fixture;
@@ -107,6 +110,9 @@ probes! {
block_transform_fixture,
breaking_fixture,
camera_probe,
+ squid_client_probe,
+ creative_mining_probe,
+ cushion_placement_probe,
cauldron_fixture,
chat_screen_fixture,
cloud_fixture,
diff --git a/crates/horizon-client/examples/probe/mob_ai_probe.rs b/crates/horizon-client/examples/probe/mob_ai_probe.rs
index 49f56c250..5b6c26d2f 100644
--- a/crates/horizon-client/examples/probe/mob_ai_probe.rs
+++ b/crates/horizon-client/examples/probe/mob_ai_probe.rs
@@ -49,6 +49,7 @@ struct Scenario {
name: String,
ticks: i32,
time: i64,
+ difficulty: u8,
rain: bool,
thunder: bool,
origin: [i32; 2],
@@ -85,11 +86,13 @@ fn parse(text: &str) -> Vec {
"scenario" => {
s.name = f[1].to_owned();
s.time = 6000;
+ s.difficulty = 2;
for kv in &f[2..] {
let (k, v) = kv.split_once('=').unwrap_or((kv, ""));
match k {
"ticks" => s.ticks = v.parse().unwrap(),
"time" => s.time = v.parse().unwrap(),
+ "difficulty" => s.difficulty = v.parse().unwrap(),
"rain" => s.rain = true,
"thunder" => s.thunder = true,
"levelseed" => {}
@@ -954,6 +957,10 @@ pub fn main() -> horizon_client::Result<()> {
for flag in &spec.flags {
match flag.as_str() {
"persistent" => m.persistent = true,
+ "named" => {
+ m.custom_name = Some("Peaceful regression".into());
+ m.persistent = true;
+ }
"ridden" => {
m.extra.head = Some("minecraft:red_harness".into());
m.rider = Some(horizon_client::mobs::equine::Rider::default());
@@ -1050,6 +1057,7 @@ pub fn main() -> horizon_client::Result<()> {
velocity: Vec3::ZERO,
yaw: 0.0,
hero: false,
+ effects: Vec::new(),
uuid: PLAYER_UUID,
allies: Default::default(),
remote: false,
@@ -1090,7 +1098,7 @@ pub fn main() -> horizon_client::Result<()> {
block_tags: &items.block_tags,
biomes: &biomes,
mob_griefing: true,
- difficulty: LocalDifficulty::new(2, world.day_time, 0, 0.0),
+ difficulty: LocalDifficulty::new(s.difficulty, world.day_time, 0, 0.0),
end: false,
world_seed: 0,
// FlatLevelSource.getSeaLevel.
diff --git a/crates/horizon-client/examples/probe/scene_motion_probe.rs b/crates/horizon-client/examples/probe/scene_motion_probe.rs
index 21977eeab..6153e098b 100644
--- a/crates/horizon-client/examples/probe/scene_motion_probe.rs
+++ b/crates/horizon-client/examples/probe/scene_motion_probe.rs
@@ -164,6 +164,8 @@ fn snapshot(out: &mut impl Write, player: &PlayerMotion) -> Result<()> {
.iter()
.filter_map(|e| match e {
MotionEvent::Fall(e) => Some(e.trace()),
+ // The oracle's player wears no armor; the game applies this one.
+ MotionEvent::Health(horizon_client::health::HealthEvent::ArmorHurt { .. }) => None,
MotionEvent::Health(e) => Some(e.trace()),
MotionEvent::Snowflake { position, velocity } => Some(format!(
"snowflake {:016x},{:016x},{:016x} {:016x},{:016x},{:016x}",
diff --git a/crates/horizon-client/examples/probe/section_mesh_fixture.rs b/crates/horizon-client/examples/probe/section_mesh_fixture.rs
index e212a5268..89624b78c 100644
--- a/crates/horizon-client/examples/probe/section_mesh_fixture.rs
+++ b/crates/horizon-client/examples/probe/section_mesh_fixture.rs
@@ -7,7 +7,7 @@ use horizon_client::{
biome::{BiomeInfo, Biomes, GrassModifier},
blocks::BlockFacts,
chunk::{Chunk, Light, MIN_Y, index},
- mesher::{MeshTables, Mesher, OPAQUE_LAYER, TerrainVertex},
+ mesher::{EMISSION_BITS, MeshTables, Mesher, OPAQUE_LAYER, TerrainVertex},
models,
};
use horizon_core::biome::BiomeZoom;
@@ -161,7 +161,15 @@ fn scene(
.iter()
.map(|v| {
let ([x, y, z], [u, w]) = (v.pos, v.uv());
- json!([x, y, z, u, w, argb(v.color), light_coords(v.light)])
+ json!([
+ x,
+ y,
+ z,
+ u,
+ w,
+ argb(v.color | EMISSION_BITS),
+ light_coords(v.light)
+ ])
})
.collect();
quads.push(json!({"layer": layer, "sprite": sprite, "vertices": vertices}));
diff --git a/crates/horizon-client/examples/probe/squid_client_probe.rs b/crates/horizon-client/examples/probe/squid_client_probe.rs
new file mode 100644
index 000000000..f9f9c9b2c
--- /dev/null
+++ b/crates/horizon-client/examples/probe/squid_client_probe.rs
@@ -0,0 +1,51 @@
+use horizon_client::{
+ Result,
+ mobs::{Mob, MobTypes, aquatic},
+};
+use horizon_core::geometry::Vec3;
+use serde_json::Value;
+use std::{
+ fs::File,
+ io::{BufRead, BufReader, BufWriter, Write},
+ path::Path,
+ sync::Arc,
+};
+
+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 types = MobTypes::load(&root)?;
+ 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?)?;
+ let kind = Arc::new(
+ types.types[&format!("minecraft:{}", row["species"].as_str().unwrap())].clone(),
+ );
+ let mut mob = Mob::new(1, kind, Vec3::ZERO, 0.0);
+ mob.fluids.in_water = row["water"].as_bool().unwrap();
+ mob.velocity = Vec3::new(
+ row["x"].as_f64().unwrap(),
+ row["y"].as_f64().unwrap(),
+ row["z"].as_f64().unwrap(),
+ );
+ mob.body_yaw = row["body"].as_f64().unwrap() as f32;
+ for (i, value) in row["v"].as_array().unwrap().iter().enumerate() {
+ mob.anim.v[i] = value.as_f64().unwrap() as f32;
+ }
+ if row["reset"].as_bool().unwrap() {
+ aquatic::squid_cycle_event(&mut mob);
+ }
+ let velocity = mob.velocity;
+ aquatic::squid_client_step(&mut mob);
+ assert_eq!(
+ velocity, mob.velocity,
+ "a remote animation must preserve packet velocity"
+ );
+ write!(out, "{:08x}", mob.body_yaw.to_bits())?;
+ for value in &mob.anim.v[..6] {
+ write!(out, " {:08x}", value.to_bits())?;
+ }
+ writeln!(out)?;
+ }
+ Ok(())
+}
diff --git a/crates/horizon-client/src/animate_tick.rs b/crates/horizon-client/src/animate_tick.rs
index 48e8f16f4..28b28544b 100644
--- a/crates/horizon-client/src/animate_tick.rs
+++ b/crates/horizon-client/src/animate_tick.rs
@@ -58,7 +58,10 @@ enum Animate {
BubbleColumn {
drag: bool,
},
- Fire,
+ /// `BaseFireBlock` (`SoulFireBlock.canBurn` is always true).
+ Fire {
+ soul: bool,
+ },
/// An active sculk sensor.
SculkSensor,
/// Potent sulfur that is not dry.
@@ -140,7 +143,8 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option) -> Animate {
"bubble_column" => Animate::BubbleColumn {
drag: prop("drag").as_deref() == Some("true"),
},
- "fire" | "soul_fire" => Animate::Fire,
+ "fire" => Animate::Fire { soul: false },
+ "soul_fire" => Animate::Fire { soul: true },
"dried_ghast" => Animate::DriedGhast {
waterlogged: prop("waterlogged").as_deref() == Some("true"),
},
@@ -190,6 +194,8 @@ pub struct AnimateTables {
power_transmitter: Vec,
/// `#triggers_ambient_dried_ghast_block_sounds` (soul sand, soul soil).
dried_ghast_sounds: Vec,
+ /// `FireBlock.canBurn`: states with ignite odds above zero (none waterlogged).
+ burnable: Vec,
/// Biome id -> ambient particles and probabilities (`minecraft:visual/ambient_particles`).
ambient: Vec>,
}
@@ -220,6 +226,21 @@ impl AnimateTables {
})
.collect()
};
+ let behaviour: serde_json::Value =
+ serde_json::from_slice(&std::fs::read(root.join("block-behaviours.json"))?)?;
+ let ignite_odds = behaviour["fire"]["igniteOdds"].as_object();
+ let burnable = (0..registry.len() as u32)
+ .map(|id| {
+ // FireBlock.getIgniteOdds: a waterlogged state never burns.
+ registry.state(BlockStateId(id)).is_some_and(|s| {
+ s.properties.get("waterlogged").map(String::as_str) != Some("true")
+ && ignite_odds
+ .and_then(|m| m.get(s.name.as_str()))
+ .and_then(serde_json::Value::as_i64)
+ .is_some_and(|odds| odds > 0)
+ })
+ })
+ .collect();
let mut ambient = vec![Vec::new(); biomes.list.len()];
for (name, &id) in &biomes.index {
let path = root.join(format!(
@@ -248,6 +269,7 @@ impl AnimateTables {
power_provider: by_state("minecraft:enchantment_power_provider"),
power_transmitter: by_state("minecraft:enchantment_power_transmitter"),
dried_ghast_sounds: by_state("minecraft:triggers_ambient_dried_ghast_block_sounds"),
+ burnable,
ambient,
})
}
@@ -1103,7 +1125,7 @@ fn block_animate_tick(
}
}
}
- Animate::Fire => {
+ Animate::Fire { soul } => {
let r = engine.random();
if next_int(r, 24) == 0 {
let (volume, pitch) = (1.0 + r.next_float(), r.next_float() * 0.7 + 0.3);
@@ -1115,6 +1137,55 @@ fn block_animate_tick(
pitch,
);
}
+ // BaseFireBlock.animateTick: three large smoke above the fire, or
+ // two at each burnable neighbour's face when nothing burnable or
+ // sturdy supports it (SoulFireBlock.canBurn is always true).
+ let can_burn = |p: [i32; 3]| {
+ soul || tables
+ .burnable
+ .get(ctx.state(p).0 as usize)
+ .copied()
+ .unwrap_or(false)
+ };
+ let below = [pos[0], pos[1] - 1, pos[2]];
+ let sturdy = ctx.facts.get(ctx.state(below)).sturdy_full & (1 << 1) != 0;
+ if !can_burn(below) && !sturdy {
+ // (neighbour, coordinate axis, face at the axis' high end): the
+ // west, east, north, south and above faces, in vanilla order.
+ for (at, axis, high) in [
+ ([pos[0] - 1, pos[1], pos[2]], 0usize, false),
+ ([pos[0] + 1, pos[1], pos[2]], 0, true),
+ ([pos[0], pos[1], pos[2] - 1], 2, false),
+ ([pos[0], pos[1], pos[2] + 1], 2, true),
+ ([pos[0], pos[1] + 1, pos[2]], 1, true),
+ ] {
+ if !can_burn(at) {
+ continue;
+ }
+ for _ in 0..2 {
+ let r = engine.random();
+ let d = [r.next_double(), r.next_double(), r.next_double()];
+ let mut p = [fx + d[0], fy + d[1], fz + d[2]];
+ // The face coordinate hugs the edge: `* 0.1F` (float).
+ p[axis] = if high {
+ f64::from(pos[axis]) + 1.0 - d[axis] * f64::from(0.1f32)
+ } else {
+ f64::from(pos[axis]) + d[axis] * f64::from(0.1f32)
+ };
+ add(engine, T::LargeSmoke, p, [0.0; 3]);
+ }
+ }
+ } else {
+ for _ in 0..3 {
+ let r = engine.random();
+ let p = [
+ fx + r.next_double(),
+ fy + r.next_double() * 0.5 + 0.5,
+ fz + r.next_double(),
+ ];
+ add(engine, T::LargeSmoke, p, [0.0; 3]);
+ }
+ }
}
}
}
@@ -1157,13 +1228,19 @@ pub fn campfire_particles<'a>(
if r.next_float() < 0.11 {
let count = next_int(r, 2) + 2;
for _ in 0..count {
- make_campfire_smoke(engine, pos, signal);
+ make_campfire_smoke(engine, pos, signal, false);
}
}
}
}
-/// `CampfireBlock.makeParticles` (always visible, overriding the limiter).
-pub fn make_campfire_smoke(engine: &mut ParticleEngine, pos: [i32; 3], signal: bool) {
+/// `CampfireBlock.makeParticles` (always visible, overriding the limiter);
+/// `smoking` adds the plain smoke puff at the logs.
+pub fn make_campfire_smoke(
+ engine: &mut ParticleEngine,
+ pos: [i32; 3],
+ signal: bool,
+ smoking: bool,
+) {
let r = engine.random();
let sign = |r: &mut LegacyRandom| if r.next_boolean() { 1.0 } else { -1.0 };
let dx = r.next_double() / 3.0;
@@ -1177,6 +1254,17 @@ pub fn make_campfire_smoke(engine: &mut ParticleEngine, pos: [i32; 3], signal: b
T::CampfireCosySmoke
};
engine.create(&ParticleOptions::simple(ty), [x, y, z], [0.0, 0.07, 0.0]);
+ if smoking {
+ let r = engine.random();
+ let dx = r.next_double() / 4.0 * sign(r);
+ let dz = r.next_double() / 4.0 * sign(r);
+ let at = [
+ f64::from(pos[0]) + 0.5 + dx,
+ f64::from(pos[1]) + 0.4,
+ f64::from(pos[2]) + 0.5 + dz,
+ ];
+ engine.create(&ParticleOptions::simple(T::Smoke), at, [0.0, 0.005, 0.0]);
+ }
}
#[cfg(test)]
diff --git a/crates/horizon-client/src/arrows.rs b/crates/horizon-client/src/arrows.rs
index 30b501011..f3c9c63bd 100644
--- a/crates/horizon-client/src/arrows.rs
+++ b/crates/horizon-client/src/arrows.rs
@@ -156,7 +156,11 @@ pub struct ArrowState {
/// `pickupItemStack`: what picking the arrow up gives.
pub pickup_item: crate::items::ItemStack,
pub no_physics: bool,
+ /// `AbstractArrow.getPierceLevel`.
pub pierce: u8,
+ /// `piercingIgnoreEntityIds`: the entities a piercing arrow has gone
+ /// through (cleared when it sticks in a block).
+ pub pierced: Vec,
/// `Entity.remainingFireTicks`.
pub fire_ticks: i32,
pub fall_distance: f64,
@@ -199,6 +203,7 @@ impl ArrowState {
pickup_item: crate::items::ItemStack::new("minecraft:arrow", 1),
no_physics: false,
pierce: 0,
+ pierced: Vec::new(),
fire_ticks: 0,
fall_distance: 0.0,
fluids: EntityFluidInteraction::new([WATER, LAVA]),
@@ -362,6 +367,29 @@ fn bounds(at: Vec3) -> Aabb {
Vec3::new(at.x + w, at.y + f64::from(HEIGHT), at.z + w),
)
}
+/// The bounding box of an arrow, or a thrown trident, at `at`.
+pub fn arrow_bounds(at: Vec3) -> Aabb {
+ bounds(at)
+}
+/// The blocks an arrow that moved from `from` to `to` was inside
+/// (`Entity.checkInsideBlocks`: the sweep of its box, 16 steps at most), for
+/// the blocks that react to it (`entityInside`).
+pub fn blocks_inside(from: Vec3, to: Vec3) -> Vec {
+ let deflated = bounds(to).deflate(Vec3::new(
+ f64::from(1.0e-5f32),
+ f64::from(1.0e-5f32),
+ f64::from(1.0e-5f32),
+ ));
+ let mut inside = Vec::new();
+ let _ = sweep_intersected_blocks(from, to, deflated, 1_000_000, |p, iteration| {
+ if iteration >= 16 {
+ return false;
+ }
+ inside.push(p);
+ true
+ });
+ inside
+}
/// `Projectile.lerpRotation`.
fn lerp_rotation(mut old: f32, new: f32) -> f32 {
while new - old < -180.0 {
@@ -431,10 +459,19 @@ pub fn finish_entity_hit(p: &mut Projectile, scene: &ArrowScene, outcome: Entity
match outcome {
EntityHit::Hurt => {
ticker.hit_sound();
- ticker.st.removed = true;
+ // A piercing arrow flies on (`getPierceLevel() <= 0` discards).
+ if ticker.st.pierce == 0 {
+ ticker.st.removed = true;
+ }
}
EntityHit::Refused => ticker.turn_back(),
- EntityHit::Deflected => {}
+ EntityHit::Deflected => {
+ // The deflection comes before `onHitEntity` adds the entity to
+ // `piercingIgnoreEntityIds`.
+ if ticker.st.pierce > 0 {
+ ticker.st.pierced.pop();
+ }
+ }
}
ticker.finish();
p.stuck = st.in_ground;
@@ -639,7 +676,8 @@ impl Ticker<'_, '_, '_, '_> {
self.st.life = 0;
}
/// `AbstractArrow.stepMoveAndHit`: the nearest entity on the way, else the
- /// block (piercing is not modelled).
+ /// block. A piercing arrow that hits an entity here goes on from it next
+ /// tick (`finish_entity_hit`); vanilla's loop goes on within the same tick.
fn step_move_and_hit(&mut self, from: Vec3, to: Vec3, targets: &[(Who, Aabb)]) -> Hit {
let block = self.scene.clip(from, to);
let end = block.map_or(to, |b| b.0);
@@ -653,6 +691,10 @@ impl Ticker<'_, '_, '_, '_> {
if Some(*who) == self.p.owner && !self.p.left_owner || !b.intersects(area) {
continue;
}
+ // canHitEntity: not an entity a piercing arrow has gone through.
+ if self.st.pierced.contains(who) {
+ continue;
+ }
if let Some(at) = self.entity_hit(b, from, end, margin) {
// Sorted by the distance to the entity's position (the
// stable sort keeps the first of equals).
@@ -667,6 +709,15 @@ impl Ticker<'_, '_, '_, '_> {
self.p.position = next;
self.apply_effects_from_blocks(from, next);
if let Some((who, at, _)) = best {
+ // AbstractArrow.onHitEntity: past pierce level + 1 entities the
+ // arrow is discarded before it hurts the next one.
+ if self.st.pierce > 0 {
+ if self.st.pierced.len() > usize::from(self.st.pierce) {
+ self.st.removed = true;
+ return Hit::None;
+ }
+ self.st.pierced.push(who);
+ }
return Hit::Entity { who, at };
}
let Some((at, pos, face)) = block else {
@@ -752,6 +803,7 @@ impl Ticker<'_, '_, '_, '_> {
self.st.shake = 7;
self.p.set_critical(false);
self.st.pierce = 0;
+ self.st.pierced.clear();
}
/// `Projectile.mayInteract`: a mob's arrows need `mob_griefing`.
fn may_interact(&self) -> bool {
diff --git a/crates/horizon-client/src/benchmark.rs b/crates/horizon-client/src/benchmark.rs
index bcb94589c..12e38e78a 100644
--- a/crates/horizon-client/src/benchmark.rs
+++ b/crates/horizon-client/src/benchmark.rs
@@ -323,10 +323,15 @@ const QUIET_MESHES_PER_S: f64 = 100.0;
/// happened (`tools/perf_bench.py`'s `wait_loaded`). With `status.streaming`, the sections are
/// drawn once no stage has held work for half a second: blocks changing after the load (fluids
/// settling, random ticks) only remesh in bursts. Without it, meshing has to go quiet.
+///
+/// Right after a teleport (and at spawn) the game reports nothing queued and steady counts for over
+/// a second before it starts loading: the counts must change once, or stay full for 5 s, and the
+/// resident chunks reach [`view_chunks`], before "steady" means loaded.
fn wait_loaded(control: &Control, near: impl Fn(&Value) -> bool) -> Result {
let deadline = Instant::now() + LOAD_LIMIT;
let (mut last, mut changed, mut meshing) = (None, Instant::now(), Instant::now());
let mut previous: Option<(Instant, f64)> = None;
+ let (mut first, mut moved, began) = (None, false, Instant::now());
while Instant::now() < deadline {
let s = control.status();
let now = Instant::now();
@@ -339,6 +344,19 @@ fn wait_loaded(control: &Control, near: impl Fn(&Value) -> bool) -> Result 0
+ && resident >= min_chunks
+ && now - began > Duration::from_secs(5));
+ if min_chunks > 0 && (!moved || resident < min_chunks) {
+ (last, changed, previous, meshing) = (None, now, None, now);
+ thread::sleep(Duration::from_millis(50));
+ continue;
+ }
if last != Some(counts) {
(last, changed) = (Some(counts), now);
}
@@ -370,6 +388,12 @@ fn wait_loaded(control: &Control, near: impl Fn(&Value) -> bool) -> Result u64 {
+ (0.8 * std::f64::consts::PI * (distance * distance) as f64) as u64
+}
+
/// Vanilla's yaw facing from `start` to `end`.
fn yaw(start: [f64; 3], end: [f64; 3]) -> f64 {
(-(end[0] - start[0])).atan2(end[2] - start[2]).to_degrees()
@@ -420,6 +444,13 @@ mod tests {
assert!(scenes("nether").is_none());
}
+ #[test]
+ fn view_chunks_matches_perf_bench() {
+ assert_eq!(view_chunks(12), 361);
+ assert_eq!(view_chunks(32), 2573);
+ assert_eq!(view_chunks(0), 0);
+ }
+
#[test]
fn yaw_matches_vanilla() {
// South (+Z) is 0, west (-X) 90, east (+X) -90.
diff --git a/crates/horizon-client/src/biome.rs b/crates/horizon-client/src/biome.rs
index 63ad004d4..c8c15e44e 100644
--- a/crates/horizon-client/src/biome.rs
+++ b/crates/horizon-client/src/biome.rs
@@ -28,6 +28,9 @@ pub struct BiomeInfo {
pub spawn_costs: HashMap,
/// The `gameplay/creature_world_gen_spawn_probability` attribute.
pub creature_probability: f32,
+ /// The `gameplay/increased_fire_burnout` attribute (jungles, swamps, peaks,
+ /// mushroom fields).
+ pub increased_fire_burnout: bool,
pub name: String,
pub temperature: f32,
pub downfall: f32,
@@ -128,6 +131,9 @@ impl Biomes {
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,
downfall,
precipitation: v["has_precipitation"].as_bool().unwrap_or(true),
diff --git a/crates/horizon-client/src/block_entity_anim.rs b/crates/horizon-client/src/block_entity_anim.rs
index d91748e73..fd91051fa 100644
--- a/crates/horizon-client/src/block_entity_anim.rs
+++ b/crates/horizon-client/src/block_entity_anim.rs
@@ -648,6 +648,61 @@ impl Conduit {
}
}
+/// What `ConduitBlockEntity.serverTick`'s 40-tick check decided.
+#[derive(Clone, Copy, Debug, Default, PartialEq)]
+pub struct ConduitCheck {
+ /// The shape changed: `CONDUIT_ACTIVATE` (true) or `CONDUIT_DEACTIVATE`.
+ pub toggled: Option,
+ /// The frame blocks counted, while the conduit is active: `applyEffects`
+ /// and `updateAndAttackTarget` follow (the attack needs 42 or more).
+ pub frames: Option,
+}
+
+/// `ConduitBlockEntity` (server side): what `serverTick` keeps between ticks.
+#[derive(Clone, Debug, Default)]
+pub struct ConduitServer {
+ shape: Conduit,
+ /// `destroyTarget`: the mob id of the enemy being struck.
+ pub target: Option,
+ next_ambient_sound: i64,
+}
+
+impl ConduitServer {
+ /// The `gameTime % 40` part of `serverTick`: the shape is checked again.
+ pub fn check(
+ &mut self,
+ pos: [i32; 3],
+ game_time: i64,
+ is_water: impl Fn([i32; 3]) -> bool,
+ is_frame: impl Fn([i32; 3]) -> bool,
+ ) -> Option {
+ if game_time % 40 != 0 {
+ return None;
+ }
+ let active = self.shape.update_shape(pos, &is_water, &is_frame);
+ let toggled = (active != self.shape.is_active).then_some(active);
+ self.shape.is_active = active;
+ Some(ConduitCheck {
+ toggled,
+ frames: active.then_some(self.shape.effect_blocks.len()),
+ })
+ }
+ /// The sounds of an active conduit at the end of `serverTick`: whether
+ /// `CONDUIT_AMBIENT` (every 80 ticks) and `CONDUIT_AMBIENT_SHORT` (every
+ /// 60 to 99 ticks) play.
+ pub fn ambient(&mut self, game_time: i64, random: &mut impl RandomSource) -> (bool, bool) {
+ if !self.shape.is_active {
+ return (false, false);
+ }
+ let short = game_time > self.next_ambient_sound;
+ if short {
+ self.next_ambient_sound =
+ game_time + 60 + i64::from(random.next_int_bound(40).unwrap_or(0));
+ }
+ (game_time % 80 == 0, short)
+ }
+}
+
/// `SkullBlockEntity` animation (client ticker of dragon and piglin heads).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Skull {
@@ -832,6 +887,8 @@ pub struct BlockEntityAnims {
pub gateways: FxHashMap<[i32; 3], EndGateway>,
/// The integrated server's copies of the gateways (`portalTick`).
pub gateway_servers: FxHashMap<[i32; 3], EndGateway>,
+ /// The integrated server's copies of the conduits (`serverTick`).
+ pub conduit_servers: FxHashMap<[i32; 3], ConduitServer>,
/// The client level's random (conduit particles).
pub random: LegacyRandom,
/// [`Ticker`] by block state id (see [`BlockEntityAnims::classify`]).
@@ -854,6 +911,7 @@ impl Default for BlockEntityAnims {
pots: FxHashMap::default(),
gateways: FxHashMap::default(),
gateway_servers: FxHashMap::default(),
+ conduit_servers: FxHashMap::default(),
random: LegacyRandom::new(0x6c6576656c),
kinds: Vec::new(),
present: Vec::new(),
@@ -909,6 +967,7 @@ impl BlockEntityAnims {
self.pots.retain(|p, _| keep(*p));
self.gateways.retain(|p, _| keep(*p));
self.gateway_servers.retain(|p, _| keep(*p));
+ self.conduit_servers.retain(|p, _| keep(*p));
}
/// `getOpenNess(partial)` of the chest or ender chest at `pos`.
pub fn lid(&self, pos: [i32; 3], partial: f32) -> f32 {
diff --git a/crates/horizon-client/src/block_entity_nbt.rs b/crates/horizon-client/src/block_entity_nbt.rs
index 54d230eb4..a4c02ba3b 100644
--- a/crates/horizon-client/src/block_entity_nbt.rs
+++ b/crates/horizon-client/src/block_entity_nbt.rs
@@ -664,7 +664,9 @@ pub fn canonical(tag: &Tag) -> Option {
}
"minecraft:trial_spawner" => {
// `TrialSpawnerStateData.Packed` and `FullConfig`: every key is optional
- // and defaulted by vanilla; they are kept as loaded.
+ // and defaulted by vanilla; they are kept as loaded. No `spawn_data` is
+ // added: an absent one loads as Optional.empty and is not stored, and
+ // an empty entity would keep the spawner from ever picking a mob.
if let Tag::Compound(c) = tag {
for (k, v) in c {
let key = k.to_string_lossy();
@@ -676,12 +678,6 @@ pub fn canonical(tag: &Tag) -> Option {
}
}
}
- if !f.out.contains_key(&"spawn_data".into()) {
- f.put(
- "spawn_data",
- Tag::compound([("entity", Tag::Compound(Compound::new()))]),
- );
- }
}
"minecraft:vault" => {
// `VaultConfig.DEFAULT` (when the config is absent): the trial key and the
@@ -827,6 +823,21 @@ mod tests {
canonical(&snbt(s)).expect("known type")
}
+ #[test]
+ fn a_trial_spawner_without_spawn_data_saves_none() {
+ let fresh = canon(
+ r#"{id:"minecraft:trial_spawner",x:1,y:2,z:3,normal_config:"minecraft:trial_chamber/ranged/skeleton/normal"}"#,
+ );
+ assert_eq!(fresh.get("spawn_data"), None);
+ let picked = canon(
+ r#"{id:"minecraft:trial_spawner",x:1,y:2,z:3,spawn_data:{entity:{id:"minecraft:skeleton"}}}"#,
+ );
+ assert_eq!(
+ picked.get("spawn_data"),
+ Some(&snbt(r#"{entity:{id:"minecraft:skeleton"}}"#))
+ );
+ }
+
#[test]
fn every_type_has_defaults() {
for id in TYPES.iter().chain(NEWEST.iter()) {
diff --git a/crates/horizon-client/src/block_entity_render.rs b/crates/horizon-client/src/block_entity_render.rs
index 10c5e2509..28a876d80 100644
--- a/crates/horizon-client/src/block_entity_render.rs
+++ b/crates/horizon-client/src/block_entity_render.rs
@@ -558,12 +558,11 @@ fn frac(x: f32) -> f32 {
/// `StringSplitter.splitLines(...).get(0)` for plain text: the first line,
/// wrapped at the last space that fits or mid-word when none does.
fn first_line(line: &str, max_width: f32, advance: &dyn Fn(char) -> f32) -> String {
- let chars: Vec = line.chars().collect();
let mut width = 0.0;
let mut last_space = None;
- for (i, &c) in chars.iter().enumerate() {
+ for (i, c) in line.char_indices() {
if c == '\n' {
- return chars[..i].iter().collect();
+ return line[..i].to_owned();
}
if c == ' ' {
last_space = Some(i);
@@ -572,9 +571,11 @@ fn first_line(line: &str, max_width: f32, advance: &dyn Fn(char) -> f32) -> Stri
if width > max_width {
let end = match last_space {
Some(s) if s > 0 => s,
- _ => i.max(1),
+ _ if i > 0 => i,
+ // The first char alone is too wide: take that one char.
+ _ => c.len_utf8(),
};
- return chars[..end].iter().collect();
+ return line[..end].to_owned();
}
}
line.to_owned()
@@ -2098,4 +2099,13 @@ mod tests {
assert_eq!(out[1].pos, [2.0, 0.0, 0.0]);
assert_eq!(out[2].uv, [0.5, 0.5]);
}
+
+ #[test]
+ fn first_line_wraps_on_bytes_without_splitting_a_char() {
+ let advance = |c: char| if c == ' ' { 4.0 } else { 6.0 };
+ assert_eq!(first_line("ab cd ef", 13.0, &advance), "ab");
+ assert_eq!(first_line("abcdef", 13.0, &advance), "ab");
+ assert_eq!(first_line("\u{6771}", 3.0, &advance), "\u{6771}");
+ assert_eq!(first_line("ab\ncd", 100.0, &advance), "ab");
+ }
}
diff --git a/crates/horizon-client/src/bundle.rs b/crates/horizon-client/src/bundle.rs
index 8658b5815..42473729f 100644
--- a/crates/horizon-client/src/bundle.rs
+++ b/crates/horizon-client/src/bundle.rs
@@ -166,6 +166,38 @@ impl Contents {
Some(stack)
}
+ /// The stacks inside, in display order (the first is the next out).
+ pub fn items(&self) -> &[ItemStack] {
+ &self.items
+ }
+
+ /// `getNumberOfItemsToShow`: a full grid shows 11 and a `+N`, so the last
+ /// row keeps its fourth cell for the count.
+ pub fn shown_count(&self) -> usize {
+ let count = self.items.len();
+ let available: usize = if count > 12 { 11 } else { 12 };
+ let on_last_row = count % 4;
+ let empty = if on_last_row == 0 { 0 } else { 4 - on_last_row };
+ count.min(available.saturating_sub(empty))
+ }
+
+ /// The items the tooltip shows, and how many sit beyond them.
+ pub fn shown(&self) -> (&[ItemStack], u32) {
+ let n = self.shown_count();
+ let hidden = self.items[n..].iter().map(|s| s.count).sum();
+ (&self.items[..n], hidden)
+ }
+
+ /// The `Fraction` weight as the float the progress bar reads.
+ pub fn weight_fraction(&self) -> f32 {
+ self.weight.n as f32 / self.weight.d as f32
+ }
+
+ /// `Fraction.ONE`: a full bundle draws the full bar and says so.
+ pub fn is_full(&self) -> bool {
+ self.weight.n >= self.weight.d
+ }
+
/// `toImmutable`, written back into the bundle's component.
pub fn store(self, bundle: &mut ItemStack) {
bundle.data_mut().bundle_contents = self.items;
@@ -190,3 +222,174 @@ mod tests {
assert_eq!(over.how_many(Weight::new(1, 64)), 0);
}
}
+
+// ------------------------------------------------------------- the tooltip
+
+/// `ClientBundleTooltip`'s geometry, relative to the tooltip box's top-left:
+/// the 96 pixel column of a 4 wide slot grid and the progress bar under it.
+#[derive(Clone, Debug, PartialEq)]
+pub struct TooltipLayout {
+ /// The component's own width (96) and height, the text's excluded.
+ pub width: f32,
+ pub height: f32,
+ /// Each slot's top-left and the stack in it, in reading order.
+ pub slots: Vec<([f32; 2], ItemStack)>,
+ /// The `+N` of the items past the grid: its top-left and the count.
+ pub overflow: Option<([f32; 2], u32)>,
+ /// The progress bar's top-left, its fill's width out of 94, and whether
+ /// the weight fills it (the full sprite).
+ pub bar: [f32; 2],
+ pub fill: f32,
+ pub full: bool,
+ /// `item.minecraft.bundle.empty` / `.full`, centred over the bar.
+ pub label: Option<&'static str>,
+ /// An empty bundle's description, wrapped at `width` from here.
+ pub empty_description: Option<[f32; 2]>,
+}
+
+/// The tooltip of a bundle's contents (`ClientBundleTooltip`), or None when
+/// the stack is not a bundle. `x`/`y` are the box's top-left, `box_width` the
+/// tooltip's width, which the component is centred in.
+pub fn tooltip_layout(
+ items: &Items,
+ stack: &ItemStack,
+ x: f32,
+ y: f32,
+ box_width: f32,
+) -> Option {
+ let contents = Contents::of(items, stack)?;
+ let offset = ((box_width - 96.0) / 2.0).floor();
+ if contents.items().is_empty() {
+ // The description takes the top; the bar follows it.
+ let description_height = 9.0 * 2.0;
+ return Some(TooltipLayout {
+ width: 96.0,
+ height: description_height + 13.0 + 8.0,
+ slots: Vec::new(),
+ overflow: None,
+ bar: [x + offset, y + description_height + 4.0],
+ fill: 0.0,
+ full: false,
+ label: Some("item.minecraft.bundle.empty"),
+ empty_description: Some([x + offset, y]),
+ });
+ }
+ let (shown, hidden) = contents.shown();
+ // `gridSizeY` counts `slotCount()` (at most twelve), not the slots the
+ // overflow count leaves room for.
+ let rows = contents.items().len().min(12).div_ceil(4).max(1);
+ let grid_height = rows as f32 * 24.0;
+ let x_start = x + offset + 96.0;
+ let y_start = y + grid_height;
+ let mut slots = Vec::with_capacity(shown.len());
+ let mut overflow = None;
+ // Rows fill bottom-up and columns right-to-left, as vanilla draws them.
+ let mut slot = 0;
+ for row in 1..=rows {
+ for column in 1..=4 {
+ let at = [x_start - column as f32 * 24.0, y_start - row as f32 * 24.0];
+ if hidden > 0 && row == 1 && column == 1 {
+ overflow = Some((at, hidden));
+ } else if let Some(stack) = shown.get(slot) {
+ slots.push((at, stack.clone()));
+ slot += 1;
+ }
+ }
+ }
+ let weight = contents.weight_fraction();
+ Some(TooltipLayout {
+ width: 96.0,
+ height: grid_height + 13.0 + 8.0,
+ slots,
+ overflow,
+ bar: [x + offset, y + grid_height + 4.0],
+ // `Mth.mulAndTruncate(weight, 94)`, kept within the bar.
+ fill: (weight * 94.0).trunc().clamp(0.0, 94.0),
+ full: contents.is_full(),
+ label: if contents.is_full() {
+ Some("item.minecraft.bundle.full")
+ } else {
+ None
+ },
+ empty_description: None,
+ })
+}
+
+#[cfg(test)]
+mod tooltip_tests {
+ use super::*;
+
+ /// The prepared resources, when they are on this machine: the tooltip
+ /// asks the item registry whether a stack is a bundle.
+ fn items() -> Option {
+ let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+ .join("../../.local/horizon-client/26.3");
+ if !root.join("items.json").exists() {
+ return None;
+ }
+ let assets = crate::assets::Assets::load(&root).ok()?;
+ let facts = crate::blocks::BlockFacts::load(&root, assets.registry).ok()?;
+ Items::load(&root, assets.registry, &facts).ok()
+ }
+
+ fn bundle_with(counts: &[(&str, u32)]) -> ItemStack {
+ let mut bundle = ItemStack::new("minecraft:bundle", 1);
+ bundle.data_mut().bundle_contents = counts
+ .iter()
+ .map(|(item, n)| ItemStack::new(item, *n))
+ .collect();
+ bundle
+ }
+
+ #[test]
+ fn a_grid_shows_twelve_stacks_and_counts_the_rest() {
+ let Some(items) = items() else { return };
+ let twelve: Vec<(&str, u32)> = (0..12).map(|_| ("minecraft:stone", 1)).collect();
+ let layout = tooltip_layout(&items, &bundle_with(&twelve), 0.0, 0.0, 96.0).unwrap();
+ assert_eq!(layout.slots.len(), 12);
+ assert_eq!(layout.overflow, None);
+ assert_eq!(layout.height, 3.0 * 24.0 + 13.0 + 8.0);
+ // Rows fill bottom-up and columns right-to-left: the first item is in
+ // the bottom-right cell, the twelfth in the top-left.
+ assert_eq!(layout.slots[0].0, [96.0 - 24.0, 3.0 * 24.0 - 24.0]);
+ assert_eq!(&*layout.slots[0].1.item, "minecraft:stone");
+ assert_eq!(layout.slots[11].0, [0.0, 0.0]);
+ // A wider tooltip centres the 96 pixel column in it.
+ let centred = tooltip_layout(&items, &bundle_with(&twelve), 0.0, 0.0, 120.0).unwrap();
+ assert_eq!(centred.slots[0].0[0], 12.0 + 96.0 - 24.0);
+ // Thirteen stacks: `getNumberOfItemsToShow` leaves the last row room
+ // for the count, so 8 slots show and `+6` (four stones and two dirt)
+ // takes the first cell.
+ let mut counts = twelve;
+ counts.push(("minecraft:dirt", 2));
+ let layout = tooltip_layout(&items, &bundle_with(&counts), 0.0, 0.0, 96.0).unwrap();
+ assert_eq!(layout.slots.len(), 8);
+ assert_eq!(layout.overflow, Some(([96.0 - 24.0, 48.0], 6)));
+ }
+
+ #[test]
+ fn the_bar_fills_with_the_weight_and_an_empty_bundle_explains_itself() {
+ let Some(items) = items() else { return };
+ let layout = tooltip_layout(&items, &bundle_with(&[]), 4.0, 8.0, 96.0).unwrap();
+ assert!(layout.slots.is_empty());
+ assert_eq!(layout.fill, 0.0);
+ assert_eq!(layout.label, Some("item.minecraft.bundle.empty"));
+ assert_eq!(layout.empty_description, Some([4.0, 8.0]));
+ assert_eq!(layout.bar, [4.0, 8.0 + 18.0 + 4.0]);
+ // A bundle holding one 64 stack is full: the whole bar, the full label.
+ let layout = tooltip_layout(
+ &items,
+ &bundle_with(&[("minecraft:stone", 64)]),
+ 0.0,
+ 0.0,
+ 96.0,
+ )
+ .unwrap();
+ assert!(layout.full);
+ assert_eq!(layout.fill, 94.0);
+ assert_eq!(layout.label, Some("item.minecraft.bundle.full"));
+ // Something that is not a bundle has no component.
+ let dirt = ItemStack::new("minecraft:dirt", 1);
+ assert!(tooltip_layout(&items, &dirt, 0.0, 0.0, 96.0).is_none());
+ }
+}
diff --git a/crates/horizon-client/src/chat_tests.rs b/crates/horizon-client/src/chat_tests.rs
index 6b058c0fb..beec6cd81 100644
--- a/crates/horizon-client/src/chat_tests.rs
+++ b/crates/horizon-client/src/chat_tests.rs
@@ -436,3 +436,34 @@ fn the_chat_option_filters_the_lines_both_ways() {
chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::Full));
assert_eq!(shown(&chat), ["client", "server", "player"]);
}
+
+/// The in-frame LAN host's chat: a guest's line is a player message, so the
+/// Chat option's player-message permission hides it at Commands Only and
+/// Hidden; command feedback (`/list`, `/tp`) is a system message and stays
+/// at Commands Only.
+#[test]
+fn a_guests_line_follows_the_player_message_permission() {
+ let font = font();
+ let shown = |visibility, source| {
+ let mut chat = Chat::default();
+ chat.set_abilities(&font, ChatAbilities::new(visibility));
+ chat.add_message(&font, Text::literal("line"), None, source, 0, false);
+ chat.messages.len()
+ };
+ for (visibility, player, system) in [
+ (ChatVisibility::Full, 1, 1),
+ (ChatVisibility::System, 0, 1),
+ (ChatVisibility::Hidden, 0, 0),
+ ] {
+ assert_eq!(
+ shown(visibility, ChatSource::Player),
+ player,
+ "{visibility:?}"
+ );
+ assert_eq!(
+ shown(visibility, ChatSource::Server),
+ system,
+ "{visibility:?}"
+ );
+ }
+}
diff --git a/crates/horizon-client/src/chunk.rs b/crates/horizon-client/src/chunk.rs
index c0bec476b..7cd375343 100644
--- a/crates/horizon-client/src/chunk.rs
+++ b/crates/horizon-client/src/chunk.rs
@@ -161,6 +161,9 @@ pub struct Chunk {
/// Structure starts reaching into this chunk (`structures.References`):
/// structure id and the start's chunk.
pub structure_refs: Vec<(Arc, (i32, i32))>,
+ /// The starts generation made in this chunk (`structures.starts`), saved
+ /// with the state their placement has reached by then.
+ pub structure_starts: Vec>,
}
impl Chunk {
pub fn empty(x: i32, z: i32) -> Self {
@@ -178,6 +181,7 @@ impl Chunk {
block_entities: Vec::new(),
block_names: Vec::new(),
structure_refs: Vec::new(),
+ structure_starts: Vec::new(),
postprocess: Vec::new(),
saved: None,
entities: Vec::new(),
diff --git a/crates/horizon-client/src/clipboard.rs b/crates/horizon-client/src/clipboard.rs
index c21460cdb..76872091c 100644
--- a/crates/horizon-client/src/clipboard.rs
+++ b/crates/horizon-client/src/clipboard.rs
@@ -346,6 +346,11 @@ mod tests {
assert_eq!(filter_text(s, true), "abc\nde\u{80}");
assert_eq!(strip_formatting("§aRed§r §zx§"), "Red §zx§");
assert_eq!(helper_clipboard_contents("§lone\r\ntwo"), "one\ntwo");
+ // TextFieldHelper.getClipboardContents runs before insertText's filter.
+ assert_eq!(
+ filter_text(&helper_clipboard_contents("§aHello"), false),
+ "Hello"
+ );
}
struct Field {
diff --git a/crates/horizon-client/src/container_screen.rs b/crates/horizon-client/src/container_screen.rs
index 2c490861a..77c7ad8aa 100644
--- a/crates/horizon-client/src/container_screen.rs
+++ b/crates/horizon-client/src/container_screen.rs
@@ -275,4 +275,30 @@ mod tests {
Release::Click(Input::QuickMove)
);
}
+
+ /// A press whose release never arrives (the pointer left the window, or
+ /// the screen closed first) leaves `isQuickCrafting` set; the next screen
+ /// starts with its own state, as `AbstractContainerScreen` does, so the
+ /// reset the game runs on open/close makes clicks work again.
+ #[test]
+ fn a_lost_release_does_not_wedge_the_next_screen() {
+ let mut clicks = Clicks::default();
+ let slot = Target::::Slot(3);
+ // Pressed with a stack carried: a quick craft drag starts, and its
+ // release is lost.
+ assert_eq!(clicks.press(&slot, Button::Left, false, false, false), None);
+ assert!(clicks.quick_crafting().is_some());
+ assert_eq!(
+ clicks.press(&slot, Button::Left, true, false, false),
+ None,
+ "the stuck drag swallows the next press"
+ );
+ // A new screen: fresh state, so the press is a normal click again.
+ clicks = Clicks::default();
+ assert!(clicks.quick_crafting().is_none());
+ assert_eq!(
+ clicks.press(&slot, Button::Left, true, false, false),
+ Some(Input::Pickup)
+ );
+ }
}
diff --git a/crates/horizon-client/src/containers.rs b/crates/horizon-client/src/containers.rs
index d13c5e45b..8ca9af0ad 100644
--- a/crates/horizon-client/src/containers.rs
+++ b/crates/horizon-client/src/containers.rs
@@ -49,6 +49,17 @@ pub struct Furnace {
/// rather than per item, and a save keeps it as the `RecipesUsed` compound.
pub recipes_used: HashMap,
}
+/// The key `recipes_used` keeps a recipe under: its full id, as vanilla's `ResourceKey`
+/// writes it in `RecipesUsed`. Horizon's own recipe ids are file names without the namespace, so
+/// keeping both forms would write a key twice, and vanilla refuses such a furnace
+/// ("Duplicate entry for key").
+pub fn recipe_used_key(id: &str) -> String {
+ if id.contains(':') {
+ id.to_owned()
+ } else {
+ format!("minecraft:{id}")
+ }
+}
/// A hopper's slots: the only plain container with five.
pub const HOPPER_SLOTS: usize = 5;
@@ -83,8 +94,68 @@ pub enum BlockEntity {
Beehive(Box),
/// `CommandBlockEntity` of the three command blocks (`crate::command_block`).
CommandBlock(Box),
+ /// `CampfireBlockEntity` of campfires and soul campfires.
+ Campfire(Box),
/// `SkullBlockEntity` of every skull and head block.
Skull(Skull),
+ /// `DaylightDetectorBlockEntity`: no data, only the ticker
+ /// (`DaylightDetectorBlock.tickEntity`).
+ DaylightDetector,
+}
+/// `CampfireBlockEntity`: four cooking slots, each with its
+/// `cookingProgress` and `cookingTime` (the recipe's).
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct Campfire {
+ pub items: [Option; 4],
+ pub progress: [i32; 4],
+ pub total: [i32; 4],
+}
+impl Campfire {
+ /// `placeFood`'s slot: the first empty one, given the item's
+ /// `campfire_cooking` time; it takes one item of `held`.
+ pub fn place_food(&mut self, held: &mut ItemStack, cooking_time: i32) -> bool {
+ let Some(slot) = self.items.iter().position(Option::is_none) else {
+ return false;
+ };
+ self.total[slot] = cooking_time;
+ self.progress[slot] = 0;
+ self.items[slot] = Some(ItemStack {
+ count: 1,
+ ..held.clone()
+ });
+ held.count = held.count.saturating_sub(1);
+ true
+ }
+ /// `cookTick` (lit): each item's progress rises by one; the slots whose
+ /// progress reached their cooking time give up their item (the caller
+ /// drops its result).
+ pub fn cook_tick(&mut self) -> Vec<(usize, ItemStack)> {
+ let mut done = Vec::new();
+ for i in 0..4 {
+ if self.items[i].is_none() {
+ continue;
+ }
+ self.progress[i] += 1;
+ if self.progress[i] >= self.total[i]
+ && let Some(item) = self.items[i].take()
+ {
+ done.push((i, item));
+ }
+ }
+ done
+ }
+ /// `cooldownTick` (unlit): the progress falls by two, no lower than 0.
+ /// Whether anything changed (`setChanged`).
+ pub fn cooldown_tick(&mut self) -> bool {
+ let mut changed = false;
+ for i in 0..4 {
+ if self.progress[i] > 0 {
+ changed = true;
+ self.progress[i] = (self.progress[i] - 2).clamp(0, self.total[i]);
+ }
+ }
+ changed
+ }
}
/// `SkullBlockEntity`: the player profile the head shows (kept as loaded:
/// a compound, or a string when it names a player only), the note block
@@ -295,6 +366,9 @@ impl BlockEntity {
if is_skull(name) {
return Some(Self::Skull(Skull::default()));
}
+ if name == "minecraft:daylight_detector" {
+ return Some(Self::DaylightDetector);
+ }
if name == "minecraft:beacon" {
return Some(Self::Beacon(Box::new(Beacon {
last_check_y: i32::MIN / 2,
@@ -304,6 +378,9 @@ impl BlockEntity {
if name == "minecraft:brewing_stand" {
return Some(Self::Brewing(Box::new(crate::brewing::BrewingStand::new())));
}
+ if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
+ return Some(Self::Campfire(Box::default()));
+ }
let size = match name {
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => 27,
n if n.ends_with("copper_chest") => 27,
@@ -341,6 +418,8 @@ impl BlockEntity {
Self::Beehive(_) => "minecraft:beehive",
Self::CommandBlock(_) => "minecraft:command_block",
Self::Skull(_) => "minecraft:skull",
+ Self::DaylightDetector => "minecraft:daylight_detector",
+ Self::Campfire(_) => "minecraft:campfire",
}
}
pub fn contents(&self) -> Vec {
@@ -357,11 +436,13 @@ impl BlockEntity {
| Self::Vault(_)
| Self::Beehive(_)
| Self::CommandBlock(_)
- | Self::Skull(_) => Vec::new(),
+ | Self::Skull(_)
+ | Self::DaylightDetector => Vec::new(),
Self::Lectern(l) => l.book.iter().cloned().collect(),
Self::Sculk(_) => Vec::new(),
Self::Banner(_) => Vec::new(),
Self::Crafter(c) => c.items.iter().flatten().cloned().collect(),
+ Self::Campfire(c) => c.items.iter().flatten().cloned().collect(),
}
}
pub fn slots(&self) -> &[Option] {
@@ -375,14 +456,17 @@ impl BlockEntity {
| Self::Lectern(_)
| Self::Sculk(_)
| Self::Banner(_)
- | Self::Skull(_) => &[],
+ | Self::Skull(_)
+ | Self::DaylightDetector => &[],
Self::Crafter(c) => &c.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
| Self::Vault(_)
| Self::Beehive(_)
- | Self::CommandBlock(_) => &[],
+ | Self::CommandBlock(_)
+ // Not a Container (only Clearable): hoppers don't reach in.
+ | Self::Campfire(_) => &[],
}
}
pub fn slots_mut(&mut self) -> &mut [Option] {
@@ -396,14 +480,16 @@ impl BlockEntity {
| Self::Lectern(_)
| Self::Sculk(_)
| Self::Banner(_)
- | Self::Skull(_) => &mut [],
+ | Self::Skull(_)
+ | Self::DaylightDetector => &mut [],
Self::Crafter(c) => &mut c.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
| Self::Vault(_)
| Self::Beehive(_)
- | Self::CommandBlock(_) => &mut [],
+ | Self::CommandBlock(_)
+ | Self::Campfire(_) => &mut [],
}
}
}
@@ -638,7 +724,10 @@ impl Furnace {
Some(out) => out.count += recipe.result.count,
None => self.items[2] = Some(recipe.result.clone()),
}
- *self.recipes_used.entry(recipe.id.clone()).or_insert(0) += 1;
+ *self
+ .recipes_used
+ .entry(recipe_used_key(&recipe.id))
+ .or_insert(0) += 1;
if let Some(i) = self.items[0].as_mut() {
i.count -= 1;
if i.count == 0 {
@@ -692,6 +781,41 @@ mod tests {
use super::*;
use horizon_core::random::{LegacyRandom, RandomSource};
+ #[test]
+ fn campfire_slots_take_food_cook_it_and_cool_down() {
+ let mut c = Campfire::default();
+ let mut beef = ItemStack::new("minecraft:beef", 2);
+ // placeFood: the first free slot, one item, its recipe's time.
+ assert!(c.place_food(&mut beef, 600));
+ assert_eq!(beef.count, 1);
+ assert_eq!(c.items[0].as_ref().map(|s| s.count), Some(1));
+ assert_eq!((c.progress[0], c.total[0]), (0, 600));
+ let mut fish = ItemStack::new("minecraft:cod", 1);
+ assert!(c.place_food(&mut fish, 3));
+ assert_eq!(c.items[1].as_ref().map(|s| &*s.item), Some("minecraft:cod"));
+ // cookTick: the cod is done on its third tick, the beef keeps cooking.
+ assert!(c.cook_tick().is_empty());
+ assert!(c.cook_tick().is_empty());
+ let done = c.cook_tick();
+ assert_eq!(done.len(), 1);
+ assert_eq!((done[0].0, &*done[0].1.item), (1, "minecraft:cod"));
+ assert!(c.items[1].is_none());
+ assert_eq!(c.progress[0], 3);
+ // cooldownTick: two a tick, never below zero.
+ assert!(c.cooldown_tick());
+ assert_eq!(c.progress[0], 1);
+ assert!(c.cooldown_tick());
+ assert_eq!(c.progress[0], 0);
+ // Full: four slots, no fifth.
+ for _ in 0..3 {
+ let mut more = ItemStack::new("minecraft:potato", 1);
+ assert!(c.place_food(&mut more, 600));
+ }
+ let mut fifth = ItemStack::new("minecraft:potato", 1);
+ assert!(!c.place_food(&mut fifth, 600));
+ assert_eq!(fifth.count, 1);
+ }
+
/// A whole amount pays itself without a draw; a fraction is a chance of one more.
#[test]
fn create_experience_rounds_the_fraction_by_chance() {
diff --git a/crates/horizon-client/src/crafting.rs b/crates/horizon-client/src/crafting.rs
index 690383963..899ce90d9 100644
--- a/crates/horizon-client/src/crafting.rs
+++ b/crates/horizon-client/src/crafting.rs
@@ -1612,7 +1612,9 @@ impl Recipes {
}
/// The `experience` of the cooking recipe with this file name, and 0 for one this data
/// pack does not have (a save from another pack can name it).
+ /// `id` with or without the `minecraft:` namespace (a furnace keeps the full id).
pub fn cooking_experience(&self, id: &str) -> f32 {
+ let id = id.strip_prefix("minecraft:").unwrap_or(id);
self.cooking
.iter()
.find(|c| c.id == id)
diff --git a/crates/horizon-client/src/creative.rs b/crates/horizon-client/src/creative.rs
index 249e40b11..1a227db65 100644
--- a/crates/horizon-client/src/creative.rs
+++ b/crates/horizon-client/src/creative.rs
@@ -1,9 +1,14 @@
//! Creative inventory (CreativeModeInventoryScreen): vanilla's tabs and their
//! contents as built by the game's own display generators
//! (`creative-inventory.json`), a 9x5 scrolling grid and the search tab.
-use crate::{Result, items::ItemStack};
+use crate::{Result, items::ItemStack, ui::EditBox};
use serde_json::Value;
-use std::{collections::HashMap, fs, path::Path};
+use std::{
+ collections::HashMap,
+ fs,
+ path::Path,
+ sync::{Arc, Mutex},
+};
#[derive(Clone, Debug)]
pub struct Tab {
@@ -35,13 +40,22 @@ pub struct Creative {
pub scroll: f32,
/// The scrollbar is held (scrolling): dragging moves it.
pub scrolling: bool,
- pub search: String,
- /// When the search box took the focus (`EditBox.setFocused`): its
- /// cursor blinks from then.
- pub search_focused: std::time::Instant,
+ /// CreativeModeInventoryScreen's search box: an unbordered `EditBox` of 50.
+ pub search: EditBox,
/// The search tab's stacks, in its display order.
search_items: Vec,
search_index: Vec,
+ /// ItemPickerMenu.items: what the grid shows, filled when the tab or the
+ /// search changes (selectTab, refreshSearchResults), not on every frame.
+ shown: Mutex