Compare commits

..
Author SHA1 Message Date
rdbtCVSandGPT 6.1 Sol ad777a0249 Merge main into split/horizon-ctl-crate
Resolve the benchmark documentation conflict by preserving the extracted horizon-ctl build target and main's Python 3.11 prerequisite.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 20:27:05 +00:00
rdbtCVSandGPT 6.1 Sol 3533ab20f5 Merge main into split/horizon-ctl-crate
Preserve both new lockfile packages and combine the official-game and extracted horizon-ctl selections in the Discord harness.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 12:31:40 +00:00
Horizon agent 79cfbfc950 Merge remote-tracking branch 'origin/main' into split/horizon-ctl-crate
# Conflicts:
#	tools/test_friends_integration_parity.py
2026-11-09 20:31:47 +01:00
Horizon agent a82091736e Merge remote-tracking branch 'origin/main' into split/horizon-ctl-crate
# Conflicts:
#	.github/workflows/ci.yml
#	.github/workflows/game-build.yml
#	crates/horizon-client-lib/Cargo.toml
#	tools/ci/build-musl.sh
2026-11-09 18:57:38 +01:00
Horizon agentandClaude Opus 5.5 40637018fa Cargo.lock: the horizon-ctl package
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:38:53 +01:00
Horizon agentandClaude Opus 5.5 c48cdddd66 horizon-ctl: its own platform crate (layer plan M6)
The horizon-ctl binary and its MCP server leave horizon-client-lib for
crates/horizon-ctl, a platform crate on the client lib's control socket.
horizon-client-lib no longer depends on horizon-mcp. Pure move: the
sources are unchanged; build commands now name -p horizon-ctl.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:13:46 +01:00
262 changed files with 1506 additions and 8863 deletions

No files matched your search

