Merge main into #6994

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-02 10:55:43 +01:00
commit 6cc25834e4
29 files changed
+4782 -321

No files matched your search

+1 -1
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` |
+32
View File
@@ -115,3 +115,35 @@ licensed under the MIT license:
> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
> DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
## BlueMap
`crates/horizon-client-render/src/bluemap.rs` reads the lowres tiles of a server's
[BlueMap](https://github.com/BlueMap-Minecraft/BlueMap) web map. It follows three of BlueMap's files:
`core/src/main/java/de/bluecolored/bluemap/core/map/lowres/LowresLayer.java` (`saveTile`) for the
tiles' pixel layout, `common/webapp/src/js/util/Utils.js` (`pathFromCoords`) for their paths, and
`core/src/main/java/de/bluecolored/bluemap/core/map/MapSettingsSerializer.java` for a map's
`settings.json`, at commit
[96ae320d1d39](https://github.com/BlueMap-Minecraft/BlueMap/tree/96ae320d1d39581c10058d8222f2290f2fcb5333).
BlueMap is licensed under the MIT license:
> Copyright (c) Blue (Lukas Rieger) <https://bluecolored.de>
> Copyright (c) contributors
>
> Permission is hereby granted, free of charge, to any person obtaining a copy
> of this software and associated documentation files (the "Software"), to deal
> in the Software without restriction, including without limitation the rights
> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
> copies of the Software, and to permit persons to whom the Software is
> furnished to do so, subject to the following conditions:
>
> The above copyright notice and this permission notice shall be included in
> all copies or substantial portions of the Software.
>
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
> THE SOFTWARE.
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());
}
}
+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]
+35 -2
View File
@@ -543,6 +543,9 @@ pub struct Graphics {
pub lod_distance: u32,
/// 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) 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,
/// Skip drawing entities hidden behind opaque terrain, synchronously or asynchronously.
@@ -641,6 +644,7 @@ impl Default for Graphics {
preset: Preset::Custom,
lod_distance: 0,
far_detail: FarDetail::Full,
blue_map: crate::bluemap::Settings::default(),
cave_culling: true,
entity_culling: EntityCullingMode::Off,
leaf_culling: LeafCullingMode::Off,
@@ -692,6 +696,14 @@ impl Graphics {
let mut g: Self = fs::read_to_string(dir.join(FILE))
.map(|t| Self::parse(&t))
.unwrap_or_default();
g.blue_map.servers = match fs::read_to_string(dir.join(crate::bluemap::SERVERS_FILE)) {
Ok(text) => crate::bluemap::Settings::parse_servers(&text),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(e) => {
log::warn!("{}: {e}; no BlueMap is named", crate::bluemap::SERVERS_FILE);
Vec::new()
}
};
if fs::remove_file(dir.join(HDR_MARKER)).is_ok() && g.hdr {
g.hdr = false;
g.save(dir);
@@ -720,6 +732,12 @@ impl Graphics {
g.lod_distance = number(g.lod_distance).min(crate::lod::MAX_CHUNKS)
}
"farDetail" => g.far_detail = FarDetail::parse(value),
"blueMapRadius" => {
if let Some(radius) = crate::bluemap::Settings::parse_radius(value) {
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),
@@ -811,7 +829,7 @@ impl Graphics {
g
}
pub fn serialize(&self) -> String {
format!(
let mut text = format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\n",
self.preset.name(),
self.lod_distance,
@@ -862,7 +880,9 @@ impl Graphics {
self.api.name(),
self.shader_pack,
bool_text(self.translucent_batching),
)
);
text += &self.blue_map.serialize();
text
}
/// Binds Horizon's graphics key `name` (`HDR_KEY`); false for another name.
pub fn set_key(&mut self, name: &str, value: String) -> bool {
@@ -989,6 +1009,12 @@ mod tests {
preset: Preset::Balanced,
lod_distance: 256,
far_detail: FarDetail::Reduced,
blue_map: crate::bluemap::Settings {
radius: 40,
auto_detect: true,
// In their own file (`bluemap::SERVERS_FILE`).
servers: Vec::new(),
},
cave_culling: false,
entity_culling: EntityCullingMode::Synchronous,
leaf_culling: LeafCullingMode::SolidAggressive,
@@ -1045,6 +1071,13 @@ mod tests {
Graphics::parse("entityCulling:false\n").entity_culling,
EntityCullingMode::Off
);
// A map named here (a shared instance's, a pack's) is no map.
assert!(
Graphics::parse("blueMap:play.example.net https://map.example.net\n")
.blue_map
.servers
.is_empty()
);
assert_eq!(Graphics::parse(""), Graphics::default());
// Translucent Batching is on, in files from before it too; only false turns it off,
// and only under its key: the old one held the false every save wrote while it was
@@ -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();
+2
View File
@@ -13,6 +13,8 @@ pub mod block_entities;
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;
File diff suppressed because it is too large. Load diff
@@ -6,9 +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>/`.
//! 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;
@@ -68,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));
@@ -102,41 +169,114 @@ 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>>,
}
/// One level's stored terrain, shared by the far-terrain threads.
/// 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 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) -> Arc<Self> {
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) {
return Found::Ready(columns);
}
self.blue_map
.as_ref()
.map_or(Found::Absent, |map| map.chunk(x, z))
}
/// 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),
@@ -148,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.
@@ -178,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) {
@@ -188,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),
@@ -205,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)?;
@@ -224,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()
}
@@ -363,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 {
@@ -377,15 +610,19 @@ impl LodSampler for Sampler {
Some(Box::new(Self {
terrain: self.terrain.clone(),
details: Vec::new(),
waited: false,
}))
}
/// Stored chunks come back whole; the others are known empty.
/// 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 = self
.terrain
.chunk(x, z)
.unwrap_or_else(|| Arc::new(std::array::from_fn(|_| Column::default())));
let columns = match self.terrain.chunk_or_map(x, z) {
Found::Ready(columns) => columns,
Found::Waiting => continue,
Found::Absent => Arc::new(std::array::from_fn(|_| Column::default())),
};
self.details.push(((x, z), columns));
}
}
@@ -398,35 +635,66 @@ impl LodSampler for Sampler {
}
}
fn sample(&mut self, x: i32, z: i32) -> Option<Surface> {
self.terrain.chunk(x >> 4, z >> 4)?[((z & 15) * 16 + (x & 15)) as usize].surface
self.column(x, z).surface
}
fn column(&mut self, x: i32, z: i32) -> Column {
self.terrain
.chunk(x >> 4, z >> 4)
.map(|c| c[((z & 15) * 16 + (x & 15)) as usize].clone())
.unwrap_or_default()
match self.terrain.chunk_or_map(x >> 4, z >> 4) {
Found::Ready(c) => c[((z & 15) * 16 + (x & 15)) as usize].clone(),
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).
/// 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 Some(chunk) = self.terrain.chunk(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);
}
// Searching wider cells would open thousands of region files.
if width >> 4 > MAX_KEPT_SIDE {
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);
}
match map.reduced_column(x, z, width) {
Found::Ready(column) => return column,
Found::Waiting => waiting = true,
Found::Absent => {}
}
}
// 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()
}
}
@@ -513,19 +781,217 @@ mod tests {
let dir = scratch("server-terrain");
let columns = columns();
{
let terrain = ServerTerrain::new(dir.clone());
let terrain = ServerTerrain::new(dir.clone(), None);
assert_eq!(terrain.chunk(-33, 5), None);
terrain.store((-33, 5), &columns).unwrap();
}
let terrain = ServerTerrain::new(dir.clone());
let terrain = ServerTerrain::new(dir.clone(), None);
assert_eq!(terrain.chunk(-33, 5), Some(columns.clone()));
assert_eq!(terrain.nearest(-64, 0, 64), Some((-33, 5)));
assert_eq!(terrain.nearest(-32, 0, 64), None);
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();
}
}
@@ -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>,
@@ -389,6 +389,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",
@@ -359,6 +359,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",
+13 -13
View File
@@ -9,19 +9,19 @@ 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, 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, options_ui, overlay, pack_screen, packs, par,
particles, poi_sections, post_chain, potent_sulfur, preedit, providers, screen_draw,
screen_effects, sculk, section_mask, server_terrain, settings, shader_options,
shader_pack_list, signs, skins, sound_engine, sound_events, sound_preview, spans, split_layout,
split_map, split_profiles, sprites, streaming, structure_block, tab_list, telemetry,
test_block, test_instance_block, testgen, title_edition, trims, ui, vault, weather, widgets,
world, worldborder, zip,
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,
options_ui, overlay, pack_screen, packs, par, particles, poi_sections, post_chain,
potent_sulfur, preedit, providers, screen_draw, screen_effects, sculk, section_mask,
server_terrain, settings, shader_options, shader_pack_list, signs, skins, sound_engine,
sound_events, sound_preview, spans, split_layout, split_map, split_profiles, sprites,
streaming, structure_block, tab_list, telemetry, test_block, test_instance_block, testgen,
title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
};
pub mod attack_strength;
pub mod attribute_updates;
+98 -14
View File
@@ -2300,6 +2300,9 @@ struct Game<'a> {
lod_observe: horizon_client::fast_hash::FxHashSet<(i32, i32)>,
/// The server level far terrain keeps the chunks of.
lod_level: Option<String>,
/// The server's BlueMap far terrain draws from, when the player named one
/// or Find BlueMap found it.
lod_blue_map: Option<Arc<horizon_client::bluemap::BlueMap>>,
/// Beacon beam sections kept after their chunk unloads, so far terrain
/// LOD still draws their beams (`beacon_geometry` refreshes them).
far_beacons: HashMap<(i32, i32, i32), Vec<(u32, i32)>>,
@@ -2443,6 +2446,9 @@ struct Game<'a> {
/// `SpectatorGui`: the menu a spectator opens with the hotbar keys.
spectator_gui: horizon_client::spectator_menu::SpectatorGui,
previous: Instant,
/// When the last frame's work ended: what runs before the next one starts (`idle`, the
/// event loop) shows in HORIZON_SPIKES as the time between frames.
frame_end: Instant,
/// FramerateLimitTracker.latestInputTime.
last_input: Instant,
/// Controllers (Horizon's own).
@@ -16607,6 +16613,7 @@ impl Game<'_> {
self.lod = None;
self.lod_source_attempted = false;
self.lod_observe.clear();
self.lod_blue_map = None;
self.far_beacons.clear();
self.lod_covered_at = None;
if let Some(renderer) = &mut self.renderer {
@@ -16665,14 +16672,66 @@ impl Game<'_> {
(None, None, Some(remote)) => std::env::current_dir().ok().map(|home| {
use horizon_client::server_terrain as terrain;
let address = &remote.session.address;
let dir = terrain::level_dir(
&home.join("server-terrain"),
let root = home.join("server-terrain");
let dir = terrain::level_dir(&root, address, remote.hashed_seed, &remote.level);
self.lod_level = Some(remote.level.clone());
// Past them, the server's BlueMap, if the player named it or
// lets Horizon look for it (on the BlueMap worker).
let maps = root.join(terrain::server_folder(address)).join("BlueMap");
let settings = &self.settings.horizon.blue_map;
let blue_map = horizon_client::bluemap_detect::source(
settings,
address,
&maps,
std::time::SystemTime::now(),
)
.map(|source| {
use horizon_client::{bluemap, bluemap_detect::Source};
// The map's address only at debug: logs land in crash reports.
log::info!("Horizon: far terrain also draws {}'s BlueMap", remote.level);
if let Source::Url { url, .. } = &source {
log::debug!("Horizon: BlueMap at {url}");
}
let map = Arc::new(bluemap::BlueMap::from_source(
source,
&remote.level,
maps,
settings.radius_blocks(),
bluemap::Palette::from_game(self.pack.assets.registry, self.res.biomes()),
));
map.start();
map
});
self.lod_blue_map = blue_map.clone();
// And the player's Bobby cache of the level, only read.
let bobby = terrain::bobby_level_dir(
&terrain::minecraft_dir().join(".bobby"),
address,
remote.hashed_seed,
&remote.level,
);
self.lod_level = Some(remote.level.clone());
terrain::ServerTerrain::new(dir) as _
)
.map(|bobby| {
log::info!(
"Horizon: far terrain also reads Bobby's chunks of {}",
remote.level
);
let registry = self.pack.assets.registry;
let biomes = self.res.biomes().clone();
let dimension = &remote.dimension;
let layout = horizon_client::chunk::ColumnLayout::of_level(
dimension.min_y,
dimension.height,
);
let sky_light = dimension.has_skylight;
let decode: terrain::DecodeChunk =
Box::new(move |root: &horizon_world::nbt::NamedTag| {
horizon_client::save::decode_lit(
root, registry, &biomes, layout, sky_light,
)
});
terrain::Bobby::new(bobby, decode, self.res.facts())
});
terrain::ServerTerrain::with_bobby(dir, blue_map, bobby) as _
}),
_ => Some(self.streaming.lod_source()),
};
@@ -16685,7 +16744,7 @@ impl Game<'_> {
}
}
fn update_world(&mut self) {
if let Err(e) = self.poll_remote() {
if let Err(e) = horizon_client::spans::time("poll_remote", || self.poll_remote()) {
log::warn!("Horizon: {e}");
}
// The renderer laid translucent quads out again (a shader pack's flat terrain came
@@ -16704,6 +16763,12 @@ impl Game<'_> {
self.lod_observe.insert((x >> 4, z >> 4));
}
}
if let (Some(map), Some(p), Some(lod)) = (&self.lod_blue_map, &self.player, &mut self.lod) {
map.set_center(p.position.x.floor() as i32, p.position.z.floor() as i32);
for change in map.take_changed() {
lod.refresh(change.area, change.widths);
}
}
if let Some(p) = &self.player {
self.streaming.center = (
(p.position.x.floor() as i32) >> 4,
@@ -16894,10 +16959,17 @@ impl Game<'_> {
.collect()
})
});
// Far terrain's finest tiles stand where full-detail terrain
// ends: on a server, where it stops sending chunks, often well
// inside the render distance. Thousands of chunk-sized tiles
// between the two filled in long after the coarser ones.
let detail = self.remote.as_ref().map_or(self.streaming.radius, |r| {
self.streaming.radius.min(r.sent_radius())
});
let meshes = horizon_client::spans::time("lod_update", || {
lod.update(
[player.position.x, player.position.y, player.position.z],
self.streaming.radius,
detail,
lod_distance,
covered,
)
@@ -18209,7 +18281,7 @@ impl Game<'_> {
self.mapped_key_event(key, event.state == ElementState::Pressed, event.repeat)?;
}
WindowEvent::RedrawRequested => {
self.recover_device()?;
horizon_client::spans::time("recover_device", || self.recover_device())?;
self.observe_fullscreen();
self.sync_ime();
let now = Instant::now();
@@ -18218,6 +18290,7 @@ impl Game<'_> {
.spikes
.and_then(|_| horizon_client::spans::thread_cpu());
let elapsed = now.duration_since(self.previous);
let between = now.saturating_duration_since(self.frame_end);
self.frame_ticks = elapsed.as_secs_f32() * 20.0;
self.previous = now;
// Arrow-key camera controls belong to gameplay; chat and
@@ -18305,8 +18378,10 @@ impl Game<'_> {
&& let Some(r) = self.renderer.as_ref()
{
let cpu = now.elapsed().saturating_sub(r.last_wait).as_secs_f32() * 1000.0;
// Spans timed between frames (`idle`) come first.
let spans = horizon_client::spans::take();
if cpu > threshold {
let between = between.as_secs_f32() * 1000.0;
if cpu > threshold || between > threshold {
// The render thread's own CPU time in the frame:
// far below the wall time, the frame waited.
let worked = horizon_client::spans::thread_cpu()
@@ -18314,8 +18389,13 @@ impl Game<'_> {
.map_or_else(String::new, |(end, start)| {
format!(", cpu {:.1} ms", (end - start).as_secs_f32() * 1000.0)
});
let gap = if between > threshold {
format!(", {between:.1} ms before it")
} else {
String::new()
};
log::info!(
"Horizon: slow frame {cpu:.1} ms{worked} ({steps} ticks): {}",
"Horizon: slow frame {cpu:.1} ms{worked}{gap} ({steps} ticks): {}",
horizon_client::spans::describe(&spans)
);
}
@@ -18431,6 +18511,7 @@ impl Game<'_> {
self.fps_frames = 0;
self.fps_at = Instant::now();
}
self.frame_end = Instant::now();
if self.options.frames.is_some_and(|f| self.frames >= f)
|| self
.options
@@ -18497,7 +18578,8 @@ impl Game<'_> {
}
return;
}
if let Err(error) = self.resume_world_entry() {
if let Err(error) = horizon_client::spans::time("world_entry", || self.resume_world_entry())
{
self.error = Some(error);
self.exit_on_error(event_loop);
return;
@@ -18510,12 +18592,12 @@ impl Game<'_> {
event_loop.exit();
return;
}
self.tick_server_packs();
self.poll_control(event_loop);
horizon_client::spans::time("server_packs", || self.tick_server_packs());
horizon_client::spans::time("control", || self.poll_control(event_loop));
if self.pending_save.is_some() {
return;
}
self.poll_pads(event_loop);
horizon_client::spans::time("pads", || self.poll_pads(event_loop));
self.pause_if_inactive();
if self.options.frames.is_some() && self.started.elapsed() > self.options.timeout {
self.error = Some("frame-limited run timed out".into());
@@ -18656,6 +18738,7 @@ impl<'a> Game<'a> {
far_beacons: HashMap::new(),
lod_observe: Default::default(),
lod_level: None,
lod_blue_map: None,
lod_covered_at: None,
view_arrival: Default::default(),
options,
@@ -18743,6 +18826,7 @@ impl<'a> Game<'a> {
started: now,
spectator_gui: Default::default(),
previous: now,
frame_end: now,
last_input: now,
pad: Default::default(),
frames: 0,
+88
View File
@@ -48,6 +48,8 @@ pub(super) struct Blast {
}
pub(super) trait Entity {
/// `Entity.getId`.
fn id(&self) -> u32;
fn position(&self) -> Vec3;
fn position_mut(&mut self) -> &mut Vec3;
fn velocity_mut(&mut self) -> &mut Vec3;
@@ -136,6 +138,28 @@ pub(super) trait Entity {
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
}
/// 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 +176,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 +358,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))
}
@@ -405,6 +438,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 +473,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 +505,12 @@ impl Entity for ExperienceOrb {
impl Entity for Boat {
stored_motion!();
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 +534,15 @@ impl Entity for Boat {
impl Entity for Minecart {
stored_motion!();
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))
}
@@ -582,6 +636,13 @@ impl Entity for ArmorStand {
impl Entity for PrimedTnt {
stored_motion!();
/// `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 +676,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 +729,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 +754,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
+2 -2
View File
@@ -2023,6 +2023,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 +2053,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 {
+41 -134
View File
@@ -9,6 +9,7 @@
//! A server keeps one level per dimension. Entity portal processes and
//! cooldowns follow their level; shared `Arrival`s hold each traveller's
//! saved compound until its destination chunks are loaded.
use super::entity::{Entity as _, entity_lists_of, for_each_entity};
use super::{Dim, Game, Screen};
use horizon_client::Result;
use horizon_client::fast_hash::FxHashMap;
@@ -110,7 +111,7 @@ fn rotate_y(v: Vec3, degrees: f32) -> Vec3 {
}
/// Whether a projectile is an arrow, which goes through a portal like the
/// other entities.
fn is_arrow(p: &horizon_client::projectiles::Projectile) -> bool {
pub(super) fn is_arrow(p: &horizon_client::projectiles::Projectile) -> bool {
matches!(
p.kind,
horizon_client::projectiles::ProjectileKind::Arrow { .. }
@@ -119,7 +120,7 @@ fn is_arrow(p: &horizon_client::projectiles::Projectile) -> bool {
/// The projectiles besides arrows that a portal sends on: the thrown items
/// and fireballs a chunk saves. A pearl has its own rules (it keeps its owner's
/// dimension) and a wind charge is not saved, so both stay.
fn is_portal_projectile(p: &horizon_client::projectiles::Projectile) -> bool {
pub(super) fn is_portal_projectile(p: &horizon_client::projectiles::Projectile) -> bool {
use horizon_client::projectiles::ProjectileKind;
!is_arrow(p)
&& !matches!(
@@ -131,7 +132,7 @@ fn is_portal_projectile(p: &horizon_client::projectiles::Projectile) -> bool {
&& horizon_client::entity_nbt::saved_with_chunk(p)
}
/// Width and height of a projectile that goes through a portal.
fn projectile_box(p: &horizon_client::projectiles::Projectile) -> (f32, f32) {
pub(super) fn projectile_box(p: &horizon_client::projectiles::Projectile) -> (f32, f32) {
if is_arrow(p) {
(
horizon_client::arrows::WIDTH,
@@ -685,73 +686,24 @@ impl Game<'_> {
}
self.load_portal_arrivals();
let mut leaving: Vec<(u32, BlockPos)> = Vec::new();
self.portals.before.clear();
for e in &self.dropped {
self.portals.before.insert(e.id, e.position);
}
for m in &self.mobs {
self.portals.before.insert(m.id, m.position);
}
for b in &self.boats {
self.portals.before.insert(b.id, b.position);
}
for c in &self.carts {
self.portals.before.insert(c.id, c.position);
}
for t in &self.tnts {
self.portals.before.insert(t.id, t.position);
}
for o in &self.orbs {
self.portals.before.insert(o.id, o.position);
}
// Only an entity in a portal, or cooling down from one, has a state
// here: whether the others could use a portal is not worth asking
// for each of them on every tick.
// Positions before the entities move, and (only for an entity in a
// portal, or cooling down from one, which has a state here) whether it
// can use one: asking every entity on every tick is not worth it.
let mut before = std::mem::take(&mut self.portals.before);
before.clear();
let mut ids: Vec<(u32, bool)> = Vec::new();
if !self.portals.entities.is_empty() {
let tracked = &self.portals.entities;
for e in &self.dropped {
if tracked.contains_key(&e.id) {
ids.push((e.id, true));
let tracked = &self.portals.entities;
for_each_entity!(entity_lists_of!(self), entity => {
if entity.portal_size().is_some() {
if entity.portal_from().is_none() {
before.insert(entity.id(), entity.position());
}
if tracked.contains_key(&entity.id()) {
ids.push((entity.id(), entity.can_use_portal(self)));
}
}
for m in &self.mobs {
if tracked.contains_key(&m.id) {
ids.push((m.id, self.mob_can_use_portal(m)));
}
}
for t in &self.tnts {
if tracked.contains_key(&t.id) {
ids.push((t.id, self.tnt_can_use_portal(t.id)));
}
}
let vehicles = self.boats.iter().map(|b| b.id);
for id in vehicles.chain(self.carts.iter().map(|c| c.id)) {
if tracked.contains_key(&id) {
ids.push((id, self.vehicle_can_use_portal(id)));
}
}
for p in &self.creatures.projectiles {
if tracked.contains_key(&p.id) && (is_arrow(p) || is_portal_projectile(p)) {
ids.push((p.id, true));
}
}
for o in &self.orbs {
if tracked.contains_key(&o.id) {
ids.push((o.id, true));
}
}
for t in &self.tridents {
if tracked.contains_key(&t.id) {
ids.push((t.id, true));
}
}
for r in &self.rockets {
if tracked.contains_key(&r.id) && r.attached_to.is_none() && !r.attached {
ids.push((r.id, true));
}
}
}
});
self.portals.before = before;
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
self.portals.entities.retain(|id, _| present.contains(id));
for (id, allowed) in ids {
@@ -825,11 +777,11 @@ impl Game<'_> {
/// `canUsePortal(false)` of a boat or minecart: empty, or ridden by
/// server players only, whom it carries along.
/// `Entity.canUsePortal(false)` for a primed TNT: it carries no mob.
fn tnt_can_use_portal(&self, id: u32) -> bool {
pub(super) fn tnt_can_use_portal(&self, id: u32) -> bool {
// Id 0: not tracked yet (the in-frame path), nothing to key its portal state by.
id != 0 && !self.mobs.iter().any(|m| m.extra.mount == Some(id))
}
fn vehicle_can_use_portal(&self, id: u32) -> bool {
pub(super) fn vehicle_can_use_portal(&self, id: u32) -> bool {
if !self.vehicle_occupied(id) {
return true;
}
@@ -883,85 +835,40 @@ impl Game<'_> {
if self.remote.is_some() {
return;
}
let mut inside: Vec<(u32, BlockPos)> = Vec::new();
let items: Vec<(u32, Vec3, f32, f32)> = self
.dropped
.iter()
.map(|e| (e.id, e.position, 0.25, 0.25))
.chain(self.orbs.iter().map(|o| (o.id, o.position, 0.5, 0.5)))
.collect();
// PrimedTnt is 0.98 across; one that carries a mob stays (the rider would be left behind).
let tnts: Vec<(u32, Vec3, f32, f32)> = self
.tnts
.iter()
.filter(|t| self.tnt_can_use_portal(t.id))
.map(|t| (t.id, t.position, 0.98, 0.98))
.collect();
let mobs: Vec<(u32, Vec3, f32, f32)> = self
.mobs
.iter()
.filter(|m| self.mob_can_use_portal(m))
.map(|m| (m.id, m.position, m.width(), m.height()))
.collect();
let vehicles: Vec<(u32, Vec3, f32, f32)> = self
.boats
.iter()
.map(|b| (b.id, b.position, super::boats::WIDTH, super::boats::HEIGHT))
.chain(self.carts.iter().map(|c| {
(
c.id,
c.position,
super::minecarts::WIDTH,
super::minecarts::HEIGHT,
)
}))
.filter(|(id, ..)| self.vehicle_can_use_portal(*id))
.map(|(id, at, w, h)| (id, at, w as f32, h as f32))
.collect();
let mut portal_sections = std::mem::take(&mut self.portals.sections);
for (id, to, width, height) in items.into_iter().chain(tnts).chain(mobs).chain(vehicles) {
let from = self.portals.before.get(&id).copied().unwrap_or(to);
if let Some(pos) = self.swept_portal(from, to, width, height, &mut portal_sections) {
inside.push((id, pos));
}
}
// The arrow's `previous` is where it stood at the start of its tick,
// which is also where an arrow shot this tick began. A stuck arrow
// that did not move this tick enters no block.
for p in self.creatures.projectiles.iter().filter(|p| {
(is_arrow(p) && (!p.stuck || p.previous != p.position)) || is_portal_projectile(p)
}) {
let (width, height) = projectile_box(p);
if let Some(pos) =
self.swept_portal(p.previous, p.position, width, height, &mut portal_sections)
{
inside.push((p.id, pos));
}
}
// A trident (0.5 across) or rocket (0.25) in flight, or one that moved
// this tick; a rocket that carries its owner stays.
let mut flyers: Vec<(u32, Vec3, Vec3, f32)> = Vec::new();
// A trident or rocket in flight gets its id here, before the walk.
for t in self
.tridents
.iter_mut()
.filter(|t| !t.in_ground || t.previous != t.position)
{
let id = super::lan_entities::assign_entity_id(&mut self.next_entity, &mut t.id);
flyers.push((id, t.previous, t.position, 0.5));
super::lan_entities::assign_entity_id(&mut self.next_entity, &mut t.id);
}
for r in self
.rockets
.iter_mut()
.filter(|r| r.attached_to.is_none() && !r.attached)
{
let id = super::lan_entities::assign_entity_id(&mut self.next_entity, &mut r.id);
flyers.push((id, r.previous, r.position, 0.25));
super::lan_entities::assign_entity_id(&mut self.next_entity, &mut r.id);
}
for (id, from, to, size) in flyers {
if let Some(pos) = self.swept_portal(from, to, size, size, &mut portal_sections) {
let mut inside: Vec<(u32, BlockPos)> = Vec::new();
let mut portal_sections = std::mem::take(&mut self.portals.sections);
// The first portal block each entity's box swept through since the
// start of its tick (a stuck arrow that did not move enters none).
for_each_entity!(entity_lists_of!(self), entity => {
let Some((width, height)) = entity.portal_size() else {
continue;
};
if !entity.portal_sweeps() || !entity.can_use_portal(self) {
continue;
}
let (id, to) = (entity.id(), entity.position());
let from = entity
.portal_from()
.unwrap_or_else(|| self.portals.before.get(&id).copied().unwrap_or(to));
if let Some(pos) = self.swept_portal(from, to, width, height, &mut portal_sections) {
inside.push((id, pos));
}
}
});
self.portals.sections = portal_sections;
for (id, pos) in inside {
self.portals
+5
View File
@@ -10045,6 +10045,11 @@ impl Remote {
}
}
}
/// How far the server sends chunks: its view distance and the ring
/// around (`ChunkTrackingView`), at least two.
pub(super) fn sent_radius(&self) -> i32 {
self.view_distance.max(2) + 1
}
/// A player entity's profile name: the one it was created with, else its
/// player-list entry's (an entity the list named after it spawned).
fn level_player_name(&self, id: i32, uuid: &u128) -> Option<&str> {
+67 -3
View File
@@ -283,8 +283,34 @@ pub fn direction_vector(d: Direction) -> Vec3 {
}
/// `PoseStack.Pose.transformNormal`: the normal matrix, renormalized.
pub fn transform_normal(m: Mat4, n: Vec3) -> Vec3 {
let normal = Mat3::from_mat4(m).inverse().transpose();
(normal * n).normalize_or_zero()
FaceNormals::new(m).transform(n)
}
/// [`transform_normal`] of one pose for many quads: its normal matrix inverted once, and
/// the normal of each of the six facings worked out the first time a quad uses it.
pub struct FaceNormals {
normal: Mat3,
faces: [Option<Vec3>; 6],
}
impl FaceNormals {
pub fn new(m: Mat4) -> Self {
Self {
normal: Mat3::from_mat4(m).inverse().transpose(),
faces: [None; 6],
}
}
fn transform(&self, n: Vec3) -> Vec3 {
(self.normal * n).normalize_or_zero()
}
/// The pose's normal of a face pointing `d`.
pub fn face(&mut self, d: Direction) -> Vec3 {
let i = d as usize;
if let Some(n) = self.faces[i] {
return n;
}
let n = self.transform(direction_vector(d));
self.faces[i] = Some(n);
n
}
}
/// An `ARGB` int as the `u32` colour of an [`ItemQuad`].
pub fn argb(color: i32) -> u32 {
@@ -307,6 +333,7 @@ fn submit_quads(
FoilType::Special => Foil::Special,
};
let _ = display;
let mut normals = FaceNormals::new(m);
// Quads of one layer mostly share a sprite: look its rect and its
// translucency up once.
let mut last: Option<(&str, bool)> = None;
@@ -363,7 +390,7 @@ fn submit_quads(
pos,
uv,
foil_uv: q.uv,
normal: transform_normal(m, direction_vector(q.facing)),
normal: normals.face(q.facing),
color: argb(color),
foil,
light_emission: q.light_emission,
@@ -1148,4 +1175,41 @@ mod tests {
let r = Mat4::from_rotation_y(std::f32::consts::FRAC_PI_2);
assert!((transform_normal(r, Vec3::Z) - Vec3::X).length() < 1e-6);
}
#[test]
fn face_normals_of_a_pose_are_its_inverse_transpose() {
// A dropped item's pose: spun, squashed and moved. Each face's normal is the
// inverse transpose of the pose's 3x3 applied to the face's axis, renormalized,
// here worked out in f64 from the cofactors.
let m = Mat4::from_translation(Vec3::new(3.0, -1.0, 7.5))
* Mat4::from_rotation_y(0.7)
* Mat4::from_rotation_x(-0.3)
* Mat4::from_scale(Vec3::new(0.5, 0.25, 2.0));
let c = m.to_cols_array();
let a = |row: usize, col: usize| f64::from(c[col * 4 + row]);
let cofactor = |row: usize, col: usize| {
let (r0, r1) = ((row + 1) % 3, (row + 2) % 3);
let (c0, c1) = ((col + 1) % 3, (col + 2) % 3);
a(r0, c0) * a(r1, c1) - a(r0, c1) * a(r1, c0)
};
let mut normals = FaceNormals::new(m);
// Twice each: a cached face gives what it gave the first time.
for d in Direction::ALL.into_iter().chain(Direction::ALL) {
let axis = direction_vector(d).to_array().map(f64::from);
// inverse^T = cofactors / det: the det's sign is the only part that survives
// renormalizing, and it is positive here.
let n: Vec<f64> = (0..3)
.map(|row| (0..3).map(|col| cofactor(row, col) * axis[col]).sum())
.collect();
let len = n.iter().map(|v| v * v).sum::<f64>().sqrt();
let got = normals.face(d);
for (g, e) in got.to_array().into_iter().zip(&n) {
assert!(
(f64::from(g) - e / len).abs() < 1e-6,
"{d:?}: {got} vs {n:?}"
);
}
assert_eq!(got, transform_normal(m, direction_vector(d)), "{d:?}");
}
}
}
@@ -3597,7 +3597,9 @@ fn a_file_item_whose_remainder_was_refused_takes_only_its_mod_out() {
fn signs_take_their_form_wood_and_wall_block_from_their_declaration() {
use horizon_world::level::{Behaviours, ItemKind};
let base = prepared().expect("this ignored test needs prepared 26.3 resources");
let Some(base) = prepared() else {
return;
};
let scratch = std::env::temp_dir().join(format!("horizon-mod-signs-{}", std::process::id()));
let _ = fs::remove_dir_all(&scratch);
let m = folder_mod(
@@ -3696,7 +3698,9 @@ fn a_feature_gives_a_mods_sign_its_block_entity() {
use crate::containers::BlockEntity;
use crate::streaming::{ChunkGenerator, Proto};
let base = prepared().expect("this ignored test needs prepared 26.3 resources");
let Some(base) = prepared() else {
return;
};
let scratch =
std::env::temp_dir().join(format!("horizon-mod-sign-feature-{}", std::process::id()));
let _ = fs::remove_dir_all(&scratch);
+187
View File
@@ -1599,6 +1599,19 @@ pub fn decode_with(
}
decode_column(root, registry, biomes, tables, layout)
}
/// A full chunk with the light saved with it (`isLightOn`), as far terrain
/// reads another client's cache of a server; `None` without its light.
pub fn decode_lit(
root: &NamedTag,
registry: &BlockRegistry,
biomes: &Biomes,
layout: ColumnLayout,
sky_light: bool,
) -> Option<Chunk> {
let mut chunk = decode_with(root, registry, biomes, None, layout)?;
load_light(&root.tag, &mut chunk, sky_light);
chunk.light_loaded.then_some(chunk)
}
/// `decode_with` whatever the chunk's status, into a chunk of `layout`
/// (sections outside it are left out).
fn decode_column(
@@ -7439,4 +7452,178 @@ mod tests {
assert!(chunk.tag.get("blending_data").is_some());
let _ = std::fs::remove_dir_all(&world);
}
/// A chunk as Bobby leaves it on disk, its palettes in both shapes: a
/// list of names, and compounds where a name without properties is
/// wrapped as `{"": name}` (a mixed list), read back summarized by far
/// terrain and kept in its own folder.
#[test]
fn far_terrain_reads_and_keeps_bobby_chunks() {
use crate::server_terrain::{Bobby, DecodeChunk, ServerTerrain};
let Some(root) = std::env::var_os("HORIZON_TEST_RESOURCES") else {
return;
};
let root = PathBuf::from(root);
let assets = crate::assets::Assets::load(&root).expect("assets");
let biomes = Arc::new(Biomes::load(&root).expect("biomes"));
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).expect("facts");
let dir = std::env::temp_dir().join(format!("horizon-bobby-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let (own, bobby) = (dir.join("own"), dir.join("bobby"));
fs::create_dir_all(&bobby).unwrap();
let name = |s: &str| Tag::String(s.into());
let biome = Tag::compound([("palette", Tag::List(vec![name("minecraft:plains")]))]);
let section = |y: i8, states: Tag| {
Tag::compound([
("Y", Tag::Byte(y)),
("block_states", states),
("biomes", biome.clone()),
("SkyLight", Tag::ByteArray(vec![0xff; 2048])),
])
};
// y 48..64 stone; y 64 an upright oak log, air above.
let stone = Tag::compound([("palette", Tag::List(vec![name("minecraft:stone")]))]);
let log = Tag::compound([
("id", name("minecraft:oak_log")),
("properties", Tag::compound([("axis", name("y"))])),
]);
let mut data = vec![0i64; 256];
data[..16].fill(0x1111_1111_1111_1111);
let logs = Tag::compound([
("palette", Tag::List(vec![name("minecraft:air"), log])),
("data", Tag::LongArray(data)),
]);
let chunk = |version: i32| NamedTag {
name: "".into(),
tag: Tag::compound([
("isLightOn", Tag::Byte(1)),
("Status", name("full")),
("zPos", Tag::Int(5)),
("block_entities", Tag::List(Vec::new())),
("yPos", Tag::Int(-4)),
("DataVersion", Tag::Int(version)),
("xPos", Tag::Int(-2)),
(
"sections",
Tag::List(vec![section(3, stone.clone()), section(4, logs.clone())]),
),
("age", Tag::Long(1_793_576_311_368)),
]),
};
let region = bobby.join("r.-1.0.mca");
let mut file = RegionFile::open_writable(&region, true).unwrap();
let record = ChunkRecord::from_nbt(
&chunk(DATA_VERSION),
Compression::Zlib,
0,
Limits::default(),
);
file.write(ChunkPos::new(-2, 5), &record.unwrap()).unwrap();
let record = ChunkRecord::from_nbt(
&chunk(DATA_VERSION - 1),
Compression::Zlib,
0,
Limits::default(),
);
file.write(ChunkPos::new(-1, 5), &record.unwrap()).unwrap();
drop(file);
let before = fs::read(&region).unwrap();
let registry = assets.registry;
let decode: DecodeChunk =
Box::new(move |root| decode_lit(root, registry, &biomes, ColumnLayout::VANILLA, true));
let terrain =
ServerTerrain::with_bobby(own.clone(), None, Some(Bobby::new(bobby, decode, &facts)));
let mut upright = registry
.state(registry.default_state("minecraft:oak_log").unwrap())
.unwrap()
.clone();
upright.properties.insert("axis".into(), "y".into());
let upright = registry.id(&upright).unwrap();
let stone = registry.default_state("minecraft:stone").unwrap();
let columns = terrain.chunk(-2, 5).expect("Bobby's chunk");
for column in columns.iter() {
let surface = column.surface.expect("a surface");
assert_eq!((surface.height, surface.state), (65, upright));
assert!(
column
.spans
.as_slice()
.iter()
.any(|span| span.state == stone)
);
}
// Another game version's chunk is none.
assert_eq!(terrain.chunk(-1, 5), None);
drop(terrain);
assert_eq!(
fs::read(&region).unwrap(),
before,
"Bobby's folder is only read"
);
assert_eq!(ServerTerrain::new(own, None).chunk(-2, 5), Some(columns));
let _ = fs::remove_dir_all(&dir);
}
/// A cache Bobby really wrote (`HORIZON_TEST_BOBBY`, its `.bobby`: the
/// fork's bobby-observe run, three local servers): found as the client
/// would find it, and every chunk of its Overworld folders decodes.
#[test]
fn far_terrain_reads_a_real_bobby_cache() {
use crate::server_terrain::{Bobby, DecodeChunk, ServerTerrain, bobby_level_dir};
let (Some(resources), Some(cache)) = (
std::env::var_os("HORIZON_TEST_RESOURCES"),
std::env::var_os("HORIZON_TEST_BOBBY"),
) else {
return;
};
let resources = PathBuf::from(resources);
let cache = PathBuf::from(cache);
let assets = crate::assets::Assets::load(&resources).expect("assets");
let biomes = Arc::new(Biomes::load(&resources).expect("biomes"));
let facts = crate::blocks::BlockFacts::load(&resources, assets.registry).expect("facts");
let registry = assets.registry;
let levels = [
("127.0.0.1", -9030034075591913594),
("127.0.0.1", 4696776658964135993),
("localhost:25566", -7181178862026813658),
];
for (address, seed) in levels {
let dir = bobby_level_dir(&cache, address, seed, "minecraft:overworld")
.unwrap_or_else(|| panic!("{address} {seed}: no folder"));
let mut chunks = Vec::new();
for entry in fs::read_dir(&dir).unwrap().flatten() {
if entry.path().extension().is_some_and(|e| e == "mca") {
let file = RegionFile::open(entry.path()).unwrap();
chunks.extend(file.chunks().map(|(pos, _)| pos));
}
}
assert!(
chunks.len() > 100,
"{address} {seed}: {} chunks",
chunks.len()
);
let own = std::env::temp_dir()
.join(format!("horizon-real-bobby-{seed}-{}", std::process::id()));
let _ = fs::remove_dir_all(&own);
let biomes = biomes.clone();
let decode: DecodeChunk = Box::new(move |root| {
decode_lit(root, registry, &biomes, ColumnLayout::VANILLA, true)
});
let terrain =
ServerTerrain::with_bobby(own.clone(), None, Some(Bobby::new(dir, decode, &facts)));
let read = chunks
.iter()
.filter(|pos| {
terrain
.chunk(pos.x, pos.z)
.is_some_and(|columns| columns.iter().all(|c| c.surface.is_some()))
})
.count();
assert_eq!(
read,
chunks.len(),
"{address} {seed}: chunks read with a surface"
);
let _ = fs::remove_dir_all(&own);
}
}
}
+11 -1
View File
@@ -63,7 +63,17 @@ A full opaque cube means `RenderShape::Model`, `can_occlude`, `solid_render` and
Distant terrain uses progressively coarser voxel columns. Columns retain separate material layers and air gaps; matching exposed faces are merged, with block-scale texture repeats. Complete chunk data is reduced once on the terrain worker and cached at each supported scale, across each cell’s area instead of enlarging its corner column. Material votes combine different light levels of the same block; reduced cells retain the brightest sampled sky and block light separately. Finer tiles replace only their own footprints in a loading fallback, and a movement margin limits rapid resolution changes at tile boundaries. When Full Detail Distance shrinks, the chunks it no longer covers stay drawn until the tiles planned there are ready, so the coarsest loaded tiles never stand in for them. Local Overworld, Nether and End worlds and a LAN world you host can supply their generator. Remote servers do not supply the client's world-generation seed or generator configuration.
On a server that doesn't sample far terrain for you (any vanilla, Paper or Spigot server; Horizon servers that do are below), distant terrain is what the server has sent: every chunk you load is kept as its reduced columns in `server-terrain/<server>/<seed>/<namespace>/<dimension>/` (Anvil region files, about a sector a chunk; `<seed>` is the hashed seed the server sends, which keeps the worlds behind one address apart) in the game folder, and drawn beyond the server's view distance on this visit and later ones. Nothing is pruned: delete `server-terrain/<server>` to forget a server (a server whose plugin sends another hashed seed at every login starts a new folder each time; the worlds of a proxy whose servers all hide their seed as 0 share one). A friend's world is not kept (its invite code changes at every share), nor a splitscreen guest's (it is the player's own world). Areas you have never loaded stay empty; far off, where a cell of far terrain is wider than a chunk, it is drawn from the kept chunk nearest its middle. A chunk the server sends again replaces the kept one; changes made while you are away show on your next visit. The kept columns are tied to the game version; after an update a server's terrain fills in again as you play.
On a server that doesn't sample far terrain for you (any vanilla, Paper or Spigot server; Horizon servers that do are below), distant terrain is what the server has sent: every chunk you load is kept as its reduced columns in `server-terrain/<server>/<seed>/<namespace>/<dimension>/` (Anvil region files, about a sector a chunk; `<seed>` is the hashed seed the server sends, which keeps the worlds behind one address apart) in the game folder, and drawn beyond the server's view distance on this visit and later ones. Nothing is pruned: delete `server-terrain/<server>` to forget a server (a server whose plugin sends another hashed seed at every login starts a new folder each time; the worlds of a proxy whose servers all hide their seed as 0 share one). A friend's world is not kept (its invite code changes at every share), nor a splitscreen guest's (it is the player's own world). If you also play with Bobby, far terrain reads its cache of the same server, world and dimension in `.bobby/` of the official launcher's game folder (`HORIZON_MINECRAFT_DIR` if set): only read, never written, and a chunk only Bobby has is kept in `server-terrain/` the first time far terrain needs it. Areas neither has loaded stay empty; far off, where a cell of far terrain is wider than a chunk, it is drawn once you have loaded at least half of it. A chunk the server sends again replaces the kept one; changes made while you are away show on your next visit. The kept columns are tied to the game version; after an update a server's terrain fills in again as you play.
If the server runs a [BlueMap](https://bluemap.bluecolored.de/) web map, far terrain can fill the rest from it: Horizon downloads the map's lowres tiles (a colour, height and block light a column) around you and draws each column as the natural block nearest its colour. Chunks you kept always come first. It is off until you name the server's map in `horizon-bluemap.txt` beside `horizon-graphics.txt`, one line per server (its own file, which shared instances and modpacks don't carry: a map is asked for tiles around where you stand, so only you name one):
```
blueMap:play.example.net https://map.example.net
```
The address is the one you join with; behind a proxy, the map named for it is drawn for every server there (name its levels, or leave it off where they differ). The map for each dimension is guessed from the map ids (`world`, `world_nether`, `world_the_end`); name others after the URL, as `overworld=survival nether=survival_nether mod:moon=moon`. `blueMapRadius:` in `horizon-graphics.txt` is how far around you tiles are downloaded, in chunks (`2048b` for blocks; 64 chunks by default). Tiles are fetched one at a time, at most ten a second, on a thread of their own; if the map stops answering, Horizon waits longer between tries and draws what it already has. They are kept in `server-terrain/<server>/BlueMap/` (256 MiB at most a server; the oldest go first) and fetched again after three days. Only BlueMap's lowres tiles are read, not its detailed (hires) models, so builds and trees appear as flat coloured terrain.
Horizon can also look for a server's map itself: turn on **Find BlueMap** under Video Settings → Advanced → Rendering (`blueMapAutoDetect:true`), next to **BlueMap Radius**. On joining a server no `blueMap:` line names, a background thread asks a few likely addresses for BlueMap's `settings.json`, one at a time with short timeouts: BlueMap's own web server (`http://<host>:8100`), `https://`/`http://` on the host and on its `map.` subdomain, and `map.` of the parent domain for `play.`/`mc.` style hosts (only port 8100 and plain HTTP for an IP address). Only a reply that is BlueMap's web-app settings (a list of maps and `mapDataRoot`) counts. The answer is kept in `server-terrain/<server>/BlueMap/detected.txt`: a map found is used on later visits without asking, and checked again after a week; a server without one is asked again after a day. A `blueMap:` line always wins.
Unknown areas are sampled directly from the dimension's seed-bound density and surface rules, with shared per-tile scratch and material evaluation. A small pool samples tiles independently of meshing. Refinement prioritizes visible screen error; high aerial views use the distance to the world height range to avoid unnecessary close detail below the camera. Initial vegetation uses the world's configured tree types, placement densities and biome assignments at every distance level, and chorus plants on the End's end stone in the same way. The End's small islands are estimated above the void from the `end_island_decorated` feature. Tree, chorus and island positions, shapes and large-cell coverage are visual approximations, not a replay of native feature placement; chorus is drawn only while its cells stay small enough to read as a plant.
+8 -3
View File
@@ -83,13 +83,18 @@ const SHARED: [&str; 9] = [
"servers.dat",
"optionsshaders.txt",
];
/// What is never copied from an instance's own game folder: logs, and the CurseForge app's own
/// files.
const SKIPPED: [&str; 4] = [
/// What is never copied from an instance's own game folder: logs, the CurseForge app's own
/// files, and the BlueMap web maps the game asks for tiles around the player
/// (`horizon_client_render::bluemap::SERVERS_FILE`): only the player names those, never a pack
/// the other launcher installed.
const SKIPPED: [&str; 6] = [
"logs",
"crash-reports",
"minecraftinstance.json",
".curseclient",
"horizon-bluemap.txt",
// Far terrain's copies of what servers sent, and of their web maps.
"server-terrain",
];
const CURSEFORGE: &str = "the CurseForge app";
const MODRINTH_APP: &str = "the Modrinth App";
+121 -38
View File
@@ -8,7 +8,12 @@ p99/p99.9, the worst frame, frames over 33 ms) in the situations where players s
ocean perf_bench's ocean flight (16 blocks/s, 12 s): Horizon's worst lows on Windows at RD 32
fly64 a 1,920-block straight line at 64 blocks/s (30 s), new terrain all the way
teleport 8 teleports of 1,500-3,000 blocks, 5 s recorded after each
elytra a 990-block straight line at 33 blocks/s (30 s, an elytra's cruise), new terrain all the way
nether the overworld -> Nether -> overworld (/execute in ... run tp), 8 s recorded after each
portal through a Nether portal twice (an obsidian frame built at two far places), 8 s recorded after each
join joining a dedicated server (seed 1234, the same view distance; its spawn chunks, entities and
tab list arrive at once), recorded from `connect` until 5 s after the view is loaded: a game
of its own, started on the title screen
inventory the inventory and the pause menu opened and closed 20 times each
One game per render distance, from a fresh world (seed 1234, creative), driven over horizon-ctl.
@@ -37,8 +42,15 @@ except ImportError:
import perf_bench as pb
WORK = lab.ROOT / ".local/pacing-bench"
SCENARIOS = ["fly16", "ocean", "fly64", "teleport", "nether", "inventory"]
SCENARIOS = ["fly16", "ocean", "fly64", "elytra", "teleport", "nether", "portal", "inventory", "join"]
SPIKE_RE = re.compile(r"slow frame ([\d.]+) ms")
GAP_RE = re.compile(r", ([\d.]+) ms before it")
def spike_ms(line):
"""A spike line's frame time plus the time before the frame (between frames)."""
gap = GAP_RE.search(line)
return float(SPIKE_RE.search(line).group(1)) + (float(gap.group(1)) if gap else 0.0)
def stats(frame_ms):
@@ -90,8 +102,11 @@ def scenario(name, control, distance):
time.sleep(2)
reply = control("fly {} {} {} {} {} {} 12 {} raw".format(*start, *end, pitch))
add((reply.get("frame_ms") or [], reply.get("cpu_ms") or []))
elif name == "fly64":
start, end = (3000.0, 140.0, -3000.0), (3000.0, 140.0, -1080.0)
elif name in ("fly64", "elytra"):
start, end = {
"fly64": ((3000.0, 140.0, -3000.0), (3000.0, 140.0, -1080.0)),
"elytra": ((-3000.0, 160.0, 3000.0), (-2010.0, 160.0, 3000.0)),
}[name]
control("tp {} {} {}".format(*start))
control("fly {0} {1} {2} {0} {1} {2} 0.1 20".format(*start))
pb.wait_loaded(control, lambda p: True, 300, pb.view_chunks(distance))
@@ -107,7 +122,21 @@ def scenario(name, control, distance):
elif name == "nether":
for dim, (x, y, z) in [("the_nether", (100, 100, 100)), ("overworld", (800, 150, 800)),
("the_nether", (-300, 100, 200)), ("overworld", (-2400, 150, 1600))]:
add(record(control, lambda: control(f"command execute in minecraft:{dim} run tp @s {x} {y} {z}"), 8))
# Through chat, as a player types it: `command` waits on the server thread in the frame.
add(record(control, lambda: control(f"chat /execute in minecraft:{dim} run tp @s {x} {y} {z}"), 8))
elif name == "portal":
# A creative player crosses a portal after one tick in it (Player.getPortalWaitTime).
for x, y, z in [(1200, 120, -1200), (-1800, 120, 600)]:
control(f"command execute in minecraft:overworld run tp @s {x + 3} {y} {z}")
control(f"fly {x + 3} {y} {z} {x + 3} {y} {z} 0.1 10")
pb.wait_loaded(control, lambda p: True, 300, pb.view_chunks(distance))
control(f"command fill {x} {y} {z - 1} {x} {y + 4} {z + 2} minecraft:obsidian")
control(f"command fill {x} {y + 1} {z} {x} {y + 3} {z + 1} minecraft:nether_portal[axis=z]")
time.sleep(1)
add(record(control, lambda: control(f"tp {x + 0.5} {y + 1} {z + 1.0}"), 8))
dimension = str(control("state").get("dimension"))
if not dimension.endswith("the_nether"):
raise RuntimeError(f"the portal at {x} {y} {z} led to {dimension}, not the Nether")
elif name == "inventory":
def spam():
for _ in range(20):
@@ -124,12 +153,13 @@ def scenario(name, control, distance):
return frames, cpu
def run(args, distance):
game_dir = WORK / f"game-{args.label}-rd{distance}"
def launch(args, distance, tag):
"""A fresh game on its title screen: (process, control, log path, log file, game dir, socket)."""
import shutil
game_dir = WORK / f"game-{args.label}-rd{distance}{tag}"
shutil.rmtree(game_dir, ignore_errors=True)
pb.write_options(game_dir, distance)
sock = Path("/tmp") / f"horizon-pacing-{os.getpid()}.sock"
sock = Path("/tmp") / f"horizon-pacing-{os.getpid()}{tag}.sock"
sock.unlink(missing_ok=True)
client = Path(args.build).resolve() / "horizon-client"
command = [str(client), "--resources", str(Path(args.resources).resolve()), "--control-socket", str(sock),
@@ -143,43 +173,96 @@ def run(args, distance):
for kv in args.env:
k, _, v = kv.partition("=")
env[k] = v
log_path = WORK / f"{args.label}-rd{distance}.log"
result = {"label": args.label, "distance": distance, "load_before": pb.load_average(), "scenarios": {}}
with log_path.open("w") as log:
proc = subprocess.Popen(command, cwd=game_dir, env=env, stdout=log, stderr=subprocess.STDOUT)
control = pb.Control(sock)
try:
pb.poll(control, lambda s: s.get("screen") == "title" and s.get("renderer"), 300)
log_path = WORK / f"{args.label}-rd{distance}{tag}.log"
log = log_path.open("w")
proc = subprocess.Popen(command, cwd=game_dir, env=env, stdout=log, stderr=subprocess.STDOUT)
control = pb.Control(sock)
pb.poll(control, lambda s: s.get("screen") == "title" and s.get("renderer"), 300)
return proc, control, log_path, log, game_dir, sock
def dedicated_server(args, distance, directory):
"""A dedicated server of the same build (seed 1234, offline, the client's view distance), once it is up:
(server, port)."""
from test_dedicated_server_parity import Server
directory.mkdir(parents=True, exist_ok=True)
port = lab.free_port()
properties = {
"online-mode": "false", "white-list": "false", "level-seed": str(pb.SEED), "gamemode": "creative",
"spawn-protection": "0", "view-distance": str(distance), "simulation-distance": "12",
"pause-when-empty-seconds": "-1", "server-port": str(port),
}
(directory / "server.properties").write_text("".join(f"{k}={v}\n" for k, v in properties.items()),
encoding="iso-8859-1")
(directory / "eula.txt").write_text("eula=true\n")
binary = Path(args.build).resolve() / "horizon-client"
server = Server([binary, "--resources", Path(args.resources).resolve(), "--dedicated", "--nogui"],
directory, "server")
server.wait_for(r"Done \(", 900)
return server, port
def play(args, distance, names, result):
"""One game through `names`: in a fresh world, or (`join` alone) joining a dedicated server."""
import shutil
joining = names == ["join"]
proc, control, log_path, log, game_dir, sock = launch(args, distance, "-join" if joining else "")
server = None
try:
if joining:
server, port = dedicated_server(args, distance, WORK / f"server-{args.label}-rd{distance}")
else:
control(f"open {pb.WORLD} {pb.SEED} creative")
pb.wait_loaded(control, lambda p: True, args.timeout, pb.view_chunks(distance))
for name in args.scenarios.split(","):
mark = log_path.stat().st_size
for name in names:
mark = log_path.stat().st_size
if name == "join":
control("stats reset")
control(f"connect 127.0.0.1:{port}")
pb.wait_loaded(control, lambda p: True, args.timeout, pb.view_chunks(distance))
time.sleep(5)
reply = control("stats raw reset")
frames, cpu = reply.get("frame_ms") or [], reply.get("cpu_ms") or []
else:
frames, cpu = scenario(name, control, distance)
log.flush()
with log_path.open(errors="replace") as f:
f.seek(mark)
spikes = [line.strip() for line in f if "slow frame" in line]
spikes.sort(key=lambda line: -float(SPIKE_RE.search(line).group(1)))
entry = stats(frames)
entry["cpu"] = stats(cpu)
entry["slowest_spans"] = spikes[:12]
entry["spike_lines"] = len(spikes)
entry["frame_ms"] = [round(v, 2) for v in frames]
result["scenarios"][name] = entry
print(f"rd{distance} {name}: " + ", ".join(f"{k} {v}" for k, v in entry.items()
if k not in ("cpu", "slowest_spans", "frame_ms")), flush=True)
finally:
try:
control("quit", timeout=60)
except (OSError, ValueError, RuntimeError):
pass
pb.stop(proc)
sock.unlink(missing_ok=True)
shutil.rmtree(game_dir, ignore_errors=True)
log.flush()
with log_path.open(errors="replace") as f:
f.seek(mark)
spikes = [line.strip() for line in f if "slow frame" in line]
spikes.sort(key=lambda line: -spike_ms(line))
entry = stats(frames)
entry["cpu"] = stats(cpu)
entry["slowest_spans"] = spikes[:12]
entry["spike_lines"] = len(spikes)
entry["frame_ms"] = [round(v, 2) for v in frames]
result["scenarios"][name] = entry
print(f"rd{distance} {name}: " + ", ".join(f"{k} {v}" for k, v in entry.items()
if k not in ("cpu", "slowest_spans", "frame_ms")), flush=True)
finally:
try:
control("quit", timeout=60)
except (OSError, ValueError, RuntimeError):
pass
pb.stop(proc)
log.close()
sock.unlink(missing_ok=True)
shutil.rmtree(game_dir, ignore_errors=True)
if server:
server.stop()
shutil.rmtree(WORK / f"server-{args.label}-rd{distance}", ignore_errors=True)
def run(args, distance):
names = args.scenarios.split(",")
result = {"label": args.label, "distance": distance, "load_before": pb.load_average(), "scenarios": {}}
in_world = [n for n in names if n != "join"]
if in_world:
play(args, distance, in_world, result)
if "join" in names:
play(args, distance, ["join"], result)
result["load_after"] = pb.load_average()
return result
COLUMNS = [("p50_ms", "p50"), ("p99_ms", "p99"), ("p999_ms", "p99.9"), ("max_ms", "max"),
("low_1pct_fps", "1% low"), ("low_01pct_fps", "0.1% low"), ("over_33ms", ">33 ms")]
+2 -2
View File
@@ -1,7 +1,7 @@
PlayerHeadItem.getName missing minor: a head whose profile has a name should read "<name>'s Head"; Horizon shows the plain item name
PlayerHeadItem.getName port crates/horizon-client/src/item_tooltip.rs::player_head_name name_line titles a head whose profile has a name through block.minecraft.player_head.named ("%s's Head")
ShearsItem.createToolProperties skip:data tool rules (cobweb speed, shears breaking-speed tags) come from item data; items.rs reads minecraft:tool
ShearsItem.mineBlock port crates/horizon-client/src/game.rs::Game::mine_block_with_held shears wear one damage_per_block on any block but fire
ShearsItem.useOn missing unsure: SHEARS_TRIM is a NeoForge ability that no block in the pinned sources registers; pumpkin carving is a separate path (game/pumpkin.rs)
ShearsItem.useOn port crates/horizon-client/src/game/growth.rs::Game::shears_crop shears crop a growing plant head to age 25 with block.growing_plant.crop, a block_change and one wear; pumpkin carving is PumpkinBlock's own path (game/pumpkin.rs)
SnowballItem.asProjectile port crates/horizon-client/src/game/dispenser.rs::Game::shoot_projectile_item a dispensed snowball or egg is shot with the dispenser's own config
SnowballItem.use port crates/horizon-client/src/game/creatures.rs::Game::throw_item throw at 1.5 power, throw sound, use cooldown, one consumed
SplashPotionItem.createPotion port crates/horizon-client/src/game/potions.rs::Game::launch_thrown the thrown splash potion entity is launched with the stack
Can't render this file because it contains an unexpected character in line 1 and column 84.
+2 -2
View File
@@ -15,8 +15,8 @@ WorldSelectionList.NoWorldsEntry.extractContent skip:service empty entry of the
WorldSelectionList.WorldListEntry.<init> port crates/horizon-client/src/title.rs::Title::draw gap: minor: no hover tooltip on overflowing name, folder or info text; the name, folder and date, and mode and version lines are drawn
WorldSelectionList.WorldListEntry.validateIconFile port crates/horizon-client/src/title.rs::Title::icon gap: minor: no symlink check; an icon that is not a decodable 64x64 image shows the unknown-server icon
WorldSelectionList.WorldListEntry.getNarration skip:ui narration text only; Horizon has no narrator output
WorldSelectionList.WorldListEntry.keyPressed missing player: Enter or Space on a selected world does nothing (title Enter only confirms a rename), so keyboard players cannot join from the list
WorldSelectionList.WorldListEntry.canInteract missing player: vanilla greys out locked and incompatible worlds and will not open them; Horizon's list_worlds has no lock or compatibility check, so Play opens every listed world
WorldSelectionList.WorldListEntry.keyPressed port crates/horizon-client/src/title.rs::Title::join_selected the selection key joins the selected world as a click does; Enter and Space reach it when the list has focus
WorldSelectionList.WorldListEntry.canInteract port crates/horizon-client/src/title.rs::WorldEntry::can_play LevelSummary.primaryActionActive: a locked or incompatible world does not join from a click or a key, and its icon shows the error mark instead of the join arrow
WorldSelectionList.WorldListEntry.joinWorld port crates/horizon-client/src/title.rs::Title::click_action gap: minor: no warning for a symlinked world before it opens
WorldSelectionList.WorldListEntry.deleteWorld port crates/horizon-client/src/title.rs::Title::click_action gap: minor: no progress screen while the world is deleted
WorldSelectionList.WorldListEntry.doDeleteWorld port crates/horizon-client/src/title.rs::Title::confirm_delete gap: minor: a failed delete shows the error screen, not the world delete failure toast
1 WorldSelectionList.pollLevelsIgnoreErrors inline crates/horizon-client/src/title.rs::list_worlds the list is read at once, so there is no pending state; a world whose level.dat fails to read is skipped; the upload filter is Realms only, not ported
15 WorldSelectionList.WorldListEntry.<init> port crates/horizon-client/src/title.rs::Title::draw gap: minor: no hover tooltip on overflowing name, folder or info text; the name, folder and date, and mode and version lines are drawn
16 WorldSelectionList.WorldListEntry.validateIconFile port crates/horizon-client/src/title.rs::Title::icon gap: minor: no symlink check; an icon that is not a decodable 64x64 image shows the unknown-server icon
17 WorldSelectionList.WorldListEntry.getNarration skip:ui narration text only; Horizon has no narrator output
18 WorldSelectionList.WorldListEntry.keyPressed missing port crates/horizon-client/src/title.rs::Title::join_selected player: Enter or Space on a selected world does nothing (title Enter only confirms a rename), so keyboard players cannot join from the list the selection key joins the selected world as a click does; Enter and Space reach it when the list has focus
19 WorldSelectionList.WorldListEntry.canInteract missing port crates/horizon-client/src/title.rs::WorldEntry::can_play player: vanilla greys out locked and incompatible worlds and will not open them; Horizon's list_worlds has no lock or compatibility check, so Play opens every listed world LevelSummary.primaryActionActive: a locked or incompatible world does not join from a click or a key, and its icon shows the error mark instead of the join arrow
20 WorldSelectionList.WorldListEntry.joinWorld port crates/horizon-client/src/title.rs::Title::click_action gap: minor: no warning for a symlinked world before it opens
21 WorldSelectionList.WorldListEntry.deleteWorld port crates/horizon-client/src/title.rs::Title::click_action gap: minor: no progress screen while the world is deleted
22 WorldSelectionList.WorldListEntry.doDeleteWorld port crates/horizon-client/src/title.rs::Title::confirm_delete gap: minor: a failed delete shows the error screen, not the world delete failure toast
+3 -3
View File
@@ -23,7 +23,7 @@ MobEffectInstance.onMobHurt inline crates/horizon-client/src/game/potions.rs::Ga
MobEffectInstance.toString skip:debug debug string
MobEffectInstance.equals skip:java value equality
MobEffectInstance.hashCode skip:java value hash
MobEffectInstance.BlendState.setImmediate missing minor: skipBlending is never used, so nausea and darkness restored on login or a dimension change fade in from 0 instead of starting full
MobEffectInstance.BlendState.setImmediate port crates/horizon-world/src/status_effect.rs::EffectInstance::skip_blending factor and previous frame jump to 1 or 0 at once; the restore on login or a dimension change never calls it, so a restored nausea or darkness still fades in from 0
MobEffectInstance.BlendState.getFactor port crates/horizon-client/src/game/fov.rs::Game::nausea_blend lerp of the previous and current factor; darkness has its own in sculk.rs::Game::darkness_blend
MobEffectInstance.Details.create skip:java record constructor for the codec
MobEffectUtil.hasDigSpeed inline crates/horizon-client-render/src/effects.rs::PlayerEffects::mining_speed_factor haste or conduit power, also read for the arm swing duration in first_person.rs
@@ -70,7 +70,7 @@ Display.createTransformation port crates/horizon-client/src/text_display.rs::Tex
Display.tick port crates/horizon-client/src/game/text_displays.rs::RemoteTextDisplay::tick start tick, interpolation duration and render state refresh; text displays only
Display.defineSynchedData port crates/horizon-client/src/text_display.rs::TextDisplayData::default the defaults of entries 8 to 22
Display.readAdditionalSaveData port crates/horizon-client/src/text_display.rs::display_data the synched data entries (ids 8 to 22) of a saved display, matrix transformation included
Display.setTransformation missing minor: server side setter; there is no local display entity
Display.setTransformation port crates/horizon-client/src/game/native_entities.rs::Game::native_set_display a display Horizon keeps as a saved tag (block_display, item_display, text_display) eases from what it showed: the transformation and its interpolation and teleport durations, written for the mod API (native_set_display); nothing writes the brightness, billboard or shadow values back
Display.addAdditionalSaveData missing minor: no local display entity to save
Display.getInterpolatedBoundingBox port crates/horizon-client/src/text_display.rs::culling_box width-centred XZ box from y to y+height at the interpolated origin; used by Game::display_in_view
Display.setBrightnessOverride missing minor: server side setter; there is no local display entity
@@ -85,7 +85,7 @@ Display.BlockDisplay home crates/horizon-client/src/game/text_displays.rs a bloc
Display.ItemDisplay home crates/horizon-client/src/game/text_displays.rs an item display submits its item model with the display context (Game::block_item_display_geometry)
Display.TextDisplay.defineSynchedData port crates/horizon-client/src/text_display.rs::TextDisplayData::default text, line width 200, background 0x40000000, opacity -1, flags 0
Display.TextDisplay.onSyncedDataUpdated port crates/horizon-client/src/game/text_displays.rs::RemoteTextDisplay::metadata the text render state ids mark the render state dirty
Display.TextDisplay.loadFlag missing minor: reads the NBT booleans into the style flags; no local display entity
Display.TextDisplay.loadFlag inline crates/horizon-client/src/text_display.rs::text_display_data each NBT boolean (shadow, see_through, default_background) ORs its bit into the flags, then the alignment
Display.TextDisplay.readAdditionalSaveData port crates/horizon-client/src/text_display.rs::text_display_data a saved text display's synched data (ids 23 to 27) is read back from its compound and re-sent
Display.TextDisplay.storeFlag missing minor: writes the style flags to NBT; no local display entity
Display.TextDisplay.addAdditionalSaveData missing minor: no local display entity to save
1 DamageSources.source port crates/horizon-client/src/mobs.rs::Damage::of a damage source is a DamageKind plus attacker and origin; type ids are resolved in creatures.rs::damage_type
23 MobEffectInstance.toString skip:debug debug string
24 MobEffectInstance.equals skip:java value equality
25 MobEffectInstance.hashCode skip:java value hash
26 MobEffectInstance.BlendState.setImmediate missing port crates/horizon-world/src/status_effect.rs::EffectInstance::skip_blending minor: skipBlending is never used, so nausea and darkness restored on login or a dimension change fade in from 0 instead of starting full factor and previous frame jump to 1 or 0 at once; the restore on login or a dimension change never calls it, so a restored nausea or darkness still fades in from 0
27 MobEffectInstance.BlendState.getFactor port crates/horizon-client/src/game/fov.rs::Game::nausea_blend lerp of the previous and current factor; darkness has its own in sculk.rs::Game::darkness_blend
28 MobEffectInstance.Details.create skip:java record constructor for the codec
29 MobEffectUtil.hasDigSpeed inline crates/horizon-client-render/src/effects.rs::PlayerEffects::mining_speed_factor haste or conduit power, also read for the arm swing duration in first_person.rs
70 Display.tick port crates/horizon-client/src/game/text_displays.rs::RemoteTextDisplay::tick start tick, interpolation duration and render state refresh; text displays only
71 Display.defineSynchedData port crates/horizon-client/src/text_display.rs::TextDisplayData::default the defaults of entries 8 to 22
72 Display.readAdditionalSaveData port crates/horizon-client/src/text_display.rs::display_data the synched data entries (ids 8 to 22) of a saved display, matrix transformation included
73 Display.setTransformation missing port crates/horizon-client/src/game/native_entities.rs::Game::native_set_display minor: server side setter; there is no local display entity a display Horizon keeps as a saved tag (block_display, item_display, text_display) eases from what it showed: the transformation and its interpolation and teleport durations, written for the mod API (native_set_display); nothing writes the brightness, billboard or shadow values back
74 Display.addAdditionalSaveData missing minor: no local display entity to save
75 Display.getInterpolatedBoundingBox port crates/horizon-client/src/text_display.rs::culling_box width-centred XZ box from y to y+height at the interpolated origin; used by Game::display_in_view
76 Display.setBrightnessOverride missing minor: server side setter; there is no local display entity
85 Display.ItemDisplay home crates/horizon-client/src/game/text_displays.rs an item display submits its item model with the display context (Game::block_item_display_geometry)
86 Display.TextDisplay.defineSynchedData port crates/horizon-client/src/text_display.rs::TextDisplayData::default text, line width 200, background 0x40000000, opacity -1, flags 0
87 Display.TextDisplay.onSyncedDataUpdated port crates/horizon-client/src/game/text_displays.rs::RemoteTextDisplay::metadata the text render state ids mark the render state dirty
88 Display.TextDisplay.loadFlag missing inline crates/horizon-client/src/text_display.rs::text_display_data minor: reads the NBT booleans into the style flags; no local display entity each NBT boolean (shadow, see_through, default_background) ORs its bit into the flags, then the alignment
89 Display.TextDisplay.readAdditionalSaveData port crates/horizon-client/src/text_display.rs::text_display_data a saved text display's synched data (ids 23 to 27) is read back from its compound and re-sent
90 Display.TextDisplay.storeFlag missing minor: writes the style flags to NBT; no local display entity
91 Display.TextDisplay.addAdditionalSaveData missing minor: no local display entity to save
+14 -14
View File
@@ -5,7 +5,7 @@ ItemFrame.shouldDamageDropItem inline crates/horizon-client/src/game/explosion.r
ItemFrame.canHurtWhenFixed port crates/horizon-client/src/game/frames.rs::attack_frame a fixed frame refuses a non-creative hit (attack_ticks reset, no break) and lets a creative player break it
ItemFrame.hurtClient inline crates/horizon-client/src/game/frames.rs::Game::attack_frame gap: minor: no separate client-side fixed-frame gate; the creative exception is missing
ItemFrame.removeFramedMap inline crates/horizon-client/src/game/maps.rs::Game::lan_map_updates frame map markers come from item_frames each tick, so a removed frame needs no step
ItemFrame.getSlot missing minor: no slot 0 access for item frames in the item command path
ItemFrame.getSlot inline crates/horizon-client/src/game/commands/items.rs::Game::slot_get slots_named maps container.0 and contents on a frame to Slot::Frame; slot_get reads the frame's item and slot_set writes it
ItemFrame.onSyncedDataUpdated skip:other network data-sync hook; the item is read from the frame field on demand
ItemFrame.setRotation inline crates/horizon-client/src/game/frames.rs::Game::interact_frame the rotate-by-one step; the neighbour update is in frame_output_changed
ItemFrame.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::item_frame_from_nbt gap: minor: Facing defaults to 2 where vanilla defaults to DOWN
@@ -17,7 +17,7 @@ LeashFenceKnotEntity.getKnot inline crates/horizon-client/src/game/leads.rs::Gam
LeashFenceKnotEntity.createKnot inline crates/horizon-client/src/game/leads.rs::Game::interact_knot no in-game knot entity is created; LAN knot ids are assigned in lan_assign_ids
Painting.defineSynchedData skip:other entity data default variant; Horizon picks the variant in place_painting
Painting.get port crates/horizon-client/src/game/paintings.rs::PaintingVariants::get lookup of the variant by id; there is no component getter
Painting.applyImplicitComponent missing minor: a painting variant component on the item is ignored; placement always picks a random fitting variant
Painting.applyImplicitComponent inline crates/horizon-client/src/game/paintings.rs::Game::place_painting the stack's painting/variant replaces the random pick (PaintingVariants::of_stack), and that variant has to survive where it goes
Painting.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::painting_from_nbt
Painting.dropItem port crates/horizon-client/src/game/paintings.rs::Game::break_painting gap: minor: the dropped painting item keeps no custom name
PaintingVariants.register skip:data registry bootstrap; variants load from the data files in PaintingVariants::load
@@ -27,7 +27,7 @@ FallingBlockEntity.addAdditionalSaveData port crates/horizon-client/src/entity_n
FallingBlockEntity.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::falling_block_from_nbt gap: minor: TileEntityData is not read; damage settings come from falling_block::Damage::from_nbt
FallingBlockEntity.fillCrashReportCategory skip:debug crash report detail
FallingBlockEntity.getTypeName skip:data - the anvil death text is its damage type's message id (death.attack.anvil), which takes no block-name argument; game/falling_damage.rs picks falling_anvil, falling_stalactite or falling_block
FallingBlockEntity.teleport missing unsure: no portal travel for falling blocks found in nether_portal.rs and no end-arrival tick
FallingBlockEntity.teleport inline crates/horizon-client/src/game.rs::Game::fall_step the portal move itself is falling_through_portal; the End-crossing duplicate tick (`forceTickAfterTeleportToDuplicate`) is in fall_step
ItemEntity.restoreFrom skip:other the ItemEntity value moves between levels with thrower kept; nothing to copy
ItemEntity.setFluidMovement inline crates/horizon-client/src/entities.rs::ItemEntity::tick water and lava drag and buoyancy are applied in tick
ItemEntity.fireImmune inline crates/horizon-client/src/entities.rs::ItemEntity::environment_tick fire immunity via resists_fire in environment_tick
@@ -40,7 +40,7 @@ ItemEntity.teleport inline crates/horizon-client/src/entities.rs::merge_with_nei
ItemEntity.getVisualRotationYInDegrees inline crates/horizon-client/src/game/item_draw.rs::Game::dropped_item_geometry spin is applied in radians there; no degree conversion
ItemEntity.getSlot missing unsure: no slot 0 access for dropped items found in commands.rs
PrimedTnt.defineSynchedData skip:other fuse and block state are struct fields; spawn sets fuse 80; the block is always TNT
PrimedTnt.explode inline crates/horizon-client/src/game/tnt.rs::Game::tick_tnt gap: minor: power fixed at 4; no explosion_power and no used-portal damage calculator
PrimedTnt.explode inline crates/horizon-client/src/game/tnt.rs::Game::tick_tnt the power is the tnt's own field (`explosion_power`, 4 by default); gap: minor: no used-portal damage calculator
PrimedTnt.addAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::tnt_to_nbt the fuse and the block state are saved
PrimedTnt.restoreFrom missing minor: PrimedTnt has no owner field, so the igniter is not kept through portal moves
PrimedTnt.teleport missing unsure: no used-portal flag on PrimedTnt; the portal damage calculator is not applied
@@ -53,7 +53,7 @@ Creeper.getMaxFallDistance port crates/horizon-client/src/mobs/nav.rs::comfortab
Creeper.defineSynchedData skip:other swell, powered and ignited are Mob fields
Creeper.addAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs::mob_to_nbt gap: minor: Fuse and ExplosionRadius are written as constants 30 and 3, not the creeper's values
Creeper.thunderHit port crates/horizon-client/src/game/lightning.rs::Game::thunder_hit
Creeper.spawnLingeringCloud port crates/horizon-client/src/mobs/hostile.rs::explode MobEvent::LingeringCloud after the Explode: radius 2.5, wait 10, 300 ticks, -radius/duration a tick, the creeper's effects at 0.25 of their duration with their ambient and visible flags (game/potions.rs::spawn_creeper_cloud); a mob keeps ambient_effects now (NBT, /effect, expiry, conversion, the synchronized particles)
Creeper.spawnLingeringCloud port crates/horizon-client/src/mobs/hostile.rs::creeper_tick MobEvent::LingeringCloud after the Explode: radius 2.5, wait 10, 300 ticks, -radius/duration a tick, the creeper's effects at 0.25 of their duration with their ambient and visible flags (game/potions.rs::spawn_creeper_cloud); a mob keeps ambient_effects now (NBT, /effect, expiry, conversion, the synchronized particles)
ElderGuardian.createAttributes skip:data attribute defaults come from data tables
Enderman.registerGoals port crates/horizon-client/src/mobs/enderman.rs::register gap: minor: no anger-reset target goal (priority 4)
Enderman.createAttributes skip:data attribute defaults come from data tables
@@ -61,14 +61,14 @@ Enderman.setTarget inline crates/horizon-client/src/mobs/enderman.rs::custom_ser
Enderman.defineSynchedData skip:other carried block, creepy and stared-at state are Mob extra fields
Enderman.playStareSound inline crates/horizon-client/src/mobs/enderman.rs::LookForPlayer::tick gap: minor: the 400-tick throttle of the stare sound is not seen
Enderman.onSyncedDataUpdated skip:other client sync hook; the stare sound is played server-side from LookForPlayer
Enderman.addAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs:976-985 the carried block is written as carriedBlockState and removed when there is none
Enderman.readAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs the same state round-trips through the registry
Enderman.addAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_to_nbt the carried block is written as carriedBlockState and removed when there is none
Enderman.readAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs::mob_from_nbt the same state round-trips through the registry
Enderman.canRandomlyTeleportTo port crates/horizon-client/src/mobs/enderman.rs::teleport_to the water check on the ground block
Enderman.repeatedlyTryToTeleport port crates/horizon-client/src/mobs/enderman.rs::ai_step pending teleports retry up to 64 times
Enderman.dropCustomDeathLoot port crates/horizon-client/src/mobs/enderman.rs::carried_block_drops the carried block's drops with a silk-touch diamond axe (carried_block_tool), drawn from the block table's random sequence (game/creatures.rs, the death handling)
Enderman.hurtWithCleanWater inline crates/horizon-client/src/game/splash_water.rs::Game::affect_entities_around
Enderman.EndermanLeaveBlockGoal.canPlaceBlock inline crates/horizon-client/src/mobs/enderman.rs::LeaveBlock::tick gap: minor: the place-on blacklist tag and the carried block canSurvive check are not in the tick
Endermite.registerGoals port crates/horizon-client/src/mobs/ai.rs::register Hostile family defaults; gap: unsure: the ClimbOnTopOfPowderSnow goal is not checked
Endermite.registerGoals port crates/horizon-client/src/mobs/ai.rs::register Hostile family defaults; Float and ClimbOnTopOfPowderSnow at 1 are in mobs/hostile.rs
Endermite.createAttributes skip:data attribute defaults come from data tables
Endermite.checkEndermiteSpawnRules port crates/horizon-client/src/mobs/spawn_rules.rs::check_spawn_rules the Endermite and Silverfish arm
Ghast.registerGoals port crates/horizon-client/src/mobs/nether.rs::register_ghast
@@ -87,18 +87,18 @@ Guardian.getActiveAttackTarget inline crates/horizon-client/src/mobs/guardian.rs
Guardian.checkGuardianSpawnRules port crates/horizon-client/src/mobs/spawn_rules.rs::check_spawn_rules the Guardian and ElderGuardian arm
Guardian.GuardianAttackGoal.canContinueToUse port crates/horizon-client/src/mobs/guardian.rs::GuardianAttack::can_continue
Guardian.GuardianAttackSelector.test inline crates/horizon-client/src/mobs/guardian.rs::register the selector is the player, squid and axolotl beyond 9 filters in register
Monster.updateNoActionTime port crates/horizon-client/src/mobs/ai.rs: no_action_time += 1 at 1556, reset at 1842/2258 the idle counter is kept and read by the despawn path in raids.rs
Monster.updateNoActionTime port crates/horizon-client/src/mobs/ai.rs::server_ai_step the idle counter is counted up there and read by the despawn path in raids.rs
Monster.getProjectile missing unsure: no held-ammo choice for mobs found in mobs/*.rs
PatrollingMonster.addAdditionalSaveData port crates/horizon-client/src/mobs/raider.rs::write_nbt
PatrollingMonster.readAdditionalSaveData port crates/horizon-client/src/mobs/raider.rs::read_nbt
PatrollingMonster.checkPatrollingMonsterSpawnRules port crates/horizon-client/src/mobs/spawn_rules.rs::check_spawn_rules the Pillager arm: block light 8 or less, then mob rules
PatrollingMonster.LongDistancePatrolGoal.findPatrolCompanions port crates/horizon-client/src/mobs/raider.rs::companions
Phantom.registerGoals port crates/horizon-client/src/mobs/phantom.rs::register
Phantom.updatePhantomSizeInfo missing minor: vanilla sets size only from NBT, and Horizon has no size, so no scale or attack bonus
Phantom.updatePhantomSizeInfo port crates/horizon-client/src/mobs/phantom.rs::set_size the size clamps to 0..64, sets the attack damage base to 6 plus the size, and the box is scaled by it (mobs/living.rs::Mob::dims)
Phantom.finalizeSpawn inline crates/horizon-client/src/mobs/phantom.rs::register the anchor 5 blocks above is set at creation; size 0
Phantom.readAdditionalSaveData missing unsure: anchor_pos and size are not read for phantoms
Phantom.addAdditionalSaveData missing unsure: anchor_pos and size are not written for phantoms
Phantom.getDefaultDimensions missing minor: no size scale on the phantom box; size is 0 unless NBT sets it
Phantom.readAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs::mob_from_nbt size is read as setPhantomSize; gap: minor: anchor_pos is not read
Phantom.addAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs::mob_to_nbt size is written; gap: minor: anchor_pos is not written
Phantom.getDefaultDimensions inline crates/horizon-client/src/mobs/living.rs::Mob::dims the box, height and eye height scale by 1 + 0.15 per size
Phantom.PhantomBodyRotationControl.clientTick inline crates/horizon-client/src/mobs/phantom.rs::move_control the head yaw follows the body yaw there
Phantom.PhantomMoveTargetGoal.touchingTarget inline crates/horizon-client/src/mobs/phantom.rs::CircleAroundAnchor::tick the squared distance under 4 check near the move target
Phantom.PhantomSweepAttackGoal.canContinueToUse port crates/horizon-client/src/mobs/phantom.rs::SweepAttack::can_continue the cat hiss and scare check
@@ -135,5 +135,5 @@ Shulker.ShulkerNearestAttackGoal.getTargetSearchArea inline crates/horizon-clien
Shulker.ShulkerPeekGoal.canContinueToUse port crates/horizon-client/src/mobs/shulker.rs::Peek::can_continue
Silverfish.registerGoals port crates/horizon-client/src/mobs/hostile.rs::register
Silverfish.createAttributes skip:data attribute defaults come from data tables
Silverfish.getWalkTargetValue missing minor: no infested-host path value (10) for silverfish in ai.rs walk_target_value
Silverfish.getWalkTargetValue inline crates/horizon-client/src/mobs/ai.rs::walk_target_value a block standing on an infested host is worth 10, else the monster light value (with its own test)
Silverfish.checkSilverfishSpawnRules port crates/horizon-client/src/mobs/spawn_rules.rs::check_spawn_rules the Endermite and Silverfish arm
1 ItemFrame.defineSynchedData skip:other entity data defaults; ItemFrame starts with item None and rotation 0 in place_item_frame
5 ItemFrame.canHurtWhenFixed port crates/horizon-client/src/game/frames.rs::attack_frame a fixed frame refuses a non-creative hit (attack_ticks reset, no break) and lets a creative player break it
6 ItemFrame.hurtClient inline crates/horizon-client/src/game/frames.rs::Game::attack_frame gap: minor: no separate client-side fixed-frame gate; the creative exception is missing
7 ItemFrame.removeFramedMap inline crates/horizon-client/src/game/maps.rs::Game::lan_map_updates frame map markers come from item_frames each tick, so a removed frame needs no step
8 ItemFrame.getSlot missing inline crates/horizon-client/src/game/commands/items.rs::Game::slot_get minor: no slot 0 access for item frames in the item command path slots_named maps container.0 and contents on a frame to Slot::Frame; slot_get reads the frame's item and slot_set writes it
9 ItemFrame.onSyncedDataUpdated skip:other network data-sync hook; the item is read from the frame field on demand
10 ItemFrame.setRotation inline crates/horizon-client/src/game/frames.rs::Game::interact_frame the rotate-by-one step; the neighbour update is in frame_output_changed
11 ItemFrame.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::item_frame_from_nbt gap: minor: Facing defaults to 2 where vanilla defaults to DOWN
17 LeashFenceKnotEntity.createKnot inline crates/horizon-client/src/game/leads.rs::Game::interact_knot no in-game knot entity is created; LAN knot ids are assigned in lan_assign_ids
18 Painting.defineSynchedData skip:other entity data default variant; Horizon picks the variant in place_painting
19 Painting.get port crates/horizon-client/src/game/paintings.rs::PaintingVariants::get lookup of the variant by id; there is no component getter
20 Painting.applyImplicitComponent missing inline crates/horizon-client/src/game/paintings.rs::Game::place_painting minor: a painting variant component on the item is ignored; placement always picks a random fitting variant the stack's painting/variant replaces the random pick (PaintingVariants::of_stack), and that variant has to survive where it goes
21 Painting.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::painting_from_nbt
22 Painting.dropItem port crates/horizon-client/src/game/paintings.rs::Game::break_painting gap: minor: the dropped painting item keeps no custom name
23 PaintingVariants.register skip:data registry bootstrap; variants load from the data files in PaintingVariants::load
27 FallingBlockEntity.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::falling_block_from_nbt gap: minor: TileEntityData is not read; damage settings come from falling_block::Damage::from_nbt
28 FallingBlockEntity.fillCrashReportCategory skip:debug crash report detail
29 FallingBlockEntity.getTypeName skip:data - the anvil death text is its damage type's message id (death.attack.anvil), which takes no block-name argument; game/falling_damage.rs picks falling_anvil, falling_stalactite or falling_block
30 FallingBlockEntity.teleport missing inline crates/horizon-client/src/game.rs::Game::fall_step unsure: no portal travel for falling blocks found in nether_portal.rs and no end-arrival tick the portal move itself is falling_through_portal; the End-crossing duplicate tick (`forceTickAfterTeleportToDuplicate`) is in fall_step
31 ItemEntity.restoreFrom skip:other the ItemEntity value moves between levels with thrower kept; nothing to copy
32 ItemEntity.setFluidMovement inline crates/horizon-client/src/entities.rs::ItemEntity::tick water and lava drag and buoyancy are applied in tick
33 ItemEntity.fireImmune inline crates/horizon-client/src/entities.rs::ItemEntity::environment_tick fire immunity via resists_fire in environment_tick
40 ItemEntity.getVisualRotationYInDegrees inline crates/horizon-client/src/game/item_draw.rs::Game::dropped_item_geometry spin is applied in radians there; no degree conversion
41 ItemEntity.getSlot missing unsure: no slot 0 access for dropped items found in commands.rs
42 PrimedTnt.defineSynchedData skip:other fuse and block state are struct fields; spawn sets fuse 80; the block is always TNT
43 PrimedTnt.explode inline crates/horizon-client/src/game/tnt.rs::Game::tick_tnt gap: minor: power fixed at 4; no explosion_power and no used-portal damage calculator the power is the tnt's own field (`explosion_power`, 4 by default); gap: minor: no used-portal damage calculator
44 PrimedTnt.addAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::tnt_to_nbt the fuse and the block state are saved
45 PrimedTnt.restoreFrom missing minor: PrimedTnt has no owner field, so the igniter is not kept through portal moves
46 PrimedTnt.teleport missing unsure: no used-portal flag on PrimedTnt; the portal damage calculator is not applied
53 Creeper.defineSynchedData skip:other swell, powered and ignited are Mob fields
54 Creeper.addAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs::mob_to_nbt gap: minor: Fuse and ExplosionRadius are written as constants 30 and 3, not the creeper's values
55 Creeper.thunderHit port crates/horizon-client/src/game/lightning.rs::Game::thunder_hit
56 Creeper.spawnLingeringCloud port crates/horizon-client/src/mobs/hostile.rs::explode crates/horizon-client/src/mobs/hostile.rs::creeper_tick MobEvent::LingeringCloud after the Explode: radius 2.5, wait 10, 300 ticks, -radius/duration a tick, the creeper's effects at 0.25 of their duration with their ambient and visible flags (game/potions.rs::spawn_creeper_cloud); a mob keeps ambient_effects now (NBT, /effect, expiry, conversion, the synchronized particles)
57 ElderGuardian.createAttributes skip:data attribute defaults come from data tables
58 Enderman.registerGoals port crates/horizon-client/src/mobs/enderman.rs::register gap: minor: no anger-reset target goal (priority 4)
59 Enderman.createAttributes skip:data attribute defaults come from data tables
61 Enderman.defineSynchedData skip:other carried block, creepy and stared-at state are Mob extra fields
62 Enderman.playStareSound inline crates/horizon-client/src/mobs/enderman.rs::LookForPlayer::tick gap: minor: the 400-tick throttle of the stare sound is not seen
63 Enderman.onSyncedDataUpdated skip:other client sync hook; the stare sound is played server-side from LookForPlayer
64 Enderman.addAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs:976-985 crates/horizon-client/src/entity_nbt.rs::mob_to_nbt the carried block is written as carriedBlockState and removed when there is none
65 Enderman.readAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs crates/horizon-client/src/entity_nbt.rs::mob_from_nbt the same state round-trips through the registry
66 Enderman.canRandomlyTeleportTo port crates/horizon-client/src/mobs/enderman.rs::teleport_to the water check on the ground block
67 Enderman.repeatedlyTryToTeleport port crates/horizon-client/src/mobs/enderman.rs::ai_step pending teleports retry up to 64 times
68 Enderman.dropCustomDeathLoot port crates/horizon-client/src/mobs/enderman.rs::carried_block_drops the carried block's drops with a silk-touch diamond axe (carried_block_tool), drawn from the block table's random sequence (game/creatures.rs, the death handling)
69 Enderman.hurtWithCleanWater inline crates/horizon-client/src/game/splash_water.rs::Game::affect_entities_around
70 Enderman.EndermanLeaveBlockGoal.canPlaceBlock inline crates/horizon-client/src/mobs/enderman.rs::LeaveBlock::tick gap: minor: the place-on blacklist tag and the carried block canSurvive check are not in the tick
71 Endermite.registerGoals port crates/horizon-client/src/mobs/ai.rs::register Hostile family defaults; gap: unsure: the ClimbOnTopOfPowderSnow goal is not checked Hostile family defaults; Float and ClimbOnTopOfPowderSnow at 1 are in mobs/hostile.rs
72 Endermite.createAttributes skip:data attribute defaults come from data tables
73 Endermite.checkEndermiteSpawnRules port crates/horizon-client/src/mobs/spawn_rules.rs::check_spawn_rules the Endermite and Silverfish arm
74 Ghast.registerGoals port crates/horizon-client/src/mobs/nether.rs::register_ghast
87 Guardian.checkGuardianSpawnRules port crates/horizon-client/src/mobs/spawn_rules.rs::check_spawn_rules the Guardian and ElderGuardian arm
88 Guardian.GuardianAttackGoal.canContinueToUse port crates/horizon-client/src/mobs/guardian.rs::GuardianAttack::can_continue
89 Guardian.GuardianAttackSelector.test inline crates/horizon-client/src/mobs/guardian.rs::register the selector is the player, squid and axolotl beyond 9 filters in register
90 Monster.updateNoActionTime port crates/horizon-client/src/mobs/ai.rs: no_action_time += 1 at 1556, reset at 1842/2258 crates/horizon-client/src/mobs/ai.rs::server_ai_step the idle counter is kept and read by the despawn path in raids.rs the idle counter is counted up there and read by the despawn path in raids.rs
91 Monster.getProjectile missing unsure: no held-ammo choice for mobs found in mobs/*.rs
92 PatrollingMonster.addAdditionalSaveData port crates/horizon-client/src/mobs/raider.rs::write_nbt
93 PatrollingMonster.readAdditionalSaveData port crates/horizon-client/src/mobs/raider.rs::read_nbt
94 PatrollingMonster.checkPatrollingMonsterSpawnRules port crates/horizon-client/src/mobs/spawn_rules.rs::check_spawn_rules the Pillager arm: block light 8 or less, then mob rules
95 PatrollingMonster.LongDistancePatrolGoal.findPatrolCompanions port crates/horizon-client/src/mobs/raider.rs::companions
96 Phantom.registerGoals port crates/horizon-client/src/mobs/phantom.rs::register
97 Phantom.updatePhantomSizeInfo missing port crates/horizon-client/src/mobs/phantom.rs::set_size minor: vanilla sets size only from NBT, and Horizon has no size, so no scale or attack bonus the size clamps to 0..64, sets the attack damage base to 6 plus the size, and the box is scaled by it (mobs/living.rs::Mob::dims)
98 Phantom.finalizeSpawn inline crates/horizon-client/src/mobs/phantom.rs::register the anchor 5 blocks above is set at creation; size 0
99 Phantom.readAdditionalSaveData missing inline crates/horizon-client/src/entity_nbt.rs::mob_from_nbt unsure: anchor_pos and size are not read for phantoms size is read as setPhantomSize; gap: minor: anchor_pos is not read
100 Phantom.addAdditionalSaveData missing inline crates/horizon-client/src/entity_nbt.rs::mob_to_nbt unsure: anchor_pos and size are not written for phantoms size is written; gap: minor: anchor_pos is not written
101 Phantom.getDefaultDimensions missing inline crates/horizon-client/src/mobs/living.rs::Mob::dims minor: no size scale on the phantom box; size is 0 unless NBT sets it the box, height and eye height scale by 1 + 0.15 per size
102 Phantom.PhantomBodyRotationControl.clientTick inline crates/horizon-client/src/mobs/phantom.rs::move_control the head yaw follows the body yaw there
103 Phantom.PhantomMoveTargetGoal.touchingTarget inline crates/horizon-client/src/mobs/phantom.rs::CircleAroundAnchor::tick the squared distance under 4 check near the move target
104 Phantom.PhantomSweepAttackGoal.canContinueToUse port crates/horizon-client/src/mobs/phantom.rs::SweepAttack::can_continue the cat hiss and scare check
135 Shulker.ShulkerPeekGoal.canContinueToUse port crates/horizon-client/src/mobs/shulker.rs::Peek::can_continue
136 Silverfish.registerGoals port crates/horizon-client/src/mobs/hostile.rs::register
137 Silverfish.createAttributes skip:data attribute defaults come from data tables
138 Silverfish.getWalkTargetValue missing inline crates/horizon-client/src/mobs/ai.rs::walk_target_value minor: no infested-host path value (10) for silverfish in ai.rs walk_target_value a block standing on an infested host is worth 10, else the monster light value (with its own test)
139 Silverfish.checkSilverfishSpawnRules port crates/horizon-client/src/mobs/spawn_rules.rs::check_spawn_rules the Endermite and Silverfish arm