mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-11 17:19:31 -10:00
Compare commits
289
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae0c0273f5 | ||
|
|
ff0dbe7864 | ||
|
|
e4042d5a2e | ||
|
|
9fe0fd9cc7 | ||
|
|
8ae679f4e1 | ||
|
|
c493a84388 | ||
|
|
d1255f85ab | ||
|
|
f176986623 | ||
|
|
f0832b0998 | ||
|
|
55477b7c27 | ||
|
|
947c085c0f | ||
|
|
9b49d846f0 | ||
|
|
e4822fe906 | ||
|
|
24a2bf3900 | ||
|
|
b2a2758162 | ||
|
|
6757c5ed35 | ||
|
|
f57714de73 | ||
|
|
7b02095700 | ||
|
|
78249e7c9e | ||
|
|
1e10e533cb | ||
|
|
a2d3ac8b64 | ||
|
|
8e26a88f53 | ||
|
|
6b704ba6f3 | ||
|
|
f8f2571c0b | ||
|
|
9c08a53ebf | ||
|
|
083321a7fd | ||
|
|
bbe8dfd73f | ||
|
|
0b4c9b627e | ||
|
|
1a42c8bf10 | ||
|
|
b2ae481dee | ||
|
|
6f3ec8b315 | ||
|
|
f7c43dd03b | ||
|
|
8ba548a33d | ||
|
|
07257993e6 | ||
|
|
bda383e91b | ||
|
|
b78c1e633a | ||
|
|
117db61043 | ||
|
|
b32ba7fa20 | ||
|
|
95a330832d | ||
|
|
eb0b785ec8 | ||
|
|
0742928943 | ||
|
|
692a4d8059 | ||
|
|
ceff58d796 | ||
|
|
31915393c1 | ||
|
|
b36e25ab16 | ||
|
|
e86000f6eb | ||
|
|
1bd446f250 | ||
|
|
08be24761f | ||
|
|
ccbb5f85bc | ||
|
|
e705c9175d | ||
|
|
2d6af5f5ea | ||
|
|
6cc36c6f3a | ||
|
|
60f0ab264d | ||
|
|
29f46b1320 | ||
|
|
7a756c4b47 | ||
|
|
b3e977c932 | ||
|
|
7b3c73f790 | ||
|
|
d740d99388 | ||
|
|
915337910a | ||
|
|
cf1f44ac13 | ||
|
|
8ea9f4a20f | ||
|
|
94233f3ce1 | ||
|
|
8778015009 | ||
|
|
114e8cad52 | ||
|
|
c63152903d | ||
|
|
35b1b3358d | ||
|
|
33859e433d | ||
|
|
b5bba8a620 | ||
|
|
ca7044d165 | ||
|
|
93beed5df7 | ||
|
|
7c61110eee | ||
|
|
edd751003e | ||
|
|
571e833b48 | ||
|
|
c8e1eb6619 | ||
|
|
31578f0509 | ||
|
|
12ddb73e82 | ||
|
|
d0f5bccf60 | ||
|
|
a5df8273bb | ||
|
|
a0bede5135 | ||
|
|
470578dd2e | ||
|
|
a5fce8fada | ||
|
|
f9a645f1ec | ||
|
|
a93fe71b74 | ||
|
|
203baeb0bd | ||
|
|
307029382a | ||
|
|
098012518a | ||
|
|
a21d37c81d | ||
|
|
2c1584e88c | ||
|
|
c7d7576e77 | ||
|
|
37bee174e9 | ||
|
|
34afc9aced | ||
|
|
1f31436c29 | ||
|
|
99d9a0b931 | ||
|
|
3eeeb544f0 | ||
|
|
d5157fe7d6 | ||
|
|
ba299deb2d | ||
|
|
dfd289adfc | ||
|
|
4e03ad84b9 | ||
|
|
969effa7e0 | ||
|
|
258ff26bd6 | ||
|
|
f85370184c | ||
|
|
b58389b53c | ||
|
|
bd20630159 | ||
|
|
a0118a4bfc | ||
|
|
61bd1fcfe4 | ||
|
|
d77c870a98 | ||
|
|
3fdf92d7f4 | ||
|
|
1d37d845bd | ||
|
|
9df2ce4dc1 | ||
|
|
6f509879fd | ||
|
|
8246b4ac62 | ||
|
|
1fbb508e06 | ||
|
|
e317cb70da | ||
|
|
8bbb380e2a | ||
|
|
3ffd798109 | ||
|
|
99e14c6d02 | ||
|
|
18ceb494e9 | ||
|
|
5a52b1c13c | ||
|
|
b745f39a11 | ||
|
|
871dc11a41 | ||
|
|
654cefca28 | ||
|
|
4b430232ed | ||
|
|
deb0b34fcb | ||
|
|
67063cfdcc | ||
|
|
48f231af5d | ||
|
|
60c4d1d4f4 | ||
|
|
83a152c98a | ||
|
|
a09d036f50 | ||
|
|
482c381f6b | ||
|
|
95ff1c5769 | ||
|
|
f46f2dbc98 | ||
|
|
982c71f54d | ||
|
|
af65ddae12 | ||
|
|
23d4652362 | ||
|
|
765ff1ed80 | ||
|
|
6b2d1cd585 | ||
|
|
dc35fac460 | ||
|
|
f7224c29ee | ||
|
|
978f37d092 | ||
|
|
87d97ec860 | ||
|
|
7117330d80 | ||
|
|
77303a4d58 | ||
|
|
7da7209026 | ||
|
|
4cb7f5a348 | ||
|
|
ccf20df11d | ||
|
|
bbdbf1a285 | ||
|
|
06d1d236bb | ||
|
|
c8370400c6 | ||
|
|
289aba5aa5 | ||
|
|
aa8769a5ac | ||
|
|
84c2f81616 | ||
|
|
90562aede1 | ||
|
|
54ef9e095a | ||
|
|
f852a2d582 | ||
|
|
7f625eeb61 | ||
|
|
bc01321fa4 | ||
|
|
4c68279066 | ||
|
|
424af5fc17 | ||
|
|
7d2ecff35c | ||
|
|
e4920df1c1 | ||
|
|
47312851b0 | ||
|
|
f48c714adc | ||
|
|
6be3db13d7 | ||
|
|
e3e8b4c973 | ||
|
|
f3946c6bbb | ||
|
|
e690aec116 | ||
|
|
9823bf6c48 | ||
|
|
47f735a888 | ||
|
|
30ae132596 | ||
|
|
0cec8f447b | ||
|
|
e559e8646f | ||
|
|
c48801cb57 | ||
|
|
200a255241 | ||
|
|
0f6a5b1b58 | ||
|
|
e9c6b85aa8 | ||
|
|
c01e5ea7bd | ||
|
|
eb56315c76 | ||
|
|
12782bd26c | ||
|
|
28c51ac694 | ||
|
|
3df28380e8 | ||
|
|
00c3584214 | ||
|
|
f288262d66 | ||
|
|
5a75c90dbc | ||
|
|
8918f22af2 | ||
|
|
c8f98e4a8c | ||
|
|
1e12fc40bc | ||
|
|
26c04d14c3 | ||
|
|
a6deb3e7de | ||
|
|
63976a33d7 | ||
|
|
aed6fcaad9 | ||
|
|
f09967622c | ||
|
|
f561a0f625 | ||
|
|
d0321de02b | ||
|
|
1a2d9072d6 | ||
|
|
e01e03f4db | ||
|
|
efd000579d | ||
|
|
f4c4b3c8bd | ||
|
|
9c99f95069 | ||
|
|
f2708c690f | ||
|
|
62f7e8ef03 | ||
|
|
f5bad28164 | ||
|
|
071a5d1d3a | ||
|
|
b689e1dfa2 | ||
|
|
8204825b4e | ||
|
|
680e91bdf9 | ||
|
|
a2864d9edd | ||
|
|
c0998126c0 | ||
|
|
a54d9f825a | ||
|
|
d7107e8f1b | ||
|
|
4caa4a90c3 | ||
|
|
0ce71c6934 | ||
|
|
e7499779c8 | ||
|
|
c1503f7ada | ||
|
|
4f76f9b35d | ||
|
|
bd8ae9f1ed | ||
|
|
95848131df | ||
|
|
f0a379cbc1 | ||
|
|
90187f9942 | ||
|
|
57bf54407a | ||
|
|
5666c93e13 | ||
|
|
a1bc8fc608 | ||
|
|
ee581f9834 | ||
|
|
4919ef98dd | ||
|
|
e723d9ea15 | ||
|
|
822fbc2af9 | ||
|
|
9c31a83fed | ||
|
|
59d9424230 | ||
|
|
0e9f555a5b | ||
|
|
6e036e8918 | ||
|
|
a97cefe7f7 | ||
|
|
15dc5e301c | ||
|
|
b02f367216 | ||
|
|
29bfd0e362 | ||
|
|
d07042696f | ||
|
|
57b2669f0a | ||
|
|
f9c3e85868 | ||
|
|
b555275591 | ||
|
|
d9ed797708 | ||
|
|
c45072ca0e | ||
|
|
14ac9b88e5 | ||
|
|
452d4be9dc | ||
|
|
c08c3e765c | ||
|
|
d386833f8b | ||
|
|
32621f2275 | ||
|
|
b6172e204f | ||
|
|
b642094bb6 | ||
|
|
e2cb60d768 | ||
|
|
fc3ac6a620 | ||
|
|
800b42b719 | ||
|
|
9ee7b165cd | ||
|
|
c3121e9a5a | ||
|
|
9c0c8ed4f4 | ||
|
|
5a452cc97f | ||
|
|
37bbe0bdea | ||
|
|
fa19462873 | ||
|
|
4976814641 | ||
|
|
f2b0c69f72 | ||
|
|
6d0d16d48b | ||
|
|
7b73baf589 | ||
|
|
fd7b18c787 | ||
|
|
5c7ea99917 | ||
|
|
9f6b9e408d | ||
|
|
a81a3151c3 | ||
|
|
bc9c686b25 | ||
|
|
4787069c5b | ||
|
|
fa250c3c93 | ||
|
|
6a31935f68 | ||
|
|
4694211e8d | ||
|
|
0ff4404c9e | ||
|
|
b50a445987 | ||
|
|
16d4b2c348 | ||
|
|
d702f7cf60 | ||
|
|
f6a6dcc007 | ||
|
|
3df13bccbd | ||
|
|
851627a847 | ||
|
|
0a36da7779 | ||
|
|
f8599e2685 | ||
|
|
c80f0361be | ||
|
|
c08d2b301e | ||
|
|
e31eeeb2bc | ||
|
|
08d02c752f | ||
|
|
31edfa94fb | ||
|
|
3a35efafa8 | ||
|
|
916b481669 | ||
|
|
8db4b7bc14 | ||
|
|
b5fd8221fc | ||
|
|
2ba5206ba9 | ||
|
|
65b8c76509 | ||
|
|
412e1a72c9 |
No files matched your search
@@ -22,13 +22,6 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
|
||||
is the GitHub account the PR is opened from (`gh api user --jq .login`). Issues say "Filed by", reviews
|
||||
"Review by". Every comment starts with `🤖 Agent:`. Write only as the person you work for: the lines on
|
||||
this page that name Ludovic are for his own agents (account `ludovic111`), never for anyone else's.
|
||||
- **Review only when asked, and decide.** Review a pull request only when its author or a maintainer asks.
|
||||
Asked by a maintainer to review open pull requests in general, take them one at a time, oldest first, skipping
|
||||
drafts, your person's own and those they already reviewed. Check that the change does what it says (bugs, edge
|
||||
cases), what it could break, whether tests cover it, and whether its scope and description hold up. Report only
|
||||
what you can point to with a `file:line`, each point marked Blocker (fix before merge), Important, Nit,
|
||||
Suggestion, Question or Praise. Submit a decision, never a plain comment: `gh pr review --approve` when nothing
|
||||
blocks, `--request-changes` when something does.
|
||||
- **Attribution.** Commits end with a `Co-Authored-By:` line for the agent that wrote them, and PR bodies
|
||||
with that agent's own "Generated with" line. Example, Ludovic's agents (Claude Code on `ludovic111`):
|
||||
first line `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, commits
|
||||
@@ -135,8 +128,8 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
|
||||
## Build and test fast
|
||||
|
||||
```sh
|
||||
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
|
||||
python3 tools/pr_ready.py --quick # same without harnesses
|
||||
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests; paste its block in the PR
|
||||
python3 tools/pr_ready.py --harnesses # also the affected harnesses (by default only when you change a harness, oracle, probe or the runner, as in CI)
|
||||
cargo test -p horizon-client -p horizon-client-lib --lib --bins --tests # skips the examples (pr_ready does this); `--doc` separately
|
||||
cargo test -p horizon-client-lib --lib mod_content::tests:: -- --ignored # the slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
|
||||
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
|
||||
@@ -145,7 +138,8 @@ python3 tools/parity.py --changed-files paths.txt --list # which harnesses cov
|
||||
python3 tools/parity.py mob_ai sculk # named harnesses
|
||||
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host)
|
||||
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
python3 tools/lang_coverage.py --summary # the keys each language still misses, and its English leftovers
|
||||
python3 tools/lang_coverage.py --summary # one line per language: how many keys it carries and what it misses
|
||||
python3 tools/lang_coverage.py --summary --show-keys # the same, then the keys behind the counts
|
||||
```
|
||||
|
||||
- Harnesses and the game need the vanilla data in `.local/` (`minecraft/`, `horizon-client/26.3`, `datagen/`,
|
||||
|
||||
+4
-3
@@ -109,9 +109,10 @@ python3 tools/pr_ready.py --base upstream/main # or origin/main if you work on
|
||||
```
|
||||
|
||||
It looks at what your branch changes and runs what matters: rustfmt, clippy with warnings denied, the tests of the
|
||||
crates you touched (and the launcher's), generated files, `Cargo.lock`, the Python tests, and the vanilla harnesses
|
||||
your change can affect. Without the vanilla data on your machine it lists those harnesses instead; CI runs them.
|
||||
`--quick` skips the harnesses. It exits non-zero when something fails.
|
||||
crates you touched (and the launcher's), generated files, `Cargo.lock` and the Python tests. Like CI, it runs the
|
||||
vanilla harnesses only when your branch changes a harness, an oracle, a probe or the runner; otherwise it lists the
|
||||
ones your change can affect, and main's parity run checks them after the merge. `--harnesses` runs them on your
|
||||
machine anyway (it needs the vanilla data). It exits non-zero when something fails.
|
||||
|
||||
For the game workspace, Clippy checks all targets of the touched crates and their transitive workspace dependents,
|
||||
including examples. The dependency scope includes optional, platform, build and development dependencies. Tests run
|
||||
|
||||
@@ -271,15 +271,31 @@ fn int_array(tag: Option<&Tag>) -> Option<&Vec<i32>> {
|
||||
fn is_pos(tag: &Tag) -> bool {
|
||||
matches!(tag, Tag::IntArray(v) if v.len() == 3)
|
||||
}
|
||||
/// The default `SignText`: four empty lines, black, not glowing.
|
||||
fn empty_sign_side() -> Tag {
|
||||
Tag::compound([
|
||||
("messages", Tag::List(vec![string(""); 4])),
|
||||
("color", string("black")),
|
||||
("has_glowing_text", boolean(false)),
|
||||
])
|
||||
}
|
||||
/// The loaded `SignText` of one side, or the empty text.
|
||||
pub fn sign_side(tag: Option<&Tag>) -> Tag {
|
||||
let empty = || string("");
|
||||
let mut messages: Vec<Tag> = match tag.and_then(|t| t.get("messages")) {
|
||||
Some(Tag::List(list)) => list.iter().filter_map(component).collect(),
|
||||
_ => Vec::new(),
|
||||
// `SignText.DIRECT_CODEC`: `messages` is a component list (an entry that
|
||||
// fails is dropped, `ListCodec`'s partial result) cut to 4 lines by
|
||||
// `Util.fixedSize`. Fewer than 4 valid lines fail the side, and
|
||||
// `SignBlockEntity.loadAdditional` then loads an empty one; more keep the
|
||||
// first 4 with the side's colour and glow.
|
||||
let lines: Option<Vec<Tag>> = match tag.and_then(|t| t.get("messages")) {
|
||||
Some(Tag::List(list)) => {
|
||||
let lines: Vec<Tag> = list.iter().filter_map(component).take(4).collect();
|
||||
(lines.len() == 4).then_some(lines)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let Some(messages) = lines else {
|
||||
return empty_sign_side();
|
||||
};
|
||||
messages.resize_with(4, empty);
|
||||
messages.truncate(4);
|
||||
let mut out = vec![
|
||||
("messages", Tag::List(messages)),
|
||||
(
|
||||
@@ -957,6 +973,32 @@ mod tests {
|
||||
assert_eq!(structure.get("showboundingbox"), Some(&Tag::Byte(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_side_needs_exactly_four_messages() {
|
||||
let ok = snbt("{messages:[\"a\",\"\",\"\",\"\"],color:\"red\",has_glowing_text:1b}");
|
||||
let side = sign_side(Some(&ok));
|
||||
assert_eq!(side.get("color"), Some(&string("red")));
|
||||
// A fifth line, or a bad entry among five, leaves the first 4 valid
|
||||
// lines with the colour (checked against the 26.3 codec).
|
||||
for kept in [
|
||||
"{messages:[\"a\",\"\",\"\",\"\",\"e\"],color:\"red\",has_glowing_text:1b}",
|
||||
"{messages:[\"a\",1,\"\",\"\",\"\"],color:\"red\",has_glowing_text:1b}",
|
||||
] {
|
||||
let side = sign_side(Some(&snbt(kept)));
|
||||
assert_eq!(side.get("messages"), ok.get("messages"), "{kept}");
|
||||
assert_eq!(side.get("color"), Some(&string("red")), "{kept}");
|
||||
}
|
||||
for bad in [
|
||||
"{color:\"red\",has_glowing_text:1b}",
|
||||
"{messages:[\"a\",\"b\"],color:\"red\",has_glowing_text:1b}",
|
||||
"{messages:[\"a\",\"\",\"\",1],color:\"red\",has_glowing_text:1b}",
|
||||
] {
|
||||
let side = sign_side(Some(&snbt(bad)));
|
||||
assert_eq!(side, sign_side(None), "{bad}");
|
||||
assert_eq!(side.get("color"), Some(&string("black")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_is_idempotent() {
|
||||
for id in TYPES.iter().chain(NEWEST.iter()) {
|
||||
|
||||
@@ -356,6 +356,12 @@ impl KeyBinds {
|
||||
pub fn is_default(&self, name: &str) -> bool {
|
||||
self.get(name) == Self::default_of(name)
|
||||
}
|
||||
/// `KeyBindsScreen.extractRenderState`: Reset All is active only while a
|
||||
/// mapping differs from its default (`KeyMapping.isDefault`, the bound key
|
||||
/// against `defaultKey`).
|
||||
pub fn changed_from_defaults(&self) -> bool {
|
||||
self.keys.keys().any(|name| !self.is_default(name))
|
||||
}
|
||||
pub fn set(&mut self, name: &str, key: &str) {
|
||||
if let Some((n, _, _)) = all_mappings().find(|m| m.0 == name) {
|
||||
self.keys.insert(n, key.to_owned());
|
||||
@@ -645,6 +651,22 @@ mod tests {
|
||||
assert_eq!(k.translate_all(KeyCode::Digit9), [Ok(KeyCode::Digit9)]);
|
||||
assert_eq!(k.translate_all(KeyCode::F3), [Ok(KeyCode::F3)]);
|
||||
}
|
||||
|
||||
/// `KeyBindsScreen.extractRenderState`: Reset All is active only while a
|
||||
/// binding differs from its default.
|
||||
#[test]
|
||||
fn reset_all_is_active_only_after_a_binding_moves() {
|
||||
let mut keys = KeyBinds::default();
|
||||
assert!(!keys.changed_from_defaults());
|
||||
keys.set("key.attack", "key.mouse.right");
|
||||
assert!(keys.changed_from_defaults());
|
||||
// Setting a mapping back to its own default leaves nothing to reset.
|
||||
keys.set("key.attack", KeyBinds::default_of("key.attack"));
|
||||
assert!(!keys.changed_from_defaults());
|
||||
keys.set("key.jump", "key.keyboard.z");
|
||||
keys.reset_all();
|
||||
assert!(!keys.changed_from_defaults());
|
||||
}
|
||||
#[test]
|
||||
fn display_names() {
|
||||
assert_eq!(display_name("key.keyboard.w").plain(), "W");
|
||||
|
||||
@@ -15,7 +15,7 @@ use crate::{
|
||||
font::{Font, Glyph},
|
||||
gfx::mark_oit,
|
||||
hud::Rect,
|
||||
mesher::{CULL_BACK, Vertex},
|
||||
mesher::{CULL_BACK, Vertex, cull_back},
|
||||
models::load_visibility,
|
||||
signs::{self, SignData, SignText},
|
||||
text::{Style, Text},
|
||||
@@ -3093,9 +3093,9 @@ impl BlockEntityRenderer {
|
||||
}
|
||||
}
|
||||
// `DecoratedPotRenderer` submits every part with `entitySolid`, which culls
|
||||
// back faces: the flat `top` and `bottom` planes show one side each.
|
||||
// back faces: the flat `top` and `bottom` planes show one side each (#15322).
|
||||
for vertex in &mut out[first..] {
|
||||
vertex.normal = CULL_BACK;
|
||||
vertex.normal = cull_back(vertex.normal);
|
||||
}
|
||||
}
|
||||
Kind::EndPortal => {
|
||||
@@ -4760,9 +4760,12 @@ mod tests {
|
||||
28,
|
||||
"the pot submits all of vanilla's quads"
|
||||
);
|
||||
// A part the shader shades keeps its normal (#15322): `w` alone says cull.
|
||||
assert!(
|
||||
verts.iter().all(|v| v.normal == CULL_BACK),
|
||||
"entitySolid culls the pot's back faces"
|
||||
verts
|
||||
.iter()
|
||||
.all(|v| v.normal[3] == crate::mesher::SHADED_CULL_W && v.normal[..3] != [0; 3]),
|
||||
"entitySolid culls the pot's back faces and shades it by its normals"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
use horizon_core::paletted::{PalettedContainer, Strategy};
|
||||
use horizon_world::nbt::{Compound, Tag};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::sync::atomic::{AtomicI16, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
pub mod block_entity;
|
||||
@@ -457,6 +458,44 @@ impl Light {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// One of a chunk's heightmaps (`Chunk::surface`): the heights, built whole on the first read,
|
||||
/// and the columns a write may have moved since, each found again on its next read. Reads share
|
||||
/// the chunk (helper threads read the world): a stale column is found the same by any of them.
|
||||
#[derive(Default)]
|
||||
struct Heights {
|
||||
heights: OnceLock<Box<[AtomicI16; 256]>>,
|
||||
stale: [AtomicU64; 4],
|
||||
}
|
||||
impl Heights {
|
||||
/// `Heightmap.update`: a write two or more below column `i`'s height can't move it; any
|
||||
/// other marks the column.
|
||||
fn write(&mut self, i: usize, y: i32) {
|
||||
let Some(heights) = self.heights.get_mut() else {
|
||||
return;
|
||||
};
|
||||
let stale = self.stale[i >> 6].get_mut();
|
||||
if *stale & 1 << (i & 63) == 0 && y >= i32::from(*heights[i].get_mut()) - 1 {
|
||||
*stale |= 1 << (i & 63);
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Clone for Heights {
|
||||
fn clone(&self) -> Self {
|
||||
let copy = Self::default();
|
||||
// A reader can refresh a height and then clear its stale bit while we clone. Copy the
|
||||
// bits first: a stale snapshot will rescan; a clean one acquires the refreshed height.
|
||||
// Copying heights first could keep an old height with a newly cleared bit.
|
||||
for (to, from) in copy.stale.iter().zip(&self.stale) {
|
||||
to.store(from.load(Ordering::Acquire), Ordering::Relaxed);
|
||||
}
|
||||
if let Some(heights) = self.heights.get() {
|
||||
let _ = copy.heights.set(Box::new(std::array::from_fn(|i| {
|
||||
AtomicI16::new(heights[i].load(Ordering::Relaxed))
|
||||
})));
|
||||
}
|
||||
copy
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
fn column(x: i32, z: i32) -> usize {
|
||||
((z & 15) << 4 | (x & 15)) as usize
|
||||
@@ -574,10 +613,10 @@ pub struct Chunk {
|
||||
pub sections: Vec<Arc<Section>>,
|
||||
/// `Heightmap.Types.WORLD_SURFACE` per column (`z << 4 | x`): one above
|
||||
/// the highest non-air block, or the column's minimum Y. Built on the first read; a
|
||||
/// `set` that can move a column's surface drops it.
|
||||
surface: OnceLock<Box<[i16; 256]>>,
|
||||
/// `set` that can move a column's surface marks that column, found again on its next read.
|
||||
surface: Heights,
|
||||
/// `Heightmap.Types.MOTION_BLOCKING` per column, kept like `surface`.
|
||||
motion_blocking: OnceLock<Box<[i16; 256]>>,
|
||||
motion_blocking: Heights,
|
||||
pub lit: bool,
|
||||
/// Light read from a save with `isLightOn`: no light job is needed.
|
||||
pub light_loaded: bool,
|
||||
@@ -618,8 +657,8 @@ impl Chunk {
|
||||
z,
|
||||
min_y: layout.min_y(),
|
||||
sections: (0..layout.sections()).map(|_| empty.clone()).collect(),
|
||||
surface: OnceLock::new(),
|
||||
motion_blocking: OnceLock::new(),
|
||||
surface: Heights::default(),
|
||||
motion_blocking: Heights::default(),
|
||||
lit: false,
|
||||
light_loaded: false,
|
||||
fresh: false,
|
||||
@@ -698,12 +737,7 @@ impl Chunk {
|
||||
// move it. Any other change rebuilds that map on the next read.
|
||||
if previous != v {
|
||||
for map in [&mut self.surface, &mut self.motion_blocking] {
|
||||
if map
|
||||
.get()
|
||||
.is_some_and(|heights| y >= i32::from(heights[column(x, z)]) - 1)
|
||||
{
|
||||
*map = OnceLock::new();
|
||||
}
|
||||
map.write(column(x, z), y);
|
||||
}
|
||||
}
|
||||
previous
|
||||
@@ -714,24 +748,55 @@ impl Chunk {
|
||||
let section = Arc::make_mut(&mut self.sections[s]);
|
||||
section.blocks = Section::from_blocks(values);
|
||||
section.compact();
|
||||
self.surface = OnceLock::new();
|
||||
self.motion_blocking = OnceLock::new();
|
||||
self.surface = Heights::default();
|
||||
self.motion_blocking = Heights::default();
|
||||
}
|
||||
/// `getHeight(Heightmap.Types.WORLD_SURFACE, x, z)` (`x`, `z` within the
|
||||
/// chunk): one above the highest block that isn't `air`, or the column's
|
||||
/// minimum Y.
|
||||
/// Sections must change only through `set` once this has been read.
|
||||
pub fn world_surface(&self, x: i32, z: i32, air: impl Fn(u32) -> bool) -> i32 {
|
||||
let surface = self.surface.get_or_init(|| self.prime(|v| !air(v)));
|
||||
i32::from(surface[column(x, z)])
|
||||
self.height(&self.surface, x, z, |v| !air(v))
|
||||
}
|
||||
/// `getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)` (`x`, `z` within
|
||||
/// the chunk): one above the highest state where `blocks` (a motion
|
||||
/// blocking block or a fluid), or the column's minimum Y. Kept as
|
||||
/// `world_surface` is.
|
||||
pub fn motion_blocking(&self, x: i32, z: i32, blocks: impl Fn(u32) -> bool) -> i32 {
|
||||
let heights = self.motion_blocking.get_or_init(|| self.prime(blocks));
|
||||
i32::from(heights[column(x, z)])
|
||||
self.height(&self.motion_blocking, x, z, blocks)
|
||||
}
|
||||
/// One column's height in `map`: the whole map built on its first read, a column a write
|
||||
/// marked found again down that column alone.
|
||||
fn height(&self, map: &Heights, x: i32, z: i32, counts: impl Fn(u32) -> bool) -> i32 {
|
||||
let heights = map.heights.get_or_init(|| {
|
||||
let built = self.prime(&counts);
|
||||
Box::new(std::array::from_fn(|i| AtomicI16::new(built[i])))
|
||||
});
|
||||
let i = column(x, z);
|
||||
let (word, bit) = (i >> 6, 1u64 << (i & 63));
|
||||
if map.stale[word].load(Ordering::Acquire) & bit != 0 {
|
||||
let height = self.column_height(i, &counts);
|
||||
heights[i].store(height, Ordering::Relaxed);
|
||||
map.stale[word].fetch_and(!bit, Ordering::Release);
|
||||
return i32::from(height);
|
||||
}
|
||||
i32::from(heights[i].load(Ordering::Relaxed))
|
||||
}
|
||||
/// [`Self::prime`] for column `i` alone.
|
||||
fn column_height(&self, i: usize, counts: impl Fn(u32) -> bool) -> i16 {
|
||||
for (s, section) in self.sections.iter().enumerate().rev() {
|
||||
if section.blocks.uniform().is_some_and(|v| !counts(v)) {
|
||||
continue;
|
||||
}
|
||||
let base = self.min_y + s as i32 * 16;
|
||||
if let Some(dy) = (0..16)
|
||||
.rev()
|
||||
.find(|&dy| counts(section.blocks.get(dy << 8 | i)))
|
||||
{
|
||||
return (base + dy as i32 + 1) as i16;
|
||||
}
|
||||
}
|
||||
self.min_y as i16
|
||||
}
|
||||
/// `Heightmap.primeHeightmaps`: each section from the top, skipping
|
||||
/// those of one state that doesn't count, until every column is found.
|
||||
@@ -797,7 +862,7 @@ impl Chunk {
|
||||
+ self.sections.capacity() * std::mem::size_of::<Arc<Section>>();
|
||||
census.heightmap_bytes += [&self.surface, &self.motion_blocking]
|
||||
.iter()
|
||||
.filter(|map| map.get().is_some())
|
||||
.filter(|map| map.heights.get().is_some())
|
||||
.count()
|
||||
* std::mem::size_of::<[i16; 256]>();
|
||||
for section in &self.sections {
|
||||
@@ -1020,9 +1085,89 @@ mod tests {
|
||||
let before = chunk.world_surface(3, 3, is_air);
|
||||
chunk.set(3, MIN_Y + HEIGHT, 3, stone);
|
||||
chunk.set(3, before - 1, 3, chunk.get(3, before - 1, 3));
|
||||
assert!(chunk.surface.get().is_some());
|
||||
assert!(chunk.surface.heights.get().is_some());
|
||||
assert_eq!(chunk.world_surface(3, 3, |_| unreachable!()), before);
|
||||
}
|
||||
/// A write two below a column's height can't move it, so the next read asks about no block;
|
||||
/// a write at or above it (placing on top, clearing the top block) is found again on the next
|
||||
/// read by looking down that column alone, and the other columns keep their heights.
|
||||
#[test]
|
||||
fn a_write_finds_its_own_column_again() {
|
||||
// Each column holds its own state (16 + i), so a read that looks at another column's
|
||||
// blocks shows up as a state that isn't air or this column's.
|
||||
let state = |i: usize| 16 + i as u32;
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
for i in 0..256 {
|
||||
for y in MIN_Y..MIN_Y + 20 + (i as i32 % 7) {
|
||||
chunk.set(i as i32 & 15, y, i as i32 >> 4, state(i));
|
||||
}
|
||||
}
|
||||
let seen = std::cell::RefCell::new(Vec::new());
|
||||
let surface = |c: &Chunk, i: usize| {
|
||||
c.world_surface(i as i32 & 15, i as i32 >> 4, |v| {
|
||||
seen.borrow_mut().push(v);
|
||||
v == 0
|
||||
})
|
||||
};
|
||||
let heights: Vec<i32> = (0..256).map(|i| surface(&chunk, i)).collect();
|
||||
assert_eq!(heights[0], MIN_Y + 20);
|
||||
assert_eq!(heights[6], MIN_Y + 26);
|
||||
seen.borrow_mut().clear();
|
||||
|
||||
let (column, x, z) = (0x35, 5, 3);
|
||||
let top = heights[column];
|
||||
// Two below the height: the column keeps its map entry.
|
||||
chunk.set(x, top - 2, z, 0);
|
||||
assert_eq!(surface(&chunk, column), top);
|
||||
assert!(seen.borrow().is_empty());
|
||||
chunk.set(x, top - 2, z, state(column));
|
||||
|
||||
// At the height: placed on top, found again down this column only.
|
||||
chunk.set(x, top, z, state(column));
|
||||
assert_eq!(surface(&chunk, column), top + 1);
|
||||
assert!(!seen.borrow().is_empty());
|
||||
assert!(seen.borrow().iter().all(|&v| v == 0 || v == state(column)));
|
||||
seen.borrow_mut().clear();
|
||||
assert_eq!(surface(&chunk, column), top + 1);
|
||||
for i in (0..256).filter(|&i| i != column) {
|
||||
assert_eq!(surface(&chunk, i), heights[i]);
|
||||
}
|
||||
assert!(
|
||||
seen.borrow().is_empty(),
|
||||
"a read found again an untouched column"
|
||||
);
|
||||
|
||||
// One below the height: the top block cleared, the height drops by one.
|
||||
chunk.set(x, top, z, 0);
|
||||
assert_eq!(surface(&chunk, column), top);
|
||||
assert!(seen.borrow().iter().all(|&v| v == 0 || v == state(column)));
|
||||
}
|
||||
/// Chunk snapshots can be cloned while helper threads refresh a stale height. No snapshot
|
||||
/// may retain the old height with its stale bit cleared, for either map.
|
||||
#[test]
|
||||
fn cloning_during_a_height_refresh_keeps_the_new_height() {
|
||||
for _ in 0..4096 {
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
chunk.set(0, MIN_Y + 4, 0, 1);
|
||||
assert_eq!(chunk.world_surface(0, 0, |v| v == 0), MIN_Y + 5);
|
||||
assert_eq!(chunk.motion_blocking(0, 0, |v| v != 0), MIN_Y + 5);
|
||||
chunk.set(0, MIN_Y + 5, 0, 1);
|
||||
let ready = std::sync::Barrier::new(2);
|
||||
std::thread::scope(|scope| {
|
||||
scope.spawn(|| {
|
||||
ready.wait();
|
||||
assert_eq!(chunk.world_surface(0, 0, |v| v == 0), MIN_Y + 6);
|
||||
assert_eq!(chunk.motion_blocking(0, 0, |v| v != 0), MIN_Y + 6);
|
||||
});
|
||||
ready.wait();
|
||||
for _ in 0..4 {
|
||||
let snapshot = chunk.clone();
|
||||
assert_eq!(snapshot.world_surface(0, 0, |v| v == 0), MIN_Y + 6);
|
||||
assert_eq!(snapshot.motion_blocking(0, 0, |v| v != 0), MIN_Y + 6);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
/// MOTION_BLOCKING ignores states that don't count (air, grass) and
|
||||
/// follows writes as the surface map does, both maps kept at once.
|
||||
#[test]
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
//!
|
||||
//! The pack's GLSL (26.3's `#include` or the older `#moj_import`, with explicit locations or
|
||||
//! without) is written again as the Vulkan-flavoured GLSL 450 naga reads:
|
||||
//! - GLSL vanilla's GL compiler takes and naga doesn't is rewritten first ([`glsl_fixups`]: a
|
||||
//! helper taking the `sampler2D` it reads, an array sized by its initializer only);
|
||||
//! - includes are inlined, each file once (as `GlslPreprocessor` imported them), from `include`;
|
||||
//! - vanilla's uniform blocks get the bindings of [`BLOCKS`], and each of [`SAMPLERS`] is split
|
||||
//! into a texture and a sampler object, combined again where the sampler is named;
|
||||
//! - the vertex inputs become globals the caller's [`Glue`] fills before the pack's `main`
|
||||
//! (renamed `hz_main`) runs, and `gl_VertexID` reads the glue's expression;
|
||||
//! (renamed `hz_main`) runs, and `gl_VertexID` and `gl_VertexIndex` read the glue's expression;
|
||||
//! - varyings get locations by name across the stages, the fragment output location 0.
|
||||
|
||||
use std::collections::HashSet;
|
||||
@@ -18,6 +20,8 @@ use naga::ShaderStage;
|
||||
use naga::front::glsl::{Frontend, Options};
|
||||
use naga::valid::{Capabilities, ValidationFlags, Validator};
|
||||
|
||||
mod glsl_fixups;
|
||||
|
||||
/// Vanilla's uniform blocks (`shaders/include/*.glsl`) and their bindings in set 0.
|
||||
pub const BLOCKS: [(&str, u32); 4] = [
|
||||
("Globals", 0),
|
||||
@@ -43,7 +47,7 @@ pub struct Glue<'a> {
|
||||
pub setup: &'a str,
|
||||
/// Statements after it, on `gl_Position`.
|
||||
pub finish: &'a str,
|
||||
/// The expression `gl_VertexID` reads.
|
||||
/// The expression the pack's `gl_VertexID` and `gl_VertexIndex` read.
|
||||
pub vertex_id: &'a str,
|
||||
}
|
||||
|
||||
@@ -64,8 +68,8 @@ pub fn translate_core(
|
||||
glue: &Glue,
|
||||
) -> Result<CoreProgram, String> {
|
||||
const CORE: &str = "minecraft:shaders/core/";
|
||||
let vertex_text = strip_comments(&expand(vertex, CORE, include)?);
|
||||
let fragment_text = strip_comments(&expand(fragment, CORE, include)?);
|
||||
let vertex_text = glsl_fixups::fixups(&strip_comments(&expand(vertex, CORE, include)?));
|
||||
let fragment_text = glsl_fixups::fixups(&strip_comments(&expand(fragment, CORE, include)?));
|
||||
let mut names = Names::default();
|
||||
let vertex_code = rewrite(&vertex_text, ShaderStage::Vertex, glue, &mut names)
|
||||
.map_err(|e| format!("vertex shader: {e}"))?;
|
||||
@@ -337,6 +341,8 @@ fn rewrite(
|
||||
writeln!(out, "{ty} {name};").unwrap();
|
||||
}
|
||||
}
|
||||
// Where the pack's own code starts: the glue above it is left as it is.
|
||||
let pack_start = out.len();
|
||||
let mut outputs: Vec<&str> = Vec::new();
|
||||
for source_line in text.lines() {
|
||||
for line in declaration_lines(source_line) {
|
||||
@@ -462,11 +468,18 @@ fn rewrite(
|
||||
if !vertex {
|
||||
return Ok(out);
|
||||
}
|
||||
let mut code = replace_ident(
|
||||
&rename_main(&out),
|
||||
"gl_VertexID",
|
||||
&format!("({})", glue.vertex_id),
|
||||
// The pack's `gl_VertexID` and 26.3's `gl_VertexIndex` both mean vanilla's vertex number
|
||||
// (four per quad, an `int`): both read the glue's expression, which for a pipeline that
|
||||
// draws six vertices per quad turns its own `gl_VertexIndex` back into that number. Only the
|
||||
// pack's code is rewritten, so the glue's expression keeps reading the real index.
|
||||
let vertex_id = format!("({})", glue.vertex_id);
|
||||
let pack = replace_ident(
|
||||
&rename_main(&out[pack_start..]),
|
||||
"gl_VertexIndex",
|
||||
&vertex_id,
|
||||
);
|
||||
let pack = replace_ident(&pack, "gl_VertexID", &vertex_id);
|
||||
let mut code = format!("{}{pack}", &out[..pack_start]);
|
||||
write!(
|
||||
code,
|
||||
"void main() {{\n{}\nhz_main();\n{}\n}}\n",
|
||||
@@ -633,6 +646,43 @@ mod tests {
|
||||
assert_eq!(program.fragment.entry_points.len(), 1);
|
||||
}
|
||||
|
||||
/// A 26.3 pack's `gl_VertexIndex` means vanilla's vertex number, four per quad. The text
|
||||
/// pipeline draws six per glyph, so the pack's code must read the glue's `hz_vertex_id()`
|
||||
/// (as `gl_VertexID` does), never the raw index.
|
||||
#[test]
|
||||
fn a_packs_gl_vertex_index_reads_the_glues_vertex_number() {
|
||||
let vsh = "#version 330\n#include <minecraft:dynamictransforms.glsl>\n#include <minecraft:projection.glsl>\n\
|
||||
in vec3 Position;\nin vec4 Color;\nin vec2 UV0;\nout vec4 vertexColor;\nout vec2 texCoord0;\n\
|
||||
void main() {\n int corner = gl_VertexIndex % 4;\n\
|
||||
gl_Position = ProjMat * ModelViewMat * vec4(Position + vec3(float(corner), 0.0, 0.0), 1.0);\n\
|
||||
vertexColor = Color;\n texCoord0 = UV0;\n}\n";
|
||||
let program = translate_core(vsh, VANILLA_FSH, &["IS_GUI"], &include, &GLUE).unwrap();
|
||||
let module = &program.vertex;
|
||||
let named = |name: &str| {
|
||||
module
|
||||
.functions
|
||||
.iter()
|
||||
.find(|(_, f)| f.name.as_deref() == Some(name))
|
||||
.unwrap_or_else(|| panic!("no {name}"))
|
||||
};
|
||||
let (vertex_id, _) = named("hz_vertex_id");
|
||||
let (_, pack_main) = named("hz_main");
|
||||
fn calls(block: &naga::Block, target: naga::Handle<naga::Function>) -> bool {
|
||||
block.iter().any(|statement| match statement {
|
||||
naga::Statement::Call { function, .. } => *function == target,
|
||||
naga::Statement::Block(inner) => calls(inner, target),
|
||||
naga::Statement::If { accept, reject, .. } => {
|
||||
calls(accept, target) || calls(reject, target)
|
||||
}
|
||||
naga::Statement::Loop {
|
||||
body, continuing, ..
|
||||
} => calls(body, target) || calls(continuing, target),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
assert!(calls(&pack_main.body, vertex_id));
|
||||
}
|
||||
|
||||
/// A 26.2-style pack shader as a HUD pack writes it: `#moj_import`, no locations, a flat
|
||||
/// varying, `gl_VertexID`, `texelFetch` and `textureSize` on the glyph atlas, `GameTime`.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,603 @@
|
||||
//! GLSL that vanilla's GL compiler takes and naga's front end doesn't (#9386), rewritten into
|
||||
//! what both take:
|
||||
//! - a function with `sampler2D` parameters is copied once per set of samplers its callers pass
|
||||
//! (all of them a shader's global samplers), the copy reading those samplers by name;
|
||||
//! - an unsized array (`const float[] w = float[](...)`, `float w[] = ...`) takes the size its
|
||||
//! initializer gives.
|
||||
|
||||
/// The longest shader rewritten (vanilla's, includes expanded, are a few KiB).
|
||||
const MAX_LEN: usize = 64 * 1024;
|
||||
|
||||
/// The bytes the rewrite may scan for calls (the whole text per call it rewrites): thousands of
|
||||
/// calls leave the text as it was rather than freeze a load.
|
||||
const SCAN_BUDGET: usize = 64 << 20;
|
||||
|
||||
/// `text` with the rewrites, where they apply; anything they can't follow is left as it was.
|
||||
pub(super) fn fixups(text: &str) -> String {
|
||||
if text.len() > MAX_LEN {
|
||||
return text.to_owned();
|
||||
}
|
||||
size_unsized_arrays(&specialize_sampler_params(text))
|
||||
}
|
||||
|
||||
fn is_ident(c: char) -> bool {
|
||||
c.is_ascii_alphanumeric() || c == '_'
|
||||
}
|
||||
|
||||
/// A name a sampler can have (`sampler2D ;` gives none, `t[2]` isn't one).
|
||||
fn is_name(s: &str) -> bool {
|
||||
s.chars().all(is_ident) && s.starts_with(|c: char| !c.is_ascii_digit()) && s != "sampler2D"
|
||||
}
|
||||
|
||||
/// The identifier that ends right before byte `end` (whitespace skipped), and where it starts.
|
||||
fn ident_before(text: &str, end: usize) -> Option<(usize, &str)> {
|
||||
let trimmed = text[..end].trim_end();
|
||||
let start = trimmed
|
||||
.char_indices()
|
||||
.rev()
|
||||
.take_while(|(_, c)| is_ident(*c))
|
||||
.last()?
|
||||
.0;
|
||||
let ident = &trimmed[start..];
|
||||
(!ident.is_empty() && !ident.starts_with(|c: char| c.is_ascii_digit()))
|
||||
.then_some((start, ident))
|
||||
}
|
||||
|
||||
/// For each byte of `text`, the byte of the bracket closing the one opening there (counting
|
||||
/// that kind of bracket only), or `None`: one pass, so a scan asking for many stays linear.
|
||||
fn closings(text: &str) -> Vec<Option<usize>> {
|
||||
let mut out = vec![None; text.len()];
|
||||
let mut open: [Vec<usize>; 3] = Default::default();
|
||||
for (i, b) in text.bytes().enumerate() {
|
||||
let (kind, opens) = match b {
|
||||
b'(' => (0, true),
|
||||
b')' => (0, false),
|
||||
b'[' => (1, true),
|
||||
b']' => (1, false),
|
||||
b'{' => (2, true),
|
||||
b'}' => (2, false),
|
||||
_ => continue,
|
||||
};
|
||||
if opens {
|
||||
open[kind].push(i);
|
||||
} else if let Some(at) = open[kind].pop() {
|
||||
out[at] = Some(i);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The comma-separated parts of `list` at its own nesting level.
|
||||
fn split_top(list: &str) -> Vec<&str> {
|
||||
let mut parts = Vec::new();
|
||||
let (mut depth, mut start) = (0i32, 0);
|
||||
for (i, c) in list.char_indices() {
|
||||
match c {
|
||||
'(' | '[' | '{' => depth += 1,
|
||||
')' | ']' | '}' => depth -= 1,
|
||||
',' if depth == 0 => {
|
||||
parts.push(&list[start..i]);
|
||||
start = i + 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !list[start..].trim().is_empty() || !parts.is_empty() {
|
||||
parts.push(&list[start..]);
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
/// A function at the file's top level whose parameters include a `sampler2D`.
|
||||
struct SamplerFn {
|
||||
name: String,
|
||||
/// The header from its return type to its `{` (or `;` for a prototype), and the body after.
|
||||
start: usize,
|
||||
end: usize,
|
||||
params: Vec<String>,
|
||||
/// The indices of the `sampler2D` parameters and their names.
|
||||
samplers: Vec<(usize, String)>,
|
||||
/// `None` for a prototype.
|
||||
body: Option<String>,
|
||||
return_type: String,
|
||||
}
|
||||
|
||||
fn sampler_functions(text: &str) -> Vec<SamplerFn> {
|
||||
let closing = closings(text);
|
||||
let mut out = Vec::new();
|
||||
let mut depth = 0i32;
|
||||
let bytes = text.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
match bytes[i] {
|
||||
b'{' => depth += 1,
|
||||
b'}' => depth -= 1,
|
||||
b'(' if depth == 0 => {
|
||||
if let (Some((name_at, name)), Some(close)) = (ident_before(text, i), closing[i])
|
||||
&& let Some((type_at, return_type)) = ident_before(text, name_at)
|
||||
{
|
||||
let params: Vec<String> = split_top(&text[i + 1..close])
|
||||
.into_iter()
|
||||
.map(|p| p.trim().to_owned())
|
||||
.collect();
|
||||
let samplers: Vec<(usize, String)> = params
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| p.split_whitespace().any(|w| w == "sampler2D"))
|
||||
.map(|(n, p)| (n, p.split_whitespace().last().unwrap_or("").to_owned()))
|
||||
.collect();
|
||||
// One without a name is left for naga to refuse.
|
||||
if !samplers.iter().all(|(_, name)| is_name(name)) {
|
||||
i = close + 1;
|
||||
continue;
|
||||
}
|
||||
let after = text[close + 1..].trim_start();
|
||||
let next = close + 1 + (text[close + 1..].len() - after.len());
|
||||
if !samplers.is_empty() && (after.starts_with('{') || after.starts_with(';')) {
|
||||
let (end, body) = if after.starts_with('{') {
|
||||
let Some(body_end) = closing[next] else {
|
||||
break;
|
||||
};
|
||||
(body_end + 1, Some(text[next..=body_end].to_owned()))
|
||||
} else {
|
||||
(next + 1, None)
|
||||
};
|
||||
out.push(SamplerFn {
|
||||
name: name.to_owned(),
|
||||
start: type_at,
|
||||
end,
|
||||
params,
|
||||
samplers,
|
||||
body,
|
||||
return_type: return_type.to_owned(),
|
||||
});
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Any other header: no other one sits in its parentheses.
|
||||
if let Some(close) = closing[i] {
|
||||
i = close + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The calls to `name` outside `skip` (its own header): each call's span and arguments. The
|
||||
/// bytes read are added to `scanned`.
|
||||
fn calls<'a>(
|
||||
scanned: &std::cell::Cell<usize>,
|
||||
text: &'a str,
|
||||
name: &str,
|
||||
skip: &[(usize, usize)],
|
||||
) -> Vec<(usize, usize, Vec<&'a str>)> {
|
||||
scanned.set(scanned.get() + text.len());
|
||||
let closing = closings(text);
|
||||
let mut out = Vec::new();
|
||||
let mut from = 0;
|
||||
while let Some(found) = text[from..].find(name) {
|
||||
let at = from + found;
|
||||
from = at + name.len();
|
||||
let before = text[..at].chars().next_back();
|
||||
let rest = &text[at + name.len()..];
|
||||
let open = at + name.len() + (rest.len() - rest.trim_start().len());
|
||||
if before.is_some_and(is_ident)
|
||||
|| !text[open..].starts_with('(')
|
||||
|| skip.iter().any(|(s, e)| (*s..*e).contains(&at))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(close) = closing[open] else {
|
||||
continue;
|
||||
};
|
||||
scanned.set(scanned.get() + close - open);
|
||||
out.push((at, close + 1, split_top(&text[open + 1..close])));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Every `from` standing as an identifier on its own, but not a member after a `.` (a
|
||||
/// parameter `s` leaves the swizzle `c.s` alone).
|
||||
fn replace_ident(text: &str, from: &str, to: &str) -> String {
|
||||
if from.is_empty() {
|
||||
return text.to_owned();
|
||||
}
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut done = 0;
|
||||
while let Some(found) = text[done..].find(from) {
|
||||
let at = done + found;
|
||||
// Read in the whole text: a match right after another (`ss`) is part of it.
|
||||
let before = text[..at].trim_end().chars().next_back();
|
||||
let joined = text[..at].chars().next_back().is_some_and(is_ident);
|
||||
let after = text[at + from.len()..].chars().next();
|
||||
out.push_str(&text[done..at]);
|
||||
if joined || before == Some('.') || after.is_some_and(is_ident) {
|
||||
out.push_str(from);
|
||||
} else {
|
||||
out.push_str(to);
|
||||
}
|
||||
done = at + from.len();
|
||||
}
|
||||
out.push_str(&text[done..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// A body with its sampler parameters reading `chosen`, all at once: a parameter named like
|
||||
/// another's sampler (`mix2(sampler2D Sampler0, sampler2D Sampler1)` called with them swapped)
|
||||
/// still reads its own.
|
||||
fn read_samplers(body: &str, params: &[(usize, String)], chosen: &[String]) -> String {
|
||||
let mut body = body.to_owned();
|
||||
for (n, (_, param)) in params.iter().enumerate() {
|
||||
body = replace_ident(&body, param, &format!("hz_sampler_param_{n}"));
|
||||
}
|
||||
for (n, sampler) in chosen.iter().enumerate().take(params.len()) {
|
||||
body = replace_ident(&body, &format!("hz_sampler_param_{n}"), sampler);
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
/// The byte after `uniform sampler2D <name>;`, where the shader declares that sampler.
|
||||
fn sampler_declaration_end(text: &str, name: &str) -> Option<usize> {
|
||||
let mut from = 0;
|
||||
while let Some(found) = text[from..].find("sampler2D") {
|
||||
let at = from + found;
|
||||
from = at + "sampler2D".len();
|
||||
let before = text[..at].trim_end();
|
||||
if !before.ends_with("uniform")
|
||||
|| before[..before.len() - 7]
|
||||
.chars()
|
||||
.next_back()
|
||||
.is_some_and(is_ident)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let rest = &text[from..];
|
||||
let ident = rest.trim_start();
|
||||
let len = ident.chars().take_while(|c| is_ident(*c)).count();
|
||||
if &ident[..len] != name {
|
||||
continue;
|
||||
}
|
||||
let after = ident[len..].trim_start();
|
||||
if after.starts_with(';') {
|
||||
return Some(text.len() - after.len() + 1);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The samplers a call passes for `f`'s `sampler2D` parameters, in order.
|
||||
fn chosen_samplers(f: &SamplerFn, args: &[&str]) -> Vec<String> {
|
||||
f.samplers
|
||||
.iter()
|
||||
.filter_map(|(n, _)| args.get(*n).map(|a| a.trim().to_owned()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One pass per function with `sampler2D` parameters, until none is left or one can't be
|
||||
/// followed (a caller passes something that is no sampler's name).
|
||||
fn specialize_sampler_params(original: &str) -> String {
|
||||
let mut text = original.to_owned();
|
||||
// Bytes scanned for calls so far, against [`SCAN_BUDGET`].
|
||||
let scanned = std::cell::Cell::new(0usize);
|
||||
let spent = || scanned.get() > SCAN_BUDGET;
|
||||
for _ in 0..16 {
|
||||
let functions = sampler_functions(&text);
|
||||
// From the outside in: no call inside another function still to be copied.
|
||||
let inside_another = |f: &SamplerFn, at: usize| {
|
||||
functions
|
||||
.iter()
|
||||
.any(|g| g.name != f.name && g.body.is_some() && (g.start..g.end).contains(&at))
|
||||
};
|
||||
let spans_of = |f: &SamplerFn| -> Vec<(usize, usize)> {
|
||||
functions
|
||||
.iter()
|
||||
.filter(|g| g.name == f.name)
|
||||
.map(|g| (g.start, g.end))
|
||||
.collect()
|
||||
};
|
||||
let Some(f) = functions.iter().find(|f| {
|
||||
f.body.is_some()
|
||||
&& !spent()
|
||||
&& calls(&scanned, &text, &f.name, &spans_of(f))
|
||||
.iter()
|
||||
.all(|(at, ..)| !inside_another(f, *at))
|
||||
}) else {
|
||||
if spent() {
|
||||
break;
|
||||
}
|
||||
// None left to copy.
|
||||
return text;
|
||||
};
|
||||
let spans = spans_of(f);
|
||||
let found = calls(&scanned, &text, &f.name, &spans);
|
||||
// Each call's samplers must be plain names (a global's, or a later pass's parameter).
|
||||
let mut variants: Vec<Vec<String>> = Vec::new();
|
||||
let mut call_starts: Vec<usize> = Vec::new();
|
||||
let mut followable = true;
|
||||
for (at, _, args) in &found {
|
||||
let chosen = chosen_samplers(f, args);
|
||||
if chosen.len() != f.samplers.len() || !chosen.iter().all(|a| is_name(a)) {
|
||||
followable = false;
|
||||
break;
|
||||
}
|
||||
call_starts.push(*at);
|
||||
if !variants.contains(&chosen) {
|
||||
variants.push(chosen);
|
||||
}
|
||||
if variants.len() > 64 {
|
||||
followable = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Overloads (`tap(sampler2D, vec2)` and `tap(sampler2D, vec3)`) each keep their own
|
||||
// body; a call picks its samplers by position, so they must sit at the same places.
|
||||
let same_places = functions.iter().filter(|g| g.name == f.name).all(|g| {
|
||||
g.samplers
|
||||
.iter()
|
||||
.map(|s| s.0)
|
||||
.eq(f.samplers.iter().map(|s| s.0))
|
||||
});
|
||||
if !followable || !same_places || spent() {
|
||||
break;
|
||||
}
|
||||
// A body may call another overload of itself with its own sampler (vanilla's
|
||||
// `sampleNearest` calls the six-argument one): once its sampler parameters read a
|
||||
// sampler, those calls name that copy too, and their sets join the variants.
|
||||
let rewrite_own_calls = |body: &str| -> Option<(String, Vec<Vec<String>>)> {
|
||||
let mut body = body.to_owned();
|
||||
let mut found = Vec::new();
|
||||
let mut starts: Vec<usize> = calls(&scanned, &body, &f.name, &[])
|
||||
.iter()
|
||||
.map(|c| c.0)
|
||||
.collect();
|
||||
starts.sort_unstable_by(|a, b| b.cmp(a));
|
||||
for start in starts {
|
||||
if spent() {
|
||||
return None;
|
||||
}
|
||||
let (end, chosen, with) = {
|
||||
let (_, end, args) = calls(&scanned, &body, &f.name, &[])
|
||||
.into_iter()
|
||||
.find(|c| c.0 == start)?;
|
||||
let chosen = chosen_samplers(f, &args);
|
||||
let rest: Vec<&str> = args
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(n, _)| !f.samplers.iter().any(|(s, _)| s == n))
|
||||
.map(|(_, a)| a.trim())
|
||||
.collect();
|
||||
let with = format!("{}_hz_{}({})", f.name, chosen.join("_"), rest.join(", "));
|
||||
(end, chosen, with)
|
||||
};
|
||||
if chosen.len() != f.samplers.len() || !chosen.iter().all(|a| is_name(a)) {
|
||||
return None;
|
||||
}
|
||||
found.push(chosen);
|
||||
body.replace_range(start..end, &with);
|
||||
}
|
||||
Some((body, found))
|
||||
};
|
||||
let specialize = |g: &SamplerFn, chosen: &[String]| {
|
||||
let body = read_samplers(g.body.as_deref().unwrap_or_default(), &g.samplers, chosen);
|
||||
rewrite_own_calls(&body)
|
||||
};
|
||||
let same: Vec<&SamplerFn> = functions.iter().filter(|g| g.name == f.name).collect();
|
||||
let mut next = 0;
|
||||
while followable && next < variants.len() {
|
||||
let chosen = variants[next].clone();
|
||||
for g in same.iter().filter(|g| g.body.is_some()) {
|
||||
match specialize(g, &chosen) {
|
||||
Some((_, inner)) => {
|
||||
for v in inner {
|
||||
if !variants.contains(&v) {
|
||||
variants.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => followable = false,
|
||||
}
|
||||
}
|
||||
next += 1;
|
||||
followable &= variants.len() <= 64 && !spent();
|
||||
}
|
||||
if !followable {
|
||||
break;
|
||||
}
|
||||
// One copy of an overload per set of samplers its callers pass, keeping its other
|
||||
// parameters; a prototype becomes the copies' prototypes.
|
||||
let signature = |g: &SamplerFn, chosen: &[String]| {
|
||||
let params: Vec<&str> = g
|
||||
.params
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(n, _)| !g.samplers.iter().any(|(s, _)| s == n))
|
||||
.map(|(_, p)| p.as_str())
|
||||
.collect();
|
||||
format!(
|
||||
"{} {}_hz_{}({})",
|
||||
g.return_type,
|
||||
g.name,
|
||||
chosen.join("_"),
|
||||
params.join(", ")
|
||||
)
|
||||
};
|
||||
let copies_of = |g: &SamplerFn| -> String {
|
||||
variants
|
||||
.iter()
|
||||
.map(|chosen| match &g.body {
|
||||
Some(_) => {
|
||||
let body = specialize(g, chosen).map(|(b, _)| b).unwrap_or_default();
|
||||
format!("{} {body}\n", signature(g, chosen))
|
||||
}
|
||||
None => format!("{};\n", signature(g, chosen)),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
// Rebuild from the back: the calls, then the function's own spans (each prototype and
|
||||
// definition becomes its copies). A call nested in another's arguments starts after it,
|
||||
// so it is rewritten first, and the call around it is read again from the text as it is
|
||||
// then. A copy reads its samplers by name, so definitions above their samplers'
|
||||
// declaration (vanilla's `sample_lightmap.glsl` is included above `uniform sampler2D
|
||||
// Sampler2;`) move after it, together and in their order, as long as no call comes first.
|
||||
let first_call = call_starts.iter().copied().min().unwrap_or(usize::MAX);
|
||||
let declared = variants
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter_map(|s| sampler_declaration_end(&text, s))
|
||||
.max();
|
||||
let mut edits: Vec<(usize, Option<(usize, String)>)> =
|
||||
call_starts.into_iter().map(|at| (at, None)).collect();
|
||||
let (mut moved, mut move_to) = (String::new(), None);
|
||||
for g in &same {
|
||||
match (
|
||||
&g.body,
|
||||
declared.filter(|&end| end > g.start && end <= first_call),
|
||||
) {
|
||||
(Some(_), Some(end)) => {
|
||||
edits.push((g.start, Some((g.end, String::new()))));
|
||||
moved.push_str(&copies_of(g));
|
||||
move_to = Some(end);
|
||||
}
|
||||
_ => edits.push((g.start, Some((g.end, copies_of(g))))),
|
||||
}
|
||||
}
|
||||
if let Some(end) = move_to {
|
||||
edits.push((end, Some((end, format!("\n{moved}")))));
|
||||
}
|
||||
// Pack text is untrusted: edits that overlap, or off the text, leave it as it was.
|
||||
let mut spans: Vec<(usize, usize)> = edits
|
||||
.iter()
|
||||
.filter_map(|(s, e)| e.as_ref().map(|(end, _)| (*s, *end)))
|
||||
.collect();
|
||||
spans.sort_unstable();
|
||||
if spans.windows(2).any(|w| w[1].0 < w[0].1) {
|
||||
break;
|
||||
}
|
||||
edits.sort_by_key(|(start, _)| std::cmp::Reverse(*start));
|
||||
let mut sound = true;
|
||||
for (start, edit) in edits {
|
||||
let (end, with) = match edit {
|
||||
Some(span) => span,
|
||||
None => {
|
||||
let found = calls(&scanned, &text, &f.name, &[])
|
||||
.into_iter()
|
||||
.find(|c| c.0 == start);
|
||||
let Some((_, end, args)) = found else {
|
||||
continue;
|
||||
};
|
||||
let rest: Vec<&str> = args
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(n, _)| !f.samplers.iter().any(|(s, _)| s == n))
|
||||
.map(|(_, a)| a.trim())
|
||||
.collect();
|
||||
let copy = format!("{}_hz_{}", f.name, chosen_samplers(f, &args).join("_"));
|
||||
(end, format!("{copy}({})", rest.join(", ")))
|
||||
}
|
||||
};
|
||||
if start > end || text.get(start..end).is_none() || spent() {
|
||||
sound = false;
|
||||
break;
|
||||
}
|
||||
text.replace_range(start..end, &with);
|
||||
}
|
||||
if !sound {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// One that couldn't be followed: the shader as written, never half rewritten.
|
||||
original.to_owned()
|
||||
}
|
||||
|
||||
/// Each array declared without a size that an initializer sizes: `T[] name = T[](a, b)` and
|
||||
/// `T name[] = T[](a, b)` (or `T[2](...)`) are written with the size.
|
||||
fn size_unsized_arrays(text: &str) -> String {
|
||||
let mut text = text.to_owned();
|
||||
let mut closing = closings(&text);
|
||||
// Each array sized reads the text's brackets again: a few hundred are plenty.
|
||||
let mut left = 256;
|
||||
let mut from = 0;
|
||||
while let Some(found) = text[from..].find('=') {
|
||||
let eq = from + found;
|
||||
from = eq + 1;
|
||||
// `==`, `<=`, `>=`, `!=`, `+=` ... are no initializers.
|
||||
let prev = text[..eq].chars().next_back();
|
||||
let next = text[eq + 1..].chars().next();
|
||||
if matches!(
|
||||
prev,
|
||||
Some('=' | '<' | '>' | '!' | '+' | '-' | '*' | '/' | '%' | '&' | '|' | '^')
|
||||
) || next == Some('=')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// The constructor: `T [ n? ] (`.
|
||||
let rest = &text[eq + 1..];
|
||||
let ty_start = eq + 1 + (rest.len() - rest.trim_start().len());
|
||||
let ty_len = text[ty_start..]
|
||||
.chars()
|
||||
.take_while(|c| is_ident(*c))
|
||||
.count();
|
||||
if ty_len == 0 {
|
||||
continue;
|
||||
}
|
||||
let after_ty = &text[ty_start + ty_len..];
|
||||
let bracket = ty_start + ty_len + (after_ty.len() - after_ty.trim_start().len());
|
||||
if !text[bracket..].starts_with('[') {
|
||||
continue;
|
||||
}
|
||||
let Some(bracket_end) = closing[bracket] else {
|
||||
continue;
|
||||
};
|
||||
let after_bracket = &text[bracket_end + 1..];
|
||||
let paren = bracket_end + 1 + (after_bracket.len() - after_bracket.trim_start().len());
|
||||
if !text[paren..].starts_with('(') {
|
||||
continue;
|
||||
}
|
||||
let Some(paren_end) = closing[paren] else {
|
||||
continue;
|
||||
};
|
||||
// The declarator: `name []` or `T [] name`, between the statement's start and `=`.
|
||||
// (Looked for on the line, at most 256 bytes back, so a long line stays linear.)
|
||||
let mut back = eq.saturating_sub(256);
|
||||
while !text.is_char_boundary(back) {
|
||||
back += 1;
|
||||
}
|
||||
let statement_start = text[back..eq]
|
||||
.rfind([';', '{', '}', '\n'])
|
||||
.map_or(back, |i| back + i + 1);
|
||||
let declarator = &text[statement_start..eq];
|
||||
let Some(empty) = declarator.rfind('[') else {
|
||||
continue;
|
||||
};
|
||||
let inside = &declarator[empty + 1..];
|
||||
let Some(close) = inside.find(']') else {
|
||||
continue;
|
||||
};
|
||||
if !inside[..close].trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let size = split_top(&text[paren + 1..paren_end]).len().to_string();
|
||||
// Sized in the constructor too, where it isn't.
|
||||
if text[bracket + 1..bracket_end].trim().is_empty() {
|
||||
text.replace_range(bracket + 1..bracket_end, &size);
|
||||
}
|
||||
let at = statement_start + empty + 1;
|
||||
text.replace_range(at..at + close, &size);
|
||||
// The declarator is before `=`, so `=` moved by what its size changed.
|
||||
from = eq + 1 + size.len() - close;
|
||||
closing = closings(&text);
|
||||
left -= 1;
|
||||
if left == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,291 @@
|
||||
//! The rewrites on GLSL a pack writes, with the text each gives written out by hand.
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_sampler_parameter_becomes_a_copy_per_sampler_passed() {
|
||||
let text = "vec4 blur(sampler2D tex, vec2 uv);\n\
|
||||
vec4 blur(sampler2D tex, vec2 uv) { return texture(tex, uv) + texture(tex, uv * 2.0); }\n\
|
||||
void main() { vec4 a = blur(Sampler0, uv); vec4 b = blur( Sampler1 , uv + 1.0); vec4 c = blur(Sampler0, uv); }\n";
|
||||
let out = specialize_sampler_params(text);
|
||||
assert!(!out.contains("sampler2D"), "{out}");
|
||||
assert!(out.contains(
|
||||
"vec4 blur_hz_Sampler0(vec2 uv) { return texture(Sampler0, uv) + texture(Sampler0, uv * 2.0); }"
|
||||
));
|
||||
assert!(out.contains("vec4 blur_hz_Sampler1(vec2 uv)"));
|
||||
assert!(out.contains("blur_hz_Sampler0(uv)"));
|
||||
assert!(out.contains("blur_hz_Sampler1(uv + 1.0)"));
|
||||
// One copy per sampler, not per call (and its prototype, from the prototype above).
|
||||
assert_eq!(out.matches("vec4 blur_hz_Sampler0(vec2 uv) {").count(), 1);
|
||||
assert_eq!(out.matches("vec4 blur_hz_Sampler0(vec2 uv);").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sampler_passed_on_through_another_function_is_followed() {
|
||||
let text = "vec4 inner(sampler2D t, vec2 uv) { return texture(t, uv); }\n\
|
||||
vec4 outer(vec2 uv, sampler2D s) { return inner(s, uv); }\n\
|
||||
void main() { vec4 a = outer(uv, Sampler2); }\n";
|
||||
let out = specialize_sampler_params(text);
|
||||
assert!(!out.contains("sampler2D"), "{out}");
|
||||
assert!(out.contains("outer_hz_Sampler2(uv)"), "{out}");
|
||||
assert!(out.contains("inner_hz_Sampler2(uv)"), "{out}");
|
||||
}
|
||||
|
||||
/// A call in another call's arguments is rewritten before the call around it, which is
|
||||
/// then read again: neither edit lands on the other's old text.
|
||||
#[test]
|
||||
fn a_call_nested_in_another_calls_arguments_is_rewritten_inside_out() {
|
||||
let text = "vec2 tap(sampler2D s, vec2 uv) { return texture(s, uv).xy; }\n\
|
||||
void main() { vec2 a = tap(Sampler0, tap(Sampler0, uv));\n\
|
||||
vec2 b = tap( Sampler1 , tap( Sampler0 , uv ) ); }\n";
|
||||
let out = specialize_sampler_params(text);
|
||||
assert!(!out.contains("sampler2D") && !out.contains("tap("), "{out}");
|
||||
assert!(
|
||||
out.contains("vec2 a = tap_hz_Sampler0(tap_hz_Sampler0(uv));"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("vec2 b = tap_hz_Sampler1(tap_hz_Sampler0(uv)); }"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(out.contains("vec2 tap_hz_Sampler0(vec2 uv) { return texture(Sampler0, uv).xy; }"));
|
||||
assert!(out.contains("vec2 tap_hz_Sampler1(vec2 uv) { return texture(Sampler1, uv).xy; }"));
|
||||
}
|
||||
|
||||
/// Overloads that differ past their sampler each keep their own body in their copies.
|
||||
#[test]
|
||||
fn overloads_with_a_sampler_each_keep_their_own_body() {
|
||||
let text = "vec4 tap(sampler2D s, vec2 p) { return texture(s, p); }\n\
|
||||
vec4 tap(sampler2D s, vec3 p) { return texture(s, p.xy / p.z); }\n\
|
||||
void main() { vec4 a = tap(Sampler0, uv); vec4 b = tap(Sampler0, vec3(uv, 2.0)); }\n";
|
||||
let out = specialize_sampler_params(text);
|
||||
assert!(!out.contains("sampler2D"), "{out}");
|
||||
assert!(
|
||||
out.contains("vec4 tap_hz_Sampler0(vec2 p) { return texture(Sampler0, p); }"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("vec4 tap_hz_Sampler0(vec3 p) { return texture(Sampler0, p.xy / p.z); }"),
|
||||
"{out}"
|
||||
);
|
||||
assert_eq!(out.matches("vec4 tap_hz_Sampler0(").count(), 2, "{out}");
|
||||
assert!(out.contains("vec4 a = tap_hz_Sampler0(uv);"), "{out}");
|
||||
assert!(
|
||||
out.contains("vec4 b = tap_hz_Sampler0(vec3(uv, 2.0));"),
|
||||
"{out}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A copy reads its samplers by name, so it goes after a sampler declared below the helper
|
||||
/// (vanilla's world `text.vsh` includes `sample_lightmap.glsl` above `Sampler2`).
|
||||
#[test]
|
||||
fn a_copy_goes_after_its_samplers_declaration() {
|
||||
let text = "vec4 tap(sampler2D s, vec2 uv) { return texture(s, uv); }\n\
|
||||
uniform sampler2D Sampler2;\n\
|
||||
void main() { vec4 c = tap(Sampler2, vec2(0.5)); }\n";
|
||||
let out = specialize_sampler_params(text);
|
||||
let declared = out
|
||||
.find("uniform sampler2D Sampler2;")
|
||||
.expect("declaration kept");
|
||||
let copy = out
|
||||
.find("vec4 tap_hz_Sampler2(vec2 uv)")
|
||||
.expect("copy written");
|
||||
let call = out
|
||||
.find("tap_hz_Sampler2(vec2(0.5))")
|
||||
.expect("call rewritten");
|
||||
assert!(declared < copy && copy < call, "{out}");
|
||||
assert_eq!(out.matches("tap_hz_Sampler2(vec2 uv)").count(), 1, "{out}");
|
||||
assert!(!out.contains("tap(sampler2D"), "{out}");
|
||||
}
|
||||
|
||||
/// A pack's text is untrusted: a sampler declared inside the helper itself (and text that
|
||||
/// isn't ASCII) gives edits that would overlap, so the shader is left as it is for the
|
||||
/// translator to refuse: never a panic, never a half-rewritten shader.
|
||||
#[test]
|
||||
fn a_sampler_declared_inside_the_helper_never_panics() {
|
||||
let text = "vec4 tap(sampler2D s, vec2 uv) { é uniform sampler2D Sampler0; x return texture(s, uv); ééé }\n\
|
||||
void main() { fragColor = tap(Sampler0, vec2(0.0)); }\n";
|
||||
let out = specialize_sampler_params(text);
|
||||
assert_eq!(out, text);
|
||||
}
|
||||
|
||||
/// Vanilla's `texture_sampling.glsl` shape: one overload calls the other with its own
|
||||
/// sampler. That call is rewritten too, and both copies come after the sampler's declaration
|
||||
/// in their own order, the called one first.
|
||||
#[test]
|
||||
fn an_overload_passing_its_sampler_to_another_is_followed() {
|
||||
let text = "vec4 sampleNearest(sampler2D source, vec2 uv, vec2 pixelSize, vec2 du, vec2 dv, vec2 texelSize) { return texture(source, uv + du + dv); }\n\
|
||||
vec4 sampleNearest(sampler2D source, vec2 uv, vec2 pixelSize) { return sampleNearest(source, uv, pixelSize, dFdx(uv), dFdy(uv), pixelSize); }\n\
|
||||
uniform sampler2D Sampler0;\n\
|
||||
void main() { vec4 c = sampleNearest(Sampler0, uv, px); }\n";
|
||||
let out = specialize_sampler_params(text);
|
||||
assert!(
|
||||
!out.contains("sampleNearest(") && !out.contains("sampler2D source"),
|
||||
"{out}"
|
||||
);
|
||||
let declared = out
|
||||
.find("uniform sampler2D Sampler0;")
|
||||
.expect("declaration");
|
||||
let six = out
|
||||
.find("vec4 sampleNearest_hz_Sampler0(vec2 uv, vec2 pixelSize, vec2 du, vec2 dv, vec2 texelSize)")
|
||||
.expect("six-argument copy");
|
||||
let three = out
|
||||
.find("vec4 sampleNearest_hz_Sampler0(vec2 uv, vec2 pixelSize) { return sampleNearest_hz_Sampler0(uv, pixelSize, dFdx(uv), dFdy(uv), pixelSize); }")
|
||||
.expect("three-argument copy calling the six-argument one");
|
||||
let call = out
|
||||
.find("vec4 c = sampleNearest_hz_Sampler0(uv, px);")
|
||||
.expect("call");
|
||||
assert!(declared < six && six < three && three < call, "{out}");
|
||||
}
|
||||
|
||||
/// A prototype above `main` with the definition below it becomes the copies' prototypes, so
|
||||
/// the call still has a declaration above it.
|
||||
#[test]
|
||||
fn a_prototype_becomes_the_copies_prototypes() {
|
||||
let text = "uniform sampler2D Sampler0;\n\
|
||||
vec4 tap(sampler2D s, vec2 uv);\n\
|
||||
void main() { vec4 c = tap(Sampler0, uv); }\n\
|
||||
vec4 tap(sampler2D s, vec2 uv) { return texture(s, uv); }\n";
|
||||
let out = specialize_sampler_params(text);
|
||||
let prototype = out
|
||||
.find("vec4 tap_hz_Sampler0(vec2 uv);")
|
||||
.expect("prototype copy");
|
||||
let call = out.find("tap_hz_Sampler0(uv); }").expect("call");
|
||||
let definition = out
|
||||
.find("vec4 tap_hz_Sampler0(vec2 uv) { return texture(Sampler0, uv); }")
|
||||
.expect("definition copy");
|
||||
assert!(prototype < call && call < definition, "{out}");
|
||||
}
|
||||
|
||||
/// Parameters named like the samplers they are passed, swapped, still read their own; and a
|
||||
/// parameter `s` leaves the swizzle `c.s` alone.
|
||||
#[test]
|
||||
fn sampler_parameters_are_replaced_at_once_and_not_as_members() {
|
||||
let text = "uniform sampler2D Sampler0;\nuniform sampler2D Sampler1;\n\
|
||||
vec4 mix2(sampler2D Sampler0, sampler2D Sampler1, vec2 uv) { return texture(Sampler0, uv) - texture(Sampler1, uv); }\n\
|
||||
vec4 swz(sampler2D s, vec2 uv) { vec4 c = texture(s, uv); return vec4(c.s, c.t, 0.0, 1.0); }\n\
|
||||
void main() { vec4 a = mix2(Sampler1, Sampler0, uv) + swz(Sampler0, uv); }\n";
|
||||
let out = specialize_sampler_params(text);
|
||||
assert!(
|
||||
out.contains("{ return texture(Sampler1, uv) - texture(Sampler0, uv); }"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("{ vec4 c = texture(Sampler0, uv); return vec4(c.s, c.t, 0.0, 1.0); }"),
|
||||
"{out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_parameter_matched_twice_in_one_identifier_stays_whole() {
|
||||
assert_eq!(replace_ident("c.ss + ss * s", "s", "t"), "c.ss + ss * t");
|
||||
assert_eq!(replace_ident("brightness(s)", "s", "t"), "brightness(t)");
|
||||
}
|
||||
|
||||
/// `fsh` translated beside a vertex shader that passes its position on.
|
||||
fn translate(fsh: &str) -> Result<crate::core_shader::CoreProgram, String> {
|
||||
let vsh = "#version 330\nin vec3 Position;\nout vec2 texCoord0;\n\
|
||||
void main() {\n gl_Position = vec4(Position, 1.0);\n texCoord0 = Position.xy;\n}\n";
|
||||
let glue = crate::core_shader::Glue {
|
||||
inputs: &[("Position", "vec3")],
|
||||
prelude: "layout(location = 0) in vec3 hz_position;",
|
||||
setup: "Position = hz_position;",
|
||||
finish: "",
|
||||
vertex_id: "int(gl_VertexIndex)",
|
||||
};
|
||||
crate::core_shader::translate_core(vsh, fsh, &[], &|_| None, &glue)
|
||||
}
|
||||
|
||||
/// #9386 end to end: naga refuses a helper taking the sampler it reads, as GLSL vanilla's GL
|
||||
/// compiler takes it; the pack's shader translates, the helper a copy reading `Sampler0`.
|
||||
#[test]
|
||||
fn a_helper_taking_its_sampler_translates() {
|
||||
let fsh = "#version 330\nuniform sampler2D Sampler0;\nin vec2 texCoord0;\nout vec4 fragColor;\n\
|
||||
vec4 tap(sampler2D tex, vec2 at, float w) { return texture(tex, at) * w; }\n\
|
||||
void main() {\n fragColor = tap(Sampler0, texCoord0, 0.5) + tap(Sampler0, texCoord0 + 0.01, 0.5);\n}\n";
|
||||
let program = translate(fsh).unwrap();
|
||||
let names: Vec<_> = program
|
||||
.fragment
|
||||
.functions
|
||||
.iter()
|
||||
.filter_map(|(_, f)| f.name.as_deref())
|
||||
.collect();
|
||||
assert!(names.contains(&"tap_hz_Sampler0"), "{names:?}");
|
||||
assert!(!names.contains(&"tap"), "{names:?}");
|
||||
}
|
||||
|
||||
/// A sampler parameter with no name used to hand `replace_ident` an empty name to replace,
|
||||
/// which it found at the same place forever, growing the text until the game ran out of memory.
|
||||
#[test]
|
||||
fn a_sampler_parameter_without_a_name_is_left_as_it_was() {
|
||||
let text = "uniform sampler2D Sampler0;\n\
|
||||
vec4 tap(sampler2D ;, vec2 uv) { return texture(Sampler0, uv); }\n\
|
||||
void main() { vec4 c = tap(Sampler0, uv); }\n";
|
||||
assert_eq!(fixups(text), text);
|
||||
assert_eq!(replace_ident("abc", "", "x"), "abc");
|
||||
}
|
||||
|
||||
/// Each call rewritten reads the whole text again: thousands of them stop at the scan budget,
|
||||
/// the shader left as it was (never half rewritten) instead of freezing the load.
|
||||
#[test]
|
||||
fn thousands_of_calls_are_left_as_they_were() {
|
||||
let shader = format!(
|
||||
"uniform sampler2D S;\nvec4 t(sampler2D s) {{ return texture(s, vec2(0.0)); }}\n\
|
||||
void main() {{ vec4 c; {}}}\n",
|
||||
"c = t(S);".repeat(5000)
|
||||
);
|
||||
assert_eq!(fixups(&shader), shader);
|
||||
// A few of them are rewritten.
|
||||
let few = shader.replace(&"c = t(S);".repeat(5000), &"c = t(S);".repeat(3));
|
||||
assert_eq!(
|
||||
fixups(&few).matches("c = t_hz_S(").count(),
|
||||
3,
|
||||
"{}",
|
||||
fixups(&few)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsized_arrays_take_their_initializers_size() {
|
||||
let text = "const float[] w = float[](0.25, 0.5, 0.25);\n\
|
||||
const vec2 offsets[] = vec2[](vec2(1.0, 0.0), vec2(0.0, 1.0));\n\
|
||||
float k[] = float[4](1.0, 2.0, 3.0, 4.0);\n\
|
||||
bool same = a == b;\n";
|
||||
let out = size_unsized_arrays(text);
|
||||
assert!(
|
||||
out.contains("const float[3] w = float[3](0.25, 0.5, 0.25);"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("const vec2 offsets[2] = vec2[2](vec2(1.0, 0.0), vec2(0.0, 1.0));"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("float k[4] = float[4](1.0, 2.0, 3.0, 4.0);"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(out.contains("bool same = a == b;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wide_character_after_a_sized_array_never_panics() {
|
||||
// Ten values make the size two digits, which moves the `=` two bytes right: the scan
|
||||
// went on from where it used to be, inside the `é`.
|
||||
let text = "float w[]\u{e9}= float[](0.,1.,2.,3.,4.,5.,6.,7.,8.,9.);\n";
|
||||
assert_eq!(
|
||||
size_unsized_arrays(text),
|
||||
"float w[10]\u{e9}= float[10](0.,1.,2.,3.,4.,5.,6.,7.,8.,9.);\n"
|
||||
);
|
||||
}
|
||||
|
||||
/// #9386 end to end: naga refuses an array sized by its initializer only.
|
||||
#[test]
|
||||
fn an_array_sized_by_its_initializer_translates() {
|
||||
let fsh = "#version 330\nuniform sampler2D Sampler0;\nin vec2 texCoord0;\nout vec4 fragColor;\n\
|
||||
const float[] weights = float[](0.25, 0.5, 0.25);\n\
|
||||
void main() {\n vec4 sum = vec4(0.0);\n\
|
||||
for (int i = 0; i < 3; i++) {\n sum += texture(Sampler0, texCoord0 + float(i) * 0.01) * weights[i];\n }\n\
|
||||
fragColor = sum;\n}\n";
|
||||
let program = translate(fsh);
|
||||
assert!(program.is_ok(), "{:?}", program.err());
|
||||
}
|
||||
@@ -439,20 +439,33 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The report for a hung server tick, written by the watchdog thread
|
||||
/// (`ServerWatchdog`): the stuck thread's stack is not available to Rust, so
|
||||
/// the report carries none.
|
||||
pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Option<PathBuf> {
|
||||
/// The report for a hung server tick, written by the watchdog thread (`ServerWatchdog`): the
|
||||
/// stack is the server thread's, read from the watchdog (`horizon_threads::stack_dump`), as
|
||||
/// vanilla's report carries the server thread's stack; without it, the details say why.
|
||||
pub fn report_watchdog(
|
||||
home: &Path,
|
||||
side: &'static str,
|
||||
message: String,
|
||||
server_stack: Result<String, horizon_threads::stack_dump::NoStack>,
|
||||
) -> Option<PathBuf> {
|
||||
let mut details = system_details(side);
|
||||
let stack = match server_stack {
|
||||
Ok(stack) => stack_lines(&stack, false),
|
||||
Err(why) => {
|
||||
details.push(("Server thread stack".into(), why.to_string()));
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
deliver(
|
||||
home,
|
||||
Report {
|
||||
time: Local::now(),
|
||||
description: "Watching Server".into(),
|
||||
error: format!("{message} (Watching Server)"),
|
||||
stack: String::new(),
|
||||
stack,
|
||||
thread: thread_name(side),
|
||||
side,
|
||||
details: system_details(side),
|
||||
details,
|
||||
log: crate::logging::recent_lines(),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -155,7 +155,8 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
var back = c;
|
||||
// Normal w: 0 keeps the baked colour, +-127 shades by the normal, and a small positive 1
|
||||
// keeps the baked colour but culls back faces (entitySolid models such as the decorated pot).
|
||||
// -126/127 shades like -127 and also culls (entityCutoutCull models such as the bat).
|
||||
// -126/127 shades like -127 and also culls (entityCutoutCull models such as the bat, and the
|
||||
// entitySolid decorated pot).
|
||||
out.cull = select(0u, 1u, (normal.w > 0.0 && normal.w < 0.5) || (normal.w < 0.0 && normal.w > -0.996));
|
||||
if abs(normal.w) >= 0.5 {
|
||||
c = vec4(c.rgb * entity_light(normal.xyz, globals.time.y != 0.0), c.a);
|
||||
|
||||
@@ -37,7 +37,9 @@
|
||||
//! are drawn in a pass of their own between the phases, which reads phase
|
||||
//! 1's depth as a texture and makes the same comparison in the shader: the
|
||||
//! fragment's depth against the stored one, greater or equal passing. The
|
||||
//! same fragments pass, so the same sections are seen.
|
||||
//! same fragments pass, so the same sections are seen. Adreno's Vulkan driver
|
||||
//! takes that path too: with the early test it now and then leaves sections
|
||||
//! out for a frame.
|
||||
use std::num::NonZeroU64;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
@@ -220,17 +222,42 @@ const RESULTS_USAGE: wgpu::BufferUsages =
|
||||
const READBACK_USAGE: wgpu::BufferUsages =
|
||||
wgpu::BufferUsages::MAP_READ.union(wgpu::BufferUsages::COPY_DST);
|
||||
|
||||
/// The first `n` values of a packed lookup in WGSL `source` (`fn name`, as cull.wgsl's
|
||||
/// `box_triangle_corner`): four bits each, eight a word, its words in the order of its cases.
|
||||
#[cfg(test)]
|
||||
pub(super) fn packed_lookup(source: &str, name: &str, n: usize) -> Vec<u32> {
|
||||
let body = &source[source.find(&format!("fn {name}(")).expect("the lookup")..];
|
||||
let body = &body[..body.find("return").expect("its return")];
|
||||
let words: Vec<u32> = body
|
||||
.split(|c: char| !c.is_ascii_alphanumeric())
|
||||
.filter_map(|t| t.strip_prefix("0x")?.strip_suffix('u'))
|
||||
.map(|hex| u32::from_str_radix(hex, 16).expect("a hex word"))
|
||||
.collect();
|
||||
(0..n)
|
||||
.map(|v| (words[v / 8] >> (v % 8 * 4)) & 0xf)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Qualcomm's PCI vendor id, which wgpu reports for Adreno GPUs.
|
||||
const ADRENO: u32 = 0x5143;
|
||||
|
||||
impl Cull {
|
||||
/// The device feature culling needs, where the adapter has it.
|
||||
pub fn features(adapter: &wgpu::Adapter) -> wgpu::Features {
|
||||
adapter.features() & wgpu::Features::SHADER_EARLY_DEPTH_TEST
|
||||
}
|
||||
/// Where the device can run the test, if it can: Vulkan with early
|
||||
/// depth tests, or Metal and DirectX 12 with the test in the shader, all
|
||||
/// drawing an indirect multi-draw's entries with the instance counts the
|
||||
/// GPU wrote, with fragment shaders that write storage.
|
||||
/// depth tests (Adreno's in the shader), or Metal and DirectX 12 with the
|
||||
/// test in the shader, all drawing an indirect multi-draw's entries with
|
||||
/// the instance counts the GPU wrote, with fragment shaders that write
|
||||
/// storage.
|
||||
pub fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> Option<DepthTest> {
|
||||
let depth_test = match adapter.get_info().backend {
|
||||
let info = adapter.get_info();
|
||||
let depth_test = match info.backend {
|
||||
// Adreno's Vulkan driver (the AYANEO Pocket DS's Adreno 740, build 0676.41) now and
|
||||
// then leaves sections out with the early test, the world flashing black or
|
||||
// see-through as a block breaks; the test in the shader, as on Metal, holds.
|
||||
wgpu::Backend::Vulkan if info.vendor == ADRENO => DepthTest::InShader,
|
||||
wgpu::Backend::Vulkan | wgpu::Backend::Gl
|
||||
if device
|
||||
.features()
|
||||
@@ -772,4 +799,20 @@ mod tests {
|
||||
.unwrap_or_else(|e| panic!("{depth_test:?}: {e:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The box's 12 triangles, as cull.wgsl and gpu_occlusion.wgsl draw them: two per face,
|
||||
/// counter-clockwise seen from outside, corners by bit (x 1, y 2, z 4).
|
||||
#[test]
|
||||
fn the_packed_box_corners_are_the_box_triangles() {
|
||||
let triangles = [
|
||||
0, 6, 2, 0, 4, 6, 1, 3, 7, 1, 7, 5, 0, 1, 5, 0, 5, 4, 2, 7, 3, 2, 6, 7, 0, 3, 1, 0, 2,
|
||||
3, 4, 5, 7, 4, 7, 6,
|
||||
];
|
||||
for source in [
|
||||
include_str!("cull.wgsl"),
|
||||
include_str!("gpu_occlusion.wgsl"),
|
||||
] {
|
||||
assert_eq!(packed_lookup(source, "box_triangle_corner", 36), triangles);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,11 +34,21 @@ struct Test {
|
||||
@group(0) @binding(5) var<uniform> params: Params;
|
||||
|
||||
// The corners (bit 0: x, bit 1: y, bit 2: z) of the box's 12 triangles,
|
||||
// counter-clockwise seen from outside.
|
||||
const CORNERS = array<u32, 36>(
|
||||
0u, 6u, 2u, 0u, 4u, 6u, 1u, 3u, 7u, 1u, 7u, 5u, 0u, 1u, 5u, 0u, 5u, 4u,
|
||||
2u, 7u, 3u, 2u, 6u, 7u, 0u, 3u, 1u, 0u, 2u, 3u, 4u, 5u, 7u, 4u, 7u, 6u,
|
||||
);
|
||||
// counter-clockwise seen from outside: 0 6 2, 0 4 6, 1 3 7, 1 7 5, 0 1 5, 0 5 4,
|
||||
// 2 7 3, 2 6 7, 0 3 1, 0 2 3, 4 5 7, 4 7 6.
|
||||
// Packed four bits each, eight a word, read through a switch: a constant array indexed by the
|
||||
// vertex would be a private array, which Adreno's Vulkan compiler (build 0676.41) fails to link.
|
||||
fn box_triangle_corner(v: u32) -> u32 {
|
||||
var word: u32;
|
||||
switch v / 8u {
|
||||
case 0u: { word = 0x31640260u; }
|
||||
case 1u: { word = 0x05105717u; }
|
||||
case 2u: { word = 0x76237245u; }
|
||||
case 3u: { word = 0x54320130u; }
|
||||
default: { word = 0x00006747u; }
|
||||
}
|
||||
return (word >> (v % 8u * 4u)) & 0xfu;
|
||||
}
|
||||
|
||||
struct Box {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
@@ -95,7 +105,7 @@ fn vs_box(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box
|
||||
out.position = select(vec4(0.0, 0.0, 0.0, 1.0), vec4(p, 1.0, 1.0), v < 3u);
|
||||
return out;
|
||||
}
|
||||
out.position = globals.view_proj * vec4(corner(origin, low, high, CORNERS[v]), 1.0);
|
||||
out.position = globals.view_proj * vec4(corner(origin, low, high, box_triangle_corner(v)), 1.0);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,11 +9,20 @@
|
||||
@group(0) @binding(12) var<storage, read> box_sections: array<Section>;
|
||||
|
||||
// The corners (bit 0: x, bit 1: y, bit 2: z) of the box's 12 triangles,
|
||||
// counter-clockwise seen from outside (cull.wgsl's).
|
||||
const CORNERS = array<u32, 36>(
|
||||
0u, 6u, 2u, 0u, 4u, 6u, 1u, 3u, 7u, 1u, 7u, 5u, 0u, 1u, 5u, 0u, 5u, 4u,
|
||||
2u, 7u, 3u, 2u, 6u, 7u, 0u, 3u, 1u, 0u, 2u, 3u, 4u, 5u, 7u, 4u, 7u, 6u,
|
||||
);
|
||||
// counter-clockwise seen from outside (cull.wgsl's `box_triangle_corner`).
|
||||
// Packed four bits each, eight a word, read through a switch: a constant array indexed by the
|
||||
// vertex would be a private array, which Adreno's Vulkan compiler (build 0676.41) fails to link.
|
||||
fn box_triangle_corner(v: u32) -> u32 {
|
||||
var word: u32;
|
||||
switch v / 8u {
|
||||
case 0u: { word = 0x31640260u; }
|
||||
case 1u: { word = 0x05105717u; }
|
||||
case 2u: { word = 0x76237245u; }
|
||||
case 3u: { word = 0x54320130u; }
|
||||
default: { word = 0x00006747u; }
|
||||
}
|
||||
return (word >> (v % 8u * 4u)) & 0xfu;
|
||||
}
|
||||
|
||||
struct BoxOut {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
@@ -26,7 +35,7 @@ fn vs_box(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box
|
||||
let o = bitcast<vec3<f32>>(box_origins[id].xyz);
|
||||
var out: BoxOut;
|
||||
out.id = id;
|
||||
out.position = params.view_proj * vec4(box_corner(o, box_sections[id].a.z, CORNERS[v]), 1.0);
|
||||
out.position = params.view_proj * vec4(box_corner(o, box_sections[id].a.z, box_triangle_corner(v)), 1.0);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@ const DISTANT_SQR: f64 = 3600.0;
|
||||
const SECTION_DIAGONAL: f64 = 28.0;
|
||||
/// In `Graph::rays`: the line from this corner stays in reached sections.
|
||||
const SEES: u8 = u8::MAX;
|
||||
/// Edits kept before a full resend.
|
||||
const EDIT_CAP: usize = 1 << 16;
|
||||
|
||||
/// VisGraph connectivity per column, one entry per section (bottom first).
|
||||
/// A missing column or section connects every face.
|
||||
@@ -611,6 +613,8 @@ pub struct Occlusion {
|
||||
queue: VecDeque<u32>,
|
||||
/// Changes whenever `contains` may answer differently (`generation`).
|
||||
generation: u64,
|
||||
/// Set when edits were dropped; the next search resends every column.
|
||||
resync: bool,
|
||||
}
|
||||
|
||||
impl Occlusion {
|
||||
@@ -631,6 +635,7 @@ impl Occlusion {
|
||||
vis: Vec::new(),
|
||||
queue: VecDeque::new(),
|
||||
generation: next_generation(),
|
||||
resync: false,
|
||||
}
|
||||
}
|
||||
pub fn set(&mut self, key: SectionKey, vis: u64) {
|
||||
@@ -639,13 +644,35 @@ impl Occlusion {
|
||||
}
|
||||
}
|
||||
fn changed(&mut self, key: SectionKey, vis: u64) {
|
||||
self.edits.push(Edit::Set(key, vis));
|
||||
self.dirty = true;
|
||||
if self.edits.len() >= EDIT_CAP || self.changed.len() >= EDIT_CAP {
|
||||
// nobody drains these with cave culling off
|
||||
self.resync = true;
|
||||
self.edits.clear();
|
||||
self.changed.clear();
|
||||
self.changed_late.clear();
|
||||
}
|
||||
if self.resync {
|
||||
return;
|
||||
}
|
||||
self.edits.push(Edit::Set(key, vis));
|
||||
self.changed.push(key);
|
||||
if self.running.is_some() {
|
||||
self.changed_late.push(key);
|
||||
}
|
||||
}
|
||||
fn resync_edits(&mut self) {
|
||||
self.resync = false;
|
||||
self.edits.clear();
|
||||
self.edits.push(Edit::Clear);
|
||||
for (&(x, z), column) in &self.columns.map {
|
||||
for (y, &vis) in column.iter().enumerate() {
|
||||
if vis != ALL_VISIBLE {
|
||||
self.edits.push(Edit::Set((x, y as i32, z), vis));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Every section of the column `x`, `z` goes (sees through every face).
|
||||
pub fn remove_column(&mut self, x: i32, z: i32) {
|
||||
let Some(column) = self.columns.map.remove(&(x, z)) else {
|
||||
@@ -674,6 +701,7 @@ impl Occlusion {
|
||||
self.columns.map.clear();
|
||||
self.edits.clear();
|
||||
self.edits.push(Edit::Clear);
|
||||
self.resync = false;
|
||||
self.dirty = true;
|
||||
// Another world: the old graph says nothing about it. Without one, every
|
||||
// section was visible already, and stays so.
|
||||
@@ -769,6 +797,9 @@ impl Occlusion {
|
||||
let from = self.columns.section_of(camera);
|
||||
self.dirty = false;
|
||||
self.searched = Some((from, radius));
|
||||
if self.resync {
|
||||
self.resync_edits();
|
||||
}
|
||||
let graph = std::mem::take(&mut self.spare);
|
||||
if let Some(worker) = &self.worker {
|
||||
let job = Job {
|
||||
@@ -1793,4 +1824,36 @@ mod tests {
|
||||
assert_ne!(answers(&o, around, spread + radius), walled.1);
|
||||
assert_ne!(o.generation(), walled.0, "the graph went");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_queue_stays_bounded_without_searches() {
|
||||
let mut o = Occlusion::new(0, 4);
|
||||
let total = (1usize << 16) + 5000;
|
||||
for i in 0..total {
|
||||
o.set(((i % 700) as i32, 1, (i / 700) as i32), 1 + (i as u64 % 5));
|
||||
}
|
||||
assert!(o.edits.len() <= 1 << 16, "{} edits queued", o.edits.len());
|
||||
assert!(
|
||||
o.changed.len() <= 1 << 16,
|
||||
"{} changed queued",
|
||||
o.changed.len()
|
||||
);
|
||||
|
||||
let mut fresh = Occlusion::new(0, 4);
|
||||
for (&(x, z), column) in &o.columns.map {
|
||||
for (y, &vis) in column.iter().enumerate() {
|
||||
fresh.set((x, y as i32, z), vis);
|
||||
}
|
||||
}
|
||||
let camera = at((0, 1, 0));
|
||||
for _ in 0..2 {
|
||||
o.settle(camera, 6);
|
||||
fresh.settle(camera, 6);
|
||||
}
|
||||
assert_eq!(
|
||||
reached(&o),
|
||||
reached(&fresh),
|
||||
"the worker saw the same world"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use horizon_shaders::{BufferObject, CustomImage, Format, Kind};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
pub(super) struct PackImage {
|
||||
pub(super) def: CustomImage,
|
||||
@@ -468,19 +469,33 @@ impl Buffers {
|
||||
.iter()
|
||||
.map(|(binding, o)| {
|
||||
let least = self.minimums.get(binding).copied().unwrap_or(0);
|
||||
let wanted = buffer_size(*o, size, least);
|
||||
let wanted = buffer_size(o, size, least);
|
||||
if wanted > largest {
|
||||
log::warn!(
|
||||
"shader pack: bufferObject.{binding} wants {wanted} bytes, this device \
|
||||
binds at most {largest}"
|
||||
);
|
||||
}
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack buffer"),
|
||||
size: wanted.min(largest),
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
let size = wanted.min(largest);
|
||||
// A buffer the pack initializes from a file starts with the file's bytes, the
|
||||
// rest of it zero.
|
||||
match o.initial_data() {
|
||||
Some(data) => {
|
||||
let mut contents = data.to_vec();
|
||||
contents.resize(size as usize, 0);
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Shader pack buffer"),
|
||||
contents: &contents,
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
})
|
||||
}
|
||||
None => device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack buffer"),
|
||||
size,
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let entries: Vec<_> = self
|
||||
@@ -509,7 +524,7 @@ impl Buffers {
|
||||
/// The bytes a storage buffer is made with for a render size of `render`: what the pack
|
||||
/// declares, or more when a shader binds it as a larger block (`least`), which wgpu needs where
|
||||
/// OpenGL binds the smaller buffer; a multiple of 4.
|
||||
fn buffer_size(object: BufferObject, render: [u32; 2], least: u64) -> u64 {
|
||||
fn buffer_size(object: &BufferObject, render: [u32; 2], least: u64) -> u64 {
|
||||
object.size(render).max(least).next_multiple_of(4)
|
||||
}
|
||||
|
||||
@@ -612,18 +627,18 @@ mod tests {
|
||||
/// bound as a 48-byte block failed wgpu's validation, #7178); a larger one keeps its size.
|
||||
#[test]
|
||||
fn a_buffer_is_at_least_its_shaders_block() {
|
||||
assert_eq!(buffer_size(BufferObject::Bytes(40), [1, 1], 48), 48);
|
||||
assert_eq!(buffer_size(&BufferObject::Bytes(40), [1, 1], 48), 48);
|
||||
assert_eq!(
|
||||
buffer_size(BufferObject::Bytes(40980), [1, 1], 40992),
|
||||
buffer_size(&BufferObject::Bytes(40980), [1, 1], 40992),
|
||||
40992
|
||||
);
|
||||
assert_eq!(buffer_size(BufferObject::Bytes(100), [1, 1], 48), 100);
|
||||
assert_eq!(buffer_size(BufferObject::Bytes(41), [1, 1], 0), 44);
|
||||
assert_eq!(buffer_size(&BufferObject::Bytes(100), [1, 1], 48), 100);
|
||||
assert_eq!(buffer_size(&BufferObject::Bytes(41), [1, 1], 0), 44);
|
||||
let per_pixel = BufferObject::PerPixel {
|
||||
bytes: 4,
|
||||
scale: [0.5, 0.5],
|
||||
};
|
||||
assert_eq!(buffer_size(per_pixel, [10, 10], 64), 100);
|
||||
assert_eq!(buffer_size(per_pixel, [4, 4], 64), 64);
|
||||
assert_eq!(buffer_size(&per_pixel, [10, 10], 64), 100);
|
||||
assert_eq!(buffer_size(&per_pixel, [4, 4], 64), 64);
|
||||
}
|
||||
}
|
||||
@@ -399,6 +399,20 @@ impl Rotation {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// CubeMap's cube: its corners' indices, six faces of two triangles.
|
||||
#[test]
|
||||
fn the_packed_cube_corners_are_cubemaps_indices() {
|
||||
let indices = [
|
||||
4, 7, 6, 4, 6, 5, 5, 6, 2, 5, 2, 1, 1, 2, 3, 1, 3, 0, 0, 3, 7, 0, 7, 4, 7, 3, 2, 7, 2,
|
||||
6, 0, 4, 5, 0, 5, 1,
|
||||
];
|
||||
let source = include_str!("panorama.wgsl");
|
||||
assert_eq!(
|
||||
super::super::cull::packed_lookup(source, "cube_corner", 36),
|
||||
indices
|
||||
);
|
||||
}
|
||||
use std::{
|
||||
fs,
|
||||
sync::atomic::{AtomicU32, Ordering},
|
||||
|
||||
@@ -8,19 +8,27 @@ struct Out {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) direction: vec3<f32>,
|
||||
}
|
||||
// The cube's corner of each of its 36 vertices (CubeMap's indices): 4 7 6, 4 6 5, 5 6 2,
|
||||
// 5 2 1, 1 2 3, 1 3 0, 0 3 7, 0 7 4, 7 3 2, 7 2 6, 0 4 5, 0 5 1.
|
||||
// Packed four bits each, eight a word, read through a switch: a constant array indexed by the
|
||||
// vertex would be a private array, which Adreno's Vulkan compiler (build 0676.41) fails to link.
|
||||
fn cube_corner(v: u32) -> u32 {
|
||||
var word: u32;
|
||||
switch v / 8u {
|
||||
case 0u: { word = 0x65564674u; }
|
||||
case 1u: { word = 0x13211252u; }
|
||||
case 2u: { word = 0x47073003u; }
|
||||
case 3u: { word = 0x40627237u; }
|
||||
default: { word = 0x00001505u; }
|
||||
}
|
||||
return (word >> (v % 8u * 4u)) & 0xfu;
|
||||
}
|
||||
@vertex fn vs_panorama(@builtin(vertex_index) index: u32) -> Out {
|
||||
let corners = array<vec3<f32>, 8>(
|
||||
vec3(-1.0, -1.0, -1.0), vec3(1.0, -1.0, -1.0),
|
||||
vec3(1.0, 1.0, -1.0), vec3(-1.0, 1.0, -1.0),
|
||||
vec3(-1.0, -1.0, 1.0), vec3(1.0, -1.0, 1.0),
|
||||
vec3(1.0, 1.0, 1.0), vec3(-1.0, 1.0, 1.0),
|
||||
);
|
||||
let indices = array<u32, 36>(
|
||||
4, 7, 6, 4, 6, 5, 5, 6, 2, 5, 2, 1,
|
||||
1, 2, 3, 1, 3, 0, 0, 3, 7, 0, 7, 4,
|
||||
7, 3, 2, 7, 2, 6, 0, 4, 5, 0, 5, 1,
|
||||
);
|
||||
let position = corners[indices[index]];
|
||||
// CubeMap's corners: -1 or 1 on each axis, x 1 at corners 1, 2, 5 and 6, y at 2, 3, 6
|
||||
// and 7, z at 4 to 7.
|
||||
let c = cube_corner(index);
|
||||
let high = vec3((c ^ (c >> 1u)) & 1u, c & 2u, c & 4u) != vec3(0u);
|
||||
let position = select(vec3(-1.0), vec3(1.0), high);
|
||||
var out: Out;
|
||||
out.clip = projection.view_proj * vec4(position, 1.0);
|
||||
out.direction = position;
|
||||
|
||||
@@ -12,8 +12,11 @@ use std::{
|
||||
};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use model_reducing::reducible_model_sprites;
|
||||
|
||||
mod animate;
|
||||
pub mod cit_glint;
|
||||
mod model_reducing;
|
||||
mod pack_text;
|
||||
pub mod pictures;
|
||||
pub(crate) mod sprite_modifiers;
|
||||
@@ -1057,25 +1060,6 @@ impl HudAtlas {
|
||||
blinks: Default::default(),
|
||||
}
|
||||
}
|
||||
/// `layout`, but when `extras` (Custom Item Textures) make the images more than `max`
|
||||
/// holds, without them: they are left out, not the packs.
|
||||
fn layout_leaving_out(
|
||||
images: &mut Vec<PackedImage>,
|
||||
animated: &mut Vec<String>,
|
||||
extras: &HashSet<String>,
|
||||
max: u32,
|
||||
) -> Result<(u32, Vec<(u32, u32)>, u32)> {
|
||||
match Self::layout(images, max) {
|
||||
Err(e) if !extras.is_empty() => {
|
||||
images.retain(|i| !extras.contains(&i.0));
|
||||
animated.retain(|n| !extras.contains(n));
|
||||
let layout = Self::layout(images, max)?;
|
||||
log::warn!("left out the custom item textures: {e}");
|
||||
Ok(layout)
|
||||
}
|
||||
layout => layout,
|
||||
}
|
||||
}
|
||||
/// Shelf placements of `images` (tallest first) in the narrowest atlas
|
||||
/// width from 4096 up that keeps the atlas, glyph rows included, within
|
||||
/// `max` texels a side; and the first row free for glyphs. High-resolution
|
||||
@@ -1460,7 +1444,20 @@ impl HudAtlas {
|
||||
}
|
||||
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
|
||||
let (width, placements, glyph_region) =
|
||||
Self::layout_leaving_out(&mut images, &mut animated, &cit_names, MAX_ATLAS_SIZE)?;
|
||||
Self::layout_leaving_out(&mut images, &mut animated, &cit_names, MAX_ATLAS_SIZE)
|
||||
.or_else(|error| -> Result<_> {
|
||||
let models = reducible_model_sprites(root, &images, &bordered);
|
||||
let layout = Self::layout_reducing_models(
|
||||
&mut images,
|
||||
&mut gui_sizes,
|
||||
&models,
|
||||
MAX_ATLAS_SIZE,
|
||||
)?;
|
||||
log::warn!(
|
||||
"reduced HUD model texture resolution to keep the resource pack: {error}"
|
||||
);
|
||||
Ok(layout)
|
||||
})?;
|
||||
// A multiple of 1024 rather than a power of two: the default resources
|
||||
// need 4096x5120 (8192 rows before), Faithful 32x 4096x12288 (16384).
|
||||
let height = (glyph_region + GLYPH_ROWS).next_multiple_of(1024);
|
||||
@@ -3778,6 +3775,47 @@ mod tests {
|
||||
assert_eq!(banner.texels((atlas.width, atlas.height)), (16, 32));
|
||||
}
|
||||
#[test]
|
||||
fn a_root_single_sprite_strip_taller_than_the_atlas_packs_one_frame() {
|
||||
let root = animated_root();
|
||||
let atlases = root.join("assets/minecraft/atlases");
|
||||
fs::create_dir_all(&atlases).unwrap();
|
||||
fs::write(
|
||||
atlases.join("items.json"),
|
||||
r#"{"sources":[{"type":"single","resource":"nexo:abc123"},
|
||||
{"type":"single","resource":"nexo:still"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
// Plugin-generated names need no item/ prefix. Keep the production
|
||||
// strip's height, but use narrow frames to keep this fixture cheap.
|
||||
let textures = root.join("assets/nexo/textures");
|
||||
fs::create_dir_all(&textures).unwrap();
|
||||
write_strip(
|
||||
&textures.join("abc123.png"),
|
||||
16,
|
||||
32768,
|
||||
&[[255, 0, 0, 255], [0, 0, 255, 255]],
|
||||
Some(r#"{"animation":{"frames":[1024,0],"frametime":3}}"#),
|
||||
);
|
||||
write_strip(&textures.join("still.png"), 16, 32, &[[7, 8, 9, 255]], None);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let rect = atlas.sprites["nexo:abc123"];
|
||||
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16));
|
||||
assert_eq!(texel_at(&atlas, rect, 0, 15), [0, 0, 255, 255]);
|
||||
let (_, animation) = atlas
|
||||
.sprite_animations
|
||||
.iter()
|
||||
.find(|(r, _)| r.u0 == rect.u0 && r.v0 == rect.v0)
|
||||
.expect("the root-level single sprite animates");
|
||||
assert_eq!(animation.frame(0), 1024);
|
||||
assert_eq!(animation.frame(3), 0);
|
||||
assert_eq!(animation.frame_count(), 2048);
|
||||
assert_eq!(
|
||||
atlas.sprites["nexo:still"].texels((atlas.width, atlas.height)),
|
||||
(16, 32)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn an_animated_gui_sprite_packs_its_first_frame_and_animates() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-atlas-gui-anim-{}", std::process::id()));
|
||||
@@ -4184,6 +4222,93 @@ texture=wide",
|
||||
assert_inside(&images, width, &placements, free);
|
||||
}
|
||||
#[test]
|
||||
fn model_resolution_fallback_protects_font_sources_and_gui_aliases() {
|
||||
let root = animated_root();
|
||||
write_png(&root, "item/font_sheet", 32, 32);
|
||||
write_png(&root, "gui/panel", 32, 32);
|
||||
write_png(&root, "hash", 32, 32);
|
||||
let font = root.join("assets/minecraft/font/default.json");
|
||||
fs::create_dir_all(font.parent().unwrap()).unwrap();
|
||||
fs::write(
|
||||
font,
|
||||
r#"{"providers":[{"type":"bitmap","file":"minecraft:item/font_sheet.png","ascent":7,"chars":["a"]}]}"#,
|
||||
).unwrap();
|
||||
let atlases = root.join("assets/minecraft/atlases");
|
||||
fs::create_dir_all(&atlases).unwrap();
|
||||
fs::write(
|
||||
atlases.join("items.json"),
|
||||
r#"{"sources":[
|
||||
{"type":"single","resource":"item/font_sheet","sprite":"font_alias"},
|
||||
{"type":"single","resource":"gui/panel","sprite":"gui_alias"},
|
||||
{"type":"single","resource":"hash"}
|
||||
]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let images = vec![
|
||||
("item/font_sheet".to_owned(), 32, 32, Vec::new()),
|
||||
("item/blurred".to_owned(), 32, 32, Vec::new()),
|
||||
("block/stone".to_owned(), 32, 32, Vec::new()),
|
||||
];
|
||||
let models =
|
||||
reducible_model_sprites(&root, &images, &HashSet::from(["item/blurred".to_owned()]));
|
||||
let _ = fs::remove_dir_all(root);
|
||||
assert_eq!(
|
||||
models,
|
||||
HashSet::from(["hash".to_owned(), "block/stone".to_owned()])
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn overflowing_model_textures_shrink_without_changing_gui_sizes_or_slots() {
|
||||
let mut images = vec![
|
||||
(
|
||||
"nexo:hash".into(),
|
||||
1024,
|
||||
1024,
|
||||
[10, 20, 30, 255].repeat(1024 * 1024),
|
||||
),
|
||||
(
|
||||
"gui/panel".into(),
|
||||
32,
|
||||
32,
|
||||
[40, 50, 60, 255].repeat(32 * 32),
|
||||
),
|
||||
("horizon/map".into(), 128, 128, Vec::new()),
|
||||
];
|
||||
let models = HashSet::from(["nexo:hash".to_owned(), "horizon/map".to_owned()]);
|
||||
let mut sizes = HashMap::new();
|
||||
assert!(HudAtlas::layout(&images, 1024).is_err());
|
||||
let (width, placements, free) =
|
||||
HudAtlas::layout_reducing_models(&mut images, &mut sizes, &models, 1024).unwrap();
|
||||
assert_inside(&images, width, &placements, free);
|
||||
assert!(free + GLYPH_ROWS <= 1024);
|
||||
let model = images.iter().find(|i| i.0 == "nexo:hash").unwrap();
|
||||
assert_eq!((model.1, model.2), (256, 256));
|
||||
assert_eq!(model.3, [10, 20, 30, 255].repeat(256 * 256));
|
||||
assert_eq!(sizes["nexo:hash"], (1024, 1024));
|
||||
let gui = images.iter().find(|i| i.0 == "gui/panel").unwrap();
|
||||
assert_eq!((gui.1, gui.2), (32, 32));
|
||||
assert_eq!(gui.3, [40, 50, 60, 255].repeat(32 * 32));
|
||||
let slot = images.iter().find(|i| i.0 == "horizon/map").unwrap();
|
||||
assert_eq!((slot.1, slot.2), (128, 128));
|
||||
assert!(slot.3.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn fitting_models_keep_their_pixels_and_existing_logical_sizes() {
|
||||
let mut images = vec![("item/gem".into(), 32, 32, [1, 2, 3, 255].repeat(32 * 32))];
|
||||
let original = images.clone();
|
||||
let mut sizes = HashMap::from([("item/gem".to_owned(), (16, 16))]);
|
||||
let models = HashSet::from(["item/gem".to_owned()]);
|
||||
HudAtlas::layout_reducing_models(&mut images, &mut sizes, &models, 1024).unwrap();
|
||||
assert_eq!(images, original);
|
||||
assert_eq!(sizes["item/gem"], (16, 16));
|
||||
// Protected oversized images remain an error; no unbounded retry or
|
||||
// silent removal of GUI/font resources when shrinking cannot help.
|
||||
images.push(("font/sheet".into(), 1024, 1024, Vec::new()));
|
||||
assert!(HudAtlas::layout_reducing_models(&mut images, &mut sizes, &models, 1024).is_err());
|
||||
assert_eq!((images[0].1, images[0].2), (1024, 1024));
|
||||
assert_eq!(sizes["item/gem"], (16, 16));
|
||||
}
|
||||
#[test]
|
||||
fn textures_that_cannot_fit_are_an_error_not_a_gpu_panic() {
|
||||
let error = HudAtlas::layout(&sized(300, 1024, 1024), MAX_ATLAS_SIZE).unwrap_err();
|
||||
assert!(error.to_string().contains("do not fit a 16384x16384 atlas"));
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Resolution fallback for model textures that exceed the shared HUD atlas.
|
||||
use super::{HashMap, HashSet, HudAtlas, PackedImage, Path, Result, bitmap_font_textures};
|
||||
|
||||
/// Only model atlas sprites may lose resolution. Font sheets can also be
|
||||
/// model textures, so protect both their file names and renamed sources.
|
||||
pub(super) fn reducible_model_sprites(
|
||||
root: &Path,
|
||||
images: &[PackedImage],
|
||||
bordered: &HashSet<String>,
|
||||
) -> HashSet<String> {
|
||||
use crate::atlas_sources::{self, SpriteOrigin};
|
||||
let fonts: HashSet<_> = bitmap_font_textures(root).into_iter().collect();
|
||||
let protected = |name: &str| {
|
||||
let path = name.split_once(':').map_or(name, |(_, path)| path);
|
||||
path.starts_with("gui/")
|
||||
|| path.starts_with("font/")
|
||||
|| fonts.contains(&atlas_sources::texture_path(root, name, "textures"))
|
||||
};
|
||||
let mut models: HashSet<String> = images
|
||||
.iter()
|
||||
.filter(|(name, ..)| name.starts_with("item/") || name.starts_with("block/"))
|
||||
.map(|(name, ..)| name.clone())
|
||||
.collect();
|
||||
let textures = atlas_sources::list_textures(root);
|
||||
let mut protected_aliases = HashSet::new();
|
||||
for (atlas, sources) in atlas_sources::atlases(root) {
|
||||
if atlas != "minecraft:blocks" && atlas != "minecraft:items" {
|
||||
continue;
|
||||
}
|
||||
for (id, origin) in atlas_sources::list(&sources, &textures) {
|
||||
let name = id.strip_prefix("minecraft:").unwrap_or(&id).to_owned();
|
||||
if let SpriteOrigin::File(texture) = origin {
|
||||
if protected(&texture) {
|
||||
protected_aliases.insert(name);
|
||||
} else {
|
||||
models.insert(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
models.retain(|name| {
|
||||
!bordered.contains(name) && !protected(name) && !protected_aliases.contains(name)
|
||||
});
|
||||
models
|
||||
}
|
||||
|
||||
impl HudAtlas {
|
||||
/// `layout`, but when `extras` (Custom Item Textures) make the images more than `max`
|
||||
/// holds, without them: they are left out, not the packs.
|
||||
pub(super) fn layout_leaving_out(
|
||||
images: &mut Vec<PackedImage>,
|
||||
animated: &mut Vec<String>,
|
||||
extras: &HashSet<String>,
|
||||
max: u32,
|
||||
) -> Result<(u32, Vec<(u32, u32)>, u32)> {
|
||||
match Self::layout(images, max) {
|
||||
Err(e) if !extras.is_empty() => {
|
||||
images.retain(|i| !extras.contains(&i.0));
|
||||
animated.retain(|n| !extras.contains(n));
|
||||
let layout = Self::layout(images, max)?;
|
||||
log::warn!("left out the custom item textures: {e}");
|
||||
Ok(layout)
|
||||
}
|
||||
layout => layout,
|
||||
}
|
||||
}
|
||||
/// Last resort for a pack that exceeds the shared atlas: halve model
|
||||
/// textures until they fit, retaining their logical GUI sizes. Never
|
||||
/// shrink run-time slots (empty pixels), or below the vanilla 16 texels.
|
||||
pub(super) fn layout_reducing_models(
|
||||
images: &mut [PackedImage],
|
||||
gui_sizes: &mut HashMap<String, (u32, u32)>,
|
||||
models: &HashSet<String>,
|
||||
max: u32,
|
||||
) -> Result<(u32, Vec<(u32, u32)>, u32)> {
|
||||
loop {
|
||||
let error = match Self::layout(images, max) {
|
||||
Ok(layout) => return Ok(layout),
|
||||
Err(error) => error,
|
||||
};
|
||||
let mut changed = false;
|
||||
for (name, w, h, pixels) in images.iter_mut() {
|
||||
if !models.contains(name) || pixels.is_empty() || (*w <= 16 && *h <= 16) {
|
||||
continue;
|
||||
}
|
||||
let (nw, nh) = ((*w / 2).max((*w).min(16)), (*h / 2).max((*h).min(16)));
|
||||
let mut scaled = Vec::with_capacity((nw * nh * 4) as usize);
|
||||
for y in 0..nh {
|
||||
for x in 0..nw {
|
||||
let at = (((y * *h / nh) * *w + x * *w / nw) * 4) as usize;
|
||||
scaled.extend_from_slice(&pixels[at..at + 4]);
|
||||
}
|
||||
}
|
||||
gui_sizes.entry(name.clone()).or_insert((*w, *h));
|
||||
*pixels = scaled;
|
||||
(*w, *h) = (nw, nh);
|
||||
changed = true;
|
||||
}
|
||||
if !changed {
|
||||
return Err(error);
|
||||
}
|
||||
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,11 +39,14 @@ pub fn text_with_backdrop(
|
||||
background: u32,
|
||||
) {
|
||||
if background != 0 {
|
||||
// Font.PreparedTextBuilder.markBackground: one pixel above and left of
|
||||
// the glyphs, the glyph advance to the right and nine high: not a
|
||||
// fixed two-pixel frame.
|
||||
out.push(fill(
|
||||
x - 2,
|
||||
y - 2,
|
||||
x + w + 2,
|
||||
y + 9 + 2,
|
||||
x - 1,
|
||||
y - 1,
|
||||
x + w,
|
||||
y + 9,
|
||||
multiply(background, color),
|
||||
));
|
||||
}
|
||||
@@ -1484,3 +1487,27 @@ mod boss_bar_room_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod backdrop_tests {
|
||||
use super::*;
|
||||
|
||||
/// `markBackground`: the backdrop reaches one pixel above and left of the
|
||||
/// glyphs, their advance to the right and nine down, not 2 on every side.
|
||||
#[test]
|
||||
fn the_backdrop_frames_the_glyphs_by_one_pixel() {
|
||||
let mut out = Vec::new();
|
||||
text_with_backdrop(
|
||||
&mut out,
|
||||
Text::literal("X"),
|
||||
10,
|
||||
20,
|
||||
6,
|
||||
u32::MAX,
|
||||
0xFF00_0000,
|
||||
);
|
||||
let Some(GuiDraw::Fill { x0, y0, x1, y1, .. }) = out.first() else {
|
||||
panic!("a backdrop is a fill first");
|
||||
};
|
||||
assert_eq!((*x0, *y0, *x1, *y1), (9, 19, 16, 29));
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,16 @@ pub const CULL_BACK: [i8; 4] = [0, 0, 0, 1];
|
||||
/// `Vertex::normal.w` of a shaded quad of an `entityCutoutCull` model (the bat): shaded by the
|
||||
/// normal like -127, and `entity.wgsl` also discards its back-facing pixels.
|
||||
pub const SHADED_CULL_W: i8 = -126;
|
||||
/// `Vertex::normal` of a quad of an `entitySolid` model, which culls its back faces: a quad
|
||||
/// the shader shades by its normal keeps it ([`SHADED_CULL_W`]), a baked-colour one (no
|
||||
/// normal) gets [`CULL_BACK`].
|
||||
pub fn cull_back(normal: [i8; 4]) -> [i8; 4] {
|
||||
if normal == [0; 4] {
|
||||
CULL_BACK
|
||||
} else {
|
||||
[normal[0], normal[1], normal[2], SHADED_CULL_W]
|
||||
}
|
||||
}
|
||||
/// A section vertex: vanilla's `DefaultVertexFormat.BLOCK` (position, colour,
|
||||
/// UV0, UV2) with the sprite's array layer beside the light, in 24 bytes
|
||||
/// rather than vanilla's 28: the UV is fixed point, which holds model UVs
|
||||
@@ -145,6 +155,17 @@ impl TerrainVertex {
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod cull_back_tests {
|
||||
use super::{CULL_BACK, SHADED_CULL_W, cull_back};
|
||||
/// #15322: a shaded `entitySolid` quad keeps its normal and culls; a baked one has
|
||||
/// no normal and only culls.
|
||||
#[test]
|
||||
fn cull_back_keeps_a_shaded_normal() {
|
||||
assert_eq!(cull_back([0; 4]), CULL_BACK);
|
||||
assert_eq!(cull_back([0, 127, 0, 127]), [0, 127, 0, SHADED_CULL_W]);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod vertex_tests {
|
||||
use super::TerrainVertex;
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ use std::time::Instant;
|
||||
|
||||
pub use crate::settings::Opt;
|
||||
|
||||
#[path = "options_ui_widgets.rs"]
|
||||
mod widgets;
|
||||
use crate::{
|
||||
gamepad::{self, GlyphStyle},
|
||||
hdr_calibration::Picture,
|
||||
@@ -17,6 +19,11 @@ use crate::{
|
||||
text::{self, Style, Text},
|
||||
ui,
|
||||
};
|
||||
pub(crate) use widgets::{button, inactive};
|
||||
|
||||
#[path = "options_ui_values.rs"]
|
||||
mod values;
|
||||
pub(crate) use values::{chat_delay_label, chat_px, generic, on_off, percent};
|
||||
|
||||
mod shell_pages;
|
||||
|
||||
@@ -35,18 +42,6 @@ pub static HORIZON_EXTRAS: std::sync::atomic::AtomicBool = std::sync::atomic::At
|
||||
fn horizon_extras() -> bool {
|
||||
HORIZON_EXTRAS.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
fn generic(caption: &str, value: Text) -> Text {
|
||||
Text::translate("options.generic_value", &[tr(caption), value])
|
||||
}
|
||||
fn percent(caption: &str, value: f32) -> Text {
|
||||
Text::translate(
|
||||
"options.percent_value",
|
||||
&[
|
||||
tr(caption),
|
||||
Text::literal(((f64::from(value) * 100.0) as i32).to_string()),
|
||||
],
|
||||
)
|
||||
}
|
||||
/// `text` cut into lines of at most `width` characters, at spaces where it has them.
|
||||
fn wrap(text: &str, width: usize) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
@@ -71,20 +66,11 @@ fn wrap(text: &str, width: usize) -> Vec<String> {
|
||||
}
|
||||
lines
|
||||
}
|
||||
fn on_off(v: bool) -> Text {
|
||||
tr(if v { "options.on" } else { "options.off" })
|
||||
}
|
||||
/// A music track's name as its row shows it: the file, without its folder
|
||||
/// (`minecraft:music/game/calm1` -> `calm1`; the group names the situation).
|
||||
fn track_name(id: &str) -> &str {
|
||||
id.rsplit('/').next().unwrap_or(id)
|
||||
}
|
||||
fn chat_px(caption: &str, px: f32) -> Text {
|
||||
Text::translate(
|
||||
"options.pixel_value",
|
||||
&[tr(caption), Text::literal((px as i32).to_string())],
|
||||
)
|
||||
}
|
||||
pub(crate) enum Kind {
|
||||
/// Slider position 0..1.
|
||||
Slider(f32),
|
||||
@@ -353,6 +339,7 @@ impl Opt {
|
||||
pub(crate) fn kind(self, s: &Settings) -> Kind {
|
||||
let unit = |v: f32, lo: f32, hi: f32| Kind::Slider(((v - lo) / (hi - lo)).clamp(0.0, 1.0));
|
||||
match self {
|
||||
Opt::ChatDelay => values::kind(s.file.double("chatDelay")),
|
||||
Opt::FovEffects | Opt::DarknessPulsing => {
|
||||
Kind::Slider(s.file.double(self.file_key().unwrap()).sqrt() as f32)
|
||||
}
|
||||
@@ -461,6 +448,7 @@ impl Opt {
|
||||
/// The discrete step count for stepped sliders, or `None` if continuous.
|
||||
pub fn slider_steps(self) -> Option<u32> {
|
||||
match self {
|
||||
Opt::ChatDelay => Some(60),
|
||||
Opt::Fov => Some(80),
|
||||
Opt::RenderDistance => Some((crate::options_file::MAX_RENDER_DISTANCE - 2) as u32),
|
||||
Opt::SimulationDistance => Some(27),
|
||||
@@ -511,7 +499,7 @@ impl Opt {
|
||||
pub fn label(self, s: &Settings) -> Text {
|
||||
let c = self.caption();
|
||||
match self {
|
||||
Opt::ChatDelay => tr("options.chat.delay_none"),
|
||||
Opt::ChatDelay => chat_delay_label(s),
|
||||
Opt::FovEffects
|
||||
| Opt::DarknessPulsing
|
||||
| Opt::DistortionEffects
|
||||
@@ -716,7 +704,7 @@ impl Opt {
|
||||
} else {
|
||||
"options.narrator.notavailable"
|
||||
}),
|
||||
Opt::ChatDelay => tr("options.chat.delay_none"),
|
||||
Opt::ChatDelay => chat_delay_label(s),
|
||||
Opt::HideLightningFlashes => on_off(s.file.bool("hideLightningFlashes")),
|
||||
Opt::MonochromeLogo => on_off(s.file.bool("darkMojangStudiosBackground")),
|
||||
Opt::HideSplashTexts => on_off(s.file.bool("hideSplashTexts")),
|
||||
@@ -901,6 +889,7 @@ impl Opt {
|
||||
let v = v.clamp(0.0, 1.0);
|
||||
let step = |lo: f32, hi: f32| (lo + v * (hi - lo)).round();
|
||||
match self {
|
||||
Opt::ChatDelay => values::set(s, v),
|
||||
Opt::FovEffects | Opt::DarknessPulsing => s.file.set(
|
||||
self.file_key().unwrap(),
|
||||
crate::options_file::V::Double(f64::from(v).powi(2)),
|
||||
@@ -1306,7 +1295,7 @@ pub enum Page {
|
||||
Music,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Act {
|
||||
pub(crate) enum Act {
|
||||
None,
|
||||
Push(Page),
|
||||
Done,
|
||||
@@ -1371,7 +1360,7 @@ enum WKind {
|
||||
code: String,
|
||||
},
|
||||
}
|
||||
struct Widget {
|
||||
pub(crate) struct Widget {
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
@@ -1707,27 +1696,6 @@ impl Default for OptionsUi {
|
||||
}
|
||||
}
|
||||
}
|
||||
fn button(x: f32, y: f32, w: f32, label: Text, act: Act) -> Widget {
|
||||
Widget {
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h: 20.0,
|
||||
kind: WKind::Button {
|
||||
label,
|
||||
active: true,
|
||||
act,
|
||||
},
|
||||
list: false,
|
||||
row: 0,
|
||||
}
|
||||
}
|
||||
fn inactive(mut w: Widget) -> Widget {
|
||||
if let WKind::Button { active, .. } = &mut w.kind {
|
||||
*active = false;
|
||||
}
|
||||
w
|
||||
}
|
||||
fn option_widget(s: &Settings, opt: Opt, x: f32, y: f32, w: f32) -> Widget {
|
||||
// OptionsSubScreen.addContents: the narrator button is active only while
|
||||
// the narrator is (`narratorButton.active = narrator.isActive()`).
|
||||
@@ -3094,13 +3062,20 @@ impl OptionsUi {
|
||||
}
|
||||
content = y + self.scroll - top + 2.0;
|
||||
out[list_start..].iter_mut().for_each(|w| w.list = true);
|
||||
out.push(button(
|
||||
let reset_all = button(
|
||||
cx - 154.0,
|
||||
h - 27.0,
|
||||
150.0,
|
||||
tr("controls.resetAll"),
|
||||
Act::ResetKeys,
|
||||
));
|
||||
);
|
||||
// KeyBindsScreen.extractRenderState: active only while some
|
||||
// binding differs from its default.
|
||||
out.push(if s.keys.changed_from_defaults() {
|
||||
reset_all
|
||||
} else {
|
||||
inactive(reset_all)
|
||||
});
|
||||
out.push(button(cx + 4.0, h - 27.0, 150.0, tr("gui.done"), Act::Done));
|
||||
}
|
||||
Page::Language => {
|
||||
|
||||
@@ -174,3 +174,42 @@ fn a_held_hdr_start_shows_as_the_graphics_screen_opens() {
|
||||
assert_eq!(ui.graphics.screen.output.held, held);
|
||||
}
|
||||
}
|
||||
|
||||
/// The slider position `Opt::ChatDelay`'s kind() gives the stored seconds.
|
||||
fn delay_pos(seconds: f64) -> f32 {
|
||||
match values::kind(seconds) {
|
||||
Kind::Slider(p) => p,
|
||||
_ => panic!("the chat delay is a slider"),
|
||||
}
|
||||
}
|
||||
/// `Options.chatDelay`: `IntRange(0, 60)` xmap(v / 10) — a slider in tenths of
|
||||
/// a second from 0 to 6, not a button with no value behind it.
|
||||
#[test]
|
||||
fn the_chat_delay_slider_steps_in_tenths() {
|
||||
assert_eq!(delay_pos(0.0), 0.0);
|
||||
assert!((delay_pos(3.0) - 0.5).abs() < 1e-6);
|
||||
assert_eq!(delay_pos(6.0), 1.0);
|
||||
// A value past the option's range clamps to its last step.
|
||||
assert_eq!(delay_pos(6.5), 1.0);
|
||||
assert_eq!(Opt::ChatDelay.slider_steps(), Some(60));
|
||||
let mut s = Settings::default();
|
||||
set_slider(Opt::ChatDelay, &mut s, 0.0);
|
||||
assert_eq!(s.file.double("chatDelay"), 0.0);
|
||||
set_slider(Opt::ChatDelay, &mut s, 0.5);
|
||||
assert!((s.file.double("chatDelay") - 3.0).abs() < 1e-9);
|
||||
set_slider(Opt::ChatDelay, &mut s, 1.0);
|
||||
assert!((s.file.double("chatDelay") - 6.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// `Options.chatDelay`'s message: None at zero, else the delay to one decimal.
|
||||
#[test]
|
||||
fn the_chat_delay_slider_says_its_delay() {
|
||||
let mut s = Settings::default();
|
||||
assert_eq!(chat_delay_label(&s).plain(), "options.chat.delay_none");
|
||||
s.file.set("chatDelay", crate::options_file::V::Double(2.0));
|
||||
assert_eq!(chat_delay_label(&s).plain(), "Chat Delay: 2.0 second(s)");
|
||||
s.file.set("chatDelay", crate::options_file::V::Double(6.0));
|
||||
assert_eq!(chat_delay_label(&s).plain(), "Chat Delay: 6.0 second(s)");
|
||||
s.file.set("chatDelay", crate::options_file::V::Double(0.0));
|
||||
assert_eq!(chat_delay_label(&s).plain(), "options.chat.delay_none");
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! The small option value labels (`generic`, `percent`, `on_off`, `chat_px`,
|
||||
//! `chat_delay_label`) and `Options.chatDelay`'s slider mapping.
|
||||
use super::*;
|
||||
|
||||
pub(crate) fn generic(caption: &str, value: Text) -> Text {
|
||||
Text::translate("options.generic_value", &[tr(caption), value])
|
||||
}
|
||||
pub(crate) fn on_off(v: bool) -> Text {
|
||||
tr(if v { "options.on" } else { "options.off" })
|
||||
}
|
||||
pub(crate) fn chat_px(caption: &str, px: f32) -> Text {
|
||||
Text::translate(
|
||||
"options.pixel_value",
|
||||
&[tr(caption), Text::literal((px as i32).to_string())],
|
||||
)
|
||||
}
|
||||
pub(crate) fn percent(caption: &str, value: f32) -> Text {
|
||||
Text::translate(
|
||||
"options.percent_value",
|
||||
&[
|
||||
tr(caption),
|
||||
Text::literal(((f64::from(value) * 100.0) as i32).to_string()),
|
||||
],
|
||||
)
|
||||
}
|
||||
/// `Options.chatDelay`'s message: `options.chat.delay_none` at zero, else
|
||||
/// `options.chat.delay` with the delay to one decimal (`%.1f` seconds).
|
||||
pub(crate) fn chat_delay_label(s: &Settings) -> Text {
|
||||
let value = s.file.double("chatDelay") as f32;
|
||||
if value <= 0.0 {
|
||||
Text::translate("options.chat.delay_none", &[])
|
||||
} else {
|
||||
Text::translate_with_fallback(
|
||||
"options.chat.delay",
|
||||
"Chat Delay: %s second(s)",
|
||||
&[Text::literal(format!("{value:.1}"))],
|
||||
)
|
||||
}
|
||||
}
|
||||
/// `IntRange(0, 60).toSliderValue` on the stored seconds (`xmap(v / 10.0)`):
|
||||
/// the tenth-of-a-second step over the slider's 60.
|
||||
pub(crate) fn kind(value: f64) -> Kind {
|
||||
Kind::Slider(((value * 10.0).floor() / 60.0).clamp(0.0, 1.0) as f32)
|
||||
}
|
||||
/// `IntRange(0, 60).fromSliderValue` then `xmap`'s `/ 10.0`:
|
||||
/// floor(map(v, 0, 1, 0, 61)), the last step capped at 60, in tenths.
|
||||
pub(crate) fn from_slider(v: f32) -> f64 {
|
||||
f64::from((v.clamp(0.0, 1.0) * 61.0).floor().min(60.0) / 10.0)
|
||||
}
|
||||
/// `Options.chatDelay.set`: the slider's value as the stored seconds.
|
||||
pub(crate) fn set(s: &mut Settings, v: f32) {
|
||||
s.file
|
||||
.set("chatDelay", crate::options_file::V::Double(from_slider(v)));
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//! The option screens' widget constructors: a 20-pixel button row and the
|
||||
//! same widget turned inactive.
|
||||
use super::*;
|
||||
|
||||
pub(crate) fn button(x: f32, y: f32, w: f32, label: Text, act: Act) -> Widget {
|
||||
Widget {
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h: 20.0,
|
||||
kind: WKind::Button {
|
||||
label,
|
||||
active: true,
|
||||
act,
|
||||
},
|
||||
list: false,
|
||||
row: 0,
|
||||
}
|
||||
}
|
||||
pub(crate) fn inactive(mut w: Widget) -> Widget {
|
||||
if let WKind::Button { active, .. } = &mut w.kind {
|
||||
*active = false;
|
||||
}
|
||||
w
|
||||
}
|
||||
@@ -1718,6 +1718,19 @@ impl PackScreen {
|
||||
[32, 2],
|
||||
);
|
||||
if l.scrollable() {
|
||||
// AbstractScrollArea.extractScrollbar: over the bar, the pointer
|
||||
// points, or resizes while the bar is held.
|
||||
let over_bar = mouse.0 >= l.scroll_bar_x()
|
||||
&& mouse.0 < l.scroll_bar_x() + 6
|
||||
&& mouse.1 >= l.y
|
||||
&& mouse.1 < l.bottom();
|
||||
if over_bar {
|
||||
crate::widgets::request_cursor(if l.held.is_some() {
|
||||
crate::widgets::Cursor::ResizeNs
|
||||
} else {
|
||||
crate::widgets::Cursor::PointingHand
|
||||
});
|
||||
}
|
||||
let sh = l.scroller_height().clamp(32, l.height - 8);
|
||||
let sy = (l.scroll as i32 * (l.height - sh) / l.max_scroll() + l.y).max(l.y);
|
||||
d::sprite(
|
||||
@@ -2197,6 +2210,29 @@ mod icon_slot_tests {
|
||||
|
||||
/// Only the available list has packs: one row of 36 for each of `count` icons fits in a
|
||||
/// window `height` high.
|
||||
/// `AbstractScrollArea.extractScrollbar`: over a held pack list's bar the
|
||||
/// pointer asks to resize vertically, at rest it points.
|
||||
#[test]
|
||||
fn the_pack_scrollbar_asks_for_its_cursor() {
|
||||
let atlas = atlas();
|
||||
let m = Measure(&atlas.font);
|
||||
let mut screen = screen(&m, 30, 200);
|
||||
let l = &screen.lists[0];
|
||||
assert!(l.scrollable());
|
||||
let at = (l.scroll_bar_x() + 2, l.y + 8);
|
||||
let _ = crate::widgets::take_cursor();
|
||||
screen.draw(&m, at, 0, 0);
|
||||
assert_eq!(
|
||||
crate::widgets::take_cursor(),
|
||||
crate::widgets::Cursor::PointingHand
|
||||
);
|
||||
screen.lists[0].held = Some(f64::from(at.1));
|
||||
screen.draw(&m, at, 0, 0);
|
||||
assert_eq!(
|
||||
crate::widgets::take_cursor(),
|
||||
crate::widgets::Cursor::ResizeNs
|
||||
);
|
||||
}
|
||||
fn screen(m: &Measure, count: usize, height: i32) -> PackScreen {
|
||||
PackScreen::new(
|
||||
m,
|
||||
|
||||
@@ -3825,7 +3825,7 @@ impl ParticleEngine {
|
||||
}
|
||||
let pending = std::mem::take(&mut self.pending);
|
||||
for mut p in pending {
|
||||
if p.spawn_check && !polytone::unobstructed(level, p.bb) {
|
||||
if !self.polytone_joins(&p, level) {
|
||||
continue;
|
||||
}
|
||||
if p.pre_tick {
|
||||
|
||||
@@ -1100,6 +1100,30 @@ pub(super) fn unobstructed(level: &ParticleLevel<'_>, bb: horizon_core::geometry
|
||||
scene.cells_without_collision(bb) || scene.unobstructed(bb).unwrap_or(true)
|
||||
}
|
||||
|
||||
impl ParticleEngine {
|
||||
/// Whether a pending Polytone particle joins the world. One with physics whose box starts
|
||||
/// inside a block's collision never does, and its type's `limit` is then counted among those
|
||||
/// that joined, so one that never joins takes no place (measured on the mod: with `limit` 1,
|
||||
/// one born inside a block and one clear of it in the same tick, the clear one shows).
|
||||
pub(super) fn polytone_joins(&self, p: &Particle, level: &ParticleLevel<'_>) -> bool {
|
||||
if !p.spawn_check {
|
||||
return true;
|
||||
}
|
||||
if !unobstructed(level, p.bb) {
|
||||
return false;
|
||||
}
|
||||
let Some(name) = p.polytone_type.as_deref() else {
|
||||
return true;
|
||||
};
|
||||
let def = self.polytone.as_ref().and_then(|r| r.get(name));
|
||||
let limit = def.and_then(|d| d.limit.filter(|_| d.gpu.is_none()));
|
||||
limit.is_none_or(|limit| {
|
||||
let same = |q: &&Particle| !q.removed && q.polytone_type.as_deref() == Some(name);
|
||||
self.particles.iter().filter(same).count() < limit as usize
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl super::ParticleController for Controller {
|
||||
fn tick(&mut self, p: &mut Particle, level: &ParticleLevel<'_>) -> ParticleUpdate {
|
||||
let mut update = ParticleUpdate::default();
|
||||
@@ -1214,7 +1238,8 @@ pub(super) fn make(
|
||||
velocity: [f64; 3],
|
||||
seed: i64,
|
||||
) -> Option<Particle> {
|
||||
// `limit`: at most that many of the type alive at once.
|
||||
// `limit`: at most that many of the type alive at once. A pending one with physics may never
|
||||
// join (`polytone_joins`), so it takes no place until it does.
|
||||
// (A GPU particle's `limit` is its ring of records instead.)
|
||||
if let Some(limit) = def.limit
|
||||
&& def.gpu.is_none()
|
||||
@@ -1222,7 +1247,7 @@ pub(super) fn make(
|
||||
let alive = engine
|
||||
.particles
|
||||
.iter()
|
||||
.chain(&engine.pending)
|
||||
.chain(engine.pending.iter().filter(|p| !p.spawn_check))
|
||||
.filter(|p| !p.removed && p.polytone_type.as_deref() == Some(name))
|
||||
.count();
|
||||
if alive >= limit as usize {
|
||||
@@ -1230,10 +1255,11 @@ pub(super) fn make(
|
||||
}
|
||||
}
|
||||
// `exclusion_radius`: none spawns within that distance of a live one of its type (rain
|
||||
// splashes, one per patch of ground).
|
||||
// splashes, one per patch of ground from tick to tick). Only those already in the world count: measured on
|
||||
// the mod, two spawned in the same tick a block apart both show, with or without physics.
|
||||
if def.exclusion_radius > 0.0 && def.gpu.is_none() {
|
||||
let r2 = def.exclusion_radius * def.exclusion_radius;
|
||||
let crowded = engine.particles.iter().chain(&engine.pending).any(|p| {
|
||||
let crowded = engine.particles.iter().any(|p| {
|
||||
!p.removed
|
||||
&& p.polytone_type.as_deref() == Some(name)
|
||||
&& (0..3)
|
||||
@@ -1858,7 +1884,8 @@ mod tests {
|
||||
let root = pack(&[
|
||||
(
|
||||
"assets/test/polytone/custom_particles/splash.json",
|
||||
serde_json::json!({"has_physics": false, "exclusion_radius": 1, "render_type": "invisible"}),
|
||||
serde_json::json!({"has_physics": false, "exclusion_radius": 1, "render_type": "invisible",
|
||||
"initializer": {"lifetime": "100"}}),
|
||||
),
|
||||
(
|
||||
"assets/test/polytone/custom_particles/drop.json",
|
||||
@@ -1868,7 +1895,8 @@ mod tests {
|
||||
"assets/minecraft/polytone/entity_modifiers/player.json",
|
||||
serde_json::json!({"emitters": [
|
||||
{"particle": "test:splash", "x": "0", "chance": "1"},
|
||||
// 0.5 from the first: excluded.
|
||||
// 0.5 from the first, in the same tick: the first isn't in the world yet,
|
||||
// so it spawns (measured on the mod).
|
||||
{"particle": "test:splash", "x": "0.5", "chance": "1"},
|
||||
// 3 away: spawns.
|
||||
{"particle": "test:splash", "x": "3", "chance": "1"},
|
||||
@@ -1890,7 +1918,15 @@ mod tests {
|
||||
engine.polytone_entity(&level, "minecraft:player", [1.0, 64.0, 1.0]);
|
||||
let mut xs: Vec<f64> = engine.pending.iter().map(|p| p.position[0]).collect();
|
||||
xs.sort_by(f64::total_cmp);
|
||||
assert_eq!(xs, [1.0, 1.5, 4.0]);
|
||||
assert_eq!(xs, [1.0, 1.5, 1.5, 4.0]);
|
||||
// Once they are in the world, the next tick's splashes near them are excluded; ones
|
||||
// farther than the radius from all of them spawn, again without excluding each other.
|
||||
engine.tick(&level);
|
||||
let splashes = |p: &&Particle| p.polytone_type.as_deref() == Some("test:splash");
|
||||
engine.polytone_entity(&level, "minecraft:player", [1.0, 64.0, 1.0]);
|
||||
assert_eq!(engine.pending.iter().filter(splashes).count(), 0);
|
||||
engine.polytone_entity(&level, "minecraft:player", [9.0, 64.0, 1.0]);
|
||||
assert_eq!(engine.pending.iter().filter(splashes).count(), 3);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
@@ -2421,6 +2457,70 @@ mod tests {
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
/// Measured on the mod: with `limit` 1, one particle born inside a block and one clear of it
|
||||
/// in the same tick, the clear one shows. The one that never joins takes no place.
|
||||
#[test]
|
||||
fn a_particle_that_never_joins_takes_no_place_under_its_limit() {
|
||||
let blocks = blocks();
|
||||
let lim = |x: &str| serde_json::json!({"particle": "test:lim", "chance": "1", "x": x});
|
||||
let root = pack(&[
|
||||
(
|
||||
"assets/test/polytone/custom_particles/lim.json",
|
||||
serde_json::json!({"has_physics": true, "render_type": "invisible", "limit": 1,
|
||||
"initializer": {"lifetime": "20", "hitbox_size": "0.08"}}),
|
||||
),
|
||||
// The first inside the block's reach, the second three blocks clear of it.
|
||||
(
|
||||
"assets/minecraft/polytone/entity_modifiers/player.json",
|
||||
serde_json::json!({"emitters": [lim("0"), lim("3")]}),
|
||||
),
|
||||
// Two clear of it: the limit still keeps one.
|
||||
(
|
||||
"assets/minecraft/polytone/entity_modifiers/zombie.json",
|
||||
serde_json::json!({"emitters": [lim("3"), lim("6")]}),
|
||||
),
|
||||
// `exclusion_radius` with physics: the one inside the block doesn't keep the clear
|
||||
// one a block away from spawning.
|
||||
(
|
||||
"assets/test/polytone/custom_particles/exc.json",
|
||||
serde_json::json!({"has_physics": true, "render_type": "invisible",
|
||||
"exclusion_radius": 2,
|
||||
"initializer": {"lifetime": "20", "hitbox_size": "0.08"}}),
|
||||
),
|
||||
(
|
||||
"assets/minecraft/polytone/entity_modifiers/skeleton.json",
|
||||
serde_json::json!({"emitters": [
|
||||
{"particle": "test:exc", "chance": "1", "x": "0"},
|
||||
{"particle": "test:exc", "chance": "1", "x": "1"}]}),
|
||||
),
|
||||
]);
|
||||
let scene = solid_scene(blocks);
|
||||
let level = ParticleLevel {
|
||||
world: &scene.world,
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
let mut joined = |entity: &str| {
|
||||
engine.particles.clear();
|
||||
engine.pending.clear();
|
||||
// The solid block is at (1, 64, 1): 0.05 under it, a 0.08 box reaches into it.
|
||||
engine.polytone_entity(&level, entity, [1.5, 63.95, 1.5]);
|
||||
engine.tick(&level);
|
||||
engine
|
||||
.particles
|
||||
.iter()
|
||||
.map(|p| p.position[0])
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(joined("minecraft:player"), vec![4.5]);
|
||||
assert_eq!(joined("minecraft:zombie"), vec![4.5]);
|
||||
assert_eq!(joined("minecraft:skeleton"), vec![2.5]);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_custom_particle_replaces_the_vanilla_type_of_its_id() {
|
||||
let root = pack(&[(
|
||||
|
||||
@@ -303,8 +303,12 @@ fn faded(code: u32, local: vec3<f32>, dx: vec3<f32>, dy: vec3<f32>) -> bool {
|
||||
let pixel = max(length(dx), length(dy));
|
||||
let unit = exp2(ceil(log2(max(pixel * 1.5, 1.0 / 16.0))));
|
||||
let p = vec2<u32>(bitcast<vec2<u32>>(vec2<i32>(floor(plane / unit)))) & vec2(3u);
|
||||
let bayer = array<u32, 16>(0u, 8u, 2u, 10u, 12u, 4u, 14u, 6u, 3u, 11u, 1u, 9u, 15u, 7u, 13u, 5u);
|
||||
return (f32(bayer[p.y * 4u + p.x]) + 0.5) / 16.0 >= shown;
|
||||
// The 4x4 Bayer matrix by rows, 0 8 2 10, 12 4 14 6, 3 11 1 9, 15 7 13 5: four bits each,
|
||||
// two rows a word (a constant array indexed by the pixel would be a private array, which
|
||||
// Adreno's Vulkan compiler, build 0676.41, fails to link).
|
||||
let rows = select(0x6e4ca280u, 0x5d7f91b3u, p.y >= 2u);
|
||||
let bayer = (rows >> (((p.y & 1u) * 4u + p.x) * 4u)) & 0xfu;
|
||||
return (f32(bayer) + 0.5) / 16.0 >= shown;
|
||||
}
|
||||
// A fading tile or section is drawn a hair toward the camera (depth is
|
||||
// reversed: nearer is larger), the newer the more, so it wins where it draws
|
||||
|
||||
@@ -1053,5 +1053,100 @@
|
||||
"horizon.studio.topbar.add_marker": "Додати маркер",
|
||||
"horizon.studio.topbar.clear_keyframes": "🗑 Очистити ключові кадри",
|
||||
"horizon.studio.topbar.timeline_hud": "⏱ Шкала часу",
|
||||
"horizon.studio.topbar.split_scene": "Розділити сцену"
|
||||
"horizon.studio.topbar.split_scene": "Розділити сцену",
|
||||
"horizon.options.radial_mode": "Радіальне меню",
|
||||
"horizon.options.radial_mode.about": "Утримуйте кнопку відкриття радіального меню або натисніть її раз, щоб відкрити, і ще раз, щоб закрити.",
|
||||
"horizon.options.radial_mode.hold.about": "Утримуйте кнопку відкриття й відпустіть, щоб виконати те, на що вказує стік.",
|
||||
"horizon.options.radial_mode.toggle.about": "Натисніть кнопку відкриття, щоб відкрити меню; A або повторне натискання кнопки відкриття виконує те, на що вказує стік, B закриває.",
|
||||
"horizon.options.radial_stick": "Вибір у радіальному меню",
|
||||
"horizon.options.radial_stick.about": "Стік, яким ви вказуєте на комірки радіального меню.",
|
||||
"horizon.options.radial_stick.right": "Правий стік",
|
||||
"horizon.options.radial_stick.left": "Лівий стік",
|
||||
"horizon.options.radial_stick.either": "Будь-який",
|
||||
"horizon.options.radial_stick.right.about": "Правий стік вказує на комірки; лівим ви й далі ходите.",
|
||||
"horizon.options.radial_stick.left.about": "Лівий стік вказує, а ви стоїте на місці. Під тим самим пальцем — хрестовина праворуч, тож оберіть кнопку відкриття на сторінці «Радіальне меню».",
|
||||
"horizon.options.radial_stick.either.about": "Вказує стік, яким рухали останнім; жоден не рухає вас і не повертає огляд. Відкрити меню можна лише бампером, тригером або кнопкою View.",
|
||||
"horizon.pages.controller_radial": "Радіальне меню",
|
||||
"horizon.radial.page.general": "Загальне",
|
||||
"horizon.radial.page.camera": "Камера",
|
||||
"horizon.radial.release_stick": "Відпустіть стік",
|
||||
"horizon.radial.action.key_inventory": "Інвентар",
|
||||
"horizon.radial.action.key_chat": "Чат",
|
||||
"horizon.radial.action.key_command": "Команди",
|
||||
"horizon.radial.action.key_advancements": "Досягнення",
|
||||
"horizon.radial.action.key_togglePerspective": "Перспектива",
|
||||
"horizon.radial.action.key_swapOffhand": "Поміняти руки",
|
||||
"horizon.radial.action.key_drop": "Викинути",
|
||||
"horizon.radial.action.key_pickItem": "Вибрати блок",
|
||||
"horizon.radial.action.key_screenshot": "Знімок екрана",
|
||||
"horizon.radial.action.key_toggleGui": "Сховати HUD",
|
||||
"horizon.radial.action.key_smoothCamera": "Кінематографічна камера",
|
||||
"horizon.radial.action.key_socialInteractions": "Соціальне",
|
||||
"horizon.radial.action.key_emote": "Емоція",
|
||||
"horizon.radial.action.pad_hotbarPrevious": "Попередня комірка",
|
||||
"horizon.radial.action.pad_hotbarNext": "Наступна комірка",
|
||||
"horizon.radial.action.key_fullscreen": "Повний екран",
|
||||
"horizon.radial.action.key_quickActions": "Швидкі дії",
|
||||
"horizon.radial.action.key_debug_overlay": "Екран налагодження",
|
||||
"horizon.radial.missing": "Відсутня",
|
||||
"horizon.radial.why.missing": "Невідома дія",
|
||||
"horizon.radial.why.cosmetics": "Потрібна увімкнена косметика",
|
||||
"horizon.radial.why.quick_actions": "На цьому сервері немає швидких дій",
|
||||
"horizon.radial.why.social": "Лише на сервері",
|
||||
"horizon.radial.next": "→ %s",
|
||||
"horizon.radial.state.first_person": "Від першої особи",
|
||||
"horizon.radial.state.third_person_back": "Від третьої особи",
|
||||
"horizon.radial.state.third_person_front": "Вигляд спереду",
|
||||
"horizon.radial.state.shown": "Показано",
|
||||
"horizon.radial.state.hidden": "Приховано",
|
||||
"horizon.radial.state.on": "Увімк.",
|
||||
"horizon.radial.state.off": "Вимк.",
|
||||
"horizon.radial.pick_title": "Оберіть дію",
|
||||
"horizon.radial.group.actions": "Дії",
|
||||
"horizon.radial.pick.slot": "Комірка «%s» сторінки «%s»",
|
||||
"horizon.radial.pick.about": "Ставить цю дію в комірку й повертається назад. Поточну дію комірки позначено.",
|
||||
"horizon.radial.pick.current": "Поточна",
|
||||
"horizon.radial.missing.about": "Ця гра не знає дії %s: комірка зберігає її, але нічого не виконує. Натисніть або «праворуч», щоб поставити сюди іншу.",
|
||||
"horizon.radial.page.new": "Нова сторінка",
|
||||
"horizon.pages.open.controller_radial": "Радіальне меню...",
|
||||
"horizon.radial.group.slots": "Комірки",
|
||||
"horizon.radial.group.page": "Сторінка",
|
||||
"horizon.radial.group.all": "Усі сторінки",
|
||||
"horizon.radial.empty": "Порожньо",
|
||||
"horizon.radial.page_of": "Сторінка %s з %s. Кожна комірка — це напрямок стіка вибору, поки кнопку відкриття утримано.",
|
||||
"horizon.radial.slot.0": "Угору",
|
||||
"horizon.radial.slot.1": "Угору праворуч",
|
||||
"horizon.radial.slot.2": "Праворуч",
|
||||
"horizon.radial.slot.3": "Униз праворуч",
|
||||
"horizon.radial.slot.4": "Униз",
|
||||
"horizon.radial.slot.5": "Униз ліворуч",
|
||||
"horizon.radial.slot.6": "Ліворуч",
|
||||
"horizon.radial.slot.7": "Угору ліворуч",
|
||||
"horizon.radial.slot.about": "Дія, яку виконує цей напрямок. Натисніть, щоб побачити список дій; «праворуч» — наступна, «ліворуч» або Shift+клік — попередня.",
|
||||
"horizon.radial.rename": "Перейменувати сторінку",
|
||||
"horizon.radial.rename.about": "Дайте цій сторінці власну назву.",
|
||||
"horizon.radial.name_title": "Назва сторінки",
|
||||
"horizon.radial.new_page.about": "Додає порожню сторінку після останньої й показує її.",
|
||||
"horizon.radial.new_page.full": "Меню вміщує щонайбільше %s сторінок.",
|
||||
"horizon.radial.delete_page": "Видалити сторінку",
|
||||
"horizon.radial.delete_page.about": "Одразу видаляє цю сторінку та її комірки. «Скасувати» повертає її.",
|
||||
"horizon.radial.delete_page.last": "У меню лишається щонайменше одна сторінка.",
|
||||
"horizon.radial.reset": "Скинути всі сторінки",
|
||||
"horizon.radial.reset.about": "Одразу повертає сторінки «Загальне» й «Камера» та видаляє решту. «Скасувати» повертає їх.",
|
||||
"horizon.radial.reset.default": "Сторінки вже типові.",
|
||||
"horizon.radial.opener": "Кнопка відкриття",
|
||||
"horizon.radial.opener.about": "Кнопка, яку утримують, щоб відкрити меню. Натисніть, потім натисніть кнопку геймпада: бампер або хрестовину, але не A, B, X, Y чи правий стік, якими керують і скасовують.",
|
||||
"horizon.radial.opener.about.left": "Кнопка, яку утримують, щоб відкрити меню. Натисніть, потім натисніть кнопку геймпада: бампер, A, X або Y, але не хрестовину чи лівий стік, якими керують, і не B, яка скасовує.",
|
||||
"horizon.radial.opener.about.either": "Кнопка, яку утримують, щоб відкрити меню. Натисніть, потім натисніть кнопку геймпада: бампер, тригер або View; обидва великі пальці керують, а B скасовує.",
|
||||
"horizon.radial.unbound": "Жодна кнопка не відкриває меню (кнопку під пальцем, яким обирають, звільнено): натисніть, потім натисніть кнопку геймпада.",
|
||||
"horizon.radial.conflict": "Спільна з іншою дією: у грі цю кнопку забирає меню. Натисніть, щоб обрати іншу.",
|
||||
"Radial Menu": "Радіальне меню",
|
||||
"Named pages of eight actions, held open with a button.": "Іменовані сторінки по вісім дій, відкриті, поки утримуєте кнопку.",
|
||||
"The shader pack lights the world itself.": "Пакет шейдерів сам освітлює світ.",
|
||||
"How bright the HUD and menus are. Auto: at paper white.": "Яскравість HUD і меню. «Авто»: на рівні білої точки.",
|
||||
"HDR output stopped the game the last time it started with this graphics driver. Choose a mode to try again.": "Минулого разу з цим графічним драйвером HDR-вивід зупинив гру. Оберіть режим, щоб спробувати знову.",
|
||||
"horizon.pad.action.radial": "Радіальне меню",
|
||||
"horizon.recipes.fuel.options": "Утримуйте SHIFT, щоб закріпити цю підказку, потім клацніть інгредієнт, щоб переглянути рецепти.",
|
||||
"horizon.recipes.fuel.any": "Будь-яке паливо",
|
||||
"horizon.recipes.fuel.minecraft": "Minecraft"
|
||||
}
|
||||
@@ -1996,7 +1996,7 @@ pub fn mob_from_nbt_with_random(
|
||||
if crate::mobs::equine::is_equine(m.species()) {
|
||||
m.health = m.health.min(m.attrs.max_health);
|
||||
}
|
||||
m.hurt_time = number(tag.get("HurtTime")).unwrap_or(0) as i32;
|
||||
m.hurt_time = i32::from(number(tag.get("HurtTime")).unwrap_or(0) as i16);
|
||||
// What it holds (equipment.mainhand / offhand), when saved.
|
||||
if let Some(eq) = tag.get("equipment") {
|
||||
m.extra
|
||||
@@ -2034,7 +2034,7 @@ pub fn mob_from_nbt_with_random(
|
||||
// vanilla); a loaded mob holds only what its file lists.
|
||||
m.extra.set_main_hand(None);
|
||||
}
|
||||
m.death_time = number(tag.get("DeathTime")).unwrap_or(0) as i32;
|
||||
m.death_time = i32::from(number(tag.get("DeathTime")).unwrap_or(0) as i16);
|
||||
m.impulse_grace_time =
|
||||
number(tag.get("current_impulse_context_reset_grace_time")).unwrap_or(0) as i32;
|
||||
// Mob.readAdditionalSaveData overrides what the constructor set (copper
|
||||
@@ -2423,7 +2423,7 @@ pub fn mob_from_nbt_with_random(
|
||||
restore_axolotl_memories(&mut m, tag);
|
||||
}
|
||||
read_variant(&mut m, tag);
|
||||
m.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i32;
|
||||
m.fire_ticks = i32::from(number(tag.get("Fire")).unwrap_or(0) as i16);
|
||||
// Entity.load: `input.getIntOr("Air", getMaxAirSupply())`, 4800 for a dolphin.
|
||||
m.air = number(tag.get("Air")).map_or(m.max_air_supply(), |air| air as i32);
|
||||
if m.species() == crate::mobs::Species::Allay {
|
||||
@@ -2609,7 +2609,7 @@ pub fn mob_from_nbt_with_random(
|
||||
}
|
||||
// Sheep.readAdditionalSaveData: white and unsheared when absent.
|
||||
if m.species() == crate::mobs::Species::Sheep {
|
||||
m.extra.color = number(tag.get("Color")).map_or(0, |c| (c as u8) & 15);
|
||||
m.extra.color = crate::mobs::farm::saved_color(tag.get("Color"));
|
||||
m.extra.sheared = flag(tag, "Sheared");
|
||||
}
|
||||
// GlowSquid.readAdditionalSaveData: not dimmed when absent.
|
||||
@@ -2959,7 +2959,8 @@ pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemE
|
||||
/// `OminousItemSpawner.addAdditionalSaveData`: its item (left out when
|
||||
/// empty) and `spawn_item_after_ticks`. Its tick count is the entity's
|
||||
/// `tickCount`, which isn't saved: a reloaded spawner counts from 0 again.
|
||||
/// It never moves, so the `Motion` it was loaded with is what it saves.
|
||||
/// It never moves or turns: it saves the Motion, Rotation, OnGround and
|
||||
/// fall_distance it was loaded with (`Entity.load`).
|
||||
pub fn ominous_item_spawner_to_nbt(
|
||||
item: &crate::items::ItemStack,
|
||||
position: Vec3,
|
||||
@@ -2972,13 +2973,12 @@ pub fn ominous_item_spawner_to_nbt(
|
||||
uuid,
|
||||
position,
|
||||
motion: raw.map_or(Vec3::ZERO, loaded_motion),
|
||||
rotation: [0.0, 0.0],
|
||||
on_ground: false,
|
||||
fall_distance: 0.0,
|
||||
rotation: raw.map_or([0.0, 0.0], rotation),
|
||||
on_ground: raw.is_some_and(|t| flag(t, "OnGround")),
|
||||
fall_distance: float(raw.and_then(|t| t.get("fall_distance"))).unwrap_or(0.0),
|
||||
};
|
||||
let mut tag = base.tag(raw);
|
||||
// `ItemStack.CODEC` can't encode `ItemStack.EMPTY`, so an empty item
|
||||
// writes no key.
|
||||
// `ItemStack.CODEC` can't encode `ItemStack.EMPTY`: an empty item writes no key.
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
map.remove(&"item".into());
|
||||
}
|
||||
@@ -3005,59 +3005,8 @@ pub fn ominous_item_spawner_from_nbt(tag: &Tag) -> Option<(crate::items::ItemSta
|
||||
Some((item, position(tag)?, after))
|
||||
}
|
||||
|
||||
/// `ExperienceOrb`: Health, Age, Value, Count.
|
||||
pub fn orb_to_nbt(o: &ExperienceOrb, uuid: [i32; 4]) -> Tag {
|
||||
let base = Base {
|
||||
id: "minecraft:experience_orb",
|
||||
uuid,
|
||||
position: o.position,
|
||||
motion: o.velocity,
|
||||
rotation: o.rotation,
|
||||
on_ground: o.on_ground,
|
||||
fall_distance: 0.0,
|
||||
};
|
||||
let mut tag = base.tag(None);
|
||||
for (key, value) in [
|
||||
("Health", Tag::Short(o.health as i16)),
|
||||
("Age", Tag::Short(o.age.min(i16::MAX as u32) as i16)),
|
||||
("Value", Tag::Short(o.value as i16)),
|
||||
("Count", Tag::Int(o.count.min(i32::MAX as u32) as i32)),
|
||||
] {
|
||||
put(&mut tag, key, value);
|
||||
}
|
||||
// Entity.saveWithoutId writes NoGravity only when set.
|
||||
if o.no_gravity {
|
||||
put(&mut tag, "NoGravity", Tag::Byte(1));
|
||||
}
|
||||
tag
|
||||
}
|
||||
/// One orb with its merged `Count`.
|
||||
pub fn orbs_from_nbt(tag: &Tag) -> Vec<ExperienceOrb> {
|
||||
let Some(position) = position(tag) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let count = number(tag.get("Count")).unwrap_or(1).max(1) as u32;
|
||||
let orb = ExperienceOrb {
|
||||
rotation: rotation(tag),
|
||||
join: Default::default(),
|
||||
no_gravity: flag(tag, "NoGravity"),
|
||||
id: 0,
|
||||
position,
|
||||
previous: position,
|
||||
velocity: motion(tag),
|
||||
// `getShortOr("Value", 0)`, unclamped.
|
||||
value: tag.get("Value").and_then(int_value).unwrap_or(0) as i16 as i32,
|
||||
age: number(tag.get("Age")).unwrap_or(0).max(0) as u32,
|
||||
on_ground: flag(tag, "OnGround"),
|
||||
count,
|
||||
ticks: 0,
|
||||
health: number(tag.get("Health")).unwrap_or(5) as i32,
|
||||
// ExperienceOrb's followingPlayer is transient: never saved, so a
|
||||
// loaded orb starts without one.
|
||||
following: None,
|
||||
};
|
||||
vec![orb]
|
||||
}
|
||||
mod orb;
|
||||
pub use orb::{orb_to_nbt, orbs_from_nbt};
|
||||
|
||||
/// `Projectile` + `AbstractArrow.addAdditionalSaveData` of a live arrow;
|
||||
/// `in_block` is its `inBlockState`, `raw` the compound it was loaded with.
|
||||
@@ -3849,19 +3798,7 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
("AbsorptionAmount", Tag::Float(0)),
|
||||
("FallFlying", Tag::Byte(0)),
|
||||
("current_impulse_context_reset_grace_time", Tag::Int(0)),
|
||||
// AttributeMap.pack: the movement_speed instance the stand's first
|
||||
// tick makes (removeFrost), at the registry default.
|
||||
(
|
||||
"attributes",
|
||||
Tag::List(if a.speed_instance {
|
||||
vec![Tag::compound([
|
||||
("id", string(SPEED_ATTRIBUTE)),
|
||||
("base", Tag::Double(0.7f64.to_bits())),
|
||||
])]
|
||||
} else {
|
||||
Vec::new()
|
||||
}),
|
||||
),
|
||||
("attributes", Tag::List(Vec::new())),
|
||||
(
|
||||
"Brain",
|
||||
Tag::compound([("memories", Tag::Compound(Compound::new()))]),
|
||||
@@ -3871,6 +3808,18 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
] {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
}
|
||||
// `removeFrost`'s first-tick movement_speed, over a saved list without it.
|
||||
if a.speed_instance
|
||||
&& let Some(Tag::List(list)) = map.get_mut(&"attributes".into())
|
||||
&& !list
|
||||
.iter()
|
||||
.any(|t| text(t.get("id")).as_deref() == Some(SPEED_ATTRIBUTE))
|
||||
{
|
||||
list.push(Tag::compound([
|
||||
("id", string(SPEED_ATTRIBUTE)),
|
||||
("base", Tag::Double(0.7f64.to_bits())),
|
||||
]));
|
||||
}
|
||||
}
|
||||
tag
|
||||
}
|
||||
@@ -3884,7 +3833,7 @@ pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
|
||||
if list.iter().any(|attr| text(attr.get("id")).as_deref() == Some(SPEED_ATTRIBUTE)));
|
||||
a.velocity = loaded_motion(tag);
|
||||
a.health = float(tag.get("Health")).unwrap_or(20.0) as f32;
|
||||
a.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i32;
|
||||
a.fire_ticks = i32::from(number(tag.get("Fire")).unwrap_or(0) as i16);
|
||||
a.custom_name = tag.get("CustomName").map(name_text);
|
||||
a.name_visible = flag(tag, "CustomNameVisible");
|
||||
if let Some(eq) = tag.get("equipment") {
|
||||
@@ -4601,6 +4550,27 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `LivingEntity.readAdditionalSaveData` reads `HurtTime` and `DeathTime`
|
||||
/// with `getShortOr`: an int tag is narrowed (`(short)(value & 65535)`),
|
||||
/// so 40000 is -25536 and never counts down.
|
||||
#[test]
|
||||
fn a_mob_narrows_its_hurt_and_death_time_to_a_short() {
|
||||
let kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let types = [(kind.id.to_string(), kind.clone())].into();
|
||||
let load = |hurt: Tag, death: Tag| {
|
||||
let raw = Tag::compound([
|
||||
("id", string(&kind.id)),
|
||||
("Pos", doubles(&[4.5, 220.0, 4.5])),
|
||||
("HurtTime", hurt),
|
||||
("DeathTime", death),
|
||||
]);
|
||||
let m = mob_from_nbt(&raw, &types, 1, None).unwrap();
|
||||
(m.hurt_time, m.death_time)
|
||||
};
|
||||
assert_eq!(load(Tag::Int(40000), Tag::Int(65546)), (-25536, 10));
|
||||
assert_eq!(load(Tag::Short(7), Tag::Short(19)), (7, 19));
|
||||
}
|
||||
|
||||
/// A mount keeps its body armour and a helmet (say
|
||||
/// `/item replace entity <horse> armor.head with minecraft:iron_helmet`)
|
||||
/// in their own slots through a save: vanilla holds both (`equipment.body`
|
||||
@@ -5891,6 +5861,43 @@ mod tests {
|
||||
assert_eq!(attributes(&armor_stand_from_nbt(&saved, 1).unwrap()), speed);
|
||||
}
|
||||
#[test]
|
||||
fn a_loaded_stand_gains_the_speed_instance_its_first_tick_makes() {
|
||||
// Vanilla 26.3: `/summon armor_stand ~ ~ ~ {attributes:[]}` saves `[]` until the
|
||||
// first tick, whose removeFrost makes movement_speed (base 0.7d); a saved
|
||||
// instance of another attribute stays in front of it.
|
||||
let ids = |stand: &ArmorStand, raw: &Tag| match armor_stand_to_nbt(stand, [0; 4], Some(raw))
|
||||
.get("attributes")
|
||||
{
|
||||
Some(Tag::List(attrs)) => attrs
|
||||
.iter()
|
||||
.map(|a| (text(a.get("id")).unwrap(), a.get("base").cloned()))
|
||||
.collect::<Vec<_>>(),
|
||||
other => panic!("attributes: {other:?}"),
|
||||
};
|
||||
let health = (
|
||||
"minecraft:max_health".to_owned(),
|
||||
Some(Tag::Double(20f64.to_bits())),
|
||||
);
|
||||
let speed = (
|
||||
"minecraft:movement_speed".to_owned(),
|
||||
Some(Tag::Double(0.7f64.to_bits())),
|
||||
);
|
||||
let empty = Tag::compound([("attributes", Tag::List(Vec::new()))]);
|
||||
let saved = Tag::compound([(
|
||||
"attributes",
|
||||
Tag::List(vec![Tag::compound([
|
||||
("id", string("minecraft:max_health")),
|
||||
("base", Tag::Double(20f64.to_bits())),
|
||||
])]),
|
||||
)]);
|
||||
let mut stand = ArmorStand::new(1, Vec3::ZERO, 0.0);
|
||||
assert_eq!(ids(&stand, &empty), []);
|
||||
assert_eq!(ids(&stand, &saved), std::slice::from_ref(&health));
|
||||
stand.speed_instance = true;
|
||||
assert_eq!(ids(&stand, &empty), std::slice::from_ref(&speed));
|
||||
assert_eq!(ids(&stand, &saved), [health, speed]);
|
||||
}
|
||||
#[test]
|
||||
fn summoned_trident_defaults_are_not_pickable_or_already_shot() {
|
||||
let raw = Tag::compound([
|
||||
("Pos", doubles(&[0.0, 0.0, 0.0])),
|
||||
@@ -7411,11 +7418,11 @@ mod sheep_nbt_tests {
|
||||
assert_eq!(number(tag.get("InWaterTime")), Some(300));
|
||||
assert_eq!(number(tag.get("DrownedConversionTime")), Some(-1));
|
||||
assert_eq!((back.extra.counter, back.extra.counter2), (300, 0));
|
||||
(zombie.extra.counter, zombie.extra.counter2) = (600, 300);
|
||||
(zombie.extra.counter, zombie.extra.counter2) = (600, 301);
|
||||
let (back, tag) = round_trip(&zombie);
|
||||
assert_eq!(number(tag.get("InWaterTime")), Some(600));
|
||||
assert_eq!(number(tag.get("DrownedConversionTime")), Some(299));
|
||||
assert_eq!((back.extra.counter, back.extra.counter2), (600, 300));
|
||||
assert_eq!(number(tag.get("DrownedConversionTime")), Some(300));
|
||||
assert_eq!((back.extra.counter, back.extra.counter2), (600, 301));
|
||||
// A skeleton outside powder snow is not afflicted: -1 whatever the
|
||||
// counter holds, as with a fresh or NoAI one.
|
||||
let (_, tag) = round_trip(&new("minecraft:skeleton"));
|
||||
@@ -9462,6 +9469,34 @@ mod pickup_loot_save_tests {
|
||||
mod stand_fall_save_tests {
|
||||
use super::*;
|
||||
|
||||
/// `Entity.load` reads `Fire` with `getShortOr`: an int tag is narrowed
|
||||
/// (`(short)(value & 65535)`), so 40000 is -25536 and the stand or mob
|
||||
/// does not burn.
|
||||
#[test]
|
||||
fn a_stand_and_a_mob_narrow_a_loaded_fire_to_a_short() {
|
||||
let kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let types = [(kind.id.to_string(), kind.clone())].into();
|
||||
for (fire, expected) in [
|
||||
(Tag::Int(40000), -25536),
|
||||
(Tag::Int(65546), 10),
|
||||
(Tag::Short(60), 60),
|
||||
] {
|
||||
let input = Tag::compound([
|
||||
("id", string(&kind.id)),
|
||||
("Pos", doubles(&[4.5, 205.0, 4.5])),
|
||||
("Fire", fire),
|
||||
]);
|
||||
assert_eq!(
|
||||
armor_stand_from_nbt(&input, 1).unwrap().fire_ticks,
|
||||
expected
|
||||
);
|
||||
assert_eq!(
|
||||
mob_from_nbt(&input, &types, 1, None).unwrap().fire_ticks,
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoded_stand_fall_distance_survives_save_without_raw_or_with_stale_raw() {
|
||||
let stale = Tag::compound([("fall_distance", Tag::Double(99.0_f64.to_bits()))]);
|
||||
|
||||
@@ -39,10 +39,16 @@ fn attached_block_pos(tag: &Tag) -> Option<[i32; 3]> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Direction.from3DDataValue` / `from2DDataValue`: the byte read by
|
||||
/// `LEGACY_ID_CODEC` indexes the table as `abs(data % modulus)`, never clamped.
|
||||
fn wrap_data_value(raw: impl TryInto<i64> + Copy, modulus: i64) -> u8 {
|
||||
let byte = raw.try_into().unwrap_or(0) as i8;
|
||||
(i64::from(byte) % modulus).unsigned_abs() as u8
|
||||
}
|
||||
/// The block position, facing and variant (if saved) of a painting.
|
||||
pub fn painting_from_nbt(tag: &Tag) -> Option<([i32; 3], u8, Option<String>)> {
|
||||
let pos = attached_block_pos(tag)?;
|
||||
let facing = (number(tag.get("facing")).unwrap_or(0) & 3) as u8;
|
||||
let facing = wrap_data_value(number(tag.get("facing")).unwrap_or(0), 4);
|
||||
Some((pos, facing, text(tag.get("variant"))))
|
||||
}
|
||||
/// ItemFrame.addAdditionalSaveData.
|
||||
@@ -92,7 +98,7 @@ pub fn item_frame_from_nbt(tag: &Tag, id: u32) -> Option<ItemFrame> {
|
||||
Some(ItemFrame {
|
||||
id,
|
||||
pos,
|
||||
facing: number(tag.get("Facing")).unwrap_or(0).clamp(0, 5) as u8,
|
||||
facing: wrap_data_value(number(tag.get("Facing")).unwrap_or(0), 6),
|
||||
glow: kind(tag).as_deref() == Some("minecraft:glow_item_frame"),
|
||||
item: tag
|
||||
.get("Item")
|
||||
@@ -107,3 +113,26 @@ pub fn item_frame_from_nbt(tag: &Tag, id: u32) -> Option<ItemFrame> {
|
||||
random: crate::random_source::entity(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn negative_facing_wraps_like_vanilla_direction_codecs() {
|
||||
// from3DDataValue: abs(-1 % 6) = 1 (UP); 7 -> 1; -6 -> 0.
|
||||
for (raw, want) in [(-1i8, 1u8), (7, 1), (-6, 0), (5, 5), (0, 0)] {
|
||||
let tag = Tag::compound([
|
||||
("Facing", Tag::Byte(raw)),
|
||||
("block_pos", Tag::IntArray(vec![0, 0, 0])),
|
||||
]);
|
||||
assert_eq!(item_frame_from_nbt(&tag, 1).unwrap().facing, want);
|
||||
}
|
||||
// from2DDataValue: abs(-1 % 4) = 1.
|
||||
let tag = Tag::compound([
|
||||
("facing", Tag::Byte(-1)),
|
||||
("block_pos", Tag::IntArray(vec![0, 0, 0])),
|
||||
]);
|
||||
assert_eq!(painting_from_nbt(&tag).unwrap().1, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Experience orbs: `ExperienceOrb` save data.
|
||||
use super::{Base, ExperienceOrb, Tag, flag, int_value, motion, number, position, put, rotation};
|
||||
|
||||
/// `ExperienceOrb`: Health, Age, Value, Count.
|
||||
pub fn orb_to_nbt(o: &ExperienceOrb, uuid: [i32; 4]) -> Tag {
|
||||
let base = Base {
|
||||
id: "minecraft:experience_orb",
|
||||
uuid,
|
||||
position: o.position,
|
||||
motion: o.velocity,
|
||||
rotation: o.rotation,
|
||||
on_ground: o.on_ground,
|
||||
fall_distance: 0.0,
|
||||
};
|
||||
let mut tag = base.tag(None);
|
||||
for (key, value) in [
|
||||
("Health", Tag::Short(o.health as i16)),
|
||||
("Age", Tag::Short(o.age.min(i16::MAX as u32) as i16)),
|
||||
("Value", Tag::Short(o.value as i16)),
|
||||
("Count", Tag::Int(o.count.min(i32::MAX as u32) as i32)),
|
||||
] {
|
||||
put(&mut tag, key, value);
|
||||
}
|
||||
// Entity.saveWithoutId writes NoGravity only when set.
|
||||
if o.no_gravity {
|
||||
put(&mut tag, "NoGravity", Tag::Byte(1));
|
||||
}
|
||||
tag
|
||||
}
|
||||
/// One orb with its merged `Count`.
|
||||
pub fn orbs_from_nbt(tag: &Tag) -> Vec<ExperienceOrb> {
|
||||
let Some(position) = position(tag) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let count = number(tag.get("Count")).unwrap_or(1).max(1) as u32;
|
||||
let orb = ExperienceOrb {
|
||||
rotation: rotation(tag),
|
||||
join: Default::default(),
|
||||
no_gravity: flag(tag, "NoGravity"),
|
||||
id: 0,
|
||||
position,
|
||||
previous: position,
|
||||
velocity: motion(tag),
|
||||
// `getShortOr("Value", 0)`, unclamped.
|
||||
value: tag.get("Value").and_then(int_value).unwrap_or(0) as i16 as i32,
|
||||
age: number(tag.get("Age")).unwrap_or(0).max(0) as u32,
|
||||
on_ground: flag(tag, "OnGround"),
|
||||
count,
|
||||
ticks: 0,
|
||||
health: number(tag.get("Health")).unwrap_or(5) as i32,
|
||||
// ExperienceOrb's followingPlayer is transient: never saved, so a
|
||||
// loaded orb starts without one.
|
||||
following: None,
|
||||
};
|
||||
vec![orb]
|
||||
}
|
||||
@@ -497,7 +497,7 @@ use horizon_client::random_source;
|
||||
pub use advancements::AdvancementDisplay;
|
||||
use horizon_client::actions::Action as Mapping;
|
||||
use horizon_client::held::Source;
|
||||
use horizon_client::spans::startup;
|
||||
use horizon_client::spans::{startup, time as span};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
assets::{Assets, RenderAssets},
|
||||
@@ -3889,6 +3889,7 @@ impl Game<'_> {
|
||||
golem_chests.extend(self.chest_other_half(key));
|
||||
}
|
||||
let native_authoritative = self.native_authoritative();
|
||||
let environment_type = self.dimension_type();
|
||||
let mut level = GameLevel {
|
||||
gateway_servers: &mut self.be_anims.gateway_servers,
|
||||
border: self.border.geometry(),
|
||||
@@ -3911,11 +3912,7 @@ impl Game<'_> {
|
||||
events: &mut self.level_events,
|
||||
dimension,
|
||||
darken,
|
||||
environment: match dimension {
|
||||
LevelDimension::Overworld => self.res.environment(),
|
||||
LevelDimension::Nether => self.res.nether_environment(),
|
||||
LevelDimension::End => self.res.end_environment(),
|
||||
},
|
||||
environment: self.res.environment_of(&environment_type.id),
|
||||
raining: rain > 0.2,
|
||||
thundering: thunder > 0.9,
|
||||
player_box,
|
||||
@@ -5545,7 +5542,7 @@ impl Game<'_> {
|
||||
// the activated item counts down.
|
||||
self.boss_overlay.tick_darken(running);
|
||||
self.tick_item_activation();
|
||||
self.native_inventory_ticks();
|
||||
span("mods_inventory_ticks", || self.native_inventory_ticks());
|
||||
if running {
|
||||
// `MinecraftServer.tickChildren`: `commandFunctions` runs before
|
||||
// any level ticks, so a tick function reads the game time and the
|
||||
@@ -5553,7 +5550,7 @@ impl Game<'_> {
|
||||
// long before this point; the lazy scoreboard/storage read has to
|
||||
// be done by then too, or a load tag writes over what it restores.
|
||||
self.load_scoreboard();
|
||||
self.tick_functions();
|
||||
span("tick_functions", || self.tick_functions());
|
||||
self.world.game_time += 1;
|
||||
if self.ui.rules.bool("advance_weather") {
|
||||
self.world.weather.tick_timers(&mut self.world.random);
|
||||
@@ -5567,7 +5564,7 @@ impl Game<'_> {
|
||||
}
|
||||
// ClientLevel.tick: the sky flash fades in a frozen level too.
|
||||
self.tick_weather_effects();
|
||||
let _ = self.finish_player_autosaves(false);
|
||||
let _ = span("player_autosaves", || self.finish_player_autosaves(false));
|
||||
// MinecraftServer.tickServer's countdown, then autoSave: three
|
||||
// hundred seconds of ticks at the rate the server runs at now.
|
||||
self.ticks_until_autosave -= 1;
|
||||
@@ -5576,7 +5573,7 @@ impl Game<'_> {
|
||||
serving.set_saving(self.autosaving);
|
||||
}
|
||||
if self.autosaving {
|
||||
self.save_game_with(false)?;
|
||||
span("autosave", || self.save_game_with(false))?;
|
||||
self.ticks_until_autosave = self.next_autosave_interval();
|
||||
if let Some(serving) = &self.serving {
|
||||
serving.set_saving(false);
|
||||
@@ -5585,27 +5582,27 @@ impl Game<'_> {
|
||||
if running {
|
||||
self.advance_clock();
|
||||
// `ServerLevel.tickTime`, once the clock has moved on.
|
||||
self.run_scheduled_functions();
|
||||
span("scheduled_functions", || self.run_scheduled_functions());
|
||||
}
|
||||
if self.use_delay > 0 {
|
||||
self.use_delay -= 1;
|
||||
}
|
||||
self.tick_totem();
|
||||
self.update_totem_ready();
|
||||
self.tick_command_extras();
|
||||
self.native_tick();
|
||||
self.tell_native_now();
|
||||
span("tick_command_extras", || self.tick_command_extras());
|
||||
span("mods_tick", || self.native_tick());
|
||||
span("mods_tick", || self.tell_native_now());
|
||||
self.tick_world(running)
|
||||
}
|
||||
/// What the tick runs after the local player's own update: effects, the
|
||||
/// world's entities and blocks, and the network hub.
|
||||
fn tick_world(&mut self, running: bool) -> Result<()> {
|
||||
// Entities added since the last tick join the level before it ticks.
|
||||
self.native_joins_flush(false, 0);
|
||||
self.share_spawn_conditions();
|
||||
span("mods_joins_flush", || self.native_joins_flush(false, 0));
|
||||
span("share_spawn_conditions", || self.share_spawn_conditions());
|
||||
horizon_client::mobs::sulfur_cube::set_tnt_explodes(self.ui.rules.bool("tnt_explodes"));
|
||||
self.update_chunk_tickets();
|
||||
self.score_destroyed_entities();
|
||||
span("chunk_tickets", || self.update_chunk_tickets());
|
||||
span("score_destroyed", || self.score_destroyed_entities());
|
||||
horizon_client::spans::time("tick_effects", || self.tick_effects(running));
|
||||
self.tick_sign_editor();
|
||||
// Player.tick's ItemCooldowns.tick for the window's player (a
|
||||
@@ -5629,8 +5626,8 @@ impl Game<'_> {
|
||||
sent = horizon_client::spans::time("tick_blocks", || self.tick_blocks())?;
|
||||
// What the blocks spawned (a dispensed minecart, a lit TNT, a
|
||||
// placed boat) joins before the entities tick.
|
||||
self.native_joins_flush(false, 0);
|
||||
self.lan_mark_synced();
|
||||
span("mods_joins_flush", || self.native_joins_flush(false, 0));
|
||||
span("lan_mark_synced", || self.lan_mark_synced());
|
||||
// `EntityTickList.forEach`: what joined before the entity pass
|
||||
// (function summons, dropped or dispensed items) ticks in it;
|
||||
// what joins during it waits for the next tick
|
||||
@@ -5640,7 +5637,7 @@ impl Game<'_> {
|
||||
}
|
||||
// ChunkMap still ticks while /tick freeze holds block/entity ticks.
|
||||
if !running {
|
||||
self.save_chunks_eagerly();
|
||||
span("save_chunks_eagerly", || self.save_chunks_eagerly());
|
||||
}
|
||||
self.begin_ordered_ticks();
|
||||
if running {
|
||||
@@ -5654,8 +5651,8 @@ impl Game<'_> {
|
||||
// ServerLevel.tick: the arrivals of this server tick are in the level
|
||||
// (`addDuringTeleport`), and each vehicle handles its portal as its
|
||||
// own tick opens.
|
||||
self.land_portal_arrivals();
|
||||
self.handle_vehicle_portals();
|
||||
span("portal_arrivals", || self.land_portal_arrivals());
|
||||
span("vehicle_portals", || self.handle_vehicle_portals());
|
||||
self.ordered_before(ordered_tick::Batch::Boats)?;
|
||||
horizon_client::spans::time("tick_boats", || self.tick_boats());
|
||||
self.ordered_before(ordered_tick::Batch::Carts)?;
|
||||
@@ -5685,11 +5682,11 @@ impl Game<'_> {
|
||||
self.ordered_before(ordered_tick::Batch::Items)?;
|
||||
horizon_client::spans::time("tick_items", || self.tick_items());
|
||||
if self.remote.is_none() {
|
||||
self.hoppers_catch_items();
|
||||
span("hoppers_catch_items", || self.hoppers_catch_items());
|
||||
}
|
||||
// Merged, burnt, despawned or picked up.
|
||||
self.native_leaves_flush();
|
||||
self.native_joins_flush(false, 0);
|
||||
span("mods_leaves_flush", || self.native_leaves_flush());
|
||||
span("mods_joins_flush", || self.native_joins_flush(false, 0));
|
||||
horizon_client::spans::time("touch_arrows", || self.touch_arrows());
|
||||
}
|
||||
horizon_client::spans::time("spawn_loaded_entities", || self.spawn_loaded_entities());
|
||||
@@ -5699,22 +5696,22 @@ impl Game<'_> {
|
||||
self.ordered_before(ordered_tick::Batch::Mobs)?;
|
||||
horizon_client::spans::time("tick_mobs", || self.tick_mobs())?;
|
||||
// Dead, despawned or gone into a hive.
|
||||
self.native_leaves_flush();
|
||||
span("mods_leaves_flush", || self.native_leaves_flush());
|
||||
horizon_client::spans::time("recheck_golem_openers", || self.recheck_golem_openers());
|
||||
self.tick_mod_container_viewers();
|
||||
span("mod_viewers", || self.tick_mod_container_viewers());
|
||||
// Orbs, primed TNT and falling blocks still to tick, in join order.
|
||||
self.run_ordered_ticks(horizon_client::join_seq::JoinSeq::MAX, false)?;
|
||||
horizon_client::spans::time("tick_experience", || self.tick_experience());
|
||||
// Merged, despawned or picked up.
|
||||
self.native_leaves_flush();
|
||||
span("mods_leaves_flush", || self.native_leaves_flush());
|
||||
horizon_client::spans::time("entity_portals", || {
|
||||
self.find_entity_portals();
|
||||
self.resolve_arrivals();
|
||||
});
|
||||
}
|
||||
// The rest of the tick's spawns join, and what left is told of.
|
||||
self.native_joins_flush(false, 0);
|
||||
self.native_leaves_flush();
|
||||
span("mods_joins_flush", || self.native_joins_flush(false, 0));
|
||||
span("mods_leaves_flush", || self.native_leaves_flush());
|
||||
// ServerLevel.tick broadcasts the changed blocks after the scheduled and
|
||||
// random ticks (`tick_blocks`), before the block events, the entities
|
||||
// and the block entities: what those change goes out with the next
|
||||
@@ -5729,23 +5726,23 @@ impl Game<'_> {
|
||||
if let Some(log) = self.world.block_log.as_mut() {
|
||||
log.extend(held);
|
||||
}
|
||||
self.tick_friends();
|
||||
span("tick_friends", || self.tick_friends());
|
||||
horizon_client::spans::time("tick_hand", || self.tick_hand());
|
||||
horizon_client::spans::time("tick_sleep", || self.tick_sleep());
|
||||
self.announce_sleep_status();
|
||||
self.free_left_beds();
|
||||
span("sleep_status", || self.announce_sleep_status());
|
||||
span("sleep_status", || self.free_left_beds());
|
||||
if running {
|
||||
horizon_client::spans::time("tick_end", || self.tick_end())?;
|
||||
horizon_client::spans::time("end_gateways", || {
|
||||
self.find_end_gateway_portals();
|
||||
self.resolve_end_gateway_arrivals()
|
||||
})?;
|
||||
self.keep_gateway_destinations();
|
||||
span("end_gateways", || self.keep_gateway_destinations());
|
||||
}
|
||||
if running {
|
||||
// Primed TNT and falling blocks that joined since the spawn packets.
|
||||
self.run_ordered_ticks(horizon_client::join_seq::JoinSeq::MAX, true)?;
|
||||
self.apply_late_wind_pushes();
|
||||
span("late_wind_pushes", || self.apply_late_wind_pushes());
|
||||
}
|
||||
// Everything that has joined has had its spawn packet and, if it joined
|
||||
// before the entity passes (TNT a piston or repeater primed), its first
|
||||
@@ -5764,21 +5761,21 @@ impl Game<'_> {
|
||||
self.tick_block_entity_anims()
|
||||
});
|
||||
// A conduit's hurt teleports an enderman now (`Enderman.hurtServer`).
|
||||
self.settle_hurt_teleports()?;
|
||||
span("settle_hurt_teleports", || self.settle_hurt_teleports())?;
|
||||
horizon_client::spans::time("tick_particles", || self.tick_particles());
|
||||
self.tick_client_ambient();
|
||||
span("tick_client_ambient", || self.tick_client_ambient());
|
||||
}
|
||||
// ServerLevel.tick's `entityManager.tick()`, frozen or not, after every
|
||||
// block entity ticker (the bells, conduits and gateways above too): the
|
||||
// chunks loaded before this pass have their entities loaded from the next.
|
||||
self.world.entities_pending.clear();
|
||||
self.tick_frame_visuals(running);
|
||||
span("tick_frame_visuals", || self.tick_frame_visuals(running));
|
||||
if running {
|
||||
// MinecraftServer.tickServer: GameTestTicker.SINGLETON.tick().
|
||||
self.tick_game_tests();
|
||||
span("tick_game_tests", || self.tick_game_tests());
|
||||
}
|
||||
// ServerBossEvent broadcasts, after this level's entities have ticked.
|
||||
self.publish_boss_bars();
|
||||
span("publish_boss_bars", || self.publish_boss_bars());
|
||||
// Minecraft.handleKeybinds: held use repeats only while no item is in
|
||||
// use (a raised shield or a drawn bow places and uses nothing).
|
||||
if self.right
|
||||
@@ -6734,19 +6731,14 @@ impl Game<'_> {
|
||||
// Entity shadows (EntityRenderer.extractShadow); a native pack's sun shadows replace them.
|
||||
self.mark_translucent_entity(out.len(), "minecraft:entity_shadow");
|
||||
let pack_shadows = self.renderer.as_ref().is_some_and(|r| {
|
||||
r.pack_entity_shadows() || (self.dim == Dim::Overworld && r.native_shadow())
|
||||
r.pack_entity_shadows() || (self.overworld_skybox() && r.native_shadow())
|
||||
});
|
||||
let shadows = self.settings.entity_shadows && self.pack.polytone_colors.entity_shadows();
|
||||
let shadow_sprite = (shadows && !pack_shadows)
|
||||
.then(|| self.gui.atlas.sprites.get("misc/shadow").copied())
|
||||
.flatten();
|
||||
if let Some(sprite) = shadow_sprite {
|
||||
// DimensionType.ambientLight: the_nether 0.1, the_end 0.25.
|
||||
let ambient = match self.dim {
|
||||
Dim::Nether => 0.1,
|
||||
Dim::End => 0.25,
|
||||
Dim::Overworld => 0.0,
|
||||
};
|
||||
let ambient = self.dimension_type().ambient_light;
|
||||
let darken = self.sky_darken();
|
||||
let a = alpha;
|
||||
let lerp = |p: Vec3, q: Vec3| {
|
||||
@@ -6909,7 +6901,7 @@ impl Game<'_> {
|
||||
emissive: &self.gui.atlas.emissive,
|
||||
translucent_sprites: &self.gui.atlas.translucent,
|
||||
atlas_revision: self.gui.atlas.revision,
|
||||
nether: self.dim == Dim::Nether,
|
||||
nether: self.nether_lighting(),
|
||||
lid: &lid,
|
||||
sign: &|p| match self.world.block_entities.get(&(p[0], p[1], p[2])) {
|
||||
Some(BlockEntity::Sign(sign)) => Some(sign),
|
||||
@@ -7627,8 +7619,10 @@ impl Game<'_> {
|
||||
r.clear_sections(self.world.layout());
|
||||
}
|
||||
self.apply_settings();
|
||||
self.streaming.sky_light = dim != Dim::Nether;
|
||||
self.streaming.nether_shading = dim == Dim::Nether;
|
||||
let facts = self.dimension_type_of(dim);
|
||||
self.streaming.sky_light = facts.height.has_skylight;
|
||||
self.streaming.nether_shading =
|
||||
facts.cardinal_light == horizon_world::dimension_type::CardinalLight::Nether;
|
||||
self.load_world_state();
|
||||
// The level the world opens in, before any of its chunks.
|
||||
self.native_level_opened(dim);
|
||||
@@ -9317,10 +9311,10 @@ impl Game<'_> {
|
||||
game_time: self.world.game_time,
|
||||
day_time: self.view_day_time(),
|
||||
render_distance: self.streaming.radius,
|
||||
sky_type: match self.dim {
|
||||
Dim::Overworld => 1,
|
||||
Dim::End => 2,
|
||||
Dim::Nether => 0,
|
||||
sky_type: match self.dimension_type().skybox {
|
||||
horizon_world::dimension_type::Skybox::Overworld => 1,
|
||||
horizon_world::dimension_type::Skybox::End => 2,
|
||||
horizon_world::dimension_type::Skybox::None => 0,
|
||||
},
|
||||
dimension: match self.dim {
|
||||
Dim::Overworld => "minecraft:overworld",
|
||||
@@ -9662,13 +9656,13 @@ impl Game<'_> {
|
||||
pr.lap("pr_dragon_bolts");
|
||||
let (celestial, stars) = if title {
|
||||
(Vec::new(), None)
|
||||
} else if self.dim == Dim::Overworld
|
||||
} else if self.overworld_skybox()
|
||||
&& self.potions.effects.get("darkness").is_none()
|
||||
&& self.potions.effects.get("blindness").is_none()
|
||||
{
|
||||
// Camera.doesMobEffectBlockSky: no sun, moon or stars.
|
||||
self.celestial_geometry(&sky, rain)
|
||||
} else if self.dim == Dim::End && self.settings.horizon.sky {
|
||||
} else if self.end_skybox() && self.settings.horizon.sky {
|
||||
(self.end_flash_geometry(), None)
|
||||
} else {
|
||||
(Vec::new(), None)
|
||||
@@ -9742,7 +9736,7 @@ impl Game<'_> {
|
||||
let frame = Frame {
|
||||
weather: &weather,
|
||||
weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32),
|
||||
nether_lighting: self.dim == Dim::Nether,
|
||||
nether_lighting: self.nether_lighting(),
|
||||
camera,
|
||||
yaw,
|
||||
pitch,
|
||||
@@ -9782,8 +9776,8 @@ impl Game<'_> {
|
||||
|| 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,
|
||||
skybox: self.overworld_skybox() && self.settings.horizon.sky,
|
||||
end_sky: self.end_skybox() && self.settings.horizon.sky,
|
||||
end_sky_sprite: self
|
||||
.gui
|
||||
.atlas
|
||||
@@ -9807,7 +9801,7 @@ impl Game<'_> {
|
||||
glint_alpha: self.glint_frame.alpha,
|
||||
glint_ticks: self.glint_frame.scroll_ticks(),
|
||||
clouds: (!title
|
||||
&& self.dim == Dim::Overworld
|
||||
&& self.overworld_skybox()
|
||||
&& horizon_client::clouds::visible(&self.settings.clouds, sky.cloud_color[3]))
|
||||
.then(|| self.gui.atlas.sprites.get("horizon/white"))
|
||||
.flatten()
|
||||
|
||||
@@ -1954,8 +1954,7 @@ impl Game<'_> {
|
||||
mut skin: Option<&mut horizon_client::mobs::skin::Skin>,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let lights =
|
||||
horizon_client::block_entity_render::diffuse_lights(self.dim == super::Dim::Nether);
|
||||
let lights = horizon_client::block_entity_render::diffuse_lights(self.nether_lighting());
|
||||
let mut submit = |state: &horizon_client::item_render::ItemRenderState,
|
||||
pose: glam::Mat4,
|
||||
out: &mut Vec<Vertex>| {
|
||||
@@ -2028,7 +2027,7 @@ impl Game<'_> {
|
||||
out,
|
||||
light,
|
||||
lights: Some(horizon_client::block_entity_render::diffuse_lights(
|
||||
self.dim == super::Dim::Nether,
|
||||
self.nether_lighting(),
|
||||
)),
|
||||
glint: self.glint(),
|
||||
tier: 0,
|
||||
|
||||
@@ -801,8 +801,17 @@ impl Game<'_> {
|
||||
let server = self.be_anims.gateway_servers.entry(key).or_insert(initial);
|
||||
let triggered = server.portal_tick();
|
||||
let age = server.age;
|
||||
if let Some(BlockEntity::EndGateway(g)) = self.world.block_entities.get_mut(&pos) {
|
||||
let mut aged = false;
|
||||
if let Some(BlockEntity::EndGateway(g)) = self.world.block_entities.get_mut(&pos)
|
||||
&& g.age != age
|
||||
{
|
||||
g.age = age;
|
||||
aged = true;
|
||||
}
|
||||
if aged {
|
||||
// `TheEndGatewayBlockEntity.portalTick` ends with `setChanged`: the
|
||||
// age the beam and the chunk save read moved.
|
||||
self.world.modified.insert((pos.0 >> 4, pos.2 >> 4));
|
||||
}
|
||||
if triggered {
|
||||
self.gateway_cooldown_event(key);
|
||||
@@ -1108,3 +1117,41 @@ mod bell_ownership_tests {
|
||||
assert!(anims.bells.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gateway_age_tests {
|
||||
use super::*;
|
||||
use crate::game::native_machines::tests::server_game;
|
||||
|
||||
/// `TheEndGatewayBlockEntity.portalTick`: an age that moved is a change
|
||||
/// to save.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_gateway_that_ages_marks_the_chunk() {
|
||||
let mut game = server_game();
|
||||
let state = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:end_gateway")
|
||||
.unwrap();
|
||||
let pos = (2, 4, 3);
|
||||
game.world.set(pos.0, pos.1, pos.2, state).unwrap();
|
||||
game.world.block_entities.insert(
|
||||
pos,
|
||||
BlockEntity::EndGateway(horizon_client::containers::EndGateway::default()),
|
||||
);
|
||||
game.world.modified.clear();
|
||||
|
||||
game.tick_end_gateway(pos);
|
||||
|
||||
let Some(BlockEntity::EndGateway(g)) = game.world.block_entities.get(&pos) else {
|
||||
panic!("no gateway");
|
||||
};
|
||||
assert_eq!(g.age, 1, "the tick aged the gateway");
|
||||
assert!(
|
||||
game.world.modified.contains(&(0, 0)),
|
||||
"the aged gateway left the chunk unmarked"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,21 +26,7 @@ impl Game<'_> {
|
||||
self.sync_tick_chunks();
|
||||
self.start_ticking_chunks();
|
||||
laps.lap("tb_tickets");
|
||||
let levels = self.tickets.levels();
|
||||
let bounds = levels.random_tick_bounds();
|
||||
// Keep the world map's chunk order, including empty chunks
|
||||
// (tick_chunk_random still draws for precipitation in those).
|
||||
scratch.ticking.clear();
|
||||
scratch
|
||||
.ticking
|
||||
.extend(self.world.chunks.keys().copied().filter(|&(x, z)| {
|
||||
if let Some((min_x, min_z, max_x, max_z)) = bounds
|
||||
&& (x < min_x || x > max_x || z < min_z || z > max_z)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
levels.random_ticking(x, z) && ticking_chunk(&self.world, x, z)
|
||||
}));
|
||||
self.random_tick_chunks(&mut scratch.ticking);
|
||||
laps.lap("tb_chunks");
|
||||
// Entity.checkInsideBlocks for the plates, tripwires, detector rails and
|
||||
// buttons under the players (the host and the LAN guests), the minecarts
|
||||
@@ -287,6 +273,30 @@ impl Game<'_> {
|
||||
Ok(sent)
|
||||
}
|
||||
|
||||
/// `ChunkMap.forEachBlockTickingChunk`: the chunks that random tick this
|
||||
/// tick, empty ones included (`tickChunk` still draws for precipitation in
|
||||
/// those).
|
||||
///
|
||||
/// Vanilla walks the simulation tracker's own table, whose order doesn't
|
||||
/// follow chunk loading. The world map's order does: it is filled as the
|
||||
/// streaming workers finish, so the level random's draws landed on other
|
||||
/// blocks from one run of a world to the next (#15164). Chunk position
|
||||
/// order is the same every run; vanilla's exact order is its fastutil
|
||||
/// table's slot order, which follows that table's whole history.
|
||||
pub(super) fn random_tick_chunks(&self, out: &mut Vec<(i32, i32)>) {
|
||||
let levels = self.tickets.levels();
|
||||
let bounds = levels.random_tick_bounds();
|
||||
out.clear();
|
||||
out.extend(self.world.chunks.keys().copied().filter(|&(x, z)| {
|
||||
if let Some((min_x, min_z, max_x, max_z)) = bounds
|
||||
&& (x < min_x || x > max_x || z < min_z || z > max_z)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
levels.random_ticking(x, z) && ticking_chunk(&self.world, x, z)
|
||||
}));
|
||||
out.sort_unstable();
|
||||
}
|
||||
/// Track which chunks are ticking (Ticks needs chunk load/unload).
|
||||
pub(super) fn sync_tick_chunks(&mut self) {
|
||||
if !self.tick_chunks.synchronize(&self.world.chunks) {
|
||||
@@ -360,3 +370,6 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod random_order_tests;
|
||||
@@ -0,0 +1,57 @@
|
||||
//! `ChunkMap.forEachBlockTickingChunk`: the order chunks random tick in must
|
||||
//! not follow the order they finished loading in, or the level random's draws
|
||||
//! land on other blocks from one run of a world to the next (#15164).
|
||||
|
||||
use super::super::Game;
|
||||
use super::super::native_machines::tests::server_game;
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A game whose player stands in chunk (0, 0), with lit chunks loaded in
|
||||
/// `order`.
|
||||
fn game_loading(order: &[(i32, i32)]) -> Game<'static> {
|
||||
let mut game = server_game();
|
||||
game.settings.simulation_distance = 5;
|
||||
game.settings.render_distance = 8;
|
||||
game.player = Some(
|
||||
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(8.5, 65.0, 8.5)).unwrap(),
|
||||
);
|
||||
game.world.chunks.clear();
|
||||
for &(x, z) in order {
|
||||
let mut chunk = Chunk::empty(x, z);
|
||||
chunk.lit = true;
|
||||
game.world.chunks.insert((x, z), Arc::new(chunk));
|
||||
}
|
||||
game.update_chunk_tickets();
|
||||
game
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn chunks_random_tick_in_the_same_order_however_they_loaded() {
|
||||
let mut loaded: Vec<(i32, i32)> = (-8..=8)
|
||||
.flat_map(|x| (-8..=8).map(move |z| (x, z)))
|
||||
.collect();
|
||||
let first = game_loading(&loaded);
|
||||
// The streaming workers finish in another order on another run.
|
||||
loaded.reverse();
|
||||
loaded.rotate_left(97);
|
||||
let second = game_loading(&loaded);
|
||||
|
||||
let (mut a, mut b) = (Vec::new(), Vec::new());
|
||||
first.random_tick_chunks(&mut a);
|
||||
second.random_tick_chunks(&mut b);
|
||||
assert!(
|
||||
a.contains(&(0, 0)) && a.contains(&(4, -4)),
|
||||
"the simulation distance's chunks random tick: {a:?}"
|
||||
);
|
||||
assert!(
|
||||
!a.contains(&(8, 0)),
|
||||
"a chunk past the simulation distance doesn't"
|
||||
);
|
||||
assert_eq!(a, b);
|
||||
let mut sorted = a.clone();
|
||||
sorted.sort_unstable();
|
||||
assert_eq!(a, sorted, "chunk position order");
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
//! paddling controls, collisions, riding (`positionRider`, `clampRotation`,
|
||||
//! dismounting), `BoatItem` placement, `VehicleEntity.hurtServer` breaking,
|
||||
//! saving and `AbstractBoatRenderer` with animated paddles.
|
||||
mod below_world;
|
||||
mod placement;
|
||||
|
||||
use super::{Game, InteractTarget, entity::Entity, entity_culling::at, view_vector};
|
||||
@@ -21,7 +22,7 @@ use horizon_client::{
|
||||
motion::{BubbleColumn, RiddenBoat, bubble_column_at},
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, ShapeOp, Vec3, VoxelShape},
|
||||
geometry::{Aabb, ShapeOp, Vec3, VoxelShape, trig},
|
||||
position::BlockPos,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
@@ -96,6 +97,9 @@ pub(super) struct Boat {
|
||||
/// `Entity.fallDistance`: counted by `AbstractBoat.checkFallDamage`, saved
|
||||
/// and loaded (`fall_distance`).
|
||||
pub fall_distance: f64,
|
||||
/// `AbstractBoat.lastYd`: the vertical velocity at the last move's
|
||||
/// `checkFallDamage`, before collisions zero it (not saved).
|
||||
pub last_yd: f64,
|
||||
/// `Entity.stuckSpeedMultiplier`: set where the boat sits in powder snow
|
||||
/// (`PowderSnowBlock.entityInside`), spent by the next move.
|
||||
stuck: Vec3,
|
||||
@@ -162,6 +166,21 @@ pub(super) fn saved_fire(tag: &Tag) -> i32 {
|
||||
}
|
||||
pub(super) const WIDTH: f64 = 1.375;
|
||||
pub(super) const HEIGHT: f64 = 0.5625;
|
||||
/// `AbstractBoat.controlBoat`'s push along the boat's yaw:
|
||||
/// `Mth.sin(-yRot * 0.017453292F) * acceleration` and `Mth.cos(..)`, with
|
||||
/// `Mth`'s sine table and float products, widened to double.
|
||||
fn paddle_push(yaw: f32, acceleration: f32) -> (f64, f64) {
|
||||
let (x, z) = (-yaw * 0.017_453_292, yaw * 0.017_453_292);
|
||||
(
|
||||
f64::from(trig::sin(f64::from(x)) * acceleration),
|
||||
f64::from(trig::cos(f64::from(z)) * acceleration),
|
||||
)
|
||||
}
|
||||
/// `getBbWidth() * Mth.SQRT_OF_TWO` in `getDismountLocationForPassenger`: a
|
||||
/// float product.
|
||||
fn boat_escape_width() -> f64 {
|
||||
f64::from(WIDTH as f32 * std::f32::consts::SQRT_2)
|
||||
}
|
||||
impl Boat {
|
||||
/// `AbstractBoat.tick`: the hurt wobble and the damage wear off, before
|
||||
/// `Entity.baseTick` can burn the boat.
|
||||
@@ -217,6 +236,7 @@ impl Boat {
|
||||
input: [false; 4],
|
||||
on_ground: false,
|
||||
fall_distance: 0.0,
|
||||
last_yd: 0.0,
|
||||
stuck: Vec3::ZERO,
|
||||
chest: false,
|
||||
items: Vec::new(),
|
||||
@@ -542,7 +562,8 @@ impl Game<'_> {
|
||||
fn water_level_above(&self, b: &Boat) -> f32 {
|
||||
let bb = b.bounds();
|
||||
let (lo, hi) = (bb.min_position(), bb.max_position());
|
||||
let (y0, y1) = (hi.y.floor() as i32, hi.y.ceil() as i32);
|
||||
// Up to where a falling boat's top was a move ago (`ceil(maxY - lastYd)`).
|
||||
let (y0, y1) = (hi.y.floor() as i32, (hi.y - b.last_yd).ceil() as i32);
|
||||
'rows: for y in y0..y1 {
|
||||
let mut height = 0.0f32;
|
||||
for x in lo.x.floor() as i32..hi.x.ceil() as i32 {
|
||||
@@ -834,9 +855,9 @@ impl Game<'_> {
|
||||
if down {
|
||||
acceleration -= 0.005;
|
||||
}
|
||||
let r = b.yaw.to_radians();
|
||||
b.velocity.x += f64::from((-r).sin() * acceleration);
|
||||
b.velocity.z += f64::from(r.cos() * acceleration);
|
||||
let (x, z) = paddle_push(b.yaw, acceleration);
|
||||
b.velocity.x += x;
|
||||
b.velocity.z += z;
|
||||
b.paddling = [right && !left || up, left && !right || up];
|
||||
}
|
||||
// Entity.move: collisions zero the blocked components, the world border's
|
||||
@@ -855,6 +876,7 @@ impl Game<'_> {
|
||||
b.position.z + moved.z,
|
||||
);
|
||||
b.on_ground = movement.y < 0.0 && moved.y > movement.y;
|
||||
b.last_yd = b.velocity.y;
|
||||
b.fall_distance = self.boat_fall(&b, moved.y);
|
||||
if (moved.x - movement.x).abs() > 1.0e-7 {
|
||||
b.velocity.x = 0.0;
|
||||
@@ -977,7 +999,7 @@ impl Game<'_> {
|
||||
};
|
||||
self.boats[i].wear_off_hurt();
|
||||
// Entity.baseTick burns it before it moves; the fire may break it.
|
||||
if !self.boat_fire_tick(i) {
|
||||
if !self.boat_fire_tick(i) && !self.boat_below_world(i) {
|
||||
let b = self.boats[i].clone();
|
||||
self.boats[i] = self.tick_boat(b, false);
|
||||
self.boat_server_passes(id, &driven, &ticking, &round);
|
||||
@@ -1435,7 +1457,7 @@ impl Game<'_> {
|
||||
if self.dismount_in_portal(self.boats[i].position) {
|
||||
return;
|
||||
}
|
||||
let target = self.boat_dismount_target(i, 0.6, self.yaw);
|
||||
let target = self.boat_dismount_target(i, f64::from(0.6f32), self.yaw);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = target;
|
||||
p.velocity = Vec3::ZERO;
|
||||
@@ -1445,14 +1467,8 @@ impl Game<'_> {
|
||||
/// facing `yaw`.
|
||||
pub(super) fn boat_dismount_target(&self, i: usize, width: f64, yaw: f32) -> Vec3 {
|
||||
let b = &self.boats[i];
|
||||
let r = yaw.to_radians();
|
||||
let distance = (WIDTH * std::f64::consts::SQRT_2 + width + 1.0e-5) / 2.0;
|
||||
let (s, c) = (-(r.sin()) as f64, r.cos() as f64);
|
||||
let m = s.abs().max(c.abs());
|
||||
let (x, z) = (
|
||||
b.position.x + s * distance / m,
|
||||
b.position.z + c * distance / m,
|
||||
);
|
||||
let off = super::mounts::escape_vector(boat_escape_width(), width, yaw);
|
||||
let (x, z) = (b.position.x + off.x, b.position.z + off.z);
|
||||
let top = b.bounds().max_position().y;
|
||||
let (bx, by, bz) = (x.floor() as i32, top.floor() as i32, z.floor() as i32);
|
||||
let below = self.res.facts().get(self.world.block(bx, by - 1, bz));
|
||||
@@ -1917,6 +1933,31 @@ impl Game<'_> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `controlBoat`'s push with `Mth`'s sine table (65,536 float entries,
|
||||
/// indexed by `(long)(radians * 10430.378350470453) & 65535`) and float
|
||||
/// products. Expected values worked out apart from Horizon, in Python with
|
||||
/// float32 rounding; the platform's sine differs in the fourth digit.
|
||||
#[test]
|
||||
fn paddling_pushes_along_the_sine_table() {
|
||||
for (yaw, push) in [
|
||||
(17.3, (-0.011893643997609615, 0.03819085285067558)),
|
||||
(-123.45, (0.03337550163269043, -0.022047126665711403)),
|
||||
(250.0, (0.03758755698800087, -0.013681206852197647)),
|
||||
] {
|
||||
assert_eq!(paddle_push(yaw, 0.04), push, "yaw {yaw}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `getDismountLocationForPassenger`'s escape vector for a player (0.6F
|
||||
/// wide) facing 17.3 degrees: the boat's float width times
|
||||
/// `Mth.SQRT_OF_TWO`, the 1.0E-5F margin and the sine table, worked out
|
||||
/// apart from Horizon as above.
|
||||
#[test]
|
||||
fn a_boat_rider_steps_off_along_the_sine_table() {
|
||||
let v = super::super::mounts::escape_vector(boat_escape_width(), f64::from(0.6f32), 17.3);
|
||||
assert_eq!((v.x, v.z), (-0.3962207300897882, 1.2722768119620014));
|
||||
}
|
||||
|
||||
fn boat() -> Boat {
|
||||
Boat::new(1, Arc::from("oak"), Vec3::ZERO, 0.0)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//! `Entity.checkBelowWorld` for boats and minecarts: one more than 64 blocks
|
||||
//! under its level's floor is discarded (`onBelowWorld`) before it moves.
|
||||
//! `AbstractBoat.tick` reaches it through `Entity.baseTick`, after the fire;
|
||||
//! `AbstractMinecart.tick` calls it itself, after its hurt and damage wear off.
|
||||
//! A discard is a destroying removal: the riders get off where they are and a
|
||||
//! chest boat or container minecart spills its slots
|
||||
//! (`AbstractChestBoat.remove`, `AbstractMinecartContainer.remove`), but no
|
||||
//! ENTITY_DIE and no item of the vehicle itself.
|
||||
use super::super::{minecarts::cart_at, native_ticks::TickRound};
|
||||
use super::Game;
|
||||
|
||||
/// `Entity.checkBelowWorld`: `getY() < level.getMinY() - 64`.
|
||||
pub(super) fn below_world(y: f64, min_y: i32) -> bool {
|
||||
y < f64::from(min_y - 64)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Entity.checkBelowWorld` for the boat at `i`: true when it fell out of
|
||||
/// the world and is gone.
|
||||
pub(in crate::game) fn boat_below_world(&mut self, i: usize) -> bool {
|
||||
if !below_world(self.boats[i].position.y, self.world.bottom_y()) {
|
||||
return false;
|
||||
}
|
||||
self.eject_boat_mobs(i, true);
|
||||
let id = self.boats[i].id;
|
||||
self.release_vehicle_container(id);
|
||||
let b = self.boats.remove(i);
|
||||
self.drop_vehicle_contents(b.position.components(), &b.items);
|
||||
self.boat_removed(&b);
|
||||
true
|
||||
}
|
||||
|
||||
/// The minecart `id` at or after `slot`, unless `AbstractMinecart.tick`'s
|
||||
/// `checkBelowWorld` discards it: then its tick is over and the round's
|
||||
/// post hook runs for it.
|
||||
pub(in crate::game) fn cart_in_world(
|
||||
&mut self,
|
||||
id: u32,
|
||||
slot: usize,
|
||||
round: &TickRound,
|
||||
kind: &str,
|
||||
) -> Option<usize> {
|
||||
let i = cart_at(&self.carts, id, slot)?;
|
||||
if !below_world(self.carts[i].position.y, self.world.bottom_y()) {
|
||||
return Some(i);
|
||||
}
|
||||
self.eject_cart_mobs(i);
|
||||
self.release_vehicle_container(id);
|
||||
let c = self.carts.remove(i);
|
||||
self.drop_vehicle_contents(c.position.components(), &c.items);
|
||||
self.native_tick_post_one(round, id, kind);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::below_world;
|
||||
|
||||
/// The line is 64 under the floor and the vehicle must be below it:
|
||||
/// -128 in the overworld (floor -64), -64 in the Nether and the End.
|
||||
#[test]
|
||||
fn a_vehicle_falls_out_of_the_world_64_blocks_under_the_floor() {
|
||||
assert!(!below_world(-128.0, -64));
|
||||
assert!(below_world(-128.001, -64));
|
||||
assert!(!below_world(-100.0, -64));
|
||||
assert!(!below_world(-64.0, 0));
|
||||
assert!(below_world(-64.5, 0));
|
||||
}
|
||||
|
||||
/// A tick discards the boat and the chest minecart under -128 in the
|
||||
/// overworld, before they move, and spills the cart's slots; the two on
|
||||
/// the line or above it stay, whatever their fall this tick.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_tick_discards_the_vehicles_under_the_world() {
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_core::position::ChunkPos;
|
||||
let mut g = crate::game::native_machines::tests::server_game();
|
||||
g.build_registries();
|
||||
g.set_allow_commands(true);
|
||||
g.world
|
||||
.chunks
|
||||
.insert((0, 0), std::sync::Arc::new(Chunk::empty(0, 0)));
|
||||
g.block_ticks.add_empty_chunk(ChunkPos::new(0, 0)).unwrap();
|
||||
assert!(g.run_command("forceload add 0 0"));
|
||||
for summon in [
|
||||
"summon oak_boat 0.5 -128 0.5",
|
||||
"summon oak_boat 4.5 -128.5 4.5",
|
||||
"summon minecart 6.5 -127 6.5",
|
||||
"summon chest_minecart 2.5 -129 2.5 {Items:[{Slot:0b,id:\"minecraft:diamond\",count:3}]}",
|
||||
] {
|
||||
assert!(g.run_command(summon), "{summon}");
|
||||
}
|
||||
g.sync_tick_chunks();
|
||||
g.tick_boats();
|
||||
g.tick_carts();
|
||||
let boats: Vec<f64> = g.boats.iter().map(|b| b.position.x).collect();
|
||||
assert_eq!(boats, [0.5]);
|
||||
let carts: Vec<f64> = g.carts.iter().map(|c| c.position.x).collect();
|
||||
assert_eq!(carts, [6.5]);
|
||||
let spilled: u32 = g
|
||||
.dropped
|
||||
.iter()
|
||||
.filter(|i| &*i.stack.item == "minecraft:diamond")
|
||||
.map(|i| i.stack.count)
|
||||
.sum();
|
||||
assert_eq!(spilled, 3);
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,12 @@ use super::commands;
|
||||
#[cfg(test)]
|
||||
#[path = "revenge_home_tests.rs"]
|
||||
mod revenge_home_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "zombie_ignite_tests.rs"]
|
||||
mod zombie_ignite_tests;
|
||||
use horizon_client::{
|
||||
health::HealthEvent,
|
||||
mobs::{Mob, Species},
|
||||
mobs::{Mob, Species, Who},
|
||||
motion::MotionEvent,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
text::Text,
|
||||
@@ -118,6 +121,11 @@ pub(super) struct CombatTracker {
|
||||
/// it the attacker is the mob nearest to where the hit came from, which
|
||||
/// walks every mob.
|
||||
pub(super) melee_mob: Option<u32>,
|
||||
/// The attack event handled last hurt its target (`doHurtTarget` returned
|
||||
/// true). The events of one blow read it: `Zombie.doHurtTarget` burns only
|
||||
/// once `super.doHurtTarget` did (`MobEvent::ZombieIgnite` follows the
|
||||
/// attack it rides).
|
||||
pub(super) hit_landed: bool,
|
||||
/// A firework rocket's burst is hurting the player now: the rocket is
|
||||
/// the direct entity (`rocket_hurt_player`).
|
||||
pub(super) rocket_hit: bool,
|
||||
@@ -222,6 +230,29 @@ impl CombatTracker {
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `Entity.igniteForSeconds`: `seconds * 20` ticks of fire, scaled by the
|
||||
/// target's burning-time attribute.
|
||||
pub(super) fn ignite_entity(
|
||||
&mut self,
|
||||
target: Who,
|
||||
seconds: i32,
|
||||
) -> horizon_client::Result<()> {
|
||||
match target {
|
||||
Who::Player => {
|
||||
if !self.creative
|
||||
&& let Some(p) = self.player.as_mut()
|
||||
{
|
||||
p.ignite_for_ticks(seconds * 20)?;
|
||||
}
|
||||
}
|
||||
Who::Mob(id) => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.ignite_for_ticks(seconds * 20);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `LivingEntity.makeDrownParticles`: eight bubbles around the entity,
|
||||
/// moving with it.
|
||||
pub(super) fn drown_particles(&mut self, at: [f64; 3], velocity: [f64; 3]) {
|
||||
|
||||
@@ -46,13 +46,13 @@ pub(super) struct CommandBlockScreen {
|
||||
}
|
||||
|
||||
/// What `ServerboundSetCommandBlockPacket` carries.
|
||||
struct CommandBlockEdit {
|
||||
pos: Pos,
|
||||
command: String,
|
||||
track_output: bool,
|
||||
mode: Mode,
|
||||
conditional: bool,
|
||||
auto: bool,
|
||||
pub(super) struct CommandBlockEdit {
|
||||
pub(super) pos: Pos,
|
||||
pub(super) command: String,
|
||||
pub(super) track_output: bool,
|
||||
pub(super) mode: Mode,
|
||||
pub(super) conditional: bool,
|
||||
pub(super) auto: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
@@ -948,8 +948,6 @@ impl Game<'_> {
|
||||
self.as_guest_messaged(host, conn, |game| {
|
||||
if allowed {
|
||||
game.apply_command_block(&edit);
|
||||
// BaseCommandBlock.onUpdated: the clients get the new command.
|
||||
game.mark_block_entity_updated(edit.pos);
|
||||
} else {
|
||||
game.system_message(Text::translate("advMode.notAllowed", &[]));
|
||||
}
|
||||
@@ -984,7 +982,7 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
/// What `handleSetCommandBlock` does once the player may edit the block.
|
||||
fn apply_command_block(&mut self, s: &CommandBlockEdit) {
|
||||
pub(super) fn apply_command_block(&mut self, s: &CommandBlockEdit) {
|
||||
let pos = s.pos;
|
||||
let registry = self.pack.assets.registry;
|
||||
let Some(current) = self.block_state_at(pos) else {
|
||||
@@ -1042,6 +1040,9 @@ impl Game<'_> {
|
||||
};
|
||||
self.system_message(Text::translate(key, &[Text::literal(command)]));
|
||||
}
|
||||
// `BaseCommandBlock.onUpdated`: the edited block entity is saved and
|
||||
// its clients (and comparators) see the new command.
|
||||
self.mark_block_entity_updated(pos);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -118,12 +118,14 @@ impl Game<'_> {
|
||||
Target::Item(id) => {
|
||||
let uuid = self.uuid_for(id);
|
||||
let e = self.dropped.iter().find(|e| e.id == id)?;
|
||||
entity_nbt::item_entity_to_nbt_with_items(
|
||||
let mut tag = entity_nbt::item_entity_to_nbt_with_items(
|
||||
e,
|
||||
uuid,
|
||||
self.persist.raw.get(&id),
|
||||
self.items(),
|
||||
)
|
||||
);
|
||||
self.save_portal_cooldown(id, &mut tag);
|
||||
tag
|
||||
}
|
||||
Target::Stand(id) => {
|
||||
let uuid = self.uuid_for(id);
|
||||
@@ -149,6 +151,7 @@ impl Game<'_> {
|
||||
self.put_mob_passengers(&mut tag, id);
|
||||
// AbstractBoat.addAdditionalSaveData: the live or pending leash.
|
||||
self.write_boat_leash(&mut tag, i);
|
||||
self.save_portal_cooldown(id, &mut tag);
|
||||
tag
|
||||
}
|
||||
Target::Cart(id) => {
|
||||
@@ -156,6 +159,7 @@ impl Game<'_> {
|
||||
let c = self.carts.iter().find(|c| c.id == id)?;
|
||||
let mut tag = c.to_nbt(uuid, self.persist.raw.get(&id));
|
||||
self.put_mob_passengers(&mut tag, id);
|
||||
self.save_portal_cooldown(id, &mut tag);
|
||||
tag
|
||||
}
|
||||
Target::Fangs(id) => {
|
||||
@@ -780,6 +784,13 @@ impl Game<'_> {
|
||||
if self.pot_data(pos).is_some() {
|
||||
return self.set_pot_data(pos, &tag).unwrap_or(false);
|
||||
}
|
||||
if let Some(changed) = self.load_comparator_output(pos, &tag) {
|
||||
let changed = changed.unwrap_or(false);
|
||||
if changed {
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let was_auto = self.command_block_auto(key);
|
||||
let Some(e) = self.world.block_entities.get(&key) else {
|
||||
@@ -887,6 +898,29 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
|
||||
/// `ComparatorBlockEntity.loadAdditional` (`getIntOr("OutputSignal", 0)`)
|
||||
/// for a comparator at `p`: None for any other block; else whether the
|
||||
/// loaded output differs from the one it had (None inside when the tag
|
||||
/// does not decode). The block's own metadata comes from `comparator_data`.
|
||||
fn load_comparator_output(&mut self, p: [i32; 3], tag: &Tag) -> Option<Option<bool>> {
|
||||
let mut input = self.comparator_data(p)?;
|
||||
let before = input.get("OutputSignal").cloned();
|
||||
if let Some(value) = tag.get("OutputSignal") {
|
||||
set_key(&mut input, "OutputSignal", value.clone());
|
||||
} else if let Tag::Compound(map) = &mut input {
|
||||
map.remove(&"OutputSignal".into());
|
||||
}
|
||||
let Some(normalized) = horizon_client::block_entity_nbt::canonical(&input) else {
|
||||
return Some(None);
|
||||
};
|
||||
let output = normalized.get("OutputSignal").cloned();
|
||||
if let Some(Tag::Int(value)) = output {
|
||||
let pos = horizon_core::position::BlockPos::new(p[0], p[1], p[2]);
|
||||
self.block_ticks.comparator_outputs.insert(pos, value);
|
||||
}
|
||||
Some(Some(output != before))
|
||||
}
|
||||
|
||||
/// `ComparatorBlockEntity.saveAdditional`: expose the circuit's live output,
|
||||
/// rather than keeping a second copy in the ordinary block entity map.
|
||||
fn comparator_data(&self, p: [i32; 3]) -> Option<Tag> {
|
||||
@@ -1050,22 +1084,8 @@ impl Game<'_> {
|
||||
return Ok(());
|
||||
}
|
||||
let key = (p[0], p[1], p[2]);
|
||||
if let Some(mut input) = self.comparator_data(*p) {
|
||||
// ComparatorBlockEntity.loadAdditional reads getIntOr(0).
|
||||
// Reuse the existing codec, with the block's own metadata.
|
||||
if let Some(value) = tag.get("OutputSignal") {
|
||||
set_key(&mut input, "OutputSignal", value.clone());
|
||||
} else if let Tag::Compound(map) = &mut input {
|
||||
map.remove(&"OutputSignal".into());
|
||||
}
|
||||
let normalized = horizon_client::block_entity_nbt::canonical(&input)
|
||||
.ok_or_else(|| err("commands.data.block.invalid", &[]))?;
|
||||
if let Some(Tag::Int(output)) = normalized.get("OutputSignal") {
|
||||
self.block_ticks.comparator_outputs.insert(
|
||||
horizon_core::position::BlockPos::new(p[0], p[1], p[2]),
|
||||
*output,
|
||||
);
|
||||
}
|
||||
if let Some(changed) = self.load_comparator_output(*p, &tag) {
|
||||
changed.ok_or_else(|| err("commands.data.block.invalid", &[]))?;
|
||||
self.mark_block_entity_updated(key);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ use horizon_protocol::packets::{
|
||||
Empty,
|
||||
play::{self, Clientbound as C, DialogHolder},
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
use horizon_world::nbt::{JavaString, Tag};
|
||||
use std::{collections::HashMap, time::Instant};
|
||||
|
||||
/// Waypoint.Icon: the locator bar style and colour of a transmitter.
|
||||
@@ -33,6 +33,48 @@ impl Default for WaypointIcon {
|
||||
}
|
||||
}
|
||||
|
||||
impl WaypointIcon {
|
||||
/// `Waypoint.Icon.hasData`.
|
||||
fn has_data(&self) -> bool {
|
||||
self.style != "minecraft:default" || self.color.is_some()
|
||||
}
|
||||
/// `LivingEntity.readAdditionalSaveData`'s `locator_bar_icon`
|
||||
/// (`Waypoint.Icon.CODEC`: a required `style` and an optional int `color`).
|
||||
/// None when the key is missing or does not decode, which is the default icon.
|
||||
fn from_saved(entity: Option<&Tag>) -> Option<Self> {
|
||||
let icon = entity?.get("locator_bar_icon")?;
|
||||
let Some(Tag::String(style)) = icon.get("style") else {
|
||||
return None;
|
||||
};
|
||||
let color = match icon.get("color") {
|
||||
Some(Tag::Int(rgb)) => Some(*rgb),
|
||||
_ => None,
|
||||
};
|
||||
Some(Self {
|
||||
style: style.to_string_lossy(),
|
||||
color,
|
||||
})
|
||||
}
|
||||
/// `LivingEntity.addAdditionalSaveData`'s `locator_bar_icon`: written only
|
||||
/// when the icon `hasData`, so any other icon takes the key out.
|
||||
fn save_into(&self, entity: &mut Option<Tag>) {
|
||||
if !self.has_data() {
|
||||
if let Some(Tag::Compound(map)) = entity {
|
||||
map.remove(&JavaString::from("locator_bar_icon"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut fields = vec![("style", Tag::String(self.style.as_str().into()))];
|
||||
if let Some(rgb) = self.color {
|
||||
fields.push(("color", Tag::Int(rgb)));
|
||||
}
|
||||
if let Tag::Compound(map) = entity.get_or_insert_with(|| Tag::Compound(Default::default()))
|
||||
{
|
||||
map.insert("locator_bar_icon".into(), Tag::compound(fields));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `/fetchprofile` lookup in flight: what it resolves, the message ids it
|
||||
/// answers with (`commands.fetchprofile.<kind>.success|failure`), the
|
||||
/// argument they show, and the source to answer.
|
||||
@@ -401,6 +443,12 @@ impl Game<'_> {
|
||||
.waypoint_icons
|
||||
.get(&self.waypoint_target(t))
|
||||
.cloned()
|
||||
// The player's own icon comes back from their saved `locator_bar_icon`.
|
||||
.or_else(|| {
|
||||
(t == Target::Player)
|
||||
.then(|| WaypointIcon::from_saved(self.level.player_extra.as_ref()))
|
||||
.flatten()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
/// `WaypointArgument.getWaypoint`.
|
||||
@@ -509,6 +557,10 @@ impl Game<'_> {
|
||||
&[Text::colored(name, rgb)],
|
||||
)
|
||||
};
|
||||
// LivingEntity.addAdditionalSaveData: the player's icon goes into their saved tag.
|
||||
if t == Target::Player {
|
||||
icon.save_into(&mut self.level.player_extra);
|
||||
}
|
||||
let t = self.waypoint_target(t);
|
||||
self.cmds.waypoint_icons.insert(t, icon);
|
||||
// WaypointCommand.mutateIcon: untrack, change, track again.
|
||||
@@ -1310,3 +1362,81 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
impl Game<'_> {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod waypoint_icon_save_tests {
|
||||
use super::*;
|
||||
use crate::save::Level;
|
||||
|
||||
fn icon(style: &str, color: Option<i32>) -> WaypointIcon {
|
||||
WaypointIcon {
|
||||
style: style.into(),
|
||||
color,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_icon_set_by_waypoint_modify_survives_a_save_and_a_load() {
|
||||
let mut level = Level::default();
|
||||
icon("minecraft:bowtie", Some(0x00FF_0000)).save_into(&mut level.player_extra);
|
||||
// The player's file: `locator_bar_icon` is `{style, color}`.
|
||||
let saved = level.player_tag();
|
||||
assert_eq!(
|
||||
saved.get("locator_bar_icon"),
|
||||
Some(&Tag::compound([
|
||||
("style", Tag::String("minecraft:bowtie".into())),
|
||||
("color", Tag::Int(0x00FF_0000)),
|
||||
]))
|
||||
);
|
||||
// Loaded again: the same icon.
|
||||
assert_eq!(
|
||||
WaypointIcon::from_saved(Some(&saved)),
|
||||
Some(icon("minecraft:bowtie", Some(0x00FF_0000)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_style_alone_or_a_colour_alone_is_data_and_is_saved() {
|
||||
let mut only_style = None;
|
||||
icon("minecraft:bowtie", None).save_into(&mut only_style);
|
||||
let tag = only_style.unwrap();
|
||||
assert_eq!(
|
||||
tag.get("locator_bar_icon"),
|
||||
Some(&Tag::compound([(
|
||||
"style",
|
||||
Tag::String("minecraft:bowtie".into())
|
||||
)]))
|
||||
);
|
||||
let mut only_color = None;
|
||||
icon("minecraft:default", Some(7)).save_into(&mut only_color);
|
||||
assert_eq!(
|
||||
WaypointIcon::from_saved(only_color.as_ref()),
|
||||
Some(icon("minecraft:default", Some(7)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_player_with_no_icon_saves_no_key_and_a_reset_takes_it_out() {
|
||||
let mut level = Level::default();
|
||||
assert!(level.player_tag().get("locator_bar_icon").is_none());
|
||||
// The default icon on a player with no file writes nothing, not even a file.
|
||||
WaypointIcon::default().save_into(&mut level.player_extra);
|
||||
assert!(level.player_extra.is_none());
|
||||
icon("minecraft:default", Some(1)).save_into(&mut level.player_extra);
|
||||
assert!(level.player_tag().get("locator_bar_icon").is_some());
|
||||
// `/waypoint modify ... reset` leaves the default icon: the key goes.
|
||||
WaypointIcon::default().save_into(&mut level.player_extra);
|
||||
assert!(level.player_tag().get("locator_bar_icon").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_save_without_the_key_or_with_a_broken_one_loads_the_default_icon() {
|
||||
assert_eq!(WaypointIcon::from_saved(None), None);
|
||||
let none = Tag::compound([("Health", Tag::Float(20f32.to_bits()))]);
|
||||
assert_eq!(WaypointIcon::from_saved(Some(&none)), None);
|
||||
// `style` is required by the codec.
|
||||
let no_style =
|
||||
Tag::compound([("locator_bar_icon", Tag::compound([("color", Tag::Int(5))]))]);
|
||||
assert_eq!(WaypointIcon::from_saved(Some(&no_style)), None);
|
||||
}
|
||||
}
|
||||
@@ -2270,6 +2270,11 @@ impl Game<'_> {
|
||||
self.next_entity += 1;
|
||||
p.id = self.next_entity;
|
||||
}
|
||||
// The UUID its own random made, unless it already has one (a client
|
||||
// copy's from the spawn packet).
|
||||
if let Some(uuid) = p.uuid.take() {
|
||||
self.persist.uuids.entry(p.id).or_insert(uuid);
|
||||
}
|
||||
// Projectile.setOwner: the acting player's or the mob's UUID, which
|
||||
// its save keeps even once the owner is gone.
|
||||
if p.owner_uuid.is_none() {
|
||||
@@ -2550,7 +2555,8 @@ impl Game<'_> {
|
||||
// above use their movement's flyDist/nextFlap threshold.
|
||||
MobEvent::GameEvent { .. } => {}
|
||||
MobEvent::AttackPlayer(amount, from) => {
|
||||
self.mob_hits_player(amount, from, "minecraft:mob_attack");
|
||||
self.combat.hit_landed =
|
||||
self.mob_hits_player(amount, from, "minecraft:mob_attack");
|
||||
}
|
||||
// doHurtTarget: the effect only when hurtServer took damage.
|
||||
// Bee.doHurtTarget: a sting that hurt leaves the stinger in the
|
||||
@@ -2574,7 +2580,9 @@ impl Game<'_> {
|
||||
ticks,
|
||||
amplifier,
|
||||
} => {
|
||||
if self.mob_hits_player(amount, from, "minecraft:mob_attack") {
|
||||
self.combat.hit_landed =
|
||||
self.mob_hits_player(amount, from, "minecraft:mob_attack");
|
||||
if self.combat.hit_landed {
|
||||
self.give_player_effect(effect, ticks, amplifier);
|
||||
}
|
||||
}
|
||||
@@ -2763,7 +2771,7 @@ impl Game<'_> {
|
||||
amount,
|
||||
source,
|
||||
} => {
|
||||
self.mob_attacks_mob(target, amount, source);
|
||||
self.combat.hit_landed = self.mob_attacks_mob(target, amount, source);
|
||||
}
|
||||
MobEvent::DamageMob {
|
||||
target,
|
||||
@@ -2783,7 +2791,8 @@ impl Game<'_> {
|
||||
// CaveSpider, Husk and WitherSkeleton.doHurtTarget: the
|
||||
// effect sits inside the branch where the hit landed, and
|
||||
// `LivingEntity.addEffect` takes a mob as well as a player.
|
||||
if self.mob_attacks_mob(target, amount, source)
|
||||
self.combat.hit_landed = self.mob_attacks_mob(target, amount, source);
|
||||
if self.combat.hit_landed
|
||||
&& let Some(t) = self.mobs.iter_mut().find(|m| m.id == target)
|
||||
{
|
||||
t.add_effect(effect, ticks, amplifier);
|
||||
@@ -3456,20 +3465,11 @@ impl Game<'_> {
|
||||
t.add_effect(effect, ticks, amplifier);
|
||||
}
|
||||
}
|
||||
MobEvent::Ignite { target, seconds } => match target {
|
||||
Who::Player => {
|
||||
if !self.creative
|
||||
&& let Some(p) = self.player.as_mut()
|
||||
{
|
||||
p.ignite_for_ticks(seconds * 20)?;
|
||||
}
|
||||
}
|
||||
Who::Mob(id) => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.ignite_for_ticks(seconds * 20);
|
||||
}
|
||||
}
|
||||
},
|
||||
MobEvent::Ignite(target, seconds) => self.ignite_entity(target, seconds)?,
|
||||
MobEvent::ZombieIgnite(target, seconds) if self.combat.hit_landed => {
|
||||
self.ignite_entity(target, seconds)?
|
||||
}
|
||||
MobEvent::ZombieIgnite(..) => {}
|
||||
MobEvent::Breed {
|
||||
parent,
|
||||
partner,
|
||||
@@ -5524,10 +5524,7 @@ impl Game<'_> {
|
||||
let mut dodged = false;
|
||||
// AbstractArrow.onHitEntity: a burning arrow sets its target alight.
|
||||
if p.arrow.as_ref().is_some_and(|a| a.on_fire()) {
|
||||
self.handle_mob_events(vec![MobEvent::Ignite {
|
||||
target: who,
|
||||
seconds: 5,
|
||||
}])?;
|
||||
self.handle_mob_events(vec![MobEvent::Ignite(who, 5)])?;
|
||||
}
|
||||
let hurt = match who {
|
||||
Who::Player => {
|
||||
@@ -7368,16 +7365,7 @@ impl Game<'_> {
|
||||
let velocity = self.known_player_movement();
|
||||
let start = Vec3::new(eye[0], eye[1] - 0.1, eye[2]);
|
||||
let mut proj = Projectile::new(kind.clone(), start, Some(Who::Player));
|
||||
proj.shoot_from_rotation(
|
||||
&mut self.world.random,
|
||||
self.pitch,
|
||||
self.yaw,
|
||||
0.0,
|
||||
1.5,
|
||||
1.0,
|
||||
velocity,
|
||||
on_ground,
|
||||
);
|
||||
proj.shoot_from_rotation(self.pitch, self.yaw, 0.0, 1.5, 1.0, velocity, on_ground);
|
||||
self.add_projectile(proj);
|
||||
// SnowballItem.use / EggItem.use / EnderpearlItem.use: awardStat(ITEM_USED).
|
||||
self.award_stat("used", item, 1);
|
||||
|
||||
@@ -371,7 +371,7 @@ impl Game<'_> {
|
||||
}
|
||||
// Flame: the arrow is spawned burning (EnchantmentHelper.onProjectileSpawned).
|
||||
self.ignite_spawned_arrow(&weapon, &mut arrow);
|
||||
arrow.shoot(&mut self.world.random, dir.to_array(), power, 1.0);
|
||||
arrow.shoot_own(dir.to_array(), power, 1.0);
|
||||
self.add_projectile(arrow);
|
||||
let (player, name, projectile) = (
|
||||
horizon_client::lan::playerdata::ints_uuid(self.acting_player_uuid()),
|
||||
|
||||
@@ -44,6 +44,8 @@ mod transfer;
|
||||
/// What the tick loop tells `ServerWatchdog`: when the tick in progress was
|
||||
/// due (`getNextTickTime`), how long a tick is, and whether the server runs.
|
||||
struct Watchdog {
|
||||
/// The thread running the ticks, whose stack the report carries.
|
||||
server: horizon_threads::stack_dump::Watched,
|
||||
base: Instant,
|
||||
next_nanos: AtomicU64,
|
||||
tick_nanos: AtomicU64,
|
||||
@@ -53,9 +55,11 @@ struct Watchdog {
|
||||
impl Watchdog {
|
||||
/// `DedicatedServer.initServer`: a thread that stops a server whose tick
|
||||
/// runs `max_tick_time` ms past its due time; none when that is 0 or less.
|
||||
/// Started from the thread that runs the ticks.
|
||||
fn start(max_tick_time: i64) -> Option<Arc<Self>> {
|
||||
let max = Duration::from_millis(u64::try_from(max_tick_time).ok().filter(|m| *m > 0)?);
|
||||
let watchdog = Arc::new(Self {
|
||||
server: horizon_threads::stack_dump::Watched::current(),
|
||||
base: Instant::now(),
|
||||
next_nanos: AtomicU64::new(0),
|
||||
tick_nanos: AtomicU64::new(50_000_000),
|
||||
@@ -84,6 +88,8 @@ impl Watchdog {
|
||||
let now = u64::try_from(self.base.elapsed().as_nanos()).unwrap_or(u64::MAX);
|
||||
let behind = now.saturating_sub(next);
|
||||
if behind > max {
|
||||
// ServerWatchdog: the server thread's stack first, while it is still stuck.
|
||||
let stack = self.server.stack(Duration::from_secs(5));
|
||||
let took = format!("{:.2}", behind as f32 / 1e9);
|
||||
let should = format!(
|
||||
"{:.2}",
|
||||
@@ -106,6 +112,7 @@ impl Watchdog {
|
||||
std::path::Path::new("."),
|
||||
"server",
|
||||
message,
|
||||
stack,
|
||||
) {
|
||||
Some(path) => log_from(
|
||||
"Server Watchdog",
|
||||
|
||||
@@ -198,7 +198,11 @@ impl Game<'_> {
|
||||
} else {
|
||||
format!("minecraft:{name}")
|
||||
};
|
||||
let ids = r.registries.dialog_tags.get(&name).into_iter().flatten();
|
||||
let ids = r
|
||||
.registries
|
||||
.tag("minecraft:dialog", &name)
|
||||
.into_iter()
|
||||
.flatten();
|
||||
ids.filter_map(|&i| list?.get(usize::try_from(i).ok()?)?.1.clone())
|
||||
.collect()
|
||||
};
|
||||
|
||||
@@ -439,14 +439,15 @@ impl Game<'_> {
|
||||
|
||||
// ---- DefaultDispenseItemBehavior ----
|
||||
|
||||
/// `DispenserBlock.getDispensePosition(source, scale, offset)`.
|
||||
/// `DispenserBlock.getDispensePosition(source, scale, offset)`; vanilla adds
|
||||
/// `scale * step + offset` first, then adds that sum to the block centre.
|
||||
fn dispense_position(pos: Pos, facing: Direction, scale: f64, offset: [f64; 3]) -> Vec3 {
|
||||
let s = step(facing);
|
||||
let c = Self::center(pos);
|
||||
Vec3::new(
|
||||
c[0] + scale * f64::from(s[0]) + offset[0],
|
||||
c[1] + scale * f64::from(s[1]) + offset[1],
|
||||
c[2] + scale * f64::from(s[2]) + offset[2],
|
||||
c[0] + (scale * f64::from(s[0]) + offset[0]),
|
||||
c[1] + (scale * f64::from(s[1]) + offset[1]),
|
||||
c[2] + (scale * f64::from(s[2]) + offset[2]),
|
||||
)
|
||||
}
|
||||
/// `DefaultDispenseItemBehavior.spawnItem` at `getDispensePosition`.
|
||||
@@ -1193,13 +1194,12 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
// Projectile.shoot spreads the shot with the projectile's own random.
|
||||
let mut own = random_source::entity();
|
||||
match item.as_str() {
|
||||
"minecraft:experience_bottle"
|
||||
| "minecraft:splash_potion"
|
||||
| "minecraft:lingering_potion" => {
|
||||
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
|
||||
shot.shoot(&mut own, dir, power, uncertainty);
|
||||
shot.shoot(&mut random_source::entity(), dir, power, uncertainty);
|
||||
self.launch_thrown(one, at, shot.velocity, None);
|
||||
}
|
||||
_ => {
|
||||
@@ -1246,7 +1246,7 @@ impl Game<'_> {
|
||||
a.set_item(&one);
|
||||
a.pickup = horizon_client::arrows::Pickup::Allowed;
|
||||
}
|
||||
shot.shoot(&mut own, dir, power, uncertainty);
|
||||
shot.shoot_own(dir, power, uncertainty);
|
||||
self.add_projectile(shot);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,11 +380,7 @@ impl Game<'_> {
|
||||
|
||||
/// The environment attributes of the current dimension.
|
||||
pub(super) fn dim_environment(&self) -> &Environment {
|
||||
match self.dim {
|
||||
Dim::Overworld => self.res.environment(),
|
||||
Dim::Nether => self.res.nether_environment(),
|
||||
Dim::End => self.res.end_environment(),
|
||||
}
|
||||
self.res.environment_of(&self.dimension_type().id)
|
||||
}
|
||||
|
||||
/// SkyRenderer.renderEndFlash, drawn after the renderer's resident sky mesh.
|
||||
|
||||
@@ -124,6 +124,7 @@ impl Game<'_> {
|
||||
|| level.facts.is_air(below)
|
||||
|| &*support.name == "minecraft:bedrock"
|
||||
|| !support.full_collision
|
||||
|| !b.can_survive(level, ticks, at, state)
|
||||
|| occupants.iter().any(|bounds| bounds.intersects(area))
|
||||
{
|
||||
return None;
|
||||
|
||||
@@ -189,7 +189,7 @@ impl Game<'_> {
|
||||
let (bx, bz) = (view.x.floor() as i32, view.z.floor() as i32);
|
||||
// Underground, under a roof or in the Nether: the floor around the player.
|
||||
let covered = self.world.light(bx, head, bz) >> 4 == 0;
|
||||
let layer = if self.dim == Dim::Nether || covered {
|
||||
let layer = if self.world.shape.has_ceiling || covered {
|
||||
Layer::Below(head)
|
||||
} else {
|
||||
Layer::Surface
|
||||
|
||||
@@ -22,9 +22,6 @@ use horizon_client::{
|
||||
hud_layout, hud_overlays,
|
||||
};
|
||||
|
||||
/// `Abilities.walkingSpeed`.
|
||||
const WALKING_SPEED: f32 = 0.1;
|
||||
|
||||
fn valid_fov_modifier(modifier: f32, fallback: f32) -> f32 {
|
||||
if modifier.is_finite() && (0.01..=10.0).contains(&modifier) {
|
||||
modifier
|
||||
@@ -80,7 +77,7 @@ impl Game<'_> {
|
||||
});
|
||||
Some(FovPlayer {
|
||||
flying: p.flying,
|
||||
walking_speed: WALKING_SPEED,
|
||||
walking_speed: self.remote_walking_speed(),
|
||||
// Player.aiStep keeps `speed` as the float movement speed attribute.
|
||||
movement_speed: f64::from(p.speed),
|
||||
using,
|
||||
|
||||
@@ -704,8 +704,7 @@ impl Game<'_> {
|
||||
}
|
||||
// `extractRenderState`: FIXED, seeded by the frame entity.
|
||||
let skinning = self.skinning();
|
||||
let lights =
|
||||
horizon_client::block_entity_render::diffuse_lights(self.dim == super::Dim::Nether);
|
||||
let lights = horizon_client::block_entity_render::diffuse_lights(self.nether_lighting());
|
||||
for (stack, id, m, light, cull, kind) in items {
|
||||
let entity_start = out.len();
|
||||
self.mark_entity(entity_start, kind);
|
||||
|
||||
@@ -116,3 +116,39 @@ impl Game<'_> {
|
||||
left.then_some(stack)
|
||||
}
|
||||
}
|
||||
|
||||
/// The item of the stack in `selected` (`Player.tick`'s main-hand item), borrowed: a guest's
|
||||
/// tick compares it with the last one and clones it only when it changed.
|
||||
pub(super) fn held_attack_item(
|
||||
inventory: &[Option<ItemStack>],
|
||||
selected: usize,
|
||||
) -> Option<&Arc<str>> {
|
||||
inventory.get(selected)?.as_ref().map(|s| &s.item)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn stack(item: &str) -> ItemStack {
|
||||
ItemStack {
|
||||
item: item.into(),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn held_attack_item_matches_the_cloned_stack() {
|
||||
let mut inventory: Vec<Option<ItemStack>> = vec![None; 36];
|
||||
inventory[0] = Some(stack("minecraft:stone"));
|
||||
inventory[3] = Some(stack("minecraft:diamond_sword"));
|
||||
for selected in [0, 1, 3, 35, 36, 1000] {
|
||||
// The clone the tick used to make, as the reference.
|
||||
let cloned = inventory.get(selected).cloned().flatten().map(|s| s.item);
|
||||
assert_eq!(held_attack_item(&inventory, selected).cloned(), cloned);
|
||||
}
|
||||
assert_eq!(held_attack_item(&[], 0), None);
|
||||
}
|
||||
}
|
||||
@@ -471,7 +471,7 @@ impl Game<'_> {
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let lights = diffuse_lights(self.dim == Dim::Nether);
|
||||
let lights = diffuse_lights(self.nether_lighting());
|
||||
self.submit_item_lit(state, pose, light, lights, out);
|
||||
}
|
||||
/// [`Self::submit_item_world`] shaded by other diffuse lights (a
|
||||
@@ -573,7 +573,7 @@ impl Game<'_> {
|
||||
light: u32,
|
||||
skinned: &mut super::creatures::Skinned,
|
||||
) -> bool {
|
||||
let lights = diffuse_lights(self.dim == Dim::Nether);
|
||||
let lights = diffuse_lights(self.nether_lighting());
|
||||
let Some((mark, bounds)) = self.submit_item_skinned(state, &[pose], light, lights) else {
|
||||
return false;
|
||||
};
|
||||
@@ -618,7 +618,7 @@ impl Game<'_> {
|
||||
let range =
|
||||
super::entity_culling::default_range(glam::DVec3::splat(0.25), self.view_scale());
|
||||
let skinning = self.skinning();
|
||||
let lights = diffuse_lights(self.dim == Dim::Nether);
|
||||
let lights = diffuse_lights(self.nether_lighting());
|
||||
for (item, at, partial, entity) in live.chain(taken) {
|
||||
let entity_start = out.len();
|
||||
self.mark_motion(entity_start, (2 << 32) | u64::from(item.id));
|
||||
|
||||
@@ -3556,13 +3556,9 @@ impl Game<'_> {
|
||||
&& self.pvp_land(host, victim, &strike)
|
||||
{
|
||||
let on_ground = Self::pvp_victim_on_ground(host, victim);
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.weapon_hit_player(on_ground);
|
||||
g.pvp_exhaustion();
|
||||
if strike.stops_sprint() {
|
||||
g.stop_sprint_after_extra_knockback();
|
||||
}
|
||||
});
|
||||
let stops_sprint = strike.stops_sprint();
|
||||
let sent = self.as_guest(host, conn, |g| g.pvp_hit_after(on_ground, stops_sprint));
|
||||
self.send_guest_motion(host, conn, sent.flatten());
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -4377,16 +4373,11 @@ impl Game<'_> {
|
||||
// Player.tick: a different main-hand item resets the attack
|
||||
// strength, and detectEquipmentUpdates gives the guest its modifiers.
|
||||
let g = &host.hub.guests[gi];
|
||||
let held = g
|
||||
.inventory
|
||||
.get(g.selected)
|
||||
.cloned()
|
||||
.flatten()
|
||||
.map(|s| s.item);
|
||||
let held = super::held_items::held_attack_item(&g.inventory, g.selected);
|
||||
let play = host.play.entry(conn).or_default();
|
||||
if play.attack_item != held {
|
||||
if play.attack_item.as_ref() != held {
|
||||
play.attack_ticks = 0;
|
||||
play.attack_item = held;
|
||||
play.attack_item = held.cloned();
|
||||
}
|
||||
self.guest_delayed_destroy(host, conn);
|
||||
self.guest_server_tick(host, conn);
|
||||
|
||||
@@ -131,10 +131,8 @@ pub(super) fn acts_on_player(e: &MobEvent) -> bool {
|
||||
| MobEvent::StabDismount {
|
||||
target: Who::Player,
|
||||
}
|
||||
| MobEvent::Ignite {
|
||||
target: Who::Player,
|
||||
..
|
||||
}
|
||||
| MobEvent::Ignite(Who::Player, _)
|
||||
| MobEvent::ZombieIgnite(Who::Player, _)
|
||||
| MobEvent::IronGolemHit {
|
||||
target: Who::Player,
|
||||
..
|
||||
|
||||
@@ -191,8 +191,8 @@ impl Game<'_> {
|
||||
let disable_blocking = if self.using.is_some() {
|
||||
0.0
|
||||
} else {
|
||||
self.attack_item_info()
|
||||
.map_or(0.0, |i| i.disable_blocking_seconds)
|
||||
self.held()
|
||||
.map_or(0.0, |stack| self.items().weapon_disable_blocking(stack))
|
||||
};
|
||||
let ignite = self.weapon_ignite_seconds();
|
||||
Some(Strike {
|
||||
|
||||
@@ -36,13 +36,25 @@ impl Game<'_> {
|
||||
let batch = match quiet::decode(data) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
super::dedicated::log(
|
||||
"WARN",
|
||||
&format!("{name} sent quiet commands that were refused: {e}"),
|
||||
);
|
||||
// At most one a second: a client sending refused payloads by the hundred a tick
|
||||
// must not fill the log.
|
||||
static LAST: std::sync::Mutex<Option<std::time::Instant>> =
|
||||
std::sync::Mutex::new(None);
|
||||
let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if last.is_none_or(|t| t.elapsed() >= std::time::Duration::from_secs(1)) {
|
||||
*last = Some(std::time::Instant::now());
|
||||
super::dedicated::log(
|
||||
"WARN",
|
||||
&format!("{name} sent quiet commands that were refused: {e}"),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Nothing to run, nothing to log: an empty batch would only be a summary line.
|
||||
if batch.lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
let waiting: usize = host
|
||||
.quiet
|
||||
.iter()
|
||||
@@ -51,6 +63,13 @@ impl Game<'_> {
|
||||
.sum();
|
||||
let room = PENDING.saturating_sub(waiting);
|
||||
let dropped = batch.lines.len().saturating_sub(room);
|
||||
// No room at all: counted on the player's last waiting batch, not a new (logged) one.
|
||||
if room == 0
|
||||
&& let Some(last) = host.quiet.iter_mut().rev().find(|b| b.conn == conn)
|
||||
{
|
||||
last.dropped += dropped;
|
||||
return;
|
||||
}
|
||||
host.quiet.push_back(QuietBatch {
|
||||
conn,
|
||||
mod_id: batch.mod_id,
|
||||
|
||||
@@ -280,8 +280,8 @@ impl Game<'_> {
|
||||
};
|
||||
let above = self.world.block(pos.x, root_y + 1, pos.z);
|
||||
let above_facts = self.res.facts().get(above);
|
||||
let nether = self.dim == super::Dim::Nether;
|
||||
let fluid = if &*above_facts.name == "minecraft:mud" && !nether {
|
||||
let evaporates = self.dimension_type().water_evaporates;
|
||||
let fluid = if &*above_facts.name == "minecraft:mud" && !evaporates {
|
||||
horizon_client::blocks::Fluid::Water
|
||||
} else {
|
||||
above_facts.fluid
|
||||
@@ -289,7 +289,9 @@ impl Game<'_> {
|
||||
let ty = match fluid {
|
||||
horizon_client::blocks::Fluid::Water => ParticleType::DrippingDripstoneWater,
|
||||
horizon_client::blocks::Fluid::Lava => ParticleType::DrippingDripstoneLava,
|
||||
horizon_client::blocks::Fluid::None if nether => ParticleType::DrippingDripstoneLava,
|
||||
horizon_client::blocks::Fluid::None if evaporates => {
|
||||
ParticleType::DrippingDripstoneLava
|
||||
}
|
||||
horizon_client::blocks::Fluid::None => ParticleType::DrippingDripstoneWater,
|
||||
};
|
||||
let offset = self
|
||||
|
||||
@@ -622,7 +622,11 @@ impl Game<'_> {
|
||||
Dim::Nether => "level_the_nether",
|
||||
Dim::End => "level_the_end",
|
||||
});
|
||||
if let Err(e) = self.enter_level(None, dim) {
|
||||
// The level's own share of the pass, outside its world tick: a slow tick's line names
|
||||
// each of them, and `level_tick` holds the world tick itself (#15349).
|
||||
if let Err(e) =
|
||||
horizon_client::spans::time("enter_level", || self.enter_level(None, dim))
|
||||
{
|
||||
result = Err(e);
|
||||
break;
|
||||
}
|
||||
@@ -633,10 +637,14 @@ impl Game<'_> {
|
||||
packets_drained: false,
|
||||
};
|
||||
if observe_level {
|
||||
let _span = horizon_client::spans::guard("mods_level_tick_pre");
|
||||
self.native_level_tick_pre(dim.id());
|
||||
}
|
||||
self.update_dedicated_streaming();
|
||||
self.update_world();
|
||||
horizon_client::spans::time("dedicated_streaming", || {
|
||||
self.update_dedicated_streaming();
|
||||
});
|
||||
horizon_client::spans::time("update_world", || self.update_world());
|
||||
let tick_span = horizon_client::spans::guard("level_tick");
|
||||
result = horizon_client::block_contact::in_level(
|
||||
(self.serving.is_some() || self.options.dedicated.is_some()).then_some(dim.id()),
|
||||
|| {
|
||||
@@ -653,16 +661,19 @@ impl Game<'_> {
|
||||
}
|
||||
},
|
||||
);
|
||||
drop(tick_span);
|
||||
if result.is_err() {
|
||||
break;
|
||||
}
|
||||
if observe_level {
|
||||
let _span = horizon_client::spans::guard("mods_level_tick_post");
|
||||
self.native_level_tick_post(dim.id());
|
||||
}
|
||||
self.publish_server_debug(dim);
|
||||
horizon_client::spans::time("server_debug", || self.publish_server_debug(dim));
|
||||
// An entity that crossed into a level that already ticked this
|
||||
// server tick is in it when the tick ends (`addDuringTeleport`).
|
||||
if running == Some(true) {
|
||||
let _span = horizon_client::spans::guard("late_portal_arrivals");
|
||||
let mut moved = false;
|
||||
for &earlier in &order[..k] {
|
||||
if !self.arrival_chunks(earlier).is_empty()
|
||||
@@ -680,6 +691,7 @@ impl Game<'_> {
|
||||
if let Some(&first) = order.first()
|
||||
&& self.dim != first
|
||||
{
|
||||
let _span = horizon_client::spans::guard("enter_level");
|
||||
let _ = self.enter_level(None, first);
|
||||
}
|
||||
self.level_pass = Pass::default();
|
||||
|
||||
@@ -378,6 +378,28 @@ impl Game<'_> {
|
||||
self.audio.play(sound, Some(feet), 1.0, 1.0);
|
||||
Some(fall)
|
||||
}
|
||||
/// What a server's guest does after their hit landed on another player:
|
||||
/// the weapon's side, the exhaustion and the sprint a knockback stops.
|
||||
/// Returns the attacker's motion when a smash set it, which
|
||||
/// `MaceItem.hurtEnemy` sends at once (`ClientboundSetEntityMotionPacket`),
|
||||
/// not at the end of the tick after gravity (#8246). The mark from before
|
||||
/// the hit stays.
|
||||
pub(super) fn pvp_hit_after(
|
||||
&mut self,
|
||||
victim_on_ground: bool,
|
||||
stops_sprint: bool,
|
||||
) -> Option<Vec3> {
|
||||
let p = self.player.as_mut()?;
|
||||
let marked = std::mem::take(&mut p.health.sync_velocity);
|
||||
self.weapon_hit_player(victim_on_ground);
|
||||
let p = self.player.as_mut()?;
|
||||
let sent = std::mem::replace(&mut p.health.sync_velocity, marked).then_some(p.velocity);
|
||||
self.pvp_exhaustion();
|
||||
if stops_sprint {
|
||||
self.stop_sprint_after_extra_knockback();
|
||||
}
|
||||
sent
|
||||
}
|
||||
/// `Player.itemAttackInteraction` after the player's hit hurt another
|
||||
/// player: the weapon's side of it, as `weapon_hit` does for a mob. A
|
||||
/// smash stops the fall and its Wind Burst (a `post_attack` explosion on
|
||||
|
||||
@@ -80,7 +80,7 @@ impl Game<'_> {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Kind {
|
||||
Generic,
|
||||
/// The saved decorated-pot slot, exposed only to hopper insertion.
|
||||
/// The saved decorated-pot slot: hopper insertion and the comparator signal.
|
||||
Pot,
|
||||
/// `AbstractFurnaceBlockEntity` (a `WorldlyContainer`).
|
||||
Furnace,
|
||||
@@ -362,24 +362,28 @@ impl Containers<'_> {
|
||||
container: None,
|
||||
})
|
||||
}
|
||||
/// `HopperBlockEntity.getBlockContainer` at its insertion target only.
|
||||
/// Pots keep their authoritative slot in the chunk's saved tag. Pending
|
||||
/// loot is deliberately left to its existing path, without unpacking it.
|
||||
fn hopper_insertion_view(&self, pos: Pos) -> Option<View> {
|
||||
self.view(pos, false).or_else(|| {
|
||||
if self.name(self.world.block(pos.0, pos.1, pos.2)) != "minecraft:decorated_pot"
|
||||
|| spill::saved_entity(self.world, [pos.0, pos.1, pos.2])
|
||||
.is_some_and(|tag| matches!(tag.get("LootTable"), Some(Tag::String(_))))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(View {
|
||||
parts: vec![(pos, 1)],
|
||||
kind: Kind::Pot,
|
||||
container: None,
|
||||
})
|
||||
/// The saved decorated-pot slot as a container: `DecoratedPotBlockEntity`
|
||||
/// is a `ContainerSingleItem`, but Horizon keeps pots out of generic
|
||||
/// container discovery (`world.block_entities`), so this is built on demand.
|
||||
/// A pot with pending loot is left to its existing unpacking path.
|
||||
fn saved_pot_view(&self, pos: Pos) -> Option<View> {
|
||||
if self.name(self.world.block(pos.0, pos.1, pos.2)) != "minecraft:decorated_pot"
|
||||
|| spill::saved_entity(self.world, [pos.0, pos.1, pos.2])
|
||||
.is_some_and(|tag| matches!(tag.get("LootTable"), Some(Tag::String(_))))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(View {
|
||||
parts: vec![(pos, 1)],
|
||||
kind: Kind::Pot,
|
||||
container: None,
|
||||
})
|
||||
}
|
||||
/// `HopperBlockEntity.getBlockContainer` at its insertion target only.
|
||||
/// Pots keep their authoritative slot in the chunk's saved tag.
|
||||
fn hopper_insertion_view(&self, pos: Pos) -> Option<View> {
|
||||
self.view(pos, false).or_else(|| self.saved_pot_view(pos))
|
||||
}
|
||||
/// The `ContainerSingleItem` slot for hopper insertion: only a saved pot
|
||||
/// needs an owned decode. Existing modeled containers stay borrowed.
|
||||
fn insertion_item(&self, view: &View, slot: usize) -> Option<Cow<'_, ItemStack>> {
|
||||
@@ -519,8 +523,9 @@ impl Containers<'_> {
|
||||
}
|
||||
let mut total = 0.0f32;
|
||||
for i in 0..view.len() {
|
||||
if let Some(s) = self.get(view, i) {
|
||||
total += s.count as f32 / self.max_stack(s) as f32;
|
||||
// `insertion_item` decodes a saved pot's slot; every other kind is `get`.
|
||||
if let Some(s) = self.insertion_item(view, i) {
|
||||
total += s.count as f32 / self.max_stack(&s) as f32;
|
||||
}
|
||||
}
|
||||
total /= view.len().max(1) as f32;
|
||||
@@ -653,6 +658,7 @@ pub(super) fn container_signal(level: &super::GameLevel<'_>, pos: BlockPos) -> i
|
||||
};
|
||||
c.view((pos.x, pos.y, pos.z), true)
|
||||
.filter(|v| !matches!(v.kind, Kind::Composter(_)))
|
||||
.or_else(|| c.saved_pot_view((pos.x, pos.y, pos.z)))
|
||||
.map_or(0, |v| c.signal(&v))
|
||||
}
|
||||
|
||||
@@ -2425,6 +2431,10 @@ mod pot_hopper_insertion_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "machines_pot_signal_tests.rs"]
|
||||
mod pot_signal_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -380,6 +380,40 @@ mod tests {
|
||||
c.set_slot_state(4, true);
|
||||
assert_eq!(c.redstone_signal(), 1);
|
||||
}
|
||||
/// `CrafterBlockEntity.setSlotState`: a slot toggled in the menu lands on
|
||||
/// the block entity itself, whose `setChanged` marks the chunk.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_slot_toggled_in_the_menu_lands_on_the_crafter() {
|
||||
use crate::game::native_machines::tests::server_game;
|
||||
let mut game = server_game();
|
||||
let key = (2, 4, 3);
|
||||
let state = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:crafter")
|
||||
.unwrap();
|
||||
game.world.set(key.0, key.1, key.2, state).unwrap();
|
||||
let mut crafter = Crafter::default();
|
||||
crafter.set_slot_state(3, false);
|
||||
game.world
|
||||
.block_entities
|
||||
.put(key, BlockEntity::Crafter(crafter));
|
||||
game.container = Some(key);
|
||||
game.world.modified.clear();
|
||||
|
||||
game.crafter_slot_clicked(303, 0, horizon_client::menu::Input::Pickup);
|
||||
|
||||
let Some(BlockEntity::Crafter(c)) = game.world.block_entities.get(&key) else {
|
||||
panic!("no crafter");
|
||||
};
|
||||
assert!(!c.disabled[3], "the click enabled the slot");
|
||||
assert!(
|
||||
game.world.modified.contains(&(0, 0)),
|
||||
"the toggled slot left the chunk unmarked"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn hoppers_fill_the_smaller_stacks_first() {
|
||||
let mut c = Crafter::default();
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//! `DecoratedPotBlock.getAnalogOutputSignal` (`getRedstoneSignalFromBlockEntity`
|
||||
//! → `getRedstoneSignalFromContainer`): a pot's one slot lives in the chunk's
|
||||
//! saved block entity, so a comparator beside it has to read that tag.
|
||||
use super::super::{native_machines::tests::server_game, spill};
|
||||
use super::*;
|
||||
use horizon_client::save;
|
||||
use horizon_world::level::LevelAccess;
|
||||
|
||||
/// `getRedstoneSignalFromBlockEntity` for the block at `pos`, the path a
|
||||
/// comparator's `getAnalogOutputSignal` takes.
|
||||
fn signal(game: &mut Game<'_>, pos: Pos) -> i32 {
|
||||
let state = game.world.block(pos.0, pos.1, pos.2);
|
||||
game.with_level(|_, level, _| {
|
||||
level.block_entity_analog_signal(BlockPos::new(pos.0, pos.1, pos.2), state, Direction::West)
|
||||
})
|
||||
}
|
||||
|
||||
fn saved_count(game: &Game<'_>, pos: Pos) -> u32 {
|
||||
let tag = spill::saved_entity(&game.world, [pos.0, pos.1, pos.2]).expect("a saved pot");
|
||||
save::item_from_nbt(tag.get("item").expect("the pot's item"))
|
||||
.expect("a decodable pot item")
|
||||
.count
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
|
||||
fn a_pot_feeds_a_comparator_from_its_saved_slot_like_a_chest() {
|
||||
let mut game = server_game();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
|
||||
let pot = (8, 200, 8);
|
||||
let chest = (8, 200, 10);
|
||||
assert!(game.run_command("setblock 8 200 8 minecraft:decorated_pot"));
|
||||
// Vanilla's signal for one slot (`Mth.lerpDiscrete(count / 64, 0, 15)`); a
|
||||
// chest at the same fill fraction in all 27 slots reads the same.
|
||||
for (count, expected) in [(64, 15), (32, 8), (1, 1)] {
|
||||
assert!(game.run_command(&format!(
|
||||
"data merge block 8 200 8 {{item:{{id:\"minecraft:stone\",count:{count}}}}}"
|
||||
)));
|
||||
let items: Vec<String> = (0..27)
|
||||
.map(|slot| format!("{{Slot:{slot}b,id:\"minecraft:stone\",count:{count}}}"))
|
||||
.collect();
|
||||
assert!(game.run_command(&format!(
|
||||
"setblock 8 200 10 minecraft:chest{{Items:[{}]}}",
|
||||
items.join(",")
|
||||
)));
|
||||
assert_eq!(signal(&mut game, pot), expected, "a pot holding {count}");
|
||||
assert_eq!(
|
||||
signal(&mut game, chest),
|
||||
expected,
|
||||
"a chest holding {count} in every slot"
|
||||
);
|
||||
}
|
||||
assert!(game.run_command("data remove block 8 200 8 item"));
|
||||
assert!(game.run_command("data merge block 8 200 10 {Items:[]}"));
|
||||
assert_eq!(signal(&mut game, pot), 0, "an empty pot");
|
||||
assert_eq!(signal(&mut game, chest), 0, "an empty chest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
|
||||
fn a_hopper_inserted_item_updates_the_comparator_beside_the_pot() {
|
||||
let mut game = server_game();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
|
||||
let hopper = (7, 200, 8);
|
||||
let pot = (8, 200, 8);
|
||||
let comparator = BlockPos::new(9, 200, 8);
|
||||
assert!(game.run_command(
|
||||
"setblock 7 200 8 minecraft:hopper[facing=east,enabled=true]{Items:[{Slot:0b,id:\"minecraft:stone\",count:1}],TransferCooldown:0}"
|
||||
));
|
||||
assert!(game.run_command(
|
||||
"setblock 8 200 8 minecraft:decorated_pot{item:{id:\"minecraft:stone\",count:63}}"
|
||||
));
|
||||
assert!(game.run_command("setblock 9 200 8 minecraft:comparator[facing=west]"));
|
||||
let output = |game: &Game<'_>| {
|
||||
game.block_ticks
|
||||
.comparator_outputs
|
||||
.get(&comparator)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
};
|
||||
// `ComparatorBlock.tick` on the read side of the comparator.
|
||||
let tick = |game: &mut Game<'_>| {
|
||||
game.with_level(|b, level, ticks| b.scheduled_block_tick(level, ticks, comparator));
|
||||
};
|
||||
tick(&mut game);
|
||||
assert_eq!(output(&game), 14, "63 items: lerpDiscrete(63/64, 0, 15)");
|
||||
for _ in 0..8 {
|
||||
game.world.game_time += 1;
|
||||
game.tick_hopper(hopper);
|
||||
}
|
||||
assert_eq!(saved_count(&game, pot), 64, "the hopper filled the pot");
|
||||
tick(&mut game);
|
||||
assert_eq!(output(&game), 15, "the comparator re-read the saved pot");
|
||||
}
|
||||
@@ -485,7 +485,7 @@ impl Game<'_> {
|
||||
brewing: &self.stations().brewing,
|
||||
banners: &self.stations().banners,
|
||||
station_recipes: &self.stations().recipes,
|
||||
enchantments: &self.stations().enchantments,
|
||||
enchantments: self.enchantments(),
|
||||
names: &self.pack.names,
|
||||
trade: Some(&trade_env),
|
||||
maps: level_maps
|
||||
@@ -675,14 +675,8 @@ impl Game<'_> {
|
||||
// EnchantmentMenu.slotsChanged: new offers for the item.
|
||||
self.station_changed();
|
||||
}
|
||||
if kind == Kind::CrafterBlock
|
||||
&& let Some(BlockEntity::Crafter(c)) = self
|
||||
.container
|
||||
.and_then(|k| self.world.block_entities.get_mut(&k))
|
||||
{
|
||||
for (i, d) in c.disabled.iter_mut().enumerate() {
|
||||
*d = menu.data[i] == 1;
|
||||
}
|
||||
if kind == Kind::CrafterBlock {
|
||||
self.apply_crafter_slot_states(&menu);
|
||||
}
|
||||
if matches!(kind, Kind::Furnace(_))
|
||||
&& let Some(BlockEntity::Furnace(f)) = self
|
||||
@@ -706,6 +700,25 @@ impl Game<'_> {
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `CrafterBlockEntity.setSlotState`: the menu's slot states land on the
|
||||
/// block entity, whose `setChanged` marks the chunk for saving.
|
||||
pub(super) fn apply_crafter_slot_states(&mut self, menu: &horizon_client::menu::Menu) {
|
||||
let Some(key) = self.container else {
|
||||
return;
|
||||
};
|
||||
let Some(BlockEntity::Crafter(c)) = self.world.block_entities.get_mut(&key) else {
|
||||
return;
|
||||
};
|
||||
let mut changed = false;
|
||||
for (i, d) in c.disabled.iter_mut().enumerate() {
|
||||
let disabled = menu.data[i] == 1;
|
||||
changed |= *d != disabled;
|
||||
*d = disabled;
|
||||
}
|
||||
if changed {
|
||||
self.container_changed(key);
|
||||
}
|
||||
}
|
||||
pub(super) fn click_inventory(&mut self, right: bool) {
|
||||
if self.mod_browser_press(right) || self.mount_click(right) {
|
||||
return;
|
||||
|
||||
@@ -30,8 +30,7 @@ use horizon_world::registry::BlockStateId;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
mod index;
|
||||
mod nbt;
|
||||
pub(super) use index::CartIndex;
|
||||
use index::cart_at;
|
||||
pub(super) use index::{CartIndex, cart_at};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub(super) enum CartKind {
|
||||
@@ -1895,7 +1894,7 @@ impl Game<'_> {
|
||||
if !self.native_tick_pre_one(&mut round, id, kind) {
|
||||
continue;
|
||||
}
|
||||
let Some(i) = cart_at(&self.carts, id, i) else {
|
||||
let Some(i) = self.cart_in_world(id, i, &round, kind) else {
|
||||
continue;
|
||||
};
|
||||
// The ticked copy takes the loot roll instead of copying it: only
|
||||
@@ -2028,8 +2027,8 @@ impl Game<'_> {
|
||||
} else {
|
||||
(left, forward)
|
||||
};
|
||||
let r = self.yaw.to_radians();
|
||||
let (s, co) = (f64::from(r.sin()), f64::from(r.cos()));
|
||||
let r = f64::from(self.yaw * 0.017_453_292);
|
||||
let (s, co) = (f64::from(trig::sin(r)), f64::from(trig::cos(r)));
|
||||
let intent = [left * co - forward * s, forward * co + left * s];
|
||||
let mut c = self.carts[i].clone();
|
||||
let alive = self.tick_cart(&mut c, intent, i, &mut CartIndex::default());
|
||||
@@ -2913,7 +2912,7 @@ impl Game<'_> {
|
||||
emissive: &self.gui.atlas.emissive,
|
||||
translucent_sprites: &self.gui.atlas.translucent,
|
||||
atlas_revision: self.gui.atlas.revision,
|
||||
nether: self.dim == super::Dim::Nether,
|
||||
nether: self.nether_lighting(),
|
||||
lid: &lid,
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
|
||||
@@ -97,7 +97,7 @@ impl CartIndex {
|
||||
/// the usual case while carts tick in order) or found by a scan. A world
|
||||
/// with hundreds of mineshaft chest carts asked this of every cart several
|
||||
/// times a tick.
|
||||
pub(super) fn cart_at(carts: &[Minecart], id: u32, hint: usize) -> Option<usize> {
|
||||
pub(in crate::game) fn cart_at(carts: &[Minecart], id: u32, hint: usize) -> Option<usize> {
|
||||
if carts.get(hint).is_some_and(|c| c.id == id) {
|
||||
return Some(hint);
|
||||
}
|
||||
|
||||
@@ -639,7 +639,7 @@ impl Game<'_> {
|
||||
light,
|
||||
lights: if lit {
|
||||
Some(horizon_client::block_entity_render::diffuse_lights(
|
||||
self.dim == super::Dim::Nether,
|
||||
self.nether_lighting(),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
|
||||
@@ -1079,7 +1079,7 @@ fn dismount_location(
|
||||
}
|
||||
/// `Entity.getCollisionHorizontalEscapeVector`: out of the vehicle's box
|
||||
/// along `yaw`, to the edge of the square it spans.
|
||||
fn escape_vector(vehicle_width: f64, passenger_width: f64, yaw: f32) -> Vec3 {
|
||||
pub(super) fn escape_vector(vehicle_width: f64, passenger_width: f64, yaw: f32) -> Vec3 {
|
||||
// 1.0E-5F: a float literal widened to double.
|
||||
let distance = (vehicle_width + passenger_width + f64::from(1.0e-5_f32)) / 2.0;
|
||||
let radians = f64::from(yaw * 0.017_453_292);
|
||||
|
||||
@@ -432,7 +432,7 @@ impl Game<'_> {
|
||||
light: 0,
|
||||
block: [0; 3],
|
||||
poses: Vec::new(),
|
||||
lights: diffuse_lights(self.dim == Dim::Nether),
|
||||
lights: diffuse_lights(self.nether_lighting()),
|
||||
vertices: std::mem::take(out),
|
||||
kept: false,
|
||||
spins: Vec::new(),
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! player in view gets `native_mob_layer` after its vanilla model
|
||||
//! (`EntityRenderersEvent.AddLayers`), in the same frame.
|
||||
|
||||
use super::{Dim, Game, native_block_entity_draw::BlockEntityDraw};
|
||||
use super::{Game, native_block_entity_draw::BlockEntityDraw};
|
||||
use glam::{DVec3, Mat4};
|
||||
use horizon_client::{
|
||||
block_entity_render::diffuse_lights,
|
||||
@@ -184,7 +184,7 @@ impl Game<'_> {
|
||||
light,
|
||||
[feet.x, feet.y, feet.z].map(|c| c.floor() as i32),
|
||||
mob_pose(feet - camera, render.body_yaw),
|
||||
diffuse_lights(self.dim == Dim::Nether),
|
||||
diffuse_lights(self.nether_lighting()),
|
||||
std::mem::take(out),
|
||||
);
|
||||
let keep = hook(&render, &mut draw);
|
||||
|
||||
@@ -755,6 +755,17 @@ impl Game<'_> {
|
||||
})
|
||||
.unwrap_or(ENTITY_DELAY)
|
||||
}
|
||||
/// `Entity.saveWithoutId` writes the live `PortalCooldown`: the countdown
|
||||
/// `Entity.processPortalCooldown` ticks down, not the value the entity was
|
||||
/// loaded with. Every entity that can stand in a portal takes it from the
|
||||
/// portal state, as the projectile writer does. An entity the portal
|
||||
/// state does not know is not on a cooldown, and writes 0.
|
||||
pub(super) fn save_portal_cooldown(&self, id: u32, tag: &mut Tag) {
|
||||
horizon_client::nether_portal::put_portal_cooldown(
|
||||
tag,
|
||||
horizon_client::nether_portal::portal_cooldown(&self.portals.entities, id),
|
||||
);
|
||||
}
|
||||
/// `Entity.saveWithoutId`: the modeled non-arrow projectile codecs
|
||||
/// share the live cooldown between command reads and chunk writes.
|
||||
pub(super) fn save_projectile_portal_cooldown(
|
||||
|
||||
@@ -81,7 +81,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// A saved spawner, as `readAdditionalSaveData` leaves it: its tick
|
||||
/// count starts again from 0.
|
||||
pub(super) fn load_ominous_item_spawner(&mut self, tag: horizon_world::nbt::Tag) {
|
||||
pub(super) fn load_ominous_item_spawner(&mut self, mut tag: horizon_world::nbt::Tag) {
|
||||
let Some((item, at, spawn_item_after)) =
|
||||
horizon_client::entity_nbt::ominous_item_spawner_from_nbt(&tag)
|
||||
else {
|
||||
@@ -92,6 +92,17 @@ impl Game<'_> {
|
||||
let saved = horizon_client::entity_nbt::uuid(&tag);
|
||||
// Its saved UUID and the attachments saved under it (#7398).
|
||||
self.remember_uuid(id, saved, &tag);
|
||||
// Entity.load's `setXRot` keeps the pitch in -90..90 and its `setRot`
|
||||
// takes both `% 360`; the save writes the rotation back from this tag.
|
||||
let [yaw, pitch] = horizon_client::entity_nbt::rotation(&tag);
|
||||
let (yaw, pitch) = (yaw % 360.0, (pitch % 360.0).clamp(-90.0, 90.0));
|
||||
let floats = [yaw, pitch].map(|v| horizon_world::nbt::Tag::Float(v.to_bits()));
|
||||
if let horizon_world::nbt::Tag::Compound(map) = &mut tag {
|
||||
map.insert(
|
||||
"Rotation".into(),
|
||||
horizon_world::nbt::Tag::List(floats.into()),
|
||||
);
|
||||
}
|
||||
let uuid = self.uuid_for(id);
|
||||
self.ominous_spawners.push(OminousItemSpawner {
|
||||
id,
|
||||
|
||||
@@ -79,6 +79,7 @@ fn find_join<T: Joined>(list: &[T], join: JoinSeq) -> Option<usize> {
|
||||
impl Game<'_> {
|
||||
/// A level tick starts: what has joined so far ticks this tick.
|
||||
pub(super) fn begin_ordered_ticks(&mut self) {
|
||||
let _span = horizon_client::spans::guard("ordered_ticks");
|
||||
let pending_buf = std::mem::take(&mut self.ordered.pending_buf);
|
||||
let firsts = [
|
||||
first_join(&self.armor_stands),
|
||||
@@ -111,6 +112,7 @@ impl Game<'_> {
|
||||
/// Ticks, one at a time in join order, every orb, primed TNT and falling
|
||||
/// block that joined before `cut` and has not ticked this tick.
|
||||
pub(super) fn run_ordered_ticks(&mut self, cut: JoinSeq, late: bool) -> Result<()> {
|
||||
let _span = horizon_client::spans::guard("ordered_ticks");
|
||||
let mut first_error = None;
|
||||
let mut followed = None;
|
||||
let mut events = false;
|
||||
|
||||
@@ -707,7 +707,7 @@ impl Game<'_> {
|
||||
p.id = self.next_entity;
|
||||
self.restore_portal_cooldown(p.id, &tag);
|
||||
self.remember_tags(p.id, &tag);
|
||||
self.remember_uuid(p.id, uuid, &tag);
|
||||
self.remember_uuid(p.id, Some(p.own_uuid(uuid)), &tag);
|
||||
self.add_projectile(p);
|
||||
}
|
||||
}
|
||||
@@ -725,8 +725,8 @@ impl Game<'_> {
|
||||
self.next_entity += 1;
|
||||
p.id = self.next_entity;
|
||||
self.restore_portal_cooldown(p.id, &tag);
|
||||
// Entity.load: the saved or summoned UUID, as the other projectiles keep theirs.
|
||||
self.remember_uuid(p.id, uuid, &tag);
|
||||
// Entity.load: the saved or summoned UUID, else its own random's.
|
||||
self.remember_uuid(p.id, Some(p.own_uuid(uuid)), &tag);
|
||||
self.add_projectile(p);
|
||||
self.remember_last_projectile_tags(&tag);
|
||||
}
|
||||
@@ -752,7 +752,7 @@ impl Game<'_> {
|
||||
self.next_entity += 1;
|
||||
p.id = self.next_entity;
|
||||
self.restore_portal_cooldown(p.id, &tag);
|
||||
self.remember_uuid(p.id, uuid, &tag);
|
||||
self.remember_uuid(p.id, Some(p.own_uuid(uuid)), &tag);
|
||||
self.add_projectile(p);
|
||||
self.remember_last_projectile_tags(&tag);
|
||||
}
|
||||
@@ -771,7 +771,7 @@ impl Game<'_> {
|
||||
self.next_entity += 1;
|
||||
p.id = self.next_entity;
|
||||
self.restore_portal_cooldown(p.id, &tag);
|
||||
self.remember_uuid(p.id, uuid, &tag);
|
||||
self.remember_uuid(p.id, Some(p.own_uuid(uuid)), &tag);
|
||||
self.add_projectile(p);
|
||||
self.remember_last_projectile_tags(&tag);
|
||||
}
|
||||
@@ -1613,6 +1613,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.write_leash(&mut tag, i);
|
||||
self.put_mob_passengers(&mut tag, id);
|
||||
self.save_portal_cooldown(id, &mut tag);
|
||||
tag
|
||||
}
|
||||
/// Forget an entity that left the world (its UUID and loaded compound).
|
||||
@@ -1895,7 +1896,8 @@ impl Game<'_> {
|
||||
out.entry(chunk_of(at)).or_default().push(tag);
|
||||
}
|
||||
for o in &self.others {
|
||||
if keys.contains(&chunk_of(o.position)) {
|
||||
// EntityType.noSave: Entity.getEncodeId is null, so the chunk never stores it.
|
||||
if keys.contains(&chunk_of(o.position)) && o.kind.as_str() != "minecraft:leash_knot" {
|
||||
let uuid = self
|
||||
.persist
|
||||
.uuids
|
||||
@@ -2192,12 +2194,13 @@ impl Game<'_> {
|
||||
keys.contains(&chunk_of(at)).then(|| {
|
||||
let id = self.dropped[i].id;
|
||||
let uuid = self.uuid_for(id);
|
||||
let tag = entity_nbt::item_entity_to_nbt_with_items(
|
||||
let mut tag = entity_nbt::item_entity_to_nbt_with_items(
|
||||
&self.dropped[i],
|
||||
uuid,
|
||||
self.persist.raw.get(&id),
|
||||
self.items(),
|
||||
);
|
||||
self.save_portal_cooldown(id, &mut tag);
|
||||
(chunk_of(at), tag)
|
||||
})
|
||||
}
|
||||
@@ -2345,6 +2348,7 @@ impl Game<'_> {
|
||||
self.put_mob_passengers(&mut tag, id);
|
||||
// AbstractBoat.addAdditionalSaveData: the lead's holder.
|
||||
self.write_boat_leash(&mut tag, i);
|
||||
self.save_portal_cooldown(id, &mut tag);
|
||||
Some((chunk_of(at), tag))
|
||||
}
|
||||
pub(super) fn save_cart(
|
||||
@@ -2359,6 +2363,7 @@ impl Game<'_> {
|
||||
let uuid = self.saved_uuid_or_random(id);
|
||||
let mut tag = self.carts[i].to_nbt(uuid, self.persist.raw.get(&id));
|
||||
self.put_mob_passengers(&mut tag, id);
|
||||
self.save_portal_cooldown(id, &mut tag);
|
||||
Some((chunk_of(at), tag))
|
||||
}
|
||||
pub(super) fn save_armor_stand(
|
||||
@@ -6004,7 +6009,20 @@ mod camel_pose_saved_tests {
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
assert!(game.run_command("forceload add 0 0"));
|
||||
// A mob only ticks with its whole 5x5 chunk neighbourhood loaded.
|
||||
for x in -2..=2 {
|
||||
for z in -2..=2 {
|
||||
if (x, z) != (0, 0) {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((x, z), Arc::new(Chunk::empty(x, z)));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(x, z))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(game.run_command("forceload add -32 -32 32 32"));
|
||||
assert!(game.run_command("tick freeze"));
|
||||
let initial = game.world.game_time;
|
||||
assert!(game.run_command("tick step 128"));
|
||||
@@ -6074,6 +6092,31 @@ mod camel_pose_saved_tests {
|
||||
game.tick_server(false).unwrap();
|
||||
}
|
||||
}
|
||||
// Unload and reload the chunk, then let time pass: the saved game time
|
||||
// stays what it was (`Camel.addAdditionalSaveData` writes it unchanged).
|
||||
let written: Vec<Tag> = game
|
||||
.entity_chunks(&HashSet::from([(0, 0)]), true)
|
||||
.into_iter()
|
||||
.flat_map(|(_, tags)| tags)
|
||||
.collect();
|
||||
game.world.loaded_entities.push(((0, 0), written));
|
||||
game.spawn_loaded_entities();
|
||||
for step in 0..8 {
|
||||
for number in 1..=cases.len() as i32 {
|
||||
let index = game
|
||||
.mobs
|
||||
.iter()
|
||||
.position(|m| game.persist.uuids.get(&m.id) == Some(&[94, 0, 0, number]))
|
||||
.unwrap();
|
||||
let value = actors[number as usize - 1].1;
|
||||
assert_eq!(
|
||||
game.mob_tree_nbt(index).get("LastPoseTick"),
|
||||
Some(&Tag::Long(value)),
|
||||
"reloaded timestamp {value} after {step} steps"
|
||||
);
|
||||
}
|
||||
game.tick_server(false).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1949,7 +1949,14 @@ impl Game<'_> {
|
||||
// Projectile.onHit calls onHitBlock first, then the potion's
|
||||
// affectEntitiesAround; both read the stack's `potion_contents`.
|
||||
if let Some((cell, face)) = hit {
|
||||
self.potion_hit_block(stack, cell, face);
|
||||
// `AbstractThrownPotion.douseFire` passes `getOwner()` to the
|
||||
// campfire's `douse`, which is where the game event's source is.
|
||||
let who = if owner.player {
|
||||
Some(Who::Player)
|
||||
} else {
|
||||
owner.attacker.map(Who::Mob)
|
||||
};
|
||||
self.potion_hit_block(stack, cell, face, who);
|
||||
}
|
||||
self.affect_entities_around(stack, at, owner.player);
|
||||
let lingering = &*stack.item == "minecraft:lingering_potion";
|
||||
|
||||
@@ -18,7 +18,7 @@ impl Game<'_> {
|
||||
fn quick_actions_dialog(&self) -> Option<Tag> {
|
||||
let registries = &self.remote.as_ref()?.registries;
|
||||
let dialogs = registries.registries.get("minecraft:dialog")?;
|
||||
let tag = registries.dialog_tags.get("minecraft:quick_actions")?;
|
||||
let tag = registries.tag("minecraft:dialog", "minecraft:quick_actions")?;
|
||||
match tag.len() {
|
||||
0 => None,
|
||||
1 => {
|
||||
|
||||
@@ -60,8 +60,12 @@ use std::{
|
||||
};
|
||||
use winit::{event::MouseButton, keyboard::KeyCode};
|
||||
|
||||
#[path = "remote_abilities.rs"]
|
||||
mod abilities;
|
||||
#[path = "remote_poll.rs"]
|
||||
mod poll;
|
||||
#[path = "remote_tables.rs"]
|
||||
mod tables;
|
||||
|
||||
type BuiltinCache = Vec<(std::path::PathBuf, Arc<Builtin>)>;
|
||||
static BUILTIN: std::sync::Mutex<BuiltinCache> = std::sync::Mutex::new(Vec::new());
|
||||
@@ -539,9 +543,8 @@ pub(super) struct Remote {
|
||||
pub(super) builtin: Arc<Builtin>,
|
||||
lang: Arc<Language>,
|
||||
pub(super) registries: Arc<Dynamic>,
|
||||
/// The game's enchantments and the server's that it lacks (a data
|
||||
/// pack's), from their synced data; `None` when the server has none.
|
||||
pub(super) enchantments: Option<Arc<horizon_client::enchant::Enchantments>>,
|
||||
/// The game's tables with what the server's synced data puts over them.
|
||||
pub(super) tables: tables::ServerTables,
|
||||
pub(super) joined: bool,
|
||||
/// `ServerReconfigScreen` is up since this tick.
|
||||
pub(super) reconfiguring: Option<u64>,
|
||||
@@ -1159,7 +1162,7 @@ impl Game<'_> {
|
||||
.unwrap_or_else(|| "Connecting to the server...".into()),
|
||||
lang,
|
||||
registries: Arc::new(Dynamic::default()),
|
||||
enchantments: None,
|
||||
tables: Default::default(),
|
||||
joined: false,
|
||||
reconfiguring: None,
|
||||
logged_in: false,
|
||||
@@ -1389,7 +1392,7 @@ impl Game<'_> {
|
||||
status: "Playing Studio Replay".into(),
|
||||
lang,
|
||||
registries: Arc::new(Dynamic::default()),
|
||||
enchantments: None,
|
||||
tables: Default::default(),
|
||||
joined: false,
|
||||
reconfiguring: None,
|
||||
logged_in: true,
|
||||
@@ -2259,25 +2262,9 @@ impl Game<'_> {
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let entries: Vec<(&str, serde_json::Value)> = registries
|
||||
.registries
|
||||
.get("minecraft:enchantment")
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|(name, data)| {
|
||||
Some((
|
||||
name.as_str(),
|
||||
horizon_client::scoreboard::tag_to_json(data.as_ref()?),
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
let enchantments = self
|
||||
.stations()
|
||||
.enchantments
|
||||
.with_entries(entries.iter().map(|(n, v)| (*n, v)))
|
||||
.map(Arc::new);
|
||||
let tables = self.server_tables(®istries);
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.enchantments = enchantments;
|
||||
r.tables = tables;
|
||||
let bits = ceil_log2(biomes.len());
|
||||
let column = r.chunk_format.layout();
|
||||
r.chunk_format =
|
||||
@@ -2414,7 +2401,7 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.is_none_or(|r| r.dimension.has_skylight);
|
||||
self.streaming.sky_light = has_sky;
|
||||
self.streaming.nether_shading = self.dim == Dim::Nether;
|
||||
self.streaming.nether_shading = self.nether_lighting();
|
||||
// The seed in SpawnInfo is already the hashed one the client's BiomeManager holds.
|
||||
self.streaming.biome_zoom = horizon_core::biome::BiomeZoom::new(spawn.seed);
|
||||
let player = self.player.as_ref();
|
||||
@@ -5298,6 +5285,7 @@ impl Game<'_> {
|
||||
self.remote_server_data(p.icon.as_deref());
|
||||
}
|
||||
C::MapItemData(p) => self.remote_map_item_data(&p),
|
||||
C::UpdateTags(p) => self.remote_update_tags(&p),
|
||||
// handleTickingState / handleTickingStep: the level's TickRateManager.
|
||||
C::TickingState(p) => {
|
||||
self.clock.set_rate(p.rate);
|
||||
@@ -6729,20 +6717,6 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// ClientPacketListener.handlePlayerAbilities: the player flies, may fly
|
||||
/// and is invulnerable as the server says (bits 2, 4 and 1).
|
||||
fn apply_remote_abilities(&mut self) {
|
||||
let (Some(player), Some(a)) = (
|
||||
self.player.as_mut(),
|
||||
self.remote.as_ref().and_then(|r| r.abilities.as_ref()),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
player.invulnerable = a.flags & 1 != 0;
|
||||
player.flying = a.flags & 2 != 0;
|
||||
player.may_fly = a.flags & 4 != 0;
|
||||
player.flying_speed = a.flying_speed;
|
||||
}
|
||||
/// The local player's share of every touching entity's
|
||||
/// `LivingEntity.pushEntities`, and of the boats' pushes: on a client
|
||||
/// `EntitySelector.pushableBy` lets entities push only the local player,
|
||||
@@ -6960,7 +6934,7 @@ impl Game<'_> {
|
||||
}
|
||||
let game_time = self.world.game_time;
|
||||
// `AbstractPiglin.isConverting`: the dimension is not piglin safe.
|
||||
let nether = self.dim == super::Dim::Nether;
|
||||
let nether = !self.dimension_type().piglins_zombify;
|
||||
// What a wither's side heads can look at, by network entity id: the
|
||||
// mobs and the player (position and eye height).
|
||||
let wither_targets: Vec<(i32, [f64; 3], f64)> = if self
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! The server's ClientboundPlayerAbilitiesPacket on the local player (`ClientPacketListener
|
||||
//! .handlePlayerAbilities`).
|
||||
use super::*;
|
||||
|
||||
/// `Abilities.walkingSpeed` before the server sends one.
|
||||
const WALKING_SPEED: f32 = 0.1;
|
||||
|
||||
/// The walking speed `AbstractClientPlayer.getFieldOfViewModifier` divides the movement speed by:
|
||||
/// the last abilities packet's (`Abilities.setWalkingSpeed`), else the default.
|
||||
fn walking_speed(abilities: Option<&play::PlayerAbilities>) -> f32 {
|
||||
abilities.map_or(WALKING_SPEED, |a| a.walking_speed)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// ClientPacketListener.handlePlayerAbilities: the player flies, may fly
|
||||
/// and is invulnerable as the server says (bits 2, 4 and 1). The walking
|
||||
/// speed is read where the field of view needs it
|
||||
/// ([`Game::remote_walking_speed`]), the instabuild bit where the packet
|
||||
/// is handled (`Game::creative`).
|
||||
pub(super) fn apply_remote_abilities(&mut self) {
|
||||
let (Some(player), Some(a)) = (
|
||||
self.player.as_mut(),
|
||||
self.remote.as_ref().and_then(|r| r.abilities.as_ref()),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
player.invulnerable = a.flags & 1 != 0;
|
||||
player.flying = a.flags & 2 != 0;
|
||||
player.may_fly = a.flags & 4 != 0;
|
||||
player.flying_speed = a.flying_speed;
|
||||
}
|
||||
|
||||
/// `Abilities.getWalkingSpeed()` of the local player: the server's, on a server.
|
||||
pub(in crate::game) fn remote_walking_speed(&self) -> f32 {
|
||||
walking_speed(self.remote.as_ref().and_then(|r| r.abilities.as_ref()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_client::camera::{FovPlayer, field_of_view_modifier};
|
||||
|
||||
fn fov(walking: f32, movement: f64) -> f32 {
|
||||
let player = FovPlayer {
|
||||
flying: false,
|
||||
walking_speed: walking,
|
||||
movement_speed: movement,
|
||||
using: None,
|
||||
};
|
||||
field_of_view_modifier(&player, true, 1.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_field_of_view_divides_by_the_servers_walking_speed() {
|
||||
// A server that sets the walking speed to 0.2 and the movement speed to 0.2: vanilla's
|
||||
// getFieldOfViewModifier is (0.2 / 0.2 + 1) / 2 = 1. Kept at 0.1 it would be
|
||||
// (0.2 / 0.1 + 1) / 2 = 1.5, a zoomed-out view.
|
||||
let packet = play::PlayerAbilities {
|
||||
flags: 0,
|
||||
flying_speed: 0.05,
|
||||
walking_speed: 0.2,
|
||||
};
|
||||
assert_eq!(fov(walking_speed(Some(&packet)), 0.2), 1.0);
|
||||
// Before any packet: vanilla's default 0.1, a still player at the base speed 0.1.
|
||||
assert_eq!(walking_speed(None), 0.1);
|
||||
assert_eq!(fov(walking_speed(None), 0.1), 1.0);
|
||||
}
|
||||
}
|
||||
@@ -188,6 +188,8 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
// Enderman.DATA_CREEPY (after DATA_CARRY_STATE): the open mouth
|
||||
// and raised head of an angry enderman.
|
||||
(Species::Enderman, 17, Value::Bool(creepy)) => m.extra.flag = *creepy,
|
||||
// Enderman.DATA_STARED_AT: kept in scratch[0] for the stare sound.
|
||||
(Species::Enderman, 18, Value::Bool(stared)) => m.extra.scratch[0] = i32::from(*stared),
|
||||
// Warden.CLIENT_ANGER_LEVEL: the heartbeat's pace.
|
||||
(Species::Warden, 16, Value::Int(anger)) => {
|
||||
if let Some(st) = m.extra.warden.as_mut() {
|
||||
@@ -498,7 +500,9 @@ impl Game<'_> {
|
||||
(14, _, Value::OptPos(bed)) => Some(bed.map_or(0, |b| self.bed_facing(b))),
|
||||
_ => None,
|
||||
});
|
||||
let mut stare_at = None;
|
||||
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, id as u32) {
|
||||
stare_at = stare_sound(m, values);
|
||||
apply(m, values, &r.builtin, r.entity_id);
|
||||
apply_registry_data(m, values, &r.registries, self.world.game_time);
|
||||
if let Some(code) = bed
|
||||
@@ -507,9 +511,43 @@ impl Game<'_> {
|
||||
m.extra.ints[1] = code;
|
||||
}
|
||||
}
|
||||
if let Some(at) = stare_at {
|
||||
// Level.playLocalSound: heard here only, never shared.
|
||||
self.audio
|
||||
.play_unshared("minecraft:entity.enderman.stare", Some(at), 2.5, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Enderman.onSyncedDataUpdated` / `playStareSound`: DATA_CREEPY changing
|
||||
/// while DATA_STARED_AT is set plays the stare sound at the eye, at most once
|
||||
/// per 400 ticks and never when silent. Entries are handled in index order, so
|
||||
/// a stared-at flag sent in the same packet counts only for later entries.
|
||||
/// Returns the sound position and records the tick in scratch[1].
|
||||
fn stare_sound(m: &mut Mob, values: &[(u8, i32, Value)]) -> Option<[f64; 3]> {
|
||||
if m.species() != Species::Enderman {
|
||||
return None;
|
||||
}
|
||||
let mut stared = m.extra.scratch[0] != 0;
|
||||
let mut sound = None;
|
||||
for (index, _, value) in values {
|
||||
match (*index, value) {
|
||||
(18, Value::Bool(b)) => stared = *b,
|
||||
(17, Value::Bool(_)) if stared => {
|
||||
let tick = m.age as i32;
|
||||
if tick >= m.extra.scratch[1].wrapping_add(400) {
|
||||
m.extra.scratch[1] = tick;
|
||||
if !m.silent {
|
||||
sound = Some([m.position.x, m.eye_y(), m.position.z]);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
sound
|
||||
}
|
||||
|
||||
/// A mob as a client constructs it before its data arrives: the synched
|
||||
/// defaults the server leaves out (`getNonDefaultValues`) that differ from
|
||||
/// what `Mob::new` gives, such as a wolf's and a cat's red collar.
|
||||
@@ -950,6 +988,26 @@ mod tests {
|
||||
/// #2920: a server's horse is moved by its rider's client, which jumps
|
||||
/// it by the type's jump strength (`createBaseHorseAttributes`,
|
||||
/// `createBaseChestedHorseAttributes`) until the server syncs its own.
|
||||
/// Enderman.playStareSound: only a DATA_CREEPY change while staring, once
|
||||
/// per 400 ticks, at the eye height.
|
||||
#[test]
|
||||
fn an_endermans_stare_sound_follows_synced_data() {
|
||||
let Some(mut m) = mob("minecraft:enderman") else {
|
||||
return;
|
||||
};
|
||||
let creepy = [(17, s::BOOLEAN, Value::Bool(true))];
|
||||
let stared = [(18, s::BOOLEAN, Value::Bool(true))];
|
||||
assert!(stare_sound(&mut m, &creepy).is_none());
|
||||
apply(&mut m, &stared);
|
||||
m.age = 500;
|
||||
let at = stare_sound(&mut m, &creepy).expect("stare sound");
|
||||
assert!((at[1] - m.eye_y()).abs() < 1e-9);
|
||||
m.age = 899;
|
||||
assert!(stare_sound(&mut m, &creepy).is_none());
|
||||
m.age = 900;
|
||||
assert!(stare_sound(&mut m, &creepy).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_servers_horse_jumps_its_types_strength() {
|
||||
for (kind, strength) in [("horse", 0.7), ("donkey", 0.5), ("mule", 0.5)] {
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
//! The game's tables with what a server's synced data puts over them: its
|
||||
//! data packs' enchantments, and its block and item tags.
|
||||
use super::super::Game;
|
||||
use horizon_client::{enchant::Enchantments, items::Items};
|
||||
use horizon_protocol::{packets::configuration::UpdateTags, registry::Dynamic};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
/// `None` where the server sent nothing over the game's own table.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ServerTables {
|
||||
/// The game's enchantments and the server's that it lacks (a data
|
||||
/// pack's), from their synced data.
|
||||
pub(crate) enchantments: Option<Arc<Enchantments>>,
|
||||
/// The game's items with the block and item tags the server sent
|
||||
/// (`ClientboundUpdateTagsPacket`) in place of its own.
|
||||
pub(crate) items: Option<Arc<Items>>,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The tables `registries` puts over the game's.
|
||||
pub(super) fn server_tables(&self, registries: &Dynamic) -> ServerTables {
|
||||
let entries: Vec<(&str, serde_json::Value)> = registries
|
||||
.registries
|
||||
.get("minecraft:enchantment")
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|(name, data)| {
|
||||
Some((
|
||||
name.as_str(),
|
||||
horizon_client::scoreboard::tag_to_json(data.as_ref()?),
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
let enchantments = self
|
||||
.stations()
|
||||
.enchantments
|
||||
.with_entries(entries.iter().map(|(n, v)| (*n, v)))
|
||||
.map(Arc::new);
|
||||
ServerTables {
|
||||
enchantments,
|
||||
items: self.server_items(registries),
|
||||
}
|
||||
}
|
||||
/// The game's items with the server's block and item tags, by name. The
|
||||
/// window's own server thread plays with the world's tables instead.
|
||||
fn server_items(&self, registries: &Dynamic) -> Option<Arc<Items>> {
|
||||
let r = self.remote.as_ref().filter(|_| self.integrated.is_none())?;
|
||||
let names = |registry: &str| -> Option<HashMap<String, Vec<String>>> {
|
||||
let ids = r.builtin.get(registry)?;
|
||||
let tags = registries
|
||||
.tags
|
||||
.get(registry)?
|
||||
.iter()
|
||||
.map(|(name, members)| {
|
||||
let members = members.iter().filter_map(|&id| ids.name(id));
|
||||
(name.clone(), members.map(str::to_owned).collect())
|
||||
});
|
||||
Some(tags.collect())
|
||||
};
|
||||
let (blocks, items) = (names("minecraft:block"), names("minecraft:item"));
|
||||
if blocks.is_none() && items.is_none() {
|
||||
return None;
|
||||
}
|
||||
Some(Arc::new(self.res.items().with_tags(blocks, items)))
|
||||
}
|
||||
/// `ClientPacketListener.handleUpdateTags` (after a `/reload`): the tags
|
||||
/// it names replace the ones the configuration sent.
|
||||
pub(super) fn remote_update_tags(&mut self, packet: &UpdateTags) {
|
||||
let Some(r) = self.remote.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let mut registries = Dynamic::clone(&r.registries);
|
||||
registries.add_tags(packet);
|
||||
let tables = self.server_tables(®istries);
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.registries = Arc::new(registries);
|
||||
r.tables = tables;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::super::{
|
||||
Audio, Game, Options, Progress, Settings, execution_role::ExecutionRole, load_resources,
|
||||
};
|
||||
use horizon_protocol::{
|
||||
connection::Pipe,
|
||||
packets::configuration::{RegistryTags, TagEntry, UpdateTags},
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
|
||||
fn client() -> Game<'static> {
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
|
||||
);
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!("no menu handoff"),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = ExecutionRole::Client;
|
||||
let res: &'static _ = Box::leak(Box::new(loaded.resources));
|
||||
let mut game = Game::new(
|
||||
res,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let (client, _server) = Pipe::pair();
|
||||
game.connect_local(client, "TagTest").unwrap();
|
||||
game
|
||||
}
|
||||
|
||||
/// A server whose data pack makes stone climbable and sticks planks: the
|
||||
/// client reads its tags, not the game's own (#7843).
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_servers_block_and_item_tags_replace_the_games() {
|
||||
let mut game = client();
|
||||
assert!(!game.items().block_tags["minecraft:climbable"].contains("minecraft:stone"));
|
||||
let builtin = game.remote.as_ref().unwrap().builtin.clone();
|
||||
let id = |registry: &str, name: &str| builtin.get(registry).unwrap().id(name).unwrap();
|
||||
let tag = |registry: &str, name: &str, members: &[&str]| RegistryTags {
|
||||
registry: registry.into(),
|
||||
tags: vec![TagEntry {
|
||||
name: name.into(),
|
||||
ids: members.iter().map(|m| id(registry, m)).collect(),
|
||||
}],
|
||||
};
|
||||
game.remote_update_tags(&UpdateTags {
|
||||
registries: vec![
|
||||
tag(
|
||||
"minecraft:block",
|
||||
"minecraft:climbable",
|
||||
&["minecraft:ladder", "minecraft:stone"],
|
||||
),
|
||||
tag(
|
||||
"minecraft:item",
|
||||
"minecraft:planks",
|
||||
&["minecraft:stick", "minecraft:oak_planks"],
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
let items = game.items();
|
||||
let set = |names: &[&str]| names.iter().map(|n| n.to_string()).collect::<HashSet<_>>();
|
||||
assert_eq!(
|
||||
items.block_tags["minecraft:climbable"],
|
||||
set(&["minecraft:ladder", "minecraft:stone"])
|
||||
);
|
||||
// The server's tags replace the game's: a tag it didn't send is gone.
|
||||
assert!(!items.block_tags.contains_key("minecraft:fences"));
|
||||
assert_eq!(
|
||||
items.item_tag_order["minecraft:planks"],
|
||||
["minecraft:stick", "minecraft:oak_planks"]
|
||||
);
|
||||
assert!(items.item_tags["minecraft:planks"].contains("minecraft:stick"));
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,11 @@ use horizon_client::{
|
||||
screen_effects::{ScreenInputs, ScreenQuad, lightmap_brightness, nausea_has_effect},
|
||||
};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use horizon_world::{heightmap::HeightmapType, worldgen::terrain::TerrainBlockStates};
|
||||
use horizon_world::{
|
||||
dimension_type::{CardinalLight, Skybox},
|
||||
heightmap::HeightmapType,
|
||||
worldgen::terrain::TerrainBlockStates,
|
||||
};
|
||||
|
||||
pub(super) fn player_tracking_body(player: &PlayerMotion) -> TrackingBody {
|
||||
TrackingBody {
|
||||
@@ -96,6 +100,18 @@ fn rains_at(world: &World, biomes: &Biomes, x: i32, y: i32, z: i32) -> bool {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `GameRenderer`'s cardinal light: the level's type uses the Nether's diffuse lights.
|
||||
pub(super) fn nether_lighting(&self) -> bool {
|
||||
self.dimension_type().cardinal_light == CardinalLight::Nether
|
||||
}
|
||||
/// `SkyRenderer`'s overworld skybox: sun, moon, stars and clouds.
|
||||
pub(super) fn overworld_skybox(&self) -> bool {
|
||||
self.dimension_type().skybox == Skybox::Overworld
|
||||
}
|
||||
/// `SkyRenderer`'s End skybox and its flashes.
|
||||
pub(super) fn end_skybox(&self) -> bool {
|
||||
self.dimension_type().skybox == Skybox::End
|
||||
}
|
||||
pub(super) fn forward(&self) -> [f64; 3] {
|
||||
let (yaw, pitch) = (
|
||||
f64::from(self.yaw).to_radians(),
|
||||
@@ -151,7 +167,7 @@ impl Game<'_> {
|
||||
forward: forward.map(|c| c as f32),
|
||||
rain,
|
||||
thunder,
|
||||
skybox: self.dim == Dim::Overworld,
|
||||
skybox: self.overworld_skybox(),
|
||||
blindness: self
|
||||
.potions
|
||||
.effects
|
||||
@@ -267,7 +283,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
f64::from(self.pack.polytone_colors.env.horizon_height)
|
||||
};
|
||||
self.dim == Dim::Overworld
|
||||
self.overworld_skybox()
|
||||
&& camera.y < horizon
|
||||
&& fog_type(
|
||||
&self.world,
|
||||
@@ -522,10 +538,10 @@ impl Game<'_> {
|
||||
let height_no_leaves =
|
||||
|p: [i32; 3]| motion_blocking_leaves(world, facts, p[0], p[2], i32::MAX, false);
|
||||
// The day timeline: firefly bush sounds from 12600 to 23400.
|
||||
let firefly_bush_sounds = self.dim == Dim::Overworld
|
||||
let firefly_bush_sounds = self.dimension_type().has_timeline("minecraft:day")
|
||||
&& (12600..23401).contains(&self.world.day_time.rem_euclid(24000));
|
||||
// EnvironmentAttributes.CREAKING_ACTIVE: the same overworld night.
|
||||
let creaking_active = self.dim == Dim::Overworld
|
||||
let creaking_active = self.dimension_type().has_timeline("minecraft:day")
|
||||
&& (12600..23401).contains(&self.world.day_time.rem_euclid(24000));
|
||||
let tables = &self.pack.context.tables;
|
||||
let model_offset = |state, x, z| tables.offset(state, x, z);
|
||||
@@ -552,7 +568,7 @@ impl Game<'_> {
|
||||
model_offset: &model_offset,
|
||||
gateway_particles: &gateway_particles,
|
||||
thundering,
|
||||
ultrawarm: self.dim == Dim::Nether,
|
||||
ultrawarm: self.dimension_type().water_evaporates,
|
||||
};
|
||||
if let Some(e) = eye {
|
||||
let block = [
|
||||
|
||||
@@ -26,6 +26,14 @@ impl Resources {
|
||||
pub(super) fn environment(&self) -> &Environment {
|
||||
&self.core.environment
|
||||
}
|
||||
/// The environment of a dimension type id; a type with no table of its own gets the overworld's.
|
||||
pub(super) fn environment_of(&self, dimension_type: &str) -> &Environment {
|
||||
match dimension_type {
|
||||
"minecraft:the_nether" => self.nether_environment(),
|
||||
"minecraft:the_end" => self.end_environment(),
|
||||
_ => self.environment(),
|
||||
}
|
||||
}
|
||||
pub(super) fn mob_types(&self) -> &HashMap<String, Arc<MobType>> {
|
||||
&self.rest().mob_types
|
||||
}
|
||||
|
||||
@@ -280,9 +280,7 @@ impl Game<'_> {
|
||||
fn pause_screen_additions(&self) -> Option<(Text, Tag)> {
|
||||
let registries = &self.remote.as_ref()?.registries;
|
||||
let dialogs = registries.registries.get("minecraft:dialog")?;
|
||||
let tag = registries
|
||||
.dialog_tags
|
||||
.get("minecraft:pause_screen_additions")?;
|
||||
let tag = registries.tag("minecraft:dialog", "minecraft:pause_screen_additions")?;
|
||||
// addCustomDialogButtons' label: computeExternalTitle falls back to the title.
|
||||
let button = |dialog_tag: Tag| -> Option<(Text, Tag)> {
|
||||
let dialog = Dialog::parse(&dialog_tag)?;
|
||||
|
||||
@@ -110,6 +110,33 @@ fn a_torch_that_stays_connects_the_wire_in_a_line_and_powers_it() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `handleSetCommandBlock` edits the block entity through
|
||||
/// `BaseCommandBlock.setCommand`/`onUpdated`: the command block is marked
|
||||
/// changed and its update tag reaches the clients. Horizon applied the edit
|
||||
/// without marking the chunk.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn editing_a_command_block_marks_its_chunk() {
|
||||
let mut game = game();
|
||||
run(&mut game, &["setblock 5 100 2 command_block[facing=up]"]);
|
||||
game.world.modified.clear();
|
||||
if let Some(log) = game.world.block_log.as_mut() {
|
||||
log.clear();
|
||||
}
|
||||
game.apply_command_block(&super::command_blocks::CommandBlockEdit {
|
||||
pos: (5, 100, 2),
|
||||
command: "say hi".into(),
|
||||
track_output: false,
|
||||
mode: horizon_client::command_block::Mode::Redstone,
|
||||
conditional: false,
|
||||
auto: false,
|
||||
});
|
||||
assert!(
|
||||
game.world.modified.contains(&(0, 0)),
|
||||
"the edited command block's chunk is dirty"
|
||||
);
|
||||
}
|
||||
|
||||
/// `TestBlock.ownSignal` reads the block state's `mode`, not the block
|
||||
/// entity's saved copy: a `start` state with a powered block entity answers
|
||||
/// 15 even when `/data merge` left a different mode in the entity.
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! `SmithingScreen`'s empty slot icons: the base and addition lists of a
|
||||
//! template (`SmithingTemplateItem`'s create*IconList helpers).
|
||||
|
||||
/// The base and additions slot icons of the template in the smithing table:
|
||||
/// `SmithingTemplateItem.createNetheriteUpgradeTemplate` (the 11 tools and
|
||||
/// armour of `SmithingTemplateItem.createNetheriteUpgradeIconList`, then the
|
||||
/// ingot) and `SmithingTemplateItem.createArmorTrimTemplate` (the four armour
|
||||
/// pieces, then the seven trim materials), as `getBaseSlotEmptyIcons` and
|
||||
/// `getAdditionalSlotEmptyIcons` give them.
|
||||
pub(super) fn smithing_slot_icons(
|
||||
template: Option<&str>,
|
||||
) -> ([&'static str; 11], usize, [&'static str; 7], usize) {
|
||||
let none = ([""; 11], 0, [""; 7], 0);
|
||||
match template.map(|t| t.trim_start_matches("minecraft:")) {
|
||||
Some("netherite_upgrade_smithing_template") => (
|
||||
[
|
||||
"helmet",
|
||||
"sword",
|
||||
"chestplate",
|
||||
"pickaxe",
|
||||
"leggings",
|
||||
"axe",
|
||||
"boots",
|
||||
"hoe",
|
||||
"shovel",
|
||||
"nautilus_armor",
|
||||
"spear",
|
||||
],
|
||||
11,
|
||||
["ingot", "", "", "", "", "", ""],
|
||||
1,
|
||||
),
|
||||
Some(t) if t.ends_with("_armor_trim_smithing_template") => (
|
||||
[
|
||||
"helmet",
|
||||
"chestplate",
|
||||
"leggings",
|
||||
"boots",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
],
|
||||
4,
|
||||
[
|
||||
"ingot",
|
||||
"redstone_dust",
|
||||
"lapis_lazuli",
|
||||
"quartz",
|
||||
"diamond",
|
||||
"emerald",
|
||||
"amethyst_shard",
|
||||
],
|
||||
7,
|
||||
),
|
||||
_ => none,
|
||||
}
|
||||
}
|
||||
@@ -161,8 +161,8 @@ impl Game<'_> {
|
||||
let disable_blocking = if self.using.is_some() {
|
||||
0.0
|
||||
} else {
|
||||
self.attack_item_info()
|
||||
.map_or(0.0, |i| i.disable_blocking_seconds)
|
||||
self.held()
|
||||
.map_or(0.0, |stack| self.items().weapon_disable_blocking(stack))
|
||||
};
|
||||
Some(PlayerStab {
|
||||
victim,
|
||||
|
||||
@@ -55,7 +55,13 @@ impl Game<'_> {
|
||||
/// face it hit, the cell behind that (the block it hit) and the four
|
||||
/// horizontal neighbours of the cell in front. `hit` is the block and
|
||||
/// `face` the face it was hit on.
|
||||
pub(super) fn potion_hit_block(&mut self, stack: &ItemStack, hit: [i32; 3], face: Direction) {
|
||||
pub(super) fn potion_hit_block(
|
||||
&mut self,
|
||||
stack: &ItemStack,
|
||||
hit: [i32; 3],
|
||||
face: Direction,
|
||||
owner: Option<Who>,
|
||||
) {
|
||||
// `PotionContents.is(PotionTags.DOUSES_FIRE)` on the stack's `potion_contents`.
|
||||
if !self.potion_in_tag(stack, "minecraft:douses_fire") {
|
||||
return;
|
||||
@@ -65,8 +71,8 @@ impl Game<'_> {
|
||||
[pos[0] + n[0], pos[1] + n[1], pos[2] + n[2]]
|
||||
};
|
||||
let effect = relative(hit, face);
|
||||
self.douse_fire(effect);
|
||||
self.douse_fire(relative(effect, face.opposite()));
|
||||
self.douse_fire(effect, owner);
|
||||
self.douse_fire(relative(effect, face.opposite()), owner);
|
||||
// Direction.Plane.HORIZONTAL.
|
||||
for direction in [
|
||||
Direction::North,
|
||||
@@ -74,14 +80,14 @@ impl Game<'_> {
|
||||
Direction::South,
|
||||
Direction::West,
|
||||
] {
|
||||
self.douse_fire(relative(effect, direction));
|
||||
self.douse_fire(relative(effect, direction), owner);
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractThrownPotion.douseFire`: a fire block is destroyed without
|
||||
/// drops, a lit candle or candle cake goes out, and a lit campfire goes
|
||||
/// out after level event 1009 and its block-change game event.
|
||||
fn douse_fire(&mut self, pos: [i32; 3]) {
|
||||
fn douse_fire(&mut self, pos: [i32; 3], owner: Option<Who>) {
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let name = self.res.facts().get(state).name.clone();
|
||||
@@ -97,9 +103,9 @@ impl Game<'_> {
|
||||
&& let Some(mut lit) = self.lit_campfire(state, &name)
|
||||
{
|
||||
self.level_event_at(1009, pos, 0);
|
||||
// CampfireBlock.douse: the block-change game event, whose source
|
||||
// is the potion's owner (not tracked here).
|
||||
self.post_block_game_event("minecraft:block_change", pos, None, None);
|
||||
// `CampfireBlock.douse(entity, ...)`: the block-change game event,
|
||||
// sourced from the potion's owner as vanilla passes `getOwner()`.
|
||||
self.post_block_game_event("minecraft:block_change", pos, owner, None);
|
||||
lit.properties.insert("lit".into(), "false".into());
|
||||
if let Ok(unlit) = self.pack.assets.registry.resolve(&lit) {
|
||||
let _ = self.level_set(pos, unlit);
|
||||
@@ -257,3 +263,54 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game::native_machines::tests::server_game;
|
||||
|
||||
/// `AbstractThrownPotion.douseFire` -> `CampfireBlock.douse(entity, ...)`:
|
||||
/// the campfire's block change is sourced from the potion's owner.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_doused_campfire_posts_block_change_from_the_potion_owner() {
|
||||
let mut game = server_game();
|
||||
let pos = [2, 4, 3];
|
||||
let campfire = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:campfire")
|
||||
.unwrap();
|
||||
game.world.set(pos[0], pos[1], pos[2], campfire).unwrap();
|
||||
// The lit campfire the douse looks for.
|
||||
let lit = game.lit_campfire(campfire, "minecraft:campfire");
|
||||
assert!(lit.is_some(), "the campfire starts lit");
|
||||
super::super::sculk::TEST_POSTED_EVENTS.with(|e| *e.borrow_mut() = Some(Vec::new()));
|
||||
|
||||
game.douse_fire(pos, Some(Who::Mob(7)));
|
||||
|
||||
let after = game.world.block(pos[0], pos[1], pos[2]);
|
||||
assert!(
|
||||
!game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(after)
|
||||
.and_then(|s| s.properties.get("lit"))
|
||||
.is_some_and(|v| v == "true"),
|
||||
"the campfire is out"
|
||||
);
|
||||
super::super::sculk::TEST_POSTED_EVENTS.with(|e| {
|
||||
assert!(
|
||||
e.borrow().as_ref().unwrap().contains(&(
|
||||
"minecraft:block_change",
|
||||
[2.5, 4.5, 3.5],
|
||||
Some(Who::Mob(7))
|
||||
)),
|
||||
"{:?}",
|
||||
e.borrow()
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@
|
||||
//! `BrewingStandScreen`); item rules live in `horizon_client::stations`.
|
||||
|
||||
mod anvil;
|
||||
#[path = "smithing_icons.rs"]
|
||||
mod smithing_icons;
|
||||
use smithing_icons::smithing_slot_icons;
|
||||
/// Invalid measurements must not reach tooltip positioning or reserve empty space.
|
||||
fn tooltip_component_size(size: Option<[f32; 2]>) -> Option<[f32; 2]> {
|
||||
size.filter(|size| size.iter().all(|v| v.is_finite() && *v > 0.0))
|
||||
@@ -272,64 +275,6 @@ const EMPTY_TEMPLATE_ICONS: [&str; 2] = [
|
||||
"smithing_template_armor_trim",
|
||||
"smithing_template_netherite_upgrade",
|
||||
];
|
||||
/// The base and additions slot icons of the template in the smithing table:
|
||||
/// `SmithingTemplateItem.createNetheriteUpgradeTemplate` (the 11 tools and
|
||||
/// armour of `SmithingTemplateItem.createNetheriteUpgradeIconList`, then the
|
||||
/// ingot) and `SmithingTemplateItem.createArmorTrimTemplate` (the four armour
|
||||
/// pieces, then the seven trim materials), as `getBaseSlotEmptyIcons` and
|
||||
/// `getAdditionalSlotEmptyIcons` give them.
|
||||
fn smithing_slot_icons(
|
||||
template: Option<&str>,
|
||||
) -> ([&'static str; 11], usize, [&'static str; 7], usize) {
|
||||
let none = ([""; 11], 0, [""; 7], 0);
|
||||
match template.map(|t| t.trim_start_matches("minecraft:")) {
|
||||
Some("netherite_upgrade_smithing_template") => (
|
||||
[
|
||||
"helmet",
|
||||
"sword",
|
||||
"chestplate",
|
||||
"pickaxe",
|
||||
"leggings",
|
||||
"axe",
|
||||
"boots",
|
||||
"hoe",
|
||||
"shovel",
|
||||
"nautilus_armor",
|
||||
"spear",
|
||||
],
|
||||
11,
|
||||
["ingot", "", "", "", "", "", ""],
|
||||
1,
|
||||
),
|
||||
Some(t) if t.ends_with("_armor_trim_smithing_template") => (
|
||||
[
|
||||
"helmet",
|
||||
"chestplate",
|
||||
"leggings",
|
||||
"boots",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
],
|
||||
4,
|
||||
[
|
||||
"ingot",
|
||||
"redstone_dust",
|
||||
"lapis_lazuli",
|
||||
"quartz",
|
||||
"diamond",
|
||||
"emerald",
|
||||
"amethyst_shard",
|
||||
],
|
||||
7,
|
||||
),
|
||||
_ => none,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct Station {
|
||||
pub(super) kind: Kind,
|
||||
@@ -737,7 +682,7 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let creative = self.creative;
|
||||
let data = self.stations();
|
||||
let (enchantments, data) = (std::sync::Arc::clone(self.enchantments()), self.stations());
|
||||
let items = self.items();
|
||||
match s.kind {
|
||||
// A server's table: its data slots carry the offers, and its
|
||||
@@ -745,7 +690,7 @@ impl Game<'_> {
|
||||
Kind::Enchanting if self.remote.is_some() => {
|
||||
let enchantable = s.inputs[0]
|
||||
.as_ref()
|
||||
.is_some_and(|i| stations::is_enchantable(&data.enchantments, i));
|
||||
.is_some_and(|i| stations::is_enchantable(&enchantments, i));
|
||||
if !enchantable {
|
||||
s.offers = Default::default();
|
||||
}
|
||||
@@ -753,7 +698,7 @@ impl Game<'_> {
|
||||
Kind::Enchanting => {
|
||||
s.bookcases = self.count_bookshelves(s.pos);
|
||||
s.offers = stations::enchant_offers(
|
||||
&data.enchantments,
|
||||
&enchantments,
|
||||
items,
|
||||
self.level.xp_seed,
|
||||
s.bookcases,
|
||||
@@ -767,7 +712,7 @@ impl Game<'_> {
|
||||
.map(|i| stations::hover_name(&self.pack.names, i))
|
||||
.unwrap_or_default();
|
||||
s.anvil = stations::anvil_result(
|
||||
&data.enchantments,
|
||||
&enchantments,
|
||||
items,
|
||||
s.inputs[0].as_ref(),
|
||||
s.inputs[1].as_ref(),
|
||||
@@ -786,7 +731,7 @@ impl Game<'_> {
|
||||
}
|
||||
Kind::Grindstone => {
|
||||
s.result = stations::grindstone_result(
|
||||
&data.enchantments,
|
||||
&enchantments,
|
||||
items,
|
||||
s.inputs[0].as_ref(),
|
||||
s.inputs[1].as_ref(),
|
||||
@@ -794,7 +739,7 @@ impl Game<'_> {
|
||||
}
|
||||
Kind::Smithing => {
|
||||
s.result = data.recipes.smithing_result(
|
||||
&data.enchantments,
|
||||
&enchantments,
|
||||
s.inputs[0].as_ref(),
|
||||
s.inputs[1].as_ref(),
|
||||
s.inputs[2].as_ref(),
|
||||
@@ -2773,10 +2718,31 @@ impl Game<'_> {
|
||||
}
|
||||
lines.splice(1..1, after_name);
|
||||
let lines = Self::resolved(&lines);
|
||||
// `getEmptyBundleDescriptionTextHeight`: the empty bundle's description
|
||||
// wrapped at the component's 96 pixel width, 9 pixels a line.
|
||||
let description_lines = self
|
||||
.gui
|
||||
.atlas
|
||||
.font
|
||||
.split_lines(
|
||||
&horizon_client::text::Text::translate(
|
||||
"item.minecraft.bundle.empty.description",
|
||||
&[],
|
||||
),
|
||||
96.0,
|
||||
)
|
||||
.len()
|
||||
.max(1);
|
||||
// The component's height does not depend on where it is drawn, so a
|
||||
// probe at the origin gives the box its extra height.
|
||||
let probe =
|
||||
horizon_client::item_tooltip::bundle_tooltip(self.items(), stack, 0.0, 0.0, 96.0);
|
||||
let probe = horizon_client::item_tooltip::bundle_tooltip(
|
||||
self.items(),
|
||||
stack,
|
||||
0.0,
|
||||
0.0,
|
||||
96.0,
|
||||
description_lines,
|
||||
);
|
||||
let bundle_extra = probe.as_ref().map_or(0.0, |l| l.height + 2.0);
|
||||
let mut component = None;
|
||||
self.native_tooltip_component(stack, &mut component);
|
||||
@@ -2801,6 +2767,7 @@ impl Game<'_> {
|
||||
x,
|
||||
y + text_h + 2.0,
|
||||
w,
|
||||
description_lines,
|
||||
) {
|
||||
self.draw_bundle_tooltip(batch, &layout);
|
||||
self.draw_bundle_selected_name(batch, &layout, x, y + text_h + 2.0, w);
|
||||
|
||||
@@ -74,3 +74,8 @@ mod tests {
|
||||
assert_eq!(station.rename.value, "Diamond Sword");
|
||||
}
|
||||
}
|
||||
|
||||
// The previews in a server session read the server's enchantments (#8688).
|
||||
#[cfg(test)]
|
||||
#[path = "remote_anvil_tests.rs"]
|
||||
mod remote_anvil_tests;
|
||||
@@ -0,0 +1,84 @@
|
||||
use super::super::super::{
|
||||
Audio, Game, Options, Progress, Settings, execution_role::ExecutionRole, load_resources,
|
||||
};
|
||||
use super::super::{Kind, Station};
|
||||
use horizon_client::items::ItemStack;
|
||||
use horizon_protocol::connection::Pipe;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A client on a server whose data pack adds `fixture:drill`, which the
|
||||
/// game's own table doesn't have: the registry sync sent its definition.
|
||||
fn client_on_a_server_with_a_custom_enchantment() -> Game<'static> {
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
|
||||
);
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!("no menu handoff"),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = ExecutionRole::Client;
|
||||
let res: &'static _ = Box::leak(Box::new(loaded.resources));
|
||||
let mut game = Game::new(
|
||||
res,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let (client, _server) = Pipe::pair();
|
||||
game.connect_local(client, "AnvilTest").unwrap();
|
||||
let definition = serde_json::json!({
|
||||
"description": "Drill", "supported_items": "minecraft:diamond_pickaxe",
|
||||
"weight": 1, "max_level": 3, "min_cost": {"base": 1, "per_level_above_first": 0},
|
||||
"max_cost": {"base": 1, "per_level_above_first": 0}, "anvil_cost": 1,
|
||||
"slots": ["mainhand"], "effects": {}
|
||||
});
|
||||
let synced = game
|
||||
.stations()
|
||||
.enchantments
|
||||
.with_entries([("fixture:drill", &definition)])
|
||||
.expect("the game's own table lacks fixture:drill");
|
||||
game.remote.as_mut().unwrap().enchantments = Some(Arc::new(synced));
|
||||
game
|
||||
}
|
||||
|
||||
/// `AnvilMenu.createResult` on the client reads the synced registry, so a
|
||||
/// book of a data pack's enchantment on a pickaxe shows its result, as the
|
||||
/// server computes it, instead of the red cross (#8688).
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_servers_custom_enchantment_book_gives_an_anvil_result() {
|
||||
let mut game = client_on_a_server_with_a_custom_enchantment();
|
||||
let mut book = ItemStack::new("minecraft:enchanted_book", 1);
|
||||
book.set_crafting_enchantments(vec![("fixture:drill".into(), 2)]);
|
||||
let mut station = Station::new(Kind::Anvil, [0, 64, 0]);
|
||||
station.inputs[0] = Some(ItemStack::new("minecraft:diamond_pickaxe", 1));
|
||||
station.inputs[1] = Some(book);
|
||||
game.station = Some(station);
|
||||
|
||||
game.station_changed();
|
||||
|
||||
let station = game.station.as_ref().unwrap();
|
||||
let result = station.result.as_ref().expect("an anvil result");
|
||||
assert_eq!(&*result.item, "minecraft:diamond_pickaxe");
|
||||
assert_eq!(
|
||||
result.enchantments(),
|
||||
&[(Arc::<str>::from("fixture:drill"), 2)]
|
||||
);
|
||||
assert!(station.anvil.cost > 0);
|
||||
}
|
||||
@@ -621,7 +621,7 @@ impl Game<'_> {
|
||||
BlockPos::new(bounds.max[0], bounds.max[1], bounds.max[2]),
|
||||
);
|
||||
self.block_ticks.blocks.clear_area(area);
|
||||
self.block_ticks.block_events.retain(|e| {
|
||||
self.block_ticks.retain_block_events(|e| {
|
||||
!(bounds.min[0]..=bounds.max[0]).contains(&e.pos.x)
|
||||
|| !(bounds.min[1]..=bounds.max[1]).contains(&e.pos.y)
|
||||
|| !(bounds.min[2]..=bounds.max[2]).contains(&e.pos.z)
|
||||
|
||||
@@ -577,17 +577,11 @@ impl Game<'_> {
|
||||
let (v, on_ground) = self.player.as_ref().map_or((Vec3::ZERO, true), |p| {
|
||||
(self.known_player_movement(), p.ground.on_ground)
|
||||
});
|
||||
projectile.shoot_from_rotation(
|
||||
&mut self.world.random,
|
||||
self.pitch,
|
||||
self.yaw,
|
||||
0.0,
|
||||
2.5,
|
||||
1.0,
|
||||
v,
|
||||
on_ground,
|
||||
);
|
||||
projectile.shoot_from_rotation(self.pitch, self.yaw, 0.0, 2.5, 1.0, v, on_ground);
|
||||
let id = self.take_entity_id();
|
||||
if let Some(uuid) = projectile.uuid {
|
||||
self.persist.uuids.insert(id, uuid);
|
||||
}
|
||||
let owner_uuid = self.acting_player_uuid();
|
||||
self.tridents.push(ThrownTrident {
|
||||
join: Default::default(),
|
||||
|
||||
@@ -628,7 +628,6 @@ impl Game<'_> {
|
||||
(self.known_player_movement(), pl.ground.on_ground)
|
||||
});
|
||||
arrow.shoot_from_rotation(
|
||||
&mut self.world.random,
|
||||
self.pitch,
|
||||
self.yaw,
|
||||
0.0,
|
||||
|
||||
@@ -101,16 +101,7 @@ impl Game<'_> {
|
||||
let velocity = self.known_player_movement();
|
||||
let start = Vec3::new(pos.x, eye[1], pos.z);
|
||||
let mut proj = Projectile::new(ProjectileKind::WindCharge, start, Some(Who::Player));
|
||||
proj.shoot_from_rotation(
|
||||
&mut self.world.random,
|
||||
self.pitch,
|
||||
self.yaw,
|
||||
0.0,
|
||||
1.5,
|
||||
1.0,
|
||||
velocity,
|
||||
on_ground,
|
||||
);
|
||||
proj.shoot_from_rotation(self.pitch, self.yaw, 0.0, 1.5, 1.0, velocity, on_ground);
|
||||
self.add_projectile(proj);
|
||||
let pitch = 0.4 / (self.world.random.next_float() * 0.4 + 0.8);
|
||||
self.audio
|
||||
|
||||
@@ -159,11 +159,16 @@ impl Game<'_> {
|
||||
// ---- the world's data packs' content tables ---------------------------
|
||||
|
||||
/// The content tables the world plays with: its data packs' over the
|
||||
/// game's own when it has packs, `Resources.rest`'s own otherwise.
|
||||
/// game's own when it has packs, a server's tags over the game's own on a
|
||||
/// server, `Resources.rest`'s own otherwise.
|
||||
pub(super) fn items(&self) -> &Items {
|
||||
match &self.world_tables {
|
||||
Some(t) => &t.items,
|
||||
None => self.res.items(),
|
||||
None => self
|
||||
.remote
|
||||
.as_ref()
|
||||
.and_then(|r| r.tables.items.as_deref())
|
||||
.unwrap_or_else(|| self.res.items()),
|
||||
}
|
||||
}
|
||||
pub(super) fn mob_suffocation(
|
||||
@@ -188,7 +193,11 @@ impl Game<'_> {
|
||||
/// The enchantments item names and tooltips read: a server's, with the
|
||||
/// entries its data packs add, else the world's.
|
||||
pub(super) fn enchantments(&self) -> &Arc<horizon_client::enchant::Enchantments> {
|
||||
match self.remote.as_ref().and_then(|r| r.enchantments.as_ref()) {
|
||||
match self
|
||||
.remote
|
||||
.as_ref()
|
||||
.and_then(|r| r.tables.enchantments.as_ref())
|
||||
{
|
||||
Some(e) => e,
|
||||
None => &self.stations().enchantments,
|
||||
}
|
||||
|
||||
@@ -889,6 +889,11 @@ pub(super) fn make_streaming(
|
||||
})?;
|
||||
inner.add_mod_signs(res.facts());
|
||||
let inner = Arc::new(inner);
|
||||
// The first mob a fresh chunk generates needs the mob data: read it now,
|
||||
// off the server's tick (#13857). The End's chunks generate none.
|
||||
if dim != Dim::End {
|
||||
inner.load_structure_mobs_behind();
|
||||
}
|
||||
if dim == Dim::Overworld && structures {
|
||||
inner.search_rings_behind();
|
||||
}
|
||||
@@ -940,8 +945,12 @@ pub(super) fn make_streaming(
|
||||
.horizon
|
||||
.leaf_culling_effective(&settings.resource_packs);
|
||||
streaming.biome_zoom = BiomeZoom::new(obfuscate_seed(seed));
|
||||
streaming.sky_light = dim != Dim::Nether;
|
||||
streaming.nether_shading = dim == Dim::Nether;
|
||||
let facts = horizon_world::dimension_type::DimensionFacts::of_builtin_level(
|
||||
horizon_core::LevelKey::builtin_or_overworld(dim.id()),
|
||||
);
|
||||
streaming.sky_light = facts.height.has_skylight;
|
||||
streaming.nether_shading =
|
||||
facts.cardinal_light == horizon_world::dimension_type::CardinalLight::Nether;
|
||||
// A server draws nothing: neither the integrated server nor the
|
||||
// dedicated one meshes sections.
|
||||
streaming.mesh = options.execution_role.is_client();
|
||||
|
||||
Loaded 100 of 216 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user