Compare commits

..
Author SHA1 Message Date
rdbtCVSandClaude Sonnet 5.5 99bc69d862 Merge split/friends-share-view; keep game.rs from growing
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 03:50:11 +00:00
rdbtCVSandGPT 6.1 Sol 476dabcb08 Merge main into split/friends-share-view
Preserve the ShareView export alongside GetTheGame and combine the Friends documentation updates.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 20:28:47 +00:00
Horizon agent 911e23fb55 Merge remote-tracking branch 'origin/split/friends-share-view' into merge/friends-page
# Conflicts:
#	crates/horizon-platform/src/lib.rs
#	docs/forking.md
2026-11-09 23:57:38 +01:00
Horizon agent ffaaa63836 Merge remote-tracking branch 'origin/main' into tmp/share-view-merge
# Conflicts:
#	crates/horizon-client/src/entry.rs
#	crates/horizon-hooks/src/lib.rs
#	crates/horizon-platform/src/lib.rs
2026-11-09 23:45:52 +01:00
Horizon agentandClaude Opus 5.5 ee60188af7 Play with Friends' screen becomes a page at Place::PauseFriends (spec #14167 step 2a)
The Friends screen is now Play with Friends' page, registered at a new
Place::PauseFriends (vanilla's pause-menu Friends icon) and drawn by the
engine as its own screen (game/page_screen.rs) with today's layout: grey and
white text, the code broken between its groups, Copy and Stop side by side,
Troubleshooting beside Done. Its rows are read when drawn (the code is polled
then, as before); a press runs on the next Hooks::share tick, which has the
game's view. horizon_hooks::tr gives crates the player's language.
game/friends/screen.rs goes; Screen::Friends stays until step 2b.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:06:46 +01:00
Horizon agent f518ef4c07 Merge branch 'split/pages-plain-rows' into split/friends-page 2026-11-09 20:56:42 +01:00
Horizon agentandClaude Opus 5.5 2719b22258 Friends reads its code while the screen is drawn, as before
The invite code and the host's events are polled while the Friends screen
is drawn or horizon-ctl friends status asks, as on main: Hooks::share stays
dispatched for the screen's page (#14167 step 2a), which is its first user.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:50:04 +01:00
Horizon agentandClaude Opus 5.5 3da374ef18 Play with Friends hosts through Hooks::share and Hooks::lan_closed (spec #14167 step 1)
The engine shows the window's world to crates that share it past the LAN
through horizon_hooks::share::ShareView, ticks them next to social::tick()
(also while paused) and tells them when the world closes to LAN. Only the
window's own player dispatches: never the serving Game or a splitscreen guest.
game/friends.rs keeps its logic but goes through the view, its state in its
own static instead of a Game field, ready to move to horizon-friends.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:40:22 +01:00
230 changed files with 2670 additions and 9431 deletions

No files matched your search

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