Merge main into #6927

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-02 12:51:46 +01:00
commit efb76fb19c
352 files changed
+13895 -5998

No files matched your search

+1 -1
View File
@@ -47,7 +47,7 @@ line are in the output) and the Rust fn before changing anything.
That one line is what makes the next agent find it.
- Fix the path players run: single player is a client of the integrated server, so server logic lives in
`game/lan*.rs` and `src/lan/`, and what only the client's copy shows (particles, local sounds, hand
animation) must be reached from `game/remote*.rs`. A fix on the old in-frame path alone fixes nothing.
animation) must be reached from `game/remote*.rs`.
- Look at the neighbours the ledger lists for the same class: a `client` or `seam` gap next to your fix is
usually the same bug on the other path.
+11 -15
View File
@@ -147,7 +147,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|---|---|
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs` |
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `server_terrain.rs` (a server's kept chunks), `bluemap.rs` + `bluemap_detect.rs` (far terrain from its web map), `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
| World generation | `crates/horizon-world/src/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `carver/`) |
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
@@ -178,28 +178,24 @@ state stays separate from the library tests. This compiles gameplay twice for ex
- Fields that say which mode runs: `remote` (this window is a client of a server: multiplayer, LAN guest, or
single player on its server thread), `lan` (`LanHost`: this world is open to the network), `integrated`
(the window's own server thread), `serving` (this `Game` *is* that server thread), `options.dedicated`.
`fn tick` returns `tick_remote()` when `remote` is set; nothing else of the local simulation runs.
- A server's players run through the same gameplay code as the local player: `as_guest` swaps the guest's
`fn tick` returns `tick_remote()` when `remote` is set; a server ticks its levels (`fn tick_world`).
- A server's players run through the shared gameplay code: `as_guest` swaps the guest's
state (position, inventory, mode, mining, item use) into `Game` for one call (`game/lan_actions.rs`), and
`levels.rs` swaps whole dimensions in the same way. Changes reach clients through the block log, level
events and the entity tracker (`lan/tracking.rs`, `game/lan_entities.rs`).
- Shared read-only data: `Resources` (`res`: blocks, items, recipes, mob types, biomes) and `PackResources`
(`pack`), both in game.rs.
## The two single-player paths
## Single player runs on a server thread
1. **Server thread (default since #1514):** `game/integrated.rs` runs a second `Game` (`serving`) on a
"Server thread" with one level per dimension; the window joins it as a client through memory (`remote`),
exactly as a LAN guest or a dedicated server's player does. This is what players run. Open
`[server-thread]` issues list the gaps left.
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`): DEPRECATED, never use it, it
will be removed soon.** The window's `Game` ticks the world itself, one level that follows the player
(`fn enter_dimension`). Compatibility does not matter: don't add features or fixes for it, don't write new
tests against it, and move code off it when you touch it. The deterministic rule fixtures still run on it
until they are ported.
`game/integrated.rs` runs a second `Game` (`serving`) on a "Server thread" with one level per dimension;
the window joins it as a client through memory (`remote`), exactly as a LAN guest or a dedicated server's
player does (#1514). A window's `Game` never ticks a world itself: the old in-frame path is gone
(`--in-frame` and `--no-server-thread` only print a notice; `HORIZON_SERVER_THREAD` is ignored).
Open `[server-thread]` issues list the gaps left.
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test the server
thread when you touch player-facing gameplay:
Single player, LAN guests and dedicated servers all run the server code, so a fix there serves all three.
Test the server thread when you touch player-facing gameplay:
```sh
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
+3 -3
View File
@@ -92,7 +92,7 @@ Owner: [ludovic111](https://github.com/ludovic111) · Tracking: [#3914](https://
<details><summary>Technical</summary>
Since [#1514](https://github.com/horizon-rust/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client; `--in-frame` keeps the old path. The open `[server-thread]` issues list the gaps. In review: breeding's advancement and statistic ([#3873](https://github.com/horizon-rust/horizon/issues/3873)), the client's menu when the server replaces the player ([#3841](https://github.com/horizon-rust/horizon/issues/3841)), splashes when falling in water ([#3621](https://github.com/horizon-rust/horizon/issues/3621)). [docs/server-levels.md](docs/server-levels.md).
Since [#1514](https://github.com/horizon-rust/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client. The open `[server-thread]` issues list the gaps. In review: breeding's advancement and statistic ([#3873](https://github.com/horizon-rust/horizon/issues/3873)), the client's menu when the server replaces the player ([#3841](https://github.com/horizon-rust/horizon/issues/3841)), splashes when falling in water ([#3621](https://github.com/horizon-rust/horizon/issues/3621)). [docs/server-levels.md](docs/server-levels.md).
</details>
@@ -234,11 +234,11 @@ Client-of-a-server fixes in `game/remote*.rs` and `horizon-protocol`: mobs' aggr
#### One single-player path: remove the in-frame world
Single player will have one way to run, the server thread, so every fix works everywhere and there are fewer bugs. The older in-frame mode goes once everything passes without it.
Single player has one way to run, the server thread, so every fix works everywhere and there are fewer bugs. The older in-frame mode is removed.
<details><summary>Technical</summary>
`--in-frame` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) still ticks the world inside the window's `Game`, and most harnesses test that path. Delete it once `lan_e2e --server-thread` and the parity harnesses pass on the server thread, which also halves the bug surface. See also 'Single player as a server' and [docs/server-levels.md](docs/server-levels.md).
`--in-frame` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) ticked the world inside the window's `Game`. [#6473](https://github.com/horizon-rust/horizon/issues/6473) removes it with the code only it reached (the window's world loader and player tick, its portal travel and loading screen, the in-frame LAN host); the harnesses run on the server thread. See also 'Single player as a server' and [docs/server-levels.md](docs/server-levels.md).
</details>
+51 -12
View File
@@ -125,19 +125,51 @@ pub const RULES: &[Rule] = &[
b("universal_anger", "mobs", false),
b("water_source_conversion", "updates", true),
];
/// GameRules.MAX_MINECART_SPEED (26.3), available with minecart improvements.
pub const MAX_MINECART_SPEED: Rule = i("max_minecart_speed", "misc", 8, 1, 1000);
pub const MINECART_IMPROVEMENTS: &str = "minecraft:minecart_improvements";
pub fn available_rules(minecart_improvements: bool) -> impl Iterator<Item = &'static Rule> {
RULES
.iter()
.chain(minecart_improvements.then_some(&MAX_MINECART_SPEED))
}
pub fn rule(name: &str) -> Option<&'static Rule> {
let name = name.strip_prefix("minecraft:").unwrap_or(name);
RULES.iter().find(|r| r.name == name)
available_rules(true).find(|r| r.name == name)
}
/// Current values (defaults unless set).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct GameRules {
values: BTreeMap<&'static str, Value>,
minecart_improvements: bool,
}
impl GameRules {
pub fn with_features(features: &[String]) -> Self {
Self {
minecart_improvements: features.iter().any(|f| f == MINECART_IMPROVEMENTS),
..Self::default()
}
}
/// GameRules.copy(enabledFeatures): keep available values, add defaults for
/// newly enabled rules and discard rules absent from the target feature set.
pub fn copy_with_features(&self, features: &[String]) -> Self {
let mut rules = Self::with_features(features);
for (&name, &value) in &self.values {
rules.set(name, value);
}
rules
}
pub fn available_rules(&self) -> impl Iterator<Item = &'static Rule> + use<> {
available_rules(self.minecart_improvements)
}
pub fn get(&self, name: &str) -> Option<Value> {
let r = rule(name)?;
if r.name == MAX_MINECART_SPEED.name && !self.minecart_improvements {
return None;
}
Some(self.values.get(r.name).copied().unwrap_or(r.default))
}
pub fn bool(&self, name: &str) -> bool {
@@ -160,7 +192,9 @@ impl GameRules {
}
/// Set a rule; false if the name or value type is wrong.
pub fn set(&mut self, name: &str, value: Value) -> bool {
let Some(r) = rule(name) else { return false };
let Some(r) = rule(name).filter(|r| self.get(r.name).is_some()) else {
return false;
};
let ok = match (r.default, value) {
(Value::Bool(_), Value::Bool(_)) => true,
(Value::Int(_), Value::Int(v)) => (r.min..=r.max).contains(&v),
@@ -176,8 +210,7 @@ impl GameRules {
/// Every rule's value by name without namespace, as `save::Level` holds
/// them (booleans as 0 or 1).
pub fn by_name(&self) -> BTreeMap<String, i32> {
RULES
.iter()
self.available_rules()
.map(|r| {
let v = match self.values.get(r.name).copied().unwrap_or(r.default) {
Value::Bool(b) => i32::from(b),
@@ -189,8 +222,8 @@ impl GameRules {
}
/// GameRuleMap codec: `{data: {"minecraft:<rule>": value...}, DataVersion}`.
pub fn to_nbt(&self) -> NamedTag {
let data: Vec<(horizon_world::nbt::JavaString, Tag)> = RULES
.iter()
let data: Vec<(horizon_world::nbt::JavaString, Tag)> = self
.available_rules()
.map(|r| {
let v = self.values.get(r.name).copied().unwrap_or(r.default);
let tag = match v {
@@ -212,11 +245,14 @@ impl GameRules {
}
}
pub fn from_nbt(root: &Tag) -> Self {
let mut rules = Self::default();
Self::from_nbt_with_features(root, &[])
}
pub fn from_nbt_with_features(root: &Tag, features: &[String]) -> Self {
let mut rules = Self::with_features(features);
let Some(data) = root.get("data") else {
return rules;
};
for r in RULES {
for r in rules.available_rules() {
let key = format!("minecraft:{}", r.name);
let value = data.get(&key).or_else(|| data.get(r.name));
match (r.default, value) {
@@ -232,15 +268,18 @@ impl GameRules {
rules
}
pub fn load(world: &Path) -> Self {
Self::load_with_features(world, &[])
}
pub fn load_with_features(world: &Path, features: &[String]) -> Self {
let Ok(bytes) = fs::read(world.join("data/minecraft/game_rules.dat")) else {
return Self::default();
return Self::with_features(features);
};
let Ok(raw) = Compression::Gzip.decode(&bytes, 16 << 20) else {
return Self::default();
return Self::with_features(features);
};
match nbt::read_named(&raw, Limits::default()) {
Ok((root, _)) => Self::from_nbt(&root.tag),
Err(_) => Self::default(),
Ok((root, _)) => Self::from_nbt_with_features(&root.tag, features),
Err(_) => Self::with_features(features),
}
}
pub fn save(&self, world: &Path) -> crate::Result<()> {
+18 -16
View File
@@ -5,7 +5,6 @@ use horizon_core::{noise::SimplexNoise, position::BlockPos, random::LegacyRandom
use serde_json::Value;
use std::{
collections::{BTreeMap, HashMap},
fs,
path::Path,
sync::Arc,
};
@@ -171,7 +170,9 @@ fn color(v: &Value) -> Option<u32> {
}
fn colormap(root: &Path, name: &str) -> Result<Vec<u32>> {
let path = root.join(format!("assets/minecraft/textures/colormap/{name}.png"));
let mut decoder = png::Decoder::new(std::io::BufReader::new(fs::File::open(path)?));
let mut decoder = png::Decoder::new(std::io::Cursor::new(horizon_core::prepared_files::read(
&path,
)?));
decoder.ignore_checksums(true);
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info()?;
@@ -277,21 +278,22 @@ impl Biomes {
dry: colormap(root, "dry_foliage")?,
};
let mut list = Vec::new();
let mut names: Vec<_> = fs::read_dir(root.join("data/minecraft/worldgen/biome"))?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "json"))
// The folder's own files (not its subfolders'), by file name: through `json_folder`,
// from the prepared data file when there is one.
let folder = horizon_world::json_folder::read(&root.join("data/minecraft/worldgen/biome"))?;
let mut files: Vec<_> = folder
.files
.iter()
.filter(|f| !f.name.contains('/'))
.collect();
names.sort();
for path in names {
let name = format!(
"minecraft:{}",
path.file_stem()
.and_then(|s| s.to_str())
.ok_or("bad biome")?
);
let v: Value = serde_json::from_slice(&fs::read(&path)?)?;
list.push(BiomeInfo::from_json(name, &v, &maps));
files.sort_by_cached_key(|f| format!("{}.json", f.name));
for file in files {
let v: Value = serde_json::from_slice(file.bytes()?)?;
list.push(BiomeInfo::from_json(
format!("minecraft:{}", file.name),
&v,
&maps,
));
}
let index = list
.iter()
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,452 @@
//! Finding a server's BlueMap without the player naming it
//! (`blueMapAutoDetect`, off by default): the BlueMap worker tries a few
//! likely web addresses (`candidates`), taking only one whose `settings.json`
//! is BlueMap's web-app settings, and the answer is kept per server in
//! `server-terrain/<server>/BlueMap/detected.txt` so a rejoin asks nothing.
//! A `blueMap:` line the player wrote always wins.
use crate::bluemap::Settings;
use std::{
net::IpAddr,
path::{Path, PathBuf},
sync::Mutex,
time::{Duration, SystemTime},
};
/// The answer kept per server, beside its BlueMap tiles.
pub const FILE: &str = "detected.txt";
/// A server found without a map is asked again after this long.
pub const RETRY_NONE: Duration = Duration::from_secs(24 * 3600);
/// A map found is checked again (its address first) after this long.
pub const RETRY_FOUND: Duration = Duration::from_secs(7 * 24 * 3600);
/// Each probe gets this long, connecting included.
pub const TIMEOUT: Duration = Duration::from_secs(4);
/// Probes start this far apart at least.
pub const GAP: Duration = Duration::from_millis(500);
/// Where a level's BlueMap comes from.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Source {
/// The player's `blueMap:` line, or a map found before.
Url {
url: String,
maps: Vec<(String, String)>,
},
/// To be looked for at these addresses, in order.
Detect(Vec<String>),
}
/// What the server at `address` draws far terrain from, its BlueMap folder
/// being `dir`: `None` when the player named no map and either auto-detect
/// is off or the server was found without one recently.
pub fn source(settings: &Settings, address: &str, dir: &Path, now: SystemTime) -> Option<Source> {
if let Some(server) = settings.server(address) {
return Some(Source::Url {
url: server.url.clone(),
maps: server.maps.clone(),
});
}
if !settings.auto_detect {
return None;
}
let mut probes = Vec::new();
// Only an address this server's would be looked for at: a remembered
// one from elsewhere (a copied folder) names someone else's.
let ours = candidates(address);
match remembered(dir, now) {
Remembered::Found(url) | Remembered::Stale(Some(url)) if !ours.contains(&url) => {}
Remembered::Found(url) => {
return Some(Source::Url {
url,
maps: Vec::new(),
});
}
Remembered::Nothing => return None,
Remembered::Stale(Some(url)) => probes.push(url),
Remembered::Stale(None) => {}
}
for candidate in ours {
if !probes.contains(&candidate) {
probes.push(candidate);
}
}
(!probes.is_empty()).then_some(Source::Detect(probes))
}
/// The first labels a game server's host name takes beside its site's.
const GAME_LABELS: &[&str] = &["play", "mc", "minecraft", "mine", "join", "server"];
/// Domains whose names belong to many customers (dynamic DNS, server
/// hosts): `map.` of them is someone else's.
const SHARED: &[&str] = &[
"aternos.me",
"ddns.net",
"duckdns.org",
"hopto.org",
"minehut.gg",
"no-ip.org",
"playit.gg",
"serveminecraft.net",
"zapto.org",
];
/// The web addresses a server's BlueMap likely answers at: BlueMap's own
/// web server (port 8100), the server's host, and its `map.` subdomain, also
/// under the parent domain of a `play.`/`mc.` style host (`GAME_LABELS`).
pub fn candidates(address: &str) -> Vec<String> {
let address = address.trim().to_ascii_lowercase();
let host = if let Some(rest) = address.strip_prefix('[') {
rest.split(']').next().unwrap_or(rest)
} else if address.matches(':').count() > 1 {
&address[..]
} else {
address.split(':').next().unwrap_or(&address)
};
let host = host.trim_end_matches('.');
if host.is_empty() {
return Vec::new();
}
let mut out = Vec::new();
if let Ok(ip) = host.parse::<IpAddr>() {
// Certificates are for names: plain HTTP only.
let host = match ip {
IpAddr::V6(_) => format!("[{host}]"),
IpAddr::V4(_) => host.to_owned(),
};
out.push(format!("http://{host}:8100"));
out.push(format!("http://{host}"));
return out;
}
if !host
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
{
return Vec::new();
}
out.push(format!("http://{host}:8100"));
let labels = host.split('.').count();
let map = |host: &str| (!host.starts_with("map.")).then(|| format!("map.{host}"));
if labels >= 2
&& let Some(map) = map(host)
{
out.push(format!("https://{map}"));
}
out.push(format!("https://{host}"));
if labels >= 2
&& let Some(map) = map(host)
{
out.push(format!("http://{map}"));
}
out.push(format!("http://{host}"));
// `play.example.net` → `map.example.net`, only for a game server's
// usual names: the parent of `name.hosting.example` is another party's.
if labels >= 3
&& let Some((first, parent)) = host.split_once('.')
&& GAME_LABELS.contains(&first)
&& !SHARED.contains(&parent)
&& let Some(map) = map(parent)
{
out.push(format!("https://{map}"));
out.push(format!("http://{map}"));
}
let mut seen = Vec::new();
out.retain(|url| {
let new = !seen.contains(url);
seen.push(url.clone());
new
});
out
}
/// Whether `json` is a BlueMap web app's `settings.json`: a list of map ids
/// and where their data lies, not any page a web server returns.
pub fn is_web_app_settings(json: &[u8]) -> bool {
let Ok(v) = serde_json::from_slice::<serde_json::Value>(json) else {
return false;
};
let maps = v.get("maps").and_then(|m| m.as_array());
maps.is_some_and(|maps| !maps.is_empty() && maps.iter().all(|m| m.is_string()))
&& v.get("mapDataRoot").is_some_and(|r| r.is_string())
}
/// Whether a BlueMap web app answers at `base`.
pub fn probe(agent: &ureq::Agent, base: &str) -> bool {
use std::io::Read;
let Ok(response) = agent
.get(&format!("{base}/settings.json"))
.timeout(TIMEOUT)
.call()
else {
return false;
};
let mut body = Vec::new();
response
.into_reader()
.take(256 << 10)
.read_to_end(&mut body)
.is_ok()
&& is_web_app_settings(&body)
}
/// What an earlier look found.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Remembered {
Found(String),
/// Nothing, recently.
Nothing,
/// Never looked, or long enough ago to look again (first at the map
/// found then, if any).
Stale(Option<String>),
}
pub fn remembered(dir: &Path, now: SystemTime) -> Remembered {
let Ok(text) = std::fs::read_to_string(dir.join(FILE)) else {
return Remembered::Stale(None);
};
let mut words = text.split_whitespace();
let (kind, at) = (words.next(), words.next().and_then(|t| t.parse().ok()));
let Some(at) = at.map(|secs| SystemTime::UNIX_EPOCH + Duration::from_secs(secs)) else {
return Remembered::Stale(None);
};
// A time ahead of the clock (a clock set wrong, a copied file) is stale.
let Ok(age) = now.duration_since(at) else {
return Remembered::Stale(None);
};
match (kind, words.next()) {
(Some("found"), Some(url)) if age < RETRY_FOUND => Remembered::Found(url.to_owned()),
(Some("found"), Some(url)) => Remembered::Stale(Some(url.to_owned())),
(Some("none"), _) if age < RETRY_NONE => Remembered::Nothing,
_ => Remembered::Stale(None),
}
}
/// Keeps what a look found (`None`: no map).
pub fn remember(dir: &Path, found: Option<&str>, now: SystemTime) {
let secs = now
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let text = match found {
Some(url) => format!("found {secs} {url}\n"),
None => format!("none {secs}\n"),
};
let _ = std::fs::create_dir_all(dir);
let _ = std::fs::write(dir.join(FILE), text);
}
/// The servers being looked at now, by BlueMap folder: one look a server at
/// a time, however many levels open meanwhile.
static LOOKING: Mutex<Vec<PathBuf>> = Mutex::new(Vec::new());
/// The right to look for `dir`'s server's map, until dropped.
pub struct Claim(PathBuf);
impl Claim {
/// `None` while another look at the same server runs.
pub fn take(dir: &Path) -> Option<Self> {
let mut looking = LOOKING.lock().unwrap();
if looking.iter().any(|d| d == dir) {
return None;
}
looking.push(dir.to_owned());
Some(Self(dir.to_owned()))
}
}
impl Drop for Claim {
fn drop(&mut self) {
LOOKING.lock().unwrap().retain(|d| *d != self.0);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn candidates_cover_the_web_server_host_and_map_subdomains() {
assert_eq!(
candidates("Play.Example.net:25566"),
[
"http://play.example.net:8100",
"https://map.play.example.net",
"https://play.example.net",
"http://map.play.example.net",
"http://play.example.net",
"https://map.example.net",
"http://map.example.net",
]
);
assert_eq!(
candidates("example.net"),
[
"http://example.net:8100",
"https://map.example.net",
"https://example.net",
"http://map.example.net",
"http://example.net",
]
);
// Already the map's host: no `map.map.`.
assert_eq!(
candidates("map.example.net"),
[
"http://map.example.net:8100",
"https://map.example.net",
"http://map.example.net",
]
);
assert_eq!(
candidates("192.0.2.7:25565"),
["http://192.0.2.7:8100", "http://192.0.2.7"]
);
assert_eq!(
candidates("[2001:db8::7]:25565"),
["http://[2001:db8::7]:8100", "http://[2001:db8::7]"]
);
assert_eq!(
candidates("2001:db8::7"),
["http://[2001:db8::7]:8100", "http://[2001:db8::7]"]
);
assert_eq!(
candidates("localhost"),
[
"http://localhost:8100",
"https://localhost",
"http://localhost"
]
);
// A hosted name's parent is the host's, not the server's.
assert!(
!candidates("play.ddns.net")
.iter()
.any(|url| url.ends_with("//map.ddns.net"))
);
assert!(
!candidates("name.host.example")
.iter()
.any(|url| url.ends_with("//map.host.example"))
);
assert!(candidates("").is_empty());
assert!(candidates("evil.example.net/path?x").is_empty());
}
#[test]
fn only_blue_maps_settings_count() {
let web_app = br#"{"version":"5.4","useCookies":true,"maps":["world","world_nether"],
"mapDataRoot":"maps","liveDataRoot":"maps","scripts":[],"styles":[]}"#;
assert!(is_web_app_settings(web_app));
assert!(!is_web_app_settings(b"<!doctype html><html></html>"));
assert!(
!is_web_app_settings(br#"{"maps":["world"]}"#),
"no data root"
);
assert!(!is_web_app_settings(br#"{"maps":[],"mapDataRoot":"maps"}"#));
assert!(!is_web_app_settings(
br#"{"maps":[1],"mapDataRoot":"maps"}"#
));
assert!(!is_web_app_settings(
br#"{"maps":"world","mapDataRoot":"maps"}"#
));
assert!(!is_web_app_settings(
br#"[{"maps":["world"],"mapDataRoot":"maps"}]"#
));
}
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"horizon-bluemap-detect-{name}-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
dir
}
#[test]
fn answers_are_kept_and_looked_at_again_later() {
let dir = scratch("remember");
let t0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000);
let hour = Duration::from_secs(3600);
assert_eq!(remembered(&dir, t0), Remembered::Stale(None));
remember(&dir, None, t0);
assert_eq!(remembered(&dir, t0 + hour), Remembered::Nothing);
// Written ahead of the clock: not trusted.
assert_eq!(remembered(&dir, t0 - hour), Remembered::Stale(None));
assert_eq!(remembered(&dir, t0 + RETRY_NONE), Remembered::Stale(None));
remember(&dir, Some("https://map.example.net"), t0);
assert_eq!(
remembered(&dir, t0 + 2 * RETRY_NONE),
Remembered::Found("https://map.example.net".into())
);
assert_eq!(
remembered(&dir, t0 + RETRY_FOUND),
Remembered::Stale(Some("https://map.example.net".into()))
);
std::fs::write(dir.join(FILE), "garbage").unwrap();
assert_eq!(remembered(&dir, t0), Remembered::Stale(None));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_players_map_wins_and_detection_waits_for_the_setting() {
let dir = scratch("source");
let now = SystemTime::now();
let mut settings = Settings::default();
let address = "play.example.net";
assert_eq!(
source(&settings, address, &dir, now),
None,
"off by default"
);
settings.auto_detect = true;
let Some(Source::Detect(probes)) = source(&settings, address, &dir, now) else {
panic!("looked for");
};
assert_eq!(probes, candidates(address));
remember(&dir, None, now);
assert_eq!(
source(&settings, address, &dir, now),
None,
"none, recently"
);
remember(&dir, Some("https://map.example.net"), now);
assert_eq!(
source(&settings, address, &dir, now),
Some(Source::Url {
url: "https://map.example.net".into(),
maps: Vec::new()
})
);
// A remembered map this server's addresses don't include (a copied
// folder's) is no map.
remember(&dir, Some("https://tracker.example"), now);
let Some(Source::Detect(probes)) = source(&settings, address, &dir, now) else {
panic!("looked for, the remembered one ignored");
};
assert_eq!(probes, candidates(address));
remember(&dir, Some("https://map.example.net"), now);
// Past a week the found map is tried first, then the rest.
let later = now + RETRY_FOUND;
let Some(Source::Detect(probes)) = source(&settings, address, &dir, later) else {
panic!("looked for again");
};
assert_eq!(probes[0], "https://map.example.net");
assert_eq!(probes.len(), candidates(address).len());
settings.servers.push(
Settings::parse_server("play.example.net http://example.net:8123 nether=n").unwrap(),
);
assert_eq!(
source(&settings, address, &dir, now),
Some(Source::Url {
url: "http://example.net:8123".into(),
maps: vec![("minecraft:the_nether".into(), "n".into())]
})
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn one_look_a_server_at_a_time() {
let dir = scratch("claim");
let claim = Claim::take(&dir).unwrap();
assert!(Claim::take(&dir).is_none());
drop(claim);
assert!(Claim::take(&dir).is_some());
}
}
@@ -544,7 +544,7 @@ fn the_chat_option_filters_the_lines_both_ways() {
assert_eq!(shown(&chat), ["client", "server", "player"]);
}
/// The in-frame LAN host's chat: a guest's line is a player message, so the
/// A server's own chat: a guest's line is a player message, so the
/// Chat option's player-message permission hides it at Commands Only and
/// Hidden; command feedback (`/list`, `/tp`) is a system message and stays
/// at Commands Only.
@@ -211,6 +211,36 @@ impl SceneCollisions<'_, '_> {
) -> Result<Vec3> {
self.resolve_with_context(bounds, movement, max_step, on_ground, self.context(bounds))
}
/// [`Self::resolve`], also stopped by the world border's walls when one is
/// given with the moving entity's x and z: `Entity.collectColliders` adds
/// `WorldBorder.getCollisionShape` for any entity inside and close to it.
pub fn resolve_in_border(
self,
bounds: Aabb,
movement: Vec3,
max_step: f32,
on_ground: bool,
border: Option<(&BorderGeometry, [f64; 2])>,
) -> Result<Vec3> {
let context = self.context(bounds);
let mut scene = EntityCollisions {
blocks: self,
entities: &[],
border,
};
Ok(resolve_world_movement(
&mut scene,
MovementRequest {
entity: EntityIdentity(0),
context,
bounds,
movement,
max_step,
on_ground,
candidate_budget: 1_000_000,
},
)?)
}
/// Resolve movement with the entity's captured collision context. Most
/// callers only need the position context; entities such as falling blocks
/// also affect contextual block shapes (for example powder snow).
@@ -617,6 +647,25 @@ mod border_movement_tests {
);
}
/// A minecart (0.98 wide, 0.7 tall) rolling east at its top speed of 0.4 a tick, 0.11
/// blocks short of the wall: the wall takes all but the 0.11 (`move_cart` hands the
/// cart's x and z to `with_border`, as every entity does in `Entity.move`).
#[test]
fn a_minecart_sized_body_stops_at_the_border_wall() {
// Centre x = 4.4, half width 0.49: the cart's east face is at 4.89, the wall at 5.0.
let bounds = Aabb::new(Vec3::new(3.91, 0.0, -0.49), Vec3::new(4.89, 0.7, 0.49));
let border = border();
let mut collisions = EntityCollisions::with_border(EmptyCollisions, &border, [4.4, 0.0]);
let moved = collisions
.resolve(bounds, Vec3::new(0.4, 0.0, 0.0), 0.0, false)
.expect("the movement resolves");
assert!(
(moved.x - 0.11).abs() < 1.0e-9,
"the cart was not stopped by the border: {moved:?}"
);
assert_eq!(moved.z, 0.0);
}
/// Away from the border, the same body moves the whole way.
#[test]
fn a_body_away_from_the_border_moves_whole() {
+5 -1
View File
@@ -121,7 +121,11 @@ impl DataPack {
}
/// The packs in `<world>/datapacks`, sorted by name.
pub fn discover(world: &Path) -> Vec<DataPack> {
let Ok(entries) = std::fs::read_dir(world.join("datapacks")) else {
discover_folder(&world.join("datapacks"))
}
/// Packs in Create World's temporary pack folder, before a world exists.
pub fn discover_folder(dir: &Path) -> Vec<DataPack> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut packs = Vec::new();
+5 -2
View File
@@ -53,7 +53,10 @@ fn namespaced(reference: &str) -> String {
fn read_model(root: &Path, name: &str) -> Option<Json> {
let (ns, path) = name.split_once(':')?;
let text = fs::read_to_string(root.join(format!("assets/{ns}/models/{path}.json"))).ok()?;
let text = horizon_core::prepared_files::read_to_string(
&root.join(format!("assets/{ns}/models/{path}.json")),
)
.ok()?;
Json::parse(&text).ok()
}
@@ -186,7 +189,7 @@ fn connecting_textures(root: &Path) -> HashMap<String, Meta> {
let Some(png) = path.to_str().and_then(|p| p.strip_suffix(".png.mcmeta")) else {
continue;
};
let Ok(text) = fs::read_to_string(&path) else {
let Ok(text) = horizon_core::prepared_files::read_to_string(&path) else {
continue;
};
if !text.contains("fusion") {
+108 -64
View File
@@ -90,18 +90,23 @@ fn gpu_culling_requested(info: &wgpu::AdapterInfo, env: Option<&str>) -> bool {
/// Whether Translucent Batching is offered (`graphics::offer_translucent_batching`): where
/// indirect multi-draws (`multi_draw`) take its batches, but not OpenGL's, which loop over
/// their entries on the CPU (and without them every section is a call of its own anyway);
/// nor where vanilla frames draw flat (`flat`'s `min_sections` below `usize::MAX`: Metal,
/// Intel Gen11/Gen12's Vulkan driver, `HORIZON_FLAT_DRAWS=1`), as flat draws read
/// translucent quads in the section arena.
fn translucent_batchable(
info: &wgpu::AdapterInfo,
multi_draw: bool,
flat_min_sections: Option<usize>,
) -> bool {
multi_draw
&& info.backend != wgpu::Backend::Gl
&& flat_min_sections.is_none_or(|min| min == usize::MAX)
/// their entries on the CPU (and without them every section is a call of its own anyway).
/// Frames drawn flat (Metal, Intel Gen11/Gen12's Vulkan driver) take it too: their
/// translucent calls then come one per page of its arena rather than about one per section.
fn translucent_batchable(info: &wgpu::AdapterInfo, multi_draw: bool) -> bool {
multi_draw && info.backend != wgpu::Backend::Gl
}
/// The vertices flat draws bind for a call's page key: a page of the section arena, or of
/// the translucent arena up to its last range in use (`flat::TRANSLUCENT_KEYS`).
fn flat_vertices<'a>(
arena: &'a arena::Arena,
translucent: Option<&'a arena::Arena>,
) -> impl Fn(u32) -> wgpu::BufferSlice<'a> + Copy {
move |key| match (translucent, key.checked_sub(flat::TRANSLUCENT_KEYS)) {
(Some(translucent), Some(page)) => translucent.binding(page),
_ => arena.buffer(key).slice(..),
}
}
/// Whether translucent quads have an arena of their own: with Translucent Batching
@@ -498,8 +503,11 @@ 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));
let key = match part {
Part::Translucent => to.0 + flat::TRANSLUCENT_KEYS,
_ => to.0,
};
flat.place(device, encoder, uploads, id, key, to.1, vertices);
}
part.set(s, to);
*budget = budget.saturating_sub(bytes);
@@ -3707,7 +3715,7 @@ impl Renderer {
flat_wanted,
);
// Sections as one index list per frame where multi-draw is emulated.
let flat = flat_wanted.then(|| {
let mut flat = flat_wanted.then(|| {
let buffers = [Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<TerrainVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
@@ -3743,11 +3751,7 @@ impl Renderer {
.get_downlevel_capabilities()
.flags
.contains(wgpu::DownlevelFlags::INDIRECT_EXECUTION);
let batchable = translucent_batchable(
&adapter.get_info(),
multi_draw,
flat.as_ref().map(|f| f.min_sections),
);
let batchable = translucent_batchable(&adapter.get_info(), multi_draw);
crate::graphics::offer_translucent_batching(batchable);
let translucent_batching = batchable && translucent_batching;
// A new renderer runs no shader pack: the game loads the picked one on its
@@ -3763,6 +3767,9 @@ impl Renderer {
flat.is_some(),
translucent_split(translucent_batching, flat.is_some(), shader_pack, false),
);
if let (Some(flat), Some(arena)) = (&mut flat, &translucent_arena) {
flat.set_translucent_page_vertices(arena.page_vertices());
}
// Without indirect draws (OpenGL before 4.0 without indirect execution, or
// before 4.2 + GL_ARB_shader_draw_parameters without indirect first instance)
// even creating an indirect buffer fails; one draw per section reads
@@ -4085,7 +4092,11 @@ impl Renderer {
match flat {
Some(flat) => {
pass.set_pipeline(&flat.translucent_mark);
flat.draw(pass, flat.translucent_calls(), |p| self.arena.buffer(p));
flat.draw(
pass,
flat.translucent_calls(),
flat_vertices(&self.arena, self.translucent_arena.as_ref()),
);
pass.set_bind_group(0, &self.group, &[]);
}
None => {
@@ -4478,17 +4489,23 @@ impl Renderer {
} else {
None
};
// 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| {
f.add(
&self.device,
encoder,
&mut self.section_uploads,
page,
start,
main,
)
// Flat draws name the blocks of its ranges by its id: its range in `arena` and,
// split, its translucent quads' in `translucent_arena` (`flat::TRANSLUCENT_KEYS`).
let translucent_key = (split && translucent > 0)
.then(|| (translucent_at.0 + flat::TRANSLUCENT_KEYS, translucent_at.1));
let flat_id = self.flat.as_mut().and_then(|f| {
let ranges = [(main > 0).then_some((page, start, main))]
.into_iter()
.chain([translucent_key.map(|(key, first)| (key, first, translucent_vertices))]);
let mut id = None;
for (key, first, vertices) in ranges.flatten() {
let uploads = &mut self.section_uploads;
match id {
None => id = Some(f.add(&self.device, encoder, uploads, key, first, vertices)),
Some(id) => f.place(&self.device, encoder, uploads, id, key, first, vertices),
}
}
id
});
let stride = std::mem::size_of::<TerrainVertex>() as u64;
let translucent_arena = self.translucent_arena.as_ref().unwrap_or(&self.arena);
@@ -4721,7 +4738,13 @@ impl Renderer {
_ => COMPACT_BUDGET,
};
let mut budget = left.min(cap);
if !move_off_page(arena, encoder, None, meshes, page, part, &mut budget) {
// Flat draws name translucent quads' blocks by their section's id; indices have none.
let flat = match part {
Part::Translucent => self.flat.as_mut(),
_ => None,
}
.map(|f| (f, &self.device, &mut self.section_uploads));
if !move_off_page(arena, encoder, flat, meshes, page, part, &mut budget) {
// New ranges took the room: both pages stay, drawn, until a later try.
arena.reopen();
self.shrink_gate.wait(now, SHRINK_RETRY);
@@ -4901,6 +4924,9 @@ impl Renderer {
self.flat.is_some(),
split,
);
if let (Some(flat), Some(arena)) = (&mut self.flat, &self.translucent_arena) {
flat.set_translucent_page_vertices(arena.page_vertices());
}
}
/// Whether every section and far tile was dropped since the last call, to be
/// laid out again (`follow_translucent_layout`): the game meshes them again,
@@ -5536,9 +5562,11 @@ impl Renderer {
.map(pack_timing::PassTiming::medians)
.unwrap_or_default()
}
/// The macros of the GL extensions this device gives shader packs: subgroups, when it
/// has them.
/// The macros this renderer gives shader packs: the GL extensions this device has
/// (subgroups, when it has them), and the vendor, renderer and mipmap levels
/// (`Macros::define_renderer`).
pub fn define_pack_macros(&self, macros: &mut horizon_shaders::Macros) {
macros.define_renderer(self.mipmaps);
if self.device.features().contains(wgpu::Features::SUBGROUP) {
for ext in [
"basic",
@@ -5854,7 +5882,11 @@ impl Renderer {
arena::free_later(self.graveyard.advance());
self.shrink_translucent(left);
if let Some(flat) = &mut self.flat {
flat.trim(|page| self.arena.has_page(page));
let translucent = self.translucent_arena.as_ref();
flat.trim(|key| match key.checked_sub(flat::TRANSLUCENT_KEYS) {
Some(page) => translucent.is_some_and(|a| a.has_page(page)),
None => self.arena.has_page(key),
});
}
if self.suspended {
return Ok(false);
@@ -7218,8 +7250,6 @@ impl Renderer {
|| pack_terrain
|| shadow_view.is_some()) =>
{
// They read translucent quads in the section arena (`translucent_split`).
debug_assert!(self.translucent_arena.is_none(), "flat draws, split");
let sections = &self.sections;
let lods = &self.lods;
let sorted = &self.sorted_indices;
@@ -7236,6 +7266,7 @@ impl Renderer {
[shadow_first, shadow_first + shadow_solid].map(|d| d as usize),
[opaque_end, cutout_start],
phase2,
self.translucent_arena.is_some(),
visible
.iter()
.chain(&shadow_visible)
@@ -7480,9 +7511,11 @@ impl Renderer {
{
pass.set_pipeline(pipeline);
let [first, translucent] = flat.shadow_calls;
flat.draw(&mut pass, &flat.calls[first..translucent], |p| {
self.arena.buffer(p)
});
flat.draw(
&mut pass,
&flat.calls[first..translucent],
flat_vertices(&self.arena, self.translucent_arena.as_ref()),
);
} else if !flat_frame {
pass.set_pipeline(&shadow.solid);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
@@ -7503,9 +7536,11 @@ impl Renderer {
{
pass.set_pipeline(pipeline);
let [_, translucent] = flat.shadow_calls;
flat.draw(&mut pass, &flat.calls[translucent..], |p| {
self.arena.buffer(p)
});
flat.draw(
&mut pass,
&flat.calls[translucent..],
flat_vertices(&self.arena, self.translucent_arena.as_ref()),
);
} else if !flat_frame {
pass.set_pipeline(&shadow.translucent);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
@@ -7668,7 +7703,7 @@ impl Renderer {
// is phase 1's.
if flat_frame && let Some(flat) = &self.flat {
pass.set_pipeline(&flat.solid_mirror);
let vertices = |p| self.arena.buffer(p);
let vertices = flat_vertices(&self.arena, self.translucent_arena.as_ref());
flat.draw(&mut pass, &flat.calls[..flat.solid_calls], vertices);
} else {
pass.set_pipeline(&self.solid_mirror);
@@ -7856,7 +7891,7 @@ impl Renderer {
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
}
Some(flat) => {
let vertices = |p| self.arena.buffer(p);
let vertices = flat_vertices(&self.arena, self.translucent_arena.as_ref());
// 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.
@@ -8331,7 +8366,11 @@ impl Renderer {
// Flat section draws take the flat pipelines.
if let Some((flat, pipeline)) = flat.zip(oit.flat(stage)) {
pass.set_pipeline(pipeline);
flat.draw(pass, flat.translucent_calls(), |p| self.arena.buffer(p));
flat.draw(
pass,
flat.translucent_calls(),
flat_vertices(&self.arena, self.translucent_arena.as_ref()),
);
return;
}
pass.set_pipeline(oit.terrain(stage));
@@ -8395,9 +8434,11 @@ impl Renderer {
}
Some(flat) => {
pass.set_pipeline(pack_flat.map_or(&flat.translucent, |p| &p.1));
flat.draw(&mut pass, flat.translucent_calls(), |p| {
self.arena.buffer(p)
});
flat.draw(
&mut pass,
flat.translucent_calls(),
flat_vertices(&self.arena, self.translucent_arena.as_ref()),
);
pass.set_bind_group(0, &self.group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
self.mark_translucent_terrain(
@@ -8578,10 +8619,16 @@ impl Renderer {
}],
});
rp.set_bind_group(5, &indices, &[]);
let vertices = |p| self.arena.buffer(p);
let vertices =
flat_vertices(&self.arena, self.translucent_arena.as_ref());
flat.draw_geometry(rp, calls, vertices, page, instances);
}
None => flat.draw_with(rp, calls, |p| self.arena.buffer(p), page),
None => flat.draw_with(
rp,
calls,
flat_vertices(&self.arena, self.translucent_arena.as_ref()),
page,
),
}
}
// A geometry stage reads flat draws' indices; without them it draws nothing.
@@ -9652,23 +9699,20 @@ mod tests {
assert!(!super::gpu_culling_requested(&discrete, Some("0")));
}
/// Translucent Batching is offered with indirect multi-draws that aren't OpenGL's,
/// and only where frames without a shader pack never draw flat.
/// Translucent Batching is offered with indirect multi-draws that aren't OpenGL's, flat
/// frames (Metal, Intel Gen11/Gen12 on Vulkan) included.
#[test]
fn translucent_batching_is_offered_where_vanilla_frames_never_draw_flat() {
fn translucent_batching_is_offered_with_indirect_multi_draws_except_on_opengl() {
let vulkan = adapter(wgpu::DeviceType::DiscreteGpu);
let mut metal = adapter(wgpu::DeviceType::IntegratedGpu);
metal.backend = wgpu::Backend::Metal;
let mut gl = adapter(wgpu::DeviceType::DiscreteGpu);
gl.backend = wgpu::Backend::Gl;
let batchable = super::translucent_batchable;
// No flat draws, or flat draws for a shader pack's terrain alone.
assert!(batchable(&vulkan, true, None));
assert!(batchable(&vulkan, true, Some(usize::MAX)));
// Vanilla frames drawn flat past MIN_SECTIONS (Metal), or all of them.
assert!(!batchable(&vulkan, true, Some(super::flat::MIN_SECTIONS)));
assert!(!batchable(&vulkan, true, Some(0)));
assert!(!batchable(&vulkan, false, None));
assert!(!batchable(&gl, true, None));
assert!(!batchable(&gl, true, Some(usize::MAX)));
assert!(batchable(&vulkan, true));
assert!(batchable(&metal, true));
assert!(!batchable(&vulkan, false));
assert!(!batchable(&gl, true));
}
/// The pack-flat rule: with Translucent Batching translucent quads have an arena of
+131 -13
View File
@@ -157,21 +157,24 @@ impl Arena {
Self::with_usage(device, 4, capacity, usage, LARGE_PAGE_BYTES)
}
/// The sorted-index arena and, when `split`, the translucent arena
/// (vertices of `stride`), both with large pages, which no shader binds;
/// otherwise translucent quads stay in the section arena, and the indices
/// in pages flat draws (`flat`) bind as storage.
/// (vertices of `stride`) with large pages, which no shader binds;
/// otherwise translucent quads stay in the section arena. The indices go in
/// large pages too, unless flat draws (`flat`) bind their pages as storage.
pub fn translucent_arenas(
device: &wgpu::Device,
stride: u64,
flat: bool,
split: bool,
) -> (Self, Option<Self>) {
if split {
let indices = Self::translucent_indices(device, 1 << 16);
(indices, Some(Self::translucent(device, stride, 1 << 18)))
let indices = if split && !flat {
Self::translucent_indices(device, 1 << 16)
} else {
(Self::indices(device, 1 << 16, flat), None)
}
Self::indices(device, 1 << 16, flat)
};
(
indices,
split.then(|| Self::translucent(device, stride, 1 << 18)),
)
}
fn with_usage(
device: &wgpu::Device,
@@ -927,6 +930,121 @@ mod tests {
}
}
/// Flat draws find a section's origin by the id its blocks name, in the section arena's
/// tables and, split, in the translucent arena's (`flat::TRANSLUCENT_KEYS`). Translucent
/// quads a shrink moves (`move_off_page`) keep naming their section, or flat draws would
/// put them at another section's origin.
#[test]
fn moved_translucent_quads_keep_their_section_s_flat_id() {
use super::super::{Part, SectionGpu, flat, move_off_page, upload::Uploads};
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(
"@vertex fn vs() -> @builtin(position) vec4<f32> { return vec4<f32>(0.0); }
@fragment fn fs() -> @location(0) vec4<f32> { return vec4<f32>(0.0); }"
.into(),
),
});
let terrain_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: None,
entries: &[],
});
let pipeline = |layout: &wgpu::PipelineLayout, label: &str, _: &str, _: &str, _, _| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::TextureFormat::Rgba8Unorm.into())],
}),
multiview_mask: None,
cache: None,
})
};
let mut flat = flat::Flat::new(&device, &terrain_layout, 4 * GRAIN, 0, pipeline);
let stride = std::mem::size_of::<crate::mesher::TerrainVertex>() as u64;
let mut translucent = Arena::translucent(&device, stride, 4 * GRAIN);
translucent.page_limit = 4 * GRAIN;
flat.set_translucent_page_vertices(translucent.page_vertices());
let mut uploads = Uploads::new(&device);
let mut encoder = device.create_command_encoder(&Default::default());
// Page 0: another section's grain, then this one's two; page 1: a grain taken.
let (_, other) = alloc(&mut translucent, &device, &queue, GRAIN).unwrap();
let (_, at) = alloc(&mut translucent, &device, &queue, 2 * GRAIN).unwrap();
let (_, rest) = alloc(&mut translucent, &device, &queue, GRAIN).unwrap();
assert_eq!(
alloc(&mut translucent, &device, &queue, GRAIN),
Some((1, 0))
);
translucent.release(0, rest, GRAIN);
let key = |page: u32| page + flat::TRANSLUCENT_KEYS;
let other_id = flat.add(&device, &mut encoder, &mut uploads, key(0), other, GRAIN);
// Its solid quads in the section arena's page 0, its translucent ones here.
let id = flat.add(&device, &mut encoder, &mut uploads, 0, 0, GRAIN);
flat.place(
&device,
&mut encoder,
&mut uploads,
id,
key(0),
at,
2 * GRAIN,
);
let mut section = SectionGpu {
page: 0,
start: 0,
translucent_at: (0, at),
solid: 64,
sides: crate::mesher::Sides::unsorted(64),
translucent: 128,
sort: super::super::translucency::SortState::new(Vec::new()),
sorted_indices: None,
flat_id: Some(id),
fading: None,
leaving: None,
animated_layers: Box::new([]),
};
translucent.close(0);
let mut budget = u64::MAX;
assert!(move_off_page(
&mut translucent,
&mut encoder,
Some((&mut flat, &device, &mut uploads)),
std::iter::once(&mut section),
0,
Part::Translucent,
&mut budget
));
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
let (page, start) = section.translucent_at;
assert_eq!(page, 1);
assert_eq!(section.flat_id, Some(id));
let named =
|page: u32, grain: u64| read(&device, &queue, flat.blocks(key(page)), grain * 4);
for grain in start / GRAIN..(start / GRAIN + 2) {
assert_eq!(named(1, grain), id, "grain {grain} of its new range");
}
// The section arena's table and the other section's grain are untouched.
assert_eq!(read(&device, &queue, flat.blocks(0), 0), id);
assert_eq!(named(0, other / GRAIN), other_id);
}
/// Draws bind a large page up to the end of its last range in use, a grain at
/// least: wgpu clears what a binding covers and nothing wrote when the frame is
/// submitted, so a grown page bound whole had its new tail cleared on the GPU.
@@ -1028,9 +1146,9 @@ mod tests {
}
/// The translucent arena comes with the split layout alone, and only then
/// are the index pages large. Otherwise, as while a shader pack's terrain
/// draws flat (gfx's `translucent_split`), translucent quads keep to the
/// section arena, and flat draws (`flat`) bind the index pages as storage.
/// are the index pages large, unless flat draws (`flat`) bind them as
/// storage. Otherwise, as while a shader pack's terrain draws flat (gfx's
/// `translucent_split`), translucent quads keep to the section arena.
#[test]
fn translucent_arenas_follow_the_layout() {
let devices = devices();
@@ -1050,9 +1168,9 @@ mod tests {
assert_eq!(translucent.is_some(), split, "{case}");
let bytes = indices.page_vertices() * 4;
let storage = indices.usage.contains(wgpu::BufferUsages::STORAGE);
assert_eq!(storage, flat && !split, "{case}");
assert_eq!(storage, flat, "{case}");
assert!(!storage || bytes <= limits.max_storage_buffer_binding_size);
assert_eq!(bytes > PAGE_BYTES, split && large, "{case}");
assert_eq!(bytes > PAGE_BYTES, split && !flat && large, "{case}");
if let Some(t) = translucent {
assert_eq!(t.page_vertices() * 24 > PAGE_BYTES, large, "{case}");
}
@@ -10,7 +10,8 @@
//!
//! 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.
//! The rest stays on the CPU path. Item models come too ([`SkinJob::item`]):
//! their quads all face an axis, and their shading is baked into the colour.
use super::upload::Uploads;
use crate::mesher::Vertex;
@@ -35,6 +36,20 @@ pub struct SkinPart {
pub normal: [f32; 9],
}
impl SkinPart {
/// An item layer's part: its pose, and its quads' colours by axis.
pub fn item(matrix: glam::Mat4, colors: [u32; 6]) -> Self {
let mut normal = [0.0; 9];
for (word, color) in normal.iter_mut().zip(colors) {
*word = f32::from_bits(color);
}
Self {
matrix: matrix.to_cols_array(),
normal,
}
}
}
/// 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)]
@@ -51,7 +66,12 @@ pub struct SkinJob {
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],
/// 1 for an item model's quads (`item_render::WorldSink`): the colour by the
/// face's axis rides in the part's first 6 normal words ([`SkinPart::item`]),
/// shaded on the CPU, and the normal is `mesher::CULL_BACK`.
pub item: u32,
/// Always 0: the shader reads the last as a zero it cannot fold away.
pub spare: [u32; 2],
}
const _: () = assert!(std::mem::size_of::<Vertex>() == 44);
@@ -352,13 +372,19 @@ pub fn expand(model: &[SkinVertex], frame: &SkinFrame) -> Vec<Vertex> {
(v.uv[0] + job.scroll) % 1.0
};
let r = job.rect;
let (color, normal) = if job.item != 0 {
let color = part.normal[v.axis as usize].to_bits();
(color, crate::mesher::CULL_BACK)
} else {
(job.color, [packed[0], packed[1], packed[2], -127])
};
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,
color,
light: job.light,
normal: [packed[0], packed[1], packed[2], -127],
normal,
glint_uv: if job.glint != 0 { v.uv } else { [0.0, 0.0] },
};
}
@@ -406,8 +432,8 @@ mod tests {
}
/// 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.
/// texture and the armour glint, and an item's: 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 {
@@ -440,6 +466,18 @@ mod tests {
part(child, normals),
part(base * turn, glam::Mat3::from_mat4(turn * turn)),
part(child * turn, glam::Mat3::IDENTITY),
// Colours whose bits as floats are NaNs and denormals: copied, never computed.
SkinPart::item(
child * turn,
[
0xff80_4020,
0x7fc0_0001,
0x0000_0001,
0xffff_ffff,
0x7f80_0001,
0x1234_5678,
],
),
],
jobs: vec![
SkinJob {
@@ -467,8 +505,20 @@ mod tests {
glint: 1,
..Default::default()
},
// An item's quads: colour by axis from its part, `CULL_BACK` normals.
SkinJob {
first: 0,
count: 24,
part: 4,
dst: 72,
rect: [0.5, 0.25, 0.5625, 0.3125],
light: 0xa0_30,
overlay: 2 << 16,
item: 1,
..Default::default()
},
],
vertices: 72,
vertices: 96,
};
let expected = expand(&model, &frame);
// The posed vertices land after 5 others, which they leave alone.
@@ -15,7 +15,9 @@ const MODEL_WORDS: u32 = 7u;
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.
// whether the overlay is the armour glint (its model UV in `glint_uv`), whether
// it draws an item's quads (`SkinJob::item`), 2 spare (0: the last one is
// `keep`'s).
const JOB_WORDS: u32 = 16u;
// `mesher::Vertex` (11 words): pos (3), uv (2), layer, color, light, normal
// (4 x i8), glint_uv (2).
@@ -30,6 +32,20 @@ fn f(words: u32) -> f32 {
return bitcast<f32>(words);
}
// A zero the compiler cannot see (the job's last word). Metal compiles shaders
// with fast math: it would fuse `a * b + c` into one rounding and reorder sums,
// a bit off the CPU's separate operations. A value xored with `keep` is opaque
// to it, so each operation below rounds on its own, in the CPU's order.
var<private> keep: u32;
fn r(v: f32) -> f32 {
return bitcast<f32>(bitcast<u32>(v) ^ keep);
}
fn r3(v: vec3<f32>) -> vec3<f32> {
return bitcast<vec3<f32>>(bitcast<vec3<u32>>(v) ^ vec3(keep));
}
fn part_f(part: u32, i: u32) -> f32 {
return f(parts[part * PART_WORDS + i]);
}
@@ -40,8 +56,8 @@ fn scroll_u(u: f32, scroll: f32) -> f32 {
return u;
}
// Rust's `%` on floats: the remainder with the dividend's sign.
let s = u + scroll;
return s - trunc(s);
let s = r(u + scroll);
return r(s - r(trunc(s)));
}
// `Polygon::vertex_normal`'s packing: clamped, times 127, cut toward zero.
@@ -72,33 +88,42 @@ fn skin(
let light = jobs[j + 10u];
let overlay = jobs[j + 11u];
let glint = jobs[j + 12u] != 0u;
let item = jobs[j + 13u] != 0u;
keep = jobs[j + 15u];
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;
// glam's Mat4::transform_point3, in its order, each operation rounded.
var pos = r3(vec3(part_f(part, 0u), part_f(part, 1u), part_f(part, 2u)) * p.x);
pos = r3(r3(vec3(part_f(part, 4u), part_f(part, 5u), part_f(part, 6u)) * p.y) + pos);
pos = r3(r3(vec3(part_f(part, 8u), part_f(part, 9u), part_f(part, 10u)) * p.z) + pos);
pos = r3(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;
}
// An item's colour by axis, shaded on the CPU, and `mesher::CULL_BACK`.
var vertex_color = color;
var normal = pack(n);
if item {
vertex_color = parts[part * PART_WORDS + 16u + axis];
normal = 1u << 24u;
}
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 + 3u] = bitcast<u32>(rect.x + r(r(rect.z - rect.x) * scroll_u(uv.x, scroll)));
out[o + 4u] = bitcast<u32>(rect.y + r(r(rect.w - rect.y) * uv.y));
out[o + 5u] = overlay;
out[o + 6u] = color;
out[o + 6u] = vertex_color;
out[o + 7u] = light;
out[o + 8u] = pack(n);
out[o + 8u] = normal;
if glint {
out[o + 9u] = bitcast<u32>(uv.x);
out[o + 10u] = bitcast<u32>(uv.y);
+75 -12
View File
@@ -48,6 +48,10 @@ struct Plan {
}
const QUADS: u32 = u32::MAX;
const GROUP_LIMIT: u32 = 65535;
/// The page keys of the translucent arena (`Renderer::translucent_arena`, Translucent
/// Batching): its page plus this. The section arena's pages are their own keys, all far
/// below it. A call names its key: its vertices come from that arena's page.
pub const TRANSLUCENT_KEYS: u32 = 1 << 10;
struct PageTable {
/// Section id of every block of the page.
@@ -67,6 +71,7 @@ pub(super) const ALPHA_ONLY: wgpu::BlendState = wgpu::BlendState {
/// A run of the frame's index list drawn from one arena page.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Call {
/// The page's key (`TRANSLUCENT_KEYS`).
pub page: u32,
pub first: u32,
pub count: u32,
@@ -98,6 +103,9 @@ pub struct Flat {
/// The layout of a page's bind group 1 (shader packs' terrain pipelines share it).
pub(super) draw_layout: wgpu::BindGroupLayout,
blocks_per_page: u64,
/// Blocks of a translucent arena page (`TRANSLUCENT_KEYS`), whose pages are larger.
translucent_blocks_per_page: u64,
/// Block tables by page key.
pages: Vec<Option<PageTable>>,
free_ids: Vec<u32>,
next_id: u32,
@@ -218,7 +226,14 @@ fn buffer(
device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size: size.max(16),
usage: usage | wgpu::BufferUsages::COPY_DST,
// Tests read the tables back.
usage: usage
| wgpu::BufferUsages::COPY_DST
| if cfg!(test) {
wgpu::BufferUsages::COPY_SRC
} else {
wgpu::BufferUsages::empty()
},
mapped_at_creation: false,
})
}
@@ -242,6 +257,7 @@ fn plan(
shadow: [usize; 2],
passes: [usize; 2],
align: u64,
key: impl Fn(u32, Option<u32>) -> u32,
calls: &mut Vec<Call>,
ranges: &mut Vec<Range>,
segments: &mut Vec<(Source, u64, u64)>,
@@ -273,6 +289,7 @@ fn plan(
}
let starts =
i == phase1_draws || i == solid_draws || shadow.contains(&i) || passes.contains(&i);
let page = key(page, sorted);
match calls.last_mut() {
Some(last) if last.page == page && !starts => last.count += d[0],
_ => calls.push(Call {
@@ -451,6 +468,7 @@ impl Flat {
phase2_group: None,
draw_layout,
blocks_per_page: page_vertices.div_ceil(GRAIN),
translucent_blocks_per_page: page_vertices.div_ceil(GRAIN),
pages: Vec::new(),
free_ids: Vec::new(),
next_id: 0,
@@ -479,8 +497,8 @@ impl Flat {
.max(1),
}
}
/// A section uploaded to `vertices` vertices from `start` of `page`: its
/// id, which its blocks now name (written through `uploads` in `encoder`).
/// A section uploaded to `vertices` vertices from `start` of the page keyed `page`:
/// its id, which its blocks now name (written through `uploads` in `encoder`).
pub fn add(
&mut self,
device: &wgpu::Device,
@@ -494,6 +512,28 @@ impl Flat {
self.next_id += 1;
self.next_id - 1
});
self.place(device, encoder, uploads, id, page, start, vertices);
id
}
/// Makes the blocks of `vertices` vertices from `start` of the page keyed `page` name
/// `id`: a section's other range (its translucent quads, in the translucent arena), or
/// a range that moved.
#[allow(clippy::too_many_arguments)]
pub fn place(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
uploads: &mut super::upload::Uploads,
id: u32,
page: u32,
start: u64,
vertices: u64,
) {
let blocks = if page >= TRANSLUCENT_KEYS {
self.translucent_blocks_per_page
} else {
self.blocks_per_page
};
let page = page as usize;
if self.pages.len() <= page {
self.pages.resize_with(page + 1, || None);
@@ -502,7 +542,7 @@ impl Flat {
blocks: buffer(
device,
"Section ids by block",
self.blocks_per_page * 4,
blocks * 4,
wgpu::BufferUsages::STORAGE,
),
group: None,
@@ -515,7 +555,19 @@ impl Flat {
first * 4,
bytemuck::cast_slice(&ids),
);
id
}
/// The block table of the page keyed `page`, for tests that read it back.
#[cfg(test)]
pub(super) fn blocks(&self, page: u32) -> &wgpu::Buffer {
&self.pages[page as usize]
.as_ref()
.expect("a page table")
.blocks
}
/// The most vertices a page of the translucent arena holds: its block tables' size.
/// Tables made before keep theirs, so this goes with `clear`.
pub fn set_translucent_page_vertices(&mut self, vertices: u64) {
self.translucent_blocks_per_page = vertices.div_ceil(GRAIN);
}
pub fn remove(&mut self, id: u32) {
self.free_ids.push(id);
@@ -525,7 +577,7 @@ impl Flat {
self.free_ids.clear();
self.next_id = 0;
}
/// Forgets the tables of pages the arena released.
/// Forgets the tables of pages the arenas released (`live`, by page key).
pub fn trim(&mut self, live: impl Fn(u32) -> bool) {
for (i, page) in self.pages.iter_mut().enumerate() {
if page.is_some() && !live(i as u32) {
@@ -556,6 +608,7 @@ impl Flat {
shadow: [usize; 2],
passes: [usize; 2],
phase2: Option<&wgpu::Buffer>,
translucent_arena: bool,
origins: impl Iterator<Item = (u32, [f32; 4])>,
sorted_page: impl Fn(u32) -> wgpu::Buffer,
timing: Option<&mut super::pack_timing::PassTiming>,
@@ -609,6 +662,11 @@ impl Flat {
shadow,
passes,
self.range_align,
// Translucent draws, and only they, have sorted indices.
|page, sorted| match sorted {
Some(_) if translucent_arena => page + TRANSLUCENT_KEYS,
_ => page,
},
&mut self.calls,
&mut self.range_data,
&mut self.segments,
@@ -801,7 +859,7 @@ impl Flat {
&self,
pass: &mut wgpu::RenderPass<'_>,
calls: &[Call],
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
) {
self.draw_with(pass, calls, vertices, |_, _| {});
}
@@ -811,7 +869,7 @@ impl Flat {
&self,
pass: &mut wgpu::RenderPass<'_>,
calls: &[Call],
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
page: impl Fn(&mut wgpu::RenderPass<'_>, u32),
) {
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
@@ -828,7 +886,7 @@ impl Flat {
&self,
pass: &mut wgpu::RenderPass<'_>,
calls: &[Call],
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
page: impl Fn(&mut wgpu::RenderPass<'_>, u32),
instances: u32,
) {
@@ -842,7 +900,7 @@ impl Flat {
pub fn draw_phase2<'a>(
&self,
pass: &mut wgpu::RenderPass<'_>,
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
) {
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
for (k, call) in self.calls[self.phase1_calls..self.solid_calls]
@@ -857,13 +915,13 @@ impl Flat {
&self,
pass: &mut wgpu::RenderPass<'_>,
page: u32,
vertices: &impl Fn(u32) -> &'a wgpu::Buffer,
vertices: &impl Fn(u32) -> wgpu::BufferSlice<'a>,
) {
let table = self.pages[page as usize]
.as_ref()
.and_then(|t| t.group.as_ref())
.expect("prepared page");
pass.set_vertex_buffer(0, vertices(page).slice(..));
pass.set_vertex_buffer(0, vertices(page));
pass.set_bind_group(1, table, &[]);
}
}
@@ -912,6 +970,7 @@ mod tests {
[draws.len(); 2],
[draws.len(), 0],
4,
|page, _| page,
&mut calls,
&mut ranges,
&mut segments,
@@ -960,6 +1019,7 @@ mod tests {
[draws.len(); 2],
[3, 1],
4,
|page, _| page,
&mut calls,
&mut ranges,
&mut segments,
@@ -995,6 +1055,7 @@ mod tests {
[camera, camera + 2],
[camera, 0],
4,
|page, _| page,
&mut calls,
&mut ranges,
&mut segments,
@@ -1023,6 +1084,7 @@ mod tests {
[draws.len(); 2],
[draws.len(), 0],
4,
|page, _| page,
&mut calls,
&mut ranges,
&mut segments,
@@ -1140,6 +1202,7 @@ mod tests {
[draws.len(); 2],
[draws.len(), 0],
align,
|page, _| page,
&mut calls,
&mut ranges,
&mut segments,
+24 -4
View File
@@ -7,13 +7,19 @@ use crate::{
mesher::SectionMesh,
};
use glam::{DVec3, Vec3};
use std::time::{Duration, Instant};
use std::{
collections::VecDeque,
time::{Duration, Instant},
};
/// Finished tile meshes not uploaded yet, the tiles they replace (removed
/// once every mesh is in) and the columns they leave out.
#[derive(Default)]
pub(super) struct Pending {
meshes: FxHashMap<TileKey, SectionMesh>,
/// The order they go up in: as handed over (`Lod::update`: finer tiles
/// first, so no tile clipped around a finer one is drawn before it).
order: VecDeque<TileKey>,
removed: FxHashSet<TileKey>,
cutout: Option<FxHashSet<(i32, i32)>>,
}
@@ -30,7 +36,9 @@ impl Pending {
) {
for (key, mesh) in meshes {
self.removed.remove(&key);
self.meshes.insert(key, mesh);
if self.meshes.insert(key, mesh).is_none() {
self.order.push_back(key);
}
}
for key in removed {
self.meshes.remove(&key);
@@ -330,7 +338,10 @@ impl Renderer {
.copied()
.collect();
while let Some(pending) = &mut self.lod_pending {
let Some(&key) = pending.meshes.keys().next() else {
// Keys meshed again keep their place; removed ones are skipped.
let next = std::iter::from_fn(|| pending.order.pop_front())
.find(|key| pending.meshes.contains_key(key));
let Some(key) = next else {
let pending = self.lod_pending.take().expect("pending tiles");
// A tile fades in over the ones it replaces. One over nothing
// shows whole: its hidden chunks would open holes to the sky
@@ -498,7 +509,16 @@ mod tests {
let mut removed: Vec<_> = pending.removed.iter().map(|k| k.x).collect();
removed.sort_unstable();
assert_eq!((meshes, removed), (vec![1, 3], vec![2, 4]));
assert!(pending.cutout.is_some_and(|c| c.contains(&(0, 0))));
assert!(pending.cutout.as_ref().is_some_and(|c| c.contains(&(0, 0))));
// They go up in the order handed over, each once.
pending.add(vec![(key(1), SectionMesh::default())], vec![], None);
let order: Vec<_> = pending
.order
.iter()
.filter(|k| pending.meshes.contains_key(k))
.map(|k| k.x)
.collect();
assert_eq!(order, [1, 3]);
}
#[test]
+3 -1
View File
@@ -544,7 +544,7 @@ pub struct Graphics {
/// How finely far terrain is drawn; Full unless the player picks Reduced.
pub far_detail: FarDetail,
/// Far terrain from servers' BlueMap web maps; no server has one unless
/// the player names it (`blueMap:` lines).
/// the player names it (`blueMap:` lines) or turns on auto-detect.
pub blue_map: crate::bluemap::Settings,
/// Skip sections hidden behind closed ones (vanilla's occlusion graph).
pub cave_culling: bool,
@@ -737,6 +737,7 @@ impl Graphics {
g.blue_map.radius = radius;
}
}
"blueMapAutoDetect" => g.blue_map.auto_detect = flag(g.blue_map.auto_detect),
"caveCulling" => g.cave_culling = flag(g.cave_culling),
"entityCulling" => g.entity_culling = EntityCullingMode::parse(value),
"leafCulling" => g.leaf_culling = LeafCullingMode::parse(value),
@@ -1010,6 +1011,7 @@ mod tests {
far_detail: FarDetail::Reduced,
blue_map: crate::bluemap::Settings {
radius: 40,
auto_detect: true,
// In their own file (`bluemap::SERVERS_FILE`).
servers: Vec::new(),
},
@@ -28,6 +28,8 @@ pub enum H {
Preset,
LodDistance,
FarDetail,
BlueMapAutoDetect,
BlueMapRadius,
CaveCulling,
EntityCulling,
LeafCulling,
@@ -190,6 +192,16 @@ pub fn rows(level: Level) -> Vec<Row> {
render,
lod,
far,
h(
H::BlueMapAutoDetect,
None,
"On servers, finds their BlueMap web map for far terrain. Next join.",
),
h(
H::BlueMapRadius,
None,
"How far around you BlueMap tiles are downloaded. Next join.",
),
v(
Opt::SimulationDistance,
Medium,
@@ -430,6 +442,8 @@ pub fn rows(level: Level) -> Vec<Row> {
rows
}
/// BlueMap download radii, in chunks.
const BLUE_MAP_RADII: [u32; 5] = [16, 32, 64, 128, 256];
const UPLOAD_STEPS: [u32; 8] = [0, 256, 512, 1024, 2048, 4096, 8192, 16384];
/// What the LOD Distance slider stops at, in chunks: off, then each doubling
/// up to the whole world.
@@ -596,6 +610,11 @@ impl H {
n => labelled("LOD Distance", lang::tr("%s chunks", &[&n])),
},
H::FarDetail => labelled("Far Detail", g.far_detail.label()),
H::BlueMapAutoDetect => labelled("Find BlueMap", on_off(g.blue_map.auto_detect)),
H::BlueMapRadius => labelled(
"BlueMap Radius",
lang::tr("%s chunks", &[&g.blue_map.radius_chunks()]),
),
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
H::EntityCulling => labelled("Entity Culling", g.entity_culling.label()),
H::LeafCulling => labelled("Leaf Culling", g.leaf_culling.label()),
@@ -743,6 +762,10 @@ impl H {
H::FarDetail => {
g.far_detail = step(&[FarDetail::Full, FarDetail::Reduced], g.far_detail, back)
}
H::BlueMapAutoDetect => g.blue_map.auto_detect = !g.blue_map.auto_detect,
H::BlueMapRadius => {
g.blue_map.radius = step(&BLUE_MAP_RADII, g.blue_map.radius_chunks(), back)
}
H::CaveCulling => g.cave_culling = !g.cave_culling,
H::EntityCulling => {
g.entity_culling = step(&EntityCullingMode::ALL, g.entity_culling, back)
@@ -855,6 +878,7 @@ impl Item {
pub fn switch(self, s: &Settings) -> Option<bool> {
let g = &s.horizon;
Some(match self {
Item::Horizon(H::BlueMapAutoDetect) => g.blue_map.auto_detect,
Item::Horizon(H::CaveCulling) => g.cave_culling,
Item::Horizon(H::CacheShulkerBoxes) => g.cache_shulker_boxes,
Item::Horizon(H::CacheSigns) => g.cache_signs,
@@ -926,6 +950,8 @@ impl Item {
self,
Item::Horizon(
H::Preset
| H::BlueMapAutoDetect
| H::BlueMapRadius
| H::Overlay
| H::ScreenshotQuality
| H::Pacing
@@ -1896,6 +1922,31 @@ mod tests {
);
}
#[test]
fn blue_map_settings_are_advanced_rendering_options() {
let mut s = Settings::default();
let find = Item::Horizon(H::BlueMapAutoDetect);
let radius = Item::Horizon(H::BlueMapRadius);
let advanced: Vec<_> = rows(Level::Advanced)
.into_iter()
.filter_map(|row| match row {
Row::Setting(setting) => Some(setting.item),
Row::Header(_) => None,
})
.collect();
assert!(advanced.contains(&find) && advanced.contains(&radius));
assert_eq!(find.switch(&s), Some(false), "off by default");
assert_eq!(radius.label(&s).plain(), "BlueMap Radius: 64 chunks");
find.cycle(&mut s, false, [854, 480]);
assert!(s.horizon.blue_map.auto_detect);
radius.cycle(&mut s, false, [854, 480]);
assert_eq!(s.horizon.blue_map.radius, 128);
radius.cycle(&mut s, true, [854, 480]);
radius.cycle(&mut s, true, [854, 480]);
assert_eq!(radius.label(&s).plain(), "BlueMap Radius: 32 chunks");
assert_eq!(s.horizon.preset, Preset::Custom);
}
#[test]
fn presets_set_vanilla_and_horizon_options() {
let mut s = Settings::default();
+114 -7
View File
@@ -132,7 +132,7 @@ pub const PACK_ICON_SIZE: u32 = 128;
/// size of the largest trim texture.
pub const TRIM_SLOTS: usize = 32;
pub fn decode(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
decode_bytes(&fs::read(path)?)
decode_bytes(&horizon_core::prepared_files::read(path)?)
}
/// A PNG in memory as RGBA pixels.
pub fn decode_bytes(data: &[u8]) -> Result<(u32, u32, Vec<u8>)> {
@@ -184,9 +184,10 @@ fn within_budget(files: &[&PathBuf], budget: u64) -> HashSet<PathBuf> {
}
/// A PNG's width and height, from its header alone.
fn png_size(path: &Path) -> Option<(u32, u32)> {
use std::io::Read;
let mut header = [0u8; 24];
fs::File::open(path).ok()?.read_exact(&mut header).ok()?;
let header: [u8; 24] = horizon_core::prepared_files::read_head(path, 24)
.ok()?
.try_into()
.ok()?;
let field = |at: usize| u32::from_be_bytes(header[at..at + 4].try_into().unwrap_or_default());
(header[..8] == *b"\x89PNG\r\n\x1a\n" && header[12..16] == *b"IHDR")
.then(|| (field(16), field(20)))
@@ -365,7 +366,8 @@ fn cit_sprite(root: &Path, id: &str) -> Option<(PackedImage, Option<TextureFlags
return None;
}
};
let mcmeta = fs::read_to_string(path.with_extension("png.mcmeta")).ok();
let mcmeta =
horizon_core::prepared_files::read_to_string(&path.with_extension("png.mcmeta")).ok();
let animation = animation_of(mcmeta.as_deref());
// Only an animation is cut to its first frame: a still CIT texture is drawn whole, and
// armour layers are twice as wide as they are tall.
@@ -463,7 +465,10 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> (Vec<PackedImage>, Vec<
// Animated strips keep their first frame and animate,
// like the packed files.
if path.starts_with("block/") || path.starts_with("item/") {
let mcmeta = fs::read_to_string(file.with_extension("png.mcmeta")).ok();
let mcmeta = horizon_core::prepared_files::read_to_string(
&file.with_extension("png.mcmeta"),
)
.ok();
let animation = animation_of(mcmeta.as_deref());
animated = animation.is_some();
first_frame(image, animation.as_ref())
@@ -528,7 +533,62 @@ fn galactic_widths(
}
widths
}
/// `TextureAtlas.dumpContents` (`F3 + S`, vanilla's "dynamic textures"): the atlas
/// written as `name.png` beside `name.txt`, every sprite in name order with its
/// rect in texels (`dumpSpriteNames`). Vanilla names each file after its atlas
/// with `/` and `:` as `_` (`Identifier.toDebugFileName`); Horizon packs the GUI,
/// font, particle, map and skin sprites into one atlas, so one file stands in for
/// the atlases vanilla dumps apart.
pub fn dump_atlas(
dir: &Path,
name: &str,
width: u32,
height: u32,
pixels: &[u8],
sprites: &HashMap<String, Rect>,
) -> std::io::Result<PathBuf> {
// A build whose pixels were freed (`fonts`) has the size and no texels:
// write nothing rather than a file every reader would reject.
if pixels.len() != (width as usize) * (height as usize) * 4 {
return Err(std::io::Error::other(format!(
"wrong data size, expected {} got {}",
(width as usize) * (height as usize) * 4,
pixels.len()
)));
}
fs::create_dir_all(dir)?;
let png_path = dir.join(format!("{name}.png"));
let mut encoder = png::Encoder::new(fs::File::create(&png_path)?, width, height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().map_err(std::io::Error::other)?;
writer
.write_image_data(pixels)
.map_err(std::io::Error::other)?;
drop(writer);
let mut names = sprites.iter().collect::<Vec<_>>();
names.sort_by(|a, b| a.0.cmp(b.0));
let mut list = String::new();
for (sprite, rect) in names {
let (w, h) = rect.texels((width, height));
let x = (rect.u0 * width as f32).round() as u32;
let y = (rect.v0 * height as f32).round() as u32;
list.push_str(&format!("{sprite}\tx={x}\ty={y}\tw={w}\th={h}\n"));
}
fs::write(dir.join(format!("{name}.txt")), list)?;
Ok(png_path)
}
impl HudAtlas {
/// `TextureAtlas.dumpContents` for this atlas: `dir` is `screenshots/debug`,
/// the folder `TextureUtil.getDebugTexturePath` names.
pub fn dump_contents(&self, dir: &Path, name: &str) -> std::io::Result<PathBuf> {
// `pixels()`: the copy `pack_pixels` compressed once the GPU had the
// atlas, decompressed; vanilla reads the texture back from the GPU.
let pixels = self.pixels();
dump_atlas(dir, name, self.width, self.height, &pixels, &self.sprites)
}
fn next_revision() -> u64 {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
@@ -960,7 +1020,9 @@ impl HudAtlas {
let image = decode(path)
.map_err(|e| log::warn!("skipped GUI texture {}: {e}", path.display()))
.ok()?;
let mcmeta = fs::read_to_string(path.with_extension("png.mcmeta")).ok();
let mcmeta =
horizon_core::prepared_files::read_to_string(&path.with_extension("png.mcmeta"))
.ok();
let animation = animation_of(mcmeta.as_deref());
// An animated strip keeps its first frame.
let frames = name.starts_with("block/") || name.starts_with("item/");
@@ -2560,6 +2622,51 @@ impl HudRenderer {
mod tests {
use super::*;
#[test]
fn dumping_the_atlas_writes_its_pixels_and_sprite_rects() {
let root = std::env::temp_dir().join("horizon-atlas-dump-test");
let _ = fs::remove_dir_all(&root);
let pixels: Vec<u8> = (0..4u8).flat_map(|i| [i, 0, 0, 255]).collect();
let rect = |u0: f32, v0: f32| Rect {
u0,
v0,
u1: u0 + 0.5,
v1: v0 + 0.5,
w: 8,
h: 8,
};
let sprites = HashMap::from([
("particle/smoke".to_string(), rect(0.5, 0.0)),
("gui/sprites/bars/experience".to_string(), rect(0.0, 0.5)),
]);
let path = dump_atlas(
&root,
"minecraft_textures_atlas_gui",
2,
2,
&pixels,
&sprites,
)
.expect("the dump writes");
assert_eq!(path, root.join("minecraft_textures_atlas_gui.png"));
let written = fs::read(&path).expect("the PNG is there");
let (width, height, rgba) = decode_bytes(&written).expect("the PNG decodes");
assert_eq!((width, height), (2, 2));
assert_eq!(rgba, pixels);
// Sorted by sprite name, rects in texels, as `dumpSpriteNames` writes them.
let mut expected = String::from("gui/sprites/bars/experience\tx=0\ty=1\tw=1\th=1\n");
expected.push_str("particle/smoke\tx=1\ty=0\tw=1\th=1\n");
assert_eq!(
fs::read_to_string(root.join("minecraft_textures_atlas_gui.txt")).unwrap(),
expected
);
// A build with no texels in hand writes no file at all.
let empty = dump_atlas(&root, "empty", 2, 2, &[], &sprites);
assert!(empty.is_err());
assert!(!root.join("empty.png").exists());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn decode_bytes_ignores_crc_checksum_error() {
let mut encoded = Vec::new();
+1
View File
@@ -14,6 +14,7 @@ pub mod block_entity_anim;
pub mod block_entity_cem;
pub mod block_entity_render;
pub mod bluemap;
pub mod bluemap_detect;
pub mod border_render;
pub mod breaking;
pub mod brewing;
+730 -30
View File
@@ -25,6 +25,9 @@ const MAX_SAVED: usize = 1024;
/// How many resident chunks may wait for the worker's summary at once
/// (`Lod::observing`).
pub const OBSERVE_AHEAD: usize = 8;
/// How long a tile whose sample waited for data (`Lod::finish_waited`) waits
/// before it is sampled again without word of it.
const WAIT_RETRY: std::time::Duration = std::time::Duration::from_secs(10);
const SAMPLE_JOBS: usize = 4;
/// Sample jobs queued per sampling thread once the near terrain is in.
/// Results are taken and jobs started once a frame: with four in flight, a
@@ -389,7 +392,7 @@ impl Column {
}
}
/// A chunk's 256 columns (`Column::from_chunk`), on the far-terrain worker.
fn summarize(chunk: &Chunk, kinds: &[u8]) -> Arc<[Column; 256]> {
pub(crate) fn summarize(chunk: &Chunk, kinds: &[u8]) -> Arc<[Column; 256]> {
let water = water_source(kinds);
Arc::new(std::array::from_fn(|i| {
Column::from_chunk(chunk, kinds, water, (i & 15) as i32, (i >> 4) as i32)
@@ -414,7 +417,7 @@ const AIR: u8 = 4;
const WATERY: u8 = 8;
/// What `Column::from_chunk` needs of each block state, read from the facts
/// once: matching their names for every block made observing a chunk slow.
fn state_kinds(facts: &BlockFacts) -> Vec<u8> {
pub(crate) fn state_kinds(facts: &BlockFacts) -> Vec<u8> {
(0..facts.states.len())
.map(|i| {
let state = BlockStateId(i as u32);
@@ -479,6 +482,41 @@ fn forget_detail(
}
}
}
/// The tiles over `chunk` to sample again once their source keeps it
/// (`Lod::resample_over`): those whose sample is still being taken, maybe
/// from before, and those of cells wider than a chunk sampled empty there,
/// which a chunk's own detail can't fill (`apply_detail`). The others hold
/// it once `finish_detail` applies it.
fn resampled_over(
ready: &FxHashMap<TileKey, Arc<Vec<Column>>>,
pending: &FxHashSet<TileKey>,
(cx, cz): (i32, i32),
) -> Vec<TileKey> {
(1..=MAX_LEVEL)
.filter_map(|level| {
let size = 16 << level;
let key = TileKey {
x: (cx * 16).div_euclid(size),
z: (cz * 16).div_euclid(size),
level,
};
let resample = match ready.get(&key) {
None => pending.contains(&key),
Some(samples) => {
let stride = size / CELLS as i32;
let [ox, _, oz] = key.origin();
let cell = (cz * 16 - oz) / stride * CELLS as i32 + (cx * 16 - ox) / stride;
stride > 16
&& (pending.contains(&key)
|| samples
.get(cell as usize)
.is_some_and(|c| *c == Column::default()))
}
};
resample.then_some(key)
})
.collect()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct TileKey {
pub x: i32,
@@ -518,6 +556,18 @@ impl TileKey {
let size = self.size();
ox < x1 && oz < z1 && ox + size > x0 && oz + size > z0
}
/// Whether new data for `area` drawn in cells `widths` wide changes the
/// tile's samples: its cells are that wide, and it covers `area` or the
/// blocks around it a cell's depth reads (`bluemap::SKIRT`: a block
/// around a chunk, the next cells' middles past one).
fn refreshed_by(self, area: [i32; 4], widths: (i32, i32)) -> bool {
let cell = self.size() / CELLS as i32;
(widths.0..=widths.1).contains(&cell) && self.overlaps(Self::around(area, cell))
}
fn around([x0, z0, x1, z1]: [i32; 4], cell: i32) -> [i32; 4] {
let m = if cell <= 16 { 1 } else { cell };
[x0 - m, z0 - m, x1 + m, z1 + m]
}
fn distance(self, camera: [i32; 2]) -> i32 {
let [x, _, z] = self.origin();
(x - camera[0])
@@ -870,6 +920,8 @@ enum Done {
Sample {
key: TileKey,
samples: Vec<Column>,
/// Some of it is still on its way (`LodSampler::waited`).
waited: bool,
},
Mesh {
key: TileKey,
@@ -987,6 +1039,8 @@ pub struct Lod {
/// Sampled tiles whose source has more since (`refresh`): sampled again,
/// their old samples drawn meanwhile.
stale: FxHashSet<TileKey>,
/// Stale tiles `refresh`ed again while sampled: that sample may be older.
restale: FxHashSet<TileKey>,
active: FxHashSet<TileKey>,
/// Tiles needing a mesh, with the revision their job must carry.
dirty: FxHashMap<TileKey, u64>,
@@ -1038,6 +1092,9 @@ pub struct Lod {
saved: Saved,
reduced: Reduced,
saved_order: VecDeque<(i32, i32)>,
/// `LodSource::keeps_observed`: tiles sampled without an observed chunk
/// are sampled again (`resample_over`).
keeps_observed: bool,
}
impl Lod {
pub fn new(source: Arc<dyn LodSource>, context: Arc<WorkerContext>) -> Self {
@@ -1054,6 +1111,9 @@ impl Lod {
context: Option<Arc<WorkerContext>>,
workers: bool,
) -> Self {
let keeps_observed = source
.as_ref()
.is_some_and(|source| source.keeps_observed());
let cores = thread::available_parallelism().map_or(1, |n| n.get());
let shared = Arc::new(Shared {
jobs: Mutex::new(VecDeque::new()),
@@ -1216,7 +1276,15 @@ impl Lod {
if shared.stop.load(Ordering::Relaxed) {
return;
}
if tx.send(Done::Sample { key, samples }).is_err() {
let waited = sample.waited();
if tx
.send(Done::Sample {
key,
samples,
waited,
})
.is_err()
{
return;
}
}
@@ -1291,7 +1359,12 @@ impl Lod {
);
let samples =
sampler.grid(x, z, key.size() / CELLS as i32, CELLS);
Done::Sample { key, samples }
let waited = sampler.waited();
Done::Sample {
key,
samples,
waited,
}
}
};
if tx.send(done).is_err() {
@@ -1346,6 +1419,7 @@ impl Lod {
unmeshed: FxHashSet::default(),
removed_pending: FxHashSet::default(),
stale: FxHashSet::default(),
restale: FxHashSet::default(),
removed: Vec::new(),
covered: FxHashSet::default(),
covered_bounds: None,
@@ -1360,6 +1434,7 @@ impl Lod {
saved: FxHashMap::default(),
reduced: FxHashMap::default(),
saved_order: VecDeque::new(),
keeps_observed,
}
}
/// The solid ground at block column (x, z): its top face's height and
@@ -1670,9 +1745,12 @@ impl Lod {
}
}
/// The source has new data for the blocks `[x0, z0, x1, z1)` (a map tile
/// downloaded since): the tiles over them are sampled again, and their
/// chunks' detail asked again, but for the chunks seen in the world.
pub fn refresh(&mut self, area: [i32; 4]) {
/// downloaded since), drawn in cells `widths` `(min, max)` blocks wide:
/// the tiles of those cells over them or beside (`refreshed_by`) are
/// sampled again, and their chunks' detail asked again, but for the
/// chunks seen in the world. A tile whose new samples are the old ones
/// isn't meshed again.
pub fn refresh(&mut self, area: [i32; 4], widths: (i32, i32)) {
self.refreshes += 1;
if self.refreshed.len() >= MAX_REFRESHED {
self.refreshed.pop_front();
@@ -1690,13 +1768,27 @@ impl Lod {
z1.saturating_add(m),
],
));
for &key in self.ready.keys() {
if !key.overlaps(area) || (key.level == 0 && self.covered.contains(&(key.x, key.z))) {
continue;
}
self.stale.insert(key);
// Tiles waiting out a retry (`finish_waited`) are asked again now.
let retrying = self.retry.len();
self.retry.retain(|key, _| !key.refreshed_by(area, widths));
if self.retry.len() != retrying {
self.wanted_stale = true;
}
// A first sample in flight read the old data too.
let keys: Vec<_> = self
.ready
.keys()
.chain(self.pending.iter())
.copied()
.filter(|key| {
key.refreshed_by(area, widths)
&& !(key.level == 0 && self.covered.contains(&(key.x, key.z)))
})
.collect();
for key in keys {
self.mark_stale(key);
if let Some(mask) = self.detail_masks.get_mut(&key) {
let area = TileKey::around(area, key.size() / CELLS as i32);
forget_detail(key, area, mask, |chunk| self.saved.contains_key(&chunk));
}
}
@@ -1717,6 +1809,15 @@ impl Lod {
&& (cz + 1) * 16 > z0
})
}
/// The tile's samples lack data its source has now: sampled again, the
/// old ones drawn meanwhile, and once more after a sample in flight.
fn mark_stale(&mut self, key: TileKey) {
self.stale.insert(key);
self.wanted_stale = true;
if self.pending.contains(&key) {
self.restale.insert(key);
}
}
/// Capture actual block columns, including canopy gaps and material
/// layers: the worker summarizes the chunk (`summarize`, a few
/// milliseconds a chunk), then `finish_observed` keeps it.
@@ -1769,22 +1870,42 @@ impl Lod {
self.saved_order.push_back(key);
}
self.saved.insert(key, columns.columns.clone());
if self.keeps_observed {
self.resample_over(key);
}
self.finish_detail(key, columns);
}
/// The source keeps the chunk now: the tiles over it whose samples lack
/// it are sampled again (`stale`), their old samples drawn meanwhile.
fn resample_over(&mut self, chunk: (i32, i32)) {
for key in resampled_over(&self.ready, &self.pending, chunk) {
self.mark_stale(key);
}
}
/// A chunk's own columns as its level-0 tile; the same columns again
/// (asked again after a `refresh`) mesh nothing.
fn apply_fine(&mut self, (cx, cz): (i32, i32), columns: &[Column; 256]) {
let fine = TileKey {
x: cx,
z: cz,
level: 0,
};
if self.plan.nodes.contains(&fine) || self.ready.contains_key(&fine) {
let existed = self
.ready
.insert(fine, Arc::new(columns.to_vec()))
.is_some();
self.detail_masks.insert(fine, [u64::MAX; 4]);
self.refresh_ready(fine, existed);
if !self.plan.nodes.contains(&fine) && !self.ready.contains_key(&fine) {
return;
}
self.detail_masks.insert(fine, [u64::MAX; 4]);
if self
.ready
.get(&fine)
.is_some_and(|old| old[..] == columns[..])
{
return;
}
let existed = self
.ready
.insert(fine, Arc::new(columns.to_vec()))
.is_some();
self.refresh_ready(fine, existed);
}
fn apply_detail(&mut self, (cx, cz): (i32, i32), columns: &ReducedColumns) {
self.apply_fine((cx, cz), &columns.columns);
@@ -1811,27 +1932,59 @@ impl Lod {
if x0 == x1 || z0 == z1 {
continue;
}
let samples = Arc::make_mut(samples);
let mask = self.detail_masks.entry(key).or_default();
let mut changed = false;
for z in z0..z1 {
for x in x0..x1 {
let index = (z * 16 + x) as usize;
samples[index] = columns
.get(
(ox + x * stride).rem_euclid(16) as usize,
(oz + z * stride).rem_euclid(16) as usize,
stride as usize,
)
.clone();
let column = columns.get(
(ox + x * stride).rem_euclid(16) as usize,
(oz + z * stride).rem_euclid(16) as usize,
stride as usize,
);
// Copied (from a mesh job's snapshot) only to change it.
if samples[index] != *column {
Arc::make_mut(samples)[index] = column.clone();
changed = true;
}
mask[index / 64] |= 1 << (index % 64);
}
}
modified.push(key);
if changed {
modified.push(key);
}
}
for key in modified {
self.mark_data_changed(key);
}
}
/// A sample with a hole, its data still on its way and nothing coarser
/// to stand in: drawn, the hole stood where the coarser tile or the older
/// sample it replaces had terrain, until the data came (`refresh`) and
/// the tile was sampled again. So it is thrown away, and the tile sampled
/// again then (at once if it came while this one was taken), or after
/// `WAIT_RETRY`: a shorter wait let the retries of near tiles take every
/// slot while the rest waited. One whose waiting cells BlueMap's coarser
/// levels stood in for is drawn (`finish_any_sample`).
fn finish_waited(&mut self, key: TileKey) {
self.pending.remove(&key);
if self.restale.remove(&key) {
self.wanted_stale = true;
} else if self.plan.nodes.contains(&key) {
self.retry
.insert(key, std::time::Instant::now() + WAIT_RETRY);
}
}
/// A sample in: thrown away when a cell of it is a hole, its data on its
/// way (`finish_waited`); one whose waiting cells a coarser level stood
/// in for is drawn (`LodSampler::waited`).
fn finish_any_sample(&mut self, key: TileKey, samples: Vec<Column>, waited: bool) {
if waited {
self.finish_waited(key);
} else {
self.finish_sample(key, samples);
}
}
fn finish_sample(&mut self, key: TileKey, samples: Vec<Column>) {
self.pending.remove(&key);
if samples.len() != CELLS * CELLS {
@@ -1840,6 +1993,7 @@ impl Lod {
self.retry.retain(|_, at| *at > now);
}
self.retry.insert(key, now + RETRY);
self.restale.remove(&key);
return;
}
self.retry.remove(&key);
@@ -1852,8 +2006,16 @@ impl Lod {
}
}
}
// Refreshed again meanwhile: sampled once more after this.
if self.restale.remove(&key) {
self.wanted_stale = true;
} else {
self.stale.remove(&key);
}
if self.ready.get(&key).is_some_and(|old| **old == samples) {
return;
}
let existed = self.ready.insert(key, Arc::new(samples)).is_some();
self.stale.remove(&key);
self.refresh_ready(key, existed);
}
}
@@ -1887,6 +2049,10 @@ impl Lod {
keep = keep.saturating_sub(1);
kept
});
// Sampled again from the queue: they read what `refresh` announced.
for key in &dropped {
self.restale.remove(key);
}
// Back in line where `wanted_tiles` put them: the near terrain can
// start and stop loading every few frames, and rebuilding the whole
// queue each time cost more than the samples it held back.
@@ -2025,6 +2191,10 @@ impl Lod {
let mut meshes = Vec::new();
crate::spans::time("lod_results", || self.receive(&mut meshes));
crate::spans::time("lod_retiring", || self.prune_retiring());
// Uploaded over frames in this order (`Renderer::upload_lods`): a
// tile is meshed clipped around its finer ones, so they go first,
// and the nearest first, where far terrain meets the near chunks.
meshes.sort_by_key(|(key, _)| (key.level, key.distance(camera)));
crate::spans::time("lod_covered", || self.cover(covered));
if std::mem::take(&mut self.reselect) {
self.rebuild_selection();
@@ -2054,6 +2224,7 @@ impl Lod {
self.plan.nodes.contains(key)
});
self.stale.retain(|key| self.plan.nodes.contains(key));
self.restale.retain(|key| self.plan.nodes.contains(key));
self.detail_masks
.retain(|key, _| self.plan.nodes.contains(key));
// Complete resident columns need no duplicate noise work.
@@ -2134,7 +2305,11 @@ impl Lod {
crate::spans::time("lod_detail", || self.finish_observed(key, columns))
}
Done::DetailRequested => self.detail_request_pending = false,
Done::Sample { key, samples } => self.finish_sample(key, samples),
Done::Sample {
key,
samples,
waited,
} => self.finish_any_sample(key, samples, waited),
Done::Mesh {
key,
version,
@@ -3106,6 +3281,22 @@ mod tests {
assert_eq!(reduced.get(4, 8, 4).spans, [span(0, 4, 1)]);
assert_eq!(reduced.get(0, 0, 16).spans, [span(0, 4, 1)]);
}
/// Detail read before a refresh over its chunk is dropped (asked
/// again); elsewhere, or read after, it is kept.
#[test]
fn only_detail_a_later_refresh_covers_is_dropped() {
let mut lod = Lod::scheduler_for_test();
let asked = lod.refreshes;
lod.refresh([0, 0, 64, 64], (1, 16));
assert!(lod.refreshed_since((1, 1), asked));
assert!(lod.refreshed_since((4, 0), asked), "a chunk around");
assert!(!lod.refreshed_since((40, 40), asked), "elsewhere");
assert!(!lod.refreshed_since((1, 1), lod.refreshes), "read after");
for _ in 0..MAX_REFRESHED {
lod.refresh([10_000, 10_000, 10_064, 10_064], (1, 16));
}
assert!(lod.refreshed_since((40, 40), asked), "too old to tell");
}
#[test]
fn refreshed_areas_forget_detail_but_seen_chunks() {
let key = TileKey {
@@ -3114,6 +3305,17 @@ mod tests {
level: 1,
};
assert!(key.overlaps([40, 0, 50, 1]) && !key.overlaps([64, 0, 80, 32]));
// Only cells of the widths the new data is drawn in, a cell around.
assert!(key.refreshed_by([64, 0, 80, 32], (1, 16)));
assert!(!key.refreshed_by([65, 0, 80, 32], (1, 16)));
assert!(!key.refreshed_by([40, 0, 50, 1], (25, i32::MAX)));
let root = TileKey {
x: 0,
z: 0,
level: MAX_LEVEL,
};
assert!(!root.refreshed_by([40, 0, 72, 32], (1, 16)));
assert!(root.refreshed_by([0, 0, 800, 800], (25, i32::MAX)));
// Level 1: 2 × 2 cells a chunk, chunks 2..4 × 0..2.
let mut mask = [u64::MAX; 4];
forget_detail(key, [48, 0, 64, 16], &mut mask, |_| false);
@@ -3127,6 +3329,47 @@ mod tests {
assert_eq!(mask.iter().map(|m| m.count_ones()).sum::<u32>(), 64);
}
#[test]
fn tiles_sampled_without_a_kept_chunk_are_sampled_again() {
let tile = |level: u8| {
let size = 16 << level;
TileKey {
x: (3 * 16i32).div_euclid(size),
z: (-5 * 16i32).div_euclid(size),
level,
}
};
let empty = || Arc::new(vec![Column::default(); CELLS * CELLS]);
let filled = || {
let column = Column {
surface: None,
spans: vec![Span {
min: -64,
max: 70,
state: BlockStateId(1),
biome: 0,
light: 0,
}]
.into(),
};
Arc::new(vec![column; CELLS * CELLS])
};
let mut ready = FxHashMap::default();
let mut pending = FxHashSet::default();
// Cells of a chunk or less take its detail; wider ones sampled empty
// there are sampled again, and filled ones aren't.
ready.insert(tile(2), empty());
ready.insert(tile(5), empty());
ready.insert(tile(6), filled());
assert_eq!(resampled_over(&ready, &pending, (3, -5)), [tile(5)]);
// Samples in flight may predate it: those that detail can't patch.
pending.extend([tile(1), tile(2), tile(6), tile(7)]);
assert_eq!(
resampled_over(&ready, &pending, (3, -5)),
[tile(1), tile(5), tile(6), tile(7)]
);
assert!(resampled_over(&ready, &FxHashSet::default(), (40, 40)).is_empty());
}
#[test]
#[ignore = "requires prepared models/textures in HORIZON_TEST_RESOURCES"]
fn real_mesh_clips_near_chunks_and_preserves_water_and_edited_surfaces() -> crate::Result<()> {
use crate::{
@@ -4334,4 +4577,461 @@ mod tests {
check(&lod);
let _ = lod.removed();
}
/// The prepared resources' far-terrain worker context and block facts.
fn test_context() -> crate::Result<(Arc<WorkerContext>, BlockFacts)> {
use crate::{assets::Assets, biome::Biomes, light::LightTables, mesher::MeshTables};
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"),
);
let assets = Arc::new(Assets::load(&root)?);
let render = Arc::new(assets.reload(&root)?);
let facts = BlockFacts::load(&root, assets.registry)?;
let biomes = Arc::new(Biomes::load(&root)?);
let tables = Arc::new(MeshTables::new(
&assets,
&render,
&facts,
crate::models::load_visibility(&root, assets.registry)?,
)?);
let context = Arc::new(WorkerContext {
light: Arc::new(LightTables::load(&root, assets.registry, &facts)?),
tables,
assets,
render,
biomes,
});
Ok((context, facts))
}
/// A server's far terrain (`server_terrain`) in a scratch folder, and a
/// chunk of stone at y 70 to observe.
struct ServerLod {
lod: Lod,
facts: BlockFacts,
stone: BlockStateId,
dir: std::path::PathBuf,
}
impl ServerLod {
fn new(name: &str) -> crate::Result<Self> {
Self::with(name, |dir| {
crate::server_terrain::ServerTerrain::new(dir.into(), None)
})
}
fn with(
name: &str,
source: impl FnOnce(&std::path::Path) -> Arc<dyn LodSource>,
) -> crate::Result<Self> {
let (context, facts) = test_context()?;
let stone = context
.assets
.registry
.default_state("minecraft:stone")
.unwrap();
let dir = std::env::temp_dir().join(format!("horizon-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
Ok(Self {
lod: Lod::new(source(&dir), context),
facts,
stone,
dir,
})
}
fn observe(&mut self, x: i32, z: i32) {
let mut chunk = Chunk::empty(x, z);
for i in 0..256 {
chunk.set(i % 16, 70, i / 16, self.stone.0);
}
self.lod.observe(Arc::new(chunk), &self.facts);
}
/// Updates until nothing is left to sample, summarize or mesh (`settle`:
/// for a while), or only a few times.
fn run(
&mut self,
camera: [f64; 3],
detail: i32,
distance: u32,
covered: &FxHashSet<(i32, i32)>,
settle: bool,
) {
let lod = &mut self.lod;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120);
let mut covered = Some(covered.clone());
let mut quiet = 0;
while quiet < if settle { 50 } else { 3 } {
lod.update(camera, detail, distance, covered.take());
lod.removed();
// Every tile sampled, but those waiting for their data.
let unsampled = lod.plan.nodes.iter().any(|key| {
!lod.plan.split.contains(key)
&& !lod.ready.contains_key(key)
&& !lod.retry.contains_key(key)
});
let idle = lod.pending.is_empty()
&& lod.outstanding_meshes == 0
&& lod.dirty.is_empty()
&& lod.observing() == 0
&& lod
.shared
.jobs
.lock()
.unwrap()
.iter()
.all(|job| matches!(job, Job::RequestDetail { .. }))
&& !unsampled;
quiet = if idle { quiet + 1 } else { 0 };
assert!(
std::time::Instant::now() < deadline,
"far terrain never settled: {} pending, {} meshing, {} observing, {} jobs, {} unsampled, {} waiting",
lod.pending.len(),
lod.outstanding_meshes,
lod.observing(),
lod.shared.jobs.lock().unwrap().len(),
lod.plan
.nodes
.iter()
.filter(|key| !lod.plan.split.contains(key)
&& !lod.ready.contains_key(key)
&& !lod.retry.contains_key(key))
.count(),
lod.retry.len(),
);
std::thread::sleep(std::time::Duration::from_millis(2));
}
}
/// The chunks of `chunks` the active tiles don't draw as observed:
/// the visited column or the tile's sample there, at the chunk's middle.
fn missing(&self, chunks: impl Iterator<Item = (i32, i32)>) -> Vec<(i32, i32)> {
chunks
.filter(|&chunk| {
self.drawn(chunk)
.is_none_or(|c| c.spans.last().is_none_or(|s| s.max != 71))
})
.collect()
}
/// The column the active tiles draw at a chunk's middle.
fn drawn(&self, (cx, cz): (i32, i32)) -> Option<Column> {
let lod = &self.lod;
let snapshot = lod.snapshot();
let drawn = |x: i32, z: i32| {
let key = lod.active.iter().copied().find(|key| {
let [ox, _, oz] = key.origin();
(ox..ox + key.size()).contains(&x) && (oz..oz + key.size()).contains(&z)
})?;
let stride = key.size() / CELLS as i32;
let [ox, _, oz] = key.origin();
let (cx, cz) = ((x - ox) / stride, (z - oz) / stride);
let (wx, wz) = (ox + cx * stride, oz + cz * stride);
Some(match snapshot.visited_source(wx, wz, stride) {
Some(source) => source.get().clone(),
None => lod.ready[&key][(cz * CELLS as i32 + cx) as usize].clone(),
})
};
drawn(cx * 16 + 8, cz * 16 + 8)
}
}
impl Drop for ServerLod {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
#[test]
#[ignore = "requires prepared models/textures in HORIZON_TEST_RESOURCES"]
fn chunks_a_server_dropped_stay_drawn_as_far_terrain() -> crate::Result<()> {
let mut far = ServerLod::new("lod-dropped")?;
// A server's view distance of 10 (it sends a ring more), drawn to a
// render distance of 32 (`streaming.radius`, the covered area).
let (view, detail, distance) = (10, 32, 64);
let mut resident = FxHashSet::default();
let mut dropped = FxHashSet::default();
// The player flies east past some 1800 chunks: more than `MAX_SAVED`.
let end = 60;
for step in 0..=end {
let center = (step, 0);
let now: FxHashSet<_> = (-view..=view)
.flat_map(|dz| (-view..=view).map(move |dx| (center.0 + dx, center.1 + dz)))
.collect();
for &(x, z) in now.difference(&resident) {
far.observe(x, z);
}
for &(x, z) in resident.difference(&now) {
// `forget_remote_chunk`: the last of it, for far terrain.
far.observe(x, z);
dropped.insert((x, z));
}
resident = now;
let covered = resident
.iter()
.copied()
.filter(|&(x, z)| crate::streaming::in_view_distance(center, detail, x, z))
.collect();
let camera = [f64::from(center.0 * 16 + 8), 100.0, 8.0];
far.run(camera, detail, distance, &covered, step == end);
}
// Those within the LOD distance of where the player is now.
let near = |&(x, z): &(i32, i32)| (x - end).pow(2) + z * z < (distance as i32 - 4).pow(2);
let missing = far.missing(dropped.iter().copied().filter(near));
assert!(
missing.is_empty(),
"{} dropped chunks aren't drawn, as {:?}",
missing.len(),
&missing[..missing.len().min(8)]
);
Ok(())
}
#[test]
#[ignore = "requires prepared models/textures in HORIZON_TEST_RESOURCES"]
fn far_cells_sampled_before_their_chunks_were_kept_fill_in() -> crate::Result<()> {
let mut far = ServerLod::new("lod-late")?;
// Seen from 5000 blocks: cells of 32 blocks and wider over them.
let camera = [-5000.0, 100.0, 8.0];
let (detail, distance) = (10, 512);
let none = FxHashSet::default();
// Every tile sampled while nothing is kept, then 32 × 32 chunks are.
far.run(camera, detail, distance, &none, true);
let chunks: Vec<_> = (0..32).flat_map(|z| (0..32).map(move |x| (x, z))).collect();
assert_eq!(far.missing(chunks.iter().copied()).len(), chunks.len());
for &(x, z) in &chunks {
far.observe(x, z);
}
far.run(camera, detail, distance, &none, true);
let missing = far.missing(chunks.iter().copied());
assert!(
missing.is_empty(),
"{} kept chunks aren't drawn",
missing.len()
);
Ok(())
}
/// A BlueMap whose coarse levels are in, its finest (cells up to a
/// chunk) still downloading until `arrived`.
struct Downloading {
stone: BlockStateId,
arrived: Arc<std::sync::atomic::AtomicBool>,
}
struct DownloadingSampler {
stone: BlockStateId,
arrived: Arc<std::sync::atomic::AtomicBool>,
details: Vec<crate::streaming::LodDetail>,
}
impl LodSource for Downloading {
fn sampler(self: Arc<Self>) -> Box<dyn crate::streaming::LodSampler> {
Box::new(DownloadingSampler {
stone: self.stone,
arrived: self.arrived.clone(),
details: Vec::new(),
})
}
}
impl crate::streaming::LodSampler for DownloadingSampler {
fn fork(&self) -> Option<Box<dyn crate::streaming::LodSampler>> {
Some(Box::new(Self {
stone: self.stone,
arrived: self.arrived.clone(),
details: Vec::new(),
}))
}
fn sample(&mut self, _x: i32, _z: i32) -> Option<Surface> {
None
}
/// The coarse levels' stone, the finest's next state, and the
/// coarse one's while the finest is on its way (`server_terrain`):
/// stood in for, so not `waited`.
fn reduced_column(&mut self, _x: i32, _z: i32, width: i32) -> Column {
let state = if width > 16 {
self.stone
} else if self.arrived.load(Ordering::Relaxed) {
BlockStateId(self.stone.0 + 1)
} else {
self.stone
};
Column {
surface: None,
spans: vec![Span {
min: MIN_Y as i16,
max: 71,
state,
biome: 0,
light: 0xF0,
}]
.into(),
}
}
/// Chunks' detail once arrived, as `server_terrain`'s: asked again
/// while on its way.
fn request_detail(&mut self, chunks: &[(i32, i32)], _center: [i32; 2]) {
if self.arrived.load(Ordering::Relaxed) {
let columns = Arc::new(std::array::from_fn(|_| self.reduced_column(0, 0, 1)));
self.details
.extend(chunks.iter().map(|&chunk| (chunk, columns.clone())));
}
}
fn drain_details(&mut self) -> Vec<crate::streaming::LodDetail> {
std::mem::take(&mut self.details)
}
}
#[test]
#[ignore = "requires prepared models/textures in HORIZON_TEST_RESOURCES"]
fn coarser_stand_ins_keep_the_ring_past_the_near_chunks_drawn() -> crate::Result<()> {
let arrived = Arc::new(std::sync::atomic::AtomicBool::new(false));
let mut far = ServerLod::with("lod-waiting", |_| {
let stone = BlockStateId(1);
Arc::new(Downloading {
stone,
arrived: arrived.clone(),
})
})?;
let (camera, detail, distance) = ([8.0, 100.0, 8.0], 10, 64);
let none = FxHashSet::default();
// The ring past the near terrain, where nothing stood.
let ring: Vec<_> = (-24..=24)
.flat_map(|z| (-24..=24).map(move |x| (x, z)))
.filter(|&(x, z)| x * x + z * z > 11 * 11)
.collect();
far.run(camera, detail, distance, &none, true);
let missing = far.missing(ring.iter().copied());
assert!(
missing.is_empty(),
"{} of {} chunks past the near terrain show nothing while their tiles wait",
missing.len(),
ring.len()
);
// The map's tiles arrive (`BlueMap::take_changed`): the finest replace them.
arrived.store(true, Ordering::Relaxed);
far.lod.refresh([-1024, -1024, 1024, 1024], (1, 24));
far.run(camera, detail, distance, &none, true);
assert!(far.lod.retry.is_empty());
let fine = BlockStateId(2);
let coarse: Vec<_> = ring
.iter()
.copied()
.filter(|&chunk| {
far.drawn(chunk)
.is_none_or(|c| c.spans.last().is_none_or(|s| s.state != fine))
})
.collect();
assert!(
coarse.is_empty(),
"{} chunks still drawn coarse",
coarse.len()
);
assert!(far.lod.active.iter().any(|key| key.level == 0));
Ok(())
}
/// The sample jobs `request_samples` queues now, taken off the queue.
fn queued(lod: &mut Lod) -> Vec<TileKey> {
lod.request_samples([0, 0]);
let mut jobs = lod.shared.jobs.lock().unwrap();
jobs.drain(..)
.filter_map(|job| match job {
Job::Sample(key) => Some(key),
_ => None,
})
.collect()
}
fn samples(top: i16) -> Vec<Column> {
let mut column = Column::default();
column.spans.push(Span {
min: -64,
max: top,
state: BlockStateId(1),
biome: 0,
light: 0,
});
vec![column; CELLS * CELLS]
}
const AREA: [i32; 4] = [0, 0, 128, 128];
const ALL: (i32, i32) = (1, i32::MAX);
/// A first sample that read a map tile still downloading is thrown
/// away; if the tile came while it was taken, it is sampled again at
/// once, else after `WAIT_RETRY` or when the tile comes.
#[test]
fn waited_samples_are_taken_again_when_their_data_comes() {
let mut lod = Lod::scheduler_for_test();
let key = TileKey {
x: 0,
z: 0,
level: 3,
};
lod.plan.nodes.insert(key);
assert_eq!(queued(&mut lod), [key]);
lod.finish_any_sample(key, vec![Column::default(); CELLS * CELLS], true);
assert!(lod.retry.contains_key(&key) && !lod.ready.contains_key(&key));
assert!(queued(&mut lod).is_empty(), "it waits for its data");
lod.refresh(AREA, ALL);
assert!(!lod.retry.contains_key(&key));
assert_eq!(queued(&mut lod), [key], "the data came");
// The data comes while it is being sampled.
lod.refresh(AREA, ALL);
lod.finish_any_sample(key, vec![Column::default(); CELLS * CELLS], true);
assert!(!lod.retry.contains_key(&key));
assert_eq!(queued(&mut lod), [key], "sampled again at once");
// Coarser levels stood in for what waited: drawn.
lod.finish_any_sample(key, samples(70), false);
assert!(lod.ready.contains_key(&key) && !lod.stale.contains(&key));
assert!(queued(&mut lod).is_empty());
// Refreshed, then sampled with a hole where a tile is still on its
// way: what is drawn stays, the hole isn't.
lod.refresh(AREA, ALL);
assert_eq!(queued(&mut lod), [key]);
let mut holed = samples(80);
holed[..CELLS].fill(Column::default());
lod.finish_any_sample(key, holed, true);
assert_eq!(lod.ready[&key][0], samples(70)[0]);
assert!(lod.retry.contains_key(&key));
}
/// A tile refreshed while its sample is in flight is sampled once more:
/// that sample read the old data. Samples equal to the drawn ones mesh
/// nothing.
#[test]
fn refreshed_tiles_are_sampled_again_and_only_meshed_when_changed() {
let mut lod = Lod::scheduler_for_test();
let key = TileKey {
x: 0,
z: 0,
level: 3,
};
lod.plan.nodes.insert(key);
lod.active.insert(key);
lod.ready.insert(key, Arc::new(samples(70)));
assert!(queued(&mut lod).is_empty());
lod.refresh(AREA, ALL);
assert_eq!(queued(&mut lod), [key]);
lod.refresh(AREA, ALL);
lod.finish_sample(key, samples(80));
assert!(lod.stale.contains(&key), "refreshed again meanwhile");
assert!(lod.dirty.contains_key(&key), "new samples are meshed");
assert_eq!(queued(&mut lod), [key]);
lod.dirty.clear();
lod.finish_sample(key, samples(80));
assert!(!lod.stale.contains(&key));
assert!(lod.dirty.is_empty(), "the same samples mesh nothing");
assert!(queued(&mut lod).is_empty());
// Data drawn in other cell widths leaves it alone.
lod.refresh(AREA, (17, 24));
assert!(queued(&mut lod).is_empty());
}
/// With a source that keeps what it observes (a server's chunks), a wide
/// tile sampled empty where a chunk is now kept is sampled again.
#[test]
fn kept_chunks_resample_the_wide_tiles_over_them() {
let mut lod = Lod::scheduler_for_test();
lod.keeps_observed = true;
let key = TileKey {
x: 0,
z: 0,
level: 5,
};
lod.plan.nodes.insert(key);
lod.ready
.insert(key, Arc::new(vec![Column::default(); CELLS * CELLS]));
let column = samples(70).swap_remove(0);
let columns: Arc<[Column; 256]> = Arc::new(std::array::from_fn(|_| column.clone()));
lod.finish_observed((1, 1), ReducedColumns::new(columns));
assert!(lod.stale.contains(&key));
assert_eq!(queued(&mut lod), [key]);
}
}
@@ -44,7 +44,7 @@ impl ParsedModels {
}
}
let parsed = crate::par::par_map(&files, |(_, path)| {
let text = fs::read_to_string(path).ok()?;
let text = horizon_core::prepared_files::read_to_string(path).ok()?;
CuboidModel::from_json(&text).ok().map(Arc::new)
});
let models = files
@@ -89,8 +89,8 @@ impl ParsedModels {
let read = || -> Result<CuboidModel> {
let (namespace, path) =
name.split_once(':').ok_or("invalid model identifier")?;
let text = fs::read_to_string(
root.join(format!("assets/{namespace}/models/{path}.json")),
let text = horizon_core::prepared_files::read_to_string(
&root.join(format!("assets/{namespace}/models/{path}.json")),
)?;
Ok(CuboidModel::from_json(&text)?)
};
+10 -1
View File
@@ -110,7 +110,16 @@ pub(crate) fn prepare_with_models(
.state(state)
.ok_or("missing gallery registry state")?;
let position = BlockPos::new((index % 4) as i32 * 4 - 7, 4, (index / 4) as i32 * 4 + 14);
let selected = catalog.collect(state, position)?;
// A state no definition binds (a pack's partial blockstate file
// without vanilla's below it) is left out, as the mesher skips it,
// instead of failing the whole pack.
let selected = match catalog.collect(state, position) {
Ok(selected) => selected,
Err(e) => {
log::warn!("Horizon: gallery block {name} has no model: {e}");
continue;
}
};
states.push(json!({"block":actual.name,"properties":actual.properties,"state_id":state.0,
"position":[position.x,position.y,position.z],
"models":selected.iter().map(|(v,_)| json!({"model":v.model,"rotations":v.rotations,"uv_lock":v.uv_lock})).collect::<Vec<_>>()}));
@@ -105,6 +105,35 @@ fn bake_resolved(
cube,
})
}
/// The definitions a blockstate file holds, bottom first: the layers of a
/// file the pack overlay stacked (`packs::BLOCKSTATE_LAYERS`), else the
/// file itself.
fn definition_layers(text: &str) -> std::result::Result<Vec<String>, String> {
if !text.contains(crate::packs::BLOCKSTATE_LAYERS) {
return Ok(vec![text.to_owned()]);
}
let value: Value = serde_json::from_str(text).map_err(|e| e.to_string())?;
match value.get(crate::packs::BLOCKSTATE_LAYERS) {
Some(Value::Array(layers)) => Ok(layers.iter().map(Value::to_string).collect()),
_ => Ok(vec![text.to_owned()]),
}
}
/// Each state's model from definitions bound bottom first: the topmost one
/// that binds the state, as (its layer, its selection). A higher pack's
/// definition replaces only the states it binds (`putAll` in
/// `loadBlockStateDefinitionStack`).
fn topmost(layers: Vec<Vec<Option<SelectedModels>>>) -> Vec<Option<(usize, SelectedModels)>> {
let mut picked: Vec<Option<(usize, SelectedModels)>> = Vec::new();
for (layer, bound) in layers.into_iter().enumerate() {
picked.resize(picked.len().max(bound.len()), None);
for (slot, selected) in picked.iter_mut().zip(bound) {
if let Some(selected) = selected {
*slot = Some((layer, selected));
}
}
}
picked
}
impl StateModels {
pub fn load(
root: &Path,
@@ -194,21 +223,17 @@ impl StateModels {
.collect::<Result<_>>()?;
let possible: Vec<Properties> = serde_json::from_value(facts["states"].clone())?;
// Vanilla's BlockStateModelLoader: a definition that does not
// read or bind is logged and its block gets no model; the other
// blocks still load.
let loaded = fs::read_to_string(
root.join(format!("assets/{namespace}/blockstates/{path}.json")),
// read or bind is logged and left out; with none left the block
// gets no model, and the other blocks still load. A file several
// packs provide binds each pack's definition on its own, bottom
// first (`loadBlockStateDefinitionStack`).
let layers = match horizon_core::prepared_files::read_to_string(
&root.join(format!("assets/{namespace}/blockstates/{path}.json")),
)
.map_err(|e| e.to_string())
.and_then(|text| BlockStateDefinition::from_json(&text).map_err(|e| e.to_string()))
.and_then(|definition| {
let bound = definition
.bind(&schema, &possible)
.map_err(|e| e.to_string())?;
Ok((definition, bound))
});
let (definition, bound) = match loaded {
Ok(loaded) => loaded,
.and_then(|text| definition_layers(&text))
{
Ok(layers) => layers,
Err(e) => {
log::warn!(
"Horizon: blockstate definition of {namespace}:{path} not loaded: {e}"
@@ -216,6 +241,26 @@ impl StateModels {
continue;
}
};
let mut loaded = Vec::with_capacity(layers.len());
for text in &layers {
let layer = BlockStateDefinition::from_json(text)
.map_err(|e| e.to_string())
.and_then(|definition| {
let bound = definition
.bind(&schema, &possible)
.map_err(|e| e.to_string())?;
Ok((definition, bound))
});
match layer {
Ok(layer) => loaded.push(layer),
Err(e) => log::warn!(
"Horizon: blockstate definition of {namespace}:{path} not loaded: {e}"
),
}
}
if loaded.is_empty() {
continue;
}
let native_order: Vec<_> = by_name
.get(name.as_str())
.map(Vec::as_slice)
@@ -233,32 +278,36 @@ impl StateModels {
"model state definitions disagree with the imported block registry".into(),
);
}
for ((id, _), selected) in native_order.into_iter().zip(bound) {
if let Some(selected) = selected {
states[id.0 as usize] = Some((choices.len(), selected));
let (definitions, bound): (Vec<_>, Vec<_>) = loaded.into_iter().unzip();
let first = choices.len();
for ((id, _), picked) in native_order.into_iter().zip(topmost(bound)) {
if let Some((layer, selected)) = picked {
states[id.0 as usize] = Some((first + layer, selected));
}
}
for choice in definition
.variants
.iter()
.flatten()
.map(|(_, choice)| choice)
.chain(
definition
.multipart
.iter()
.flatten()
.map(|selector| &selector.variant),
)
{
match choice {
VariantSet::Single(v) => {
variants.insert(key(v));
for definition in definitions {
for choice in definition
.variants
.iter()
.flatten()
.map(|(_, choice)| choice)
.chain(
definition
.multipart
.iter()
.flatten()
.map(|selector| &selector.variant),
)
{
match choice {
VariantSet::Single(v) => {
variants.insert(key(v));
}
VariantSet::Weighted(v) => variants.extend(v.iter().map(|(v, _)| key(v))),
}
VariantSet::Weighted(v) => variants.extend(v.iter().map(|(v, _)| key(v))),
}
choices.push(CompiledChoices::new(&definition)?);
}
choices.push(CompiledChoices::new(&definition)?);
}
let names: Vec<String> = variants.iter().map(|(name, _, _)| name.clone()).collect();
let library = match models.block_library(root, names.clone()) {
@@ -428,6 +477,44 @@ mod tests {
use super::*;
use crate::model_library::ParsedModels;
/// #7013: a pack's blockstate file that defines only some states (here
/// a fence gate's `powered=true` ones) keeps vanilla's model for the
/// rest, as vanilla binds each pack's definition and lets a higher one
/// replace only what it binds.
#[test]
fn a_partial_pack_blockstate_keeps_vanilla_for_the_other_states() {
let schema: PropertySchema = BTreeMap::from([(
"powered".to_owned(),
PropertyValues::Named(BTreeSet::from(["false".to_owned(), "true".to_owned()])),
)]);
let states: Vec<Properties> = ["false", "true"]
.iter()
.map(|p| BTreeMap::from([("powered".to_owned(), (*p).to_owned())]))
.collect();
let stacked = serde_json::json!({ crate::packs::BLOCKSTATE_LAYERS: [
{"variants":{"":{"model":"minecraft:block/oak_fence_gate"}}},
{"variants":{"powered=true":{"model":"minecraft:block/stone"}}}
]})
.to_string();
let layers = definition_layers(&stacked).unwrap();
assert_eq!(layers.len(), 2);
let bound = layers
.iter()
.map(|text| {
BlockStateDefinition::from_json(text)
.unwrap()
.bind(&schema, &states)
.unwrap()
})
.collect();
let picked = topmost(bound);
assert_eq!(picked[0], Some((0, SelectedModels::Variant(0))));
assert_eq!(picked[1], Some((1, SelectedModels::Variant(0))));
// A file that isn't stacked is one definition.
let plain = r#"{"variants":{"":{"model":"minecraft:block/stone"}}}"#;
assert_eq!(definition_layers(plain).unwrap(), vec![plain.to_owned()]);
}
struct Fixture(std::path::PathBuf);
impl Fixture {
fn write(&self, name: &str, json: &str) {
+14 -1
View File
@@ -126,7 +126,6 @@ impl Opt {
| Opt::HideMatchedNames
| Opt::OnlySecureChat
| Opt::SaveChatDrafts
| Opt::RotateWithMinecarts
// Saved, but no Horizon system reads them yet: no Realms, no
// friends list, no directional audio, no now-playing toast.
| Opt::RealmsNotifications
@@ -937,6 +936,7 @@ impl Opt {
| Opt::RealmsNotifications
| Opt::InGameNotification
| Opt::DirectionalAudio
| Opt::RotateWithMinecarts
| Opt::NarratorHotkey => {
let key = self.file_key().unwrap();
s.file
@@ -1512,6 +1512,8 @@ pub struct OptionsUi {
/// (`update_pack_infos`) differs from the settings'.
pack_reselected: bool,
pub in_world: bool,
/// AccessibilityOptionsScreen: the loaded world's experimental feature.
pub minecart_improvements: bool,
/// Scroll the selected language into the middle on the next layout.
center_language: bool,
pub graphics: crate::graphics_ui::GraphicsUi,
@@ -1576,6 +1578,7 @@ impl Default for OptionsUi {
pack_focus: Instant::now(),
pack_reselected: false,
in_world: false,
minecart_improvements: false,
center_language: false,
graphics: Default::default(),
split_profiles: Default::default(),
@@ -3236,6 +3239,14 @@ impl OptionsUi {
if widget.list {
widget.y += previous_scroll - self.scroll;
}
if let WKind::Button {
act: Act::Cycle(Opt::RotateWithMinecarts),
active,
..
} = &mut widget.kind
{
*active = self.in_world && self.minecart_improvements;
}
}
self.row_bands = rows
.bands
@@ -3385,6 +3396,8 @@ impl OptionsUi {
}
if self.page() == Page::SplitSetup {
self.split_setup.on = s.extras.splitscreen;
// The controller picks a tile up between presses: the screen's size from the draw.
self.split_setup.size = (batch.width, batch.height);
self.split_setup.draw(batch, atlas, cursor, self.in_world);
return;
}
+47 -2
View File
@@ -746,7 +746,8 @@ fn build_overlay_uncached(
}
for name in names {
let child = format!("{prefix}{name}");
if hidden.contains(&child) {
// Vanilla's files in one file: it describes vanilla's root, not this mirror.
if hidden.contains(&child) || child == horizon_core::prepared_files::FILE_NAME {
continue;
}
if let Some(bytes) = merged.get(&child) {
@@ -840,7 +841,14 @@ enum Merge {
Font,
/// `SpriteSourceList.load`: sources from the bottom pack up.
Atlas,
/// `BlockStateModelLoader.loadBlockStateDefinitionStack`: every pack's
/// definition binds on its own, and a higher one replaces only the states
/// it binds. Kept as its layers ([`BLOCKSTATE_LAYERS`]) for the catalog.
BlockStates,
}
/// The key of a stacked blockstate file: its definitions, bottom first, each
/// as the pack wrote it.
pub const BLOCKSTATE_LAYERS: &str = "horizon:layers";
fn merge_kind(name: &str) -> Option<Merge> {
let (_, rest) = name.strip_prefix("assets/")?.split_once('/')?;
if rest == "sounds.json" {
@@ -851,6 +859,7 @@ fn merge_kind(name: &str) -> Option<Merge> {
"lang" => Some(Merge::Lang),
"font" => Some(Merge::Font),
"atlases" => Some(Merge::Atlas),
"blockstates" => Some(Merge::BlockStates),
_ => None,
}
}
@@ -858,7 +867,7 @@ fn merge_kind(name: &str) -> Option<Merge> {
/// does. Layers that do not parse are skipped, as vanilla logs and skips them.
fn merge(kind: Merge, layers: &[Vec<u8>]) -> Option<Vec<u8>> {
use serde_json::{Map, Value};
let parsed: Vec<Map<String, Value>> = layers
let mut parsed: Vec<Map<String, Value>> = layers
.iter()
// SoundManager skips a sounds.json Gson rejects, whole.
.filter(|b| kind != Merge::Sounds || crate::sound_events::is_valid_file(b))
@@ -901,6 +910,12 @@ fn merge(kind: Merge, layers: &[Vec<u8>]) -> Option<Vec<u8>> {
}
Merge::Font => list("providers", &mut parsed.iter().rev()),
Merge::Atlas => list("sources", &mut parsed.iter()),
// One definition is the file as it is.
Merge::BlockStates if parsed.len() < 2 => Value::Object(parsed.pop()?),
Merge::BlockStates => Value::Object(Map::from_iter([(
BLOCKSTATE_LAYERS.to_owned(),
Value::Array(parsed.into_iter().map(Value::Object).collect()),
)])),
};
serde_json::to_vec(&out).ok()
}
@@ -1404,6 +1419,36 @@ mod tests {
&[json(r#"{"sources":[1]}"#), json(r#"{"sources":[2]}"#)],
);
assert_eq!(parse(atlas.unwrap()), serde_json::json!({"sources":[1,2]}));
// BlockStateModelLoader: every definition kept, bottom first, for the
// catalog to bind one by one; one that does not parse is skipped.
let gate = merge(
Merge::BlockStates,
&[
json(r#"{"variants":{"":{"model":"v"}}}"#),
b"not json".to_vec(),
json(r#"{"variants":{"powered=true":{"model":"p"}}}"#),
],
);
assert_eq!(
parse(gate.unwrap()),
serde_json::json!({BLOCKSTATE_LAYERS:[
{"variants":{"":{"model":"v"}}},
{"variants":{"powered=true":{"model":"p"}}}
]})
);
// A single definition stays the file as it is.
let alone = merge(
Merge::BlockStates,
&[json(r#"{"variants":{"":{"model":"p"}}}"#)],
);
assert_eq!(
parse(alone.unwrap()),
serde_json::json!({"variants":{"":{"model":"p"}}})
);
assert_eq!(
merge_kind("assets/minecraft/blockstates/oak_fence_gate.json"),
Some(Merge::BlockStates)
);
assert_eq!(
merge_kind("assets/minecraft/lang/en_us.json"),
Some(Merge::Lang)
@@ -12,7 +12,8 @@ use std::{collections::BTreeSet, fs, io, path::Path, path::PathBuf};
// Version 3 could omit untouched vanilla directories on Windows (#1649, #1688).
// Version 5 read every vanilla file to stamp it on Windows.
// Version 6 stamps vanilla's files by their metadata on Windows too.
const VERSION: u32 = 6;
// Version 7 stacks blockstate files (every pack's definition, bottom first).
const VERSION: u32 = 7;
#[derive(Default, Serialize, Deserialize)]
pub(super) struct Dependencies {
@@ -1158,7 +1158,9 @@ impl ParticleEngine {
}
let dir = root.join("assets/minecraft/particles");
for &ty in ParticleType::ALL {
let Ok(bytes) = std::fs::read(dir.join(format!("{}.json", ty.name()))) else {
let Ok(bytes) =
horizon_core::prepared_files::read(&dir.join(format!("{}.json", ty.name())))
else {
continue;
};
let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
@@ -6,11 +6,15 @@
//!
//! The summaries live in Anvil region files (`r.X.Z.mca`, one NBT record a
//! chunk), under `server-terrain/<server>/<hashed seed>/<namespace>/<level>/`.
//! Past what it has, the server's BlueMap may fill in (`bluemap`).
//! A chunk it doesn't have may be in the player's Bobby cache of the same
//! level (`Bobby`), only ever read; past both, the server's BlueMap may fill
//! in (`bluemap`).
use crate::{
blocks::BlockFacts,
bluemap::{BlueMap, Found},
chunk::Chunk,
fast_hash::FxHashMap,
lod::{Column, Span, Spans, Surface},
lod::{Column, Span, Spans, Surface, state_kinds, summarize},
streaming::{LodDetail, LodSampler, LodSource},
};
use horizon_core::position::ChunkPos;
@@ -70,6 +74,67 @@ pub fn level_dir(root: &Path, address: &str, hashed_seed: i64, level: &str) -> P
.join(level_path(level))
}
/// The official launcher's game folder (`HORIZON_MINECRAFT_DIR` if set),
/// where Bobby keeps its cache (`.bobby`): as
/// `horizon_prepare::artifacts::minecraft_dir` finds it, which this crate
/// can't depend on.
pub fn minecraft_dir() -> PathBuf {
let home = || {
let var = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
std::env::var_os(var).map_or_else(|| PathBuf::from("."), PathBuf::from)
};
if let Some(dir) = std::env::var_os("HORIZON_MINECRAFT_DIR").filter(|d| !d.is_empty()) {
let dir = PathBuf::from(dir);
if let Ok(rest) = dir.strip_prefix("~") {
return home().join(rest);
}
return dir;
}
if cfg!(target_os = "macos") {
home().join("Library/Application Support/minecraft")
} else if cfg!(windows) {
std::env::var_os("APPDATA")
.map_or_else(home, PathBuf::from)
.join(".minecraft")
} else {
home().join(".minecraft")
}
}
/// Bobby's folder of a level under `root` (`.bobby`), as Bobby leaves it on
/// disk: `<address as typed, ':' as '_'>/<hashed seed>/<namespace>/<path>`.
/// Names are compared without case, and `host` and `host_25565` both stand
/// for the default port: neither was seen, so both are taken.
pub fn bobby_level_dir(
root: &Path,
address: &str,
hashed_seed: i64,
level: &str,
) -> Option<PathBuf> {
let typed = address.trim().replace(':', "_");
let bare = typed.strip_suffix("_25565").unwrap_or(&typed);
if bare.is_empty() {
return None;
}
let names = [bare.to_owned(), format!("{bare}_25565")];
let mut servers: Vec<PathBuf> = std::fs::read_dir(root)
.ok()?
.flatten()
.filter(|entry| {
entry
.file_name()
.to_str()
.is_some_and(|name| names.iter().any(|n| n.eq_ignore_ascii_case(name)))
})
.map(|entry| entry.path())
.collect();
servers.sort();
servers
.into_iter()
.map(|server| server.join(hashed_seed.to_string()).join(level_path(level)))
.find(|dir| dir.is_dir())
}
/// `<namespace>/<path>` of a level key.
fn level_path(level: &str) -> PathBuf {
let (namespace, path) = level.split_once(':').unwrap_or(("minecraft", level));
@@ -104,29 +169,73 @@ type Regions = FxHashMap<(i32, i32), Option<Region>>;
#[derive(Default)]
struct State {
regions: Regions,
/// Bobby's, opened read-only.
bobby: Regions,
/// `None`: the chunk isn't stored.
chunks: FxHashMap<(i32, i32), Option<Columns>>,
}
/// A saved full chunk with its stored light, or `None` (`save::decode_lit`
/// in the game).
pub type DecodeChunk = Box<dyn Fn(&NamedTag) -> Option<Chunk> + Send + Sync>;
/// The chunks the player's Bobby kept of the same level: vanilla chunk NBT
/// in region files, as Bobby leaves them on disk. Only ever read; a chunk
/// far terrain needs from there is kept in the level's own folder.
pub struct Bobby {
dir: PathBuf,
decode: DecodeChunk,
/// `lod::state_kinds`, to summarize its chunks as loaded ones are.
kinds: Vec<u8>,
}
impl Bobby {
pub fn new(dir: PathBuf, decode: DecodeChunk, facts: &BlockFacts) -> Self {
Self {
dir,
decode,
kinds: state_kinds(facts),
}
}
}
/// One level's stored terrain, shared by the far-terrain threads. A chunk
/// it doesn't have comes from the server's BlueMap, if the player named it.
/// it doesn't have comes from Bobby's cache, else the server's BlueMap, if
/// the player named it or Find BlueMap found it.
pub struct ServerTerrain {
dir: PathBuf,
blue_map: Option<Arc<BlueMap>>,
bobby: Option<Bobby>,
state: Mutex<State>,
warned: std::sync::atomic::AtomicBool,
}
impl ServerTerrain {
pub fn new(dir: PathBuf, blue_map: Option<Arc<BlueMap>>) -> Arc<Self> {
Self::with_bobby(dir, blue_map, None)
}
pub fn with_bobby(
dir: PathBuf,
blue_map: Option<Arc<BlueMap>>,
bobby: Option<Bobby>,
) -> Arc<Self> {
Arc::new(Self {
dir,
blue_map,
bobby,
state: Mutex::default(),
warned: Default::default(),
})
}
/// BlueMap's coarser levels at block (x, z) (a void there is `Ready`).
fn coarser(&self, x: i32, z: i32) -> Found<Column> {
self.blue_map
.as_ref()
.map_or(Found::Absent, |map| map.coarser_column(x, z))
}
/// A chunk's columns: stored, else BlueMap's (`Waiting`: on its way).
fn chunk_or_map(&self, x: i32, z: i32) -> Found<Columns> {
if let Some(columns) = self.chunk(x, z) {
@@ -139,19 +248,35 @@ impl ServerTerrain {
/// The stored chunk nearest the middle of the `side` × `side` chunks
/// from `(x, z)`, found in the region headers without reading any.
#[cfg(test)]
fn nearest(&self, x: i32, z: i32, side: i32) -> Option<(i32, i32)> {
self.kept(x, z, side).1
}
/// How many of the `side` × `side` chunks from `(x, z)` are stored here
/// or in Bobby's cache, and the one nearest their middle (`nearest`).
fn kept(&self, x: i32, z: i32, side: i32) -> (usize, Option<(i32, i32)>) {
let mut state = self.state.lock().unwrap();
let middle = (x * 2 + side, z * 2 + side);
let mut count = 0;
let mut best: Option<(i64, (i32, i32))> = None;
for rz in z >> 5..=(z + side - 1) >> 5 {
for rx in x >> 5..=(x + side - 1) >> 5 {
let present = region(&mut state.regions, &self.dir, (rx, rz))
let mut present = region(&mut state.regions, &self.dir, (rx, rz))
.map_or([false; 1024], |r| r.present);
if let Some(bobby) = &self.bobby
&& let Some(theirs) = read_only(&mut state.bobby, &bobby.dir, (rx, rz))
{
for (here, there) in present.iter_mut().zip(theirs.present) {
*here |= there;
}
}
for (i, _) in present.iter().enumerate().filter(|(_, p)| **p) {
let (cx, cz) = (rx * 32 + (i % 32) as i32, rz * 32 + (i / 32) as i32);
if cx < x || cz < z || cx >= x + side || cz >= z + side {
continue;
}
count += 1;
let (dx, dz) = (
i64::from(cx * 2 + 1 - middle.0),
i64::from(cz * 2 + 1 - middle.1),
@@ -163,26 +288,15 @@ impl ServerTerrain {
}
}
}
best.map(|(_, chunk)| chunk)
(count, best.map(|(_, chunk)| chunk))
}
/// A stored chunk's columns.
/// A stored chunk's columns, else Bobby's (kept here from then on).
pub fn chunk(&self, x: i32, z: i32) -> Option<Columns> {
let key = (x >> 5, z >> 5);
let here = {
let mut state = self.state.lock().unwrap();
if let Some(known) = state.chunks.get(&(x, z)) {
return known.clone();
}
read(region(&mut state.regions, &self.dir, key), x, z)
};
// A record of another game version or layout is none.
let columns = here.and_then(|record| {
record
.nbt(Limits::default())
.ok()
.and_then(|root| decode(&root.tag))
});
if let Some(known) = self.state.lock().unwrap().chunks.get(&(x, z)) {
return known.clone();
}
let columns = self.stored(x, z).or_else(|| self.bobby_chunk(x, z));
let mut state = self.state.lock().unwrap();
// Stored by another thread meanwhile (`write`): that, newer than
// what was read here.
@@ -193,6 +307,51 @@ impl ServerTerrain {
columns
}
/// The chunk's record in the level's own folder; one of another game
/// version or layout is none.
fn stored(&self, x: i32, z: i32) -> Option<Columns> {
let record = {
let mut state = self.state.lock().unwrap();
read(
region(&mut state.regions, &self.dir, (x >> 5, z >> 5)),
x,
z,
)
}?;
record
.nbt(Limits::default())
.ok()
.and_then(|root| decode(&root.tag))
}
/// A chunk in Bobby's cache, summarized and kept here. One of another
/// game version, or that doesn't decode, is none.
fn bobby_chunk(&self, x: i32, z: i32) -> Option<Columns> {
let bobby = self.bobby.as_ref()?;
let (record, kept) = {
let mut state = self.state.lock().unwrap();
let key = (x >> 5, z >> 5);
let kept =
region(&mut state.regions, &self.dir, key).is_some_and(|r| r.present[slot(x, z)]);
(
read(read_only(&mut state.bobby, &bobby.dir, key), x, z),
kept,
)
};
let record = record?;
let root = record.nbt(Limits::default()).ok()?;
if root.tag.get("DataVersion") != Some(&Tag::Int(DATA_VERSION)) {
return None;
}
let chunk = (bobby.decode)(&root).filter(|chunk| (chunk.x, chunk.z) == (x, z))?;
let columns = summarize(&chunk, &bobby.kinds);
// Not over a chunk the server sent while this one was decoded.
if let Err(e) = self.write((x, z), &columns, Some(kept)) {
self.warn_once(&e);
}
Some(columns)
}
/// A chunk couldn't be kept (disk full, a folder it can't write): said
/// once a level, not for every chunk after.
fn warn_once(&self, error: &std::io::Error) {
@@ -203,16 +362,29 @@ impl ServerTerrain {
/// Keep a chunk's columns, unless they are what is stored already.
pub fn store(&self, key: (i32, i32), columns: &Columns) -> std::io::Result<()> {
if self
.chunk(key.0, key.1)
.is_some_and(|old| *old == **columns)
{
let known = self.state.lock().unwrap().chunks.get(&key).cloned();
let old = known.unwrap_or_else(|| {
let old = self.stored(key.0, key.1);
let mut state = self.state.lock().unwrap();
if !state.chunks.contains_key(&key) {
remember(&mut state, key, old.clone());
}
old
});
if old.is_some_and(|old| *old == **columns) {
return Ok(());
}
self.write(key, columns)
self.write(key, columns, None)
}
fn write(&self, (x, z): (i32, i32), columns: &Columns) -> std::io::Result<()> {
/// `kept`: whether the chunk's record was in the folder before; the
/// columns aren't written over one another thread kept meanwhile.
fn write(
&self,
(x, z): (i32, i32),
columns: &Columns,
kept: Option<bool>,
) -> std::io::Result<()> {
let root = NamedTag {
name: "".into(),
tag: encode(x, z, columns),
@@ -220,6 +392,14 @@ impl ServerTerrain {
let record = ChunkRecord::from_nbt(&root, Compression::Zlib, now(), Limits::default())?;
let mut state = self.state.lock().unwrap();
let key = (x >> 5, z >> 5);
if let Some(kept) = kept
&& (matches!(state.chunks.get(&(x, z)), Some(Some(_)))
|| (!kept
&& region(&mut state.regions, &self.dir, key)
.is_some_and(|r| r.present[slot(x, z)])))
{
return Ok(());
}
if region(&mut state.regions, &self.dir, key).is_none() {
std::fs::create_dir_all(&self.dir)?;
let file = RegionFile::open_writable(region_path(&self.dir, key), true)?;
@@ -239,12 +419,26 @@ fn region_path(dir: &Path, (rx, rz): (i32, i32)) -> PathBuf {
/// A region of `dir`, opened on first use.
fn region<'a>(regions: &'a mut Regions, dir: &Path, key: (i32, i32)) -> Option<&'a mut Region> {
open(regions, key, || {
RegionFile::open_writable(region_path(dir, key), false)
})
}
/// A region of Bobby's `dir`, opened for reading only.
fn read_only<'a>(regions: &'a mut Regions, dir: &Path, key: (i32, i32)) -> Option<&'a mut Region> {
open(regions, key, || RegionFile::open(region_path(dir, key)))
}
fn open(
regions: &mut Regions,
key: (i32, i32),
file: impl FnOnce() -> std::io::Result<RegionFile>,
) -> Option<&mut Region> {
if !regions.contains_key(&key) {
if regions.len() >= MAX_REGIONS {
regions.clear();
}
let file = RegionFile::open_writable(region_path(dir, key), false);
regions.insert(key, file.ok().map(Region::new));
regions.insert(key, file().ok().map(Region::new));
}
regions.get_mut(&key)?.as_mut()
}
@@ -378,13 +572,37 @@ impl LodSource for ServerTerrain {
Box::new(Sampler {
terrain: self,
details: Vec::new(),
waited: false,
})
}
fn keeps_observed(&self) -> bool {
true
}
}
impl Sampler {
/// For a column whose chunk of the map is on its way: its coarser
/// levels' meanwhile, as they come first (a void there is drawn as
/// one). None there either is a hole (`waited`: the sample isn't drawn);
/// either way the column is sampled again when its tile arrives
/// (`Lod::refresh`).
fn coarser(&mut self, x: i32, z: i32) -> Column {
match self.terrain.coarser(x, z) {
Found::Ready(column) => column,
Found::Waiting | Found::Absent => {
self.waited = true;
Column::default()
}
}
}
}
struct Sampler {
terrain: Arc<ServerTerrain>,
details: Vec<LodDetail>,
/// A column was left empty for BlueMap's on its way, with no coarser
/// level to stand in (`waited`).
waited: bool,
}
impl LodSampler for Sampler {
@@ -392,10 +610,12 @@ impl LodSampler for Sampler {
Some(Box::new(Self {
terrain: self.terrain.clone(),
details: Vec::new(),
waited: false,
}))
}
/// Stored chunks come back whole, else BlueMap's; the others are known
/// empty. One BlueMap is still downloading is asked again later.
/// Stored chunks come back whole, else BlueMap's (its coarser levels'
/// where the finest is out of reach); the others are known empty. One
/// whose BlueMap tile is still downloading is asked again later.
fn request_detail(&mut self, chunks: &[(i32, i32)], _center: [i32; 2]) {
for &(x, z) in chunks {
let columns = match self.terrain.chunk_or_map(x, z) {
@@ -420,41 +640,61 @@ impl LodSampler for Sampler {
fn column(&mut self, x: i32, z: i32) -> Column {
match self.terrain.chunk_or_map(x >> 4, z >> 4) {
Found::Ready(c) => c[((z & 15) * 16 + (x & 15)) as usize].clone(),
_ => Column::default(),
Found::Waiting => self.coarser(x, z),
Found::Absent => Column::default(),
}
}
fn waited(&mut self) -> bool {
std::mem::take(&mut self.waited)
}
/// A cell of `width` blocks from one stored chunk: its own area up to a
/// chunk, else the stored chunk nearest the cell's middle (the server
/// sent only the chunks a player came near). With a BlueMap, a wider
/// sent only the chunks a player came near), when most of the cell is
/// stored. With a BlueMap, a wider
/// cell takes the chunk at its middle if stored, else the map's pixel
/// there, and the nearest stored chunk only while the map has none.
fn reduced_column(&mut self, x: i32, z: i32, width: i32) -> Column {
if width <= 16 {
let Found::Ready(chunk) = self.terrain.chunk_or_map(x >> 4, z >> 4) else {
return Column::default();
let chunk = match self.terrain.chunk_or_map(x >> 4, z >> 4) {
Found::Ready(chunk) => chunk,
Found::Waiting => return self.coarser(x, z),
Found::Absent => return Column::default(),
};
let width = width.max(1) as usize;
let lx = (x & 15) as usize & !(width - 1);
let lz = (z & 15) as usize & !(width - 1);
return Column::reduced(&chunk, lx, lz, width);
}
let mut waiting = false;
if let Some(map) = &self.terrain.blue_map {
let (mx, mz) = ((x + width / 2) >> 4, (z + width / 2) >> 4);
if let Some(chunk) = self.terrain.chunk(mx, mz) {
return Column::reduced(&chunk, 0, 0, 16);
}
if let Found::Ready(column) = map.reduced_column(x, z, width) {
return column;
match map.reduced_column(x, z, width) {
Found::Ready(column) => return column,
Found::Waiting => waiting = true,
Found::Absent => {}
}
}
// Searching wider cells would open thousands of region files.
if width >> 4 > MAX_KEPT_SIDE {
// Only over a cell the server sent most of, as `Column::reduced`
// keeps what fills half a cell: one chunk drawn as a whole cell stood
// as a flat slab far wider than anything seen there, until finer
// tiles replaced it with the nothing they know of.
let side = width >> 4;
// Wider cells are never mostly sent, and counting them would open
// thousands of region files.
if side > MAX_KEPT_SIDE {
self.waited |= waiting;
return Column::default();
}
self.terrain
.nearest(x >> 4, z >> 4, width >> 4)
.and_then(|(cx, cz)| self.terrain.chunk(cx, cz))
.map_or_else(Column::default, |chunk| Column::reduced(&chunk, 0, 0, 16))
let (count, nearest) = self.terrain.kept(x >> 4, z >> 4, side);
let column = (count * 2 >= (side * side) as usize)
.then(|| nearest.and_then(|(cx, cz)| self.terrain.chunk(cx, cz)))
.flatten()
.map(|chunk| Column::reduced(&chunk, 0, 0, 16));
self.waited |= waiting && column.is_none();
column.unwrap_or_default()
}
}
@@ -552,8 +792,206 @@ mod tests {
let mut sampler = terrain.clone().sampler();
assert_eq!(sampler.sample(-33 * 16 + 1, 5 * 16), columns[1].surface);
assert_eq!(sampler.column(0, 0), Column::default());
// A far cell finds the stored chunk anywhere in it.
assert_ne!(sampler.reduced_column(-64 * 16, 0, 1024), Column::default());
// One chunk doesn't stand for a far cell of 64 × 64.
assert_eq!(sampler.reduced_column(-64 * 16, 0, 1024), Column::default());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn wide_cells_are_drawn_where_most_of_them_was_kept() {
let dir = scratch("server-terrain-wide");
let columns = columns();
let terrain = ServerTerrain::new(dir.clone(), None);
let mut sampler = terrain.clone().sampler();
// A cell of 4 × 4 chunks, and a chunk of it kept.
terrain.store((2, 1), &columns).unwrap();
assert_eq!(sampler.reduced_column(0, 0, 64), Column::default());
for (x, z) in [(0, 0), (1, 0), (2, 0), (3, 0), (0, 1), (1, 1)] {
terrain.store((x, z), &columns).unwrap();
assert_eq!(sampler.reduced_column(0, 0, 64), Column::default());
}
// Half of it: drawn from the kept chunk nearest its middle.
terrain.store((2, 2), &columns).unwrap();
assert_eq!(
sampler.reduced_column(0, 0, 64),
Column::reduced(&columns, 0, 0, 16)
);
std::fs::remove_dir_all(&dir).unwrap();
}
/// Far terrain samples a cell before the server sent its chunk, then
/// again once it is kept: nothing remembers the empty sample.
#[test]
fn a_chunk_kept_after_an_empty_sample_is_sampled_next_time() {
let dir = scratch("server-terrain-late");
let columns = columns();
let terrain = ServerTerrain::new(dir.clone(), None);
let mut sampler = terrain.clone().sampler();
let mut other = sampler.fork().expect("a sampling thread's own");
for width in [1, 4, 16] {
assert_eq!(other.reduced_column(-16, 32, width), Column::default());
}
assert_eq!(other.grid(-16, 32, 1, 16), vec![Column::default(); 256]);
// `Job::Observe` stores it on the far-terrain worker.
sampler.observed((-1, 2), &columns);
assert_eq!(other.grid(-16, 32, 1, 16), columns.to_vec());
assert_eq!(
other.reduced_column(-16, 32, 16),
Column::reduced(&columns, 0, 0, 16)
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn bobby_folders_follow_the_address_as_typed() {
let root = scratch("bobby-folders");
for dir in [
"127.0.0.1/-9030034075591913594/minecraft/overworld",
"127.0.0.1/-9030034075591913594/minecraft/the_nether",
"127.0.0.1/4696776658964135993/minecraft/overworld",
"localhost_25566/-7181178862026813658/minecraft/overworld",
"Play.Example.net_25565/5/minecraft/overworld",
] {
std::fs::create_dir_all(root.join(dir)).unwrap();
}
let find = |address: &str, seed: i64, level: &str| {
bobby_level_dir(&root, address, seed, level)
.map(|dir| dir.strip_prefix(&root).unwrap().to_owned())
};
let overworld = "minecraft:overworld";
let first = Some(PathBuf::from(
"127.0.0.1/-9030034075591913594/minecraft/overworld",
));
assert_eq!(find("127.0.0.1", -9030034075591913594, overworld), first);
assert_eq!(
find("127.0.0.1:25565", -9030034075591913594, overworld),
first
);
assert_eq!(
find("127.0.0.1", -9030034075591913594, "minecraft:the_nether"),
Some(PathBuf::from(
"127.0.0.1/-9030034075591913594/minecraft/the_nether"
))
);
assert_eq!(
find("127.0.0.1", 4696776658964135993, overworld),
Some(PathBuf::from(
"127.0.0.1/4696776658964135993/minecraft/overworld"
))
);
let other_port = Some(PathBuf::from(
"localhost_25566/-7181178862026813658/minecraft/overworld",
));
assert_eq!(
find("localhost:25566", -7181178862026813658, overworld),
other_port
);
assert_eq!(
find("LocalHost:25566", -7181178862026813658, overworld),
other_port
);
assert_eq!(
find("play.example.net", 5, overworld),
Some(PathBuf::from(
"Play.Example.net_25565/5/minecraft/overworld"
))
);
// Another world behind the address, another dimension, another port.
assert_eq!(find("127.0.0.1", 1111, overworld), None);
assert_eq!(
find("127.0.0.1", -9030034075591913594, "minecraft:the_end"),
None
);
assert_eq!(find("localhost", -7181178862026813658, overworld), None);
assert_eq!(
find("localhost:25567", -7181178862026813658, overworld),
None
);
assert_eq!(find("", 0, overworld), None);
assert_eq!(
bobby_level_dir(&root.join("none"), "127.0.0.1", 0, overworld),
None
);
std::fs::remove_dir_all(&root).unwrap();
}
/// A Bobby-shaped region of chunk records, written with `version`.
fn bobby_region(dir: &Path, chunks: &[(i32, i32)], version: i32) {
std::fs::create_dir_all(dir).unwrap();
let (rx, rz) = (chunks[0].0 >> 5, chunks[0].1 >> 5);
let mut file = RegionFile::open_writable(region_path(dir, (rx, rz)), true).unwrap();
for &(x, z) in chunks {
let root = NamedTag {
name: "".into(),
tag: Tag::compound([
("DataVersion", Tag::Int(version)),
("Status", Tag::String("full".into())),
("xPos", Tag::Int(x)),
("zPos", Tag::Int(z)),
]),
};
let record = ChunkRecord::from_nbt(&root, Compression::Zlib, 0, Limits::default());
file.write(ChunkPos::new(x, z), &record.unwrap()).unwrap();
}
}
/// Stone from y 60 to 70 in every column (the game decodes vanilla chunk NBT
/// with `save::decode_lit`; its palettes are tested there).
fn stone(root: &NamedTag) -> Option<Chunk> {
let (Some(Tag::Int(x)), Some(Tag::Int(z))) = (root.tag.get("xPos"), root.tag.get("zPos"))
else {
return None;
};
let mut chunk = Chunk::empty(*x, *z);
for i in 0..256 {
for y in 60..70 {
chunk.set(i & 15, y, i >> 4, 1);
}
}
Some(chunk)
}
#[test]
fn chunks_only_bobby_has_are_read_once_and_kept() {
let dir = scratch("server-terrain-bobby");
let (own, theirs) = (dir.join("own"), dir.join("bobby"));
bobby_region(&theirs, &[(-3, 5), (-2, 5), (-1, 6)], DATA_VERSION);
bobby_region(&theirs.join("old"), &[(-3, 5)], DATA_VERSION - 1);
let region = region_path(&theirs, (-1, 0));
let before = std::fs::read(&region).unwrap();
// State 0 air, state 1 a kept block (`lod::state_kinds`).
let bobby = |dir: &Path| Bobby {
dir: dir.to_owned(),
decode: Box::new(stone),
kinds: vec![4, 1],
};
let terrain = ServerTerrain::with_bobby(own.clone(), None, Some(bobby(&theirs)));
// Counted for wide cells before they are read.
assert_eq!(terrain.kept(-4, 4, 4), (3, Some((-3, 5))));
let expected = summarize(
&stone(&NamedTag {
name: "".into(),
tag: Tag::compound([("xPos", Tag::Int(-2)), ("zPos", Tag::Int(5))]),
})
.unwrap(),
&[4, 1],
);
assert!(expected[0].surface.is_some());
assert_eq!(terrain.chunk(-2, 5), Some(expected.clone()));
assert_eq!(terrain.chunk(-2, 6), None);
let mut sampler = terrain.clone().sampler();
assert_eq!(sampler.column(-2 * 16 + 3, 5 * 16), expected[3]);
drop(sampler);
drop(terrain);
// Kept in the level's own folder; Bobby's left as it was.
let kept = ServerTerrain::new(own.clone(), None);
assert_eq!(kept.chunk(-2, 5), Some(expected));
assert_eq!(kept.chunk(-3, 5), None);
assert_eq!(std::fs::read(&region).unwrap(), before);
// Another game version's chunk is none.
let old =
ServerTerrain::with_bobby(dir.join("own-old"), None, Some(bobby(&theirs.join("old"))));
assert_eq!(old.chunk(-3, 5), None);
std::fs::remove_dir_all(&dir).unwrap();
}
}
+297 -4
View File
@@ -1,5 +1,6 @@
//! Setup's monitor map and draft window choices. Apply is consumed by the game adapter.
use crate::{
gamepad::Button as PadButton,
graphics_style::STYLE,
hud::{HudAtlas, HudBatch, rgba},
lang,
@@ -48,12 +49,28 @@ pub struct SplitUi {
/// Names: the names in the views' corners are shown (the game sets it), and pressed.
pub names_shown: bool,
pub toggle_names: bool,
/// P1 has a controller (the game sets it), and Give Up Pad pressed: it's offered to join.
pub p1_pad: bool,
pub give_up_pad: bool,
edit: Option<Draft>,
selected: Option<usize>,
/// A tile held by the mouse: whose, and where the pointer is (`None` until it moves).
drag: Option<(usize, Option<(f32, f32)>)>,
/// The screen's size at the last press: where a drag's release lands.
size: (f32, f32),
/// A player picked up with the controller (A), moved with the bumpers.
held: Option<Held>,
/// Dropped or put back: the player whose tile the controller's focus goes to.
refocus: Option<usize>,
/// The screen's size: at the last draw (the options screen sets it) or press.
pub(crate) size: (f32, f32),
}
/// A player picked up with the controller: where it could go, the one picked so far, and the
/// draft with it there (the map shows it).
struct Held {
player: usize,
spots: Vec<(String, Drop)>,
at: Option<usize>,
preview: Option<Draft>,
}
/// Where a dragged player goes: a place (`Spot`), or a place among a window's players.
@@ -73,6 +90,8 @@ enum Target {
Identify,
/// The players' names in their views' corners, on or off.
Names,
/// P1's controller freed and offered to join (handed to a friend).
GiveUpPad,
Revert,
Apply,
Done,
@@ -124,9 +143,113 @@ impl SplitUi {
self.selected = None;
self.apply = None;
self.drag = None;
self.held = None;
}
/// The draft shown: with a held player where the controller put it, else the changes so
/// far, else the arrangement now.
fn draft(&self) -> Option<&Draft> {
self.edit.as_ref().or(self.live.as_ref())
let preview = self.held.as_ref().and_then(|h| h.preview.as_ref());
preview.or(self.edit.as_ref()).or(self.live.as_ref())
}
/// The controller (`pad_menu`), A at `at`: on a player's tile, picks it up. True when it did
/// (the press is taken).
pub fn pad_pick_up(&mut self, at: (f32, f32)) -> bool {
let (w, h) = self.size;
let hit = self
.buttons(w, h)
.into_iter()
.rev()
.find(|([x, y, bw, bh], _, _, active)| *active && ui::hovered(at, *x, *y, *bw, *bh));
let Some((_, _, Target::Player(i), _)) = hit else {
return false;
};
self.selected = Some(i);
let spots = self.stops(i);
self.held = Some(Held {
player: i,
spots,
at: None,
preview: None,
});
true
}
/// The controller while a player is held: the bumpers or left and right cycle where it goes,
/// as when joining (the map shows it there), A drops it there (into the draft), B puts it
/// back. True when the button was taken: every button, while one is held.
pub fn pad_button(&mut self, button: PadButton) -> bool {
let Some(held) = &self.held else {
return false;
};
let player = held.player;
match button {
PadButton::LeftBumper
| PadButton::RightBumper
| PadButton::DPadLeft
| PadButton::DPadRight => {
let n = held.spots.len();
if n == 0 {
return true;
}
let back = matches!(button, PadButton::LeftBumper | PadButton::DPadLeft);
let next = match held.at {
None if back => n - 1,
None => 0,
Some(k) if back => (k + n - 1) % n,
Some(k) => (k + 1) % n,
};
let (player, drop) = (held.player, held.spots[next].1.clone());
let open = self.open_now();
let preview = self.edit.clone().or(self.live.clone()).map(|mut draft| {
match drop {
Drop::At(spot) => draft.place_player(player, spot),
Drop::Place(n, k) => {
draft.place_player(player, Spot::Window(n));
draft.put_at(player, k);
}
}
draft.drop_empty(&open);
draft
});
if let Some(held) = &mut self.held {
held.at = Some(next);
held.preview = preview;
}
}
PadButton::South => {
let held = self.held.take().expect("held");
// One highlight after: the focus on the player where it landed, not selected too.
(self.selected, self.refocus) = (None, Some(held.player));
match held.at.map(|k| held.spots[k].1.clone()) {
Some(Drop::At(spot)) => self.place(held.player, spot),
Some(Drop::Place(n, k)) => self.place_at(held.player, n, k),
None => {}
}
}
PadButton::East => {
(self.selected, self.refocus) = (None, Some(player));
self.held = None;
}
// While one is held, the focus stays put: A drops, it presses nothing else.
_ => {}
}
true
}
/// Whether a player is held (the controller's left and right cycle it).
pub fn holding(&self) -> bool {
self.held.is_some()
}
/// The tile of the player just dropped or put back, for the controller's focus. Taken once.
pub fn take_refocus(&mut self) -> Option<[f32; 4]> {
let i = self.refocus.take()?;
let (w, h) = self.size;
self.buttons(w, h)
.into_iter()
.find(|b| b.2 == Target::Player(i))
.map(|b| b.0)
}
fn map(&self, w: f32, h: f32) -> split_map::Map {
if self.idle {
@@ -270,8 +393,18 @@ impl SplitUi {
Target::Switch,
!self.on || self.names.len() <= 1,
);
// P1's controller handed to a friend: it's offered to join.
let give_up = (
[90.0, 6.0, 80.0, 20.0],
tr("give_up_pad", &[]),
Target::GiveUpPad,
true,
);
if !self.on || self.idle {
let mut out = vec![switch];
if self.on && self.p1_pad {
out.push(give_up);
}
row(&mut out, h - 27.0, vec![done]);
return out;
}
@@ -329,6 +462,9 @@ impl SplitUi {
true,
);
out.extend([switch, names]);
if self.p1_pad {
out.push(give_up);
}
// A square for how 2 (or 3) players share a window, while a window has that many (drawn
// as the layout; the label is for the control socket and the tests).
let mut footer = Vec::new();
@@ -373,6 +509,7 @@ impl SplitUi {
Target::Switch => tr("help.switch", &[]),
Target::Identify => tr("help.identify", &[]),
Target::Names => tr("help.names", &[]),
Target::GiveUpPad => tr("help.give_up_pad", &[]),
Target::Revert if *active => tr("help.revert", &[]),
Target::Apply if *active => tr("help.apply", &[]),
Target::Revert | Target::Apply => tr("help.unchanged", &[]),
@@ -542,7 +679,9 @@ impl SplitUi {
([x, y, bw, bh], label, target, active): &Button,
) {
let (mut x, mut y, mut bw, mut bh) = (*x, *y, *bw, *bh);
let hover = *active && ui::hovered(cursor, x, y, bw, bh);
// While a player is held, only its tile is lit: the controller's pointer stays where
// the pick-up was, over whichever tile is there now.
let hover = *active && self.held.is_none() && ui::hovered(cursor, x, y, bw, bh);
let selected = matches!(target, Target::Player(i) if Some(*i) == self.selected);
if matches!(target, Target::Player(_)) {
(x, y, bw, bh) = (x + 1.0, y + 1.0, (bw - 2.0).max(4.0), (bh - 2.0).max(4.0));
@@ -571,6 +710,16 @@ impl SplitUi {
*active,
hover || selected,
);
// The held player: "< Name >", the arrows lit, for left and right.
if matches!(target, Target::Player(i) if self.held.as_ref().is_some_and(|h| h.player == *i))
{
let (half, cx) = (HudBatch::text_width(atlas, label) / 2.0, x + bw / 2.0);
let ty = (y + (bh - 8.0) / 2.0).floor();
let lit = rgba(255, 255, 85, 255);
let lw = HudBatch::text_width(atlas, "<");
batch.text(atlas, "<", (cx - half - 4.0 - lw).floor(), ty, lit, true);
batch.text(atlas, ">", (cx + half + 4.0).ceil(), ty, lit, true);
}
// A layout's square: its panes, in a 14 x 10 window; Auto a letter. Its name for the
// control socket, which lists widgets by what they say.
if glyph.is_some() {
@@ -601,7 +750,19 @@ impl SplitUi {
buttons: &[Button],
) {
let (w, h) = (batch.width, batch.height);
let held = self.held.as_ref().map(|h| {
let who = self
.names
.get(h.player)
.cloned()
.unwrap_or_else(|| tr("this_player", &[]));
match h.at {
Some(k) => tr("note.held_at", &[&who, &h.spots[k].0]),
None => tr("note.held", &[&who]),
}
});
let note = match self.selected {
_ if held.is_some() => held.unwrap_or_default(),
None => tr("note.select", &[]),
Some(_) if self.edit.is_some() => tr("note.apply", &[]),
Some(i) => tr(
@@ -651,6 +812,10 @@ impl SplitUi {
/// A press at `(x, y)` on a screen `w` x `h`, or the mouse moving with it held (`drag`).
pub fn click(&mut self, (x, y): (f32, f32), (w, h): (f32, f32), drag: bool) -> Click {
self.size = (w, h);
// The mouse takes over from a player held with the controller.
if !drag {
self.held = None;
}
if drag {
if let Some((_, at)) = &mut self.drag {
*at = Some((x, y));
@@ -690,6 +855,7 @@ impl SplitUi {
}
Target::Identify => self.identify = true,
Target::Names => self.toggle_names = true,
Target::GiveUpPad => self.give_up_pad = true,
Target::Revert => self.edit = None,
Target::Apply => {
// Windows emptied in the draft close now, not as players moved.
@@ -1237,6 +1403,133 @@ mod tests {
// Alone: neither.
assert_eq!(labels(&screen(1)).count(), 0);
}
fn centre(ui: &SplitUi, label: &str) -> (f32, f32) {
let [x, y, w, h] = rect(ui, label);
(x + w / 2.0, y + h / 2.0)
}
#[test]
fn a_controller_picks_a_player_up_moves_it_and_drops_it() {
let mut ui = screen(2);
ui.size = SIZE;
assert!(ui.pad_pick_up(centre(&ui, "P2")));
// Nothing moves until the bumpers do; then the map shows it there: the places in Window 1
// first, then a new window.
assert_eq!(ui.draft().unwrap().players, [1, 1]);
for _ in 0..3 {
assert!(ui.pad_button(PadButton::RightBumper));
}
assert_eq!(ui.draft().unwrap().players, [1, 2]);
assert_eq!(ui.edit, None);
// Right again: filling the TV; A drops it there, into the draft.
assert!(ui.pad_button(PadButton::RightBumper));
assert!(ui.pad_button(PadButton::South));
let draft = ui.edit.as_ref().unwrap();
assert_eq!(draft.players, [1, 2]);
assert_eq!(draft.windows[1].placement, Placement::Fill("TV".into()));
// Not held any more: the buttons go back to the menu.
assert!(!ui.pad_button(PadButton::RightBumper));
}
#[test]
fn a_controller_moves_a_player_to_a_place_among_the_others() {
let mut ui = screen(2);
ui.size = SIZE;
assert!(ui.pad_pick_up(centre(&ui, "P2")));
let stops: Vec<String> = ui
.held
.as_ref()
.unwrap()
.spots
.iter()
.map(|s| s.0.clone())
.collect();
assert_eq!(
&stops[..3],
["Window 1 (1 of 2)", "Window 1 (2 of 2)", "New window"]
);
assert!(
!stops.iter().any(|s| s.starts_with("Swap")),
"no swaps: it joins"
);
// RB to the first place: before P1.
assert!(ui.pad_button(PadButton::RightBumper));
assert!(ui.pad_button(PadButton::South));
assert_eq!(ui.edit.as_ref().unwrap().order, [1, 0]);
}
#[test]
fn idle_replaces_preview_but_keeps_the_switch_and_pad_handoff() {
let mut ui = screen(1);
ui.on = true;
ui.idle = true;
ui.p1_pad = true;
assert_eq!(ui.status_lines().unwrap()[0], "Splitscreen idle");
assert!(ui.map(SIZE.0, SIZE.1).2.is_empty());
let buttons = ui.buttons(SIZE.0, SIZE.1);
assert!(buttons.iter().any(|b| b.2 == Target::GiveUpPad));
assert!(
!buttons
.iter()
.any(|b| matches!(b.2, Target::Player(_) | Target::Apply | Target::Revert))
);
press(&mut ui, "Give Up Pad");
assert!(ui.give_up_pad);
ui.idle = false;
assert!(ui.status_lines().is_none());
assert_eq!(ui.map(SIZE.0, SIZE.1).2.len(), 1);
ui.on = false;
assert_eq!(ui.status_lines().unwrap()[0], "Splitscreen is off.");
}
#[test]
fn give_up_pad_shows_only_while_p1_has_one() {
let mut ui = screen(1);
ui.on = true;
let shown = |ui: &SplitUi| {
ui.buttons(SIZE.0, SIZE.1)
.iter()
.any(|b| b.2 == Target::GiveUpPad)
};
assert!(!shown(&ui));
ui.p1_pad = true;
assert!(shown(&ui));
press(&mut ui, "Give Up Pad");
assert!(ui.give_up_pad);
}
#[test]
fn b_puts_a_held_player_back() {
let mut ui = screen(2);
ui.size = SIZE;
assert!(ui.pad_pick_up(centre(&ui, "P2")));
ui.pad_button(PadButton::RightBumper);
assert!(ui.pad_button(PadButton::East));
assert_eq!(ui.edit, None);
assert_eq!(ui.draft().unwrap().players, [1, 1]);
}
#[test]
fn dropped_or_put_back_one_highlight_and_the_focus_follows() {
let mut ui = screen(2);
ui.size = SIZE;
assert!(ui.pad_pick_up(centre(&ui, "P2")));
assert!(ui.pad_button(PadButton::RightBumper)); // to a new window
assert!(ui.pad_button(PadButton::South));
// Not selected (the focus is its highlight), and the focus goes to its new tile.
assert_eq!(ui.selected, None);
assert_eq!(ui.take_refocus(), Some(rect(&ui, "P2")));
assert_eq!(ui.take_refocus(), None);
// Put back: the same.
assert!(ui.pad_pick_up(centre(&ui, "P2")));
assert!(ui.pad_button(PadButton::East));
assert_eq!(
(ui.selected, ui.take_refocus()),
(None, Some(rect(&ui, "P2")))
);
}
#[test]
fn locale_switch_and_pack_overrides_keep_the_same_draft() {
// The language table is process-wide. Run this test alone in a child so
+13 -11
View File
@@ -1,13 +1,7 @@
//! Shared nearest-sampled sprite array for block and item textures.
use crate::Result;
use horizon_assets::animation::{AnimationClip, AnimationMetadata};
use std::{
collections::BTreeMap,
fs::{self, File},
io::BufReader,
path::Path,
sync::Arc,
};
use std::{collections::BTreeMap, path::Path, sync::Arc};
#[derive(Clone)]
pub struct SpriteAnimation {
@@ -427,7 +421,9 @@ impl Sprites {
per_tile: bool,
) -> Result<Vec<Option<u32>>> {
let path = crate::atlas_sources::texture_path(root, name, "textures");
let metadata = match fs::read_to_string(path.with_extension("png.mcmeta")) {
let metadata = match horizon_core::prepared_files::read_to_string(
&path.with_extension("png.mcmeta"),
) {
Ok(text) => Some(serde_json::from_str::<
BTreeMap<String, Box<serde_json::value::RawValue>>,
>(&text)?),
@@ -536,7 +532,9 @@ impl Sprites {
Self::decode_file(&path, name)
}
fn decode_file(path: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
let metadata = match fs::read_to_string(path.with_extension("png.mcmeta")) {
let metadata = match horizon_core::prepared_files::read_to_string(
&path.with_extension("png.mcmeta"),
) {
Ok(text) => Some(serde_json::from_str::<
BTreeMap<String, Box<serde_json::value::RawValue>>,
>(&text)?),
@@ -694,9 +692,12 @@ impl Sprites {
}
}
/// A PNG's width, height and pixels as RGBA.
/// A PNG's width, height and pixels as RGBA. From the prepared data file
/// when it holds the texture: no file opened per texture, nor for its absent
/// LabPBR maps.
fn read_rgba(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
let mut decoder = png::Decoder::new(BufReader::new(File::open(path)?));
let bytes = horizon_core::prepared_files::read(path)?;
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
decoder.ignore_checksums(true);
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info()?;
@@ -792,6 +793,7 @@ pub(crate) fn sprite_mipmap_limit(requested: u32, size: u32, minimum: u32, avail
#[cfg(test)]
mod tests {
use super::*;
use std::fs::{self, File};
#[test]
fn the_texture_budget_picks_the_largest_side_that_fits() {
// 1300 layers: vanilla's count, about.
+34 -76
View File
@@ -185,6 +185,13 @@ pub trait LodSampler: Send {
fn reduced_column(&mut self, x: i32, z: i32, _width: i32) -> crate::lod::Column {
self.column(x, z)
}
/// Whether a column since the last call was left empty because its data
/// is still on its way (a BlueMap tile downloading, nothing coarser to
/// stand in): that sample isn't drawn, what it would replace is
/// (`Lod::finish_any_sample`), and it is taken again later.
fn waited(&mut self) -> bool {
false
}
/// `side` x `side` columns from (`x`, `z`), `stride` blocks apart; none
/// when sampling failed and should be tried again later.
fn grid(&mut self, x: i32, z: i32, stride: i32, side: usize) -> Vec<crate::lod::Column> {
@@ -203,6 +210,11 @@ pub trait LodSampler: Send {
}
pub trait LodSource: Send + Sync {
fn sampler(self: Arc<Self>) -> Box<dyn LodSampler>;
/// Whether its samplers' `observed` chunks become its own (a server's
/// kept terrain): tiles sampled without one are sampled again.
fn keeps_observed(&self) -> bool {
false
}
}
struct GeneratorSampler<G: ChunkGenerator> {
generator: Arc<G>,
@@ -427,8 +439,8 @@ pub struct Streaming {
decoration_order: DecorationOrder,
order_area: Option<((i32, i32), i32)>,
/// What the queued jobs' priorities were computed from (`requeue`): the
/// centre, the loading screen, the band, and the extra areas.
queue_basis: (((i32, i32), bool, Option<Band>), Vec<((i32, i32), i32)>),
/// centre, the band, and the extra areas.
queue_basis: (((i32, i32), Option<Band>), Vec<((i32, i32), i32)>),
finishing: FxHashSet<(i32, i32)>,
decorates: bool,
/// `ChunkGenerator::decorates_beside_final`.
@@ -460,8 +472,6 @@ pub struct Streaming {
pub forced: HashSet<(i32, i32)>,
/// No work is scheduled while paused (title screen).
pub paused: bool,
/// The level loading screen is up: work goes ring by ring (`Ranges::rings`).
pub loading: bool,
/// Dimension flags: sky light, Nether face shading.
pub sky_light: bool,
pub nether_shading: bool,
@@ -532,9 +542,9 @@ struct GenerationScan {
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)>,
/// What `plan` was made for: the ranges and decoration; the chunk
/// map's revision is `chunk_revision`.
plan_basis: Option<bool>,
}
impl GenerationScan {
fn same_ranges(&self, ranges: &Ranges<'_>) -> bool {
@@ -558,9 +568,7 @@ impl GenerationScan {
/// 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)
{
if self.plan_basis != Some(input.decorates) || !self.same_ranges(&input.ranges) {
return false;
}
let revision = input.world.chunks.membership_revision();
@@ -594,7 +602,6 @@ struct GenerationInputs<'a> {
finishing: &'a FxHashSet<(i32, i32)>,
decorates: bool,
remote: bool,
loading: bool,
}
impl GenerationScan {
/// None means the scan was skipped; Some preserves the original candidate
@@ -669,7 +676,7 @@ impl GenerationScan {
{
continue;
}
wanted.push((input.ranges.priority(x, z, input.loading), x, z));
wanted.push((input.ranges.priority(x, z), x, z));
}
}
// Forced chunks and their generation neighbourhood: the ring that
@@ -691,7 +698,7 @@ impl GenerationScan {
{
continue;
}
wanted.push((input.ranges.priority(x, z, input.loading), x, z));
wanted.push((input.ranges.priority(x, z), x, z));
}
}
}
@@ -711,7 +718,7 @@ impl GenerationScan {
// A bounded batch may leave candidates behind: the next calls
// take them from the plan as capacity opens.
self.exhausted = false;
self.plan_basis = Some((input.loading, input.decorates));
self.plan_basis = Some(input.decorates);
}
wanted.sort_unstable();
wanted.dedup();
@@ -982,7 +989,6 @@ impl Streaming {
radius: 8,
forced: HashSet::new(),
paused: false,
loading: false,
sky_light: true,
nether_shading: false,
shader_pack_lighting: false,
@@ -1159,7 +1165,7 @@ impl Streaming {
/// render distance 32 nothing was decorated until every proto chunk had
/// been generated.
fn requeue(&mut self) {
let basis = (self.center, self.loading, self.band);
let basis = (self.center, self.band);
if self.queue_basis.0 == basis && self.queue_basis.1 == self.extra {
return;
}
@@ -1297,7 +1303,7 @@ impl Streaming {
}
/// The order jobs around the player run in, lowest first.
fn distance(&self, x: i32, z: i32) -> i64 {
self.ranges().priority(x, z, self.loading)
self.ranges().priority(x, z)
}
/// The 3x3 a light job reads: the finished columns, and for a neighbour
/// that will not finish (past the area), its decorated proto chunk, as
@@ -1958,7 +1964,6 @@ impl Streaming {
finishing: &self.finishing,
decorates: self.decorates,
remote: self.remote,
loading: self.loading,
};
if let Some(wanted) = self.generation_scan.candidates(input, workers * 3) {
for (_, x, z) in wanted {
@@ -2445,33 +2450,14 @@ impl ExtraCover {
}
}
impl Ranges<'_> {
/// `distance`, or `rings` while the level loading screen is up.
fn priority(&self, x: i32, z: i32, loading: bool) -> i64 {
let own = if loading {
self.rings(x, z)
} else {
self.distance(x, z)
};
/// `distance`, or the band's nearer priority close to it.
fn priority(&self, x: i32, z: i32) -> i64 {
let own = self.distance(x, z);
match self.band {
Some(band) if band.within(x, z, 4) => own.min(band.priority(x, z)),
_ => own,
}
}
/// Ring by ring: the Chebyshev distance to the nearest centre first, then
/// the squared distance. Vanilla's chunk tasks run by ticket level, which
/// spreads one level per ring of chunks (`ChunkTaskPriorityQueue`), so
/// `LevelLoadingScreen`'s chunk map fills squares around the spawn.
fn rings(&self, x: i32, z: i32) -> i64 {
let d = |c: (i32, i32)| {
let (dx, dz) = (i64::from(x - c.0), i64::from(z - c.1));
let ring = dx.abs().max(dz.abs());
(ring * ring) << 20 | (dx * dx + dz * dz)
};
self.extra
.iter()
.map(|(c, _)| d(*c))
.fold(d(self.center), i64::min)
}
fn distance(&self, x: i32, z: i32) -> i64 {
let d = |c: (i32, i32)| {
let (dx, dz) = (i64::from(x - c.0), i64::from(z - c.1));
@@ -2855,7 +2841,7 @@ mod tests {
}
#[test]
fn queued_jobs_follow_the_centre_and_the_loading_screen() {
fn queued_jobs_follow_the_centre() {
let forced = HashSet::new();
let ranges = |center| Ranges {
center,
@@ -2872,13 +2858,13 @@ mod tests {
order,
job: Job::Generate(x, z),
};
let loading = ranges((0, 0));
// Both queued around an earlier centre, where the farther job came first.
let earlier = ranges((40, 0));
let current = ranges((0, 0));
let queued = || {
BinaryHeap::from(vec![
// Queued while the loading screen was up: ring order.
generate(3, 1, loading.priority(3, 1, true) * 4, 1),
// Queued after it closed: plain distances, far below any ring past the first.
generate(20, 0, loading.priority(20, 0, false) * 4, 2),
generate(3, 1, earlier.priority(3, 1) * 4, 1),
generate(20, 0, earlier.priority(20, 0) * 4, 2),
Queued {
priority: i64::MIN,
at: None,
@@ -2899,12 +2885,12 @@ mod tests {
"the stale order"
);
let mut queue = queued();
rank_again(&mut queue, |x, z| loading.priority(x, z, false));
rank_again(&mut queue, |x, z| current.priority(x, z));
assert_eq!(pops(queue), [None, Some((3, 1)), Some((20, 0))]);
// The player moved: the job near the new centre goes first.
let mut queue = BinaryHeap::from(vec![generate(3, 1, 0, 1), generate(20, 0, 1, 2)]);
let moved = ranges((19, 0));
rank_again(&mut queue, |x, z| moved.priority(x, z, false));
rank_again(&mut queue, |x, z| moved.priority(x, z));
assert_eq!(queue.pop().and_then(|q| q.at), Some((20, 0)));
}
@@ -3088,7 +3074,6 @@ mod tests {
finishing: &self.finishing,
decorates: self.decorates,
remote: self.remote,
loading: false,
}
}
fn fill(&mut self) {
@@ -3508,33 +3493,6 @@ mod tests {
/// from the save) and is not unloaded right after: either way it would be
/// asked for and loaded again on the next frame, forever (8 such columns
/// after a teleport at render distance 16 held 8 of the in-flight slots).
/// #770: while the loading screen is up, chunks go ring by ring (a
/// square front, as vanilla's ticket levels), nearest first in a ring.
#[test]
fn loading_goes_ring_by_ring() {
let forced = HashSet::new();
let r = Ranges {
center: (2, -1),
radius: 12,
extra: &[],
forced: &forced,
cover: None,
band: None,
};
let at = |dx: i32, dz: i32| (2 + dx, -1 + dz);
let key = |(x, z): (i32, i32), loading| r.priority(x, z, loading);
// The corner of ring 3 comes before the edge of ring 4 only while loading.
assert!(key(at(3, 3), true) < key(at(0, 4), true));
assert!(key(at(3, 3), false) > key(at(0, 4), false));
// Inside a ring, nearest first; rings never interleave.
assert!(key(at(0, 3), true) < key(at(2, -3), true));
assert!(key(at(-3, -3), true) < key(at(4, 0), true));
for dx in -20..=20 {
for dz in -20..=20 {
assert_eq!(key(at(dx, dz), false), r.distance(2 + dx, -1 + dz));
}
}
}
#[test]
fn requested_columns_stay_loaded() {
let forced = HashSet::new();
+5 -4
View File
@@ -176,7 +176,7 @@ impl TrimTexture {
let Some(palette) = &self.palette else {
return Some((w, h, pixels));
};
let base = fs::read(path.with_extension("png.mcmeta"))
let base = horizon_core::prepared_files::read(&path.with_extension("png.mcmeta"))
.ok()
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
.and_then(|m| {
@@ -241,9 +241,10 @@ pub fn slot_size(root: &Path) -> (u32, u32) {
/// The width and height in a PNG's IHDR chunk.
fn png_size(path: &Path) -> Option<(u32, u32)> {
use std::io::Read;
let mut head = [0u8; 24];
fs::File::open(path).ok()?.read_exact(&mut head).ok()?;
let head: [u8; 24] = horizon_core::prepared_files::read_head(path, 24)
.ok()?
.try_into()
.ok()?;
let be = |i: usize| u32::from_be_bytes(head[i..i + 4].try_into().unwrap());
(&head[12..16] == b"IHDR").then(|| (be(16), be(20)))
}
+4
View File
@@ -130,6 +130,10 @@ path = "src/main.rs"
name = "launch_api"
path = "tests/launch_api.rs"
[[test]]
name = "mod_api_hooks"
path = "tests/mod_api_hooks.rs"
[lints]
workspace = true
@@ -207,8 +207,6 @@
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Pro Bild an die GPU gesendete Mesh-Daten. Weniger ist flüssiger beim Laden.",
"Frame rate and frame times over the game, with a graph.": "Bildrate und Bildzeiten über dem Spiel, mit Diagramm.",
"horizon.multiplayer.finding_friend": "Die Welt deines Freundes wird gesucht …",
"horizon.loading.opening_world": "Welt wird geöffnet",
"horizon.loading.cancelling_world": "Wird abgebrochen",
"horizon.title.not_affiliated": "Nicht mit Mojang oder Microsoft verbunden",
"horizon.extras.header.lighting": "Beleuchtung",
"horizon.extras.header.third_person": "Dritte Person",
@@ -34,6 +34,7 @@
"horizon.pages.extras": "Horizon Extras",
"horizon.pages.settings": "Horizon Settings",
"horizon.pages.agent": "AI Agent",
"horizon.split.leave": "Leave",
"horizon.pages.split_settings": "Splitscreen Settings",
"horizon.pages.split_profiles": "Who's playing?",
"horizon.pages.split_setup": "Splitscreen Setup",
@@ -211,8 +212,6 @@
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Mesh data sent to the GPU per frame. Lower is smoother while loading.",
"Frame rate and frame times over the game, with a graph.": "Frame rate and frame times over the game, with a graph.",
"horizon.multiplayer.finding_friend": "Finding your friend's world...",
"horizon.loading.opening_world": "Opening world",
"horizon.loading.cancelling_world": "Cancelling world",
"horizon.multiplayer.not_signed_in": "This server is in online mode. Sign in with your Microsoft account in the Horizon launcher to join it.",
"horizon.multiplayer.invalid_address": "Invalid server address: %s",
"horizon.multiplayer.connected_friend_direct": "Connected straight to your friend...",
@@ -362,6 +361,21 @@
"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.",
"horizon.splitscreen.setup.give_up_pad": "Give Up Pad",
"horizon.splitscreen.prompt.controller_connected": "Controller connected",
"horizon.splitscreen.prompt.controller_disconnected": "Controller disconnected",
"horizon.splitscreen.prompt.controller_disconnected.hint": "Reconnect it, or press a button on another",
"horizon.splitscreen.prompt.join.one": "%s wants to join",
"horizon.splitscreen.prompt.join.many": "%s controllers want to join",
"horizon.splitscreen.prompt.join.yes": "Join",
"horizon.splitscreen.prompt.join.as_p1": "Play as P1",
"horizon.splitscreen.prompt.join.no": "No",
"horizon.splitscreen.prompt.lost": "%s: controller disconnected",
"horizon.splitscreen.prompt.lost.use_another": "Use another",
"horizon.splitscreen.prompt.lost.leave": "Leave",
"horizon.splitscreen.setup.help.give_up_pad": "Your controller stops driving you and is offered to join (A).",
"horizon.splitscreen.setup.note.held_at": "%s to %s: A drops it here, B puts it back.",
"horizon.splitscreen.setup.note.held": "%s picked up: left/right picks where, A drops, B puts it back.",
"Culling": "Culling",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Hides models behind opaque blocks. Async checks off-thread; entities tick.",
"Block Entities": "Block Entities",
@@ -389,6 +403,10 @@
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Shows a made-up frame between each two real ones: smoother, a little more lag.",
"Shadow Cache": "Shadow Cache",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Reuses the pack shadow map when sun, view and world match. Off by default.",
"Find BlueMap": "Find BlueMap",
"On servers, finds their BlueMap web map for far terrain. Next join.": "On servers, finds their BlueMap web map for far terrain. Next join.",
"BlueMap Radius": "BlueMap Radius",
"How far around you BlueMap tiles are downloaded. Next join.": "How far around you BlueMap tiles are downloaded. Next join.",
"Synchronous": "Synchronous",
"Asynchronous": "Asynchronous",
"Solid": "Solid",
@@ -198,8 +198,6 @@
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Datos de malla enviados a la GPU por fotograma. Menos es más fluido durante la carga.",
"Frame rate and frame times over the game, with a graph.": "Fotogramas por segundo y tiempos de fotograma sobre el juego, con un gráfico.",
"horizon.multiplayer.finding_friend": "Buscando el mundo de tu amigo…",
"horizon.loading.opening_world": "Abriendo el mundo",
"horizon.loading.cancelling_world": "Cancelando",
"horizon.multiplayer.not_signed_in": "Este servidor está en modo en línea. Inicia sesión con tu cuenta de Microsoft en el launcher de Horizon para unirte.",
"horizon.multiplayer.invalid_address": "Dirección de servidor no válida: %s",
"horizon.multiplayer.connected_friend_direct": "Conectado directamente con tu amigo…",
@@ -207,8 +207,6 @@
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Données de maillage vers le GPU par image. Moins : plus fluide.",
"Frame rate and frame times over the game, with a graph.": "Fréquence et temps d'image par-dessus le jeu, avec un graphique.",
"horizon.multiplayer.finding_friend": "Recherche du monde de votre ami...",
"horizon.loading.opening_world": "Ouverture du monde",
"horizon.loading.cancelling_world": "Annulation en cours",
"horizon.title.not_affiliated": "Non affilié à Mojang ou à Microsoft",
"horizon.extras.header.lighting": "Éclairage",
"horizon.extras.header.third_person": "Troisième personne",
@@ -347,6 +345,21 @@
"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é.",
"horizon.splitscreen.setup.give_up_pad": "Céder la manette",
"horizon.splitscreen.prompt.controller_connected": "Manette connectée",
"horizon.splitscreen.prompt.controller_disconnected": "Manette déconnectée",
"horizon.splitscreen.prompt.controller_disconnected.hint": "Reconnectez-la, ou appuyez sur un bouton d'une autre",
"horizon.splitscreen.prompt.join.one": "%s veut rejoindre",
"horizon.splitscreen.prompt.join.many": "%s manettes veulent rejoindre",
"horizon.splitscreen.prompt.join.yes": "Rejoindre",
"horizon.splitscreen.prompt.join.as_p1": "Jouer en J1",
"horizon.splitscreen.prompt.join.no": "Non",
"horizon.splitscreen.prompt.lost": "%s : manette déconnectée",
"horizon.splitscreen.prompt.lost.use_another": "Utiliser une autre",
"horizon.splitscreen.prompt.lost.leave": "Quitter",
"horizon.splitscreen.setup.help.give_up_pad": "Votre manette ne vous contrôle plus et peut rejoindre la partie (A).",
"horizon.splitscreen.setup.note.held_at": "%s vers %s : A le dépose ici, B le remet en place.",
"horizon.splitscreen.setup.note.held": "%s est pris : gauche/droite choisit où, A dépose, B remet en place.",
"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",
@@ -359,6 +372,10 @@
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Insère une image calculée entre deux vraies : plus fluide, un peu plus de latence.",
"Shadow Cache": "Cache des ombres",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Réutilise la carte d'ombres du pack si soleil, vue et monde sont identiques. Désactivé par défaut.",
"Find BlueMap": "Trouver BlueMap",
"On servers, finds their BlueMap web map for far terrain. Next join.": "Sur les serveurs, trouve leur carte web BlueMap pour le terrain lointain. À la prochaine connexion.",
"BlueMap Radius": "Rayon BlueMap",
"How far around you BlueMap tiles are downloaded. Next join.": "Distance autour de vous jusqu'où les tuiles BlueMap sont téléchargées. À la prochaine connexion.",
"Synchronous": "Synchrone",
"Asynchronous": "Asynchrone",
"Solid": "Opaque",
@@ -207,8 +207,6 @@
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Dane siatki wysyłane do GPU na klatkę. Mniej to płynniej podczas ładowania.",
"Frame rate and frame times over the game, with a graph.": "Liczba klatek i czasy klatek nad grą, z wykresem.",
"horizon.multiplayer.finding_friend": "Szukanie świata znajomego…",
"horizon.loading.opening_world": "Otwieranie świata",
"horizon.loading.cancelling_world": "Anulowanie",
"horizon.multiplayer.not_signed_in": "Ten serwer działa w trybie online. Zaloguj się kontem Microsoft w launcherze Horizon, aby dołączyć.",
"horizon.multiplayer.invalid_address": "Nieprawidłowy adres serwera: %s",
"horizon.multiplayer.connected_friend_direct": "Połączono bezpośrednio ze znajomym…",
@@ -197,8 +197,6 @@
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Данных меша в видеокарту за кадр. Меньше — плавнее во время загрузки.",
"Frame rate and frame times over the game, with a graph.": "Частота кадров и их время поверх игры, с графиком.",
"horizon.multiplayer.finding_friend": "Ищем мир друга…",
"horizon.loading.opening_world": "Открытие мира",
"horizon.loading.cancelling_world": "Отмена",
"horizon.multiplayer.not_signed_in": "Этот сервер работает в онлайн-режиме. Войдите с помощью аккаунта Microsoft в лаунчере Horizon, чтобы присоединиться.",
"horizon.multiplayer.invalid_address": "Недействительный адрес сервера: %s",
"horizon.multiplayer.connected_friend_direct": "Установлено прямое соединение с другом…",
@@ -207,8 +207,6 @@
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Дані сітки до GPU за кадр. Менше — плавніше завантаження.",
"Frame rate and frame times over the game, with a graph.": "Частота та час кадрів поверх гри, з графіком.",
"horizon.multiplayer.finding_friend": "Шукаємо світ друга…",
"horizon.loading.opening_world": "Відкриття світу",
"horizon.loading.cancelling_world": "Скасування",
"horizon.multiplayer.not_signed_in": "Цей сервер працює в онлайн-режимі. Увійдіть за допомогою акаунта Microsoft у лаунчері Horizon, щоб приєднатися.",
"horizon.multiplayer.invalid_address": "Недійсна адреса сервера: %s",
"horizon.multiplayer.connected_friend_direct": "Пряме з'єднання з другом встановлено…",
@@ -343,5 +341,104 @@
"horizon.pages.open.split_setup": "Розділений екран…",
"horizon.pages.split_settings": "Налаштування розділеного екрана",
"horizon.pages.split_setup": "Розділений екран",
"horizon.pages.split_setup.tooltip": "Гравці на цьому комп’ютері: геймпади можуть долучитися до вашого світу."
"horizon.pages.split_setup.tooltip": "Гравці на цьому комп’ютері: геймпади можуть долучитися до вашого світу.",
"horizon.split.leave": "Вийти",
"horizon.splitscreen.setup.monitor": "Монітор %s",
"horizon.splitscreen.setup.monitor_unknown": "монітор",
"horizon.splitscreen.setup.window": "Вікно %s",
"horizon.splitscreen.setup.new_window": "Нове вікно",
"horizon.splitscreen.setup.fill_monitor": "На весь %s",
"horizon.splitscreen.setup.done": "Готово",
"horizon.splitscreen.setup.switch": "%s: %s",
"horizon.splitscreen.setup.on": "Увімк.",
"horizon.splitscreen.setup.off": "Вимк.",
"horizon.splitscreen.setup.player": "Гравець %s",
"horizon.splitscreen.setup.revert": "Скасувати",
"horizon.splitscreen.setup.apply": "Застосувати",
"horizon.splitscreen.setup.warning.empty_main": "У вікні 1 ніхто не грає: воно сховається",
"horizon.splitscreen.setup.warning.empty_close": "У вікні %s ніхто не грає: воно закриється після «Застосувати»",
"horizon.splitscreen.setup.warning.empty_new": "У вікні %s ніхто не грає: воно не відкриється",
"horizon.splitscreen.setup.warning.on_main": "Вікно %s займає монітор вікна 1: його гравці грають у вікні 1",
"horizon.splitscreen.setup.warning.unplugged": "Монітор вікна %s не під’єднано: його гравці грають у вікні 1",
"horizon.splitscreen.setup.status.off": "Розділений екран вимкнено.",
"horizon.splitscreen.setup.status.enable_hint": "Увімкніть його, а потім у світі натисніть кнопку на іншому контролері.",
"horizon.splitscreen.setup.status.join_hint": "Натисніть A, щоб приєднатися, і виберіть профіль.",
"horizon.splitscreen.setup.status.idle": "Розділений екран очікує",
"horizon.splitscreen.setup.status.alone": "Інших локальних гравців немає.",
"horizon.splitscreen.setup.status.solo_controls": "Керування й віддача контролера для одного гравця не змінюються.",
"horizon.splitscreen.setup.window_empty": "Вікно %s: порожнє",
"horizon.splitscreen.setup.note.select": "Клацніть гравця або перетягніть його на вікно чи монітор.",
"horizon.splitscreen.setup.note.apply": "«Застосувати» — грати так, «Скасувати» — повернути як було.",
"horizon.splitscreen.setup.note.place": "Де має грати %s?",
"horizon.splitscreen.setup.this_player": "цей гравець",
"horizon.splitscreen.setup.switch_disabled": "Розділений екран вимкнеться, щойно інші вийдуть (через їхнє меню паузи).",
"horizon.splitscreen.setup.window_place": "Вікно %s (%s із %s)",
"horizon.splitscreen.setup.names": "Імена",
"horizon.splitscreen.setup.identify": "Позначити",
"horizon.splitscreen.setup.layout.two": "Удвох: %s",
"horizon.splitscreen.setup.layout.three": "Утрьох: %s",
"horizon.splitscreen.setup.layout.two.0": "Авто",
"horizon.splitscreen.setup.layout.two.1": "Поруч",
"horizon.splitscreen.setup.layout.two.2": "Згори й знизу",
"horizon.splitscreen.setup.layout.three.0": "Один широкий згори",
"horizon.splitscreen.setup.layout.three.1": "Один високий ліворуч",
"horizon.splitscreen.setup.layout.three.2": "Поруч",
"horizon.splitscreen.setup.help.player": "Клацніть або перетягніть %s, щоб перемістити.",
"horizon.splitscreen.setup.help.layout_auto": "%s (поруч, коли вікно широке). Натисніть, щоб змінити.",
"horizon.splitscreen.setup.help.layout": "%s. Натисніть, щоб змінити.",
"horizon.splitscreen.setup.help.switch": "Розділений екран на цьому комп’ютері: увімкнено чи вимкнено.",
"horizon.splitscreen.setup.help.identify": "Кожен гравець бачить у своєму виді, в якому він вікні.",
"horizon.splitscreen.setup.help.names": "Ім’я кожного гравця в кутку його виду.",
"horizon.splitscreen.setup.help.revert": "Скасувати ще не застосовані зміни.",
"horizon.splitscreen.setup.help.apply": "Грати так, як показує схема.",
"horizon.splitscreen.setup.help.unchanged": "Поки нічого не змінено.",
"horizon.splitscreen.setup.give_up_pad": "Віддати контролер",
"horizon.splitscreen.prompt.controller_connected": "Контролер під’єднано",
"horizon.splitscreen.prompt.controller_disconnected": "Контролер від’єднано",
"horizon.splitscreen.prompt.controller_disconnected.hint": "Під’єднайте його знову або натисніть кнопку на іншому",
"horizon.splitscreen.prompt.join.one": "%s хоче приєднатися",
"horizon.splitscreen.prompt.join.many": "Контролерів, що хочуть приєднатися: %s",
"horizon.splitscreen.prompt.join.yes": "Приєднатися",
"horizon.splitscreen.prompt.join.as_p1": "Грати як Г1",
"horizon.splitscreen.prompt.join.no": "Ні",
"horizon.splitscreen.prompt.lost": "%s: контролер від’єднано",
"horizon.splitscreen.prompt.lost.use_another": "Узяти інший",
"horizon.splitscreen.prompt.lost.leave": "Вийти",
"horizon.splitscreen.setup.help.give_up_pad": "Ваш контролер більше не керує вами, і його можна взяти, щоб приєднатися (A).",
"horizon.splitscreen.setup.note.held_at": "%s → %s: A — поставити тут, B — повернути назад.",
"horizon.splitscreen.setup.note.held": "%s узято: ліворуч/праворуч — вибрати місце, A — поставити, B — повернути назад.",
"Block Entities": "Блокові сутності",
"Shulker Boxes": "Шалкерові коробки",
"Reuses settled boxes; lid movement stays animated.": "Повторно використовує нерухомі коробки; рух кришки лишається анімованим.",
"Signs": "Таблички",
"Reuses sign text until text, light, or font changes.": "Повторно використовує текст табличок, доки не зміняться текст, світло чи шрифт.",
"Decorated Pots": "Оздоблені горщики",
"Reuses still pots; break wobble and sherd changes redraw.": "Повторно використовує нерухомі горщики; хитання від удару й зміна черепків перемальовуються.",
"Bells": "Дзвони",
"Reuses still bells; ringing stays animated.": "Повторно використовує нерухомі дзвони; дзвін лишається анімованим.",
"Chests": "Скрині",
"Reuses closed chests; moving lids stay animated.": "Повторно використовує закриті скрині; рухомі кришки лишаються анімованими.",
"Banners": "Стяги",
"Reuses poles; flags and pattern layers still update.": "Повторно використовує держаки; полотнища й шари візерунків оновлюються.",
"Copper Golem Statues": "Статуї мідних ґолемів",
"Reuses statue geometry until its block state changes.": "Повторно використовує геометрію статуї, доки не зміниться стан її блока.",
"Find BlueMap": "Шукати BlueMap",
"On servers, finds their BlueMap web map for far terrain. Next join.": "На серверах шукає їхню вебмапу BlueMap для далекого рельєфу. З наступного входу.",
"BlueMap Radius": "Радіус BlueMap",
"How far around you BlueMap tiles are downloaded. Next join.": "Як далеко навколо вас завантажуються плитки BlueMap. З наступного входу.",
"%s nits": "%s ніт",
"Auto (%s nits)": "Авто (%s ніт)",
"%.2f nits": "%.2f ніт",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Приховує моделі за непрозорими блоками. Сутності оновлюються. Вимкнено з власними наборами ресурсів.",
"horizon.extras.repro_keys": "Клавіші запису",
"horizon.extras.repro_keys.tooltip": "F3+R починає або зупиняє запис для відтворення помилки, F3+E зберігає його пакет. Вимкнено за замовчуванням: запис копіює світ.",
"horizon.agent.record_session": "Почати запис сесії",
"horizon.agent.record_stop": "Зупинити запис сесії",
"horizon.agent.export_repro": "Експортувати пакет відтворення",
"horizon.repro.started": "Запис почато; F3+R зупиняє його й зберігає пакет.",
"horizon.repro.stopped": "Запис зупинено: %s",
"horizon.repro.bundle": "Пакет відтворення: %s",
"horizon.repro.error": "Відтворення: %s",
"horizon.repro.no_recording": "Поки немає запису для експорту.",
"horizon.repro.replay_started": "Відтворення почато: %s"
}
@@ -201,8 +201,6 @@
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "每帧发送给 GPU 的网格数据。越低加载时越流畅。",
"Frame rate and frame times over the game, with a graph.": "游戏运行时的帧率与帧时间,附带图表。",
"horizon.multiplayer.finding_friend": "正在查找好友的世界…",
"horizon.loading.opening_world": "正在打开世界",
"horizon.loading.cancelling_world": "正在取消",
"horizon.multiplayer.not_signed_in": "该服务器为在线模式服务器。请先在 Horizon 启动器中登录 Microsoft 账户再加入。",
"horizon.multiplayer.invalid_address": "无效的服务器地址:%s",
"horizon.multiplayer.connected_friend_direct": "已直连好友的世界…",
@@ -5,7 +5,7 @@
//!
//! 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]
//! [--threads 1,2,4,8] [--period-ms 2] [--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
@@ -16,8 +16,7 @@
//! 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.
//! server thread runs one per 50 ms tick and pumps between ticks).
//!
//! `--simulate PROTO,DECORATE,FINISH` (milliseconds) swaps the generator for
//! one whose jobs sleep that long and make empty columns: what is left is the
@@ -117,13 +116,11 @@ fn run<G: ChunkGenerator>(
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)))
@@ -215,7 +212,6 @@ fn main() -> Result<(), Error> {
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()
@@ -291,7 +287,7 @@ fn main() -> Result<(), Error> {
render,
});
let terrain = || VanillaTerrain::new(&root.join("worldgen"), assets.registry, &biomes, seed);
let go = |center, radius, n, loading| -> Result<Run, Error> {
let go = |center, radius, n| -> Result<Run, Error> {
match &simulate {
Some(ms) => {
let ms = |i: usize| Duration::from_secs_f64(ms[i] / 1e3);
@@ -300,18 +296,18 @@ fn main() -> Result<(), Error> {
decorate: ms(1),
finish: ms(2),
};
run(generator, &context, center, radius, n, period, loading)
run(generator, &context, center, radius, n, period)
}
None => run(terrain()?, &context, center, radius, n, period, loading),
None => run(terrain()?, &context, center, radius, n, period),
}
};
// Warm the shared generator data and the code away from the measured area.
go((center.0 + 4000, center.1), 2, n, false)?;
go((center.0 + 4000, center.1), 2, n)?;
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 r = go(center, radius, n)?;
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, \
@@ -116,7 +116,7 @@ fn entity_state(mob: &mut Mob, species: Species, case: &Value) {
for (i, flags) in ticks.iter().enumerate() {
mob.age = i as u32 + 1;
bat::set_resting(mob, flags.as_i64().unwrap_or(0) & 1 != 0);
if flag("@batInFrame") {
if flag("@batServerTick") {
bat::tick(mob);
} else {
bat::setup_animation_states(mob);
@@ -1171,6 +1171,7 @@ pub fn main() -> horizon_client::Result<()> {
player: player.clone(),
nearest_player: player.as_ref().filter(|p| !p.spectator).map(|p| p.position),
others: $snapshot,
dragon: None,
leash_boats: &[],
others_grid: $grid,
others_index: None,
@@ -1196,6 +1197,7 @@ pub fn main() -> horizon_client::Result<()> {
items: Some(&items),
lid_openers: &lid_openers,
mod_effects: None,
border: None,
}
};
}
+11 -28
View File
@@ -104,22 +104,20 @@ impl Tree {
let Ok(namespaces) = std::fs::read_dir(data) else {
return tree;
};
let mut files = Vec::new();
for ns in namespaces.flatten() {
let root = ns.path().join("advancement");
collect(&root, &root, &ns.file_name().to_string_lossy(), &mut files);
}
// One JSON per id, the packs' over the game's own.
// One JSON per id, the packs' over the game's own. Through `json_folder`: from
// the prepared data file, and read once while a load holds the folder.
let mut json: BTreeMap<String, Value> = BTreeMap::new();
files.sort();
for (id, path) in files {
let Ok(text) = std::fs::read_to_string(&path) else {
for ns in namespaces.flatten() {
let Ok(folder) = horizon_world::json_folder::read(&ns.path().join("advancement"))
else {
continue;
};
let Ok(v) = serde_json::from_str::<Value>(&text) else {
continue;
};
json.insert(id, v);
let ns = ns.file_name().to_string_lossy().into_owned();
for file in &folder.files {
if let Ok(v) = serde_json::from_slice::<Value>(file.bytes().unwrap_or_default()) {
json.insert(format!("{ns}:{}", file.name), v);
}
}
}
for (id, files) in overlay.kind("advancement") {
match serde_json::from_slice::<Value>(horizon_world::loot::PackOverlay::top(files)) {
@@ -249,21 +247,6 @@ impl InventoryIndex {
out
}
}
fn collect(root: &Path, dir: &Path, ns: &str, out: &mut Vec<(String, std::path::PathBuf)>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
collect(root, &p, ns, out);
} else if p.extension().is_some_and(|x| x == "json") {
let rel = p.strip_prefix(root).unwrap_or(&p).with_extension("");
let rel = rel.to_string_lossy().replace('\\', "/");
out.push((format!("{ns}:{rel}"), p));
}
}
}
fn parse(id: &str, v: &Value) -> Option<Advancement> {
let criteria: Vec<Criterion> = v["criteria"]
.as_object()?
+11 -9
View File
@@ -6,7 +6,7 @@
use crate::sound_engine::{AMBIENT, InstanceId, PLAYERS, SoundEngine, SoundInstance, Sounds};
use horizon_core::random::{LegacyRandom, RandomSource};
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path};
use std::{collections::HashMap, path::Path};
/// `AmbientMoodSettings`.
#[derive(Clone, Debug, PartialEq)]
@@ -54,17 +54,19 @@ pub struct AmbientTables {
impl AmbientTables {
pub fn load(root: &Path) -> Self {
let read = |dir: &str| -> HashMap<String, AmbientSounds> {
let Ok(entries) = fs::read_dir(root.join("data/minecraft").join(dir)) else {
let Ok(folder) =
horizon_world::json_folder::read(&root.join("data/minecraft").join(dir))
else {
return HashMap::new();
};
entries
.flatten()
.filter_map(|e| {
let path = e.path();
let name = path.file_stem()?.to_str()?.to_owned();
let json: Value = serde_json::from_slice(&fs::read(&path).ok()?).ok()?;
folder
.files
.iter()
.filter_map(|file| {
let json: Value = serde_json::from_slice(file.bytes().ok()?).ok()?;
let v = &json["attributes"]["minecraft:audio/ambient_sounds"];
(!v.is_null()).then(|| (format!("minecraft:{name}"), AmbientSounds::parse(v)))
(!v.is_null())
.then(|| (format!("minecraft:{}", file.name), AmbientSounds::parse(v)))
})
.collect()
};
+3 -2
View File
@@ -4,6 +4,7 @@
//! when the block goes, and despawning. The tick stops at an entity it hits:
//! the game hurts the entity (`projectile_hit`), then `finish_entity_hit`
//! spends the arrow or turns it back and ends the tick.
use crate::entity_rules::EntityRules;
use crate::{
blocks::BlockFacts,
collisions::SceneCollisions,
@@ -777,9 +778,9 @@ impl Ticker<'_, '_, '_, '_> {
if !self.st.was_touching_water {
self.p.velocity = self.p.velocity.scale(f64::from(0.99f32));
}
if physics && !self.st.in_ground && !self.p.no_gravity {
if physics && !self.st.in_ground {
// Entity.applyGravity: AbstractArrow.getDefaultGravity, none with NoGravity.
self.add_velocity(-0.05);
self.add_velocity(-self.p.get_gravity());
}
// Projectile.tick, then Entity.baseTick's fluid and fire phases.
if !self.st.has_been_shot {
+5 -4
View File
@@ -9,10 +9,10 @@ pub mod arrows;
pub mod block_contact;
pub use horizon_client_render::{
assets, atlas_sources, audio, auth, ban_notice, bidi, biome, block_edit, block_entities,
block_entity_anim, block_entity_render, bluemap, border_render, breaking, brewing, camera,
chat, chunk, chunk_map, cit, cit_armor, cit_assets, cit_glint, clouds, collisions,
command_block, containers, crash, datapack, dynamic_light, effects, environment, extras,
fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui, hdr_calibration, hud,
block_entity_anim, block_entity_render, bluemap, bluemap_detect, border_render, breaking,
brewing, camera, chat, chunk, chunk_map, cit, cit_armor, cit_assets, cit_glint, clouds,
collisions, command_block, containers, crash, datapack, dynamic_light, effects, environment,
extras, fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui, hdr_calibration, hud,
hud_layout, hud_overlays, inventory_effects, java_text, jigsaw_block, json_folder,
level_loading, light, lod, logging, menu_blur, mesher, minimap, mipmap, mod_list,
model_library, models, multiline_edit, native_emissive, options_file, options_fixes,
@@ -59,6 +59,7 @@ pub mod end_gateway;
pub mod entities;
pub mod entity_culling;
pub mod entity_nbt;
pub mod entity_rules;
pub mod extras_hud;
pub mod falling_block;
pub mod fire_render;
-72
View File
@@ -311,10 +311,6 @@ pub struct FixedStep {
/// (`ServerTickRateManager.updateStepTicks` broadcasts each one).
step_changes: u32,
step_requested: i32,
/// In-frame ticks dropped since the last overload warning (`drop_ticks`).
dropped: u32,
/// When the last overload warning was due, on the frame clock.
last_overload_warning: std::time::Duration,
}
impl Default for FixedStep {
@@ -332,8 +328,6 @@ impl Default for FixedStep {
client: false,
step_changes: 0,
step_requested: 0,
dropped: 0,
last_overload_warning: std::time::Duration::ZERO,
}
}
}
@@ -448,41 +442,6 @@ impl FixedStep {
pub fn sprinting(&self) -> bool {
self.manager.is_sprinting()
}
/// Whether a frame that ran its world in the frame, and whose ticks so
/// far took `spent`, may run another one. Vanilla's world ticks on the
/// integrated server's thread, so a slow tick costs its frames nothing:
/// the server falls behind and says so (`MinecraftServer.runServer`'s
/// "Can't keep up!"). In the frame, a world whose ticks take longer than
/// their length falls behind every frame, so every frame ran the client
/// timer's ten: at 57 ms a tick, 1.7 FPS. So a frame stops once its
/// ticks have taken a tick's length: ticks of a few milliseconds keep the
/// full ten, and a world that cannot keep up runs one tick a frame,
/// slower but drawn. A sprint (`/tick sprint`) runs its own frame's worth.
pub fn tick_fits(&self, spent: std::time::Duration) -> bool {
self.sprinting() || spent < self.tick_duration()
}
/// The frame skips its remaining `ticks` (`tick_fits`): the world runs
/// slower than its tick rate. Returns vanilla's overload warning when one
/// is due: a second's worth of ticks behind, at most every ten seconds
/// (`OVERLOADED_THRESHOLD_NANOS`, `OVERLOADED_WARNING_INTERVAL_NANOS`).
pub fn drop_ticks(&mut self, ticks: u32) -> Option<String> {
self.dropped = self.dropped.saturating_add(ticks);
let tick = self.tick_duration();
let behind = tick * self.dropped;
let threshold = std::time::Duration::from_secs(1) + tick * 20;
let interval = std::time::Duration::from_secs(10) + tick * 100;
if behind <= threshold || self.now < self.last_overload_warning + interval {
return None;
}
let warning = format!(
"Can't keep up! Is the server overloaded? Running {}ms or {} ticks behind",
behind.as_millis(),
self.dropped
);
self.dropped = 0;
self.last_overload_warning = self.now;
Some(warning)
}
/// `requestGameToSprint`: true when a sprint was already running.
pub fn start_sprint(&mut self, ticks: u32) -> bool {
self.manager
@@ -554,37 +513,6 @@ impl FixedStep {
mod tests {
use super::*;
#[test]
fn a_frame_stops_ticking_after_a_ticks_length() {
let clock = FixedStep::default();
let ms = std::time::Duration::from_millis;
// Ticks of 4 ms: the ten the timer allows fit in 50 ms.
assert!(clock.tick_fits(ms(4 * 9)));
// Ticks of 40 ms: the second one runs, not a third.
assert!(clock.tick_fits(ms(40)));
assert!(!clock.tick_fits(ms(80)));
// One tick longer than its length still runs, once a frame.
assert!(clock.tick_fits(ms(0)));
assert!(!clock.tick_fits(ms(120)));
}
#[test]
fn dropped_ticks_warn_once_a_second_behind_and_not_again_for_ten() {
let mut clock = FixedStep::default();
let s = std::time::Duration::from_secs;
clock.now = s(20);
assert_eq!(
clock.drop_ticks(40),
None,
"two seconds: not past 1 s + 20 ticks"
);
let warning = clock.drop_ticks(1).expect("past the threshold");
assert!(warning.contains("2050ms or 41 ticks behind"), "{warning}");
clock.now = s(25);
assert_eq!(clock.drop_ticks(100), None, "within ten seconds");
clock.now = s(36);
assert!(clock.drop_ticks(0).unwrap().contains("100 ticks behind"));
}
#[test]
fn timer_carries_fractions_and_caps_nothing_itself() {
let mut t = Timer::new(20.0, 0);
+66 -6
View File
@@ -4,6 +4,7 @@
//! piston that is carrying slime), water buoyancy, pickup delay, 6000-tick despawn,
//! `HoneyBlock.entityInside` when falling against a honey block's side, and
//! `BubbleColumnBlock.entityInside` (lift / drag, including the surface jump).
use crate::entity_rules::EntityRules;
use crate::{
blocks::{BlockFacts, Fluid},
collisions::SceneCollisions,
@@ -135,6 +136,26 @@ impl ItemEnvironmentTick {
self.lava_hurt && (!self.alive || tick.is_multiple_of(10))
}
}
impl crate::entity_rules::EntityRules for ItemEntity {
fn is_no_gravity(&self) -> bool {
self.no_gravity
}
/// `ItemEntity.getDefaultGravity`.
fn get_default_gravity(&self) -> f64 {
0.04
}
}
impl crate::entity_rules::EntityRules for ExperienceOrb {
fn is_no_gravity(&self) -> bool {
self.no_gravity
}
/// `ExperienceOrb.getDefaultGravity`.
fn get_default_gravity(&self) -> f64 {
0.03
}
}
/// An armor stand keeps no `NoGravity` flag yet, so it takes the defaults.
impl crate::entity_rules::EntityRules for ArmorStand {}
impl ItemEntity {
pub const WIDTH: f64 = 0.25;
pub fn bounds(&self) -> Aabb {
@@ -263,9 +284,7 @@ impl ItemEntity {
self.velocity.z * 0.95,
);
} else {
if !self.no_gravity {
self.velocity.y -= 0.04;
}
self.velocity.y -= self.get_gravity();
}
let horizontal = self.velocity.x * self.velocity.x + self.velocity.z * self.velocity.z;
// ItemEntity.tick: skip move 3 of 4 ticks while grounded with no
@@ -856,8 +875,8 @@ impl ExperienceOrb {
(self.velocity.y + 5.0e-4).min(0.06),
self.velocity.z * 0.99,
);
} else if !colliding && !self.no_gravity {
self.velocity.y -= 0.03;
} else if !colliding {
self.velocity.y -= self.get_gravity();
}
if facts.get(block).fluid == Fluid::Lava {
// ExperienceOrb.tick: lava throws the orb back out.
@@ -1235,6 +1254,10 @@ pub struct ArmorStand {
pub no_base_plate: bool,
pub invisible: bool,
pub marker: bool,
/// `Entity.isNoGravity` (`NoGravity` in the save). `ArmorStand.hasPhysics`
/// is false with it, as with `Marker`: `travel` does nothing, so the stand
/// never moves, and the blocks it stands in do not touch it.
pub no_gravity: bool,
/// `CustomName`: the text of a hologram.
pub custom_name: Option<crate::text::Text>,
/// `CustomNameVisible`.
@@ -1324,6 +1347,7 @@ impl ArmorStand {
no_base_plate: false,
invisible: false,
marker: false,
no_gravity: false,
custom_name: None,
name_visible: false,
last_hit: i64::MIN / 2,
@@ -1409,7 +1433,9 @@ impl ArmorStand {
self.fire_ticks = self.fire_ticks.max(300);
}
let fire = crate::motion::environment::fire_contact(world, registry, self.bounds()) > 0.0;
if fire && !self.invisible && !self.marker {
// The fire block reaches a stand through `applyEffectsFromBlocks`,
// which a NoGravity stand does not feel (measured beside the server).
if fire && !self.invisible && !self.marker && !self.no_gravity {
if self.fire_ticks > 0 {
self.health -= 0.15;
} else {
@@ -1466,6 +1492,10 @@ impl ArmorStand {
if v.z.abs() < 0.003 { 0.0 } else { v.z },
);
self.velocity = wanted;
// ArmorStand.travel: no physics with NoGravity, the velocity stays.
if self.no_gravity {
return;
}
// `travelInFluid` reads `isFalling` before the move and any push;
// `travelInAir` reads ground friction before the move.
let falling = wanted.y <= 0.0;
@@ -2625,6 +2655,36 @@ mod armor_stand_motion_tests {
);
}
#[test]
fn a_no_gravity_stand_stays_put() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let collision = crate::collisions::load(&root, assets.registry).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let world = World::test_scene(&assets, &render, 3).unwrap();
let start = Vec3::new(20.5, 20.0, 20.5);
let mut stand = ArmorStand::new(1, start, 0.0);
stand.on_ground = false;
stand.no_gravity = true;
// ArmorStand.travel does nothing without physics: measured on the real
// server, Pos and Motion stay exactly as summoned for every tick.
for _ in 0..5 {
tick_stand(&mut stand, &world, &facts, &collision);
assert_eq!(stand.position, start);
assert_eq!(stand.velocity, Vec3::ZERO);
}
stand.no_gravity = false;
tick_stand(&mut stand, &world, &facts, &collision);
tick_stand(&mut stand, &world, &facts, &collision);
assert!(stand.position.y < start.y, "gravity again once cleared");
}
#[test]
fn leather_boots_walk_on_powder_snow() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
+33
View File
@@ -2919,6 +2919,12 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
} else if let Tag::Compound(map) = &mut tag {
map.remove(&"Marker".into());
}
// Entity.saveWithoutId writes NoGravity only when set.
if a.no_gravity {
put(&mut tag, "NoGravity", Tag::Byte(1));
} else if let Tag::Compound(map) = &mut tag {
map.remove(&"NoGravity".into());
}
if a.name_visible {
put(&mut tag, "CustomNameVisible", Tag::Byte(1));
} else if let Tag::Compound(map) = &mut tag {
@@ -2984,6 +2990,7 @@ pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
a.no_base_plate = flag(tag, "NoBasePlate");
a.invisible = flag(tag, "Invisible");
a.marker = flag(tag, "Marker");
a.no_gravity = flag(tag, "NoGravity");
a.on_ground = flag(tag, "OnGround");
// ArmorStand.readAdditionalSaveData: each part of `Pose` that is a list of
// three numbers, the others staying at their defaults.
@@ -3387,6 +3394,32 @@ mod tests {
assert!(restored.name_visible);
}
#[test]
fn armor_stand_keeps_no_gravity_through_a_save() {
let tag = Tag::compound([
("id", string("minecraft:armor_stand")),
(
"Pos",
Tag::List(vec![
Tag::Double(0.0f64.to_bits()),
Tag::Double(64.0f64.to_bits()),
Tag::Double(0.0f64.to_bits()),
]),
),
("NoGravity", Tag::Byte(1)),
]);
let mut a = armor_stand_from_nbt(&tag, 7).unwrap();
assert!(a.no_gravity, "summon NBT sets the flag");
let saved = armor_stand_to_nbt(&a, [0; 4], Some(&tag));
assert_eq!(saved.get("NoGravity"), Some(&Tag::Byte(1)));
assert!(armor_stand_from_nbt(&saved, 7).unwrap().no_gravity);
// `data merge entity @s {NoGravity:0b}` clears it again.
a.no_gravity = false;
let cleared = armor_stand_to_nbt(&a, [0; 4], Some(&saved));
assert!(cleared.get("NoGravity").is_none());
assert!(!armor_stand_from_nbt(&cleared, 7).unwrap().no_gravity);
}
#[test]
fn falling_block_round_trip_keeps_impact_distance_and_settings() {
let state = Tag::compound([("Name", string("minecraft:damaged_anvil"))]);
+33
View File
@@ -0,0 +1,33 @@
//! PURPOSE: `Entity`'s gravity and fluid-push rules, decided once for every kind.
//! WHY: each kind used to check its own `NoGravity` flag and pick its own
//! number, so a kind that forgot the check ignored the flag (#6481).
//! REFS: vanilla `Entity.isNoGravity`, `getDefaultGravity`, `getGravity`,
//! `applyGravity`, `isPushedByFluid`; issue #6509, slice #6544.
//! LOGS: none. READS-BY: the kinds' ticks in this crate and in `game/`.
//! ENDS-WHEN: kinds share one stored `Entity` state and this folds into it.
/// The rules vanilla's `Entity` answers for every kind. A kind overrides one
/// only where its vanilla class does; the defaults are `Entity`'s.
pub trait EntityRules {
/// `Entity.isNoGravity` (`NoGravity` in the save).
fn is_no_gravity(&self) -> bool {
false
}
/// `Entity.getDefaultGravity`.
fn get_default_gravity(&self) -> f64 {
0.0
}
/// `Entity.getGravity`: none with `NoGravity`, else the kind's default.
/// `applyGravity` subtracts this from the vertical velocity.
fn get_gravity(&self) -> f64 {
if self.is_no_gravity() {
0.0
} else {
self.get_default_gravity()
}
}
/// `Entity.isPushedByFluid`.
fn is_pushed_by_fluid(&self) -> bool {
true
}
}
File diff suppressed because it is too large. Load diff
+75 -135
View File
@@ -3,6 +3,9 @@
//! like a player (walk, look, break, place, use, click buttons). Actions that
//! take time run as a task advanced every frame and reply when they finish or
//! time out, so `horizon-ctl break 10 64 3` returns what the block dropped.
#[path = "agent_navigation.rs"]
mod navigation;
use super::event_loop::EventLoopCtl;
use super::{Game, Screen};
use horizon_client::{
@@ -17,6 +20,7 @@ use horizon_client::{
};
use horizon_core::geometry::Vec3;
use horizon_world::registry::BlockStateId;
use navigation::{Cell, Node, Plan, Progress};
use serde_json::{Value, json};
use std::{
collections::{BTreeMap, HashMap, HashSet},
@@ -206,9 +210,6 @@ fn delta(before: &BTreeMap<String, i64>, after: &BTreeMap<String, i64>) -> BTree
out
}
/// A feet position the A* path walks through.
type Node = [i32; 3];
enum TaskKind {
/// Hold keys until a deadline.
Move {
@@ -251,9 +252,11 @@ struct Goto {
target: [f64; 3],
near: f64,
sprint: bool,
path: Vec<Node>,
path: Plan,
blocked: Vec<(Node, Node)>,
progress: Progress,
next: usize,
/// Tick of the last waypoint reached, and how many replans so far.
/// Tick of the last waypoint progress, and stuck recoveries so far.
progress_tick: u64,
replans: u32,
jump_until: u64,
@@ -817,105 +820,24 @@ impl Game<'_> {
})
}
/// Whether a block stops movement, for the path finder.
fn blocks_motion(&self, pos: [i32; 3]) -> bool {
let f = self
.res
.facts()
.get(self.world.block(pos[0], pos[1], pos[2]));
f.full_collision || f.legacy_solid
}
fn walkable(&self, n: Node) -> bool {
let below = self.world.block(n[0], n[1] - 1, n[2]);
let lava = |p: [i32; 3]| {
&*self
.res
.facts()
.get(self.world.block(p[0], p[1], p[2]))
.name
== "minecraft:lava"
};
self.world.chunks.contains_key(&(n[0] >> 4, n[2] >> 4))
&& (self.blocks_motion([n[0], n[1] - 1, n[2]])
|| self.res.facts().get(below).fluid != horizon_client::blocks::Fluid::None)
&& !self.blocks_motion(n)
&& !self.blocks_motion([n[0], n[1] + 1, n[2]])
&& !lava(n)
&& !lava([n[0], n[1] - 1, n[2]])
}
/// A* over standable feet positions: steps up one block, drops up to three.
fn find_path(&self, from: Node, goal: [f64; 3], near: f64) -> Option<Vec<Node>> {
use std::{cmp::Reverse, collections::BinaryHeap};
let reached = |n: Node| {
let c = [
f64::from(n[0]) + 0.5,
f64::from(n[1]),
f64::from(n[2]) + 0.5,
];
((c[0] - goal[0]).powi(2) + (c[2] - goal[2]).powi(2)).sqrt() <= near.max(0.5)
&& (c[1] - goal[1]).abs() <= near.max(1.0) + 0.5
};
let h = |n: Node| {
((f64::from(n[0]) + 0.5 - goal[0]).abs()
+ (f64::from(n[1]) - goal[1]).abs()
+ (f64::from(n[2]) + 0.5 - goal[2]).abs())
* 10.0
};
let mut open = BinaryHeap::new();
let mut cost: HashMap<Node, u32> = HashMap::new();
let mut came: HashMap<Node, Node> = HashMap::new();
cost.insert(from, 0);
open.push(Reverse((h(from) as u32, from)));
let mut expanded = 0;
while let Some(Reverse((_, n))) = open.pop() {
if reached(n) {
let mut path = vec![n];
let mut at = n;
while let Some(&p) = came.get(&at) {
path.push(p);
at = p;
}
path.reverse();
return Some(path);
}
expanded += 1;
if expanded > 30_000 {
fn find_path(
&self,
from: Node,
goal: [f64; 3],
near: f64,
blocked: &[(Node, Node)],
) -> Option<Plan> {
navigation::find_path(from, goal, near, blocked, |n| {
if !self.world.chunks.contains_key(&(n[0] >> 4, n[2] >> 4)) {
return None;
}
let base = cost[&n];
for d in [[1, 0], [-1, 0], [0, 1], [0, -1]] {
for dy in [0, 1, -1, -2, -3] {
let m = [n[0] + d[0], n[1] + dy, n[2] + d[1]];
// A step up needs head room above the start; a drop needs
// the column in front clear down to the landing.
let clear = match dy {
1 => !self.blocks_motion([n[0], n[1] + 2, n[2]]),
d if d < 0 => {
(m[1]..=n[1] + 1).all(|y| !self.blocks_motion([m[0], y, m[2]]))
}
_ => true,
};
if !clear || !self.walkable(m) {
continue;
}
let water = self
.res
.facts()
.get(self.world.block(m[0], m[1], m[2]))
.fluid
!= horizon_client::blocks::Fluid::None;
let step = 10 + if dy != 0 { 5 } else { 0 } + if water { 30 } else { 0 };
let c = base + step;
if cost.get(&m).is_none_or(|&old| c < old) {
cost.insert(m, c);
came.insert(m, n);
open.push(Reverse((c + h(m) as u32, m)));
}
break;
}
}
}
None
let f = self.res.facts().get(self.world.block(n[0], n[1], n[2]));
Some(Cell {
solid: f.full_collision || f.legacy_solid,
fluid: f.fluid != horizon_client::blocks::Fluid::None,
lava: &*f.name == "minecraft:lava",
})
})
}
/// Point the view at `at` from the eye.
@@ -1259,10 +1181,13 @@ impl Game<'_> {
let timeout = a.num("timeout", 60.0)?;
let p = self.player.as_ref().ok_or("no player")?.position;
let from = [p.x.floor() as i32, p.y.round() as i32, p.z.floor() as i32];
let (path, warning) = match self.find_path(from, target, near) {
let (path, warning) = match self.find_path(from, target, near, &[]) {
Some(path) => (path, None),
None if a.has("direct") => (
Vec::new(),
Plan {
nodes: Vec::new(),
complete: true,
},
Some("no path found through loaded terrain; walking straight".to_owned()),
),
None => {
@@ -1272,12 +1197,17 @@ impl Game<'_> {
.into());
}
};
let position = [p.x, p.y, p.z];
let mut next = 1;
let aim = path.aim(position, &mut next, target).unwrap_or(target);
let goto = Goto {
target,
near,
sprint: a.has("sprint"),
path,
next: 1,
blocked: Vec::new(),
progress: Progress::new(position, aim),
next,
progress_tick: self.ticks,
replans: 0,
jump_until: 0,
@@ -2077,9 +2007,7 @@ impl Game<'_> {
let on_ground = self.player.as_ref().is_some_and(|p| p.ground.on_ground);
let horizontal =
|a: [f64; 3], b: [f64; 3]| ((a[0] - b[0]).powi(2) + (a[2] - b[2]).powi(2)).sqrt();
let arrived = horizontal(p, g.target) <= g.near
&& (p[1] - g.target[1]).abs() <= g.near.max(1.0) + 1.0;
if arrived {
if navigation::arrived(p, g.target, g.near) {
return Some(
json!({ "ok": true, "arrived": true, "position": p, "target": g.target,
"distance": dist(p, g.target), "warning": g.warning }),
@@ -2091,48 +2019,60 @@ impl Game<'_> {
"target": g.target, "distance": dist(p, g.target), "warning": g.warning }),
);
}
// The next waypoint: path nodes, then the target itself.
let mut aim = g.target;
while g.next < g.path.len() {
let n = g.path[g.next];
let c = [
f64::from(n[0]) + 0.5,
f64::from(n[1]),
f64::from(n[2]) + 0.5,
];
let last = g.next + 1 == g.path.len();
if horizontal(p, c) < if last { 0.3 } else { 0.45 } && (p[1] - c[1]).abs() < 1.1 {
g.next += 1;
let previous_next = g.next;
let mut aim = g.path.aim(p, &mut g.next, g.target);
if let Some(aim) = aim {
if g.next != previous_next {
g.progress = Progress::new(p, aim);
g.progress_tick = self.ticks;
} else if g.progress.advance(p, aim) {
g.progress_tick = self.ticks;
continue;
}
aim = c;
break;
}
// Stuck: jump, then replan from here.
let stalled = self.ticks.saturating_sub(g.progress_tick);
if stalled > 60 && on_ground {
g.jump_until = self.ticks + 4;
}
if stalled > 120 {
g.replans += 1;
if g.replans > 3 {
return Some(
json!({ "error": "stuck: cannot make progress", "arrived": false, "position": p,
"target": g.target, "distance": dist(p, g.target) }),
);
if aim.is_none() || stalled > 120 {
if stalled > 120 {
g.replans += 1;
if g.replans > 3 {
return Some(
json!({ "error": "stuck: cannot make progress", "arrived": false,
"position": p, "target": g.target, "distance": dist(p, g.target) }),
);
}
if g.next > 0 && g.next < g.path.nodes.len() {
let edge = (g.path.nodes[g.next - 1], g.path.nodes[g.next]);
if edge.0 != edge.1 && !g.blocked.contains(&edge) {
g.blocked.push(edge);
}
}
}
let from = [
p[0].floor() as i32,
p[1].round() as i32,
p[2].floor() as i32,
];
if let Some(path) = self.find_path(from, g.target, g.near) {
g.path = path;
g.next = 1;
}
let Some(path) = self.find_path(from, g.target, g.near, &g.blocked) else {
return Some(
json!({ "error": "no further walking progress through loaded terrain",
"arrived": false, "position": p, "target": g.target,
"distance": dist(p, g.target), "warning": g.warning }),
);
};
g.path = path;
g.next = 1;
g.progress_tick = self.ticks;
aim = g.path.aim(p, &mut g.next, g.target);
g.progress = Progress::new(p, aim.unwrap_or(g.target));
}
let Some(aim) = aim else {
return Some(
json!({ "error": "path did not provide a new walking waypoint",
"arrived": false, "position": p, "target": g.target }),
);
};
let d = [aim[0] - p[0], aim[2] - p[2]];
if d[0].abs() + d[1].abs() > 1e-3 {
self.yaw = (-d[0]).atan2(d[1]).to_degrees() as f32;
@@ -0,0 +1,439 @@
use std::{
cmp::Reverse,
collections::{BinaryHeap, HashMap},
};
pub(crate) type Node = [i32; 3];
#[derive(Clone, Copy, Default)]
pub(crate) struct Cell {
pub solid: bool,
pub fluid: bool,
pub lava: bool,
}
#[derive(Debug)]
pub(crate) struct Plan {
pub nodes: Vec<Node>,
pub complete: bool,
}
fn solid(query: &impl Fn(Node) -> Option<Cell>, n: Node) -> bool {
query(n).is_none_or(|b| b.solid)
}
fn walkable(query: &impl Fn(Node) -> Option<Cell>, n: Node) -> bool {
let Some(feet) = query(n) else {
return false;
};
let Some(below) = query([n[0], n[1] - 1, n[2]]) else {
return false;
};
let Some(head) = query([n[0], n[1] + 1, n[2]]) else {
return false;
};
(below.solid || below.fluid) && !feet.solid && !head.solid && !feet.lava && !below.lava
}
pub(crate) fn find_path(
from: Node,
goal: [f64; 3],
near: f64,
blocked: &[(Node, Node)],
query: impl Fn(Node) -> Option<Cell>,
) -> Option<Plan> {
let reached = |n: Node| {
let c = [
f64::from(n[0]) + 0.5,
f64::from(n[1]),
f64::from(n[2]) + 0.5,
];
((c[0] - goal[0]).powi(2) + (c[2] - goal[2]).powi(2)).sqrt() <= near.max(0.5)
&& (c[1] - goal[1]).abs() <= near.max(1.0) + 0.5
};
let h = |n: Node| {
((f64::from(n[0]) + 0.5 - goal[0]).abs()
+ (f64::from(n[1]) - goal[1]).abs()
+ (f64::from(n[2]) + 0.5 - goal[2]).abs())
* 10.0
};
let mut open = BinaryHeap::new();
let mut cost: HashMap<Node, u32> = HashMap::new();
let mut came: HashMap<Node, Node> = HashMap::new();
cost.insert(from, 0);
open.push(Reverse((h(from) as u32, from)));
let distance = |n: Node| {
(f64::from(n[0]) + 0.5 - goal[0]).powi(2)
+ (f64::from(n[1]) - goal[1]).powi(2)
+ (f64::from(n[2]) + 0.5 - goal[2]).powi(2)
};
let mut best = from;
let mut expanded = 0;
while let Some(Reverse((_, n))) = open.pop() {
if reached(n) {
return Some(reconstruct(n, &came, true));
}
if distance(n) < distance(best) {
best = n;
}
expanded += 1;
if expanded >= 30_000 {
break;
}
let base = cost[&n];
for d in [[1, 0], [-1, 0], [0, 1], [0, -1]] {
for dy in [0, 1, -1, -2, -3] {
let m = [n[0] + d[0], n[1] + dy, n[2] + d[1]];
// A step up needs head room above the start; a drop needs
// the column in front clear down to the landing.
let clear = match dy {
1 => !solid(&query, [n[0], n[1] + 2, n[2]]),
d if d < 0 => (m[1]..=n[1] + 1).all(|y| !solid(&query, [m[0], y, m[2]])),
_ => true,
};
if !clear || !walkable(&query, m) {
continue;
}
if blocked
.iter()
.any(|&(a, b)| (a == n && b == m) || (a == m && b == n))
{
break;
}
let water = query(m).is_some_and(|b| b.fluid);
let step = 10 + if dy != 0 { 5 } else { 0 } + if water { 30 } else { 0 };
let c = base + step;
if cost.get(&m).is_none_or(|&old| c < old) {
cost.insert(m, c);
came.insert(m, n);
open.push(Reverse((c + h(m) as u32, m)));
}
break;
}
}
}
(best != from && distance(best) + 1e-6 < distance(from))
.then(|| reconstruct(best, &came, false))
}
fn reconstruct(end: Node, came: &HashMap<Node, Node>, complete: bool) -> Plan {
let mut nodes = vec![end];
let mut at = end;
while let Some(&previous) = came.get(&at) {
nodes.push(previous);
at = previous;
}
nodes.reverse();
Plan { nodes, complete }
}
impl Plan {
pub(crate) fn aim(&self, p: [f64; 3], next: &mut usize, target: [f64; 3]) -> Option<[f64; 3]> {
while *next < self.nodes.len() {
let n = self.nodes[*next];
let c = [
f64::from(n[0]) + 0.5,
f64::from(n[1]),
f64::from(n[2]) + 0.5,
];
let last = *next + 1 == self.nodes.len();
if ((p[0] - c[0]).powi(2) + (p[2] - c[2]).powi(2)).sqrt()
< if last { 0.3 } else { 0.45 }
&& (p[1] - c[1]).abs() < 1.1
{
*next += 1;
} else {
return Some(c);
}
}
self.complete.then_some(target)
}
}
pub(crate) fn arrived(p: [f64; 3], target: [f64; 3], near: f64) -> bool {
((p[0] - target[0]).powi(2) + (p[2] - target[2]).powi(2)).sqrt() <= near
&& (p[1] - target[1]).abs() <= near.max(1.0) + 1.0
}
fn horizontal_distance(p: [f64; 3], aim: [f64; 3]) -> f64 {
((p[0] - aim[0]).powi(2) + (p[2] - aim[2]).powi(2)).sqrt()
}
pub(crate) struct Progress {
best_distance: f64,
}
impl Progress {
pub(crate) fn new(position: [f64; 3], aim: [f64; 3]) -> Self {
Self {
best_distance: horizontal_distance(position, aim),
}
}
pub(crate) fn advance(&mut self, p: [f64; 3], aim: [f64; 3]) -> bool {
let distance = horizontal_distance(p, aim);
if self.best_distance - distance >= 0.25 {
self.best_distance = distance;
true
} else {
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Default)]
struct Terrain {
floors: HashMap<(i32, i32), i32>,
cells: HashMap<Node, Cell>,
}
impl Terrain {
fn flat(x: std::ops::RangeInclusive<i32>, z: std::ops::RangeInclusive<i32>) -> Self {
let mut t = Self::default();
for x in x {
for z in z.clone() {
t.floors.insert((x, z), 63);
}
}
t
}
fn query(&self, n: Node) -> Option<Cell> {
let floor = self.floors.get(&(n[0], n[2]))?;
Some(self.cells.get(&n).copied().unwrap_or(Cell {
solid: n[1] <= *floor,
..Cell::default()
}))
}
fn path(&self, from: Node, goal: [f64; 3], blocked: &[(Node, Node)]) -> Option<Plan> {
find_path(from, goal, 0.6, blocked, |n| self.query(n))
}
}
#[test]
fn short_path_is_complete() {
let p = Terrain::flat(0..=5, 0..=0)
.path([0, 64, 0], [5.5, 64.0, 0.5], &[])
.unwrap();
assert!(p.complete);
assert_eq!(p.nodes.last(), Some(&[5, 64, 0]));
}
#[test]
fn loaded_frontier_continues_after_chunks_extend() {
let mut t = Terrain::flat(0..=5, 0..=0);
let goal = [10.5, 64.0, 0.5];
let partial = t
.path([0, 64, 0], goal, &[])
.expect("loaded frontier should be usable");
assert!(!partial.complete);
assert_eq!(partial.nodes.last(), Some(&[5, 64, 0]));
let endpoint = *partial.nodes.last().unwrap();
assert!(
t.path(endpoint, goal, &[]).is_none(),
"unchanged frontier must stop"
);
for x in 6..=10 {
t.floors.insert((x, 0), 63);
}
let complete = t.path(endpoint, goal, &[]).unwrap();
assert!(complete.complete);
assert_eq!(complete.nodes.last(), Some(&[10, 64, 0]));
}
#[test]
fn no_closer_endpoint_fails() {
assert!(
Terrain::flat(-4..=0, 0..=0)
.path([0, 64, 0], [10.5, 64.0, 0.5], &[])
.is_none()
);
}
#[test]
fn complete_path_can_initially_move_away() {
let mut t = Terrain::flat(-2..=0, 0..=0);
for z in 0..=2 {
t.floors.insert((-2, z), 63);
}
for x in -2..=5 {
t.floors.insert((x, 2), 63);
}
for z in 0..=2 {
t.floors.insert((5, z), 63);
}
let p = t.path([0, 64, 0], [5.5, 64.0, 0.5], &[]).unwrap();
assert!(p.complete);
assert_eq!(p.nodes[1], [-1, 64, 0]);
}
#[test]
fn failed_edge_uses_bypass() {
let blocked = [([0, 64, 0], [1, 64, 0])];
let p = Terrain::flat(0..=3, 0..=1)
.path([0, 64, 0], [3.5, 64.0, 0.5], &blocked)
.unwrap();
assert!(p.complete);
assert!(
!p.nodes
.windows(2)
.any(|w| w == [blocked[0].0, blocked[0].1])
);
assert_eq!(p.nodes[1], [0, 64, 1]);
}
#[test]
fn failed_edge_without_bypass_stops() {
assert!(
Terrain::flat(0..=1, 0..=0)
.path([0, 64, 0], [1.5, 64.0, 0.5], &[([0, 64, 0], [1, 64, 0])])
.is_none()
);
}
#[test]
fn lava_and_unknown_terrain_are_not_walked() {
let mut t = Terrain::flat(0..=1, 0..=0);
t.cells.insert(
[1, 63, 0],
Cell {
lava: true,
fluid: true,
solid: false,
},
);
assert!(t.path([0, 64, 0], [1.5, 64.0, 0.5], &[]).is_none());
t.cells.remove(&[1, 63, 0]);
t.cells.insert(
[1, 64, 0],
Cell {
lava: true,
fluid: true,
solid: false,
},
);
assert!(t.path([0, 64, 0], [1.5, 64.0, 0.5], &[]).is_none());
assert!(
Terrain::flat(0..=0, 0..=0)
.path([0, 64, 0], [5.5, 64.0, 0.5], &[])
.is_none()
);
}
#[test]
fn one_step_up_and_three_step_drop_only_with_clearance() {
let mut t = Terrain::flat(0..=1, 0..=0);
t.floors.insert((1, 0), 64);
assert!(t.path([0, 64, 0], [1.5, 65.0, 0.5], &[]).unwrap().complete);
t.cells.insert(
[0, 66, 0],
Cell {
solid: true,
..Cell::default()
},
);
assert!(t.path([0, 64, 0], [1.5, 65.0, 0.5], &[]).is_none());
t.cells.clear();
t.floors.insert((1, 0), 65);
assert!(t.path([0, 64, 0], [1.5, 66.0, 0.5], &[]).is_none());
t.floors.insert((1, 0), 60);
assert!(t.path([0, 64, 0], [1.5, 61.0, 0.5], &[]).unwrap().complete);
t.cells.insert(
[1, 64, 0],
Cell {
solid: true,
..Cell::default()
},
);
assert!(t.path([0, 64, 0], [1.5, 61.0, 0.5], &[]).is_none());
t.cells.clear();
t.floors.insert((1, 0), 59);
assert!(t.path([0, 64, 0], [1.5, 60.0, 0.5], &[]).is_none());
}
#[test]
fn dry_bypass_costs_less_than_water() {
let mut t = Terrain::flat(0..=2, 0..=1);
t.cells.insert(
[1, 64, 0],
Cell {
fluid: true,
..Cell::default()
},
);
let p = t.path([0, 64, 0], [2.5, 64.0, 0.5], &[]).unwrap();
assert!(p.complete);
assert!(!p.nodes.contains(&[1, 64, 0]));
}
#[test]
fn search_budget_returns_progress_instead_of_discarding_it() {
let p = Terrain::flat(0..=30_010, 0..=0)
.path([0, 64, 0], [40_000.5, 64.0, 0.5], &[])
.unwrap();
assert!(!p.complete);
assert!(p.nodes.len() > 2 && p.nodes.len() <= 30_001);
}
#[test]
fn consumed_partial_never_aims_at_unknown_goal_or_arrives() {
let plan = Plan {
nodes: vec![[0, 64, 0], [1, 64, 0]],
complete: false,
};
let p = [1.5, 64.0, 0.5];
let goal = [10.5, 64.0, 0.5];
let mut next = 1;
assert_eq!(plan.aim(p, &mut next, goal), None);
assert!(!arrived(p, goal, 0.6));
}
#[test]
fn replacement_path_uses_its_new_waypoint() {
let plan = Plan {
nodes: vec![[0, 64, 0], [0, 64, 1], [1, 64, 1]],
complete: false,
};
assert_eq!(
plan.aim([0.5, 64.0, 0.5], &mut 1, [5.5, 64.0, 0.5]),
Some([0.5, 64.0, 1.5])
);
assert!(arrived([5.5, 64.0, 0.5], [5.5, 64.0, 0.5], 0.6));
}
#[test]
fn jumping_in_place_does_not_reset_stuck_timer() {
let start = [0.5, 64.0, 0.5];
let aim = [4.5, 64.0, 0.5];
let mut progress = Progress::new(start, aim);
assert!(!progress.advance([0.5, 65.0, 0.5], aim));
assert!(!progress.advance([0.6, 64.0, 0.6], aim));
assert!(progress.advance([0.8, 64.0, 0.5], aim));
}
#[test]
fn oscillation_and_vertical_drift_do_not_count_as_progress() {
let aim = [4.5, 64.0, 0.5];
let mut progress = Progress::new([0.5, 64.0, 0.5], aim);
for tick in 0..400 {
let x = if tick % 2 == 0 { 0.2 } else { 0.5 };
assert!(
!progress.advance([x, 64.0, 0.5], aim),
"oscillation reset at {tick}"
);
}
assert!(!progress.advance([0.5, 66.0, 0.5], aim));
}
#[test]
fn only_new_best_distance_counts_and_waypoint_switch_resets_baseline() {
let aim = [4.5, 64.0, 0.5];
let start = [0.5, 64.0, 0.5];
let mut progress = Progress::new(start, aim);
assert!(!progress.advance([0.6, 64.0, 0.5], aim));
assert!(progress.advance([0.8, 64.0, 0.5], aim));
assert!(!progress.advance(start, aim));
assert!(!progress.advance([0.8, 64.0, 0.5], aim));
assert!(progress.advance([1.1, 64.0, 0.5], aim));
let detour_aim = [1.1, 64.0, 2.5];
let mut progress = Progress::new([1.1, 64.0, 0.5], detour_aim);
assert!(progress.advance([1.1, 64.0, 0.8], detour_aim));
}
}
@@ -660,11 +660,12 @@ impl Game<'_> {
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
return false;
}
let (_, _, _, magic) = self.melee_damage("minecraft:armor_stand", true);
let (_, _, crit, magic) = self.melee_damage("minecraft:armor_stand", true);
let hurt = self.hit_armor_stand(i, |g, area, id| {
g.attack_effects(
area,
Some(id as i32),
crit,
super::mace::Spared::Stand(id),
magic.then_some(id as i32),
);
@@ -729,7 +730,7 @@ impl Game<'_> {
let spread = [width / 4.0, height / 4.0, width / 4.0];
self.particles
.send_particles(&options, centre, spread, 0.05, 10);
self.lan_level_particles(&options, centre, spread, 0.05, 10, Some(state));
self.lan_level_particles(None, &options, centre, spread, 0.05, 10, Some(state));
}
/// `ArmorStand.hurtServer` for a player's attack (also a sweep's): a
/// creative player breaks it, otherwise the first hit wobbles it and a
+1 -1
View File
@@ -97,7 +97,7 @@ impl Game<'_> {
let back = self.dim;
for (dim, head) in std::mem::take(&mut self.player_beds) {
self.enter_level(None, dim)?;
// The in-frame game has only the level it is in.
// A client holds no other levels (`enter_level`).
if self.dim == dim {
self.set_bed_occupied(head, false);
}
+3 -12
View File
@@ -486,23 +486,14 @@ impl Game<'_> {
/// The leads on boats that hang on a player or a mob, for the entity
/// tracker: the holder's entity id (`ClientboundSetEntityLinkPacket`).
pub(super) fn boat_leash_link(
&self,
b: &Boat,
host_entity: Option<i32>,
guests: &[(u128, i32)],
) -> Option<i32> {
pub(super) fn boat_leash_link(&self, b: &Boat, guests: &[(u128, i32)]) -> Option<i32> {
match b.leash? {
Holder::Player => {
let uuid = self.boat_leash_player(b);
match guests
guests
.iter()
.find(|(u, _)| Some(horizon_client::lan::playerdata::uuid_ints(*u)) == uuid)
{
Some(&(_, id)) => Some(id),
None if uuid == self.level.player_uuid => host_entity,
None => None,
}
.map(|&(_, id)| id)
}
Holder::Mob(id) | Holder::Boat(id) => Some(id as i32),
Holder::Knot(p) => self.knot_ids.get(&p).map(|&id| id as i32),
+7 -49
View File
@@ -2,7 +2,8 @@
//! paddling controls, collisions, riding (`positionRider`, `clampRotation`,
//! dismounting), `BoatItem` placement, `VehicleEntity.hurtServer` breaking,
//! saving and `AbstractBoatRenderer` with animated paddles.
use super::{Game, InteractTarget, Keys, entity_culling::at, view_vector};
use super::{Game, InteractTarget, entity::Entity, entity_culling::at, view_vector};
use horizon_client::entity_rules::EntityRules;
use horizon_client::{
blocks::Fluid,
collisions::{EntityCollisions, SceneCollisions},
@@ -586,17 +587,10 @@ impl Game<'_> {
}
// `Entity.baseTick`: the boat is immersed and pushed by the current it
// sits in (`isPushedByFluid`), before `floatBoat` scales the velocity.
let _ = horizon_client::entities::update_entity_fluids(
b.bounds(),
b.position,
b.position.y + HEIGHT,
&mut b.velocity,
&self.world,
&self.res.motion().fluids,
);
let _ = b.push_by_fluid(&self.world, &self.res.motion().fluids);
// floatBoat
// `-getGravity()`: none with NoGravity.
let mut vspeed = if b.no_gravity { 0.0 } else { -0.04 };
let mut vspeed = 0.0 - b.get_gravity();
let mut buoyancy = 0.0;
let inv_friction: f32;
let obstacles = self.boat_move_obstacles(&b);
@@ -683,11 +677,12 @@ impl Game<'_> {
b.velocity.z += f64::from(r.cos() * acceleration);
b.paddling = [right && !left || up, left && !right || up];
}
// Entity.move: collisions zero the blocked components.
// Entity.move: collisions zero the blocked components, the world border's
// among them (`CollisionGetter.borderCollision`).
let mut scene = EntityCollisions {
blocks: scene,
entities: &obstacles,
border: None,
border: Some((self.border.geometry(), [b.position.x, b.position.z])),
};
if let Ok(moved) = scene.resolve(b.bounds(), b.velocity, 0.0, b.on_ground) {
b.position = Vec3::new(
@@ -1068,43 +1063,6 @@ impl Game<'_> {
max_y: b.bounds().max_position().y,
})
}
/// The player's tick while riding: steer, tick the boat, sit in it.
pub(super) fn tick_riding(&mut self, keys: Keys) -> horizon_client::Result<()> {
if self.tick_cushion_riding(keys) {
return Ok(());
}
let Some(i) = self.boats.iter().position(|b| Some(b.id) == self.riding) else {
if self.tick_cart_riding(keys) || self.tick_mount_riding(keys) {
return Ok(());
}
self.riding = None;
return Ok(());
};
if keys.shift || self.boats[i].out_of_control >= 60.0 {
self.dismount(i);
return Ok(());
}
// The boat steers with the input `LocalPlayer.rideTick` latched on the
// previous tick; this tick's keys are latched after it ticked.
let mut b = self.tick_boat(self.boats[i].clone(), true);
b.input = [keys.left, keys.right, keys.forward, keys.backward];
// positionRider: the seat, turned with the boat; clampRotation to 105 degrees.
self.yaw += b.delta_rotation;
let delta = (self.yaw - b.yaw + 180.0).rem_euclid(360.0) - 180.0;
self.yaw += delta.clamp(-105.0, 105.0) - delta;
self.boats[i] = b;
let seat = self.player_boat_seat(i);
if let Some(p) = self.player.as_mut() {
self.previous_position = p.position;
p.position = seat;
p.velocity = Vec3::ZERO;
p.fall_distance = 0.0;
}
if self.boat_lava_tick(i) {
self.riding = None;
}
Ok(())
}
/// `getDismountLocationForPassenger`: in front of the player if there is
/// floor there, else on top of the boat.
pub(super) fn dismount(&mut self, i: usize) {
+140 -115
View File
@@ -197,20 +197,105 @@ struct ResourceIds {
damage_types: Vec<String>,
loot_tables: Vec<String>,
templates: Vec<String>,
/// `entity-types.json`'s keys, sorted (empty when the file is missing).
entity_types: Vec<String>,
enchantments: Vec<String>,
structures: Vec<String>,
biomes: Vec<String>,
clocks: Vec<String>,
/// The overworld clock's timelines, their periods and time markers.
timelines: Vec<(String, Option<i64>)>,
markers: Vec<(String, TimeMarker)>,
}
static RESOURCE_IDS: Mutex<Vec<(PathBuf, Arc<ResourceIds>)>> = Mutex::new(Vec::new());
/// The `minecraft:` ids of the `.json` files directly in `dir`, sorted.
fn json_ids_in(dir: &Path) -> Vec<String> {
let mut out: Vec<String> = std::fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
e.file_name()
.to_str()?
.strip_suffix(".json")
.map(|n| format!("minecraft:{n}"))
})
.collect();
out.sort();
out
}
/// The overworld clock's timelines (with their periods) and their time
/// markers, read from `data/minecraft/timeline`.
fn overworld_timelines(root: &Path) -> (Vec<(String, Option<i64>)>, Vec<(String, TimeMarker)>) {
let dir = root.join("data/minecraft/timeline");
let (mut timelines, mut markers) = (Vec::new(), Vec::new());
for name in json_ids_in(&dir) {
let path = dir.join(format!("{}.json", name.trim_start_matches("minecraft:")));
let Ok(v) =
serde_json::from_slice::<serde_json::Value>(&std::fs::read(path).unwrap_or_default())
else {
continue;
};
if v["clock"].as_str() != Some("minecraft:overworld") {
continue;
}
let period = v["period_ticks"].as_i64();
timelines.push((name.clone(), period));
for (marker, def) in v["time_markers"].as_object().into_iter().flatten() {
let (ticks, show) = match def {
serde_json::Value::Number(n) => (n.as_i64().unwrap_or(0), false),
other => (
other["ticks"].as_i64().unwrap_or(0),
other["show_in_commands"].as_bool().unwrap_or(false),
),
};
markers.push((
marker.clone(),
TimeMarker {
ticks,
period,
show,
},
));
}
}
(timelines, markers)
}
fn resource_ids(root: &Path) -> Arc<ResourceIds> {
let mut cache = RESOURCE_IDS.lock().unwrap_or_else(|e| e.into_inner());
if let Some((_, ids)) = cache.iter().find(|(cached, _)| cached == root) {
return ids.clone();
}
let data = root.join("data/minecraft");
let mut entity_types: Vec<String> = serde_json::from_slice::<serde_json::Value>(
&std::fs::read(root.join("entity-types.json")).unwrap_or_default(),
)
.ok()
.and_then(|v| {
v.as_object().map(|o| {
o.keys()
.map(|k| horizon_client::command::args::normalize(k))
.collect()
})
})
.unwrap_or_default();
entity_types.sort();
let (timelines, markers) = overworld_timelines(root);
let ids = Arc::new(ResourceIds {
recipes: ids_under(&data.join("recipe"), "json"),
damage_types: ids_under(&data.join("damage_type"), "json"),
loot_tables: ids_under(&data.join("loot_table"), "json"),
templates: templates_under(&root.join("worldgen/structure")),
entity_types,
enchantments: json_ids_in(&data.join("enchantment")),
structures: json_ids_in(&data.join("worldgen/structure")),
biomes: json_ids_in(&data.join("worldgen/biome")),
clocks: json_ids_in(&data.join("world_clock")),
timelines,
markers,
});
cache.push((root.to_owned(), ids.clone()));
ids
@@ -326,10 +411,11 @@ fn ids_in(root: &Path, extension: &str, namespace: &str) -> Vec<String> {
}
impl Game<'_> {
/// The caches `build_registries` reads (`resource_ids`, `block_properties`,
/// `command_export`), filled on a helper thread while the title screen is up: the
/// first world opened or server joined built them in its frame (20 ms of the Join
/// click on an M4 Pro). Whoever needs one before it is done waits for it, as before.
/// The caches `build_registries` and `join_server` read (`resource_ids`,
/// `block_properties`, `command_export`, `builtin_registries`), filled on a helper
/// thread while the title screen is up: the first world opened or server joined
/// built them in its frame (20 ms of the Join click on an M4 Pro). Whoever needs one
/// before it is done waits for it, as before.
pub(super) fn preload_registries(&self) {
if self.options.dedicated.is_some() {
return;
@@ -340,6 +426,7 @@ impl Game<'_> {
.name("Registries preload".into())
.spawn(move || {
horizon_threads::behind_server();
super::remote::builtin_registries(&dirs[0]);
resource_ids(&dirs[0]);
block_properties(registry);
for dir in &dirs {
@@ -472,50 +559,23 @@ impl Game<'_> {
summonable.sort();
summonable.dedup();
reg.summonable = summonable;
let root = self.options.resources.clone();
let mut types: Vec<String> = serde_json::from_slice::<serde_json::Value>(
&std::fs::read(root.join("entity-types.json")).unwrap_or_default(),
)
.ok()
.and_then(|v| {
v.as_object().map(|o| {
o.keys()
.map(|k| horizon_client::command::args::normalize(k))
.collect()
})
})
.unwrap_or_default();
let mut types = ids.entity_types.clone();
if types.is_empty() {
types = reg.summonable.clone();
types.push("minecraft:player".into());
types.sort();
}
types.sort();
reg.entity_types = types;
let list = |dir: &str| -> Vec<String> {
let mut out: Vec<String> = std::fs::read_dir(root.join(dir))
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
e.file_name()
.to_str()?
.strip_suffix(".json")
.map(|n| format!("minecraft:{n}"))
})
.collect();
out.sort();
out
};
// The mob effect registry: every effect `/effect` and the
// `mob_effect` resource arguments accept.
reg.effects = horizon_client::brewing::all_effects()
.map(|e| horizon_client::brewing::namespaced(e.id).to_owned())
.collect();
reg.effects.sort();
reg.enchantments = list("data/minecraft/enchantment");
reg.structures = list("data/minecraft/worldgen/structure");
reg.biomes = list("data/minecraft/worldgen/biome");
reg.clocks = list("data/minecraft/world_clock");
reg.enchantments = ids.enchantments.clone();
reg.structures = ids.structures.clone();
reg.biomes = ids.biomes.clone();
reg.clocks = ids.clocks.clone();
reg.dimensions = vec![
"minecraft:overworld".into(),
"minecraft:the_nether".into(),
@@ -523,41 +583,8 @@ impl Game<'_> {
];
reg.players = vec![PLAYER_NAME.into()];
// Timelines of the overworld clock and their time markers.
self.ui.markers.clear();
self.ui.timelines.clear();
for name in list("data/minecraft/timeline") {
let path = root.join(format!(
"data/minecraft/timeline/{}.json",
name.trim_start_matches("minecraft:")
));
let Ok(v) = serde_json::from_slice::<serde_json::Value>(
&std::fs::read(path).unwrap_or_default(),
) else {
continue;
};
if v["clock"].as_str() != Some("minecraft:overworld") {
continue;
}
let period = v["period_ticks"].as_i64();
self.ui.timelines.insert(name.clone(), period);
for (marker, def) in v["time_markers"].as_object().into_iter().flatten() {
let (ticks, show) = match def {
serde_json::Value::Number(n) => (n.as_i64().unwrap_or(0), false),
other => (
other["ticks"].as_i64().unwrap_or(0),
other["show_in_commands"].as_bool().unwrap_or(false),
),
};
self.ui.markers.insert(
marker.clone(),
TimeMarker {
ticks,
period,
show,
},
);
}
}
self.ui.timelines = ids.timelines.iter().cloned().collect();
self.ui.markers = ids.markers.iter().cloned().collect();
// Vanilla's built-in registries (mob effects, components, dialogs...).
for dir in [&self.options.resources, &self.ui.vanilla_root] {
if let Some(export) = command_export(dir, self.options.dedicated.is_none()) {
@@ -746,8 +773,8 @@ impl Game<'_> {
// The Chat option's Hidden level takes the server's messages too.
self.local_chat_line(text, Some(SYSTEM_INDICATOR), ChatSource::Server);
}
/// A player's chat line on the window's own chat: a LAN guest's message
/// while the host runs in-frame (`HostEvent::Chat`). `ChatListener`
/// A player's chat line on the window's own chat: a guest's message on
/// the server's own chat (`HostEvent::Chat`). `ChatListener`
/// checks `canReceivePlayerMessages` for these, so Commands Only and
/// Hidden drop them; command feedback (`/list`, `/tp`) is a system
/// message and takes the system-message permission instead.
@@ -930,7 +957,6 @@ impl Game<'_> {
self.ticks,
focused,
);
self.lan_host_chat(&message);
}
}
pub(super) fn chat_scroll(&mut self, amount: f32) {
@@ -1335,7 +1361,8 @@ impl Game<'_> {
}
/// Load per-world command state when a world opens.
pub(super) fn load_world_state(&mut self) {
self.ui.rules = GameRules::load(&self.world_root);
self.ui.rules =
GameRules::load_with_features(&self.world_root, &self.level.enabled_features);
self.ui.clock = WorldClock::default();
// PrimaryLevelData: the difficulty comes back from level.dat's difficulty_settings.
self.ui.difficulty = self.level.difficulty_id();
@@ -1414,45 +1441,6 @@ impl Game<'_> {
log::warn!("the world clocks were not written: {e}");
}
}
/// Death message in chat (CombatTracker.getDeathMessage, as far as the
/// cause is known to the client).
pub(super) fn death_message(&mut self) {
let cause = self.ui.death_cause.take();
let player = self.native_acting_id();
self.native_player_died(player, cause.as_deref());
self.ui.died_at = Some(std::time::Instant::now());
self.ui.death_text = None;
self.ui.death_confirm = None;
if !self.ui.rules.bool("show_death_messages") {
return;
}
let Some(p) = self.player.as_ref() else {
return;
};
let below_world = p.position.y < f64::from(self.world.bottom_y() - 64);
let name = self.player_display_text();
let message = match cause {
Some(key) => Text::translate(&key, &[name]),
None if below_world => Text::translate("death.attack.outOfWorld", &[name]),
None => self.combat_death_message(),
};
self.ui.death_text = Some(message.clone());
// ServerPlayer.die: the team's death message visibility decides who
// hears it, the dying player's own chat included (the death screen
// still shows it).
let board = &self.score.board;
if let Some(host) = &self.lan {
host.hub.system_message_to(
horizon_client::lan::data::component_nbt(&message.component()),
|other| {
horizon_client::scoreboard::death_message_reaches(board, PLAYER_NAME, other)
},
);
}
if horizon_client::scoreboard::death_message_reaches(board, PLAYER_NAME, PLAYER_NAME) {
self.system_message(message);
}
}
/// F1 hides the HUD (see `hud`); the screenshot key still works.
pub(super) fn toggle_hide_gui(&mut self) {
self.ui.hide_gui = !self.ui.hide_gui;
@@ -1579,6 +1567,43 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}
/// Only the overworld clock's timelines count; a marker is a bare tick
/// count (hidden from commands) or an object that may show itself.
#[test]
fn overworld_timelines_and_markers() {
let root = std::env::temp_dir().join(format!("horizon-timelines-{}", std::process::id()));
let dir = root.join("data/minecraft/timeline");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("day.json"),
r#"{"clock":"minecraft:overworld","period_ticks":24000,
"time_markers":{"minecraft:noon":6000,
"minecraft:night":{"ticks":13000,"show_in_commands":true}}}"#,
)
.unwrap();
std::fs::write(
dir.join("other.json"),
r#"{"clock":"minecraft:the_end","time_markers":{"minecraft:x":1}}"#,
)
.unwrap();
std::fs::write(dir.join("broken.json"), "{").unwrap();
let (timelines, markers) = overworld_timelines(&root);
assert_eq!(timelines, [("minecraft:day".to_owned(), Some(24000))]);
let mut markers: Vec<_> = markers
.iter()
.map(|(name, m)| (name.as_str(), m.ticks, m.period, m.show))
.collect();
markers.sort();
assert_eq!(
markers,
[
("minecraft:night", 13000, Some(24000), true),
("minecraft:noon", 6000, Some(24000), false),
]
);
let _ = std::fs::remove_dir_all(&root);
}
/// The export is read once per folder; a folder without one stays without.
#[test]
fn command_exports_are_read_once_per_folder() {
@@ -1,7 +1,7 @@
//! The window's own ambient effects of entities, which a client makes for
//! itself (`level().isClientSide()` branches): endermen's and endermites'
//! portal particles, blazes' smoke and burn, eyes of ender's trail. They run
//! on the game that has the window, in-frame or following a server, never on
//! on the game that has the window, following a server, never on
//! a server: nothing of them goes over the network.
use super::Game;
use horizon_client::blocks::RenderShape;
@@ -78,8 +78,7 @@ impl Game<'_> {
runners.push((m.position, movement, None, f64::from(m.width())));
}
}
let guests = self.lan.iter().flat_map(|host| host.models.values());
for m in self.mobs.iter().chain(guests) {
for m in self.mobs.iter() {
// Another player's fluids aren't known here: none where they stand.
let feet = [
m.position.x.floor() as i32,
@@ -175,8 +174,7 @@ impl Game<'_> {
invisible,
);
}
let guests = self.lan.iter().flat_map(|host| host.models.values());
for m in self.mobs.iter().chain(guests) {
for m in self.mobs.iter() {
if !m.alive() {
continue;
}
+1 -62
View File
@@ -1,6 +1,6 @@
//! The player's `CombatTracker`: the damage taken lately, who dealt it and
//! where the player fell from, turned into the vanilla death message.
use super::{Game, chat::PLAYER_NAME, commands::Target};
use super::{Game, commands::Target};
use horizon_client::{
health::HealthEvent,
mobs::{Mob, Species},
@@ -254,12 +254,6 @@ impl Game<'_> {
.pending_direct
.push((damage_type.to_owned(), name));
}
/// `LivingEntity.handleDamageEvent` for the player: the hurt sound of the
/// source's `DamageEffects` (`Player.getHurtSound`).
pub(super) fn player_hurt_sound(&mut self, sound: &str, at: [f64; 3]) {
// Player.getVoicePitch: always 1.0.
self.audio.play(sound, Some(at), 1.0, 1.0);
}
/// `CombatTracker.recordDamage` for the motion events of this tick.
pub(super) fn record_combat(&mut self, events: &[MotionEvent]) {
self.recheck_combat();
@@ -384,57 +378,6 @@ impl Game<'_> {
damage.in_tag(id, tag) || damage.in_tag(id, &format!("minecraft:{tag}"))
})
}
/// The killing blow as advancement predicates see it (`DamageSource` of the
/// last combat entry): its type and the causing entity. The projectile
/// damage types have the projectile as direct entity, not the shooter.
pub(super) fn combat_killing_blow(&self) -> Option<horizon_client::advancements::DamageView> {
use horizon_client::advancements::{DamageView, EntityView};
let e = self.combat.entries.last()?;
let source = e.attacker.as_ref().and_then(|(id, _)| {
self.mobs
.iter()
.find(|m| m.id == *id)
.map(|m| self.mob_entity(m))
});
let projectile = match e.damage_type.as_str() {
// The rocket is the direct entity of its `fireworks` damage.
"minecraft:fireworks" => Some("minecraft:firework_rocket"),
t @ ("minecraft:arrow"
| "minecraft:trident"
| "minecraft:mob_projectile"
| "minecraft:fireball"
| "minecraft:unattributed_fireball"
| "minecraft:wither_skull"
| "minecraft:wind_charge"
| "minecraft:thrown"
| "minecraft:spit") => Some(t),
_ => None,
};
let direct = match projectile {
Some(kind) => Some(EntityView {
kind: kind.to_owned(),
..Default::default()
}),
None => source.clone(),
};
Some(DamageView {
kind: e.damage_type.clone(),
direct,
source,
is_direct: projectile.is_none(),
original: e.damage,
actual: e.damage,
blocked: false,
})
}
/// The type of the mob that dealt the killing blow (`killed_by`).
pub(super) fn combat_killer_kind(&self) -> Option<String> {
let (id, _) = self.combat.entries.last()?.attacker.clone()?;
self.mobs
.iter()
.find(|m| m.id == id)
.map(|m| m.kind.id.clone())
}
/// `CombatTracker.getMostSignificantFall`.
fn most_significant_fall(&self) -> Option<usize> {
let (mut result, mut alternative) = (None, None);
@@ -498,10 +441,6 @@ impl Game<'_> {
.clone()
.or_else(|| self.mob_main_hand_name(e.attacker.as_ref()?.0))
}
/// `CombatTracker.getDeathMessage`.
pub(super) fn combat_death_message(&mut self) -> Text {
self.combat_death_message_for(Text::literal(PLAYER_NAME))
}
/// `CombatTracker.getDeathMessage` for a player whose display name is
/// `me`: a server's guest is not `PLAYER_NAME`.
pub(super) fn combat_death_message_for(&mut self, me: Text) -> Text {
@@ -210,6 +210,12 @@ impl Game<'_> {
out.push('\n');
}
}
"mobs" => {
// Observe authoritative AI state without a control-socket race.
let record = self.harness_owner(|g| Ok(g.control_mobs(128.0)))?;
out.push_str(&record.to_string());
out.push('\n');
}
"tick" => {
self.chat_capture = Some(Vec::new());
for _ in 0..op["n"].as_u64().unwrap_or(1) {
+13 -79
View File
@@ -164,7 +164,7 @@ impl Game<'_> {
if self.client_mod_command(command) {
return true;
}
let d = vanilla::game_dispatcher();
let d = vanilla::game_dispatcher_with_features(&self.level.enabled_features);
let parse = d.parse(command, &self.ui.registries);
let result = match parse.executable() {
Err(e) => {
@@ -295,8 +295,7 @@ impl Game<'_> {
}
/// `CommandSourceStack.broadcastToAdmins` for a command block or a command
/// minecart called `name`: `[name: message]`, gray and italic, for every
/// operator when `send_command_feedback` (the world's host too: no player
/// ran the command) and for a dedicated server's console when
/// operator when `send_command_feedback` and for a dedicated server's console when
/// `log_admin_commands`. Queued: `send_admin_feedback` sends it.
pub(super) fn broadcast_source_to_admins(&mut self, name: Text, message: Text) {
if message
@@ -308,19 +307,10 @@ impl Game<'_> {
let line = Text::translate("chat.type.admin", &[name, message])
.with_color(text::GRAY)
.italic();
// Nobody else is in this game: its player is the only operator there is.
if self.lan.is_none()
&& self.lan_acting.is_none()
&& self.serving.is_none()
&& self.options.dedicated.is_none()
{
self.system_message(line);
return;
}
let ops = self.ui.rules.bool("send_command_feedback");
let console = self.options.dedicated.is_some() && self.ui.rules.bool("log_admin_commands");
if ops || console {
self.admin_feedback.push((line, ops, console, true));
self.admin_feedback.push((line, ops, console));
}
}
/// CommandSourceStack.broadcastToAdmins (`sendSuccess` with `broadcast`)
@@ -364,7 +354,7 @@ impl Game<'_> {
let line = Text::translate("chat.type.admin", &[who, message])
.with_color(text::GRAY)
.italic();
self.admin_feedback.push((line, ops, console, false));
self.admin_feedback.push((line, ops, console));
}
/// Player.getDisplayName: the name (team-formatted), suggesting a
/// `/tell`, showing the player on hover, inserting the name.
@@ -1128,7 +1118,7 @@ impl Game<'_> {
}
}
fn cmd_help(&mut self, ctx: &Context) -> R<i32> {
let d = vanilla::game_dispatcher();
let d = vanilla::game_dispatcher_with_features(&self.level.enabled_features);
let level = self.ui.registries.permission;
match ctx.get("command") {
None => {
@@ -1614,43 +1604,6 @@ impl Game<'_> {
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.
fn teleport_player_to_dimension(
&mut self,
dimension: Dim,
pos: [f64; 3],
rotation: Option<[f32; 2]>,
) -> R<()> {
let Some(mut player) = self.player.take() else {
return Ok(());
};
let from = self.dim;
if self.switch_level(dimension, player.position).is_err() {
self.player = Some(player);
return Err(err("command.failed", &[]));
}
let at = player.position;
self.trigger_dimension(from, dimension, [at.x, at.y, at.z]);
let target = Vec3::new(pos[0], pos[1], pos[2]);
player.position = target;
player.velocity = Vec3::ZERO;
player.fall_distance = 0.0;
player.ground.on_ground = false;
if let Some([yaw, pitch]) = rotation {
let pitch = pitch.clamp(-90.0, 90.0);
self.yaw = yaw;
self.pitch = pitch;
player.yaw = yaw;
player.head_yaw = yaw;
player.pitch = pitch;
}
self.travelling = Some((player, target));
self.portals.travel = None;
self.end_travel = false;
self.end_portal_travel = false;
Ok(())
}
/// A server's player teleported into another level than theirs
/// (ServerPlayer.teleport): queued to move after the command. False
@@ -1668,7 +1621,7 @@ impl Game<'_> {
_ => None,
};
let at = self.target_level(t);
let Some(conn) = conn.filter(|_| at != level && self.options.dedicated.is_some()) else {
let Some(conn) = conn.filter(|_| at != level) else {
return false;
};
self.level_teleports
@@ -1693,10 +1646,7 @@ impl Game<'_> {
pub(super) fn network_entity_id(&self, t: Target) -> Option<i32> {
let host = self.lan.as_ref();
match t {
Target::Player => match self.lan_acting {
Some(conn) => host?.hub.guest(conn).map(|g| g.entity_id),
None => host.map(|h| h.host_entity),
},
Target::Player => host?.hub.guest(self.lan_acting?).map(|g| g.entity_id),
Target::Guest(conn) => host?.hub.guest(conn).map(|g| g.entity_id),
other => non_player_entity_id(other),
}
@@ -1825,12 +1775,7 @@ impl Game<'_> {
if self.teleport_across(t, source_level, pos, rotation) {
continue;
}
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
if t == Target::Player && dimension != self.dim {
self.teleport_player_to_dimension(dimension, pos, rotation)?;
} else {
self.teleport_to(t, pos, rotation);
}
self.teleport_to(t, pos, rotation);
// LookAt.perform after a successful teleportTo.
if let Some(f) = facing {
self.send_player_look_at(t, anchored_eyes, f, facing_entity);
@@ -3622,27 +3567,16 @@ impl Game<'_> {
}
out
}
/// ListPlayersCommand: every online player, the host of a hosted world
/// first and then the guests in join order, with the count and the max.
/// ListPlayersCommand: every online player in join order (a single-player
/// owner is one of them), with the count and the max.
fn cmd_list(&mut self, ctx: &Context) -> R<i32> {
// A single-player owner is a placed guest of the in-process server, so
// it is the host and needs no entry of its own.
let owner_listed = self
.lan
.as_ref()
.is_some_and(|h| h.hub.singleplayer_owner.is_some());
// The host is online unless this is a dedicated server, which has none.
let host = (self.options.dedicated.is_none() && !owner_listed).then(|| match &self.lan {
Some(h) => (h.host_uuid, h.host_name.clone()),
None => (self.player_uuid(), PLAYER_NAME.to_owned()),
});
let guests = self
let players: Vec<(u128, String)> = self
.lan
.iter()
.flat_map(|h| h.hub.guests.iter())
.filter(|g| g.placed)
.map(|g| (g.uuid, g.name.clone()));
let players: Vec<(u128, String)> = host.into_iter().chain(guests).collect();
.map(|g| (g.uuid, g.name.clone()))
.collect();
let max = self
.lan
.as_ref()
@@ -226,7 +226,7 @@ impl Game<'_> {
/// Run one command line with `source` (no error reporting): the entry
/// for functions and `execute run`.
pub(in crate::game) fn execute_line(&mut self, line: &str, source: Source) -> R<i32> {
let d = vanilla::game_dispatcher();
let d = vanilla::game_dispatcher_with_features(&self.level.enabled_features);
let parse = d.parse(line, &self.ui.registries);
let (ctx, _) = parse.executable()?;
let ctx = ctx.clone();
@@ -306,10 +306,6 @@ impl Game<'_> {
_ => self.set_camera(None),
}
}
/// The eye height the camera follows (`Camera.tick`'s entity).
pub(in crate::game) fn camera_eye_height(&self) -> Option<f32> {
self.cmds.camera.map(|t| self.target_eye_height(t) as f32)
}
// ---- /waypoint ------------------------------------------------------
@@ -968,11 +964,8 @@ impl Game<'_> {
guest_commands: allow,
force_game_mode: false,
};
let started = if self.serving() {
self.publish(settings).is_ok()
} else {
self.lan_start(settings).is_ok()
};
// A dedicated server is open from the start (`published` above).
let started = self.serving() && self.publish(settings).is_ok();
if !started {
return Err(err("commands.publish.failed", &[]));
}
@@ -1307,18 +1300,4 @@ impl Game<'_> {
}
}
}
impl Game<'_> {
/// The player's profile id: the saved id, else the offline id of the
/// player's name.
pub(in crate::game) fn player_uuid(&self) -> u128 {
self.level.player_uuid.map_or_else(
|| horizon_protocol::crypto::offline_uuid(super::super::chat::PLAYER_NAME),
|[a, b, c, d]| {
(u128::from(a as u32) << 96)
| (u128::from(b as u32) << 64)
| (u128::from(c as u32) << 32)
| u128::from(d as u32)
},
)
}
}
impl Game<'_> {}
@@ -81,6 +81,9 @@ impl Game<'_> {
_ => 20.0,
};
self.clock.set_rate(rate);
// MinecraftServer.setTickRate: the autosave wait already
// running is shortened when the new rate asks for less.
self.on_tick_rate_changed();
self.feedback(t(
"commands.tick.rate.success",
&[lit(format_fixed(f64::from(rate), 1))],
@@ -210,8 +210,7 @@ impl Game<'_> {
if !self.verify_test_structure(&def.structure) {
return Ok(None);
}
// `server.getLevel(dimension)`: the server's levels; a client-only
// game (deprecated in-frame mode) has the one it is in.
// `server.getLevel(dimension)`: the server's levels.
let dim = match def.dimension.as_str() {
"minecraft:overworld" | "minecraft:the_nether" | "minecraft:the_end" => {
Some(Dim::from_id(&def.dimension))
+62 -8
View File
@@ -753,6 +753,7 @@ impl Game<'_> {
nearest_player: player.as_ref().filter(|p| !p.spectator).map(|p| p.position),
player,
others,
dragon: self.dragon_target(),
leash_boats: &self.leash_boats,
others_grid: None,
others_index: None,
@@ -783,6 +784,7 @@ impl Game<'_> {
} else {
None
},
border: Some(self.border.geometry()),
// The first player in the level: a server has no player of its own.
difficulty: self.local_difficulty(
self.level_players()
@@ -1105,7 +1107,6 @@ impl Game<'_> {
self.tick_apparition();
self.guardian_bubbles();
self.villager_reputation_tick();
self.merchant_tick();
self.villager_item_sense();
self.villager_pickups();
for m in dead {
@@ -1710,6 +1711,16 @@ impl Game<'_> {
self.creatures.projectiles.push(p);
self.native_projectile_launch(player, &name, id, kind);
}
/// `MushroomCow.shear`'s `sendParticles(EXPLOSION, x, getY(0.5), z, 1, 0, 0, 0, 0)`:
/// one puff over a sheared mooshroom, half its `height` up, shown here and to
/// the players within range.
pub(super) fn mooshroom_shear_puff(&mut self, at: [f64; 3], height: f64) {
let at = [at[0], at[1] + 0.5 * height, at[2]];
let options = ParticleOptions::simple(ParticleType::Explosion);
self.particles
.send_particles(&options, at, [0.0; 3], 0.0, 1);
self.lan_level_particles(None, &options, at, [0.0; 3], 0.0, 1, None);
}
/// `Entity.sendBubbleColumnParticles` at the top of the column block `pos`: two
/// splashes and two bubbles over it, which the level random places, shown here
/// and to the players within range.
@@ -1729,11 +1740,19 @@ impl Game<'_> {
let splash_options = ParticleOptions::simple(ParticleType::Splash);
self.particles
.send_particles(&splash_options, splash, [0.0; 3], 1.0, 1);
self.lan_level_particles(&splash_options, splash, [0.0; 3], 1.0, 1, None);
self.lan_level_particles(None, &splash_options, splash, [0.0; 3], 1.0, 1, None);
let bubble_options = ParticleOptions::simple(ParticleType::Bubble);
self.particles
.send_particles(&bubble_options, bubble, [0.0, 0.01, 0.0], 0.2, 1);
self.lan_level_particles(&bubble_options, bubble, [0.0, 0.01, 0.0], 0.2, 1, None);
self.lan_level_particles(
None,
&bubble_options,
bubble,
[0.0, 0.01, 0.0],
0.2,
1,
None,
);
}
}
pub(super) fn handle_mob_events(&mut self, events: Vec<MobEvent>) -> Result<()> {
@@ -1742,6 +1761,13 @@ impl Game<'_> {
MobEvent::BubbleColumnParticles { pos } => {
self.bubble_column_particles(BlockPos::new(pos[0], pos[1], pos[2]))
}
MobEvent::LandingDust { state, at, count } => self.landing_dust(
None,
state,
Vec3::new(at[0], at[1], at[2]),
count,
f64::from(0.15_f32),
),
MobEvent::SquidCycle { mob, at } => self.lan_entity_event(mob as i32, 19, at),
MobEvent::RabbitHop { mob, at } => self.lan_entity_event(mob as i32, 1, at),
MobEvent::Drown { mob, at } => {
@@ -1769,9 +1795,22 @@ impl Game<'_> {
let options = horizon_client::particles::ParticleOptions::simple(ty);
self.particles
.send_particles(&options, at, spread, speed, count);
self.lan_level_particles(&options, at, spread, speed, count, None);
self.lan_level_particles(None, &options, at, spread, speed, count, None);
}
}
// The LAN packet has no item for a particle yet: the host's window only.
MobEvent::ItemParticles {
item,
at,
spread,
speed,
count,
} => {
let options =
self.item_particle(&horizon_client::items::ItemStack::new(item, 1));
self.particles
.send_particles(&options, at, spread, speed, count);
}
MobEvent::Sound(name, at, volume, pitch)
| MobEvent::LocalSound(name, at, volume, pitch) => {
self.audio
@@ -5136,6 +5175,8 @@ impl Game<'_> {
// `Some(true)` for the four spell particles, `Some(false)` for the two
// smoke puffs of a cow that already holds effects.
let mut stew_particles: Option<bool> = None;
// `MushroomCow.shear`: where, and how tall, the mooshroom that was sheared stood.
let mut shear_puff: Option<([f64; 3], f64)> = None;
// `Ocelot.mobInteract`: whether the fish won its trust.
let mut ocelot_trust: Option<bool> = None;
match species {
@@ -5179,6 +5220,10 @@ impl Game<'_> {
"minecraft:red_mushroom"
};
drops.push((mushroom.into(), 5));
shear_puff = Some((
[m.position.x, m.position.y, m.position.z],
f64::from(m.height()),
));
m.removed = true;
let setup = SpawnSetup {
yaw: m.yaw,
@@ -5724,6 +5769,9 @@ impl Game<'_> {
self.trigger_item_entity("minecraft:player_sheared_equipment", &stack, &target);
self.spawn_item(stack, [center[0], center[1] + 0.5, center[2]], [0.0; 3], 10);
}
if let Some((at, height)) = shear_puff {
self.mooshroom_shear_puff(at, height);
}
let mut drops: Vec<ItemStack> = drops
.into_iter()
.map(|(item, count)| ItemStack::new(&item, count))
@@ -6449,7 +6497,9 @@ impl Game<'_> {
v.layer |= u32::from(tier) << 16;
}
}
if is_player {
// AvatarRenderer.shouldRenderLayers: a spectator draws no layers.
let layers = horizon_client::mobs::render::layers_shown(mob);
if is_player && layers {
// PlayerRenderer's CapeLayer, WingsLayer, ParrotOnShoulderLayer
// and SpinAttackEffectLayer. Only the cape hides with the body;
// the others have no `isInvisible` check.
@@ -6470,9 +6520,11 @@ impl Game<'_> {
self.stuck_in_body_geometry(mob, mob.id as i32, camera, alpha as f32, packed, out);
}
// CustomHeadLayer (a pumpkin, skull or head item) included.
self.mob_block_geometry(mob, camera, alpha, packed, out);
// `native_mob_layer`: a native mod's layer, over the vanilla ones.
self.mod_mob_layer_geometry(mob, who, alpha as f32, camera, packed, out);
if layers {
self.mob_block_geometry(mob, camera, alpha, packed, out);
// `native_mob_layer`: a native mod's layer, over the vanilla ones.
self.mod_mob_layer_geometry(mob, who, alpha as f32, camera, packed, out);
}
if let Some(rgb) = outline {
self.mark_outline(&out[entity_start..], rgb);
if mob.invisible {
@@ -7225,6 +7277,7 @@ mod pearl_tests {
player: None,
nearest_player: None,
others: &[],
dragon: None,
leash_boats: &[],
others_grid: None,
others_index: None,
@@ -7249,6 +7302,7 @@ mod pearl_tests {
items: Some(&items),
lid_openers: &lid_openers,
mod_effects: None,
border: None,
};
let pw = ProjWorld {
w: &w,
+42 -7
View File
@@ -10,7 +10,7 @@ use horizon_client::{
gizmos::Gizmos,
hud::HudBatch,
hud_layout, keybinds,
text::Text,
text::{ClickEvent, Style, Text},
};
use horizon_core::geometry::{Axis, Vec3};
use horizon_protocol::packets::play::{BlockEntityTagQuery, EntityTagQuery, Serverbound};
@@ -722,11 +722,42 @@ impl Game<'_> {
self.export_repro_key();
true
}
DebugAction::DumpDynamicTextures => {
// `KeyboardHandler`: `TextureUtil.getDebugTexturePath` is the game
// folder's `screenshots/debug`; `TextureManager.dumpAllSheets` then
// writes every dumpable texture there and the chat names the folder.
let dir = horizon_client::logging::player_dir()
.join("screenshots")
.join("debug");
match self
.gui
.atlas
.dump_contents(&dir, "minecraft_textures_atlas_gui")
{
Ok(_) => {
log::info!("Dumped dynamic textures to {}", dir.display());
let link = Text::styled(
"screenshots/debug",
Style {
underlined: Some(true),
click: Some(ClickEvent::OpenFile(
dir.to_string_lossy().into_owned(),
)),
..Style::default()
},
);
self.debug_feedback(Text::translate(
"debug.dump_dynamic_textures",
&[link],
));
}
Err(e) => log::error!("Failed to dump dynamic textures: {e}"),
}
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
| DebugAction::Profiling
| DebugAction::ProfilingChart => true,
// its chart): the keys are used up as in vanilla.
DebugAction::Profiling | DebugAction::ProfilingChart => true,
DebugAction::ShowHitboxes
| DebugAction::ShowChunkBorders
| DebugAction::SwitchGameMode
@@ -922,8 +953,7 @@ impl Game<'_> {
}
let lerp = |a: f64, b: f64| a + f64::from(alpha) * (b - a);
let lerpf = |a: f32, b: f32| a + alpha * (b - a);
let guests = self.lan.iter().flat_map(|h| h.models.values());
for m in self.mobs.iter().chain(guests) {
for m in self.mobs.iter() {
if m.removed || m.invisible || m.has_effect("minecraft:invisibility") {
continue;
}
@@ -1129,6 +1159,11 @@ impl Game<'_> {
for b in &self.bolts {
g.hitbox(b.at, (0.0, 0.0), 0.0, false, (0.0, 0.0));
}
// AreaEffectCloud.getDimensions: diameter follows synced radius,
// height is 0.5; EntityDimensions.scalable gives eyes at 85% height.
for c in &self.potions.clouds {
g.hitbox(c.at, (c.radius * 2.0, 0.5), 0.425, false, (0.0, 0.0));
}
// The dragon: 16 by 8, a living entity; its parts follow.
if let Some(d) = &self.dragon {
g.hitbox(
@@ -172,8 +172,7 @@ impl Game<'_> {
}
"tps" => self.debug_tps_line(d),
// DebugEntryChunkGeneration, where the world is generated
// here: the window's server thread publishes its generators;
// the deprecated in-frame path is not extended.
// here: the window's server thread publishes its generators.
"chunk_generation_stats" => {
if let Some(p) = self.player.as_ref() {
let terrain = self
@@ -521,10 +520,6 @@ impl Game<'_> {
self.dragon_hit()
.map(|(_, d)| (d, "minecraft:ender_dragon".to_owned())),
);
offer(
self.guest_hit()
.map(|(_, d)| (d, "minecraft:player".to_owned())),
);
best.map(|b| b.1)
}
+4 -2
View File
@@ -637,6 +637,8 @@ struct ServerState {
max_players: usize,
/// The mutable `gamemode` server property (`DedicatedServer.gameMode`).
game_mode: i32,
/// The mutable `force-gamemode` server property (`DedicatedServer.forceGameMode`).
force_game_mode: bool,
}
/// The dedicated-only commands' replies vanilla sends with `broadcast`
@@ -931,7 +933,6 @@ impl Game<'_> {
levels: self.level_shapes,
seed: self.level.seed,
max_players: props.max_players.max(0) as usize,
dedicated: true,
integrated: false,
};
let settings = LanSettings {
@@ -942,7 +943,7 @@ impl Game<'_> {
if let Some(sync) = sync {
sync.adopt(&self.pack_files());
}
if let Err(e) = self.lan_host(settings, net, config, None) {
if let Err(e) = self.lan_host(settings, net, config) {
log("WARN", "**** FAILED TO BIND TO PORT!");
log("WARN", &format!("The exception was: {e}"));
log("WARN", "Perhaps a server is already running on that port?");
@@ -1005,6 +1006,7 @@ impl Game<'_> {
broadcast_rcon: props.broadcast_rcon_to_ops,
op_level: props.op_permission_level,
game_mode: props.game_mode,
force_game_mode: props.force_game_mode,
report: None,
view: (
props.view_distance.clamp(2, 32) as u32,
@@ -89,6 +89,22 @@ impl Game<'_> {
self.set_default_game_mode(state.game_mode);
return json!({ "value": GAME_MODES[state.game_mode as usize] });
}
"serversettings/force_game_mode" => {
return json!({ "value": state.force_game_mode });
}
"serversettings/force_game_mode/set" => {
let Some(force) = body["value"].as_bool() else {
return json!({ "error": "force game mode must be a boolean" });
};
rpc(format!(
"Update force game mode from {} to {}",
state.force_game_mode, force
));
self.store_property("force-gamemode", force.to_string());
state.force_game_mode = force;
self.set_force_game_mode(force);
return json!({ "value": state.force_game_mode });
}
"serversettings/difficulty" => {
return json!({ "value": DIFFICULTIES[self.level.difficulty_id() as usize] });
}
+4 -4
View File
@@ -3,7 +3,7 @@
//! `ProjectileDeflection.AIM_DEFLECT` instead of hurting anything. It flies on
//! along the player's view and is the player's now (`Projectile.deflect`
//! sets the owner), so a ghast it meets dies of it (`Ghast.isReflectedFireball`).
use super::{Game, ray_box, view_vector};
use super::{Game, lan::LanHost, ray_box, view_vector};
use horizon_client::{
mobs::Who,
projectiles::{Projectile, ProjectileKind},
@@ -125,7 +125,7 @@ impl Game<'_> {
self.creatures.projectiles[i].kind,
ProjectileKind::ShulkerBullet { .. }
) {
self.hit_shulker_bullet(i);
self.hit_shulker_bullet(None, i);
} else {
self.deflect(i);
}
@@ -135,7 +135,7 @@ impl Game<'_> {
/// `ShulkerBullet.hurtServer` from a player's blow: the hurt sound, fifteen crit
/// particles (`sendParticles`, spread 0.2) and `destroy`. The blow is
/// `Player.attack` on a target that is not a mob: its sounds, then the swing.
pub(super) fn hit_shulker_bullet(&mut self, i: usize) {
pub(super) fn hit_shulker_bullet(&mut self, host: Option<&LanHost>, i: usize) {
let p = self.creatures.projectiles.remove(i);
let at = [p.position.x, p.position.y, p.position.z];
self.audio
@@ -144,7 +144,7 @@ impl Game<'_> {
let options = horizon_client::particles::ParticleOptions::simple(ty);
self.particles
.send_particles(&options, at, [0.2, 0.2, 0.2], 0.0, 15);
self.lan_level_particles(&options, at, [0.2, 0.2, 0.2], 0.0, 15, None);
self.lan_level_particles(host, &options, at, [0.2, 0.2, 0.2], 0.0, 15, None);
}
let (_, _, _, magic) = self.melee_damage("minecraft:shulker_bullet", false);
self.vehicle_attack_sounds(p.aabb(), magic.then_some(p.id as i32));
@@ -1951,6 +1951,7 @@ impl Game<'_> {
.play("minecraft:entity.mooshroom.shear", sound_at, 1.0, 1.0);
// convertTo(COW): the cow takes its place, then the mushrooms drop.
self.convert_mob(i, "minecraft:cow");
self.mooshroom_shear_puff([at.x, at.y, at.z], height);
let drops = self.sequence_loot(
"minecraft:shearing/mooshroom",
&format!("minecraft:shearing/mooshroom/{variant}"),
+56 -7
View File
@@ -866,15 +866,15 @@ impl Game<'_> {
(-2..=1).all(|cz| (-2..=1).all(|cx| self.streaming.is_lit(cx, cz)))
}
/// A player's entity id: the acting player's body has it, another
/// player's comes from the LAN host (0 when it is out on a guest's turn,
/// and for an in-frame host with none).
/// player's comes from the hub (0 when it is out on a guest's turn, and
/// for no player).
fn fight_entity(&self, who: FightWho) -> i32 {
if who == self.acting_player() {
return self.player.as_ref().map_or(0, |p| p.entity_id);
}
let host = self.lan.as_ref();
match who {
FightWho::Host => host.map_or(0, |h| h.host_entity),
FightWho::Host => 0,
FightWho::Guest(conn) => host
.and_then(|h| h.hub.guest(conn))
.map_or(0, |g| g.entity_id),
@@ -950,9 +950,6 @@ impl Game<'_> {
/// `EnderDragonFight.tick` and the fight's entities, while the End is
/// ticked (in place for its player, or on a server with players in it).
pub(super) fn tick_fight(&mut self) {
if self.travelling.is_some() {
return;
}
// A dragon outside the End (`/summon`) has no fight: `ServerLevel.getDragonFight` is
// null there, but `EnderDragon.aiStep` still flies it.
if self.dim != Dim::End {
@@ -1389,7 +1386,7 @@ impl Game<'_> {
let hurt = self.hit_special(id, amount, true, DamageKind::PlayerAttack, None);
// The sweep and the sounds read the charge, which the hit then spends.
if let (Some(area), Hurt::Landed { .. }) = (area, hurt) {
self.attack_effects(area, None, super::mace::Spared::Nothing, None);
self.attack_effects(area, None, false, super::mace::Spared::Nothing, None);
}
self.attack_ticks = 0;
true
@@ -2188,6 +2185,27 @@ impl Game<'_> {
set(d, 3 + i, x, y, z);
}
}
/// `Level.getEntity`: revenge goals must resolve the dragon too.
pub(super) fn dragon_target(&self) -> Option<(u32, horizon_client::mobs::TargetInfo)> {
let d = self.dragon.as_ref().filter(|d| d.alive())?;
let p = d.position;
Some((
d.id,
horizon_client::mobs::TargetInfo {
position: p,
eye_y: p.y + 6.8,
bounds: Aabb::new(
Vec3::new(p.x - 8.0, p.y, p.z - 8.0),
Vec3::new(p.x + 8.0, p.y + 8.0, p.z + 8.0),
),
alive: true,
can_be_seen_as_enemy: true,
is_player: false,
creative: false,
passenger: false,
},
))
}
/// Wing knockback, head and neck damage, and breaking blocks in the way.
fn dragon_contacts(&mut self) {
let Some(d) = self.dragon.as_ref() else {
@@ -2232,6 +2250,35 @@ impl Game<'_> {
}
}
}
// EnderDragon.aiStep queries living entities, not only players.
if let Some(d) = self.dragon.as_ref() {
let frame = horizon_client::mobs::dragon_contacts::Frame {
dragon_id: d.id,
origin: d.position,
center: (bc, bz),
wings: wings.map(|w| {
w.inflate(Vec3::new(4.0, 2.0, 4.0))
.move_by(Vec3::new(0.0, -2.0, 0.0))
}),
heads: heads.map(|h| h.inflate(Vec3::new(1.0, 1.0, 1.0))),
sitting,
recently_hurt,
};
// Keep team lookup in Game (including tamed pets' owner teams).
let allowed: Vec<bool> = self
.mobs
.iter()
.map(|m| {
horizon_client::scoreboard::teams_allow_damage(
self.mob_team(d.id),
self.mob_team_of(m),
)
})
.collect();
for (mob, allowed) in self.mobs.iter_mut().zip(allowed) {
horizon_client::mobs::dragon_contacts::contact(mob, &frame, allowed);
}
}
// checkWalls over head, neck and body.
let griefing = self.ui.rules.bool("mob_griefing");
let mut in_wall = false;
@@ -3254,6 +3301,7 @@ impl Game<'_> {
head_yaw: d.yaw,
on_ground: false,
velocity: [d.velocity.x, d.velocity.y, d.velocity.z],
minecart_steps: None,
data: 0,
meta,
defaults: Vec::new(),
@@ -3266,6 +3314,7 @@ impl Game<'_> {
leash: None,
spectator: false,
watching: false,
hidden: None,
})
}
}
+1 -118
View File
@@ -11,7 +11,6 @@ use horizon_client::{
items::ItemStack,
mesher::Vertex,
mobs::Who,
motion::PlayerMotion,
particles::{ParticleOptions, ParticleType},
projectiles::{Projectile, ProjectileKind},
vanillagen::VanillaTerrain,
@@ -115,7 +114,6 @@ fn exit_target(exit: [i32; 3], exact: bool, find: impl FnOnce([i32; 3]) -> [i32;
#[derive(Default)]
pub(super) struct EndState {
job: Option<GatewayJob>,
/// Ticks until the player can use a gateway again (TheEndGatewayBlockEntity cooldown).
gateway_cooldown: u32,
gateways: HashMap<u32, horizon_client::end_gateway::Traveller>,
@@ -531,43 +529,6 @@ impl Game<'_> {
if self.dim == Dim::End {
self.ensure_exit_portal()?;
}
let Some(p) = self.player.as_ref() else {
return Ok(());
};
if self.travelling.is_some() || p.health.dead_or_dying() {
return Ok(());
}
let (portal, gateway) = self.inside_end_portals(
p.position,
f64::from(p.dimensions.width),
f64::from(p.dimensions.height),
);
if portal && self.dim == Dim::End && !self.level.seen_credits {
// EndPortalBlock.entityInside: the first exit shows the credits.
self.show_end_credits()?;
} else if portal {
if self.portals.player.cooldown == 0 {
let target = if self.dim == Dim::End {
Dim::Overworld
} else {
Dim::End
};
self.end_portal_travel = true;
let result = self.enter_dimension(target);
self.end_portal_travel = false;
result?;
// Player.getDimensionChangingDelay.
self.portals.player.cooldown = 10;
} else {
// Entity.setAsInsidePortal while cooling down restarts the cooldown.
self.portals.player.cooldown = 10;
}
} else if let Some(g) = gateway
&& self.end.gateway_cooldown == 0
&& self.gateway_enter(g)
{
self.enter_gateway(g)?;
}
Ok(())
}
@@ -629,55 +590,6 @@ impl Game<'_> {
Some(Vec3::new(s[0].floor() + 0.5, s[1], s[2].floor() + 0.5))
}
/// Arrival after End portal or gateway travel, once the destination is loaded.
pub(super) fn place_after_end_travel(
&mut self,
mut player: PlayerMotion,
target: Vec3,
) -> Result<()> {
self.end_travel = false;
// Player.getDimensionChangingDelay (enter_dimension uses the Nether portal value).
self.portals.player.cooldown = 10;
if player.health.dead_or_dying() {
// Respawning out of the End: a fresh player at the spawn.
player = PlayerMotion::new(self.motion, target)?;
self.eye = 1.62;
self.previous_eye = 1.62;
self.clock.reset();
}
let position = if let Some(job) = self.end.job.take() {
self.resolve_gateway(job, target)?
} else {
let mut finder = self.player_spawn.take();
let arrival = self.end_arrival(target, &mut finder);
self.player_spawn = finder;
let Some((position, yaw)) = arrival? else {
// A spawn candidate's chunk is loading: try again next tick.
self.travelling = Some((player, target));
self.end_travel = true;
return Ok(());
};
if let Some(yaw) = yaw {
self.yaw = yaw;
player.yaw = yaw;
player.head_yaw = yaw;
}
position
};
player.position = position;
player.velocity = Vec3::ZERO;
player.fall_distance = 0.0;
self.previous_position = player.position;
self.player = Some(player);
self.pad.player_replaced();
self.sway = Default::default();
self.apply_equipment();
let pitch = self.world.random.next_float() * 0.4 + 0.8;
self.audio
.play("minecraft:block.portal.travel", None, 0.25, pitch);
Ok(())
}
/// Where an End portal's traveller lands once `target` has loaded, and
/// the yaw it faces there: the platform below the End's spawn point,
/// facing west, or the top of the Overworld's spawn column.
@@ -736,27 +648,13 @@ impl Game<'_> {
Ok(Some((Vec3::new(target.x, y, target.z), None)))
}
/// Dying away from the Overworld respawns at the world spawn there.
pub(super) fn respawn_out_of_end(&mut self) -> Result<()> {
if self.dim == Dim::Overworld || self.player.is_none() {
return Ok(());
}
self.end_portal_travel = true;
let result = self.enter_dimension(Dim::Overworld);
self.end_portal_travel = false;
// The arrival places a fresh player at the spawn.
self.end_travel = true;
result
}
/// EnderDragonFight without a dragon: once the centre of the main island
/// is loaded, place the active exit portal (EndPodiumFeature) and the
/// first gateway (spawnNewGateway) if the portal is not there yet. Like
/// scanState and spawnExitPortal, only while the fight has no exit portal
/// location: a podium that is later broken stays broken.
fn ensure_exit_portal(&mut self) -> Result<()> {
if self.travelling.is_some()
|| !(-1..=0).all(|cz| (-1..=0).all(|cx| self.streaming.is_lit(cx, cz)))
if !(-1..=0).all(|cz| (-1..=0).all(|cx| self.streaming.is_lit(cx, cz)))
|| !self.fight_ready()
{
return Ok(());
@@ -980,21 +878,6 @@ impl Game<'_> {
}
}
/// EndGatewayBlock.entityInside and TheEndGatewayBlockEntity.getPortalPosition:
/// travel within the End, waiting for the destination chunks to load.
fn enter_gateway(&mut self, gateway: [i32; 3]) -> Result<()> {
// TheEndGatewayBlockEntity.triggerCooldown: 40 ticks.
self.end.gateway_cooldown = 40;
let (job, destination) = self.gateway_job(gateway);
let Some(player) = self.player.take() else {
return Ok(());
};
self.end.job = Some(job);
self.end_travel = true;
self.travelling = Some((player, destination));
Ok(())
}
/// Where `gateway` leads (TheEndGatewayBlockEntity.getPortalPosition):
/// its exit, or the search for one, and the spot to load before either
/// is resolved ([`Game::resolve_gateway`]).
+223 -10
View File
@@ -13,6 +13,7 @@ use super::{
boats::Boat, fireworks::Rocket, minecarts::Minecart, tnt::PrimedTnt, trident::ThrownTrident,
};
use horizon_client::entities::{ArmorStand, ExperienceOrb, ItemEntity};
use horizon_client::entity_rules::EntityRules;
use horizon_client::mobs::{Mob, Species};
use horizon_client::projectiles::Projectile;
use horizon_core::geometry::{Aabb, Direction, Vec3};
@@ -47,7 +48,9 @@ pub(super) struct Blast {
pub(super) spared: Option<u32>,
}
pub(super) trait Entity {
pub(super) trait Entity: EntityRules {
/// `Entity.getId`.
fn id(&self) -> u32;
fn position(&self) -> Vec3;
fn position_mut(&mut self) -> &mut Vec3;
fn velocity_mut(&mut self) -> &mut Vec3;
@@ -132,10 +135,82 @@ pub(super) trait Entity {
fn is_supported_by(&self, _pos: BlockPos) -> bool {
false
}
/// `Entity.getEyeY`, where `updateFluidHeightAndDoFluidPushing` reads the
/// eye's fluid; the top of the box unless a kind sets it.
fn fluid_eye_y(&self) -> f64 {
self.bounds().max_position().y
}
/// `Entity.updateFluidHeightAndDoFluidPushing`: its water and lava heights
/// (`(0, 0)` for a kind that is not pushed by fluid), and the current of
/// each fluid added to its velocity. One body for every kind.
fn push_by_fluid(
&mut self,
world: &horizon_client::world::World,
fluids: &horizon_client::motion::FluidResources,
) -> (f64, f64) {
if !self.is_pushed_by_fluid() {
return (0.0, 0.0);
}
let (bounds, at, eye) = (self.bounds(), self.position(), self.fluid_eye_y());
horizon_client::entities::update_entity_fluids(
bounds,
at,
eye,
self.velocity_mut(),
world,
fluids,
)
}
/// `BubbleColumnBlock.entityInside` through `applyEffectsFromBlocks`: a
/// column lifts or drags every kind alike.
fn push_by_bubble_column(
&mut self,
world: &horizon_client::world::World,
registry: &horizon_world::registry::BlockRegistry,
facts: &horizon_client::blocks::BlockFacts,
collision: &horizon_world::block_collision::BlockCollisionRegistry<'_>,
) -> bool {
let bounds = self.bounds();
horizon_client::motion::bubble_column_push(
world,
registry,
facts,
collision,
bounds,
self.velocity_mut(),
)
}
/// `Entity.getPistonPushReaction`.
fn get_piston_push_reaction(&self) -> PushReaction {
PushReaction::PushPull
}
/// Width and height of the box a portal sweep follows, or None for a kind
/// the game does not carry through portals (so far armor stands and
/// falling blocks; fishing hooks are off the walk, and
/// `FishingHook.canUsePortal` is false in vanilla).
fn portal_size(&self) -> Option<(f32, f32)> {
None
}
/// `Entity.canUsePortal(false)` for a kind in `portal_size`. A kind that
/// cannot stays tracked, so its cooldown and process run out.
fn can_use_portal(&self, _game: &Game<'_>) -> bool {
true
}
/// A boat or minecart: the kinds whose portal pass opens the tick, before
/// they move (`AbstractMinecart.tick` handles its portal first).
fn is_vehicle(&self) -> bool {
false
}
/// Where it stood at the start of its tick, for the kinds that store it
/// (None: `portals.before` has it).
fn portal_from(&self) -> Option<Vec3> {
None
}
/// Whether this tick's movement can have entered a portal block: a stuck
/// arrow or a trident in the ground that did not move enters none.
fn portal_sweeps(&self) -> bool {
true
}
/// `Entity.move(MoverType.PISTON, …)` once the movement is resolved.
fn move_by_piston(&mut self, movement: Vec3) {
let position = self.position_mut();
@@ -152,6 +227,9 @@ pub(super) trait Entity {
/// The fields every kind stores under the same names.
macro_rules! stored_motion {
() => {
fn id(&self) -> u32 {
self.id
}
fn position(&self) -> Vec3 {
self.position
}
@@ -331,6 +409,12 @@ pub(super) use with_entity_mut;
impl Entity for Mob {
stored_motion!();
fn portal_size(&self) -> Option<(f32, f32)> {
Some((self.width(), self.height()))
}
fn can_use_portal(&self, game: &Game<'_>) -> bool {
game.mob_can_use_portal(self)
}
fn selector_target(&self) -> Option<Target> {
(self.death_time == 0).then_some(Target::Mob(self.id))
}
@@ -363,16 +447,11 @@ impl Entity for Mob {
|| (self.species() == Species::Warden
&& horizon_client::mobs::warden::digging_or_emerging(self))
}
/// The kind's own box, not the baby's or the slime's.
fn explosion_box(&self) -> Aabb {
let (feet, half) = (self.position, self.kind.width / 2.0);
Aabb::new(
Vec3::new(feet.x - half, feet.y, feet.z - half),
Vec3::new(feet.x + half, feet.y + self.kind.height, feet.z + half),
)
}
/// `getEyeHeight` of the mob as it stands: a baby's, a slime's or a
/// phantom's own scaled eye height, not the adult kind's. The blast's box
/// is its real `bounds`, so it needs no override.
fn explosion_eye_height(&self) -> f64 {
self.kind.eye_height
self.eye_height()
}
/// `Entity.push` sets `needsSync`.
fn push_from_explosion(&mut self, push: Vec3) {
@@ -405,6 +484,9 @@ impl Entity for Mob {
impl Entity for ItemEntity {
stored_motion!();
fn portal_size(&self) -> Option<(f32, f32)> {
Some((0.25, 0.25))
}
fn selector_target(&self) -> Option<Target> {
Some(Target::Item(self.id))
}
@@ -437,6 +519,9 @@ impl Entity for ItemEntity {
impl Entity for ExperienceOrb {
stored_motion!();
fn portal_size(&self) -> Option<(f32, f32)> {
Some((0.5, 0.5))
}
fn selector_target(&self) -> Option<Target> {
Some(Target::Orb(self.id))
}
@@ -466,6 +551,19 @@ impl Entity for ExperienceOrb {
impl Entity for Boat {
stored_motion!();
fn is_vehicle(&self) -> bool {
true
}
/// `Boat.getEyeY`: the top of the 0.5625 box.
fn fluid_eye_y(&self) -> f64 {
self.position.y + super::boats::HEIGHT
}
fn portal_size(&self) -> Option<(f32, f32)> {
Some((super::boats::WIDTH as f32, super::boats::HEIGHT as f32))
}
fn can_use_portal(&self, game: &Game<'_>) -> bool {
game.vehicle_can_use_portal(self.id)
}
fn selector_target(&self) -> Option<Target> {
Some(Target::Boat(self.id))
}
@@ -489,6 +587,18 @@ impl Entity for Boat {
impl Entity for Minecart {
stored_motion!();
fn is_vehicle(&self) -> bool {
true
}
fn portal_size(&self) -> Option<(f32, f32)> {
Some((
super::minecarts::WIDTH as f32,
super::minecarts::HEIGHT as f32,
))
}
fn can_use_portal(&self, game: &Game<'_>) -> bool {
game.vehicle_can_use_portal(self.id)
}
fn selector_target(&self) -> Option<Target> {
Some(Target::Cart(self.id))
}
@@ -567,6 +677,12 @@ impl Entity for ArmorStand {
fn is_ignoring_block_triggers(&self) -> bool {
self.marker
}
/// A NoGravity stand is not swept for the blocks it stands in (plates,
/// fire): measured beside the real server, it never presses a plate nor
/// catches fire, and does again once `NoGravity` is cleared.
fn sweeps_blocks_inside(&self) -> bool {
!self.no_gravity
}
fn pushed_by_explosion(&self) -> bool {
false
}
@@ -582,6 +698,17 @@ impl Entity for ArmorStand {
impl Entity for PrimedTnt {
stored_motion!();
/// `PrimedTnt` eye height: `0.98 * 0.85`.
fn fluid_eye_y(&self) -> f64 {
self.position.y + f64::from(0.98f32 * 0.85f32)
}
/// `PrimedTnt` is 0.98 across.
fn portal_size(&self) -> Option<(f32, f32)> {
Some((0.98, 0.98))
}
fn can_use_portal(&self, game: &Game<'_>) -> bool {
game.tnt_can_use_portal(self.id)
}
fn selector_target(&self) -> Option<Target> {
Some(Target::Tnt(self.id))
}
@@ -615,6 +742,17 @@ impl Entity for FallingBlock {
impl Entity for Projectile {
stored_motion!();
/// Arrows and the thrown items and fireballs a chunk saves.
fn portal_size(&self) -> Option<(f32, f32)> {
(super::nether_portal::is_arrow(self) || super::nether_portal::is_portal_projectile(self))
.then(|| super::nether_portal::projectile_box(self))
}
fn portal_from(&self) -> Option<Vec3> {
Some(self.previous)
}
fn portal_sweeps(&self) -> bool {
!super::nether_portal::is_arrow(self) || !self.stuck || self.previous != self.position
}
fn selector_target(&self) -> Option<Target> {
use horizon_client::projectiles::ProjectileKind as K;
// The kinds /data can write (entity_nbt).
@@ -657,6 +795,13 @@ impl Entity for Projectile {
impl Entity for Rocket {
stored_motion!();
/// A rocket that carries its owner stays.
fn portal_size(&self) -> Option<(f32, f32)> {
(self.attached_to.is_none() && !self.attached).then_some((0.25, 0.25))
}
fn portal_from(&self) -> Option<Vec3> {
Some(self.previous)
}
fn selector_target(&self) -> Option<Target> {
Some(Target::Rocket(self.id))
}
@@ -675,6 +820,15 @@ impl Entity for Rocket {
impl Entity for ThrownTrident {
stored_motion!();
fn portal_size(&self) -> Option<(f32, f32)> {
Some((0.5, 0.5))
}
fn portal_from(&self) -> Option<Vec3> {
Some(self.previous)
}
fn portal_sweeps(&self) -> bool {
!self.in_ground || self.previous != self.position
}
fn selector_target(&self) -> Option<Target> {
// No `Target` variant for it yet: `@e` does not find tridents.
None
@@ -699,3 +853,62 @@ impl Entity for ThrownTrident {
false
}
}
impl EntityRules for Rocket {}
impl EntityRules for ThrownTrident {}
impl EntityRules for Boat {
fn is_no_gravity(&self) -> bool {
self.no_gravity
}
/// `AbstractBoat.getDefaultGravity`.
fn get_default_gravity(&self) -> f64 {
0.04
}
}
impl EntityRules for Minecart {
fn is_no_gravity(&self) -> bool {
self.no_gravity
}
/// `AbstractMinecart.getDefaultGravity`.
fn get_default_gravity(&self) -> f64 {
if self.in_water { 0.005 } else { 0.04 }
}
}
impl EntityRules for PrimedTnt {
fn is_no_gravity(&self) -> bool {
self.no_gravity
}
/// `PrimedTnt.getDefaultGravity`.
fn get_default_gravity(&self) -> f64 {
0.04
}
}
impl EntityRules for FallingBlock {
fn is_no_gravity(&self) -> bool {
self.no_gravity
}
/// `FallingBlockEntity.getDefaultGravity`.
fn get_default_gravity(&self) -> f64 {
0.04
}
}
#[cfg(test)]
mod no_gravity_stand_tests {
use super::Entity as _;
use horizon_client::entities::ArmorStand;
use horizon_core::geometry::Vec3;
#[test]
fn a_no_gravity_stand_is_not_swept_for_the_blocks_it_is_in() {
let mut stand = ArmorStand::new(1, Vec3::new(0.5, 100.0, 0.5), 0.0);
// A resting stand presses a plate (measured beside the real server)...
assert!(stand.sweeps_blocks_inside() && stand.triggers_blocks());
// ...a NoGravity one does not, nor does a marker.
stand.no_gravity = true;
assert!(!stand.sweeps_blocks_inside());
stand.no_gravity = false;
stand.marker = true;
assert!(!stand.triggers_blocks());
}
}
@@ -1,5 +1,6 @@
//! A Game's execution role is fixed at construction. Connecting or disconnecting
//! changes `remote`, not this role. A client may simulate a world in-frame.
//! changes `remote`, not this role. A client never simulates a world itself: single
//! player's runs on its integrated server thread.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum ExecutionRole {
@@ -14,34 +15,10 @@ impl ExecutionRole {
self == Self::Client
}
/// The optional in-frame simulation remains a client execution mode.
pub fn starts_integrated_server(self, server_thread: bool) -> bool {
self.is_client() && server_thread
}
/// Single-player worlds (in-frame or on the integrated server thread) keep
/// the owner's state in `Level` and write `players/data/<singleplayer_uuid>.dat`
/// with the world; a dedicated server saves each connected player separately.
/// Single-player worlds (on the integrated server thread) keep the owner's state in
/// `Level` and write `players/data/<singleplayer_uuid>.dat` with the world; a dedicated
/// server saves each connected player separately.
pub fn writes_singleplayer_player(self) -> bool {
self != Self::DedicatedServer
}
}
#[cfg(test)]
mod tests {
use super::ExecutionRole::*;
#[test]
fn world_loading_keeps_in_frame_clients_and_server_roles_separate() {
for (role, client, threaded, writes_player) in [
(Client, true, true, true),
(IntegratedServer, false, false, true),
(DedicatedServer, false, false, false),
] {
assert_eq!(role.is_client(), client);
assert_eq!(role.starts_integrated_server(true), threaded);
assert!(!role.starts_integrated_server(false));
assert_eq!(role.writes_singleplayer_player(), writes_player);
}
}
}
@@ -1362,4 +1362,38 @@ mod tests {
add_or_append_stack(&mut stacks, pick(5), [0, 0, 0], &max);
assert_eq!(stacks.len(), 2);
}
/// A blast and a burst of wind cast their rays at, and push from the eyes of,
/// the mob as it stands: a baby's and a size-4 slime's own box and eye
/// height, not the adult kind's (`Entity.getBoundingBox`, `getEyeHeight`).
#[test]
fn a_scaled_mob_is_blasted_by_its_own_box_and_eye_height() {
use crate::game::entity::Entity;
use horizon_client::mobs::{Mob, Species};
let mob = |species| {
let mut kind = (*zombie_type()).clone();
kind.species = species;
Mob::new(1, std::sync::Arc::new(kind), Vec3::ZERO, 0.0)
};
let adult = mob(Species::Zombie);
assert_eq!(adult.explosion_box(), adult.bounds());
let mut baby = mob(Species::Zombie);
baby.extra.age = -24000;
let mut slime = mob(Species::Slime);
slime.extra.size = 4;
for m in [&baby, &slime] {
let b = Entity::explosion_box(m);
assert_eq!(b, m.bounds());
assert_eq!(Entity::explosion_eye_height(m), m.eye_height());
assert_ne!(
f64::from(m.height()),
f64::from(adult.height()),
"the case must scale the box"
);
assert_eq!(
b.max_position().y - b.min_position().y,
f64::from(m.height())
);
}
}
}
+18 -1
View File
@@ -245,7 +245,9 @@ impl Game<'_> {
}
/// `FireworkRocketItem.useOn`: launch from the clicked point.
pub(super) fn launch_firework(&mut self, hit: &Hit, stack: &ItemStack) -> bool {
if &*stack.item != "minecraft:firework_rocket" {
if &*stack.item != "minecraft:firework_rocket"
|| use_on_passes(self.player.as_ref().is_some_and(|p| p.fall_flying))
{
return false;
}
let at = Vec3::new(
@@ -716,3 +718,18 @@ impl Game<'_> {
}
}
}
/// `FireworkRocketItem.useOn` returns PASS while the player is fall flying,
/// leaving the boost to `use` (a guest's UseItemOn must not launch a rocket).
fn use_on_passes(fall_flying: bool) -> bool {
fall_flying
}
#[cfg(test)]
mod tests {
#[test]
fn use_on_passes_only_while_gliding() {
assert!(super::use_on_passes(true));
assert!(!super::use_on_passes(false));
}
}
+2 -19
View File
@@ -803,17 +803,6 @@ impl Game<'_> {
/// (`Projectile.canHitEntity`), the host and the guests in the level.
pub(super) fn refresh_hook_targets(&mut self, host: &LanHost) {
let mut targets = Vec::new();
if self.options.dedicated.is_none()
&& !self.spectator()
&& let Some(p) = self.player.as_ref()
&& !p.health.dead_or_dying()
{
targets.push(HookTarget {
id: host.host_entity as u32,
conn: None,
bounds: p.bounds(),
});
}
for g in &host.hub.guests {
if host.hub.here(g) && g.loaded && !g.dead && !g.spectator() {
targets.push(HookTarget {
@@ -838,9 +827,7 @@ impl Game<'_> {
p.velocity = p.velocity.add_vector(delta);
}
};
if id == host.host_entity as u32 {
push(self);
} else if let Some(conn) = host
if let Some(conn) = host
.hub
.guests
.iter()
@@ -1174,17 +1161,13 @@ impl Hook {
}
impl Game<'_> {
/// The hooks guests track: the host's and each guest's in the level.
/// The hooks guests track: each player's in the level.
pub(super) fn lan_hook_states(
&self,
host_entity: Option<i32>,
play: &HashMap<u32, GuestPlay>,
hub: &Hub,
out: &mut States,
) {
if let (Some(h), Some(owner)) = (&self.hook, host_entity) {
h.track(owner, out);
}
for g in hub.guests.iter().filter(|g| hub.here(g)) {
if let Some(h) = play.get(&g.conn).and_then(|p| p.hook.as_ref()) {
h.track(g.entity_id, out);
-1
View File
@@ -423,7 +423,6 @@ impl Game<'_> {
|| before(self.interaction_hit().map(|h| h.1))
|| before(self.dragon_hit().map(|h| h.1))
|| before(self.mob_hit().map(|h| h.1))
|| before(self.guest_hit().map(|h| h.1))
|| before(self.redirectable_hit().map(|h| h.1))
}
/// `LocalPlayer.isHandsBusy`: paddling a boat.
+17 -10
View File
@@ -6,10 +6,8 @@
//! the world's options) with [`Integrated::call`], as vanilla's client calls
//! into its `IntegratedServer`.
//!
//! On by default. `--in-frame` (or `--no-server-thread`, or
//! `HORIZON_SERVER_THREAD=0`) runs the world in the frame instead. That path
//! is DEPRECATED: never use it, it gets no fixes and will be removed soon.
//! LAN guests and dedicated servers use the same code either way.
//! Every single-player world runs this way; LAN guests and dedicated servers
//! use the same server code.
use super::{
Dim, Game, Options, PackResources, Resources, UiResources, WorldIcon,
execution_role::ExecutionRole,
@@ -79,6 +77,9 @@ struct Flags {
/// What the server's `LevelLoadListener` reports, for the window's
/// `LevelLoadingScreen` (`LevelLoadTracker.serverProgress/statusView`).
load: Arc<horizon_client::level_loading::ServerLoadView>,
/// What the world's upgrade has done (`UpgradeProgress`): the window shows it on
/// `FileFixerProgressScreen` while the file fixers run, and its Cancel stops them.
upgrade: Arc<horizon_world::datafix::progress::UpgradeProgress>,
/// Where the window's debug entries want the server's lines for (its
/// dimension and feet), and the lines of the last tick there.
debug_at: Mutex<Option<(Dim, [i32; 3])>>,
@@ -108,6 +109,10 @@ impl Integrated {
pub fn load_view(&self) -> &Arc<horizon_client::level_loading::ServerLoadView> {
&self.flags.load
}
/// The world's upgrade while the server opens it.
pub fn upgrade(&self) -> &horizon_world::datafix::progress::UpgradeProgress {
&self.flags.upgrade
}
pub fn smoothed_tick_ms(&self) -> f32 {
f32::from_bits(self.flags.smoothed_tick_ms.load(Ordering::Relaxed))
}
@@ -378,7 +383,6 @@ impl Game<'_> {
// A server without a console: no local player, no window, no control socket.
// Compatibility for gameplay checks outside startup/world loading.
options.dedicated = Some(Vec::new());
options.server_thread = false;
options.execution_role = ExecutionRole::IntegratedServer;
options.control = None;
options.benchmark = None;
@@ -473,6 +477,12 @@ impl Game<'_> {
let (pipe, world_name) = match starting.ready.try_recv() {
Ok(Ok(ready)) => ready,
Err(std::sync::mpsc::TryRecvError::Empty) => return Ok(()),
// FileFixerProgressScreen's Cancel: the upgrade stopped, back to the title.
Ok(Err(_)) if starting.handle.upgrade().is_canceled() => {
self.integrated_starting = None;
self.perf.cancel_world_opening();
return Ok(());
}
Ok(Err(e)) => {
self.integrated_starting = None;
return Err(e.into());
@@ -738,7 +748,6 @@ impl Game<'_> {
seed: self.level.seed,
max_players: MAX_PLAYERS,
// No player of its own: the hub runs without a host.
dedicated: true,
integrated: true,
};
let owner = launch.owner;
@@ -777,9 +786,7 @@ impl Game<'_> {
guest_commands: false,
force_game_mode: false,
};
startup::time("server.lan_host", || {
self.lan_host(settings, net, config, None)
})?;
startup::time("server.lan_host", || self.lan_host(settings, net, config))?;
let commands = self.allow_commands();
let pipe = {
let host = self
@@ -1163,6 +1170,7 @@ fn serve(
pack.clone(),
options.clone(),
settings.clone(),
flags.upgrade.clone(),
)
});
let opening = Opening {
@@ -1203,7 +1211,6 @@ impl Game<'_> {
let mut options = self.options.clone();
options.execution_role = ExecutionRole::IntegratedServer;
options.dedicated = Some(Vec::new());
options.server_thread = false;
let result = std::thread::Builder::new()
.name("Server thread".into())
.spawn(move || {
+47 -5
View File
@@ -460,6 +460,38 @@ impl Game<'_> {
&mut sink,
);
}
/// [`Self::submit_item_lit`] of `state` under each of `poses` on the GPU path: the mark
/// before its draws and their bounds, or `None` (nothing drawn) when the state cannot
/// take it.
pub(super) fn submit_item_skinned(
&self,
state: &ItemRenderState,
poses: &[glam::Mat4],
light: u32,
lights: (glam::Vec3, glam::Vec3),
) -> Option<((usize, usize, u32), Option<(glam::Vec3, glam::Vec3)>)> {
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,
};
let sink = (
item_hdr_light(state, light),
(state.hdr_light == 0).then_some(lights),
state.hdr_tier,
);
for &pose in poses {
let renderer = &self.pack.item_renderer;
if !state.submit_skinned(renderer, &self.gui.atlas.sprites, pose, sink, &mut skin) {
// Decided before any draw, the same for every pose.
return None;
}
}
Some((mark, skin.bounds))
}
/// `ItemEntityRenderer.submit` for every dropped item.
pub(super) fn dropped_item_geometry(
&self,
@@ -488,6 +520,8 @@ impl Game<'_> {
// 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());
let skinning = self.skinning();
let lights = diffuse_lights(self.dim == Dim::Nether);
for (item, at, partial, entity) in live.chain(taken) {
let entity_start = out.len();
self.mark_motion(entity_start, (2 << 32) | u64::from(item.id));
@@ -538,19 +572,27 @@ impl Game<'_> {
);
let seed = i64::from(self.pack.item_renderer.item_id(&item.stack.item))
+ i64::from(item.stack.damage);
for copy in item_render::cluster_poses(
let copies = item_render::cluster_poses(
&state,
pose,
item_render::rendered_amount(item.stack.count),
seed,
) {
self.submit_item_world(&state, copy, vertex_light(packed), out);
);
let glowing = self.entity_glowing(item.id);
// Posed on the GPU when every layer can be (the outline reads the CPU's vertices).
let skinned = (skinning && !glowing)
.then(|| self.submit_item_skinned(&state, &copies, vertex_light(packed), lights))
.flatten();
if skinned.is_none() {
for &copy in &copies {
self.submit_item_world(&state, copy, vertex_light(packed), out);
}
}
if self.entity_glowing(item.id) {
if glowing {
let rgb = super::glowing::team_color(self.mob_team(item.id));
self.mark_outline(&out[entity_start..], rgb);
} else {
self.cull_entity_vertices(out, entity_start);
self.cull_skinned(out, entity_start, skinned);
}
}
}
+21 -2
View File
@@ -204,6 +204,15 @@ pub(super) fn release_parts(canceled: bool, hand_changed: bool) -> (bool, bool)
(!canceled && !hand_changed, !canceled)
}
pub(super) fn bow_release_power(has_ammo: bool, charge: i32, canceled: bool) -> Option<f32> {
if canceled || charge < 0 || !has_ammo {
return None;
}
let f = charge as f32 / 20.0;
let power = ((f * f + f * 2.0) / 3.0).min(1.0);
(power >= 0.1).then_some(power)
}
/// `useItemRemaining` of a use of `duration` ticks, `ticks` old.
fn ticks_left(duration: i32, ticks: u32) -> i32 {
duration.saturating_sub_unsigned(ticks)
@@ -272,8 +281,8 @@ pub(super) fn use_pose(
#[cfg(test)]
mod tests {
use super::{
consumes_at_once, count_down, duration_leaving, held, release_parts, start_of, tick_left,
ticks_left, use_pose,
bow_release_power, consumes_at_once, count_down, duration_leaving, held, release_parts,
start_of, tick_left, ticks_left, use_pose,
};
use horizon_client::first_person::ItemPose;
use horizon_world::consumable::UseAnimation;
@@ -367,6 +376,16 @@ mod tests {
assert_eq!(release_parts(false, true), (false, true));
}
#[test]
fn bow_release_respects_charge_cancel_and_ammo() {
assert_eq!(bow_release_power(true, 20, false), Some(1.0));
assert_eq!(bow_release_power(true, 10, false), Some(5.0 / 12.0));
assert_eq!(bow_release_power(true, 0, false), None);
assert_eq!(bow_release_power(true, -1, false), None);
assert_eq!(bow_release_power(true, 20, true), None);
assert_eq!(bow_release_power(false, 20, false), None);
}
#[test]
fn a_moved_end_moves_the_charge() {
// a bow held 10 ticks of 72000: 10, as vanilla's.
+132 -446
View File
@@ -2,25 +2,22 @@
//! server threads and the play-state hub, feeds it the world's entities each
//! tick and turns what the guests do into gameplay. A world that is not open
//! costs one `Option` check per tick.
use super::{Game, shield::OFFHAND_SLOT, shoulders::shoulder_variants};
use super::{Game, shield::OFFHAND_SLOT};
use horizon_client::{
Result,
items::ItemStack,
lan::{
self, Lan, MAX_PLAYERS, NetConfig,
self, Lan, NetConfig,
chunks::ChunkEncoder,
data::SyncData,
entity::{EntityState, States},
hub::{BodyFlags, HostEvent, HostState, Hub, HubConfig, TickCtx},
hub::{HostEvent, Hub, HubConfig, TickCtx},
playerdata,
},
mobs::{Mob, Species, Who, leash::Holder},
text::Text,
world::World,
};
use horizon_core::geometry::Vec3;
use horizon_protocol::{
crypto::offline_uuid,
entity_data::{Value, serializer},
packets::play::{self, Clientbound as C},
};
@@ -81,6 +78,7 @@ pub(super) fn reload_sync_data(
let packs = !overlay.files.is_empty() || kept.has_pack_entries();
let sync = SyncData::load(root, overlay).map(|mut sync| {
sync.keep_registries(root, kept);
sync.features.clone_from(&kept.features);
Arc::new(sync)
});
*SYNC.lock().unwrap_or_else(|e| e.into_inner()) = Some((packs, sync.clone()));
@@ -153,23 +151,15 @@ pub(super) struct LanHost {
/// The mods heard that everyone still here leaves with the server.
pub players_told_left: bool,
pub settings: LanSettings,
pub host_entity: i32,
/// The host's swings the guests were told of (`Game::swings_started`).
pub host_swings: u32,
/// The host's crack the guests were told of: the block and its stage.
pub host_crack: Option<([i32; 3], i32)>,
pub host_uuid: u128,
/// The host's profile name (the account's, or the offline name).
pub host_name: String,
/// The guests' models in the host's world.
pub models: HashMap<u32, Mob>,
host_mode: i32,
pub play: HashMap<u32, super::lan_actions::GuestPlay>,
/// Blocks broken this tick (their sound is a level event for the guests).
pub broken: Vec<[f64; 3]>,
/// Hurt time, death and swing time (while swinging) of the mobs guests
/// see; the players' hurt time by entity id.
mob_state: HashMap<super::Dim, HashMap<u32, (i32, bool, Option<i32>)>>,
/// Each placed guest's pose as the mods last heard it
/// (`native_entity_pose_change`), by connection.
pub poses: HashMap<u32, horizon_client::pose::Pose>,
/// The guest each mob sees as its player, when not the host (game/lan_mobs.rs).
/// Looked up per mob each tick: a fast hasher (never iterated).
pub mob_players: horizon_client::fast_hash::FxHashMap<u32, u32>,
@@ -1107,7 +1097,7 @@ fn enforce_guest_game_mode(
return;
}
let mode = host.hub.config.default_game_mode;
let spares_operators = !host.hub.config.dedicated || host.hub.config.integrated;
let spares_operators = host.hub.config.integrated;
let stale: Vec<u32> = host
.hub
.guests
@@ -1168,7 +1158,8 @@ impl Game<'_> {
match &self.lan {
Some(h) if !self.serving() || self.published_port().is_some() => json!({
"open": true,
"host": h.host_name,
// The owner's profile name.
"host": self.options.player_name(),
"port": h.settings.port,
// The server's own world: its finished chunks and how many it
// has generated or loaded so far.
@@ -1203,17 +1194,6 @@ impl Game<'_> {
],
"chunks": g.sent_chunks.len(),
"tracked": g.tracker.len(),
// Whether the host draws this guest lying in a bed.
"model_lying": h.models.get(&g.conn).map(|m| m.extra.ints[1] != 0),
// The armour the host draws on this guest, head to feet.
"model_armor": h.models.get(&g.conn).map(|m| {
[&m.extra.head, &m.extra.chest, &m.extra.legs, &m.extra.feet]
.map(|s| s.as_deref())
}),
// The host draws this guest's arm swinging.
"model_swinging": h.models.get(&g.conn).map(|m| m.ai.swinging),
// For how many ticks the host draws this guest using an item.
"model_using": h.models.get(&g.conn).map(|m| m.extra.using_item),
})).collect::<Vec<_>>(),
}),
_ => json!({ "open": false }),
@@ -1229,8 +1209,7 @@ impl Game<'_> {
} else if self.serving() {
return self.publish(settings).map(drop).map_err(Into::into);
} else {
self.lan_start(settings)?;
self.lan.as_ref().map_or(0, |l| l.settings.port)
return Err("no world of this game to open".into());
};
self.system_message(Text::translate(
"menu.multiplayerOptions.publish.started.lan",
@@ -1238,45 +1217,6 @@ impl Game<'_> {
));
Ok(())
}
/// IntegratedServer.publishServer without the screen's announcement.
pub(super) fn lan_start(&mut self, settings: LanSettings) -> Result<()> {
if self.lan.is_some() || self.remote.is_some() || self.title.is_some() {
return Err("already open".into());
}
// IntegratedServer.publishServer: the MOTD names the host's profile.
let name = self.options.player_name();
let motd = format!("{name} - {}", self.level.name);
let host_uuid = self.level.player_uuid.map_or_else(
|| offline_uuid(&name),
|u| {
u128::from(u[0] as u32) << 96
| u128::from(u[1] as u32) << 64
| u128::from(u[2] as u32) << 32
| u128::from(u[3] as u32)
},
);
let config = HubConfig {
allow_flight: true,
default_game_mode: self.level.mode(),
guest_commands: settings.guest_commands,
force_game_mode: settings.force_game_mode,
hardcore: self.level.hardcore,
view_distance: self.settings.render_distance.clamp(2, 32),
simulation_distance: self.settings.render_distance.clamp(2, 32),
dimension: self.dim.id().to_owned(),
levels: self.level_shapes,
seed: self.level.seed,
max_players: MAX_PLAYERS,
dedicated: false,
integrated: false,
};
let net = NetConfig {
levels: self.level_shapes,
online: horizon_client::lan::OnlineMode::lan(),
..NetConfig::lan(settings.port, motd, MAX_PLAYERS)
};
self.lan_host(settings, net, config, Some(host_uuid))
}
/// Start the network side and the play-state hub: for the LAN (with the
/// host's own profile reserved) or for a dedicated server (no host).
pub(super) fn lan_host(
@@ -1284,9 +1224,22 @@ impl Game<'_> {
settings: LanSettings,
net: NetConfig,
config: HubConfig,
host: Option<u128>,
) -> Result<()> {
let sync = sync_data(&self.options.resources, &self.pack_files())?;
let mut sync = sync_data(&self.options.resources, &self.pack_files())?;
let features = if self.level.enabled_features.is_empty() {
vec!["minecraft:vanilla".to_owned()]
} else {
self.level.enabled_features.clone()
};
if sync.features.iter().any(|f| !features.contains(f))
|| features.iter().any(|f| !sync.features.contains(f))
{
// The shared resource preload has vanilla flags. Feature flags belong
// to this world, not to the cached resources or the next world opened.
let mut world_sync = SyncData::load(&self.options.resources, &self.pack_files())?;
world_sync.features = features;
sync = Arc::new(world_sync);
}
self.sync_dialog_ids(&sync);
// The table the world plays with (`world_biomes!`): a world with
// biome data packs sends their ids past the built-in list, as the
@@ -1328,12 +1281,6 @@ impl Game<'_> {
let far = (net.lod_distance > 0)
.then(|| horizon_client::lod_net::Service::new(net.lod_distance, biome_names.clone()));
let lan = Lan::start_with(sync.clone(), net)?;
if let Some(uuid) = host {
lan.reserve(uuid);
}
let host_uuid = host.unwrap_or(0);
self.next_entity += 1;
let host_entity = self.next_entity as i32;
let mut hub = Hub::new(lan, sync, encoder, config);
hub.flat_world = self.world_flat;
hub.debug_world = self.world_debug;
@@ -1344,16 +1291,10 @@ impl Game<'_> {
players_told_left: false,
hub,
settings: LanSettings { port, ..settings },
host_entity,
host_swings: self.swings_started,
host_crack: None,
host_uuid,
host_name: self.options.player_name(),
models: HashMap::new(),
host_mode: self.mode_id(),
play: HashMap::new(),
broken: Vec::new(),
mob_state: HashMap::new(),
poses: HashMap::new(),
mob_players: Default::default(),
owner: None,
waypoints: Default::default(),
@@ -1695,6 +1636,15 @@ impl Game<'_> {
}
}
/// Apply the default mode to connected players when forcing it is enabled.
pub(super) fn set_force_game_mode(&mut self, force: bool) {
if let Some(host) = self.lan.as_mut() {
host.settings.force_game_mode = force;
host.hub.config.force_game_mode = force;
enforce_guest_game_mode(host, &self.world, self.collision);
}
}
/// New command access and game mode forcing settings of the open world.
pub(super) fn lan_apply_settings(&mut self, guest_commands: bool, force: bool) {
if let Some(server) = &self.integrated {
@@ -1706,11 +1656,9 @@ impl Game<'_> {
let effective = guest_commands && (!self.serving() || self.allow_commands());
if let Some(host) = self.lan.as_mut() {
host.settings.guest_commands = guest_commands;
host.settings.force_game_mode = force;
host.hub.config.guest_commands = effective;
host.hub.config.force_game_mode = force;
enforce_guest_game_mode(host, &self.world, self.collision);
}
self.set_force_game_mode(force);
self.resend_commands_filtered();
}
@@ -1724,70 +1672,23 @@ impl Game<'_> {
self.lan.as_ref().map(|h| h.settings.port)
}
/// The host as the guests see them.
fn lan_host_state(&self, host: &LanHost) -> Option<HostState> {
let p = self.player.as_ref()?;
let mut equipment: [Option<ItemStack>; 6] = Default::default();
equipment[0] = self.inventory.get(self.selected).cloned().flatten();
equipment[1] = self.offhand.clone();
// Feet, legs, chest, head are slots 2..=5; armor is stored head first.
equipment[2] = self.armor[3].clone();
equipment[3] = self.armor[2].clone();
equipment[4] = self.armor[1].clone();
equipment[5] = self.armor[0].clone();
Some(HostState {
uuid: host.host_uuid,
name: host.host_name.clone(),
entity_id: host.host_entity,
position: [p.position.x, p.position.y, p.position.z],
yaw: self.yaw,
pitch: self.pitch,
head_yaw: self.yaw,
on_ground: p.ground.on_ground,
game_mode: self.mode_id(),
sneaking: p.pose == horizon_client::pose::Pose::Crouching,
sprinting: p.sprinting,
equipment,
health: p.health.health(),
attributes: horizon_client::lan::attributes::syncable(&p.attributes),
// Bit 4: Riptide's spin (LivingEntity.isAutoSpinAttack).
living_flags: living_flags(self.using)
| if p.auto_spin_attack_ticks > 0 { 4 } else { 0 },
stuck: (self.combat.stuck.arrows, self.combat.stuck.stingers),
shoulders: shoulder_variants(self.level.player_extra.as_ref()),
bed: self.sleeping,
body: BodyFlags {
gliding: p.fall_flying,
swimming: p.swimming,
crawling: p.pose == horizon_client::pose::Pose::Swimming && !p.swimming,
crouching: p.pose == horizon_client::pose::Pose::Crouching,
spin_attack: p.auto_spin_attack_ticks > 0,
on_fire: p.fire_ticks > 0,
// ServerPlayer.updateInvisibilityStatus: a spectator is always invisible.
invisible: self.potions.effects.get("invisibility").is_some() || self.spectator(),
glowing: self.potions.effects.get("glowing").is_some(),
},
})
}
/// The entities guests track: mobs, items and the rest (game/lan_entities.rs).
/// `mob_players` is the guest each mob sees as its player (game/lan_mobs.rs).
fn lan_entities(
&self,
host_entity: Option<i32>,
&mut self,
mob_players: &horizon_client::fast_hash::FxHashMap<u32, u32>,
hub: &mut Hub,
out: &mut States,
drain_minecart_steps: bool,
) {
out.clear();
for m in self.mobs.iter().filter(|m| !m.removed) {
// The lead's holder: the player the mob sees (a guest, else the
// host), another mob or a fence knot (game/lan_entities.rs).
// The lead's holder: the player the mob sees, another mob or a
// fence knot (game/lan_entities.rs).
let player_entity = mob_players
.get(&m.id)
.and_then(|conn| hub.guest(*conn))
.map(|g| g.entity_id)
.or(host_entity);
.map(|g| g.entity_id);
let leash = m.extra.leash.and_then(|h| match h {
Holder::Player => player_entity,
Holder::Mob(id) | Holder::Boat(id) => Some(id as i32),
@@ -1843,7 +1744,7 @@ impl Game<'_> {
e.meta
.push((8, serializer::INT, Value::Int(o.value as i32)));
}
self.lan_object_states(host_entity, hub, out);
self.lan_object_states(hub, out, drain_minecart_steps);
}
/// Apply gameplay events produced while the hub handles guest packets.
@@ -1856,7 +1757,6 @@ impl Game<'_> {
&mut self,
mut host: LanHost,
events: impl IntoIterator<Item = HostEvent>,
state: Option<&HostState>,
entities: &[EntityState],
spawn: [f64; 3],
) -> Option<LanHost> {
@@ -1912,7 +1812,7 @@ impl Game<'_> {
continue;
}
}
self.lan_guest_joined(&mut host, conn, state, entities, spawn);
self.lan_guest_joined(&mut host, conn, entities, spawn);
if self.options.dedicated.is_some()
&& let Some(g) = host.hub.guest(conn)
{
@@ -1989,7 +1889,6 @@ impl Game<'_> {
self.take_parent_vehicle(&mut host, &mut guest);
self.save_guest(&host, &mut guest);
self.guest_attachments.remove(&guest.uuid);
host.models.remove(&guest.conn);
host.play.remove(&guest.conn);
self.guest_combat.remove(&guest.conn);
if self.options.dedicated.is_some() && guest.placed {
@@ -2062,12 +1961,6 @@ impl Game<'_> {
return;
};
let holding = lan::Holding::start();
let state = self.lan_host_state(&host);
if state.is_none() && !host.hub.config.dedicated {
self.lan = Some(host);
self.level_pass.packets_drained = true;
return;
}
// Last tick's tracked entities: enough for place collision and joins
// before this tick moves anyone.
let entities = std::mem::take(&mut host.states);
@@ -2076,7 +1969,6 @@ impl Game<'_> {
let clock_rate = self.network_clock_rate();
let mut ctx = TickCtx {
world: &mut self.world,
host: state.as_ref(),
entities: entities.as_slice(),
next_entity: &mut self.next_entity,
spawn,
@@ -2103,9 +1995,7 @@ impl Game<'_> {
events.extend(host.hub.drain_packets(&mut ctx));
events
};
let Some(back) =
self.lan_process_events(host, events, state.as_ref(), entities.as_slice(), spawn)
else {
let Some(back) = self.lan_process_events(host, events, entities.as_slice(), spawn) else {
return;
};
host = back;
@@ -2158,7 +2048,6 @@ impl Game<'_> {
if !self.admin_feedback.is_empty() {
self.send_admin_feedback(&mut host, None);
}
let state = self.lan_host_state(&host);
// An animal whose rider left the game (or the level) stops taking
// its input.
for m in &mut self.mobs {
@@ -2177,12 +2066,10 @@ impl Game<'_> {
if !paused {
self.tick_guests(&mut host);
}
// A dedicated server runs the hub without a host player.
if state.is_some() || host.hub.config.dedicated {
{
let has_guests = !host.hub.guests.is_empty();
let mut states = std::mem::take(&mut host.states);
if has_guests {
let host_entity = state.as_ref().map(|s| s.entity_id);
self.lan_assign_ids();
let guests: Vec<(u32, i32)> = host
.hub
@@ -2190,12 +2077,17 @@ impl Game<'_> {
.iter()
.filter_map(|g| Some((g.vehicle?, g.entity_id)))
.collect();
self.lan_riding(host_entity, &guests, &mut host.hub.riding);
self.lan_entities(host_entity, &host.mob_players, &mut host.hub, &mut states);
self.lan_hook_states(host_entity, &host.play, &host.hub, &mut states);
self.lan_riding(&guests, &mut host.hub.riding);
self.lan_entities(&host.mob_players, &mut host.hub, &mut states, true);
self.lan_hook_states(&host.play, &host.hub, &mut states);
self.lan_entity_uuids(&mut states);
} else {
states.clear();
// Nobody can receive past movement. A returning viewer pairs
// from the cart's current position/rotation, as AddEntity does.
for cart in &mut self.carts {
cart.lerp_steps.clear();
}
}
// ServerEntity.sendChanges clears needsSync once it has looked.
for m in &mut self.mobs {
@@ -2238,7 +2130,6 @@ impl Game<'_> {
let clock_rate = self.network_clock_rate();
let mut ctx = TickCtx {
world: &mut self.world,
host: state.as_ref(),
entities,
next_entity: &mut self.next_entity,
spawn,
@@ -2259,7 +2150,7 @@ impl Game<'_> {
// IntegratedServer.getScaledTrackingDistance: the Entity Distance
// option scales what an integrated server or a LAN host tracks (a
// dedicated one keeps its entity-broadcast-range-percentage).
if host.hub.config.integrated || !host.hub.config.dedicated {
if host.hub.config.integrated {
host.hub.entity_range_percent =
(self.settings.entity_distance * 100.0).round() as i32;
}
@@ -2284,15 +2175,8 @@ impl Game<'_> {
}
events
};
if let Some(state) = &state
&& host.host_mode != state.game_mode
{
host.host_mode = state.game_mode;
host.hub.set_host_game_mode(state.uuid, state.game_mode);
}
self.wake_woken_guests(&mut host);
let Some(back) = self.lan_process_events(host, events, state.as_ref(), entities, spawn)
else {
let Some(back) = self.lan_process_events(host, events, entities, spawn) else {
return;
};
host = back;
@@ -2351,8 +2235,8 @@ impl Game<'_> {
self.tick_server_travel(&mut host);
self.lan_swings(&mut host);
self.lan_update_streaming(&host);
self.lan_update_models(&mut host);
self.lan_update_waypoints(&mut host, state.as_ref());
self.lan_guest_poses(&mut host);
self.lan_update_waypoints(&mut host);
self.lan_sync_scoreboard(&mut host);
self.lan_sync_border(&mut host);
host.hub.anticheat_ping();
@@ -2362,8 +2246,8 @@ impl Game<'_> {
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
self.lan = Some(host);
self.flush_native_sounds();
// `tick_server` resets the pass after all dimensions. The deprecated
// in-frame path has no outer pass, so make its packet gate per-tick.
// `tick_server` resets the pass after all dimensions; outside a pass
// the packet gate is per-tick.
if self.level_pass.running.is_none() {
self.level_pass.packets_drained = false;
}
@@ -2373,7 +2257,6 @@ impl Game<'_> {
&mut self,
host: &mut LanHost,
conn: u32,
state: Option<&HostState>,
entities: &[EntityState],
spawn: [f64; 3],
) {
@@ -2412,11 +2295,10 @@ impl Game<'_> {
g.food = 20;
g.dimension = super::Dim::Overworld.id().to_owned();
}
// A server places each player in their own level
// (`PlayerList.placeNewPlayer`); the in-frame game has one.
// A server places each player in their own level (`PlayerList.placeNewPlayer`).
let back = self.dim;
let level = super::Dim::from_id(&g.dimension);
let elsewhere = self.options.dedicated.is_some() && level != back;
let elsewhere = level != back;
if !elsewhere {
g.dimension = in_place;
}
@@ -2430,8 +2312,8 @@ impl Game<'_> {
return;
}
// A dedicated server has no host player.
self.lan_entities(None, &host.mob_players, &mut host.hub, &mut theirs);
self.lan_hook_states(None, &host.play, &host.hub, &mut theirs);
self.lan_entities(&host.mob_players, &mut host.hub, &mut theirs, false);
self.lan_hook_states(&host.play, &host.hub, &mut theirs);
self.lan_entity_uuids(&mut theirs);
theirs.as_slice()
} else {
@@ -2440,7 +2322,6 @@ impl Game<'_> {
let clock_rate = self.network_clock_rate();
let mut ctx = TickCtx {
world: &mut self.world,
host: state,
entities,
next_entity: &mut self.next_entity,
spawn,
@@ -2566,6 +2447,12 @@ impl Game<'_> {
EquipmentSlot::Body => 65,
EquipmentSlot::Saddle => 68,
};
// The mods hear a server player's break (a creative player's gear takes no wear).
if !self.creative
&& let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
{
self.native_player_destroy_item(uuid, &name, &stack.item, equipment_slot_name(slot));
}
let Some(at) = self
.player
.as_ref()
@@ -2573,15 +2460,10 @@ impl Game<'_> {
else {
return;
};
let told = if self.lan_acting.is_some() {
let told = self.lan_acting.is_some();
if told {
self.guest_equipment_breaks.push((event, at));
true
} else if let Some(host) = &self.lan {
host.hub.entity_event(host.host_entity, event, at);
true
} else {
false
};
}
if told && !self.options.execution_role.is_client() {
return;
}
@@ -2692,14 +2574,14 @@ impl Game<'_> {
{
use horizon_client::mobs::{DamageKind as K, Who};
let kind = super::creatures::source_damage_type(source);
// The player a mob sees is the guest acting, else the host.
let cause = source.attacker.map(|who| match who {
// The player a mob sees is the guest acting.
let cause = source.attacker.and_then(|who| match who {
Who::Player => host
.mob_players
.get(&m.id)
.and_then(|conn| host.hub.guest(*conn))
.map_or(host.host_entity, |g| g.entity_id),
Who::Mob(id) => id as i32,
.map(|g| g.entity_id),
Who::Mob(id) => Some(id as i32),
});
// A projectile is the direct entity of its hit: not tracked here.
let melee = matches!(
@@ -2745,48 +2627,20 @@ impl Game<'_> {
}
seen.insert(id, (d.hurt_time, dead, None));
}
// LivingEntity.hurtServer for the in-frame LAN host's player (guests
// broadcast directly from HealthEvent::DamageBroadcast): a hit that
// restarted the hurt time and was not blocked by a shield broadcasts
// the damage event, so the clients around flash it red.
if let Some(p) = self.player.as_ref() {
let id = host.host_entity;
let at = [p.position.x, p.position.y, p.position.z];
let hurt = p.health.hurt_animation_time;
let dead = p.health.dead_or_dying();
let old = host
.mob_state
.get(&self.dim)
.and_then(|s| s.get(&(id as u32)));
if hurt > old.map_or(0, |o| o.0) && !dead {
let kind = p
.health
.hurt
.last_source
.and_then(|s| self.motion.health.damage.definition(s))
.map_or("minecraft:generic", |d| d.name.as_str());
host.hub.damage_event(id, kind, None, None, at);
}
seen.insert(id as u32, (hurt, dead, None));
}
host.mob_state.insert(self.dim, seen);
// Level.broadcastEntityEvent(player, 2) of a spear's stab.
for attacker in kinetic_hits {
let hit = match attacker {
None => self
.player
.as_ref()
.map(|p| (host.host_entity, [p.position.x, p.position.y, p.position.z])),
Some(conn) => host.hub.guest(conn).map(|g| (g.entity_id, g.position)),
};
let hit = attacker
.and_then(|conn| host.hub.guest(conn))
.map(|g| (g.entity_id, g.position));
if let Some((id, at)) = hit {
host.hub.entity_event(id, 2, at);
}
}
for (attacker, target, magic) in critical_hits {
let Some(attacker) = attacker.map_or(Some(host.host_entity), |conn| {
host.hub.guest(conn).map(|g| g.entity_id)
}) else {
let Some(attacker) =
attacker.and_then(|conn| host.hub.guest(conn).map(|g| g.entity_id))
else {
continue;
};
host.hub.critical_hit(attacker, target, magic);
@@ -2828,153 +2682,30 @@ impl Game<'_> {
.collect();
}
/// The guests' models, for the host to see them.
fn lan_update_models(&mut self, host: &mut LanHost) {
let Some(kind) = self.res.player_type().clone() else {
/// The bed at `head` as a sleeping model lies in it (`ints[1]`,
/// `villager::sleep_pose`): its `facing`.
/// `Entity.setPose` for the server's players: the mods hear each placed
/// guest's change of pose. Nothing is kept while no mod is loaded.
fn lan_guest_poses(&mut self, host: &mut LanHost) {
if horizon_client::mod_api::mods().is_empty() {
host.poses.clear();
return;
};
host.models
}
host.poses
.retain(|conn, _| host.hub.guest(*conn).is_some_and(|g| g.placed));
let mut changed = Vec::new();
for g in host.hub.guests.iter().filter(|g| g.placed) {
let at = Vec3::new(
g.position[0],
g.position[1] - if g.sneaking { 0.125 } else { 0.0 },
g.position[2],
);
let m = host
.models
.entry(g.conn)
.or_insert_with(|| Mob::new(g.entity_id as u32, kind.clone(), at, g.yaw));
let (dx, dy, dz) = (
at.x - m.position.x,
at.y - m.position.y,
at.z - m.position.z,
);
m.previous = m.position;
m.position = at;
if dx * dx + dy * dy + dz * dz > 100.0 {
m.previous = at;
let to = g.pose();
let from = host.poses.insert(g.conn, to).unwrap_or_default();
if from != to {
changed.push((g.uuid, g.name.clone(), from, to));
}
m.previous_yaw = m.yaw;
m.previous_body_yaw = m.body_yaw;
m.previous_head_yaw = m.head_yaw;
m.previous_head_pitch = m.head_pitch;
m.yaw = g.yaw;
m.head_yaw = g.head_yaw;
m.head_pitch = g.pitch;
let mut target = m.body_yaw;
let wrap = |a: f32| (a + 180.0).rem_euclid(360.0) - 180.0;
if dx * dx + dz * dz > 0.002_500_000_2 {
// LivingEntity.tick: walking backwards the body faces away
// from the walk direction, not along it.
let walk = (dz.atan2(dx) as f32).to_degrees() - 90.0;
let facing = (wrap(g.yaw) - walk).abs();
target = if 95.0 < facing && facing < 265.0 {
walk - 180.0
} else {
walk
};
}
m.body_yaw += wrap(target - m.body_yaw) * 0.3;
let diff = wrap(g.yaw - m.body_yaw);
if diff.abs() > 50.0 {
m.body_yaw += diff - diff.signum() * 50.0;
}
let dist = (dx * dx + dz * dz).sqrt() as f32;
m.previous_limb_speed = m.limb_speed;
m.limb_speed += ((dist * 4.0).min(1.0) - m.limb_speed) * 0.4;
m.limb_position += m.limb_speed;
m.age = m.age.wrapping_add(1);
// The hurt red flash (LivingEntity.hurtTime).
m.hurt_time = g
.motion
.as_ref()
.map_or(0, |p| p.health.hurt_animation_time);
m.extra
.set_main_hand_stack(g.inventory.get(g.selected).cloned().flatten());
m.extra.off_hand = g.offhand.as_ref().map(|s| s.item.clone());
[m.extra.head, m.extra.chest, m.extra.legs, m.extra.feet] = g
.armor
.each_ref()
.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.sprinting = g.sprinting;
// Entity.DATA_POSE and the shared and living flags the others
// get (hub::guest_body): gliding, swimming, Riptide's spin.
let body = lan::hub::guest_body(g);
m.extra.fall_flying = body.gliding;
m.extra.synced_pose = if body.swimming || body.crawling { 3 } else { 0 };
m.extra.auto_spin_attack = body.spin_attack;
m.extra.previous_velocity = m.velocity;
(m.extra.arrow_count, m.extra.stinger_count) = g.stuck;
m.extra.shoulder_parrots = g.shoulders;
m.velocity = g
.motion
.as_ref()
.map_or(Vec3::new(dx, dy, dz), |motion| motion.velocity);
m.tick_avatar_pose(
g.motion
.as_ref()
.is_some_and(|motion| motion.fluids.was_touching_water),
);
// CrossbowItem.isCharged: the held crossbow's projectiles.
m.extra.charged = g
.inventory
.get(g.selected)
.and_then(|s| s.as_ref())
.is_some_and(|s| s.data().is_some_and(|d| !d.charged.is_empty()));
// ClientInformation.mainHand (HumanoidArm: 0 left, 1 right).
m.extra.main_hand_left = g.info.main_hand == 0;
m.extra.hidden_model_parts = !g.info.model_customisation & 0x7F;
m.extra.spectator = g.game_mode == horizon_client::lan::guest::SPECTATOR;
// LivingEntity.DATA_EFFECT_PARTICLES: the swirls of its visible effects.
let visible = || g.effects.list.iter().filter(|e| e.visible);
m.extra.effect_particles = visible()
.filter_map(|e| super::client_ambient::effect_particle(e.id, e.ambient))
.collect();
m.extra.effect_ambient = visible().all(|e| e.ambient);
// Entity.isInvisible: the invisibility effect hides the body (the
// model pass, the name tag rules and `trader::is_invisible` read it).
m.extra.effects.retain(|e| e.0 != "minecraft:invisibility");
if let Some(e) = g.effects.get("invisibility") {
m.extra
.effects
.push(("minecraft:invisibility", e.duration, e.amplifier));
}
// Lying in its bed (LivingEntity.getBedOrientation).
m.extra.ints[1] = g.bed.map_or(0, |b| self.bed_facing(b));
// LivingEntity.updateSwingTime: the arm swing lan_swings started.
m.anim.attack_anim_o = m.anim.attack_anim;
horizon_client::mobs::ai::tick_swing(m);
// LivingEntity.onSyncedDataUpdated(DATA_LIVING_ENTITY_FLAGS): the
// item in use and its hand (the arm poses of a bow, a shield...).
let using = host.play.get(&g.conn).and_then(|p| p.using());
m.extra.using_item = using.map_or(0, |(_, ticks)| ticks as i32 + 1);
m.extra.using_off_hand = using.is_some_and(|(slot, _)| slot == OFFHAND_SLOT);
// AvatarRenderer.getArmPose: the used item's BLOCK animation (an
// item with `blocks_attacks`) raises the arm.
m.extra.blocking = using
.and_then(|(slot, _)| {
if slot == OFFHAND_SLOT {
m.extra.off_hand.as_ref()
} else {
m.extra.main_hand.as_ref()
}
})
.and_then(|i| self.items().get(i))
.is_some_and(|i| i.blocks_attacks.is_some());
}
for (uuid, name, from, to) in changed {
self.native_entity_pose_change(uuid, &name, from.name(), to.name());
}
}
/// The bed at `head` as a sleeping model lies in it (`ints[1]`,
/// `villager::sleep_pose`): its `facing`.
pub(super) fn bed_facing(&self, head: [i32; 3]) -> i32 {
let state = self.world.block(head[0], head[1], head[2]);
let facing = self
@@ -2986,82 +2717,6 @@ impl Game<'_> {
.map(String::as_str);
horizon_client::mobs::villager::bed_facing_code(facing)
}
/// The guests' models for the entity pass.
pub(super) fn guest_model_geometry(
&self,
camera: glam::DVec3,
alpha: f64,
out: &mut Vec<horizon_client::mesher::Vertex>,
) {
let Some(host) = &self.lan else {
return;
};
let atlas = &self.gui.atlas;
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
for (conn, m) in &host.models {
let entity_start = out.len();
let probe = super::perspective::entity_light_block(
m.position,
m.extra.crouching,
m.eye_height(),
);
let light = self.world.light(probe[0], probe[1], probe[2]);
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
horizon_client::mobs::render::render_with(
m,
alpha as f32,
camera,
&lookup,
packed,
self.res.mob_data(),
&host.hub.guest(*conn).map_or_else(
|| self.worn_armor_layers(m),
|g| self.stack_armor_layers(&g.armor),
),
out,
);
self.player_elytra_geometry(
m,
m.extra.chest.as_deref(),
camera,
alpha as f32,
packed,
out,
);
if let Some(g) = host.hub.guest(*conn) {
self.player_ears_geometry(m, &g.name, camera, alpha as f32, packed, out);
}
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
if let Some(g) = host.hub.guest(*conn) {
self.stuck_in_body_geometry(m, g.entity_id, camera, alpha as f32, packed, out);
}
self.held_item_geometry(m, camera, alpha, packed, true, out);
self.player_pumpkin_geometry(
m,
host.hub.guest(*conn).and_then(|g| g.armor[0].as_ref()),
camera,
alpha,
packed,
out,
);
// LivingEntity.isCurrentlyGlowing: the guest's Glowing effect.
let guest = host.hub.guest(*conn);
if m.glowing || guest.is_some_and(|g| g.effects.get("glowing").is_some()) {
let team = guest.and_then(|g| self.score.board.players_team(&g.name));
self.mark_outline(&out[entity_start..], super::glowing::team_color(team));
} else {
self.cull_entity_vertices(out, entity_start);
}
}
}
/// A chat line the host typed, for the guests.
pub(super) fn lan_host_chat(&mut self, message: &str) {
if let Some(host) = &self.lan {
host.hub.chat(&host.host_name, message);
}
}
}
impl Game<'_> {
@@ -3097,9 +2752,25 @@ pub(super) fn living_flags(using: Option<(usize, u32)>) -> i8 {
using.map_or(0, |(slot, _)| if slot == OFFHAND_SLOT { 3 } else { 1 })
}
/// The name a mod hears for an equipment slot (the armor stand hook's names, plus
/// the two slots a mount's gear uses).
fn equipment_slot_name(slot: horizon_world::player_inventory::EquipmentSlot) -> &'static str {
use horizon_world::player_inventory::EquipmentSlot;
match slot {
EquipmentSlot::MainHand => "mainhand",
EquipmentSlot::OffHand => "offhand",
EquipmentSlot::Head => "head",
EquipmentSlot::Chest => "chest",
EquipmentSlot::Legs => "legs",
EquipmentSlot::Feet => "feet",
EquipmentSlot::Body => "body",
EquipmentSlot::Saddle => "saddle",
}
}
#[cfg(test)]
mod tests {
use super::{OFFHAND_SLOT, living_flags};
use super::{OFFHAND_SLOT, equipment_slot_name, living_flags};
/// `LivingEntity.startUsingItem` sets flag 1 for the use and flag 2 for
/// the off hand, so an off-hand use is 3, not 2. A reader takes bit 0 as
@@ -3110,4 +2781,19 @@ mod tests {
assert_eq!(living_flags(Some((0, 12))), 1);
assert_eq!(living_flags(Some((OFFHAND_SLOT, 12))), 3);
}
/// The names a mod matches on for a broken item's slot (a contract).
#[test]
fn a_broken_items_slot_is_named_for_mods() {
use horizon_world::player_inventory::EquipmentSlot::*;
let named: Vec<_> = [MainHand, OffHand, Head, Chest, Legs, Feet, Body, Saddle]
.map(equipment_slot_name)
.to_vec();
assert_eq!(
named,
[
"mainhand", "offhand", "head", "chest", "legs", "feet", "body", "saddle"
]
);
}
}
+37 -146
View File
@@ -308,6 +308,16 @@ impl Game<'_> {
host: &mut LanHost,
conn: u32,
f: impl FnOnce(&mut Self) -> R,
) -> Option<R> {
self.as_guest_with_host(host, conn, |g, _| f(g))
}
/// `as_guest`, passing the active `&LanHost` to `f`.
pub(super) fn as_guest_with_host<R>(
&mut self,
host: &mut LanHost,
conn: u32,
f: impl FnOnce(&mut Self, &LanHost) -> R,
) -> Option<R> {
let swap = self.enter_guest(host, conn)?;
super::dedicated::crash_fixture("guest", || {
@@ -315,7 +325,7 @@ impl Game<'_> {
});
let dimension =
(self.serving.is_some() || self.options.dedicated.is_some()).then_some(self.dim.id());
let result = horizon_client::block_contact::in_level(dimension, || f(self));
let result = horizon_client::block_contact::in_level(dimension, || f(self, host));
self.leave_guest(host, swap);
Some(result)
}
@@ -2374,8 +2384,10 @@ impl Game<'_> {
host.hub.send_to(
conn,
&C::GameRuleValues(play::GameRuleValues {
entries: horizon_client::gamerules::RULES
.iter()
entries: self
.ui
.rules
.available_rules()
.map(|r| play::GameRuleEntry {
name: format!("minecraft:{}", r.name),
value: self.ui.rules.get(r.name).unwrap_or(r.default).to_string(),
@@ -2681,39 +2693,23 @@ impl Game<'_> {
/// player: the host's own, or a guest in the world.
fn guest_spectate_teleport(&mut self, host: &mut LanHost, gi: usize, uuid: u128) {
let own = host.hub.guests[gi].conn;
let host_player = self
.player
.as_ref()
.filter(|_| self.options.dedicated.is_none() && uuid == host.host_uuid)
.map(|p| {
(
[p.position.x, p.position.y, p.position.z],
self.yaw,
self.pitch,
self.dim,
)
});
let target = host_player.or_else(|| {
host.hub
.guests
.iter()
.find(|g| g.placed && g.conn != own && g.uuid == uuid)
.map(|g| (g.position, g.yaw, g.pitch, Dim::from_id(&g.dimension)))
});
let target = host
.hub
.guests
.iter()
.find(|g| g.placed && g.conn != own && g.uuid == uuid)
.map(|g| (g.position, g.yaw, g.pitch, Dim::from_id(&g.dimension)));
let Some((at, yaw, pitch, dim)) = target else {
return;
};
if Dim::from_id(&host.hub.guests[gi].dimension) != dim {
// Only a dedicated server keeps its players in different levels.
if self.options.dedicated.is_some() {
self.level_teleports
.push(super::server_travel::LevelTeleport {
conn: own,
dim,
at,
rotation: Some([yaw, pitch]),
});
}
self.level_teleports
.push(super::server_travel::LevelTeleport {
conn: own,
dim,
at,
rotation: Some([yaw, pitch]),
});
return;
}
let g = &mut host.hub.guests[gi];
@@ -2891,9 +2887,6 @@ impl Game<'_> {
pub(super) fn guest_target_box(&self, host: &LanHost, gi: usize, id: i32) -> Option<Aabb> {
let conn = host.hub.guests[gi].conn;
if id == host.host_entity {
return self.player.as_ref().map(|p| p.bounds());
}
if let Some(g) = host
.hub
.guests
@@ -2939,12 +2932,7 @@ impl Game<'_> {
}
// Native mods, where Player.attack starts: a canceled blow does
// nothing (game/native_hooks.rs).
let aimed = if id == host.host_entity {
self.player.as_ref().map(|p| {
let at = [p.position.x, p.position.y, p.position.z];
("minecraft:player".to_owned(), at)
})
} else if let Some(g) = host
let aimed = if let Some(g) = host
.hub
.guests
.iter()
@@ -2972,8 +2960,7 @@ impl Game<'_> {
if let Some(Some(strike)) = self.as_guest(host, conn, |g| g.pvp_strike())
&& self.pvp_land(host, victim, &strike)
{
let host_on_ground = self.player.as_ref().map(|p| p.ground.on_ground);
let on_ground = Self::pvp_victim_on_ground(host, victim, host_on_ground);
let on_ground = Self::pvp_victim_on_ground(host, victim);
self.as_guest(host, conn, |g| {
g.weapon_hit_player(on_ground);
g.pvp_exhaustion();
@@ -2993,13 +2980,13 @@ impl Game<'_> {
.iter()
.position(|p| p.id != 0 && p.id as i32 == id && super::deflect::attackable(p))
{
self.as_guest(host, conn, |g| {
self.as_guest_with_host(host, conn, |g, host| {
if g.within_interaction_range(i) {
if matches!(
g.creatures.projectiles[i].kind,
horizon_client::projectiles::ProjectileKind::ShulkerBullet { .. }
) {
g.hit_shulker_bullet(i);
g.hit_shulker_bullet(Some(host), i);
} else {
g.deflect(i);
}
@@ -3105,12 +3092,7 @@ impl Game<'_> {
// ListPlayersCommand needs no permission and lists every player.
let words: Vec<&str> = command.trim_start_matches('/').split(' ').collect();
if words[0] == "list" && (words.len() == 1 || words[1..] == ["uuids"]) {
// A single-player owner is a placed guest already: no host entry.
let me = (self.options.dedicated.is_none()
&& self.player.is_some()
&& host.hub.singleplayer_owner.is_none())
.then_some((host.host_uuid, host.host_name.as_str()));
let content = host.hub.list_reply(me, words.len() == 2);
let content = host.hub.list_reply(words.len() == 2);
host.hub.send_to(
conn,
&C::SystemChat(play::SystemChat {
@@ -3236,14 +3218,9 @@ impl Game<'_> {
/// operator gets them, and the console logs them as the server's own
/// system messages (MinecraftServer.sendSystemMessage).
pub(super) fn send_admin_feedback(&mut self, host: &mut LanHost, source: Option<u32>) {
// PlayerList.isOp for the host of a world open to LAN: the owner when
// the world allows commands, everyone when the LAN does.
let host_ops =
!host.hub.config.dedicated && (self.allow_commands() || host.hub.config.guest_commands);
let host_op = source.is_some() && host_ops;
// server.toml's `admin_feedback_to_ops` off: the console's log only.
let to_ops = host.hub.admin_feedback_to_ops;
for (line, ops, console, no_player) in std::mem::take(&mut self.admin_feedback) {
for (line, ops, console) in std::mem::take(&mut self.admin_feedback) {
if ops && to_ops {
let content = horizon_client::lan::data::component_nbt(&line.component());
for g in host
@@ -3257,10 +3234,6 @@ impl Game<'_> {
overlay: false,
}));
}
// A line no player's command made (a command block's) reaches the host too.
if host_op || (no_player && host_ops) {
self.system_message(line.clone());
}
}
if console {
super::dedicated::log("INFO", &format!("System chat: {}", line.plain()));
@@ -3300,7 +3273,7 @@ impl Game<'_> {
};
let skip = usize::from(text.starts_with('/'));
let command = &text[skip..];
let d = vanilla::game_dispatcher();
let d = vanilla::game_dispatcher_with_features(&self.level.enabled_features);
// The completions come from the server's players and lists, not from the host's own registries: a
// dedicated server has no local player (they would list "Player"), and a LAN world's guests are not in
// the host's list.
@@ -3483,7 +3456,6 @@ impl Game<'_> {
let clock_rate = self.network_clock_rate();
let ctx = TickCtx {
world: &mut self.world,
host: None,
entities: &[],
next_entity: &mut self.next_entity,
spawn,
@@ -4653,11 +4625,9 @@ impl Game<'_> {
g.trigger_thrown_picked_up_by_player(&item.stack, thrower);
});
}
self.guest_pickup(host, id, item);
taken.push((item_id, before));
}
Some(rest) if rest.count < before => {
self.guest_pickup(host, id, self.dropped[i].clone());
self.dropped[i].stack = rest;
if let Some(thrower) = thrower {
let left = self.dropped[i].stack.clone();
@@ -4741,13 +4711,6 @@ impl Game<'_> {
self.native_player_pickup_arrow(player, &name, shot.id, &arrow.pickup_item.item);
}
host.hub.take_item(shot.id as i32, id, 1, at);
if let Some(target) = super::pickups::guest_target(host, id as u32) {
self.add_pickup_toward(
super::pickups::Taken::Arrow(Box::new(shot)),
super::pickups::Collector::Guest(id as u32),
target,
);
}
}
}
@@ -4844,14 +4807,6 @@ impl Game<'_> {
pitch,
);
let value = orb.value;
if let Some(target) = super::pickups::guest_target(host, id as u32) {
use super::pickups::{Collector, Taken};
self.add_pickup_toward(
Taken::Orb(Box::new(orb)),
Collector::Guest(id as u32),
target,
);
}
self.as_guest(host, conn, |game| {
let rest = game.mending(value);
if rest > 0 {
@@ -4867,23 +4822,6 @@ impl Game<'_> {
});
}
/// The host's own `ItemPickupParticle` toward the guest's model.
fn guest_pickup(
&mut self,
host: &LanHost,
id: i32,
item: horizon_client::entities::ItemEntity,
) {
use super::pickups::{Collector, Taken, guest_target};
if let Some(target) = guest_target(host, id as u32) {
self.add_pickup_toward(
Taken::Item(Box::new(item)),
Collector::Guest(id as u32),
target,
);
}
}
/// `level.playSound(player, ...)` of a guest's use it predicted (`remote::hand_operated`
/// blocks only, as the guest decides): the others hear it now, the guest already did.
fn guest_use_sounds(&mut self, host: &mut LanHost, conn: u32, logged: usize, heard: usize) {
@@ -5027,9 +4965,8 @@ impl Game<'_> {
}
}
/// The tick's arm swings for the players around (the guests' to everyone
/// else, the host's to the guests), and `LivingEntity.updateSwingTime`
/// for the guests' bodies here.
/// The tick's arm swings for the players around (each guest's to everyone
/// else), and `LivingEntity.updateSwingTime` for the guests' bodies here.
pub(super) fn lan_swings(&mut self, host: &mut LanHost) {
let conns: Vec<u32> = host.play.keys().copied().collect();
for conn in conns {
@@ -5042,55 +4979,9 @@ impl Game<'_> {
if let (true, true, Some(c)) = (swung, here, play.swing.current) {
let packet = swing_packet(id, c.hand, c.animation);
host.hub.broadcast_near(at, 128.0, &packet, Some(conn));
if let Some(m) = host.models.get_mut(&conn) {
// The duration already counts the guest's haste or fatigue.
m.swing_hand(c.hand == Hand::Off, c.duration_ticks);
}
}
play.swing.tick();
}
self.lan_host_crack(host);
if self.swings_started != host.host_swings {
host.host_swings = self.swings_started;
if let (Some(p), Some(c)) = (self.player.as_ref(), self.swing.current) {
let at = [p.position.x, p.position.y, p.position.z];
let packet = swing_packet(host.host_entity, c.hand, c.animation);
host.hub.broadcast_near(at, 128.0, &packet, None);
}
}
}
}
impl Game<'_> {
/// The host player's crack, for the guests (`ServerLevel.destroyBlockProgress` from the
/// host's mining): each new stage, and -1 once it stops or breaks the block.
fn lan_host_crack(&mut self, host: &mut LanHost) {
let now = self
.mining
.as_ref()
.and_then(|m| (m.progress > 0.0).then(|| (m.pos, ((m.progress * 10.0) as i32).min(9))));
if now == host.host_crack {
return;
}
let packet = |pos: [i32; 3], progress: u8| {
C::BlockDestruction(play::BlockDestruction {
id: host.host_entity,
pos,
progress,
})
};
let near = |pos: [i32; 3]| [f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])];
if let Some((pos, _)) = host.host_crack
&& now.is_none_or(|(p, _)| p != pos)
{
host.hub
.broadcast_near(near(pos), 64.0, &packet(pos, 255), None);
}
if let Some((pos, stage)) = now {
host.hub
.broadcast_near(near(pos), 64.0, &packet(pos, stage as u8), None);
}
host.host_crack = now;
}
}
@@ -43,13 +43,6 @@ impl Game<'_> {
{
return (!g.dead).then_some((g.position, [g.yaw, g.pitch]));
}
if id == host.host_entity {
let p = self.player.as_ref()?;
return Some((
[p.position.x, p.position.y, p.position.z],
[self.yaw, self.pitch],
));
}
if let Some(special) = self.camera_extra(id) {
return Some((special.position, special.rotation));
}
@@ -102,9 +95,6 @@ impl Game<'_> {
if let Some(g) = host.hub.guests.iter().find(|g| g.entity_id == id) {
return !g.spectator() && !g.dead;
}
if id == host.host_entity {
return self.player.is_some() && !self.spectator();
}
if self.camera_extra(id).is_some() {
return true;
}
+5 -3
View File
@@ -21,7 +21,8 @@ impl Game<'_> {
/// CommandDispatcher.parse with the guest's permission level.
fn parse_as(&mut self, permission: u8, command: &str) -> ParseResults {
let level = std::mem::replace(&mut self.ui.registries.permission, permission);
let parse = vanilla::game_dispatcher().parse(command, &self.ui.registries);
let parse = vanilla::game_dispatcher_with_features(&self.level.enabled_features)
.parse(command, &self.ui.registries);
self.ui.registries.permission = level;
parse
}
@@ -33,8 +34,9 @@ impl Game<'_> {
permission: u8,
command: &str,
) -> Vec<(String, String)> {
self.parse_as(permission, command)
.signable_arguments(vanilla::game_dispatcher())
self.parse_as(permission, command).signable_arguments(
vanilla::game_dispatcher_with_features(&self.level.enabled_features),
)
}
/// ServerGamePacketListenerImpl.detectRateSpam: a chat message or command adds 20 to the
+24 -20
View File
@@ -40,6 +40,7 @@ fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) ->
head_yaw: 0.0,
on_ground,
velocity: [0.0; 3],
minecart_steps: None,
data: 0,
meta: Vec::new(),
defaults: Vec::new(),
@@ -52,6 +53,7 @@ fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) ->
leash: None,
spectator: false,
watching: false,
hidden: None,
}
}
@@ -129,14 +131,9 @@ impl Game<'_> {
}
}
/// Who rides what (`Entity.getPassengers`): mobs on mobs, and the
/// host in its boat or minecart.
pub(super) fn lan_riding(
&self,
host_entity: Option<i32>,
guests: &[(u32, i32)],
riding: &mut Riding,
) {
/// Who rides what (`Entity.getPassengers`): the players in their vehicles and
/// mobs on mobs.
pub(super) fn lan_riding(&self, guests: &[(u32, i32)], riding: &mut Riding) {
riding.clear();
// The server's players in their boats and minecarts.
for &(vehicle, guest) in guests {
@@ -147,18 +144,15 @@ impl Game<'_> {
riding.add(vehicle as i32, m.id as i32);
}
}
if let (Some(host), Some(vehicle)) = (host_entity, self.riding) {
riding.add(vehicle as i32, host);
}
}
/// Boats, minecarts, armor stands, item frames, paintings and the
/// entities kept unsimulated, as guests track them.
pub(super) fn lan_object_states(
&self,
host_entity: Option<i32>,
&mut self,
hub: &mut Hub,
out: &mut States,
drain_minecart_steps: bool,
) {
let guests: Vec<(u128, i32)> = hub
.guests
@@ -170,7 +164,7 @@ impl Game<'_> {
let p = b.position;
let mut e = state(b.id, &b.entity_type(), [p.x, p.y, p.z], b.yaw, b.on_ground);
// Leashable.setLeashedTo / dropLeash: who holds the boat's lead.
e.leash = self.boat_leash_link(b, host_entity, &guests);
e.leash = self.boat_leash_link(b, &guests);
e.velocity = [b.velocity.x, b.velocity.y, b.velocity.z];
e.meta = vehicle_meta(b.hurt_time, b.hurt_dir, b.damage);
// AbstractBoat.DATA_ID_PADDLE_LEFT and DATA_ID_PADDLE_RIGHT.
@@ -193,10 +187,21 @@ impl Game<'_> {
e.defaults.push((13, serializer::INT, Value::Int(0)));
out.push(e);
}
for c in &self.carts {
let improvements = self.minecart_improvements();
for c in &mut self.carts {
let p = c.position;
let mut e = state(c.id, c.kind.id(), [p.x, p.y, p.z], c.yaw, c.on_ground());
e.pitch = c.pitch;
e.velocity = [c.velocity.x, c.velocity.y, c.velocity.z];
if improvements {
// Joining another dimension only takes a spawn snapshot; its
// regular tracking tick still owns the unsent steps.
e.minecart_steps = Some(if drain_minecart_steps {
std::mem::take(&mut c.lerp_steps)
} else {
Vec::new()
});
}
e.meta = vehicle_meta(c.hurt_time, c.hurt_dir, c.damage);
match c.kind {
// MinecartFurnace.DATA_ID_FUEL.
@@ -396,7 +401,7 @@ impl Game<'_> {
));
out.push(e);
}
self.lan_projectile_states(host_entity, hub, out);
self.lan_projectile_states(hub, out);
for o in &self.others {
let p = o.position;
let mut e = state(o.id, &o.kind, [p.x, p.y, p.z], o.yaw, false);
@@ -536,11 +541,10 @@ impl Game<'_> {
/// Primed TNT, falling blocks, projectiles, tridents, firework rockets and
/// end crystals, as guests track them.
fn lan_projectile_states(&self, host_entity: Option<i32>, hub: &mut Hub, out: &mut States) {
fn lan_projectile_states(&self, hub: &mut Hub, out: &mut States) {
let owner = |who: Option<horizon_client::mobs::Who>| match who {
Some(horizon_client::mobs::Who::Mob(id)) => id as i32,
Some(horizon_client::mobs::Who::Player) => host_entity.unwrap_or(0),
None => 0,
Some(horizon_client::mobs::Who::Player) | None => 0,
};
let at = |p: horizon_core::geometry::Vec3| [p.x, p.y, p.z];
for f in &self.fangs.list {
@@ -708,7 +712,7 @@ impl Game<'_> {
.iter()
.find(|g| g.conn == conn)
.map(|g| g.entity_id),
None => host_entity,
None => None,
};
if r.attached
&& let Some(player) = boosted
+163 -27
View File
@@ -154,6 +154,44 @@ pub(super) fn send_snowflake_particle(
}
}
/// `ServerLevel.sendParticles`: sends a `ClientboundLevelParticlesPacket` to each player within 32 blocks.
pub(super) fn send_hub_level_particles(
hub: &Hub,
options: &ParticleOptions,
at: [f64; 3],
spread: [f64; 3],
speed: f64,
count: u32,
state: Option<BlockStateId>,
) {
let Some(particle) = Game::hub_particle(hub, options, state) else {
return;
};
let horizon_only = mod_particle_only_for_horizon(&particle);
let packet = C::LevelParticles(play::LevelParticles {
particle,
override_limiter: false,
always_show: false,
pos: at,
dist: spread.map(|d| d as f32),
max_speed: [speed as f32; 3],
count: count as i32,
randomization: 0,
});
for g in hub.guests.iter().filter(|g| hub.here(g)) {
if horizon_only && g.brand != "horizon" {
continue;
}
// ServerLevel compares the player's block centre, strictly within 32.
let distance: f64 = (0..3)
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
.sum();
if distance < 32.0 * 32.0 {
g.send(&packet);
}
}
}
impl Game<'_> {
/// `SonicBoom.tick` uses `ServerLevel.sendParticles` for each ray point.
/// Keep the local event's rendering; connected players receive one at rest.
@@ -184,9 +222,11 @@ impl Game<'_> {
/// `ServerLevel.sendParticles` of a mob's particles: a packet to each
/// player within 32 blocks. The window draws its own copy. `state` is the
/// block of a block particle.
/// block of a block particle. `host` is the hub of a guest's turn, which
/// is out of `self.lan` meanwhile.
pub(super) fn lan_level_particles(
&self,
host: Option<&LanHost>,
options: &ParticleOptions,
at: [f64; 3],
spread: [f64; 3],
@@ -194,33 +234,10 @@ impl Game<'_> {
count: u32,
state: Option<BlockStateId>,
) {
let (Some(host), Some(particle)) = (&self.lan, self.network_particle(options, state))
else {
let Some(hub) = host.or(self.lan.as_ref()).map(|h| &h.hub) else {
return;
};
let horizon_only = mod_particle_only_for_horizon(&particle);
let packet = C::LevelParticles(play::LevelParticles {
particle,
override_limiter: false,
always_show: false,
pos: at,
dist: spread.map(|d| d as f32),
max_speed: [speed as f32; 3],
count: count as i32,
randomization: 0,
});
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
if horizon_only && g.brand != "horizon" {
continue;
}
// ServerLevel compares the player's block centre, strictly within 32.
let distance: f64 = (0..3)
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
.sum();
if distance < 32.0 * 32.0 {
g.send(&packet);
}
}
send_hub_level_particles(hub, options, at, spread, speed, count, state);
}
/// `LivingEntity.checkFallDamage`'s `ServerLevel.sendParticles` of the
@@ -727,8 +744,17 @@ impl Game<'_> {
#[cfg(test)]
mod tests {
use super::spread;
use super::*;
use horizon_client::lan::{
Joined, Lan, NetConfig, Out, PlayerSender,
chunks::ChunkEncoder,
data,
guest::Guest,
hub::{Hub, HubConfig},
};
use horizon_client::particles::{ParticleOptions, ParticleType};
use horizon_core::random::LegacyRandom;
use horizon_protocol::packets::play;
/// The Nether's evaporation smoke (ALTERNATIVE, dist 1, speed 0) lands
/// inside the block from its corner and stands still; DEFAULT spreads
@@ -750,4 +776,114 @@ mod tests {
}
assert!(below, "DEFAULT is a Gaussian around the position");
}
/// `ShulkerBullet.hurtServer`: fifteen crit particles (spread 0.2, speed 0.0)
/// are sent to tracking players within 32 blocks.
#[test]
fn hitting_shulker_bullet_sends_15_crit_particles_to_players() {
let config = NetConfig {
announce: false,
..NetConfig::lan(0, "ShulkerBulletTest".into(), 2)
};
let mut sync = data::SyncData::empty();
sync.builtin.registries.insert(
"minecraft:particle_type".to_string(),
horizon_protocol::registry::Ids::from_names(vec!["minecraft:crit".to_string()]),
);
let sync = std::sync::Arc::new(sync);
let lan = Lan::start_with(sync.clone(), config).unwrap();
let encoder = ChunkEncoder::new(
[] as [(std::sync::Arc<str>, bool); 0],
&horizon_protocol::registry::Builtin::default(),
&[],
&[],
);
let hub_config = HubConfig {
default_game_mode: 0,
guest_commands: false,
force_game_mode: false,
hardcore: false,
view_distance: 10,
simulation_distance: 10,
dimension: "minecraft:overworld".into(),
allow_flight: false,
seed: 0,
max_players: 20,
integrated: false,
levels: Default::default(),
};
let mut hub = Hub::new(lan, sync, encoder, hub_config);
let (tx_near, rx_near) = std::sync::mpsc::channel();
let mut near = Guest::new(
Joined {
conn: 1,
uuid: 1,
name: "NearPlayer".into(),
properties: Vec::new(),
info: horizon_protocol::packets::configuration::ClientInformation::new(8),
brand: "vanilla".into(),
sender: PlayerSender::new(tx_near, std::sync::Arc::default()),
latency: std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0)),
address: "127.0.0.1:25565".parse().unwrap(),
},
1,
[5.0, 64.0, 5.0],
8,
);
near.placed = true;
near.dimension = "minecraft:overworld".into();
hub.guests.push(near);
let (tx_far, rx_far) = std::sync::mpsc::channel();
let mut far = Guest::new(
Joined {
conn: 2,
uuid: 2,
name: "FarPlayer".into(),
properties: Vec::new(),
info: horizon_protocol::packets::configuration::ClientInformation::new(8),
brand: "vanilla".into(),
sender: PlayerSender::new(tx_far, std::sync::Arc::default()),
latency: std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0)),
address: "127.0.0.1:25566".parse().unwrap(),
},
2,
[100.0, 64.0, 100.0],
8,
);
far.placed = true;
far.dimension = "minecraft:overworld".into();
hub.guests.push(far);
let at = [0.0, 64.0, 0.0];
let options = ParticleOptions::simple(ParticleType::from_name("crit").unwrap());
send_hub_level_particles(&hub, &options, at, [0.2, 0.2, 0.2], 0.0, 15, None);
assert!(
rx_far.try_recv().is_err(),
"far guest should not receive particles"
);
let out = rx_near
.try_recv()
.expect("near guest should receive LevelParticles packet");
let body = match out {
Out::Packet(body) => body,
_ => panic!("expected Out::Packet"),
};
let mut r = horizon_protocol::buf::Reader::new(&body);
let id = r.varint().unwrap();
let packet = play::Clientbound::decode(id, r.rest()).unwrap();
match packet {
play::Clientbound::LevelParticles(p) => {
assert_eq!(p.count, 15);
assert_eq!(p.dist, [0.2, 0.2, 0.2]);
assert_eq!(p.max_speed, [0.0; 3]);
assert_eq!(p.pos, at);
assert_eq!(p.particle.id, 0);
}
other => panic!("expected LevelParticles packet, got {other:?}"),
}
}
}
+16 -109
View File
@@ -1,11 +1,10 @@
//! Players hitting players in an open world (`Player.attack` on a player):
//! the host hits a guest, a guest hits the host or another guest. The hit
//! Players hitting players on a server (`Player.attack` on a player). The hit
//! is worked out as the attacker (weapon, attack strength, critical hit,
//! enchantments, a mace's smash) and lands on the victim like any player
//! attack (blocking, armour, knockback), when the `pvp` game rule allows it
//! (`ServerPlayer.canHarmPlayer`, `isPvpAllowed`). A player's arrow on
//! another player lands the same way.
use super::{Game, lan::LanHost, lan_mobs::guest_bounds, mace::Spared, ray_box};
use super::{Game, lan::LanHost, lan_mobs::guest_bounds, mace::Spared};
use horizon_client::{
Result,
@@ -30,19 +29,16 @@ pub(super) fn meets_players(kind: &ProjectileKind) -> bool {
)
}
/// Who a player's attack lands on.
/// Who a player's attack lands on: a player by connection.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum Victim {
Host,
Guest(u32),
}
impl Victim {
fn conn(self) -> Option<u32> {
match self {
Victim::Host => None,
Victim::Guest(conn) => Some(conn),
}
let Victim::Guest(conn) = self;
Some(conn)
}
}
@@ -106,53 +102,9 @@ impl Strike {
}
impl Game<'_> {
/// The guest on the host's crosshair and how far, within reach.
pub(super) fn guest_hit(&self) -> Option<(u32, f64)> {
let host = self.lan.as_ref()?;
let (eye, dir) = self.aim()?;
let reach = self.entity_reach;
host.hub
.guests
.iter()
.filter(|g| host.hub.here(g) && g.loaded && !g.dead && !g.spectator())
.filter_map(|g| Some((g.conn, ray_box(eye, dir, reach, guest_bounds(g))?)))
.min_by(|a, b| a.1.total_cmp(&b.1))
}
/// The host's attack on the guest under the crosshair, when no mob or
/// block is nearer.
pub(super) fn attack_guest(&mut self) -> bool {
let Some((conn, d)) = self.guest_hit() else {
return false;
};
if self.mob_hit().is_some_and(|(_, m)| m < d) || !self.before_block(d) {
return false;
}
let Some(mut host) = self.lan.take() else {
return false;
};
if let Some(strike) = self.pvp_strike()
&& self.pvp_land(&mut host, Victim::Guest(conn), &strike)
{
self.weapon_hit_player(Self::pvp_victim_on_ground(&host, Victim::Guest(conn), None));
self.pvp_exhaustion();
if strike.stops_sprint() {
self.stop_sprint_after_extra_knockback();
}
}
self.lan = Some(host);
true
}
/// Whether the player a hit landed on stands on the ground (the host's
/// `ground` is passed in as `host_on_ground` when the host is the victim).
pub(super) fn pvp_victim_on_ground(
host: &LanHost,
victim: Victim,
host_on_ground: Option<bool>,
) -> bool {
/// Whether the player a hit landed on stands on the ground.
pub(super) fn pvp_victim_on_ground(host: &LanHost, victim: Victim) -> bool {
match victim {
Victim::Host => host_on_ground.unwrap_or(true),
Victim::Guest(conn) => host
.hub
.guest(conn)
@@ -163,9 +115,6 @@ impl Game<'_> {
/// The player `id` that guest `conn` attacks, if it is one.
pub(super) fn pvp_victim(host: &LanHost, conn: u32, id: i32) -> Option<Victim> {
if id == host.host_entity {
return Some(Victim::Host);
}
host.hub
.guests
.iter()
@@ -229,8 +178,7 @@ impl Game<'_> {
})
}
/// The player a player's projectile hit, when it hit one: the host for a
/// guest's shot, or a guest.
/// The player a player's projectile hit, when it hit one.
pub(super) fn projectile_pvp_victim(&self, p: &Projectile, hit: Hit) -> Option<Victim> {
if p.owner != Some(Who::Player) || !meets_players(&p.kind) {
return None;
@@ -239,7 +187,7 @@ impl Game<'_> {
return None;
};
match who {
Who::Player => p.guest.map(|_| Victim::Host),
Who::Player => None,
Who::Mob(id) => self
.lan
.as_ref()?
@@ -273,7 +221,6 @@ impl Game<'_> {
None => self.player_team().cloned(),
};
let target = match victim {
Victim::Host => self.player_team().cloned(),
Victim::Guest(conn) => host.hub.guest(conn).and_then(|g| team_of(self, &g.name)),
};
let refused = !(self.ui.rules.bool("pvp")
@@ -284,16 +231,6 @@ impl Game<'_> {
};
let shooter = self.player_attacker(&host, p.guest);
let result = match victim {
Victim::Host => {
self.lan = Some(host);
self.combat.refuse_player_hit = refused;
self.combat.projectile_attacker = shooter;
let r = self.projectile_hit(p, on_player);
self.combat.refuse_player_hit = false;
// A refused or creative hit returns before hurt_player_from takes it.
self.combat.projectile_attacker = None;
return r;
}
Victim::Guest(conn) => self.guest_hit_by(&mut host, conn, |g| {
g.combat.refuse_player_hit = refused;
g.combat.projectile_attacker = shooter;
@@ -313,10 +250,7 @@ impl Game<'_> {
let g = host.hub.guest(conn)?;
Some((g.entity_id as u32, self.player_text(&g.name, g.uuid)))
}
None => self.player.is_some().then(|| {
let name = self.player_text(&host.host_name, host.host_uuid);
(host.host_entity as u32, name)
}),
None => None,
}
}
@@ -326,7 +260,6 @@ impl Game<'_> {
// ServerPlayer.canHarmPlayer: the pvp game rule, then the victim's
// team (Player.canHarmPlayer: allies only with friendly fire on).
let team = match victim {
Victim::Host => self.player_team(),
Victim::Guest(conn) => host
.hub
.guest(conn)
@@ -346,16 +279,11 @@ impl Game<'_> {
// CombatTracker: the attacking player is the death message's killer.
// Without it the message is built with one argument and a template
// that names a killer ("%1$s was slain by %2$s") shows as written.
let attacker_note = match s.attacker {
Some(conn) => host
.hub
let attacker_note = s.attacker.and_then(|conn| {
host.hub
.guest(conn)
.map(|a| (a.entity_id, self.player_text(&a.name, a.uuid))),
None => Some((
host.host_entity,
self.player_text(&host.host_name, host.host_uuid),
)),
};
.map(|a| (a.entity_id, self.player_text(&a.name, a.uuid)))
});
let land = |g: &mut Self| {
g.combat.player_attacker = Some((s.from, s.disable_blocking));
if let Some((id, name)) = attacker_note.clone() {
@@ -373,7 +301,6 @@ impl Game<'_> {
};
let (hurt, thorns) = if self.ui.rules.bool("pvp") && harm {
match victim {
Victim::Host => land(self),
Victim::Guest(conn) => self.guest_struck_by(host, conn, land).unwrap_or_default(),
}
} else {
@@ -384,10 +311,6 @@ impl Game<'_> {
let died = match victim {
// A totem that fired is applied on the next tick_totem, so the
// victim is still at zero health but is not dying.
Victim::Host => self
.player
.as_ref()
.is_some_and(|p| p.health.dead_or_dying() && !p.health.totem_used),
Victim::Guest(conn) => host
.hub
.guest(conn)
@@ -405,7 +328,6 @@ impl Game<'_> {
}
if hurt && (s.crit || s.magic) {
let target = match victim {
Victim::Host => self.player.as_ref().map(|p| (host.host_entity, p)),
Victim::Guest(conn) => host
.hub
.guest(conn)
@@ -454,7 +376,6 @@ impl Game<'_> {
if let Some(at) = s.sweep.filter(|_| hurt) {
// The sweep around the player that was hit, as its attacker.
let (area, id) = match victim {
Victim::Host => (self.player.as_ref().map(|p| p.bounds()), host.host_entity),
Victim::Guest(conn) => {
let g = host.hub.guest(conn);
(g.map(guest_bounds), g.map_or(-1, |g| g.entity_id))
@@ -531,16 +452,6 @@ impl Game<'_> {
pub(super) fn sweep_players(&mut self, host: &mut LanHost, sweep: &Sweep) {
let from = Vec3::new(sweep.from[0], sweep.from[1], sweep.from[2]);
let mut victims = Vec::new();
if sweep.attacker.is_some()
&& self.options.dedicated.is_none()
&& !self.spectator()
&& let Some(p) = self.player.as_ref()
&& !p.health.dead_or_dying()
&& p.bounds().intersects(sweep.hit_box)
&& p.position.distance_squared(from) < 9.0
{
victims.push((Victim::Host, host.host_entity, self.player_team().cloned()));
}
for g in &host.hub.guests {
if host.hub.here(g)
&& g.loaded
@@ -572,12 +483,8 @@ impl Game<'_> {
{
continue;
}
match victim {
Victim::Host => land(self),
Victim::Guest(conn) => {
self.guest_struck_by(host, conn, land);
}
}
let Victim::Guest(conn) = victim;
self.guest_struck_by(host, conn, land);
}
}
}
+11 -1
View File
@@ -561,7 +561,17 @@ impl Game<'_> {
else {
return;
};
let mut rules = horizon_client::gamerules::GameRules::default();
// The response itself lists the server's available rules; the client
// may not own this world's level.dat (including on the server thread).
let features = if entries.iter().any(|e| {
horizon_client::gamerules::rule(&e.name)
.is_some_and(|r| r.name == horizon_client::gamerules::MAX_MINECART_SPEED.name)
}) {
vec![horizon_client::gamerules::MINECART_IMPROVEMENTS.to_owned()]
} else {
Vec::new()
};
let mut rules = horizon_client::gamerules::GameRules::with_features(&features);
for entry in entries {
rules.set_string(&entry.name, &entry.value);
}
@@ -3,16 +3,9 @@
use super::{Game, lan::LanHost};
use horizon_client::{
items::ItemStack,
lan::{
hub::HostState,
waypoints::{self, HIDING_HEADS, Icon, PLAYER_RANGE},
},
lan::waypoints::{self, HIDING_HEADS, Icon, PLAYER_RANGE},
};
/// The host player's connection id in the waypoint managers: its packets
/// go to this client's locator bar rather than to a guest.
const HOST_CONN: u32 = u32::MAX;
/// `waypoint_transmit_range`: hidden by crouching, invisibility or a head worn.
fn transmit_range(crouching: bool, invisible: bool, head: Option<&ItemStack>) -> f64 {
let hiding_head = head.is_some_and(|s| HIDING_HEADS.contains(&&*s.item));
@@ -35,38 +28,8 @@ impl Game<'_> {
/// level: a player who changes level leaves the old one's manager
/// (`ServerLevel.removePlayerImmediately`: untracked for everyone there)
/// and joins the new one's (`addDuringTeleport`).
pub(super) fn lan_update_waypoints(&mut self, host: &mut LanHost, state: Option<&HostState>) {
pub(super) fn lan_update_waypoints(&mut self, host: &mut LanHost) {
let mut players: Vec<(&str, waypoints::Player)> = Vec::new();
if let Some(h) = state {
let icon = self
.cmds
.waypoint_icons
.get(&super::commands::Target::Player)
.map(|i| Icon {
style: i.style.clone(),
color: i.color,
})
.unwrap_or_default();
players.push((
self.dim.id(),
waypoints::Player {
uuid: h.uuid,
conn: Some(HOST_CONN),
position: h.position,
spectator: self.spectator(),
transmit_range: transmit_range(
h.sneaking,
self.potions.effects.get("invisibility").is_some(),
h.equipment[5].as_ref(),
),
receive_range: PLAYER_RANGE,
first_tick: false,
view_distance: host.hub.config.view_distance,
team: self.waypoint_team(&h.name),
icon,
},
));
}
let mut respawned = Vec::new();
for g in host.hub.guests.iter().filter(|g| g.placed) {
// PlayerList.respawn makes a new ServerPlayer.
@@ -127,12 +90,7 @@ impl Game<'_> {
}
}
for (conn, packet) in out {
if conn == HOST_CONN {
// The host's own connection: its locator bar.
if let horizon_protocol::packets::play::Clientbound::TrackedWaypoint(w) = &packet {
self.handle_waypoint(w);
}
} else if let Some(g) = host.hub.guest(conn) {
if let Some(g) = host.hub.guest(conn) {
g.send(&packet);
}
}
+19 -3
View File
@@ -5,8 +5,7 @@
//! A server (the dedicated server and single player's server thread) keeps
//! the other dimensions' levels here and ticks each one with players or forced
//! chunks by swapping it into place (`enter_level`), the way `as_guest` swaps a player
//! in, so the tick code runs unchanged, one level at a time. The in-frame
//! game holds one level, which follows its player (`enter_dimension`).
//! in, so the tick code runs unchanged, one level at a time.
//!
//! Shared by every level, as vanilla's derived levels share the
//! Overworld's level data: the game time, the day time and the weather, the
@@ -309,7 +308,7 @@ impl Game<'_> {
mut host: Option<&mut LanHost>,
target: Dim,
) -> Result<()> {
// The in-frame game keeps its one level (`enter_dimension`).
// A client holds no levels.
if self.options.execution_role.is_client() {
return Ok(());
}
@@ -425,6 +424,7 @@ impl Game<'_> {
let observe_level =
running.is_some() && (self.serving.is_some() || self.options.dedicated.is_some());
let dims = self.levels_to_tick();
let order = dims.clone();
let n = dims.len();
let mut result = Ok(());
for (k, dim) in dims.into_iter().enumerate() {
@@ -467,6 +467,22 @@ impl Game<'_> {
self.native_level_tick_post(dim.id());
}
self.publish_server_debug(dim);
// An entity that crossed into a level that already ticked this
// server tick is in it when the tick ends (`addDuringTeleport`).
if running == Some(true) {
let mut moved = false;
for &earlier in &order[..k] {
if !self.arrival_chunks(earlier).is_empty()
&& self.enter_level(None, earlier).is_ok()
{
self.land_portal_arrivals();
moved = true;
}
}
if moved {
self.enter_level(None, dim)?;
}
}
}
self.level_pass = Pass::default();
if result.is_err() {
-26
View File
@@ -15,7 +15,6 @@ use horizon_core::{
position::BlockPos,
random::RandomSource,
};
use horizon_world::registry::BlockStateId;
/// `MaceItem.SMASH_ATTACK_FALL_THRESHOLD`.
const FALL_THRESHOLD: f64 = 1.5;
@@ -585,31 +584,6 @@ impl Game<'_> {
};
self.sweep_other_players(sweep);
}
/// `ServerPlayer.checkFallDamage` after a smash on a grounded target:
/// the landing block's particles, 50 for every block fallen (200 at most).
pub(super) fn smash_landing_particles(
&mut self,
state: BlockStateId,
pos: BlockPos,
fall_distance: f64,
) {
let Some((sprite, tint)) = self.particle_sprite(state, [pos.x, pos.y, pos.z]) else {
return;
};
let count = (50.0 * fall_distance).clamp(0.0, 200.0) as u32;
let spread = f64::from(0.3f32);
self.particles.send_particles(
&ParticleOptions::block(ParticleType::Block, sprite, tint),
[
f64::from(pos.x) + 0.5,
f64::from(pos.y) + 1.0,
f64::from(pos.z) + 0.5,
],
[spread; 3],
f64::from(0.15f32),
count,
);
}
/// Level event 2013 (`ParticleUtils.spawnSmashAttackParticles`): dust
/// pillars of the block at `pos`, a cluster over it and a ring 3.5 out.
pub(super) fn smash_dust(&mut self, pos: BlockPos, count: i32) {
+324 -101
View File
@@ -4,7 +4,8 @@
//! `MinecartItem` placement, `VehicleEntity` breaking, the chest, hopper,
//! furnace and TNT variants, saving, and `AbstractMinecartRenderer`'s old
//! rail-following render with the display block.
use super::{Game, Hit, InteractTarget, Keys};
use super::{Game, Hit, InteractTarget, Keys, entity::Entity};
use horizon_client::entity_rules::EntityRules;
use horizon_client::{
blocks::Fluid,
collisions::SceneCollisions,
@@ -99,7 +100,17 @@ pub(super) struct Minecart {
pub velocity: Vec3,
pub yaw: f32,
pub previous_yaw: f32,
pub pitch: f32,
pub previous_pitch: f32,
/// Minecart's client-only accumulated turn and the passenger's eased turn.
pub rotation_offset: f32,
pub player_rotation_offset: f32,
pub passenger_yaw_delta: f32,
/// Experimental track steps, drained by the server entity tracker.
pub lerp_steps: Vec<horizon_protocol::packets::play::MinecartStep>,
pub flipped: bool,
/// `AbstractMinecart.onRails`: transitions into rails need a position snap.
on_rails: bool,
pub(super) on_ground: bool,
horizontal_collision: bool,
/// The horizontal speed the cart wanted when the collision stopped it:
@@ -270,6 +281,33 @@ pub(super) struct Rail {
powered: bool,
}
impl Minecart {
/// `Minecart.tick`: stationary rotation does not turn the passenger.
pub(super) fn accumulate_rotation(&mut self) {
self.passenger_yaw_delta = 0.0;
if self.kind == CartKind::Rideable && self.position.subtract(self.previous).length() > 0.01
{
self.rotation_offset +=
((f64::from(self.yaw) - f64::from(self.previous_yaw)) % 360.0) as f32;
self.rotation_offset %= 360.0;
}
}
/// `Minecart.positionRider`: double rotLerp, then round back to float.
/// Only yaw follows; the player's pitch and manual look remain their own.
pub(super) fn rotate_passenger(&mut self, yaw: &mut f32) {
let mut delta =
(f64::from(self.rotation_offset) - f64::from(self.player_rotation_offset)) % 360.0;
if delta >= 180.0 {
delta -= 360.0;
}
if delta < -180.0 {
delta += 360.0;
}
let next = (f64::from(self.player_rotation_offset) + 0.5 * delta) as f32;
let previous = *yaw;
*yaw -= next - self.player_rotation_offset;
self.passenger_yaw_delta = *yaw - previous;
self.player_rotation_offset = next;
}
/// `Entity.onGround`.
pub(super) fn on_ground(&self) -> bool {
self.on_ground
@@ -283,7 +321,14 @@ impl Minecart {
velocity: Vec3::ZERO,
yaw: 0.0,
previous_yaw: 0.0,
pitch: 0.0,
previous_pitch: 0.0,
rotation_offset: 0.0,
player_rotation_offset: 0.0,
passenger_yaw_delta: 0.0,
lerp_steps: Vec::new(),
flipped: false,
on_rails: false,
on_ground: false,
horizontal_collision: false,
collision_speed: 0.0,
@@ -379,8 +424,13 @@ impl Game<'_> {
/// `getCurrentBlockPosOrRailBelow`.
fn rail_block(&self, p: Vec3) -> [i32; 3] {
let (x, y, z) = (p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
if self.rail(x, y - 1, z).is_some() {
[x, y - 1, z]
let below = if self.minecart_improvements() {
(p.y - 0.1 - f64::from(1.0e-5_f32)).floor() as i32
} else {
y - 1
};
if self.rail(x, below, z).is_some() {
[x, below, z]
} else {
[x, y, z]
}
@@ -488,7 +538,8 @@ impl Game<'_> {
boxes.retain(|b| b.intersects(near));
boxes
}
/// Entity.move for a cart: collisions zero the blocked components.
/// Entity.move for a cart: collisions zero the blocked components, the world border's walls
/// among them (`CollisionGetter.borderCollision`, asked for every entity).
fn move_cart(&self, c: &mut Minecart, delta: Vec3) {
let scene = SceneCollisions {
world: &self.world,
@@ -499,7 +550,7 @@ impl Game<'_> {
let mut scene = horizon_client::collisions::EntityCollisions {
blocks: scene,
entities: &entities,
border: None,
border: Some((self.border.geometry(), [c.position.x, c.position.z])),
};
if let Ok(moved) = scene.resolve(c.bounds(), delta, 0.0, c.on_ground) {
c.position = Vec3::new(
@@ -539,9 +590,11 @@ impl Game<'_> {
c.fall_distance = 0.0;
}
}
// Entity.move ends with applyEffectsFromBlocks: a bubble column lifts
// or drags the cart (tick_cart runs it a second time, like vanilla).
self.cart_block_effects(c);
// OldMinecartBehavior applies block effects here and at tick end.
// NewMinecartBehavior applies them after recording each track step.
if !self.minecart_improvements() {
self.cart_block_effects(c);
}
}
fn max_speed(&self, c: &Minecart) -> f64 {
let base = if c.in_water { 0.2 } else { 0.4 };
@@ -712,23 +765,26 @@ impl Game<'_> {
}
/// `AbstractMinecart`: `minecart_improvements` picks the smoother
/// physics.
fn minecart_improvements(&self) -> bool {
self.level
.enabled_features
pub(super) fn minecart_improvements(&self) -> bool {
self.remote
.as_ref()
.map_or(&self.level.enabled_features, |remote| &remote.features)
.iter()
.any(|f| f == "minecraft:minecart_improvements")
}
/// `NewMinecartBehavior.getMaxSpeed`: the game rule over water's half,
/// every cart alike (the old behavior's furnace bonus is its own).
fn max_speed_new(&self, c: &Minecart) -> f64 {
if c.in_water { 0.2 } else { 0.4 }
f64::from(self.ui.rules.int("max_minecart_speed")) * if c.in_water { 0.5 } else { 1.0 }
/ 20.0
}
/// `AbstractMinecart.getRedstoneDirection` (over the old port's
/// conductor proxy): which way a powered rail pushes a resting cart.
fn redstone_direction(&self, pos: [i32; 3], shape: Shape) -> Vec3 {
let (dx, dz) = match shape {
Shape::NorthSouth | Shape::AscendingNorth | Shape::AscendingSouth => (0, 1),
_ => (1, 0),
Shape::NorthSouth => (0, 1),
Shape::EastWest => (1, 0),
_ => return Vec3::ZERO,
};
// The push points away from the signal's side (getRedstoneDirection).
if self.conductor(pos[0] - dx, pos[1], pos[2] - dz) {
@@ -750,12 +806,110 @@ impl Game<'_> {
_ => false,
}
}
/// `NewMinecartBehavior.setRotation`: preserve the cart's facing when
/// motion reverses, including the pitch sign on a slope.
fn set_cart_rotation(c: &mut Minecart, mut yaw: f32, mut pitch: f32) {
if (175.0..=185.0).contains(&f64::from((yaw - c.yaw).abs())) {
c.flipped = !c.flipped;
yaw -= 180.0;
pitch = -pitch;
}
c.yaw = yaw % 360.0;
c.pitch = pitch.clamp(-45.0, 45.0) % 360.0;
}
/// `NewMinecartBehavior.MinecartStep`: wire rotations use ROTATION_BYTE.
fn record_cart_step(c: &mut Minecart, weight: f32) {
use horizon_protocol::buf::pack_degrees;
c.lerp_steps
.push(horizon_protocol::packets::play::MinecartStep {
position: c.position.components(),
movement: c.velocity.components(),
yaw: pack_degrees(c.yaw),
pitch: pack_degrees(c.pitch),
weight,
});
}
/// `NewMinecartBehavior.moveAlongTrack`: each step has its own rotation
/// and interpolation weight, including the final off-rail move.
fn finish_cart_step(&self, c: &mut Minecart, old: Vec3, initial: Vec3) {
let delta = old.vector_to(c.position);
let length = delta.length();
if length > f64::from(1.0e-5_f32) {
if delta.horizontal_distance_squared() > f64::from(1.0e-5_f32) {
let yaw = 180.0 - java_degrees(delta.z, delta.x) as f32
+ if c.flipped { 180.0 } else { 0.0 };
let pitch = if c.on_ground && !c.on_rails {
0.0
} else {
(90.0 - java_degrees(delta.horizontal_distance(), delta.y) as f32)
* if c.flipped { -1.0 } else { 1.0 }
};
Self::set_cart_rotation(c, yaw, pitch);
} else if !c.on_rails {
c.pitch = if c.on_ground {
0.0
} else {
c.pitch + 0.2 * ((-c.pitch + 180.0).rem_euclid(360.0) - 180.0)
};
}
Self::record_cart_step(c, length.min(self.max_speed_new(c)) as f32);
} else if initial.horizontal_distance_squared() > 0.0 {
Self::record_cart_step(c, 1.0);
}
}
/// `NewMinecartBehavior.adjustToRails`: snap only on the first tick or
/// when entering rails; curves project onto the segment between exits.
fn adjust_cart_to_rails(c: &mut Minecart, pos: [i32; 3], shape: Shape) {
let (e0, e1) = shape.exits();
let exit =
|e: [i32; 3]| Vec3::new(f64::from(e[0]), f64::from(e[1]), f64::from(e[2])).scale(0.5);
let old = c.position;
let (exit0, exit1) = (exit(e0), exit(e1));
let center = Vec3::new(
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
);
let mut out = Vec3::new(exit0.x, 0.0, exit0.z);
let mut into = Vec3::new(exit1.x, 0.0, exit1.z);
if c.velocity.length() > f64::from(1.0e-5_f32) && c.velocity.dot(out) < c.velocity.dot(into)
|| Self::is_descending(into, shape)
{
std::mem::swap(&mut out, &mut into);
}
let mut yaw = 180.0 - java_degrees(out.z, out.x) as f32;
if exit0.x != exit1.x && exit0.z != exit1.z {
let along = exit1.subtract(exit0);
let from = c.position.subtract(center).subtract(exit0);
let projected = along.scale(along.dot(from) / along.dot(along));
c.position = center.add_vector(exit0).add_vector(projected);
yaw = 180.0 - java_degrees(projected.z, projected.x) as f32;
} else {
if exit0.z != exit1.z {
c.position.x = center.x;
}
if exit0.x != exit1.x {
c.position.z = center.z;
}
c.position.y = center.y;
}
let pitch = if shape.is_slope() {
c.position.y += center.add_vector(into).distance(c.position) + 0.1;
if c.flipped { 45.0 } else { -45.0 }
} else {
c.position.y += 0.1;
0.0
};
Self::set_cart_rotation(c, yaw + if c.flipped { 180.0 } else { 0.0 }, pitch);
let distance = old.distance(c.position);
if distance > 0.0 {
Self::record_cart_step(c, if c.first_tick { 0.0 } else { distance as f32 });
}
}
/// `NewMinecartBehavior.moveAlongTrack`: the iteration keeps moving
/// while distance is left, over as many rails as it takes, the speed
/// recomputed per rail (slope, halt, boost).
fn move_along_track_new(&mut self, c: &mut Minecart, _rail: &Rail, intent: [f64; 2]) {
c.fall_distance = 0.0;
let ridden = self.cart_is_vehicle(c.id);
fn move_along_track_new(&mut self, c: &mut Minecart, intent: [f64; 2]) {
// `TrackIteration`: the slope, halt and boost speeds each apply once
// per tick, and the iteration keeps moving while distance is left.
let mut first = true;
@@ -763,27 +917,38 @@ impl Game<'_> {
let mut slope_done = false;
let mut halted = false;
let mut boosted = false;
for _ in 0..16 {
if !first && movement_left <= 1.0e-5 {
break;
}
let mut step_start = c.previous;
while first || movement_left > f64::from(1.0e-5_f32) {
let initial = c.velocity;
let pos = self.rail_block(c.position);
let Some(rail) = self.rail(pos[0], pos[1], pos[2]) else {
c.on_rails = false;
self.come_off_track(c);
self.finish_cart_step(c, step_start, initial);
if first || c.position.distance(step_start) > f64::from(1.0e-5_f32) {
self.cart_block_effects(c);
self.cart_block_effects(c);
}
return;
};
let initial_h = c.velocity.x.hypot(c.velocity.z);
let mut v = c.velocity;
if !c.on_rails {
c.on_rails = true;
Self::adjust_cart_to_rails(c, pos, rail.shape);
}
c.fall_distance = 0.0;
step_start = c.position;
if &*rail.name == "minecraft:activator_rail" {
self.activate_cart(c, rail.powered);
}
let ridden = self.cart_is_vehicle(c.id);
let initial_h = c.velocity.horizontal_distance();
let mut v = Vec3::new(c.velocity.x, 0.0, c.velocity.z);
// calculateSlopeSpeed (once per tick): the slide grows with the
// speed, so a descending cart keeps its pace.
if !slope_done {
let base: f64 = if c.in_water {
0.0078125 * 0.2
} else {
0.0078125
};
let slide = base.max(initial_h * 0.02);
let before = v.x.hypot(v.z);
let slide =
0.0078125_f64.max(initial_h * 0.02) * if c.in_water { 0.2 } else { 1.0 };
let before = v.horizontal_distance_squared();
match rail.shape {
Shape::AscendingEast => v.x -= slide,
Shape::AscendingWest => v.x += slide,
@@ -791,7 +956,10 @@ impl Game<'_> {
Shape::AscendingSouth => v.z -= slide,
_ => {}
}
slope_done = v.x.hypot(v.z) != before;
slope_done = v.horizontal_distance_squared() != before;
if !slope_done {
v = Vec3::new(c.velocity.x, 0.0, c.velocity.z);
}
}
// calculatePlayerInputSpeed (first iteration).
if first
@@ -799,10 +967,14 @@ impl Game<'_> {
&& (intent[0] != 0.0 || intent[1] != 0.0)
&& v.x * v.x + v.z * v.z < 0.01
{
let l = (intent[0] * intent[0] + intent[1] * intent[1]).sqrt();
v.x += intent[0] / l * 0.001;
v.z += intent[1] / l * 0.001;
halted = true;
let input = Vec3::new(intent[0], 0.0, intent[1])
.normalize()
.scale(0.001);
let pushed = v.add_vector(input);
if pushed.horizontal_distance_squared() != v.horizontal_distance_squared() {
v = pushed;
halted = true;
}
}
// calculateHaltTrackSpeed (until the input halted).
if !halted && &*rail.name == "minecraft:powered_rail" && !rail.powered {
@@ -816,34 +988,45 @@ impl Game<'_> {
}
// applyNaturalSlowdown and the speed cap (first iteration).
if first {
c.velocity = v;
v = self.natural_slowdown(c, ridden, 0.975);
let len = v.x.hypot(v.z);
if len > 0.0 {
let speed = len.min(self.max_speed_new(c));
v = Vec3::new(v.x / len * speed, v.y, v.z / len * speed);
if v.length_squared() > 0.0 {
let speed = v.length().min(self.max_speed_new(c));
v = v.normalize().scale(speed);
}
}
// calculateBoostTrackSpeed (once per tick).
if !boosted && &*rail.name == "minecraft:powered_rail" && rail.powered {
let len = v.x.hypot(v.z);
let before = v;
let len = v.length();
if len > 0.01 {
v = Vec3::new(v.x / len * (len + 0.06), v.y, v.z / len * (len + 0.06));
v = v.normalize().scale(len + 0.06);
} else {
let dir = self.redstone_direction(pos, rail.shape);
if dir != Vec3::ZERO {
v = Vec3::new(dir.x * 0.2, v.y, dir.z * 0.2);
v = dir.scale(len + 0.2);
}
}
boosted = true;
boosted = v.horizontal_distance_squared() != before.horizontal_distance_squared();
if !boosted {
v = before;
}
}
c.velocity = v;
let new_h = v.x.hypot(v.z);
let new_h = v.horizontal_distance();
movement_left = if first {
new_h
} else {
movement_left + (new_h - initial_h)
};
movement_left = self.new_step_along_track(c, pos, &rail, movement_left);
self.finish_cart_step(c, step_start, initial);
// NewMinecartBehavior.moveAlongTrack applies block effects twice,
// after recording the step, including a stationary first step.
if first || c.position.distance(step_start) > f64::from(1.0e-5_f32) {
self.cart_block_effects(c);
self.cart_block_effects(c);
}
first = false;
}
}
@@ -856,13 +1039,13 @@ impl Game<'_> {
rail: &Rail,
mut movement_left: f64,
) -> f64 {
if movement_left < 1.0e-5 {
if movement_left < f64::from(1.0e-5_f32) {
return 0.0;
}
let old = c.position;
let (e0, e1) = rail.shape.exits();
let movement = Vec3::new(c.velocity.x, 0.0, c.velocity.z);
if movement.x.hypot(movement.z) < 1.0e-5 {
if movement.length() < f64::from(1.0e-5_f32) {
c.velocity = Vec3::ZERO;
return 0.0;
}
@@ -874,9 +1057,9 @@ impl Game<'_> {
}
let out_len = out_dir.x.hypot(out_dir.z);
let mut out_pos = Vec3::new(
f64::from(pos[0]) + 0.5 + out_dir.x + out_dir.x / out_len * 1.0e-5,
f64::from(pos[0]) + 0.5 + out_dir.x + out_dir.x / out_len * f64::from(1.0e-5_f32),
f64::from(pos[1]) + 0.1,
f64::from(pos[2]) + 0.5 + out_dir.z + out_dir.z / out_len * 1.0e-5,
f64::from(pos[2]) + 0.5 + out_dir.z + out_dir.z / out_len * f64::from(1.0e-5_f32),
);
if in_hill && !Self::is_descending(c.velocity, rail.shape) {
out_pos.y += 1.0;
@@ -891,7 +1074,7 @@ impl Game<'_> {
old.add_vector(
movement
.normalize()
.scale(movement_left * std::f64::consts::SQRT_2),
.scale(movement_left * f64::from(std::f32::consts::SQRT_2)),
)
} else {
old.add_vector(movement.normalize().scale(movement_left))
@@ -904,10 +1087,14 @@ impl Game<'_> {
}
self.move_cart(c, old.vector_to(new_pos));
if in_hill {
let new_block = self.rail_block(c.position);
let new_block = [
new_pos.x.floor() as i32,
new_pos.y.floor() as i32,
new_pos.z.floor() as i32,
];
if let Some(new_rail) = self.rail(new_block[0], new_block[1], new_block[2]) {
// restAtVShape: opposing slopes rest a slow cart.
if c.velocity.length() < 0.005_f64.sqrt()
if c.velocity.length_squared() < 0.005
&& new_rail.shape.is_slope()
&& Self::is_descending(c.velocity, rail.shape)
&& !Self::is_descending(c.velocity, new_rail.shape)
@@ -928,7 +1115,9 @@ impl Game<'_> {
}
}
// The cart did not move but the step would have: it is stuck.
if c.position.distance(old) < 1.0e-5 && new_pos.distance(old) > 1.0e-5 {
if c.position.distance(old) < f64::from(1.0e-5_f32)
&& new_pos.distance(old) > f64::from(1.0e-5_f32)
{
c.velocity = Vec3::ZERO;
return 0.0;
}
@@ -937,7 +1126,11 @@ impl Game<'_> {
}
/// `comeOffTrack`.
fn come_off_track(&self, c: &mut Minecart) {
let max = self.max_speed(c);
let max = if self.minecart_improvements() {
self.max_speed_new(c)
} else {
self.max_speed(c)
};
c.velocity.x = c.velocity.x.clamp(-max, max);
c.velocity.z = c.velocity.z.clamp(-max, max);
if c.on_ground {
@@ -963,13 +1156,11 @@ impl Game<'_> {
/// `applyEffectsFromBlocks` for a cart: a bubble column lifts or drags it,
/// and `onInsideBubbleColumn` resets the fall distance.
fn cart_block_effects(&self, c: &mut Minecart) {
if horizon_client::motion::bubble_column_push(
if c.push_by_bubble_column(
&self.world,
self.pack.assets.registry,
self.res.facts(),
self.collision,
c.bounds(),
&mut c.velocity,
) {
c.fall_distance = 0.0;
}
@@ -989,62 +1180,71 @@ impl Game<'_> {
}
/// `AbstractMinecart.tick` for one cart; returns false once it is gone.
fn tick_cart(&mut self, c: &mut Minecart, intent: [f64; 2]) -> bool {
c.first_tick = false;
c.tick_count += 1;
c.previous = c.position;
c.previous_yaw = c.yaw;
c.previous_pitch = c.pitch;
c.hurt_time = (c.hurt_time - 1).max(0);
c.damage = (c.damage - 1.0).max(0.0);
// applyGravity
if !c.no_gravity {
c.velocity.y -= if c.in_water { 0.005 } else { 0.04 };
}
let pos = self.rail_block(c.position);
if let Some(rail) = self.rail(pos[0], pos[1], pos[2]) {
if self.minecart_improvements() {
self.move_along_track_new(c, &rail, intent);
// NewMinecartBehavior.tick snaps a newly created cart before gravity.
if self.minecart_improvements() && c.first_tick {
let pos = self.rail_block(c.position);
if let Some(rail) = self.rail(pos[0], pos[1], pos[2]) {
c.on_rails = true;
Self::adjust_cart_to_rails(c, pos, rail.shape);
} else {
self.move_along_track(c, pos, &rail, intent);
c.on_rails = false;
}
}
// applyGravity
c.velocity.y -= c.get_gravity();
let pos = self.rail_block(c.position);
if self.minecart_improvements() {
self.move_along_track_new(c, intent);
} else if let Some(rail) = self.rail(pos[0], pos[1], pos[2]) {
self.move_along_track(c, pos, &rail, intent);
if &*rail.name == "minecraft:activator_rail" {
self.activate_cart(c, rail.powered);
}
} else {
self.come_off_track(c);
}
// Yaw from the movement, flipped when it turns around.
let (xd, zd) = (c.previous.x - c.position.x, c.previous.z - c.position.z);
if xd * xd + zd * zd > 0.001 {
c.yaw = zd.atan2(xd).to_degrees() as f32;
if c.flipped {
c.yaw += 180.0;
c.first_tick = false;
// The old behavior derives yaw once per tick; the experiment has
// already recorded each step's yaw and pitch.
if !self.minecart_improvements() {
let (xd, zd) = (c.previous.x - c.position.x, c.previous.z - c.position.z);
if xd * xd + zd * zd > 0.001 {
c.yaw = zd.atan2(xd).to_degrees() as f32;
if c.flipped {
c.yaw += 180.0;
}
}
let diff = (c.yaw - c.previous_yaw + 180.0).rem_euclid(360.0) - 180.0;
if !(-170.0..170.0).contains(&diff) {
c.yaw += 180.0;
c.flipped = !c.flipped;
}
c.yaw %= 360.0;
}
let diff = (c.yaw - c.previous_yaw + 180.0).rem_euclid(360.0) - 180.0;
if !(-170.0..170.0).contains(&diff) {
c.yaw += 180.0;
c.flipped = !c.flipped;
if !self.minecart_improvements() {
self.cart_block_effects(c);
}
c.yaw %= 360.0;
// applyEffectsFromBlocks: a bubble column lifts or drags the cart.
self.cart_block_effects(c);
// OldMinecartBehavior.tick ends with pushAndPickupEntities, before
// AbstractMinecart.tick's updateFluidInteraction.
// AbstractMinecart.tick's updateFluidInteraction. The experiment still
// uses this fallback until its per-move pickup/push retry is ported.
self.push_from_ticked_cart(c);
// AbstractMinecart.tick: updateFluidInteraction, a stream pushes the cart.
c.in_water = horizon_client::entities::update_entity_fluids(
c.bounds(),
c.position,
c.bounds().max_position().y,
&mut c.velocity,
&self.world,
&self.res.motion().fluids,
)
.0 > 0.0;
c.in_water = c.push_by_fluid(&self.world, &self.res.motion().fluids).0 > 0.0;
// updateFluidInteraction: being in water resets the fall distance.
if c.in_water {
c.fall_distance = 0.0;
}
if self.minecart_improvements() {
// MinecartTNT.tick reads the final delta after AbstractMinecart.tick,
// including the pushes and fluid interaction after the track steps.
c.collision_speed = c.velocity.horizontal_distance_squared();
}
// Variants.
match c.kind {
CartKind::Furnace => {
@@ -2023,6 +2223,7 @@ impl Game<'_> {
tint: impl Fn(i32) -> Option<u32>,
out: &mut Vec<Vertex>,
) {
let mut normals = horizon_client::item_render::FaceNormals::new(m);
for model in models {
for face in &model.faces {
let Some(s) = self
@@ -2052,8 +2253,7 @@ impl Game<'_> {
);
}
let normal = if diffuse {
use horizon_client::item_render::{direction_vector, transform_normal};
let n = transform_normal(m, direction_vector(face.quad.facing));
let n = normals.face(face.quad.facing);
let n = n.to_array().map(|v| (v.clamp(-1.0, 1.0) * 127.0) as i8);
[n[0], n[1], n[2], 127]
} else {
@@ -2129,11 +2329,17 @@ impl Game<'_> {
let Some(model) = self.cart_models.models.get(&c.kind) else {
continue;
};
let (x, y, z) = (
c.previous.x + (c.position.x - c.previous.x) * alpha,
c.previous.y + (c.position.y - c.previous.y) * alpha,
c.previous.z + (c.position.z - c.previous.z) * alpha,
);
let lerp = self.remote_minecart_lerp(c.id, a);
let new_render = self.minecart_improvements() || lerp.is_some();
let (x, y, z) = if let Some((p, _, _)) = lerp {
(p[0], p[1], p[2])
} else {
(
c.previous.x + (c.position.x - c.previous.x) * alpha,
c.previous.y + (c.position.y - c.previous.y) * alpha,
c.previous.z + (c.position.z - c.previous.z) * alpha,
)
};
// LevelExtractor.isEntityVisible: the cart the player rides always draws.
if self.riding != Some(c.id) {
let d = glam::DVec3::new(c.position.x, c.position.y, c.position.z) - camera;
@@ -2162,7 +2368,9 @@ impl Game<'_> {
c.previous_yaw + ((c.yaw - c.previous_yaw + 180.0).rem_euclid(360.0) - 180.0) * a;
let mut pitch = 0.0f32;
let mut offset = glam::DVec3::ZERO;
if let Some(on) = self.rail_pos(x, y, z) {
if new_render {
(yaw, pitch) = lerp.map_or((c.yaw, c.pitch), |(_, yaw, pitch)| (yaw, pitch));
} else if let Some(on) = self.rail_pos(x, y, z) {
let front = self.rail_pos_offs(x, y, z, 0.3).unwrap_or(on);
let back = self.rail_pos_offs(x, y, z, -0.3).unwrap_or(on);
offset = glam::DVec3::new(on[0] - x, (front[1] + back[1]) / 2.0 - y, on[2] - z);
@@ -2182,10 +2390,19 @@ impl Game<'_> {
.wrapping_add(seed.wrapping_mul(98_761));
let jitter = |shift: i64| (((seed >> shift & 7) as f32 + 0.5) / 8.0 - 0.5) * 0.004;
let p = glam::DVec3::new(x, y, z) + offset - camera;
let mut base = glam::Mat4::from_translation(
p.as_vec3() + glam::Vec3::new(jitter(16), jitter(20) + 0.375, jitter(24)),
) * glam::Mat4::from_rotation_y((180.0 - yaw).to_radians())
* glam::Mat4::from_rotation_z((-pitch).to_radians());
let jittered = p.as_vec3() + glam::Vec3::new(jitter(16), jitter(20), jitter(24));
let mut base = if new_render {
// AbstractMinecartRenderer.newRender translates the model
// after rotating, so the seat follows the pitched cart.
glam::Mat4::from_translation(jittered)
* glam::Mat4::from_rotation_y(yaw.to_radians())
* glam::Mat4::from_rotation_z((-pitch).to_radians())
* glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.375, 0.0))
} else {
glam::Mat4::from_translation(jittered + glam::Vec3::new(0.0, 0.375, 0.0))
* glam::Mat4::from_rotation_y((180.0 - yaw).to_radians())
* glam::Mat4::from_rotation_z((-pitch).to_radians())
};
let hurt = c.hurt_time as f32 - a;
if hurt > 0.0 {
let damage = (c.damage - a).max(0.0);
@@ -2402,6 +2619,12 @@ fn tnt_ignites_by_collision(horizontal_collision: bool, speed_sqr: f64) -> bool
horizontal_collision && speed_sqr >= 0.009_999_999_776_482_582
}
/// NewMinecartBehavior's `(float) (Math.atan2(y, x) * 180.0 / Math.PI)` before
/// the cast: HotSpot's atan2, multiplied then divided in Java's order.
fn java_degrees(y: f64, x: f64) -> f64 {
horizon_core::jmath::atan2(y, x) * 180.0 / std::f64::consts::PI
}
#[cfg(test)]
mod tests {
#[test]
Loaded 100 of 352 files, more files were not shown because too many files have changed in this diff. Show more