Merge main into #6822

Main's #6821 added its own test beside this PR's; both are kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-02 08:45:39 +01:00
commit e7226b798f
395 files changed
+24209 -1550

No files matched your search

+1 -1
View File
@@ -174,7 +174,7 @@ jobs:
run: |
if ! xcrun metal --version > /dev/null 2>&1; then
echo "::notice::No Metal toolchain on this runner: GPUI compiles its shaders at run time"
echo "LAUNCHER_FEATURES=gpui/runtime_shaders" >> "$GITHUB_ENV"
echo "LAUNCHER_FEATURES=gpui_platform/runtime_shaders" >> "$GITHUB_ENV"
fi
# Every test target (the library, the binary, tests/), on each system the launcher ships for.
- run: cargo test --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
+4
View File
@@ -103,6 +103,10 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
+ a Rust probe: `crates/<crate>/examples/<name>_{probe,fixture}.rs` (horizon-world 175, horizon-client 104),
or the game binary with a fixture env var (`game/dispense_fixture.rs`, `game/sculk_fixture.rs`,
`game/command_harness.rs`, `game/net_harness.rs`). Copy a nearby harness for a new one.
- Developing a native mod? Run `horizon-launcher settings native-profile dev` once. `dev` builds the game crate
unoptimized: a mod edit rebuilds about 6× faster than with `performance`, while game logic runs up to about twice as
slow. Use it while developing, not for playing; `performance` stays the default for players and releases
([docs/native-mods.md](docs/native-mods.md)).
- Probes build into `.local/parity-target/`; harnesses find binaries via `lab.cargo_target()`, never `target/`.
- Harness selection uses Cargo dep-info and reachable-code narrowing (`tools/parity_reach.py`).
`horizon-client-lib` builds the control tool and client probes without gameplay; their dep-info
Generated
+2
View File
@@ -1461,6 +1461,7 @@ dependencies = [
"horizon-threads",
"horizon-world",
"log",
"lz4_flex",
"miniz_oxide 0.8.9",
"naga",
"png",
@@ -1733,6 +1734,7 @@ dependencies = [
"flate2",
"horizon-core",
"libm",
"log",
"lz4_flex",
"postcard",
"serde",
+92 -18
View File
@@ -239,8 +239,19 @@ impl Action {
pub struct Mute {
pub uuid: String,
pub name: String,
/// Epoch milliseconds.
pub until: i64,
/// Epoch milliseconds; `None`: until `/unmute`.
pub until: Option<i64>,
/// The operator who muted them (`/mute`, `/tempmute`); `None`: the filter.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
impl Mute {
pub fn active(&self, now: i64) -> bool {
self.until.is_none_or(|until| until > now)
}
}
#[derive(Default, Serialize, Deserialize)]
@@ -312,12 +323,38 @@ impl Guard {
)
}
/// When the player's mute ends, if they are muted at `now`.
pub fn muted_until(&self, uuid: u128, now: i64) -> Option<i64> {
self.mutes
.get(&uuid)
.map(|m| m.until)
.filter(|&until| until > now)
/// The player's mute, if they are muted at `now`.
pub fn muted(&self, uuid: u128, now: i64) -> Option<&Mute> {
self.mutes.get(&uuid).filter(|m| m.active(now))
}
/// The mutes in force at `now`, by name.
pub fn mutes(&self, now: i64) -> Vec<&Mute> {
let mut mutes: Vec<&Mute> = self.mutes.values().filter(|m| m.active(now)).collect();
mutes.sort_by(|a, b| a.name.cmp(&b.name));
mutes
}
/// An operator's `/mute` (`until` `None`) or `/tempmute`: replaces the player's mute.
pub fn mute(
&mut self,
uuid: u128,
name: &str,
until: Option<i64>,
by: &str,
reason: Option<String>,
) {
self.mutes.insert(
uuid,
Mute {
uuid: uuid_string(uuid),
name: name.to_owned(),
until,
by: Some(by.to_owned()),
reason,
},
);
self.save();
}
/// A hit by the player at `now`: the hit's number in the window, and what it leads to.
@@ -343,15 +380,24 @@ impl Guard {
let until = now + i64::from(c.mute_minutes) * MINUTE;
// The window starts again when the mute ends.
s.counts_until = until + window;
self.mutes.insert(
uuid,
Mute {
uuid: uuid_string(uuid),
name: name.to_owned(),
until,
},
);
self.save();
// An operator's longer mute stays.
if !self
.mutes
.get(&uuid)
.is_some_and(|m| m.until.is_none_or(|u| u >= until))
{
self.mutes.insert(
uuid,
Mute {
uuid: uuid_string(uuid),
name: name.to_owned(),
until: Some(until),
by: None,
reason: None,
},
);
self.save();
}
Action::Muted { until }
} else {
Action::Refused
@@ -377,7 +423,7 @@ impl Guard {
return;
};
let now = now_millis();
self.mutes.retain(|_, m| m.until > now);
self.mutes.retain(|_, m| m.active(now));
let mut mutes: Vec<Mute> = self.mutes.values().cloned().collect();
mutes.sort_by(|a, b| a.name.cmp(&b.name));
if let Ok(text) = serde_json::to_string_pretty(&MutesFile { mutes }) {
@@ -424,6 +470,34 @@ pub fn append_log(path: &Path, line: &str) -> std::io::Result<()> {
writeln!(f, "{line}")
}
/// `/tempmute`'s duration in milliseconds: a positive whole number and a unit, `s`, `m`, `h`, `d` or
/// `w` (`10m`, `2h`, `1d`).
pub fn parse_duration(text: &str) -> Option<i64> {
let unit = match text.chars().last()? {
's' => 1_000,
'm' => MINUTE,
'h' => 60 * MINUTE,
'd' => 24 * 60 * MINUTE,
'w' => 7 * 24 * 60 * MINUTE,
_ => return None,
};
let count: i64 = text[..text.len() - 1].parse().ok()?;
(count > 0).then(|| count.checked_mul(unit)).flatten()
}
/// `millis` for players, rounded up: minutes up to an hour, hours up to two days, then days.
pub fn duration_text(millis: i64) -> String {
let up = |unit: i64| (millis + unit - 1) / unit;
let (count, unit) = if millis <= 60 * MINUTE {
(up(MINUTE), "minute")
} else if millis <= 48 * 60 * MINUTE {
(up(60 * MINUTE), "hour")
} else {
(up(24 * 60 * MINUTE), "day")
};
format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
}
pub fn now_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
+74 -6
View File
@@ -120,14 +120,14 @@ fn the_second_hit_mutes_and_the_third_bans() {
let mut g = guard(Config::default());
let id = 7;
assert_eq!(g.strike(id, "Ann", NOW), (1, Action::Refused));
assert_eq!(g.muted_until(id, NOW), None);
assert_eq!(g.muted(id, NOW), None);
let until = NOW + 3 * MIN + 10 * MIN;
assert_eq!(
g.strike(id, "Ann", NOW + 3 * MIN),
(2, Action::Muted { until })
);
assert_eq!(g.muted_until(id, NOW + 4 * MIN), Some(until));
assert_eq!(g.muted_until(id, until), None);
assert_eq!(g.muted(id, NOW + 4 * MIN).unwrap().until, Some(until));
assert_eq!(g.muted(id, until), None);
// The time spent muted doesn't count: 8 minutes after the mute ended is still within the window.
assert_eq!(
g.strike(id, "Ann", until + 8 * MIN),
@@ -190,15 +190,83 @@ fn mutes_survive_a_restart_and_unmute_lifts_them() {
let (_, action) = g.strike(5, "Dee", now);
assert!(matches!(action, Action::Muted { .. }));
let (mut again, _) = Guard::load(Config::default(), &dir);
assert!(again.muted_until(5, now).is_some());
assert!(again.muted(5, now).is_some());
assert_eq!(again.unmute("dee").as_deref(), Some("Dee"));
assert_eq!(again.unmute("dee"), None);
assert_eq!(again.muted_until(5, now), None);
assert_eq!(again.muted(5, now), None);
// An operator's mutes are kept too, with who and why.
again.mute(6, "Eve", None, "Op", Some("spam".into()));
again.mute(7, "Fay", Some(now + MIN), "Op", None);
let (reloaded, _) = Guard::load(Config::default(), &dir);
assert_eq!(reloaded.muted_until(5, now), None);
assert_eq!(reloaded.muted(5, now), None);
let names: Vec<_> = reloaded
.mutes(now)
.iter()
.map(|m| m.name.as_str())
.collect();
assert_eq!(names, ["Eve", "Fay"]);
let eve = reloaded.muted(6, now).unwrap();
assert_eq!(
(eve.until, eve.by.as_deref(), eve.reason.as_deref()),
(None, Some("Op"), Some("spam"))
);
assert_eq!(reloaded.mutes(now + MIN).len(), 1);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_mute_file_from_before_manual_mutes_still_loads() {
let saved: MutesFile = serde_json::from_str(
r#"{"mutes":[{"uuid":"00000000-0000-0000-0000-000000000005","name":"Dee","until":5}]}"#,
)
.unwrap();
assert_eq!(saved.mutes[0].until, Some(5));
assert_eq!(saved.mutes[0].by, None);
}
#[test]
fn the_filter_never_shortens_an_operators_mute() {
let mut g = guard(Config::default());
g.mute(1, "Bo", None, "Op", None);
g.strike(1, "Bo", NOW);
g.strike(1, "Bo", NOW + MIN);
assert_eq!(
g.muted(1, NOW + 60 * MIN).unwrap().by.as_deref(),
Some("Op")
);
// A shorter one is replaced by the filter's.
g.mute(2, "Cy", Some(NOW + MIN), "Op", None);
g.strike(2, "Cy", NOW);
g.strike(2, "Cy", NOW);
assert_eq!(g.muted(2, NOW).unwrap().until, Some(NOW + 10 * MIN));
}
#[test]
fn durations_read_and_print() {
assert_eq!(parse_duration("10m"), Some(10 * MIN));
assert_eq!(parse_duration("2h"), Some(120 * MIN));
assert_eq!(parse_duration("1d"), Some(24 * 60 * MIN));
assert_eq!(parse_duration("1w"), Some(7 * 24 * 60 * MIN));
assert_eq!(parse_duration("30s"), Some(30_000));
for bad in [
"",
"m",
"0m",
"-1m",
"1.5h",
"10",
"10x",
"99999999999999999w",
] {
assert_eq!(parse_duration(bad), None, "{bad}");
}
assert_eq!(duration_text(61_000), "2 minutes");
assert_eq!(duration_text(MIN), "1 minute");
assert_eq!(duration_text(2 * 60 * MIN), "2 hours");
assert_eq!(duration_text(24 * 60 * MIN), "24 hours");
assert_eq!(duration_text(3 * 24 * 60 * MIN), "3 days");
}
#[test]
fn a_missing_words_file_falls_back_to_the_built_in_list() {
let config = Config {
+4
View File
@@ -132,6 +132,10 @@ path = "../horizon-client/src/bin/horizon-ctl/main.rs"
name = "chunk_load_bench"
path = "../horizon-client/examples/chunk_load_bench.rs"
[[example]]
name = "gen_light_bench"
path = "../horizon-client/examples/gen_light_bench.rs"
[[example]]
name = "gameplay_decode_profile"
path = "../horizon-client/examples/gameplay_decode_profile.rs"
+2
View File
@@ -7,6 +7,8 @@ rust-version = "1.90"
description = "The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer, the HUD, menus and settings; no gameplay"
[dependencies]
# The GUI atlas's pixels, kept compressed once uploaded (hud.rs `pack_pixels`).
lz4_flex = { version = "0.14", default-features = false, features = ["std", "safe-encode", "safe-decode", "checked-decode"] }
horizon-assets = { path = "../horizon-assets" }
horizon-bench = { path = "../horizon-bench" }
horizon-client-core = { path = "../horizon-client-core" }
+74 -6
View File
@@ -187,6 +187,7 @@ pub(crate) fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
let doc: serde_json::Value = agent()
.get(&discovery_url())
.call()
.inspect_err(|e| log::warn!("The services discovery document: {e}"))
.ok()
.and_then(|r| r.into_string().ok())
.and_then(|t| serde_json::from_str(&t).ok())
@@ -253,9 +254,33 @@ pub fn join_request(session: &AuthSession, server_hash: &str) -> String {
.to_string()
}
/// A join that got no answer is worth one more try, as leaving and joining
/// again would be: `join` is safe to repeat, it only records the server's
/// hash. Any other answer is the service's own and stands.
fn with_one_retry(
mut join: impl FnMut() -> Result<(), JoinError>,
pause: Duration,
) -> Result<(), JoinError> {
match join() {
Err(JoinError::Unavailable) => {
std::thread::sleep(pause);
join()
}
result => result,
}
}
/// MinecraftServicesSessionService.joinServer: tell the session service this
/// account is joining the server whose hash is `server_hash`.
/// account is joining the server whose hash is `server_hash`. Horizon tries
/// twice when the service doesn't answer; vanilla asks the player to.
pub fn join_server(session: &AuthSession, server_hash: &str) -> Result<(), JoinError> {
with_one_retry(
|| join_server_once(session, server_hash),
Duration::from_millis(500),
)
}
fn join_server_once(session: &AuthSession, server_hash: &str) -> Result<(), JoinError> {
let doc = discovery()?;
let url = endpoint(&doc, "session", "join").ok_or(JoinError::Unavailable)?;
match agent()
@@ -264,11 +289,19 @@ pub fn join_server(session: &AuthSession, server_hash: &str) -> Result<(), JoinE
.send_string(&join_request(session, server_hash))
{
Ok(_) => Ok(()),
Err(ureq::Error::Status(status, response)) => Err(JoinError::from_response(
status,
&response.into_string().unwrap_or_default(),
)),
Err(ureq::Error::Transport(_)) => Err(JoinError::Unavailable),
Err(ureq::Error::Status(status, response)) => {
if status >= 500 {
log::warn!("The session service's join answered {status}");
}
Err(JoinError::from_response(
status,
&response.into_string().unwrap_or_default(),
))
}
Err(ureq::Error::Transport(e)) => {
log::warn!("The session service's join: {e}");
Err(JoinError::Unavailable)
}
}
}
@@ -564,6 +597,41 @@ mod tests {
const UUID: u128 = 0x069a79f444e94726a5befca90e38aaf5;
/// Runs `with_one_retry` over these answers, in order: the result and how
/// many it asked for.
fn joined(answers: &[Result<(), JoinError>]) -> (Result<(), JoinError>, usize) {
let mut asked = 0;
let result = with_one_retry(
|| {
asked += 1;
answers[asked - 1].clone()
},
Duration::ZERO,
);
(result, asked)
}
#[test]
fn a_join_with_no_answer_is_tried_once_more() {
let none = Err(JoinError::Unavailable);
assert_eq!(joined(&[none.clone(), Ok(())]), (Ok(()), 2));
// Twice without an answer: the message the player knows.
assert_eq!(joined(&[none.clone(), none.clone()]), (none, 2));
}
#[test]
fn a_join_the_service_answered_is_not_tried_again() {
assert_eq!(joined(&[Ok(())]), (Ok(()), 1));
for refused in [
JoinError::InvalidSession,
JoinError::Banned,
JoinError::InsufficientPrivileges,
JoinError::Other("Status: 429".into()),
] {
assert_eq!(joined(&[Err(refused.clone())]), (Err(refused), 1));
}
}
fn write(dir: &Path, text: &str, mode: u32) -> std::path::PathBuf {
let path = dir.join("session.json");
std::fs::write(&path, text).unwrap();
@@ -1792,6 +1792,15 @@ impl BlockEntityRenderer {
portal_cube(base, &faces, END_GATEWAY_LAYER, out);
}
Kind::Conduit => {
// HDR output: the conduit shines, not its crumbling overlay.
let start = out.len();
let shine = |out: &mut Vec<Vertex>| {
if !crumbling {
for v in &mut out[start..] {
v.layer |= u32::from(crate::mesher::BRIGHT) << 16;
}
}
};
let (ticks, active, hunting, raw_rotation) = (scene.anim)(pos).conduit;
// ConduitRenderer.extractRenderState.
let rotation = (raw_rotation + if active { scene.partial } else { 0.0 }) * -0.0375;
@@ -1809,6 +1818,7 @@ impl BlockEntityRenderer {
* Mat4::from_translation(Vec3::splat(0.5))
* Mat4::from_rotation_y(rotation.to_radians());
draw_part(model, "root", m, &none, paint, out);
shine(out);
return;
}
let anim_time = ticks as f32 + scene.partial;
@@ -1867,6 +1877,7 @@ impl BlockEntityRenderer {
* Mat4::from_scale(Vec3::splat(1.333_333_4));
draw_part(model, "root", m, &none, paint, out);
}
shine(out);
}
Kind::EnchantingTable => {
let book = "entity/enchantment/enchanting_table_book";
@@ -2141,11 +2152,13 @@ const PORTAL_FACES: [[[f32; 3]; 4]; 6] = [
];
/// `AbstractEndPortalRenderer.submitCube`: position-only quads (the portal
/// shader projects its textures from the screen; uv, colour and light are unused).
/// `rendertype_end_portal` culls back faces, as `entitySolid` does, so the cube's
/// two Y faces are one-sided: only the one facing the camera draws.
fn portal_cube(m: Mat4, faces: &[Direction], layer: u32, out: &mut Vec<Vertex>) {
for &d in faces {
for c in PORTAL_FACES[d as usize] {
out.push(Vertex {
normal: [0; 4],
normal: CULL_BACK,
pos: m.transform_point3(Vec3::from(c)).to_array(),
uv: [0.0; 2],
layer,
@@ -2716,6 +2729,35 @@ mod tests {
assert_eq!(first_line("\u{6771}", 3.0, &advance), "\u{6771}");
assert_eq!(first_line("ab\ncd", 100.0, &advance), "ab");
}
/// `rendertype_end_portal` culls back faces, and `AbstractEndPortalRenderer`
/// submits the unit cube's two Y faces. Both are wound outward, so of each
/// face only the side looking at the camera draws: the portal shows one
/// surface from above and one from below, never both at once.
#[test]
fn the_end_portal_cube_is_one_sided() {
let mut out = Vec::new();
portal_cube(
Mat4::IDENTITY,
&[Direction::Down, Direction::Up],
END_PORTAL_LAYER,
&mut out,
);
// `TheEndPortalBlockEntity.shouldRenderFace` keeps the Y faces only.
assert_eq!(out.len(), 8, "the cube submits both Y faces");
assert!(
out.iter().all(|v| v.normal == CULL_BACK),
"rendertype_end_portal culls back faces"
);
// Each face is wound outward, so the shader's `!front` discard keeps the
// camera-facing side of it and drops the other.
let facing = |quad: &[Vertex]| {
let p = |i: usize| Vec3::from(quad[i].pos);
(p(1) - p(0)).cross(p(2) - p(0))
};
assert!(facing(&out[..4]).y < 0.0, "the Down face looks down");
assert!(facing(&out[4..]).y > 0.0, "the Up face looks up");
}
/// `DecoratedPotRenderer` submits every part with `RenderTypes.entitySolid`,
/// whose pipeline culls back faces. The pot's `top` and `bottom` are flat
/// boxes, so without that cull both sides of each plane are drawn and they
+223 -17
View File
@@ -285,20 +285,122 @@ fn forget_shared() {
*shared = None;
}
}
/// Packed light per block: sky light in the high nibble, block light in the low one.
/// Packed light per block: sky light in the high nibble, block light in the
/// low one. A section keeps only the kind of light that varies in it
/// (`Light::packed`): most sections have no block light and only sky light
/// that changes, or the reverse below ground.
#[derive(Clone)]
pub enum Light {
Uniform(u8),
/// Sky light per block, as nibbles (block `i` in byte `i / 2`, the even
/// one in the low nibble, as a vanilla DataLayer), and one block light.
Sky(Box<[u8; 2048]>, u8),
/// Block light per block, as nibbles, and one sky light.
Block(Box<[u8; 2048]>, u8),
Full(Box<[u8; 4096]>),
}
#[inline]
fn nibble(layer: &[u8; 2048], i: usize) -> u8 {
layer[i >> 1] >> ((i & 1) << 2) & 15
}
impl Light {
#[inline]
pub fn get(&self, i: usize) -> u8 {
match self {
Self::Uniform(v) => *v,
Self::Sky(layer, block) => nibble(layer, i) << 4 | block,
Self::Block(layer, sky) => sky << 4 | nibble(layer, i),
Self::Full(a) => a[i],
}
}
/// The smallest form of packed light per block.
pub fn packed(light: Box<[u8; 4096]>) -> Self {
let first = light[0];
let (mut sky_varies, mut block_varies) = (false, false);
for &v in light.iter() {
let d = v ^ first;
sky_varies |= d & 0xF0 != 0;
block_varies |= d & 15 != 0;
}
let layer = |shift: u32| {
let mut layer = Box::new([0u8; 2048]);
for (out, pair) in layer.iter_mut().zip(light.as_chunks::<2>().0) {
*out = (pair[0] >> shift & 15) | (pair[1] >> shift & 15) << 4;
}
layer
};
match (sky_varies, block_varies) {
(false, false) => Self::Uniform(first),
(true, false) => Self::Sky(layer(4), first & 15),
(false, true) => Self::Block(layer(0), first >> 4),
(true, true) => Self::Full(light),
}
}
/// The packed light of blocks `at..at + out.len()`, into `out`.
#[inline]
pub fn copy_run(&self, at: usize, out: &mut [u8]) {
match self {
Self::Uniform(v) => out.fill(*v),
Self::Full(a) => out.copy_from_slice(&a[at..at + out.len()]),
Self::Sky(layer, block) => {
for (k, o) in out.iter_mut().enumerate() {
*o = nibble(layer, at + k) << 4 | block;
}
}
Self::Block(layer, sky) => {
for (k, o) in out.iter_mut().enumerate() {
*o = sky << 4 | nibble(layer, at + k);
}
}
}
}
/// The packed light of every block, in index order.
pub fn to_array(&self) -> Box<[u8; 4096]> {
match self {
Self::Full(a) => a.clone(),
Self::Uniform(v) => Box::new([*v; 4096]),
_ => Box::new(std::array::from_fn(|i| self.get(i))),
}
}
/// Whether any block's packed light has a bit of `mask` set.
pub fn any(&self, mask: u8) -> bool {
let layer_any = |layer: &[u8; 2048]| layer.iter().any(|&v| v != 0);
match self {
Self::Uniform(v) => v & mask != 0,
Self::Sky(layer, block) => block & mask != 0 || (mask & 0xF0 != 0 && layer_any(layer)),
Self::Block(layer, sky) => {
(sky << 4) & mask != 0 || (mask & 15 != 0 && layer_any(layer))
}
Self::Full(a) => a.iter().any(|v| v & mask != 0),
}
}
/// Whether any block's packed light differs from `v` in the bits of `mask`.
pub fn any_not(&self, mask: u8, v: u8) -> bool {
match self {
Self::Uniform(u) => (u ^ v) & mask != 0,
Self::Full(a) => a.iter().any(|u| (u ^ v) & mask != 0),
_ => (0..4096).any(|i| (self.get(i) ^ v) & mask != 0),
}
}
/// Heap bytes it holds.
pub fn heap_bytes(&self) -> usize {
match self {
Self::Uniform(_) => 0,
Self::Sky(..) | Self::Block(..) => 2048,
Self::Full(_) => 4096,
}
}
/// Whether every block's light is the same as in `other`.
pub fn same_as(&self, other: &Self) -> bool {
match (self, other) {
(Self::Uniform(a), Self::Uniform(b)) => a == b,
(Self::Full(a), Self::Full(b)) => a == b,
(Self::Sky(a, x), Self::Sky(b, y)) | (Self::Block(a, x), Self::Block(b, y)) => {
x == y && a == b
}
_ => (0..4096).all(|i| self.get(i) == other.get(i)),
}
}
}
#[inline]
fn column(x: i32, z: i32) -> usize {
@@ -347,14 +449,15 @@ impl Section {
pub fn is_uniform(&self, v: u32) -> bool {
self.blocks.is_uniform(v)
}
/// Whether every block, biome and light value is the same as `other`'s.
pub fn same_as(&self, other: &Self) -> bool {
self.blocks == other.blocks
&& self.biomes == other.biomes
&& self.light.same_as(&other.light)
}
/// Heap bytes held by the block, biome and light storage (benchmark accounting).
pub fn heap_bytes(&self) -> usize {
self.blocks.heap_bytes()
+ self.biomes.heap_bytes()
+ match &self.light {
Light::Uniform(_) => 0,
Light::Full(_) => 4096,
}
self.blocks.heap_bytes() + self.biomes.heap_bytes() + self.light.heap_bytes()
}
/// Collapse storage that became uniform, and drop palette entries no
/// block uses any more.
@@ -366,13 +469,47 @@ impl Section {
*container = PalettedContainer::new(container.strategy(), v);
}
}
if let Light::Full(a) = &self.light
&& a.iter().all(|&b| b == a[0])
{
self.light = Light::Uniform(a[0]);
// Only the kind of light that varies is kept.
if matches!(self.light, Light::Full(_)) {
let Light::Full(a) = std::mem::replace(&mut self.light, Light::Uniform(0)) else {
unreachable!()
};
self.light = Light::packed(a);
}
}
}
/// The heap bytes of a set of columns by what holds them (`Chunk::census`,
/// `horizon-ctl memory`).
#[derive(Clone, Copy, Debug, Default)]
pub struct ColumnCensus {
pub columns: usize,
/// Section slots of every column, shared ones included.
pub sections: usize,
/// Distinct section allocations.
pub unique_sections: usize,
/// Distinct sections of a single block state (no data array).
pub uniform_block_sections: usize,
/// Distinct sections with light per block (not one value).
pub light_arrays: usize,
pub block_bytes: usize,
pub biome_bytes: usize,
pub light_bytes: usize,
pub heightmap_bytes: usize,
/// The section records themselves (`Arc<Section>`).
pub section_record_bytes: usize,
/// The column records and their lists of sections.
pub column_bytes: usize,
}
impl ColumnCensus {
pub fn total_bytes(&self) -> usize {
self.block_bytes
+ self.biome_bytes
+ self.light_bytes
+ self.heightmap_bytes
+ self.section_record_bytes
+ self.column_bytes
}
}
#[derive(Clone)]
pub struct Chunk {
pub x: i32,
@@ -564,19 +701,61 @@ impl Chunk {
}
#[inline]
pub fn light(&self, x: i32, y: i32, z: i32) -> u8 {
let s = (y - self.min_y) >> 4;
if s < 0 {
return 0;
match self.section_at(y) {
Ok(section) => section.light(index(x, y, z)),
Err(light) => light,
}
if s >= self.sections.len() as i32 {
return 0xF0;
}
/// The section holding block height `y`, or the light of the air outside the
/// column's sections there: none below them, full sky above.
#[inline]
pub fn section_at(&self, y: i32) -> Result<&Section, u8> {
match self.section_index(y) {
Some(s) => Ok(&self.sections[s]),
None if y < self.min_y => Err(0),
None => Err(0xF0),
}
self.sections[s as usize].light(index(x, y, z))
}
/// Heap bytes held by the sections (benchmark accounting).
pub fn heap_bytes(&self) -> usize {
self.sections.iter().map(|s| s.heap_bytes()).sum::<usize>()
}
/// Adds this column's heap bytes to `census` by what holds them. A
/// section shared with another column (`share_uniform`, or a copy not yet
/// written to) counts once: `seen` holds the sections counted so far.
pub fn census(
&self,
census: &mut ColumnCensus,
seen: &mut crate::fast_hash::FxHashSet<*const Section>,
) {
census.columns += 1;
census.sections += self.sections.len();
census.column_bytes += std::mem::size_of::<Self>()
+ self.sections.capacity() * std::mem::size_of::<Arc<Section>>();
census.heightmap_bytes += [&self.surface, &self.motion_blocking]
.iter()
.filter(|map| map.get().is_some())
.count()
* std::mem::size_of::<[i16; 256]>();
for section in &self.sections {
if !seen.insert(Arc::as_ptr(section)) {
continue;
}
census.unique_sections += 1;
// The `Arc`'s two counts and the section record.
census.section_record_bytes +=
2 * std::mem::size_of::<usize>() + std::mem::size_of::<Section>();
if section.blocks.bits() == 0 {
census.uniform_block_sections += 1;
}
census.block_bytes += section.blocks.heap_bytes();
census.biome_bytes += section.biomes.heap_bytes();
if section.light.heap_bytes() != 0 {
census.light_arrays += 1;
census.light_bytes += section.light.heap_bytes();
}
}
}
/// Biome table index at a block position (`ChunkAccess.getNoiseBiome` with
/// the quart y clamped to the column).
#[inline]
@@ -674,6 +853,33 @@ pub struct ChunkExtras {
mod tests {
use super::*;
#[test]
fn each_light_form_keeps_every_value_and_run() {
// Sky varies alone, block varies alone, both, neither.
let cases: [(fn(usize) -> u8, &str); 4] = [
(|i| ((i % 16) as u8) << 4 | 3, "sky"),
(|i| 0xA0 | (i / 256) as u8, "block"),
(|i| (i % 251) as u8, "full"),
(|_| 0x7C, "uniform"),
];
for (value, name) in cases {
let light = Light::packed(Box::new(std::array::from_fn(value)));
let form = match light {
Light::Sky(..) => "sky",
Light::Block(..) => "block",
Light::Full(_) => "full",
Light::Uniform(_) => "uniform",
};
assert_eq!(form, name);
for at in [0, 17, 1000, 4078] {
let mut run = [0u8; 18];
light.copy_run(at, &mut run);
let expected: Vec<u8> = (at..at + 18).map(value).collect();
assert_eq!(run.as_slice(), expected, "{name} at {at}");
assert_eq!(light.get(at + 5), value(at + 5), "{name}");
}
}
}
#[test]
fn highest_skips_only_sections_that_cannot_match() {
let (air, cave_air, stone) = (0, 7, 1);
let mut chunk = Chunk::empty(0, 0);
+3 -1
View File
@@ -351,7 +351,9 @@ fn shadow_color(in: Out) -> vec4<f32> {
if in.tier == 0u {
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(mix(in.scene, globals.hdr_light.y, fogged)));
}
return vec4(0.0, 0.0, 0.0, hdr_mark(in.tier, brightness(texel.rgb * in.color.rgb) * (1.0 - fogged)));
// A layer that fades (the warden's) shines by what's left of it.
let y = brightness(texel.rgb * in.color.rgb) * in.color.a * (1.0 - fogged);
return vec4(0.0, 0.0, 0.0, hdr_mark(in.tier, y));
}
fn brightness(c: vec3<f32>) -> f32 {
let l = pow(max(c, vec3(0.0)), vec3(2.2));
+22 -1
View File
@@ -124,6 +124,14 @@ pub struct Extras {
pub discord_presence: bool,
/// Another controller can join P1's world (`splitscreen`, off by default).
pub splitscreen: bool,
/// Two players in one window: 0 auto (side by side when wider than tall), 1 side by side,
/// 2 top and bottom (`splitscreenTwo`).
pub splitscreen_two: u8,
/// Three players on a screen: 0 one wide on top, 1 one tall on the left, 2 side by side
/// (`splitscreenThree`).
pub splitscreen_three: u8,
/// Splitscreen: each player's name in the corner of their view (`splitscreenNames`).
pub splitscreen_names: bool,
}
impl Default for Extras {
fn default() -> Self {
@@ -162,6 +170,9 @@ impl Default for Extras {
agent_control: false,
discord_presence: true,
splitscreen: false,
splitscreen_two: 0,
splitscreen_three: 0,
splitscreen_names: true,
}
}
}
@@ -242,6 +253,9 @@ impl Extras {
"agentControl" => e.agent_control = flag(e.agent_control),
"discordPresence" => e.discord_presence = flag(e.discord_presence),
"splitscreen" => e.splitscreen = flag(e.splitscreen),
"splitscreenTwo" => e.splitscreen_two = value.parse().unwrap_or(0).min(2),
"splitscreenThree" => e.splitscreen_three = value.parse().unwrap_or(0).min(2),
"splitscreenNames" => e.splitscreen_names = flag(e.splitscreen_names),
_ => {}
}
}
@@ -249,7 +263,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
format!(
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\n",
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
bool_text(self.zoom),
@@ -289,6 +303,9 @@ impl Extras {
bool_text(self.agent_control),
bool_text(self.discord_presence),
bool_text(self.splitscreen),
self.splitscreen_two,
self.splitscreen_three,
bool_text(self.splitscreen_names),
)
}
/// Binds the extra key `name` (`SORT_KEY`); false for
@@ -402,6 +419,10 @@ impl Cps {
self.clicks.len()
}
}
/// Two players sharing a window (`splitscreen_two`): halves this way.
pub const SPLIT_TWO: [&str; 3] = ["Auto", "Side by side", "Top and bottom"];
/// Three players sharing a window (`splitscreen_three`).
pub const SPLIT_THREE: [&str; 3] = ["One wide on top", "One tall on the left", "Side by side"];
/// The settings the Extras page shows, one per row.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+93
View File
@@ -777,6 +777,7 @@ pub struct Renderer {
solid_opaque: wgpu::RenderPipeline,
solid_cutout: wgpu::RenderPipeline,
translucent: wgpu::RenderPipeline,
translucent_mark: wgpu::RenderPipeline,
/// Flat section draws (`flat`), where multi-draw is emulated.
flat: Option<flat::Flat>,
/// `flat` draws this frame's sections (off: the per-section draws).
@@ -1841,6 +1842,53 @@ impl Renderer {
let arenas = self.arena.bytes() + translucent.map_or(0, arena::Arena::bytes);
[arenas, vertices * stride]
}
/// Device memory the backend's allocator holds, by the label of what holds
/// it, largest first, with its allocated and reserved totals (Vulkan and
/// DirectX 12; none on Metal and OpenGL) (`horizon-ctl memory`).
pub fn gpu_memory_by_label(&self) -> Option<(u64, u64, Vec<(String, u64)>)> {
let report = self.device.generate_allocator_report()?;
let mut by_label: HashMap<String, u64> = HashMap::new();
for a in report.allocations {
*by_label.entry(a.name).or_default() += a.size;
}
let mut labels: Vec<_> = by_label.into_iter().collect();
labels.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
Some((
report.total_allocated_bytes,
report.total_reserved_bytes,
labels,
))
}
/// Heap bytes of the renderer's per-section bookkeeping (`horizon-ctl memory`):
/// resident sections, far tiles, block entities and light sources by section.
pub fn cpu_census(&self) -> [(&'static str, usize); 4] {
use std::mem::size_of;
let per_section = |map_len: usize, lists: usize| {
map_len * (size_of::<SectionKey>() + size_of::<Vec<([i32; 3], u32)>>() + 1)
+ lists * size_of::<([i32; 3], u32)>()
};
let lists = |map: &crate::fast_hash::FxHashMap<SectionKey, Vec<([i32; 3], u32)>>| {
per_section(
map.capacity(),
map.values().map(Vec::capacity).sum::<usize>(),
)
};
let animated: usize = self
.sections
.values()
.map(|s| s.animated_layers.len() * size_of::<u16>())
.sum();
[
("resident_sections", self.sections.heap_bytes() + animated),
(
"far_tiles",
self.lods.capacity()
* (size_of::<crate::lod::TileKey>() + size_of::<lod::Tile>() + 1),
),
("block_entities", lists(&self.block_entities)),
("light_sources", lists(&self.light_blocks)),
]
}
}
impl Renderer {
pub async fn new(
@@ -2233,6 +2281,17 @@ impl Renderer {
true,
Some(alpha_blend),
);
let translucent_mark = pipeline(
"Terrain translucent HDR mark",
"vs_terrain",
"fs_translucent_mark",
&terrain_buffers,
tri,
Some(wgpu::Face::Back),
false,
true,
Some(flat::ALPHA_ONLY),
);
let sky = pipeline(
"Sky",
"vs_sky",
@@ -3647,6 +3706,7 @@ impl Renderer {
solid_opaque,
solid_cutout,
translucent,
translucent_mark,
flat_on: flat.is_some(),
cull_on: cull.is_some(),
sides_on: true,
@@ -3835,6 +3895,31 @@ impl Renderer {
pass.draw_indexed(0..quads * 6, (first * 4) as i32, 0..1);
}
}
/// HDR lighting on translucent terrain, over the draws that just blended it.
fn mark_translucent_terrain(
&self,
pass: &mut wgpu::RenderPass<'_>,
flat: Option<&flat::Flat>,
solid_draws: u32,
translucent_draws: u32,
) {
if self.hdr.as_ref().is_none_or(|h| !h.lighting) {
return;
}
match flat {
Some(flat) => {
pass.set_pipeline(&flat.translucent_mark);
flat.draw(pass, flat.translucent_calls(), |p| self.arena.buffer(p));
pass.set_bind_group(0, &self.group, &[]);
}
None => {
pass.set_pipeline(&self.translucent_mark);
pass.set_vertex_buffer(1, self.instances.slice(..));
self.draw_sections(pass, &self.indirect, 0, solid_draws, translucent_draws);
}
}
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
}
/// LevelRenderer.submitBlockOutline: the buffer holds `edges` per stroke, the black
/// secondary stroke first when high contrast is on.
fn draw_outline(&self, pass: &mut wgpu::RenderPass<'_>, edges: u32, high_contrast: bool) {
@@ -7837,6 +7922,7 @@ impl Renderer {
}
pass.set_bind_group(0, &self.group, &[]);
}
self.mark_translucent_terrain(&mut pass, flat, solid_draws, translucent_draws);
}
Some(flat) => {
pass.set_pipeline(pack_flat.map_or(&flat.translucent, |p| &p.1));
@@ -7845,6 +7931,12 @@ impl Renderer {
});
pass.set_bind_group(0, &self.group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
self.mark_translucent_terrain(
&mut pass,
Some(flat),
solid_draws,
translucent_draws,
);
}
None => {
pass.set_pipeline(translucent_pipeline);
@@ -7857,6 +7949,7 @@ impl Renderer {
solid_draws,
translucent_draws,
);
self.mark_translucent_terrain(&mut pass, None, solid_draws, translucent_draws);
}
}
// LevelRenderer.executeTranslucentAfterTerrain: vanilla's
@@ -55,6 +55,15 @@ struct PageTable {
group: Option<wgpu::BindGroup>,
}
/// Writes the fragment's alpha over the frame's and leaves its colour.
pub(super) const ALPHA_ONLY: wgpu::BlendState = wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent::REPLACE,
};
/// A run of the frame's index list drawn from one arena page.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Call {
@@ -74,6 +83,7 @@ pub struct Flat {
/// The solid draws as the water mirror's camera draws them (`fs_solid_mirror`).
pub solid_mirror: wgpu::RenderPipeline,
pub translucent: wgpu::RenderPipeline,
pub translucent_mark: wgpu::RenderPipeline,
flatten: wgpu::ComputePipeline,
compute_layout: wgpu::BindGroupLayout,
flatten_seen: wgpu::ComputePipeline,
@@ -418,6 +428,14 @@ impl Flat {
Some(wgpu::BlendState::ALPHA_BLENDING),
back,
),
translucent_mark: pipeline(
&layout,
"Terrain translucent HDR mark (flat)",
"vs_terrain_flat",
"fs_translucent_mark",
Some(ALPHA_ONLY),
back,
),
flatten,
compute_layout,
flatten_seen,
@@ -55,6 +55,22 @@ impl<T> Resident<T> {
pub fn len(&self) -> usize {
self.items.len()
}
/// Heap bytes of its own lists and maps, not counting what a `T` points
/// to (`horizon-ctl memory`). A hash map entry costs its pair and a
/// control byte.
pub fn heap_bytes(&self) -> usize {
use std::mem::size_of;
self.items.capacity() * size_of::<(SectionKey, T)>()
+ self.bounds.capacity() * size_of::<Option<[Vec3; 2]>>()
+ self.slots.capacity() * (size_of::<(SectionKey, u32)>() + 1)
+ self.columns.capacity() * size_of::<Column>()
+ self
.columns
.iter()
.map(|c| c.sections.capacity() * size_of::<(i32, u32)>())
.sum::<usize>()
+ self.column_index.capacity() * (size_of::<((i32, i32), u32)>() + 1)
}
pub fn contains(&self, key: SectionKey) -> bool {
self.slots.contains_key(&key)
}
+111 -1
View File
@@ -7,6 +7,7 @@ use std::{
collections::{HashMap, HashSet},
fs,
path::{Path, PathBuf},
sync::{Arc, OnceLock},
};
use wgpu::util::DeviceExt;
@@ -58,7 +59,15 @@ impl TextureFlags {
}
}
pub struct HudAtlas {
/// RGBA8, `width` by `height`, as built: empty once `pack_pixels` has
/// compressed them (read them through `pixels()`).
pub pixels: Vec<u8>,
/// `pixels` compressed (lz4) on a thread of its own once the renderer
/// has uploaded them: only rebuilding a renderer reads them again.
packed: Option<Arc<OnceLock<Vec<u8>>>>,
/// The width each Standard Galactic letter uses in `font/ascii_sga`'s
/// cell, by ASCII code (the enchanting table's text), read when built.
pub galactic_widths: [u16; 128],
pub width: u32,
pub height: u32,
pub sprites: HashMap<String, Rect>,
@@ -483,7 +492,76 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> (Vec<PackedImage>, Vec<
/// `MissingTextureAtlasSprite`: what a sprite the atlas lacks draws as.
pub const MISSING_SPRITE: &str = "horizon/missing";
/// The used width of each Standard Galactic letter (A-Z, a-z) in the
/// `font/ascii_sga` sheet: one past its last column with a texel that isn't
/// transparent.
fn galactic_widths(
sprites: &HashMap<String, Rect>,
pixels: &[u8],
width: u32,
height: u32,
) -> [u16; 128] {
let mut widths = [0u16; 128];
let Some(sheet) = sprites.get("font/ascii_sga") else {
return widths;
};
let cell = sheet.w / 16;
let (px, py) = (
(sheet.u0 * width as f32) as u32,
(sheet.v0 * height as f32) as u32,
);
for code in (0x41..=0x5Au32).chain(0x61..=0x7A) {
let (cx, cy) = (px + code % 16 * cell, py + code / 16 * cell);
let alpha = |x: u32, y: u32| {
pixels
.get(((y * width + x) * 4 + 3) as usize)
.copied()
.unwrap_or(0)
};
widths[code as usize] = (0..cell)
.rev()
.find(|&dx| (0..cell).any(|dy| alpha(cx + dx, cy + dy) != 0))
.map_or(0, |dx| (dx + 1).min(u32::from(u16::MAX)) as u16);
}
widths
}
impl HudAtlas {
/// The atlas's pixels: as built, or uncompressed again after
/// `pack_pixels` (a renderer being rebuilt).
pub fn pixels(&self) -> std::borrow::Cow<'_, [u8]> {
let Some(packed) = self.packed.as_ref().filter(|_| self.pixels.is_empty()) else {
return std::borrow::Cow::Borrowed(&self.pixels);
};
let size = (self.width * self.height * 4) as usize;
std::borrow::Cow::Owned(
lz4_flex::decompress_size_prepended(packed.wait())
.ok()
.filter(|p| p.len() == size)
.unwrap_or_else(|| vec![0; size]),
)
}
/// Keep the pixels compressed from now on, once a renderer has them: the
/// window's GPU holds the atlas, and a copy as built only serves a
/// renderer made again (another window, a lost device, another graphics
/// API). They are compressed on a thread of their own; `pixels()` waits
/// for it.
pub fn pack_pixels(&mut self) {
if self.pixels.is_empty() || self.packed.is_some() {
return;
}
let raw = Arc::new(std::mem::take(&mut self.pixels));
let slot = Arc::new(OnceLock::new());
let (job_raw, job_slot) = (raw.clone(), slot.clone());
let spawned = std::thread::Builder::new()
.name("Atlas packing".into())
.spawn(move || {
let _ = job_slot.set(lz4_flex::compress_prepend_size(&job_raw));
});
if spawned.is_err() {
let _ = slot.set(lz4_flex::compress_prepend_size(&raw));
}
self.packed = Some(slot);
}
/// A sprite, or the missing one when the atlas has no such sprite (an
/// effect without its icon), as a stitched atlas answers.
pub fn sprite_or_missing(&self, name: &str) -> Option<Rect> {
@@ -630,7 +708,9 @@ impl HudAtlas {
(glyph_top, height - glyph_top),
white,
),
galactic_widths: galactic_widths(&sprites, &pixels, width, height),
pixels,
packed: None,
width,
height,
sprites,
@@ -1063,7 +1143,9 @@ impl HudAtlas {
white,
);
Ok(Self {
galactic_widths: galactic_widths(&sprites, &pixels, width, height),
pixels,
packed: None,
width,
height,
sprites,
@@ -1956,6 +2038,12 @@ impl HudRenderer {
label: Some("HUD"),
..Default::default()
});
let linear_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("HUD linear"),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
..Default::default()
});
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("HUD size"),
contents: bytemuck::bytes_of(&HudUniform::new([1.0; 4], EndPortalFill::default())),
@@ -2006,6 +2094,12 @@ impl HudRenderer {
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 5,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
@@ -2032,6 +2126,10 @@ impl HudRenderer {
binding: 4,
resource: wgpu::BindingResource::Sampler(glint_sampler),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::Sampler(&linear_sampler),
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
@@ -2134,7 +2232,7 @@ impl HudRenderer {
operation: wgpu::BlendOperation::Add,
},
},
"fs_main",
"fs_vignette",
false,
);
// RenderPipelines.END_PORTAL: opaque.
@@ -2781,6 +2879,18 @@ mod tests {
assert_eq!(texel_at(&atlas, *rect, 15, 15), [0, 0, 255, 255]);
}
#[test]
fn packed_pixels_come_back_the_same() {
let root = animated_root();
let mut atlas = HudAtlas::build(&root).unwrap();
let built = atlas.pixels.clone();
atlas.pack_pixels();
assert!(atlas.pixels.is_empty());
assert!(atlas.pixels() == built.as_slice());
// Packing again keeps the same image.
atlas.pack_pixels();
assert!(atlas.pixels() == built.as_slice());
}
#[test]
fn a_strip_frames_size_comes_from_its_mcmeta() {
let root = animated_root();
let block = root.join("assets/minecraft/textures/block");
@@ -10,6 +10,7 @@ struct Hud {
// enchanted_glint_item on its own repeating texture (vanilla's GlintSampler).
@group(0) @binding(3) var glint_tex: texture_2d<f32>;
@group(0) @binding(4) var glint_sampler: sampler;
@group(0) @binding(5) var linear_sampler: sampler;
struct Out {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@@ -75,6 +76,12 @@ struct CitSlots {
if c.a == 0.0 { discard; }
return c;
}
// RenderPipelines.VIGNETTE (WinScreen's credits vignette): bilinear sample of
// credits_vignette.png (which has blur: true in its .mcmeta and a wrapped border in the atlas).
@fragment fn fs_vignette(in: Out) -> @location(0) vec4<f32> {
let c = textureSample(atlas, linear_sampler, in.uv) * in.color;
return vec4(c.rgb, 1.0);
}
// rendertype_end_portal.fsh (entity.wgsl has the same for portal blocks).
const PORTAL_COLORS = array<vec3<f32>, 16>(
vec3(0.022087, 0.098399, 0.110818),
+3 -1
View File
@@ -49,7 +49,7 @@ pub mod hud_overlays;
pub mod inventory_effects;
pub mod java_text;
pub mod jigsaw_block;
pub mod json_folder;
pub use horizon_world::json_folder;
pub mod level_loading;
pub mod light;
pub mod lod;
@@ -91,7 +91,9 @@ pub mod sound_events;
pub mod sound_preview;
pub mod spans;
pub mod split_layout;
pub mod split_map;
pub mod split_profiles;
pub mod split_ui;
mod sprite_cache;
pub mod sprites;
pub mod streaming;
+11 -17
View File
@@ -562,11 +562,7 @@ fn pack_lighting(
}
}
}
if data.iter().all(|&v| v == data[0]) {
Light::Uniform(data[0])
} else {
Light::Full(data)
}
Light::packed(data)
})
.collect()
}
@@ -810,10 +806,9 @@ impl<'w> Region<'w> {
return;
};
let slot = &mut self.slots[slot];
let light = slot.light.get_or_insert_with(|| match &slot.section.light {
Light::Uniform(v) => Box::new([*v; 4096]),
Light::Full(a) => a.clone(),
});
let light = slot
.light
.get_or_insert_with(|| slot.section.light.to_array());
let i = index(x, y, z);
light[i] = f(light[i]);
}
@@ -830,7 +825,7 @@ impl<'w> Region<'w> {
drop(slot.section);
let (cx, s, cz) = slot.key;
self.world
.set_section_light(cx, s as usize, cz, Light::Full(light));
.set_section_light(cx, s as usize, cz, Light::packed(light));
}
}
}
@@ -1624,21 +1619,20 @@ mod tests {
current.set(8, MIN_Y, 8, TORCH);
let region = [[None; 3], [None, Some(&current), None], [None; 3]];
let lights = light_chunk(&region, &tables(), true);
let Light::Full(data) = &lights[0] else {
panic!("torch light varies");
let storage = |light: &Light| match light {
Light::Full(data) => data.as_ptr(),
Light::Sky(layer, _) | Light::Block(layer, _) => layer.as_ptr(),
Light::Uniform(_) => panic!("torch light varies"),
};
let result_storage = data.as_ptr();
let result_storage = storage(&lights[0]);
let snapshot = current.clone();
current.set(8, MIN_Y, 8, STONE);
Arc::make_mut(&mut current.sections[0]).biomes.set(0, 3);
let edited_snapshot = current.clone();
apply_lighting(&mut current, lights);
let Light::Full(data) = &current.sections[0].light else {
panic!("torch light varies");
};
assert_eq!(
result_storage,
data.as_ptr(),
storage(&current.sections[0].light),
"returned light storage moves without cloning"
);
current.lit = true;
+6 -1
View File
@@ -214,7 +214,12 @@ impl Mesher {
Paint {
layer: face.layer,
light: span.light,
color: channel(16) | channel(8) << 8 | channel(0) << 16 | 0xff000000,
// The HDR tier, as the section mesher writes it, so a light source
// keeps shining across the LOD boundary.
color: crate::mesher::emitting(
channel(16) | channel(8) << 8 | channel(0) << 16 | 0xff000000,
context.tables.hdr_tier(span.state),
),
translucent: face.translucent,
}
})
+21 -24
View File
@@ -12,7 +12,7 @@ use crate::{
assets::{Assets, RenderAssets},
biome::{Biomes, GrassModifier},
blocks::{BlockFacts, RenderShape},
chunk::{Chunk, Light, MIN_Y},
chunk::{Chunk, MIN_Y},
graphics::LeafCullingMode,
models::catalog::BakedFace,
};
@@ -368,15 +368,15 @@ pub const ALL_VISIBLE: u64 = (1 << 36) - 1;
fn visibility(pad: &Padded, info: &[Info]) -> u64 {
let solid: Vec<bool> = info.iter().map(|i| i.solid).collect();
let mut rows = Vec::new();
opaque_rows(pad, &solid, &mut rows);
solid_rows(pad, &solid, &mut rows);
visibility_rows(&open_rows(&rows))
}
/// The section's non-opaque cells by row (`y << 4 | z`, bit x) from the padded
/// opacity rows (`MeshScratch::opaque`).
fn open_rows(opaque: &[u32]) -> [u16; 256] {
/// The section's cells that are not `isSolidRender`, by row (`y << 4 | z`, bit
/// x), from the padded rows of `MeshScratch::solid_rows`.
fn open_rows(solid: &[u32]) -> [u16; 256] {
std::array::from_fn(|r| {
let (y, z) = ((r >> 4) as i32, (r & 15) as i32);
!(opaque[((y + B) as usize) * P + (z + B) as usize] >> B) as u16
!(solid[((y + B) as usize) * P + (z + B) as usize] >> B) as u16
})
}
/// The cells of `open` connected to `seeds` (a subset) within the row: an
@@ -753,6 +753,10 @@ impl MeshTables {
.copied()
.unwrap_or(false)
}
/// HDR output's tier of a state (`hdr_tier`), for what meshes it elsewhere (the LOD tiles).
pub fn hdr_tier(&self, state: BlockStateId) -> u8 {
self.info.get(state.0 as usize).map_or(0, |i| i.tier)
}
pub fn facts(&self, state: BlockStateId) -> Option<MeshFacts> {
let i = self.info.get(state.0 as usize)?;
Some(MeshFacts {
@@ -922,14 +926,10 @@ impl Padded {
let sources: [[Source; 3]; 3] = std::array::from_fn(|rz| {
std::array::from_fn(|rx| match region[rz][rx] {
None => Source::Missing,
// `Chunk::get` and `Chunk::light` outside the column's sections.
Some(c) => {
let s = (wy - c.min_y()) >> 4;
match c.sections.get(s as usize).filter(|_| s >= 0) {
Some(section) => Source::Section(section),
None => Source::Outside(if s < 0 { 0 } else { 0xF0 }),
}
}
Some(c) => match c.section_at(wy) {
Ok(section) => Source::Section(section),
Err(light) => Source::Outside(light),
},
})
});
for z in -B..16 + B {
@@ -977,10 +977,7 @@ fn fill_run(
Some(own) => blocks.copy_from_slice(own),
None => section.blocks.get_run(at, blocks),
}
match &section.light {
Light::Uniform(v) => light.fill(*v),
Light::Full(all) => light.copy_from_slice(&all[at..at + light.len()]),
}
section.light.copy_run(at, light);
}
Source::Outside(v) => {
blocks.fill(0);
@@ -1015,11 +1012,11 @@ pub struct MeshScratch {
own: Option<Box<[u32; 4096]>>,
leaf: leaf_culling::LeafMask,
/// Per padded row (`pi(-B, y, z) / P`), bit `x + B` set where the block
/// is `isSolidRender`.
opaque: Vec<u32>,
/// is `isSolidRender` (`MeshTables::solid`).
solid_rows: Vec<u32>,
}
/// Fills `rows` with the padded section's opacity, one bit per block (`MeshScratch::opaque`).
fn opaque_rows(pad: &Padded, solid: &[bool], rows: &mut Vec<u32>) {
/// Fills `rows` with the padded section's `isSolidRender` blocks, one bit each (`MeshScratch::solid_rows`).
fn solid_rows(pad: &Padded, solid: &[bool], rows: &mut Vec<u32>) {
rows.clear();
rows.extend(pad.blocks.as_chunks::<P>().0.iter().map(|row| {
row.iter().enumerate().fold(0u32, |bits, (x, &id)| {
@@ -1396,8 +1393,8 @@ impl Mesher<'_> {
let own = scratch.own.get_or_insert_with(|| Box::new([0; 4096]));
scratch.padded.fill(region, sy, own);
let pad = &scratch.padded;
opaque_rows(pad, &self.tables.solid, &mut scratch.opaque);
let opaque = &scratch.opaque;
solid_rows(pad, &self.tables.solid, &mut scratch.solid_rows);
let opaque = &scratch.solid_rows;
out.visibility = visibility_rows(&open_rows(opaque));
let base_y = center.min_y() + sy * 16;
if self.leaf_culling.is_interior() {
@@ -1,4 +1,5 @@
use super::*;
use crate::chunk::Light;
#[test]
fn padded_reuse_resets_missing_neighbors_and_matches_chunk_reads_across_layouts() {
@@ -45,6 +46,65 @@ fn padded_reuse_resets_missing_neighbors_and_matches_chunk_reads_across_layouts(
}
}
#[test]
fn padded_fill_reads_every_neighbour_and_its_light_like_chunk_reads() {
let mut state = 0x2545_F491_4F6C_DD1Du64;
let mut next = move || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
};
let mut pad = Padded::default();
let mut own = Box::new([0; 4096]);
for min_y in [MIN_Y, 0] {
let layout = crate::chunk::ColumnLayout::new(min_y, 32 * 16).unwrap();
// Nine chunks with their own blocks and per-block light, near the
// bottom and top sections too, where the padding leaves the column.
let chunks: Vec<Chunk> = (0..9)
.map(|i| {
let mut c = Chunk::with_layout(i % 3 - 1, i / 3 - 1, layout);
for s in [0, 1, 30, 31] {
for _ in 0..300 {
let (x, y, z) = (next() % 16, next() % 16, next() % 16);
let y = min_y + s * 16 + y as i32;
c.set(x as i32, y, z as i32, 1 + (next() % 40) as u32);
}
let mut light = Box::new([0u8; 4096]);
light.iter_mut().for_each(|l| *l = next() as u8);
std::sync::Arc::make_mut(&mut c.sections[s as usize]).light =
Light::Full(light);
}
c
})
.collect();
let region: [[Option<&Chunk>; 3]; 3] =
std::array::from_fn(|rz| std::array::from_fn(|rx| Some(&chunks[rz * 3 + rx])));
for sy in [0, 1, 30, 31] {
pad.fill(&region, sy, &mut own);
let base_y = min_y + sy * 16;
for y in -B..16 + B {
for z in -B..16 + B {
for x in -B..16 + B {
let c = region[((z + 16) >> 4) as usize][((x + 16) >> 4) as usize].unwrap();
let (cx, cy, cz) = (x & 15, base_y + y, z & 15);
assert_eq!(
pad.blocks[pi(x, y, z)],
c.get(cx, cy, cz),
"{min_y} {sy} {x} {y} {z}"
);
assert_eq!(
pad.light[pi(x, y, z)],
c.light(cx, cy, cz),
"{min_y} {sy} {x} {y} {z}"
);
}
}
}
}
}
}
/// Run explicitly with prepared assets; never silently pass without exercising geometry.
/// With a shader pack lighting the faces, every face of a lone stone block has one colour; the
/// game's face shading (up 1.0, down 0.5, north/south 0.8, east/west 0.6) is left to the pack.
@@ -181,7 +241,7 @@ fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
assert!(scratch.own.is_none());
assert_eq!(scratch.padded.blocks.capacity(), 0);
assert_eq!(scratch.padded.light.capacity(), 0);
assert_eq!(scratch.opaque.capacity(), 0);
assert_eq!(scratch.solid_rows.capacity(), 0);
}
fresh.sort_solid();
recycled.sort_solid();
@@ -203,7 +203,8 @@ fn bucket(quad: &[TerrainVertex; 4]) -> usize {
/// Reorders `solid`'s quads by bucket, opaque ones first in each. Solid
/// cutout quads are also ordered front to back within each axis-facing
/// bucket; stable sorting preserves the mesher's order for coplanar overlays.
/// bucket; quads that tie keep the mesher's order (the quad's index ends its
/// sort key), so coplanar overlays still draw over their base.
pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
sort_parts(solid, GROUPED.load(Ordering::Relaxed))
}
@@ -213,9 +214,9 @@ fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
SORT_SCRATCH.with_borrow_mut(|scratch| sort_with(solid, grouped, scratch))
}
/// `sort`'s buffers, kept by each meshing thread: a section's quads went
/// through fresh vectors, and the sorted copy replaced the mesh's own
/// (recycled) buffer.
/// `sort`'s buffers, kept by each meshing thread so sorting a mesh allocates
/// nothing: each quad's part, its sort key, and the sorted quads, which are
/// copied back into the mesh's own (recycled) buffer.
#[derive(Default)]
struct SortScratch {
slots: Vec<u8>,
+148 -21
View File
@@ -1264,7 +1264,7 @@ pub enum Page {
Online,
/// Splitscreen: the profile another player joins as (`split_profiles`).
SplitProfiles,
/// Splitscreen: enable joining on this computer.
/// Splitscreen: the monitor map and draft window arrangement.
SplitSetup,
}
#[derive(Clone, Debug, PartialEq)]
@@ -1512,10 +1512,15 @@ pub struct OptionsUi {
pub calibration: Option<crate::hdr_calibration::Calibration>,
/// Whether the window was fullscreen before calibration made it so (the game keeps it).
pub calibration_fullscreen: Option<bool>,
pub split_setup: crate::split_ui::SplitUi,
/// The AI Agent page's status lines (set by the game while it shows).
pub agent_status: Vec<String>,
/// Keyboard focus: the interactive target currently focused.
focus: Option<FocusId>,
/// The focus came from keyboard or pad navigation, so the mouse wheel steps it
/// from anywhere (`wheel`). A click focuses its widget too, for the highlight,
/// but vanilla's wheel ignores that: only the hovered widget then reacts.
focus_from_keyboard: bool,
/// Whether a focused slider is in value-editing mode (`AbstractSliderButton.canChangeValue`).
pub(crate) can_change_value: bool,
/// The current GUI size (w, h).
@@ -1528,8 +1533,6 @@ pub struct OptionsUi {
/// The list's rows as last laid out: each row's top and bottom (GUI
/// pixels), for the narration's row under the mouse.
row_bands: Vec<[f32; 2]>,
/// Other local players are joined: leave before turning splitscreen off.
pub split_joined: bool,
}
impl Default for OptionsUi {
fn default() -> Self {
@@ -1568,14 +1571,15 @@ impl Default for OptionsUi {
split_profiles: Default::default(),
calibration: None,
calibration_fullscreen: None,
split_setup: Default::default(),
agent_status: Vec::new(),
focus: None,
focus_from_keyboard: false,
can_change_value: false,
size: (427.0, 240.0),
framebuffer: [1280, 720],
split_guest: false,
row_bands: Vec::new(),
split_joined: false,
}
}
}
@@ -2093,6 +2097,7 @@ impl OptionsUi {
self.stack.clear();
self.data_pack_dir = None;
self.focus = None;
self.focus_from_keyboard = false;
self.can_change_value = false;
self.push(page);
self.in_world = in_world;
@@ -2103,6 +2108,7 @@ impl OptionsUi {
self.scroll = 0.0;
self.scrollbar_drag_y = None;
self.focus = None;
self.focus_from_keyboard = false;
self.can_change_value = false;
if matches!(page, Page::Video | Page::Controls | Page::KeyBinds) {
self.settings_search.set_value("");
@@ -2122,6 +2128,9 @@ impl OptionsUi {
) {
self.graphics.reset();
}
if page == Page::SplitSetup {
self.split_setup.reset();
}
if page == Page::SplitProfiles {
self.split_profiles.reset();
}
@@ -2262,14 +2271,7 @@ impl OptionsUi {
fn list_rows(&self, s: &Settings) -> Vec<Row> {
match self.page() {
Page::SplitSetup => vec![Row::Buttons(
if self.split_guest || self.split_joined {
inactive(self.extra_button(s, crate::extras::Item::Splitscreen))
} else {
self.extra_button(s, crate::extras::Item::Splitscreen)
},
None,
)],
Page::SplitSetup => Vec::new(),
Page::Extras => {
// Each section's switches two to a row, as vanilla's
// OptionsList.addSmall lays out an options screen.
@@ -3346,6 +3348,11 @@ impl OptionsUi {
.draw(level, s, batch, atlas, cursor, self.in_world);
return;
}
if self.page() == Page::SplitSetup {
self.split_setup.on = s.extras.splitscreen;
self.split_setup.draw(batch, atlas, cursor, self.in_world);
return;
}
if self.page() == Page::SplitProfiles {
self.split_profiles
.draw(batch, atlas, cursor, self.in_world, self.pad_style);
@@ -3646,6 +3653,26 @@ impl OptionsUi {
}
return Vec::new();
}
if self.page() == Page::SplitSetup {
use crate::split_ui::Click;
if right {
return Vec::new();
}
self.split_setup.on = s.extras.splitscreen;
return match self.split_setup.click((x, y), size, drag) {
Click::Nothing => Vec::new(),
Click::Pressed => vec![Event::ButtonClick],
Click::Switch => {
crate::extras::Item::Splitscreen.cycle(&mut s.extras, false);
vec![Event::ButtonClick, Event::Changed]
}
Click::Done => {
let mut events = vec![Event::ButtonClick];
events.extend(self.back(s));
events
}
};
}
if self.page() == Page::SplitProfiles {
use crate::split_profiles::Click;
if right || drag {
@@ -3750,6 +3777,7 @@ impl OptionsUi {
search.focused = ui::hovered((x, y), search.x, search.y, search.width, search.height);
if search.focused {
self.focus = Some(FocusId::Search);
self.focus_from_keyboard = false;
self.can_change_value = false;
search.click(atlas, x, false, false);
self.scroll = 0.0;
@@ -3762,6 +3790,7 @@ impl OptionsUi {
search.focused = ui::hovered((x, y), search.x, search.y, search.width, search.height);
if search.focused {
self.focus = Some(FocusId::Search);
self.focus_from_keyboard = false;
self.can_change_value = false;
search.click(atlas, x, false, false);
self.scroll = 0.0;
@@ -3875,7 +3904,10 @@ impl OptionsUi {
if wd.list && (y < top || y >= bottom) {
continue;
}
// A click focuses the widget for its highlight only: vanilla's wheel
// steps a keyboard-focused widget from anywhere, not a clicked one.
self.focus = Some(focus_id(wd_idx, &wd));
self.focus_from_keyboard = false;
match wd.kind {
WKind::Button {
active: true, act, ..
@@ -4049,8 +4081,11 @@ impl OptionsUi {
let slider = self.dragging.take().is_some() | self.shader_dragging.take().is_some();
let graphics_slider = self.graphics.release();
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
let split_drop = self.split_setup.release();
if slider || graphics_slider || calibration_slider {
vec![Event::ButtonClick, Event::Changed]
} else if split_drop {
vec![Event::ButtonClick]
} else {
Vec::new()
}
@@ -4095,15 +4130,19 @@ impl OptionsUi {
}
events
}
/// The mouse wheel at the cursor. Over a cycle button it takes the step
/// (`CycleButton.mouseScrolled`: up goes back) and the list stays put;
/// over a slider it nudges the slider up or down.
/// The mouse wheel at the cursor. Vanilla scrolls the list
/// (`AbstractScrollArea.mouseScrolled`), except that a hovered cycle button
/// takes the step and keeps the wheel (`CycleButton.mouseScrolled`: up goes
/// back); `AbstractSliderButton` does not override it, so a slider under the
/// cursor lets the list scroll.
///
/// **Horizon improvement (diverges from Vanilla 26.3):** when a slider or
/// cycle button is keyboard-focused (highlighted), the wheel steps or
/// cycles that widget regardless of where the cursor is. Vanilla 26.3
/// does not intercept the wheel for sliders at all; this makes keyboard
/// navigation far more usable.
/// **Horizon improvement (diverges from Vanilla 26.3):** a slider or cycle
/// button focused by the keyboard (Tab, the arrows, a pad) steps or cycles on
/// the wheel wherever the cursor is. Vanilla 26.3 does not intercept the wheel
/// for sliders at all; this makes keyboard navigation far more usable. Focus
/// left by a click does not count (`focus_from_keyboard`): vanilla's wheel
/// ignores it, and honouring it toggled the clicked option on every later wheel
/// event, from anywhere on the screen, while the list never scrolled.
pub fn wheel(
&mut self,
s: &mut Settings,
@@ -4155,7 +4194,10 @@ impl OptionsUi {
}
// Horizon improvement: a keyboard-focused slider or cycle button
// intercepts the wheel before hover detection (see doc-comment above).
if amount != 0.0 {
// A click focuses its widget for the highlight too, and vanilla's wheel
// must not act on that: it would toggle the clicked option from anywhere
// on the screen and the list would never scroll.
if amount != 0.0 && self.focus_from_keyboard {
let (widgets, _, _, _) = self.layout(s, size.0, size.1);
match &self.focus {
Some(FocusId::Slider(opt)) => {
@@ -4341,6 +4383,7 @@ impl OptionsUi {
self.selected_key = None;
self.selected_pad_action = None;
self.focus = None;
self.focus_from_keyboard = false;
self.settings_search.focused = false;
self.language_search.focused = false;
self.can_change_value = false;
@@ -4684,6 +4727,7 @@ impl OptionsUi {
};
let (next_id, rect, in_list) = &focusables[next_idx];
self.focus = Some(next_id.clone());
self.focus_from_keyboard = true;
self.can_change_value = matches!(next_id, FocusId::Slider(_));
if *in_list {
if rect[1] < top {
@@ -4722,6 +4766,7 @@ impl OptionsUi {
if let Some(next_idx) = gamepad::focus_step_rect(from, dir, &rects) {
let (next_id, rect, in_list) = &focusables[next_idx];
self.focus = Some(next_id.clone());
self.focus_from_keyboard = true;
self.can_change_value = false;
if *in_list {
if rect[1] < top {
@@ -4735,6 +4780,7 @@ impl OptionsUi {
}
} else {
self.focus = Some(focusables[0].0.clone());
self.focus_from_keyboard = true;
self.can_change_value = false;
}
return Vec::new();
@@ -4757,6 +4803,7 @@ impl OptionsUi {
if let Some(next_idx) = step {
let (next_id, rect, in_list) = &focusables[next_idx];
self.focus = Some(next_id.clone());
self.focus_from_keyboard = true;
self.can_change_value = false;
if *in_list {
if rect[1] < top {
@@ -4774,6 +4821,7 @@ impl OptionsUi {
}
} else {
self.focus = Some(focusables[0].0.clone());
self.focus_from_keyboard = true;
self.can_change_value = false;
}
return Vec::new();
@@ -4797,6 +4845,7 @@ impl OptionsUi {
match act {
Act::Push(p) => {
self.focus = None;
self.focus_from_keyboard = false;
self.can_change_value = false;
if p == Page::ShaderPacks {
events.push(Event::ListShaderPacks);
@@ -4805,6 +4854,7 @@ impl OptionsUi {
}
Act::Done => {
self.focus = None;
self.focus_from_keyboard = false;
self.can_change_value = false;
events.extend(self.back(s));
}
@@ -4844,6 +4894,7 @@ impl OptionsUi {
}
Act::ModSettings(i) => {
self.focus = None;
self.focus_from_keyboard = false;
self.can_change_value = false;
self.push(Page::NativeMod(i));
events.push(Event::NativeSettings(i));
@@ -4868,6 +4919,7 @@ impl OptionsUi {
// The shader options page's buttons, as a click does them.
Act::ShaderOptions => {
self.focus = None;
self.focus_from_keyboard = false;
self.can_change_value = false;
s.shader_options = None;
self.push(Page::ShaderOptions);
@@ -4885,6 +4937,7 @@ impl OptionsUi {
Act::ShaderScreen(screen) => {
if let Some(options) = &mut s.shader_options {
self.focus = None;
self.focus_from_keyboard = false;
options.path.push(screen);
self.scroll = 0.0;
}
@@ -7147,6 +7200,80 @@ mod tests {
assert_eq!(s.horizon.chunk_updates, 11);
}
/// Vanilla's wheel scrolls the list (`AbstractScrollArea.mouseScrolled`); only a
/// widget focused by the keyboard takes the step from anywhere on the screen.
/// A click focuses its widget for the highlight, and honouring that focus made a
/// clicked option toggle on every later wheel event while the list never scrolled.
#[test]
fn a_clicked_option_lets_the_wheel_scroll_the_list() {
let atlas = atlas();
let mut s = Settings::default();
let mut ui = OptionsUi::default();
ui.show(Page::Video);
let framebuffer = [854, 480];
let click_size = (427.0, 400.0);
let (widgets, click_top, click_bottom, _) = ui.layout(&s, click_size.0, click_size.1);
// A click leaves the widget focused for its highlight, not for the wheel.
let wd = widgets
.iter()
.find(|wd| {
matches!(
wd.kind,
WKind::Button {
act: Act::Cycle(_),
..
}
) && (!wd.list || (wd.y >= click_top && wd.y + wd.h <= click_bottom))
})
.expect("a visible cycle button");
ui.click(
&mut s,
&atlas,
wd.x + 5.0,
wd.y + 5.0,
click_size,
framebuffer,
false,
false,
);
assert!(
matches!(ui.focus, Some(FocusId::Cycle(_))),
"the click focuses the cycle button it hit"
);
assert!(
!ui.focus_from_keyboard,
"a click does not leave keyboard focus"
);
// The wheel over the list's empty margin scrolls it at a size where the
// page overflows, and the clicked cycle option stays put: no ButtonClick,
// no Changed.
let size = (427.0, 240.0);
let (_, top, bottom, content) = ui.layout(&s, size.0, size.1);
assert!(
content > bottom - top,
"the video page scrolls at this size"
);
let before = s.view_bobbing;
let start = ui.scroll;
let cursor = (2.0, (top + bottom) / 2.0);
let events = ui.wheel(&mut s, size, cursor, framebuffer, -1.0);
assert_eq!(s.view_bobbing, before, "the clicked option does not toggle");
assert!(!events.contains(&Event::ButtonClick), "no click reaches it");
assert!(!events.contains(&Event::Changed));
assert!(ui.scroll > start, "the list scrolls down instead");
// Keyboard focus keeps the documented divergence: the wheel steps it
// wherever the cursor is, even off the widget.
ui.focus = Some(FocusId::Cycle(Opt::ViewBobbing));
ui.focus_from_keyboard = true;
let events = ui.wheel(&mut s, size, cursor, framebuffer, -1.0);
assert_ne!(s.view_bobbing, before, "a keyboard-focused option cycles");
assert!(events.contains(&Event::ButtonClick));
assert!(events.contains(&Event::Changed));
}
#[test]
fn options_step_slider_matches_set_slider_for_all_stepped_options() {
let stepped_opts = [
+487 -25
View File
@@ -1,5 +1,6 @@
//! Splitscreen's layout, pure data tested without a window: where each player's view goes in
//! its window (`place`).
//! its window (`place`), and the setup screen's draft (`Draft`): the windows, where each is, who
//! plays in each, before any of it is applied.
use winit::dpi::PhysicalSize;
/// Players at most: P1 and fifteen more.
@@ -25,30 +26,6 @@ const ROWS: [&[u32]; MAX_PLAYERS - 4] = [
&[4, 4, 4, 4],
];
/// Where a window is.
#[derive(Clone, Debug, PartialEq)]
pub enum Placement {
/// A normal window: its corner on the desktop and its size in pixels (`None`: a new one,
/// where the system opens it).
Free(Option<([i32; 2], [u32; 2])>),
/// Borderless over the monitor of this name.
Fill(String),
}
/// How far window `number` opens right and down of the centre, in logical pixels: Window 2
/// centred, each next one a step further, as systems cascade new windows (back to the centre
/// after six), so new windows never open exactly on one another.
pub fn cascade(number: u8) -> f64 {
f64::from(number.saturating_sub(2) % 6) * 32.0
}
/// A window: its number (1 is the game's own) and where it is.
#[derive(Clone, Debug, PartialEq)]
pub struct Window {
pub number: u8,
pub placement: Placement,
}
/// `order` made a whole order of `n` players: those it names (once, if there are), then the
/// others, P1 first.
pub fn ordered(order: &[usize], n: usize) -> Vec<usize> {
@@ -152,10 +129,310 @@ fn rows(n: usize, slot: u32, w: u32, h: u32) -> [u32; 4] {
}
}
/// A screen as the system reports it: its name, where it is on the desktop and its size (both
/// physical pixels), and its scale.
#[derive(Clone, Debug, PartialEq)]
pub struct Monitor {
pub name: String,
pub at: [i32; 2],
pub size: [u32; 2],
pub scale: f64,
}
impl Monitor {
/// Where it is on the desktop and its size, as windows are placed: macOS places them in
/// points, each monitor at its own scale; Windows and X11 in desktop pixels.
pub fn desktop(&self) -> [f64; 4] {
let k = if cfg!(target_os = "macos") {
self.scale
} else {
1.0
};
[
f64::from(self.at[0]) / k,
f64::from(self.at[1]) / k,
f64::from(self.size[0]) / k,
f64::from(self.size[1]) / k,
]
}
}
/// Where a window is.
#[derive(Clone, Debug, PartialEq)]
pub enum Placement {
/// A normal window: its corner on the desktop (`Monitor::desktop`'s units) and its size in
/// pixels (`None`: a new one, where the system opens it, `NEW_WINDOW` big).
Free(Option<([i32; 2], [u32; 2])>),
/// Borderless over the monitor of this name.
Fill(String),
}
/// A new free window's size, in logical pixels.
pub const NEW_WINDOW: [f64; 2] = [854.0, 480.0];
/// How far window `number` opens right and down of the centre, in logical pixels: Window 2
/// centred, each next one a step further, as systems cascade new windows (back to the centre
/// after six), so new windows never open exactly on one another.
pub fn cascade(number: u8) -> f64 {
f64::from(number.saturating_sub(2) % 6) * 32.0
}
/// A window: its number (1 is the game's own) and where it is.
#[derive(Clone, Debug, PartialEq)]
pub struct Window {
pub number: u8,
pub placement: Placement,
}
/// Where a player can go: a window there is (by number), a new free one, or a new one filling a
/// monitor (by name).
#[derive(Clone, Debug, PartialEq)]
pub enum Spot {
Window(u8),
New,
Fill(String),
}
/// Something to tell before Apply.
#[derive(Clone, Debug, PartialEq)]
pub enum Warning {
/// Nobody plays in this window: it won't open (Window 1 stays, blank).
Empty(u8),
/// This window fills a monitor that isn't there: its players play in Window 1.
Unplugged(u8, String),
/// This window fills the monitor Window 1 is on: its players play in Window 1.
OnWindow1(u8),
}
/// The setup screen's arrangement until Apply: the windows, each player's window (by number, P1
/// first) and the shapes two or three players sharing a window take (`place`'s `two`, `three`).
#[derive(Clone, Debug, PartialEq)]
pub struct Draft {
pub windows: Vec<Window>,
pub players: Vec<u8>,
pub two: u8,
pub three: u8,
/// The players (indices) in the order their views fill a window shared with others: first
/// left or on top. Empty, or missing some: P1, P2... (`ordered`).
pub order: Vec<usize>,
}
impl Draft {
/// Everyone in Window 1 (where it is now).
pub fn everyone_here(players: usize, window1: Placement, two: u8, three: u8) -> Self {
Self {
windows: vec![Window {
number: 1,
placement: window1,
}],
players: vec![1; players],
two,
three,
order: Vec::new(),
}
}
/// Players `i` and `j` trade places: their windows and their places in them.
pub fn swap(&mut self, i: usize, j: usize) {
let n = self.players.len();
if i >= n || j >= n || i == j {
return;
}
self.players.swap(i, j);
let mut order = ordered(&self.order, n);
let (a, b) = (
order.iter().position(|&p| p == i),
order.iter().position(|&p| p == j),
);
if let (Some(a), Some(b)) = (a, b) {
order.swap(a, b);
}
self.order = order;
}
/// Player `i` to place `k` among the others in its window (0: before them all; past the
/// last: after them), the others keeping their order: the bumpers' "Window 1 (2 of 3)".
pub fn put_at(&mut self, i: usize, k: usize) {
let n = self.players.len();
if i >= n {
return;
}
let mine = self.players[i];
let mut order: Vec<usize> = ordered(&self.order, n)
.into_iter()
.filter(|&p| p != i)
.collect();
let others: Vec<usize> = order
.iter()
.copied()
.filter(|&p| self.players[p] == mine)
.collect();
let at = match others.get(k) {
Some(j) => order.iter().position(|p| p == j).unwrap_or(order.len()),
None => others
.last()
.and_then(|j| order.iter().position(|p| p == j))
.map_or(order.len(), |p| p + 1),
};
order.insert(at, i);
self.order = order;
}
/// The players but `i` in window `number`, in their order.
pub fn sharing(&self, i: usize, number: u8) -> Vec<usize> {
ordered(&self.order, self.players.len())
.into_iter()
.filter(|&p| p != i && self.players[p] == number)
.collect()
}
/// A new free window, numbered with the lowest number free; its number.
pub fn add_window(&mut self) -> u8 {
self.open(Placement::Free(None))
}
fn open(&mut self, placement: Placement) -> u8 {
let number = (2..=u8::MAX)
.find(|n| self.windows.iter().all(|w| w.number != *n))
.unwrap_or(u8::MAX);
self.windows.push(Window { number, placement });
number
}
/// Player `i` to `spot`: a new window opens for it. A window it leaves empty stays in the
/// draft until `drop_empty` (at Apply), so numbers hold still while players move. A window
/// number the draft doesn't have is ignored.
pub fn place_player(&mut self, i: usize, spot: Spot) {
if i >= self.players.len() {
return;
}
let number = match spot {
Spot::Window(n) if self.windows.iter().any(|w| w.number == n) => n,
Spot::Window(_) => return,
Spot::New => self.add_window(),
Spot::Fill(name) => self.open(Placement::Fill(name)),
};
self.players[i] = number;
}
/// The windows nobody plays in, gone, but Window 1 and those in `keep`. At Apply, all of
/// them; while a draft is edited, the ones it added (the windows open now stay until Apply,
/// so their numbers hold still as players move).
pub fn drop_empty(&mut self, keep: &[u8]) {
let players = &self.players;
self.windows
.retain(|w| w.number == 1 || players.contains(&w.number) || keep.contains(&w.number));
}
/// Whether nobody plays in window `number`.
pub fn empty(&self, number: u8) -> bool {
!self.players.contains(&number)
}
/// What to tell before Apply, in window order.
pub fn warnings(&self, monitors: &[Monitor], main: &str) -> Vec<Warning> {
let mut out = Vec::new();
for w in &self.windows {
if let Placement::Fill(name) = &w.placement
&& !monitors.iter().any(|m| &m.name == name)
{
out.push(Warning::Unplugged(w.number, name.clone()));
} else if w.number != 1 && w.placement == Placement::Fill(main.to_owned()) {
out.push(Warning::OnWindow1(w.number));
} else if !self.players.contains(&w.number) {
out.push(Warning::Empty(w.number));
}
}
out
}
/// Where each window would be: its corner on the desktop (`Monitor::desktop`'s units) and its
/// size in pixels, in `windows` order: free ones where they were (new ones at `main`'s
/// corner), the others over their monitor; a window over a missing monitor or Window 1's
/// (its players play in Window 1, as the game does): `None`. Window 1 always has one: give
/// it its real rect (`Free(Some)`); else it is taken as a new window would open.
pub fn rects(&self, monitors: &[Monitor], main: &str) -> Vec<Option<([i32; 2], [u32; 2])>> {
let home = monitors
.iter()
.find(|m| m.name == main)
.or(monitors.first());
// Centred on Window 1's monitor, as the game opens a new window.
let new = |number: u8| {
let (at, scale, k) = home.map_or(([0.0; 4], 1.0, 1.0), |m| {
let at = m.desktop();
(at, m.scale, at[2] / f64::from(m.size[0].max(1)))
});
let size = NEW_WINDOW.map(|v| (v * scale).round() as u32);
let step = cascade(number) * scale * k;
let x = at[0] + (at[2] - f64::from(size[0]) * k) / 2.0 + step;
let y = at[1] + (at[3] - f64::from(size[1]) * k) / 2.0 + step;
([x as i32, y as i32], size)
};
self.windows
.iter()
.map(|w| {
let rect = match &w.placement {
Placement::Free(Some(rect)) => Some(*rect),
Placement::Free(None) => Some(new(w.number)),
Placement::Fill(name) if w.number != 1 && name == main => None,
Placement::Fill(name) => monitors.iter().find(|m| &m.name == name).map(|m| {
let at = m.desktop();
([at[0] as i32, at[1] as i32], m.size)
}),
};
// Everyone with nowhere else plays in Window 1: it never has no size.
if w.number == 1 {
rect.or_else(|| Some(new(1)))
} else {
rect
}
})
.collect()
}
/// Every player's view as Apply would make it (`place`, as the game tiles them), with
/// `View`'s window an index into `windows`; players of a window over a missing monitor in
/// Window 1's.
pub fn preview(&self, monitors: &[Monitor], main: &str) -> Vec<View> {
let rects = self.rects(monitors, main);
let index = |number: u8| self.windows.iter().position(|w| w.number == number);
let window1 = index(1).unwrap_or(0);
let windows: Vec<usize> = self
.players
.iter()
.map(|&n| index(n).filter(|&i| rects[i].is_some()).unwrap_or(window1))
.collect();
let sizes: Vec<PhysicalSize<u32>> = rects
.iter()
.map(|r| {
r.map_or(PhysicalSize::new(1, 1), |(_, [w, h])| {
PhysicalSize::new(w, h)
})
})
.collect();
place_ordered(&windows, &self.order, &sizes, self.two, self.three)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn window_1_always_has_a_size_and_unknown_windows_take_nobody() {
let monitors = [Monitor {
name: "Main".into(),
at: [0, 0],
size: [1920, 1080],
scale: 1.0,
}];
let mut d = Draft::everyone_here(2, Placement::Fill("Gone".into()), 0, 0);
assert!(d.rects(&monitors, "Main")[0].is_some());
assert!(d.preview(&monitors, "Main").iter().all(|v| v.2.width > 1));
d.place_player(1, Spot::Window(9));
assert_eq!(d.players, [1, 1], "no Window 9 in the draft");
}
fn size(w: u32, h: u32) -> PhysicalSize<u32> {
PhysicalSize::new(w, h)
}
@@ -272,6 +549,97 @@ mod tests {
)
);
}
let mut draft = here(16);
draft.place_player(15, Spot::New);
let views = draft.preview(&monitors(), "Built-in");
assert_eq!(views.len(), 16);
assert_ne!(views[0].0, views[15].0);
}
fn monitors() -> Vec<Monitor> {
vec![
Monitor {
name: "Built-in".into(),
at: [0, 0],
size: [3024, 1964],
scale: 2.0,
},
Monitor {
name: "TV".into(),
at: [3024, 0],
size: [3840, 2160],
scale: 1.0,
},
]
}
/// P1 in Window 1, everyone else in Window 2 filling the TV.
trait DeskAndTv {
fn desk_and_tv(self, monitors: &[Monitor], main: &str) -> Option<Draft>;
}
impl DeskAndTv for Draft {
fn desk_and_tv(mut self, monitors: &[Monitor], main: &str) -> Option<Draft> {
let tv = monitors.iter().find(|m| m.name != main)?;
self.windows.push(Window {
number: 2,
placement: Placement::Fill(tv.name.clone()),
});
for p in self.players.iter_mut().skip(1) {
*p = 2;
}
Some(self)
}
}
fn here(players: usize) -> Draft {
Draft::everyone_here(
players,
Placement::Free(Some(([100, 100], [2000, 1200]))),
0,
0,
)
}
#[test]
fn presets() {
let m = monitors();
// Everyone here: four quarters of Window 1.
let views = here(4).preview(&m, "Built-in");
assert_eq!(views[3], (0, [1000, 600], size(1000, 600)));
// P1 alone, the others share the TV in rows.
let tv = here(4).desk_and_tv(&m, "Built-in").unwrap();
assert_eq!(tv.players, [1, 2, 2, 2]);
assert_eq!(tv.windows[1].placement, Placement::Fill("TV".into()));
let views = tv.preview(&m, "Built-in");
assert_eq!(views[0], (0, [0, 0], size(2000, 1200)));
assert_eq!(views[1], (1, [0, 0], size(3840, 1080)));
}
#[test]
fn new_windows_cascade_instead_of_stacking() {
let m = monitors();
let mut d = here(3);
d.place_player(1, Spot::New);
d.place_player(2, Spot::New);
let rects = d.rects(&m, "Built-in");
let ((a, _), (b, _)) = (rects[1].unwrap(), rects[2].unwrap());
// Window 3 a step right and down of Window 2, on the same diagonal.
assert!(b[0] > a[0] && b[0] - a[0] == b[1] - a[1]);
assert_eq!((cascade(2), cascade(3), cascade(8)), (0.0, 32.0, 0.0));
}
#[test]
fn put_at_a_place_among_a_window_s_players() {
let mut d = here(3);
d.put_at(2, 0);
assert_eq!(d.order, [2, 0, 1]);
d.put_at(2, 5); // past the last: after them
assert_eq!(d.order, [0, 1, 2]);
// Players in another window keep their places.
d.players = vec![1, 2, 1];
d.put_at(2, 0);
assert_eq!(d.sharing(2, 1), [0]);
assert_eq!(d.order, [2, 0, 1]);
}
#[test]
@@ -293,4 +661,98 @@ mod tests {
let views = place_ordered(&[0, 0, 0], &[2], &wide, 0, 0);
assert_eq!(views[2].2, size(2000, 500));
}
#[test]
fn two_players_swap_in_a_window_and_between_windows() {
let m = monitors();
// In one window: P1 and P2 trade sides.
let mut d = here(2);
d.swap(0, 1);
assert_eq!(d.order, [1, 0]);
let views = d.preview(&m, "Built-in");
assert!(views[1].1[0] < views[0].1[0], "P2 left of P1");
// Between windows: P2 on the TV, P3 in Window 1; they trade windows.
let mut d = here(3);
d.place_player(1, Spot::Fill("TV".into()));
d.swap(1, 2);
assert_eq!(d.players, [1, 1, 2]);
}
#[test]
fn players_placed() {
let m = monitors();
let mut d = here(3);
d.place_player(1, Spot::Fill("TV".into()));
d.place_player(2, Spot::Window(2));
assert_eq!(d.players, [1, 2, 2]);
d.place_player(2, Spot::New);
assert_eq!(d.players, [1, 2, 3]);
assert!(d.warnings(&m, "Built-in").is_empty());
// The window left behind stays while the draft is edited (numbers hold still), and
// closes at Apply; Window 1 never.
d.place_player(1, Spot::Window(1));
let numbers = |d: &Draft| d.windows.iter().map(|w| w.number).collect::<Vec<_>>();
assert_eq!(numbers(&d), [1, 2, 3]);
assert!(d.empty(2));
d.drop_empty(&[]);
assert_eq!(numbers(&d), [1, 3]);
assert!(d.warnings(&m, "Built-in").is_empty());
d.place_player(0, Spot::Window(3));
d.place_player(1, Spot::Window(3));
assert_eq!(d.warnings(&m, "Built-in"), [Warning::Empty(1)]);
}
#[test]
fn windows_numbered_lowest_free() {
let mut d = here(3);
assert_eq!(d.add_window(), 2);
assert_eq!(d.add_window(), 3);
d.windows.retain(|w| w.number != 2);
assert_eq!(d.add_window(), 2);
// Nobody to place: no window opens for it.
d.place_player(9, Spot::New);
assert_eq!(d.windows.len(), 3);
}
#[test]
fn a_window_over_window_1s_monitor() {
let m = monitors();
let mut d = here(2);
d.place_player(1, Spot::Fill("Built-in".into()));
assert_eq!(d.warnings(&m, "Built-in"), [Warning::OnWindow1(2)]);
// As the game does: P2 beside P1 in Window 1.
assert_eq!(d.preview(&m, "Built-in")[1].0, 0);
}
#[test]
fn warnings_and_an_unplugged_monitor() {
let m = monitors();
let mut d = here(2).desk_and_tv(&m, "Built-in").unwrap();
let three = d.add_window();
assert_eq!(d.warnings(&m, "Built-in"), [Warning::Empty(three)]);
// The TV unplugged: its player plays in Window 1 beside P1.
assert_eq!(
d.warnings(&m[..1], "Built-in"),
[Warning::Unplugged(2, "TV".into()), Warning::Empty(three)]
);
let views = d.preview(&m[..1], "Built-in");
assert_eq!(views[1], (0, [1000, 0], size(1000, 1200)));
// P1 elsewhere: Window 1 is blank.
d.players = vec![2, 2];
assert_eq!(d.warnings(&m, "Built-in")[0], Warning::Empty(1));
}
#[test]
fn eight_players_two_windows() {
let m = monitors();
let d = here(8).desk_and_tv(&m, "Built-in").unwrap();
let views = d.preview(&m, "Built-in");
// Seven on the TV: rows of 3, 2 and 2, the whole TV covered.
let area: u64 = views[1..]
.iter()
.map(|v| u64::from(v.2.width) * u64::from(v.2.height))
.sum();
assert_eq!(area, 3840 * 2160);
assert!(views[1..].iter().all(|v| v.0 == 1));
}
}
@@ -0,0 +1,166 @@
//! Project a draft arrangement onto the Setup screen, without opening a window.
use crate::split_layout::{Draft, Monitor};
/// Map area: below the title and above the move row and footer.
pub const TOP: f32 = 30.0;
pub const BOTTOM: f32 = 80.0;
pub type Rect = [f32; 4];
pub type Map = (Vec<Rect>, Vec<(u8, Rect)>, Vec<(u8, Rect)>);
/// Monitor, window and player rectangles in UI coordinates, in their drawing order.
pub fn project(draft: &Draft, monitors: &[Monitor], main: &str, w: f32, h: f32) -> Map {
let desk: Vec<[f64; 4]> = monitors.iter().map(Monitor::desktop).collect();
// A window's size on the desktop: its pixels at the scale of the monitor it is on.
let rects = draft.rects(monitors, main);
let on_desk: Vec<(u8, [f64; 4])> = draft
.windows
.iter()
.zip(&rects)
.filter_map(|(win, rect)| {
let ([x, y], [pw, ph]) = (*rect)?;
let (x, y) = (f64::from(x), f64::from(y));
let scale = monitors
.iter()
.find(|m| {
let [mx, my, mw, mh] = m.desktop();
x >= mx && x < mx + mw && y >= my && y < my + mh
})
.or_else(|| monitors.iter().find(|m| m.name == main))
.map_or(1.0, |m| m.desktop()[2] / f64::from(m.size[0]));
Some((
win.number,
[x, y, f64::from(pw) * scale, f64::from(ph) * scale],
))
})
.collect();
// The monitors, and windows past them (Wayland's, whose place is unknown) still shown.
let all = || desk.iter().chain(on_desk.iter().map(|(_, d)| d));
let x0 = all().map(|d| d[0]).fold(f64::MAX, f64::min);
let y0 = all().map(|d| d[1]).fold(f64::MAX, f64::min);
let x1 = all().map(|d| d[0] + d[2]).fold(f64::MIN, f64::max);
let y1 = all().map(|d| d[1] + d[3]).fold(f64::MIN, f64::max);
if desk.is_empty() || x1 <= x0 || y1 <= y0 {
return Default::default();
}
let (aw, ah) = (
f64::from(w) - 40.0,
// Room under the monitors for their names, and always for the move row: the map
// doesn't jump when a player is picked (a drop back to where its tile was misses).
(f64::from(h - TOP - BOTTOM) - 18.0).max(10.0),
);
let k = (aw / (x1 - x0)).min(ah / (y1 - y0));
let (ox, oy) = (
(f64::from(w) - (x1 - x0) * k) / 2.0,
f64::from(TOP) + 4.0 + (ah - (y1 - y0) * k) / 2.0,
);
let to_map = |[x, y, dw, dh]: [f64; 4]| {
[
(ox + (x - x0) * k) as f32,
(oy + (y - y0) * k) as f32,
(dw * k) as f32,
(dh * k) as f32,
]
};
let mapped_monitors = desk.iter().copied().map(to_map).collect();
let windows: Vec<(u8, [f32; 4])> = on_desk.iter().map(|&(n, d)| (n, to_map(d))).collect();
// In their own order, emptied ones too: a window a player was just dragged out of stays
// where it was until Apply closes it, so the player can be dragged back into it.
let tiles = draft
.preview(monitors, main)
.iter()
.map(|&(i, [vx, vy], size)| {
let number = draft.windows.get(i).map_or(1, |w| w.number);
let Some((_, [wx, wy, ww, wh])) = windows.iter().find(|(n, _)| *n == number) else {
return (number, [0.0; 4]);
};
let [pw, ph] = rects[i].map_or([1, 1], |r| r.1).map(|v| v.max(1) as f32);
(
number,
[
wx + vx as f32 / pw * ww,
wy + vy as f32 / ph * wh,
size.width as f32 / pw * ww,
size.height as f32 / ph * wh,
],
)
})
.collect();
(mapped_monitors, windows, tiles)
}
/// The largest visible side of a tile after later windows cover it.
pub fn uncovered(mut r: [f32; 4], covers: impl Iterator<Item = [f32; 4]>) -> [f32; 4] {
for [cx, cy, cw, ch] in covers {
let [x, y, w, h] = r;
if cx >= x + w || cx + cw <= x || cy >= y + h || cy + ch <= y {
continue;
}
let sides = [
[x, y, cx - x, h],
[cx + cw, y, x + w - (cx + cw), h],
[x, y, w, cy - y],
[x, cy + ch, w, y + h - (cy + ch)],
];
r = sides
.into_iter()
.map(|[sx, sy, sw, sh]| [sx, sy, sw.max(0.0), sh.max(0.0)])
.max_by(|a, b| (a[2] * a[3]).total_cmp(&(b[2] * b[3])))
.unwrap_or([x, y, 0.0, 0.0]);
}
r
}
#[cfg(test)]
mod tests {
use super::*;
use crate::split_layout::{Placement, Spot};
#[test]
fn preview_tiles_share_a_window_and_fallback_when_a_monitor_disappears() {
let monitors = vec![
Monitor {
name: "Desk".into(),
at: [0, 0],
size: [2000, 1200],
scale: 1.0,
},
Monitor {
name: "TV".into(),
at: [-1920, 0],
size: [1920, 1080],
scale: 1.0,
},
];
let mut draft =
Draft::everyone_here(2, Placement::Free(Some(([0, 0], [2000, 1200]))), 0, 0);
let (_, windows, tiles) = project(&draft, &monitors, "Desk", 427.0, 300.0);
assert_eq!(windows.len(), 1);
let (a, b) = (tiles[0].1, tiles[1].1);
assert!((a[2] - b[2]).abs() < 0.01);
assert!((a[0] + a[2] - b[0]).abs() < 0.01);
draft.place_player(1, Spot::Fill("TV".into()));
let (screens, windows, tiles) = project(&draft, &monitors, "Desk", 427.0, 300.0);
assert!(screens[1][0] < screens[0][0]);
assert_eq!(windows.len(), 2);
assert_eq!((tiles[0].0, tiles[1].0), (1, 2));
let (_, windows, tiles) = project(&draft, &monitors[..1], "Desk", 427.0, 300.0);
assert_eq!(windows.len(), 1);
assert_eq!((tiles[0].0, tiles[1].0), (1, 1));
assert!(project(&draft, &[], "Desk", 427.0, 300.0).0.is_empty());
}
#[test]
fn covered_tiles_only_expose_a_visible_side_for_clicks() {
let tile = [0.0, 0.0, 100.0, 100.0];
assert_eq!(
uncovered(tile, [[0.0, 0.0, 60.0, 100.0]].into_iter()),
[60.0, 0.0, 40.0, 100.0]
);
let hidden = uncovered(tile, [tile].into_iter());
assert_eq!(hidden[2] * hidden[3], 0.0);
assert_eq!(
uncovered(tile, [[110.0, 0.0, 50.0, 50.0]].into_iter()),
tile
);
}
}
File diff suppressed because it is too large. Load diff
+56 -8
View File
@@ -25,6 +25,40 @@ use std::{
/// generator's scratch buffers ([`ChunkGenerator::idle`]).
pub const IDLE_RELEASE: std::time::Duration = std::time::Duration::from_secs(5);
/// Worker CPU time and job counts by kind, for benchmarks
/// (`examples/gen_light_bench.rs`). Off unless enabled: a relaxed load per
/// job then.
pub mod job_stats {
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
pub const KINDS: [&str; 7] = [
"load", "generate", "decorate", "finish", "light", "mesh", "other",
];
static ENABLED: AtomicBool = AtomicBool::new(false);
static NANOS: [AtomicU64; 7] = [const { AtomicU64::new(0) }; 7];
static COUNT: [AtomicU64; 7] = [const { AtomicU64::new(0) }; 7];
pub fn enable(on: bool) {
ENABLED.store(on, Ordering::Relaxed);
}
pub(super) fn enabled() -> bool {
ENABLED.load(Ordering::Relaxed)
}
pub(super) fn add(kind: usize, nanos: u64) {
NANOS[kind].fetch_add(nanos, Ordering::Relaxed);
COUNT[kind].fetch_add(1, Ordering::Relaxed);
}
/// (kind, nanoseconds, count) since the last call; resets them.
pub fn take() -> Vec<(&'static str, u64, u64)> {
(0..KINDS.len())
.map(|i| {
(
KINDS[i],
NANOS[i].swap(0, Ordering::Relaxed),
COUNT[i].swap(0, Ordering::Relaxed),
)
})
.collect()
}
}
/// A chunk under generation, opaque to the streamer.
pub type Proto = Box<dyn std::any::Any + Send>;
/// Chunk generation in vanilla's stages: a neighbour-independent proto chunk
@@ -716,6 +750,17 @@ impl<G: ChunkGenerator> Source for Jobs<G> {
let Some(job) = self.shared.queue.lock().unwrap().pop() else {
return false;
};
let started = job_stats::enabled().then(|| {
let kind = match &job.job {
Job::Generate(..) => 1,
Job::Decorate(..) => 2,
Job::Finish(..) => 3,
Job::Light(..) => 4,
Job::Mesh(..) => 5,
_ => 6,
};
(kind, crate::spans::thread_cpu().unwrap_or_default())
});
let done = self.locals.with(
worker,
|| WorkerLocal {
@@ -725,6 +770,16 @@ impl<G: ChunkGenerator> Source for Jobs<G> {
},
|local| self.run_job(job.job, local),
);
if let Some((kind, started)) = started {
// A generation job that found the column saved loaded it.
let kind = if kind == 1 && matches!(done, Some(Done::Generated(_))) {
0
} else {
kind
};
let cpu = crate::spans::thread_cpu().unwrap_or_default();
job_stats::add(kind, cpu.saturating_sub(started).as_nanos() as u64);
}
if let Some(done) = done {
self.shared.done.send(done);
}
@@ -2627,14 +2682,7 @@ fn keep_unchanged_sections(
}
/// Whether two sections' packed light (4,096 cells) holds the same values.
fn same_light(a: &crate::chunk::Light, b: &crate::chunk::Light) -> bool {
use crate::chunk::Light;
match (a, b) {
(Light::Uniform(x), Light::Uniform(y)) => x == y,
(Light::Full(x), Light::Full(y)) => x == y,
(Light::Uniform(v), Light::Full(f)) | (Light::Full(f), Light::Uniform(v)) => {
f.iter().all(|c| c == v)
}
}
a.same_as(b)
}
/// Whether two biome containers (64 cells) hold the same values.
fn same_values(
@@ -510,6 +510,19 @@ fn solid_finish(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>) -> vec4<f32>
}
return apply_fog(vec4(color.rgb, emissive), in.spherical, in.cylindrical);
}
// HDR lighting on what's mostly opaque among translucent faces (water), drawn again after
// them with only alpha written: `fs_translucent` blends its alpha as coverage, so a lake's
// value would be diluted. Clear glass leaves what's behind it, light sources included, alone.
@fragment fn fs_translucent_mark(in: TerrainOut) -> @location(0) vec4<f32> {
let dx = dpdx(in.local);
let dy = dpdy(in.local);
hdr_normal = normalize(cross(dy, dx));
if sprite(in.uv, in.layer & 0x7fffu).a < 0.6 { discard; }
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
let scene = mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()), globals.hdr_light.y, fogged);
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(scene));
}
// Water, glass and other see-through faces draw whole as they fade in, the
// layer drawn first in front (gfx.rs's water tiers): dithered, the faded-out
// pixels showed the layer behind through the one in front, twice the water.
+3
View File
@@ -114,6 +114,9 @@ audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace", "horizon-client-lib/rng-trace"]
# Rust's allocations go to the system heap instead of mimalloc, where heap profilers that replace
# malloc (heaptrack, valgrind's massif) see them. For profiling only: mimalloc is faster.
system-allocator = []
# Compatibility with earlier experiment commands: main already uses mimalloc.
windows-mimalloc = []
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
@@ -35,16 +35,16 @@
"horizon.pages.settings": "Horizon Settings",
"horizon.pages.agent": "AI Agent",
"horizon.pages.split_settings": "Splitscreen Settings",
"horizon.pages.split_setup": "Splitscreen",
"horizon.pages.split_profiles": "Who's playing?",
"horizon.pages.split_setup": "Splitscreen Setup",
"horizon.pages.settings.tooltip": "Horizon's own settings: graphics, controllers, extras and mods.",
"horizon.pages.graphics.tooltip": "Graphics, simply: each setting says what it costs.",
"horizon.pages.graphics_advanced.tooltip": "Every graphics setting, Horizon's included, with what each costs.",
"horizon.pages.controller.tooltip": "Controllers: their buttons, sticks, vibration and menu navigation.",
"horizon.pages.extras.tooltip": "Optional additions, all off by default: the game stays vanilla until you turn one on.",
"horizon.pages.agent.tooltip": "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
"horizon.pages.split_setup.tooltip": "Players on this computer: controllers can join your world.",
"horizon.pages.split_profiles.tooltip": "Who plays: a profile kept in this game folder.",
"horizon.pages.split_setup.tooltip": "The windows and monitors, and who plays where.",
"horizon.pages.shader_packs.tooltip": "Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time.",
"horizon.pages.mods.tooltip": "Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on.",
"horizon.pages.open.graphics": "Graphics...",
@@ -306,6 +306,55 @@
"horizon.extras.chat_animation.tooltip": "A new chat line slides in from the left while it fades in, instead of appearing whole.",
"horizon.extras.chat_indicator.tooltip": "The coloured bar at the left of a chat line.",
"horizon.extras.splitscreen.tooltip": "In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play.",
"horizon.splitscreen.setup.monitor": "Monitor %s",
"horizon.splitscreen.setup.monitor_unknown": "a monitor",
"horizon.splitscreen.setup.window": "Window %s",
"horizon.splitscreen.setup.new_window": "New window",
"horizon.splitscreen.setup.fill_monitor": "Fill %s",
"horizon.splitscreen.setup.done": "Done",
"horizon.splitscreen.setup.switch": "%s: %s",
"horizon.splitscreen.setup.on": "On",
"horizon.splitscreen.setup.off": "Off",
"horizon.splitscreen.setup.player": "Player %s",
"horizon.splitscreen.setup.revert": "Revert",
"horizon.splitscreen.setup.apply": "Apply",
"horizon.splitscreen.setup.warning.empty_main": "Nobody plays in Window 1: it hides",
"horizon.splitscreen.setup.warning.empty_close": "Nobody plays in Window %s: it closes on Apply",
"horizon.splitscreen.setup.warning.empty_new": "Nobody plays in Window %s: it won't open",
"horizon.splitscreen.setup.warning.on_main": "Window %s fills Window 1's monitor: its players play in Window 1",
"horizon.splitscreen.setup.warning.unplugged": "Window %s's monitor is not connected: its players play in Window 1",
"horizon.splitscreen.setup.status.off": "Splitscreen is off.",
"horizon.splitscreen.setup.status.enable_hint": "Turn it on, then press a button on another controller in a world.",
"horizon.splitscreen.setup.status.join_hint": "Press A to join and pick a profile.",
"horizon.splitscreen.setup.status.idle": "Splitscreen idle",
"horizon.splitscreen.setup.status.alone": "No other local players.",
"horizon.splitscreen.setup.status.solo_controls": "Solo controls and controller feedback are unchanged.",
"horizon.splitscreen.setup.window_empty": "Window %s: empty",
"horizon.splitscreen.setup.note.select": "Click a player, or drag it to a window or a monitor.",
"horizon.splitscreen.setup.note.apply": "Apply to play this way, Revert to undo.",
"horizon.splitscreen.setup.note.place": "Where should %s play?",
"horizon.splitscreen.setup.this_player": "this player",
"horizon.splitscreen.setup.switch_disabled": "Splitscreen turns off once the others have left (their pause menu).",
"horizon.splitscreen.setup.window_place": "Window %s (%s of %s)",
"horizon.splitscreen.setup.names": "Names",
"horizon.splitscreen.setup.identify": "Identify",
"horizon.splitscreen.setup.layout.two": "2 sharing: %s",
"horizon.splitscreen.setup.layout.three": "3 sharing: %s",
"horizon.splitscreen.setup.layout.two.0": "Auto",
"horizon.splitscreen.setup.layout.two.1": "Side by side",
"horizon.splitscreen.setup.layout.two.2": "Top and bottom",
"horizon.splitscreen.setup.layout.three.0": "One wide on top",
"horizon.splitscreen.setup.layout.three.1": "One tall on the left",
"horizon.splitscreen.setup.layout.three.2": "Side by side",
"horizon.splitscreen.setup.help.player": "Click or drag %s to move them.",
"horizon.splitscreen.setup.help.layout_auto": "%s (side by side when the window is wide). Press to change.",
"horizon.splitscreen.setup.help.layout": "%s. Press to change.",
"horizon.splitscreen.setup.help.switch": "Splitscreen for this computer, on or off.",
"horizon.splitscreen.setup.help.identify": "Each player's view shows which window it is in.",
"horizon.splitscreen.setup.help.names": "Each player's name in the corner of their view.",
"horizon.splitscreen.setup.help.revert": "Undo the changes not applied yet.",
"horizon.splitscreen.setup.help.apply": "Play the way the map shows.",
"horizon.splitscreen.setup.help.unchanged": "Nothing changed yet.",
"Culling": "Culling",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Hides models behind opaque blocks. Async checks off-thread; entities tick.",
"Leaf Culling": "Leaf Culling",
@@ -289,6 +289,60 @@
"horizon.multiplayer.invalid_address": "Adresse de serveur invalide : %s",
"horizon.multiplayer.connected_friend_direct": "Connecté directement à votre ami...",
"horizon.multiplayer.connected_friend_relay": "Connecté à votre ami via le relais...",
"horizon.splitscreen.setup.monitor": "Écran %s",
"horizon.splitscreen.setup.monitor_unknown": "un écran",
"horizon.splitscreen.setup.window": "Fenêtre %s",
"horizon.splitscreen.setup.new_window": "Nouvelle fenêtre",
"horizon.splitscreen.setup.fill_monitor": "Remplir %s",
"horizon.splitscreen.setup.done": "Terminé",
"horizon.splitscreen.setup.switch": "%s : %s",
"horizon.splitscreen.setup.on": "Activé",
"horizon.splitscreen.setup.off": "Désactivé",
"horizon.splitscreen.setup.player": "Joueur %s",
"horizon.splitscreen.setup.revert": "Rétablir",
"horizon.splitscreen.setup.apply": "Appliquer",
"horizon.splitscreen.setup.warning.empty_main": "La fenêtre 1 se masque si personne n'y joue.",
"horizon.splitscreen.setup.warning.empty_close": "Personne ne joue dans la fenêtre %s : elle se fermera après application.",
"horizon.splitscreen.setup.warning.empty_new": "Personne ne joue dans la fenêtre %s : elle ne s'ouvrira pas.",
"horizon.splitscreen.setup.warning.on_main": "La fenêtre %s occupe l'écran de la fenêtre 1 : ses joueurs restent dans la fenêtre 1.",
"horizon.splitscreen.setup.warning.unplugged": "L'écran de la fenêtre %s est débranché : ses joueurs restent dans la fenêtre 1.",
"horizon.splitscreen.setup.status.off": "L'écran partagé est désactivé.",
"horizon.splitscreen.setup.status.enable_hint": "Activez-le, puis appuyez sur un bouton d'une autre manette dans un monde.",
"horizon.splitscreen.setup.status.join_hint": "Appuyez sur A pour rejoindre et choisir un profil.",
"horizon.splitscreen.setup.status.idle": "Écran partagé en attente",
"horizon.splitscreen.setup.status.alone": "Aucun autre joueur local.",
"horizon.splitscreen.setup.status.solo_controls": "Les commandes et les vibrations en solo restent inchangées.",
"horizon.splitscreen.setup.window_empty": "Fenêtre %s : vide",
"horizon.splitscreen.setup.note.select": "Cliquez sur un joueur, ou faites-le glisser vers une fenêtre ou un écran.",
"horizon.splitscreen.setup.note.apply": "Appliquez les modifications ou annulez-les.",
"horizon.splitscreen.setup.note.place": "Où %s doit-il jouer ?",
"horizon.splitscreen.setup.this_player": "ce joueur",
"horizon.splitscreen.setup.switch_disabled": "L'écran partagé peut être désactivé après le départ des autres joueurs depuis leur menu pause.",
"horizon.pages.split_setup": "Configuration de l'écran partagé",
"horizon.pages.split_setup.tooltip": "Les fenêtres, les écrans et la place de chaque joueur.",
"horizon.pages.open.split_setup": "Écran partagé...",
"horizon.pages.split_settings": "Options de l'écran partagé",
"horizon.extras.splitscreen": "Écran partagé",
"horizon.splitscreen.setup.window_place": "Fenêtre %s (%s sur %s)",
"horizon.splitscreen.setup.names": "Noms",
"horizon.splitscreen.setup.identify": "Identifier",
"horizon.splitscreen.setup.layout.two": "2 par fenêtre : %s",
"horizon.splitscreen.setup.layout.three": "3 par fenêtre : %s",
"horizon.splitscreen.setup.layout.two.0": "Auto",
"horizon.splitscreen.setup.layout.two.1": "Côte à côte",
"horizon.splitscreen.setup.layout.two.2": "Haut et bas",
"horizon.splitscreen.setup.layout.three.0": "Un large en haut",
"horizon.splitscreen.setup.layout.three.1": "Un haut à gauche",
"horizon.splitscreen.setup.layout.three.2": "Côte à côte",
"horizon.splitscreen.setup.help.player": "Cliquez sur %s ou faites-le glisser pour le déplacer.",
"horizon.splitscreen.setup.help.layout_auto": "%s (côte à côte si la fenêtre est large). Appuyez pour changer.",
"horizon.splitscreen.setup.help.layout": "%s. Appuyez pour changer.",
"horizon.splitscreen.setup.help.switch": "L'écran partagé sur cet ordinateur, activé ou désactivé.",
"horizon.splitscreen.setup.help.identify": "La vue de chaque joueur indique sa fenêtre.",
"horizon.splitscreen.setup.help.names": "Le nom de chaque joueur dans le coin de sa vue.",
"horizon.splitscreen.setup.help.revert": "Annuler les modifications pas encore appliquées.",
"horizon.splitscreen.setup.help.apply": "Jouer comme le montre la carte.",
"horizon.splitscreen.setup.help.unchanged": "Rien n'a encore changé.",
"Culling": "Occlusion",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Masque les modèles derrière les blocs opaques. Vérification en arrière-plan ; les entités continuent.",
"Leaf Culling": "Occlusion feuillage",
@@ -329,12 +383,7 @@
"horizon.extras.header.sound": "Son",
"horizon.extras.hide_block_particles": "Masquer particules",
"horizon.extras.hide_block_particles.tooltip": "Masque les débris qui jaillissent d'un bloc cassé et s'effritent de celui que vous minez. Les autres particules restent.",
"horizon.extras.splitscreen": "Écran partagé",
"horizon.extras.splitscreen.tooltip": "Dans un monde, une autre manette peut rejoindre en tant que nouveau joueur : appuyez sur A quand c'est demandé, puis choisissez un profil et où jouer.",
"horizon.extras.ui_sound_reserve": "Canaux pour sons UI",
"horizon.extras.ui_sound_reserve.tooltip": "Les sons du monde laissent 16 des 247 canaux sonores libres, pour qu'un déluge de sons (une grosse explosion) ne couvre pas les clics de menu.",
"horizon.pages.open.split_setup": "Écran partagé...",
"horizon.pages.split_settings": "Options de l'écran partagé",
"horizon.pages.split_setup": "Écran partagé",
"horizon.pages.split_setup.tooltip": "Joueurs sur cet ordinateur : des manettes peuvent rejoindre votre monde."
"horizon.extras.ui_sound_reserve.tooltip": "Les sons du monde laissent 16 des 247 canaux sonores libres, pour qu'un déluge de sons (une grosse explosion) ne couvre pas les clics de menu."
}
+9 -1
View File
@@ -24,7 +24,15 @@ fn main() {
resource.set_manifest_file(&manifest);
}
if let Err(e) = resource.compile() {
panic!("embedding Windows resources: {e}");
// The GNU toolchain's `windres` (the launcher's mod builds use it) can't take a path
// with a space, and a Windows user name often has one: such a build goes on without
// the icon. Release builds run in CI, where the icon stays required.
if std::env::var_os("CI").is_some() {
panic!("embedding Windows resources: {e}");
}
println!(
"cargo:warning=building without Horizon's icon: embedding Windows resources failed: {e}"
);
}
}
}
@@ -0,0 +1,356 @@
//! Generation and light throughput of the game's worker pool, and how it
//! scales with the number of workers: the columns around a point go through
//! the whole pipeline (proto chunk, decoration, final column, light) as when a
//! world loads, once per worker count, each on a freshly seeded generator.
//!
//! cargo run --release -p horizon-client-lib --example gen_light_bench -- \
//! .local/horizon-client/26.3 [--seed 1234] [--center CX CZ] [--radius 12]
//! [--threads 1,2,4,8] [--period-ms 2] [--loading] [--repeat 1] [--json]
//!
//! For each run it prints the wall time until every column within the radius
//! (the area Streaming finishes) was lit, columns finished and lit per second, the process's CPU seconds per
//! column, the coordinator's (`Streaming::update`) share, and the workers' CPU
//! time by job kind (`streaming::job_stats`). The shared worker pool is sized
//! once per process (`HORIZON_JOB_THREADS`), so each worker count runs in a
//! process of its own, started by this one. The checksum
//! covers the blocks and light of the columns within the radius: an A/B pair of
//! builds, and every worker count, must print the same one.
//! `--period-ms` is how long the coordinator sleeps between updates (the
//! server thread runs one per 50 ms tick and pumps between ticks); `--loading`
//! orders work ring by ring, as under the level loading screen.
//!
//! `--simulate PROTO,DECORATE,FINISH` (milliseconds) swaps the generator for
//! one whose jobs sleep that long and make empty columns: what is left is the
//! pipeline itself (the coordinator, the in-flight limits, decoration order,
//! neighbour waits). Sleeping workers don't compete for cores, so this shows
//! how the pipeline scales to many workers even on a busy machine.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
assets::Assets,
biome::Biomes,
blocks::BlockFacts,
chunk::Chunk,
light::LightTables,
mesher::MeshTables,
models,
spans::thread_cpu,
streaming::{ChunkGenerator, Proto, Streaming, WorkerContext, job_stats},
vanillagen::VanillaTerrain,
world::World,
};
use horizon_world::registry::BlockStateId;
use std::{
hash::{DefaultHasher, Hash, Hasher},
path::PathBuf,
process::Command,
sync::Arc,
thread,
time::{Duration, Instant},
};
type Error = Box<dyn std::error::Error>;
fn flag<T: std::str::FromStr>(args: &[String], name: &str, default: T) -> T {
args.iter()
.position(|a| a == name)
.and_then(|i| args.get(i + 1)?.parse().ok())
.unwrap_or(default)
}
fn process_cpu() -> Duration {
#[cfg(unix)]
{
let t = rustix::time::clock_gettime(rustix::time::ClockId::ProcessCPUTime);
Duration::new(t.tv_sec as u64, t.tv_nsec as u32)
}
#[cfg(not(unix))]
Duration::ZERO
}
/// Jobs that take a fixed time and make empty columns (`--simulate`).
struct Simulated {
proto: Duration,
decorate: Duration,
finish: Duration,
}
impl ChunkGenerator for Simulated {
type Worker = ();
fn worker(&self) {}
fn proto(&self, _worker: &mut (), x: i32, z: i32) -> Proto {
thread::sleep(self.proto);
Box::new((x, z))
}
fn decorates(&self) -> bool {
true
}
fn decorate(&self, _worker: &mut (), _region: &mut [Proto; 9]) {
thread::sleep(self.decorate);
}
fn finish(&self, proto: Proto) -> Chunk {
thread::sleep(self.finish);
let (x, z) = *proto.downcast::<(i32, i32)>().expect("simulated proto");
Chunk::empty(x, z)
}
fn light_view(&self, proto: &Proto) -> Option<Chunk> {
let &(x, z) = proto.downcast_ref::<(i32, i32)>()?;
Some(Chunk::empty(x, z))
}
}
struct Run {
wall: f64,
columns: usize,
lit: usize,
cpu: f64,
coordinator: f64,
updates: usize,
jobs: Vec<(&'static str, u64, u64)>,
checksum: u64,
half: f64,
}
#[allow(clippy::too_many_arguments)]
fn run<G: ChunkGenerator>(
generator: G,
context: &Arc<WorkerContext>,
center: (i32, i32),
radius: i32,
threads: usize,
period: Duration,
loading: bool,
) -> Result<Run, Error> {
let mut streaming = Streaming::new(Arc::new(generator), context.clone(), threads);
streaming.mesh = false;
streaming.center = center;
streaming.radius = radius;
streaming.loading = loading;
let mut world = World::new(BlockStateId(0), 0);
let view: Vec<(i32, i32)> = (center.0 - radius - 1..=center.0 + radius + 1)
.flat_map(|x| (center.1 - radius - 1..=center.1 + radius + 1).map(move |z| (x, z)))
// The columns Streaming finishes (`Ranges::in_main_range` with a
// margin of 1). `in_view_distance` reaches a few columns past them.
.filter(|&(x, z)| {
let (dx, dz) = (i64::from(x - center.0), i64::from(z - center.1));
dx * dx + dz * dz <= i64::from(radius + 1).pow(2)
})
.collect();
job_stats::enable(true);
job_stats::take();
let (cpu0, started) = (process_cpu(), Instant::now());
let mut coordinator = Duration::ZERO;
let mut updates = 0;
let mut half = None;
let trace = std::env::var_os("GEN_LIGHT_TRACE").is_some();
let mut next_trace = Duration::from_secs(2);
loop {
let t = thread_cpu().unwrap_or_default();
let _ = streaming.update(&mut world);
coordinator += thread_cpu().unwrap_or_default().saturating_sub(t);
updates += 1;
let lit = view
.iter()
.filter(|k| world.chunks.get(k).is_some_and(|c| c.lit))
.count();
if half.is_none() && lit * 2 >= view.len() {
half = Some(started.elapsed().as_secs_f64());
}
if lit == view.len() {
break;
}
if trace && started.elapsed() >= next_trace {
next_trace += Duration::from_secs(2);
let missing: Vec<_> = view
.iter()
.filter(|k| !world.chunks.get(k).is_some_and(|c| c.lit))
.take(4)
.collect();
eprintln!(
" {:.0} s: {lit}/{} lit, {} columns, {:?}, waiting e.g. {missing:?}",
started.elapsed().as_secs_f64(),
view.len(),
world.chunks.len(),
streaming.stages()
);
}
if started.elapsed() > Duration::from_secs(1800) {
return Err("timed out".into());
}
streaming.wait(period);
}
let wall = started.elapsed().as_secs_f64();
let cpu = (process_cpu() - cpu0).as_secs_f64();
let jobs = job_stats::take();
job_stats::enable(false);
let mut hash = DefaultHasher::new();
let mut ids = Box::new([0u32; 4096]);
for key in &view {
let chunk = &world.chunks[key];
key.hash(&mut hash);
for section in &chunk.sections {
section.unpack(&mut ids);
ids.hash(&mut hash);
(0..4096)
.map(|i| section.light.get(i))
.for_each(|v| v.hash(&mut hash));
}
}
let lit = world.chunks.values().filter(|c| c.lit).count();
Ok(Run {
wall,
columns: world.chunks.len(),
lit,
cpu,
coordinator: coordinator.as_secs_f64(),
updates,
jobs,
checksum: hash.finish(),
half: half.unwrap_or(wall),
})
}
fn main() -> Result<(), Error> {
let args: Vec<String> = std::env::args().collect();
let root = PathBuf::from(args.get(1).ok_or("usage: gen_light_bench <resources>")?);
let seed: i64 = flag(&args, "--seed", 1234);
let radius: i32 = flag(&args, "--radius", 12);
let repeat: usize = flag(&args, "--repeat", 1);
let period = Duration::from_millis(flag(&args, "--period-ms", 2));
let loading = args.iter().any(|a| a == "--loading");
let json = args.iter().any(|a| a == "--json");
let simulate: Option<Vec<f64>> = args
.iter()
.position(|a| a == "--simulate")
.map(|i| -> Result<Vec<f64>, Error> {
let ms: Vec<f64> = args
.get(i + 1)
.ok_or("--simulate PROTO,DECORATE,FINISH")?
.split(',')
.map(str::parse)
.collect::<Result<_, _>>()?;
(ms.len() == 3)
.then_some(ms)
.ok_or_else(|| "--simulate PROTO,DECORATE,FINISH".into())
})
.transpose()?;
let threads: Vec<usize> = flag(&args, "--threads", String::from("1,2,4,8"))
.split(',')
.map(str::parse)
.collect::<Result<_, _>>()?;
let center = args
.iter()
.position(|a| a == "--center")
.map(|i| -> Result<(i32, i32), Error> {
Ok((
args.get(i + 1).ok_or("--center CX CZ")?.parse()?,
args.get(i + 2).ok_or("--center CX CZ")?.parse()?,
))
})
.transpose()?
.unwrap_or((-60, 10));
// The pool is made once per process with its size: one process per
// worker count and repeat, each given the same flags but one count.
if threads.len() > 1 || repeat > 1 {
let exe = std::env::current_exe()?;
let mut rest = Vec::new();
let mut i = 1;
while i < args.len() {
if args[i] == "--threads" || args[i] == "--repeat" {
i += 2;
continue;
}
rest.push(args[i].clone());
i += 1;
}
for _ in 0..repeat.max(1) {
for &n in &threads {
let status = Command::new(&exe)
.args(&rest)
.args(["--threads", &n.to_string()])
.env("HORIZON_JOB_THREADS", n.to_string())
.status()?;
if !status.success() {
return Err(format!("the run with {n} workers failed: {status}").into());
}
}
}
return Ok(());
}
let n = threads[0];
horizon_threads::jobs::configure(n);
let assets = Arc::new(Assets::load(&root)?);
let render = Arc::new(assets.reload(&root)?);
let facts = BlockFacts::load(&root, assets.registry)?;
let visibility = models::load_visibility(&root, assets.registry)?;
let biomes = Arc::new(Biomes::load(&root)?);
let context = Arc::new(WorkerContext {
tables: Arc::new(MeshTables::new(&assets, &render, &facts, visibility)?),
light: Arc::new(LightTables::load(&root, assets.registry, &facts)?),
biomes: biomes.clone(),
assets: assets.clone(),
render,
});
let terrain = || VanillaTerrain::new(&root.join("worldgen"), assets.registry, &biomes, seed);
let go = |center, radius, n, loading| -> Result<Run, Error> {
match &simulate {
Some(ms) => {
let ms = |i: usize| Duration::from_secs_f64(ms[i] / 1e3);
let generator = Simulated {
proto: ms(0),
decorate: ms(1),
finish: ms(2),
};
run(generator, &context, center, radius, n, period, loading)
}
None => run(terrain()?, &context, center, radius, n, period, loading),
}
};
// Warm the shared generator data and the code away from the measured area.
go((center.0 + 4000, center.1), 2, n, false)?;
let pool = horizon_threads::jobs::pool().threads();
if pool != n {
eprintln!("the worker pool has {pool} threads, not {n} (HORIZON_JOB_THREADS?)");
}
let r = go(center, radius, n, loading)?;
let busy: f64 = r.jobs.iter().map(|j| j.1 as f64).sum::<f64>() / 1e9;
println!(
"workers {n:2}: {:.2} s ({:.2} s to half), {} columns ({} lit), {:.1} columns/s, {:.1} lit/s, \
{:.1} ms CPU/column, coordinator {:.2} s CPU in {} updates, workers busy {:.0}%, checksum {:016x}",
r.wall,
r.half,
r.columns,
r.lit,
r.columns as f64 / r.wall,
r.lit as f64 / r.wall,
r.cpu * 1e3 / r.columns.max(1) as f64,
r.coordinator,
r.updates,
busy * 100.0 / (r.wall * n as f64),
r.checksum,
);
let line: Vec<String> = r
.jobs
.iter()
.filter(|j| j.2 > 0)
.map(|(kind, ns, count)| {
format!(
"{kind} {count} x {:.2} ms = {:.2} s",
*ns as f64 / 1e6 / *count as f64,
*ns as f64 / 1e9
)
})
.collect();
println!(" {}", line.join(", "));
if json {
println!(
"BENCH {}",
serde_json::json!({
"workers": n, "wall_s": r.wall, "half_s": r.half, "columns": r.columns,
"lit": r.lit, "cpu_s": r.cpu, "coordinator_s": r.coordinator,
"updates": r.updates, "checksum": format!("{:016x}", r.checksum),
"jobs": r.jobs.iter().map(|(k, ns, c)| serde_json::json!({"kind": k, "s": *ns as f64 / 1e9, "count": c})).collect::<Vec<_>>(),
})
);
}
Ok(())
}
+90 -1
View File
@@ -116,6 +116,39 @@ impl Control {
}
}
/// The wait before a scene's flight, spent timing Horizon's own render passes on the GPU:
/// `gpu_pass.<scene>.<pass>_ms`, where a slow GPU spends its frame. The timing is off again for
/// the flight, whose frames it would slow. False, having waited for nothing, on Metal: its passes
/// overlap on the GPU, so their times add up to more than the frame.
fn time_passes(control: &Control, scene: &str, report: &mut Report) -> Result<bool, String> {
if report
.hardware
.gpu
.as_ref()
.is_some_and(|g| g.backend == "metal")
{
return Ok(false);
}
control.call("gpu-passes on")?;
let held = control.call(&format!("bench {}", SETTLE.as_secs_f64()));
control.call("gpu-passes off")?;
for (pass, ms) in held?["gpu_passes"].as_object().into_iter().flatten() {
let pass: String = pass
.trim()
.chars()
.map(|c| match c {
'a'..='z' | '0'..='9' => c,
'A'..='Z' => c.to_ascii_lowercase(),
_ => '_',
})
.collect();
if let Some(ms) = ms.as_f64() {
report.metric(&format!("gpu_pass.{scene}.{pass}_ms"), &[ms]);
}
}
Ok(true)
}
fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
let scenes =
scenes(&setup.scenario).ok_or_else(|| format!("unknown scenario {}", setup.scenario))?;
@@ -204,7 +237,9 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
&[chunks / load.as_secs_f64()],
);
}
thread::sleep(SETTLE);
if !time_passes(control, name, &mut report)? {
thread::sleep(SETTLE);
}
let [ex, ey, ez] = end;
let begin = Instant::now();
let flight = control.call(&format!(
@@ -499,6 +534,60 @@ mod tests {
let _ = server.join();
}
/// The wait before a flight times the passes and turns the timing off again before the
/// flight; the report gets one metric per pass. Not on Metal.
#[test]
fn the_wait_times_the_passes_but_not_the_flight() {
let time_on = |backend: &str| {
let (tx, rx) = mpsc::channel::<Request>();
let server = thread::spawn(move || {
let mut lines = Vec::new();
while let Ok(request) = rx.recv() {
let reply = if request.line.starts_with("bench ") {
serde_json::json!({
"frames": 120,
"gpu_passes": {"World": 1.5, "World after culling": 0.25, "HUD": 0.01},
})
} else {
serde_json::json!({ "ok": true })
};
let _ = request.reply.send(reply.to_string());
lines.push(request.line);
}
lines
});
let hardware = horizon_bench::Hardware {
gpu: Some(horizon_bench::Gpu {
backend: backend.into(),
..Default::default()
}),
..Default::default()
};
let mut report = Report::with_hardware(Kind::Game, App::default(), hardware);
let control = Control(tx);
let timed = time_passes(&control, "forest", &mut report).expect("the wait runs");
drop(control);
(timed, server.join().unwrap(), report.metrics)
};
let (timed, lines, metrics) = time_on("vulkan");
assert!(timed);
assert_eq!(lines, ["gpu-passes on", "bench 2", "gpu-passes off"]);
let mean = |name: &str| metrics.get(name).map(|s| s.mean);
assert_eq!(mean("gpu_pass.forest.world_ms"), Some(1.5));
assert_eq!(mean("gpu_pass.forest.world_after_culling_ms"), Some(0.25));
assert_eq!(mean("gpu_pass.forest.hud_ms"), Some(0.01));
assert_eq!(metrics.len(), 3);
let (timed, lines, metrics) = time_on("metal");
assert!(!timed);
assert!(
lines.is_empty(),
"Metal's overlapping passes are not timed: {lines:?}"
);
assert!(metrics.is_empty());
}
#[test]
fn yaw_matches_vanilla() {
// South (+Z) is 0, west (-X) 90, east (+X) -90.
@@ -217,6 +217,8 @@ pub const COMMANDS: &[Cmd] = &[
cmd!("hello", Session, "hello", "the control protocol's version, the game's release and the command groups it serves",
"Replies protocol (MAJOR.MINOR.PATCH of the control protocol, docs/api/stability.md), release (the game's version, the string MCP serverInfo carries too) and features (the command groups the game answers, so a script can test for one before calling it).",
["horizon-ctl hello"], &[], mcp: true, local: false),
cmd!("memory", Session, "memory", "what holds the game's memory: resident set, allocator, client and server columns by part, renderer, GPU memory by resource",
"Columns count blocks, biomes, light, heightmaps and records, each shared section once; server_columns are the single-player server thread's own levels. GPU memory by label needs Vulkan or DirectX 12. What the census doesn't reach is the resident set less its parts.", ["horizon-ctl memory"], &[], mcp: true, local: false),
cmd!("worlds", Session, "worlds", "the saved worlds, newest first: folder, name, mode, last played, version",
"folder is what open, world-rename, world-backup and world-delete take.", ["horizon-ctl worlds"], &[], mcp: true, local: false),
cmd!("world-rename", Session, "world-rename FOLDER NAME", "rename a world, as Edit World does (its folder stays)",
+1 -1
View File
@@ -36,7 +36,7 @@ pub fn native_living_breathe(
consume: &mut i32,
refill: &mut i32,
) {
let _ = (entity, kind, can_breathe, consume, refill);
crate::native_client_hooks::living_breathe(entity, kind, can_breathe, consume, refill);
}
/// `vanilla`'s breathing after the mods have had their say.
+4 -3
View File
@@ -18,9 +18,9 @@ pub use horizon_client_render::{
native_emissive, options_file, options_fixes, options_ui, overlay, pack_screen, packs, par,
particles, poi_sections, post_chain, potent_sulfur, preedit, providers, screen_draw,
screen_effects, sculk, section_mask, settings, shader_options, shader_pack_list, signs, skins,
sound_engine, sound_events, sound_preview, spans, split_layout, split_profiles, sprites,
streaming, structure_block, tab_list, telemetry, test_block, test_instance_block, testgen,
title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
sound_engine, sound_events, sound_preview, spans, split_layout, split_map, split_profiles,
sprites, streaming, structure_block, tab_list, telemetry, test_block, test_instance_block,
testgen, title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
};
pub mod attack_strength;
pub mod attribute_updates;
@@ -89,6 +89,7 @@ pub mod mod_content;
pub mod motion;
pub mod multiplayer;
pub mod music;
pub mod native_client_hooks;
pub mod native_data;
pub mod net;
pub mod nether_portal;
+1 -1
View File
@@ -33,7 +33,7 @@ pub fn native_ingredient(
data: &serde_json::Map<String, Value>,
items: &mut Vec<String>,
) {
let _ = (kind, data, items);
crate::native_client_hooks::ingredient(kind, data, items);
}
/// Keep first-seen order, drop the rest.
+47
View File
@@ -3725,6 +3725,10 @@ pub fn apply_item_components(m: &mut Mob, stack: &crate::items::ItemStack) {
),
// ZombieNautilus.applyImplicitComponents: the variant only.
crate::mobs::Species::ZombieNautilus => (Some("minecraft:zombie_nautilus/variant"), None),
// Fox and Frog.applyImplicitComponent: `setVariant` from the component
// alone, so a spawn egg carrying one hatches that variant.
crate::mobs::Species::Fox => (Some("minecraft:fox/variant"), None),
crate::mobs::Species::Frog => (Some("minecraft:frog/variant"), None),
_ => (None, None),
};
if let Some(name) = variant.and_then(|id| text(components.other(id))) {
@@ -4581,6 +4585,49 @@ mod item_component_tests {
assert_eq!(nautilus.extra.variant, 1, "minecraft:warm, not temperate");
}
/// `Fox.applyImplicitComponent` and `Frog.applyImplicitComponent`: the
/// variant component alone decides the mob, `setVariant` from the egg.
#[test]
fn a_fox_or_frog_spawn_egg_picks_its_variant_component() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let spawn = |kind: &str, id: u32| {
Mob::new(
id,
Arc::new(data.types[kind].clone()),
Vec3::new(0.0, 4.0, 0.0),
0.0,
)
};
// A red fox, as a spawn in a forest leaves it.
let mut fox = spawn("minecraft:fox", 1);
assert_eq!(fox.extra.variant, 0);
let mut egg = crate::items::ItemStack::new("minecraft:fox_spawn_egg", 1);
egg.data_mut()
.set_other("minecraft:fox/variant", Some(string("minecraft:snow")));
apply_item_components(&mut fox, &egg);
assert_eq!(fox.extra.variant, 1, "Fox.Variant.SNOW, not red");
// A temperate frog, as a swamp spawn leaves it.
let mut frog = spawn("minecraft:frog", 2);
assert_eq!(frog.extra.variant, 0);
let mut egg = crate::items::ItemStack::new("minecraft:frog_spawn_egg", 1);
egg.data_mut()
.set_other("minecraft:frog/variant", Some(string("minecraft:warm")));
apply_item_components(&mut frog, &egg);
assert_eq!(frog.extra.variant, 1, "minecraft:warm, not temperate");
// An unknown name falls back to the codec's default, red.
let mut egg = crate::items::ItemStack::new("minecraft:fox_spawn_egg", 1);
egg.data_mut()
.set_other("minecraft:fox/variant", Some(string("minecraft:temperate")));
apply_item_components(&mut fox, &egg);
assert_eq!(fox.extra.variant, 0, "unknown names read as red");
}
/// `EntityType.updateCustomEntityTag`: the egg's `entity_data` on the mob it
/// spawned, when the type is its own — the mob's compound, the data merged
/// over it and loaded back, as the spawn egg site does it.
+47
View File
@@ -74,12 +74,59 @@ pub fn main_with(mods: crate::mod_api::Mods) {
components,
);
},
loot_function: |kind, data, ctx, stack| {
crate::mod_api::dispatch_loot_function(
crate::mod_api::mods(),
kind,
data,
ctx,
stack,
);
},
loot_condition: |kind, data, ctx, holds| {
crate::mod_api::dispatch_loot_condition(
crate::mod_api::mods(),
kind,
data,
ctx,
holds,
);
},
},
)
.is_err()
{
log::warn!("Native data-load hooks already installed; keeping the first callbacks");
}
if crate::native_client_hooks::install(crate::native_client_hooks::NativeClientHooks {
menu_slot_rules: |menu, slot, stack, may_place, max_stack| {
crate::mod_api::dispatch_menu_slot_rules(
crate::mod_api::mods(),
menu,
slot,
stack,
may_place,
max_stack,
);
},
living_breathe: |entity, kind, can_breathe, consume, refill| {
crate::mod_api::dispatch_living_breathe(
crate::mod_api::mods(),
entity,
kind,
can_breathe,
consume,
refill,
);
},
ingredient: |kind, data, items| {
crate::mod_api::dispatch_ingredient(crate::mod_api::mods(), kind, data, items);
},
})
.is_err()
{
log::warn!("Native client hooks already installed; keeping the first callbacks");
}
raise_open_file_limit();
if let Err(error) = game::run_with() {
// A bad command line is the caller's mistake, not a crash: no report.
+399 -54
View File
@@ -180,6 +180,7 @@ mod loom;
mod mace;
mod machines;
mod maps;
mod memory;
mod minecarts;
mod mob_blocks;
mod mob_gear;
@@ -386,6 +387,16 @@ use horizon_world::{
},
registry::BlockStateId,
};
pub use maps::{NativeMapDecoration, NativeMapInfo};
pub use native_block_entity_draw::BlockEntityDraw;
pub use native_claimed_items::{NativeHeldItem, NativeHit, NativeModEvent};
pub use native_data_maps::NativeDataMapType;
pub use native_falling::NativeFalling;
pub use native_hooks::{NativeClock, NativeShake};
pub use native_mob_draw::{NativeMobRender, NativePlayerRender};
pub use native_server::NativeExperienceOrb;
pub use native_world_columns::NativeColumnBlocks;
pub use native_world_edit::{NativeBlockEdit, NativeBlockRun, NativeHeightmap};
use std::{
cell::RefCell,
collections::{HashMap, HashSet},
@@ -410,6 +421,10 @@ use winit::{
/// The time a frame spends uploading chunk meshes when the player set no
/// Chunk Updates or Upload Budget: a burst past it waits for the next frames.
const AUTO_UPLOAD_TIME: Duration = Duration::from_millis(2);
/// `AUTO_UPLOAD_TIME` while the view's terrain is still arriving (a world's
/// open, a teleport): the meshes waiting then are the view itself, not edits
/// to it, so a frame gives them more of its time to fill the view sooner.
const AUTO_UPLOAD_TIME_ARRIVING: Duration = Duration::from_millis(6);
/// A frame's finished meshes up to this many upload at once, unqueued.
const AUTO_UPLOADS_AT_ONCE: usize = 8;
@@ -840,6 +855,16 @@ struct Hit {
face: [i32; 3],
point: [f64; 3],
}
impl Hit {
/// The block the clicked face leads to (`pos.relative(direction)`).
fn beside(&self) -> [i32; 3] {
[
self.pos[0] + self.face[0],
self.pos[1] + self.face[1],
self.pos[2] + self.face[2],
]
}
}
/// What a falling block's tick did (`FallingBlockEntity.tick`).
enum FallStep {
Falling,
@@ -946,6 +971,7 @@ struct GameLevel<'w> {
client_side: bool,
/// Only the integrated or dedicated server dispatches native observations.
native_authoritative: bool,
native_player: Option<u128>,
/// `Level.isDebug`: `setBlock` changes nothing.
debug: bool,
native_sounds: &'w mut Vec<creatures::NativeSound>,
@@ -1153,6 +1179,13 @@ impl GameLevel<'_> {
}
}
impl LevelAccess for GameLevel<'_> {
fn allow_hopper_pickup_item(&self, pos: BlockPos, item: &str, count: u32) -> bool {
let mut cancel = false;
if self.native_authoritative {
self.native_hopper_pickup_item(self.dimension, pos, item, count, &mut cancel);
}
!cancel
}
fn observe_block_changed(&self, pos: BlockPos, old: BlockStateId, new: BlockStateId) {
if self.native_authoritative {
self.native_block_changed(self.dimension, pos, old, new);
@@ -1185,6 +1218,108 @@ impl LevelAccess for GameLevel<'_> {
}
!canceled
}
fn allow_fluid_level_change(
&self,
pos: BlockPos,
fluid: &'static str,
from: i32,
to: i32,
) -> bool {
let mut cancel = false;
if self.native_authoritative {
self.native_fluid_level_change(self.dimension, pos, fluid, from, to, &mut cancel);
}
!cancel
}
fn allow_block_ignite(&self, pos: BlockPos, cause: &'static str) -> bool {
let mut cancel = false;
if self.native_authoritative {
self.native_block_ignite(self.dimension, self.native_player, pos, cause, &mut cancel);
}
!cancel
}
fn allow_block_burn(&self, pos: BlockPos) -> bool {
let mut cancel = false;
if self.native_authoritative {
self.native_block_burn(self.dimension, pos, &mut cancel);
}
!cancel
}
fn allow_leaves_decay(&self, pos: BlockPos) -> bool {
let mut cancel = false;
if self.native_authoritative {
self.native_leaves_decay(self.dimension, pos, &mut cancel);
}
!cancel
}
fn allow_moisture_change(&self, pos: BlockPos, from: i32, to: i32) -> bool {
let mut cancel = false;
if self.native_authoritative {
self.native_moisture_change(self.dimension, pos, from, to, &mut cancel);
}
!cancel
}
fn observe_sponge_absorb(&self, pos: BlockPos) {
if self.native_authoritative {
self.native_sponge_absorb(self.dimension, pos);
}
}
fn allow_block_fade(&self, pos: BlockPos, from: BlockStateId, to: BlockStateId) -> bool {
let mut cancel = false;
if self.native_authoritative {
self.native_block_fade(self.dimension, pos, from, to, &mut cancel);
}
!cancel
}
fn allow_block_form(&self, pos: BlockPos, from: BlockStateId, to: BlockStateId) -> bool {
let mut cancel = false;
if self.native_authoritative {
self.native_block_form(self.dimension, pos, from, to, &mut cancel);
}
!cancel
}
fn allow_note_play(&self, pos: BlockPos, instrument: &str, note: i32) -> bool {
let mut cancel = false;
if self.native_authoritative {
self.native_note_play(
self.dimension,
self.native_player,
pos,
instrument,
note,
&mut cancel,
);
}
!cancel
}
fn allow_bell_ring(&self, pos: BlockPos) -> bool {
let mut cancel = false;
if self.native_authoritative {
self.native_bell_ring(self.dimension, self.native_player, pos, &mut cancel);
}
!cancel
}
fn allow_cauldron_level_change(
&self,
pos: BlockPos,
from: i32,
to: i32,
reason: &'static str,
) -> bool {
let mut cancel = false;
if self.native_authoritative {
self.native_cauldron_level_changed(
self.dimension,
self.native_player,
pos,
from,
to,
reason,
&mut cancel,
);
}
!cancel
}
fn allow_piston_move(
&self,
piston: BlockPos,
@@ -2290,6 +2425,8 @@ struct Game<'a> {
regrab_on_refocus: bool,
/// Only the local player owning keyboard and mouse changes the native pointer.
keyboard_mouse: bool,
/// Offscreen rendering into a visible splitscreen window still accepts native input.
split_window: bool,
/// This view's corner in its shared window; cursor warps include the offset.
view_at: [u32; 2],
/// Splitscreen: what it draws over this player's view (its badge, questions, notices).
@@ -2584,6 +2721,9 @@ struct Game<'a> {
server_trips: Vec<server_travel::Trip>,
/// Players a command sends to another level, once it has run.
level_teleports: Vec<server_travel::LevelTeleport>,
/// Teleports a command made inside a level, for `native_player_teleported`
/// once the player's move has reached their client (`leave_guest`).
teleport_notes: Vec<server_travel::TeleportNote>,
/// A server's levels other than the one in place (game/levels.rs).
levels: levels::Levels,
/// Which pass of a server tick runs (`tick_server`): the first one reads
@@ -2649,6 +2789,8 @@ struct Game<'a> {
/// Models drawn only once such an entity shows up (lead knots, wind charges...).
entity_models: EntityModels,
carts: Vec<minecarts::Minecart>,
/// The boats and minecarts `native_vehicle_moved` follows (lan_vehicles.rs).
watched_vehicles: Vec<lan_vehicles::WatchedVehicle>,
/// Songs playing from jukeboxes (tracked sound handles).
jukebox_sounds: HashMap<(i32, i32, i32), u64>,
bolts: Vec<lightning::Bolt>,
@@ -2850,6 +2992,9 @@ struct Game<'a> {
/// The world was saved for the application ending (`save_for_exit`).
saved_for_exit: bool,
pending_save: Option<saving::ClosingSave>,
/// The last world frame reached the swap chain. A window the compositor
/// stops showing gets no frames, and the event loop must not spin on it.
frame_drawn: bool,
/// The world list's icon in progress (`GameRenderer.tryTakeScreenshotIfNeeded`):
/// `None` without a local world, or once the save has its icon.png.
world_icon: Option<WorldIcon>,
@@ -2920,6 +3065,9 @@ struct Game<'a> {
lan_acting_name: Option<String>,
/// Their UUID (`Player.getUUID`).
lan_acting_uuid: Option<u128>,
/// Where the mods refused to prime TNT: the sound and game event the level
/// queued right after the prime are dropped (`prime_tnt_event`).
tnt_refused: Option<BlockPos>,
/// Their permission level: an op's above 0 (`PlayerList.isOp`).
lan_acting_permission: Option<u8>,
/// Their entity id, which the hub (often taken out of `lan` while they
@@ -3717,6 +3865,8 @@ impl Game<'_> {
velocity: [f64; 3],
pickup_delay: u32,
) {
let item = stack.item.clone();
let count = stack.count as i32;
self.next_entity += 1;
let p = Vec3::new(position[0], position[1], position[2]);
// ItemEntity.bobOffs comes from the entity's own random, not the level's.
@@ -3743,6 +3893,8 @@ impl Game<'_> {
// ServerLevel.addFreshEntity.
if !self.native_entity_joins(self.next_entity, "minecraft:item", position, false) {
self.dropped.pop();
} else {
self.native_item_spawn(self.next_entity, &item, count, position);
}
}
/// The block entity at `pos` saved with its id and position
@@ -3801,8 +3953,7 @@ impl Game<'_> {
match world_items!(self).block_loot_sequence(registry, state) {
Some(sequence) => {
let random = self.rand_sequences.get(&sequence, self.level.seed);
self.res
.items()
world_items!(self)
.block_drops_by_player(registry, state, held, &dynamic, random)
}
None => world_items!(self).block_drops_by_player(
@@ -4288,6 +4439,7 @@ impl Game<'_> {
lid_openers: &self.lid_openers,
client_side: self.remote.is_some(),
native_authoritative,
native_player: self.lan_acting_uuid,
debug: self.world_debug,
native_sounds: &mut self.creatures.lan_sounds,
native_particles: &mut self.creatures.lan_particles,
@@ -4300,6 +4452,22 @@ impl Game<'_> {
}
result
}
pub(super) fn native_block_ignite_allowed(
&mut self,
pos: BlockPos,
cause: &'static str,
) -> bool {
self.with_level(|_, level, _| level.allow_block_ignite(pos, cause))
}
pub(super) fn native_cauldron_level_change_allowed(
&mut self,
pos: BlockPos,
from: i32,
to: i32,
reason: &'static str,
) -> bool {
self.with_level(|_, level, _| level.allow_cauldron_level_change(pos, from, to, reason))
}
/// Level.setBlock(pos, state, UPDATE_ALL) with neighbour/shape updates.
fn level_set(&mut self, pos: [i32; 3], state: BlockStateId) -> Result<()> {
self.level_set_with_flags(pos, state, level_flags::UPDATE_ALL)
@@ -5019,6 +5187,15 @@ impl Game<'_> {
_ => {}
}
}
// A prime the mods refused is silent: no fuse sound, no game event.
LevelEvent::Sound { pos, ref sound, .. }
if self.tnt_refused == Some(pos)
&& &**sound == "minecraft:entity.tnt.primed" => {}
LevelEvent::GameEvent {
pos,
event: "minecraft:prime_fuse",
..
} if self.tnt_refused == Some(pos) => self.tnt_refused = None,
LevelEvent::Sound {
pos,
sound,
@@ -5108,7 +5285,7 @@ impl Game<'_> {
}
PlayerEffect::InteractLoot(_) => {}
},
LevelEvent::PrimeTnt { pos, rot } => self.add_primed_tnt(pos, rot, 80, None, None),
LevelEvent::PrimeTnt { pos, rot } => self.prime_tnt_event(pos, rot),
LevelEvent::BlockEvent {
pos,
state,
@@ -5944,7 +6121,22 @@ impl Game<'_> {
return;
};
let group = horizon_client::cooldowns::group(stack, default.as_ref());
self.add_cooldown(group, component.ticks());
let mut cancel = false;
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
self.native_player_item_cooldown(
player,
&name,
&stack.item,
component.ticks(),
&mut cancel,
);
}
if !cancel {
self.add_cooldown(group, component.ticks());
} else if self.lan_acting.is_some() {
// The guest's client started the cooldown itself; 0 ends it there.
self.guest_cooldowns.push((group, 0));
}
}
/// `ItemCooldowns.addCooldown` (a guest also gets the packet).
pub(super) fn add_cooldown(&mut self, group: Arc<str>, ticks: i32) {
@@ -6115,6 +6307,13 @@ impl Game<'_> {
self.stats.add("used", &used.item, 1);
self.stats.custom("pot_flower", 1);
self.consume_held();
if let (Some(player), Some(name)) =
(self.lan_acting_uuid, self.lan_acting_name.clone())
{
self.native_player_flower_pot_manipulate(
player, &name, hit.pos, &used.item,
);
}
}
// NoteBlock.useWithoutItem (tuned) and BellBlock.onHit (rung).
if result == horizon_world::level::InteractionResult::Success {
@@ -6424,6 +6623,22 @@ impl Game<'_> {
}
Ok(true)
}
/// Asks the mods whether the acting guest may empty `stack` at `pos`
/// (`native_bucket_empty`): true when one cancelled it. A game's own
/// player is not asked.
fn bucket_empty_cancelled(&mut self, stack: &ItemStack, pos: [i32; 3]) -> bool {
let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) else {
return false;
};
let fluid = match &*stack.item {
"minecraft:lava_bucket" => "minecraft:lava",
"minecraft:powder_snow_bucket" => "minecraft:powder_snow",
_ => "minecraft:water",
};
let mut cancel = false;
self.native_bucket_empty(uuid, &name, pos, fluid, &mut cancel);
cancel
}
/// The player as a block's `useItemOn` / `useWithoutItem` sees them.
fn use_context(&self) -> PlayerContext {
PlayerContext {
@@ -7549,6 +7764,7 @@ impl Game<'_> {
horizon_client::spans::time("tick_cushions", || self.tick_cushions());
horizon_client::spans::time("tick_boats", || self.tick_boats());
horizon_client::spans::time("tick_carts", || self.tick_carts());
self.tick_vehicle_watch();
horizon_client::spans::time("tick_thunder", || self.tick_thunder());
horizon_client::spans::time("tick_lecterns", || self.tick_lecterns());
horizon_client::spans::time("tick_maps", || self.tick_maps());
@@ -7932,7 +8148,12 @@ impl Game<'_> {
// MouseHandler.grabMouse never captures an inactive window, and vanilla
// grabs only on `setScreen(null)`: the pack prompt and server dialogs
// are screens over the world that keep the pointer.
if capture && (!self.focused || self.server_packs.prompt.is_some() || self.dialog_open()) {
if capture
&& (!self.focused
|| self.server_packs.prompt.is_some()
|| self.dialog_open()
|| self.command_prompt.is_some())
{
return;
}
// The container screen's press state belongs to the screen: a screen
@@ -7971,9 +8192,9 @@ impl Game<'_> {
};
let scale = HudBatch::new(size).scale;
let was_grabbed = self.captured;
if self.options.offscreen {
// `--offscreen` shows no window: the system pointer stays where the
// person at the computer has it, and the grab is the game's own (an
if self.options.offscreen && !self.split_window {
// A genuinely hidden `--offscreen` run leaves the system pointer where
// the person at the computer has it. Its grab is internal (an
// agent turns the view with `mouse-move`).
if capture {
self.mouse.set_ignore_first_move();
@@ -8034,7 +8255,7 @@ impl Game<'_> {
) {
self.cursor = pos;
}
if !self.options.offscreen {
if !self.options.offscreen || self.split_window {
window.set_cursor_visible(!self.captured);
}
}
@@ -8228,7 +8449,10 @@ impl Game<'_> {
} else {
self.hud_layers(size)
};
self.draw_dialog(&mut batch);
// Vanilla's confirmation replaces the dialog as the screen.
if self.command_prompt.is_none() {
self.draw_dialog(&mut batch);
}
self.draw_pack_prompt(&mut batch);
self.draw_code_of_conduct(&mut batch);
self.draw_link_prompt(&mut batch);
@@ -12630,24 +12854,29 @@ impl Game<'_> {
}
}
/// `BucketItem.use`'s place position: a water bucket fills the clicked
/// block itself when that block is a liquid container (a waterloggable
/// stair, slab, fence, ...); every other bucket empties into the block the
/// clicked face leads to. `SolidBucketItem` places like a block item, into
/// a replaceable clicked block.
/// block itself when that block can hold it (`canBlockContainFluid`: a
/// waterloggable stair, slab, fence, ..., but not a double slab, kelp or
/// seagrass); every other bucket empties into the block the clicked face
/// leads to. `SolidBucketItem` places like a block item, into a
/// replaceable clicked block.
pub(super) fn bucket_target(&self, content: BucketContent, hit: &Hit) -> [i32; 3] {
let clicked = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
let behaviours = self.res.behaviours();
if content == BucketContent::Powder {
if self.res.facts().get(clicked).replaceable {
return hit.pos;
}
} else if content.is_water() && self.res.behaviours().is_liquid_container(clicked) {
} else if content.is_water()
&& behaviours.is_liquid_container(clicked)
&& behaviours.can_place_liquid(
clicked,
horizon_world::fluid::FluidKind::Water,
self.creative,
)
{
return hit.pos;
}
[
hit.pos[0] + hit.face[0],
hit.pos[1] + hit.face[1],
hit.pos[2] + hit.face[2],
]
hit.beside()
}
/// The cauldron at `pos`, and whether water is above it
/// (`CauldronInteractions.isUnderWater`).
@@ -12922,6 +13151,30 @@ impl Game<'_> {
let Ok(new) = registry.resolve(&horizon_client::cauldron::extinguished(&old)) else {
return;
};
let level = |state: &horizon_world::chunk::BlockState| {
if matches!(state.name.as_str(), "minecraft:cauldron") {
0
} else if state.name == "minecraft:lava_cauldron" {
3
} else {
state
.properties
.get("level")
.and_then(|level| level.parse().ok())
.unwrap_or(1)
}
};
let Some(new_state) = registry.state(new).cloned() else {
return;
};
if !self.native_cauldron_level_change_allowed(
pos,
level(&old),
level(&new_state),
"extinguishing",
) {
return;
}
if self.level_set(at, new).is_ok() {
self.post_block_game_event("minecraft:block_change", at, None, Some(new));
}
@@ -13071,22 +13324,28 @@ impl Game<'_> {
let target = self.bucket_target(content, &hit);
let mut canceled = false;
self.native_fill_bucket(stack, target, &mut canceled);
if canceled {
if canceled || self.bucket_empty_cancelled(stack, target) {
return Ok(UseResult::Fail);
}
let pos = BlockPos::new(target[0], target[1], target[2]);
// `Player.isShiftKeyDown`: only air takes a fluid under Shift.
let [x, y, z] = hit.beside();
let beside = Some(BlockPos::new(x, y, z));
// `Player.isShiftKeyDown`: a block that refuses the fluid (any
// non-air block under Shift) passes it to the block beside, where
// Shift no longer counts.
let sneaking = self.keys.contains(&KeyCode::ShiftLeft);
let creative = self.creative;
let emptied = self.with_level(|b, level, ticks| {
b.bucket_empty(level, ticks, pos, content, sound, sneaking, creative)
b.bucket_empty(
level, ticks, pos, beside, content, sound, sneaking, creative,
)
});
self.process_level_events()?;
if !emptied {
let Some(emptied) = emptied else {
return Ok(UseResult::Fail);
}
};
if self.dim == Dim::Nether && content.is_water() {
self.evaporation_smoke(target);
self.evaporation_smoke([emptied.x, emptied.y, emptied.z]);
}
// `BucketItem.emptyContents` uses `playSound(player, …)`: the
// actor's client already played the empty / evaporate sound from
@@ -13627,6 +13886,12 @@ impl Game<'_> {
on_ground,
);
self.add_projectile(arrow);
let (player, name, projectile) = (
horizon_client::lan::playerdata::ints_uuid(self.acting_player_uuid()),
self.acting_player_name().to_owned(),
self.next_entity,
);
self.native_entity_shoot_bow(player, &name, projectile, power * 3.0);
self.player_projectile_shot();
let pitch = 1.0 / (self.world.random.next_float() * 0.4 + 1.2) + power * 0.5;
self.audio
@@ -14248,22 +14513,38 @@ impl Game<'_> {
let moved = self
.respawn_config
.is_none_or(|c| (c.dim, c.pos) != (config.dim, config.pos));
self.respawn_config = Some(config);
if moved {
self.tell_sleeper("block.minecraft.set_spawn", "Respawn point set", false);
let mut cancel = false;
if moved
&& let (Some(player), Some(name)) =
(self.lan_acting_uuid, self.lan_acting_name.clone())
{
self.native_player_spawn_point_set(
player,
&name,
self.dim.id(),
head,
"bed",
&mut cancel,
);
}
if !cancel {
self.respawn_config = Some(config);
if moved {
self.tell_sleeper("block.minecraft.set_spawn", "Respawn point set", false);
}
// The fallback becomes the world spawn: a guest's is none of its
// own, so it follows `setworldspawn`.
self.spawn = if self.lan_acting.is_some() {
None
} else {
self.level
.world_spawn
.map(|[x, y, z]| {
Vec3::new(f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5)
})
.or(self.spawn)
};
}
// The fallback becomes the world spawn: a guest's is none of its
// own, so it follows `setworldspawn`.
self.spawn = if self.lan_acting.is_some() {
None
} else {
self.level
.world_spawn
.map(|[x, y, z]| {
Vec3::new(f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5)
})
.or(self.spawn)
};
}
if !(self.dim == Dim::Overworld && self.sky_darken() >= 4) {
self.tell_sleeper(
@@ -14302,6 +14583,13 @@ impl Game<'_> {
true,
);
}
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
let mut cancel = false;
self.native_player_bed_enter(player, &name, head, &mut cancel);
if cancel {
return;
}
}
self.stop_mining();
self.left = false;
self.right = false;
@@ -14357,6 +14645,9 @@ impl Game<'_> {
self.pitch = 0.0;
self.snap_player_model_pitch();
}
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
self.native_player_bed_leave(player, &name, head);
}
}
fn tick_sleep(&mut self) {
self.sleep_timer = tick_sleep_timer(self.sleep_timer, self.sleeping.is_some());
@@ -14397,7 +14688,12 @@ impl Game<'_> {
if self.enough_slept() {
if self.ui.rules.bool("advance_time") {
let t = self.world.day_time;
self.world.day_time = t + 24000 - t.rem_euclid(24000);
let target = t + 24000 - t.rem_euclid(24000);
let mut cancel = false;
self.native_time_skip(self.dim.id(), t, target, "sleep", &mut cancel);
if !cancel {
self.world.day_time = target;
}
}
// Level.isRaining: the rain level is above 0.2.
if self.ui.rules.bool("advance_weather") && self.world.weather.levels(1.0).0 > 0.2 {
@@ -14977,7 +15273,9 @@ impl Game<'_> {
}
self.save_level(wait)?;
self.save_parked_levels(wait)?;
self.save_game_metadata()
self.save_game_metadata()?;
self.native_world_saved(wait);
Ok(())
}
fn save_game_metadata(&mut self) -> Result<()> {
self.save_maps();
@@ -15087,6 +15385,11 @@ impl Game<'_> {
});
}
fn render(&mut self) -> Result<bool> {
// The window's GPU holds the atlas: its copy in memory is kept
// compressed (a no-op once it is).
if self.renderer.is_some() {
self.gui.atlas.pack_pixels();
}
// Before any text of this frame: a full glyph region starts over.
self.gui.atlas.font.begin_frame();
self.glint_frame = self.capture_glint_frame();
@@ -15714,6 +16017,8 @@ impl Game<'_> {
}
None => Err("renderer".into()),
};
// A frame the surface refused (occluded, lost, timed out) is `Ok(false)`.
self.frame_drawn = matches!(result, Ok(true));
// Restore storage even when rendering fails (for example, a lost surface).
self.entity_vertices = entities;
*self.entity_spans.get_mut() = entity_spans;
@@ -16433,9 +16738,14 @@ impl Game<'_> {
// AUTO_UPLOAD_TIME of this frame (at least one goes): a burst of
// finished meshes spreads over frames, nearest first.
let auto = !budgeted;
let auto_time = if arriving {
AUTO_UPLOAD_TIME_ARRIVING
} else {
AUTO_UPLOAD_TIME
};
let started = Instant::now();
while let Some((_, mesh)) = pending.last() {
if auto && count > 0 && started.elapsed() >= AUTO_UPLOAD_TIME {
if auto && count > 0 && started.elapsed() >= auto_time {
break;
}
let size = ((mesh.solid.len() + mesh.translucent.len())
@@ -16571,11 +16881,13 @@ impl ApplicationHandler for Game<'_> {
}
}
fn device_event(&mut self, _: &ActiveEventLoop, _: DeviceId, event: DeviceEvent) {
// The real mouse never steers an offscreen game: its pointer is not grabbed.
// A composited view renders offscreen but its keyboard/mouse owner still
// controls a visible native window. A headless offscreen run never does.
if self.pending_save.is_none()
&& self.captured
&& self.focused
&& !self.options.offscreen
&& self.keyboard_mouse
&& (!self.options.offscreen || self.split_window)
&& self.screen == Screen::Playing
&& let DeviceEvent::MouseMotion { delta: (x, y) } = event
{
@@ -17525,9 +17837,9 @@ impl Game<'_> {
// ConfirmScreen.keyPressed: Escape answers no.
WindowEvent::KeyboardInput { event, .. } if self.command_prompt.is_some() => {
if event.state == ElementState::Pressed
&& event.physical_key == PhysicalKey::Code(KeyCode::Escape)
&& let PhysicalKey::Code(key) = event.physical_key
{
self.answer_command_prompt(false);
self.command_prompt_key(key);
}
}
WindowEvent::KeyboardInput { event, .. } if self.link_prompt.is_some() => {
@@ -18106,7 +18418,18 @@ impl Game<'_> {
self.handle_window_event(event_loop, WindowId::dummy(), WindowEvent::RedrawRequested);
return;
}
event_loop.set_control_flow(ControlFlow::Poll);
// A window the compositor hides gets no `RedrawRequested` at all, so
// polling would spin the loop with nothing to draw and pin a core.
// Once a requested frame fails to reach the swap chain, wait for the
// next event instead; a resize or occlusion change wakes the loop and
// the frame that follows sets the pace back to polling.
if self.frame_drawn {
event_loop.set_control_flow(ControlFlow::Poll);
} else {
event_loop.set_control_flow(ControlFlow::WaitUntil(
Instant::now() + Duration::from_millis(16),
));
}
}
}
/// clouds.png as filled cells (`CloudRenderer.prepare`: alpha 10 or more).
@@ -18254,6 +18577,7 @@ impl<'a> Game<'a> {
captured: false,
regrab_on_refocus: false,
keyboard_mouse: true,
split_window: false,
view_at: [0, 0],
split_hud: Default::default(),
focused: true,
@@ -18418,6 +18742,7 @@ impl<'a> Game<'a> {
travelling: None,
server_trips: Vec::new(),
level_teleports: Vec::new(),
teleport_notes: Vec::new(),
levels: Default::default(),
level_pass: Default::default(),
portals: Default::default(),
@@ -18458,6 +18783,7 @@ impl<'a> Game<'a> {
cushion_model: prepared.cushion_model,
entity_models: prepared.entity_models,
carts: Vec::new(),
watched_vehicles: Vec::new(),
jukebox_sounds: HashMap::new(),
bolts: Vec::new(),
maps: maps::Maps::default(),
@@ -18580,6 +18906,7 @@ impl<'a> Game<'a> {
text_selection: Default::default(),
saved_for_exit: false,
pending_save: None,
frame_drawn: true,
world_icon: None,
forced_hit: None,
tick_hit: None,
@@ -18612,6 +18939,7 @@ impl<'a> Game<'a> {
guest_player_sounds: Vec::new(),
lan_acting_name: None,
lan_acting_uuid: None,
tnt_refused: None,
lan_acting_permission: None,
lan_acting_entity: None,
chat_capture: None,
@@ -19227,7 +19555,7 @@ struct CoreTables {
item_renderer: horizon_client::item_render::ItemRenderer,
visibility: horizon_world::block_render::FaceVisibility,
gameplay: GameplayTables,
biomes: Biomes,
biomes: Arc<Biomes>,
environment: Environment,
collision: BlockCollisionRegistry<'static>,
}
@@ -19251,6 +19579,7 @@ fn load_core(
headless: bool,
parallel: bool,
progress: &Progress,
launch_world: Option<&str>,
) -> std::result::Result<std::result::Result<CoreTables, String>, String> {
let assets = match startup::time("core.assets", || Assets::load(root)) {
Ok(assets) => assets,
@@ -19295,7 +19624,17 @@ fn load_core(
.map_err(|e| e.to_string())
});
let biomes = Step::spawn(scope, parallel, || {
startup::time("core.biomes", || Biomes::load(root)).map_err(|e| e.to_string())
let biomes = Arc::new(
startup::time("core.biomes", || Biomes::load(root))
.map_err(|e| e.to_string())?,
);
// A launch that names a world: its data preload needs only the
// block registry and the biomes, so it starts here rather than
// after the block models and facts (~250 ms later).
if let Some(folder) = launch_world {
world_loading::preload_launch_world(folder, root, registry, biomes.clone());
}
Ok::<_, String>(biomes)
});
let environment = Step::spawn(scope, parallel, || {
startup::time("core.environment", || Environment::load(root))
@@ -19434,7 +19773,13 @@ fn load_resources(
let (core, loaded_gui) = loop {
let (ui, core) = std::thread::scope(|scope| {
let core = Step::spawn(scope, parallel, || {
startup::time("core", || load_core(&root, headless, parallel, progress))
// A failed pack starts core again from the game's own
// resources, which the world's preload must read: with packs
// it waits for the core that loaded.
let early = launch_world.filter(|_| root == vanilla_root);
startup::time("core", || {
load_core(&root, headless, parallel, progress, early)
})
});
let ui = load_ui(&root).map(|ui| offer(ui, &root));
(ui, core.join())
@@ -19479,7 +19824,6 @@ fn load_resources(
} = core;
let assets = Arc::new(assets);
let render = Arc::new(render);
let biomes = Arc::new(biomes);
if let Some(folder) = launch_world {
world_loading::preload_launch_world(folder, &root, assets.registry, biomes.clone());
}
@@ -19821,6 +20165,7 @@ fn build_renderer(
atlas.font.reset_cache();
// Made here when the worker did not: the renderer's own fallback has no LabPBR maps.
let prepared = Some(prepared.unwrap_or_else(|| prepare_renderer_textures(pack)));
let atlas_pixels = atlas.pixels();
let mut renderer = pollster::block_on(Renderer::new(
window,
offscreen,
@@ -19830,7 +20175,7 @@ fn build_renderer(
assets.texture_layers,
&assets.sprite_mips,
&assets.animations,
(&atlas.pixels, atlas.width, atlas.height),
(&atlas_pixels, atlas.width, atlas.height),
(&atlas.glint.0, atlas.glint.1, atlas.glint.2),
(
&atlas.glint_armor.0,
+34 -4
View File
@@ -10,6 +10,7 @@ use horizon_client::{
toasts::Slide,
};
use horizon_core::geometry::Vec3;
use horizon_protocol::packets::play::Clientbound as C;
use serde_json::Value;
use std::{
collections::{BTreeMap, BTreeSet, VecDeque},
@@ -626,18 +627,44 @@ impl Game<'_> {
/// After `ServerRecipeBook.addRecipes`: the toast and the
/// `recipe_unlocked` trigger of each recipe now known.
pub(super) fn recipe_unlocked(&mut self, unlocked: &[String]) {
let mut allowed = Vec::with_capacity(unlocked.len());
let mut refused = Vec::new();
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
for recipe in unlocked {
let mut cancel = false;
self.native_player_recipe_discovered(player, &name, recipe, &mut cancel);
if cancel {
refused.push(recipe.clone());
} else {
allowed.push(recipe.clone());
}
}
if !refused.is_empty() {
let removed = self
.level
.recipe_book
.remove_recipes(&world_stations!(self).recipe_index, &refused);
if self.lan_acting.is_some() && !removed.is_empty() {
self.guest_packets.push(C::RecipeBookRemove(
horizon_protocol::packets::recipes::RecipeBookRemove { ids: removed },
));
}
}
} else {
allowed.extend_from_slice(unlocked);
}
if self.lan_acting.is_some() {
// A network player's client gets them in ClientboundRecipeBookAddPacket.
self.adv.unlocked.extend_from_slice(unlocked);
self.adv.unlocked.extend_from_slice(&allowed);
} else {
self.recipe_toast(unlocked);
self.recipe_toast(&allowed);
}
if unlocked.is_empty() || self.remote.is_some() || self.no_player_here() {
if allowed.is_empty() || self.remote.is_some() || self.no_player_here() {
return;
}
self.load_advancements();
let tree = self.adv.tree.clone();
for r in unlocked {
for r in &allowed {
let list = tree.recipe_criteria(r);
self.trigger_criteria(&tree, &list, |c, _| {
c["recipes"].as_str() == Some(r.as_str())
@@ -681,6 +708,9 @@ impl Game<'_> {
fn award(&mut self, index: usize) {
let tree = self.adv.tree.clone();
let a = &tree.list[index];
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
self.native_player_advancement_done(player, &name, &a.id);
}
// AdvancementRewards.grant
if a.rewards.experience > 0 {
self.experience.give(a.rewards.experience);
+10 -3
View File
@@ -500,7 +500,7 @@ impl Game<'_> {
json!({
"dialog": self.dialog_status(),
"screen": self.screen_label(),
"menu": (self.title.is_some() || self.split_profiles_open()).then(|| self.title_value()),
"menu": (self.title.is_some() || self.split_profiles_open() || self.split_setup_open()).then(|| self.title_value()),
"world": (self.title.is_none()).then(|| match &self.integrated {
Some(server) => server.world_name.clone(),
None => self.level.name.clone(),
@@ -535,11 +535,18 @@ impl Game<'_> {
self.screen == Screen::Options
&& self.ui.options.page() == horizon_client::options_ui::Page::SplitProfiles
}
fn split_setup_open(&self) -> bool {
self.screen == Screen::Options
&& self.ui.options.page() == horizon_client::options_ui::Page::SplitSetup
}
fn title_value(&self) -> Value {
use horizon_client::title::Mode;
if self.split_profiles_open() {
return self.ui.options.split_profiles.state();
}
if self.split_setup_open() {
return json!({ "mode": "splitscreen-setup", "status": self.ui.options.split_setup.status() });
}
let Some(title) = &self.title else {
return Value::Null;
};
@@ -742,7 +749,7 @@ impl Game<'_> {
v["chat_input"] = json!(chat.input.value);
v["chat_suggestions"] = json!(chat.suggestions_visible());
}
if self.title.is_some() || self.split_profiles_open() {
if self.title.is_some() || self.split_profiles_open() || self.split_setup_open() {
v["menu"] = self.title_value();
}
if self.screen == Screen::Inventory && self.title.is_none() {
@@ -1705,7 +1712,7 @@ impl Game<'_> {
if let Some(server) = &self.integrated {
let chat = server
.call(move |g| g.owner_console_command(&command))
.ok_or("the integrated server stopped")?;
.ok_or(super::integrated::NO_ANSWER)?;
return ok(json!({ "ok": true, "chat": chat }));
}
if self.remote.is_some() {
@@ -600,6 +600,23 @@ impl Game<'_> {
if held.is_none() && current.is_none() {
return false;
}
// A server's mods hear the swap before it happens, and may stop it.
// A stack of several on an occupied slot goes nowhere (below).
let moves = held
.as_ref()
.is_none_or(|h| h.count <= 1 || current.is_none());
if moves
&& target.is_some()
&& let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
{
let stand = self.armor_stands[i].id as i32;
let slot_name = ["mainhand", "offhand", "feet", "legs", "chest", "head"][slot];
let mut cancel = false;
self.native_armor_stand_manipulate(player, &name, stand, slot_name, &mut cancel);
if cancel {
return true;
}
}
// ArmorStand.swapItem.
match held {
// A player with infinite materials puts a copy of one on an empty
+5
View File
@@ -130,8 +130,13 @@ impl Game<'_> {
let Some(BlockEntity::Beacon(mut b)) = self.world.block_entities.take(&key) else {
return;
};
let previous = b.levels;
self.tick_beacon(key, &mut b, false);
let current = b.levels;
self.world.block_entities.put(key, BlockEntity::Beacon(b));
if (previous == 0) != (current == 0) {
self.native_beacon_state_change(self.dim.id(), [key.0, key.1, key.2], current > 0);
}
}
/// `ClientLevel.tickBlockEntities` for beacons: a server's chunk data
/// carries no beam sections, so the client builds them too; effects,
+12 -2
View File
@@ -150,8 +150,18 @@ impl Game<'_> {
self.world
.block_entities
.put(key, BlockEntity::Beehive(hive));
if stored && let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
m.removed = true;
if stored {
let kind = self.mobs[i].kind.id.clone();
let block = self
.res
.facts()
.get(self.world.block(at[0], at[1], at[2]))
.name
.clone();
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
m.removed = true;
}
self.native_entity_enter_block(mob, &kind, at, &block);
}
}
/// `BeehiveBlockEntity.emptyAllLivingFromHive` with no player about.
@@ -714,6 +714,16 @@ impl Game<'_> {
}
/// `EndGatewayBlock.entityInside` on the server: whether the gateway at
/// `pos` let the entity in (`triggerCooldown`, with its block event).
pub(super) fn gateway_available(&self, pos: [i32; 3]) -> bool {
self.be_anims
.gateway_servers
.get(&pos)
.copied()
.unwrap_or_else(|| self.gateway_loaded(pos))
.teleport_cooldown
<= 0
}
pub(super) fn gateway_enter(&mut self, pos: [i32; 3]) -> bool {
let initial = self.gateway_loaded(pos);
let entered = self
@@ -215,16 +215,51 @@ impl Game<'_> {
/// `AbstractFurnaceBlockEntity.serverTick` of the furnace at `pos`, and
/// its block's `lit` following it.
fn tick_furnace(&mut self, pos: Pos) -> Result<()> {
let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get_mut(&pos) else {
return Ok(());
// What the hooks need from before the tick, without cloning stacks: the
// fuel's id only while the furnace is out (it can light this tick), the
// input's id (a last item is gone after it smelts) and the result count.
let before = match self.world.block_entities.get(&pos) {
Some(BlockEntity::Furnace(f)) => (
f.cooking_timer,
(f.lit_remaining == 0)
.then(|| f.items[1].as_ref().map(|s| s.item.clone()))
.flatten(),
f.items[0].as_ref().map(|s| s.item.clone()),
f.items[2].as_ref().map_or(0, |s| s.count),
),
_ => return Ok(()),
};
let mut drops = Vec::new();
let lit = f.tick(
world_recipes!(self),
world_items!(self),
self.res.providers(),
&mut drops,
);
let (lit, burning, smelted, smelting) = {
let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get_mut(&pos) else {
return Ok(());
};
let lit = f.tick(
world_recipes!(self),
world_items!(self),
self.res.providers(),
&mut drops,
);
let burning = before.1.filter(|_| f.lit_remaining > 0);
let smelted = f.items[2]
.as_ref()
.filter(|after| after.count > before.3)
.map(|after| (before.2.clone(), after.item.clone()));
let started = before.0 == 0 && f.cooking_timer > 0;
let smelting = started
.then(|| f.items[0].as_ref().map(|item| item.item.clone()))
.flatten();
(lit, burning, smelted, smelting)
};
if let Some(item) = smelting {
self.native_furnace_start_smelt([pos.0, pos.1, pos.2], &item);
}
if let Some(fuel) = burning {
self.native_furnace_burn([pos.0, pos.1, pos.2], &fuel);
}
if let Some((Some(item), result)) = smelted {
self.native_furnace_smelt([pos.0, pos.1, pos.2], &item, &result);
}
for stack in drops {
self.campfire_drop(pos, stack);
}
+1 -1
View File
@@ -76,7 +76,7 @@ impl Game<'_> {
let words: Vec<&str> = owned.iter().map(String::as_str).collect();
g.in_owner_level(|g| g.block_command(&words).map_err(|e| e.to_string()))
})
.ok_or("the integrated server stopped")?
.ok_or(super::integrated::NO_ANSWER)?
.map_err(Into::into);
}
// Another server's world changes only through its own commands, which
+72 -3
View File
@@ -146,10 +146,22 @@ impl Game<'_> {
}
ClickEvent::OpenUrl(url) => self.click_url(url),
// closeContainerOnServer, then clickCommandAction with no screen
// after the command: the book closes and the command runs.
// after the command: the book closes and the command runs. A
// lectern's screen goes with its menu; a book is still the screen
// a confirmation returns to.
ClickEvent::RunCommand(command) => {
let book = if lectern.is_none() {
self.book.take()
} else {
None
};
self.close_book();
self.click_command(&command);
if let Some(book) = book {
self.command_prompt_returns_to(super::remote_chat::Behind::Book(Box::new(
book,
)));
}
}
// Screen.defaultHandleClickEvent: copied, the book stays open.
ClickEvent::CopyToClipboard(value) => {
@@ -334,9 +346,40 @@ impl Game<'_> {
{
return;
}
self.as_guest(host, conn, |g| {
g.write_book(p.slot as usize, p.pages, p.title)
let menu_slot = if p.slot == super::shield::OFFHAND_SLOT as i32 {
45
} else {
36 + p.slot as usize
};
let cancelled = self.as_guest(host, conn, |g| {
let mut cancel = false;
let writable = g
.stack_in(p.slot as usize)
.is_some_and(|stack| &*stack.item == "minecraft:writable_book");
if writable
&& let (Some(player), Some(name)) = (g.lan_acting_uuid, g.lan_acting_name.clone())
{
g.native_player_edit_book(
player,
&name,
p.pages.len() as i32,
p.title.is_some(),
&mut cancel,
);
}
if cancel {
return true;
}
g.write_book(p.slot as usize, p.pages, p.title);
false
});
// A cancelled edit leaves the client's own copy of the book: forget
// what was sent so the next broadcast resends the server's stack.
if cancelled == Some(true)
&& let Some(g) = host.hub.guests.iter_mut().find(|g| g.conn == conn)
{
g.menu_sent[menu_slot] = None;
}
}
pub(super) fn book_key(&mut self, event: &KeyEvent) {
if event.state != ElementState::Pressed {
@@ -811,6 +854,9 @@ impl Game<'_> {
self.audio
.play("minecraft:item.book.put", Some(at), 1.0, 1.0);
self.container_changed(key);
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
self.native_player_insert_lectern_book(player, &name, pos, &book.item);
}
true
}
/// `LecternBlock.openScreen`: reading the lectern's book.
@@ -874,7 +920,21 @@ impl Game<'_> {
/// lectern, so the screen closes (the server closes the menu once the
/// book is gone; a server's client closes at once rather than wait).
fn take_lectern_book(&mut self, key: (i32, i32, i32)) {
let before = match self.world.block_entities.get(&key) {
Some(BlockEntity::Lectern(l)) => l.book.clone(),
_ => None,
};
self.lectern_button(key, 3);
if let (Some(book), Some(player), Some(name)) = (
before.filter(|_| {
matches!(self.world.block_entities.get(&key),
Some(BlockEntity::Lectern(l)) if l.book.is_none())
}),
self.lan_acting_uuid,
self.lan_acting_name.clone(),
) {
self.native_player_take_lectern_book(player, &name, [key.0, key.1, key.2], &book.item);
}
if self.may_build()
&& matches!(
self.book,
@@ -1023,6 +1083,15 @@ impl Game<'_> {
self.lectern_pulses.push((key, 2));
self.level_event_at(1043, [key.0, key.1, key.2], 0);
self.container_changed(key);
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
{
self.native_player_lectern_page_change(
player,
&name,
[key.0, key.1, key.2],
menu.data[0],
);
}
}
}
/// The open lectern screen follows its block entity (`LecternScreen`'s
@@ -48,6 +48,7 @@ impl Game<'_> {
_ => false,
};
if placed {
self.native_campfire_start(pos, &held.item);
// ItemStack.consumeAndReturn: creative keeps the item.
self.consume_held();
self.stats.custom("interact_with_campfire", 1);
@@ -102,6 +103,7 @@ impl Game<'_> {
.recipes()
.cook(KIND, &item)
.map_or_else(|| item.clone(), |r| r.result.clone());
self.native_block_cook([pos.0, pos.1, pos.2], &item.item, &result.item);
self.campfire_drop(pos, result);
self.mark_block_entity_updated(pos);
self.post_block_game_event(
+108 -18
View File
@@ -1,7 +1,7 @@
//! The dedicated server's chat filter, game side (docs/chat-filter.md): what the hub refused is
//! written to `logs/chat-filter.log` and told to horizon-server's event stream as `chat_filtered`
//! (never as `chat`), a temporary ban goes into banned-players.json, and operators lift mutes with
//! `/unmute <player>` (or `unmute` on the console).
//! (never as `chat`), a temporary ban goes into banned-players.json, and operators mute and unmute
//! players by hand with `/mute`, `/tempmute`, `/mutelist` and `/unmute` (the same words on the console).
use super::{Game, dedicated, lan::LanHost};
use horizon_chat_filter::Action;
use horizon_client::lan::hub::{HostEvent, chat_filter::Place};
@@ -9,8 +9,11 @@ use horizon_protocol::packets::play::{self, Clientbound as C};
use horizon_protocol::text::{literal, translatable};
use horizon_server_files::user_lists::{BanEntry, NameAndId};
/// The operator level `/unmute` needs, as `/ban`'s.
const UNMUTE_LEVEL: u8 = 3;
/// The operator level the mute commands need, as `/ban`'s.
const MUTE_LEVEL: u8 = 3;
/// The commands of `mute_command`.
pub(super) const MUTE_COMMANDS: [&str; 4] = ["mute", "tempmute", "mutelist", "unmute"];
/// The reason a temporary ban gives at login.
const BAN_REASON: &str = "Hate speech in chat (Horizon chat filter)";
@@ -155,27 +158,114 @@ impl Game<'_> {
allowed
}
/// `/unmute <player>` from a guest: operators of level 3 (`/ban`'s); vanilla's unknown command
/// for the others.
pub(super) fn guest_unmute(&mut self, host: &mut LanHost, conn: u32, words: &[&str]) {
/// A mute command from a guest: operators of level 3 (`/ban`'s); vanilla's unknown command for
/// the others.
pub(super) fn guest_mute_command(&mut self, host: &mut LanHost, conn: u32, words: &[&str]) {
let Some(g) = host.hub.guest(conn) else {
return;
};
let (permission, by) = (g.permission, g.name.clone());
let reply = if permission < UNMUTE_LEVEL {
translatable("command.unknown.command", &[])
} else if words.len() != 2 {
literal("/unmute <player>")
let replies = if permission < MUTE_LEVEL {
vec![translatable("command.unknown.command", &[])]
} else {
literal(&self.unmute(host, words[1], &by))
let words: Vec<&str> = words.iter().copied().filter(|w| !w.is_empty()).collect();
self.mute_command(host, &words, &by)
.iter()
.map(|line| literal(line))
.collect()
};
host.hub.send_to(
conn,
&C::SystemChat(play::SystemChat {
content: reply,
overlay: false,
}),
for content in replies {
host.hub.send_to(
conn,
&C::SystemChat(play::SystemChat {
content,
overlay: false,
}),
);
}
}
/// `/mute <player> [reason]`, `/tempmute <player> <duration> [reason]`, `/mutelist` and
/// `/unmute <player>` (`words`, the command first) by `by`: the lines to answer.
pub(super) fn mute_command(
&mut self,
host: &mut LanHost,
words: &[&str],
by: &str,
) -> Vec<String> {
let reason = |from: usize| (words.len() > from).then(|| words[from..].join(" "));
match words {
["mutelist"] => {
let now = horizon_chat_filter::now_millis();
let mutes = host.hub.mutes();
if mutes.is_empty() {
return vec!["There are no mutes".into()];
}
let mut lines = vec![format!("There are {} mute(s):", mutes.len())];
for m in mutes {
let by = m.by.as_deref().unwrap_or("the chat filter");
let until = m.until.map_or_else(
|| "until unmuted".into(),
|u| {
let left = horizon_chat_filter::duration_text(u - now);
format!("for {}", left.replacen(' ', " more ", 1))
},
);
let why = m.reason.map(|r| format!(": {r}")).unwrap_or_default();
lines.push(format!("{} was muted by {by} {until}{why}", m.name));
}
lines
}
["unmute", name] => vec![self.unmute(host, name, by)],
["mute", name, ..] => vec![self.mute(host, name, None, reason(2), by)],
["tempmute", name, time, ..] => match horizon_chat_filter::parse_duration(time) {
Some(ms) => vec![self.mute(host, name, Some(ms), reason(3), by)],
None => vec![format!("{time} isn't a duration: 10m, 2h, 1d")],
},
["mute", ..] => vec!["/mute <player> [reason]".into()],
["tempmute", ..] => vec!["/tempmute <player> <duration> [reason]".into()],
["unmute", ..] => vec!["/unmute <player>".into()],
_ => vec!["/mutelist".into()],
}
}
/// Mutes `name` for `time` milliseconds (`None`: until `/unmute`), logged; what to answer.
fn mute(
&mut self,
host: &mut LanHost,
name: &str,
time: Option<i64>,
reason: Option<String>,
by: &str,
) -> String {
// A player here, else one the server has seen (the user cache).
let user = host
.hub
.guests
.iter()
.find(|g| g.name.eq_ignore_ascii_case(name))
.map(|g| (g.uuid, g.name.clone()))
.or_else(|| self.resolve_name(name).map(|u| (u.id, u.name)));
let Some((uuid, name)) = user else {
return format!("No player was found named {name}");
};
let until = time.map(|ms| horizon_chat_filter::now_millis() + ms);
let what = time.map_or_else(
|| "until unmuted".into(),
|ms| format!("for {}", horizon_chat_filter::duration_text(ms)),
);
let why = reason
.as_deref()
.map(|r| format!(": {r}"))
.unwrap_or_default();
host.hub.mute(uuid, &name, until, by, reason);
if self.options.dedicated.is_some() {
dedicated::log(
"INFO",
&format!("[chat-filter] {by} muted {name} {what}{why}"),
);
}
format!("Muted {name} {what}{why}")
}
/// Lifts `name`'s mute, logged; what to answer.
+109 -16
View File
@@ -1469,6 +1469,7 @@ impl Game<'_> {
let _ = self.in_target_level(t, |g| g.teleport_to_in_level(t, pos, rotation));
}
fn teleport_to_in_level(&mut self, t: Target, pos: [f64; 3], rotation: Option<[f32; 2]>) {
let from = self.target_position(t);
let v = Vec3::new(pos[0], pos[1], pos[2]);
match t {
Target::Guest(_) => {
@@ -1483,6 +1484,10 @@ impl Game<'_> {
if let Some(conn) = self.lan_acting {
self.server_trips.retain(|trip| !trip.is(conn));
}
let from = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]);
if let Some(p) = self.player.as_mut() {
p.position = v;
p.velocity = Vec3::new(0.0, 0.0, 0.0);
@@ -1494,6 +1499,24 @@ impl Game<'_> {
self.yaw = yaw;
self.pitch = pitch.clamp(-90.0, 90.0);
}
// A server player's teleport is told to the mods once their
// move is in the hub (`leave_guest`).
if let (Some(conn), Some(uuid), Some(name), Some(from)) = (
self.lan_acting,
self.lan_acting_uuid,
self.lan_acting_name.clone(),
from,
) {
self.teleport_notes
.push(super::server_travel::TeleportNote {
conn,
uuid,
name,
from,
to: pos,
dimension: self.dim.id().to_owned(),
});
}
}
Target::Mob(id) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
@@ -1602,6 +1625,12 @@ impl Game<'_> {
// Wall-mounted entities do not move.
Target::Frame(_) | Target::Painting(_) => {}
}
if self.options.dedicated.is_some()
&& let (Some(entity), Some(from), Some(to)) =
(self.network_entity_id(t), from, self.target_position(t))
{
self.native_entity_teleport(self.dim.id(), entity as u32, from, to);
}
}
/// Teleport the local player into the command source's dimension. Unlike
/// portal travel, `/tp` keeps the coordinates given by the command.
@@ -1875,8 +1904,12 @@ impl Game<'_> {
if total == t {
return Err(err("commands.time.set.already_at_time", &[&clock, &t]));
}
self.world.day_time = t;
self.ui.clock.partial = 0.0;
let mut cancel = false;
self.native_time_skip(self.dim.id(), total, t, "command", &mut cancel);
if !cancel {
self.world.day_time = t;
self.ui.clock.partial = 0.0;
}
self.feedback(Text::tr("commands.time.set.absolute", &[&clock, &t]));
Ok(t as i32)
}
@@ -1896,8 +1929,13 @@ impl Game<'_> {
&[&clock, &marker],
));
}
self.world.day_time = m.resolve(total);
self.ui.clock.partial = 0.0;
let target = m.resolve(total);
let mut cancel = false;
self.native_time_skip(self.dim.id(), total, target, "command", &mut cancel);
if !cancel {
self.world.day_time = target;
self.ui.clock.partial = 0.0;
}
self.feedback(Text::tr(
"commands.time.set.time_marker",
&[&clock, &marker],
@@ -1906,7 +1944,12 @@ impl Game<'_> {
}
"time.add" => {
let t = i64::from(Self::int(ctx, "time").unwrap_or(0));
self.world.day_time = (total + t).max(0);
let target = (total + t).max(0);
let mut cancel = false;
self.native_time_skip(self.dim.id(), total, target, "command", &mut cancel);
if !cancel {
self.world.day_time = target;
}
let now = self.world.day_time;
self.feedback(Text::tr("commands.time.set.absolute", &[&clock, &now]));
Ok((now % i64::from(i32::MAX)) as i32)
@@ -1990,12 +2033,10 @@ impl Game<'_> {
let sample = |min: i32, max: i32, r: &mut horizon_core::random::LegacyRandom| {
min + r.next_int_bound(max - min + 1).unwrap_or(0)
};
let w = &mut self.world.weather;
let r = &mut self.world.random;
let (key, clear, time, raining, thundering) = if ctx.has("clear") {
(
"commands.weather.set.clear",
duration.unwrap_or_else(|| sample(12_000, 180_000, r)),
duration.unwrap_or_else(|| sample(12_000, 180_000, &mut self.world.random)),
0,
false,
false,
@@ -2004,7 +2045,7 @@ impl Game<'_> {
(
"commands.weather.set.rain",
0,
duration.unwrap_or_else(|| sample(12_000, 24_000, r)),
duration.unwrap_or_else(|| sample(12_000, 24_000, &mut self.world.random)),
true,
false,
)
@@ -2012,17 +2053,46 @@ impl Game<'_> {
(
"commands.weather.set.thunder",
0,
duration.unwrap_or_else(|| sample(3_600, 15_600, r)),
duration.unwrap_or_else(|| sample(3_600, 15_600, &mut self.world.random)),
true,
true,
)
};
// ServerLevel.setWeatherParameters.
w.clear_time = clear;
w.rain_time = time;
w.thunder_time = time;
w.raining = raining;
w.thundering = thundering;
let previous = (
self.world.weather.clear_time,
self.world.weather.rain_time,
self.world.weather.thunder_time,
self.world.weather.raining,
self.world.weather.thundering,
);
let mut cancel = false;
self.native_weather_change(self.dim.id(), raining, thundering, &mut cancel);
let old_thundering = self.world.weather.thundering;
let old_thunder_time = self.world.weather.thunder_time;
let mut cancel_thunder = false;
self.native_thunder_change(self.dim.id(), thundering, &mut cancel_thunder);
if cancel {
let w = &mut self.world.weather;
(
w.clear_time,
w.rain_time,
w.thunder_time,
w.raining,
w.thundering,
) = previous;
} else {
// ServerLevel.setWeatherParameters.
let w = &mut self.world.weather;
w.clear_time = clear;
w.rain_time = time;
w.thunder_time = time;
w.raining = raining;
w.thundering = thundering;
if cancel_thunder {
w.thunder_time = old_thunder_time;
w.thundering = old_thundering;
}
}
self.feedback(Text::translate(key, &[]));
Ok(duration.unwrap_or(-1))
}
@@ -2222,6 +2292,14 @@ impl Game<'_> {
name = Text::translate("entity.minecraft.experience_orb", &[]);
}
"lightning_bolt" => {
let block = [
pos[0].floor() as i32,
pos[1].floor() as i32,
pos[2].floor() as i32,
];
if !self.lightning_strike_allowed(self.lan_acting_uuid, block, "command") {
return Err(err("commands.summon.failed", &[]));
}
self.spawn_bolt(pos, false);
name = Text::translate("entity.minecraft.lightning_bolt", &[]);
}
@@ -3050,6 +3128,20 @@ impl Game<'_> {
let level = self.dim;
for &t in &targets {
self.for_player(t, |g| {
let mut cancel = false;
if let (Some(player), Some(name)) = (g.lan_acting_uuid, g.lan_acting_name.clone()) {
g.native_player_spawn_point_set(
player,
&name,
level.id(),
pos,
"command",
&mut cancel,
);
}
if cancel {
return;
}
if level == Dim::Overworld {
g.spawn = Some(Vec3::new(
f64::from(pos[0]) + 0.5,
@@ -3106,6 +3198,7 @@ impl Game<'_> {
// LevelData.setRespawnData: the world's spawn (level.dat `spawn`).
let old = self.level.world_spawn.replace(pos);
self.level.world_spawn_angle = rot;
self.native_spawn_change(pos, rot);
// A player whose fallback is the world spawn (no spawnpoint of its
// own; a bed or an anchor that is gone falls back to it) respawns at
// the new one.
@@ -365,6 +365,7 @@ impl Game<'_> {
&[shown],
));
}
self.native_world_border_bounds_changed();
Ok((distance - current) as i32)
}
"worldborder.center" => {
@@ -382,6 +383,7 @@ impl Game<'_> {
));
}
self.border.set_center([x, z]);
self.native_world_border_center_changed();
self.feedback(Text::translate(
"commands.worldborder.center.success",
&[Text::literal(float2(x)), Text::literal(float2(z))],
@@ -59,6 +59,7 @@ impl Game<'_> {
}
if level < 7 {
self.composter_add(pos, &layers);
self.native_compost_item(pos, &stack.item);
self.stats.add("used", &stack.item, 1);
self.consume_held();
}
+11 -10
View File
@@ -377,6 +377,7 @@ impl Game<'_> {
"sculk" => Ok(Some(self.sculk_command(&words)?)),
"hello" => Ok(Some(hello())),
"status" => Ok(Some(self.control_status())),
"memory" => Ok(Some(self.memory_report())),
"stall" => {
let millis: u64 = arg(1)?.parse()?;
if millis > 5000 {
@@ -394,7 +395,7 @@ impl Game<'_> {
json!({ "game_time": g.world.game_time, "projectiles": g.projectiles_status() })
})
})
.ok_or("the integrated server stopped")?
.ok_or(super::integrated::NO_ANSWER)?
} else {
json!({ "game_time": self.world.game_time, "projectiles": self.projectiles_status() })
};
@@ -410,7 +411,7 @@ impl Game<'_> {
if let Some(server) = &self.integrated {
server
.call(move |g| g.agent.hold_server = held)
.ok_or("the integrated server stopped")?;
.ok_or(super::integrated::NO_ANSWER)?;
} else if self.remote.is_some() {
return Err("server-hold requires the world's host".into());
} else {
@@ -428,10 +429,10 @@ impl Game<'_> {
)
.into());
}
// ConfirmScreen.keyPressed: Escape answers no, nothing else goes past it.
// The command confirmation takes the keys; none go past it.
if self.command_prompt.is_some() {
if key == KeyCode::Escape && arg(2)? != "up" {
self.answer_command_prompt(false);
if arg(2)? != "up" {
self.command_prompt_key(key);
}
return ok();
}
@@ -674,7 +675,7 @@ impl Game<'_> {
let command = format!("give @s {item} {count}");
server
.call(move |g| g.owner_console_command(&command))
.ok_or("the integrated server stopped")?;
.ok_or(super::integrated::NO_ANSWER)?;
return Ok(Some(json!({ "ok": true, "given": count })));
}
let max = info.max_stack;
@@ -727,7 +728,7 @@ impl Game<'_> {
if let Some(server) = &self.integrated {
server
.call(move |g| g.world.day_time = time)
.ok_or("the integrated server stopped")?;
.ok_or(super::integrated::NO_ANSWER)?;
}
ok()
}
@@ -746,7 +747,7 @@ impl Game<'_> {
let center = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position);
let value = server
.call(move |g| g.in_owner_level(|g| g.control_mobs_at(radius, center)))
.ok_or("the integrated server stopped")?;
.ok_or(super::integrated::NO_ANSWER)?;
return Ok(Some(value));
}
Ok(Some(self.control_mobs(radius)))
@@ -756,7 +757,7 @@ impl Game<'_> {
if let Some(server) = &self.integrated {
let value = server
.call(|g| g.control_hurt_log())
.ok_or("the integrated server stopped")?;
.ok_or(super::integrated::NO_ANSWER)?;
return Ok(Some(value));
}
Ok(Some(self.control_hurt_log()))
@@ -986,7 +987,7 @@ impl Game<'_> {
if let Some(server) = &self.integrated {
server
.call(move |g| g.control_save(all).map_err(|e| e.to_string()))
.ok_or("the integrated server stopped")??;
.ok_or(super::integrated::NO_ANSWER)??;
} else if self.remote.is_none() {
self.begin_control_save(all, reply.clone())?;
return Ok(None);
+115 -10
View File
@@ -3,7 +3,7 @@
//! breeding, conversions, drops), projectiles, player interactions
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
use super::{
Dim, Game, InteractTarget, dispenser, native_mob_draw::NativePlayerRender,
Dim, Game, Hand, InteractTarget, dispenser, native_mob_draw::NativePlayerRender,
native_projectiles::Impact, view_vector,
};
use horizon_assets::item_model::DisplayContext;
@@ -618,6 +618,14 @@ impl Game<'_> {
}
self.mobs.push(mob);
self.native_mobs_joined(self.mobs.len() - 1, false);
let transformed_kind = self
.mobs
.last()
.expect("converted mob was just inserted")
.kind
.id
.clone();
self.native_entity_transform(from.id, &from.kind.id, id, &transformed_kind);
Some(id)
}
/// The `summon` command: spawn a mob in front of the player or at a position.
@@ -1691,7 +1699,16 @@ impl Game<'_> {
None => None,
};
}
let player = (p.owner == Some(Who::Player))
.then(|| horizon_client::lan::playerdata::ints_uuid(self.acting_player_uuid()));
let name = if player.is_some() {
self.acting_player_name().to_owned()
} else {
String::new()
};
let (id, kind) = (p.id, p.kind.id());
self.creatures.projectiles.push(p);
self.native_projectile_launch(player, &name, id, kind);
}
/// `Entity.sendBubbleColumnParticles` at the top of the column block `pos`: two
/// splashes and two bubbles over it, which the level random places, shown here
@@ -2309,9 +2326,15 @@ impl Game<'_> {
self.with_level(|b, level, ticks| b.set_door_open(level, ticks, pos, open));
self.process_level_events()?;
}
MobEvent::BreakDoor { pos } => {
MobEvent::BreakDoor { mob, pos } => {
// BreakDoorGoal.tick: removeBlock, then level events 1021
// and 2001 with the state that is there now.
let kind = self
.mobs
.iter()
.find(|m| m.id == mob)
.map(super::native_hooks::mob_kind)
.unwrap_or_default();
let air = self.world.air;
let at = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, air, 3));
@@ -2319,6 +2342,7 @@ impl Game<'_> {
self.level_event_at(1021, pos, 0);
let left = self.world.block(pos[0], pos[1], pos[2]);
self.level_event_at(2001, pos, left.0 as i32);
self.native_entity_break_door(mob, &kind, pos);
}
MobEvent::DestroyProgress { mob, pos, progress } => {
self.destroy_block_progress(mob as i32, pos, progress);
@@ -2344,6 +2368,7 @@ impl Game<'_> {
}
MobEvent::LevelEvent { id, pos, data } => self.level_event_at(id, pos, data),
MobEvent::SetBlockName {
mob,
pos,
name,
effect,
@@ -2363,7 +2388,19 @@ impl Game<'_> {
self.level_event_at(2001, pos, state.0 as i32);
}
if let Some(state) = self.pack.assets.registry.default_state(name) {
let old_name = self
.res
.facts()
.get(self.world.block(pos[0], pos[1], pos[2]))
.name
.clone();
self.level_set_with_flags(pos, state, flags)?;
if let Some(mob) = mob
&& let Some(m) = self.mobs.iter().find(|m| m.id == mob)
{
let kind = m.kind.id.clone();
self.native_entity_change_block(mob, &kind, pos, &old_name, name);
}
}
}
MobEvent::Infest {
@@ -2566,7 +2603,12 @@ impl Game<'_> {
parent,
partner,
llama,
} => self.breed(parent, partner, llama),
} => {
let mob_count = self.mobs.len();
self.breed(parent, partner, llama);
let child = self.mobs.get(mob_count).map(|m| m.id);
self.native_entity_breed(parent, partner, child);
}
MobEvent::LockGaze { mob, partner } => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
horizon_client::mobs::animal_brain::lock_gaze(m, partner);
@@ -3897,9 +3939,11 @@ impl Game<'_> {
ArrowEvent::BubbleColumnParticles { pos } => self.bubble_column_particles(pos),
ArrowEvent::Block { pos, reaction } => match reaction {
BlockReaction::PrimeTnt => {
self.spawn_primed_tnt(pos, false, None, None);
self.with_level(|b, level, ticks| b.remove_block(level, ticks, pos, false));
let _ = self.process_level_events();
if self.tnt_prime_allowed(pos) {
self.spawn_primed_tnt(pos, false, None, None);
self.with_level(|b, level, ticks| b.remove_block(level, ticks, pos, false));
let _ = self.process_level_events();
}
}
BlockReaction::Light(lit) => {
let _ = self.level_set([pos.x, pos.y, pos.z], lit);
@@ -3988,6 +4032,7 @@ impl Game<'_> {
});
if let Some(strength) = hit {
let _ = self.process_level_events();
self.native_target_hit(self.dim.id(), pos, strength);
if p.owner == Some(Who::Player) && (p.guest.is_none() || p.guest == self.lan_acting) {
self.stats.custom("target_hit", 1);
let kind = if p
@@ -4862,6 +4907,34 @@ impl Game<'_> {
if self.name_mob(index) {
return Ok(true);
}
if self
.held()
.is_some_and(|stack| &*stack.item == "minecraft:shears")
{
let mob = &self.mobs[index];
let shearable = match mob.species() {
Species::Sheep => !mob.extra.sheared && !mob.is_baby(),
Species::Mooshroom => !mob.is_baby(),
Species::SnowGolem | Species::Bogged => mob.alive() && !mob.extra.sheared,
_ => false,
};
if shearable
&& let (Some(player), Some(name)) =
(self.lan_acting_uuid, self.lan_acting_name.clone())
{
let mut cancel = false;
self.native_player_shear_entity(
player,
&name,
mob.id as i32,
self.hand == Hand::Off,
&mut cancel,
);
if cancel {
return Ok(true);
}
}
}
if self.spawn_egg_offspring(index) {
self.swing_arm();
return Ok(true);
@@ -4900,9 +4973,10 @@ impl Game<'_> {
self.audio
.play(sound, Some(self.mobs[index].center()), 1.0, 1.0);
self.trigger_item("minecraft:filled_bucket", &filled);
let species = bucket.trim_end_matches("_bucket").to_owned();
let id = self.mobs[index].id as i32;
// Leashable.dropLeash, then discard.
let id = self.mobs[index].id;
self.break_leash(id, true, false);
self.break_leash(id as u32, true, false);
self.mobs[index].removed = true;
if self.creative {
if let Some(rest) = self.give(filled) {
@@ -4911,13 +4985,27 @@ impl Game<'_> {
} else {
*self.held_mut() = Some(filled);
}
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
{
self.native_player_bucket_entity(player, &name, id, &species);
}
self.swing_arm();
return Ok(true);
}
// AgeableMob.mobInteract: a golden dandelion on a baby freezes its
// growth, or lets it grow again.
if self.mobs[index].alive() && self.age_lock_takes(&self.mobs[index], &item) {
let entity_id = self.mobs[index].id;
self.set_age_locked(index);
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
{
self.native_player_toggle_entity_age_lock(
player,
&name,
entity_id,
self.mobs[index].extra.age_locked,
);
}
if let Some(stack) = &held {
let target = self.mob_entity(&self.mobs[index]);
self.trigger_item_entity("minecraft:player_interacted_with_entity", stack, &target);
@@ -5037,6 +5125,7 @@ impl Game<'_> {
let mut eat: Option<(&'static str, f32, f32)> = None;
let feet = [m.position.x, m.position.y, m.position.z];
let mut handled = true;
let mut ignited_creeper = false;
let mut drops: Vec<(String, u32)> = Vec::new();
// The shearing table rolled, for the mods (none for a mooshroom).
let mut drops_table = String::new();
@@ -5387,6 +5476,7 @@ impl Game<'_> {
}
Species::Creeper if creeper_igniter => {
m.extra.flag = true;
ignited_creeper = true;
// FIRECHARGE_USE for a fire charge, else FLINTANDSTEEL_USE,
// at the creeper's feet with a pitch from its own random.
let event = if item == "minecraft:fire_charge" {
@@ -5530,8 +5620,11 @@ impl Game<'_> {
// Animal.setInLove: entity event 18 (a baby that
// only grew is not in love).
if self.mobs[index].extra.in_love > 0 {
self.love_cause.insert(self.mobs[index].id, self.lan_acting);
self.mob_event_particles(self.mobs[index].id, 18);
let id = self.mobs[index].id;
self.love_cause.insert(id, self.lan_acting);
let kind = self.mobs[index].kind.id.clone();
self.native_entity_enter_love_mode(id, &kind, self.lan_acting_uuid);
self.mob_event_particles(id, 18);
}
}
// Animal.mobInteract still consumes the client click when
@@ -5544,6 +5637,13 @@ impl Game<'_> {
if !handled {
return Ok(false);
}
if ignited_creeper {
self.native_creeper_ignite(
self.mobs[index].id,
horizon_client::lan::playerdata::ints_uuid(acting),
&item,
);
}
if let Some(trusts) = ocelot_trust {
// `spawnTrustingParticles` on the clients: entity event 41 (hearts) or 40 (smoke).
self.mob_event_particles(self.mobs[index].id, if trusts { 41 } else { 40 });
@@ -5837,6 +5937,11 @@ impl Game<'_> {
*self.held_mut() = None;
}
}
if kind == ProjectileKind::Egg
&& let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
{
self.native_player_egg_throw(player, &name);
}
Ok(true)
}
/// `Mob.checkAndHandleImportantInteractions` with a spawn egg of mob
@@ -255,6 +255,12 @@ impl Game<'_> {
}
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()),
self.acting_player_name().to_owned(),
self.next_entity,
);
self.native_entity_shoot_bow(player, &name, projectile, power as f32);
}
self.player_projectile_shot();
let pitch = if index == 0 {
+20 -9
View File
@@ -1236,7 +1236,7 @@ impl Game<'_> {
.iter()
.map(|e| (e.user.id, e.level as u8))
.collect();
host.hub.resend_commands();
self.resend_commands_filtered();
}
/// A player on the server, by name.
@@ -1457,13 +1457,17 @@ impl Game<'_> {
}
"whitelist" => self.console_whitelist(state, &rest),
// The chat filter's (game/chat_filter.rs), on servers that filter.
"unmute"
if rest.len() == 1 && self.lan.as_ref().is_some_and(|h| h.hub.chat_filter_on()) =>
command
if super::chat_filter::MUTE_COMMANDS.contains(&command)
&& self.lan.as_ref().is_some_and(|h| h.hub.chat_filter_on()) =>
{
if let Some(mut host) = self.lan.take() {
let reply = self.unmute(&mut host, rest[0], "Server");
let words: Vec<&str> = [command].into_iter().chain(rest).collect();
let replies = self.mute_command(&mut host, &words, "Server");
self.lan = Some(host);
self.console_reply(lit(&reply));
for reply in replies {
self.console_reply(lit(&reply));
}
}
}
"ban" if !rest.is_empty() => {
@@ -1685,7 +1689,7 @@ impl Game<'_> {
match rest {
["on"] | ["off"] => {
let on = rest[0] == "on";
let mut l = state.lists.lock().unwrap();
let l = state.lists.lock().unwrap();
let turned_on = on && !l.use_whitelist;
let key = match (on, l.use_whitelist) {
(true, true) => "commands.whitelist.alreadyOn",
@@ -1693,9 +1697,8 @@ impl Game<'_> {
(false, false) => "commands.whitelist.alreadyOff",
(false, true) => "commands.whitelist.disabled",
};
l.use_whitelist = on;
drop(l);
self.store_property("white-list", on.to_string());
self.set_using_whitelist(&state.lists, on);
self.console_reply(Text::translate(key, &[]));
if turned_on {
let lists = state.lists.clone();
@@ -1763,8 +1766,16 @@ impl Game<'_> {
/// `DedicatedServer.setUsingWhitelist`: the flag, and `white-list` in
/// server.properties.
pub(super) fn set_using_whitelist(&mut self, lists: &Arc<Mutex<Lists>>, on: bool) {
lists.lock().unwrap().use_whitelist = on;
let changed = {
let mut lists = lists.lock().unwrap();
let changed = lists.use_whitelist != on;
lists.use_whitelist = on;
changed
};
self.store_property("white-list", on.to_string());
if changed {
self.native_whitelist_state_update(on);
}
}
/// DedicatedServerSettings.update: change one key and store the file.
+23
View File
@@ -4,6 +4,7 @@
//! connecting screen in configuration), its inputs, and the actions its
//! buttons send back.
use super::Game;
use super::remote_chat::Behind;
use horizon_client::{
Result,
clipboard::EditShortcut,
@@ -35,6 +36,9 @@ struct Open {
selecting: Option<usize>,
}
/// A dialog a clicked command closed, kept for its confirmation's no.
pub(super) struct ClosedDialog(Box<Open>);
/// A text input's control: `EditBox`, or `MultiLineEditBox` for a multiline one.
enum Field {
Line(ui::EditBox),
@@ -252,6 +256,12 @@ impl Game<'_> {
}
}
/// The confirmation's no: the dialog again, with what was typed in it.
pub(super) fn reopen_dialog(&mut self, dialog: ClosedDialog) {
self.dialogs.open = Some(*dialog.0);
self.dialogs.waiting = None;
}
/// `DialogScreen.runAction`: the click event, then the screen the dialog's
/// `after_action` leaves.
fn run_dialog_action(&mut self, click: Option<Click>, after: AfterAction) -> Result<()> {
@@ -266,6 +276,14 @@ impl Game<'_> {
}
return Ok(());
}
// openSendConfirmationWindow's `currentScreen`: a command that asks
// first still has this dialog to go back to.
let closing = match (&click, &after) {
(Some(Click::RunCommand(_)), AfterAction::Close | AfterAction::WaitForResponse) => {
self.dialogs.open.take()
}
_ => None,
};
match after {
AfterAction::None => {}
AfterAction::Close => self.dialogs.open = None,
@@ -284,6 +302,11 @@ impl Game<'_> {
// In configuration there is nothing to run it (vanilla warns).
if self.player.is_some() {
self.send_unattended_command(&command);
if let Some(open) = closing {
self.command_prompt_returns_to(Behind::Dialog(ClosedDialog(Box::new(
open,
))));
}
} else {
log::warn!(
"Horizon: commands are not supported in configuration, trying to run '{command}'"
+46 -8
View File
@@ -634,6 +634,13 @@ impl Game<'_> {
);
}
if let Some((left, event)) = mod_dispensed(answer) {
if event == FAIL {
self.native_block_failed_dispense(
self.dim.id(),
[pos.0, pos.1, pos.2],
&stack.item,
);
}
// DefaultDispenseItemBehavior.dispense: playSound, then playAnimation;
// a NOOP behaviour (event 0) plays neither.
if event != 0 {
@@ -690,6 +697,9 @@ impl Game<'_> {
}
_ => self.dispense_default_method(pos, facing, stack)?,
};
if event == FAIL {
self.native_block_failed_dispense(self.dim.id(), [pos.0, pos.1, pos.2], &item);
}
self.dispenser_event(event, pos, 0);
self.dispenser_event(2000, pos, data_value(facing));
Ok(left)
@@ -899,6 +909,12 @@ impl Game<'_> {
};
self.player_equipped(name, Some(&one));
self.apply_equipment();
self.native_block_dispense_armor(
self.dim.id(),
[pos.0, pos.1, pos.2],
&stack.item,
"player",
);
return true;
}
Target::Stand(i) => {
@@ -932,6 +948,16 @@ impl Game<'_> {
let ctx = self.who_context(who, None);
self.post_game_event("minecraft:equip", [at.x, at.y, at.z], ctx);
}
self.native_block_dispense_armor(
self.dim.id(),
[pos.0, pos.1, pos.2],
&stack.item,
match target {
Target::Stand(_) => "armor_stand",
Target::Mob(_) => "mob",
Target::Player => "player",
},
);
true
}
/// After `EntityType.spawn` / `MobBucketItem.spawn`: `mob.playAmbientSound()`.
@@ -1247,10 +1273,10 @@ impl Game<'_> {
horizon_client::items::bucket_contents(&item).expect("filled bucket dispatcher");
let target_pos = Self::pos_of(target);
let emptied = self.with_level(|b, level, ticks| {
b.bucket_empty(level, ticks, target_pos, content, sound, false, false)
b.bucket_empty(level, ticks, target_pos, None, content, sound, false, false)
});
self.process_level_events()?;
if !emptied {
if emptied.is_none() {
return Ok((self.dispense_default(pos, facing, stack), CLICK));
}
if self.dim == super::Dim::Nether
@@ -1354,6 +1380,9 @@ impl Game<'_> {
if self
.with_level(|b, level, ticks| b.fire_can_be_placed_at(level, ticks, target_pos, facing))
{
if !self.native_block_ignite_allowed(target_pos, "flint_and_steel") {
return Ok((Some(stack), CLICK));
}
let fire =
self.with_level(|b, level, ticks| b.fire_state_for(level, ticks, target_pos));
self.level_set([target.0, target.1, target.2], fire)?;
@@ -1374,7 +1403,7 @@ impl Game<'_> {
None,
);
} else if &*name == "minecraft:tnt" {
if self.ui.rules.bool("tnt_explodes") {
if self.ui.rules.bool("tnt_explodes") && self.tnt_prime_allowed(target_pos) {
self.spawn_primed_tnt(target_pos, false, None, None);
self.with_level(|b, level, ticks| b.remove_block(level, ticks, target_pos, false));
self.process_level_events()?;
@@ -1435,9 +1464,13 @@ impl Game<'_> {
if self.sulfur_cube_swallow(front, &stack) {
return (shrink(stack), optional_event("tnt"));
}
let target = Self::pos_of(front);
if !self.tnt_prime_allowed(target) {
set_success("tnt", false);
return (Some(stack), FAIL);
}
set_success("tnt", true);
// new PrimedTnt(level, x + 0.5, y, z + 0.5, null), its sound and ENTITY_PLACE.
let target = Self::pos_of(front);
let rot = self.world.random.next_double() * f64::from(std::f32::consts::TAU);
self.add_primed_tnt(target, rot, 80, None, None);
self.audio.play(
@@ -1746,10 +1779,7 @@ impl Game<'_> {
let random = self.rand_sequences.get(&sequence, self.level.seed);
world_items!(self).loot_table_from(table, false, held, random)
}
None => self
.res
.items()
.loot_table(table, false, held, &mut self.world.random),
None => world_items!(self).loot_table(table, false, held, &mut self.world.random),
}
}
/// `Entity.spawnAtLocation(level, stack, offset)`: an item at the entity's
@@ -1818,6 +1848,8 @@ impl Game<'_> {
continue;
}
let at = m.position;
let entity_id = m.id;
let kind = m.kind.id.clone();
let sound_at = Some([at.x, at.y, at.z]);
match m.species() {
Species::SulfurCube if sulfur_cube::has_body(m) => {
@@ -1936,6 +1968,12 @@ impl Game<'_> {
}
_ => continue,
}
self.native_block_shear_entity(
self.dim.id(),
[target.0, target.1, target.2],
entity_id,
&kind,
);
self.post_block_game_event(
"minecraft:shear",
[target.0, target.1, target.2],
+296 -57
View File
@@ -17,6 +17,7 @@ use horizon_client::{
projectiles::ProjectileKind,
text::Text,
};
use horizon_core::position::BlockPos;
use horizon_core::{
geometry::{Aabb, Vec3},
random::{LegacyRandom, RandomSource},
@@ -24,6 +25,42 @@ use horizon_core::{
use horizon_protocol::entity_data::Value;
use horizon_world::nbt::Tag;
/// `HealOrHarmMobEffect.applyInstantaneousEffect` at an `AreaEffectCloud`'s
/// 0.5 scale: `(int) (scale * (6 << amplifier) + 0.5)`.
fn harm_amount(amplifier: u32) -> f32 {
(0.5 * f64::from(6u32 << amplifier) + 0.5) as i32 as f32
}
/// Whether a cloud at `at` (its floor) reaches a living entity: its box is
/// `radius * 2` wide and 0.5 tall, and the entity's position must lie within
/// the radius horizontally.
fn cloud_hits(at: [f64; 3], radius: f32, bounds: Aabb, pos: Vec3) -> bool {
let r = f64::from(radius);
let area = Aabb::new(
Vec3::new(at[0] - r, at[1], at[2] - r),
Vec3::new(at[0] + r, at[1] + 0.5, at[2] + r),
);
let (dx, dz) = (pos.x - at[0], pos.z - at[2]);
bounds.intersects(area) && dx * dx + dz * dz <= f64::from(radius * radius)
}
/// `DragonFireball.onHit`: the cloud moves to the first living entity in the
/// fireball's box inflated by (4, 2, 4) that is within 4 blocks of it.
/// `feet` is the fireball's position (the bottom of its 1x1x1 box).
fn fireball_cloud_origin(feet: [f64; 3], targets: &[(Aabb, Vec3)]) -> [f64; 3] {
let area = Aabb::new(
Vec3::new(feet[0] - 4.5, feet[1] - 2.0, feet[2] - 4.5),
Vec3::new(feet[0] + 4.5, feet[1] + 3.0, feet[2] + 4.5),
);
for (bounds, pos) in targets {
let (dx, dy, dz) = (pos.x - feet[0], pos.y - feet[1], pos.z - feet[2]);
if bounds.intersects(area) && dx * dx + dy * dy + dz * dz < 16.0 {
return [pos.x, pos.y, pos.z];
}
}
feet
}
/// Mob ids from here on are dragon parts (`SPECIAL + part`) and crystals
/// (`SPECIAL + 16 + crystal`), so projectiles can target them.
pub(super) const SPECIAL: u32 = u32::MAX - 4096;
@@ -1897,6 +1934,17 @@ impl Game<'_> {
};
super::view_vector(d.yaw, pitch).map(|v| v)
}
/// `getHeadLookVector(1).normalize()`, which the phases' `doClientTick`s
/// take (a look the dragon has never turned from stays the zero vector).
fn dragon_look_normalised(&self) -> [f64; 3] {
let look = self.dragon_look();
let norm = (look[0] * look[0] + look[1] * look[1] + look[2] * look[2]).sqrt();
if norm < 1.0e-5 {
[0.0; 3]
} else {
[look[0] / norm, look[1] / norm, look[2] / norm]
}
}
/// `EnderDragon.aiStep` (and `tickDeath`) for the dragon.
fn tick_dragon(&mut self) {
let Some(d) = self.dragon.as_mut() else {
@@ -2272,7 +2320,17 @@ impl Game<'_> {
f.full_collision || f.legacy_solid
})
.map_or(64, |y| y + 1);
let _ = self.level_set([0, y, 0], egg);
let pos = BlockPos::new(0, y, 0);
let allowed = self.with_level(|_, level, _| {
let mut cancel = false;
if level.native_authoritative {
level.native_dragon_egg_form(level.dimension, pos, &mut cancel);
}
!cancel
});
if allowed {
let _ = self.level_set([0, y, 0], egg);
}
}
self.fight.previously_killed = true;
self.fight.killed = true;
@@ -2312,6 +2370,13 @@ impl Game<'_> {
.filter(|p| p.alive && !p.spectator)
.map(|p| p.bounds)
.collect();
// DragonFireball.onHit snaps to any living entity (mobs not ported here).
let snap_targets: Vec<(Aabb, Vec3)> = self
.fight_players()
.iter()
.filter(|p| p.alive)
.map(|p| (p.bounds, p.position))
.collect();
balls.retain_mut(|b| {
b.previous = b.position;
b.age += 1;
@@ -2341,9 +2406,13 @@ impl Game<'_> {
});
if hit_block || hit_player || b.age > 600 {
let id = self.take_entity_id();
// The cloud starts at the fireball's feet; DragonFireball.onHit
// then moves it to the first living entity within 4 blocks.
let feet = [next.x, next.y - 0.5, next.z];
let at = fireball_cloud_origin(feet, &snap_targets);
self.breath_clouds.push(Breath {
id,
at: [next.x, next.y, next.z],
at,
radius: 3.0,
age: 0,
duration: 600,
@@ -2425,13 +2494,11 @@ impl Game<'_> {
}
// AreaEffectCloud: after its 20-tick wait, every 20 ticks per entity.
if c.age >= 20 && c.age % 20 == 0 {
// Instant damage: I (6) from the breath, II (12) from a fireball.
let amount = if c.from_dragon { 6.0 } else { 12.0 };
// Instant damage I from the flame cloud, II from a fireball's,
// both at the cloud's 0.5 scale.
let amount = harm_amount(u32::from(!c.from_dragon));
for p in &players {
let pp = p.position;
if (pp.x - c.at[0]).hypot(pp.z - c.at[2]) <= f64::from(r)
&& (pp.y - c.at[1]).abs() < 1.0
{
if cloud_hits(c.at, r, p.bounds, p.position) {
harmed.push((p.who, amount));
}
}
@@ -2571,6 +2638,67 @@ impl DragonFireball {
);
}
}
/// `DragonLandingPhase.doClientTick` and `DragonSittingFlamingPhase.doClientTick`:
/// the dragon-breath particles one tick of `doClientTick` spawns at the head, as
/// (position, velocity) pairs. `look` is `EnderDragon.getHeadLookVector(1)`
/// normalised, `head` the head part's centre (`getY(0.5)`), `motion` the
/// dragon's `getDeltaMovement`, and `random` the stream the jitter comes from.
/// Both phases rotate `look` by a quarter turn and throw the result away, so
/// every puff of a fan leaves along the same line; a flaming dragon walks down
/// that line in six steps.
fn breath_puffs(
phase: &Phase,
look: [f64; 3],
head: [f64; 3],
motion: [f64; 3],
random: &mut LegacyRandom,
) -> Vec<([f64; 3], [f64; 3])> {
// A puff's start: the head's centre jittered by `nextGaussian() / 2`.
let jitter = |random: &mut LegacyRandom| {
[
head[0] + random.next_gaussian() / 2.0,
head[1] + random.next_gaussian() / 2.0,
head[2] + random.next_gaussian() / 2.0,
]
};
match phase {
// Eight a tick, along `-look * 0.08F` (0.3 for the vertical part) plus
// the dragon's own motion.
Phase::Landing { .. } => (0..8)
.map(|_| {
(
jitter(random),
[
-look[0] * f64::from(0.08_f32) + motion[0],
-look[1] * f64::from(0.3_f32) + motion[1],
-look[2] * f64::from(0.08_f32) + motion[2],
],
)
})
.collect(),
// Every second tick of the first ten: eight starts, each a fan of six
// puffs down the look line, the vertical part of the speed once.
Phase::Flaming { ticks } if ticks % 2 == 0 && *ticks < 10 => {
let mut puffs = Vec::with_capacity(48);
for _ in 0..8 {
let at = jitter(random);
for j in 0..6 {
let j = f64::from(j);
puffs.push((
at,
[
-look[0] * f64::from(0.08_f32) * j,
-look[1] * f64::from(0.6_f32),
-look[2] * f64::from(0.08_f32) * j,
],
));
}
}
puffs
}
_ => Vec::new(),
}
}
pub(super) struct Breath {
/// Its entity id once a server's players track it (0 until then).
pub(super) id: u32,
@@ -3768,13 +3896,7 @@ impl Game<'_> {
/// away in vanilla, so every puff leaves along the same line), the third
/// puffs an explosion every 10 ticks and the last growls on every tick.
fn remote_dragon_phase_tick(&mut self) {
let look = self.dragon_look();
let norm = (look[0] * look[0] + look[1] * look[1] + look[2] * look[2]).sqrt();
let look = if norm < 1.0e-5 {
[0.0; 3]
} else {
[look[0] / norm, look[1] / norm, look[2] / norm]
};
let look = self.dragon_look_normalised();
let Some(d) = self.dragon.as_mut() else {
return;
};
@@ -3782,51 +3904,20 @@ impl Game<'_> {
let head_y = head.y + PART_SIZES[0].1 * 0.5;
let motion = d.velocity;
let at = [d.position.x, d.position.y, d.position.z];
let mut puffs: Vec<([f64; 3], [f64; 3])> = Vec::new();
// `flameTicks`: the client copy's own counter, advanced before it is read.
if let Phase::Flaming { ticks } = &mut d.phase {
*ticks += 1;
}
let puffs = breath_puffs(
&d.phase,
look,
[head.x, head_y, head.z],
[motion.x, motion.y, motion.z],
&mut d.random,
);
let mut emitter = None;
let mut growl = None;
match &mut d.phase {
Phase::Landing { .. } => {
for _ in 0..8 {
let r = &mut d.random;
let p = [
head.x + r.next_gaussian() / 2.0,
head_y + r.next_gaussian() / 2.0,
head.z + r.next_gaussian() / 2.0,
];
puffs.push((
p,
[
-look[0] * f64::from(0.08_f32) + motion.x,
-look[1] * f64::from(0.3_f32) + motion.y,
-look[2] * f64::from(0.08_f32) + motion.z,
],
));
}
}
Phase::Flaming { ticks } => {
*ticks += 1;
if *ticks % 2 == 0 && *ticks < 10 {
for _ in 0..8 {
let r = &mut d.random;
let p = [
head.x + r.next_gaussian() / 2.0,
head_y + r.next_gaussian() / 2.0,
head.z + r.next_gaussian() / 2.0,
];
for j in 0..6 {
puffs.push((
p,
[
-look[0] * f64::from(0.08_f32) * f64::from(j),
-look[1] * f64::from(0.6_f32),
-look[2] * f64::from(0.08_f32) * f64::from(j),
],
));
}
}
}
}
Phase::Dying { time, .. } => {
if *time % 10 == 0 {
let r = &mut d.random;
@@ -3975,6 +4066,53 @@ fn push_seen_with_hurt(
mod tests {
use super::*;
fn player_box(feet: [f64; 3]) -> (Aabb, Vec3) {
(
Aabb::new(
Vec3::new(feet[0] - 0.3, feet[1], feet[2] - 0.3),
Vec3::new(feet[0] + 0.3, feet[1] + 1.8, feet[2] + 0.3),
),
Vec3::new(feet[0], feet[1], feet[2]),
)
}
#[test]
fn instant_damage_at_cloud_scale() {
assert_eq!(harm_amount(0), 3.0);
assert_eq!(harm_amount(1), 6.0);
}
#[test]
fn cloud_box_is_half_a_block_tall_from_its_floor() {
let at = [0.0, 64.0, 0.0];
let hit = |feet: [f64; 3], r: f32| {
let (b, p) = player_box(feet);
cloud_hits(at, r, b, p)
};
assert!(hit([0.0, 64.0, 0.0], 3.0));
// Feet above the 0.5 box: the old |dy| < 1 test hit here.
assert!(!hit([0.0, 64.6, 0.0], 3.0));
// Feet below, head reaching into the box.
assert!(hit([0.0, 63.0, 0.0], 3.0));
// Box overlaps but the centre is outside the radius.
assert!(!hit([3.2, 64.0, 0.0], 3.0));
assert!(hit([3.0, 64.0, 0.0], 3.0));
}
#[test]
fn fireball_cloud_snaps_to_a_player_within_four() {
let (b, p) = player_box([2.0, 70.0, 1.0]);
assert_eq!(
fireball_cloud_origin([0.0, 70.0, 0.0], &[(b, p)]),
[2.0, 70.0, 1.0]
);
let (b, p) = player_box([4.5, 70.0, 0.0]);
assert_eq!(
fireball_cloud_origin([0.0, 70.0, 0.0], &[(b, p)]),
[0.0, 70.0, 0.0]
);
}
#[test]
fn dying_dragons_keep_their_flight_pose_between_frames() {
let mut dragon = Dragon::new(Vec3::new(0.0, 80.0, 0.0), 0.0, 432);
@@ -4094,6 +4232,107 @@ mod tests {
}
}
/// `DragonLandingPhase.doClientTick`: eight puffs a tick from the head,
/// along `getHeadLookVector(1)` (a quarter turn of it is thrown away in
/// vanilla) plus the dragon's own motion.
#[test]
fn a_landing_dragon_breathes_eight_puffs_from_its_head() {
let look = [0.0, -0.6, 0.8];
let head = [10.0, 70.5, -3.0];
let motion = [0.25, -0.05, 0.5];
let mut random = LegacyRandom::new(7);
let puffs = breath_puffs(
&Phase::Landing { target: None },
look,
head,
motion,
&mut random,
);
assert_eq!(puffs.len(), 8);
let speed = [
-look[0] * f64::from(0.08_f32) + motion[0],
-look[1] * f64::from(0.3_f32) + motion[1],
-look[2] * f64::from(0.08_f32) + motion[2],
];
for (at, v) in puffs {
assert_eq!(v, speed);
// The head's centre, jittered by `nextGaussian() / 2`.
for a in 0..3 {
assert!(
(at[a] - head[a]).abs() < 2.0,
"puff {} off the head: {at:?}",
a
);
}
}
}
/// `DragonSittingFlamingPhase.doClientTick`: on the even ticks of the first
/// ten, eight starts each breathe a fan of six puffs down the look line.
#[test]
fn a_flaming_dragon_breathes_a_fan_on_every_second_tick() {
let look = [0.6, -0.0, 0.8];
let head = [0.0, 65.5, 4.0];
for (tick, want) in [(1, 0), (2, 48), (3, 0), (4, 48), (8, 48), (9, 0), (10, 0)] {
let mut random = LegacyRandom::new(11);
let puffs = breath_puffs(
&Phase::Flaming { ticks: tick },
look,
head,
[0.0; 3],
&mut random,
);
assert_eq!(puffs.len(), want, "flame tick {tick}");
}
let mut random = LegacyRandom::new(11);
let puffs = breath_puffs(
&Phase::Flaming { ticks: 2 },
look,
head,
[0.0; 3],
&mut random,
);
for (i, (at, v)) in puffs.iter().enumerate() {
let j = (i % 6) as f64;
// Six puffs leave from each of the eight starts.
assert_eq!(at, &puffs[i - i % 6].0);
assert_eq!(
*v,
[
-look[0] * f64::from(0.08_f32) * j,
-look[1] * f64::from(0.6_f32),
-look[2] * f64::from(0.08_f32) * j,
]
);
}
}
/// No other phase has a `doClientTick` that breathes.
#[test]
fn only_the_landing_and_flaming_phases_breathe() {
let mut random = LegacyRandom::new(3);
for phase in [
flying(),
Phase::Scanning { time: 5 },
Phase::Hover { target: None },
Phase::Takeoff {
first: false,
path: None,
target: None,
},
Phase::Attacking { ticks: 3 },
Phase::Dying {
target: None,
time: 0,
},
] {
assert!(
breath_puffs(&phase, [0.0, -1.0, 0.0], [0.0; 3], [0.0; 3], &mut random).is_empty(),
"{phase:?}"
);
}
}
#[test]
fn a_perched_dragon_shrugs_arrows_off() {
// AbstractDragonSittingPhase.onHurt: an arrow does nothing, melee does.
@@ -584,9 +584,21 @@ impl Game<'_> {
return amount;
}
current.damage -= repaired;
let item = current.item.to_string();
let player = (self.serving.is_some() || self.options.dedicated.is_some())
.then(|| self.native_acting_id());
let remaining = amount.saturating_sub(
(u64::from(repaired) * u64::from(amount) / u64::from(to_repair_xp)) as u32,
);
if let Some(player) = player
&& let Some(name) = self
.native_server_players()
.into_iter()
.find(|p| p.uuid == player)
.map(|p| p.name)
{
self.native_player_item_mend(player, &name, &item, repaired, amount);
}
if remaining == 0 {
return 0;
}
+3 -3
View File
@@ -706,6 +706,7 @@ impl Game<'_> {
}
}
}
self.native_portal_create("minecraft:the_end", origin);
// Direction.WEST.toYRot().
return Ok(Some((target, Some(90.0))));
}
@@ -1302,8 +1303,8 @@ impl Game<'_> {
target
};
let survive = self.world.random.next_int_bound(5).unwrap_or(0) > 0;
self.next_entity += 1;
let id = self.next_entity;
let at = [p.position.x, p.position.y, p.position.z];
let id = self.take_entity_id();
self.end.eyes.push(EyeOfEnder {
id,
position: start,
@@ -1315,7 +1316,6 @@ impl Game<'_> {
});
let r = self.world.random.next_float();
let pitch = 0.33 + r * (0.5 - 0.33);
let at = [p.position.x, p.position.y, p.position.z];
self.audio
.play("minecraft:entity.ender_eye.launch", Some(at), 1.0, pitch);
self.consume_held();
+7 -3
View File
@@ -224,10 +224,10 @@ impl Game<'_> {
let lifetime = 10 * (1 + i32::from(flight))
+ r.next_int_bound(6).unwrap_or(0)
+ r.next_int_bound(7).unwrap_or(0);
// An id from the entity counter at once, so a command can name it.
self.next_entity += 1;
// Entity.<init>: the id comes from the one counter at construction.
let id = self.take_entity_id();
Rocket {
id: self.next_entity,
id,
position: at,
previous: at,
velocity,
@@ -345,6 +345,9 @@ impl Game<'_> {
self.consume_held();
}
self.swing_arm();
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
self.native_player_elytra_boost(player, &name);
}
true
}
pub(super) fn tick_rockets(&mut self) {
@@ -517,6 +520,7 @@ impl Game<'_> {
let v = [r.velocity.x, r.velocity.y, r.velocity.z];
self.particles
.fireworks(at, v, r.explosions.clone(), camera);
self.native_firework_explode(r.id, at, r.explosions.len());
}
fn rocket_hurt_mobs(&mut self, hits: &[(u32, f32)], attacker: Option<Who>, at: [f64; 3]) {
for &(id, amount) in hits {
+15
View File
@@ -210,6 +210,8 @@ impl Game<'_> {
} else if let Some(item) = self.dropped.iter_mut().find(|i| i.id == id) {
item.velocity = pulled(item.velocity);
hooked_item = true;
} else if let Some(t) = self.tnts.iter_mut().find(|t| t.id != 0 && t.id == id) {
t.velocity = pulled(t.velocity);
} else if self.creatures.hook_targets.iter().any(|t| t.id == id) {
// A player: the hub moves them when the tick ends.
self.creatures
@@ -607,6 +609,13 @@ impl Game<'_> {
.iter()
.find(|i| touches(i.bounds()))
.map(|i| i.id)
})
// A lit TNT is pickable too (`PrimedTnt.isPickable`).
.or_else(|| {
self.tnts
.iter()
.find(|t| t.id != 0 && touches(t.bounds()))
.map(|t| t.id)
});
if let Some(id) = hit {
// Projectile.onHit on an entity: PROJECTILE_LAND.
@@ -674,6 +683,12 @@ impl Game<'_> {
.iter()
.find(|i| i.id == id)
.map(|i| (i.position, horizon_client::entities::ItemEntity::WIDTH))
})
.or_else(|| {
self.tnts
.iter()
.find(|t| t.id != 0 && t.id == id)
.map(|t| (t.position, super::tnt::PrimedTnt::SIZE))
});
match hooked {
Some((at, height)) => h.position = Vec3::new(at.x, at.y + height * 0.8, at.z),
+48
View File
@@ -125,6 +125,17 @@ impl Game<'_> {
.iter()
.any(|o| o.id != f.id && o.facing == f.facing && overlaps(o.bounds()))
}
/// Asks the mods whether the acting guest may place a hanging entity
/// of type `kind` at `pos` (`native_hanging_place`): true when one
/// cancelled it. A game's own player is not asked.
pub(super) fn hanging_place_cancelled(&mut self, kind: &str, pos: [i32; 3]) -> bool {
let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) else {
return false;
};
let mut cancel = false;
self.native_hanging_place(uuid, &name, kind, pos, &mut cancel);
cancel
}
/// `ItemFrameItem.useOn`.
pub(super) fn place_item_frame(&mut self, hit: &Hit, stack: &ItemStack) -> bool {
let glow = match &*stack.item {
@@ -162,6 +173,14 @@ impl Game<'_> {
if !self.frame_survives(&frame) {
return false;
}
let kind = if glow {
"minecraft:glow_item_frame"
} else {
"minecraft:item_frame"
};
if self.hanging_place_cancelled(kind, pos) {
return true;
}
self.next_entity += 1;
self.audio
.play(&sound(&frame, "place"), Some(center(&frame)), 1.0, 1.0);
@@ -224,11 +243,14 @@ impl Game<'_> {
return false;
}
let at = center(&self.item_frames[i]);
let entity_id = self.item_frames[i].id;
let action;
if self.item_frames[i].item.is_none() {
let Some(held) = self.held().cloned() else {
return false;
};
self.item_frames[i].item = Some(ItemStack { count: 1, ..held });
action = "put";
self.audio
.play(&sound(&self.item_frames[i], "add_item"), Some(at), 1.0, 1.0);
if !self.creative {
@@ -237,12 +259,21 @@ impl Game<'_> {
} else {
let f = &mut self.item_frames[i];
f.rotation = (f.rotation + 1) % 8;
action = "rotate";
let s = sound(f, "rotate_item");
self.audio.play(&s, Some(at), 1.0, 1.0);
}
let p = self.item_frames[i].pos;
self.world.modified.insert((p[0] >> 4, p[2] >> 4));
self.frame_output_changed(p);
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
let frame = self.item_frames.iter().find(|f| f.id == entity_id);
let item = frame
.and_then(|f| f.item.as_ref())
.map_or_else(String::new, |s| s.item.to_string());
let rotation = frame.map_or(0, |f| f.rotation);
self.native_player_item_frame_change(player, &name, entity_id, action, &item, rotation);
}
self.swing_arm();
true
}
@@ -264,6 +295,13 @@ impl Game<'_> {
return true;
}
let with_frame = self.item_frames[i].item.is_none();
let entity_id = self.item_frames[i].id;
let old_item = self.item_frames[i]
.item
.as_ref()
.map_or("", |s| &*s.item)
.to_owned();
let rotation = self.item_frames[i].rotation;
let at = center(&self.item_frames[i]);
let f = if with_frame {
self.item_frames.remove(i)
@@ -284,6 +322,16 @@ impl Game<'_> {
if f.item.is_some() {
self.frame_output_changed(f.pos);
}
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
self.native_player_item_frame_change(
player,
&name,
entity_id,
if with_frame { "break" } else { "remove_item" },
if with_frame { "" } else { &old_item },
rotation,
);
}
true
}
/// `ItemFrame.setItem` / `setRotation`: `updateNeighbourForOutputSignal`, so
+26 -3
View File
@@ -32,12 +32,19 @@ use std::{
sync::{
Arc, Mutex,
atomic::{AtomicBool, AtomicI32, AtomicU16, AtomicU32, Ordering},
mpsc::{Receiver, Sender, channel, sync_channel},
mpsc::{Receiver, RecvTimeoutError, Sender, channel, sync_channel},
},
thread::JoinHandle,
time::{Duration, Instant},
};
/// How long the window waits for the server thread to answer a `call`.
const CALL_TIMEOUT: Duration = Duration::from_secs(10);
/// The error of a `call` without an answer: the server stopped, or it was
/// busy past `CALL_TIMEOUT` (the log says which).
pub(super) const NO_ANSWER: &str =
"the integrated server stopped or did not answer within 10 s (the game log says which)";
/// `LevelLoadProgressTracker.EXPECTED_PLAYER_CHUNKS`: the 7x7 chunks around the spawn.
const PLAYER_CHUNKS: usize = 49;
@@ -177,7 +184,9 @@ impl Integrated {
Some(self.flags.published.load(Ordering::Relaxed)).filter(|&p| p != 0)
}
/// `executeBlocking`: run `f` on the server thread between two ticks and
/// wait for its answer; `None` once the server has stopped.
/// wait for its answer; `None` once the server has stopped, or when it has
/// not answered within `CALL_TIMEOUT` (`f` still runs there later). The log
/// tells the two apart.
pub fn call<T: Send + 'static>(
&self,
f: impl FnOnce(&mut Game<'_>) -> T + Send + 'static,
@@ -188,7 +197,21 @@ impl Integrated {
let _ = tx.send(f(g));
}))
.ok()?;
rx.recv_timeout(Duration::from_secs(10)).ok()
match rx.recv_timeout(CALL_TIMEOUT) {
Ok(answer) => Some(answer),
Err(RecvTimeoutError::Timeout) => {
log::warn!(
"Horizon: the server thread did not answer within {} s (running: {}, \
autosaving: {}, smoothed tick {:.1} ms)",
CALL_TIMEOUT.as_secs(),
!self.stopped(),
self.is_saving(),
self.smoothed_tick_ms()
);
None
}
Err(RecvTimeoutError::Disconnected) => None,
}
}
/// `IntegratedServer.halt(true)`: returns once the world is saved and let go.
pub fn stop(mut self) -> Result<()> {
+44 -4
View File
@@ -1133,7 +1133,7 @@ impl Game<'_> {
// The hub is the server thread's.
let status = server
.call(|g| g.lan_status())
.ok_or("the integrated server stopped")?;
.ok_or(super::integrated::NO_ANSWER)?;
return Ok(Some(status));
}
match words.get(1).copied() {
@@ -1225,7 +1225,7 @@ impl Game<'_> {
// ShareToLanScreen: IntegratedServer.publishServer.
server
.call(move |g| g.publish(settings))
.ok_or("the integrated server stopped")??
.ok_or(super::integrated::NO_ANSWER)??
} else if self.serving() {
return self.publish(settings).map(drop).map_err(Into::into);
} else {
@@ -1708,8 +1708,8 @@ impl Game<'_> {
host.hub.config.guest_commands = effective;
host.hub.config.force_game_mode = force;
enforce_guest_game_mode(host, &self.world, self.collision);
host.hub.resend_commands();
}
self.resend_commands_filtered();
}
pub(super) fn lan_port(&self) -> Option<u16> {
@@ -1884,6 +1884,27 @@ impl Game<'_> {
}
}
HostEvent::Joined(conn) => {
// The mods may refuse the login before the player is placed.
if let Some((uuid, name)) =
host.hub.guest(conn).map(|g| (g.uuid, g.name.clone()))
{
let mut cancel = false;
let mut reason = None;
self.native_player_login(uuid, &name, &mut cancel, &mut reason);
if cancel {
let reason = match reason {
Some(text) => {
horizon_client::lan::data::component_nbt(&text.component())
}
None => horizon_protocol::text::translatable(
"multiplayer.disconnect.generic",
&[],
),
};
host.hub.kick(conn, reason);
continue;
}
}
self.lan_guest_joined(&mut host, conn, state, entities, spawn);
if self.options.dedicated.is_some()
&& let Some(g) = host.hub.guest(conn)
@@ -1996,6 +2017,18 @@ impl Game<'_> {
HostEvent::Flight { conn, flying } => {
self.lan_flight_toggled(&mut host, conn, flying);
}
HostEvent::MainHandChanged(conn, left) => {
if let Some(g) = host.hub.guest(conn) {
let (uuid, name) = (g.uuid, g.name.clone());
self.native_player_change_main_hand(uuid, &name, left);
}
}
HostEvent::LocaleChanged(conn, locale) => {
if let Some(g) = host.hub.guest(conn) {
let (uuid, name) = (g.uuid, g.name.clone());
self.native_player_locale_change(uuid, &name, &locale);
}
}
HostEvent::Packet(conn, packet) => {
self.lan_guest_packet(&mut host, conn, *packet);
if !host.commands.is_empty() {
@@ -2381,6 +2414,8 @@ impl Game<'_> {
g.dimension = in_place;
}
self.load_guest_records(host, conn);
// The mods say which commands the newcomer's tree keeps.
self.native_filter_commands(host, Some(conn));
let mut theirs = States::default();
let entities = if elsewhere {
if let Err(e) = self.enter_level(Some(host), level) {
@@ -2857,7 +2892,12 @@ impl Game<'_> {
.map(|s| s.as_ref().map(|s| s.item.clone()));
m.extra.armor = g.armor.clone();
m.extra.crouching = g.sneaking;
m.extra.avatar_pose = g.pose();
let from_pose = m.extra.avatar_pose;
let to_pose = g.pose();
m.extra.avatar_pose = to_pose;
if from_pose != to_pose {
self.native_entity_pose_change(g.uuid, &g.name, from_pose.name(), to_pose.name());
}
m.extra.sprinting = g.sprinting;
// Entity.DATA_POSE and the shared and living flags the others
// get (hub::guest_body): gliding, swimming, Riptide's spin.
+252 -15
View File
@@ -663,6 +663,28 @@ impl Game<'_> {
}
// A totem that saved the guest this turn (its hurt set it off).
let totem = self.apply_totem();
// The mods hear the food level the turn changed (eating, hunger,
// starving...), once for the change it made as a whole, with the
// guest's state still swapped in; they may change or undo it before it
// reaches the guest.
if let Some(now) = self.player.as_ref().map(|p| p.health.food.food)
&& let Some(g) = host.hub.guests.get(swap.i)
&& g.food != now
{
let (uuid, name, from, saturation) = (g.uuid, g.name.clone(), g.food, g.saturation);
let (mut to, mut cancel) = (now, false);
self.native_food_level_changed(uuid, &name, from, &mut to, &mut cancel);
if let Some(p) = self.player.as_mut() {
if cancel {
p.health.food.food = from;
p.health.food.saturation = saturation;
} else if to != now {
p.health.food.food = to.clamp(0, 20);
p.health.food.saturation =
p.health.food.saturation.min(p.health.food.food as f32);
}
}
}
let GuestSwap {
conn,
i,
@@ -1025,6 +1047,8 @@ impl Game<'_> {
if let Some(mob) = guest_mount {
self.lan_open_mount(host, conn, mob);
}
// The turn's teleports are in the hub now: the mods hear them.
self.flush_teleport_notes(host);
}
/// `ServerLevel.broadcastDamageEvent` for a guest that took an unblocked hit.
@@ -1098,6 +1122,31 @@ impl Game<'_> {
self.creative = self.mode == GameMode::Creative;
}
/// Whether the mods let the guest open the menu `kind` (`native_container_opened`),
/// heard with the guest's state swapped in. Asked before the menu that
/// was open closes.
pub(super) fn guest_may_open(
&mut self,
host: &mut LanHost,
conn: u32,
kind: &str,
pos: Option<[i32; 3]>,
) -> bool {
let Some((uuid, name)) = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone())) else {
return true;
};
let mut cancel = false;
let mut hook =
|game: &mut Self| game.native_container_opened(uuid, &name, kind, pos, &mut cancel);
// The guest whose turn it is is in place already.
if self.lan_acting == Some(conn) {
hook(self);
} else {
self.as_guest(host, conn, hook);
}
!cancel
}
/// Hand a container the guest opened over (ServerPlayer.openMenu);
/// false when it is not one a guest can open.
fn lan_open_container(&mut self, host: &mut LanHost, conn: u32, pos: (i32, i32, i32)) -> bool {
@@ -1202,10 +1251,15 @@ impl Game<'_> {
Slots::Block(k) => [Some(*k), None],
_ => [None, None],
};
let locked = halves
.iter()
.flatten()
.any(|&k| self.container_locked_for(k, spectator, hand.as_ref()));
let Some((uuid, name)) = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone())) else {
return false;
};
let mut locked = false;
for key in halves.iter().flatten().copied() {
let mut check = self.container_locked_for(key, spectator, hand.as_ref());
self.native_block_lock_check(uuid, &name, [key.0, key.1, key.2], &mut check);
locked |= check;
}
if locked {
host.hub.send_to(
conn,
@@ -1246,6 +1300,15 @@ impl Game<'_> {
if !horizon_client::containers::menu_reaches(brand, menu) {
return false;
}
// The mods may stop the menu; the click is spent.
if !self.guest_may_open(host, conn, menu, Some([pos.0, pos.1, pos.2])) {
return true;
}
let mut cancel = false;
self.native_inventory_block_start(uuid, &name, [pos.0, pos.1, pos.2], &mut cancel);
if cancel {
return false;
}
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let hidden = self.native_hidden_slots(pos, items.len());
@@ -1294,6 +1357,10 @@ impl Game<'_> {
else {
return false;
};
// The mods may stop the menu; the click is spent.
if !self.guest_may_open(host, conn, menu, None) {
return true;
}
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
host.hub
@@ -1784,6 +1851,25 @@ impl Game<'_> {
// SWAP_ITEM_WITH_OFFHAND: the hands swap and the item in use stops
// (ServerGamePacketListenerImpl.handlePlayerAction), not for spectators.
7 if !spectator => {
// The mods hear the swap first, and may stop it: the
// client, which swapped at once, gets its slots back.
let (uuid, name) = {
let g = &host.hub.guests[gi];
(g.uuid, g.name.clone())
};
// With the player's state swapped in, so that the helpers
// reach them.
let cancel = self
.as_guest(host, conn, |g| {
let mut cancel = false;
g.native_player_swap_hands(uuid, &name, &mut cancel);
cancel
})
.unwrap_or(false);
if cancel {
host.hub.send_inventory(gi);
return;
}
let g = &mut host.hub.guests[gi];
let slot = g.selected;
std::mem::swap(&mut g.inventory[slot], &mut g.offhand);
@@ -1796,6 +1882,8 @@ impl Game<'_> {
g.player_on_equip("offhand", main.as_ref(), offhand.as_ref());
g.player_on_equip("mainhand", offhand.as_ref(), main.as_ref());
});
let guest = &host.hub.guests[gi];
self.native_player_swap_with_equipment_slot(guest.uuid, &guest.name, "offhand");
}
// STAB: `PiercingWeapon.attack` of a left click with a spear.
8 if !spectator => self.guest_jab(host, conn),
@@ -1803,11 +1891,37 @@ impl Game<'_> {
},
// handleSetCarriedItem: a new slot stops a main-hand use.
S::SetCarriedItem(p) => {
let g = &mut host.hub.guests[gi];
if !(0..9).contains(&p.slot) {
eprintln!("{} tried to set an invalid carried item", g.name);
eprintln!(
"{} tried to set an invalid carried item",
host.hub.guests[gi].name
);
return;
}
let (uuid, name, old_slot) = {
let g = &host.hub.guests[gi];
(g.uuid, g.name.clone(), g.selected)
};
if old_slot != p.slot as usize {
let mut cancel = false;
self.native_player_item_held(
uuid,
&name,
old_slot as i32,
i32::from(p.slot),
&mut cancel,
);
if cancel {
host.hub.send_to(
conn,
&C::SetHeldSlot(play::SetHeldSlot {
slot: old_slot as i32,
}),
);
return;
}
}
let g = &mut host.hub.guests[gi];
let changed = g.selected != p.slot as usize;
g.selected = p.slot as usize;
if let Some(play) = host.play.get_mut(&conn)
@@ -1824,10 +1938,15 @@ impl Game<'_> {
// click on nothing), seen by the players around, and the attack
// strength starts again.
S::Punch(_) => {
let (player, name) = {
let g = &host.hub.guests[gi];
(g.uuid, g.name.clone())
};
self.as_guest(host, conn, |g| {
g.swing_hand(Hand::Main, SwingAnimation::DEFAULT);
g.reset_attack_strength();
});
self.native_player_attack_cooldown_reset(player, &name);
}
S::UseItemOn(p) => {
host.hub.ack(conn, p.sequence);
@@ -2034,6 +2153,9 @@ impl Game<'_> {
if !self.creative_stack_valid(host, gi, &stack) {
return;
}
if !self.guest_may_set_creative_slot(host, conn, i32::from(p.slot), Some(&stack)) {
return;
}
let play = host.play.entry(conn).or_default();
if play.creative_drops >= 1480 {
eprintln!(
@@ -2058,6 +2180,12 @@ impl Game<'_> {
{
return;
}
if !self.guest_may_set_creative_slot(host, conn, i32::from(p.slot), item.as_ref()) {
// The creative client already holds the stack it sent: treat that as
// what it was told, so the next broadcast sends the real slot back.
host.hub.guests[gi].menu_sent[p.slot as usize] = item;
return;
}
let slot = p.slot as usize;
let g = &mut host.hub.guests[gi];
let old = g.menu_slot(slot);
@@ -2096,6 +2224,22 @@ impl Game<'_> {
});
}
S::PlayerCommand(p) if p.action == 6 => self.lan_start_gliding(host, conn),
// ServerboundResourcePackPacket in play: the server has nothing to
// do with it (a declined required pack is handled on the connection).
S::ResourcePack(r) => {
let (uuid, name) = (host.hub.guests[gi].uuid, host.hub.guests[gi].name.clone());
self.as_guest(host, conn, |g| {
g.native_resource_pack_status(uuid, &name, r.action);
});
}
// START_SPRINTING and STOP_SPRINTING: the hub has set the sprint
// and sends the packet on when it changed it.
S::PlayerCommand(p) if matches!(p.action, 1 | 2) => {
let (uuid, name) = (host.hub.guests[gi].uuid, host.hub.guests[gi].name.clone());
self.as_guest(host, conn, |g| {
g.native_player_sprint(uuid, &name, p.action == 1);
});
}
S::ContainerButtonClick(p) => {
self.lan_station_button(host, conn, p.container, p.button);
}
@@ -2241,6 +2385,13 @@ impl Game<'_> {
S::SetGameRule(p) if host.hub.guests[gi].permission >= 2 => {
for entry in p.entries {
if self.ui.rules.set_string(&entry.name, &entry.value) {
let guest = &host.hub.guests[gi];
self.native_world_game_rule_change(
guest.uuid,
&guest.name,
&entry.name,
&entry.value,
);
let message = horizon_client::text::Text::tr(
"commands.gamerule.set",
&[&entry.name, &entry.value],
@@ -2364,7 +2515,7 @@ impl Game<'_> {
// handlePickItemFromBlock: a block within the block reach plus 1, its
// data only with infinite materials.
S::PickItemFromBlock(p) => {
self.guest_pick_item(host, conn, |g| {
self.guest_pick_item(host, conn, false, p.pos, 0, |g| {
// handlePickItemFromBlock: within reach and loaded.
if !g.world.loaded(p.pos[0], p.pos[2]) {
return None;
@@ -2385,7 +2536,7 @@ impl Game<'_> {
}
// handlePickItemFromEntity.
S::PickItemFromEntity(p) => {
self.guest_pick_item(host, conn, |g| {
self.guest_pick_item(host, conn, true, [0; 3], p.id, |g| {
g.within_entity_range(p.id)
.then(|| g.entity_pick_result(p.id))
.flatten()
@@ -2427,11 +2578,22 @@ impl Game<'_> {
&mut self,
host: &mut LanHost,
conn: u32,
from_entity: bool,
pos: [i32; 3],
entity_id: i32,
stack: impl FnOnce(&mut Self) -> Option<ItemStack>,
) {
let Some((uuid, name)) = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone())) else {
return;
};
let picked = self.as_guest(host, conn, |g| {
let stack = stack(g)?;
let infinite = g.creative;
let mut cancel = false;
g.native_player_pick_item(uuid, &name, from_entity, pos, entity_id, &mut cancel);
if cancel {
return None;
}
horizon_client::pick_item::pick(
&mut g.inventory[..36],
&mut g.selected,
@@ -2452,6 +2614,32 @@ impl Game<'_> {
}
}
/// Whether the mods let a creative player set `slot` to `stack`
/// (`native_creative_slot`), heard with the guest's state swapped in.
fn guest_may_set_creative_slot(
&mut self,
host: &mut LanHost,
conn: u32,
slot: i32,
stack: Option<&ItemStack>,
) -> bool {
let Some((uuid, name)) = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone())) else {
return true;
};
let (item, count) = stack.map_or(("minecraft:air", 0), |s| (&*s.item, s.count as i32));
let mut cancel = false;
let mut hook = |game: &mut Self| {
game.native_creative_slot(uuid, &name, slot, item, count, &mut cancel)
};
// The guest whose turn it is is in place already.
if self.lan_acting == Some(conn) {
hook(self);
} else {
self.as_guest(host, conn, hook);
}
!cancel
}
/// What ServerboundSetCreativeModeSlotPacket asks of a stack. Its codec
/// (`ItemStack.validatedStreamCodec`) encodes the stack with `ItemStack.CODEC`, whose count is
/// 1 to 99: more fails the packet and disconnects the player, as vanilla's decoder error does.
@@ -2927,9 +3115,9 @@ impl Game<'_> {
);
return;
}
// The chat filter's `/unmute` (game/chat_filter.rs), on servers that filter.
if words[0] == "unmute" && host.hub.chat_filter_on() {
self.guest_unmute(host, conn, &words);
// The chat filter's mute commands (game/chat_filter.rs), on servers that filter.
if super::chat_filter::MUTE_COMMANDS.contains(&words[0]) && host.hub.chat_filter_on() {
self.guest_mute_command(host, conn, &words);
return;
}
// A native mod's command, for every player its level allows,
@@ -3100,6 +3288,11 @@ impl Game<'_> {
let Some(permission) = host.hub.guest(conn).map(|g| g.permission) else {
return;
};
let Some((player_uuid, player_name)) =
host.hub.guest(conn).map(|g| (g.uuid, g.name.clone()))
else {
return;
};
let skip = usize::from(text.starts_with('/'));
let command = &text[skip..];
let d = vanilla::game_dispatcher();
@@ -3146,6 +3339,13 @@ impl Game<'_> {
let parse = d.parse(command, &self.ui.registries);
let ((start, end), mut suggestions) =
d.suggestions(&parse, command.len(), &self.ui.registries);
self.native_tab_complete(
player_uuid,
&player_name,
command,
[start, end],
&mut suggestions,
);
let reg = &mut self.ui.registries;
reg.permission = level;
reg.players = own_players;
@@ -3619,6 +3819,11 @@ impl Game<'_> {
/// teleport the client there.
fn wake_guest_from(&mut self, host: &mut LanHost, gi: usize, head: [i32; 3]) {
let yaw = host.hub.guests[gi].yaw;
let (player, name) = {
let g = &host.hub.guests[gi];
(g.uuid, g.name.clone())
};
self.native_player_bed_leave(player, &name, head);
let Some((stand, yaw)) = self.bed_wake_pose(head, yaw) else {
return;
};
@@ -3664,7 +3869,10 @@ impl Game<'_> {
/// sleeper who died or whose bed is gone.
fn guest_sleep_tick(&mut self, host: &mut LanHost, gi: usize) {
let g = &mut host.hub.guests[gi];
let was_deep = g.sleep_timer >= 100;
g.sleep_timer = super::tick_sleep_timer(g.sleep_timer, g.bed.is_some());
let deep_sleep = !was_deep && g.sleep_timer >= 100 && g.bed.is_some();
let identity = deep_sleep.then(|| (g.uuid, g.name.clone()));
if let Some(head) = g.bed {
let block = self.world.block(head[0], head[1], head[2]);
let bed = self.res.facts().get(block).name.ends_with("_bed");
@@ -3678,6 +3886,9 @@ impl Game<'_> {
}
}
}
if let Some((player, name)) = identity {
self.native_player_deep_sleep(player, &name);
}
}
/// `SleepStatus.update`'s counts over the host's player and the guests:
@@ -4117,6 +4328,7 @@ impl Game<'_> {
}
}
if let (Some(v), Some(g)) = (marked, host.hub.guest(conn)) {
self.native_player_velocity(g.uuid, &g.name, [v.x, v.y, v.z]);
g.send(&C::SetEntityMotion(play::SetEntityMotion {
id: g.entity_id,
velocity: [v.x, v.y, v.z],
@@ -4139,8 +4351,16 @@ impl Game<'_> {
.is_some_and(|p| p.fluids.was_touching_water || p.is_in_lava())
})
.unwrap_or(true);
let mut changed = None;
if let Some(g) = host.hub.guest_mut(conn) {
g.fall_flying = start && !in_liquid;
let gliding = start && !in_liquid;
if g.fall_flying != gliding {
g.fall_flying = gliding;
changed = Some((g.uuid, g.name.clone(), gliding));
}
}
if let Some((uuid, name, gliding)) = changed {
self.native_entity_toggle_glide(uuid, &name, gliding);
}
}
@@ -4315,6 +4535,8 @@ impl Game<'_> {
let swinging = play.swing.swinging();
if progress != play.last_progress && progress >= 0 {
play.last_progress = progress;
let guest = &host.hub.guests[gi];
self.native_block_break_progress_update(guest.uuid, &guest.name, pos, progress);
// The host player sees it too (on the server thread its window is a guest).
self.destroy_block_progress(host.hub.guests[gi].entity_id, pos, progress);
host.hub.broadcast_near(
@@ -4456,7 +4678,7 @@ impl Game<'_> {
/// (`handleTakeItemEntity`).
fn guest_arrows(&mut self, host: &mut LanHost, gi: usize) {
use horizon_client::arrows::Touch;
let (conn, id, at, creative, unable, dims) = {
let (conn, id, at, creative, unable, dims, player, name) = {
let g = &host.hub.guests[gi];
(
g.conn,
@@ -4465,6 +4687,8 @@ impl Game<'_> {
g.creative(),
g.spectator() || g.dead || g.health <= 0.0,
g.dimensions(),
g.uuid,
g.name.clone(),
)
};
if unable || self.creatures.projectiles.is_empty() {
@@ -4508,6 +4732,9 @@ impl Game<'_> {
});
self.creatures.projectiles = projectiles;
for shot in picked {
if let Some(arrow) = shot.arrow.as_ref() {
self.native_player_pickup_arrow(player, &name, shot.id, &arrow.pickup_item.item);
}
host.hub.take_item(shot.id as i32, id, 1, at);
if let Some(target) = super::pickups::guest_target(host, id as u32) {
self.add_pickup_toward(
@@ -4526,11 +4753,13 @@ impl Game<'_> {
fn guest_orbs(&mut self, host: &mut LanHost, gi: usize) {
let g = &host.hub.guests[gi];
let height = f64::from(g.dimensions().height);
let (conn, id, at) = (g.conn, g.entity_id, g.position);
let (conn, id, at, player, name) =
(g.conn, g.entity_id, g.position, g.uuid, g.name.clone());
let unable = g.spectator() || g.dead || g.health <= 0.0;
let play = host.play.entry(conn).or_default();
if play.xp_delay > 0 {
play.xp_delay -= 1;
self.native_player_exp_cooldown_change(player, &name, play.xp_delay);
}
if unable || play.xp_delay > 0 {
return;
@@ -4598,6 +4827,7 @@ impl Game<'_> {
self.orbs[index].count -= 1;
}
host.play.entry(conn).or_default().xp_delay = 2;
self.native_player_exp_cooldown_change(player, &name, 2);
host.hub.take_item(orb.id as i32, id, 1, at);
// ClientPacketListener.handleTakeItemEntity's sound, at the orb.
let pitch = (self.world.random.next_float() - self.world.random.next_float()) * 0.35 + 0.9;
@@ -4620,7 +4850,14 @@ impl Game<'_> {
self.as_guest(host, conn, |game| {
let rest = game.mending(value);
if rest > 0 {
game.give_orb_points(rest);
let (Some(player), Some(name)) =
(game.lan_acting_uuid, game.lan_acting_name.clone())
else {
return;
};
let mut amount = rest.min(i32::MAX as u32) as i32;
game.native_player_exp_change(player, &name, &mut amount);
game.give_orb_points(amount.max(0) as u32);
}
});
}
+8 -1
View File
@@ -175,7 +175,9 @@ impl Game<'_> {
/// `ServerPlayer.setCamera`: the view moves to the new camera, which
/// the client is told, or back to the player (`None`).
pub(super) fn guest_set_camera(&mut self, host: &mut LanHost, gi: usize, camera: Option<i32>) {
let (conn, own) = (host.hub.guests[gi].conn, host.hub.guests[gi].entity_id);
let guest = &host.hub.guests[gi];
let (conn, own, uuid, name) = (guest.conn, guest.entity_id, guest.uuid, guest.name.clone());
let previous = self.cmds.guest_cameras.get(&conn).map(|c| c.id);
let camera = camera.filter(|id| *id != own);
if self.cmds.guest_cameras.get(&conn).map(|c| c.id) == camera {
return;
@@ -208,6 +210,11 @@ impl Game<'_> {
if let Some(c) = self.cmds.guest_cameras.get_mut(&conn) {
c.teleports = teleports;
}
if camera.is_none()
&& let Some(entity_id) = previous
{
self.native_player_stop_spectating_entity(uuid, &name, entity_id);
}
}
/// The camera part of `ServerPlayer.tick` for every guest, after the
+33 -1
View File
@@ -206,6 +206,12 @@ impl Game<'_> {
.filter(|&s| menu.slot(s) != before[s].as_ref())
.collect::<Vec<_>>()
});
let guest = &host.hub.guests[gi];
let (uuid, name) = (guest.uuid, guest.name.clone());
let mut result = menu.slot(0).cloned();
self.native_prepare_item_craft(uuid, &name, None, &mut result);
let env = self.menu_env();
menu.set_slot(&env, 0, result);
let g = &mut host.hub.guests[gi];
for k in 0..4 {
g.craft_grid[k] = menu.slot(1 + k).cloned();
@@ -343,6 +349,17 @@ impl Game<'_> {
let count = |s: Option<&ItemStack>| s.map_or(0, |s| s.count);
if furnace && count(menu.slot(2)) < count(items[2].as_ref()) {
let at = host.hub.guests[gi].position;
let guest = &host.hub.guests[gi];
let output = items[2].as_ref();
if let Some(output) = output {
self.native_furnace_extract(
guest.uuid,
&guest.name,
[pos.0, pos.1, pos.2],
&output.item,
output.count - count(menu.slot(2)),
);
}
self.award_furnace_recipes(pos, at);
}
// Slot.setChanged on a changed container slot: BlockEntity.setChanged,
@@ -424,6 +441,8 @@ impl Game<'_> {
{
return;
}
let (player, name) = (g.uuid, g.name.clone());
self.native_player_recipe_book_click(player, &name, &id, p.use_max);
let creative = g.creative();
let placed = if p.container == 0 {
let crafting = host.play.entry(conn).or_default();
@@ -469,6 +488,9 @@ impl Game<'_> {
/// The guest opened a crafting table (`CraftingTableBlock.getMenuProvider`).
pub(super) fn lan_open_crafting(&mut self, host: &mut LanHost, conn: u32, pos: [i32; 3]) {
if !self.guest_may_open(host, conn, "minecraft:crafting", Some(pos)) {
return;
}
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let title = horizon_protocol::text::translatable("container.crafting", &[]);
@@ -506,7 +528,7 @@ impl Game<'_> {
&& !host.hub.guests[gi].spectator()
&& self.lan_table_valid(&host.hub.guests[gi], pos)
{
let changed = self.guest_menu_op(host, gi, &mut menu, |menu, env| {
let mut changed = self.guest_menu_op(host, gi, &mut menu, |menu, env| {
let before: Vec<_> = (0..menu.slot_count())
.map(|s| menu.slot(s).cloned())
.collect();
@@ -515,6 +537,16 @@ impl Game<'_> {
.filter(|&s| menu.slot(s) != before[s].as_ref())
.collect::<Vec<_>>()
});
let guest = &host.hub.guests[gi];
let (uuid, name) = (guest.uuid, guest.name.clone());
let mut result = menu.slot(0).cloned();
let vanilla = result.clone();
self.native_prepare_item_craft(uuid, &name, Some(pos), &mut result);
if result != vanilla && !changed.contains(&0) {
changed.push(0);
}
let env = self.menu_env();
menu.set_slot(&env, 0, result);
agreed = p.state == state
&& host.hub.click_agreed(
gi,
@@ -40,7 +40,12 @@ pub(super) fn player_for(
return Some(*conn);
}
}
// An iron golem's anger is for the player who hit it (`NeutralMob` keeps
// the angry-at UUID), and outlasts the 100 ticks of `getLastHurtByMob`:
// while it lasts, the golem keeps that player, not whoever is nearest.
let angry = m.species() == Species::IronGolem && m.extra.anger > 0;
let targeting = m.ai.target == Some(Who::Player)
|| angry
|| m.ai.last_hurt_by == Some(Who::Player) && m.age as i32 - m.ai.last_hurt_by_time <= 100;
// A tame wolf, cat or parrot sees its owner (`TamableAnimal.getOwner`), while they
// are in play, whoever is nearer. Not while it targets or was just hurt by
@@ -506,4 +511,35 @@ mod tests {
let guests = vec![(7, view(12.0, false))];
assert_eq!(choose(MOB, false, Some(&host), &guests, Some(7)), None);
}
/// The mob of the prepared resources' type `kind` (None without them).
fn mob_of(kind: &str) -> Option<Mob> {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
let types = horizon_client::mobs::MobTypes::load(&root).ok()?;
let t = std::sync::Arc::new(types.types.get(kind)?.clone());
Some(Mob::new(1, t, MOB, 0.0))
}
/// #6781: a golem a guest hit stays angry for 400+ ticks but remembers
/// its last attacker for 100. After that it must still see the guest it
/// is angry at, not the bystander standing nearer.
#[test]
fn an_angry_golem_keeps_the_player_who_hit_it() {
let Some(mut golem) = mob_of("minecraft:iron_golem") else {
return;
};
golem.age = 500;
golem.ai.last_hurt_by = Some(Who::Player);
golem.ai.last_hurt_by_time = 100;
golem.ai.target = None;
golem.extra.anger = 300;
// The attacker (conn 7) ran off; a bystander (conn 8) is nearer.
let guests = vec![(7, view(30.0, false)), (8, view(3.0, false))];
assert_eq!(player_for(&golem, None, &guests, Some(7)), Some(7));
// Calm again: the nearest player is seen.
golem.extra.anger = 0;
assert_eq!(player_for(&golem, None, &guests, Some(7)), Some(8));
}
}
@@ -31,6 +31,9 @@ impl Game<'_> {
return;
};
let columns = super::mount_menu::mount_columns(&self.mobs[index]);
if !self.guest_may_open(host, conn, "minecraft:horse_inventory", None) {
return;
}
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let Some(window) = host.hub.open_mount_window(conn, columns as i32, mob as i32) else {
+112 -6
View File
@@ -69,6 +69,10 @@ impl Game<'_> {
let Some((menu_type, kind, _)) = menu_of(station.kind) else {
return false;
};
// The mods may stop the menu; the click is spent.
if !self.guest_may_open(host, conn, menu_type, Some(station.pos)) {
return true;
}
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let title = horizon_protocol::text::translatable(station.kind.title_key(), &[]);
@@ -121,7 +125,49 @@ impl Game<'_> {
if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn)
&& !host.hub.guests[gi].spectator()
{
// createResult runs when the inputs change (or a rename): a click
// elsewhere must not hand the hooks their own result again.
let result_slot = match gs.kind {
StationKind::Anvil | StationKind::Grindstone => Some(2),
StationKind::Smithing => Some(3),
_ => None,
};
let before = result_slot.map(|r| {
let slots: Vec<_> = (0..=r).map(|s| gs.menu.slot(s).cloned()).collect();
(slots, gs.menu.data[0])
});
self.guest_menu_op(host, gi, &mut gs.menu, op);
let recomputed = before.is_some_and(|(slots, cost)| {
cost != gs.menu.data[0]
|| slots
.iter()
.enumerate()
.any(|(s, b)| gs.menu.slot(s) != b.as_ref())
});
if recomputed && gs.kind == StationKind::Anvil {
let guest = &host.hub.guests[gi];
let (uuid, name) = (guest.uuid, guest.name.clone());
let mut result = gs.menu.slot(2).cloned();
self.native_prepare_anvil(uuid, &name, gs.pos, &mut result, &mut gs.menu.data[0]);
let env = self.menu_env();
gs.menu.set_slot(&env, 2, result);
}
if recomputed && gs.kind == StationKind::Grindstone {
let guest = &host.hub.guests[gi];
let (uuid, name) = (guest.uuid, guest.name.clone());
let mut result = gs.menu.slot(2).cloned();
self.native_prepare_grindstone(uuid, &name, gs.pos, &mut result);
let env = self.menu_env();
gs.menu.set_slot(&env, 2, result);
}
if recomputed && gs.kind == StationKind::Smithing {
let guest = &host.hub.guests[gi];
let (uuid, name) = (guest.uuid, guest.name.clone());
let mut result = gs.menu.slot(3).cloned();
self.native_prepare_smithing(uuid, &name, gs.pos, &mut result);
let env = self.menu_env();
gs.menu.set_slot(&env, 3, result);
}
self.lan_station_sounds(&mut gs);
if let Some(amount) = gs.menu.ground.take() {
self.grindstone_taken(gs.pos, amount);
@@ -149,6 +195,9 @@ impl Game<'_> {
/// `LecternBlock.openScreen` for a guest: `minecraft:lectern` over the
/// block entity, with its book and page.
pub(super) fn lan_open_lectern(&mut self, host: &mut LanHost, conn: u32, key: (i32, i32, i32)) {
if !self.guest_may_open(host, conn, "minecraft:lectern", Some([key.0, key.1, key.2])) {
return;
}
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let title = horizon_protocol::text::translatable("container.lectern", &[]);
@@ -286,9 +335,28 @@ impl Game<'_> {
bookcases,
gs.menu.slot(0),
);
let guest = &host.hub.guests[gi];
let mut costs = offers.costs;
self.native_prepare_item_enchant(guest.uuid, &guest.name, gs.pos, &mut costs);
let mut clues = offers.clues;
for i in 0..3 {
gs.menu.data[i] = offers.costs[i];
let clue = offers.clues[i].as_ref();
// A cost a mod changed shows the clue of the list it will give.
if costs[i] != offers.costs[i]
&& let Some(stack) = gs.menu.slot(0)
{
clues[i] = horizon_client::stations::enchant_clue(
&self.stations().enchantments,
self.items(),
seed,
i,
costs[i],
stack,
);
}
}
for i in 0..3 {
gs.menu.data[i] = costs[i];
let clue = clues[i].as_ref();
gs.menu.data[4 + i] = clue
.and_then(|(e, _)| host.hub.registry_id("minecraft:enchantment", e))
.unwrap_or(-1);
@@ -305,7 +373,8 @@ impl Game<'_> {
return;
};
let g = &host.hub.guests[gi];
let (seed, creative, conn) = (g.xp_seed, g.creative(), g.conn);
let (seed, creative, conn, uuid, name) =
(g.xp_seed, g.creative(), g.conn, g.uuid, g.name.clone());
let Some(enchanted) = horizon_client::stations::apply_enchantment(
&self.stations().enchantments,
self.items(),
@@ -316,6 +385,7 @@ impl Game<'_> {
) else {
return;
};
let enchanted_for_hook = enchanted.clone();
let need = button as i32 + 1;
// Player.onEnchantmentPerformed, and EnchantmentMenu's awardStat(ENCHANT_ITEM).
self.as_guest(host, conn, |game| {
@@ -344,6 +414,7 @@ impl Game<'_> {
1.0,
pitch,
);
self.native_enchant_item(uuid, &name, gs.pos, &enchanted_for_hook, need);
}
/// `ServerGamePacketListenerImpl.handleRenameItem`: the guest's anvil
@@ -438,9 +509,28 @@ impl Game<'_> {
}) {
return;
}
let identity = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone()));
let mut selected = false;
let kind = host
.play
.get(&conn)
.and_then(|p| p.station.as_ref())
.filter(|s| s.window == window)
.map(|s| s.kind);
self.lan_station_op(host, conn, Some(window), |menu, env| {
menu.button(env, id);
selected = menu.button(env, id);
});
if selected && let Some((player, name)) = identity {
match kind {
Some(StationKind::Loom) => {
self.native_player_loom_pattern_select(player, &name, id);
}
Some(StationKind::Stonecutter) => {
self.native_player_stonecutter_recipe_select(player, &name, id);
}
_ => {}
}
}
}
/// The whole station menu and its changed data slots for the client
@@ -573,6 +663,15 @@ impl Game<'_> {
else {
return;
};
let Some((player, name)) = host
.hub
.guests
.iter()
.find(|g| g.conn == conn)
.map(|g| (g.uuid, g.name.clone()))
else {
return;
};
// An id the registry doesn't have fails the packet's codec.
let mut powers: [Option<(i32, String)>; 2] = [None, None];
for (k, id) in [primary, secondary].into_iter().enumerate() {
@@ -615,10 +714,17 @@ impl Game<'_> {
if let Some(BlockEntity::Beacon(b)) =
self.world.block_entities.get_mut(&(pos[0], pos[1], pos[2]))
{
b.primary = primary;
b.secondary = secondary;
b.primary = primary.clone();
b.secondary = secondary.clone();
select_sound = !b.sections.is_empty();
}
self.native_player_change_beacon_effect(
player,
&name,
pos,
primary.as_deref().unwrap_or(""),
secondary.as_deref().unwrap_or(""),
);
if select_sound {
self.beacon_sound(pos, "minecraft:block.beacon.power_select");
}
+23 -2
View File
@@ -57,6 +57,10 @@ impl Game<'_> {
state.session.update_special_prices();
}
let title = self.merchant_display_name(&state);
if !self.guest_may_open(host, conn, "minecraft:merchant", None) {
self.merchant_closed(&mut state);
return;
}
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let Some(window) = host.hub.open_merchant_window(conn, title) else {
@@ -283,6 +287,12 @@ impl Game<'_> {
gm.merchant.session = session;
if !traded.is_empty() {
// The guest's own statistic and advancements.
if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) {
let g = &host.hub.guests[gi];
if let Some(entity_id) = gm.merchant.mob {
self.native_player_trade(g.uuid, &g.name, entity_id);
}
}
self.as_guest(host, conn, |g| g.merchant_traded(gm.merchant.mob, &traded));
}
self.drop_guest_items(host, gi, dropped);
@@ -317,9 +327,20 @@ impl Game<'_> {
let Ok(index) = usize::try_from(index) else {
return;
};
self.lan_merchant_op(host, conn, None, |menu, env| {
let Some(guest) = host.hub.guests.iter().find(|g| g.conn == conn) else {
return;
};
let (uuid, name) = (guest.uuid, guest.name.clone());
if self.lan_merchant_op(host, conn, None, |menu, env| {
menu.merchant_select(env, index)
});
}) {
let villager = host
.play
.get(&conn)
.and_then(|p| p.merchant.as_ref())
.and_then(|m| m.merchant.mob);
self.native_trade_select(uuid, &name, villager, index);
}
}
/// The guest closed the trading window (or it is closed for them):
/// MerchantMenu.removed, then the villager gets its state back.
+110 -1
View File
@@ -9,7 +9,7 @@
//! here with the rider's input (`ServerPlayer.getLastClientMoveIntent`).
//! Sneaking gets out (`Player.rideTick`: `wantsToStopRiding`), and the client
//! is teleported where the player lands (`ServerPlayer.dismountTo`).
use super::{Game, Keys, lan::LanHost, minecarts::SEAT};
use super::{Dim, Game, Keys, lan::LanHost, minecarts::SEAT};
use horizon_client::collisions::SceneCollisions;
use horizon_client::lan::hub::no_blocks_in;
use horizon_client::mobs::equine;
@@ -38,7 +38,116 @@ fn corners(b: Aabb) -> ([f64; 3], [f64; 3]) {
([lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z])
}
/// A boat or minecart a server player rode, followed for
/// `native_vehicle_moved`.
pub(super) struct WatchedVehicle {
id: u32,
dim: Dim,
/// The server player it carries now.
rider: Option<u128>,
/// Where the hook last heard of it.
at: [f64; 3],
/// Ticks it has stood still.
still: u32,
}
impl Game<'_> {
/// Where the boat or minecart `id` of this level is.
fn watched_vehicle_position(&self, id: u32) -> Option<[f64; 3]> {
let at = |v: Vec3| [v.x, v.y, v.z];
self.boats
.iter()
.find(|b| b.id == id)
.map(|b| at(b.position))
.or_else(|| {
self.carts
.iter()
.find(|c| c.id == id)
.map(|c| at(c.position))
})
}
/// `VehicleMoveEvent` for the boats and minecarts server players ride
/// (and left less than ten seconds ago): once a tick, after they moved.
pub(super) fn tick_vehicle_watch(&mut self) {
let riders: Vec<(u32, u32, u128)> = self.lan.as_ref().map_or_else(Vec::new, |h| {
h.hub
.guests
.iter()
.filter_map(|g| Some((g.vehicle?, g.conn, g.uuid)))
.collect()
});
if riders.is_empty() && self.watched_vehicles.is_empty() {
return;
}
let dim = self.dim;
for &(id, _, uuid) in &riders {
let Some(at) = self.watched_vehicle_position(id) else {
continue;
};
match self.watched_vehicles.iter_mut().find(|w| w.id == id) {
Some(w) => (w.dim, w.rider) = (dim, Some(uuid)),
None => self.watched_vehicles.push(WatchedVehicle {
id,
dim,
rider: Some(uuid),
at,
still: 0,
}),
}
}
let mut kept = Vec::new();
// (rider's connection and UUID, vehicle type, from, to)
let mut moved = Vec::new();
for mut w in std::mem::take(&mut self.watched_vehicles) {
// Another level's vehicle is told of on that level's tick.
if w.dim != dim {
kept.push(w);
continue;
}
// Broken, or gone from this level.
let Some(now) = self.watched_vehicle_position(w.id) else {
continue;
};
let rider = riders.iter().find(|r| r.0 == w.id);
w.rider = rider.map(|r| r.2);
if now != w.at {
let from = std::mem::replace(&mut w.at, now);
w.still = 0;
let kind = self.target_type(
self.level_entity(w.id as i32)
.unwrap_or(super::commands::Target::Boat(w.id)),
);
moved.push((rider.map(|r| (r.1, r.2)), kind, from, now));
} else {
w.still += 1;
if w.rider.is_none() && w.still > 200 {
continue;
}
}
kept.push(w);
}
self.watched_vehicles = kept;
// The rider's state is swapped in for the call, so the helpers reach
// them; a vehicle nobody rides is told of with nobody swapped in.
for (rider, kind, from, to) in moved {
let Some((conn, uuid)) = rider else {
self.native_vehicle_moved(None, &kind, from, to);
continue;
};
let Some(mut host) = self.lan.take() else {
continue;
};
let hook = |game: &mut Self| game.native_vehicle_moved(Some(uuid), &kind, from, to);
if self.lan_acting == Some(conn) {
hook(self);
} else {
self.as_guest(&mut host, conn, hook);
}
self.lan = Some(host);
}
}
/// The box of a boat, minecart, armor stand, item frame or painting, and
/// the point an interact packet's location is measured from (the
/// client's `remote_object_hit` uses the same points).
+21 -6
View File
@@ -136,16 +136,23 @@ impl Game<'_> {
}
fn drop_leash(&mut self, id: u32, drop: bool, sound: bool, used: bool) {
let killer = self.lead_killers.remove(&id).map(|(killer, _)| killer);
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
let Some((alive, at, kind)) = self.mobs.iter_mut().find(|m| m.id == id).and_then(|m| {
horizon_client::mobs::leash::remove(m)?;
Some((
m.alive(),
[m.position.x, m.position.y, m.position.z],
super::native_hooks::mob_kind(m),
))
}) else {
return;
};
if horizon_client::mobs::leash::remove(m).is_none() {
return;
if used
&& let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
{
self.native_player_unleash_entity(player, &name, id);
}
// Leashable.tickLeash gates a dying entity's lead drop on entity_drops.
let drop = drop && (m.alive() || self.ui.rules.bool("entity_drops"));
let at = [m.position.x, m.position.y, m.position.z];
let kind = super::native_hooks::mob_kind(m);
let drop = drop && (alive || self.ui.rules.bool("entity_drops"));
if sound {
self.lead_sound("break", at);
}
@@ -401,6 +408,14 @@ impl Game<'_> {
{
return false;
}
// A knot is made when a lead is tied to a fence that has none.
if existing.is_empty()
&& existing_boats.is_empty()
&& (!in_hand.is_empty() || !boats_in_hand.is_empty())
&& self.hanging_place_cancelled("minecraft:leash_knot", knot_pos)
{
return true;
}
let mut attached = self.tie_player_boats(center, Holder::Knot(knot_pos));
for j in in_hand {
if self.can_attach_lead(j, center) {
+5
View File
@@ -132,6 +132,11 @@ impl Levels {
self.parked.get(&dim).map(|level| &level.world)
}
/// The worlds of the levels not in place.
pub(super) fn parked_worlds(&self) -> impl Iterator<Item = &World> {
self.parked.values().map(|level| &level.world)
}
/// The world of the level not in place, in `dim`, to change.
pub(super) fn parked_world_mut(&mut self, dim: Dim) -> Option<&mut World> {
self.parked.get_mut(&dim).map(|level| &mut level.world)
+24 -1
View File
@@ -88,6 +88,9 @@ impl Game<'_> {
let trap = self.ui.rules.bool("spawn_mobs")
&& self.world.random.next_double() < difficulty * 0.01
&& !below.ends_with("lightning_rod");
if !self.lightning_strike_allowed(None, pos, "weather") {
continue;
}
if trap {
let at = [f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])];
// ServerLevel.tickChunk: a trap horse waits for a player.
@@ -243,6 +246,16 @@ impl Game<'_> {
cause: None,
});
}
pub(super) fn lightning_strike_allowed(
&mut self,
player: Option<u128>,
pos: [i32; 3],
cause: &str,
) -> bool {
let mut cancel = false;
self.native_lightning_strike(player, pos, cause, &mut cancel);
!cancel
}
/// `ClientLevel.addEntity` for a server's lightning bolt: on a client it
/// only flashes, sounds and draws (`LightningBolt.tick` off the server).
pub(super) fn spawn_remote_bolt(&mut self, id: u32, at: [f64; 3]) {
@@ -294,6 +307,9 @@ impl Game<'_> {
b.can_survive(level, ticks, at, fire).then_some(fire)
});
if let Some(fire) = fire {
if !self.native_block_ignite_allowed(at, "lightning") {
continue;
}
let _ = self.level_set(p, fire);
set += 1;
}
@@ -497,6 +513,7 @@ impl Game<'_> {
from: Some(b.at),
};
let mut events = Vec::new();
let mut powered = Vec::new();
let peaceful = self.ui.difficulty == 0;
for m in self.mobs.iter_mut().filter(|m| m.alive()) {
if !meets(
@@ -544,7 +561,10 @@ impl Game<'_> {
m.damage(f32::MAX, damage);
continue;
}
Species::Creeper => m.extra.charged = true,
Species::Creeper => {
m.extra.charged = true;
powered.push(m.id);
}
// CopperGolem.thunderHit (after Entity.thunderHit's fire and
// damage): once per bolt, an oxidized golem goes back one step
// and its weathering timer (or wax, -2) is reset to -1.
@@ -569,6 +589,9 @@ impl Game<'_> {
if !events.is_empty() {
let _ = self.handle_mob_events(events);
}
for entity in powered {
self.native_creeper_power(entity, [at[0], at[1], at[2]]);
}
// Cushion.thunderHit: a cushion whose box meets the strike box breaks and drops its item
// (the bolt is no player, so no creative spare). Last first, since
// break_cushion removes the entry.
+5 -2
View File
@@ -61,8 +61,11 @@ impl Game<'_> {
}
// Animal.setInLove: entity event 18.
if loved {
self.love_cause.insert(self.mobs[index].id, self.lan_acting);
self.mob_event_particles(self.mobs[index].id, 18);
let id = self.mobs[index].id;
self.love_cause.insert(id, self.lan_acting);
let kind = self.mobs[index].kind.id.clone();
self.native_entity_enter_love_mode(id, &kind, self.lan_acting_uuid);
self.mob_event_particles(id, 18);
}
self.llama_sound(index, "entity.llama.eat", 1.0);
self.consume_held();
+39 -4
View File
@@ -15,7 +15,10 @@ use horizon_client::{
world::World,
};
use horizon_core::{geometry::Direction, position::BlockPos, random::RandomSource};
use horizon_world::{level::Behaviours, registry::BlockStateId};
use horizon_world::{
level::{Behaviours, LevelAccess},
registry::BlockStateId,
};
use std::sync::Arc;
type Pos = (i32, i32, i32);
@@ -826,7 +829,18 @@ impl Game<'_> {
let mut taken = false;
if !full {
let stack = self.dropped[i].stack.clone();
match self.add_item(None, &view, stack, None) {
let allowed = self.with_level(|_, level, _| {
level.allow_hopper_pickup_item(
BlockPos::new(pos.0, pos.1, pos.2),
&stack.item,
stack.count,
)
});
match allowed
.then(|| self.add_item(None, &view, stack, None))
.flatten()
{
None if !allowed => {}
None => {
self.remove_dropped(i);
taken = true;
@@ -922,7 +936,7 @@ impl Game<'_> {
{
return false;
}
self.suck_item_entities(hopper, min, max)
self.suck_item_entities(Some(pos), hopper, min, max)
}
/// The container half of `HopperBlockEntity.suckInItems`: one item from the
/// container at `above`, through its bottom face; with no block container
@@ -1090,7 +1104,13 @@ impl Game<'_> {
}
/// The item half of `HopperBlockEntity.suckInItems` (`getItemsAtAndAbove`):
/// adds the item entities that meet the box, until one is taken whole.
fn suck_item_entities(&mut self, hopper: &View, min: [f64; 3], max: [f64; 3]) -> bool {
fn suck_item_entities(
&mut self,
hopper_pos: Option<Pos>,
hopper: &View,
min: [f64; 3],
max: [f64; 3],
) -> bool {
let mut i = 0;
while i < self.dropped.len() {
let b = self.dropped[i].bounds();
@@ -1103,6 +1123,19 @@ impl Game<'_> {
&& hi.z > min[2];
if inside {
let stack = self.dropped[i].stack.clone();
let allowed = hopper_pos.is_none_or(|pos| {
self.with_level(|_, level, _| {
level.allow_hopper_pickup_item(
BlockPos::new(pos.0, pos.1, pos.2),
&stack.item,
stack.count,
)
})
});
if !allowed {
i += 1;
continue;
}
match self.add_item(None, hopper, stack, None) {
None => {
self.remove_dropped(i);
@@ -1136,6 +1169,7 @@ impl Game<'_> {
let took = match self.suck_from_container(above, at, &view) {
Some(took) => took,
None => self.suck_item_entities(
None,
&view,
[p.x - 0.5, p.y + 11.0 / 16.0, p.z - 0.5],
[p.x + 0.5, p.y + 2.0, p.z + 0.5],
@@ -1146,6 +1180,7 @@ impl Game<'_> {
let b = cart.bounds();
let (lo, hi) = (b.min_position(), b.max_position());
self.suck_item_entities(
None,
&view,
[lo.x - 0.25, lo.y, lo.z - 0.25],
[hi.x + 0.25, hi.y, hi.z + 0.25],
@@ -137,6 +137,7 @@ impl Game<'_> {
}
}
self.container_changed(key);
self.native_crafter_craft([key.0, key.1, key.2], &recipe);
Ok(())
}
/// `CrafterBlock.dispenseItem`: into the container in front, else out as an item.
+3 -1
View File
@@ -40,7 +40,6 @@ pub(super) struct Decoration {
}
/// A map as a native mod sees it when `native_map_decorations` is asked.
// Native mods read it; nothing in Horizon does.
#[allow(dead_code)]
#[derive(Clone, Copy, Debug)]
pub struct NativeMapInfo<'a> {
/// The map's id (`map_<id>`).
@@ -1131,6 +1130,9 @@ impl Game<'_> {
self.consume_held();
self.give_or_drop_stack(filled);
}
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
self.native_player_map_filled(player, &name, id);
}
let at = [p.x, p.y, p.z];
self.audio.play(
"minecraft:ui.cartography_table.take_result",
+146
View File
@@ -0,0 +1,146 @@
//! `horizon-ctl memory`: what holds the game's memory, by category. The
//! process's resident set and the allocator's committed bytes say how much;
//! the census of the client's columns, the integrated server's levels, the
//! renderer's bookkeeping and the GPU allocator's resources by label say
//! where it goes. Bytes the census doesn't reach show as the difference.
use super::Game;
use horizon_client::chunk::ColumnCensus;
use horizon_client::fast_hash::FxHashSet;
use horizon_client::world::World;
use serde_json::{Value, json};
use std::sync::Arc;
const MB: f64 = 1048576.0;
fn mb(bytes: impl TryInto<u64>) -> f64 {
let bytes: u64 = bytes.try_into().unwrap_or(0);
(bytes as f64 / MB * 10.0).round() / 10.0
}
/// The columns of `worlds`, each section counted once however many columns
/// (or worlds) share it.
fn census<'a>(worlds: impl IntoIterator<Item = &'a World>) -> ColumnCensus {
let mut census = ColumnCensus::default();
let mut seen = FxHashSet::default();
for world in worlds {
for chunk in world.chunks.values() {
chunk.census(&mut census, &mut seen);
}
}
census
}
fn census_json(c: &ColumnCensus) -> Value {
json!({
"total_mb": mb(c.total_bytes()),
"columns": c.columns,
"sections": c.sections,
"unique_sections": c.unique_sections,
"uniform_block_sections": c.uniform_block_sections,
"light_arrays": c.light_arrays,
"blocks_mb": mb(c.block_bytes),
"biomes_mb": mb(c.biome_bytes),
"light_mb": mb(c.light_bytes),
"heightmaps_mb": mb(c.heightmap_bytes),
"section_records_mb": mb(c.section_record_bytes),
"column_records_mb": mb(c.column_bytes),
})
}
/// The sections of `worlds` that are also in `others` (by address), and
/// their heap bytes: what one copy of them costs.
fn shared<'a>(
worlds: impl IntoIterator<Item = &'a World>,
others: &FxHashSet<usize>,
) -> (usize, usize) {
let mut seen = FxHashSet::default();
let (mut count, mut bytes) = (0, 0);
for world in worlds {
for chunk in world.chunks.values() {
for section in &chunk.sections {
let at = Arc::as_ptr(section) as usize;
if others.contains(&at) && seen.insert(at) {
count += 1;
bytes += section.heap_bytes();
}
}
}
}
(count, bytes)
}
impl Game<'_> {
fn all_worlds(&self) -> impl Iterator<Item = &World> {
std::iter::once(&self.world).chain(self.levels.parked_worlds())
}
/// The census of this game's levels: the one in place and the parked ones.
fn level_census(&self) -> ColumnCensus {
census(self.all_worlds())
}
/// The addresses of this game's sections.
fn section_addresses(&self) -> FxHashSet<usize> {
self.all_worlds()
.flat_map(|w| w.chunks.values())
.flat_map(|c| c.sections.iter().map(|s| Arc::as_ptr(s) as usize))
.collect()
}
pub(super) fn memory_report(&self) -> Value {
let (commit, peak_commit) = horizon_threads::allocator_commit();
let client = self.level_census();
// The single-player server thread's own levels: a second copy of the
// columns both hold, unless they share sections.
let server = self
.integrated
.as_ref()
.and_then(|i| i.call(|g| (g.level_census(), g.section_addresses())));
// The window's sections the server thread holds too (lan/handoff.rs).
let shared = server.as_ref().map(|(_, at)| {
let (sections, bytes) = shared(self.all_worlds(), at);
json!({ "sections": sections, "mb": mb(bytes) })
});
let renderer = self.renderer.as_ref().map(|r| {
let [arenas, vertices] = r.section_vertex_bytes();
let cpu: serde_json::Map<String, Value> = r
.cpu_census()
.into_iter()
.map(|(k, v)| (format!("{k}_mb"), mb(v).into()))
.collect();
let gpu = r
.gpu_memory_by_label()
.map(|(allocated, reserved, labels)| {
json!({
"allocated_mb": mb(allocated),
"reserved_mb": mb(reserved),
"by_label_mb": labels
.iter()
.take(40)
.map(|(label, bytes)| json!([label, mb(*bytes)]))
.collect::<Vec<_>>(),
})
});
json!({
"section_arenas_mb": mb(arenas),
"section_vertices_mb": mb(vertices),
"cpu": cpu,
"gpu": gpu,
})
});
json!({
"process": {
"resident_mb": horizon_threads::memory_limit::resident_bytes().map(mb),
"allocator_commit_mb": mb(commit),
"allocator_peak_commit_mb": mb(peak_commit),
},
"render_distance": self.streaming.radius,
"client_columns": census_json(&client),
"server_columns": server.as_ref().map(|(c, _)| census_json(c)),
"shared_with_server": shared,
"far_tiles": self.lod.as_ref().map(|lod| {
let (active, cached) = lod.stats();
json!({ "active": active, "cached": cached })
}),
"renderer": renderer,
})
}
}
+85 -8
View File
@@ -108,6 +108,10 @@ pub(super) struct Minecart {
/// velocity), so a cart that ran into a block still counts as moving.
collision_speed: f64,
fall_distance: f64,
/// The squared speed `MinecartTNT.causeFallDamage` blows up with, set by
/// a landing of three blocks or more and taken by the cart's next tick
/// step (vanilla explodes inside the move itself).
fall_blast: Option<f64>,
pub hurt_time: i32,
pub hurt_dir: i32,
pub damage: f32,
@@ -156,6 +160,19 @@ fn display_offset(kind: CartKind) -> f64 {
/// `MinecartFurnace.calculateNewPushAlong`: the push turned onto the
/// movement's direction, at the push's own length (3D, so it holds from tick
/// to tick while the movement has a vertical part).
/// `AbstractMinecart.push(entity)` for a non-cart `entity` at `feet`: the
/// push the cart at `cart` loses (the entity gets a quarter of it back).
fn cart_push_from(feet: Vec3, cart: Vec3) -> Option<(f64, f64)> {
let (xa, za) = (feet.x - cart.x, feet.z - cart.z);
let dd = xa * xa + za * za;
if dd < f64::from(1.0e-4f32) {
return None;
}
let d = dd.sqrt();
let pow = (1.0 / d).min(1.0);
let k = f64::from(0.1f32) * 0.5;
Some((xa / d * pow * k, za / d * pow * k))
}
fn push_along(push: Vec3, movement: Vec3) -> Vec3 {
if push.horizontal_distance_squared() > 1.0e-4 && movement.horizontal_distance_squared() > 0.001
{
@@ -265,6 +282,7 @@ impl Minecart {
horizontal_collision: false,
collision_speed: 0.0,
fall_distance: 0.0,
fall_blast: None,
hurt_time: 0,
hurt_dir: 1,
damage: 0.0,
@@ -506,10 +524,20 @@ impl Game<'_> {
if (moved.y - delta.y).abs() > 1.0e-7 {
c.velocity.y = 0.0;
}
if c.on_ground || moved.y >= 0.0 {
// Entity.checkFallDamage: the move's drop counts (not in water),
// and a landing hands the whole fall to causeFallDamage, which
// sets a TNT minecart off at (fall / 10) squared.
if moved.y < 0.0 && !self.cart_in_water(c) {
c.fall_distance -= f64::from(moved.y as f32);
}
if c.on_ground {
if c.kind == CartKind::Tnt && c.fall_distance >= 3.0 {
let power = c.fall_distance / 10.0;
c.fall_blast = Some(power * power);
}
c.fall_distance = 0.0;
} else if moved.y >= 0.0 {
c.fall_distance = 0.0;
} else {
c.fall_distance -= moved.y;
}
}
}
@@ -912,7 +940,6 @@ impl Game<'_> {
c.velocity = Vec3::new(c.velocity.x * 0.5, c.velocity.y * 0.5, c.velocity.z * 0.5);
}
let v = c.velocity;
let fall = c.fall_distance;
self.move_cart(c, v);
// `comeOffTrack` moves the cart and keeps the delta the move resolved
// (then scales it by the air drag), so a cart an obstacle stopped here
@@ -925,10 +952,6 @@ impl Game<'_> {
c.velocity.y * 0.95,
c.velocity.z * 0.95,
);
} else if c.kind == CartKind::Tnt && fall >= 3.0 {
// MinecartTNT.causeFallDamage
c.fuse = 0;
c.velocity = Vec3::new(c.velocity.x, fall / 10.0, c.velocity.z);
}
}
/// `AbstractMinecart.tick` for one cart; returns false once it is gone.
@@ -989,6 +1012,11 @@ impl Game<'_> {
}
}
CartKind::Tnt => {
// MinecartTNT.causeFallDamage ran inside the move.
if let Some(speed_sqr) = c.fall_blast.take() {
self.explode_cart(c, speed_sqr);
return false;
}
// MinecartTNT.tick: the fuse and the collision check are separate.
if c.fuse > 0 {
c.fuse -= 1;
@@ -1132,6 +1160,47 @@ impl Game<'_> {
c.velocity.z -= za;
}
}
self.push_carts_from_mobs();
}
/// `AbstractMinecart.push(mob)` from each mob walking into a cart
/// (`LivingEntity.pushEntities` -> `doPush`): the cart gets the push and
/// the mob a quarter of it back (`Entity.push`, which needs the mob to be
/// pushable and to carry no one). Bats push nothing (`Bat.pushEntities`).
fn push_carts_from_mobs(&mut self) {
let teams = self.mob_teams();
// (mob index, feet, box)
let pushers: Vec<(usize, Vec3, Aabb)> = self
.mobs
.iter()
.enumerate()
.filter(|(_, m)| {
!m.removed
&& m.alive()
&& m.species() != Species::Bat
&& pushable_by(teams.of(Who::Mob(m.id)), None, true)
})
.map(|(j, m)| (j, m.position, m.bounds()))
.collect();
drop(teams);
for (j, feet, bb) in pushers {
for c in &mut self.carts {
// hasPassenger(mob): a mob in this cart never pushes it.
if self.mobs[j].extra.mount == Some(c.id) || !bb.intersects(c.bounds()) {
continue;
}
let Some((xa, za)) = cart_push_from(feet, c.position) else {
continue;
};
c.velocity.x -= xa;
c.velocity.z -= za;
let m = &mut self.mobs[j];
if m.pushable() && m.rider.is_none() && m.extra.rider.is_none() {
m.velocity.x += xa / 4.0;
m.velocity.z += za / 4.0;
m.needs_sync = true;
}
}
}
}
/// `OldMinecartBehavior.pushAndPickupEntities` for carts, at the end of
/// cart `i`'s own tick: each other cart in its box (0.2 wider on each
@@ -2283,6 +2352,14 @@ fn tnt_ignites_by_collision(horizontal_collision: bool, speed_sqr: f64) -> bool
#[cfg(test)]
mod tests {
#[test]
fn mob_walking_into_a_cart_pushes_it_by_a_twentieth() {
// Issue #6776: a villager at x=0.13 next to a cart at x=0.5 east-west.
let (xa, za) =
super::cart_push_from(Vec3::new(0.13, 0.0, 0.0), Vec3::new(0.5, 0.0, 0.0)).unwrap();
assert!((xa + 0.05).abs() < 1e-6 && za == 0.0, "{xa} {za}");
assert!(super::cart_push_from(Vec3::ZERO, Vec3::ZERO).is_none());
}
/// The collision detonation of a TNT minecart needs both the collision and
/// a cart that was still moving: a nudge below a tenth of a block a tick
/// does not set it off, and a fast cart on an open track does not either.
+109
View File
@@ -159,6 +159,115 @@ impl Host for super::Game<'_> {
) -> bool {
self.native_set_block(dimension, pos, state, flags)
}
fn edit_blocks(&mut self, dimension: &str, edit: &NativeBlockEdit) -> Result<u64, String> {
self.native_edit_blocks(dimension, edit)
}
fn surface_heights(
&self,
dimension: &str,
columns: &[(i32, i32)],
heightmap: NativeHeightmap,
) -> Result<Vec<Option<i32>>, String> {
self.native_surface_heights(dimension, columns, heightmap)
}
fn read_columns(
&self,
dimension: &str,
columns: &[(i32, i32)],
heightmap: NativeHeightmap,
range: (u32, u32),
) -> Result<NativeColumnBlocks, String> {
self.native_read_columns(dimension, columns, heightmap, range)
}
fn refresh_biome_colors(&mut self) {
self.native_refresh_biome_colors()
}
fn neighbor_signal(&mut self, dimension: &str, pos: [i32; 3]) -> i32 {
self.native_neighbor_signal(dimension, pos)
}
fn light(&self, pos: [i32; 3]) -> (u8, u8) {
self.native_light(pos)
}
fn weather(&self) -> (f32, f32) {
self.native_weather()
}
fn day_time(&self) -> i64 {
self.native_day_time()
}
fn world_height(&self) -> (i32, i32) {
self.native_world_height()
}
fn max_stack_size(&self, item: &str) -> Option<u32> {
self.native_max_stack_size(item)
}
fn item_model(
&self,
batch: &mut HudBatch,
item: &str,
model: &str,
at: [f32; 2],
size: f32,
) -> bool {
self.native_item_model(batch, item, model, at, size)
}
fn quad(&self, batch: &mut HudBatch, points: [[f32; 2]; 4], argb: u32) {
self.native_quad(batch, points, argb)
}
fn mod_screen_open(&mut self, mod_id: &str) -> bool {
self.native_mod_screen_open(mod_id)
}
fn mod_screen_close(&mut self, mod_id: &str) {
self.native_mod_screen_close(mod_id)
}
fn mod_screen(&self) -> Option<&str> {
self.native_mod_screen()
}
fn set_experience_orb(
&mut self,
dimension: &str,
id: u32,
value: Option<u32>,
count: Option<u32>,
age: Option<u32>,
) -> bool {
self.native_set_experience_orb(dimension, id, value, count, age)
}
fn experience_orbs(
&self,
dimension: &str,
min: [f64; 3],
max: [f64; 3],
) -> Vec<NativeExperienceOrb> {
self.native_experience_orbs(dimension, min, max)
}
fn clock(&self) -> Option<NativeClock> {
self.native_clock()
}
fn spawn_falling_block(
&mut self,
dimension: &str,
state: BlockStateId,
pos: [f64; 3],
velocity: [f64; 3],
falling: NativeFalling,
) -> bool {
self.native_spawn_falling_block(dimension, state, pos, velocity, falling)
}
fn block_state_property(&self, state: BlockStateId, key: &str) -> Option<&str> {
self.native_block_state_property(state, key)
}
fn raycast(&self, distance: f32) -> Option<NativeHit> {
self.native_raycast(distance)
}
fn view_hit(&self) -> Option<(f32, NativeHit)> {
self.native_view_hit()
}
fn claim_items(&mut self, mod_id: &str, ids: &[String]) {
self.native_claim_items(mod_id, ids)
}
fn poll(&mut self, mod_id: &str) -> Vec<NativeModEvent> {
self.native_poll(mod_id)
}
fn set_item_entity(&mut self, dimension: &str, id: u32, stack: ItemStack) -> bool {
self.native_set_item_entity(dimension, id, stack)
}
+14
View File
@@ -351,6 +351,8 @@ impl Game<'_> {
// Animal.setInLove: entity event 18.
if loved {
self.love_cause.insert(id, self.lan_acting);
let kind = self.mobs[index].kind.id.clone();
self.native_entity_enter_love_mode(id, &kind, self.lan_acting_uuid);
self.mob_event_particles(id, 18);
}
self.consume_held();
@@ -613,6 +615,18 @@ impl Game<'_> {
// AbstractHorse.tameWithName: entity event 7, spawnTamingParticles(true).
self.mob_event_particles(mob, 7);
}
if let Some(kind) = self
.mobs
.iter()
.find(|m| m.id == mob)
.map(|m| m.kind.id.clone())
{
let owner = self
.level
.player_uuid
.map(horizon_client::lan::playerdata::ints_uuid);
self.native_entity_tame(mob, &kind, owner);
}
}
MobEvent::Buck { mob } => {
if self.riding == Some(mob) {
+7
View File
@@ -29,11 +29,18 @@ impl Game<'_> {
if !m.alive() {
return false;
}
let entity_id = m.id;
let entity_name = name.clone();
m.custom_name = Some(name);
m.persistent = true;
// itemStack.shrink(1), in creative too.
self.consume_held();
self.swing_arm();
if let (Some(player), Some(player_name)) =
(self.lan_acting_uuid, self.lan_acting_name.clone())
{
self.native_player_name_entity(player, &player_name, entity_id, &entity_name);
}
true
}
/// `Entity.belowNameDisplay`: the score holder's score in the objective
@@ -43,7 +43,6 @@ fn in_frustum(frustum: Option<&impl Cull>, origin: Vec3, bounds: [f32; 6]) -> bo
/// partial tick, game time and light, and the pose stack the helpers draw
/// under (`native_draw_quad`, `native_draw_cube`, `native_draw_block`).
// Native mods read and drive it; nothing in Horizon does.
#[allow(dead_code)]
pub struct BlockEntityDraw {
/// The frame's partial tick, 0 to 1 (`BlockEntityRenderState`).
pub partial: f32,
@@ -174,7 +173,6 @@ impl BlockEntityDraw {
/// One face of a `native_draw_cube` box: its sprite (`mymod:entity/cog`,
/// `mymod:block/casing`) and its UVs in texels of 16 per side, as a model
/// face's `uv` (none takes the box's own, as a model's default).
#[allow(dead_code)]
#[derive(Clone, Copy, Debug)]
pub struct CubeFace<'a> {
pub sprite: &'a str,
@@ -5,36 +5,33 @@ use super::Game;
use std::collections::BTreeMap;
/// A stack as a client mod's use events carry it.
#[allow(dead_code)]
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct NativeHeldItem {
pub(crate) id: String,
pub(crate) count: u32,
pub struct NativeHeldItem {
pub id: String,
pub count: u32,
/// The inventory slot: 0 to 8, the hotbar (a mod use is the main hand's).
pub(crate) slot: u32,
pub slot: u32,
/// `minecraft:custom_name` as SNBT, when the stack has one.
pub(crate) custom_name: Option<String>,
pub custom_name: Option<String>,
/// `minecraft:custom_data` as SNBT, when the stack has some.
pub(crate) custom_data: Option<String>,
pub custom_data: Option<String>,
}
/// The block a ray along the view meets.
#[allow(dead_code)]
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct NativeHit {
pub(crate) block_x: i32,
pub(crate) block_y: i32,
pub(crate) block_z: i32,
pub struct NativeHit {
pub block_x: i32,
pub block_y: i32,
pub block_z: i32,
/// Where the ray meets the block.
pub(crate) x: f64,
pub(crate) y: f64,
pub(crate) z: f64,
pub x: f64,
pub y: f64,
pub z: f64,
/// The block's id (`minecraft:stone`).
pub(crate) block: String,
pub block: String,
}
/// What happened to a mod's claimed item, oldest first.
#[allow(dead_code)]
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum NativeModEvent {
pub enum NativeModEvent {
/// A right-click with the item began its use: the stack, and 0 ticks.
UseStart(NativeHeldItem, u32),
/// The use ended: the stack it began with, and the ticks it was held. It
@@ -15,12 +15,12 @@ use std::collections::{BTreeMap, HashMap, HashSet};
/// A data map type a mod registers (`RegisterDataMapTypesEvent`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct NativeDataMapType {
pub struct NativeDataMapType {
/// `mod_id:name`: not in the `minecraft` namespace. Its files are
/// `data/<mod_id>/data_maps/<registry path>/<name>.json`.
pub(crate) id: String,
pub id: String,
/// The registry it is for: `minecraft:item` or `minecraft:block`.
pub(crate) registry: String,
pub registry: String,
}
/// The registries data maps can be attached to so far, with their folder
@@ -159,8 +159,8 @@ impl Game<'_> {
let first = self.next_entity + 1;
let deferred = (self.tnts.len(), self.falling.len(), self.rockets.len());
let target = self.spawn_entity_from_nbt(tag);
// These roots load with id 0 and have no command Target variant.
// Select the newly appended root, never an older waiting entity.
// These roots take their id as they load, and a rocket has no command
// Target variant. Select the newly appended root, never an older waiting entity.
let root = match kind {
"minecraft:tnt" => self.tnts.get_mut(deferred.0).map(|e| &mut e.id),
"minecraft:falling_block" => self.falling.get_mut(deferred.1).map(|e| &mut e.id),
@@ -16,7 +16,7 @@ const NATIVE_FALLING_MAX_SPEED: f64 = 3.9;
/// What a mod's falling block does when it lands and when it hits.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct NativeFalling {
pub struct NativeFalling {
/// `FallingBlockEntity.dropItem`: whether it drops as an item where it
/// can't be placed.
pub drop_item: bool,
File diff suppressed because it is too large. Load diff
@@ -20,7 +20,6 @@ use horizon_client::{
/// `LivingEntityRenderState`: what a model reads to pose and animate itself.
/// Angles are in degrees, interpolated to the frame's partial tick.
// Native mods read it; nothing in Horizon does.
#[allow(dead_code)]
#[derive(Clone, Debug)]
pub struct NativeMobRender<'a> {
/// The mob's network id, as the mob helpers and mod messages name it.
@@ -83,7 +82,6 @@ impl<'a> NativeMobRender<'a> {
/// Which player a [`NativeMobRender`] is, for `native_player_submit` and
/// `native_mob_layer`.
// Native mods read it; nothing in Horizon does.
#[allow(dead_code)]
#[derive(Clone, Copy, Debug)]
pub struct NativePlayerRender<'a> {
/// The player's profile name.
@@ -375,7 +375,12 @@ impl Game<'_> {
)
}
/// A system line in every player's chat and the server's log.
pub(super) fn native_broadcast(&self, line: Text) {
pub(super) fn native_broadcast(&self, mut line: Text) {
let mut cancel = false;
self.native_broadcast_message(&mut line, &mut cancel);
if cancel {
return;
}
let plain = line.plain();
if self.options.dedicated.is_some() {
super::dedicated::log("INFO", &plain);
@@ -722,9 +727,8 @@ impl Game<'_> {
/// An experience orb as a server mod reads it (`native_experience_orbs`).
/// Read by mods only.
#[allow(dead_code)]
#[derive(Clone, Debug)]
pub(super) struct NativeExperienceOrb {
pub struct NativeExperienceOrb {
/// The entity's ID, as `native_entity_join_level` names it.
pub id: u32,
pub position: [f64; 3],
@@ -893,6 +897,49 @@ impl Game<'_> {
host.native_commands = Some(levels);
}
/// Asks the mods what root commands each player's tree keeps
/// (`native_player_commands_sent`) and stores the answer in the hub,
/// which sends the trees without what the mods took out: for `only`, or
/// for every placed player.
pub(super) fn native_filter_commands(&mut self, host: &mut LanHost, only: Option<u32>) {
let players: Vec<(u32, u128, String)> = host
.hub
.guests
.iter()
.filter(|g| only.map_or(g.placed, |conn| g.conn == conn))
.map(|g| (g.conn, g.uuid, g.name.clone()))
.collect();
for (conn, uuid, name) in players {
let names = host.hub.command_names(conn);
let mut kept = names.clone();
let placed = host.hub.guest(conn).is_some_and(|g| g.placed);
if !placed {
// A joining player has no world yet: nothing can be told them.
self.native_player_commands_sent(uuid, &name, &mut kept);
} else if self.lan_acting == Some(conn) {
self.native_player_commands_sent(uuid, &name, &mut kept);
} else {
self.as_guest(host, conn, |g| {
g.native_player_commands_sent(uuid, &name, &mut kept);
});
}
if let Some(g) = host.hub.guest_mut(conn) {
g.hidden_commands = names.into_iter().filter(|n| !kept.contains(n)).collect();
}
}
}
/// `Hub::resend_commands` with the mods' say on each tree.
pub(super) fn resend_commands_filtered(&mut self) {
let Some(mut host) = self.lan.take() else {
return;
};
host.hub.refresh_permissions();
self.native_filter_commands(&mut host, None);
host.hub.resend_commands();
self.lan = Some(host);
}
/// The level a guest needs for a mod's command (`line`, without its
/// slash), when a mod registered it.
pub(super) fn native_command_level(host: &LanHost, line: &str) -> Option<u8> {
@@ -7,11 +7,11 @@ use super::native_world_edit::NativeHeightmap;
/// What `native_read_columns` read: the blocks round each column's height (`bases`, none where it isn't loaded),
/// as indexes into `palette`, a column after another, and three biome tints a column (grass, foliage, water).
#[derive(Clone, Debug, PartialEq, Default)]
pub(crate) struct NativeColumnBlocks {
pub(crate) palette: Vec<String>,
pub(crate) bases: Vec<Option<i32>>,
pub(crate) blocks: Vec<u16>,
pub(crate) tints: Vec<u32>,
pub struct NativeColumnBlocks {
pub palette: Vec<String>,
pub bases: Vec<Option<i32>>,
pub blocks: Vec<u16>,
pub tints: Vec<u32>,
}
#[allow(dead_code)]
@@ -51,8 +51,9 @@ impl Game<'_> {
dimension: &str,
columns: &[(i32, i32)],
heightmap: NativeHeightmap,
(below, above): (u32, u32),
range: (u32, u32),
) -> Result<NativeColumnBlocks, String> {
let (below, above) = range;
if !self.native_level_open(dimension) {
return Err(format!("{dimension} is not the level being ticked"));
}
@@ -3,9 +3,8 @@
use super::Game;
/// Which of a column's heights (`Heightmap.Types`) a run's `y` counts from.
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum NativeHeightmap {
pub enum NativeHeightmap {
WorldSurface,
MotionBlocking,
MotionBlockingNoLeaves,
@@ -14,22 +13,22 @@ pub(crate) enum NativeHeightmap {
/// A run: `height` blocks up a column from `y` (from the column's `surface` height when one is
/// given), set to palette entry `block`, only where filter `only` matches when one is given.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct NativeBlockRun {
pub(crate) x: i32,
pub(crate) z: i32,
pub(crate) y: i32,
pub(crate) height: u32,
pub(crate) surface: Option<NativeHeightmap>,
pub(crate) block: u32,
pub(crate) only: Option<u32>,
pub struct NativeBlockRun {
pub x: i32,
pub z: i32,
pub y: i32,
pub height: u32,
pub surface: Option<NativeHeightmap>,
pub block: u32,
pub only: Option<u32>,
}
/// A bulk edit: block states and block predicates as commands write them (without NBT), and the runs naming them
/// by index.
#[derive(Clone, Debug, PartialEq, Default)]
pub(crate) struct NativeBlockEdit {
pub(crate) palette: Vec<String>,
pub(crate) filters: Vec<String>,
pub(crate) runs: Vec<NativeBlockRun>,
pub struct NativeBlockEdit {
pub palette: Vec<String>,
pub filters: Vec<String>,
pub runs: Vec<NativeBlockRun>,
}
#[allow(dead_code)]
Loaded 100 of 395 files, more files were not shown because too many files have changed in this diff. Show more