Compare commits

..
Author SHA1 Message Date
rdbtCVSandClaude Sonnet 5.5 851cf36dfc Merge main into layers/game-plain-bin
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 11:25:13 +00:00
rdbtCVSandClaude Sonnet 5.5 b789c435d0 Merge main into layers/game-plain-bin
Resolve the CI clippy, benchmark doc and pad_menu_e2e conflicts; build the
horizon-client binary from horizon-game in entity_save_reload.py.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 03:50:35 +00:00
Horizon agentandClaude Opus 5.5 c8213de26b horizon-game: drop the discord feature, which left horizon-client for horizon-discord
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:35:01 +01:00
Horizon agent e48ac4159f Merge remote-tracking branch 'origin/main' into layers/game-plain-bin
# Conflicts:
#	tools/discord_presence_e2e.py
2026-11-09 23:27:42 +01:00
Ludovic Marie bc8e4f3188 Merge pull request #14322 from horizon-rust/layers/game-main-with
🤖 Agent: the generated native-mod main switches to horizon_game::main_with only when the revision has it (older revisions keep entry::main_with byte for byte), and horizon-game comes in with default-features = false so a server build stays without sound or controllers.
2026-11-09 21:44:21 +01:00
Horizon agentandClaude Opus 5.5 043ac0d8d9 Native-mod builds start through horizon_game::main_with
layer plan M6: the platform's parts (pack formats next) register in
horizon-game. The launcher's generated native-mod executable now runs
horizon_game::main_with(mods) when the revision has it, so builds with native
mods keep them; older revisions keep the engine's entry::main_with. Its
output is otherwise unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:30:55 +01:00
Horizon agentandClaude Opus 5.5 219a8f22b0 Cargo.lock: horizon-client's winresource build dependency
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:26:52 +01:00
Horizon agentandClaude Opus 5.5 d1049cff8d horizon-client's build script still gives native-mod games their icon and stack
The launcher's generated native-mod game package runs horizon-client's build.rs
for its horizon-client executable; it keeps Windows' 8 MB stack and the icon.
horizon-client's own build (no executable now) skips them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:25:59 +01:00
Horizon agentandClaude Opus 5.5 868acf52a0 Launcher test: the game's main.rs is horizon-game's now
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:21:06 +01:00
Horizon agent b97ee11dfb Merge remote-tracking branch 'origin/main' into layers/game-plain-bin 2026-11-09 20:07:52 +01:00
Horizon agentandClaude Opus 5.5 3c44b85f13 The plain executable moves to horizon-game
layer plan M6: this repository's plain executable is horizon-game's, so the
platform's parts (OptiFine/Polytone pack formats next) can register in
horizon_game::main() without the engine naming them. The binary keeps its
name, horizon-client: harnesses, horizon-ctl launch, lan_e2e and packaging
find it where they did; only the commands that build it say -p horizon-game.

- horizon-game: [[bin]] horizon-client (main.rs moved from horizon-client,
  now calling horizon_game::main()), its Windows icon and stack (build.rs),
  horizon-client's features forwarded, the launch_api test moved with it.
- horizon-client keeps the library, the mod API generation and its examples.
- Build commands in tools/, CI (server build, its clippy), the server
  Dockerfile, xtask messages, AGENTS.md and docs switch to -p horizon-game.
- docs/layers.md names the plain executable.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:07:39 +01:00
341 changed files with 2781 additions and 9748 deletions

No files matched your search

