mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e5a2c74d2 |
No files matched your search
@@ -26,7 +26,6 @@ on:
|
||||
- 'launcher/**'
|
||||
- 'crates/horizon-bench/**'
|
||||
- 'crates/horizon-friends/**'
|
||||
- 'crates/horizon-mcp/**'
|
||||
- 'crates/horizon-mods/**'
|
||||
- 'crates/horizon-port-pack/**'
|
||||
- 'crates/horizon-prepare/**'
|
||||
@@ -175,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_platform/runtime_shaders" >> "$GITHUB_ENV"
|
||||
echo "LAUNCHER_FEATURES=gpui/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,10 +103,6 @@ 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
-11
@@ -1399,7 +1399,6 @@ dependencies = [
|
||||
"horizon-friends",
|
||||
"horizon-hdr",
|
||||
"horizon-items",
|
||||
"horizon-mcp",
|
||||
"horizon-mods",
|
||||
"horizon-narrator",
|
||||
"horizon-optifine",
|
||||
@@ -1462,7 +1461,6 @@ dependencies = [
|
||||
"horizon-threads",
|
||||
"horizon-world",
|
||||
"log",
|
||||
"lz4_flex",
|
||||
"miniz_oxide 0.8.9",
|
||||
"naga",
|
||||
"png",
|
||||
@@ -1544,13 +1542,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mcp"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mods"
|
||||
version = "0.1.0"
|
||||
@@ -1655,7 +1646,6 @@ dependencies = [
|
||||
"horizon-bench",
|
||||
"horizon-chat-filter",
|
||||
"horizon-core",
|
||||
"horizon-mcp",
|
||||
"horizon-release-sign",
|
||||
"horizon-server-files",
|
||||
"libc",
|
||||
@@ -1743,7 +1733,6 @@ dependencies = [
|
||||
"flate2",
|
||||
"horizon-core",
|
||||
"libm",
|
||||
"log",
|
||||
"lz4_flex",
|
||||
"postcard",
|
||||
"serde",
|
||||
|
||||
@@ -17,8 +17,6 @@ members = [
|
||||
"crates/horizon-server-files",
|
||||
"crates/horizon-anticheat",
|
||||
"crates/horizon-chat-filter",
|
||||
# The Model Context Protocol, shared by horizon-ctl, horizon-server and the launcher.
|
||||
"crates/horizon-mcp",
|
||||
"crates/horizon-server",
|
||||
"crates/horizon-bots",
|
||||
"crates/horizon-assets",
|
||||
|
||||
@@ -14,8 +14,7 @@
|
||||
//!
|
||||
//! 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), one with the first bot's spawn position, and
|
||||
//! writes a summary with `--report`.
|
||||
//! It prints one JSON line a second (bots online, chunks received) 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
|
||||
@@ -103,8 +102,6 @@ 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)]
|
||||
@@ -180,8 +177,6 @@ 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,
|
||||
}
|
||||
@@ -212,7 +207,6 @@ fn options() -> Result<Options, String> {
|
||||
click: None,
|
||||
name: "Bot".into(),
|
||||
report: None,
|
||||
capture: None,
|
||||
brand: None,
|
||||
timer: 1.0,
|
||||
};
|
||||
@@ -262,7 +256,6 @@ 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(',') {
|
||||
@@ -826,53 +819,12 @@ 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;
|
||||
@@ -896,9 +848,6 @@ 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() {
|
||||
@@ -1058,10 +1007,7 @@ fn run_bot(
|
||||
{
|
||||
let (writer, bot) = (writer.clone(), bot.clone());
|
||||
let track = behaviour == Behaviour::Fight;
|
||||
let capture = (index == 0)
|
||||
.then(|| o.capture.as_deref().and_then(Capture::create))
|
||||
.flatten();
|
||||
thread::spawn(move || read(reader, writer, bot, track, capture));
|
||||
thread::spawn(move || read(reader, writer, bot, track));
|
||||
}
|
||||
use ids::play::serverbound as sb;
|
||||
// Wait for the spawn position.
|
||||
@@ -1079,10 +1025,6 @@ 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,19 +239,8 @@ impl Action {
|
||||
pub struct Mute {
|
||||
pub uuid: String,
|
||||
pub name: String,
|
||||
/// 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)
|
||||
}
|
||||
/// Epoch milliseconds.
|
||||
pub until: i64,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
@@ -323,38 +312,12 @@ impl Guard {
|
||||
)
|
||||
}
|
||||
|
||||
/// 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();
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// A hit by the player at `now`: the hit's number in the window, and what it leads to.
|
||||
@@ -380,24 +343,15 @@ impl Guard {
|
||||
let until = now + i64::from(c.mute_minutes) * MINUTE;
|
||||
// The window starts again when the mute ends.
|
||||
s.counts_until = until + window;
|
||||
// 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();
|
||||
}
|
||||
self.mutes.insert(
|
||||
uuid,
|
||||
Mute {
|
||||
uuid: uuid_string(uuid),
|
||||
name: name.to_owned(),
|
||||
until,
|
||||
},
|
||||
);
|
||||
self.save();
|
||||
Action::Muted { until }
|
||||
} else {
|
||||
Action::Refused
|
||||
@@ -423,7 +377,7 @@ impl Guard {
|
||||
return;
|
||||
};
|
||||
let now = now_millis();
|
||||
self.mutes.retain(|_, m| m.active(now));
|
||||
self.mutes.retain(|_, m| m.until > 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 }) {
|
||||
@@ -470,34 +424,6 @@ 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(id, NOW), None);
|
||||
assert_eq!(g.muted_until(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(id, NOW + 4 * MIN).unwrap().until, Some(until));
|
||||
assert_eq!(g.muted(id, until), None);
|
||||
assert_eq!(g.muted_until(id, NOW + 4 * MIN), Some(until));
|
||||
assert_eq!(g.muted_until(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,83 +190,15 @@ 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(5, now).is_some());
|
||||
assert!(again.muted_until(5, now).is_some());
|
||||
assert_eq!(again.unmute("dee").as_deref(), Some("Dee"));
|
||||
assert_eq!(again.unmute("dee"), 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);
|
||||
assert_eq!(again.muted_until(5, now), None);
|
||||
let (reloaded, _) = Guard::load(Config::default(), &dir);
|
||||
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);
|
||||
assert_eq!(reloaded.muted_until(5, now), None);
|
||||
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 {
|
||||
|
||||
@@ -20,8 +20,6 @@ horizon-client-render = { path = "../horizon-client-render" }
|
||||
horizon-command = { path = "../horizon-command" }
|
||||
horizon-items = { path = "../horizon-items" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
# `horizon-ctl mcp`: the Model Context Protocol, shared with the launcher and the server.
|
||||
horizon-mcp = { path = "../horizon-mcp" }
|
||||
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
@@ -134,10 +132,6 @@ 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,8 +7,6 @@ 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,7 +187,6 @@ 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())
|
||||
@@ -254,33 +253,9 @@ 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`. Horizon tries
|
||||
/// twice when the service doesn't answer; vanilla asks the player to.
|
||||
/// account is joining the server whose hash is `server_hash`.
|
||||
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()
|
||||
@@ -289,19 +264,11 @@ fn join_server_once(session: &AuthSession, server_hash: &str) -> Result<(), Join
|
||||
.send_string(&join_request(session, server_hash))
|
||||
{
|
||||
Ok(_) => Ok(()),
|
||||
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)
|
||||
}
|
||||
Err(ureq::Error::Status(status, response)) => Err(JoinError::from_response(
|
||||
status,
|
||||
&response.into_string().unwrap_or_default(),
|
||||
)),
|
||||
Err(ureq::Error::Transport(_)) => Err(JoinError::Unavailable),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -597,41 +564,6 @@ 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,15 +1792,6 @@ 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;
|
||||
@@ -1818,7 +1809,6 @@ 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;
|
||||
@@ -1877,7 +1867,6 @@ 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";
|
||||
@@ -2152,13 +2141,11 @@ 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: CULL_BACK,
|
||||
normal: [0; 4],
|
||||
pos: m.transform_point3(Vec3::from(c)).to_array(),
|
||||
uv: [0.0; 2],
|
||||
layer,
|
||||
@@ -2729,35 +2716,6 @@ 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,122 +285,20 @@ fn forget_shared() {
|
||||
*shared = None;
|
||||
}
|
||||
}
|
||||
/// 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.
|
||||
/// Packed light per block: sky light in the high nibble, block light in the low one.
|
||||
#[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 {
|
||||
@@ -449,15 +347,14 @@ 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() + self.light.heap_bytes()
|
||||
self.blocks.heap_bytes()
|
||||
+ self.biomes.heap_bytes()
|
||||
+ match &self.light {
|
||||
Light::Uniform(_) => 0,
|
||||
Light::Full(_) => 4096,
|
||||
}
|
||||
}
|
||||
/// Collapse storage that became uniform, and drop palette entries no
|
||||
/// block uses any more.
|
||||
@@ -469,47 +366,13 @@ impl Section {
|
||||
*container = PalettedContainer::new(container.strategy(), v);
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
if let Light::Full(a) = &self.light
|
||||
&& a.iter().all(|&b| b == a[0])
|
||||
{
|
||||
self.light = Light::Uniform(a[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// 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,
|
||||
@@ -701,61 +564,19 @@ impl Chunk {
|
||||
}
|
||||
#[inline]
|
||||
pub fn light(&self, x: i32, y: i32, z: i32) -> u8 {
|
||||
match self.section_at(y) {
|
||||
Ok(section) => section.light(index(x, y, z)),
|
||||
Err(light) => light,
|
||||
let s = (y - self.min_y) >> 4;
|
||||
if s < 0 {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
/// 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),
|
||||
if s >= self.sections.len() as i32 {
|
||||
return 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]
|
||||
@@ -853,33 +674,6 @@ 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,8 +518,7 @@ pub fn panic_report(info: &std::panic::PanicHookInfo, side: &'static str) -> Rep
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `name` is one of the shared worker pool's threads (`horizon_threads::jobs`), vanilla's
|
||||
/// `Util` worker threads.
|
||||
/// Whether `name` is one of the streaming pool's threads, vanilla's `Util` worker threads.
|
||||
fn is_worker_thread(name: Option<&str>) -> bool {
|
||||
name.is_some_and(|n| n.starts_with("horizon-worker-"))
|
||||
}
|
||||
|
||||
@@ -351,9 +351,7 @@ 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)));
|
||||
}
|
||||
// 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));
|
||||
return vec4(0.0, 0.0, 0.0, hdr_mark(in.tier, brightness(texel.rgb * in.color.rgb) * (1.0 - fogged)));
|
||||
}
|
||||
fn brightness(c: vec3<f32>) -> f32 {
|
||||
let l = pow(max(c, vec3(0.0)), vec3(2.2));
|
||||
|
||||
@@ -120,21 +120,10 @@ pub struct Extras {
|
||||
/// Agents may control the game the player starts: it opens its control socket for
|
||||
/// horizon-ctl and MCP clients (off by default; an agent's own launch always opens it).
|
||||
pub agent_control: bool,
|
||||
/// F3+R and F3+E start/stop a repro recording and write its bundle (off by
|
||||
/// default: a recording copies the world).
|
||||
pub repro_keys: bool,
|
||||
/// "Playing Horizon" on the player's Discord profile (`discord_presence`; on by default).
|
||||
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 {
|
||||
@@ -171,12 +160,8 @@ impl Default for Extras {
|
||||
chat_indicator: true,
|
||||
no_ai: false,
|
||||
agent_control: false,
|
||||
repro_keys: false,
|
||||
discord_presence: true,
|
||||
splitscreen: false,
|
||||
splitscreen_two: 0,
|
||||
splitscreen_three: 0,
|
||||
splitscreen_names: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -255,12 +240,8 @@ impl Extras {
|
||||
"chatIndicator" => e.chat_indicator = flag(e.chat_indicator),
|
||||
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
|
||||
"agentControl" => e.agent_control = flag(e.agent_control),
|
||||
"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),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -268,7 +249,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:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\n",
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
@@ -306,12 +287,8 @@ impl Extras {
|
||||
horizon_core::no_ai::KEY,
|
||||
bool_text(self.no_ai),
|
||||
bool_text(self.agent_control),
|
||||
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
|
||||
@@ -425,10 +402,6 @@ 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)]
|
||||
@@ -452,8 +425,6 @@ pub enum Item {
|
||||
AgentControl,
|
||||
/// On the Horizon Settings page.
|
||||
DiscordPresence,
|
||||
/// On the AI Agent page, next to Agent Control: the F3 recording shortcuts.
|
||||
ReproKeys,
|
||||
/// On the Splitscreen settings page.
|
||||
Splitscreen,
|
||||
}
|
||||
@@ -498,7 +469,6 @@ impl Item {
|
||||
Item::SortKey => "horizon.extras.sort_key",
|
||||
Item::NoAi => "horizon.extras.no_ai",
|
||||
Item::AgentControl => "horizon.extras.agent_control",
|
||||
Item::ReproKeys => "horizon.extras.repro_keys",
|
||||
Item::DiscordPresence => "horizon.extras.discord_presence",
|
||||
Item::Splitscreen => "horizon.extras.splitscreen",
|
||||
Item::ChatAnimation => "horizon.extras.chat_animation",
|
||||
@@ -520,7 +490,6 @@ impl Item {
|
||||
Item::SortKey => "horizon.extras.sort_key.tooltip",
|
||||
Item::NoAi => "horizon.extras.no_ai.tooltip",
|
||||
Item::AgentControl => "horizon.extras.agent_control.tooltip",
|
||||
Item::ReproKeys => "horizon.extras.repro_keys.tooltip",
|
||||
Item::DiscordPresence => "horizon.extras.discord_presence.tooltip",
|
||||
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
|
||||
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
|
||||
@@ -553,7 +522,6 @@ impl Item {
|
||||
Item::ChatIndicator => "Message indicator",
|
||||
Item::NoAi => "No AI",
|
||||
Item::AgentControl => "Agent Control",
|
||||
Item::ReproKeys => "Recording shortcuts",
|
||||
Item::DiscordPresence => "Discord Rich Presence",
|
||||
Item::Splitscreen => "Splitscreen",
|
||||
}
|
||||
@@ -567,7 +535,7 @@ impl Item {
|
||||
SortKey => Sort,
|
||||
ChatIndicator => ChatAnimation,
|
||||
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
|
||||
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
|
||||
| AutoReconnect | NoAi | AgentControl | DiscordPresence | Splitscreen
|
||||
| ChatAnimation => {
|
||||
return None;
|
||||
}
|
||||
@@ -620,9 +588,6 @@ impl Item {
|
||||
Item::AgentControl => {
|
||||
"Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only."
|
||||
}
|
||||
Item::ReproKeys => {
|
||||
"F3+R starts or stops a repro recording and F3+E writes its bundle. Off by default: a recording copies the world."
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The frame-time cost the main switch of an extra shows.
|
||||
@@ -658,7 +623,6 @@ impl Item {
|
||||
Item::ChatIndicator => e.chat_indicator,
|
||||
Item::NoAi => e.no_ai,
|
||||
Item::AgentControl => e.agent_control,
|
||||
Item::ReproKeys => e.repro_keys,
|
||||
Item::DiscordPresence => e.discord_presence,
|
||||
Item::Splitscreen => e.splitscreen,
|
||||
_ => return None,
|
||||
@@ -691,7 +655,6 @@ impl Item {
|
||||
Item::ChatIndicator => e.chat_indicator = !e.chat_indicator,
|
||||
Item::NoAi => e.no_ai = !e.no_ai,
|
||||
Item::AgentControl => e.agent_control = !e.agent_control,
|
||||
Item::ReproKeys => e.repro_keys = !e.repro_keys,
|
||||
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
|
||||
Item::Splitscreen => e.splitscreen = !e.splitscreen,
|
||||
Item::SortKey => {}
|
||||
|
||||
@@ -110,9 +110,9 @@ mod arena;
|
||||
pub mod compositor;
|
||||
mod cull;
|
||||
mod end_sky;
|
||||
pub mod entity_skin;
|
||||
mod flat;
|
||||
mod frame_gen;
|
||||
mod gpu_lists;
|
||||
mod hdr;
|
||||
mod hdr_icc;
|
||||
mod hdr_stats;
|
||||
@@ -358,6 +358,8 @@ struct SectionGpu {
|
||||
sorted_indices: Option<(u32, u64)>,
|
||||
/// Its id for flat draws (`flat::Flat::add`), when they are on.
|
||||
flat_id: Option<u32>,
|
||||
/// Its row for phase 1's lists made on the GPU (`gpu_lists::GpuLists::add`).
|
||||
list_id: Option<u32>,
|
||||
/// When it began to fade in over the far terrain it replaces, which
|
||||
/// stays whole behind it until then: a section new where far tiles are
|
||||
/// drawn. Meshed again, it stays as it was.
|
||||
@@ -448,12 +450,19 @@ impl Part {
|
||||
/// hold on `page` of `arena` (`part`) goes to its other pages in video memory,
|
||||
/// until the next range would pass `budget` bytes (it keeps what is left; the
|
||||
/// first range moves whatever its size, so none keeps the page closed), the
|
||||
/// copies into `encoder` and, in the section arena, the ranges' flat draw ids
|
||||
/// (`flat`) along. False when the other pages have no room left.
|
||||
/// copies into `encoder` and, in the section arena, the ranges' flat draw ids and
|
||||
/// GPU list rows (`ids`) along. False when the other pages have no room left.
|
||||
/// The flat draws and GPU lists that name a mesh's range, with what writes to them.
|
||||
type MeshIds<'a> = (
|
||||
Option<&'a mut flat::Flat>,
|
||||
Option<&'a mut gpu_lists::GpuLists>,
|
||||
&'a wgpu::Device,
|
||||
&'a mut upload::Uploads,
|
||||
);
|
||||
fn move_off_page<'a>(
|
||||
arena: &mut arena::Arena,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
mut flat: Option<(&mut flat::Flat, &wgpu::Device, &mut upload::Uploads)>,
|
||||
mut ids: Option<MeshIds<'_>>,
|
||||
meshes: impl Iterator<Item = &'a mut SectionGpu>,
|
||||
page: u32,
|
||||
part: Part,
|
||||
@@ -475,9 +484,14 @@ fn move_off_page<'a>(
|
||||
};
|
||||
arena.copy(encoder, (page, start), to, vertices);
|
||||
arena.release(page, start, vertices);
|
||||
if let (Some((flat, device, uploads)), Some(id)) = (&mut flat, s.flat_id) {
|
||||
flat.remove(id);
|
||||
s.flat_id = Some(flat.add(device, encoder, uploads, to.0, to.1, vertices));
|
||||
if let Some((flat, lists, device, uploads)) = &mut ids {
|
||||
if let (Some(flat), Some(id)) = (flat, s.flat_id) {
|
||||
flat.remove(id);
|
||||
s.flat_id = Some(flat.add(device, encoder, uploads, to.0, to.1, vertices));
|
||||
}
|
||||
if let (Some(lists), Some(id)) = (lists, s.list_id) {
|
||||
lists.set(device, encoder, uploads, id, to.1 as u32, &s.sides);
|
||||
}
|
||||
}
|
||||
part.set(s, to);
|
||||
*budget = budget.saturating_sub(bytes);
|
||||
@@ -628,9 +642,6 @@ 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.
|
||||
@@ -777,11 +788,12 @@ 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).
|
||||
flat_on: bool,
|
||||
/// Phase 1's solid lists made on the GPU, in flat frames (with `flat`).
|
||||
gpu_lists: Option<gpu_lists::GpuLists>,
|
||||
/// Occlusion culling of solid section draws, where the GPU's instance
|
||||
/// counts reach a multi-draw's entries (Vulkan, Metal).
|
||||
cull: Option<cull::Cull>,
|
||||
@@ -861,8 +873,6 @@ 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.
|
||||
@@ -1024,8 +1034,6 @@ pub struct Renderer {
|
||||
pack_atlas: Option<pack_atlas::PackAtlas>,
|
||||
/// The pack's terrain program reads flat draws' vertices (`pack_terrain_inputs`).
|
||||
pack_terrain_flat: bool,
|
||||
/// OptiFine/Iris separate ambient occlusion: pass vertex alpha to hz_Color.a.
|
||||
pub separate_ao: bool,
|
||||
pub hud: HudRenderer,
|
||||
/// Vanilla's menu background blur, made on first use.
|
||||
menu_blur: Option<crate::menu_blur::MenuBlur>,
|
||||
@@ -1532,64 +1540,37 @@ async fn open_device(
|
||||
api: GraphicsApi,
|
||||
layers: u32,
|
||||
) -> Result<Opened> {
|
||||
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 {
|
||||
// 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 {
|
||||
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);
|
||||
log::warn!(
|
||||
"Horizon renderer: no GPU adapter could be used ({first}); \
|
||||
rendering on the CPU, which is slow"
|
||||
);
|
||||
Ok(opened)
|
||||
}
|
||||
Err(e) => {
|
||||
first.get_or_insert(e);
|
||||
}
|
||||
}
|
||||
Err(_) => Err(first),
|
||||
},
|
||||
}
|
||||
Err(first.unwrap_or_else(|| NO_GPU_DRIVER.into()))
|
||||
}
|
||||
/// 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`.
|
||||
/// `open_device`, taking an adapter of the CPU type (software rendering) only
|
||||
/// when `allow_cpu`.
|
||||
async fn open_device_with(
|
||||
window: &Arc<Window>,
|
||||
offscreen: bool,
|
||||
order: &[wgpu::Backends],
|
||||
api: GraphicsApi,
|
||||
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);
|
||||
@@ -1844,53 +1825,6 @@ 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(
|
||||
@@ -2283,17 +2217,6 @@ 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",
|
||||
@@ -3537,14 +3460,10 @@ 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: entity_skin::EntitySkin::usage(entity_skin.is_some()),
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let flat_wanted = flat::wanted(&adapter, &device);
|
||||
@@ -3595,6 +3514,7 @@ impl Renderer {
|
||||
.get_downlevel_capabilities()
|
||||
.flags
|
||||
.contains(wgpu::DownlevelFlags::INDIRECT_EXECUTION);
|
||||
let gpu_lists = flat.as_ref().map(|_| gpu_lists::GpuLists::new(&device));
|
||||
let batchable = translucent_batchable(
|
||||
&adapter.get_info(),
|
||||
multi_draw,
|
||||
@@ -3708,8 +3628,8 @@ impl Renderer {
|
||||
solid_opaque,
|
||||
solid_cutout,
|
||||
translucent,
|
||||
translucent_mark,
|
||||
flat_on: flat.is_some(),
|
||||
gpu_lists,
|
||||
cull_on: cull.is_some(),
|
||||
sides_on: true,
|
||||
cull,
|
||||
@@ -3771,7 +3691,6 @@ impl Renderer {
|
||||
entity_buffer,
|
||||
entity_capacity,
|
||||
entity_batch: EntityBatch::default(),
|
||||
entity_skin,
|
||||
group,
|
||||
globals,
|
||||
pbr_maps,
|
||||
@@ -3859,7 +3778,6 @@ impl Renderer {
|
||||
pack_weather: None,
|
||||
pack_atlas: None,
|
||||
pack_terrain_flat: false,
|
||||
separate_ao: false,
|
||||
hud,
|
||||
menu_blur: None,
|
||||
post_effects: None,
|
||||
@@ -3898,31 +3816,6 @@ 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) {
|
||||
@@ -4283,6 +4176,11 @@ impl Renderer {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let sides = if mesh.sides.quads() == solid {
|
||||
mesh.sides
|
||||
} else {
|
||||
Sides::unsorted(solid)
|
||||
};
|
||||
// Flat draws name the blocks of its range in `arena`: split, its solid
|
||||
// quads alone (no frame draws flat then: `translucent_split`).
|
||||
let flat_id = self.flat.as_mut().filter(|_| main > 0).map(|f| {
|
||||
@@ -4295,6 +4193,15 @@ impl Renderer {
|
||||
main,
|
||||
)
|
||||
});
|
||||
let list_id = self.gpu_lists.as_mut().filter(|_| main > 0).map(|l| {
|
||||
l.add(
|
||||
&self.device,
|
||||
encoder,
|
||||
&mut self.section_uploads,
|
||||
start as u32,
|
||||
&sides,
|
||||
)
|
||||
});
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let translucent_arena = self.translucent_arena.as_ref().unwrap_or(&self.arena);
|
||||
for (part, arena, (part_page, first)) in [
|
||||
@@ -4325,11 +4232,7 @@ impl Renderer {
|
||||
start,
|
||||
translucent_at,
|
||||
solid,
|
||||
sides: if mesh.sides.quads() == solid {
|
||||
mesh.sides
|
||||
} else {
|
||||
Sides::unsorted(solid)
|
||||
},
|
||||
sides,
|
||||
translucent,
|
||||
sort: translucency::SortState::new(
|
||||
mesh.translucent
|
||||
@@ -4341,6 +4244,7 @@ impl Renderer {
|
||||
),
|
||||
sorted_indices,
|
||||
flat_id,
|
||||
list_id,
|
||||
fading: None,
|
||||
leaving: None,
|
||||
animated_layers,
|
||||
@@ -4413,6 +4317,9 @@ impl Renderer {
|
||||
if let (Some(flat), Some(id)) = (&mut self.flat, s.flat_id) {
|
||||
flat.remove(id);
|
||||
}
|
||||
if let (Some(lists), Some(id)) = (&mut self.gpu_lists, s.list_id) {
|
||||
lists.remove(id);
|
||||
}
|
||||
if let Some((page, start)) = s.sorted_indices {
|
||||
self.sorted_indices
|
||||
.release(page, start, u64::from(s.translucent) * 6);
|
||||
@@ -4455,13 +4362,15 @@ impl Renderer {
|
||||
.sections
|
||||
.values_mut()
|
||||
.chain(self.lods.values_mut().flat_map(|tile| tile.meshes_mut()));
|
||||
let flat = self
|
||||
.flat
|
||||
.as_mut()
|
||||
.map(|f| (f, &self.device, &mut self.section_uploads));
|
||||
let ids = Some((
|
||||
self.flat.as_mut(),
|
||||
self.gpu_lists.as_mut(),
|
||||
&self.device,
|
||||
&mut self.section_uploads,
|
||||
));
|
||||
let split = self.translucent_arena.is_some();
|
||||
let (arena, part) = (&mut self.arena, Part::Section { split });
|
||||
if !move_off_page(arena, encoder, flat, meshes, page, part, &mut budget) {
|
||||
if !move_off_page(arena, encoder, ids, meshes, page, part, &mut budget) {
|
||||
// The other pages have no room after all.
|
||||
arena.reopen();
|
||||
}
|
||||
@@ -4767,6 +4676,11 @@ impl Renderer {
|
||||
flat.remove(id);
|
||||
}
|
||||
}
|
||||
if let Some(lists) = &mut self.gpu_lists {
|
||||
for id in released.iter().filter_map(|s| s.list_id) {
|
||||
lists.remove(id);
|
||||
}
|
||||
}
|
||||
self.sorted_indices
|
||||
.release_all(released.iter().filter_map(|s| {
|
||||
let (page, start) = s.sorted_indices?;
|
||||
@@ -4967,23 +4881,6 @@ 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
|
||||
}
|
||||
@@ -5078,16 +4975,6 @@ impl Renderer {
|
||||
self.pack_post = None;
|
||||
self.native = None;
|
||||
self.pack_atlas = None;
|
||||
self.separate_ao = false;
|
||||
}
|
||||
/// Sets whether shader pack terrain inputs pass vertex alpha to `hz_Color.a` (`separateAo=true`).
|
||||
pub fn set_separate_ao(&mut self, separate_ao: bool) {
|
||||
if self.separate_ao != separate_ao {
|
||||
self.separate_ao = separate_ao;
|
||||
if let Some(atlas) = &mut self.pack_atlas {
|
||||
atlas.separate_ao = separate_ao;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Runs the sky, material and post stages of one of Horizon's own shader packs from now
|
||||
/// on (`None`: no pack), with its options' values; replaces a `shaders/` pack. Returns the
|
||||
@@ -5170,7 +5057,7 @@ impl Renderer {
|
||||
.iter()
|
||||
.map(|t| t.depth_or_array_layers())
|
||||
.sum();
|
||||
let mut atlas = pack_atlas::PackAtlas::new(
|
||||
self.pack_atlas = pack_atlas::PackAtlas::new(
|
||||
&self.device,
|
||||
&mut encoder,
|
||||
&self.sprite_textures,
|
||||
@@ -5178,10 +5065,6 @@ impl Renderer {
|
||||
layers,
|
||||
self.sprite_textures[0].width(),
|
||||
);
|
||||
if let Some(atlas) = &mut atlas {
|
||||
atlas.separate_ao = self.separate_ao;
|
||||
}
|
||||
self.pack_atlas = atlas;
|
||||
self.queue.submit([encoder.finish()]);
|
||||
}
|
||||
// Flat draws whenever the device has them (`render` keeps them on with a pack).
|
||||
@@ -6156,10 +6039,40 @@ impl Renderer {
|
||||
(true, true) => &[(1, Quads::All)],
|
||||
}
|
||||
};
|
||||
// Phase 1's sections can leave finding their facing quads to the GPU
|
||||
// (`gpu_lists`) in frames drawn flat with nothing but the camera's
|
||||
// terrain: no mirror, shader pack terrain or shadow, nor tiles.
|
||||
let use_lists = gpu_lists_on()
|
||||
&& !pack_terrain
|
||||
&& shadow_view.is_none()
|
||||
&& mirror.is_none()
|
||||
&& visible_lods.is_empty()
|
||||
&& self.flat_on
|
||||
&& (self.flat.as_ref()).is_some_and(|f| self.drawn_sections >= f.min_sections);
|
||||
if let Some(lists) = &mut self.gpu_lists {
|
||||
lists.clear(&self.device);
|
||||
}
|
||||
let mut lists = self.gpu_lists.as_mut().filter(|_| use_lists);
|
||||
let mut opaque_draws = 0;
|
||||
for (i, v) in visible.iter().enumerate() {
|
||||
let s = self.sections.get(v.slot);
|
||||
let first_phase = cull.as_ref().is_none_or(|c| c.first(v.slot));
|
||||
if first_phase
|
||||
&& let (Some(lists), Some(id)) = (&mut lists, s.list_id)
|
||||
&& lists.list(
|
||||
id,
|
||||
s.page,
|
||||
v.distance,
|
||||
&s.sides,
|
||||
parts(fade_code(s, &v.key) != 0),
|
||||
v.origin,
|
||||
)
|
||||
{
|
||||
if let Some(c) = &mut cull {
|
||||
c.drawn(i, v.slot, s.sides.quads() * 6);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (draws, pages) = if first_phase {
|
||||
(&mut self.draws, &mut self.draw_pages)
|
||||
} else {
|
||||
@@ -6569,13 +6482,6 @@ 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
|
||||
@@ -6631,7 +6537,7 @@ impl Renderer {
|
||||
camera,
|
||||
);
|
||||
// Frame generation's made frame draws its own hand, halfway, after the others.
|
||||
let made_hand_vertex = (posed as usize + self.entity_batch.combined.len()) as i32;
|
||||
let made_hand_vertex = self.entity_batch.combined.len() as i32;
|
||||
if fg {
|
||||
self.frame_gen
|
||||
.halfway_hand(frame.hand, hand_view, &mut self.entity_batch.combined);
|
||||
@@ -6648,32 +6554,22 @@ 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 = u64::from(posed) + all_entities.len() as u64;
|
||||
let entity_vertices = 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: entity_skin::EntitySkin::usage(self.entity_skin.is_some()),
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
}
|
||||
if !all_entities.is_empty() {
|
||||
if entity_vertices > 0 {
|
||||
self.uploads.buffer(
|
||||
&mut encoder,
|
||||
&self.entity_buffer,
|
||||
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,
|
||||
bytemuck::cast_slice(all_entities),
|
||||
);
|
||||
}
|
||||
self.entity_outline.prepare(
|
||||
@@ -6786,7 +6682,8 @@ impl Renderer {
|
||||
let flat_frame = match &mut self.flat {
|
||||
Some(flat)
|
||||
if self.flat_on
|
||||
&& !self.draws.is_empty()
|
||||
&& (!self.draws.is_empty()
|
||||
|| self.gpu_lists.as_ref().is_some_and(|l| l.listed() > 0))
|
||||
&& (self.drawn_sections >= flat.min_sections
|
||||
|| pack_terrain
|
||||
|| shadow_view.is_some()) =>
|
||||
@@ -6825,6 +6722,8 @@ impl Renderer {
|
||||
Some((id, lod::instance(v.origin, v.fade)))
|
||||
})),
|
||||
|page| sorted.buffer(page).clone(),
|
||||
self.gpu_lists.as_mut().filter(|l| l.listed() > 0),
|
||||
eye.map(|e| e.to_array()),
|
||||
self.own_timing.as_mut(),
|
||||
)
|
||||
})
|
||||
@@ -6832,7 +6731,8 @@ impl Renderer {
|
||||
_ => false,
|
||||
};
|
||||
if flat_frame {
|
||||
self.section_draw_calls = self.flat.as_ref().map_or(0, |f| f.shadow_calls[0]);
|
||||
self.section_draw_calls = self.flat.as_ref().map_or(0, |f| f.shadow_calls[0])
|
||||
+ self.gpu_lists.as_ref().map_or(0, |l| l.calls().len());
|
||||
} else if self.multi_draw && !self.draws.is_empty() {
|
||||
if self.draws.len() as u64 > self.indirect_capacity {
|
||||
self.indirect_capacity = (self.draws.len() as u64).next_power_of_two();
|
||||
@@ -7433,19 +7333,26 @@ impl Renderer {
|
||||
// Without a pack, on Metal, the opaque pass, then the cutout one
|
||||
// (terrain.wgsl's `opaque_quad`), each over phase 1's lists with
|
||||
// quads for it, and over all of phase 2's.
|
||||
// Each pass's GPU-made lists by group, too (`flat::Flat::list`).
|
||||
let [opaque, cutout] = flat.pass_calls;
|
||||
let passes: &[(&wgpu::RenderPipeline, std::ops::Range<usize>)] = match pack_flat
|
||||
{
|
||||
Some(p) => &[(&p.0, 0..flat.phase1_calls)],
|
||||
type Passes<'p> =
|
||||
&'p [(&'p wgpu::RenderPipeline, std::ops::Range<usize>, &'p [u8])];
|
||||
let passes: Passes = match pack_flat {
|
||||
Some(p) => &[(&p.0, 0..flat.phase1_calls, &[0, 1])],
|
||||
None if split_solid => &[
|
||||
(&flat.solid_opaque, 0..opaque),
|
||||
(&flat.solid_cutout, cutout..flat.phase1_calls),
|
||||
(&flat.solid_opaque, 0..opaque, &[0]),
|
||||
(&flat.solid_cutout, cutout..flat.phase1_calls, &[1]),
|
||||
],
|
||||
None => &[(&flat.solid, 0..flat.phase1_calls)],
|
||||
None => &[(&flat.solid, 0..flat.phase1_calls, &[0, 1])],
|
||||
};
|
||||
let draw = |pass: &mut wgpu::RenderPass<'_>| {
|
||||
for (pipeline, calls) in passes {
|
||||
for (pipeline, calls, groups) in passes {
|
||||
pass.set_pipeline(pipeline);
|
||||
if let Some(lists) = &self.gpu_lists {
|
||||
for &group in *groups {
|
||||
flat.draw_lists(pass, lists, group, vertices);
|
||||
}
|
||||
}
|
||||
flat.draw(pass, &flat.calls[calls.clone()], vertices);
|
||||
}
|
||||
};
|
||||
@@ -7475,7 +7382,7 @@ impl Renderer {
|
||||
};
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pack_frame(&mut pass);
|
||||
for (pipeline, _) in passes {
|
||||
for (pipeline, ..) in passes {
|
||||
pass.set_pipeline(pipeline);
|
||||
flat.draw_phase2(&mut pass, vertices);
|
||||
}
|
||||
@@ -7939,7 +7846,6 @@ 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));
|
||||
@@ -7948,12 +7854,6 @@ 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);
|
||||
@@ -7966,7 +7866,6 @@ impl Renderer {
|
||||
solid_draws,
|
||||
translucent_draws,
|
||||
);
|
||||
self.mark_translucent_terrain(&mut pass, None, solid_draws, translucent_draws);
|
||||
}
|
||||
}
|
||||
// LevelRenderer.executeTranslucentAfterTerrain: vanilla's
|
||||
@@ -8704,9 +8603,8 @@ 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, software))
|
||||
.get_or_insert_with(|| crate::menu_blur::MenuBlur::new(device, format))
|
||||
.apply(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
@@ -9136,6 +9034,12 @@ fn projection_center(view_proj: Mat4) -> Option<Vec3> {
|
||||
fn facing_runs(sides: &Sides, eye: Option<Vec3>, origin: [f32; 3], draw: impl FnMut(u32, u32)) {
|
||||
facing_runs_from(sides, eye, None, origin, Quads::All, draw)
|
||||
}
|
||||
/// Whether phase 1's solid lists may be made on the GPU (`flat::Flat::list`);
|
||||
/// `HORIZON_GPU_LISTS=0` keeps them on the CPU.
|
||||
fn gpu_lists_on() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| std::env::var("HORIZON_GPU_LISTS").as_deref() != Ok("0"))
|
||||
}
|
||||
/// `facing_runs` for the eye and, with `low`, every eye straight below it down to that
|
||||
/// camera-relative height (the water mirror's eye).
|
||||
fn facing_runs_from(
|
||||
@@ -9495,37 +9399,6 @@ 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).
|
||||
|
||||
@@ -836,6 +836,7 @@ mod tests {
|
||||
sort: super::super::translucency::SortState::new(Vec::new()),
|
||||
sorted_indices: None,
|
||||
flat_id: None,
|
||||
list_id: None,
|
||||
fading: None,
|
||||
leaving: None,
|
||||
animated_layers: Box::new([]),
|
||||
@@ -1119,6 +1120,7 @@ mod tests {
|
||||
sort: super::super::translucency::SortState::new(Vec::new()),
|
||||
sorted_indices: (translucent > 0).then_some(if indices { at } else { (7, 7) }),
|
||||
flat_id: None,
|
||||
list_id: None,
|
||||
fading: None,
|
||||
leaving: None,
|
||||
animated_layers: Box::new([]),
|
||||
|
||||
@@ -1,511 +0,0 @@
|
||||
//! 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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
// 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,15 +55,6 @@ 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 {
|
||||
@@ -83,7 +74,6 @@ 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,
|
||||
@@ -233,6 +223,7 @@ fn buffer(
|
||||
/// from `shadow[0]` (solid) and `shadow[1]` (translucent) on are a shader
|
||||
/// pack's shadow camera's, with calls of their own. Phase 1's draws before
|
||||
/// `passes[0]` and from `passes[1]` on, the two solid passes' on Metal, too.
|
||||
/// The list starts at index `base` (the GPU-made lists' rooms come first: `gpu_lists`).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn plan(
|
||||
draws: &[[u32; 5]],
|
||||
@@ -245,6 +236,7 @@ fn plan(
|
||||
calls: &mut Vec<Call>,
|
||||
ranges: &mut Vec<Range>,
|
||||
segments: &mut Vec<(Source, u64, u64)>,
|
||||
base: u64,
|
||||
) -> Plan {
|
||||
calls.clear();
|
||||
ranges.clear();
|
||||
@@ -252,7 +244,7 @@ fn plan(
|
||||
let (mut phase1_calls, mut solid_calls) = (None, None);
|
||||
let mut shadow_calls = [None; 2];
|
||||
let mut pass_calls = [None; 2];
|
||||
let mut total = 0u64;
|
||||
let mut total = base;
|
||||
let mut by_source: Vec<(Source, Vec<Range>)> = Vec::new();
|
||||
for (i, (d, &(page, sorted))) in draws.iter().zip(pages).enumerate() {
|
||||
if i == phase1_draws {
|
||||
@@ -428,14 +420,6 @@ 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,
|
||||
@@ -558,6 +542,8 @@ impl Flat {
|
||||
phase2: Option<&wgpu::Buffer>,
|
||||
origins: impl Iterator<Item = (u32, [f32; 4])>,
|
||||
sorted_page: impl Fn(u32) -> wgpu::Buffer,
|
||||
mut lists: Option<&mut super::gpu_lists::GpuLists>,
|
||||
eye: Option<[f32; 3]>,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) -> bool {
|
||||
self.phase2_group = None;
|
||||
@@ -595,6 +581,8 @@ impl Flat {
|
||||
bytemuck::cast_slice(&self.origin_data),
|
||||
);
|
||||
}
|
||||
// The GPU-made lists' rooms come first in the index list.
|
||||
let room = lists.as_mut().map_or(0, |l| l.rooms());
|
||||
let Plan {
|
||||
phase1_calls,
|
||||
solid_calls,
|
||||
@@ -612,6 +600,7 @@ impl Flat {
|
||||
&mut self.calls,
|
||||
&mut self.range_data,
|
||||
&mut self.segments,
|
||||
room,
|
||||
);
|
||||
self.phase1_calls = phase1_calls;
|
||||
self.solid_calls = solid_calls;
|
||||
@@ -674,6 +663,9 @@ impl Flat {
|
||||
bytemuck::cast_slice(&self.phase2_call_data),
|
||||
);
|
||||
}
|
||||
if let Some(lists) = &mut lists {
|
||||
lists.prepare(device, encoder, uploads, &self.indices, eye);
|
||||
}
|
||||
let ranges_at = |first: u64, count: u64| {
|
||||
wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &self.ranges,
|
||||
@@ -743,13 +735,17 @@ impl Flat {
|
||||
label: Some("Flatten section draws"),
|
||||
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten")),
|
||||
});
|
||||
if let Some(lists) = &lists {
|
||||
lists.dispatch(&mut pass);
|
||||
}
|
||||
pass.set_pipeline(&self.flatten);
|
||||
for (group, count) in &groups {
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
|
||||
}
|
||||
drop(pass);
|
||||
for page in self.calls.iter().map(|c| c.page) {
|
||||
let list_pages = lists.iter().flat_map(|l| l.calls().iter().map(|c| c.page));
|
||||
for page in self.calls.iter().map(|c| c.page).chain(list_pages) {
|
||||
let table = self.pages[page as usize]
|
||||
.as_mut()
|
||||
.expect("a drawn page has its block table");
|
||||
@@ -790,6 +786,27 @@ impl Flat {
|
||||
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
|
||||
}
|
||||
|
||||
/// Draws pass `group`'s calls of `lists` (`gpu_lists::Out::Indices`, prepared with
|
||||
/// this frame's `prepare`) with a flat pipeline set.
|
||||
pub fn draw_lists<'a>(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
lists: &super::gpu_lists::GpuLists,
|
||||
group: u8,
|
||||
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
|
||||
) {
|
||||
if !lists.prepared() {
|
||||
return;
|
||||
}
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
for (k, call) in lists.calls().iter().enumerate() {
|
||||
if call.group == group {
|
||||
self.bind_page(pass, call.page, &vertices);
|
||||
lists.draw_indexed(pass, k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The camera's translucent calls.
|
||||
pub fn translucent_calls(&self) -> &[Call] {
|
||||
&self.calls[self.solid_calls..self.shadow_calls[0]]
|
||||
@@ -915,6 +932,7 @@ mod tests {
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
0,
|
||||
);
|
||||
assert_eq!(total, 6 * 13);
|
||||
assert_eq!(phase1_calls, solid_calls);
|
||||
@@ -963,6 +981,7 @@ mod tests {
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
0,
|
||||
);
|
||||
assert_eq!((plan.pass_calls, plan.solid_calls), ([3, 1], 4));
|
||||
let call = |page, first, count| Call { page, first, count };
|
||||
@@ -998,6 +1017,7 @@ mod tests {
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
0,
|
||||
);
|
||||
assert_eq!((plan.solid_calls, plan.shadow_calls), (3, [4, 5]));
|
||||
let call = |page, first, count| Call { page, first, count };
|
||||
@@ -1026,6 +1046,7 @@ mod tests {
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
0,
|
||||
);
|
||||
assert_eq!(
|
||||
plan,
|
||||
@@ -1143,6 +1164,7 @@ mod tests {
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
0,
|
||||
);
|
||||
// Phase 2's draws as the test leaves them: instance counts of 0,
|
||||
// 1 where it saw the section.
|
||||
|
||||
@@ -0,0 +1,639 @@
|
||||
//! Phase 1's solid section draws made on the GPU (gpu_lists.wgsl).
|
||||
//!
|
||||
//! Each frame the render thread walked every section phase 1 draws through
|
||||
//! `mesher::Sides::visible_from` to find the runs of its quads that face the eye,
|
||||
//! and pushed one draw per run. Here it pushes one record per section instead:
|
||||
//! its row (where its quads start and how `Sides` sorted them, uploaded with its
|
||||
//! mesh), the call each of its parts draws in and its camera-relative origin. The
|
||||
//! GPU finds the same runs and writes their indices, per call (one per pass
|
||||
//! group and arena page), into the flat draws' index list, drawn with
|
||||
//! `draw_indexed_indirect`. Calls take room for all the quads of their sections'
|
||||
//! parts; the GPU counts the indices it wrote.
|
||||
//!
|
||||
//! Solid draws write depth and are tested against it, so their order within a
|
||||
//! call does not change the picture.
|
||||
use crate::mesher::{Part, Sides};
|
||||
|
||||
/// Words per row: first vertex, `Sides::gpu_row`, padding.
|
||||
const ROW: usize = 24;
|
||||
/// Most workgroups along one dimension of a dispatch.
|
||||
const GROUP_LIMIT: u32 = 65535;
|
||||
|
||||
/// A part of a section in a record: `Part` as gpu_lists.wgsl names it.
|
||||
fn part_code(part: Part) -> u32 {
|
||||
match part {
|
||||
Part::All => 0,
|
||||
Part::Opaque => 1,
|
||||
Part::Cutout => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// One of the frame's calls: what pass `group` draws of arena `page`, from
|
||||
/// index `first` with room for `count`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) struct ListCall {
|
||||
pub group: u8,
|
||||
pub page: u32,
|
||||
pub first: u32,
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
pub(super) struct GpuLists {
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
/// Per id: its first vertex and `Sides::gpu_row` (`ROW` words).
|
||||
rows: wgpu::Buffer,
|
||||
row_capacity: u64,
|
||||
free_ids: Vec<u32>,
|
||||
next_id: u32,
|
||||
/// The frame's sections: id, the call and part of each of its parts, its
|
||||
/// number of parts; then its origin.
|
||||
records: Vec<[u32; 8]>,
|
||||
record_buffer: wgpu::Buffer,
|
||||
record_capacity: u64,
|
||||
calls: Vec<ListCall>,
|
||||
/// `calls`' index by page and group in the band of distance the last
|
||||
/// section listed is in (`u32::MAX`: none yet).
|
||||
call_of: Vec<u32>,
|
||||
band: u32,
|
||||
call_data: Vec<[u32; 5]>,
|
||||
/// Per call `DrawIndexedIndirectArgs`; the GPU adds what it writes to the count.
|
||||
call_buffer: wgpu::Buffer,
|
||||
call_capacity: u64,
|
||||
view: wgpu::Buffer,
|
||||
group: Option<(wgpu::BindGroup, u32)>,
|
||||
/// The indices the frame's calls take, and the most they may.
|
||||
room: u64,
|
||||
cap: u64,
|
||||
}
|
||||
|
||||
fn buffer(
|
||||
device: &wgpu::Device,
|
||||
label: &str,
|
||||
size: u64,
|
||||
usage: wgpu::BufferUsages,
|
||||
) -> wgpu::Buffer {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(label),
|
||||
size,
|
||||
// COPY_SRC: rows move to a larger buffer; tests read calls back.
|
||||
usage: usage | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn entry(binding: u32, ty: wgpu::BufferBindingType) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
}
|
||||
}
|
||||
|
||||
impl GpuLists {
|
||||
pub fn new(device: &wgpu::Device) -> Self {
|
||||
let read = wgpu::BufferBindingType::Storage { read_only: true };
|
||||
let write = wgpu::BufferBindingType::Storage { read_only: false };
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Section lists"),
|
||||
entries: &[
|
||||
entry(0, read),
|
||||
entry(1, read),
|
||||
entry(2, wgpu::BufferBindingType::Uniform),
|
||||
entry(3, write),
|
||||
entry(4, write),
|
||||
],
|
||||
});
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Section lists"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("gpu_lists.wgsl").into()),
|
||||
});
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("Section lists"),
|
||||
layout: Some(
|
||||
&device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Section lists"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
}),
|
||||
),
|
||||
module: &module,
|
||||
entry_point: Some("flatten_sides"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let storage = wgpu::BufferUsages::STORAGE;
|
||||
let indirect = wgpu::BufferUsages::INDIRECT | storage;
|
||||
Self {
|
||||
pipeline,
|
||||
layout,
|
||||
rows: buffer(device, "Section rows", ROW as u64 * 4, storage),
|
||||
row_capacity: 1,
|
||||
free_ids: Vec::new(),
|
||||
next_id: 0,
|
||||
records: Vec::new(),
|
||||
record_buffer: buffer(device, "Sections listed", 32, storage),
|
||||
record_capacity: 1,
|
||||
calls: Vec::new(),
|
||||
call_of: Vec::new(),
|
||||
band: 0,
|
||||
call_data: Vec::new(),
|
||||
call_buffer: buffer(device, "Section list calls", 20, indirect),
|
||||
call_capacity: 1,
|
||||
view: buffer(
|
||||
device,
|
||||
"Section lists' eye",
|
||||
16,
|
||||
wgpu::BufferUsages::UNIFORM,
|
||||
),
|
||||
group: None,
|
||||
room: 0,
|
||||
cap: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A mesh uploaded from vertex `start` of its page, its solid quads sorted as
|
||||
/// `sides` says: its id, whose row is written through `uploads` in `encoder`.
|
||||
pub fn add(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
uploads: &mut super::upload::Uploads,
|
||||
start: u32,
|
||||
sides: &Sides,
|
||||
) -> u32 {
|
||||
let id = self.free_ids.pop().unwrap_or_else(|| {
|
||||
self.next_id += 1;
|
||||
self.next_id - 1
|
||||
});
|
||||
self.set(device, encoder, uploads, id, start, sides);
|
||||
id
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, id: u32) {
|
||||
self.free_ids.push(id);
|
||||
}
|
||||
|
||||
/// Mesh `id` now starts at vertex `start` (moved to another page).
|
||||
pub fn set(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
uploads: &mut super::upload::Uploads,
|
||||
id: u32,
|
||||
start: u32,
|
||||
sides: &Sides,
|
||||
) {
|
||||
let need = u64::from(id) + 1;
|
||||
if need > self.row_capacity {
|
||||
let capacity = need.next_power_of_two().max(1024);
|
||||
let rows = buffer(
|
||||
device,
|
||||
"Section rows",
|
||||
capacity * ROW as u64 * 4,
|
||||
wgpu::BufferUsages::STORAGE,
|
||||
);
|
||||
// Earlier rows: uploads record their copies in `encoder`, so this one follows them.
|
||||
encoder.copy_buffer_to_buffer(
|
||||
&self.rows,
|
||||
0,
|
||||
&rows,
|
||||
0,
|
||||
self.row_capacity * ROW as u64 * 4,
|
||||
);
|
||||
self.rows = rows;
|
||||
self.row_capacity = capacity;
|
||||
}
|
||||
let mut row = [0u32; ROW];
|
||||
row[0] = start;
|
||||
row[1..23].copy_from_slice(&sides.gpu_row());
|
||||
uploads.buffer(
|
||||
encoder,
|
||||
&self.rows,
|
||||
u64::from(id) * ROW as u64 * 4,
|
||||
bytemuck::cast_slice(&row),
|
||||
);
|
||||
}
|
||||
|
||||
/// Starts the frame's lists.
|
||||
pub fn clear(&mut self, device: &wgpu::Device) {
|
||||
self.records.clear();
|
||||
self.calls.clear();
|
||||
self.call_of.fill(u32::MAX);
|
||||
self.band = 0;
|
||||
self.room = 0;
|
||||
self.group = None;
|
||||
// A quarter of the binding they share with the CPU's draws.
|
||||
self.cap = device.limits().max_storage_buffer_binding_size / 16;
|
||||
}
|
||||
|
||||
/// Section `id`, in `page`, at `origin` from the camera and `distance`
|
||||
/// (squared, as bits) from it: each of `parts` (pass group, part) draws the
|
||||
/// quads of the part that face the eye. False, listing nothing, when the
|
||||
/// frame's lists hold all the room they may: the caller draws it.
|
||||
///
|
||||
/// Sections come nearest first. Each band of distance gets calls of its own,
|
||||
/// drawn in that order, as the CPU's lists keep them (`page_runs`): within
|
||||
/// a call the GPU writes sections in no set order, and cutout quads, which
|
||||
/// tile GPUs can't hide before shading, then draw mostly front to back.
|
||||
pub fn list(
|
||||
&mut self,
|
||||
id: u32,
|
||||
page: u32,
|
||||
distance: u64,
|
||||
sides: &Sides,
|
||||
parts: &[(u8, Part)],
|
||||
origin: [f32; 3],
|
||||
) -> bool {
|
||||
let parts = &parts[..parts.len().min(2)];
|
||||
let mut counts = [0; 2];
|
||||
for (count, &(_, part)) in counts.iter_mut().zip(parts) {
|
||||
*count = sides.part_quads(part) * 6;
|
||||
}
|
||||
let room = u64::from(counts[0]) + u64::from(counts[1]);
|
||||
if self.room + room > self.cap {
|
||||
return false;
|
||||
}
|
||||
self.room += room;
|
||||
let band = super::page_runs::band(distance) as u32;
|
||||
if band != self.band {
|
||||
self.band = band;
|
||||
self.call_of.fill(u32::MAX);
|
||||
}
|
||||
let mut record = [id, 0, 0, parts.len() as u32, 0, 0, 0, 0];
|
||||
for (k, &(group, part)) in parts.iter().enumerate() {
|
||||
let key = page as usize * 2 + usize::from(group.min(1));
|
||||
if self.call_of.len() <= key {
|
||||
self.call_of.resize(key + 1, u32::MAX);
|
||||
}
|
||||
if self.call_of[key] == u32::MAX {
|
||||
self.call_of[key] = self.calls.len() as u32;
|
||||
self.calls.push(ListCall {
|
||||
group,
|
||||
page,
|
||||
first: 0,
|
||||
count: 0,
|
||||
});
|
||||
}
|
||||
let call = self.call_of[key];
|
||||
self.calls[call as usize].count += counts[k];
|
||||
record[1 + k] = call | part_code(part) << 28;
|
||||
}
|
||||
for (to, o) in record[4..7].iter_mut().zip(origin) {
|
||||
*to = o.to_bits();
|
||||
}
|
||||
self.records.push(record);
|
||||
true
|
||||
}
|
||||
|
||||
/// The sections listed this frame.
|
||||
pub fn listed(&self) -> usize {
|
||||
self.records.len()
|
||||
}
|
||||
|
||||
/// The frame's calls, with their rooms once laid out (`rooms`).
|
||||
pub fn calls(&self) -> &[ListCall] {
|
||||
&self.calls
|
||||
}
|
||||
|
||||
/// Lays the calls' rooms out one after the other from the start of the
|
||||
/// index list: the indices they take.
|
||||
pub fn rooms(&mut self) -> u64 {
|
||||
let mut room = 0u64;
|
||||
for call in &mut self.calls {
|
||||
call.first = room as u32;
|
||||
room += u64::from(call.count);
|
||||
}
|
||||
room
|
||||
}
|
||||
|
||||
/// Stages the frame's sections, calls and eye (camera-relative; none: every
|
||||
/// quad faces it), and binds them for `dispatch` writing into `indices`.
|
||||
/// After `rooms`.
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
uploads: &mut super::upload::Uploads,
|
||||
indices: &wgpu::Buffer,
|
||||
eye: Option<[f32; 3]>,
|
||||
) {
|
||||
self.group = None;
|
||||
let records = self.records.len() as u64;
|
||||
if records == 0 {
|
||||
return;
|
||||
}
|
||||
if records > self.record_capacity {
|
||||
self.record_capacity = records.next_power_of_two();
|
||||
self.record_buffer = buffer(
|
||||
device,
|
||||
"Sections listed",
|
||||
self.record_capacity * 32,
|
||||
wgpu::BufferUsages::STORAGE,
|
||||
);
|
||||
}
|
||||
uploads.buffer(
|
||||
encoder,
|
||||
&self.record_buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&self.records),
|
||||
);
|
||||
self.call_data.clear();
|
||||
self.call_data
|
||||
.extend(self.calls.iter().map(|c| [0, 1, c.first, 0, 0]));
|
||||
let calls = self.call_data.len() as u64;
|
||||
let indirect = wgpu::BufferUsages::INDIRECT | wgpu::BufferUsages::STORAGE;
|
||||
if calls > self.call_capacity {
|
||||
self.call_capacity = calls.next_power_of_two();
|
||||
self.call_buffer = buffer(
|
||||
device,
|
||||
"Section list calls",
|
||||
self.call_capacity * 20,
|
||||
indirect,
|
||||
);
|
||||
}
|
||||
uploads.buffer(
|
||||
encoder,
|
||||
&self.call_buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&self.call_data),
|
||||
);
|
||||
let [x, y, z] = eye.unwrap_or_default();
|
||||
let view = [x, y, z, if eye.is_some() { 1.0 } else { 0.0 }];
|
||||
uploads.buffer(encoder, &self.view, 0, bytemuck::cast_slice(&view));
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Section lists"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &self.record_buffer,
|
||||
offset: 0,
|
||||
size: wgpu::BufferSize::new(records * 32),
|
||||
}),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: self.rows.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: self.view.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: self.call_buffer.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: indices.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
self.group = Some((group, records as u32));
|
||||
}
|
||||
|
||||
/// Writes the frame's lists (after `prepare`).
|
||||
pub fn dispatch(&self, pass: &mut wgpu::ComputePass<'_>) {
|
||||
let Some((group, records)) = &self.group else {
|
||||
return;
|
||||
};
|
||||
let groups = *records;
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.dispatch_workgroups(groups.min(GROUP_LIMIT), groups.div_ceil(GROUP_LIMIT), 1);
|
||||
}
|
||||
|
||||
/// Whether `dispatch` writes this frame's lists.
|
||||
pub fn prepared(&self) -> bool {
|
||||
self.group.is_some()
|
||||
}
|
||||
|
||||
/// Draws call `k` with a pipeline, its page's vertices and the index list bound.
|
||||
pub fn draw_indexed(&self, pass: &mut wgpu::RenderPass<'_>, k: usize) {
|
||||
pass.draw_indexed_indirect(&self.call_buffer, k as u64 * 20);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mesher::{Part, Sides};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
struct Section {
|
||||
sides: Sides,
|
||||
origin: [f32; 3],
|
||||
start: u32,
|
||||
page: u32,
|
||||
parts: &'static [(u8, Part)],
|
||||
}
|
||||
|
||||
/// Random sorts: some parts empty, grouped or not, split or not, planes in
|
||||
/// and out of a section, each listed in one of the three ways it can be.
|
||||
fn sections(count: usize) -> Vec<Section> {
|
||||
let mut seed = 0x2545_f491_4f6c_dd1du64;
|
||||
let mut next = |n: u32| {
|
||||
seed = seed
|
||||
.wrapping_mul(6_364_136_223_846_793_005)
|
||||
.wrapping_add(1_442_695_040_888_963_407);
|
||||
((seed >> 33) % u64::from(n)) as u32
|
||||
};
|
||||
(0..count)
|
||||
.map(|_| {
|
||||
let mut ends = [0; 14];
|
||||
let mut end = 0;
|
||||
for e in &mut ends {
|
||||
if next(3) > 0 {
|
||||
end += next(40);
|
||||
}
|
||||
*e = end;
|
||||
}
|
||||
let planes = std::array::from_fn(|_| next(17) as f32 + next(16) as f32 / 16.0);
|
||||
Section {
|
||||
sides: Sides::from_parts(ends, next(2) == 0, next(4) > 0, planes),
|
||||
origin: [0, 1, 2].map(|_| next(129) as f32 - 64.0),
|
||||
start: next(10_000) * 4,
|
||||
page: next(3),
|
||||
parts: match next(3) {
|
||||
0 => &[(0, Part::All)],
|
||||
1 => &[(0, Part::Opaque), (1, Part::Cutout)],
|
||||
_ => &[(1, Part::All)],
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The runs the CPU finds for a section's part, as `Gfx` drew them.
|
||||
fn cpu_runs(s: &Section, part: Part, eye: Option<[f32; 3]>) -> Vec<(u32, u32)> {
|
||||
let mut runs = Vec::new();
|
||||
let draw = |first, quads| runs.push((first, quads));
|
||||
match eye {
|
||||
Some(eye) => {
|
||||
let eye = [0, 1, 2].map(|a| eye[a] - s.origin[a]);
|
||||
s.sides.visible_from(eye, eye[1], part, draw);
|
||||
}
|
||||
None => s.sides.every(part, draw),
|
||||
}
|
||||
runs
|
||||
}
|
||||
|
||||
/// Lists `sections` from each of a few eyes; per eye, the lists, the
|
||||
/// calls' words and the indices written.
|
||||
fn run(sections: &[Section]) -> Option<Vec<(Option<[f32; 3]>, GpuLists, Vec<u32>, Vec<u32>)>> {
|
||||
let (device, queue) = device()?;
|
||||
let mut results = Vec::new();
|
||||
for eye in [
|
||||
Some([3.5f32, 70.25, -12.0]),
|
||||
Some([-40.0, -3.0, 50.5]),
|
||||
None,
|
||||
] {
|
||||
let mut lists = GpuLists::new(&device);
|
||||
lists.clear(&device);
|
||||
let mut rows = vec![0u32; sections.len() * ROW];
|
||||
for (id, s) in sections.iter().enumerate() {
|
||||
rows[id * ROW] = s.start;
|
||||
rows[id * ROW + 1..id * ROW + 23].copy_from_slice(&s.sides.gpu_row());
|
||||
// Nearest first, over several bands.
|
||||
let distance = (id as f64 * 2.0).powi(2).to_bits();
|
||||
assert!(lists.list(id as u32, s.page, distance, &s.sides, s.parts, s.origin));
|
||||
}
|
||||
let room = lists.rooms();
|
||||
let init = |contents: &[u8], usage| {
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents,
|
||||
usage,
|
||||
})
|
||||
};
|
||||
let storage = wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC;
|
||||
lists.rows = init(bytemuck::cast_slice(&rows), storage);
|
||||
let indices = init(&vec![0xffu8; room as usize * 4], storage);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let mut uploads = super::super::upload::Uploads::new(&device);
|
||||
lists.prepare(&device, &mut encoder, &mut uploads, &indices, eye);
|
||||
uploads.finish(&encoder);
|
||||
{
|
||||
let mut pass = encoder.begin_compute_pass(&Default::default());
|
||||
lists.dispatch(&mut pass);
|
||||
}
|
||||
queue.submit([encoder.finish()]);
|
||||
let read_back = |buffer: &wgpu::Buffer, words: u64| -> Vec<u32> {
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: words * 4,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.copy_buffer_to_buffer(buffer, 0, &read, 0, words * 4);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
bytemuck::cast_slice(&read.slice(..).get_mapped_range().unwrap()).to_vec()
|
||||
};
|
||||
let calls = read_back(&lists.call_buffer, lists.calls.len() as u64 * 5);
|
||||
let words = read_back(&indices, room);
|
||||
results.push((eye, lists, calls, words));
|
||||
}
|
||||
Some(results)
|
||||
}
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return None;
|
||||
};
|
||||
let device = pollster::block_on(adapter.request_device(&Default::default())).ok();
|
||||
if device.is_none() {
|
||||
eprintln!("no GPU device: skipped");
|
||||
}
|
||||
device
|
||||
}
|
||||
|
||||
/// The call section `i`'s `k`th part draws in, as listed.
|
||||
fn call_of(lists: &GpuLists, i: usize, k: usize) -> usize {
|
||||
(lists.records[i][1 + k] & 0x0fff_ffff) as usize
|
||||
}
|
||||
|
||||
/// Each band of distance and page gets a pass's calls of its own, in the
|
||||
/// order sections come (nearest first), and sections of one share them.
|
||||
#[test]
|
||||
fn calls_go_by_band_then_page_nearest_first() {
|
||||
let Some((device, _)) = device() else {
|
||||
return;
|
||||
};
|
||||
let mut lists = GpuLists::new(&device);
|
||||
lists.clear(&device);
|
||||
let sides = Sides::unsorted(4);
|
||||
let parts = &[(0, Part::Opaque), (1, Part::Cutout)];
|
||||
for (page, blocks) in [(1, 10.0), (0, 20.0), (1, 30.0), (1, 100.0), (0, 130.0)] {
|
||||
let distance = f64::powi(blocks, 2).to_bits();
|
||||
assert!(lists.list(0, page, distance, &sides, parts, [0.0; 3]));
|
||||
}
|
||||
let calls: Vec<_> = lists
|
||||
.calls()
|
||||
.iter()
|
||||
.map(|c| (c.group, c.page, c.count))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
calls,
|
||||
[
|
||||
(0, 1, 48),
|
||||
(1, 1, 48),
|
||||
(0, 0, 24),
|
||||
(1, 0, 24),
|
||||
(0, 1, 24),
|
||||
(1, 1, 24),
|
||||
(0, 0, 24),
|
||||
(1, 0, 24)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// `flatten_sides` writes, in each call's room, the indices of exactly the
|
||||
/// runs `Sides::visible_from` finds for each listed section's parts (in
|
||||
/// some order: workgroups take room as they come), and counts them.
|
||||
#[test]
|
||||
fn flatten_sides_writes_the_runs_the_cpu_finds() {
|
||||
let sections = sections(200);
|
||||
let Some(results) = run(§ions) else {
|
||||
return;
|
||||
};
|
||||
for (eye, lists, calls, out) in results {
|
||||
let mut expected = vec![Vec::new(); lists.calls.len()];
|
||||
for (i, s) in sections.iter().enumerate() {
|
||||
for (k, &(_, part)) in s.parts.iter().enumerate() {
|
||||
let call = call_of(&lists, i, k);
|
||||
for (first, quads) in cpu_runs(s, part, eye) {
|
||||
for k in 0..quads * 6 {
|
||||
let corner = [0, 1, 2, 0, 2, 3][(k % 6) as usize];
|
||||
expected[call].push(s.start + first * 4 + (k / 6) * 4 + corner);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (k, call) in lists.calls.iter().enumerate() {
|
||||
let count = calls[k * 5] as usize;
|
||||
assert_eq!(
|
||||
count,
|
||||
expected[k].len(),
|
||||
"call {k}'s index count, eye {eye:?}"
|
||||
);
|
||||
assert_eq!(calls[k * 5 + 2], call.first);
|
||||
let mut got = out[call.first as usize..call.first as usize + count].to_vec();
|
||||
got.sort_unstable();
|
||||
expected[k].sort_unstable();
|
||||
assert_eq!(got, expected[k], "call {k}'s indices, eye {eye:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Phase 1's solid section draws made on the GPU (gfx/gpu_lists.rs): per section
|
||||
// listed, the runs of its quads that face the eye, found as
|
||||
// `mesher::Sides::visible_from` finds them on the CPU, written into the call
|
||||
// each of its parts draws in (one per pass group and arena page) after taking
|
||||
// room there: their indices, in the flat draws' index list, as `flatten_seen`
|
||||
// does for occlusion culling's phase 2. Solid draws write depth and are tested
|
||||
// against it, so their order within a call does not change the picture.
|
||||
struct View {
|
||||
// The eye relative to the camera, and 1.0 when there is one (`sides_on`);
|
||||
// without, every bucket is drawn (`Sides::every`).
|
||||
eye: vec3<f32>,
|
||||
has_eye: f32,
|
||||
}
|
||||
// Per section listed: its row's id; for each of its parts (up to two) the call
|
||||
// it draws in, with the part (`PART_*`) in the top bits; its number of parts.
|
||||
// Then its origin relative to the camera.
|
||||
struct Record {
|
||||
words: vec4<u32>,
|
||||
origin: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<storage, read> records: array<Record>;
|
||||
// Per id, `ROW` words: its first vertex in its page, then `Sides::gpu_row`
|
||||
// (14 ends, 7 planes' bits, flags).
|
||||
@group(0) @binding(1) var<storage, read> rows: array<u32>;
|
||||
@group(0) @binding(2) var<uniform> view: View;
|
||||
// Per call `DrawIndexedIndirectArgs`: its index count starts at 0, its first
|
||||
// index is where its room starts.
|
||||
@group(0) @binding(3) var<storage, read_write> calls: array<atomic<u32>>;
|
||||
// The flat draws' index list.
|
||||
@group(0) @binding(4) var<storage, read_write> out: array<u32>;
|
||||
|
||||
const ROW: u32 = 24u;
|
||||
const LANES: u32 = 64u;
|
||||
const PART_ALL: u32 = 0u;
|
||||
const PART_OPAQUE: u32 = 1u;
|
||||
const PART_CUTOUT: u32 = 2u;
|
||||
const CALL_MASK: u32 = 0x0fffffffu;
|
||||
// `sides::MARGIN`.
|
||||
const MARGIN: f32 = 0.0625;
|
||||
|
||||
// Index `k` of a solid draw: quad k / 6's vertices 0 1 2 0 2 3.
|
||||
fn quad(k: u32) -> u32 {
|
||||
let c = k % 6u;
|
||||
return (k / 6u) * 4u + select(select(c - 2u, 0u, c == 3u), c, c < 3u);
|
||||
}
|
||||
|
||||
fn plane(row: u32, bucket: u32) -> f32 {
|
||||
return bitcast<f32>(rows[row + 15u + bucket]);
|
||||
}
|
||||
|
||||
// `Sides::visible_from`'s `seen`, buckets in mesh order: +X, +Z, other, +Y, -Z, -X, -Y.
|
||||
fn seen(row: u32, bucket: u32, eye: vec3<f32>) -> bool {
|
||||
var faces = true;
|
||||
if view.has_eye != 0.0 {
|
||||
switch bucket {
|
||||
case 0u: { faces = eye.x > plane(row, 0u) - MARGIN; }
|
||||
case 1u: { faces = eye.z > plane(row, 1u) - MARGIN; }
|
||||
case 3u: { faces = eye.y > plane(row, 3u) - MARGIN; }
|
||||
case 4u: { faces = eye.z < plane(row, 4u) + MARGIN; }
|
||||
case 5u: { faces = eye.x < plane(row, 5u) + MARGIN; }
|
||||
case 6u: { faces = eye.y < plane(row, 6u) + MARGIN; }
|
||||
default: {}
|
||||
}
|
||||
}
|
||||
return faces;
|
||||
}
|
||||
|
||||
// A part's runs (first quad, quads): at most 7, as a run ends at a part left
|
||||
// out and there are 14.
|
||||
struct Runs {
|
||||
n: u32,
|
||||
quads: u32,
|
||||
runs: array<vec2<u32>, 7>,
|
||||
}
|
||||
|
||||
// `Sides::runs` for `part` of the mesh at `row`, seen from `eye` (relative to
|
||||
// the section): empty parts do not break a run.
|
||||
fn part_runs(row: u32, part: u32, eye: vec3<f32>) -> Runs {
|
||||
var r: Runs;
|
||||
let grouped = (rows[row + 22u] & 1u) != 0u;
|
||||
let split = (rows[row + 22u] & 2u) != 0u;
|
||||
var start = 0u;
|
||||
var open = false;
|
||||
var first = 0u;
|
||||
for (var n = 0u; n < 14u; n++) {
|
||||
let end = rows[row + 1u + n];
|
||||
if end == start {
|
||||
continue;
|
||||
}
|
||||
var bucket: u32;
|
||||
var cutout: bool;
|
||||
if grouped {
|
||||
bucket = n % 7u;
|
||||
cutout = n >= 7u;
|
||||
} else {
|
||||
bucket = n / 2u;
|
||||
cutout = n % 2u == 1u;
|
||||
}
|
||||
let wanted = part == PART_ALL || !split
|
||||
|| (part == PART_OPAQUE && !cutout) || (part == PART_CUTOUT && cutout);
|
||||
let take = wanted && seen(row, bucket, eye);
|
||||
if take && !open {
|
||||
open = true;
|
||||
first = start;
|
||||
} else if !take && open {
|
||||
r.runs[r.n] = vec2(first, start - first);
|
||||
r.quads += start - first;
|
||||
r.n++;
|
||||
open = false;
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
if open {
|
||||
r.runs[r.n] = vec2(first, start - first);
|
||||
r.quads += start - first;
|
||||
r.n++;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
fn record_index(group: vec3<u32>) -> u32 {
|
||||
return group.y * 65535u + group.x;
|
||||
}
|
||||
|
||||
// The eye relative to `record`'s section.
|
||||
fn eye_of(record: Record) -> vec3<f32> {
|
||||
return view.eye - record.origin.xyz;
|
||||
}
|
||||
|
||||
fn part_word(record: Record, p: u32) -> u32 {
|
||||
return select(record.words.z, record.words.y, p == 0u);
|
||||
}
|
||||
|
||||
// Lane 0's runs per part, and where each part's indices start.
|
||||
var<workgroup> found: array<Runs, 2>;
|
||||
var<workgroup> at: array<u32, 2>;
|
||||
|
||||
// One workgroup per record: lane 0 finds the runs and takes room for their
|
||||
// indices, then every lane writes them.
|
||||
@compute @workgroup_size(64)
|
||||
fn flatten_sides(@builtin(workgroup_id) group: vec3<u32>,
|
||||
@builtin(local_invocation_index) lane: u32) {
|
||||
let r = record_index(group);
|
||||
if r >= arrayLength(&records) {
|
||||
return;
|
||||
}
|
||||
let record = records[r];
|
||||
let row = record.words.x * ROW;
|
||||
let parts = record.words.w;
|
||||
if lane == 0u {
|
||||
let eye = eye_of(record);
|
||||
for (var p = 0u; p < parts; p++) {
|
||||
let word = part_word(record, p);
|
||||
let runs = part_runs(row, word >> 28u, eye);
|
||||
let call = (word & CALL_MASK) * 5u;
|
||||
var begin = 0u;
|
||||
if runs.quads > 0u {
|
||||
begin = atomicLoad(&calls[call + 2u]) + atomicAdd(&calls[call], runs.quads * 6u);
|
||||
}
|
||||
found[p] = runs;
|
||||
at[p] = begin;
|
||||
}
|
||||
}
|
||||
workgroupBarrier();
|
||||
let base = rows[row];
|
||||
for (var p = 0u; p < parts; p++) {
|
||||
var to = at[p];
|
||||
for (var k = 0u; k < found[p].n; k++) {
|
||||
let run = found[p].runs[k];
|
||||
let first = base + run.x * 4u;
|
||||
let count = run.y * 6u;
|
||||
for (var i = lane; i < count; i += LANES) {
|
||||
out[to + i] = first + quad(i);
|
||||
}
|
||||
to += count;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,6 @@ pub(super) struct PackAtlas {
|
||||
pub sprite: u32,
|
||||
/// Layers per sprite array.
|
||||
split: u32,
|
||||
/// OptiFine/Iris separate ambient occlusion: pass vertex alpha to hz_Color.a.
|
||||
pub separate_ao: bool,
|
||||
}
|
||||
|
||||
impl PackAtlas {
|
||||
@@ -59,7 +57,6 @@ impl PackAtlas {
|
||||
columns,
|
||||
sprite,
|
||||
split,
|
||||
separate_ao: false,
|
||||
};
|
||||
atlas.copy(encoder, arrays, 0..layers);
|
||||
Some(atlas)
|
||||
@@ -182,12 +179,7 @@ impl PackAtlas {
|
||||
];
|
||||
let columns = self.columns;
|
||||
// UVs past a sprite (models up to 2, distant tiles) cannot repeat in an atlas: clamped.
|
||||
// The colour's alpha holds HDR's emission level (`mesher::EMISSION_BITS`): packs get 1 unless separate_ao.
|
||||
let color_a = if self.separate_ao {
|
||||
"hz_v_color.a"
|
||||
} else {
|
||||
"1.0"
|
||||
};
|
||||
// The colour's alpha holds HDR's emission level (`mesher::EMISSION_BITS`): packs get 1.
|
||||
let body = format!(
|
||||
" vec4 hz_origin = {origin};\n \
|
||||
uint hz_layer = hz_v_layer & 32767u;\n \
|
||||
@@ -197,7 +189,7 @@ impl PackAtlas {
|
||||
hz_MultiTexCoord0 = vec4((hz_cell + clamp(vec2(hz_v_uv) * (hz_origin.w > 0.0 ? uintBitsToFloat(floatBitsToUint(hz_origin.w) & 0xff800000u) : fract(abs(hz_origin.w))), 0.0, 1.0)) * hz_scale, 0.0, 1.0);\n \
|
||||
hz_MultiTexCoord1 = vec4(float(hz_v_light.y), float(hz_v_light.x), 0.0, 1.0);\n \
|
||||
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
|
||||
hz_Color = vec4(hz_v_color.rgb, {color_a});\n \
|
||||
hz_Color = vec4(hz_v_color.rgb, 1.0);\n \
|
||||
hz_Normal = vec3(0.0, 1.0, 0.0);\n \
|
||||
hz_tangent = vec4(1.0, 0.0, 0.0, 1.0);\n",
|
||||
scale[0], scale[1],
|
||||
@@ -321,50 +313,4 @@ mod tests {
|
||||
assert_eq!([red(0, 0), red(2, 0), red(4, 0)], [0, 40, 80]);
|
||||
assert_eq!([red(1, 3), red(3, 2), red(5, 2)], [120, 160, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terrain_inputs_respects_separate_ao() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let (device, _) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 16,
|
||||
height: 16,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&Default::default());
|
||||
let mut atlas = PackAtlas {
|
||||
texture,
|
||||
view,
|
||||
columns: 1,
|
||||
sprite: 16,
|
||||
split: 1,
|
||||
separate_ao: false,
|
||||
};
|
||||
let inputs_default = atlas.terrain_inputs(false);
|
||||
assert!(
|
||||
inputs_default
|
||||
.body
|
||||
.contains("hz_Color = vec4(hz_v_color.rgb, 1.0);")
|
||||
);
|
||||
|
||||
atlas.separate_ao = true;
|
||||
let inputs_separate = atlas.terrain_inputs(false);
|
||||
assert!(
|
||||
inputs_separate
|
||||
.body
|
||||
.contains("hz_Color = vec4(hz_v_color.rgb, hz_v_color.a);")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -30,17 +30,11 @@ const BUFFERS: usize = horizon_shaders::BUFFERS;
|
||||
enum Source {
|
||||
Color(usize),
|
||||
Depth,
|
||||
/// `depthtex1`: depth without translucent terrain (a copy taken before it).
|
||||
/// `depthtex1`/`depthtex2`: depth without translucent terrain (a copy taken before it).
|
||||
Depth1,
|
||||
/// `depthtex2`: depth without translucent terrain and without the first-person hand.
|
||||
Depth2,
|
||||
Noise,
|
||||
/// A 1×1 stand-in for names this slice does not provide yet.
|
||||
Dummy,
|
||||
/// A 1×1 flat normal (128, 128, 255) stand-in when no normal map is present.
|
||||
Normals,
|
||||
/// A 1×1 black (0, 0, 0, 0) stand-in when no specular map is present.
|
||||
Specular,
|
||||
/// `texture.<stage>.<name>`: the pack's own texture, filtered and wrapped as its
|
||||
/// `.mcmeta` says.
|
||||
Custom {
|
||||
@@ -87,18 +81,10 @@ enum SamplerKind {
|
||||
impl SamplerKind {
|
||||
fn of(source: Source, filtering: bool) -> Self {
|
||||
match source {
|
||||
Source::Color(_)
|
||||
| Source::ShadowColor(_)
|
||||
| Source::Dummy
|
||||
| Source::Normals
|
||||
| Source::Specular
|
||||
if !filtering =>
|
||||
{
|
||||
Self::Nearest
|
||||
}
|
||||
Source::Depth | Source::Depth1 | Source::Depth2 | Source::ShadowDepth(_, false) => {
|
||||
Source::Color(_) | Source::ShadowColor(_) | Source::Dummy if !filtering => {
|
||||
Self::Nearest
|
||||
}
|
||||
Source::Depth | Source::Depth1 | Source::ShadowDepth(_, false) => Self::Nearest,
|
||||
Source::Noise => Self::Repeat,
|
||||
Source::Shadow | Source::ShadowDepth(_, true) => Self::Comparison,
|
||||
// Blocks look as the game draws them: nearest texels.
|
||||
@@ -111,12 +97,9 @@ impl SamplerKind {
|
||||
(true, false) => Self::Repeat,
|
||||
(false, false) => Self::NearestRepeat,
|
||||
},
|
||||
Source::Color(_)
|
||||
| Source::ShadowColor(_)
|
||||
| Source::Dummy
|
||||
| Source::Normals
|
||||
| Source::Specular
|
||||
| Source::Lightmap => Self::Linear,
|
||||
Source::Color(_) | Source::ShadowColor(_) | Source::Dummy | Source::Lightmap => {
|
||||
Self::Linear
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,8 +387,6 @@ struct Targets {
|
||||
depth_texture: wgpu::Texture,
|
||||
/// `depthtex1`, when a program after translucent terrain reads it.
|
||||
depth1: Option<(wgpu::Texture, wgpu::TextureView)>,
|
||||
/// `depthtex2`, when a program after the hand reads it without the hand.
|
||||
depth2: Option<(wgpu::Texture, wgpu::TextureView)>,
|
||||
/// Both sides' mip chains of the buffers with mipmaps (`color` is their first level).
|
||||
mips: [Option<[Mips; 2]>; BUFFERS],
|
||||
/// Where a pass that both samples and stores a buffer samples it from.
|
||||
@@ -512,8 +493,6 @@ pub struct PackPost {
|
||||
hand: Option<(Pass, super::pack_hand::PackHand)>,
|
||||
/// A program after translucent terrain reads `depthtex1`: it is copied before water.
|
||||
depth1: bool,
|
||||
/// A program after the hand reads `depthtex2`: it is copied before the hand.
|
||||
depth2: bool,
|
||||
/// The shadow buffers, when the shadow pass runs (2.5), and the `shadowcomp` programs.
|
||||
shadow_targets: Option<super::pack_shadow::ShadowTargets>,
|
||||
/// What draws terrain from the light (`shadow`), and translucent terrain (`shadow_water`).
|
||||
@@ -556,8 +535,6 @@ pub struct PackPost {
|
||||
targets: Option<Targets>,
|
||||
noise: wgpu::TextureView,
|
||||
dummy: wgpu::TextureView,
|
||||
dummy_black: wgpu::TextureView,
|
||||
dummy_normal: wgpu::TextureView,
|
||||
dummy_3d: wgpu::TextureView,
|
||||
dummy_1d: wgpu::TextureView,
|
||||
/// `image.*` and `bufferObject.*`.
|
||||
@@ -672,13 +649,11 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
|
||||
}
|
||||
match name {
|
||||
"depthtex0" | "gdepthtex" => Source::Depth,
|
||||
"depthtex1" => Source::Depth1,
|
||||
"depthtex2" => Source::Depth2,
|
||||
// The hand is drawn after the pack, so `depthtex2` (without it) is `depthtex1`.
|
||||
"depthtex1" | "depthtex2" => Source::Depth1,
|
||||
"noisetex" => Source::Noise,
|
||||
"texture" | "gtexture" | "tex" => Source::Atlas,
|
||||
"lightmap" => Source::Lightmap,
|
||||
"normals" => Source::Normals,
|
||||
"specular" => Source::Specular,
|
||||
n => horizon_shaders::buffer_index(n).map_or(Source::Dummy, Source::Color),
|
||||
}
|
||||
}
|
||||
@@ -1100,7 +1075,7 @@ fn bindings(
|
||||
Source::Atlas => geometry.is_none(),
|
||||
Source::Depth => geometry.is_some(),
|
||||
// Translucent terrain reads the copy; opaque terrain would read itself.
|
||||
Source::Depth1 | Source::Depth2 => group == Group::Gbuffers,
|
||||
Source::Depth1 => group == Group::Gbuffers,
|
||||
Source::Color(n) => in_place & 1 << n != 0,
|
||||
_ => false,
|
||||
};
|
||||
@@ -1111,7 +1086,7 @@ fn bindings(
|
||||
Source::ShadowColor(n) => shadow
|
||||
.and_then(|f| f[n].sample_type(None, Some(features)))
|
||||
.unwrap_or(float),
|
||||
Source::Depth | Source::Depth1 | Source::Depth2 | Source::ShadowDepth(..) => {
|
||||
Source::Depth | Source::Depth1 | Source::ShadowDepth(..) => {
|
||||
wgpu::TextureSampleType::Float { filterable: false }
|
||||
}
|
||||
// Unfilterable on WebGPU-level devices; the lightmap is sampled at texel centres.
|
||||
@@ -2105,20 +2080,13 @@ impl PackPost {
|
||||
}
|
||||
}
|
||||
}
|
||||
let depth1 = (water.is_some() || hand.is_some())
|
||||
let depth1 = water.is_some()
|
||||
&& passes
|
||||
.iter()
|
||||
.filter(|p| matches!(p.group, Group::Composite | Group::Final))
|
||||
.chain(&water)
|
||||
.chain(weather.iter().take(1).flatten())
|
||||
.any(|p| p.samplers.iter().any(|s| s.source == Source::Depth1));
|
||||
let depth2 = hand.is_some()
|
||||
&& passes
|
||||
.iter()
|
||||
.filter(|p| matches!(p.group, Group::Composite | Group::Final))
|
||||
.chain(&water)
|
||||
.chain(weather.iter().take(1).flatten())
|
||||
.any(|p| p.samplers.iter().any(|s| s.source == Source::Depth2));
|
||||
let every_pass = || {
|
||||
(passes
|
||||
.iter()
|
||||
@@ -2193,7 +2161,7 @@ impl PackPost {
|
||||
|| passes.iter().any(|p| {
|
||||
p.samplers
|
||||
.iter()
|
||||
.any(|s| matches!(s.source, Source::Depth | Source::Depth1 | Source::Depth2))
|
||||
.any(|s| matches!(s.source, Source::Depth | Source::Depth1))
|
||||
});
|
||||
// Buffers of a size of their own clear apart from the blit, which writes colortex0.
|
||||
let (mut cleared, mut other_clears): (Vec<usize>, Vec<usize>) =
|
||||
@@ -2282,8 +2250,6 @@ impl PackPost {
|
||||
});
|
||||
}
|
||||
let white = [255u8; 4];
|
||||
let black = [0u8; 4];
|
||||
let flat_normal = [128u8, 128, 255, 255];
|
||||
Self {
|
||||
notes,
|
||||
passes,
|
||||
@@ -2302,7 +2268,6 @@ impl PackPost {
|
||||
weather_generation: 0,
|
||||
hand,
|
||||
depth1,
|
||||
depth2,
|
||||
shadow_targets,
|
||||
shadow_terrain,
|
||||
shadow_water,
|
||||
@@ -2337,14 +2302,6 @@ impl PackPost {
|
||||
.cloned()
|
||||
.unwrap_or_else(|| noise(device, queue)),
|
||||
dummy: texture(device, queue, "Shader pack stand-in", 1, &white),
|
||||
dummy_black: texture(device, queue, "Shader pack black stand-in", 1, &black),
|
||||
dummy_normal: texture(
|
||||
device,
|
||||
queue,
|
||||
"Shader pack normal stand-in",
|
||||
1,
|
||||
&flat_normal,
|
||||
),
|
||||
images: {
|
||||
let mut images = images;
|
||||
for (k, image) in images.iter_mut().enumerate() {
|
||||
@@ -2550,27 +2507,6 @@ impl PackPost {
|
||||
}
|
||||
_ => &targets.depth,
|
||||
},
|
||||
Source::Depth2 => match &targets.depth2 {
|
||||
Some(d)
|
||||
if matches!(
|
||||
pass.group,
|
||||
Group::Composite | Group::Final | Group::Translucent
|
||||
) =>
|
||||
{
|
||||
&d.1
|
||||
}
|
||||
_ => match &targets.depth1 {
|
||||
Some(d)
|
||||
if matches!(
|
||||
pass.group,
|
||||
Group::Composite | Group::Final | Group::Translucent
|
||||
) =>
|
||||
{
|
||||
&d.1
|
||||
}
|
||||
_ => &targets.depth,
|
||||
},
|
||||
},
|
||||
Source::Noise => &self.noise,
|
||||
Source::Dummy if s.dimension == wgpu::TextureViewDimension::D3 => {
|
||||
&self.dummy_3d
|
||||
@@ -2578,8 +2514,6 @@ impl PackPost {
|
||||
Source::Dummy if s.dimension == wgpu::TextureViewDimension::D1 => {
|
||||
&self.dummy_1d
|
||||
}
|
||||
Source::Normals => &self.dummy_normal,
|
||||
Source::Specular => &self.dummy_black,
|
||||
Source::Dummy => &self.dummy,
|
||||
Source::Image(k) if pass.image_copies & 1 << k != 0 => {
|
||||
self.images[k].copy.as_ref().unwrap_or(&self.dummy)
|
||||
@@ -2751,6 +2685,16 @@ impl PackPost {
|
||||
return;
|
||||
};
|
||||
let translucent = list.translucent();
|
||||
if list == TerrainList::Translucent
|
||||
&& let Some(t) = &self.targets
|
||||
&& let Some((copy, _)) = &t.depth1
|
||||
{
|
||||
encoder.copy_texture_to_texture(
|
||||
t.depth_texture.as_image_copy(),
|
||||
copy.as_image_copy(),
|
||||
t.depth_texture.size(),
|
||||
);
|
||||
}
|
||||
let group = self.bind(device, queue, encoder, &mut pass, size);
|
||||
{
|
||||
let mut rp = self.begin(encoder, &pass);
|
||||
@@ -3028,22 +2972,6 @@ impl PackPost {
|
||||
self.draw_terrain(device, queue, encoder, size, TerrainList::Solid, terrain);
|
||||
self.draw_entities(device, queue, encoder, size, false);
|
||||
self.draw_clouds(device, queue, encoder, size);
|
||||
if let Some(t) = &self.targets {
|
||||
if let Some((copy, _)) = &t.depth1 {
|
||||
encoder.copy_texture_to_texture(
|
||||
t.depth_texture.as_image_copy(),
|
||||
copy.as_image_copy(),
|
||||
t.depth_texture.size(),
|
||||
);
|
||||
}
|
||||
if let Some((copy, _)) = &t.depth2 {
|
||||
encoder.copy_texture_to_texture(
|
||||
t.depth_texture.as_image_copy(),
|
||||
copy.as_image_copy(),
|
||||
t.depth_texture.size(),
|
||||
);
|
||||
}
|
||||
}
|
||||
self.draw_hand(device, queue, encoder, size, frame);
|
||||
}
|
||||
|
||||
@@ -3434,11 +3362,6 @@ impl PackPost {
|
||||
let view = t.create_view(&Default::default());
|
||||
(t, view)
|
||||
});
|
||||
let depth2 = self.depth2.then(|| {
|
||||
let t = texture("depthtex2", depth32, wgpu::TextureUsages::COPY_DST);
|
||||
let view = t.create_view(&Default::default());
|
||||
(t, view)
|
||||
});
|
||||
let scratch = std::array::from_fn(|i| {
|
||||
(self.copied & 1 << i != 0).then(|| {
|
||||
// A copy also read as an image before a dispatch.
|
||||
@@ -3472,7 +3395,6 @@ impl PackPost {
|
||||
depth: depth_texture.create_view(&Default::default()),
|
||||
depth_texture,
|
||||
depth1,
|
||||
depth2,
|
||||
mips,
|
||||
});
|
||||
self.forget_groups();
|
||||
@@ -4156,10 +4078,7 @@ mod tests {
|
||||
let main = |n| source(n, false, false, false);
|
||||
assert!(main("gaux1") == Source::Color(4));
|
||||
assert!(main("gdepthtex") == Source::Depth);
|
||||
assert!(main("depthtex1") == Source::Depth1);
|
||||
assert!(main("depthtex2") == Source::Depth2);
|
||||
assert!(main("normals") == Source::Normals);
|
||||
assert!(main("specular") == Source::Specular);
|
||||
assert!(main("depthtex2") == Source::Depth1);
|
||||
assert!(main("noisetex") == Source::Noise);
|
||||
// Without the shadow pass, the shadow buffers are stand-ins.
|
||||
assert!(main("shadowcolor0") == Source::Dummy);
|
||||
|
||||
@@ -969,7 +969,6 @@ mod initial_history_tests {
|
||||
emissive_layers: &[],
|
||||
shadow_only: &[],
|
||||
entity_spans: &[],
|
||||
skin: None,
|
||||
glowing: &[],
|
||||
dynamic_lights: &[],
|
||||
clouds: None,
|
||||
|
||||
@@ -29,6 +29,11 @@ pub(super) struct Run {
|
||||
pub tag: [u32; 3],
|
||||
}
|
||||
|
||||
/// The band of a section `distance` from the camera (squared, as bits).
|
||||
pub(super) fn band(distance: u64) -> u64 {
|
||||
(f64::from_bits(distance).sqrt() / BAND) as u64
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct PageRuns {
|
||||
runs: Vec<Run>,
|
||||
@@ -58,7 +63,7 @@ impl PageRuns {
|
||||
tag: [u32; 3],
|
||||
) {
|
||||
if len > 0 {
|
||||
let band = (f64::from_bits(distance).sqrt() / BAND) as u64;
|
||||
let band = band(distance);
|
||||
self.runs.push(Run {
|
||||
key: u64::from(group) << GROUP_SHIFT | band << 32 | u64::from(page),
|
||||
start: start as u32,
|
||||
|
||||
@@ -55,22 +55,6 @@ 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,7 +7,6 @@ use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, OnceLock},
|
||||
};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
@@ -59,15 +58,7 @@ 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>,
|
||||
@@ -492,76 +483,7 @@ 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> {
|
||||
@@ -708,9 +630,7 @@ impl HudAtlas {
|
||||
(glyph_top, height - glyph_top),
|
||||
white,
|
||||
),
|
||||
galactic_widths: galactic_widths(&sprites, &pixels, width, height),
|
||||
pixels,
|
||||
packed: None,
|
||||
width,
|
||||
height,
|
||||
sprites,
|
||||
@@ -1143,9 +1063,7 @@ impl HudAtlas {
|
||||
white,
|
||||
);
|
||||
Ok(Self {
|
||||
galactic_widths: galactic_widths(&sprites, &pixels, width, height),
|
||||
pixels,
|
||||
packed: None,
|
||||
width,
|
||||
height,
|
||||
sprites,
|
||||
@@ -2038,12 +1956,6 @@ 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())),
|
||||
@@ -2094,12 +2006,6 @@ 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 {
|
||||
@@ -2126,10 +2032,6 @@ 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 {
|
||||
@@ -2232,7 +2134,7 @@ impl HudRenderer {
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
},
|
||||
"fs_vignette",
|
||||
"fs_main",
|
||||
false,
|
||||
);
|
||||
// RenderPipelines.END_PORTAL: opaque.
|
||||
@@ -2879,18 +2781,6 @@ 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,7 +10,6 @@ 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>,
|
||||
@@ -76,12 +75,6 @@ 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),
|
||||
|
||||
File renamed without changes.
@@ -49,7 +49,7 @@ pub mod hud_overlays;
|
||||
pub mod inventory_effects;
|
||||
pub mod java_text;
|
||||
pub mod jigsaw_block;
|
||||
pub use horizon_world::json_folder;
|
||||
pub mod json_folder;
|
||||
pub mod level_loading;
|
||||
pub mod light;
|
||||
pub mod lod;
|
||||
@@ -91,9 +91,7 @@ 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,7 +562,11 @@ fn pack_lighting(
|
||||
}
|
||||
}
|
||||
}
|
||||
Light::packed(data)
|
||||
if data.iter().all(|&v| v == data[0]) {
|
||||
Light::Uniform(data[0])
|
||||
} else {
|
||||
Light::Full(data)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -806,9 +810,10 @@ impl<'w> Region<'w> {
|
||||
return;
|
||||
};
|
||||
let slot = &mut self.slots[slot];
|
||||
let light = slot
|
||||
.light
|
||||
.get_or_insert_with(|| slot.section.light.to_array());
|
||||
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 i = index(x, y, z);
|
||||
light[i] = f(light[i]);
|
||||
}
|
||||
@@ -825,7 +830,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::packed(light));
|
||||
.set_section_light(cx, s as usize, cz, Light::Full(light));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1619,20 +1624,21 @@ 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 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 Light::Full(data) = &lights[0] else {
|
||||
panic!("torch light varies");
|
||||
};
|
||||
let result_storage = storage(&lights[0]);
|
||||
let result_storage = data.as_ptr();
|
||||
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,
|
||||
storage(¤t.sections[0].light),
|
||||
data.as_ptr(),
|
||||
"returned light storage moves without cloning"
|
||||
);
|
||||
current.lit = true;
|
||||
|
||||
@@ -214,12 +214,7 @@ impl Mesher {
|
||||
Paint {
|
||||
layer: face.layer,
|
||||
light: span.light,
|
||||
// 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),
|
||||
),
|
||||
color: channel(16) | channel(8) << 8 | channel(0) << 16 | 0xff000000,
|
||||
translucent: face.translucent,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
//! 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.
|
||||
@@ -32,20 +27,15 @@ 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`; at half size, then the
|
||||
/// frame copied at full size.
|
||||
/// The copied frame (`minecraft:main`) and `swap`.
|
||||
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. `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 {
|
||||
/// `format` is the swapchain's; the blur works in its non-sRGB view.
|
||||
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
|
||||
let format = format.remove_srgb_suffix();
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Menu blur"),
|
||||
@@ -121,7 +111,8 @@ impl MenuBlur {
|
||||
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||||
..Default::default()
|
||||
});
|
||||
let uniforms = (0..=PASSES.len())
|
||||
let uniforms = PASSES
|
||||
.iter()
|
||||
.map(|_| {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Menu blur pass"),
|
||||
@@ -139,15 +130,6 @@ 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]) {
|
||||
@@ -155,15 +137,8 @@ impl MenuBlur {
|
||||
return;
|
||||
}
|
||||
self.size = size;
|
||||
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| {
|
||||
self.targets = (0..2)
|
||||
.map(|_| {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Menu blur target"),
|
||||
size: wgpu::Extent3d {
|
||||
@@ -205,112 +180,65 @@ 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[copy].0.as_image_copy(),
|
||||
self.targets[0].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: [work[0] as f32, work[1] as f32, dir[0], dir[1]],
|
||||
size_dir: [size[0] as f32, size[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
|
||||
};
|
||||
self.pass(
|
||||
device,
|
||||
queue,
|
||||
encoder,
|
||||
i,
|
||||
data,
|
||||
source,
|
||||
output,
|
||||
timing.as_deref_mut(),
|
||||
);
|
||||
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);
|
||||
}
|
||||
}
|
||||
/// 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)]
|
||||
@@ -390,7 +318,7 @@ mod tests {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let mut blur = MenuBlur::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, false);
|
||||
let mut blur = MenuBlur::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb);
|
||||
let size = [64, 8];
|
||||
let first = blurred(&device, &queue, &mut blur, size);
|
||||
// The stripes are blurred into greys.
|
||||
@@ -399,44 +327,6 @@ 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, GrassModifier},
|
||||
biome::Biomes,
|
||||
blocks::{BlockFacts, RenderShape},
|
||||
chunk::{Chunk, MIN_Y},
|
||||
chunk::{Chunk, Light, MIN_Y},
|
||||
graphics::LeafCullingMode,
|
||||
models::catalog::BakedFace,
|
||||
};
|
||||
@@ -73,15 +73,6 @@ 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`),
|
||||
@@ -91,7 +82,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| round_to_u16(v * UV_STEPS)),
|
||||
uv: uv.map(|v| (v * UV_STEPS).round().clamp(0.0, f32::from(u16::MAX)) as u16),
|
||||
layer,
|
||||
light,
|
||||
color,
|
||||
@@ -102,7 +93,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| round_to_u16(v * TILED_UV_STEPS)),
|
||||
uv: uv.map(|v| (v * TILED_UV_STEPS).round().clamp(0.0, f32::from(u16::MAX)) as u16),
|
||||
layer,
|
||||
light,
|
||||
color,
|
||||
@@ -125,30 +116,6 @@ 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()];
|
||||
@@ -366,112 +333,94 @@ 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 {
|
||||
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))
|
||||
visibility_reusing(pad, info, &mut Vec::new())
|
||||
}
|
||||
/// 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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
if opaque_count == 0 {
|
||||
return ALL_VISIBLE;
|
||||
}
|
||||
if open_count == 0 {
|
||||
if opaque_count == 4096 {
|
||||
return 0;
|
||||
}
|
||||
let mut unvisited = *open;
|
||||
let mut region = [0u16; 256];
|
||||
let mut result = 0u64;
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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;
|
||||
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;
|
||||
}
|
||||
// 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 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 (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);
|
||||
}
|
||||
}
|
||||
}
|
||||
if result == ALL_VISIBLE {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
if result == ALL_VISIBLE {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
result
|
||||
@@ -560,9 +509,6 @@ 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,
|
||||
@@ -732,10 +678,6 @@ 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")?,
|
||||
@@ -753,10 +695,6 @@ 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 {
|
||||
@@ -913,94 +851,66 @@ 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 in runs, from
|
||||
// sections found once per layer.
|
||||
// the border cells of the neighbouring sections are read one by one,
|
||||
// each run of them from a section found once.
|
||||
own.fill(0);
|
||||
let center = region[1][1];
|
||||
if let Some(c) = center {
|
||||
c.sections[sy as usize].unpack(own);
|
||||
let center = region[1][1].map(|c| &c.sections[sy as usize]);
|
||||
if let Some(section) = center {
|
||||
section.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;
|
||||
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);
|
||||
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()]),
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
blocks.fill(0);
|
||||
light.fill(0xF0);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Per-worker temporary arrays. They never hold resource-pack references or
|
||||
@@ -1010,13 +920,14 @@ fn fill_run(
|
||||
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` (`MeshTables::solid`).
|
||||
solid_rows: Vec<u32>,
|
||||
/// is `isSolidRender`.
|
||||
opaque: 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>) {
|
||||
/// 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>) {
|
||||
rows.clear();
|
||||
rows.extend(pad.blocks.as_chunks::<P>().0.iter().map(|row| {
|
||||
row.iter().enumerate().fold(0u32, |bits, (x, &id)| {
|
||||
@@ -1038,8 +949,6 @@ 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
|
||||
@@ -1152,8 +1061,6 @@ 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(
|
||||
@@ -1171,7 +1078,6 @@ 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`
|
||||
@@ -1210,52 +1116,8 @@ 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(|| {
|
||||
@@ -1347,8 +1209,6 @@ pub struct Mesher<'a> {
|
||||
pub nether_shading: bool,
|
||||
/// A shader pack lights the faces (its `oldLighting` off): no face shading in the colours.
|
||||
pub shader_pack_lighting: bool,
|
||||
/// OptiFine/Iris separate ambient occlusion: vertex RGB does not include AO, and AO factor is passed in vertex alpha.
|
||||
pub separate_ao: bool,
|
||||
pub assets: &'a Assets,
|
||||
pub render: &'a RenderAssets,
|
||||
pub tables: &'a MeshTables,
|
||||
@@ -1395,9 +1255,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;
|
||||
solid_rows(pad, &self.tables.solid, &mut scratch.solid_rows);
|
||||
let opaque = &scratch.solid_rows;
|
||||
out.visibility = visibility_rows(&open_rows(opaque));
|
||||
out.visibility = visibility_reusing(pad, &self.tables.info, &mut scratch.visibility);
|
||||
opaque_rows(pad, &self.tables.solid, &mut scratch.opaque);
|
||||
let opaque = &scratch.opaque;
|
||||
let base_y = center.min_y() + sy * 16;
|
||||
if self.leaf_culling.is_interior() {
|
||||
scratch
|
||||
@@ -1412,7 +1272,6 @@ 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 {
|
||||
@@ -1436,11 +1295,7 @@ impl Mesher<'_> {
|
||||
}
|
||||
if state.fluid.kind() != FluidKind::Empty {
|
||||
let water = tints.get(Resolver::Water, x, y, z);
|
||||
let shade = (
|
||||
self.nether_shading,
|
||||
self.shader_pack_lighting,
|
||||
self.separate_ao,
|
||||
);
|
||||
let shade = (self.nether_shading, self.shader_pack_lighting);
|
||||
fluid::tesselate(self.tables, shade, pad, water, &mut out, [x, y, z]);
|
||||
}
|
||||
if !state.model {
|
||||
@@ -1474,11 +1329,6 @@ 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;
|
||||
@@ -1588,23 +1438,19 @@ impl Mesher<'_> {
|
||||
} else {
|
||||
lighting::cardinal(self.nether_shading, shade_face)
|
||||
};
|
||||
// Grey level per vertex before tinting, vanilla light coords, and ambient occlusion factor.
|
||||
let (gray, coords, ao_factor) = if ao {
|
||||
// Grey level per vertex before tinting, and vanilla light coords.
|
||||
let (gray, coords) = if ao {
|
||||
let info = &self.tables.info;
|
||||
let (brightness, coords) =
|
||||
lighting::ambient_occlusion(pad, info, state, pos, &q.positions, q.facing);
|
||||
let gray = if self.separate_ao {
|
||||
[lighting::gray(directional); 4]
|
||||
} else {
|
||||
brightness.map(|b| lighting::scale(lighting::gray(b), directional))
|
||||
};
|
||||
(gray, coords, brightness)
|
||||
let gray = brightness.map(|b| lighting::scale(lighting::gray(b), directional));
|
||||
(gray, coords)
|
||||
} else {
|
||||
let coords = match q.cull {
|
||||
Some(d) => lighting::coords(pad, state, lighting::step(pos, d)),
|
||||
None => lighting::flat(pad, state, pos, &q.positions, q.facing),
|
||||
};
|
||||
([lighting::gray(directional); 4], [coords; 4], [1.0f32; 4])
|
||||
([lighting::gray(directional); 4], [coords; 4])
|
||||
};
|
||||
let tint = Self::tint(state, q.tint_index, tints, pos);
|
||||
// OptiFine connected textures: another sprite, or one per quarter, and the overlays
|
||||
@@ -1647,18 +1493,12 @@ impl Mesher<'_> {
|
||||
None => [g; 3],
|
||||
};
|
||||
let p = q.positions[i];
|
||||
let color = if self.separate_ao {
|
||||
let ao_byte = (ao_factor[i] * 255.0).round().clamp(0.0, 255.0) as u32;
|
||||
ao_byte << 24 | (pack_rgb(rgb) & 0x00FF_FFFF)
|
||||
} else {
|
||||
emitting(pack_rgb(rgb), tier)
|
||||
};
|
||||
(
|
||||
TerrainVertex::new(
|
||||
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
|
||||
uv[i],
|
||||
0,
|
||||
color,
|
||||
emitting(pack_rgb(rgb), tier),
|
||||
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
|
||||
),
|
||||
uv[i],
|
||||
|
||||
@@ -189,11 +189,11 @@ impl FluidFlowWorld for FlowView<'_> {
|
||||
}
|
||||
}
|
||||
/// Emits one fluid cell at section-relative `[x, y, z]`.
|
||||
/// `(nether, shader_pack_lighting, separate_ao)`: the Nether's face shading, none when a shader pack
|
||||
/// lights the faces (`Mesher::shader_pack_lighting`), and whether to keep pure alpha for separate AO.
|
||||
/// `(nether, shader_pack_lighting)`: the Nether's face shading, or none when a shader pack lights
|
||||
/// the faces (`Mesher::shader_pack_lighting`).
|
||||
pub(super) fn tesselate(
|
||||
tables: &MeshTables,
|
||||
(nether, shader_pack_lighting, separate_ao): (bool, bool, bool),
|
||||
(nether, shader_pack_lighting): (bool, bool),
|
||||
pad: &Padded,
|
||||
water_color: u32,
|
||||
out: &mut SectionMesh,
|
||||
@@ -244,11 +244,7 @@ pub(super) fn tesselate(
|
||||
};
|
||||
let color = |by: f32| {
|
||||
let rgb = [16, 8, 0].map(|shift| lighting::scale((tint >> shift & 255) as i32, by) as u32);
|
||||
if separate_ao {
|
||||
pack_rgb(rgb)
|
||||
} else {
|
||||
crate::mesher::emitting(pack_rgb(rgb), emission)
|
||||
}
|
||||
crate::mesher::emitting(pack_rgb(rgb), emission)
|
||||
};
|
||||
let height = |p: [i32; 3]| -> f32 {
|
||||
let s = at(p);
|
||||
@@ -334,11 +330,7 @@ pub(super) fn tesselate(
|
||||
if tables.is_animated(layer) && !sprite_layers.contains(&layer) {
|
||||
sprite_layers.push(layer);
|
||||
}
|
||||
let color = if separate_ao {
|
||||
color
|
||||
} else {
|
||||
fluid_face(color, face)
|
||||
};
|
||||
let color = fluid_face(color, face);
|
||||
let vertex = |(pos, uv)| TerrainVertex::new(pos, uv, layer, color, light);
|
||||
target.extend(v.map(vertex));
|
||||
if back {
|
||||
|
||||
@@ -72,7 +72,6 @@ fn mesh(
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: false,
|
||||
shader_pack_lighting: false,
|
||||
separate_ao: false,
|
||||
assets: &f.assets,
|
||||
render: &f.render,
|
||||
tables: &f.tables,
|
||||
|
||||
@@ -83,17 +83,9 @@ fn weighted_blend(c: [u32; 4], w: [f32; 4]) -> u32 {
|
||||
};
|
||||
channel(0) | channel(16) << 16
|
||||
}
|
||||
/// `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).
|
||||
/// `ARGB.gray` channel: `Mth.floor(brightness * 255)`.
|
||||
pub(super) fn gray(brightness: f32) -> i32 {
|
||||
let v = brightness * 255.0;
|
||||
let whole = v as i32;
|
||||
if whole as f32 > v {
|
||||
whole.saturating_sub(1)
|
||||
} else {
|
||||
whole
|
||||
}
|
||||
(brightness * 255.0).floor() as i32
|
||||
}
|
||||
/// One channel of `ARGB.scaleRGB(color, float)`.
|
||||
pub(super) fn scale(channel: i32, by: f32) -> i32 {
|
||||
@@ -414,28 +406,6 @@ 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,5 +1,4 @@
|
||||
use super::*;
|
||||
use crate::chunk::Light;
|
||||
|
||||
#[test]
|
||||
fn padded_reuse_resets_missing_neighbors_and_matches_chunk_reads_across_layouts() {
|
||||
@@ -46,65 +45,6 @@ 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.
|
||||
@@ -132,7 +72,6 @@ fn a_shader_pack_lights_the_faces_itself() {
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: false,
|
||||
shader_pack_lighting,
|
||||
separate_ao: false,
|
||||
assets: &assets,
|
||||
render: &render,
|
||||
tables: &tables,
|
||||
@@ -159,54 +98,6 @@ fn a_shader_pack_lights_the_faces_itself() {
|
||||
assert_eq!(colours(true).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn separate_ao_passes_ao_in_alpha_without_darkening_rgb() {
|
||||
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")
|
||||
});
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let biomes = Biomes::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
|
||||
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
|
||||
let stone = assets.registry.default_state("minecraft:stone").unwrap().0;
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
// Place stone with neighbouring blocks to create AO variations.
|
||||
chunk.set(8, MIN_Y + 8, 8, stone);
|
||||
chunk.set(9, MIN_Y + 8, 8, stone);
|
||||
let mut region = [[None; 3]; 3];
|
||||
region[1][1] = Some(&chunk);
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: false,
|
||||
shader_pack_lighting: true,
|
||||
separate_ao: true,
|
||||
assets: &assets,
|
||||
render: &render,
|
||||
tables: &tables,
|
||||
biomes: &biomes,
|
||||
visibility,
|
||||
zoom: BiomeZoom::new(0),
|
||||
blend_radius: 0,
|
||||
smooth_lighting: true,
|
||||
cutout_leaves: true,
|
||||
translucent_layers: &[],
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
};
|
||||
let mesh = mesher.mesh(®ion, 0);
|
||||
assert!(!mesh.solid.is_empty());
|
||||
for v in &mesh.solid {
|
||||
// RGB components should be pure un-darkened white (0xFFFFFF) because shader_pack_lighting is on.
|
||||
assert_eq!(v.color & 0x00FF_FFFF, 0x00FF_FFFF);
|
||||
// Alpha holds the AO factor, which must be non-zero.
|
||||
assert!(v.color >> 24 > 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
|
||||
@@ -258,7 +149,6 @@ fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: false,
|
||||
shader_pack_lighting: false,
|
||||
separate_ao: false,
|
||||
assets: &assets,
|
||||
render: &render,
|
||||
tables: &tables,
|
||||
@@ -291,7 +181,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.solid_rows.capacity(), 0);
|
||||
assert_eq!(scratch.visibility.capacity(), 0);
|
||||
}
|
||||
fresh.sort_solid();
|
||||
recycled.sort_solid();
|
||||
|
||||
@@ -103,6 +103,22 @@ impl Sides {
|
||||
}
|
||||
}
|
||||
|
||||
/// A sort with these parts' `ends` and `planes`, for tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn from_parts(
|
||||
ends: [u32; 14],
|
||||
grouped: bool,
|
||||
split: bool,
|
||||
planes: [f32; 7],
|
||||
) -> Self {
|
||||
Self {
|
||||
ends,
|
||||
grouped,
|
||||
split,
|
||||
planes,
|
||||
}
|
||||
}
|
||||
|
||||
/// The number of solid quads the buckets hold.
|
||||
pub fn quads(&self) -> u32 {
|
||||
self.ends[13]
|
||||
@@ -128,6 +144,26 @@ impl Sides {
|
||||
self.runs(seen, part, draw);
|
||||
}
|
||||
|
||||
/// How many quads `part` draws facing every way (`every`).
|
||||
pub fn part_quads(&self, part: Part) -> u32 {
|
||||
let mut quads = 0;
|
||||
self.every(part, |_, count| quads += count);
|
||||
quads
|
||||
}
|
||||
|
||||
/// The facts `visible_from` reads, for the GPU to find the same runs
|
||||
/// (gpu_lists.wgsl): the parts' ends, the planes' bits,
|
||||
/// then 1 when grouped plus 2 when split.
|
||||
pub fn gpu_row(&self) -> [u32; 22] {
|
||||
let mut row = [0; 22];
|
||||
row[..14].copy_from_slice(&self.ends);
|
||||
for (to, plane) in row[14..21].iter_mut().zip(self.planes) {
|
||||
*to = plane.to_bits();
|
||||
}
|
||||
row[21] = u32::from(self.grouped) | u32::from(self.split) << 1;
|
||||
row
|
||||
}
|
||||
|
||||
/// The runs of `part`'s quads facing every way.
|
||||
pub fn every(&self, part: Part, draw: impl FnMut(u32, u32)) {
|
||||
self.runs(|_| true, part, draw);
|
||||
@@ -203,8 +239,7 @@ 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; quads that tie keep the mesher's order (the quad's index ends its
|
||||
/// sort key), so coplanar overlays still draw over their base.
|
||||
/// bucket; stable sorting preserves the mesher's order for coplanar overlays.
|
||||
pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
|
||||
sort_parts(solid, GROUPED.load(Ordering::Relaxed))
|
||||
}
|
||||
@@ -214,9 +249,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 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.
|
||||
/// `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.
|
||||
#[derive(Default)]
|
||||
struct SortScratch {
|
||||
slots: Vec<u8>,
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
//! 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,72 +190,18 @@ fn visibility_matches_reference() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
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() {
|
||||
fn visibility_scratch_reuse_keeps_shortcuts_and_subsequent_fills_exact() {
|
||||
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));
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1264,7 +1264,7 @@ pub enum Page {
|
||||
Online,
|
||||
/// Splitscreen: the profile another player joins as (`split_profiles`).
|
||||
SplitProfiles,
|
||||
/// Splitscreen: the monitor map and draft window arrangement.
|
||||
/// Splitscreen: enable joining on this computer.
|
||||
SplitSetup,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -1308,10 +1308,6 @@ enum Act {
|
||||
/// Credits & Attribution's Credits button (WinScreen without the poem).
|
||||
Credits,
|
||||
CopyAgentDetails,
|
||||
/// AI Agent: start or stop the repro recording.
|
||||
ToggleRecording,
|
||||
/// AI Agent: write the repro bundle now.
|
||||
ExportRepro,
|
||||
}
|
||||
enum WKind {
|
||||
Button {
|
||||
@@ -1439,10 +1435,6 @@ pub enum Event {
|
||||
Credits,
|
||||
/// Copy how an agent connects (the AI Agent page).
|
||||
CopyAgentDetails,
|
||||
/// The AI Agent page's recording button: start or stop.
|
||||
ToggleRecording,
|
||||
/// The AI Agent page's export button.
|
||||
ExportRepro,
|
||||
/// The narrator option changed (after its `Changed`): the option's
|
||||
/// `onValueUpdate`, `GameNarrator.updateNarratorStatus`.
|
||||
Narrator,
|
||||
@@ -1520,17 +1512,10 @@ 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).
|
||||
@@ -1543,6 +1528,8 @@ 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 {
|
||||
@@ -1581,16 +1568,14 @@ 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2108,7 +2093,6 @@ 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;
|
||||
@@ -2119,7 +2103,6 @@ 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("");
|
||||
@@ -2139,9 +2122,6 @@ impl OptionsUi {
|
||||
) {
|
||||
self.graphics.reset();
|
||||
}
|
||||
if page == Page::SplitSetup {
|
||||
self.split_setup.reset();
|
||||
}
|
||||
if page == Page::SplitProfiles {
|
||||
self.split_profiles.reset();
|
||||
}
|
||||
@@ -2282,7 +2262,14 @@ impl OptionsUi {
|
||||
|
||||
fn list_rows(&self, s: &Settings) -> Vec<Row> {
|
||||
match self.page() {
|
||||
Page::SplitSetup => Vec::new(),
|
||||
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::Extras => {
|
||||
// Each section's switches two to a row, as vanilla's
|
||||
// OptionsList.addSmall lays out an options screen.
|
||||
@@ -2389,30 +2376,6 @@ impl OptionsUi {
|
||||
Act::CopyAgentDetails,
|
||||
)),
|
||||
)];
|
||||
rows.push(Row::Buttons(
|
||||
button(
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
tr(if self.agent_recording {
|
||||
"horizon.agent.record_stop"
|
||||
} else {
|
||||
"horizon.agent.record_session"
|
||||
}),
|
||||
Act::ToggleRecording,
|
||||
),
|
||||
Some(button(
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
tr("horizon.agent.export_repro"),
|
||||
Act::ExportRepro,
|
||||
)),
|
||||
));
|
||||
rows.push(Row::Buttons(
|
||||
self.extra_button(s, crate::extras::Item::ReproKeys),
|
||||
None,
|
||||
));
|
||||
rows.extend(self.agent_status.iter().cloned().map(Row::Note));
|
||||
rows
|
||||
}
|
||||
@@ -3383,11 +3346,6 @@ 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);
|
||||
@@ -3688,26 +3646,6 @@ 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 {
|
||||
@@ -3812,7 +3750,6 @@ 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;
|
||||
@@ -3825,7 +3762,6 @@ 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;
|
||||
@@ -3939,10 +3875,7 @@ 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, ..
|
||||
@@ -4061,8 +3994,6 @@ impl OptionsUi {
|
||||
}
|
||||
Act::Credits => events.push(Event::Credits),
|
||||
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
|
||||
Act::ToggleRecording => events.push(Event::ToggleRecording),
|
||||
Act::ExportRepro => events.push(Event::ExportRepro),
|
||||
Act::None => {}
|
||||
}
|
||||
}
|
||||
@@ -4118,11 +4049,8 @@ 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()
|
||||
}
|
||||
@@ -4167,19 +4095,15 @@ impl OptionsUi {
|
||||
}
|
||||
events
|
||||
}
|
||||
/// 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.
|
||||
/// 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.
|
||||
///
|
||||
/// **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.
|
||||
/// **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.
|
||||
pub fn wheel(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
@@ -4231,10 +4155,7 @@ impl OptionsUi {
|
||||
}
|
||||
// Horizon improvement: a keyboard-focused slider or cycle button
|
||||
// intercepts the wheel before hover detection (see doc-comment above).
|
||||
// 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 {
|
||||
if amount != 0.0 {
|
||||
let (widgets, _, _, _) = self.layout(s, size.0, size.1);
|
||||
match &self.focus {
|
||||
Some(FocusId::Slider(opt)) => {
|
||||
@@ -4420,7 +4341,6 @@ 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;
|
||||
@@ -4764,7 +4684,6 @@ 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 {
|
||||
@@ -4803,7 +4722,6 @@ 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 {
|
||||
@@ -4817,7 +4735,6 @@ impl OptionsUi {
|
||||
}
|
||||
} else {
|
||||
self.focus = Some(focusables[0].0.clone());
|
||||
self.focus_from_keyboard = true;
|
||||
self.can_change_value = false;
|
||||
}
|
||||
return Vec::new();
|
||||
@@ -4840,7 +4757,6 @@ 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 {
|
||||
@@ -4858,7 +4774,6 @@ impl OptionsUi {
|
||||
}
|
||||
} else {
|
||||
self.focus = Some(focusables[0].0.clone());
|
||||
self.focus_from_keyboard = true;
|
||||
self.can_change_value = false;
|
||||
}
|
||||
return Vec::new();
|
||||
@@ -4882,7 +4797,6 @@ 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);
|
||||
@@ -4891,7 +4805,6 @@ impl OptionsUi {
|
||||
}
|
||||
Act::Done => {
|
||||
self.focus = None;
|
||||
self.focus_from_keyboard = false;
|
||||
self.can_change_value = false;
|
||||
events.extend(self.back(s));
|
||||
}
|
||||
@@ -4931,7 +4844,6 @@ 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));
|
||||
@@ -4956,7 +4868,6 @@ 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);
|
||||
@@ -4974,7 +4885,6 @@ 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;
|
||||
}
|
||||
@@ -5007,8 +4917,6 @@ impl OptionsUi {
|
||||
}
|
||||
Act::Credits => events.push(Event::Credits),
|
||||
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
|
||||
Act::ToggleRecording => events.push(Event::ToggleRecording),
|
||||
Act::ExportRepro => events.push(Event::ExportRepro),
|
||||
Act::None => {}
|
||||
}
|
||||
return events;
|
||||
@@ -7239,80 +7147,6 @@ 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,6 +1,5 @@
|
||||
//! Splitscreen's layout, pure data tested without a window: where each player's view goes in
|
||||
//! 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.
|
||||
//! its window (`place`).
|
||||
use winit::dpi::PhysicalSize;
|
||||
|
||||
/// Players at most: P1 and fifteen more.
|
||||
@@ -26,6 +25,30 @@ 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> {
|
||||
@@ -129,310 +152,10 @@ 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)
|
||||
}
|
||||
@@ -549,97 +272,6 @@ 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]
|
||||
@@ -661,98 +293,4 @@ 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));
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
//! 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,67 +6,34 @@ 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},
|
||||
sync::{
|
||||
Arc, Condvar, Mutex, MutexGuard, RwLock, TryLockError,
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
},
|
||||
thread,
|
||||
};
|
||||
|
||||
/// 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 of the shared pool
|
||||
/// (`horizon_threads::jobs`) keeps one `Worker` per streamer.
|
||||
/// must be thread-safe; each worker thread owns one `Worker`.
|
||||
pub trait ChunkGenerator: Send + Sync + 'static {
|
||||
type Worker: Send + 'static;
|
||||
fn worker(&self) -> Self::Worker;
|
||||
@@ -234,7 +201,6 @@ impl<G: ChunkGenerator> Saves for G {
|
||||
struct MeshSettings {
|
||||
nether: bool,
|
||||
shader_pack_lighting: bool,
|
||||
separate_ao: bool,
|
||||
zoom: BiomeZoom,
|
||||
blend_radius: i32,
|
||||
smooth_lighting: bool,
|
||||
@@ -310,6 +276,8 @@ 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>>,
|
||||
@@ -317,6 +285,11 @@ 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 {
|
||||
@@ -391,11 +364,7 @@ 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>,
|
||||
/// 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,
|
||||
threads: Vec<thread::JoinHandle<()>>,
|
||||
order: u64,
|
||||
/// Ticks since the columns and protos that fell out of range were swept.
|
||||
scan_age: u8,
|
||||
@@ -466,9 +435,6 @@ pub struct Streaming {
|
||||
/// A shader pack lights the faces: sections mesh without face shading
|
||||
/// (`Mesher::shader_pack_lighting`).
|
||||
pub shader_pack_lighting: bool,
|
||||
/// OptiFine/Iris separate ambient occlusion: vertex RGB has pure tint and alpha holds AO factor
|
||||
/// (`Mesher::separate_ao`).
|
||||
pub separate_ao: bool,
|
||||
/// `BiomeManager`'s zoom seed (the hashed world seed) and `biomeBlendRadius`.
|
||||
pub biome_zoom: BiomeZoom,
|
||||
pub biome_blend: i32,
|
||||
@@ -510,14 +476,6 @@ 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,
|
||||
@@ -527,52 +485,17 @@ 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 same_ranges(&self, ranges: &Ranges<'_>) -> bool {
|
||||
self.center == ranges.center
|
||||
fn unchanged(&self, ranges: &Ranges<'_>, world: &World) -> bool {
|
||||
self.exhausted
|
||||
&& 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;
|
||||
@@ -610,30 +533,6 @@ 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'
|
||||
@@ -693,218 +592,28 @@ 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() {
|
||||
self.plan.clear();
|
||||
self.plan_basis = None;
|
||||
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);
|
||||
} else {
|
||||
// A bounded batch may leave candidates behind: the next calls
|
||||
// take them from the plan as capacity opens.
|
||||
// A bounded batch may leave candidates behind. Keep scanning
|
||||
// as capacity opens, even if no range or membership changed.
|
||||
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,
|
||||
separate_ao: settings.separate_ao,
|
||||
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>,
|
||||
@@ -920,23 +629,190 @@ 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 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 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 non_air_sections = {
|
||||
let generator = generator.clone();
|
||||
Arc::new(move |p: &Proto| generator.non_air_sections(p))
|
||||
@@ -944,8 +820,7 @@ impl Streaming {
|
||||
Self {
|
||||
shared,
|
||||
non_air_sections,
|
||||
jobs,
|
||||
workers,
|
||||
threads,
|
||||
order: 0,
|
||||
scan_age: 0,
|
||||
scan_area: ((i32::MIN, i32::MIN), i32::MIN),
|
||||
@@ -984,7 +859,6 @@ impl Streaming {
|
||||
sky_light: true,
|
||||
nether_shading: false,
|
||||
shader_pack_lighting: false,
|
||||
separate_ao: false,
|
||||
biome_zoom: BiomeZoom::new(0),
|
||||
biome_blend: 2,
|
||||
smooth_lighting: true,
|
||||
@@ -1145,7 +1019,7 @@ impl Streaming {
|
||||
order: self.order,
|
||||
job,
|
||||
});
|
||||
self.jobs.notify();
|
||||
self.shared.ready.notify_one();
|
||||
}
|
||||
/// Rank the queued jobs again when what their priorities were computed
|
||||
/// from changed: the centre, the extra areas, the band, or the loading
|
||||
@@ -1615,7 +1489,7 @@ impl Streaming {
|
||||
.map(|(k, _)| *k)
|
||||
.collect();
|
||||
if !far.is_empty() {
|
||||
self.generation_scan.invalidate();
|
||||
self.generation_scan.exhausted = false;
|
||||
}
|
||||
// Those decoration wrote to are saved first (see
|
||||
// `ChunkGenerator::save_protos`), and dropped once back.
|
||||
@@ -1664,7 +1538,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.workers;
|
||||
let workers = self.threads.len();
|
||||
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.
|
||||
@@ -1726,24 +1600,14 @@ 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| {
|
||||
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
|
||||
}) {
|
||||
if self
|
||||
.decoration_order
|
||||
.blocked(x, z, |nx, nz| waits_for(self, nx, nz))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let keys: Vec<(i32, i32)> =
|
||||
@@ -1763,7 +1627,6 @@ 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)| {
|
||||
@@ -1817,7 +1680,7 @@ impl Streaming {
|
||||
for key in gone {
|
||||
world.dirty_light.remove(&key);
|
||||
}
|
||||
let room = (self.workers * 4).saturating_sub(self.lighting.len());
|
||||
let room = (self.threads.len() * 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 {
|
||||
@@ -1903,7 +1766,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.workers * 32).saturating_sub(self.meshing.len());
|
||||
let room = (self.threads.len() * 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 {
|
||||
@@ -1926,7 +1789,6 @@ impl Streaming {
|
||||
MeshSettings {
|
||||
nether: self.nether_shading,
|
||||
shader_pack_lighting: self.shader_pack_lighting,
|
||||
separate_ao: self.separate_ao,
|
||||
zoom: self.biome_zoom,
|
||||
blend_radius: self.biome_blend,
|
||||
smooth_lighting: self.smooth_lighting,
|
||||
@@ -2576,8 +2438,11 @@ impl Drop for Streaming {
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
// `jobs` leaves the pool after this, once its running jobs are
|
||||
// done; the jobs still queued go with the queue.
|
||||
self.shared.stop.store(true, Ordering::Relaxed);
|
||||
self.shared.ready.notify_all();
|
||||
for t in self.threads.drain(..) {
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The queued jobs' priorities computed again from each one's column
|
||||
@@ -2689,7 +2554,14 @@ 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 {
|
||||
a.same_as(b)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Whether two biome containers (64 cells) hold the same values.
|
||||
fn same_values(
|
||||
@@ -3271,70 +3143,6 @@ 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,19 +510,6 @@ 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,9 +114,6 @@ 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,55 +306,6 @@
|
||||
"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",
|
||||
@@ -385,20 +336,5 @@
|
||||
"horizon.agent.off": "Off: agents can't reach this game.",
|
||||
"horizon.agent.session_opened": "Opened for this session by the program that started the game.",
|
||||
"horizon.agent.socket": "Socket: %s (copy the details for its full path)",
|
||||
"horizon.agent.local_only": "Local only: agents on this computer, never the network.",
|
||||
"%s nits": "%s nits",
|
||||
"Auto (%s nits)": "Auto (%s nits)",
|
||||
"%.2f nits": "%.2f nits",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Hides models behind opaque blocks. Entities update. Off with custom packs.",
|
||||
"horizon.extras.repro_keys": "Recording shortcuts",
|
||||
"horizon.extras.repro_keys.tooltip": "F3+R starts or stops a repro recording and F3+E writes its bundle. Off by default: a recording copies the world.",
|
||||
"horizon.agent.record_session": "Start Recording Session",
|
||||
"horizon.agent.record_stop": "Stop Recording Session",
|
||||
"horizon.agent.export_repro": "Export Repro Bundle",
|
||||
"horizon.repro.started": "Recording started; F3+R stops it and writes the bundle.",
|
||||
"horizon.repro.stopped": "Recording stopped: %s",
|
||||
"horizon.repro.bundle": "Repro bundle: %s",
|
||||
"horizon.repro.error": "Repro: %s",
|
||||
"horizon.repro.no_recording": "There is no recording to export yet.",
|
||||
"horizon.repro.replay_started": "Replay started: %s"
|
||||
"horizon.agent.local_only": "Local only: agents on this computer, never the network."
|
||||
}
|
||||
@@ -289,60 +289,6 @@
|
||||
"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",
|
||||
@@ -383,7 +329,12 @@
|
||||
"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.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."
|
||||
}
|
||||
@@ -24,15 +24,7 @@ fn main() {
|
||||
resource.set_manifest_file(&manifest);
|
||||
}
|
||||
if let Err(e) = resource.compile() {
|
||||
// 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}"
|
||||
);
|
||||
panic!("embedding Windows resources: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,9 +89,8 @@ fn main() -> Result<(), Error> {
|
||||
});
|
||||
let terrain = VanillaTerrain::new(&root.join("worldgen"), assets.registry, &biomes, seed)?;
|
||||
|
||||
// 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);
|
||||
// Generation and light, on the game's worker pool. Meshing is left out
|
||||
// here and measured on its own below.
|
||||
let mut streaming = Streaming::new(Arc::new(terrain), context.clone(), threads);
|
||||
streaming.mesh = false;
|
||||
streaming.center = center;
|
||||
@@ -219,7 +218,6 @@ fn mesher<'a>(context: &'a WorkerContext, seed: i64) -> Mesher<'a> {
|
||||
Mesher {
|
||||
nether_shading: false,
|
||||
shader_pack_lighting: false,
|
||||
separate_ao: false,
|
||||
zoom: BiomeZoom::new(obfuscate_seed(seed)),
|
||||
blend_radius: 2,
|
||||
smooth_lighting: true,
|
||||
|
||||
@@ -1,356 +0,0 @@
|
||||
//! 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(())
|
||||
}
|
||||
@@ -129,7 +129,6 @@ fn scene(
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: row["nether"].as_bool().unwrap_or(false),
|
||||
shader_pack_lighting: false,
|
||||
separate_ao: false,
|
||||
assets,
|
||||
render,
|
||||
tables,
|
||||
|
||||
@@ -116,39 +116,6 @@ 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))?;
|
||||
@@ -237,9 +204,7 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
|
||||
&[chunks / load.as_secs_f64()],
|
||||
);
|
||||
}
|
||||
if !time_passes(control, name, &mut report)? {
|
||||
thread::sleep(SETTLE);
|
||||
}
|
||||
thread::sleep(SETTLE);
|
||||
let [ex, ey, ez] = end;
|
||||
let begin = Instant::now();
|
||||
let flight = control.call(&format!(
|
||||
@@ -534,60 +499,6 @@ 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.
|
||||
|
||||
@@ -204,9 +204,6 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
cmd!("events", Observe, "events [--since SEQ]", "what happened since SEQ, oldest first: screens, chat, deaths, respawns, items gained or lost, dimensions, worlds",
|
||||
"Every event has seq and tick; pass the reply's last as --since next time. The game keeps the newest 256.",
|
||||
["horizon-ctl events", "horizon-ctl events --since 42"], &[flag("since", Ty::Int, "only events after this seq")], mcp: true, local: false),
|
||||
cmd!("input-log", Observe, "input-log [--since SEQ] [--limit N] [--origin user|agent|game]", "recent input and who caused it: a person, an agent or the game",
|
||||
"Every event has seq, tick and origin. `user` is the window's own keyboard, mouse or pad; `agent` is the control socket (this tool); `game` is input the game made up. A `user-input` event in `events` means a person stepped in while an agent was driving. The game keeps the newest 512.",
|
||||
["horizon-ctl input-log", "horizon-ctl input-log --origin user --limit 20", "horizon-ctl input-log --since 120"], &[flag("since", Ty::Int, "only events after this seq"), flag("limit", Ty::Int, "newest N events"), flag("origin", Ty::Enum(&["user", "agent", "game"]), "user, agent or game")], mcp: true, local: false),
|
||||
cmd!("wait-ready", Session, "wait-ready [SECONDS]", "wait until the player has spawned and no chunks are pending",
|
||||
"Polls status every 250 ms; replies with the status, or an error after SECONDS (default 120).",
|
||||
["horizon-ctl wait-ready 300"], &[opt("seconds", Ty::Num, "how long to wait (default 120)")], mcp: true, local: true),
|
||||
@@ -220,8 +217,6 @@ 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)",
|
||||
@@ -457,23 +452,6 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
cmd!("fly", Scripting, "fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]]", "fly the camera along a line, then reply with the frame summary",
|
||||
"The reply's tick_ms and tick_ms_max are the world's smoothed tick time over the flight (the server thread's when single player runs on one). With raw, the reply also lists every frame time in milliseconds (frame_ms).",
|
||||
["horizon-ctl fly 0 100 0 500 100 0 20"], &[], mcp: false, local: false),
|
||||
// Repro recordings
|
||||
cmd!("record-start", Scripting, "record-start [NAME] [--no-world]", "record input, state and the world for a bug repro",
|
||||
"Writes recordings/<stamp>-NAME/: inputs.jsonl (who caused each input: user, agent or game), state.jsonl (the player and world every second), events.jsonl and, in single player, a copy of the world. The recording starts from a fresh save. Replies once the world copy is done.",
|
||||
["horizon-ctl record-start repro-bug", "horizon-ctl record-start --no-world"], &[opt("name", Ty::Str, "folder name after the timestamp (default session)"), flag("no-world", Ty::Bool, "do not copy the world (a server session)")], mcp: true, local: false),
|
||||
cmd!("record-stop", Scripting, "record-stop [--export]", "stop the recording (and write the repro zip)",
|
||||
"Without --export only the recording folder is closed; repro-export zips it later. With --export the reply carries the zip path.",
|
||||
["horizon-ctl record-stop", "horizon-ctl record-stop --export"], &[flag("export", Ty::Bool, "write the repro bundle too")], mcp: true, local: false),
|
||||
cmd!("record-status", Scripting, "record-status", "the running recording and replay, if any", "",
|
||||
["horizon-ctl record-status"], &[], mcp: true, local: false),
|
||||
cmd!("record-list", Scripting, "record-list", "the recordings and repro bundles on disk", "", ["horizon-ctl record-list"], &[], mcp: true, local: false),
|
||||
cmd!("record-replay", Scripting, "record-replay NAME [--stop] [--in-place] [--keep-world] [--timeout S]", "replay a recording, best effort",
|
||||
"Loads the recording's world copy into saves/, opens it and feeds the recorded input back at its ticks. Only input and movement commands replay (never world, network or account ones). --in-place feeds the input into the world already open (a server recording needs it); --keep-world leaves the copied world behind when it ends. --stop ends a running replay. Same build and machine only; state.jsonl is the oracle, not a promise.",
|
||||
["horizon-ctl record-replay repro-bug", "horizon-ctl record-replay repro-bug --stop", "horizon-ctl record-replay server-fight --in-place"],
|
||||
&[opt("name", Ty::Str, "recording from record-list"), flag("stop", Ty::Bool, "stop the running replay"), flag("in-place", Ty::Bool, "feed the open world instead of opening the copy"), flag("keep-world", Ty::Bool, "leave the copied world in saves/"), flag("timeout", Ty::Int, "give up after this many seconds (default 300)")], mcp: true, local: false),
|
||||
cmd!("repro-export", Scripting, "repro-export [NAME] [PATH] [--no-world]", "zip a recording with the logs, crash report and status",
|
||||
"Writes repro/<stamp>-NAME.zip: the recording, logs/latest.log, the newest crash report, a status.json and a README. The player attaches it to the bug report; nothing is uploaded. Stops a running recording first.",
|
||||
["horizon-ctl repro-export", "horizon-ctl repro-export repro-bug out.zip"], &[opt("name", Ty::Str, "recording from record-list (default the newest)"), opt("path", Ty::Str, "where to write the zip"), flag("no-world", Ty::Bool, "leave the world out")], mcp: true, local: false),
|
||||
];
|
||||
|
||||
pub fn find(name: &str) -> Option<&'static Cmd> {
|
||||
|
||||
@@ -314,26 +314,13 @@ fn usage(name: &str) -> String {
|
||||
commands::find(name).map_or_else(|| name.to_owned(), |c| format!("horizon-ctl {}", c.usage))
|
||||
}
|
||||
|
||||
/// Paths are written by the game process: make them absolute here.
|
||||
/// Screenshot paths are written by the game process: make them absolute here.
|
||||
fn absolute_paths(words: &mut [String]) {
|
||||
let Ok(cwd) = std::env::current_dir() else {
|
||||
return;
|
||||
};
|
||||
match words.first().map(String::as_str) {
|
||||
Some("screenshot") => {
|
||||
if let Some(path) = words.get_mut(1) {
|
||||
*path = cwd.join(&*path).display().to_string();
|
||||
}
|
||||
}
|
||||
// `repro-export [NAME] [PATH]`: the second positional is the zip.
|
||||
Some("repro-export") => {
|
||||
if let Some(path) = words.get_mut(2)
|
||||
&& (path.contains('/') || path.ends_with(".zip"))
|
||||
{
|
||||
*path = cwd.join(&*path).display().to_string();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
if words.first().map(String::as_str) == Some("screenshot")
|
||||
&& let Some(path) = words.get_mut(1)
|
||||
&& let Ok(cwd) = std::env::current_dir()
|
||||
{
|
||||
*path = cwd.join(&*path).display().to_string();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,20 @@
|
||||
//! shared table, run through the same `Session` as the CLI.
|
||||
use crate::{Session, commands};
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
/// What an agent reads before its first call.
|
||||
const INSTRUCTIONS: &str = "Play Horizon, a Rust reimplementation of Minecraft Java Edition. Start with launch (it finds the game installed by the Horizon launcher and opens or creates a world), then read the world with state, inventory, screen, entities and find_block, and act with goto, look_at_block, break, place, interact, click_slot and click_button. Actions block until they finish and report what happened. Coordinates are block coordinates; yaw 0 faces south (+Z).";
|
||||
/// Revisions this server implements. Older ones are not claimed: only the revision actually
|
||||
/// implemented is advertised (no JSON-RPC batches, no other capability than `tools`).
|
||||
const SUPPORTED: &[&str] = &["2025-06-18"];
|
||||
|
||||
/// The revisions this server advertises: `horizon_mcp` serves them (`2026-07-28` stateless,
|
||||
/// `2025-06-18` for the clients that still open with `initialize`).
|
||||
fn server() -> horizon_mcp::Server<'static> {
|
||||
horizon_mcp::Server::new("horizon-ctl", horizon_core::release::VERSION, INSTRUCTIONS)
|
||||
/// MCP lifecycle: answer with the client's version if supported, otherwise one we support.
|
||||
fn negotiate_version(params: &Value) -> &'static str {
|
||||
let wanted = params.get("protocolVersion").and_then(Value::as_str);
|
||||
SUPPORTED
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|v| Some(*v) == wanted)
|
||||
.unwrap_or(SUPPORTED[0])
|
||||
}
|
||||
|
||||
fn tools() -> Vec<Value> {
|
||||
@@ -137,10 +143,15 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// The two tool methods; `horizon_mcp` answers the protocol's own (`initialize`,
|
||||
/// `server/discover`) and frames these results for the revision the request speaks.
|
||||
fn handle(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
|
||||
match method {
|
||||
"initialize" => Ok(json!({
|
||||
"protocolVersion": negotiate_version(params),
|
||||
"capabilities": { "tools": {} },
|
||||
"serverInfo": { "name": "horizon-ctl", "version": horizon_core::release::VERSION },
|
||||
"instructions": "Play Horizon, a Rust reimplementation of Minecraft Java Edition. Start with launch (it finds the game installed by the Horizon launcher and opens or creates a world), then read the world with state, inventory, screen, entities and find_block, and act with goto, look_at_block, break, place, interact, click_slot and click_button. Actions block until they finish and report what happened. Coordinates are block coordinates; yaw 0 faces south (+Z).",
|
||||
})),
|
||||
"ping" => Ok(json!({})),
|
||||
"tools/list" => Ok(json!({ "tools": tools() })),
|
||||
"tools/call" => {
|
||||
let name = params
|
||||
@@ -160,13 +171,52 @@ impl Session {
|
||||
}
|
||||
|
||||
pub fn serve(session: &mut Session) -> Result<(), String> {
|
||||
server().serve(|method, params| session.handle(method, params))
|
||||
let stdin = std::io::stdin();
|
||||
let mut stdout = std::io::stdout();
|
||||
for line in stdin.lock().lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Err(e) => Some(
|
||||
json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": e.to_string() } }),
|
||||
),
|
||||
Ok(msg) => {
|
||||
let method = msg
|
||||
.get("method")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
// Notifications (no id) get no reply.
|
||||
msg.get("id").cloned().map(|id| match session.handle(method, ¶ms) {
|
||||
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
|
||||
Err((code, message)) => json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }),
|
||||
})
|
||||
}
|
||||
};
|
||||
if let Some(reply) = reply {
|
||||
writeln!(stdout, "{reply}")
|
||||
.and_then(|_| stdout.flush())
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
#[test]
|
||||
fn version_negotiation() {
|
||||
let v = |p: Value| negotiate_version(&p);
|
||||
assert_eq!(v(json!({ "protocolVersion": "2025-06-18" })), "2025-06-18");
|
||||
assert_eq!(v(json!({ "protocolVersion": "2026-07-28" })), "2025-06-18");
|
||||
assert_eq!(v(json!({ "protocolVersion": "1999-01-01" })), "2025-06-18");
|
||||
assert_eq!(v(json!({ "protocolVersion": 7 })), "2025-06-18");
|
||||
assert_eq!(v(json!({})), "2025-06-18");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_matches_rfc_4648() {
|
||||
@@ -205,66 +255,28 @@ mod tests {
|
||||
#[test]
|
||||
fn protocol_basics() {
|
||||
let mut s = Session::new(std::env::temp_dir().join("horizon-ctl-mcp-test.sock"), None);
|
||||
let mut app = |method: &str, params: &Value| s.handle(method, params);
|
||||
let server = server();
|
||||
let line = |method: &str, params: Value| {
|
||||
json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string()
|
||||
};
|
||||
|
||||
// A client that opens with the handshake revision.
|
||||
let init = server
|
||||
.handle_line(
|
||||
&line("initialize", json!({ "protocolVersion": "2025-06-18" })),
|
||||
&mut app,
|
||||
let init = s
|
||||
.handle("initialize", &json!({ "protocolVersion": "2025-06-18" }))
|
||||
.unwrap();
|
||||
assert_eq!(init["protocolVersion"], "2025-06-18");
|
||||
assert!(s.handle("nope", &json!({})).is_err());
|
||||
let r = s
|
||||
.handle(
|
||||
"tools/call",
|
||||
&json!({ "name": "goto", "arguments": { "x": 1 } }),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(init["result"]["protocolVersion"], "2025-06-18");
|
||||
assert_eq!(init["result"]["serverInfo"]["name"], "horizon-ctl");
|
||||
|
||||
// A modern client: stateless, and the same tools.
|
||||
let mut meta = serde_json::Map::new();
|
||||
meta.insert(
|
||||
horizon_mcp::VERSION_KEY.to_owned(),
|
||||
json!(horizon_mcp::MODERN),
|
||||
);
|
||||
meta.insert(horizon_mcp::CLIENT_CAPABILITIES_KEY.to_owned(), json!({}));
|
||||
let params = json!({ "_meta": Value::Object(meta) });
|
||||
let list = server
|
||||
.handle_line(&line("tools/list", params), &mut app)
|
||||
.unwrap();
|
||||
assert_eq!(list["result"]["resultType"], "complete");
|
||||
assert!(list["result"]["tools"].as_array().unwrap().len() > 3);
|
||||
|
||||
let r = server
|
||||
.handle_line(
|
||||
&line(
|
||||
"tools/call",
|
||||
json!({ "name": "goto", "arguments": { "x": 1 } }),
|
||||
),
|
||||
&mut app,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["result"]["isError"], true);
|
||||
assert_eq!(r["isError"], true);
|
||||
assert!(
|
||||
r["result"]["content"][0]["text"]
|
||||
r["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("y is required")
|
||||
);
|
||||
// No game: a connection error, reported as a tool error.
|
||||
let r = server
|
||||
.handle_line(
|
||||
&line("tools/call", json!({ "name": "state", "arguments": {} })),
|
||||
&mut app,
|
||||
)
|
||||
let r = s
|
||||
.handle("tools/call", &json!({ "name": "state", "arguments": {} }))
|
||||
.unwrap();
|
||||
assert_eq!(r["result"]["isError"], true);
|
||||
// Methods this server does not have are the protocol's to refuse.
|
||||
assert_eq!(
|
||||
server
|
||||
.handle_line(&line("nope/nope", json!({})), &mut app)
|
||||
.unwrap()["error"]["code"],
|
||||
json!(-32601)
|
||||
);
|
||||
assert_eq!(r["isError"], true);
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ pub fn native_living_breathe(
|
||||
consume: &mut i32,
|
||||
refill: &mut i32,
|
||||
) {
|
||||
crate::native_client_hooks::living_breathe(entity, kind, can_breathe, consume, refill);
|
||||
let _ = (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_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,
|
||||
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,
|
||||
};
|
||||
pub mod attack_strength;
|
||||
pub mod attribute_updates;
|
||||
@@ -89,7 +89,6 @@ 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>,
|
||||
) {
|
||||
crate::native_client_hooks::ingredient(kind, data, items);
|
||||
let _ = (kind, data, items);
|
||||
}
|
||||
|
||||
/// Keep first-seen order, drop the rest.
|
||||
|
||||
@@ -28,10 +28,6 @@ pub enum DebugAction {
|
||||
NetworkCharts,
|
||||
LightmapTexture,
|
||||
ImprovedTransparency,
|
||||
/// F3+R: start a repro recording, or stop it and write its bundle.
|
||||
RecordSession,
|
||||
/// F3+E: write the bundle now.
|
||||
ExportRepro,
|
||||
}
|
||||
|
||||
/// The mappings `handleDebugKeys` tests, in its order.
|
||||
@@ -73,14 +69,6 @@ pub const ACTIONS: [(&str, DebugAction); 20] = [
|
||||
),
|
||||
];
|
||||
|
||||
/// Horizon's own debug keys: fixed, so they never enter the options table that
|
||||
/// vanilla parity compares (`MAPPINGS`). Off until the AI Agent page enables
|
||||
/// them; `Game` decides that in `debug_action`.
|
||||
const HORIZON_ACTIONS: [(&str, DebugAction); 2] = [
|
||||
("key.keyboard.r", DebugAction::RecordSession),
|
||||
("key.keyboard.e", DebugAction::ExportRepro),
|
||||
];
|
||||
|
||||
/// GLFW's key actions.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum KeyAction {
|
||||
@@ -227,11 +215,6 @@ impl DebugKeys {
|
||||
did |= host.debug_action(action, shift);
|
||||
}
|
||||
}
|
||||
for (name, action) in HORIZON_ACTIONS {
|
||||
if key == name {
|
||||
did |= host.debug_action(action, shift);
|
||||
}
|
||||
}
|
||||
did
|
||||
}
|
||||
/// `KeyboardHandler.tick`.
|
||||
|
||||
@@ -3725,10 +3725,6 @@ 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))) {
|
||||
@@ -4585,49 +4581,6 @@ 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,59 +74,12 @@ 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.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -10,7 +10,6 @@ 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},
|
||||
@@ -627,44 +626,18 @@ 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(&allowed);
|
||||
self.adv.unlocked.extend_from_slice(unlocked);
|
||||
} else {
|
||||
self.recipe_toast(&allowed);
|
||||
self.recipe_toast(unlocked);
|
||||
}
|
||||
if allowed.is_empty() || self.remote.is_some() || self.no_player_here() {
|
||||
if unlocked.is_empty() || self.remote.is_some() || self.no_player_here() {
|
||||
return;
|
||||
}
|
||||
self.load_advancements();
|
||||
let tree = self.adv.tree.clone();
|
||||
for r in &allowed {
|
||||
for r in unlocked {
|
||||
let list = tree.recipe_criteria(r);
|
||||
self.trigger_criteria(&tree, &list, |c, _| {
|
||||
c["recipes"].as_str() == Some(r.as_str())
|
||||
@@ -708,9 +681,6 @@ 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);
|
||||
|
||||
@@ -466,7 +466,7 @@ impl Game<'_> {
|
||||
json!({ "kind": kind, "block": block, "entity": entity })
|
||||
}
|
||||
|
||||
pub(super) fn state_value(&self) -> Value {
|
||||
fn state_value(&self) -> Value {
|
||||
let player = self.player.as_ref().map(|p| {
|
||||
json!({
|
||||
"position": [p.position.x, p.position.y, p.position.z],
|
||||
@@ -497,10 +497,10 @@ impl Game<'_> {
|
||||
"effects": self.potions.effects.list.iter().map(|e| json!({ "id": e.id, "duration_ticks": e.duration, "amplifier": e.amplifier })).collect::<Vec<_>>(),
|
||||
})
|
||||
});
|
||||
let mut state = json!({
|
||||
json!({
|
||||
"dialog": self.dialog_status(),
|
||||
"screen": self.screen_label(),
|
||||
"menu": (self.title.is_some() || self.split_profiles_open() || self.split_setup_open()).then(|| self.title_value()),
|
||||
"menu": (self.title.is_some() || self.split_profiles_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(),
|
||||
@@ -527,13 +527,7 @@ impl Game<'_> {
|
||||
"looking_at": self.looking_at(),
|
||||
"busy": self.agent.task.as_ref().map(|t| t.name),
|
||||
"ticks": self.ticks,
|
||||
});
|
||||
// Who caused the input, and whether a repro recording runs.
|
||||
let mut input = self.input_trace.summary();
|
||||
self.repro
|
||||
.fill_summary(&mut input, self.input_trace.events());
|
||||
state["input"] = input;
|
||||
state
|
||||
})
|
||||
}
|
||||
|
||||
/// Splitscreen's profile page, a joining player's (`split_profiles`).
|
||||
@@ -541,18 +535,11 @@ 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;
|
||||
};
|
||||
@@ -670,7 +657,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn entities_value(&self, radius: f64, kind: Option<&str>) -> Value {
|
||||
fn entities_value(&self, radius: f64, kind: Option<&str>) -> Value {
|
||||
let Some(p) = &self.player else {
|
||||
return json!({ "entities": [] });
|
||||
};
|
||||
@@ -724,7 +711,7 @@ impl Game<'_> {
|
||||
json!({ "entities": list.into_iter().map(|(_, v)| v).collect::<Vec<_>>() })
|
||||
}
|
||||
|
||||
pub(super) fn inventory_value(&self) -> Value {
|
||||
fn inventory_value(&self) -> Value {
|
||||
let armor = ["head", "chest", "legs", "feet"];
|
||||
json!({
|
||||
"selected": self.selected + 1,
|
||||
@@ -755,7 +742,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() || self.split_setup_open() {
|
||||
if self.title.is_some() || self.split_profiles_open() {
|
||||
v["menu"] = self.title_value();
|
||||
}
|
||||
if self.screen == Screen::Inventory && self.title.is_none() {
|
||||
@@ -1015,7 +1002,6 @@ impl Game<'_> {
|
||||
device_id,
|
||||
position,
|
||||
},
|
||||
super::input_trace::InputOrigin::Agent,
|
||||
);
|
||||
if i == 0 {
|
||||
self.handle_window_event(
|
||||
@@ -1026,7 +1012,6 @@ impl Game<'_> {
|
||||
state: ElementState::Pressed,
|
||||
button,
|
||||
},
|
||||
super::input_trace::InputOrigin::Agent,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1038,7 +1023,6 @@ impl Game<'_> {
|
||||
state: ElementState::Released,
|
||||
button,
|
||||
},
|
||||
super::input_trace::InputOrigin::Agent,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1059,11 +1043,6 @@ impl Game<'_> {
|
||||
Ok(Handled::Deferred)
|
||||
}
|
||||
|
||||
/// A deferred agent action (goto, break, wait...) is still running.
|
||||
pub(super) fn agent_busy(&self) -> bool {
|
||||
self.agent.task.is_some()
|
||||
}
|
||||
|
||||
/// Agent commands; `Handled::No` when `words[0]` is not one of them.
|
||||
pub(super) fn agent_command(
|
||||
&mut self,
|
||||
@@ -1603,7 +1582,6 @@ impl Game<'_> {
|
||||
device_id,
|
||||
position,
|
||||
},
|
||||
super::input_trace::InputOrigin::Agent,
|
||||
);
|
||||
for _ in 0..steps.abs() as u32 {
|
||||
self.handle_window_event(
|
||||
@@ -1614,7 +1592,6 @@ impl Game<'_> {
|
||||
delta: MouseScrollDelta::LineDelta(0.0, steps.signum()),
|
||||
phase: TouchPhase::Moved,
|
||||
},
|
||||
super::input_trace::InputOrigin::Agent,
|
||||
);
|
||||
}
|
||||
ok(json!({ "ok": true, "screen": self.screen_label() }))
|
||||
@@ -1728,7 +1705,7 @@ impl Game<'_> {
|
||||
if let Some(server) = &self.integrated {
|
||||
let chat = server
|
||||
.call(move |g| g.owner_console_command(&command))
|
||||
.ok_or(super::integrated::NO_ANSWER)?;
|
||||
.ok_or("the integrated server stopped")?;
|
||||
return ok(json!({ "ok": true, "chat": chat }));
|
||||
}
|
||||
if self.remote.is_some() {
|
||||
|
||||
@@ -96,15 +96,11 @@ impl Game<'_> {
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
});
|
||||
let mut lines = status_lines(
|
||||
status_lines(
|
||||
server,
|
||||
self.agent_last.map(|t| t.elapsed().as_secs()),
|
||||
self.options.control.is_some() && !self.agent_control_on,
|
||||
);
|
||||
if let Some(line) = self.repro.agent_line(self.input_trace.events()) {
|
||||
lines.push(line);
|
||||
}
|
||||
lines
|
||||
)
|
||||
}
|
||||
|
||||
/// What Copy Connection Details puts on the clipboard: the socket, a horizon-ctl line to try,
|
||||
|
||||
@@ -600,23 +600,6 @@ 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,13 +130,8 @@ 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,18 +150,8 @@ impl Game<'_> {
|
||||
self.world
|
||||
.block_entities
|
||||
.put(key, BlockEntity::Beehive(hive));
|
||||
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);
|
||||
if stored && let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
|
||||
m.removed = true;
|
||||
}
|
||||
}
|
||||
/// `BeehiveBlockEntity.emptyAllLivingFromHive` with no player about.
|
||||
|
||||
@@ -714,16 +714,6 @@ 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,51 +215,16 @@ impl Game<'_> {
|
||||
/// `AbstractFurnaceBlockEntity.serverTick` of the furnace at `pos`, and
|
||||
/// its block's `lit` following it.
|
||||
fn tick_furnace(&mut self, pos: Pos) -> Result<()> {
|
||||
// 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 Some(BlockEntity::Furnace(f)) = self.world.block_entities.get_mut(&pos) else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut drops = Vec::new();
|
||||
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);
|
||||
}
|
||||
let lit = f.tick(
|
||||
world_recipes!(self),
|
||||
world_items!(self),
|
||||
self.res.providers(),
|
||||
&mut drops,
|
||||
);
|
||||
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(super::integrated::NO_ANSWER)?
|
||||
.ok_or("the integrated server stopped")?
|
||||
.map_err(Into::into);
|
||||
}
|
||||
// Another server's world changes only through its own commands, which
|
||||
|
||||
@@ -146,22 +146,10 @@ 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. A
|
||||
// lectern's screen goes with its menu; a book is still the screen
|
||||
// a confirmation returns to.
|
||||
// after the command: the book closes and the command runs.
|
||||
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) => {
|
||||
@@ -346,40 +334,9 @@ impl Game<'_> {
|
||||
{
|
||||
return;
|
||||
}
|
||||
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
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.write_book(p.slot as usize, p.pages, p.title)
|
||||
});
|
||||
// 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 {
|
||||
@@ -854,9 +811,6 @@ 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.
|
||||
@@ -920,21 +874,7 @@ 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,
|
||||
@@ -1083,15 +1023,6 @@ 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,7 +48,6 @@ 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);
|
||||
@@ -103,7 +102,6 @@ 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 mute and unmute
|
||||
//! players by hand with `/mute`, `/tempmute`, `/mutelist` and `/unmute` (the same words on the console).
|
||||
//! (never as `chat`), a temporary ban goes into banned-players.json, and operators lift mutes with
|
||||
//! `/unmute <player>` (or `unmute` on the console).
|
||||
use super::{Game, dedicated, lan::LanHost};
|
||||
use horizon_chat_filter::Action;
|
||||
use horizon_client::lan::hub::{HostEvent, chat_filter::Place};
|
||||
@@ -9,11 +9,8 @@ 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 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 operator level `/unmute` needs, as `/ban`'s.
|
||||
const UNMUTE_LEVEL: u8 = 3;
|
||||
|
||||
/// The reason a temporary ban gives at login.
|
||||
const BAN_REASON: &str = "Hate speech in chat (Horizon chat filter)";
|
||||
@@ -158,114 +155,27 @@ impl Game<'_> {
|
||||
allowed
|
||||
}
|
||||
|
||||
/// 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]) {
|
||||
/// `/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]) {
|
||||
let Some(g) = host.hub.guest(conn) else {
|
||||
return;
|
||||
};
|
||||
let (permission, by) = (g.permission, g.name.clone());
|
||||
let replies = if permission < MUTE_LEVEL {
|
||||
vec![translatable("command.unknown.command", &[])]
|
||||
let reply = if permission < UNMUTE_LEVEL {
|
||||
translatable("command.unknown.command", &[])
|
||||
} else if words.len() != 2 {
|
||||
literal("/unmute <player>")
|
||||
} else {
|
||||
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()
|
||||
literal(&self.unmute(host, words[1], &by))
|
||||
};
|
||||
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)),
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::SystemChat(play::SystemChat {
|
||||
content: reply,
|
||||
overlay: false,
|
||||
}),
|
||||
);
|
||||
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,7 +1469,6 @@ 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(_) => {
|
||||
@@ -1484,10 +1483,6 @@ 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);
|
||||
@@ -1499,24 +1494,6 @@ 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) {
|
||||
@@ -1625,12 +1602,6 @@ 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.
|
||||
@@ -1904,12 +1875,8 @@ impl Game<'_> {
|
||||
if total == t {
|
||||
return Err(err("commands.time.set.already_at_time", &[&clock, &t]));
|
||||
}
|
||||
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.world.day_time = t;
|
||||
self.ui.clock.partial = 0.0;
|
||||
self.feedback(Text::tr("commands.time.set.absolute", &[&clock, &t]));
|
||||
Ok(t as i32)
|
||||
}
|
||||
@@ -1929,13 +1896,8 @@ impl Game<'_> {
|
||||
&[&clock, &marker],
|
||||
));
|
||||
}
|
||||
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.world.day_time = m.resolve(total);
|
||||
self.ui.clock.partial = 0.0;
|
||||
self.feedback(Text::tr(
|
||||
"commands.time.set.time_marker",
|
||||
&[&clock, &marker],
|
||||
@@ -1944,12 +1906,7 @@ impl Game<'_> {
|
||||
}
|
||||
"time.add" => {
|
||||
let t = i64::from(Self::int(ctx, "time").unwrap_or(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;
|
||||
}
|
||||
self.world.day_time = (total + t).max(0);
|
||||
let now = self.world.day_time;
|
||||
self.feedback(Text::tr("commands.time.set.absolute", &[&clock, &now]));
|
||||
Ok((now % i64::from(i32::MAX)) as i32)
|
||||
@@ -2033,10 +1990,12 @@ 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, &mut self.world.random)),
|
||||
duration.unwrap_or_else(|| sample(12_000, 180_000, r)),
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
@@ -2045,7 +2004,7 @@ impl Game<'_> {
|
||||
(
|
||||
"commands.weather.set.rain",
|
||||
0,
|
||||
duration.unwrap_or_else(|| sample(12_000, 24_000, &mut self.world.random)),
|
||||
duration.unwrap_or_else(|| sample(12_000, 24_000, r)),
|
||||
true,
|
||||
false,
|
||||
)
|
||||
@@ -2053,46 +2012,17 @@ impl Game<'_> {
|
||||
(
|
||||
"commands.weather.set.thunder",
|
||||
0,
|
||||
duration.unwrap_or_else(|| sample(3_600, 15_600, &mut self.world.random)),
|
||||
duration.unwrap_or_else(|| sample(3_600, 15_600, r)),
|
||||
true,
|
||||
true,
|
||||
)
|
||||
};
|
||||
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;
|
||||
}
|
||||
}
|
||||
// ServerLevel.setWeatherParameters.
|
||||
w.clear_time = clear;
|
||||
w.rain_time = time;
|
||||
w.thunder_time = time;
|
||||
w.raining = raining;
|
||||
w.thundering = thundering;
|
||||
self.feedback(Text::translate(key, &[]));
|
||||
Ok(duration.unwrap_or(-1))
|
||||
}
|
||||
@@ -2292,14 +2222,6 @@ 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", &[]);
|
||||
}
|
||||
@@ -3128,20 +3050,6 @@ 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,
|
||||
@@ -3198,7 +3106,6 @@ 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,7 +365,6 @@ impl Game<'_> {
|
||||
&[shown],
|
||||
));
|
||||
}
|
||||
self.native_world_border_bounds_changed();
|
||||
Ok((distance - current) as i32)
|
||||
}
|
||||
"worldborder.center" => {
|
||||
@@ -383,7 +382,6 @@ 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,7 +59,6 @@ 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();
|
||||
}
|
||||
|
||||
@@ -157,31 +157,24 @@ impl Game<'_> {
|
||||
}
|
||||
horizon_client::widgets::end_frame(self.frames);
|
||||
self.narrate_screen();
|
||||
self.poll_repro(event_loop);
|
||||
self.advance_agent();
|
||||
for request in self.ui_wait.ready() {
|
||||
self.answer(request, event_loop);
|
||||
}
|
||||
while let Some(request) = self.control.as_ref().and_then(|c| c.try_recv()) {
|
||||
// Once per line received, not once per handler: a deferred `ui`
|
||||
// comes back through `ui_wait` and must not be traced twice.
|
||||
self.note_socket_line(&request.line);
|
||||
self.agent_command_seen();
|
||||
self.answer(request, event_loop);
|
||||
if self.pending_save.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// A recording made from the F3 keys or the Settings page has no socket,
|
||||
// and still wants its events.
|
||||
if self.control.is_some() || self.repro.recording_active() {
|
||||
if self.control.is_some() {
|
||||
self.watch_events();
|
||||
}
|
||||
if self.screen == Screen::Options
|
||||
&& self.ui.options.page() == horizon_client::options_ui::Page::Agent
|
||||
{
|
||||
self.ui.options.agent_status = self.agent_status_lines();
|
||||
self.ui.options.agent_recording = self.repro.recording_active();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,15 +273,6 @@ impl Game<'_> {
|
||||
result
|
||||
}
|
||||
|
||||
/// A recorded control line fed back by a replay: not new input, so the
|
||||
/// trace does not see it, and its reply has no one waiting.
|
||||
pub(super) fn replay_line(&mut self, line: &str, event_loop: &dyn EventLoopCtl) {
|
||||
let (reply, _) = std::sync::mpsc::channel();
|
||||
if let Err(e) = self.control_command(line, &reply, event_loop) {
|
||||
log::warn!("Horizon: replay: {line}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn control_command_inner(
|
||||
&mut self,
|
||||
line: &str,
|
||||
@@ -317,12 +301,6 @@ impl Game<'_> {
|
||||
if let Some(value) = self.events_command(&words, reply)? {
|
||||
return Ok(value);
|
||||
}
|
||||
if let Some(value) = self.input_trace.command(&words)? {
|
||||
return Ok(Some(value));
|
||||
}
|
||||
if let Some(value) = self.repro_command(&words, line, reply)? {
|
||||
return Ok(value);
|
||||
}
|
||||
if let Some(value) = self.saves_control(&words, line, reply)? {
|
||||
return Ok(value);
|
||||
}
|
||||
@@ -355,8 +333,6 @@ impl Game<'_> {
|
||||
.chain(super::events::HELP)
|
||||
.chain(super::saves_control::HELP)
|
||||
.chain(super::pad::HELP)
|
||||
.chain(super::input_trace::HELP)
|
||||
.chain(super::repro::HELP)
|
||||
.collect::<Vec<_>>() }))),
|
||||
"setblock" | "fill" | "particle" | "signedit" => self.render_command(&words),
|
||||
"section-draws" => {
|
||||
@@ -401,7 +377,6 @@ 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 {
|
||||
@@ -419,7 +394,7 @@ impl Game<'_> {
|
||||
json!({ "game_time": g.world.game_time, "projectiles": g.projectiles_status() })
|
||||
})
|
||||
})
|
||||
.ok_or(super::integrated::NO_ANSWER)?
|
||||
.ok_or("the integrated server stopped")?
|
||||
} else {
|
||||
json!({ "game_time": self.world.game_time, "projectiles": self.projectiles_status() })
|
||||
};
|
||||
@@ -435,7 +410,7 @@ impl Game<'_> {
|
||||
if let Some(server) = &self.integrated {
|
||||
server
|
||||
.call(move |g| g.agent.hold_server = held)
|
||||
.ok_or(super::integrated::NO_ANSWER)?;
|
||||
.ok_or("the integrated server stopped")?;
|
||||
} else if self.remote.is_some() {
|
||||
return Err("server-hold requires the world's host".into());
|
||||
} else {
|
||||
@@ -453,10 +428,10 @@ impl Game<'_> {
|
||||
)
|
||||
.into());
|
||||
}
|
||||
// The command confirmation takes the keys; none go past it.
|
||||
// ConfirmScreen.keyPressed: Escape answers no, nothing else goes past it.
|
||||
if self.command_prompt.is_some() {
|
||||
if arg(2)? != "up" {
|
||||
self.command_prompt_key(key);
|
||||
if key == KeyCode::Escape && arg(2)? != "up" {
|
||||
self.answer_command_prompt(false);
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
@@ -699,7 +674,7 @@ impl Game<'_> {
|
||||
let command = format!("give @s {item} {count}");
|
||||
server
|
||||
.call(move |g| g.owner_console_command(&command))
|
||||
.ok_or(super::integrated::NO_ANSWER)?;
|
||||
.ok_or("the integrated server stopped")?;
|
||||
return Ok(Some(json!({ "ok": true, "given": count })));
|
||||
}
|
||||
let max = info.max_stack;
|
||||
@@ -752,7 +727,7 @@ impl Game<'_> {
|
||||
if let Some(server) = &self.integrated {
|
||||
server
|
||||
.call(move |g| g.world.day_time = time)
|
||||
.ok_or(super::integrated::NO_ANSWER)?;
|
||||
.ok_or("the integrated server stopped")?;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
@@ -771,7 +746,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(super::integrated::NO_ANSWER)?;
|
||||
.ok_or("the integrated server stopped")?;
|
||||
return Ok(Some(value));
|
||||
}
|
||||
Ok(Some(self.control_mobs(radius)))
|
||||
@@ -781,7 +756,7 @@ impl Game<'_> {
|
||||
if let Some(server) = &self.integrated {
|
||||
let value = server
|
||||
.call(|g| g.control_hurt_log())
|
||||
.ok_or(super::integrated::NO_ANSWER)?;
|
||||
.ok_or("the integrated server stopped")?;
|
||||
return Ok(Some(value));
|
||||
}
|
||||
Ok(Some(self.control_hurt_log()))
|
||||
@@ -1011,7 +986,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(super::integrated::NO_ANSWER)??;
|
||||
.ok_or("the integrated server stopped")??;
|
||||
} else if self.remote.is_none() {
|
||||
self.begin_control_save(all, reply.clone())?;
|
||||
return Ok(None);
|
||||
@@ -1175,7 +1150,7 @@ impl Game<'_> {
|
||||
json!({ "ok": true, "players": players })
|
||||
}
|
||||
|
||||
pub(super) fn control_status(&self) -> Value {
|
||||
fn control_status(&self) -> Value {
|
||||
let player = self.player.as_ref().map(|p| {
|
||||
json!({
|
||||
"position": [p.position.x, p.position.y, p.position.z],
|
||||
@@ -1345,11 +1320,6 @@ impl Game<'_> {
|
||||
status["border"] = json!({ "center": self.border.center(), "size": self.border.size() });
|
||||
// The scoreboard sidebar the HUD shows.
|
||||
status["sidebar"] = self.sidebar_status();
|
||||
// Who caused the input, and whether a repro recording runs.
|
||||
let mut input = self.input_trace.summary();
|
||||
self.repro
|
||||
.fill_summary(&mut input, self.input_trace.events());
|
||||
status["input"] = input;
|
||||
status
|
||||
}
|
||||
}
|
||||
@@ -1380,10 +1350,6 @@ fn released_modifier(line: &str) -> Option<KeyCode> {
|
||||
|
||||
fn key_code(name: &str) -> Option<KeyCode> {
|
||||
let name = name.to_ascii_lowercase();
|
||||
// The game's own names (`key.keyboard.w`), which a recording writes.
|
||||
if let Some(code) = horizon_client::keybinds::key_code(&name) {
|
||||
return Some(code);
|
||||
}
|
||||
let letters = [
|
||||
KeyCode::KeyA,
|
||||
KeyCode::KeyB,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! breeding, conversions, drops), projectiles, player interactions
|
||||
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
|
||||
use super::{
|
||||
Dim, Game, Hand, InteractTarget, dispenser, native_mob_draw::NativePlayerRender,
|
||||
Dim, Game, InteractTarget, dispenser, native_mob_draw::NativePlayerRender,
|
||||
native_projectiles::Impact, view_vector,
|
||||
};
|
||||
use horizon_assets::item_model::DisplayContext;
|
||||
@@ -618,14 +618,6 @@ 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.
|
||||
@@ -1699,16 +1691,7 @@ 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
|
||||
@@ -2326,15 +2309,9 @@ impl Game<'_> {
|
||||
self.with_level(|b, level, ticks| b.set_door_open(level, ticks, pos, open));
|
||||
self.process_level_events()?;
|
||||
}
|
||||
MobEvent::BreakDoor { mob, pos } => {
|
||||
MobEvent::BreakDoor { 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));
|
||||
@@ -2342,7 +2319,6 @@ 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);
|
||||
@@ -2368,7 +2344,6 @@ impl Game<'_> {
|
||||
}
|
||||
MobEvent::LevelEvent { id, pos, data } => self.level_event_at(id, pos, data),
|
||||
MobEvent::SetBlockName {
|
||||
mob,
|
||||
pos,
|
||||
name,
|
||||
effect,
|
||||
@@ -2388,19 +2363,7 @@ 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 {
|
||||
@@ -2603,12 +2566,7 @@ impl Game<'_> {
|
||||
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);
|
||||
}
|
||||
} => self.breed(parent, partner, llama),
|
||||
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);
|
||||
@@ -3939,11 +3897,9 @@ impl Game<'_> {
|
||||
ArrowEvent::BubbleColumnParticles { pos } => self.bubble_column_particles(pos),
|
||||
ArrowEvent::Block { pos, reaction } => match reaction {
|
||||
BlockReaction::PrimeTnt => {
|
||||
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();
|
||||
}
|
||||
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);
|
||||
@@ -4032,7 +3988,6 @@ 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
|
||||
@@ -4907,34 +4862,6 @@ 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);
|
||||
@@ -4973,10 +4900,9 @@ 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.
|
||||
self.break_leash(id as u32, true, false);
|
||||
let id = self.mobs[index].id;
|
||||
self.break_leash(id, true, false);
|
||||
self.mobs[index].removed = true;
|
||||
if self.creative {
|
||||
if let Some(rest) = self.give(filled) {
|
||||
@@ -4985,27 +4911,13 @@ 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);
|
||||
@@ -5125,7 +5037,6 @@ 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();
|
||||
@@ -5476,7 +5387,6 @@ 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" {
|
||||
@@ -5620,11 +5530,8 @@ impl Game<'_> {
|
||||
// Animal.setInLove: entity event 18 (a baby that
|
||||
// only grew is not in love).
|
||||
if self.mobs[index].extra.in_love > 0 {
|
||||
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.love_cause.insert(self.mobs[index].id, self.lan_acting);
|
||||
self.mob_event_particles(self.mobs[index].id, 18);
|
||||
}
|
||||
}
|
||||
// Animal.mobInteract still consumes the client click when
|
||||
@@ -5637,13 +5544,6 @@ 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 });
|
||||
@@ -5937,11 +5837,6 @@ 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
|
||||
@@ -6203,19 +6098,6 @@ 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,
|
||||
@@ -6358,55 +6240,21 @@ impl Game<'_> {
|
||||
Vec::new()
|
||||
};
|
||||
let body = out.len();
|
||||
// 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));
|
||||
let render = if layers_only {
|
||||
horizon_client::mobs::render::render_layers
|
||||
} else {
|
||||
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,
|
||||
);
|
||||
}
|
||||
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() {
|
||||
@@ -6454,23 +6302,7 @@ 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,12 +255,6 @@ 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 {
|
||||
|
||||
@@ -710,18 +710,6 @@ impl Game<'_> {
|
||||
self.toggle_debug_options();
|
||||
true
|
||||
}
|
||||
// Horizon's own: a repro recording for a bug report (game/repro.rs).
|
||||
// Off unless the AI Agent page enables the shortcuts.
|
||||
DebugAction::RecordSession if !self.settings.extras.repro_keys => false,
|
||||
DebugAction::ExportRepro if !self.settings.extras.repro_keys => false,
|
||||
DebugAction::RecordSession => {
|
||||
self.toggle_recording_key();
|
||||
true
|
||||
}
|
||||
DebugAction::ExportRepro => {
|
||||
self.export_repro_key();
|
||||
true
|
||||
}
|
||||
// Screens and overlays Horizon does not draw yet (the profiler and
|
||||
// its chart, the texture dump): the keys are used up as in vanilla.
|
||||
DebugAction::DumpDynamicTextures
|
||||
|
||||
@@ -1236,7 +1236,7 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.map(|e| (e.user.id, e.level as u8))
|
||||
.collect();
|
||||
self.resend_commands_filtered();
|
||||
host.hub.resend_commands();
|
||||
}
|
||||
|
||||
/// A player on the server, by name.
|
||||
@@ -1457,17 +1457,13 @@ impl Game<'_> {
|
||||
}
|
||||
"whitelist" => self.console_whitelist(state, &rest),
|
||||
// The chat filter's (game/chat_filter.rs), on servers that filter.
|
||||
command
|
||||
if super::chat_filter::MUTE_COMMANDS.contains(&command)
|
||||
&& self.lan.as_ref().is_some_and(|h| h.hub.chat_filter_on()) =>
|
||||
"unmute"
|
||||
if rest.len() == 1 && self.lan.as_ref().is_some_and(|h| h.hub.chat_filter_on()) =>
|
||||
{
|
||||
if let Some(mut host) = self.lan.take() {
|
||||
let words: Vec<&str> = [command].into_iter().chain(rest).collect();
|
||||
let replies = self.mute_command(&mut host, &words, "Server");
|
||||
let reply = self.unmute(&mut host, rest[0], "Server");
|
||||
self.lan = Some(host);
|
||||
for reply in replies {
|
||||
self.console_reply(lit(&reply));
|
||||
}
|
||||
self.console_reply(lit(&reply));
|
||||
}
|
||||
}
|
||||
"ban" if !rest.is_empty() => {
|
||||
@@ -1689,7 +1685,7 @@ impl Game<'_> {
|
||||
match rest {
|
||||
["on"] | ["off"] => {
|
||||
let on = rest[0] == "on";
|
||||
let l = state.lists.lock().unwrap();
|
||||
let mut l = state.lists.lock().unwrap();
|
||||
let turned_on = on && !l.use_whitelist;
|
||||
let key = match (on, l.use_whitelist) {
|
||||
(true, true) => "commands.whitelist.alreadyOn",
|
||||
@@ -1697,8 +1693,9 @@ impl Game<'_> {
|
||||
(false, false) => "commands.whitelist.alreadyOff",
|
||||
(false, true) => "commands.whitelist.disabled",
|
||||
};
|
||||
l.use_whitelist = on;
|
||||
drop(l);
|
||||
self.set_using_whitelist(&state.lists, on);
|
||||
self.store_property("white-list", on.to_string());
|
||||
self.console_reply(Text::translate(key, &[]));
|
||||
if turned_on {
|
||||
let lists = state.lists.clone();
|
||||
@@ -1766,16 +1763,8 @@ 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) {
|
||||
let changed = {
|
||||
let mut lists = lists.lock().unwrap();
|
||||
let changed = lists.use_whitelist != on;
|
||||
lists.use_whitelist = on;
|
||||
changed
|
||||
};
|
||||
lists.lock().unwrap().use_whitelist = on;
|
||||
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,7 +4,6 @@
|
||||
//! 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,
|
||||
@@ -36,9 +35,6 @@ 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),
|
||||
@@ -256,12 +252,6 @@ 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<()> {
|
||||
@@ -276,14 +266,6 @@ 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,
|
||||
@@ -302,11 +284,6 @@ 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,13 +634,6 @@ 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 {
|
||||
@@ -697,9 +690,6 @@ 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)
|
||||
@@ -909,12 +899,6 @@ 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) => {
|
||||
@@ -948,16 +932,6 @@ 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()`.
|
||||
@@ -1273,10 +1247,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, None, content, sound, false, false)
|
||||
b.bucket_empty(level, ticks, target_pos, content, sound, false, false)
|
||||
});
|
||||
self.process_level_events()?;
|
||||
if emptied.is_none() {
|
||||
if !emptied {
|
||||
return Ok((self.dispense_default(pos, facing, stack), CLICK));
|
||||
}
|
||||
if self.dim == super::Dim::Nether
|
||||
@@ -1380,9 +1354,6 @@ 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)?;
|
||||
@@ -1403,7 +1374,7 @@ impl Game<'_> {
|
||||
None,
|
||||
);
|
||||
} else if &*name == "minecraft:tnt" {
|
||||
if self.ui.rules.bool("tnt_explodes") && self.tnt_prime_allowed(target_pos) {
|
||||
if self.ui.rules.bool("tnt_explodes") {
|
||||
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()?;
|
||||
@@ -1464,13 +1435,9 @@ 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(
|
||||
@@ -1779,7 +1746,10 @@ impl Game<'_> {
|
||||
let random = self.rand_sequences.get(&sequence, self.level.seed);
|
||||
world_items!(self).loot_table_from(table, false, held, random)
|
||||
}
|
||||
None => world_items!(self).loot_table(table, false, held, &mut self.world.random),
|
||||
None => self
|
||||
.res
|
||||
.items()
|
||||
.loot_table(table, false, held, &mut self.world.random),
|
||||
}
|
||||
}
|
||||
/// `Entity.spawnAtLocation(level, stack, offset)`: an item at the entity's
|
||||
@@ -1848,8 +1818,6 @@ 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) => {
|
||||
@@ -1968,12 +1936,6 @@ 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,7 +17,6 @@ use horizon_client::{
|
||||
projectiles::ProjectileKind,
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
random::{LegacyRandom, RandomSource},
|
||||
@@ -25,42 +24,6 @@ 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;
|
||||
@@ -1934,17 +1897,6 @@ 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 {
|
||||
@@ -2320,17 +2272,7 @@ impl Game<'_> {
|
||||
f.full_collision || f.legacy_solid
|
||||
})
|
||||
.map_or(64, |y| y + 1);
|
||||
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);
|
||||
}
|
||||
let _ = self.level_set([0, y, 0], egg);
|
||||
}
|
||||
self.fight.previously_killed = true;
|
||||
self.fight.killed = true;
|
||||
@@ -2370,13 +2312,6 @@ 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;
|
||||
@@ -2406,13 +2341,9 @@ 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,
|
||||
at: [next.x, next.y, next.z],
|
||||
radius: 3.0,
|
||||
age: 0,
|
||||
duration: 600,
|
||||
@@ -2494,11 +2425,13 @@ impl Game<'_> {
|
||||
}
|
||||
// AreaEffectCloud: after its 20-tick wait, every 20 ticks per entity.
|
||||
if c.age >= 20 && c.age % 20 == 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));
|
||||
// Instant damage: I (6) from the breath, II (12) from a fireball.
|
||||
let amount = if c.from_dragon { 6.0 } else { 12.0 };
|
||||
for p in &players {
|
||||
if cloud_hits(c.at, r, p.bounds, p.position) {
|
||||
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
|
||||
{
|
||||
harmed.push((p.who, amount));
|
||||
}
|
||||
}
|
||||
@@ -2638,67 +2571,6 @@ 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,
|
||||
@@ -3896,7 +3768,13 @@ 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_normalised();
|
||||
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 Some(d) = self.dragon.as_mut() else {
|
||||
return;
|
||||
};
|
||||
@@ -3904,20 +3782,51 @@ 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];
|
||||
// `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 puffs: Vec<([f64; 3], [f64; 3])> = Vec::new();
|
||||
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;
|
||||
@@ -4066,53 +3975,6 @@ 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);
|
||||
@@ -4232,107 +4094,6 @@ 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,21 +584,9 @@ 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,7 +706,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.native_portal_create("minecraft:the_end", origin);
|
||||
// Direction.WEST.toYRot().
|
||||
return Ok(Some((target, Some(90.0))));
|
||||
}
|
||||
@@ -1303,8 +1302,8 @@ impl Game<'_> {
|
||||
target
|
||||
};
|
||||
let survive = self.world.random.next_int_bound(5).unwrap_or(0) > 0;
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
let id = self.take_entity_id();
|
||||
self.next_entity += 1;
|
||||
let id = self.next_entity;
|
||||
self.end.eyes.push(EyeOfEnder {
|
||||
id,
|
||||
position: start,
|
||||
@@ -1316,6 +1315,7 @@ 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,17 +712,6 @@ 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]
|
||||
|
||||
@@ -64,14 +64,6 @@ impl Events {
|
||||
}
|
||||
self.log.push_back(event);
|
||||
}
|
||||
/// An event from outside the once-a-turn comparison (`user-input`).
|
||||
pub(super) fn note(&mut self, tick: u64, event: Value) {
|
||||
self.push(tick, event);
|
||||
}
|
||||
/// Whether a `wait-for` is waiting: its agent is still watching.
|
||||
pub(super) fn has_waits(&self) -> bool {
|
||||
!self.waits.is_empty()
|
||||
}
|
||||
fn since(&self, seq: u64) -> impl Iterator<Item = &Value> {
|
||||
self.log
|
||||
.iter()
|
||||
@@ -189,7 +181,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn loaded(&self) -> bool {
|
||||
fn loaded(&self) -> bool {
|
||||
self.title.is_none()
|
||||
&& self.player.is_some()
|
||||
&& self.streaming.pending() + self.pending_uploads.len() == 0
|
||||
@@ -215,7 +207,6 @@ impl Game<'_> {
|
||||
None => self.ui.chat.recent_lines(new_chat),
|
||||
};
|
||||
for event in diff(before, &now, &lines) {
|
||||
self.repro.note_event(self.ticks, &event);
|
||||
self.events.push(self.ticks, event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
// Entity.<init>: the id comes from the one counter at construction.
|
||||
let id = self.take_entity_id();
|
||||
// An id from the entity counter at once, so a command can name it.
|
||||
self.next_entity += 1;
|
||||
Rocket {
|
||||
id,
|
||||
id: self.next_entity,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity,
|
||||
@@ -345,9 +345,6 @@ 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) {
|
||||
@@ -520,7 +517,6 @@ 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,8 +210,6 @@ 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
|
||||
@@ -609,13 +607,6 @@ 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.
|
||||
@@ -683,12 +674,6 @@ 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,17 +125,6 @@ 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 {
|
||||
@@ -173,14 +162,6 @@ 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);
|
||||
@@ -243,14 +224,11 @@ 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 {
|
||||
@@ -259,21 +237,12 @@ 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
|
||||
}
|
||||
@@ -295,13 +264,6 @@ 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)
|
||||
@@ -322,16 +284,6 @@ 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
|
||||
|
||||
@@ -1,675 +0,0 @@
|
||||
//! Who caused an input event: a person at the keyboard (`user`), an agent on the
|
||||
//! control socket (`agent`), or the game itself (`game`, made-up input no one
|
||||
//! asked for). Agents read this with `input-log` and the `input` section of
|
||||
//! `state` and `status`, so a script can tell that a person took over instead of
|
||||
//! mistaking their keystrokes for its own. A `user-input` event also lands in
|
||||
//! `events` (and so in `wait-for event user-input`) when a person steps in
|
||||
//! while an agent was driving.
|
||||
//!
|
||||
//! The events are also what a repro recording writes to `inputs.jsonl`
|
||||
//! (game/repro.rs).
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{Result, keybinds};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
sync::mpsc::Sender,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use winit::{
|
||||
event::{ElementState, MouseScrollDelta, WindowEvent},
|
||||
keyboard::PhysicalKey,
|
||||
};
|
||||
|
||||
pub(super) const HELP: &[&str] = &[
|
||||
"input-log [--since SEQ] [--limit N] [--origin user|agent|game] recent input and who caused it",
|
||||
];
|
||||
|
||||
/// Input events kept for `input-log`.
|
||||
const KEPT: usize = 512;
|
||||
/// A person's input this soon after an agent command is an intrusion.
|
||||
const INTRUSION_GAP: Duration = Duration::from_secs(5);
|
||||
/// At most one `user-input` event per this long, so holding a key does not flood.
|
||||
const INTRUSION_QUIET: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Who caused an input event.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub(super) enum InputOrigin {
|
||||
/// The window's own events: someone at this computer's keyboard, mouse or pad.
|
||||
User,
|
||||
/// The control socket: `horizon-ctl`, an MCP client, or a script.
|
||||
Agent,
|
||||
/// Neither: input the game made up itself (pad menus, UI tasks).
|
||||
Game,
|
||||
}
|
||||
|
||||
impl InputOrigin {
|
||||
pub(super) fn name(self) -> &'static str {
|
||||
match self {
|
||||
InputOrigin::User => "user",
|
||||
InputOrigin::Agent => "agent",
|
||||
InputOrigin::Game => "game",
|
||||
}
|
||||
}
|
||||
fn index(self) -> usize {
|
||||
self as usize
|
||||
}
|
||||
fn parse(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
"user" => Some(InputOrigin::User),
|
||||
"agent" => Some(InputOrigin::Agent),
|
||||
"game" => Some(InputOrigin::Game),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One input event, as `input-log` shows it and the recording writes it.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub(super) struct TraceEvent {
|
||||
pub seq: u64,
|
||||
pub tick: u64,
|
||||
/// Milliseconds since the first event this run; the clock a replay keeps.
|
||||
pub ms: u64,
|
||||
pub origin: InputOrigin,
|
||||
#[serde(flatten)]
|
||||
pub kind: TraceKind,
|
||||
}
|
||||
|
||||
/// What was pressed, clicked or typed. Key and button names are the game's own
|
||||
/// (`key.keyboard.w`, `key.mouse.left`), so a recording can feed them back.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "kebab-case")]
|
||||
pub(super) enum TraceKind {
|
||||
Key {
|
||||
key: String,
|
||||
/// `down`, `up` or `repeat` (the system's auto-repeat).
|
||||
action: String,
|
||||
},
|
||||
Mouse {
|
||||
button: String,
|
||||
action: String,
|
||||
},
|
||||
Wheel {
|
||||
x: f64,
|
||||
y: f64,
|
||||
/// The window gave lines (a notched wheel) rather than pixels.
|
||||
lines: bool,
|
||||
},
|
||||
/// The pointer's screen position, kept while a screen is open: GUI clicks
|
||||
/// are replayed at it.
|
||||
Cursor {
|
||||
x: f64,
|
||||
y: f64,
|
||||
},
|
||||
/// Relative mouse motion, which turns the view.
|
||||
Motion {
|
||||
x: f64,
|
||||
y: f64,
|
||||
},
|
||||
Pad {
|
||||
button: String,
|
||||
action: String,
|
||||
},
|
||||
/// A control-socket line: `key`, `mouse`, `goto`, anything an agent ran.
|
||||
Command {
|
||||
line: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl TraceKind {
|
||||
pub(super) fn name(&self) -> &'static str {
|
||||
match self {
|
||||
TraceKind::Key { .. } => "key",
|
||||
TraceKind::Mouse { .. } => "mouse",
|
||||
TraceKind::Wheel { .. } => "wheel",
|
||||
TraceKind::Cursor { .. } => "cursor",
|
||||
TraceKind::Motion { .. } => "motion",
|
||||
TraceKind::Pad { .. } => "pad",
|
||||
TraceKind::Command { .. } => "command",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Commands that only read. Polling them must not flood the ring or make an
|
||||
/// agent that is merely watching look like it acted.
|
||||
const READ_ONLY: &[&str] = &[
|
||||
"state",
|
||||
"status",
|
||||
"events",
|
||||
"wait-for",
|
||||
"input-log",
|
||||
"record-status",
|
||||
"record-list",
|
||||
"looking-at",
|
||||
"entities",
|
||||
"block",
|
||||
"blocks",
|
||||
"light",
|
||||
"inventory",
|
||||
"screen",
|
||||
"menu",
|
||||
"messages",
|
||||
"worlds",
|
||||
"servers",
|
||||
"options-get",
|
||||
"stats",
|
||||
"logs",
|
||||
"ui",
|
||||
];
|
||||
|
||||
/// Commands that steer the session rather than the game: recording them as
|
||||
/// input would put `record-stop` in a replay it must not run.
|
||||
const SESSION: &[&str] = &[
|
||||
"record-start",
|
||||
"record-stop",
|
||||
"record-replay",
|
||||
"repro-export",
|
||||
"quit",
|
||||
"launch",
|
||||
"mcp",
|
||||
"bench",
|
||||
"sleep",
|
||||
];
|
||||
|
||||
/// Lines whose arguments can carry someone's data: the trace keeps the command
|
||||
/// name and drops the rest.
|
||||
const PRIVATE_ARGUMENTS: &[&str] = &[
|
||||
"chat", "command", "type", "signedit", "connect", "book", "publish",
|
||||
];
|
||||
|
||||
/// Whether `line` only reads.
|
||||
fn is_read_only(line: &str) -> bool {
|
||||
line.split_whitespace()
|
||||
.next()
|
||||
.is_some_and(|name| READ_ONLY.contains(&name))
|
||||
}
|
||||
|
||||
/// Whether `line` steers the session, not the game.
|
||||
fn is_session_command(line: &str) -> bool {
|
||||
line.split_whitespace()
|
||||
.next()
|
||||
.is_some_and(|name| SESSION.contains(&name))
|
||||
}
|
||||
|
||||
/// The line as the trace keeps it: a command whose arguments are private loses
|
||||
/// them (`chat <redacted>`).
|
||||
fn redact_command(line: &str) -> String {
|
||||
match line.split_whitespace().next() {
|
||||
Some(name) if PRIVATE_ARGUMENTS.contains(&name) => format!("{name} <redacted>"),
|
||||
_ => line.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The recent input, the counters and the recording's line writer.
|
||||
#[derive(Default)]
|
||||
pub(super) struct InputTrace {
|
||||
seq: u64,
|
||||
log: VecDeque<TraceEvent>,
|
||||
counts: [u64; 3],
|
||||
last: Option<InputOrigin>,
|
||||
last_user: Option<Instant>,
|
||||
last_agent: Option<Instant>,
|
||||
/// The person's events since the last agent command.
|
||||
user_since_agent: u64,
|
||||
quiet_until: Option<Instant>,
|
||||
/// The first event's wall clock and tick.
|
||||
started: Option<(Instant, u64)>,
|
||||
/// The recording's `inputs.jsonl` writer; `None` when nothing records.
|
||||
writer: Option<Sender<String>>,
|
||||
written: u64,
|
||||
/// A replay feeds events back: they are not new input and are not recorded.
|
||||
suppress: bool,
|
||||
}
|
||||
|
||||
impl InputTrace {
|
||||
/// Record one event; whether it is a person intruding on an agent's run.
|
||||
/// `agent_active`: an agent is watching or waiting, not only recently heard.
|
||||
fn record(
|
||||
&mut self,
|
||||
tick: u64,
|
||||
origin: InputOrigin,
|
||||
kind: TraceKind,
|
||||
agent_active: bool,
|
||||
) -> bool {
|
||||
if self.suppress {
|
||||
return false;
|
||||
}
|
||||
let now = Instant::now();
|
||||
let started = self.started.get_or_insert((now, tick)).0;
|
||||
let ms = now.duration_since(started).as_millis() as u64;
|
||||
|
||||
let intrusion = origin == InputOrigin::User
|
||||
&& (agent_active
|
||||
|| self
|
||||
.last_agent
|
||||
.is_some_and(|t| now.duration_since(t) < INTRUSION_GAP))
|
||||
&& self.quiet_until.is_none_or(|t| now >= t);
|
||||
if intrusion {
|
||||
self.quiet_until = Some(now + INTRUSION_QUIET);
|
||||
}
|
||||
|
||||
self.seq += 1;
|
||||
let event = TraceEvent {
|
||||
seq: self.seq,
|
||||
tick,
|
||||
ms,
|
||||
origin,
|
||||
kind,
|
||||
};
|
||||
if self.writer.is_some() {
|
||||
let mut failed = false;
|
||||
match serde_json::to_string(&event) {
|
||||
Ok(line) => {
|
||||
if let Some(writer) = &self.writer {
|
||||
failed = writer.send(line).is_err();
|
||||
}
|
||||
}
|
||||
Err(e) => log::warn!("Horizon: input trace: {e}"),
|
||||
}
|
||||
if failed {
|
||||
self.writer = None;
|
||||
log::warn!("Horizon: input trace stopped: its writer is gone");
|
||||
} else {
|
||||
self.written += 1;
|
||||
}
|
||||
}
|
||||
if self.log.len() == KEPT {
|
||||
self.log.pop_front();
|
||||
}
|
||||
self.log.push_back(event);
|
||||
self.counts[origin.index()] += 1;
|
||||
self.last = Some(origin);
|
||||
match origin {
|
||||
InputOrigin::User => {
|
||||
self.last_user = Some(now);
|
||||
self.user_since_agent += 1;
|
||||
}
|
||||
InputOrigin::Agent => {
|
||||
self.last_agent = Some(now);
|
||||
self.user_since_agent = 0;
|
||||
}
|
||||
InputOrigin::Game => {}
|
||||
}
|
||||
intrusion
|
||||
}
|
||||
|
||||
/// An agent line that is not input (a poll, a wait): it still means an
|
||||
/// agent is watching, so a person's next key is an intrusion.
|
||||
pub(super) fn agent_activity(&mut self) {
|
||||
if self.suppress {
|
||||
return;
|
||||
}
|
||||
self.last_agent = Some(Instant::now());
|
||||
self.user_since_agent = 0;
|
||||
}
|
||||
|
||||
/// The `input` section of `state` and `status`.
|
||||
pub(super) fn summary(&self) -> Value {
|
||||
json!({
|
||||
"last_origin": self.last.map(InputOrigin::name),
|
||||
"counts": {
|
||||
"user": self.counts[InputOrigin::User.index()],
|
||||
"agent": self.counts[InputOrigin::Agent.index()],
|
||||
"game": self.counts[InputOrigin::Game.index()],
|
||||
},
|
||||
"user_since_agent": self.user_since_agent,
|
||||
"last_user_age_s": self
|
||||
.last_user
|
||||
.map(|t| (t.elapsed().as_secs_f64() * 10.0).round() / 10.0),
|
||||
"events": self.seq,
|
||||
})
|
||||
}
|
||||
|
||||
/// Start writing the events to a recording.
|
||||
pub(super) fn start_file(&mut self, writer: Sender<String>) {
|
||||
self.writer = Some(writer);
|
||||
self.written = 0;
|
||||
}
|
||||
/// Stop writing; how many events the recording received.
|
||||
pub(super) fn stop_file(&mut self) -> u64 {
|
||||
self.writer = None;
|
||||
std::mem::take(&mut self.written)
|
||||
}
|
||||
/// How many events this run has seen.
|
||||
pub(super) fn events(&self) -> u64 {
|
||||
self.seq
|
||||
}
|
||||
/// A replay's own events are not new input.
|
||||
pub(super) fn set_suppress(&mut self, suppress: bool) {
|
||||
self.suppress = suppress;
|
||||
}
|
||||
|
||||
/// `input-log`.
|
||||
pub(super) fn command(&self, words: &[&str]) -> Result<Option<Value>> {
|
||||
if words[0] != "input-log" {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut since = 0;
|
||||
let mut limit = usize::MAX;
|
||||
let mut origin = None;
|
||||
let mut i = 1;
|
||||
while i < words.len() {
|
||||
let value = words
|
||||
.get(i + 1)
|
||||
.ok_or_else(|| format!("{} takes a value", words[i]))?;
|
||||
match words[i] {
|
||||
"--since" => since = value.parse().map_err(|_| "--since takes a number")?,
|
||||
"--limit" => limit = value.parse().map_err(|_| "--limit takes a number")?,
|
||||
"--origin" => {
|
||||
origin = Some(
|
||||
InputOrigin::parse(value).ok_or("--origin takes user, agent or game")?,
|
||||
)
|
||||
}
|
||||
other => return Err(format!("input-log: unknown argument {other}").into()),
|
||||
}
|
||||
i += 2;
|
||||
}
|
||||
let mut events: Vec<&TraceEvent> = self
|
||||
.log
|
||||
.iter()
|
||||
.filter(|e| e.seq > since && origin.is_none_or(|o| e.origin == o))
|
||||
.collect();
|
||||
if events.len() > limit {
|
||||
events.drain(..events.len() - limit);
|
||||
}
|
||||
Ok(Some(json!({
|
||||
"events": events,
|
||||
"last": self.seq,
|
||||
"input": self.summary(),
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// An agent's control line: input unless it only reads, which still counts
|
||||
/// as the agent watching.
|
||||
pub(super) fn note_socket_line(&mut self, line: &str) {
|
||||
if is_read_only(line) || is_session_command(line) {
|
||||
self.input_trace.agent_activity();
|
||||
return;
|
||||
}
|
||||
self.note_input(
|
||||
InputOrigin::Agent,
|
||||
TraceKind::Command {
|
||||
line: redact_command(line),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether anything can read the trace: the control socket (agent control
|
||||
/// on), the repro shortcuts, or a recording under way. Otherwise every key
|
||||
/// and mouse motion skips it, so a player who uses none of them pays
|
||||
/// nothing per input event.
|
||||
fn tracing_input(&self) -> bool {
|
||||
self.control.is_some() || self.settings.extras.repro_keys || self.repro.recording_active()
|
||||
}
|
||||
|
||||
/// Record an input event and, when a person steps into an agent's run, tell
|
||||
/// every watcher once.
|
||||
pub(super) fn note_input(&mut self, origin: InputOrigin, kind: TraceKind) {
|
||||
if !self.tracing_input() {
|
||||
return;
|
||||
}
|
||||
let kind_name = kind.name();
|
||||
// An agent waiting on a task or a wait, or a client still connected, is
|
||||
// watching even when its last line was minutes ago.
|
||||
let agent_active = self.control.as_ref().is_some_and(|c| c.clients() > 0)
|
||||
|| self.agent_busy()
|
||||
|| self.events.has_waits();
|
||||
if self
|
||||
.input_trace
|
||||
.record(self.ticks, origin, kind, agent_active)
|
||||
{
|
||||
let event = json!({ "event": "user-input", "input": kind_name });
|
||||
self.repro.note_event(self.ticks, &event);
|
||||
self.events.note(self.ticks, event);
|
||||
log::info!(
|
||||
"Horizon: {kind_name} input from a person while an agent was driving \
|
||||
(input-log has the run)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the window events that are input. Agent-made window events are left
|
||||
/// out: the control-socket line that caused them is recorded instead.
|
||||
pub(super) fn trace_window_event(&mut self, origin: InputOrigin, event: &WindowEvent) {
|
||||
if origin == InputOrigin::Agent || !self.tracing_input() {
|
||||
return;
|
||||
}
|
||||
match event {
|
||||
WindowEvent::KeyboardInput {
|
||||
event,
|
||||
is_synthetic,
|
||||
..
|
||||
} => {
|
||||
let PhysicalKey::Code(code) = event.physical_key else {
|
||||
return;
|
||||
};
|
||||
let Some(name) = keybinds::key_name(code) else {
|
||||
return;
|
||||
};
|
||||
let action = match (event.state, event.repeat) {
|
||||
(ElementState::Released, _) => "up",
|
||||
(_, true) => "repeat",
|
||||
(ElementState::Pressed, false) => "down",
|
||||
};
|
||||
// The platform made this one up (an IME's dead key): not a person.
|
||||
let origin = if *is_synthetic {
|
||||
InputOrigin::Game
|
||||
} else {
|
||||
origin
|
||||
};
|
||||
self.note_input(
|
||||
origin,
|
||||
TraceKind::Key {
|
||||
key: name.to_owned(),
|
||||
action: action.to_owned(),
|
||||
},
|
||||
);
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. } => self.note_input(
|
||||
origin,
|
||||
TraceKind::Mouse {
|
||||
button: keybinds::mouse_name(*button),
|
||||
action: match state {
|
||||
ElementState::Pressed => "down".to_owned(),
|
||||
ElementState::Released => "up".to_owned(),
|
||||
},
|
||||
},
|
||||
),
|
||||
WindowEvent::MouseWheel { delta, .. } => {
|
||||
let (x, y, lines) = match delta {
|
||||
MouseScrollDelta::LineDelta(x, y) => (f64::from(*x), f64::from(*y), true),
|
||||
MouseScrollDelta::PixelDelta(p) => (p.x, p.y, false),
|
||||
};
|
||||
self.note_input(origin, TraceKind::Wheel { x, y, lines });
|
||||
}
|
||||
// GUI clicks land where the pointer is; in the world the pointer is
|
||||
// grabbed and turns the view through `device_event` instead.
|
||||
WindowEvent::CursorMoved { position, .. } if self.screen != Screen::Playing => {
|
||||
self.note_input(
|
||||
origin,
|
||||
TraceKind::Cursor {
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
},
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn key(name: &str) -> TraceKind {
|
||||
TraceKind::Key {
|
||||
key: name.to_owned(),
|
||||
action: "down".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn origins_round_trip() {
|
||||
for origin in [InputOrigin::User, InputOrigin::Agent, InputOrigin::Game] {
|
||||
assert_eq!(InputOrigin::parse(origin.name()), Some(origin));
|
||||
}
|
||||
assert_eq!(InputOrigin::parse("nobody"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ring_keeps_the_newest_and_counts_every_origin() {
|
||||
let mut trace = InputTrace::default();
|
||||
for i in 0..=(KEPT as u64 + 10) {
|
||||
trace.record(i, InputOrigin::User, key("key.keyboard.w"), false);
|
||||
}
|
||||
assert_eq!(trace.log.len(), KEPT);
|
||||
assert_eq!(trace.seq, KEPT as u64 + 11);
|
||||
assert_eq!(trace.log.front().unwrap().seq, 12);
|
||||
assert_eq!(trace.counts[InputOrigin::User.index()], KEPT as u64 + 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_person_after_an_agent_is_an_intrusion_once() {
|
||||
let mut trace = InputTrace::default();
|
||||
trace.record(1, InputOrigin::Agent, key("key.keyboard.w"), false);
|
||||
assert!(trace.record(2, InputOrigin::User, key("key.keyboard.a"), false));
|
||||
// The quiet period keeps a held key from flooding the event log.
|
||||
assert!(!trace.record(3, InputOrigin::User, key("key.keyboard.a"), false));
|
||||
assert_eq!(trace.user_since_agent, 2);
|
||||
// An agent command resets the count.
|
||||
trace.record(4, InputOrigin::Agent, key("key.keyboard.w"), false);
|
||||
assert_eq!(trace.user_since_agent, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_intrusion_without_an_agent() {
|
||||
let mut trace = InputTrace::default();
|
||||
assert!(!trace.record(1, InputOrigin::User, key("key.keyboard.w"), false));
|
||||
// The game's own input is not a person either.
|
||||
trace.record(2, InputOrigin::Agent, key("key"), false);
|
||||
assert!(!trace.record(3, InputOrigin::Game, key("key.keyboard.w"), false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_log_filters_since_origin_and_limit() {
|
||||
let mut trace = InputTrace::default();
|
||||
for (i, origin) in [
|
||||
InputOrigin::Agent,
|
||||
InputOrigin::User,
|
||||
InputOrigin::Game,
|
||||
InputOrigin::User,
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
trace.record(i as u64, origin, key("key.keyboard.w"), false);
|
||||
}
|
||||
let all = trace.command(&["input-log"]).unwrap().unwrap();
|
||||
assert_eq!(all["events"].as_array().unwrap().len(), 4);
|
||||
assert_eq!(all["last"], 4);
|
||||
|
||||
let users = trace
|
||||
.command(&["input-log", "--origin", "user"])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(users["events"].as_array().unwrap().len(), 2);
|
||||
assert!(
|
||||
users["events"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|e| e["origin"] == "user")
|
||||
);
|
||||
|
||||
let newest = trace
|
||||
.command(&["input-log", "--since", "2", "--limit", "1"])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let events = newest["events"].as_array().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0]["seq"], 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_recording_writer_gets_one_json_line_per_event() {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let mut trace = InputTrace::default();
|
||||
trace.start_file(tx);
|
||||
trace.record(7, InputOrigin::User, key("key.keyboard.w"), false);
|
||||
trace.record(8, InputOrigin::Agent, key("key.keyboard.s"), false);
|
||||
assert_eq!(trace.stop_file(), 2);
|
||||
let lines: Vec<String> = rx.try_iter().collect();
|
||||
assert_eq!(lines.len(), 2);
|
||||
let first: Value = serde_json::from_str(&lines[0]).unwrap();
|
||||
assert_eq!(first["tick"], 7);
|
||||
assert_eq!(first["origin"], "user");
|
||||
assert_eq!(first["kind"], "key");
|
||||
assert_eq!(first["key"], "key.keyboard.w");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_arguments_are_dropped() {
|
||||
assert_eq!(redact_command("chat /login hunter2"), "chat <redacted>");
|
||||
assert_eq!(
|
||||
redact_command("connect friend:ABCD-1234"),
|
||||
"connect <redacted>"
|
||||
);
|
||||
assert_eq!(
|
||||
redact_command("key key.keyboard.w down"),
|
||||
"key key.keyboard.w down"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polls_are_read_only() {
|
||||
for line in [
|
||||
"state",
|
||||
"wait-for event death",
|
||||
"record-status",
|
||||
"input-log --origin user",
|
||||
"ui",
|
||||
] {
|
||||
assert!(is_read_only(line), "{line}");
|
||||
}
|
||||
for line in ["key w down", "record-start x", "mouse left click"] {
|
||||
assert!(!is_read_only(line), "{line}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_commands_are_not_input() {
|
||||
for line in [
|
||||
"record-start x",
|
||||
"record-stop --export",
|
||||
"record-replay x",
|
||||
"repro-export x",
|
||||
"quit",
|
||||
] {
|
||||
assert!(is_session_command(line), "{line}");
|
||||
}
|
||||
assert!(!is_session_command("key w down"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agent_still_watching_makes_a_later_person_an_intrusion() {
|
||||
let mut trace = InputTrace::default();
|
||||
trace.record(1, InputOrigin::Agent, key("key"), false);
|
||||
// The agent is waiting on a task or a wait: no five-second window.
|
||||
assert!(trace.record(2, InputOrigin::User, key("key.keyboard.a"), true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_suppressed_replay_records_nothing() {
|
||||
let mut trace = InputTrace::default();
|
||||
trace.set_suppress(true);
|
||||
assert!(!trace.record(1, InputOrigin::User, key("key.keyboard.w"), false));
|
||||
assert_eq!(trace.seq, 0);
|
||||
assert!(trace.summary()["last_origin"].is_null());
|
||||
}
|
||||
}
|
||||
@@ -32,19 +32,12 @@ use std::{
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, AtomicI32, AtomicU16, AtomicU32, Ordering},
|
||||
mpsc::{Receiver, RecvTimeoutError, Sender, channel, sync_channel},
|
||||
mpsc::{Receiver, 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;
|
||||
|
||||
@@ -184,9 +177,7 @@ 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, or when it has
|
||||
/// not answered within `CALL_TIMEOUT` (`f` still runs there later). The log
|
||||
/// tells the two apart.
|
||||
/// wait for its answer; `None` once the server has stopped.
|
||||
pub fn call<T: Send + 'static>(
|
||||
&self,
|
||||
f: impl FnOnce(&mut Game<'_>) -> T + Send + 'static,
|
||||
@@ -197,21 +188,7 @@ impl Integrated {
|
||||
let _ = tx.send(f(g));
|
||||
}))
|
||||
.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,
|
||||
}
|
||||
rx.recv_timeout(Duration::from_secs(10)).ok()
|
||||
}
|
||||
/// `IntegratedServer.halt(true)`: returns once the world is saved and let go.
|
||||
pub fn stop(mut self) -> Result<()> {
|
||||
|
||||
@@ -483,22 +483,12 @@ 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;
|
||||
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 {
|
||||
if pos.length_squared() > 64.0 * 64.0 {
|
||||
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(super::integrated::NO_ANSWER)?;
|
||||
.ok_or("the integrated server stopped")?;
|
||||
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(super::integrated::NO_ANSWER)??
|
||||
.ok_or("the integrated server stopped")??
|
||||
} 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,27 +1884,6 @@ 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)
|
||||
@@ -2017,18 +1996,6 @@ 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() {
|
||||
@@ -2414,8 +2381,6 @@ 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) {
|
||||
@@ -2892,12 +2857,7 @@ impl Game<'_> {
|
||||
.map(|s| s.as_ref().map(|s| s.item.clone()));
|
||||
m.extra.armor = g.armor.clone();
|
||||
m.extra.crouching = g.sneaking;
|
||||
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.avatar_pose = g.pose();
|
||||
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.
|
||||
|
||||
Loaded 100 of 474 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user