mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge main into #6712
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
7ba83315a8
419 files changed
+28123
-2125
No files matched your search
@@ -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"}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
//!
|
||||
//! horizon-bots --server 127.0.0.1:25565 --bots 25 --behaviour mixed --seconds 360 --report bots.json
|
||||
//!
|
||||
//! It prints one JSON line a second (bots online, chunks received) and writes a summary with `--report`.
|
||||
//! It prints one JSON line a second (bots online, chunks received), one with the first bot's spawn position, and
|
||||
//! writes a summary with `--report`.
|
||||
//! A bot moves only while it holds the chunk it stands in: vanilla's client stops ticking its player in a chunk
|
||||
//! it hasn't received, so a player can't outrun the server's chunk sending, on any server.
|
||||
//! Each bot also keeps the chunks it holds, as a client's chunk cache does, so the report tells chunks that
|
||||
@@ -102,6 +103,8 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
to test what a server takes
|
||||
--timer F cheat, to test an anticheat: tick F times as fast as vanilla's client (1.5: 30 Hz)
|
||||
--report FILE a JSON summary at the end (- prints it after the per-second lines)
|
||||
--capture FILE record the first bot's play packets (milliseconds since it joined, id, body)
|
||||
for crates/horizon-protocol/examples/net_replay.rs
|
||||
";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -177,6 +180,8 @@ struct Options {
|
||||
/// The `minecraft:brand` to send, if any.
|
||||
brand: Option<String>,
|
||||
report: Option<String>,
|
||||
/// --capture: where the first bot's play packets go.
|
||||
capture: Option<String>,
|
||||
/// How many times as fast as vanilla's 20 Hz the bots tick (a timer cheat above 1).
|
||||
timer: f64,
|
||||
}
|
||||
@@ -207,6 +212,7 @@ fn options() -> Result<Options, String> {
|
||||
click: None,
|
||||
name: "Bot".into(),
|
||||
report: None,
|
||||
capture: None,
|
||||
brand: None,
|
||||
timer: 1.0,
|
||||
};
|
||||
@@ -256,6 +262,7 @@ fn options() -> Result<Options, String> {
|
||||
"--attack-rounds" => o.attack_rounds = value.parse().map_err(|_| number("a number"))?,
|
||||
"--name" => o.name = value,
|
||||
"--report" => o.report = Some(value),
|
||||
"--capture" => o.capture = Some(value),
|
||||
"--brand" => o.brand = Some(value),
|
||||
"--give" => {
|
||||
for part in value.split(',') {
|
||||
@@ -819,12 +826,53 @@ impl BatchSize {
|
||||
}
|
||||
}
|
||||
|
||||
/// `--capture`: one record per play packet, little-endian: u32 milliseconds since the
|
||||
/// bot joined, i32 packet id, u32 body length, then the body (without the id).
|
||||
struct Capture {
|
||||
out: std::io::BufWriter<std::fs::File>,
|
||||
start: Instant,
|
||||
/// The second last flushed: the process may end before the reader thread does.
|
||||
flushed: u32,
|
||||
}
|
||||
impl Capture {
|
||||
fn create(path: &str) -> Option<Self> {
|
||||
match std::fs::File::create(path) {
|
||||
Ok(file) => Some(Self {
|
||||
out: std::io::BufWriter::new(file),
|
||||
start: Instant::now(),
|
||||
flushed: 0,
|
||||
}),
|
||||
Err(e) => {
|
||||
eprintln!("--capture {path}: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
fn record(&mut self, id: i32, body: &[u8]) {
|
||||
use std::io::Write;
|
||||
let ms = self.start.elapsed().as_millis() as u32;
|
||||
let mut head = [0u8; 12];
|
||||
head[..4].copy_from_slice(&ms.to_le_bytes());
|
||||
head[4..8].copy_from_slice(&id.to_le_bytes());
|
||||
head[8..].copy_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
let _ = self
|
||||
.out
|
||||
.write_all(&head)
|
||||
.and_then(|()| self.out.write_all(body));
|
||||
if ms / 1000 != self.flushed {
|
||||
self.flushed = ms / 1000;
|
||||
let _ = self.out.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the play state: answers what a client must answer, counts chunks, records teleports.
|
||||
fn read(
|
||||
mut reader: horizon_protocol::connection::ConnReader,
|
||||
writer: Arc<Mutex<ConnWriter>>,
|
||||
bot: Arc<Bot>,
|
||||
track: bool,
|
||||
mut capture: Option<Capture>,
|
||||
) {
|
||||
use ids::play::clientbound as cb;
|
||||
use ids::play::serverbound as sb;
|
||||
@@ -848,6 +896,9 @@ fn read(
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(c) = &mut capture {
|
||||
c.record(id, &body);
|
||||
}
|
||||
let mut r = Reader::new(&body);
|
||||
let sent = match id {
|
||||
cb::KEEP_ALIVE => match r.i64() {
|
||||
@@ -1007,7 +1058,10 @@ fn run_bot(
|
||||
{
|
||||
let (writer, bot) = (writer.clone(), bot.clone());
|
||||
let track = behaviour == Behaviour::Fight;
|
||||
thread::spawn(move || read(reader, writer, bot, track));
|
||||
let capture = (index == 0)
|
||||
.then(|| o.capture.as_deref().and_then(Capture::create))
|
||||
.flatten();
|
||||
thread::spawn(move || read(reader, writer, bot, track, capture));
|
||||
}
|
||||
use ids::play::serverbound as sb;
|
||||
// Wait for the spawn position.
|
||||
@@ -1025,6 +1079,10 @@ fn run_bot(
|
||||
};
|
||||
bot.joined_after_ms
|
||||
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
|
||||
if index == 0 {
|
||||
// Where the bots gather, for a client that comes to watch them (tools/perf_bench.py --server-bots).
|
||||
println!("{}", json!({ "spawn": spawn }));
|
||||
}
|
||||
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
|
||||
if !o.no_loaded {
|
||||
send(raw(sb::PLAYER_LOADED, |_| {}));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -518,7 +518,8 @@ pub fn panic_report(info: &std::panic::PanicHookInfo, side: &'static str) -> Rep
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `name` is one of the streaming pool's threads, vanilla's `Util` worker threads.
|
||||
/// Whether `name` is one of the shared worker pool's threads (`horizon_threads::jobs`), vanilla's
|
||||
/// `Util` worker threads.
|
||||
fn is_worker_thread(name: Option<&str>) -> bool {
|
||||
name.is_some_and(|n| n.starts_with("horizon-worker-"))
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -127,6 +127,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 {
|
||||
@@ -166,6 +174,9 @@ impl Default for Extras {
|
||||
repro_keys: false,
|
||||
discord_presence: true,
|
||||
splitscreen: false,
|
||||
splitscreen_two: 0,
|
||||
splitscreen_three: 0,
|
||||
splitscreen_names: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -247,6 +258,9 @@ impl Extras {
|
||||
"reproKeys" => e.repro_keys = flag(e.repro_keys),
|
||||
"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),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -254,7 +268,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:{}\nreproKeys:{}\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:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
@@ -295,6 +309,9 @@ impl Extras {
|
||||
bool_text(self.repro_keys),
|
||||
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
|
||||
@@ -408,6 +425,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)]
|
||||
|
||||
@@ -110,6 +110,7 @@ mod arena;
|
||||
pub mod compositor;
|
||||
mod cull;
|
||||
mod end_sky;
|
||||
pub mod entity_skin;
|
||||
mod flat;
|
||||
mod frame_gen;
|
||||
mod hdr;
|
||||
@@ -627,6 +628,9 @@ pub struct Frame<'a> {
|
||||
pub glowing: &'a [Vertex],
|
||||
/// Runs of `entities` by entity, while a shader pack draws them (`pack_draw::EntitySpan`).
|
||||
pub entity_spans: &'a [pack_draw::EntitySpan],
|
||||
/// Ordinary entity models posed on the GPU (`entity_skin`), drawn before `entities`'
|
||||
/// ordinary ones; only when `Renderer::skins_entities` said so.
|
||||
pub skin: Option<&'a entity_skin::SkinFrame>,
|
||||
pub weather: &'a [crate::weather::Vertex],
|
||||
pub weather_time: crate::weather::Time,
|
||||
/// Horizon Extras' dynamic lights, nearest first (`dynamic_light::select`); empty when off.
|
||||
@@ -773,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).
|
||||
@@ -856,6 +861,8 @@ pub struct Renderer {
|
||||
entity_buffer: wgpu::Buffer,
|
||||
entity_capacity: u64,
|
||||
entity_batch: EntityBatch,
|
||||
/// Ordinary entity models posed on the GPU; `None` without compute shaders.
|
||||
entity_skin: Option<entity_skin::EntitySkin>,
|
||||
group: wgpu::BindGroup,
|
||||
globals: wgpu::Buffer,
|
||||
/// The resource packs' LabPBR maps (normals, specular), for native shader packs.
|
||||
@@ -1523,37 +1530,64 @@ async fn open_device(
|
||||
api: GraphicsApi,
|
||||
layers: u32,
|
||||
) -> Result<Opened> {
|
||||
// A software adapter (WARP, llvmpipe) is a last resort: a later backend may
|
||||
// have the real GPU (#3978: a Fermi GPU with no Vulkan and no DX12 driver
|
||||
// still has OpenGL).
|
||||
match open_device_with(window, offscreen, api, layers, false).await {
|
||||
Ok(opened) => Ok(opened),
|
||||
Err(first) => match open_device_with(window, offscreen, api, layers, true).await {
|
||||
let wgpu_backend = std::env::var_os("WGPU_BACKEND").is_some();
|
||||
let mut first = None;
|
||||
for (order, allow_cpu) in device_search(api.with_env(), std::env::consts::OS, wgpu_backend) {
|
||||
match open_device_with(window, offscreen, &order, layers, allow_cpu).await {
|
||||
Ok(opened) => {
|
||||
log::warn!(
|
||||
"Horizon renderer: no GPU adapter could be used ({first}); \
|
||||
rendering on the CPU, which is slow"
|
||||
);
|
||||
Ok(opened)
|
||||
if let Some(first) = &first {
|
||||
log::warn!(
|
||||
"Horizon renderer: the chosen graphics API gave no GPU ({first}); {}",
|
||||
if allow_cpu {
|
||||
"rendering on the CPU, which is slow"
|
||||
} else {
|
||||
"using the GPU through another one"
|
||||
}
|
||||
);
|
||||
}
|
||||
return Ok(opened);
|
||||
}
|
||||
Err(_) => Err(first),
|
||||
},
|
||||
Err(e) => {
|
||||
first.get_or_insert(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(first.unwrap_or_else(|| NO_GPU_DRIVER.into()))
|
||||
}
|
||||
/// `open_device`, taking an adapter of the CPU type (software rendering) only
|
||||
/// when `allow_cpu`.
|
||||
/// The tries `open_device` makes, in turn: the chosen API's backends (`backend_order`) on a
|
||||
/// real GPU; when the player chose one API, the other backends `Auto` would try, on a real
|
||||
/// GPU; and last the chosen backends with a software adapter (WARP, llvmpipe) allowed.
|
||||
/// Software rendering takes most of a weak CPU even on the title screen: an Intel HD 2500 has
|
||||
/// DirectX 12 only through WARP and its GPU through OpenGL (#5185), and a Fermi GPU has no
|
||||
/// Vulkan or DirectX 12 driver but OpenGL (#3978). DirectX 12 never falls back to Vulkan: the
|
||||
/// launcher chooses it after Vulkan crashed in the driver (#746), which the game can't catch.
|
||||
fn device_search(
|
||||
api: GraphicsApi,
|
||||
os: &str,
|
||||
wgpu_backend: bool,
|
||||
) -> Vec<(Vec<wgpu::Backends>, bool)> {
|
||||
let chosen = backend_order(api, os, wgpu_backend);
|
||||
let others: Vec<_> = backend_order(GraphicsApi::Auto, os, wgpu_backend)
|
||||
.into_iter()
|
||||
.filter(|b| !chosen.contains(b))
|
||||
.filter(|&b| !(api == GraphicsApi::Dx12 && b == wgpu::Backends::VULKAN))
|
||||
.collect();
|
||||
let mut tries = vec![(chosen.clone(), false)];
|
||||
if !others.is_empty() {
|
||||
tries.push((others, false));
|
||||
}
|
||||
tries.push((chosen, true));
|
||||
tries
|
||||
}
|
||||
/// `open_device` on `order`'s backends in turn, taking an adapter of the CPU type
|
||||
/// (software rendering) only when `allow_cpu`.
|
||||
async fn open_device_with(
|
||||
window: &Arc<Window>,
|
||||
offscreen: bool,
|
||||
api: GraphicsApi,
|
||||
order: &[wgpu::Backends],
|
||||
layers: u32,
|
||||
allow_cpu: bool,
|
||||
) -> Result<Opened> {
|
||||
let order = backend_order(
|
||||
api.with_env(),
|
||||
std::env::consts::OS,
|
||||
std::env::var_os("WGPU_BACKEND").is_some(),
|
||||
);
|
||||
let mut failures = Vec::new();
|
||||
for (attempt, backends) in order.iter().copied().enumerate() {
|
||||
let name = backends_name(backends);
|
||||
@@ -1808,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(
|
||||
@@ -2200,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",
|
||||
@@ -3443,10 +3535,14 @@ impl Renderer {
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let entity_capacity = 1 << 14;
|
||||
let entity_skin = entity_skin::EntitySkin::new(
|
||||
&device,
|
||||
animate::SpriteAnimator::compute(&adapter, &device),
|
||||
);
|
||||
let entity_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Entity vertices"),
|
||||
size: entity_capacity * std::mem::size_of::<Vertex>() as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
usage: entity_skin::EntitySkin::usage(entity_skin.is_some()),
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let flat_wanted = flat::wanted(&adapter, &device);
|
||||
@@ -3610,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,
|
||||
@@ -3672,6 +3769,7 @@ impl Renderer {
|
||||
entity_buffer,
|
||||
entity_capacity,
|
||||
entity_batch: EntityBatch::default(),
|
||||
entity_skin,
|
||||
group,
|
||||
globals,
|
||||
pbr_maps,
|
||||
@@ -3797,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) {
|
||||
@@ -4841,6 +4964,23 @@ impl Renderer {
|
||||
}
|
||||
/// The share of real frames frame generation shows a made frame before (1 while it
|
||||
/// keeps up), `None` while it isn't running.
|
||||
/// Whether the game may pose ordinary entity models on the GPU (`Frame::skin`): not
|
||||
/// while a shader pack or a native pack draws entities from their vertices, nor while
|
||||
/// frame generation follows them.
|
||||
/// The posed models' store size (`SkinFrame::base`), with entity skinning.
|
||||
pub fn skinned_models(&self) -> Option<u32> {
|
||||
self.entity_skin
|
||||
.as_ref()
|
||||
.map(entity_skin::EntitySkin::stored)
|
||||
}
|
||||
|
||||
pub fn skins_entities(&self) -> bool {
|
||||
self.entity_skin.is_some()
|
||||
&& !self.frame_gen.on
|
||||
&& self.pack_post.is_none()
|
||||
&& self.native.is_none()
|
||||
}
|
||||
|
||||
pub fn frame_generation_share(&self) -> Option<f32> {
|
||||
self.frame_gen.made_share
|
||||
}
|
||||
@@ -6412,6 +6552,13 @@ impl Renderer {
|
||||
oit_on,
|
||||
);
|
||||
self.entity_batch.append_shadow_only(frame.shadow_only);
|
||||
// The posed models' vertices come first: the ordinary entities' draw takes them in.
|
||||
let skin = frame.skin.filter(|skin| match &mut self.entity_skin {
|
||||
Some(skinner) => skinner.register(&self.device, &mut self.uploads, &mut encoder, skin),
|
||||
None => false,
|
||||
});
|
||||
let posed = skin.map_or(0, |s| s.vertices);
|
||||
let entity_quads = entity_quads + posed / 4;
|
||||
let weather_quads = frame.weather.len() as u32 / 4;
|
||||
let world_quads = entity_quads
|
||||
+ text_quads
|
||||
@@ -6467,7 +6614,7 @@ impl Renderer {
|
||||
camera,
|
||||
);
|
||||
// Frame generation's made frame draws its own hand, halfway, after the others.
|
||||
let made_hand_vertex = self.entity_batch.combined.len() as i32;
|
||||
let made_hand_vertex = (posed as usize + self.entity_batch.combined.len()) as i32;
|
||||
if fg {
|
||||
self.frame_gen
|
||||
.halfway_hand(frame.hand, hand_view, &mut self.entity_batch.combined);
|
||||
@@ -6484,24 +6631,34 @@ impl Renderer {
|
||||
let additive_quads = (frame.additive.len() / 4) as u32 - self.entity_batch.moved_additive;
|
||||
let see_through_quads = (frame.see_through.len() / 4) as u32;
|
||||
let shadow_only_quads = (frame.shadow_only.len() / 4) as u32;
|
||||
let entity_vertices = all_entities.len() as u64;
|
||||
let entity_vertices = u64::from(posed) + all_entities.len() as u64;
|
||||
if entity_vertices > self.entity_capacity {
|
||||
self.entity_capacity = entity_vertices.next_power_of_two();
|
||||
self.entity_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Entity vertices"),
|
||||
size: self.entity_capacity * std::mem::size_of::<Vertex>() as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
usage: entity_skin::EntitySkin::usage(self.entity_skin.is_some()),
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
}
|
||||
if entity_vertices > 0 {
|
||||
if !all_entities.is_empty() {
|
||||
self.uploads.buffer(
|
||||
&mut encoder,
|
||||
&self.entity_buffer,
|
||||
0,
|
||||
u64::from(posed) * std::mem::size_of::<Vertex>() as u64,
|
||||
bytemuck::cast_slice(all_entities),
|
||||
);
|
||||
}
|
||||
if let (Some(skinner), Some(skin)) = (&mut self.entity_skin, skin) {
|
||||
skinner.dispatch(
|
||||
&self.device,
|
||||
&mut self.uploads,
|
||||
&mut encoder,
|
||||
skin,
|
||||
&self.entity_buffer,
|
||||
0,
|
||||
);
|
||||
}
|
||||
self.entity_outline.prepare(
|
||||
&self.device,
|
||||
&mut self.uploads,
|
||||
@@ -7765,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));
|
||||
@@ -7773,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);
|
||||
@@ -7785,6 +7949,7 @@ impl Renderer {
|
||||
solid_draws,
|
||||
translucent_draws,
|
||||
);
|
||||
self.mark_translucent_terrain(&mut pass, None, solid_draws, translucent_draws);
|
||||
}
|
||||
}
|
||||
// LevelRenderer.executeTranslucentAfterTerrain: vanilla's
|
||||
@@ -8522,8 +8687,9 @@ impl Renderer {
|
||||
if let Some(radius) = menu_blur {
|
||||
let format = self.config.format;
|
||||
let device = &self.device;
|
||||
let software = is_software(self.info.device_type);
|
||||
self.menu_blur
|
||||
.get_or_insert_with(|| crate::menu_blur::MenuBlur::new(device, format))
|
||||
.get_or_insert_with(|| crate::menu_blur::MenuBlur::new(device, format, software))
|
||||
.apply(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
@@ -9312,6 +9478,37 @@ mod tests {
|
||||
assert_eq!(order(api, "macos"), [Backends::default()]);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn a_chosen_api_without_a_gpu_tries_the_others_before_software() {
|
||||
use super::{GraphicsApi, device_search};
|
||||
use wgpu::Backends;
|
||||
let (v, d, g) = (Backends::VULKAN, Backends::DX12, Backends::GL);
|
||||
// #5185: DirectX 12 chosen on an Intel HD 2500 (WARP only): OpenGL's GPU before WARP.
|
||||
assert_eq!(
|
||||
device_search(GraphicsApi::Dx12, "windows", false),
|
||||
[(vec![d], false), (vec![g], false), (vec![d], true)]
|
||||
);
|
||||
assert_eq!(
|
||||
device_search(GraphicsApi::Vulkan, "windows", false),
|
||||
[(vec![v], false), (vec![d, g], false), (vec![v], true)]
|
||||
);
|
||||
assert_eq!(
|
||||
device_search(GraphicsApi::Auto, "windows", false),
|
||||
[(vec![v, d, g], false), (vec![v, d, g], true)]
|
||||
);
|
||||
assert_eq!(
|
||||
device_search(GraphicsApi::OpenGl, "linux", false),
|
||||
[(vec![g], false), (vec![v], false), (vec![g], true)]
|
||||
);
|
||||
// WGPU_BACKEND and macOS: one set of backends, nothing else to try.
|
||||
let all = Backends::default();
|
||||
for (os, env) in [("windows", true), ("macos", false)] {
|
||||
assert_eq!(
|
||||
device_search(GraphicsApi::Vulkan, os, env),
|
||||
[(vec![all], false), (vec![all], true)]
|
||||
);
|
||||
}
|
||||
}
|
||||
/// The second try at a backend shows the adapters wgpu hides for reporting
|
||||
/// no Vulkan conformance version (Mesa's hasvk, which drives Ivy Bridge and
|
||||
/// Haswell, reports 0.0.0.0 and works).
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
//! Entity models posed on the GPU. The CPU path (`mobs::render::draw`) walks a
|
||||
//! model's parts and pushes every vertex of every polygon, transformed by its
|
||||
//! part's matrix: ~280 vertices of 44 bytes per mob, then copied by material
|
||||
//! and uploaded each frame. Here each model's vertices are uploaded once, in
|
||||
//! model space, and a frame only sends each drawn part's matrices and one job
|
||||
//! per run of drawn parts; `entity_skin.wgsl` writes the same vertices, in the
|
||||
//! same order, into the entity vertex buffer, after the CPU's ordinary entities
|
||||
//! (`EntityBatch` leaves the gap). Every pass that draws ordinary entities
|
||||
//! then draws them as before.
|
||||
//!
|
||||
//! The game decides which draws come here (`SkinFrame`): models whose
|
||||
//! polygons all face an axis, drawn as ordinary opaque or cutout entities.
|
||||
//! The rest stays on the CPU path.
|
||||
|
||||
use super::upload::Uploads;
|
||||
use crate::mesher::Vertex;
|
||||
|
||||
/// A model vertex: position / 16, model UV, the face's normal as an axis (0..2
|
||||
/// = +x, +y, +z; 3..5 = -x, -y, -z), the part's index in its model.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct SkinVertex {
|
||||
pub pos: [f32; 3],
|
||||
pub uv: [f32; 2],
|
||||
pub axis: u32,
|
||||
pub part: u32,
|
||||
}
|
||||
|
||||
/// A drawn part: its geometry matrix and its normal matrix, by columns, the
|
||||
/// normal matrix's columns normalized when the part's scale is not uniform.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct SkinPart {
|
||||
pub matrix: [f32; 16],
|
||||
pub normal: [f32; 9],
|
||||
}
|
||||
|
||||
/// One run of a model's vertices drawn with one texture rect, colour, light
|
||||
/// and overlay. `dst` counts from the frame's first posed vertex.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct SkinJob {
|
||||
pub first: u32,
|
||||
pub count: u32,
|
||||
pub part: u32,
|
||||
pub dst: u32,
|
||||
pub rect: [f32; 4],
|
||||
pub scroll: f32,
|
||||
pub color: u32,
|
||||
pub light: u32,
|
||||
pub overlay: u32,
|
||||
/// 1 when the overlay is the armour glint: the model UV rides in `glint_uv`.
|
||||
pub glint: u32,
|
||||
pub spare: [u32; 3],
|
||||
}
|
||||
|
||||
const _: () = assert!(std::mem::size_of::<Vertex>() == 44);
|
||||
const _: () = assert!(std::mem::size_of::<SkinVertex>() == 7 * 4);
|
||||
const _: () = assert!(std::mem::size_of::<SkinPart>() == 25 * 4);
|
||||
const _: () = assert!(std::mem::size_of::<SkinJob>() == 16 * 4);
|
||||
|
||||
/// What the game hands the renderer for one frame.
|
||||
#[derive(Default)]
|
||||
pub struct SkinFrame {
|
||||
/// The model store restarted (models are registered again from 0).
|
||||
pub reset: bool,
|
||||
/// The store's size before `new_model`: the renderer takes the frame only
|
||||
/// when its own store is that large (it missed none of the earlier ones).
|
||||
pub base: u32,
|
||||
/// Model vertices registered since the last frame, at the store's end.
|
||||
pub new_model: Vec<SkinVertex>,
|
||||
pub parts: Vec<SkinPart>,
|
||||
pub jobs: Vec<SkinJob>,
|
||||
/// Vertices the jobs write: a whole number of quads.
|
||||
pub vertices: u32,
|
||||
}
|
||||
|
||||
impl SkinFrame {
|
||||
/// Starts a frame's draws; models not yet handed to the renderer stay.
|
||||
pub fn clear_draws(&mut self) {
|
||||
self.parts.clear();
|
||||
self.jobs.clear();
|
||||
self.vertices = 0;
|
||||
}
|
||||
|
||||
/// The renderer took the new models.
|
||||
pub fn handed(&mut self) {
|
||||
self.base += self.new_model.len() as u32;
|
||||
self.new_model.clear();
|
||||
self.reset = false;
|
||||
}
|
||||
|
||||
/// Where the draws stand, to take back a mob's (`truncate`).
|
||||
pub fn mark(&self) -> (usize, usize, u32) {
|
||||
(self.jobs.len(), self.parts.len(), self.vertices)
|
||||
}
|
||||
|
||||
/// Takes back the draws since `mark` (a mob culled after drawing).
|
||||
pub fn truncate(&mut self, (jobs, parts, vertices): (usize, usize, u32)) {
|
||||
self.jobs.truncate(jobs);
|
||||
self.parts.truncate(parts);
|
||||
self.vertices = vertices;
|
||||
}
|
||||
|
||||
/// Whether the renderer has anything to take.
|
||||
pub fn pending(&self) -> bool {
|
||||
self.reset || !self.new_model.is_empty() || !self.jobs.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Jobs per row of workgroups (the dispatch limit is 65535 per dimension).
|
||||
const ROW: u32 = 32_768;
|
||||
|
||||
pub(super) struct EntitySkin {
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
model: Vec<SkinVertex>,
|
||||
model_buffer: Option<wgpu::Buffer>,
|
||||
parts: Grow,
|
||||
jobs: Grow,
|
||||
/// The jobs with `dst` moved to the frame's gap, as uploaded.
|
||||
staged: Vec<SkinJob>,
|
||||
/// The bind group and the buffers it binds (buffers compare by identity).
|
||||
group: Option<(wgpu::BindGroup, [wgpu::Buffer; 4])>,
|
||||
}
|
||||
|
||||
/// A storage buffer that grows to the next power of two.
|
||||
struct Grow {
|
||||
label: &'static str,
|
||||
buffer: Option<wgpu::Buffer>,
|
||||
}
|
||||
|
||||
impl Grow {
|
||||
fn ensure(&mut self, device: &wgpu::Device, bytes: u64) -> bool {
|
||||
let bytes = bytes.max(256).next_power_of_two();
|
||||
if self.buffer.as_ref().is_some_and(|b| b.size() >= bytes) {
|
||||
return false;
|
||||
}
|
||||
self.buffer = Some(device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(self.label),
|
||||
size: bytes,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}));
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl EntitySkin {
|
||||
/// The entity vertex buffer's usage: the jobs write into it.
|
||||
pub(super) fn usage(skinning: bool) -> wgpu::BufferUsages {
|
||||
let usage = wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST;
|
||||
if skinning {
|
||||
usage | wgpu::BufferUsages::STORAGE
|
||||
} else {
|
||||
usage
|
||||
}
|
||||
}
|
||||
|
||||
/// The compute pipeline, or `None` without compute shaders (the CPU path
|
||||
/// then draws every model).
|
||||
pub(super) fn new(device: &wgpu::Device, compute: bool) -> Option<Self> {
|
||||
// HORIZON_NO_ENTITY_SKIN: every model on the CPU path (A/B runs, a driver's bug).
|
||||
if !compute
|
||||
|| device.limits().max_storage_buffers_per_shader_stage < 4
|
||||
|| std::env::var_os("HORIZON_NO_ENTITY_SKIN").is_some()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Entity skinning"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("entity_skin.wgsl").into()),
|
||||
});
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("Entity skinning"),
|
||||
layout: None,
|
||||
module: &module,
|
||||
entry_point: Some("skin"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let layout = pipeline.get_bind_group_layout(0);
|
||||
let internal = pollster::block_on(internal.pop());
|
||||
if let Some(e) = internal.or(pollster::block_on(validation.pop())) {
|
||||
log::warn!("Horizon renderer: entity models posed on the CPU ({e})");
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
pipeline,
|
||||
layout,
|
||||
model: Vec::new(),
|
||||
model_buffer: None,
|
||||
parts: Grow {
|
||||
label: "Entity skinning parts",
|
||||
buffer: None,
|
||||
},
|
||||
jobs: Grow {
|
||||
label: "Entity skinning jobs",
|
||||
buffer: None,
|
||||
},
|
||||
staged: Vec::new(),
|
||||
group: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The model vertices stored (`SkinFrame::base` of the next frame).
|
||||
pub(super) fn stored(&self) -> u32 {
|
||||
self.model.len() as u32
|
||||
}
|
||||
|
||||
/// Takes the frame's new models; whether its jobs can run (the store
|
||||
/// holds every model they read).
|
||||
pub(super) fn register(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
uploads: &mut Uploads,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
frame: &SkinFrame,
|
||||
) -> bool {
|
||||
if frame.reset {
|
||||
self.model.clear();
|
||||
self.model_buffer = None;
|
||||
}
|
||||
if self.model.len() as u32 != frame.base {
|
||||
// Out of step with the game's store: it restarts it.
|
||||
return false;
|
||||
}
|
||||
if frame.new_model.is_empty() {
|
||||
return self.model_buffer.is_some() || frame.jobs.is_empty();
|
||||
}
|
||||
let old = self.model.len();
|
||||
self.model.extend_from_slice(&frame.new_model);
|
||||
let bytes = (self.model.len() * std::mem::size_of::<SkinVertex>()) as u64;
|
||||
if self
|
||||
.model_buffer
|
||||
.as_ref()
|
||||
.is_some_and(|b| b.size() >= bytes)
|
||||
{
|
||||
let buffer = self.model_buffer.as_ref().expect("checked");
|
||||
uploads.buffer(
|
||||
encoder,
|
||||
buffer,
|
||||
(old * std::mem::size_of::<SkinVertex>()) as u64,
|
||||
bytemuck::cast_slice(&frame.new_model),
|
||||
);
|
||||
} else {
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Entity skinning models"),
|
||||
size: bytes.max(256).next_power_of_two(),
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
uploads.buffer(encoder, &buffer, 0, bytemuck::cast_slice(&self.model));
|
||||
self.model_buffer = Some(buffer);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Records the frame's jobs: they write `frame.vertices` vertices into
|
||||
/// `out` from vertex `base` on. Call after the CPU vertices' upload.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn dispatch(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
uploads: &mut Uploads,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
frame: &SkinFrame,
|
||||
out: &wgpu::Buffer,
|
||||
base: u32,
|
||||
) {
|
||||
if frame.jobs.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(model) = &self.model_buffer else {
|
||||
return;
|
||||
};
|
||||
// Whole rows of workgroups past `ROW` jobs: the padding jobs write nothing.
|
||||
let count = frame.jobs.len() as u32;
|
||||
let rows = count.div_ceil(ROW);
|
||||
let padded = if rows > 1 {
|
||||
(rows * ROW) as usize
|
||||
} else {
|
||||
frame.jobs.len()
|
||||
};
|
||||
self.staged.clear();
|
||||
self.staged.extend(frame.jobs.iter().map(|job| SkinJob {
|
||||
dst: job.dst + base,
|
||||
..*job
|
||||
}));
|
||||
self.staged.resize(padded, SkinJob::default());
|
||||
self.parts.ensure(
|
||||
device,
|
||||
(frame.parts.len() * std::mem::size_of::<SkinPart>()) as u64,
|
||||
);
|
||||
self.jobs
|
||||
.ensure(device, (padded * std::mem::size_of::<SkinJob>()) as u64);
|
||||
let (Some(parts), Some(jobs)) = (&self.parts.buffer, &self.jobs.buffer) else {
|
||||
return;
|
||||
};
|
||||
uploads.buffer(encoder, parts, 0, bytemuck::cast_slice(&frame.parts));
|
||||
uploads.buffer(encoder, jobs, 0, bytemuck::cast_slice(&self.staged));
|
||||
let key = [model.clone(), parts.clone(), jobs.clone(), out.clone()];
|
||||
if self.group.as_ref().is_none_or(|(_, k)| *k != key) {
|
||||
let entries: Vec<_> = key
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(binding, buffer)| wgpu::BindGroupEntry {
|
||||
binding: binding as u32,
|
||||
resource: buffer.as_entire_binding(),
|
||||
})
|
||||
.collect();
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Entity skinning"),
|
||||
layout: &self.layout,
|
||||
entries: &entries,
|
||||
});
|
||||
self.group = Some((group, key));
|
||||
}
|
||||
let Some((group, _)) = &self.group else {
|
||||
return;
|
||||
};
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("Entity skinning"),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.dispatch_workgroups(count.min(ROW), rows, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// What `entity_skin.wgsl` writes for `frame`'s jobs, computed on the CPU
|
||||
/// the same way: the reference its tests hold the shader and the game's walk
|
||||
/// (`mobs::skin`) to. `model` is the whole store.
|
||||
pub fn expand(model: &[SkinVertex], frame: &SkinFrame) -> Vec<Vertex> {
|
||||
let mut out = vec![bytemuck::Zeroable::zeroed(); frame.vertices as usize];
|
||||
for job in &frame.jobs {
|
||||
for k in 0..job.count {
|
||||
let v = model[(job.first + k) as usize];
|
||||
let part = &frame.parts[(job.part + v.part) as usize];
|
||||
let m = glam::Mat4::from_cols_array(&part.matrix);
|
||||
let pos = m.transform_point3(glam::Vec3::from_array(v.pos));
|
||||
let c = (v.axis % 3) as usize * 3;
|
||||
let column = glam::Vec3::from_slice(&part.normal[c..c + 3]);
|
||||
let n = if v.axis >= 3 { -column } else { column };
|
||||
let packed = n.to_array().map(|v| (v.clamp(-1.0, 1.0) * 127.0) as i8);
|
||||
let u = if job.scroll == 0.0 {
|
||||
v.uv[0]
|
||||
} else {
|
||||
(v.uv[0] + job.scroll) % 1.0
|
||||
};
|
||||
let r = job.rect;
|
||||
out[(job.dst + k) as usize] = Vertex {
|
||||
pos: pos.to_array(),
|
||||
uv: [r[0] + (r[2] - r[0]) * u, r[1] + (r[3] - r[1]) * v.uv[1]],
|
||||
layer: job.overlay,
|
||||
color: job.color,
|
||||
light: job.light,
|
||||
normal: [packed[0], packed[1], packed[2], -127],
|
||||
glint_uv: if job.glint != 0 { v.uv } else { [0.0, 0.0] },
|
||||
};
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
/// A cube's 24 vertices for part `part`, its faces on every axis.
|
||||
fn cube(part: u32) -> Vec<SkinVertex> {
|
||||
let mut out = Vec::new();
|
||||
for axis in 0..6u32 {
|
||||
for corner in 0..4u32 {
|
||||
let a = (axis % 3) as usize;
|
||||
let sign = if axis >= 3 { -0.25 } else { 0.25 };
|
||||
let mut pos = [0.0f32; 3];
|
||||
pos[a] = sign;
|
||||
pos[(a + 1) % 3] = if corner & 1 == 0 { -0.25 } else { 0.25 } + 0.1 * part as f32;
|
||||
pos[(a + 2) % 3] = if corner & 2 == 0 { -0.3 } else { 0.3 };
|
||||
out.push(SkinVertex {
|
||||
pos,
|
||||
uv: [corner as f32 / 3.0, 0.125 * axis as f32 + 0.0625],
|
||||
axis,
|
||||
part,
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn part(m: glam::Mat4, normal: glam::Mat3) -> SkinPart {
|
||||
SkinPart {
|
||||
matrix: m.to_cols_array(),
|
||||
normal: normal.to_cols_array(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Two draws of a two-part model with turned, scaled parts, a scrolled
|
||||
/// texture and the armour glint: the shader writes what `expand` does,
|
||||
/// to the bit, after the vertices before them.
|
||||
#[test]
|
||||
fn the_shader_writes_what_the_cpu_reference_computes() {
|
||||
let Some((device, queue)) = device() else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
let Some(mut skin) = EntitySkin::new(&device, true) else {
|
||||
eprintln!("no compute shaders: skipped");
|
||||
return;
|
||||
};
|
||||
let model: Vec<SkinVertex> = cube(0).into_iter().chain(cube(1)).collect();
|
||||
let turn =
|
||||
glam::Mat4::from_quat(glam::Quat::from_euler(glam::EulerRot::ZYX, 0.3, -1.1, 2.0));
|
||||
let base = glam::Mat4::from_translation(glam::Vec3::new(-3.7, 1.25, 9.1)) * turn;
|
||||
let child = base
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.2, -0.4, 0.05))
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(1.0, 2.5, 0.5));
|
||||
let normals = glam::Mat3::from_mat4(child).inverse().transpose();
|
||||
let normals = glam::Mat3::from_cols(
|
||||
normals.x_axis.normalize_or_zero(),
|
||||
normals.y_axis.normalize_or_zero(),
|
||||
normals.z_axis.normalize_or_zero(),
|
||||
);
|
||||
let frame = SkinFrame {
|
||||
reset: false,
|
||||
base: 0,
|
||||
new_model: model.clone(),
|
||||
parts: vec![
|
||||
part(base, glam::Mat3::from_mat4(turn)),
|
||||
part(child, normals),
|
||||
part(base * turn, glam::Mat3::from_mat4(turn * turn)),
|
||||
part(child * turn, glam::Mat3::IDENTITY),
|
||||
],
|
||||
jobs: vec![
|
||||
SkinJob {
|
||||
first: 0,
|
||||
count: 48,
|
||||
part: 0,
|
||||
dst: 0,
|
||||
rect: [0.25, 0.5, 0.375, 0.5625],
|
||||
scroll: 0.0,
|
||||
color: 0xff80_40ff,
|
||||
light: 0xf0_70,
|
||||
overlay: 1,
|
||||
..Default::default()
|
||||
},
|
||||
SkinJob {
|
||||
first: 24,
|
||||
count: 24,
|
||||
part: 2,
|
||||
dst: 48,
|
||||
rect: [0.0, 0.0, 0.0625, 0.0625],
|
||||
scroll: 0.73,
|
||||
color: u32::MAX,
|
||||
light: 0xf0f0,
|
||||
overlay: 36,
|
||||
glint: 1,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
vertices: 72,
|
||||
};
|
||||
let expected = expand(&model, &frame);
|
||||
// The posed vertices land after 5 others, which they leave alone.
|
||||
let before = 5u32;
|
||||
let out = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(before + frame.vertices) * 44,
|
||||
usage: EntitySkin::usage(true) | wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let sentinel = vec![0xabu8; before as usize * 44];
|
||||
queue.write_buffer(&out, 0, &sentinel);
|
||||
let mut uploads = Uploads::new(&device);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
assert!(skin.register(&device, &mut uploads, &mut encoder, &frame));
|
||||
skin.dispatch(&device, &mut uploads, &mut encoder, &frame, &out, before);
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: out.size(),
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_buffer_to_buffer(&out, 0, &read, 0, out.size());
|
||||
uploads.finish(&encoder);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let data = read.slice(..).get_mapped_range().unwrap();
|
||||
assert_eq!(&data[..sentinel.len()], &sentinel[..]);
|
||||
let got: &[Vertex] = bytemuck::cast_slice(&data[sentinel.len()..]);
|
||||
for (i, (got, want)) in got.iter().zip(&expected).enumerate() {
|
||||
assert_eq!(
|
||||
bytemuck::bytes_of(got),
|
||||
bytemuck::bytes_of(want),
|
||||
"vertex {i}: {got:?} != {want:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Entity models posed on the GPU (gfx/entity_skin.rs): each job is one draw of
|
||||
// a model (`mobs::render::draw`), and writes the same vertices the CPU path
|
||||
// would push, in the same order, into the entity vertex buffer. One workgroup
|
||||
// per job; its invocations walk the model's vertices.
|
||||
//
|
||||
// Words, not structs: every record is read and written as u32 words, so the
|
||||
// layouts are the Rust ones byte for byte.
|
||||
|
||||
// A model vertex (7 words): position / 16, model UV, the face's normal as an
|
||||
// axis (0..2 = +x, +y, +z; 3..5 = -x, -y, -z), the part's index in the model.
|
||||
const MODEL_WORDS: u32 = 7u;
|
||||
// A posed part (25 words): the geometry matrix (16, by columns), then the
|
||||
// normal matrix (9, by columns), its columns already normalized when the
|
||||
// part's scale is not uniform (`ModelNormals.trusted` false).
|
||||
const PART_WORDS: u32 = 25u;
|
||||
// A job (16 words): first model vertex, vertex count, first part, first output
|
||||
// vertex, the texture rect (4 floats), scroll, colour, light, overlay,
|
||||
// whether the overlay is the armour glint (its model UV in `glint_uv`), 3 spare.
|
||||
const JOB_WORDS: u32 = 16u;
|
||||
// `mesher::Vertex` (11 words): pos (3), uv (2), layer, color, light, normal
|
||||
// (4 x i8), glint_uv (2).
|
||||
const VERTEX_WORDS: u32 = 11u;
|
||||
|
||||
@group(0) @binding(0) var<storage, read> model: array<u32>;
|
||||
@group(0) @binding(1) var<storage, read> parts: array<u32>;
|
||||
@group(0) @binding(2) var<storage, read> jobs: array<u32>;
|
||||
@group(0) @binding(3) var<storage, read_write> out: array<u32>;
|
||||
|
||||
fn f(words: u32) -> f32 {
|
||||
return bitcast<f32>(words);
|
||||
}
|
||||
|
||||
fn part_f(part: u32, i: u32) -> f32 {
|
||||
return f(parts[part * PART_WORDS + i]);
|
||||
}
|
||||
|
||||
// `mobs::render::scroll_u`.
|
||||
fn scroll_u(u: f32, scroll: f32) -> f32 {
|
||||
if scroll == 0.0 {
|
||||
return u;
|
||||
}
|
||||
// Rust's `%` on floats: the remainder with the dividend's sign.
|
||||
let s = u + scroll;
|
||||
return s - trunc(s);
|
||||
}
|
||||
|
||||
// `Polygon::vertex_normal`'s packing: clamped, times 127, cut toward zero.
|
||||
fn pack(n: vec3<f32>) -> u32 {
|
||||
let b = vec3<i32>(clamp(n, vec3(-1.0), vec3(1.0)) * 127.0);
|
||||
return (u32(b.x) & 0xffu) | ((u32(b.y) & 0xffu) << 8u) | ((u32(b.z) & 0xffu) << 16u)
|
||||
| (u32(-127 & 0xff) << 24u);
|
||||
}
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn skin(
|
||||
@builtin(workgroup_id) group: vec3<u32>,
|
||||
@builtin(num_workgroups) groups: vec3<u32>,
|
||||
@builtin(local_invocation_index) lane: u32,
|
||||
) {
|
||||
let job = group.y * groups.x + group.x;
|
||||
if job >= arrayLength(&jobs) / JOB_WORDS {
|
||||
return;
|
||||
}
|
||||
let j = job * JOB_WORDS;
|
||||
let first = jobs[j];
|
||||
let count = jobs[j + 1u];
|
||||
let first_part = jobs[j + 2u];
|
||||
let dst = jobs[j + 3u];
|
||||
let rect = vec4(f(jobs[j + 4u]), f(jobs[j + 5u]), f(jobs[j + 6u]), f(jobs[j + 7u]));
|
||||
let scroll = f(jobs[j + 8u]);
|
||||
let color = jobs[j + 9u];
|
||||
let light = jobs[j + 10u];
|
||||
let overlay = jobs[j + 11u];
|
||||
let glint = jobs[j + 12u] != 0u;
|
||||
for (var k = lane; k < count; k += 64u) {
|
||||
let v = (first + k) * MODEL_WORDS;
|
||||
let p = vec3(f(model[v]), f(model[v + 1u]), f(model[v + 2u]));
|
||||
let uv = vec2(f(model[v + 3u]), f(model[v + 4u]));
|
||||
let axis = model[v + 5u];
|
||||
let part = first_part + model[v + 6u];
|
||||
// glam's Mat4::transform_point3, in its order.
|
||||
var pos = vec3(part_f(part, 0u), part_f(part, 1u), part_f(part, 2u)) * p.x;
|
||||
pos = vec3(part_f(part, 4u), part_f(part, 5u), part_f(part, 6u)) * p.y + pos;
|
||||
pos = vec3(part_f(part, 8u), part_f(part, 9u), part_f(part, 10u)) * p.z + pos;
|
||||
pos = vec3(part_f(part, 12u), part_f(part, 13u), part_f(part, 14u)) + pos;
|
||||
// An axis normal times the normal matrix is that column, exactly.
|
||||
let c = 16u + (axis % 3u) * 3u;
|
||||
var n = vec3(part_f(part, c), part_f(part, c + 1u), part_f(part, c + 2u));
|
||||
if axis >= 3u {
|
||||
n = -n;
|
||||
}
|
||||
let o = (dst + k) * VERTEX_WORDS;
|
||||
out[o] = bitcast<u32>(pos.x);
|
||||
out[o + 1u] = bitcast<u32>(pos.y);
|
||||
out[o + 2u] = bitcast<u32>(pos.z);
|
||||
out[o + 3u] = bitcast<u32>(rect.x + (rect.z - rect.x) * scroll_u(uv.x, scroll));
|
||||
out[o + 4u] = bitcast<u32>(rect.y + (rect.w - rect.y) * uv.y);
|
||||
out[o + 5u] = overlay;
|
||||
out[o + 6u] = color;
|
||||
out[o + 7u] = light;
|
||||
out[o + 8u] = pack(n);
|
||||
if glint {
|
||||
out[o + 9u] = bitcast<u32>(uv.x);
|
||||
out[o + 10u] = bitcast<u32>(uv.y);
|
||||
} else {
|
||||
out[o + 9u] = 0u;
|
||||
out[o + 10u] = 0u;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -969,6 +969,7 @@ mod initial_history_tests {
|
||||
emissive_layers: &[],
|
||||
shadow_only: &[],
|
||||
entity_spans: &[],
|
||||
skin: None,
|
||||
glowing: &[],
|
||||
dynamic_lights: &[],
|
||||
clouds: None,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(¤t), None], [None; 3]];
|
||||
let lights = light_chunk(®ion, &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) = ¤t.sections[0].light else {
|
||||
panic!("torch light varies");
|
||||
};
|
||||
assert_eq!(
|
||||
result_storage,
|
||||
data.as_ptr(),
|
||||
storage(¤t.sections[0].light),
|
||||
"returned light storage moves without cloning"
|
||||
);
|
||||
current.lit = true;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
//! framebuffer pixels wide, before the screen is drawn over it. Works in the
|
||||
//! frame's own (gamma) values, as vanilla's main target does, and needs a
|
||||
//! swapchain that can be copied from.
|
||||
//!
|
||||
//! On a software adapter (WARP, llvmpipe) the rounds run at half the frame's
|
||||
//! width and height with half the radius: the same blur, a quarter of the
|
||||
//! pixels. At full size they took 250 ms of a CPU per frame at 1440x900, and
|
||||
//! every blurred menu ran at 3 FPS (#5185).
|
||||
|
||||
/// `blur.json`: the passes' directions, ping-ponging between the copied
|
||||
/// frame and a swap texture; the last one writes the frame.
|
||||
@@ -27,15 +32,20 @@ pub struct MenuBlur {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
sampler: wgpu::Sampler,
|
||||
/// One per pass, then the half-size copy's.
|
||||
uniforms: Vec<wgpu::Buffer>,
|
||||
/// The copied frame (`minecraft:main`) and `swap`.
|
||||
/// The copied frame (`minecraft:main`) and `swap`; at half size, then the
|
||||
/// frame copied at full size.
|
||||
targets: Vec<(wgpu::Texture, wgpu::TextureView)>,
|
||||
size: [u32; 2],
|
||||
format: wgpu::TextureFormat,
|
||||
/// Blur at half size (`new`'s `software`).
|
||||
half: bool,
|
||||
}
|
||||
impl MenuBlur {
|
||||
/// `format` is the swapchain's; the blur works in its non-sRGB view.
|
||||
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
|
||||
/// `format` is the swapchain's; the blur works in its non-sRGB view. `software`:
|
||||
/// the adapter renders on the CPU, so the blur runs at half size.
|
||||
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat, software: bool) -> Self {
|
||||
let format = format.remove_srgb_suffix();
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Menu blur"),
|
||||
@@ -111,8 +121,7 @@ impl MenuBlur {
|
||||
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||||
..Default::default()
|
||||
});
|
||||
let uniforms = PASSES
|
||||
.iter()
|
||||
let uniforms = (0..=PASSES.len())
|
||||
.map(|_| {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Menu blur pass"),
|
||||
@@ -130,6 +139,15 @@ impl MenuBlur {
|
||||
targets: Vec::new(),
|
||||
size: [0, 0],
|
||||
format,
|
||||
half: software,
|
||||
}
|
||||
}
|
||||
/// The size the blur's rounds run at for a frame of `size`.
|
||||
fn work_size(&self, size: [u32; 2]) -> [u32; 2] {
|
||||
if self.half {
|
||||
size.map(|s| s.div_ceil(2))
|
||||
} else {
|
||||
size
|
||||
}
|
||||
}
|
||||
fn ensure(&mut self, device: &wgpu::Device, size: [u32; 2]) {
|
||||
@@ -137,8 +155,15 @@ impl MenuBlur {
|
||||
return;
|
||||
}
|
||||
self.size = size;
|
||||
self.targets = (0..2)
|
||||
.map(|_| {
|
||||
let work = self.work_size(size);
|
||||
let sizes = if self.half {
|
||||
vec![work, work, size]
|
||||
} else {
|
||||
vec![work, work]
|
||||
};
|
||||
self.targets = sizes
|
||||
.into_iter()
|
||||
.map(|size| {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Menu blur target"),
|
||||
size: wgpu::Extent3d {
|
||||
@@ -180,65 +205,112 @@ impl MenuBlur {
|
||||
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
|
||||
) {
|
||||
self.ensure(device, size);
|
||||
let work = self.work_size(size);
|
||||
let copy = if self.half { 2 } else { 0 };
|
||||
encoder.copy_texture_to_texture(
|
||||
frame.as_image_copy(),
|
||||
self.targets[0].0.as_image_copy(),
|
||||
self.targets[copy].0.as_image_copy(),
|
||||
wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
let radius = if self.half { radius / 2.0 } else { radius };
|
||||
if self.half {
|
||||
// A radius of 0 samples each half-size pixel's 2x2 block at its shared
|
||||
// corner: their mean.
|
||||
let data = Uniform {
|
||||
size_dir: [size[0] as f32, size[1] as f32, 0.0, 0.0],
|
||||
radius: [0.0; 4],
|
||||
};
|
||||
let (source, output) = (&self.targets[2].1, &self.targets[0].1);
|
||||
self.pass(
|
||||
device,
|
||||
queue,
|
||||
encoder,
|
||||
PASSES.len(),
|
||||
data,
|
||||
source,
|
||||
output,
|
||||
None,
|
||||
);
|
||||
}
|
||||
for (i, dir) in PASSES.iter().enumerate() {
|
||||
let data = Uniform {
|
||||
size_dir: [size[0] as f32, size[1] as f32, dir[0], dir[1]],
|
||||
size_dir: [work[0] as f32, work[1] as f32, dir[0], dir[1]],
|
||||
radius: [radius, 0.0, 0.0, 0.0],
|
||||
};
|
||||
queue.write_buffer(&self.uniforms[i], 0, bytemuck::bytes_of(&data));
|
||||
let source = &self.targets[i % 2].1;
|
||||
// The last pass writes the frame; from half size it samples up as it blurs.
|
||||
let output = if i + 1 == PASSES.len() {
|
||||
target
|
||||
} else {
|
||||
&self.targets[(i + 1) % 2].1
|
||||
};
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Menu blur pass"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(source),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: self.uniforms[i].as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: output,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Menu blur pass"),
|
||||
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("Menu blur")),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
self.pass(
|
||||
device,
|
||||
queue,
|
||||
encoder,
|
||||
i,
|
||||
data,
|
||||
source,
|
||||
output,
|
||||
timing.as_deref_mut(),
|
||||
);
|
||||
}
|
||||
}
|
||||
/// One `box_blur` pass from `source` into `output` with uniform buffer `slot`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn pass(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
slot: usize,
|
||||
data: Uniform,
|
||||
source: &wgpu::TextureView,
|
||||
output: &wgpu::TextureView,
|
||||
timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
|
||||
) {
|
||||
queue.write_buffer(&self.uniforms[slot], 0, bytemuck::bytes_of(&data));
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Menu blur pass"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(source),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: self.uniforms[slot].as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: output,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Menu blur pass"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("Menu blur")),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -318,7 +390,7 @@ mod tests {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let mut blur = MenuBlur::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb);
|
||||
let mut blur = MenuBlur::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, false);
|
||||
let size = [64, 8];
|
||||
let first = blurred(&device, &queue, &mut blur, size);
|
||||
// The stripes are blurred into greys.
|
||||
@@ -327,6 +399,44 @@ mod tests {
|
||||
assert_eq!(blurred(&device, &queue, &mut blur, size), first);
|
||||
}
|
||||
|
||||
/// On a software adapter the blur runs at half size and still blurs the
|
||||
/// frame much as at full size: stripes two pixels wide turn to an even grey.
|
||||
#[test]
|
||||
fn the_half_size_blur_blurs_like_the_full_size_one() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let format = wgpu::TextureFormat::Rgba8UnormSrgb;
|
||||
let size = [64, 8];
|
||||
let full = blurred(
|
||||
&device,
|
||||
&queue,
|
||||
&mut MenuBlur::new(&device, format, false),
|
||||
size,
|
||||
);
|
||||
let half = blurred(
|
||||
&device,
|
||||
&queue,
|
||||
&mut MenuBlur::new(&device, format, true),
|
||||
size,
|
||||
);
|
||||
// Away from the edges, both are within a few levels of the stripes' mean grey.
|
||||
for row in 0..size[1] as usize {
|
||||
for x in 8..56 {
|
||||
let at = (row * size[0] as usize + x) * 4;
|
||||
for (name, frame) in [("full", &full), ("half", &half)] {
|
||||
let v = frame[at];
|
||||
assert!((120..=136).contains(&v), "{name} size: {v} at ({x}, {row})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_blur_shader_validates() {
|
||||
let module = naga::front::wgsl::parse_str(include_str!("menu_blur.wgsl")).unwrap();
|
||||
|
||||
@@ -10,9 +10,9 @@ mod sides;
|
||||
use crate::connected::{Pick, TileLayer};
|
||||
use crate::{
|
||||
assets::{Assets, RenderAssets},
|
||||
biome::Biomes,
|
||||
biome::{Biomes, GrassModifier},
|
||||
blocks::{BlockFacts, RenderShape},
|
||||
chunk::{Chunk, Light, MIN_Y},
|
||||
chunk::{Chunk, MIN_Y},
|
||||
graphics::LeafCullingMode,
|
||||
models::catalog::BakedFace,
|
||||
};
|
||||
@@ -73,6 +73,15 @@ pub struct TerrainVertex {
|
||||
/// RGBA8: tint multiplied by directional shade and ambient occlusion.
|
||||
pub color: u32,
|
||||
}
|
||||
/// `v.round().clamp(0.0, 65535.0) as u16`, without the `roundf` call x86-64's
|
||||
/// baseline instruction set makes of `f32::round`: clamped first (the same
|
||||
/// result), the fraction is exact below 2^23, and every float above is whole.
|
||||
#[inline]
|
||||
fn round_to_u16(v: f32) -> u16 {
|
||||
let v = v.clamp(0.0, f32::from(u16::MAX));
|
||||
let whole = v as u32;
|
||||
(whole + u32::from(v - whole as f32 >= 0.5)) as u16
|
||||
}
|
||||
/// Fixed-point steps per unit of `TerrainVertex` UVs, which span `[0, 2)`.
|
||||
pub const UV_STEPS: f32 = 32768.0;
|
||||
/// Fixed-point steps per block of distant-terrain UVs (`TerrainVertex::tiled`),
|
||||
@@ -82,7 +91,7 @@ impl TerrainVertex {
|
||||
pub fn new(pos: [f32; 3], uv: [f32; 2], layer: u16, color: u32, light: u16) -> Self {
|
||||
Self {
|
||||
pos,
|
||||
uv: uv.map(|v| (v * UV_STEPS).round().clamp(0.0, f32::from(u16::MAX)) as u16),
|
||||
uv: uv.map(|v| round_to_u16(v * UV_STEPS)),
|
||||
layer,
|
||||
light,
|
||||
color,
|
||||
@@ -93,7 +102,7 @@ impl TerrainVertex {
|
||||
pub fn tiled(pos: [f32; 3], uv: [f32; 2], layer: u16, color: u32, light: u16) -> Self {
|
||||
Self {
|
||||
pos,
|
||||
uv: uv.map(|v| (v * TILED_UV_STEPS).round().clamp(0.0, f32::from(u16::MAX)) as u16),
|
||||
uv: uv.map(|v| round_to_u16(v * TILED_UV_STEPS)),
|
||||
layer,
|
||||
light,
|
||||
color,
|
||||
@@ -116,6 +125,30 @@ mod vertex_tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn uv_rounding_matches_f32_round() {
|
||||
let reference = |v: f32| v.round().clamp(0.0, f32::from(u16::MAX)) as u16;
|
||||
let mut values = vec![
|
||||
f32::NAN,
|
||||
f32::INFINITY,
|
||||
f32::NEG_INFINITY,
|
||||
-0.0,
|
||||
0.5,
|
||||
1.5,
|
||||
65534.5,
|
||||
1e9,
|
||||
];
|
||||
for bits in (0..u32::MAX).step_by(9973) {
|
||||
values.push(f32::from_bits(bits));
|
||||
}
|
||||
for i in 0..=140_000 {
|
||||
values.push(i as f32 * 0.5 - 2.0);
|
||||
values.push(i as f32 * 0.5 - 2.0 + f32::EPSILON);
|
||||
}
|
||||
for v in values {
|
||||
assert_eq!(super::round_to_u16(v), reference(v), "{v:e}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn other_uvs_round_within_a_step() {
|
||||
// A flowing fluid's rotated UV.
|
||||
let uv = [0.5 + 0.25 * 0.3f32.sin(), 0.5 - 0.25 * 0.3f32.cos()];
|
||||
@@ -333,94 +366,112 @@ pub const ALL_VISIBLE: u64 = (1 << 36) - 1;
|
||||
/// VisGraph.resolve: flood fill non-opaque cells and connect the faces each region touches.
|
||||
#[cfg(test)]
|
||||
fn visibility(pad: &Padded, info: &[Info]) -> u64 {
|
||||
visibility_reusing(pad, info, &mut Vec::new())
|
||||
let solid: Vec<bool> = info.iter().map(|i| i.solid).collect();
|
||||
let mut rows = Vec::new();
|
||||
solid_rows(pad, &solid, &mut rows);
|
||||
visibility_rows(&open_rows(&rows))
|
||||
}
|
||||
fn visibility_reusing(pad: &Padded, info: &[Info], stack: &mut Vec<usize>) -> u64 {
|
||||
// The all-visible shortcut can leave a partially filled stack behind.
|
||||
stack.clear();
|
||||
// Opaque cells are already visited: resolve each state's opacity once,
|
||||
// then reuse this map for the flood fill and the uniform-section shortcuts.
|
||||
let mut seen = [false; 4096];
|
||||
let mut opaque_count = 0;
|
||||
for y in 0..16 {
|
||||
for z in 0..16 {
|
||||
let row = pi(0, y, z);
|
||||
for x in 0..16 {
|
||||
let opaque = info[pad.blocks[row + x] as usize].solid;
|
||||
seen[(y as usize) << 8 | (z as usize) << 4 | x] = opaque;
|
||||
opaque_count += usize::from(opaque);
|
||||
}
|
||||
}
|
||||
/// 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);
|
||||
!(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
|
||||
/// occluded fill each way, doubling its reach at each step.
|
||||
#[inline]
|
||||
fn fill_row(seeds: u16, open: u16) -> u16 {
|
||||
let (mut up, mut through_up) = (seeds, open);
|
||||
let (mut down, mut through_down) = (seeds, open);
|
||||
for step in [1, 2, 4, 8] {
|
||||
up |= through_up & up << step;
|
||||
through_up &= through_up << step;
|
||||
down |= through_down & down >> step;
|
||||
through_down &= through_down >> step;
|
||||
}
|
||||
if opaque_count == 0 {
|
||||
up | down
|
||||
}
|
||||
/// `visibility` on bit rows, one region at a time: a region grows a row at
|
||||
/// a time (each row filled along its open runs at once) by sweeps over the
|
||||
/// rows it can reach, until a sweep adds nothing.
|
||||
fn visibility_rows(open: &[u16; 256]) -> u64 {
|
||||
let open_count: u32 = open.iter().map(|r| r.count_ones()).sum();
|
||||
if open_count == 4096 {
|
||||
return ALL_VISIBLE;
|
||||
}
|
||||
if opaque_count == 4096 {
|
||||
if open_count == 0 {
|
||||
return 0;
|
||||
}
|
||||
let mut unvisited = *open;
|
||||
let mut region = [0u16; 256];
|
||||
let mut result = 0u64;
|
||||
for start in 0..4096usize {
|
||||
if seen[start] {
|
||||
continue;
|
||||
}
|
||||
let mut faces = 0u8;
|
||||
seen[start] = true;
|
||||
stack.push(start);
|
||||
while let Some(c) = stack.pop() {
|
||||
let (x, y, z) = ((c & 15) as i32, (c >> 8) as i32, ((c >> 4) & 15) as i32);
|
||||
if y == 0 {
|
||||
faces |= 1 << 0;
|
||||
for start in 0..256 {
|
||||
while unvisited[start] != 0 {
|
||||
let seed = unvisited[start] & unvisited[start].wrapping_neg();
|
||||
region[start] = fill_row(seed, unvisited[start]);
|
||||
let (mut lo, mut hi) = (start, start);
|
||||
loop {
|
||||
let mut grown = false;
|
||||
let (first, last) = (lo.saturating_sub(16), (hi + 16).min(255));
|
||||
for r in (first..=last).chain((first..=last).rev()) {
|
||||
let mut near = region[r];
|
||||
if r >= 16 {
|
||||
near |= region[r - 16];
|
||||
}
|
||||
if r + 16 < 256 {
|
||||
near |= region[r + 16];
|
||||
}
|
||||
if r & 15 != 0 {
|
||||
near |= region[r - 1];
|
||||
}
|
||||
if r & 15 != 15 {
|
||||
near |= region[r + 1];
|
||||
}
|
||||
let reached = near & unvisited[r];
|
||||
if reached & !region[r] != 0 {
|
||||
region[r] = fill_row(reached, unvisited[r]);
|
||||
(lo, hi) = (lo.min(r), hi.max(r));
|
||||
grown = true;
|
||||
}
|
||||
}
|
||||
if !grown {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if y == 15 {
|
||||
faces |= 1 << 1;
|
||||
}
|
||||
if z == 0 {
|
||||
faces |= 1 << 2;
|
||||
}
|
||||
if z == 15 {
|
||||
faces |= 1 << 3;
|
||||
}
|
||||
if x == 0 {
|
||||
faces |= 1 << 4;
|
||||
}
|
||||
if x == 15 {
|
||||
faces |= 1 << 5;
|
||||
let mut faces = 0u8;
|
||||
for r in lo..=hi {
|
||||
let row = std::mem::take(&mut region[r]);
|
||||
if row == 0 {
|
||||
continue;
|
||||
}
|
||||
unvisited[r] &= !row;
|
||||
let (y, z) = (r >> 4, r & 15);
|
||||
faces |= u8::from(y == 0)
|
||||
| u8::from(y == 15) << 1
|
||||
| u8::from(z == 0) << 2
|
||||
| u8::from(z == 15) << 3
|
||||
| u8::from(row & 1 != 0) << 4
|
||||
| u8::from(row & 0x8000 != 0) << 5;
|
||||
}
|
||||
// A single connected region touching all six faces sets every
|
||||
// pair (including the diagonal); unvisited cavities cannot add more.
|
||||
if faces == 0b11_1111 {
|
||||
return ALL_VISIBLE;
|
||||
}
|
||||
for (dx, dy, dz) in [
|
||||
(1, 0, 0),
|
||||
(-1, 0, 0),
|
||||
(0, 1, 0),
|
||||
(0, -1, 0),
|
||||
(0, 0, 1),
|
||||
(0, 0, -1),
|
||||
] {
|
||||
let (nx, ny, nz) = (x + dx, y + dy, z + dz);
|
||||
if !(0..16).contains(&nx) || !(0..16).contains(&ny) || !(0..16).contains(&nz) {
|
||||
continue;
|
||||
}
|
||||
let n = (ny << 8 | nz << 4 | nx) as usize;
|
||||
if !seen[n] {
|
||||
seen[n] = true;
|
||||
stack.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
for a in 0..6 {
|
||||
if faces & (1 << a) != 0 {
|
||||
for b in 0..6 {
|
||||
if faces & (1 << b) != 0 {
|
||||
result |= 1 << (a * 6 + b);
|
||||
for a in 0..6 {
|
||||
if faces & (1 << a) != 0 {
|
||||
for b in 0..6 {
|
||||
if faces & (1 << b) != 0 {
|
||||
result |= 1 << (a * 6 + b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if result == ALL_VISIBLE {
|
||||
return result;
|
||||
if result == ALL_VISIBLE {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
@@ -509,6 +560,9 @@ pub struct MeshTables {
|
||||
/// reads 8,000 states, and a few bytes each stay in cache where `Info`
|
||||
/// does not.
|
||||
solid: Vec<bool>,
|
||||
/// By state, bit `d` set where its occlusion face toward direction `d` is the
|
||||
/// full block (`Info::face` 1): a face looking at it never shows.
|
||||
full_faces: Vec<u8>,
|
||||
pub visibility: FaceVisibility,
|
||||
water_still: u16,
|
||||
water_flow: u16,
|
||||
@@ -678,6 +732,10 @@ impl MeshTables {
|
||||
}
|
||||
Ok(Self {
|
||||
solid: info.iter().map(|i| i.solid).collect(),
|
||||
full_faces: info
|
||||
.iter()
|
||||
.map(|i| (0..6).fold(0, |bits, d| bits | u8::from(i.face[d] == 1) << d))
|
||||
.collect(),
|
||||
info,
|
||||
visibility,
|
||||
water_still: layer("minecraft:block/water_still")?,
|
||||
@@ -695,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 {
|
||||
@@ -851,68 +913,96 @@ impl Padded {
|
||||
let (blocks, light) = (&mut self.blocks, &mut self.light);
|
||||
let base_y = region[1][1].map_or(MIN_Y, Chunk::min_y) + sy * 16;
|
||||
// The section itself is unpacked in one pass and copied row by row;
|
||||
// the border cells of the neighbouring sections are read one by one,
|
||||
// each run of them from a section found once.
|
||||
// the border cells of the neighbouring sections are read in runs, from
|
||||
// sections found once per layer.
|
||||
own.fill(0);
|
||||
let center = region[1][1].map(|c| &c.sections[sy as usize]);
|
||||
if let Some(section) = center {
|
||||
section.unpack(own);
|
||||
let center = region[1][1];
|
||||
if let Some(c) = center {
|
||||
c.sections[sy as usize].unpack(own);
|
||||
}
|
||||
// The x runs of a padded row by the chunk column they fall in.
|
||||
const RUNS: [(usize, i32, i32); 3] = [(0, -B, 0), (1, 0, 16), (2, 16, 16 + B)];
|
||||
for y in -B..16 + B {
|
||||
let wy = base_y + y;
|
||||
// Where each chunk column's cells of this layer come from, found once.
|
||||
let sources: [[Source; 3]; 3] = std::array::from_fn(|rz| {
|
||||
std::array::from_fn(|rx| match region[rz][rx] {
|
||||
None => Source::Missing,
|
||||
Some(c) => match c.section_at(wy) {
|
||||
Ok(section) => Source::Section(section),
|
||||
Err(light) => Source::Outside(light),
|
||||
},
|
||||
})
|
||||
});
|
||||
for z in -B..16 + B {
|
||||
let row = pi(-B, y, z);
|
||||
let rz = ((z + 16) >> 4) as usize;
|
||||
for (rx, x0, x1) in RUNS {
|
||||
let (start, end) = (row + (x0 + B) as usize, row + (x1 + B) as usize);
|
||||
let (blocks, light) = (&mut blocks[start..end], &mut light[start..end]);
|
||||
let Some(c) = region[rz][rx] else {
|
||||
match region[1][1] {
|
||||
// A neighbour not loaded continues this chunk's edge
|
||||
// column. As lit air, the outermost chunks' water drew
|
||||
// a wall toward it (fluids show both sides), a light
|
||||
// sheet in the water where far terrain carries on.
|
||||
// Vanilla draws no chunk without its neighbours.
|
||||
Some(c) => {
|
||||
let ez = z.clamp(0, 15);
|
||||
for (k, x) in (x0..x1).enumerate() {
|
||||
let ex = x.clamp(0, 15);
|
||||
blocks[k] = c.get(ex, wy, ez);
|
||||
light[k] = c.light(ex, wy, ez);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
blocks.fill(0);
|
||||
light.fill(0xF0);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
};
|
||||
// `Chunk::get` and `Chunk::light` with the section found once.
|
||||
let s = (wy - c.min_y()) >> 4;
|
||||
let Some(section) = c.sections.get(s as usize).filter(|_| s >= 0) else {
|
||||
blocks.fill(0);
|
||||
light.fill(if s < 0 { 0 } else { 0xF0 });
|
||||
continue;
|
||||
};
|
||||
let at = crate::chunk::index(x0, wy, z);
|
||||
if rx == 1 && rz == 1 && (0..16).contains(&y) {
|
||||
let own_row = (y << 8 | z << 4) as usize;
|
||||
blocks.copy_from_slice(&own[own_row..own_row + 16]);
|
||||
} else {
|
||||
section.blocks.get_run(at, blocks);
|
||||
}
|
||||
match §ion.light {
|
||||
Light::Uniform(v) => light.fill(*v),
|
||||
Light::Full(all) => light.copy_from_slice(&all[at..at + light.len()]),
|
||||
}
|
||||
}
|
||||
let (west, rest) = blocks[row..row + P].split_at_mut(B as usize);
|
||||
let (middle, east) = rest.split_at_mut(16);
|
||||
let (west_light, rest) = light[row..row + P].split_at_mut(B as usize);
|
||||
let (middle_light, east_light) = rest.split_at_mut(16);
|
||||
let own_row = (rz == 1 && (0..16).contains(&y))
|
||||
.then(|| &own[(y << 8 | z << 4) as usize..][..16]);
|
||||
let at = |x: i32| crate::chunk::index(x, wy, z);
|
||||
let [w, m, e] = &sources[rz];
|
||||
let edge = (center, wy, z);
|
||||
fill_run(west, west_light, w, None, at(-B), -B, edge);
|
||||
fill_run(middle, middle_light, m, own_row, at(0), 0, edge);
|
||||
fill_run(east, east_light, e, None, at(16), 16, edge);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Where the cells of one chunk column at one height come from (`Padded::fill`).
|
||||
enum Source<'a> {
|
||||
/// A neighbour not loaded.
|
||||
Missing,
|
||||
/// Above or below the column's sections: air with this light.
|
||||
Outside(u8),
|
||||
Section(&'a crate::chunk::Section),
|
||||
}
|
||||
/// Fills one run of a padded row, `blocks.len()` cells from x = `x0`, starting
|
||||
/// at section index `at`; `own` is the centre section's row, already unpacked.
|
||||
#[inline(always)]
|
||||
fn fill_run(
|
||||
blocks: &mut [u32],
|
||||
light: &mut [u8],
|
||||
source: &Source,
|
||||
own: Option<&[u32]>,
|
||||
at: usize,
|
||||
x0: i32,
|
||||
(center, wy, z): (Option<&Chunk>, i32, i32),
|
||||
) {
|
||||
match source {
|
||||
Source::Section(section) => {
|
||||
match own {
|
||||
Some(own) => blocks.copy_from_slice(own),
|
||||
None => section.blocks.get_run(at, blocks),
|
||||
}
|
||||
section.light.copy_run(at, light);
|
||||
}
|
||||
Source::Outside(v) => {
|
||||
blocks.fill(0);
|
||||
light.fill(*v);
|
||||
}
|
||||
// A neighbour not loaded continues this chunk's edge column. As lit
|
||||
// air, the outermost chunks' water drew a wall toward it (fluids show
|
||||
// both sides), a light sheet in the water where far terrain carries
|
||||
// on. Vanilla draws no chunk without its neighbours.
|
||||
Source::Missing => match center {
|
||||
Some(c) => {
|
||||
let ez = z.clamp(0, 15);
|
||||
for (k, (block, light)) in blocks.iter_mut().zip(light.iter_mut()).enumerate() {
|
||||
let ex = (x0 + k as i32).clamp(0, 15);
|
||||
*block = c.get(ex, wy, ez);
|
||||
*light = c.light(ex, wy, ez);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
blocks.fill(0);
|
||||
light.fill(0xF0);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
/// Per-worker temporary arrays. They never hold resource-pack references or
|
||||
/// geometry still owned by the renderer, and total about 150 KiB per worker
|
||||
/// (the leaf mask adds 8,000 cells, depths and a queue).
|
||||
@@ -920,14 +1010,13 @@ impl Padded {
|
||||
pub struct MeshScratch {
|
||||
padded: Padded,
|
||||
own: Option<Box<[u32; 4096]>>,
|
||||
visibility: Vec<usize>,
|
||||
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)| {
|
||||
@@ -949,6 +1038,8 @@ fn hidden(rows: &[u32], x: i32, y: i32, z: i32) -> bool {
|
||||
mod leaf_culling_tests;
|
||||
#[cfg(test)]
|
||||
mod reuse_tests;
|
||||
#[cfg(test)]
|
||||
mod tint_tests;
|
||||
/// Set on a terrain vertex's sprite layer when its texture alpha must be ignored.
|
||||
pub const OPAQUE_LAYER: u16 = 1 << 15;
|
||||
/// The bits of `TerrainVertex::color`'s alpha byte that hold how brightly its face shines
|
||||
@@ -1061,6 +1152,8 @@ struct Tints<'a> {
|
||||
/// Chunk x, chunk z and the section's lowest block y.
|
||||
origin: [i32; 3],
|
||||
layers: [Option<Box<Layer>>; 16],
|
||||
/// `uniform_biome`, once computed.
|
||||
uniform: Option<Option<u16>>,
|
||||
}
|
||||
impl<'a> Tints<'a> {
|
||||
fn new(
|
||||
@@ -1078,6 +1171,7 @@ impl<'a> Tints<'a> {
|
||||
plains: biomes.id("minecraft:plains"),
|
||||
origin,
|
||||
layers: Default::default(),
|
||||
uniform: None,
|
||||
}
|
||||
}
|
||||
/// The biome of block `(x, y, z)` (section-local x/z, y in 0..16), as `BiomeManager.getBiome`
|
||||
@@ -1116,8 +1210,52 @@ impl<'a> Tints<'a> {
|
||||
plains
|
||||
}
|
||||
}
|
||||
/// The biome every block of the section's blend squares zooms to, when they
|
||||
/// all do: `quart_at_block` picks one of the eight quart cells at its base
|
||||
/// cell plus 0 or 1 on each axis, so if every such cell the section reaches
|
||||
/// holds one biome, every zoomed cell is that biome.
|
||||
fn uniform_biome(&mut self) -> Option<u16> {
|
||||
if let Some(uniform) = self.uniform {
|
||||
return uniform;
|
||||
}
|
||||
let (r, [cx, cz, base_y]) = (self.radius, self.origin);
|
||||
// The base cells of blocks lo..=hi, and the cell past the last.
|
||||
let span = |lo: i32, hi: i32| (lo.wrapping_sub(2) >> 2)..=(hi.wrapping_sub(2) >> 2) + 1;
|
||||
let (xs, ys, zs) = (
|
||||
span(cx * 16 - r, cx * 16 + 15 + r),
|
||||
span(base_y, base_y + 15),
|
||||
span(cz * 16 - r, cz * 16 + 15 + r),
|
||||
);
|
||||
let first = Self::quart_biome(
|
||||
self.region,
|
||||
self.plains,
|
||||
[cx, cz],
|
||||
[*xs.start(), *ys.start(), *zs.start()],
|
||||
);
|
||||
let uniform = ys
|
||||
.flat_map(|y| zs.clone().map(move |z| (y, z)))
|
||||
.flat_map(|(y, z)| xs.clone().map(move |x| [x, y, z]))
|
||||
.all(|q| Self::quart_biome(self.region, self.plains, [cx, cz], q) == first)
|
||||
.then_some(first);
|
||||
self.uniform = Some(uniform);
|
||||
uniform
|
||||
}
|
||||
/// The blended tint of block `(x, y, z)` (section-local x/z, y in 0..16).
|
||||
fn get(&mut self, resolver: Resolver, x: i32, y: i32, z: i32) -> u32 {
|
||||
// One biome over every blend square: the mean of (2r+1)^2 equal
|
||||
// colours is that colour, unless it varies by position (swamp grass).
|
||||
if let Some(biome) = self.uniform_biome() {
|
||||
let info = self.biomes.get(biome);
|
||||
match resolver {
|
||||
Resolver::Grass if !matches!(info.modifier, GrassModifier::Swamp) => {
|
||||
return self.biomes.grass(biome, 0.0, 0.0) & 0xFF_FFFF;
|
||||
}
|
||||
Resolver::Grass => {}
|
||||
Resolver::Foliage => return info.foliage & 0xFF_FFFF,
|
||||
Resolver::Dry => return info.dry_foliage & 0xFF_FFFF,
|
||||
Resolver::Water => return info.water & 0xFF_FFFF,
|
||||
}
|
||||
}
|
||||
let r = self.radius;
|
||||
let dim = (16 + 2 * r) as usize;
|
||||
let layer = self.layers[y as usize].get_or_insert_with(|| {
|
||||
@@ -1255,9 +1393,9 @@ impl Mesher<'_> {
|
||||
let own = scratch.own.get_or_insert_with(|| Box::new([0; 4096]));
|
||||
scratch.padded.fill(region, sy, own);
|
||||
let pad = &scratch.padded;
|
||||
out.visibility = visibility_reusing(pad, &self.tables.info, &mut scratch.visibility);
|
||||
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() {
|
||||
scratch
|
||||
@@ -1272,6 +1410,7 @@ impl Mesher<'_> {
|
||||
[center.x, center.z, base_y],
|
||||
);
|
||||
let info = &self.tables.info;
|
||||
let full_faces = &self.tables.full_faces;
|
||||
let catalog = &self.render.model_catalog;
|
||||
let mut models = Vec::new();
|
||||
for y in 0..16 {
|
||||
@@ -1329,6 +1468,11 @@ impl Mesher<'_> {
|
||||
for d in Direction::ALL {
|
||||
let [dx, dy, dz] = d.normal();
|
||||
let neighbor = pad.blocks[pi(x + dx, y + dy, z + dz)];
|
||||
// `should_render_face`'s first test, without its lookups: a
|
||||
// full occlusion face toward this block hides the face.
|
||||
if full_faces[neighbor as usize] >> d.opposite() as u8 & 1 != 0 {
|
||||
continue;
|
||||
}
|
||||
let other = &info[neighbor as usize];
|
||||
// LeavesBlock.skipRendering with opaque leaves.
|
||||
let opaque_leaves = !self.cutout_leaves && state.leaves && other.leaves;
|
||||
|
||||
@@ -83,9 +83,17 @@ fn weighted_blend(c: [u32; 4], w: [f32; 4]) -> u32 {
|
||||
};
|
||||
channel(0) | channel(16) << 16
|
||||
}
|
||||
/// `ARGB.gray` channel: `Mth.floor(brightness * 255)`.
|
||||
/// `ARGB.gray` channel: `Mth.floor(brightness * 255)`. Without the `floorf`
|
||||
/// call x86-64's baseline instruction set makes of `f32::floor`: truncation,
|
||||
/// one lower where that rounded a negative value up (saturating as `as` does).
|
||||
pub(super) fn gray(brightness: f32) -> i32 {
|
||||
(brightness * 255.0).floor() as i32
|
||||
let v = brightness * 255.0;
|
||||
let whole = v as i32;
|
||||
if whole as f32 > v {
|
||||
whole.saturating_sub(1)
|
||||
} else {
|
||||
whole
|
||||
}
|
||||
}
|
||||
/// One channel of `ARGB.scaleRGB(color, float)`.
|
||||
pub(super) fn scale(channel: i32, by: f32) -> i32 {
|
||||
@@ -406,6 +414,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn gray_floors_like_f32_floor() {
|
||||
let mut values = vec![
|
||||
f32::NAN,
|
||||
f32::INFINITY,
|
||||
f32::NEG_INFINITY,
|
||||
-0.0,
|
||||
1.0,
|
||||
-1.0,
|
||||
1e10,
|
||||
-1e10,
|
||||
];
|
||||
for bits in (0..u32::MAX).step_by(7919) {
|
||||
values.push(f32::from_bits(bits));
|
||||
}
|
||||
for i in -3000..3000 {
|
||||
values.push(i as f32 / 1024.0);
|
||||
}
|
||||
for v in values {
|
||||
assert_eq!(gray(v), (v * 255.0).floor() as i32, "{v:e}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn colour_arithmetic_matches_argb_helpers() {
|
||||
assert_eq!(gray(1.0), 255);
|
||||
assert_eq!(gray(0.8), 204);
|
||||
|
||||
@@ -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(®ion, 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.visibility.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>,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
//! The one-biome shortcut of `Tints::get` against the full blend. Needs the
|
||||
//! prepared biome data, like the other mesher tests that load resources.
|
||||
use super::*;
|
||||
|
||||
fn biomes() -> Biomes {
|
||||
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3")
|
||||
});
|
||||
Biomes::load(&root).unwrap()
|
||||
}
|
||||
|
||||
/// A chunk whose every quart cell holds `biome`.
|
||||
fn filled(cx: i32, cz: i32, biome: u16) -> Chunk {
|
||||
let mut chunk = Chunk::empty(cx, cz);
|
||||
for qy in 0..(384 / 4) {
|
||||
for qz in 0..4 {
|
||||
for qx in 0..4 {
|
||||
chunk.set_biome_cell(qx, qy, qz, biome);
|
||||
}
|
||||
}
|
||||
}
|
||||
chunk
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn one_biome_shortcut_blends_like_the_full_square() {
|
||||
let biomes = biomes();
|
||||
let desert = biomes.id("minecraft:desert");
|
||||
let (cx, cz) = (3, -2);
|
||||
// Grass without and with the dark forest modifier, swamp noise, and
|
||||
// biomes whose foliage, dry foliage and water differ from the defaults.
|
||||
for name in [
|
||||
"minecraft:plains",
|
||||
"minecraft:dark_forest",
|
||||
"minecraft:swamp",
|
||||
"minecraft:mangrove_swamp",
|
||||
"minecraft:cherry_grove",
|
||||
"minecraft:warm_ocean",
|
||||
"minecraft:badlands",
|
||||
] {
|
||||
let biome = biomes.id(name);
|
||||
let chunks: Vec<Vec<Chunk>> = (0..3)
|
||||
.map(|rz| {
|
||||
(0..3)
|
||||
.map(|rx| filled(cx + rx - 1, cz + rz - 1, biome))
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
// One desert column in the east neighbour, next to the section's edge,
|
||||
// where every blend radius reaches it.
|
||||
let mut east = filled(cx + 1, cz, biome);
|
||||
for qy in 0..(384 / 4) {
|
||||
east.set_biome_cell(0, qy, 2, desert);
|
||||
}
|
||||
let all = |rz: usize, rx: usize| Some(&chunks[rz][rx]);
|
||||
let uniform: [[Option<&Chunk>; 3]; 3] =
|
||||
std::array::from_fn(|rz| std::array::from_fn(|rx| all(rz, rx)));
|
||||
let mut mixed = uniform;
|
||||
mixed[1][2] = Some(&east);
|
||||
// A missing neighbour reads as plains.
|
||||
let mut missing = uniform;
|
||||
missing[0][0] = None;
|
||||
for (region, one_biome) in [
|
||||
(&uniform, true),
|
||||
(&mixed, false),
|
||||
(&missing, name == "minecraft:plains"),
|
||||
] {
|
||||
for base_y in [MIN_Y, 0, 304] {
|
||||
for (radius, seed) in [(0, 0), (1, 42), (2, -7), (5, 1234), (7, i64::MAX)] {
|
||||
let new = || {
|
||||
Tints::new(
|
||||
region,
|
||||
&biomes,
|
||||
BiomeZoom::new(seed),
|
||||
radius,
|
||||
[cx, cz, base_y],
|
||||
)
|
||||
};
|
||||
let mut fast = new();
|
||||
let mut full = new();
|
||||
full.uniform = Some(None);
|
||||
assert_eq!(
|
||||
fast.uniform_biome().is_some(),
|
||||
one_biome,
|
||||
"{name} r{radius} y{base_y}"
|
||||
);
|
||||
for resolver in [
|
||||
Resolver::Grass,
|
||||
Resolver::Foliage,
|
||||
Resolver::Dry,
|
||||
Resolver::Water,
|
||||
] {
|
||||
for y in 0..16 {
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
assert_eq!(
|
||||
fast.get(resolver, x, y, z),
|
||||
full.get(resolver, x, y, z),
|
||||
"{name} resolver {} r{radius} at {x} {y} {z} of y{base_y}",
|
||||
resolver as usize
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,20 +190,74 @@ fn visibility_matches_reference() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_scratch_reuse_keeps_shortcuts_and_subsequent_fills_exact() {
|
||||
let info = [info(false), info(true), info(false), info(true)];
|
||||
let mut stack = vec![usize::MAX];
|
||||
for _ in 0..3 {
|
||||
for (name, pad) in cases() {
|
||||
assert_eq!(
|
||||
visibility_reusing(&pad, &info, &mut stack),
|
||||
reference(&pad, &info),
|
||||
"{name}"
|
||||
);
|
||||
fn row_fill_matches_a_cell_by_cell_fill() {
|
||||
let naive = |seeds: u16, open: u16| {
|
||||
let mut fill = seeds;
|
||||
loop {
|
||||
let next = (fill | fill << 1 | fill >> 1) & open;
|
||||
if next == fill {
|
||||
return fill;
|
||||
}
|
||||
fill = next;
|
||||
}
|
||||
};
|
||||
let mut state = 0x9E37_79B9u32;
|
||||
for _ in 0..200_000 {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 17;
|
||||
state ^= state << 5;
|
||||
let open = state as u16;
|
||||
let seeds = (state >> 16) as u16 & open;
|
||||
assert_eq!(
|
||||
fill_row(seeds, open),
|
||||
naive(seeds, open),
|
||||
"{open:016b} {seeds:016b}"
|
||||
);
|
||||
}
|
||||
// Several seeds in one run, the case a carry-based fill gets wrong.
|
||||
assert_eq!(fill_row(0b0110, 0b1111), 0b1111);
|
||||
assert_eq!(fill_row(0xC000, 0xF000), 0xF000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_fill_follows_open_runs() {
|
||||
for open in [0u16, 0xFFFF, 0b0111_0110, 0x8001, 0xF0F0, 0x5555] {
|
||||
for bit in 0..16 {
|
||||
let seed = 1u16 << bit;
|
||||
if open & seed == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut expected = seed;
|
||||
for x in (0..bit).rev().take_while(|&x| open & 1 << x != 0) {
|
||||
expected |= 1 << x;
|
||||
}
|
||||
for x in (bit + 1..16).take_while(|&x| open & 1 << x != 0) {
|
||||
expected |= 1 << x;
|
||||
}
|
||||
assert_eq!(fill_row(seed, open), expected, "{open:016b} from {bit}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mazes: one long winding cavity, and many small ones, where the row
|
||||
/// sweeps are slowest.
|
||||
#[test]
|
||||
fn winding_and_scattered_cavities_match_reference() {
|
||||
let info = [info(false), info(true), info(false), info(true)];
|
||||
let serpentine = fixture(|x, y, z| {
|
||||
let lane = z % 4 == 0 && y % 4 == 0;
|
||||
let turn = (x == 0 && z % 8 == 4) || (x == 15 && z % 8 == 0);
|
||||
let climb = x == 0 && z == 0 && y % 4 != 0;
|
||||
!(lane || (turn && y % 4 == 0) || climb)
|
||||
});
|
||||
assert_eq!(
|
||||
visibility(&serpentine, &info),
|
||||
reference(&serpentine, &info)
|
||||
);
|
||||
let pockets = fixture(|x, y, z| !(x % 3 == 1 && y % 3 == 1 && z % 3 == 1));
|
||||
assert_eq!(visibility(&pockets, &info), reference(&pockets, &info));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_preserves_every_face_pair() {
|
||||
let info = [info(false), info(true), info(false), info(true)];
|
||||
|
||||
@@ -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)]
|
||||
@@ -1520,12 +1520,17 @@ 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>,
|
||||
/// A repro recording is running: the AI Agent page's button says stop.
|
||||
pub agent_recording: bool,
|
||||
/// 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).
|
||||
@@ -1538,8 +1543,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 {
|
||||
@@ -1578,15 +1581,16 @@ impl Default for OptionsUi {
|
||||
split_profiles: Default::default(),
|
||||
calibration: None,
|
||||
calibration_fullscreen: None,
|
||||
split_setup: Default::default(),
|
||||
agent_status: Vec::new(),
|
||||
agent_recording: false,
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2104,6 +2108,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;
|
||||
@@ -2114,6 +2119,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("");
|
||||
@@ -2133,6 +2139,9 @@ impl OptionsUi {
|
||||
) {
|
||||
self.graphics.reset();
|
||||
}
|
||||
if page == Page::SplitSetup {
|
||||
self.split_setup.reset();
|
||||
}
|
||||
if page == Page::SplitProfiles {
|
||||
self.split_profiles.reset();
|
||||
}
|
||||
@@ -2273,14 +2282,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.
|
||||
@@ -3381,6 +3383,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);
|
||||
@@ -3681,6 +3688,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 {
|
||||
@@ -3785,6 +3812,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;
|
||||
@@ -3797,6 +3825,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;
|
||||
@@ -3910,7 +3939,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, ..
|
||||
@@ -4086,8 +4118,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()
|
||||
}
|
||||
@@ -4132,15 +4167,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,
|
||||
@@ -4192,7 +4231,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)) => {
|
||||
@@ -4378,6 +4420,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;
|
||||
@@ -4721,6 +4764,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 {
|
||||
@@ -4759,6 +4803,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 {
|
||||
@@ -4772,6 +4817,7 @@ impl OptionsUi {
|
||||
}
|
||||
} else {
|
||||
self.focus = Some(focusables[0].0.clone());
|
||||
self.focus_from_keyboard = true;
|
||||
self.can_change_value = false;
|
||||
}
|
||||
return Vec::new();
|
||||
@@ -4794,6 +4840,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 {
|
||||
@@ -4811,6 +4858,7 @@ impl OptionsUi {
|
||||
}
|
||||
} else {
|
||||
self.focus = Some(focusables[0].0.clone());
|
||||
self.focus_from_keyboard = true;
|
||||
self.can_change_value = false;
|
||||
}
|
||||
return Vec::new();
|
||||
@@ -4834,6 +4882,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);
|
||||
@@ -4842,6 +4891,7 @@ impl OptionsUi {
|
||||
}
|
||||
Act::Done => {
|
||||
self.focus = None;
|
||||
self.focus_from_keyboard = false;
|
||||
self.can_change_value = false;
|
||||
events.extend(self.back(s));
|
||||
}
|
||||
@@ -4881,6 +4931,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));
|
||||
@@ -4905,6 +4956,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);
|
||||
@@ -4922,6 +4974,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;
|
||||
}
|
||||
@@ -7186,6 +7239,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 = [
|
||||
|
||||
@@ -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
@@ -6,34 +6,67 @@ use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use crate::section_mask::SectionMask;
|
||||
use crate::{
|
||||
chunk::Chunk,
|
||||
chunk_map::ChunkMembershipDelta,
|
||||
graphics::LeafCullingMode,
|
||||
light::{LightChanges, LightTables, apply_lighting, apply_relighting, light_chunk},
|
||||
mesher::{MeshScratch, MeshTables, Mesher, Region, SectionMesh, borrow},
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::biome::BiomeZoom;
|
||||
use horizon_threads::jobs::{Class, PerWorker, Rank, Registration, Source};
|
||||
use horizon_world::block_render::FaceVisibility;
|
||||
use horizon_world::worldgen::decoration_order::DecorationOrder;
|
||||
use std::{
|
||||
collections::{BinaryHeap, HashSet, VecDeque},
|
||||
sync::{
|
||||
Arc, Condvar, Mutex, MutexGuard, RwLock, TryLockError,
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
},
|
||||
thread,
|
||||
sync::{Arc, Condvar, Mutex, MutexGuard, RwLock, TryLockError},
|
||||
};
|
||||
|
||||
/// How long a worker waits with nothing to do before it gives back its
|
||||
/// 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
|
||||
/// (biomes, noise, surface, carvers), decoration of a 3x3 neighbourhood
|
||||
/// (FEATURES, which may write into neighbours), then conversion of a chunk
|
||||
/// whose eight neighbours are decorated into a final column. Implementations
|
||||
/// must be thread-safe; each worker thread owns one `Worker`.
|
||||
/// must be thread-safe; each worker of the shared pool
|
||||
/// (`horizon_threads::jobs`) keeps one `Worker` per streamer.
|
||||
pub trait ChunkGenerator: Send + Sync + 'static {
|
||||
type Worker: Send + 'static;
|
||||
fn worker(&self) -> Self::Worker;
|
||||
@@ -276,8 +309,6 @@ impl Ord for Queued {
|
||||
}
|
||||
struct Shared {
|
||||
queue: Mutex<BinaryHeap<Queued>>,
|
||||
ready: Condvar,
|
||||
stop: AtomicBool,
|
||||
/// What meshing reads (block models and sprites), swapped when resource
|
||||
/// packs reload; each mesh job takes the current one.
|
||||
context: RwLock<Arc<WorkerContext>>,
|
||||
@@ -285,11 +316,6 @@ struct Shared {
|
||||
/// Only workers use this pool; frames never wait for its lock. Retaining
|
||||
/// cleared buffers avoids racing the renderer for the process heap.
|
||||
meshes: Mutex<MeshPool>,
|
||||
/// Workers that have freed their buffers ([`IDLE_RELEASE`]) and taken no
|
||||
/// job since: the last of them gives the freed memory back to the OS.
|
||||
released: AtomicUsize,
|
||||
/// The workers that started.
|
||||
workers: AtomicUsize,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct MeshPool {
|
||||
@@ -364,7 +390,11 @@ pub struct Streaming {
|
||||
/// next arrive, though read from the save (`World::loaded_chunks`).
|
||||
dropped_new: FxHashSet<(i32, i32)>,
|
||||
non_air_sections: Arc<dyn Fn(&Proto) -> Option<SectionMask> + Send + Sync>,
|
||||
threads: Vec<thread::JoinHandle<()>>,
|
||||
/// This streamer's place in the shared pool (`horizon_threads::jobs`);
|
||||
/// dropping it waits for its running jobs.
|
||||
jobs: Registration,
|
||||
/// The workers its in-flight work is sized for.
|
||||
workers: usize,
|
||||
order: u64,
|
||||
/// Ticks since the columns and protos that fell out of range were swept.
|
||||
scan_age: u8,
|
||||
@@ -476,6 +506,14 @@ pub struct Streaming {
|
||||
/// Snapshot only after a scan found no missing candidates. Public range fields
|
||||
/// and `World::chunks` can change without going through Streaming, so compare
|
||||
/// their values and structural membership token rather than a frame timeout.
|
||||
///
|
||||
/// A scan also keeps every candidate it found, nearest first (`plan`): while
|
||||
/// the ranges stay the same and no column leaves (`World::chunks` removals, or
|
||||
/// `invalidate` for proto chunks dropped), no column becomes wanted, so the
|
||||
/// next batches are the plan's entries still missing. A full scan looks at
|
||||
/// every column of the areas (thousands at render distance 32), and one ran
|
||||
/// whenever a worker finished: it took a quarter of the thread that runs
|
||||
/// `Streaming::update` while a world loaded.
|
||||
#[derive(Default)]
|
||||
struct GenerationScan {
|
||||
exhausted: bool,
|
||||
@@ -485,17 +523,52 @@ struct GenerationScan {
|
||||
forced: HashSet<(i32, i32)>,
|
||||
band: Option<Band>,
|
||||
chunk_revision: u64,
|
||||
plan: Vec<(i64, i32, i32)>,
|
||||
/// Entries before it are no longer missing.
|
||||
next: usize,
|
||||
/// What `plan` was made for: the ranges, the loading screen and
|
||||
/// decoration; the chunk map's revision is `chunk_revision`.
|
||||
plan_basis: Option<(bool, bool)>,
|
||||
}
|
||||
impl GenerationScan {
|
||||
fn unchanged(&self, ranges: &Ranges<'_>, world: &World) -> bool {
|
||||
self.exhausted
|
||||
&& self.center == ranges.center
|
||||
fn same_ranges(&self, ranges: &Ranges<'_>) -> bool {
|
||||
self.center == ranges.center
|
||||
&& self.radius == ranges.radius
|
||||
&& self.extra == ranges.extra
|
||||
&& self.forced == *ranges.forced
|
||||
&& self.band.as_ref() == ranges.band
|
||||
}
|
||||
fn unchanged(&self, ranges: &Ranges<'_>, world: &World) -> bool {
|
||||
self.exhausted
|
||||
&& self.same_ranges(ranges)
|
||||
&& self.chunk_revision == world.chunks.membership_revision()
|
||||
}
|
||||
/// A column may be wanted again (a proto chunk was dropped): the next
|
||||
/// call scans the areas again.
|
||||
fn invalidate(&mut self) {
|
||||
self.exhausted = false;
|
||||
self.plan_basis = None;
|
||||
}
|
||||
/// Whether `plan` still holds every candidate: same ranges, and no
|
||||
/// column left the chunk map since it was made.
|
||||
fn plan_holds(&mut self, input: &GenerationInputs<'_>) -> bool {
|
||||
if self.plan_basis != Some((input.loading, input.decorates))
|
||||
|| !self.same_ranges(&input.ranges)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let revision = input.world.chunks.membership_revision();
|
||||
if revision != self.chunk_revision {
|
||||
let Some(mut changes) = input.world.chunks.deltas_since(self.chunk_revision) else {
|
||||
return false;
|
||||
};
|
||||
if !changes.all(|(_, change)| matches!(change, ChunkMembershipDelta::Added(_))) {
|
||||
return false;
|
||||
}
|
||||
self.chunk_revision = revision;
|
||||
}
|
||||
true
|
||||
}
|
||||
fn remember(&mut self, ranges: Ranges<'_>, world: &World) {
|
||||
self.exhausted = true;
|
||||
self.center = ranges.center;
|
||||
@@ -533,6 +606,30 @@ impl GenerationScan {
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let missing = |&(_, x, z): &(i64, i32, i32)| {
|
||||
!input.world.chunks.contains_key(&(x, z))
|
||||
&& !input.generating.contains(&(x, z))
|
||||
&& !input.protos.contains_key(&(x, z))
|
||||
&& !input.finishing.contains(&(x, z))
|
||||
};
|
||||
if self.plan_holds(&input) {
|
||||
while self.next < self.plan.len() && !missing(&self.plan[self.next]) {
|
||||
self.next += 1;
|
||||
}
|
||||
let room = capacity - input.generating.len();
|
||||
let batch: Vec<_> = self.plan[self.next..]
|
||||
.iter()
|
||||
.filter(|c| missing(c))
|
||||
.take(room)
|
||||
.copied()
|
||||
.collect();
|
||||
// Past the plan's end, the full scan below decides whether
|
||||
// anything is left (and remembers when nothing is).
|
||||
if !batch.is_empty() {
|
||||
self.exhausted = false;
|
||||
return Some(batch);
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut wanted = Vec::new();
|
||||
// Each area's square of columns, as one run per row. Players'
|
||||
@@ -592,28 +689,217 @@ impl GenerationScan {
|
||||
}
|
||||
}
|
||||
}
|
||||
let ranges = Ranges {
|
||||
center: input.ranges.center,
|
||||
radius: input.ranges.radius,
|
||||
extra: input.ranges.extra,
|
||||
forced: input.ranges.forced,
|
||||
cover: input.ranges.cover,
|
||||
band: input.ranges.band,
|
||||
};
|
||||
self.remember(ranges, input.world);
|
||||
if wanted.is_empty() {
|
||||
let ranges = Ranges {
|
||||
center: input.ranges.center,
|
||||
radius: input.ranges.radius,
|
||||
extra: input.ranges.extra,
|
||||
forced: input.ranges.forced,
|
||||
cover: input.ranges.cover,
|
||||
band: input.ranges.band,
|
||||
};
|
||||
self.remember(ranges, input.world);
|
||||
self.plan.clear();
|
||||
self.plan_basis = None;
|
||||
} else {
|
||||
// A bounded batch may leave candidates behind. Keep scanning
|
||||
// as capacity opens, even if no range or membership changed.
|
||||
// A bounded batch may leave candidates behind: the next calls
|
||||
// take them from the plan as capacity opens.
|
||||
self.exhausted = false;
|
||||
self.plan_basis = Some((input.loading, input.decorates));
|
||||
}
|
||||
wanted.sort_unstable();
|
||||
wanted.dedup();
|
||||
self.plan.clear();
|
||||
self.plan.extend_from_slice(&wanted);
|
||||
self.next = 0;
|
||||
wanted.truncate(capacity - input.generating.len());
|
||||
Some(wanted)
|
||||
}
|
||||
}
|
||||
}
|
||||
/// What a pool worker keeps for one streamer between its jobs: the
|
||||
/// generator's worker, and the face visibility table and its occlusion
|
||||
/// cache (like vanilla's per-thread cache: cloning it per job copied every
|
||||
/// state and face shape) and the mesher's scratch.
|
||||
struct WorkerLocal<W> {
|
||||
worker: W,
|
||||
faces: Option<(Arc<MeshTables>, FaceVisibility)>,
|
||||
mesh_scratch: MeshScratch,
|
||||
}
|
||||
/// A streamer's jobs, run by the process's shared pool (`horizon_threads::jobs`).
|
||||
struct Jobs<G: ChunkGenerator> {
|
||||
shared: Arc<Shared>,
|
||||
generator: Arc<G>,
|
||||
light: Arc<LightTables>,
|
||||
locals: PerWorker<WorkerLocal<G::Worker>>,
|
||||
}
|
||||
impl<G: ChunkGenerator> Source for Jobs<G> {
|
||||
fn next(&self) -> Option<Rank> {
|
||||
let queue = self.shared.queue.lock().unwrap();
|
||||
queue.peek().map(|q| {
|
||||
// Jobs without a column (saves, meshes to free) go first.
|
||||
let class = if q.at.is_none() {
|
||||
Class::Urgent
|
||||
} else {
|
||||
Class::Near
|
||||
};
|
||||
Rank::new(class, q.priority)
|
||||
})
|
||||
}
|
||||
fn run(&self, worker: usize) -> bool {
|
||||
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 {
|
||||
worker: self.generator.worker(),
|
||||
faces: None,
|
||||
mesh_scratch: MeshScratch::default(),
|
||||
},
|
||||
|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);
|
||||
}
|
||||
true
|
||||
}
|
||||
fn idle(&self, worker: usize) {
|
||||
// A server nobody is on, a client standing still: free the
|
||||
// generator's buffers (and the mesher's face cache and scratch)
|
||||
// until the next job needs them.
|
||||
self.locals.with_existing(worker, |local| {
|
||||
self.generator.idle(&mut local.worker);
|
||||
local.faces = None;
|
||||
local.mesh_scratch = MeshScratch::default();
|
||||
});
|
||||
}
|
||||
fn all_idle(&self) {
|
||||
// No worker needs retained mesh capacity now. Drop it outside the
|
||||
// pool lock; the pool then asks the allocator to return freed pages.
|
||||
let meshes = std::mem::take(&mut *self.shared.meshes.lock().unwrap());
|
||||
drop(meshes);
|
||||
}
|
||||
}
|
||||
impl<G: ChunkGenerator> Jobs<G> {
|
||||
fn run_job(&self, job: Job, local: &mut WorkerLocal<G::Worker>) -> Option<Done> {
|
||||
let (generator, shared, light) = (&self.generator, &self.shared, &self.light);
|
||||
let WorkerLocal {
|
||||
worker,
|
||||
faces,
|
||||
mesh_scratch,
|
||||
} = local;
|
||||
let done = match job {
|
||||
Job::Generate(x, z) => match generator.load(x, z) {
|
||||
Some(chunk) => {
|
||||
generator.prepare_loaded(&mut *worker, &chunk);
|
||||
Done::Generated(chunk)
|
||||
}
|
||||
None => {
|
||||
let proto = generator.proto(&mut *worker, x, z);
|
||||
let decorated = generator.is_decorated(&proto);
|
||||
Done::Proto(x, z, proto, decorated)
|
||||
}
|
||||
},
|
||||
Job::Decorate(x, z, mut region, finals) => {
|
||||
// The final chunks' proto chunks, made here: a
|
||||
// generated proto chunk holding their blocks.
|
||||
for (i, snapshot) in &finals {
|
||||
if let Some(proto) = generator.proto_beside_final(&mut *worker, snapshot) {
|
||||
region[*i] = proto;
|
||||
}
|
||||
}
|
||||
generator.decorate(&mut *worker, &mut region);
|
||||
let mut writes = FinalWrites::default();
|
||||
for (i, snapshot) in &finals {
|
||||
let blocks = generator.final_writes(snapshot, ®ion[*i]);
|
||||
writes
|
||||
.block_entities
|
||||
.extend(generator.final_block_entities(®ion[*i], &blocks));
|
||||
writes.blocks.extend(blocks);
|
||||
region[*i] = Box::new(());
|
||||
}
|
||||
let finals = finals.into_iter().map(|(i, _)| i).collect();
|
||||
Done::Decorated(x, z, region, finals, writes)
|
||||
}
|
||||
Job::Finish(proto) => {
|
||||
let mut chunk = generator.finish(proto);
|
||||
chunk.generated = true;
|
||||
Done::Generated(chunk)
|
||||
}
|
||||
Job::Save(protos) => {
|
||||
let refs: Vec<&Proto> = protos.iter().map(|(_, p)| p).collect();
|
||||
generator.save_protos(&refs);
|
||||
Done::Saved(protos)
|
||||
}
|
||||
Job::LightView(x, z, proto) => {
|
||||
let view = generator.light_view(&proto);
|
||||
Done::LightView(x, z, proto, view)
|
||||
}
|
||||
Job::Free(meshes) => {
|
||||
for (_, mesh) in meshes {
|
||||
let rejected = shared.meshes.lock().unwrap().put(mesh);
|
||||
drop(rejected);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
Job::Light(x, z, region, seq, sky) => {
|
||||
Done::Lit(x, z, light_chunk(&borrow(®ion), light, sky), seq)
|
||||
}
|
||||
Job::Mesh(x, sy, z, region, seq, settings) => {
|
||||
let context = shared.context.read().unwrap().clone();
|
||||
let visibility = match faces.take() {
|
||||
Some((tables, visibility)) if Arc::ptr_eq(&tables, &context.tables) => {
|
||||
visibility
|
||||
}
|
||||
_ => context.tables.visibility.clone(),
|
||||
};
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: settings.nether,
|
||||
shader_pack_lighting: settings.shader_pack_lighting,
|
||||
zoom: settings.zoom,
|
||||
blend_radius: settings.blend_radius,
|
||||
smooth_lighting: settings.smooth_lighting,
|
||||
cutout_leaves: settings.cutout_leaves,
|
||||
translucent_layers: &settings.translucent_layers,
|
||||
leaf_culling: settings.leaf_culling,
|
||||
assets: &context.assets,
|
||||
render: &context.render,
|
||||
tables: &context.tables,
|
||||
biomes: &context.biomes,
|
||||
visibility,
|
||||
};
|
||||
let recycled = shared.meshes.lock().unwrap().take();
|
||||
let mut mesh = mesher.mesh_reusing(&borrow(®ion), sy, recycled, mesh_scratch);
|
||||
mesh.sort_solid();
|
||||
*faces = Some((context.tables.clone(), mesher.visibility));
|
||||
Done::Meshed(x, sy, z, mesh, seq)
|
||||
}
|
||||
};
|
||||
Some(done)
|
||||
}
|
||||
}
|
||||
pub struct WorkerContext {
|
||||
pub assets: Arc<crate::assets::Assets>,
|
||||
pub render: Arc<crate::assets::RenderAssets>,
|
||||
@@ -629,190 +915,23 @@ impl Streaming {
|
||||
) -> Self {
|
||||
let shared = Arc::new(Shared {
|
||||
queue: Mutex::new(BinaryHeap::new()),
|
||||
ready: Condvar::new(),
|
||||
stop: AtomicBool::new(false),
|
||||
context: RwLock::new(context.clone()),
|
||||
done: Mailbox::default(),
|
||||
meshes: Mutex::new(MeshPool::default()),
|
||||
released: AtomicUsize::new(0),
|
||||
workers: AtomicUsize::new(0),
|
||||
});
|
||||
let light_tables = context.light.clone();
|
||||
let saves: Arc<dyn Saves> = generator.clone();
|
||||
let threads = (0..threads.max(1))
|
||||
.map(|i| {
|
||||
let shared = shared.clone();
|
||||
let generator = generator.clone();
|
||||
let light = context.light.clone();
|
||||
thread::Builder::new()
|
||||
.name(format!("horizon-worker-{i}"))
|
||||
.spawn(move || {
|
||||
// Ticks and frames come first (horizon_threads).
|
||||
horizon_threads::behind_server();
|
||||
let mut worker = generator.worker();
|
||||
// The face visibility table and its occlusion cache stay with
|
||||
// the worker across mesh jobs, like vanilla's per-thread cache:
|
||||
// cloning it per job copied every state and face shape.
|
||||
let mut faces: Option<(Arc<MeshTables>, FaceVisibility)> = None;
|
||||
let mut mesh_scratch = MeshScratch::default();
|
||||
loop {
|
||||
let job = {
|
||||
let mut queue = shared.queue.lock().unwrap();
|
||||
let mut released = false;
|
||||
loop {
|
||||
if shared.stop.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
if let Some(job) = queue.pop() {
|
||||
if released {
|
||||
shared.released.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
break job.job;
|
||||
}
|
||||
if released {
|
||||
queue = shared.ready.wait(queue).unwrap();
|
||||
continue;
|
||||
}
|
||||
let (woken, wait) =
|
||||
shared.ready.wait_timeout(queue, IDLE_RELEASE).unwrap();
|
||||
queue = woken;
|
||||
if wait.timed_out() && queue.is_empty() {
|
||||
// A server nobody is on, a client standing still:
|
||||
// free the generator's buffers (and the mesher's
|
||||
// face cache and scratch) until the next job
|
||||
// needs them.
|
||||
drop(queue);
|
||||
generator.idle(&mut worker);
|
||||
faces = None;
|
||||
mesh_scratch = MeshScratch::default();
|
||||
released = true;
|
||||
// The allocator keeps freed pages until memory
|
||||
// runs short: once every worker has freed its own,
|
||||
// hand them back.
|
||||
if shared.released.fetch_add(1, Ordering::Relaxed) + 1
|
||||
== shared.workers.load(Ordering::Relaxed)
|
||||
{
|
||||
// No worker needs retained mesh capacity now.
|
||||
// Drop it outside the pool lock before asking
|
||||
// the allocator to return freed pages.
|
||||
let meshes =
|
||||
std::mem::take(&mut *shared.meshes.lock().unwrap());
|
||||
drop(meshes);
|
||||
horizon_threads::release_free_memory();
|
||||
}
|
||||
queue = shared.queue.lock().unwrap();
|
||||
}
|
||||
}
|
||||
};
|
||||
let done = match job {
|
||||
Job::Generate(x, z) => match generator.load(x, z) {
|
||||
Some(chunk) => {
|
||||
generator.prepare_loaded(&mut worker, &chunk);
|
||||
Done::Generated(chunk)
|
||||
}
|
||||
None => {
|
||||
let proto = generator.proto(&mut worker, x, z);
|
||||
let decorated = generator.is_decorated(&proto);
|
||||
Done::Proto(x, z, proto, decorated)
|
||||
}
|
||||
},
|
||||
Job::Decorate(x, z, mut region, finals) => {
|
||||
// The final chunks' proto chunks, made here: a
|
||||
// generated proto chunk holding their blocks.
|
||||
for (i, snapshot) in &finals {
|
||||
if let Some(proto) =
|
||||
generator.proto_beside_final(&mut worker, snapshot)
|
||||
{
|
||||
region[*i] = proto;
|
||||
}
|
||||
}
|
||||
generator.decorate(&mut worker, &mut region);
|
||||
let mut writes = FinalWrites::default();
|
||||
for (i, snapshot) in &finals {
|
||||
let blocks = generator.final_writes(snapshot, ®ion[*i]);
|
||||
writes.block_entities.extend(
|
||||
generator.final_block_entities(®ion[*i], &blocks),
|
||||
);
|
||||
writes.blocks.extend(blocks);
|
||||
region[*i] = Box::new(());
|
||||
}
|
||||
let finals = finals.into_iter().map(|(i, _)| i).collect();
|
||||
Done::Decorated(x, z, region, finals, writes)
|
||||
}
|
||||
Job::Finish(proto) => {
|
||||
let mut chunk = generator.finish(proto);
|
||||
chunk.generated = true;
|
||||
Done::Generated(chunk)
|
||||
}
|
||||
Job::Save(protos) => {
|
||||
let refs: Vec<&Proto> = protos.iter().map(|(_, p)| p).collect();
|
||||
generator.save_protos(&refs);
|
||||
Done::Saved(protos)
|
||||
}
|
||||
Job::LightView(x, z, proto) => {
|
||||
let view = generator.light_view(&proto);
|
||||
Done::LightView(x, z, proto, view)
|
||||
}
|
||||
Job::Free(meshes) => {
|
||||
for (_, mesh) in meshes {
|
||||
let rejected = shared.meshes.lock().unwrap().put(mesh);
|
||||
drop(rejected);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Job::Light(x, z, region, seq, sky) => {
|
||||
Done::Lit(x, z, light_chunk(&borrow(®ion), &light, sky), seq)
|
||||
}
|
||||
Job::Mesh(x, sy, z, region, seq, settings) => {
|
||||
let context = shared.context.read().unwrap().clone();
|
||||
let visibility = match faces.take() {
|
||||
Some((tables, visibility))
|
||||
if Arc::ptr_eq(&tables, &context.tables) =>
|
||||
{
|
||||
visibility
|
||||
}
|
||||
_ => context.tables.visibility.clone(),
|
||||
};
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: settings.nether,
|
||||
shader_pack_lighting: settings.shader_pack_lighting,
|
||||
zoom: settings.zoom,
|
||||
blend_radius: settings.blend_radius,
|
||||
smooth_lighting: settings.smooth_lighting,
|
||||
cutout_leaves: settings.cutout_leaves,
|
||||
translucent_layers: &settings.translucent_layers,
|
||||
leaf_culling: settings.leaf_culling,
|
||||
assets: &context.assets,
|
||||
render: &context.render,
|
||||
tables: &context.tables,
|
||||
biomes: &context.biomes,
|
||||
visibility,
|
||||
};
|
||||
let recycled = shared.meshes.lock().unwrap().take();
|
||||
let mut mesh = mesher.mesh_reusing(
|
||||
&borrow(®ion),
|
||||
sy,
|
||||
recycled,
|
||||
&mut mesh_scratch,
|
||||
);
|
||||
mesh.sort_solid();
|
||||
faces = Some((context.tables.clone(), mesher.visibility));
|
||||
Done::Meshed(x, sy, z, mesh, seq)
|
||||
}
|
||||
};
|
||||
shared.done.send(done);
|
||||
}
|
||||
})
|
||||
})
|
||||
// A thread the system refuses (a container's process limit reached) leaves the pool a
|
||||
// worker short: a server that panicked here went down with its players' moves unsaved.
|
||||
.filter_map(|spawned| {
|
||||
spawned
|
||||
.inspect_err(|e| log::error!("Horizon: chunk worker not started: {e}"))
|
||||
.ok()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
shared.workers.store(threads.len(), Ordering::Relaxed);
|
||||
let pool = horizon_threads::jobs::pool();
|
||||
let source = Arc::new(Jobs {
|
||||
shared: shared.clone(),
|
||||
generator: generator.clone(),
|
||||
light: context.light.clone(),
|
||||
locals: horizon_threads::jobs::PerWorker::new(pool.threads()),
|
||||
});
|
||||
let jobs = pool.register(source);
|
||||
// The work kept in flight is sized for the workers this streamer may
|
||||
// use: the pool's, or fewer when asked (tests, a placeholder).
|
||||
let workers = threads.clamp(1, pool.threads());
|
||||
let non_air_sections = {
|
||||
let generator = generator.clone();
|
||||
Arc::new(move |p: &Proto| generator.non_air_sections(p))
|
||||
@@ -820,7 +939,8 @@ impl Streaming {
|
||||
Self {
|
||||
shared,
|
||||
non_air_sections,
|
||||
threads,
|
||||
jobs,
|
||||
workers,
|
||||
order: 0,
|
||||
scan_age: 0,
|
||||
scan_area: ((i32::MIN, i32::MIN), i32::MIN),
|
||||
@@ -1019,7 +1139,7 @@ impl Streaming {
|
||||
order: self.order,
|
||||
job,
|
||||
});
|
||||
self.shared.ready.notify_one();
|
||||
self.jobs.notify();
|
||||
}
|
||||
/// Rank the queued jobs again when what their priorities were computed
|
||||
/// from changed: the centre, the extra areas, the band, or the loading
|
||||
@@ -1489,7 +1609,7 @@ impl Streaming {
|
||||
.map(|(k, _)| *k)
|
||||
.collect();
|
||||
if !far.is_empty() {
|
||||
self.generation_scan.exhausted = false;
|
||||
self.generation_scan.invalidate();
|
||||
}
|
||||
// Those decoration wrote to are saved first (see
|
||||
// `ChunkGenerator::save_protos`), and dropped once back.
|
||||
@@ -1538,7 +1658,7 @@ impl Streaming {
|
||||
// Decorate chunks whose 3x3 proto neighbourhood is available, in
|
||||
// vanilla's order: decoration is order dependent where write regions
|
||||
// overlap (see `DecorationOrder`).
|
||||
let workers = self.threads.len();
|
||||
let workers = self.workers;
|
||||
if self.decorates && self.order_area != Some((self.center, self.radius)) {
|
||||
let (decorated, chunks) = (&self.decorated, &world.chunks);
|
||||
// The render distance is the server view distance in singleplayer.
|
||||
@@ -1600,14 +1720,24 @@ impl Streaming {
|
||||
w.0 = self.decoration_order.rank_or_append(w.2, w.3);
|
||||
}
|
||||
wanted.sort_unstable();
|
||||
// Most wanted chunks are blocked, and the same neighbours are
|
||||
// asked about for each of them: what `waits_for` says is kept for
|
||||
// the pass (nothing it reads changes in it but `decorated`, which
|
||||
// updates the entry). Without it, scheduling at render distance 32
|
||||
// took most of the thread that runs it while the world loaded.
|
||||
let waiting: std::cell::RefCell<FxHashMap<(i32, i32), bool>> = Default::default();
|
||||
for (_, _, x, z) in wanted {
|
||||
if self.decorating >= workers * 2 {
|
||||
break;
|
||||
}
|
||||
if self
|
||||
.decoration_order
|
||||
.blocked(x, z, |nx, nz| waits_for(self, nx, nz))
|
||||
{
|
||||
if self.decoration_order.blocked(x, z, |nx, nz| {
|
||||
if let Some(&known) = waiting.borrow().get(&(nx, nz)) {
|
||||
return known;
|
||||
}
|
||||
let known = waits_for(self, nx, nz);
|
||||
waiting.borrow_mut().insert((nx, nz), known);
|
||||
known
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
let keys: Vec<(i32, i32)> =
|
||||
@@ -1627,6 +1757,7 @@ impl Streaming {
|
||||
.collect();
|
||||
if !finals.is_empty() && !self.saves_beside_final {
|
||||
self.decorated.insert((x, z));
|
||||
waiting.borrow_mut().insert((x, z), false);
|
||||
continue;
|
||||
}
|
||||
if !keys.iter().enumerate().all(|(i, k)| {
|
||||
@@ -1680,7 +1811,7 @@ impl Streaming {
|
||||
for key in gone {
|
||||
world.dirty_light.remove(&key);
|
||||
}
|
||||
let room = (self.threads.len() * 4).saturating_sub(self.lighting.len());
|
||||
let room = (self.workers * 4).saturating_sub(self.lighting.len());
|
||||
nearest_first(&mut light, room);
|
||||
for (_, x, z) in light {
|
||||
let Some(region) = self.light_region(world, x, z) else {
|
||||
@@ -1766,7 +1897,7 @@ impl Streaming {
|
||||
// jobs went stale long before a worker reached them (#3629). What
|
||||
// stays dirty is asked for again, from the current centre, as
|
||||
// capacity frees; `requeue` then has a small queue to keep close.
|
||||
let room = (self.threads.len() * 32).saturating_sub(self.meshing.len());
|
||||
let room = (self.workers * 32).saturating_sub(self.meshing.len());
|
||||
nearest_first(&mut candidates, room);
|
||||
for (_, x, z, sections) in candidates {
|
||||
let Some(region) = Self::region(world, x, z) else {
|
||||
@@ -2438,11 +2569,8 @@ impl Drop for Streaming {
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
self.shared.stop.store(true, Ordering::Relaxed);
|
||||
self.shared.ready.notify_all();
|
||||
for t in self.threads.drain(..) {
|
||||
let _ = t.join();
|
||||
}
|
||||
// `jobs` leaves the pool after this, once its running jobs are
|
||||
// done; the jobs still queued go with the queue.
|
||||
}
|
||||
}
|
||||
/// The queued jobs' priorities computed again from each one's column
|
||||
@@ -2554,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(
|
||||
@@ -3143,6 +3264,70 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Batches taken from the plan are the full scan's, as jobs finish,
|
||||
/// columns and proto chunks leave and the centre moves.
|
||||
#[test]
|
||||
fn generation_plan_matches_full_scans_as_columns_arrive() {
|
||||
for decorates in [false, true] {
|
||||
let mut f = GenerationFixture::new(6, decorates);
|
||||
let mut gate = GenerationScan::default();
|
||||
let chunk = Arc::new(Chunk::empty(0, 0));
|
||||
let mut seed = 0x2545_F491_4F6C_DD1Du64;
|
||||
let mut rand = |n: u64| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
seed % n
|
||||
};
|
||||
let mut scheduled = 0;
|
||||
for step in 0..600 {
|
||||
let capacity = 3 + rand(6) as usize;
|
||||
let expected = baseline_candidates(f.input(), capacity);
|
||||
let batch = gate.candidates(f.input(), capacity).unwrap_or_default();
|
||||
assert_eq!(batch, expected, "step {step}");
|
||||
for (_, x, z) in batch {
|
||||
f.generating.insert((x, z));
|
||||
scheduled += 1;
|
||||
}
|
||||
// Some jobs finish: as a proto chunk, or a final column.
|
||||
let mut done: Vec<_> = f.generating.iter().copied().collect();
|
||||
done.sort_unstable();
|
||||
for key in done {
|
||||
if rand(3) != 0 {
|
||||
continue;
|
||||
}
|
||||
f.generating.remove(&key);
|
||||
if rand(2) == 0 {
|
||||
f.protos.insert(key, None);
|
||||
} else {
|
||||
f.world.chunks.insert(key, chunk.clone());
|
||||
}
|
||||
gate.exhausted = false;
|
||||
}
|
||||
// Now and then a column or a proto chunk leaves.
|
||||
if rand(20) == 0 {
|
||||
let mut keys: Vec<_> = f.world.chunks.keys().copied().collect();
|
||||
keys.sort_unstable();
|
||||
if let Some(key) = keys.get(rand(keys.len().max(1) as u64) as usize) {
|
||||
f.world.chunks.remove(key);
|
||||
}
|
||||
}
|
||||
if rand(20) == 0 {
|
||||
let mut keys: Vec<_> = f.protos.keys().copied().collect();
|
||||
keys.sort_unstable();
|
||||
if let Some(key) = keys.get(rand(keys.len().max(1) as u64) as usize) {
|
||||
f.protos.remove(key);
|
||||
gate.invalidate();
|
||||
}
|
||||
}
|
||||
if step == 300 {
|
||||
f.center = (3, -2);
|
||||
}
|
||||
}
|
||||
assert!(scheduled > 200);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_scan_looks_at_overlapping_areas_once() {
|
||||
// A server's players: many areas around one spot, some apart, one
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,8 +89,9 @@ fn main() -> Result<(), Error> {
|
||||
});
|
||||
let terrain = VanillaTerrain::new(&root.join("worldgen"), assets.registry, &biomes, seed)?;
|
||||
|
||||
// Generation and light, on the game's worker pool. Meshing is left out
|
||||
// here and measured on its own below.
|
||||
// Generation and light, on the game's worker pool (`threads` workers).
|
||||
// Meshing is left out here and measured on its own below.
|
||||
horizon_threads::jobs::configure(threads);
|
||||
let mut streaming = Streaming::new(Arc::new(terrain), context.clone(), threads);
|
||||
streaming.mesh = false;
|
||||
streaming.center = center;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -220,6 +220,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)",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -181,9 +181,11 @@ mod loom;
|
||||
mod mace;
|
||||
mod machines;
|
||||
mod maps;
|
||||
mod memory;
|
||||
mod minecarts;
|
||||
mod mob_blocks;
|
||||
mod mob_gear;
|
||||
mod mob_index;
|
||||
mod mob_looting;
|
||||
mod mob_sounds;
|
||||
mod mod_api_host;
|
||||
@@ -387,6 +389,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},
|
||||
@@ -411,6 +423,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;
|
||||
|
||||
@@ -841,6 +857,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,
|
||||
@@ -947,6 +973,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>,
|
||||
@@ -1154,6 +1181,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);
|
||||
@@ -1186,6 +1220,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,
|
||||
@@ -2291,6 +2427,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).
|
||||
@@ -2345,6 +2483,10 @@ struct Game<'a> {
|
||||
entity_spans: std::cell::RefCell<Vec<horizon_client::gfx::pack_draw::EntitySpan>>,
|
||||
/// Which entity this frame's entity vertices belong to (`Frame::entity_motion`).
|
||||
entity_motion: std::cell::RefCell<Vec<(u32, u64)>>,
|
||||
/// Mob models posed on the GPU (`Frame::skin`, `mobs::skin`): the models stored there,
|
||||
/// and this frame's draws.
|
||||
skin_models: std::cell::RefCell<horizon_client::mobs::skin::SkinModels>,
|
||||
skin_frame: std::cell::RefCell<horizon_client::gfx::entity_skin::SkinFrame>,
|
||||
/// The glowing entities' silhouettes this frame (game/glowing.rs).
|
||||
glowing_vertices: std::cell::RefCell<Vec<Vertex>>,
|
||||
/// The shader pack's entity and block entity ids, while one draws entities.
|
||||
@@ -2441,6 +2583,8 @@ struct Game<'a> {
|
||||
/// player's own (taken by `update_world`).
|
||||
spawn_wait: Vec<(i32, i32)>,
|
||||
mobs: Vec<Mob>,
|
||||
/// `mobs` by network id, for the packets that name one (`game/mob_index.rs`).
|
||||
mob_index: mob_index::MobIndex,
|
||||
creatures: creatures::Creatures,
|
||||
sculk: sculk::SculkState,
|
||||
raiding: raids::Raiding,
|
||||
@@ -2579,6 +2723,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
|
||||
@@ -2644,6 +2791,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>,
|
||||
@@ -2849,6 +2998,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>,
|
||||
@@ -2919,6 +3071,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
|
||||
@@ -3716,6 +3871,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.
|
||||
@@ -3742,6 +3899,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
|
||||
@@ -3800,8 +3959,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(
|
||||
@@ -4287,6 +4445,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,
|
||||
@@ -4299,6 +4458,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)
|
||||
@@ -5018,6 +5193,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,
|
||||
@@ -5107,7 +5291,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,
|
||||
@@ -5943,7 +6127,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) {
|
||||
@@ -6114,6 +6313,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 {
|
||||
@@ -6423,6 +6629,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 +7771,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 +8155,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 +8199,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 +8262,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 +8456,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);
|
||||
@@ -12635,24 +12866,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`).
|
||||
@@ -12927,6 +13163,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));
|
||||
}
|
||||
@@ -13076,22 +13336,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
|
||||
@@ -13632,6 +13898,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
|
||||
@@ -14253,22 +14525,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(
|
||||
@@ -14307,6 +14595,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;
|
||||
@@ -14362,6 +14657,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());
|
||||
@@ -14402,7 +14700,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 {
|
||||
@@ -14982,7 +15285,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();
|
||||
@@ -15092,6 +15397,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();
|
||||
@@ -15560,6 +15870,7 @@ impl Game<'_> {
|
||||
let entity_motion = std::mem::take(self.entity_motion.get_mut());
|
||||
let glowing = std::mem::take(self.glowing_vertices.get_mut());
|
||||
let shadow_only = std::mem::take(self.shadow_only_vertices.get_mut());
|
||||
let skin = std::mem::take(self.skin_frame.get_mut());
|
||||
let frame = Frame {
|
||||
weather: &weather,
|
||||
weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32),
|
||||
@@ -15643,6 +15954,7 @@ impl Game<'_> {
|
||||
entities: &entities,
|
||||
entity_motion: &entity_motion,
|
||||
entity_spans: &entity_spans,
|
||||
skin: skin.pending().then_some(&skin),
|
||||
glowing: &glowing,
|
||||
dynamic_lights: &self.dynamic_lights,
|
||||
colored_lights: &colored_lights,
|
||||
@@ -15717,12 +16029,26 @@ 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;
|
||||
*self.entity_motion.get_mut() = entity_motion;
|
||||
*self.glowing_vertices.get_mut() = glowing;
|
||||
*self.shadow_only_vertices.get_mut() = shadow_only;
|
||||
let mut skin = skin;
|
||||
// The renderer took the new models when its store grew by them; otherwise they stay
|
||||
// pending, or the stores restart (`SkinModels::begin`).
|
||||
if self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.and_then(|r| r.skinned_models())
|
||||
.is_some_and(|stored| stored == skin.base + skin.new_model.len() as u32)
|
||||
{
|
||||
skin.handed();
|
||||
}
|
||||
*self.skin_frame.get_mut() = skin;
|
||||
self.see_through_vertices = see_through;
|
||||
result
|
||||
}
|
||||
@@ -16424,9 +16750,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())
|
||||
@@ -16562,11 +16893,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
|
||||
{
|
||||
@@ -17524,9 +17857,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() => {
|
||||
@@ -18115,7 +18448,18 @@ impl Game<'_> {
|
||||
);
|
||||
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).
|
||||
@@ -18263,6 +18607,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,
|
||||
@@ -18291,6 +18636,8 @@ impl<'a> Game<'a> {
|
||||
shadow_only_vertices: Default::default(),
|
||||
entity_spans: Default::default(),
|
||||
entity_motion: Default::default(),
|
||||
skin_models: Default::default(),
|
||||
skin_frame: Default::default(),
|
||||
glowing_vertices: Default::default(),
|
||||
pack_ids: None,
|
||||
health_hud: hud_layout::HealthHud::default(),
|
||||
@@ -18352,6 +18699,7 @@ impl<'a> Game<'a> {
|
||||
player_spawn: None,
|
||||
spawn_wait: Vec::new(),
|
||||
mobs: Vec::new(),
|
||||
mob_index: Default::default(),
|
||||
creatures: Default::default(),
|
||||
sculk: Default::default(),
|
||||
raiding: Default::default(),
|
||||
@@ -18424,6 +18772,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(),
|
||||
@@ -18464,6 +18813,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(),
|
||||
@@ -18588,6 +18938,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,
|
||||
@@ -18620,6 +18971,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,
|
||||
@@ -19235,7 +19587,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>,
|
||||
}
|
||||
@@ -19259,6 +19611,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,
|
||||
@@ -19303,7 +19656,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))
|
||||
@@ -19403,6 +19766,9 @@ fn load_resources(
|
||||
publish: &mut dyn FnMut(UiResources, &std::path::Path),
|
||||
) -> Result<Loaded> {
|
||||
let threads = settings.horizon.worker_threads();
|
||||
// One pool of workers for every level's streaming, the client's and the
|
||||
// server thread's (horizon_threads::jobs), sized before any world opens.
|
||||
horizon_threads::jobs::configure(threads);
|
||||
horizon_client::sprites::set_texture_budget(settings.horizon.texture_budget_mib);
|
||||
horizon_client::sprites::set_labpbr_maps(shader_packs::picks_native_pack(settings));
|
||||
// Stage A: the menu bundle (atlas and panorama). With a loading screen it is
|
||||
@@ -19439,7 +19805,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())
|
||||
@@ -19484,7 +19856,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());
|
||||
}
|
||||
@@ -19826,6 +20197,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,
|
||||
@@ -19835,7 +20207,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,
|
||||
@@ -19985,6 +20357,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
entities: &[],
|
||||
entity_motion: &[],
|
||||
entity_spans: &[],
|
||||
skin: None,
|
||||
glowing: &[],
|
||||
dynamic_lights: &[],
|
||||
colored_lights: &[],
|
||||
@@ -20721,9 +21094,8 @@ pub fn run_with() -> Result<()> {
|
||||
horizon_threads::put_helpers_behind();
|
||||
if options.execution_role.is_client() {
|
||||
// The render thread, like the server thread, ahead of the workers on
|
||||
// macOS and Windows. While terrain loads, the worker pools (two with
|
||||
// single player's server thread) and other programs outnumber the
|
||||
// cores, and a frame that gave its core up (in a GPU submit) waited
|
||||
// macOS and Windows. While terrain loads, the workers
|
||||
// (horizon_threads::jobs) and other programs outnumber the cores, and a frame that gave its core up (in a GPU submit) waited
|
||||
// 10 to 200 ms to get one back.
|
||||
horizon_threads::favour_current();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -500,7 +500,7 @@ impl Game<'_> {
|
||||
let mut state = 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(),
|
||||
@@ -541,11 +541,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;
|
||||
};
|
||||
@@ -748,7 +755,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() {
|
||||
@@ -1721,7 +1728,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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -401,6 +401,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 {
|
||||
@@ -418,7 +419,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() })
|
||||
};
|
||||
@@ -434,7 +435,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 {
|
||||
@@ -452,10 +453,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();
|
||||
}
|
||||
@@ -698,7 +699,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;
|
||||
@@ -751,7 +752,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()
|
||||
}
|
||||
@@ -770,7 +771,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)))
|
||||
@@ -780,7 +781,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()))
|
||||
@@ -1010,7 +1011,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);
|
||||
|
||||
@@ -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
|
||||
@@ -6098,6 +6203,19 @@ impl Game<'_> {
|
||||
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
let scale = self.view_scale();
|
||||
self.rendered_mobs.set(0);
|
||||
// Mob models posed on the GPU (mobs::skin), when the renderer takes them this frame.
|
||||
let skinning = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.filter(|r| r.skins_entities())
|
||||
.and_then(|r| r.skinned_models());
|
||||
match skinning {
|
||||
Some(stored) => self
|
||||
.skin_models
|
||||
.borrow_mut()
|
||||
.begin(&mut self.skin_frame.borrow_mut(), stored),
|
||||
None => self.skin_frame.borrow_mut().clear_draws(),
|
||||
}
|
||||
// What custom entity model animations read of the frame.
|
||||
horizon_client::mobs::cem_anim::set_frame(horizon_client::mobs::cem_anim::Frame {
|
||||
time: self.world.game_time as f64 + alpha,
|
||||
@@ -6240,21 +6358,55 @@ impl Game<'_> {
|
||||
Vec::new()
|
||||
};
|
||||
let body = out.len();
|
||||
let render = if layers_only {
|
||||
horizon_client::mobs::render::render_layers
|
||||
// The GPU path: an ordinary mob, no outline, no layers the CPU path changes after
|
||||
// drawing them (a player's, the HDR tiers below, the sulfur cube's shell).
|
||||
let posable = skinning.is_some()
|
||||
&& !is_player
|
||||
&& !layers_only
|
||||
&& outline.is_none()
|
||||
&& armor.is_empty()
|
||||
&& !matches!(
|
||||
mob.species(),
|
||||
Species::Blaze | Species::MagmaCube | Species::GlowSquid | Species::SulfurCube
|
||||
);
|
||||
let mut skinned = None;
|
||||
if posable {
|
||||
let mut models = self.skin_models.borrow_mut();
|
||||
let mut frame = self.skin_frame.borrow_mut();
|
||||
let mark = frame.mark();
|
||||
let mut skin = horizon_client::mobs::skin::Skin {
|
||||
models: &mut models,
|
||||
frame: &mut frame,
|
||||
bounds: None,
|
||||
};
|
||||
horizon_client::mobs::render::render_skinned(
|
||||
mob,
|
||||
alpha as f32,
|
||||
camera,
|
||||
&lookup,
|
||||
packed,
|
||||
self.res.mob_data(),
|
||||
out,
|
||||
&mut skin,
|
||||
);
|
||||
skinned = Some((mark, skin.bounds));
|
||||
} else {
|
||||
horizon_client::mobs::render::render_with
|
||||
};
|
||||
render(
|
||||
mob,
|
||||
alpha as f32,
|
||||
camera,
|
||||
&lookup,
|
||||
packed,
|
||||
self.res.mob_data(),
|
||||
&armor,
|
||||
out,
|
||||
);
|
||||
let render = if layers_only {
|
||||
horizon_client::mobs::render::render_layers
|
||||
} else {
|
||||
horizon_client::mobs::render::render_with
|
||||
};
|
||||
render(
|
||||
mob,
|
||||
alpha as f32,
|
||||
camera,
|
||||
&lookup,
|
||||
packed,
|
||||
self.res.mob_data(),
|
||||
&armor,
|
||||
out,
|
||||
);
|
||||
}
|
||||
// HDR output: mobs that are light shine by their kind, as blocks do (entity.wgsl
|
||||
// fs_hdr_mark); a tier's window lifts only their bright texels.
|
||||
if let Some(tier) = match mob.species() {
|
||||
@@ -6302,7 +6454,23 @@ impl Game<'_> {
|
||||
if outline.is_none()
|
||||
&& horizon_client::mobs::guardian::beam(mob, alpha as f32).is_none()
|
||||
{
|
||||
// What the GPU draws of the mob counts by its bounds' corners.
|
||||
let corners = skinned.and_then(|(_, bounds)| bounds);
|
||||
if let Some((lo, hi)) = corners {
|
||||
out.extend([lo, hi].map(|pos| Vertex {
|
||||
pos: pos.to_array(),
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
}));
|
||||
}
|
||||
let len = out.len();
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
if out.len() < len {
|
||||
if let Some((mark, _)) = skinned {
|
||||
self.skin_frame.borrow_mut().truncate(mark);
|
||||
}
|
||||
} else if corners.is_some() {
|
||||
out.truncate(len - 2);
|
||||
}
|
||||
}
|
||||
// GuardianRenderer: the laser to the locked target.
|
||||
if let Some((time, scale)) = horizon_client::mobs::guardian::beam(mob, alpha as f32)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}'"
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -712,6 +712,17 @@ mod tests {
|
||||
assert_eq!(range, 48.0);
|
||||
}
|
||||
|
||||
/// `Entity.shouldRenderAtSqrDistance` for a dropped item (`ItemEntity`
|
||||
/// keeps the default and its box is 0.25 on each side): 24 blocks at
|
||||
/// render distance 12, 32 at 16, 40 from 20 on; never 64.
|
||||
#[test]
|
||||
fn a_dropped_item_is_drawn_sixteen_blocks_times_the_view_scale() {
|
||||
let item = glam::DVec3::splat(0.25);
|
||||
assert_eq!(default_range(item, entity_view_scale(12, 1.0)), 24.0);
|
||||
assert_eq!(default_range(item, entity_view_scale(16, 1.0)), 32.0);
|
||||
assert_eq!(default_range(item, entity_view_scale(32, 1.0)), 40.0);
|
||||
}
|
||||
|
||||
/// `Entity.shouldRenderAtSqrDistance` on a broken box: the size falls
|
||||
/// back to one block.
|
||||
#[test]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -483,12 +483,22 @@ impl Game<'_> {
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
// Entity.shouldRenderAtSqrDistance for the item's 0.25 box: 16 blocks
|
||||
// times the view scale (32 at render distance 16), from its current
|
||||
// position. A pickup particle is not an entity and keeps 64 blocks.
|
||||
let range =
|
||||
super::entity_culling::default_range(glam::DVec3::splat(0.25), self.view_scale());
|
||||
for (item, at, partial, entity) in live.chain(taken) {
|
||||
let entity_start = out.len();
|
||||
self.mark_motion(entity_start, (2 << 32) | u64::from(item.id));
|
||||
let q = item.position;
|
||||
let pos = glam::DVec3::from_array(at) - camera;
|
||||
if pos.length_squared() > 64.0 * 64.0 {
|
||||
let far = if entity {
|
||||
(glam::DVec3::new(q.x, q.y, q.z) - camera).length_squared() >= range * range
|
||||
} else {
|
||||
pos.length_squared() > 64.0 * 64.0
|
||||
};
|
||||
if far {
|
||||
continue;
|
||||
}
|
||||
// EntityRenderer.shouldRender: the item's 0.25 box, grown by 0.5,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Finding a mob by its network id without walking the whole list: a
|
||||
//! multiplayer client looks one up for every movement, data, motion and
|
||||
//! event packet. A `Mob` is ~3 KB, so a linear search touches a cache line
|
||||
//! per mob: 1000 movement packets in a 3000-mob farm cost ~5 ms a tick that
|
||||
//! way, against ~0.01 ms through the index.
|
||||
//!
|
||||
//! The index is a cache: the mob list changes in many places (spawns,
|
||||
//! removals, dimension swaps), and none of them has to tell it. A hit is
|
||||
//! checked against the list, and a miss falls back to the linear search,
|
||||
//! which rebuilds the index when it finds the mob. Ids are unique in the list
|
||||
//! (`spawn_entity` drops a mob with the same id before adding one), so the
|
||||
//! answer is always the linear search's.
|
||||
|
||||
use horizon_client::fast_hash::FxHashMap;
|
||||
use horizon_client::mobs::Mob;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct MobIndex {
|
||||
slots: FxHashMap<u32, u32>,
|
||||
}
|
||||
|
||||
impl MobIndex {
|
||||
/// The position of the mob with this id in `mobs`, as
|
||||
/// `mobs.iter().position(|m| m.id == id)`.
|
||||
pub(super) fn find(&mut self, mobs: &[Mob], id: u32) -> Option<usize> {
|
||||
self.position(mobs, |m| m.id, id)
|
||||
}
|
||||
|
||||
fn position<T>(&mut self, items: &[T], id_of: impl Fn(&T) -> u32, id: u32) -> Option<usize> {
|
||||
if let Some(&i) = self.slots.get(&id)
|
||||
&& items.get(i as usize).is_some_and(|m| id_of(m) == id)
|
||||
{
|
||||
return Some(i as usize);
|
||||
}
|
||||
let i = items.iter().position(|m| id_of(m) == id)?;
|
||||
self.slots.clear();
|
||||
for (i, m) in items.iter().enumerate() {
|
||||
self.slots.entry(id_of(m)).or_insert(i as u32);
|
||||
}
|
||||
Some(i)
|
||||
}
|
||||
|
||||
/// The mob with this id, as `mobs.iter_mut().find(|m| m.id == id)`.
|
||||
pub(super) fn get_mut<'a>(&mut self, mobs: &'a mut [Mob], id: u32) -> Option<&'a mut Mob> {
|
||||
let i = self.find(mobs, id)?;
|
||||
Some(&mut mobs[i])
|
||||
}
|
||||
|
||||
/// The mob with this id, as `mobs.iter().find(|m| m.id == id)`.
|
||||
pub(super) fn get<'a>(&mut self, mobs: &'a [Mob], id: u32) -> Option<&'a Mob> {
|
||||
let i = self.find(mobs, id)?;
|
||||
Some(&mobs[i])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Whatever the list went through since the last lookup, the index
|
||||
/// answers as the linear search does.
|
||||
#[test]
|
||||
fn finds_what_a_linear_search_finds_after_any_change() {
|
||||
let mut index = MobIndex::default();
|
||||
let check = |index: &mut MobIndex, ids: &[u32]| {
|
||||
for id in 0..20 {
|
||||
let found = index.position(ids, |&m| m, id);
|
||||
assert_eq!(
|
||||
found,
|
||||
ids.iter().position(|&m| m == id),
|
||||
"id {id} in {ids:?}"
|
||||
);
|
||||
}
|
||||
};
|
||||
let mut ids = vec![7, 3, 9, 12];
|
||||
check(&mut index, &ids);
|
||||
ids.remove(1);
|
||||
check(&mut index, &ids);
|
||||
ids.push(3);
|
||||
ids.swap(0, 2);
|
||||
check(&mut index, &ids);
|
||||
ids.retain(|&m| m != 9);
|
||||
ids.insert(0, 15);
|
||||
check(&mut index, &ids);
|
||||
// Another list altogether: a dimension swapped in.
|
||||
check(&mut index, &[12, 15, 1]);
|
||||
ids.clear();
|
||||
check(&mut index, &ids);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 419 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user