+1 -1
View File
@@ -123,7 +123,7 @@ jobs:
cargo +stable fmt --all -- --check
cargo +stable clippy --workspace --all-targets --locked -- -D warnings
# The server build (released as horizon-engine): the game without sound or controllers.
cargo +stable clippy -p horizon-client --all-targets --locked --no-default-features -- -D warnings
cargo +stable clippy -p horizon-client -p horizon-game --all-targets --locked --no-default-features -- -D warnings
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
# type-checks and the parity job builds and runs (they were most of this step's build and link time).
# cargo test --locked, not nextest: nextest's process per test reloads each binary's shared tables for every test, and on
+1 -1
View File
@@ -339,7 +339,7 @@ jobs:
set -euo pipefail
# After the client is packaged: target/release/horizon-client is rebuilt without the audio and
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them).
cargo build --release --locked -p horizon-client --no-default-features --bin horizon-client
cargo build --release --locked -p horizon-game --no-default-features --bin horizon-client
cargo run --locked -p xtask -- package-server target/release/horizon-client "dist/${{ matrix.server_dir }}"
folder="dist/${{ matrix.server_dir }}"
python3 tools/check_imports.py --server "$folder/horizon-engine" "$folder/horizon-server" "$folder/horizon-prepare"
+6 -7
View File
@@ -77,7 +77,7 @@ 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` | Gameplay library (`src/lib.rs`: `game.rs` + `game/`, `entry.rs`), also the dedicated server (`--dedicated`); `horizon-game` builds its executable. Re-exports `horizon-client-lib` for existing mod paths. |
| `horizon-client-lib` | Reusable client systems, `horizon-ctl`, probes and their tests. Its manifest points to the existing sources under `crates/horizon-client/`; the library root is `src/client_lib.rs`. It never depends on gameplay. |
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
@@ -133,7 +133,7 @@ python3 tools/pr_ready.py # the self-check: fmt, clippy -D warn
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)
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
cargo build --profile fast -p horizon-game # the game (bin horizon-client), optimized, quick to link: to play or run lan_e2e
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
python3 tools/parity.py mob_ai sculk # named harnesses
@@ -187,9 +187,8 @@ python3 tools/lang_coverage.py --summary --show-keys # the same, then the keys
`L:` = lib (`crates/horizon-client/src/`, or `crates/horizon-client-render/src/` for the modules that crate holds, under
the same `horizon_client::<module>` paths), `G:` = gameplay (`crates/horizon-client/src/game/`), also in the lib in normal builds.
Gameplay keeps its `horizon_client::<module>` imports through a self-crate alias. The executable
contains only the allocator, mod API marker and a call to `entry::main`. For unit tests, the lib
excludes `entry` and `game`, and the executable test target owns `game` so its process-wide test
state stays separate from the library tests. This compiles gameplay twice for executable tests.
(`crates/horizon-game/src/main.rs`) contains only the allocator, mod API marker and a call to
`horizon_game::main()`, which runs `entry::main` with the platform's parts registered.
| Subsystem | Files |
|---|---|
@@ -214,7 +213,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` |
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs` + `extras/` (each extra's glue: `mouse_tweaks.rs`, `sorting.rs`, `pvp.rs`, `reconnect.rs`), `options.rs` |
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
## How `Game` is organised
@@ -247,7 +246,7 @@ When you change how the server and its clients talk (packets, tracking, guests,
it there. Other gameplay fixes are covered by their own harness and CI's parity job:
```sh
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
cargo run --profile fast -p horizon-game -- --resources .local/horizon-client/26.3 --world T # server thread
python3 tools/lan_e2e.py --check <name> # host on its server thread, guest is a server client
python3 tools/parity.py dedicated_server server_entities server_player_damage
```
+1 -1
View File
@@ -32,7 +32,7 @@ here.
```sh
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar
cargo run --release -p horizon-client
cargo run --release -p horizon-game
```
- **Launcher:** its own Cargo workspace in `launcher/` (`cd launcher && cargo run`; on NixOS, `nix-shell` first).
Generated
+3
View File
@@ -1769,8 +1769,11 @@ name = "horizon-game"
version = "0.1.0"
dependencies = [
"horizon-client",
"horizon-core",
"horizon-mods",
"horizon-threads",
"serde_json",
"winresource",
]
[[package]]
+1 -1
View File
@@ -119,7 +119,7 @@ git clone https://github.com/horizon-rust/horizon.git
cd horizon
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar # needs JDK 25
cargo run --release -p horizon-prepare -- sounds # optional: sounds from your launcher install
cargo run --release -p horizon-client
cargo run --release -p horizon-game
```
Prepared files land in the gitignored `.local/`: never commit or share them. Options, controls and architecture: [the native client guide](docs/native-client.md); all developer docs: [docs/README.md](docs/README.md).
-2
View File
@@ -62,8 +62,6 @@ The GLSL frontend also resets the common type for each comma-separated declarato
variable's array specifier does not change later variables.
It also reads a struct member outside a constant expression through a pointer to the member,
not out of a loaded copy of the whole struct, so a member an atomic function uses can be read.
The shared interpolation defaults also compile with `glsl-in` alone, without needing the
SPIR-V or WGSL frontend to enable them.
## DirectX Shader Compiler
+1 -33
View File
@@ -206,7 +206,7 @@ impl Config {
Ok(file.anticheat)
}
/// Thresholds can't be negative, and neither can the timer's milliseconds.
/// Thresholds can't be negative.
pub fn check(&self) -> Result<(), String> {
for &c in Check::ALL {
let a = self.actions(c);
@@ -220,18 +220,6 @@ impl Config {
));
}
}
// A negative tolerance flags a client on time every tick, and so does a cap below minus the
// tolerance (the balance rests at the cap); NaN never flags, or never caps the balance. No
// negative number of milliseconds means anything, so all are refused.
if [self.timer.tolerance_ms, self.timer.max_lag_ms]
.iter()
.any(|v| !v.is_finite() || *v < 0.0)
{
return Err(
"anticheat.timer: tolerance_ms and max_lag_ms are numbers of milliseconds from 0 up"
.into(),
);
}
let tolerance = self.prediction.tolerance;
if !tolerance.is_finite() || tolerance <= 0.0 {
return Err("anticheat.prediction: tolerance is a number of blocks above 0".into());
@@ -373,26 +361,6 @@ mod tests {
assert!(Config::from_server_toml("[anticheat.timer]\nkick = -1\n").is_err());
}
/// A negative `tolerance_ms` flags a client on time every tick, and so does a `max_lag_ms` below
/// `-tolerance_ms` (the balance rests at the cap); a NaN turns the check (or its cap) off without a
/// word. Every negative or non-finite value is refused, as a negative threshold is.
#[test]
fn refuses_timer_settings_that_are_not_milliseconds_from_0_up() {
for bad in [
"tolerance_ms = -1",
"tolerance_ms = nan",
"tolerance_ms = inf",
"max_lag_ms = -1",
"max_lag_ms = nan",
] {
let text = format!("[anticheat.timer]\n{bad}\n");
let e = Config::from_server_toml(&text).expect_err(bad);
assert!(e.starts_with("anticheat.timer:"), "{bad}: {e}");
}
let edge = "[anticheat.timer]\ntolerance_ms = 0\nmax_lag_ms = 0\n";
assert!(Config::from_server_toml(edge).is_ok());
}
#[test]
fn levels_rise_decay_and_act_in_order() {
let mut c = on();
+1 -25
View File
@@ -45,11 +45,6 @@ mod tests {
/// `seconds` of server ticks of `server_ms` each, with `per_tick` client ticks handled in each.
fn run(b: &mut Balance, server_ms: f64, per_tick: f64, seconds: f64) -> usize {
run_at(b, 20.0, server_ms, per_tick, seconds)
}
/// [`run`] at the tick rate `rate` (`/tick rate`), which the client's ticks follow.
fn run_at(b: &mut Balance, rate: f32, server_ms: f64, per_tick: f64, seconds: f64) -> usize {
let c = TimerConfig::default();
let mut owed = 0.0;
let mut flags = 0;
@@ -58,31 +53,12 @@ mod tests {
owed += per_tick;
while owed >= 1.0 {
owed -= 1.0;
flags += usize::from(b.tick_end(rate, &c).is_some());
flags += usize::from(b.tick_end(20.0, &c).is_some());
}
}
flags
}
/// At another tick rate a vanilla client ticks at the rate the server sent it, and each of its
/// ticks is worth `1000 / rate` ms: never flagged on time, flagged again and again 1.5 times as fast.
#[test]
fn the_balance_follows_the_tick_rate() {
for rate in [10.0_f32, 60.0] {
let tick_ms = 1000.0 / f64::from(rate);
assert_eq!(
run_at(&mut Balance::default(), rate, tick_ms, 1.0, 600.0),
0,
"on time at {rate}"
);
// Half a tick ahead every server tick: 75 flags in a minute at 10 a second, 94 at 60.
let flags = run_at(&mut Balance::default(), rate, tick_ms, 1.5, 60.0);
assert!(flags >= 50, "1.5 times as fast at {rate}: {flags} flags");
}
// The rate counts: at 10 a second, a client sending 20 ticks a second is twice as fast.
assert!(run_at(&mut Balance::default(), 10.0, 100.0, 2.0, 60.0) >= 50);
}
#[test]
fn a_vanilla_client_is_never_flagged() {
let mut b = Balance::default();
@@ -1442,8 +1442,6 @@ impl Audio {
} else {
Err("no output on a dedicated server".into())
};
// Without `audio` a stream can't exist (`Infallible`), so the `Ok` arm never runs.
#[cfg_attr(not(feature = "audio"), allow(unreachable_code))]
let (stream, live) = match opened {
Ok(v) => (Some(v), true),
Err(e) => {
@@ -465,9 +465,6 @@ pub struct Jukebox {
/// reference exists. Keep this separate from legacy playback adoption.
pub song_clock_present: bool,
pub playing: bool,
/// A clock a save brought back, not playing yet: `setSongWithoutPlaying`
/// keeps the song and its clock without the sound's level event 1010.
pub loaded_clock: bool,
}
impl Jukebox {
/// `JukeboxBlockEntity.getTheItem`: the disc it holds.
+177 -3
View File
@@ -210,7 +210,6 @@ mod hdr_icc;
mod hdr_start;
mod hdr_stats;
pub(crate) mod hud_coverage;
mod in_flight;
pub use hdr::{Calibration as HdrCalibration, Output as HdrOutput};
mod hold;
pub use hold::WorldHold;
@@ -12352,6 +12351,133 @@ pub(crate) fn copy_frame(
);
buffer
}
impl Renderer {
/// The frame's readbacks to start right after its submission (on the submit thread when
/// the frame goes there): occlusion culling's results (`cull`) and GPU culling's
/// statistics. GPU frames leave occlusion culling off, so a frame has one at most.
fn after_submit(&mut self) -> Option<submit::AfterSubmit> {
let cull = self.cull.as_mut().and_then(|c| c.submitted());
let gpu = self.gpu_culling.as_mut().and_then(|g| g.submitted());
match (cull, gpu) {
(Some(c), Some(g)) => Some(Box::new(move || {
c();
g();
})),
(Some(c), None) => Some(Box::new(c)),
(None, Some(g)) => Some(Box::new(g)),
(None, None) => None,
}
}
/// Frame `frame`'s statistics shown, unless a newer frame's are: a CPU frame's as it is
/// drawn, a GPU frame's when its counts come back (`GpuCulling::collect`).
fn show_stats(&mut self, frame: u64, [sections, draws, calls]: [usize; 3]) {
if frame > self.stats_frame {
self.stats_frame = frame;
(
self.drawn_sections,
self.section_draws,
self.section_draw_calls,
) = (sections, draws, calls);
}
}
/// Frames between the last one drawn and the one whose statistics are shown: 0 after a
/// CPU frame, one to three in GPU frames, while their counts are on their way back.
pub fn stats_lag(&self) -> u64 {
self.frame_serial - self.stats_frame
}
/// Take in the frame the submit thread submitted: start its readbacks and, offscreen,
/// keep at most two frames ahead of the GPU, as a frame submitted in place does.
fn land_frame(&mut self) {
let Some(offscreen) = self.deferred.take() else {
return;
};
let submission = self.queue.landed();
if let Some(timer) = &mut self.gpu_timer {
timer.submitted(&self.queue);
timer.collect(&self.device, &mut self.gpu_times);
}
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
timer.submitted(&self.queue);
}
if let Some(timer) = &mut self.own_timing {
timer.submitted(&self.queue);
}
if offscreen && let Some(submission) = submission {
self.in_flight.push_back(submission);
if !self.paced_elsewhere {
self.wait_frames();
}
}
}
/// No swapchain paces offscreen frames: keep two on the GPU at most.
pub fn wait_in_flight(&mut self) {
self.land_frame();
self.wait_frames();
}
/// The frame on the submit thread submitted: a surface reconfigure waits for the
/// device and breaks if a frame is submitted while it waits, and the compositor
/// copies views after them. (The end of a frame already lands the frames of the
/// turn before; this is the turn's own.)
pub fn wait_submissions(&mut self) {
self.queue.wait();
}
fn wait_frames(&mut self) {
while self.in_flight.len() > 2 {
let index = self.in_flight.pop_front();
let _ = self.device.poll(wgpu::PollType::Wait {
submission_index: index,
timeout: Some(std::time::Duration::from_secs(5)),
});
}
}
/// Take a screenshot request: queue the frame's copy, or report why the
/// frame cannot be read back.
fn take_readback(
&mut self,
request: ScreenshotRequest,
encoder: &mut wgpu::CommandEncoder,
texture: &wgpu::Texture,
) -> Option<(ScreenshotRequest, wgpu::Buffer)> {
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
// the row stride too small for the copy and abort in validation.
if self.config.format.block_copy_size(None) == Some(4) {
let size = [self.config.width, self.config.height];
let format = self.config.format.remove_srgb_suffix();
// The layered float frame maps the world to SDR, then adds the HUD as drawn.
// Other 16-bit frames keep their clipped 8-bit copy.
let copy = self
.hdr
.as_mut()
.and_then(|hdr| {
hdr.sdr_screenshot(&self.device, encoder, format, request.size.is_some())
})
.or_else(|| {
(texture.format() != format && texture.format() == self.output.frame.format)
.then(|| {
self.readback
.unorm_copy(&self.device, encoder, texture, format)
})
});
let texture = copy.as_ref().unwrap_or(texture);
Some((request, copy_frame(&self.device, encoder, texture, size)))
} else {
let error = format!(
"screenshots need a 32-bit surface format, not {:?}",
self.config.format
);
if request.background {
self.saved_screenshots.push(screenshot_save::Saved {
path: request.path,
result: Err(error),
hdr: None,
});
} else {
self.screenshot_result = Some(Err(error));
}
None
}
}
}
/// `screenshots/2026-10-28_12.00.00.png` -> `screenshots/2026-10-28_12.00.00-hdr.png`.
fn hdr_screenshot_path(path: &std::path::Path) -> PathBuf {
let stem = path.file_stem().unwrap_or_default().to_string_lossy();
@@ -12457,8 +12583,56 @@ fn block_entities_within_reach<'a>(
})
.flatten()
}
mod view_frustum;
pub use view_frustum::Frustum;
/// Camera-relative clip planes (`Frustum`).
#[derive(Clone, Copy, Debug)]
pub struct Frustum {
planes: [Vec4; 6],
}
impl Frustum {
/// Whether the camera-relative box `min..max` is at least partly inside.
pub fn intersects(&self, min: Vec3, max: Vec3) -> bool {
!self.planes.iter().any(|p| {
let sign = p.truncate().cmpge(Vec3::ZERO);
let v = Vec3::select(sign, max, min);
p.truncate().dot(v) + p.w < 0.0
})
}
/// Whether a camera-relative sphere is at least partly inside. Frustum
/// planes are normalized, so each test needs only one dot product and a
/// radius comparison.
pub fn intersects_sphere(&self, center: Vec3, radius: f32) -> bool {
if !center.is_finite() || !radius.is_finite() || radius < 0.0 {
return true;
}
!self
.planes
.iter()
.any(|plane| plane.truncate().dot(center) + plane.w < -radius)
}
/// Depth clip planes alone: X/Y projection jitter cannot change this test.
/// Retaining side-of-screen geometry makes this safe before TAA selects a phase.
pub fn intersects_depth(&self, min: Vec3, max: Vec3) -> bool {
if !min.is_finite() || !max.is_finite() || !min.cmple(max).all() {
return true;
}
!self.planes[4..].iter().any(|p| {
let sign = p.truncate().cmpge(Vec3::ZERO);
let v = Vec3::select(sign, max, min);
p.truncate().dot(v) + p.w < 0.0
})
}
/// Whether the camera-relative box `min..max` is entirely inside. Then
/// `intersects` accepts every box within it: each plane's distance only
/// grows from this box's nearest corner to the inner box's farthest, and
/// rounding keeps that order.
pub fn contains(&self, min: Vec3, max: Vec3) -> bool {
self.planes.iter().all(|p| {
let sign = p.truncate().cmpge(Vec3::ZERO);
let v = Vec3::select(sign, min, max);
p.truncate().dot(v) + p.w >= 0.0
})
}
}
/// Vanilla's `Projection`: reversed depth (near maps to 1, far to 0, tests
/// are GREATER_THAN_OR_EQUAL), which keeps far faces a hair apart, like
/// fire_side's 0.01 pixel in front of its block, from z-fighting.
@@ -1,132 +0,0 @@
//! Frames in flight: the readbacks a submission starts, landing frames the submit thread
//! submitted, keeping offscreen frames at most two ahead of the GPU, and a screenshot's copy.
use super::{Renderer, ScreenshotRequest, copy_frame, screenshot_save, submit};
impl Renderer {
/// The frame's readbacks to start right after its submission (on the submit thread when
/// the frame goes there): occlusion culling's results (`cull`) and GPU culling's
/// statistics. GPU frames leave occlusion culling off, so a frame has one at most.
pub(super) fn after_submit(&mut self) -> Option<submit::AfterSubmit> {
let cull = self.cull.as_mut().and_then(|c| c.submitted());
let gpu = self.gpu_culling.as_mut().and_then(|g| g.submitted());
match (cull, gpu) {
(Some(c), Some(g)) => Some(Box::new(move || {
c();
g();
})),
(Some(c), None) => Some(Box::new(c)),
(None, Some(g)) => Some(Box::new(g)),
(None, None) => None,
}
}
/// Frame `frame`'s statistics shown, unless a newer frame's are: a CPU frame's as it is
/// drawn, a GPU frame's when its counts come back (`GpuCulling::collect`).
pub(super) fn show_stats(&mut self, frame: u64, [sections, draws, calls]: [usize; 3]) {
if frame > self.stats_frame {
self.stats_frame = frame;
(
self.drawn_sections,
self.section_draws,
self.section_draw_calls,
) = (sections, draws, calls);
}
}
/// Frames between the last one drawn and the one whose statistics are shown: 0 after a
/// CPU frame, one to three in GPU frames, while their counts are on their way back.
pub fn stats_lag(&self) -> u64 {
self.frame_serial - self.stats_frame
}
/// Take in the frame the submit thread submitted: start its readbacks and, offscreen,
/// keep at most two frames ahead of the GPU, as a frame submitted in place does.
pub(super) fn land_frame(&mut self) {
let Some(offscreen) = self.deferred.take() else {
return;
};
let submission = self.queue.landed();
if let Some(timer) = &mut self.gpu_timer {
timer.submitted(&self.queue);
timer.collect(&self.device, &mut self.gpu_times);
}
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
timer.submitted(&self.queue);
}
if let Some(timer) = &mut self.own_timing {
timer.submitted(&self.queue);
}
if offscreen && let Some(submission) = submission {
self.in_flight.push_back(submission);
if !self.paced_elsewhere {
self.wait_frames();
}
}
}
/// No swapchain paces offscreen frames: keep two on the GPU at most.
pub fn wait_in_flight(&mut self) {
self.land_frame();
self.wait_frames();
}
/// The frame on the submit thread submitted: a surface reconfigure waits for the
/// device and breaks if a frame is submitted while it waits, and the compositor
/// copies views after them. (The end of a frame already lands the frames of the
/// turn before; this is the turn's own.)
pub fn wait_submissions(&mut self) {
self.queue.wait();
}
pub(super) fn wait_frames(&mut self) {
while self.in_flight.len() > 2 {
let index = self.in_flight.pop_front();
let _ = self.device.poll(wgpu::PollType::Wait {
submission_index: index,
timeout: Some(std::time::Duration::from_secs(5)),
});
}
}
/// Take a screenshot request: queue the frame's copy, or report why the
/// frame cannot be read back.
pub(super) fn take_readback(
&mut self,
request: ScreenshotRequest,
encoder: &mut wgpu::CommandEncoder,
texture: &wgpu::Texture,
) -> Option<(ScreenshotRequest, wgpu::Buffer)> {
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
// the row stride too small for the copy and abort in validation.
if self.config.format.block_copy_size(None) == Some(4) {
let size = [self.config.width, self.config.height];
let format = self.config.format.remove_srgb_suffix();
// The layered float frame maps the world to SDR, then adds the HUD as drawn.
// Other 16-bit frames keep their clipped 8-bit copy.
let copy = self
.hdr
.as_mut()
.and_then(|hdr| {
hdr.sdr_screenshot(&self.device, encoder, format, request.size.is_some())
})
.or_else(|| {
(texture.format() != format && texture.format() == self.output.frame.format)
.then(|| {
self.readback
.unorm_copy(&self.device, encoder, texture, format)
})
});
let texture = copy.as_ref().unwrap_or(texture);
Some((request, copy_frame(&self.device, encoder, texture, size)))
} else {
let error = format!(
"screenshots need a 32-bit surface format, not {:?}",
self.config.format
);
if request.background {
self.saved_screenshots.push(screenshot_save::Saved {
path: request.path,
result: Err(error),
hdr: None,
});
} else {
self.screenshot_result = Some(Err(error));
}
None
}
}
}
@@ -1,252 +0,0 @@
//! The camera's view frustum, as camera-relative clip planes.
use glam::{Vec3, Vec4};
/// Camera-relative clip planes (`Frustum`).
#[derive(Clone, Copy, Debug)]
pub struct Frustum {
pub(super) planes: [Vec4; 6],
}
impl Frustum {
/// Whether the camera-relative box `min..max` is at least partly inside.
pub fn intersects(&self, min: Vec3, max: Vec3) -> bool {
!self.planes.iter().any(|p| {
let sign = p.truncate().cmpge(Vec3::ZERO);
let v = Vec3::select(sign, max, min);
p.truncate().dot(v) + p.w < 0.0
})
}
/// Whether a camera-relative sphere is at least partly inside. Frustum
/// planes are normalized, so each test needs only one dot product and a
/// radius comparison.
pub fn intersects_sphere(&self, center: Vec3, radius: f32) -> bool {
if !center.is_finite() || !radius.is_finite() || radius < 0.0 {
return true;
}
!self
.planes
.iter()
.any(|plane| plane.truncate().dot(center) + plane.w < -radius)
}
/// Depth clip planes alone: X/Y projection jitter cannot change this test.
/// Retaining side-of-screen geometry makes this safe before TAA selects a phase.
pub fn intersects_depth(&self, min: Vec3, max: Vec3) -> bool {
if !min.is_finite() || !max.is_finite() || !min.cmple(max).all() {
return true;
}
!self.planes[4..].iter().any(|p| {
let sign = p.truncate().cmpge(Vec3::ZERO);
let v = Vec3::select(sign, max, min);
p.truncate().dot(v) + p.w < 0.0
})
}
/// Whether the camera-relative box `min..max` is entirely inside. Then
/// `intersects` accepts every box within it: each plane's distance only
/// grows from this box's nearest corner to the inner box's farthest, and
/// rounding keeps that order.
pub fn contains(&self, min: Vec3, max: Vec3) -> bool {
self.planes.iter().all(|p| {
let sign = p.truncate().cmpge(Vec3::ZERO);
let v = Vec3::select(sign, min, max);
p.truncate().dot(v) + p.w >= 0.0
})
}
/// Whether the camera-relative `point` is on or inside every plane (vanilla's
/// `Frustum.pointInFrustum`). A point that is not finite is kept: a test that cannot
/// tell must not hide the particle.
pub fn contains_point(&self, point: Vec3) -> bool {
if !point.is_finite() {
return true;
}
self.planes
.iter()
.all(|plane| plane.truncate().dot(point) + plane.w >= 0.0)
}
/// The same frustum seen from a camera moved `blocks` back along the view direction
/// (vanilla's `Frustum.offset`): the sides open up near the camera and the near plane
/// moves behind it, so what sits just behind or beside the camera stays inside.
///
/// The view direction is the normal of the depth planes (the two are opposite), signed
/// towards where the side planes point. A frustum that has no direction to pull along
/// comes back unchanged: a box with no depth planes (the shadow pass's), and a box whose
/// sides are parallel to the view direction (an orthographic projection), where the side
/// normals cancel and say nothing about which way is forward.
pub fn pulled_back(&self, blocks: f32) -> Frustum {
let sides: Vec3 = self.planes[..4].iter().map(|p| p.truncate()).sum();
let depth = self.planes[4].truncate();
let along = depth.dot(sides);
// A perspective frustum's four side normals each lean forward, so this is well above 1.
if along.abs() < 1.0e-3 {
return *self;
}
let toward_sides = if along > 0.0 { depth } else { -depth };
let axis = toward_sides.normalize_or_zero();
let mut planes = self.planes;
for plane in &mut planes {
plane.w += blocks * plane.truncate().dot(axis);
}
Frustum { planes }
}
/// The frustum of a camera-relative `view_proj`, for tests outside this module.
#[cfg(test)]
pub(crate) fn from_view_projection(view_proj: glam::Mat4) -> Frustum {
Frustum {
planes: super::frustum(view_proj),
}
}
}
#[cfg(test)]
mod tests {
use super::{Frustum, Vec3};
use crate::gfx::world_projection;
use glam::Mat4;
const FOV: f32 = 70.0;
const ASPECT: f32 = 16.0 / 9.0;
const FAR: f32 = 512.0;
const EPS: f32 = 0.01;
fn looking_down_minus_z() -> Frustum {
Frustum::from_view_projection(world_projection(FOV, ASPECT, FAR))
}
fn tangents() -> (f32, f32) {
let up = (FOV / 2.0).to_radians().tan();
(up * ASPECT, up)
}
/// Each of the six planes of the unmoved frustum, a point just inside and one just outside,
/// from the field of view worked out by hand.
#[test]
fn a_point_is_inside_only_within_all_six_planes() {
let view = looking_down_minus_z();
let (tan_x, tan_y) = tangents();
let depth = 10.0;
let inside = [
Vec3::new(0.0, 0.0, -depth),
Vec3::new(depth * tan_x - EPS, 0.0, -depth),
Vec3::new(-(depth * tan_x - EPS), 0.0, -depth),
Vec3::new(0.0, depth * tan_y - EPS, -depth),
Vec3::new(0.0, -(depth * tan_y - EPS), -depth),
Vec3::new(0.0, 0.0, -0.06),
Vec3::new(0.0, 0.0, -(FAR - 0.1)),
];
let outside = [
Vec3::new(depth * tan_x + EPS, 0.0, -depth),
Vec3::new(-(depth * tan_x + EPS), 0.0, -depth),
Vec3::new(0.0, depth * tan_y + EPS, -depth),
Vec3::new(0.0, -(depth * tan_y + EPS), -depth),
Vec3::new(0.0, 0.0, -0.04),
Vec3::new(0.0, 0.0, -(FAR + 0.1)),
Vec3::new(0.0, 0.0, 1.0),
Vec3::new(0.0, 0.0, 0.0),
];
for p in inside {
assert!(view.contains_point(p), "{p} should be inside");
}
for p in outside {
assert!(!view.contains_point(p), "{p} should be outside");
}
assert!(view.contains_point(Vec3::NAN), "an unknown point is kept");
}
/// Camera 3 blocks back: its apex is at z = 3, so the sides open by 3 * tan, the near
/// plane is at z = 3 - 0.05 and the far plane at z = 3 - FAR.
#[test]
fn pulling_the_camera_back_moves_every_plane() {
let (tan_x, tan_y) = tangents();
let back = looking_down_minus_z().pulled_back(3.0);
let inside = [
Vec3::new(0.0, 0.0, 2.9),
Vec3::new(3.0 * tan_x - EPS, 0.0, 0.0),
Vec3::new(-(3.0 * tan_x - EPS), 0.0, 0.0),
Vec3::new(0.0, 3.0 * tan_y - EPS, 0.0),
Vec3::new(0.0, -(3.0 * tan_y - EPS), 0.0),
Vec3::new(0.0, 0.0, 3.0 - FAR + 0.1),
];
let outside = [
Vec3::new(0.0, 0.0, 3.0),
Vec3::new(3.0 * tan_x + EPS, 0.0, 0.0),
Vec3::new(-(3.0 * tan_x + EPS), 0.0, 0.0),
Vec3::new(0.0, 3.0 * tan_y + EPS, 0.0),
Vec3::new(0.0, -(3.0 * tan_y + EPS), 0.0),
Vec3::new(0.0, 0.0, 3.0 - FAR - 0.1),
];
for p in inside {
assert!(back.contains_point(p), "{p} should be inside");
}
for p in outside {
assert!(!back.contains_point(p), "{p} should be outside");
}
// The unmoved frustum does not have the point that only the offset one has.
assert!(!looking_down_minus_z().contains_point(Vec3::new(0.0, 0.0, 2.9)));
// An offset of nothing changes no verdict.
let same = looking_down_minus_z();
let unmoved = same.pulled_back(0.0);
for p in [
Vec3::new(1.0, 2.0, -3.0),
Vec3::new(0.0, 0.0, 1.0),
Vec3::new(40.0, 0.0, -5.0),
] {
assert_eq!(same.contains_point(p), unmoved.contains_point(p));
}
}
/// The offset follows the camera's own view direction, whatever way it looks.
#[test]
fn the_offset_is_along_the_view_direction() {
for (yaw, pitch) in [(0.3_f32, 0.0_f32), (2.0, -0.5), (-1.2, 0.9), (3.0, 1.2)] {
let pose = Mat4::from_rotation_x(pitch) * Mat4::from_rotation_y(yaw);
let view = Frustum::from_view_projection(world_projection(FOV, ASPECT, FAR) * pose);
let forward = pose.inverse().transform_vector3(-Vec3::Z);
let back = view.pulled_back(3.0);
assert!(
back.contains_point(forward * -2.9),
"{yaw} {pitch}: 2.9 behind"
);
assert!(
!back.contains_point(forward * -3.0),
"{yaw} {pitch}: 3 behind"
);
assert!(
!view.contains_point(forward * -2.9),
"{yaw} {pitch}: unmoved"
);
assert!(back.contains_point(forward * 20.0) && view.contains_point(forward * 20.0));
}
}
/// An orthographic box has no apex to pull back from: its side normals cancel, so there is
/// no direction, and the frustum must come back as it was.
#[test]
fn an_orthographic_frustum_stays_where_it_is() {
let ortho =
Frustum::from_view_projection(Mat4::orthographic_rh(-8.0, 8.0, -4.5, 4.5, 100.0, 0.05));
let moved = ortho.pulled_back(3.0);
for p in [
Vec3::new(0.0, 0.0, 2.0),
Vec3::new(0.0, 0.0, -9.0),
Vec3::new(7.9, 4.4, -9.0),
Vec3::new(8.1, 0.0, -9.0),
Vec3::new(0.0, 0.0, -101.0),
] {
assert_eq!(ortho.contains_point(p), moved.contains_point(p), "{p}");
}
}
/// The shadow pass's box has no depth planes, so no direction: it must not move.
#[test]
fn a_frustum_without_depth_planes_stays_where_it_is() {
let mut flat = looking_down_minus_z();
flat.planes[4] = glam::Vec4::W;
flat.planes[5] = glam::Vec4::W;
let moved = flat.pulled_back(3.0);
for p in [
Vec3::new(0.0, 0.0, 2.0),
Vec3::new(0.0, 0.0, -9.0),
Vec3::new(80.0, 0.0, -9.0),
] {
assert_eq!(flat.contains_point(p), moved.contains_point(p));
}
}
}
@@ -853,7 +853,7 @@ impl H {
fn label(self, s: &Settings, out: &EffectiveOutput) -> Text {
let g = &s.horizon;
match self {
H::Preset => labelled("Horizon Preset", g.preset.label()),
H::Preset => labelled("Preset", g.preset.label()),
H::LodDistance => match g.lod_distance {
0 => labelled("LOD Distance", "Off"),
n => labelled("LOD Distance", lang::tr("%s chunks", &[&n])),
@@ -1753,14 +1753,6 @@ mod tests {
ui.set_search("tearing");
let found = ui.listed(&s, SIZE);
assert!(found.iter().any(|(_, i)| *i == Item::Vanilla(Opt::Vsync)));
// Both presets answer "preset"; Horizon's own, named apart from vanilla's, answers its name.
ui.set_search("preset");
let found = ui.listed(&s, SIZE);
let general = |item| (Page::General, item);
assert!(found.contains(&general(Item::Horizon(H::Preset))));
assert!(found.contains(&general(Item::Vanilla(Opt::Preset))));
ui.set_search("horizon preset");
assert_eq!(ui.listed(&s, SIZE), [general(Item::Horizon(H::Preset))]);
ui.set_search("hdr");
let [x, y, w, h] = cols.page(Page::ALL.len() - 1);
press(&mut ui, &mut s, (x + w / 2.0, y + h / 2.0));
+2 -2
View File
@@ -55,7 +55,7 @@ pub struct TextureFlags {
impl TextureFlags {
/// The `texture` section of a `.png.mcmeta` file, if it sets either flag.
pub fn from_mcmeta(json: &str) -> Option<Self> {
let meta: serde_json::Value = crate::packs::first_json(json.as_bytes()).ok()?;
let meta = crate::packs::first_json(json.as_bytes())?;
let section = meta.get("texture")?;
let flag = |key| section.get(key).and_then(serde_json::Value::as_bool) == Some(true);
let flags = Self {
@@ -388,7 +388,7 @@ fn strip_frame(pixels: &[u8], stride: u32, x: u32, y: u32, w: u32, h: u32) -> Ve
}
/// The `animation` section of a texture's `.mcmeta`.
fn animation_of(mcmeta: Option<&str>) -> Option<AnimationMetadata> {
let meta: serde_json::Value = crate::packs::first_json(mcmeta?.as_bytes()).ok()?;
let meta = crate::packs::first_json(mcmeta?.as_bytes())?;
AnimationMetadata::from_json(&meta.get("animation")?.to_string()).ok()
}
/// An animated strip's first frame: the region the mcmeta's frame size picks
@@ -39,13 +39,14 @@ pub fn text_with_backdrop(
background: u32,
) {
if background != 0 {
// GuiGraphicsExtractor.textWithBackdrop adds two pixels around the
// text's advance and the font's nine-pixel line height.
// 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 - 2,
y - 2,
x + w + 2,
y + 11,
x - 1,
y - 1,
x + w,
y + 9,
multiply(background, color),
));
}
@@ -1490,10 +1491,10 @@ mod boss_bar_room_tests {
mod backdrop_tests {
use super::*;
/// `GuiGraphicsExtractor.textWithBackdrop`: two pixels around the advance
/// and the nine-pixel font line, independently of glyph-background drawing.
/// `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_text_by_two_pixels() {
fn the_backdrop_frames_the_glyphs_by_one_pixel() {
let mut out = Vec::new();
text_with_backdrop(
&mut out,
@@ -1507,6 +1508,6 @@ mod backdrop_tests {
let Some(GuiDraw::Fill { x0, y0, x1, y1, .. }) = out.first() else {
panic!("a backdrop is a fill first");
};
assert_eq!((*x0, *y0, *x1, *y1), (8, 18, 18, 31));
assert_eq!((*x0, *y0, *x1, *y1), (9, 19, 16, 29));
}
}
+46 -34
View File
@@ -1594,9 +1594,9 @@ pub struct OptionsUi {
radial_pick: shell::Shell<crate::radial_editor::PickScreen>,
/// Shaders: the shader packs and the picked one's options, on the settings shell.
shader: shell::Shell<crate::shader_ui::ShaderScreen>,
/// Pack Options, on the settings shell, and the packs' Polytone config entries by pack id
/// (the game fills them beside `pack_infos`).
pack_options: shell::Shell<crate::pack_options_ui::PackOptionsScreen>,
/// The Pack Options page, and the packs' Polytone config entries by pack id (the game
/// fills them beside `pack_infos`).
pub pack_options: crate::pack_options_ui::PackOptionsUi,
pub pack_configs: std::collections::BTreeMap<String, horizon_polytone::pack::Configs>,
pub split_profiles: crate::split_profiles::SplitProfiles,
/// HDR calibration while its page is open.
@@ -3401,6 +3401,10 @@ impl OptionsUi {
c.draw(batch, atlas, cursor);
return;
}
if self.page() == Page::PackOptions {
self.pack_options.draw(batch, atlas, cursor, self.in_world);
return;
}
let waiting = self.waiting();
let in_world = self.in_world;
if self.draw_radial_name(batch, atlas, cursor, in_world) {
@@ -3782,6 +3786,20 @@ impl OptionsUi {
});
return self.shell_events(s, outputs);
}
if self.page() == Page::PackOptions {
let mut events = Vec::new();
for action in self
.pack_options
.click((x, y), size, drag, right || self.shift)
{
match action {
crate::pack_options_ui::Action::ButtonClick => events.push(Event::ButtonClick),
crate::pack_options_ui::Action::SliderDrag => {}
crate::pack_options_ui::Action::Done => events.extend(self.back(s)),
}
}
return events;
}
if self.page() == Page::Packs {
let mut events = Vec::new();
if drag {
@@ -3811,7 +3829,14 @@ impl OptionsUi {
crate::pack_screen::Action::BrowsePick(i) => {
events.push(Event::DownloadPack(i));
}
crate::pack_screen::Action::Options(id) => self.open_pack_options(&id),
crate::pack_screen::Action::Options(id) => {
let mut configs = horizon_polytone::pack::Configs::default();
if let Some(own) = self.pack_configs.get(&id) {
configs.merge(own);
}
self.pack_options = crate::pack_options_ui::PackOptionsUi::open(configs);
self.push(Page::PackOptions);
}
crate::pack_screen::Action::None => {}
}
return events;
@@ -4049,6 +4074,7 @@ impl OptionsUi {
// release, even if the pointer has left the widget or its value stayed.
let slider = self.dragging.take().is_some();
let shell_slider = self.shell_mut().and_then(|shell| shell.release());
self.pack_options.release();
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
let split_drop = self.split_setup.release();
if slider || shell_slider.is_some() || calibration_slider {
@@ -4142,6 +4168,10 @@ impl OptionsUi {
self.studio.wheel(amount);
return Vec::new();
}
if self.page() == Page::PackOptions {
self.pack_options.scroll(size, amount);
return Vec::new();
}
let plain = self.resource_error.is_none()
&& !(self.pack_screen.is_some() && self.stack.last() == Some(&Page::Packs));
if !plain {
@@ -4225,6 +4255,10 @@ impl OptionsUi {
self.studio.wheel(amount);
return;
}
if self.page() == Page::PackOptions {
self.pack_options.scroll(size, amount);
return;
}
match &mut self.pack_screen {
Some(p) if self.stack.last() == Some(&Page::Packs) => {
p.scroll(cursor.0 as f64, cursor.1 as f64, amount as f64)
@@ -4359,8 +4393,14 @@ impl OptionsUi {
self.can_change_value = false;
match page {
Page::PackOptions => {
let screen = std::mem::take(&mut self.pack_options.screen);
events.extend(screen.finished());
let ui = std::mem::take(&mut self.pack_options);
if ui.changed {
let entries = ui.configs.entries().keys().cloned().collect();
events.push(Event::PackOptions {
entries,
chosen: ui.configs.chosen(),
});
}
}
Page::ShaderOptions | Page::ShaderPacks => {
if s.shader_options.as_ref().is_some_and(|o| o.changed) {
@@ -4920,34 +4960,6 @@ mod tests {
/// A renderer whose destination offers nothing and whose display reports nothing.
const NONE: EffectiveOutput = EffectiveOutput::NONE;
#[test]
fn pack_options_leave_with_a_change_and_with_nothing_after_its_undo() {
use crate::settings_shell::Screen as _;
let mut s = Settings::default();
let atlas = HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets"));
let atlas = atlas.unwrap();
let mut ui = OptionsUi::default();
let mut configs = horizon_polytone::pack::Configs::default();
let birds = serde_json::json!({"default_value": true});
configs.add_entry("p:birds".into(), &birds);
ui.pack_configs.insert("pack".into(), configs);
for undone in [false, true] {
ui.open_pack_options("pack");
assert!(ui.shell().is_some(), "Pack Options is on the shell");
let birds = crate::pack_options_ui::Item::Entry(0);
ui.pack_options.screen.press(&mut s, birds, false);
if undone {
let before = s.clone();
ui.pack_options.screen.undo(&mut s, &before);
}
let events = ui.key(&mut s, &atlas, winit::keyboard::KeyCode::Escape, None);
let sent = events
.iter()
.any(|e| matches!(e, Event::PackOptions { .. }));
assert_eq!(sent, !undone, "undone: {undone}");
}
}
/// The window allows the input method only while `typing` says a search
/// box has the focus (`Game::text_field_focused`), so a page without one
/// must not claim the keys: the movement and menu keys keep working there.
@@ -23,7 +23,6 @@ impl OptionsUi {
Page::ControllerRadialPick => Some(&self.radial_pick),
page if Self::controller_page(page).is_some() => Some(&self.controller),
page if Self::shader_page(page).is_some() => Some(&self.shader),
Page::PackOptions => Some(&self.pack_options),
_ => None,
}
}
@@ -36,20 +35,9 @@ impl OptionsUi {
Page::ControllerRadialPick => Some(&mut self.radial_pick),
page if Self::controller_page(page).is_some() => Some(&mut self.controller),
page if Self::shader_page(page).is_some() => Some(&mut self.shader),
Page::PackOptions => Some(&mut self.pack_options),
_ => None,
}
}
/// Pack Options for the pack `id`, from the pack screen: its Polytone config entries.
pub(super) fn open_pack_options(&mut self, id: &str) {
let mut configs = horizon_polytone::pack::Configs::default();
if let Some(own) = self.pack_configs.get(id) {
configs.merge(own);
}
// Opening a shell starts it afresh: the entries go in after.
self.push(Page::PackOptions);
self.pack_options.screen = crate::pack_options_ui::PackOptionsScreen::open(configs);
}
/// Where the open shell screen asked the focus to open (a radial slot's list, on the slot's
/// action), for a controller's focus to land there.
pub fn shell_opened_focus(&self) -> Option<[f32; 4]> {
@@ -65,14 +53,13 @@ impl OptionsUi {
return;
}
// Every shell: the click may have changed the page.
let shells: [&mut dyn shell::Hosted; 7] = [
let shells: [&mut dyn shell::Hosted; 6] = [
&mut self.extras,
&mut self.graphics,
&mut self.controller,
&mut self.radial,
&mut self.radial_pick,
&mut self.shader,
&mut self.pack_options,
];
for shell in shells {
shell.clicks_step(false);
@@ -1,36 +1,45 @@
//! The Pack Options screen (#11486), on the settings shell (`settings_shell`): the config entries
//! of the selected resource packs' Polytone `config_entries` (wiki "Polytone Configs"). Each
//! `section` is a page, named by the packs' own translations; numbers are sliders that stop at
//! the entry's `step`, value lists are choices, booleans switch and free text reads. The first
//! page starts with the pack-wide presets and Reset all. The shell gives it the keyboard, a
//! controller's focus, the search, the strip's descriptions and Undo; leaving keeps what differs
//! from how the screen opened, and the packs load again with it.
//! The Pack Options page (#11486): the config entries of the selected resource packs' Polytone
//! `config_entries` (wiki "Polytone Configs"), in the frame of Horizon's settings screens
//! (`settings_shell`). Numbers are sliders, texts cycle, booleans switch; the entries sit in
//! their `section`s, named by the packs' own translations. Leaving with Done keeps what changed,
//! and the packs load again with it.
use crate::widgets::Action as Declared;
use crate::{
graphics_style as style,
hud::{HudAtlas, HudBatch},
lang,
settings::Settings,
settings_shell::{self as shell, Group, Output, Row, Sheet, Value},
settings_shell::{self as shell, Columns, Line, Lines},
text::Text,
ui,
};
use horizon_polytone::pack::{ConfigKind, ConfigValue, Configs};
/// A row: the pack-wide presets, Reset all, or the entry at an index of `ids`.
/// What a click asks of the options screen.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Item {
Presets,
ResetAll,
Entry(u16),
pub enum Action {
ButtonClick,
SliderDrag,
/// Done: leave, keeping what changed.
Done,
}
const TITLE: &str = "horizon.pack.options.title";
/// The first page's group of rows for the whole pack, and every page's group of entries.
const WHOLE_PACK: &str = "Whole pack";
const PACK_OPTIONS: &str = "Pack settings";
const RESET_ALL: &str = "Reset all";
const RESET_ABOUT: &str = "Puts every option of this pack back to its default.";
const AT_DEFAULTS: &str = "Every option is at its default already.";
const PRESETS_ABOUT: &str = "Sets many options at once, as the pack groups them.";
const PACK_SETTINGS: &str = "Settings made by the pack's author: they change only this pack.";
/// A pack's tooltips, with the performance line after them, run longer than Horizon's own.
const NOTE_LINES: usize = 3;
/// The page's state: the entries being edited, how they are listed, and the pointer's hold.
#[derive(Default)]
pub struct PackOptionsUi {
/// The selected packs' entries and the values being picked.
pub configs: Configs,
/// Entry ids, by the row index the list uses.
ids: Vec<String>,
/// The sections in order: the `section` id (none for entries without one) and its rows.
sections: Vec<(Option<String>, Vec<u16>)>,
scroll: ui::Scroll,
dragging: Option<u16>,
/// A value changed since the page opened.
pub changed: bool,
}
/// The presets' row (and its section): past every entry's index.
const PRESETS: u16 = u16::MAX;
/// A translation, or `fallback` where no language has the key.
fn translated(key: &str, fallback: impl FnOnce() -> String) -> String {
@@ -56,34 +65,9 @@ fn readable(path: &str) -> String {
.unwrap_or_default()
}
/// A number as its entry shows it: whole with a whole step, else up to two decimals.
fn number_text(n: f64, step: f64) -> String {
if step >= 1.0 && step.fract() == 0.0 {
format!("{n:.0}")
} else {
let text = format!("{n:.2}");
text.trim_end_matches('0').trim_end_matches('.').to_owned()
}
}
/// The screen: the entries being edited, as they were when it opened (for Undo and for whether
/// leaving changes anything), and how they are paged.
#[derive(Default)]
pub(crate) struct PackOptionsScreen {
/// The selected packs' entries and the values being picked.
pub(crate) configs: Configs,
opened: Configs,
/// Entry ids, by `Item::Entry`'s index.
ids: Vec<String>,
/// The sections in order: the `section` id (none for entries without one) and its entries.
sections: Vec<(Option<String>, Vec<u16>)>,
/// Bumped on every change, for `stamp`: the values are floats, which don't hash.
revision: u64,
}
impl PackOptionsScreen {
/// The screen opens on `configs`, the selected packs' entries with the player's values.
pub(crate) fn open(configs: Configs) -> Self {
impl PackOptionsUi {
/// The page opens on `configs`, the selected packs' entries with the player's values.
pub fn open(configs: Configs) -> Self {
let mut ids: Vec<(String, Option<String>, i64)> = configs
.entries()
.iter()
@@ -112,21 +96,25 @@ impl PackOptionsScreen {
(None, None) => a.0.cmp(&b.0),
});
Self {
opened: configs.clone(),
configs,
ids: ids.into_iter().map(|(id, ..)| id).collect(),
sections: sections.into_iter().map(|(s, _, rows)| (s, rows)).collect(),
revision: 0,
..Self::default()
}
}
/// What leaving sends: the entries and the player's choices, if they differ from how the
/// screen opened (an Undo, or a value stepped back, leaves nothing to send).
pub(crate) fn finished(&self) -> Option<crate::options_ui::Event> {
(self.configs != self.opened).then(|| crate::options_ui::Event::PackOptions {
entries: self.configs.entries().keys().cloned().collect(),
chosen: self.configs.chosen(),
})
fn layout(&self, w: f32, h: f32) -> (Columns, Vec<Line<u16, u16>>, f32) {
let cols = Columns::of(w, h);
let mut lines = Lines::new();
// The pack-wide presets first, when the entries have some.
if !self.configs.presets().is_empty() {
lines.titled(PRESETS, vec![PRESETS]);
}
for (i, (_, rows)) in self.sections.iter().enumerate() {
lines.titled(i as u16, rows.clone());
}
let (lines, content) = lines.finish();
(cols, lines, content)
}
/// The namespace the presets' translations are under: of the first entry with presets.
@@ -178,14 +166,21 @@ impl PackOptionsScreen {
None if back => n - 1,
None => 0,
};
let before = self.configs.clone();
self.configs.apply_preset(&presets[next]);
self.revision += 1;
if self.configs != before {
self.changed = true;
}
}
/// A section's page name: `config.<namespace>.section.<id>`, by its first entry's namespace.
fn section_name(&self, section: usize) -> String {
let Some((id, rows)) = self.sections.get(section) else {
return lang::get("horizon.pack.options.general");
/// A section's heading: `config.<namespace>.section.<id>`, by its first entry's namespace.
fn section_name(&self, section: u16) -> String {
if section == PRESETS {
let key = format!("{}.presets", self.presets_namespace());
return translated(&key, || lang::get("horizon.pack.options.presets"));
}
let Some((id, rows)) = self.sections.get(usize::from(section)) else {
return String::new();
};
let Some(id) = id else {
return lang::get("horizon.pack.options.general");
@@ -197,218 +192,367 @@ impl PackOptionsScreen {
translated(&format!("config.{namespace}.section.{id}"), || readable(id))
}
/// The tooltip (`config.<namespace>.<name>.tooltip`) and the performance line after it.
fn description(&self, id: &str) -> String {
/// An entry's name (`config.<namespace>.<name>`) and its value's label.
fn parts(&self, row: u16) -> (String, Option<String>) {
if row == PRESETS {
let label = self.preset_label(self.current_preset().as_deref());
return (self.section_name(PRESETS), Some(label));
}
let Some(id) = self.ids.get(usize::from(row)) else {
return (String::new(), None);
};
let (namespace, path) = split(id);
let name = translated(&format!("config.{namespace}.{path}"), || readable(path));
let entry = &self.configs.entries()[id];
let value = match self.configs.get(id) {
Some(ConfigValue::Number(n)) => {
let step = match entry.kind {
ConfigKind::Number { step, .. } => step,
_ => 1.0,
};
let text = if step >= 1.0 && step.fract() == 0.0 {
format!("{n:.0}")
} else {
format!("{n:.2}")
.trim_end_matches('0')
.trim_end_matches('.')
.to_owned()
};
Some(text)
}
Some(ConfigValue::Text(t)) => Some(t.clone()),
Some(ConfigValue::Bool(_)) | None => None,
};
let value = value.map(|v| match &entry.value_translation {
Some(key) if lang::has(key) => lang::tr(key, &[&v]),
_ => v,
});
(name, value)
}
/// The tooltip (`config.<namespace>.<name>.tooltip`) and the performance hint.
fn description(&self, row: u16) -> String {
let Some(id) = self.ids.get(usize::from(row)) else {
return String::new();
};
let (namespace, path) = split(id);
let key = format!("config.{namespace}.{path}.tooltip");
let tooltip = if lang::has(&key) {
let mut text = if lang::has(&key) {
lang::get(&key)
} else {
String::new()
};
let impact = self.configs.entries()[id]
.performance_impact
.as_ref()
.map(|impact| {
let level = lang::get(&format!("horizon.pack.options.impact.{impact}"));
lang::tr("horizon.pack.options.impact", &[&level])
});
match impact {
Some(line) if tooltip.is_empty() => line,
Some(line) => format!("{tooltip} {line}"),
None => tooltip,
if let Some(impact) = &self.configs.entries()[id].performance_impact {
let level = lang::get(&format!("horizon.pack.options.impact.{impact}"));
let line = lang::tr("horizon.pack.options.impact", &[&level]);
text = if text.is_empty() {
line
} else {
format!("{text} {line}")
};
}
text
}
/// A slider's place (0 to 1) for a number entry.
fn slider(&self, row: u16) -> Option<f32> {
let id = self.ids.get(usize::from(row))?;
let ConfigKind::Number { min, max, .. } = self.configs.entries()[id].kind else {
return None;
};
let Some(ConfigValue::Number(n)) = self.configs.get(id) else {
return None;
};
Some(if max > min {
((n - min) / (max - min)) as f32
} else {
0.0
})
}
fn switch(&self, row: u16) -> Option<bool> {
match self.configs.get(self.ids.get(usize::from(row))?)? {
ConfigValue::Bool(b) => Some(*b),
_ => None,
}
}
/// Entry `i`'s row: a switch, a slider in its steps, a choice, or text to read.
fn entry_row(&self, i: u16) -> Option<Row<Item>> {
let id = self.ids.get(usize::from(i))?;
let (namespace, path) = split(id);
let name = translated(&format!("config.{namespace}.{path}"), || readable(path));
let entry = &self.configs.entries()[id];
let shown = |text: String| match &entry.value_translation {
Some(key) if lang::has(key) => lang::tr(key, &[&text]),
_ => text,
};
let value = match (&entry.kind, self.configs.get(id)?) {
(_, ConfigValue::Bool(on)) => Value::Switch(*on),
(&ConfigKind::Number { min, max, step }, &ConfigValue::Number(n)) => {
let span = max - min;
let stops = if step > 0.0 {
(span / step).round()
} else {
0.0
};
Value::Slider {
position: if span > 0.0 {
((n - min) / span) as f32
} else {
0.0
},
steps: (stops >= 1.0).then_some(stops as u32),
text: shown(number_text(n, step)),
pub fn draw(
&mut self,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
in_world: bool,
) {
let (w, h) = (batch.width, batch.height);
let (cols, lines, content) = self.layout(w, h);
self.scroll.clamp(cols.top, cols.bottom, content);
shell::backdrop(batch, atlas, in_world);
shell::mark(
batch,
atlas,
&cols,
&lang::get("horizon.pack.options.title"),
);
shell::plate(
batch,
atlas,
&cols,
&lang::get("horizon.pack.options.title"),
);
let over_list = cursor.1 >= cols.top && cursor.1 < cols.bottom;
let mut described = None;
shell::list(
batch,
atlas,
&cols,
&lines,
self.scroll.amount,
|section| self.section_name(section),
|batch, row: u16, y| {
let [rx, ry, rw, rh] = cols.row(y);
let hover = over_list && ui::hovered(cursor, rx, ry, rw, rh);
if hover || self.dragging == Some(row) {
described = Some(row);
}
let (name, value) = self.parts(row);
let label = match &value {
Some(v) => format!("{name}: {v}"),
None => name.clone(),
};
let view = shell::RowView {
label: &label,
name: &name,
value: value.as_deref(),
switch: self.switch(row),
slider: self.slider(row),
live: true,
hover,
focused: false,
held: self.dragging == Some(row),
key: None,
};
shell::row(batch, atlas, &cols, y, &view);
},
);
shell::scroller(batch, atlas, &cols, content, self.scroll.amount);
// The strip: the pointed-at entry's tooltip.
let [x, y, ..] = cols.dock;
style::panel(batch, atlas, cols.dock);
if let Some(row) = described {
let name = Text::literal(self.parts(row).0);
batch.text_styled(atlas, &name, x + 12.0, y + 8.0, style::WHITE, true);
shell::dock_note(batch, atlas, &cols, self.description(row), style::GREY);
}
let mut foot = vec![(cols.button(0), "gui.done", Some(Declared::Done))];
if self
.configs
.chosen()
.iter()
.any(|(id, _)| self.ids.contains(id))
{
foot.push((cols.button(1), "controls.reset", None));
}
for (rect, key, declared) in foot {
if let Some(declared) = declared {
crate::widgets::declare_action(declared, rect, true);
}
(ConfigKind::Text { allowed }, ConfigValue::Text(t)) if !allowed.is_empty() => {
Value::Choice(shown(t.clone()))
}
(_, value) => Value::Read(shown(match value {
ConfigValue::Number(n) => number_text(*n, 1.0),
ConfigValue::Text(t) => t.clone(),
ConfigValue::Bool(b) => b.to_string(),
})),
};
Some(Row::new(Item::Entry(i), name, value).about(self.description(id)))
let label = Text::literal(lang::get(key));
shell::foot_button(batch, atlas, rect, &label, cursor, (false, true));
}
}
/// The rows for the whole pack: its presets, when it has some, and Reset all.
fn whole_pack(&self) -> Group<Item> {
let mut rows = Vec::new();
if !self.configs.presets().is_empty() {
let key = format!("{}.presets", self.presets_namespace());
let name = translated(&key, || lang::get("horizon.pack.options.presets"));
let label = self.preset_label(self.current_preset().as_deref());
rows.push(
Row::new(Item::Presets, name, Value::Choice(label)).about(lang::get(PRESETS_ABOUT)),
);
/// A press (or a drag with `drag`) at `(x, y)`; `back` turns a cycle backwards.
pub fn click(
&mut self,
(x, y): (f32, f32),
(w, h): (f32, f32),
drag: bool,
back: bool,
) -> Vec<Action> {
let (cols, lines, content) = self.layout(w, h);
let at = |[rx, ry, rw, rh]: [f32; 4]| ui::hovered((x, y), rx, ry, rw, rh);
if drag {
if self.scroll.held() {
self.scroll.drag(y, cols.top, cols.bottom, content);
} else if let Some(row) = self.dragging {
self.set_slider(row, cols.slider_at(x));
return vec![Action::SliderDrag];
}
return Vec::new();
}
let at_defaults = self.configs.chosen().is_empty();
let reset = Row::new(Item::ResetAll, lang::get(RESET_ALL), Value::Press)
.about(lang::get(RESET_ABOUT))
.blocked(at_defaults.then(|| lang::get(AT_DEFAULTS)));
rows.push(reset);
Group {
name: WHOLE_PACK,
rows,
if cols.scrolls()
&& self
.scroll
.press((x, y), cols.bar_x(), cols.top, cols.bottom, content)
{
return Vec::new();
}
if at(cols.button(0)) {
return vec![Action::ButtonClick, Action::Done];
}
if at(cols.button(1)) {
self.reset();
return vec![Action::ButtonClick];
}
if y < cols.top || y >= cols.bottom {
return Vec::new();
}
let Some(row) = lines.iter().find_map(|line| {
let row = line.setting?;
at(cols.row(cols.top + line.y - self.scroll.amount)).then_some(row)
}) else {
return Vec::new();
};
if row == PRESETS {
self.cycle_preset(back);
return vec![Action::ButtonClick];
}
let Some(id) = self.ids.get(usize::from(row)).cloned() else {
return Vec::new();
};
let entry = self.configs.entries()[&id].clone();
match (&entry.kind, self.configs.get(&id).cloned()) {
(ConfigKind::Number { .. }, _) => {
self.dragging = Some(row);
self.set_slider(row, cols.slider_at(x));
return vec![Action::SliderDrag];
}
(ConfigKind::Bool, Some(ConfigValue::Bool(b))) => {
self.set(&id, ConfigValue::Bool(!b));
}
(ConfigKind::Text { allowed }, Some(ConfigValue::Text(t))) if !allowed.is_empty() => {
let i = allowed.iter().position(|a| *a == t).unwrap_or(0);
let n = allowed.len();
let next = if back { (i + n - 1) % n } else { (i + 1) % n };
self.set(&id, ConfigValue::Text(allowed[next].clone()));
}
_ => return Vec::new(),
}
vec![Action::ButtonClick]
}
fn set(&mut self, id: &str, value: ConfigValue) {
let before = self.configs.get(id).cloned();
self.configs.set(id, value);
self.revision += 1;
if self.configs.get(id).cloned() != before {
self.changed = true;
}
}
/// Number entry `row` at slider place `at` (0 to 1).
fn set_slider(&mut self, row: u16, at: f32) {
let Some(id) = self.ids.get(usize::from(row)).cloned() else {
return;
};
if let ConfigKind::Number { min, max, .. } = self.configs.entries()[&id].kind {
self.set(&id, ConfigValue::Number(min + f64::from(at) * (max - min)));
}
}
/// Every entry back to its default.
fn reset(&mut self) {
let defaults: Vec<_> = (self.configs.entries().iter())
let defaults: Vec<_> = self
.configs
.entries()
.iter()
.map(|(id, e)| (id.clone(), e.default.clone()))
.collect();
for (id, value) in defaults {
self.set(&id, value);
}
}
}
impl shell::Screen for PackOptionsScreen {
type Page = u16;
type Row = Item;
fn title(&self) -> &'static str {
TITLE
/// The pointer lets go.
pub fn release(&mut self) {
self.scroll.release();
self.dragging = None;
}
fn note_lines(&self) -> usize {
NOTE_LINES
}
/// The rows read only the screen's own entries, which change through it alone.
fn stamp(&self, _s: &Settings) -> u64 {
self.revision
}
/// A page per section; the first starts with the rows for the whole pack.
fn sheets(&self, _s: &Settings) -> Vec<Sheet<u16, Item>> {
let about = lang::get(PACK_SETTINGS);
let mut sheets = Vec::new();
for (i, (_, entries)) in self.sections.iter().enumerate() {
let rows = entries.iter().filter_map(|&e| self.entry_row(e)).collect();
let mut groups = vec![Group {
name: PACK_OPTIONS,
rows,
}];
if i == 0 {
groups.insert(0, self.whole_pack());
}
sheets.push(Sheet::new(
i as u16,
self.section_name(i),
about.clone(),
groups,
));
}
if sheets.is_empty() {
let groups = vec![self.whole_pack()];
sheets.push(Sheet::new(0, self.section_name(0), about, groups));
}
sheets
}
/// A switch flips, a choice and the presets step (back with Shift, or Left), Reset all
/// resets.
fn press(&mut self, _s: &mut Settings, item: Item, back: bool) -> Vec<Output> {
let i = match item {
Item::Presets => {
self.cycle_preset(back);
return Vec::new();
}
Item::ResetAll => {
self.reset();
return Vec::new();
}
Item::Entry(i) => i,
};
let Some(id) = self.ids.get(usize::from(i)).cloned() else {
return Vec::new();
};
let kind = self.configs.entries()[&id].kind.clone();
match (kind, self.configs.get(&id).cloned()) {
(_, Some(ConfigValue::Bool(on))) => self.set(&id, ConfigValue::Bool(!on)),
(ConfigKind::Text { allowed }, Some(ConfigValue::Text(t))) if !allowed.is_empty() => {
let at = allowed.iter().position(|a| *a == t).unwrap_or(0);
let n = allowed.len();
let next = if back { (at + n - 1) % n } else { (at + 1) % n };
self.set(&id, ConfigValue::Text(allowed[next].clone()));
}
_ => {}
}
Vec::new()
}
/// A slider moves its entry to the step nearest it (`Configs::set` snaps it).
fn slide(&mut self, _s: &mut Settings, item: Item, position: f32) {
let Item::Entry(i) = item else { return };
let Some(id) = self.ids.get(usize::from(i)).cloned() else {
return;
};
if let ConfigKind::Number { min, max, .. } = self.configs.entries()[&id].kind {
let value = min + f64::from(position.clamp(0.0, 1.0)) * (max - min);
self.set(&id, ConfigValue::Number(value));
}
}
/// With the "Scroll wheel changes settings" extra, the wheel steps a choice and the presets.
fn wheel_step(&mut self, s: &mut Settings, item: Item, back: bool) -> Option<Vec<Output>> {
let steps = match item {
Item::Presets => true,
Item::Entry(i) => self
.entry_row(i)
.is_some_and(|row| matches!(row.value, Value::Choice(_))),
Item::ResetAll => false,
};
if !steps {
return None;
}
let mut outputs = vec![Output::Event(crate::options_ui::Event::ButtonClick)];
outputs.extend(self.press(s, item, back));
Some(outputs)
}
fn undoes(&self) -> bool {
true
}
/// The entries as the screen opened with them.
fn undo(&mut self, _s: &mut Settings, _before: &Settings) -> Vec<Output> {
self.configs = self.opened.clone();
self.revision += 1;
Vec::new()
}
/// Undo shows exactly while an entry differs from how the screen opened.
fn differs(&self, _s: &Settings, _before: &Settings) -> Option<bool> {
Some(self.configs != self.opened)
/// The mouse wheel scrolls the list.
pub fn scroll(&mut self, (w, h): (f32, f32), amount: f32) {
let (cols, _, content) = self.layout(w, h);
self.scroll
.wheel(amount, 18.0, cols.top, cols.bottom, content);
}
}
#[cfg(test)]
mod tests;
mod tests {
use super::*;
fn configs() -> Configs {
let mut c = Configs::default();
c.add_entry(
"p:fog".into(),
&serde_json::json!({"default_value": 0.5, "section": "sky", "section_order": 2}),
);
c.add_entry("p:rain".into(), &serde_json::json!({"default_value": "drops", "allowed_values": ["drops", "streaks"], "section": "weather", "section_order": 1}));
c.add_entry(
"p:birds".into(),
&serde_json::json!({"default_value": true}),
);
c
}
#[test]
fn sections_sort_by_their_order_and_the_unsectioned_last() {
let ui = PackOptionsUi::open(configs());
let order: Vec<_> = ui.sections.iter().map(|(s, _)| s.as_deref()).collect();
assert_eq!(order, [Some("weather"), Some("sky"), None]);
}
#[test]
fn rows_switch_cycle_and_slide() {
let mut ui = PackOptionsUi::open(configs());
let size = (480.0, 270.0);
let (cols, lines, _) = ui.layout(size.0, size.1);
let ids = ui.ids.clone();
let point = |id: &str| {
let row = ids.iter().position(|i| i == id).unwrap() as u16;
let line = lines.iter().find(|l| l.setting == Some(row)).unwrap();
let [x, y, w, h] = cols.row(cols.top + line.y);
(x + w - 4.0, y + h / 2.0)
};
let (birds, rain) = (point("p:birds"), point("p:rain"));
ui.click(birds, size, false, false);
assert_eq!(ui.configs.get("p:birds"), Some(&ConfigValue::Bool(false)));
ui.click(rain, size, false, false);
assert_eq!(
ui.configs.get("p:rain"),
Some(&ConfigValue::Text("streaks".into()))
);
// The slider's far right end is its maximum.
let fog = point("p:fog");
let (tx, tw) = cols.track();
ui.click((tx + tw, fog.1), size, false, false);
ui.release();
assert_eq!(ui.configs.get("p:fog"), Some(&ConfigValue::Number(1.0)));
assert!(ui.changed);
assert_eq!(ui.configs.chosen().len(), 3);
ui.reset();
assert!(ui.configs.chosen().is_empty());
}
#[test]
fn the_presets_row_cycles_the_pack_wide_presets_and_shows_custom_otherwise() {
let mut c = Configs::default();
let entry = |default: f64, fast: f64, pretty: f64| serde_json::json!({"default_value": default, "presets": {"fast": fast, "pretty": pretty}});
c.add_entry("p:fog".into(), &entry(0.5, 0.0, 1.0));
c.add_entry("p:dust".into(), &entry(0.5, 0.0, 0.8));
let mut ui = PackOptionsUi::open(c);
// The defaults are no preset.
assert_eq!(ui.current_preset(), None);
ui.cycle_preset(false);
assert_eq!(ui.current_preset().as_deref(), Some("fast"));
assert_eq!(ui.configs.get("p:fog"), Some(&ConfigValue::Number(0.0)));
ui.cycle_preset(false);
assert_eq!(ui.current_preset().as_deref(), Some("pretty"));
ui.cycle_preset(true);
assert_eq!(ui.current_preset().as_deref(), Some("fast"));
// One value off the preset: custom again.
ui.set("p:dust", ConfigValue::Number(0.3));
assert_eq!(ui.current_preset(), None);
assert!(ui.changed);
}
}
@@ -1,281 +0,0 @@
use super::*;
use crate::options_ui::Event;
use crate::settings_shell::{Columns, Hosted, Screen as _, Shell, Target};
use winit::keyboard::KeyCode as K;
const SIZE: (f32, f32) = (854.0, 480.0);
/// Fog (a number) under Sky, rain (a list of values) under Weather, birds (a boolean) in no
/// section.
fn configs() -> Configs {
let mut c = Configs::default();
c.add_entry(
"p:fog".into(),
&serde_json::json!({"default_value": 0.5, "section": "sky", "section_order": 2}),
);
c.add_entry("p:rain".into(), &serde_json::json!({"default_value": "drops", "allowed_values": ["drops", "streaks"], "section": "weather", "section_order": 1}));
c.add_entry(
"p:birds".into(),
&serde_json::json!({"default_value": true}),
);
c
}
/// Two entries with the pack-wide presets `fast` and `pretty`.
fn with_presets() -> Configs {
let mut c = Configs::default();
let entry = |default: f64, fast: f64, pretty: f64| serde_json::json!({"default_value": default, "presets": {"fast": fast, "pretty": pretty}});
c.add_entry("p:fog".into(), &entry(0.5, 0.0, 1.0));
c.add_entry("p:dust".into(), &entry(0.5, 0.0, 0.8));
c
}
fn shell_of(configs: Configs) -> Shell<PackOptionsScreen> {
let mut shell = Shell::default();
shell.screen = PackOptionsScreen::open(configs);
shell
}
/// The row of entry `id`, and the page it is on.
fn entry(shell: &Shell<PackOptionsScreen>, id: &str) -> (u16, Item) {
let i = shell.screen.ids.iter().position(|e| e == id).unwrap() as u16;
let page = shell.screen.sections.iter().position(|s| s.1.contains(&i));
(page.unwrap() as u16, Item::Entry(i))
}
fn press(shell: &mut Shell<PackOptionsScreen>, s: &mut Settings, item: Item) -> Vec<Output> {
let at = shell.point_at(s, SIZE, item);
shell.click(s, at, SIZE, false, false, false)
}
fn value<'a>(shell: &'a Shell<PackOptionsScreen>, id: &str) -> Option<&'a ConfigValue> {
shell.screen.configs.get(id)
}
#[test]
fn sections_sort_by_their_order_and_the_unsectioned_last() {
let screen = PackOptionsScreen::open(configs());
let order: Vec<_> = screen.sections.iter().map(|(s, _)| s.as_deref()).collect();
assert_eq!(order, [Some("weather"), Some("sky"), None]);
// Each section is a page, the first one holding Reset all before its entries.
let sheets = screen.sheets(&Settings::default());
let names: Vec<_> = sheets.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, ["Weather", "Sky", "horizon.pack.options.general"]);
let first: Vec<Vec<Item>> = (sheets[0].groups.iter())
.map(|g| g.rows.iter().map(|r| r.id).collect())
.collect();
let rain = screen.ids.iter().position(|e| e == "p:rain").unwrap() as u16;
assert_eq!(first, [vec![Item::ResetAll], vec![Item::Entry(rain)]]);
}
#[test]
fn rows_switch_cycle_and_slide() {
let mut s = Settings::default();
let mut shell = shell_of(configs());
let (page, birds) = entry(&shell, "p:birds");
shell.show(page);
assert_eq!(
press(&mut shell, &mut s, birds)[0],
Output::Event(Event::ButtonClick)
);
assert_eq!(value(&shell, "p:birds"), Some(&ConfigValue::Bool(false)));
let (page, rain) = entry(&shell, "p:rain");
shell.show(page);
press(&mut shell, &mut s, rain);
let streaks = ConfigValue::Text("streaks".into());
assert_eq!(value(&shell, "p:rain"), Some(&streaks));
// The slider's far right end is its maximum.
let (page, fog) = entry(&shell, "p:fog");
shell.show(page);
let at = shell.point_at(&s, SIZE, fog);
let (track, width) = Columns::of(SIZE.0, SIZE.1).track();
shell.click(&mut s, (track + width, at.1), SIZE, false, false, false);
shell.release();
assert_eq!(value(&shell, "p:fog"), Some(&ConfigValue::Number(1.0)));
assert_eq!(shell.screen.configs.chosen().len(), 3);
// Reset all, on the first page, puts them all back, and is greyed once they are.
shell.show(0);
press(&mut shell, &mut s, Item::ResetAll);
assert!(shell.screen.configs.chosen().is_empty());
let sheets = shell.screen.sheets(&s);
assert!(!sheets[0].groups[0].rows[0].live);
}
#[test]
fn the_presets_row_cycles_the_pack_wide_presets_and_shows_custom_otherwise() {
let mut screen = PackOptionsScreen::open(with_presets());
// The defaults are no preset.
assert_eq!(screen.current_preset(), None);
screen.cycle_preset(false);
assert_eq!(screen.current_preset().as_deref(), Some("fast"));
assert_eq!(screen.configs.get("p:fog"), Some(&ConfigValue::Number(0.0)));
screen.cycle_preset(false);
assert_eq!(screen.current_preset().as_deref(), Some("pretty"));
screen.cycle_preset(true);
assert_eq!(screen.current_preset().as_deref(), Some("fast"));
// One value off the preset: custom again.
screen.set("p:dust", ConfigValue::Number(0.3));
assert_eq!(screen.current_preset(), None);
let presets = &screen.sheets(&Settings::default())[0].groups[0].rows[0];
assert_eq!(presets.id, Item::Presets);
let custom = lang::get("horizon.pack.options.presets.custom");
assert_eq!(presets.value, Value::Choice(custom));
}
#[test]
fn left_and_right_step_the_presets() {
let mut s = Settings::default();
let mut shell = shell_of(with_presets());
shell.set_focus(Some(Target::Row(Item::Presets)));
shell.key(&mut s, K::ArrowRight, None, false, SIZE);
assert_eq!(shell.screen.current_preset().as_deref(), Some("fast"));
shell.key(&mut s, K::ArrowRight, None, false, SIZE);
assert_eq!(shell.screen.current_preset().as_deref(), Some("pretty"));
shell.key(&mut s, K::ArrowLeft, None, false, SIZE);
assert_eq!(shell.screen.current_preset().as_deref(), Some("fast"));
assert_eq!(shell.focused_row(), Some(Item::Presets));
}
#[test]
fn sliders_stop_at_the_entrys_step() {
let mut c = Configs::default();
let json = serde_json::json!({"default_value": 4.0, "min": 0.0, "max": 10.0, "step": 2.0});
c.add_entry("p:clouds".into(), &json);
let mut s = Settings::default();
let mut shell = shell_of(c);
let row = &shell.screen.sheets(&s)[0].groups[1].rows[0];
let stops = Value::Slider {
position: 0.4,
steps: Some(5),
text: "4".into(),
};
assert_eq!(row.value, stops);
// A press a little past the middle of the track lands on the step nearest it.
let at = shell.point_at(&s, SIZE, Item::Entry(0));
let (track, width) = Columns::of(SIZE.0, SIZE.1).track();
shell.click(
&mut s,
(track + width * 0.55, at.1),
SIZE,
false,
false,
false,
);
shell.release();
assert_eq!(value(&shell, "p:clouds"), Some(&ConfigValue::Number(6.0)));
// Held by the press, Right goes one step on and Left one back.
shell.set_focus(Some(Target::Row(Item::Entry(0))));
shell.key(&mut s, K::ArrowRight, None, false, SIZE);
assert_eq!(value(&shell, "p:clouds"), Some(&ConfigValue::Number(8.0)));
shell.key(&mut s, K::ArrowLeft, None, false, SIZE);
shell.key(&mut s, K::ArrowLeft, None, false, SIZE);
assert_eq!(value(&shell, "p:clouds"), Some(&ConfigValue::Number(4.0)));
}
/// A language with the pack's own strings: a name, a section and a tooltip.
struct PackLang;
impl lang::ResourceSource for PackLang {
fn read(&self, path: &str) -> Option<Vec<u8>> {
(path == "assets/minecraft/lang/en_us.json").then(|| {
let strings = serde_json::json!({
"config.minecraft.haze": "Haze",
"config.minecraft.haze.tooltip": "Thickens the distant air.",
"config.minecraft.section.sky": "Sky and fog",
});
serde_json::to_vec(&strings).unwrap()
})
}
}
fn haze() -> Configs {
let mut c = configs();
let json =
serde_json::json!({"default_value": false, "section": "sky", "performance_impact": "high"});
c.add_entry("minecraft:haze".into(), &json);
c
}
#[test]
fn the_search_finds_an_entry_by_its_tooltip_and_the_strip_has_its_impact() {
let language = lang::Language::load(&[&PackLang], "en_us");
lang::scoped(language, || {
let s = Settings::default();
let mut shell = shell_of(haze());
let (page, haze) = entry(&shell, "minecraft:haze");
shell.set_search("distant air");
assert_eq!(shell.listed(&s, SIZE), [(page, haze)]);
let sheets = shell.screen.sheets(&s);
assert_eq!(sheets[usize::from(page)].name, "Sky and fog");
let row = sheets.iter().flat_map(|p| &p.groups).flat_map(|g| &g.rows);
let row = row.into_iter().find(|r| r.id == haze).unwrap();
assert_eq!(row.name, "Haze");
assert_eq!(
row.about,
"Thickens the distant air. Performance impact: high"
);
});
}
#[test]
fn tab_reaches_every_row_of_every_page() {
let mut s = Settings::default();
let mut shell = shell_of(haze());
let pages = shell.screen.sheets(&s).len() as u16;
let mut seen = Vec::new();
for page in 0..pages {
shell.show(page);
shell.set_focus(None);
for _ in 0..8 {
shell.key(&mut s, K::Tab, None, false, SIZE);
if let Some(row) = shell.focused_row()
&& !seen.contains(&row)
{
seen.push(row);
}
}
}
// Reset all is greyed at the defaults; every entry is reached.
let mut entries: Vec<Item> = (0..shell.screen.ids.len() as u16)
.map(Item::Entry)
.collect();
seen.sort_by_key(|item| format!("{item:?}"));
entries.sort_by_key(|item| format!("{item:?}"));
assert_eq!(seen, entries);
}
#[test]
fn undo_puts_the_entries_back_and_then_leaving_sends_nothing() {
let mut s = Settings::default();
let mut shell = shell_of(configs());
let (page, birds) = entry(&shell, "p:birds");
shell.show(page);
press(&mut shell, &mut s, birds);
assert!(shell.foot_at(&s, SIZE).iter().any(|f| f.0 == Target::Undo));
assert!(shell.screen.finished().is_some());
let undo = shell
.foot_at(&s, SIZE)
.into_iter()
.find(|f| f.0 == Target::Undo);
let [x, y, w, h] = undo.unwrap().1;
shell.click(
&mut s,
(x + w / 2.0, y + h / 2.0),
SIZE,
false,
false,
false,
);
assert_eq!(value(&shell, "p:birds"), Some(&ConfigValue::Bool(true)));
assert!(!shell.foot_at(&s, SIZE).iter().any(|f| f.0 == Target::Undo));
assert_eq!(shell.screen.finished(), None);
// Stepped there and back by hand, it sends nothing either.
press(&mut shell, &mut s, birds);
press(&mut shell, &mut s, birds);
assert_eq!(shell.screen.finished(), None);
press(&mut shell, &mut s, birds);
let Some(Event::PackOptions { entries, chosen }) = shell.screen.finished() else {
panic!("a change is sent");
};
assert_eq!(entries.len(), 3);
assert_eq!(chosen.get("p:birds"), Some(&ConfigValue::Bool(false)));
}
+16 -232
View File
@@ -468,20 +468,17 @@ fn read_meta(source: &Source) -> Option<serde_json::Value> {
/// file with a comment after the closing brace. Gson reads past a UTF-8 byte order mark;
/// serde_json rejects it.
pub(crate) fn parse_meta(bytes: &[u8]) -> Option<serde_json::Value> {
first_json(bytes).ok()
first_json(bytes)
}
/// The first JSON value of a `.mcmeta` file, as `ResourceMetadata.fromJsonStream` reads it:
/// Gson's `TypeAdapter.read` stops after the value, so text after it (packs leave `//` notes
/// there) is never read, and a UTF-8 byte order mark is skipped.
pub(crate) fn first_json<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> serde_json::Result<T> {
let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
match serde_json::Deserializer::from_slice(bytes)
/// Reads the first JSON value from resource metadata, matching Gson's
/// `JsonReader` (which ignores a trailing signature or comment). Resource
/// packs commonly append such text to texture metadata as well as pack.mcmeta.
pub(crate) fn first_json(bytes: &[u8]) -> Option<serde_json::Value> {
serde_json::Deserializer::from_slice(bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes))
.into_iter()
.next()
{
Some(value) => value,
None => Err(serde::de::Error::custom("empty metadata")),
}
.next()?
.ok()
}
/// A pack's Polytone config entries (`assets/<namespace>/polytone/config_entries/<name>.json`,
/// in the pack's base, not its overlays) at their default values.
@@ -830,6 +827,13 @@ fn build_overlay_uncached(
}
// Vanilla sits below every pack, so filters hide its files too.
let filters: Vec<&Filter> = packs.iter().flat_map(|p| &p.filter).collect();
let touched_dirs: BTreeSet<String> = files
.keys()
.flat_map(|f| {
let parts: Vec<&str> = f.split('/').collect();
(1..parts.len()).map(move |n| parts[..n].join("/"))
})
.collect();
let zips: Vec<Option<Zip>> = packs
.iter()
.map(|p| {
@@ -878,21 +882,6 @@ fn build_overlay_uncached(
merged.insert(name.clone(), bytes);
}
}
add_sprite_renames(
vanilla,
&merged,
&filters,
&mut files,
&mut hidden,
dependencies,
);
let touched_dirs: BTreeSet<String> = files
.keys()
.flat_map(|f| {
let parts: Vec<&str> = f.split('/').collect();
(1..parts.len()).map(move |n| parts[..n].join("/"))
})
.collect();
/// A zip entry to write: its pack, its name in the zip and where it goes.
type Extract<'a> = (usize, &'a str, PathBuf);
#[allow(clippy::too_many_arguments)]
@@ -1043,123 +1032,6 @@ fn build_overlay_uncached(
}
extracted
}
/// `SingleFile` with a `sprite`: the atlas holds the resource's pixels, and the resource's
/// `.mcmeta`, under the sprite's id. Every other sprite of the blocks and items atlases is its
/// texture's own id, and the loaders read a model's sprite from `textures/<sprite>.png`, so the
/// overlay gives a renamed sprite that file. ItemsAdder and Nexo packs name their models'
/// textures only through these renames (#14485).
///
/// A sprite gets its resource's file only when the whole source list, in order, ends on the
/// rename (`SpriteSourceList.list`: a later source of the same id replaces it), when the other
/// atlas, which reads the same file, lists it from that resource too or not at all, and when it
/// has no texture of its own. A texture that is there may be read as itself (another source's
/// resource, an unstitched or paletted sprite's input), so no alias ever replaces one; such a
/// sprite draws its own texture, as before aliases.
fn add_sprite_renames(
vanilla: &Path,
merged: &BTreeMap<String, Vec<u8>>,
filters: &[&Filter],
files: &mut BTreeMap<String, (usize, Provided)>,
hidden: &mut BTreeSet<String>,
dependencies: &mut cache::Dependencies,
) {
use crate::atlas_sources::{
SpriteOrigin, SpriteSource, decode, list, list_textures, texture_path,
};
let png = |id: &str| {
texture_path(Path::new(""), id, "textures")
.to_string_lossy()
.replace('\\', "/")
};
let atlases: Vec<_> = ["blocks", "items"]
.into_iter()
.filter_map(|atlas| merged.get(&format!("assets/minecraft/atlases/{atlas}.json")))
.filter_map(|bytes| decode(std::str::from_utf8(bytes).ok()?))
.collect();
let renamed = |source: &SpriteSource| matches!(source, SpriteSource::Single { resource, sprite: Some(sprite) } if sprite != resource);
if !atlases.iter().flatten().any(renamed) {
return;
}
// The textures a source can list: the packs' and vanilla's the packs leave visible.
let mut textures: Vec<String> = list_textures(vanilla)
.into_iter()
.filter(|id| {
let name = png(id);
!hidden.contains(&name) && !filters.iter().any(|f| f.blocks(&name))
})
.chain(files.keys().filter_map(|name| {
let (namespace, rest) = name.strip_prefix("assets/")?.split_once('/')?;
let path = rest.strip_prefix("textures/")?.strip_suffix(".png")?;
Some(format!("{namespace}:{path}"))
}))
.collect();
textures.sort();
textures.dedup();
// Each sprite's resource, `None` where an atlas lists it any other way.
let mut renames: BTreeMap<String, Option<String>> = BTreeMap::new();
for sources in &atlases {
for (sprite, origin) in list(sources, &textures) {
let from = match origin {
SpriteOrigin::File(resource) if resource != sprite => Some(resource),
_ => None,
};
renames
.entry(sprite)
.and_modify(|seen| {
if *seen != from {
*seen = None;
}
})
.or_insert(from);
}
}
let renames = renames
.into_iter()
.filter(|(sprite, _)| textures.binary_search(sprite).is_err())
.filter_map(|(sprite, resource)| Some((png(&resource?), png(&sprite))));
// Each resource is the file the packs give, never another rename's sprite.
let mut found = Vec::new();
for (from, to) in renames {
// `texture_path` names an id that would leave the root as a file that is never there.
if !stays_inside(&from) || !stays_inside(&to) {
continue;
}
let from_meta = format!("{from}.mcmeta");
// A pack's texture with its own `.mcmeta`, or vanilla's below every pack.
let (file, meta) = match files.get(&from) {
Some(file) => (file.clone(), files.get(&from_meta).cloned()),
None => {
let (path, meta_path) = (vanilla.join(&from), vanilla.join(&from_meta));
dependencies.file(&path);
dependencies.file(&meta_path);
if !path.is_file() || filters.iter().any(|f| f.blocks(&from)) {
continue;
}
// `createStackMetadataFinder`: a pack's filter hides vanilla's `.mcmeta` too.
let meta = files.get(&from_meta).cloned().or_else(|| {
(meta_path.is_file() && !filters.iter().any(|f| f.blocks(&from_meta)))
.then(|| (0, (Some(meta_path), from_meta.clone())))
});
((0, (Some(path), from.clone())), meta)
}
};
found.push((to, file, meta));
}
for (to, file, meta) in found {
let to_meta = format!("{to}.mcmeta");
files.insert(to, file);
match meta {
Some(meta) => {
hidden.remove(&to_meta);
files.insert(to_meta, meta);
}
None => {
files.remove(&to_meta);
hidden.insert(to_meta);
}
}
}
}
/// The most the zip packs in `packs` may unpack to in one overlay: 16 times their size on disk
/// (real packs are mostly PNGs and JSON, a few times smaller zipped at most), at least 64 MiB
/// for a small pack of text, and never past 8 GiB.
@@ -2034,94 +1906,6 @@ mod tests {
let _ = fs::remove_dir_all(base);
}
#[test]
fn a_renamed_atlas_sprite_reads_its_resource() {
let base =
std::env::temp_dir().join(format!("horizon-sprite-rename-{}", std::process::id()));
let _ = fs::remove_dir_all(&base);
let write = |path: PathBuf, bytes: &[u8]| {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, bytes).unwrap();
};
let vanilla = base.join("vanilla");
let textures = vanilla.join("assets/minecraft/textures");
let animation = b"{\"animation\":{}}";
write(textures.join("block/sea_lantern.png"), b"lantern");
write(textures.join("block/sea_lantern.png.mcmeta"), animation);
write(textures.join("item/stick.png"), b"vanilla stick");
write(textures.join("item/stick.png.mcmeta"), animation);
let meta = br#"{"pack":{"description":"d","pack_format":97}}"#;
let pack = base.join("packs/IA");
write(pack.join("pack.mcmeta"), meta);
write(pack.join("assets/iageneric/textures/item/box.png"), b"box");
// An ItemsAdder pack's items atlas: its models name only the `ia:` sprites.
write(
pack.join("assets/minecraft/atlases/items.json"),
br#"{"sources":[
{"type":"single","resource":"iageneric:item/box","sprite":"ia:0"},
{"type":"single","resource":"minecraft:block/sea_lantern","sprite":"ia:1199"},
{"type":"single","resource":"iageneric:item/box","sprite":"minecraft:item/stick"},
{"type":"single","resource":"minecraft:item/stick","sprite":"ia:3"},
{"type":"single","resource":"iageneric:item/gone","sprite":"ia:2"},
{"type":"single","resource":"iageneric:item/box","sprite":"ia:../../../escape"},
{"type":"single","resource":"iageneric:item/box","sprite":"ia:7"},
{"type":"single","resource":"iageneric:item/box","sprite":"ia:8"},
{"type":"single","resource":"iageneric:item/box","sprite":"ia:9"}
]}"#,
);
// A pack above it: later sources of three of those sprites, a blocks atlas that
// disagrees about another, and a filter hiding vanilla's animation of the stick.
let higher = base.join("packs/Top");
write(
higher.join("pack.mcmeta"),
br#"{"pack":{"description":"d","pack_format":97},
"filter":{"block":[{"path":"textures/item/stick\\.png\\.mcmeta"}]}}"#,
);
write(
higher.join("assets/minecraft/atlases/items.json"),
br#"{"sources":[
{"type":"single","resource":"minecraft:block/sea_lantern","sprite":"ia:8"},
{"type":"filter","pattern":{"namespace":"ia","path":"7"}}
]}"#,
);
write(
higher.join("assets/minecraft/atlases/blocks.json"),
br#"{"sources":[
{"type":"single","resource":"minecraft:block/sea_lantern","sprite":"ia:9"}
]}"#,
);
let packs = discover(&vanilla, &base.join("packs"));
let out = base.join("overlay");
build_overlay(&vanilla, &packs, &out).unwrap();
let read = |rel: &str| fs::read(out.join(rel)).ok();
let texture = |id: &str| read(&format!("assets/ia/textures/{id}.png"));
assert_eq!(texture("0").as_deref(), Some(&b"box"[..]));
assert_eq!(read("assets/ia/textures/0.png.mcmeta"), None);
// A vanilla resource comes with its animation.
assert_eq!(texture("1199").as_deref(), Some(&b"lantern"[..]));
assert_eq!(
read("assets/ia/textures/1199.png.mcmeta").as_deref(),
Some(&animation[..])
);
// A texture that is there stays itself: other sources may read it (here as `ia:3`'s
// resource; an unstitched or paletted sprite's input alike).
assert_eq!(
read("assets/minecraft/textures/item/stick.png").as_deref(),
Some(&b"vanilla stick"[..])
);
// ...with its `.mcmeta` only where no filter hides it.
assert_eq!(texture("3").as_deref(), Some(&b"vanilla stick"[..]));
assert_eq!(read("assets/ia/textures/3.png.mcmeta"), None);
// A missing resource adds nothing, and no rename leaves the overlay.
assert_eq!(texture("2"), None);
assert!(!out.join("escape.png").exists());
// The last source of a sprite wins: a filter drops it, a later rename replaces it.
assert_eq!(texture("7"), None);
assert_eq!(texture("8").as_deref(), Some(&b"lantern"[..]));
// The two atlases read one file, and disagree about `ia:9`: it gets neither.
assert_eq!(texture("9"), None);
let _ = fs::remove_dir_all(base);
}
#[test]
fn every_file_of_a_big_stack_comes_from_its_top_pack() {
use std::io::Write as _;
let base = std::env::temp_dir().join(format!("horizon-pack-stack-{}", std::process::id()));
@@ -13,8 +13,7 @@ use std::{collections::BTreeSet, fs, io, path::Path, path::PathBuf};
// Version 5 read every vanilla file to stamp it on Windows.
// Version 6 stamps vanilla's files by their metadata on Windows too.
// Version 7 stacks blockstate files (every pack's definition, bottom first).
// Version 8 left an atlas `single` source's renamed sprite without its texture file (#14485).
const VERSION: u32 = 9;
const VERSION: u32 = 8;
#[derive(Default, Serialize, Deserialize)]
pub(super) struct Dependencies {
+105 -119
View File
@@ -1402,8 +1402,100 @@ impl ParticleLightCache {
}
}
mod view_cull;
use view_cull::{PARTICLE_FRUSTUM_OFFSET, ParticleSectionFrustumCache, SectionFrustumResult};
const PARTICLE_SECTION_CACHE_SIZE: usize = 128;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SectionFrustumResult {
Outside,
Intersects,
Inside,
}
#[derive(Clone, Copy)]
struct CachedSectionFrustum {
section: [i32; 3],
radius_quarters: i32,
result: SectionFrustumResult,
}
struct ParticleSectionFrustumCache {
entries: [Option<CachedSectionFrustum>; PARTICLE_SECTION_CACHE_SIZE],
}
impl Default for ParticleSectionFrustumCache {
fn default() -> Self {
Self {
entries: [None; PARTICLE_SECTION_CACHE_SIZE],
}
}
}
impl ParticleSectionFrustumCache {
fn result(
&mut self,
world: [f64; 3],
camera: DVec3,
radius: f64,
frustum: &crate::gfx::Frustum,
) -> SectionFrustumResult {
let Some(section) = world_section(world) else {
return SectionFrustumResult::Intersects;
};
if !radius.is_finite() || radius < 0.0 || radius * 4.0 > f64::from(i32::MAX) {
return SectionFrustumResult::Intersects;
}
let radius_quarters = (radius * 4.0).ceil() as i32;
let hash = (section[0] as u32).wrapping_mul(73_856_093)
^ (section[1] as u32).wrapping_mul(19_349_663)
^ (section[2] as u32).wrapping_mul(83_492_791)
^ (radius_quarters as u32).wrapping_mul(2_654_435_761);
let slot = hash as usize & (PARTICLE_SECTION_CACHE_SIZE - 1);
if let Some(entry) = self.entries[slot]
&& entry.section == section
&& entry.radius_quarters == radius_quarters
{
return entry.result;
}
let pad = f64::from(radius_quarters) * 0.25;
let camera = camera.to_array();
let min = array::from_fn(|axis| f64::from(section[axis]) * 16.0 - pad - camera[axis]);
let max = array::from_fn(|axis| min[axis] + 16.0 + 2.0 * pad);
let result = if min.into_iter().chain(max).any(|v| !v.is_finite()) {
SectionFrustumResult::Intersects
} else {
let min = FVec3::from_array(min.map(|v| v as f32));
let max = FVec3::from_array(max.map(|v| v as f32));
if !min.is_finite() || !max.is_finite() {
SectionFrustumResult::Intersects
} else if frustum.contains(min, max) {
SectionFrustumResult::Inside
} else if frustum.intersects(min, max) {
SectionFrustumResult::Intersects
} else {
SectionFrustumResult::Outside
}
};
self.entries[slot] = Some(CachedSectionFrustum {
section,
radius_quarters,
result,
});
result
}
}
fn world_section(position: [f64; 3]) -> Option<[i32; 3]> {
let mut block = [0; 3];
for axis in 0..3 {
let value = position[axis].floor();
if !value.is_finite() || value < f64::from(i32::MIN) || value > f64::from(i32::MAX) {
return None;
}
block[axis] = value as i32;
}
Some([block[0] >> 4, block[1] >> 4, block[2] >> 4])
}
fn below_half_pixel(radius: f64, distance_squared: f64, pixel_focal_length: f32) -> bool {
let projected_limit = 4.0 * radius * f64::from(pixel_focal_length);
@@ -4611,7 +4703,16 @@ impl ParticleEngine {
light_at: &dyn Fn([i32; 3]) -> u8,
out: &mut Vec<Vertex>,
) {
self.render_in_view(camera, base, partial, None, light_at, out);
self.render_inner::<false>(
camera,
base,
partial,
0.0,
None,
&mut |_, _| false,
light_at,
out,
);
}
/// Render particles after rejecting view-frustum misses and any particles
@@ -4629,7 +4730,7 @@ impl ParticleEngine {
out: &mut Vec<Vertex>,
) {
if !culling_enabled {
self.render_in_view(camera, base, partial, frustum, light_at, out);
self.render(camera, base, partial, light_at, out);
return;
}
self.render_inner::<true>(
@@ -4660,16 +4761,10 @@ impl ParticleEngine {
additive.clear();
let mut section_cache =
(CULL && frustum.is_some()).then(ParticleSectionFrustumCache::default);
let view = frustum.map(|f| f.pulled_back(PARTICLE_FRUSTUM_OFFSET));
for p in &self.particles {
if p.no_render {
continue;
}
if let Some(view) = &view
&& !view_cull::in_view(view, p, camera)
{
continue;
}
// SparkParticle.extract: twinkling sparks blink after a third of their life.
if let Behaviour::Spark { twinkle: true, .. } = p.behaviour
&& p.age >= p.lifetime / 3
@@ -5104,115 +5199,6 @@ mod tests {
assert_eq!(attributes(&disabled), attributes(&regular));
}
/// An engine whose smoke particles stand at `positions`, camera-relative (the camera is at
/// the origin looking down -z, like `Mat4::IDENTITY` as a view).
fn smoke_at(positions: &[[f64; 3]]) -> ParticleEngine {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
let sprite = "test:smoke".to_owned();
engine.atlas.insert(
sprite.clone(),
(
Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
},
false,
),
);
let mut random = LegacyRandom::new(7);
for &at in positions {
let mut particle = Particle::moving(&mut random, ParticleType::Smoke, at, [0.0; 3]);
particle.sprite = Sprite::Named(sprite.clone());
engine.particles.push(particle);
}
engine
}
fn quad_vertices(engine: &ParticleEngine, view: Option<&crate::gfx::Frustum>) -> usize {
let mut out = Vec::new();
engine.render_in_view(DVec3::ZERO, Quat::IDENTITY, 0.5, view, &|_| 0xf0, &mut out);
out.len()
}
#[test]
fn particles_outside_the_pulled_back_frustum_make_no_quads() {
use crate::gfx::{Frustum, world_projection};
let view = Frustum::from_view_projection(world_projection(70.0, 16.0 / 9.0, 512.0));
let kept = [
[0.0, 0.0, -5.0], // straight ahead
[0.0, 0.0, 2.5], // behind the camera, inside the 3 blocks of offset
[0.0, 0.0, -300.0], // far, inside the far plane
];
let dropped = [
[0.0, 0.0, 10.0], // well behind the camera
[0.0, 0.0, 3.5], // just past the offset
[100.0, 0.0, -5.0], // far to the side
[0.0, 60.0, -5.0], // far above
[0.0, 0.0, -600.0], // beyond the far plane
];
let all: Vec<_> = kept.iter().chain(&dropped).copied().collect();
let engine = smoke_at(&all);
let only_kept = quad_vertices(&smoke_at(&kept), None);
assert!(only_kept > 0);
assert_eq!(quad_vertices(&engine, Some(&view)), only_kept);
// Without a view every particle is drawn, as before.
assert_eq!(
quad_vertices(&engine, None),
only_kept * all.len() / kept.len()
);
// `render` is the same as no view.
let mut regular = Vec::new();
engine.render(DVec3::ZERO, Quat::IDENTITY, 0.5, &|_| 0xf0, &mut regular);
assert_eq!(regular.len(), quad_vertices(&engine, None));
}
#[test]
fn the_culled_path_applies_the_same_frustum_test() {
use crate::gfx::{Frustum, world_projection};
let view = Frustum::from_view_projection(world_projection(70.0, 16.0 / 9.0, 512.0));
let engine = smoke_at(&[[0.0, 0.0, -5.0], [0.0, 0.0, 10.0]]);
let mut out = Vec::new();
// The disabled setting still keeps vanilla's always-on test.
engine.render_culled(
DVec3::ZERO,
Quat::IDENTITY,
0.5,
900.0,
false,
Some(&view),
|_, _| panic!("disabled culling must not query particle visibility"),
&|_| 0xf0,
&mut out,
);
assert_eq!(
out.len(),
quad_vertices(&smoke_at(&[[0.0, 0.0, -5.0]]), None)
);
}
#[test]
fn a_particle_is_tested_at_its_position_this_tick_not_an_interpolated_one() {
use crate::gfx::{Frustum, world_projection};
let view = Frustum::from_view_projection(world_projection(70.0, 16.0 / 9.0, 512.0));
// The frame is drawn half way (0.5) between `previous` and `position`.
// Ahead now, far behind a tick ago (half way: 12.5 behind, outside): kept.
let mut coming = smoke_at(&[[0.0, 0.0, -5.0]]);
coming.particles[0].previous = [0.0, 0.0, 30.0];
assert!(quad_vertices(&coming, None) > 0);
assert_eq!(
quad_vertices(&coming, Some(&view)),
quad_vertices(&coming, None)
);
// Behind now, ahead a tick ago (half way: 5 ahead, inside): dropped.
let mut going = smoke_at(&[[0.0, 0.0, 10.0]]);
going.particles[0].previous = [0.0, 0.0, -20.0];
assert_eq!(quad_vertices(&going, Some(&view)), 0);
}
#[test]
fn quads_face_the_rotation_they_are_given() {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
@@ -1,138 +0,0 @@
//! The camera's view frustum for particles: vanilla's always-on point test, and the section
//! cache behind the opt-in Particle Culling setting.
use super::{Particle, ParticleEngine, Vertex};
use glam::{DVec3, Quat, Vec3 as FVec3};
use std::array;
const PARTICLE_SECTION_CACHE_SIZE: usize = 128;
/// How far back, in blocks, the camera's frustum is pulled for the particle test (vanilla's
/// `Frustum.offset` in `ParticleEngine.extract`).
pub(super) const PARTICLE_FRUSTUM_OFFSET: f32 = 3.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum SectionFrustumResult {
Outside,
Intersects,
Inside,
}
#[derive(Clone, Copy)]
struct CachedSectionFrustum {
section: [i32; 3],
radius_quarters: i32,
result: SectionFrustumResult,
}
pub(super) struct ParticleSectionFrustumCache {
entries: [Option<CachedSectionFrustum>; PARTICLE_SECTION_CACHE_SIZE],
}
impl Default for ParticleSectionFrustumCache {
fn default() -> Self {
Self {
entries: [None; PARTICLE_SECTION_CACHE_SIZE],
}
}
}
impl ParticleSectionFrustumCache {
pub(super) fn result(
&mut self,
world: [f64; 3],
camera: DVec3,
radius: f64,
frustum: &crate::gfx::Frustum,
) -> SectionFrustumResult {
let Some(section) = world_section(world) else {
return SectionFrustumResult::Intersects;
};
if !radius.is_finite() || radius < 0.0 || radius * 4.0 > f64::from(i32::MAX) {
return SectionFrustumResult::Intersects;
}
let radius_quarters = (radius * 4.0).ceil() as i32;
let hash = (section[0] as u32).wrapping_mul(73_856_093)
^ (section[1] as u32).wrapping_mul(19_349_663)
^ (section[2] as u32).wrapping_mul(83_492_791)
^ (radius_quarters as u32).wrapping_mul(2_654_435_761);
let slot = hash as usize & (PARTICLE_SECTION_CACHE_SIZE - 1);
if let Some(entry) = self.entries[slot]
&& entry.section == section
&& entry.radius_quarters == radius_quarters
{
return entry.result;
}
let pad = f64::from(radius_quarters) * 0.25;
let camera = camera.to_array();
let min = array::from_fn(|axis| f64::from(section[axis]) * 16.0 - pad - camera[axis]);
let max = array::from_fn(|axis| min[axis] + 16.0 + 2.0 * pad);
let result = if min.into_iter().chain(max).any(|v| !v.is_finite()) {
SectionFrustumResult::Intersects
} else {
let min = FVec3::from_array(min.map(|v| v as f32));
let max = FVec3::from_array(max.map(|v| v as f32));
if !min.is_finite() || !max.is_finite() {
SectionFrustumResult::Intersects
} else if frustum.contains(min, max) {
SectionFrustumResult::Inside
} else if frustum.intersects(min, max) {
SectionFrustumResult::Intersects
} else {
SectionFrustumResult::Outside
}
};
self.entries[slot] = Some(CachedSectionFrustum {
section,
radius_quarters,
result,
});
result
}
}
fn world_section(position: [f64; 3]) -> Option<[i32; 3]> {
let mut block = [0; 3];
for axis in 0..3 {
let value = position[axis].floor();
if !value.is_finite() || value < f64::from(i32::MIN) || value > f64::from(i32::MAX) {
return None;
}
block[axis] = value as i32;
}
Some([block[0] >> 4, block[1] >> 4, block[2] >> 4])
}
/// Whether `QuadParticleGroup`'s test keeps `particle`: its position this tick (camera-relative, not
/// interpolated) is on or inside `view`, the camera's frustum pulled back [`PARTICLE_FRUSTUM_OFFSET`] blocks.
pub(super) fn in_view(view: &crate::gfx::Frustum, particle: &Particle, camera: DVec3) -> bool {
let at = DVec3::from_array(particle.position) - camera;
view.contains_point(at.as_vec3())
}
impl ParticleEngine {
/// [`Self::render`], keeping only the particles inside `view`, the camera's frustum pulled
/// back [`PARTICLE_FRUSTUM_OFFSET`] blocks (vanilla's `ParticleEngine.extract` and
/// `QuadParticleGroup`): the test runs whatever the Particle Culling setting says. With
/// `None`, every particle is drawn.
pub fn render_in_view(
&self,
camera: DVec3,
base: Quat,
partial: f32,
view: Option<&crate::gfx::Frustum>,
light_at: &dyn Fn([i32; 3]) -> u8,
out: &mut Vec<Vertex>,
) {
self.render_inner::<false>(
camera,
base,
partial,
0.0,
view,
&mut |_, _| false,
light_at,
out,
);
}
}
@@ -222,6 +222,11 @@ impl<P: Copy, S: Copy> Lines<P, S> {
}
self.y += style::GROUP_GAP;
}
/// A group of rows under its page's own name (`list`'s `page_name`), for pages named at run
/// time, such as a resource pack's config entries (`pack_options_ui`).
pub(crate) fn titled(&mut self, page: P, rows: Vec<S>) {
self.group(page, "", rows);
}
/// The lines and the content's height.
pub(crate) fn finish(self) -> (Vec<Line<P, S>>, f32) {
(self.lines, (self.y - style::GROUP_GAP).max(0.0))
@@ -347,7 +352,8 @@ pub(crate) fn plate(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, head
}
/// The list, clipped to its view: each group's panel, then its names and rows on it. `row`
/// draws a setting at its `y`; `page_name` names a page, in the player's language.
/// draws a setting at its `y`; `page_name` names a page, and a group without a name of its own
/// (`Lines::titled`), in the player's language.
pub(crate) fn list<P: Copy, S: Copy>(
batch: &mut HudBatch,
atlas: &HudAtlas,
@@ -380,6 +386,7 @@ pub(crate) fn list<P: Copy, S: Copy>(
}
let Some(setting) = line.setting else {
let (name, color, dy) = match line.group {
Some(("", _)) => (page_name(line.page), style::muted(), 3.0),
Some((name, _)) => (lang::get(name), style::muted(), 3.0),
None => (page_name(line.page), style::accent(), 4.0),
};
@@ -945,10 +952,22 @@ pub(crate) fn dock_note(
}
}
/// Done, Undo or Apply at the foot of the page list, registered for a controller. Its label
/// leaves `room` at its left for a controller's glyph: a button that declared its job gets one,
/// as vanilla's do (`ui::action_button`), which a half-width Undo or Apply needs.
/// Done or Undo at the foot of the page list.
pub(crate) fn foot_button(
batch: &mut HudBatch,
atlas: &HudAtlas,
rect: [f32; 4],
label: &Text,
cursor: (f32, f32),
flags: (bool, bool),
) {
foot_button_with_room(batch, atlas, rect, label, cursor, flags, 0.0);
}
/// A foot button whose label leaves `room` at its left for a controller's glyph: a button that
/// declared its job gets one, as vanilla's do (`ui::action_button`), which a half-width Undo or
/// Apply needs.
fn foot_button_with_room(
batch: &mut HudBatch,
atlas: &HudAtlas,
rect: [f32; 4],
@@ -2395,7 +2414,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
}
None => 0.0,
};
foot_button(batch, atlas, rect, &label, cursor, flags, room);
foot_button_with_room(batch, atlas, rect, &label, cursor, flags, room);
}
}
fn click(
+17 -11
View File
@@ -93,17 +93,18 @@ pub fn unpack(value: &str, signature: Signature, valid_uris: &[impl AsRef<str>])
signed: signature == Signature::Signed,
..empty.clone()
};
// Authlib's MinecraftServicesSessionService validates every URL in
// unpackTextures before returning any textures. A bad cape rejects the skin too.
// Each texture is loaded independently: an invalid or disallowed URL for one
// texture (cape, elytra) must not drop another (the skin). Vanilla's
// SkinManager works the same way.
for (kind, texture) in textures {
let Some(url) = texture.get("url").and_then(|u| u.as_str()) else {
return empty;
continue;
};
let allowed = valid_uris
.iter()
.any(|u| url.starts_with(&u.as_ref().replace("{textureId}", "")));
if !allowed {
return empty;
continue;
}
if kind == "SKIN" {
out.slim = texture
@@ -1387,9 +1388,10 @@ mod tests {
&super::TEXTURE_URIS,
);
assert_eq!(textures.slim, slim && !url.contains("evil"));
// Authlib rejects the whole payload if any URL is invalid,
// including the allowed cape when the skin is disallowed.
assert_eq!(textures.cape.is_some(), !url.contains("evil"));
// Textures are judged per URL (vanilla's SkinManager): the
// cape's URL is always allowed here, so it is kept even when
// the skin's URL is not.
assert!(textures.cape.is_some());
assert_eq!(parsed.skin.is_some(), !url.contains("evil"));
assert!(std::sync::Arc::ptr_eq(&parsed, &cache.get(&profile)));
}
@@ -1542,14 +1544,18 @@ mod tests {
assert!(t.slim);
assert!(!unpack(wide, Signature::Signed, &TEXTURE_URIS).slim);
}
/// Authlib's `MinecraftServicesSessionService` rejects the whole profile in
/// `unpackTextures` when any texture URL is outside the allowed origins.
/// `unpack` keeps each texture independently: a cape from another origin
/// must not drop the skin.
#[test]
fn unpack_rejects_the_skin_when_another_texture_is_rejected() {
fn unpack_keeps_the_skin_when_another_texture_is_rejected() {
use super::{Signature, TEXTURE_URIS, unpack};
let mixed = "eyJ0ZXh0dXJlcyI6IHsiU0tJTiI6IHsidXJsIjogImh0dHBzOi8vdGV4dHVyZXMubWluZWNyYWZ0Lm5ldC90ZXh0dXJlL3NraW4xIn0sICJDQVBFIjogeyJ1cmwiOiAiaHR0cHM6Ly9ldmlsLmV4YW1wbGUvdGV4dHVyZS9jYXBlMSJ9fX0=";
let t = unpack(mixed, Signature::Unsigned, &TEXTURE_URIS);
assert_eq!(t, super::Textures::default());
assert_eq!(
t.skin.as_deref(),
Some("https://textures.minecraft.net/texture/skin1")
);
assert_eq!(t.cape, None);
}
#[test]
fn profiles_parse_like_resolvable_profiles() {
+4 -4
View File
@@ -478,9 +478,9 @@ impl Sprites {
let metadata = match horizon_core::prepared_files::read_to_string(
&path.with_extension("png.mcmeta"),
) {
Ok(text) => Some(crate::packs::first_json::<
Ok(text) => Some(serde_json::from_str::<
BTreeMap<String, Box<serde_json::value::RawValue>>,
>(text.as_bytes())?),
>(&text)?),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => return Err(error.into()),
};
@@ -589,9 +589,9 @@ impl Sprites {
let metadata = match horizon_core::prepared_files::read_to_string(
&path.with_extension("png.mcmeta"),
) {
Ok(text) => Some(crate::packs::first_json::<
Ok(text) => Some(serde_json::from_str::<
BTreeMap<String, Box<serde_json::value::RawValue>>,
>(text.as_bytes())?),
>(&text)?),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => return Err(error.into()),
};
@@ -1438,7 +1438,7 @@ impl Streaming {
let entities = std::mem::take(&mut chunk.entities);
world.entities_pending.insert((chunk.x, chunk.z));
if !entities.is_empty() {
world.queue_entity_nbt((chunk.x, chunk.z), entities);
world.loaded_entities.push(((chunk.x, chunk.z), entities));
}
self.generated_total += 1;
// A chunk saved with its light needs no light job; its unlit
+1 -23
View File
@@ -104,7 +104,7 @@ pub fn read_metadata(root: &Path) -> UiMetadata {
let Ok(text) = horizon_core::prepared_files::read_to_string(&p) else {
continue;
};
let Some(nine_slice) = crate::packs::first_json(text.as_bytes())
let Some(nine_slice) = serde_json::from_str(&text)
.ok()
.as_ref()
.and_then(parse_nine_slice)
@@ -2025,28 +2025,6 @@ impl EditBox {
mod tests {
use super::*;
#[test]
fn notes_after_a_sprites_metadata_keep_its_nine_slice() {
// Gson stops reading after the first value, so vanilla keeps this nine slice
// (CozyUI+'s highlighted button, #3046).
let root = std::env::temp_dir().join(format!("horizon-ui-meta-{}", std::process::id()));
let widget = root.join("assets/minecraft/textures/gui/sprites/widget");
std::fs::create_dir_all(&widget).unwrap();
std::fs::write(
widget.join("button_highlighted.png.mcmeta"),
"{\"gui\": {\"scaling\": {\"type\": \"nine_slice\", \"width\": 80, \"height\": 20,\n\
\"border\": {\"left\": 34, \"top\": 8, \"right\": 34, \"bottom\": 8}}}}\n\n// 0 10\n// 1 8\n",
)
.unwrap();
let metadata = read_metadata(&root);
std::fs::remove_dir_all(&root).unwrap();
let slice = metadata.0["gui/sprites/widget/button_highlighted"];
assert_eq!(
(slice.width, slice.height, slice.border),
(80, 20, [34, 8, 34, 8])
);
}
/// The quads' destination rectangles and their UVs.
fn quads(batch: &HudBatch) -> Vec<([f32; 4], [f32; 4])> {
batch
+1 -10
View File
@@ -9,9 +9,6 @@ use horizon_core::random::{LegacyRandom, LevelRandom};
use horizon_world::registry::BlockStateId;
use std::sync::Arc;
mod pending_entity;
pub use pending_entity::{EntityLoadReason, PendingEntity};
mod modified;
pub use modified::ModifiedChunks;
@@ -84,7 +81,7 @@ pub struct World {
/// incrementally by streaming.
pub light_edits: Vec<((i32, i32, i32), u32, u32)>,
/// Saved entities of chunks that just loaded, spawned by the game.
pub loaded_entities: Vec<((i32, i32), Vec<PendingEntity>)>,
pub loaded_entities: Vec<((i32, i32), Vec<horizon_world::nbt::Tag>)>,
/// Chunks loaded since the level's last pass, their entity sections not
/// ready yet (`PersistentEntitySectionManager`'s PENDING), empty ones too:
/// the pass ends by readying them (`processPendingLoads`).
@@ -218,12 +215,6 @@ impl Weather {
}
}
impl World {
/// Queue ordinary NBT; transient transfer state is never decoded from it.
pub fn queue_entity_nbt(&mut self, key: (i32, i32), tags: Vec<horizon_world::nbt::Tag>) {
self.loaded_entities
.push((key, tags.into_iter().map(PendingEntity::from).collect()));
}
pub fn new(air: BlockStateId, seed: i64) -> Self {
Self {
shape: LevelShape::OVERWORLD,
@@ -1,89 +0,0 @@
//! Pending entity NBT and the runtime context that must not be stored inside it.
use horizon_world::nbt::Tag;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EntityLoadReason {
Ordinary,
TeleportedTnt,
}
#[derive(Clone, Debug)]
pub struct PendingEntity {
tag: Tag,
reason: EntityLoadReason,
}
impl From<Tag> for PendingEntity {
fn from(tag: Tag) -> Self {
Self {
tag,
reason: EntityLoadReason::Ordinary,
}
}
}
impl PendingEntity {
/// Only an in-memory TNT transfer supplies this context, never saved NBT.
pub fn after_tnt_portal(tag: Tag) -> Self {
Self {
tag,
reason: EntityLoadReason::TeleportedTnt,
}
}
pub fn into_parts(self) -> (Tag, EntityLoadReason) {
(self.tag, self.reason)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ordinary_nbt_cannot_request_transient_portal_state() {
for marker in [0, 1] {
let tag = Tag::compound([("horizon:tnt_used_portal", Tag::Byte(marker))]);
let (loaded, reason) = PendingEntity::from(tag.clone()).into_parts();
assert_eq!(loaded, tag);
assert_eq!(reason, EntityLoadReason::Ordinary);
}
}
#[test]
fn queue_swaps_and_appends_keep_each_entitys_context_and_order() {
let ordinary = Tag::compound([("id", Tag::String("ordinary".into()))]);
let transfer = Tag::compound([("id", Tag::String("transfer".into()))]);
let mut batches = vec![(
(1, 2),
vec![
ordinary.clone().into(),
PendingEntity::after_tnt_portal(transfer.clone()),
],
)];
let mut waiting = std::mem::take(&mut batches);
batches.push(((3, 4), vec![ordinary.clone().into()]));
waiting.append(&mut batches);
assert!(batches.is_empty());
let mut batches = waiting.into_iter();
let (key, entities) = batches.next().unwrap();
assert_eq!(key, (1, 2));
let mut entities = entities.into_iter();
assert_eq!(
entities.next().unwrap().into_parts(),
(ordinary.clone(), EntityLoadReason::Ordinary)
);
assert_eq!(
entities.next().unwrap().into_parts(),
(transfer, EntityLoadReason::TeleportedTnt)
);
assert!(entities.next().is_none());
let (key, entities) = batches.next().unwrap();
assert_eq!(key, (3, 4));
assert_eq!(
entities.into_iter().next().unwrap().into_parts(),
(ordinary, EntityLoadReason::Ordinary)
);
assert!(batches.next().is_none());
}
}
+2 -10
View File
@@ -1,6 +1,5 @@
[package]
name = "horizon-client"
default-run = "horizon-client"
version = "0.1.0"
edition.workspace = true
license.workspace = true
@@ -146,18 +145,10 @@ windows-mimalloc = []
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
windows-segment-heap = []
[[bin]]
name = "horizon-client"
path = "src/main.rs"
[[test]]
name = "datapack_biome_sync"
path = "tests/datapack_biome_sync.rs"
[[test]]
name = "launch_api"
path = "tests/launch_api.rs"
[[test]]
name = "mod_api_hooks"
path = "tests/mod_api_hooks.rs"
@@ -172,7 +163,8 @@ path = "examples/wind_burst_hook_probe.rs"
[build-dependencies]
horizon-mod-api-gen = { path = "../horizon-mod-api-gen" }
# The build script runs on the host, but embeds resources based on the target OS.
# The build script runs on the host, but embeds resources based on the target OS (for the
# launcher's native-mod game package, which uses this build script).
winresource = "0.1"
[lints]
@@ -316,7 +316,6 @@
"horizon.multiplayer.invite_code": "Invite Code",
"horizon.multiplayer.relay": "Relay (optional)",
"Preset": "Preset",
"Horizon Preset": "Horizon Preset",
"LOD Distance": "LOD Distance",
"Far Detail": "Far Detail",
"Cave Culling": "Cave Culling",
@@ -708,9 +707,6 @@
"Pick a shader pack. Picking the one in use turns shaders off.": "Pick a shader pack. Picking the one in use turns shaders off.",
"Changed options apply with Apply, or when you leave.": "Changed options apply with Apply, or when you leave.",
"Pack settings": "Pack settings",
"Whole pack": "Whole pack",
"Reset all": "Reset all",
"Every option is at its default already.": "Every option is at its default already.",
"Built-in settings": "Built-in settings",
"%s settings": "%s settings",
"Settings made by the pack's author: they change only this pack.": "Settings made by the pack's author: they change only this pack.",
+6 -16
View File
@@ -1,5 +1,7 @@
//! Generates the native mod API, and gives the Windows executable Horizon's icon and its stack
//! (`packaging/windows/exe.rs`, shared with the official executable's build script).
//! Generates the native mod API. The launcher's native-mod game package (`horizon-native-game`,
//! which owns the `horizon-client` executable of a build with native mods) runs this build script
//! too: there it also gives the Windows executable Horizon's icon and its stack
//! (`packaging/windows/exe.rs`). horizon-client itself has no executable (horizon-game's).
#[path = "../../packaging/windows/exe.rs"]
mod windows_exe;
@@ -21,19 +23,7 @@ fn main() {
if !staged.is_empty() {
println!("cargo:rustc-env=HORIZON_MIXIN_MODS={}", staged.join(","));
}
// Whether a native mod can run in a mob's turn (`game/creatures/mob_islands.rs`). The
// launcher adds trait packages to the workspace's members.
println!(
"cargo:rerun-if-changed={}",
root.join("Cargo.toml").display()
);
for dir in ["native-api", "native-crates"] {
if root.join(dir).is_dir() {
println!("cargo:rerun-if-changed={}", root.join(dir).display());
}
if std::env::var("CARGO_PKG_NAME").as_deref() != Ok("horizon-client") {
windows_exe::resources("horizon-client");
}
if let Some(found) = horizon_mod_api_gen::mob_turn(&root) {
println!("cargo:rustc-env=HORIZON_MOB_TURN={found}");
}
windows_exe::resources("horizon-client");
}
@@ -318,8 +318,6 @@ pub fn main() -> horizon_client::Result<()> {
Ok(())
}
drops::Drop::Orb(v) => writeln!(out, "orb {v}"),
// The star's `setExtendedLifetime` is no part of the loot.
drops::Drop::Aged(s, _) => writeln!(out, "{}", line(&s)),
};
}
}
@@ -237,7 +237,7 @@ fn goal_name(m: &Mob, name: &str) -> String {
"RestrictSun" => "RestrictSunGoal",
"FleeSun" => "FleeSunGoal",
// `Cat.CatAvoidEntityGoal`, `Fox.FoxAvoidEntityGoal`... name their subclass.
"AvoidEntity" | "CatAvoid" if m.species() == Species::Cat => "CatAvoidEntityGoal",
"AvoidEntity" if m.species() == Species::Cat => "CatAvoidEntityGoal",
"AvoidEntity" => "AvoidEntityGoal",
"BowAttack" => "RangedBowAttackGoal",
"Swell" => "SwellGoal",
+14 -14
View File
@@ -1517,8 +1517,10 @@ fn predicate_references(
.unwrap_or("");
match &*full(kind) {
"minecraft:inverted" => predicate_references(&v["term"], w, active),
"minecraft:all_of" | "minecraft:any_of" => terms(v)
.iter()
"minecraft:all_of" | "minecraft:any_of" => v["terms"]
.as_array()
.into_iter()
.flatten()
.try_for_each(|v| predicate_references(v, w, active)),
_ => Ok(()),
}
@@ -1527,16 +1529,6 @@ fn predicate_references(
}
}
/// `terms` as `HolderSetCodec` reads it: a list, or one holder alone; a `#tag` is not expanded.
fn terms(v: &Value) -> &[Value] {
match &v["terms"] {
Value::Array(list) => list,
Value::Null => &[],
Value::String(tag) if tag.starts_with('#') => &[],
one => std::slice::from_ref(one),
}
}
/// The existing condition traversal with a caller-owned leaf context.
/// Leaves run synchronously, in order, with normal boolean short-circuiting.
pub fn conditions_with(
@@ -1555,8 +1547,16 @@ pub fn conditions_with(
.unwrap_or("");
match &*full(ty) {
"minecraft:inverted" => !conditions_with(&v["term"], ctx, test),
"minecraft:all_of" => terms(v).iter().all(|t| conditions_with(t, ctx, test)),
"minecraft:any_of" => terms(v).iter().any(|t| conditions_with(t, ctx, test)),
"minecraft:all_of" => v["terms"]
.as_array()
.into_iter()
.flatten()
.all(|t| conditions_with(t, ctx, test)),
"minecraft:any_of" => v["terms"]
.as_array()
.into_iter()
.flatten()
.any(|t| conditions_with(t, ctx, test)),
_ => test(v, ctx),
}
}
+1 -15
View File
@@ -71,10 +71,6 @@ pub fn trample_allowed(entity: i32, kind: &str, position: BlockPos, distance: f6
let (Some(dimension), Some(hook)) = (LEVEL.get(), TRAMPLE.get()) else {
return true;
};
// A grouped mob turn: no mod hooks it (`native_client_hooks::Deferred`).
if crate::native_client_hooks::deferring() {
return true;
}
let mut cancel = false;
let at = [position.x, position.y, position.z];
hook(dimension, entity, kind, at, distance, &mut cancel);
@@ -91,8 +87,7 @@ fn dispatch(event: Event<'_>, damage: &mut f32) {
let contact = match event {
Event::StepOn(c) | Event::Inside(c) | Event::FallOn(c, _) => c,
};
// A grouped mob turn reaches no mod's hook (`native_client_hooks::Deferred`).
if contact.block.starts_with("minecraft:") || crate::native_client_hooks::deferring() {
if contact.block.starts_with("minecraft:") {
return;
}
if let Some(dimension) = LEVEL.get()
@@ -218,15 +213,6 @@ mod tests {
let bounds = bounds.move_by(Vec3::new(-1.0, 0.0, 0.0));
swept_inside(17, c.kind, at, at, bounds, block);
assert_eq!(SEEN.get(), 8, "stationary overlap still notifies");
// A grouped mob turn, on any thread: no mod hears its contacts or tramples.
let ((), _) = crate::native_client_hooks::deferring_turn(|| {
emit(Event::StepOn(c));
assert_eq!(fall(c, 12.0, 10.0), 10.0);
let _ = TRAMPLE.set(|_, _, _, _, _, cancel| *cancel = true);
assert!(trample_allowed(17, c.kind, c.position, 12.0));
});
assert_eq!(SEEN.get(), 8, "a grouped turn reaches no contact hook");
assert!(!trample_allowed(17, c.kind, c.position, 12.0));
});
let _ =
std::panic::catch_unwind(|| in_level(Some("minecraft:the_nether"), || panic!("test")));
+7 -21
View File
@@ -123,32 +123,18 @@ pub fn spawn_egg_entity_data<'a>(
/// `EntityType.appendDefaultStackConfig` for a hanging entity or armor stand an
/// item places: `Entity.applyImplicitComponents` stores the item's
/// `minecraft:custom_name` as the entity's `CustomName` and its
/// `minecraft:custom_data` as its `data`, then `updateCustomEntityTag`
/// `minecraft:custom_data` as the entity's `data`, then `updateCustomEntityTag`
/// (`TypedEntityData.loadInto`) merges the item's `minecraft:entity_data` over
/// the entity's saved tag `own` when its type is `kind`.
pub fn stack_configured_entity(stack: &crate::items::ItemStack, kind: &str, own: &Tag) -> Tag {
stack_configured_entity_by(stack, kind, own, false)
}
/// [`stack_configured_entity`], `op` saying whether the user may load the
/// data of an op-only entity type (`EntityType.onlyOpCanSetNbt`).
pub fn stack_configured_entity_by(
stack: &crate::items::ItemStack,
kind: &str,
own: &Tag,
op: bool,
) -> Tag {
let mut tag = own.clone();
if let Tag::Compound(map) = &mut tag {
if let Some(name) = stack.data().and_then(|d| d.custom_name.clone()) {
map.insert("CustomName".into(), name);
}
if let Some(custom) = stack.data().and_then(|d| d.other("minecraft:custom_data")) {
map.insert("data".into(), custom.clone());
}
if let (Tag::Compound(map), Some(custom)) = (
&mut tag,
stack.data().and_then(|d| d.other("minecraft:custom_data")),
) {
map.insert("data".into(), custom.clone());
}
match spawn_egg_entity_data(stack, kind, op) {
match spawn_egg_entity_data(stack, kind, false) {
Some(data) => crate::data_cmd::merged(&tag, data),
None => tag,
}
-22
View File
@@ -75,12 +75,6 @@ fn grid_state(count: u32, mut x: i32, mut z: i32) -> u32 {
impl ChunkGenerator for DebugTerrain {
type Worker = ();
fn worker(&self) {}
/// DebugLevelSource's proto is already its decorated column. Keep a copy
/// for lighting a finished neighbour outside the kept area, matching the
/// vanilla FEATURES neighbour path.
fn light_view(&self, proto: &crate::streaming::Proto) -> Option<Chunk> {
proto.downcast_ref::<Chunk>().cloned()
}
/// `DebugLevelSource.buildTerrain` builds nothing; `applyBiomeDecoration`
/// writes the barriers and the grid.
fn proto(&self, _: &mut (), cx: i32, cz: i32) -> crate::streaming::Proto {
@@ -134,20 +128,4 @@ mod tests {
assert_eq!(grid_state(1000, 65, 1), 0);
assert_eq!(grid_state(1000, 61, 3), 30 * 32 + 1);
}
#[test]
fn light_view_keeps_a_debug_proto_column() {
let terrain = DebugTerrain {
shape: LevelShape::OVERWORLD,
biome: 0,
barrier: 0,
states: 0,
};
let mut chunk = Chunk::empty(-2, 5);
chunk.set(0, 0, 0, 42);
let proto: crate::streaming::Proto = Box::new(chunk);
let view = terrain.light_view(&proto).expect("debug proto light view");
assert_eq!((view.x, view.z), (-2, 5));
assert_eq!(view.block(0, 0, 0).0, 42);
}
}
+4 -93
View File
@@ -1652,7 +1652,9 @@ pub fn mob_to_nbt(
if let Some(saddle) = m.saddle_item() {
equipment.insert("saddle".into(), crate::save::item_to_nbt(&saddle));
}
// Body slot: harness, armour, a cube's block, a wolf's armor; emptied, it leaves the earlier save.
// A happy ghast wears its harness in the body slot (and a nautilus
// its armour, a sulfur cube its swallowed block, a wolf its armor,
// with the durability it lost).
if crate::mobs::species::body_slot(m.species())
&& let Some(id) = &m.extra.body
{
@@ -1662,8 +1664,6 @@ pub fn mob_to_nbt(
stack.extra = m.extra.body_extra.clone();
}
equipment.insert("body".into(), crate::save::item_to_nbt(&stack));
} else if crate::mobs::species::body_slot(m.species()) {
equipment.remove(&"body".into());
}
// Vanilla saves `equipment` only when something is equipped.
if equipment.is_empty() {
@@ -2867,7 +2867,7 @@ pub fn evoker_fangs_to_nbt(
motion: raw.map(motion).unwrap_or(Vec3::ZERO),
rotation: [yaw, raw.map(rotation).unwrap_or([0.0; 2])[1]],
on_ground: raw.is_some_and(|t| flag(t, "OnGround")),
fall_distance: raw.map_or(0.0, |t| ground_from_nbt(t).1),
fall_distance: 0.0,
}
.tag(raw);
put(&mut tag, "Warmup", Tag::Int(warmup));
@@ -4163,28 +4163,6 @@ mod tests {
);
}
/// `EvokerFangs` extends `Entity` and never moves: `Entity.saveWithoutId`
/// writes the `fall_distance` it loaded, and 0 for fangs a spell made.
#[test]
fn evoker_fangs_keep_their_loaded_fall_distance() {
let raw = horizon_world::nbt::parse_snbt(
"{id:\"minecraft:evoker_fangs\",Pos:[1d,200d,1d],fall_distance:5.0d,Warmup:100}",
)
.unwrap()
.0;
let at = super::Vec3::new(1.0, 200.0, 1.0);
let fall = |raw: Option<&super::Tag>| match super::evoker_fangs_to_nbt(
at, 0.0, 99, None, [1; 4], raw,
)
.get("fall_distance")
{
Some(super::Tag::Double(bits)) => f64::from_bits(*bits),
other => panic!("no fall_distance: {other:?}"),
};
assert_eq!(fall(Some(&raw)), 5.0);
assert_eq!(fall(None), 0.0);
}
/// `Shulker` reads `AttachFace` as a byte before
/// `Direction.from3DDataValue` (`abs(data % 6)`): an int is narrowed
/// first, so 258 is byte 2, not 258 % 6 = 0, and 250 is byte -6, face 0.
@@ -4571,37 +4549,6 @@ mod tests {
assert_eq!(id("head"), "minecraft:iron_helmet");
}
/// A body item taken off (`/item replace entity <cube> armor.body with minecraft:air`)
/// is not saved again from the entity's earlier save: vanilla writes `equipment.body` only
/// while the slot holds something (#12038).
#[test]
fn an_emptied_body_slot_is_not_saved_from_the_earlier_nbt() {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
ty.id = "minecraft:sulfur_cube".into();
ty.species = crate::mobs::Species::SulfurCube;
let types = [("minecraft:sulfur_cube".into(), kind)].into();
let raw = Tag::compound([
("id", string("minecraft:sulfur_cube")),
("Pos", doubles(&[4.5, 220.0, 4.5])),
(
"equipment",
Tag::compound([(
"body",
Tag::compound([
("id", string("minecraft:oak_planks")),
("count", Tag::Int(1)),
]),
)]),
),
]);
let mut m = mob_from_nbt(&raw, &types, 1, None).unwrap();
assert_eq!(m.extra.body.as_deref(), Some("minecraft:oak_planks"));
m.extra.body = None;
let saved = mob_to_nbt(&m, [0; 4], Some(&raw), None, |_| None);
assert!(saved.get("equipment").is_none(), "{saved:?}");
}
/// A wolf loads its armor (`equipment.body`) into the body slot with the
/// durability it lost, and a helmet next to it into the head slot.
#[test]
@@ -5567,34 +5514,6 @@ mod tests {
}
}
/// `Goat` reads `HasLeftHorn` and `HasRightHorn` with `getBooleanOr(key,
/// true)`: a number is true when its byte value is not 0, so an int 256
/// (byte 0) is a lost horn, as `0b` is.
#[test]
fn a_goats_horn_flags_are_read_as_bytes() {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
ty.id = "minecraft:goat".into();
ty.species = crate::mobs::Species::Goat;
let types = [("minecraft:goat".into(), kind)].into();
let lost = |left: Option<Tag>| {
let mut fields = vec![
("id", string("minecraft:goat")),
("Pos", doubles(&[4.5, 220.0, 4.5])),
];
fields.extend(left.map(|v| ("HasLeftHorn", v)));
mob_from_nbt(&Tag::compound(fields), &types, 1, None)
.unwrap()
.extra
.lost_horns
};
assert_eq!(lost(None), [false, false]);
assert_eq!(lost(Some(Tag::Byte(1))), [false, false]);
assert_eq!(lost(Some(Tag::Byte(0))), [true, false]);
assert_eq!(lost(Some(Tag::Int(256))), [true, false]);
assert_eq!(lost(Some(Tag::Int(257))), [false, false]);
}
#[test]
fn non_mob_saves_drop_mob_flags_without_losing_entity_fields() {
let uuid = [104, 0, 0, 1];
@@ -8084,14 +8003,6 @@ mod fireball_nbt_tests {
("Facing", Tag::Byte(1)),
]);
assert_eq!(item_frame_from_nbt(&frame, 1).unwrap().pos, [-1, 80, 6]);
// BlockAttachedEntity.readAdditionalSaveData: a block_pos 16 blocks or
// more from the Pos block is refused for that block.
let mut far = painting.clone();
put(&mut far, "block_pos", Tag::IntArray(vec![28, 80, 22]));
assert_eq!(painting_from_nbt(&far).map(|p| p.0), Some([28, 80, 6]));
let mut near = painting.clone();
put(&mut near, "block_pos", Tag::IntArray(vec![28, 80, 21]));
assert_eq!(painting_from_nbt(&near).map(|p| p.0), Some([28, 80, 21]));
}
#[test]
+21 -68
View File
@@ -13,8 +13,7 @@ fn loaded_pitch(raw: Option<&Tag>) -> f32 {
0.0
}
}
/// Painting.addAdditionalSaveData: `facing`, `variant` and `block_pos`, after
/// Entity.saveWithoutId's common fields.
/// Painting.addAdditionalSaveData: `facing`, `variant` and `block_pos`.
pub fn painting_to_nbt(p: &Painting, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
let (lo, hi) = p.bounds();
let base = Base {
@@ -27,12 +26,8 @@ pub fn painting_to_nbt(p: &Painting, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
),
motion: Vec3::ZERO,
rotation: [f32::from(p.facing) * 90.0, loaded_pitch(raw)],
// BlockAttachedEntity.tick never moves a painting (nor runs baseTick), so its
// onGround and fallDistance stay the ones Entity.load read.
on_ground: raw.is_some_and(|raw| flag(raw, "OnGround")),
fall_distance: raw
.and_then(|raw| float(raw.get("fall_distance")))
.unwrap_or(0.0),
on_ground: false,
fall_distance: 0.0,
};
let mut tag = base.tag(raw);
put(&mut tag, "facing", Tag::Byte(p.facing as i8));
@@ -40,37 +35,21 @@ pub fn painting_to_nbt(p: &Painting, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
put(&mut tag, "block_pos", Tag::IntArray(p.pos.to_vec()));
tag
}
/// `BlockAttachedEntity.readAdditionalSaveData`: `block_pos` when saved and
/// closer than 16 blocks to the block holding `Pos`; else (a summoned one
/// has none) `setPos` puts it at that block.
/// `BlockAttachedEntity.readAdditionalSaveData`: `block_pos` when saved; a
/// summoned one has none, and `setPos` puts its block at the block holding
/// the summon position.
fn attached_block_pos(tag: &Tag) -> Option<[i32; 3]> {
let stored = match tag.get("block_pos") {
match tag.get("block_pos") {
Some(Tag::IntArray(v)) if v.len() == 3 => Some([v[0], v[1], v[2]]),
_ => None,
};
let Some(at) = position(tag) else {
return stored;
};
let here = [
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
];
// Vec3i.closerThan(blockPosition(), 16): the squared distance under 256.
let near = |p: &[i32; 3]| {
(0..3)
.map(|i| (f64::from(p[i]) - f64::from(here[i])).powi(2))
.sum::<f64>()
< 256.0
};
Some(stored.filter(near).unwrap_or(here))
}
/// The block position and facing of a saved painting (`HangingEntity`'s
/// `facing`, a 2D direction).
pub fn painting_placement_from_nbt(tag: &Tag) -> Option<([i32; 3], u8)> {
let pos = attached_block_pos(tag)?;
let facing = wrap_data_value(number(tag.get("facing")).unwrap_or(0), 4);
Some((pos, facing))
_ => {
let at = position(tag)?;
Some([
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
])
}
}
}
/// `Direction.from3DDataValue` / `from2DDataValue`: the byte read by
/// `LEGACY_ID_CODEC` indexes the table as `abs(data % modulus)`, never clamped.
@@ -80,7 +59,8 @@ fn wrap_data_value(raw: impl TryInto<i64> + Copy, modulus: i64) -> 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, facing) = painting_placement_from_nbt(tag)?;
let pos = attached_block_pos(tag)?;
let facing = wrap_data_value(number(tag.get("facing")).unwrap_or(0), 4);
Some((pos, facing, text(tag.get("variant"))))
}
/// ItemFrame.addAdditionalSaveData.
@@ -94,14 +74,10 @@ pub fn item_frame_to_nbt(f: &ItemFrame, uuid: [i32; 4], raw: Option<&Tag>) -> Ta
(lo[1] + hi[1]) / 2.0,
(lo[2] + hi[2]) / 2.0,
),
// BlockAttachedEntity.tick never runs super.tick, so the Motion, onGround and
// fallDistance that Entity.load read stay.
motion: raw.map_or(Vec3::ZERO, loaded_motion),
motion: Vec3::ZERO,
rotation: [0.0, 0.0],
on_ground: raw.is_some_and(|raw| flag(raw, "OnGround")),
fall_distance: raw
.and_then(|raw| float(raw.get("fall_distance")))
.unwrap_or(0.0),
on_ground: false,
fall_distance: 0.0,
};
let mut tag = base.tag(raw);
// `Entity.saveWithoutId`: `CustomName` before the frame's own fields.
@@ -172,27 +148,4 @@ mod tests {
]);
assert_eq!(painting_from_nbt(&tag).unwrap().1, 1);
}
#[test]
fn item_frame_keeps_loaded_motion_on_ground_and_fall_distance() {
let tag = Tag::compound([
("id", string("minecraft:item_frame")),
("Pos", doubles(&[0.5, 64.0, 0.5])),
("Facing", Tag::Byte(2)),
("Motion", doubles(&[0.5, 20.0, -0.25])),
("OnGround", Tag::Byte(1)),
("fall_distance", Tag::Double(3.5f64.to_bits())),
]);
let frame = item_frame_from_nbt(&tag, 1).unwrap();
let saved = item_frame_to_nbt(&frame, [1, 2, 3, 4], Some(&tag));
// A component beyond 10 loads as 0, the others stay.
assert_eq!(motion(&saved), Vec3::new(0.5, 0.0, -0.25));
assert!(flag(&saved, "OnGround"));
assert_eq!(float(saved.get("fall_distance")), Some(3.5));
// A frame made in play has nothing loaded: zeros.
let made = item_frame_to_nbt(&frame, [1, 2, 3, 4], None);
assert_eq!(motion(&made), Vec3::ZERO);
assert!(!flag(&made, "OnGround"));
assert_eq!(float(made.get("fall_distance")), Some(0.0));
}
}
-23
View File
@@ -317,12 +317,6 @@ pub fn settings_from_json(
impl ChunkGenerator for FlatTerrain {
type Worker = ();
fn worker(&self) {}
/// FlatLevelSource's proto is already its decorated column. Keep a copy
/// for lighting a finished neighbour outside the kept area, just as the
/// vanilla pipeline does for FEATURES neighbours.
fn light_view(&self, proto: &crate::streaming::Proto) -> Option<Chunk> {
proto.downcast_ref::<Chunk>().cloned()
}
fn lod_surface(&self, _: &mut (), _: i32, _: i32) -> Option<crate::lod::Surface> {
self.surface
}
@@ -616,21 +610,4 @@ mod tests {
.is_none()
);
}
#[test]
fn light_view_keeps_a_flat_proto_column() {
let terrain = FlatTerrain {
shape: LevelShape::OVERWORLD,
layers: Vec::new(),
biome: 0,
surface: None,
void_platform: None,
};
let mut chunk = Chunk::empty(3, -4);
chunk.set(0, 0, 0, 42);
let proto: crate::streaming::Proto = Box::new(chunk);
let view = terrain.light_view(&proto).expect("flat proto light view");
assert_eq!((view.x, view.z), (3, -4));
assert_eq!(view.block(0, 0, 0).0, 42);
}
}
+19 -44
View File
@@ -476,6 +476,7 @@ mod trial_spawner;
mod trial_visuals;
mod trident;
mod tutorial;
mod tweaks;
mod ui_control;
mod use_hand;
mod using_item;
@@ -2229,7 +2230,7 @@ struct Game<'a> {
/// `AbstractContainerScreen`'s quick craft and skip-release state.
inv_clicks: horizon_client::container_screen::Clicks,
/// The Mouse Tweaks extra's drag and wheel (`tweaks`).
tweaks: extras::mouse_tweaks::Tweaks,
tweaks: tweaks::Tweaks,
/// The wheel and hovered slot of `BundleMouseActions`.
bundle_mouse: bundles::BundleMouse,
/// The last inventory click, for the double click that gathers a stack.
@@ -7210,14 +7211,14 @@ impl Game<'_> {
laps.lap("eg_objects");
// Particles (ParticleEngine.extract): camera-facing quads.
self.mark_stage(out.len(), "PARTICLES");
let world = &self.world;
let particle_mode = self.settings.horizon.particle_culling;
if particle_mode == horizon_client::graphics::ParticleCullingMode::Off {
self.particles.render_in_view(
self.particles.render(
camera,
self.particle_rotation(),
alpha as f32,
self.view_frustum.as_ref(),
&|b| self.world.light(b[0], b[1], b[2]),
&|b| world.light(b[0], b[1], b[2]),
&mut out,
);
} else {
@@ -7240,7 +7241,7 @@ impl Game<'_> {
true,
frustum.as_ref(),
&mut |min, max| particle_culling.hidden(min, max),
&|b| self.world.light(b[0], b[1], b[2]),
&|b| world.light(b[0], b[1], b[2]),
&mut out,
);
}
@@ -8188,16 +8189,19 @@ impl Game<'_> {
.ok_or("missing lit block state")?;
state.properties.insert("lit".into(), "true".into());
self.level_set(hit.pos, registry.resolve(&state)?)?;
let sound = if item == "fire_charge" {
"minecraft:item.firecharge.use"
} else {
"minecraft:item.flintandsteel.use"
};
let pitch = horizon_client::items::fire_starter_pitch(
item == "fire_charge",
&mut self.world.random,
self.audio.play(
if item == "fire_charge" {
"minecraft:item.firecharge.use"
} else {
"minecraft:item.flintandsteel.use"
},
Some(at(hit.pos)),
1.0,
horizon_client::items::fire_starter_pitch(
item == "fire_charge",
&mut self.world.random,
),
);
self.play_block_use_sound(sound, at(hit.pos), pitch);
// FlintAndSteelItem / FireChargeItem.useOn: BLOCK_CHANGE at the lit block,
// and ItemStack.useOn's used statistic.
self.post_block_game_event(
@@ -8228,7 +8232,7 @@ impl Game<'_> {
item == "fire_charge",
&mut self.world.random,
);
self.play_block_use_sound(sound, at(target), pitch);
self.audio.play(sound, Some(at(target)), 1.0, pitch);
// BLOCK_PLACE: flint and steel names the clicked block, a fire charge the
// fire. Then ItemStack.useOn's used statistic.
let placed_at = if item == "fire_charge" {
@@ -12586,35 +12590,6 @@ mod subtitles;
#[cfg(test)]
#[path = "game_tests.rs"]
mod tests;
#[cfg(test)]
mod test_neighbourhood {
//! Shared fixture step for tests that tick entities on prepared data.
use super::*;
impl Game<'_> {
/// Loads the chunks within 2 columns of `(cx, cz)` that are not loaded yet.
/// Entity ticking waits for every chunk in that 5x5 neighbourhood
/// (`ChunkMap.prepareEntityTickingChunk`), so a fixture that ticks mobs,
/// items or TNT in a chunk calls this after setting up its own chunks.
pub(crate) fn load_ticking_neighbourhood(&mut self, cx: i32, cz: i32) {
for z in cz - 2..=cz + 2 {
for x in cx - 2..=cx + 2 {
if self.world.chunks.contains_key(&(x, z)) {
continue;
}
self.world.chunks.insert(
(x, z),
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(x, z)),
);
// A chunk that is already registered keeps its ticks.
let _ = self
.block_ticks
.add_empty_chunk(horizon_core::position::ChunkPos::new(x, z));
}
}
}
}
}
fn write_level_for_role(
level: &mut Level,
@@ -51,7 +51,6 @@ fn game() -> Game<'static> {
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
game.load_ticking_neighbourhood(0, 0);
for command in [
"forceload add 0 0",
"fill 0 99 0 15 99 15 stone",
@@ -2709,7 +2709,6 @@ mod postbaseline_lodestone_controls {
game.block_ticks
.add_empty_chunk(BlockPos::new(64, 0, 64).chunk())
.unwrap();
game.load_ticking_neighbourhood(0, 0);
assert!(game.run_command("fill 64 199 64 79 199 79 stone"));
assert!(game.run_command(&format!("summon armor_stand 65.5 200.0 65.5 {{UUID:[I;42321,0,0,102],Health:20f,NoGravity:1b,equipment:{{mainhand:{}}}}}",item("minecraft:compass",tracked,100,1))));
let id = |n| {
+2 -2
View File
@@ -1508,6 +1508,7 @@ impl Game<'_> {
Vec3::new(b.position.x, top, b.position.z)
}
}
/// `BoatItem.use`: aim at a block or the water surface.
/// `Entity.setYRot` on a boat just made (its old rotation moves with it).
pub(super) fn set_boat_yaw(&mut self, id: u32, yaw: f32) {
if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
@@ -1515,7 +1516,6 @@ impl Game<'_> {
b.previous_yaw = yaw;
}
}
/// `BoatItem.use`: aim at a block or the water surface.
pub(super) fn place_boat(&mut self, stack: &ItemStack) -> bool {
let Some(boat) = self.boat_to_place(stack) else {
return false;
@@ -1528,7 +1528,7 @@ impl Game<'_> {
self.world.modified.insert(key);
let (id, kind, yaw) = (boat.id, boat.entity_type(), boat.yaw);
self.boats.push(boat);
self.apply_item_stack_config(super::commands::Target::Boat(id), &kind, stack, true);
self.apply_vehicle_stack_config(super::commands::Target::Boat(id), &kind, stack, true);
// BoatItem.use: setYRot(player.getYRot()) after the stack's config.
self.set_boat_yaw(id, yaw);
if self.serving.is_some() || self.options.dedicated.is_some() {
@@ -18,7 +18,7 @@ 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.shape.min_y) {
if !below_world(self.boats[i].position.y, self.world.bottom_y()) {
return false;
}
self.eject_boat_mobs(i, true);
@@ -41,7 +41,7 @@ impl Game<'_> {
kind: &str,
) -> Option<usize> {
let i = cart_at(&self.carts, id, slot)?;
if !below_world(self.carts[i].position.y, self.world.shape.min_y) {
if !below_world(self.carts[i].position.y, self.world.bottom_y()) {
return Some(i);
}
self.eject_cart_mobs(i);
@@ -68,51 +68,44 @@ mod tests {
assert!(below_world(-64.5, 0));
}
/// A tick discards vehicles below each dimension's own floor, even when
/// its chunk storage extends lower, and spills the container's contents.
/// Vehicles on the line or above it survive this tick.
/// 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;
for min_y in [-64, 0] {
let mut g = crate::game::native_machines::tests::server_game();
g.world.shape.min_y = min_y;
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"));
let line = min_y - 64;
for summon in [
format!("summon oak_boat 0.5 {line} 0.5"),
format!("summon oak_boat 4.5 {} 4.5", f64::from(line) - 0.5),
format!("summon minecart 6.5 {} 6.5", line + 1),
format!(
"summon chest_minecart 2.5 {} 2.5 {{Items:[{{Slot:0b,id:\"minecraft:diamond\",count:3}}]}}",
line - 1
),
] {
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);
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);
}
#[test]
@@ -54,9 +54,6 @@ fn game() -> Game<'static> {
.chunks
.insert((x, 0), Arc::new(Chunk::empty(x, 0)));
}
for x in 0..2 {
game.load_ticking_neighbourhood(x, 0);
}
assert!(game.run_command("fill 0 99 0 31 99 15 stone"));
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(4.5, 100.0, 6.5)).unwrap());
let net = NetConfig {
+125 -9
View File
@@ -4,7 +4,6 @@ mod admin;
pub(super) mod compute;
mod data;
mod display_names;
mod target_position;
pub(super) use data::place_summoned;
mod effects;
pub(super) mod execute;
@@ -452,7 +451,9 @@ impl Game<'_> {
// the server's own source, "Server".
let who = match self.command_source.and_then(|s| s.entity) {
Some(entity) => self.target_name(entity),
None if self.console_broadcast.is_some() => self.server_name(),
None if self.console_broadcast.is_some() => {
Text::literal(if self.rcon_source { "Rcon" } else { "Server" }.to_owned())
}
None => self.player_display_name(),
};
let line = Text::translate("chat.type.admin", &[who, message])
@@ -537,6 +538,118 @@ impl Game<'_> {
conn.and_then(|c| self.command_guest(c))
.map_or(self.dim, |g| Dim::from_id(&g.dimension))
}
pub(super) fn target_position(&self, t: Target) -> Option<[f64; 3]> {
match t {
Target::Player => self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]),
Target::Guest(conn) => self.command_guest(conn).map(|g| g.position),
Target::Mob(id) => self
.mobs
.iter()
.find(|m| m.id == id)
.map(|m| [m.position.x, m.position.y, m.position.z]),
Target::Item(id) => self
.dropped
.iter()
.find(|i| i.id == id)
.map(|i| [i.position.x, i.position.y, i.position.z]),
Target::Orb(id) => self
.orbs
.iter()
.find(|o| o.id == id)
.map(|o| [o.position.x, o.position.y, o.position.z]),
Target::Projectile(id) => self
.creatures
.projectiles
.iter()
.find(|a| a.id == id)
.map(|a| [a.position.x, a.position.y, a.position.z]),
Target::Stand(id) => self
.armor_stands
.iter()
.find(|a| a.id == id)
.map(|a| [a.position.x, a.position.y, a.position.z]),
Target::Frame(id) => self.item_frames.iter().find(|f| f.id == id).map(|f| {
let (lo, hi) = f.bounds();
[
(lo[0] + hi[0]) / 2.0,
(lo[1] + hi[1]) / 2.0,
(lo[2] + hi[2]) / 2.0,
]
}),
Target::Painting(id) => self.paintings.iter().find(|p| p.id == id).map(|p| {
let (lo, hi) = p.bounds();
[
(lo[0] + hi[0]) / 2.0,
(lo[1] + hi[1]) / 2.0,
(lo[2] + hi[2]) / 2.0,
]
}),
Target::Boat(id) => self
.boats
.iter()
.find(|b| b.id == id)
.map(|b| [b.position.x, b.position.y, b.position.z]),
Target::Cart(id) => self
.carts
.iter()
.find(|c| c.id == id)
.map(|c| [c.position.x, c.position.y, c.position.z]),
Target::Other(id) => self
.others
.iter()
.find(|o| o.id == id)
.map(|o| [o.position.x, o.position.y, o.position.z]),
Target::Crystal(id) => self
.crystals
.iter()
.find(|c| c.id == id)
.map(|c| [c.position.x, c.position.y, c.position.z]),
Target::Dragon(id) => self
.dragon
.as_ref()
.filter(|d| d.id == id)
.map(|d| [d.position.x, d.position.y, d.position.z]),
Target::Cushion(id) => self
.cushions
.iter()
.find(|c| c.id == id)
.map(|c| [c.position.x, c.position.y, c.position.z]),
Target::Tnt(id) => self
.tnts
.iter()
.find(|t| t.id == id)
.map(|t| [t.position.x, t.position.y, t.position.z]),
Target::Falling(id) => self
.falling
.iter()
.find(|f| f.id == id)
.map(|f| [f.position.x, f.position.y, f.position.z]),
Target::Rocket(id) => self
.rockets
.iter()
.find(|r| r.id == id)
.map(|r| [r.position.x, r.position.y, r.position.z]),
Target::Cloud(id) => self.breath_clouds.iter().find(|c| c.id == id).map(|c| c.at),
Target::Trident(id) => self
.tridents
.iter()
.find(|t| t.id == id)
.map(|t| [t.position.x, t.position.y, t.position.z]),
Target::Fangs(id) => self.fangs.list.iter().find(|f| f.id == id).map(|f| f.pos),
Target::Spawner(id) => self
.ominous_spawners
.iter()
.find(|s| s.id == id)
.map(|s| s.position),
Target::Knot(id) => self.natural_knot(id).map(|fence| {
let p = horizon_client::mobs::leash::knot_state(fence).position;
[p.x, p.y, p.z]
}),
}
}
pub(super) fn target_type(&self, t: Target) -> String {
match t {
Target::Player | Target::Guest(_) => "minecraft:player".into(),
@@ -1738,7 +1851,7 @@ impl Game<'_> {
}
Target::Crystal(id) => {
if let Some(c) = self.crystals.iter_mut().find(|c| c.id == id) {
(c.position, c.fall.0) = (v, true); // performTeleport: setOnGround(true)
c.position = v;
}
}
Target::Dragon(id) => {
@@ -1749,8 +1862,6 @@ impl Game<'_> {
}
Target::Tnt(id) => {
if let Some(t) = self.tnts.iter_mut().find(|t| t.id == id) {
// `PrimedTnt.teleport` includes same-level command teleports.
t.used_portal = true;
t.position = v;
t.previous = v;
if let Some(r) = rotation {
@@ -2524,9 +2635,14 @@ impl Game<'_> {
self.next_entity += 1;
let at = Vec3::new(pos[0], pos[1], pos[2]);
let drawn = self.world.random.next_int_bound(100_000).unwrap_or(0);
let time = super::dragon::Crystal::start_time(at, drawn, self.clock.still());
let crystal = super::dragon::Crystal::new(self.next_entity, at, true, time);
self.crystals.push(crystal);
self.crystals.push(super::dragon::Crystal {
id: self.next_entity,
position: at,
show_bottom: true,
time: super::dragon::Crystal::start_time(at, drawn, self.clock.still()),
beam: None,
invulnerable: false,
});
// tryAddFreshEntityWithPassengers refused: ERROR_DUPLICATE_UUID.
if !self.native_joins_flush(false, self.next_entity) {
return Err(err("commands.summon.failed.uuid", &[]));
@@ -4296,7 +4412,7 @@ impl Game<'_> {
.map(|cart| cart.command.name())
})
})
.unwrap_or_else(|| self.server_name()),
.unwrap_or_else(|| Text::literal("Server")),
},
None => self.player_display_name(),
};
@@ -697,27 +697,6 @@ impl Game<'_> {
self.carts[i] = cart;
set_key(&mut tag, "UUID", Tag::IntArray(uuid.to_vec()));
}
Target::Painting(id) => {
// Painting.readAdditionalSaveData over HangingEntity's: the
// facing, the block it hangs on and a known variant
// (`VariantUtils.readVariant(..).ifPresent`: an unknown or
// unreadable one keeps the painting's).
let uuid = self.uuid_for(id);
let (pos, facing) =
entity_nbt::painting_placement_from_nbt(&tag).ok_or_else(stale)?;
let known = entity_nbt::painting_from_nbt(&tag)
.and_then(|(_, _, v)| self.painting_variants.get(&v?).cloned());
let p = self
.paintings
.iter_mut()
.find(|p| p.id == id)
.ok_or_else(stale)?;
(p.pos, p.facing) = (pos, facing);
if let Some(v) = known {
(p.variant, p.width, p.height) = (v.id, v.width, v.height);
}
set_key(&mut tag, "UUID", Tag::IntArray(uuid.to_vec()));
}
Target::Other(id) => {
let uuid = self.uuid_for(id);
let marker = self
@@ -769,7 +748,6 @@ impl Game<'_> {
| Target::Item(id)
| Target::Stand(id)
| Target::Frame(id)
| Target::Painting(id)
| Target::Boat(id)
| Target::Cart(id)
| Target::Tnt(id)
@@ -1567,7 +1545,7 @@ impl Game<'_> {
let before = self.next_entity;
let mut tag = tag;
crate::game::persist::mark_fresh(&mut tag);
self.world.queue_entity_nbt(key, vec![tag]);
self.world.loaded_entities.push((key, vec![tag]));
self.spawn_loaded_entities();
waiting.append(&mut self.world.loaded_entities);
self.world.loaded_entities = waiting;
@@ -61,12 +61,6 @@ impl Game<'_> {
.unwrap_or(Target::Player)
}
/// The name of the server's own source: "Rcon" for a command an RCON client sent
/// (`RconConsoleSource`), "Server" otherwise (`MinecraftServer.createCommandSourceStack`).
pub(in crate::game) fn server_name(&self) -> Text {
Text::literal(if self.rcon_source { "Rcon" } else { "Server" })
}
/// `PlayerList.getPlayerByName`: names are not case sensitive. Horizon:
/// on a server where a mod gave players nicknames
/// (`native_set_nickname`), a name no player has finds the player going
@@ -1,116 +0,0 @@
use super::{Game, Target};
impl Game<'_> {
pub(in crate::game) fn target_position(&self, t: Target) -> Option<[f64; 3]> {
match t {
Target::Player => self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]),
Target::Guest(conn) => self.command_guest(conn).map(|g| g.position),
Target::Mob(id) => self
.mobs
.iter()
.find(|m| m.id == id)
.map(|m| [m.position.x, m.position.y, m.position.z]),
Target::Item(id) => self
.dropped
.iter()
.find(|i| i.id == id)
.map(|i| [i.position.x, i.position.y, i.position.z]),
Target::Orb(id) => self
.orbs
.iter()
.find(|o| o.id == id)
.map(|o| [o.position.x, o.position.y, o.position.z]),
Target::Projectile(id) => self
.creatures
.projectiles
.iter()
.find(|a| a.id == id)
.map(|a| [a.position.x, a.position.y, a.position.z]),
Target::Stand(id) => self
.armor_stands
.iter()
.find(|a| a.id == id)
.map(|a| [a.position.x, a.position.y, a.position.z]),
Target::Frame(id) => self.item_frames.iter().find(|f| f.id == id).map(|f| {
let (lo, hi) = f.bounds();
[
(lo[0] + hi[0]) / 2.0,
(lo[1] + hi[1]) / 2.0,
(lo[2] + hi[2]) / 2.0,
]
}),
Target::Painting(id) => self.paintings.iter().find(|p| p.id == id).map(|p| {
let (lo, hi) = p.bounds();
[
(lo[0] + hi[0]) / 2.0,
(lo[1] + hi[1]) / 2.0,
(lo[2] + hi[2]) / 2.0,
]
}),
Target::Boat(id) => self
.boats
.iter()
.find(|b| b.id == id)
.map(|b| [b.position.x, b.position.y, b.position.z]),
Target::Cart(id) => self
.carts
.iter()
.find(|c| c.id == id)
.map(|c| [c.position.x, c.position.y, c.position.z]),
Target::Other(id) => self
.others
.iter()
.find(|o| o.id == id)
.map(|o| [o.position.x, o.position.y, o.position.z]),
Target::Crystal(id) => self
.crystals
.iter()
.find(|c| c.id == id)
.map(|c| [c.position.x, c.position.y, c.position.z]),
Target::Dragon(id) => self
.dragon
.as_ref()
.filter(|d| d.id == id)
.map(|d| [d.position.x, d.position.y, d.position.z]),
Target::Cushion(id) => self
.cushions
.iter()
.find(|c| c.id == id)
.map(|c| [c.position.x, c.position.y, c.position.z]),
Target::Tnt(id) => self
.tnts
.iter()
.find(|t| t.id == id)
.map(|t| [t.position.x, t.position.y, t.position.z]),
Target::Falling(id) => self
.falling
.iter()
.find(|f| f.id == id)
.map(|f| [f.position.x, f.position.y, f.position.z]),
Target::Rocket(id) => self
.rockets
.iter()
.find(|r| r.id == id)
.map(|r| [r.position.x, r.position.y, r.position.z]),
Target::Cloud(id) => self.breath_clouds.iter().find(|c| c.id == id).map(|c| c.at),
Target::Trident(id) => self
.tridents
.iter()
.find(|t| t.id == id)
.map(|t| [t.position.x, t.position.y, t.position.z]),
Target::Fangs(id) => self.fangs.list.iter().find(|f| f.id == id).map(|f| f.pos),
Target::Spawner(id) => self
.ominous_spawners
.iter()
.find(|s| s.id == id)
.map(|s| s.position),
Target::Knot(id) => self.natural_knot(id).map(|fence| {
let p = horizon_client::mobs::leash::knot_state(fence).position;
[p.x, p.y, p.z]
}),
}
}
}
+14 -14
View File
@@ -1080,7 +1080,7 @@ impl Game<'_> {
max_entity_cramming: self.ui.rules.int("max_entity_cramming"),
dimension_type: self.dimension_type(),
world_seed: self.level.seed,
sea_level: horizon_world::level::sea_level_of(self.level_key()),
sea_level: if self.dim == Dim::Nether { 32 } else { 63 },
teams: std::sync::Arc::new(self.mob_teams()),
items: Some(self.items()),
lid_openers: &self.lid_openers,
@@ -1280,9 +1280,10 @@ impl Game<'_> {
.is_some()
.then_some(self.level.player_uuid)
.flatten(),
mods: round.listening() || !horizon_client::mod_api::mods().is_empty(),
ticking: &|| Box::new(self.entity_ticking_area()),
};
let mut group = match islands.plan(&mobs, &snapshot, &turns, &start) {
let mut group = match islands.plan(&mobs, &snapshot, &turns) {
Some(plan) => islands.run(plan, mobs.into(), snapshot, start, &mut turns, &shared),
None => {
let mut group = mob_group::Group::new(mobs.into(), snapshot, start);
@@ -1877,9 +1878,10 @@ impl Game<'_> {
for item in stacks {
// Every death drop is its own ItemEntity, and the drop entity's
// own random (not the level's) gives it the constructor's
// horizontal push (its motion otherwise stays [0, 0.2, 0] and the
// drops land stacked at the mob's centre, without disturbing the
// level random that the orbs' merge ids and block ticks read next).
// horizontal push (its motion otherwise stays [0, 0.2, 0] and
// the drops land stacked at the mob's centre, without disturbing
// the level random that the orbs' merge ids and block ticks read
// next).
self.spawn_item_own(item, pos, 10);
}
// LivingEntity.dropExperience: a mod may change the mob's experience
@@ -1919,9 +1921,8 @@ impl Game<'_> {
self.spawn_item_own(item, pos, 10);
}
}
drops::Drop::Aged(s, age) => self.spawn_item_aged(from_loot(&s), pos, age),
drops::Drop::Orb(value) if !consumed => self.spawn_orbs(pos, value as u32),
_ => {}
drops::Drop::Item(_) | drops::Drop::Stack(_) | drops::Drop::Orb(_) => {}
}
}
}
@@ -3233,7 +3234,11 @@ impl Game<'_> {
MobEvent::DolphinTreasure { mob } => {
// ServerLevel.findNearestMapStructure(#dolphin_located, 50).
let Some(at) = self.mobs.iter().find(|m| m.id == mob).map(|m| {
[m.position.x, m.position.y, m.position.z].map(|v| v.floor() as i32)
[
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
]
}) else {
continue;
};
@@ -3244,11 +3249,7 @@ impl Game<'_> {
.filter(|_| !names.is_empty())
.and_then(|t| t.locate(&names, at, 50));
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
m.extra.home = found.as_ref().map(|(_, p)| *p);
}
// DolphinSwimToTreasureGoal.start: entity event 38 once it has a treasure.
if found.is_some() {
self.mob_event_particles(mob, 38);
m.extra.home = found.map(|(_, p)| p);
}
}
MobEvent::EffectPlayer {
@@ -9050,7 +9051,6 @@ mod h169_glide_travel_tests {
.unwrap();
}
}
game.load_ticking_neighbourhood(0, 0);
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
game.creative = true;
assert!(game.run_command("fill 0 199 0 31 199 31 stone"));
@@ -64,9 +64,6 @@ pub(super) struct Group<M: MobSlot> {
pub corpses: Vec<Mob>,
pub gone: Vec<Gone>,
pub guest_events: Vec<(u32, u32, MobEvent)>,
/// The mod hooks its turns kept for the join, with each turn's place in the whole list (a
/// group of the parallel tick with mods loaded: `native_client_hooks::Deferred`).
pub deferred: Vec<(usize, horizon_client::native_client_hooks::Deferred)>,
}
/// What decides each turn besides the group, read at the turn as the serial loop reads it.
@@ -80,7 +77,7 @@ pub(super) struct Turns<'t, 'w> {
/// The one player's position, when the level has one: every mob's nearest.
pub single_player: Option<Option<Vec3>>,
/// Whether a mod canceled a mob's tick.
pub canceled: &'t (dyn Fn(u32) -> bool + Sync),
pub canceled: &'t dyn Fn(u32) -> bool,
/// `getCurrentDifficultyAt` a position.
pub difficulty: &'t (dyn Fn([f64; 3]) -> LocalDifficulty + Sync),
/// The last chunk's difficulty: mobs come in runs of one chunk.
@@ -140,7 +137,6 @@ impl<M: MobSlot> Group<M> {
corpses: Vec::new(),
gone: Vec::new(),
guest_events: Vec::new(),
deferred: Vec::new(),
}
}
@@ -177,17 +173,7 @@ impl<M: MobSlot> Group<M> {
foreign.reader.set(at);
}
if let Some(foreign) = foreign {
foreign.in_seed_order(at, || {
if foreign.defers {
let ((), kept) =
horizon_client::native_client_hooks::deferring_turn(|| {
self.turn(i, turns, shared);
});
self.deferred.extend(kept.into_iter().map(|d| (at, d)));
} else {
self.turn(i, turns, shared);
}
});
foreign.in_seed_order(at, || self.turn(i, turns, shared));
} else {
self.turn(i, turns, shared);
}
@@ -377,10 +363,7 @@ impl<M: MobSlot> Group<M> {
shared.guest_ids,
self.player.as_ref(),
);
// A grouped turn runs while no mod hooks the change (`horizon_mod_api_gen::mob_turn`).
if let Some((player, mob)) = named
&& !horizon_client::native_client_hooks::deferring()
{
if let Some((player, mob)) = named {
let kind = super::super::native_hooks::mob_kind(m);
let mut cancel = false;
Game::native_living_change_target(
File diff suppressed because it is too large. Load diff
@@ -5,14 +5,9 @@ use super::super::{
};
use horizon_client::{mobs::Species, scoreboard::teams_allow_damage};
use horizon_core::geometry::Vec3;
use horizon_protocol::packets::play::{Clientbound, GameEvent};
/// `ClientboundGameEventPacket.PUFFER_FISH_STING`.
const PUFFER_FISH_STING: u8 = 9;
impl Game<'_> {
/// `Pufferfish.playerTouch`: accepted player damage, the sting game event, then poison with
/// this fish as source.
/// `Pufferfish.playerTouch`: accepted player damage, then poison with this fish as source.
pub(in crate::game) fn touch_pufferfish(&mut self) {
if self.remote.is_some() || self.spectator() {
return;
@@ -51,11 +46,10 @@ impl Game<'_> {
m.extra.size,
[m.position.x, m.position.y, m.position.z],
self.mob_entity(m),
m.silent,
)
})
.collect();
for (id, puff, at, source, silent) in touching {
for (id, puff, at, source) in touching {
if !self.hurt_player_from(
(1 + puff) as f32,
Some(at),
@@ -64,10 +58,6 @@ impl Game<'_> {
) {
continue;
}
// `if (!this.isSilent())` inside the `hurtServer` branch: before the poison.
if !silent {
self.puffer_fish_sting();
}
let changes = self.potions.effects.changes;
self.give_player_effect("minecraft:poison", 60 * puff, 0);
if self.potions.effects.changes != changes {
@@ -75,17 +65,4 @@ impl Game<'_> {
}
}
}
/// `ServerPlayer.connection.send(ClientboundGameEventPacket(PUFFER_FISH_STING, 0.0F))`: the
/// stung player's own client plays it (`ClientPacketListener.handleGameEvent`). A server has
/// no player of its own, so the packet is the only way to reach it.
fn puffer_fish_sting(&mut self) {
self.guest_packets.push(Clientbound::GameEvent(GameEvent {
event: PUFFER_FISH_STING,
param: 0.0,
}));
}
}
#[cfg(test)]
#[path = "pufferfish_sting_tests.rs"]
mod pufferfish_sting_tests;
@@ -1,95 +0,0 @@
//! `Pufferfish.playerTouch`: the sting game event (`ClientboundGameEventPacket.PUFFER_FISH_STING`)
//! goes out only inside the `hurtServer` branch, and only when the fish is not
//! silent; the poison follows it.
use crate::game::{Audio, Game, Options, Progress, Settings, execution_role, load_resources};
use horizon_client::mobs::Mob;
use horizon_client::motion::PlayerMotion;
use horizon_core::geometry::Vec3;
use horizon_protocol::packets::play::Clientbound;
/// A game whose window player touches one puffed (state 2) pufferfish. The
/// server acts for the guest `lan_acting`: `playerTouch` runs there, and the
/// event it sends reaches that player's own client.
fn game_with(silent: bool) -> 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,
false,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
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();
game.creative = false;
game.lan_acting = Some(7);
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(8.5, 66.0, 8.5)).unwrap());
let kind = game.res.mob_types()["minecraft:pufferfish"].clone();
let mut fish = Mob::new(9001, kind, Vec3::new(8.5, 66.0, 8.5), 0.0);
fish.extra.size = 2;
fish.silent = silent;
game.mobs.push(fish);
game
}
/// The game events the stung player's client was sent, as `(event, parameter)`.
fn stings(game: &Game<'_>) -> Vec<(u8, f32)> {
game.guest_packets
.iter()
.filter_map(|packet| match packet {
Clientbound::GameEvent(e) => Some((e.event, e.param)),
_ => None,
})
.collect()
}
fn health(game: &Game<'_>) -> f32 {
game.player.as_ref().unwrap().health.health()
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn the_sting_event_goes_out_once_per_accepted_hit() {
let mut g = game_with(false);
g.touch_pufferfish();
assert_eq!(health(&g), 17.0, "1 + PuffState 2 of damage landed");
assert_eq!(stings(&g), vec![(9, 0.0)], "PUFFER_FISH_STING, parameter 0");
let poison = g.potions.effects.get("poison").expect("poisoned");
assert_eq!((poison.duration, poison.amplifier), (120, 0));
// A second touch inside the hurt cooldown is refused (`hurtServer` is
// false): no damage and no event.
g.touch_pufferfish();
assert_eq!(health(&g), 17.0, "the cooldown refused the second hit");
assert_eq!(stings(&g).len(), 1, "a refused hit sends nothing");
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_silent_fish_damages_and_poisons_without_the_sting() {
let mut g = game_with(true);
g.touch_pufferfish();
assert_eq!(health(&g), 17.0, "the damage still lands");
assert!(g.potions.effects.get("poison").is_some(), "the poison too");
assert!(stings(&g).is_empty(), "a silent fish sends no event");
}
+26 -271
View File
@@ -308,17 +308,6 @@ fn overload_warning(behind: Duration, tick: Duration) -> String {
)
}
/// `ServerGamePacketListenerImpl.tick`'s idle kick: a player whose last action is more than
/// `minutes` (`player-idle-timeout`, none when 0 or less) behind, counted here in ticks, is kicked,
/// unless the End poem is showing (`ServerPlayer.wonGame`): its client sends no action until it
/// asks to respawn.
fn idles_out(idle_ticks: u64, minutes: i32, won_game: bool) -> bool {
let Ok(minutes @ 1..) = u64::try_from(minutes) else {
return false;
};
!won_game && idle_ticks > minutes * 1200
}
/// Log4j's console pattern: `[HH:mm:ss] [thread/LEVEL]: message`.
pub(super) fn log(level: &str, message: &str) {
log_from("Server thread", level, message);
@@ -539,28 +528,6 @@ fn parse_whitelist_bypass(text: &str) -> std::result::Result<Vec<String>, String
Ok(out)
}
impl Lists {
/// The console's `/ban` of one player (`BanPlayerCommands.banPlayers`): whether they are banned now.
/// One banned already (`isBanned`) is left as they are, their first reason kept.
fn ban(&mut self, user: &NameAndId, reason: Option<String>) -> bool {
!self.bans.contains(user)
&& self.bans.add(BanEntry::new(
user.clone(),
Some("Server".into()),
None,
reason,
))
}
/// The console's `/ban-ip` (`BanIpCommands.banIp`): whether the address is banned now. One banned
/// already (`isBanned`) is left as it is.
fn ban_ip(&mut self, ip: &str, reason: Option<String>) -> bool {
let ip = ip.to_owned();
!self.ip_bans.contains(&ip)
&& self
.ip_bans
.add(BanEntry::new(ip, Some("Server".into()), None, reason))
}
fn load() -> Self {
let mut l = Self {
ops: OpList::new("ops.json"),
@@ -897,28 +864,6 @@ fn auto_save(state: &ServerState) -> bool {
state.lists.lock().unwrap().autosave
}
/// `MinecraftServer.tickServer`'s pause-when-empty count. `pause_after` is its
/// `pauseWhenEmptySeconds() * 20`; while that is above 0, a tick with no player and no
/// `/tick sprint` running (`ServerTickRateManager.isSprinting`) adds one to `empty_ticks`, and any
/// other tick sets it back to 0. A `/pregen` task keeps the server busy as a player would: paused,
/// nothing would post-process its chunks. Returns whether the server is paused this tick.
fn count_empty_tick(
empty_ticks: &mut i32,
pause_after: i32,
players: usize,
pregen: bool,
sprinting: bool,
) -> bool {
if pause_after > 0 {
if players == 0 && !pregen && !sprinting {
*empty_ticks += 1;
} else {
*empty_ticks = 0;
}
}
pause_after > 0 && *empty_ticks >= pause_after
}
/// `MinecraftServer.setAutoSave`: enable or disable periodic saves and report whether it changed.
pub(super) fn set_auto_save(lists: &Mutex<Lists>, enabled: bool) -> bool {
let mut l = lists.lock().unwrap();
@@ -1103,17 +1048,9 @@ impl Game<'_> {
// now rather than at the first tick (reading parses functions; it runs none), and a broken
// predicate graph stops the start before anything listens (a partial check: predicate_graph.rs).
self.ensure_datapacks();
let store = &self.fns.store;
let tag = |id: &str| {
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
store
.files
.contains_key(&format!("tags/{ns}:predicate/{path}"))
};
if let Err(error) = predicate_graph::check(&store.predicates, tag) {
if let Err(error) = predicate_graph::check(&self.fns.store.predicates) {
let level = match error {
predicate_graph::GraphError::Unbound(_)
| predicate_graph::GraphError::UnboundTags(_) => "ERROR",
predicate_graph::GraphError::Unbound(_) => "ERROR",
predicate_graph::GraphError::Recursive(_) => "WARN",
};
for line in error.lines() {
@@ -1434,13 +1371,16 @@ impl Game<'_> {
// Vanilla reads pauseWhenEmptySeconds on every tick, so management changes take effect
// without restarting the server.
let pause_after = state.pause_when_empty_seconds.wrapping_mul(20);
let paused = count_empty_tick(
&mut state.empty_ticks,
pause_after,
players,
self.pregen_level().is_some(),
self.clock.sprinting(),
);
if pause_after > 0 {
// A `/pregen` task keeps the server busy as a player would:
// paused, nothing would post-process its chunks.
if players == 0 && self.pregen_level().is_none() {
state.empty_ticks += 1;
} else {
state.empty_ticks = 0;
}
}
let paused = pause_after > 0 && state.empty_ticks >= pause_after;
state.paused = paused;
if paused && state.empty_ticks == pause_after {
log(
@@ -1638,15 +1578,12 @@ impl Game<'_> {
let Some(host) = self.lan.as_mut() else {
return;
};
let limit = u64::try_from(minutes).unwrap_or(0) * 1200;
let idle: Vec<u32> = host
.hub
.guests
.iter()
.filter(|g| {
let idle_ticks = host.hub.ticks.saturating_sub(g.last_action_tick);
let won_game = host.play.get(&g.conn).is_some_and(|play| play.won_game);
idles_out(idle_ticks, minutes, won_game)
})
.filter(|g| host.hub.ticks.saturating_sub(g.last_action_tick) > limit)
.map(|g| g.conn)
.collect();
for conn in idle {
@@ -1755,17 +1692,9 @@ impl Game<'_> {
/// bound to the overworld, whichever level the last server tick left in
/// place (the last one ticked, often the nether). Its commands run there
/// and the level in place is put back afterwards.
/// The source is the server's own: no entity, at the world spawn, its feedback heard
/// (the console's and an RCON client's always is).
fn as_console_in_overworld<T>(&mut self, f: impl FnOnce(&mut Self) -> T) -> T {
let back = console_level(self.dim).and_then(|dim| self.enter_command_level(dim));
let source = super::commands::execute::Source {
silent: false,
..self.server_source()
};
let outer = self.command_source.replace(source);
let out = self.as_console(f);
self.command_source = outer;
if let Some(back) = back {
self.enter_command_level(back);
}
@@ -1983,7 +1912,12 @@ impl Game<'_> {
return;
};
let reason = (rest.len() > 1).then(|| rest[1..].join(" "));
let added = state.lists.lock().unwrap().ban(&user, reason.clone());
let added = state.lists.lock().unwrap().bans.add(BanEntry::new(
user.clone(),
Some("Server".into()),
None,
reason.clone(),
));
if added {
self.console_reply(Text::translate(
"commands.ban.success",
@@ -2023,7 +1957,12 @@ impl Game<'_> {
return;
};
let reason = (rest.len() > 1).then(|| rest[1..].join(" "));
let added = state.lists.lock().unwrap().ban_ip(&ip, reason.clone());
let added = state.lists.lock().unwrap().ip_bans.add(BanEntry::new(
ip.clone(),
Some("Server".into()),
None,
reason.clone(),
));
if !added {
self.console_reply(Text::translate("commands.banip.failed", &[]));
return;
@@ -2641,23 +2580,6 @@ mod tests {
assert!(parse_whitelist_bypass("not-a-uuid-at-all").is_err());
}
/// ServerGamePacketListenerImpl.tick: a player idle past `player-idle-timeout` minutes is kicked,
/// unless the End poem is showing (`ServerPlayer.wonGame`), which sends no action.
#[test]
fn a_player_watching_the_end_credits_is_not_kicked_for_idling() {
use super::idles_out;
// 5 minutes: 6000 ticks. One more is over the limit.
assert!(!idles_out(6000, 5, false));
assert!(idles_out(6001, 5, false));
assert!(!idles_out(6001, 5, true));
assert!(!idles_out(1_000_000, 5, true));
// The credits are over (won_game false again): the same idle time kicks.
assert!(idles_out(1_000_000, 5, false));
// 0 or less: no timeout.
assert!(!idles_out(1_000_000, 0, false));
assert!(!idles_out(1_000_000, -3, false));
}
/// `SaveAllCommand.saveAll`: a save that throws is the `ERROR_FAILED` the
/// source is told about, not a log line the caller never sees.
#[test]
@@ -2682,51 +2604,6 @@ mod tests {
);
}
/// MinecraftServer.tickServer: a tick only counts toward pause-when-empty with no player and
/// no `/tick sprint` running (`ServerTickRateManager.isSprinting`); a sprint on an empty server
/// keeps the count at 0, so it is never paused, and the count starts again after it.
#[test]
fn a_sprint_on_an_empty_server_does_not_count_toward_the_pause() {
let pause_after = 60 * 20;
let mut empty_ticks = 0;
// One server tick: (players, a `/pregen` task, a `/tick sprint`) -> paused.
let tick = |empty_ticks: &mut i32, players, pregen, sprinting| {
super::count_empty_tick(empty_ticks, pause_after, players, pregen, sprinting)
};
for _ in 0..2000 {
assert!(!tick(&mut empty_ticks, 0, false, true));
}
assert_eq!(empty_ticks, 0);
// The sprint ended: 1200 empty ticks later the server pauses, not before.
for n in 1..=pause_after {
assert_eq!(
tick(&mut empty_ticks, 0, false, false),
n == pause_after,
"tick {n}"
);
}
// A sprint that starts while paused resumes the server at once.
assert!(!tick(&mut empty_ticks, 0, false, true));
assert_eq!(empty_ticks, 0);
// A player, or a `/pregen` task, resets the count too.
empty_ticks = 5;
assert!(!tick(&mut empty_ticks, 1, false, false));
assert_eq!(empty_ticks, 0);
empty_ticks = 5;
assert!(!tick(&mut empty_ticks, 0, true, false));
assert_eq!(empty_ticks, 0);
// pause-when-empty-seconds=0 never pauses and leaves the count alone.
empty_ticks = 5;
assert!(!super::count_empty_tick(
&mut empty_ticks,
0,
0,
false,
false
));
assert_eq!(empty_ticks, 5);
}
/// A `panic!("literal")` carries a `&str`, a formatted one a `String`; anything else is
/// named as unknown, so the crash line is never empty.
#[test]
@@ -2836,46 +2713,6 @@ mod tests {
}
}
/// `BanPlayerCommands.banPlayers` skips a player who `isBanned`, and `BanIpCommands.banIp` refuses an
/// address that is: the console's `/ban` and `/ban-ip` of one banned already change nothing (the first
/// reason stays, and the console hears "Nothing changed"), whatever reason they give.
#[test]
fn the_console_does_not_ban_again_who_is_banned_already() {
use super::{BanList, Lists, NameAndId};
let dir = std::env::temp_dir().join(format!("horizon-console-ban-{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
let mut lists = Lists {
ops: super::OpList::new(dir.join("ops.json")),
white: super::WhiteList::new(dir.join("whitelist.json")),
bans: BanList::new(dir.join("banned-players.json")),
ip_bans: super::IpBanList::new(dir.join("banned-ips.json")),
use_whitelist: false,
enforce_whitelist: false,
op_level: 4,
player_idle_timeout: 0,
stop: false,
autosave: true,
whitelist_bypass: Vec::new(),
};
let alpha = NameAndId {
id: 0x5ca1ab1e,
name: "Alpha".into(),
};
assert!(lists.ban(&alpha, Some("cheating".into())));
assert!(!lists.ban(&alpha, Some("again".into())));
assert!(!lists.ban(&alpha, None));
let reason = lists.bans.get(&alpha).and_then(|b| b.reason.clone());
assert_eq!(reason.as_deref(), Some("cheating"));
assert!(lists.ban_ip("1.2.3.4", Some("spam".into())));
assert!(!lists.ban_ip("1.2.3.4", Some("more spam".into())));
let reason = lists
.ip_bans
.get(&"1.2.3.4".to_owned())
.and_then(|b| b.reason.clone());
assert_eq!(reason.as_deref(), Some("spam"));
let _ = fs::remove_dir_all(&dir);
}
/// A temporary ban (`/tempban`, the chat filter's) keeps the player out and says when it ends;
/// once it has ended, or after `/pardon`, they come in.
#[test]
@@ -2959,85 +2796,3 @@ mod tests {
fs::remove_dir_all(dir).unwrap();
}
}
#[cfg(test)]
mod console_source_tests {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::chunk::Chunk;
use horizon_core::position::ChunkPos;
use std::sync::Arc;
/// Runs `command` as the console does and returns whether it worked and every message it printed.
fn console(game: &mut Game<'_>, command: &str) -> (bool, String) {
game.chat_capture = Some(Vec::new());
let ok = game.as_console_in_overworld(|g| g.run_command(command));
let said = game.chat_capture.take().unwrap_or_default();
let said = said
.iter()
.map(|t| t.component().to_string())
.collect::<Vec<_>>()
.join("\n");
(ok, said)
}
/// `MinecraftServer.createCommandSourceStack`: the console's source has no entity and is named "Server"
/// (an RCON client's, `RconConsoleSource`, "Rcon"): `/me` says so, and `teammsg`, which needs an entity,
/// refuses it (`permissions.requires.entity`) instead of running as the player in place.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
fn the_console_runs_as_the_server_not_the_player_in_place() {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("prepared resources required"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
let (ok, said) = console(&mut game, "me waves");
assert!(
ok && said.contains("chat.type.emote") && said.contains("\"Server\""),
"{said}"
);
game.rcon_source = true;
let (ok, said) = console(&mut game, "me waves");
assert!(ok && said.contains("\"Rcon\""), "{said}");
game.rcon_source = false;
let (ok, said) = console(&mut game, "teammsg hi");
assert!(
!ok && said.contains("permissions.requires.entity"),
"{said}"
);
// The console's source is put back after each line.
assert!(game.command_source.is_none());
}
}
@@ -1,16 +1,13 @@
//! A partial check of the world's predicate graph before a dedicated server is Ready.
//!
//! Vanilla refuses to start a world whose packs' predicates reference a predicate no pack defines
//! (`MappedRegistry.freeze`: "Unbound values in registry"), name a predicate tag no pack defines
//! ("Unbound tags in registry"), or reference each other in a cycle
//! (`MappedRegistry.freeze`: "Unbound values in registry"), or reference each other in a cycle
//! (`ReloadableServerRegistries.validateLootRegistries`: a reference "is recursively called").
//!
//! This is new, partial validation, not a port of `RegistryDataLoader` or `ValidationContext`. It
//! follows a predicate's holders only where the condition codecs hold one: `all_of` and `any_of`
//! `terms`, and `inverted` `term`. A holder is an identifier (a reference) or an inline condition,
//! which is followed in the same way. `terms` is a `HolderSetCodec`: a list of holders, one holder
//! alone (`compactListCodec`, at `.terms[0]`), or a `#tag`. A tag must be defined; the entries of a
//! defined tag are not followed. Every other condition type and field, an identifier that does
//! which is followed in the same way. Every other condition type and field, an identifier that does
//! not parse, and the holders of loot tables, advancements and item modifiers are not checked.
use horizon_client_core::identifier::parse_identifier;
use serde_json::Value;
@@ -23,8 +20,6 @@ const REGISTRY: &str = "minecraft:predicate";
pub(super) enum GraphError {
/// Referenced ids that no predicate binds.
Unbound(BTreeSet<String>),
/// Predicate tags that a `terms` names and no pack defines.
UnboundTags(BTreeSet<String>),
/// References that re-enter a predicate while it is being validated.
Recursive(Vec<Recursion>),
}
@@ -44,10 +39,6 @@ impl GraphError {
"Unbound values in registry ResourceKey[minecraft:root / {REGISTRY}]: [{}]",
ids.iter().cloned().collect::<Vec<_>>().join(", ")
)],
GraphError::UnboundTags(ids) => vec![format!(
"Unbound tags in registry ResourceKey[minecraft:root / {REGISTRY}]: [{}]",
ids.iter().cloned().collect::<Vec<_>>().join(", ")
)],
GraphError::Recursive(found) => found
.iter()
.map(|r| {
@@ -71,16 +62,14 @@ fn holders(condition: &Value) -> Vec<(String, &Value)> {
return Vec::new();
};
match ty.as_str() {
"minecraft:all_of" | "minecraft:any_of" => match condition.get("terms") {
Some(Value::Array(terms)) => terms
.iter()
.enumerate()
.map(|(i, term)| (format!(".terms[{i}]"), term))
.collect(),
Some(Value::String(tag)) if tag.starts_with('#') => Vec::new(),
Some(term) => vec![(".terms[0]".to_owned(), term)],
None => Vec::new(),
},
"minecraft:all_of" | "minecraft:any_of" => condition
.get("terms")
.and_then(Value::as_array)
.into_iter()
.flatten()
.enumerate()
.map(|(i, term)| (format!(".terms[{i}]"), term))
.collect(),
"minecraft:inverted" => condition
.get("term")
.map(|term| vec![(".term".to_owned(), term)])
@@ -89,22 +78,12 @@ fn holders(condition: &Value) -> Vec<(String, &Value)> {
}
}
/// The predicate tag an `all_of` or `any_of` names as its `terms` (`#ns:path`).
fn tag(condition: &Value) -> Option<String> {
let ty = parse_identifier(condition.get("type")?.as_str()?)?;
if !matches!(ty.as_str(), "minecraft:all_of" | "minecraft:any_of") {
return None;
}
parse_identifier(condition.get("terms")?.as_str()?.strip_prefix('#')?)
}
/// Every reference and tag a condition holds, inline conditions included.
fn references(condition: &Value, out: &mut Vec<String>, tags: &mut Vec<String>) {
tags.extend(tag(condition));
/// Every reference a condition holds, inline conditions included.
fn references(condition: &Value, out: &mut Vec<String>) {
for (_, holder) in holders(condition) {
match holder {
Value::String(name) => out.extend(parse_identifier(name)),
Value::Object(_) => references(holder, out, tags),
Value::Object(_) => references(holder, out),
_ => {}
}
}
@@ -146,17 +125,12 @@ fn walk<'v>(
}
}
/// The world's predicates (by normalized id, after pack precedence) and whether a pack defines a
/// predicate tag: missing references first, then missing tags, as the registry freezes before
/// anything is validated, then recursive references.
pub(super) fn check(
predicates: &BTreeMap<String, Value>,
has_tag: impl Fn(&str) -> bool,
) -> Result<(), GraphError> {
/// The world's predicates (by normalized id, after pack precedence): missing references first, as
/// the registry freezes before anything is validated, then recursive ones.
pub(super) fn check(predicates: &BTreeMap<String, Value>) -> Result<(), GraphError> {
let mut referenced = Vec::new();
let mut tags = Vec::new();
for condition in predicates.values() {
references(condition, &mut referenced, &mut tags);
references(condition, &mut referenced);
}
let unbound: BTreeSet<String> = referenced
.into_iter()
@@ -165,10 +139,6 @@ pub(super) fn check(
if !unbound.is_empty() {
return Err(GraphError::Unbound(unbound));
}
let unbound: BTreeSet<String> = tags.into_iter().filter(|id| !has_tag(id)).collect();
if !unbound.is_empty() {
return Err(GraphError::UnboundTags(unbound));
}
let mut found = Vec::new();
for (id, condition) in predicates {
let mut stack = vec![id.as_str()];
@@ -223,7 +193,7 @@ mod tests {
// An identifier that does not parse is not checked here.
("a:v", json!({"type": "inverted", "term": "Not An Id"})),
]);
assert_eq!(check(&predicates, |_| false), Ok(()));
assert_eq!(check(&predicates), Ok(()));
}
#[test]
@@ -235,7 +205,7 @@ mod tests {
),
("a:c", json!({"type": "inverted", "term": "a:c"})),
]);
let error = check(&predicates, |_| false).unwrap_err();
let error = check(&predicates).unwrap_err();
assert_eq!(
error,
GraphError::Unbound(["a:gone".to_owned(), "b:none".to_owned()].into())
@@ -264,7 +234,7 @@ mod tests {
json!({"type": "any_of", "terms": [{"type": "inverted", "term": "prg:self"}]}),
),
]);
let mut lines = check(&predicates, |_| false).unwrap_err().lines();
let mut lines = check(&predicates).unwrap_err().lines();
lines.sort();
// The two cycle lines are the ones vanilla 26.3 logged for this pack.
assert_eq!(
@@ -276,74 +246,4 @@ mod tests {
]
);
}
#[test]
fn a_lone_terms_holder_is_terms_0_and_a_tag_must_be_defined() {
let target = json!({"type": "minecraft:all_of", "terms": []});
// Vanilla 26.3 starts a world whose `terms` is one bound holder alone.
let valid = graph(&[
("prg:target", target.clone()),
("prg:ref", json!({"type": "all_of", "terms": "prg:target"})),
(
"prg:inline",
json!({"type": "any_of", "terms": {"type": "inverted", "term": "prg:target"}}),
),
(
"prg:tagged",
json!({"type": "any_of", "terms": "#prg:some"}),
),
]);
assert_eq!(check(&valid, |tag| tag == "prg:some"), Ok(()));
let missing = graph(&[
("prg:target", target.clone()),
(
"prg:ref",
json!({"type": "minecraft:all_of", "terms": "prg:missing"}),
),
(
"prg:tagged",
json!({"type": "all_of", "terms": "#prg:none"}),
),
]);
// Missing values first, then missing tags: the lines vanilla 26.3 logged for each.
assert_eq!(
check(&missing, |_| false).unwrap_err().lines(),
[
"Unbound values in registry ResourceKey[minecraft:root / minecraft:predicate]: [prg:missing]"
]
);
let tags = graph(&[
("prg:target", target.clone()),
(
"prg:ref",
json!({"type": "minecraft:all_of", "terms": "#prg:none"}),
),
]);
assert_eq!(
check(&tags, |_| false).unwrap_err().lines(),
[
"Unbound tags in registry ResourceKey[minecraft:root / minecraft:predicate]: [prg:none]"
]
);
let cycle = graph(&[
("prg:target", target),
(
"prg:ref",
json!({"type": "minecraft:any_of", "terms": "prg:loop"}),
),
(
"prg:loop",
json!({"type": "minecraft:inverted", "term": "prg:ref"}),
),
]);
let mut lines = check(&cycle, |_| false).unwrap_err().lines();
lines.sort();
assert_eq!(
lines,
[
"Found loot table element validation problem in {prg:loop@minecraft:predicate}.term->{prg:ref@minecraft:predicate}.terms[0]: prg:loop of type minecraft:predicate is recursively called",
"Found loot table element validation problem in {prg:ref@minecraft:predicate}.terms[0]->{prg:loop@minecraft:predicate}.term: prg:ref of type minecraft:predicate is recursively called",
]
);
}
}
@@ -982,7 +982,6 @@ impl Game<'_> {
items: [stack],
ticks: 0,
playing: false,
loaded_clock: false,
}),
);
}
@@ -1443,9 +1442,6 @@ impl Game<'_> {
ProjectileKind::Egg => "minecraft:egg",
ProjectileKind::SmallFireball => "minecraft:small_fireball",
ProjectileKind::WindCharge => "minecraft:wind_charge",
ProjectileKind::Potion(_, true) => "minecraft:lingering_potion",
ProjectileKind::Potion(..) => "minecraft:splash_potion",
ProjectileKind::ExperienceBottle => "minecraft:experience_bottle",
_ => "minecraft:unknown_projectile",
};
let (p, v) = (shot.position, shot.velocity);
+16 -14
View File
@@ -12,6 +12,7 @@ use horizon_client::{
mobs::{Mob, Species, sulfur_cube},
particles::{ParticleOptions, ParticleType},
projectiles::{Projectile, ProjectileKind},
random_source,
};
// Its old home, for mixin code that names it here.
pub(super) use horizon_client::mod_api::NativeDispensed;
@@ -1197,9 +1198,9 @@ impl Game<'_> {
"minecraft:experience_bottle"
| "minecraft:splash_potion"
| "minecraft:lingering_potion" => {
let mut shot = super::potions::thrown_projectile(one, at, false);
shot.shoot_own(dir, power, uncertainty);
self.add_projectile(shot);
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
shot.shoot(&mut random_source::entity(), dir, power, uncertainty);
self.launch_thrown(one, at, shot.velocity, None);
}
_ => {
let kind = match item.as_str() {
@@ -1266,18 +1267,14 @@ impl Game<'_> {
self.next_entity += 1;
let (_, uuid) = horizon_client::mobs::living_random();
self.persist.uuids.insert(self.next_entity, uuid);
let stand =
let mut stand =
horizon_client::entities::ArmorStand::new(self.next_entity, foot, y_rot(facing));
// Entity.applyImplicitComponents: a name from an anvil names the stand.
if let Some(name) = stack.custom_name() {
stand.custom_name = Some(horizon_client::text::Text::literal(name));
}
self.world.modified.insert((front.0 >> 4, front.2 >> 4));
self.armor_stands.push(stand);
// EntityType.appendDefaultStackConfig: the dispenser's yaw first, then
// the item's createDefaultStackConfig with no user.
self.apply_item_stack_config(
super::commands::Target::Stand(self.next_entity),
"minecraft:armor_stand",
&stack,
false,
);
(shrink(stack), CLICK)
}
/// A tame donkey, mule or llama in front without a chest gets it.
@@ -1346,7 +1343,7 @@ impl Game<'_> {
self.boats.push(boat);
// BoatDispenseItemBehavior: createDefaultStackConfig with no user,
// then setYRot(direction.toYRot()).
self.apply_item_stack_config(super::commands::Target::Boat(id), &kind, &stack, false);
self.apply_vehicle_stack_config(super::commands::Target::Boat(id), &kind, &stack, false);
self.set_boat_yaw(id, yaw);
if self.serving.is_some() || self.options.dedicated.is_some() {
let boat = self.boats.last().unwrap();
@@ -1396,7 +1393,12 @@ impl Game<'_> {
self.carts.push(cart);
// AbstractMinecart.createMinecart: createDefaultStackConfig with no user.
let id = self.next_entity;
self.apply_item_stack_config(super::commands::Target::Cart(id), kind.id(), &stack, false);
self.apply_vehicle_stack_config(
super::commands::Target::Cart(id),
kind.id(),
&stack,
false,
);
if self.serving.is_some() || self.options.dedicated.is_some() {
let cart = self.carts.last().unwrap();
self.native_vehicle_create(
+35 -14
View File
@@ -191,12 +191,22 @@ pub(super) struct Crystal {
pub beam: Option<[i32; 3]>,
/// `setPermanentlyInvulnerable` (respawned spike crystals).
pub invulnerable: bool,
/// `onGround` and `fallDistance`: a crystal never moves itself, so they stay
/// as loaded until `/tp` grounds it or a cobweb or powder snow resets the
/// fall (`crystal.rs`).
pub fall: (bool, f64),
}
impl Crystal {
/// The start of `time` (the spin and bob) for a crystal at `position`: the random draw
/// vanilla makes, or under `HORIZON_STILL` one seeded from the position alone. Which crystal
/// gets which draw, and a server's entity ids, follow the order chunks load in, which differs
/// between launches; the position is the same in every copy of the world. The draw is made
/// either way, so the random stays where it would be.
pub(super) fn start_time(position: Vec3, drawn: i32, still: bool) -> i32 {
if still {
horizon_client::mobs::entity_random(0, position)
.next_int_bound(100_000)
.unwrap_or(0)
} else {
drawn
}
}
/// `EntityType.END_CRYSTAL`: 2 x 2.
pub(super) fn bounds(&self) -> Aabb {
let p = self.position;
@@ -1281,8 +1291,14 @@ impl Game<'_> {
let time = Crystal::start_time(at, drawn, self.clock.still());
// EndCrystal's constructor takes an id from the entity counter.
self.next_entity += 1;
self.crystals
.push(Crystal::new(self.next_entity, at, true, time));
self.crystals.push(Crystal {
id: self.next_entity,
position: at,
show_bottom: true,
time,
beam: None,
invulnerable: false,
});
self.world
.modified
.insert((at.x.floor() as i32 >> 4, at.z.floor() as i32 >> 4));
@@ -1312,7 +1328,6 @@ impl Game<'_> {
.flatten();
for i in 0..self.crystals.len() {
self.crystals[i].time += 1;
self.crystal_block_effects(i);
let p = self.crystals[i].position;
let b = [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32];
if let Some(fire) = fire
@@ -3651,7 +3666,7 @@ impl Crystal {
self.position,
Vec3::ZERO,
[0.0, 0.0],
self.fall,
(false, 0.0),
uuid,
raw,
);
@@ -3679,7 +3694,6 @@ impl Crystal {
_ => None,
},
invulnerable: horizon_client::entity_nbt::flag(tag, "Invulnerable"),
fall: horizon_client::entity_nbt::ground_from_nbt(tag),
})
}
}
@@ -3902,8 +3916,14 @@ impl Game<'_> {
// EndCrystal's constructor takes an id from the entity counter, so
// the crystal is an entity a command can name (`@e[type=end_crystal]`).
self.next_entity += 1;
self.crystals
.push(Crystal::new(self.next_entity, at, false, time));
self.crystals.push(Crystal {
id: self.next_entity,
position: at,
show_bottom: false,
time,
beam: None,
invulnerable: false,
});
// EndCrystalItem.useOn: ENTITY_PLACE at the block above, by the player.
self.post_block_game_event("minecraft:entity_place", above, self.sculk_actor(), None);
self.world.modified.insert((above[0] >> 4, above[2] >> 4));
@@ -4199,9 +4219,12 @@ impl Game<'_> {
let drawn = self.world.random.next_int_bound(100_000).unwrap_or(0);
let time = Crystal::start_time(at, drawn, self.clock.still());
self.crystals.push(Crystal {
id: 0,
position: at,
show_bottom: true,
time,
beam: Some([0, 128, 0]),
invulnerable: true,
..Crystal::new(0, at, true, time)
});
let _ = self.set_state_named([cx, h, cz], "minecraft:bedrock", &[]);
let _ = self.set_state_named([cx, h + 1, cz], "minecraft:fire", &[]);
@@ -4746,7 +4769,6 @@ impl Game<'_> {
}
}
mod crystal;
mod death_render;
use death_render::{dissolve_color, dissolve_mask, dragon_rays};
@@ -5711,7 +5733,6 @@ mod dragon_fireball_spawn_tests {
.insert((x, z), Arc::new(Chunk::empty(x, z)));
}
}
game.load_ticking_neighbourhood(0, 0);
assert!(game.run_command("forceload add 0 0"));
game
}
@@ -1,60 +0,0 @@
//! An end crystal's construction and the blocks its tick reads.
use super::*;
impl Crystal {
/// `new EndCrystal(level, x, y, z)`: no beam, not invulnerable, on no
/// ground with no fall (what `Entity`'s constructor leaves).
pub(in crate::game) fn new(id: u32, position: Vec3, show_bottom: bool, time: i32) -> Self {
Self {
id,
position,
show_bottom,
time,
beam: None,
invulnerable: false,
fall: (false, 0.0),
}
}
/// The start of `time` (the spin and bob) for a crystal at `position`: the random draw
/// vanilla makes, or under `HORIZON_STILL` one seeded from the position alone. Which crystal
/// gets which draw, and a server's entity ids, follow the order chunks load in, which differs
/// between launches; the position is the same in every copy of the world. The draw is made
/// either way, so the random stays where it would be.
pub(in crate::game) fn start_time(position: Vec3, drawn: i32, still: bool) -> i32 {
if still {
horizon_client::mobs::entity_random(0, position)
.next_int_bound(100_000)
.unwrap_or(0)
} else {
drawn
}
}
}
impl Game<'_> {
/// `EndCrystal.tick`'s `applyEffectsFromBlocks` for crystal `i`, which never
/// moves: a cobweb or powder snow in its box (2 x 2 x 2, deflated by
/// `1.0E-5F`) calls `makeStuckInBlock`, which resets its fall distance.
/// Powder snow's inside shape for a fall over 2.5 is only 0.9 tall
/// (`FALLING_COLLISION_SHAPE`). A bubble column's reset and `stepOn` for a
/// grounded crystal are not ported.
pub(super) fn crystal_block_effects(&mut self, i: usize) {
let (p, fall) = (self.crystals[i].position, self.crystals[i].fall.1);
let e = f64::from(1.0e-5_f32);
let lo = [p.x - 1.0 + e, p.y + e, p.z - 1.0 + e].map(|v| v.floor() as i32);
let hi = [p.x + 1.0 - e, p.y + 2.0 - e, p.z + 1.0 - e].map(|v| v.floor() as i32);
let facts = self.res.facts();
let stuck = (lo[0]..=hi[0]).any(|x| {
(lo[1]..=hi[1]).any(|y| {
(lo[2]..=hi[2]).any(|z| match &*facts.get(self.world.block(x, y, z)).name {
"minecraft:cobweb" => true,
"minecraft:powder_snow" => fall <= 2.5 || p.y + e < f64::from(y) + 0.9,
_ => false,
})
})
});
if stuck {
self.crystals[i].fall.1 = 0.0;
}
}
}
@@ -47,7 +47,6 @@ fn game() -> Game<'static> {
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
game.load_ticking_neighbourhood(0, 0);
assert!(game.run_command("fill 0 99 0 15 99 15 stone"));
game
}
@@ -68,7 +68,6 @@ fn items_past_the_simulation_distance_enter_no_end_gateway() {
game.world
.chunks
.insert((cx, 0), Arc::new(Chunk::empty(cx, 0)));
game.load_ticking_neighbourhood(cx, 0);
game.world.set(x, 64, 8, gateway).unwrap();
game.dropped
.push(item(id, Vec3::new(f64::from(x) + 0.5, 64.2, 8.5)));
@@ -36,7 +36,6 @@ fn a_mob_presses_a_plate_only_inside_the_simulation_distance() {
game.world
.chunks
.insert((cx, 0), Arc::new(Chunk::empty(cx, 0)));
game.load_ticking_neighbourhood(cx, 0);
game.world.set(x, 63, 8, stone).unwrap();
game.world.set(x, 64, 8, plate).unwrap();
game.mobs.push(Mob::new(
+1 -73
View File
@@ -862,36 +862,13 @@ impl Game<'_> {
let (mut items_kept, mut orbs_kept) = (0, 0);
let (mut boats_gone, mut carts_gone) = (0, 0);
let mut destroyed_contents = Vec::new();
// The entities in reach, less those a mod spares (`native_explosion_entities`); no list
// without native mods, which are the only ones to spare any.
let spared: std::collections::HashSet<u32> = if horizon_client::mod_api::mods().is_empty() {
Default::default()
} else {
let reach: Vec<u32> = self
.entity_handles()
.filter_map(|handle| {
with_entity!(self, handle, e => {
let feet = e.position();
let dist = blast_distance(center, [feet.x, feet.y, feet.z]);
(dist <= diameter).then(|| e.id())
})
})
.collect();
let mut kept = reach.clone();
self.native_explosion_entities(self.dim.id(), center, &mut kept);
let kept: std::collections::HashSet<u32> = kept.into_iter().collect();
reach.into_iter().filter(|id| !kept.contains(id)).collect()
};
for handle in self.entity_handles() {
let handle = match handle {
EntityHandle::Boat(i) => EntityHandle::Boat(i - boats_gone),
EntityHandle::Cart(i) => EntityHandle::Cart(i - carts_gone),
other => other,
};
let spare =
!spared.is_empty() && spared.contains(&with_entity!(self, handle, e => e.id()));
let hit = with_entity!(self, handle, e => self.blast_hit(e, center, diameter, &blast))
.filter(|_| !spare);
let hit = with_entity!(self, handle, e => self.blast_hit(e, center, diameter, &blast));
let mut destroyed = false;
if let Some(Hit {
dist,
@@ -1851,55 +1828,6 @@ mod tests {
})
}
/// Spares one entity from every blast (`native_explosion_entities`).
struct Spare(u32);
impl crate::mod_api::Hooks for Spare {
fn explosion_entities(
&self,
_: &mut dyn crate::mod_api::Host,
event: &mut crate::mod_api::ExplosionEntitiesEvent<'_>,
) {
event.entities.retain(|&id| id != self.0);
}
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_mod_spares_an_entity_from_a_blast() {
crate::mod_api::in_own_process(
"game::explosion::tests::a_mod_spares_an_entity_from_a_blast",
a_mod_spares_an_entity_from_a_blast_in_own_process,
);
}
/// Two husks either side of a blast: the one a mod takes out of the list is neither hurt nor
/// pushed, the other is both.
fn a_mod_spares_an_entity_from_a_blast_in_own_process() {
let mut game = crate::game::native_machines::tests::server_game();
game.build_registries();
let spare: Box<dyn crate::mod_api::Hooks> = Box::new(Spare(2));
assert!(crate::mod_api::MODS.set(vec![spare].into()).is_ok());
let kind = game.res.mob_types().get("minecraft:husk").unwrap().clone();
for (id, x) in [(1, 6.5), (2, 10.5)] {
let husk =
horizon_client::mobs::Mob::new(id, kind.clone(), Vec3::new(x, 64.0, 8.5), 0.0);
game.mobs.push(husk);
}
let full = game.mobs[0].health;
game.server_explode_from_cause(
[8.5, 64.0, 8.5],
2.0,
BlockInteraction::Keep,
false,
ExplosionSource::default().with_silence(true),
None,
)
.unwrap();
let (hit, spared) = (&game.mobs[0], &game.mobs[1]);
assert!(hit.health < full && hit.velocity != Vec3::ZERO);
assert_eq!((spared.health, spared.velocity), (full, Vec3::ZERO));
}
#[test]
fn a_mob_that_refuses_the_hit_is_still_pushed() {
use horizon_client::mobs::{Damage, DamageKind, Mob, Species};
+1 -4
View File
@@ -4,12 +4,9 @@
//! Each hook does nothing while its extra is off and no mod uses it.
//!
//! Each Horizon extra's game glue is a child module here, so it leaves the engine in one move
//! (docs/layers.md, plan M5): `reconnect` (auto-reconnect), `pvp` (the PvP & HUD options),
//! `mouse_tweaks` and `sorting` (the inventory extras).
pub(in crate::game) mod mouse_tweaks;
//! (docs/layers.md, plan M5): `reconnect` (auto-reconnect), `pvp` (the PvP & HUD options).
pub(in crate::game) mod pvp;
mod reconnect;
mod sorting;
use super::{Dim, Game, Screen, native_hooks::NativeMinimap};
use horizon_client::{
@@ -1,155 +0,0 @@
//! Sorting (`sort`, `sortKey`; #332, `docs/extras.md`), off by default: a small button by a
//! menu's storage slots and one by the inventory's (an optional key sorts the part under the
//! cursor). The clicks come from `inv_sort::plan` and go through the Mouse Tweaks' ordinary slot
//! clicks (`mouse_tweaks.rs`), so they work wherever vanilla's do.
use crate::game::Game;
use horizon_client::{
hud::{HudBatch, rgba},
inv_sort,
items::ItemStack,
keybinds,
menu::Input,
stations::hover_name,
};
use winit::{event::MouseButton, keyboard::KeyCode};
/// A sort button's side in GUI pixels.
const SORT_BUTTON: f32 = 9.0;
impl Game<'_> {
/// The sort buttons of the open screen, with the extra on: their
/// corner (from the screen's) and the slots each sorts. Any menu screen
/// gets them, by its slots' roles (`native_slot_roles`): one by the top
/// right of its storage slots (a chest's title line) when it has some,
/// one by the main inventory's (the "Inventory" label's line).
fn sort_buttons(&self) -> Vec<((f32, f32), Vec<usize>)> {
use crate::game::NativeSlotRole as Role;
if !self.settings.extras.sort {
return Vec::new();
}
let roles = self.native_slot_roles();
let of = |role| -> Vec<usize> {
roles
.iter()
.filter(|(_, r)| *r == role)
.map(|(s, _)| *s)
.collect()
};
// Over the section's last column: (160, 5) over a chest's slots.
let right = |slots: &[usize]| {
slots
.iter()
.map(|&s| self.slot_position(s).0 + 8.0)
.fold(f32::MIN, f32::max)
};
let mut buttons = Vec::new();
let storage = of(Role::Storage);
if !storage.is_empty() {
let top = storage
.iter()
.map(|&s| self.slot_position(s).1 - 13.0)
.fold(f32::MAX, f32::min);
buttons.push(((right(&storage), top), storage));
}
// On the "Inventory" label's line (`inventoryLabelY`, height - 94, 12
// above the first row): a mod's screen places its inventory itself.
let main = of(Role::Inventory);
if !main.is_empty() {
let y = self
.native_screen_inventory_y()
.map_or(self.screen_height() - 95.0, |y| y - 13.0);
buttons.push(((right(&main), y), main));
}
buttons
}
/// The sort buttons: a bordered square with three bars, lighter under
/// the cursor.
pub(in crate::game) fn draw_sort_buttons(&self, batch: &mut HudBatch, ox: f32, oy: f32) {
let atlas = &self.gui.atlas;
for ((x, y), _) in self.sort_buttons() {
let (x, y) = (ox + x, oy + y);
let (cx, cy) = self.cursor;
let hovered = cx >= x && cx < x + SORT_BUTTON && cy >= y && cy < y + SORT_BUTTON;
let face = if hovered {
rgba(220, 220, 220, 255)
} else {
rgba(160, 160, 160, 255)
};
batch.fill(atlas, x, y, SORT_BUTTON, SORT_BUTTON, rgba(55, 55, 55, 255));
batch.fill(
atlas,
x + 1.0,
y + 1.0,
SORT_BUTTON - 2.0,
SORT_BUTTON - 2.0,
face,
);
for (i, w) in [5.0, 4.0, 3.0].into_iter().enumerate() {
let by = y + 2.0 + 2.0 * i as f32;
batch.fill(atlas, x + 2.0, by, w, 1.0, rgba(55, 55, 55, 255));
}
}
}
/// A left press on a sort button sorts its slots (true).
pub(in crate::game) fn sort_press(&mut self) -> bool {
let Some(r) = self.renderer.as_ref() else {
return false;
};
let batch = HudBatch::new([r.config.width, r.config.height]);
let (ox, oy) = self.inventory_origin(&batch);
let (cx, cy) = self.cursor;
let hit = self.sort_buttons().into_iter().find(|((x, y), _)| {
let (x, y) = (ox + x, oy + y);
cx >= x && cx < x + SORT_BUTTON && cy >= y && cy < y + SORT_BUTTON
});
let Some((_, slots)) = hit else {
return false;
};
self.sort_slots(&slots);
true
}
/// The sort key: sorts the part under the cursor (the container when
/// the cursor is over no slot).
pub(in crate::game) fn sort_key_press(&mut self, key: KeyCode) -> bool {
keybinds::key_name(key).is_some_and(|name| self.sort_input(name))
}
pub(in crate::game) fn sort_mouse_press(&mut self, button: MouseButton) -> bool {
self.sort_input(&keybinds::mouse_name(button))
}
fn sort_input(&mut self, name: &str) -> bool {
let e = &self.settings.extras;
if !e.sort || e.sort_key != name || self.recipe_search_focused() {
return false;
}
let buttons = self.sort_buttons();
let hovered = self.inventory_target().0;
let slots = buttons
.iter()
.find(|(_, slots)| hovered.is_some_and(|h| slots.contains(&h)))
.or(buttons.first())
.map(|(_, slots)| slots.clone());
let Some(slots) = slots else {
return false;
};
self.sort_slots(&slots);
true
}
/// Sorts `slots` with ordinary left clicks (needs an empty hand).
fn sort_slots(&mut self, slots: &[usize]) {
if self.carried.is_some() {
return;
}
let views: Vec<Option<ItemStack>> = slots.iter().map(|&s| self.slot_view(s)).collect();
let clicks = {
let rank = |s: &ItemStack| {
let order = self.creative_screen.order_of(s).unwrap_or(usize::MAX);
(order, hover_name(&self.pack.names, s))
};
let max = |s: &ItemStack| self.max_stack(s);
inv_sort::plan(&views, &rank, &max)
};
for i in clicks {
self.tweak_click(slots[i], 0, Input::Pickup);
}
}
}
@@ -44,7 +44,6 @@ fn loaded_air_is_discarded_on_first_actual_game_tick() {
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
game.load_ticking_neighbourhood(0, 0);
assert!(game.run_command("forceload add 0 0"));
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
assert!(game.res.facts().is_air(game.world.block(4, 204, 4)));
@@ -67,9 +67,6 @@ fn game() -> Game<'static> {
.add_empty_chunk(ChunkPos::new(x, 0))
.unwrap();
}
for x in 0..3 {
game.load_ticking_neighbourhood(x, 0);
}
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(24.5, 201.0, 12.5)).unwrap());
let net = NetConfig {
announce: false,
-1
View File
@@ -273,7 +273,6 @@ mod item_collision_tests {
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
game.load_ticking_neighbourhood(0, 0);
assert!(game.run_command("forceload add 0 0"));
assert!(game.run_command("summon minecart 4.5 200 4.5"));
assert!(game.run_command("summon oak_boat 10.5 200 10.5"));
@@ -55,9 +55,6 @@ fn game() -> Game<'static> {
.chunks
.insert((x, 0), Arc::new(Chunk::empty(x, 0)));
}
for x in 0..2 {
game.load_ticking_neighbourhood(x, 0);
}
assert!(game.run_command("fill 0 99 0 31 99 15 stone"));
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(28.5, 100.0, 10.5)).unwrap());
let net = NetConfig {
@@ -56,9 +56,6 @@ fn game() -> Game<'static> {
.add_empty_chunk(ChunkPos::new(cx, 0))
.unwrap();
}
for cx in 0..2 {
game.load_ticking_neighbourhood(cx, 0);
}
for command in [
"difficulty normal",
"forceload add 0 0 31 15",
@@ -49,7 +49,6 @@ fn game() -> Game<'static> {
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
game.load_ticking_neighbourhood(0, 0);
for command in [
"forceload add 0 0",
"fill 0 99 0 15 99 15 stone",
+10 -25
View File
@@ -694,7 +694,7 @@ impl Game<'_> {
position: [f64; 3],
velocity: [f64; 3],
) {
self.add_item_entity_drawn(stack, position, Some(velocity), true, 0, 0);
self.add_item_entity_drawn(stack, position, Some(velocity), true, 0);
}
pub(super) fn add_item_entity(
@@ -704,21 +704,7 @@ impl Game<'_> {
velocity: Option<[f64; 3]>,
pickup_delay: u32,
) -> [f64; 3] {
self.add_item_entity_drawn(
stack,
position,
velocity,
velocity.is_none(),
pickup_delay,
0,
)
}
/// `spawnAtLocation` for a drop that then calls
/// `ItemEntity.setExtendedLifetime`: the item joins the world at `age`
/// instead of 0, so the wither's nether star lives 12000 ticks, not 6000.
pub(super) fn spawn_item_aged(&mut self, stack: ItemStack, position: [f64; 3], age: i32) {
self.add_item_entity_drawn(stack, position, None, true, 10, age);
self.add_item_entity_drawn(stack, position, velocity, velocity.is_none(), pickup_delay)
}
/// `add_item_entity`; `constructor_motion` draws the five-argument
@@ -730,7 +716,6 @@ impl Game<'_> {
velocity: Option<[f64; 3]>,
constructor_motion: bool,
pickup_delay: u32,
age: i32,
) -> [f64; 3] {
let (item, count) = (stack.item.clone(), stack.count as i32);
self.next_entity += 1;
@@ -753,7 +738,6 @@ impl Game<'_> {
rotation: [yaw, 0.0],
pickup_delay: i64::from(pickup_delay),
random: own,
age,
..ItemEntity::new(
self.next_entity,
stack,
@@ -1033,10 +1017,14 @@ mod escape_tests {
let mut game = server_game();
let feet = horizon_core::geometry::Vec3::new(8.5, 70.0, 8.5);
game.player = Some(horizon_client::motion::PlayerMotion::new(game.motion, feet).unwrap());
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.load_ticking_neighbourhood(0, 0);
// Entity-ticking chunks also need the surrounding loaded chunks.
for x in -2..=2 {
for z in -2..=2 {
game.world
.chunks
.insert((x, z), Arc::new(Chunk::empty(x, z)));
}
}
game.update_chunk_tickets();
assert!(game.entity_ticking_area()(8, 8));
let stone = game
@@ -1068,6 +1056,3 @@ mod escape_tests {
#[cfg(test)]
#[path = "item_void_tests.rs"]
mod item_void_tests;
#[cfg(test)]
#[path = "wither_star_tests.rs"]
mod wither_star_tests;
+49 -117
View File
@@ -8,7 +8,7 @@ use horizon_client::{
containers::{BlockEntity, Jukebox},
first_person::Hand,
items::ItemStack,
mobs::{Species, Who},
mobs::Species,
particles::{ParticleOptions, ParticleType},
text::Text,
};
@@ -53,49 +53,33 @@ impl Game<'_> {
_ => None,
}
}
fn set_has_record(&mut self, pos: [i32; 3], has: bool) -> bool {
self.set_has_record_notify(pos, has, true)
fn set_has_record(&mut self, pos: [i32; 3], has: bool) {
self.set_has_record_notify(pos, has, true);
}
/// The state change alone (`level.setBlock` with flag 2); `notify` adds
/// `notifyItemChangedInJukebox`'s `block_change`, which
/// `JukeboxBlock.setPlacedBy` does not post.
fn set_has_record_notify(&mut self, pos: [i32; 3], has: bool, notify: bool) -> bool {
fn set_has_record_notify(&mut self, pos: [i32; 3], has: bool, notify: bool) {
let state = self.world.block(pos[0], pos[1], pos[2]);
let registry = self.pack.assets.registry;
let Some(mut s) = registry.state(state).cloned() else {
return false;
return;
};
s.properties.insert(
"has_record".into(),
if has { "true" } else { "false" }.into(),
);
let Some(new) = registry.id(&s) else {
return false;
};
let p = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| {
b.set_block(level, ticks, p, new, level_flags::UPDATE_CLIENTS)
});
if notify {
let state = self.world.block(pos[0], pos[1], pos[2]);
self.post_block_game_event("minecraft:block_change", pos, None, Some(state));
if let Some(new) = registry.id(&s) {
let p = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| {
b.set_block(level, ticks, p, new, level_flags::UPDATE_CLIENTS)
});
// `JukeboxBlockEntity.notifyItemChangedInJukebox`: the disc coming
// or going is a block change the sculk sensors hear.
if notify && self.world.block(pos[0], pos[1], pos[2]) != state {
self.post_block_game_event("minecraft:block_change", pos, None, Some(new));
}
}
true
}
/// `JukeboxBlock.HAS_RECORD`, the state both clicks are decided by.
fn jukebox_has_record(&self, pos: [i32; 3]) -> bool {
let state = self.world.block(pos[0], pos[1], pos[2]);
self.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("has_record"))
.is_some_and(|v| v == "true")
}
/// `JukeboxBlockEntity.notifyItemChangedInJukebox`: the block's
/// `has_record`, then `BLOCK_CHANGE` with the state it now has.
fn jukebox_record_changed(&mut self, pos: [i32; 3], has: bool) {
self.set_has_record(pos, has);
}
/// `JukeboxBlock.setPlacedBy`: a jukebox placed from an item whose
/// `block_entity_data` carries a `RecordItem` keeps that record, so
@@ -137,10 +121,9 @@ impl Game<'_> {
{
j.playing = true;
j.song_clock_present = true;
j.loaded_clock = false;
j.ticks = 0;
}
self.jukebox_record_changed(pos, true);
self.set_has_record(pos, true);
self.start_song(pos, &song);
self.jukebox_changed(pos);
}
@@ -158,17 +141,15 @@ impl Game<'_> {
was_playing = j.playing;
j.playing = false;
j.song_clock_present = false;
j.loaded_clock = false;
j.ticks = 0;
}
// `setTheItem(EMPTY)` first tells the block, then stops the song.
self.jukebox_record_changed(pos, false);
// `JukeboxSongPlayer.stop` posts only while a song plays.
if was_playing {
self.stop_song_heard(pos);
} else {
self.stop_song(pos);
}
self.set_has_record(pos, false);
self.jukebox_changed(pos);
}
pub(super) fn jukebox_set_item(&mut self, pos: [i32; 3], stack: Option<ItemStack>) {
@@ -183,9 +164,8 @@ impl Game<'_> {
j.items[0] = stack;
j.playing = song.is_some();
j.song_clock_present = song.is_some();
j.loaded_clock = false;
j.ticks = 0;
self.jukebox_record_changed(pos, has_record);
self.set_has_record(pos, has_record);
match song {
Some(song) => self.start_song(pos, &song),
None if was_playing => self.stop_song_heard(pos),
@@ -204,10 +184,7 @@ impl Game<'_> {
{
return false;
}
let state = self.world.block(pos[0], pos[1], pos[2]);
// The state, not the slot, decides the click: `JukeboxBlock` with a
// record pops it out, without one takes a disc.
if self.jukebox_has_record(pos) {
if self.jukebox(pos).is_some_and(|j| j.record().is_some()) {
// TRY_WITH_EMPTY_HAND: only the main hand goes on to useWithoutItem.
if self.hand != Hand::Main {
return false;
@@ -222,7 +199,7 @@ impl Game<'_> {
let Some(song) = jukebox_playable(stack, self.res.items()) else {
return false;
};
// JukeboxPlayable.tryInsertIntoJukebox: the one disc the player gives up.
// JukeboxPlayable.tryInsertIntoJukebox
let record = ItemStack {
count: 1,
..stack.clone()
@@ -230,9 +207,7 @@ impl Game<'_> {
if !self.creative {
self.consume_held();
}
// `JukeboxBlockEntity.setTheItem`: the slot takes the disc, the block
// tells its listeners, and the song plays (`play`); a disc already in
// the slot (under a state that lost its record) is replaced.
self.award_custom_stat("play_record", 1);
self.world.block_entities.insert(
(pos[0], pos[1], pos[2]),
BlockEntity::Jukebox(Jukebox {
@@ -240,23 +215,13 @@ impl Game<'_> {
ticks: 0,
playing: true,
song_clock_present: true,
loaded_clock: false,
}),
);
self.jukebox_record_changed(pos, true);
self.set_has_record(pos, true);
if let Some(song) = song {
self.start_song(pos, &song);
}
self.jukebox_changed(pos);
// `tryInsertIntoJukebox`: the same change again, against the state this
// click found, with the player as its source.
self.post_block_game_event(
"minecraft:block_change",
pos,
Some(Who::Player),
Some(state),
);
self.award_custom_stat("play_record", 1);
// ServerPlayerGameMode.useItemOn: the action was consumed.
self.trigger_block("minecraft:item_used_on_block", pos, stack);
true
@@ -270,8 +235,14 @@ impl Game<'_> {
if &*self.res.facts().get(state).name != "minecraft:jukebox" {
return false;
}
self.jukebox_has_record(pos)
|| held.is_some_and(|stack| jukebox_playable(stack, self.res.items()).is_some())
let has_record = self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("has_record"))
.is_some_and(|v| v == "true");
has_record || held.is_some_and(|stack| jukebox_playable(stack, self.res.items()).is_some())
}
/// Level event 1010: the song from the jukebox, and the "Now Playing" overlay.
fn start_song(&mut self, pos: [i32; 3], song: &str) {
@@ -346,28 +317,20 @@ impl Game<'_> {
m.extra.party_jukebox = playing.then_some(pos);
}
}
/// `popOutTheItem`: the disc above the jukebox, and the song stops. An
/// empty slot pops nothing out (`if (!itemBeforePoppingOut.isEmpty())`),
/// whatever the state says.
/// `popOutTheItem`: the disc above the jukebox, and the song stops.
fn eject_record(&mut self, pos: [i32; 3]) {
let key = (pos[0], pos[1], pos[2]);
let (record, was_playing) = match self.world.block_entities.get_mut(&key) {
let mut was_playing = false;
let record = match self.world.block_entities.get_mut(&key) {
Some(BlockEntity::Jukebox(j)) => {
if j.items[0].is_none() {
return;
}
let was_playing = j.playing;
was_playing = j.playing;
j.playing = false;
j.song_clock_present = false;
j.loaded_clock = false;
j.ticks = 0;
(j.items[0].take(), was_playing)
j.items[0].take()
}
_ => return,
_ => None,
};
// `removeTheItem` -> `setTheItem(EMPTY)`: the block loses its record,
// then the song stops.
self.jukebox_record_changed(pos, false);
// `JukeboxSongPlayer.stop` posts only while a song plays: a disc
// taken out after its song ended is silent.
if was_playing {
@@ -384,6 +347,7 @@ impl Game<'_> {
);
self.spawn_item_own(record, at, 10);
}
self.set_has_record(pos, false);
self.jukebox_changed(pos);
}
/// A broken jukebox stops its song (its disc drops with the contents).
@@ -395,11 +359,11 @@ impl Game<'_> {
let playing = match self.world.block_entities.get(&key) {
Some(BlockEntity::Jukebox(j)) if j.playing => j.record().and_then(|r| {
let song = song_of_record(r, self.items())?;
Some((song, j.ticks, j.loaded_clock))
Some((song, j.ticks))
}),
_ => None,
};
let Some((song, ticks, loaded)) = playing else {
let Some((song, ticks)) = playing else {
return;
};
let pos = [key.0, key.1, key.2];
@@ -410,26 +374,13 @@ impl Game<'_> {
if let Some(BlockEntity::Jukebox(j)) = self.world.block_entities.get_mut(&key) {
j.playing = false;
j.song_clock_present = false;
j.loaded_clock = false;
j.ticks = 0;
}
// `setSongWithoutPlaying` never adopted a clock past its song's
// end: loading such a jukebox is silent, while a song that ends
// here stops out loud.
if loaded {
return;
}
self.stop_song_heard(pos);
self.jukebox_changed(pos);
return;
}
if loaded {
// The song a save kept goes on without its sound: vanilla starts
// one only from `play` (level event 1010).
if let Some(BlockEntity::Jukebox(j)) = self.world.block_entities.get_mut(&key) {
j.loaded_clock = false;
}
} else if !self.jukebox_sounds.contains_key(&key) && ticks == 0 {
if !self.jukebox_sounds.contains_key(&key) && ticks == 0 {
self.start_song(pos, &song);
}
if ticks % 20 == 0 {
@@ -501,19 +452,12 @@ pub(super) fn jukebox_signals(
) -> Option<(i32, i32)> {
match world.block_entities.get(&(pos.x, pos.y, pos.z)) {
Some(BlockEntity::Jukebox(j)) => {
let song = j.record().and_then(|r| song_of_record(r, items));
let comparator = song
.as_deref()
.and_then(|s| items.songs.get(s).map(|song| song.comparator))
let comparator = j
.record()
.and_then(|r| song_of_record(r, items))
.and_then(|s| items.songs.get(&s).map(|song| song.comparator))
.unwrap_or(0);
// `JukeboxSongPlayer.isPlaying`, less a clock a save brought back
// past its song's end: the first tick drops that one.
let stale = j.loaded_clock
&& song
.as_deref()
.and_then(|s| items.songs.get(s))
.is_some_and(|s| s.finished(j.ticks));
Some((if j.playing && !stale { 15 } else { 0 }, comparator))
Some((if j.playing { 15 } else { 0 }, comparator))
}
_ => None,
}
@@ -634,24 +578,18 @@ mod jukebox_play_tests {
);
// The song is still playing after 41 ticks.
assert!(
!lines
.iter()
.any(|l| l.ends_with("minecraft:jukebox_stop_play")),
lines.iter().all(|l| l.ends_with("minecraft:jukebox_play")),
"{lines:?}"
);
}
/// A jukebox whose song is stopped posts no play event: only the twenty
/// ticks of a playing song do. Its insert's `BLOCK_CHANGE` events are the
/// only ones there.
/// ticks of a playing song do.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_stopped_jukebox_posts_no_play_event() {
let Some(lines) = playing(0) else { return };
assert!(
lines.iter().all(|l| l.ends_with("minecraft:block_change")),
"{lines:?}"
);
assert!(lines.is_empty(), "{lines:?}");
}
}
@@ -718,8 +656,7 @@ mod saved_song_clock_tests {
assert!(game.run_command("setblock 8 199 4 stone"));
assert!(game.run_command("setblock 8 200 4 jukebox[has_record=true]{RecordItem:{id:\"minecraft:music_disc_cat\",count:1,components:{\"minecraft:custom_data\":{h032:1}}}}"));
let j = game.jukebox([8, 200, 4]).unwrap();
// `setSongWithoutPlaying`: a record whose save had no clock is silent.
assert!(!j.playing, "a record without a clock does not play on load");
assert!(j.playing, "preserve legacy record-only adoption");
assert_eq!(j.ticks, 0);
let saved = game.block_entity_nbt([8, 200, 4]).unwrap();
assert_eq!(
@@ -755,7 +692,6 @@ mod jukebox_component_tests {
ticks: 0,
song_clock_present: true,
playing: true,
loaded_clock: false,
}),
);
jukebox_signals(
@@ -839,10 +775,6 @@ mod jukebox_component_tests {
}
}
#[cfg(test)]
#[path = "jukebox_tests.rs"]
mod jukebox_tests;
#[cfg(test)]
mod block_change_tests {
use super::*;
@@ -1,288 +0,0 @@
//! Jukeboxes against `JukeboxBlock`, `JukeboxBlockEntity` and `JukeboxPlayable`:
//! what a load brings back of a saved song, the `BLOCK_CHANGE` of an insert and
//! of an eject, and which click the `has_record` state allows.
use super::Game;
use crate::game::{Audio, Options, Progress, Settings, load_resources};
use horizon_client::{
chunk::Chunk,
containers::{BlockEntity, Jukebox},
items::ItemStack,
mobs::Who,
};
use horizon_world::registry::BlockStateId;
use std::sync::Arc;
/// The game events posted while `f` runs, as `(event, position, source)`.
fn record(f: impl FnOnce()) -> Vec<(&'static str, [f64; 3], Option<Who>)> {
crate::game::sculk::TEST_POSTED_EVENTS.with(|e| *e.borrow_mut() = Some(Vec::new()));
f();
crate::game::sculk::TEST_POSTED_EVENTS.with(|e| e.borrow_mut().take().unwrap_or_default())
}
/// A windowless integrated-server game from the prepared resources with a
/// resident chunk; `None` without them.
fn with_game<R>(f: impl FnOnce(&mut Game<'static>) -> R) -> Option<R> {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES")?);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.ok()?;
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
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();
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
Some(f(&mut game))
}
const AT: [f64; 3] = [0.5, 64.5, 0.5];
/// The jukebox at (0, 64, 0) in the state `record` names; its state id.
fn place_jukebox(game: &mut Game<'_>, record: bool) -> BlockStateId {
let registry = game.pack.assets.registry;
let mut state = registry
.state(registry.default_state("minecraft:jukebox").unwrap())
.unwrap()
.clone();
state.properties.insert(
"has_record".into(),
if record { "true" } else { "false" }.into(),
);
let id = registry.id(&state).unwrap();
game.world.set(0, 64, 0, id).unwrap();
id
}
/// A jukebox block entity as a save writes it, loaded back into the world at
/// (0, 64, 0): `RecordItem` and, with a `clock`, `ticks_since_song_started`.
fn load_saved(game: &mut Game<'_>, clock: Option<i64>) {
let clock = clock.map_or(String::new(), |n| format!("ticks_since_song_started:{n}L,"));
let (tag, _) = horizon_world::nbt::parse_snbt(&format!(
"{{id:\"minecraft:jukebox\",x:0,y:64,z:0,\
RecordItem:{{id:\"minecraft:music_disc_11\",count:1,}},{clock}}}"
))
.unwrap();
let (pos, model) = horizon_client::save::decode_block_entity(&tag).unwrap();
game.world.block_entities.insert(pos, model);
}
/// `JukeboxBlockEntity.setTheItem` posts `BLOCK_CHANGE` with the state the
/// block now has, and `JukeboxPlayable.tryInsertIntoJukebox` then posts the
/// same change again with the player as its source (against the state the
/// click found). `popOutTheItem` -> `setTheItem(EMPTY)` posts one on the way
/// out, before the song's own `JUKEBOX_STOP_PLAY`. A sculk sensor beside a
/// jukebox heard neither insert nor eject: only the song's end.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn an_insert_posts_two_block_changes_and_an_eject_one() {
let Some((inserted, popped)) = with_game(|game| {
place_jukebox(game, false);
let disc = ItemStack::new("minecraft:music_disc_cat", 1);
let inserted = record(|| {
assert!(
game.use_jukebox([0, 64, 0], Some(&disc)),
"the disc goes in"
);
});
let popped = record(|| {
assert!(
game.use_jukebox([0, 64, 0], None),
"the empty hand pops it out"
);
});
(inserted, popped)
}) else {
return;
};
assert_eq!(
inserted,
vec![
("minecraft:block_change", AT, None),
("minecraft:block_change", AT, Some(Who::Player)),
],
"the slot's change, then the player's"
);
assert_eq!(
popped,
vec![
("minecraft:block_change", AT, None),
("minecraft:jukebox_stop_play", AT, None),
],
"the record leaves the block before its song stops"
);
}
/// `JukeboxBlock.useWithoutItem` only pops when the state says a record is
/// there, and `popOutTheItem` pops nothing from an empty slot; `useItemOn`
/// only inserts when it says there is none, and replacing the slot's item is
/// `setTheItem`'s own doing. Horizon decided both by the slot.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn the_has_record_state_decides_both_clicks() {
let Some((ignored, replaced)) = with_game(|game| {
// has_record with nothing in the slot: the click pops nothing and the
// disc stays in hand, so the state stays as it is.
let recorded = place_jukebox(game, true);
game.world
.block_entities
.insert((0, 64, 0), BlockEntity::Jukebox(Jukebox::default()));
let disc = ItemStack::new("minecraft:music_disc_cat", 1);
let ignored = record(|| {
assert!(
game.use_jukebox([0, 64, 0], Some(&disc)),
"the click is consumed"
);
});
assert_eq!(game.world.block(0, 64, 0), recorded, "keeps its record");
assert!(
game.jukebox([0, 64, 0]).unwrap().record().is_none(),
"the held disc is not inserted"
);
// No record in the state with a disc in the slot: the held one replaces
// it (`JukeboxBlockEntity.setTheItem`), it does not eject the old one.
place_jukebox(game, false);
let old = ItemStack::new("minecraft:music_disc_13", 1);
game.world.block_entities.insert(
(0, 64, 0),
BlockEntity::Jukebox(Jukebox {
items: [Some(old)],
..Default::default()
}),
);
let replaced = record(|| {
assert!(
game.use_jukebox([0, 64, 0], Some(&disc)),
"the disc goes in"
);
});
let j = game.jukebox([0, 64, 0]).unwrap();
assert_eq!(&*j.record().unwrap().item, "minecraft:music_disc_cat");
assert!(j.playing, "the new disc plays");
(ignored, replaced)
}) else {
return;
};
assert!(ignored.is_empty(), "nothing happened: {ignored:?}");
assert_eq!(
replaced,
vec![
("minecraft:block_change", AT, None),
("minecraft:block_change", AT, Some(Who::Player)),
],
"the old disc is replaced, not popped out"
);
}
/// `JukeboxBlockEntity.loadAdditional` hands a saved clock to
/// `JukeboxSongPlayer.setSongWithoutPlaying`: a song under its length comes
/// back playing, without `play`'s level event 1010 and its "Now Playing", a
/// clock past the song's end is dropped without a `JUKEBOX_STOP_PLAY`, and a
/// record saved without a clock is no song at all.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_saved_song_comes_back_without_its_sound() {
let Some(cases) = with_game(|game| {
place_jukebox(game, true);
let signal = |game: &Game<'_>| {
let p = horizon_core::position::BlockPos::new(0, 64, 0);
super::jukebox_signals(&game.world, game.items(), p)
.unwrap()
.0
};
let mut cases = Vec::new();
for clock in [Some(41), Some(10_000), None, Some(0)] {
load_saved(game, clock);
let events = record(|| game.tick_jukebox((0, 64, 0)));
let j = game.jukebox([0, 64, 0]).unwrap();
cases.push((
clock,
(j.playing, j.ticks, j.song_clock_present),
signal(game),
game.titles.overlay.is_some(),
events,
));
}
cases
}) else {
return;
};
let at = |clock: Option<i64>| cases.iter().find(|c| c.0 == clock).unwrap();
// Mid-song and at its start: the clock goes on, at 15, quietly; only the
// twenty-tick chatter of a song that is running.
assert_eq!(at(Some(41)).1, (true, 42, true), "the clock goes on");
assert_eq!(at(Some(41)).2, 15);
assert!(!at(Some(41)).3, "nothing says \"Now Playing\" on a load");
assert!(at(Some(41)).4.is_empty(), "{:?}", at(Some(41)).4);
assert_eq!(at(Some(0)).1, (true, 1, true));
assert!(!at(Some(0)).3, "a clock saved at its start stays quiet");
assert_eq!(
at(Some(0)).4,
vec![("minecraft:jukebox_play", AT, None)],
"the first tick's game event is vanilla's"
);
// Past the end: gone, and silent about going.
assert_eq!(at(Some(10_000)).1, (false, 0, false));
assert_eq!(at(Some(10_000)).2, 0, "a dropped song powers nothing");
assert!(at(Some(10_000)).4.is_empty(), "{:?}", at(Some(10_000)).4);
// A record saved without a clock: no song, no signal, no event.
assert_eq!(at(None).1, (false, 0, false));
assert_eq!(at(None).2, 0);
assert!(!at(None).3, "a record alone starts no song");
assert!(at(None).4.is_empty(), "{:?}", at(None).4);
}
/// The same three cases on the load path itself, which needs no resources: only
/// a saved clock brings a song back, and it comes back unstarted.
#[test]
fn only_a_saved_clock_brings_a_song_back() {
let load = |clock: Option<i64>| {
let clock = clock.map_or(String::new(), |n| format!("ticks_since_song_started:{n}L,"));
let (tag, _) = horizon_world::nbt::parse_snbt(&format!(
"{{id:\"minecraft:jukebox\",x:0,y:64,z:0,\
RecordItem:{{id:\"minecraft:music_disc_11\",count:1,}},{clock}}}"
))
.unwrap();
match horizon_client::save::decode_block_entity(&tag).unwrap().1 {
BlockEntity::Jukebox(j) => j,
other => panic!("not a jukebox: {other:?}"),
}
};
let mid = load(Some(41));
assert_eq!(mid.ticks, 41);
assert!(
mid.playing && mid.song_clock_present,
"a saved clock brings the song back"
);
let unclocked = load(None);
assert_eq!(
unclocked.record().map(|r| &*r.item),
Some("minecraft:music_disc_11")
);
assert_eq!(unclocked.ticks, 0);
assert!(
!unclocked.playing && !unclocked.song_clock_present,
"a record alone is no song"
);
}
@@ -47,7 +47,6 @@ fn game() -> Game<'static> {
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
game.load_ticking_neighbourhood(0, 0);
for command in [
"forceload add 0 0",
"fill 0 63 0 15 63 15 stone",
+1 -1
View File
@@ -1161,7 +1161,7 @@ impl Game<'_> {
let scale = 1i32 << m.scale;
let start_x = m.center[0] / scale - 64;
let start_z = m.center[1] / scale - 64;
let sea_level = horizon_world::level::sea_level_of(self.level_key());
let sea_level = 63;
let quarts: Vec<[i32; 3]> = (0..128)
.flat_map(|row| {
(0..128).map(move |column| {
@@ -656,16 +656,10 @@ impl Game<'_> {
self.station_sound(sound);
}
} else if kind == Kind::Stonecutter {
// `StonecutterMenu.container`'s listener runs on every change of the
// input slot (`slotsChanged` and `containerChanged` both hang off
// it): a recipe button or an inventory click is not one.
let input_changed = before[0] != menu.slot(0).cloned();
if let Some(s) = self.station.as_mut() {
s.selected = usize::try_from(menu.data[0]).ok();
s.stone = std::mem::take(&mut menu.stone);
if input_changed {
s.stonecutter_changed();
}
s.stonecutter_changed();
}
for sound in sounds.into_iter().filter(|_| !predicting) {
self.station_sound(sound);
+47 -40
View File
@@ -233,19 +233,6 @@ fn pickup_instead_of_push(species: Species, is_passenger: bool, cart_is_vehicle:
!cart_is_vehicle && !is_passenger && species != Species::IronGolem
}
/// `EntitySelector.pushableBy` asks `isPushable`: `Shulker` takes no push
/// (`Mob::pushable` leaves it out) but still counts, so a cart offers it
/// `startRiding` like any other living entity.
fn offered_pickup(pushable: bool, species: Species, alive: bool) -> bool {
pushable || species == Species::Shulker && alive
}
/// `Shulker.push(Entity)` is empty, so a shulker that is not picked up (it
/// already rides something) neither moves nor slows the cart.
fn takes_push_from_cart(species: Species) -> bool {
species != Species::Shulker
}
/// `Entity.canRide` for a cart: the boarding cooldown, and a warden or the
/// wither never rides (`canRide` false).
fn can_ride_cart(species: Species, boarding_cooldown: i32) -> bool {
@@ -1807,10 +1794,7 @@ impl Game<'_> {
let (teams, own) = (self.mob_teams(), self.team_with_owner(id, || None));
near.iter()
.map(|&j| &self.mobs[j as usize])
.filter(|m| {
offered_pickup(m.pushable(), m.species(), m.alive())
&& pushable_by(own, teams.of(Who::Mob(m.id)), true)
})
.filter(|m| m.pushable() && pushable_by(own, teams.of(Who::Mob(m.id)), true))
.map(|m| m.id)
.collect()
};
@@ -1843,9 +1827,6 @@ impl Game<'_> {
}
continue;
}
if !takes_push_from_cart(species) {
continue;
}
let other = self.mobs[j].position;
let Some(v) = super::boat_passengers::push_impulse(pos, other) else {
continue;
@@ -2163,6 +2144,51 @@ impl Game<'_> {
p.velocity = Vec3::ZERO;
}
}
/// `EntityType.createDefaultStackConfig` on a vehicle an item made: the
/// item's custom name and custom data (`applyComponentsFromItemStack`:
/// `Entity.applyImplicitComponent`'s two, saved as `CustomName` and
/// `data`), then its
/// `minecraft:entity_data` loaded over the vehicle (`updateCustomEntityTag`)
/// when it names this type. An op-only type (command block and spawner
/// minecarts) takes it from an op player only, never from a dispenser
/// (`by_player` false).
pub(super) fn apply_vehicle_stack_config(
&mut self,
target: super::commands::Target,
kind: &str,
stack: &ItemStack,
by_player: bool,
) {
use horizon_world::nbt::Tag;
let name = stack.data().and_then(|d| d.custom_name.clone());
let custom = stack
.data()
.and_then(|d| d.other("minecraft:custom_data"))
.cloned();
let op = by_player && self.acting_player_is_op();
let data =
horizon_client::block_entity_item::spawn_egg_entity_data(stack, kind, op).cloned();
if name.is_none() && custom.is_none() && data.is_none() {
return;
}
let Some(mut own) = self.entity_nbt(target) else {
return;
};
if let Tag::Compound(map) = &mut own {
if let Some(name) = name {
map.insert("CustomName".into(), name);
}
if let Some(custom) = custom {
map.insert("data".into(), custom);
}
}
let tag = match &data {
Some(data) => horizon_client::data_cmd::merged(&own, data),
None => own,
};
// The vehicle's own compound, its name and data merged in: it loads back.
let _ = self.apply_entity_nbt(target, tag);
}
/// `MinecartItem.useOn`: a cart on the clicked rail.
pub(super) fn place_minecart(&mut self, hit: &Hit, stack: &ItemStack) -> bool {
let Some(kind) = CartKind::from_id(&stack.item) else {
@@ -2184,7 +2210,7 @@ impl Game<'_> {
.insert((hit.pos[0] >> 4, hit.pos[2] >> 4));
self.carts.push(cart);
let id = self.next_entity;
self.apply_item_stack_config(super::commands::Target::Cart(id), kind.id(), stack, true);
self.apply_vehicle_stack_config(super::commands::Target::Cart(id), kind.id(), stack, true);
if self.serving.is_some() || self.options.dedicated.is_some() {
let cart = self.carts.last().unwrap();
self.native_vehicle_create(
@@ -3356,25 +3382,6 @@ mod tests {
assert!(!can_ride_cart(Species::Warden, 0));
}
/// A shulker beside the rail is picked up although `Mob::pushable` leaves it out.
#[test]
fn a_cart_picks_up_a_shulker_but_not_a_dead_one() {
assert!(offered_pickup(false, Species::Shulker, true));
assert!(!offered_pickup(false, Species::Shulker, false));
assert!(!offered_pickup(false, Species::Bat, true));
assert!(offered_pickup(true, Species::Cow, true));
assert!(pickup_instead_of_push(Species::Shulker, false, false));
assert!(can_ride_cart(Species::Shulker, 0));
}
/// A shulker already riding a boat is no pickup candidate and `Shulker.push` is a no-op.
#[test]
fn a_mounted_shulker_does_not_push_a_cart() {
assert!(!pickup_instead_of_push(Species::Shulker, true, false));
assert!(!takes_push_from_cart(Species::Shulker));
assert!(takes_push_from_cart(Species::Cow));
}
/// `WitherBoss.canRide` returns false, so a moving cart cannot scoop the boss up.
#[test]
fn a_wither_never_rides_a_cart() {
@@ -268,40 +268,3 @@ fn efficiency_applies_to_a_same_item_swap() {
}
});
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_stacks_attribute_modifiers_replace_its_items_attack_damage() {
with_survival_game(|game| {
// Player.attack reads getAttributeValue(ATTACK_DAMAGE): the base 1
// plus what detectEquipmentUpdates gave the player for the held stack
// (ItemStack.forEachModifier, the stack's ATTRIBUTE_MODIFIERS). An
// iron pickaxe's own modifier is +3 (ToolMaterial.IRON's 2 plus the
// pickaxe's 1); the stack's +20 replaces it, so the hit is 21 (#14641).
for (stack, damage) in [
("minecraft:iron_pickaxe", 4.0),
(
"minecraft:iron_pickaxe[attribute_modifiers=[{type:\"minecraft:attack_damage\",id:\"minecraft:base_attack_damage\",amount:20.0,operation:\"add_value\",slot:\"mainhand\"}]]",
21.0,
),
// Identifier.CODEC reads bare names as `minecraft:`'s: the same
// modifier, so the attribute the host applies it to is found.
(
"minecraft:iron_pickaxe[attribute_modifiers=[{type:\"attack_damage\",id:\"base_attack_damage\",amount:20.0,operation:\"add_value\",slot:\"mainhand\"}]]",
21.0,
),
// In the off hand slot group, it does not apply to the main hand.
(
"minecraft:iron_pickaxe[attribute_modifiers=[{type:\"minecraft:attack_damage\",id:\"minecraft:base_attack_damage\",amount:20.0,operation:\"add_value\",slot:\"offhand\"}]]",
1.0,
),
("minecraft:iron_pickaxe[!attribute_modifiers]", 1.0),
] {
assert!(game.run_command(&format!(
"item replace entity @s weapon.mainhand with {stack}"
)));
game.tick_hand();
assert_eq!(game.attack_damage(), damage, "{stack}");
}
});
}
@@ -44,13 +44,18 @@ fn game() -> Game<'static> {
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
game.load_ticking_neighbourhood(0, 0);
// Entities tick once every chunk within 2 columns is loaded
// (ChunkMap.prepareEntityTickingChunk, #13953).
for x in -2..=2 {
for z in -2..=2 {
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("fill 0 99 0 15 99 15 stone"));
game
}
+107 -14
View File
@@ -992,8 +992,6 @@
use super::native_data_maps::NativeDataMapType;
use super::native_guards::{box_meets, finite, finite_params, mod_image_fits};
use super::native_item_energy::ItemEnergyBuffer;
use super::native_level_effects::{mod_particles_ok, mod_sound_ok, sound_id};
use super::native_menus::viewer_changes;
use super::native_world_edit::{property_change, with_property};
use super::{
Game, GameLevel, LevelDimension, Screen,
@@ -8506,18 +8504,6 @@ impl Game<'_> {
blocks,
);
}
/// An explosion at `center` in `dimension` is about to hurt and push `entities`, the ids of
/// the entities in its reach (players aside: `native_living_incoming_damage` hears them),
/// after `native_explosion_detonate`: `ServerExplosion.hurtEntities`. Take out the ids to
/// spare: they are neither hurt nor pushed. Server-side. API @API:explosion_entities@.
pub(super) fn native_explosion_entities(
&mut self,
dimension: &str,
center: [f64; 3],
entities: &mut Vec<u32>,
) {
mod_api::dispatch_explosion_entities(self, mod_api::mods(), dimension, center, entities);
}
/// An explosion at `center` in `dimension`, about to break `blocks`, pushes
/// `entity` (None for a player, whom `native_acting_player` names):
/// `ServerExplosion.hurtEntities`, after the knockback is calculated. Change `knockback_velocity` to change
@@ -10005,6 +9991,113 @@ fn mob_source(damage: &Damage) -> HurtSource<'static> {
}
}
/// Whether `id` is a sound event's id, `namespace:path` as `Identifier`
/// allows: the namespace of `[a-z0-9_.-]`, the path also with `/`.
/// Whether a native mod's sound can be sent: a namespaced event, a sound
/// source, a position inside the world's range and a finite, non-negative
/// volume and pitch.
pub(super) fn mod_sound_ok(
sound: &str,
at: [f64; 3],
source: usize,
volume: f32,
pitch: f32,
) -> bool {
let position_ok = at
.into_iter()
.all(|v| v.is_finite() && v.abs() <= f64::from(i32::MAX) / 8.0);
let gain_ok = volume.is_finite() && volume >= 0.0 && pitch.is_finite() && pitch >= 0.0;
sound_id(sound)
&& source < horizon_client::settings::SOUND_SOURCES.len()
&& position_ok
&& gain_ok
}
/// Whether a native mod's particles can be sent. Block particle types need
/// a known state (`state_ok`); item, trail and vibration particles aren't
/// supported by the network dispatch; a mod's own type must be namespaced.
pub(super) fn mod_particles_ok(
options: &ParticleOptions,
state: Option<BlockStateId>,
state_ok: impl Fn(BlockStateId) -> bool,
at: [f64; 3],
spread: [f64; 3],
speed: f64,
) -> bool {
use ParticleType as T;
// A native mod's own type: the name has to be namespaced, and a client
// draws it only when its pack has the sprite set.
if let Some(name) = &options.mod_particle {
let namespaced = name
.split_once(':')
.is_some_and(|(ns, path)| !ns.is_empty() && !path.is_empty());
if !namespaced {
return false;
}
}
let supported = match options.ty {
T::Block | T::BlockMarker | T::FallingDust | T::DustPillar | T::BlockCrumble => {
state.is_some_and(state_ok)
}
T::Item | T::Trail | T::Vibration => false,
T::Geyser | T::GeyserPlume | T::GeyserBase | T::GeyserPoof => options
.geyser
.is_some_and(|(_, impulse)| impulse.is_finite()),
_ => true,
};
supported
&& at.into_iter().all(f64::is_finite)
&& spread
.into_iter()
.all(|v| v.is_finite() && v.abs() <= f32::MAX as f64)
&& speed.is_finite()
&& speed.abs() <= f32::MAX as f64
&& options.color.into_iter().all(f32::is_finite)
&& options.to_color.into_iter().all(f32::is_finite)
&& options.scale.is_finite()
}
fn sound_id(id: &str) -> bool {
let Some((namespace, path)) = id.split_once(':') else {
return false;
};
let allowed = |c: char, slash: bool| {
c.is_ascii_lowercase() || c.is_ascii_digit() || "_.-".contains(c) || (slash && c == '/')
};
!namespace.is_empty()
&& !path.is_empty()
&& namespace.chars().all(|c| allowed(c, false))
&& path.chars().all(|c| allowed(c, true))
}
/// Sets the viewer counts `known` holds for `dim` to `now` and returns the
/// changes as `(pos, previous, viewers)`, in position order. A container that
/// is no longer counted goes to 0 and leaves `known`.
pub(super) fn viewer_changes(
known: &mut HashMap<(&'static str, (i32, i32, i32)), u32>,
dim: &'static str,
mut now: HashMap<(i32, i32, i32), u32>,
) -> Vec<((i32, i32, i32), u32, u32)> {
let mut changes = Vec::new();
known.retain(|&(d, pos), previous| {
if d != dim {
return true;
}
let viewers = now.remove(&pos).unwrap_or(0);
if viewers != *previous {
changes.push((pos, *previous, viewers));
*previous = viewers;
}
viewers > 0
});
for (pos, viewers) in now {
known.insert((dim, pos), viewers);
changes.push((pos, 0, viewers));
}
changes.sort_unstable();
changes
}
pub(super) fn dimension_id(dimension: LevelDimension) -> &'static str {
match dimension {
LevelDimension::Overworld => "minecraft:overworld",
@@ -3,9 +3,9 @@
//! Every player in range hears or sees them, as `ServerLevel.playSound` and
//! `sendParticles` send them. They return false on a client, for a handle of
//! an earlier open, or for arguments the network can't carry.
use super::native_hooks::{mod_particles_ok, mod_sound_ok};
use super::{Dim, Game, creatures::NativeSound, lan_particles::QueuedParticle};
use horizon_client::mod_api::{LevelRef, ParticleOptions};
use horizon_client::particles::ParticleType;
use horizon_world::registry::BlockStateId;
impl Game<'_> {
@@ -85,82 +85,3 @@ impl Game<'_> {
true
}
}
/// Whether `id` is a sound event's id, `namespace:path` as `Identifier`
/// allows: the namespace of `[a-z0-9_.-]`, the path also with `/`.
/// Whether a native mod's sound can be sent: a namespaced event, a sound
/// source, a position inside the world's range and a finite, non-negative
/// volume and pitch.
pub(super) fn mod_sound_ok(
sound: &str,
at: [f64; 3],
source: usize,
volume: f32,
pitch: f32,
) -> bool {
let position_ok = at
.into_iter()
.all(|v| v.is_finite() && v.abs() <= f64::from(i32::MAX) / 8.0);
let gain_ok = volume.is_finite() && volume >= 0.0 && pitch.is_finite() && pitch >= 0.0;
sound_id(sound)
&& source < horizon_client::settings::SOUND_SOURCES.len()
&& position_ok
&& gain_ok
}
/// Whether a native mod's particles can be sent. Block particle types need
/// a known state (`state_ok`); item, trail and vibration particles aren't
/// supported by the network dispatch; a mod's own type must be namespaced.
pub(super) fn mod_particles_ok(
options: &ParticleOptions,
state: Option<BlockStateId>,
state_ok: impl Fn(BlockStateId) -> bool,
at: [f64; 3],
spread: [f64; 3],
speed: f64,
) -> bool {
use ParticleType as T;
// A native mod's own type: the name has to be namespaced, and a client
// draws it only when its pack has the sprite set.
if let Some(name) = &options.mod_particle {
let namespaced = name
.split_once(':')
.is_some_and(|(ns, path)| !ns.is_empty() && !path.is_empty());
if !namespaced {
return false;
}
}
let supported = match options.ty {
T::Block | T::BlockMarker | T::FallingDust | T::DustPillar | T::BlockCrumble => {
state.is_some_and(state_ok)
}
T::Item | T::Trail | T::Vibration => false,
T::Geyser | T::GeyserPlume | T::GeyserBase | T::GeyserPoof => options
.geyser
.is_some_and(|(_, impulse)| impulse.is_finite()),
_ => true,
};
supported
&& at.into_iter().all(f64::is_finite)
&& spread
.into_iter()
.all(|v| v.is_finite() && v.abs() <= f32::MAX as f64)
&& speed.is_finite()
&& speed.abs() <= f32::MAX as f64
&& options.color.into_iter().all(f32::is_finite)
&& options.to_color.into_iter().all(f32::is_finite)
&& options.scale.is_finite()
}
pub(super) fn sound_id(id: &str) -> bool {
let Some((namespace, path)) = id.split_once(':') else {
return false;
};
let allowed = |c: char, slash: bool| {
c.is_ascii_lowercase() || c.is_ascii_digit() || "_.-".contains(c) || (slash && c == '/')
};
!namespace.is_empty()
&& !path.is_empty()
&& namespace.chars().all(|c| allowed(c, false))
&& path.chars().all(|c| allowed(c, true))
}
@@ -560,34 +560,6 @@ impl Game<'_> {
}
}
/// Sets the viewer counts `known` holds for `dim` to `now` and returns the
/// changes as `(pos, previous, viewers)`, in position order. A container that
/// is no longer counted goes to 0 and leaves `known`.
pub(super) fn viewer_changes(
known: &mut std::collections::HashMap<(&'static str, (i32, i32, i32)), u32>,
dim: &'static str,
mut now: std::collections::HashMap<(i32, i32, i32), u32>,
) -> Vec<((i32, i32, i32), u32, u32)> {
let mut changes = Vec::new();
known.retain(|&(d, pos), previous| {
if d != dim {
return true;
}
let viewers = now.remove(&pos).unwrap_or(0);
if viewers != *previous {
changes.push((pos, *previous, viewers));
*previous = viewers;
}
viewers > 0
});
for (pos, viewers) in now {
known.insert((dim, pos), viewers);
changes.push((pos, 0, viewers));
}
changes.sort_unstable();
changes
}
#[cfg(test)]
mod tests {
use super::*;
@@ -208,9 +208,6 @@ fn not_drawn(m: &Manifest, switch: bool) -> Option<String> {
.into(),
);
}
if m.entity.is_some() {
return Some("the [entity] stage isn't drawn yet: leave it out".into());
}
(m.api != Api::V0_1 && !switch).then(|| {
format!(
"it is written for shader API {}, which is experimental and drawn only under \
@@ -362,8 +359,6 @@ mod tests {
why.contains("primaries = \"bt2020\" isn't served yet"),
"{why}"
);
let why = not_drawn(&manifest("0.2", "[entity]\nfile = \"e.wgsl\"\n"), switch);
assert!(why.unwrap().contains("[entity] stage isn't drawn yet"));
let named = manifest("0.2", "[frame]\nprimaries = \"bt709\"\n");
assert_eq!(not_drawn(&named, true), None);
}
+12 -56
View File
@@ -13,7 +13,6 @@ use super::entity::{Entity as _, entity_lists_of, for_each_entity, with_entity};
use super::{Dim, Game, Result, Screen, level_flags};
use horizon_client::fast_hash::FxHashMap;
use horizon_client::nether_portal::{ENTITY_DELAY, PortalState};
use horizon_client::world::PendingEntity;
use horizon_core::{
geometry::{Aabb, Axis, Vec3},
position::BlockPos,
@@ -75,22 +74,10 @@ impl Plan {
/// An entity on its way to a dimension that isn't loaded.
#[derive(Clone, Debug)]
struct Arrival {
/// Teleport-only state; deliberately absent from arrival_tag.
tnt_used_portal: bool,
plan: Plan,
tag: Tag,
}
impl Arrival {
fn new(plan: Plan, tag: Tag, tnt_used_portal: bool) -> Self {
Self {
plan,
tag,
tnt_used_portal,
}
}
}
/// The nether portal state of the world (`Game::portals`).
#[derive(Default)]
pub(super) struct Portals {
@@ -270,7 +257,6 @@ fn arrival_from_tag(a: &Tag) -> Option<Arrival> {
return None;
};
Some(Arrival {
tnt_used_portal: false,
plan: Plan {
dim,
approximate: BlockPos::new(x, y, z),
@@ -545,7 +531,6 @@ impl Game<'_> {
self.tickets.add_portal(chunk);
}
}
/// `NetherPortalBlock.createDimensionTransition`: the arrival position
/// and the yaw turned by.
pub(super) fn arrival(&self, plan: &Plan, exit: FoundRectangle) -> (Vec3, f32) {
@@ -1113,7 +1098,7 @@ impl Game<'_> {
);
self.remove_dropped(i);
self.detach_entity(id);
self.portals.arrivals.push(Arrival::new(plan, tag, false));
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self.tnts.iter().position(|t| t.id == id) {
let t = &self.tnts[i];
@@ -1121,13 +1106,10 @@ impl Game<'_> {
if !self.may_enter(plan.dim) {
return;
}
self.tnts[i].used_portal = true;
let tag = self.tnt_nbt(i);
let transferred = self.tnts.remove(i);
self.tnts.remove(i);
self.forget_entity(id);
self.portals
.arrivals
.push(Arrival::new(plan, tag, transferred.used_portal));
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self.tridents.iter().position(|t| t.id == id) {
let plan = self.entity_plan(entry, self.tridents[i].position, 0.5, 0.5, false);
@@ -1139,7 +1121,7 @@ impl Game<'_> {
self.tridents[i].to_nbt(uuid, self.pack.assets.registry, self.persist_raw(id));
self.tridents.remove(i);
self.forget_entity(id);
self.portals.arrivals.push(Arrival::new(plan, tag, false));
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self.rockets.iter().position(|r| r.id == id) {
let plan = self.entity_plan(entry, self.rockets[i].position, 0.25, 0.25, false);
@@ -1150,7 +1132,7 @@ impl Game<'_> {
let tag = self.rockets[i].to_nbt(uuid, self.level.player_uuid, self.persist_raw(id));
self.rockets.remove(i);
self.forget_entity(id);
self.portals.arrivals.push(Arrival::new(plan, tag, false));
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self.orbs.iter().position(|o| o.id == id) {
let plan = self.entity_plan(entry, self.orbs[i].position, 0.5, 0.5, false);
@@ -1161,7 +1143,7 @@ impl Game<'_> {
let tag = horizon_client::entity_nbt::orb_to_nbt(&self.orbs[i], uuid);
self.orbs.remove(i);
self.forget_entity(id);
self.portals.arrivals.push(Arrival::new(plan, tag, false));
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
let m = &self.mobs[i];
@@ -1180,7 +1162,7 @@ impl Game<'_> {
for g in gone {
self.detach_entity(g);
}
self.portals.arrivals.push(Arrival::new(plan, tag, false));
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self.boats.iter().position(|b| b.id == id) {
let b = &self.boats[i];
@@ -1200,7 +1182,7 @@ impl Game<'_> {
}
self.boats.remove(i);
self.detach_entity(id);
self.portals.arrivals.push(Arrival::new(plan, tag, false));
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self.carts.iter().position(|c| c.id == id) {
let c = &self.carts[i];
@@ -1231,7 +1213,7 @@ impl Game<'_> {
}
self.carts.remove(i);
self.detach_entity(id);
self.portals.arrivals.push(Arrival::new(plan, tag, false));
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self
.creatures
@@ -1266,7 +1248,7 @@ impl Game<'_> {
};
self.creatures.projectiles.remove(i);
self.forget_entity(id);
self.portals.arrivals.push(Arrival::new(plan, tag, false));
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
}
if self.portals.arrivals.len() > arrivals {
@@ -1341,11 +1323,7 @@ impl Game<'_> {
i += 1;
continue;
}
let Arrival {
plan,
mut tag,
tnt_used_portal,
} = self.portals.arrivals.remove(i);
let Arrival { plan, mut tag } = self.portals.arrivals.remove(i);
self.portals.dirty = true;
let motion = horizon_client::entity_nbt::motion(&tag);
let [yaw, pitch] = horizon_client::entity_nbt::rotation(&tag);
@@ -1381,11 +1359,6 @@ impl Game<'_> {
preserve_arrival_cooldown(&mut tag);
// ServerLevel.addDuringTeleport: a fresh add, not loaded from disk.
super::persist::mark_fresh(&mut tag);
let tag = if tnt_used_portal {
PendingEntity::after_tnt_portal(tag)
} else {
tag.into()
};
let key = ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4);
match self
.world
@@ -1450,7 +1423,7 @@ impl Game<'_> {
if f.id != 0 {
self.forget_entity(f.id);
}
self.portals.arrivals.push(Arrival::new(plan, tag, false));
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
Some(plan.dim)
}
@@ -1625,20 +1598,6 @@ impl Game<'_> {
mod tests {
use super::*;
#[test]
fn portal_transfer_pending_arrival_does_not_save_transient_tnt_immunity() {
let arrival = Arrival {
plan: Plan::end_portal(Dim::End, BlockPos::new(0, 64, 0), 0.98, 0.98),
tag: Tag::compound([("id", Tag::String("minecraft:tnt".into()))]),
tnt_used_portal: true,
};
assert!(
!arrival_from_tag(&arrival_tag(&arrival))
.unwrap()
.tnt_used_portal
);
}
#[test]
fn end_portals_lead_to_the_end_spawn_and_back_to_the_world_spawn() {
let spawn = BlockPos::new(-7, 70, 12);
@@ -1718,7 +1677,6 @@ mod tests {
let arrival = Arrival {
plan: Plan::end_portal(Dim::Overworld, BlockPos::new(0, 64, 0), 1.375, 0.5625),
tag,
tnt_used_portal: false,
};
let restored = arrival_from_tag(&arrival_tag(&arrival)).unwrap();
let expected = old.filter(|n| *n > 0).unwrap_or(ENTITY_DELAY);
@@ -1734,7 +1692,6 @@ mod tests {
let tag = Tag::compound([("id", Tag::String("minecraft:cow".into()))]);
for (from, dim) in [(Dim::Overworld, Dim::End), (Dim::End, Dim::Overworld)] {
let arrival = Arrival {
tnt_used_portal: false,
plan: Plan::end_portal(from, BlockPos::new(3, 64, -5), 0.9, 1.4),
tag: tag.clone(),
};
@@ -1746,7 +1703,6 @@ mod tests {
}
// Arrivals saved before End portals were Nether portal plans.
let mut old = arrival_tag(&Arrival {
tnt_used_portal: false,
plan: Plan::end_portal(Dim::End, BlockPos::new(0, 64, 0), 0.25, 0.25),
tag,
});
-2
View File
@@ -126,8 +126,6 @@ impl Game<'_> {
.map(super::native_hooks::mob_kind)
.unwrap_or_default();
self.native_entity_enter_love_mode(id, &kind, self.lan_acting_uuid);
// Animal.setInLove: entity event 18, the love hearts.
self.mob_event_particles(id, 18);
}
self.consume_held();
if let Some(stack) = dropped {
+22 -38
View File
@@ -7,7 +7,6 @@ mod display;
mod hurt_by_refs;
use super::{FallingBlock, Game};
use horizon_client::world::EntityLoadReason;
use horizon_client::{
Result,
chunk::Chunk,
@@ -535,7 +534,7 @@ impl Game<'_> {
let player_uuid = self.level.player_uuid;
for (key, tags) in loaded {
let count = tags.len();
for (mut tag, reason) in tags.into_iter().map(|pending| pending.into_parts()) {
for mut tag in tags {
// A world older builds named by the offline profile names the player that
// way in its entities too: migrate them as they load, so the next save of
// the chunk keeps the account's UUID (`playerdata::migrate_owner`).
@@ -592,16 +591,7 @@ impl Game<'_> {
}
// Kinds outside `entity_lists!` first, then the listed kinds
// (`Entity::load_nbt`), then whatever else is a compound.
let loaded = if reason == EntityLoadReason::TeleportedTnt
&& kind == "minecraft:tnt"
&& !is_mob
{
self.load_tnt_with_portal(tag, uuid, true);
Ok(())
} else {
self.load_unlisted(key, &kind, uuid, tag)
};
if let Err(tag) = loaded
if let Err(tag) = self.load_unlisted(key, &kind, uuid, tag)
&& let Err(tag) = self.load_listed(&kind, uuid, player_uuid, tag)
{
if !kind.is_empty() && entity_nbt::position(&tag).is_some() {
@@ -862,9 +852,6 @@ impl Game<'_> {
/// `PrimedTnt.readAdditionalSaveData`: its numeric short fuse (80 when absent) and motion.
// Its Passengers ride it (EntityType.loadEntityRecursive forces them on).
pub(super) fn load_tnt(&mut self, tag: Tag, uuid: Option<[i32; 4]>) {
self.load_tnt_with_portal(tag, uuid, false);
}
fn load_tnt_with_portal(&mut self, tag: Tag, uuid: Option<[i32; 4]>, used_portal: bool) {
if uuid.is_some_and(|u| self.target_with_uuid(u).is_some()) {
return;
}
@@ -881,7 +868,6 @@ impl Game<'_> {
// to int before short narrowing; integer tags narrow directly.
let fuse = tnt_fuse(&tag);
self.tnts.push(super::tnt::PrimedTnt {
used_portal,
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: entity_nbt::flag(&tag, "NoGravity"),
@@ -1384,7 +1370,7 @@ impl Game<'_> {
{
entity_nbt::fill_common_defaults(&mut tag);
}
with_raw(tag, None) // Entity.saveWithoutId's flags, as booleans.
tag
}
/// The root of a stack of riders (`Entity.getRootVehicle`).
fn mob_root(&self, mut i: usize) -> usize {
@@ -1514,7 +1500,7 @@ impl Game<'_> {
(at.z.floor() as i32).div_euclid(16),
);
self.persist.restored.insert(root_uuid);
self.world.queue_entity_nbt(key, vec![tag]);
self.world.loaded_entities.push((key, vec![tag]));
self.spawn_loaded_entities();
}
self.persist.restored.insert(uuid);
@@ -3012,7 +2998,7 @@ mod tests {
}
// The level is a server's, whose attachments a mod reaches.
game.options.dedicated = Some(Vec::new());
game.world.queue_entity_nbt((0, 0), tags);
game.world.loaded_entities.push(((0, 0), tags));
game.spawn_loaded_entities();
let saved = game.entity_chunks(&HashSet::from([(0, 0)]), false);
let saved: Vec<&Tag> = saved.iter().flat_map(|(_, tags)| tags).collect();
@@ -3102,7 +3088,7 @@ mod tests {
.filter(|c| c.potion.is_some())
.count()
};
game.world.queue_entity_nbt((0, 0), vec![cloud]);
game.world.loaded_entities.push(((0, 0), vec![cloud]));
game.spawn_loaded_entities();
assert_eq!(clouds(&game), 1, "the cloud loads");
assert_eq!(game.breath_clouds[0].motion, [0.6, 0.0, 0.0]);
@@ -3114,7 +3100,7 @@ mod tests {
Vec3::new(0.6, 0.0, 0.0)
);
assert_eq!(clouds(&game), 0, "and takes it out of the level");
game.world.queue_entity_nbt((0, 0), written);
game.world.loaded_entities.push(((0, 0), written));
game.spawn_loaded_entities();
assert_eq!(clouds(&game), 1, "loading the chunk again gives one cloud");
assert_eq!(game.breath_clouds[0].motion, [0.6, 0.0, 0.0]);
@@ -4272,7 +4258,6 @@ mod attribute_instance_save_tests {
assert!(game.run_command("forceload add 0 0"));
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
game.creative = true;
game.load_ticking_neighbourhood(0, 0);
test(&mut game);
}
fn mob(game: &Game<'_>, n: i32) -> usize {
@@ -5291,7 +5276,6 @@ mod h262_enderman_absolute_anger_tests {
game.run_command("summon pig 13.5 200 2.5 {UUID:[I;262,0,0,99],NoAI:1b,NoGravity:1b}")
);
game.world.random.set_seed(262);
game.load_ticking_neighbourhood(0, 0);
test(&mut game);
}
fn load(game: &mut Game<'_>, n: i32, end: Option<i64>) -> (u32, usize) {
@@ -5527,7 +5511,6 @@ mod sulfur_body_attribute_tests {
assert!(game.run_command("scoreboard objectives add h192 dummy"));
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
game.creative = true;
game.load_ticking_neighbourhood(0, 0);
test(&mut game);
}
/// `/attribute <cube> <attribute> get 1000`, read back through a score.
@@ -5553,9 +5536,9 @@ mod sulfur_body_attribute_tests {
(2, ""),
] {
assert!(game.run_command(&format!(
"summon minecraft:sulfur_cube {}.5 200 4.5 {{UUID:[I;192,0,0,{n}],NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b,Size:1{body}}}",
3 * n
)));
"summon minecraft:sulfur_cube {}.5 200 4.5 {{UUID:[I;192,0,0,{n}],NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b,Size:1{body}}}",
3 * n
)));
}
let attrs = [
"knockback_resistance",
@@ -5599,8 +5582,8 @@ mod sulfur_body_attribute_tests {
fn a_bodiless_cube_keeps_its_command_knockback_resistance_effect() {
with_game(|game| {
assert!(game.run_command(
"summon minecraft:sulfur_cube 4.5 200 8.5 {UUID:[I;192,0,0,9],NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b,Size:1}"
));
"summon minecraft:sulfur_cube 4.5 200 8.5 {UUID:[I;192,0,0,9],NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b,Size:1}"
));
let sel = "@e[type=minecraft:sulfur_cube,nbt={UUID:[I;192,0,0,9]},limit=1]";
let cached = |game: &Game<'_>| {
let m = game
@@ -5667,9 +5650,9 @@ mod sulfur_body_attribute_tests {
(11, ",equipment:{body:{id:\"minecraft:oak_planks\"}}"),
] {
assert!(game.run_command(&format!(
"summon minecraft:sulfur_cube {}.5 200 8.5 {{UUID:[I;192,0,0,{n}],NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b,Size:1{body}}}",
n - 8
)));
"summon minecraft:sulfur_cube {}.5 200 8.5 {{UUID:[I;192,0,0,{n}],NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b,Size:1{body}}}",
n - 8
)));
}
game.tick_world(true).unwrap();
let cached = |game: &Game<'_>, n: i32| {
@@ -5782,7 +5765,6 @@ mod summoned_knot_tests {
game.run_command("summon pig 13.5 200 2.5 {UUID:[I;129,0,0,99],NoAI:1b,NoGravity:1b}")
);
game.world.random.set_seed(129);
game.load_ticking_neighbourhood(0, 0);
test(&mut game);
}
fn knot_id(game: &Game<'_>, n: i32) -> u32 {
@@ -5913,8 +5895,12 @@ mod summoned_knot_tests {
#[ignore = "prepared resources; negative fractional registered position"]
fn negative_fractional_knot_position_uses_floor_before_centering() {
with_game(|game| {
// Chunk (-1, -1) is already loaded by the fixture's neighbourhood.
assert!(game.world.chunks.contains_key(&(-1, -1)));
game.world
.chunks
.insert((-1, -1), Arc::new(Chunk::empty(-1, -1)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(-1, -1))
.unwrap();
assert!(game.run_command("setblock -2 199 -3 stone strict"));
assert!(game.run_command("setblock -2 200 -3 oak_fence strict"));
assert!(game.run_command("summon leash_knot -1.2 200.8 -2.1 {UUID:[I;129,0,0,5]}"));
@@ -6141,9 +6127,7 @@ mod camel_pose_saved_tests {
.into_iter()
.flat_map(|(_, tags)| tags)
.collect();
game.world
.loaded_entities
.push(((0, 0), written.into_iter().map(Into::into).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 {
@@ -1498,7 +1498,6 @@ mod h255_mob_piston_ground_tests {
.unwrap();
}
}
game.load_ticking_neighbourhood(0, 0);
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
game.creative = true;
assert!(game.run_command("fill 0 199 0 31 199 31 stone"));
@@ -112,21 +112,19 @@ impl Game<'_> {
equipped.push((self.inventory[self.selected].as_ref(), "mainhand"));
equipped.push((self.offhand.as_ref(), "offhand"));
for (stack, slot) in equipped {
// ItemStack.forEachModifier: the stack's own ATTRIBUTE_MODIFIERS
// (its item's unless a command or a mod replaced them, #14641).
let Some(stack) = stack else {
continue;
};
for (attribute, id, amount, op, group) in world_items!(self).attribute_modifiers(stack)
{
if !equipment_modifier_applies(&group, slot) {
let modifiers = world_items!(self).attribute_modifiers(stack);
for (attribute, id, amount, op, group) in &modifiers {
if !equipment_modifier_applies(group, slot) {
continue;
}
if player
.set_modifier_unclamped(&attribute, &id, amount, &op, true)
.set_modifier_unclamped(attribute, id, *amount, op, true)
.is_ok()
{
self.applied_modifiers.push((attribute, id));
self.applied_modifiers.push((attribute.clone(), id.clone()));
}
}
}
@@ -35,7 +35,6 @@ fn mobs_past_the_simulation_distance_neither_enter_portals_nor_cool_down() {
game.world
.chunks
.insert((cx, 0), Arc::new(Chunk::empty(cx, 0)));
game.load_ticking_neighbourhood(cx, 0);
for z in [8, 12] {
game.world.set(x, 63, z, stone).unwrap();
}
+60 -31
View File
@@ -385,21 +385,31 @@ impl Game<'_> {
.play("minecraft:entity.silverfish.hurt", Some(at), 1.0, 1.0);
}
}
}
/// A splash potion, lingering potion or experience bottle about to fly (the
/// player's throws and dispensers both make one): `ThrownSplashPotion`,
/// `ThrownLingeringPotion` or `ThrownExperienceBottle`, which
/// `level.addFreshEntity` makes a level entity like a summoned one, so
/// selectors and `/data` find it (#13201).
pub(super) fn thrown_projectile(stack: ItemStack, position: Vec3, by_player: bool) -> Projectile {
let kind = match &*stack.item {
"minecraft:experience_bottle" => ProjectileKind::ExperienceBottle,
item => {
let lingering = item == "minecraft:lingering_potion";
ProjectileKind::Potion(Box::new(stack), lingering)
}
};
Projectile::new(kind, position, by_player.then_some(Who::Player))
/// A splash potion, lingering potion or experience bottle in flight (the
/// player's throws and dispensers both end up here).
pub(super) fn launch_thrown(
&mut self,
stack: ItemStack,
position: Vec3,
velocity: Vec3,
owner: Option<Text>,
) {
let id = self.take_entity_id();
self.potions.thrown.push(ThrownPotion {
id,
stack,
previous: position,
position,
velocity,
age: 0,
thrower: owner.is_some().then_some(Who::Player),
guest: self.lan_acting.filter(|_| owner.is_some()),
owner,
left_owner: false,
last_deflected_by: None,
random: LegacyRandom::new(i64::from(id)),
});
}
}
/// What a player's body was last given for its effects and enchantments:
/// a guest keeps their own while the host's are in `PotionState`.
@@ -576,21 +586,43 @@ impl Game<'_> {
_ => ("minecraft:entity.lingering_potion.throw", 0.5),
};
self.audio.play(sound, Some(eye), 0.5, pitch);
// Projectile.shootFromRotation(xRot, yRot, -20, power, 1.0), with
// what the thrower last moved (getKnownMovement).
let velocity = self.known_player_movement();
// Projectile.shootFromRotation(xRot, yRot, -20, 0.5, 1.0).
let (yaw, pitch) = (self.yaw.to_radians(), self.pitch.to_radians());
let dir = [
-yaw.sin() * pitch.cos(),
-(self.pitch - 20.0).to_radians().sin(),
yaw.cos() * pitch.cos(),
];
let l = dir
.iter()
.map(|c| f64::from(*c).powi(2))
.sum::<f64>()
.sqrt()
.max(1e-9);
// getMovementToShoot: normalize, add triangle(0, 0.0172275 * uncertainty), scale.
let r = &mut self.world.random;
let mut jitter = || r.triangle(0.0, 0.0172275);
let v = Vec3::new(
(f64::from(dir[0]) / l + jitter()) * power,
(f64::from(dir[1]) / l + jitter()) * power,
(f64::from(dir[2]) / l + jitter()) * power,
);
let pv = self.known_player_movement();
let on_ground = self.player.as_ref().is_some_and(|p| p.ground.on_ground);
let one = ItemStack {
count: 1,
..stack.clone()
};
let mut shot =
thrown_projectile(one, Vec3::new(eye[0], eye[1] - 0.1, eye[2]), true);
shot.shoot_from_rotation(
self.pitch, self.yaw, -20.0, power, 1.0, velocity, on_ground,
let v = Vec3::new(
v.x + pv.x,
v.y + if on_ground { 0.0 } else { pv.y },
v.z + pv.z,
);
self.launch_thrown(
ItemStack {
count: 1,
..stack.clone()
},
Vec3::new(eye[0], eye[1] - 0.1, eye[2]),
v,
Some(self.player_display_text()),
);
shot.guest = self.lan_acting;
self.add_projectile(shot);
// ThrowablePotionItem.use / ExperienceBottleItem.use: awardStat(ITEM_USED).
self.award_stat("used", &stack.item, 1);
self.swing_arm();
@@ -2635,7 +2667,6 @@ mod mob_absorption_max_tests {
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
game.creative = true;
assert!(game.run_command("scoreboard objectives add h203 dummy"));
game.load_ticking_neighbourhood(0, 0);
test(&mut game);
}
fn pig(game: &Game<'_>, n: i32) -> usize {
@@ -2805,7 +2836,6 @@ mod mob_health_boost_tests {
assert!(game.run_command("forceload add 0 0"));
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
game.creative = true;
game.load_ticking_neighbourhood(0, 0);
test(&mut game);
}
fn pig(game: &Game<'_>, n: i32) -> usize {
@@ -3051,7 +3081,6 @@ mod depleted_absorption_tests {
assert!(game.run_command("forceload add 0 0"));
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
game.creative = true;
game.load_ticking_neighbourhood(0, 0);
test(&mut game);
}
/// The mob in the level with UUID word `n`.
+26 -10
View File
@@ -3029,12 +3029,11 @@ impl Game<'_> {
d.on_ground = on_ground;
return;
}
// A newly spawned block or TNT is at the back of its list.
if let Some(f) = self.falling.iter_mut().rev().find(|f| f.id == id as u32) {
if let Some(f) = self.falling.iter_mut().find(|f| f.id == id as u32) {
f.position = at;
return;
}
if let Some(t) = self.tnts.iter_mut().rev().find(|t| t.id == id as u32) {
if let Some(t) = self.tnts.iter_mut().find(|t| t.id == id as u32) {
t.position = at;
t.on_ground = on_ground;
return;
@@ -3410,7 +3409,20 @@ impl Game<'_> {
self.capture(true);
}
C::PlayerPosition(p) => self.remote_teleport(&p)?,
C::PlayerRotation(p) => self.remote_rotation(&p),
C::PlayerRotation(p) => {
self.yaw = if p.relative_yaw {
self.yaw + p.yaw
} else {
p.yaw
};
self.pitch = (if p.relative_pitch {
self.pitch + p.pitch
} else {
p.pitch
})
.clamp(-90.0, 90.0);
self.snap_player_model_pitch();
}
C::PlayerLookAt(p) => self.remote_look_at(&p),
// ClientCommonPacketListenerImpl.handlePostEffects.
C::PostEffects(p) => self.post_effects = p.effects,
@@ -3564,7 +3576,6 @@ impl Game<'_> {
p.id as u32,
horizon_client::lan::playerdata::uuid_ints(p.uuid),
);
let replace = self.tracks_remote_entity(p.id);
self.spawn_entity(p.id, &kind, p.position, rotation);
if let Some(r) = self.remote.as_mut() {
r.cart_lerps.remove(&p.id);
@@ -3577,7 +3588,14 @@ impl Game<'_> {
(rotation[0], rotation[1]),
p.data,
) {
self.spawn_added_projectile(&p, &kind, (rotation[0], rotation[1]), replace);
self.spawn_remote_projectile(
p.id,
&kind,
p.position,
(rotation[0], rotation[1]),
p.velocity,
p.data,
);
}
// Entity.recreateFromPacket: objects must exist before applying motion.
self.remote_motion(p.id, p.velocity);
@@ -5880,16 +5898,14 @@ impl Game<'_> {
fn remote_motion(&mut self, id: i32, velocity: [f64; 3]) {
self.remote_dragon_motion(id, velocity);
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
// `falling` and `tnts` are searched from the back: AddEntity calls this right after
// appending the block or TNT, so a spawn doesn't walk the whole list.
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, id as u32) {
// Squid.aiStep derives its body pitch and roll from network motion.
m.velocity = v;
} else if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
d.velocity = v;
} else if let Some(f) = self.falling.iter_mut().rev().find(|f| f.id == id as u32) {
} else if let Some(f) = self.falling.iter_mut().find(|f| f.id == id as u32) {
f.velocity = v;
} else if let Some(t) = self.tnts.iter_mut().rev().find(|t| t.id == id as u32) {
} else if let Some(t) = self.tnts.iter_mut().find(|t| t.id == id as u32) {
t.velocity = v;
} else if let Some(o) = self.orbs.iter_mut().find(|o| o.id == id as u32) {
o.velocity = v;
@@ -34,29 +34,6 @@ impl Game<'_> {
pub(in crate::game) fn remote_walking_speed(&self) -> f32 {
walking_speed(self.remote.as_ref().and_then(|r| r.abilities.as_ref()))
}
/// ClientPacketListener.handleRotatePlayer: the server's yaw and pitch, each added to the
/// current one when its bit says relative, then sent back as a `Rot` move with both
/// flags false.
pub(super) fn remote_rotation(&mut self, p: &play::PlayerRotation) {
self.yaw = if p.relative_yaw {
self.yaw + p.yaw
} else {
p.yaw
};
self.pitch = (if p.relative_pitch {
self.pitch + p.pitch
} else {
p.pitch
})
.clamp(-90.0, 90.0);
self.snap_player_model_pitch();
self.send(S::MovePlayerRot(play::MovePlayerRot {
yaw: self.yaw,
pitch: self.pitch,
flags: 0,
}));
}
}
#[cfg(test)]
@@ -132,14 +132,6 @@ impl Game<'_> {
.unwrap_or_default();
nautilus_takes(nautilus::is_food(m, item, &taming), m.extra.tame, baby)
}
// Dolphin.mobInteract: a #minecraft:fishes item is SUCCESS on both
// sides (the server feeds it), so the client swings and keeps the fish.
Species::Dolphin => self
.res
.items()
.item_tags
.get("minecraft:fishes")
.is_some_and(|t| t.contains(item)),
// IronGolem.mobInteract.
Species::IronGolem => iron_golem_takes(item, m.health, m.attrs.max_health),
// CopperGolem.mobInteract.
@@ -262,15 +254,7 @@ fn copper_golem_takes(item: &str, holding: bool, shearable: bool) -> bool {
/// `handleEating` gates both on `isBaby() && !isAgeLocked()` — a locked baby
/// does not grow, and the particle goes with the growth.
pub(super) fn ages_on_food(m: &Mob, item: &str) -> bool {
if m.extra.age_locked || item.is_empty() {
return false;
}
// Tadpole.feed draws the same particle on both sides; a tadpole is never
// `isBaby`, its age counts up to the frog.
if m.species() == Species::Tadpole {
return m.kind.food.iter().any(|f| f.as_str() == item);
}
if !m.is_baby() {
if !m.is_baby() || m.extra.age_locked || item.is_empty() {
return false;
}
match m.species() {
@@ -510,18 +494,4 @@ mod tests {
assert!(!ages_on_food(&camel, "minecraft:cactus"), "locked camel");
}
}
/// `Tadpole.feed` draws its particle on the client too, though a tadpole
/// is never a baby; an age-locked one is not fed (#8554).
#[test]
fn a_tadpole_fed_a_slime_ball_draws_happy_villager() {
let Some(mut tadpole) = mob("minecraft:tadpole") else {
return;
};
assert!(!tadpole.is_baby());
assert!(ages_on_food(&tadpole, "minecraft:slime_ball"));
assert!(!ages_on_food(&tadpole, "minecraft:wheat"), "not frog food");
tadpole.extra.age_locked = true;
assert!(!ages_on_food(&tadpole, "minecraft:slime_ball"), "locked");
}
}
Loaded 100 of 341 files, more files were not shown because too many files have changed in this diff. Show more