+1 -1
View File
@@ -221,7 +221,7 @@ jobs:
Write-Output "Using $($clang.Source)"
& $clang.Source --version
- name: Build the game for native Windows ARM64 (the official game, as game-build.yml ships it)
run: cargo build --release --locked --target aarch64-pc-windows-msvc -p horizon-official --bin horizon-official -p horizon-client-lib --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
run: cargo build --release --locked --target aarch64-pc-windows-msvc -p horizon-official --bin horizon-official -p horizon-ctl --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Check the game's PE machine
run: python tools/check_pe_arch.py arm64 target/aarch64-pc-windows-msvc/release/horizon-official.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
- name: Check game DLL imports
+1 -1
View File
@@ -170,7 +170,7 @@ jobs:
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the official game, horizon-ctl, horizon-prepare and horizon-server
if: ${{ !matrix.musl }}
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-official --bin horizon-official -p horizon-client-lib --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-official --bin horizon-official -p horizon-ctl --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: The official game is the game players get, under its name
# horizon-official: horizon-client's game with Horizon's own parts plugged in (docs/layers.md, "How a
# Horizon crate plugs in"). Everything below, the launcher and players know it as horizon-client.
+13 -6
View File
@@ -22,6 +22,13 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
is the GitHub account the PR is opened from (`gh api user --jq .login`). Issues say "Filed by", reviews
"Review by". Every comment starts with `🤖 Agent:`. Write only as the person you work for: the lines on
this page that name Ludovic are for his own agents (account `ludovic111`), never for anyone else's.
- **Review only when asked, and decide.** Review a pull request only when its author or a maintainer asks.
Asked by a maintainer to review open pull requests in general, take them one at a time, oldest first, skipping
drafts, your person's own and those they already reviewed. Check that the change does what it says (bugs, edge
cases), what it could break, whether tests cover it, and whether its scope and description hold up. Report only
what you can point to with a `file:line`, each point marked Blocker (fix before merge), Important, Nit,
Suggestion, Question or Praise. Submit a decision, never a plain comment: `gh pr review --approve` when nothing
blocks, `--request-changes` when something does.
- **Attribution.** Commits end with a `Co-Authored-By:` line for the agent that wrote them, and PR bodies
with that agent's own "Generated with" line. Example, Ludovic's agents (Claude Code on `ludovic111`):
first line `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, commits
@@ -78,7 +85,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| Crate | Owns |
|---|---|
| `horizon-client` | Gameplay library (`src/lib.rs`: `game.rs` + `game/`, `entry.rs`) and thin executable (`src/main.rs`). Also the dedicated server (`--dedicated`). Re-exports `horizon-client-lib` for existing mod paths. |
| `horizon-client-lib` | Reusable client systems, `horizon-ctl`, probes and their tests. Its manifest points to the existing sources under `crates/horizon-client/`; the library root is `src/client_lib.rs`. It never depends on gameplay. |
| `horizon-client-lib` | Reusable client systems, the control socket, probes and their tests. Its manifest points to the existing sources under `crates/horizon-client/`; the library root is `src/client_lib.rs`. It never depends on gameplay. |
| `horizon-ctl` | `horizon-ctl`, the CLI over the control socket, and its MCP server (platform): `commands.rs` is the command table ([docs/agent-control.md](docs/agent-control.md)) |
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
@@ -128,8 +136,8 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
## Build and test fast
```sh
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests; paste its block in the PR
python3 tools/pr_ready.py --harnesses # also the affected harnesses (by default only when you change a harness, oracle, probe or the runner, as in CI)
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
python3 tools/pr_ready.py --quick # same without harnesses
cargo test -p horizon-client -p horizon-client-lib --lib --bins --tests # skips the examples (pr_ready does this); `--doc` separately
cargo test -p horizon-client-lib --lib mod_content::tests:: -- --ignored # the slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
@@ -138,8 +146,7 @@ python3 tools/parity.py --changed-files paths.txt --list # which harnesses cov
python3 tools/parity.py mob_ai sculk # named harnesses
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host)
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
python3 tools/lang_coverage.py --summary # one line per language: how many keys it carries and what it misses
python3 tools/lang_coverage.py --summary --show-keys # the same, then the keys behind the counts
python3 tools/lang_coverage.py --summary # the keys each language still misses, and its English leftovers
```
- Harnesses and the game need the vanilla data in `.local/` (`minecraft/`, `horizon-client/26.3`, `datagen/`,
@@ -211,7 +218,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
| Input | game.rs `fn window_event`; G: `play_keys.rs` (`fn key_pressed`), `play_input.rs` (`fn play_mouse`, toggled keys, the hotbar wheel); crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs`, `pad_craft.rs`, `pointer_capture.rs` (mouse look, pointer grab) |
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` |
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
| Agent control | L: `control.rs` (socket), `crates/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
+3 -4
View File
@@ -109,10 +109,9 @@ python3 tools/pr_ready.py --base upstream/main # or origin/main if you work on
```
It looks at what your branch changes and runs what matters: rustfmt, clippy with warnings denied, the tests of the
crates you touched (and the launcher's), generated files, `Cargo.lock` and the Python tests. Like CI, it runs the
vanilla harnesses only when your branch changes a harness, an oracle, a probe or the runner; otherwise it lists the
ones your change can affect, and main's parity run checks them after the merge. `--harnesses` runs them on your
machine anyway (it needs the vanilla data). It exits non-zero when something fails.
crates you touched (and the launcher's), generated files, `Cargo.lock`, the Python tests, and the vanilla harnesses
your change can affect. Without the vanilla data on your machine it lists those harnesses instead; CI runs them.
`--quick` skips the harnesses. It exits non-zero when something fails.
For the game workspace, Clippy checks all targets of the touched crates and their transitive workspace dependents,
including examples. The dependency scope includes optional, platform, build and development dependencies. Tests run
Generated
+11 -1
View File
@@ -1532,7 +1532,6 @@ dependencies = [
"horizon-hdr",
"horizon-hooks",
"horizon-items",
"horizon-mcp",
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
@@ -1650,6 +1649,17 @@ dependencies = [
"ureq",
]
[[package]]
name = "horizon-ctl"
version = "0.1.0"
dependencies = [
"horizon-client-lib",
"horizon-core",
"horizon-mcp",
"horizon-threads",
"serde_json",
]
[[package]]
name = "horizon-discord"
version = "0.1.0"
+2
View File
@@ -13,6 +13,8 @@ members = [
"crates/horizon-client-core",
"crates/horizon-client-input",
"crates/horizon-client-render",
# horizon-ctl and its MCP server (platform).
"crates/horizon-ctl",
"crates/horizon-cosmetics",
"crates/horizon-command",
"crates/horizon-items",
@@ -271,31 +271,15 @@ fn int_array(tag: Option<&Tag>) -> Option<&Vec<i32>> {
fn is_pos(tag: &Tag) -> bool {
matches!(tag, Tag::IntArray(v) if v.len() == 3)
}
/// The default `SignText`: four empty lines, black, not glowing.
fn empty_sign_side() -> Tag {
Tag::compound([
("messages", Tag::List(vec![string(""); 4])),
("color", string("black")),
("has_glowing_text", boolean(false)),
])
}
/// The loaded `SignText` of one side, or the empty text.
pub fn sign_side(tag: Option<&Tag>) -> Tag {
// `SignText.DIRECT_CODEC`: `messages` is a component list (an entry that
// fails is dropped, `ListCodec`'s partial result) cut to 4 lines by
// `Util.fixedSize`. Fewer than 4 valid lines fail the side, and
// `SignBlockEntity.loadAdditional` then loads an empty one; more keep the
// first 4 with the side's colour and glow.
let lines: Option<Vec<Tag>> = match tag.and_then(|t| t.get("messages")) {
Some(Tag::List(list)) => {
let lines: Vec<Tag> = list.iter().filter_map(component).take(4).collect();
(lines.len() == 4).then_some(lines)
}
_ => None,
};
let Some(messages) = lines else {
return empty_sign_side();
let empty = || string("");
let mut messages: Vec<Tag> = match tag.and_then(|t| t.get("messages")) {
Some(Tag::List(list)) => list.iter().filter_map(component).collect(),
_ => Vec::new(),
};
messages.resize_with(4, empty);
messages.truncate(4);
let mut out = vec![
("messages", Tag::List(messages)),
(
@@ -973,32 +957,6 @@ mod tests {
assert_eq!(structure.get("showboundingbox"), Some(&Tag::Byte(1)));
}
#[test]
fn sign_side_needs_exactly_four_messages() {
let ok = snbt("{messages:[\"a\",\"\",\"\",\"\"],color:\"red\",has_glowing_text:1b}");
let side = sign_side(Some(&ok));
assert_eq!(side.get("color"), Some(&string("red")));
// A fifth line, or a bad entry among five, leaves the first 4 valid
// lines with the colour (checked against the 26.3 codec).
for kept in [
"{messages:[\"a\",\"\",\"\",\"\",\"e\"],color:\"red\",has_glowing_text:1b}",
"{messages:[\"a\",1,\"\",\"\",\"\"],color:\"red\",has_glowing_text:1b}",
] {
let side = sign_side(Some(&snbt(kept)));
assert_eq!(side.get("messages"), ok.get("messages"), "{kept}");
assert_eq!(side.get("color"), Some(&string("red")), "{kept}");
}
for bad in [
"{color:\"red\",has_glowing_text:1b}",
"{messages:[\"a\",\"b\"],color:\"red\",has_glowing_text:1b}",
"{messages:[\"a\",\"\",\"\",1],color:\"red\",has_glowing_text:1b}",
] {
let side = sign_side(Some(&snbt(bad)));
assert_eq!(side, sign_side(None), "{bad}");
assert_eq!(side.get("color"), Some(&string("black")));
}
}
#[test]
fn canonical_is_idempotent() {
for id in TYPES.iter().chain(NEWEST.iter()) {
@@ -356,12 +356,6 @@ impl KeyBinds {
pub fn is_default(&self, name: &str) -> bool {
self.get(name) == Self::default_of(name)
}
/// `KeyBindsScreen.extractRenderState`: Reset All is active only while a
/// mapping differs from its default (`KeyMapping.isDefault`, the bound key
/// against `defaultKey`).
pub fn changed_from_defaults(&self) -> bool {
self.keys.keys().any(|name| !self.is_default(name))
}
pub fn set(&mut self, name: &str, key: &str) {
if let Some((n, _, _)) = all_mappings().find(|m| m.0 == name) {
self.keys.insert(n, key.to_owned());
@@ -651,22 +645,6 @@ mod tests {
assert_eq!(k.translate_all(KeyCode::Digit9), [Ok(KeyCode::Digit9)]);
assert_eq!(k.translate_all(KeyCode::F3), [Ok(KeyCode::F3)]);
}
/// `KeyBindsScreen.extractRenderState`: Reset All is active only while a
/// binding differs from its default.
#[test]
fn reset_all_is_active_only_after_a_binding_moves() {
let mut keys = KeyBinds::default();
assert!(!keys.changed_from_defaults());
keys.set("key.attack", "key.mouse.right");
assert!(keys.changed_from_defaults());
// Setting a mapping back to its own default leaves nothing to reset.
keys.set("key.attack", KeyBinds::default_of("key.attack"));
assert!(!keys.changed_from_defaults());
keys.set("key.jump", "key.keyboard.z");
keys.reset_all();
assert!(!keys.changed_from_defaults());
}
#[test]
fn display_names() {
assert_eq!(display_name("key.keyboard.w").plain(), "W");
-6
View File
@@ -22,8 +22,6 @@ horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# Extension points for the crates above the engine (docs/layers.md, "How a Horizon crate plugs in").
horizon-hooks = { path = "../horizon-hooks" }
# `horizon-ctl mcp`: the Model Context Protocol, shared with the launcher and the server.
horizon-mcp = { path = "../horizon-mcp" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-optifine = { path = "../horizon-optifine" }
horizon-threads = { path = "../horizon-threads" }
@@ -132,10 +130,6 @@ horizon-items = { path = "../horizon-items", features = ["test-support"] }
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
[[bin]]
name = "horizon-ctl"
path = "../horizon-client/src/bin/horizon-ctl/main.rs"
[[example]]
name = "chunk_load_bench"
path = "../horizon-client/examples/chunk_load_bench.rs"
@@ -15,7 +15,7 @@ use crate::{
font::{Font, Glyph},
gfx::mark_oit,
hud::Rect,
mesher::{CULL_BACK, Vertex, cull_back},
mesher::{CULL_BACK, Vertex},
models::load_visibility,
signs::{self, SignData, SignText},
text::{Style, Text},
@@ -3093,9 +3093,9 @@ impl BlockEntityRenderer {
}
}
// `DecoratedPotRenderer` submits every part with `entitySolid`, which culls
// back faces: the flat `top` and `bottom` planes show one side each (#15322).
// back faces: the flat `top` and `bottom` planes show one side each.
for vertex in &mut out[first..] {
vertex.normal = cull_back(vertex.normal);
vertex.normal = CULL_BACK;
}
}
Kind::EndPortal => {
@@ -4760,12 +4760,9 @@ mod tests {
28,
"the pot submits all of vanilla's quads"
);
// A part the shader shades keeps its normal (#15322): `w` alone says cull.
assert!(
verts
.iter()
.all(|v| v.normal[3] == crate::mesher::SHADED_CULL_W && v.normal[..3] != [0; 3]),
"entitySolid culls the pot's back faces and shades it by its normals"
verts.iter().all(|v| v.normal == CULL_BACK),
"entitySolid culls the pot's back faces"
);
}
+19 -164
View File
@@ -4,7 +4,6 @@
use horizon_core::paletted::{PalettedContainer, Strategy};
use horizon_world::nbt::{Compound, Tag};
use horizon_world::registry::BlockStateId;
use std::sync::atomic::{AtomicI16, AtomicU64, Ordering};
use std::sync::{Arc, OnceLock};
pub mod block_entity;
@@ -458,44 +457,6 @@ impl Light {
}
}
}
/// One of a chunk's heightmaps (`Chunk::surface`): the heights, built whole on the first read,
/// and the columns a write may have moved since, each found again on its next read. Reads share
/// the chunk (helper threads read the world): a stale column is found the same by any of them.
#[derive(Default)]
struct Heights {
heights: OnceLock<Box<[AtomicI16; 256]>>,
stale: [AtomicU64; 4],
}
impl Heights {
/// `Heightmap.update`: a write two or more below column `i`'s height can't move it; any
/// other marks the column.
fn write(&mut self, i: usize, y: i32) {
let Some(heights) = self.heights.get_mut() else {
return;
};
let stale = self.stale[i >> 6].get_mut();
if *stale & 1 << (i & 63) == 0 && y >= i32::from(*heights[i].get_mut()) - 1 {
*stale |= 1 << (i & 63);
}
}
}
impl Clone for Heights {
fn clone(&self) -> Self {
let copy = Self::default();
// A reader can refresh a height and then clear its stale bit while we clone. Copy the
// bits first: a stale snapshot will rescan; a clean one acquires the refreshed height.
// Copying heights first could keep an old height with a newly cleared bit.
for (to, from) in copy.stale.iter().zip(&self.stale) {
to.store(from.load(Ordering::Acquire), Ordering::Relaxed);
}
if let Some(heights) = self.heights.get() {
let _ = copy.heights.set(Box::new(std::array::from_fn(|i| {
AtomicI16::new(heights[i].load(Ordering::Relaxed))
})));
}
copy
}
}
#[inline]
fn column(x: i32, z: i32) -> usize {
((z & 15) << 4 | (x & 15)) as usize
@@ -613,10 +574,10 @@ pub struct Chunk {
pub sections: Vec<Arc<Section>>,
/// `Heightmap.Types.WORLD_SURFACE` per column (`z << 4 | x`): one above
/// 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 marks that column, found again on its next read.
surface: Heights,
/// `set` that can move a column's surface drops it.
surface: OnceLock<Box<[i16; 256]>>,
/// `Heightmap.Types.MOTION_BLOCKING` per column, kept like `surface`.
motion_blocking: Heights,
motion_blocking: OnceLock<Box<[i16; 256]>>,
pub lit: bool,
/// Light read from a save with `isLightOn`: no light job is needed.
pub light_loaded: bool,
@@ -657,8 +618,8 @@ impl Chunk {
z,
min_y: layout.min_y(),
sections: (0..layout.sections()).map(|_| empty.clone()).collect(),
surface: Heights::default(),
motion_blocking: Heights::default(),
surface: OnceLock::new(),
motion_blocking: OnceLock::new(),
lit: false,
light_loaded: false,
fresh: false,
@@ -737,7 +698,12 @@ impl Chunk {
// move it. Any other change rebuilds that map on the next read.
if previous != v {
for map in [&mut self.surface, &mut self.motion_blocking] {
map.write(column(x, z), y);
if map
.get()
.is_some_and(|heights| y >= i32::from(heights[column(x, z)]) - 1)
{
*map = OnceLock::new();
}
}
}
previous
@@ -748,55 +714,24 @@ impl Chunk {
let section = Arc::make_mut(&mut self.sections[s]);
section.blocks = Section::from_blocks(values);
section.compact();
self.surface = Heights::default();
self.motion_blocking = Heights::default();
self.surface = OnceLock::new();
self.motion_blocking = OnceLock::new();
}
/// `getHeight(Heightmap.Types.WORLD_SURFACE, x, z)` (`x`, `z` within the
/// 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 {
self.height(&self.surface, x, z, |v| !air(v))
let surface = self.surface.get_or_init(|| self.prime(|v| !air(v)));
i32::from(surface[column(x, z)])
}
/// `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 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 {
self.height(&self.motion_blocking, x, z, blocks)
}
/// One column's height in `map`: the whole map built on its first read, a column a write
/// marked found again down that column alone.
fn height(&self, map: &Heights, x: i32, z: i32, counts: impl Fn(u32) -> bool) -> i32 {
let heights = map.heights.get_or_init(|| {
let built = self.prime(&counts);
Box::new(std::array::from_fn(|i| AtomicI16::new(built[i])))
});
let i = column(x, z);
let (word, bit) = (i >> 6, 1u64 << (i & 63));
if map.stale[word].load(Ordering::Acquire) & bit != 0 {
let height = self.column_height(i, &counts);
heights[i].store(height, Ordering::Relaxed);
map.stale[word].fetch_and(!bit, Ordering::Release);
return i32::from(height);
}
i32::from(heights[i].load(Ordering::Relaxed))
}
/// [`Self::prime`] for column `i` alone.
fn column_height(&self, i: usize, counts: impl Fn(u32) -> bool) -> i16 {
for (s, section) in self.sections.iter().enumerate().rev() {
if section.blocks.uniform().is_some_and(|v| !counts(v)) {
continue;
}
let base = self.min_y + s as i32 * 16;
if let Some(dy) = (0..16)
.rev()
.find(|&dy| counts(section.blocks.get(dy << 8 | i)))
{
return (base + dy as i32 + 1) as i16;
}
}
self.min_y as i16
let heights = self.motion_blocking.get_or_init(|| self.prime(blocks));
i32::from(heights[column(x, z)])
}
/// `Heightmap.primeHeightmaps`: each section from the top, skipping
/// those of one state that doesn't count, until every column is found.
@@ -862,7 +797,7 @@ impl Chunk {
+ self.sections.capacity() * std::mem::size_of::<Arc<Section>>();
census.heightmap_bytes += [&self.surface, &self.motion_blocking]
.iter()
.filter(|map| map.heights.get().is_some())
.filter(|map| map.get().is_some())
.count()
* std::mem::size_of::<[i16; 256]>();
for section in &self.sections {
@@ -1085,89 +1020,9 @@ mod tests {
let before = chunk.world_surface(3, 3, is_air);
chunk.set(3, MIN_Y + HEIGHT, 3, stone);
chunk.set(3, before - 1, 3, chunk.get(3, before - 1, 3));
assert!(chunk.surface.heights.get().is_some());
assert!(chunk.surface.get().is_some());
assert_eq!(chunk.world_surface(3, 3, |_| unreachable!()), before);
}
/// A write two below a column's height can't move it, so the next read asks about no block;
/// a write at or above it (placing on top, clearing the top block) is found again on the next
/// read by looking down that column alone, and the other columns keep their heights.
#[test]
fn a_write_finds_its_own_column_again() {
// Each column holds its own state (16 + i), so a read that looks at another column's
// blocks shows up as a state that isn't air or this column's.
let state = |i: usize| 16 + i as u32;
let mut chunk = Chunk::empty(0, 0);
for i in 0..256 {
for y in MIN_Y..MIN_Y + 20 + (i as i32 % 7) {
chunk.set(i as i32 & 15, y, i as i32 >> 4, state(i));
}
}
let seen = std::cell::RefCell::new(Vec::new());
let surface = |c: &Chunk, i: usize| {
c.world_surface(i as i32 & 15, i as i32 >> 4, |v| {
seen.borrow_mut().push(v);
v == 0
})
};
let heights: Vec<i32> = (0..256).map(|i| surface(&chunk, i)).collect();
assert_eq!(heights[0], MIN_Y + 20);
assert_eq!(heights[6], MIN_Y + 26);
seen.borrow_mut().clear();
let (column, x, z) = (0x35, 5, 3);
let top = heights[column];
// Two below the height: the column keeps its map entry.
chunk.set(x, top - 2, z, 0);
assert_eq!(surface(&chunk, column), top);
assert!(seen.borrow().is_empty());
chunk.set(x, top - 2, z, state(column));
// At the height: placed on top, found again down this column only.
chunk.set(x, top, z, state(column));
assert_eq!(surface(&chunk, column), top + 1);
assert!(!seen.borrow().is_empty());
assert!(seen.borrow().iter().all(|&v| v == 0 || v == state(column)));
seen.borrow_mut().clear();
assert_eq!(surface(&chunk, column), top + 1);
for i in (0..256).filter(|&i| i != column) {
assert_eq!(surface(&chunk, i), heights[i]);
}
assert!(
seen.borrow().is_empty(),
"a read found again an untouched column"
);
// One below the height: the top block cleared, the height drops by one.
chunk.set(x, top, z, 0);
assert_eq!(surface(&chunk, column), top);
assert!(seen.borrow().iter().all(|&v| v == 0 || v == state(column)));
}
/// Chunk snapshots can be cloned while helper threads refresh a stale height. No snapshot
/// may retain the old height with its stale bit cleared, for either map.
#[test]
fn cloning_during_a_height_refresh_keeps_the_new_height() {
for _ in 0..4096 {
let mut chunk = Chunk::empty(0, 0);
chunk.set(0, MIN_Y + 4, 0, 1);
assert_eq!(chunk.world_surface(0, 0, |v| v == 0), MIN_Y + 5);
assert_eq!(chunk.motion_blocking(0, 0, |v| v != 0), MIN_Y + 5);
chunk.set(0, MIN_Y + 5, 0, 1);
let ready = std::sync::Barrier::new(2);
std::thread::scope(|scope| {
scope.spawn(|| {
ready.wait();
assert_eq!(chunk.world_surface(0, 0, |v| v == 0), MIN_Y + 6);
assert_eq!(chunk.motion_blocking(0, 0, |v| v != 0), MIN_Y + 6);
});
ready.wait();
for _ in 0..4 {
let snapshot = chunk.clone();
assert_eq!(snapshot.world_surface(0, 0, |v| v == 0), MIN_Y + 6);
assert_eq!(snapshot.motion_blocking(0, 0, |v| v != 0), MIN_Y + 6);
}
});
}
}
/// MOTION_BLOCKING ignores states that don't count (air, grass) and
/// follows writes as the surface map does, both maps kept at once.
#[test]
@@ -4,13 +4,11 @@
//!
//! The pack's GLSL (26.3's `#include` or the older `#moj_import`, with explicit locations or
//! without) is written again as the Vulkan-flavoured GLSL 450 naga reads:
//! - GLSL vanilla's GL compiler takes and naga doesn't is rewritten first ([`glsl_fixups`]: a
//! helper taking the `sampler2D` it reads, an array sized by its initializer only);
//! - includes are inlined, each file once (as `GlslPreprocessor` imported them), from `include`;
//! - vanilla's uniform blocks get the bindings of [`BLOCKS`], and each of [`SAMPLERS`] is split
//! into a texture and a sampler object, combined again where the sampler is named;
//! - the vertex inputs become globals the caller's [`Glue`] fills before the pack's `main`
//! (renamed `hz_main`) runs, and `gl_VertexID` and `gl_VertexIndex` read the glue's expression;
//! (renamed `hz_main`) runs, and `gl_VertexID` reads the glue's expression;
//! - varyings get locations by name across the stages, the fragment output location 0.
use std::collections::HashSet;
@@ -20,8 +18,6 @@ use naga::ShaderStage;
use naga::front::glsl::{Frontend, Options};
use naga::valid::{Capabilities, ValidationFlags, Validator};
mod glsl_fixups;
/// Vanilla's uniform blocks (`shaders/include/*.glsl`) and their bindings in set 0.
pub const BLOCKS: [(&str, u32); 4] = [
("Globals", 0),
@@ -47,7 +43,7 @@ pub struct Glue<'a> {
pub setup: &'a str,
/// Statements after it, on `gl_Position`.
pub finish: &'a str,
/// The expression the pack's `gl_VertexID` and `gl_VertexIndex` read.
/// The expression `gl_VertexID` reads.
pub vertex_id: &'a str,
}
@@ -68,8 +64,8 @@ pub fn translate_core(
glue: &Glue,
) -> Result<CoreProgram, String> {
const CORE: &str = "minecraft:shaders/core/";
let vertex_text = glsl_fixups::fixups(&strip_comments(&expand(vertex, CORE, include)?));
let fragment_text = glsl_fixups::fixups(&strip_comments(&expand(fragment, CORE, include)?));
let vertex_text = strip_comments(&expand(vertex, CORE, include)?);
let fragment_text = strip_comments(&expand(fragment, CORE, include)?);
let mut names = Names::default();
let vertex_code = rewrite(&vertex_text, ShaderStage::Vertex, glue, &mut names)
.map_err(|e| format!("vertex shader: {e}"))?;
@@ -341,8 +337,6 @@ fn rewrite(
writeln!(out, "{ty} {name};").unwrap();
}
}
// Where the pack's own code starts: the glue above it is left as it is.
let pack_start = out.len();
let mut outputs: Vec<&str> = Vec::new();
for source_line in text.lines() {
for line in declaration_lines(source_line) {
@@ -468,18 +462,11 @@ fn rewrite(
if !vertex {
return Ok(out);
}
// The pack's `gl_VertexID` and 26.3's `gl_VertexIndex` both mean vanilla's vertex number
// (four per quad, an `int`): both read the glue's expression, which for a pipeline that
// draws six vertices per quad turns its own `gl_VertexIndex` back into that number. Only the
// pack's code is rewritten, so the glue's expression keeps reading the real index.
let vertex_id = format!("({})", glue.vertex_id);
let pack = replace_ident(
&rename_main(&out[pack_start..]),
"gl_VertexIndex",
&vertex_id,
let mut code = replace_ident(
&rename_main(&out),
"gl_VertexID",
&format!("({})", glue.vertex_id),
);
let pack = replace_ident(&pack, "gl_VertexID", &vertex_id);
let mut code = format!("{}{pack}", &out[..pack_start]);
write!(
code,
"void main() {{\n{}\nhz_main();\n{}\n}}\n",
@@ -646,43 +633,6 @@ mod tests {
assert_eq!(program.fragment.entry_points.len(), 1);
}
/// A 26.3 pack's `gl_VertexIndex` means vanilla's vertex number, four per quad. The text
/// pipeline draws six per glyph, so the pack's code must read the glue's `hz_vertex_id()`
/// (as `gl_VertexID` does), never the raw index.
#[test]
fn a_packs_gl_vertex_index_reads_the_glues_vertex_number() {
let vsh = "#version 330\n#include <minecraft:dynamictransforms.glsl>\n#include <minecraft:projection.glsl>\n\
in vec3 Position;\nin vec4 Color;\nin vec2 UV0;\nout vec4 vertexColor;\nout vec2 texCoord0;\n\
void main() {\n int corner = gl_VertexIndex % 4;\n\
gl_Position = ProjMat * ModelViewMat * vec4(Position + vec3(float(corner), 0.0, 0.0), 1.0);\n\
vertexColor = Color;\n texCoord0 = UV0;\n}\n";
let program = translate_core(vsh, VANILLA_FSH, &["IS_GUI"], &include, &GLUE).unwrap();
let module = &program.vertex;
let named = |name: &str| {
module
.functions
.iter()
.find(|(_, f)| f.name.as_deref() == Some(name))
.unwrap_or_else(|| panic!("no {name}"))
};
let (vertex_id, _) = named("hz_vertex_id");
let (_, pack_main) = named("hz_main");
fn calls(block: &naga::Block, target: naga::Handle<naga::Function>) -> bool {
block.iter().any(|statement| match statement {
naga::Statement::Call { function, .. } => *function == target,
naga::Statement::Block(inner) => calls(inner, target),
naga::Statement::If { accept, reject, .. } => {
calls(accept, target) || calls(reject, target)
}
naga::Statement::Loop {
body, continuing, ..
} => calls(body, target) || calls(continuing, target),
_ => false,
})
}
assert!(calls(&pack_main.body, vertex_id));
}
/// A 26.2-style pack shader as a HUD pack writes it: `#moj_import`, no locations, a flat
/// varying, `gl_VertexID`, `texelFetch` and `textureSize` on the glyph atlas, `GameTime`.
#[test]
@@ -1,603 +0,0 @@
//! GLSL that vanilla's GL compiler takes and naga's front end doesn't (#9386), rewritten into
//! what both take:
//! - a function with `sampler2D` parameters is copied once per set of samplers its callers pass
//! (all of them a shader's global samplers), the copy reading those samplers by name;
//! - an unsized array (`const float[] w = float[](...)`, `float w[] = ...`) takes the size its
//! initializer gives.
/// The longest shader rewritten (vanilla's, includes expanded, are a few KiB).
const MAX_LEN: usize = 64 * 1024;
/// The bytes the rewrite may scan for calls (the whole text per call it rewrites): thousands of
/// calls leave the text as it was rather than freeze a load.
const SCAN_BUDGET: usize = 64 << 20;
/// `text` with the rewrites, where they apply; anything they can't follow is left as it was.
pub(super) fn fixups(text: &str) -> String {
if text.len() > MAX_LEN {
return text.to_owned();
}
size_unsized_arrays(&specialize_sampler_params(text))
}
fn is_ident(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_'
}
/// A name a sampler can have (`sampler2D ;` gives none, `t[2]` isn't one).
fn is_name(s: &str) -> bool {
s.chars().all(is_ident) && s.starts_with(|c: char| !c.is_ascii_digit()) && s != "sampler2D"
}
/// The identifier that ends right before byte `end` (whitespace skipped), and where it starts.
fn ident_before(text: &str, end: usize) -> Option<(usize, &str)> {
let trimmed = text[..end].trim_end();
let start = trimmed
.char_indices()
.rev()
.take_while(|(_, c)| is_ident(*c))
.last()?
.0;
let ident = &trimmed[start..];
(!ident.is_empty() && !ident.starts_with(|c: char| c.is_ascii_digit()))
.then_some((start, ident))
}
/// For each byte of `text`, the byte of the bracket closing the one opening there (counting
/// that kind of bracket only), or `None`: one pass, so a scan asking for many stays linear.
fn closings(text: &str) -> Vec<Option<usize>> {
let mut out = vec![None; text.len()];
let mut open: [Vec<usize>; 3] = Default::default();
for (i, b) in text.bytes().enumerate() {
let (kind, opens) = match b {
b'(' => (0, true),
b')' => (0, false),
b'[' => (1, true),
b']' => (1, false),
b'{' => (2, true),
b'}' => (2, false),
_ => continue,
};
if opens {
open[kind].push(i);
} else if let Some(at) = open[kind].pop() {
out[at] = Some(i);
}
}
out
}
/// The comma-separated parts of `list` at its own nesting level.
fn split_top(list: &str) -> Vec<&str> {
let mut parts = Vec::new();
let (mut depth, mut start) = (0i32, 0);
for (i, c) in list.char_indices() {
match c {
'(' | '[' | '{' => depth += 1,
')' | ']' | '}' => depth -= 1,
',' if depth == 0 => {
parts.push(&list[start..i]);
start = i + 1;
}
_ => {}
}
}
if !list[start..].trim().is_empty() || !parts.is_empty() {
parts.push(&list[start..]);
}
parts
}
/// A function at the file's top level whose parameters include a `sampler2D`.
struct SamplerFn {
name: String,
/// The header from its return type to its `{` (or `;` for a prototype), and the body after.
start: usize,
end: usize,
params: Vec<String>,
/// The indices of the `sampler2D` parameters and their names.
samplers: Vec<(usize, String)>,
/// `None` for a prototype.
body: Option<String>,
return_type: String,
}
fn sampler_functions(text: &str) -> Vec<SamplerFn> {
let closing = closings(text);
let mut out = Vec::new();
let mut depth = 0i32;
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'{' => depth += 1,
b'}' => depth -= 1,
b'(' if depth == 0 => {
if let (Some((name_at, name)), Some(close)) = (ident_before(text, i), closing[i])
&& let Some((type_at, return_type)) = ident_before(text, name_at)
{
let params: Vec<String> = split_top(&text[i + 1..close])
.into_iter()
.map(|p| p.trim().to_owned())
.collect();
let samplers: Vec<(usize, String)> = params
.iter()
.enumerate()
.filter(|(_, p)| p.split_whitespace().any(|w| w == "sampler2D"))
.map(|(n, p)| (n, p.split_whitespace().last().unwrap_or("").to_owned()))
.collect();
// One without a name is left for naga to refuse.
if !samplers.iter().all(|(_, name)| is_name(name)) {
i = close + 1;
continue;
}
let after = text[close + 1..].trim_start();
let next = close + 1 + (text[close + 1..].len() - after.len());
if !samplers.is_empty() && (after.starts_with('{') || after.starts_with(';')) {
let (end, body) = if after.starts_with('{') {
let Some(body_end) = closing[next] else {
break;
};
(body_end + 1, Some(text[next..=body_end].to_owned()))
} else {
(next + 1, None)
};
out.push(SamplerFn {
name: name.to_owned(),
start: type_at,
end,
params,
samplers,
body,
return_type: return_type.to_owned(),
});
i = end;
continue;
}
}
// Any other header: no other one sits in its parentheses.
if let Some(close) = closing[i] {
i = close + 1;
continue;
}
}
_ => {}
}
i += 1;
}
out
}
/// The calls to `name` outside `skip` (its own header): each call's span and arguments. The
/// bytes read are added to `scanned`.
fn calls<'a>(
scanned: &std::cell::Cell<usize>,
text: &'a str,
name: &str,
skip: &[(usize, usize)],
) -> Vec<(usize, usize, Vec<&'a str>)> {
scanned.set(scanned.get() + text.len());
let closing = closings(text);
let mut out = Vec::new();
let mut from = 0;
while let Some(found) = text[from..].find(name) {
let at = from + found;
from = at + name.len();
let before = text[..at].chars().next_back();
let rest = &text[at + name.len()..];
let open = at + name.len() + (rest.len() - rest.trim_start().len());
if before.is_some_and(is_ident)
|| !text[open..].starts_with('(')
|| skip.iter().any(|(s, e)| (*s..*e).contains(&at))
{
continue;
}
let Some(close) = closing[open] else {
continue;
};
scanned.set(scanned.get() + close - open);
out.push((at, close + 1, split_top(&text[open + 1..close])));
}
out
}
/// Every `from` standing as an identifier on its own, but not a member after a `.` (a
/// parameter `s` leaves the swizzle `c.s` alone).
fn replace_ident(text: &str, from: &str, to: &str) -> String {
if from.is_empty() {
return text.to_owned();
}
let mut out = String::with_capacity(text.len());
let mut done = 0;
while let Some(found) = text[done..].find(from) {
let at = done + found;
// Read in the whole text: a match right after another (`ss`) is part of it.
let before = text[..at].trim_end().chars().next_back();
let joined = text[..at].chars().next_back().is_some_and(is_ident);
let after = text[at + from.len()..].chars().next();
out.push_str(&text[done..at]);
if joined || before == Some('.') || after.is_some_and(is_ident) {
out.push_str(from);
} else {
out.push_str(to);
}
done = at + from.len();
}
out.push_str(&text[done..]);
out
}
/// A body with its sampler parameters reading `chosen`, all at once: a parameter named like
/// another's sampler (`mix2(sampler2D Sampler0, sampler2D Sampler1)` called with them swapped)
/// still reads its own.
fn read_samplers(body: &str, params: &[(usize, String)], chosen: &[String]) -> String {
let mut body = body.to_owned();
for (n, (_, param)) in params.iter().enumerate() {
body = replace_ident(&body, param, &format!("hz_sampler_param_{n}"));
}
for (n, sampler) in chosen.iter().enumerate().take(params.len()) {
body = replace_ident(&body, &format!("hz_sampler_param_{n}"), sampler);
}
body
}
/// The byte after `uniform sampler2D <name>;`, where the shader declares that sampler.
fn sampler_declaration_end(text: &str, name: &str) -> Option<usize> {
let mut from = 0;
while let Some(found) = text[from..].find("sampler2D") {
let at = from + found;
from = at + "sampler2D".len();
let before = text[..at].trim_end();
if !before.ends_with("uniform")
|| before[..before.len() - 7]
.chars()
.next_back()
.is_some_and(is_ident)
{
continue;
}
let rest = &text[from..];
let ident = rest.trim_start();
let len = ident.chars().take_while(|c| is_ident(*c)).count();
if &ident[..len] != name {
continue;
}
let after = ident[len..].trim_start();
if after.starts_with(';') {
return Some(text.len() - after.len() + 1);
}
}
None
}
/// The samplers a call passes for `f`'s `sampler2D` parameters, in order.
fn chosen_samplers(f: &SamplerFn, args: &[&str]) -> Vec<String> {
f.samplers
.iter()
.filter_map(|(n, _)| args.get(*n).map(|a| a.trim().to_owned()))
.collect()
}
/// One pass per function with `sampler2D` parameters, until none is left or one can't be
/// followed (a caller passes something that is no sampler's name).
fn specialize_sampler_params(original: &str) -> String {
let mut text = original.to_owned();
// Bytes scanned for calls so far, against [`SCAN_BUDGET`].
let scanned = std::cell::Cell::new(0usize);
let spent = || scanned.get() > SCAN_BUDGET;
for _ in 0..16 {
let functions = sampler_functions(&text);
// From the outside in: no call inside another function still to be copied.
let inside_another = |f: &SamplerFn, at: usize| {
functions
.iter()
.any(|g| g.name != f.name && g.body.is_some() && (g.start..g.end).contains(&at))
};
let spans_of = |f: &SamplerFn| -> Vec<(usize, usize)> {
functions
.iter()
.filter(|g| g.name == f.name)
.map(|g| (g.start, g.end))
.collect()
};
let Some(f) = functions.iter().find(|f| {
f.body.is_some()
&& !spent()
&& calls(&scanned, &text, &f.name, &spans_of(f))
.iter()
.all(|(at, ..)| !inside_another(f, *at))
}) else {
if spent() {
break;
}
// None left to copy.
return text;
};
let spans = spans_of(f);
let found = calls(&scanned, &text, &f.name, &spans);
// Each call's samplers must be plain names (a global's, or a later pass's parameter).
let mut variants: Vec<Vec<String>> = Vec::new();
let mut call_starts: Vec<usize> = Vec::new();
let mut followable = true;
for (at, _, args) in &found {
let chosen = chosen_samplers(f, args);
if chosen.len() != f.samplers.len() || !chosen.iter().all(|a| is_name(a)) {
followable = false;
break;
}
call_starts.push(*at);
if !variants.contains(&chosen) {
variants.push(chosen);
}
if variants.len() > 64 {
followable = false;
break;
}
}
// Overloads (`tap(sampler2D, vec2)` and `tap(sampler2D, vec3)`) each keep their own
// body; a call picks its samplers by position, so they must sit at the same places.
let same_places = functions.iter().filter(|g| g.name == f.name).all(|g| {
g.samplers
.iter()
.map(|s| s.0)
.eq(f.samplers.iter().map(|s| s.0))
});
if !followable || !same_places || spent() {
break;
}
// A body may call another overload of itself with its own sampler (vanilla's
// `sampleNearest` calls the six-argument one): once its sampler parameters read a
// sampler, those calls name that copy too, and their sets join the variants.
let rewrite_own_calls = |body: &str| -> Option<(String, Vec<Vec<String>>)> {
let mut body = body.to_owned();
let mut found = Vec::new();
let mut starts: Vec<usize> = calls(&scanned, &body, &f.name, &[])
.iter()
.map(|c| c.0)
.collect();
starts.sort_unstable_by(|a, b| b.cmp(a));
for start in starts {
if spent() {
return None;
}
let (end, chosen, with) = {
let (_, end, args) = calls(&scanned, &body, &f.name, &[])
.into_iter()
.find(|c| c.0 == start)?;
let chosen = chosen_samplers(f, &args);
let rest: Vec<&str> = args
.iter()
.enumerate()
.filter(|(n, _)| !f.samplers.iter().any(|(s, _)| s == n))
.map(|(_, a)| a.trim())
.collect();
let with = format!("{}_hz_{}({})", f.name, chosen.join("_"), rest.join(", "));
(end, chosen, with)
};
if chosen.len() != f.samplers.len() || !chosen.iter().all(|a| is_name(a)) {
return None;
}
found.push(chosen);
body.replace_range(start..end, &with);
}
Some((body, found))
};
let specialize = |g: &SamplerFn, chosen: &[String]| {
let body = read_samplers(g.body.as_deref().unwrap_or_default(), &g.samplers, chosen);
rewrite_own_calls(&body)
};
let same: Vec<&SamplerFn> = functions.iter().filter(|g| g.name == f.name).collect();
let mut next = 0;
while followable && next < variants.len() {
let chosen = variants[next].clone();
for g in same.iter().filter(|g| g.body.is_some()) {
match specialize(g, &chosen) {
Some((_, inner)) => {
for v in inner {
if !variants.contains(&v) {
variants.push(v);
}
}
}
None => followable = false,
}
}
next += 1;
followable &= variants.len() <= 64 && !spent();
}
if !followable {
break;
}
// One copy of an overload per set of samplers its callers pass, keeping its other
// parameters; a prototype becomes the copies' prototypes.
let signature = |g: &SamplerFn, chosen: &[String]| {
let params: Vec<&str> = g
.params
.iter()
.enumerate()
.filter(|(n, _)| !g.samplers.iter().any(|(s, _)| s == n))
.map(|(_, p)| p.as_str())
.collect();
format!(
"{} {}_hz_{}({})",
g.return_type,
g.name,
chosen.join("_"),
params.join(", ")
)
};
let copies_of = |g: &SamplerFn| -> String {
variants
.iter()
.map(|chosen| match &g.body {
Some(_) => {
let body = specialize(g, chosen).map(|(b, _)| b).unwrap_or_default();
format!("{} {body}\n", signature(g, chosen))
}
None => format!("{};\n", signature(g, chosen)),
})
.collect()
};
// Rebuild from the back: the calls, then the function's own spans (each prototype and
// definition becomes its copies). A call nested in another's arguments starts after it,
// so it is rewritten first, and the call around it is read again from the text as it is
// then. A copy reads its samplers by name, so definitions above their samplers'
// declaration (vanilla's `sample_lightmap.glsl` is included above `uniform sampler2D
// Sampler2;`) move after it, together and in their order, as long as no call comes first.
let first_call = call_starts.iter().copied().min().unwrap_or(usize::MAX);
let declared = variants
.iter()
.flatten()
.filter_map(|s| sampler_declaration_end(&text, s))
.max();
let mut edits: Vec<(usize, Option<(usize, String)>)> =
call_starts.into_iter().map(|at| (at, None)).collect();
let (mut moved, mut move_to) = (String::new(), None);
for g in &same {
match (
&g.body,
declared.filter(|&end| end > g.start && end <= first_call),
) {
(Some(_), Some(end)) => {
edits.push((g.start, Some((g.end, String::new()))));
moved.push_str(&copies_of(g));
move_to = Some(end);
}
_ => edits.push((g.start, Some((g.end, copies_of(g))))),
}
}
if let Some(end) = move_to {
edits.push((end, Some((end, format!("\n{moved}")))));
}
// Pack text is untrusted: edits that overlap, or off the text, leave it as it was.
let mut spans: Vec<(usize, usize)> = edits
.iter()
.filter_map(|(s, e)| e.as_ref().map(|(end, _)| (*s, *end)))
.collect();
spans.sort_unstable();
if spans.windows(2).any(|w| w[1].0 < w[0].1) {
break;
}
edits.sort_by_key(|(start, _)| std::cmp::Reverse(*start));
let mut sound = true;
for (start, edit) in edits {
let (end, with) = match edit {
Some(span) => span,
None => {
let found = calls(&scanned, &text, &f.name, &[])
.into_iter()
.find(|c| c.0 == start);
let Some((_, end, args)) = found else {
continue;
};
let rest: Vec<&str> = args
.iter()
.enumerate()
.filter(|(n, _)| !f.samplers.iter().any(|(s, _)| s == n))
.map(|(_, a)| a.trim())
.collect();
let copy = format!("{}_hz_{}", f.name, chosen_samplers(f, &args).join("_"));
(end, format!("{copy}({})", rest.join(", ")))
}
};
if start > end || text.get(start..end).is_none() || spent() {
sound = false;
break;
}
text.replace_range(start..end, &with);
}
if !sound {
break;
}
}
// One that couldn't be followed: the shader as written, never half rewritten.
original.to_owned()
}
/// Each array declared without a size that an initializer sizes: `T[] name = T[](a, b)` and
/// `T name[] = T[](a, b)` (or `T[2](...)`) are written with the size.
fn size_unsized_arrays(text: &str) -> String {
let mut text = text.to_owned();
let mut closing = closings(&text);
// Each array sized reads the text's brackets again: a few hundred are plenty.
let mut left = 256;
let mut from = 0;
while let Some(found) = text[from..].find('=') {
let eq = from + found;
from = eq + 1;
// `==`, `<=`, `>=`, `!=`, `+=` ... are no initializers.
let prev = text[..eq].chars().next_back();
let next = text[eq + 1..].chars().next();
if matches!(
prev,
Some('=' | '<' | '>' | '!' | '+' | '-' | '*' | '/' | '%' | '&' | '|' | '^')
) || next == Some('=')
{
continue;
}
// The constructor: `T [ n? ] (`.
let rest = &text[eq + 1..];
let ty_start = eq + 1 + (rest.len() - rest.trim_start().len());
let ty_len = text[ty_start..]
.chars()
.take_while(|c| is_ident(*c))
.count();
if ty_len == 0 {
continue;
}
let after_ty = &text[ty_start + ty_len..];
let bracket = ty_start + ty_len + (after_ty.len() - after_ty.trim_start().len());
if !text[bracket..].starts_with('[') {
continue;
}
let Some(bracket_end) = closing[bracket] else {
continue;
};
let after_bracket = &text[bracket_end + 1..];
let paren = bracket_end + 1 + (after_bracket.len() - after_bracket.trim_start().len());
if !text[paren..].starts_with('(') {
continue;
}
let Some(paren_end) = closing[paren] else {
continue;
};
// The declarator: `name []` or `T [] name`, between the statement's start and `=`.
// (Looked for on the line, at most 256 bytes back, so a long line stays linear.)
let mut back = eq.saturating_sub(256);
while !text.is_char_boundary(back) {
back += 1;
}
let statement_start = text[back..eq]
.rfind([';', '{', '}', '\n'])
.map_or(back, |i| back + i + 1);
let declarator = &text[statement_start..eq];
let Some(empty) = declarator.rfind('[') else {
continue;
};
let inside = &declarator[empty + 1..];
let Some(close) = inside.find(']') else {
continue;
};
if !inside[..close].trim().is_empty() {
continue;
}
let size = split_top(&text[paren + 1..paren_end]).len().to_string();
// Sized in the constructor too, where it isn't.
if text[bracket + 1..bracket_end].trim().is_empty() {
text.replace_range(bracket + 1..bracket_end, &size);
}
let at = statement_start + empty + 1;
text.replace_range(at..at + close, &size);
// The declarator is before `=`, so `=` moved by what its size changed.
from = eq + 1 + size.len() - close;
closing = closings(&text);
left -= 1;
if left == 0 {
break;
}
}
text
}
#[cfg(test)]
mod tests;
@@ -1,291 +0,0 @@
//! The rewrites on GLSL a pack writes, with the text each gives written out by hand.
use super::*;
#[test]
fn a_sampler_parameter_becomes_a_copy_per_sampler_passed() {
let text = "vec4 blur(sampler2D tex, vec2 uv);\n\
vec4 blur(sampler2D tex, vec2 uv) { return texture(tex, uv) + texture(tex, uv * 2.0); }\n\
void main() { vec4 a = blur(Sampler0, uv); vec4 b = blur( Sampler1 , uv + 1.0); vec4 c = blur(Sampler0, uv); }\n";
let out = specialize_sampler_params(text);
assert!(!out.contains("sampler2D"), "{out}");
assert!(out.contains(
"vec4 blur_hz_Sampler0(vec2 uv) { return texture(Sampler0, uv) + texture(Sampler0, uv * 2.0); }"
));
assert!(out.contains("vec4 blur_hz_Sampler1(vec2 uv)"));
assert!(out.contains("blur_hz_Sampler0(uv)"));
assert!(out.contains("blur_hz_Sampler1(uv + 1.0)"));
// One copy per sampler, not per call (and its prototype, from the prototype above).
assert_eq!(out.matches("vec4 blur_hz_Sampler0(vec2 uv) {").count(), 1);
assert_eq!(out.matches("vec4 blur_hz_Sampler0(vec2 uv);").count(), 1);
}
#[test]
fn a_sampler_passed_on_through_another_function_is_followed() {
let text = "vec4 inner(sampler2D t, vec2 uv) { return texture(t, uv); }\n\
vec4 outer(vec2 uv, sampler2D s) { return inner(s, uv); }\n\
void main() { vec4 a = outer(uv, Sampler2); }\n";
let out = specialize_sampler_params(text);
assert!(!out.contains("sampler2D"), "{out}");
assert!(out.contains("outer_hz_Sampler2(uv)"), "{out}");
assert!(out.contains("inner_hz_Sampler2(uv)"), "{out}");
}
/// A call in another call's arguments is rewritten before the call around it, which is
/// then read again: neither edit lands on the other's old text.
#[test]
fn a_call_nested_in_another_calls_arguments_is_rewritten_inside_out() {
let text = "vec2 tap(sampler2D s, vec2 uv) { return texture(s, uv).xy; }\n\
void main() { vec2 a = tap(Sampler0, tap(Sampler0, uv));\n\
vec2 b = tap( Sampler1 , tap( Sampler0 , uv ) ); }\n";
let out = specialize_sampler_params(text);
assert!(!out.contains("sampler2D") && !out.contains("tap("), "{out}");
assert!(
out.contains("vec2 a = tap_hz_Sampler0(tap_hz_Sampler0(uv));"),
"{out}"
);
assert!(
out.contains("vec2 b = tap_hz_Sampler1(tap_hz_Sampler0(uv)); }"),
"{out}"
);
assert!(out.contains("vec2 tap_hz_Sampler0(vec2 uv) { return texture(Sampler0, uv).xy; }"));
assert!(out.contains("vec2 tap_hz_Sampler1(vec2 uv) { return texture(Sampler1, uv).xy; }"));
}
/// Overloads that differ past their sampler each keep their own body in their copies.
#[test]
fn overloads_with_a_sampler_each_keep_their_own_body() {
let text = "vec4 tap(sampler2D s, vec2 p) { return texture(s, p); }\n\
vec4 tap(sampler2D s, vec3 p) { return texture(s, p.xy / p.z); }\n\
void main() { vec4 a = tap(Sampler0, uv); vec4 b = tap(Sampler0, vec3(uv, 2.0)); }\n";
let out = specialize_sampler_params(text);
assert!(!out.contains("sampler2D"), "{out}");
assert!(
out.contains("vec4 tap_hz_Sampler0(vec2 p) { return texture(Sampler0, p); }"),
"{out}"
);
assert!(
out.contains("vec4 tap_hz_Sampler0(vec3 p) { return texture(Sampler0, p.xy / p.z); }"),
"{out}"
);
assert_eq!(out.matches("vec4 tap_hz_Sampler0(").count(), 2, "{out}");
assert!(out.contains("vec4 a = tap_hz_Sampler0(uv);"), "{out}");
assert!(
out.contains("vec4 b = tap_hz_Sampler0(vec3(uv, 2.0));"),
"{out}"
);
}
/// A copy reads its samplers by name, so it goes after a sampler declared below the helper
/// (vanilla's world `text.vsh` includes `sample_lightmap.glsl` above `Sampler2`).
#[test]
fn a_copy_goes_after_its_samplers_declaration() {
let text = "vec4 tap(sampler2D s, vec2 uv) { return texture(s, uv); }\n\
uniform sampler2D Sampler2;\n\
void main() { vec4 c = tap(Sampler2, vec2(0.5)); }\n";
let out = specialize_sampler_params(text);
let declared = out
.find("uniform sampler2D Sampler2;")
.expect("declaration kept");
let copy = out
.find("vec4 tap_hz_Sampler2(vec2 uv)")
.expect("copy written");
let call = out
.find("tap_hz_Sampler2(vec2(0.5))")
.expect("call rewritten");
assert!(declared < copy && copy < call, "{out}");
assert_eq!(out.matches("tap_hz_Sampler2(vec2 uv)").count(), 1, "{out}");
assert!(!out.contains("tap(sampler2D"), "{out}");
}
/// A pack's text is untrusted: a sampler declared inside the helper itself (and text that
/// isn't ASCII) gives edits that would overlap, so the shader is left as it is for the
/// translator to refuse: never a panic, never a half-rewritten shader.
#[test]
fn a_sampler_declared_inside_the_helper_never_panics() {
let text = "vec4 tap(sampler2D s, vec2 uv) { é uniform sampler2D Sampler0; x return texture(s, uv); ééé }\n\
void main() { fragColor = tap(Sampler0, vec2(0.0)); }\n";
let out = specialize_sampler_params(text);
assert_eq!(out, text);
}
/// Vanilla's `texture_sampling.glsl` shape: one overload calls the other with its own
/// sampler. That call is rewritten too, and both copies come after the sampler's declaration
/// in their own order, the called one first.
#[test]
fn an_overload_passing_its_sampler_to_another_is_followed() {
let text = "vec4 sampleNearest(sampler2D source, vec2 uv, vec2 pixelSize, vec2 du, vec2 dv, vec2 texelSize) { return texture(source, uv + du + dv); }\n\
vec4 sampleNearest(sampler2D source, vec2 uv, vec2 pixelSize) { return sampleNearest(source, uv, pixelSize, dFdx(uv), dFdy(uv), pixelSize); }\n\
uniform sampler2D Sampler0;\n\
void main() { vec4 c = sampleNearest(Sampler0, uv, px); }\n";
let out = specialize_sampler_params(text);
assert!(
!out.contains("sampleNearest(") && !out.contains("sampler2D source"),
"{out}"
);
let declared = out
.find("uniform sampler2D Sampler0;")
.expect("declaration");
let six = out
.find("vec4 sampleNearest_hz_Sampler0(vec2 uv, vec2 pixelSize, vec2 du, vec2 dv, vec2 texelSize)")
.expect("six-argument copy");
let three = out
.find("vec4 sampleNearest_hz_Sampler0(vec2 uv, vec2 pixelSize) { return sampleNearest_hz_Sampler0(uv, pixelSize, dFdx(uv), dFdy(uv), pixelSize); }")
.expect("three-argument copy calling the six-argument one");
let call = out
.find("vec4 c = sampleNearest_hz_Sampler0(uv, px);")
.expect("call");
assert!(declared < six && six < three && three < call, "{out}");
}
/// A prototype above `main` with the definition below it becomes the copies' prototypes, so
/// the call still has a declaration above it.
#[test]
fn a_prototype_becomes_the_copies_prototypes() {
let text = "uniform sampler2D Sampler0;\n\
vec4 tap(sampler2D s, vec2 uv);\n\
void main() { vec4 c = tap(Sampler0, uv); }\n\
vec4 tap(sampler2D s, vec2 uv) { return texture(s, uv); }\n";
let out = specialize_sampler_params(text);
let prototype = out
.find("vec4 tap_hz_Sampler0(vec2 uv);")
.expect("prototype copy");
let call = out.find("tap_hz_Sampler0(uv); }").expect("call");
let definition = out
.find("vec4 tap_hz_Sampler0(vec2 uv) { return texture(Sampler0, uv); }")
.expect("definition copy");
assert!(prototype < call && call < definition, "{out}");
}
/// Parameters named like the samplers they are passed, swapped, still read their own; and a
/// parameter `s` leaves the swizzle `c.s` alone.
#[test]
fn sampler_parameters_are_replaced_at_once_and_not_as_members() {
let text = "uniform sampler2D Sampler0;\nuniform sampler2D Sampler1;\n\
vec4 mix2(sampler2D Sampler0, sampler2D Sampler1, vec2 uv) { return texture(Sampler0, uv) - texture(Sampler1, uv); }\n\
vec4 swz(sampler2D s, vec2 uv) { vec4 c = texture(s, uv); return vec4(c.s, c.t, 0.0, 1.0); }\n\
void main() { vec4 a = mix2(Sampler1, Sampler0, uv) + swz(Sampler0, uv); }\n";
let out = specialize_sampler_params(text);
assert!(
out.contains("{ return texture(Sampler1, uv) - texture(Sampler0, uv); }"),
"{out}"
);
assert!(
out.contains("{ vec4 c = texture(Sampler0, uv); return vec4(c.s, c.t, 0.0, 1.0); }"),
"{out}"
);
}
#[test]
fn a_parameter_matched_twice_in_one_identifier_stays_whole() {
assert_eq!(replace_ident("c.ss + ss * s", "s", "t"), "c.ss + ss * t");
assert_eq!(replace_ident("brightness(s)", "s", "t"), "brightness(t)");
}
/// `fsh` translated beside a vertex shader that passes its position on.
fn translate(fsh: &str) -> Result<crate::core_shader::CoreProgram, String> {
let vsh = "#version 330\nin vec3 Position;\nout vec2 texCoord0;\n\
void main() {\n gl_Position = vec4(Position, 1.0);\n texCoord0 = Position.xy;\n}\n";
let glue = crate::core_shader::Glue {
inputs: &[("Position", "vec3")],
prelude: "layout(location = 0) in vec3 hz_position;",
setup: "Position = hz_position;",
finish: "",
vertex_id: "int(gl_VertexIndex)",
};
crate::core_shader::translate_core(vsh, fsh, &[], &|_| None, &glue)
}
/// #9386 end to end: naga refuses a helper taking the sampler it reads, as GLSL vanilla's GL
/// compiler takes it; the pack's shader translates, the helper a copy reading `Sampler0`.
#[test]
fn a_helper_taking_its_sampler_translates() {
let fsh = "#version 330\nuniform sampler2D Sampler0;\nin vec2 texCoord0;\nout vec4 fragColor;\n\
vec4 tap(sampler2D tex, vec2 at, float w) { return texture(tex, at) * w; }\n\
void main() {\n fragColor = tap(Sampler0, texCoord0, 0.5) + tap(Sampler0, texCoord0 + 0.01, 0.5);\n}\n";
let program = translate(fsh).unwrap();
let names: Vec<_> = program
.fragment
.functions
.iter()
.filter_map(|(_, f)| f.name.as_deref())
.collect();
assert!(names.contains(&"tap_hz_Sampler0"), "{names:?}");
assert!(!names.contains(&"tap"), "{names:?}");
}
/// A sampler parameter with no name used to hand `replace_ident` an empty name to replace,
/// which it found at the same place forever, growing the text until the game ran out of memory.
#[test]
fn a_sampler_parameter_without_a_name_is_left_as_it_was() {
let text = "uniform sampler2D Sampler0;\n\
vec4 tap(sampler2D ;, vec2 uv) { return texture(Sampler0, uv); }\n\
void main() { vec4 c = tap(Sampler0, uv); }\n";
assert_eq!(fixups(text), text);
assert_eq!(replace_ident("abc", "", "x"), "abc");
}
/// Each call rewritten reads the whole text again: thousands of them stop at the scan budget,
/// the shader left as it was (never half rewritten) instead of freezing the load.
#[test]
fn thousands_of_calls_are_left_as_they_were() {
let shader = format!(
"uniform sampler2D S;\nvec4 t(sampler2D s) {{ return texture(s, vec2(0.0)); }}\n\
void main() {{ vec4 c; {}}}\n",
"c = t(S);".repeat(5000)
);
assert_eq!(fixups(&shader), shader);
// A few of them are rewritten.
let few = shader.replace(&"c = t(S);".repeat(5000), &"c = t(S);".repeat(3));
assert_eq!(
fixups(&few).matches("c = t_hz_S(").count(),
3,
"{}",
fixups(&few)
);
}
#[test]
fn unsized_arrays_take_their_initializers_size() {
let text = "const float[] w = float[](0.25, 0.5, 0.25);\n\
const vec2 offsets[] = vec2[](vec2(1.0, 0.0), vec2(0.0, 1.0));\n\
float k[] = float[4](1.0, 2.0, 3.0, 4.0);\n\
bool same = a == b;\n";
let out = size_unsized_arrays(text);
assert!(
out.contains("const float[3] w = float[3](0.25, 0.5, 0.25);"),
"{out}"
);
assert!(
out.contains("const vec2 offsets[2] = vec2[2](vec2(1.0, 0.0), vec2(0.0, 1.0));"),
"{out}"
);
assert!(
out.contains("float k[4] = float[4](1.0, 2.0, 3.0, 4.0);"),
"{out}"
);
assert!(out.contains("bool same = a == b;"));
}
#[test]
fn a_wide_character_after_a_sized_array_never_panics() {
// Ten values make the size two digits, which moves the `=` two bytes right: the scan
// went on from where it used to be, inside the `é`.
let text = "float w[]\u{e9}= float[](0.,1.,2.,3.,4.,5.,6.,7.,8.,9.);\n";
assert_eq!(
size_unsized_arrays(text),
"float w[10]\u{e9}= float[10](0.,1.,2.,3.,4.,5.,6.,7.,8.,9.);\n"
);
}
/// #9386 end to end: naga refuses an array sized by its initializer only.
#[test]
fn an_array_sized_by_its_initializer_translates() {
let fsh = "#version 330\nuniform sampler2D Sampler0;\nin vec2 texCoord0;\nout vec4 fragColor;\n\
const float[] weights = float[](0.25, 0.5, 0.25);\n\
void main() {\n vec4 sum = vec4(0.0);\n\
for (int i = 0; i < 3; i++) {\n sum += texture(Sampler0, texCoord0 + float(i) * 0.01) * weights[i];\n }\n\
fragColor = sum;\n}\n";
let program = translate(fsh);
assert!(program.is_ok(), "{:?}", program.err());
}
+6 -19
View File
@@ -439,33 +439,20 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
}
}
/// The report for a hung server tick, written by the watchdog thread (`ServerWatchdog`): the
/// stack is the server thread's, read from the watchdog (`horizon_threads::stack_dump`), as
/// vanilla's report carries the server thread's stack; without it, the details say why.
pub fn report_watchdog(
home: &Path,
side: &'static str,
message: String,
server_stack: Result<String, horizon_threads::stack_dump::NoStack>,
) -> Option<PathBuf> {
let mut details = system_details(side);
let stack = match server_stack {
Ok(stack) => stack_lines(&stack, false),
Err(why) => {
details.push(("Server thread stack".into(), why.to_string()));
String::new()
}
};
/// The report for a hung server tick, written by the watchdog thread
/// (`ServerWatchdog`): the stuck thread's stack is not available to Rust, so
/// the report carries none.
pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Option<PathBuf> {
deliver(
home,
Report {
time: Local::now(),
description: "Watching Server".into(),
error: format!("{message} (Watching Server)"),
stack,
stack: String::new(),
thread: thread_name(side),
side,
details,
details: system_details(side),
log: crate::logging::recent_lines(),
},
)
+1 -2
View File
@@ -155,8 +155,7 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
var back = c;
// Normal w: 0 keeps the baked colour, +-127 shades by the normal, and a small positive 1
// keeps the baked colour but culls back faces (entitySolid models such as the decorated pot).
// -126/127 shades like -127 and also culls (entityCutoutCull models such as the bat, and the
// entitySolid decorated pot).
// -126/127 shades like -127 and also culls (entityCutoutCull models such as the bat).
out.cull = select(0u, 1u, (normal.w > 0.0 && normal.w < 0.5) || (normal.w < 0.0 && normal.w > -0.996));
if abs(normal.w) >= 0.5 {
c = vec4(c.rgb * entity_light(normal.xyz, globals.time.y != 0.0), c.a);
+5 -48
View File
@@ -37,9 +37,7 @@
//! are drawn in a pass of their own between the phases, which reads phase
//! 1's depth as a texture and makes the same comparison in the shader: the
//! fragment's depth against the stored one, greater or equal passing. The
//! same fragments pass, so the same sections are seen. Adreno's Vulkan driver
//! takes that path too: with the early test it now and then leaves sections
//! out for a frame.
//! same fragments pass, so the same sections are seen.
use std::num::NonZeroU64;
use std::sync::{
Arc,
@@ -222,42 +220,17 @@ const RESULTS_USAGE: wgpu::BufferUsages =
const READBACK_USAGE: wgpu::BufferUsages =
wgpu::BufferUsages::MAP_READ.union(wgpu::BufferUsages::COPY_DST);
/// The first `n` values of a packed lookup in WGSL `source` (`fn name`, as cull.wgsl's
/// `box_triangle_corner`): four bits each, eight a word, its words in the order of its cases.
#[cfg(test)]
pub(super) fn packed_lookup(source: &str, name: &str, n: usize) -> Vec<u32> {
let body = &source[source.find(&format!("fn {name}(")).expect("the lookup")..];
let body = &body[..body.find("return").expect("its return")];
let words: Vec<u32> = body
.split(|c: char| !c.is_ascii_alphanumeric())
.filter_map(|t| t.strip_prefix("0x")?.strip_suffix('u'))
.map(|hex| u32::from_str_radix(hex, 16).expect("a hex word"))
.collect();
(0..n)
.map(|v| (words[v / 8] >> (v % 8 * 4)) & 0xf)
.collect()
}
/// Qualcomm's PCI vendor id, which wgpu reports for Adreno GPUs.
const ADRENO: u32 = 0x5143;
impl Cull {
/// The device feature culling needs, where the adapter has it.
pub fn features(adapter: &wgpu::Adapter) -> wgpu::Features {
adapter.features() & wgpu::Features::SHADER_EARLY_DEPTH_TEST
}
/// Where the device can run the test, if it can: Vulkan with early
/// depth tests (Adreno's in the shader), or Metal and DirectX 12 with the
/// test in the shader, all drawing an indirect multi-draw's entries with
/// the instance counts the GPU wrote, with fragment shaders that write
/// storage.
/// depth tests, or Metal and DirectX 12 with the test in the shader, all
/// drawing an indirect multi-draw's entries with the instance counts the
/// GPU wrote, with fragment shaders that write storage.
pub fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> Option<DepthTest> {
let info = adapter.get_info();
let depth_test = match info.backend {
// Adreno's Vulkan driver (the AYANEO Pocket DS's Adreno 740, build 0676.41) now and
// then leaves sections out with the early test, the world flashing black or
// see-through as a block breaks; the test in the shader, as on Metal, holds.
wgpu::Backend::Vulkan if info.vendor == ADRENO => DepthTest::InShader,
let depth_test = match adapter.get_info().backend {
wgpu::Backend::Vulkan | wgpu::Backend::Gl
if device
.features()
@@ -799,20 +772,4 @@ mod tests {
.unwrap_or_else(|e| panic!("{depth_test:?}: {e:?}"));
}
}
/// The box's 12 triangles, as cull.wgsl and gpu_occlusion.wgsl draw them: two per face,
/// counter-clockwise seen from outside, corners by bit (x 1, y 2, z 4).
#[test]
fn the_packed_box_corners_are_the_box_triangles() {
let triangles = [
0, 6, 2, 0, 4, 6, 1, 3, 7, 1, 7, 5, 0, 1, 5, 0, 5, 4, 2, 7, 3, 2, 6, 7, 0, 3, 1, 0, 2,
3, 4, 5, 7, 4, 7, 6,
];
for source in [
include_str!("cull.wgsl"),
include_str!("gpu_occlusion.wgsl"),
] {
assert_eq!(packed_lookup(source, "box_triangle_corner", 36), triangles);
}
}
}
+6 -16
View File
@@ -34,21 +34,11 @@ struct Test {
@group(0) @binding(5) var<uniform> params: Params;
// The corners (bit 0: x, bit 1: y, bit 2: z) of the box's 12 triangles,
// counter-clockwise seen from outside: 0 6 2, 0 4 6, 1 3 7, 1 7 5, 0 1 5, 0 5 4,
// 2 7 3, 2 6 7, 0 3 1, 0 2 3, 4 5 7, 4 7 6.
// Packed four bits each, eight a word, read through a switch: a constant array indexed by the
// vertex would be a private array, which Adreno's Vulkan compiler (build 0676.41) fails to link.
fn box_triangle_corner(v: u32) -> u32 {
var word: u32;
switch v / 8u {
case 0u: { word = 0x31640260u; }
case 1u: { word = 0x05105717u; }
case 2u: { word = 0x76237245u; }
case 3u: { word = 0x54320130u; }
default: { word = 0x00006747u; }
}
return (word >> (v % 8u * 4u)) & 0xfu;
}
// counter-clockwise seen from outside.
const CORNERS = array<u32, 36>(
0u, 6u, 2u, 0u, 4u, 6u, 1u, 3u, 7u, 1u, 7u, 5u, 0u, 1u, 5u, 0u, 5u, 4u,
2u, 7u, 3u, 2u, 6u, 7u, 0u, 3u, 1u, 0u, 2u, 3u, 4u, 5u, 7u, 4u, 7u, 6u,
);
struct Box {
@builtin(position) position: vec4<f32>,
@@ -105,7 +95,7 @@ fn vs_box(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box
out.position = select(vec4(0.0, 0.0, 0.0, 1.0), vec4(p, 1.0, 1.0), v < 3u);
return out;
}
out.position = globals.view_proj * vec4(corner(origin, low, high, box_triangle_corner(v)), 1.0);
out.position = globals.view_proj * vec4(corner(origin, low, high, CORNERS[v]), 1.0);
return out;
}
@@ -9,20 +9,11 @@
@group(0) @binding(12) var<storage, read> box_sections: array<Section>;
// The corners (bit 0: x, bit 1: y, bit 2: z) of the box's 12 triangles,
// counter-clockwise seen from outside (cull.wgsl's `box_triangle_corner`).
// Packed four bits each, eight a word, read through a switch: a constant array indexed by the
// vertex would be a private array, which Adreno's Vulkan compiler (build 0676.41) fails to link.
fn box_triangle_corner(v: u32) -> u32 {
var word: u32;
switch v / 8u {
case 0u: { word = 0x31640260u; }
case 1u: { word = 0x05105717u; }
case 2u: { word = 0x76237245u; }
case 3u: { word = 0x54320130u; }
default: { word = 0x00006747u; }
}
return (word >> (v % 8u * 4u)) & 0xfu;
}
// counter-clockwise seen from outside (cull.wgsl's).
const CORNERS = array<u32, 36>(
0u, 6u, 2u, 0u, 4u, 6u, 1u, 3u, 7u, 1u, 7u, 5u, 0u, 1u, 5u, 0u, 5u, 4u,
2u, 7u, 3u, 2u, 6u, 7u, 0u, 3u, 1u, 0u, 2u, 3u, 4u, 5u, 7u, 4u, 7u, 6u,
);
struct BoxOut {
@builtin(position) position: vec4<f32>,
@@ -35,7 +26,7 @@ fn vs_box(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box
let o = bitcast<vec3<f32>>(box_origins[id].xyz);
var out: BoxOut;
out.id = id;
out.position = params.view_proj * vec4(box_corner(o, box_sections[id].a.z, box_triangle_corner(v)), 1.0);
out.position = params.view_proj * vec4(box_corner(o, box_sections[id].a.z, CORNERS[v]), 1.0);
return out;
}
@@ -42,8 +42,6 @@ const DISTANT_SQR: f64 = 3600.0;
const SECTION_DIAGONAL: f64 = 28.0;
/// In `Graph::rays`: the line from this corner stays in reached sections.
const SEES: u8 = u8::MAX;
/// Edits kept before a full resend.
const EDIT_CAP: usize = 1 << 16;
/// VisGraph connectivity per column, one entry per section (bottom first).
/// A missing column or section connects every face.
@@ -613,8 +611,6 @@ pub struct Occlusion {
queue: VecDeque<u32>,
/// Changes whenever `contains` may answer differently (`generation`).
generation: u64,
/// Set when edits were dropped; the next search resends every column.
resync: bool,
}
impl Occlusion {
@@ -635,7 +631,6 @@ impl Occlusion {
vis: Vec::new(),
queue: VecDeque::new(),
generation: next_generation(),
resync: false,
}
}
pub fn set(&mut self, key: SectionKey, vis: u64) {
@@ -644,35 +639,13 @@ impl Occlusion {
}
}
fn changed(&mut self, key: SectionKey, vis: u64) {
self.dirty = true;
if self.edits.len() >= EDIT_CAP || self.changed.len() >= EDIT_CAP {
// nobody drains these with cave culling off
self.resync = true;
self.edits.clear();
self.changed.clear();
self.changed_late.clear();
}
if self.resync {
return;
}
self.edits.push(Edit::Set(key, vis));
self.dirty = true;
self.changed.push(key);
if self.running.is_some() {
self.changed_late.push(key);
}
}
fn resync_edits(&mut self) {
self.resync = false;
self.edits.clear();
self.edits.push(Edit::Clear);
for (&(x, z), column) in &self.columns.map {
for (y, &vis) in column.iter().enumerate() {
if vis != ALL_VISIBLE {
self.edits.push(Edit::Set((x, y as i32, z), vis));
}
}
}
}
/// Every section of the column `x`, `z` goes (sees through every face).
pub fn remove_column(&mut self, x: i32, z: i32) {
let Some(column) = self.columns.map.remove(&(x, z)) else {
@@ -701,7 +674,6 @@ impl Occlusion {
self.columns.map.clear();
self.edits.clear();
self.edits.push(Edit::Clear);
self.resync = false;
self.dirty = true;
// Another world: the old graph says nothing about it. Without one, every
// section was visible already, and stays so.
@@ -797,9 +769,6 @@ impl Occlusion {
let from = self.columns.section_of(camera);
self.dirty = false;
self.searched = Some((from, radius));
if self.resync {
self.resync_edits();
}
let graph = std::mem::take(&mut self.spare);
if let Some(worker) = &self.worker {
let job = Job {
@@ -1824,36 +1793,4 @@ mod tests {
assert_ne!(answers(&o, around, spread + radius), walled.1);
assert_ne!(o.generation(), walled.0, "the graph went");
}
#[test]
fn edit_queue_stays_bounded_without_searches() {
let mut o = Occlusion::new(0, 4);
let total = (1usize << 16) + 5000;
for i in 0..total {
o.set(((i % 700) as i32, 1, (i / 700) as i32), 1 + (i as u64 % 5));
}
assert!(o.edits.len() <= 1 << 16, "{} edits queued", o.edits.len());
assert!(
o.changed.len() <= 1 << 16,
"{} changed queued",
o.changed.len()
);
let mut fresh = Occlusion::new(0, 4);
for (&(x, z), column) in &o.columns.map {
for (y, &vis) in column.iter().enumerate() {
fresh.set((x, y as i32, z), vis);
}
}
let camera = at((0, 1, 0));
for _ in 0..2 {
o.settle(camera, 6);
fresh.settle(camera, 6);
}
assert_eq!(
reached(&o),
reached(&fresh),
"the worker saw the same world"
);
}
}
@@ -5,7 +5,6 @@
use std::collections::BTreeMap;
use horizon_shaders::{BufferObject, CustomImage, Format, Kind};
use wgpu::util::DeviceExt;
pub(super) struct PackImage {
pub(super) def: CustomImage,
@@ -469,33 +468,19 @@ impl Buffers {
.iter()
.map(|(binding, o)| {
let least = self.minimums.get(binding).copied().unwrap_or(0);
let wanted = buffer_size(o, size, least);
let wanted = buffer_size(*o, size, least);
if wanted > largest {
log::warn!(
"shader pack: bufferObject.{binding} wants {wanted} bytes, this device \
binds at most {largest}"
);
}
let size = wanted.min(largest);
// A buffer the pack initializes from a file starts with the file's bytes, the
// rest of it zero.
match o.initial_data() {
Some(data) => {
let mut contents = data.to_vec();
contents.resize(size as usize, 0);
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Shader pack buffer"),
contents: &contents,
usage: wgpu::BufferUsages::STORAGE,
})
}
None => device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack buffer"),
size,
usage: wgpu::BufferUsages::STORAGE,
mapped_at_creation: false,
}),
}
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack buffer"),
size: wanted.min(largest),
usage: wgpu::BufferUsages::STORAGE,
mapped_at_creation: false,
})
})
.collect();
let entries: Vec<_> = self
@@ -524,7 +509,7 @@ impl Buffers {
/// The bytes a storage buffer is made with for a render size of `render`: what the pack
/// declares, or more when a shader binds it as a larger block (`least`), which wgpu needs where
/// OpenGL binds the smaller buffer; a multiple of 4.
fn buffer_size(object: &BufferObject, render: [u32; 2], least: u64) -> u64 {
fn buffer_size(object: BufferObject, render: [u32; 2], least: u64) -> u64 {
object.size(render).max(least).next_multiple_of(4)
}
@@ -627,18 +612,18 @@ mod tests {
/// bound as a 48-byte block failed wgpu's validation, #7178); a larger one keeps its size.
#[test]
fn a_buffer_is_at_least_its_shaders_block() {
assert_eq!(buffer_size(&BufferObject::Bytes(40), [1, 1], 48), 48);
assert_eq!(buffer_size(BufferObject::Bytes(40), [1, 1], 48), 48);
assert_eq!(
buffer_size(&BufferObject::Bytes(40980), [1, 1], 40992),
buffer_size(BufferObject::Bytes(40980), [1, 1], 40992),
40992
);
assert_eq!(buffer_size(&BufferObject::Bytes(100), [1, 1], 48), 100);
assert_eq!(buffer_size(&BufferObject::Bytes(41), [1, 1], 0), 44);
assert_eq!(buffer_size(BufferObject::Bytes(100), [1, 1], 48), 100);
assert_eq!(buffer_size(BufferObject::Bytes(41), [1, 1], 0), 44);
let per_pixel = BufferObject::PerPixel {
bytes: 4,
scale: [0.5, 0.5],
};
assert_eq!(buffer_size(&per_pixel, [10, 10], 64), 100);
assert_eq!(buffer_size(&per_pixel, [4, 4], 64), 64);
assert_eq!(buffer_size(per_pixel, [10, 10], 64), 100);
assert_eq!(buffer_size(per_pixel, [4, 4], 64), 64);
}
}
@@ -399,20 +399,6 @@ impl Rotation {
#[cfg(test)]
mod tests {
use super::*;
/// CubeMap's cube: its corners' indices, six faces of two triangles.
#[test]
fn the_packed_cube_corners_are_cubemaps_indices() {
let indices = [
4, 7, 6, 4, 6, 5, 5, 6, 2, 5, 2, 1, 1, 2, 3, 1, 3, 0, 0, 3, 7, 0, 7, 4, 7, 3, 2, 7, 2,
6, 0, 4, 5, 0, 5, 1,
];
let source = include_str!("panorama.wgsl");
assert_eq!(
super::super::cull::packed_lookup(source, "cube_corner", 36),
indices
);
}
use std::{
fs,
sync::atomic::{AtomicU32, Ordering},
@@ -8,27 +8,19 @@ struct Out {
@builtin(position) clip: vec4<f32>,
@location(0) direction: vec3<f32>,
}
// The cube's corner of each of its 36 vertices (CubeMap's indices): 4 7 6, 4 6 5, 5 6 2,
// 5 2 1, 1 2 3, 1 3 0, 0 3 7, 0 7 4, 7 3 2, 7 2 6, 0 4 5, 0 5 1.
// Packed four bits each, eight a word, read through a switch: a constant array indexed by the
// vertex would be a private array, which Adreno's Vulkan compiler (build 0676.41) fails to link.
fn cube_corner(v: u32) -> u32 {
var word: u32;
switch v / 8u {
case 0u: { word = 0x65564674u; }
case 1u: { word = 0x13211252u; }
case 2u: { word = 0x47073003u; }
case 3u: { word = 0x40627237u; }
default: { word = 0x00001505u; }
}
return (word >> (v % 8u * 4u)) & 0xfu;
}
@vertex fn vs_panorama(@builtin(vertex_index) index: u32) -> Out {
// CubeMap's corners: -1 or 1 on each axis, x 1 at corners 1, 2, 5 and 6, y at 2, 3, 6
// and 7, z at 4 to 7.
let c = cube_corner(index);
let high = vec3((c ^ (c >> 1u)) & 1u, c & 2u, c & 4u) != vec3(0u);
let position = select(vec3(-1.0), vec3(1.0), high);
let corners = array<vec3<f32>, 8>(
vec3(-1.0, -1.0, -1.0), vec3(1.0, -1.0, -1.0),
vec3(1.0, 1.0, -1.0), vec3(-1.0, 1.0, -1.0),
vec3(-1.0, -1.0, 1.0), vec3(1.0, -1.0, 1.0),
vec3(1.0, 1.0, 1.0), vec3(-1.0, 1.0, 1.0),
);
let indices = array<u32, 36>(
4, 7, 6, 4, 6, 5, 5, 6, 2, 5, 2, 1,
1, 2, 3, 1, 3, 0, 0, 3, 7, 0, 7, 4,
7, 3, 2, 7, 2, 6, 0, 4, 5, 0, 5, 1,
);
let position = corners[indices[index]];
var out: Out;
out.clip = projection.view_proj * vec4(position, 1.0);
out.direction = position;
+20 -145
View File
@@ -12,11 +12,8 @@ use std::{
};
use wgpu::util::DeviceExt;
use model_reducing::reducible_model_sprites;
mod animate;
pub mod cit_glint;
mod model_reducing;
mod pack_text;
pub mod pictures;
pub(crate) mod sprite_modifiers;
@@ -1060,6 +1057,25 @@ impl HudAtlas {
blinks: Default::default(),
}
}
/// `layout`, but when `extras` (Custom Item Textures) make the images more than `max`
/// holds, without them: they are left out, not the packs.
fn layout_leaving_out(
images: &mut Vec<PackedImage>,
animated: &mut Vec<String>,
extras: &HashSet<String>,
max: u32,
) -> Result<(u32, Vec<(u32, u32)>, u32)> {
match Self::layout(images, max) {
Err(e) if !extras.is_empty() => {
images.retain(|i| !extras.contains(&i.0));
animated.retain(|n| !extras.contains(n));
let layout = Self::layout(images, max)?;
log::warn!("left out the custom item textures: {e}");
Ok(layout)
}
layout => layout,
}
}
/// Shelf placements of `images` (tallest first) in the narrowest atlas
/// width from 4096 up that keeps the atlas, glyph rows included, within
/// `max` texels a side; and the first row free for glyphs. High-resolution
@@ -1444,20 +1460,7 @@ impl HudAtlas {
}
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
let (width, placements, glyph_region) =
Self::layout_leaving_out(&mut images, &mut animated, &cit_names, MAX_ATLAS_SIZE)
.or_else(|error| -> Result<_> {
let models = reducible_model_sprites(root, &images, &bordered);
let layout = Self::layout_reducing_models(
&mut images,
&mut gui_sizes,
&models,
MAX_ATLAS_SIZE,
)?;
log::warn!(
"reduced HUD model texture resolution to keep the resource pack: {error}"
);
Ok(layout)
})?;
Self::layout_leaving_out(&mut images, &mut animated, &cit_names, MAX_ATLAS_SIZE)?;
// A multiple of 1024 rather than a power of two: the default resources
// need 4096x5120 (8192 rows before), Faithful 32x 4096x12288 (16384).
let height = (glyph_region + GLYPH_ROWS).next_multiple_of(1024);
@@ -3775,47 +3778,6 @@ mod tests {
assert_eq!(banner.texels((atlas.width, atlas.height)), (16, 32));
}
#[test]
fn a_root_single_sprite_strip_taller_than_the_atlas_packs_one_frame() {
let root = animated_root();
let atlases = root.join("assets/minecraft/atlases");
fs::create_dir_all(&atlases).unwrap();
fs::write(
atlases.join("items.json"),
r#"{"sources":[{"type":"single","resource":"nexo:abc123"},
{"type":"single","resource":"nexo:still"}]}"#,
)
.unwrap();
// Plugin-generated names need no item/ prefix. Keep the production
// strip's height, but use narrow frames to keep this fixture cheap.
let textures = root.join("assets/nexo/textures");
fs::create_dir_all(&textures).unwrap();
write_strip(
&textures.join("abc123.png"),
16,
32768,
&[[255, 0, 0, 255], [0, 0, 255, 255]],
Some(r#"{"animation":{"frames":[1024,0],"frametime":3}}"#),
);
write_strip(&textures.join("still.png"), 16, 32, &[[7, 8, 9, 255]], None);
let atlas = HudAtlas::build(&root).unwrap();
let _ = fs::remove_dir_all(&root);
let rect = atlas.sprites["nexo:abc123"];
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16));
assert_eq!(texel_at(&atlas, rect, 0, 15), [0, 0, 255, 255]);
let (_, animation) = atlas
.sprite_animations
.iter()
.find(|(r, _)| r.u0 == rect.u0 && r.v0 == rect.v0)
.expect("the root-level single sprite animates");
assert_eq!(animation.frame(0), 1024);
assert_eq!(animation.frame(3), 0);
assert_eq!(animation.frame_count(), 2048);
assert_eq!(
atlas.sprites["nexo:still"].texels((atlas.width, atlas.height)),
(16, 32)
);
}
#[test]
fn an_animated_gui_sprite_packs_its_first_frame_and_animates() {
let root =
std::env::temp_dir().join(format!("horizon-atlas-gui-anim-{}", std::process::id()));
@@ -4222,93 +4184,6 @@ texture=wide",
assert_inside(&images, width, &placements, free);
}
#[test]
fn model_resolution_fallback_protects_font_sources_and_gui_aliases() {
let root = animated_root();
write_png(&root, "item/font_sheet", 32, 32);
write_png(&root, "gui/panel", 32, 32);
write_png(&root, "hash", 32, 32);
let font = root.join("assets/minecraft/font/default.json");
fs::create_dir_all(font.parent().unwrap()).unwrap();
fs::write(
font,
r#"{"providers":[{"type":"bitmap","file":"minecraft:item/font_sheet.png","ascent":7,"chars":["a"]}]}"#,
).unwrap();
let atlases = root.join("assets/minecraft/atlases");
fs::create_dir_all(&atlases).unwrap();
fs::write(
atlases.join("items.json"),
r#"{"sources":[
{"type":"single","resource":"item/font_sheet","sprite":"font_alias"},
{"type":"single","resource":"gui/panel","sprite":"gui_alias"},
{"type":"single","resource":"hash"}
]}"#,
)
.unwrap();
let images = vec![
("item/font_sheet".to_owned(), 32, 32, Vec::new()),
("item/blurred".to_owned(), 32, 32, Vec::new()),
("block/stone".to_owned(), 32, 32, Vec::new()),
];
let models =
reducible_model_sprites(&root, &images, &HashSet::from(["item/blurred".to_owned()]));
let _ = fs::remove_dir_all(root);
assert_eq!(
models,
HashSet::from(["hash".to_owned(), "block/stone".to_owned()])
);
}
#[test]
fn overflowing_model_textures_shrink_without_changing_gui_sizes_or_slots() {
let mut images = vec![
(
"nexo:hash".into(),
1024,
1024,
[10, 20, 30, 255].repeat(1024 * 1024),
),
(
"gui/panel".into(),
32,
32,
[40, 50, 60, 255].repeat(32 * 32),
),
("horizon/map".into(), 128, 128, Vec::new()),
];
let models = HashSet::from(["nexo:hash".to_owned(), "horizon/map".to_owned()]);
let mut sizes = HashMap::new();
assert!(HudAtlas::layout(&images, 1024).is_err());
let (width, placements, free) =
HudAtlas::layout_reducing_models(&mut images, &mut sizes, &models, 1024).unwrap();
assert_inside(&images, width, &placements, free);
assert!(free + GLYPH_ROWS <= 1024);
let model = images.iter().find(|i| i.0 == "nexo:hash").unwrap();
assert_eq!((model.1, model.2), (256, 256));
assert_eq!(model.3, [10, 20, 30, 255].repeat(256 * 256));
assert_eq!(sizes["nexo:hash"], (1024, 1024));
let gui = images.iter().find(|i| i.0 == "gui/panel").unwrap();
assert_eq!((gui.1, gui.2), (32, 32));
assert_eq!(gui.3, [40, 50, 60, 255].repeat(32 * 32));
let slot = images.iter().find(|i| i.0 == "horizon/map").unwrap();
assert_eq!((slot.1, slot.2), (128, 128));
assert!(slot.3.is_empty());
}
#[test]
fn fitting_models_keep_their_pixels_and_existing_logical_sizes() {
let mut images = vec![("item/gem".into(), 32, 32, [1, 2, 3, 255].repeat(32 * 32))];
let original = images.clone();
let mut sizes = HashMap::from([("item/gem".to_owned(), (16, 16))]);
let models = HashSet::from(["item/gem".to_owned()]);
HudAtlas::layout_reducing_models(&mut images, &mut sizes, &models, 1024).unwrap();
assert_eq!(images, original);
assert_eq!(sizes["item/gem"], (16, 16));
// Protected oversized images remain an error; no unbounded retry or
// silent removal of GUI/font resources when shrinking cannot help.
images.push(("font/sheet".into(), 1024, 1024, Vec::new()));
assert!(HudAtlas::layout_reducing_models(&mut images, &mut sizes, &models, 1024).is_err());
assert_eq!((images[0].1, images[0].2), (1024, 1024));
assert_eq!(sizes["item/gem"], (16, 16));
}
#[test]
fn textures_that_cannot_fit_are_an_error_not_a_gpu_panic() {
let error = HudAtlas::layout(&sized(300, 1024, 1024), MAX_ATLAS_SIZE).unwrap_err();
assert!(error.to_string().contains("do not fit a 16384x16384 atlas"));
@@ -1,105 +0,0 @@
//! Resolution fallback for model textures that exceed the shared HUD atlas.
use super::{HashMap, HashSet, HudAtlas, PackedImage, Path, Result, bitmap_font_textures};
/// Only model atlas sprites may lose resolution. Font sheets can also be
/// model textures, so protect both their file names and renamed sources.
pub(super) fn reducible_model_sprites(
root: &Path,
images: &[PackedImage],
bordered: &HashSet<String>,
) -> HashSet<String> {
use crate::atlas_sources::{self, SpriteOrigin};
let fonts: HashSet<_> = bitmap_font_textures(root).into_iter().collect();
let protected = |name: &str| {
let path = name.split_once(':').map_or(name, |(_, path)| path);
path.starts_with("gui/")
|| path.starts_with("font/")
|| fonts.contains(&atlas_sources::texture_path(root, name, "textures"))
};
let mut models: HashSet<String> = images
.iter()
.filter(|(name, ..)| name.starts_with("item/") || name.starts_with("block/"))
.map(|(name, ..)| name.clone())
.collect();
let textures = atlas_sources::list_textures(root);
let mut protected_aliases = HashSet::new();
for (atlas, sources) in atlas_sources::atlases(root) {
if atlas != "minecraft:blocks" && atlas != "minecraft:items" {
continue;
}
for (id, origin) in atlas_sources::list(&sources, &textures) {
let name = id.strip_prefix("minecraft:").unwrap_or(&id).to_owned();
if let SpriteOrigin::File(texture) = origin {
if protected(&texture) {
protected_aliases.insert(name);
} else {
models.insert(name);
}
}
}
}
models.retain(|name| {
!bordered.contains(name) && !protected(name) && !protected_aliases.contains(name)
});
models
}
impl HudAtlas {
/// `layout`, but when `extras` (Custom Item Textures) make the images more than `max`
/// holds, without them: they are left out, not the packs.
pub(super) fn layout_leaving_out(
images: &mut Vec<PackedImage>,
animated: &mut Vec<String>,
extras: &HashSet<String>,
max: u32,
) -> Result<(u32, Vec<(u32, u32)>, u32)> {
match Self::layout(images, max) {
Err(e) if !extras.is_empty() => {
images.retain(|i| !extras.contains(&i.0));
animated.retain(|n| !extras.contains(n));
let layout = Self::layout(images, max)?;
log::warn!("left out the custom item textures: {e}");
Ok(layout)
}
layout => layout,
}
}
/// Last resort for a pack that exceeds the shared atlas: halve model
/// textures until they fit, retaining their logical GUI sizes. Never
/// shrink run-time slots (empty pixels), or below the vanilla 16 texels.
pub(super) fn layout_reducing_models(
images: &mut [PackedImage],
gui_sizes: &mut HashMap<String, (u32, u32)>,
models: &HashSet<String>,
max: u32,
) -> Result<(u32, Vec<(u32, u32)>, u32)> {
loop {
let error = match Self::layout(images, max) {
Ok(layout) => return Ok(layout),
Err(error) => error,
};
let mut changed = false;
for (name, w, h, pixels) in images.iter_mut() {
if !models.contains(name) || pixels.is_empty() || (*w <= 16 && *h <= 16) {
continue;
}
let (nw, nh) = ((*w / 2).max((*w).min(16)), (*h / 2).max((*h).min(16)));
let mut scaled = Vec::with_capacity((nw * nh * 4) as usize);
for y in 0..nh {
for x in 0..nw {
let at = (((y * *h / nh) * *w + x * *w / nw) * 4) as usize;
scaled.extend_from_slice(&pixels[at..at + 4]);
}
}
gui_sizes.entry(name.clone()).or_insert((*w, *h));
*pixels = scaled;
(*w, *h) = (nw, nh);
changed = true;
}
if !changed {
return Err(error);
}
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
}
}
}
@@ -39,14 +39,11 @@ pub fn text_with_backdrop(
background: u32,
) {
if background != 0 {
// Font.PreparedTextBuilder.markBackground: one pixel above and left of
// the glyphs, the glyph advance to the right and nine high: not a
// fixed two-pixel frame.
out.push(fill(
x - 1,
y - 1,
x + w,
y + 9,
x - 2,
y - 2,
x + w + 2,
y + 9 + 2,
multiply(background, color),
));
}
@@ -1487,27 +1484,3 @@ mod boss_bar_room_tests {
}
}
}
#[cfg(test)]
mod backdrop_tests {
use super::*;
/// `markBackground`: the backdrop reaches one pixel above and left of the
/// glyphs, their advance to the right and nine down, not 2 on every side.
#[test]
fn the_backdrop_frames_the_glyphs_by_one_pixel() {
let mut out = Vec::new();
text_with_backdrop(
&mut out,
Text::literal("X"),
10,
20,
6,
u32::MAX,
0xFF00_0000,
);
let Some(GuiDraw::Fill { x0, y0, x1, y1, .. }) = out.first() else {
panic!("a backdrop is a fill first");
};
assert_eq!((*x0, *y0, *x1, *y1), (9, 19, 16, 29));
}
}
@@ -59,16 +59,6 @@ pub const CULL_BACK: [i8; 4] = [0, 0, 0, 1];
/// `Vertex::normal.w` of a shaded quad of an `entityCutoutCull` model (the bat): shaded by the
/// normal like -127, and `entity.wgsl` also discards its back-facing pixels.
pub const SHADED_CULL_W: i8 = -126;
/// `Vertex::normal` of a quad of an `entitySolid` model, which culls its back faces: a quad
/// the shader shades by its normal keeps it ([`SHADED_CULL_W`]), a baked-colour one (no
/// normal) gets [`CULL_BACK`].
pub fn cull_back(normal: [i8; 4]) -> [i8; 4] {
if normal == [0; 4] {
CULL_BACK
} else {
[normal[0], normal[1], normal[2], SHADED_CULL_W]
}
}
/// A section vertex: vanilla's `DefaultVertexFormat.BLOCK` (position, colour,
/// UV0, UV2) with the sprite's array layer beside the light, in 24 bytes
/// rather than vanilla's 28: the UV is fixed point, which holds model UVs
@@ -155,17 +145,6 @@ impl TerrainVertex {
}
}
#[cfg(test)]
mod cull_back_tests {
use super::{CULL_BACK, SHADED_CULL_W, cull_back};
/// #15322: a shaded `entitySolid` quad keeps its normal and culls; a baked one has
/// no normal and only culls.
#[test]
fn cull_back_keeps_a_shaded_normal() {
assert_eq!(cull_back([0; 4]), CULL_BACK);
assert_eq!(cull_back([0, 127, 0, 127]), [0, 127, 0, SHADED_CULL_W]);
}
}
#[cfg(test)]
mod vertex_tests {
use super::TerrainVertex;
+48 -23
View File
@@ -6,8 +6,6 @@ use std::time::Instant;
pub use crate::settings::Opt;
#[path = "options_ui_widgets.rs"]
mod widgets;
use crate::{
gamepad::{self, GlyphStyle},
hdr_calibration::Picture,
@@ -19,11 +17,6 @@ use crate::{
text::{self, Style, Text},
ui,
};
pub(crate) use widgets::{button, inactive};
#[path = "options_ui_values.rs"]
mod values;
pub(crate) use values::{chat_delay_label, chat_px, generic, on_off, percent};
mod shell_pages;
@@ -42,6 +35,18 @@ pub static HORIZON_EXTRAS: std::sync::atomic::AtomicBool = std::sync::atomic::At
fn horizon_extras() -> bool {
HORIZON_EXTRAS.load(std::sync::atomic::Ordering::Relaxed)
}
fn generic(caption: &str, value: Text) -> Text {
Text::translate("options.generic_value", &[tr(caption), value])
}
fn percent(caption: &str, value: f32) -> Text {
Text::translate(
"options.percent_value",
&[
tr(caption),
Text::literal(((f64::from(value) * 100.0) as i32).to_string()),
],
)
}
/// `text` cut into lines of at most `width` characters, at spaces where it has them.
fn wrap(text: &str, width: usize) -> Vec<String> {
let mut lines = Vec::new();
@@ -66,11 +71,20 @@ fn wrap(text: &str, width: usize) -> Vec<String> {
}
lines
}
fn on_off(v: bool) -> Text {
tr(if v { "options.on" } else { "options.off" })
}
/// A music track's name as its row shows it: the file, without its folder
/// (`minecraft:music/game/calm1` -> `calm1`; the group names the situation).
fn track_name(id: &str) -> &str {
id.rsplit('/').next().unwrap_or(id)
}
fn chat_px(caption: &str, px: f32) -> Text {
Text::translate(
"options.pixel_value",
&[tr(caption), Text::literal((px as i32).to_string())],
)
}
pub(crate) enum Kind {
/// Slider position 0..1.
Slider(f32),
@@ -339,7 +353,6 @@ impl Opt {
pub(crate) fn kind(self, s: &Settings) -> Kind {
let unit = |v: f32, lo: f32, hi: f32| Kind::Slider(((v - lo) / (hi - lo)).clamp(0.0, 1.0));
match self {
Opt::ChatDelay => values::kind(s.file.double("chatDelay")),
Opt::FovEffects | Opt::DarknessPulsing => {
Kind::Slider(s.file.double(self.file_key().unwrap()).sqrt() as f32)
}
@@ -448,7 +461,6 @@ impl Opt {
/// The discrete step count for stepped sliders, or `None` if continuous.
pub fn slider_steps(self) -> Option<u32> {
match self {
Opt::ChatDelay => Some(60),
Opt::Fov => Some(80),
Opt::RenderDistance => Some((crate::options_file::MAX_RENDER_DISTANCE - 2) as u32),
Opt::SimulationDistance => Some(27),
@@ -499,7 +511,7 @@ impl Opt {
pub fn label(self, s: &Settings) -> Text {
let c = self.caption();
match self {
Opt::ChatDelay => chat_delay_label(s),
Opt::ChatDelay => tr("options.chat.delay_none"),
Opt::FovEffects
| Opt::DarknessPulsing
| Opt::DistortionEffects
@@ -704,7 +716,7 @@ impl Opt {
} else {
"options.narrator.notavailable"
}),
Opt::ChatDelay => chat_delay_label(s),
Opt::ChatDelay => tr("options.chat.delay_none"),
Opt::HideLightningFlashes => on_off(s.file.bool("hideLightningFlashes")),
Opt::MonochromeLogo => on_off(s.file.bool("darkMojangStudiosBackground")),
Opt::HideSplashTexts => on_off(s.file.bool("hideSplashTexts")),
@@ -889,7 +901,6 @@ impl Opt {
let v = v.clamp(0.0, 1.0);
let step = |lo: f32, hi: f32| (lo + v * (hi - lo)).round();
match self {
Opt::ChatDelay => values::set(s, v),
Opt::FovEffects | Opt::DarknessPulsing => s.file.set(
self.file_key().unwrap(),
crate::options_file::V::Double(f64::from(v).powi(2)),
@@ -1295,7 +1306,7 @@ pub enum Page {
Music,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum Act {
enum Act {
None,
Push(Page),
Done,
@@ -1360,7 +1371,7 @@ enum WKind {
code: String,
},
}
pub(crate) struct Widget {
struct Widget {
x: f32,
y: f32,
w: f32,
@@ -1696,6 +1707,27 @@ impl Default for OptionsUi {
}
}
}
fn button(x: f32, y: f32, w: f32, label: Text, act: Act) -> Widget {
Widget {
x,
y,
w,
h: 20.0,
kind: WKind::Button {
label,
active: true,
act,
},
list: false,
row: 0,
}
}
fn inactive(mut w: Widget) -> Widget {
if let WKind::Button { active, .. } = &mut w.kind {
*active = false;
}
w
}
fn option_widget(s: &Settings, opt: Opt, x: f32, y: f32, w: f32) -> Widget {
// OptionsSubScreen.addContents: the narrator button is active only while
// the narrator is (`narratorButton.active = narrator.isActive()`).
@@ -3062,20 +3094,13 @@ impl OptionsUi {
}
content = y + self.scroll - top + 2.0;
out[list_start..].iter_mut().for_each(|w| w.list = true);
let reset_all = button(
out.push(button(
cx - 154.0,
h - 27.0,
150.0,
tr("controls.resetAll"),
Act::ResetKeys,
);
// KeyBindsScreen.extractRenderState: active only while some
// binding differs from its default.
out.push(if s.keys.changed_from_defaults() {
reset_all
} else {
inactive(reset_all)
});
));
out.push(button(cx + 4.0, h - 27.0, 150.0, tr("gui.done"), Act::Done));
}
Page::Language => {
@@ -174,42 +174,3 @@ fn a_held_hdr_start_shows_as_the_graphics_screen_opens() {
assert_eq!(ui.graphics.screen.output.held, held);
}
}
/// The slider position `Opt::ChatDelay`'s kind() gives the stored seconds.
fn delay_pos(seconds: f64) -> f32 {
match values::kind(seconds) {
Kind::Slider(p) => p,
_ => panic!("the chat delay is a slider"),
}
}
/// `Options.chatDelay`: `IntRange(0, 60)` xmap(v / 10) — a slider in tenths of
/// a second from 0 to 6, not a button with no value behind it.
#[test]
fn the_chat_delay_slider_steps_in_tenths() {
assert_eq!(delay_pos(0.0), 0.0);
assert!((delay_pos(3.0) - 0.5).abs() < 1e-6);
assert_eq!(delay_pos(6.0), 1.0);
// A value past the option's range clamps to its last step.
assert_eq!(delay_pos(6.5), 1.0);
assert_eq!(Opt::ChatDelay.slider_steps(), Some(60));
let mut s = Settings::default();
set_slider(Opt::ChatDelay, &mut s, 0.0);
assert_eq!(s.file.double("chatDelay"), 0.0);
set_slider(Opt::ChatDelay, &mut s, 0.5);
assert!((s.file.double("chatDelay") - 3.0).abs() < 1e-9);
set_slider(Opt::ChatDelay, &mut s, 1.0);
assert!((s.file.double("chatDelay") - 6.0).abs() < 1e-9);
}
/// `Options.chatDelay`'s message: None at zero, else the delay to one decimal.
#[test]
fn the_chat_delay_slider_says_its_delay() {
let mut s = Settings::default();
assert_eq!(chat_delay_label(&s).plain(), "options.chat.delay_none");
s.file.set("chatDelay", crate::options_file::V::Double(2.0));
assert_eq!(chat_delay_label(&s).plain(), "Chat Delay: 2.0 second(s)");
s.file.set("chatDelay", crate::options_file::V::Double(6.0));
assert_eq!(chat_delay_label(&s).plain(), "Chat Delay: 6.0 second(s)");
s.file.set("chatDelay", crate::options_file::V::Double(0.0));
assert_eq!(chat_delay_label(&s).plain(), "options.chat.delay_none");
}
@@ -1,54 +0,0 @@
//! The small option value labels (`generic`, `percent`, `on_off`, `chat_px`,
//! `chat_delay_label`) and `Options.chatDelay`'s slider mapping.
use super::*;
pub(crate) fn generic(caption: &str, value: Text) -> Text {
Text::translate("options.generic_value", &[tr(caption), value])
}
pub(crate) fn on_off(v: bool) -> Text {
tr(if v { "options.on" } else { "options.off" })
}
pub(crate) fn chat_px(caption: &str, px: f32) -> Text {
Text::translate(
"options.pixel_value",
&[tr(caption), Text::literal((px as i32).to_string())],
)
}
pub(crate) fn percent(caption: &str, value: f32) -> Text {
Text::translate(
"options.percent_value",
&[
tr(caption),
Text::literal(((f64::from(value) * 100.0) as i32).to_string()),
],
)
}
/// `Options.chatDelay`'s message: `options.chat.delay_none` at zero, else
/// `options.chat.delay` with the delay to one decimal (`%.1f` seconds).
pub(crate) fn chat_delay_label(s: &Settings) -> Text {
let value = s.file.double("chatDelay") as f32;
if value <= 0.0 {
Text::translate("options.chat.delay_none", &[])
} else {
Text::translate_with_fallback(
"options.chat.delay",
"Chat Delay: %s second(s)",
&[Text::literal(format!("{value:.1}"))],
)
}
}
/// `IntRange(0, 60).toSliderValue` on the stored seconds (`xmap(v / 10.0)`):
/// the tenth-of-a-second step over the slider's 60.
pub(crate) fn kind(value: f64) -> Kind {
Kind::Slider(((value * 10.0).floor() / 60.0).clamp(0.0, 1.0) as f32)
}
/// `IntRange(0, 60).fromSliderValue` then `xmap`'s `/ 10.0`:
/// floor(map(v, 0, 1, 0, 61)), the last step capped at 60, in tenths.
pub(crate) fn from_slider(v: f32) -> f64 {
f64::from((v.clamp(0.0, 1.0) * 61.0).floor().min(60.0) / 10.0)
}
/// `Options.chatDelay.set`: the slider's value as the stored seconds.
pub(crate) fn set(s: &mut Settings, v: f32) {
s.file
.set("chatDelay", crate::options_file::V::Double(from_slider(v)));
}
@@ -1,25 +0,0 @@
//! The option screens' widget constructors: a 20-pixel button row and the
//! same widget turned inactive.
use super::*;
pub(crate) fn button(x: f32, y: f32, w: f32, label: Text, act: Act) -> Widget {
Widget {
x,
y,
w,
h: 20.0,
kind: WKind::Button {
label,
active: true,
act,
},
list: false,
row: 0,
}
}
pub(crate) fn inactive(mut w: Widget) -> Widget {
if let WKind::Button { active, .. } = &mut w.kind {
*active = false;
}
w
}
@@ -1718,19 +1718,6 @@ impl PackScreen {
[32, 2],
);
if l.scrollable() {
// AbstractScrollArea.extractScrollbar: over the bar, the pointer
// points, or resizes while the bar is held.
let over_bar = mouse.0 >= l.scroll_bar_x()
&& mouse.0 < l.scroll_bar_x() + 6
&& mouse.1 >= l.y
&& mouse.1 < l.bottom();
if over_bar {
crate::widgets::request_cursor(if l.held.is_some() {
crate::widgets::Cursor::ResizeNs
} else {
crate::widgets::Cursor::PointingHand
});
}
let sh = l.scroller_height().clamp(32, l.height - 8);
let sy = (l.scroll as i32 * (l.height - sh) / l.max_scroll() + l.y).max(l.y);
d::sprite(
@@ -2210,29 +2197,6 @@ mod icon_slot_tests {
/// Only the available list has packs: one row of 36 for each of `count` icons fits in a
/// window `height` high.
/// `AbstractScrollArea.extractScrollbar`: over a held pack list's bar the
/// pointer asks to resize vertically, at rest it points.
#[test]
fn the_pack_scrollbar_asks_for_its_cursor() {
let atlas = atlas();
let m = Measure(&atlas.font);
let mut screen = screen(&m, 30, 200);
let l = &screen.lists[0];
assert!(l.scrollable());
let at = (l.scroll_bar_x() + 2, l.y + 8);
let _ = crate::widgets::take_cursor();
screen.draw(&m, at, 0, 0);
assert_eq!(
crate::widgets::take_cursor(),
crate::widgets::Cursor::PointingHand
);
screen.lists[0].held = Some(f64::from(at.1));
screen.draw(&m, at, 0, 0);
assert_eq!(
crate::widgets::take_cursor(),
crate::widgets::Cursor::ResizeNs
);
}
fn screen(m: &Measure, count: usize, height: i32) -> PackScreen {
PackScreen::new(
m,
@@ -3825,7 +3825,7 @@ impl ParticleEngine {
}
let pending = std::mem::take(&mut self.pending);
for mut p in pending {
if !self.polytone_joins(&p, level) {
if p.spawn_check && !polytone::unobstructed(level, p.bb) {
continue;
}
if p.pre_tick {
@@ -1100,30 +1100,6 @@ pub(super) fn unobstructed(level: &ParticleLevel<'_>, bb: horizon_core::geometry
scene.cells_without_collision(bb) || scene.unobstructed(bb).unwrap_or(true)
}
impl ParticleEngine {
/// Whether a pending Polytone particle joins the world. One with physics whose box starts
/// inside a block's collision never does, and its type's `limit` is then counted among those
/// that joined, so one that never joins takes no place (measured on the mod: with `limit` 1,
/// one born inside a block and one clear of it in the same tick, the clear one shows).
pub(super) fn polytone_joins(&self, p: &Particle, level: &ParticleLevel<'_>) -> bool {
if !p.spawn_check {
return true;
}
if !unobstructed(level, p.bb) {
return false;
}
let Some(name) = p.polytone_type.as_deref() else {
return true;
};
let def = self.polytone.as_ref().and_then(|r| r.get(name));
let limit = def.and_then(|d| d.limit.filter(|_| d.gpu.is_none()));
limit.is_none_or(|limit| {
let same = |q: &&Particle| !q.removed && q.polytone_type.as_deref() == Some(name);
self.particles.iter().filter(same).count() < limit as usize
})
}
}
impl super::ParticleController for Controller {
fn tick(&mut self, p: &mut Particle, level: &ParticleLevel<'_>) -> ParticleUpdate {
let mut update = ParticleUpdate::default();
@@ -1238,8 +1214,7 @@ pub(super) fn make(
velocity: [f64; 3],
seed: i64,
) -> Option<Particle> {
// `limit`: at most that many of the type alive at once. A pending one with physics may never
// join (`polytone_joins`), so it takes no place until it does.
// `limit`: at most that many of the type alive at once.
// (A GPU particle's `limit` is its ring of records instead.)
if let Some(limit) = def.limit
&& def.gpu.is_none()
@@ -1247,7 +1222,7 @@ pub(super) fn make(
let alive = engine
.particles
.iter()
.chain(engine.pending.iter().filter(|p| !p.spawn_check))
.chain(&engine.pending)
.filter(|p| !p.removed && p.polytone_type.as_deref() == Some(name))
.count();
if alive >= limit as usize {
@@ -1255,11 +1230,10 @@ pub(super) fn make(
}
}
// `exclusion_radius`: none spawns within that distance of a live one of its type (rain
// splashes, one per patch of ground from tick to tick). Only those already in the world count: measured on
// the mod, two spawned in the same tick a block apart both show, with or without physics.
// splashes, one per patch of ground).
if def.exclusion_radius > 0.0 && def.gpu.is_none() {
let r2 = def.exclusion_radius * def.exclusion_radius;
let crowded = engine.particles.iter().any(|p| {
let crowded = engine.particles.iter().chain(&engine.pending).any(|p| {
!p.removed
&& p.polytone_type.as_deref() == Some(name)
&& (0..3)
@@ -1884,8 +1858,7 @@ mod tests {
let root = pack(&[
(
"assets/test/polytone/custom_particles/splash.json",
serde_json::json!({"has_physics": false, "exclusion_radius": 1, "render_type": "invisible",
"initializer": {"lifetime": "100"}}),
serde_json::json!({"has_physics": false, "exclusion_radius": 1, "render_type": "invisible"}),
),
(
"assets/test/polytone/custom_particles/drop.json",
@@ -1895,8 +1868,7 @@ mod tests {
"assets/minecraft/polytone/entity_modifiers/player.json",
serde_json::json!({"emitters": [
{"particle": "test:splash", "x": "0", "chance": "1"},
// 0.5 from the first, in the same tick: the first isn't in the world yet,
// so it spawns (measured on the mod).
// 0.5 from the first: excluded.
{"particle": "test:splash", "x": "0.5", "chance": "1"},
// 3 away: spawns.
{"particle": "test:splash", "x": "3", "chance": "1"},
@@ -1918,15 +1890,7 @@ mod tests {
engine.polytone_entity(&level, "minecraft:player", [1.0, 64.0, 1.0]);
let mut xs: Vec<f64> = engine.pending.iter().map(|p| p.position[0]).collect();
xs.sort_by(f64::total_cmp);
assert_eq!(xs, [1.0, 1.5, 1.5, 4.0]);
// Once they are in the world, the next tick's splashes near them are excluded; ones
// farther than the radius from all of them spawn, again without excluding each other.
engine.tick(&level);
let splashes = |p: &&Particle| p.polytone_type.as_deref() == Some("test:splash");
engine.polytone_entity(&level, "minecraft:player", [1.0, 64.0, 1.0]);
assert_eq!(engine.pending.iter().filter(splashes).count(), 0);
engine.polytone_entity(&level, "minecraft:player", [9.0, 64.0, 1.0]);
assert_eq!(engine.pending.iter().filter(splashes).count(), 3);
assert_eq!(xs, [1.0, 1.5, 4.0]);
std::fs::remove_dir_all(root).unwrap();
}
@@ -2457,70 +2421,6 @@ mod tests {
std::fs::remove_dir_all(root).unwrap();
}
/// Measured on the mod: with `limit` 1, one particle born inside a block and one clear of it
/// in the same tick, the clear one shows. The one that never joins takes no place.
#[test]
fn a_particle_that_never_joins_takes_no_place_under_its_limit() {
let blocks = blocks();
let lim = |x: &str| serde_json::json!({"particle": "test:lim", "chance": "1", "x": x});
let root = pack(&[
(
"assets/test/polytone/custom_particles/lim.json",
serde_json::json!({"has_physics": true, "render_type": "invisible", "limit": 1,
"initializer": {"lifetime": "20", "hitbox_size": "0.08"}}),
),
// The first inside the block's reach, the second three blocks clear of it.
(
"assets/minecraft/polytone/entity_modifiers/player.json",
serde_json::json!({"emitters": [lim("0"), lim("3")]}),
),
// Two clear of it: the limit still keeps one.
(
"assets/minecraft/polytone/entity_modifiers/zombie.json",
serde_json::json!({"emitters": [lim("3"), lim("6")]}),
),
// `exclusion_radius` with physics: the one inside the block doesn't keep the clear
// one a block away from spawning.
(
"assets/test/polytone/custom_particles/exc.json",
serde_json::json!({"has_physics": true, "render_type": "invisible",
"exclusion_radius": 2,
"initializer": {"lifetime": "20", "hitbox_size": "0.08"}}),
),
(
"assets/minecraft/polytone/entity_modifiers/skeleton.json",
serde_json::json!({"emitters": [
{"particle": "test:exc", "chance": "1", "x": "0"},
{"particle": "test:exc", "chance": "1", "x": "1"}]}),
),
]);
let scene = solid_scene(blocks);
let level = ParticleLevel {
world: &scene.world,
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
let mut joined = |entity: &str| {
engine.particles.clear();
engine.pending.clear();
// The solid block is at (1, 64, 1): 0.05 under it, a 0.08 box reaches into it.
engine.polytone_entity(&level, entity, [1.5, 63.95, 1.5]);
engine.tick(&level);
engine
.particles
.iter()
.map(|p| p.position[0])
.collect::<Vec<_>>()
};
assert_eq!(joined("minecraft:player"), vec![4.5]);
assert_eq!(joined("minecraft:zombie"), vec![4.5]);
assert_eq!(joined("minecraft:skeleton"), vec![2.5]);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn a_pack_custom_particle_replaces_the_vanilla_type_of_its_id() {
let root = pack(&[(
@@ -303,12 +303,8 @@ fn faded(code: u32, local: vec3<f32>, dx: vec3<f32>, dy: vec3<f32>) -> bool {
let pixel = max(length(dx), length(dy));
let unit = exp2(ceil(log2(max(pixel * 1.5, 1.0 / 16.0))));
let p = vec2<u32>(bitcast<vec2<u32>>(vec2<i32>(floor(plane / unit)))) & vec2(3u);
// The 4x4 Bayer matrix by rows, 0 8 2 10, 12 4 14 6, 3 11 1 9, 15 7 13 5: four bits each,
// two rows a word (a constant array indexed by the pixel would be a private array, which
// Adreno's Vulkan compiler, build 0676.41, fails to link).
let rows = select(0x6e4ca280u, 0x5d7f91b3u, p.y >= 2u);
let bayer = (rows >> (((p.y & 1u) * 4u + p.x) * 4u)) & 0xfu;
return (f32(bayer) + 0.5) / 16.0 >= shown;
let bayer = array<u32, 16>(0u, 8u, 2u, 10u, 12u, 4u, 14u, 6u, 3u, 11u, 1u, 9u, 15u, 7u, 13u, 5u);
return (f32(bayer[p.y * 4u + p.x]) + 0.5) / 16.0 >= shown;
}
// A fading tile or section is drawn a hair toward the camera (depth is
// reversed: nearer is larger), the newer the more, so it wins where it draws
@@ -1053,100 +1053,5 @@
"horizon.studio.topbar.add_marker": "Додати маркер",
"horizon.studio.topbar.clear_keyframes": "🗑 Очистити ключові кадри",
"horizon.studio.topbar.timeline_hud": "⏱ Шкала часу",
"horizon.studio.topbar.split_scene": "Розділити сцену",
"horizon.options.radial_mode": "Радіальне меню",
"horizon.options.radial_mode.about": "Утримуйте кнопку відкриття радіального меню або натисніть її раз, щоб відкрити, і ще раз, щоб закрити.",
"horizon.options.radial_mode.hold.about": "Утримуйте кнопку відкриття й відпустіть, щоб виконати те, на що вказує стік.",
"horizon.options.radial_mode.toggle.about": "Натисніть кнопку відкриття, щоб відкрити меню; A або повторне натискання кнопки відкриття виконує те, на що вказує стік, B закриває.",
"horizon.options.radial_stick": "Вибір у радіальному меню",
"horizon.options.radial_stick.about": "Стік, яким ви вказуєте на комірки радіального меню.",
"horizon.options.radial_stick.right": "Правий стік",
"horizon.options.radial_stick.left": "Лівий стік",
"horizon.options.radial_stick.either": "Будь-який",
"horizon.options.radial_stick.right.about": "Правий стік вказує на комірки; лівим ви й далі ходите.",
"horizon.options.radial_stick.left.about": "Лівий стік вказує, а ви стоїте на місці. Під тим самим пальцем — хрестовина праворуч, тож оберіть кнопку відкриття на сторінці «Радіальне меню».",
"horizon.options.radial_stick.either.about": "Вказує стік, яким рухали останнім; жоден не рухає вас і не повертає огляд. Відкрити меню можна лише бампером, тригером або кнопкою View.",
"horizon.pages.controller_radial": "Радіальне меню",
"horizon.radial.page.general": "Загальне",
"horizon.radial.page.camera": "Камера",
"horizon.radial.release_stick": "Відпустіть стік",
"horizon.radial.action.key_inventory": "Інвентар",
"horizon.radial.action.key_chat": "Чат",
"horizon.radial.action.key_command": "Команди",
"horizon.radial.action.key_advancements": "Досягнення",
"horizon.radial.action.key_togglePerspective": "Перспектива",
"horizon.radial.action.key_swapOffhand": "Поміняти руки",
"horizon.radial.action.key_drop": "Викинути",
"horizon.radial.action.key_pickItem": "Вибрати блок",
"horizon.radial.action.key_screenshot": "Знімок екрана",
"horizon.radial.action.key_toggleGui": "Сховати HUD",
"horizon.radial.action.key_smoothCamera": "Кінематографічна камера",
"horizon.radial.action.key_socialInteractions": "Соціальне",
"horizon.radial.action.key_emote": "Емоція",
"horizon.radial.action.pad_hotbarPrevious": "Попередня комірка",
"horizon.radial.action.pad_hotbarNext": "Наступна комірка",
"horizon.radial.action.key_fullscreen": "Повний екран",
"horizon.radial.action.key_quickActions": "Швидкі дії",
"horizon.radial.action.key_debug_overlay": "Екран налагодження",
"horizon.radial.missing": "Відсутня",
"horizon.radial.why.missing": "Невідома дія",
"horizon.radial.why.cosmetics": "Потрібна увімкнена косметика",
"horizon.radial.why.quick_actions": "На цьому сервері немає швидких дій",
"horizon.radial.why.social": "Лише на сервері",
"horizon.radial.next": "→ %s",
"horizon.radial.state.first_person": "Від першої особи",
"horizon.radial.state.third_person_back": "Від третьої особи",
"horizon.radial.state.third_person_front": "Вигляд спереду",
"horizon.radial.state.shown": "Показано",
"horizon.radial.state.hidden": "Приховано",
"horizon.radial.state.on": "Увімк.",
"horizon.radial.state.off": "Вимк.",
"horizon.radial.pick_title": "Оберіть дію",
"horizon.radial.group.actions": "Дії",
"horizon.radial.pick.slot": "Комірка «%s» сторінки «%s»",
"horizon.radial.pick.about": "Ставить цю дію в комірку й повертається назад. Поточну дію комірки позначено.",
"horizon.radial.pick.current": "Поточна",
"horizon.radial.missing.about": "Ця гра не знає дії %s: комірка зберігає її, але нічого не виконує. Натисніть або «праворуч», щоб поставити сюди іншу.",
"horizon.radial.page.new": "Нова сторінка",
"horizon.pages.open.controller_radial": "Радіальне меню...",
"horizon.radial.group.slots": "Комірки",
"horizon.radial.group.page": "Сторінка",
"horizon.radial.group.all": "Усі сторінки",
"horizon.radial.empty": "Порожньо",
"horizon.radial.page_of": "Сторінка %s з %s. Кожна комірка — це напрямок стіка вибору, поки кнопку відкриття утримано.",
"horizon.radial.slot.0": "Угору",
"horizon.radial.slot.1": "Угору праворуч",
"horizon.radial.slot.2": "Праворуч",
"horizon.radial.slot.3": "Униз праворуч",
"horizon.radial.slot.4": "Униз",
"horizon.radial.slot.5": "Униз ліворуч",
"horizon.radial.slot.6": "Ліворуч",
"horizon.radial.slot.7": "Угору ліворуч",
"horizon.radial.slot.about": "Дія, яку виконує цей напрямок. Натисніть, щоб побачити список дій; «праворуч» — наступна, «ліворуч» або Shift+клік — попередня.",
"horizon.radial.rename": "Перейменувати сторінку",
"horizon.radial.rename.about": "Дайте цій сторінці власну назву.",
"horizon.radial.name_title": "Назва сторінки",
"horizon.radial.new_page.about": "Додає порожню сторінку після останньої й показує її.",
"horizon.radial.new_page.full": "Меню вміщує щонайбільше %s сторінок.",
"horizon.radial.delete_page": "Видалити сторінку",
"horizon.radial.delete_page.about": "Одразу видаляє цю сторінку та її комірки. «Скасувати» повертає її.",
"horizon.radial.delete_page.last": "У меню лишається щонайменше одна сторінка.",
"horizon.radial.reset": "Скинути всі сторінки",
"horizon.radial.reset.about": "Одразу повертає сторінки «Загальне» й «Камера» та видаляє решту. «Скасувати» повертає їх.",
"horizon.radial.reset.default": "Сторінки вже типові.",
"horizon.radial.opener": "Кнопка відкриття",
"horizon.radial.opener.about": "Кнопка, яку утримують, щоб відкрити меню. Натисніть, потім натисніть кнопку геймпада: бампер або хрестовину, але не A, B, X, Y чи правий стік, якими керують і скасовують.",
"horizon.radial.opener.about.left": "Кнопка, яку утримують, щоб відкрити меню. Натисніть, потім натисніть кнопку геймпада: бампер, A, X або Y, але не хрестовину чи лівий стік, якими керують, і не B, яка скасовує.",
"horizon.radial.opener.about.either": "Кнопка, яку утримують, щоб відкрити меню. Натисніть, потім натисніть кнопку геймпада: бампер, тригер або View; обидва великі пальці керують, а B скасовує.",
"horizon.radial.unbound": "Жодна кнопка не відкриває меню (кнопку під пальцем, яким обирають, звільнено): натисніть, потім натисніть кнопку геймпада.",
"horizon.radial.conflict": "Спільна з іншою дією: у грі цю кнопку забирає меню. Натисніть, щоб обрати іншу.",
"Radial Menu": "Радіальне меню",
"Named pages of eight actions, held open with a button.": "Іменовані сторінки по вісім дій, відкриті, поки утримуєте кнопку.",
"The shader pack lights the world itself.": "Пакет шейдерів сам освітлює світ.",
"How bright the HUD and menus are. Auto: at paper white.": "Яскравість HUD і меню. «Авто»: на рівні білої точки.",
"HDR output stopped the game the last time it started with this graphics driver. Choose a mode to try again.": "Минулого разу з цим графічним драйвером HDR-вивід зупинив гру. Оберіть режим, щоб спробувати знову.",
"horizon.pad.action.radial": "Радіальне меню",
"horizon.recipes.fuel.options": "Утримуйте SHIFT, щоб закріпити цю підказку, потім клацніть інгредієнт, щоб переглянути рецепти.",
"horizon.recipes.fuel.any": "Будь-яке паливо",
"horizon.recipes.fuel.minecraft": "Minecraft"
"horizon.studio.topbar.split_scene": "Розділити сцену"
}
+80 -115
View File
@@ -1996,7 +1996,7 @@ pub fn mob_from_nbt_with_random(
if crate::mobs::equine::is_equine(m.species()) {
m.health = m.health.min(m.attrs.max_health);
}
m.hurt_time = i32::from(number(tag.get("HurtTime")).unwrap_or(0) as i16);
m.hurt_time = number(tag.get("HurtTime")).unwrap_or(0) as i32;
// What it holds (equipment.mainhand / offhand), when saved.
if let Some(eq) = tag.get("equipment") {
m.extra
@@ -2034,7 +2034,7 @@ pub fn mob_from_nbt_with_random(
// vanilla); a loaded mob holds only what its file lists.
m.extra.set_main_hand(None);
}
m.death_time = i32::from(number(tag.get("DeathTime")).unwrap_or(0) as i16);
m.death_time = number(tag.get("DeathTime")).unwrap_or(0) as i32;
m.impulse_grace_time =
number(tag.get("current_impulse_context_reset_grace_time")).unwrap_or(0) as i32;
// Mob.readAdditionalSaveData overrides what the constructor set (copper
@@ -2423,7 +2423,7 @@ pub fn mob_from_nbt_with_random(
restore_axolotl_memories(&mut m, tag);
}
read_variant(&mut m, tag);
m.fire_ticks = i32::from(number(tag.get("Fire")).unwrap_or(0) as i16);
m.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i32;
// Entity.load: `input.getIntOr("Air", getMaxAirSupply())`, 4800 for a dolphin.
m.air = number(tag.get("Air")).map_or(m.max_air_supply(), |air| air as i32);
if m.species() == crate::mobs::Species::Allay {
@@ -2609,7 +2609,7 @@ pub fn mob_from_nbt_with_random(
}
// Sheep.readAdditionalSaveData: white and unsheared when absent.
if m.species() == crate::mobs::Species::Sheep {
m.extra.color = crate::mobs::farm::saved_color(tag.get("Color"));
m.extra.color = number(tag.get("Color")).map_or(0, |c| (c as u8) & 15);
m.extra.sheared = flag(tag, "Sheared");
}
// GlowSquid.readAdditionalSaveData: not dimmed when absent.
@@ -2959,8 +2959,7 @@ pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemE
/// `OminousItemSpawner.addAdditionalSaveData`: its item (left out when
/// empty) and `spawn_item_after_ticks`. Its tick count is the entity's
/// `tickCount`, which isn't saved: a reloaded spawner counts from 0 again.
/// It never moves or turns: it saves the Motion, Rotation, OnGround and
/// fall_distance it was loaded with (`Entity.load`).
/// It never moves, so the `Motion` it was loaded with is what it saves.
pub fn ominous_item_spawner_to_nbt(
item: &crate::items::ItemStack,
position: Vec3,
@@ -2973,12 +2972,13 @@ pub fn ominous_item_spawner_to_nbt(
uuid,
position,
motion: raw.map_or(Vec3::ZERO, loaded_motion),
rotation: raw.map_or([0.0, 0.0], rotation),
on_ground: raw.is_some_and(|t| flag(t, "OnGround")),
fall_distance: float(raw.and_then(|t| t.get("fall_distance"))).unwrap_or(0.0),
rotation: [0.0, 0.0],
on_ground: false,
fall_distance: 0.0,
};
let mut tag = base.tag(raw);
// `ItemStack.CODEC` can't encode `ItemStack.EMPTY`: an empty item writes no key.
// `ItemStack.CODEC` can't encode `ItemStack.EMPTY`, so an empty item
// writes no key.
if let Tag::Compound(map) = &mut tag {
map.remove(&"item".into());
}
@@ -3005,8 +3005,59 @@ pub fn ominous_item_spawner_from_nbt(tag: &Tag) -> Option<(crate::items::ItemSta
Some((item, position(tag)?, after))
}
mod orb;
pub use orb::{orb_to_nbt, orbs_from_nbt};
/// `ExperienceOrb`: Health, Age, Value, Count.
pub fn orb_to_nbt(o: &ExperienceOrb, uuid: [i32; 4]) -> Tag {
let base = Base {
id: "minecraft:experience_orb",
uuid,
position: o.position,
motion: o.velocity,
rotation: o.rotation,
on_ground: o.on_ground,
fall_distance: 0.0,
};
let mut tag = base.tag(None);
for (key, value) in [
("Health", Tag::Short(o.health as i16)),
("Age", Tag::Short(o.age.min(i16::MAX as u32) as i16)),
("Value", Tag::Short(o.value as i16)),
("Count", Tag::Int(o.count.min(i32::MAX as u32) as i32)),
] {
put(&mut tag, key, value);
}
// Entity.saveWithoutId writes NoGravity only when set.
if o.no_gravity {
put(&mut tag, "NoGravity", Tag::Byte(1));
}
tag
}
/// One orb with its merged `Count`.
pub fn orbs_from_nbt(tag: &Tag) -> Vec<ExperienceOrb> {
let Some(position) = position(tag) else {
return Vec::new();
};
let count = number(tag.get("Count")).unwrap_or(1).max(1) as u32;
let orb = ExperienceOrb {
rotation: rotation(tag),
join: Default::default(),
no_gravity: flag(tag, "NoGravity"),
id: 0,
position,
previous: position,
velocity: motion(tag),
// `getShortOr("Value", 0)`, unclamped.
value: tag.get("Value").and_then(int_value).unwrap_or(0) as i16 as i32,
age: number(tag.get("Age")).unwrap_or(0).max(0) as u32,
on_ground: flag(tag, "OnGround"),
count,
ticks: 0,
health: number(tag.get("Health")).unwrap_or(5) as i32,
// ExperienceOrb's followingPlayer is transient: never saved, so a
// loaded orb starts without one.
following: None,
};
vec![orb]
}
/// `Projectile` + `AbstractArrow.addAdditionalSaveData` of a live arrow;
/// `in_block` is its `inBlockState`, `raw` the compound it was loaded with.
@@ -3798,7 +3849,19 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
("AbsorptionAmount", Tag::Float(0)),
("FallFlying", Tag::Byte(0)),
("current_impulse_context_reset_grace_time", Tag::Int(0)),
("attributes", Tag::List(Vec::new())),
// AttributeMap.pack: the movement_speed instance the stand's first
// tick makes (removeFrost), at the registry default.
(
"attributes",
Tag::List(if a.speed_instance {
vec![Tag::compound([
("id", string(SPEED_ATTRIBUTE)),
("base", Tag::Double(0.7f64.to_bits())),
])]
} else {
Vec::new()
}),
),
(
"Brain",
Tag::compound([("memories", Tag::Compound(Compound::new()))]),
@@ -3808,18 +3871,6 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
] {
map.entry(key.into()).or_insert(value);
}
// `removeFrost`'s first-tick movement_speed, over a saved list without it.
if a.speed_instance
&& let Some(Tag::List(list)) = map.get_mut(&"attributes".into())
&& !list
.iter()
.any(|t| text(t.get("id")).as_deref() == Some(SPEED_ATTRIBUTE))
{
list.push(Tag::compound([
("id", string(SPEED_ATTRIBUTE)),
("base", Tag::Double(0.7f64.to_bits())),
]));
}
}
tag
}
@@ -3833,7 +3884,7 @@ pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
if list.iter().any(|attr| text(attr.get("id")).as_deref() == Some(SPEED_ATTRIBUTE)));
a.velocity = loaded_motion(tag);
a.health = float(tag.get("Health")).unwrap_or(20.0) as f32;
a.fire_ticks = i32::from(number(tag.get("Fire")).unwrap_or(0) as i16);
a.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i32;
a.custom_name = tag.get("CustomName").map(name_text);
a.name_visible = flag(tag, "CustomNameVisible");
if let Some(eq) = tag.get("equipment") {
@@ -4550,27 +4601,6 @@ mod tests {
}
}
/// `LivingEntity.readAdditionalSaveData` reads `HurtTime` and `DeathTime`
/// with `getShortOr`: an int tag is narrowed (`(short)(value & 65535)`),
/// so 40000 is -25536 and never counts down.
#[test]
fn a_mob_narrows_its_hurt_and_death_time_to_a_short() {
let kind = crate::mobs::sensor_scan_tests::kind();
let types = [(kind.id.to_string(), kind.clone())].into();
let load = |hurt: Tag, death: Tag| {
let raw = Tag::compound([
("id", string(&kind.id)),
("Pos", doubles(&[4.5, 220.0, 4.5])),
("HurtTime", hurt),
("DeathTime", death),
]);
let m = mob_from_nbt(&raw, &types, 1, None).unwrap();
(m.hurt_time, m.death_time)
};
assert_eq!(load(Tag::Int(40000), Tag::Int(65546)), (-25536, 10));
assert_eq!(load(Tag::Short(7), Tag::Short(19)), (7, 19));
}
/// A mount keeps its body armour and a helmet (say
/// `/item replace entity <horse> armor.head with minecraft:iron_helmet`)
/// in their own slots through a save: vanilla holds both (`equipment.body`
@@ -5861,43 +5891,6 @@ mod tests {
assert_eq!(attributes(&armor_stand_from_nbt(&saved, 1).unwrap()), speed);
}
#[test]
fn a_loaded_stand_gains_the_speed_instance_its_first_tick_makes() {
// Vanilla 26.3: `/summon armor_stand ~ ~ ~ {attributes:[]}` saves `[]` until the
// first tick, whose removeFrost makes movement_speed (base 0.7d); a saved
// instance of another attribute stays in front of it.
let ids = |stand: &ArmorStand, raw: &Tag| match armor_stand_to_nbt(stand, [0; 4], Some(raw))
.get("attributes")
{
Some(Tag::List(attrs)) => attrs
.iter()
.map(|a| (text(a.get("id")).unwrap(), a.get("base").cloned()))
.collect::<Vec<_>>(),
other => panic!("attributes: {other:?}"),
};
let health = (
"minecraft:max_health".to_owned(),
Some(Tag::Double(20f64.to_bits())),
);
let speed = (
"minecraft:movement_speed".to_owned(),
Some(Tag::Double(0.7f64.to_bits())),
);
let empty = Tag::compound([("attributes", Tag::List(Vec::new()))]);
let saved = Tag::compound([(
"attributes",
Tag::List(vec![Tag::compound([
("id", string("minecraft:max_health")),
("base", Tag::Double(20f64.to_bits())),
])]),
)]);
let mut stand = ArmorStand::new(1, Vec3::ZERO, 0.0);
assert_eq!(ids(&stand, &empty), []);
assert_eq!(ids(&stand, &saved), std::slice::from_ref(&health));
stand.speed_instance = true;
assert_eq!(ids(&stand, &empty), std::slice::from_ref(&speed));
assert_eq!(ids(&stand, &saved), [health, speed]);
}
#[test]
fn summoned_trident_defaults_are_not_pickable_or_already_shot() {
let raw = Tag::compound([
("Pos", doubles(&[0.0, 0.0, 0.0])),
@@ -7418,11 +7411,11 @@ mod sheep_nbt_tests {
assert_eq!(number(tag.get("InWaterTime")), Some(300));
assert_eq!(number(tag.get("DrownedConversionTime")), Some(-1));
assert_eq!((back.extra.counter, back.extra.counter2), (300, 0));
(zombie.extra.counter, zombie.extra.counter2) = (600, 301);
(zombie.extra.counter, zombie.extra.counter2) = (600, 300);
let (back, tag) = round_trip(&zombie);
assert_eq!(number(tag.get("InWaterTime")), Some(600));
assert_eq!(number(tag.get("DrownedConversionTime")), Some(300));
assert_eq!((back.extra.counter, back.extra.counter2), (600, 301));
assert_eq!(number(tag.get("DrownedConversionTime")), Some(299));
assert_eq!((back.extra.counter, back.extra.counter2), (600, 300));
// A skeleton outside powder snow is not afflicted: -1 whatever the
// counter holds, as with a fresh or NoAI one.
let (_, tag) = round_trip(&new("minecraft:skeleton"));
@@ -9469,34 +9462,6 @@ mod pickup_loot_save_tests {
mod stand_fall_save_tests {
use super::*;
/// `Entity.load` reads `Fire` with `getShortOr`: an int tag is narrowed
/// (`(short)(value & 65535)`), so 40000 is -25536 and the stand or mob
/// does not burn.
#[test]
fn a_stand_and_a_mob_narrow_a_loaded_fire_to_a_short() {
let kind = crate::mobs::sensor_scan_tests::kind();
let types = [(kind.id.to_string(), kind.clone())].into();
for (fire, expected) in [
(Tag::Int(40000), -25536),
(Tag::Int(65546), 10),
(Tag::Short(60), 60),
] {
let input = Tag::compound([
("id", string(&kind.id)),
("Pos", doubles(&[4.5, 205.0, 4.5])),
("Fire", fire),
]);
assert_eq!(
armor_stand_from_nbt(&input, 1).unwrap().fire_ticks,
expected
);
assert_eq!(
mob_from_nbt(&input, &types, 1, None).unwrap().fire_ticks,
expected
);
}
}
#[test]
fn decoded_stand_fall_distance_survives_save_without_raw_or_with_stale_raw() {
let stale = Tag::compound([("fall_distance", Tag::Double(99.0_f64.to_bits()))]);
@@ -39,16 +39,10 @@ fn attached_block_pos(tag: &Tag) -> Option<[i32; 3]> {
}
}
}
/// `Direction.from3DDataValue` / `from2DDataValue`: the byte read by
/// `LEGACY_ID_CODEC` indexes the table as `abs(data % modulus)`, never clamped.
fn wrap_data_value(raw: impl TryInto<i64> + Copy, modulus: i64) -> u8 {
let byte = raw.try_into().unwrap_or(0) as i8;
(i64::from(byte) % modulus).unsigned_abs() as u8
}
/// The block position, facing and variant (if saved) of a painting.
pub fn painting_from_nbt(tag: &Tag) -> Option<([i32; 3], u8, Option<String>)> {
let pos = attached_block_pos(tag)?;
let facing = wrap_data_value(number(tag.get("facing")).unwrap_or(0), 4);
let facing = (number(tag.get("facing")).unwrap_or(0) & 3) as u8;
Some((pos, facing, text(tag.get("variant"))))
}
/// ItemFrame.addAdditionalSaveData.
@@ -98,7 +92,7 @@ pub fn item_frame_from_nbt(tag: &Tag, id: u32) -> Option<ItemFrame> {
Some(ItemFrame {
id,
pos,
facing: wrap_data_value(number(tag.get("Facing")).unwrap_or(0), 6),
facing: number(tag.get("Facing")).unwrap_or(0).clamp(0, 5) as u8,
glow: kind(tag).as_deref() == Some("minecraft:glow_item_frame"),
item: tag
.get("Item")
@@ -113,26 +107,3 @@ pub fn item_frame_from_nbt(tag: &Tag, id: u32) -> Option<ItemFrame> {
random: crate::random_source::entity(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn negative_facing_wraps_like_vanilla_direction_codecs() {
// from3DDataValue: abs(-1 % 6) = 1 (UP); 7 -> 1; -6 -> 0.
for (raw, want) in [(-1i8, 1u8), (7, 1), (-6, 0), (5, 5), (0, 0)] {
let tag = Tag::compound([
("Facing", Tag::Byte(raw)),
("block_pos", Tag::IntArray(vec![0, 0, 0])),
]);
assert_eq!(item_frame_from_nbt(&tag, 1).unwrap().facing, want);
}
// from2DDataValue: abs(-1 % 4) = 1.
let tag = Tag::compound([
("facing", Tag::Byte(-1)),
("block_pos", Tag::IntArray(vec![0, 0, 0])),
]);
assert_eq!(painting_from_nbt(&tag).unwrap().1, 1);
}
}
@@ -1,56 +0,0 @@
//! Experience orbs: `ExperienceOrb` save data.
use super::{Base, ExperienceOrb, Tag, flag, int_value, motion, number, position, put, rotation};
/// `ExperienceOrb`: Health, Age, Value, Count.
pub fn orb_to_nbt(o: &ExperienceOrb, uuid: [i32; 4]) -> Tag {
let base = Base {
id: "minecraft:experience_orb",
uuid,
position: o.position,
motion: o.velocity,
rotation: o.rotation,
on_ground: o.on_ground,
fall_distance: 0.0,
};
let mut tag = base.tag(None);
for (key, value) in [
("Health", Tag::Short(o.health as i16)),
("Age", Tag::Short(o.age.min(i16::MAX as u32) as i16)),
("Value", Tag::Short(o.value as i16)),
("Count", Tag::Int(o.count.min(i32::MAX as u32) as i32)),
] {
put(&mut tag, key, value);
}
// Entity.saveWithoutId writes NoGravity only when set.
if o.no_gravity {
put(&mut tag, "NoGravity", Tag::Byte(1));
}
tag
}
/// One orb with its merged `Count`.
pub fn orbs_from_nbt(tag: &Tag) -> Vec<ExperienceOrb> {
let Some(position) = position(tag) else {
return Vec::new();
};
let count = number(tag.get("Count")).unwrap_or(1).max(1) as u32;
let orb = ExperienceOrb {
rotation: rotation(tag),
join: Default::default(),
no_gravity: flag(tag, "NoGravity"),
id: 0,
position,
previous: position,
velocity: motion(tag),
// `getShortOr("Value", 0)`, unclamped.
value: tag.get("Value").and_then(int_value).unwrap_or(0) as i16 as i32,
age: number(tag.get("Age")).unwrap_or(0).max(0) as u32,
on_ground: flag(tag, "OnGround"),
count,
ticks: 0,
health: number(tag.get("Health")).unwrap_or(5) as i32,
// ExperienceOrb's followingPlayer is transient: never saved, so a
// loaded orb starts without one.
following: None,
};
vec![orb]
}
+61 -55
View File
@@ -497,7 +497,7 @@ use horizon_client::random_source;
pub use advancements::AdvancementDisplay;
use horizon_client::actions::Action as Mapping;
use horizon_client::held::Source;
use horizon_client::spans::{startup, time as span};
use horizon_client::spans::startup;
use horizon_client::{
Result,
assets::{Assets, RenderAssets},
@@ -3889,7 +3889,6 @@ impl Game<'_> {
golem_chests.extend(self.chest_other_half(key));
}
let native_authoritative = self.native_authoritative();
let environment_type = self.dimension_type();
let mut level = GameLevel {
gateway_servers: &mut self.be_anims.gateway_servers,
border: self.border.geometry(),
@@ -3912,7 +3911,11 @@ impl Game<'_> {
events: &mut self.level_events,
dimension,
darken,
environment: self.res.environment_of(&environment_type.id),
environment: match dimension {
LevelDimension::Overworld => self.res.environment(),
LevelDimension::Nether => self.res.nether_environment(),
LevelDimension::End => self.res.end_environment(),
},
raining: rain > 0.2,
thundering: thunder > 0.9,
player_box,
@@ -5542,7 +5545,7 @@ impl Game<'_> {
// the activated item counts down.
self.boss_overlay.tick_darken(running);
self.tick_item_activation();
span("mods_inventory_ticks", || self.native_inventory_ticks());
self.native_inventory_ticks();
if running {
// `MinecraftServer.tickChildren`: `commandFunctions` runs before
// any level ticks, so a tick function reads the game time and the
@@ -5550,7 +5553,7 @@ impl Game<'_> {
// long before this point; the lazy scoreboard/storage read has to
// be done by then too, or a load tag writes over what it restores.
self.load_scoreboard();
span("tick_functions", || self.tick_functions());
self.tick_functions();
self.world.game_time += 1;
if self.ui.rules.bool("advance_weather") {
self.world.weather.tick_timers(&mut self.world.random);
@@ -5564,7 +5567,7 @@ impl Game<'_> {
}
// ClientLevel.tick: the sky flash fades in a frozen level too.
self.tick_weather_effects();
let _ = span("player_autosaves", || self.finish_player_autosaves(false));
let _ = self.finish_player_autosaves(false);
// MinecraftServer.tickServer's countdown, then autoSave: three
// hundred seconds of ticks at the rate the server runs at now.
self.ticks_until_autosave -= 1;
@@ -5573,7 +5576,7 @@ impl Game<'_> {
serving.set_saving(self.autosaving);
}
if self.autosaving {
span("autosave", || self.save_game_with(false))?;
self.save_game_with(false)?;
self.ticks_until_autosave = self.next_autosave_interval();
if let Some(serving) = &self.serving {
serving.set_saving(false);
@@ -5582,27 +5585,27 @@ impl Game<'_> {
if running {
self.advance_clock();
// `ServerLevel.tickTime`, once the clock has moved on.
span("scheduled_functions", || self.run_scheduled_functions());
self.run_scheduled_functions();
}
if self.use_delay > 0 {
self.use_delay -= 1;
}
self.tick_totem();
self.update_totem_ready();
span("tick_command_extras", || self.tick_command_extras());
span("mods_tick", || self.native_tick());
span("mods_tick", || self.tell_native_now());
self.tick_command_extras();
self.native_tick();
self.tell_native_now();
self.tick_world(running)
}
/// What the tick runs after the local player's own update: effects, the
/// world's entities and blocks, and the network hub.
fn tick_world(&mut self, running: bool) -> Result<()> {
// Entities added since the last tick join the level before it ticks.
span("mods_joins_flush", || self.native_joins_flush(false, 0));
span("share_spawn_conditions", || self.share_spawn_conditions());
self.native_joins_flush(false, 0);
self.share_spawn_conditions();
horizon_client::mobs::sulfur_cube::set_tnt_explodes(self.ui.rules.bool("tnt_explodes"));
span("chunk_tickets", || self.update_chunk_tickets());
span("score_destroyed", || self.score_destroyed_entities());
self.update_chunk_tickets();
self.score_destroyed_entities();
horizon_client::spans::time("tick_effects", || self.tick_effects(running));
self.tick_sign_editor();
// Player.tick's ItemCooldowns.tick for the window's player (a
@@ -5626,8 +5629,8 @@ impl Game<'_> {
sent = horizon_client::spans::time("tick_blocks", || self.tick_blocks())?;
// What the blocks spawned (a dispensed minecart, a lit TNT, a
// placed boat) joins before the entities tick.
span("mods_joins_flush", || self.native_joins_flush(false, 0));
span("lan_mark_synced", || self.lan_mark_synced());
self.native_joins_flush(false, 0);
self.lan_mark_synced();
// `EntityTickList.forEach`: what joined before the entity pass
// (function summons, dropped or dispensed items) ticks in it;
// what joins during it waits for the next tick
@@ -5637,7 +5640,7 @@ impl Game<'_> {
}
// ChunkMap still ticks while /tick freeze holds block/entity ticks.
if !running {
span("save_chunks_eagerly", || self.save_chunks_eagerly());
self.save_chunks_eagerly();
}
self.begin_ordered_ticks();
if running {
@@ -5651,8 +5654,8 @@ impl Game<'_> {
// ServerLevel.tick: the arrivals of this server tick are in the level
// (`addDuringTeleport`), and each vehicle handles its portal as its
// own tick opens.
span("portal_arrivals", || self.land_portal_arrivals());
span("vehicle_portals", || self.handle_vehicle_portals());
self.land_portal_arrivals();
self.handle_vehicle_portals();
self.ordered_before(ordered_tick::Batch::Boats)?;
horizon_client::spans::time("tick_boats", || self.tick_boats());
self.ordered_before(ordered_tick::Batch::Carts)?;
@@ -5682,11 +5685,11 @@ impl Game<'_> {
self.ordered_before(ordered_tick::Batch::Items)?;
horizon_client::spans::time("tick_items", || self.tick_items());
if self.remote.is_none() {
span("hoppers_catch_items", || self.hoppers_catch_items());
self.hoppers_catch_items();
}
// Merged, burnt, despawned or picked up.
span("mods_leaves_flush", || self.native_leaves_flush());
span("mods_joins_flush", || self.native_joins_flush(false, 0));
self.native_leaves_flush();
self.native_joins_flush(false, 0);
horizon_client::spans::time("touch_arrows", || self.touch_arrows());
}
horizon_client::spans::time("spawn_loaded_entities", || self.spawn_loaded_entities());
@@ -5696,22 +5699,22 @@ impl Game<'_> {
self.ordered_before(ordered_tick::Batch::Mobs)?;
horizon_client::spans::time("tick_mobs", || self.tick_mobs())?;
// Dead, despawned or gone into a hive.
span("mods_leaves_flush", || self.native_leaves_flush());
self.native_leaves_flush();
horizon_client::spans::time("recheck_golem_openers", || self.recheck_golem_openers());
span("mod_viewers", || self.tick_mod_container_viewers());
self.tick_mod_container_viewers();
// Orbs, primed TNT and falling blocks still to tick, in join order.
self.run_ordered_ticks(horizon_client::join_seq::JoinSeq::MAX, false)?;
horizon_client::spans::time("tick_experience", || self.tick_experience());
// Merged, despawned or picked up.
span("mods_leaves_flush", || self.native_leaves_flush());
self.native_leaves_flush();
horizon_client::spans::time("entity_portals", || {
self.find_entity_portals();
self.resolve_arrivals();
});
}
// The rest of the tick's spawns join, and what left is told of.
span("mods_joins_flush", || self.native_joins_flush(false, 0));
span("mods_leaves_flush", || self.native_leaves_flush());
self.native_joins_flush(false, 0);
self.native_leaves_flush();
// ServerLevel.tick broadcasts the changed blocks after the scheduled and
// random ticks (`tick_blocks`), before the block events, the entities
// and the block entities: what those change goes out with the next
@@ -5726,23 +5729,23 @@ impl Game<'_> {
if let Some(log) = self.world.block_log.as_mut() {
log.extend(held);
}
span("tick_friends", || self.tick_friends());
self.tick_friends();
horizon_client::spans::time("tick_hand", || self.tick_hand());
horizon_client::spans::time("tick_sleep", || self.tick_sleep());
span("sleep_status", || self.announce_sleep_status());
span("sleep_status", || self.free_left_beds());
self.announce_sleep_status();
self.free_left_beds();
if running {
horizon_client::spans::time("tick_end", || self.tick_end())?;
horizon_client::spans::time("end_gateways", || {
self.find_end_gateway_portals();
self.resolve_end_gateway_arrivals()
})?;
span("end_gateways", || self.keep_gateway_destinations());
self.keep_gateway_destinations();
}
if running {
// Primed TNT and falling blocks that joined since the spawn packets.
self.run_ordered_ticks(horizon_client::join_seq::JoinSeq::MAX, true)?;
span("late_wind_pushes", || self.apply_late_wind_pushes());
self.apply_late_wind_pushes();
}
// Everything that has joined has had its spawn packet and, if it joined
// before the entity passes (TNT a piston or repeater primed), its first
@@ -5761,21 +5764,21 @@ impl Game<'_> {
self.tick_block_entity_anims()
});
// A conduit's hurt teleports an enderman now (`Enderman.hurtServer`).
span("settle_hurt_teleports", || self.settle_hurt_teleports())?;
self.settle_hurt_teleports()?;
horizon_client::spans::time("tick_particles", || self.tick_particles());
span("tick_client_ambient", || self.tick_client_ambient());
self.tick_client_ambient();
}
// ServerLevel.tick's `entityManager.tick()`, frozen or not, after every
// block entity ticker (the bells, conduits and gateways above too): the
// chunks loaded before this pass have their entities loaded from the next.
self.world.entities_pending.clear();
span("tick_frame_visuals", || self.tick_frame_visuals(running));
self.tick_frame_visuals(running);
if running {
// MinecraftServer.tickServer: GameTestTicker.SINGLETON.tick().
span("tick_game_tests", || self.tick_game_tests());
self.tick_game_tests();
}
// ServerBossEvent broadcasts, after this level's entities have ticked.
span("publish_boss_bars", || self.publish_boss_bars());
self.publish_boss_bars();
// Minecraft.handleKeybinds: held use repeats only while no item is in
// use (a raised shield or a drawn bow places and uses nothing).
if self.right
@@ -6731,14 +6734,19 @@ impl Game<'_> {
// Entity shadows (EntityRenderer.extractShadow); a native pack's sun shadows replace them.
self.mark_translucent_entity(out.len(), "minecraft:entity_shadow");
let pack_shadows = self.renderer.as_ref().is_some_and(|r| {
r.pack_entity_shadows() || (self.overworld_skybox() && r.native_shadow())
r.pack_entity_shadows() || (self.dim == Dim::Overworld && r.native_shadow())
});
let shadows = self.settings.entity_shadows && self.pack.polytone_colors.entity_shadows();
let shadow_sprite = (shadows && !pack_shadows)
.then(|| self.gui.atlas.sprites.get("misc/shadow").copied())
.flatten();
if let Some(sprite) = shadow_sprite {
let ambient = self.dimension_type().ambient_light;
// DimensionType.ambientLight: the_nether 0.1, the_end 0.25.
let ambient = match self.dim {
Dim::Nether => 0.1,
Dim::End => 0.25,
Dim::Overworld => 0.0,
};
let darken = self.sky_darken();
let a = alpha;
let lerp = |p: Vec3, q: Vec3| {
@@ -6901,7 +6909,7 @@ impl Game<'_> {
emissive: &self.gui.atlas.emissive,
translucent_sprites: &self.gui.atlas.translucent,
atlas_revision: self.gui.atlas.revision,
nether: self.nether_lighting(),
nether: self.dim == Dim::Nether,
lid: &lid,
sign: &|p| match self.world.block_entities.get(&(p[0], p[1], p[2])) {
Some(BlockEntity::Sign(sign)) => Some(sign),
@@ -7619,10 +7627,8 @@ impl Game<'_> {
r.clear_sections(self.world.layout());
}
self.apply_settings();
let facts = self.dimension_type_of(dim);
self.streaming.sky_light = facts.height.has_skylight;
self.streaming.nether_shading =
facts.cardinal_light == horizon_world::dimension_type::CardinalLight::Nether;
self.streaming.sky_light = dim != Dim::Nether;
self.streaming.nether_shading = dim == Dim::Nether;
self.load_world_state();
// The level the world opens in, before any of its chunks.
self.native_level_opened(dim);
@@ -9311,10 +9317,10 @@ impl Game<'_> {
game_time: self.world.game_time,
day_time: self.view_day_time(),
render_distance: self.streaming.radius,
sky_type: match self.dimension_type().skybox {
horizon_world::dimension_type::Skybox::Overworld => 1,
horizon_world::dimension_type::Skybox::End => 2,
horizon_world::dimension_type::Skybox::None => 0,
sky_type: match self.dim {
Dim::Overworld => 1,
Dim::End => 2,
Dim::Nether => 0,
},
dimension: match self.dim {
Dim::Overworld => "minecraft:overworld",
@@ -9656,13 +9662,13 @@ impl Game<'_> {
pr.lap("pr_dragon_bolts");
let (celestial, stars) = if title {
(Vec::new(), None)
} else if self.overworld_skybox()
} else if self.dim == Dim::Overworld
&& self.potions.effects.get("darkness").is_none()
&& self.potions.effects.get("blindness").is_none()
{
// Camera.doesMobEffectBlockSky: no sun, moon or stars.
self.celestial_geometry(&sky, rain)
} else if self.end_skybox() && self.settings.horizon.sky {
} else if self.dim == Dim::End && self.settings.horizon.sky {
(self.end_flash_geometry(), None)
} else {
(Vec::new(), None)
@@ -9736,7 +9742,7 @@ impl Game<'_> {
let frame = Frame {
weather: &weather,
weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32),
nether_lighting: self.nether_lighting(),
nether_lighting: self.dim == Dim::Nether,
camera,
yaw,
pitch,
@@ -9776,8 +9782,8 @@ impl Game<'_> {
|| self.potions.effects.get("darkness").is_some(),
self.boss_overlay.should_create_world_fog(),
),
skybox: self.overworld_skybox() && self.settings.horizon.sky,
end_sky: self.end_skybox() && self.settings.horizon.sky,
skybox: self.dim == Dim::Overworld && self.settings.horizon.sky,
end_sky: self.dim == Dim::End && self.settings.horizon.sky,
end_sky_sprite: self
.gui
.atlas
@@ -9801,7 +9807,7 @@ impl Game<'_> {
glint_alpha: self.glint_frame.alpha,
glint_ticks: self.glint_frame.scroll_ticks(),
clouds: (!title
&& self.overworld_skybox()
&& self.dim == Dim::Overworld
&& horizon_client::clouds::visible(&self.settings.clouds, sky.cloud_color[3]))
.then(|| self.gui.atlas.sprites.get("horizon/white"))
.flatten()
@@ -1954,7 +1954,8 @@ impl Game<'_> {
mut skin: Option<&mut horizon_client::mobs::skin::Skin>,
out: &mut Vec<Vertex>,
) {
let lights = horizon_client::block_entity_render::diffuse_lights(self.nether_lighting());
let lights =
horizon_client::block_entity_render::diffuse_lights(self.dim == super::Dim::Nether);
let mut submit = |state: &horizon_client::item_render::ItemRenderState,
pose: glam::Mat4,
out: &mut Vec<Vertex>| {
@@ -2027,7 +2028,7 @@ impl Game<'_> {
out,
light,
lights: Some(horizon_client::block_entity_render::diffuse_lights(
self.nether_lighting(),
self.dim == super::Dim::Nether,
)),
glint: self.glint(),
tier: 0,
@@ -801,17 +801,8 @@ impl Game<'_> {
let server = self.be_anims.gateway_servers.entry(key).or_insert(initial);
let triggered = server.portal_tick();
let age = server.age;
let mut aged = false;
if let Some(BlockEntity::EndGateway(g)) = self.world.block_entities.get_mut(&pos)
&& g.age != age
{
if let Some(BlockEntity::EndGateway(g)) = self.world.block_entities.get_mut(&pos) {
g.age = age;
aged = true;
}
if aged {
// `TheEndGatewayBlockEntity.portalTick` ends with `setChanged`: the
// age the beam and the chunk save read moved.
self.world.modified.insert((pos.0 >> 4, pos.2 >> 4));
}
if triggered {
self.gateway_cooldown_event(key);
@@ -1117,41 +1108,3 @@ mod bell_ownership_tests {
assert!(anims.bells.is_empty());
}
}
#[cfg(test)]
mod gateway_age_tests {
use super::*;
use crate::game::native_machines::tests::server_game;
/// `TheEndGatewayBlockEntity.portalTick`: an age that moved is a change
/// to save.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_gateway_that_ages_marks_the_chunk() {
let mut game = server_game();
let state = game
.pack
.assets
.registry
.default_state("minecraft:end_gateway")
.unwrap();
let pos = (2, 4, 3);
game.world.set(pos.0, pos.1, pos.2, state).unwrap();
game.world.block_entities.insert(
pos,
BlockEntity::EndGateway(horizon_client::containers::EndGateway::default()),
);
game.world.modified.clear();
game.tick_end_gateway(pos);
let Some(BlockEntity::EndGateway(g)) = game.world.block_entities.get(&pos) else {
panic!("no gateway");
};
assert_eq!(g.age, 1, "the tick aged the gateway");
assert!(
game.world.modified.contains(&(0, 0)),
"the aged gateway left the chunk unmarked"
);
}
}
+15 -28
View File
@@ -26,7 +26,21 @@ impl Game<'_> {
self.sync_tick_chunks();
self.start_ticking_chunks();
laps.lap("tb_tickets");
self.random_tick_chunks(&mut scratch.ticking);
let levels = self.tickets.levels();
let bounds = levels.random_tick_bounds();
// Keep the world map's chunk order, including empty chunks
// (tick_chunk_random still draws for precipitation in those).
scratch.ticking.clear();
scratch
.ticking
.extend(self.world.chunks.keys().copied().filter(|&(x, z)| {
if let Some((min_x, min_z, max_x, max_z)) = bounds
&& (x < min_x || x > max_x || z < min_z || z > max_z)
{
return false;
}
levels.random_ticking(x, z) && ticking_chunk(&self.world, x, z)
}));
laps.lap("tb_chunks");
// Entity.checkInsideBlocks for the plates, tripwires, detector rails and
// buttons under the players (the host and the LAN guests), the minecarts
@@ -273,30 +287,6 @@ impl Game<'_> {
Ok(sent)
}
/// `ChunkMap.forEachBlockTickingChunk`: the chunks that random tick this
/// tick, empty ones included (`tickChunk` still draws for precipitation in
/// those).
///
/// Vanilla walks the simulation tracker's own table, whose order doesn't
/// follow chunk loading. The world map's order does: it is filled as the
/// streaming workers finish, so the level random's draws landed on other
/// blocks from one run of a world to the next (#15164). Chunk position
/// order is the same every run; vanilla's exact order is its fastutil
/// table's slot order, which follows that table's whole history.
pub(super) fn random_tick_chunks(&self, out: &mut Vec<(i32, i32)>) {
let levels = self.tickets.levels();
let bounds = levels.random_tick_bounds();
out.clear();
out.extend(self.world.chunks.keys().copied().filter(|&(x, z)| {
if let Some((min_x, min_z, max_x, max_z)) = bounds
&& (x < min_x || x > max_x || z < min_z || z > max_z)
{
return false;
}
levels.random_ticking(x, z) && ticking_chunk(&self.world, x, z)
}));
out.sort_unstable();
}
/// Track which chunks are ticking (Ticks needs chunk load/unload).
pub(super) fn sync_tick_chunks(&mut self) {
if !self.tick_chunks.synchronize(&self.world.chunks) {
@@ -370,6 +360,3 @@ impl Game<'_> {
Ok(())
}
}
#[cfg(test)]
mod random_order_tests;
@@ -1,57 +0,0 @@
//! `ChunkMap.forEachBlockTickingChunk`: the order chunks random tick in must
//! not follow the order they finished loading in, or the level random's draws
//! land on other blocks from one run of a world to the next (#15164).
use super::super::Game;
use super::super::native_machines::tests::server_game;
use horizon_client::chunk::Chunk;
use horizon_core::geometry::Vec3;
use std::sync::Arc;
/// A game whose player stands in chunk (0, 0), with lit chunks loaded in
/// `order`.
fn game_loading(order: &[(i32, i32)]) -> Game<'static> {
let mut game = server_game();
game.settings.simulation_distance = 5;
game.settings.render_distance = 8;
game.player = Some(
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(8.5, 65.0, 8.5)).unwrap(),
);
game.world.chunks.clear();
for &(x, z) in order {
let mut chunk = Chunk::empty(x, z);
chunk.lit = true;
game.world.chunks.insert((x, z), Arc::new(chunk));
}
game.update_chunk_tickets();
game
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn chunks_random_tick_in_the_same_order_however_they_loaded() {
let mut loaded: Vec<(i32, i32)> = (-8..=8)
.flat_map(|x| (-8..=8).map(move |z| (x, z)))
.collect();
let first = game_loading(&loaded);
// The streaming workers finish in another order on another run.
loaded.reverse();
loaded.rotate_left(97);
let second = game_loading(&loaded);
let (mut a, mut b) = (Vec::new(), Vec::new());
first.random_tick_chunks(&mut a);
second.random_tick_chunks(&mut b);
assert!(
a.contains(&(0, 0)) && a.contains(&(4, -4)),
"the simulation distance's chunks random tick: {a:?}"
);
assert!(
!a.contains(&(8, 0)),
"a chunk past the simulation distance doesn't"
);
assert_eq!(a, b);
let mut sorted = a.clone();
sorted.sort_unstable();
assert_eq!(a, sorted, "chunk position order");
}
+15 -56
View File
@@ -2,7 +2,6 @@
//! paddling controls, collisions, riding (`positionRider`, `clampRotation`,
//! dismounting), `BoatItem` placement, `VehicleEntity.hurtServer` breaking,
//! saving and `AbstractBoatRenderer` with animated paddles.
mod below_world;
mod placement;
use super::{Game, InteractTarget, entity::Entity, entity_culling::at, view_vector};
@@ -22,7 +21,7 @@ use horizon_client::{
motion::{BubbleColumn, RiddenBoat, bubble_column_at},
};
use horizon_core::{
geometry::{Aabb, ShapeOp, Vec3, VoxelShape, trig},
geometry::{Aabb, ShapeOp, Vec3, VoxelShape},
position::BlockPos,
random::{LegacyRandom, RandomSource},
};
@@ -97,9 +96,6 @@ pub(super) struct Boat {
/// `Entity.fallDistance`: counted by `AbstractBoat.checkFallDamage`, saved
/// and loaded (`fall_distance`).
pub fall_distance: f64,
/// `AbstractBoat.lastYd`: the vertical velocity at the last move's
/// `checkFallDamage`, before collisions zero it (not saved).
pub last_yd: f64,
/// `Entity.stuckSpeedMultiplier`: set where the boat sits in powder snow
/// (`PowderSnowBlock.entityInside`), spent by the next move.
stuck: Vec3,
@@ -166,21 +162,6 @@ pub(super) fn saved_fire(tag: &Tag) -> i32 {
}
pub(super) const WIDTH: f64 = 1.375;
pub(super) const HEIGHT: f64 = 0.5625;
/// `AbstractBoat.controlBoat`'s push along the boat's yaw:
/// `Mth.sin(-yRot * 0.017453292F) * acceleration` and `Mth.cos(..)`, with
/// `Mth`'s sine table and float products, widened to double.
fn paddle_push(yaw: f32, acceleration: f32) -> (f64, f64) {
let (x, z) = (-yaw * 0.017_453_292, yaw * 0.017_453_292);
(
f64::from(trig::sin(f64::from(x)) * acceleration),
f64::from(trig::cos(f64::from(z)) * acceleration),
)
}
/// `getBbWidth() * Mth.SQRT_OF_TWO` in `getDismountLocationForPassenger`: a
/// float product.
fn boat_escape_width() -> f64 {
f64::from(WIDTH as f32 * std::f32::consts::SQRT_2)
}
impl Boat {
/// `AbstractBoat.tick`: the hurt wobble and the damage wear off, before
/// `Entity.baseTick` can burn the boat.
@@ -236,7 +217,6 @@ impl Boat {
input: [false; 4],
on_ground: false,
fall_distance: 0.0,
last_yd: 0.0,
stuck: Vec3::ZERO,
chest: false,
items: Vec::new(),
@@ -562,8 +542,7 @@ impl Game<'_> {
fn water_level_above(&self, b: &Boat) -> f32 {
let bb = b.bounds();
let (lo, hi) = (bb.min_position(), bb.max_position());
// Up to where a falling boat's top was a move ago (`ceil(maxY - lastYd)`).
let (y0, y1) = (hi.y.floor() as i32, (hi.y - b.last_yd).ceil() as i32);
let (y0, y1) = (hi.y.floor() as i32, hi.y.ceil() as i32);
'rows: for y in y0..y1 {
let mut height = 0.0f32;
for x in lo.x.floor() as i32..hi.x.ceil() as i32 {
@@ -855,9 +834,9 @@ impl Game<'_> {
if down {
acceleration -= 0.005;
}
let (x, z) = paddle_push(b.yaw, acceleration);
b.velocity.x += x;
b.velocity.z += z;
let r = b.yaw.to_radians();
b.velocity.x += f64::from((-r).sin() * acceleration);
b.velocity.z += f64::from(r.cos() * acceleration);
b.paddling = [right && !left || up, left && !right || up];
}
// Entity.move: collisions zero the blocked components, the world border's
@@ -876,7 +855,6 @@ impl Game<'_> {
b.position.z + moved.z,
);
b.on_ground = movement.y < 0.0 && moved.y > movement.y;
b.last_yd = b.velocity.y;
b.fall_distance = self.boat_fall(&b, moved.y);
if (moved.x - movement.x).abs() > 1.0e-7 {
b.velocity.x = 0.0;
@@ -999,7 +977,7 @@ impl Game<'_> {
};
self.boats[i].wear_off_hurt();
// Entity.baseTick burns it before it moves; the fire may break it.
if !self.boat_fire_tick(i) && !self.boat_below_world(i) {
if !self.boat_fire_tick(i) {
let b = self.boats[i].clone();
self.boats[i] = self.tick_boat(b, false);
self.boat_server_passes(id, &driven, &ticking, &round);
@@ -1457,7 +1435,7 @@ impl Game<'_> {
if self.dismount_in_portal(self.boats[i].position) {
return;
}
let target = self.boat_dismount_target(i, f64::from(0.6f32), self.yaw);
let target = self.boat_dismount_target(i, 0.6, self.yaw);
if let Some(p) = self.player.as_mut() {
p.position = target;
p.velocity = Vec3::ZERO;
@@ -1467,8 +1445,14 @@ impl Game<'_> {
/// facing `yaw`.
pub(super) fn boat_dismount_target(&self, i: usize, width: f64, yaw: f32) -> Vec3 {
let b = &self.boats[i];
let off = super::mounts::escape_vector(boat_escape_width(), width, yaw);
let (x, z) = (b.position.x + off.x, b.position.z + off.z);
let r = yaw.to_radians();
let distance = (WIDTH * std::f64::consts::SQRT_2 + width + 1.0e-5) / 2.0;
let (s, c) = (-(r.sin()) as f64, r.cos() as f64);
let m = s.abs().max(c.abs());
let (x, z) = (
b.position.x + s * distance / m,
b.position.z + c * distance / m,
);
let top = b.bounds().max_position().y;
let (bx, by, bz) = (x.floor() as i32, top.floor() as i32, z.floor() as i32);
let below = self.res.facts().get(self.world.block(bx, by - 1, bz));
@@ -1933,31 +1917,6 @@ impl Game<'_> {
mod tests {
use super::*;
/// `controlBoat`'s push with `Mth`'s sine table (65,536 float entries,
/// indexed by `(long)(radians * 10430.378350470453) & 65535`) and float
/// products. Expected values worked out apart from Horizon, in Python with
/// float32 rounding; the platform's sine differs in the fourth digit.
#[test]
fn paddling_pushes_along_the_sine_table() {
for (yaw, push) in [
(17.3, (-0.011893643997609615, 0.03819085285067558)),
(-123.45, (0.03337550163269043, -0.022047126665711403)),
(250.0, (0.03758755698800087, -0.013681206852197647)),
] {
assert_eq!(paddle_push(yaw, 0.04), push, "yaw {yaw}");
}
}
/// `getDismountLocationForPassenger`'s escape vector for a player (0.6F
/// wide) facing 17.3 degrees: the boat's float width times
/// `Mth.SQRT_OF_TWO`, the 1.0E-5F margin and the sine table, worked out
/// apart from Horizon as above.
#[test]
fn a_boat_rider_steps_off_along_the_sine_table() {
let v = super::super::mounts::escape_vector(boat_escape_width(), f64::from(0.6f32), 17.3);
assert_eq!((v.x, v.z), (-0.3962207300897882, 1.2722768119620014));
}
fn boat() -> Boat {
Boat::new(1, Arc::from("oak"), Vec3::ZERO, 0.0)
}
@@ -1,110 +0,0 @@
//! `Entity.checkBelowWorld` for boats and minecarts: one more than 64 blocks
//! under its level's floor is discarded (`onBelowWorld`) before it moves.
//! `AbstractBoat.tick` reaches it through `Entity.baseTick`, after the fire;
//! `AbstractMinecart.tick` calls it itself, after its hurt and damage wear off.
//! A discard is a destroying removal: the riders get off where they are and a
//! chest boat or container minecart spills its slots
//! (`AbstractChestBoat.remove`, `AbstractMinecartContainer.remove`), but no
//! ENTITY_DIE and no item of the vehicle itself.
use super::super::{minecarts::cart_at, native_ticks::TickRound};
use super::Game;
/// `Entity.checkBelowWorld`: `getY() < level.getMinY() - 64`.
pub(super) fn below_world(y: f64, min_y: i32) -> bool {
y < f64::from(min_y - 64)
}
impl Game<'_> {
/// `Entity.checkBelowWorld` for the boat at `i`: true when it fell out of
/// the world and is gone.
pub(in crate::game) fn boat_below_world(&mut self, i: usize) -> bool {
if !below_world(self.boats[i].position.y, self.world.bottom_y()) {
return false;
}
self.eject_boat_mobs(i, true);
let id = self.boats[i].id;
self.release_vehicle_container(id);
let b = self.boats.remove(i);
self.drop_vehicle_contents(b.position.components(), &b.items);
self.boat_removed(&b);
true
}
/// The minecart `id` at or after `slot`, unless `AbstractMinecart.tick`'s
/// `checkBelowWorld` discards it: then its tick is over and the round's
/// post hook runs for it.
pub(in crate::game) fn cart_in_world(
&mut self,
id: u32,
slot: usize,
round: &TickRound,
kind: &str,
) -> Option<usize> {
let i = cart_at(&self.carts, id, slot)?;
if !below_world(self.carts[i].position.y, self.world.bottom_y()) {
return Some(i);
}
self.eject_cart_mobs(i);
self.release_vehicle_container(id);
let c = self.carts.remove(i);
self.drop_vehicle_contents(c.position.components(), &c.items);
self.native_tick_post_one(round, id, kind);
None
}
}
#[cfg(test)]
mod tests {
use super::below_world;
/// The line is 64 under the floor and the vehicle must be below it:
/// -128 in the overworld (floor -64), -64 in the Nether and the End.
#[test]
fn a_vehicle_falls_out_of_the_world_64_blocks_under_the_floor() {
assert!(!below_world(-128.0, -64));
assert!(below_world(-128.001, -64));
assert!(!below_world(-100.0, -64));
assert!(!below_world(-64.0, 0));
assert!(below_world(-64.5, 0));
}
/// A tick discards the boat and the chest minecart under -128 in the
/// overworld, before they move, and spills the cart's slots; the two on
/// the line or above it stay, whatever their fall this tick.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_tick_discards_the_vehicles_under_the_world() {
use horizon_client::chunk::Chunk;
use horizon_core::position::ChunkPos;
let mut g = crate::game::native_machines::tests::server_game();
g.build_registries();
g.set_allow_commands(true);
g.world
.chunks
.insert((0, 0), std::sync::Arc::new(Chunk::empty(0, 0)));
g.block_ticks.add_empty_chunk(ChunkPos::new(0, 0)).unwrap();
assert!(g.run_command("forceload add 0 0"));
for summon in [
"summon oak_boat 0.5 -128 0.5",
"summon oak_boat 4.5 -128.5 4.5",
"summon minecart 6.5 -127 6.5",
"summon chest_minecart 2.5 -129 2.5 {Items:[{Slot:0b,id:\"minecraft:diamond\",count:3}]}",
] {
assert!(g.run_command(summon), "{summon}");
}
g.sync_tick_chunks();
g.tick_boats();
g.tick_carts();
let boats: Vec<f64> = g.boats.iter().map(|b| b.position.x).collect();
assert_eq!(boats, [0.5]);
let carts: Vec<f64> = g.carts.iter().map(|c| c.position.x).collect();
assert_eq!(carts, [6.5]);
let spilled: u32 = g
.dropped
.iter()
.filter(|i| &*i.stack.item == "minecraft:diamond")
.map(|i| i.stack.count)
.sum();
assert_eq!(spilled, 3);
}
}
+1 -32
View File
@@ -8,12 +8,9 @@ use super::commands;
#[cfg(test)]
#[path = "revenge_home_tests.rs"]
mod revenge_home_tests;
#[cfg(test)]
#[path = "zombie_ignite_tests.rs"]
mod zombie_ignite_tests;
use horizon_client::{
health::HealthEvent,
mobs::{Mob, Species, Who},
mobs::{Mob, Species},
motion::MotionEvent,
particles::{ParticleOptions, ParticleType},
text::Text,
@@ -121,11 +118,6 @@ pub(super) struct CombatTracker {
/// it the attacker is the mob nearest to where the hit came from, which
/// walks every mob.
pub(super) melee_mob: Option<u32>,
/// The attack event handled last hurt its target (`doHurtTarget` returned
/// true). The events of one blow read it: `Zombie.doHurtTarget` burns only
/// once `super.doHurtTarget` did (`MobEvent::ZombieIgnite` follows the
/// attack it rides).
pub(super) hit_landed: bool,
/// A firework rocket's burst is hurting the player now: the rocket is
/// the direct entity (`rocket_hurt_player`).
pub(super) rocket_hit: bool,
@@ -230,29 +222,6 @@ impl CombatTracker {
}
}
impl Game<'_> {
/// `Entity.igniteForSeconds`: `seconds * 20` ticks of fire, scaled by the
/// target's burning-time attribute.
pub(super) fn ignite_entity(
&mut self,
target: Who,
seconds: i32,
) -> horizon_client::Result<()> {
match target {
Who::Player => {
if !self.creative
&& let Some(p) = self.player.as_mut()
{
p.ignite_for_ticks(seconds * 20)?;
}
}
Who::Mob(id) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.ignite_for_ticks(seconds * 20);
}
}
}
Ok(())
}
/// `LivingEntity.makeDrownParticles`: eight bubbles around the entity,
/// moving with it.
pub(super) fn drown_particles(&mut self, at: [f64; 3], velocity: [f64; 3]) {
@@ -46,13 +46,13 @@ pub(super) struct CommandBlockScreen {
}
/// What `ServerboundSetCommandBlockPacket` carries.
pub(super) struct CommandBlockEdit {
pub(super) pos: Pos,
pub(super) command: String,
pub(super) track_output: bool,
pub(super) mode: Mode,
pub(super) conditional: bool,
pub(super) auto: bool,
struct CommandBlockEdit {
pos: Pos,
command: String,
track_output: bool,
mode: Mode,
conditional: bool,
auto: bool,
}
#[derive(Clone, Copy, PartialEq)]
@@ -948,6 +948,8 @@ impl Game<'_> {
self.as_guest_messaged(host, conn, |game| {
if allowed {
game.apply_command_block(&edit);
// BaseCommandBlock.onUpdated: the clients get the new command.
game.mark_block_entity_updated(edit.pos);
} else {
game.system_message(Text::translate("advMode.notAllowed", &[]));
}
@@ -982,7 +984,7 @@ impl Game<'_> {
);
}
/// What `handleSetCommandBlock` does once the player may edit the block.
pub(super) fn apply_command_block(&mut self, s: &CommandBlockEdit) {
fn apply_command_block(&mut self, s: &CommandBlockEdit) {
let pos = s.pos;
let registry = self.pack.assets.registry;
let Some(current) = self.block_state_at(pos) else {
@@ -1040,9 +1042,6 @@ impl Game<'_> {
};
self.system_message(Text::translate(key, &[Text::literal(command)]));
}
// `BaseCommandBlock.onUpdated`: the edited block entity is saved and
// its clients (and comparators) see the new command.
self.mark_block_entity_updated(pos);
}
}
+18 -38
View File
@@ -118,14 +118,12 @@ impl Game<'_> {
Target::Item(id) => {
let uuid = self.uuid_for(id);
let e = self.dropped.iter().find(|e| e.id == id)?;
let mut tag = entity_nbt::item_entity_to_nbt_with_items(
entity_nbt::item_entity_to_nbt_with_items(
e,
uuid,
self.persist.raw.get(&id),
self.items(),
);
self.save_portal_cooldown(id, &mut tag);
tag
)
}
Target::Stand(id) => {
let uuid = self.uuid_for(id);
@@ -151,7 +149,6 @@ impl Game<'_> {
self.put_mob_passengers(&mut tag, id);
// AbstractBoat.addAdditionalSaveData: the live or pending leash.
self.write_boat_leash(&mut tag, i);
self.save_portal_cooldown(id, &mut tag);
tag
}
Target::Cart(id) => {
@@ -159,7 +156,6 @@ impl Game<'_> {
let c = self.carts.iter().find(|c| c.id == id)?;
let mut tag = c.to_nbt(uuid, self.persist.raw.get(&id));
self.put_mob_passengers(&mut tag, id);
self.save_portal_cooldown(id, &mut tag);
tag
}
Target::Fangs(id) => {
@@ -784,13 +780,6 @@ impl Game<'_> {
if self.pot_data(pos).is_some() {
return self.set_pot_data(pos, &tag).unwrap_or(false);
}
if let Some(changed) = self.load_comparator_output(pos, &tag) {
let changed = changed.unwrap_or(false);
if changed {
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
}
return changed;
}
let key = (pos[0], pos[1], pos[2]);
let was_auto = self.command_block_auto(key);
let Some(e) = self.world.block_entities.get(&key) else {
@@ -898,29 +887,6 @@ impl Game<'_> {
true
}
/// `ComparatorBlockEntity.loadAdditional` (`getIntOr("OutputSignal", 0)`)
/// for a comparator at `p`: None for any other block; else whether the
/// loaded output differs from the one it had (None inside when the tag
/// does not decode). The block's own metadata comes from `comparator_data`.
fn load_comparator_output(&mut self, p: [i32; 3], tag: &Tag) -> Option<Option<bool>> {
let mut input = self.comparator_data(p)?;
let before = input.get("OutputSignal").cloned();
if let Some(value) = tag.get("OutputSignal") {
set_key(&mut input, "OutputSignal", value.clone());
} else if let Tag::Compound(map) = &mut input {
map.remove(&"OutputSignal".into());
}
let Some(normalized) = horizon_client::block_entity_nbt::canonical(&input) else {
return Some(None);
};
let output = normalized.get("OutputSignal").cloned();
if let Some(Tag::Int(value)) = output {
let pos = horizon_core::position::BlockPos::new(p[0], p[1], p[2]);
self.block_ticks.comparator_outputs.insert(pos, value);
}
Some(Some(output != before))
}
/// `ComparatorBlockEntity.saveAdditional`: expose the circuit's live output,
/// rather than keeping a second copy in the ordinary block entity map.
fn comparator_data(&self, p: [i32; 3]) -> Option<Tag> {
@@ -1084,8 +1050,22 @@ impl Game<'_> {
return Ok(());
}
let key = (p[0], p[1], p[2]);
if let Some(changed) = self.load_comparator_output(*p, &tag) {
changed.ok_or_else(|| err("commands.data.block.invalid", &[]))?;
if let Some(mut input) = self.comparator_data(*p) {
// ComparatorBlockEntity.loadAdditional reads getIntOr(0).
// Reuse the existing codec, with the block's own metadata.
if let Some(value) = tag.get("OutputSignal") {
set_key(&mut input, "OutputSignal", value.clone());
} else if let Tag::Compound(map) = &mut input {
map.remove(&"OutputSignal".into());
}
let normalized = horizon_client::block_entity_nbt::canonical(&input)
.ok_or_else(|| err("commands.data.block.invalid", &[]))?;
if let Some(Tag::Int(output)) = normalized.get("OutputSignal") {
self.block_ticks.comparator_outputs.insert(
horizon_core::position::BlockPos::new(p[0], p[1], p[2]),
*output,
);
}
self.mark_block_entity_updated(key);
return Ok(());
}
@@ -15,7 +15,7 @@ use horizon_protocol::packets::{
Empty,
play::{self, Clientbound as C, DialogHolder},
};
use horizon_world::nbt::{JavaString, Tag};
use horizon_world::nbt::Tag;
use std::{collections::HashMap, time::Instant};
/// Waypoint.Icon: the locator bar style and colour of a transmitter.
@@ -33,48 +33,6 @@ impl Default for WaypointIcon {
}
}
impl WaypointIcon {
/// `Waypoint.Icon.hasData`.
fn has_data(&self) -> bool {
self.style != "minecraft:default" || self.color.is_some()
}
/// `LivingEntity.readAdditionalSaveData`'s `locator_bar_icon`
/// (`Waypoint.Icon.CODEC`: a required `style` and an optional int `color`).
/// None when the key is missing or does not decode, which is the default icon.
fn from_saved(entity: Option<&Tag>) -> Option<Self> {
let icon = entity?.get("locator_bar_icon")?;
let Some(Tag::String(style)) = icon.get("style") else {
return None;
};
let color = match icon.get("color") {
Some(Tag::Int(rgb)) => Some(*rgb),
_ => None,
};
Some(Self {
style: style.to_string_lossy(),
color,
})
}
/// `LivingEntity.addAdditionalSaveData`'s `locator_bar_icon`: written only
/// when the icon `hasData`, so any other icon takes the key out.
fn save_into(&self, entity: &mut Option<Tag>) {
if !self.has_data() {
if let Some(Tag::Compound(map)) = entity {
map.remove(&JavaString::from("locator_bar_icon"));
}
return;
}
let mut fields = vec![("style", Tag::String(self.style.as_str().into()))];
if let Some(rgb) = self.color {
fields.push(("color", Tag::Int(rgb)));
}
if let Tag::Compound(map) = entity.get_or_insert_with(|| Tag::Compound(Default::default()))
{
map.insert("locator_bar_icon".into(), Tag::compound(fields));
}
}
}
/// A `/fetchprofile` lookup in flight: what it resolves, the message ids it
/// answers with (`commands.fetchprofile.<kind>.success|failure`), the
/// argument they show, and the source to answer.
@@ -443,12 +401,6 @@ impl Game<'_> {
.waypoint_icons
.get(&self.waypoint_target(t))
.cloned()
// The player's own icon comes back from their saved `locator_bar_icon`.
.or_else(|| {
(t == Target::Player)
.then(|| WaypointIcon::from_saved(self.level.player_extra.as_ref()))
.flatten()
})
.unwrap_or_default()
}
/// `WaypointArgument.getWaypoint`.
@@ -557,10 +509,6 @@ impl Game<'_> {
&[Text::colored(name, rgb)],
)
};
// LivingEntity.addAdditionalSaveData: the player's icon goes into their saved tag.
if t == Target::Player {
icon.save_into(&mut self.level.player_extra);
}
let t = self.waypoint_target(t);
self.cmds.waypoint_icons.insert(t, icon);
// WaypointCommand.mutateIcon: untrack, change, track again.
@@ -1362,81 +1310,3 @@ impl Game<'_> {
}
}
impl Game<'_> {}
#[cfg(test)]
mod waypoint_icon_save_tests {
use super::*;
use crate::save::Level;
fn icon(style: &str, color: Option<i32>) -> WaypointIcon {
WaypointIcon {
style: style.into(),
color,
}
}
#[test]
fn an_icon_set_by_waypoint_modify_survives_a_save_and_a_load() {
let mut level = Level::default();
icon("minecraft:bowtie", Some(0x00FF_0000)).save_into(&mut level.player_extra);
// The player's file: `locator_bar_icon` is `{style, color}`.
let saved = level.player_tag();
assert_eq!(
saved.get("locator_bar_icon"),
Some(&Tag::compound([
("style", Tag::String("minecraft:bowtie".into())),
("color", Tag::Int(0x00FF_0000)),
]))
);
// Loaded again: the same icon.
assert_eq!(
WaypointIcon::from_saved(Some(&saved)),
Some(icon("minecraft:bowtie", Some(0x00FF_0000)))
);
}
#[test]
fn a_style_alone_or_a_colour_alone_is_data_and_is_saved() {
let mut only_style = None;
icon("minecraft:bowtie", None).save_into(&mut only_style);
let tag = only_style.unwrap();
assert_eq!(
tag.get("locator_bar_icon"),
Some(&Tag::compound([(
"style",
Tag::String("minecraft:bowtie".into())
)]))
);
let mut only_color = None;
icon("minecraft:default", Some(7)).save_into(&mut only_color);
assert_eq!(
WaypointIcon::from_saved(only_color.as_ref()),
Some(icon("minecraft:default", Some(7)))
);
}
#[test]
fn a_player_with_no_icon_saves_no_key_and_a_reset_takes_it_out() {
let mut level = Level::default();
assert!(level.player_tag().get("locator_bar_icon").is_none());
// The default icon on a player with no file writes nothing, not even a file.
WaypointIcon::default().save_into(&mut level.player_extra);
assert!(level.player_extra.is_none());
icon("minecraft:default", Some(1)).save_into(&mut level.player_extra);
assert!(level.player_tag().get("locator_bar_icon").is_some());
// `/waypoint modify ... reset` leaves the default icon: the key goes.
WaypointIcon::default().save_into(&mut level.player_extra);
assert!(level.player_tag().get("locator_bar_icon").is_none());
}
#[test]
fn a_save_without_the_key_or_with_a_broken_one_loads_the_default_icon() {
assert_eq!(WaypointIcon::from_saved(None), None);
let none = Tag::compound([("Health", Tag::Float(20f32.to_bits()))]);
assert_eq!(WaypointIcon::from_saved(Some(&none)), None);
// `style` is required by the codec.
let no_style =
Tag::compound([("locator_bar_icon", Tag::compound([("color", Tag::Int(5))]))]);
assert_eq!(WaypointIcon::from_saved(Some(&no_style)), None);
}
}
+32 -20
View File
@@ -2270,11 +2270,6 @@ impl Game<'_> {
self.next_entity += 1;
p.id = self.next_entity;
}
// The UUID its own random made, unless it already has one (a client
// copy's from the spawn packet).
if let Some(uuid) = p.uuid.take() {
self.persist.uuids.entry(p.id).or_insert(uuid);
}
// Projectile.setOwner: the acting player's or the mob's UUID, which
// its save keeps even once the owner is gone.
if p.owner_uuid.is_none() {
@@ -2555,8 +2550,7 @@ impl Game<'_> {
// above use their movement's flyDist/nextFlap threshold.
MobEvent::GameEvent { .. } => {}
MobEvent::AttackPlayer(amount, from) => {
self.combat.hit_landed =
self.mob_hits_player(amount, from, "minecraft:mob_attack");
self.mob_hits_player(amount, from, "minecraft:mob_attack");
}
// doHurtTarget: the effect only when hurtServer took damage.
// Bee.doHurtTarget: a sting that hurt leaves the stinger in the
@@ -2580,9 +2574,7 @@ impl Game<'_> {
ticks,
amplifier,
} => {
self.combat.hit_landed =
self.mob_hits_player(amount, from, "minecraft:mob_attack");
if self.combat.hit_landed {
if self.mob_hits_player(amount, from, "minecraft:mob_attack") {
self.give_player_effect(effect, ticks, amplifier);
}
}
@@ -2771,7 +2763,7 @@ impl Game<'_> {
amount,
source,
} => {
self.combat.hit_landed = self.mob_attacks_mob(target, amount, source);
self.mob_attacks_mob(target, amount, source);
}
MobEvent::DamageMob {
target,
@@ -2791,8 +2783,7 @@ impl Game<'_> {
// CaveSpider, Husk and WitherSkeleton.doHurtTarget: the
// effect sits inside the branch where the hit landed, and
// `LivingEntity.addEffect` takes a mob as well as a player.
self.combat.hit_landed = self.mob_attacks_mob(target, amount, source);
if self.combat.hit_landed
if self.mob_attacks_mob(target, amount, source)
&& let Some(t) = self.mobs.iter_mut().find(|m| m.id == target)
{
t.add_effect(effect, ticks, amplifier);
@@ -3465,11 +3456,20 @@ impl Game<'_> {
t.add_effect(effect, ticks, amplifier);
}
}
MobEvent::Ignite(target, seconds) => self.ignite_entity(target, seconds)?,
MobEvent::ZombieIgnite(target, seconds) if self.combat.hit_landed => {
self.ignite_entity(target, seconds)?
}
MobEvent::ZombieIgnite(..) => {}
MobEvent::Ignite { target, seconds } => match target {
Who::Player => {
if !self.creative
&& let Some(p) = self.player.as_mut()
{
p.ignite_for_ticks(seconds * 20)?;
}
}
Who::Mob(id) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.ignite_for_ticks(seconds * 20);
}
}
},
MobEvent::Breed {
parent,
partner,
@@ -5524,7 +5524,10 @@ impl Game<'_> {
let mut dodged = false;
// AbstractArrow.onHitEntity: a burning arrow sets its target alight.
if p.arrow.as_ref().is_some_and(|a| a.on_fire()) {
self.handle_mob_events(vec![MobEvent::Ignite(who, 5)])?;
self.handle_mob_events(vec![MobEvent::Ignite {
target: who,
seconds: 5,
}])?;
}
let hurt = match who {
Who::Player => {
@@ -7365,7 +7368,16 @@ impl Game<'_> {
let velocity = self.known_player_movement();
let start = Vec3::new(eye[0], eye[1] - 0.1, eye[2]);
let mut proj = Projectile::new(kind.clone(), start, Some(Who::Player));
proj.shoot_from_rotation(self.pitch, self.yaw, 0.0, 1.5, 1.0, velocity, on_ground);
proj.shoot_from_rotation(
&mut self.world.random,
self.pitch,
self.yaw,
0.0,
1.5,
1.0,
velocity,
on_ground,
);
self.add_projectile(proj);
// SnowballItem.use / EggItem.use / EnderpearlItem.use: awardStat(ITEM_USED).
self.award_stat("used", item, 1);
+1 -1
View File
@@ -371,7 +371,7 @@ impl Game<'_> {
}
// Flame: the arrow is spawned burning (EnchantmentHelper.onProjectileSpawned).
self.ignite_spawned_arrow(&weapon, &mut arrow);
arrow.shoot_own(dir.to_array(), power, 1.0);
arrow.shoot(&mut self.world.random, dir.to_array(), power, 1.0);
self.add_projectile(arrow);
let (player, name, projectile) = (
horizon_client::lan::playerdata::ints_uuid(self.acting_player_uuid()),
@@ -44,8 +44,6 @@ mod transfer;
/// What the tick loop tells `ServerWatchdog`: when the tick in progress was
/// due (`getNextTickTime`), how long a tick is, and whether the server runs.
struct Watchdog {
/// The thread running the ticks, whose stack the report carries.
server: horizon_threads::stack_dump::Watched,
base: Instant,
next_nanos: AtomicU64,
tick_nanos: AtomicU64,
@@ -55,11 +53,9 @@ struct Watchdog {
impl Watchdog {
/// `DedicatedServer.initServer`: a thread that stops a server whose tick
/// runs `max_tick_time` ms past its due time; none when that is 0 or less.
/// Started from the thread that runs the ticks.
fn start(max_tick_time: i64) -> Option<Arc<Self>> {
let max = Duration::from_millis(u64::try_from(max_tick_time).ok().filter(|m| *m > 0)?);
let watchdog = Arc::new(Self {
server: horizon_threads::stack_dump::Watched::current(),
base: Instant::now(),
next_nanos: AtomicU64::new(0),
tick_nanos: AtomicU64::new(50_000_000),
@@ -88,8 +84,6 @@ impl Watchdog {
let now = u64::try_from(self.base.elapsed().as_nanos()).unwrap_or(u64::MAX);
let behind = now.saturating_sub(next);
if behind > max {
// ServerWatchdog: the server thread's stack first, while it is still stuck.
let stack = self.server.stack(Duration::from_secs(5));
let took = format!("{:.2}", behind as f32 / 1e9);
let should = format!(
"{:.2}",
@@ -112,7 +106,6 @@ impl Watchdog {
std::path::Path::new("."),
"server",
message,
stack,
) {
Some(path) => log_from(
"Server Watchdog",
+1 -5
View File
@@ -198,11 +198,7 @@ impl Game<'_> {
} else {
format!("minecraft:{name}")
};
let ids = r
.registries
.tag("minecraft:dialog", &name)
.into_iter()
.flatten();
let ids = r.registries.dialog_tags.get(&name).into_iter().flatten();
ids.filter_map(|&i| list?.get(usize::try_from(i).ok()?)?.1.clone())
.collect()
};
+7 -7
View File
@@ -439,15 +439,14 @@ impl Game<'_> {
// ---- DefaultDispenseItemBehavior ----
/// `DispenserBlock.getDispensePosition(source, scale, offset)`; vanilla adds
/// `scale * step + offset` first, then adds that sum to the block centre.
/// `DispenserBlock.getDispensePosition(source, scale, offset)`.
fn dispense_position(pos: Pos, facing: Direction, scale: f64, offset: [f64; 3]) -> Vec3 {
let s = step(facing);
let c = Self::center(pos);
Vec3::new(
c[0] + (scale * f64::from(s[0]) + offset[0]),
c[1] + (scale * f64::from(s[1]) + offset[1]),
c[2] + (scale * f64::from(s[2]) + offset[2]),
c[0] + scale * f64::from(s[0]) + offset[0],
c[1] + scale * f64::from(s[1]) + offset[1],
c[2] + scale * f64::from(s[2]) + offset[2],
)
}
/// `DefaultDispenseItemBehavior.spawnItem` at `getDispensePosition`.
@@ -1194,12 +1193,13 @@ impl Game<'_> {
return;
}
// Projectile.shoot spreads the shot with the projectile's own random.
let mut own = random_source::entity();
match item.as_str() {
"minecraft:experience_bottle"
| "minecraft:splash_potion"
| "minecraft:lingering_potion" => {
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
shot.shoot(&mut random_source::entity(), dir, power, uncertainty);
shot.shoot(&mut own, dir, power, uncertainty);
self.launch_thrown(one, at, shot.velocity, None);
}
_ => {
@@ -1246,7 +1246,7 @@ impl Game<'_> {
a.set_item(&one);
a.pickup = horizon_client::arrows::Pickup::Allowed;
}
shot.shoot_own(dir, power, uncertainty);
shot.shoot(&mut own, dir, power, uncertainty);
self.add_projectile(shot);
}
}
+5 -1
View File
@@ -380,7 +380,11 @@ impl Game<'_> {
/// The environment attributes of the current dimension.
pub(super) fn dim_environment(&self) -> &Environment {
self.res.environment_of(&self.dimension_type().id)
match self.dim {
Dim::Overworld => self.res.environment(),
Dim::Nether => self.res.nether_environment(),
Dim::End => self.res.end_environment(),
}
}
/// SkyRenderer.renderEndFlash, drawn after the renderer's resident sky mesh.
@@ -124,7 +124,6 @@ impl Game<'_> {
|| level.facts.is_air(below)
|| &*support.name == "minecraft:bedrock"
|| !support.full_collision
|| !b.can_survive(level, ticks, at, state)
|| occupants.iter().any(|bounds| bounds.intersects(area))
{
return None;
+1 -1
View File
@@ -189,7 +189,7 @@ impl Game<'_> {
let (bx, bz) = (view.x.floor() as i32, view.z.floor() as i32);
// Underground, under a roof or in the Nether: the floor around the player.
let covered = self.world.light(bx, head, bz) >> 4 == 0;
let layer = if self.world.shape.has_ceiling || covered {
let layer = if self.dim == Dim::Nether || covered {
Layer::Below(head)
} else {
Layer::Surface
+4 -1
View File
@@ -22,6 +22,9 @@ use horizon_client::{
hud_layout, hud_overlays,
};
/// `Abilities.walkingSpeed`.
const WALKING_SPEED: f32 = 0.1;
fn valid_fov_modifier(modifier: f32, fallback: f32) -> f32 {
if modifier.is_finite() && (0.01..=10.0).contains(&modifier) {
modifier
@@ -77,7 +80,7 @@ impl Game<'_> {
});
Some(FovPlayer {
flying: p.flying,
walking_speed: self.remote_walking_speed(),
walking_speed: WALKING_SPEED,
// Player.aiStep keeps `speed` as the float movement speed attribute.
movement_speed: f64::from(p.speed),
using,
+2 -1
View File
@@ -704,7 +704,8 @@ impl Game<'_> {
}
// `extractRenderState`: FIXED, seeded by the frame entity.
let skinning = self.skinning();
let lights = horizon_client::block_entity_render::diffuse_lights(self.nether_lighting());
let lights =
horizon_client::block_entity_render::diffuse_lights(self.dim == super::Dim::Nether);
for (stack, id, m, light, cull, kind) in items {
let entity_start = out.len();
self.mark_entity(entity_start, kind);
@@ -116,39 +116,3 @@ impl Game<'_> {
left.then_some(stack)
}
}
/// The item of the stack in `selected` (`Player.tick`'s main-hand item), borrowed: a guest's
/// tick compares it with the last one and clones it only when it changed.
pub(super) fn held_attack_item(
inventory: &[Option<ItemStack>],
selected: usize,
) -> Option<&Arc<str>> {
inventory.get(selected)?.as_ref().map(|s| &s.item)
}
#[cfg(test)]
mod tests {
use super::*;
fn stack(item: &str) -> ItemStack {
ItemStack {
item: item.into(),
count: 1,
damage: 0,
extra: None,
}
}
#[test]
fn held_attack_item_matches_the_cloned_stack() {
let mut inventory: Vec<Option<ItemStack>> = vec![None; 36];
inventory[0] = Some(stack("minecraft:stone"));
inventory[3] = Some(stack("minecraft:diamond_sword"));
for selected in [0, 1, 3, 35, 36, 1000] {
// The clone the tick used to make, as the reference.
let cloned = inventory.get(selected).cloned().flatten().map(|s| s.item);
assert_eq!(held_attack_item(&inventory, selected).cloned(), cloned);
}
assert_eq!(held_attack_item(&[], 0), None);
}
}
+3 -3
View File
@@ -471,7 +471,7 @@ impl Game<'_> {
light: u32,
out: &mut Vec<Vertex>,
) {
let lights = diffuse_lights(self.nether_lighting());
let lights = diffuse_lights(self.dim == Dim::Nether);
self.submit_item_lit(state, pose, light, lights, out);
}
/// [`Self::submit_item_world`] shaded by other diffuse lights (a
@@ -573,7 +573,7 @@ impl Game<'_> {
light: u32,
skinned: &mut super::creatures::Skinned,
) -> bool {
let lights = diffuse_lights(self.nether_lighting());
let lights = diffuse_lights(self.dim == Dim::Nether);
let Some((mark, bounds)) = self.submit_item_skinned(state, &[pose], light, lights) else {
return false;
};
@@ -618,7 +618,7 @@ impl Game<'_> {
let range =
super::entity_culling::default_range(glam::DVec3::splat(0.25), self.view_scale());
let skinning = self.skinning();
let lights = diffuse_lights(self.nether_lighting());
let lights = diffuse_lights(self.dim == Dim::Nether);
for (item, at, partial, entity) in live.chain(taken) {
let entity_start = out.len();
self.mark_motion(entity_start, (2 << 32) | u64::from(item.id));
+15 -6
View File
@@ -3556,9 +3556,13 @@ impl Game<'_> {
&& self.pvp_land(host, victim, &strike)
{
let on_ground = Self::pvp_victim_on_ground(host, victim);
let stops_sprint = strike.stops_sprint();
let sent = self.as_guest(host, conn, |g| g.pvp_hit_after(on_ground, stops_sprint));
self.send_guest_motion(host, conn, sent.flatten());
self.as_guest(host, conn, |g| {
g.weapon_hit_player(on_ground);
g.pvp_exhaustion();
if strike.stops_sprint() {
g.stop_sprint_after_extra_knockback();
}
});
}
return;
}
@@ -4373,11 +4377,16 @@ impl Game<'_> {
// Player.tick: a different main-hand item resets the attack
// strength, and detectEquipmentUpdates gives the guest its modifiers.
let g = &host.hub.guests[gi];
let held = super::held_items::held_attack_item(&g.inventory, g.selected);
let held = g
.inventory
.get(g.selected)
.cloned()
.flatten()
.map(|s| s.item);
let play = host.play.entry(conn).or_default();
if play.attack_item.as_ref() != held {
if play.attack_item != held {
play.attack_ticks = 0;
play.attack_item = held.cloned();
play.attack_item = held;
}
self.guest_delayed_destroy(host, conn);
self.guest_server_tick(host, conn);
+4 -2
View File
@@ -131,8 +131,10 @@ pub(super) fn acts_on_player(e: &MobEvent) -> bool {
| MobEvent::StabDismount {
target: Who::Player,
}
| MobEvent::Ignite(Who::Player, _)
| MobEvent::ZombieIgnite(Who::Player, _)
| MobEvent::Ignite {
target: Who::Player,
..
}
| MobEvent::IronGolemHit {
target: Who::Player,
..
+2 -2
View File
@@ -191,8 +191,8 @@ impl Game<'_> {
let disable_blocking = if self.using.is_some() {
0.0
} else {
self.held()
.map_or(0.0, |stack| self.items().weapon_disable_blocking(stack))
self.attack_item_info()
.map_or(0.0, |i| i.disable_blocking_seconds)
};
let ignite = self.weapon_ignite_seconds();
Some(Strike {
+4 -23
View File
@@ -36,25 +36,13 @@ impl Game<'_> {
let batch = match quiet::decode(data) {
Ok(b) => b,
Err(e) => {
// At most one a second: a client sending refused payloads by the hundred a tick
// must not fill the log.
static LAST: std::sync::Mutex<Option<std::time::Instant>> =
std::sync::Mutex::new(None);
let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner());
if last.is_none_or(|t| t.elapsed() >= std::time::Duration::from_secs(1)) {
*last = Some(std::time::Instant::now());
super::dedicated::log(
"WARN",
&format!("{name} sent quiet commands that were refused: {e}"),
);
}
super::dedicated::log(
"WARN",
&format!("{name} sent quiet commands that were refused: {e}"),
);
return;
}
};
// Nothing to run, nothing to log: an empty batch would only be a summary line.
if batch.lines.is_empty() {
return;
}
let waiting: usize = host
.quiet
.iter()
@@ -63,13 +51,6 @@ impl Game<'_> {
.sum();
let room = PENDING.saturating_sub(waiting);
let dropped = batch.lines.len().saturating_sub(room);
// No room at all: counted on the player's last waiting batch, not a new (logged) one.
if room == 0
&& let Some(last) = host.quiet.iter_mut().rev().find(|b| b.conn == conn)
{
last.dropped += dropped;
return;
}
host.quiet.push_back(QuietBatch {
conn,
mod_id: batch.mod_id,
@@ -280,8 +280,8 @@ impl Game<'_> {
};
let above = self.world.block(pos.x, root_y + 1, pos.z);
let above_facts = self.res.facts().get(above);
let evaporates = self.dimension_type().water_evaporates;
let fluid = if &*above_facts.name == "minecraft:mud" && !evaporates {
let nether = self.dim == super::Dim::Nether;
let fluid = if &*above_facts.name == "minecraft:mud" && !nether {
horizon_client::blocks::Fluid::Water
} else {
above_facts.fluid
@@ -289,9 +289,7 @@ impl Game<'_> {
let ty = match fluid {
horizon_client::blocks::Fluid::Water => ParticleType::DrippingDripstoneWater,
horizon_client::blocks::Fluid::Lava => ParticleType::DrippingDripstoneLava,
horizon_client::blocks::Fluid::None if evaporates => {
ParticleType::DrippingDripstoneLava
}
horizon_client::blocks::Fluid::None if nether => ParticleType::DrippingDripstoneLava,
horizon_client::blocks::Fluid::None => ParticleType::DrippingDripstoneWater,
};
let offset = self
+4 -16
View File
@@ -622,11 +622,7 @@ impl Game<'_> {
Dim::Nether => "level_the_nether",
Dim::End => "level_the_end",
});
// The level's own share of the pass, outside its world tick: a slow tick's line names
// each of them, and `level_tick` holds the world tick itself (#15349).
if let Err(e) =
horizon_client::spans::time("enter_level", || self.enter_level(None, dim))
{
if let Err(e) = self.enter_level(None, dim) {
result = Err(e);
break;
}
@@ -637,14 +633,10 @@ impl Game<'_> {
packets_drained: false,
};
if observe_level {
let _span = horizon_client::spans::guard("mods_level_tick_pre");
self.native_level_tick_pre(dim.id());
}
horizon_client::spans::time("dedicated_streaming", || {
self.update_dedicated_streaming();
});
horizon_client::spans::time("update_world", || self.update_world());
let tick_span = horizon_client::spans::guard("level_tick");
self.update_dedicated_streaming();
self.update_world();
result = horizon_client::block_contact::in_level(
(self.serving.is_some() || self.options.dedicated.is_some()).then_some(dim.id()),
|| {
@@ -661,19 +653,16 @@ impl Game<'_> {
}
},
);
drop(tick_span);
if result.is_err() {
break;
}
if observe_level {
let _span = horizon_client::spans::guard("mods_level_tick_post");
self.native_level_tick_post(dim.id());
}
horizon_client::spans::time("server_debug", || self.publish_server_debug(dim));
self.publish_server_debug(dim);
// An entity that crossed into a level that already ticked this
// server tick is in it when the tick ends (`addDuringTeleport`).
if running == Some(true) {
let _span = horizon_client::spans::guard("late_portal_arrivals");
let mut moved = false;
for &earlier in &order[..k] {
if !self.arrival_chunks(earlier).is_empty()
@@ -691,7 +680,6 @@ impl Game<'_> {
if let Some(&first) = order.first()
&& self.dim != first
{
let _span = horizon_client::spans::guard("enter_level");
let _ = self.enter_level(None, first);
}
self.level_pass = Pass::default();
-22
View File
@@ -378,28 +378,6 @@ impl Game<'_> {
self.audio.play(sound, Some(feet), 1.0, 1.0);
Some(fall)
}
/// What a server's guest does after their hit landed on another player:
/// the weapon's side, the exhaustion and the sprint a knockback stops.
/// Returns the attacker's motion when a smash set it, which
/// `MaceItem.hurtEnemy` sends at once (`ClientboundSetEntityMotionPacket`),
/// not at the end of the tick after gravity (#8246). The mark from before
/// the hit stays.
pub(super) fn pvp_hit_after(
&mut self,
victim_on_ground: bool,
stops_sprint: bool,
) -> Option<Vec3> {
let p = self.player.as_mut()?;
let marked = std::mem::take(&mut p.health.sync_velocity);
self.weapon_hit_player(victim_on_ground);
let p = self.player.as_mut()?;
let sent = std::mem::replace(&mut p.health.sync_velocity, marked).then_some(p.velocity);
self.pvp_exhaustion();
if stops_sprint {
self.stop_sprint_after_extra_knockback();
}
sent
}
/// `Player.itemAttackInteraction` after the player's hit hurt another
/// player: the weapon's side of it, as `weapon_hit` does for a mob. A
/// smash stops the fall and its Wind Burst (a `post_attack` explosion on
+18 -28
View File
@@ -80,7 +80,7 @@ impl Game<'_> {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Kind {
Generic,
/// The saved decorated-pot slot: hopper insertion and the comparator signal.
/// The saved decorated-pot slot, exposed only to hopper insertion.
Pot,
/// `AbstractFurnaceBlockEntity` (a `WorldlyContainer`).
Furnace,
@@ -362,27 +362,23 @@ impl Containers<'_> {
container: None,
})
}
/// The saved decorated-pot slot as a container: `DecoratedPotBlockEntity`
/// is a `ContainerSingleItem`, but Horizon keeps pots out of generic
/// container discovery (`world.block_entities`), so this is built on demand.
/// A pot with pending loot is left to its existing unpacking path.
fn saved_pot_view(&self, pos: Pos) -> Option<View> {
if self.name(self.world.block(pos.0, pos.1, pos.2)) != "minecraft:decorated_pot"
|| spill::saved_entity(self.world, [pos.0, pos.1, pos.2])
.is_some_and(|tag| matches!(tag.get("LootTable"), Some(Tag::String(_))))
{
return None;
}
Some(View {
parts: vec![(pos, 1)],
kind: Kind::Pot,
container: None,
})
}
/// `HopperBlockEntity.getBlockContainer` at its insertion target only.
/// Pots keep their authoritative slot in the chunk's saved tag.
/// Pots keep their authoritative slot in the chunk's saved tag. Pending
/// loot is deliberately left to its existing path, without unpacking it.
fn hopper_insertion_view(&self, pos: Pos) -> Option<View> {
self.view(pos, false).or_else(|| self.saved_pot_view(pos))
self.view(pos, false).or_else(|| {
if self.name(self.world.block(pos.0, pos.1, pos.2)) != "minecraft:decorated_pot"
|| spill::saved_entity(self.world, [pos.0, pos.1, pos.2])
.is_some_and(|tag| matches!(tag.get("LootTable"), Some(Tag::String(_))))
{
return None;
}
Some(View {
parts: vec![(pos, 1)],
kind: Kind::Pot,
container: None,
})
})
}
/// The `ContainerSingleItem` slot for hopper insertion: only a saved pot
/// needs an owned decode. Existing modeled containers stay borrowed.
@@ -523,9 +519,8 @@ impl Containers<'_> {
}
let mut total = 0.0f32;
for i in 0..view.len() {
// `insertion_item` decodes a saved pot's slot; every other kind is `get`.
if let Some(s) = self.insertion_item(view, i) {
total += s.count as f32 / self.max_stack(&s) as f32;
if let Some(s) = self.get(view, i) {
total += s.count as f32 / self.max_stack(s) as f32;
}
}
total /= view.len().max(1) as f32;
@@ -658,7 +653,6 @@ pub(super) fn container_signal(level: &super::GameLevel<'_>, pos: BlockPos) -> i
};
c.view((pos.x, pos.y, pos.z), true)
.filter(|v| !matches!(v.kind, Kind::Composter(_)))
.or_else(|| c.saved_pot_view((pos.x, pos.y, pos.z)))
.map_or(0, |v| c.signal(&v))
}
@@ -2431,10 +2425,6 @@ mod pot_hopper_insertion_tests {
}
}
#[cfg(test)]
#[path = "machines_pot_signal_tests.rs"]
mod pot_signal_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -380,40 +380,6 @@ mod tests {
c.set_slot_state(4, true);
assert_eq!(c.redstone_signal(), 1);
}
/// `CrafterBlockEntity.setSlotState`: a slot toggled in the menu lands on
/// the block entity itself, whose `setChanged` marks the chunk.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_slot_toggled_in_the_menu_lands_on_the_crafter() {
use crate::game::native_machines::tests::server_game;
let mut game = server_game();
let key = (2, 4, 3);
let state = game
.pack
.assets
.registry
.default_state("minecraft:crafter")
.unwrap();
game.world.set(key.0, key.1, key.2, state).unwrap();
let mut crafter = Crafter::default();
crafter.set_slot_state(3, false);
game.world
.block_entities
.put(key, BlockEntity::Crafter(crafter));
game.container = Some(key);
game.world.modified.clear();
game.crafter_slot_clicked(303, 0, horizon_client::menu::Input::Pickup);
let Some(BlockEntity::Crafter(c)) = game.world.block_entities.get(&key) else {
panic!("no crafter");
};
assert!(!c.disabled[3], "the click enabled the slot");
assert!(
game.world.modified.contains(&(0, 0)),
"the toggled slot left the chunk unmarked"
);
}
#[test]
fn hoppers_fill_the_smaller_stacks_first() {
let mut c = Crafter::default();
@@ -1,98 +0,0 @@
//! `DecoratedPotBlock.getAnalogOutputSignal` (`getRedstoneSignalFromBlockEntity`
//! → `getRedstoneSignalFromContainer`): a pot's one slot lives in the chunk's
//! saved block entity, so a comparator beside it has to read that tag.
use super::super::{native_machines::tests::server_game, spill};
use super::*;
use horizon_client::save;
use horizon_world::level::LevelAccess;
/// `getRedstoneSignalFromBlockEntity` for the block at `pos`, the path a
/// comparator's `getAnalogOutputSignal` takes.
fn signal(game: &mut Game<'_>, pos: Pos) -> i32 {
let state = game.world.block(pos.0, pos.1, pos.2);
game.with_level(|_, level, _| {
level.block_entity_analog_signal(BlockPos::new(pos.0, pos.1, pos.2), state, Direction::West)
})
}
fn saved_count(game: &Game<'_>, pos: Pos) -> u32 {
let tag = spill::saved_entity(&game.world, [pos.0, pos.1, pos.2]).expect("a saved pot");
save::item_from_nbt(tag.get("item").expect("the pot's item"))
.expect("a decodable pot item")
.count
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
fn a_pot_feeds_a_comparator_from_its_saved_slot_like_a_chest() {
let mut game = server_game();
game.build_registries();
game.set_allow_commands(true);
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
let pot = (8, 200, 8);
let chest = (8, 200, 10);
assert!(game.run_command("setblock 8 200 8 minecraft:decorated_pot"));
// Vanilla's signal for one slot (`Mth.lerpDiscrete(count / 64, 0, 15)`); a
// chest at the same fill fraction in all 27 slots reads the same.
for (count, expected) in [(64, 15), (32, 8), (1, 1)] {
assert!(game.run_command(&format!(
"data merge block 8 200 8 {{item:{{id:\"minecraft:stone\",count:{count}}}}}"
)));
let items: Vec<String> = (0..27)
.map(|slot| format!("{{Slot:{slot}b,id:\"minecraft:stone\",count:{count}}}"))
.collect();
assert!(game.run_command(&format!(
"setblock 8 200 10 minecraft:chest{{Items:[{}]}}",
items.join(",")
)));
assert_eq!(signal(&mut game, pot), expected, "a pot holding {count}");
assert_eq!(
signal(&mut game, chest),
expected,
"a chest holding {count} in every slot"
);
}
assert!(game.run_command("data remove block 8 200 8 item"));
assert!(game.run_command("data merge block 8 200 10 {Items:[]}"));
assert_eq!(signal(&mut game, pot), 0, "an empty pot");
assert_eq!(signal(&mut game, chest), 0, "an empty chest");
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
fn a_hopper_inserted_item_updates_the_comparator_beside_the_pot() {
let mut game = server_game();
game.build_registries();
game.set_allow_commands(true);
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
let hopper = (7, 200, 8);
let pot = (8, 200, 8);
let comparator = BlockPos::new(9, 200, 8);
assert!(game.run_command(
"setblock 7 200 8 minecraft:hopper[facing=east,enabled=true]{Items:[{Slot:0b,id:\"minecraft:stone\",count:1}],TransferCooldown:0}"
));
assert!(game.run_command(
"setblock 8 200 8 minecraft:decorated_pot{item:{id:\"minecraft:stone\",count:63}}"
));
assert!(game.run_command("setblock 9 200 8 minecraft:comparator[facing=west]"));
let output = |game: &Game<'_>| {
game.block_ticks
.comparator_outputs
.get(&comparator)
.copied()
.unwrap_or(0)
};
// `ComparatorBlock.tick` on the read side of the comparator.
let tick = |game: &mut Game<'_>| {
game.with_level(|b, level, ticks| b.scheduled_block_tick(level, ticks, comparator));
};
tick(&mut game);
assert_eq!(output(&game), 14, "63 items: lerpDiscrete(63/64, 0, 15)");
for _ in 0..8 {
game.world.game_time += 1;
game.tick_hopper(hopper);
}
assert_eq!(saved_count(&game, pot), 64, "the hopper filled the pot");
tick(&mut game);
assert_eq!(output(&game), 15, "the comparator re-read the saved pot");
}
+9 -22
View File
@@ -485,7 +485,7 @@ impl Game<'_> {
brewing: &self.stations().brewing,
banners: &self.stations().banners,
station_recipes: &self.stations().recipes,
enchantments: self.enchantments(),
enchantments: &self.stations().enchantments,
names: &self.pack.names,
trade: Some(&trade_env),
maps: level_maps
@@ -675,8 +675,14 @@ impl Game<'_> {
// EnchantmentMenu.slotsChanged: new offers for the item.
self.station_changed();
}
if kind == Kind::CrafterBlock {
self.apply_crafter_slot_states(&menu);
if kind == Kind::CrafterBlock
&& let Some(BlockEntity::Crafter(c)) = self
.container
.and_then(|k| self.world.block_entities.get_mut(&k))
{
for (i, d) in c.disabled.iter_mut().enumerate() {
*d = menu.data[i] == 1;
}
}
if matches!(kind, Kind::Furnace(_))
&& let Some(BlockEntity::Furnace(f)) = self
@@ -700,25 +706,6 @@ impl Game<'_> {
}
true
}
/// `CrafterBlockEntity.setSlotState`: the menu's slot states land on the
/// block entity, whose `setChanged` marks the chunk for saving.
pub(super) fn apply_crafter_slot_states(&mut self, menu: &horizon_client::menu::Menu) {
let Some(key) = self.container else {
return;
};
let Some(BlockEntity::Crafter(c)) = self.world.block_entities.get_mut(&key) else {
return;
};
let mut changed = false;
for (i, d) in c.disabled.iter_mut().enumerate() {
let disabled = menu.data[i] == 1;
changed |= *d != disabled;
*d = disabled;
}
if changed {
self.container_changed(key);
}
}
pub(super) fn click_inventory(&mut self, right: bool) {
if self.mod_browser_press(right) || self.mount_click(right) {
return;
+6 -5
View File
@@ -30,7 +30,8 @@ use horizon_world::registry::BlockStateId;
use std::{collections::HashMap, sync::Arc};
mod index;
mod nbt;
pub(super) use index::{CartIndex, cart_at};
pub(super) use index::CartIndex;
use index::cart_at;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(super) enum CartKind {
@@ -1894,7 +1895,7 @@ impl Game<'_> {
if !self.native_tick_pre_one(&mut round, id, kind) {
continue;
}
let Some(i) = self.cart_in_world(id, i, &round, kind) else {
let Some(i) = cart_at(&self.carts, id, i) else {
continue;
};
// The ticked copy takes the loot roll instead of copying it: only
@@ -2027,8 +2028,8 @@ impl Game<'_> {
} else {
(left, forward)
};
let r = f64::from(self.yaw * 0.017_453_292);
let (s, co) = (f64::from(trig::sin(r)), f64::from(trig::cos(r)));
let r = self.yaw.to_radians();
let (s, co) = (f64::from(r.sin()), f64::from(r.cos()));
let intent = [left * co - forward * s, forward * co + left * s];
let mut c = self.carts[i].clone();
let alive = self.tick_cart(&mut c, intent, i, &mut CartIndex::default());
@@ -2912,7 +2913,7 @@ impl Game<'_> {
emissive: &self.gui.atlas.emissive,
translucent_sprites: &self.gui.atlas.translucent,
atlas_revision: self.gui.atlas.revision,
nether: self.nether_lighting(),
nether: self.dim == super::Dim::Nether,
lid: &lid,
sign: &|_| None,
banner: &|_| Vec::new(),
@@ -97,7 +97,7 @@ impl CartIndex {
/// the usual case while carts tick in order) or found by a scan. A world
/// with hundreds of mineshaft chest carts asked this of every cart several
/// times a tick.
pub(in crate::game) fn cart_at(carts: &[Minecart], id: u32, hint: usize) -> Option<usize> {
pub(super) fn cart_at(carts: &[Minecart], id: u32, hint: usize) -> Option<usize> {
if carts.get(hint).is_some_and(|c| c.id == id) {
return Some(hint);
}
+1 -1
View File
@@ -639,7 +639,7 @@ impl Game<'_> {
light,
lights: if lit {
Some(horizon_client::block_entity_render::diffuse_lights(
self.nether_lighting(),
self.dim == super::Dim::Nether,
))
} else {
None
+1 -1
View File
@@ -1079,7 +1079,7 @@ fn dismount_location(
}
/// `Entity.getCollisionHorizontalEscapeVector`: out of the vehicle's box
/// along `yaw`, to the edge of the square it spans.
pub(super) fn escape_vector(vehicle_width: f64, passenger_width: f64, yaw: f32) -> Vec3 {
fn escape_vector(vehicle_width: f64, passenger_width: f64, yaw: f32) -> Vec3 {
// 1.0E-5F: a float literal widened to double.
let distance = (vehicle_width + passenger_width + f64::from(1.0e-5_f32)) / 2.0;
let radians = f64::from(yaw * 0.017_453_292);
@@ -432,7 +432,7 @@ impl Game<'_> {
light: 0,
block: [0; 3],
poses: Vec::new(),
lights: diffuse_lights(self.nether_lighting()),
lights: diffuse_lights(self.dim == Dim::Nether),
vertices: std::mem::take(out),
kept: false,
spins: Vec::new(),
@@ -8,7 +8,7 @@
//! player in view gets `native_mob_layer` after its vanilla model
//! (`EntityRenderersEvent.AddLayers`), in the same frame.
use super::{Game, native_block_entity_draw::BlockEntityDraw};
use super::{Dim, Game, native_block_entity_draw::BlockEntityDraw};
use glam::{DVec3, Mat4};
use horizon_client::{
block_entity_render::diffuse_lights,
@@ -184,7 +184,7 @@ impl Game<'_> {
light,
[feet.x, feet.y, feet.z].map(|c| c.floor() as i32),
mob_pose(feet - camera, render.body_yaw),
diffuse_lights(self.nether_lighting()),
diffuse_lights(self.dim == Dim::Nether),
std::mem::take(out),
);
let keep = hook(&render, &mut draw);
@@ -755,17 +755,6 @@ impl Game<'_> {
})
.unwrap_or(ENTITY_DELAY)
}
/// `Entity.saveWithoutId` writes the live `PortalCooldown`: the countdown
/// `Entity.processPortalCooldown` ticks down, not the value the entity was
/// loaded with. Every entity that can stand in a portal takes it from the
/// portal state, as the projectile writer does. An entity the portal
/// state does not know is not on a cooldown, and writes 0.
pub(super) fn save_portal_cooldown(&self, id: u32, tag: &mut Tag) {
horizon_client::nether_portal::put_portal_cooldown(
tag,
horizon_client::nether_portal::portal_cooldown(&self.portals.entities, id),
);
}
/// `Entity.saveWithoutId`: the modeled non-arrow projectile codecs
/// share the live cooldown between command reads and chunk writes.
pub(super) fn save_projectile_portal_cooldown(
@@ -81,7 +81,7 @@ impl Game<'_> {
}
/// A saved spawner, as `readAdditionalSaveData` leaves it: its tick
/// count starts again from 0.
pub(super) fn load_ominous_item_spawner(&mut self, mut tag: horizon_world::nbt::Tag) {
pub(super) fn load_ominous_item_spawner(&mut self, tag: horizon_world::nbt::Tag) {
let Some((item, at, spawn_item_after)) =
horizon_client::entity_nbt::ominous_item_spawner_from_nbt(&tag)
else {
@@ -92,17 +92,6 @@ impl Game<'_> {
let saved = horizon_client::entity_nbt::uuid(&tag);
// Its saved UUID and the attachments saved under it (#7398).
self.remember_uuid(id, saved, &tag);
// Entity.load's `setXRot` keeps the pitch in -90..90 and its `setRot`
// takes both `% 360`; the save writes the rotation back from this tag.
let [yaw, pitch] = horizon_client::entity_nbt::rotation(&tag);
let (yaw, pitch) = (yaw % 360.0, (pitch % 360.0).clamp(-90.0, 90.0));
let floats = [yaw, pitch].map(|v| horizon_world::nbt::Tag::Float(v.to_bits()));
if let horizon_world::nbt::Tag::Compound(map) = &mut tag {
map.insert(
"Rotation".into(),
horizon_world::nbt::Tag::List(floats.into()),
);
}
let uuid = self.uuid_for(id);
self.ominous_spawners.push(OminousItemSpawner {
id,
@@ -79,7 +79,6 @@ fn find_join<T: Joined>(list: &[T], join: JoinSeq) -> Option<usize> {
impl Game<'_> {
/// A level tick starts: what has joined so far ticks this tick.
pub(super) fn begin_ordered_ticks(&mut self) {
let _span = horizon_client::spans::guard("ordered_ticks");
let pending_buf = std::mem::take(&mut self.ordered.pending_buf);
let firsts = [
first_join(&self.armor_stands),
@@ -112,7 +111,6 @@ impl Game<'_> {
/// Ticks, one at a time in join order, every orb, primed TNT and falling
/// block that joined before `cut` and has not ticked this tick.
pub(super) fn run_ordered_ticks(&mut self, cut: JoinSeq, late: bool) -> Result<()> {
let _span = horizon_client::spans::guard("ordered_ticks");
let mut first_error = None;
let mut followed = None;
let mut events = false;
+8 -51
View File
@@ -707,7 +707,7 @@ impl Game<'_> {
p.id = self.next_entity;
self.restore_portal_cooldown(p.id, &tag);
self.remember_tags(p.id, &tag);
self.remember_uuid(p.id, Some(p.own_uuid(uuid)), &tag);
self.remember_uuid(p.id, uuid, &tag);
self.add_projectile(p);
}
}
@@ -725,8 +725,8 @@ impl Game<'_> {
self.next_entity += 1;
p.id = self.next_entity;
self.restore_portal_cooldown(p.id, &tag);
// Entity.load: the saved or summoned UUID, else its own random's.
self.remember_uuid(p.id, Some(p.own_uuid(uuid)), &tag);
// Entity.load: the saved or summoned UUID, as the other projectiles keep theirs.
self.remember_uuid(p.id, uuid, &tag);
self.add_projectile(p);
self.remember_last_projectile_tags(&tag);
}
@@ -752,7 +752,7 @@ impl Game<'_> {
self.next_entity += 1;
p.id = self.next_entity;
self.restore_portal_cooldown(p.id, &tag);
self.remember_uuid(p.id, Some(p.own_uuid(uuid)), &tag);
self.remember_uuid(p.id, uuid, &tag);
self.add_projectile(p);
self.remember_last_projectile_tags(&tag);
}
@@ -771,7 +771,7 @@ impl Game<'_> {
self.next_entity += 1;
p.id = self.next_entity;
self.restore_portal_cooldown(p.id, &tag);
self.remember_uuid(p.id, Some(p.own_uuid(uuid)), &tag);
self.remember_uuid(p.id, uuid, &tag);
self.add_projectile(p);
self.remember_last_projectile_tags(&tag);
}
@@ -1613,7 +1613,6 @@ impl Game<'_> {
}
self.write_leash(&mut tag, i);
self.put_mob_passengers(&mut tag, id);
self.save_portal_cooldown(id, &mut tag);
tag
}
/// Forget an entity that left the world (its UUID and loaded compound).
@@ -1896,8 +1895,7 @@ impl Game<'_> {
out.entry(chunk_of(at)).or_default().push(tag);
}
for o in &self.others {
// EntityType.noSave: Entity.getEncodeId is null, so the chunk never stores it.
if keys.contains(&chunk_of(o.position)) && o.kind.as_str() != "minecraft:leash_knot" {
if keys.contains(&chunk_of(o.position)) {
let uuid = self
.persist
.uuids
@@ -2194,13 +2192,12 @@ impl Game<'_> {
keys.contains(&chunk_of(at)).then(|| {
let id = self.dropped[i].id;
let uuid = self.uuid_for(id);
let mut tag = entity_nbt::item_entity_to_nbt_with_items(
let tag = entity_nbt::item_entity_to_nbt_with_items(
&self.dropped[i],
uuid,
self.persist.raw.get(&id),
self.items(),
);
self.save_portal_cooldown(id, &mut tag);
(chunk_of(at), tag)
})
}
@@ -2348,7 +2345,6 @@ impl Game<'_> {
self.put_mob_passengers(&mut tag, id);
// AbstractBoat.addAdditionalSaveData: the lead's holder.
self.write_boat_leash(&mut tag, i);
self.save_portal_cooldown(id, &mut tag);
Some((chunk_of(at), tag))
}
pub(super) fn save_cart(
@@ -2363,7 +2359,6 @@ impl Game<'_> {
let uuid = self.saved_uuid_or_random(id);
let mut tag = self.carts[i].to_nbt(uuid, self.persist.raw.get(&id));
self.put_mob_passengers(&mut tag, id);
self.save_portal_cooldown(id, &mut tag);
Some((chunk_of(at), tag))
}
pub(super) fn save_armor_stand(
@@ -6009,20 +6004,7 @@ mod camel_pose_saved_tests {
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
// A mob only ticks with its whole 5x5 chunk neighbourhood loaded.
for x in -2..=2 {
for z in -2..=2 {
if (x, z) != (0, 0) {
game.world
.chunks
.insert((x, z), Arc::new(Chunk::empty(x, z)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(x, z))
.unwrap();
}
}
}
assert!(game.run_command("forceload add -32 -32 32 32"));
assert!(game.run_command("forceload add 0 0"));
assert!(game.run_command("tick freeze"));
let initial = game.world.game_time;
assert!(game.run_command("tick step 128"));
@@ -6092,31 +6074,6 @@ mod camel_pose_saved_tests {
game.tick_server(false).unwrap();
}
}
// Unload and reload the chunk, then let time pass: the saved game time
// stays what it was (`Camel.addAdditionalSaveData` writes it unchanged).
let written: Vec<Tag> = game
.entity_chunks(&HashSet::from([(0, 0)]), true)
.into_iter()
.flat_map(|(_, tags)| tags)
.collect();
game.world.loaded_entities.push(((0, 0), written));
game.spawn_loaded_entities();
for step in 0..8 {
for number in 1..=cases.len() as i32 {
let index = game
.mobs
.iter()
.position(|m| game.persist.uuids.get(&m.id) == Some(&[94, 0, 0, number]))
.unwrap();
let value = actors[number as usize - 1].1;
assert_eq!(
game.mob_tree_nbt(index).get("LastPoseTick"),
Some(&Tag::Long(value)),
"reloaded timestamp {value} after {step} steps"
);
}
game.tick_server(false).unwrap();
}
}
}
+1 -8
View File
@@ -1949,14 +1949,7 @@ impl Game<'_> {
// Projectile.onHit calls onHitBlock first, then the potion's
// affectEntitiesAround; both read the stack's `potion_contents`.
if let Some((cell, face)) = hit {
// `AbstractThrownPotion.douseFire` passes `getOwner()` to the
// campfire's `douse`, which is where the game event's source is.
let who = if owner.player {
Some(Who::Player)
} else {
owner.attacker.map(Who::Mob)
};
self.potion_hit_block(stack, cell, face, who);
self.potion_hit_block(stack, cell, face);
}
self.affect_entities_around(stack, at, owner.player);
let lingering = &*stack.item == "minecraft:lingering_potion";
@@ -18,7 +18,7 @@ impl Game<'_> {
fn quick_actions_dialog(&self) -> Option<Tag> {
let registries = &self.remote.as_ref()?.registries;
let dialogs = registries.registries.get("minecraft:dialog")?;
let tag = registries.tag("minecraft:dialog", "minecraft:quick_actions")?;
let tag = registries.dialog_tags.get("minecraft:quick_actions")?;
match tag.len() {
0 => None,
1 => {
+39 -13
View File
@@ -60,12 +60,8 @@ use std::{
};
use winit::{event::MouseButton, keyboard::KeyCode};
#[path = "remote_abilities.rs"]
mod abilities;
#[path = "remote_poll.rs"]
mod poll;
#[path = "remote_tables.rs"]
mod tables;
type BuiltinCache = Vec<(std::path::PathBuf, Arc<Builtin>)>;
static BUILTIN: std::sync::Mutex<BuiltinCache> = std::sync::Mutex::new(Vec::new());
@@ -543,8 +539,9 @@ pub(super) struct Remote {
pub(super) builtin: Arc<Builtin>,
lang: Arc<Language>,
pub(super) registries: Arc<Dynamic>,
/// The game's tables with what the server's synced data puts over them.
pub(super) tables: tables::ServerTables,
/// The game's enchantments and the server's that it lacks (a data
/// pack's), from their synced data; `None` when the server has none.
pub(super) enchantments: Option<Arc<horizon_client::enchant::Enchantments>>,
pub(super) joined: bool,
/// `ServerReconfigScreen` is up since this tick.
pub(super) reconfiguring: Option<u64>,
@@ -1162,7 +1159,7 @@ impl Game<'_> {
.unwrap_or_else(|| "Connecting to the server...".into()),
lang,
registries: Arc::new(Dynamic::default()),
tables: Default::default(),
enchantments: None,
joined: false,
reconfiguring: None,
logged_in: false,
@@ -1392,7 +1389,7 @@ impl Game<'_> {
status: "Playing Studio Replay".into(),
lang,
registries: Arc::new(Dynamic::default()),
tables: Default::default(),
enchantments: None,
joined: false,
reconfiguring: None,
logged_in: true,
@@ -2262,9 +2259,25 @@ impl Game<'_> {
.collect()
})
.unwrap_or_default();
let tables = self.server_tables(&registries);
let entries: Vec<(&str, serde_json::Value)> = registries
.registries
.get("minecraft:enchantment")
.into_iter()
.flatten()
.filter_map(|(name, data)| {
Some((
name.as_str(),
horizon_client::scoreboard::tag_to_json(data.as_ref()?),
))
})
.collect();
let enchantments = self
.stations()
.enchantments
.with_entries(entries.iter().map(|(n, v)| (*n, v)))
.map(Arc::new);
if let Some(r) = self.remote.as_mut() {
r.tables = tables;
r.enchantments = enchantments;
let bits = ceil_log2(biomes.len());
let column = r.chunk_format.layout();
r.chunk_format =
@@ -2401,7 +2414,7 @@ impl Game<'_> {
.as_ref()
.is_none_or(|r| r.dimension.has_skylight);
self.streaming.sky_light = has_sky;
self.streaming.nether_shading = self.nether_lighting();
self.streaming.nether_shading = self.dim == Dim::Nether;
// The seed in SpawnInfo is already the hashed one the client's BiomeManager holds.
self.streaming.biome_zoom = horizon_core::biome::BiomeZoom::new(spawn.seed);
let player = self.player.as_ref();
@@ -5285,7 +5298,6 @@ impl Game<'_> {
self.remote_server_data(p.icon.as_deref());
}
C::MapItemData(p) => self.remote_map_item_data(&p),
C::UpdateTags(p) => self.remote_update_tags(&p),
// handleTickingState / handleTickingStep: the level's TickRateManager.
C::TickingState(p) => {
self.clock.set_rate(p.rate);
@@ -6717,6 +6729,20 @@ impl Game<'_> {
Ok(())
}
/// ClientPacketListener.handlePlayerAbilities: the player flies, may fly
/// and is invulnerable as the server says (bits 2, 4 and 1).
fn apply_remote_abilities(&mut self) {
let (Some(player), Some(a)) = (
self.player.as_mut(),
self.remote.as_ref().and_then(|r| r.abilities.as_ref()),
) else {
return;
};
player.invulnerable = a.flags & 1 != 0;
player.flying = a.flags & 2 != 0;
player.may_fly = a.flags & 4 != 0;
player.flying_speed = a.flying_speed;
}
/// The local player's share of every touching entity's
/// `LivingEntity.pushEntities`, and of the boats' pushes: on a client
/// `EntitySelector.pushableBy` lets entities push only the local player,
@@ -6934,7 +6960,7 @@ impl Game<'_> {
}
let game_time = self.world.game_time;
// `AbstractPiglin.isConverting`: the dimension is not piglin safe.
let nether = !self.dimension_type().piglins_zombify;
let nether = self.dim == super::Dim::Nether;
// What a wither's side heads can look at, by network entity id: the
// mobs and the player (position and eye height).
let wither_targets: Vec<(i32, [f64; 3], f64)> = if self
@@ -1,69 +0,0 @@
//! The server's ClientboundPlayerAbilitiesPacket on the local player (`ClientPacketListener
//! .handlePlayerAbilities`).
use super::*;
/// `Abilities.walkingSpeed` before the server sends one.
const WALKING_SPEED: f32 = 0.1;
/// The walking speed `AbstractClientPlayer.getFieldOfViewModifier` divides the movement speed by:
/// the last abilities packet's (`Abilities.setWalkingSpeed`), else the default.
fn walking_speed(abilities: Option<&play::PlayerAbilities>) -> f32 {
abilities.map_or(WALKING_SPEED, |a| a.walking_speed)
}
impl Game<'_> {
/// ClientPacketListener.handlePlayerAbilities: the player flies, may fly
/// and is invulnerable as the server says (bits 2, 4 and 1). The walking
/// speed is read where the field of view needs it
/// ([`Game::remote_walking_speed`]), the instabuild bit where the packet
/// is handled (`Game::creative`).
pub(super) fn apply_remote_abilities(&mut self) {
let (Some(player), Some(a)) = (
self.player.as_mut(),
self.remote.as_ref().and_then(|r| r.abilities.as_ref()),
) else {
return;
};
player.invulnerable = a.flags & 1 != 0;
player.flying = a.flags & 2 != 0;
player.may_fly = a.flags & 4 != 0;
player.flying_speed = a.flying_speed;
}
/// `Abilities.getWalkingSpeed()` of the local player: the server's, on a server.
pub(in crate::game) fn remote_walking_speed(&self) -> f32 {
walking_speed(self.remote.as_ref().and_then(|r| r.abilities.as_ref()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_client::camera::{FovPlayer, field_of_view_modifier};
fn fov(walking: f32, movement: f64) -> f32 {
let player = FovPlayer {
flying: false,
walking_speed: walking,
movement_speed: movement,
using: None,
};
field_of_view_modifier(&player, true, 1.0)
}
#[test]
fn the_field_of_view_divides_by_the_servers_walking_speed() {
// A server that sets the walking speed to 0.2 and the movement speed to 0.2: vanilla's
// getFieldOfViewModifier is (0.2 / 0.2 + 1) / 2 = 1. Kept at 0.1 it would be
// (0.2 / 0.1 + 1) / 2 = 1.5, a zoomed-out view.
let packet = play::PlayerAbilities {
flags: 0,
flying_speed: 0.05,
walking_speed: 0.2,
};
assert_eq!(fov(walking_speed(Some(&packet)), 0.2), 1.0);
// Before any packet: vanilla's default 0.1, a still player at the base speed 0.1.
assert_eq!(walking_speed(None), 0.1);
assert_eq!(fov(walking_speed(None), 0.1), 1.0);
}
}
@@ -188,8 +188,6 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
// Enderman.DATA_CREEPY (after DATA_CARRY_STATE): the open mouth
// and raised head of an angry enderman.
(Species::Enderman, 17, Value::Bool(creepy)) => m.extra.flag = *creepy,
// Enderman.DATA_STARED_AT: kept in scratch[0] for the stare sound.
(Species::Enderman, 18, Value::Bool(stared)) => m.extra.scratch[0] = i32::from(*stared),
// Warden.CLIENT_ANGER_LEVEL: the heartbeat's pace.
(Species::Warden, 16, Value::Int(anger)) => {
if let Some(st) = m.extra.warden.as_mut() {
@@ -500,9 +498,7 @@ impl Game<'_> {
(14, _, Value::OptPos(bed)) => Some(bed.map_or(0, |b| self.bed_facing(b))),
_ => None,
});
let mut stare_at = None;
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, id as u32) {
stare_at = stare_sound(m, values);
apply(m, values, &r.builtin, r.entity_id);
apply_registry_data(m, values, &r.registries, self.world.game_time);
if let Some(code) = bed
@@ -511,43 +507,9 @@ impl Game<'_> {
m.extra.ints[1] = code;
}
}
if let Some(at) = stare_at {
// Level.playLocalSound: heard here only, never shared.
self.audio
.play_unshared("minecraft:entity.enderman.stare", Some(at), 2.5, 1.0);
}
}
}
/// `Enderman.onSyncedDataUpdated` / `playStareSound`: DATA_CREEPY changing
/// while DATA_STARED_AT is set plays the stare sound at the eye, at most once
/// per 400 ticks and never when silent. Entries are handled in index order, so
/// a stared-at flag sent in the same packet counts only for later entries.
/// Returns the sound position and records the tick in scratch[1].
fn stare_sound(m: &mut Mob, values: &[(u8, i32, Value)]) -> Option<[f64; 3]> {
if m.species() != Species::Enderman {
return None;
}
let mut stared = m.extra.scratch[0] != 0;
let mut sound = None;
for (index, _, value) in values {
match (*index, value) {
(18, Value::Bool(b)) => stared = *b,
(17, Value::Bool(_)) if stared => {
let tick = m.age as i32;
if tick >= m.extra.scratch[1].wrapping_add(400) {
m.extra.scratch[1] = tick;
if !m.silent {
sound = Some([m.position.x, m.eye_y(), m.position.z]);
}
}
}
_ => {}
}
}
sound
}
/// A mob as a client constructs it before its data arrives: the synched
/// defaults the server leaves out (`getNonDefaultValues`) that differ from
/// what `Mob::new` gives, such as a wolf's and a cat's red collar.
@@ -988,26 +950,6 @@ mod tests {
/// #2920: a server's horse is moved by its rider's client, which jumps
/// it by the type's jump strength (`createBaseHorseAttributes`,
/// `createBaseChestedHorseAttributes`) until the server syncs its own.
/// Enderman.playStareSound: only a DATA_CREEPY change while staring, once
/// per 400 ticks, at the eye height.
#[test]
fn an_endermans_stare_sound_follows_synced_data() {
let Some(mut m) = mob("minecraft:enderman") else {
return;
};
let creepy = [(17, s::BOOLEAN, Value::Bool(true))];
let stared = [(18, s::BOOLEAN, Value::Bool(true))];
assert!(stare_sound(&mut m, &creepy).is_none());
apply(&mut m, &stared);
m.age = 500;
let at = stare_sound(&mut m, &creepy).expect("stare sound");
assert!((at[1] - m.eye_y()).abs() < 1e-9);
m.age = 899;
assert!(stare_sound(&mut m, &creepy).is_none());
m.age = 900;
assert!(stare_sound(&mut m, &creepy).is_some());
}
#[test]
fn a_servers_horse_jumps_its_types_strength() {
for (kind, strength) in [("horse", 0.7), ("donkey", 0.5), ("mule", 0.5)] {
@@ -1,175 +0,0 @@
//! The game's tables with what a server's synced data puts over them: its
//! data packs' enchantments, and its block and item tags.
use super::super::Game;
use horizon_client::{enchant::Enchantments, items::Items};
use horizon_protocol::{packets::configuration::UpdateTags, registry::Dynamic};
use std::{collections::HashMap, sync::Arc};
/// `None` where the server sent nothing over the game's own table.
#[derive(Default)]
pub(crate) struct ServerTables {
/// The game's enchantments and the server's that it lacks (a data
/// pack's), from their synced data.
pub(crate) enchantments: Option<Arc<Enchantments>>,
/// The game's items with the block and item tags the server sent
/// (`ClientboundUpdateTagsPacket`) in place of its own.
pub(crate) items: Option<Arc<Items>>,
}
impl Game<'_> {
/// The tables `registries` puts over the game's.
pub(super) fn server_tables(&self, registries: &Dynamic) -> ServerTables {
let entries: Vec<(&str, serde_json::Value)> = registries
.registries
.get("minecraft:enchantment")
.into_iter()
.flatten()
.filter_map(|(name, data)| {
Some((
name.as_str(),
horizon_client::scoreboard::tag_to_json(data.as_ref()?),
))
})
.collect();
let enchantments = self
.stations()
.enchantments
.with_entries(entries.iter().map(|(n, v)| (*n, v)))
.map(Arc::new);
ServerTables {
enchantments,
items: self.server_items(registries),
}
}
/// The game's items with the server's block and item tags, by name. The
/// window's own server thread plays with the world's tables instead.
fn server_items(&self, registries: &Dynamic) -> Option<Arc<Items>> {
let r = self.remote.as_ref().filter(|_| self.integrated.is_none())?;
let names = |registry: &str| -> Option<HashMap<String, Vec<String>>> {
let ids = r.builtin.get(registry)?;
let tags = registries
.tags
.get(registry)?
.iter()
.map(|(name, members)| {
let members = members.iter().filter_map(|&id| ids.name(id));
(name.clone(), members.map(str::to_owned).collect())
});
Some(tags.collect())
};
let (blocks, items) = (names("minecraft:block"), names("minecraft:item"));
if blocks.is_none() && items.is_none() {
return None;
}
Some(Arc::new(self.res.items().with_tags(blocks, items)))
}
/// `ClientPacketListener.handleUpdateTags` (after a `/reload`): the tags
/// it names replace the ones the configuration sent.
pub(super) fn remote_update_tags(&mut self, packet: &UpdateTags) {
let Some(r) = self.remote.as_ref() else {
return;
};
let mut registries = Dynamic::clone(&r.registries);
registries.add_tags(packet);
let tables = self.server_tables(&registries);
if let Some(r) = self.remote.as_mut() {
r.registries = Arc::new(registries);
r.tables = tables;
}
}
}
#[cfg(test)]
mod tests {
use super::super::super::{
Audio, Game, Options, Progress, Settings, execution_role::ExecutionRole, load_resources,
};
use horizon_protocol::{
connection::Pipe,
packets::configuration::{RegistryTags, TagEntry, UpdateTags},
};
use std::collections::HashSet;
fn client() -> Game<'static> {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES")
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!("no menu handoff"),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = ExecutionRole::Client;
let res: &'static _ = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
res,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
let (client, _server) = Pipe::pair();
game.connect_local(client, "TagTest").unwrap();
game
}
/// A server whose data pack makes stone climbable and sticks planks: the
/// client reads its tags, not the game's own (#7843).
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_servers_block_and_item_tags_replace_the_games() {
let mut game = client();
assert!(!game.items().block_tags["minecraft:climbable"].contains("minecraft:stone"));
let builtin = game.remote.as_ref().unwrap().builtin.clone();
let id = |registry: &str, name: &str| builtin.get(registry).unwrap().id(name).unwrap();
let tag = |registry: &str, name: &str, members: &[&str]| RegistryTags {
registry: registry.into(),
tags: vec![TagEntry {
name: name.into(),
ids: members.iter().map(|m| id(registry, m)).collect(),
}],
};
game.remote_update_tags(&UpdateTags {
registries: vec![
tag(
"minecraft:block",
"minecraft:climbable",
&["minecraft:ladder", "minecraft:stone"],
),
tag(
"minecraft:item",
"minecraft:planks",
&["minecraft:stick", "minecraft:oak_planks"],
),
],
});
let items = game.items();
let set = |names: &[&str]| names.iter().map(|n| n.to_string()).collect::<HashSet<_>>();
assert_eq!(
items.block_tags["minecraft:climbable"],
set(&["minecraft:ladder", "minecraft:stone"])
);
// The server's tags replace the game's: a tag it didn't send is gone.
assert!(!items.block_tags.contains_key("minecraft:fences"));
assert_eq!(
items.item_tag_order["minecraft:planks"],
["minecraft:stick", "minecraft:oak_planks"]
);
assert!(items.item_tags["minecraft:planks"].contains("minecraft:stick"));
}
}
+6 -22
View File
@@ -21,11 +21,7 @@ use horizon_client::{
screen_effects::{ScreenInputs, ScreenQuad, lightmap_brightness, nausea_has_effect},
};
use horizon_core::random::{LegacyRandom, RandomSource};
use horizon_world::{
dimension_type::{CardinalLight, Skybox},
heightmap::HeightmapType,
worldgen::terrain::TerrainBlockStates,
};
use horizon_world::{heightmap::HeightmapType, worldgen::terrain::TerrainBlockStates};
pub(super) fn player_tracking_body(player: &PlayerMotion) -> TrackingBody {
TrackingBody {
@@ -100,18 +96,6 @@ fn rains_at(world: &World, biomes: &Biomes, x: i32, y: i32, z: i32) -> bool {
}
impl Game<'_> {
/// `GameRenderer`'s cardinal light: the level's type uses the Nether's diffuse lights.
pub(super) fn nether_lighting(&self) -> bool {
self.dimension_type().cardinal_light == CardinalLight::Nether
}
/// `SkyRenderer`'s overworld skybox: sun, moon, stars and clouds.
pub(super) fn overworld_skybox(&self) -> bool {
self.dimension_type().skybox == Skybox::Overworld
}
/// `SkyRenderer`'s End skybox and its flashes.
pub(super) fn end_skybox(&self) -> bool {
self.dimension_type().skybox == Skybox::End
}
pub(super) fn forward(&self) -> [f64; 3] {
let (yaw, pitch) = (
f64::from(self.yaw).to_radians(),
@@ -167,7 +151,7 @@ impl Game<'_> {
forward: forward.map(|c| c as f32),
rain,
thunder,
skybox: self.overworld_skybox(),
skybox: self.dim == Dim::Overworld,
blindness: self
.potions
.effects
@@ -283,7 +267,7 @@ impl Game<'_> {
} else {
f64::from(self.pack.polytone_colors.env.horizon_height)
};
self.overworld_skybox()
self.dim == Dim::Overworld
&& camera.y < horizon
&& fog_type(
&self.world,
@@ -538,10 +522,10 @@ impl Game<'_> {
let height_no_leaves =
|p: [i32; 3]| motion_blocking_leaves(world, facts, p[0], p[2], i32::MAX, false);
// The day timeline: firefly bush sounds from 12600 to 23400.
let firefly_bush_sounds = self.dimension_type().has_timeline("minecraft:day")
let firefly_bush_sounds = self.dim == Dim::Overworld
&& (12600..23401).contains(&self.world.day_time.rem_euclid(24000));
// EnvironmentAttributes.CREAKING_ACTIVE: the same overworld night.
let creaking_active = self.dimension_type().has_timeline("minecraft:day")
let creaking_active = self.dim == Dim::Overworld
&& (12600..23401).contains(&self.world.day_time.rem_euclid(24000));
let tables = &self.pack.context.tables;
let model_offset = |state, x, z| tables.offset(state, x, z);
@@ -568,7 +552,7 @@ impl Game<'_> {
model_offset: &model_offset,
gateway_particles: &gateway_particles,
thundering,
ultrawarm: self.dimension_type().water_evaporates,
ultrawarm: self.dim == Dim::Nether,
};
if let Some(e) = eye {
let block = [
@@ -26,14 +26,6 @@ impl Resources {
pub(super) fn environment(&self) -> &Environment {
&self.core.environment
}
/// The environment of a dimension type id; a type with no table of its own gets the overworld's.
pub(super) fn environment_of(&self, dimension_type: &str) -> &Environment {
match dimension_type {
"minecraft:the_nether" => self.nether_environment(),
"minecraft:the_end" => self.end_environment(),
_ => self.environment(),
}
}
pub(super) fn mob_types(&self) -> &HashMap<String, Arc<MobType>> {
&self.rest().mob_types
}
+3 -1
View File
@@ -280,7 +280,9 @@ impl Game<'_> {
fn pause_screen_additions(&self) -> Option<(Text, Tag)> {
let registries = &self.remote.as_ref()?.registries;
let dialogs = registries.registries.get("minecraft:dialog")?;
let tag = registries.tag("minecraft:dialog", "minecraft:pause_screen_additions")?;
let tag = registries
.dialog_tags
.get("minecraft:pause_screen_additions")?;
// addCustomDialogButtons' label: computeExternalTitle falls back to the title.
let button = |dialog_tag: Tag| -> Option<(Text, Tag)> {
let dialog = Dialog::parse(&dialog_tag)?;
@@ -110,33 +110,6 @@ fn a_torch_that_stays_connects_the_wire_in_a_line_and_powers_it() {
);
}
/// `handleSetCommandBlock` edits the block entity through
/// `BaseCommandBlock.setCommand`/`onUpdated`: the command block is marked
/// changed and its update tag reaches the clients. Horizon applied the edit
/// without marking the chunk.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn editing_a_command_block_marks_its_chunk() {
let mut game = game();
run(&mut game, &["setblock 5 100 2 command_block[facing=up]"]);
game.world.modified.clear();
if let Some(log) = game.world.block_log.as_mut() {
log.clear();
}
game.apply_command_block(&super::command_blocks::CommandBlockEdit {
pos: (5, 100, 2),
command: "say hi".into(),
track_output: false,
mode: horizon_client::command_block::Mode::Redstone,
conditional: false,
auto: false,
});
assert!(
game.world.modified.contains(&(0, 0)),
"the edited command block's chunk is dirty"
);
}
/// `TestBlock.ownSignal` reads the block state's `mode`, not the block
/// entity's saved copy: a `start` state with a powered block entity answers
/// 15 even when `/data merge` left a different mode in the entity.
@@ -1,61 +0,0 @@
//! `SmithingScreen`'s empty slot icons: the base and addition lists of a
//! template (`SmithingTemplateItem`'s create*IconList helpers).
/// The base and additions slot icons of the template in the smithing table:
/// `SmithingTemplateItem.createNetheriteUpgradeTemplate` (the 11 tools and
/// armour of `SmithingTemplateItem.createNetheriteUpgradeIconList`, then the
/// ingot) and `SmithingTemplateItem.createArmorTrimTemplate` (the four armour
/// pieces, then the seven trim materials), as `getBaseSlotEmptyIcons` and
/// `getAdditionalSlotEmptyIcons` give them.
pub(super) fn smithing_slot_icons(
template: Option<&str>,
) -> ([&'static str; 11], usize, [&'static str; 7], usize) {
let none = ([""; 11], 0, [""; 7], 0);
match template.map(|t| t.trim_start_matches("minecraft:")) {
Some("netherite_upgrade_smithing_template") => (
[
"helmet",
"sword",
"chestplate",
"pickaxe",
"leggings",
"axe",
"boots",
"hoe",
"shovel",
"nautilus_armor",
"spear",
],
11,
["ingot", "", "", "", "", "", ""],
1,
),
Some(t) if t.ends_with("_armor_trim_smithing_template") => (
[
"helmet",
"chestplate",
"leggings",
"boots",
"",
"",
"",
"",
"",
"",
"",
],
4,
[
"ingot",
"redstone_dust",
"lapis_lazuli",
"quartz",
"diamond",
"emerald",
"amethyst_shard",
],
7,
),
_ => none,
}
}
@@ -161,8 +161,8 @@ impl Game<'_> {
let disable_blocking = if self.using.is_some() {
0.0
} else {
self.held()
.map_or(0.0, |stack| self.items().weapon_disable_blocking(stack))
self.attack_item_info()
.map_or(0.0, |i| i.disable_blocking_seconds)
};
Some(PlayerStab {
victim,
+8 -65
View File
@@ -55,13 +55,7 @@ impl Game<'_> {
/// face it hit, the cell behind that (the block it hit) and the four
/// horizontal neighbours of the cell in front. `hit` is the block and
/// `face` the face it was hit on.
pub(super) fn potion_hit_block(
&mut self,
stack: &ItemStack,
hit: [i32; 3],
face: Direction,
owner: Option<Who>,
) {
pub(super) fn potion_hit_block(&mut self, stack: &ItemStack, hit: [i32; 3], face: Direction) {
// `PotionContents.is(PotionTags.DOUSES_FIRE)` on the stack's `potion_contents`.
if !self.potion_in_tag(stack, "minecraft:douses_fire") {
return;
@@ -71,8 +65,8 @@ impl Game<'_> {
[pos[0] + n[0], pos[1] + n[1], pos[2] + n[2]]
};
let effect = relative(hit, face);
self.douse_fire(effect, owner);
self.douse_fire(relative(effect, face.opposite()), owner);
self.douse_fire(effect);
self.douse_fire(relative(effect, face.opposite()));
// Direction.Plane.HORIZONTAL.
for direction in [
Direction::North,
@@ -80,14 +74,14 @@ impl Game<'_> {
Direction::South,
Direction::West,
] {
self.douse_fire(relative(effect, direction), owner);
self.douse_fire(relative(effect, direction));
}
}
/// `AbstractThrownPotion.douseFire`: a fire block is destroyed without
/// drops, a lit candle or candle cake goes out, and a lit campfire goes
/// out after level event 1009 and its block-change game event.
fn douse_fire(&mut self, pos: [i32; 3], owner: Option<Who>) {
fn douse_fire(&mut self, pos: [i32; 3]) {
let at = BlockPos::new(pos[0], pos[1], pos[2]);
let state = self.world.block(pos[0], pos[1], pos[2]);
let name = self.res.facts().get(state).name.clone();
@@ -103,9 +97,9 @@ impl Game<'_> {
&& let Some(mut lit) = self.lit_campfire(state, &name)
{
self.level_event_at(1009, pos, 0);
// `CampfireBlock.douse(entity, ...)`: the block-change game event,
// sourced from the potion's owner as vanilla passes `getOwner()`.
self.post_block_game_event("minecraft:block_change", pos, owner, None);
// CampfireBlock.douse: the block-change game event, whose source
// is the potion's owner (not tracked here).
self.post_block_game_event("minecraft:block_change", pos, None, None);
lit.properties.insert("lit".into(), "false".into());
if let Ok(unlit) = self.pack.assets.registry.resolve(&lit) {
let _ = self.level_set(pos, unlit);
@@ -263,54 +257,3 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game::native_machines::tests::server_game;
/// `AbstractThrownPotion.douseFire` -> `CampfireBlock.douse(entity, ...)`:
/// the campfire's block change is sourced from the potion's owner.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_doused_campfire_posts_block_change_from_the_potion_owner() {
let mut game = server_game();
let pos = [2, 4, 3];
let campfire = game
.pack
.assets
.registry
.default_state("minecraft:campfire")
.unwrap();
game.world.set(pos[0], pos[1], pos[2], campfire).unwrap();
// The lit campfire the douse looks for.
let lit = game.lit_campfire(campfire, "minecraft:campfire");
assert!(lit.is_some(), "the campfire starts lit");
super::super::sculk::TEST_POSTED_EVENTS.with(|e| *e.borrow_mut() = Some(Vec::new()));
game.douse_fire(pos, Some(Who::Mob(7)));
let after = game.world.block(pos[0], pos[1], pos[2]);
assert!(
!game
.pack
.assets
.registry
.state(after)
.and_then(|s| s.properties.get("lit"))
.is_some_and(|v| v == "true"),
"the campfire is out"
);
super::super::sculk::TEST_POSTED_EVENTS.with(|e| {
assert!(
e.borrow().as_ref().unwrap().contains(&(
"minecraft:block_change",
[2.5, 4.5, 3.5],
Some(Who::Mob(7))
)),
"{:?}",
e.borrow()
);
});
}
}
+66 -33
View File
@@ -5,9 +5,6 @@
//! `BrewingStandScreen`); item rules live in `horizon_client::stations`.
mod anvil;
#[path = "smithing_icons.rs"]
mod smithing_icons;
use smithing_icons::smithing_slot_icons;
/// Invalid measurements must not reach tooltip positioning or reserve empty space.
fn tooltip_component_size(size: Option<[f32; 2]>) -> Option<[f32; 2]> {
size.filter(|size| size.iter().all(|v| v.is_finite() && *v > 0.0))
@@ -275,6 +272,64 @@ const EMPTY_TEMPLATE_ICONS: [&str; 2] = [
"smithing_template_armor_trim",
"smithing_template_netherite_upgrade",
];
/// The base and additions slot icons of the template in the smithing table:
/// `SmithingTemplateItem.createNetheriteUpgradeTemplate` (the 11 tools and
/// armour of `SmithingTemplateItem.createNetheriteUpgradeIconList`, then the
/// ingot) and `SmithingTemplateItem.createArmorTrimTemplate` (the four armour
/// pieces, then the seven trim materials), as `getBaseSlotEmptyIcons` and
/// `getAdditionalSlotEmptyIcons` give them.
fn smithing_slot_icons(
template: Option<&str>,
) -> ([&'static str; 11], usize, [&'static str; 7], usize) {
let none = ([""; 11], 0, [""; 7], 0);
match template.map(|t| t.trim_start_matches("minecraft:")) {
Some("netherite_upgrade_smithing_template") => (
[
"helmet",
"sword",
"chestplate",
"pickaxe",
"leggings",
"axe",
"boots",
"hoe",
"shovel",
"nautilus_armor",
"spear",
],
11,
["ingot", "", "", "", "", "", ""],
1,
),
Some(t) if t.ends_with("_armor_trim_smithing_template") => (
[
"helmet",
"chestplate",
"leggings",
"boots",
"",
"",
"",
"",
"",
"",
"",
],
4,
[
"ingot",
"redstone_dust",
"lapis_lazuli",
"quartz",
"diamond",
"emerald",
"amethyst_shard",
],
7,
),
_ => none,
}
}
pub(super) struct Station {
pub(super) kind: Kind,
@@ -682,7 +737,7 @@ impl Game<'_> {
return;
};
let creative = self.creative;
let (enchantments, data) = (std::sync::Arc::clone(self.enchantments()), self.stations());
let data = self.stations();
let items = self.items();
match s.kind {
// A server's table: its data slots carry the offers, and its
@@ -690,7 +745,7 @@ impl Game<'_> {
Kind::Enchanting if self.remote.is_some() => {
let enchantable = s.inputs[0]
.as_ref()
.is_some_and(|i| stations::is_enchantable(&enchantments, i));
.is_some_and(|i| stations::is_enchantable(&data.enchantments, i));
if !enchantable {
s.offers = Default::default();
}
@@ -698,7 +753,7 @@ impl Game<'_> {
Kind::Enchanting => {
s.bookcases = self.count_bookshelves(s.pos);
s.offers = stations::enchant_offers(
&enchantments,
&data.enchantments,
items,
self.level.xp_seed,
s.bookcases,
@@ -712,7 +767,7 @@ impl Game<'_> {
.map(|i| stations::hover_name(&self.pack.names, i))
.unwrap_or_default();
s.anvil = stations::anvil_result(
&enchantments,
&data.enchantments,
items,
s.inputs[0].as_ref(),
s.inputs[1].as_ref(),
@@ -731,7 +786,7 @@ impl Game<'_> {
}
Kind::Grindstone => {
s.result = stations::grindstone_result(
&enchantments,
&data.enchantments,
items,
s.inputs[0].as_ref(),
s.inputs[1].as_ref(),
@@ -739,7 +794,7 @@ impl Game<'_> {
}
Kind::Smithing => {
s.result = data.recipes.smithing_result(
&enchantments,
&data.enchantments,
s.inputs[0].as_ref(),
s.inputs[1].as_ref(),
s.inputs[2].as_ref(),
@@ -2718,31 +2773,10 @@ impl Game<'_> {
}
lines.splice(1..1, after_name);
let lines = Self::resolved(&lines);
// `getEmptyBundleDescriptionTextHeight`: the empty bundle's description
// wrapped at the component's 96 pixel width, 9 pixels a line.
let description_lines = self
.gui
.atlas
.font
.split_lines(
&horizon_client::text::Text::translate(
"item.minecraft.bundle.empty.description",
&[],
),
96.0,
)
.len()
.max(1);
// The component's height does not depend on where it is drawn, so a
// probe at the origin gives the box its extra height.
let probe = horizon_client::item_tooltip::bundle_tooltip(
self.items(),
stack,
0.0,
0.0,
96.0,
description_lines,
);
let probe =
horizon_client::item_tooltip::bundle_tooltip(self.items(), stack, 0.0, 0.0, 96.0);
let bundle_extra = probe.as_ref().map_or(0.0, |l| l.height + 2.0);
let mut component = None;
self.native_tooltip_component(stack, &mut component);
@@ -2767,7 +2801,6 @@ impl Game<'_> {
x,
y + text_h + 2.0,
w,
description_lines,
) {
self.draw_bundle_tooltip(batch, &layout);
self.draw_bundle_selected_name(batch, &layout, x, y + text_h + 2.0, w);
@@ -74,8 +74,3 @@ mod tests {
assert_eq!(station.rename.value, "Diamond Sword");
}
}
// The previews in a server session read the server's enchantments (#8688).
#[cfg(test)]
#[path = "remote_anvil_tests.rs"]
mod remote_anvil_tests;
@@ -1,84 +0,0 @@
use super::super::super::{
Audio, Game, Options, Progress, Settings, execution_role::ExecutionRole, load_resources,
};
use super::super::{Kind, Station};
use horizon_client::items::ItemStack;
use horizon_protocol::connection::Pipe;
use std::sync::Arc;
/// A client on a server whose data pack adds `fixture:drill`, which the
/// game's own table doesn't have: the registry sync sent its definition.
fn client_on_a_server_with_a_custom_enchantment() -> Game<'static> {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES")
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!("no menu handoff"),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = ExecutionRole::Client;
let res: &'static _ = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
res,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
let (client, _server) = Pipe::pair();
game.connect_local(client, "AnvilTest").unwrap();
let definition = serde_json::json!({
"description": "Drill", "supported_items": "minecraft:diamond_pickaxe",
"weight": 1, "max_level": 3, "min_cost": {"base": 1, "per_level_above_first": 0},
"max_cost": {"base": 1, "per_level_above_first": 0}, "anvil_cost": 1,
"slots": ["mainhand"], "effects": {}
});
let synced = game
.stations()
.enchantments
.with_entries([("fixture:drill", &definition)])
.expect("the game's own table lacks fixture:drill");
game.remote.as_mut().unwrap().enchantments = Some(Arc::new(synced));
game
}
/// `AnvilMenu.createResult` on the client reads the synced registry, so a
/// book of a data pack's enchantment on a pickaxe shows its result, as the
/// server computes it, instead of the red cross (#8688).
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_servers_custom_enchantment_book_gives_an_anvil_result() {
let mut game = client_on_a_server_with_a_custom_enchantment();
let mut book = ItemStack::new("minecraft:enchanted_book", 1);
book.set_crafting_enchantments(vec![("fixture:drill".into(), 2)]);
let mut station = Station::new(Kind::Anvil, [0, 64, 0]);
station.inputs[0] = Some(ItemStack::new("minecraft:diamond_pickaxe", 1));
station.inputs[1] = Some(book);
game.station = Some(station);
game.station_changed();
let station = game.station.as_ref().unwrap();
let result = station.result.as_ref().expect("an anvil result");
assert_eq!(&*result.item, "minecraft:diamond_pickaxe");
assert_eq!(
result.enchantments(),
&[(Arc::<str>::from("fixture:drill"), 2)]
);
assert!(station.anvil.cost > 0);
}
@@ -621,7 +621,7 @@ impl Game<'_> {
BlockPos::new(bounds.max[0], bounds.max[1], bounds.max[2]),
);
self.block_ticks.blocks.clear_area(area);
self.block_ticks.retain_block_events(|e| {
self.block_ticks.block_events.retain(|e| {
!(bounds.min[0]..=bounds.max[0]).contains(&e.pos.x)
|| !(bounds.min[1]..=bounds.max[1]).contains(&e.pos.y)
|| !(bounds.min[2]..=bounds.max[2]).contains(&e.pos.z)
Loaded 100 of 262 files, more files were not shown because too many files have changed in this diff. Show more