mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-10 10:31:29 -10:00
Merge main into #10538
# Conflicts: # crates/horizon-client-render/src/gfx/pack_post.rs
This commit is contained in:
commit
d8676fff04
373 files changed
+25987
-2712
No files matched your search
@@ -34,7 +34,9 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
|
||||
- Contributors first: `gh pr list --state open --search <area>` before you start; never duplicate theirs.
|
||||
- **No hardcoded user-facing strings.** Keep UI translation-ready from the start. In the launcher (`launcher/`), wrap visible text with `tr("...")` or `trf("... {arg} ...", &[("arg", &val)])` from `crate::i18n` instead of raw string literals or `format!`. In the game, use translation keys (`Text::tr`, `lang::get`), never hardcoded English strings.
|
||||
- **Claim before you start**: comment `/claim` on the issue (no issue yet: open one, then claim it). An assigned
|
||||
issue is taken: pick another. `/unclaim` when you stop; a claim with no open PR linking it is released after 5 days.
|
||||
issue is taken, and so is one labelled `in progress` (claimed on Discord): pick another. `/unclaim` when you stop;
|
||||
a claim with no open PR linking it is released after 5 days. The issues free to claim, without reading threads:
|
||||
`gh issue list --search 'is:open no:assignee -label:"in progress" -label:tracker -label:proposal -label:"not claimable" -linked:pr'`
|
||||
- **Controllers reach what registers.** Draw controls with a registering helper (`ui::button`, `settings_shell::foot_button`); `graphics_style::button` and `screen_draw::button` only paint, and registering one part of a screen ends its crosshair, so register all of it.
|
||||
|
||||
## Crates
|
||||
|
||||
+40
-2
@@ -266,7 +266,7 @@ Shader packs were removed and are being rebuilt from scratch. They come back, of
|
||||
|
||||
A clean-room rewrite in `horizon-shaderpack`, from the packs' public format documentation only: reading packs, the preprocessor and `.properties` files are merged ([#1584](https://github.com/horizon-rust/horizon/issues/1584), [#1585](https://github.com/horizon-rust/horizon/issues/1585), [#1586](https://github.com/horizon-rust/horizon/issues/1586)); translating programs to WGSL through naga, buffers, uniforms and the terrain, entity, sky, weather and hand stages are in review ([#1698](https://github.com/horizon-rust/horizon/issues/1698) to [#1801](https://github.com/horizon-rust/horizon/issues/1801)).
|
||||
|
||||
More: [#1561](https://github.com/horizon-rust/horizon/issues/1561) · [#1771](https://github.com/horizon-rust/horizon/issues/1771) · [#1786](https://github.com/horizon-rust/horizon/issues/1786)
|
||||
More: [#1561](https://github.com/horizon-rust/horizon/issues/1561) · [#1771](https://github.com/horizon-rust/horizon/issues/1771) · [#1786](https://github.com/horizon-rust/horizon/issues/1786) · [#9045](https://github.com/horizon-rust/horizon/issues/9045) · [#6233](https://github.com/horizon-rust/horizon/issues/6233) · [#6485](https://github.com/horizon-rust/horizon/issues/6485) · [#3734](https://github.com/horizon-rust/horizon/issues/3734) · [#3125](https://github.com/horizon-rust/horizon/issues/3125)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -298,6 +298,8 @@ Far terrain drawn from the world you really built and explored, caves and builds
|
||||
|
||||
Keep a 3D mip store on disk built from real chunks, drawn by the GPU beside the seed preview for areas nobody has visited. Written from scratch and measured before it is kept. Builds on Far Detail and the far-terrain levels of detail already shipped; the open work on them is [#718](https://github.com/horizon-rust/horizon/issues/718).
|
||||
|
||||
More: [#4678](https://github.com/horizon-rust/horizon/issues/4678)
|
||||
|
||||
</details>
|
||||
|
||||
#### A batch of targeted optimisations
|
||||
@@ -308,7 +310,7 @@ Several smaller speed-ups, each measured: idle block entities that sleep, cheape
|
||||
|
||||
Block entities and hoppers that skip work when idle (the hopper item scan is already skipped when no item is near); a particle frustum test first (also a parity gap), then particle quads built on the GPU; jigsaw shortcuts that skip blocked connectors and pool elements known not to fit, with layouts unchanged (trial chambers and ancient cities first); batched raw mouse input on Windows for 8000 Hz mice; a better atlas packer for huge resource packs (a 64x pack builds a 704 MiB atlas for 452 MiB of textures); sign-heavy scenes ([#1461](https://github.com/horizon-rust/horizon/issues/1461)). Profile first, keep what pays, show before and after numbers.
|
||||
|
||||
More: [docs/profiling.md](docs/profiling.md)
|
||||
More: [docs/profiling.md](docs/profiling.md) · [#7752](https://github.com/horizon-rust/horizon/issues/7752) · [#8251](https://github.com/horizon-rust/horizon/issues/8251) · [#10229](https://github.com/horizon-rust/horizon/issues/10229)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -322,6 +324,8 @@ Recognise mods built for versions before 26.1 in the mod browser, so they can be
|
||||
|
||||
A mapping table from intermediary and SRG names so pre-26.1 jars can be classified, and a rename for mod ids with dashes (`fabric-api`); the port step then treats them like newer jars ([docs/mod-browser.md](docs/mod-browser.md)).
|
||||
|
||||
More: [#10262](https://github.com/horizon-rust/horizon/issues/10262)
|
||||
|
||||
</details>
|
||||
|
||||
#### A modpack's own resource and shader packs
|
||||
@@ -536,6 +540,8 @@ Contraptions and physics like the Create and Aeronautics mods, as a mod for Hori
|
||||
|
||||
Issue [#805](https://github.com/horizon-rust/horizon/issues/805) (porting QOL mods) closed with this answer: it would come as a native Horizon mod.
|
||||
|
||||
More: [#6302](https://github.com/horizon-rust/horizon/issues/6302) · [#5313](https://github.com/horizon-rust/horizon/issues/5313) · [#5556](https://github.com/horizon-rust/horizon/issues/5556)
|
||||
|
||||
</details>
|
||||
|
||||
#### Proximity voice chat as a native mod
|
||||
@@ -558,6 +564,8 @@ Every past and future Minecraft version prepared for Horizon automatically. When
|
||||
|
||||
The port pipeline ([docs/version-ports.md](docs/version-ports.md), `tools/version_port.py`) detects a release, prepares it, diffs its classes against the pinned 26.3, regenerates the data-driven tables, runs every parity harness on it and writes a port report that becomes issues. The goal is as little human work as possible for any version, past or future. Detection and the first slices are merged; the scheduled agent that runs it every day isn't installed yet. Two versions in one build are out of scope.
|
||||
|
||||
More: [#4758](https://github.com/horizon-rust/horizon/issues/4758)
|
||||
|
||||
</details>
|
||||
|
||||
#### Controllers: tested on real pads, and in the launcher
|
||||
@@ -568,6 +576,8 @@ Make controller support solid: tested with real PlayStation and Xbox pads and a
|
||||
|
||||
The game side is merged ([docs/controllers.md](docs/controllers.md)): pads read with gilrs, menus, rumble, glyphs, remapping and DualSense adaptive triggers, lightbar and haptics, but not yet confirmed on real pads or a Steam Deck (DualSense haptics stay behind a switch until then). Left: a test pass on real hardware, and the same focus navigation in the GPUI launcher.
|
||||
|
||||
More: [#10298](https://github.com/horizon-rust/horizon/issues/10298) · [#8678](https://github.com/horizon-rust/horizon/issues/8678)
|
||||
|
||||
</details>
|
||||
|
||||
#### The full debug screen
|
||||
@@ -668,6 +678,16 @@ A shared resource store keyed by the preparer's digest, no prepare intermediates
|
||||
|
||||
</details>
|
||||
|
||||
#### A launcher screen readers can read
|
||||
|
||||
Screen readers read the launcher's buttons, lists and settings, and every part of it works from the keyboard.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#4537](https://github.com/horizon-rust/horizon/issues/4537): the launcher is GPUI (not Electron), so it exposes nothing to UI Automation, NSAccessibility or AT-SPI yet. Needs an accessibility tree from GPUI (an AccessKit bridge) and a role and label on each widget.
|
||||
|
||||
</details>
|
||||
|
||||
#### A visible beta label
|
||||
|
||||
A clear 'beta' label in the launcher and the game, so nobody is surprised that Horizon is still being built.
|
||||
@@ -690,6 +710,18 @@ Issue [#1709](https://github.com/horizon-rust/horizon/issues/1709): aarch64 buil
|
||||
|
||||
</details>
|
||||
|
||||
#### The launcher builds Horizon on other systems
|
||||
|
||||
On a system Horizon has no download for, such as FreeBSD, the launcher offers to build the game on your computer, as it already does for native mods.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#4354](https://github.com/horizon-rust/horizon/issues/4354): when no release asset matches the platform, the launcher fetches the tagged source and runs the same pinned-toolchain `cargo build --locked` its native-mod builds use, with a warning about the time it takes. The build is unsigned, so the launcher marks the version as built locally.
|
||||
|
||||
More: [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Horizon on Windows ARM64
|
||||
|
||||
A native Horizon for Windows on ARM computers, such as Snapdragon laptops.
|
||||
@@ -710,6 +742,8 @@ Versions of Horizon for Android, iOS and web browsers. Nothing official yet; con
|
||||
|
||||
No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS ([#1813](https://github.com/horizon-rust/horizon/issues/1813)) and helper-thread limits on Android ([#1751](https://github.com/horizon-rust/horizon/issues/1751)). Preparing without Java ([#1750](https://github.com/horizon-rust/horizon/issues/1750)) matters here too. Horizon ships none of Mojang's files on any platform.
|
||||
|
||||
More: [#9134](https://github.com/horizon-rust/horizon/issues/9134) · [#8069](https://github.com/horizon-rust/horizon/issues/8069) · [#4153](https://github.com/horizon-rust/horizon/issues/4153)
|
||||
|
||||
</details>
|
||||
|
||||
#### Horizon on Intel Macs
|
||||
@@ -752,6 +786,8 @@ Record, replay and edit videos without leaving the game: record in a keypress, r
|
||||
|
||||
Three stages. Recording: capture gameplay in a keypress with an encoder thread and little frame-time cost. Replay: a replay mode for recorded sessions. Editing: cuts, camera paths, titles and export. None of it exists yet; the agent-control socket ([docs/agent-control.md](docs/agent-control.md)) already lets tools drive the game for scripted shots.
|
||||
|
||||
More: [#10025](https://github.com/horizon-rust/horizon/issues/10025)
|
||||
|
||||
</details>
|
||||
|
||||
#### Game menus in the launcher's style
|
||||
@@ -842,6 +878,8 @@ Every download Horizon makes is checked against the project's signature, includi
|
||||
|
||||
Releases carry signed checksums, and the launcher and `horizon-server update` refuse anything unsigned ([#1011](https://github.com/horizon-rust/horizon/issues/1011), [#1014](https://github.com/horizon-rust/horizon/issues/1014)). Left: the first install through `install.sh` and `install.ps1` still trusts only the checksum GitHub publishes, because verifying Ed25519 signatures needs OpenSSL 3, which macOS doesn't ship ([docs/security.md](docs/security.md)).
|
||||
|
||||
More: [#10354](https://github.com/horizon-rust/horizon/issues/10354)
|
||||
|
||||
</details>
|
||||
|
||||
## Help wanted
|
||||
|
||||
@@ -108,6 +108,16 @@ impl<T> CompiledChoices<T> {
|
||||
multipart: self.multipart.iter().map(|c| c.map(&mut f)).collect(),
|
||||
}
|
||||
}
|
||||
/// A variant that needs no random selection and produces exactly one model.
|
||||
pub fn single_variant(&self, selected: &SelectedModels) -> Option<&T> {
|
||||
let SelectedModels::Variant(index) = selected else {
|
||||
return None;
|
||||
};
|
||||
match self.variants.get(*index)? {
|
||||
Choice::Single(value) => Some(value),
|
||||
Choice::Weighted { .. } => None,
|
||||
}
|
||||
}
|
||||
/// The variant `particleMaterial` comes from: the first entry of a weighted
|
||||
/// list (`WeightedVariants`) and, for a multipart root, of its first
|
||||
/// selector whether or not that selector applies (`MultiPartModel`).
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
//! NBT path parser output and byte cursor for the pinned Java oracle.
|
||||
use horizon_client_core::nbt_path::NbtPath;
|
||||
use std::io::{BufRead, Write};
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let input = std::io::BufReader::new(std::fs::File::open(args.next().unwrap())?);
|
||||
let mut output = std::io::BufWriter::new(std::fs::File::create(args.next().unwrap())?);
|
||||
for line in input.lines() {
|
||||
let text: String = serde_json::from_str(&line?)?;
|
||||
match NbtPath::parse_prefix(&text) {
|
||||
Ok((path, cursor)) => {
|
||||
write!(output, "OK {cursor} ")?;
|
||||
for value in path.as_str().as_bytes() {
|
||||
write!(output, "{value:02x}")?;
|
||||
}
|
||||
writeln!(output)?;
|
||||
}
|
||||
Err(error) => writeln!(output, "ERR {} {}", error.cursor, error.key)?,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -111,7 +111,15 @@ impl Parser<'_> {
|
||||
map.insert(key, self.value()?);
|
||||
self.ws();
|
||||
match self.peek() {
|
||||
Some(b',') => self.i += 1,
|
||||
// SnbtGrammar: an entry may be followed by a comma before the brace.
|
||||
Some(b',') => {
|
||||
self.i += 1;
|
||||
self.ws();
|
||||
if self.peek() == Some(b'}') {
|
||||
self.i += 1;
|
||||
return Ok(Value::Object(map));
|
||||
}
|
||||
}
|
||||
Some(b'}') => {
|
||||
self.i += 1;
|
||||
return Ok(Value::Object(map));
|
||||
@@ -140,7 +148,15 @@ impl Parser<'_> {
|
||||
list.push(self.value()?);
|
||||
self.ws();
|
||||
match self.peek() {
|
||||
Some(b',') => self.i += 1,
|
||||
// SnbtGrammar: a list or array may end with a comma.
|
||||
Some(b',') => {
|
||||
self.i += 1;
|
||||
self.ws();
|
||||
if self.peek() == Some(b']') {
|
||||
self.i += 1;
|
||||
return Ok(Value::Array(list));
|
||||
}
|
||||
}
|
||||
Some(b']') => {
|
||||
self.i += 1;
|
||||
return Ok(Value::Array(list));
|
||||
@@ -497,6 +513,22 @@ pub fn name_text(tag: &Tag) -> Text {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// SnbtGrammar: a compound or list may end with one comma; two in a row
|
||||
/// stay an error.
|
||||
#[test]
|
||||
fn a_component_takes_one_trailing_comma() {
|
||||
let parse = |s: &str| parse_snbt(s).map(|(v, _)| v);
|
||||
assert_eq!(
|
||||
parse("{text:\"a\",color:\"red\",}"),
|
||||
parse("{text:\"a\",color:\"red\"}")
|
||||
);
|
||||
assert_eq!(
|
||||
parse("[\"a\", {text:\"b\",} ,]"),
|
||||
parse("[\"a\",{text:\"b\"}]")
|
||||
);
|
||||
assert!(parse("{text:\"a\",,}").is_err());
|
||||
assert!(parse("[\"a\",,]").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_color_follows_text_color_parse_color() {
|
||||
|
||||
@@ -726,11 +726,16 @@ fn parse_node(text: &str, i: usize, first: bool) -> Result<(Node, usize), PathEr
|
||||
Some(b']') => Ok((Node::AllElements, j + 1)),
|
||||
_ => {
|
||||
let start = j;
|
||||
if b.get(j) == Some(&b'-') {
|
||||
// StringReader.readInt scans all allowed number characters
|
||||
// before parsing, including malformed signs and decimal points.
|
||||
while b
|
||||
.get(j)
|
||||
.is_some_and(|c| c.is_ascii_digit() || matches!(c, b'-' | b'.'))
|
||||
{
|
||||
j += 1;
|
||||
}
|
||||
while b.get(j).is_some_and(u8::is_ascii_digit) {
|
||||
j += 1;
|
||||
if start == j {
|
||||
return Err(PathError::new("parsing.int.expected", vec![], start));
|
||||
}
|
||||
let n: i32 = text[start..j].parse().map_err(|_| {
|
||||
PathError::new(
|
||||
@@ -792,6 +797,13 @@ fn read_quoted(text: &str, i: usize) -> Result<(String, usize), PathError> {
|
||||
let mut escaped = false;
|
||||
for (o, c) in text[i + 1..].char_indices() {
|
||||
if escaped {
|
||||
if c != quote && c != '\\' {
|
||||
return Err(PathError::new(
|
||||
"parsing.quote.escape",
|
||||
vec![c.to_string()],
|
||||
i + 1 + o,
|
||||
));
|
||||
}
|
||||
out.push(c);
|
||||
escaped = false;
|
||||
} else if c == '\\' {
|
||||
@@ -840,6 +852,40 @@ fn parse_compound(text: &str, i: usize) -> Result<(Tag, usize), PathError> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn quoted_escapes_and_index_tokens_match_brigadier() {
|
||||
let cases = [
|
||||
(r#""a\q""#, "parsing.quote.escape", vec!["q"], 3),
|
||||
(r#"'a\x'"#, "parsing.quote.escape", vec!["x"], 3),
|
||||
("[1.0]", "parsing.int.invalid", vec!["1.0"], 1),
|
||||
("[.]", "parsing.int.invalid", vec!["."], 1),
|
||||
("[--1]", "parsing.int.invalid", vec!["--1"], 1),
|
||||
("[2147483648]", "parsing.int.invalid", vec!["2147483648"], 1),
|
||||
("[+1]", "parsing.int.expected", vec![], 1),
|
||||
(r#""é\q""#, "parsing.quote.escape", vec!["q"], 4),
|
||||
(r#""é"[1.0]"#, "parsing.int.invalid", vec!["1.0"], 5),
|
||||
];
|
||||
let mut failures = Vec::new();
|
||||
for (text, key, args, cursor) in cases {
|
||||
match NbtPath::parse_prefix(text) {
|
||||
Err(error) if error.key == key && error.args == args && error.cursor == cursor => {}
|
||||
other => failures.push(format!("{text:?}: {other:?}")),
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_quoted_escapes_still_select_the_exact_field() {
|
||||
let root = Tag::compound([("a\"b", Tag::Int(7)), ("a\\b", Tag::Int(8))]);
|
||||
for (path, value) in [(r#""a\"b""#, 7), (r#""a\\b""#, 8)] {
|
||||
assert_eq!(
|
||||
NbtPath::parse(path).unwrap().get(&root).unwrap(),
|
||||
vec![Tag::Int(value)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_provenance_handles_quotes_and_filters_without_matching_lists() {
|
||||
for text in [
|
||||
|
||||
@@ -393,19 +393,38 @@ impl FullscreenState {
|
||||
let reapply = self.set_exclusive(exclusive);
|
||||
self.set_preferred_mode(preferred);
|
||||
let mode_change = !in_video_screen && self.take_mode_change();
|
||||
match request {
|
||||
Some(false) => window.set_fullscreen(None),
|
||||
Some(true) => window.set_fullscreen(Some(fullscreen_request(
|
||||
if wanted && window.is_maximized() {
|
||||
window.set_maximized(false);
|
||||
window.set_fullscreen(Some(fullscreen_request(
|
||||
window,
|
||||
exclusive,
|
||||
preferred.as_ref(),
|
||||
))),
|
||||
None if reapply || mode_change => window.set_fullscreen(Some(fullscreen_request(
|
||||
window,
|
||||
exclusive,
|
||||
preferred.as_ref(),
|
||||
))),
|
||||
None => {}
|
||||
)));
|
||||
} else {
|
||||
match request {
|
||||
Some(false) => window.set_fullscreen(None),
|
||||
Some(true) => {
|
||||
if window.is_maximized() {
|
||||
window.set_maximized(false);
|
||||
}
|
||||
window.set_fullscreen(Some(fullscreen_request(
|
||||
window,
|
||||
exclusive,
|
||||
preferred.as_ref(),
|
||||
)));
|
||||
}
|
||||
None if reapply || mode_change => {
|
||||
if window.is_maximized() {
|
||||
window.set_maximized(false);
|
||||
}
|
||||
window.set_fullscreen(Some(fullscreen_request(
|
||||
window,
|
||||
exclusive,
|
||||
preferred.as_ref(),
|
||||
)));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -592,13 +611,13 @@ pub fn fullscreen_request(
|
||||
preferred: Option<&VideoMode>,
|
||||
) -> winit::window::Fullscreen {
|
||||
use winit::window::Fullscreen;
|
||||
if !exclusive {
|
||||
return Fullscreen::Borderless(None);
|
||||
}
|
||||
let Some(monitor) = window
|
||||
let monitor = window
|
||||
.current_monitor()
|
||||
.or_else(|| window.primary_monitor())
|
||||
else {
|
||||
.or_else(|| window.primary_monitor());
|
||||
if !exclusive {
|
||||
return Fullscreen::Borderless(monitor);
|
||||
}
|
||||
let Some(monitor) = monitor else {
|
||||
return Fullscreen::Borderless(None);
|
||||
};
|
||||
let wanted = preferred.and_then(|p| {
|
||||
@@ -608,7 +627,7 @@ pub fn fullscreen_request(
|
||||
});
|
||||
match wanted.or_else(|| monitor_current_mode(&monitor)) {
|
||||
Some(mode) => Fullscreen::Exclusive(mode),
|
||||
None => Fullscreen::Borderless(None),
|
||||
None => Fullscreen::Borderless(Some(monitor)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -369,6 +369,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
visible_static_block_entity_sections: &[],
|
||||
static_block_entity_stats: Default::default(),
|
||||
entity_motion: &[],
|
||||
entity_motion_posed: &[],
|
||||
entity_spans: &[],
|
||||
skin: None,
|
||||
glowing: &[],
|
||||
|
||||
@@ -856,27 +856,50 @@ fn cards_changed(seen: Option<&str>, cards: Option<&str>) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The buffer size to request for `open_device`. On Linux the ALSA backend's
|
||||
/// `BufferSize::Default` means a ~100 ms buffer, so sound effects come out
|
||||
/// about 100 ms late: request a short fixed buffer (1024 frames) by default.
|
||||
/// An environment override `HORIZON_AUDIO_BUFFER` can specify `default` or a
|
||||
/// custom frame count (e.g. 2048) when the default short buffer underruns
|
||||
/// (e.g. PipeWire min-quantum >= 1024).
|
||||
#[cfg(feature = "audio")]
|
||||
fn target_buffer_size(
|
||||
range: (u32, u32),
|
||||
var: impl Fn(&str) -> Option<std::ffi::OsString>,
|
||||
) -> Option<cpal::BufferSize> {
|
||||
if let Some(val) = var("HORIZON_AUDIO_BUFFER").and_then(|v| v.into_string().ok()) {
|
||||
if val == "default" {
|
||||
return Some(cpal::BufferSize::Default);
|
||||
}
|
||||
if let Ok(frames) = val.parse::<u32>() {
|
||||
return Some(cpal::BufferSize::Fixed(frames.clamp(range.0, range.1)));
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
Some(cpal::BufferSize::Fixed(1024u32.clamp(range.0, range.1)))
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The cpal `StreamConfig` to open an output stream with. On Linux the ALSA
|
||||
/// backend's `BufferSize::Default` means a ~100 ms buffer, so sound effects
|
||||
/// come out about 100 ms late: request a short fixed buffer there. Other
|
||||
/// platforms keep cpal's default behaviour.
|
||||
#[cfg(feature = "audio")]
|
||||
fn audio_stream_config(config: &cpal::SupportedStreamConfig) -> cpal::StreamConfig {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let range = match config.buffer_size() {
|
||||
cpal::SupportedBufferSize::Range { min, max } => (*min, *max),
|
||||
cpal::SupportedBufferSize::Unknown => (256, 4096),
|
||||
};
|
||||
let target = 1024u32.clamp(range.0, range.1);
|
||||
let mut sc = config.config();
|
||||
sc.buffer_size = cpal::BufferSize::Fixed(target);
|
||||
sc
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
config.config()
|
||||
let mut sc = config.config();
|
||||
let range = match config.buffer_size() {
|
||||
cpal::SupportedBufferSize::Range { min, max } => (*min, *max),
|
||||
cpal::SupportedBufferSize::Unknown => (256, 4096),
|
||||
};
|
||||
if let Some(bs) = target_buffer_size(range, |name| std::env::var_os(name)) {
|
||||
sc.buffer_size = bs;
|
||||
}
|
||||
sc
|
||||
}
|
||||
|
||||
#[cfg(feature = "audio")]
|
||||
@@ -1805,6 +1828,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
#[cfg(feature = "audio")]
|
||||
fn buffer_size_override_and_defaults() {
|
||||
let env = |pairs: &'static [(&'static str, &'static str)]| {
|
||||
move |name: &str| {
|
||||
pairs
|
||||
.iter()
|
||||
.find(|(k, _)| *k == name)
|
||||
.map(|(_, v)| std::ffi::OsString::from(*v))
|
||||
}
|
||||
};
|
||||
let range = (256, 4096);
|
||||
assert_eq!(
|
||||
target_buffer_size(range, env(&[("HORIZON_AUDIO_BUFFER", "default")])),
|
||||
Some(cpal::BufferSize::Default)
|
||||
);
|
||||
assert_eq!(
|
||||
target_buffer_size(range, env(&[("HORIZON_AUDIO_BUFFER", "2048")])),
|
||||
Some(cpal::BufferSize::Fixed(2048))
|
||||
);
|
||||
assert_eq!(
|
||||
target_buffer_size(range, env(&[("HORIZON_AUDIO_BUFFER", "100")])),
|
||||
Some(cpal::BufferSize::Fixed(256))
|
||||
);
|
||||
assert_eq!(
|
||||
target_buffer_size(range, env(&[("HORIZON_AUDIO_BUFFER", "10000")])),
|
||||
Some(cpal::BufferSize::Fixed(4096))
|
||||
);
|
||||
#[cfg(target_os = "linux")]
|
||||
assert_eq!(
|
||||
target_buffer_size(range, env(&[])),
|
||||
Some(cpal::BufferSize::Fixed(1024))
|
||||
);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
assert_eq!(target_buffer_size(range, env(&[])), None);
|
||||
}
|
||||
#[test]
|
||||
fn linear_attenuation_reaches_zero_at_the_max_distance() {
|
||||
assert_eq!(distance_gain(None, 100.0), 1.0);
|
||||
assert_eq!(distance_gain(Some(16.0), 0.0), 1.0);
|
||||
|
||||
@@ -3407,7 +3407,9 @@ impl BeamPart {
|
||||
(at(b, self.end), [s.u0, v1]),
|
||||
] {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
// The beacon beam render types keep the default back-face culling: seen
|
||||
// from inside a beam, its walls must not show.
|
||||
normal: CULL_BACK,
|
||||
pos: p.to_array(),
|
||||
uv,
|
||||
layer: 6,
|
||||
@@ -4277,6 +4279,23 @@ mod tests {
|
||||
/// submits the unit cube's two Y faces. Both are wound outward, so of each
|
||||
/// face only the side looking at the camera draws: the portal shows one
|
||||
/// surface from above and one from below, never both at once.
|
||||
/// `RenderPipelines.BEACON_BEAM_OPAQUE` / `_TRANSLUCENT` keep the default
|
||||
/// back-face culling, so a camera inside a beam sees no wall of it.
|
||||
#[test]
|
||||
fn beacon_beam_faces_cull_their_backs() {
|
||||
let rect = Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
beacon_beam(Vec3::ZERO, &[(0xFFFFFF, 4)], 0.0, 1.0, rect, &mut out);
|
||||
assert_eq!(out.len(), 32, "solid and glow, four quads each");
|
||||
assert!(out.iter().all(|v| v.normal == CULL_BACK));
|
||||
}
|
||||
#[test]
|
||||
fn the_end_portal_cube_is_one_sided() {
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
//! Every block state's facts and looks by its id, for a client mod drawing blocks or a map off the main thread
|
||||
//! (`native_block_states`; docs/native-mods.md): what the game knows of each state, what its model draws on each side
|
||||
//! and the texture behind each of those sprites. A copy, so the game's own tables can change shape under it.
|
||||
use crate::{
|
||||
block_sprites::{BlockSprites, Lookup, SpriteRef},
|
||||
streaming::WorkerContext,
|
||||
};
|
||||
use horizon_items::blocks::{BlockFacts, MAP_COLORS, RenderShape};
|
||||
use horizon_world::{
|
||||
block_collision::BlockCollisionRegistry, fluid::FluidState, registry::BlockStateId,
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
/// What one block state is, as a renderer or a map reads it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub struct BlockStateFacts {
|
||||
/// Its block, without properties (`minecraft:grass_block`).
|
||||
pub name: Arc<str>,
|
||||
/// `BlockState.isAir`: air, cave air or void air.
|
||||
pub air: bool,
|
||||
/// No model draws it (render shape `INVISIBLE`): air, barriers, light, and water and lava, drawn as fluids.
|
||||
pub invisible: bool,
|
||||
/// `BlockState.getFluidState`: the fluid in it, a waterlogged block's and kelp's included; empty for none.
|
||||
pub fluid: FluidState,
|
||||
/// Its `waterlogged` property is true.
|
||||
pub waterlogged: bool,
|
||||
/// The light it gives, 0 to 15.
|
||||
pub emission: u8,
|
||||
/// `BlockState.getShape`, the outline the cursor picks, as block-local boxes
|
||||
/// `[min_x, min_y, min_z, max_x, max_y, max_z]`: `[0, 0, 0, 1, 1, 1]` for a whole block, none for air. Without
|
||||
/// the random offset of flowers and grass.
|
||||
pub outline: Arc<[[f64; 6]]>,
|
||||
/// `MapColor.col` (`0xRRGGBB`), its colour on a vanilla map; none for `MapColor.NONE`, which maps leave out.
|
||||
pub map_color: Option<u32>,
|
||||
/// It can collide at all: false for a state whose collision shape is always empty (air, flowers, water).
|
||||
pub collides: bool,
|
||||
}
|
||||
|
||||
// A mod builds its tables from these on a worker thread.
|
||||
const _: () = {
|
||||
const fn shareable<T: Send + Sync + 'static>() {}
|
||||
shareable::<BlockStates>()
|
||||
};
|
||||
|
||||
/// Every block state by its id: its facts, and what its model draws with the resources loaded when it was made.
|
||||
#[derive(Clone)]
|
||||
pub struct BlockStates {
|
||||
states: Arc<[BlockStateFacts]>,
|
||||
context: Arc<WorkerContext>,
|
||||
/// Texture names by sprite layer, across every array.
|
||||
textures: Arc<HashMap<u32, String>>,
|
||||
split: u32,
|
||||
}
|
||||
|
||||
impl BlockStates {
|
||||
/// The states of `context`'s registry, with their `facts` and `collision`, and the sprites of `context`'s
|
||||
/// resources at `split` layers to an array (`Renderer::sprite_split`).
|
||||
pub fn new(
|
||||
facts: &BlockFacts,
|
||||
collision: &BlockCollisionRegistry<'_>,
|
||||
context: Arc<WorkerContext>,
|
||||
split: u32,
|
||||
) -> Self {
|
||||
let registry = context.assets.registry;
|
||||
let states = facts
|
||||
.states
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, f)| {
|
||||
let id = BlockStateId(i as u32);
|
||||
BlockStateFacts {
|
||||
name: f.name.clone(),
|
||||
air: facts.is_air(id),
|
||||
invisible: f.render_shape == RenderShape::Invisible,
|
||||
fluid: registry
|
||||
.state(id)
|
||||
.and_then(|state| FluidState::from_block(state).ok())
|
||||
.unwrap_or(FluidState::EMPTY),
|
||||
waterlogged: f.waterlogged,
|
||||
emission: f.emission,
|
||||
outline: f.outline.clone(),
|
||||
map_color: MAP_COLORS
|
||||
.get(usize::from(f.map_color))
|
||||
.copied()
|
||||
.filter(|_| f.map_color != 0),
|
||||
collides: collision.may_collide(id),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// Vanilla's names go without their namespace, as `native_draw_cube` takes them.
|
||||
let textures = context
|
||||
.render
|
||||
.sprite_layers
|
||||
.iter()
|
||||
.map(|(name, layer)| (*layer, name.trim_start_matches("minecraft:").to_owned()))
|
||||
.collect();
|
||||
Self {
|
||||
states,
|
||||
context,
|
||||
textures: Arc::new(textures),
|
||||
split: split.max(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// How many states there are: their ids run from 0 to one less.
|
||||
pub fn len(&self) -> usize {
|
||||
self.states.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.states.is_empty()
|
||||
}
|
||||
|
||||
/// `state`'s facts; none for an id past the last state.
|
||||
pub fn get(&self, state: BlockStateId) -> Option<&BlockStateFacts> {
|
||||
self.states.get(state.0 as usize)
|
||||
}
|
||||
|
||||
/// Every state with its id, in id order.
|
||||
pub fn iter(&self) -> impl Iterator<Item = (BlockStateId, &BlockStateFacts)> {
|
||||
self.states
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, facts)| (BlockStateId(i as u32), facts))
|
||||
}
|
||||
|
||||
/// What `state` draws on each side, as `native_block_sprites` says for a block by name; none for an id past the
|
||||
/// last state.
|
||||
pub fn sprites(&self, state: BlockStateId) -> Option<BlockSprites> {
|
||||
self.get(state)?;
|
||||
Some(Lookup::new(&self.context, self.split).state(state))
|
||||
}
|
||||
|
||||
/// The texture `sprite` shows, by the name `native_texture_sprites` takes (`block/grass_block_top`; a mod's with
|
||||
/// its namespace, `mymod:block/gear`).
|
||||
pub fn texture(&self, sprite: SpriteRef) -> Option<&str> {
|
||||
let layer = sprite
|
||||
.array
|
||||
.checked_mul(self.split)?
|
||||
.checked_add(sprite.layer)?;
|
||||
self.textures.get(&layer).map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
assets::Assets, biome::Biomes, block_sprites::parse_state, light::LightTables,
|
||||
mesher::MeshTables,
|
||||
};
|
||||
use horizon_assets::model::Direction;
|
||||
use horizon_world::fluid::FluidKind;
|
||||
use std::{path::PathBuf, sync::OnceLock};
|
||||
|
||||
/// Far fewer layers to an array than a GPU takes, so most sprites lie past array 0.
|
||||
const SMALL_SPLIT: u32 = 64;
|
||||
|
||||
/// The game's own states from real prepared resources, loaded once for every test here.
|
||||
fn states() -> &'static BlockStates {
|
||||
static STATES: OnceLock<BlockStates> = OnceLock::new();
|
||||
STATES.get_or_init(|| {
|
||||
let root = PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"),
|
||||
);
|
||||
let assets = Arc::new(Assets::load(&root).unwrap());
|
||||
let render = Arc::new(assets.reload(&root).unwrap());
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let collision = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
|
||||
let context = Arc::new(WorkerContext {
|
||||
light: Arc::new(LightTables::load(&root, assets.registry, &facts).unwrap()),
|
||||
tables: Arc::new(MeshTables::new(&assets, &render, &facts, visibility).unwrap()),
|
||||
assets: assets.clone(),
|
||||
render,
|
||||
biomes: Arc::new(Biomes::load(&root).unwrap()),
|
||||
});
|
||||
BlockStates::new(&facts, &collision, context, SMALL_SPLIT)
|
||||
})
|
||||
}
|
||||
|
||||
fn facts(text: &str) -> &'static BlockStateFacts {
|
||||
let states = states();
|
||||
let id = parse_state(states.context.assets.registry, text).expect(text);
|
||||
states.get(id).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_grass_block_is_a_whole_solid_grass_coloured_block() {
|
||||
let grass = facts("grass_block");
|
||||
assert_eq!(&*grass.name, "minecraft:grass_block");
|
||||
assert!(!grass.air && !grass.invisible && !grass.waterlogged);
|
||||
assert_eq!(grass.fluid.kind(), FluidKind::Empty);
|
||||
assert_eq!(grass.emission, 0);
|
||||
assert_eq!(&*grass.outline, [[0.0, 0.0, 0.0, 1.0, 1.0, 1.0]]);
|
||||
assert_eq!(grass.map_color, Some(0x7F_B238), "MapColor.GRASS");
|
||||
assert!(grass.collides);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn air_and_light_are_not_drawn() {
|
||||
let air = facts("air");
|
||||
assert!(air.air && air.invisible && !air.collides);
|
||||
assert!(air.outline.is_empty());
|
||||
assert_eq!(air.map_color, None);
|
||||
let light = facts("light[level=15]");
|
||||
assert!(!light.air && light.invisible);
|
||||
assert_eq!(light.emission, 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_slabs_outline_is_its_half() {
|
||||
assert_eq!(
|
||||
&*facts("stone_slab[type=bottom]").outline,
|
||||
[[0.0, 0.0, 0.0, 1.0, 0.5, 1.0]]
|
||||
);
|
||||
assert_eq!(
|
||||
&*facts("stone_slab[type=top]").outline,
|
||||
[[0.0, 0.5, 0.0, 1.0, 1.0, 1.0]]
|
||||
);
|
||||
let wet = facts("stone_slab[type=bottom,waterlogged=true]");
|
||||
assert!(wet.waterlogged && wet.collides);
|
||||
assert_eq!(wet.fluid.kind(), FluidKind::Water);
|
||||
assert!(wet.fluid.is_source());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_face_names_its_texture() {
|
||||
let states = states();
|
||||
let id = parse_state(states.context.assets.registry, "grass_block").unwrap();
|
||||
let grass = states.sprites(id).unwrap();
|
||||
let top = grass.sides[Direction::Up as usize][0].sprite;
|
||||
assert_eq!(states.texture(top), Some("block/grass_block_top"));
|
||||
let north = &grass.sides[Direction::North as usize];
|
||||
assert_eq!(
|
||||
states.texture(north[0].sprite),
|
||||
Some("block/grass_block_side")
|
||||
);
|
||||
assert_eq!(
|
||||
states.texture(north[1].sprite),
|
||||
Some("block/grass_block_side_overlay")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//! The files of the resources the game loaded, resource packs' over vanilla's, for a client mod reading textures and
|
||||
//! other resources on any thread (`native_client_resources`; docs/native-mods.md).
|
||||
use horizon_core::prepared_files;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A texture's pixels: `width` by `height` RGBA, rows top first, as its PNG stores them (gamma-encoded, alpha not
|
||||
/// premultiplied). An animated texture is its frames one under another, as in its file.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TexturePixels {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub rgba: Vec<u8>,
|
||||
}
|
||||
|
||||
// A mod reads textures on a worker thread.
|
||||
const _: () = {
|
||||
const fn shareable<T: Send + Sync + 'static>() {}
|
||||
shareable::<ClientResources>()
|
||||
};
|
||||
|
||||
/// The resources the game loaded, as one tree: vanilla's, with the resource packs turned on (a higher pack over a
|
||||
/// lower one) and the content mods' files over it. A pack turned on or off makes a new tree: ask for it again then.
|
||||
/// It reads the files on disk at each call, not a copy: while a reload rebuilds them, a read can miss a file or get
|
||||
/// the new pack's before `native_block_sprites_version()` changes.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ClientResources {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl ClientResources {
|
||||
/// The tree at `root` (the game's `options.resources`).
|
||||
pub fn new(root: PathBuf) -> Self {
|
||||
Self { root }
|
||||
}
|
||||
|
||||
/// The bytes of the resource at `path` in the tree (`assets/minecraft/textures/entity/cow/cow_temperate.png`).
|
||||
/// None when there is no such file, or for a path that isn't plain names under `assets/`.
|
||||
pub fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
let plain = |p: &str| !p.is_empty() && p != "." && p != ".." && !p.contains('\\');
|
||||
if !path.starts_with("assets/") || !path.split('/').all(plain) {
|
||||
return None;
|
||||
}
|
||||
prepared_files::read(&self.root.join(path)).ok()
|
||||
}
|
||||
|
||||
/// A texture's pixels by name (`block/stone`, `entity/cow/cow_temperate`, a mod's `mymod:item/gear`): the PNG
|
||||
/// under `assets/<namespace>/textures/`, decoded. None when there is no such PNG or it doesn't decode.
|
||||
pub fn texture(&self, name: &str) -> Option<TexturePixels> {
|
||||
let path = crate::atlas_sources::texture_path(&self.root, name, "textures");
|
||||
let (width, height, rgba) =
|
||||
crate::hud::decode_bytes(&prepared_files::read(&path).ok()?).ok()?;
|
||||
Some(TexturePixels {
|
||||
width,
|
||||
height,
|
||||
rgba,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::packs::{build_overlay, discover};
|
||||
use std::{fs, path::Path};
|
||||
|
||||
const COW: &str = "assets/minecraft/textures/entity/cow/cow_temperate.png";
|
||||
const RED: [u8; 4] = [255, 0, 0, 255];
|
||||
const BLUE: [u8; 4] = [0, 0, 255, 255];
|
||||
|
||||
fn one_pixel_png(rgba: [u8; 4]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut encoder = png::Encoder::new(&mut out, 1, 1);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&rgba)
|
||||
.unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
fn write(path: &Path, bytes: &[u8]) {
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resource_pack_overrides_a_texture() {
|
||||
let base =
|
||||
std::env::temp_dir().join(format!("horizon-client-resources-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
let vanilla = base.join("vanilla");
|
||||
write(&vanilla.join(COW), &one_pixel_png(RED));
|
||||
let pack = base.join("packs/Blue cows");
|
||||
write(
|
||||
&pack.join("pack.mcmeta"),
|
||||
br#"{"pack":{"description":"blue cows","pack_format":97}}"#,
|
||||
);
|
||||
write(&pack.join(COW), &one_pixel_png(BLUE));
|
||||
let overlay = base.join("overlay");
|
||||
build_overlay(&vanilla, &discover(&vanilla, &base.join("packs")), &overlay).unwrap();
|
||||
|
||||
let (plain, packed) = (ClientResources::new(vanilla), ClientResources::new(overlay));
|
||||
assert_eq!(plain.read(COW), Some(one_pixel_png(RED)));
|
||||
assert_eq!(packed.read(COW), Some(one_pixel_png(BLUE)));
|
||||
assert_eq!(
|
||||
packed.texture("entity/cow/cow_temperate"),
|
||||
Some(TexturePixels {
|
||||
width: 1,
|
||||
height: 1,
|
||||
rgba: BLUE.to_vec(),
|
||||
})
|
||||
);
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_plain_paths_under_assets_are_read() {
|
||||
let base = std::env::temp_dir().join(format!(
|
||||
"horizon-client-resources-paths-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
let root = base.join("root");
|
||||
write(&root.join(COW), &one_pixel_png(RED));
|
||||
write(&root.join("horizon-resources.json"), b"{}");
|
||||
write(&base.join("secret.txt"), b"secret");
|
||||
|
||||
let resources = ClientResources::new(root);
|
||||
assert!(resources.read(COW).is_some());
|
||||
for refused in [
|
||||
"horizon-resources.json",
|
||||
"assets/../horizon-resources.json",
|
||||
"assets/../../secret.txt",
|
||||
"assets//minecraft/textures/entity/cow/cow_temperate.png",
|
||||
"/assets/minecraft/textures/entity/cow/cow_temperate.png",
|
||||
] {
|
||||
assert_eq!(resources.read(refused), None, "{refused}");
|
||||
}
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
}
|
||||
@@ -98,14 +98,42 @@ pub struct Source {
|
||||
/// The [`MAX`] sources nearest `camera` within reach, as the shaders read them: camera-relative
|
||||
/// position and level. Overwrites `out`.
|
||||
pub fn select(sources: &[Source], camera: DVec3, out: &mut Vec<[f32; 4]>) {
|
||||
select_iter(sources.iter().copied(), camera, out);
|
||||
}
|
||||
|
||||
/// Streaming selection with the same stable distance order as [`select`]. Only the best
|
||||
/// [`MAX`] sources are kept, and each source's distance is evaluated once.
|
||||
pub fn select_iter(
|
||||
sources: impl IntoIterator<Item = Source>,
|
||||
camera: DVec3,
|
||||
out: &mut Vec<[f32; 4]>,
|
||||
) {
|
||||
out.clear();
|
||||
let key = |s: &Source| s.at.distance_squared(camera);
|
||||
let mut near: Vec<&Source> = sources
|
||||
.iter()
|
||||
.filter(|s| s.level > 0 && key(s) <= REACH * REACH)
|
||||
.collect();
|
||||
near.sort_by(|a, b| key(a).total_cmp(&key(b)));
|
||||
out.extend(near.into_iter().take(MAX).map(|s| {
|
||||
let mut near = [(
|
||||
0.0_f64,
|
||||
Source {
|
||||
at: DVec3::ZERO,
|
||||
level: 0,
|
||||
},
|
||||
); MAX];
|
||||
let mut len = 0;
|
||||
for source in sources {
|
||||
let distance = source.at.distance_squared(camera);
|
||||
// The original <= test also rejects NaN and infinite distances.
|
||||
if source.level == 0 || !(0.0..=REACH * REACH).contains(&distance) {
|
||||
continue;
|
||||
}
|
||||
let at = near[..len].partition_point(|(d, _)| d.total_cmp(&distance).is_le());
|
||||
if at == MAX {
|
||||
continue;
|
||||
}
|
||||
// Insert after equal distances: the first MAX sources in input order win ties.
|
||||
let end = len.min(MAX - 1);
|
||||
near.copy_within(at..end, at + 1);
|
||||
near[at] = (distance, source);
|
||||
len = (len + 1).min(MAX);
|
||||
}
|
||||
out.extend(near[..len].iter().map(|(_, s)| {
|
||||
let p = (s.at - camera).as_vec3();
|
||||
[p.x, p.y, p.z, f32::from(s.level.min(15))]
|
||||
}));
|
||||
@@ -179,6 +207,104 @@ mod tests {
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_selection_matches_stable_full_sort() {
|
||||
let camera = DVec3::new(30_000_000.0, -64.0, -30_000_000.0);
|
||||
let mut seed = 0x8469_d28b_314a_723fu64;
|
||||
let mut sources = Vec::new();
|
||||
for i in 0..2048 {
|
||||
let offset = std::array::from_fn::<_, 3, _>(|_| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
(seed % 241) as f64 - 120.0
|
||||
});
|
||||
sources.push(Source {
|
||||
at: camera + DVec3::from(offset),
|
||||
level: (i % 22) as u8,
|
||||
});
|
||||
}
|
||||
sources.extend([
|
||||
Source {
|
||||
at: camera + DVec3::X * 96.0,
|
||||
level: 14,
|
||||
},
|
||||
Source {
|
||||
at: camera - DVec3::X * 96.0,
|
||||
level: 15,
|
||||
},
|
||||
Source {
|
||||
at: camera + DVec3::X * 96.0001,
|
||||
level: 15,
|
||||
},
|
||||
Source {
|
||||
at: DVec3::splat(f64::NAN),
|
||||
level: 15,
|
||||
},
|
||||
Source {
|
||||
at: DVec3::splat(f64::INFINITY),
|
||||
level: 15,
|
||||
},
|
||||
]);
|
||||
let reference = |input: &[Source], camera: DVec3| {
|
||||
let mut selected: Vec<_> = input
|
||||
.iter()
|
||||
.filter(|s| s.level > 0 && s.at.distance_squared(camera) <= 96.0 * 96.0)
|
||||
.collect();
|
||||
selected.sort_by(|a, b| {
|
||||
a.at.distance_squared(camera)
|
||||
.total_cmp(&b.at.distance_squared(camera))
|
||||
});
|
||||
selected
|
||||
.into_iter()
|
||||
.take(16)
|
||||
.map(|s| {
|
||||
let p = (s.at - camera).as_vec3();
|
||||
[p.x, p.y, p.z, f32::from(s.level.min(15))]
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let mut out = vec![[999.0; 4]; 20];
|
||||
for input in [sources.clone(), sources.into_iter().rev().collect()] {
|
||||
for n in [0, 1, 15, 16, 17, 40, input.len()] {
|
||||
select_iter(input[..n].iter().copied(), camera, &mut out);
|
||||
assert_eq!(out, reference(&input[..n], camera));
|
||||
}
|
||||
select_iter(input.iter().copied(), DVec3::splat(f64::NAN), &mut out);
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_distances_keep_first_sources_and_reach_is_inclusive() {
|
||||
// Every source ties: neither a different position nor a different light level
|
||||
// may win the sixteenth slot just because it was inserted later.
|
||||
let sources: Vec<_> = (0..40)
|
||||
.map(|i| Source {
|
||||
at: if i % 2 == 0 {
|
||||
DVec3::X * 96.0
|
||||
} else {
|
||||
-DVec3::X * 96.0
|
||||
},
|
||||
level: (i % 15 + 1) as u8,
|
||||
})
|
||||
.collect();
|
||||
let mut out = Vec::new();
|
||||
select_iter(sources, DVec3::ZERO, &mut out);
|
||||
assert_eq!(out.len(), 16);
|
||||
for (i, light) in out.iter().enumerate() {
|
||||
assert_eq!(
|
||||
*light,
|
||||
[
|
||||
if i % 2 == 0 { 96.0 } else { -96.0 },
|
||||
0.0,
|
||||
0.0,
|
||||
(i % 15 + 1) as f32
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_falls_off_a_level_per_block() {
|
||||
let torch = [[0.0, 0.0, 0.0, 14.0]];
|
||||
|
||||
@@ -413,6 +413,13 @@ pub fn clear_distance_fog(fog: &mut FogData, input: &FogInputs) {
|
||||
(fog.environmental_start, fog.environmental_end) = (1e9, 2e9);
|
||||
}
|
||||
}
|
||||
/// Whether the frame draws no sky, only the clear to the fog colour: `LevelRenderer.addSkyPass`
|
||||
/// leaves the sky out in lava and powder snow and while blindness or darkness is on (`Camera`'s
|
||||
/// `doesMobEffectBlockSky`), and `GameRenderer.renderLevel` gives `LevelRenderer.render`
|
||||
/// `shouldRenderSky` false while a boss bar makes world fog (the Ender Dragon's).
|
||||
pub fn sky_pass_skipped(fog_type: FogType, effect_blocks_sky: bool, boss_world_fog: bool) -> bool {
|
||||
matches!(fog_type, FogType::Lava | FogType::PowderSnow) || effect_blocks_sky || boss_world_fog
|
||||
}
|
||||
/// `AtmosphericFogEnvironment.updateRainFogState`.
|
||||
pub fn update_rain_fog(
|
||||
state: &mut FogState,
|
||||
@@ -480,6 +487,20 @@ mod tests {
|
||||
assert_eq!(fog(surface - 0.01, -90.0), FogType::Atmospheric);
|
||||
}
|
||||
#[test]
|
||||
fn the_dragon_fights_world_fog_leaves_out_the_sky() {
|
||||
use FogType::*;
|
||||
// GameRenderer.renderLevel: no sky pass under a createWorldFog boss bar, in clear air
|
||||
// as in water.
|
||||
assert!(sky_pass_skipped(Atmospheric, false, true));
|
||||
assert!(sky_pass_skipped(Water, false, true));
|
||||
// LevelRenderer.addSkyPass's own conditions.
|
||||
assert!(sky_pass_skipped(Lava, false, false));
|
||||
assert!(sky_pass_skipped(PowderSnow, false, false));
|
||||
assert!(sky_pass_skipped(Atmospheric, true, false));
|
||||
assert!(!sky_pass_skipped(Atmospheric, false, false));
|
||||
assert!(!sky_pass_skipped(Water, false, false));
|
||||
}
|
||||
#[test]
|
||||
fn boss_world_fog_closes_in() {
|
||||
// AtmosphericFogEnvironment.setupFog under a createWorldFog boss bar.
|
||||
let sky = Sky {
|
||||
|
||||
@@ -881,7 +881,8 @@ pub struct Frame<'a> {
|
||||
pub cave_culling: bool,
|
||||
/// Title screen: only the dedicated panorama cube map and the HUD.
|
||||
pub title: bool,
|
||||
/// Lava, powder snow, blindness or darkness blocks the sky pass.
|
||||
/// Lava, powder snow, blindness, darkness or a boss bar's world fog leaves out the sky pass
|
||||
/// (`fog::sky_pass_skipped`).
|
||||
pub sky_blocked: bool,
|
||||
/// Dimensions without a skybox (the Nether) clear to the fog colour.
|
||||
pub skybox: bool,
|
||||
@@ -934,6 +935,10 @@ pub struct Frame<'a> {
|
||||
/// Which entity `entities`' vertices belong to: (first vertex, entity id), each run up
|
||||
/// to the next; id 0 for none. Frame generation follows these entities' own motion.
|
||||
pub entity_motion: &'a [(u32, u64)],
|
||||
/// For each mark of `entity_motion`, the GPU-posed vertex count at it (`skin`'s
|
||||
/// `vertices`): where that entity's posed vertices start. A shader pack's `at_velocity`
|
||||
/// matches both parts of an entity with last frame's.
|
||||
pub entity_motion_posed: &'a [u32],
|
||||
/// The glowing entities' silhouettes in their team colours (`OutlineBufferSource`),
|
||||
/// outlined through walls after the hand.
|
||||
pub glowing: &'a [Vertex],
|
||||
@@ -1394,10 +1399,14 @@ pub struct Renderer {
|
||||
hdr_lighting_setting: bool,
|
||||
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
|
||||
pack_post: Option<(i32, Option<pack_post::PackPost>)>,
|
||||
/// A `shaders/` pack whose pipelines build on a thread of their own (`load_shader_pack`).
|
||||
pack_loading: Option<PackLoading>,
|
||||
/// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s.
|
||||
native: Option<native_pack::NativePack>,
|
||||
/// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`).
|
||||
pass_timing: bool,
|
||||
/// Least time between timed frames (`set_pass_timing_gap`).
|
||||
pass_timing_gap: std::time::Duration,
|
||||
/// Times Horizon's own passes on the GPU, as `pass_timing` does (`own_pass_times`).
|
||||
own_timing: Option<pack_timing::PassTiming>,
|
||||
/// The entity vertices Horizon draws itself while a shader pack draws the others.
|
||||
@@ -2124,10 +2133,22 @@ async fn open_with_retry(
|
||||
);
|
||||
match open_backend(backends, window, offscreen, true, layers, allow_cpu).await {
|
||||
Ok(opened) => {
|
||||
let info = opened.adapter.get_info();
|
||||
if is_broken_haswell_vulkan(&info) {
|
||||
log::warn!(
|
||||
"Horizon renderer: skipping {} on Vulkan because Mesa hasvk suffers from \
|
||||
vertex corruption on Haswell; falling back to next backend",
|
||||
info.name
|
||||
);
|
||||
return Err(format!(
|
||||
"Mesa hasvk on Haswell ({}) has broken vertex buffers under Vulkan; falling back",
|
||||
info.name
|
||||
));
|
||||
}
|
||||
log::warn!(
|
||||
"Horizon renderer: using {} although its driver reports no Vulkan \
|
||||
conformance version",
|
||||
opened.adapter.get_info().name
|
||||
info.name
|
||||
);
|
||||
Ok(opened)
|
||||
}
|
||||
@@ -2138,6 +2159,19 @@ async fn open_with_retry(
|
||||
Ok(opened) => Ok(opened),
|
||||
}
|
||||
}
|
||||
/// Intel Haswell (Gen 7.5) Mesa Vulkan driver (`hasvk`) reports 0.0.0.0 and suffers
|
||||
/// from severe vertex buffer corruption and polygon tearing (#9912), but its OpenGL driver
|
||||
/// (`crocus`) is fully conformant and stable. Ivy Bridge (`IVB`) keeps Vulkan (#4059).
|
||||
pub(crate) fn is_broken_haswell_vulkan(info: &wgpu::AdapterInfo) -> bool {
|
||||
info.backend == wgpu::Backend::Vulkan
|
||||
&& info.vendor == 0x8086
|
||||
&& (matches!(
|
||||
info.device,
|
||||
0x0400..=0x042f // Haswell Desktop / Mobile
|
||||
| 0x0a00..=0x0a2f // Haswell ULT (Ultrabook)
|
||||
| 0x0d00..=0x0d2f // Haswell CRW (Crystal Well)
|
||||
) || ["HSW", "Haswell"].iter().any(|tag| info.name.contains(tag)))
|
||||
}
|
||||
/// Opens a device on `backends` alone: its surface, adapter and device, or what
|
||||
/// stopped it. `allow_nonconformant` is `open_with_retry`'s second try; a
|
||||
/// software adapter is taken only when `allow_cpu`.
|
||||
@@ -4720,8 +4754,10 @@ impl Renderer {
|
||||
frame_format,
|
||||
hdr_marker: None,
|
||||
pack_post: None,
|
||||
pack_loading: None,
|
||||
native: None,
|
||||
pass_timing: pack_timing::PassTiming::logging(),
|
||||
pass_timing_gap: std::time::Duration::ZERO,
|
||||
own_timing: None,
|
||||
pack_entity_rest: Vec::new(),
|
||||
cloud_bindings,
|
||||
@@ -6471,7 +6507,17 @@ impl Renderer {
|
||||
world,
|
||||
)
|
||||
});
|
||||
let post = match post.transpose() {
|
||||
self.pack_loading = None;
|
||||
self.install_shader_pack(dimension, post.transpose())
|
||||
}
|
||||
/// Runs the pack `set_shader_pack` or `load_shader_pack` built (`None`: no pack), or none when
|
||||
/// it can't run, for the dimension `dimension`; the programs that run and their notes.
|
||||
fn install_shader_pack(
|
||||
&mut self,
|
||||
dimension: i32,
|
||||
post: std::result::Result<Option<pack_post::PackPost>, pack_post::Refused>,
|
||||
) -> std::result::Result<Option<(Vec<String>, Vec<String>)>, pack_post::Refused> {
|
||||
let post = match post {
|
||||
Ok(post) => post,
|
||||
Err(e) => {
|
||||
self.pack_post = Some((dimension, None));
|
||||
@@ -6490,8 +6536,138 @@ impl Renderer {
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
/// As `set_shader_pack` with a pack, but its pipelines build on a thread of their own while
|
||||
/// the game keeps drawing without one: the driver compiles a big pack's programs for seconds.
|
||||
/// `poll_shader_pack` runs it once they are built. `label` and `started` come back with it.
|
||||
pub fn load_shader_pack(
|
||||
&mut self,
|
||||
dimension: i32,
|
||||
pack: horizon_shaders::Post,
|
||||
block_ids: Vec<i32>,
|
||||
label: String,
|
||||
started: std::time::Instant,
|
||||
) {
|
||||
let device = self.device.clone();
|
||||
let queue = self.queue.clone();
|
||||
let adapter = self.adapter.clone();
|
||||
let format = self.frame_format;
|
||||
let atlas = self.pack_atlas.as_ref().map(|a| (a.view.clone(), a.size()));
|
||||
let sprite_rects = self
|
||||
.pack_atlas
|
||||
.as_ref()
|
||||
.map_or_else(Vec::new, |a| a.rects_table());
|
||||
let material_maps = self
|
||||
.pack_material_atlases
|
||||
.as_ref()
|
||||
.map(|a| [a[0].view.clone(), a[1].view.clone()]);
|
||||
let lightmap = self.lightmap_view.clone();
|
||||
let gui_size = self
|
||||
.hud
|
||||
.atlas_texture
|
||||
.as_ref()
|
||||
.map_or([1, 1], |t| [t.width(), t.height()]);
|
||||
let celestial = self
|
||||
.hud
|
||||
.atlas_texture
|
||||
.as_ref()
|
||||
.map(|t| t.create_view(&Default::default()));
|
||||
let flat = self.pack_terrain_flat;
|
||||
let table = (self.flat.as_ref())
|
||||
.filter(|_| flat)
|
||||
.map(|f| f.draw_layout.clone());
|
||||
let terrain = self.pack_atlas.is_some();
|
||||
let clouds = self.cloud_bindings.clone();
|
||||
let build = move || {
|
||||
let instance = wgpu::vertex_attr_array![5=>Float32x4];
|
||||
let vertex = wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<TerrainVertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &TERRAIN_ATTRIBUTES,
|
||||
};
|
||||
let instances = wgpu::VertexBufferLayout {
|
||||
array_stride: 16,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &instance,
|
||||
};
|
||||
let buffers = [Some(vertex), Some(instances)];
|
||||
let world = pack_post::World {
|
||||
atlas,
|
||||
sprite_rects: &sprite_rects,
|
||||
material_maps,
|
||||
lightmap,
|
||||
gui_size,
|
||||
terrain: terrain.then(|| pack_post::Geometry {
|
||||
buffers: if flat { &buffers[..1] } else { &buffers },
|
||||
table: table.as_ref(),
|
||||
depth_write: true,
|
||||
atlas: None,
|
||||
material_maps: true,
|
||||
ids: None,
|
||||
cull: true,
|
||||
}),
|
||||
block_ids: &block_ids,
|
||||
celestial,
|
||||
clouds: Some(&clouds),
|
||||
weather: true,
|
||||
hand: true,
|
||||
};
|
||||
pack_post::PackPost::new(&device, &queue, &adapter, pack, format, world)
|
||||
};
|
||||
// A pack is not the game's code: a panic building it turns the pack off (#3525).
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("Shader pack".into())
|
||||
.spawn(move || crate::crash::contain(build));
|
||||
match thread {
|
||||
Ok(thread) => {
|
||||
// The last pack doesn't draw this dimension meanwhile.
|
||||
self.pack_post = None;
|
||||
self.pack_loading = Some(PackLoading {
|
||||
dimension,
|
||||
thread,
|
||||
label,
|
||||
started,
|
||||
});
|
||||
}
|
||||
Err(e) => log::error!("shader pack {label}: no thread to build it on: {e}"),
|
||||
}
|
||||
}
|
||||
/// The pack `load_shader_pack` builds, once its thread is done: it runs from now on, as
|
||||
/// `set_shader_pack` would run it. Its dimension, label, start and what `set_shader_pack`
|
||||
/// returns.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn poll_shader_pack(
|
||||
&mut self,
|
||||
) -> Option<(
|
||||
i32,
|
||||
String,
|
||||
std::time::Instant,
|
||||
std::result::Result<Option<(Vec<String>, Vec<String>)>, pack_post::Refused>,
|
||||
)> {
|
||||
if !self.pack_loading.as_ref()?.thread.is_finished() {
|
||||
return None;
|
||||
}
|
||||
let loading = self.pack_loading.take()?;
|
||||
let built = match loading.thread.join() {
|
||||
Ok(Ok(built)) => built,
|
||||
Ok(Err(panic)) => Err(pack_post::Refused {
|
||||
reason: format!("building it panicked: {panic}"),
|
||||
notes: Vec::new(),
|
||||
}),
|
||||
Err(_) => Err(pack_post::Refused {
|
||||
reason: "its thread panicked".into(),
|
||||
notes: Vec::new(),
|
||||
}),
|
||||
};
|
||||
let report = self.install_shader_pack(loading.dimension, built.map(Some));
|
||||
Some((loading.dimension, loading.label, loading.started, report))
|
||||
}
|
||||
/// Whether a `shaders/` pack is still building (`load_shader_pack`).
|
||||
pub fn shader_pack_loading(&self) -> bool {
|
||||
self.pack_loading.is_some()
|
||||
}
|
||||
/// Drops the running shader pack, of either kind.
|
||||
pub fn drop_shader_packs(&mut self) {
|
||||
self.pack_loading = None;
|
||||
self.pack_post = None;
|
||||
self.native = None;
|
||||
self.pack_atlas = None;
|
||||
@@ -6666,6 +6842,15 @@ impl Renderer {
|
||||
pub fn set_pass_timing(&mut self, on: bool) {
|
||||
self.pass_timing = on || pack_timing::PassTiming::logging();
|
||||
}
|
||||
/// Times a frame at most once per `gap` while passes are timed (the F3 overlay needs far fewer
|
||||
/// samples than a measurement). `HORIZON_SHADER_PACK_TIMING` keeps every sample.
|
||||
pub fn set_pass_timing_gap(&mut self, gap: std::time::Duration) {
|
||||
self.pass_timing_gap = if pack_timing::PassTiming::logging() {
|
||||
std::time::Duration::ZERO
|
||||
} else {
|
||||
gap
|
||||
};
|
||||
}
|
||||
/// The running shader pack's timer, of either kind.
|
||||
fn pass_timer<'a>(
|
||||
pack: &'a mut Option<(i32, Option<pack_post::PackPost>)>,
|
||||
@@ -6718,7 +6903,8 @@ impl Renderer {
|
||||
|
||||
/// The dimension the shader pack was last set for.
|
||||
pub fn shader_pack_dimension(&self) -> Option<i32> {
|
||||
self.pack_post.as_ref().map(|p| p.0)
|
||||
let loading = self.pack_loading.as_ref().map(|l| l.dimension);
|
||||
loading.or_else(|| self.pack_post.as_ref().map(|p| p.0))
|
||||
}
|
||||
/// The Texture Filtering and Max Anisotropy options (`filtering`: 0 none, 1 RGSS, 2
|
||||
/// anisotropic; `bit`: `maxAnisotropyBit`). All take LevelRenderer's chunk layer sampler
|
||||
@@ -7240,6 +7426,7 @@ impl Renderer {
|
||||
.flatten();
|
||||
}
|
||||
if let Some(t) = &mut self.own_timing {
|
||||
t.gap = self.pass_timing_gap;
|
||||
t.begin(&self.device, &self.queue);
|
||||
}
|
||||
if self.animated_textures {
|
||||
@@ -8370,6 +8557,7 @@ impl Renderer {
|
||||
frame.end_portal_sprites[1],
|
||||
&mut self.pack_entity_rest,
|
||||
skin.filter(|_| pack_skin),
|
||||
(frame.entity_motion, frame.entity_motion_posed),
|
||||
);
|
||||
entities = &self.pack_entity_rest;
|
||||
}
|
||||
@@ -8877,6 +9065,9 @@ impl Renderer {
|
||||
.then(|| pack_timing::PassTiming::new(&self.device, &self.queue))
|
||||
.flatten();
|
||||
}
|
||||
if let Some(Some(t)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
|
||||
t.gap = self.pass_timing_gap;
|
||||
}
|
||||
let pack_reads_world = pack_post || native_post;
|
||||
laps.lap("r_uploads");
|
||||
// Phase 2 of occlusion culling draws over the depth, and a shader pack or TAA reads it.
|
||||
@@ -11529,6 +11720,21 @@ impl Frustum {
|
||||
.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 v = Vec3::new(
|
||||
if p.x >= 0.0 { max.x } else { min.x },
|
||||
if p.y >= 0.0 { max.y } else { min.y },
|
||||
if p.z >= 0.0 { max.z } else { min.z },
|
||||
);
|
||||
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
|
||||
@@ -11735,6 +11941,47 @@ fn frustum(m: Mat4) -> [Vec4; 6] {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn depth_culling_keeps_jittered_edges_and_depth_crossing_models() {
|
||||
use super::*;
|
||||
for (yaw, pitch) in [(0.0, 0.0), (73.0, -35.0), (-142.0, 79.0)] {
|
||||
let pose = Mat4::from_translation(Vec3::new(0.03, -0.02, 0.01))
|
||||
* crate::camera::view_matrix(yaw, pitch);
|
||||
let matrix = world_projection(70.0, 16.0 / 9.0, 512.0) * pose;
|
||||
let view = Frustum {
|
||||
planes: frustum(matrix),
|
||||
};
|
||||
for size in [[1, 1], [320, 180], [1280, 720], [3840, 2160]] {
|
||||
for (x, y) in [(-1.0, 1.0), (1.0, -1.0)] {
|
||||
let jitter = Mat4::from_translation(Vec3::new(
|
||||
x / size[0] as f32,
|
||||
y / size[1] as f32,
|
||||
0.0,
|
||||
));
|
||||
let inverse = (jitter * matrix).inverse();
|
||||
for (x, y) in [(-1.0, -1.0), (1.0, 1.0), (-1.0, 1.0), (1.0, -1.0)] {
|
||||
for z in [0.001, 0.5, 0.999] {
|
||||
let p = inverse * Vec4::new(x, y, z, 1.0);
|
||||
let p = p.truncate() / p.w;
|
||||
assert!(view.intersects_depth(
|
||||
p - Vec3::splat(1.0e-4),
|
||||
p + Vec3::splat(1.0e-4)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let point = |p| pose.inverse().transform_point3(p);
|
||||
for z in [10.0, -600.0] {
|
||||
let p = point(Vec3::new(0.0, 0.0, z));
|
||||
assert!(!view.intersects_depth(p - Vec3::splat(0.1), p + Vec3::splat(0.1)));
|
||||
}
|
||||
// An oversized model straddling the eye must stay, even with its origin behind.
|
||||
let p = point(Vec3::ZERO);
|
||||
assert!(view.intersects_depth(p - Vec3::splat(10.0), p + Vec3::splat(10.0)));
|
||||
assert!(view.intersects_depth(Vec3::splat(f32::NAN), Vec3::ONE));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn opaque_gizmo_lines_upload_first_for_the_depth_writing_pipeline() {
|
||||
let line = |argb: u32| [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, f32::from_bits(argb), 2.5];
|
||||
@@ -13228,6 +13475,36 @@ mod sprite_upload_tests {
|
||||
assert_eq!(adapter_requested_split(&info, None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_haswell_vulkan_detects_haswell_and_leaves_ivy_bridge() {
|
||||
let mut info = wgpu::AdapterInfo {
|
||||
name: "Intel(R) HD Graphics 4400 (HSW GT2)".into(),
|
||||
vendor: 0x8086,
|
||||
device: 0x0a16,
|
||||
device_type: wgpu::DeviceType::IntegratedGpu,
|
||||
device_pci_bus_id: String::new(),
|
||||
driver: "Mesa 26.2.5".into(),
|
||||
driver_info: "Mesa 26.2.5".into(),
|
||||
backend: wgpu::Backend::Vulkan,
|
||||
subgroup_min_size: 0,
|
||||
subgroup_max_size: 0,
|
||||
transient_saves_memory: None,
|
||||
limit_bucket: None,
|
||||
};
|
||||
assert!(is_broken_haswell_vulkan(&info));
|
||||
|
||||
// Ivy Bridge keeps Vulkan (#4059)
|
||||
info.name = "Intel(R) HD Graphics 4000 (IVB GT2)".into();
|
||||
info.device = 0x0166;
|
||||
assert!(!is_broken_haswell_vulkan(&info));
|
||||
|
||||
// Haswell on OpenGL is stable (crocus)
|
||||
info.name = "Intel(R) HD Graphics 4400 (HSW GT2)".into();
|
||||
info.device = 0x0a16;
|
||||
info.backend = wgpu::Backend::Gl;
|
||||
assert!(!is_broken_haswell_vulkan(&info));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mip_chain_allocates_exactly_its_size() {
|
||||
for size in [16, 64] {
|
||||
@@ -13742,3 +14019,15 @@ mod shadow_caster_tests {
|
||||
assert!(!kept(50.0, 0.0, 0.0, [-2.0, 3.0, 0.0]));
|
||||
}
|
||||
}
|
||||
|
||||
/// A `shaders/` pack building on its own thread (`Renderer::load_shader_pack`): the dimension it
|
||||
/// is for, the thread (a panic in it contained), and what the game named it with and when.
|
||||
struct PackLoading {
|
||||
dimension: i32,
|
||||
#[allow(clippy::type_complexity)]
|
||||
thread: std::thread::JoinHandle<
|
||||
std::result::Result<std::result::Result<pack_post::PackPost, pack_post::Refused>, String>,
|
||||
>,
|
||||
label: String,
|
||||
started: std::time::Instant,
|
||||
}
|
||||
@@ -534,6 +534,7 @@ impl Scene {
|
||||
visible_static_block_entity_sections: &[],
|
||||
static_block_entity_stats: Default::default(),
|
||||
entity_motion: &[],
|
||||
entity_motion_posed: &[],
|
||||
entity_spans: &[],
|
||||
skin: None,
|
||||
glowing: &[],
|
||||
|
||||
@@ -512,26 +512,36 @@ impl NativeFrame {
|
||||
|
||||
/// Writes this frame's inputs, for a world of `size`.
|
||||
pub fn write(&mut self, queue: &wgpu::Queue, frame: &Frame, size: [u32; 2]) {
|
||||
let inputs = self.next_inputs(frame, size);
|
||||
queue.write_buffer(&self.buffer, 0, bytemuck::bytes_of(&inputs));
|
||||
}
|
||||
|
||||
/// This frame's inputs, moving the eased values and the frame count on.
|
||||
fn next_inputs(&mut self, frame: &Frame, size: [u32; 2]) -> FrameInputs {
|
||||
// Under `HORIZON_STILL` the world's ticks are the clock (`PackInputs::still_ticks`), and
|
||||
// eased values sit at their targets.
|
||||
let still = frame.pack.still_ticks;
|
||||
let seconds = still.map_or_else(
|
||||
|| self.start.elapsed().as_secs_f32() % 3600.0,
|
||||
|ticks| (ticks as f32 / 20.0) % 3600.0,
|
||||
);
|
||||
// A scripted clock (`PackInputs::frame_timing`) comes first, and eased values
|
||||
// move by its delta.
|
||||
let now = std::time::Instant::now();
|
||||
let since = match still {
|
||||
Some(_) => f32::INFINITY,
|
||||
None => self.wetness.1.map_or(0.0, |t| (now - t).as_secs_f32()),
|
||||
let timing = frame.pack.frame_timing;
|
||||
// Seconds, frame index, and the seconds eased values move by when not the
|
||||
// measured ones.
|
||||
let (seconds, index, step) = match (timing, frame.pack.still_ticks) {
|
||||
(Some(t), _) => (t.elapsed, t.counter, Some(t.delta)),
|
||||
(None, Some(ticks)) => (
|
||||
(ticks as f32 / 20.0) % 3600.0,
|
||||
ticks as u32,
|
||||
Some(f32::INFINITY),
|
||||
),
|
||||
(None, None) => (
|
||||
self.start.elapsed().as_secs_f32() % 3600.0,
|
||||
self.frames,
|
||||
None,
|
||||
),
|
||||
};
|
||||
let since = step.unwrap_or_else(|| self.wetness.1.map_or(0.0, |t| (now - t).as_secs_f32()));
|
||||
self.wetness = (wetness(self.wetness.0, frame.pack.rain, since), Some(now));
|
||||
let mut inputs = frame_inputs(
|
||||
frame,
|
||||
size,
|
||||
seconds,
|
||||
still.map_or(self.frames, |ticks| ticks as u32),
|
||||
self.shadow_camera.as_ref(),
|
||||
);
|
||||
let mut inputs = frame_inputs(frame, size, seconds, index, self.shadow_camera.as_ref());
|
||||
inputs.wetness = self.wetness.0;
|
||||
if !self.reflects {
|
||||
inputs.has_reflection = 0;
|
||||
@@ -542,8 +552,11 @@ impl NativeFrame {
|
||||
inputs.previous_view_projection = view_proj;
|
||||
// `write` skips title frames, so a gap means a pause or another world: the frame
|
||||
// before says nothing about this one, and the eased values start over.
|
||||
// A scripted clock's frames follow each other however long one takes to draw.
|
||||
match self.last {
|
||||
Some((previous, camera, time)) if (now - time).as_secs_f32() < MAX_FRAME_GAP => {
|
||||
Some((previous, camera, time))
|
||||
if timing.is_some() || (now - time).as_secs_f32() < MAX_FRAME_GAP =>
|
||||
{
|
||||
inputs.previous_view_projection = previous;
|
||||
inputs.camera_motion = (frame.camera - camera).as_vec3().to_array();
|
||||
inputs.delta_time = (now - time).as_secs_f32();
|
||||
@@ -562,12 +575,11 @@ impl NativeFrame {
|
||||
(f32::from(u8::from(inputs.eye_in == 1)), 0.25),
|
||||
(f32::from(u8::from(inputs.eye_in == 2)), 0.25),
|
||||
];
|
||||
let dt = match still {
|
||||
Some(_) => {
|
||||
inputs.delta_time = 0.05;
|
||||
f32::INFINITY
|
||||
}
|
||||
None => inputs.delta_time,
|
||||
let dt = step.unwrap_or(inputs.delta_time);
|
||||
inputs.delta_time = match (timing, step) {
|
||||
(Some(t), _) => t.delta,
|
||||
(None, Some(_)) => 0.05,
|
||||
(None, None) => inputs.delta_time,
|
||||
};
|
||||
for (value, (target, half_life)) in self.eye.iter_mut().zip(targets) {
|
||||
*value = eased(*value, target, half_life, dt);
|
||||
@@ -577,7 +589,7 @@ impl NativeFrame {
|
||||
inputs.in_water = water;
|
||||
inputs.in_lava = lava;
|
||||
self.frames = self.frames.wrapping_add(1);
|
||||
queue.write_buffer(&self.buffer, 0, bytemuck::bytes_of(&inputs));
|
||||
inputs
|
||||
}
|
||||
|
||||
/// Takes the post stage's pending history reset. It stays pending if post rendering is
|
||||
@@ -1320,6 +1332,48 @@ mod tests {
|
||||
assert_eq!(wetness(0.25, 0.25, 5.0), 0.25);
|
||||
}
|
||||
|
||||
/// A scripted clock comes before STILL's ticks, eased values and wetness move by its
|
||||
/// delta, and a long wait between its frames is not a gap that starts them over.
|
||||
#[test]
|
||||
fn a_scripted_clock_drives_time_frame_index_and_eased_values() {
|
||||
use crate::gfx::pack_uniforms::FrameTiming;
|
||||
let Some((device, _)) = device() else {
|
||||
return;
|
||||
};
|
||||
let hud = crate::hud::HudBatch::new([4, 4]);
|
||||
let mut frame = crate::gfx::pack_uniforms::initial_history_tests::frame(&hud);
|
||||
let mut native = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
|
||||
frame.pack.still_ticks = Some(47_811);
|
||||
frame.pack.eye_light = [0, 15];
|
||||
frame.pack.rain = 1.0;
|
||||
frame.pack.frame_timing = Some(FrameTiming::nth(1, 2000.0));
|
||||
let first = native.next_inputs(&frame, [4, 4]);
|
||||
assert_eq!(
|
||||
(first.time, first.frame_index, first.delta_time),
|
||||
(0.0, 1, 0.0)
|
||||
);
|
||||
// Wetness starts dry, as on a fresh pack; frame 1's delta of 0 leaves it there.
|
||||
assert_eq!((first.eye_light_smooth[1], first.wetness), (1.0, 0.0));
|
||||
assert!(native.take_history_reset());
|
||||
native.last.as_mut().unwrap().2 -= std::time::Duration::from_secs(5);
|
||||
frame.pack.eye_light = [0, 0];
|
||||
frame.pack.frame_timing = Some(FrameTiming::nth(2, 2000.0));
|
||||
let second = native.next_inputs(&frame, [4, 4]);
|
||||
assert_eq!(
|
||||
(second.time, second.frame_index, second.delta_time),
|
||||
(2.0, 2, 2.0)
|
||||
);
|
||||
// Two seconds: the eye light's 2 s half-life halves it, and wetness rises
|
||||
// by 1 - 0.5^(2/30) (a 30 s half-life while it rains harder).
|
||||
assert_eq!(second.eye_light_smooth[1], 0.5);
|
||||
assert!(
|
||||
(second.wetness - 0.045_158_4).abs() < 1e-6,
|
||||
"{}",
|
||||
second.wetness
|
||||
);
|
||||
assert!(!native.take_history_reset());
|
||||
}
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
|
||||
@@ -32,13 +32,28 @@ pub fn buffers() -> [Option<wgpu::VertexBufferLayout<'static>>; 1] {
|
||||
/// normal. The quad's corners, read from the same vertices at set 1, give the face normal where
|
||||
/// the vertex has none (block entities) and `at_tangent` along the texture's u, as for terrain.
|
||||
pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
let declarations = "layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
|
||||
let words = std::mem::size_of::<Vertex>() / 4;
|
||||
let mut declarations = "layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
|
||||
layout(location = 2) in uint hz_v_overlay;\nlayout(location = 3) in vec4 hz_v_color;\n\
|
||||
layout(location = 4) in uvec4 hz_v_light;\nlayout(location = 5) in vec4 hz_v_normal;\n\
|
||||
layout(set = 1, binding = 0) readonly buffer HzEntityVertices { uint hz_entity_vertices[]; };\n\
|
||||
vec4 hz_tangent; vec2 hz_mid;\n"
|
||||
.to_owned();
|
||||
let words = std::mem::size_of::<Vertex>() / 4;
|
||||
// `at_velocity`: the vertex's offset from last frame in view space, this frame's view
|
||||
// position less last frame's. Last frame's vertices stay at binding 2, relative to last
|
||||
// frame's camera, and binding 3 gives each vertex its index there (none: no velocity).
|
||||
declarations.push_str(&format!(
|
||||
"layout(set = 1, binding = 2) readonly buffer HzEntityPrevious {{ uint hz_entity_previous[]; }};\n\
|
||||
layout(set = 1, binding = 3) readonly buffer HzEntityPreviousIndex {{ uint hz_previous_index[]; }};\n\
|
||||
uint hz_vi;\n\
|
||||
vec3 hz_velocity(mat4 view, mat4 previous_view) {{\n \
|
||||
uint p = hz_previous_index[hz_vi];\n \
|
||||
if (p == 0xffffffffu) return vec3(0.0);\n \
|
||||
uint w = p * {words}u;\n \
|
||||
vec3 then = uintBitsToFloat(uvec3(hz_entity_previous[w], hz_entity_previous[w + 1u], hz_entity_previous[w + 2u]));\n \
|
||||
return (view * vec4(hz_v_pos, 1.0)).xyz - (previous_view * vec4(then, 1.0)).xyz;\n\
|
||||
}}\n"
|
||||
));
|
||||
let corner = |k: usize, field: &str| {
|
||||
let at = |o: usize| format!("hz_entity_vertices[hz_q + {}u]", k * words + o);
|
||||
match field {
|
||||
@@ -47,7 +62,8 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
}
|
||||
};
|
||||
let body = format!(
|
||||
" uint hz_q = (uint(gl_VertexIndex) & ~3u) * {words}u;\n \
|
||||
" hz_vi = uint(gl_VertexIndex);\n \
|
||||
uint hz_q = (uint(gl_VertexIndex) & ~3u) * {words}u;\n \
|
||||
vec3 hz_e1 = {p1} - {p0};\n \
|
||||
vec3 hz_e2 = {p2} - {p0};\n \
|
||||
vec2 hz_t0 = {t0};\n \
|
||||
@@ -87,6 +103,10 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
("vaUV2", "ivec2(hz_MultiTexCoord1.xy)"),
|
||||
("vaColor", "hz_Color"),
|
||||
("vaNormal", "hz_Normal"),
|
||||
(
|
||||
"at_velocity",
|
||||
"hz_velocity(gbufferModelView, gbufferPreviousModelView)",
|
||||
),
|
||||
];
|
||||
horizon_shaders::GbuffersInputs {
|
||||
declarations,
|
||||
@@ -229,7 +249,15 @@ impl Casters {
|
||||
/// The vertices and per-draw uniforms of the runs a pack draws, rebuilt each frame.
|
||||
pub(super) struct PackEntities {
|
||||
vertices: wgpu::Buffer,
|
||||
/// Set 1: the vertices again, for the quads' corners.
|
||||
/// Last frame's `vertices` (the two swap each frame), for `at_velocity`.
|
||||
previous: wgpu::Buffer,
|
||||
/// For each of `vertices`, its index in `previous` (`NO_PREVIOUS`: none).
|
||||
previous_index: wgpu::Buffer,
|
||||
/// This frame's entities (`Frame::entity_motion`) and last frame's.
|
||||
motion: Motion,
|
||||
previous_motion: Motion,
|
||||
index_scratch: Vec<u32>,
|
||||
/// Set 1: the vertices again, for the quads' corners; last frame's and the indices into them.
|
||||
pub table: wgpu::BindGroupLayout,
|
||||
pub table_group: wgpu::BindGroup,
|
||||
slots: wgpu::Buffer,
|
||||
@@ -248,23 +276,32 @@ impl PackEntities {
|
||||
let stride = SLOT.max(u64::from(
|
||||
device.limits().min_uniform_buffer_offset_alignment,
|
||||
));
|
||||
let entry = |binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Storage { read_only: true },
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let table = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Shader pack entity vertices"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Storage { read_only: true },
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
entries: &[entry(0), entry(2), entry(3)],
|
||||
});
|
||||
let (vertices, table_group) = Self::vertex_buffer(device, &table, 4096);
|
||||
let vertices = Self::vertex_buffer(device, 4096);
|
||||
let previous = Self::vertex_buffer(device, 4096);
|
||||
let previous_index = Self::index_buffer(device, 4096);
|
||||
let table_group = Self::table_group(device, &table, &vertices, &previous, &previous_index);
|
||||
let (slots, group) = Self::slot_buffer(device, layout, 64, stride);
|
||||
Self {
|
||||
vertices,
|
||||
previous,
|
||||
previous_index,
|
||||
motion: Motion::default(),
|
||||
previous_motion: Motion::default(),
|
||||
index_scratch: Vec::new(),
|
||||
table,
|
||||
table_group,
|
||||
slots,
|
||||
@@ -278,28 +315,51 @@ impl PackEntities {
|
||||
}
|
||||
}
|
||||
|
||||
fn vertex_buffer(
|
||||
device: &wgpu::Device,
|
||||
table: &wgpu::BindGroupLayout,
|
||||
vertices: u64,
|
||||
) -> (wgpu::Buffer, wgpu::BindGroup) {
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
fn vertex_buffer(device: &wgpu::Device, vertices: u64) -> wgpu::Buffer {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack entity vertices"),
|
||||
size: vertices * std::mem::size_of::<Vertex>() as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX
|
||||
| wgpu::BufferUsages::STORAGE
|
||||
| wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
})
|
||||
}
|
||||
|
||||
fn index_buffer(device: &wgpu::Device, vertices: u64) -> wgpu::Buffer {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack entity previous indices"),
|
||||
size: vertices * 4,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn table_group(
|
||||
device: &wgpu::Device,
|
||||
table: &wgpu::BindGroupLayout,
|
||||
vertices: &wgpu::Buffer,
|
||||
previous: &wgpu::Buffer,
|
||||
index: &wgpu::Buffer,
|
||||
) -> wgpu::BindGroup {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack entity vertices"),
|
||||
layout: table,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
(buffer, group)
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: vertices.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: previous.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: index.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
fn slot_buffer(
|
||||
@@ -359,11 +419,17 @@ impl PackEntities {
|
||||
shadow: Casters,
|
||||
rest: &mut Vec<Vertex>,
|
||||
skin: Option<&super::entity_skin::SkinFrame>,
|
||||
motion: (&[(u32, u64)], &[u32]),
|
||||
) {
|
||||
self.draws.clear();
|
||||
self.scratch.clear();
|
||||
self.uniforms.clear();
|
||||
rest.clear();
|
||||
// Last frame's vertices stay for `at_velocity`; this frame's go to the other buffer.
|
||||
std::mem::swap(&mut self.vertices, &mut self.previous);
|
||||
std::mem::swap(&mut self.motion, &mut self.previous_motion);
|
||||
self.motion.clear();
|
||||
let mut copied = Vec::new();
|
||||
let posed = skin.map_or(0, |s| s.vertices);
|
||||
if let Some(skin) = skin {
|
||||
for (span, range, overlay) in skin_runs(skin) {
|
||||
@@ -389,6 +455,7 @@ impl PackEntities {
|
||||
extend_without_portals(rest, &entities[..first.min(entities.len())]);
|
||||
let (mut other, mut portal_faces, mut beam_faces) = (Vec::new(), Vec::new(), Vec::new());
|
||||
for (span, range) in runs(entities.len(), spans) {
|
||||
let run_start = range.start as u32;
|
||||
let whole = &entities[range];
|
||||
// A block entity's run can hold End portal faces and beams beside plain parts (an End
|
||||
// gateway: its cube and its beam): each goes to its own program.
|
||||
@@ -444,6 +511,10 @@ impl PackEntities {
|
||||
Part::Beam => beam_segments(run, &mut self.scratch),
|
||||
_ => self.scratch.extend_from_slice(run),
|
||||
}
|
||||
// A run copied whole: its vertices keep their order, for `at_velocity`.
|
||||
if !split {
|
||||
copied.push((run_start, posed + start, run.len() as u32));
|
||||
}
|
||||
if program == DESTROY {
|
||||
project_cracks(&mut self.scratch[start as usize..], crack.0, crack.1);
|
||||
}
|
||||
@@ -468,6 +539,14 @@ impl PackEntities {
|
||||
if self.draws.is_empty() && posed == 0 {
|
||||
return;
|
||||
}
|
||||
let total = posed + self.scratch.len() as u32;
|
||||
let posed_marks = if skin.is_some() { motion.1 } else { &[] };
|
||||
self.motion
|
||||
.collect(motion.0, posed_marks, entities.len() as u32, posed, &copied);
|
||||
self.index_scratch.clear();
|
||||
self.index_scratch.resize(total as usize, NO_PREVIOUS);
|
||||
self.motion
|
||||
.link(&self.previous_motion, &mut self.index_scratch);
|
||||
let vertex_size = std::mem::size_of::<Vertex>() as u64;
|
||||
let (need_vertices, need_slots) = (
|
||||
u64::from(posed) + self.scratch.len() as u64,
|
||||
@@ -476,9 +555,23 @@ impl PackEntities {
|
||||
let have_vertices = self.vertices.size() / vertex_size;
|
||||
let have_slots = self.slots.size() / self.stride;
|
||||
if need_vertices > have_vertices {
|
||||
(self.vertices, self.table_group) =
|
||||
Self::vertex_buffer(device, &self.table, need_vertices.next_power_of_two());
|
||||
self.vertices = Self::vertex_buffer(device, need_vertices.next_power_of_two());
|
||||
}
|
||||
if need_vertices * 4 > self.previous_index.size() {
|
||||
self.previous_index = Self::index_buffer(device, need_vertices.next_power_of_two());
|
||||
}
|
||||
queue.write_buffer(
|
||||
&self.previous_index,
|
||||
0,
|
||||
bytemuck::cast_slice(&self.index_scratch),
|
||||
);
|
||||
self.table_group = Self::table_group(
|
||||
device,
|
||||
&self.table,
|
||||
&self.vertices,
|
||||
&self.previous,
|
||||
&self.previous_index,
|
||||
);
|
||||
if need_slots > have_slots {
|
||||
(self.slots, self.group) =
|
||||
Self::slot_buffer(device, layout, need_slots.next_power_of_two(), self.stride);
|
||||
@@ -670,6 +763,87 @@ fn drawn_by_pack(span: &EntitySpan, drawn: [bool; PROGRAMS]) -> Option<usize> {
|
||||
}
|
||||
|
||||
/// The kind of program that draws `span`'s run, when a pack may.
|
||||
/// A vertex with no copy last frame: `at_velocity` reads zero.
|
||||
const NO_PREVIOUS: u32 = u32::MAX;
|
||||
|
||||
/// Where each entity's vertices are in a frame's pack buffer, to match them with last frame's
|
||||
/// (`at_velocity`): the indices of each one's GPU-posed vertices, then of its CPU ones.
|
||||
#[derive(Default)]
|
||||
struct Motion {
|
||||
/// Entity id (`Frame::entity_motion`) → (first index in `indices`, posed count, CPU count).
|
||||
entities: std::collections::HashMap<u64, (u32, u32, u32)>,
|
||||
indices: Vec<u32>,
|
||||
}
|
||||
|
||||
impl Motion {
|
||||
fn clear(&mut self) {
|
||||
self.entities.clear();
|
||||
self.indices.clear();
|
||||
}
|
||||
|
||||
/// Records each marked entity (`marks`: first CPU vertex and id, each run up to the next;
|
||||
/// `posed_marks`: the posed count at each mark, empty without posing). A CPU vertex is in
|
||||
/// the pack buffer where `copied` (first vertex in `Frame::entities`, first index in the
|
||||
/// buffer, count, in order) has it, else `NO_PREVIOUS`.
|
||||
fn collect(
|
||||
&mut self,
|
||||
marks: &[(u32, u64)],
|
||||
posed_marks: &[u32],
|
||||
cpu_total: u32,
|
||||
posed_total: u32,
|
||||
copied: &[(u32, u32, u32)],
|
||||
) {
|
||||
let at = |e: u32| {
|
||||
let i = copied.partition_point(|c| c.0 <= e);
|
||||
match i.checked_sub(1).map(|i| copied[i]) {
|
||||
Some((first, index, n)) if e < first + n => index + (e - first),
|
||||
_ => NO_PREVIOUS,
|
||||
}
|
||||
};
|
||||
for (i, &(cpu, id)) in marks.iter().enumerate() {
|
||||
if id == 0 {
|
||||
continue;
|
||||
}
|
||||
let cpu_end = marks.get(i + 1).map_or(cpu_total, |m| m.0).max(cpu);
|
||||
let posed = posed_marks.get(i).copied().unwrap_or(0).min(posed_total);
|
||||
let posed_end = posed_marks
|
||||
.get(i + 1)
|
||||
.copied()
|
||||
.unwrap_or(posed_total)
|
||||
.clamp(posed, posed_total);
|
||||
let first = self.indices.len() as u32;
|
||||
self.indices.extend(posed..posed_end);
|
||||
self.indices.extend((cpu..cpu_end).map(at));
|
||||
self.entities
|
||||
.insert(id, (first, posed_end - posed, cpu_end - cpu));
|
||||
}
|
||||
}
|
||||
|
||||
/// For each entity drawn last frame with the same posed and CPU counts, each vertex's index
|
||||
/// last frame, written into `out` (indexed by this frame's buffer).
|
||||
fn link(&self, previous: &Motion, out: &mut [u32]) {
|
||||
for (id, &(first, posed, cpu)) in &self.entities {
|
||||
let Some(&(then, p, c)) = previous.entities.get(id) else {
|
||||
continue;
|
||||
};
|
||||
if (p, c) != (posed, cpu) {
|
||||
continue;
|
||||
}
|
||||
let n = (posed + cpu) as usize;
|
||||
let now = &self.indices[first as usize..][..n];
|
||||
let before = &previous.indices[then as usize..][..n];
|
||||
for (&now, &before) in now.iter().zip(before) {
|
||||
if now != NO_PREVIOUS
|
||||
&& before != NO_PREVIOUS
|
||||
&& let Some(slot) = out.get_mut(now as usize)
|
||||
{
|
||||
*slot = before;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn program_of(span: &EntitySpan) -> Option<usize> {
|
||||
Some(if span.stage == render_stage("ENTITIES") {
|
||||
if span.translucent {
|
||||
@@ -1079,6 +1253,7 @@ mod gpu_span_tests {
|
||||
},
|
||||
&mut rest,
|
||||
None,
|
||||
(&[], &[]),
|
||||
);
|
||||
assert_eq!(
|
||||
(geometry.draws.iter())
|
||||
@@ -1160,6 +1335,7 @@ mod gpu_span_tests {
|
||||
shadows,
|
||||
&mut rest,
|
||||
Some(&skin),
|
||||
(&[], &[]),
|
||||
);
|
||||
assert_eq!(rest.len(), 4, "untagged CPU prefix stays in Horizon's draw");
|
||||
assert_eq!(geometry.scratch.len(), 4, "only CPU geometry is uploaded");
|
||||
@@ -1203,6 +1379,7 @@ mod gpu_span_tests {
|
||||
shadows,
|
||||
&mut rest,
|
||||
None,
|
||||
(&[], &[]),
|
||||
);
|
||||
assert!(geometry.draws.is_empty());
|
||||
assert_eq!(
|
||||
@@ -1230,8 +1407,125 @@ mod gpu_span_tests {
|
||||
Casters::default(),
|
||||
&mut rest,
|
||||
Some(&skin),
|
||||
(&[], &[]),
|
||||
);
|
||||
assert!(geometry.draws.is_empty());
|
||||
assert!(geometry.skin_target().size() >= 5000 * 44);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod velocity_tests {
|
||||
use super::{Motion, NO_PREVIOUS};
|
||||
|
||||
/// Two mobs (ids 7 and 9) and a run of unmarked geometry (id 0); `copied` places CPU
|
||||
/// vertices `first..first + n` at buffer index `at`.
|
||||
fn frame(order: [(u64, u32); 2], copied: &[(u32, u32, u32)]) -> Motion {
|
||||
let mut marks = Vec::new();
|
||||
let mut cpu = 0;
|
||||
for (id, n) in order {
|
||||
marks.push((cpu, id));
|
||||
cpu += n;
|
||||
}
|
||||
marks.push((cpu, 0));
|
||||
let mut m = Motion::default();
|
||||
m.collect(&marks, &[], cpu + 2, 0, copied);
|
||||
m
|
||||
}
|
||||
|
||||
/// The same mob's vertices find themselves however the frame's order and the buffer's
|
||||
/// layout moved between frames; geometry of no entity, and vertices not in the buffer,
|
||||
/// find nothing.
|
||||
#[test]
|
||||
fn each_vertex_finds_its_copy_from_last_frame() {
|
||||
// Last frame: mob 7 (4 vertices) then mob 9 (2), copied whole from buffer index 10.
|
||||
let before = frame([(7, 4), (9, 2)], &[(0, 10, 8)]);
|
||||
// This frame: mob 9 first, then mob 7, at buffer index 0; 9's last vertex not drawn.
|
||||
let now = frame([(9, 2), (7, 4)], &[(0, 0, 1), (2, 1, 6)]);
|
||||
let mut out = vec![NO_PREVIOUS; 8];
|
||||
now.link(&before, &mut out);
|
||||
// 9's first vertex (buffer 0) was 10 + 4; 7's four (buffer 1..5) were 10..14.
|
||||
assert_eq!(
|
||||
out,
|
||||
[14, 10, 11, 12, 13, NO_PREVIOUS, NO_PREVIOUS, NO_PREVIOUS]
|
||||
);
|
||||
}
|
||||
|
||||
/// A mob whose vertex count changed (armor put on, an item picked up) gets no velocity
|
||||
/// this frame rather than the wrong vertices'.
|
||||
#[test]
|
||||
fn a_changed_model_gets_none() {
|
||||
let before = frame([(7, 4), (9, 2)], &[(0, 0, 8)]);
|
||||
let now = frame([(7, 6), (9, 2)], &[(0, 0, 10)]);
|
||||
let mut out = vec![NO_PREVIOUS; 10];
|
||||
now.link(&before, &mut out);
|
||||
assert!(out[..6].iter().all(|&i| i == NO_PREVIOUS));
|
||||
assert_eq!(out[6..8], [4, 5]);
|
||||
}
|
||||
|
||||
/// GPU-posed vertices come first in the buffer: each mob's posed part is matched by its
|
||||
/// own posed count, then its CPU part.
|
||||
#[test]
|
||||
fn posed_and_cpu_parts_match_separately() {
|
||||
let marks = [(0, 7), (2, 9), (2, 0)];
|
||||
let mut before = Motion::default();
|
||||
// Mob 7: 3 posed (0..3) and 2 CPU; mob 9: 1 posed (3), no CPU.
|
||||
before.collect(&marks, &[0, 3, 4], 2, 4, &[(0, 4, 2)]);
|
||||
let mut now = Motion::default();
|
||||
// Mob 9 posed first this frame (0), then mob 7 (1..4).
|
||||
now.collect(&[(0, 9), (0, 7), (2, 0)], &[0, 1, 4], 2, 4, &[(0, 4, 2)]);
|
||||
let mut out = vec![NO_PREVIOUS; 6];
|
||||
now.link(&before, &mut out);
|
||||
assert_eq!(out, [3, 0, 1, 2, 4, 5]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod velocity_translate_tests {
|
||||
/// A pack that reads `at_velocity` translates with the entity inputs (which read the
|
||||
/// previous view and last frame's vertices) and with the hand's (which read zero).
|
||||
#[test]
|
||||
fn a_pack_reading_at_velocity_translates_for_entities_and_the_hand() {
|
||||
let dir = std::env::temp_dir().join(format!("hz-at-velocity-{}", std::process::id()));
|
||||
let shaders = dir.join("shaders");
|
||||
std::fs::create_dir_all(&shaders).unwrap();
|
||||
std::fs::write(
|
||||
shaders.join("gbuffers_entities.vsh"),
|
||||
"#version 120\nattribute vec3 at_velocity;\nvarying vec3 v;\n\
|
||||
void main() {\n gl_Position = ftransform();\n v = at_velocity;\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
shaders.join("gbuffers_entities.fsh"),
|
||||
"#version 120\nvarying vec3 v;\nvoid main() {\n gl_FragData[0] = vec4(v, 1.0);\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
let pack = horizon_shaders::Pack::open(&dir).unwrap();
|
||||
let m = horizon_shaders::Macros::standard();
|
||||
let vsh = horizon_shaders::preprocess(&pack, "gbuffers_entities.vsh", &m);
|
||||
let fsh = horizon_shaders::preprocess(&pack, "gbuffers_entities.fsh", &m);
|
||||
for (who, inputs) in [
|
||||
("entities", super::inputs()),
|
||||
("hand", super::super::pack_hand::inputs()),
|
||||
] {
|
||||
let p = horizon_shaders::translate_gbuffers(
|
||||
&vsh,
|
||||
&fsh,
|
||||
horizon_shaders::Target::Buffers,
|
||||
&inputs,
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{who}: {e:?}"));
|
||||
let previous = p
|
||||
.interface
|
||||
.uniforms
|
||||
.iter()
|
||||
.any(|u| u.name == "gbufferPreviousModelView");
|
||||
assert_eq!(
|
||||
previous,
|
||||
who == "entities",
|
||||
"{who}: previous view in the block"
|
||||
);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,13 @@ const SLOT: u64 = 32;
|
||||
/// What `gbuffers_hand` reads: the entity inputs, and the hand's projection from set 1.
|
||||
pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
let mut inputs = super::pack_entities::inputs();
|
||||
// No last frame's hand to measure from: the hand's `at_velocity` reads zero (and its set 1
|
||||
// has no previous vertices).
|
||||
for (name, value) in &mut inputs.attributes {
|
||||
if name == "at_velocity" {
|
||||
*value = "vec3(0.0)".to_owned();
|
||||
}
|
||||
}
|
||||
// Iris 1.11.7 declares `currentRenderedItemId` in the hand's programs (observed).
|
||||
inputs.item_id_declared = true;
|
||||
inputs.declarations.push_str(
|
||||
@@ -54,8 +61,12 @@ pub struct PackHand {
|
||||
slots: wgpu::Buffer,
|
||||
pub slot_group: wgpu::BindGroup,
|
||||
stride: u64,
|
||||
/// This frame's runs: first quad, quads, and the dynamic offset of their slot.
|
||||
/// This frame's runs: first quad, quads, and the dynamic offset of their slot. The opaque
|
||||
/// quads come first in the buffer, then the translucent ones (`translucent_runs`), which a
|
||||
/// pack's `gbuffers_hand_water` draws after deferred.
|
||||
pub runs: Vec<(u32, u32, u32)>,
|
||||
pub translucent_runs: Vec<(u32, u32, u32)>,
|
||||
order: Vec<Vertex>,
|
||||
}
|
||||
|
||||
impl PackHand {
|
||||
@@ -120,6 +131,8 @@ impl PackHand {
|
||||
slot_group,
|
||||
stride,
|
||||
runs: Vec::new(),
|
||||
translucent_runs: Vec::new(),
|
||||
order: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,17 +179,25 @@ impl PackHand {
|
||||
) {
|
||||
self.quads = (hand.len() / 4) as u32;
|
||||
self.runs.clear();
|
||||
self.translucent_runs.clear();
|
||||
if self.quads == 0 {
|
||||
return;
|
||||
}
|
||||
let split = split(hand, items);
|
||||
self.order = split.order;
|
||||
let mut contents = Vec::new();
|
||||
for (start, quads, id) in runs(self.quads, items) {
|
||||
let slot = (self.runs.len() as u64).min(SLOTS - 1);
|
||||
if slot as usize == self.runs.len() {
|
||||
contents.extend_from_slice(&draw_slot(id));
|
||||
for (i, &(first, quads, id, translucent)) in split.runs.iter().enumerate() {
|
||||
let slot = (i as u64).min(SLOTS - 1);
|
||||
if slot == i as u64 {
|
||||
contents.extend_from_slice(&draw_slot(id, translucent));
|
||||
contents.resize(((slot + 1) * self.stride) as usize, 0);
|
||||
}
|
||||
self.runs.push((start, quads, (slot * self.stride) as u32));
|
||||
let run = (first, quads, (slot * self.stride) as u32);
|
||||
if translucent {
|
||||
self.translucent_runs.push(run);
|
||||
} else {
|
||||
self.runs.push(run);
|
||||
}
|
||||
}
|
||||
queue.write_buffer(&self.slots, 0, &contents);
|
||||
if self.quads > self.capacity {
|
||||
@@ -185,7 +206,7 @@ impl PackHand {
|
||||
Self::vertex_buffer(device, &self.layout, &self.uniform, self.capacity);
|
||||
self.indices = super::pack_entities::quad_indices(device, self.capacity);
|
||||
}
|
||||
queue.write_buffer(&self.vertices, 0, bytemuck::cast_slice(hand));
|
||||
queue.write_buffer(&self.vertices, 0, bytemuck::cast_slice(&self.order));
|
||||
let matrices = [projection, projection.inverse()].map(|m| m.to_cols_array());
|
||||
queue.write_buffer(&self.uniform, 0, bytemuck::cast_slice(&matrices));
|
||||
}
|
||||
@@ -198,11 +219,62 @@ impl PackHand {
|
||||
/// The hand's runs a frame keeps slots for.
|
||||
const SLOTS: u64 = 8;
|
||||
|
||||
/// `HzDraw` of a hand run: `renderStage` the solid hand's, `blockEntityId` -1 as outside block
|
||||
/// entities, and `currentRenderedItemId`.
|
||||
fn draw_slot(id: i32) -> [u8; SLOT as usize] {
|
||||
/// The hand's quads reordered, opaque first and translucent after (each in their order), and
|
||||
/// their runs: first quad, quads, `currentRenderedItemId`, translucent.
|
||||
struct Split {
|
||||
order: Vec<Vertex>,
|
||||
runs: Vec<(u32, u32, i32, bool)>,
|
||||
}
|
||||
|
||||
fn split(hand: &[Vertex], items: &[(u32, i32)]) -> Split {
|
||||
let quads = (hand.len() / 4) as u32;
|
||||
let mut ids = vec![0; quads as usize];
|
||||
for (start, n, id) in runs(quads, items) {
|
||||
ids[start as usize..(start + n) as usize].fill(id);
|
||||
}
|
||||
let kind = |q: usize| translucent_quad(&hand[q * 4..q * 4 + 4]);
|
||||
let opaque: Vec<usize> = (0..quads as usize).filter(|&q| !kind(q)).collect();
|
||||
let clear: Vec<usize> = (0..quads as usize).filter(|&q| kind(q)).collect();
|
||||
let mut out = Split {
|
||||
order: Vec::with_capacity(hand.len()),
|
||||
runs: Vec::new(),
|
||||
};
|
||||
for &q in opaque.iter().chain(&clear) {
|
||||
out.order.extend_from_slice(&hand[q * 4..q * 4 + 4]);
|
||||
}
|
||||
for (translucent, list, base) in [(false, &opaque, 0), (true, &clear, opaque.len())] {
|
||||
let mut i = 0;
|
||||
while i < list.len() {
|
||||
let id = ids[list[i]];
|
||||
let mut n = 1;
|
||||
while i + n < list.len() && ids[list[i + n]] == id {
|
||||
n += 1;
|
||||
}
|
||||
out.runs
|
||||
.push(((base + i) as u32, n as u32, id, translucent));
|
||||
i += n;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A held item's quad drawn from a translucent sheet (`item_render`: its overlay layer).
|
||||
fn translucent_quad(quad: &[Vertex]) -> bool {
|
||||
matches!(
|
||||
quad[0].layer & 0xffff,
|
||||
super::TRANSLUCENT_ITEM_OVERLAY | super::TRANSLUCENT_GLINT_ITEM_OVERLAY
|
||||
)
|
||||
}
|
||||
|
||||
/// `HzDraw` of a hand run: `renderStage` the solid or translucent hand's, `blockEntityId` -1 as
|
||||
/// outside block entities, and `currentRenderedItemId`.
|
||||
fn draw_slot(id: i32, translucent: bool) -> [u8; SLOT as usize] {
|
||||
let mut bytes = [0u8; SLOT as usize];
|
||||
let stage = horizon_shaders::render_stage("HAND_SOLID") as i32;
|
||||
let stage = horizon_shaders::render_stage(if translucent {
|
||||
"HAND_TRANSLUCENT"
|
||||
} else {
|
||||
"HAND_SOLID"
|
||||
}) as i32;
|
||||
bytes[0..4].copy_from_slice(&stage.to_le_bytes());
|
||||
bytes[8..12].copy_from_slice(&(-1i32).to_le_bytes());
|
||||
bytes[12..16].copy_from_slice(&id.to_le_bytes());
|
||||
@@ -233,6 +305,42 @@ fn runs(quads: u32, items: &[(u32, i32)]) -> Vec<(u32, u32, i32)> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The arm, then an item whose middle quad is translucent, then a second item: the opaque
|
||||
/// quads keep their order first, the translucent one comes after them, and each run keeps
|
||||
/// its item's id.
|
||||
#[test]
|
||||
fn translucent_quads_go_after_the_opaque_ones() {
|
||||
let quad = |layer: u32, tag: f32| {
|
||||
[Vertex {
|
||||
layer,
|
||||
pos: [tag, 0.0, 0.0],
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
}; 4]
|
||||
};
|
||||
let glass = super::super::TRANSLUCENT_ITEM_OVERLAY;
|
||||
let hand: Vec<Vertex> = [
|
||||
quad(0, 0.),
|
||||
quad(0, 1.),
|
||||
quad(glass, 2.),
|
||||
quad(0, 3.),
|
||||
quad(0, 4.),
|
||||
]
|
||||
.concat();
|
||||
// The arm is quad 0; item 7 quads 1..4; item 9 quad 4.
|
||||
let split = split(&hand, &[(4, 7), (16, 9)]);
|
||||
let order: Vec<f32> = split.order.chunks(4).map(|q| q[0].pos[0]).collect();
|
||||
assert_eq!(order, [0., 1., 3., 4., 2.]);
|
||||
assert_eq!(
|
||||
split.runs,
|
||||
[
|
||||
(0, 1, 0, false),
|
||||
(1, 2, 7, false),
|
||||
(3, 1, 9, false),
|
||||
(4, 1, 7, true)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// Each item's quads draw with its id; geometry before the first item is the arm's (0).
|
||||
#[test]
|
||||
fn runs_split_the_hand_by_item() {
|
||||
|
||||
@@ -30,11 +30,12 @@ impl PackImage {
|
||||
}
|
||||
}
|
||||
|
||||
/// The images `image.*` declares.
|
||||
pub(super) fn images(defs: Vec<CustomImage>) -> Vec<PackImage> {
|
||||
/// The images `image.*` declares; `packed_float`: this device stores into `Rg11b10Ufloat`
|
||||
/// (`format`).
|
||||
pub(super) fn images(defs: Vec<CustomImage>, packed_float: bool) -> Vec<PackImage> {
|
||||
defs.into_iter()
|
||||
.map(|def| PackImage {
|
||||
format: format(def.format),
|
||||
format: format(def.format, packed_float),
|
||||
dimension: if def.is_1d() {
|
||||
wgpu::TextureViewDimension::D1
|
||||
} else if def.is_3d() {
|
||||
@@ -63,7 +64,10 @@ fn texture_dimension(view: wgpu::TextureViewDimension) -> wgpu::TextureDimension
|
||||
}
|
||||
|
||||
/// The wgpu format of an image's internal format: a storage format of the same channels.
|
||||
pub(super) fn format(f: Format) -> wgpu::TextureFormat {
|
||||
/// `R11F_G11F_B10F` is itself where the device stores into it (`packed_float`): no alpha, so it
|
||||
/// reads 1 there, as Iris 1.11.7 read it with a probe pack of ours (#10490); elsewhere an
|
||||
/// `Rgba16Float` stand-in, which keeps an alpha.
|
||||
pub(super) fn format(f: Format, packed_float: bool) -> wgpu::TextureFormat {
|
||||
use wgpu::TextureFormat as T;
|
||||
let pick = |r, rg, rgba| match f.channels {
|
||||
1 => r,
|
||||
@@ -78,6 +82,7 @@ pub(super) fn format(f: Format) -> wgpu::TextureFormat {
|
||||
(Kind::Uint, 16) => pick(T::R16Uint, T::Rg16Uint, T::Rgba16Uint),
|
||||
(Kind::Uint, _) => pick(T::R32Uint, T::Rg32Uint, T::Rgba32Uint),
|
||||
(Kind::Float, 32) => pick(T::R32Float, T::Rg32Float, T::Rgba32Float),
|
||||
(Kind::Float, 11) if packed_float => T::Rg11b10Ufloat,
|
||||
(Kind::Float, _) => pick(T::R16Float, T::Rg16Float, T::Rgba16Float),
|
||||
(Kind::Snorm, _) => pick(T::R8Snorm, T::Rg8Snorm, T::Rgba8Snorm),
|
||||
(Kind::Unorm, 16) => pick(T::R16Unorm, T::Rg16Unorm, T::Rgba16Unorm),
|
||||
@@ -87,8 +92,12 @@ pub(super) fn format(f: Format) -> wgpu::TextureFormat {
|
||||
|
||||
/// Whether this device can make `def`: its format's features, and a size in pixels within the
|
||||
/// device's limits.
|
||||
pub(super) fn makeable(device: &wgpu::Device, def: &CustomImage) -> Result<(), String> {
|
||||
let format = format(def.format);
|
||||
pub(super) fn makeable(
|
||||
device: &wgpu::Device,
|
||||
def: &CustomImage,
|
||||
packed_float: bool,
|
||||
) -> Result<(), String> {
|
||||
let format = format(def.format, packed_float);
|
||||
let missing = format.required_features() - device.features();
|
||||
if !missing.is_empty() {
|
||||
return Err(format!(
|
||||
|
||||
@@ -34,24 +34,36 @@ mod packed;
|
||||
#[cfg(test)]
|
||||
mod world_target_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod celestial_blend_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod higher_shadowcolor_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod single_side_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod sky_disc_blend_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod undrawn_terrain_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod shadow_faces_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod r11f_image_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod forced_flip_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod weather_depth_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod float_into_integer_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod custom_type_tests;
|
||||
|
||||
@@ -61,9 +73,15 @@ mod sky_depth_tests;
|
||||
#[cfg(test)]
|
||||
mod end_fan_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod runtime_length_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod translucent_color_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod noise_resolution_tests;
|
||||
|
||||
/// The texture behind a pack sampler name.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Source {
|
||||
@@ -375,6 +393,9 @@ struct Pass {
|
||||
name: String,
|
||||
group: Group,
|
||||
pipeline: Pipeline,
|
||||
/// The same program as vanilla blends the sky's parts (`PostProgram::vanilla_sky_blends`):
|
||||
/// without blending for the disc and the void, then with `STARS_BLEND` for the stars.
|
||||
sky_parts: Option<[wgpu::RenderPipeline; 2]>,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
ubo: wgpu::Buffer,
|
||||
block: Vec<u8>,
|
||||
@@ -596,6 +617,8 @@ pub struct PackPost {
|
||||
/// What draws the first-person hand (`gbuffers_hand` or its fallback), after the opaque
|
||||
/// world, and its vertices.
|
||||
hand: Option<(Pass, super::pack_hand::PackHand)>,
|
||||
/// `gbuffers_hand_water` (or its fallback): the hand's translucent quads, after deferred.
|
||||
hand_water: Option<Pass>,
|
||||
/// The pre-translucent copy: `depthtex1`, or `depthtex2` without a shader hand.
|
||||
depth1: bool,
|
||||
/// A program after the hand reads `depthtex2`: it is copied before the hand.
|
||||
@@ -861,10 +884,11 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
|
||||
}
|
||||
}
|
||||
|
||||
/// 256×256 RGB white noise (6.4), from a fixed seed so every run looks the same.
|
||||
fn noise(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
|
||||
/// `size`×`size` RGB white noise (6.4), from a fixed seed so every run looks the same.
|
||||
fn noise(device: &wgpu::Device, queue: &wgpu::Queue, size: u32) -> wgpu::TextureView {
|
||||
let size = size.min(device.limits().max_texture_dimension_2d);
|
||||
let mut state = 0x2545_f491_4f6c_dd1du64;
|
||||
let texels: Vec<u8> = (0..256 * 256 * 4)
|
||||
let texels: Vec<u8> = (0..size as usize * size as usize * 4)
|
||||
.map(|i| {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
@@ -872,7 +896,7 @@ fn noise(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
|
||||
if i % 4 == 3 { 255 } else { (state >> 56) as u8 }
|
||||
})
|
||||
.collect();
|
||||
texture(device, queue, "Shader pack noisetex", 256, &texels)
|
||||
texture(device, queue, "Shader pack noisetex", size, &texels)
|
||||
}
|
||||
|
||||
fn texture(
|
||||
@@ -1528,13 +1552,20 @@ fn bindings(
|
||||
}
|
||||
(None, None) => stand_in(&i.name, notes),
|
||||
};
|
||||
if actual != format {
|
||||
// An `R11F_G11F_B10F` image or buffer this device makes `RGBA16F` (a float stand-in
|
||||
// that neither clamps nor drops NaN) takes `layout(r11f_g11f_b10f)` as `rgba16f`
|
||||
// (`storage_formats_of`): Iris 1.11.7 stored into and read back such an image with a
|
||||
// probe pack of ours (#10490), where Horizon refused the program.
|
||||
let stand_in = format == wgpu::TextureFormat::Rg11b10Ufloat
|
||||
&& actual == wgpu::TextureFormat::Rgba16Float;
|
||||
if actual != format && !stand_in {
|
||||
notes.push(format!(
|
||||
"{name}: `{}` is {} but its texture is {actual:?}",
|
||||
i.name, i.format
|
||||
));
|
||||
return None;
|
||||
}
|
||||
let format = actual;
|
||||
let access = match i.access.as_str() {
|
||||
"writeonly" => wgpu::StorageTextureAccess::WriteOnly,
|
||||
"readonly" => wgpu::StorageTextureAccess::ReadOnly,
|
||||
@@ -1565,6 +1596,51 @@ fn bindings(
|
||||
Some((entries, samplers, slots, reads, shadow_reads, images))
|
||||
}
|
||||
|
||||
/// Makes each storage image of `module` in group 0 declare the format its layout entry binds:
|
||||
/// a `layout(r11f_g11f_b10f)` image whose texture is the `RGBA16F` stand-in (`bindings`).
|
||||
fn storage_formats_of(module: &mut naga::Module, entries: &[wgpu::BindGroupLayoutEntry]) {
|
||||
let globals: Vec<_> = module.global_variables.iter().map(|(h, _)| h).collect();
|
||||
for handle in globals {
|
||||
let var = &module.global_variables[handle];
|
||||
let Some(naga::ResourceBinding { group: 0, binding }) = var.binding else {
|
||||
continue;
|
||||
};
|
||||
let naga::TypeInner::Image {
|
||||
dim,
|
||||
arrayed,
|
||||
class: naga::ImageClass::Storage { format, access },
|
||||
} = module.types[var.ty].inner
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let bound = entries.iter().find_map(|e| match e.ty {
|
||||
wgpu::BindingType::StorageTexture { format, .. } if e.binding == binding => {
|
||||
Some(format)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
if format == naga::StorageFormat::Rg11b10Ufloat
|
||||
&& bound == Some(wgpu::TextureFormat::Rgba16Float)
|
||||
{
|
||||
let ty = module.types.insert(
|
||||
naga::Type {
|
||||
name: module.types[var.ty].name.clone(),
|
||||
inner: naga::TypeInner::Image {
|
||||
dim,
|
||||
arrayed,
|
||||
class: naga::ImageClass::Storage {
|
||||
format: naga::StorageFormat::Rgba16Float,
|
||||
access,
|
||||
},
|
||||
},
|
||||
},
|
||||
naga::Span::UNDEFINED,
|
||||
);
|
||||
module.global_variables[handle].ty = ty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The wgpu format of an image's format qualifier.
|
||||
fn image_format(qualifier: &str) -> Option<wgpu::TextureFormat> {
|
||||
use wgpu::TextureFormat as T;
|
||||
@@ -1680,10 +1756,15 @@ fn build_pass(
|
||||
features, interface, &p.name, p.group, &p.buffers, geometry, formats, shadow, notes, pack,
|
||||
)?;
|
||||
let final_pass = p.group == Group::Final;
|
||||
// Outputs into an integer buffer, and its integer kind (`packed::bitcast_outputs`).
|
||||
let mut int_outputs = Vec::new();
|
||||
let mut targets = Vec::new();
|
||||
let mut writes = Vec::new();
|
||||
// Outputs into a buffer of a small packed format, and the bits it keeps.
|
||||
let mut packed_outputs = Vec::new();
|
||||
// Each output's own `blend.<program>.<buffer>`, and whether its buffer blends at all: what
|
||||
// the sky's part pipelines keep (`PostProgram::vanilla_sky_blends`).
|
||||
let mut own_blends: Vec<(Option<wgpu::BlendState>, bool)> = Vec::new();
|
||||
for &location in &interface.outputs {
|
||||
let l = location as usize;
|
||||
if targets.len() <= l {
|
||||
@@ -1695,6 +1776,15 @@ fn build_pass(
|
||||
}
|
||||
} else if let Some(&buffer) = p.buffers.get(l) {
|
||||
let format = formats[buffer as usize];
|
||||
match format.sample_type(None, Some(features)) {
|
||||
Some(wgpu::TextureSampleType::Uint) => {
|
||||
int_outputs.push((location, naga::ScalarKind::Uint));
|
||||
}
|
||||
Some(wgpu::TextureSampleType::Sint) => {
|
||||
int_outputs.push((location, naga::ScalarKind::Sint));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let mut state = target(format);
|
||||
if !p.group.shadow()
|
||||
&& pack.rgb & 1 << buffer != 0
|
||||
@@ -1702,19 +1792,21 @@ fn build_pass(
|
||||
{
|
||||
t.write_mask = wgpu::ColorWrites::COLOR;
|
||||
}
|
||||
if let (Some(t), Some(b)) = (
|
||||
&mut state,
|
||||
p.buffer_blends
|
||||
.iter()
|
||||
.find(|(n, _)| *n == buffer)
|
||||
.map_or(p.blend, |(_, b)| *b),
|
||||
) && format
|
||||
let own = p.buffer_blends.iter().find(|(n, _)| *n == buffer);
|
||||
let blendable = format
|
||||
.guaranteed_format_features(features)
|
||||
.flags
|
||||
.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE)
|
||||
.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE);
|
||||
if let (Some(t), Some(b)) = (&mut state, own.map_or(p.blend, |(_, b)| *b))
|
||||
&& blendable
|
||||
{
|
||||
t.blend = Some(blend_state(b));
|
||||
}
|
||||
if own_blends.len() <= l {
|
||||
own_blends.resize(l + 1, (None, false));
|
||||
}
|
||||
let own = own.and_then(|(_, b)| *b).filter(|_| blendable);
|
||||
own_blends[l] = (own.map(blend_state), blendable);
|
||||
if !p.group.shadow()
|
||||
&& let Some(bits) = pack.packed[buffer as usize]
|
||||
{
|
||||
@@ -1774,6 +1866,7 @@ fn build_pass(
|
||||
});
|
||||
let module = |mut m: naga::Module| {
|
||||
share_samplers(&mut m, &slots);
|
||||
storage_formats_of(&mut m, &entries);
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(&p.name),
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(m)),
|
||||
@@ -1781,55 +1874,76 @@ fn build_pass(
|
||||
};
|
||||
let mut fragment = program.fragment;
|
||||
packed::quantise_outputs(&mut fragment, &packed_outputs);
|
||||
packed::bitcast_outputs(&mut fragment, &int_outputs);
|
||||
let (vs, fs) = (module(program.vertex), module(fragment));
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(&p.name),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &vs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
// A geometry stage reads its vertices itself.
|
||||
buffers: geometry
|
||||
.filter(|_| p.geometry_triangles == 0)
|
||||
.map_or(&[], |g| g.buffers),
|
||||
},
|
||||
// Rows flipped to GL's turn front faces clockwise.
|
||||
primitive: wgpu::PrimitiveState {
|
||||
front_face: wgpu::FrontFace::Cw,
|
||||
cull_mode: geometry
|
||||
.filter(|g| g.cull && p.geometry_triangles == 0)
|
||||
.map(|_| wgpu::Face::Back),
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: geometry.map(|g| wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(g.depth_write),
|
||||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
stencil: Default::default(),
|
||||
bias: if p.name == "gbuffers_damagedblock" {
|
||||
wgpu::DepthBiasState {
|
||||
constant: -10,
|
||||
slope_scale: -1.0,
|
||||
clamp: 0.0,
|
||||
}
|
||||
} else {
|
||||
Default::default()
|
||||
let descriptor = |targets: &[Option<wgpu::ColorTargetState>]| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(&p.name),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &vs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
// A geometry stage reads its vertices itself.
|
||||
buffers: geometry
|
||||
.filter(|_| p.geometry_triangles == 0)
|
||||
.map_or(&[], |g| g.buffers),
|
||||
},
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &fs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &targets,
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
// Rows flipped to GL's turn front faces clockwise.
|
||||
primitive: wgpu::PrimitiveState {
|
||||
front_face: wgpu::FrontFace::Cw,
|
||||
cull_mode: geometry
|
||||
.filter(|g| g.cull && p.geometry_triangles == 0)
|
||||
.map(|_| wgpu::Face::Back),
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: geometry.map(|g| wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(g.depth_write),
|
||||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
stencil: Default::default(),
|
||||
bias: if p.name == "gbuffers_damagedblock" {
|
||||
wgpu::DepthBiasState {
|
||||
constant: -10,
|
||||
slope_scale: -1.0,
|
||||
clamp: 0.0,
|
||||
}
|
||||
} else {
|
||||
Default::default()
|
||||
},
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &fs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
targets,
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let pipeline = descriptor(&targets);
|
||||
// The program's own blend replaced by vanilla's for the part; a buffer's own
|
||||
// `blend.<program>.<buffer>` still applies (Iris 1.11.7 blended the sky disc into that
|
||||
// buffer only, with a probe pack of ours).
|
||||
let sky_parts = p.vanilla_sky_blends.then(|| {
|
||||
[None, Some(blend_state(horizon_shaders::STARS_BLEND))].map(|part| {
|
||||
let targets: Vec<_> = (targets.iter().enumerate())
|
||||
.map(|(l, t)| {
|
||||
let (own, blendable) = own_blends.get(l).copied().unwrap_or((None, false));
|
||||
t.clone().map(|t| wgpu::ColorTargetState {
|
||||
blend: own.or(part.filter(|_| blendable)),
|
||||
..t
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
descriptor(&targets)
|
||||
})
|
||||
});
|
||||
(layout, pipeline)
|
||||
(layout, pipeline, sky_parts)
|
||||
});
|
||||
let (layout, pipeline) = match created {
|
||||
let (layout, pipeline, sky_parts) = match created {
|
||||
Ok(created) => created,
|
||||
Err(e) => {
|
||||
notes.push(format!("{}: skipped: {e}", p.name));
|
||||
@@ -1867,6 +1981,7 @@ fn build_pass(
|
||||
name: p.name,
|
||||
group: p.group,
|
||||
pipeline: Pipeline::Render(pipeline),
|
||||
sky_parts,
|
||||
layout,
|
||||
samplers,
|
||||
writes,
|
||||
@@ -1960,6 +2075,7 @@ fn build_compute(
|
||||
});
|
||||
let mut module = c.module;
|
||||
share_samplers(&mut module, &slots);
|
||||
storage_formats_of(&mut module, &entries);
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(&name),
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
|
||||
@@ -2005,6 +2121,7 @@ fn build_compute(
|
||||
render: c.work_groups_render,
|
||||
local,
|
||||
},
|
||||
sky_parts: None,
|
||||
layout,
|
||||
samplers,
|
||||
writes: Vec::new(),
|
||||
@@ -2089,6 +2206,7 @@ impl PackPost {
|
||||
}
|
||||
let textures = pack_textures::upload(device, queue, post.textures, &mut notes);
|
||||
let shadow_config = post.config.shadow.clone();
|
||||
let noise_size = post.config.noise_resolution.unwrap_or(256);
|
||||
// shadowcolor2 to 7 only for a pack that names `HIGHER_SHADOWCOLOR`.
|
||||
let shadow_colors = (1u16 << post.shadow_colors) - 1;
|
||||
let mut custom = post.custom;
|
||||
@@ -2131,9 +2249,14 @@ impl PackPost {
|
||||
// An image this device can't make or store into: the programs using it are skipped.
|
||||
let mut image_defs = std::mem::take(&mut post.images);
|
||||
let mut left_out = Vec::new();
|
||||
let packed_float = gpu.storable(adapter, wgpu::TextureFormat::Rg11b10Ufloat, features);
|
||||
image_defs.retain(|def| {
|
||||
let made = pack_images::makeable(device, def).and_then(|()| {
|
||||
if gpu.storable(adapter, pack_images::format(def.format), features) {
|
||||
let made = pack_images::makeable(device, def, packed_float).and_then(|()| {
|
||||
if gpu.storable(
|
||||
adapter,
|
||||
pack_images::format(def.format, packed_float),
|
||||
features,
|
||||
) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("this GPU can't store into its format".into())
|
||||
@@ -2155,7 +2278,7 @@ impl PackPost {
|
||||
));
|
||||
left_out.push(def);
|
||||
}
|
||||
let images = pack_images::images(image_defs);
|
||||
let images = pack_images::images(image_defs, packed_float);
|
||||
let mut objects = std::mem::take(&mut post.buffer_objects);
|
||||
if gpu.storage_stages().is_empty() {
|
||||
objects.clear();
|
||||
@@ -2468,6 +2591,20 @@ impl PackPost {
|
||||
};
|
||||
build(p, Some(&g)).map(|pass| (pass, geometry))
|
||||
});
|
||||
// Translucent held items, drawn from the hand's own buffer.
|
||||
let hand_water = post.hand_water.and_then(|p| {
|
||||
let (_, geometry) = hand.as_ref()?;
|
||||
let g = Geometry {
|
||||
buffers: &entity_buffers,
|
||||
table: Some(&geometry.layout),
|
||||
depth_write: true,
|
||||
atlas: world.celestial.clone(),
|
||||
material_maps: false,
|
||||
ids: None,
|
||||
cull: false,
|
||||
};
|
||||
build(p, Some(&g))
|
||||
});
|
||||
let weather = post
|
||||
.weather
|
||||
.zip(weather_geometry.as_ref())
|
||||
@@ -2588,6 +2725,9 @@ impl PackPost {
|
||||
if let Some((h, _)) = &hand {
|
||||
notes.push(format!("the hand draws with {}", h.name));
|
||||
}
|
||||
if let Some(h) = &hand_water {
|
||||
notes.push(format!("translucent held items draw with {}", h.name));
|
||||
}
|
||||
if let Some([w, _]) = &weather {
|
||||
notes.push(format!("rain and snow draw with {}", w.name));
|
||||
}
|
||||
@@ -2641,6 +2781,7 @@ impl PackPost {
|
||||
.chain(&lines)
|
||||
.chain(weather.iter().flatten())
|
||||
.chain(hand.iter().map(|h| &h.0))
|
||||
.chain(&hand_water)
|
||||
};
|
||||
let shadow_targets = shadow_formats.map(|f| {
|
||||
let colors = every_pass().fold(0u16, |m, p| {
|
||||
@@ -2694,6 +2835,7 @@ impl PackPost {
|
||||
.chain(&water)
|
||||
.chain(weather.iter().take(1).flatten())
|
||||
.chain(hand.iter().map(|h| &h.0))
|
||||
.chain(&hand_water)
|
||||
.find(touched)
|
||||
.map(|_| true)
|
||||
.or_else(|| passes.iter().find(touched).map(|_| true));
|
||||
@@ -2873,6 +3015,7 @@ impl PackPost {
|
||||
weather_draw: None,
|
||||
weather_generation: 0,
|
||||
hand,
|
||||
hand_water,
|
||||
depth1,
|
||||
depth2,
|
||||
shadow_targets,
|
||||
@@ -2913,7 +3056,7 @@ impl PackPost {
|
||||
targets: None,
|
||||
noise: pack_textures::noise(&textures)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| noise(device, queue)),
|
||||
.unwrap_or_else(|| noise(device, queue, noise_size)),
|
||||
dummy: texture(device, queue, "Shader pack stand-in", 1, &white),
|
||||
dummy_black: texture(device, queue, "Shader pack black stand-in", 1, &black),
|
||||
dummy_normal: texture(
|
||||
@@ -3503,6 +3646,16 @@ impl PackPost {
|
||||
open.as_mut().expect("open").1 = i;
|
||||
}
|
||||
let rp = &mut open.as_mut().expect("open").0;
|
||||
// Each part with vanilla's blend for it: one program, three pipelines.
|
||||
let part_pipeline =
|
||||
pass.sky_parts
|
||||
.as_ref()
|
||||
.and_then(|[plain, stars]| match part.stage {
|
||||
"SKY" | "VOID" => Some(plain),
|
||||
"STARS" => Some(stars),
|
||||
_ => None,
|
||||
});
|
||||
rp.set_pipeline(part_pipeline.unwrap_or(pass.render()));
|
||||
if pass.draw {
|
||||
self.draws.bind(rp, part.stage);
|
||||
}
|
||||
@@ -3703,6 +3856,7 @@ impl PackPost {
|
||||
portal: [f32; 4],
|
||||
rest: &mut Vec<super::Vertex>,
|
||||
skin: Option<&super::entity_skin::SkinFrame>,
|
||||
motion: (&[(u32, u64)], &[u32]),
|
||||
) {
|
||||
self.portal_rect = portal;
|
||||
let shadow = self.shadow_casters();
|
||||
@@ -3723,6 +3877,7 @@ impl PackPost {
|
||||
shadow,
|
||||
rest,
|
||||
skin,
|
||||
motion,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3841,8 +3996,14 @@ impl PackPost {
|
||||
rp.set_bind_group(1, &geometry.group, &[]);
|
||||
rp.set_vertex_buffer(0, geometry.vertices());
|
||||
rp.set_index_buffer(geometry.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
// The arm and each held item: their own `currentRenderedItemId`.
|
||||
for &(first, quads, offset) in &geometry.runs {
|
||||
// The arm and each held item: their own `currentRenderedItemId`. Translucent
|
||||
// quads wait for `gbuffers_hand_water` when the pack has one.
|
||||
let late = self.hand_water.is_some();
|
||||
let runs = geometry
|
||||
.runs
|
||||
.iter()
|
||||
.chain(geometry.translucent_runs.iter().filter(|_| !late));
|
||||
for &(first, quads, offset) in runs {
|
||||
if pass.draw {
|
||||
rp.set_bind_group(2, &geometry.slot_group, &[offset]);
|
||||
}
|
||||
@@ -3969,9 +4130,53 @@ impl PackPost {
|
||||
terrain,
|
||||
);
|
||||
self.draw_entities(device, queue, encoder, size, true);
|
||||
self.draw_hand_water(device, queue, encoder, size);
|
||||
self.draw_weather(device, queue, encoder, size);
|
||||
}
|
||||
|
||||
/// The hand's translucent quads (uploaded by `draw_hand` this frame), with
|
||||
/// `gbuffers_hand_water`.
|
||||
fn draw_hand_water(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
size: [u32; 2],
|
||||
) {
|
||||
let Some(mut pass) = self.hand_water.take() else {
|
||||
return;
|
||||
};
|
||||
let hand = self.hand.take();
|
||||
if let Some((_, geometry)) = hand
|
||||
.as_ref()
|
||||
.filter(|(_, g)| g.quads > 0 && !g.translucent_runs.is_empty())
|
||||
{
|
||||
let group = self.bind(device, queue, encoder, &mut pass, size);
|
||||
{
|
||||
let mut rp = self.begin(encoder, &pass);
|
||||
rp.set_pipeline(pass.render());
|
||||
rp.set_bind_group(0, &group, &[]);
|
||||
if pass.buffers
|
||||
&& let Some(g) = &self.buffers.group
|
||||
{
|
||||
rp.set_bind_group(4, g, &[]);
|
||||
}
|
||||
rp.set_bind_group(1, &geometry.group, &[]);
|
||||
rp.set_vertex_buffer(0, geometry.vertices());
|
||||
rp.set_index_buffer(geometry.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
for &(first, quads, offset) in &geometry.translucent_runs {
|
||||
if pass.draw {
|
||||
rp.set_bind_group(2, &geometry.slot_group, &[offset]);
|
||||
}
|
||||
rp.draw_indexed(first * 6..(first + quads) * 6, 0, 0..1);
|
||||
}
|
||||
}
|
||||
self.mips_valid &= !pass.written;
|
||||
}
|
||||
self.hand = hand;
|
||||
self.hand_water = Some(pass);
|
||||
}
|
||||
|
||||
/// The rain, then the snow, `set_weather` gave for this frame, with `gbuffers_weather`.
|
||||
fn draw_weather(
|
||||
&mut self,
|
||||
@@ -4147,6 +4352,7 @@ impl PackPost {
|
||||
.chain(&mut self.lines)
|
||||
.chain(self.weather.iter_mut().flatten())
|
||||
.chain(self.hand.iter_mut().map(|h| &mut h.0))
|
||||
.chain(&mut self.hand_water)
|
||||
.chain(&mut self.shadow_terrain)
|
||||
.chain(&mut self.shadow_water)
|
||||
.chain(&mut self.shadow_comp);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//! The sun and moon blend as vanilla's `RenderPipelines.CELESTIAL` (`BlendFunction.OVERLAY`):
|
||||
//! their colour is added to the sky's, and their own alpha replaces the buffer's. Iris 1.11.7
|
||||
//! left the sun's alpha in a buffer the sky had written first, with a probe pack of ours
|
||||
//! (#10490). Original fixture, no pack files.
|
||||
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
|
||||
use super::*;
|
||||
use crate::hud::HudBatch;
|
||||
use wgpu::TextureFormat as T;
|
||||
|
||||
/// The sky writes alpha 1 into colortex1, then the sun (default blend) writes alpha 0.5 there;
|
||||
/// final shows colortex1's alpha in red.
|
||||
fn files() -> Vec<(String, String)> {
|
||||
let program = |alpha: &str| {
|
||||
format!(
|
||||
"#version 120\n/* DRAWBUFFERS:1 */\n\
|
||||
void main() {{ gl_FragData[0] = vec4(0.0, 0.0, 0.0, {alpha}); }}\n"
|
||||
)
|
||||
};
|
||||
let vertex = "#version 120\nvoid main() { gl_Position = ftransform(); }\n";
|
||||
vec![
|
||||
("gbuffers_skybasic.vsh".into(), vertex.into()),
|
||||
("gbuffers_skybasic.fsh".into(), program("1.0")),
|
||||
("gbuffers_skytextured.vsh".into(), vertex.into()),
|
||||
("gbuffers_skytextured.fsh".into(), program("0.5")),
|
||||
("final.vsh".into(), VERTEX.into()),
|
||||
(
|
||||
"final.fsh".into(),
|
||||
"#version 120\nuniform sampler2D colortex1;\nvarying vec2 uv;\n\
|
||||
void main() { gl_FragColor = vec4(texture2D(colortex1, uv).a, 0.0, 0.0, 1.0); }\n"
|
||||
.into(),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// A quad across the whole view, `z` blocks in front of the camera.
|
||||
fn quad(z: f32) -> [crate::mesher::Vertex; 4] {
|
||||
[
|
||||
[-400.0, -400.0],
|
||||
[400.0, -400.0],
|
||||
[400.0, 400.0],
|
||||
[-400.0, 400.0],
|
||||
]
|
||||
.map(|[x, y]| crate::mesher::Vertex {
|
||||
pos: [x, y, z],
|
||||
uv: [0.0; 2],
|
||||
layer: 0,
|
||||
color: u32::MAX,
|
||||
light: 0,
|
||||
normal: [0; 4],
|
||||
glint_uv: [0.0; 2],
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sun_leaves_its_own_alpha_over_the_sky() {
|
||||
let Some(gpu) = Gpu::new() else { return };
|
||||
let color = gpu.texture(T::Rgba8Unorm);
|
||||
let depth = gpu.texture(T::Depth32Float);
|
||||
// The world drew nothing: the far plane (reverse-Z) everywhere.
|
||||
gpu.clear(&color, &depth, 0.0);
|
||||
let sun = gpu.texture(T::Rgba8Unorm);
|
||||
let mut post = gpu.load(
|
||||
files(),
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: color.clone(),
|
||||
terrain: None,
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: Some(sun),
|
||||
gui_size: SIZE,
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
horizon_shaders::GameInputs {
|
||||
sky: Some(super::super::pack_sky::inputs()),
|
||||
celestial: Some(super::super::pack_sky::celestial_inputs()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(post.sky.is_some(), "{:?}", post.notes);
|
||||
let hud = HudBatch::new(SIZE);
|
||||
let mut frame = fixture_frame(&hud);
|
||||
frame.title = false;
|
||||
frame.skybox = true;
|
||||
let sun = quad(100.0);
|
||||
frame.celestial = &sun;
|
||||
let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {});
|
||||
// The top four rows see the sky disc (sky_depth_tests); the sun covers the whole view.
|
||||
for (i, p) in pixels[..4 * SIZE[0] as usize].iter().enumerate() {
|
||||
assert!(
|
||||
p[0].abs_diff(128) <= 1,
|
||||
"pixel {i}: colortex1 alpha {} / 255, expected the sun's 0.5",
|
||||
p[0]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -614,6 +614,7 @@ pub(super) fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
visible_static_block_entity_sections: &[],
|
||||
static_block_entity_stats: crate::block_entity_render::StaticCacheFrameStats::default(),
|
||||
entity_motion: &[],
|
||||
entity_motion_posed: &[],
|
||||
colored_lights: &[],
|
||||
emissive_layers: &[],
|
||||
shadow_only: &[],
|
||||
|
||||
@@ -452,6 +452,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
visible_static_block_entity_sections: &[],
|
||||
static_block_entity_stats: crate::block_entity_render::StaticCacheFrameStats::default(),
|
||||
entity_motion: &[],
|
||||
entity_motion_posed: &[],
|
||||
colored_lights: &[],
|
||||
emissive_layers: &[],
|
||||
shadow_only: &[],
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
//! A program writing a float `vec4` into an integer buffer draws: its other buffers get what it
|
||||
//! wrote, and the integer buffer the floats' bits, as Iris 1.11.7 on NVIDIA 615.78.08 did with
|
||||
//! a probe pack of ours (#10490). Original fixture, no pack files.
|
||||
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
|
||||
use super::*;
|
||||
use crate::hud::HudBatch;
|
||||
use wgpu::TextureFormat as T;
|
||||
|
||||
#[test]
|
||||
fn a_float_output_into_an_integer_buffer_writes_its_bits() {
|
||||
let Some(gpu) = Gpu::new() else { return };
|
||||
let files = vec![
|
||||
("composite.vsh".into(), VERTEX.into()),
|
||||
(
|
||||
"composite.fsh".into(),
|
||||
"#version 130\n/*\nconst int colortex3Format = RGBA32UI;\n*/\n/* DRAWBUFFERS:03 */\n\
|
||||
void main() {\n\
|
||||
\x20 gl_FragData[0] = vec4(0.25, 0.5, 0.75, 1.0);\n\
|
||||
\x20 gl_FragData[1] = vec4(1.5, 2.0, 7.0, 0.25);\n}\n"
|
||||
.into(),
|
||||
),
|
||||
("final.vsh".into(), VERTEX.into()),
|
||||
(
|
||||
"final.fsh".into(),
|
||||
"#version 130\nuniform sampler2D colortex0;\nuniform usampler2D colortex3;\n\
|
||||
varying vec2 uv;\n\
|
||||
void main() {\n\
|
||||
\x20 uvec4 q = texelFetch(colortex3, ivec2(gl_FragCoord.xy), 0);\n\
|
||||
\x20 bvec4 bits = equal(q, uvec4(0x3FC00000u, 0x40000000u, 0x40E00000u, 0x3E800000u));\n\
|
||||
\x20 gl_FragColor = uv.y > 0.5 ? texture2D(colortex0, uv)\n\
|
||||
\x20 : vec4(float(bits.x && bits.y), float(bits.z), float(bits.w), 1.0);\n}\n"
|
||||
.into(),
|
||||
),
|
||||
];
|
||||
let color = gpu.texture(T::Rgba8Unorm);
|
||||
let depth = gpu.texture(T::Depth32Float);
|
||||
gpu.clear(&color, &depth, 0.0);
|
||||
let mut post = gpu.load(
|
||||
files,
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: color.clone(),
|
||||
terrain: None,
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: SIZE,
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
horizon_shaders::GameInputs::default(),
|
||||
);
|
||||
let hud = HudBatch::new(SIZE);
|
||||
let mut frame = fixture_frame(&hud);
|
||||
frame.title = false;
|
||||
let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {});
|
||||
let width = SIZE[0] as usize;
|
||||
let (top, bottom) = (pixels[0], pixels[(SIZE[1] as usize - 1) * width]);
|
||||
assert!(
|
||||
top[0].abs_diff(64) <= 1 && top[1].abs_diff(128) <= 1 && top[2].abs_diff(191) <= 1,
|
||||
"colortex0 {top:?}, notes {:?}",
|
||||
post.notes
|
||||
);
|
||||
assert_eq!(bottom, [255, 255, 255, 255], "colortex3's bits");
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! `noiseTextureResolution` sizes the `noisetex` Horizon makes, as Iris 1.11.7 did with probe packs
|
||||
//! of ours (#10490): a declaration in a fragment shader counts, one in a vertex shader doesn't.
|
||||
//! Original fixture, no pack files.
|
||||
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
|
||||
use super::*;
|
||||
use crate::hud::HudBatch;
|
||||
use wgpu::TextureFormat as T;
|
||||
|
||||
/// The width of `noisetex` that `final` sees, with these lines added to `final.vsh` and `final.fsh`.
|
||||
fn noise_width(vertex_extra: &str, fragment_extra: &str) -> u8 {
|
||||
let Some(gpu) = Gpu::new() else { return 0 };
|
||||
let vertex = VERTEX.replacen('\n', &format!("\n{vertex_extra}"), 1);
|
||||
let files = vec![
|
||||
("final.vsh".into(), vertex),
|
||||
(
|
||||
"final.fsh".into(),
|
||||
format!(
|
||||
"#version 130\n{fragment_extra}uniform sampler2D noisetex;\nvarying vec2 uv;\n\
|
||||
void main() {{ gl_FragColor = vec4(float(textureSize(noisetex, 0).x) / 255.0, 0.0, 0.0, 1.0); }}\n"
|
||||
),
|
||||
),
|
||||
];
|
||||
let color = gpu.texture(T::Rgba8Unorm);
|
||||
let depth = gpu.texture(T::Depth32Float);
|
||||
gpu.clear(&color, &depth, 0.0);
|
||||
let mut post = gpu.load(
|
||||
files,
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: color.clone(),
|
||||
terrain: None,
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: SIZE,
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
horizon_shaders::GameInputs::default(),
|
||||
);
|
||||
let hud = HudBatch::new(SIZE);
|
||||
let mut frame = fixture_frame(&hud);
|
||||
frame.title = false;
|
||||
let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {});
|
||||
assert!(post.notes.is_empty(), "{:?}", post.notes);
|
||||
pixels[0][0]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fragment_shader_sets_the_noise_resolution() {
|
||||
if Gpu::new().is_none() {
|
||||
return;
|
||||
}
|
||||
let declared = "#define NOISE 64\nconst int noiseTextureResolution = NOISE;\n";
|
||||
assert_eq!(noise_width("", declared), 64);
|
||||
// Not from a vertex shader: the default 256, read as 255 (1.0).
|
||||
assert_eq!(
|
||||
noise_width("const int noiseTextureResolution = 64;\n", ""),
|
||||
255
|
||||
);
|
||||
}
|
||||
@@ -180,6 +180,124 @@ pub(super) fn quantise_outputs(module: &mut naga::Module, outputs: &[(u32, [u8;
|
||||
f.body = out;
|
||||
}
|
||||
|
||||
/// A program's `vec4` float output into an integer buffer (`RGBA32UI`...) writes the float's
|
||||
/// bits there, and the program draws its other buffers. Horizon refused such a program, since
|
||||
/// wgpu has no float output into an integer target. Iris 1.11.7 drew it, with a probe pack of
|
||||
/// ours (#10490): its other buffers kept every bit written, NaN payloads included, and the
|
||||
/// integer buffer read the floats' bits back (1.5 as 0x3FC00000). GL leaves the integer
|
||||
/// buffer's value undefined, so those bits are what this driver (NVIDIA 615.78.08) wrote.
|
||||
pub(super) fn bitcast_outputs(module: &mut naga::Module, outputs: &[(u32, naga::ScalarKind)]) {
|
||||
let Some(ep) =
|
||||
(module.entry_points.iter_mut()).find(|e| e.stage == naga::ShaderStage::Fragment)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(result) = &ep.function.result else {
|
||||
return;
|
||||
};
|
||||
let naga::TypeInner::Struct { members, span } = module.types[result.ty].inner.clone() else {
|
||||
return;
|
||||
};
|
||||
let mut new_members = members.clone();
|
||||
// (member, integer kind) of each output that changes.
|
||||
let mut changed = Vec::new();
|
||||
for (i, m) in members.iter().enumerate() {
|
||||
let Some(naga::Binding::Location { location, .. }) = m.binding else {
|
||||
continue;
|
||||
};
|
||||
let Some(&(_, kind)) = outputs.iter().find(|o| o.0 == location) else {
|
||||
continue;
|
||||
};
|
||||
if !matches!(
|
||||
module.types[m.ty].inner,
|
||||
naga::TypeInner::Vector {
|
||||
size: VectorSize::Quad,
|
||||
scalar: naga::Scalar::F32
|
||||
}
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let ty = module.types.insert(
|
||||
naga::Type {
|
||||
name: None,
|
||||
inner: naga::TypeInner::Vector {
|
||||
size: VectorSize::Quad,
|
||||
scalar: naga::Scalar { kind, width: 4 },
|
||||
},
|
||||
},
|
||||
naga::Span::UNDEFINED,
|
||||
);
|
||||
new_members[i].ty = ty;
|
||||
if let Some(naga::Binding::Location {
|
||||
interpolation,
|
||||
sampling,
|
||||
..
|
||||
}) = &mut new_members[i].binding
|
||||
{
|
||||
*interpolation = Some(naga::Interpolation::Flat);
|
||||
*sampling = None;
|
||||
}
|
||||
changed.push((i, kind));
|
||||
}
|
||||
if changed.is_empty() {
|
||||
return;
|
||||
}
|
||||
let old_name = module.types[result.ty].name.clone();
|
||||
let struct_ty = module.types.insert(
|
||||
naga::Type {
|
||||
name: old_name.map(|n| n + "_bits"),
|
||||
inner: naga::TypeInner::Struct {
|
||||
members: new_members,
|
||||
span,
|
||||
},
|
||||
},
|
||||
naga::Span::UNDEFINED,
|
||||
);
|
||||
let f = &mut ep.function;
|
||||
if let Some(r) = &mut f.result {
|
||||
r.ty = struct_ty;
|
||||
}
|
||||
let body = std::mem::take(&mut f.body);
|
||||
let mut out = naga::Block::with_capacity(body.len() + 1);
|
||||
for (statement, sp) in body.span_into_iter() {
|
||||
let Statement::Return { value: Some(value) } = statement else {
|
||||
out.push(statement, sp);
|
||||
continue;
|
||||
};
|
||||
let Expression::Compose { components, .. } = &f.expressions[value] else {
|
||||
out.push(statement, sp);
|
||||
continue;
|
||||
};
|
||||
let mut components = components.clone();
|
||||
let mut first = None;
|
||||
for &(member, kind) in &changed {
|
||||
let e = f.expressions.append(
|
||||
Expression::As {
|
||||
expr: components[member],
|
||||
kind,
|
||||
convert: None,
|
||||
},
|
||||
sp,
|
||||
);
|
||||
first.get_or_insert(e);
|
||||
components[member] = e;
|
||||
}
|
||||
let value = f.expressions.append(
|
||||
Expression::Compose {
|
||||
ty: struct_ty,
|
||||
components,
|
||||
},
|
||||
sp,
|
||||
);
|
||||
out.push(
|
||||
Statement::Emit(naga::Range::new_from_bounds(first.unwrap_or(value), value)),
|
||||
sp,
|
||||
);
|
||||
out.push(Statement::Return { value: Some(value) }, sp);
|
||||
}
|
||||
f.body = out;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
//! An `R11F_G11F_B10F` image that a compute program declares `layout(r11f_g11f_b10f)` is stored
|
||||
//! into and read back, as Iris 1.11.7 did with a probe pack of ours (#10490): its program is no
|
||||
//! longer refused when the image is the `RGBA16F` stand-in. Original fixture, no pack files.
|
||||
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
|
||||
use super::*;
|
||||
use crate::hud::HudBatch;
|
||||
use wgpu::TextureFormat as T;
|
||||
|
||||
#[test]
|
||||
fn an_r11f_image_is_stored_into_and_read_back() {
|
||||
let Some(gpu) = Gpu::new() else { return };
|
||||
let files = vec![
|
||||
(
|
||||
"shaders.properties".into(),
|
||||
"image.imgA = texA RGBA R11F_G11F_B10F FLOAT false false 4 4\n".into(),
|
||||
),
|
||||
(
|
||||
"composite.csh".into(),
|
||||
"#version 430\nlayout(local_size_x = 1) in;\nconst ivec3 workGroups = ivec3(1, 1, 1);\n\
|
||||
layout(r11f_g11f_b10f) writeonly uniform image2D imgA;\n\
|
||||
void main() { imageStore(imgA, ivec2(1, 1), vec4(0.25, 1.5, 3.0, 1.0)); }\n"
|
||||
.into(),
|
||||
),
|
||||
("final.vsh".into(), VERTEX.into()),
|
||||
(
|
||||
"final.fsh".into(),
|
||||
"#version 130\nuniform sampler2D texA;\nvarying vec2 uv;\n\
|
||||
void main() { gl_FragColor = vec4(texelFetch(texA, ivec2(1, 1), 0).rgb / 4.0, 1.0); }\n"
|
||||
.into(),
|
||||
),
|
||||
];
|
||||
let color = gpu.texture(T::Rgba8Unorm);
|
||||
let depth = gpu.texture(T::Depth32Float);
|
||||
gpu.clear(&color, &depth, 0.0);
|
||||
let mut post = gpu.load(
|
||||
files,
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: color.clone(),
|
||||
terrain: None,
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: SIZE,
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
horizon_shaders::GameInputs::default(),
|
||||
);
|
||||
let hud = HudBatch::new(SIZE);
|
||||
let mut frame = fixture_frame(&hud);
|
||||
frame.title = false;
|
||||
let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {});
|
||||
// (0.25, 1.5, 3.0) / 4 = (0.0625, 0.375, 0.75).
|
||||
let p = pixels[0];
|
||||
assert!(
|
||||
p[0].abs_diff(16) <= 1 && p[1].abs_diff(96) <= 1 && p[2].abs_diff(191) <= 1,
|
||||
"{p:?}, notes {:?}",
|
||||
post.notes
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! `.length()` of a runtime-sized array in a named buffer block reads the buffer's own length, as
|
||||
//! Iris 1.11.7 did with a probe pack of ours (#10490): `bufferObject.0 = 32` holds 8 floats. The
|
||||
//! program was refused before. Original fixture, no pack files.
|
||||
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
|
||||
use super::*;
|
||||
use crate::hud::HudBatch;
|
||||
use wgpu::TextureFormat as T;
|
||||
|
||||
#[test]
|
||||
fn a_runtime_arrays_length_is_the_buffers() {
|
||||
let Some(gpu) = Gpu::new() else { return };
|
||||
let files = vec![
|
||||
("shaders.properties".into(), "bufferObject.0 = 32\n".into()),
|
||||
("final.vsh".into(), VERTEX.into()),
|
||||
(
|
||||
"final.fsh".into(),
|
||||
"#version 430 compatibility\nin vec2 uv;\n\
|
||||
layout(std430, binding = 0) buffer Values { float v[]; } values;\n\
|
||||
void main() { gl_FragColor = vec4(float(values.v.length()) / 32.0, 0.0, 0.0, 1.0); }\n"
|
||||
.into(),
|
||||
),
|
||||
];
|
||||
let color = gpu.texture(T::Rgba8Unorm);
|
||||
let depth = gpu.texture(T::Depth32Float);
|
||||
gpu.clear(&color, &depth, 0.0);
|
||||
let mut post = gpu.load(
|
||||
files,
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: color.clone(),
|
||||
terrain: None,
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: SIZE,
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
horizon_shaders::GameInputs::default(),
|
||||
);
|
||||
let hud = HudBatch::new(SIZE);
|
||||
let mut frame = fixture_frame(&hud);
|
||||
frame.title = false;
|
||||
let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {});
|
||||
// 32 bytes of floats: 8 entries, 8 / 32 = 0.25.
|
||||
let p = pixels[0];
|
||||
assert!(p[0].abs_diff(64) <= 1, "{p:?}, notes {:?}", post.notes);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! The sky disc draws unblended, as vanilla draws it (`RenderPipelines.SKY`): what
|
||||
//! `gbuffers_skybasic` writes there stays, alpha included. Iris 1.11.7 left exactly that with
|
||||
//! probe packs of ours (#10490). Original fixture, no pack files.
|
||||
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
|
||||
use super::*;
|
||||
use crate::hud::HudBatch;
|
||||
use wgpu::TextureFormat as T;
|
||||
|
||||
/// `gbuffers_skybasic` writes (0.2, 0.4, 0.6, 0.25) into colortex1, with `blend` as the
|
||||
/// properties line; final shows colortex1's colour on the top half and its alpha on the bottom
|
||||
/// half, each seeing the top of the view.
|
||||
fn files(blend: &str) -> Vec<(String, String)> {
|
||||
vec![
|
||||
("shaders.properties".into(), blend.into()),
|
||||
(
|
||||
"gbuffers_skybasic.vsh".into(),
|
||||
"#version 120\nvoid main() { gl_Position = ftransform(); }\n".into(),
|
||||
),
|
||||
(
|
||||
"gbuffers_skybasic.fsh".into(),
|
||||
"#version 120\n/* DRAWBUFFERS:1 */\n\
|
||||
void main() { gl_FragData[0] = vec4(0.2, 0.4, 0.6, 0.25); }\n"
|
||||
.into(),
|
||||
),
|
||||
("final.vsh".into(), VERTEX.into()),
|
||||
(
|
||||
"final.fsh".into(),
|
||||
"#version 120\nuniform sampler2D colortex1;\nvarying vec2 uv;\n\
|
||||
void main() {\n\
|
||||
\x20 vec4 c = texture2D(colortex1, vec2(uv.x, uv.y > 0.5 ? uv.y : uv.y + 0.5));\n\
|
||||
\x20 gl_FragColor = uv.y > 0.5 ? vec4(c.rgb, 1.0) : vec4(c.a, c.a, c.a, 1.0);\n}\n"
|
||||
.into(),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The pack's picture of a frame whose world drew nothing.
|
||||
fn picture(gpu: &Gpu, blend: &str) -> Vec<[u8; 4]> {
|
||||
let color = gpu.texture(T::Rgba8Unorm);
|
||||
let depth = gpu.texture(T::Depth32Float);
|
||||
// The far plane everywhere (reverse-Z).
|
||||
gpu.clear(&color, &depth, 0.0);
|
||||
let mut post = gpu.load(
|
||||
files(blend),
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: color.clone(),
|
||||
terrain: None,
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: SIZE,
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
horizon_shaders::GameInputs {
|
||||
sky: Some(super::super::pack_sky::inputs()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(post.sky.is_some(), "{:?}", post.notes);
|
||||
let hud = HudBatch::new(SIZE);
|
||||
let mut frame = fixture_frame(&hud);
|
||||
frame.title = false;
|
||||
frame.skybox = true;
|
||||
gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {})
|
||||
}
|
||||
|
||||
/// The top four rows see the sky disc (sky_depth_tests); final repeats them in rows 8 to 11.
|
||||
#[test]
|
||||
fn the_sky_disc_keeps_what_the_program_wrote() {
|
||||
let Some(gpu) = Gpu::new() else { return };
|
||||
let width = SIZE[0] as usize;
|
||||
let pixels = picture(&gpu, "");
|
||||
for (i, p) in pixels[..4 * width].iter().enumerate() {
|
||||
let near = |got: u8, want: u8| got.abs_diff(want) <= 1;
|
||||
assert!(
|
||||
near(p[0], 51) && near(p[1], 102) && near(p[2], 153),
|
||||
"pixel {i}: colour {p:?}, expected (0.2, 0.4, 0.6)"
|
||||
);
|
||||
}
|
||||
for (i, p) in pixels[8 * width..12 * width].iter().enumerate() {
|
||||
assert!(p[0].abs_diff(64) <= 1, "pixel {i}: alpha {} / 255", p[0]);
|
||||
}
|
||||
// A pack's own `blend.gbuffers_skybasic` still blends the disc: alpha over the cleared 1.
|
||||
let blended = picture(
|
||||
&gpu,
|
||||
"blend.gbuffers_skybasic = SRC_ALPHA ONE_MINUS_SRC_ALPHA ONE ZERO\n",
|
||||
);
|
||||
assert!(blended[0][0].abs_diff(51) > 8, "{:?}", blended[0]);
|
||||
// So does a `blend.gbuffers_skybasic.colortex1` of its own, into that buffer.
|
||||
let own = picture(
|
||||
&gpu,
|
||||
"blend.gbuffers_skybasic.colortex1 = SRC_ALPHA ONE_MINUS_SRC_ALPHA ONE ZERO\n",
|
||||
);
|
||||
assert!(own[0][0].abs_diff(51) > 8, "{:?}", own[0]);
|
||||
}
|
||||
@@ -421,6 +421,7 @@ fn frame(hud: &HudBatch) -> Frame<'_> {
|
||||
visible_static_block_entity_sections: &[],
|
||||
static_block_entity_stats: crate::block_entity_render::StaticCacheFrameStats::default(),
|
||||
entity_motion: &[],
|
||||
entity_motion_posed: &[],
|
||||
colored_lights: &[],
|
||||
emissive_layers: &[],
|
||||
shadow_only: &[],
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
//! `HORIZON_SHADER_PACK_TIMING=1`. One frame
|
||||
//! at a time is timed, and the CPU never waits for it: its timestamps are resolved in a later
|
||||
//! frame, once the GPU has finished it (Metal resolves them without waiting for the passes that
|
||||
//! write them), then read back when mapped. The next frame after that is timed again.
|
||||
//! write them), then read back when mapped. The next frame after that is timed again, once `gap`
|
||||
//! has passed since the last.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const REPORT: usize = 30;
|
||||
/// Passes timed per frame at most (two timestamps each).
|
||||
@@ -43,6 +45,10 @@ pub struct PassTiming {
|
||||
/// `mark`'s pipeline, for the target format it was made for.
|
||||
marker: Option<(wgpu::TextureFormat, wgpu::RenderPipeline)>,
|
||||
reports: usize,
|
||||
/// Least time between timed frames.
|
||||
pub gap: Duration,
|
||||
/// When the last timed frame began.
|
||||
timed_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl PassTiming {
|
||||
@@ -81,6 +87,8 @@ impl PassTiming {
|
||||
log: Self::logging(),
|
||||
marker: None,
|
||||
reports: 0,
|
||||
gap: Duration::ZERO,
|
||||
timed_at: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -123,9 +131,12 @@ impl PassTiming {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if matches!(self.stage, Stage::Idle) {
|
||||
if matches!(self.stage, Stage::Idle)
|
||||
&& self.timed_at.is_none_or(|at| at.elapsed() >= self.gap)
|
||||
{
|
||||
self.names.clear();
|
||||
self.stage = Stage::Recording;
|
||||
self.timed_at = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,31 @@ pub struct PackInputs {
|
||||
/// `frameCounter`) instead of the wall clock and the frames drawn, and smoothed values
|
||||
/// (`wetness`, `eyeBrightnessSmooth`, `centerDepthSmooth`) at their targets.
|
||||
pub still_ticks: Option<u64>,
|
||||
/// A scripted clock (`screenshot-sequence`): taken before `still_ticks` and the wall
|
||||
/// clock, and smoothed values move by its `delta`.
|
||||
pub frame_timing: Option<FrameTiming>,
|
||||
}
|
||||
|
||||
/// One frame of a scripted pack clock: the frame's number from 1, the seconds since frame 1,
|
||||
/// and the seconds since the frame before (0 on frame 1).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct FrameTiming {
|
||||
pub counter: u32,
|
||||
pub elapsed: f32,
|
||||
pub delta: f32,
|
||||
}
|
||||
|
||||
impl FrameTiming {
|
||||
/// Frame `n` (from 1) of frames `step_ms` milliseconds apart (`16.666667` for 60 fps). The
|
||||
/// elapsed time is worked out in f64, so frame 10 000 is as exact as frame 2.
|
||||
pub fn nth(n: u32, step_ms: f64) -> Self {
|
||||
let step = step_ms / 1000.0;
|
||||
Self {
|
||||
counter: n,
|
||||
elapsed: (f64::from(n.saturating_sub(1)) * step) as f32,
|
||||
delta: if n <= 1 { 0.0 } else { step as f32 },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `cloudHeight` uniform: the dimension's `visual/cloud_height`
|
||||
@@ -349,8 +374,10 @@ pub struct FrameUniforms {
|
||||
start: std::time::Instant,
|
||||
last: Option<std::time::Instant>,
|
||||
frame: u32,
|
||||
/// The last frame's `PackInputs::still_ticks`, for `eval_custom`.
|
||||
still_ticks: Option<u64>,
|
||||
/// The tick `eval_custom` restarts the custom uniforms at: every frame's under STILL; on a
|
||||
/// scripted clock's frame 1 only 0 (where a freshly parsed pack starts), so its later frames
|
||||
/// carry their smoothing and randoms.
|
||||
custom_restart: Option<u64>,
|
||||
previous: Option<(glam::DVec3, Mat4, Mat4)>,
|
||||
wetness: f32,
|
||||
eye_smooth: [f32; 2],
|
||||
@@ -369,7 +396,7 @@ impl Default for FrameUniforms {
|
||||
start: std::time::Instant::now(),
|
||||
last: None,
|
||||
frame: 0,
|
||||
still_ticks: None,
|
||||
custom_restart: None,
|
||||
previous: None,
|
||||
wetness: 0.0,
|
||||
eye_smooth: [0.0; 2],
|
||||
@@ -687,9 +714,16 @@ impl FrameUniforms {
|
||||
let now = std::time::Instant::now();
|
||||
let measured = self.last.map_or(0.0, |l| (now - l).as_secs_f32());
|
||||
self.last = Some(now);
|
||||
self.still_ticks = inputs.still_ticks;
|
||||
self.custom_restart = match inputs.frame_timing {
|
||||
Some(timing) => (timing.counter <= 1).then_some(0),
|
||||
None => inputs.still_ticks,
|
||||
};
|
||||
// Smoothing over an endless step lands on its target.
|
||||
let dt = inputs.still_ticks.map_or(measured, |_| f32::INFINITY);
|
||||
let dt = match (inputs.frame_timing, inputs.still_ticks) {
|
||||
(Some(timing), _) => timing.delta,
|
||||
(None, Some(_)) => f32::INFINITY,
|
||||
(None, None) => measured,
|
||||
};
|
||||
let aspect = size[0] as f32 / size[1].max(1) as f32;
|
||||
// Packs take gbufferProjection for a plain perspective (they invert only its perspective
|
||||
// terms): the view bob and the nausea spin belong to the model-view, as GL drew them.
|
||||
@@ -781,13 +815,14 @@ impl FrameUniforms {
|
||||
let (shadow_view, shadow_view_inverse) = shadow_model_view(frame, &self.shadow);
|
||||
let day_time = inputs.day_time.rem_euclid(24000) as i32;
|
||||
let world_day = inputs.day_time.div_euclid(24000);
|
||||
let (seconds, frame_time, frame_counter) = match inputs.still_ticks {
|
||||
Some(ticks) => (
|
||||
let (seconds, frame_time, frame_counter) = match (inputs.frame_timing, inputs.still_ticks) {
|
||||
(Some(timing), _) => (timing.elapsed, timing.delta, timing.counter as i32),
|
||||
(None, Some(ticks)) => (
|
||||
(ticks as f32 / 20.0) % 3600.0,
|
||||
0.05,
|
||||
(ticks % 720_720) as i32,
|
||||
),
|
||||
None => (
|
||||
(None, None) => (
|
||||
(now - self.start).as_secs_f32() % 3600.0,
|
||||
measured,
|
||||
self.frame as i32,
|
||||
@@ -929,7 +964,7 @@ impl FrameUniforms {
|
||||
/// Evaluates the pack's custom uniforms with this frame's values and puts them after
|
||||
/// `NAMES`' values, in `custom.uniforms()` order.
|
||||
pub fn eval_custom(&mut self, custom: &mut horizon_shaders::Custom) {
|
||||
if let Some(tick) = self.still_ticks {
|
||||
if let Some(tick) = self.custom_restart {
|
||||
custom.restart_at(tick);
|
||||
}
|
||||
self.values.truncate(NAMES.len());
|
||||
@@ -1298,7 +1333,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod initial_history_tests {
|
||||
pub(super) mod initial_history_tests {
|
||||
use super::*;
|
||||
use crate::hud::HudBatch;
|
||||
fn float(u: &FrameUniforms, name: &str) -> [f32; 16] {
|
||||
@@ -2193,6 +2228,145 @@ mod initial_history_tests {
|
||||
assert_eq!(float(&u, "centerDepthSmooth")[0], 0.0);
|
||||
}
|
||||
|
||||
/// A scripted clock's values reach the pack as they are, before STILL's ticks.
|
||||
#[test]
|
||||
fn a_scripted_clock_comes_before_the_still_ticks() {
|
||||
let hud = HudBatch::new([800, 450]);
|
||||
let f = frame(&hud);
|
||||
for still_ticks in [None, Some(47_811)] {
|
||||
let mut u = FrameUniforms::default();
|
||||
for (n, elapsed, delta) in [(1, 0.0, 0.0), (64, 3.15, 0.05)] {
|
||||
let input = PackInputs {
|
||||
day_time: 6_001,
|
||||
still_ticks,
|
||||
frame_timing: Some(FrameTiming {
|
||||
counter: n,
|
||||
elapsed,
|
||||
delta,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
u.update(&f, &input, [800, 450]);
|
||||
match u.values[index("frameCounter").unwrap()] {
|
||||
Value::I(v) => assert_eq!(v[0], n as i32),
|
||||
_ => panic!("integer uniform expected"),
|
||||
}
|
||||
assert_eq!(float(&u, "frameTimeCounter")[0], elapsed);
|
||||
assert_eq!(float(&u, "frameTime")[0], delta);
|
||||
match u.values[index("worldTime").unwrap()] {
|
||||
Value::I(v) => assert_eq!(v[0], 6_001),
|
||||
_ => panic!("integer uniform expected"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Smoothed values move by a scripted clock's delta, also under STILL, which would
|
||||
/// otherwise put them at their targets.
|
||||
#[test]
|
||||
fn a_scripted_clock_moves_smoothed_values_by_its_delta() {
|
||||
let hud = HudBatch::new([800, 450]);
|
||||
let f = frame(&hud);
|
||||
let mut u = FrameUniforms::default();
|
||||
let mut input = PackInputs {
|
||||
rain: 1.0,
|
||||
eye_light: [0, 15],
|
||||
still_ticks: Some(47_811),
|
||||
frame_timing: Some(FrameTiming::nth(1, 1000.0)),
|
||||
..Default::default()
|
||||
};
|
||||
u.center_depth_raw = Some(1.0);
|
||||
u.update(&f, &input, [800, 450]);
|
||||
input.rain = 0.0;
|
||||
input.eye_light = [0, 0];
|
||||
u.center_depth_raw = Some(0.0);
|
||||
// A second each: eyeBrightnessSmooth halves (1 s half-life),
|
||||
// centerDepthSmooth halves ten times (0.1 s), wetness falls by 0.5^(1/20) (20 s).
|
||||
for (n, eye, depth, wetness) in [
|
||||
(2, 120, 1.0 / 1_024.0, 0.965_936_3),
|
||||
(3, 60, 1.0 / 1_048_576.0, 0.933_033),
|
||||
] {
|
||||
input.frame_timing = Some(FrameTiming::nth(n, 1000.0));
|
||||
u.update(&f, &input, [800, 450]);
|
||||
match u.values[index("eyeBrightnessSmooth").unwrap()] {
|
||||
Value::I(v) => assert_eq!(v[1], eye),
|
||||
_ => panic!("integer uniform expected"),
|
||||
}
|
||||
assert_eq!(float(&u, "centerDepthSmooth")[0], depth);
|
||||
assert!((float(&u, "wetness")[0] - wetness).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom uniforms start over on a scripted clock's frame 1 and carry their smoothing
|
||||
/// and randoms through the frames after it, so a sequence drawn again repeats.
|
||||
#[test]
|
||||
fn custom_uniforms_start_over_on_a_scripted_frame_1() {
|
||||
let properties = horizon_shaders::Properties::parse(
|
||||
"uniform.float.s = smooth(rainStrength, 10.0)
|
||||
uniform.float.r = random()
|
||||
",
|
||||
&horizon_shaders::Macros::standard(),
|
||||
);
|
||||
let mut custom = horizon_shaders::Custom::parse(&properties);
|
||||
let hud = HudBatch::new([800, 450]);
|
||||
let f = frame(&hud);
|
||||
let mut u = FrameUniforms::default();
|
||||
let mut run = |u: &mut FrameUniforms| -> Vec<[u32; 2]> {
|
||||
(1..=3)
|
||||
.map(|n| {
|
||||
let input = PackInputs {
|
||||
rain: if n == 1 { 0.0 } else { 1.0 },
|
||||
still_ticks: Some(47_811),
|
||||
frame_timing: Some(FrameTiming::nth(n, 1000.0)),
|
||||
..Default::default()
|
||||
};
|
||||
u.update(&f, &input, [800, 450]);
|
||||
u.eval_custom(&mut custom);
|
||||
std::array::from_fn(|i| match u.values[NAMES.len() + i] {
|
||||
Value::F(v) => v[0].to_bits(),
|
||||
_ => panic!("float uniform expected"),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let first = run(&mut u);
|
||||
// The smoothed rain moves toward 1 over frames 2 and 3; the randoms differ
|
||||
// each frame.
|
||||
let s: Vec<f32> = first.iter().map(|v| f32::from_bits(v[0])).collect();
|
||||
assert!(
|
||||
s[0] == 0.0 && s[0] < s[1] && s[1] < s[2] && s[2] < 1.0,
|
||||
"{s:?}"
|
||||
);
|
||||
assert!(first[0][1] != first[1][1] && first[1][1] != first[2][1]);
|
||||
assert_eq!(run(&mut u), first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_nth_scripted_frame_counts_from_1() {
|
||||
assert_eq!(
|
||||
FrameTiming::nth(1, 50.0),
|
||||
FrameTiming {
|
||||
counter: 1,
|
||||
elapsed: 0.0,
|
||||
delta: 0.0
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
FrameTiming::nth(2, 50.0),
|
||||
FrameTiming {
|
||||
counter: 2,
|
||||
elapsed: 0.05,
|
||||
delta: 0.05
|
||||
}
|
||||
);
|
||||
assert_eq!(FrameTiming::nth(64, 50.0).elapsed, 3.15);
|
||||
assert_eq!(FrameTiming::nth(10_000, 50.0).elapsed, 499.95);
|
||||
assert_eq!(FrameTiming::nth(3, 1.0).elapsed, 0.002);
|
||||
// 60 fps: frame 61 is one second in, not 61 × 16 ms or 61 × 17 ms.
|
||||
let sixty = FrameTiming::nth(61, 1000.0 / 60.0);
|
||||
assert_eq!((sixty.elapsed, sixty.delta), (1.0, 1.0 / 60.0));
|
||||
}
|
||||
|
||||
/// Iris 1.11.7 (probe pack): `ambientLight` is the dimension type's (0.1 in the Nether,
|
||||
/// 0.25 in the End), and `playerMood` and `constantMood` both the cave mood.
|
||||
#[test]
|
||||
@@ -2210,7 +2384,7 @@ mod initial_history_tests {
|
||||
assert_eq!(float(&u, "constantMood")[0], 0.0235);
|
||||
}
|
||||
|
||||
fn frame(hud: &HudBatch) -> Frame<'_> {
|
||||
pub(in crate::gfx) fn frame(hud: &HudBatch) -> Frame<'_> {
|
||||
Frame {
|
||||
eye: glam::DVec3::ZERO,
|
||||
eye_rotation: [0.0; 2],
|
||||
@@ -2258,6 +2432,7 @@ mod initial_history_tests {
|
||||
visible_static_block_entity_sections: &[],
|
||||
static_block_entity_stats: crate::block_entity_render::StaticCacheFrameStats::default(),
|
||||
entity_motion: &[],
|
||||
entity_motion_posed: &[],
|
||||
colored_lights: &[],
|
||||
emissive_layers: &[],
|
||||
shadow_only: &[],
|
||||
|
||||
@@ -771,7 +771,7 @@ pub struct Graphics {
|
||||
/// is made (start, resource-pack reload), like `translucent_batching`, where it is offered
|
||||
/// (`smooth_gradients_offered`).
|
||||
pub hdr_smooth_gradients: bool,
|
||||
/// The shader pack picked in `shaderpacks/` (`shader_pack_list`); empty: none. Its key is
|
||||
/// The native `horizon:<manifest-id>` or classic filename picked in `shaderpacks/`; empty: none. Its key is
|
||||
/// not `shaderPack`, which versions before the clean-room rewrite saved and which stays
|
||||
/// ignored.
|
||||
pub shader_pack: String,
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod block_entity_anim;
|
||||
pub mod block_entity_cem;
|
||||
pub mod block_entity_render;
|
||||
pub mod block_sprites;
|
||||
pub mod block_states;
|
||||
pub mod bluemap;
|
||||
pub mod bluemap_detect;
|
||||
pub mod border_render;
|
||||
@@ -27,6 +28,7 @@ pub mod cit;
|
||||
pub mod cit_armor;
|
||||
pub mod cit_assets;
|
||||
pub mod cit_glint;
|
||||
pub mod client_resources;
|
||||
pub mod clouds;
|
||||
pub mod collisions;
|
||||
pub mod command_block;
|
||||
|
||||
@@ -52,6 +52,8 @@ pub struct LightTables {
|
||||
/// what `light_chunk` stores per cell, so a cell costs 2 bytes and the
|
||||
/// flood fill never reads the (large) `cells` table.
|
||||
info: Vec<u32>,
|
||||
/// `info`'s low 16 bits.
|
||||
packed: Vec<u16>,
|
||||
/// `blocked(type_a, type_b, direction)` as a bit per direction.
|
||||
block_masks: Vec<u8>,
|
||||
}
|
||||
@@ -73,6 +75,7 @@ impl LightTables {
|
||||
faces: 2,
|
||||
types: 1,
|
||||
info: Vec::new(),
|
||||
packed: Vec::new(),
|
||||
block_masks: Vec::new(),
|
||||
};
|
||||
tables.rebuild_cache();
|
||||
@@ -101,6 +104,9 @@ impl LightTables {
|
||||
types <= 4096,
|
||||
"light tables: {types} cell configurations exceed the 12-bit type field"
|
||||
);
|
||||
// The region `light_chunk` floods over is filled with 0, and every air
|
||||
// cell of a section writes `info` of state 0 over it (`states_of`).
|
||||
debug_assert_eq!(info[0], 0, "state 0 (air) must hold no light info");
|
||||
let mut block_masks = vec![0u8; types * types];
|
||||
for a in 0..types {
|
||||
for b in 0..types {
|
||||
@@ -121,6 +127,7 @@ impl LightTables {
|
||||
}
|
||||
}
|
||||
self.types = types;
|
||||
self.packed = info.iter().map(|&i| i as u16).collect();
|
||||
self.info = info;
|
||||
self.block_masks = block_masks;
|
||||
}
|
||||
@@ -212,6 +219,7 @@ impl LightTables {
|
||||
faces,
|
||||
types: 0,
|
||||
info: Vec::new(),
|
||||
packed: Vec::new(),
|
||||
block_masks: Vec::new(),
|
||||
};
|
||||
tables.rebuild_cache();
|
||||
@@ -276,7 +284,8 @@ struct LightScratch {
|
||||
sky_queue: Vec<u32>,
|
||||
present: Vec<bool>,
|
||||
lowest: Vec<i32>,
|
||||
ids: Box<[u32; 4096]>,
|
||||
/// What `states_of` keeps between sections.
|
||||
section: SectionScratch,
|
||||
}
|
||||
|
||||
impl LightScratch {
|
||||
@@ -289,7 +298,7 @@ impl LightScratch {
|
||||
sky_queue: Vec::new(),
|
||||
present: vec![false; W * W],
|
||||
lowest: vec![0; W * W],
|
||||
ids: Box::new([0u32; 4096]),
|
||||
section: SectionScratch::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,10 +316,132 @@ impl LightScratch {
|
||||
}
|
||||
}
|
||||
|
||||
/// One section's values mapped to the packed light info, and the palette's
|
||||
/// info (`states_of`, kept between sections).
|
||||
struct SectionScratch {
|
||||
stage: Box<[u16; 4096]>,
|
||||
stage32: Box<[u32; 4096]>,
|
||||
info: Vec<u32>,
|
||||
packed: Vec<u16>,
|
||||
}
|
||||
impl SectionScratch {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
stage: Box::new([0u16; 4096]),
|
||||
stage32: Box::new([0u32; 4096]),
|
||||
info: Vec::new(),
|
||||
packed: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static SCRATCH: RefCell<LightScratch> = RefCell::new(LightScratch::new());
|
||||
}
|
||||
|
||||
/// Where a section sits in the 48-wide region.
|
||||
#[derive(Clone, Copy)]
|
||||
struct At {
|
||||
x: usize,
|
||||
y: usize,
|
||||
z: usize,
|
||||
}
|
||||
impl At {
|
||||
/// The region index of the section's row `row` (`ly << 4 | lz`), the
|
||||
/// start of a run of 16 cells along x.
|
||||
#[inline]
|
||||
fn row(self, row: usize) -> usize {
|
||||
(self.y + (row >> 4)) * (W * W) + (self.z + (row & 15)) * W + self.x
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy one section's block states into the region's `states` as the packed
|
||||
/// light info `light_chunk` floods over, and seed `queue` (and `block`) with
|
||||
/// the cells that emit light. The values are mapped to that info in one pass
|
||||
/// over the section's packed storage into `stage` (index order), then copied a
|
||||
/// row of 16 along x at a time: the region's rows are 48 apart, so the two
|
||||
/// cannot be one.
|
||||
fn states_of(
|
||||
section: &Section,
|
||||
tables: &LightTables,
|
||||
at: At,
|
||||
states: &mut [u16],
|
||||
block: &mut [u8],
|
||||
queue: &mut Vec<u32>,
|
||||
scratch: &mut SectionScratch,
|
||||
) {
|
||||
if section.blocks.bits() == 0 {
|
||||
// A section of one state (the caller has skipped the air ones): a row
|
||||
// of the region is that state's info.
|
||||
let info = tables.info[section.blocks.get(0) as usize];
|
||||
for ly in 0..16 {
|
||||
let mut p = at.row(ly << 4);
|
||||
for _ in 0..16 {
|
||||
states[p..p + 16].fill(info as u16);
|
||||
if info > 0xFFFF {
|
||||
for x in 0..16 {
|
||||
block[p + x] = (info >> 16) as u8;
|
||||
queue.push((p + x) as u32);
|
||||
}
|
||||
}
|
||||
p += W;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// The info of the ids the section stores: a list palette's ids are mapped
|
||||
// once, a global palette's are state ids, which `tables.info` holds. Only
|
||||
// a section whose palette emits light (an info's high bits) needs the
|
||||
// emission of each cell.
|
||||
let (info, packed, emits) = match section.blocks.palette() {
|
||||
Some(list) => {
|
||||
scratch.info.clear();
|
||||
scratch
|
||||
.info
|
||||
.extend(list.iter().map(|&id| tables.info[id as usize]));
|
||||
scratch.packed.clear();
|
||||
scratch
|
||||
.packed
|
||||
.extend(scratch.info.iter().map(|&i| i as u16));
|
||||
let emits = scratch.info.iter().any(|&i| i > 0xFFFF);
|
||||
(&scratch.info[..], &scratch.packed[..], emits)
|
||||
}
|
||||
None => (&tables.info[..], &tables.packed[..], true),
|
||||
};
|
||||
if emits {
|
||||
section
|
||||
.blocks
|
||||
.unpack_ids_mapped(&mut scratch.stage32[..], info);
|
||||
for ly in 0..16 {
|
||||
let mut p = at.row(ly << 4);
|
||||
for lz in 0..16 {
|
||||
let i = (ly << 8) | (lz << 4);
|
||||
for x in 0..16 {
|
||||
let info = scratch.stage32[i + x];
|
||||
states[p + x] = info as u16;
|
||||
if info > 0xFFFF {
|
||||
block[p + x] = (info >> 16) as u8;
|
||||
queue.push((p + x) as u32);
|
||||
}
|
||||
}
|
||||
p += W;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
section
|
||||
.blocks
|
||||
.unpack_ids_mapped(&mut scratch.stage[..], packed);
|
||||
for ly in 0..16 {
|
||||
let mut p = at.row(ly << 4);
|
||||
let (rows, _) = scratch.stage[(ly << 8)..(ly << 8) + 256].as_chunks::<16>();
|
||||
for row in rows {
|
||||
states[p..p + 16].copy_from_slice(row);
|
||||
p += W;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `region[dz][dx]` holds the chunks around the centre (index 1,1).
|
||||
/// `sky` is false in dimensions without skylight (the Nether).
|
||||
pub fn light_chunk(
|
||||
@@ -356,7 +487,7 @@ fn light_chunk_with_scratch(
|
||||
let block = &mut scratch.block[..total];
|
||||
let queue = &mut scratch.queue;
|
||||
let sky_queue = &mut scratch.sky_queue;
|
||||
let ids = &mut scratch.ids;
|
||||
let section_scratch = &mut scratch.section;
|
||||
|
||||
for (dz, row) in region.iter().enumerate() {
|
||||
let z_base = dz * 16;
|
||||
@@ -367,31 +498,12 @@ fn light_chunk_with_scratch(
|
||||
if section.is_uniform(0) {
|
||||
continue;
|
||||
}
|
||||
section.unpack(ids);
|
||||
let y_base = si * 16;
|
||||
for ly in 0..16 {
|
||||
let y = y_base + ly;
|
||||
let y_offset = y * (W * W);
|
||||
for lz in 0..16 {
|
||||
let z = z_base + lz;
|
||||
let p_row = y_offset + z * W + x_base;
|
||||
let i_row = (ly << 8) | (lz << 4);
|
||||
for lx in 0..16 {
|
||||
let id = ids[i_row + lx];
|
||||
if id == 0 {
|
||||
continue;
|
||||
}
|
||||
let p = p_row + lx;
|
||||
let info = tables.info[id as usize];
|
||||
states[p] = info as u16;
|
||||
let emission = info >> 16;
|
||||
if emission > 0 {
|
||||
block[p] = emission as u8;
|
||||
queue.push(p as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let at = At {
|
||||
x: x_base,
|
||||
y: si * 16,
|
||||
z: z_base,
|
||||
};
|
||||
states_of(section, tables, at, states, block, queue, section_scratch);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2195,6 +2307,88 @@ mod tests {
|
||||
pack_lighting(sections, h, &sky, &block, sky_light)
|
||||
}
|
||||
|
||||
/// 600 states of every dampening, the last 300 of them light sources (a
|
||||
/// section's palette must hold emitting values too, which take
|
||||
/// `states_of`'s other pass).
|
||||
fn info_tables() -> LightTables {
|
||||
let cells = (0..600u32)
|
||||
.map(|id| Cell {
|
||||
dampening: (id % 16) as u8,
|
||||
emission: if id >= 300 { 14 } else { 0 },
|
||||
shaped: false,
|
||||
faces: [EMPTY; 6],
|
||||
})
|
||||
.collect();
|
||||
LightTables::from_parts(cells, vec![false, true, true, true], 2)
|
||||
}
|
||||
|
||||
/// `states_of` writes what walking the section's `unpack`ed state ids
|
||||
/// writes, for every palette the collector can hold: one value, a list of
|
||||
/// up to 256 (four to eight bits wide) and the global palette beyond it,
|
||||
/// with and without a light source in the palette.
|
||||
#[test]
|
||||
fn section_states_match_the_unpacked_ids() {
|
||||
let tables = info_tables();
|
||||
let at = At { x: 0, y: 0, z: 0 };
|
||||
let mut ids = Box::new([0u32; 4096]);
|
||||
// (distinct values, the bits the collector stores them in)
|
||||
let cases = [
|
||||
(1, 0),
|
||||
(3, 4),
|
||||
(20, 5),
|
||||
(40, 6),
|
||||
(80, 7),
|
||||
(200, 8),
|
||||
(300, 16),
|
||||
];
|
||||
for (distinct, bits) in cases {
|
||||
for emitting in [false, true] {
|
||||
let base = if emitting { 300 } else { 0 };
|
||||
let values: Vec<u32> = (0..4096)
|
||||
// A pattern that repeats each value the section holds.
|
||||
.map(|i| base + ((i * 7 + i / 16) % distinct) as u32)
|
||||
.collect();
|
||||
let section = Section {
|
||||
blocks: Section::from_blocks(values.as_slice().try_into().unwrap()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
section.blocks.bits(),
|
||||
bits,
|
||||
"{distinct} values should be stored in {bits} bits"
|
||||
);
|
||||
let mut states = vec![0u16; W * W * 16];
|
||||
let mut block = vec![0u8; W * W * 16];
|
||||
let mut queue = Vec::new();
|
||||
states_of(
|
||||
§ion,
|
||||
&tables,
|
||||
at,
|
||||
&mut states,
|
||||
&mut block,
|
||||
&mut queue,
|
||||
&mut SectionScratch::new(),
|
||||
);
|
||||
|
||||
section.unpack(&mut ids);
|
||||
let mut want_queue = Vec::new();
|
||||
for (i, &id) in ids.iter().enumerate() {
|
||||
let info = tables.info[id as usize];
|
||||
let p = at.row(i >> 4) + (i & 15);
|
||||
assert_eq!(
|
||||
states[p], info as u16,
|
||||
"{distinct} values, emitting {emitting}: state at {i}"
|
||||
);
|
||||
if info > 0xFFFF {
|
||||
assert_eq!(block[p], (info >> 16) as u8, "emission at {i}");
|
||||
want_queue.push(p as u32);
|
||||
}
|
||||
}
|
||||
assert_eq!(queue, want_queue, "{distinct} values, emitting {emitting}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_chunk_matches_old_algorithm() {
|
||||
let t = tables();
|
||||
|
||||
@@ -401,6 +401,7 @@ fn visibility(pad: &Padded, info: &[Info]) -> u64 {
|
||||
}
|
||||
/// The section's cells that are not `isSolidRender`, by row (`y << 4 | z`, bit
|
||||
/// x), from the padded rows of `MeshScratch::solid_rows`.
|
||||
#[cfg(test)]
|
||||
fn open_rows(solid: &[u32]) -> [u16; 256] {
|
||||
std::array::from_fn(|r| {
|
||||
let (y, z) = ((r >> 4) as i32, (r & 15) as i32);
|
||||
@@ -977,10 +978,11 @@ impl Padded {
|
||||
// The section itself is unpacked in one pass and copied row by row;
|
||||
// the border cells of the neighbouring sections are read in runs, from
|
||||
// sections found once per layer.
|
||||
own.fill(0);
|
||||
let center = region[1][1];
|
||||
if let Some(c) = center {
|
||||
c.sections[sy as usize].unpack(own);
|
||||
} else {
|
||||
own.fill(0);
|
||||
}
|
||||
for y in -B..16 + B {
|
||||
let wy = base_y + y;
|
||||
@@ -1065,6 +1067,18 @@ fn fill_run(
|
||||
},
|
||||
}
|
||||
}
|
||||
// Direction order is the same as Direction::ALL; all values are evaluated at compile time.
|
||||
const DIRECTIONS: [(Direction, [i32; 3], u8); 6] = [
|
||||
direction_info(Direction::Down),
|
||||
direction_info(Direction::Up),
|
||||
direction_info(Direction::North),
|
||||
direction_info(Direction::South),
|
||||
direction_info(Direction::West),
|
||||
direction_info(Direction::East),
|
||||
];
|
||||
const fn direction_info(d: Direction) -> (Direction, [i32; 3], u8) {
|
||||
(d, d.normal(), d.opposite() as u8)
|
||||
}
|
||||
/// Per-worker temporary arrays. They never hold resource-pack references or
|
||||
/// geometry still owned by the renderer, and total about 150 KiB per worker
|
||||
/// (the leaf mask adds 8,000 cells, depths and a queue).
|
||||
@@ -1080,21 +1094,63 @@ pub struct MeshScratch {
|
||||
/// Fills `rows` with the padded section's `isSolidRender` blocks, one bit each (`MeshScratch::solid_rows`).
|
||||
fn solid_rows(pad: &Padded, solid: &[bool], rows: &mut Vec<u32>) {
|
||||
rows.clear();
|
||||
rows.extend(pad.blocks.as_chunks::<P>().0.iter().map(|row| {
|
||||
row.iter().enumerate().fold(0u32, |bits, (x, &id)| {
|
||||
bits | u32::from(solid[id as usize]) << x
|
||||
})
|
||||
}));
|
||||
for row in pad.blocks.as_chunks::<P>().0 {
|
||||
let mut bits = 0;
|
||||
for (x, &id) in row.iter().enumerate() {
|
||||
bits |= u32::from(solid[id as usize]) << x;
|
||||
}
|
||||
rows.push(bits);
|
||||
}
|
||||
}
|
||||
/// Whether the block at `(x, y, z)` (section-local) and its six neighbours
|
||||
/// are all `isSolidRender`: none of its faces can show.
|
||||
#[inline]
|
||||
fn hidden(rows: &[u32], x: i32, y: i32, z: i32) -> bool {
|
||||
let row = |y: i32, z: i32| rows[((y + B) as usize) * P + (z + B) as usize];
|
||||
let r = row(y, z);
|
||||
let around =
|
||||
r & r << 1 & r >> 1 & row(y - 1, z) & row(y + 1, z) & row(y, z - 1) & row(y, z + 1);
|
||||
around >> (x + B) & 1 != 0
|
||||
/// Open and fully surrounded cells in each inner row.
|
||||
fn row_masks(rows: &[u32]) -> ([u16; 256], [u16; 256]) {
|
||||
let mut open = [0; 256];
|
||||
let mut hidden = [0; 256];
|
||||
for y in 0..16 {
|
||||
for z in 0..16 {
|
||||
let i = (y + B as usize) * P + z + B as usize;
|
||||
let r = rows[i];
|
||||
let around =
|
||||
r & r << 1 & r >> 1 & rows[i - P] & rows[i + P] & rows[i - 1] & rows[i + 1];
|
||||
open[y * 16 + z] = !(r >> B) as u16;
|
||||
hidden[y * 16 + z] = (around >> B) as u16;
|
||||
}
|
||||
}
|
||||
(open, hidden)
|
||||
}
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn row_masks_match_cell_definition() {
|
||||
let mut seed = 42u32;
|
||||
for _ in 0..100 {
|
||||
let rows: Vec<u32> = (0..P * P)
|
||||
.map(|_| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
seed & ((1 << P) - 1)
|
||||
})
|
||||
.collect();
|
||||
let (open, hidden) = row_masks(&rows);
|
||||
for y in 0..16 {
|
||||
for z in 0..16 {
|
||||
let cell = |x: usize, y: usize, z: usize| (rows[y * P + z] >> x) & 1 != 0;
|
||||
for x in 0..16 {
|
||||
let (x, py, pz) = (x + B as usize, y + B as usize, z + B as usize);
|
||||
let occupied = cell(x, py, pz);
|
||||
let enclosed = occupied
|
||||
&& cell(x - 1, py, pz)
|
||||
&& cell(x + 1, py, pz)
|
||||
&& cell(x, py - 1, pz)
|
||||
&& cell(x, py + 1, pz)
|
||||
&& cell(x, py, pz - 1)
|
||||
&& cell(x, py, pz + 1);
|
||||
assert_eq!((open[y * 16 + z] >> (x - B as usize)) & 1 != 0, !occupied);
|
||||
assert_eq!((hidden[y * 16 + z] >> (x - B as usize)) & 1 != 0, enclosed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod leaf_culling_tests;
|
||||
@@ -1463,7 +1519,8 @@ impl Mesher<'_> {
|
||||
let pad = &scratch.padded;
|
||||
solid_rows(pad, &self.tables.solid, &mut scratch.solid_rows);
|
||||
let opaque = &scratch.solid_rows;
|
||||
out.visibility = visibility_rows(&open_rows(opaque));
|
||||
let (open, hidden) = row_masks(opaque);
|
||||
out.visibility = visibility_rows(&open);
|
||||
let base_y = center.min_y() + sy * 16;
|
||||
if self.leaf_culling.is_interior() {
|
||||
scratch
|
||||
@@ -1497,7 +1554,7 @@ impl Mesher<'_> {
|
||||
out.lights
|
||||
.push(([center.x * 16 + x, base_y + y, center.z * 16 + z], id));
|
||||
}
|
||||
if hidden(opaque, x, y, z) {
|
||||
if hidden[(y * 16 + z) as usize] >> x & 1 != 0 {
|
||||
continue;
|
||||
}
|
||||
if state.fluid.kind() != FluidKind::Empty {
|
||||
@@ -1538,12 +1595,11 @@ impl Mesher<'_> {
|
||||
let interior_leaf = state.leaves
|
||||
&& self.leaf_culling.is_interior()
|
||||
&& scratch.leaf.interior(self.leaf_culling, x, y, z);
|
||||
for d in Direction::ALL {
|
||||
let [dx, dy, dz] = d.normal();
|
||||
for (d, [dx, dy, dz], opposite) in DIRECTIONS {
|
||||
let neighbor = pad.blocks[pi(x + dx, y + dy, z + dz)];
|
||||
// `should_render_face`'s first test, without its lookups: a
|
||||
// full occlusion face toward this block hides the face.
|
||||
if full_faces[neighbor as usize] >> d.opposite() as u8 & 1 != 0 {
|
||||
if full_faces[neighbor as usize] >> opposite & 1 != 0 {
|
||||
continue;
|
||||
}
|
||||
let other = &info[neighbor as usize];
|
||||
|
||||
@@ -229,7 +229,7 @@ pub(super) fn tesselate(
|
||||
let kind = me.fluid.kind();
|
||||
let same = |s: &Info| s.fluid.kind().same_family(kind);
|
||||
let neighbor = |d: Direction| {
|
||||
let [dx, dy, dz] = d.normal();
|
||||
let [dx, dy, dz] = super::DIRECTIONS[d as usize].1;
|
||||
at([x + dx, y + dy, z + dz])
|
||||
};
|
||||
let (down, up) = (neighbor(Direction::Down), neighbor(Direction::Up));
|
||||
@@ -238,12 +238,11 @@ pub(super) fn tesselate(
|
||||
let render_down =
|
||||
should_render(Direction::Down) && !occluded_by(Direction::Down, MAX_HEIGHT, down);
|
||||
let sides = [
|
||||
Direction::North,
|
||||
Direction::South,
|
||||
Direction::West,
|
||||
Direction::East,
|
||||
]
|
||||
.map(should_render);
|
||||
should_render(Direction::North),
|
||||
should_render(Direction::South),
|
||||
should_render(Direction::West),
|
||||
should_render(Direction::East),
|
||||
];
|
||||
if !render_up && !render_down && !sides.iter().any(|&s| s) {
|
||||
return;
|
||||
}
|
||||
@@ -356,9 +355,9 @@ pub(super) fn tesselate(
|
||||
fluid_face(color, face)
|
||||
};
|
||||
let vertex = |(pos, uv)| TerrainVertex::new(pos, uv, layer, color, light);
|
||||
target.extend(v.map(vertex));
|
||||
target.extend([vertex(v[0]), vertex(v[1]), vertex(v[2]), vertex(v[3])]);
|
||||
if back {
|
||||
target.extend([v[0], v[3], v[2], v[1]].map(vertex));
|
||||
target.extend([vertex(v[0]), vertex(v[3]), vertex(v[2]), vertex(v[1])]);
|
||||
}
|
||||
};
|
||||
let (fx, fy, fz) = (x as f32, y as f32, z as f32);
|
||||
|
||||
@@ -23,6 +23,7 @@ use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fs,
|
||||
path::Path,
|
||||
sync::OnceLock,
|
||||
};
|
||||
|
||||
pub struct BakedFace {
|
||||
@@ -51,8 +52,11 @@ type VariantKey = (String, [u8; 3], bool);
|
||||
pub struct StateModels {
|
||||
/// Each definition's choices, a variant standing as its index in `baked`.
|
||||
choices: Vec<CompiledChoices<u32>>,
|
||||
/// All baked alternatives per definition, invalidated before mutable model access.
|
||||
bounds: OnceLock<Vec<Option<[[f32; 3]; 2]>>>,
|
||||
/// By state id: its definition in `choices` and the models it selects.
|
||||
states: Vec<Option<(usize, SelectedModels)>>,
|
||||
single_models: Vec<Option<u32>>,
|
||||
/// Every transformed model once, with the variant it was baked from.
|
||||
baked: Vec<(Variant, BakedModel)>,
|
||||
/// Standalone models by id: their index in `baked`.
|
||||
@@ -354,10 +358,19 @@ impl StateModels {
|
||||
baked.push((variant, model));
|
||||
}
|
||||
// Every variant of every definition was baked above.
|
||||
let choices = choices.iter().map(|c| c.map(|v| index[&key(v)])).collect();
|
||||
let choices: Vec<_> = choices.iter().map(|c| c.map(|v| index[&key(v)])).collect();
|
||||
let single_models = states
|
||||
.iter()
|
||||
.map(|state| {
|
||||
let (choice, selected) = state.as_ref()?;
|
||||
choices[*choice].single_variant(selected).copied()
|
||||
})
|
||||
.collect();
|
||||
Ok(Self {
|
||||
choices,
|
||||
bounds: OnceLock::new(),
|
||||
states,
|
||||
single_models,
|
||||
baked,
|
||||
standalone: BTreeMap::new(),
|
||||
})
|
||||
@@ -408,6 +421,7 @@ impl StateModels {
|
||||
}
|
||||
/// Every baked model with the variant it was baked from, to change its faces.
|
||||
pub fn models_mut(&mut self) -> impl Iterator<Item = (&Variant, &mut BakedModel)> {
|
||||
self.bounds.take();
|
||||
self.baked
|
||||
.iter_mut()
|
||||
.map(|(variant, model)| (&*variant, model))
|
||||
@@ -432,6 +446,37 @@ impl StateModels {
|
||||
let model = self.choices[*choice].particle_variant(selected)?;
|
||||
self.baked[*model as usize].1.particle
|
||||
}
|
||||
/// Envelope of every weighted/multipart alternative in the state's definition.
|
||||
/// Includes transformed resource-pack faces outside the unit cube. Empty or
|
||||
/// non-finite geometry has no usable envelope and keeps the original draw path.
|
||||
pub fn render_bounds(&self, state: BlockStateId) -> Option<[[f32; 3]; 2]> {
|
||||
let (definition, _) = self.state(state)?;
|
||||
let bounds = self.bounds.get_or_init(|| {
|
||||
self.choices
|
||||
.iter()
|
||||
.map(|choices| {
|
||||
let mut lo = glam::Vec3::splat(f32::INFINITY);
|
||||
let mut hi = glam::Vec3::splat(f32::NEG_INFINITY);
|
||||
let mut valid = true;
|
||||
// `map` visits every choice, without consuming the render RNG.
|
||||
choices.map(|&index| {
|
||||
for face in &self.baked[index as usize].1.faces {
|
||||
for p in face.quad.positions {
|
||||
let p = glam::Vec3::from_array(p);
|
||||
valid &= p.is_finite();
|
||||
lo = lo.min(p);
|
||||
hi = hi.max(p);
|
||||
}
|
||||
}
|
||||
});
|
||||
(valid && lo.is_finite() && hi.is_finite())
|
||||
.then_some([lo.to_array(), hi.to_array()])
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
bounds.get(*definition).copied().flatten()
|
||||
}
|
||||
|
||||
pub fn collect_seeded(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
@@ -452,6 +497,10 @@ impl StateModels {
|
||||
out: &mut Vec<&'a BakedModel>,
|
||||
) -> Result<()> {
|
||||
out.clear();
|
||||
if let Some(Some(model)) = self.single_models.get(state.0 as usize) {
|
||||
out.push(&self.baked[*model as usize].1);
|
||||
return Ok(());
|
||||
}
|
||||
self.each_seeded(state, position_seed(position), |(_, model)| out.push(model))
|
||||
}
|
||||
fn each_seeded<'a>(
|
||||
@@ -619,3 +668,7 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "catalog/bounds_tests.rs"]
|
||||
mod bounds_tests;
|
||||
@@ -0,0 +1,115 @@
|
||||
use super::*;
|
||||
|
||||
fn catalog(definition: &str) -> StateModels {
|
||||
let definition = BlockStateDefinition::from_json(definition).unwrap();
|
||||
let choices = CompiledChoices::new(&definition)
|
||||
.unwrap()
|
||||
.map(|v| if v.model == "horizon:a" { 0 } else { 1 });
|
||||
let baked = [
|
||||
([-16, 0, 0], [16, 16, 16], [0, 0, 0]),
|
||||
([0, -8, -16], [32, 32, 16], [0, 90, 0]),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (from, to, turns))| {
|
||||
let faces: serde_json::Map<String, Value> =
|
||||
["up", "down", "north", "south", "east", "west"]
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
(
|
||||
name.to_owned(),
|
||||
serde_json::json!({"texture":"#all"}),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let json =
|
||||
serde_json::json!({"textures":{"all":"horizon:block/test"},"elements":[{"from":from,"to":to,"faces":faces}]}).to_string();
|
||||
let model = horizon_assets::model::CuboidModel::from_json(&json).unwrap();
|
||||
let name = format!("horizon:{}", if i == 0 { "a" } else { "b" });
|
||||
let library =
|
||||
horizon_assets::resolve::ModelLibrary::new(BTreeMap::from([(name.clone(), model)]))
|
||||
.unwrap();
|
||||
let baked = bake_resolved(
|
||||
Path::new("/nonexistent-horizon-bounds-fixture"),
|
||||
&mut Sprites::default(),
|
||||
&library.resolve(&name).unwrap(),
|
||||
turns,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
(
|
||||
Variant {
|
||||
model: name,
|
||||
rotations: turns,
|
||||
uv_lock: false,
|
||||
},
|
||||
baked,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
StateModels {
|
||||
choices: vec![choices],
|
||||
states: vec![Some((0, SelectedModels::Variant(0)))],
|
||||
baked,
|
||||
..StateModels::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelopes_cover_weighted_and_multipart_geometry_and_mutation_invalidates_them() {
|
||||
for (definition, selected) in [
|
||||
(
|
||||
r#"{"variants":{"": [{"model":"horizon:a","weight":1},{"model":"horizon:b","weight":3}]}}"#,
|
||||
SelectedModels::Variant(0),
|
||||
),
|
||||
(
|
||||
r#"{"multipart":[{"apply":{"model":"horizon:a"}},{"apply":{"model":"horizon:b"}}]}"#,
|
||||
SelectedModels::Multipart(vec![0, 1]),
|
||||
),
|
||||
] {
|
||||
let mut models = catalog(definition);
|
||||
models.states[0] = Some((0, selected));
|
||||
let [lo, hi] = models.render_bounds(BlockStateId(0)).unwrap();
|
||||
assert!(
|
||||
lo[0] <= -1.0 && hi[1] >= 2.0,
|
||||
"pack geometry exceeds a unit cube"
|
||||
);
|
||||
let mut variants = BTreeSet::new();
|
||||
for i in 0..128 {
|
||||
let seed = position_seed(BlockPos::new(i * 13, i % 31, -i * 23));
|
||||
for (variant, model) in models.collect_seeded(BlockStateId(0), seed).unwrap() {
|
||||
variants.insert(variant.model.clone());
|
||||
for point in model.faces.iter().flat_map(|f| f.quad.positions) {
|
||||
assert!((0..3).all(|axis| point[axis] >= lo[axis] && point[axis] <= hi[axis]));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(variants.len(), 2);
|
||||
models.models_mut().next().unwrap().1.faces[0]
|
||||
.quad
|
||||
.positions[0] = [100.0, 2.0, -5.0];
|
||||
let [lo, hi] = models.render_bounds(BlockStateId(0)).unwrap();
|
||||
assert_eq!(hi[0], 100.0);
|
||||
assert_eq!(lo[2], -5.0);
|
||||
// A resource replacement owns a fresh catalog rather than the previous cache.
|
||||
let replacement = catalog(r#"{"variants":{"":{"model":"horizon:a"}}}"#);
|
||||
assert_eq!(
|
||||
replacement.render_bounds(BlockStateId(0)),
|
||||
Some([[-1.0, 0.0, 0.0], [1.0, 1.0, 1.0]])
|
||||
);
|
||||
models.models_mut().next().unwrap().1.faces[0]
|
||||
.quad
|
||||
.positions[0][0] = f32::NAN;
|
||||
assert!(models.render_bounds(BlockStateId(0)).is_none());
|
||||
}
|
||||
assert!(
|
||||
StateModels::default()
|
||||
.render_bounds(BlockStateId(0))
|
||||
.is_none()
|
||||
);
|
||||
let mut empty = catalog(r#"{"variants":{"":{"model":"horizon:a"}}}"#);
|
||||
for (_, model) in empty.models_mut() {
|
||||
model.faces.clear();
|
||||
}
|
||||
assert!(empty.render_bounds(BlockStateId(0)).is_none());
|
||||
}
|
||||
@@ -338,11 +338,21 @@ impl Settings {
|
||||
.map_or(-1, |i| i as i32)
|
||||
}
|
||||
pub fn load(path: &Path) -> Self {
|
||||
let horizon = path
|
||||
let mut horizon = path
|
||||
.parent()
|
||||
.map(crate::graphics::Graphics::load)
|
||||
.unwrap_or_default();
|
||||
crate::mipmap::set_dither_cutouts(horizon.dither_cutout_mips);
|
||||
if let Some(dir) = path.parent() {
|
||||
let (key, _) = crate::shader_pack_list::resolve(
|
||||
&crate::logging::shared_dir(dir).join(crate::shader_pack_list::DIR),
|
||||
&horizon.shader_pack,
|
||||
);
|
||||
if key != horizon.shader_pack {
|
||||
horizon.shader_pack = key;
|
||||
horizon.save(dir);
|
||||
}
|
||||
}
|
||||
let controller = path
|
||||
.parent()
|
||||
.map(crate::gamepad::PadSettings::load)
|
||||
|
||||
@@ -18,8 +18,10 @@ pub enum Kind {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Entry {
|
||||
/// The folder's or file's name in `shaderpacks`: what the pick saves.
|
||||
/// The folder or file name in `shaderpacks`.
|
||||
pub file: String,
|
||||
/// Native manifest identity (`horizon:<id>`), or a classic filename.
|
||||
pub key: String,
|
||||
/// The pack's own name for Horizon's packs, else `file`.
|
||||
pub title: String,
|
||||
pub kind: Kind,
|
||||
@@ -33,7 +35,7 @@ pub struct Entry {
|
||||
impl Entry {
|
||||
/// Saves the player's option values, for the pack in the folder `dir`.
|
||||
pub fn save_values(&self, dir: &Path) {
|
||||
let path = dir.join(&self.file);
|
||||
let path = values_pack(&dir.join(&self.file), &self.key);
|
||||
if let Err(e) = save_values(&path, &self.options, &self.values) {
|
||||
log::warn!("shader pack options of {}: {e}", self.file);
|
||||
}
|
||||
@@ -106,7 +108,22 @@ pub fn scan(dir: &Path) -> Vec<Entry> {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut packs: Vec<Entry> = entries.flatten().filter_map(|e| entry(&e.path())).collect();
|
||||
let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
|
||||
paths.sort();
|
||||
let mut keys = std::collections::HashSet::new();
|
||||
let mut packs: Vec<Entry> = paths
|
||||
.iter()
|
||||
.filter_map(|p| entry(p))
|
||||
.filter(|e| keys.insert(e.key.clone()))
|
||||
.collect();
|
||||
for pack in &mut packs {
|
||||
if let Some(id) = pack.key.strip_prefix("horizon:") {
|
||||
pack.values = load_values(
|
||||
&native_values_pack(&dir.join(&pack.file), id),
|
||||
&pack.options,
|
||||
);
|
||||
}
|
||||
}
|
||||
packs.sort_by(|a, b| {
|
||||
(a.kind == Kind::Classic, a.title.to_lowercase())
|
||||
.cmp(&(b.kind == Kind::Classic, b.title.to_lowercase()))
|
||||
@@ -122,26 +139,18 @@ fn entry(path: &Path) -> Option<Entry> {
|
||||
if horizon_shader_api::package::is_pack(path) {
|
||||
// Its manifest's name; a pack that can't be read is still listed, under its file
|
||||
// name: picking it says why in the chat.
|
||||
let manifest = if path.is_dir() {
|
||||
std::fs::read_to_string(path.join(horizon_shader_api::MANIFEST))
|
||||
.ok()
|
||||
.and_then(|t| horizon_shader_api::Manifest::parse(&t).ok())
|
||||
} else {
|
||||
horizon_shader_api::Pack::read(path)
|
||||
.ok()
|
||||
.map(|p| p.manifest)
|
||||
};
|
||||
let (title, options, pages) = manifest.map_or_else(
|
||||
|| (file.clone(), Vec::new(), Vec::new()),
|
||||
|m| (m.name, m.options, m.pages),
|
||||
let manifest = horizon_shader_api::Pack::read_manifest(path).ok();
|
||||
let (key, title, options, pages) = manifest.map_or_else(
|
||||
|| (file.clone(), file.clone(), Vec::new(), Vec::new()),
|
||||
|m| (format!("horizon:{}", m.id), m.name, m.options, m.pages),
|
||||
);
|
||||
let values = load_values(path, &options);
|
||||
return Some(Entry {
|
||||
key,
|
||||
file,
|
||||
title,
|
||||
kind: Kind::Horizon,
|
||||
options,
|
||||
values,
|
||||
values: Vec::new(),
|
||||
pages,
|
||||
});
|
||||
}
|
||||
@@ -151,6 +160,7 @@ fn entry(path: &Path) -> Option<Entry> {
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("zip"));
|
||||
(zip || path.join("shaders").is_dir()).then(|| Entry {
|
||||
title: file.clone(),
|
||||
key: file.clone(),
|
||||
file,
|
||||
kind: Kind::Classic,
|
||||
options: Vec::new(),
|
||||
@@ -159,6 +169,104 @@ fn entry(path: &Path) -> Option<Entry> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolves a saved identity, migrating a legacy native filename when its manifest or the
|
||||
/// launcher's `<id>-<version>.hshader` name identifies it. Duplicate IDs use the first filename
|
||||
/// in lexical order, matching `scan`; versions are free-form strings, not ordered versions.
|
||||
/// An absent arbitrary old filename cannot be attributed to a manifest safely.
|
||||
pub fn resolve(dir: &Path, picked: &str) -> (String, PathBuf) {
|
||||
if picked.is_empty() {
|
||||
return (String::new(), dir.join(picked));
|
||||
}
|
||||
let direct = dir.join(picked);
|
||||
if direct.exists() && !horizon_shader_api::package::is_pack(&direct) {
|
||||
return (picked.to_owned(), direct);
|
||||
}
|
||||
let mut native: Vec<_> = std::fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let path = e.path();
|
||||
let file = path.file_name()?.to_str()?.to_owned();
|
||||
if file.starts_with('.') || !horizon_shader_api::package::is_pack(&path) {
|
||||
return None;
|
||||
}
|
||||
Some((file, horizon_shader_api::Pack::read_manifest(&path).ok()?))
|
||||
})
|
||||
.collect();
|
||||
native.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let id = native
|
||||
.iter()
|
||||
.find(|(file, _)| file == picked)
|
||||
.map(|(_, m)| m.id.as_str())
|
||||
.or_else(|| picked.strip_prefix("horizon:"))
|
||||
.or_else(|| {
|
||||
// Never reinterpret an installed classic pack as a native identity.
|
||||
if dir.join(picked).exists() {
|
||||
return None;
|
||||
}
|
||||
let stem = picked.strip_suffix(".hshader")?;
|
||||
native
|
||||
.iter()
|
||||
.find(|(_, m)| {
|
||||
stem.strip_prefix(&m.id)
|
||||
.is_some_and(|v| v.starts_with('-') && v.len() > 1)
|
||||
})
|
||||
.map(|(_, m)| m.id.as_str())
|
||||
});
|
||||
if let Some((file, m)) = native.iter().find(|(_, m)| Some(m.id.as_str()) == id) {
|
||||
let key = format!("horizon:{}", m.id);
|
||||
if !picked.starts_with("horizon:") {
|
||||
migrate_values(&dir.join(picked), &key);
|
||||
}
|
||||
migrate_values(&dir.join(file), &key);
|
||||
return (key, dir.join(file));
|
||||
}
|
||||
(picked.to_owned(), dir.join(picked))
|
||||
}
|
||||
|
||||
fn values_pack(pack: &Path, key: &str) -> PathBuf {
|
||||
key.strip_prefix("horizon:").map_or_else(
|
||||
|| pack.to_path_buf(),
|
||||
|id| {
|
||||
pack.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.join(".horizon-options")
|
||||
.join(id)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// The native options path; imports a filename sidecar once, keeping the original intact.
|
||||
pub fn native_values_pack(pack: &Path, id: &str) -> PathBuf {
|
||||
let key = format!("horizon:{id}");
|
||||
migrate_values(pack, &key);
|
||||
values_pack(pack, &key)
|
||||
}
|
||||
|
||||
fn migrate_values(pack: &Path, key: &str) {
|
||||
let target = values_path(&values_pack(pack, key));
|
||||
if target.exists() {
|
||||
return;
|
||||
}
|
||||
if let Ok(text) = std::fs::read(values_path(pack)) {
|
||||
let result = std::fs::create_dir_all(target.parent().unwrap()).and_then(|()| {
|
||||
use std::io::Write;
|
||||
// Another reader may already have imported the values.
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&target)?;
|
||||
file.write_all(&text)
|
||||
});
|
||||
if let Err(e) = result
|
||||
&& e.kind() != std::io::ErrorKind::AlreadyExists
|
||||
{
|
||||
log::warn!("shader pack options migration: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the option values of the Horizon pack at `pack` are kept: `<pack>.txt` beside it,
|
||||
/// one `id=value` a line.
|
||||
pub fn values_path(pack: &Path) -> PathBuf {
|
||||
@@ -186,8 +294,7 @@ pub fn load_values(pack: &Path, options: &[PackOption]) -> Vec<f64> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Saves `values` of `options`; values at their defaults are left out, and with none left
|
||||
/// the file goes.
|
||||
/// Saves non-default values. An empty file records a reset, so old sidecars stay retired.
|
||||
fn save_values(pack: &Path, options: &[PackOption], values: &[f64]) -> std::io::Result<()> {
|
||||
let text: String = options
|
||||
.iter()
|
||||
@@ -196,14 +303,10 @@ fn save_values(pack: &Path, options: &[PackOption], values: &[f64]) -> std::io::
|
||||
.map(|(o, v)| format!("{}={v}\n", o.id))
|
||||
.collect();
|
||||
let path = values_path(pack);
|
||||
if text.is_empty() {
|
||||
match std::fs::remove_file(&path) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
|
||||
_ => Ok(()),
|
||||
}
|
||||
} else {
|
||||
std::fs::write(path, text)
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
std::fs::write(path, text)
|
||||
}
|
||||
|
||||
fn clamp(kind: &OptionKind, v: f64) -> f64 {
|
||||
@@ -315,6 +418,102 @@ pub fn show_value(kind: &OptionKind, v: f64) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn install(dir: &Path, file: &str, version: &str) {
|
||||
let manifest = format!(
|
||||
"schema = 1\nid = \"warm\"\nname = \"Warm\"\nversion = \"{version}\"\napi = \"0.1\"\n\
|
||||
[[post]]\nfile = \"p.wgsl\"\n[[options]]\nid = \"strength\"\nname = \"Strength\"\ntype = \"float\"\ndefault = 1.0\nmin = 0.0\nmax = 2.0\nstep = 0.5\n"
|
||||
);
|
||||
let files = [
|
||||
(horizon_shader_api::MANIFEST.into(), manifest.into_bytes()),
|
||||
("p.wgsl".into(), b"original test fixture".to_vec()),
|
||||
]
|
||||
.into();
|
||||
let pack = horizon_shader_api::Pack::from_files(files).unwrap();
|
||||
std::fs::write(dir.join(file), pack.package().unwrap()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_selection_and_values_survive_install_update_rename_and_reset() {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-identity-update-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
install(&dir, "warm-1.hshader", "1");
|
||||
std::fs::write(dir.join("warm-1.hshader.txt"), "strength=1.5\n").unwrap();
|
||||
let options = dir.join("options.txt");
|
||||
std::fs::write(&options, "").unwrap();
|
||||
std::fs::write(
|
||||
dir.join(crate::graphics::FILE),
|
||||
"pickedShaderPack:warm-1.hshader\n",
|
||||
)
|
||||
.unwrap();
|
||||
// Use the real settings loader, including persistence of the migrated selection.
|
||||
// Its shaderpacks folder sits beside options.txt.
|
||||
let packs = dir.join(DIR);
|
||||
std::fs::create_dir_all(&packs).unwrap();
|
||||
std::fs::rename(dir.join("warm-1.hshader"), packs.join("warm-1.hshader")).unwrap();
|
||||
std::fs::rename(
|
||||
dir.join("warm-1.hshader.txt"),
|
||||
packs.join("warm-1.hshader.txt"),
|
||||
)
|
||||
.unwrap();
|
||||
let settings = crate::settings::Settings::load(&options);
|
||||
assert_eq!(settings.horizon.shader_pack, "horizon:warm");
|
||||
assert_eq!(
|
||||
crate::graphics::Graphics::load(&dir).shader_pack,
|
||||
"horizon:warm"
|
||||
);
|
||||
assert_eq!(scan(&packs)[0].values, [1.5]);
|
||||
install(&packs, "warm-2.hshader", "2");
|
||||
std::fs::remove_file(packs.join("warm-1.hshader")).unwrap();
|
||||
let (_, path) = resolve(&packs, &settings.horizon.shader_pack);
|
||||
assert_eq!(path, packs.join("warm-2.hshader"));
|
||||
std::fs::rename(path, packs.join("renamed.hshader")).unwrap();
|
||||
let (_, path) = resolve(&packs, "horizon:warm");
|
||||
assert_eq!(path, packs.join("renamed.hshader"));
|
||||
let manifest = horizon_shader_api::Pack::read_manifest(&path).unwrap();
|
||||
assert_eq!(
|
||||
load_values(&native_values_pack(&path, &manifest.id), &manifest.options),
|
||||
[1.5]
|
||||
);
|
||||
let mut entry = scan(&packs).remove(0);
|
||||
entry.values[0] = 0.5;
|
||||
entry.save_values(&packs);
|
||||
assert_eq!(
|
||||
load_values(&native_values_pack(&path, &manifest.id), &manifest.options),
|
||||
[0.5]
|
||||
);
|
||||
entry.reset_options();
|
||||
entry.save_values(&packs);
|
||||
assert_eq!(scan(&packs)[0].values, [1.0]);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(packs.join("warm-1.hshader.txt")).unwrap(),
|
||||
"strength=1.5\n"
|
||||
);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrates_a_removed_registry_filename_and_resolves_duplicates_deterministically() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"horizon-identity-duplicates-{}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
install(&dir, "z.hshader", "newest");
|
||||
install(&dir, "a.hshader", "oldest");
|
||||
std::fs::write(dir.join("warm-previous.hshader.txt"), "strength=2\n").unwrap();
|
||||
let (key, path) = resolve(&dir, "warm-previous.hshader");
|
||||
assert_eq!(key, "horizon:warm");
|
||||
assert_eq!(path, dir.join("a.hshader"));
|
||||
assert_eq!(resolve(&dir, "z.hshader"), (key.clone(), path));
|
||||
assert_eq!(scan(&dir).len(), 1);
|
||||
assert_eq!(scan(&dir)[0].values, [2.0]);
|
||||
// A classic folder named like a native ID keeps its filename identity.
|
||||
std::fs::create_dir_all(dir.join("warm/shaders")).unwrap();
|
||||
assert_eq!(resolve(&dir, "warm"), ("warm".into(), dir.join("warm")));
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_both_kinds_horizon_first_and_skips_the_rest() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-shader-list-{}", std::process::id()));
|
||||
@@ -397,7 +596,7 @@ mod tests {
|
||||
std::fs::write(values_path(&pack), "a=9\nb=1\nzzz=4\n").unwrap();
|
||||
assert_eq!(load_values(&pack, &options), [2.0, 1.0]);
|
||||
save_values(&pack, &options, &[1.0, 0.0]).unwrap();
|
||||
assert!(!values_path(&pack).exists());
|
||||
assert_eq!(std::fs::read_to_string(values_path(&pack)).unwrap(), "");
|
||||
assert_eq!(load_values(&pack, &options), [1.0, 0.0]);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
@@ -417,6 +616,7 @@ mod tests {
|
||||
};
|
||||
let mut entry = Entry {
|
||||
file: "p".into(),
|
||||
key: "horizon:p".into(),
|
||||
title: "P".into(),
|
||||
kind: Kind::Horizon,
|
||||
options: vec![
|
||||
|
||||
@@ -64,7 +64,7 @@ fn picked(s: &Settings) -> Option<(usize, Kind)> {
|
||||
let i = s
|
||||
.shader_packs
|
||||
.iter()
|
||||
.position(|p| !s.horizon.shader_pack.is_empty() && p.file == s.horizon.shader_pack)?;
|
||||
.position(|p| !s.horizon.shader_pack.is_empty() && p.key == s.horizon.shader_pack)?;
|
||||
Some((i, s.shader_packs[i].kind))
|
||||
}
|
||||
|
||||
@@ -360,7 +360,7 @@ impl ShaderUi {
|
||||
Item::Pack(i) => Some(
|
||||
s.shader_packs
|
||||
.get(usize::from(i))
|
||||
.is_some_and(|p| p.file == s.horizon.shader_pack),
|
||||
.is_some_and(|p| p.key == s.horizon.shader_pack),
|
||||
),
|
||||
Item::Option(i) => match picked(s)? {
|
||||
(p, Kind::Horizon) => {
|
||||
@@ -406,7 +406,7 @@ impl ShaderUi {
|
||||
Kind::Horizon => "A Horizon shader pack.",
|
||||
Kind::Classic => "An OptiFine or Iris shader pack.",
|
||||
});
|
||||
let what = lang::get(if pack.file == s.horizon.shader_pack {
|
||||
let what = lang::get(if pack.key == s.horizon.shader_pack {
|
||||
"In use: press to turn shaders off."
|
||||
} else {
|
||||
"Press to use it."
|
||||
@@ -588,8 +588,14 @@ impl ShaderUi {
|
||||
// The picked pack's own screens go under its name, apart from Horizon's pack list.
|
||||
if side.len() > 1 {
|
||||
let [x, y, w, _] = cols.page(1);
|
||||
let pack = &s.horizon.shader_pack;
|
||||
let pack = pack.strip_suffix(".zip").unwrap_or(pack);
|
||||
let pack = match picked(s) {
|
||||
Some((i, Kind::Horizon)) => s.shader_packs[i].title.as_str(),
|
||||
_ => s
|
||||
.horizon
|
||||
.shader_pack
|
||||
.strip_suffix(".zip")
|
||||
.unwrap_or(&s.horizon.shader_pack),
|
||||
};
|
||||
let caption = lang::tr("%s settings", &[&pack]);
|
||||
let rect = [x + 2.0, y + 6.0, w - 4.0, 8.0];
|
||||
crate::widgets::record(&caption, rect, false);
|
||||
@@ -854,10 +860,10 @@ impl ShaderUi {
|
||||
let Some(p) = s.shader_packs.get(usize::from(i)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
s.horizon.shader_pack = if s.horizon.shader_pack == p.file {
|
||||
s.horizon.shader_pack = if s.horizon.shader_pack == p.key {
|
||||
String::new()
|
||||
} else {
|
||||
p.file.clone()
|
||||
p.key.clone()
|
||||
};
|
||||
vec![Action::ButtonClick, Action::Picked]
|
||||
}
|
||||
@@ -1107,6 +1113,7 @@ mod tests {
|
||||
fn entry(file: &str, kind: Kind) -> Entry {
|
||||
Entry {
|
||||
file: file.into(),
|
||||
key: file.into(),
|
||||
title: file.into(),
|
||||
kind,
|
||||
options: Vec::new(),
|
||||
@@ -1174,14 +1181,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn picking_a_pack_and_picking_it_again_turns_shaders_off() {
|
||||
let mut s = settings();
|
||||
let mut ui = ShaderUi::default();
|
||||
let actions = ui.activate(&mut s, Item::Pack(1), false, None);
|
||||
assert_eq!(actions, [Action::ButtonClick, Action::Picked]);
|
||||
assert_eq!(s.horizon.shader_pack, "B.zip");
|
||||
ui.activate(&mut s, Item::Pack(1), false, None);
|
||||
assert_eq!(s.horizon.shader_pack, "");
|
||||
assert_eq!(ShaderUi::switch(Item::Pack(0), &s), Some(false));
|
||||
for (kind, key) in [(Kind::Classic, "B.zip"), (Kind::Horizon, "horizon:warm")] {
|
||||
let mut s = settings();
|
||||
s.shader_packs[1].kind = kind;
|
||||
s.shader_packs[1].key = key.into();
|
||||
let mut ui = ShaderUi::default();
|
||||
let actions = ui.activate(&mut s, Item::Pack(1), false, None);
|
||||
assert_eq!(actions, [Action::ButtonClick, Action::Picked]);
|
||||
assert_eq!(s.horizon.shader_pack, key);
|
||||
assert_eq!(picked(&s), Some((1, kind)));
|
||||
assert_eq!(ShaderUi::switch(Item::Pack(1), &s), Some(true));
|
||||
ui.activate(&mut s, Item::Pack(1), false, None);
|
||||
assert_eq!(s.horizon.shader_pack, "");
|
||||
assert_eq!(ShaderUi::switch(Item::Pack(0), &s), Some(false));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1251,6 +1264,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_horizon_packs_slider_drag_is_saved_once_when_let_go() {
|
||||
let mut pack = entry("H", Kind::Horizon);
|
||||
pack.key = "horizon:slider_test".into();
|
||||
pack.options = vec![horizon_shader_api::PackOption {
|
||||
id: "strength".into(),
|
||||
name: "Strength".into(),
|
||||
@@ -1267,7 +1281,7 @@ mod tests {
|
||||
shader_packs: vec![pack],
|
||||
..Settings::default()
|
||||
};
|
||||
s.horizon.shader_pack = "H".into();
|
||||
s.horizon.shader_pack = "horizon:slider_test".into();
|
||||
let mut ui = ShaderUi::default();
|
||||
ui.open(Tab::Screen(0));
|
||||
let cols = Columns::of(SIZE.0, SIZE.1);
|
||||
|
||||
@@ -258,19 +258,10 @@ fn root_style(component: &Tag) -> Vec<(&'static str, Tag)> {
|
||||
.filter_map(|key| Some((*key, root?.get(key)?.clone())))
|
||||
.collect()
|
||||
}
|
||||
/// Plain text of a stored text component (string, `{text: ...}` or JSON string).
|
||||
/// `ComponentSerialization.CODEC`: modern NBT strings are literal text, not JSON.
|
||||
fn component_text(tag: &Tag) -> String {
|
||||
match tag {
|
||||
Tag::String(s) => {
|
||||
let s = s.to_string_lossy();
|
||||
match serde_json::from_str::<serde_json::Value>(&s) {
|
||||
Ok(serde_json::Value::String(text)) => text,
|
||||
Ok(v) if v.get("text").is_some() => {
|
||||
v["text"].as_str().unwrap_or_default().to_owned()
|
||||
}
|
||||
_ => s,
|
||||
}
|
||||
}
|
||||
Tag::String(s) => s.to_string_lossy(),
|
||||
// Component.getString: translations and key binds resolved.
|
||||
Tag::Compound(_) | Tag::List(_) => {
|
||||
scoreboard::comp_text(&scoreboard::tag_to_json(tag)).plain()
|
||||
@@ -323,6 +314,35 @@ mod tests {
|
||||
decoded.decode(&tag);
|
||||
assert_eq!(decoded, data);
|
||||
}
|
||||
#[test]
|
||||
fn modern_string_messages_keep_literal_quotes_and_json_text() {
|
||||
let lines = [
|
||||
"plain text",
|
||||
"\"quoted text\"",
|
||||
r#"{"text":"literal object","bold":true}"#,
|
||||
"",
|
||||
];
|
||||
let mut saved = styled_sign();
|
||||
let messages = Tag::List(lines.map(|line| Tag::String(line.into())).to_vec());
|
||||
if let Tag::Compound(front) = saved.get_mut("front_text").unwrap() {
|
||||
front.insert("messages".into(), messages.clone());
|
||||
}
|
||||
let mut data = SignData::default();
|
||||
data.decode(&saved);
|
||||
assert_eq!(data.front.lines, lines.map(str::to_owned));
|
||||
let encoded = Tag::compound(data.encode());
|
||||
assert_eq!(
|
||||
encoded.get("front_text").unwrap().get("messages"),
|
||||
Some(&messages)
|
||||
);
|
||||
let mut copied = SignData::default();
|
||||
copied.decode(&encoded);
|
||||
assert_eq!(copied.front.lines, data.front.lines);
|
||||
assert_eq!(
|
||||
front_messages(&Tag::compound(copied.encode())),
|
||||
front_messages(&encoded)
|
||||
);
|
||||
}
|
||||
/// A sign as a server sends it: a green "Online" and a plain line, the
|
||||
/// way `ComponentSerialization.CODEC` writes them to NBT.
|
||||
fn styled_sign() -> Tag {
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::sound_events::{INTENTIONALLY_EMPTY, Sentinel, Sound, SoundRegistry};
|
||||
use horizon_core::random::LegacyRandom;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, RwLock},
|
||||
sync::{Arc, OnceLock, RwLock},
|
||||
};
|
||||
|
||||
/// `SoundSource` indices (vanilla order).
|
||||
@@ -593,19 +593,25 @@ impl SoundInstance {
|
||||
}
|
||||
/// `AbstractSoundInstance.getVolume` (after `getOrResolve`).
|
||||
pub fn get_volume(&mut self) -> f32 {
|
||||
self.volume_with(self.volume)
|
||||
}
|
||||
fn volume_with(&mut self, volume: f32) -> f32 {
|
||||
let sample = match &self.sound {
|
||||
Some(s) => s.volume.sample(&mut self.random),
|
||||
None => 1.0,
|
||||
};
|
||||
self.volume * sample
|
||||
volume * sample
|
||||
}
|
||||
/// `AbstractSoundInstance.getPitch`.
|
||||
pub fn get_pitch(&mut self) -> f32 {
|
||||
self.pitch_with(self.pitch)
|
||||
}
|
||||
fn pitch_with(&mut self, pitch: f32) -> f32 {
|
||||
let sample = match &self.sound {
|
||||
Some(s) => s.pitch.sample(&mut self.random),
|
||||
None => 1.0,
|
||||
};
|
||||
self.pitch * sample
|
||||
pitch * sample
|
||||
}
|
||||
/// `AbstractTickableSoundInstance.stop`.
|
||||
fn stop(&mut self) {
|
||||
@@ -872,9 +878,36 @@ struct Handle {
|
||||
channel: Option<ChannelId>,
|
||||
streaming: bool,
|
||||
stopped: bool,
|
||||
/// Restore the unedited pitch when a later refresh no longer changes it.
|
||||
pitch_edited: bool,
|
||||
}
|
||||
/// One operation about to apply a sound's parameters on the game thread.
|
||||
/// `starting` is true for initial/delayed/loop-replay starts, false for live
|
||||
/// tickable or category-volume updates. The identifier is the sound event,
|
||||
/// not its selected file; source is the vanilla SoundSource index.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct SoundPlayback<'a> {
|
||||
/// Engine-local handle; not unique across split-screen engines.
|
||||
pub instance: InstanceId,
|
||||
pub identifier: &'a str,
|
||||
pub source: usize,
|
||||
pub position: [f64; 3],
|
||||
pub relative: bool,
|
||||
pub starting: bool,
|
||||
}
|
||||
pub type PlaybackHook = fn(&SoundPlayback<'_>, &mut f32, &mut f32, &mut bool);
|
||||
static PLAYBACK_HOOK: OnceLock<PlaybackHook> = OnceLock::new();
|
||||
|
||||
/// Install native playback dispatch before constructing client audio engines.
|
||||
/// No audio/device thread invokes this callback.
|
||||
pub fn install_playback_hook(hook: PlaybackHook) -> Result<(), PlaybackHook> {
|
||||
PLAYBACK_HOOK.set(hook)
|
||||
}
|
||||
|
||||
pub struct SoundEngine {
|
||||
pub registry: SoundRegistry,
|
||||
playback_hook: Option<PlaybackHook>,
|
||||
/// `loaded`: the library opened.
|
||||
pub loaded: bool,
|
||||
tick_count: i32,
|
||||
@@ -912,6 +945,7 @@ impl SoundEngine {
|
||||
pub fn new(registry: SoundRegistry) -> Self {
|
||||
Self {
|
||||
registry,
|
||||
playback_hook: PLAYBACK_HOOK.get().copied(),
|
||||
loaded: true,
|
||||
tick_count: 0,
|
||||
instances: HashMap::new(),
|
||||
@@ -934,6 +968,42 @@ impl SoundEngine {
|
||||
notifications: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// Isolate a fixture's dispatch from process-wide native installation.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn set_test_playback_hook(&mut self, hook: PlaybackHook) {
|
||||
self.playback_hook = Some(hook);
|
||||
}
|
||||
fn playback_parameters(&self, id: InstanceId, starting: bool) -> Option<(f32, f32)> {
|
||||
let instance = self.instances.get(&id)?;
|
||||
let (mut volume, mut pitch) = (instance.volume, instance.pitch);
|
||||
if let Some(hook) = self.playback_hook {
|
||||
let sound = SoundPlayback {
|
||||
instance: id,
|
||||
identifier: &instance.identifier,
|
||||
source: instance.source,
|
||||
position: instance.position,
|
||||
relative: instance.relative,
|
||||
starting,
|
||||
};
|
||||
let mut cancel = false;
|
||||
hook(&sound, &mut volume, &mut pitch, &mut cancel);
|
||||
let volume_edited = volume.to_bits() != instance.volume.to_bits();
|
||||
let pitch_edited = pitch.to_bits() != instance.pitch.to_bits();
|
||||
if cancel
|
||||
|| (volume_edited && (!volume.is_finite() || volume < 0.0))
|
||||
|| (pitch_edited && (!pitch.is_finite() || pitch <= 0.0))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some((volume, pitch))
|
||||
}
|
||||
fn cancel_playback(&mut self, id: InstanceId, backend: &mut impl Backend) {
|
||||
if let Some(instance) = self.instances.get_mut(&id) {
|
||||
instance.stop();
|
||||
}
|
||||
self.stop(id, backend);
|
||||
}
|
||||
/// Hand an instance to the engine. A retained instance stays until
|
||||
/// `release`; others go once the engine no longer refers to them.
|
||||
pub fn add(&mut self, instance: SoundInstance, retain: bool) -> InstanceId {
|
||||
@@ -1027,10 +1097,27 @@ impl SoundEngine {
|
||||
continue;
|
||||
};
|
||||
if source == instance.source || source == MASTER {
|
||||
let (v, s) = (instance.get_volume(), instance.source);
|
||||
let original_pitch = instance.pitch;
|
||||
let Some((base_volume, base_pitch)) = self.playback_parameters(id, false) else {
|
||||
self.cancel_playback(id, backend);
|
||||
continue;
|
||||
};
|
||||
let instance = self.instances.get_mut(&id).expect("present");
|
||||
let (v, s) = (instance.volume_with(base_volume), instance.source);
|
||||
let pitch_edited = base_pitch.to_bits() != original_pitch.to_bits();
|
||||
// Restore a prior edit, but an always-no-op hook samples exactly as before.
|
||||
let restore_pitch = self.handles.get(&handle).is_some_and(|h| h.pitch_edited);
|
||||
let pitch = (pitch_edited || restore_pitch)
|
||||
.then(|| Self::calculate_pitch(instance.pitch_with(base_pitch)));
|
||||
let volume = self.calculate_volume(v, s);
|
||||
if let Some(h) = self.handles.get_mut(&handle) {
|
||||
h.pitch_edited = pitch_edited;
|
||||
}
|
||||
if let Some(c) = self.handles.get(&handle).and_then(handle_channel) {
|
||||
backend.set_volume(c, volume);
|
||||
if let Some(pitch) = pitch {
|
||||
backend.set_pitch(c, pitch);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1207,8 +1294,11 @@ impl SoundEngine {
|
||||
if sound.sentinel != Sentinel::None {
|
||||
return PlayResult::NotStarted;
|
||||
}
|
||||
let Some((base_volume, base_pitch)) = self.playback_parameters(id, true) else {
|
||||
return PlayResult::NotStarted;
|
||||
};
|
||||
let instance = self.instances.get_mut(&id).expect("present");
|
||||
let instance_volume = instance.get_volume();
|
||||
let instance_volume = instance.volume_with(base_volume);
|
||||
let attenuation_distance = sound.attenuation_distance(instance_volume);
|
||||
if self.listening {
|
||||
self.notify(id, subtitle, attenuation_distance);
|
||||
@@ -1252,6 +1342,7 @@ impl SoundEngine {
|
||||
channel: Some(channel),
|
||||
streaming,
|
||||
stopped: false,
|
||||
pitch_edited: base_pitch.to_bits() != self.instances[&id].pitch.to_bits(),
|
||||
},
|
||||
);
|
||||
self.delete_time.insert(id, self.tick_count + 20);
|
||||
@@ -1264,7 +1355,7 @@ impl SoundEngine {
|
||||
self.instance_by_source.push((source, id));
|
||||
}
|
||||
let instance = self.instances.get_mut(&id).expect("present");
|
||||
let pitch = Self::calculate_pitch(instance.get_pitch());
|
||||
let pitch = Self::calculate_pitch(instance.pitch_with(base_pitch));
|
||||
let (attenuation, relative, tickable) =
|
||||
(instance.attenuation, instance.relative, instance.tickable());
|
||||
backend.set_pitch(channel, pitch);
|
||||
@@ -1350,13 +1441,22 @@ impl SoundEngine {
|
||||
self.stop(id, backend);
|
||||
continue;
|
||||
}
|
||||
let (v, source) = (instance.get_volume(), instance.source);
|
||||
let pitch = Self::calculate_pitch(instance.get_pitch());
|
||||
let Some((base_volume, base_pitch)) = self.playback_parameters(id, false) else {
|
||||
self.cancel_playback(id, backend);
|
||||
continue;
|
||||
};
|
||||
let instance = self.instances.get_mut(&id).expect("present");
|
||||
let pitch_edited = base_pitch.to_bits() != instance.pitch.to_bits();
|
||||
let (v, source) = (instance.volume_with(base_volume), instance.source);
|
||||
let pitch = Self::calculate_pitch(instance.pitch_with(base_pitch));
|
||||
let position = instance.position;
|
||||
let volume = self.calculate_volume(v, source);
|
||||
let Some(handle) = self.channel_of(id) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(h) = self.handles.get_mut(&handle) {
|
||||
h.pitch_edited = pitch_edited;
|
||||
}
|
||||
if let Some(c) = self.handles.get(&handle).and_then(handle_channel) {
|
||||
backend.set_volume(c, volume);
|
||||
backend.set_pitch(c, pitch);
|
||||
@@ -1370,7 +1470,7 @@ impl SoundEngine {
|
||||
&& let (Some(sound), Some(subtitle)) =
|
||||
(instance.sound.clone(), instance.event.clone())
|
||||
{
|
||||
let distance = sound.attenuation_distance(instance.get_volume());
|
||||
let distance = sound.attenuation_distance(instance.volume_with(base_volume));
|
||||
self.notify(id, subtitle, distance);
|
||||
}
|
||||
}
|
||||
@@ -1995,3 +2095,7 @@ mod entity_bound_tests {
|
||||
assert_ne!(sound.position, replacement.state().position);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sound_engine/playback_tests.rs"]
|
||||
mod playback_tests;
|
||||
@@ -0,0 +1,188 @@
|
||||
use super::*;
|
||||
use horizon_core::random::RandomSource;
|
||||
|
||||
fn setup() -> (SoundEngine, test_backend::Null) {
|
||||
let mut registry = SoundRegistry::default();
|
||||
registry.add_file(
|
||||
"test",
|
||||
br#"{"beep":{"subtitle":"test.beep","sounds":[{"name":"beep","volume":0.5,"pitch":0.5}]}}"#,
|
||||
&|_, _| true,
|
||||
);
|
||||
let mut engine = SoundEngine::new(registry);
|
||||
engine.playback_hook = None;
|
||||
engine.listening = true;
|
||||
(engine, test_backend::Null::default())
|
||||
}
|
||||
fn instance() -> SoundInstance {
|
||||
SoundInstance::at(
|
||||
"test:beep",
|
||||
4,
|
||||
0.8,
|
||||
1.2,
|
||||
LegacyRandom::new(1),
|
||||
[2.0, 3.0, 4.0],
|
||||
)
|
||||
}
|
||||
fn adjust(sound: &SoundPlayback<'_>, volume: &mut f32, pitch: &mut f32, _: &mut bool) {
|
||||
assert_eq!(sound.identifier, "test:beep");
|
||||
assert_eq!(sound.source, 4);
|
||||
assert!(!sound.relative);
|
||||
*volume *= 0.5;
|
||||
*pitch = 2.0;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn played_parameters_reach_channels_without_changing_the_instance() {
|
||||
let (mut engine, mut backend) = setup();
|
||||
engine.set_test_playback_hook(adjust);
|
||||
let (id, result) = engine.play_new(instance(), true, &mut backend);
|
||||
assert_eq!(result, PlayResult::Started);
|
||||
let channel = engine.channel(id).unwrap();
|
||||
assert_eq!(backend.volume[&channel], 0.2);
|
||||
assert_eq!(backend.pitch[&channel], 1.0);
|
||||
assert_eq!(backend.position[&channel], [2.0, 3.0, 4.0]);
|
||||
assert_eq!(engine.instances[&id].volume, 0.8);
|
||||
assert_eq!(engine.instances[&id].pitch, 1.2);
|
||||
assert_eq!(engine.notifications.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_and_manually_looped_plays_do_not_compound_edits() {
|
||||
let (mut engine, mut backend) = setup();
|
||||
engine.set_test_playback_hook(adjust);
|
||||
let mut sound = instance();
|
||||
sound.looping = true;
|
||||
sound.delay = 2;
|
||||
let id = engine.add(sound, true);
|
||||
engine.play_delayed(id, 2);
|
||||
engine.tick(false, &TickContext::default(), &mut backend);
|
||||
assert!(backend.volume.is_empty() && engine.notifications.is_empty());
|
||||
engine.tick(false, &TickContext::default(), &mut backend);
|
||||
let first = engine.channel(id).unwrap();
|
||||
assert_eq!(backend.volume[&first], 0.2);
|
||||
backend.stopped.insert(first, true);
|
||||
for _ in 0..22 {
|
||||
engine.tick(false, &TickContext::default(), &mut backend);
|
||||
}
|
||||
let replay = engine.channel(id).unwrap();
|
||||
assert_ne!(first, replay);
|
||||
assert_eq!(backend.volume[&replay], 0.2);
|
||||
assert_eq!(engine.instances[&id].volume, 0.8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tickable_and_category_updates_keep_transient_edits_and_can_cancel() {
|
||||
let (mut engine, mut backend) = setup();
|
||||
engine.set_test_playback_hook(adjust);
|
||||
let mut sound = instance();
|
||||
sound.ticking = Ticking::Native;
|
||||
let (id, _) = engine.play_new(sound, true, &mut backend);
|
||||
let channel = engine.channel(id).unwrap();
|
||||
let sound = engine.instance_mut(id).unwrap();
|
||||
sound.volume = 0.4;
|
||||
sound.position = [8.0, 9.0, 10.0];
|
||||
engine.tick(false, &TickContext::default(), &mut backend);
|
||||
assert_eq!(backend.volume[&channel], 0.1);
|
||||
assert_eq!(backend.position[&channel], [8.0, 9.0, 10.0]);
|
||||
engine.set_test_playback_hook(|_, volume, _, _| *volume *= 0.5);
|
||||
engine.update_category_volume(4, 0.5, &mut backend);
|
||||
assert_eq!(backend.volume[&channel], 0.05);
|
||||
assert_eq!(
|
||||
backend.pitch[&channel], 0.6,
|
||||
"a later no-pitch edit restores the base pitch"
|
||||
);
|
||||
engine.notifications.clear();
|
||||
engine.set_test_playback_hook(|sound, _, _, cancel| {
|
||||
assert!(!sound.starting);
|
||||
*cancel = true;
|
||||
});
|
||||
engine.tick(false, &TickContext::default(), &mut backend);
|
||||
assert!(backend.stopped(channel));
|
||||
assert!(engine.notifications.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_and_invalid_edits_never_allocate_or_notify() {
|
||||
let edits: [PlaybackHook; 6] = [
|
||||
|_, _, _, cancel| *cancel = true,
|
||||
|_, volume, _, _| *volume = f32::NAN,
|
||||
|_, volume, _, _| *volume = f32::INFINITY,
|
||||
|_, volume, _, _| *volume = -1.0,
|
||||
|_, _, pitch, _| *pitch = 0.0,
|
||||
|_, _, pitch, _| *pitch = f32::NEG_INFINITY,
|
||||
];
|
||||
for hook in edits {
|
||||
let (mut engine, mut backend) = setup();
|
||||
engine.set_test_playback_hook(hook);
|
||||
let (_, result) = engine.play_new(instance(), false, &mut backend);
|
||||
assert_eq!(result, PlayResult::NotStarted);
|
||||
assert!(backend.stopped.is_empty() && engine.notifications.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_op_dispatch_preserves_random_sampling_and_failed_pool_behavior() {
|
||||
let (mut plain, mut plain_backend) = setup();
|
||||
let (mut hooked, mut hooked_backend) = setup();
|
||||
hooked.set_test_playback_hook(|_, _, _, _| {});
|
||||
for (engine, backend) in [
|
||||
(&mut plain, &mut plain_backend),
|
||||
(&mut hooked, &mut hooked_backend),
|
||||
] {
|
||||
engine.limits = [0, 0];
|
||||
let id = engine.add(instance(), true);
|
||||
assert_eq!(engine.play(id, backend), PlayResult::NotStarted);
|
||||
engine.limits = [247, 8];
|
||||
assert_eq!(engine.play(id, backend), PlayResult::Started);
|
||||
}
|
||||
assert_eq!(plain_backend.volume, hooked_backend.volume);
|
||||
assert_eq!(plain_backend.pitch, hooked_backend.pitch);
|
||||
assert_eq!(plain.notifications, hooked.notifications);
|
||||
assert_eq!(
|
||||
plain.instances.get_mut(&1).unwrap().random.next_long(),
|
||||
hooked.instances.get_mut(&1).unwrap().random.next_long()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_bound_updates_are_intercepted_at_the_new_position() {
|
||||
let (mut engine, mut backend) = setup();
|
||||
let entity = SoundEntity::new(42, [2.0, 3.0, 4.0], false);
|
||||
engine.set_test_playback_hook(|sound, volume, _, _| {
|
||||
if !sound.starting {
|
||||
assert_eq!(sound.position, [8.0, 9.0, 10.0]);
|
||||
}
|
||||
*volume *= 0.5;
|
||||
});
|
||||
let sound = SoundInstance::entity_bound(
|
||||
"test:beep",
|
||||
4,
|
||||
0.8,
|
||||
1.0,
|
||||
LegacyRandom::new(1),
|
||||
entity.clone(),
|
||||
);
|
||||
let (id, _) = engine.play_new(sound, true, &mut backend);
|
||||
entity.update(SoundEntityState {
|
||||
position: [8.0, 9.0, 10.0],
|
||||
silent: false,
|
||||
removed: false,
|
||||
});
|
||||
engine.tick(false, &TickContext::default(), &mut backend);
|
||||
let channel = engine.channel(id).unwrap();
|
||||
assert_eq!(backend.position[&channel], [8.0, 9.0, 10.0]);
|
||||
assert_eq!(backend.volume[&channel], 0.2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_volume_only_edit_preserves_legacy_zero_pitch_clamping() {
|
||||
let (mut engine, mut backend) = setup();
|
||||
engine.set_test_playback_hook(|_, volume, _, _| *volume *= 0.5);
|
||||
let mut sound = instance();
|
||||
sound.pitch = 0.0;
|
||||
let (id, result) = engine.play_new(sound, true, &mut backend);
|
||||
assert_eq!(result, PlayResult::Started);
|
||||
let channel = engine.channel(id).unwrap();
|
||||
assert_eq!(backend.volume[&channel], 0.2);
|
||||
assert_eq!(backend.pitch[&channel], 0.5);
|
||||
}
|
||||
@@ -112,6 +112,7 @@ pub fn main() -> Result<()> {
|
||||
raining: &no_rain,
|
||||
mob_griefing: true,
|
||||
tnt_explodes: true,
|
||||
owners: &(),
|
||||
};
|
||||
let targets: Vec<_> = target.iter().map(|t| (Who::Mob(2), t.0)).collect();
|
||||
let (_, hit) = arrows::tick(&mut arrow, &view, &targets);
|
||||
|
||||
@@ -9,7 +9,7 @@ use horizon_client::{
|
||||
stations,
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use std::{fmt::Write as _, fs, path::Path};
|
||||
use std::{fmt::Write as _, fs, path::Path, sync::Arc};
|
||||
|
||||
pub fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
@@ -24,7 +24,20 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
for line in input.lines() {
|
||||
let f: Vec<&str> = line.split(' ').collect();
|
||||
let (seed, bookcases, item): (i32, i32, &str) = (f[0].parse()?, f[1].parse()?, f[2]);
|
||||
let stack = ItemStack::new(item, 1);
|
||||
let mut stack = ItemStack::new(item, 1);
|
||||
if let Some(damage) = f.get(3) {
|
||||
stack.damage = damage.parse()?;
|
||||
}
|
||||
if let Some(levels) = f.get(4).filter(|levels| **levels != "-") {
|
||||
stack.data_mut().enchantments = levels
|
||||
.split(',')
|
||||
.map(|entry| {
|
||||
let (id, level) = entry.split_once('=')?;
|
||||
Some((Arc::from(id), level.parse().ok()?))
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()
|
||||
.ok_or("bad enchantments")?;
|
||||
}
|
||||
let offers = stations::enchant_offers(&e, &items, seed, bookcases, Some(&stack));
|
||||
for i in 0..3 {
|
||||
let cost = offers.costs[i];
|
||||
|
||||
@@ -607,7 +607,12 @@ fn player_damage(e: &MobEvent) -> f32 {
|
||||
| MobEvent::CubeHurtPlayer { amount, .. }
|
||||
| MobEvent::StingPlayer { amount, .. }
|
||||
| MobEvent::AttackPlayerWithEffect { amount, .. }
|
||||
| MobEvent::DamagePlayer { amount, .. } => *amount,
|
||||
| MobEvent::DamagePlayer { amount, .. }
|
||||
| MobEvent::IronGolemHit {
|
||||
target: Who::Player,
|
||||
amount,
|
||||
..
|
||||
} => *amount,
|
||||
MobEvent::SonicBoom {
|
||||
target: Who::Player,
|
||||
..
|
||||
@@ -626,7 +631,12 @@ fn player_hurt(player: Option<&PlayerView>, e: &MobEvent) -> bool {
|
||||
| MobEvent::CubeHurtPlayer { amount, .. }
|
||||
| MobEvent::StingPlayer { amount, .. }
|
||||
| MobEvent::AttackPlayerWithEffect { amount, .. }
|
||||
| MobEvent::DamagePlayer { amount, .. } => *amount > 0.0,
|
||||
| MobEvent::DamagePlayer { amount, .. }
|
||||
| MobEvent::IronGolemHit {
|
||||
target: Who::Player,
|
||||
amount,
|
||||
..
|
||||
} => *amount > 0.0,
|
||||
MobEvent::SonicBoom {
|
||||
target: Who::Player,
|
||||
..
|
||||
@@ -1391,14 +1401,28 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
// (LivingEntity.hurtServer), with the sounds they raise.
|
||||
let mut k = from;
|
||||
while k < events.len() {
|
||||
if let MobEvent::AttackMob {
|
||||
target,
|
||||
amount,
|
||||
source,
|
||||
} = events[k]
|
||||
let (hit, toss) = match events[k] {
|
||||
MobEvent::AttackMob {
|
||||
target,
|
||||
amount,
|
||||
source,
|
||||
} => (Some((target, amount, source)), false),
|
||||
// IronGolem.doHurtTarget: thrown up only if the hit landed.
|
||||
MobEvent::IronGolemHit {
|
||||
target: Who::Mob(target),
|
||||
amount,
|
||||
source,
|
||||
} => (Some((target, amount, source)), true),
|
||||
_ => (None, false),
|
||||
};
|
||||
if let Some((target, amount, source)) = hit
|
||||
&& let Some(t) = mobs.iter_mut().find(|m| m.id == target)
|
||||
&& t.damage(amount, source)
|
||||
{
|
||||
if toss {
|
||||
t.velocity.y +=
|
||||
f64::from(0.4f32) * (1.0 - t.knockback_resistance()).max(0.0);
|
||||
}
|
||||
let raised = std::mem::take(&mut t.outbox);
|
||||
let n = raised.len();
|
||||
events.splice(k + 1..k + 1, raised);
|
||||
|
||||
@@ -656,12 +656,19 @@ pub struct EntityView {
|
||||
pub variant: Option<String>,
|
||||
/// The data components the entity reports (`Entity.get`): id and value.
|
||||
pub components: Vec<(String, String)>,
|
||||
/// `Entity.get(CUSTOM_NAME)`: the live Text component, preserving styles
|
||||
/// and siblings rather than treating its contents as a registry id.
|
||||
pub custom_name: Option<Value>,
|
||||
/// The entity as saved (`NbtPredicate.getEntityTagToCompare`), for
|
||||
/// `minecraft:nbt`; built only when a criterion may ask (`Tree::tests_nbt`).
|
||||
pub nbt: Option<horizon_world::nbt::Tag>,
|
||||
/// `Entity.getTeam().getName()` (a tamed pet answers its owner's team);
|
||||
/// `None` without a team or where the identity is not bound.
|
||||
pub team: Option<String>,
|
||||
/// Existing objective scores for the bound command source holder. `None`
|
||||
/// means this context did not bind a scoreboard holder; an empty map is a
|
||||
/// real holder without scores. Named predicates snapshot current values.
|
||||
pub scores: Option<BTreeMap<String, i32>>,
|
||||
}
|
||||
/// What `PlayerPredicate` reads of a player beyond its entity. Each part is
|
||||
/// filled only when a criterion may ask for it (`Tree::player_facts_asked`);
|
||||
@@ -931,12 +938,20 @@ pub fn location(v: &Value, p: [f64; 3], w: &dyn World) -> bool {
|
||||
}
|
||||
/// `DataComponentExactPredicate.test` on an entity: every expected
|
||||
/// component equals what the entity reports (absent ones never match).
|
||||
fn components(expected: &Value, have: &[(String, String)]) -> bool {
|
||||
fn components(expected: &Value, entity: &EntityView) -> bool {
|
||||
expected.as_object().is_some_and(|o| {
|
||||
o.iter().all(|(id, want)| {
|
||||
let id = full(id);
|
||||
if id == "minecraft:custom_name" {
|
||||
return entity.custom_name.as_ref().is_some_and(|have| {
|
||||
crate::scoreboard::canonical_component(have)
|
||||
== crate::scoreboard::canonical_component(want)
|
||||
});
|
||||
}
|
||||
want.as_str().is_some_and(|want| {
|
||||
have.iter()
|
||||
entity
|
||||
.components
|
||||
.iter()
|
||||
.any(|(k, v)| full(k) == id && full(v) == full(want))
|
||||
})
|
||||
})
|
||||
@@ -1043,7 +1058,7 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
|
||||
"minecraft:type_specific/fishing_hook" => p["in_open_water"]
|
||||
.as_bool()
|
||||
.is_none_or(|want| e.fishing_open_water == Some(want)),
|
||||
"minecraft:components" => components(p, &e.components),
|
||||
"minecraft:components" => components(p, e),
|
||||
// `TeamPredicate.matches`: a non-null effective team whose name
|
||||
// equals the string exactly.
|
||||
"minecraft:team" => {
|
||||
@@ -1343,6 +1358,30 @@ pub fn conditions(v: &Value, ctx: &Entities, w: &dyn World) -> bool {
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
match &*full(ty) {
|
||||
// EntityHasScoreCondition.test/hasScore: missing objective or
|
||||
// score fails; this bounded path supports constant integer ranges.
|
||||
"minecraft:entity_scores" if v["entity"].as_str() == Some("this") => {
|
||||
let scores = ctx.this.and_then(|e| e.scores.as_ref());
|
||||
v["scores"].as_object().is_some_and(|tests| {
|
||||
scores.is_some_and(|scores| {
|
||||
tests.iter().all(|(objective, range)| {
|
||||
// Do not treat unsupported number providers as
|
||||
// absent min/max bounds in the shared range helper.
|
||||
let numeric = range.as_i64().is_some()
|
||||
|| range.as_object().is_some_and(|bounds| {
|
||||
bounds.iter().all(|(key, value)| {
|
||||
matches!(key.as_str(), "min" | "max")
|
||||
&& value.as_i64().is_some()
|
||||
})
|
||||
});
|
||||
numeric
|
||||
&& scores
|
||||
.get(objective)
|
||||
.is_some_and(|score| range_i(range, i64::from(*score)))
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
"minecraft:entity_properties" => ctx
|
||||
.this
|
||||
.is_some_and(|e| entity(&v["predicate"], e, w, ctx.origin)),
|
||||
|
||||
@@ -331,6 +331,17 @@ pub struct ArrowScene<'a, 'r> {
|
||||
/// The `mob_griefing` and `tnt_explodes` game rules.
|
||||
pub mob_griefing: bool,
|
||||
pub tnt_explodes: bool,
|
||||
pub owners: &'a dyn OwnerLookup,
|
||||
}
|
||||
/// `EntityReference.getEntity` for an owner the arrow only knows by UUID.
|
||||
pub trait OwnerLookup {
|
||||
/// A loaded entity other than a player has this UUID.
|
||||
fn non_player_entity(&self, uuid: [i32; 4]) -> bool;
|
||||
}
|
||||
impl OwnerLookup for () {
|
||||
fn non_player_entity(&self, _: [i32; 4]) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
impl ArrowScene<'_, '_> {
|
||||
fn block(&self, p: BlockPos) -> BlockStateId {
|
||||
@@ -1065,9 +1076,17 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
self.st.pierce = 0;
|
||||
self.st.pierced.clear();
|
||||
}
|
||||
/// `Projectile.mayInteract`: a mob's arrows need `mob_griefing`.
|
||||
/// `Projectile.mayInteract`: an arrow owned by anything but a player needs `mob_griefing`.
|
||||
fn may_interact(&self) -> bool {
|
||||
!matches!(self.p.owner, Some(Who::Mob(_))) || self.scene.mob_griefing
|
||||
let by_entity = match self.p.owner {
|
||||
Some(Who::Mob(_)) => true,
|
||||
Some(Who::Player) => false,
|
||||
None => self
|
||||
.p
|
||||
.owner_uuid
|
||||
.is_some_and(|u| self.scene.owners.non_player_entity(u)),
|
||||
};
|
||||
!by_entity || self.scene.mob_griefing
|
||||
}
|
||||
/// `onProjectileHit` of the blocks a burning projectile lights.
|
||||
fn block_reaction(&self, state: BlockStateId) -> Option<BlockReaction> {
|
||||
|
||||
@@ -47,6 +47,17 @@ fn shape_tag(shape: &VoxelShape) -> Tag {
|
||||
Tag::compound(fields)
|
||||
}
|
||||
|
||||
const TNT: u32 = 20;
|
||||
struct Rules<'o> {
|
||||
mob_griefing: bool,
|
||||
owners: &'o dyn OwnerLookup,
|
||||
server: bool,
|
||||
}
|
||||
const CLIENT: Rules<'static> = Rules {
|
||||
mob_griefing: true,
|
||||
owners: &(),
|
||||
server: false,
|
||||
};
|
||||
fn run(cells: &[([i32; 3], u32)], ticks: usize, shared_fire: bool) -> Vec<Projectile> {
|
||||
run_with(cells, ticks, shared_fire, |_| {})
|
||||
}
|
||||
@@ -55,6 +66,15 @@ fn run_with(
|
||||
ticks: usize,
|
||||
shared_fire: bool,
|
||||
setup: impl FnOnce(&mut Projectile),
|
||||
) -> Vec<Projectile> {
|
||||
run_in(cells, ticks, shared_fire, CLIENT, setup)
|
||||
}
|
||||
fn run_in(
|
||||
cells: &[([i32; 3], u32)],
|
||||
ticks: usize,
|
||||
shared_fire: bool,
|
||||
rules: Rules,
|
||||
setup: impl FnOnce(&mut Projectile),
|
||||
) -> Vec<Projectile> {
|
||||
let mut definitions = vec![("air", None), ("stone", None)];
|
||||
for level in 0..16 {
|
||||
@@ -63,6 +83,7 @@ fn run_with(
|
||||
for drag in [false, true] {
|
||||
definitions.push(("bubble_column", Some(("drag", drag.to_string()))));
|
||||
}
|
||||
definitions.push(("tnt", None));
|
||||
let states: Vec<_> = definitions
|
||||
.iter()
|
||||
.map(|(name, property)| {
|
||||
@@ -83,6 +104,7 @@ fn run_with(
|
||||
("minecraft:stone", Tag::Int(1)),
|
||||
("minecraft:water", Tag::Int(2)),
|
||||
("minecraft:bubble_column", Tag::Int(18)),
|
||||
("minecraft:tnt", Tag::Int(TNT as i32)),
|
||||
]),
|
||||
),
|
||||
]))
|
||||
@@ -99,7 +121,7 @@ fn run_with(
|
||||
serde_json::json!({"states":(0..states.len()).map(|id|serde_json::json!({"id":id})).collect::<Vec<_>>()} ).to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let behaviours = serde_json::json!({"states":(0..states.len()).map(|id|if id==1{[63,0]}else{[0,0]}).collect::<Vec<_>>(),"blocks":(["air","stone","water","bubble_column"].iter().map(|name|serde_json::json!({"name":format!("minecraft:{name}"),"class":["Block"]})).collect::<Vec<_>>())});
|
||||
let behaviours = serde_json::json!({"states":(0..states.len()).map(|id|if id==1||id==20{[63,0]}else{[0,0]}).collect::<Vec<_>>(),"blocks":(["air","stone","water","bubble_column","tnt"].iter().map(|name|serde_json::json!({"name":format!("minecraft:{name}"),"class":["Block"]})).collect::<Vec<_>>())});
|
||||
let facts = BlockFacts::load_with_behaviour_json(&root, ®istry, &behaviours).unwrap();
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
let table = Tag::compound([
|
||||
@@ -114,7 +136,7 @@ fn run_with(
|
||||
),
|
||||
(
|
||||
"cached_shapes",
|
||||
Tag::IntArray((0..20).map(|id| i32::from(id == 1)).collect()),
|
||||
Tag::IntArray((0..21).map(|id| i32::from(id == 1 || id == TNT)).collect()),
|
||||
),
|
||||
]);
|
||||
let collision = BlockCollisionRegistry::new(Arc::new(
|
||||
@@ -140,8 +162,9 @@ fn run_with(
|
||||
collision: &collision,
|
||||
fluids: &fluids,
|
||||
raining: &|_| false,
|
||||
mob_griefing: true,
|
||||
mob_griefing: rules.mob_griefing,
|
||||
tnt_explodes: true,
|
||||
owners: rules.owners,
|
||||
};
|
||||
let mut p = Projectile::new(
|
||||
ProjectileKind::Arrow {
|
||||
@@ -159,7 +182,11 @@ fn run_with(
|
||||
let mut result = Vec::new();
|
||||
for _ in 0..ticks {
|
||||
p.arrow.as_mut().unwrap().events.clear();
|
||||
tick_client(&mut p, &scene);
|
||||
if rules.server {
|
||||
tick(&mut p, &scene, &[]);
|
||||
} else {
|
||||
tick_client(&mut p, &scene);
|
||||
}
|
||||
result.push(p.clone());
|
||||
}
|
||||
result
|
||||
@@ -189,6 +216,43 @@ fn first_water_tick_moves_before_water_inertia_and_keeps_synced_flames() {
|
||||
}
|
||||
}
|
||||
|
||||
struct ArmorStand;
|
||||
impl OwnerLookup for ArmorStand {
|
||||
fn non_player_entity(&self, uuid: [i32; 4]) -> bool {
|
||||
uuid == [1, 2, 3, 4]
|
||||
}
|
||||
}
|
||||
fn primes_tnt(owner_uuid: Option<[i32; 4]>, mob_griefing: bool) -> bool {
|
||||
let rules = Rules {
|
||||
mob_griefing,
|
||||
owners: &ArmorStand,
|
||||
server: true,
|
||||
};
|
||||
let steps = run_in(&[([1, 18, 0], TNT)], 2, false, rules, |p| {
|
||||
p.owner_uuid = owner_uuid;
|
||||
});
|
||||
steps
|
||||
.iter()
|
||||
.flat_map(|p| &p.arrow.as_ref().unwrap().events)
|
||||
.any(|e| {
|
||||
matches!(
|
||||
e,
|
||||
ArrowEvent::Block {
|
||||
reaction: BlockReaction::PrimeTnt,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
#[test]
|
||||
fn an_arrow_owned_by_an_armor_stand_needs_mob_griefing_to_prime_tnt() {
|
||||
assert!(!primes_tnt(Some([1, 2, 3, 4]), false));
|
||||
assert!(primes_tnt(Some([1, 2, 3, 4]), true));
|
||||
// An owner that isn't loaded is no owner (getOwner is null).
|
||||
assert!(primes_tnt(Some([9, 9, 9, 9]), false));
|
||||
assert!(primes_tnt(None, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "run by client-arrow differential harness"]
|
||||
fn client_arrow_parity_fixture() {
|
||||
|
||||
@@ -110,6 +110,25 @@ pub fn spawn_egg_entity_data<'a>(
|
||||
Some(data)
|
||||
}
|
||||
|
||||
/// `EntityType.appendDefaultStackConfig` for a hanging entity or armor stand an
|
||||
/// item places: `Entity.applyImplicitComponents` stores the item's
|
||||
/// `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 {
|
||||
let mut tag = own.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, false) {
|
||||
Some(data) => crate::data_cmd::merged(&tag, data),
|
||||
None => tag,
|
||||
}
|
||||
}
|
||||
|
||||
/// `Spawner.getSpawnEntityDisplayName` (`SpawnData.entity.id` of the item's
|
||||
/// data, which `ItemStack.addDetailsToTooltip` reads for spawners and trial
|
||||
/// spawners alike): the entity type a spawner item spawns.
|
||||
@@ -379,7 +398,13 @@ pub fn place(saved: &Tag, components: &Compound, game_master: bool) -> Tag {
|
||||
merged.insert(key.into(), v.clone());
|
||||
}
|
||||
}
|
||||
if let Some(Tag::Compound(loaded)) = canonical(&Tag::Compound(merged)) {
|
||||
let merged = Tag::Compound(merged);
|
||||
// A native mod's live entity supplies its own canonical id and
|
||||
// schema. Keep the merged opaque payload for that type, while
|
||||
// retaining vanilla's load/save validation for minecraft types.
|
||||
let loaded =
|
||||
canonical(&merged).or_else(|| (!id.starts_with("minecraft:")).then_some(merged));
|
||||
if let Some(Tag::Compound(loaded)) = loaded {
|
||||
tag = loaded;
|
||||
}
|
||||
}
|
||||
@@ -481,6 +506,35 @@ mod tests {
|
||||
crate::item_data::canon_tag(&Tag::Compound(patch.clone()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_native_entity_item_merges_opaque_data_without_replacing_its_identity() {
|
||||
let fresh = snbt(
|
||||
"{id:\"example:heater\",x:4,y:65,z:8,fuel:1,\
|
||||
nested:{retained:17,changed:0},components:{}}",
|
||||
);
|
||||
let Tag::Compound(patch) = snbt(
|
||||
"{\"minecraft:block_entity_data\":{id:\"example:heater\",\
|
||||
x:99,y:99,z:99,fuel:923,nested:{changed:5},opaque:[B;1,-2,3]}}",
|
||||
) else {
|
||||
unreachable!()
|
||||
};
|
||||
let placed = place(&fresh, &patch, false);
|
||||
assert_eq!(
|
||||
placed,
|
||||
snbt(
|
||||
"{id:\"example:heater\",x:4,y:65,z:8,fuel:923,\
|
||||
nested:{retained:17,changed:5},opaque:[B;1,-2,3],components:{}}"
|
||||
)
|
||||
);
|
||||
let Tag::Compound(other_type) =
|
||||
snbt("{\"minecraft:block_entity_data\":{id:\"example:other\",fuel:888}}")
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(place(&fresh, &other_type, false), fresh);
|
||||
assert_eq!(place(&fresh, &pick(&placed), false), placed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_filled_named_chest_gives_its_items_and_name_as_components() {
|
||||
let chest = saved(
|
||||
@@ -558,6 +612,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_placed_entity_takes_the_items_custom_data_and_matching_entity_data() {
|
||||
let item = |components: &str| {
|
||||
let components = snbt(components);
|
||||
let (damage, extra) = crate::item_data::decode_components(Some(&components));
|
||||
crate::items::ItemStack {
|
||||
damage,
|
||||
extra,
|
||||
..crate::items::ItemStack::new("minecraft:armor_stand", 1)
|
||||
}
|
||||
};
|
||||
let own = snbt("{CustomName:\"Named\",ShowArms:0b}");
|
||||
let stack = item(
|
||||
"{\"minecraft:custom_data\":{k:1b},\"minecraft:entity_data\":{id:\"minecraft:armor_stand\",ShowArms:1b}}",
|
||||
);
|
||||
let tag = stack_configured_entity(&stack, "minecraft:armor_stand", &own);
|
||||
assert_eq!(tag.get("ShowArms"), Some(&Tag::Byte(1)));
|
||||
assert_eq!(tag.get("CustomName"), Some(&Tag::String("Named".into())));
|
||||
assert_eq!(
|
||||
tag.get("data").and_then(|d| d.get("k")),
|
||||
Some(&Tag::Byte(1))
|
||||
);
|
||||
// Data of another type is not loaded; the custom data still is.
|
||||
let tag = stack_configured_entity(&stack, "minecraft:item_frame", &own);
|
||||
assert_eq!(tag.get("ShowArms"), Some(&Tag::Byte(0)));
|
||||
assert!(tag.get("data").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spawn_egg_data_needs_its_own_type_and_an_op_for_an_op_only_one() {
|
||||
let egg = |entity_data: &str| {
|
||||
|
||||
@@ -10,20 +10,20 @@ pub mod block_contact;
|
||||
pub mod powder_snow;
|
||||
pub use horizon_client_render::{
|
||||
assets, atlas_sources, audio, auth, ban_notice, bidi, biome, block_edit, block_entities,
|
||||
block_entity_anim, block_entity_render, block_sprites, bluemap, bluemap_detect, border_render,
|
||||
breaking, brewing, camera, chat, chunk, chunk_map, cit, cit_armor, cit_assets, cit_glint,
|
||||
clouds, collisions, command_block, containers, controller_ui, crash, datapack, dynamic_light,
|
||||
effects, environment, extras, fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui,
|
||||
hdr_calibration, hud, hud_layout, hud_overlays, inventory_effects, java_text, jigsaw_block,
|
||||
json_folder, level_loading, light, lod, logging, menu_blur, mesher, minimap, mipmap,
|
||||
mod_browser, mod_list, model_library, models, multiline_edit, native_emissive, options_file,
|
||||
options_fixes, options_ui, overlay, pack_browser, pack_screen, packs, pad_cursor, pad_glyph,
|
||||
par, particles, poi_sections, post_chain, potent_sulfur, preedit, providers, pvp, pvp_hud,
|
||||
screen_draw, screen_effects, sculk, section_mask, server_terrain, settings, shader_options,
|
||||
shader_pack_list, signs, skins, sound_engine, sound_events, sound_preview, spans, split_layout,
|
||||
split_map, split_profiles, sprites, streaming, structure_block, tab_list, telemetry,
|
||||
test_block, test_instance_block, testgen, title_edition, trims, ui, vault, weather, widgets,
|
||||
world, worldborder, zip,
|
||||
block_entity_anim, block_entity_render, block_sprites, block_states, bluemap, bluemap_detect,
|
||||
border_render, breaking, brewing, camera, chat, chunk, chunk_map, cit, cit_armor, cit_assets,
|
||||
cit_glint, client_resources, clouds, collisions, command_block, containers, controller_ui,
|
||||
crash, datapack, dynamic_light, effects, environment, extras, fast_hash, fog, font, gfx,
|
||||
graphics, graphics_style, graphics_ui, hdr_calibration, hud, hud_layout, hud_overlays,
|
||||
inventory_effects, java_text, jigsaw_block, json_folder, level_loading, light, lod, logging,
|
||||
menu_blur, mesher, minimap, mipmap, mod_browser, mod_list, model_library, models,
|
||||
multiline_edit, native_emissive, options_file, options_fixes, options_ui, overlay,
|
||||
pack_browser, pack_screen, packs, pad_cursor, pad_glyph, par, particles, poi_sections,
|
||||
post_chain, potent_sulfur, preedit, providers, pvp, pvp_hud, screen_draw, screen_effects,
|
||||
sculk, section_mask, server_terrain, settings, shader_options, shader_pack_list, signs, skins,
|
||||
sound_engine, sound_events, sound_preview, spans, split_layout, split_map, split_profiles,
|
||||
sprites, streaming, structure_block, tab_list, telemetry, test_block, test_instance_block,
|
||||
testgen, title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
|
||||
};
|
||||
pub mod attack_strength;
|
||||
pub mod attribute_updates;
|
||||
@@ -122,6 +122,7 @@ pub mod scoreboard_cmd;
|
||||
pub mod screen_layout;
|
||||
pub mod screen_narrator;
|
||||
pub mod social_screen;
|
||||
pub mod sound_playback;
|
||||
pub use horizon_server_files as server;
|
||||
pub mod controller_notice;
|
||||
pub mod server_packs;
|
||||
|
||||
@@ -437,14 +437,15 @@ impl FixedStep {
|
||||
/// `TickRateManager.tick` for one tick, then whether the world runs
|
||||
/// (`runsNormally`). Also the sprint check that opens a sprint tick.
|
||||
pub fn world_runs(&mut self) -> bool {
|
||||
// MinecraftServer checks for a finished sprint before tickServer
|
||||
// asks the tick-rate manager whether the levels should run.
|
||||
if self.manager.is_sprinting() && self.manager.remaining_sprint_ticks() == 0 {
|
||||
self.sprint_report = Some(self.manager.finish_tick_sprint());
|
||||
}
|
||||
self.manager.base.tick();
|
||||
self.sprint_checked = false;
|
||||
if self.manager.is_sprinting() {
|
||||
let (_, report) = self.manager.check_should_sprint_this_tick(self.now_ns());
|
||||
self.sprint_checked = report.is_none();
|
||||
if report.is_some() {
|
||||
self.sprint_report = report;
|
||||
}
|
||||
self.sprint_checked = self.manager.check_should_sprint_this_tick(self.now_ns()).0;
|
||||
}
|
||||
self.manager.base.runs_normally()
|
||||
}
|
||||
@@ -578,6 +579,25 @@ mod tests {
|
||||
assert!(!m.base.runs_normally());
|
||||
}
|
||||
#[test]
|
||||
fn a_finished_sprint_restores_freeze_before_the_next_world_tick() {
|
||||
for was_frozen in [false, true] {
|
||||
let mut clock = FixedStep::default();
|
||||
clock.set_frozen(was_frozen);
|
||||
assert_eq!(clock.world_runs(), !was_frozen);
|
||||
|
||||
assert!(!clock.start_sprint(1));
|
||||
assert!(clock.world_runs());
|
||||
assert!(!clock.frozen());
|
||||
assert!(clock.take_sprint_report().is_none());
|
||||
|
||||
assert_eq!(clock.world_runs(), !was_frozen);
|
||||
assert_eq!(clock.frozen(), was_frozen);
|
||||
assert!(clock.take_sprint_report().is_some());
|
||||
assert_eq!(clock.world_runs(), !was_frozen);
|
||||
assert!(clock.take_sprint_report().is_none());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn the_camera_keeps_interpolating_in_a_frozen_level() {
|
||||
let mut clock = FixedStep::default();
|
||||
clock.set_frozen(true);
|
||||
|
||||
@@ -308,12 +308,13 @@ impl DebugEntries {
|
||||
pub fn is_using_profile(&self, profile: Profile) -> bool {
|
||||
self.profile == Some(profile)
|
||||
}
|
||||
/// `getStatus`: never for an entry the statuses leave out.
|
||||
/// `getStatus`: never for an entry the statuses leave out. The debug renderers ask every
|
||||
/// frame, so no identifier is built for the lookup.
|
||||
pub fn status(&self, path: &str) -> Status {
|
||||
self.statuses
|
||||
.get(&identifier(path))
|
||||
.copied()
|
||||
.unwrap_or(Status::Never)
|
||||
.iter()
|
||||
.find(|(id, _)| id.strip_prefix("minecraft:") == Some(path))
|
||||
.map_or(Status::Never, |(_, status)| *status)
|
||||
}
|
||||
/// `setStatus`: the statuses are custom from now on, and saved.
|
||||
pub fn set_status(&mut self, path: &str, status: Status) {
|
||||
@@ -343,11 +344,13 @@ impl DebugEntries {
|
||||
};
|
||||
shown && entry(path).is_some_and(|e| e.reduced || !reduced)
|
||||
}
|
||||
/// `getCurrentlyEnabled().isEmpty()` negated: something to draw.
|
||||
/// `getCurrentlyEnabled().isEmpty()` negated: something to draw. Asked every frame, F3 closed
|
||||
/// or not, so it walks the few statuses kept rather than every entry.
|
||||
pub fn any_enabled(&self, overlay: bool, reduced: bool) -> bool {
|
||||
ENTRIES
|
||||
.iter()
|
||||
.any(|e| self.enabled(e.path, overlay, reduced))
|
||||
self.statuses
|
||||
.keys()
|
||||
.filter_map(|id| id.strip_prefix("minecraft:"))
|
||||
.any(|path| self.enabled(path, overlay, reduced))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
//! them last, over any screen, at the debug GUI scale.
|
||||
use crate::{
|
||||
font::Font,
|
||||
hud::HudVertex,
|
||||
hud_layout::{GuiDraw, PoseOp, fill},
|
||||
java_text,
|
||||
text::Text,
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// `extractLines`'s background: ARGB 0x90505050.
|
||||
const BACKGROUND: u32 = 0x9050_5050;
|
||||
@@ -15,6 +17,39 @@ const TEXT: u32 = 0xFFE0_E0E0;
|
||||
/// `Font.lineHeight`.
|
||||
const LINE_HEIGHT: i32 = 9;
|
||||
|
||||
/// How long the painted overlay is reused: 20 times a second, the tick rate most of its lines
|
||||
/// change at or below (fps once a second, memory twice).
|
||||
const REPAINT: Duration = Duration::from_millis(50);
|
||||
/// Least time between frames timed for the overlay's GPU lines. Their medians still span about
|
||||
/// 1.5 s of samples.
|
||||
pub const GPU_TIMING_GAP: Duration = Duration::from_millis(100);
|
||||
|
||||
/// The overlay's painted vertices between rebuilds: copying them is far cheaper than formatting
|
||||
/// its lines and laying out their glyphs each frame.
|
||||
#[derive(Default)]
|
||||
pub struct PaintCache {
|
||||
vertices: Vec<HudVertex>,
|
||||
/// When they were painted, and for what layout (see [`PaintCache::fresh`]).
|
||||
painted: Option<(Instant, PaintLayout)>,
|
||||
}
|
||||
|
||||
/// What the painted vertices depend on besides the lines: the framebuffer size, the GUI scale's
|
||||
/// bits and `Font::epoch` (glyphs move in the atlas when it changes).
|
||||
pub type PaintLayout = ([u32; 2], u32, u64);
|
||||
|
||||
impl PaintCache {
|
||||
/// The vertices, while they are recent and painted for this `layout`.
|
||||
pub fn fresh(&self, now: Instant, layout: PaintLayout) -> Option<&[HudVertex]> {
|
||||
let (at, painted) = self.painted?;
|
||||
(painted == layout && now.duration_since(at) < REPAINT).then_some(&self.vertices)
|
||||
}
|
||||
pub fn store(&mut self, now: Instant, layout: PaintLayout, vertices: &[HudVertex]) {
|
||||
self.vertices.clear();
|
||||
self.vertices.extend_from_slice(vertices);
|
||||
self.painted = Some((now, layout));
|
||||
}
|
||||
}
|
||||
|
||||
/// What the enabled entries reported (`DebugScreenDisplayer`).
|
||||
#[derive(Default, Debug)]
|
||||
pub struct Displayer {
|
||||
@@ -422,8 +457,9 @@ pub fn light_line(sky: u8, block: u8) -> String {
|
||||
}
|
||||
|
||||
/// What the process holds, for `DebugEntryMemory` (Horizon has no JVM heap):
|
||||
/// anonymous resident memory for the heap's used part, all resident memory
|
||||
/// for its committed part, the machine's memory for its maximum.
|
||||
/// anonymous (on Windows, private) resident memory for the heap's used part,
|
||||
/// all resident memory for its committed part, the machine's memory for its
|
||||
/// maximum.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ProcessMemory {
|
||||
pub used: u64,
|
||||
@@ -432,14 +468,20 @@ pub struct ProcessMemory {
|
||||
}
|
||||
|
||||
impl ProcessMemory {
|
||||
/// From `/proc/self/status` and `/proc/meminfo`; `None` elsewhere.
|
||||
/// From `/proc/self/status` and `/proc/meminfo` on Linux, the process's
|
||||
/// counters on Windows; `None` elsewhere.
|
||||
pub fn read() -> Option<Self> {
|
||||
if !cfg!(target_os = "linux") {
|
||||
return None;
|
||||
if cfg!(target_os = "linux") {
|
||||
let status = std::fs::read_to_string("/proc/self/status").ok()?;
|
||||
let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?;
|
||||
return Self::parse(&status, &meminfo);
|
||||
}
|
||||
let status = std::fs::read_to_string("/proc/self/status").ok()?;
|
||||
let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?;
|
||||
Self::parse(&status, &meminfo)
|
||||
let [used, allocated, max] = horizon_threads::process_memory::usage()?;
|
||||
Some(Self {
|
||||
used,
|
||||
allocated,
|
||||
max: max.max(1),
|
||||
})
|
||||
}
|
||||
fn parse(status: &str, meminfo: &str) -> Option<Self> {
|
||||
let kib = |text: &str, key: &str| -> Option<u64> {
|
||||
@@ -510,6 +552,38 @@ mod tests {
|
||||
v.iter().map(|s| (*s).to_owned()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn painted_vertices_are_reused_until_stale_or_laid_out_again() {
|
||||
let vertex = HudVertex {
|
||||
pos: [1.0, 2.0],
|
||||
uv: [0.0; 2],
|
||||
color: 7,
|
||||
depth: 0.0,
|
||||
glint_uv: [0.0; 2],
|
||||
text: 1,
|
||||
};
|
||||
let layout = ([1920, 1080], 2.0f32.to_bits(), 5);
|
||||
let start = Instant::now();
|
||||
let mut cache = PaintCache::default();
|
||||
assert!(cache.fresh(start, layout).is_none());
|
||||
cache.store(start, layout, &[vertex]);
|
||||
let just_before = start + Duration::from_millis(49);
|
||||
assert_eq!(cache.fresh(just_before, layout).map(<[_]>::len), Some(1));
|
||||
// Stale after a twentieth of a second, so the stats keep moving.
|
||||
assert!(cache.fresh(start + REPAINT, layout).is_none());
|
||||
// Another window size, GUI scale or glyph layout repaints at once.
|
||||
for other in [
|
||||
([1280, 720], layout.1, layout.2),
|
||||
(layout.0, 3.0f32.to_bits(), layout.2),
|
||||
(layout.0, layout.1, 6),
|
||||
] {
|
||||
assert!(cache.fresh(just_before, other).is_none());
|
||||
}
|
||||
// A smaller repaint leaves nothing of the last one behind.
|
||||
cache.store(start, layout, &[]);
|
||||
assert_eq!(cache.fresh(start, layout).map(<[_]>::len), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looking_at_lines_match_vanilla() {
|
||||
let props = [
|
||||
@@ -611,6 +685,20 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn the_memory_lines_show_on_windows() {
|
||||
let m = ProcessMemory::read().expect("Windows reports the process's memory");
|
||||
assert!(
|
||||
0 < m.used && m.used <= m.allocated && m.allocated <= m.max,
|
||||
"{m:?}"
|
||||
);
|
||||
let lines = MemoryMeter::default()
|
||||
.lines()
|
||||
.expect("the meter reads the same counters");
|
||||
assert!(lines[0].starts_with("Mem: "), "{lines:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_follow_the_vanilla_split() {
|
||||
// The default profile's entries, sorted by identifier as
|
||||
|
||||
@@ -369,7 +369,8 @@ impl crate::entity_rules::EntityRules for ArmorStand {
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct ItemStep {
|
||||
old_velocity: Vec3,
|
||||
moved: bool,
|
||||
movement: Option<Vec3>,
|
||||
clipped_vertical: bool,
|
||||
}
|
||||
#[cfg(test)]
|
||||
impl ItemEntity {
|
||||
@@ -585,10 +586,35 @@ impl ItemEntity {
|
||||
facts: &BlockFacts,
|
||||
collision: &BlockCollisionRegistry<'_>,
|
||||
no_physics: bool,
|
||||
(water_height, lava_height): (f64, f64),
|
||||
heights: (f64, f64),
|
||||
entity_boxes: &[Aabb],
|
||||
border: Option<&BorderGeometry>,
|
||||
blocks: Option<&horizon_world::movement_properties::BoundMovementProperties>,
|
||||
) -> ItemStep {
|
||||
let step = self.tick_move_before_block_effects(
|
||||
world,
|
||||
facts,
|
||||
collision,
|
||||
no_physics,
|
||||
heights,
|
||||
entity_boxes,
|
||||
border,
|
||||
);
|
||||
self.finish_move_block_effects(world, facts, no_physics, blocks, step);
|
||||
step
|
||||
}
|
||||
|
||||
/// Resolve the item's move before the owner handles block contact and
|
||||
/// resumes the normal block effects.
|
||||
pub fn tick_move_before_block_effects(
|
||||
&mut self,
|
||||
world: &World,
|
||||
facts: &BlockFacts,
|
||||
collision: &BlockCollisionRegistry<'_>,
|
||||
no_physics: bool,
|
||||
(water_height, lava_height): (f64, f64),
|
||||
entity_boxes: &[Aabb],
|
||||
border: Option<&BorderGeometry>,
|
||||
) -> ItemStep {
|
||||
self.ticks += 1;
|
||||
self.previous = self.position;
|
||||
@@ -686,22 +712,10 @@ impl ItemEntity {
|
||||
slime,
|
||||
);
|
||||
}
|
||||
// Entity.move's last step: soul sand and honey slow the item's
|
||||
// horizontal speed. A move through blocks (noPhysics) returns
|
||||
// before it. `blocks` is None only for the isolated unit tests.
|
||||
if !no_physics && let Some(blocks) = blocks {
|
||||
let factor =
|
||||
crate::motion::block_speed_factor_at(world, blocks, self.position, None);
|
||||
self.velocity =
|
||||
horizon_world::entity_move::scale_by_block_speed(self.velocity, factor);
|
||||
}
|
||||
// Entity.move -> applyEffectsFromBlocks: honey then bubble columns
|
||||
// (before air drag), matching ItemEntity.tick.
|
||||
self.apply_honey_slide(world, facts);
|
||||
self.stuck = self.stuck_multiplier(world, facts).unwrap_or(Vec3::ZERO);
|
||||
return ItemStep {
|
||||
old_velocity,
|
||||
moved: true,
|
||||
movement: Some(moved),
|
||||
clipped_vertical: !no_physics && moved.y != wanted.y,
|
||||
};
|
||||
}
|
||||
// applyEffectsFromBlocksForLastMovements: still lift/drag while idle.
|
||||
@@ -710,7 +724,67 @@ impl ItemEntity {
|
||||
}
|
||||
ItemStep {
|
||||
old_velocity,
|
||||
moved: false,
|
||||
movement: None,
|
||||
clipped_vertical: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The block at `Entity.getOnPosLegacy` for this tick's clipped move.
|
||||
/// Idle ticks retain the entity's collision flags but have no contact.
|
||||
pub fn vertical_contact(
|
||||
&self,
|
||||
world: &World,
|
||||
collision: &BlockCollisionRegistry<'_>,
|
||||
blocks: &horizon_world::movement_properties::BoundMovementProperties,
|
||||
step: ItemStep,
|
||||
) -> Option<BlockPos> {
|
||||
if !step.clipped_vertical {
|
||||
return None;
|
||||
}
|
||||
let mut scene = SceneCollisions {
|
||||
world,
|
||||
binding: collision,
|
||||
subject: None,
|
||||
};
|
||||
let mut ground = horizon_world::ground_support::GroundState::default();
|
||||
ground
|
||||
.check_support(self.on_ground, self.bounds(), step.movement, |bounds| {
|
||||
horizon_world::ground_support::find_supporting_block(
|
||||
&mut scene,
|
||||
horizon_world::collision_context::CollisionContext::empty(false),
|
||||
self.position,
|
||||
bounds,
|
||||
1_000_000,
|
||||
)
|
||||
})
|
||||
.ok()?;
|
||||
Some(crate::motion::legacy_block_support_at(
|
||||
world,
|
||||
blocks,
|
||||
self.position,
|
||||
ground.supporting_block,
|
||||
))
|
||||
}
|
||||
|
||||
/// Resume the normal block effects after the collision response.
|
||||
pub fn finish_move_block_effects(
|
||||
&mut self,
|
||||
world: &World,
|
||||
facts: &BlockFacts,
|
||||
no_physics: bool,
|
||||
blocks: Option<&horizon_world::movement_properties::BoundMovementProperties>,
|
||||
step: ItemStep,
|
||||
) {
|
||||
if step.movement.is_some() {
|
||||
// Entity.move applies the block's speed factor before block effects.
|
||||
if !no_physics && let Some(blocks) = blocks {
|
||||
let factor =
|
||||
crate::motion::block_speed_factor_at(world, blocks, self.position, None);
|
||||
self.velocity =
|
||||
horizon_world::entity_move::scale_by_block_speed(self.velocity, factor);
|
||||
}
|
||||
self.apply_honey_slide(world, facts);
|
||||
self.stuck = self.stuck_multiplier(world, facts).unwrap_or(Vec3::ZERO);
|
||||
}
|
||||
}
|
||||
/// The rest of `ItemEntity.tick`, after the bubble column's push: slime,
|
||||
@@ -724,7 +798,7 @@ impl ItemEntity {
|
||||
no_physics: bool,
|
||||
step: ItemStep,
|
||||
) -> bool {
|
||||
if step.moved {
|
||||
if step.movement.is_some() {
|
||||
// Entity.applyEffectsFromBlocks -> SlimeBlock.stepOn, with the
|
||||
// vertical speed the bounce just left (before the air drag).
|
||||
if !no_physics {
|
||||
|
||||
@@ -52,8 +52,12 @@ pub fn motion(tag: &Tag) -> Vec3 {
|
||||
pub fn rotation(tag: &Tag) -> [f32; 2] {
|
||||
read_doubles::<2>(tag.get("Rotation")).map_or([0.0; 2], |r| [r[0] as f32, r[1] as f32])
|
||||
}
|
||||
/// `NumericTag.byteValue`'s nonzero test for entity NBT, defaulting to false.
|
||||
pub fn flag(tag: &Tag, key: &str) -> bool {
|
||||
number(tag.get(key)).unwrap_or(0) != 0
|
||||
// NBT floats floor before int narrowing; the final byte keeps the low bits.
|
||||
tag.get(key)
|
||||
.and_then(int_value)
|
||||
.is_some_and(|value| value as i8 != 0)
|
||||
}
|
||||
/// `Entity.saveWithoutId` fields every entity has, over the loaded compound.
|
||||
struct Base<'a> {
|
||||
@@ -103,6 +107,25 @@ impl Base<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.saveWithoutId`'s common fields for entities Horizon keeps as raw
|
||||
/// compounds: each one is inserted only when the loaded compound has no value
|
||||
/// for it, so explicit values survive.
|
||||
pub fn fill_common_defaults(tag: &mut Tag) {
|
||||
let Tag::Compound(map) = tag else {
|
||||
return;
|
||||
};
|
||||
for (key, value) in [
|
||||
("Motion", doubles(&[0.0, 0.0, 0.0])),
|
||||
("OnGround", Tag::Byte(0)),
|
||||
("fall_distance", Tag::Double(0.0f64.to_bits())),
|
||||
("Air", Tag::Short(300)),
|
||||
("Invulnerable", Tag::Byte(0)),
|
||||
("PortalCooldown", Tag::Int(0)),
|
||||
] {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// The mobs that save `Age` and `AgeLocked`.
|
||||
fn saves_age(s: crate::mobs::Species) -> bool {
|
||||
use crate::mobs::{Species, drops::can_be_baby, finalize::ageable};
|
||||
@@ -469,8 +492,19 @@ pub fn mob_to_nbt(
|
||||
-1
|
||||
}),
|
||||
);
|
||||
if let Some(uuid) = m.extra.angry_at.and_then(|who| who_uuid(who, &mut uuid_of)) {
|
||||
put(&mut tag, "angry_at", Tag::IntArray(uuid.to_vec()));
|
||||
// `angry_at` only while there is an anger target: the UUID of the one
|
||||
// the level has (`uuid_of`). A mob target read from the save that is
|
||||
// not in the world (`Who::Mob(0)`) keeps the loaded UUID.
|
||||
match m.extra.angry_at {
|
||||
Some(crate::mobs::Who::Mob(0)) => {}
|
||||
target => match target.and_then(|who| who_uuid(who, &mut uuid_of)) {
|
||||
Some(uuid) => put(&mut tag, "angry_at", Tag::IntArray(uuid.to_vec())),
|
||||
None => {
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
map.remove(&"angry_at".into());
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
// `Mannequin.addAdditionalSaveData`: the profile whose skin it wears, whether it
|
||||
@@ -618,8 +652,23 @@ pub fn mob_to_nbt(
|
||||
// Animal.addAdditionalSaveData: the player who fed it.
|
||||
if let Some(cause) = m.extra.love_cause {
|
||||
map.insert("LoveCause".into(), Tag::IntArray(cause.to_vec()));
|
||||
} else {
|
||||
map.remove(&"LoveCause".into());
|
||||
}
|
||||
}
|
||||
// InventoryCarrier.writeInventoryToTag: the occupied stacks, even when empty.
|
||||
if matches!(id, "minecraft:pillager" | "minecraft:wandering_trader") {
|
||||
map.insert(
|
||||
"Inventory".into(),
|
||||
Tag::List(
|
||||
m.extra
|
||||
.inventory
|
||||
.iter()
|
||||
.map(crate::save::item_to_nbt)
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
}
|
||||
let species = m.species();
|
||||
// `VariantUtils.writeVariant`: only the species that store the name. A
|
||||
// mooshroom saves `Type` (below), a fox `Type`, a horse and a llama a
|
||||
@@ -709,7 +758,10 @@ pub fn mob_to_nbt(
|
||||
}
|
||||
"minecraft:creeper" => {
|
||||
map.insert("Fuse".into(), Tag::Short(m.max_swell as i16));
|
||||
map.entry("ExplosionRadius".into()).or_insert(Tag::Byte(3));
|
||||
map.insert(
|
||||
"ExplosionRadius".into(),
|
||||
Tag::Byte(m.extra.explosion_radius),
|
||||
);
|
||||
map.insert("powered".into(), Tag::Byte(i8::from(m.extra.charged)));
|
||||
map.insert("ignited".into(), Tag::Byte(i8::from(m.extra.flag)));
|
||||
}
|
||||
@@ -1103,6 +1155,11 @@ pub fn mob_to_nbt(
|
||||
map.insert("Peek".into(), Tag::Byte(m.extra.counter as i8));
|
||||
map.insert("Color".into(), Tag::Byte(m.extra.color as i8));
|
||||
}
|
||||
// HappyGhast.addAdditionalSaveData: the still timeout (`serverStillTimeout`,
|
||||
// kept in `counter`), which vanilla writes even at 0.
|
||||
"minecraft:happy_ghast" => {
|
||||
map.insert("still_timeout".into(), Tag::Int(m.extra.counter));
|
||||
}
|
||||
// GlowSquid.addAdditionalSaveData.
|
||||
"minecraft:glow_squid" => {
|
||||
map.insert(
|
||||
@@ -1364,6 +1421,11 @@ pub fn mob_to_nbt(
|
||||
}
|
||||
}
|
||||
}
|
||||
// LivingEntity saves the actual saddle equipment even on a mob whose
|
||||
// Survival interaction does not offer saddling (for example a husk).
|
||||
if let Some(saddle) = m.saddle_item() {
|
||||
equipment.insert("saddle".into(), crate::save::item_to_nbt(&saddle));
|
||||
}
|
||||
// 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).
|
||||
@@ -1699,12 +1761,13 @@ pub fn mob_from_nbt(
|
||||
// target come back with the mob.
|
||||
if persistent_anger(m.species()) {
|
||||
m.extra.anger_end = number(tag.get("anger_end_time")).unwrap_or(-1);
|
||||
if tag
|
||||
.get("angry_at")
|
||||
.is_some_and(|t| uuid_from_tag(Some(t)) == Some(player_uuid()))
|
||||
{
|
||||
m.extra.angry_at = Some(crate::mobs::Who::Player);
|
||||
}
|
||||
// Anger at an entity other than the player is anger at a mob that may
|
||||
// not be here (`EntityReference`): it still is not `isAngryAtAllPlayers`.
|
||||
m.extra.angry_at = match uuid_from_tag(tag.get("angry_at")) {
|
||||
Some(uuid) if uuid == player_uuid() => Some(crate::mobs::Who::Player),
|
||||
Some(_) => Some(crate::mobs::Who::Mob(0)),
|
||||
None => None,
|
||||
};
|
||||
}
|
||||
// `Mannequin.readAdditionalSaveData`: the profile comes back with the skin, then
|
||||
// validated pose, `immovable`, `hide_description` and `description` (`updateDescription`).
|
||||
@@ -1794,6 +1857,23 @@ pub fn mob_from_nbt(
|
||||
// Creeper.readAdditionalSaveData: `ignited` lights the fuse.
|
||||
m.extra.flag = flag(tag, "ignited");
|
||||
m.max_swell = number(tag.get("Fuse")).unwrap_or(30) as i16 as i32;
|
||||
// `getByteOr("ExplosionRadius", 3)`: any numeric tag, as a byte.
|
||||
m.extra.explosion_radius = number(tag.get("ExplosionRadius")).unwrap_or(3) as i8;
|
||||
}
|
||||
// InventoryCarrier.readInventoryFromTag, using the existing occupied-stack model.
|
||||
let carrier_slots = match m.species() {
|
||||
crate::mobs::Species::Pillager => 5,
|
||||
crate::mobs::Species::WanderingTrader => 8,
|
||||
_ => 0,
|
||||
};
|
||||
if carrier_slots != 0
|
||||
&& let Some(Tag::List(items)) = tag.get("Inventory")
|
||||
{
|
||||
m.extra.inventory = items
|
||||
.iter()
|
||||
.filter_map(crate::save::item_from_nbt)
|
||||
.take(carrier_slots)
|
||||
.collect();
|
||||
}
|
||||
if matches!(
|
||||
m.species(),
|
||||
@@ -2183,6 +2263,11 @@ pub fn mob_from_nbt(
|
||||
.and_then(|t| state_from_nbt(registry, t))
|
||||
.filter(|&state| !is_air(registry, state));
|
||||
}
|
||||
// HappyGhast.readAdditionalSaveData: the still timeout, 0 when absent (a fresh
|
||||
// ghast, or one saved by a version that did not write it).
|
||||
if m.species() == crate::mobs::Species::HappyGhast {
|
||||
m.extra.counter = number(tag.get("still_timeout")).unwrap_or(0) as i32;
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Shulker {
|
||||
m.extra.variant3 = number(tag.get("AttachFace")).unwrap_or(0).clamp(0, 5) as u8;
|
||||
m.extra.counter = number(tag.get("Peek")).unwrap_or(0).clamp(0, 100) as i32;
|
||||
@@ -2341,6 +2426,8 @@ pub fn tnt_to_nbt(
|
||||
put(&mut tag, "fuse", Tag::Short(fuse as i16));
|
||||
if power != 4.0 {
|
||||
put(&mut tag, "explosion_power", Tag::Float(power.to_bits()));
|
||||
} else if let Tag::Compound(map) = &mut tag {
|
||||
map.remove(&"explosion_power".into());
|
||||
}
|
||||
put(&mut tag, "block_state", block_state);
|
||||
if let Some(owner) = owner {
|
||||
@@ -2380,6 +2467,14 @@ pub fn evoker_fangs_to_nbt(
|
||||
tag
|
||||
}
|
||||
|
||||
/// `UUIDUtil.CODEC` via `NbtOps.getIntStream`: boxed Number conversions,
|
||||
/// with the first-four partial value retained by `TagValueInput.read`.
|
||||
fn item_uuid_from_tag(tag: Option<&Tag>) -> Option<[i32; 4]> {
|
||||
let values = horizon_world::datafix::dynamic::as_int_stream(tag)?;
|
||||
values.get(..4)?.try_into().ok()
|
||||
}
|
||||
|
||||
/// `ItemEntity.readAdditionalSaveData`: Owner and Thrower use the numeric UUID codec.
|
||||
pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemEntity> {
|
||||
let stack = item_from_nbt(tag.get("Item")?)?;
|
||||
let position = position(tag)?;
|
||||
@@ -2391,8 +2486,8 @@ pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemE
|
||||
fire_ticks: number(tag.get("Fire")).unwrap_or(0) as i32,
|
||||
id,
|
||||
stack,
|
||||
thrower: uuid_from_tag(tag.get("Thrower")),
|
||||
target: uuid_from_tag(tag.get("Owner")),
|
||||
thrower: item_uuid_from_tag(tag.get("Thrower")),
|
||||
target: item_uuid_from_tag(tag.get("Owner")),
|
||||
position,
|
||||
previous: position,
|
||||
velocity: motion(tag),
|
||||
@@ -2629,8 +2724,9 @@ pub fn arrow_from_nbt(
|
||||
Some((p, tag.get("inBlockState").cloned()))
|
||||
}
|
||||
|
||||
/// `LargeFireball` and `SmallFireball`: `AbstractHurtingProjectile`'s
|
||||
/// `acceleration_power`, `Projectile`'s owner and flags, `ExplosionPower`.
|
||||
/// `LargeFireball`, `SmallFireball` and `WitherSkull`: `AbstractHurtingProjectile`'s
|
||||
/// `acceleration_power`, `Projectile`'s owner and flags, `ExplosionPower`,
|
||||
/// and a skull's `dangerous`.
|
||||
pub fn fireball_to_nbt(
|
||||
p: &Projectile,
|
||||
uuid: [i32; 4],
|
||||
@@ -2641,7 +2737,8 @@ pub fn fireball_to_nbt(
|
||||
ProjectileKind::LargeFireball { power } => Some(power),
|
||||
ProjectileKind::SmallFireball
|
||||
| ProjectileKind::WindCharge
|
||||
| ProjectileKind::BreezeWindCharge => None,
|
||||
| ProjectileKind::BreezeWindCharge
|
||||
| ProjectileKind::WitherSkull { .. } => None,
|
||||
_ => return None,
|
||||
};
|
||||
let base = Base {
|
||||
@@ -2661,14 +2758,19 @@ pub fn fireball_to_nbt(
|
||||
);
|
||||
put_projectile(&mut tag, p, owner);
|
||||
// AbstractHurtingProjectile.tick: shouldBurn is true for both fireballs, so
|
||||
// once ticked they igniteForSeconds(1) every tick.
|
||||
if p.age > 0 && !p.kind.ignores_blast() {
|
||||
// once ticked they igniteForSeconds(1) every tick. WitherSkull.shouldBurn is
|
||||
// false, so a skull never saves a fire.
|
||||
if p.age > 0 && p.kind.is_item_fireball() {
|
||||
let fire = number(tag.get("Fire")).unwrap_or(0).max(20);
|
||||
put(&mut tag, "Fire", Tag::Short(fire as i16));
|
||||
}
|
||||
if let Some(power) = power {
|
||||
put(&mut tag, "ExplosionPower", Tag::Byte(power as i8));
|
||||
}
|
||||
// WitherSkull.addAdditionalSaveData: `dangerous`, false for a black skull.
|
||||
if let ProjectileKind::WitherSkull { dangerous } = p.kind {
|
||||
put(&mut tag, "dangerous", Tag::Byte(i8::from(dangerous)));
|
||||
}
|
||||
// Fireball.addAdditionalSaveData: the item `getItem` returns, a fire
|
||||
// charge unless `setItem` was given another (kept as a single one).
|
||||
if let Some(item) = &p.item {
|
||||
@@ -2747,6 +2849,7 @@ pub fn saved_with_chunk(p: &Projectile) -> bool {
|
||||
| ProjectileKind::SmallFireball
|
||||
| ProjectileKind::WindCharge
|
||||
| ProjectileKind::BreezeWindCharge
|
||||
| ProjectileKind::WitherSkull { .. }
|
||||
| ProjectileKind::Snowball
|
||||
| ProjectileKind::Egg
|
||||
| ProjectileKind::EnderPearl
|
||||
@@ -2761,8 +2864,7 @@ pub fn saved_with_chunk(p: &Projectile) -> bool {
|
||||
ProjectileKind::Trident
|
||||
| ProjectileKind::ShulkerBullet {
|
||||
target: Some(_), ..
|
||||
}
|
||||
| ProjectileKind::WitherSkull { .. } => false,
|
||||
} => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2896,6 +2998,10 @@ pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projecti
|
||||
"minecraft:small_fireball" => ProjectileKind::SmallFireball,
|
||||
"minecraft:wind_charge" => ProjectileKind::WindCharge,
|
||||
"minecraft:breeze_wind_charge" => ProjectileKind::BreezeWindCharge,
|
||||
// WitherSkull.readAdditionalSaveData: an absent `dangerous` is a black skull.
|
||||
"minecraft:wither_skull" => ProjectileKind::WitherSkull {
|
||||
dangerous: flag(tag, "dangerous"),
|
||||
},
|
||||
_ => return None,
|
||||
};
|
||||
let owner = uuid_from_tag(tag.get("Owner"));
|
||||
@@ -3746,6 +3852,58 @@ mod tnt_power_tests {
|
||||
assert!(plain.get("explosion_power").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_power_uses_the_model_and_omits_stale_defaults() {
|
||||
let block = string("minecraft:stone");
|
||||
let save = |power, raw| {
|
||||
tnt_to_nbt(
|
||||
Vec3::new(1.5, 200.0, 4.5),
|
||||
Vec3::ZERO,
|
||||
false,
|
||||
80,
|
||||
power,
|
||||
block.clone(),
|
||||
[0.0; 2],
|
||||
[91, 0, 0, 1],
|
||||
Some([91, 1, 0, 90]),
|
||||
raw,
|
||||
)
|
||||
};
|
||||
let raw_absent = Tag::compound([("Silent", Tag::Byte(1))]);
|
||||
let raw_default = Tag::compound([
|
||||
("Silent", Tag::Byte(1)),
|
||||
("explosion_power", Tag::Float(4.0_f32.to_bits())),
|
||||
]);
|
||||
let raw_stale = Tag::compound([
|
||||
("Silent", Tag::Byte(1)),
|
||||
("explosion_power", Tag::Float(2.0_f32.to_bits())),
|
||||
]);
|
||||
for raw in [
|
||||
None,
|
||||
Some(&raw_absent),
|
||||
Some(&raw_default),
|
||||
Some(&raw_stale),
|
||||
] {
|
||||
let saved = save(4.0, raw);
|
||||
assert!(saved.get("explosion_power").is_none(), "stale raw {raw:?}");
|
||||
assert_eq!(saved.get("fuse"), Some(&Tag::Short(80)));
|
||||
assert_eq!(saved.get("block_state"), Some(&block));
|
||||
assert_eq!(saved.get("owner"), Some(&Tag::IntArray(vec![91, 1, 0, 90])));
|
||||
assert_eq!(saved.get("UUID"), Some(&Tag::IntArray(vec![91, 0, 0, 1])));
|
||||
if raw.is_some() {
|
||||
assert_eq!(saved.get("Silent"), Some(&Tag::Byte(1)));
|
||||
}
|
||||
}
|
||||
for power in [0.0, 2.0, 5.0, 128.0] {
|
||||
for raw in [None, Some(&raw_default), Some(&raw_stale)] {
|
||||
assert_eq!(
|
||||
save(power, raw).get("explosion_power"),
|
||||
Some(&Tag::Float(power.to_bits()))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `EntityReference.store(owner, output, "owner")`: the igniter's UUID as
|
||||
/// an int array, read back by `EntityReference.read`; an owner the save
|
||||
/// could not name keeps the loaded one from the raw compound.
|
||||
@@ -4095,6 +4253,52 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #11332: `Creeper.readAdditionalSaveData` reads `ExplosionRadius` as a
|
||||
/// byte (default 3), `explodeCreeper` blows with it, twice it when powered,
|
||||
/// and `addAdditionalSaveData` writes the value back as a byte.
|
||||
#[test]
|
||||
fn creeper_explosion_radius_nbt_sizes_the_blast_and_saves() {
|
||||
let blast = |mob: &mut Mob| -> f32 {
|
||||
let mut events = Vec::new();
|
||||
mob.max_swell = 1;
|
||||
crate::mobs::hostile::creeper_tick(mob, &mut events);
|
||||
events
|
||||
.iter()
|
||||
.find_map(|e| match e {
|
||||
crate::mobs::MobEvent::Explode(_, radius, _) => Some(*radius),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the creeper exploded")
|
||||
};
|
||||
for (radius, powered, expected) in [
|
||||
(None, false, 3.0),
|
||||
(None, true, 6.0),
|
||||
(Some(Tag::Byte(12)), false, 12.0),
|
||||
(Some(Tag::Byte(12)), true, 24.0),
|
||||
(Some(Tag::Byte(0)), false, 0.0),
|
||||
// getByteOr takes any numeric tag, narrowed to a byte.
|
||||
(Some(Tag::Long(300)), false, 44.0),
|
||||
] {
|
||||
let (mut mob, raw) = fused_creeper(None);
|
||||
let mut tag = raw.clone();
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
if let Some(radius) = radius.clone() {
|
||||
c.insert("ExplosionRadius".into(), radius);
|
||||
}
|
||||
c.insert("powered".into(), Tag::Byte(i8::from(powered)));
|
||||
}
|
||||
let types = [("minecraft:creeper".into(), mob.kind.clone())].into();
|
||||
mob = mob_from_nbt(&tag, &types, 1, None).unwrap();
|
||||
let saved = mob_to_nbt(&mob, [0; 4], Some(&raw), None, |_| None);
|
||||
assert_eq!(
|
||||
saved.get("ExplosionRadius"),
|
||||
Some(&Tag::Byte(expected as i8 / if powered { 2 } else { 1 })),
|
||||
"{radius:?} powered={powered}"
|
||||
);
|
||||
assert_eq!(blast(&mut mob), expected, "{radius:?} powered={powered}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creeper_fall_swell_uses_custom_fuse_without_a_zero_floor() {
|
||||
for (fuse, expected) in [(Some(1), -4), (None, 25), (Some(40), 35)] {
|
||||
@@ -4469,6 +4673,149 @@ mod tests {
|
||||
assert_eq!(legacy.pickup_delay, i64::from(u32::MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_numeric_uuid_codec_adopts_numeric_streams_before_save() {
|
||||
let cases = [
|
||||
(Tag::IntArray(vec![54, 1, -2, 3]), [54, 1, -2, 3]),
|
||||
(
|
||||
Tag::LongArray(vec![
|
||||
54,
|
||||
4_294_967_297,
|
||||
-4_294_967_298,
|
||||
9_007_199_254_740_993,
|
||||
]),
|
||||
[54, 1, -2, 1],
|
||||
),
|
||||
(
|
||||
Tag::List(vec![
|
||||
Tag::Float(54.9_f32.to_bits()),
|
||||
Tag::Float(1.75_f32.to_bits()),
|
||||
Tag::Float((-2.75_f32).to_bits()),
|
||||
Tag::Float((-0.5_f32).to_bits()),
|
||||
]),
|
||||
[54, 1, -2, 0],
|
||||
),
|
||||
(
|
||||
Tag::List(vec![
|
||||
Tag::Double(54.9_f64.to_bits()),
|
||||
Tag::Double(1.75_f64.to_bits()),
|
||||
Tag::Double((-2.75_f64).to_bits()),
|
||||
Tag::Double((-0.5_f64).to_bits()),
|
||||
]),
|
||||
[54, 1, -2, 0],
|
||||
),
|
||||
(
|
||||
Tag::List(vec![
|
||||
Tag::Long(54),
|
||||
Tag::Long(4_294_967_297),
|
||||
Tag::Long(-4_294_967_298),
|
||||
Tag::Long(9_007_199_254_740_993),
|
||||
]),
|
||||
[54, 1, -2, 1],
|
||||
),
|
||||
(
|
||||
Tag::ByteArray(vec![54, 1, (-2_i8) as u8, 3]),
|
||||
[54, 1, -2, 3],
|
||||
),
|
||||
// Util.fixedSize returns a partial first-four result for overlong streams.
|
||||
(Tag::IntArray(vec![54, 1, -2, 3, 4]), [54, 1, -2, 3]),
|
||||
];
|
||||
for field in ["Owner", "Thrower"] {
|
||||
for (value, expected) in &cases {
|
||||
let raw = Tag::compound([
|
||||
("Pos", doubles(&[2.5, 200.0, 2.5])),
|
||||
(
|
||||
"Item",
|
||||
Tag::compound([("id", string("minecraft:stone")), ("count", Tag::Int(1))]),
|
||||
),
|
||||
("CustomName", string("kept")),
|
||||
(field, value.clone()),
|
||||
]);
|
||||
let mut item = item_entity_from_nbt(&raw, 7, 0.0).unwrap();
|
||||
assert_eq!(
|
||||
if field == "Owner" {
|
||||
item.target
|
||||
} else {
|
||||
item.thrower
|
||||
},
|
||||
Some(*expected),
|
||||
"{field}: {value:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
if field == "Owner" {
|
||||
item.thrower
|
||||
} else {
|
||||
item.target
|
||||
},
|
||||
None
|
||||
);
|
||||
for source in [None, Some(&raw)] {
|
||||
let saved = item_entity_to_nbt(&item, [54, 0, 0, 1], source);
|
||||
assert_eq!(saved.get(field), Some(&Tag::IntArray(expected.to_vec())));
|
||||
assert_eq!(saved.get("Item").unwrap().get("count"), Some(&Tag::Int(1)));
|
||||
}
|
||||
let saved = item_entity_to_nbt(&item, [54, 0, 0, 1], Some(&raw));
|
||||
assert_eq!(saved.get("CustomName"), raw.get("CustomName"));
|
||||
item.target = None;
|
||||
item.thrower = None;
|
||||
let cleared = item_entity_to_nbt(&item, [54, 0, 0, 1], Some(&saved));
|
||||
assert!(cleared.get("Owner").is_none());
|
||||
assert!(cleared.get("Thrower").is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_numeric_uuid_codec_rejects_nonstreams_and_short_inputs() {
|
||||
let cases = [
|
||||
None,
|
||||
Some(string("00000036-0000-0001-ffff-fffe00000003")),
|
||||
Some(Tag::IntArray(vec![])),
|
||||
Some(Tag::IntArray(vec![54, 1, -2])),
|
||||
Some(Tag::Int(7)),
|
||||
Some(Tag::compound([("value", Tag::Int(7))])),
|
||||
Some(Tag::List(vec![
|
||||
Tag::Int(54),
|
||||
Tag::Int(1),
|
||||
Tag::Int(-2),
|
||||
string("3"),
|
||||
])),
|
||||
];
|
||||
for field in ["Owner", "Thrower"] {
|
||||
for value in &cases {
|
||||
let mut raw = Tag::compound([
|
||||
("Pos", doubles(&[2.5, 200.0, 2.5])),
|
||||
(
|
||||
"Item",
|
||||
Tag::compound([("id", string("minecraft:stone")), ("count", Tag::Int(1))]),
|
||||
),
|
||||
]);
|
||||
if let Some(value) = value {
|
||||
put(&mut raw, field, value.clone());
|
||||
}
|
||||
let mut item = item_entity_from_nbt(&raw, 7, 0.0).unwrap();
|
||||
assert_eq!(
|
||||
(item.target, item.thrower),
|
||||
(None, None),
|
||||
"{field}: {value:?}"
|
||||
);
|
||||
for source in [None, Some(&raw)] {
|
||||
let saved = item_entity_to_nbt(&item, [54, 0, 0, 1], source);
|
||||
assert!(saved.get("Owner").is_none());
|
||||
assert!(saved.get("Thrower").is_none());
|
||||
}
|
||||
item.target = Some([54, 1, -2, 3]);
|
||||
item.thrower = Some([54, 4, 5, 6]);
|
||||
let saved = item_entity_to_nbt(&item, [54, 0, 0, 1], Some(&raw));
|
||||
assert_eq!(saved.get("Owner"), Some(&Tag::IntArray(vec![54, 1, -2, 3])));
|
||||
assert_eq!(
|
||||
saved.get("Thrower"),
|
||||
Some(&Tag::IntArray(vec![54, 4, 5, 6]))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_entities_round_trip() {
|
||||
let e = ItemEntity {
|
||||
@@ -4517,6 +4864,76 @@ mod tests {
|
||||
let cleared = item_entity_to_nbt(&cleared, [1, 2, 3, 4], Some(&tag));
|
||||
assert!(cleared.get("Thrower").is_none());
|
||||
}
|
||||
#[test]
|
||||
fn nbt_numeric_flags_decode_and_save_modeled_values_without_raw() {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let def = Arc::make_mut(&mut kind);
|
||||
def.id = "minecraft:pig".into();
|
||||
def.species = crate::mobs::Species::Pig;
|
||||
let types = [("minecraft:pig".into(), kind)].into();
|
||||
for (input, expected) in [
|
||||
(None, false),
|
||||
(Some(Tag::Byte(0)), false),
|
||||
(Some(Tag::Byte(1)), true),
|
||||
(Some(Tag::Byte(-1)), true),
|
||||
(Some(Tag::Short(256)), false),
|
||||
(Some(Tag::Int(256)), false),
|
||||
(Some(Tag::Int(257)), true),
|
||||
(Some(Tag::Int(-256)), false),
|
||||
(Some(Tag::Long(256)), false),
|
||||
(Some(Tag::Long(257)), true),
|
||||
(Some(Tag::Long(4294967296)), false),
|
||||
(Some(Tag::Long(9007199254740993)), true),
|
||||
(Some(Tag::Float(0.5f32.to_bits())), false),
|
||||
(Some(Tag::Float((-0.5f32).to_bits())), true),
|
||||
(Some(Tag::Float(256.5f32.to_bits())), false),
|
||||
(Some(Tag::Float((-255.5f32).to_bits())), false),
|
||||
(Some(Tag::Double(0.5f64.to_bits())), false),
|
||||
(Some(Tag::Double((-0.5f64).to_bits())), true),
|
||||
(Some(Tag::Double(256.5f64.to_bits())), false),
|
||||
(Some(Tag::Double((-255.5f64).to_bits())), false),
|
||||
(Some(Tag::Float((-1e20f32).to_bits())), false),
|
||||
(Some(Tag::Double((-1e20f64).to_bits())), false),
|
||||
(Some(Tag::Double(1e20f64.to_bits())), true),
|
||||
(Some(string("1")), false),
|
||||
(Some(string("true")), false),
|
||||
(Some(Tag::compound([("a", Tag::Int(1))])), false),
|
||||
] {
|
||||
let mut raw = Tag::compound([
|
||||
("id", string("minecraft:pig")),
|
||||
("Pos", doubles(&[4.5, 200.0, 4.5])),
|
||||
("Health", Tag::Float(10f32.to_bits())),
|
||||
("NoGravity", Tag::Byte(1)),
|
||||
("Unrelated", string("keep")),
|
||||
]);
|
||||
if let Some(value) = input.as_ref() {
|
||||
for key in ["NoAI", "CanPickUpLoot"] {
|
||||
put(&mut raw, key, value.clone());
|
||||
}
|
||||
}
|
||||
let loaded = mob_from_nbt(&raw, &types, 7, None).unwrap();
|
||||
assert_eq!(loaded.no_ai, expected, "NoAI {input:?}");
|
||||
assert_eq!(loaded.can_pick_up_loot(), expected, "pickup {input:?}");
|
||||
assert!(loaded.no_gravity);
|
||||
for source in [None, Some(&raw)] {
|
||||
let saved = mob_to_nbt(&loaded, [45, 0, 0, 1], source, None, |_| None);
|
||||
assert_eq!(
|
||||
saved.get("NoAI"),
|
||||
expected.then_some(&Tag::Byte(1)),
|
||||
"NoAI {input:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
saved.get("CanPickUpLoot"),
|
||||
Some(&Tag::Byte(i8::from(expected))),
|
||||
"pickup {input:?}"
|
||||
);
|
||||
if source.is_some() {
|
||||
assert_eq!(saved.get("Unrelated"), Some(&string("keep")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animal_integer_loads_save_modeled_values_without_raw_source() {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
@@ -5280,6 +5697,47 @@ mod axolotl_nbt_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `HappyGhast.addAdditionalSaveData` / `readAdditionalSaveData`: the still timeout
|
||||
/// (`serverStillTimeout`) goes with the world. A ghast saved while it was held still
|
||||
/// comes back still, which is what keeps the box a dropped item rests on
|
||||
/// (`EntityGetter.getEntityCollisions`) and the control a rider has
|
||||
/// (`getControllingPassenger` reads `isOnStillTimeout`).
|
||||
#[test]
|
||||
fn a_happy_ghast_keeps_its_still_timeout() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let types: HashMap<_, _> = data
|
||||
.types
|
||||
.into_iter()
|
||||
.map(|(id, kind)| (id, Arc::new(kind)))
|
||||
.collect();
|
||||
let kind = types["minecraft:happy_ghast"].clone();
|
||||
let mut m = Mob::new(1, kind, Vec3::new(0.5, 80.0, 0.5), 0.0);
|
||||
// Past `HappyGhast.tick`'s 60-tick gate, so the timer counts down from here.
|
||||
m.extra.age = 100;
|
||||
for timeout in [0, 7, 10] {
|
||||
m.extra.counter = timeout;
|
||||
let saved = mob_to_nbt(&m, [1; 4], None, None, |_| None);
|
||||
assert_eq!(
|
||||
number(saved.get("still_timeout")),
|
||||
Some(i64::from(timeout)),
|
||||
"{timeout}"
|
||||
);
|
||||
let back = mob_from_nbt(&saved, &types, 2, None).unwrap();
|
||||
assert_eq!(back.extra.counter, timeout, "{timeout}");
|
||||
assert_eq!(
|
||||
crate::mobs::happy_ghast::on_still_timeout(&back),
|
||||
timeout > 0,
|
||||
"{timeout}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_cooldown_and_brain_memories_survive_and_expired_cooldown_is_removed() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
@@ -6387,6 +6845,38 @@ mod item_component_tests {
|
||||
#[cfg(test)]
|
||||
mod fireball_nbt_tests {
|
||||
use super::*;
|
||||
|
||||
/// `WitherSkull`: a skull in flight is saved with its chunk, `dangerous`
|
||||
/// is a byte (absent means a black skull), and it never saves a fire
|
||||
/// because `WitherSkull.shouldBurn` is false.
|
||||
#[test]
|
||||
fn a_wither_skull_keeps_whether_it_is_dangerous() {
|
||||
let blue = Tag::compound([
|
||||
("id", Tag::String("minecraft:wither_skull".into())),
|
||||
("Pos", doubles(&[3.0, 64.0, 3.0])),
|
||||
("Motion", doubles(&[0.0, 0.0, 0.0])),
|
||||
("dangerous", Tag::Byte(1)),
|
||||
]);
|
||||
let mut skull = fireball_from_nbt(&blue, None).unwrap();
|
||||
assert_eq!(skull.kind, ProjectileKind::WitherSkull { dangerous: true });
|
||||
assert!(saved_with_chunk(&skull));
|
||||
skull.age = 5;
|
||||
let back = fireball_to_nbt(&skull, [1, 2, 3, 4], None, None).unwrap();
|
||||
assert_eq!(kind(&back).as_deref(), Some("minecraft:wither_skull"));
|
||||
assert_eq!(number(back.get("dangerous")), Some(1));
|
||||
// Entity saves Fire every time; a skull's stays 0 (a fireball's is 20 once ticked).
|
||||
assert_eq!(number(back.get("Fire")), Some(0));
|
||||
|
||||
let black = Tag::compound([
|
||||
("id", Tag::String("minecraft:wither_skull".into())),
|
||||
("Pos", doubles(&[0.0, 0.0, 0.0])),
|
||||
]);
|
||||
let skull = fireball_from_nbt(&black, None).unwrap();
|
||||
assert_eq!(skull.kind, ProjectileKind::WitherSkull { dangerous: false });
|
||||
let back = fireball_to_nbt(&skull, [1, 2, 3, 4], None, None).unwrap();
|
||||
assert_eq!(number(back.get("dangerous")), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fireball_round_trips_its_flight() {
|
||||
let raw = Tag::compound([
|
||||
@@ -6857,6 +7347,28 @@ mod neutral_anger_tests {
|
||||
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
|
||||
assert_eq!(back.extra.anger_end, 3000);
|
||||
assert_eq!(back.extra.angry_at, Some(crate::mobs::Who::Player));
|
||||
// An iron golem angry at a monster: the target's UUID comes back as
|
||||
// anger at a mob (not at every player under universal_anger), and is
|
||||
// written back as it was read.
|
||||
let mut golem = Mob::new(4, types["minecraft:iron_golem"].clone(), Vec3::ZERO, 0.0);
|
||||
golem.extra.anger = 400;
|
||||
golem.extra.anger_end = 3000;
|
||||
let mut raw = mob_to_nbt(&golem, [1, 2, 3, 6], None, None, |_| None);
|
||||
if let Tag::Compound(map) = &mut raw {
|
||||
map.insert("angry_at".into(), Tag::IntArray(vec![9, 8, 7, 6]));
|
||||
}
|
||||
let back = mob_from_nbt(&raw, &types, 5, None).unwrap();
|
||||
assert_eq!(back.extra.angry_at, Some(crate::mobs::Who::Mob(0)));
|
||||
let again = mob_to_nbt(&back, [1, 2, 3, 6], Some(&raw), None, |_| None);
|
||||
assert_eq!(
|
||||
crate::save::uuid_from_tag(again.get("angry_at")),
|
||||
Some([9, 8, 7, 6])
|
||||
);
|
||||
// Once the anger is over (`stopBeingAngry`), the loaded target goes.
|
||||
let mut calm = back.clone();
|
||||
calm.extra.angry_at = None;
|
||||
let again = mob_to_nbt(&calm, [1, 2, 3, 6], Some(&raw), None, |_| None);
|
||||
assert!(again.get("angry_at").is_none());
|
||||
// A mob that is no NeutralMob writes no anger.
|
||||
let pig = Mob::new(3, types["minecraft:pig"].clone(), Vec3::ZERO, 0.0);
|
||||
let tag = mob_to_nbt(&pig, [1, 2, 3, 5], None, None, |_| None);
|
||||
@@ -7251,6 +7763,23 @@ mod generic_home_nbt_tests {
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawned_trader_home_restricts_and_survives_save() {
|
||||
let mut m = mob(Species::WanderingTrader, "minecraft:wandering_trader");
|
||||
crate::mobs::trader::start_visit(&mut m, [4, 201, 0]);
|
||||
m.position = Vec3::new(19.5, 201.0, 0.5);
|
||||
assert!(crate::mobs::trader::within_home(&m));
|
||||
m.position = Vec3::new(20.5, 201.0, 0.5);
|
||||
assert!(!crate::mobs::trader::within_home(&m));
|
||||
let saved = mob_to_nbt(&m, [0; 4], None, None, |_| None);
|
||||
assert_eq!(saved.get("home_radius"), Some(&Tag::Int(16)));
|
||||
assert_eq!(saved.get("home_pos"), Some(&Tag::IntArray(vec![4, 201, 0])));
|
||||
let mut loaded = mob(Species::WanderingTrader, "minecraft:wandering_trader");
|
||||
loaded.position = m.position;
|
||||
read_mob_home(&mut loaded, &saved);
|
||||
assert!(!crate::mobs::trader::within_home(&loaded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_home_output_uses_model_and_removes_stale_raw_fields() {
|
||||
let mut m = mob(Species::Pig, "minecraft:pig");
|
||||
@@ -7555,4 +8084,231 @@ mod stand_fall_save_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.saveWithoutId`'s common fields are filled in when a raw compound
|
||||
/// (a marker's) lacks them, and explicit values win.
|
||||
#[test]
|
||||
fn marker_common_fields_default_and_keep_explicit_values() {
|
||||
let mut tag = Tag::compound([("Air", Tag::Short(10)), ("OnGround", Tag::Byte(1))]);
|
||||
fill_common_defaults(&mut tag);
|
||||
assert_eq!(tag.get("Air"), Some(&Tag::Short(10)));
|
||||
assert_eq!(tag.get("OnGround"), Some(&Tag::Byte(1)));
|
||||
assert_eq!(tag.get("Invulnerable"), Some(&Tag::Byte(0)));
|
||||
assert_eq!(tag.get("PortalCooldown"), Some(&Tag::Int(0)));
|
||||
assert_eq!(
|
||||
tag.get("fall_distance"),
|
||||
Some(&Tag::Double(0.0f64.to_bits()))
|
||||
);
|
||||
assert_eq!(tag.get("Motion"), Some(&doubles(&[0.0, 0.0, 0.0])));
|
||||
// Anything but a compound is left untouched.
|
||||
let mut plain = Tag::String("x".into());
|
||||
fill_common_defaults(&mut plain);
|
||||
assert_eq!(plain, Tag::String("x".into()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod animal_love_cause_tests {
|
||||
use super::*;
|
||||
use crate::mobs::Species;
|
||||
|
||||
#[test]
|
||||
fn animal_save_uses_the_parsed_love_cause_instead_of_stale_raw() {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let ty = Arc::make_mut(&mut kind);
|
||||
ty.id = "minecraft:pig".into();
|
||||
ty.species = Species::Pig;
|
||||
let types = HashMap::from([("minecraft:pig".to_string(), kind)]);
|
||||
let raw = |cause: Option<Tag>| {
|
||||
let mut tag = Tag::compound([
|
||||
("id", string("minecraft:pig")),
|
||||
("Pos", doubles(&[1.5, 200.0, 4.5])),
|
||||
("InLove", Tag::Int(123)),
|
||||
("CustomName", string("kept")),
|
||||
("Tags", Tag::List(vec![string("kept")])),
|
||||
]);
|
||||
if let Some(cause) = cause {
|
||||
put(&mut tag, "LoveCause", cause);
|
||||
}
|
||||
tag
|
||||
};
|
||||
let valid = [57, 1, 2, 3];
|
||||
for cause in [
|
||||
None,
|
||||
Some(Tag::IntArray(vec![])),
|
||||
Some(Tag::IntArray(vec![57, 1, 2])),
|
||||
Some(Tag::IntArray(vec![57, 1, 2, 3, 4])),
|
||||
Some(Tag::Int(7)),
|
||||
Some(Tag::compound([("value", Tag::Int(7))])),
|
||||
] {
|
||||
let raw = raw(cause);
|
||||
let mut mob = mob_from_nbt(&raw, &types, 1, None).unwrap();
|
||||
assert_eq!(mob.extra.love_cause, None);
|
||||
for input in [None, Some(&raw)] {
|
||||
let saved = mob_to_nbt(&mob, [57, 0, 0, 1], input, None, |_| None);
|
||||
assert!(saved.get("LoveCause").is_none(), "invalid raw {raw:?}");
|
||||
assert_eq!(saved.get("InLove"), Some(&Tag::Int(123)));
|
||||
if input.is_some() {
|
||||
assert_eq!(saved.get("CustomName"), raw.get("CustomName"));
|
||||
assert_eq!(saved.get("Tags"), raw.get("Tags"));
|
||||
}
|
||||
mob.extra.love_cause = Some(valid);
|
||||
let saved = mob_to_nbt(&mob, [57, 0, 0, 1], input, None, |_| None);
|
||||
assert_eq!(saved.get("LoveCause"), Some(&Tag::IntArray(valid.to_vec())));
|
||||
mob.extra.love_cause = None;
|
||||
}
|
||||
}
|
||||
let loaded_raw = raw(Some(Tag::IntArray(valid.to_vec())));
|
||||
let mut mob = mob_from_nbt(&loaded_raw, &types, 2, None).unwrap();
|
||||
assert_eq!(mob.extra.love_cause, Some(valid));
|
||||
assert_eq!(
|
||||
mob_to_nbt(&mob, [57, 0, 0, 2], None, None, |_| None).get("LoveCause"),
|
||||
Some(&Tag::IntArray(valid.to_vec()))
|
||||
);
|
||||
mob.extra.love_cause = None;
|
||||
assert!(
|
||||
mob_to_nbt(&mob, [57, 0, 0, 2], Some(&loaded_raw), None, |_| None)
|
||||
.get("LoveCause")
|
||||
.is_none()
|
||||
);
|
||||
// This cleanup belongs only to Animal's writer, not generic raw data.
|
||||
Arc::make_mut(&mut mob.kind).id = "minecraft:zombie".into();
|
||||
Arc::make_mut(&mut mob.kind).species = Species::Zombie;
|
||||
assert_eq!(
|
||||
mob_to_nbt(&mob, [57, 0, 0, 2], Some(&loaded_raw), None, |_| None).get("LoveCause"),
|
||||
loaded_raw.get("LoveCause")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod carrier_inventory_tests {
|
||||
use super::*;
|
||||
use crate::mobs::Species;
|
||||
|
||||
#[test]
|
||||
fn carrier_inventory_is_adopted_and_saved_from_the_model() {
|
||||
let stack = Tag::compound([
|
||||
("id", string("minecraft:stone")),
|
||||
("count", Tag::Int(3)),
|
||||
(
|
||||
"components",
|
||||
Tag::compound([(
|
||||
"minecraft:custom_data",
|
||||
Tag::compound([("audit", Tag::Int(137))]),
|
||||
)]),
|
||||
),
|
||||
]);
|
||||
let offers = Tag::compound([(
|
||||
"Recipes",
|
||||
Tag::List(vec![Tag::compound([
|
||||
(
|
||||
"buy",
|
||||
Tag::compound([("id", string("minecraft:emerald")), ("count", Tag::Int(1))]),
|
||||
),
|
||||
(
|
||||
"sell",
|
||||
Tag::compound([("id", string("minecraft:dirt")), ("count", Tag::Int(2))]),
|
||||
),
|
||||
])]),
|
||||
)]);
|
||||
for (species, id) in [
|
||||
(Species::Pillager, "minecraft:pillager"),
|
||||
(Species::WanderingTrader, "minecraft:wandering_trader"),
|
||||
(Species::Piglin, "minecraft:piglin"),
|
||||
] {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
Arc::make_mut(&mut kind).id = id.into();
|
||||
Arc::make_mut(&mut kind).species = species;
|
||||
let types = HashMap::from([(id.to_string(), kind)]);
|
||||
for input in [
|
||||
None,
|
||||
Some(Tag::List(vec![])),
|
||||
Some(Tag::List(vec![stack.clone()])),
|
||||
] {
|
||||
let mut raw = Tag::compound([
|
||||
("id", string(id)),
|
||||
("Pos", doubles(&[1.5, 200.0, 4.5])),
|
||||
("CustomName", string("kept")),
|
||||
("Tags", Tag::List(vec![string("kept")])),
|
||||
]);
|
||||
if let Some(input) = input.clone() {
|
||||
put(&mut raw, "Inventory", input);
|
||||
}
|
||||
if species == Species::WanderingTrader {
|
||||
put(&mut raw, "Offers", offers.clone());
|
||||
put(&mut raw, "DespawnDelay", Tag::Int(48000));
|
||||
}
|
||||
if species == Species::Pillager {
|
||||
put(&mut raw, "PatrolLeader", Tag::Byte(1));
|
||||
put(&mut raw, "CanJoinRaid", Tag::Byte(0));
|
||||
}
|
||||
let mut mob = mob_from_nbt(&raw, &types, 1, None).unwrap();
|
||||
let has_stack = matches!(&input, Some(Tag::List(v)) if !v.is_empty());
|
||||
assert_eq!(
|
||||
mob.extra.inventory.len(),
|
||||
usize::from(has_stack),
|
||||
"{id} adoption"
|
||||
);
|
||||
let expected = Tag::List(if has_stack {
|
||||
vec![stack.clone()]
|
||||
} else {
|
||||
vec![]
|
||||
});
|
||||
let mut stale = raw.clone();
|
||||
put(
|
||||
&mut stale,
|
||||
"Inventory",
|
||||
Tag::List(vec![Tag::compound([
|
||||
("id", string("minecraft:dirt")),
|
||||
("count", Tag::Int(2)),
|
||||
])]),
|
||||
);
|
||||
for base in [None, Some(&raw), Some(&stale)] {
|
||||
let saved = mob_to_nbt(&mob, [137, 0, 0, 1], base, None, |_| None);
|
||||
assert_eq!(saved.get("Inventory"), Some(&expected), "{id} save");
|
||||
if base.is_some() {
|
||||
assert_eq!(saved.get("CustomName"), raw.get("CustomName"));
|
||||
assert_eq!(saved.get("Tags"), raw.get("Tags"));
|
||||
}
|
||||
if species == Species::WanderingTrader {
|
||||
assert_eq!(saved.get("Offers"), Some(&offers));
|
||||
assert_eq!(saved.get("DespawnDelay"), Some(&Tag::Int(48000)));
|
||||
}
|
||||
if species == Species::Pillager {
|
||||
assert_eq!(saved.get("PatrolLeader"), Some(&Tag::Byte(1)));
|
||||
assert_eq!(saved.get("CanJoinRaid"), Some(&Tag::Byte(0)));
|
||||
}
|
||||
}
|
||||
if has_stack {
|
||||
mob.extra.inventory[0].count = 4;
|
||||
let mut changed = stack.clone();
|
||||
put(&mut changed, "count", Tag::Int(4));
|
||||
assert_eq!(
|
||||
mob_to_nbt(&mob, [137, 0, 0, 1], Some(&raw), None, |_| None)
|
||||
.get("Inventory"),
|
||||
Some(&Tag::List(vec![changed]))
|
||||
);
|
||||
}
|
||||
mob.extra.inventory.clear();
|
||||
assert_eq!(
|
||||
mob_to_nbt(&mob, [137, 0, 0, 1], Some(&stale), None, |_| None).get("Inventory"),
|
||||
Some(&Tag::List(vec![]))
|
||||
);
|
||||
}
|
||||
}
|
||||
// This InventoryCarrier projection does not add a pig inventory.
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
Arc::make_mut(&mut kind).id = "minecraft:pig".into();
|
||||
Arc::make_mut(&mut kind).species = Species::Pig;
|
||||
let mut pig = Mob::new(2, kind, Vec3::ZERO, 0.0);
|
||||
pig.extra
|
||||
.inventory
|
||||
.push(crate::save::item_from_nbt(&stack).unwrap());
|
||||
assert!(
|
||||
mob_to_nbt(&pig, [137, 0, 0, 2], None, None, |_| None)
|
||||
.get("Inventory")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,15 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
cancel,
|
||||
);
|
||||
},
|
||||
sound_play: |sound, volume, pitch, cancel| {
|
||||
crate::mod_api::dispatch_sound_play(
|
||||
crate::mod_api::mods(),
|
||||
sound,
|
||||
volume,
|
||||
pitch,
|
||||
cancel,
|
||||
);
|
||||
},
|
||||
living_heal: |entity, amount, cancel| {
|
||||
crate::mod_api::dispatch_living_heal(crate::mod_api::mods(), entity, amount, cancel);
|
||||
},
|
||||
@@ -176,6 +185,13 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
{
|
||||
log::warn!("Native client hooks already installed; keeping the first callbacks");
|
||||
}
|
||||
if horizon_client::sound_engine::install_playback_hook(
|
||||
horizon_client::sound_playback::native_sound_play,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
log::warn!("Native sound hook already installed; keeping the first callback");
|
||||
}
|
||||
raise_open_file_limit();
|
||||
if let Err(error) = game::run_with() {
|
||||
// A bad command line is the caller's mistake, not a crash: no report.
|
||||
|
||||
@@ -159,7 +159,11 @@ mod integrated;
|
||||
mod interaction_shapes;
|
||||
mod interactions;
|
||||
mod item_areas;
|
||||
#[cfg(test)]
|
||||
mod item_contact_tests;
|
||||
mod item_draw;
|
||||
#[cfg(test)]
|
||||
mod item_interact_mob_tests;
|
||||
mod item_particles;
|
||||
mod item_use;
|
||||
mod jigsaw_blocks;
|
||||
@@ -200,6 +204,8 @@ mod maps;
|
||||
mod memory;
|
||||
mod minecarts;
|
||||
mod mob_blocks;
|
||||
#[cfg(test)]
|
||||
mod mob_discard_tests;
|
||||
mod mob_gear;
|
||||
mod mob_index;
|
||||
mod mob_looting;
|
||||
@@ -216,9 +222,13 @@ mod narrator;
|
||||
mod native_biome_colors;
|
||||
pub mod native_block_entity_draw;
|
||||
mod native_block_sprites;
|
||||
mod native_bots;
|
||||
mod native_chunk_tickets;
|
||||
mod native_claimed_items;
|
||||
mod native_client_particles;
|
||||
mod native_client_particles_fixture;
|
||||
pub mod native_client_services;
|
||||
mod native_client_world;
|
||||
mod native_components;
|
||||
mod native_configs;
|
||||
mod native_criteria;
|
||||
@@ -450,6 +460,7 @@ pub use machines::native_fluids::NativeFluidTank;
|
||||
pub use maps::{NativeMapDecoration, NativeMapInfo};
|
||||
pub use native_block_entity_draw::BlockEntityDraw;
|
||||
pub use native_claimed_items::{NativeHeldItem, NativeHit, NativeModEvent};
|
||||
pub use native_client_world::{NativeBiomeId, NativeClientBiomes, NativeClientChunks};
|
||||
pub use native_components::NativeComponentError;
|
||||
pub use native_configs::{NativeConfig, NativeConfigFile, NativeConfigKind};
|
||||
pub use native_criteria::NativeCriterion;
|
||||
@@ -2345,21 +2356,64 @@ fn ticking_chunk(world: &World, x: i32, z: i32) -> bool {
|
||||
}
|
||||
/// Key events carry the scancode's US-layout code, which ignores XKB options like
|
||||
/// `caps:swapescape`. For the keys whose meaning the keymap may remap, take the code
|
||||
/// from the keysym the keymap resolved instead.
|
||||
/// from the keysym the keymap resolved instead. This includes function keys on
|
||||
/// remapped media rows; ordinary character bindings still use their physical key.
|
||||
fn follow_keymap(event: &mut WindowEvent) {
|
||||
if let WindowEvent::KeyboardInput { event, .. } = event {
|
||||
follow_named_keymap(&event.logical_key, &mut event.physical_key);
|
||||
}
|
||||
}
|
||||
|
||||
fn follow_named_keymap(logical_key: &winit::keyboard::Key, physical_key: &mut PhysicalKey) {
|
||||
use winit::keyboard::{Key, NamedKey};
|
||||
if let WindowEvent::KeyboardInput { event, .. } = event
|
||||
&& let Key::Named(named) = &event.logical_key
|
||||
{
|
||||
if let Key::Named(named) = logical_key {
|
||||
let code = match named {
|
||||
NamedKey::Escape => KeyCode::Escape,
|
||||
NamedKey::CapsLock => KeyCode::CapsLock,
|
||||
NamedKey::F1 => KeyCode::F1,
|
||||
NamedKey::F2 => KeyCode::F2,
|
||||
NamedKey::F3 => KeyCode::F3,
|
||||
NamedKey::F4 => KeyCode::F4,
|
||||
NamedKey::F5 => KeyCode::F5,
|
||||
NamedKey::F6 => KeyCode::F6,
|
||||
NamedKey::F7 => KeyCode::F7,
|
||||
NamedKey::F8 => KeyCode::F8,
|
||||
NamedKey::F9 => KeyCode::F9,
|
||||
NamedKey::F10 => KeyCode::F10,
|
||||
NamedKey::F11 => KeyCode::F11,
|
||||
NamedKey::F12 => KeyCode::F12,
|
||||
NamedKey::F13 => KeyCode::F13,
|
||||
NamedKey::F14 => KeyCode::F14,
|
||||
NamedKey::F15 => KeyCode::F15,
|
||||
NamedKey::F16 => KeyCode::F16,
|
||||
NamedKey::F17 => KeyCode::F17,
|
||||
NamedKey::F18 => KeyCode::F18,
|
||||
NamedKey::F19 => KeyCode::F19,
|
||||
NamedKey::F20 => KeyCode::F20,
|
||||
NamedKey::F21 => KeyCode::F21,
|
||||
NamedKey::F22 => KeyCode::F22,
|
||||
NamedKey::F23 => KeyCode::F23,
|
||||
NamedKey::F24 => KeyCode::F24,
|
||||
NamedKey::F25 => KeyCode::F25,
|
||||
NamedKey::F26 => KeyCode::F26,
|
||||
NamedKey::F27 => KeyCode::F27,
|
||||
NamedKey::F28 => KeyCode::F28,
|
||||
NamedKey::F29 => KeyCode::F29,
|
||||
NamedKey::F30 => KeyCode::F30,
|
||||
NamedKey::F31 => KeyCode::F31,
|
||||
NamedKey::F32 => KeyCode::F32,
|
||||
NamedKey::F33 => KeyCode::F33,
|
||||
NamedKey::F34 => KeyCode::F34,
|
||||
NamedKey::F35 => KeyCode::F35,
|
||||
_ => return,
|
||||
};
|
||||
event.physical_key = PhysicalKey::Code(code);
|
||||
*physical_key = PhysicalKey::Code(code);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod keymap_tests;
|
||||
|
||||
/// Q throws the whole stack with Control down: the same modifier word the
|
||||
/// screens read (`has_control_down`), never a latched sprint toggle.
|
||||
fn drop_whole_stack(modifiers: winit::keyboard::ModifiersState) -> bool {
|
||||
@@ -2498,7 +2552,9 @@ struct Game<'a> {
|
||||
spawn: Option<Vec3>,
|
||||
clock: FixedStep,
|
||||
previous_position: Vec3,
|
||||
/// `yRotO` / `xRotO` of the local player: the view at the start of the tick.
|
||||
/// `yRotO` / `xRotO` of the local player: the view at the start of the tick,
|
||||
/// moved by `Entity.turn` and a boat's `clampRotation` as the view is
|
||||
/// (`LocalPlayer.getRopeHoldPosition` eases the held lead from it).
|
||||
rope_rot_old: (f32, f32),
|
||||
eye: f32,
|
||||
previous_eye: f32,
|
||||
@@ -2623,6 +2679,8 @@ struct Game<'a> {
|
||||
entity_spans: std::cell::RefCell<Vec<horizon_client::gfx::pack_draw::EntitySpan>>,
|
||||
/// Which entity this frame's entity vertices belong to (`Frame::entity_motion`).
|
||||
entity_motion: std::cell::RefCell<Vec<(u32, u64)>>,
|
||||
/// The GPU-posed vertex count at each of `entity_motion`'s marks (`Frame::entity_motion_posed`).
|
||||
entity_motion_posed: std::cell::RefCell<Vec<u32>>,
|
||||
/// Mob models posed on the GPU (`Frame::skin`, `mobs::skin`): the models stored there,
|
||||
/// and this frame's draws.
|
||||
skin_models: std::cell::RefCell<horizon_client::mobs::skin::SkinModels>,
|
||||
@@ -2989,10 +3047,6 @@ struct Game<'a> {
|
||||
/// Vehicles that gained a rider this tick whose `START_RIDING_TRIGGER` was
|
||||
/// not fired yet (`game/boarding.rs`), for a guest's turn.
|
||||
started_riding: Vec<u32>,
|
||||
/// `Entity.boardingCooldown` of the acting player, and its vehicle at the
|
||||
/// last tick (`game/boarding.rs`).
|
||||
boarding_cooldown: i32,
|
||||
riding_at_tick: Option<u32>,
|
||||
/// A rotation-only write (`/rotate`, `Entity.forceSetRotation`) to the player of this
|
||||
/// turn: `leave_guest` sends it as `ClientboundPlayerRotationPacket`, not a teleport.
|
||||
rotation_forced: bool,
|
||||
@@ -3165,6 +3219,10 @@ struct Game<'a> {
|
||||
debug_options: Option<debug_options::DebugOptionsUi>,
|
||||
/// `DebugEntryMemory`'s readings (game/debug_lines.rs).
|
||||
memory_meter: std::cell::RefCell<horizon_client::debug_overlay::MemoryMeter>,
|
||||
/// The F3 overlay's vertices between rebuilds (`draw_debug_overlay_under`).
|
||||
debug_paint: RefCell<horizon_client::debug_overlay::PaintCache>,
|
||||
/// The F3 overlay's last reading of the refresh rate, and when it was taken.
|
||||
debug_refresh: std::cell::Cell<Option<(Instant, Option<u32>)>>,
|
||||
/// `Minecraft.gpuUtilization`: the last timed frame's GPU time over its
|
||||
/// CPU time, in percent.
|
||||
gpu_utilization: f64,
|
||||
@@ -3253,6 +3311,9 @@ struct Game<'a> {
|
||||
/// have each chest open.
|
||||
lid_openers: HashMap<(i32, i32, i32), i32>,
|
||||
lan_acting: Option<u32>,
|
||||
/// A mod asked for the command trees again (`native_refresh_commands`):
|
||||
/// the next server tick asks every mod for its commands and resends them.
|
||||
native_commands_refresh: bool,
|
||||
/// Other players in the current guest turn, while its hub is borrowed out.
|
||||
lan_building_boxes: building_players::BuildingPlayers,
|
||||
/// A guest's `/save-all`: saved once their turn ends (`run_guest_command`),
|
||||
@@ -4443,17 +4504,11 @@ impl Game<'_> {
|
||||
);
|
||||
obstacles.near(item.bounds().inflate(reach), &mut nearby);
|
||||
let mut entered = Vec::new();
|
||||
let alive = entity::tick_item(
|
||||
let (alive, discarded) = self.tick_item_with_block_contacts(
|
||||
item,
|
||||
&self.world,
|
||||
self.pack.assets.registry,
|
||||
self.res.facts(),
|
||||
self.collision,
|
||||
&self.res.motion().fluids,
|
||||
no_physics,
|
||||
&nearby,
|
||||
Some(self.border.geometry()),
|
||||
Some(&self.res.motion().blocks),
|
||||
false,
|
||||
&mut |splash| entered.push(splash),
|
||||
);
|
||||
// Entity.doWaterSplashEffect: the server plays the sound (broadcast
|
||||
@@ -4461,6 +4516,11 @@ impl Game<'_> {
|
||||
for splash in entered {
|
||||
self.apply_item_splash(splash);
|
||||
}
|
||||
if discarded {
|
||||
removed[i] = true;
|
||||
cells.remove(i, item.previous);
|
||||
continue;
|
||||
}
|
||||
if merging && !no_physics {
|
||||
self.item_landing_event(item.id, item.previous, item.position, item.on_ground);
|
||||
self.entity_powder_snow_particles(
|
||||
@@ -4555,12 +4615,12 @@ impl Game<'_> {
|
||||
let mut removed = removed.into_iter();
|
||||
items.retain(|_| !removed.next().unwrap_or(false));
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
self.dropped = items;
|
||||
self.restore_item_batch(items);
|
||||
self.spawn_destroyed_contents(destroyed);
|
||||
return;
|
||||
};
|
||||
if p.health.dead_or_dying() || self.spectator() {
|
||||
self.dropped = items;
|
||||
self.restore_item_batch(items);
|
||||
self.spawn_destroyed_contents(destroyed);
|
||||
return;
|
||||
}
|
||||
@@ -4642,7 +4702,7 @@ impl Game<'_> {
|
||||
}
|
||||
kept.push(item);
|
||||
}
|
||||
self.dropped = kept.into();
|
||||
self.restore_item_batch(kept.into());
|
||||
self.spawn_destroyed_contents(destroyed);
|
||||
}
|
||||
/// `ItemUtils.onContainerDestroyed`: each stack a destroyed item held
|
||||
@@ -6626,22 +6686,50 @@ impl Game<'_> {
|
||||
/// `Player.interactOn` with the acting hand: whether it took the click. A
|
||||
/// guest's interact packet names its entity (`target`), which the helpers
|
||||
/// use as it is; the local player picks from the crosshair instead, the
|
||||
/// way `Minecraft.hitResult` does.
|
||||
/// way `Minecraft.hitResult` does. `native_entity_interact_specific` and
|
||||
/// `native_interact_entity` hear it first; a claim swings the hand and
|
||||
/// ends the click.
|
||||
fn interact_entity(&mut self, target: Option<InteractTarget>) -> Result<bool> {
|
||||
// Native mods, where Player.interactOn starts: before the entity's
|
||||
// own interact and the item's interactLivingEntity. The entity is the
|
||||
// one the packet named, or the one the crosshair picked.
|
||||
let named = match target {
|
||||
Some(t) => Some(t.id),
|
||||
None => self.pick_entity_hit().flatten(),
|
||||
let (named, distance) = match target {
|
||||
Some(t) => (Some(t.id), None),
|
||||
None => match self.pick_entity_hit_at() {
|
||||
Some((distance, id)) => (id, Some(distance)),
|
||||
None => (None, None),
|
||||
},
|
||||
};
|
||||
if let Some(id) = named
|
||||
&& let Some(entity) = self.level_entity(id)
|
||||
{
|
||||
// The point clicked, measured from the entity's position: a
|
||||
// guest's packet carries it, and the local player's is where the
|
||||
// crosshair's ray met the entity (`EntityHitResult.getLocation()`).
|
||||
let location = match target {
|
||||
Some(t) => Some(t.location),
|
||||
None => distance
|
||||
.zip(self.aim())
|
||||
.zip(self.target_position(entity))
|
||||
.map(|((d, (eye, dir)), at)| {
|
||||
std::array::from_fn(|k| eye[k] + dir[k] * d - at[k])
|
||||
}),
|
||||
};
|
||||
let kind = self.target_type(entity);
|
||||
let stack = self.held().cloned();
|
||||
let mut claimed = false;
|
||||
self.native_interact_entity(id, &kind, stack.as_ref(), &mut claimed);
|
||||
if let Some(location) = location {
|
||||
self.native_entity_interact_specific(
|
||||
id,
|
||||
&kind,
|
||||
stack.as_ref(),
|
||||
location,
|
||||
&mut claimed,
|
||||
);
|
||||
}
|
||||
if !claimed {
|
||||
self.native_interact_entity(id, &kind, stack.as_ref(), &mut claimed);
|
||||
}
|
||||
if claimed {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
@@ -6654,7 +6742,35 @@ impl Game<'_> {
|
||||
|| self.interact_cart(target)
|
||||
|| self.interact_mob(target)?
|
||||
// A guest's Interact naming a knot's id goes to `use_knot` before this.
|
||||
|| target.is_none() && self.interact_knot())
|
||||
|| target.is_none() && self.interact_knot()
|
||||
|| self.item_interact_mob(target))
|
||||
}
|
||||
/// The `ItemStack.interactLivingEntity` fallback in `Player.interactOn`,
|
||||
/// currently for native Mob entries after the entity's own action passed.
|
||||
fn item_interact_mob(&mut self, target: Option<InteractTarget>) -> bool {
|
||||
if self.spectator() {
|
||||
return false;
|
||||
}
|
||||
let Some(index) = self.mob_interaction_target(target) else {
|
||||
return false;
|
||||
};
|
||||
let mob = &self.mobs[index];
|
||||
if mob.removed || !mob.alive() {
|
||||
return false;
|
||||
}
|
||||
let (entity, kind) = (
|
||||
mob.id as i32,
|
||||
horizon_client::command::args::normalize(&mob.kind.id),
|
||||
);
|
||||
let Some(stack) = self.held().filter(|stack| stack.count > 0).cloned() else {
|
||||
return false;
|
||||
};
|
||||
let mut swing = None;
|
||||
self.native_item_interact_mob(entity, &kind, Some(&stack), &mut swing);
|
||||
if swing == Some(true) {
|
||||
self.swing_arm();
|
||||
}
|
||||
swing.is_some()
|
||||
}
|
||||
/// `ItemStack.applyAfterUseComponentSideEffects`: a used stack with a
|
||||
/// `use_cooldown` component starts its entry (`UseCooldown.apply`). A
|
||||
@@ -7547,11 +7663,8 @@ impl Game<'_> {
|
||||
/// `ITEM_INTERACT_FINISH` when it ends) from the player, at its feet, when
|
||||
/// the stack's use effects ask for interaction vibrations (the default).
|
||||
pub(super) fn use_vibration(&mut self, stack: &ItemStack, event: &'static str) {
|
||||
if !self
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.is_some_and(|i| i.interact_vibrations)
|
||||
{
|
||||
let default = self.items().get(&stack.item).map(|i| i.interact_vibrations);
|
||||
if !item_use::interact_vibrations(stack, default) {
|
||||
return;
|
||||
}
|
||||
let Some(p) = self.player.as_ref().map(|p| p.position) else {
|
||||
@@ -8267,7 +8380,7 @@ impl Game<'_> {
|
||||
gameplay_has_keyboard(
|
||||
self.screen,
|
||||
self.title.is_some(),
|
||||
self.dialog_open(),
|
||||
self.screen_over_playing(),
|
||||
),
|
||||
action,
|
||||
) {
|
||||
@@ -8312,8 +8425,11 @@ impl Game<'_> {
|
||||
(_, false) => KeyAction::Press,
|
||||
(_, true) => KeyAction::Repeat,
|
||||
};
|
||||
let screen_open =
|
||||
!gameplay_has_keyboard(self.screen, self.title.is_some(), self.dialog_open());
|
||||
let screen_open = !gameplay_has_keyboard(
|
||||
self.screen,
|
||||
self.title.is_some(),
|
||||
self.screen_over_playing(),
|
||||
);
|
||||
self.toggles.key(slot, action, screen_open, option);
|
||||
self.sync_toggle(slot);
|
||||
if !pressed {
|
||||
@@ -8341,7 +8457,11 @@ impl Game<'_> {
|
||||
fn track_toggle_screens(&mut self) {
|
||||
let dialog = self.dialog_open();
|
||||
drop_held_behind_dialog(dialog, &mut self.keys, &mut self.left, &mut self.right);
|
||||
let playing = gameplay_has_keyboard(self.screen, self.title.is_some(), dialog);
|
||||
let playing = gameplay_has_keyboard(
|
||||
self.screen,
|
||||
self.title.is_some(),
|
||||
self.screen_over_playing(),
|
||||
);
|
||||
if playing == self.was_playing {
|
||||
return;
|
||||
}
|
||||
@@ -10390,6 +10510,14 @@ impl Game<'_> {
|
||||
menu.stone = state;
|
||||
menu.set_slot(&env, 1, before[1].clone());
|
||||
}
|
||||
if anvil_open && let Some(s) = self.station.as_ref() {
|
||||
// The same: the cost, material cost and rename flag between clicks, as
|
||||
// the station's createResult and the mods' anvil_update left them (the
|
||||
// result came with the slots), so a take is predicted as the server makes it.
|
||||
menu.data[0] = s.anvil.cost;
|
||||
menu.anvil.repair_count = s.anvil.repair_item_count_cost;
|
||||
menu.anvil.only_renaming = s.anvil.only_renaming;
|
||||
}
|
||||
if kind == Kind::Enchanting
|
||||
&& let Some(s) = self.station.as_ref()
|
||||
{
|
||||
@@ -11271,6 +11399,10 @@ impl Game<'_> {
|
||||
/// generation to follow its own motion. Each run lasts until the next mark.
|
||||
pub(super) fn mark_motion(&self, start: usize, id: u64) {
|
||||
self.entity_motion.borrow_mut().push((start as u32, id));
|
||||
// Where its GPU-posed vertices start (`entity_skin`): a shader pack's `at_velocity`
|
||||
// follows those too.
|
||||
let posed = self.skin_frame.borrow().vertices;
|
||||
self.entity_motion_posed.borrow_mut().push(posed);
|
||||
}
|
||||
/// ItemEntityRenderer: items bob and spin; blocks render as small cubes.
|
||||
fn entity_geometry(
|
||||
@@ -11292,6 +11424,7 @@ impl Game<'_> {
|
||||
let mut static_block_entity_stats = StaticCacheFrameStats::default();
|
||||
self.entity_spans.borrow_mut().clear();
|
||||
self.entity_motion.borrow_mut().clear();
|
||||
self.entity_motion_posed.borrow_mut().clear();
|
||||
// LevelExtractor.extract: the transient blocks queued since the last frame.
|
||||
self.drain_transient_block_queue();
|
||||
self.prepare_entity_culling();
|
||||
@@ -11830,7 +11963,42 @@ impl Game<'_> {
|
||||
laps.lap("eg_orbs");
|
||||
// Falling blocks use their baked model, including non-cubic shapes.
|
||||
self.mark_entity(out.len(), "minecraft:falling_block");
|
||||
// Packs can move vertices or draw another view; otherwise only depth
|
||||
// planes are used, which TAA's X/Y projection jitter cannot change.
|
||||
let falling_view = self
|
||||
.view_frustum
|
||||
.as_ref()
|
||||
.filter(|_| self.pack_ids.is_none() && self.native_shaders.picked.is_empty());
|
||||
let tables = &self.pack.context.tables;
|
||||
let falling_forward = self.camera_orientation() * -glam::Vec3::Z;
|
||||
for f in &self.falling {
|
||||
let p = f.model_origin(alpha, |state, x, z| tables.offset(state, x, z)) - camera;
|
||||
let o = p.as_vec3();
|
||||
// Only rear-origin models pay for the envelope/depth check. The
|
||||
// full posed-camera test still decides rejection, including models
|
||||
// whose origin is behind but whose geometry reaches into view.
|
||||
if falling_forward.dot(o) < 0.0
|
||||
&& falling_view.is_some_and(|view| {
|
||||
self.pack
|
||||
.render
|
||||
.model_catalog
|
||||
.render_bounds(f.state)
|
||||
.is_some_and(|[lo, hi]| {
|
||||
let lo = glam::Vec3::from_array(lo) + o;
|
||||
let hi = glam::Vec3::from_array(hi) + o;
|
||||
let magnitude = o
|
||||
.abs()
|
||||
.max_element()
|
||||
.max(lo.abs().max_element())
|
||||
.max(hi.abs().max_element());
|
||||
let padding =
|
||||
glam::Vec3::splat(1.0e-4 + magnitude * f32::EPSILON * 4.0);
|
||||
!view.intersects_depth(lo - padding, hi + padding)
|
||||
})
|
||||
})
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// `FallingBlockEntity.blockPosition()`: the cell its feet are in.
|
||||
let (x, y, z) = (
|
||||
f.position.x.floor() as i32,
|
||||
@@ -11843,9 +12011,6 @@ impl Game<'_> {
|
||||
let Some((_, tint)) = self.block_face_sprite(f.state) else {
|
||||
continue;
|
||||
};
|
||||
let tables = &self.pack.context.tables;
|
||||
let p = f.model_origin(alpha, |state, x, z| tables.offset(state, x, z)) - camera;
|
||||
let o = p.as_vec3();
|
||||
let light = self.world.light(x, y + 1, z);
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
let start = out.len();
|
||||
@@ -12606,6 +12771,7 @@ impl Game<'_> {
|
||||
.collect()
|
||||
}
|
||||
fn tick_mobs(&mut self) -> Result<()> {
|
||||
self.sync_mob_death_protection();
|
||||
self.tick_creatures()?;
|
||||
if !self.entity_attachments.is_empty() {
|
||||
self.prune_entity_attachments();
|
||||
@@ -13316,6 +13482,7 @@ impl Game<'_> {
|
||||
self.shadow_only_vertices.get_mut().clear();
|
||||
self.entity_spans.borrow_mut().clear();
|
||||
self.entity_motion.borrow_mut().clear();
|
||||
self.entity_motion_posed.borrow_mut().clear();
|
||||
self.skin_models
|
||||
.borrow_mut()
|
||||
.restart(&mut self.skin_frame.borrow_mut());
|
||||
@@ -13843,6 +14010,18 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
pub(super) fn damage_held(&mut self, amount: u32) {
|
||||
self.damage_held_in_slot(
|
||||
amount,
|
||||
horizon_world::player_inventory::EquipmentSlot::MainHand,
|
||||
);
|
||||
}
|
||||
/// Hurt the acting hand's stack through ordinary wear/criterion/break handling,
|
||||
/// reporting its break in the caller's explicit equipment slot.
|
||||
pub(super) fn damage_held_in_slot(
|
||||
&mut self,
|
||||
amount: u32,
|
||||
break_slot: horizon_world::player_inventory::EquipmentSlot,
|
||||
) {
|
||||
if self.creative {
|
||||
return;
|
||||
}
|
||||
@@ -13868,10 +14047,7 @@ impl Game<'_> {
|
||||
*cell = None;
|
||||
self.award_stat("broken", &broken, 1);
|
||||
// LivingEntity.onEquippedItemBroken: the sound and the crumbs.
|
||||
self.player_equipment_broke(
|
||||
horizon_world::player_inventory::EquipmentSlot::MainHand,
|
||||
&gone,
|
||||
);
|
||||
self.player_equipment_broke(break_slot, &gone);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16162,6 +16338,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// Save the level in place: its changed chunks, its entities and its raids.
|
||||
fn save_level(&mut self, wait: bool) -> Result<()> {
|
||||
self.save_border();
|
||||
self.save.save_attachments(&self.world.attachments)?;
|
||||
self.save.save_forced_chunks(&self.streaming.forced)?;
|
||||
self.streaming.save(&mut self.world, wait);
|
||||
@@ -16772,6 +16949,7 @@ impl Game<'_> {
|
||||
hud.patches.append(&mut self.map_patches);
|
||||
let entity_spans = std::mem::take(self.entity_spans.get_mut());
|
||||
let entity_motion = std::mem::take(self.entity_motion.get_mut());
|
||||
let entity_motion_posed = std::mem::take(self.entity_motion_posed.get_mut());
|
||||
let glowing = std::mem::take(self.glowing_vertices.get_mut());
|
||||
let shadow_only = std::mem::take(self.shadow_only_vertices.get_mut());
|
||||
let skin = std::mem::take(self.skin_frame.get_mut());
|
||||
@@ -16807,17 +16985,17 @@ impl Game<'_> {
|
||||
self.water_plane(camera)
|
||||
},
|
||||
title,
|
||||
sky_blocked: self.potions.effects.get("blindness").is_some()
|
||||
|| self.potions.effects.get("darkness").is_some()
|
||||
|| matches!(
|
||||
horizon_client::fog::fog_type(
|
||||
&self.world,
|
||||
self.res.facts(),
|
||||
[camera.x, camera.y, camera.z],
|
||||
&self.near_plane(),
|
||||
),
|
||||
horizon_client::fog::FogType::Lava | horizon_client::fog::FogType::PowderSnow
|
||||
sky_blocked: horizon_client::fog::sky_pass_skipped(
|
||||
horizon_client::fog::fog_type(
|
||||
&self.world,
|
||||
self.res.facts(),
|
||||
[camera.x, camera.y, camera.z],
|
||||
&self.near_plane(),
|
||||
),
|
||||
self.potions.effects.get("blindness").is_some()
|
||||
|| self.potions.effects.get("darkness").is_some(),
|
||||
self.boss_overlay.should_create_world_fog(),
|
||||
),
|
||||
skybox: self.dim == Dim::Overworld && self.settings.horizon.sky,
|
||||
end_sky: self.dim == Dim::End && self.settings.horizon.sky,
|
||||
end_sky_sprite: self
|
||||
@@ -16862,6 +17040,7 @@ impl Game<'_> {
|
||||
visible_static_block_entity_sections: &visible_static_block_entity_sections,
|
||||
static_block_entity_stats,
|
||||
entity_motion: &entity_motion,
|
||||
entity_motion_posed: &entity_motion_posed,
|
||||
entity_spans: &entity_spans,
|
||||
skin: skin.pending().then_some(&skin),
|
||||
glowing: &glowing,
|
||||
@@ -16936,9 +17115,16 @@ impl Game<'_> {
|
||||
pr.lap("pr_frame");
|
||||
// Renderer encoding and GPU waiting belong to render, not prepare.
|
||||
drop(prepare);
|
||||
let debug_passes = self.debug_lists_gpu_passes();
|
||||
let result = match self.renderer.as_mut() {
|
||||
Some(renderer) => {
|
||||
renderer.set_pass_timing(self.debug || self.gpu_passes);
|
||||
renderer.set_pass_timing(debug_passes || self.gpu_passes);
|
||||
// `gpu_passes` is a measurement: it takes every sample it can.
|
||||
renderer.set_pass_timing_gap(if self.gpu_passes {
|
||||
Duration::ZERO
|
||||
} else {
|
||||
horizon_client::debug_overlay::GPU_TIMING_GAP
|
||||
});
|
||||
horizon_client::spans::time("renderer", || renderer.render(&frame))
|
||||
}
|
||||
None => Err("renderer".into()),
|
||||
@@ -16950,6 +17136,7 @@ impl Game<'_> {
|
||||
self.entity_vertices = entities;
|
||||
*self.entity_spans.get_mut() = entity_spans;
|
||||
*self.entity_motion.get_mut() = entity_motion;
|
||||
*self.entity_motion_posed.get_mut() = entity_motion_posed;
|
||||
*self.glowing_vertices.get_mut() = glowing;
|
||||
*self.shadow_only_vertices.get_mut() = shadow_only;
|
||||
let mut skin = skin;
|
||||
@@ -17995,6 +18182,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
if self.window.is_some() {
|
||||
return;
|
||||
}
|
||||
debug_keys::warm_debug_cpu();
|
||||
// The Window constructor: created fullscreen when options.txt or --fullscreen says so.
|
||||
let fullscreen = self.settings.fullscreen || self.options.fullscreen;
|
||||
let size = self.options.window_size.map(f64::from);
|
||||
@@ -18022,6 +18210,13 @@ impl ApplicationHandler for Game<'_> {
|
||||
return;
|
||||
}
|
||||
};
|
||||
if fullscreen && window.is_maximized() {
|
||||
window.set_maximized(false);
|
||||
let monitor = window
|
||||
.current_monitor()
|
||||
.or_else(|| window.primary_monitor());
|
||||
window.set_fullscreen(Some(winit::window::Fullscreen::Borderless(monitor)));
|
||||
}
|
||||
drop(window_step);
|
||||
horizon_client::telemetry::note_display(&window);
|
||||
match self.new_renderer(window.clone()) {
|
||||
@@ -19782,7 +19977,7 @@ impl<'a> Game<'a> {
|
||||
));
|
||||
let prepared = prepared
|
||||
.map(Ok)
|
||||
.unwrap_or_else(|| GameCpuResources::load(&root))?;
|
||||
.unwrap_or_else(|| GameCpuResources::load(&root, None))?;
|
||||
let world = World::new(res.facts().air, 0);
|
||||
let tutorial = tutorial::Tutorial::new(tutorial::Step::from_option(
|
||||
settings.file.int("tutorialStep"),
|
||||
@@ -19914,6 +20109,7 @@ impl<'a> Game<'a> {
|
||||
shadow_only_vertices: Default::default(),
|
||||
entity_spans: Default::default(),
|
||||
entity_motion: Default::default(),
|
||||
entity_motion_posed: Default::default(),
|
||||
skin_models: Default::default(),
|
||||
skin_frame: Default::default(),
|
||||
glowing_vertices: Default::default(),
|
||||
@@ -20132,8 +20328,6 @@ impl<'a> Game<'a> {
|
||||
cart_models: prepared.cart_models,
|
||||
riding: None,
|
||||
started_riding: Vec::new(),
|
||||
boarding_cooldown: 0,
|
||||
riding_at_tick: None,
|
||||
rotation_forced: false,
|
||||
posed_rider: None,
|
||||
pending_vehicle: None,
|
||||
@@ -20227,6 +20421,8 @@ impl<'a> Game<'a> {
|
||||
),
|
||||
debug_options: None,
|
||||
memory_meter: Default::default(),
|
||||
debug_paint: Default::default(),
|
||||
debug_refresh: Default::default(),
|
||||
gpu_utilization: 0.0,
|
||||
rendered_mobs: Default::default(),
|
||||
screen_blur_requested: Default::default(),
|
||||
@@ -20265,6 +20461,7 @@ impl<'a> Game<'a> {
|
||||
lan_global_events: Vec::new(),
|
||||
lid_openers: HashMap::new(),
|
||||
lan_acting: None,
|
||||
native_commands_refresh: false,
|
||||
lan_building_boxes: Default::default(),
|
||||
save_after_guest_turn: false,
|
||||
player_attachments: Default::default(),
|
||||
@@ -20375,26 +20572,39 @@ impl EntityModels {
|
||||
}
|
||||
}
|
||||
impl GameCpuResources {
|
||||
fn load(root: &std::path::Path) -> Result<Self> {
|
||||
let document = horizon_client::mobs::EntityModelDocument::load(root).ok();
|
||||
let model = |key| document.as_ref().and_then(|document| document.model(key));
|
||||
fn load(
|
||||
root: &std::path::Path,
|
||||
document: Option<&horizon_client::mobs::EntityModelDocument>,
|
||||
) -> Result<Self> {
|
||||
let owned_document;
|
||||
let document = match document {
|
||||
Some(document) => Some(document),
|
||||
None => match horizon_client::mobs::EntityModelDocument::load(root) {
|
||||
Ok(document) => {
|
||||
owned_document = document;
|
||||
Some(&owned_document)
|
||||
}
|
||||
Err(_) => None,
|
||||
},
|
||||
};
|
||||
let model = |key| document.and_then(|document| document.model(key));
|
||||
Ok(Self {
|
||||
painting_variants: paintings::PaintingVariants::load(root),
|
||||
frame_model: frames::bake_frame(root, "item_frame"),
|
||||
glow_frame_model: frames::bake_frame(root, "glow_item_frame"),
|
||||
map_frame_model: frames::bake_frame(root, "item_frame_map"),
|
||||
glow_map_frame_model: frames::bake_frame(root, "glow_item_frame_map"),
|
||||
armor_stand_assets: armor_stands::ArmorStandAssets::load(root, document.as_ref()),
|
||||
armor_stand_assets: armor_stands::ArmorStandAssets::load(root, document),
|
||||
waypoint_styles: horizon_client::locator_bar::WaypointStyles::load(root),
|
||||
boat_models: boats::BoatModels::load(document.as_ref()),
|
||||
cart_models: minecarts::CartModels::load(document.as_ref()),
|
||||
boat_models: boats::BoatModels::load(document),
|
||||
cart_models: minecarts::CartModels::load(document),
|
||||
crystal_model: model("minecraft:end_crystal#main"),
|
||||
cushion_model: model("minecraft:cushion#main"),
|
||||
dragon_model: model("minecraft:ender_dragon#main"),
|
||||
arrow_model: model("minecraft:arrow#main"),
|
||||
bee_stinger_model: model("minecraft:bee_stinger#main"),
|
||||
trident_model: model("minecraft:trident#main"),
|
||||
entity_models: EntityModels::load(document.as_ref()),
|
||||
entity_models: EntityModels::load(document),
|
||||
creative_screen: Creative::load(root)?,
|
||||
})
|
||||
}
|
||||
@@ -20403,6 +20613,9 @@ impl GameCpuResources {
|
||||
/// The data the background loader produces for `Game::new`.
|
||||
struct Loaded {
|
||||
game_cpu: Option<GameCpuResources>,
|
||||
/// The one parsed `entity-models.json`, for the handoff `Game::new` does
|
||||
/// itself on the synchronous launch.
|
||||
entity_models: Option<Arc<horizon_client::mobs::EntityModelDocument>>,
|
||||
renderer_cpu: Option<horizon_client::gfx::PreparedTextures>,
|
||||
root: PathBuf,
|
||||
pack_error: Option<String>,
|
||||
@@ -21223,8 +21436,8 @@ fn load_resources(
|
||||
Some(ui)
|
||||
}
|
||||
};
|
||||
let (core, loaded_gui) = loop {
|
||||
let (ui, core) = std::thread::scope(|scope| {
|
||||
let (core, loaded_gui, entity_models) = loop {
|
||||
let (ui, core, entity_models) = std::thread::scope(|scope| {
|
||||
let core = Step::spawn(scope, parallel, "core", || {
|
||||
// A failed pack starts core again from the game's own
|
||||
// resources, which the world's preload must read: with packs
|
||||
@@ -21234,12 +21447,22 @@ fn load_resources(
|
||||
load_core(&root, headless, parallel, progress, early)
|
||||
})
|
||||
});
|
||||
// The handoff's entity models, the rest worker's mob models and
|
||||
// its transformers all read this one document. A server draws
|
||||
// none of them.
|
||||
let entity_models = (!headless).then(|| {
|
||||
Step::spawn(scope, parallel, "entity_models", || {
|
||||
startup::time("core.entity_models", || {
|
||||
horizon_client::mobs::EntityModelDocument::load(&root).ok()
|
||||
})
|
||||
})
|
||||
});
|
||||
let ui = load_ui(&root).map(|ui| offer(ui, &root));
|
||||
(ui, core.join())
|
||||
(ui, core.join(), entity_models.and_then(|step| step.join()))
|
||||
});
|
||||
let packs = root != vanilla_root;
|
||||
let failure = match (ui, core) {
|
||||
(Ok(gui), Ok(Ok(core))) => break (core, gui),
|
||||
(Ok(gui), Ok(Ok(core))) => break (core, gui, entity_models),
|
||||
(Err(e), _) if packs => e.to_string(),
|
||||
(_, Ok(Err(e))) if packs => e,
|
||||
(_, Ok(Err(e))) => {
|
||||
@@ -21258,6 +21481,7 @@ fn load_resources(
|
||||
root = vanilla_root.to_path_buf();
|
||||
};
|
||||
log::info!("load core tables: {:.2}s", started.elapsed().as_secs_f32());
|
||||
let entity_models = entity_models.map(Arc::new);
|
||||
let CoreTables {
|
||||
assets,
|
||||
item_defaults,
|
||||
@@ -21334,6 +21558,7 @@ fn load_resources(
|
||||
let resources = resources.clone();
|
||||
let root = root.clone();
|
||||
let vanilla_root = vanilla_root.to_owned();
|
||||
let entity_models = entity_models.clone();
|
||||
let rest_progress = progress.clone();
|
||||
let progress = progress.clone();
|
||||
let handle = std::thread::Builder::new()
|
||||
@@ -21347,6 +21572,7 @@ fn load_resources(
|
||||
&assets,
|
||||
&behaviour_json,
|
||||
&gameplay_projection,
|
||||
entity_models.as_deref(),
|
||||
visibility,
|
||||
&progress,
|
||||
started,
|
||||
@@ -21377,6 +21603,7 @@ fn load_resources(
|
||||
&assets,
|
||||
&behaviour_json,
|
||||
&gameplay_projection,
|
||||
entity_models.as_deref(),
|
||||
visibility,
|
||||
progress,
|
||||
started,
|
||||
@@ -21399,7 +21626,9 @@ fn load_resources(
|
||||
prepare_renderer_textures(&pack)
|
||||
})
|
||||
});
|
||||
let game_cpu = startup::time("handoff.game_cpu", || GameCpuResources::load(&root));
|
||||
let game_cpu = startup::time("handoff.game_cpu", || {
|
||||
GameCpuResources::load(&root, entity_models.as_deref())
|
||||
});
|
||||
(game_cpu, textures.join())
|
||||
});
|
||||
let game_cpu = match game_cpu {
|
||||
@@ -21421,6 +21650,7 @@ fn load_resources(
|
||||
};
|
||||
Ok(Loaded {
|
||||
game_cpu,
|
||||
entity_models,
|
||||
renderer_cpu,
|
||||
root,
|
||||
pack_error,
|
||||
@@ -21468,6 +21698,7 @@ fn load_world_rest(
|
||||
assets: &Assets,
|
||||
behaviour_json: &serde_json::Value,
|
||||
gameplay_projection: &[horizon_client::blocks::GameplayProjection],
|
||||
entity_models: Option<&horizon_client::mobs::EntityModelDocument>,
|
||||
visibility: horizon_world::block_render::FaceVisibility,
|
||||
progress: &Progress,
|
||||
started: Instant,
|
||||
@@ -21522,7 +21753,10 @@ fn load_world_rest(
|
||||
let load_mobs = || -> Result<_> {
|
||||
let _step = startup::step("rest.mobs");
|
||||
let mob_types: HashMap<_, _> = if draws {
|
||||
MobTypes::load(root)?
|
||||
match entity_models {
|
||||
Some(document) => MobTypes::load_with_document(root, document)?,
|
||||
None => MobTypes::load(root)?,
|
||||
}
|
||||
} else {
|
||||
MobTypes::load_for_server(root)?
|
||||
}
|
||||
@@ -21542,11 +21776,18 @@ fn load_world_rest(
|
||||
let load_transformers = || -> Result<_> {
|
||||
let _step = startup::step("rest.transformers_models");
|
||||
let transformers = horizon_client::block_transform::Transformers::load(root, registry)?;
|
||||
let player_type = match entity_models {
|
||||
Some(document) => perspective::player_type_with_document(root, document),
|
||||
None => perspective::player_type(root),
|
||||
};
|
||||
let models = (
|
||||
perspective::player_type(root),
|
||||
player_arms(root, "minecraft:player#main"),
|
||||
player_arms(root, "minecraft:player_slim#main"),
|
||||
horizon_client::mobs::load_model(root, "minecraft:spin_attack#main"),
|
||||
player_type,
|
||||
player_arms(root, "minecraft:player#main", entity_models),
|
||||
player_arms(root, "minecraft:player_slim#main", entity_models),
|
||||
match entity_models {
|
||||
Some(document) => document.model("minecraft:spin_attack#main"),
|
||||
None => horizon_client::mobs::load_model(root, "minecraft:spin_attack#main"),
|
||||
},
|
||||
);
|
||||
Ok((transformers, models))
|
||||
};
|
||||
@@ -21838,6 +22079,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
visible_static_block_entity_sections: &[],
|
||||
static_block_entity_stats: Default::default(),
|
||||
entity_motion: &[],
|
||||
entity_motion_posed: &[],
|
||||
entity_spans: &[],
|
||||
skin: None,
|
||||
glowing: &[],
|
||||
@@ -21890,6 +22132,7 @@ fn start_game<'a>(
|
||||
rest_loader: Option<WorldRestLoader>,
|
||||
settings: Option<Settings>,
|
||||
prepared: Option<GameCpuResources>,
|
||||
entity_models: Option<Arc<horizon_client::mobs::EntityModelDocument>>,
|
||||
) -> Result<Option<Game<'a>>> {
|
||||
let handoff = prepared.is_some();
|
||||
let mut settings = match settings {
|
||||
@@ -21899,6 +22142,15 @@ fn start_game<'a>(
|
||||
if let Some(d) = options.distance_override {
|
||||
settings.render_distance = d;
|
||||
}
|
||||
// A synchronous launch has no handoff worker: build its CPU resources
|
||||
// here, from the same entity-model document the rest worker read.
|
||||
let prepared = match prepared {
|
||||
Some(prepared) => Some(prepared),
|
||||
None => Some(GameCpuResources::load(
|
||||
&options.resources,
|
||||
entity_models.as_deref(),
|
||||
)?),
|
||||
};
|
||||
let mut game = Game::new(res, pack, gui, options, settings, audio, prepared)?;
|
||||
game.rest_loader = rest_loader;
|
||||
game.ui.vanilla_root = vanilla_root;
|
||||
@@ -21950,6 +22202,10 @@ fn start_game<'a>(
|
||||
},
|
||||
);
|
||||
}
|
||||
if let Some(directory) = std::env::var_os("HORIZON_NATIVE_CLIENT_PARTICLES_FIXTURE") {
|
||||
game.run_native_client_particles_fixture(std::path::Path::new(&directory))?;
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(directory) = std::env::var_os("HORIZON_BOAT_RIDER_FIXTURE") {
|
||||
game.run_boat_rider_fixture(std::path::Path::new(&directory))?;
|
||||
return Ok(None);
|
||||
@@ -22170,6 +22426,13 @@ impl Loading {
|
||||
return;
|
||||
}
|
||||
};
|
||||
if fullscreen && window.is_maximized() {
|
||||
window.set_maximized(false);
|
||||
let monitor = window
|
||||
.current_monitor()
|
||||
.or_else(|| window.primary_monitor());
|
||||
window.set_fullscreen(Some(winit::window::Fullscreen::Borderless(monitor)));
|
||||
}
|
||||
drop(window_step);
|
||||
horizon_client::telemetry::note_display(&window);
|
||||
// The renderer takes seconds to build and winit's window sits white
|
||||
@@ -22396,6 +22659,7 @@ impl Loading {
|
||||
loaded.rest_loader,
|
||||
Some(self.settings.clone()),
|
||||
loaded.game_cpu,
|
||||
loaded.entity_models,
|
||||
)?
|
||||
else {
|
||||
return Ok(None);
|
||||
@@ -22789,6 +23053,7 @@ pub fn run_with() -> Result<()> {
|
||||
loaded.rest_loader,
|
||||
None,
|
||||
None,
|
||||
loaded.entity_models,
|
||||
)?;
|
||||
if let Some(mut game) = game {
|
||||
// Minecraft.onGameLoadFinished: a start that made it this far is clean.
|
||||
@@ -22806,8 +23071,15 @@ pub fn run_with() -> Result<()> {
|
||||
|
||||
/// The right and left arms of a player model layer (`PlayerModel.rightArm`
|
||||
/// and `leftArm`, with their sleeves).
|
||||
fn player_arms(root: &std::path::Path, key: &str) -> [Option<horizon_client::mobs::Part>; 2] {
|
||||
let model = horizon_client::mobs::load_model(root, key);
|
||||
fn player_arms(
|
||||
root: &std::path::Path,
|
||||
key: &str,
|
||||
document: Option<&horizon_client::mobs::EntityModelDocument>,
|
||||
) -> [Option<horizon_client::mobs::Part>; 2] {
|
||||
let model = match document {
|
||||
Some(document) => document.model(key),
|
||||
None => horizon_client::mobs::load_model(root, key),
|
||||
};
|
||||
["right_arm", "left_arm"].map(|name| {
|
||||
model
|
||||
.as_ref()
|
||||
@@ -22835,10 +23107,13 @@ fn keyboard_button(playing: bool, action: KeyAction) -> Option<bool> {
|
||||
playing.then_some(action != KeyAction::Release)
|
||||
}
|
||||
|
||||
/// Gameplay owns the keyboard: `Screen::Playing`, no title, no server dialog.
|
||||
/// Vanilla's `DialogScreen` is a screen, so `KeyMapping.releaseAll` runs.
|
||||
fn gameplay_has_keyboard(screen: Screen, title: bool, dialog: bool) -> bool {
|
||||
screen == Screen::Playing && !title && !dialog
|
||||
/// Gameplay owns the keyboard: `Screen::Playing`, no title, and nothing open
|
||||
/// over the world (`screen_over_playing`: a server dialog, a book or lectern,
|
||||
/// a sign editor, a prompt). Each is a `Screen` in vanilla, so `Gui.setScreen`
|
||||
/// runs `KeyMapping.releaseAll` and `Minecraft.tick` skips `handleKeybinds`
|
||||
/// while it is up: a mouse button held into it is let go of (#9729).
|
||||
fn gameplay_has_keyboard(screen: Screen, title: bool, screen_over: bool) -> bool {
|
||||
screen == Screen::Playing && !title && !screen_over
|
||||
}
|
||||
|
||||
/// A dialog drops held keys and mouse buttons it does not see (#4974).
|
||||
@@ -23000,7 +23275,7 @@ mod startup_cpu_tests {
|
||||
.unwrap();
|
||||
let worker_root = root.clone();
|
||||
let prepared = std::thread::spawn(move || {
|
||||
GameCpuResources::load(&worker_root).map_err(|error| error.to_string())
|
||||
GameCpuResources::load(&worker_root, None).map_err(|error| error.to_string())
|
||||
})
|
||||
.join()
|
||||
.unwrap()
|
||||
@@ -23021,7 +23296,7 @@ mod startup_cpu_tests {
|
||||
"minecraft:stone"
|
||||
);
|
||||
// No documents from the old generation remain available to reload.
|
||||
assert!(GameCpuResources::load(&root).is_err());
|
||||
assert!(GameCpuResources::load(&root, None).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23575,8 +23850,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A server dialog is a screen: held keys and the use button are released
|
||||
/// (`KeyMapping.releaseAll`), and toggled sneak comes back when it closes (#4974).
|
||||
/// A server dialog, a book, a sign editor or a prompt over the world is a
|
||||
/// screen: held keys and the mouse buttons are released
|
||||
/// (`KeyMapping.releaseAll`), and toggled sneak comes back when it closes
|
||||
/// (#4974, #9729).
|
||||
#[test]
|
||||
fn a_dialog_releases_held_keys_like_a_screen() {
|
||||
assert!(gameplay_has_keyboard(Screen::Playing, false, false));
|
||||
|
||||
@@ -661,6 +661,8 @@ impl Game<'_> {
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
/// `Entity.get`: the mob's live custom-name component and type variants,
|
||||
/// alongside the other state the entity predicate asks for.
|
||||
pub(super) fn mob_entity(&self, m: &horizon_client::mobs::Mob) -> EntityView {
|
||||
// mainhand, offhand, head, chest, legs, feet.
|
||||
let slot = |i: &Option<std::sync::Arc<str>>| {
|
||||
@@ -720,6 +722,7 @@ impl Game<'_> {
|
||||
.map(|(id, value)| (id.to_owned(), value))
|
||||
.into_iter()
|
||||
.collect(),
|
||||
custom_name: m.custom_name_text.as_ref().map(Text::component),
|
||||
passengers,
|
||||
nbt,
|
||||
team: self.mob_team_of(m).map(|t| t.name.clone()),
|
||||
@@ -2391,6 +2394,91 @@ pub(super) fn text_of(v: &Value) -> Text {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn mob_name_components_read_live_text_without_saved_nbt() {
|
||||
use crate::game::{Audio, Options, Settings, commands::Target, load_resources};
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&crate::game::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();
|
||||
let kind = game.res.mob_types()["minecraft:pig"].clone();
|
||||
game.mobs.push(horizon_client::mobs::Mob::new(
|
||||
12500,
|
||||
kind,
|
||||
Vec3::new(4.5, 200.0, 4.5),
|
||||
0.0,
|
||||
));
|
||||
let matches = |game: &Game<'_>, key: &str, want: Value| {
|
||||
let view = game.entity_view_of(Target::Mob(12500));
|
||||
assert_eq!(view.kind, "minecraft:pig");
|
||||
assert!(view.nbt.is_none());
|
||||
let mut predicate = serde_json::Map::new();
|
||||
predicate.insert(
|
||||
key.into(),
|
||||
serde_json::json!({"minecraft:custom_name": want}),
|
||||
);
|
||||
adv::entity(
|
||||
&Value::Object(predicate),
|
||||
&view,
|
||||
&View(game),
|
||||
[4.5, 200.0, 4.5],
|
||||
)
|
||||
};
|
||||
for key in ["components", "minecraft:components"] {
|
||||
assert!(!matches(&game, key, serde_json::json!("Bob")));
|
||||
}
|
||||
for (name, unequal) in [
|
||||
(serde_json::json!("Bob"), serde_json::json!("minecraft:Bob")),
|
||||
(serde_json::json!("minecraft:Bob"), serde_json::json!("Bob")),
|
||||
(
|
||||
serde_json::json!({"text": "Bob", "color": "red", "bold": true}),
|
||||
serde_json::json!("Bob"),
|
||||
),
|
||||
(
|
||||
serde_json::json!({"text": "B", "extra": [{"text": "ob"}]}),
|
||||
serde_json::json!("Bob"),
|
||||
),
|
||||
] {
|
||||
let text = horizon_client::scoreboard::comp_text(&name);
|
||||
game.mobs[0].custom_name = Some(text.plain());
|
||||
game.mobs[0].custom_name_text = Some(text);
|
||||
for key in ["components", "minecraft:components"] {
|
||||
assert!(matches(&game, key, name.clone()), "{key}: {name}");
|
||||
assert!(!matches(&game, key, unequal.clone()), "{key}: {unequal}");
|
||||
}
|
||||
}
|
||||
game.mobs[0].custom_name = None;
|
||||
game.mobs[0].custom_name_text = None;
|
||||
for key in ["components", "minecraft:components"] {
|
||||
assert!(!matches(&game, key, serde_json::json!("Bob")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn mob_predicate_view_reads_active_effect_instances() {
|
||||
|
||||
@@ -359,8 +359,26 @@ impl Game<'_> {
|
||||
.into_iter()
|
||||
.zip(["helmet", "chestplate", "leggings", "boots"])
|
||||
}));
|
||||
// The smithing screen's preview stand wears the result slot's item.
|
||||
let preview = self
|
||||
.station
|
||||
.as_ref()
|
||||
.filter(|s| s.kind == super::stations::Kind::Smithing)
|
||||
.and_then(|s| s.result.as_ref())
|
||||
.map(|stack| {
|
||||
let equip = items.get(&stack.item).and_then(|i| i.equip_slot.as_deref());
|
||||
let layer = match preview_slot(equip) {
|
||||
2 => "boots",
|
||||
3 => "leggings",
|
||||
4 => "chestplate",
|
||||
5 => "helmet",
|
||||
_ => "",
|
||||
};
|
||||
(Some(stack), layer)
|
||||
});
|
||||
let stacks = stacks.map(|(s, l)| (s.as_ref(), l)).chain(preview);
|
||||
for (stack, layer) in stacks {
|
||||
let Some(stack) = stack.as_ref().filter(|s| s.extra.is_some()) else {
|
||||
let Some(stack) = stack.filter(|s| s.extra.is_some()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(asset) = items
|
||||
@@ -504,6 +522,27 @@ impl Game<'_> {
|
||||
let drop = self.drop_offset_from(0.5, 1.975, (pos[0], pos[1], pos[2]), true);
|
||||
stand.position.y = f64::from(pos[1]) + drop;
|
||||
stand.previous = stand.position;
|
||||
// `EntityType.createDefaultStackConfig`: the item's `custom_data`, then its
|
||||
// `entity_data` merged over the stand; `ArmorStandItem.useOn` then snaps it
|
||||
// to the placement yaw with pitch 0.
|
||||
let own = horizon_client::entity_nbt::armor_stand_to_nbt(&stand, [0; 4], None);
|
||||
let mut raw = horizon_client::block_entity_item::stack_configured_entity(
|
||||
stack,
|
||||
"minecraft:armor_stand",
|
||||
&own,
|
||||
);
|
||||
if let Some(mut loaded) = horizon_client::entity_nbt::armor_stand_from_nbt(&raw, stand.id) {
|
||||
loaded.first_tick = stand.first_tick;
|
||||
loaded.yaw = stand.yaw;
|
||||
loaded.previous_yaw = stand.previous_yaw;
|
||||
loaded.pitch = 0.0;
|
||||
loaded.previous = loaded.position;
|
||||
stand = loaded;
|
||||
}
|
||||
if let horizon_world::nbt::Tag::Compound(map) = &mut raw {
|
||||
map.remove(&"UUID".into());
|
||||
}
|
||||
self.persist.raw.insert(stand.id, raw);
|
||||
let foot = stand.position;
|
||||
// EntityType.create's spawn yaw, `level.getRandom().nextFloat() * 360`, before
|
||||
// ArmorStandItem.useOn turns the stand to face the player.
|
||||
|
||||
@@ -81,7 +81,13 @@ impl Game<'_> {
|
||||
if let (Some(name), Tag::Compound(map)) = (self.world.block_names.get(&key), &mut tag) {
|
||||
map.insert("CustomName".into(), Tag::String(name.as_str().into()));
|
||||
}
|
||||
block_entity_nbt::canonical(&tag)
|
||||
if matches!(entity, BlockEntity::Mod(_)) {
|
||||
// The mod owns its serializer; the vanilla canonicalizer only knows
|
||||
// vanilla entity types and must not discard this instance's data.
|
||||
Some(tag)
|
||||
} else {
|
||||
block_entity_nbt::canonical(&tag)
|
||||
}
|
||||
}
|
||||
|
||||
/// The `CustomName` a block entity loads from `tag`
|
||||
@@ -125,11 +131,26 @@ impl Game<'_> {
|
||||
Some(t) if t == "sticky" => "minecraft:sticky_piston",
|
||||
_ => "minecraft:piston",
|
||||
});
|
||||
let item = clone_item(&facts.name, head.unwrap_or(&facts.item));
|
||||
// FlowerPotBlock.getCloneItemStack: a potted plant picks as that plant
|
||||
// (`new ItemStack(potted)`), an empty pot as the pot itself.
|
||||
let item = picked_item(self.res.behaviours(), state, item)?;
|
||||
let mut stack = ItemStack::new(item, 1);
|
||||
let mut custom = None;
|
||||
self.native_block_clone_item(&facts.name, pos, state, &mut custom);
|
||||
let mut stack = match custom {
|
||||
Some(stack) => {
|
||||
if stack.count == 0
|
||||
|| stack.item.as_ref() == "minecraft:air"
|
||||
|| self.items().get(&stack.item).is_none()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
stack
|
||||
}
|
||||
None => {
|
||||
let item = clone_item(&facts.name, head.unwrap_or(&facts.item));
|
||||
// FlowerPotBlock.getCloneItemStack: a potted plant picks as
|
||||
// that plant (`new ItemStack(potted)`), an empty pot as itself.
|
||||
let item = picked_item(self.res.behaviours(), state, item)?;
|
||||
ItemStack::new(item, 1)
|
||||
}
|
||||
};
|
||||
let mut patch = Compound::new();
|
||||
// BeehiveBlock.getCloneItemStack: the honey level as the item's block state.
|
||||
if include_data
|
||||
|
||||
@@ -1,23 +1,9 @@
|
||||
//! The player's `Entity.boardingCooldown`: 60 ticks after leaving a vehicle in
|
||||
//! which a non-forced `startRiding` (`Entity.canRide`) is refused.
|
||||
//! Mobs keep theirs in `extra.boarding_cooldown`; a server's guests keep theirs
|
||||
//! in `Guest::boarding_cooldown`, swapped in for their turn like `riding`.
|
||||
use super::{Game, boat_passengers::BOARDING_COOLDOWN};
|
||||
|
||||
/// `Entity.baseTick`: the cooldown counts down while positive.
|
||||
pub(super) fn tick_down(cooldown: i32) -> i32 {
|
||||
(cooldown - 1).max(0)
|
||||
}
|
||||
|
||||
/// `Entity.removePassenger`: the passenger that left sets its cooldown to 60.
|
||||
/// `was` and `now` are its vehicle before and after.
|
||||
pub(super) fn after_leaving(cooldown: i32, was: Option<u32>, now: Option<u32>) -> i32 {
|
||||
if was.is_some() && now.is_none() {
|
||||
BOARDING_COOLDOWN
|
||||
} else {
|
||||
cooldown
|
||||
}
|
||||
}
|
||||
//! A player's `Entity.boardingCooldown` is always 0: `Player.removeVehicle`
|
||||
//! zeroes the 60 ticks `Entity.removePassenger` just set (#11158), so
|
||||
//! `Entity.canRide` only asks that the player is not sneaking, which each
|
||||
//! vehicle's interaction checks. Mobs keep theirs in `extra.boarding_cooldown`
|
||||
//! (`boat_passengers::BOARDING_COOLDOWN`).
|
||||
use super::Game;
|
||||
|
||||
/// `Entity.getIndirectPassengersStream` for `vehicle`: the vehicle and every
|
||||
/// vehicle riding it, directly or through another rider. `mounts` holds
|
||||
@@ -63,48 +49,12 @@ impl Game<'_> {
|
||||
self.trigger_started_riding(vehicle);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.canRide` for the player acting now (the host's, or the guest
|
||||
/// whose turn it is): the cooldown has elapsed, and the player did not
|
||||
/// leave a vehicle since the tick began (the tick that starts the
|
||||
/// cooldown has not run yet).
|
||||
pub(super) fn may_board(&self) -> bool {
|
||||
self.boarding_cooldown <= 0 && !(self.riding_at_tick.is_some() && self.riding.is_none())
|
||||
}
|
||||
/// The host's own cooldown, once per tick.
|
||||
pub(super) fn tick_boarding(&mut self) {
|
||||
self.boarding_cooldown = tick_down(self.boarding_cooldown);
|
||||
self.boarding_cooldown =
|
||||
after_leaving(self.boarding_cooldown, self.riding_at_tick, self.riding);
|
||||
self.riding_at_tick = self.riding;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn leaving_sets_sixty_and_boarding_waits_it_out() {
|
||||
let mut cd = after_leaving(0, Some(7), None);
|
||||
assert_eq!(cd, 60);
|
||||
for _ in 0..59 {
|
||||
cd = tick_down(cd);
|
||||
assert!(cd > 0, "still refused");
|
||||
}
|
||||
cd = tick_down(cd);
|
||||
assert_eq!(cd, 0, "allowed on the sixtieth tick");
|
||||
}
|
||||
#[test]
|
||||
fn staying_or_boarding_does_not_set_it() {
|
||||
assert_eq!(after_leaving(0, Some(7), Some(7)), 0);
|
||||
assert_eq!(after_leaving(0, None, Some(7)), 0);
|
||||
assert_eq!(after_leaving(0, None, None), 0);
|
||||
}
|
||||
#[test]
|
||||
fn never_negative() {
|
||||
assert_eq!(tick_down(0), 0);
|
||||
}
|
||||
#[test]
|
||||
fn started_riding_reaches_every_rider_of_the_vehicle() {
|
||||
// Entity.startRiding: START_RIDING_TRIGGER for every ServerPlayer in
|
||||
// entityToRide.getIndirectPassengersStream(). A mob between two
|
||||
|
||||
@@ -415,6 +415,7 @@ impl Game<'_> {
|
||||
let b = &self.boats[i];
|
||||
let full = count == b.max_passengers();
|
||||
let delta = b.delta_rotation;
|
||||
let boat_box = b.bounds();
|
||||
let seats: Vec<_> = mobs
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -437,14 +438,16 @@ impl Game<'_> {
|
||||
.entity_has("minecraft:can_turn_in_boats", &m.kind.id);
|
||||
(
|
||||
j,
|
||||
offset,
|
||||
b.seat(index, count, animal, offset, false),
|
||||
b.seat(index, count, animal, offset, true),
|
||||
turns.then_some((b.yaw + turn, b.previous_yaw + turn)),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for (j, at, previous, facing) in seats {
|
||||
for (j, offset, at, previous, facing) in seats {
|
||||
let m = &mut self.mobs[j];
|
||||
m.seated(id, offset, boat_box);
|
||||
m.position = at;
|
||||
m.previous = previous;
|
||||
m.velocity = Vec3::ZERO;
|
||||
|
||||
@@ -1376,7 +1376,6 @@ impl Game<'_> {
|
||||
if sneaking
|
||||
|| self.boats[i].out_of_control >= 60.0
|
||||
|| self.riding.is_some()
|
||||
|| !self.may_board()
|
||||
|| self.ridden_by_guest(self.boats[i].id)
|
||||
|| !self.boat_has_room(i)
|
||||
{
|
||||
@@ -1489,7 +1488,12 @@ impl Game<'_> {
|
||||
self.hurt_boat_result(i, damage, creative).unwrap_or(false)
|
||||
}
|
||||
/// None means a canceled hit; Some records whether an accepted hit destroyed it.
|
||||
fn hurt_boat_result(&mut self, i: usize, mut damage: f32, creative: bool) -> Option<bool> {
|
||||
pub(super) fn hurt_boat_result(
|
||||
&mut self,
|
||||
i: usize,
|
||||
mut damage: f32,
|
||||
creative: bool,
|
||||
) -> Option<bool> {
|
||||
let mut cancel = false;
|
||||
self.native_vehicle_damage("boat", &mut damage, &mut cancel);
|
||||
if cancel {
|
||||
|
||||
@@ -541,6 +541,25 @@ impl Game<'_> {
|
||||
self.ui.registries.permission = level;
|
||||
out
|
||||
}
|
||||
/// Active enchantment registry snapshot for command codecs and completions.
|
||||
/// Replace both ID views; keep the existing registry tags intact.
|
||||
pub(super) fn sync_enchantment_registries(&mut self) {
|
||||
let mut ids: Vec<String> = self
|
||||
.enchantments()
|
||||
.list
|
||||
.iter()
|
||||
.map(|entry| entry.id.to_string())
|
||||
.collect();
|
||||
ids.sort();
|
||||
ids.dedup();
|
||||
self.ui.registries.enchantments = ids.clone();
|
||||
self.ui
|
||||
.registries
|
||||
.by_registry
|
||||
.entry("minecraft:enchantment".into())
|
||||
.or_default()
|
||||
.ids = ids;
|
||||
}
|
||||
/// Registry contents for argument parsing (built when a world opens).
|
||||
pub(super) fn build_registries(&mut self) {
|
||||
let permission = owner_permission(self.allow_commands());
|
||||
@@ -665,6 +684,7 @@ impl Game<'_> {
|
||||
.filter(|(_, m)| m.show)
|
||||
.map(|(k, _)| k.clone())
|
||||
.collect();
|
||||
self.sync_enchantment_registries();
|
||||
}
|
||||
/// The block the player is looking at, for coordinate suggestions, and
|
||||
/// the entity, for entity arguments (ClientSuggestionProvider).
|
||||
@@ -2067,3 +2087,130 @@ mod tests {
|
||||
assert_eq!(step(bed.rect, Button::DPadDown), Some(field));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod enchantment_snapshot_tests {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn synchronized_definitions_refresh_existing_enchantment_views() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
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();
|
||||
|
||||
let dir =
|
||||
std::path::PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap()).join("h024-model");
|
||||
let pack = dir.join("datapacks/model");
|
||||
let tags = pack.join("data/h024/tags/enchantment");
|
||||
std::fs::create_dir_all(&tags).unwrap();
|
||||
std::fs::write(pack.join("pack.mcmeta"), "{}").unwrap();
|
||||
std::fs::write(tags.join("empty.json"), r#"{"values":[]}"#).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("datapacks.json"),
|
||||
r#"{"enabled":["file/model"],"disabled":[]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
game.world_root = dir;
|
||||
game.world_tables = game.build_world_tables(false, &[]).unwrap().0;
|
||||
assert!(
|
||||
game.world_tables.is_some(),
|
||||
"the real active table container must be available"
|
||||
);
|
||||
let base = game.stations().enchantments.clone();
|
||||
assert!(base.get("minecraft:sharpness").is_some());
|
||||
// Same definition independently admitted by fresh Java. This is controlled
|
||||
// synchronized model input, NOT a world-pack or packet-delivery claim.
|
||||
let definition = serde_json::json!({"description":"H024 audit","supported_items":"minecraft:diamond_sword",
|
||||
"weight":1,"max_level":1,"min_cost":{"base":1,"per_level_above_first":0},
|
||||
"max_cost":{"base":1,"per_level_above_first":0},"anvil_cost":1,"slots":["mainhand"],"effects":{}});
|
||||
let synced = std::sync::Arc::new(base.with_entries([("h024:audit", &definition)]).unwrap());
|
||||
game.world_tables.as_mut().unwrap().stations.enchantments = synced.clone();
|
||||
game.world_tables.as_mut().unwrap().items.enchantments = synced;
|
||||
assert!(
|
||||
game.enchantments().get("h024:audit").is_some(),
|
||||
"actual authority adopts the definition first"
|
||||
);
|
||||
let tags = vec!["h024:retained_tag".to_owned()];
|
||||
game.ui
|
||||
.registries
|
||||
.by_registry
|
||||
.entry("minecraft:enchantment".into())
|
||||
.or_default()
|
||||
.tags = tags.clone();
|
||||
let item_ids = game.ui.registries.ids("minecraft:item").to_vec();
|
||||
let effects = game.ui.registries.effects.clone();
|
||||
game.sync_function_registries();
|
||||
for phase in [0, 1] {
|
||||
if phase == 1 {
|
||||
game.build_registries();
|
||||
}
|
||||
let reg = &game.ui.registries;
|
||||
assert!(
|
||||
reg.ids("minecraft:enchantment")
|
||||
.contains(&"h024:audit".to_owned()),
|
||||
"active custom ID reaches actual Game sync/build snapshot"
|
||||
);
|
||||
assert!(
|
||||
reg.ids("minecraft:enchantment")
|
||||
.contains(&"minecraft:sharpness".to_owned())
|
||||
);
|
||||
assert!(
|
||||
!reg.ids("minecraft:enchantment")
|
||||
.contains(&"h024:missing".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
reg.ids("minecraft:enchantment"),
|
||||
reg.enchantments.as_slice()
|
||||
);
|
||||
assert_eq!(reg.by_registry["minecraft:enchantment"].tags, tags);
|
||||
assert_eq!(reg.ids("minecraft:item"), item_ids);
|
||||
assert_eq!(reg.effects, effects);
|
||||
}
|
||||
game.world_tables.as_mut().unwrap().stations.enchantments = base.clone();
|
||||
game.world_tables.as_mut().unwrap().items.enchantments = base;
|
||||
assert!(game.enchantments().get("h024:audit").is_none());
|
||||
game.sync_function_registries();
|
||||
assert!(
|
||||
!game
|
||||
.ui
|
||||
.registries
|
||||
.ids("minecraft:enchantment")
|
||||
.contains(&"h024:audit".to_owned()),
|
||||
"refresh replaces rather than accumulating stale IDs"
|
||||
);
|
||||
assert_eq!(
|
||||
game.ui.registries.ids("minecraft:enchantment"),
|
||||
game.ui.registries.enchantments.as_slice()
|
||||
);
|
||||
assert_eq!(
|
||||
game.ui.registries.by_registry["minecraft:enchantment"].tags,
|
||||
tags
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ impl Game<'_> {
|
||||
return None;
|
||||
}
|
||||
let m = self.modifiers;
|
||||
let held = |keys: [KeyCode; 2]| keys.iter().any(|k| self.keys.raw(k));
|
||||
let held = |keys: [KeyCode; 2]| keys.iter().any(|k| self.physical_keys.contains(k));
|
||||
clipboard::edit_shortcut(
|
||||
clipboard::shortcut_letter(&event.logical_key, event.physical_key),
|
||||
self.has_control_down_with_quirk(),
|
||||
@@ -23,9 +23,11 @@ impl Game<'_> {
|
||||
}
|
||||
/// `InputWithModifiers.hasControlDownWithQuirk`: the edit shortcuts'
|
||||
/// modifier (`InputQuirks.EDIT_SHORTCUT_KEY_MODIFIER`: Cmd on macOS, Ctrl
|
||||
/// elsewhere), held on the keyboard or by an agent.
|
||||
/// elsewhere), held on the keyboard or by an agent. It reads the physical
|
||||
/// keys, never `keys`: a latched sprint or sneak toggle puts Control or
|
||||
/// Shift in `keys` without the key being down.
|
||||
pub(super) fn has_control_down_with_quirk(&self) -> bool {
|
||||
let held = |keys: [KeyCode; 2]| keys.iter().any(|k| self.keys.raw(k));
|
||||
let held = |keys: [KeyCode; 2]| keys.iter().any(|k| self.physical_keys.contains(k));
|
||||
if cfg!(target_os = "macos") {
|
||||
self.modifiers.super_key() || held([KeyCode::SuperLeft, KeyCode::SuperRight])
|
||||
} else {
|
||||
|
||||
@@ -153,6 +153,9 @@ pub(super) struct CombatTracker {
|
||||
/// (`MobEvent::ExplosionSource`): their names, for the death messages of
|
||||
/// the mobs the blast killed.
|
||||
pub(super) exploded: Vec<(u32, Text)>,
|
||||
/// The entity types of `exploded`: a player their blast killed is still
|
||||
/// credited to them (`LivingEntity.getKillCredit` during the explosion).
|
||||
pub(super) exploded_kinds: Vec<(u32, String)>,
|
||||
/// The player's fall distance before this tick's movement (as of the
|
||||
/// end of the last one).
|
||||
fall_before: f64,
|
||||
@@ -329,8 +332,9 @@ impl Game<'_> {
|
||||
self.combat.in_combat = true;
|
||||
}
|
||||
}
|
||||
// Player.actuallyHurt: damage_taken in tenths.
|
||||
self.award_custom_stat("damage_taken", (amount * 10.0).round() as i32);
|
||||
if let Some(tenths) = damage_taken_award(amount) {
|
||||
self.award_custom_stat("damage_taken", tenths);
|
||||
}
|
||||
self.combat.entries.push(Entry {
|
||||
damage_type: name,
|
||||
damage: amount,
|
||||
@@ -697,6 +701,32 @@ fn mob_name(m: &Mob) -> Text {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Player.actuallyHurt`'s DAMAGE_TAKEN award: `dmg * 10.0F` rounded as Java
|
||||
/// rounds a float (`jmath::round_f32`), only for damage under 3.4028235E37F.
|
||||
/// A huge hit (`/damage @s 3.4e38`) awards nothing, not a saturated count
|
||||
/// (#11339).
|
||||
fn damage_taken_award(amount: f32) -> Option<i32> {
|
||||
(amount < 3.402_823_5e37).then(|| horizon_core::jmath::round_f32(amount * 10.0))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod damage_taken_tests {
|
||||
use super::damage_taken_award;
|
||||
|
||||
#[test]
|
||||
fn tenths_rounded_as_java_does_and_nothing_for_a_huge_hit() {
|
||||
assert_eq!(damage_taken_award(2.0), Some(20));
|
||||
// Java rounds 0.5F up to 1: ties go up.
|
||||
assert_eq!(damage_taken_award(0.05), Some(1));
|
||||
assert_eq!(damage_taken_award(0.04), Some(0));
|
||||
// Below the threshold the count saturates, as Java's float rounding does.
|
||||
assert_eq!(damage_taken_award(3.4e37), Some(i32::MAX));
|
||||
assert_eq!(damage_taken_award(3.402_823_5e37), None);
|
||||
assert_eq!(damage_taken_award(3.4e38), None);
|
||||
assert_eq!(damage_taken_award(f32::INFINITY), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod held_item_tests {
|
||||
use super::has_custom_name;
|
||||
|
||||
@@ -971,12 +971,13 @@ impl Game<'_> {
|
||||
.into_iter()
|
||||
.find(|t| self.level_holds(*t))
|
||||
}
|
||||
/// Whether the level in place holds the entity a target names.
|
||||
/// `ServerLevel.getEntity`: whether the level in place still holds the
|
||||
/// entity a target names. Discard removes mobs from lookup immediately.
|
||||
fn level_holds(&self, t: Target) -> bool {
|
||||
match t {
|
||||
// A player is in no level of its own.
|
||||
Target::Player | Target::Guest(_) => true,
|
||||
Target::Mob(id) => self.mobs.iter().any(|m| m.id == id),
|
||||
Target::Mob(id) => self.mobs.iter().any(|m| m.id == id && !m.removed),
|
||||
Target::Item(id) => self.dropped.iter().any(|i| i.id == id),
|
||||
Target::Orb(id) => self.orbs.iter().any(|o| o.id == id),
|
||||
Target::Projectile(id) => self.creatures.projectiles.iter().any(|p| p.id == id),
|
||||
@@ -2476,11 +2477,13 @@ impl Game<'_> {
|
||||
// EndCrystal's constructor takes an id from the entity
|
||||
// counter, and the crystal is shown with its base.
|
||||
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);
|
||||
self.crystals.push(super::dragon::Crystal {
|
||||
id: self.next_entity,
|
||||
position: Vec3::new(pos[0], pos[1], pos[2]),
|
||||
position: at,
|
||||
show_bottom: true,
|
||||
time: self.world.random.next_int_bound(100_000).unwrap_or(0),
|
||||
time: super::dragon::Crystal::start_time(at, drawn, self.clock.still()),
|
||||
beam: None,
|
||||
invulnerable: false,
|
||||
});
|
||||
@@ -4457,6 +4460,48 @@ mod teleport_rider_tests {
|
||||
let rider = game.mobs.iter().find(|m| m.id == 43).unwrap();
|
||||
assert_eq!(rider.extra.mount, None, "the teleported rider dismounted");
|
||||
assert_eq!(rider.position, Vec3::new(0.0, 64.0, 0.0));
|
||||
// #9335 (2): a rider keeps its angle to the vehicle through an
|
||||
// absolute turn, and a relative turn adds the delta: a villager 30
|
||||
// degrees off a horse turning 0 -> 40 ends at 70.
|
||||
let horse2 = game.res.mob_types().get("minecraft:horse").unwrap().clone();
|
||||
let villager2 = game
|
||||
.res
|
||||
.mob_types()
|
||||
.get("minecraft:villager")
|
||||
.unwrap()
|
||||
.clone();
|
||||
game.mobs.push(horizon_client::mobs::Mob::new(
|
||||
50,
|
||||
horse2,
|
||||
Vec3::new(10.0, 64.0, 10.0),
|
||||
0.0,
|
||||
));
|
||||
let mut rider2 =
|
||||
horizon_client::mobs::Mob::new(51, villager2, Vec3::new(10.0, 64.5, 10.0), 30.0);
|
||||
rider2.extra.mount = Some(50);
|
||||
game.mobs.push(rider2);
|
||||
// Absolute turn: vehicle 0 -> 40, rider keeps its 30-degree offset.
|
||||
game.teleport_to_in_level(Target::Mob(50), [10.0, 64.0, 10.0], Some([40.0, 0.0]), true);
|
||||
let rider2 = game.mobs.iter().find(|m| m.id == 51).unwrap();
|
||||
assert_eq!(
|
||||
rider2.yaw, 70.0,
|
||||
"absolute turn keeps the rider's angle to the vehicle"
|
||||
);
|
||||
// Relative turn (~10 on the vehicle): the transition carries the
|
||||
// 10-degree delta and the rider adds it to its own angle (70 -> 80).
|
||||
game.teleport_to_in_level(Target::Mob(50), [10.0, 64.0, 10.0], Some([50.0, 0.0]), true);
|
||||
let rider2 = game.mobs.iter().find(|m| m.id == 51).unwrap();
|
||||
assert_eq!(
|
||||
rider2.yaw, 80.0,
|
||||
"relative turn shifts the rider by the delta"
|
||||
);
|
||||
// `facing` (turn_riders = false) leaves the rider's angle alone.
|
||||
game.teleport_to_in_level(Target::Mob(50), [10.0, 64.0, 10.0], Some([0.0, 0.0]), false);
|
||||
let rider2 = game.mobs.iter().find(|m| m.id == 51).unwrap();
|
||||
assert_eq!(
|
||||
rider2.yaw, 80.0,
|
||||
"a vehicle turned by `facing` leaves its rider"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -181,7 +181,9 @@ impl Game<'_> {
|
||||
.and_then(|a| a.last_state)
|
||||
.map(|s| self.state_to_nbt(s));
|
||||
let raw = self.persist.raw.get(&id);
|
||||
entity_nbt::arrow_to_nbt(p, uuid, owner, in_block, raw)
|
||||
// Projectile.addAdditionalSaveData stores the retained EntityReference,
|
||||
// including an owner that is not currently resolved to a live entity.
|
||||
entity_nbt::arrow_to_nbt(p, uuid, p.owner_uuid.or(owner), in_block, raw)
|
||||
.or_else(|| entity_nbt::fireball_to_nbt(p, uuid, owner, raw))
|
||||
.or_else(|| entity_nbt::throwable_to_nbt(p, uuid, owner, raw))
|
||||
.or_else(|| entity_nbt::spit_or_bullet_to_nbt(p, uuid, owner, raw))?
|
||||
@@ -311,6 +313,7 @@ impl Game<'_> {
|
||||
return Err(err("commands.data.entity.invalid", &[]));
|
||||
}
|
||||
Target::Mob(id) => {
|
||||
Self::normalize_loaded_mob_tags(&mut tag);
|
||||
let uuid = self.uuid_for(id);
|
||||
let kind = self
|
||||
.mobs
|
||||
@@ -433,6 +436,35 @@ impl Game<'_> {
|
||||
}
|
||||
set_key(&mut tag, "UUID", Tag::IntArray(uuid.to_vec()));
|
||||
}
|
||||
Target::Tnt(id) => {
|
||||
// Entity.load and PrimedTnt.readAdditionalSaveData mutate the
|
||||
// resident instance; load_tnt would allocate another id/join.
|
||||
let position = entity_nbt::position(&tag).ok_or_else(stale)?;
|
||||
let uuid = self.uuid_for(id);
|
||||
let block_state = tag
|
||||
.get("block_state")
|
||||
.and_then(|v| entity_nbt::state_from_nbt(self.pack.assets.registry, v));
|
||||
let tnt = self
|
||||
.tnts
|
||||
.iter_mut()
|
||||
.find(|tnt| tnt.id == id)
|
||||
.ok_or_else(stale)?;
|
||||
tnt.position = position;
|
||||
tnt.previous = position;
|
||||
tnt.velocity = entity_nbt::motion(&tag);
|
||||
tnt.rotation = entity_nbt::rotation(&tag);
|
||||
// Keep the existing loaded-fuse conversion policy (H090 is separate).
|
||||
tnt.fuse = match tag.get("fuse") {
|
||||
Some(Tag::Short(fuse)) => i32::from(*fuse),
|
||||
_ => 80,
|
||||
};
|
||||
tnt.on_ground = matches!(tag.get("OnGround"), Some(Tag::Byte(1)));
|
||||
tnt.no_gravity = entity_nbt::flag(&tag, "NoGravity");
|
||||
tnt.power = entity_nbt::tnt_power(&tag);
|
||||
tnt.block_state = block_state;
|
||||
// Runtime id/join, owner binding and guest attribution stay live.
|
||||
set_key(&mut tag, "UUID", Tag::IntArray(uuid.to_vec()));
|
||||
}
|
||||
Target::Cart(id) => {
|
||||
let uuid = self.uuid_for(id);
|
||||
let i = self
|
||||
@@ -475,6 +507,7 @@ impl Game<'_> {
|
||||
| Target::Frame(id)
|
||||
| Target::Boat(id)
|
||||
| Target::Cart(id)
|
||||
| Target::Tnt(id)
|
||||
| Target::Other(id)
|
||||
| Target::Fangs(id) = t
|
||||
{
|
||||
@@ -1508,3 +1541,112 @@ mod loot_table_tests {
|
||||
assert!(saved.get("Items").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod arrow_owner_tests {
|
||||
use super::Target;
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn loaded_arrow_getter_uses_registered_owner_reference_without_raw() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
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();
|
||||
|
||||
let position = Tag::List(vec![
|
||||
Tag::Double(8.5_f64.to_bits()),
|
||||
Tag::Double(202.0_f64.to_bits()),
|
||||
Tag::Double(4.5_f64.to_bits()),
|
||||
]);
|
||||
let mob_uuid = [213, 1, 2, 3];
|
||||
let mob = Tag::compound([
|
||||
("id", Tag::String("minecraft:pig".into())),
|
||||
("UUID", Tag::IntArray(mob_uuid.to_vec())),
|
||||
("Pos", position.clone()),
|
||||
("Health", Tag::Float(10.0_f32.to_bits())),
|
||||
("NoAI", Tag::Byte(1)),
|
||||
]);
|
||||
game.load_mob(mob, None).unwrap();
|
||||
assert!(game.mob_by_uuid(mob_uuid).is_some());
|
||||
let time = game.world.game_time;
|
||||
for (index, owner) in [Some(mob_uuid), Some([213, 91, 92, 93]), None]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let uuid = [213, 10, 0, index as i32 + 1];
|
||||
let mut tag = Tag::compound([
|
||||
("id", Tag::String("minecraft:arrow".into())),
|
||||
("UUID", Tag::IntArray(uuid.to_vec())),
|
||||
("Pos", position.clone()),
|
||||
("NoGravity", Tag::Byte(1)),
|
||||
("CustomName", Tag::String("kept name".into())),
|
||||
("Tags", Tag::List(vec![Tag::String("kept_tag".into())])),
|
||||
]);
|
||||
if let (Some(owner), Tag::Compound(fields)) = (owner, &mut tag) {
|
||||
fields.insert("Owner".into(), Tag::IntArray(owner.to_vec()));
|
||||
}
|
||||
game.load_arrow(tag, Some(uuid), None);
|
||||
let p = game.creatures.projectiles.last().unwrap();
|
||||
// Prove actual loader adoption before reading any saved NBT.
|
||||
assert_eq!(p.owner_uuid, owner);
|
||||
let id = p.id;
|
||||
assert_eq!(game.persist.uuids.get(&id), Some(&uuid));
|
||||
let expected = owner.map(|u| Tag::IntArray(u.to_vec()));
|
||||
let loaded_raw = game.persist.raw.get(&id).cloned();
|
||||
let mut stale = loaded_raw.clone().unwrap();
|
||||
if let Tag::Compound(fields) = &mut stale {
|
||||
fields.insert("Owner".into(), Tag::IntArray(vec![99; 4]));
|
||||
}
|
||||
let mut raw_cases = vec![None, loaded_raw];
|
||||
// A positive modeled reference must overwrite a conflicting raw UUID.
|
||||
// Generic stale optional-key cleanup is outside this getter bridge.
|
||||
if owner.is_some() {
|
||||
raw_cases.push(Some(stale));
|
||||
}
|
||||
for raw in raw_cases {
|
||||
if let Some(raw) = &raw {
|
||||
game.persist.raw.insert(id, raw.clone());
|
||||
} else {
|
||||
game.persist.raw.remove(&id);
|
||||
}
|
||||
let saved = game.entity_nbt(Target::Projectile(id)).unwrap();
|
||||
assert_eq!(saved.get("Owner"), expected.as_ref());
|
||||
assert_eq!(saved.get("UUID"), Some(&Tag::IntArray(uuid.to_vec())));
|
||||
if let Some(raw) = &raw {
|
||||
assert_eq!(saved.get("CustomName"), raw.get("CustomName"));
|
||||
assert_eq!(saved.get("Tags"), raw.get("Tags"));
|
||||
}
|
||||
assert_eq!(game.creatures.projectiles.last().unwrap().owner_uuid, owner);
|
||||
assert_eq!(game.persist.raw.get(&id), raw.as_ref());
|
||||
assert_eq!(game.world.game_time, time);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -371,7 +371,7 @@ impl Game<'_> {
|
||||
"if" | "unless" => {
|
||||
let want = word == "if";
|
||||
if c.child.is_none() {
|
||||
return self.run_conditional_terminal(c, sources, want);
|
||||
return self.run_conditional_terminal(c, sources, want, forked);
|
||||
}
|
||||
forked = true;
|
||||
let mut kept = Vec::new();
|
||||
@@ -411,11 +411,14 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// A conditional that ends the chain runs as a command: a test per source.
|
||||
/// `CommandSourceStack.handleError`: error delivery depends on the fork flag,
|
||||
/// including a fork that yielded only one source; success feedback is unchanged.
|
||||
fn run_conditional_terminal(
|
||||
&mut self,
|
||||
c: &Context,
|
||||
sources: Vec<Source>,
|
||||
want: bool,
|
||||
forked: bool,
|
||||
) -> R<i32> {
|
||||
let numeric = ["entity", "data", "blocks", "items", "slots"]
|
||||
.iter()
|
||||
@@ -429,10 +432,12 @@ impl Game<'_> {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
self.deliver(s.store, false, 0);
|
||||
if many {
|
||||
self.command_failure(e.message);
|
||||
} else {
|
||||
failure = Some(e);
|
||||
if !forked {
|
||||
if many {
|
||||
self.command_failure(e.message);
|
||||
} else {
|
||||
failure = Some(e);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -457,10 +462,12 @@ impl Game<'_> {
|
||||
} else {
|
||||
err("commands.execute.conditional.fail", &[])
|
||||
};
|
||||
if many {
|
||||
self.command_failure(e.message);
|
||||
} else {
|
||||
failure = Some(e);
|
||||
if !forked {
|
||||
if many {
|
||||
self.command_failure(e.message);
|
||||
} else {
|
||||
failure = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -946,16 +953,17 @@ impl Game<'_> {
|
||||
let (matched, count) = self.condition_matches(c)?;
|
||||
Ok((matched == want, count))
|
||||
}
|
||||
/// `if function`/`unless function`: the source stays when a function
|
||||
/// returns a non-zero (`if`) or zero (`unless`) value; one that does not
|
||||
/// return leaves it out either way.
|
||||
/// `FallthroughTask.execute`: a fallthrough supplies failure/zero to the
|
||||
/// function-condition continuation. Preserve explicit-result decisions
|
||||
/// and direct function callbacks.
|
||||
fn condition_function(&mut self, c: &Context, want: bool) -> R<(bool, i32)> {
|
||||
let Some(Arg::Resource(name)) = c.get("name").cloned() else {
|
||||
return Ok((false, 0));
|
||||
};
|
||||
let results = self.call_function_ref(&name, None)?;
|
||||
let keep = results.iter().any(|r| r.is_some_and(|v| (v != 0) == want));
|
||||
Ok((keep, 0))
|
||||
let fallthrough = !results.is_empty() && results.iter().all(Option::is_none);
|
||||
Ok((keep || (!want && fallthrough), 0))
|
||||
}
|
||||
/// `ExecuteCommand.isChunkLoaded`: an entity-ticking loading status and
|
||||
/// completed entity loading, rather than merely a retained lit column.
|
||||
@@ -1216,3 +1224,140 @@ mod tests {
|
||||
assert!(!is_player_in_place(OWNER, None, None));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod terminal_fork_tests {
|
||||
use super::Game;
|
||||
use crate::game::{Audio, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_core::position::ChunkPos;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn capture(game: &mut Game<'_>, command: &str) -> (Vec<String>, Vec<(bool, i32)>) {
|
||||
game.chat_capture = Some(Vec::new());
|
||||
game.cmds.results = Some(Vec::new());
|
||||
game.as_console(|g| g.run_command(command));
|
||||
let messages = game
|
||||
.chat_capture
|
||||
.take()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|t| t.plain())
|
||||
.collect();
|
||||
(messages, game.cmds.results.take().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn terminal_conditions_keep_the_fork_state_and_callbacks() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
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.game_dir = Some(std::env::current_dir().unwrap().join("game"));
|
||||
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);
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
let stone = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:stone")
|
||||
.unwrap();
|
||||
for x in 0..16 {
|
||||
for z in 0..16 {
|
||||
chunk.set(x, 199, z, stone.0);
|
||||
}
|
||||
}
|
||||
game.world.chunks.insert((0, 0), Arc::new(chunk));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
assert!(game.run_command("forceload add 0 0"));
|
||||
assert!(game.run_command("scoreboard objectives add h147 dummy"));
|
||||
assert!(game.run_command("scoreboard players set zero h147 0"));
|
||||
for n in [1, 2] {
|
||||
assert!(game.run_command(&format!("summon pig {} 200 4.5 {{UUID:[I;147,0,0,{n}],Health:10f,NoAI:1b,NoGravity:1b,Tags:[\"h147\"]}}", n as f64 + 0.5)));
|
||||
assert!(
|
||||
game.mobs
|
||||
.iter()
|
||||
.any(|m| game.persist.uuids.get(&m.id) == Some(&[147, 0, 0, n])
|
||||
&& m.kind.id == "minecraft:pig"
|
||||
&& m.health == 10.0)
|
||||
);
|
||||
}
|
||||
let time = game.world.game_time;
|
||||
let a = "00000093-0000-0000-0000-000000000001";
|
||||
let positive = capture(
|
||||
&mut game,
|
||||
&format!("execute as {a} if score zero h147 matches 0"),
|
||||
);
|
||||
assert_eq!(positive.0.len(), 1);
|
||||
assert_eq!(positive.1, vec![(true, 1)]);
|
||||
let direct = capture(&mut game, "execute if score zero h147 matches 1");
|
||||
assert_eq!(direct.0.len(), 1);
|
||||
assert_eq!(direct.1, vec![(false, 0)]);
|
||||
for tail in [
|
||||
format!("as {a} if score zero h147 matches 1"),
|
||||
format!("at {a} if block ~ ~-1 ~ air"),
|
||||
format!("as {a} if score zero h147_missing matches 1"),
|
||||
] {
|
||||
assert!(game.run_command("scoreboard players set result h147 -99"));
|
||||
assert!(game.run_command("scoreboard players set success h147 -99"));
|
||||
let (messages, callbacks) = capture(
|
||||
&mut game,
|
||||
&format!(
|
||||
"execute store result score result h147 store success score success h147 {tail}"
|
||||
),
|
||||
);
|
||||
assert_eq!(callbacks, vec![(false, 0)]);
|
||||
assert_eq!(
|
||||
game.score.board.score("result", "h147").map(|s| s.value),
|
||||
Some(0)
|
||||
);
|
||||
assert_eq!(
|
||||
game.score.board.score("success", "h147").map(|s| s.value),
|
||||
Some(0)
|
||||
);
|
||||
assert!(messages.is_empty(), "forked terminal error: {messages:?}");
|
||||
}
|
||||
let many = capture(
|
||||
&mut game,
|
||||
"execute as @e[type=pig,tag=h147] if score zero h147 matches 1",
|
||||
);
|
||||
assert!(many.0.is_empty());
|
||||
assert_eq!(many.1, vec![(false, 0), (false, 0)]);
|
||||
let missing = capture(&mut game, "execute if score zero h147_missing matches 1");
|
||||
assert_eq!(missing.0.len(), 1);
|
||||
assert_eq!(missing.1, vec![(false, 0)]);
|
||||
let empty = capture(
|
||||
&mut game,
|
||||
"execute as @e[tag=h147_none] if score zero h147 matches 1",
|
||||
);
|
||||
assert!(empty.0.is_empty() && empty.1.is_empty());
|
||||
assert_eq!(game.world.game_time, time);
|
||||
}
|
||||
}
|
||||
@@ -387,6 +387,9 @@ impl Game<'_> {
|
||||
Slot::Ender(i) => self.ender_items.get(*i).cloned().flatten(),
|
||||
Slot::Mob(id, key) => {
|
||||
let m = self.mobs.iter().find(|m| m.id == *id)?;
|
||||
if *key == "saddle" {
|
||||
return m.saddle_item();
|
||||
}
|
||||
let item = match *key {
|
||||
"mainhand" => &m.extra.main_hand,
|
||||
"offhand" => &m.extra.off_hand,
|
||||
@@ -495,7 +498,9 @@ impl Game<'_> {
|
||||
let name = match s {
|
||||
Slot::Guest(_, inner) => return self.equipment_slot_accepts(inner, stack),
|
||||
Slot::Armor(i @ 1..=3) => ["head", "chest", "legs", "feet"][*i],
|
||||
Slot::Mob(_, key) if matches!(*key, "chest" | "legs" | "feet") => key,
|
||||
Slot::Mob(_, key) if matches!(*key, "chest" | "legs" | "feet" | "body" | "saddle") => {
|
||||
key
|
||||
}
|
||||
_ => return true,
|
||||
};
|
||||
// ItemStack.isEmpty: air or a count of 0.
|
||||
@@ -588,6 +593,7 @@ impl Game<'_> {
|
||||
"chest" => m.extra.chest = item,
|
||||
"legs" => m.extra.legs = item,
|
||||
"feet" => m.extra.feet = item,
|
||||
"saddle" => m.set_saddle(stack),
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
@@ -1523,3 +1529,188 @@ mod stand_equip_event_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod mob_equipment_command_tests {
|
||||
use crate::game::commands::{Target, data::DataRef};
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_core::position::ChunkPos;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn with_game(test: impl FnOnce(&mut Game<'_>)) {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
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.game_dir = Some(
|
||||
std::path::PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap())
|
||||
.join(format!("h025-{:?}", std::thread::current().id())),
|
||||
);
|
||||
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();
|
||||
assert!(game.run_command("difficulty normal"));
|
||||
assert!(game.run_command("forceload add 0 0"));
|
||||
test(&mut game);
|
||||
}
|
||||
|
||||
fn actor(game: &mut Game<'_>, kind: &str, n: i32) -> (String, u32) {
|
||||
let uuid = format!("00000019-0000-0000-0000-{n:012x}");
|
||||
assert!(game.run_command(&format!("summon {kind} 4.5 200 5.5 {{UUID:[I;25,0,0,{n}],NoAI:1b,NoGravity:1b,Silent:1b,h025_raw:7,Tags:[h025]}}")), "{kind}/{n}: {:?}", game.ui.chat.recent_lines(3));
|
||||
let mob = game
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| game.persist.uuid_of(m.id) == Some([25, 0, 0, n]))
|
||||
.unwrap();
|
||||
assert!(mob.no_ai);
|
||||
(uuid, mob.id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn body_commands_reject_wrong_items_without_changing_head() {
|
||||
with_game(|game| {
|
||||
for (n, kind, slot, item) in [
|
||||
(1, "horse", "armor.body", "iron_horse_armor"),
|
||||
(2, "wolf", "armor.body", "wolf_armor"),
|
||||
(3, "husk", "armor.head", "iron_helmet"),
|
||||
] {
|
||||
let (uuid, id) = actor(game, kind, n);
|
||||
assert!(
|
||||
game.run_command(&format!("item replace entity {uuid} {slot} with {item}"))
|
||||
);
|
||||
let mob = game.mobs.iter().find(|m| m.id == id).unwrap();
|
||||
let actual = if slot == "armor.body" {
|
||||
&mob.extra.body
|
||||
} else {
|
||||
&mob.extra.head
|
||||
};
|
||||
assert_eq!(
|
||||
actual.as_deref(),
|
||||
Some(format!("minecraft:{item}").as_str())
|
||||
);
|
||||
}
|
||||
for (n, kind) in [(4, "husk"), (5, "pig")] {
|
||||
let (uuid, id) = actor(game, kind, n);
|
||||
assert!(
|
||||
game.run_command(&format!("item replace entity {uuid} armor.head with stone"))
|
||||
);
|
||||
assert!(
|
||||
!game.run_command(&format!(
|
||||
"item replace entity {uuid} armor.body with diamond_chestplate"
|
||||
)),
|
||||
"body must reject a chest item"
|
||||
);
|
||||
let mob = game.mobs.iter().find(|m| m.id == id).unwrap();
|
||||
assert!(mob.extra.body.is_none());
|
||||
assert_eq!(mob.extra.head.as_deref(), Some("minecraft:stone"));
|
||||
let saved = game.data_of(&DataRef::Entity(Target::Mob(id))).unwrap();
|
||||
assert!(saved.get("equipment").unwrap().get("body").is_none());
|
||||
assert_eq!(
|
||||
saved
|
||||
.get("equipment")
|
||||
.unwrap()
|
||||
.get("head")
|
||||
.unwrap()
|
||||
.get("id"),
|
||||
Some(&horizon_world::nbt::Tag::String("minecraft:stone".into()))
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn saddle_commands_round_trip_existing_model_and_saved_equipment() {
|
||||
use super::Slot;
|
||||
use horizon_world::nbt::Tag;
|
||||
with_game(|game| {
|
||||
// Pig is the existing species saver; Husk needs the common projection.
|
||||
for (n, kind) in [(6, "pig"), (7, "husk")] {
|
||||
let (uuid, id) = actor(game, kind, n);
|
||||
assert!(game.slot_get(&Slot::Mob(id, "saddle")).is_none());
|
||||
assert!(
|
||||
game.run_command(&format!("item replace entity {uuid} armor.head with stone"))
|
||||
);
|
||||
assert!(
|
||||
game.run_command(&format!("item replace entity {uuid} saddle with saddle")),
|
||||
"command must reach the existing saddle model"
|
||||
);
|
||||
assert_eq!(
|
||||
game.slot_get(&Slot::Mob(id, "saddle"))
|
||||
.unwrap()
|
||||
.item
|
||||
.as_ref(),
|
||||
"minecraft:saddle"
|
||||
);
|
||||
let saved = game.data_of(&DataRef::Entity(Target::Mob(id))).unwrap();
|
||||
assert_eq!(
|
||||
saved
|
||||
.get("equipment")
|
||||
.unwrap()
|
||||
.get("saddle")
|
||||
.unwrap()
|
||||
.get("id"),
|
||||
Some(&Tag::String("minecraft:saddle".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
saved
|
||||
.get("equipment")
|
||||
.unwrap()
|
||||
.get("head")
|
||||
.unwrap()
|
||||
.get("id"),
|
||||
Some(&Tag::String("minecraft:stone".into()))
|
||||
);
|
||||
assert_eq!(saved.get("h025_raw"), Some(&Tag::Int(7)));
|
||||
assert_eq!(saved.get("Silent"), Some(&Tag::Byte(1)));
|
||||
assert_eq!(
|
||||
saved.get("Tags"),
|
||||
Some(&Tag::List(vec![Tag::String("h025".into())]))
|
||||
);
|
||||
assert!(
|
||||
!game.run_command(&format!("item replace entity {uuid} saddle with stick"))
|
||||
);
|
||||
assert_eq!(
|
||||
game.slot_get(&Slot::Mob(id, "saddle"))
|
||||
.unwrap()
|
||||
.item
|
||||
.as_ref(),
|
||||
"minecraft:saddle"
|
||||
);
|
||||
assert!(game.run_command(&format!("item replace entity {uuid} saddle with air")));
|
||||
assert!(game.slot_get(&Slot::Mob(id, "saddle")).is_none());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -299,7 +299,8 @@ impl Game<'_> {
|
||||
let now = horizon_client::lan::border::Sent::of(&self.border);
|
||||
if let Some(sent) = host.border_sent.replace(now) {
|
||||
for packet in sent.changes(&self.border) {
|
||||
host.hub.broadcast(&packet);
|
||||
// PlayerList$1: only the players of the border's own level.
|
||||
host.hub.broadcast_level(&packet);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -527,6 +528,8 @@ impl Game<'_> {
|
||||
.first()
|
||||
.and_then(|s| self.slot_get(s)))
|
||||
}
|
||||
/// `LootCommand.dropBlockLoot`: copy the actual block entity's components
|
||||
/// into the mine result without removing the source block or its contents.
|
||||
pub(super) fn cmd_loot(&mut self, id: &str, ctx: &Context) -> R<i32> {
|
||||
// The drops.
|
||||
let (drops, table): (Vec<ItemStack>, Option<String>) = match id {
|
||||
@@ -632,7 +635,18 @@ impl Game<'_> {
|
||||
return Err(err("commands.drop.no_loot_table.block", &[&name]));
|
||||
};
|
||||
let tool = self.tool_stack(ctx)?;
|
||||
let drops = match world_items!(self).loot_sequence(&table) {
|
||||
let mut pending = crate::game::spill::Pending::default();
|
||||
let key = (p[0], p[1], p[2]);
|
||||
let has_block_entity = if let Some(entity) = self.world.block_entities.get(&key) {
|
||||
pending.record(p, entity, &name, self.world.block_names.get(&key).cloned());
|
||||
true
|
||||
} else if let Some(saved) = self.block_entity_nbt(p) {
|
||||
pending.record_saved(p, &saved);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let mut drops = match world_items!(self).loot_sequence(&table) {
|
||||
Some(seq) => {
|
||||
let random = self.rand_sequences.get(&seq, self.level.seed);
|
||||
world_items!(self).block_drops(
|
||||
@@ -649,6 +663,11 @@ impl Game<'_> {
|
||||
&mut self.world.random,
|
||||
),
|
||||
};
|
||||
if has_block_entity {
|
||||
for drop in &mut drops {
|
||||
pending.attach(p, drop);
|
||||
}
|
||||
}
|
||||
(drops, Some(table))
|
||||
}
|
||||
};
|
||||
@@ -838,3 +857,210 @@ mod tests {
|
||||
assert_eq!(super::size_limit_text(), "5.9999968E7");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod loot_mine_component_tests {
|
||||
use super::*;
|
||||
use crate::game::{Audio, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::{chunk::Chunk, containers::BlockEntity, save};
|
||||
use horizon_world::nbt::{Compound, Tag};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn slot(game: &Game<'_>, index: usize) -> ItemStack {
|
||||
let Some(BlockEntity::Chest(items)) = game.world.block_entities.get(&(12, 200, 4)) else {
|
||||
panic!("actual command-created chest required");
|
||||
};
|
||||
items[index]
|
||||
.clone()
|
||||
.expect("actual inventory slot must be occupied")
|
||||
}
|
||||
|
||||
fn decorations(stack: &ItemStack) -> Option<Tag> {
|
||||
save::item_to_nbt(stack)
|
||||
.get("components")?
|
||||
.get("minecraft:pot_decorations")
|
||||
.cloned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
|
||||
fn loot_mine_copies_actual_empty_and_right_pot_components_into_the_chest() {
|
||||
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();
|
||||
assert!(game.run_command("forceload add 0 0"));
|
||||
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
|
||||
assert!(game.run_command(
|
||||
"setblock 12 200 4 chest[facing=north,type=single,waterlogged=false] strict"
|
||||
));
|
||||
assert!(
|
||||
game.run_command("item replace block 12 200 4 container.26 with minecraft:diamond 7")
|
||||
);
|
||||
assert!(game.run_command("item replace block 12 200 4 container.2 with minecraft:decorated_pot[minecraft:pot_decorations={}] 1"));
|
||||
let empty = Tag::Compound(Compound::new());
|
||||
assert_eq!(
|
||||
(&*slot(&game, 26).item, slot(&game, 26).count),
|
||||
("minecraft:diamond", 7)
|
||||
);
|
||||
assert_eq!(
|
||||
decorations(&slot(&game, 2)),
|
||||
Some(empty.clone()),
|
||||
"direct-item parser/encoder positive control"
|
||||
);
|
||||
assert!(
|
||||
slot(&game, 2)
|
||||
.extra
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.pot_decorations
|
||||
.is_some()
|
||||
);
|
||||
let start = game.world.game_time;
|
||||
let right = "minecraft:angler_pottery_sherd";
|
||||
let mut failures = Vec::new();
|
||||
for (x, nbt, decorated) in [
|
||||
(4, "", false),
|
||||
(
|
||||
7,
|
||||
r#"{sherds:{right:{id:"minecraft:angler_pottery_sherd"}}}"#,
|
||||
true,
|
||||
),
|
||||
] {
|
||||
assert!(game.run_command(&format!(
|
||||
"setblock {x} 200 4 decorated_pot[facing=north,cracked=false,waterlogged=false]{nbt} strict"
|
||||
)));
|
||||
let pos = [x, 200, 4];
|
||||
let state = game.world.block(x, 200, 4);
|
||||
let actual = game.pack.assets.registry.state(state).unwrap();
|
||||
assert_eq!(&*actual.name, "minecraft:decorated_pot");
|
||||
assert_eq!(
|
||||
actual.properties.get("cracked").map(String::as_str),
|
||||
Some("false")
|
||||
);
|
||||
assert_eq!(
|
||||
game.world.block(x, 199, 4),
|
||||
game.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:stone")
|
||||
.unwrap()
|
||||
);
|
||||
let before = game.pot_data(pos).expect("actual source block entity read");
|
||||
assert_eq!(
|
||||
before.get("id"),
|
||||
Some(&Tag::String("minecraft:decorated_pot".into()))
|
||||
);
|
||||
for (key, value) in [("x", x), ("y", 200), ("z", 4)] {
|
||||
assert_eq!(before.get(key), Some(&Tag::Int(value)));
|
||||
}
|
||||
if decorated {
|
||||
assert_eq!(
|
||||
before
|
||||
.get("sherds")
|
||||
.and_then(|t| t.get("right"))
|
||||
.and_then(|t| t.get("id")),
|
||||
Some(&Tag::String(right.into())),
|
||||
"actual admitted source sherd"
|
||||
);
|
||||
} else {
|
||||
assert!(before.get("sherds").is_none());
|
||||
}
|
||||
assert!(before.get("item").is_none() && before.get("LootTable").is_none());
|
||||
assert!(
|
||||
game.run_command("item replace block 12 200 4 container.0 with minecraft:stone 3")
|
||||
);
|
||||
let initial = slot(&game, 0);
|
||||
assert_eq!((&*initial.item, initial.count), ("minecraft:stone", 3));
|
||||
assert!(game.run_command(&format!(
|
||||
"loot replace block 12 200 4 container.0 1 mine {x} 200 4"
|
||||
)));
|
||||
let out = slot(&game, 0);
|
||||
assert_eq!(
|
||||
(&*out.item, out.count),
|
||||
("minecraft:decorated_pot", 1),
|
||||
"inventory is the count authority"
|
||||
);
|
||||
let expected = if decorated {
|
||||
Tag::compound([("right", Tag::compound([("id", Tag::String(right.into()))]))])
|
||||
} else {
|
||||
empty.clone()
|
||||
};
|
||||
if decorations(&out) != Some(expected) {
|
||||
failures.push(format!(
|
||||
"source {x}: actual saved output pot_decorations missing/wrong"
|
||||
));
|
||||
}
|
||||
let modeled = out.extra.as_ref().and_then(|d| d.pot_decorations.as_ref());
|
||||
if modeled.is_none_or(|sides| {
|
||||
sides.iter().enumerate().any(|(i, item)| {
|
||||
if decorated && i == 2 {
|
||||
item.as_ref()
|
||||
.is_none_or(|s| &*s.item != right || s.count != 1)
|
||||
} else {
|
||||
item.is_some()
|
||||
}
|
||||
})
|
||||
}) {
|
||||
failures.push(format!(
|
||||
"source {x}: actual output component state missing/wrong"
|
||||
));
|
||||
}
|
||||
assert_eq!(
|
||||
game.pot_data(pos),
|
||||
Some(before),
|
||||
"mine leaves the actual source unchanged"
|
||||
);
|
||||
assert_eq!(game.world.block(x, 200, 4), state);
|
||||
assert_eq!(
|
||||
(&*slot(&game, 26).item, slot(&game, 26).count),
|
||||
("minecraft:diamond", 7)
|
||||
);
|
||||
assert_eq!(decorations(&slot(&game, 2)), Some(empty.clone()));
|
||||
assert_eq!(game.world.game_time, start);
|
||||
}
|
||||
assert!(game.run_command("setblock 10 200 4 dirt strict"));
|
||||
assert!(game.run_command("loot replace block 12 200 4 container.0 1 mine 10 200 4"));
|
||||
let plain = slot(&game, 0);
|
||||
assert_eq!((&*plain.item, plain.count), ("minecraft:dirt", 1));
|
||||
assert!(decorations(&plain).is_none());
|
||||
assert_eq!(game.world.game_time, start);
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"actual loot-mine component-copy failures: {failures:#?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -194,8 +194,9 @@ impl Game<'_> {
|
||||
Some(Arg::Coords(_)) => self.position_arg(ctx, "location"),
|
||||
_ => by.and_then(|t| self.target_position(t)),
|
||||
};
|
||||
// A lethal item hit discards it before the success message is built.
|
||||
let item_name = matches!(target, Target::Item(_)).then(|| self.target_name(target));
|
||||
// Destruction can remove the target before its success message is built.
|
||||
let discarded_name = matches!(target, Target::Item(_) | Target::Boat(_) | Target::Cart(_))
|
||||
.then(|| self.target_name(target));
|
||||
let hurt = match target {
|
||||
// The causing mob is the one the hit is credited to and thorns
|
||||
// answer; the source position gives the knockback its direction.
|
||||
@@ -264,6 +265,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Target::Item(id) => self.command_damage_item(id, amount, &ty, cause.or(by)),
|
||||
Target::Boat(_) | Target::Cart(_) => {
|
||||
self.command_damage_vehicle(target, amount, &ty, cause.or(by))
|
||||
}
|
||||
Target::Dragon(id) => self.command_damage_dragon(
|
||||
id,
|
||||
amount,
|
||||
@@ -283,7 +287,7 @@ impl Game<'_> {
|
||||
if !hurt {
|
||||
return Err(err("commands.damage.invulnerable", &[]));
|
||||
}
|
||||
let name = item_name.unwrap_or_else(|| self.target_name(target));
|
||||
let name = discarded_name.unwrap_or_else(|| self.target_name(target));
|
||||
self.feedback(Text::translate(
|
||||
"commands.damage.success",
|
||||
&[
|
||||
@@ -294,6 +298,53 @@ impl Game<'_> {
|
||||
Ok(1)
|
||||
}
|
||||
|
||||
/// `VehicleEntity.hurtServer`: report acceptance even when the vehicle survives.
|
||||
fn command_damage_vehicle(
|
||||
&mut self,
|
||||
target: Target,
|
||||
amount: f32,
|
||||
damage_type: &str,
|
||||
causing: Option<Target>,
|
||||
) -> bool {
|
||||
let registry = &self.motion.health.damage;
|
||||
let Some(kind) = registry.id(damage_type) else {
|
||||
return false;
|
||||
};
|
||||
let creative = match causing {
|
||||
Some(Target::Player) => self.creative,
|
||||
Some(Target::Guest(conn)) => self.command_guest(conn).is_some_and(|g| g.creative()),
|
||||
_ => false,
|
||||
};
|
||||
let id = match target {
|
||||
Target::Boat(id) | Target::Cart(id) => id,
|
||||
_ => return false,
|
||||
};
|
||||
let invulnerable = self
|
||||
.persist
|
||||
.raw
|
||||
.get(&id)
|
||||
.is_some_and(|tag| horizon_client::entity_nbt::flag(tag, "Invulnerable"));
|
||||
if invulnerable && !creative && !registry.in_tag(kind, "minecraft:bypasses_invulnerability")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let ignites = registry.in_tag(kind, "minecraft:is_fire")
|
||||
|| registry.in_tag(kind, "minecraft:is_explosion");
|
||||
match target {
|
||||
Target::Boat(_) => self
|
||||
.boats
|
||||
.iter()
|
||||
.position(|b| b.id == id)
|
||||
.is_some_and(|i| self.hurt_boat_result(i, amount, creative).is_some()),
|
||||
Target::Cart(_) => self
|
||||
.carts
|
||||
.iter()
|
||||
.position(|c| c.id == id)
|
||||
.is_some_and(|i| self.hurt_cart_result(i, amount, creative, ignites)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `ItemEntity.hurtServer`: the item branch reached by `DamageCommand.damage`.
|
||||
fn command_damage_item(
|
||||
&mut self,
|
||||
@@ -393,17 +444,21 @@ impl Game<'_> {
|
||||
|
||||
/// `DamageCommand.damage`: retain the accepted hit's player context and run
|
||||
/// `Zombie.hurtServer`'s reinforcement requests before the command returns.
|
||||
/// Health-changing hits also raise their damage vibration before return.
|
||||
fn command_damage_mob(&mut self, id: u32, amount: f32, damage: Damage) -> bool {
|
||||
let player_uuid = (damage.attacker == Some(Who::Player))
|
||||
.then(|| self.owner_damage_uuid())
|
||||
.flatten();
|
||||
let mut reinforcements = Vec::new();
|
||||
let mut health_changed = false;
|
||||
let hurt = self.mobs.iter_mut().find(|m| m.id == id).is_some_and(|m| {
|
||||
let queued = m.outbox.len();
|
||||
let old_health = m.health;
|
||||
let hurt = match player_uuid {
|
||||
Some(uuid) => m.damage_with_player_uuid(amount, damage, uuid, None),
|
||||
None => m.damage(amount, damage),
|
||||
};
|
||||
health_changed = hurt && m.health != old_health;
|
||||
if hurt {
|
||||
reinforcements = take_new_reinforcements(&mut m.outbox, queued);
|
||||
}
|
||||
@@ -424,6 +479,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if health_changed {
|
||||
self.mob_damage_event(id);
|
||||
}
|
||||
}
|
||||
for (mob, target) in reinforcements {
|
||||
self.call_reinforcements(mob, target);
|
||||
@@ -2140,3 +2198,138 @@ mod tests {
|
||||
assert_eq!(point[1..], [64.0, 0.0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod vehicle_damage_command_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;
|
||||
fn with_game(test: impl FnOnce(&mut Game<'_>)) {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
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.game_dir = Some(
|
||||
std::path::PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap())
|
||||
.join(format!("h050-{:?}", std::thread::current().id())),
|
||||
);
|
||||
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();
|
||||
assert!(game.run_command("difficulty normal"));
|
||||
assert!(game.run_command("forceload add 0 0"));
|
||||
test(&mut game);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn generic_commands_reach_actual_vehicle_damage_and_drop() {
|
||||
with_game(|game| {
|
||||
game.creative = true; // issuer creative is not an attacker
|
||||
let clock = game.world.game_time;
|
||||
for (kind, n) in [("oak_boat", 1), ("minecart", 2)] {
|
||||
let uuid = format!("000001f4-0000-0000-0000-{n:012x}");
|
||||
assert!(game.run_command(&format!(
|
||||
"summon {kind} 4.5 200 5.5 {{UUID:[I;500,0,0,{n}],NoGravity:1b}}"
|
||||
)));
|
||||
let id = game
|
||||
.boats
|
||||
.iter()
|
||||
.map(|b| b.id)
|
||||
.chain(game.carts.iter().map(|c| c.id))
|
||||
.find(|id| game.persist.uuid_of(*id) == Some([500, 0, 0, n]))
|
||||
.unwrap();
|
||||
assert!(
|
||||
game.run_command(&format!("damage {uuid} 1 minecraft:generic")),
|
||||
"accepted small hit must reach vehicle"
|
||||
);
|
||||
let damage = if kind == "oak_boat" {
|
||||
game.boats.iter().find(|b| b.id == id).unwrap().damage
|
||||
} else {
|
||||
game.carts.iter().find(|c| c.id == id).unwrap().damage
|
||||
};
|
||||
assert_eq!(damage, 10.0);
|
||||
assert!(game.run_command(&format!("damage {uuid} 4 minecraft:generic")));
|
||||
assert!(!game.boats.iter().any(|b| b.id == id));
|
||||
assert!(!game.carts.iter().any(|c| c.id == id));
|
||||
assert_eq!(
|
||||
game.dropped
|
||||
.iter()
|
||||
.filter(|d| d.stack.item.as_ref() == format!("minecraft:{kind}")
|
||||
&& d.stack.count == 1)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
let n = n + 2;
|
||||
let uuid = format!("000001f4-0000-0000-0000-{n:012x}");
|
||||
assert!(game.run_command(&format!(
|
||||
"summon {kind} 8.5 200 5.5 {{UUID:[I;500,0,0,{n}],NoGravity:1b}}"
|
||||
)));
|
||||
let id = game
|
||||
.boats
|
||||
.iter()
|
||||
.map(|b| b.id)
|
||||
.chain(game.carts.iter().map(|c| c.id))
|
||||
.find(|id| game.persist.uuid_of(*id) == Some([500, 0, 0, n]))
|
||||
.unwrap();
|
||||
assert!(game.run_command(&format!("damage {uuid} 4 minecraft:generic")));
|
||||
let damage = if kind == "oak_boat" {
|
||||
game.boats.iter().find(|b| b.id == id).unwrap().damage
|
||||
} else {
|
||||
game.carts.iter().find(|c| c.id == id).unwrap().damage
|
||||
};
|
||||
assert_eq!(damage, 40.0, "exact threshold survives");
|
||||
}
|
||||
// Fresh rejected subjects avoid relying on vehicle data adoption.
|
||||
for (kind, n) in [("oak_boat", 5), ("minecart", 6)] {
|
||||
let uuid = format!("000001f4-0000-0000-0000-{n:012x}");
|
||||
assert!(game.run_command(&format!("summon {kind} 11.5 200 5.5 {{UUID:[I;500,0,0,{n}],NoGravity:1b,Invulnerable:1b}}")));
|
||||
let id = game
|
||||
.boats
|
||||
.iter()
|
||||
.map(|b| b.id)
|
||||
.chain(game.carts.iter().map(|c| c.id))
|
||||
.find(|id| game.persist.uuid_of(*id) == Some([500, 0, 0, n]))
|
||||
.unwrap();
|
||||
assert!(!game.run_command(&format!("damage {uuid} 4 minecraft:generic")));
|
||||
let damage = if kind == "oak_boat" {
|
||||
game.boats.iter().find(|b| b.id == id).unwrap().damage
|
||||
} else {
|
||||
game.carts.iter().find(|c| c.id == id).unwrap().damage
|
||||
};
|
||||
assert_eq!(damage, 0.0);
|
||||
}
|
||||
assert_eq!(game.world.game_time, clock);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -191,6 +191,16 @@ impl ProjectileWorld for ProjWorld<'_, '_> {
|
||||
fn empty(&self, p: [i32; 3]) -> bool {
|
||||
self.w.facts.is_air(self.w.world.block(p[0], p[1], p[2]))
|
||||
}
|
||||
fn bubble_column(&self, p: [i32; 3]) -> Option<(bool, bool)> {
|
||||
horizon_client::motion::bubble_column_at(
|
||||
self.w.world,
|
||||
self.w.registry,
|
||||
self.w.facts,
|
||||
self.w.collision,
|
||||
BlockPos::new(p[0], p[1], p[2]),
|
||||
)
|
||||
.map(|c| (c.drag, c.surface))
|
||||
}
|
||||
fn solid(&self, p: [i32; 3]) -> bool {
|
||||
self.w
|
||||
.facts
|
||||
@@ -199,20 +209,21 @@ impl ProjectileWorld for ProjWorld<'_, '_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractWindCharge` is pickable, so a thrown pearl's
|
||||
/// `ProjectileUtil.getEntityHitResult` can strike it (`ThrownEnderpearl.onHit`).
|
||||
/// `WIND_CHARGE_IDS + j` is `projectiles[j]` during `tick_projectiles`, below
|
||||
/// the dragon's reserved ids.
|
||||
const WIND_CHARGE_IDS: u32 = super::dragon::SPECIAL - 0x10000;
|
||||
/// `Projectile.isPickable`: a projectile in `#minecraft:redirectable_projectile`
|
||||
/// (a ghast's fireball and both wind charges) is pickable, so another
|
||||
/// projectile's `ProjectileUtil.getEntityHitResult` can strike it
|
||||
/// (`Projectile.onHit` then redirects it). `PICKABLE_PROJECTILE_IDS + j` is
|
||||
/// `projectiles[j]` during `tick_projectiles`, below the dragon's reserved ids.
|
||||
const PICKABLE_PROJECTILE_IDS: u32 = super::dragon::SPECIAL - 0x10000;
|
||||
|
||||
/// The index of the wind charge a `wind_charge_hitboxes` id stands for.
|
||||
pub(super) fn wind_charge_hit(id: u32) -> Option<usize> {
|
||||
(WIND_CHARGE_IDS..super::dragon::SPECIAL)
|
||||
/// The index of the projectile a `pickable_projectile_hitboxes` id stands for.
|
||||
pub(super) fn pickable_projectile_hit(id: u32) -> Option<usize> {
|
||||
(PICKABLE_PROJECTILE_IDS..super::dragon::SPECIAL)
|
||||
.contains(&id)
|
||||
.then(|| (id - WIND_CHARGE_IDS) as usize)
|
||||
.then(|| (id - PICKABLE_PROJECTILE_IDS) as usize)
|
||||
}
|
||||
|
||||
fn wind_charge_hitboxes(
|
||||
fn pickable_projectile_hitboxes(
|
||||
projectiles: &[Projectile],
|
||||
alive: &[bool],
|
||||
skip: usize,
|
||||
@@ -220,16 +231,8 @@ fn wind_charge_hitboxes(
|
||||
projectiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(j, q)| {
|
||||
*j != skip
|
||||
&& alive[*j]
|
||||
&& !q.stuck
|
||||
&& matches!(
|
||||
q.kind,
|
||||
ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge
|
||||
)
|
||||
})
|
||||
.map(|(j, q)| (Who::Mob(WIND_CHARGE_IDS + j as u32), q.aabb()))
|
||||
.filter(|(j, q)| *j != skip && alive[*j] && super::deflect::redirectable(q))
|
||||
.map(|(j, q)| (Who::Mob(PICKABLE_PROJECTILE_IDS + j as u32), q.aabb()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -996,6 +999,7 @@ impl Game<'_> {
|
||||
// waiting any more.
|
||||
self.combat.charged_exploders.clear();
|
||||
self.combat.exploded.clear();
|
||||
self.combat.exploded_kinds.clear();
|
||||
// EntityTickEvent.Pre for the mobs about to tick, before the loop holds them.
|
||||
let mut round = self.native_tick_round();
|
||||
if round.listening() {
|
||||
@@ -1051,7 +1055,10 @@ impl Game<'_> {
|
||||
.path_writes
|
||||
.get_or_insert_with(Vec::new)
|
||||
.split_off(0);
|
||||
// The mobs whose bodies go this tick: (id, feet, width, height).
|
||||
// The mobs whose bodies go this tick: (id, feet, width, height, split).
|
||||
// 🤖 Agent: the last field is the grown slime, magma cube or sulfur
|
||||
// cube the body leaves behind (`AbstractCubeMob.remove`), captured as
|
||||
// the body goes because it has left the list by the time it splits.
|
||||
let mut gone = Vec::new();
|
||||
let level_random;
|
||||
{
|
||||
@@ -1211,7 +1218,21 @@ impl Game<'_> {
|
||||
// LivingEntity.tickDeath: after twenty ticks the body is removed.
|
||||
if !kept && !m.removed && !m.alive() && m.death_time >= 20 {
|
||||
let (width, height) = (f64::from(m.width()), f64::from(m.height()));
|
||||
gone.push((m.id, m.position, width, height));
|
||||
// 🤖 Agent: `AbstractCubeMob.remove` splits a grown cube into
|
||||
// its smaller halves when it is removed, twenty ticks after
|
||||
// the death. The parent leaves the list before the split
|
||||
// runs, so its size, position, kind and persistence are
|
||||
// captured here, as they are at the removal.
|
||||
let split = if matches!(
|
||||
m.species(),
|
||||
Species::Slime | Species::MagmaCube | Species::SulfurCube
|
||||
) && m.extra.size > 1
|
||||
{
|
||||
Some(m.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
gone.push((m.id, m.position, width, height, split));
|
||||
}
|
||||
if let Some(conn) = seen_guest {
|
||||
// ShulkerBullet.finalTarget is the guest's entity: the
|
||||
@@ -1284,13 +1305,17 @@ impl Game<'_> {
|
||||
self.creatures.ticked = ticked;
|
||||
self.native_tick_post_batch(round);
|
||||
// ... and entity event 60 goes out as it is: the poof.
|
||||
for (id, at, width, height) in gone {
|
||||
// 🤖 Agent: the split follows the poof, as `LivingEntity.tickDeath`
|
||||
// broadcasts event 60 before it calls `remove`.
|
||||
for (id, at, width, height, split) in gone {
|
||||
let at = [at.x, at.y, at.z];
|
||||
self.lan_entity_event(id as i32, 60, at);
|
||||
self.death_poof(at, width, height);
|
||||
if let Some(m) = split {
|
||||
self.split_dead_cube(&m);
|
||||
}
|
||||
}
|
||||
laps.lap("creature_retain");
|
||||
self.tick_boarding();
|
||||
self.sync_riders();
|
||||
self.tick_skeleton_traps();
|
||||
self.tick_apparition();
|
||||
@@ -1481,32 +1506,37 @@ impl Game<'_> {
|
||||
// CopperGolem.dropEquipment: the preserved equipment too.
|
||||
self.golem_drop_equipment(&mut dying);
|
||||
}
|
||||
}
|
||||
/// 🤖 Agent: `AbstractCubeMob.remove`'s split: a grown cube mob leaves its
|
||||
/// smaller halves behind when its body goes, twenty ticks after the death
|
||||
/// (`LivingEntity.tickDeath`), not when it dies.
|
||||
fn split_dead_cube(&mut self, m: &Mob) {
|
||||
if m.species() == Species::SulfurCube {
|
||||
self.split_sulfur_cube(m);
|
||||
return;
|
||||
}
|
||||
if m.species() == Species::Slime || m.species() == Species::MagmaCube {
|
||||
// Slime.remove: split into 2-4 smaller slimes.
|
||||
let size = m.extra.size;
|
||||
if size > 1 {
|
||||
let mut n = 2 + self.world.random.next_int_bound(3).unwrap_or(0);
|
||||
let mut cancel = false;
|
||||
let mut count = n as u32;
|
||||
let kind = super::native_hooks::mob_kind(m);
|
||||
self.native_mob_split(m.id, &kind, size, size / 2, &mut count, &mut cancel);
|
||||
n = if cancel { 0 } else { count.min(64) as _ };
|
||||
for k in 0..n {
|
||||
let off = |kk: i32| (f64::from(kk % 2) - 0.5) * f64::from(size) / 4.0;
|
||||
let at = [
|
||||
m.position.x + off(k),
|
||||
m.position.y + 0.5,
|
||||
m.position.z + off(k / 2),
|
||||
];
|
||||
if let Some(id) = self.spawn_mob(&m.kind.id, at, m.persistent)
|
||||
&& let Some(c) = self.mobs.iter_mut().find(|c| c.id == id)
|
||||
{
|
||||
horizon_client::mobs::cube::set_size(c, size / 2);
|
||||
}
|
||||
}
|
||||
// 🤖 Agent: AbstractCubeMob.getSplitCount: two to four smaller slimes.
|
||||
let size = m.extra.size;
|
||||
if size <= 1 {
|
||||
return;
|
||||
}
|
||||
let mut n = 2 + self.world.random.next_int_bound(3).unwrap_or(0);
|
||||
let mut cancel = false;
|
||||
let mut count = n as u32;
|
||||
let kind = super::native_hooks::mob_kind(m);
|
||||
self.native_mob_split(m.id, &kind, size, size / 2, &mut count, &mut cancel);
|
||||
n = if cancel { 0 } else { count.min(64) as _ };
|
||||
for k in 0..n {
|
||||
let off = |kk: i32| (f64::from(kk % 2) - 0.5) * f64::from(size) / 4.0;
|
||||
let at = [
|
||||
m.position.x + off(k),
|
||||
m.position.y + 0.5,
|
||||
m.position.z + off(k / 2),
|
||||
];
|
||||
if let Some(id) = self.spawn_mob(&m.kind.id, at, m.persistent)
|
||||
&& let Some(c) = self.mobs.iter_mut().find(|c| c.id == id)
|
||||
{
|
||||
horizon_client::mobs::cube::set_size(c, size / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2330,12 +2360,14 @@ impl Game<'_> {
|
||||
charged,
|
||||
name,
|
||||
in_water,
|
||||
kind,
|
||||
} => {
|
||||
self.combat.explosion_source = Some(mob);
|
||||
self.combat.explosion_in_water = in_water;
|
||||
// The creeper causes the blast's damage, but it is out of
|
||||
// the mob list already: its name comes with the event.
|
||||
self.combat.exploded.push((mob, name));
|
||||
self.combat.exploded_kinds.push((mob, kind));
|
||||
if charged {
|
||||
self.combat.charged_exploders.push(mob);
|
||||
}
|
||||
@@ -2520,18 +2552,46 @@ impl Game<'_> {
|
||||
MobEvent::Tamed { .. } | MobEvent::Buck { .. } | MobEvent::StabDismount { .. } => {
|
||||
self.mount_event(&e)
|
||||
}
|
||||
MobEvent::Launch { target, up } => match target {
|
||||
Who::Player => {
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.velocity.y += up;
|
||||
MobEvent::IronGolemHit {
|
||||
target,
|
||||
amount,
|
||||
source,
|
||||
} => {
|
||||
// IronGolem.doHurtTarget: the toss only follows a hit that
|
||||
// landed, scaled by the target's KNOCKBACK_RESISTANCE.
|
||||
let hurt = match target {
|
||||
Who::Player => {
|
||||
let from = source.from.unwrap_or_default();
|
||||
self.mob_hits_player(amount, from, "minecraft:mob_attack")
|
||||
}
|
||||
Who::Mob(id) => self.mob_hits_mob(id, amount, source),
|
||||
};
|
||||
if hurt {
|
||||
let resistance = match target {
|
||||
Who::Player => self.player.as_mut().map_or(0.0, |p| {
|
||||
p.attribute("minecraft:knockback_resistance").unwrap_or(0.0)
|
||||
}),
|
||||
Who::Mob(id) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.map_or(0.0, |m| self.mob_knockback_resistance(m)),
|
||||
};
|
||||
let up = f64::from(0.4f32) * (1.0 - resistance).max(0.0);
|
||||
match target {
|
||||
Who::Player => {
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.velocity.y += up;
|
||||
}
|
||||
}
|
||||
Who::Mob(id) => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.velocity.y += up;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Who::Mob(id) => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.velocity.y += up;
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
MobEvent::Bonemeal { pos } => {
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
if self.with_level(|b, level, ticks| b.grow_crop(level, ticks, at)) {
|
||||
@@ -4079,12 +4139,13 @@ impl Game<'_> {
|
||||
.collect()
|
||||
});
|
||||
for i in 0..projectiles.len() {
|
||||
// A wind charge is pickable, so a pearl, an arrow or a trident can
|
||||
// strike it (`Projectile.isPickable`, `Projectile.onHit`).
|
||||
// A ghast's fireball or a wind charge is pickable, so a pearl, an
|
||||
// arrow or a trident can strike it (`Projectile.isPickable`,
|
||||
// `Projectile.onHit`).
|
||||
let winds = if matches!(projectiles[i].kind, ProjectileKind::EnderPearl)
|
||||
|| projectiles[i].arrow.is_some()
|
||||
{
|
||||
wind_charge_hitboxes(&projectiles, &keep_flags, i)
|
||||
pickable_projectile_hitboxes(&projectiles, &keep_flags, i)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
@@ -4232,11 +4293,12 @@ impl Game<'_> {
|
||||
hits.push((p.clone(), hit));
|
||||
}
|
||||
keep_flags[i] = keep;
|
||||
// Projectile.onHit: the wind charge it hit turns before its own tick.
|
||||
// Projectile.onHit: the redirectable projectile it hit (a ghast's
|
||||
// fireball or a wind charge) turns before its own tick.
|
||||
if let Hit::Entity {
|
||||
who: Who::Mob(id), ..
|
||||
} = hit
|
||||
&& let Some(j) = wind_charge_hit(id)
|
||||
&& let Some(j) = pickable_projectile_hit(id)
|
||||
{
|
||||
let (owner, owner_uuid, guest) = (p.owner, p.owner_uuid, p.guest);
|
||||
let look = self.owner_look(owner, guest);
|
||||
@@ -4471,10 +4533,11 @@ impl Game<'_> {
|
||||
| ProjectileKind::WindCharge
|
||||
| ProjectileKind::BreezeWindCharge
|
||||
);
|
||||
use horizon_client::arrows::OwnerLookup;
|
||||
let shooter_may_break = match p.owner {
|
||||
Some(Who::Player) => self.mode != GameMode::Adventure,
|
||||
Some(Who::Mob(_)) => false,
|
||||
None => true,
|
||||
None => !p.owner_uuid.is_some_and(|u| self.non_player_entity(u)),
|
||||
};
|
||||
if !impact || !shooter_may_break || !self.ui.rules.bool("projectiles_can_break_blocks") {
|
||||
return;
|
||||
@@ -4623,6 +4686,7 @@ impl Game<'_> {
|
||||
raining: rain,
|
||||
mob_griefing: self.ui.rules.bool("mob_griefing"),
|
||||
tnt_explodes: self.ui.rules.bool("tnt_explodes"),
|
||||
owners: self,
|
||||
}
|
||||
}
|
||||
/// The rest of the tick of an arrow that hit an entity
|
||||
@@ -4894,15 +4958,16 @@ impl Game<'_> {
|
||||
}
|
||||
// AbstractArrow.onHitBlock plays its sound itself (ArrowEvent).
|
||||
(ProjectileKind::Trident, Hit::Block { .. }) => {}
|
||||
// AbstractArrow.onHitEntity on a wind charge: `hurtOrSimulate` fails
|
||||
// (the charge turned in `Projectile.onHit`, which already ran), so the
|
||||
// arrow takes `deflect(REVERSE, 0.2)` instead of vanishing.
|
||||
// AbstractArrow.onHitEntity on a ghast's fireball or a wind charge:
|
||||
// `hurtOrSimulate` fails (`AbstractHurtingProjectile.hurtServer` is
|
||||
// false; the projectile turned in `Projectile.onHit`, which already
|
||||
// ran), so the arrow takes `deflect(REVERSE, 0.2)` instead of vanishing.
|
||||
(
|
||||
ProjectileKind::Arrow { .. },
|
||||
Hit::Entity {
|
||||
who: Who::Mob(id), ..
|
||||
},
|
||||
) if wind_charge_hit(id).is_some() => {
|
||||
) if pickable_projectile_hit(id).is_some() => {
|
||||
let mut arrow = p.clone();
|
||||
arrow_damage(&mut arrow, &mut self.world.random);
|
||||
self.finish_arrow_hit(arrow, EntityHit::Refused);
|
||||
@@ -5576,11 +5641,10 @@ impl Game<'_> {
|
||||
};
|
||||
self.audio.play(sound, Some(at), 1.0, 1.0);
|
||||
}
|
||||
/// Right click on a mob (Mob.interact / mobInteract), on the mob under the
|
||||
/// crosshair or on the one a guest's interact packet named: returns true
|
||||
/// when consumed.
|
||||
pub(super) fn interact_mob(&mut self, target: Option<InteractTarget>) -> Result<bool> {
|
||||
let Some((index, distance, _)) = (match target {
|
||||
/// The mob a normal interaction can reach: a packet's exact validated ID,
|
||||
/// or the crosshair's mob before its blocking shape.
|
||||
pub(super) fn mob_interaction_target(&self, target: Option<InteractTarget>) -> Option<usize> {
|
||||
let (index, distance, _) = (match target {
|
||||
// The id the packet named, under the filters the crosshair pick
|
||||
// applies (`mob_hit`): a mob in its death animation is not clicked
|
||||
// by either path, so naming one in a packet must not reach the
|
||||
@@ -5592,9 +5656,7 @@ impl Game<'_> {
|
||||
})
|
||||
.map(|i| (i, 0.0, [0.0; 3])),
|
||||
None => self.mob_hit(),
|
||||
}) else {
|
||||
return Ok(false);
|
||||
};
|
||||
})?;
|
||||
// Only a client's own pick is stopped by a block: the range check passed.
|
||||
if target.is_none()
|
||||
&& let Some(hit) = self.pick_unfiltered()
|
||||
@@ -5605,9 +5667,18 @@ impl Game<'_> {
|
||||
+ (hit.point[2] - eye[2]).powi(2))
|
||||
.sqrt();
|
||||
if d < distance {
|
||||
return Ok(false);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(index)
|
||||
}
|
||||
/// Right click on a mob (Mob.interact / mobInteract), on the mob under the
|
||||
/// crosshair or on the one a guest's interact packet named: returns true
|
||||
/// when consumed.
|
||||
pub(super) fn interact_mob(&mut self, target: Option<InteractTarget>) -> Result<bool> {
|
||||
let Some(index) = self.mob_interaction_target(target) else {
|
||||
return Ok(false);
|
||||
};
|
||||
// PlayerInteractTrigger: the name tag as it was before it named the mob.
|
||||
let used = self.held().cloned();
|
||||
if self.name_mob(index) {
|
||||
@@ -8580,6 +8651,16 @@ mod dye_mix_tests {
|
||||
}
|
||||
}
|
||||
|
||||
impl horizon_client::arrows::OwnerLookup for Game<'_> {
|
||||
fn non_player_entity(&self, uuid: [i32; 4]) -> bool {
|
||||
let found = self.target_by_uuid(horizon_client::lan::playerdata::ints_uuid(uuid));
|
||||
!matches!(
|
||||
found,
|
||||
None | Some(super::commands::Target::Player | super::commands::Target::Guest(_))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod conversion_tag_tests {
|
||||
use super::*;
|
||||
@@ -8680,3 +8761,92 @@ mod conversion_tag_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pickable_projectile_tests {
|
||||
use super::{pickable_projectile_hit, pickable_projectile_hitboxes};
|
||||
use horizon_client::mobs::Who;
|
||||
use horizon_client::projectiles::{Hit, Projectile, ProjectileKind, ProjectileWorld};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
|
||||
/// Open air with nothing in it but `.0`.
|
||||
struct Open(Vec<(Who, Aabb)>);
|
||||
impl ProjectileWorld for Open {
|
||||
fn clip(&self, _: Vec3, _: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
|
||||
None
|
||||
}
|
||||
fn in_water(&self, _: Aabb) -> bool {
|
||||
false
|
||||
}
|
||||
fn targets(&self) -> &[(Who, Aabb)] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Which of `list` another projectile (the one at 0) can strike.
|
||||
fn struck(list: &[Projectile]) -> Vec<usize> {
|
||||
let alive = vec![true; list.len()];
|
||||
pickable_projectile_hitboxes(list, &alive, 0)
|
||||
.iter()
|
||||
.map(|(who, _)| match who {
|
||||
Who::Mob(id) => pickable_projectile_hit(*id).expect("a pickable projectile id"),
|
||||
Who::Player => panic!("not a projectile"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `Projectile.isPickable` is `#minecraft:redirectable_projectile`: a ghast's
|
||||
/// fireball is in it, as both wind charges are, so another projectile's hit
|
||||
/// test can strike it. A blaze's small fireball is not, and a projectile
|
||||
/// that is stuck is no target.
|
||||
#[test]
|
||||
fn a_ghast_fireball_is_pickable_like_a_wind_charge() {
|
||||
let at = Vec3::new(0.0, 100.0, 0.0);
|
||||
let fired = |kind| Projectile::fireball(kind, at, None, [1.0, 0.0, 0.0]);
|
||||
let mut stuck = fired(ProjectileKind::LargeFireball { power: 1 });
|
||||
stuck.stuck = true;
|
||||
let list = vec![
|
||||
Projectile::new(ProjectileKind::EnderPearl, at, Some(Who::Player)),
|
||||
fired(ProjectileKind::LargeFireball { power: 1 }),
|
||||
fired(ProjectileKind::WindCharge),
|
||||
fired(ProjectileKind::BreezeWindCharge),
|
||||
fired(ProjectileKind::SmallFireball),
|
||||
stuck,
|
||||
];
|
||||
assert_eq!(struck(&list), vec![1, 2, 3]);
|
||||
}
|
||||
|
||||
/// A pearl thrown through a ghast's fireball strikes it (`Projectile.onHit`
|
||||
/// then turns the fireball), where it used to fly on through.
|
||||
#[test]
|
||||
fn a_pearl_thrown_through_a_ghast_fireball_strikes_it() {
|
||||
let mut pearl = Projectile::new(
|
||||
ProjectileKind::EnderPearl,
|
||||
Vec3::new(0.0, 100.0, 0.0),
|
||||
Some(Who::Player),
|
||||
);
|
||||
pearl.velocity = Vec3::new(1.5, 0.0, 0.0);
|
||||
let fireball = Projectile::fireball(
|
||||
ProjectileKind::LargeFireball { power: 1 },
|
||||
Vec3::new(1.0, 99.5, 0.0),
|
||||
None,
|
||||
[0.0, 0.0, 1.0],
|
||||
);
|
||||
let list = vec![pearl, fireball];
|
||||
let alive = vec![true; list.len()];
|
||||
let world = Open(pickable_projectile_hitboxes(&list, &alive, 0));
|
||||
let mut pearl = list[0].clone();
|
||||
let (keep, hit) = pearl.tick(&world);
|
||||
assert!(!keep, "the pearl is spent on the hit");
|
||||
match hit {
|
||||
Hit::Entity {
|
||||
who: Who::Mob(id), ..
|
||||
} => assert_eq!(
|
||||
pickable_projectile_hit(id),
|
||||
Some(1),
|
||||
"it struck the fireball"
|
||||
),
|
||||
other => panic!("the pearl flew through the fireball: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -339,7 +339,7 @@ impl Game<'_> {
|
||||
}
|
||||
// `Entity.startRiding`: a passenger first gets off what it rides
|
||||
// (`stopRiding`), so a seated player moves to this cushion.
|
||||
if !self.may_board() || !self.native_may_board(id) {
|
||||
if !self.native_may_board(id) {
|
||||
return true;
|
||||
}
|
||||
self.stop_riding();
|
||||
@@ -520,6 +520,31 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.positionRider` for the mobs (and other players) sitting on a
|
||||
/// cushion: its passenger attachment `(0, 0.25, 0)`, less the rider's own
|
||||
/// vehicle attachment. A server sends no position for a passenger, so
|
||||
/// every client seats it here.
|
||||
pub(super) fn seat_cushion_mobs(&mut self, riding: &super::passengers::Riders) {
|
||||
for c in &self.cushions {
|
||||
let Some(js) = riding.get(&c.id) else {
|
||||
continue;
|
||||
};
|
||||
for &j in js {
|
||||
let m = &mut self.mobs[j];
|
||||
// Cushion's box is 1 wide.
|
||||
let offset = horizon_client::mobs::riders::vehicle_attachment(m, 1.0);
|
||||
let at = Vec3::new(c.position.x, c.position.y + 0.25 - offset, c.position.z);
|
||||
m.position = at;
|
||||
m.previous = at;
|
||||
m.velocity = Vec3::ZERO;
|
||||
m.fall_distance = 0.0;
|
||||
m.on_ground = false;
|
||||
m.extra.passenger = true;
|
||||
m.ai.nav.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Player.rideTick` on a cushion: hold the seat, and get up on sneak.
|
||||
pub(super) fn tick_cushion_riding(&mut self, keys: Keys) -> bool {
|
||||
let Some(c) = self
|
||||
|
||||
@@ -15,12 +15,27 @@ use horizon_client::{
|
||||
use horizon_core::geometry::{Axis, Vec3};
|
||||
use horizon_protocol::packets::play::{BlockEntityTagQuery, EntityTagQuery, Serverbound};
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::time::{Duration, Instant};
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
/// `ChatFormatting.YELLOW` and `RED`.
|
||||
const YELLOW: u32 = 0xFF_FF55;
|
||||
const RED: u32 = 0xFF_5555;
|
||||
|
||||
/// The overlay's CPU name. Reading it can start a process (`reg`, `sysctl`), which the render
|
||||
/// thread shouldn't wait for, so [`warm_debug_cpu`] reads it ahead.
|
||||
static DEBUG_CPU: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
|
||||
/// Reads the overlay's CPU name in the background when the window opens, for F3's first frame.
|
||||
pub(super) fn warm_debug_cpu() {
|
||||
// A thread that can't start only leaves the read to that first frame.
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("debug-cpu-name".into())
|
||||
.spawn(|| {
|
||||
DEBUG_CPU.get_or_init(horizon_client::crash::cpu);
|
||||
});
|
||||
}
|
||||
|
||||
/// The game as `KeyboardHandler` sees it while it runs one key event.
|
||||
struct Host<'g, 'a> {
|
||||
game: &'g mut Game<'a>,
|
||||
@@ -289,7 +304,8 @@ impl Game<'_> {
|
||||
}
|
||||
/// `DebugScreenOverlay.extractRenderState`: the enabled entries' lines
|
||||
/// (`debug-profile.json`), at the debug GUI scale, and the charts while
|
||||
/// the overlay is open. F1 hides it unless a screen is open.
|
||||
/// the overlay is open. F1 hides it unless a screen is open. Horizon paints
|
||||
/// it 20 times a second and copies those vertices in between.
|
||||
pub(super) fn draw_debug_overlay_under(&self, batch: &mut HudBatch, size: [u32; 2]) {
|
||||
let screen_open = self.screen != Screen::Playing;
|
||||
let reduced = self.reduced_debug_info();
|
||||
@@ -297,8 +313,19 @@ impl Game<'_> {
|
||||
|| !self.debug_entries.any_enabled(self.debug, reduced)
|
||||
|| (self.ui.hide_gui && !screen_open)
|
||||
{
|
||||
// Nothing reads the server's lines now: it stops computing them each tick.
|
||||
if let Some(server) = &self.integrated {
|
||||
server.request_debug(None);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let now = Instant::now();
|
||||
let layout = (size, batch.scale.to_bits(), self.gui.atlas.font.epoch());
|
||||
if let Some(vertices) = self.debug_paint.borrow().fresh(now, layout) {
|
||||
batch.vertices.extend_from_slice(vertices);
|
||||
return;
|
||||
}
|
||||
let first_vertex = batch.vertices.len();
|
||||
let framebuffer = size.map(|v| v as i32);
|
||||
let (scale, [width, height]) = debug_overlay::scale(
|
||||
framebuffer,
|
||||
@@ -343,6 +370,33 @@ impl Game<'_> {
|
||||
draws.push(hud_layout::GuiDraw::Pop);
|
||||
}
|
||||
hud_layout::paint(batch, atlas, &draws);
|
||||
self.debug_paint
|
||||
.borrow_mut()
|
||||
.store(now, layout, &batch.vertices[first_vertex..]);
|
||||
}
|
||||
/// Whether the overlay is listing GPU pass times (its `system_specs` lines), the only reason
|
||||
/// it has to time passes.
|
||||
pub(super) fn debug_lists_gpu_passes(&self) -> bool {
|
||||
self.debug
|
||||
&& self.title.is_none()
|
||||
&& !(self.ui.hide_gui && self.screen == Screen::Playing)
|
||||
&& self.debug_entry("system_specs")
|
||||
}
|
||||
/// The monitor's refresh rate in millihertz, asked of the OS at most once a second (on
|
||||
/// Windows, winit calls `EnumDisplaySettingsExW` each time).
|
||||
fn debug_refresh_rate(&self) -> Option<u32> {
|
||||
if let Some((at, rate)) = self.debug_refresh.get()
|
||||
&& at.elapsed() < Duration::from_secs(1)
|
||||
{
|
||||
return rate;
|
||||
}
|
||||
let rate = self
|
||||
.window
|
||||
.as_ref()
|
||||
.and_then(|w| w.current_monitor())
|
||||
.and_then(|m| m.refresh_rate_millihertz());
|
||||
self.debug_refresh.set(Some((Instant::now(), rate)));
|
||||
rate
|
||||
}
|
||||
/// `DebugEntryFps`.
|
||||
pub(super) fn debug_fps_line(&self, d: &mut debug_overlay::Displayer) {
|
||||
@@ -357,14 +411,9 @@ impl Game<'_> {
|
||||
wgpu::PresentMode::FifoRelaxed => " (fifo relaxed)",
|
||||
_ => "",
|
||||
});
|
||||
let refresh = self
|
||||
.window
|
||||
.as_ref()
|
||||
.and_then(|w| w.current_monitor())
|
||||
.and_then(|m| m.refresh_rate_millihertz())
|
||||
.map_or("0".into(), |mhz| {
|
||||
debug_overlay::refresh_rate_label(f64::from(mhz) / 1000.0)
|
||||
});
|
||||
let refresh = self.debug_refresh_rate().map_or("0".into(), |mhz| {
|
||||
debug_overlay::refresh_rate_label(f64::from(mhz) / 1000.0)
|
||||
});
|
||||
// Horizon: with frame generation running, the frames reaching the screen too.
|
||||
let generated = self
|
||||
.renderer
|
||||
@@ -420,12 +469,11 @@ impl Game<'_> {
|
||||
framebuffer: [i32; 2],
|
||||
) {
|
||||
if let Some(r) = self.renderer.as_ref() {
|
||||
static CPU: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
d.group(
|
||||
"system",
|
||||
[format!(
|
||||
"CPU: {}",
|
||||
CPU.get_or_init(horizon_client::crash::cpu)
|
||||
DEBUG_CPU.get_or_init(horizon_client::crash::cpu)
|
||||
)],
|
||||
);
|
||||
let info = &r.info;
|
||||
@@ -445,7 +493,8 @@ impl Game<'_> {
|
||||
);
|
||||
if let Some(w) = self.window.as_ref() {
|
||||
let density = w.scale_factor();
|
||||
let logical = w.inner_size().to_logical::<f64>(density);
|
||||
let logical = winit::dpi::PhysicalSize::new(framebuffer[0], framebuffer[1])
|
||||
.to_logical::<f64>(density);
|
||||
d.group(
|
||||
"system",
|
||||
[format!(
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! <json>` edits the lists as vanilla's management protocol does (management.rs).
|
||||
//! `player-expect <uuid> [seconds]`, `player-drop <uuid>`, `player-export <uuid> [freeze]`,
|
||||
//! `player-release <uuid> ...`, `player-import <uuid> <json>` and `player-confirm <uuid> ...` are a proxy's,
|
||||
//! for a player it moves between two servers (transfer.rs).
|
||||
//! for a player it moves between two servers, and `transfer-status` the counts it reads first (transfer.rs).
|
||||
use super::{ServerState, capture_log};
|
||||
use crate::game::Game;
|
||||
use serde_json::{Value, json};
|
||||
@@ -46,6 +46,8 @@ impl Game<'_> {
|
||||
while let Some(request) = self.control.as_ref().and_then(|c| c.try_recv()) {
|
||||
let reply = match request.line.trim().split_once(' ') {
|
||||
_ if request.line.trim() == "server-status" => self.server_status(state),
|
||||
// A proxy reading whether it may send players here (transfer.rs).
|
||||
_ if request.line.trim() == "transfer-status" => self.transfer_status(),
|
||||
Some(("server-events", since)) => {
|
||||
super::events::since(since.trim().parse().unwrap_or(0))
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
//! `player-export <uuid> [freeze]` (the source answers with the player's record) and
|
||||
//! `player-release <uuid> <hand-over> moved [announce] | abort` (the source lets the player go),
|
||||
//! `player-import <uuid> <json>` and `player-confirm <uuid> <hand-over>` (the target takes the
|
||||
//! record and lets the held player in). They run
|
||||
//! record and lets the held player in), and `transfer-status` (the counts a proxy reads before
|
||||
//! it sends players here). They run
|
||||
//! between two ticks, on the server thread, so the player is never caught half-way through a
|
||||
//! tick. The rules are `lan/arrivals.rs`'s. All of them are refused while `server.toml`'s
|
||||
//! `[transfer]` is off.
|
||||
@@ -39,6 +40,16 @@ impl Game<'_> {
|
||||
reply
|
||||
}
|
||||
|
||||
/// `transfer-status`: the counts a proxy reads before it sends players here
|
||||
/// (`arrivals::status_request`). A proxy asks it every few seconds, so it writes nothing to
|
||||
/// the log.
|
||||
pub(super) fn transfer_status(&self) -> Value {
|
||||
match self.lan.as_ref() {
|
||||
Some(host) => arrivals::status_request(&host.hub, Instant::now()),
|
||||
None => json!({ "error": "the server is not open to players" }),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_player_request(&mut self, verb: &str, args: &str) -> Value {
|
||||
let Some(host) = self.lan.as_ref() else {
|
||||
return json!({ "error": "the server is not open to players" });
|
||||
|
||||
@@ -878,14 +878,19 @@ impl Game<'_> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
/// A survival player three blocks south of `pos` mines it holding the
|
||||
/// case's item (`break_block`, which every player's mining goes through):
|
||||
/// the statistics and the held stack, as `VanillaDispenserOracle.mine`
|
||||
/// prints them.
|
||||
/// A player three blocks south of `pos` mines it holding the case's item
|
||||
/// (`break_block`, which every player's mining goes through), in survival
|
||||
/// or in creative, where the block and its contents never drop: the
|
||||
/// statistics and the held stack, as `VanillaDispenserOracle.mine` prints
|
||||
/// them.
|
||||
fn fixture_mine(&mut self, pos: BlockPos, op: &Value) -> Result<Vec<String>> {
|
||||
let stack = parse_stack(op["item"].as_str().unwrap_or("-"))?;
|
||||
self.mode = GameMode::Survival;
|
||||
self.creative = false;
|
||||
self.mode = if op["creative"].as_bool().unwrap_or(false) {
|
||||
GameMode::Creative
|
||||
} else {
|
||||
GameMode::Survival
|
||||
};
|
||||
self.creative = self.mode == GameMode::Creative;
|
||||
self.player = Some(PlayerMotion::new(
|
||||
self.motion,
|
||||
horizon_core::geometry::Vec3::new(
|
||||
|
||||
@@ -191,6 +191,20 @@ pub(super) struct Crystal {
|
||||
pub invulnerable: bool,
|
||||
}
|
||||
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;
|
||||
@@ -1260,7 +1274,8 @@ impl Game<'_> {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let time = self.world.random.next_int_bound(100_000).unwrap_or(0);
|
||||
let drawn = self.world.random.next_int_bound(100_000).unwrap_or(0);
|
||||
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 {
|
||||
@@ -2575,15 +2590,17 @@ impl Game<'_> {
|
||||
pub(super) fn dragon_target(&self) -> Option<(u32, horizon_client::mobs::TargetInfo)> {
|
||||
let d = self.dragon.as_ref().filter(|d| d.alive())?;
|
||||
let p = d.position;
|
||||
let bounds = Aabb::new(
|
||||
Vec3::new(p.x - 8.0, p.y, p.z - 8.0),
|
||||
Vec3::new(p.x + 8.0, p.y + 8.0, p.z + 8.0),
|
||||
);
|
||||
Some((
|
||||
d.id,
|
||||
horizon_client::mobs::TargetInfo {
|
||||
position: p,
|
||||
eye_y: p.y + 6.8,
|
||||
bounds: Aabb::new(
|
||||
Vec3::new(p.x - 8.0, p.y, p.z - 8.0),
|
||||
Vec3::new(p.x + 8.0, p.y + 8.0, p.z + 8.0),
|
||||
),
|
||||
bounds,
|
||||
hitbox: bounds,
|
||||
alive: true,
|
||||
can_be_seen_as_enemy: true,
|
||||
is_player: false,
|
||||
@@ -3881,15 +3898,17 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
// EndCrystal's constructor: `this.random.nextInt(100000)`, the crystal's own random.
|
||||
let time = super::dispenser::entity_random()
|
||||
let drawn = super::dispenser::entity_random()
|
||||
.next_int_bound(100_000)
|
||||
.unwrap_or(0);
|
||||
let at = Vec3::new(x + 0.5, y, z + 0.5);
|
||||
let time = Crystal::start_time(at, drawn, self.clock.still());
|
||||
// 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 {
|
||||
id: self.next_entity,
|
||||
position: Vec3::new(x + 0.5, y, z + 0.5),
|
||||
position: at,
|
||||
show_bottom: false,
|
||||
time,
|
||||
beam: None,
|
||||
@@ -4178,7 +4197,8 @@ impl Game<'_> {
|
||||
let at = Vec3::new(f64::from(cx) + 0.5, f64::from(h + 1), f64::from(cz) + 0.5);
|
||||
self.crystals
|
||||
.retain(|c| c.position.distance_squared(at) > 0.01);
|
||||
let time = self.world.random.next_int_bound(100_000).unwrap_or(0);
|
||||
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,
|
||||
@@ -4829,6 +4849,31 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// Under `HORIZON_STILL` a crystal's spin and bob start from its position, whichever order
|
||||
/// the crystals load in; without it each takes the next draw, as vanilla's do.
|
||||
#[test]
|
||||
fn a_still_crystal_starts_from_its_position_in_any_load_order() {
|
||||
let spikes = [Vec3::new(42.5, 77.0, 0.5), Vec3::new(-33.5, 95.0, 24.5)];
|
||||
let starts = |order: [usize; 2], still: bool| {
|
||||
let mut random = LegacyRandom::new(5);
|
||||
let mut times = [0; 2];
|
||||
for i in order {
|
||||
let drawn = random.next_int_bound(100_000).unwrap();
|
||||
times[i] = Crystal::start_time(spikes[i], drawn, still);
|
||||
}
|
||||
times
|
||||
};
|
||||
let still = starts([0, 1], true);
|
||||
assert_eq!(still, starts([1, 0], true));
|
||||
assert_ne!(still[0], still[1], "each crystal keeps its own phase");
|
||||
let first = LegacyRandom::new(5).next_int_bound(100_000).unwrap();
|
||||
assert_eq!(starts([0, 1], false)[0], first);
|
||||
assert_eq!(
|
||||
starts([1, 0], false)[1],
|
||||
first,
|
||||
"the first loaded takes the first draw"
|
||||
);
|
||||
}
|
||||
/// `Mth.wrapDegrees`: `% 360` then one step of 360, exact for a negative angle
|
||||
/// (a remainder made positive and back again loses the float's low bits).
|
||||
#[test]
|
||||
|
||||
@@ -4,108 +4,116 @@ use super::{Game, shield};
|
||||
use glam::DVec3;
|
||||
use horizon_client::dynamic_light::{self, BURNING, Luminance, Source};
|
||||
|
||||
/// The brighter hand (the second wins ties), or fire if it outshines both. Item names
|
||||
/// borrow the entity's stack; even sources outside the selected MAX need no allocation.
|
||||
fn brighter<'a>(
|
||||
lum: &Luminance,
|
||||
items: [Option<&'a str>; 2],
|
||||
on_fire: bool,
|
||||
) -> (u8, Option<&'a str>) {
|
||||
let (level, item) = items
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|i| (lum.of(i), Some(i)))
|
||||
.max_by_key(|(level, _)| *level)
|
||||
.unwrap_or((0, None));
|
||||
if on_fire && BURNING > level {
|
||||
(BURNING, None)
|
||||
} else {
|
||||
(level, item.filter(|_| level > 0))
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Fills `dynamic_lights` for the frame: the player's and mobs' held items and fire, and
|
||||
/// glowing dropped items, the nearest [`dynamic_light::MAX`] of them.
|
||||
/// How brightly each item glows, built on first use.
|
||||
pub(super) fn luminance(&self) -> &Luminance {
|
||||
self.luminance
|
||||
.get_or_init(|| Luminance::new(self.res.facts(), self.pack.assets.registry))
|
||||
}
|
||||
/// The light sources this frame while the extra is on: the player's and mobs' held items and
|
||||
/// fire, and glowing dropped items, each with the item that gives the light (none for fire).
|
||||
pub(super) fn dynamic_light_sources(&self, alpha: f64) -> Vec<(Source, Option<String>)> {
|
||||
let mut sources = Vec::new();
|
||||
if !self.settings.extras.dynamic_lights {
|
||||
return sources;
|
||||
}
|
||||
let lum = self.luminance();
|
||||
// The brighter of two hands with what it holds, or fire if it outshines both.
|
||||
let brighter = |items: [Option<&str>; 2], on_fire: bool| {
|
||||
let (level, item) = items
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|i| (lum.of(i), Some(i)))
|
||||
.max_by_key(|(level, _)| *level)
|
||||
.unwrap_or((0, None));
|
||||
if on_fire && BURNING > level {
|
||||
(BURNING, None)
|
||||
} else {
|
||||
// Only a lit source names its item: nothing is allocated for a dark one.
|
||||
(level, item.filter(|_| level > 0).map(str::to_owned))
|
||||
}
|
||||
};
|
||||
if let Some(p) = &self.player
|
||||
&& !p.spectator
|
||||
{
|
||||
let feet = self
|
||||
.previous_position
|
||||
.lerp(p.position, self.clock.camera_fraction());
|
||||
let (level, item) = brighter(
|
||||
[
|
||||
self.stack_in(self.selected).map(|s| &*s.item),
|
||||
self.stack_in(shield::OFFHAND_SLOT).map(|s| &*s.item),
|
||||
],
|
||||
p.fire_ticks > 0,
|
||||
);
|
||||
// About where the hands are.
|
||||
sources.push((
|
||||
Source {
|
||||
at: DVec3::new(feet.x, feet.y + 1.2, feet.z),
|
||||
level,
|
||||
},
|
||||
item,
|
||||
));
|
||||
}
|
||||
for m in self.mobs.iter().filter(|m| !m.removed) {
|
||||
let (level, item) = brighter(
|
||||
[m.extra.main_hand.as_deref(), m.extra.off_hand.as_deref()],
|
||||
m.fire_ticks > 0,
|
||||
);
|
||||
if level == 0 {
|
||||
continue;
|
||||
}
|
||||
let at = m.previous.lerp(m.position, alpha);
|
||||
sources.push((
|
||||
Source {
|
||||
at: DVec3::new(at.x, at.y + f64::from(m.height()) * 0.6, at.z),
|
||||
level,
|
||||
},
|
||||
item,
|
||||
));
|
||||
}
|
||||
for dropped in &self.dropped {
|
||||
let (level, item) =
|
||||
brighter([Some(&*dropped.stack.item), None], dropped.fire_ticks > 0);
|
||||
if level == 0 {
|
||||
continue;
|
||||
}
|
||||
let at = dropped.previous.lerp(dropped.position, alpha);
|
||||
sources.push((
|
||||
Source {
|
||||
at: DVec3::new(at.x, at.y + 0.25, at.z),
|
||||
level,
|
||||
},
|
||||
item,
|
||||
));
|
||||
}
|
||||
sources
|
||||
/// The player's, mobs' and dropped items' sources in their original order, with borrowed
|
||||
/// item names (none for fire). Nothing, including luminance, is visited while disabled.
|
||||
pub(super) fn dynamic_light_sources(
|
||||
&self,
|
||||
alpha: f64,
|
||||
) -> impl Iterator<Item = (Source, Option<&str>)> {
|
||||
let lum = self
|
||||
.settings
|
||||
.extras
|
||||
.dynamic_lights
|
||||
.then(|| self.luminance());
|
||||
lum.into_iter().flat_map(move |lum| {
|
||||
let player = self.player.as_ref().filter(|p| !p.spectator).map(move |p| {
|
||||
let feet = self
|
||||
.previous_position
|
||||
.lerp(p.position, self.clock.camera_fraction());
|
||||
let (level, item) = brighter(
|
||||
lum,
|
||||
[
|
||||
self.stack_in(self.selected).map(|s| &*s.item),
|
||||
self.stack_in(shield::OFFHAND_SLOT).map(|s| &*s.item),
|
||||
],
|
||||
p.fire_ticks > 0,
|
||||
);
|
||||
(
|
||||
Source {
|
||||
at: DVec3::new(feet.x, feet.y + 1.2, feet.z),
|
||||
level,
|
||||
},
|
||||
item,
|
||||
)
|
||||
});
|
||||
let mobs = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| !m.removed)
|
||||
.filter_map(move |m| {
|
||||
let (level, item) = brighter(
|
||||
lum,
|
||||
[m.extra.main_hand.as_deref(), m.extra.off_hand.as_deref()],
|
||||
m.fire_ticks > 0,
|
||||
);
|
||||
if level == 0 {
|
||||
return None;
|
||||
}
|
||||
let at = m.previous.lerp(m.position, alpha);
|
||||
Some((
|
||||
Source {
|
||||
at: DVec3::new(at.x, at.y + f64::from(m.height()) * 0.6, at.z),
|
||||
level,
|
||||
},
|
||||
item,
|
||||
))
|
||||
});
|
||||
let dropped = self.dropped.iter().filter_map(move |dropped| {
|
||||
let (level, item) = brighter(
|
||||
lum,
|
||||
[Some(&*dropped.stack.item), None],
|
||||
dropped.fire_ticks > 0,
|
||||
);
|
||||
if level == 0 {
|
||||
return None;
|
||||
}
|
||||
let at = dropped.previous.lerp(dropped.position, alpha);
|
||||
Some((
|
||||
Source {
|
||||
at: DVec3::new(at.x, at.y + 0.25, at.z),
|
||||
level,
|
||||
},
|
||||
item,
|
||||
))
|
||||
});
|
||||
player.into_iter().chain(mobs).chain(dropped)
|
||||
})
|
||||
}
|
||||
/// Fills `dynamic_lights` for the frame from [`Self::dynamic_light_sources`], the nearest
|
||||
/// [`dynamic_light::MAX`] of them.
|
||||
/// The nearest MAX sources, without building an intermediate list or owning item names.
|
||||
pub(super) fn gather_dynamic_lights(&mut self, camera: DVec3, alpha: f64) {
|
||||
let _span = horizon_client::spans::guard("pr_light_gather");
|
||||
let mut laps = horizon_client::spans::laps();
|
||||
let mut out = std::mem::take(&mut self.dynamic_lights);
|
||||
out.clear();
|
||||
let sources: Vec<Source> = self
|
||||
.dynamic_light_sources(alpha)
|
||||
.into_iter()
|
||||
.map(|(s, _)| s)
|
||||
.collect();
|
||||
laps.lap("pr_light_sources");
|
||||
dynamic_light::select(&sources, camera, &mut out);
|
||||
dynamic_light::select_iter(
|
||||
self.dynamic_light_sources(alpha).map(|(s, _)| s),
|
||||
camera,
|
||||
&mut out,
|
||||
);
|
||||
self.dynamic_lights = out;
|
||||
laps.lap("pr_light_select");
|
||||
}
|
||||
}
|
||||
@@ -1210,6 +1210,12 @@ impl Game<'_> {
|
||||
};
|
||||
let survive = self.world.random.next_int_bound(5).unwrap_or(0) > 0;
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
let ctx = self.player_context(None);
|
||||
self.post_game_event(
|
||||
"minecraft:projectile_shoot",
|
||||
[start.x, start.y, start.z],
|
||||
ctx,
|
||||
);
|
||||
let id = self.take_entity_id();
|
||||
self.end.eyes.push(EyeOfEnder {
|
||||
id,
|
||||
|
||||
@@ -567,42 +567,77 @@ macro_rules! stored_motion {
|
||||
};
|
||||
}
|
||||
|
||||
/// One `ItemEntity.tick`, with the fluid push and the bubble column decided by
|
||||
/// the `Entity` defaults every kind shares: fluids, move, column, settle,
|
||||
/// fluids again. Returns false when the item should be removed.
|
||||
pub(super) fn tick_item(
|
||||
item: &mut ItemEntity,
|
||||
world: &horizon_client::world::World,
|
||||
registry: &horizon_world::registry::BlockRegistry,
|
||||
facts: &horizon_client::blocks::BlockFacts,
|
||||
collision: &horizon_world::block_collision::BlockCollisionRegistry<'_>,
|
||||
fluids: &horizon_client::motion::FluidResources,
|
||||
no_physics: bool,
|
||||
entity_boxes: &[Aabb],
|
||||
border: Option<&horizon_core::geometry::border::BorderGeometry>,
|
||||
blocks: Option<&horizon_world::movement_properties::BoundMovementProperties>,
|
||||
splash: &mut impl FnMut(horizon_client::entities::ItemSplash),
|
||||
) -> bool {
|
||||
let (water, _lava) = item.push_by_fluid(world, fluids);
|
||||
// `Entity.baseTick` splashes before the item moves. The first tick does not.
|
||||
item.water_entry(water > 0.0, item.ticks == 0, splash);
|
||||
let step = item.tick_move(
|
||||
world,
|
||||
facts,
|
||||
collision,
|
||||
no_physics,
|
||||
(water, _lava),
|
||||
entity_boxes,
|
||||
border,
|
||||
blocks,
|
||||
);
|
||||
item.push_by_bubble_column(world, registry, facts, collision);
|
||||
let alive = item.tick_settle(world, facts, no_physics, step);
|
||||
// `ItemEntity.tick` refreshes immersion after moving and splashes if that
|
||||
// move is what entered the water.
|
||||
let (water, _) = item.push_by_fluid(world, fluids);
|
||||
item.water_entry(water > 0.0, false, splash);
|
||||
alive
|
||||
impl Game<'_> {
|
||||
/// Tick an item owned by the caller. The two results are ordinary survival
|
||||
/// and explicit consumption by a block-contact handler.
|
||||
pub(super) fn tick_item_with_block_contacts(
|
||||
&mut self,
|
||||
item: &mut ItemEntity,
|
||||
no_physics: bool,
|
||||
entity_boxes: &[Aabb],
|
||||
client: bool,
|
||||
splash: &mut impl FnMut(horizon_client::entities::ItemSplash),
|
||||
) -> (bool, bool) {
|
||||
let heights = item.push_by_fluid(&self.world, &self.res.motion().fluids);
|
||||
// Entity.baseTick splashes before moving, except on the first tick.
|
||||
item.water_entry(heights.0 > 0.0, item.ticks == 0, splash);
|
||||
let step = item.tick_move_before_block_effects(
|
||||
&self.world,
|
||||
self.res.facts(),
|
||||
self.collision,
|
||||
no_physics,
|
||||
heights,
|
||||
entity_boxes,
|
||||
Some(self.border.geometry()),
|
||||
);
|
||||
if let Some(pos) =
|
||||
item.vertical_contact(&self.world, self.collision, &self.res.motion().blocks, step)
|
||||
{
|
||||
let state = self.world.block(pos.x, pos.y, pos.z);
|
||||
let position = item.position;
|
||||
let mut discarded = false;
|
||||
self.native_item_after_vertical_collision(
|
||||
self.dim.id(),
|
||||
client,
|
||||
item.id,
|
||||
state,
|
||||
[pos.x, pos.y, pos.z],
|
||||
[position.x, position.y, position.z],
|
||||
&mut item.stack,
|
||||
&mut discarded,
|
||||
);
|
||||
if discarded || item.stack.count == 0 {
|
||||
return (false, true);
|
||||
}
|
||||
}
|
||||
item.finish_move_block_effects(
|
||||
&self.world,
|
||||
self.res.facts(),
|
||||
no_physics,
|
||||
Some(&self.res.motion().blocks),
|
||||
step,
|
||||
);
|
||||
item.push_by_bubble_column(
|
||||
&self.world,
|
||||
self.pack.assets.registry,
|
||||
self.res.facts(),
|
||||
self.collision,
|
||||
);
|
||||
let alive = item.tick_settle(&self.world, self.res.facts(), no_physics, step);
|
||||
// ItemEntity.tick refreshes immersion and splashes on water entry.
|
||||
let (water, _) = item.push_by_fluid(&self.world, &self.res.motion().fluids);
|
||||
item.water_entry(water > 0.0, false, splash);
|
||||
(alive, false)
|
||||
}
|
||||
|
||||
/// Keep new joins after the active batch, so they tick next time.
|
||||
pub(super) fn restore_item_batch(
|
||||
&mut self,
|
||||
mut items: horizon_client::join_seq::JoinList<ItemEntity>,
|
||||
) {
|
||||
items.extend(std::mem::take(&mut self.dropped));
|
||||
self.dropped = items;
|
||||
}
|
||||
}
|
||||
|
||||
/// The game's lists of entities, in the order systems walk them. Hands them to
|
||||
@@ -964,6 +999,16 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
impl Entity for Mob {
|
||||
/// `Entity.move(MoverType.PISTON, ...)` ends in `setPos`, which `Shulker`
|
||||
/// overrides to snap to the block centre.
|
||||
fn move_by_piston(&mut self, movement: Vec3) {
|
||||
let moved = self.position.add_vector(movement);
|
||||
if self.species() == Species::Shulker {
|
||||
crate::mobs::shulker::client_set_pos(self, moved.x, moved.y, moved.z);
|
||||
} else {
|
||||
self.position = moved;
|
||||
}
|
||||
}
|
||||
fn rotation(&self) -> [f32; 2] {
|
||||
[self.yaw, self.head_pitch]
|
||||
}
|
||||
@@ -1008,8 +1053,10 @@ impl Entity for Mob {
|
||||
fn can_use_portal(&self, game: &Game<'_>) -> bool {
|
||||
game.mob_can_use_portal(self)
|
||||
}
|
||||
/// `PersistentEntitySectionManager.Callback.onRemove`: discard stops
|
||||
/// section enumeration immediately, before the entity list is swept.
|
||||
fn selector_target(&self) -> Option<Target> {
|
||||
(self.death_time == 0).then_some(Target::Mob(self.id))
|
||||
(!self.removed && self.death_time == 0).then_some(Target::Mob(self.id))
|
||||
}
|
||||
fn bounds(&self) -> Aabb {
|
||||
Mob::bounds(self)
|
||||
@@ -1618,7 +1665,8 @@ impl Entity for Projectile {
|
||||
"minecraft:fireball"
|
||||
| "minecraft:small_fireball"
|
||||
| "minecraft:wind_charge"
|
||||
| "minecraft:breeze_wind_charge" => game.load_fireball(tag, uuid, player_uuid),
|
||||
| "minecraft:breeze_wind_charge"
|
||||
| "minecraft:wither_skull" => game.load_fireball(tag, uuid, player_uuid),
|
||||
id if horizon_client::entity_nbt::throwable_kind(id).is_some() => {
|
||||
game.load_throwable(tag, uuid, player_uuid)
|
||||
}
|
||||
|
||||
@@ -673,10 +673,18 @@ impl Game<'_> {
|
||||
// ravagers are pushed.
|
||||
hurt_mob(m, hurtable, cause, (amount, source));
|
||||
}
|
||||
// 5 health, destroyed by the explosion damage (nether stars resist).
|
||||
// `ItemEntity.hurtServer`: the damage comes off `Health`
|
||||
// (5 to start) in f32 and the item is destroyed at zero, so
|
||||
// a survivor keeps the health the blast left it. The nether
|
||||
// star is `ItemStack.canBeHurtBy`'s explosion exception, and
|
||||
// a blast with a source entity under `mobGriefing` off never
|
||||
// reaches here (`items_ignore`).
|
||||
EntityHandle::Item(i) => {
|
||||
destroyed = &*self.dropped[i].stack.item != "minecraft:nether_star"
|
||||
&& amount >= 5.0;
|
||||
let item = &mut self.dropped[i];
|
||||
if &*item.stack.item != "minecraft:nether_star" {
|
||||
item.health = (item.health as f32 - amount) as i32;
|
||||
destroyed = item.health <= 0;
|
||||
}
|
||||
}
|
||||
// `ExperienceOrb.hurtServer` takes the damage off their 5 health.
|
||||
EntityHandle::Orb(i) => {
|
||||
|
||||
@@ -426,6 +426,11 @@ impl Game<'_> {
|
||||
1.0,
|
||||
pitch,
|
||||
);
|
||||
// A rod that broke during retrieval is now an empty ItemStack,
|
||||
// whose use-effects component is absent.
|
||||
if let Some(rod) = self.held().cloned() {
|
||||
self.use_vibration(&rod, "minecraft:item_interact_finish");
|
||||
}
|
||||
} else {
|
||||
let pitch = 0.4 / (self.world.random.next_float() * 0.4 + 0.8);
|
||||
self.audio.play(
|
||||
@@ -473,6 +478,7 @@ impl Game<'_> {
|
||||
});
|
||||
// FishingRodItem.use counts a cast, not a reel-in.
|
||||
self.award_stat("used", "minecraft:fishing_rod", 1);
|
||||
self.use_vibration(stack, "minecraft:item_interact_start");
|
||||
}
|
||||
self.swing_arm();
|
||||
true
|
||||
|
||||
@@ -116,12 +116,7 @@ impl Game<'_> {
|
||||
));
|
||||
let supported = behind.legacy_solid || (s[1] == 0 && behind.has_class("DiodeBlock"));
|
||||
// canCoexist(true): only hanging entities facing the same way block it.
|
||||
let overlaps = |o: ([f64; 3], [f64; 3])| (0..3).all(|i| o.0[i] < hi[i] && lo[i] < o.1[i]);
|
||||
supported
|
||||
&& !self
|
||||
.item_frames
|
||||
.iter()
|
||||
.any(|o| o.id != f.id && o.facing == f.facing && overlaps(o.bounds()))
|
||||
supported && super::paintings::frame_coexists(f, &self.item_frames, &self.paintings)
|
||||
}
|
||||
/// Asks the mods whether the acting guest may place a hanging entity
|
||||
/// of type `kind` at `pos` (`native_hanging_place`): true when one
|
||||
@@ -163,21 +158,6 @@ impl Game<'_> {
|
||||
"minecraft:glow_item_frame" => true,
|
||||
_ => return false,
|
||||
};
|
||||
let entity_data = stack.extra.as_ref().and_then(|extra| {
|
||||
extra.other.iter().find_map(|(k, v)| {
|
||||
(k == "minecraft:entity_data" || k == "entity_data").then_some(v)
|
||||
})
|
||||
});
|
||||
let invisible = entity_data
|
||||
.and_then(|v| horizon_client::save::number(v.get("Invisible")))
|
||||
.unwrap_or(0)
|
||||
!= 0;
|
||||
// `applyComponentsFromItemStack` sets `CUSTOM_NAME`, then `entity_data`
|
||||
// loads (`updateCustomEntityTag`): a missing `CustomName` clears it.
|
||||
let custom_name = match entity_data {
|
||||
Some(data) => data.get("CustomName").cloned(),
|
||||
None => stack.data().and_then(|d| d.custom_name.clone()),
|
||||
};
|
||||
let pos: [i32; 3] = std::array::from_fn(|i| hit.pos[i] + hit.face[i]);
|
||||
// `ItemFrameItem.mayPlace`: `Level.isInsideBuildHeight(blockPos)`, so a frame is not
|
||||
// placed on the top face of the highest block or under the lowest one.
|
||||
@@ -189,7 +169,7 @@ impl Game<'_> {
|
||||
self.use_failed = true;
|
||||
return true;
|
||||
}
|
||||
let frame = ItemFrame {
|
||||
let mut frame = ItemFrame {
|
||||
id: self.next_entity + 1,
|
||||
pos,
|
||||
facing: facing_of(hit.face),
|
||||
@@ -198,20 +178,29 @@ impl Game<'_> {
|
||||
rotation: 0,
|
||||
drop_chance: 1.0,
|
||||
fixed: false,
|
||||
invisible,
|
||||
invisible: false,
|
||||
check_interval: 0,
|
||||
custom_name,
|
||||
// `applyComponentsFromItemStack` sets `CUSTOM_NAME`.
|
||||
custom_name: stack.data().and_then(|d| d.custom_name.clone()),
|
||||
};
|
||||
// `EntityType.createDefaultStackConfig`: the item's `custom_data`, then its
|
||||
// `entity_data` merged over the frame (`TypedEntityData.loadInto`), before
|
||||
// `survives` runs on the configured frame.
|
||||
let kind = frame.kind();
|
||||
let own = horizon_client::entity_nbt::item_frame_to_nbt(&frame, [0; 4], None);
|
||||
let configured =
|
||||
horizon_client::block_entity_item::stack_configured_entity(stack, kind, &own);
|
||||
if let Some(mut loaded) =
|
||||
horizon_client::entity_nbt::item_frame_from_nbt(&configured, frame.id)
|
||||
{
|
||||
loaded.glow = glow;
|
||||
frame = loaded;
|
||||
}
|
||||
// HangingEntity.survives false: CONSUME, which ItemStack.useOn still counts as a use.
|
||||
if !self.frame_survives(&frame) {
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
return true;
|
||||
}
|
||||
let kind = if glow {
|
||||
"minecraft:glow_item_frame"
|
||||
} else {
|
||||
"minecraft:item_frame"
|
||||
};
|
||||
if self.hanging_place_cancelled(kind, pos) {
|
||||
return true;
|
||||
}
|
||||
@@ -223,6 +212,12 @@ impl Game<'_> {
|
||||
let ctx = self.player_context(None);
|
||||
self.post_game_event("minecraft:entity_place", center(&frame), ctx);
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
// The unmodelled rest of the loaded data (`data`, `Tags`...) stays with the frame.
|
||||
let mut raw = configured;
|
||||
if let horizon_world::nbt::Tag::Compound(map) = &mut raw {
|
||||
map.remove(&"UUID".into());
|
||||
}
|
||||
self.persist.raw.insert(frame.id, raw);
|
||||
self.item_frames.push(frame);
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
|
||||
@@ -222,6 +222,7 @@ impl Game<'_> {
|
||||
let _ = std::fs::write(self.datapack_settings_path(), json.to_string());
|
||||
}
|
||||
pub(super) fn sync_function_registries(&mut self) {
|
||||
self.sync_enchantment_registries();
|
||||
let reg = &mut self.ui.registries;
|
||||
reg.functions = self.fns.store.functions.keys().cloned().collect();
|
||||
reg.function_tags = self.fns.store.function_tags.keys().cloned().collect();
|
||||
@@ -564,9 +565,14 @@ impl Game<'_> {
|
||||
_ => data,
|
||||
};
|
||||
if !matches!(tag, Tag::Compound(_)) {
|
||||
// FunctionCommand.getArgumentTag uses TagType.getName, not the tag's value.
|
||||
let names = [
|
||||
"END", "BYTE", "SHORT", "INT", "LONG", "FLOAT", "DOUBLE", "BYTE[]", "STRING",
|
||||
"LIST", "COMPOUND", "INT[]", "LONG[]",
|
||||
];
|
||||
return Err(err(
|
||||
"commands.function.error.argument_not_compound",
|
||||
&[&"tag"],
|
||||
&[&names[usize::from(tag.id())]],
|
||||
));
|
||||
}
|
||||
Some(tag)
|
||||
@@ -1009,7 +1015,19 @@ impl Game<'_> {
|
||||
.ok_or_else(|| err("argument.predicate.unknown", &[&id]))?
|
||||
};
|
||||
let source = self.command_source.unwrap_or_else(|| self.player_source());
|
||||
let view = source.entity.map(|t| self.entity_view_asked(t, &json));
|
||||
let view = source.entity.map(|t| {
|
||||
let scores = self.holder_name(t).map(|holder| {
|
||||
self.score
|
||||
.board
|
||||
.holder_scores(&holder)
|
||||
.into_iter()
|
||||
.map(|(objective, value)| (objective.name.clone(), value))
|
||||
.collect()
|
||||
});
|
||||
let mut view = self.entity_view_asked(t, &json);
|
||||
view.scores = scores;
|
||||
view
|
||||
});
|
||||
Ok(self.conditions_hold(&json, view.as_ref(), source.pos))
|
||||
}
|
||||
}
|
||||
@@ -1026,6 +1044,95 @@ const FEATURE_PACKS: &[(&str, &str)] = &[
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn unless_function_keeps_a_fallthrough_after_running_it_once() {
|
||||
use crate::game::{Audio, Options, Settings, load_resources};
|
||||
use horizon_client::datapack::FunctionLine;
|
||||
use std::time::Instant;
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&crate::game::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.fns.loaded = true;
|
||||
let run = |game: &mut Game<'_>, line: &str| {
|
||||
game.exec = ExecState::default();
|
||||
let source = game.player_source();
|
||||
game.execute_line(line, source).unwrap();
|
||||
};
|
||||
run(&mut game, "scoreboard objectives add h154 dummy");
|
||||
let clock = game.world.game_time;
|
||||
for (name, tail) in [
|
||||
("counter", None),
|
||||
("zero", Some("return 0")),
|
||||
("five", Some("return 5")),
|
||||
("fail", Some("return fail")),
|
||||
] {
|
||||
let id = format!("h154:{name}");
|
||||
let mut lines = vec![FunctionLine::Command(
|
||||
"scoreboard players add #calls h154 1".into(),
|
||||
)];
|
||||
if let Some(tail) = tail {
|
||||
lines.push(FunctionLine::Command(tail.into()));
|
||||
}
|
||||
game.fns.store.functions.insert(
|
||||
id.clone(),
|
||||
Function {
|
||||
id,
|
||||
lines,
|
||||
parameters: Vec::new(),
|
||||
},
|
||||
);
|
||||
}
|
||||
// Explicit returns establish both signs before the old fallthrough
|
||||
// failure. The commands run through the actual function/execute route.
|
||||
for name in ["zero", "five", "fail", "counter"] {
|
||||
for sign in ["if", "unless"] {
|
||||
run(&mut game, "scoreboard players set #calls h154 0");
|
||||
run(&mut game, "scoreboard players set #out h154 99");
|
||||
run(
|
||||
&mut game,
|
||||
&format!(
|
||||
"execute {sign} function h154:{name} run scoreboard players set #out h154 7"
|
||||
),
|
||||
);
|
||||
assert_eq!(game.score.board.score("#calls", "h154").unwrap().value, 1);
|
||||
let keep = (name == "five") == (sign == "if");
|
||||
assert_eq!(
|
||||
game.score.board.score("#out", "h154").unwrap().value,
|
||||
if keep { 7 } else { 99 },
|
||||
"{sign} {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(game.world.game_time, clock);
|
||||
}
|
||||
|
||||
/// Two packs override the same recipe: the later one in the selected
|
||||
/// (`datapacks.json`) order wins, not the later one alphabetically.
|
||||
#[test]
|
||||
@@ -1139,3 +1246,109 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod entity_score_tests {
|
||||
use super::*;
|
||||
use crate::game::{Audio, Options, Progress, Settings, commands::Target, load_resources};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn named_scores_follow_the_registered_target_holder_and_mutations() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
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.ensure_datapacks();
|
||||
game.fns.store.predicates.insert(
|
||||
"h270:min2".into(),
|
||||
serde_json::json!({
|
||||
"type": "minecraft:entity_scores", "entity": "this", "scores": {"pts": {"min": 2}}
|
||||
}),
|
||||
);
|
||||
assert!(game.run_command("scoreboard objectives add pts dummy"));
|
||||
let mut actors = Vec::new();
|
||||
for (index, score) in [Some(0), Some(2), None].into_iter().enumerate() {
|
||||
let uuid = [270, 1, 0, index as i32 + 1];
|
||||
let tag = Tag::compound([
|
||||
("id", Tag::String("minecraft:pig".into())),
|
||||
("UUID", Tag::IntArray(uuid.to_vec())),
|
||||
(
|
||||
"Pos",
|
||||
Tag::List(vec![
|
||||
Tag::Double((3.5 + index as f64).to_bits()),
|
||||
Tag::Double(200.0_f64.to_bits()),
|
||||
Tag::Double(4.5_f64.to_bits()),
|
||||
]),
|
||||
),
|
||||
("Health", Tag::Float(10.0_f32.to_bits())),
|
||||
("NoAI", Tag::Byte(1)),
|
||||
("NoGravity", Tag::Byte(1)),
|
||||
]);
|
||||
let id = game.load_mob(tag, None).unwrap();
|
||||
assert_eq!(game.persist.uuids.get(&id), Some(&uuid));
|
||||
let holder = game.holder_name(Target::Mob(id)).unwrap();
|
||||
assert_eq!(holder, horizon_client::save::uuid_text(uuid));
|
||||
if let Some(score) = score {
|
||||
assert!(game.run_command(&format!("scoreboard players set {holder} pts {score}")));
|
||||
}
|
||||
assert_eq!(
|
||||
game.score.board.score(&holder, "pts").map(|s| s.value),
|
||||
score
|
||||
);
|
||||
actors.push((id, holder));
|
||||
}
|
||||
let time = game.world.game_time;
|
||||
let matches = |game: &mut Game<'_>, id| {
|
||||
let mut source = game.player_source();
|
||||
source.entity = Some(Target::Mob(id));
|
||||
source.pos = game.target_position(Target::Mob(id)).unwrap();
|
||||
game.command_source = Some(source);
|
||||
let result = game.check_predicate("h270:min2").unwrap();
|
||||
game.command_source = None;
|
||||
result
|
||||
};
|
||||
assert!(!matches(&mut game, actors[0].0));
|
||||
assert!(matches(&mut game, actors[1].0));
|
||||
assert!(!matches(&mut game, actors[2].0));
|
||||
for (index, score, expected) in [(0, 3, true), (1, 1, false)] {
|
||||
let (id, holder) = &actors[index];
|
||||
assert!(game.run_command(&format!("scoreboard players set {holder} pts {score}")));
|
||||
assert_eq!(
|
||||
game.score.board.score(holder, "pts").map(|s| s.value),
|
||||
Some(score)
|
||||
);
|
||||
assert_eq!(matches(&mut game, *id), expected);
|
||||
}
|
||||
assert!(!matches(&mut game, actors[2].0));
|
||||
assert!(game.score.board.score(&actors[2].1, "pts").is_none());
|
||||
assert_eq!(game.world.game_time, time);
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,7 @@ impl Game<'_> {
|
||||
if harnessed && !sneaking {
|
||||
// doPlayerRide.
|
||||
let id = self.mobs[index].id;
|
||||
if !self.may_board() || !self.native_may_board(id) {
|
||||
if !self.native_may_board(id) {
|
||||
return true;
|
||||
}
|
||||
self.break_leash(id, true, false);
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
use super::*;
|
||||
use crate::mod_api::{self, Flow, Hooks, Host, ItemAfterVerticalCollisionEvent};
|
||||
use horizon_client::{chunk::Chunk, join_seq::Joined as _};
|
||||
use std::sync::Mutex;
|
||||
|
||||
type Contact = (bool, u32, [i32; 3], [f64; 3], u32);
|
||||
|
||||
#[derive(Default)]
|
||||
struct Probe {
|
||||
contacts: Mutex<Vec<Contact>>,
|
||||
}
|
||||
|
||||
impl Hooks for Arc<Probe> {
|
||||
fn item_after_vertical_collision(
|
||||
&self,
|
||||
_: &mut dyn Host,
|
||||
event: &mut ItemAfterVerticalCollisionEvent<'_>,
|
||||
) -> Flow {
|
||||
self.contacts.lock().unwrap().push((
|
||||
event.client,
|
||||
event.entity,
|
||||
event.block_pos,
|
||||
event.item_pos,
|
||||
event.stack.count,
|
||||
));
|
||||
if event.stack.count == 1 {
|
||||
return Flow::Cancel;
|
||||
}
|
||||
event.stack.count = event.stack.count.saturating_sub(2);
|
||||
Flow::Continue
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and an isolated mod registry"]
|
||||
fn the_active_stack_survives_a_partial_contact_and_consumption_removes_the_entity() {
|
||||
let root = PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
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 = execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.options.dedicated = Some(Vec::new());
|
||||
game.world = World::new(game.res.facts().air, 1);
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
let stone = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:stone")
|
||||
.unwrap();
|
||||
game.world.set(4, 63, 4, stone).unwrap();
|
||||
|
||||
// The centre of the item is outside the block, while its box overlaps it.
|
||||
let landing = [3.99, 64.0, 4.5];
|
||||
let mut input = ItemStack::new("minecraft:iron_ingot", 13);
|
||||
input
|
||||
.data_mut()
|
||||
.set_other("fixture:opaque", Some(horizon_world::nbt::Tag::Long(99)));
|
||||
let spawn = |game: &mut Game<'_>, count| {
|
||||
game.dropped.clear();
|
||||
let mut stack = input.clone();
|
||||
stack.count = count;
|
||||
game.spawn_item(stack, [landing[0], 64.1, landing[2]], [0.0, -0.3, 0.0], 20);
|
||||
game.ticked_entities_through = game.next_entity;
|
||||
game.dropped[0].clone()
|
||||
};
|
||||
let before = spawn(&mut game, 13);
|
||||
game.tick_items();
|
||||
let vanilla = game.dropped[0].clone();
|
||||
assert_eq!(vanilla.id, before.id);
|
||||
assert_eq!(
|
||||
vanilla.position,
|
||||
Vec3::new(landing[0], landing[1], landing[2])
|
||||
);
|
||||
assert_eq!(vanilla.velocity, Vec3::ZERO);
|
||||
assert_eq!(vanilla.stack, input);
|
||||
assert_eq!(vanilla.age, 1);
|
||||
|
||||
let probe = Arc::new(Probe::default());
|
||||
assert!(
|
||||
mod_api::MODS
|
||||
.set(Arc::from(vec![Box::new(probe.clone()) as Box<dyn Hooks>]))
|
||||
.is_ok()
|
||||
);
|
||||
let old = spawn(&mut game, 13);
|
||||
game.tick_items();
|
||||
let item = &game.dropped[0];
|
||||
assert_eq!(item.id, old.id);
|
||||
assert_eq!(item.join_seq(), old.join_seq());
|
||||
assert_eq!(item.position, vanilla.position);
|
||||
assert_eq!(item.velocity, vanilla.velocity);
|
||||
let mut expected = input.clone();
|
||||
expected.count = 11;
|
||||
assert_eq!(item.stack, expected);
|
||||
assert_eq!(
|
||||
probe.contacts.lock().unwrap().as_slice(),
|
||||
&[(false, old.id, [4, 63, 4], landing, 13)]
|
||||
);
|
||||
|
||||
for count in [1, 2] {
|
||||
spawn(&mut game, count);
|
||||
game.tick_items();
|
||||
assert!(game.dropped.is_empty(), "consumed stack of {count}");
|
||||
}
|
||||
}
|
||||
@@ -593,10 +593,12 @@ impl Game<'_> {
|
||||
// EntityRenderer.shouldRender: the item's 0.25 box, grown by 0.5,
|
||||
// in the view frustum. Checked before its model is resolved.
|
||||
if entity && let Some(view) = &self.view_frustum {
|
||||
let feet = (glam::DVec3::new(q.x, q.y, q.z) - camera).as_vec3();
|
||||
let half = glam::Vec3::new(0.125 + 0.5, 0.5, 0.125 + 0.5);
|
||||
let lo = feet - half;
|
||||
let hi = feet + glam::Vec3::new(half.x, 0.25 + 0.5, half.z);
|
||||
let (lo, hi) = dropped_item_cull_box(
|
||||
glam::DVec3::new(item.previous.x, item.previous.y, item.previous.z),
|
||||
glam::DVec3::new(q.x, q.y, q.z),
|
||||
alpha,
|
||||
camera,
|
||||
);
|
||||
if !view.intersects(lo, hi) {
|
||||
continue;
|
||||
}
|
||||
@@ -699,6 +701,25 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `EntityRenderer.shouldRender`'s box for a dropped item, in camera space:
|
||||
/// `EntityRenderer.getBoundingBoxForCulling(entity, partialTick)` is
|
||||
/// `Entity.getInterpolatedBoundingBox(partialTick)` - the item's 0.25 box at
|
||||
/// the position the frame draws, `ItemEntity` overriding neither - grown by 0.5
|
||||
/// all round before the frustum test.
|
||||
fn dropped_item_cull_box(
|
||||
previous: glam::DVec3,
|
||||
position: glam::DVec3,
|
||||
alpha: f64,
|
||||
camera: glam::DVec3,
|
||||
) -> (glam::Vec3, glam::Vec3) {
|
||||
let feet = (previous.lerp(position, alpha) - camera).as_vec3();
|
||||
let half = glam::Vec3::new(0.125 + 0.5, 0.5, 0.125 + 0.5);
|
||||
(
|
||||
feet - half,
|
||||
feet + glam::Vec3::new(half.x, 0.25 + 0.5, half.z),
|
||||
)
|
||||
}
|
||||
|
||||
/// The packed light of an item (sky low, block above it: `vertex_light`, entity.wgsl), its block
|
||||
/// light raised to what it glows with in HDR output.
|
||||
fn item_hdr_light(state: &ItemRenderState, light: u32) -> u32 {
|
||||
@@ -711,6 +732,38 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// `EntityRenderer.shouldRender` culls with `getBoundingBoxForCulling(entity,
|
||||
/// partialTick)`, which is `Entity.getInterpolatedBoundingBox(partialTick)`:
|
||||
/// an item's 0.25 box at the position the frame draws, grown by 0.5. The box
|
||||
/// was built from the tick's own position, so an item crossing the frustum
|
||||
/// edge mid-tick disappeared for the frames before it got there.
|
||||
#[test]
|
||||
fn a_dropped_item_is_culled_at_the_frame_s_position() {
|
||||
let camera = glam::DVec3::ZERO;
|
||||
let previous = glam::DVec3::new(0.0, 64.0, 0.0);
|
||||
let position = glam::DVec3::new(1.0, 64.0, 0.0);
|
||||
let bounds = |alpha: f64| {
|
||||
let (lo, hi) = dropped_item_cull_box(previous, position, alpha, camera);
|
||||
(lo.to_array(), hi.to_array())
|
||||
};
|
||||
// The frame's start: the box stands at the item's previous position.
|
||||
assert_eq!(bounds(0.0), ([-0.625, 63.5, -0.625], [0.625, 64.75, 0.625]));
|
||||
// Halfway through the tick the box has travelled half the block.
|
||||
assert_eq!(bounds(0.5), ([-0.125, 63.5, -0.625], [1.125, 64.75, 0.625]));
|
||||
// And at its end it is where the old code put it on every frame.
|
||||
assert_eq!(bounds(1.0), ([0.375, 63.5, -0.625], [1.625, 64.75, 0.625]));
|
||||
// A falling item moves in y the same way: halfway down a 66 -> 65 drop
|
||||
// the box's floor is at 65.0, where the old code had the tick's 64.5.
|
||||
let (lo, hi) = dropped_item_cull_box(
|
||||
glam::DVec3::new(0.0, 66.0, 0.0),
|
||||
glam::DVec3::new(0.0, 65.0, 0.0),
|
||||
0.5,
|
||||
camera,
|
||||
);
|
||||
assert!((lo.y - 65.0).abs() < 1e-6);
|
||||
assert!((hi.y - 66.25).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn civil_dates_follow_the_gregorian_calendar() {
|
||||
assert_eq!(civil(0), (1970, 1, 1));
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
//! Run alone with private prepared resources: installs process-wide trait mods.
|
||||
use super::*;
|
||||
use crate::mod_api::{self, Flow, Hooks, Host, InteractEntityEvent, ItemInteractMobEvent};
|
||||
use horizon_client::{
|
||||
chunk::Chunk,
|
||||
merchant::{ItemCost, MerchantOffer, Trader},
|
||||
mobs::Mob,
|
||||
};
|
||||
use std::sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, AtomicU8, Ordering},
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Probe {
|
||||
calls: Mutex<Vec<(i32, String, String)>>,
|
||||
answer: AtomicU8,
|
||||
claim_entity: AtomicBool,
|
||||
discard: AtomicBool,
|
||||
}
|
||||
impl Hooks for Arc<Probe> {
|
||||
fn interact_entity(&self, _: &mut dyn Host, _: &mut InteractEntityEvent<'_>) -> Flow {
|
||||
if self.claim_entity.load(Ordering::Relaxed) {
|
||||
Flow::Cancel
|
||||
} else {
|
||||
Flow::Continue
|
||||
}
|
||||
}
|
||||
fn item_interact_mob(
|
||||
&self,
|
||||
host: &mut dyn Host,
|
||||
event: &mut ItemInteractMobEvent<'_>,
|
||||
) -> Option<bool> {
|
||||
let stack = event.stack.expect("only a nonempty item has this fallback");
|
||||
assert!(stack.count > 0);
|
||||
self.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((event.entity, event.kind.into(), stack.item.to_string()));
|
||||
let answer = self.answer.load(Ordering::Relaxed);
|
||||
if answer != 0 && self.discard.load(Ordering::Relaxed) {
|
||||
assert!(host.remove_mob("minecraft:overworld", event.entity as u32));
|
||||
}
|
||||
match answer {
|
||||
0 => None,
|
||||
1 => Some(false),
|
||||
_ => Some(true),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn setup(game: &mut Game<'_>, probe: &Probe, kind: &str, id: u32) {
|
||||
game.mobs.clear();
|
||||
let kind = game.res.mob_types().get(kind).unwrap().clone();
|
||||
game.mobs
|
||||
.push(Mob::new(id, kind, Vec3::new(0.5, 64.0, 2.5), 0.0));
|
||||
game.station = None;
|
||||
game.screen = Screen::Playing;
|
||||
game.inventory[0] = Some(ItemStack::new("minecraft:stick", 2));
|
||||
game.offhand = Some(ItemStack::new("minecraft:paper", 3));
|
||||
game.swing = Default::default();
|
||||
game.swings_started = 0;
|
||||
game.tick_hit = None;
|
||||
game.tick_mob = None;
|
||||
game.riding = None;
|
||||
probe.calls.lock().unwrap().clear();
|
||||
probe.answer.store(1, Ordering::Relaxed);
|
||||
probe.claim_entity.store(false, Ordering::Relaxed);
|
||||
probe.discard.store(false, Ordering::Relaxed);
|
||||
}
|
||||
fn target(id: i32) -> Option<InteractTarget> {
|
||||
Some(InteractTarget {
|
||||
id,
|
||||
location: [0.0, 1.0, 0.0],
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "private prepared resources; process-wide mods, run this test alone"]
|
||||
fn actual_item_interact_mob_fallback_priority_and_filters() {
|
||||
let root = PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
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 = execution_role::ExecutionRole::IntegratedServer;
|
||||
let probe = Arc::new(Probe::default());
|
||||
assert!(
|
||||
mod_api::MODS
|
||||
.set(Arc::from(vec![Box::new(probe.clone()) as Box<dyn Hooks>]))
|
||||
.is_ok()
|
||||
);
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.world = World::new(game.res.facts().air, 1);
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(0.5, 64.0, 0.5)).unwrap());
|
||||
game.yaw = 0.0;
|
||||
game.pitch = 0.0;
|
||||
game.selected = 0;
|
||||
game.set_mode_id(0);
|
||||
|
||||
// A custom item willing to own every mob still cannot preempt trading.
|
||||
setup(&mut game, &probe, "minecraft:villager", 42);
|
||||
game.mobs[0].extra.trade = Some(Box::new(Trader {
|
||||
offers: vec![MerchantOffer {
|
||||
base_cost_a: ItemCost {
|
||||
stack: ItemStack::new("minecraft:emerald", 1),
|
||||
},
|
||||
cost_b: None,
|
||||
result: ItemStack::new("minecraft:bread", 1),
|
||||
uses: 0,
|
||||
max_uses: 12,
|
||||
reward_exp: true,
|
||||
special_price_diff: 0,
|
||||
demand: 0,
|
||||
price_multiplier: 0.0,
|
||||
xp: 1,
|
||||
}],
|
||||
..Default::default()
|
||||
}));
|
||||
assert!(game.interact_entity(target(42)).unwrap());
|
||||
assert!(probe.calls.lock().unwrap().is_empty());
|
||||
assert!(!game.mobs[0].removed);
|
||||
assert_eq!(
|
||||
game.station
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.merchant
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.mob,
|
||||
Some(42)
|
||||
);
|
||||
|
||||
// Existing API 29 owns the earlier phase and retains its usual swing.
|
||||
setup(&mut game, &probe, "minecraft:zombie", 42);
|
||||
probe.claim_entity.store(true, Ordering::Relaxed);
|
||||
assert!(game.interact_entity(target(42)).unwrap());
|
||||
assert!(probe.calls.lock().unwrap().is_empty());
|
||||
assert_eq!(game.swings_started, 1);
|
||||
for (answer, consumed, swings) in [(0, false, 0), (1, true, 0), (2, true, 1)] {
|
||||
setup(&mut game, &probe, "minecraft:zombie", 42);
|
||||
probe.answer.store(answer, Ordering::Relaxed);
|
||||
assert_eq!(game.interact_entity(target(42)).unwrap(), consumed);
|
||||
assert_eq!(game.swings_started, swings);
|
||||
assert_eq!(
|
||||
*probe.calls.lock().unwrap(),
|
||||
[(42, "minecraft:zombie".into(), "minecraft:stick".into())]
|
||||
);
|
||||
}
|
||||
|
||||
// An ordinary mob with no own item action reaches the fallback exactly
|
||||
// once. The mod may discard it with no loot and end without an arm swing.
|
||||
setup(&mut game, &probe, "minecraft:blaze", 42);
|
||||
probe.discard.store(true, Ordering::Relaxed);
|
||||
assert!(game.interact_entity(None).unwrap());
|
||||
assert!(game.mobs[0].removed);
|
||||
assert!(game.dropped.is_empty());
|
||||
assert_eq!(game.swings_started, 0);
|
||||
assert_eq!(probe.calls.lock().unwrap().len(), 1);
|
||||
assert_eq!(game.inventory[0].as_ref().unwrap().count, 2);
|
||||
|
||||
// Panda's early false is still a normal pass into the outer fallback.
|
||||
setup(&mut game, &probe, "minecraft:panda", 42);
|
||||
assert!(game.interact_entity(target(42)).unwrap());
|
||||
assert_eq!(probe.calls.lock().unwrap().len(), 1);
|
||||
assert_eq!(game.swings_started, 0);
|
||||
|
||||
setup(&mut game, &probe, "minecraft:zombie", 42);
|
||||
assert!(
|
||||
game.with_hand(Hand::Off, |g| g.interact_entity(target(42)))
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(probe.calls.lock().unwrap()[0].2, "minecraft:paper");
|
||||
assert_eq!(game.hand, Hand::Main);
|
||||
assert_eq!(game.inventory[0].as_ref().unwrap().count, 2);
|
||||
assert_eq!(game.offhand.as_ref().unwrap().count, 3);
|
||||
assert_eq!(game.swings_started, 0);
|
||||
|
||||
for invalid in ["dead", "removed", "riding", "wrong_id", "empty", "zero"] {
|
||||
setup(&mut game, &probe, "minecraft:zombie", 42);
|
||||
match invalid {
|
||||
"dead" => game.mobs[0].health = 0.0,
|
||||
"removed" => game.mobs[0].removed = true,
|
||||
"riding" => game.riding = Some(42),
|
||||
"empty" => game.inventory[0] = None,
|
||||
"zero" => game.inventory[0].as_mut().unwrap().count = 0,
|
||||
_ => {}
|
||||
}
|
||||
assert!(
|
||||
!game
|
||||
.interact_entity(target(if invalid == "wrong_id" { 99 } else { 42 }))
|
||||
.unwrap(),
|
||||
"{invalid}"
|
||||
);
|
||||
assert!(probe.calls.lock().unwrap().is_empty(), "{invalid}");
|
||||
}
|
||||
setup(&mut game, &probe, "minecraft:zombie", 42);
|
||||
game.mobs[0].health = 0.0;
|
||||
assert!(!game.interact_entity(None).unwrap());
|
||||
assert!(probe.calls.lock().unwrap().is_empty());
|
||||
|
||||
// Local selection is occluded; a validated server packet keeps its exact
|
||||
// entity ID, independent of the acting player's camera and local ray.
|
||||
setup(&mut game, &probe, "minecraft:blaze", 42);
|
||||
let stone = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:stone")
|
||||
.unwrap();
|
||||
game.world.set(0, 65, 1, stone).unwrap();
|
||||
assert!(!game.interact_entity(None).unwrap());
|
||||
assert!(probe.calls.lock().unwrap().is_empty());
|
||||
assert!(game.interact_entity(target(42)).unwrap());
|
||||
assert_eq!(probe.calls.lock().unwrap().len(), 1);
|
||||
setup(&mut game, &probe, "minecraft:zombie", 42);
|
||||
game.set_mode_id(3);
|
||||
assert_eq!(game.use_item_as_player().unwrap(), None);
|
||||
assert!(probe.calls.lock().unwrap().is_empty());
|
||||
}
|
||||
@@ -12,6 +12,23 @@ use horizon_client::{
|
||||
};
|
||||
use horizon_world::consumable::UseAnimation;
|
||||
|
||||
/// Resolve USE_EFFECTS through the stack's component patch.
|
||||
pub(super) fn interact_vibrations(stack: &ItemStack, default: Option<bool>) -> bool {
|
||||
if stack.count == 0 {
|
||||
return false;
|
||||
}
|
||||
let Some(data) = stack.data() else {
|
||||
return default.unwrap_or(false);
|
||||
};
|
||||
if data.other("!minecraft:use_effects").is_some() {
|
||||
return false;
|
||||
}
|
||||
data.other("minecraft:use_effects").map_or_else(
|
||||
|| default.unwrap_or(false),
|
||||
|tag| horizon_client::save::number(tag.get("interact_vibrations")).unwrap_or(1) != 0,
|
||||
)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `LivingEntity.startUsingItem` for the acting player's hand: nothing
|
||||
/// when a use runs already or the hand is empty. The use lasts
|
||||
@@ -454,9 +471,25 @@ mod tests {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod interact_vibration_tests {
|
||||
pub(super) mod interact_vibration_tests {
|
||||
use super::*;
|
||||
|
||||
type RecordedEvent = (&'static str, [f64; 3], Option<horizon_client::mobs::Who>);
|
||||
thread_local! {
|
||||
static EVENTS: std::cell::RefCell<Vec<RecordedEvent>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
static RECORDING: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
pub(in crate::game) fn record_event(
|
||||
event: &'static str,
|
||||
pos: [f64; 3],
|
||||
source: Option<horizon_client::mobs::Who>,
|
||||
) {
|
||||
if RECORDING.get() {
|
||||
EVENTS.with(|events| events.borrow_mut().push((event, pos, source)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `f` on a windowless game built from the prepared resources with
|
||||
/// `held` in the main hand and returns the game events it posted;
|
||||
/// `None` without the resources.
|
||||
@@ -498,7 +531,9 @@ mod interact_vibration_tests {
|
||||
);
|
||||
*game.held_mut() = Some(ItemStack::new(held, 1));
|
||||
crate::game::dispense_fixture::start_recording();
|
||||
RECORDING.set(true);
|
||||
f(&mut game);
|
||||
RECORDING.set(false);
|
||||
Some(crate::game::dispense_fixture::stop_recording())
|
||||
}
|
||||
|
||||
@@ -535,4 +570,116 @@ mod interact_vibration_tests {
|
||||
};
|
||||
assert!(lines.is_empty(), "{lines:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stack_use_effects_patch_controls_vibrations() {
|
||||
let mut rod = ItemStack::new("minecraft:fishing_rod", 1);
|
||||
assert!(interact_vibrations(&rod, Some(true)));
|
||||
rod.data_mut().set_loot_component(
|
||||
"minecraft:use_effects",
|
||||
&serde_json::json!({"interact_vibrations": false}),
|
||||
);
|
||||
assert!(!interact_vibrations(&rod, Some(true)));
|
||||
rod.data_mut()
|
||||
.set_loot_component("minecraft:use_effects", &serde_json::json!({}));
|
||||
assert!(interact_vibrations(&rod, Some(false)));
|
||||
rod.data_mut().set_other("minecraft:use_effects", None);
|
||||
rod.data_mut().set_other(
|
||||
"!minecraft:use_effects",
|
||||
Some(horizon_world::nbt::Tag::Compound(Default::default())),
|
||||
);
|
||||
assert!(!interact_vibrations(&rod, Some(true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn fishing_cast_and_reel_post_player_vibrations() {
|
||||
use horizon_client::mobs::Who;
|
||||
let Some(_) = with_game("minecraft:fishing_rod", |game| {
|
||||
let rod = ItemStack::new("minecraft:fishing_rod", 1);
|
||||
EVENTS.with(|events| events.borrow_mut().clear());
|
||||
assert!(game.use_fishing_rod(&rod));
|
||||
assert!(game.use_fishing_rod(&rod));
|
||||
EVENTS.with(|events| {
|
||||
assert_eq!(
|
||||
*events.borrow(),
|
||||
vec![
|
||||
(
|
||||
"minecraft:item_interact_start",
|
||||
[10.5, 64.0, -3.25],
|
||||
Some(Who::Player)
|
||||
),
|
||||
(
|
||||
"minecraft:item_interact_finish",
|
||||
[10.5, 64.0, -3.25],
|
||||
Some(Who::Player)
|
||||
),
|
||||
]
|
||||
);
|
||||
});
|
||||
let mut silent = rod.clone();
|
||||
silent.data_mut().set_loot_component(
|
||||
"minecraft:use_effects",
|
||||
&serde_json::json!({"interact_vibrations": false}),
|
||||
);
|
||||
*game.held_mut() = Some(silent.clone());
|
||||
EVENTS.with(|events| events.borrow_mut().clear());
|
||||
assert!(game.use_fishing_rod(&silent));
|
||||
assert!(game.use_fishing_rod(&silent));
|
||||
EVENTS.with(|events| assert!(events.borrow().is_empty()));
|
||||
*game.held_mut() = Some(rod.clone());
|
||||
assert!(game.use_fishing_rod(&rod));
|
||||
game.hook
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.move_to([10.5, 64.0, -3.25], true);
|
||||
let max = game
|
||||
.items()
|
||||
.get("minecraft:fishing_rod")
|
||||
.unwrap()
|
||||
.max_damage;
|
||||
game.held_mut().as_mut().unwrap().damage = max - 1;
|
||||
EVENTS.with(|events| events.borrow_mut().clear());
|
||||
assert!(game.use_fishing_rod(&rod));
|
||||
EVENTS.with(|events| assert!(events.borrow().is_empty(), "{:?}", *events.borrow()));
|
||||
}) else {
|
||||
panic!("prepared resources failed to load")
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn thrown_eye_posts_projectile_shoot_at_eye_from_player() {
|
||||
use horizon_client::mobs::Who;
|
||||
let Some(_) = with_game("minecraft:ender_eye", |game| {
|
||||
let root =
|
||||
std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let terrain = horizon_client::vanillagen::VanillaTerrain::new(
|
||||
&root.join("worldgen"),
|
||||
game.pack.assets.registry,
|
||||
&game.res.core.biomes,
|
||||
1234,
|
||||
)
|
||||
.unwrap();
|
||||
game.end.set_locator(Some(std::sync::Arc::new(terrain)));
|
||||
EVENTS.with(|events| events.borrow_mut().clear());
|
||||
assert!(
|
||||
game.use_ender_eye(&ItemStack::new("minecraft:ender_eye", 1), false)
|
||||
.unwrap()
|
||||
);
|
||||
EVENTS.with(|events| {
|
||||
let events = events.borrow();
|
||||
assert!(
|
||||
events.contains(&(
|
||||
"minecraft:projectile_shoot",
|
||||
[10.5, 64.0 + f64::from(1.8_f32) * 0.5, -3.25],
|
||||
Some(Who::Player),
|
||||
)),
|
||||
"{events:?}"
|
||||
);
|
||||
});
|
||||
}) else {
|
||||
panic!("prepared resources failed to load")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
use super::{follow_keymap, follow_named_keymap};
|
||||
use horizon_client::{
|
||||
debug_keys::{DebugAction, DebugHost, DebugKeys, KeyAction, OpenScreen},
|
||||
keybinds::{self, KeyBinds},
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
use winit::{
|
||||
event::WindowEvent,
|
||||
keyboard::{Key, KeyCode, NamedKey, NativeKeyCode, PhysicalKey},
|
||||
};
|
||||
|
||||
// KeyEvent has private platform fields. Exercise the production mapping with its actual public
|
||||
// winit key fields, without creating a window or sending operating-system keyboard events.
|
||||
#[test]
|
||||
fn xkb_f3_reaches_debug_press_repeat_release_and_binding_capture() {
|
||||
struct Host(u32);
|
||||
impl DebugHost for Host {
|
||||
fn screen(&self) -> OpenScreen {
|
||||
OpenScreen::None
|
||||
}
|
||||
fn toggle_overlay(&mut self) {
|
||||
self.0 += 1;
|
||||
}
|
||||
fn pause_game(&mut self, _: bool) {
|
||||
panic!("F3 alone must not pause");
|
||||
}
|
||||
fn debug_action(&mut self, _: DebugAction, _: bool) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
let mut host = Host(0);
|
||||
let mut debug = DebugKeys::default();
|
||||
let binds = KeyBinds::default();
|
||||
let mut held = HashSet::new();
|
||||
for (action, overlays, modifier) in [
|
||||
(KeyAction::Press, 0, true),
|
||||
(KeyAction::Repeat, 0, true),
|
||||
(KeyAction::Release, 1, false),
|
||||
] {
|
||||
let mut physical = PhysicalKey::Code(KeyCode::BrowserRefresh);
|
||||
follow_named_keymap(&Key::Named(NamedKey::F3), &mut physical);
|
||||
assert_eq!(
|
||||
physical,
|
||||
PhysicalKey::Code(KeyCode::F3),
|
||||
"XKB's F3 must override the media-key position"
|
||||
);
|
||||
let PhysicalKey::Code(code) = physical else {
|
||||
panic!("resolved F3 is a known key")
|
||||
};
|
||||
let name = keybinds::key_name(code).unwrap();
|
||||
assert_eq!(
|
||||
name, "key.keyboard.f3",
|
||||
"the binding editor must receive F3 too"
|
||||
);
|
||||
if action == KeyAction::Release {
|
||||
held.remove(&code);
|
||||
} else {
|
||||
held.insert(code);
|
||||
}
|
||||
debug.key_press(&mut host, &binds, name, action, false, false, 0);
|
||||
assert_eq!(host.0, overlays, "repeats must not toggle the overlay");
|
||||
assert_eq!(debug.modifier_down, modifier);
|
||||
}
|
||||
assert!(
|
||||
held.is_empty(),
|
||||
"the remapped release must clear the held key"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapped_function_keys_keep_their_binding_names() {
|
||||
let keys = [
|
||||
NamedKey::F1,
|
||||
NamedKey::F2,
|
||||
NamedKey::F3,
|
||||
NamedKey::F4,
|
||||
NamedKey::F5,
|
||||
NamedKey::F6,
|
||||
NamedKey::F7,
|
||||
NamedKey::F8,
|
||||
NamedKey::F9,
|
||||
NamedKey::F10,
|
||||
NamedKey::F11,
|
||||
NamedKey::F12,
|
||||
NamedKey::F13,
|
||||
NamedKey::F14,
|
||||
NamedKey::F15,
|
||||
NamedKey::F16,
|
||||
NamedKey::F17,
|
||||
NamedKey::F18,
|
||||
NamedKey::F19,
|
||||
NamedKey::F20,
|
||||
NamedKey::F21,
|
||||
NamedKey::F22,
|
||||
NamedKey::F23,
|
||||
NamedKey::F24,
|
||||
];
|
||||
for (index, named) in keys.into_iter().enumerate() {
|
||||
let mut physical = PhysicalKey::Code(KeyCode::BrowserBack);
|
||||
follow_named_keymap(&Key::Named(named), &mut physical);
|
||||
let PhysicalKey::Code(code) = physical else {
|
||||
panic!("a function key has a code")
|
||||
};
|
||||
assert_eq!(
|
||||
keybinds::key_name(code),
|
||||
Some(format!("key.keyboard.f{}", index + 1).as_str())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_layout_keys_and_existing_escape_caps_remaps_are_preserved() {
|
||||
for (logical, before, expected) in [
|
||||
(Key::Character("z".into()), KeyCode::KeyW, KeyCode::KeyW),
|
||||
(Key::Character("F3".into()), KeyCode::KeyA, KeyCode::KeyA),
|
||||
(Key::Dead(Some('´')), KeyCode::Quote, KeyCode::Quote),
|
||||
(
|
||||
Key::Named(NamedKey::ArrowLeft),
|
||||
KeyCode::ArrowLeft,
|
||||
KeyCode::ArrowLeft,
|
||||
),
|
||||
(
|
||||
Key::Named(NamedKey::Escape),
|
||||
KeyCode::CapsLock,
|
||||
KeyCode::Escape,
|
||||
),
|
||||
(
|
||||
Key::Named(NamedKey::CapsLock),
|
||||
KeyCode::Escape,
|
||||
KeyCode::CapsLock,
|
||||
),
|
||||
] {
|
||||
let mut physical = PhysicalKey::Code(before);
|
||||
follow_named_keymap(&logical, &mut physical);
|
||||
assert_eq!(physical, PhysicalKey::Code(expected));
|
||||
}
|
||||
let mut unidentified = PhysicalKey::Unidentified(NativeKeyCode::Unidentified);
|
||||
follow_named_keymap(&Key::Character("é".into()), &mut unidentified);
|
||||
assert_eq!(
|
||||
unidentified,
|
||||
PhysicalKey::Unidentified(NativeKeyCode::Unidentified)
|
||||
);
|
||||
let mut focused = WindowEvent::Focused(false);
|
||||
follow_keymap(&mut focused);
|
||||
assert!(matches!(focused, WindowEvent::Focused(false)));
|
||||
}
|
||||
@@ -2318,6 +2318,9 @@ impl Game<'_> {
|
||||
host = self.lan.take().expect("the hub is back after the commands");
|
||||
}
|
||||
}
|
||||
HostEvent::Jump { conn, from, yaw } => {
|
||||
self.guest_jump(&mut host, conn, from, yaw);
|
||||
}
|
||||
}
|
||||
}
|
||||
note_event(&host, previous, &mut timings);
|
||||
@@ -3365,6 +3368,7 @@ fn event_kind(event: &HostEvent) -> EventKind {
|
||||
HostEvent::Joined(c) => ("join", Some(*c), None),
|
||||
HostEvent::Left(g) => ("leave", None, Some(g.name.clone())),
|
||||
HostEvent::Packet(c, p) => (p.name(), Some(*c), None),
|
||||
HostEvent::Jump { conn, .. } => ("jump", Some(*conn), None),
|
||||
HostEvent::MainHandChanged(c, _) => ("main hand", Some(*c), None),
|
||||
HostEvent::LocaleChanged(c, _) => ("locale", Some(*c), None),
|
||||
HostEvent::PlayerLoaded(c) => ("loaded", Some(*c), None),
|
||||
|
||||
@@ -197,7 +197,6 @@ pub(super) struct GuestSwap {
|
||||
left: bool,
|
||||
right: bool,
|
||||
riding: Option<u32>,
|
||||
host_boarding: (i32, Option<u32>),
|
||||
host_station: Option<super::stations::Station>,
|
||||
sleeping: Option<[i32; 3]>,
|
||||
sleep_timer: u32,
|
||||
@@ -572,11 +571,6 @@ impl Game<'_> {
|
||||
self.right = play.using.is_some() || self.using.is_some();
|
||||
// The guest's vehicle (Entity.vehicle) for its turn.
|
||||
let riding = std::mem::replace(&mut self.riding, g.vehicle);
|
||||
// The guest's boarding cooldown for its turn; `guest_server_tick`
|
||||
// counts it down once per tick (Entity.baseTick), not per turn.
|
||||
let host_boarding = (self.boarding_cooldown, self.riding_at_tick);
|
||||
self.boarding_cooldown = g.boarding_cooldown;
|
||||
self.riding_at_tick = g.vehicle;
|
||||
// `Entity.isPassenger()` for that turn, from the same vehicle: the
|
||||
// guest's body keeps no swim (`Entity.updateSwimming` needs it false).
|
||||
// Taken from `g.vehicle` each turn, so boarding and getting out land.
|
||||
@@ -692,7 +686,6 @@ impl Game<'_> {
|
||||
left,
|
||||
right,
|
||||
riding,
|
||||
host_boarding,
|
||||
host_station,
|
||||
sleeping,
|
||||
sleep_timer,
|
||||
@@ -794,7 +787,6 @@ impl Game<'_> {
|
||||
left,
|
||||
right,
|
||||
riding,
|
||||
host_boarding,
|
||||
host_station,
|
||||
sleeping,
|
||||
sleep_timer,
|
||||
@@ -975,11 +967,6 @@ impl Game<'_> {
|
||||
self.guest_attachments.entry(g.uuid).or_default(),
|
||||
);
|
||||
let left_vehicle = g.vehicle.filter(|_| guest_vehicle.is_none());
|
||||
// Entity.removePassenger: 60 ticks before it may board again.
|
||||
let guest_boarding =
|
||||
super::boarding::after_leaving(self.boarding_cooldown, g.vehicle, guest_vehicle);
|
||||
(self.boarding_cooldown, self.riding_at_tick) = host_boarding;
|
||||
g.boarding_cooldown = guest_boarding;
|
||||
if guest_vehicle.is_none() {
|
||||
g.vehicle_good = None;
|
||||
}
|
||||
@@ -2334,6 +2321,7 @@ impl Game<'_> {
|
||||
g.trigger_any_block_use(p.pos, &stack);
|
||||
});
|
||||
}
|
||||
reset_use_attack_strength(&mut g.attack_ticks, swings, g.swings_started);
|
||||
g.forced_hit = None;
|
||||
g.guest_use = None;
|
||||
(g.swings_started != swings, consumed, attempt)
|
||||
@@ -2404,6 +2392,7 @@ impl Game<'_> {
|
||||
g.guest_use = Some(GuestUse::Item(hand));
|
||||
let swings = g.swings_started;
|
||||
let _ = g.use_item();
|
||||
reset_use_attack_strength(&mut g.attack_ticks, swings, g.swings_started);
|
||||
g.guest_use = None;
|
||||
(g.swings_started != swings, horn)
|
||||
});
|
||||
@@ -2432,6 +2421,7 @@ impl Game<'_> {
|
||||
let swung = self.as_guest(host, conn, |g| {
|
||||
let swings = g.swings_started;
|
||||
g.with_hand(hand, |g| g.use_knot(knot, true));
|
||||
reset_use_attack_strength(&mut g.attack_ticks, swings, g.swings_started);
|
||||
g.swings_started != swings
|
||||
});
|
||||
self.lan_swing_back(host, conn, swung);
|
||||
@@ -2454,6 +2444,7 @@ impl Game<'_> {
|
||||
g.guest_use = Some(GuestUse::Entity(hand, target));
|
||||
let swings = g.swings_started;
|
||||
let _ = g.use_item();
|
||||
reset_use_attack_strength(&mut g.attack_ticks, swings, g.swings_started);
|
||||
g.guest_use = None;
|
||||
g.swings_started != swings
|
||||
});
|
||||
@@ -4111,6 +4102,16 @@ impl Game<'_> {
|
||||
let drops = (g.game_mode != SPECTATOR)
|
||||
.then(|| [at[0], at[1] + g.eye_height() - f64::from(0.3f32), at[2]]);
|
||||
let cause = host.play.get_mut(&conn).and_then(|p| p.death_cause.take());
|
||||
// ServerPlayer.die opens with `gameEvent(ENTITY_DIE)` at the player:
|
||||
// sculk sensors and wardens hear the death.
|
||||
if here {
|
||||
self.as_guest(host, conn, |game| {
|
||||
let ctx = game.player_context(None);
|
||||
game.post_game_event("minecraft:entity_die", at, ctx);
|
||||
});
|
||||
}
|
||||
// `getKillCredit`, as the death finds it.
|
||||
let killer = self.guest_kill_credit_type(host, conn);
|
||||
self.native_player_died(uuid, cause.as_deref());
|
||||
// ServerPlayer.die: the neutral mobs near a guest in this level
|
||||
// forget their anger at them.
|
||||
@@ -4183,14 +4184,18 @@ impl Game<'_> {
|
||||
if consumed && catalyst_xp > 0 {
|
||||
self.guest_catalyst_trigger(host, gi, conn, at, blow.clone());
|
||||
}
|
||||
// ServerPlayer.die: the drops, then the deathCount objectives, then
|
||||
// the killer's awardKillScore, then the `deaths` statistic and the
|
||||
// two timers it resets.
|
||||
// ServerPlayer.die: the drops, then the deathCount objectives, then,
|
||||
// with a kill credit, `killed_by` for the killer's type and the
|
||||
// killer's awardKillScore, then the `deaths` statistic and the two
|
||||
// timers it resets.
|
||||
self.as_guest(host, conn, |game| {
|
||||
if let Some(eyes) = drops {
|
||||
game.drop_inventory(eyes, (uuid, id));
|
||||
}
|
||||
game.score_player_died();
|
||||
if let Some(kind) = &killer {
|
||||
game.award_stat("killed_by", kind, 1);
|
||||
}
|
||||
});
|
||||
self.guest_killed_player_trigger(host, conn, at, blow);
|
||||
self.as_guest(host, conn, |game| {
|
||||
@@ -4302,6 +4307,38 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
|
||||
/// The type of the entity a dying guest's combat record credits with the
|
||||
/// kill (`LivingEntity.getKillCredit`): a player (the one in place or a
|
||||
/// guest), a mob (one gone already credits nobody, as vanilla's reference
|
||||
/// no longer resolves), a mob whose own blast this tick killed the guest,
|
||||
/// or the ender dragon.
|
||||
fn guest_kill_credit_type(&self, host: &LanHost, conn: u32) -> Option<String> {
|
||||
let id = self
|
||||
.guest_combat
|
||||
.get(&conn)?
|
||||
.kill_credit(self.world.game_time)?;
|
||||
let in_place = self.lan_acting.is_none()
|
||||
&& self
|
||||
.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.entity_id as u32 == id);
|
||||
if in_place || host.hub.guests.iter().any(|g| g.entity_id as u32 == id) {
|
||||
return Some("minecraft:player".into());
|
||||
}
|
||||
if let Some(m) = self.mobs.iter().find(|m| m.id == id) {
|
||||
return Some(m.kind.id.clone());
|
||||
}
|
||||
// A mob that blew up this tick has left the list, but vanilla's player
|
||||
// dies inside that blast, before the source is discarded.
|
||||
if let Some((_, kind)) = self.combat.exploded_kinds.iter().rfind(|(m, _)| *m == id) {
|
||||
return Some(kind.clone());
|
||||
}
|
||||
self.dragon
|
||||
.as_ref()
|
||||
.filter(|d| d.id == id)
|
||||
.map(|_| "minecraft:ender_dragon".into())
|
||||
}
|
||||
|
||||
/// The dying guest's last blow (`LivingEntity.getLastDamageSource`), its
|
||||
/// damage type and causing entity as the combat record held them, as a
|
||||
/// damage source: the projectile as the direct entity.
|
||||
@@ -4588,10 +4625,43 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `handlePlayerPositionChange` jumps from the pre-move position and yaw.
|
||||
/// A later attack packet reads the resulting velocity even before travel.
|
||||
pub(super) fn guest_jump(&mut self, host: &mut LanHost, conn: u32, from: [f64; 3], yaw: f32) {
|
||||
let identity = if horizon_client::mod_api::mods().is_empty() {
|
||||
None
|
||||
} else {
|
||||
host.hub.guest(conn).map(|g| (g.uuid, g.name.clone()))
|
||||
};
|
||||
self.as_guest(host, conn, |game| {
|
||||
let Some(player) = game.player.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let (position, yaw_after, ground) =
|
||||
(player.position, player.yaw, player.ground.on_ground);
|
||||
player.position = Vec3::new(from[0], from[1], from[2]);
|
||||
player.yaw = yaw;
|
||||
player.ground.on_ground = true;
|
||||
let mut w = MotionWorld {
|
||||
entities: &[],
|
||||
player,
|
||||
scene: &mut game.world,
|
||||
assets: &game.pack.assets,
|
||||
collision_binding: game.collision,
|
||||
resources: game.motion,
|
||||
health: &game.health_resources,
|
||||
};
|
||||
let _ = w.jump_from_ground();
|
||||
(w.player.position, w.player.yaw, w.player.ground.on_ground) =
|
||||
(position, yaw_after, ground);
|
||||
if let Some((uuid, name)) = identity {
|
||||
game.native_player_jump(uuid, &name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The guest's body on the server each tick: ServerPlayer.tick's damage
|
||||
/// clock and Entity.baseTick's fluid phase (burning), then for each move
|
||||
/// the guest made ServerGamePacketListenerImpl.handleMovePlayer's jump
|
||||
/// (`ServerPlayer.jumpFromGround`'s exhaustion),
|
||||
/// ServerPlayer.doCheckFallDamage (the fall distance grows while they go
|
||||
/// down and hurts them where they land, as the host's own landing does;
|
||||
/// water resets it, lava halves it) and checkMovementStatistics (the
|
||||
@@ -4600,10 +4670,6 @@ impl Game<'_> {
|
||||
/// regeneration and the food tick (ServerPlayer.doTick). Their hurt
|
||||
/// sounds are heard at the guest.
|
||||
fn guest_server_tick(&mut self, host: &mut LanHost, conn: u32) {
|
||||
// Entity.baseTick: the boarding cooldown counts down once a tick.
|
||||
if let Some(g) = host.hub.guest_mut(conn) {
|
||||
g.boarding_cooldown = super::boarding::tick_down(g.boarding_cooldown);
|
||||
}
|
||||
self.guest_replay(host, conn);
|
||||
self.guest_vehicle_tick(host, conn);
|
||||
// The last move's fall stands in for the server's delta movement.
|
||||
@@ -4641,7 +4707,6 @@ impl Game<'_> {
|
||||
self.as_guest(host, conn, |game| game.server_spin_attack());
|
||||
}
|
||||
let mut soul = None;
|
||||
let mut jumps = 0;
|
||||
let events = self.as_guest(host, conn, |game| {
|
||||
let rules = game.world.health_rules;
|
||||
let freeze_immune = game.wears_freeze_immune();
|
||||
@@ -4698,13 +4763,6 @@ impl Game<'_> {
|
||||
}
|
||||
for (moved, on_ground) in moves {
|
||||
let movement = Vec3::new(moved[0], moved[1], moved[2]);
|
||||
if w.player.ground.on_ground && !on_ground && moved[1] > 0.0 {
|
||||
// ServerGamePacketListenerImpl.handlePlayerPositionChange:
|
||||
// the server's copy jumps with the player, so the velocity
|
||||
// it sends them keeps the jump.
|
||||
jumps += 1;
|
||||
let _ = w.jump_from_ground();
|
||||
}
|
||||
let landed = (|| -> horizon_client::Result<()> {
|
||||
w.set_on_ground_with_movement(on_ground, false, movement)?;
|
||||
let pos = w.legacy_on_position()?;
|
||||
@@ -4827,16 +4885,6 @@ impl Game<'_> {
|
||||
let Some((events, marked)) = events else {
|
||||
return;
|
||||
};
|
||||
if jumps > 0
|
||||
&& !horizon_client::mod_api::mods().is_empty()
|
||||
&& let Some((uuid, name)) = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone()))
|
||||
{
|
||||
self.as_guest(host, conn, |game| {
|
||||
for _ in 0..jumps {
|
||||
game.native_player_jump(uuid, &name);
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some((at, velocity)) = soul {
|
||||
super::lan_particles::send_soul_particle(host, at, velocity);
|
||||
}
|
||||
@@ -5752,11 +5800,30 @@ fn stop_dig(play: &GuestPlay, pos: [i32; 3], air: bool, ticks: u64, per_tick: f3
|
||||
}
|
||||
}
|
||||
|
||||
/// ServerPlayer.swingAndResetAttackStrength resets only when a use starts a swing.
|
||||
fn reset_use_attack_strength(ticker: &mut u32, before: u32, after: u32) {
|
||||
if before != after {
|
||||
*ticker = 0;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::eye_columns;
|
||||
use super::{eye_columns, reset_use_attack_strength};
|
||||
use horizon_client::pose::Pose;
|
||||
|
||||
#[test]
|
||||
fn use_swing_resets_attack_charge_only_when_started() {
|
||||
let mut ticker = 20;
|
||||
reset_use_attack_strength(&mut ticker, 3, 4);
|
||||
// A sword's attack speed is 1.6: vanilla's (ticker + 0.5) / 12.5.
|
||||
assert_eq!((ticker as f32 + 0.5) / 12.5, 0.04);
|
||||
|
||||
ticker = 8;
|
||||
reset_use_attack_strength(&mut ticker, 4, 4);
|
||||
assert_eq!(ticker, 8); // No swing, or a swing rejected in its first half.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_break_suppresses_its_positional_duplicate_but_not_an_entity_sound() {
|
||||
let mut sound = horizon_client::audio::CapturedSound {
|
||||
|
||||
@@ -43,7 +43,7 @@ pub(super) fn player_for(
|
||||
// An iron golem's anger is for the player who hit it (`NeutralMob` keeps
|
||||
// the angry-at UUID), and outlasts the 100 ticks of `getLastHurtByMob`:
|
||||
// while it lasts, the golem keeps that player, not whoever is nearest.
|
||||
let angry = m.species() == Species::IronGolem && m.extra.anger > 0;
|
||||
let angry = m.species() == Species::IronGolem && m.extra.angry_at == Some(Who::Player);
|
||||
let targeting = m.ai.target == Some(Who::Player)
|
||||
|| angry
|
||||
|| m.ai.last_hurt_by == Some(Who::Player) && m.age as i32 - m.ai.last_hurt_by_time <= 100;
|
||||
@@ -135,7 +135,7 @@ pub(super) fn acts_on_player(e: &MobEvent) -> bool {
|
||||
target: Who::Player,
|
||||
..
|
||||
}
|
||||
| MobEvent::Launch {
|
||||
| MobEvent::IronGolemHit {
|
||||
target: Who::Player,
|
||||
..
|
||||
}
|
||||
@@ -570,11 +570,13 @@ mod tests {
|
||||
golem.ai.last_hurt_by_time = 100;
|
||||
golem.ai.target = None;
|
||||
golem.extra.anger = 300;
|
||||
golem.extra.angry_at = Some(Who::Player);
|
||||
// The attacker (conn 7) ran off; a bystander (conn 8) is nearer.
|
||||
let guests = vec![(7, view(30.0, false)), (8, view(3.0, false))];
|
||||
assert_eq!(player_for(&golem, None, &guests, Some(7)), Some(7));
|
||||
// Calm again: the nearest player is seen.
|
||||
// Calm again (`stopBeingAngry`): the nearest player is seen.
|
||||
golem.extra.anger = 0;
|
||||
golem.extra.angry_at = None;
|
||||
assert_eq!(player_for(&golem, None, &guests, Some(7)), Some(8));
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use horizon_client::{
|
||||
containers::BlockEntity,
|
||||
items::ItemStack,
|
||||
menu::{AnvilCraft, Kind, Menu},
|
||||
stations::AnvilResult,
|
||||
};
|
||||
use horizon_core::random::RandomSource;
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
@@ -136,8 +137,14 @@ impl Game<'_> {
|
||||
};
|
||||
let before = result_slot.map(|r| {
|
||||
let slots: Vec<_> = (0..=r).map(|s| gs.menu.slot(s).cloned()).collect();
|
||||
// Only the anvil has a data slot (its cost); grindstone and smithing menus have none.
|
||||
(slots, gs.menu.data.first().copied())
|
||||
// Only the anvil has a data slot (its cost) and a name; grindstone and
|
||||
// smithing menus have neither. A rename runs createResult even when the
|
||||
// result comes out the same (AnvilMenu.setItemName).
|
||||
(
|
||||
slots,
|
||||
gs.menu.data.first().copied(),
|
||||
gs.menu.anvil.name.clone(),
|
||||
)
|
||||
});
|
||||
let mut undo = None;
|
||||
let hook = gs.menu.anvil_hook;
|
||||
@@ -168,8 +175,9 @@ impl Game<'_> {
|
||||
}
|
||||
// A cancelled take runs no createResult: the inputs are untouched.
|
||||
let recomputed = !cancelled
|
||||
&& before.is_some_and(|(slots, cost)| {
|
||||
&& before.is_some_and(|(slots, cost, item_name)| {
|
||||
cost != gs.menu.data.first().copied()
|
||||
|| item_name != gs.menu.anvil.name
|
||||
|| slots
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -179,6 +187,21 @@ impl Game<'_> {
|
||||
let guest = &host.hub.guests[gi];
|
||||
let (uuid, name) = (guest.uuid, guest.name.clone());
|
||||
let mut result = gs.menu.slot(2).cloned();
|
||||
// NeoForge's AnvilUpdateEvent where createResult ends, before Bukkit's
|
||||
// PrepareAnvilEvent: it is asked only while the left slot holds an item.
|
||||
if let Some(left) = gs.menu.slot(0) {
|
||||
let mut answer = AnvilResult {
|
||||
result,
|
||||
cost: gs.menu.data[0],
|
||||
repair_item_count_cost: gs.menu.anvil.repair_count,
|
||||
only_renaming: gs.menu.anvil.only_renaming,
|
||||
};
|
||||
let (right, item_name) = (gs.menu.slot(1), gs.menu.anvil.name.as_deref());
|
||||
self.anvil_update(uuid, &name, left, right, item_name, &mut answer);
|
||||
result = answer.result;
|
||||
gs.menu.data[0] = answer.cost;
|
||||
gs.menu.anvil.repair_count = answer.repair_item_count_cost;
|
||||
}
|
||||
self.native_prepare_anvil(uuid, &name, gs.pos, &mut result, &mut gs.menu.data[0]);
|
||||
let env = self.menu_env();
|
||||
gs.menu.set_slot(&env, 2, result);
|
||||
|
||||
Loaded 100 of 373 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user