Merge remote-tracking branch 'origin/main' into fix/7080-entity-world-border

This commit is contained in:
Akirabane committed 2026-11-02 12:38:36 +01:00
commit 2453e2d2c0
42 files changed
+1881 -313

No files matched your search

@@ -648,6 +648,25 @@ mod border_movement_tests {
);
}
/// A minecart (0.98 wide, 0.7 tall) rolling east at its top speed of 0.4 a tick, 0.11
/// blocks short of the wall: the wall takes all but the 0.11 (`move_cart` hands the
/// cart's x and z to `with_border`, as every entity does in `Entity.move`).
#[test]
fn a_minecart_sized_body_stops_at_the_border_wall() {
// Centre x = 4.4, half width 0.49: the cart's east face is at 4.89, the wall at 5.0.
let bounds = Aabb::new(Vec3::new(3.91, 0.0, -0.49), Vec3::new(4.89, 0.7, 0.49));
let border = border();
let mut collisions = EntityCollisions::with_border(EmptyCollisions, &border, [4.4, 0.0]);
let moved = collisions
.resolve(bounds, Vec3::new(0.4, 0.0, 0.0), 0.0, false)
.expect("the movement resolves");
assert!(
(moved.x - 0.11).abs() < 1.0e-9,
"the cart was not stopped by the border: {moved:?}"
);
assert_eq!(moved.z, 0.0);
}
/// Away from the border, the same body moves the whole way.
#[test]
fn a_body_away_from_the_border_moves_whole() {
+10 -1
View File
@@ -110,7 +110,16 @@ pub(crate) fn prepare_with_models(
.state(state)
.ok_or("missing gallery registry state")?;
let position = BlockPos::new((index % 4) as i32 * 4 - 7, 4, (index / 4) as i32 * 4 + 14);
let selected = catalog.collect(state, position)?;
// A state no definition binds (a pack's partial blockstate file
// without vanilla's below it) is left out, as the mesher skips it,
// instead of failing the whole pack.
let selected = match catalog.collect(state, position) {
Ok(selected) => selected,
Err(e) => {
log::warn!("Horizon: gallery block {name} has no model: {e}");
continue;
}
};
states.push(json!({"block":actual.name,"properties":actual.properties,"state_id":state.0,
"position":[position.x,position.y,position.z],
"models":selected.iter().map(|(v,_)| json!({"model":v.model,"rotations":v.rotations,"uv_lock":v.uv_lock})).collect::<Vec<_>>()}));
@@ -105,6 +105,35 @@ fn bake_resolved(
cube,
})
}
/// The definitions a blockstate file holds, bottom first: the layers of a
/// file the pack overlay stacked (`packs::BLOCKSTATE_LAYERS`), else the
/// file itself.
fn definition_layers(text: &str) -> std::result::Result<Vec<String>, String> {
if !text.contains(crate::packs::BLOCKSTATE_LAYERS) {
return Ok(vec![text.to_owned()]);
}
let value: Value = serde_json::from_str(text).map_err(|e| e.to_string())?;
match value.get(crate::packs::BLOCKSTATE_LAYERS) {
Some(Value::Array(layers)) => Ok(layers.iter().map(Value::to_string).collect()),
_ => Ok(vec![text.to_owned()]),
}
}
/// Each state's model from definitions bound bottom first: the topmost one
/// that binds the state, as (its layer, its selection). A higher pack's
/// definition replaces only the states it binds (`putAll` in
/// `loadBlockStateDefinitionStack`).
fn topmost(layers: Vec<Vec<Option<SelectedModels>>>) -> Vec<Option<(usize, SelectedModels)>> {
let mut picked: Vec<Option<(usize, SelectedModels)>> = Vec::new();
for (layer, bound) in layers.into_iter().enumerate() {
picked.resize(picked.len().max(bound.len()), None);
for (slot, selected) in picked.iter_mut().zip(bound) {
if let Some(selected) = selected {
*slot = Some((layer, selected));
}
}
}
picked
}
impl StateModels {
pub fn load(
root: &Path,
@@ -194,21 +223,17 @@ impl StateModels {
.collect::<Result<_>>()?;
let possible: Vec<Properties> = serde_json::from_value(facts["states"].clone())?;
// Vanilla's BlockStateModelLoader: a definition that does not
// read or bind is logged and its block gets no model; the other
// blocks still load.
let loaded = horizon_core::prepared_files::read_to_string(
// read or bind is logged and left out; with none left the block
// gets no model, and the other blocks still load. A file several
// packs provide binds each pack's definition on its own, bottom
// first (`loadBlockStateDefinitionStack`).
let layers = match horizon_core::prepared_files::read_to_string(
&root.join(format!("assets/{namespace}/blockstates/{path}.json")),
)
.map_err(|e| e.to_string())
.and_then(|text| BlockStateDefinition::from_json(&text).map_err(|e| e.to_string()))
.and_then(|definition| {
let bound = definition
.bind(&schema, &possible)
.map_err(|e| e.to_string())?;
Ok((definition, bound))
});
let (definition, bound) = match loaded {
Ok(loaded) => loaded,
.and_then(|text| definition_layers(&text))
{
Ok(layers) => layers,
Err(e) => {
log::warn!(
"Horizon: blockstate definition of {namespace}:{path} not loaded: {e}"
@@ -216,6 +241,26 @@ impl StateModels {
continue;
}
};
let mut loaded = Vec::with_capacity(layers.len());
for text in &layers {
let layer = BlockStateDefinition::from_json(text)
.map_err(|e| e.to_string())
.and_then(|definition| {
let bound = definition
.bind(&schema, &possible)
.map_err(|e| e.to_string())?;
Ok((definition, bound))
});
match layer {
Ok(layer) => loaded.push(layer),
Err(e) => log::warn!(
"Horizon: blockstate definition of {namespace}:{path} not loaded: {e}"
),
}
}
if loaded.is_empty() {
continue;
}
let native_order: Vec<_> = by_name
.get(name.as_str())
.map(Vec::as_slice)
@@ -233,32 +278,36 @@ impl StateModels {
"model state definitions disagree with the imported block registry".into(),
);
}
for ((id, _), selected) in native_order.into_iter().zip(bound) {
if let Some(selected) = selected {
states[id.0 as usize] = Some((choices.len(), selected));
let (definitions, bound): (Vec<_>, Vec<_>) = loaded.into_iter().unzip();
let first = choices.len();
for ((id, _), picked) in native_order.into_iter().zip(topmost(bound)) {
if let Some((layer, selected)) = picked {
states[id.0 as usize] = Some((first + layer, selected));
}
}
for choice in definition
.variants
.iter()
.flatten()
.map(|(_, choice)| choice)
.chain(
definition
.multipart
.iter()
.flatten()
.map(|selector| &selector.variant),
)
{
match choice {
VariantSet::Single(v) => {
variants.insert(key(v));
for definition in definitions {
for choice in definition
.variants
.iter()
.flatten()
.map(|(_, choice)| choice)
.chain(
definition
.multipart
.iter()
.flatten()
.map(|selector| &selector.variant),
)
{
match choice {
VariantSet::Single(v) => {
variants.insert(key(v));
}
VariantSet::Weighted(v) => variants.extend(v.iter().map(|(v, _)| key(v))),
}
VariantSet::Weighted(v) => variants.extend(v.iter().map(|(v, _)| key(v))),
}
choices.push(CompiledChoices::new(&definition)?);
}
choices.push(CompiledChoices::new(&definition)?);
}
let names: Vec<String> = variants.iter().map(|(name, _, _)| name.clone()).collect();
let library = match models.block_library(root, names.clone()) {
@@ -428,6 +477,44 @@ mod tests {
use super::*;
use crate::model_library::ParsedModels;
/// #7013: a pack's blockstate file that defines only some states (here
/// a fence gate's `powered=true` ones) keeps vanilla's model for the
/// rest, as vanilla binds each pack's definition and lets a higher one
/// replace only what it binds.
#[test]
fn a_partial_pack_blockstate_keeps_vanilla_for_the_other_states() {
let schema: PropertySchema = BTreeMap::from([(
"powered".to_owned(),
PropertyValues::Named(BTreeSet::from(["false".to_owned(), "true".to_owned()])),
)]);
let states: Vec<Properties> = ["false", "true"]
.iter()
.map(|p| BTreeMap::from([("powered".to_owned(), (*p).to_owned())]))
.collect();
let stacked = serde_json::json!({ crate::packs::BLOCKSTATE_LAYERS: [
{"variants":{"":{"model":"minecraft:block/oak_fence_gate"}}},
{"variants":{"powered=true":{"model":"minecraft:block/stone"}}}
]})
.to_string();
let layers = definition_layers(&stacked).unwrap();
assert_eq!(layers.len(), 2);
let bound = layers
.iter()
.map(|text| {
BlockStateDefinition::from_json(text)
.unwrap()
.bind(&schema, &states)
.unwrap()
})
.collect();
let picked = topmost(bound);
assert_eq!(picked[0], Some((0, SelectedModels::Variant(0))));
assert_eq!(picked[1], Some((1, SelectedModels::Variant(0))));
// A file that isn't stacked is one definition.
let plain = r#"{"variants":{"":{"model":"minecraft:block/stone"}}}"#;
assert_eq!(definition_layers(plain).unwrap(), vec![plain.to_owned()]);
}
struct Fixture(std::path::PathBuf);
impl Fixture {
fn write(&self, name: &str, json: &str) {
+45 -1
View File
@@ -841,7 +841,14 @@ enum Merge {
Font,
/// `SpriteSourceList.load`: sources from the bottom pack up.
Atlas,
/// `BlockStateModelLoader.loadBlockStateDefinitionStack`: every pack's
/// definition binds on its own, and a higher one replaces only the states
/// it binds. Kept as its layers ([`BLOCKSTATE_LAYERS`]) for the catalog.
BlockStates,
}
/// The key of a stacked blockstate file: its definitions, bottom first, each
/// as the pack wrote it.
pub const BLOCKSTATE_LAYERS: &str = "horizon:layers";
fn merge_kind(name: &str) -> Option<Merge> {
let (_, rest) = name.strip_prefix("assets/")?.split_once('/')?;
if rest == "sounds.json" {
@@ -852,6 +859,7 @@ fn merge_kind(name: &str) -> Option<Merge> {
"lang" => Some(Merge::Lang),
"font" => Some(Merge::Font),
"atlases" => Some(Merge::Atlas),
"blockstates" => Some(Merge::BlockStates),
_ => None,
}
}
@@ -859,7 +867,7 @@ fn merge_kind(name: &str) -> Option<Merge> {
/// does. Layers that do not parse are skipped, as vanilla logs and skips them.
fn merge(kind: Merge, layers: &[Vec<u8>]) -> Option<Vec<u8>> {
use serde_json::{Map, Value};
let parsed: Vec<Map<String, Value>> = layers
let mut parsed: Vec<Map<String, Value>> = layers
.iter()
// SoundManager skips a sounds.json Gson rejects, whole.
.filter(|b| kind != Merge::Sounds || crate::sound_events::is_valid_file(b))
@@ -902,6 +910,12 @@ fn merge(kind: Merge, layers: &[Vec<u8>]) -> Option<Vec<u8>> {
}
Merge::Font => list("providers", &mut parsed.iter().rev()),
Merge::Atlas => list("sources", &mut parsed.iter()),
// One definition is the file as it is.
Merge::BlockStates if parsed.len() < 2 => Value::Object(parsed.pop()?),
Merge::BlockStates => Value::Object(Map::from_iter([(
BLOCKSTATE_LAYERS.to_owned(),
Value::Array(parsed.into_iter().map(Value::Object).collect()),
)])),
};
serde_json::to_vec(&out).ok()
}
@@ -1405,6 +1419,36 @@ mod tests {
&[json(r#"{"sources":[1]}"#), json(r#"{"sources":[2]}"#)],
);
assert_eq!(parse(atlas.unwrap()), serde_json::json!({"sources":[1,2]}));
// BlockStateModelLoader: every definition kept, bottom first, for the
// catalog to bind one by one; one that does not parse is skipped.
let gate = merge(
Merge::BlockStates,
&[
json(r#"{"variants":{"":{"model":"v"}}}"#),
b"not json".to_vec(),
json(r#"{"variants":{"powered=true":{"model":"p"}}}"#),
],
);
assert_eq!(
parse(gate.unwrap()),
serde_json::json!({BLOCKSTATE_LAYERS:[
{"variants":{"":{"model":"v"}}},
{"variants":{"powered=true":{"model":"p"}}}
]})
);
// A single definition stays the file as it is.
let alone = merge(
Merge::BlockStates,
&[json(r#"{"variants":{"":{"model":"p"}}}"#)],
);
assert_eq!(
parse(alone.unwrap()),
serde_json::json!({"variants":{"":{"model":"p"}}})
);
assert_eq!(
merge_kind("assets/minecraft/blockstates/oak_fence_gate.json"),
Some(Merge::BlockStates)
);
assert_eq!(
merge_kind("assets/minecraft/lang/en_us.json"),
Some(Merge::Lang)
@@ -12,7 +12,8 @@ use std::{collections::BTreeSet, fs, io, path::Path, path::PathBuf};
// Version 3 could omit untouched vanilla directories on Windows (#1649, #1688).
// Version 5 read every vanilla file to stamp it on Windows.
// Version 6 stamps vanilla's files by their metadata on Windows too.
const VERSION: u32 = 6;
// Version 7 stacks blockstate files (every pack's definition, bottom first).
const VERSION: u32 = 7;
#[derive(Default, Serialize, Deserialize)]
pub(super) struct Dependencies {
+42 -1
View File
@@ -1255,6 +1255,10 @@ pub struct ArmorStand {
pub no_base_plate: bool,
pub invisible: bool,
pub marker: bool,
/// `Entity.isNoGravity` (`NoGravity` in the save). `ArmorStand.hasPhysics`
/// is false with it, as with `Marker`: `travel` does nothing, so the stand
/// never moves, and the blocks it stands in do not touch it.
pub no_gravity: bool,
/// `CustomName`: the text of a hologram.
pub custom_name: Option<crate::text::Text>,
/// `CustomNameVisible`.
@@ -1344,6 +1348,7 @@ impl ArmorStand {
no_base_plate: false,
invisible: false,
marker: false,
no_gravity: false,
custom_name: None,
name_visible: false,
last_hit: i64::MIN / 2,
@@ -1429,7 +1434,9 @@ impl ArmorStand {
self.fire_ticks = self.fire_ticks.max(300);
}
let fire = crate::motion::environment::fire_contact(world, registry, self.bounds()) > 0.0;
if fire && !self.invisible && !self.marker {
// The fire block reaches a stand through `applyEffectsFromBlocks`,
// which a NoGravity stand does not feel (measured beside the server).
if fire && !self.invisible && !self.marker && !self.no_gravity {
if self.fire_ticks > 0 {
self.health -= 0.15;
} else {
@@ -1486,6 +1493,10 @@ impl ArmorStand {
if v.z.abs() < 0.003 { 0.0 } else { v.z },
);
self.velocity = wanted;
// ArmorStand.travel: no physics with NoGravity, the velocity stays.
if self.no_gravity {
return;
}
// `travelInFluid` reads `isFalling` before the move and any push;
// `travelInAir` reads ground friction before the move.
let falling = wanted.y <= 0.0;
@@ -2718,6 +2729,36 @@ mod armor_stand_motion_tests {
);
}
#[test]
fn a_no_gravity_stand_stays_put() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let collision = crate::collisions::load(&root, assets.registry).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let world = World::test_scene(&assets, &render, 3).unwrap();
let start = Vec3::new(20.5, 20.0, 20.5);
let mut stand = ArmorStand::new(1, start, 0.0);
stand.on_ground = false;
stand.no_gravity = true;
// ArmorStand.travel does nothing without physics: measured on the real
// server, Pos and Motion stay exactly as summoned for every tick.
for _ in 0..5 {
tick_stand(&mut stand, &world, &facts, &collision);
assert_eq!(stand.position, start);
assert_eq!(stand.velocity, Vec3::ZERO);
}
stand.no_gravity = false;
tick_stand(&mut stand, &world, &facts, &collision);
tick_stand(&mut stand, &world, &facts, &collision);
assert!(stand.position.y < start.y, "gravity again once cleared");
}
#[test]
fn leather_boots_walk_on_powder_snow() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
+33
View File
@@ -2919,6 +2919,12 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
} else if let Tag::Compound(map) = &mut tag {
map.remove(&"Marker".into());
}
// Entity.saveWithoutId writes NoGravity only when set.
if a.no_gravity {
put(&mut tag, "NoGravity", Tag::Byte(1));
} else if let Tag::Compound(map) = &mut tag {
map.remove(&"NoGravity".into());
}
if a.name_visible {
put(&mut tag, "CustomNameVisible", Tag::Byte(1));
} else if let Tag::Compound(map) = &mut tag {
@@ -2984,6 +2990,7 @@ pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
a.no_base_plate = flag(tag, "NoBasePlate");
a.invisible = flag(tag, "Invisible");
a.marker = flag(tag, "Marker");
a.no_gravity = flag(tag, "NoGravity");
a.on_ground = flag(tag, "OnGround");
// ArmorStand.readAdditionalSaveData: each part of `Pose` that is a list of
// three numbers, the others staying at their defaults.
@@ -3387,6 +3394,32 @@ mod tests {
assert!(restored.name_visible);
}
#[test]
fn armor_stand_keeps_no_gravity_through_a_save() {
let tag = Tag::compound([
("id", string("minecraft:armor_stand")),
(
"Pos",
Tag::List(vec![
Tag::Double(0.0f64.to_bits()),
Tag::Double(64.0f64.to_bits()),
Tag::Double(0.0f64.to_bits()),
]),
),
("NoGravity", Tag::Byte(1)),
]);
let mut a = armor_stand_from_nbt(&tag, 7).unwrap();
assert!(a.no_gravity, "summon NBT sets the flag");
let saved = armor_stand_to_nbt(&a, [0; 4], Some(&tag));
assert_eq!(saved.get("NoGravity"), Some(&Tag::Byte(1)));
assert!(armor_stand_from_nbt(&saved, 7).unwrap().no_gravity);
// `data merge entity @s {NoGravity:0b}` clears it again.
a.no_gravity = false;
let cleared = armor_stand_to_nbt(&a, [0; 4], Some(&saved));
assert!(cleared.get("NoGravity").is_none());
assert!(!armor_stand_from_nbt(&cleared, 7).unwrap().no_gravity);
}
#[test]
fn falling_block_round_trip_keeps_impact_distance_and_settings() {
let state = Tag::compound([("Name", string("minecraft:damaged_anvil"))]);
+27 -4
View File
@@ -7316,6 +7316,11 @@ impl Game<'_> {
horizon_client::spans::time("tick_item_frames", || self.tick_item_frames());
horizon_client::spans::time("tick_armor_stands", || self.tick_armor_stands())?;
horizon_client::spans::time("tick_cushions", || self.tick_cushions());
// ServerLevel.tick: the arrivals of this server tick are in the level
// (`addDuringTeleport`), and each vehicle handles its portal as its
// own tick opens.
self.land_portal_arrivals();
self.handle_vehicle_portals();
horizon_client::spans::time("tick_boats", || self.tick_boats());
horizon_client::spans::time("tick_carts", || self.tick_carts());
self.tick_vehicle_watch();
@@ -9688,11 +9693,13 @@ impl Game<'_> {
/// key over a slot swaps before the search box sees it.
fn search_tab_hotbar_key(&mut self, code: KeyCode) -> bool {
use horizon_client::creative::{Hover, search_key_swaps};
let hotbar_key = HOTBAR_DIGITS.contains(&code);
let numeric = hotbar_key || NUMERIC_KEYS.contains(&code);
let numeric = HOTBAR_DIGITS.contains(&code) || NUMERIC_KEYS.contains(&code);
if !numeric {
return false;
}
let Some(key) = self.settings.keys.container_hotbar_key(code) else {
return false;
};
let Some(size) = self
.renderer
.as_ref()
@@ -9714,8 +9721,7 @@ impl Game<'_> {
}
Some(_) => Hover::Slot,
};
search_key_swaps(numeric, hotbar_key, hover, self.carried.is_some())
&& self.inventory_hotkey(code)
search_key_swaps(numeric, true, hover, self.carried.is_some()) && self.inventory_hotkey(key)
}
/// Number keys, F and Q over a slot of the open menu: SWAP with a hotbar
/// slot or the off hand, THROW one or the whole stack.
@@ -17584,6 +17590,23 @@ impl Game<'_> {
self.title_shortcut(shortcut);
return Ok(());
}
// `TabNavigationBar.keyPressed` comes before the open
// tab's own widgets: Ctrl plus a digit or Tab picks a
// tab instead of reaching them.
if let PhysicalKey::Code(key) = event.physical_key {
let ctrl = self.has_control_down_with_quirk();
let shift = self.has_shift_down();
let tab = self
.title
.as_mut()
.and_then(|t| t.tab_bar_key(key, ctrl, shift));
if let Some(sound) = tab {
if sound {
self.audio.play_button_click();
}
return Ok(());
}
}
let (w, h) = self.gui_size();
match event.physical_key {
// `WorldListEntry.keyPressed`: Space joins too, with the button click sound.
+75 -135
View File
@@ -3,6 +3,9 @@
//! like a player (walk, look, break, place, use, click buttons). Actions that
//! take time run as a task advanced every frame and reply when they finish or
//! time out, so `horizon-ctl break 10 64 3` returns what the block dropped.
#[path = "agent_navigation.rs"]
mod navigation;
use super::event_loop::EventLoopCtl;
use super::{Game, Screen};
use horizon_client::{
@@ -17,6 +20,7 @@ use horizon_client::{
};
use horizon_core::geometry::Vec3;
use horizon_world::registry::BlockStateId;
use navigation::{Cell, Node, Plan, Progress};
use serde_json::{Value, json};
use std::{
collections::{BTreeMap, HashMap, HashSet},
@@ -206,9 +210,6 @@ fn delta(before: &BTreeMap<String, i64>, after: &BTreeMap<String, i64>) -> BTree
out
}
/// A feet position the A* path walks through.
type Node = [i32; 3];
enum TaskKind {
/// Hold keys until a deadline.
Move {
@@ -251,9 +252,11 @@ struct Goto {
target: [f64; 3],
near: f64,
sprint: bool,
path: Vec<Node>,
path: Plan,
blocked: Vec<(Node, Node)>,
progress: Progress,
next: usize,
/// Tick of the last waypoint reached, and how many replans so far.
/// Tick of the last waypoint progress, and stuck recoveries so far.
progress_tick: u64,
replans: u32,
jump_until: u64,
@@ -817,105 +820,24 @@ impl Game<'_> {
})
}
/// Whether a block stops movement, for the path finder.
fn blocks_motion(&self, pos: [i32; 3]) -> bool {
let f = self
.res
.facts()
.get(self.world.block(pos[0], pos[1], pos[2]));
f.full_collision || f.legacy_solid
}
fn walkable(&self, n: Node) -> bool {
let below = self.world.block(n[0], n[1] - 1, n[2]);
let lava = |p: [i32; 3]| {
&*self
.res
.facts()
.get(self.world.block(p[0], p[1], p[2]))
.name
== "minecraft:lava"
};
self.world.chunks.contains_key(&(n[0] >> 4, n[2] >> 4))
&& (self.blocks_motion([n[0], n[1] - 1, n[2]])
|| self.res.facts().get(below).fluid != horizon_client::blocks::Fluid::None)
&& !self.blocks_motion(n)
&& !self.blocks_motion([n[0], n[1] + 1, n[2]])
&& !lava(n)
&& !lava([n[0], n[1] - 1, n[2]])
}
/// A* over standable feet positions: steps up one block, drops up to three.
fn find_path(&self, from: Node, goal: [f64; 3], near: f64) -> Option<Vec<Node>> {
use std::{cmp::Reverse, collections::BinaryHeap};
let reached = |n: Node| {
let c = [
f64::from(n[0]) + 0.5,
f64::from(n[1]),
f64::from(n[2]) + 0.5,
];
((c[0] - goal[0]).powi(2) + (c[2] - goal[2]).powi(2)).sqrt() <= near.max(0.5)
&& (c[1] - goal[1]).abs() <= near.max(1.0) + 0.5
};
let h = |n: Node| {
((f64::from(n[0]) + 0.5 - goal[0]).abs()
+ (f64::from(n[1]) - goal[1]).abs()
+ (f64::from(n[2]) + 0.5 - goal[2]).abs())
* 10.0
};
let mut open = BinaryHeap::new();
let mut cost: HashMap<Node, u32> = HashMap::new();
let mut came: HashMap<Node, Node> = HashMap::new();
cost.insert(from, 0);
open.push(Reverse((h(from) as u32, from)));
let mut expanded = 0;
while let Some(Reverse((_, n))) = open.pop() {
if reached(n) {
let mut path = vec![n];
let mut at = n;
while let Some(&p) = came.get(&at) {
path.push(p);
at = p;
}
path.reverse();
return Some(path);
}
expanded += 1;
if expanded > 30_000 {
fn find_path(
&self,
from: Node,
goal: [f64; 3],
near: f64,
blocked: &[(Node, Node)],
) -> Option<Plan> {
navigation::find_path(from, goal, near, blocked, |n| {
if !self.world.chunks.contains_key(&(n[0] >> 4, n[2] >> 4)) {
return None;
}
let base = cost[&n];
for d in [[1, 0], [-1, 0], [0, 1], [0, -1]] {
for dy in [0, 1, -1, -2, -3] {
let m = [n[0] + d[0], n[1] + dy, n[2] + d[1]];
// A step up needs head room above the start; a drop needs
// the column in front clear down to the landing.
let clear = match dy {
1 => !self.blocks_motion([n[0], n[1] + 2, n[2]]),
d if d < 0 => {
(m[1]..=n[1] + 1).all(|y| !self.blocks_motion([m[0], y, m[2]]))
}
_ => true,
};
if !clear || !self.walkable(m) {
continue;
}
let water = self
.res
.facts()
.get(self.world.block(m[0], m[1], m[2]))
.fluid
!= horizon_client::blocks::Fluid::None;
let step = 10 + if dy != 0 { 5 } else { 0 } + if water { 30 } else { 0 };
let c = base + step;
if cost.get(&m).is_none_or(|&old| c < old) {
cost.insert(m, c);
came.insert(m, n);
open.push(Reverse((c + h(m) as u32, m)));
}
break;
}
}
}
None
let f = self.res.facts().get(self.world.block(n[0], n[1], n[2]));
Some(Cell {
solid: f.full_collision || f.legacy_solid,
fluid: f.fluid != horizon_client::blocks::Fluid::None,
lava: &*f.name == "minecraft:lava",
})
})
}
/// Point the view at `at` from the eye.
@@ -1259,10 +1181,13 @@ impl Game<'_> {
let timeout = a.num("timeout", 60.0)?;
let p = self.player.as_ref().ok_or("no player")?.position;
let from = [p.x.floor() as i32, p.y.round() as i32, p.z.floor() as i32];
let (path, warning) = match self.find_path(from, target, near) {
let (path, warning) = match self.find_path(from, target, near, &[]) {
Some(path) => (path, None),
None if a.has("direct") => (
Vec::new(),
Plan {
nodes: Vec::new(),
complete: true,
},
Some("no path found through loaded terrain; walking straight".to_owned()),
),
None => {
@@ -1272,12 +1197,17 @@ impl Game<'_> {
.into());
}
};
let position = [p.x, p.y, p.z];
let mut next = 1;
let aim = path.aim(position, &mut next, target).unwrap_or(target);
let goto = Goto {
target,
near,
sprint: a.has("sprint"),
path,
next: 1,
blocked: Vec::new(),
progress: Progress::new(position, aim),
next,
progress_tick: self.ticks,
replans: 0,
jump_until: 0,
@@ -2077,9 +2007,7 @@ impl Game<'_> {
let on_ground = self.player.as_ref().is_some_and(|p| p.ground.on_ground);
let horizontal =
|a: [f64; 3], b: [f64; 3]| ((a[0] - b[0]).powi(2) + (a[2] - b[2]).powi(2)).sqrt();
let arrived = horizontal(p, g.target) <= g.near
&& (p[1] - g.target[1]).abs() <= g.near.max(1.0) + 1.0;
if arrived {
if navigation::arrived(p, g.target, g.near) {
return Some(
json!({ "ok": true, "arrived": true, "position": p, "target": g.target,
"distance": dist(p, g.target), "warning": g.warning }),
@@ -2091,48 +2019,60 @@ impl Game<'_> {
"target": g.target, "distance": dist(p, g.target), "warning": g.warning }),
);
}
// The next waypoint: path nodes, then the target itself.
let mut aim = g.target;
while g.next < g.path.len() {
let n = g.path[g.next];
let c = [
f64::from(n[0]) + 0.5,
f64::from(n[1]),
f64::from(n[2]) + 0.5,
];
let last = g.next + 1 == g.path.len();
if horizontal(p, c) < if last { 0.3 } else { 0.45 } && (p[1] - c[1]).abs() < 1.1 {
g.next += 1;
let previous_next = g.next;
let mut aim = g.path.aim(p, &mut g.next, g.target);
if let Some(aim) = aim {
if g.next != previous_next {
g.progress = Progress::new(p, aim);
g.progress_tick = self.ticks;
} else if g.progress.advance(p, aim) {
g.progress_tick = self.ticks;
continue;
}
aim = c;
break;
}
// Stuck: jump, then replan from here.
let stalled = self.ticks.saturating_sub(g.progress_tick);
if stalled > 60 && on_ground {
g.jump_until = self.ticks + 4;
}
if stalled > 120 {
g.replans += 1;
if g.replans > 3 {
return Some(
json!({ "error": "stuck: cannot make progress", "arrived": false, "position": p,
"target": g.target, "distance": dist(p, g.target) }),
);
if aim.is_none() || stalled > 120 {
if stalled > 120 {
g.replans += 1;
if g.replans > 3 {
return Some(
json!({ "error": "stuck: cannot make progress", "arrived": false,
"position": p, "target": g.target, "distance": dist(p, g.target) }),
);
}
if g.next > 0 && g.next < g.path.nodes.len() {
let edge = (g.path.nodes[g.next - 1], g.path.nodes[g.next]);
if edge.0 != edge.1 && !g.blocked.contains(&edge) {
g.blocked.push(edge);
}
}
}
let from = [
p[0].floor() as i32,
p[1].round() as i32,
p[2].floor() as i32,
];
if let Some(path) = self.find_path(from, g.target, g.near) {
g.path = path;
g.next = 1;
}
let Some(path) = self.find_path(from, g.target, g.near, &g.blocked) else {
return Some(
json!({ "error": "no further walking progress through loaded terrain",
"arrived": false, "position": p, "target": g.target,
"distance": dist(p, g.target), "warning": g.warning }),
);
};
g.path = path;
g.next = 1;
g.progress_tick = self.ticks;
aim = g.path.aim(p, &mut g.next, g.target);
g.progress = Progress::new(p, aim.unwrap_or(g.target));
}
let Some(aim) = aim else {
return Some(
json!({ "error": "path did not provide a new walking waypoint",
"arrived": false, "position": p, "target": g.target }),
);
};
let d = [aim[0] - p[0], aim[2] - p[2]];
if d[0].abs() + d[1].abs() > 1e-3 {
self.yaw = (-d[0]).atan2(d[1]).to_degrees() as f32;
@@ -0,0 +1,439 @@
use std::{
cmp::Reverse,
collections::{BinaryHeap, HashMap},
};
pub(crate) type Node = [i32; 3];
#[derive(Clone, Copy, Default)]
pub(crate) struct Cell {
pub solid: bool,
pub fluid: bool,
pub lava: bool,
}
#[derive(Debug)]
pub(crate) struct Plan {
pub nodes: Vec<Node>,
pub complete: bool,
}
fn solid(query: &impl Fn(Node) -> Option<Cell>, n: Node) -> bool {
query(n).is_none_or(|b| b.solid)
}
fn walkable(query: &impl Fn(Node) -> Option<Cell>, n: Node) -> bool {
let Some(feet) = query(n) else {
return false;
};
let Some(below) = query([n[0], n[1] - 1, n[2]]) else {
return false;
};
let Some(head) = query([n[0], n[1] + 1, n[2]]) else {
return false;
};
(below.solid || below.fluid) && !feet.solid && !head.solid && !feet.lava && !below.lava
}
pub(crate) fn find_path(
from: Node,
goal: [f64; 3],
near: f64,
blocked: &[(Node, Node)],
query: impl Fn(Node) -> Option<Cell>,
) -> Option<Plan> {
let reached = |n: Node| {
let c = [
f64::from(n[0]) + 0.5,
f64::from(n[1]),
f64::from(n[2]) + 0.5,
];
((c[0] - goal[0]).powi(2) + (c[2] - goal[2]).powi(2)).sqrt() <= near.max(0.5)
&& (c[1] - goal[1]).abs() <= near.max(1.0) + 0.5
};
let h = |n: Node| {
((f64::from(n[0]) + 0.5 - goal[0]).abs()
+ (f64::from(n[1]) - goal[1]).abs()
+ (f64::from(n[2]) + 0.5 - goal[2]).abs())
* 10.0
};
let mut open = BinaryHeap::new();
let mut cost: HashMap<Node, u32> = HashMap::new();
let mut came: HashMap<Node, Node> = HashMap::new();
cost.insert(from, 0);
open.push(Reverse((h(from) as u32, from)));
let distance = |n: Node| {
(f64::from(n[0]) + 0.5 - goal[0]).powi(2)
+ (f64::from(n[1]) - goal[1]).powi(2)
+ (f64::from(n[2]) + 0.5 - goal[2]).powi(2)
};
let mut best = from;
let mut expanded = 0;
while let Some(Reverse((_, n))) = open.pop() {
if reached(n) {
return Some(reconstruct(n, &came, true));
}
if distance(n) < distance(best) {
best = n;
}
expanded += 1;
if expanded >= 30_000 {
break;
}
let base = cost[&n];
for d in [[1, 0], [-1, 0], [0, 1], [0, -1]] {
for dy in [0, 1, -1, -2, -3] {
let m = [n[0] + d[0], n[1] + dy, n[2] + d[1]];
// A step up needs head room above the start; a drop needs
// the column in front clear down to the landing.
let clear = match dy {
1 => !solid(&query, [n[0], n[1] + 2, n[2]]),
d if d < 0 => (m[1]..=n[1] + 1).all(|y| !solid(&query, [m[0], y, m[2]])),
_ => true,
};
if !clear || !walkable(&query, m) {
continue;
}
if blocked
.iter()
.any(|&(a, b)| (a == n && b == m) || (a == m && b == n))
{
break;
}
let water = query(m).is_some_and(|b| b.fluid);
let step = 10 + if dy != 0 { 5 } else { 0 } + if water { 30 } else { 0 };
let c = base + step;
if cost.get(&m).is_none_or(|&old| c < old) {
cost.insert(m, c);
came.insert(m, n);
open.push(Reverse((c + h(m) as u32, m)));
}
break;
}
}
}
(best != from && distance(best) + 1e-6 < distance(from))
.then(|| reconstruct(best, &came, false))
}
fn reconstruct(end: Node, came: &HashMap<Node, Node>, complete: bool) -> Plan {
let mut nodes = vec![end];
let mut at = end;
while let Some(&previous) = came.get(&at) {
nodes.push(previous);
at = previous;
}
nodes.reverse();
Plan { nodes, complete }
}
impl Plan {
pub(crate) fn aim(&self, p: [f64; 3], next: &mut usize, target: [f64; 3]) -> Option<[f64; 3]> {
while *next < self.nodes.len() {
let n = self.nodes[*next];
let c = [
f64::from(n[0]) + 0.5,
f64::from(n[1]),
f64::from(n[2]) + 0.5,
];
let last = *next + 1 == self.nodes.len();
if ((p[0] - c[0]).powi(2) + (p[2] - c[2]).powi(2)).sqrt()
< if last { 0.3 } else { 0.45 }
&& (p[1] - c[1]).abs() < 1.1
{
*next += 1;
} else {
return Some(c);
}
}
self.complete.then_some(target)
}
}
pub(crate) fn arrived(p: [f64; 3], target: [f64; 3], near: f64) -> bool {
((p[0] - target[0]).powi(2) + (p[2] - target[2]).powi(2)).sqrt() <= near
&& (p[1] - target[1]).abs() <= near.max(1.0) + 1.0
}
fn horizontal_distance(p: [f64; 3], aim: [f64; 3]) -> f64 {
((p[0] - aim[0]).powi(2) + (p[2] - aim[2]).powi(2)).sqrt()
}
pub(crate) struct Progress {
best_distance: f64,
}
impl Progress {
pub(crate) fn new(position: [f64; 3], aim: [f64; 3]) -> Self {
Self {
best_distance: horizontal_distance(position, aim),
}
}
pub(crate) fn advance(&mut self, p: [f64; 3], aim: [f64; 3]) -> bool {
let distance = horizontal_distance(p, aim);
if self.best_distance - distance >= 0.25 {
self.best_distance = distance;
true
} else {
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Default)]
struct Terrain {
floors: HashMap<(i32, i32), i32>,
cells: HashMap<Node, Cell>,
}
impl Terrain {
fn flat(x: std::ops::RangeInclusive<i32>, z: std::ops::RangeInclusive<i32>) -> Self {
let mut t = Self::default();
for x in x {
for z in z.clone() {
t.floors.insert((x, z), 63);
}
}
t
}
fn query(&self, n: Node) -> Option<Cell> {
let floor = self.floors.get(&(n[0], n[2]))?;
Some(self.cells.get(&n).copied().unwrap_or(Cell {
solid: n[1] <= *floor,
..Cell::default()
}))
}
fn path(&self, from: Node, goal: [f64; 3], blocked: &[(Node, Node)]) -> Option<Plan> {
find_path(from, goal, 0.6, blocked, |n| self.query(n))
}
}
#[test]
fn short_path_is_complete() {
let p = Terrain::flat(0..=5, 0..=0)
.path([0, 64, 0], [5.5, 64.0, 0.5], &[])
.unwrap();
assert!(p.complete);
assert_eq!(p.nodes.last(), Some(&[5, 64, 0]));
}
#[test]
fn loaded_frontier_continues_after_chunks_extend() {
let mut t = Terrain::flat(0..=5, 0..=0);
let goal = [10.5, 64.0, 0.5];
let partial = t
.path([0, 64, 0], goal, &[])
.expect("loaded frontier should be usable");
assert!(!partial.complete);
assert_eq!(partial.nodes.last(), Some(&[5, 64, 0]));
let endpoint = *partial.nodes.last().unwrap();
assert!(
t.path(endpoint, goal, &[]).is_none(),
"unchanged frontier must stop"
);
for x in 6..=10 {
t.floors.insert((x, 0), 63);
}
let complete = t.path(endpoint, goal, &[]).unwrap();
assert!(complete.complete);
assert_eq!(complete.nodes.last(), Some(&[10, 64, 0]));
}
#[test]
fn no_closer_endpoint_fails() {
assert!(
Terrain::flat(-4..=0, 0..=0)
.path([0, 64, 0], [10.5, 64.0, 0.5], &[])
.is_none()
);
}
#[test]
fn complete_path_can_initially_move_away() {
let mut t = Terrain::flat(-2..=0, 0..=0);
for z in 0..=2 {
t.floors.insert((-2, z), 63);
}
for x in -2..=5 {
t.floors.insert((x, 2), 63);
}
for z in 0..=2 {
t.floors.insert((5, z), 63);
}
let p = t.path([0, 64, 0], [5.5, 64.0, 0.5], &[]).unwrap();
assert!(p.complete);
assert_eq!(p.nodes[1], [-1, 64, 0]);
}
#[test]
fn failed_edge_uses_bypass() {
let blocked = [([0, 64, 0], [1, 64, 0])];
let p = Terrain::flat(0..=3, 0..=1)
.path([0, 64, 0], [3.5, 64.0, 0.5], &blocked)
.unwrap();
assert!(p.complete);
assert!(
!p.nodes
.windows(2)
.any(|w| w == [blocked[0].0, blocked[0].1])
);
assert_eq!(p.nodes[1], [0, 64, 1]);
}
#[test]
fn failed_edge_without_bypass_stops() {
assert!(
Terrain::flat(0..=1, 0..=0)
.path([0, 64, 0], [1.5, 64.0, 0.5], &[([0, 64, 0], [1, 64, 0])])
.is_none()
);
}
#[test]
fn lava_and_unknown_terrain_are_not_walked() {
let mut t = Terrain::flat(0..=1, 0..=0);
t.cells.insert(
[1, 63, 0],
Cell {
lava: true,
fluid: true,
solid: false,
},
);
assert!(t.path([0, 64, 0], [1.5, 64.0, 0.5], &[]).is_none());
t.cells.remove(&[1, 63, 0]);
t.cells.insert(
[1, 64, 0],
Cell {
lava: true,
fluid: true,
solid: false,
},
);
assert!(t.path([0, 64, 0], [1.5, 64.0, 0.5], &[]).is_none());
assert!(
Terrain::flat(0..=0, 0..=0)
.path([0, 64, 0], [5.5, 64.0, 0.5], &[])
.is_none()
);
}
#[test]
fn one_step_up_and_three_step_drop_only_with_clearance() {
let mut t = Terrain::flat(0..=1, 0..=0);
t.floors.insert((1, 0), 64);
assert!(t.path([0, 64, 0], [1.5, 65.0, 0.5], &[]).unwrap().complete);
t.cells.insert(
[0, 66, 0],
Cell {
solid: true,
..Cell::default()
},
);
assert!(t.path([0, 64, 0], [1.5, 65.0, 0.5], &[]).is_none());
t.cells.clear();
t.floors.insert((1, 0), 65);
assert!(t.path([0, 64, 0], [1.5, 66.0, 0.5], &[]).is_none());
t.floors.insert((1, 0), 60);
assert!(t.path([0, 64, 0], [1.5, 61.0, 0.5], &[]).unwrap().complete);
t.cells.insert(
[1, 64, 0],
Cell {
solid: true,
..Cell::default()
},
);
assert!(t.path([0, 64, 0], [1.5, 61.0, 0.5], &[]).is_none());
t.cells.clear();
t.floors.insert((1, 0), 59);
assert!(t.path([0, 64, 0], [1.5, 60.0, 0.5], &[]).is_none());
}
#[test]
fn dry_bypass_costs_less_than_water() {
let mut t = Terrain::flat(0..=2, 0..=1);
t.cells.insert(
[1, 64, 0],
Cell {
fluid: true,
..Cell::default()
},
);
let p = t.path([0, 64, 0], [2.5, 64.0, 0.5], &[]).unwrap();
assert!(p.complete);
assert!(!p.nodes.contains(&[1, 64, 0]));
}
#[test]
fn search_budget_returns_progress_instead_of_discarding_it() {
let p = Terrain::flat(0..=30_010, 0..=0)
.path([0, 64, 0], [40_000.5, 64.0, 0.5], &[])
.unwrap();
assert!(!p.complete);
assert!(p.nodes.len() > 2 && p.nodes.len() <= 30_001);
}
#[test]
fn consumed_partial_never_aims_at_unknown_goal_or_arrives() {
let plan = Plan {
nodes: vec![[0, 64, 0], [1, 64, 0]],
complete: false,
};
let p = [1.5, 64.0, 0.5];
let goal = [10.5, 64.0, 0.5];
let mut next = 1;
assert_eq!(plan.aim(p, &mut next, goal), None);
assert!(!arrived(p, goal, 0.6));
}
#[test]
fn replacement_path_uses_its_new_waypoint() {
let plan = Plan {
nodes: vec![[0, 64, 0], [0, 64, 1], [1, 64, 1]],
complete: false,
};
assert_eq!(
plan.aim([0.5, 64.0, 0.5], &mut 1, [5.5, 64.0, 0.5]),
Some([0.5, 64.0, 1.5])
);
assert!(arrived([5.5, 64.0, 0.5], [5.5, 64.0, 0.5], 0.6));
}
#[test]
fn jumping_in_place_does_not_reset_stuck_timer() {
let start = [0.5, 64.0, 0.5];
let aim = [4.5, 64.0, 0.5];
let mut progress = Progress::new(start, aim);
assert!(!progress.advance([0.5, 65.0, 0.5], aim));
assert!(!progress.advance([0.6, 64.0, 0.6], aim));
assert!(progress.advance([0.8, 64.0, 0.5], aim));
}
#[test]
fn oscillation_and_vertical_drift_do_not_count_as_progress() {
let aim = [4.5, 64.0, 0.5];
let mut progress = Progress::new([0.5, 64.0, 0.5], aim);
for tick in 0..400 {
let x = if tick % 2 == 0 { 0.2 } else { 0.5 };
assert!(
!progress.advance([x, 64.0, 0.5], aim),
"oscillation reset at {tick}"
);
}
assert!(!progress.advance([0.5, 66.0, 0.5], aim));
}
#[test]
fn only_new_best_distance_counts_and_waypoint_switch_resets_baseline() {
let aim = [4.5, 64.0, 0.5];
let start = [0.5, 64.0, 0.5];
let mut progress = Progress::new(start, aim);
assert!(!progress.advance([0.6, 64.0, 0.5], aim));
assert!(progress.advance([0.8, 64.0, 0.5], aim));
assert!(!progress.advance(start, aim));
assert!(!progress.advance([0.8, 64.0, 0.5], aim));
assert!(progress.advance([1.1, 64.0, 0.5], aim));
let detour_aim = [1.1, 64.0, 2.5];
let mut progress = Progress::new([1.1, 64.0, 0.5], detour_aim);
assert!(progress.advance([1.1, 64.0, 0.8], detour_aim));
}
}
+37
View File
@@ -196,6 +196,11 @@ pub(super) trait Entity: EntityRules {
fn can_use_portal(&self, _game: &Game<'_>) -> bool {
true
}
/// A boat or minecart: the kinds whose portal pass opens the tick, before
/// they move (`AbstractMinecart.tick` handles its portal first).
fn is_vehicle(&self) -> bool {
false
}
/// Where it stood at the start of its tick, for the kinds that store it
/// (None: `portals.before` has it).
fn portal_from(&self) -> Option<Vec3> {
@@ -546,6 +551,9 @@ impl Entity for ExperienceOrb {
impl Entity for Boat {
stored_motion!();
fn is_vehicle(&self) -> bool {
true
}
/// `Boat.getEyeY`: the top of the 0.5625 box.
fn fluid_eye_y(&self) -> f64 {
self.position.y + super::boats::HEIGHT
@@ -579,6 +587,9 @@ impl Entity for Boat {
impl Entity for Minecart {
stored_motion!();
fn is_vehicle(&self) -> bool {
true
}
fn portal_size(&self) -> Option<(f32, f32)> {
Some((
super::minecarts::WIDTH as f32,
@@ -666,6 +677,12 @@ impl Entity for ArmorStand {
fn is_ignoring_block_triggers(&self) -> bool {
self.marker
}
/// A NoGravity stand is not swept for the blocks it stands in (plates,
/// fire): measured beside the real server, it never presses a plate nor
/// catches fire, and does again once `NoGravity` is cleared.
fn sweeps_blocks_inside(&self) -> bool {
!self.no_gravity
}
fn pushed_by_explosion(&self) -> bool {
false
}
@@ -875,3 +892,23 @@ impl EntityRules for FallingBlock {
0.04
}
}
#[cfg(test)]
mod no_gravity_stand_tests {
use super::Entity as _;
use horizon_client::entities::ArmorStand;
use horizon_core::geometry::Vec3;
#[test]
fn a_no_gravity_stand_is_not_swept_for_the_blocks_it_is_in() {
let mut stand = ArmorStand::new(1, Vec3::new(0.5, 100.0, 0.5), 0.0);
// A resting stand presses a plate (measured beside the real server)...
assert!(stand.sweeps_blocks_inside() && stand.triggers_blocks());
// ...a NoGravity one does not, nor does a marker.
stand.no_gravity = true;
assert!(!stand.sweeps_blocks_inside());
stand.no_gravity = false;
stand.marker = true;
assert!(!stand.triggers_blocks());
}
}
+17
View File
@@ -424,6 +424,7 @@ impl Game<'_> {
let observe_level =
running.is_some() && (self.serving.is_some() || self.options.dedicated.is_some());
let dims = self.levels_to_tick();
let order = dims.clone();
let n = dims.len();
let mut result = Ok(());
for (k, dim) in dims.into_iter().enumerate() {
@@ -466,6 +467,22 @@ impl Game<'_> {
self.native_level_tick_post(dim.id());
}
self.publish_server_debug(dim);
// An entity that crossed into a level that already ticked this
// server tick is in it when the tick ends (`addDuringTeleport`).
if running == Some(true) {
let mut moved = false;
for &earlier in &order[..k] {
if !self.arrival_chunks(earlier).is_empty()
&& self.enter_level(None, earlier).is_ok()
{
self.land_portal_arrivals();
moved = true;
}
}
if moved {
self.enter_level(None, dim)?;
}
}
}
self.level_pass = Pass::default();
if result.is_err() {
+3 -2
View File
@@ -538,7 +538,8 @@ impl Game<'_> {
boxes.retain(|b| b.intersects(near));
boxes
}
/// Entity.move for a cart: collisions zero the blocked components.
/// Entity.move for a cart: collisions zero the blocked components, the world border's walls
/// among them (`CollisionGetter.borderCollision`, asked for every entity).
fn move_cart(&self, c: &mut Minecart, delta: Vec3) {
let scene = SceneCollisions {
world: &self.world,
@@ -549,7 +550,7 @@ impl Game<'_> {
let mut scene = horizon_client::collisions::EntityCollisions {
blocks: scene,
entities: &entities,
border: None,
border: Some((self.border.geometry(), [c.position.x, c.position.z])),
};
if let Ok(moved) = scene.resolve(c.bounds(), delta, 0.0, c.on_ground) {
c.position = Vec3::new(
+130 -36
View File
@@ -140,6 +140,38 @@ pub(super) fn projectile_box(p: &horizon_client::projectiles::Projectile) -> (f3
(size, size)
}
}
/// `Entity.calculatePassengerTransition`: the riders under `tag`'s
/// `Passengers`, and theirs, keep their place on the vehicle: they move by
/// what it moved and turn by what it turned.
fn move_passengers(tag: &mut Tag, by: Vec3, turn: f32) {
let Tag::Compound(c) = tag else {
return;
};
let Some(Tag::List(riders)) = c.get_mut(&"Passengers".into()) else {
return;
};
for rider in riders {
if let Some(at) = horizon_client::entity_nbt::position(rider)
&& let Tag::Compound(r) = rider
{
r.insert(
"Pos".into(),
doubles([at.x + by.x, at.y + by.y, at.z + by.z]),
);
let [yaw, pitch] = horizon_client::entity_nbt::rotation(rider);
if let Tag::Compound(r) = rider {
r.insert(
"Rotation".into(),
Tag::List(vec![
Tag::Float((yaw + turn).to_bits()),
Tag::Float(pitch.to_bits()),
]),
);
}
}
move_passengers(rider, by, turn);
}
}
fn doubles(values: [f64; 3]) -> Tag {
Tag::List(values.iter().map(|v| Tag::Double(v.to_bits())).collect())
}
@@ -557,6 +589,15 @@ impl Game<'_> {
/// at the start of their tick: an item or mob that stayed in a portal
/// goes through (transition time 0) and waits for its dimension.
pub(super) fn handle_entity_portals(&mut self) {
self.handle_portals(false);
}
/// `Entity.handlePortal` of the boats and minecarts, which `tick_boats`
/// and `tick_carts` move: it runs before them, from the position they
/// ended the last tick at, as the start of a vehicle's own tick does.
pub(super) fn handle_vehicle_portals(&mut self) {
self.handle_portals(true);
}
fn handle_portals(&mut self, vehicles: bool) {
if self.remote.is_some() {
return;
}
@@ -564,13 +605,17 @@ impl Game<'_> {
let mut leaving: Vec<(u32, BlockPos)> = Vec::new();
// 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.
// can use one: asking every entity on every tick is not worth it. The
// vehicles' pass opens the tick and the other kinds' pass follows, so
// each walk takes only its own group.
let mut before = std::mem::take(&mut self.portals.before);
before.clear();
if vehicles {
before.clear();
}
let mut ids: Vec<(u32, bool)> = Vec::new();
let tracked = &self.portals.entities;
for_each_entity!(entity_lists_of!(self), entity => {
if entity.portal_size().is_some() {
if entity.portal_size().is_some() && entity.is_vehicle() == vehicles {
if entity.portal_from().is_none() {
before.insert(entity.id(), entity.position());
}
@@ -581,7 +626,12 @@ impl Game<'_> {
});
self.portals.before = before;
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
self.portals.entities.retain(|id, _| present.contains(id));
// Each pass keeps the other group's states.
let (boats, carts) = (&self.boats, &self.carts);
self.portals.entities.retain(|id, _| {
let vehicle = boats.iter().any(|b| b.id == *id) || carts.iter().any(|c| c.id == *id);
present.contains(id) || vehicle != vehicles
});
for (id, allowed) in ids {
let Some(state) = self.portals.entities.get_mut(&id) else {
continue;
@@ -650,20 +700,20 @@ impl Game<'_> {
&& (!self.guest_rides(m.id) || self.carries_riders())
&& m.species() != Species::Wither
}
/// `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.
pub(super) fn tnt_can_use_portal(&self, id: u32) -> bool {
// Id 0: not tracked yet, nothing to key its portal state by.
id != 0 && !self.mobs.iter().any(|m| m.extra.mount == Some(id))
}
/// `canUsePortal(false)` of a boat or minecart: it is nobody's passenger;
/// the mobs riding it, and on a server the players, cross with it.
pub(super) fn vehicle_can_use_portal(&self, id: u32) -> bool {
if !self.vehicle_occupied(id) {
return true;
// The window's player is moved by its own trip, not by the vehicle's.
if self.riding == Some(id) {
return false;
}
self.carries_riders()
&& self.riding != Some(id)
&& !self.mobs.iter().any(|m| m.extra.mount == Some(id))
// Mob riders always go along; server players only on a server.
!self.ridden_by_guest(id) || self.carries_riders()
}
/// Whether a vehicle can take the server players riding it through a
/// portal: only a server moves players between its levels. Elsewhere a
@@ -681,27 +731,23 @@ impl Game<'_> {
here.into_iter().map(|(_, id, entry)| (id, entry)).collect()
}
/// The plan of vehicle `id` through the portal at `entry`: from its own
/// position and size (`getPortalDestination(level, vehicle)`).
/// position and size (`getPortalDestination(level, vehicle)`). A boat or
/// minecart, like a mob, lands in the middle of the exit portal's depth
/// (`AbstractBoat` and `AbstractMinecart.getRelativePortalPosition`).
pub(super) fn vehicle_plan(&self, id: u32, entry: BlockPos) -> Option<Plan> {
if let Some(m) = self.mobs.iter().find(|m| m.id == id) {
return Some(self.entity_plan(entry, m.position, m.width(), m.height(), true));
}
if let Some(b) = self.boats.iter().find(|b| b.id == id) {
let (width, height) = (super::boats::WIDTH as f32, super::boats::HEIGHT as f32);
return Some(self.entity_plan(entry, b.position, width, height, false));
return Some(self.entity_plan(entry, b.position, width, height, true));
}
let c = self.carts.iter().find(|c| c.id == id)?;
let (width, height) = (
super::minecarts::WIDTH as f32,
super::minecarts::HEIGHT as f32,
);
Some(self.entity_plan(entry, c.position, width, height, false))
}
/// Whether the player, a server player or a mob rides vehicle `id`.
fn vehicle_occupied(&self, id: u32) -> bool {
self.riding == Some(id)
|| self.ridden_by_guest(id)
|| self.mobs.iter().any(|m| m.extra.mount == Some(id))
Some(self.entity_plan(entry, c.position, width, height, true))
}
/// `NetherPortalBlock.entityInside` and `EndPortalBlock.entityInside`
/// for the items, mobs, empty boats and minecarts, and arrows that moved
@@ -929,18 +975,8 @@ impl Game<'_> {
c.remove(&"leash".into());
}
let mut gone = vec![id];
let mut k = 0;
while k < gone.len() {
let v = gone[k];
gone.extend(
self.mobs
.iter()
.filter(|m| m.extra.mount == Some(v))
.map(|m| m.id),
);
k += 1;
}
self.mobs.retain(|m| !gone.contains(&m.id));
gone.extend(self.remove_mob_riders(id));
self.mobs.retain(|m| m.id != id);
for g in gone {
self.detach_entity(g);
}
@@ -949,14 +985,14 @@ impl Game<'_> {
} else if let Some(i) = self.boats.iter().position(|b| b.id == id) {
let b = &self.boats[i];
let (width, height) = (super::boats::WIDTH as f32, super::boats::HEIGHT as f32);
let plan = self.entity_plan(entry, b.position, width, height, false);
let plan = self.entity_plan(entry, b.position, width, height, true);
if !self.may_enter(plan.dim) {
return;
}
self.release_vehicle_container(id);
let uuid = self.uuid_for(id);
let b = &self.boats[i];
let tag = horizon_client::entity_nbt::boat_to_nbt(
let mut tag = horizon_client::entity_nbt::boat_to_nbt(
&b.entity_type(),
b.position,
b.velocity,
@@ -965,6 +1001,11 @@ impl Game<'_> {
self.persist_raw(id),
b.chest.then_some(&b.items[..]),
);
// The mobs riding it cross with it and mount it again.
self.put_mob_passengers(&mut tag, id);
for rider in self.remove_mob_riders(id) {
self.detach_entity(rider);
}
self.boats.remove(i);
self.detach_entity(id);
self.portals.arrivals.push(Arrival { plan, tag });
@@ -975,13 +1016,27 @@ impl Game<'_> {
super::minecarts::WIDTH as f32,
super::minecarts::HEIGHT as f32,
);
let plan = self.entity_plan(entry, c.position, width, height, false);
let plan = self.entity_plan(entry, c.position, width, height, true);
if !self.may_enter(plan.dim) {
return;
}
self.release_vehicle_container(id);
let uuid = self.uuid_for(id);
let tag = self.carts[i].to_nbt(uuid, self.persist_raw(id));
let mut tag = self.carts[i].to_nbt(uuid, self.persist_raw(id));
// `PositionMoveRotation.of(this)` takes the cart's `getKnownMovement`:
// `OldMinecartBehavior` clamps it to 0.4 either way along x and z.
let m = horizon_client::entity_nbt::motion(&tag);
if let Tag::Compound(c) = &mut tag {
c.insert(
"Motion".into(),
doubles([m.x.clamp(-0.4, 0.4), m.y, m.z.clamp(-0.4, 0.4)]),
);
}
// The mobs riding it cross with it and mount it again.
self.put_mob_passengers(&mut tag, id);
for rider in self.remove_mob_riders(id) {
self.detach_entity(rider);
}
self.carts.remove(i);
self.detach_entity(id);
self.portals.arrivals.push(Arrival { plan, tag });
@@ -1026,6 +1081,26 @@ impl Game<'_> {
self.native_entity_portal(self.dim.id(), id, [entry.x, entry.y, entry.z]);
}
}
/// Removes the mobs riding `vehicle`, and those riding them, from the
/// level: `Entity.teleportCrossDimension` takes the whole tree with its
/// root. Their ids.
fn remove_mob_riders(&mut self, vehicle: u32) -> Vec<u32> {
let mut tree = vec![vehicle];
let mut k = 0;
while k < tree.len() {
let v = tree[k];
tree.extend(
self.mobs
.iter()
.filter(|m| m.extra.mount == Some(v))
.map(|m| m.id),
);
k += 1;
}
tree.remove(0);
self.mobs.retain(|m| !tree.contains(&m.id));
tree
}
/// The chunks of the arrivals still waiting to land in `dim`, for the
/// streaming `extra` areas that light them: vanilla's portal ticket
/// (`Entity.teleport` adds `TicketType.PORTAL` at the destination) keeps
@@ -1038,6 +1113,20 @@ impl Game<'_> {
.map(|a| (a.plan.approximate.x >> 4, a.plan.approximate.z >> 4))
.collect()
}
/// `ServerLevel.addDuringTeleport`: the entities that arrive in the level in
/// place join it now, before its entities tick, so a vehicle that crossed
/// from an earlier level this server tick moves in its new level the same
/// tick, and one that crossed from a later level stands there until the
/// next. Only the portal arrivals join; the chunks' entities wait.
pub(super) fn land_portal_arrivals(&mut self) {
if self.remote.is_some() || self.portals.arrivals.is_empty() {
return;
}
let waiting = std::mem::take(&mut self.world.loaded_entities);
self.resolve_arrivals();
self.spawn_loaded_entities();
self.world.loaded_entities.extend(waiting);
}
/// Entities waiting for the loaded dimension whose exit area is loaded:
/// their exit portal (found or built) or End destination, position,
/// turned yaw and motion, and portal cooldown; they join the world with the chunk entities.
@@ -1059,6 +1148,7 @@ impl Game<'_> {
self.portals.dirty = true;
let motion = horizon_client::entity_nbt::motion(&tag);
let [yaw, pitch] = horizon_client::entity_nbt::rotation(&tag);
let was_yaw = yaw;
let (at, motion, [yaw, pitch]) = if plan.end {
// Relative.DELTA: the motion is kept.
let at = self.end_portal_exit(&plan);
@@ -1075,6 +1165,10 @@ impl Game<'_> {
let (at, rotation) = self.arrival(&plan, exit);
(at, rotate_y(motion, -rotation), [yaw + rotation, pitch])
};
let yaw_turn = yaw - was_yaw;
if let Some(was) = horizon_client::entity_nbt::position(&tag) {
move_passengers(&mut tag, at.subtract(was), yaw_turn);
}
if let Tag::Compound(c) = &mut tag {
c.insert("Pos".into(), doubles([at.x, at.y, at.z]));
c.insert("Motion".into(), doubles([motion.x, motion.y, motion.z]));
+1 -1
View File
@@ -771,7 +771,7 @@ impl Game<'_> {
}
/// The mobs riding entity `id` under `tag`'s `Passengers`, after the
/// passengers it kept from loading.
fn put_mob_passengers(&mut self, tag: &mut Tag, id: u32) {
pub(super) fn put_mob_passengers(&mut self, tag: &mut Tag, id: u32) {
let riders: Vec<usize> = (0..self.mobs.len())
.filter(|&j| self.mobs[j].extra.mount == Some(id) && self.mobs[j].alive())
.collect();
+59 -8
View File
@@ -795,7 +795,12 @@ impl Game<'_> {
240 | 240 << 8,
&mut verts,
);
// `WingsLayer` draws through `RenderTypes.armorCutoutNoCull`: the
// wings' faces show from both sides (the elytra texture is empty on
// the face that looks at the body).
let wings_start = verts.len();
self.player_elytra_geometry(&m, chest, camera, 1.0, 240 | 240 << 8, &mut verts);
let wings = wings_start..verts.len();
self.shoulder_parrots_geometry(&m, camera, 1.0, 240 | 240 << 8, &mut verts);
self.held_item_geometry(&m, camera, 1.0, 240 | 240 << 8, false, &mut verts);
self.head_item_geometry(&m, camera, 1.0, 240 | 240 << 8, false, &mut verts);
@@ -812,7 +817,7 @@ impl Game<'_> {
.as_ref()
.filter(|p| p.fire_ticks > 0 && !p.spectator)
.map(|p| (p.dimensions.width, p.dimensions.height));
let quads = player_preview_quads(verts, gui, fire, &atlas.sprites);
let quads = player_preview_quads_no_cull(verts, gui, fire, &atlas.sprites, wings);
let start = batch.vertices.len();
for (_, p, uv, color) in quads {
batch.quad_3d(p, uv, color);
@@ -1176,10 +1181,23 @@ fn entity_in_ui_shade(normal: glam::Vec3) -> f32 {
}
pub(super) fn player_preview_quads(
verts: Vec<horizon_client::mesher::Vertex>,
gui: glam::Mat4,
fire: Option<(f32, f32)>,
sprites: &std::collections::HashMap<String, horizon_client::hud::Rect>,
) -> Vec<(f32, [[f32; 3]; 4], [[f32; 2]; 4], u32)> {
player_preview_quads_no_cull(verts, gui, fire, sprites, 0..0)
}
/// `player_preview_quads` where the faces of the vertices in `no_cull` are
/// kept when they look away (a `NoCull` render type); their light still comes
/// from the face's own outward normal, as the vertex normal gives it.
pub(super) fn player_preview_quads_no_cull(
mut verts: Vec<horizon_client::mesher::Vertex>,
gui: glam::Mat4,
fire: Option<(f32, f32)>,
sprites: &std::collections::HashMap<String, horizon_client::hud::Rect>,
no_cull: std::ops::Range<usize>,
) -> Vec<(f32, [[f32; 3]; 4], [[f32; 2]; 4], u32)> {
let flame_start = verts.len();
if let Some((width, height)) = fire {
@@ -1200,7 +1218,8 @@ pub(super) fn player_preview_quads(
std::array::from_fn(|i| gui.transform_point3(glam::Vec3::from_array(q[i].pos)));
let n = (p[1] - p[0]).cross(p[2] - p[0]);
// The winding gives inward normals: faces with n.z >= 0 point away.
if n.z >= 0.0 {
let back = n.z >= 0.0;
if back && !no_cull.contains(&(index * 4)) {
continue;
}
let n = n.normalize_or_zero();
@@ -1213,12 +1232,13 @@ pub(super) fn player_preview_quads(
let ch = |s: u32| ((((c >> s) & 255) as f32 * shade) as u32) << s;
let color = (c & 0xFF00_0000) | ch(0) | ch(8) | ch(16);
let depth = p.iter().map(|v| v.z).sum::<f32>() / 4.0;
quads.push((
depth,
p.map(|v| v.to_array()),
std::array::from_fn(|i| q[i].uv),
color,
));
let (mut pos, mut uv) = (p.map(|v| v.to_array()), std::array::from_fn(|i| q[i].uv));
if back {
// Seen from behind: the same quad in the opposite winding.
pos.reverse();
uv.reverse();
}
quads.push((depth, pos, uv, color));
}
// Keep far-to-near order for translucent faces; opaque overlap uses depth.
quads.sort_by(|a, b| a.0.total_cmp(&b.0));
@@ -1394,6 +1414,37 @@ mod preview_tests {
assert_eq!(batch.runs(), vec![(0..12, Blend::Depth([1, 0, 1, 1]))]);
}
#[test]
fn no_cull_faces_stay_in_the_preview_when_they_look_away() {
use horizon_client::mesher::Vertex;
// The wings' `armorCutoutNoCull`: a face turned from the viewer is
// dropped by the preview's culling unless its vertices are no-cull.
let face = |z: f32| {
[[-1.0, -1.0], [-1.0, 1.0], [1.0, 1.0], [1.0, -1.0]].map(|[x, y]| Vertex {
pos: [x, y, z],
uv: [x, y],
layer: 0,
color: u32::MAX,
light: 0,
normal: [0; 4],
glint_uv: [0.0, 0.0],
})
};
// Wound one way a face looks at the viewer, the other way it looks away.
let mut away = face(0.0);
away.reverse();
let verts: Vec<_> = face(0.0).into_iter().chain(away).collect();
let sprites = Default::default();
let gui = glam::Mat4::IDENTITY;
let culled = player_preview_quads(verts.clone(), gui, None, &sprites);
assert_eq!(culled.len(), 1);
let kept = player_preview_quads_no_cull(verts.clone(), gui, None, &sprites, 4..8);
assert_eq!(kept.len(), 2);
// The first face is outside the range and still culled when it looks away.
let first_away = player_preview_quads_no_cull(verts, gui, None, &sprites, 0..0);
assert_eq!(first_away.len(), 1);
}
#[test]
fn the_inventory_preview_is_lit_as_entity_in_ui() {
// A face looking at the viewer, up, down, to the right and to the left.
@@ -259,6 +259,21 @@ impl StartupMenu {
}
return Action::None;
}
// `TabNavigationBar.keyPressed` comes before the open tab's own
// widgets: Ctrl plus a digit or Tab picks a tab instead of reaching
// them.
let ctrl = if cfg!(target_os = "macos") {
self.modifiers.super_key()
} else {
self.modifiers.control_key()
};
if let PhysicalKey::Code(key) = event.physical_key
&& title
.tab_bar_key(key, ctrl, self.modifiers.shift_key())
.is_some()
{
return Action::None;
}
match event.physical_key {
PhysicalKey::Code(KeyCode::Escape) => title.escape(),
PhysicalKey::Code(KeyCode::F5) => title.refresh_servers(),
+35
View File
@@ -556,3 +556,38 @@ mod item_tests {
assert!(m.extra.main_hand.is_none(), "the mouth is empty");
}
}
#[cfg(test)]
mod target_tests {
use super::*;
use crate::mobs::world::with_test_world;
use crate::mobs::{Species, sensor_scan_tests::kind};
use horizon_core::geometry::Vec3;
fn dolphin(baby: bool) -> Mob {
let mut kind = kind();
let k = std::sync::Arc::make_mut(&mut kind);
k.id = "minecraft:dolphin".into();
k.species = Species::Dolphin;
let mut m = Mob::new(1, kind, Vec3::ZERO, 0.0);
m.extra.age = if baby { -24000 } else { 0 };
m
}
/// `Dolphin.canAttack`: a baby takes nobody as its target, so a hit on it
/// is not answered; an adult does take the one that hurt it.
#[test]
fn a_baby_dolphin_spares_whatever_hurt_it() {
let ran = with_test_world(&[], |w, _| {
assert!(
dolphin(true).spares_target(w, Who::Mob(7)),
"a baby dolphin fights nobody"
);
assert!(
!dolphin(false).spares_target(w, Who::Mob(7)),
"an adult dolphin takes its attacker"
);
});
let _ = ran;
}
}
+5
View File
@@ -689,6 +689,11 @@ impl Mob {
/// `IronGolem.canAttack`'s refusal of creepers. The callers check that
/// the target is alive and can be seen as an enemy.
pub fn spares_target(&self, w: &MobWorld, who: Who) -> bool {
// Dolphin.canAttack: a baby dolphin attacks nobody, so whatever hurt it
// is never taken as its target.
if self.species() == Species::Dolphin && self.is_baby() {
return true;
}
match who {
Who::Player => self.spares_players(w),
Who::Mob(id) => {
+8 -13
View File
@@ -32,8 +32,7 @@ pub trait Hooks: Send + Sync {
let _ = event;
}
/// The world's phases, read-only; `error_text` carries the engine's
/// own open-failure text, empty otherwise. Server-side; the deprecated
/// in-frame path delivers no lifecycle events. A server loop or final
/// own open-failure text, empty otherwise. Server-side. A server loop or final
/// save failure fires `WorldCloseFailed` with the error text.
fn world_lifecycle(&self, host: &mut dyn Host, event: &mut WorldLifecycleEvent<'_>) {
let _ = host;
@@ -734,8 +733,7 @@ pub trait Hooks: Send + Sync {
/// as on a refused hit, except that a canceled hit on the ender dragon
/// still counts as landed (`EnderDragon.hurt` returns true whatever
/// `reallyHurt` did: the arrow is spent, the melee effects run). It runs
/// on the thread that ticks the world (the server thread; the window's
/// in-frame): state shared with render thread hooks needs synchronising.
/// on the thread that ticks the world (the server thread): state shared with render thread hooks needs synchronising.
/// No `self`: the game is in the middle of the hit. API 50.
///
/// Return `Flow::Cancel` to take it: this sets `canceled`, and later mods are skipped.
@@ -1369,12 +1367,10 @@ pub trait Hooks: Send + Sync {
/// 0 runs it out on its first tick, as `native_living_entity_use_item_tick`
/// running it out, unless the item's own is 0 already), or set
/// `canceled` and the use doesn't start. Both sides, as
/// vanilla's: wherever the world runs (the server, single player
/// in-frame, a LAN host) for every player of the world, and on a
/// vanilla's: on the server for every player of the world, and on a
/// server's client for the uses it predicts. Answer the same on both,
/// and change the world only where this `Game` runs the world, not on a
/// server's client: no helper tells the two apart yet (`native_window`
/// is also true in-frame and on an in-frame LAN host). API 90.
/// and change the world only on the server (`native_window` is false
/// there), not on a server's client. API 90.
///
/// Return `Flow::Cancel` to take it: this sets `canceled`, and later mods are skipped.
fn living_entity_use_item_start(
@@ -1796,7 +1792,7 @@ pub trait Hooks: Send + Sync {
/// ender dragon's wings, Punch arrows, explosions, wind charges, a mace smash's area, a
/// hoglin's throw), nor yet for the ones Horizon makes another way: a mob's melee push from
/// its attack knockback or a Knockback weapon, an armor stand's, the ender dragon's. It runs
/// on the thread that ticks the world (the server thread; the window's in-frame): state
/// on the thread that ticks the world (the server thread): state
/// shared with render thread hooks needs synchronising. No `self`: the game is in the middle
/// of the hit. API 125.
///
@@ -1869,9 +1865,8 @@ pub trait Hooks: Send + Sync {
/// the code. On a server's client a run-out use stays in use, frozen,
/// so letting go still tells the server. Both sides, as vanilla's
/// `updatingUsingItem`: answer the same on both, and change the world
/// only where this `Game` runs the world, not on a server's client (no
/// helper tells the two apart yet; `native_window` is also true
/// in-frame and on an in-frame LAN host). Runs every tick of every use:
/// only on the server (`native_window` is false there), not on a
/// server's client. Runs every tick of every use:
/// check the item first. API 131.
///
/// Return `Flow::Cancel` to take it: this sets `canceled`, and later mods are skipped.
+42 -8
View File
@@ -3207,6 +3207,45 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
let lines = atlas.font.split(&Text::translate(key, &[]), 170.0);
crate::ui::tooltip(batch, atlas, &lines, cursor);
}
/// `TabManager.setCurrentTab`: the screen shows `tab`, whose own fields
/// take no focus, and whether the open tab changed (the click sound).
fn select_create_tab(&mut self, tab: usize) -> bool {
let Mode::Create { focus, options, .. } = &mut self.mode else {
return false;
};
let changed = options.tab != tab;
if changed {
options.tab = tab;
*focus = 2;
}
changed
}
/// `TabNavigationBar.keyPressed`: with Ctrl held, a digit picks a tab and
/// Tab steps to the next one, or to the previous with Shift. `None` for a
/// key the bar leaves alone, so a digit still reaches the field; otherwise
/// whether to play the click sound, as a tab that changed does.
pub fn tab_bar_key(
&mut self,
key: winit::keyboard::KeyCode,
ctrl: bool,
shift: bool,
) -> Option<bool> {
// The rules and the experiments take the keys, and so does the read
// that prepares the world.
if !ctrl
|| self.preparing
|| self.rules.get_mut().is_some()
|| self.experiments.get_mut().is_some()
|| self.confirm_experiments.get_mut().is_some()
{
return None;
}
let Mode::Create { options, .. } = &self.mode else {
return None;
};
let next = create_world::next_tab_index(options.tab, key, shift)?;
Some(self.select_create_tab(next.min(create_world::tab_count() - 1)))
}
/// The action and whether to play `AbstractWidget.playButtonClickSound`.
/// Navigation buttons can return `Action::None` while still sounding.
pub fn click(&mut self, x: f32, y: f32, size: (f32, f32)) -> (Action, bool) {
@@ -3221,15 +3260,10 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
}
let (w, h) = (size.0.ceil(), size.1.ceil());
// MenuTabBar: a new tab clears the focus and clicks.
if let Mode::Create { focus, options, .. } = &mut self.mode
&& let Some(tab) = create_world::tab_at(w, (x, y))
if let Some(tab) = create_world::tab_at(w, (x, y))
&& matches!(self.mode, Mode::Create { .. })
{
let changed = options.tab != tab;
if changed {
options.tab = tab;
*focus = 2;
}
return (Action::None, changed);
return (Action::None, self.select_create_tab(tab));
}
// The SafetyScreen's box toggles where it was last drawn.
if let Mode::MultiplayerWarning { check } = &mut self.mode {
@@ -260,6 +260,48 @@ pub(super) fn tab_at(w: f32, (x, y): (f32, f32)) -> Option<usize> {
.then(|| ((x - x0) / width) as usize)
.filter(|i| *i < TABS.len())
}
/// `TabNavigationBar.tabs.size()`: the last tab Ctrl+0 and Ctrl+4..9 land on.
pub(super) fn tab_count() -> usize {
TABS.len()
}
/// `InputConstants.Key.getNumericKeyValue`: the digit a key stands for, on the
/// number row or the keypad, and 0 for the `0` key. `None` for every other key.
fn numeric_key(key: winit::keyboard::KeyCode) -> Option<u8> {
use winit::keyboard::KeyCode;
Some(match key {
KeyCode::Digit0 | KeyCode::Numpad0 => 0,
KeyCode::Digit1 | KeyCode::Numpad1 => 1,
KeyCode::Digit2 | KeyCode::Numpad2 => 2,
KeyCode::Digit3 | KeyCode::Numpad3 => 3,
KeyCode::Digit4 | KeyCode::Numpad4 => 4,
KeyCode::Digit5 | KeyCode::Numpad5 => 5,
KeyCode::Digit6 | KeyCode::Numpad6 => 6,
KeyCode::Digit7 | KeyCode::Numpad7 => 7,
KeyCode::Digit8 | KeyCode::Numpad8 => 8,
KeyCode::Digit9 | KeyCode::Numpad9 => 9,
_ => return None,
})
}
/// `TabNavigationBar.getNextTabIndex`: the tab Ctrl plus `key` asks for, before
/// `Mth.clamp` to the tabs there are. A digit is `floorMod(key - 1, 10)`, so
/// Ctrl+1 is the first tab and Ctrl+0 the tenth; Tab steps one tab forward,
/// or back with Shift, wrapping. Every Create World tab is active, so the
/// walk never has to skip one. `None` where the key is neither a digit nor
/// Tab, which the bar ignores and the screen reads itself.
pub(super) fn next_tab_index(
tab: usize,
key: winit::keyboard::KeyCode,
shift: bool,
) -> Option<usize> {
if let Some(digit) = numeric_key(key) {
return Some((i32::from(digit) - 1).rem_euclid(10) as usize);
}
if key != winit::keyboard::KeyCode::Tab {
return None;
}
let next = tab as i32 + if shift { -1 } else { 1 };
Some(next.rem_euclid(TABS.len() as i32) as usize)
}
/// GridLayoutTab.doLayout: the open tab's grid, aligned at (0.5, 1/6) in the
/// area between the tab bar and the footer.
fn grid(w: f32, h: f32, tab: usize) -> (f32, f32) {
+59
View File
@@ -1041,6 +1041,65 @@ fn create_world_tabs_follow_vanilla_and_their_choices_reach_the_action() {
assert!(!dir.exists());
}
#[test]
fn ctrl_digits_and_ctrl_tab_switch_the_create_world_tabs() {
use winit::keyboard::KeyCode;
let mut f = Fixture::new();
f.click("Singleplayer");
assert!(matches!(f.title.mode, Mode::Create { .. }));
let shown = |f: &Fixture| -> (usize, u8) {
let Mode::Create { focus, options, .. } = &f.title.mode else {
panic!("the Create World screen")
};
(options.tab, *focus)
};
// The screen opens on the Game tab with the name box focused.
assert_eq!(shown(&f), (0, 0));
// `TabNavigationBar.getNextTabIndex`: a digit is floorMod(key - 1, 10), so
// Ctrl+2 is the World tab, Ctrl+3 the More tab. The tab's fields are new
// widgets, so the name box that had the keys gives them up.
assert_eq!(
f.title.tab_bar_key(KeyCode::Digit2, true, false),
Some(true)
);
assert_eq!(shown(&f), (1, 2));
assert_eq!(
f.title.tab_bar_key(KeyCode::Numpad3, true, false),
Some(true)
);
assert_eq!(shown(&f), (2, 2));
// The same tab again changes nothing and plays no click sound.
assert_eq!(
f.title.tab_bar_key(KeyCode::Digit3, true, false),
Some(false)
);
// Ctrl+4..9 and Ctrl+0 ask for tabs past the third: Mth.clamp keeps the
// last tab, and none of them moves or sounds.
for key in [KeyCode::Digit4, KeyCode::Digit9, KeyCode::Digit0] {
assert_eq!(f.title.tab_bar_key(key, true, false), Some(false));
assert_eq!(shown(&f).0, 2);
}
// Ctrl+Tab steps one tab forward and Ctrl+Shift+Tab one back, wrapping.
assert_eq!(
f.title.tab_bar_key(KeyCode::Digit1, true, false),
Some(true)
);
assert_eq!(shown(&f).0, 0);
assert_eq!(f.title.tab_bar_key(KeyCode::Tab, true, false), Some(true));
assert_eq!(shown(&f).0, 1);
assert_eq!(f.title.tab_bar_key(KeyCode::Tab, true, true), Some(true));
assert_eq!(shown(&f).0, 0);
assert_eq!(f.title.tab_bar_key(KeyCode::Tab, true, true), Some(true));
assert_eq!(shown(&f).0, 2);
// Without Ctrl the bar leaves the key alone, so the digit still types.
assert_eq!(f.title.tab_bar_key(KeyCode::Digit2, false, false), None);
assert_eq!(shown(&f).0, 2);
// Only the Create World screen has a tab bar under these keys.
f.click("Cancel");
assert!(matches!(f.title.mode, Mode::Main));
assert_eq!(f.title.tab_bar_key(KeyCode::Digit2, true, false), None);
}
#[test]
fn new_world_generation_settings_round_trip() {
let f = Fixture::new();
+3
View File
@@ -342,6 +342,8 @@ pub struct Shared {
/// The event stream's events (events.rs), and the streams' wake-up.
pub events: Mutex<crate::events::Events>,
pub event_added: std::sync::Condvar,
/// The management protocol's connections, which its notifications go to (management.rs).
pub management: crate::management::Clients,
}
impl Shared {
@@ -372,6 +374,7 @@ impl Shared {
update: Mutex::new(Updates::default()),
events: Mutex::default(),
event_added: std::sync::Condvar::new(),
management: crate::management::Clients::default(),
})
}
+209 -3
View File
@@ -16,10 +16,69 @@ use serde_json::{Value, json};
use std::{
net::{SocketAddr, TcpListener, TcpStream, ToSocketAddrs},
path::Path,
sync::Arc,
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
mpsc,
},
time::Duration,
};
/// The connections that have upgraded, and the queue that carries notifications to them, which
/// `JsonRpcNotificationService.broadcastNotification` does to every `Connection` of the `ManagementServer`.
/// A queue and a thread, so that a caller (the game's output pump, the supervisor) never waits on a slow client.
#[derive(Default)]
pub struct Clients {
sockets: Mutex<Vec<(u32, Arc<Socket>)>>,
outbox: Mutex<Option<mpsc::Sender<String>>>,
/// `server/stopping` went out for this run of the game: a stop comes from the supervisor and from the
/// game's own `stop`, and one run says it once.
stopping_told: AtomicBool,
}
/// A connection in `Clients` for as long as this lives.
struct Registered<'a>(&'a Shared, u32);
impl Drop for Registered<'_> {
fn drop(&mut self) {
let id = self.1;
self.0
.management
.sockets
.lock()
.unwrap()
.retain(|(client, _)| *client != id);
}
}
/// A notification to every connected client: `short` is the method without `minecraft:` and `params` its one
/// parameter, as `JsonRPCUtils.createRequest` writes a request without an `id`.
pub fn notify(shared: &Shared, short: &str, params: Option<Value>) {
let mut message = json!({ "jsonrpc": "2.0", "method": format!("minecraft:{short}") });
if let Some(params) = params {
message["params"] = json!([params]);
}
if let Some(outbox) = shared.management.outbox.lock().unwrap().as_ref() {
let _ = outbox.send(message.to_string());
}
}
/// `NotificationService.serverStarted`, after the game's `Done`: a new run of the game.
pub fn started(shared: &Shared) {
shared
.management
.stopping_told
.store(false, Ordering::SeqCst);
notify(shared, "notification/server/started", None);
}
/// `NotificationService.serverShuttingDown`, which `DedicatedServer.stopServer` sends first of all.
pub fn stopping(shared: &Shared) {
if !shared.management.stopping_told.swap(true, Ordering::SeqCst) {
notify(shared, "notification/server/stopping", None);
}
}
/// The management server's settings, read from server.properties as vanilla's `JsonRpc.create` reads them.
struct Settings {
host: String,
@@ -150,6 +209,26 @@ fn serve(
settings: Arc<Settings>,
listener: TcpListener,
) -> std::io::Result<()> {
let (outbox, queue) = mpsc::channel::<String>();
*shared.management.outbox.lock().unwrap() = Some(outbox);
let carrier = shared.clone();
std::thread::Builder::new()
.name("management-notify".into())
.spawn(move || {
for text in queue {
let sockets: Vec<_> = carrier
.management
.sockets
.lock()
.unwrap()
.iter()
.map(|(_, socket)| socket.clone())
.collect();
for socket in sockets {
let _ = socket.send_text(&text);
}
}
})?;
std::thread::Builder::new()
.name("management".into())
.spawn(move || {
@@ -191,7 +270,15 @@ fn connection(shared: &Arc<Shared>, settings: &Settings, stream: &TcpStream) {
return;
};
// `Connection`'s `ClientInfo`: the connections are numbered from 1, as the log lines show them.
let client = CONNECTIONS.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
let client = CONNECTIONS.fetch_add(1, Ordering::Relaxed) + 1;
let socket = Arc::new(socket);
shared
.management
.sockets
.lock()
.unwrap()
.push((client, socket.clone()));
let _registered = Registered(shared, client);
while let Ok(Some(text)) = socket.read_text() {
match receive(shared, client, &text) {
Some(Ok(reply)) => {
@@ -645,12 +732,131 @@ mod tests {
}
fn listen(settings: Settings) -> SocketAddr {
listen_on(shared(), settings)
}
fn listen_on(shared: Arc<Shared>, settings: Settings) -> SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
serve(shared(), Arc::new(settings), listener).unwrap();
serve(shared, Arc::new(settings), listener).unwrap();
address
}
/// The upgrade over a plain socket, without the `rpc.discover` that follows it in `discover`.
fn upgrade(address: SocketAddr) -> TcpStream {
let mut stream = TcpStream::connect(address).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.unwrap();
write!(
stream,
"GET / HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\
Authorization: Bearer {SECRET}\r\n\r\n"
)
.unwrap();
let mut head = Vec::new();
while !head.ends_with(b"\r\n\r\n") {
let mut b = [0u8];
stream.read_exact(&mut b).unwrap();
head.push(b[0]);
}
assert!(head.starts_with(b"HTTP/1.1 101 "));
stream
}
/// The text of the next frame the server sends (unmasked, short enough for a 7-bit length).
fn next_text(stream: &mut TcpStream) -> String {
let mut head = [0u8; 2];
stream.read_exact(&mut head).unwrap();
assert_eq!(head[0], 0x81);
let mut payload = vec![0u8; usize::from(head[1])];
stream.read_exact(&mut payload).unwrap();
String::from_utf8(payload).unwrap()
}
/// Waits until `count` connections are in the registry, which the upgrade's reply doesn't guarantee yet.
fn registered(shared: &Shared, count: usize) {
for _ in 0..500 {
if shared.management.sockets.lock().unwrap().len() == count {
return;
}
std::thread::sleep(Duration::from_millis(10));
}
panic!("{count} connections never registered");
}
#[test]
fn notifications_go_to_every_client_as_vanilla_writes_them() {
let shared = shared();
let address = listen_on(shared.clone(), settings());
let mut first = upgrade(address);
let mut second = upgrade(address);
registered(&shared, 2);
started(&shared);
let expected = r#"{"jsonrpc":"2.0","method":"minecraft:notification/server/started"}"#;
assert_eq!(next_text(&mut first), expected);
assert_eq!(next_text(&mut second), expected);
// One stop is told once per run of the game, whoever asks for it first; the next run says it again.
stopping(&shared);
stopping(&shared);
started(&shared);
stopping(&shared);
let stopping_frame =
r#"{"jsonrpc":"2.0","method":"minecraft:notification/server/stopping"}"#;
for client in [&mut first, &mut second] {
assert_eq!(next_text(client), stopping_frame);
assert_eq!(next_text(client), expected);
assert_eq!(next_text(client), stopping_frame);
}
// A parameter goes in an array of one, and a client that left is dropped from the registry.
drop(second);
notify(
&shared,
"notification/players/left",
Some(json!({ "name": "A" })),
);
assert_eq!(
next_text(&mut first),
r#"{"jsonrpc":"2.0","method":"minecraft:notification/players/left","params":[{"name":"A"}]}"#
);
for _ in 0..500 {
if shared.management.sockets.lock().unwrap().len() == 1 {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
assert_eq!(shared.management.sockets.lock().unwrap().len(), 1);
}
#[test]
fn every_notification_sent_is_one_discover_lists() {
let listed = schema::discover();
let names: Vec<&str> = listed["methods"]
.as_array()
.unwrap()
.iter()
.map(|m| m["name"].as_str().unwrap())
.collect();
for sent in [
"notification/server/started",
"notification/server/stopping",
] {
assert!(
names.contains(&format!("minecraft:{sent}").as_str()),
"{sent}"
);
}
}
#[test]
fn without_a_management_server_a_notification_goes_nowhere() {
let shared = shared();
started(&shared);
stopping(&shared);
assert!(shared.management.outbox.lock().unwrap().is_none());
}
#[test]
fn a_websocket_client_discovers_the_methods() {
let address = listen(settings());
+7
View File
@@ -498,7 +498,11 @@ fn pump(shared: &Arc<Shared>, from: impl Read + Send + 'static, to_stderr: bool)
};
if up {
shared.emit("phase", serde_json::json!({ "phase": "running" }));
crate::management::started(&shared);
}
} else if line.contains("/INFO]: Stopping server") {
// The game's own `stop` (a command in the game, not the supervisor's).
crate::management::stopping(&shared);
}
}
shared.log_line(line);
@@ -648,6 +652,7 @@ fn supervise(
if command == "stop" && stopping.is_none() {
stopping = Some(Instant::now());
shared.set_phase(Phase::Stopping);
crate::management::stopping(shared);
}
if let Some(input) = stdin.as_mut()
&& writeln!(input, "{line}")
@@ -665,6 +670,7 @@ fn supervise(
note(shared, "INFO", "Restarting the server");
stopping = Some(Instant::now());
shared.set_phase(Phase::Restarting);
crate::management::stopping(shared);
send_stop(&mut stdin);
}
if asked {
@@ -673,6 +679,7 @@ fn supervise(
note(shared, "INFO", "Stopping the server");
stopping = Some(Instant::now());
shared.set_phase(Phase::Stopping);
crate::management::stopping(shared);
send_stop(&mut stdin);
} else {
note(shared, "WARN", "Stopping the game at once, without saving");
@@ -437,6 +437,18 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
}
// DecoratedPotBlock.useWithoutItem: the wobble always succeeds.
Class::DecoratedPot => Success,
// MovingPistonBlock.useWithoutItem: a moving piston whose block
// entity is gone (`Level.getBlockEntity` null) is removed by the
// click on a server; one still in flight passes it on.
Class::MovingPiston => {
if !self.level.is_client_side() && self.moving_piston(pos).is_none() {
self.remove_block(pos, false);
// CONSUME: the action is used, the arm does not swing.
Consume
} else {
Pass
}
}
// JukeboxBlock.useWithoutItem: ejecting a disc is SUCCESS (the
// insert is useItemOn). An empty jukebox passes.
Class::Jukebox if table.bool(state, p.has_record) => Success,
+37
View File
@@ -1945,6 +1945,43 @@ fn a_broken_moving_piston_drops_the_block_it_carries() {
);
}
/// `MovingPistonBlock.useWithoutItem`: a moving piston with no block entity (its
/// move is not in flight) is removed by the click, and one that is still moving
/// keeps its block and passes the click on.
#[test]
fn a_moving_piston_without_a_block_entity_is_removed_by_a_click() {
let mut w = world!();
let moving = w.id("moving_piston", &[("facing", "east"), ("type", "normal")]);
let orphan = BlockPos::new(0, 1, 0);
let in_flight = BlockPos::new(4, 1, 4);
let air = w.id("air", &[]);
for pos in [orphan, in_flight] {
w.put(pos, moving);
}
w.ticks.moving_pistons.push((
in_flight,
MovingPiston {
moved_state: w.id("sand", &[]),
direction: Direction::East,
extending: true,
source: false,
progress: 0.5,
progress_o: 0.4,
last_ticked: 0,
death_ticks: 0,
id: 0,
},
));
assert_eq!(w.use_block(orphan), InteractionResult::Consume);
assert_eq!(w.get(orphan), air, "the orphan moving piston is removed");
assert_eq!(w.use_block(in_flight), InteractionResult::Pass);
assert_eq!(
w.get(in_flight),
moving,
"a moving piston in flight keeps its block"
);
}
#[test]
fn dripstone_fills_cauldrons() {
let mut w = world!();
+3 -2
View File
@@ -79,8 +79,9 @@ addition; a change to one starts `/api/v2`, served beside `/api/v1`.
- **Backups**: restore a single dimension.
- **Mods on a dedicated server**: list, turn on and off, and their settings, once native mods can be built for the
server ([mods.md](../mods.md), [native-mods.md](../native-mods.md#multiplayer)).
- **Vanilla's management protocol**: `serversettings/*`, `gamerules`, the notifications and the status
heartbeat. They answer an internal error saying so; a kick's translatable message is sent as its key.
- **Vanilla's management protocol**: `serversettings/*`, `gamerules`, the notifications other than
`server/started` and `server/stopping`, and the status heartbeat. The methods answer an internal error saying
so; a kick's translatable message is sent as its key.
- **Compatibility tests**: golden replies per endpoint, and a `server.toml` from each release line.
## Changelog
+5 -1
View File
@@ -506,7 +506,11 @@ runtime or new dependency. Server-sent events can replace the polling later if a
`server/{status,save,stop,system_message}`, and the four lists (`allowlist/*`, `bans/*`, `ip_bans/*`,
`operators/*`), whose changes the game applies itself (its control endpoint's `management` request, in
`game/dedicated/management.rs`), so the source, expiry, log lines and kicks are vanilla's;
- planned: `serversettings/*` and `gamerules`, the notifications and heartbeat (from the event stream).
- notifications: the supervisor pushes them to every connected client as vanilla's `JsonRpcNotificationService`
does (a JSON-RPC request without an `id`, its one parameter in an array), through a queue and a thread so a slow
client never holds the game's output. Built: `server/started` (the game's `Done`) and `server/stopping`
(when the supervisor or the game's own `stop` begins the shutdown, once per run);
- planned: `serversettings/*` and `gamerules`, the other notifications and the heartbeat.
## 9. Benchmark plan (bots against vanilla and Paper)
+13
View File
@@ -32,6 +32,14 @@ class PrepareOutputTests(unittest.TestCase):
'block-behaviours.json': b'{"c": "Foo$$Lambda/0x00000ff0"}',
'export.log': b'run 2',
'assets/minecraft/sounds/a.ogg': b'two',
# From the launcher's asset store (CI clones the languages in later), not the preparer's export.
'assets/pack.mcmeta': b'{"language": {"fr_fr": {}}}',
'assets/minecraft/lang/fr_fr.json': b'{"a": "b"}',
'asset-index.json': b'{"objects": {}}',
# The game's run-time caches, there once a harness ran the game on this preparation.
'horizon-sprites.cache': b'sprites',
'horizon-data.bin': b'data',
'horizon-data.bin.123.tmp': b'half',
'horizon-prepare.json': b'{"output_version": 1}',
'.horizon-prepare.lock': b'',
}
@@ -48,6 +56,11 @@ class PrepareOutputTests(unittest.TestCase):
(Path(tmp) / 'b/horizon-resources.json').write_text(json.dumps(manifest))
(Path(tmp) / 'b/new.nbt').write_bytes(b'')
self.assertNotEqual(first, prepare_output.digest(Path(tmp) / 'b'))
# en_us and deprecated are the JAR's, made by the preparer: they count.
(Path(tmp) / 'b/new.nbt').unlink()
self.assertEqual(first, prepare_output.digest(Path(tmp) / 'b'))
(Path(tmp) / 'b/assets/minecraft/lang/en_us.json').write_bytes(b'{}')
self.assertNotEqual(first, prepare_output.digest(Path(tmp) / 'b'))
def test_the_versions_file_ends_at_the_constant(self):
versions = prepare_output.parse_versions('# comment\n1 aa\n2 bb\n')
+252
View File
@@ -0,0 +1,252 @@
#!/usr/bin/env python3
"""A minecart through a Nether portal on Horizon's dedicated server, tick by tick against vanilla's numbers.
python3 tools/cart_portal_e2e.py --bin-dir target/fast
PURPOSE: a minecart with a mob rider crosses a Nether portal with its rider (`Entity.teleportCrossDimension`
takes the passengers along and mounts them again), and an empty one arrives where and when vanilla's does:
the forward offset reset of `AbstractMinecart.getRelativePortalPosition`, the 0.4 clamp of its known movement
(`OldMinecartBehavior.getKnownMovement`), and the tick the portal is handled in (`handlePortal` opens the
minecart's own tick, and a cart that arrives in a level that ticks later in the same server tick moves in it
that tick).
WHY: before the fix a cart with a pig on it never left the portal, and an empty one landed 0.6 blocks off the
exit portal's centre a tick late, fell onto the ground between the portal and the rails and lost half its
speed each tick.
REFS: the expected numbers are what the vanilla 26.3 server printed for the same scenes (stone floor at
y=99, powered rails under the minecart and behind the exit portal, a minecart pushed at 0.4 blocks per
tick along 10 rails into the portal, ticks frozen and stepped one at a time, no player).
LOGS: the server's output goes to --out (a temporary folder by default), one file per lane.
READS-BY: tools/pr_ready.py does not run it; run it by hand after changing game/nether_portal.rs.
ENDS-WHEN: each lane has checked its ticks, or the first mismatch is printed (exit status 1).
"""
import argparse
from pathlib import Path
import re
import shutil
import sys
import tempfile
import time
sys.path.insert(0, str(Path(__file__).resolve().parent))
import lab # noqa: E402
from server_bench import DONE, Server # noqa: E402
PROPERTIES = {
'online-mode': 'false', 'level-type': 'minecraft:flat', 'spawn-protection': '0', 'view-distance': '4',
'simulation-distance': '4', 'pause-when-empty-seconds': '-1', 'spawn-monsters': 'false',
'spawn-animals': 'false', 'spawn-npcs': 'false', 'generate-structures': 'false', 'allow-flight': 'true',
'difficulty': 'normal', 'enable-command-block': 'true',
}
NETHER = 'execute in minecraft:the_nether run '
RAIL = 'minecraft:powered_rail[powered=true,shape=east_west]'
TOL = 1e-5
# Per lane: tick -> (cart x, y, speed along x, pig x, pig y or None). The numbers vanilla 26.3 printed. The pig's y
# is left out on the tick it arrives unticked in the Overworld (vanilla 100.0, Horizon puts it 0.1875 above the
# cart): it is the one value the rider's first, unticked sample does not match.
LANES = {
'overworld_to_nether_with_pig': {
'rider': True, 'z': 4, 'zn': 0, 'dest_z': 0.5,
'ticks': {
21: (9.732167700000005, 100.0625, 1.5984504303618299, 9.732167700000005, 100.25),
22: (1.9, 100.0225, 0.38, 1.9, 100.21),
23: (2.2799999952316283, 100.0, 0.38, 2.2799999952316283, 100.1875),
30: (4.867302549534948, 100.0625, 0.7883303007081885, 4.867302549534948, 100.25),
40: (8.867302549534951, 100.0625, 1.3569615856515638, 8.867302549534951, 100.25),
}},
'overworld_to_nether_empty': {
'rider': False, 'z': 4, 'zn': 0, 'dest_z': 0.5,
'ticks': {
21: (9.9, 100.0625, 1.0332454298233449, None, None),
22: (1.9, 100.0225, 0.38, None, None),
30: (5.0599999904632575, 100.0625, 0.6583788209356798, None, None),
40: (9.059999990463261, 100.0625, 0.9404627730163926, None, None),
}},
'nether_to_overworld_with_pig': {
'rider': True, 'z': 3, 'zn': 0, 'dest_z': 3.5,
'ticks': {
21: (9.732167700000005, 100.0625, 1.5984504303618299, 9.732167700000005, 100.25),
22: (84.5, 100.0625, 0.4, 84.5, -1),
23: (84.9, 100.0225, 0.38, 84.9, 100.21),
30: (87.46730254953496, 100.0625, 0.7305218663071098, 87.46730254953496, 100.25),
40: (91.46730254953502, 100.0625, 1.300864178186122, 91.46730254953502, 100.25),
}},
'nether_to_overworld_empty': {
'rider': False, 'z': 3, 'zn': 0, 'dest_z': 3.5,
'ticks': {
21: (9.9, 100.0625, 1.0332454298233449, None, None),
22: (84.5, 100.0625, 0.4, None, None),
23: (84.9, 100.0225, 0.38, None, None),
30: (87.65999999046329, 100.0625, 0.6233112718079998, None, None),
40: (91.65999999046335, 100.0625, 0.9171487218920757, None, None),
}},
}
class Console:
"""The server's console: each command's output is the lines before a marker `say` that follows it."""
def __init__(self, server):
self.server, self.n = server, 0
def __call__(self, command, timeout=60):
# Horizon keeps the level an `execute in` named for the next plain command: name the Overworld.
if command.split()[0] in ('forceload', 'fill', 'setblock', 'summon'):
command = 'execute in minecraft:overworld run ' + command
self.n += 1
mark = f'CARTMARK{self.n}X'
start = self.server.count()
self.server.console(command)
self.server.console('say ' + mark)
end = time.time() + timeout
while time.time() < end:
lines = self.server.since(start)
for i, line in enumerate(lines):
if mark in line:
return lines[:i]
time.sleep(0.02)
raise RuntimeError(f'no reply to {command!r} in {timeout} s')
def gametime(self):
for line in self('time query gametime'):
found = re.search(r'time is (\d+)', line)
if found:
return int(found.group(1))
raise RuntimeError('cannot read the game time')
def step(self, n):
before = self.gametime()
self(f'tick step {n}')
end = time.time() + 60
while time.time() < end:
if self.gametime() >= before + n:
return
time.sleep(0.05)
raise RuntimeError(f'tick step {n} did not finish')
def loaded(self, dim, x, y, z):
end = time.time() + 180
while time.time() < end:
if any('Test passed' in l for l in self(f'execute in minecraft:{dim} if loaded {x} {y} {z}')):
return
time.sleep(1)
raise RuntimeError(f'the chunk at {dim} {x} {y} {z} never loaded')
def numbers(self, selector, path):
for line in self(f'data get entity {selector} {path}'):
found = re.search(r'entity data: \[(.*)\]$', line)
if found:
return [float(v.rstrip('d')) for v in found.group(1).split(', ')]
return None
def build(console, name, lane):
"""The scenes of the sbs cartA and cartB runs, one lane: rails 0..9 into a portal at x=10, a portal behind
which rails run on in the other level."""
z, zn = lane['z'], lane['zn']
o2n = name.startswith('overworld')
cart_dim = '' if o2n else NETHER
far = (z, zn) if o2n else (zn, z)
# The destination level's portal and rails; the Overworld's forced chunks come first.
if o2n:
console(NETHER + 'forceload add 0 -16 15 47')
console(f'forceload add 0 {z} 15 {z}')
for dim, x, zz in (('overworld', 8, z), ('the_nether', 8, -10)):
console.loaded(dim, x, 100, zz)
else:
console(NETHER + 'forceload add 0 -16 15 47')
console(f'forceload add 80 {z} 95 {z}')
for dim, x, zz in (('overworld', 88, z), ('the_nether', 8, -10)):
console.loaded(dim, x, 100, zz)
console('tick freeze')
if o2n:
build_side(console, '', 0, 12, 10, z, rails=(0, 9))
build_side(console, NETHER, 0, 14, 1, zn, rails=(2, 12), redstone=(6, zn + 1))
console(f'setblock 4 100 {z + 1} minecraft:redstone_block')
start, at_z = 1.5, z + 0.5
run_in = ''
else:
build_side(console, '', 80, 95, 84, z, rails=(85, 95), redstone=(90, z + 1))
build_side(console, NETHER, 0, 14, 10, zn, rails=(0, 9))
console(NETHER + f'setblock 4 100 {zn + 1} minecraft:redstone_block')
start, at_z = 1.5, zn + 0.5
run_in = NETHER
passenger = (',Passengers:[{id:"minecraft:pig",Tags:["L0"],NoAI:1b,Silent:1b,PersistenceRequired:1b}]'
if lane['rider'] else '')
console(run_in + f'summon minecraft:minecart {start} 100.1 {at_z} {{Tags:["L0"],Motion:[0.4d,0.0d,0.0d]{passenger}}}')
del cart_dim, far
def build_side(console, prefix, x0, x1, portal_x, z, rails, redstone=None):
"""Stone floor, air above, an obsidian frame with a lit portal (axis z) at `portal_x`, powered rails."""
console(prefix + f'fill {x0} 95 {z - 2} {x1 + 2} 106 {z + 3} minecraft:air')
console(prefix + f'fill {x0} 99 {z - 2} {x1 + 2} 99 {z + 3} minecraft:stone')
console(prefix + f'fill {portal_x} 99 {z - 1} {portal_x} 103 {z + 2} minecraft:obsidian')
console(prefix + f'fill {portal_x} 100 {z} {portal_x} 102 {z + 1} minecraft:nether_portal[axis=z]')
console(prefix + f'fill {rails[0]} 100 {z} {rails[1]} 100 {z} {RAIL}')
if redstone:
console(prefix + f'setblock {redstone[0]} 100 {redstone[1]} minecraft:redstone_block')
def run_lane(bins, name, lane, out):
work = Path(tempfile.mkdtemp(prefix='hz-cart-portal-'))
folder = work / 'server'
folder.mkdir()
(folder / 'eula.txt').write_text('eula=true\n')
props = dict(PROPERTIES, **{'server-port': str(lab.free_port()), 'server-ip': '127.0.0.1'})
(folder / 'server.properties').write_text(''.join(f'{k}={v}\n' for k, v in props.items()))
command = [bins / 'horizon-client', '--resources', lab.RESOURCES, '--dedicated', '--nogui']
server = Server(command, folder, out / f'{name}.log')
failed = []
try:
server.wait_for(DONE, 300)
console = Console(server)
console('forceload add -16 -16 16 16')
time.sleep(3)
build(console, name, lane)
at, cart_sel, pig_sel = 0, '@e[type=minecraft:minecart,tag=L0,limit=1]', '@e[type=minecraft:pig,tag=L0,limit=1]'
for tick, (x, y, speed, pig_x, pig_y) in lane['ticks'].items():
console.step(tick - at)
at = tick
pos = console.numbers(cart_sel, 'Pos')
motion = console.numbers(cart_sel, 'Motion')
expect = [x, y, lane['dest_z'] if tick >= 22 else lane['z'] + 0.5 if name.startswith('overworld')
else lane['zn'] + 0.5]
if tick == 21:
expect[2] = pos[2] if pos else 0.0
if not pos or any(abs(a - b) > TOL for a, b in zip(pos, expect)):
failed.append(f'{name} t{tick}: cart at {pos}, vanilla {expect}')
if not motion or abs(motion[0] - speed) > TOL:
failed.append(f'{name} t{tick}: cart speed {motion and motion[0]}, vanilla {speed}')
if lane['rider'] and tick >= 22:
pig = console.numbers(pig_sel, 'Pos')
bad = not pig or abs(pig[0] - pig_x) > TOL or abs(pig[2] - lane['dest_z']) > TOL
if pig_y is not None and pig_y > 0 and pig and abs(pig[1] - pig_y) > TOL:
bad = True
if bad:
failed.append(f'{name} t{tick}: pig at {pig}, vanilla x {pig_x} y {pig_y} z {lane["dest_z"]}')
print(f'{name} t{tick}: cart {pos} speed {motion and motion[0]}')
finally:
server.stop()
shutil.rmtree(work, ignore_errors=True)
return failed
def main():
parser = argparse.ArgumentParser(description=__doc__.split('\n')[0])
parser.add_argument('--bin-dir', default='target/fast', type=Path)
parser.add_argument('--out', type=Path)
parser.add_argument('--lane', action='append', choices=sorted(LANES))
args = parser.parse_args()
out = args.out or Path(tempfile.mkdtemp(prefix='cart-portal-logs-'))
out.mkdir(parents=True, exist_ok=True)
failed = []
for name in args.lane or LANES:
failed += run_lane(args.bin_dir.resolve(), name, LANES[name], out)
for line in failed:
print('FAIL', line)
print('cart portal: ' + ('FAILED' if failed else 'all lanes match vanilla'), f'(logs in {out})')
return 1 if failed else 0
if __name__ == '__main__':
sys.exit(main())
+5 -5
View File
@@ -13,7 +13,7 @@ ClientAdvancements.get port crates/horizon-client/src/advancements.rs::Tree::get
ClientChunkCache.isValidChunk skip:other chunks are stored by position, so there is no ring slot to check
ClientChunkCache.drop port crates/horizon-client-render/src/streaming.rs::Streaming::forget gap: minor: no ChunkEvent unload hook; block entities and renderer columns are cleared in Game::handle_packet
ClientChunkCache.getChunk skip:java lookup of a loaded chunk; Horizon reads world.chunks directly
ClientChunkCache.replaceBiomes missing minor: ChunksBiomes (id 13) has no arm in Game::handle_packet, so server biome changes (e.g. /fillbiome) do not reach the client until the chunk is resent
ClientChunkCache.replaceBiomes port crates/horizon-client/src/game/remote_chunks.rs::Game::remote_chunks_biomes each loaded column takes the packet's biomes per section, then it and its neighbours are marked dirty so their sections are drawn again (/fillbiome reaches the client)
ClientChunkCache.flipUpdateTrackingSets skip:other double-buffered change lists for LevelRenderer; Horizon hands changed columns to the renderer directly
ClientChunkCache.Storage.getIndex skip:java ring-buffer index into the view range; Horizon keys chunks by position
ClientChunkCache.Storage.replace port crates/horizon-client/src/game/remote_chunks.rs::Game::remote_chunk gap: minor: old chunk unload is in Streaming::forget, not here
@@ -48,10 +48,10 @@ ClientCommonPacketListenerImpl.addOrUpdatePackPrompt port crates/horizon-client/
ClientCommonPacketListenerImpl.PackConfirmScreen.<init> port crates/horizon-client/src/game/server_packs.rs::Game::handle_pack_push creates the prompt with its pending requests
ClientCommonPacketListenerImpl.PackConfirmScreen.update port crates/horizon-client/src/game/server_packs.rs::Game::handle_pack_push adds the new request to the open prompt
ClientConfigurationPacketListenerImpl.handleRegistryData port crates/horizon-client/src/net.rs::configure registry entries are collected in configuration
ClientConfigurationPacketListenerImpl.handleUpdateTags missing minor: UpdateTags is not read in configure (net.rs); Horizon uses its built-in tags, which match vanilla unless a datapack changes them
ClientConfigurationPacketListenerImpl.handleUpdateTags port crates/horizon-client/src/net.rs::configure UpdateTags fills the registry's tags before the play state, so a datapack's tags are the server's
ClientConfigurationPacketListenerImpl.handleSelectKnownPacks port crates/horizon-client/src/net.rs::configure gap: minor: always answers with no known packs, so the server sends every registry entry
ClientConfigurationPacketListenerImpl.runWithResources skip:data resource loading for known packs; Horizon reads its data from tables
ClientConfigurationPacketListenerImpl.handleCodeOfConduct port crates/horizon-client/src/net.rs::configure gap: player: accepts the server's code of conduct at once; the player never sees its text or can decline
ClientConfigurationPacketListenerImpl.handleCodeOfConduct port crates/horizon-client/src/net.rs::configure the screen shows the text with the not-again box, Acknowledge and Disconnect before the connection continues (game/code_of_conduct.rs)
ClientConfigurationPacketListenerImpl.handleConfigurationFinished port crates/horizon-client/src/net.rs::configure gap: minor: no registry filter for an integrated server (see filterRegistries)
ClientConfigurationPacketListenerImpl.filterRegistries skip:data Horizon's registries come from its own data; nothing to filter
ClientDebugSubscriber.requestedSubscriptions missing minor: Horizon never requests debug subscriptions; vanilla asks for server tick time while the F3+2 chart is on
@@ -88,7 +88,7 @@ ClientLevel.setBlock port crates/horizon-client/src/game/remote.rs::Game::set_bl
ClientLevel.<init> port crates/horizon-client/src/game/remote.rs::Game::enter_remote_dimension gap: minor: the environment layers are applied in Game::render
ClientLevel.addEnvironmentAttributeLayers port crates/horizon-client/src/game.rs::Game::render sky colour and sky light move during a lightning flash
ClientLevel.pollLightUpdates skip:other light is applied when it arrives or relit at once (light.rs relight); there is no queue to poll
ClientLevel.getPrecipitationAt port crates/horizon-client/src/game.rs::GameLevel::precipitation gap: minor: no check that the chunk is loaded; vanilla answers none for an unloaded column
ClientLevel.getPrecipitationAt port crates/horizon-client/src/game.rs::GameLevel::precipitation the column's biome decides; no column, no answer (the chunk lookup fails, as vanilla answers none for an unloaded column)
ClientLevel.removeBlockBreakingProgress inline crates/horizon-client/src/game.rs::Game::destroy_block_progress minor: the 400-tick expiry runs on every update, not every 20 ticks
ClientLevel.removeProgress inline crates/horizon-client/src/game.rs::Game::destroy_block_progress cracks are keyed by breaker id, so there are no per-position sets to tidy
ClientLevel.shouldTickDeath missing minor: death animations tick for every remote mob held; no simulation-distance check
@@ -104,7 +104,7 @@ ClientLevel.spawnParticle port crates/horizon-client/src/animate_tick.rs::try_sp
ClientLevel.spawnFluidParticle port crates/horizon-client/src/animate_tick.rs::try_spawn_drip drip particle over a solid top (doAnimateTick)
ClientLevel.fillReportDetails skip:debug crash report details only
ClientLevel.playPlayerSound skip:other no caller in the 26.3 client sources
ClientLevel.playSound missing player: distance-delayed sounds (over 10 blocks, sqrt(distance)/40 s) play at once, so far fireworks and similar sounds arrive early; audio.rs has no delay
ClientLevel.playSound port crates/horizon-client-render/src/audio.rs::Audio::play_distance_delayed a sound over 10 blocks from the camera starts sqrt(distance)/40 s later (Audio::play_after)
ClientLevel.getRelativeTickSpeed skip:other no caller in the 26.3 client sources
ClientLevel.destroyBlockProgress port crates/horizon-client/src/game.rs::Game::destroy_block_progress sets or clears another player's crack on a block
ClientLevel.addAlwaysVisibleParticle port crates/horizon-client-render/src/particles.rs::ParticleEngine::add_with always-show flag set
1 AccountProfileKeyPairManager.prepareKeyPair port crates/horizon-client-render/src/auth.rs::prepare_chat_keys on a background thread, at start and on each online-mode join
13 ClientChunkCache.isValidChunk skip:other chunks are stored by position, so there is no ring slot to check
14 ClientChunkCache.drop port crates/horizon-client-render/src/streaming.rs::Streaming::forget gap: minor: no ChunkEvent unload hook; block entities and renderer columns are cleared in Game::handle_packet
15 ClientChunkCache.getChunk skip:java lookup of a loaded chunk; Horizon reads world.chunks directly
16 ClientChunkCache.replaceBiomes missing port crates/horizon-client/src/game/remote_chunks.rs::Game::remote_chunks_biomes minor: ChunksBiomes (id 13) has no arm in Game::handle_packet, so server biome changes (e.g. /fillbiome) do not reach the client until the chunk is resent each loaded column takes the packet's biomes per section, then it and its neighbours are marked dirty so their sections are drawn again (/fillbiome reaches the client)
17 ClientChunkCache.flipUpdateTrackingSets skip:other double-buffered change lists for LevelRenderer; Horizon hands changed columns to the renderer directly
18 ClientChunkCache.Storage.getIndex skip:java ring-buffer index into the view range; Horizon keys chunks by position
19 ClientChunkCache.Storage.replace port crates/horizon-client/src/game/remote_chunks.rs::Game::remote_chunk gap: minor: old chunk unload is in Streaming::forget, not here
48 ClientCommonPacketListenerImpl.PackConfirmScreen.<init> port crates/horizon-client/src/game/server_packs.rs::Game::handle_pack_push creates the prompt with its pending requests
49 ClientCommonPacketListenerImpl.PackConfirmScreen.update port crates/horizon-client/src/game/server_packs.rs::Game::handle_pack_push adds the new request to the open prompt
50 ClientConfigurationPacketListenerImpl.handleRegistryData port crates/horizon-client/src/net.rs::configure registry entries are collected in configuration
51 ClientConfigurationPacketListenerImpl.handleUpdateTags missing port crates/horizon-client/src/net.rs::configure minor: UpdateTags is not read in configure (net.rs); Horizon uses its built-in tags, which match vanilla unless a datapack changes them UpdateTags fills the registry's tags before the play state, so a datapack's tags are the server's
52 ClientConfigurationPacketListenerImpl.handleSelectKnownPacks port crates/horizon-client/src/net.rs::configure gap: minor: always answers with no known packs, so the server sends every registry entry
53 ClientConfigurationPacketListenerImpl.runWithResources skip:data resource loading for known packs; Horizon reads its data from tables
54 ClientConfigurationPacketListenerImpl.handleCodeOfConduct port crates/horizon-client/src/net.rs::configure gap: player: accepts the server's code of conduct at once; the player never sees its text or can decline the screen shows the text with the not-again box, Acknowledge and Disconnect before the connection continues (game/code_of_conduct.rs)
55 ClientConfigurationPacketListenerImpl.handleConfigurationFinished port crates/horizon-client/src/net.rs::configure gap: minor: no registry filter for an integrated server (see filterRegistries)
56 ClientConfigurationPacketListenerImpl.filterRegistries skip:data Horizon's registries come from its own data; nothing to filter
57 ClientDebugSubscriber.requestedSubscriptions missing minor: Horizon never requests debug subscriptions; vanilla asks for server tick time while the F3+2 chart is on
88 ClientLevel.<init> port crates/horizon-client/src/game/remote.rs::Game::enter_remote_dimension gap: minor: the environment layers are applied in Game::render
89 ClientLevel.addEnvironmentAttributeLayers port crates/horizon-client/src/game.rs::Game::render sky colour and sky light move during a lightning flash
90 ClientLevel.pollLightUpdates skip:other light is applied when it arrives or relit at once (light.rs relight); there is no queue to poll
91 ClientLevel.getPrecipitationAt port crates/horizon-client/src/game.rs::GameLevel::precipitation gap: minor: no check that the chunk is loaded; vanilla answers none for an unloaded column the column's biome decides; no column, no answer (the chunk lookup fails, as vanilla answers none for an unloaded column)
92 ClientLevel.removeBlockBreakingProgress inline crates/horizon-client/src/game.rs::Game::destroy_block_progress minor: the 400-tick expiry runs on every update, not every 20 ticks
93 ClientLevel.removeProgress inline crates/horizon-client/src/game.rs::Game::destroy_block_progress cracks are keyed by breaker id, so there are no per-position sets to tidy
94 ClientLevel.shouldTickDeath missing minor: death animations tick for every remote mob held; no simulation-distance check
104 ClientLevel.spawnFluidParticle port crates/horizon-client/src/animate_tick.rs::try_spawn_drip drip particle over a solid top (doAnimateTick)
105 ClientLevel.fillReportDetails skip:debug crash report details only
106 ClientLevel.playPlayerSound skip:other no caller in the 26.3 client sources
107 ClientLevel.playSound missing port crates/horizon-client-render/src/audio.rs::Audio::play_distance_delayed player: distance-delayed sounds (over 10 blocks, sqrt(distance)/40 s) play at once, so far fireworks and similar sounds arrive early; audio.rs has no delay a sound over 10 blocks from the camera starts sqrt(distance)/40 s later (Audio::play_after)
108 ClientLevel.getRelativeTickSpeed skip:other no caller in the 26.3 client sources
109 ClientLevel.destroyBlockProgress port crates/horizon-client/src/game.rs::Game::destroy_block_progress sets or clears another player's crack on a block
110 ClientLevel.addAlwaysVisibleParticle port crates/horizon-client-render/src/particles.rs::ParticleEngine::add_with always-show flag set
+11 -11
View File
@@ -8,12 +8,12 @@ ClientPacketListener.handleTeleportEntity inline crates/horizon-client/src/game/
ClientPacketListener.handleTickingState port crates/horizon-client/src/game/remote.rs::Game::handle_packet TickingState arm: clock rate and frozen flag
ClientPacketListener.handleTickingStep port crates/horizon-client/src/game/remote.rs::Game::handle_packet TickingStep arm: frozen ticks to run
ClientPacketListener.handleMoveEntity port crates/horizon-client/src/game/remote.rs::Game::move_entity relative move and rotation from the Pos, PosRot and Rot packets
ClientPacketListener.handleMinecartAlongTrack missing player: the server's rail steps for a minecart are dropped, so a cart on a dedicated server follows the client's own rail physics and can drift from where the server has it
ClientPacketListener.handleMinecartAlongTrack port crates/horizon-client/src/game/remote.rs::Game::remote_move_minecart the server's steps join the cart's own queue while it runs NewMinecartBehavior
ClientPacketListener.handleRemoveEntities port crates/horizon-client/src/game/remote.rs::Game::remove_entity gap: minor: a removed vehicle carrying the player is not remembered for a later teleport
ClientPacketListener.setValuesFromPositionPacket port crates/horizon-client/src/game/remote.rs::Game::remote_teleport gap: minor: the entity case (TeleportEntity) is in the handle_packet arm, not here
ClientPacketListener.handleRotatePlayer port crates/horizon-client/src/game/remote.rs::Game::handle_packet PlayerRotation arm; gap: minor: no ServerboundMovePlayerPacket.Rot reply is sent
ClientPacketListener.handleChunkBlocksUpdate port crates/horizon-client/src/game/remote.rs::Game::handle_packet SectionBlocksUpdate arm: each change through the block update
ClientPacketListener.handleChunksBiomes missing player: biome updates for loaded chunks are dropped, so grass, leaves and water keep the old biome colour
ClientPacketListener.handleChunksBiomes port crates/horizon-client/src/game/remote_chunks.rs::Game::remote_chunks_biomes each loaded column replaces its biomes, then its sections and its neighbours' are drawn again
ClientPacketListener.enableChunkLight inline crates/horizon-client-render/src/streaming.rs::Streaming::insert_remote gap: minor: the air-only section status is not set; every section of the 3x3 columns is marked dirty
ClientPacketListener.handleForgetLevelChunk port crates/horizon-client/src/game/remote.rs::Game::handle_packet ForgetLevelChunk arm: Streaming::forget, block entities and renderer columns dropped
ClientPacketListener.queueLightRemoval inline crates/horizon-client-render/src/streaming.rs::Streaming::forget gap: minor: the column's light state is dropped with the column; no per-section light clearing
@@ -26,8 +26,8 @@ ClientPacketListener.handleDeleteChat port crates/horizon-client/src/game/remote
ClientPacketListener.handleAnimate port crates/horizon-client/src/game/remote.rs::Game::remote_animate action 0 stops sleeping, 1 and 2 emit crit and enchanted hit particles
ClientPacketListener.handleHurtAnimation port crates/horizon-client/src/game/remote.rs::Game::handle_packet HurtAnimation arm: own player's hurt tilt, or a mob's hurt time
ClientPacketListener.handleSetSpawn port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetDefaultSpawnPosition arm; gap: minor: only an overworld spawn is kept, other dimensions are dropped
ClientPacketListener.handleAddTransientBlockPacket missing minor: the transient block shown while a falling block lands is not drawn; the block update still arrives
ClientPacketListener.findTotem missing player: the totem of undying pop-up over the screen is not shown when death protection triggers; Horizon has no item activation
ClientPacketListener.handleAddTransientBlockPacket port crates/horizon-client/src/game/transient_blocks.rs::Game::add_transient_block the block is queued, shown for its time to live and dropped once its section is drawn again
ClientPacketListener.findTotem port crates/horizon-client/src/game/totem.rs::Game::find_totem the first hand's item with death_protection, else a new totem, then LocalPlayer.displayItemActivation
ClientPacketListener.handleSetExperience port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetExperience arm
ClientPacketListener.determineLevelLoadingReason port crates/horizon-client-render/src/level_loading.rs::reason_for_respawn the reason of a Respawn packet: death, a Nether or an End crossing
ClientPacketListener.handleOpenScreen port crates/horizon-client/src/game/remote.rs::Game::remote_open_screen
@@ -40,14 +40,14 @@ ClientPacketListener.handleContainerClose port crates/horizon-client/src/game/re
ClientPacketListener.handleBlockEvent port crates/horizon-client/src/game/remote.rs::Game::handle_packet BlockEvent arm: client block event, then the block entity event
ClientPacketListener.handleGameEvent port crates/horizon-client/src/game/remote.rs::Game::handle_packet GameEvent arm (weather, mode, credits, respawn, arrow hit, sting, limited crafting, chunks load start); gap: minor: demo mode (5) is not shown
ClientPacketListener.openDemoIntroScreen skip:other demo mode only (GameEvent 5); Horizon has no demo mode
ClientPacketListener.startWaitingForNewLevel missing player: no level loading screen after a dimension change or respawn on a dedicated server; a join shows only the 'Loading terrain' status text
ClientPacketListener.startWaitingForNewLevel inline crates/horizon-client/src/game/remote.rs::Game::enter_remote_dimension the tracker starts and the LevelLoadingScreen draws until the player's section is shown
ClientPacketListener.handleMapItemData port crates/horizon-client/src/game/maps.rs::Game::remote_map_item_data
ClientPacketListener.handleUpdateAdvancementsPacket port crates/horizon-client/src/game/advancements.rs::Game::remote_advancements UpdateAdvancements arm passes the added icons
ClientPacketListener.handleSelectAdvancementsTab missing minor: the server's advancement tab choice is ignored
ClientPacketListener.handleCommands port crates/horizon-client/src/game/remote.rs::Game::handle_packet Commands arm: command tree from the packet, merged with Horizon's own
ClientPacketListener.handleStopSoundEvent port crates/horizon-client/src/game/remote.rs::Game::handle_packet StopSound arm: audio stop_sounds
ClientPacketListener.handleUpdateRecipes skip:data Horizon reads recipes from its own tables; the server's list is not read
ClientPacketListener.handleLookAt missing player: a server command that turns the camera (/faceplayer, /tp with facing) does not turn this player's view
ClientPacketListener.handleLookAt port crates/horizon-client/src/game/remote.rs::Game::remote_look_at the anchor's point, or the entity's, turns the player's view
ClientPacketListener.handleTagQueryPacket port crates/horizon-client/src/game/debug_keys.rs::Game::answer_tag_query reached from the TagQuery arm of remote.rs handle_packet
ClientPacketListener.handleAwardStats port crates/horizon-client/src/game/remote.rs::Game::handle_packet AwardStats arm: known stat keys into the stats values
ClientPacketListener.handleRecipeBookAdd port crates/horizon-client/src/game/recipe_book.rs::Game::remote_recipe_book_add
@@ -57,7 +57,7 @@ ClientPacketListener.refreshRecipeBook inline crates/horizon-client/src/game/rec
ClientPacketListener.updateTags skip:data the server's tag payload is not read; tags come from Horizon's own data (net.rs configure ignores UpdateTags)
ClientPacketListener.handleUpdateTags skip:data the server's tag payload is not read; tags come from Horizon's own data (net.rs configure ignores UpdateTags)
ClientPacketListener.handleChangeDifficulty port crates/horizon-client/src/game/remote.rs::Game::handle_packet ChangeDifficulty arm
ClientPacketListener.handleSetCamera missing player: a spectator's view does not attach to the entity it spectates (for example /spectate)
ClientPacketListener.handleSetCamera port crates/horizon-client/src/game/remote_camera.rs::Game::remote_set_camera the view follows the entity; the player's own id gives it back
ClientPacketListener.handleInitializeBorder port crates/horizon-client/src/game/remote.rs::Game::handle_packet InitializeBorder arm: centre, size or lerp, absolute max, warnings
ClientPacketListener.handleSetBorderCenter port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetBorderCenter arm
ClientPacketListener.handleSetBorderLerpSize port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetBorderLerpSize arm
@@ -75,7 +75,7 @@ ClientPacketListener.handlePlayerInfoRemove port crates/horizon-client/src/game/
ClientPacketListener.handlePlayerInfoUpdate port crates/horizon-client/src/game/remote.rs::Game::handle_packet PlayerInfoUpdate arm: new entries, then listing, latency, name, hat, list order and game mode for tab players
ClientPacketListener.applyPlayerInfoUpdate inline crates/horizon-client/src/game/remote.rs::Game::handle_packet gap: minor: the own player's game-mode update does not change the local mode; INITIALIZE_CHAT is ignored
ClientPacketListener.initializeChatSession port crates/horizon-client/src/game/remote.rs::Remote::new_chat_validator the session each INITIALIZE_CHAT entry carries is kept and checked against the services' profile-key keys when the player's chat is first read
ClientPacketListener.enforcesSecureChat missing minor: the Login flag enforces_secure_chat is not read
ClientPacketListener.enforcesSecureChat inline crates/horizon-client/src/game/remote.rs::Game::handle_packet the Login flag is kept as serverEnforcesSecureChat and the fallback message validator enforces it
ClientPacketListener.handleGameRuleValues port crates/horizon-client/src/game/lan_screen.rs::Game::remote_world_options_rules
ClientPacketListener.handleSoundEntityEvent port crates/horizon-client/src/game/remote.rs::Game::handle_packet SoundEntity arm: the sound plays where the tracked entity is
ClientPacketListener.handleAddObjective port crates/horizon-client/src/game/remote.rs::Game::remote_objective add, remove and update of an objective
@@ -88,7 +88,7 @@ ClientPacketListener.handlePlaceRecipe port crates/horizon-client/src/game/recip
ClientPacketListener.handleLightUpdatePacket port crates/horizon-client/src/game/remote_chunks.rs::Game::remote_light_update
ClientPacketListener.handleMerchantOffers port crates/horizon-client/src/game/remote.rs::Game::remote_merchant_offers
ClientPacketListener.handleSetChunkCacheRadius port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetChunkCacheRadius arm: streaming radius, columns out of range dropped
ClientPacketListener.handleSetSimulationDistance missing minor: the server's simulation distance is not read
ClientPacketListener.handleSetSimulationDistance inline crates/horizon-client/src/game/remote.rs::Game::handle_packet SetSimulationDistance arm: ClientLevel.serverSimulationDistance
ClientPacketListener.handleSetChunkCacheCenter port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetChunkCacheCenter arm
ClientPacketListener.handleBlockChangedAck port crates/horizon-client/src/game/remote.rs::Game::end_predictions
ClientPacketListener.handleBundlePacket inline crates/horizon-client/src/net.rs::receive_play bundle delimiters are dropped; the packets inside arrive as a stream and are handled one by one, in order
@@ -117,7 +117,7 @@ ClientPacketListener.setKeyPair port crates/horizon-client/src/game/remote_chat.
ClientRegistryLayer.createRegistryAccess skip:data registry layers come from Horizon's own tables
ClientSuggestionProvider.getOnlinePlayerNames port crates/horizon-client/src/game/remote.rs::Game::remote_online_names sorted names of the server's player list
ClientSuggestionProvider.getCustomTabSuggestions port crates/horizon-client-core/src/registries.rs::Registries::custom_tab_suggestions the player names and the server's custom completions, each once
ClientSuggestionProvider.getSelectedEntities missing minor: no suggestion for the entity under the crosshair
ClientSuggestionProvider.getSelectedEntities port crates/horizon-client/src/game/pick_entity.rs::Game::crosshair_entity_uuid the UUID of the entity under the crosshair, before any block; the command suggestions add it
ClientSuggestionProvider.suggestRegistryElements port crates/horizon-command/src/args.rs::suggest_resources registry ids come from Horizon's registry tables
ClientSuggestionProvider.getRelevantCoordinates port crates/horizon-command/src/args.rs::suggest_coordinates the block under the crosshair for block positions
ClientSuggestionProvider.getAbsoluteCoordinates port crates/horizon-command/src/args.rs::suggest_coordinates gap: minor: the exact hit point is not offered; Vec3 arguments only get ~
@@ -129,7 +129,7 @@ LevelLoadTracker.startClientLoad inline crates/horizon-client/src/game/remote.rs
LevelLoadTracker.isLevelReady inline crates/horizon-client/src/game/remote.rs::Game::tick_remote_level gap: minor: readiness is the player's column being lit; vanilla's close delay is not kept
LevelLoadTracker.getPlayerCompiledSectionCallback inline crates/horizon-client/src/game/remote.rs::Game::tick_remote_level gap: minor: the gate is the column being lit, not the section being compiled
LevelLoadTracker.WaitingForPlayerChunk.isReady inline crates/horizon-client/src/game/remote.rs::Game::tick_remote_level gap: minor: no timeout and no spectator or dead exemption
LevelLoadTracker.WaitingForServer.loadingPacketsReceived missing minor: the server's level-chunks-start event (GameEvent 13) is ignored
LevelLoadTracker.WaitingForServer.loadingPacketsReceived port crates/horizon-client-render/src/level_loading.rs::LevelLoadTracker::loading_packets_received GameEvent 13 (LEVEL_CHUNKS_LOAD_START) moves the wait on to the player's chunk
MultiPlayerGameMode.startPrediction port crates/horizon-client/src/game/remote.rs::Game::next_sequence gap: minor: only the sequence number; each caller builds and sends its own packet
MultiPlayerGameMode.ensureHasSentCarriedItem inline crates/horizon-client/src/game/remote.rs::Game::remote_send_state the SetCarriedItem check, made on every send
MultiPlayerGameMode.performUseItemOn port crates/horizon-client/src/game/remote.rs::Game::remote_use_block gap: minor: no world-border check, and UseItemOn always sends world_border false
1 ClientPacketListener.<init> port crates/horizon-client/src/game/remote.rs::Game::join_server gap: minor: clears the chat on join; vanilla restores chat state kept across a reconfiguration
8 ClientPacketListener.handleTickingState port crates/horizon-client/src/game/remote.rs::Game::handle_packet TickingState arm: clock rate and frozen flag
9 ClientPacketListener.handleTickingStep port crates/horizon-client/src/game/remote.rs::Game::handle_packet TickingStep arm: frozen ticks to run
10 ClientPacketListener.handleMoveEntity port crates/horizon-client/src/game/remote.rs::Game::move_entity relative move and rotation from the Pos, PosRot and Rot packets
11 ClientPacketListener.handleMinecartAlongTrack missing port crates/horizon-client/src/game/remote.rs::Game::remote_move_minecart player: the server's rail steps for a minecart are dropped, so a cart on a dedicated server follows the client's own rail physics and can drift from where the server has it the server's steps join the cart's own queue while it runs NewMinecartBehavior
12 ClientPacketListener.handleRemoveEntities port crates/horizon-client/src/game/remote.rs::Game::remove_entity gap: minor: a removed vehicle carrying the player is not remembered for a later teleport
13 ClientPacketListener.setValuesFromPositionPacket port crates/horizon-client/src/game/remote.rs::Game::remote_teleport gap: minor: the entity case (TeleportEntity) is in the handle_packet arm, not here
14 ClientPacketListener.handleRotatePlayer port crates/horizon-client/src/game/remote.rs::Game::handle_packet PlayerRotation arm; gap: minor: no ServerboundMovePlayerPacket.Rot reply is sent
15 ClientPacketListener.handleChunkBlocksUpdate port crates/horizon-client/src/game/remote.rs::Game::handle_packet SectionBlocksUpdate arm: each change through the block update
16 ClientPacketListener.handleChunksBiomes missing port crates/horizon-client/src/game/remote_chunks.rs::Game::remote_chunks_biomes player: biome updates for loaded chunks are dropped, so grass, leaves and water keep the old biome colour each loaded column replaces its biomes, then its sections and its neighbours' are drawn again
17 ClientPacketListener.enableChunkLight inline crates/horizon-client-render/src/streaming.rs::Streaming::insert_remote gap: minor: the air-only section status is not set; every section of the 3x3 columns is marked dirty
18 ClientPacketListener.handleForgetLevelChunk port crates/horizon-client/src/game/remote.rs::Game::handle_packet ForgetLevelChunk arm: Streaming::forget, block entities and renderer columns dropped
19 ClientPacketListener.queueLightRemoval inline crates/horizon-client-render/src/streaming.rs::Streaming::forget gap: minor: the column's light state is dropped with the column; no per-section light clearing
26 ClientPacketListener.handleAnimate port crates/horizon-client/src/game/remote.rs::Game::remote_animate action 0 stops sleeping, 1 and 2 emit crit and enchanted hit particles
27 ClientPacketListener.handleHurtAnimation port crates/horizon-client/src/game/remote.rs::Game::handle_packet HurtAnimation arm: own player's hurt tilt, or a mob's hurt time
28 ClientPacketListener.handleSetSpawn port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetDefaultSpawnPosition arm; gap: minor: only an overworld spawn is kept, other dimensions are dropped
29 ClientPacketListener.handleAddTransientBlockPacket missing port crates/horizon-client/src/game/transient_blocks.rs::Game::add_transient_block minor: the transient block shown while a falling block lands is not drawn; the block update still arrives the block is queued, shown for its time to live and dropped once its section is drawn again
30 ClientPacketListener.findTotem missing port crates/horizon-client/src/game/totem.rs::Game::find_totem player: the totem of undying pop-up over the screen is not shown when death protection triggers; Horizon has no item activation the first hand's item with death_protection, else a new totem, then LocalPlayer.displayItemActivation
31 ClientPacketListener.handleSetExperience port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetExperience arm
32 ClientPacketListener.determineLevelLoadingReason port crates/horizon-client-render/src/level_loading.rs::reason_for_respawn the reason of a Respawn packet: death, a Nether or an End crossing
33 ClientPacketListener.handleOpenScreen port crates/horizon-client/src/game/remote.rs::Game::remote_open_screen
40 ClientPacketListener.handleBlockEvent port crates/horizon-client/src/game/remote.rs::Game::handle_packet BlockEvent arm: client block event, then the block entity event
41 ClientPacketListener.handleGameEvent port crates/horizon-client/src/game/remote.rs::Game::handle_packet GameEvent arm (weather, mode, credits, respawn, arrow hit, sting, limited crafting, chunks load start); gap: minor: demo mode (5) is not shown
42 ClientPacketListener.openDemoIntroScreen skip:other demo mode only (GameEvent 5); Horizon has no demo mode
43 ClientPacketListener.startWaitingForNewLevel missing inline crates/horizon-client/src/game/remote.rs::Game::enter_remote_dimension player: no level loading screen after a dimension change or respawn on a dedicated server; a join shows only the 'Loading terrain' status text the tracker starts and the LevelLoadingScreen draws until the player's section is shown
44 ClientPacketListener.handleMapItemData port crates/horizon-client/src/game/maps.rs::Game::remote_map_item_data
45 ClientPacketListener.handleUpdateAdvancementsPacket port crates/horizon-client/src/game/advancements.rs::Game::remote_advancements UpdateAdvancements arm passes the added icons
46 ClientPacketListener.handleSelectAdvancementsTab missing minor: the server's advancement tab choice is ignored
47 ClientPacketListener.handleCommands port crates/horizon-client/src/game/remote.rs::Game::handle_packet Commands arm: command tree from the packet, merged with Horizon's own
48 ClientPacketListener.handleStopSoundEvent port crates/horizon-client/src/game/remote.rs::Game::handle_packet StopSound arm: audio stop_sounds
49 ClientPacketListener.handleUpdateRecipes skip:data Horizon reads recipes from its own tables; the server's list is not read
50 ClientPacketListener.handleLookAt missing port crates/horizon-client/src/game/remote.rs::Game::remote_look_at player: a server command that turns the camera (/faceplayer, /tp with facing) does not turn this player's view the anchor's point, or the entity's, turns the player's view
51 ClientPacketListener.handleTagQueryPacket port crates/horizon-client/src/game/debug_keys.rs::Game::answer_tag_query reached from the TagQuery arm of remote.rs handle_packet
52 ClientPacketListener.handleAwardStats port crates/horizon-client/src/game/remote.rs::Game::handle_packet AwardStats arm: known stat keys into the stats values
53 ClientPacketListener.handleRecipeBookAdd port crates/horizon-client/src/game/recipe_book.rs::Game::remote_recipe_book_add
57 ClientPacketListener.updateTags skip:data the server's tag payload is not read; tags come from Horizon's own data (net.rs configure ignores UpdateTags)
58 ClientPacketListener.handleUpdateTags skip:data the server's tag payload is not read; tags come from Horizon's own data (net.rs configure ignores UpdateTags)
59 ClientPacketListener.handleChangeDifficulty port crates/horizon-client/src/game/remote.rs::Game::handle_packet ChangeDifficulty arm
60 ClientPacketListener.handleSetCamera missing port crates/horizon-client/src/game/remote_camera.rs::Game::remote_set_camera player: a spectator's view does not attach to the entity it spectates (for example /spectate) the view follows the entity; the player's own id gives it back
61 ClientPacketListener.handleInitializeBorder port crates/horizon-client/src/game/remote.rs::Game::handle_packet InitializeBorder arm: centre, size or lerp, absolute max, warnings
62 ClientPacketListener.handleSetBorderCenter port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetBorderCenter arm
63 ClientPacketListener.handleSetBorderLerpSize port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetBorderLerpSize arm
75 ClientPacketListener.handlePlayerInfoUpdate port crates/horizon-client/src/game/remote.rs::Game::handle_packet PlayerInfoUpdate arm: new entries, then listing, latency, name, hat, list order and game mode for tab players
76 ClientPacketListener.applyPlayerInfoUpdate inline crates/horizon-client/src/game/remote.rs::Game::handle_packet gap: minor: the own player's game-mode update does not change the local mode; INITIALIZE_CHAT is ignored
77 ClientPacketListener.initializeChatSession port crates/horizon-client/src/game/remote.rs::Remote::new_chat_validator the session each INITIALIZE_CHAT entry carries is kept and checked against the services' profile-key keys when the player's chat is first read
78 ClientPacketListener.enforcesSecureChat missing inline crates/horizon-client/src/game/remote.rs::Game::handle_packet minor: the Login flag enforces_secure_chat is not read the Login flag is kept as serverEnforcesSecureChat and the fallback message validator enforces it
79 ClientPacketListener.handleGameRuleValues port crates/horizon-client/src/game/lan_screen.rs::Game::remote_world_options_rules
80 ClientPacketListener.handleSoundEntityEvent port crates/horizon-client/src/game/remote.rs::Game::handle_packet SoundEntity arm: the sound plays where the tracked entity is
81 ClientPacketListener.handleAddObjective port crates/horizon-client/src/game/remote.rs::Game::remote_objective add, remove and update of an objective
88 ClientPacketListener.handleLightUpdatePacket port crates/horizon-client/src/game/remote_chunks.rs::Game::remote_light_update
89 ClientPacketListener.handleMerchantOffers port crates/horizon-client/src/game/remote.rs::Game::remote_merchant_offers
90 ClientPacketListener.handleSetChunkCacheRadius port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetChunkCacheRadius arm: streaming radius, columns out of range dropped
91 ClientPacketListener.handleSetSimulationDistance missing inline crates/horizon-client/src/game/remote.rs::Game::handle_packet minor: the server's simulation distance is not read SetSimulationDistance arm: ClientLevel.serverSimulationDistance
92 ClientPacketListener.handleSetChunkCacheCenter port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetChunkCacheCenter arm
93 ClientPacketListener.handleBlockChangedAck port crates/horizon-client/src/game/remote.rs::Game::end_predictions
94 ClientPacketListener.handleBundlePacket inline crates/horizon-client/src/net.rs::receive_play bundle delimiters are dropped; the packets inside arrive as a stream and are handled one by one, in order
117 ClientRegistryLayer.createRegistryAccess skip:data registry layers come from Horizon's own tables
118 ClientSuggestionProvider.getOnlinePlayerNames port crates/horizon-client/src/game/remote.rs::Game::remote_online_names sorted names of the server's player list
119 ClientSuggestionProvider.getCustomTabSuggestions port crates/horizon-client-core/src/registries.rs::Registries::custom_tab_suggestions the player names and the server's custom completions, each once
120 ClientSuggestionProvider.getSelectedEntities missing port crates/horizon-client/src/game/pick_entity.rs::Game::crosshair_entity_uuid minor: no suggestion for the entity under the crosshair the UUID of the entity under the crosshair, before any block; the command suggestions add it
121 ClientSuggestionProvider.suggestRegistryElements port crates/horizon-command/src/args.rs::suggest_resources registry ids come from Horizon's registry tables
122 ClientSuggestionProvider.getRelevantCoordinates port crates/horizon-command/src/args.rs::suggest_coordinates the block under the crosshair for block positions
123 ClientSuggestionProvider.getAbsoluteCoordinates port crates/horizon-command/src/args.rs::suggest_coordinates gap: minor: the exact hit point is not offered; Vec3 arguments only get ~
129 LevelLoadTracker.isLevelReady inline crates/horizon-client/src/game/remote.rs::Game::tick_remote_level gap: minor: readiness is the player's column being lit; vanilla's close delay is not kept
130 LevelLoadTracker.getPlayerCompiledSectionCallback inline crates/horizon-client/src/game/remote.rs::Game::tick_remote_level gap: minor: the gate is the column being lit, not the section being compiled
131 LevelLoadTracker.WaitingForPlayerChunk.isReady inline crates/horizon-client/src/game/remote.rs::Game::tick_remote_level gap: minor: no timeout and no spectator or dead exemption
132 LevelLoadTracker.WaitingForServer.loadingPacketsReceived missing port crates/horizon-client-render/src/level_loading.rs::LevelLoadTracker::loading_packets_received minor: the server's level-chunks-start event (GameEvent 13) is ignored GameEvent 13 (LEVEL_CHUNKS_LOAD_START) moves the wait on to the player's chunk
133 MultiPlayerGameMode.startPrediction port crates/horizon-client/src/game/remote.rs::Game::next_sequence gap: minor: only the sequence number; each caller builds and sends its own packet
134 MultiPlayerGameMode.ensureHasSentCarriedItem inline crates/horizon-client/src/game/remote.rs::Game::remote_send_state the SetCarriedItem check, made on every send
135 MultiPlayerGameMode.performUseItemOn port crates/horizon-client/src/game/remote.rs::Game::remote_use_block gap: minor: no world-border check, and UseItemOn always sends world_border false
+8 -8
View File
@@ -6,7 +6,7 @@ ShulkerModel.createShellMesh skip:data layer baked into entity-models.json, loa
ShulkerModel.createBodyLayer skip:data layer baked into entity-models.json, loaded by key
SilverfishModel.createBodyLayer skip:data layer baked into entity-models.json, loaded by key
BoggedModel.createBodyLayer skip:data layer baked into entity-models.json, loaded by key
BoggedModel.setupAnim missing player: a sheared bogged keeps its mushrooms on its head; Bogged shearing sets sheared but nothing hides the mushrooms part
BoggedModel.setupAnim inline crates/horizon-client/src/mobs/anim.rs::setup the mushrooms part comes off once the bogged is sheared
SkeletonModel.createBodyLayer skip:data layer baked into entity-models.json, loaded by key
SkeletonModel.createDefaultSkeletonMesh skip:data layer baked into entity-models.json, loaded by key
SkeletonModel.createSingleModelDualBodyLayer skip:data layer baked into entity-models.json, loaded by key
@@ -70,9 +70,9 @@ ElytraModel.setupAnim port crates/horizon-client/src/game/perspective.rs::Game::
ShieldModel.createLayer skip:data layer baked into entity-models.json, loaded by key
LeashKnotModel.createBodyLayer skip:data layer baked into entity-models.json, loaded by key
ArrowModel.createBodyLayer skip:data layer baked into entity-models.json, loaded by key
ArrowModel.setupAnim missing player: an arrow that lands in a block does not wobble for 7 ticks; arrows.rs sets shake but the arrow draw has no roll
ShulkerBulletModel.createBodyLayer missing player: shulker bullets draw as a flat spark sprite, not the tumbling 3D orb; the baked shulker_bullet model is never loaded
ShulkerBulletModel.setupAnim missing player: the bullet's spin and facing toward its flight are absent, since no model is drawn
ArrowModel.setupAnim inline crates/horizon-client/src/game/creatures.rs::Game::mob_geometry the root rolls while the shake timer runs
ShulkerBulletModel.createBodyLayer inline crates/horizon-client/src/game/creatures.rs::Game::mob_geometry the model is the baked shulker_bullet#main, drawn there as the tumbling orb
ShulkerBulletModel.setupAnim inline crates/horizon-client/src/game/creatures.rs::Game::mob_geometry yaw and pitch toward the flight, the tumble turned by its age, scaled -0.5
TridentModel.createLayer skip:data layer baked into entity-models.json, loaded by key
WindChargeModel.createBodyLayer skip:data layer baked into entity-models.json, loaded by key
WindChargeModel.setupAnim port crates/horizon-client/src/game/wind.rs::Game::wind_charge_geometry wind and wind_charge parts spin in opposite directions
@@ -82,9 +82,9 @@ PiglinHeadModel.setupAnim inline crates/horizon-client-render/src/block_entity_r
SkullModel.createHeadModel skip:data layer baked into entity-models.json, loaded by key
SkullModel.createHumanoidHeadLayer skip:data layer baked into entity-models.json, loaded by key
SkullModel.setupAnim inline crates/horizon-client-render/src/block_entity_render.rs::BlockEntityRenderer::render_one head yaw and pitch in the skull branch of render_one
PlayerCapeModel.createCapeLayer missing player: capes never draw; skins.rs downloads the cape texture but nothing draws the cape layer
PlayerCapeModel.setupAnim missing player: the cape does not lean or flap with movement, since no cape is drawn
PlayerEarsModel.createEarsLayer missing minor: extra ears (showExtraEars accounts) are not drawn
PlayerCapeModel.createCapeLayer port crates/horizon-client/src/mobs/render.rs::cape the layer is the baked player#cape model, posed and drawn there
PlayerCapeModel.setupAnim port crates/horizon-client/src/mobs/render.rs::cape the cape part turned by the flap and the two leans
PlayerEarsModel.createEarsLayer port crates/horizon-client/src/mobs/render.rs::ears the layer is the baked player#ears model, drawn there
PlayerModel.createMesh skip:data layer baked into entity-models.json, loaded by key
PlayerModel.createArmorMeshSet skip:data layer baked into entity-models.json, loaded by key
PlayerModel.setupAnim port crates/horizon-client/src/mobs/anim.rs::setup gap: player: hat, jacket, sleeve and pant toggles (modelPart options) are not read, so a hidden layer still draws; humanoid pose is in the Humanoid branch
@@ -116,7 +116,7 @@ BuiltInBlockModels.createChest port crates/horizon-client-render/src/block_entit
BuiltInBlockModels.createSingletonChest port crates/horizon-client-render/src/block_entity_render.rs::classify Chest kind, single chest
BuiltInBlockModels.createXmasChest port crates/horizon-client-render/src/block_entity_render.rs::classify Chest kind with the Christmas texture
BuiltInBlockModels.createCopperGolem port crates/horizon-client-render/src/block_entity_render.rs::classify CopperGolem kind
BuiltInBlockModels.createFlowerBedModel missing minor: the 0.25 block shift for a flower bed shown by a copper golem is not applied
BuiltInBlockModels.createFlowerBedModel port crates/horizon-client/src/game/mob_blocks.rs::antenna_block_matrix wildflowers and pink petals take the flower bed's 0.25 shift on a golem's antenna
BuiltInBlockModels.createEnchantingTable port crates/horizon-client-render/src/block_entity_render.rs::classify EnchantingTable kind
BuiltInBlockModels.specialModelWithPropertyDispatch port crates/horizon-client-render/src/block_entity_render.rs::classify property dispatch by block state values
BuiltInBlockModels.Builder.put skip:java builder map of special models
1 PiglinModel.setupAttackAnimation port crates/horizon-client/src/mobs/anim.rs::humanoid melee swing branch via weapon_swing, which piglin_pose sets
6 ShulkerModel.createBodyLayer skip:data layer baked into entity-models.json, loaded by key
7 SilverfishModel.createBodyLayer skip:data layer baked into entity-models.json, loaded by key
8 BoggedModel.createBodyLayer skip:data layer baked into entity-models.json, loaded by key
9 BoggedModel.setupAnim missing inline crates/horizon-client/src/mobs/anim.rs::setup player: a sheared bogged keeps its mushrooms on its head; Bogged shearing sets sheared but nothing hides the mushrooms part the mushrooms part comes off once the bogged is sheared
10 SkeletonModel.createBodyLayer skip:data layer baked into entity-models.json, loaded by key
11 SkeletonModel.createDefaultSkeletonMesh skip:data layer baked into entity-models.json, loaded by key
12 SkeletonModel.createSingleModelDualBodyLayer skip:data layer baked into entity-models.json, loaded by key
70 ShieldModel.createLayer skip:data layer baked into entity-models.json, loaded by key
71 LeashKnotModel.createBodyLayer skip:data layer baked into entity-models.json, loaded by key
72 ArrowModel.createBodyLayer skip:data layer baked into entity-models.json, loaded by key
73 ArrowModel.setupAnim missing inline crates/horizon-client/src/game/creatures.rs::Game::mob_geometry player: an arrow that lands in a block does not wobble for 7 ticks; arrows.rs sets shake but the arrow draw has no roll the root rolls while the shake timer runs
74 ShulkerBulletModel.createBodyLayer missing inline crates/horizon-client/src/game/creatures.rs::Game::mob_geometry player: shulker bullets draw as a flat spark sprite, not the tumbling 3D orb; the baked shulker_bullet model is never loaded the model is the baked shulker_bullet#main, drawn there as the tumbling orb
75 ShulkerBulletModel.setupAnim missing inline crates/horizon-client/src/game/creatures.rs::Game::mob_geometry player: the bullet's spin and facing toward its flight are absent, since no model is drawn yaw and pitch toward the flight, the tumble turned by its age, scaled -0.5
76 TridentModel.createLayer skip:data layer baked into entity-models.json, loaded by key
77 WindChargeModel.createBodyLayer skip:data layer baked into entity-models.json, loaded by key
78 WindChargeModel.setupAnim port crates/horizon-client/src/game/wind.rs::Game::wind_charge_geometry wind and wind_charge parts spin in opposite directions
82 SkullModel.createHeadModel skip:data layer baked into entity-models.json, loaded by key
83 SkullModel.createHumanoidHeadLayer skip:data layer baked into entity-models.json, loaded by key
84 SkullModel.setupAnim inline crates/horizon-client-render/src/block_entity_render.rs::BlockEntityRenderer::render_one head yaw and pitch in the skull branch of render_one
85 PlayerCapeModel.createCapeLayer missing port crates/horizon-client/src/mobs/render.rs::cape player: capes never draw; skins.rs downloads the cape texture but nothing draws the cape layer the layer is the baked player#cape model, posed and drawn there
86 PlayerCapeModel.setupAnim missing port crates/horizon-client/src/mobs/render.rs::cape player: the cape does not lean or flap with movement, since no cape is drawn the cape part turned by the flap and the two leans
87 PlayerEarsModel.createEarsLayer missing port crates/horizon-client/src/mobs/render.rs::ears minor: extra ears (showExtraEars accounts) are not drawn the layer is the baked player#ears model, drawn there
88 PlayerModel.createMesh skip:data layer baked into entity-models.json, loaded by key
89 PlayerModel.createArmorMeshSet skip:data layer baked into entity-models.json, loaded by key
90 PlayerModel.setupAnim port crates/horizon-client/src/mobs/anim.rs::setup gap: player: hat, jacket, sleeve and pant toggles (modelPart options) are not read, so a hidden layer still draws; humanoid pose is in the Humanoid branch
116 BuiltInBlockModels.createSingletonChest port crates/horizon-client-render/src/block_entity_render.rs::classify Chest kind, single chest
117 BuiltInBlockModels.createXmasChest port crates/horizon-client-render/src/block_entity_render.rs::classify Chest kind with the Christmas texture
118 BuiltInBlockModels.createCopperGolem port crates/horizon-client-render/src/block_entity_render.rs::classify CopperGolem kind
119 BuiltInBlockModels.createFlowerBedModel missing port crates/horizon-client/src/game/mob_blocks.rs::antenna_block_matrix minor: the 0.25 block shift for a flower bed shown by a copper golem is not applied wildflowers and pink petals take the flower bed's 0.25 shift on a golem's antenna
120 BuiltInBlockModels.createEnchantingTable port crates/horizon-client-render/src/block_entity_render.rs::classify EnchantingTable kind
121 BuiltInBlockModels.specialModelWithPropertyDispatch port crates/horizon-client-render/src/block_entity_render.rs::classify property dispatch by block state values
122 BuiltInBlockModels.Builder.put skip:java builder map of special models
+10 -10
View File
@@ -12,11 +12,11 @@ LavaParticle.Provider.createParticle inline crates/horizon-client-render/src/par
NoteParticle.<init> inline crates/horizon-client-render/src/particles.rs::ParticleEngine::make Note arm: colour from the pitch, friction 0.66, lifetime 6, size x1.5
NoteParticle.getQuadSize port crates/horizon-client-render/src/particles.rs::Particle::quad_size Size::GrowIn over 32 ticks
NoteParticle.Provider.createParticle inline crates/horizon-client-render/src/particles.rs::ParticleEngine::make Note arm
NoxiousGasCloudParticle.tick missing player: potent sulfur in vanilla 26.3 gives off drifting noxious gas clouds; Horizon has no noxious_gas particle type and no PotentSulfurBlockEntity client code, so no cloud appears
NoxiousGasCloudParticle.pickRandomParticleSpawnPoint missing player: potent sulfur in vanilla 26.3 gives off drifting noxious gas clouds; Horizon has no noxious_gas particle type and no PotentSulfurBlockEntity client code, so no cloud appears
NoxiousGasCloudParticle.spawnNoxiousGasParticle missing player: potent sulfur in vanilla 26.3 gives off drifting noxious gas clouds; Horizon has no noxious_gas particle type and no PotentSulfurBlockEntity client code, so no cloud appears
NoxiousGasParticle.tick missing player: potent sulfur in vanilla 26.3 gives off drifting noxious gas clouds; Horizon has no noxious_gas particle type and no PotentSulfurBlockEntity client code, so no cloud appears; the fade-out is missing with it
NoxiousGasParticle.Provider.createParticle missing player: potent sulfur in vanilla 26.3 gives off drifting noxious gas clouds; Horizon has no noxious_gas particle type and no PotentSulfurBlockEntity client code, so no cloud appears
NoxiousGasCloudParticle.tick port crates/horizon-client-render/src/particles.rs::ParticleEngine::tick_particle the `NoxiousGasCloud` arm: base tick, then every second tick a gas particle at a random spawn point that `canBeReachedByNoxiousGas` accepts
NoxiousGasCloudParticle.pickRandomParticleSpawnPoint port crates/horizon-client-render/src/particles.rs::pick_random_particle_spawn_point up to three blocks out in a random horizontal direction, a quarter of a block below the source block's centre, from the level's random
NoxiousGasCloudParticle.spawnNoxiousGasParticle port crates/horizon-client-render/src/particles.rs::ParticleEngine::spawn_noxious_gas_particle the gas is added always visible (`ClientLevel.addAlwaysVisibleParticle`), so the particle level does not thin it
NoxiousGasParticle.tick port crates/horizon-client-render/src/particles.rs::ParticleEngine::tick_particle the `NoxiousGas` arm: base tick and the sprite by age, then alpha falls from its value to 0 over the ticks past the fade start
NoxiousGasParticle.Provider.createParticle port crates/horizon-client-render/src/particles.rs::ParticleEngine::make the `NoxiousGas` arm: white, lifetime `6 / (0.5 to 1) * 3` ticks with the fade half way; the cloud arm lives 20 ticks and draws nothing
Particle.<init> port crates/horizon-client-render/src/particles.rs::Particle::base_at 4-arg constructor (box and lifetime draw); the 7-arg one is Particle::base_moving
Particle.toString skip:debug
Particle.setLocationFromBoundingbox port crates/horizon-client-render/src/particles.rs::Particle::set_location_from_bb
@@ -25,7 +25,7 @@ ParticleDescription.fromJson port crates/horizon-client-render/src/particles.rs:
ParticleEngine.makeParticle port crates/horizon-client-render/src/particles.rs::ParticleEngine::make gap: minor: VibrationSignal options return None here; ParticleEngine::vibration creates them
ParticleEngine.createParticleGroup skip:other no render-type groups: ParticleEngine::render draws every particle in one pass, and the ITEM_PICKUP group is tick_pickups
ParticleEngine.countParticles skip:debug
ParticleEngine.hasSpaceInParticleLimit port crates/horizon-client-render/src/particles.rs (test spore_blossom_air_stops_at_its_particle_limit) per-type particle caps, pinned by that test
ParticleEngine.hasSpaceInParticleLimit port crates/horizon-client-render/src/particles.rs::ParticleEngine::create the only capped type is `ParticleLimit.SPORE_BLOSSOM`: a spore blossom air particle past `SPORE_BLOSSOM_LIMIT` (1000) is dropped in `create`, counted by scanning the live and queued particles instead of vanilla's counted map
ParticleEngine.clearParticles port crates/horizon-client-render/src/particles.rs::ParticleEngine::clear clears emitters, live and pending particles; no limit counts to clear
ParticleGroup.tickParticles port crates/horizon-client-render/src/particles.rs::ParticleEngine::tick ticks live particles and drops the removed ones
ParticleGroup.tickParticle skip:debug crash-report wrapper around particle.tick
@@ -64,8 +64,8 @@ SnowflakeParticle.<init> inline crates/horizon-client-render/src/particles.rs::P
SnowflakeParticle.tick port crates/horizon-client-render/src/particles.rs::ParticleEngine::tick_particle Behaviour::Snowflake: base tick, sprite from age, drag 0.95/0.9/0.95
SnowflakeParticle.Provider.createParticle inline crates/horizon-client-render/src/particles.rs::ParticleEngine::make Snowflake branch: colour 0.923/0.964/0.999
SonicBoomParticle.Provider.createParticle inline crates/horizon-client-render/src/particles.rs::ParticleEngine::make Explosion|SonicBoom arm, SonicBoom branch: lifetime 16, size 1.5
SpellParticle.tick port crates/horizon-client-render/src/particles.rs::ParticleEngine::tick_particle gap: player: the spyglass check is missing: while scoping in first person, vanilla makes spell particles within 3 blocks of the eye fully transparent; Horizon only lerps alpha
SpellParticle.isCloseToScopingPlayer missing player: spell particles near the eye stay visible while the player looks through a spyglass in first person; vanilla hides them
SpellParticle.tick port crates/horizon-client-render/src/particles.rs::ParticleEngine::tick_particle the `Spell` arm: alpha 0 while the particle is close to the scoping eye (`close_to_scoping_player`), else eased back to the original alpha at 0.05
SpellParticle.isCloseToScopingPlayer port crates/horizon-client-render/src/particles.rs::ParticleEngine::close_to_scoping_player within three blocks of the local player's eye while it scopes in first person; the spell arms set alpha 0 there and ease back to the original alpha at 0.05
SpellParticle.InstantProvider.createParticle inline crates/horizon-client-render/src/particles.rs::ParticleEngine::make Effect|InstantEffect arm: colour and power from the option
SpellParticle.MobEffectProvider.createParticle inline crates/horizon-client-render/src/particles.rs::ParticleEngine::make EntityEffect arm: colour and alpha from the option
SpellParticle.Provider.createParticle inline crates/horizon-client-render/src/particles.rs::ParticleEngine::make uncoloured spell arm (Infested, TrialOmen, RaidOmen)
@@ -79,7 +79,7 @@ SulfurBubbleParticle.randomHorizontalWiggling inline crates/horizon-client-rende
SulfurBubbleParticle.Provider.createParticle inline crates/horizon-client-render/src/particles.rs::ParticleEngine::make SulfurBubbles arm
SuspendedParticle.<init> inline crates/horizon-client-render/src/particles.rs::ParticleEngine::make Underwater/CrimsonSpore/WarpedSpore/SporeBlossomAir arm: spawned 0.125 below the point
SuspendedParticle.CrimsonSporeProvider.createParticle inline crates/horizon-client-render/src/particles.rs::ParticleEngine::make CrimsonSpore arm: gaussian drift, colour 0.9/0.4/0.5
SuspendedParticle.SporeBlossomAirProvider.createParticle inline crates/horizon-client-render/src/particles.rs::ParticleEngine::make gap: minor: SporeBlossomAir arm; the SPORE_BLOSSOM particle limit (1000) is not applied
SuspendedParticle.SporeBlossomAirProvider.createParticle inline crates/horizon-client-render/src/particles.rs::ParticleEngine::make SporeBlossomAir arm: spawned 0.125 below the point; the 1000 `SPORE_BLOSSOM` cap is applied in `ParticleEngine::create`
SuspendedParticle.UnderwaterProvider.createParticle inline crates/horizon-client-render/src/particles.rs::ParticleEngine::make Underwater arm: colour 0.4/0.4/0.7
SuspendedParticle.WarpedSporeProvider.createParticle inline crates/horizon-client-render/src/particles.rs::ParticleEngine::make WarpedSpore arm: colour 0.1/0.1/0.3, size 0.001
SuspendedTownParticle.move port crates/horizon-client-render/src/particles.rs::Particle::move_by free_move path: moves the box without block collision
@@ -133,7 +133,7 @@ ClientLanguage.appendFrom port crates/horizon-client-core/src/lang.rs::Language:
LanguageManager.extractLanguages port crates/horizon-client-core/src/lang.rs::available lists the languages the packs declare
LanguageManager.onResourceManagerReload port crates/horizon-client/src/game/options.rs::Game::load_language gap: minor: no error screen when the translations are empty or missing
AnimationMetadataSection.calculateFrameSize port crates/horizon-assets/src/animation.rs::AnimationMetadata::frame_size
GuiSpriteScaling.NineSlice.validate missing minor: Horizon accepts nine-slice metadata whose borders cover the whole sprite (parse_nine_slice has no check)
GuiSpriteScaling.NineSlice.validate port crates/horizon-client-render/src/ui.rs::parse_nine_slice borders that meet leave no centre slice, so the metadata is dropped and the sprite draws as a plain stretch
GuiSpriteScaling.NineSlice.Border.unpackValue skip:java codec helper that collapses a uniform border to one number; Horizon stores four values
BlockStateDefinitions.createItemFrameFakeState skip:data block state definition for the map property; Horizon chooses the frame model by flags
BlockStateDefinitions.getItemFrameFakeState port crates/horizon-client/src/game/frames.rs::Game::frame_geometry gap: minor: picks the glow and map frame model by flags; no block state
1 HugeExplosionParticle.tick port crates/horizon-client-render/src/particles.rs::ParticleEngine::tick_particle Behaviour::HugeExplosion: age, sprite from age, removed at lifetime
12 NoteParticle.<init> inline crates/horizon-client-render/src/particles.rs::ParticleEngine::make Note arm: colour from the pitch, friction 0.66, lifetime 6, size x1.5
13 NoteParticle.getQuadSize port crates/horizon-client-render/src/particles.rs::Particle::quad_size Size::GrowIn over 32 ticks
14 NoteParticle.Provider.createParticle inline crates/horizon-client-render/src/particles.rs::ParticleEngine::make Note arm
15 NoxiousGasCloudParticle.tick missing port crates/horizon-client-render/src/particles.rs::ParticleEngine::tick_particle player: potent sulfur in vanilla 26.3 gives off drifting noxious gas clouds; Horizon has no noxious_gas particle type and no PotentSulfurBlockEntity client code, so no cloud appears the `NoxiousGasCloud` arm: base tick, then every second tick a gas particle at a random spawn point that `canBeReachedByNoxiousGas` accepts
16 NoxiousGasCloudParticle.pickRandomParticleSpawnPoint missing port crates/horizon-client-render/src/particles.rs::pick_random_particle_spawn_point player: potent sulfur in vanilla 26.3 gives off drifting noxious gas clouds; Horizon has no noxious_gas particle type and no PotentSulfurBlockEntity client code, so no cloud appears up to three blocks out in a random horizontal direction, a quarter of a block below the source block's centre, from the level's random
17 NoxiousGasCloudParticle.spawnNoxiousGasParticle missing port crates/horizon-client-render/src/particles.rs::ParticleEngine::spawn_noxious_gas_particle player: potent sulfur in vanilla 26.3 gives off drifting noxious gas clouds; Horizon has no noxious_gas particle type and no PotentSulfurBlockEntity client code, so no cloud appears the gas is added always visible (`ClientLevel.addAlwaysVisibleParticle`), so the particle level does not thin it
18 NoxiousGasParticle.tick missing port crates/horizon-client-render/src/particles.rs::ParticleEngine::tick_particle player: potent sulfur in vanilla 26.3 gives off drifting noxious gas clouds; Horizon has no noxious_gas particle type and no PotentSulfurBlockEntity client code, so no cloud appears; the fade-out is missing with it the `NoxiousGas` arm: base tick and the sprite by age, then alpha falls from its value to 0 over the ticks past the fade start
19 NoxiousGasParticle.Provider.createParticle missing port crates/horizon-client-render/src/particles.rs::ParticleEngine::make player: potent sulfur in vanilla 26.3 gives off drifting noxious gas clouds; Horizon has no noxious_gas particle type and no PotentSulfurBlockEntity client code, so no cloud appears the `NoxiousGas` arm: white, lifetime `6 / (0.5 to 1) * 3` ticks with the fade half way; the cloud arm lives 20 ticks and draws nothing
20 Particle.<init> port crates/horizon-client-render/src/particles.rs::Particle::base_at 4-arg constructor (box and lifetime draw); the 7-arg one is Particle::base_moving
21 Particle.toString skip:debug
22 Particle.setLocationFromBoundingbox port crates/horizon-client-render/src/particles.rs::Particle::set_location_from_bb
25 ParticleEngine.makeParticle port crates/horizon-client-render/src/particles.rs::ParticleEngine::make gap: minor: VibrationSignal options return None here; ParticleEngine::vibration creates them
26 ParticleEngine.createParticleGroup skip:other no render-type groups: ParticleEngine::render draws every particle in one pass, and the ITEM_PICKUP group is tick_pickups
27 ParticleEngine.countParticles skip:debug
28 ParticleEngine.hasSpaceInParticleLimit port crates/horizon-client-render/src/particles.rs (test spore_blossom_air_stops_at_its_particle_limit) crates/horizon-client-render/src/particles.rs::ParticleEngine::create per-type particle caps, pinned by that test the only capped type is `ParticleLimit.SPORE_BLOSSOM`: a spore blossom air particle past `SPORE_BLOSSOM_LIMIT` (1000) is dropped in `create`, counted by scanning the live and queued particles instead of vanilla's counted map
29 ParticleEngine.clearParticles port crates/horizon-client-render/src/particles.rs::ParticleEngine::clear clears emitters, live and pending particles; no limit counts to clear
30 ParticleGroup.tickParticles port crates/horizon-client-render/src/particles.rs::ParticleEngine::tick ticks live particles and drops the removed ones
31 ParticleGroup.tickParticle skip:debug crash-report wrapper around particle.tick
64 SnowflakeParticle.tick port crates/horizon-client-render/src/particles.rs::ParticleEngine::tick_particle Behaviour::Snowflake: base tick, sprite from age, drag 0.95/0.9/0.95
65 SnowflakeParticle.Provider.createParticle inline crates/horizon-client-render/src/particles.rs::ParticleEngine::make Snowflake branch: colour 0.923/0.964/0.999
66 SonicBoomParticle.Provider.createParticle inline crates/horizon-client-render/src/particles.rs::ParticleEngine::make Explosion|SonicBoom arm, SonicBoom branch: lifetime 16, size 1.5
67 SpellParticle.tick port crates/horizon-client-render/src/particles.rs::ParticleEngine::tick_particle gap: player: the spyglass check is missing: while scoping in first person, vanilla makes spell particles within 3 blocks of the eye fully transparent; Horizon only lerps alpha the `Spell` arm: alpha 0 while the particle is close to the scoping eye (`close_to_scoping_player`), else eased back to the original alpha at 0.05
68 SpellParticle.isCloseToScopingPlayer missing port crates/horizon-client-render/src/particles.rs::ParticleEngine::close_to_scoping_player player: spell particles near the eye stay visible while the player looks through a spyglass in first person; vanilla hides them within three blocks of the local player's eye while it scopes in first person; the spell arms set alpha 0 there and ease back to the original alpha at 0.05
69 SpellParticle.InstantProvider.createParticle inline crates/horizon-client-render/src/particles.rs::ParticleEngine::make Effect|InstantEffect arm: colour and power from the option
70 SpellParticle.MobEffectProvider.createParticle inline crates/horizon-client-render/src/particles.rs::ParticleEngine::make EntityEffect arm: colour and alpha from the option
71 SpellParticle.Provider.createParticle inline crates/horizon-client-render/src/particles.rs::ParticleEngine::make uncoloured spell arm (Infested, TrialOmen, RaidOmen)
79 SulfurBubbleParticle.Provider.createParticle inline crates/horizon-client-render/src/particles.rs::ParticleEngine::make SulfurBubbles arm
80 SuspendedParticle.<init> inline crates/horizon-client-render/src/particles.rs::ParticleEngine::make Underwater/CrimsonSpore/WarpedSpore/SporeBlossomAir arm: spawned 0.125 below the point
81 SuspendedParticle.CrimsonSporeProvider.createParticle inline crates/horizon-client-render/src/particles.rs::ParticleEngine::make CrimsonSpore arm: gaussian drift, colour 0.9/0.4/0.5
82 SuspendedParticle.SporeBlossomAirProvider.createParticle inline crates/horizon-client-render/src/particles.rs::ParticleEngine::make gap: minor: SporeBlossomAir arm; the SPORE_BLOSSOM particle limit (1000) is not applied SporeBlossomAir arm: spawned 0.125 below the point; the 1000 `SPORE_BLOSSOM` cap is applied in `ParticleEngine::create`
83 SuspendedParticle.UnderwaterProvider.createParticle inline crates/horizon-client-render/src/particles.rs::ParticleEngine::make Underwater arm: colour 0.4/0.4/0.7
84 SuspendedParticle.WarpedSporeProvider.createParticle inline crates/horizon-client-render/src/particles.rs::ParticleEngine::make WarpedSpore arm: colour 0.1/0.1/0.3, size 0.001
85 SuspendedTownParticle.move port crates/horizon-client-render/src/particles.rs::Particle::move_by free_move path: moves the box without block collision
133 LanguageManager.extractLanguages port crates/horizon-client-core/src/lang.rs::available lists the languages the packs declare
134 LanguageManager.onResourceManagerReload port crates/horizon-client/src/game/options.rs::Game::load_language gap: minor: no error screen when the translations are empty or missing
135 AnimationMetadataSection.calculateFrameSize port crates/horizon-assets/src/animation.rs::AnimationMetadata::frame_size
136 GuiSpriteScaling.NineSlice.validate missing port crates/horizon-client-render/src/ui.rs::parse_nine_slice minor: Horizon accepts nine-slice metadata whose borders cover the whole sprite (parse_nine_slice has no check) borders that meet leave no centre slice, so the metadata is dropped and the sprite draws as a plain stretch
137 GuiSpriteScaling.NineSlice.Border.unpackValue skip:java codec helper that collapses a uniform border to one number; Horizon stores four values
138 BlockStateDefinitions.createItemFrameFakeState skip:data block state definition for the map property; Horizon chooses the frame model by flags
139 BlockStateDefinitions.getItemFrameFakeState port crates/horizon-client/src/game/frames.rs::Game::frame_geometry gap: minor: picks the glow and map frame model by flags; no block state
+3 -3
View File
@@ -128,7 +128,7 @@ DownloadedPackSource.onRecoveryFailure port crates/horizon-client/src/server_pac
DownloadedPackSource.waitForPackFeedback skip:java future over the feedback callback; replies go through ServerPacks::take_replies
ServerPackManager.markExistingPacksAsRemoved inline crates/horizon-client/src/server_packs.rs::ServerPacks::push packs with the same id are removed as ServerReplaced
ServerPackManager.pushPack port crates/horizon-client/src/server_packs.rs::ServerPacks::push declined prompt answers DECLINED
ServerPackManager.pushLocalPack missing player: a world's own resource pack (resources.zip in the save) is not loaded when the world opens
ServerPackManager.pushLocalPack port crates/horizon-client/src/server_packs.rs::ServerPacks::push_local the world's `resourcepacks/resources.zip` is pushed as a pack already on disk (`Download::Done`, no url or sha1); a declined prompt reports DECLINED
ServerPackManager.pushNewPack inline crates/horizon-client/src/server_packs.rs::ServerPacks::push adds the pack and accepts it when allowed
ServerPackManager.findPackInfo inline crates/horizon-client/src/server_packs.rs::ServerPacks::pop finds the live pack with the id
ServerPackManager.popPack port crates/horizon-client/src/server_packs.rs::ServerPacks::pop marks the pack ServerRemoved
@@ -139,9 +139,9 @@ ServerPackManager.updateDownloads port crates/horizon-client/src/server_packs.rs
AbstractSoundInstance.getOrResolve port crates/horizon-client-render/src/sound_engine.rs::SoundEngine::resolve event lookup, then a random sound from it
BeeSoundInstance.tick port crates/horizon-client-render/src/sound_engine.rs::SoundInstance::tick Ticking::Bee follows the bee; pitch and volume from speed
DirectionalSoundInstance.setPosition port crates/horizon-client/src/game/end.rs::Game::tick_end gap: minor: the end flash plays without a position, so the 10-block offset toward the view is not kept
ElytraOnPlayerSoundInstance.tick missing player: no elytra wind loop (entity.elytra.flying) while gliding; gliding is silent
ElytraOnPlayerSoundInstance.tick port crates/horizon-client-render/src/sound_engine.rs::SoundInstance::tick the `Elytra` arm: `item.elytra.flying` follows the player each tick, stops 20 ticks after gliding ends, volume `speed / 4` clamped, silent for a second then fading in over the next
EntityBoundSoundInstance.tick missing minor: a sound tied to an entity does not follow it; it stays at its start point
RidingEntitySoundInstance.tick missing player: no riding loop for a happy ghast or nautilus (HAPPY_GHAST_RIDING, NAUTILUS_RIDING)
RidingEntitySoundInstance.tick port crates/horizon-client-render/src/sound_engine.rs::SoundInstance::tick the `RidingEntity` arm for `entity.happy_ghast.riding` and `entity.nautilus.riding`: stops when the ridden mob is gone or silent, volume 0 unless the underwater test matches and the speed is at least 0.01, else the clamped lerp
RidingMinecartSoundInstance.shoudlPlaySound port crates/horizon-client-render/src/sound_engine.rs::SoundInstance::tick RidingMinecart arm; the predicate is always true for OldMinecartBehavior, Horizon's only minecart
SnifferSoundInstance.tick port crates/horizon-client/src/game/sniffers.rs::Game::tick_sniffer_effects gap: player: the digging sound plays once when digging starts; vanilla loops it while digging
Sound.Type.getByName port crates/horizon-client-render/src/sound_events.rs::parse_sound type file or event; other values reject the sound
1 EquipmentClientInfo.Layer.leatherDyeable skip:data equipment asset factory; Horizon reads the equipment JSON in armor_stands.rs
128 DownloadedPackSource.waitForPackFeedback skip:java future over the feedback callback; replies go through ServerPacks::take_replies
129 ServerPackManager.markExistingPacksAsRemoved inline crates/horizon-client/src/server_packs.rs::ServerPacks::push packs with the same id are removed as ServerReplaced
130 ServerPackManager.pushPack port crates/horizon-client/src/server_packs.rs::ServerPacks::push declined prompt answers DECLINED
131 ServerPackManager.pushLocalPack missing port crates/horizon-client/src/server_packs.rs::ServerPacks::push_local player: a world's own resource pack (resources.zip in the save) is not loaded when the world opens the world's `resourcepacks/resources.zip` is pushed as a pack already on disk (`Download::Done`, no url or sha1); a declined prompt reports DECLINED
132 ServerPackManager.pushNewPack inline crates/horizon-client/src/server_packs.rs::ServerPacks::push adds the pack and accepts it when allowed
133 ServerPackManager.findPackInfo inline crates/horizon-client/src/server_packs.rs::ServerPacks::pop finds the live pack with the id
134 ServerPackManager.popPack port crates/horizon-client/src/server_packs.rs::ServerPacks::pop marks the pack ServerRemoved
139 AbstractSoundInstance.getOrResolve port crates/horizon-client-render/src/sound_engine.rs::SoundEngine::resolve event lookup, then a random sound from it
140 BeeSoundInstance.tick port crates/horizon-client-render/src/sound_engine.rs::SoundInstance::tick Ticking::Bee follows the bee; pitch and volume from speed
141 DirectionalSoundInstance.setPosition port crates/horizon-client/src/game/end.rs::Game::tick_end gap: minor: the end flash plays without a position, so the 10-block offset toward the view is not kept
142 ElytraOnPlayerSoundInstance.tick missing port crates/horizon-client-render/src/sound_engine.rs::SoundInstance::tick player: no elytra wind loop (entity.elytra.flying) while gliding; gliding is silent the `Elytra` arm: `item.elytra.flying` follows the player each tick, stops 20 ticks after gliding ends, volume `speed / 4` clamped, silent for a second then fading in over the next
143 EntityBoundSoundInstance.tick missing minor: a sound tied to an entity does not follow it; it stays at its start point
144 RidingEntitySoundInstance.tick missing port crates/horizon-client-render/src/sound_engine.rs::SoundInstance::tick player: no riding loop for a happy ghast or nautilus (HAPPY_GHAST_RIDING, NAUTILUS_RIDING) the `RidingEntity` arm for `entity.happy_ghast.riding` and `entity.nautilus.riding`: stops when the ridden mob is gone or silent, volume 0 unless the underwater test matches and the speed is at least 0.01, else the clamped lerp
145 RidingMinecartSoundInstance.shoudlPlaySound port crates/horizon-client-render/src/sound_engine.rs::SoundInstance::tick RidingMinecart arm; the predicate is always true for OldMinecartBehavior, Horizon's only minecart
146 SnifferSoundInstance.tick port crates/horizon-client/src/game/sniffers.rs::Game::tick_sniffer_effects gap: player: the digging sound plays once when digging starts; vanilla loops it while digging
147 Sound.Type.getByName port crates/horizon-client-render/src/sound_events.rs::parse_sound type file or event; other values reject the sound
+16 -16
View File
@@ -48,30 +48,30 @@ ConfirmExperimentalFeaturesScreen.DetailsScreen.init port crates/horizon-client/
ConfirmExperimentalFeaturesScreen.DetailsScreen.repositionElements skip:ui layout only
ConfirmExperimentalFeaturesScreen.DetailsScreen.PackListEntry.extractContent port crates/horizon-client/src/title/confirm_experiments.rs::ConfirmExperimentsScreen::draw each entry's bold title and the flags under it
ConfirmExperimentalFeaturesScreen.DetailsScreen.PackListEntry.getNarration skip:ui narration text only
CreateWorldScreen.openFresh port crates/horizon-client/src/title.rs::Title::new_world gap: minor: no resource load for datapack registries before the screen
CreateWorldScreen.openFresh port crates/horizon-client/src/title.rs::Title::new_world the screen shows createWorld.preparing while the world creation data is read, then opens
CreateWorldScreen.testWorld skip:debug TW button, shown only when running in the IDE
CreateWorldScreen.openCreateWorldScreen skip:data loads datapack registries and world presets before the screen; Horizon reads data from tables
CreateWorldScreen.createFromExisting port crates/horizon-client/src/title/create_world.rs::WorldOptions::of_world gap: minor: no datapacks copied; name, seed and mode come from title.rs click_action arm 15
CreateWorldScreen.createFromExisting port crates/horizon-client/src/title/create_world.rs::WorldOptions::of_world name, seed and mode come from title.rs click_action arm 15; the old world's datapacks come from datapack::for_recreate
CreateWorldScreen.init port crates/horizon-client/src/title/create_world.rs::buttons tab bar and footer buttons; tabs are drawn by draw_create
CreateWorldScreen.repositionElements skip:ui layout only
CreateWorldScreen.onCreate port crates/horizon-client/src/title.rs::Title::create_action gap: minor: no experimental or deprecated-data warning before creating
CreateWorldScreen.createWorldAndCleanup inline crates/horizon-client/src/game/startup_menu.rs::StartupMenu::queue_entry gap: minor: temp datapack folder cleanup not modelled (no datapack copy)
CreateWorldScreen.createNewWorld port crates/horizon-client/src/game.rs::Game::open_world gap: minor: the folder is made in WorldRequest::prepare and no datapacks are copied in
CreateWorldScreen.createWorldAndCleanup inline crates/horizon-client/src/game/startup_menu.rs::StartupMenu::queue_entry the temp pack folder is removed once its packs are installed (game.rs) and when Create is cancelled (Title::cancel_create)
CreateWorldScreen.createNewWorld port crates/horizon-client/src/game.rs::Game::open_world the folder is made in WorldRequest::prepare; the picked data packs are installed into it before the world opens (datapack::install)
CreateWorldScreen.createLevelSettings port crates/horizon-client/src/game/world_loading.rs::WorldRequest::prepare name, mode, difficulty, hardcore and commands written with the new level; gap: minor: debug world (spectator, peaceful) not modelled
CreateWorldScreen.keyPressed port crates/horizon-client/src/title.rs::confirm_key_size player: Enter with no button focused creates the world (Screenless Create mode falls through to create_action), as the screen does after its widgets decline the key
CreateWorldScreen.extractRenderState port crates/horizon-client/src/title/create_world.rs::Title::draw_create draws the footer separator
CreateWorldScreen.extractMenuBackground port crates/horizon-client/src/title/create_world.rs::Title::draw_create draws the tab header strip and menu background
CreateWorldScreen.getOrCreateTempDataPackDir port crates/horizon-client/src/title.rs:971 -> crate::datapack::temp_folder the Create screen's chosen packs live in a temp folder under options.pack_dir
CreateWorldScreen.getOrCreateTempDataPackDir port crates/horizon-client-render/src/datapack.rs::temp_folder the Create screen's chosen packs live in a temp folder kept in options.pack_dir, made on the first drop or Data Packs click
CreateWorldScreen.openExperimentsScreen port crates/horizon-client/src/title.rs::Title::click_action the Experiments button opens experiments::ExperimentsScreen with the selected packs' flags
CreateWorldScreen.openDataPackSelectionScreen port crates/horizon-client/src/title.rs::Mode::DataPacks the More tab's Data Packs button opens the pack selection screen, and Back reopens it (reopen_data_packs)
CreateWorldScreen.tryApplyNewDataPacks port crates/horizon-client/src/title.rs::creation_packs + preparing the chosen pack_dir and data_packs feed the worldgen read before the world is created
CreateWorldScreen.openDataPackSelectionScreen port crates/horizon-client/src/title.rs::Title::click_action the More tab's Data Packs button (arm 31) opens the pack selection screen on the temp folder; Back reopens it (reopen_data_packs)
CreateWorldScreen.tryApplyNewDataPacks port crates/horizon-client/src/title.rs::Title::offer_data_packs the chosen ids apply at once and the data is read again, or the experimental confirm comes first
CreateWorldScreen.applyNewPackConfig inline crates/horizon-client/src/title.rs::creation_packs the selected ids are applied at creation rather than validated into a separate config object
CreateWorldScreen.createDefaultLoadConfig skip:data builds the datapack load config; Horizon loads no datapack set at creation
CreateWorldScreen.removeTempDataPackDir inline crates/horizon-client/src/title.rs:831 the temp folder's cleanup on leaving the create screen
CreateWorldScreen.removeTempDataPackDir inline crates/horizon-client/src/title.rs::Title::cancel_create the temp folder goes when Create is cancelled; it is also removed once its packs are installed
CreateWorldScreen.copyBetweenDirs port crates/horizon-client-render/src/datapack.rs::copy_pack a dropped pack is copied into the temp folder
CreateWorldScreen.createNewWorldDirectory port crates/horizon-client/src/game/world_loading.rs::WorldRequest::prepare makes the world folder and writes level.dat; gap: minor: datapack files not copied in
CreateWorldScreen.createTempDataPackDirFromExistingWorld port crates/horizon-client/src/title.rs:3644 Re-Create reopens the world's own data pack selection
CreateWorldScreen.getDataPackSelectionSettings inline crates/horizon-client/src/title.rs::Mode::Create { options } options.pack_dir and options.data_packs are the selection settings
CreateWorldScreen.createTempDataPackDirFromExistingWorld port crates/horizon-client-render/src/datapack.rs::for_recreate Re-Create copies the old world's packs into a temp folder and keeps its selection
CreateWorldScreen.getDataPackSelectionSettings inline crates/horizon-client/src/title.rs::Title::creation_packs options.pack_dir and options.data_packs are the selection settings the picker and the worldgen read open on
CreateWorldScreen.GameTab.<init> port crates/horizon-client/src/title/create_world.rs::buttons Game tab: name box, game mode, difficulty, allow cheats; gap: minor: vanilla's Experiments button sits on this tab in non-stable builds
CreateWorldScreen.MoreTab.openGameRulesScreen port crates/horizon-client/src/title.rs::Title::click_action Game Rules button (arm 29) over a copy of the rules, via RulesScreen::for_creation
CreateWorldScreen.WorldTab.<init> port crates/horizon-client/src/title/create_world.rs::buttons World tab: world type, customize, seed box (field 1), structures and bonus chest; gap: player: only Default and Superflat are offered, vanilla also offers Amplified and other presets
@@ -130,21 +130,21 @@ WorldCreationUiState.WorldTypeEntry.describePreset port crates/horizon-client/sr
WorldCreationUiState.WorldTypeEntry.isAmplified inline crates/horizon-client/src/title/create_world.rs::buttons the amplified preset's help tooltip on the world type cycle button
WorldOpenFlows.createFreshLevel port crates/horizon-client/src/game/world_loading.rs::WorldRequest::prepare writes a fresh level.dat; gap: minor: vanilla uses it for the debug recreate and the demo world; no datapack dimensions
WorldOpenFlows.createWorldAccess port crates/horizon-client/src/save.rs::SessionLock::acquire takes the world's session lock as the access; gap: minor: no symlink warning
WorldOpenFlows.createLevelFromExistingSettings port crates/horizon-client/src/game.rs::Game::open_world starts the world with the given settings; gap: minor: no datapack repository is built
WorldOpenFlows.loadWorldStem port crates/horizon-client/src/game/world_loading.rs::WorldRequest::prepare reads level data and generator settings; gap: minor: no datapack load or safe-mode retry
WorldOpenFlows.createLevelFromExistingSettings port crates/horizon-client/src/game.rs::Game::open_world starts the world with the given settings; the packs come from datapack::for_recreate and are installed before the world opens
WorldOpenFlows.loadWorldStem port crates/horizon-client/src/game/world_loading.rs::WorldRequest::prepare reads level data and generator settings and discovers the world's packs (datapack::discover_with_features); gap: minor: no safe-mode retry when a pack fails to load
WorldOpenFlows.recreateWorldData port crates/horizon-client/src/title/create_world.rs::WorldOptions::of_world reads the old world's settings for Re-Create; title.rs click_action arm 15 fills name, seed and mode
WorldOpenFlows.loadWorldDataBlocking port crates/horizon-client/src/game/world_loading.rs::WorldLoader::finish waits for the load worker and returns its result
WorldOpenFlows.askForBackup port crates/horizon-client/src/title.rs::Title::play a world that needs its files fixed or comes from a newer version asks for a backup first (Mode::BackupBeforeUpgrade)
WorldOpenFlows.confirmWorldCreation skip:data lifecycle warning for experimental or deprecated data; Horizon has no such data
WorldOpenFlows.openWorld port crates/horizon-client/src/game/startup_menu.rs::StartupMenu::queue_entry Play (title.rs arm 10) queues the existing world for loading
WorldOpenFlows.openWorldLoadLevelData port crates/horizon-client/src/game/world_loading.rs::WorldRequest::prepare gap: player: a level.dat that will not read (Level::read gives None) opens with a random seed and the folder name, not the Recover World Data screen
WorldOpenFlows.openWorldLoadLevelData port crates/horizon-client/src/game/world_loading.rs::WorldRequest::prepare the level data is read by WorldRequest::prepare; a level.dat that does not read opens RecoverWorldDataScreen instead of the world (Title::play, Mode::Recover)
WorldOpenFlows.openWorldCheckVersionCompatibility port crates/horizon-client/src/title.rs::Title::play a world from another series opens the incompatible AlertScreen instead
WorldOpenFlows.createBackupAndOpenWorld port crates/horizon-client/src/title.rs::Title::start_backup the backup runs on its own thread; the world opens once it finishes (backup_then_open)
WorldOpenFlows.upgradeAndOpenWorld port crates/horizon-client/src/save.rs::upgrade_older_world runs the file fixes and upgrades on open; gap: player: no file-fix progress screen and no upgrade done, join now confirm
WorldOpenFlows.upgradeAndOpenWorld port crates/horizon-client/src/save.rs::upgrade_older_world runs the file fixes and upgrades on open under FileFixerProgressScreen (level_loading::draw_file_fixer, Cancel wired in game.rs); gap: player: no upgrade-done join-now confirm
WorldOpenFlows.tryFileFixAndReportErrors port crates/horizon-world/src/datafix/files.rs::fix_world_files runs the file fixer; gap: player: cancel, symlink and failed-cleanup alerts are not shown
WorldOpenFlows.openWorldLoadLevelStem port crates/horizon-client/src/game/world_loading.rs::WorldRequest::prepare loads the level stem and generator; gap: minor: no safe-mode retry
WorldOpenFlows.openWorldCheckWorldStemCompatibility port crates/horizon-client/src/title.rs::Title::after_prompt an old customized or experimental world asks its own question before the load, the stem issue of Mode::BackupBeforeUpgrade
WorldOpenFlows.openWorldLoadBundledResourcePack missing player: the world's own resources.zip is not applied on open; only an old-world file fix moves it into resourcepacks (datafix fix 4772)
WorldOpenFlows.openWorldLoadBundledResourcePack inline crates/horizon-client/src/game/server_packs.rs::Game::server_packs_world_pack the world's pack goes in as a local pack and the world connects (game/integrated.rs); a pack that fails to load has no prompt
WorldOpenFlows.openWorldCheckDiskSpace port crates/horizon-client/src/title.rs::Title::after_prompt little space left opens the LowDiskSpace ConfirmScreen instead of the world
WorldOpenFlows.loadBundledResourcePack missing player: pushes the world's resources.zip as a local pack; Horizon has no such path
WorldOpenFlows.loadBundledResourcePack port crates/horizon-client/src/game/server_packs.rs::Game::server_packs_world_pack saves/<world>/resourcepacks/resources.zip is pushed as the world pack before the connect
WorldOpenFlows.promptBundledPackLoadFailure missing minor: prompt when the world's own pack fails to load; no such pack in Horizon
1 SocialInteractionsPlayerList.enableScissor skip:service social list (hidden and blocked players); Horizon has no Social Interactions screen
48 ConfirmExperimentalFeaturesScreen.DetailsScreen.repositionElements skip:ui layout only
49 ConfirmExperimentalFeaturesScreen.DetailsScreen.PackListEntry.extractContent port crates/horizon-client/src/title/confirm_experiments.rs::ConfirmExperimentsScreen::draw each entry's bold title and the flags under it
50 ConfirmExperimentalFeaturesScreen.DetailsScreen.PackListEntry.getNarration skip:ui narration text only
51 CreateWorldScreen.openFresh port crates/horizon-client/src/title.rs::Title::new_world gap: minor: no resource load for datapack registries before the screen the screen shows createWorld.preparing while the world creation data is read, then opens
52 CreateWorldScreen.testWorld skip:debug TW button, shown only when running in the IDE
53 CreateWorldScreen.openCreateWorldScreen skip:data loads datapack registries and world presets before the screen; Horizon reads data from tables
54 CreateWorldScreen.createFromExisting port crates/horizon-client/src/title/create_world.rs::WorldOptions::of_world gap: minor: no datapacks copied; name, seed and mode come from title.rs click_action arm 15 name, seed and mode come from title.rs click_action arm 15; the old world's datapacks come from datapack::for_recreate
55 CreateWorldScreen.init port crates/horizon-client/src/title/create_world.rs::buttons tab bar and footer buttons; tabs are drawn by draw_create
56 CreateWorldScreen.repositionElements skip:ui layout only
57 CreateWorldScreen.onCreate port crates/horizon-client/src/title.rs::Title::create_action gap: minor: no experimental or deprecated-data warning before creating
58 CreateWorldScreen.createWorldAndCleanup inline crates/horizon-client/src/game/startup_menu.rs::StartupMenu::queue_entry gap: minor: temp datapack folder cleanup not modelled (no datapack copy) the temp pack folder is removed once its packs are installed (game.rs) and when Create is cancelled (Title::cancel_create)
59 CreateWorldScreen.createNewWorld port crates/horizon-client/src/game.rs::Game::open_world gap: minor: the folder is made in WorldRequest::prepare and no datapacks are copied in the folder is made in WorldRequest::prepare; the picked data packs are installed into it before the world opens (datapack::install)
60 CreateWorldScreen.createLevelSettings port crates/horizon-client/src/game/world_loading.rs::WorldRequest::prepare name, mode, difficulty, hardcore and commands written with the new level; gap: minor: debug world (spectator, peaceful) not modelled
61 CreateWorldScreen.keyPressed port crates/horizon-client/src/title.rs::confirm_key_size player: Enter with no button focused creates the world (Screenless Create mode falls through to create_action), as the screen does after its widgets decline the key
62 CreateWorldScreen.extractRenderState port crates/horizon-client/src/title/create_world.rs::Title::draw_create draws the footer separator
63 CreateWorldScreen.extractMenuBackground port crates/horizon-client/src/title/create_world.rs::Title::draw_create draws the tab header strip and menu background
64 CreateWorldScreen.getOrCreateTempDataPackDir port crates/horizon-client/src/title.rs:971 -> crate::datapack::temp_folder crates/horizon-client-render/src/datapack.rs::temp_folder the Create screen's chosen packs live in a temp folder under options.pack_dir the Create screen's chosen packs live in a temp folder kept in options.pack_dir, made on the first drop or Data Packs click
65 CreateWorldScreen.openExperimentsScreen port crates/horizon-client/src/title.rs::Title::click_action the Experiments button opens experiments::ExperimentsScreen with the selected packs' flags
66 CreateWorldScreen.openDataPackSelectionScreen port crates/horizon-client/src/title.rs::Mode::DataPacks crates/horizon-client/src/title.rs::Title::click_action the More tab's Data Packs button opens the pack selection screen, and Back reopens it (reopen_data_packs) the More tab's Data Packs button (arm 31) opens the pack selection screen on the temp folder; Back reopens it (reopen_data_packs)
67 CreateWorldScreen.tryApplyNewDataPacks port crates/horizon-client/src/title.rs::creation_packs + preparing crates/horizon-client/src/title.rs::Title::offer_data_packs the chosen pack_dir and data_packs feed the worldgen read before the world is created the chosen ids apply at once and the data is read again, or the experimental confirm comes first
68 CreateWorldScreen.applyNewPackConfig inline crates/horizon-client/src/title.rs::creation_packs the selected ids are applied at creation rather than validated into a separate config object
69 CreateWorldScreen.createDefaultLoadConfig skip:data builds the datapack load config; Horizon loads no datapack set at creation
70 CreateWorldScreen.removeTempDataPackDir inline crates/horizon-client/src/title.rs:831 crates/horizon-client/src/title.rs::Title::cancel_create the temp folder's cleanup on leaving the create screen the temp folder goes when Create is cancelled; it is also removed once its packs are installed
71 CreateWorldScreen.copyBetweenDirs port crates/horizon-client-render/src/datapack.rs::copy_pack a dropped pack is copied into the temp folder
72 CreateWorldScreen.createNewWorldDirectory port crates/horizon-client/src/game/world_loading.rs::WorldRequest::prepare makes the world folder and writes level.dat; gap: minor: datapack files not copied in
73 CreateWorldScreen.createTempDataPackDirFromExistingWorld port crates/horizon-client/src/title.rs:3644 crates/horizon-client-render/src/datapack.rs::for_recreate Re-Create reopens the world's own data pack selection Re-Create copies the old world's packs into a temp folder and keeps its selection
74 CreateWorldScreen.getDataPackSelectionSettings inline crates/horizon-client/src/title.rs::Mode::Create { options } crates/horizon-client/src/title.rs::Title::creation_packs options.pack_dir and options.data_packs are the selection settings options.pack_dir and options.data_packs are the selection settings the picker and the worldgen read open on
75 CreateWorldScreen.GameTab.<init> port crates/horizon-client/src/title/create_world.rs::buttons Game tab: name box, game mode, difficulty, allow cheats; gap: minor: vanilla's Experiments button sits on this tab in non-stable builds
76 CreateWorldScreen.MoreTab.openGameRulesScreen port crates/horizon-client/src/title.rs::Title::click_action Game Rules button (arm 29) over a copy of the rules, via RulesScreen::for_creation
77 CreateWorldScreen.WorldTab.<init> port crates/horizon-client/src/title/create_world.rs::buttons World tab: world type, customize, seed box (field 1), structures and bonus chest; gap: player: only Default and Superflat are offered, vanilla also offers Amplified and other presets
130 WorldCreationUiState.WorldTypeEntry.isAmplified inline crates/horizon-client/src/title/create_world.rs::buttons the amplified preset's help tooltip on the world type cycle button
131 WorldOpenFlows.createFreshLevel port crates/horizon-client/src/game/world_loading.rs::WorldRequest::prepare writes a fresh level.dat; gap: minor: vanilla uses it for the debug recreate and the demo world; no datapack dimensions
132 WorldOpenFlows.createWorldAccess port crates/horizon-client/src/save.rs::SessionLock::acquire takes the world's session lock as the access; gap: minor: no symlink warning
133 WorldOpenFlows.createLevelFromExistingSettings port crates/horizon-client/src/game.rs::Game::open_world starts the world with the given settings; gap: minor: no datapack repository is built starts the world with the given settings; the packs come from datapack::for_recreate and are installed before the world opens
134 WorldOpenFlows.loadWorldStem port crates/horizon-client/src/game/world_loading.rs::WorldRequest::prepare reads level data and generator settings; gap: minor: no datapack load or safe-mode retry reads level data and generator settings and discovers the world's packs (datapack::discover_with_features); gap: minor: no safe-mode retry when a pack fails to load
135 WorldOpenFlows.recreateWorldData port crates/horizon-client/src/title/create_world.rs::WorldOptions::of_world reads the old world's settings for Re-Create; title.rs click_action arm 15 fills name, seed and mode
136 WorldOpenFlows.loadWorldDataBlocking port crates/horizon-client/src/game/world_loading.rs::WorldLoader::finish waits for the load worker and returns its result
137 WorldOpenFlows.askForBackup port crates/horizon-client/src/title.rs::Title::play a world that needs its files fixed or comes from a newer version asks for a backup first (Mode::BackupBeforeUpgrade)
138 WorldOpenFlows.confirmWorldCreation skip:data lifecycle warning for experimental or deprecated data; Horizon has no such data
139 WorldOpenFlows.openWorld port crates/horizon-client/src/game/startup_menu.rs::StartupMenu::queue_entry Play (title.rs arm 10) queues the existing world for loading
140 WorldOpenFlows.openWorldLoadLevelData port crates/horizon-client/src/game/world_loading.rs::WorldRequest::prepare gap: player: a level.dat that will not read (Level::read gives None) opens with a random seed and the folder name, not the Recover World Data screen the level data is read by WorldRequest::prepare; a level.dat that does not read opens RecoverWorldDataScreen instead of the world (Title::play, Mode::Recover)
141 WorldOpenFlows.openWorldCheckVersionCompatibility port crates/horizon-client/src/title.rs::Title::play a world from another series opens the incompatible AlertScreen instead
142 WorldOpenFlows.createBackupAndOpenWorld port crates/horizon-client/src/title.rs::Title::start_backup the backup runs on its own thread; the world opens once it finishes (backup_then_open)
143 WorldOpenFlows.upgradeAndOpenWorld port crates/horizon-client/src/save.rs::upgrade_older_world runs the file fixes and upgrades on open; gap: player: no file-fix progress screen and no upgrade done, join now confirm runs the file fixes and upgrades on open under FileFixerProgressScreen (level_loading::draw_file_fixer, Cancel wired in game.rs); gap: player: no upgrade-done join-now confirm
144 WorldOpenFlows.tryFileFixAndReportErrors port crates/horizon-world/src/datafix/files.rs::fix_world_files runs the file fixer; gap: player: cancel, symlink and failed-cleanup alerts are not shown
145 WorldOpenFlows.openWorldLoadLevelStem port crates/horizon-client/src/game/world_loading.rs::WorldRequest::prepare loads the level stem and generator; gap: minor: no safe-mode retry
146 WorldOpenFlows.openWorldCheckWorldStemCompatibility port crates/horizon-client/src/title.rs::Title::after_prompt an old customized or experimental world asks its own question before the load, the stem issue of Mode::BackupBeforeUpgrade
147 WorldOpenFlows.openWorldLoadBundledResourcePack missing inline crates/horizon-client/src/game/server_packs.rs::Game::server_packs_world_pack player: the world's own resources.zip is not applied on open; only an old-world file fix moves it into resourcepacks (datafix fix 4772) the world's pack goes in as a local pack and the world connects (game/integrated.rs); a pack that fails to load has no prompt
148 WorldOpenFlows.openWorldCheckDiskSpace port crates/horizon-client/src/title.rs::Title::after_prompt little space left opens the LowDiskSpace ConfirmScreen instead of the world
149 WorldOpenFlows.loadBundledResourcePack missing port crates/horizon-client/src/game/server_packs.rs::Game::server_packs_world_pack player: pushes the world's resources.zip as a local pack; Horizon has no such path saves/<world>/resourcepacks/resources.zip is pushed as the world pack before the connect
150 WorldOpenFlows.promptBundledPackLoadFailure missing minor: prompt when the world's own pack fails to load; no such pack in Horizon
+1 -1
View File
@@ -113,7 +113,7 @@ Dolphin.addAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_
Dolphin.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_from_nbt GotFish and Moistness, 2400 when absent
Dolphin.registerGoals port crates/horizon-client/src/mobs/aquatic.rs::register gap: player: no MoveToItemGoal and PlayWithItemsGoal (dolphins ignore items thrown in the water), no FollowPlayerRiddenEntityGoal for boats or nautiluses, no AvoidEntity of guardians, and HurtByTarget does not skip guardians
Dolphin.createAttributes skip:data attributes come from the entity tables
Dolphin.canAttack missing minor: a baby dolphin that is hurt can still pick the attacker as target
Dolphin.canAttack port crates/horizon-client/src/mobs/goals.rs::spares_target a baby dolphin takes nobody as its target
Dolphin.pickUpItem port crates/horizon-client/src/game/foxes.rs::Game::tick_dolphins the game finds the first allowed item in the water within 8 blocks and takes it
Dolphin.handleEntityEvent port crates/horizon-client-render/src/particles.rs::entity_event_burst event 38: seven happy villager particles about the dolphin
Dolphin.closeToNextPos inline crates/horizon-client/src/mobs/dolphin.rs::SwimToTreasure::tick nav target within 12 blocks (144 squared) of the dolphin
1 MemoryMap.equals skip:java map equality of a built memory set
113 Dolphin.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_from_nbt GotFish and Moistness, 2400 when absent
114 Dolphin.registerGoals port crates/horizon-client/src/mobs/aquatic.rs::register gap: player: no MoveToItemGoal and PlayWithItemsGoal (dolphins ignore items thrown in the water), no FollowPlayerRiddenEntityGoal for boats or nautiluses, no AvoidEntity of guardians, and HurtByTarget does not skip guardians
115 Dolphin.createAttributes skip:data attributes come from the entity tables
116 Dolphin.canAttack missing port crates/horizon-client/src/mobs/goals.rs::spares_target minor: a baby dolphin that is hurt can still pick the attacker as target a baby dolphin takes nobody as its target
117 Dolphin.pickUpItem port crates/horizon-client/src/game/foxes.rs::Game::tick_dolphins the game finds the first allowed item in the water within 8 blocks and takes it
118 Dolphin.handleEntityEvent port crates/horizon-client-render/src/particles.rs::entity_event_burst event 38: seven happy villager particles about the dolphin
119 Dolphin.closeToNextPos inline crates/horizon-client/src/mobs/dolphin.rs::SwimToTreasure::tick nav target within 12 blocks (144 squared) of the dolphin
+4 -4
View File
@@ -89,7 +89,7 @@ Level.addBlockEntityTicker port crates/horizon-client/src/game.rs::GameLevel::lo
Level.guardEntityTick skip:debug crash-report wrapper around an entity tick
Level.getBlockEntity skip:other bare block_entities map lookup at each caller; no port of its own
Level.setBlockEntity port crates/horizon-client/src/game.rs::GameLevel::load_block_entity
Level.loadedAndEntityCanStandOnFace missing unsure: no face-sturdy test for placed hanging entities found
Level.loadedAndEntityCanStandOnFace inline crates/horizon-client/src/mobs/shulker.rs::can_stay_at the supporting block's face toward the shell is tested for sturdiness (`BlockState.entityCanStandOnFace`) before the opened shell's box; the chunk is loaded by construction where the sim asks
Level.getWorldBorderAdjustedRespawnData missing unsure: respawn clamp to the world border not found
Level.hasEntities missing unsure: no entity-in-box test found for this use
Level.getClockTimeTicks port crates/horizon-client/src/game.rs::GameLevel::day_time day time; no separate clock time
@@ -99,7 +99,7 @@ Level.setRainLevel port crates/horizon-client/src/game/remote.rs::Game::handle_p
Level.fillReportDetails skip:debug crash-report details
BlockGetter.getBlockEntity skip:other bare block_entities map lookup at each caller
BlockGetter.isBlockInLine skip:other no caller in 26.3 code
BlockGetter.clipWithInteractionOverride missing unsure: no interaction-shape override found for the pick ray; clip_ray not checked in depth
BlockGetter.clipWithInteractionOverride port crates/horizon-world/src/world_ray.rs::clip_with_interaction the block's interaction shape is clipped too, and when it lands nearer the hit takes its direction
BlockGetter.traverseBlocks port crates/horizon-core/src/geometry/traversal.rs::traverse_blocks
BlockGetter.forEachBlockIntersectedBetween port crates/horizon-core/src/geometry/sweep.rs::sweep_intersected_blocks
BlockGetter.addCollisionsAlongTravel inline crates/horizon-client-render/src/collisions.rs::SceneCollisions::resolve_with_context gap: minor: the shape list along the path is built inside the resolver
@@ -112,7 +112,7 @@ ChunkPos.getRegionZ port crates/horizon-core/src/position.rs::ChunkPos::region
ChunkPos.getBlockAt skip:other no caller in 26.3 code
ChunkPos.contains port crates/horizon-core/src/position.rs::BlockPos::chunk a block is in a chunk when its chunk() is that chunk
ChunkPos.getChessboardDistance inline crates/horizon-client/src/ticket_levels.rs::compute Chebyshev distance folded into the ticket level loop
ChunkPos.distanceSquared missing unsure: caller not identified in 26.3 sources; no port located
ChunkPos.distanceSquared inline crates/horizon-client/src/lan/hub.rs::requeue_ring the pending chunk queue is sorted by the squared chunk distance, the only use vanilla makes of it (PlayerChunkSender.collectChunksToSend)
CollisionGetter.isUnobstructed port crates/horizon-client/src/game.rs::GameLevel::is_unobstructed the state-based overload
CollisionGetter.noBorderCollision port crates/horizon-client-render/src/collisions.rs::EntityCollisions::movement_border border shape when inside close to the border
CollisionGetter.getPreMoveCollisions inline crates/horizon-client-render/src/collisions.rs::SceneCollisions::resolve_with_context gap: minor: entity and block shapes are not returned as a list; the resolver takes them together
@@ -132,7 +132,7 @@ LevelReader.getBlockStatesIfLoaded port crates/horizon-client-render/src/world.r
LevelReader.getNoiseBiome skip:other no caller in 26.3 code
LevelReader.getHeightmapPos port crates/horizon-client-render/src/world.rs::World::world_surface gap: minor: the height only; the BlockPos is built by the caller
LevelReader.getChunk missing unsure: same as Level.getChunk
LevelReader.findBlocksInBoxByManhattanDistance missing unsure: no block search by Manhattan distance found in Horizon
LevelReader.findBlocksInBoxByManhattanDistance inline crates/horizon-client/src/mobs/bee.rs::manhattan_box the box in vanilla's order (`BlockPos.withinBoxByManhattanDistance`); the first-match search over it is inlined at each caller, e.g. Pollinate::find_flower for findNearbyFlower
LevelWriter.destroyBlock port crates/horizon-world/src/level/update.rs::Sim::destroy_block
LocalMobCapCalculator.getPlayersNear port crates/horizon-client/src/mobs/spawn_selection.rs::SpawnState::players_near
LocalMobCapCalculator.addMob port crates/horizon-client/src/mobs/spawn_selection.rs::SpawnState::add_local
1 Enchantment.getSlotItems inline crates/horizon-world/src/loot.rs::LootData::enchantment_level slot filter and item lookup folded into the best-level loop
89 Level.guardEntityTick skip:debug crash-report wrapper around an entity tick
90 Level.getBlockEntity skip:other bare block_entities map lookup at each caller; no port of its own
91 Level.setBlockEntity port crates/horizon-client/src/game.rs::GameLevel::load_block_entity
92 Level.loadedAndEntityCanStandOnFace missing inline crates/horizon-client/src/mobs/shulker.rs::can_stay_at unsure: no face-sturdy test for placed hanging entities found the supporting block's face toward the shell is tested for sturdiness (`BlockState.entityCanStandOnFace`) before the opened shell's box; the chunk is loaded by construction where the sim asks
93 Level.getWorldBorderAdjustedRespawnData missing unsure: respawn clamp to the world border not found
94 Level.hasEntities missing unsure: no entity-in-box test found for this use
95 Level.getClockTimeTicks port crates/horizon-client/src/game.rs::GameLevel::day_time day time; no separate clock time
99 Level.fillReportDetails skip:debug crash-report details
100 BlockGetter.getBlockEntity skip:other bare block_entities map lookup at each caller
101 BlockGetter.isBlockInLine skip:other no caller in 26.3 code
102 BlockGetter.clipWithInteractionOverride missing port crates/horizon-world/src/world_ray.rs::clip_with_interaction unsure: no interaction-shape override found for the pick ray; clip_ray not checked in depth the block's interaction shape is clipped too, and when it lands nearer the hit takes its direction
103 BlockGetter.traverseBlocks port crates/horizon-core/src/geometry/traversal.rs::traverse_blocks
104 BlockGetter.forEachBlockIntersectedBetween port crates/horizon-core/src/geometry/sweep.rs::sweep_intersected_blocks
105 BlockGetter.addCollisionsAlongTravel inline crates/horizon-client-render/src/collisions.rs::SceneCollisions::resolve_with_context gap: minor: the shape list along the path is built inside the resolver
112 ChunkPos.getBlockAt skip:other no caller in 26.3 code
113 ChunkPos.contains port crates/horizon-core/src/position.rs::BlockPos::chunk a block is in a chunk when its chunk() is that chunk
114 ChunkPos.getChessboardDistance inline crates/horizon-client/src/ticket_levels.rs::compute Chebyshev distance folded into the ticket level loop
115 ChunkPos.distanceSquared missing inline crates/horizon-client/src/lan/hub.rs::requeue_ring unsure: caller not identified in 26.3 sources; no port located the pending chunk queue is sorted by the squared chunk distance, the only use vanilla makes of it (PlayerChunkSender.collectChunksToSend)
116 CollisionGetter.isUnobstructed port crates/horizon-client/src/game.rs::GameLevel::is_unobstructed the state-based overload
117 CollisionGetter.noBorderCollision port crates/horizon-client-render/src/collisions.rs::EntityCollisions::movement_border border shape when inside close to the border
118 CollisionGetter.getPreMoveCollisions inline crates/horizon-client-render/src/collisions.rs::SceneCollisions::resolve_with_context gap: minor: entity and block shapes are not returned as a list; the resolver takes them together
132 LevelReader.getNoiseBiome skip:other no caller in 26.3 code
133 LevelReader.getHeightmapPos port crates/horizon-client-render/src/world.rs::World::world_surface gap: minor: the height only; the BlockPos is built by the caller
134 LevelReader.getChunk missing unsure: same as Level.getChunk
135 LevelReader.findBlocksInBoxByManhattanDistance missing inline crates/horizon-client/src/mobs/bee.rs::manhattan_box unsure: no block search by Manhattan distance found in Horizon the box in vanilla's order (`BlockPos.withinBoxByManhattanDistance`); the first-match search over it is inlined at each caller, e.g. Pollinate::find_flower for findNearbyFlower
136 LevelWriter.destroyBlock port crates/horizon-world/src/level/update.rs::Sim::destroy_block
137 LocalMobCapCalculator.getPlayersNear port crates/horizon-client/src/mobs/spawn_selection.rs::SpawnState::players_near
138 LocalMobCapCalculator.addMob port crates/horizon-client/src/mobs/spawn_selection.rs::SpawnState::add_local
+14 -3
View File
@@ -18,7 +18,9 @@ It only grows: the last line is the current version, and a change to the output
The digest covers every file of the preparation, canonicalised where the preparer is not deterministic between
runs (verified across the CI runners): JSON is compared as data (key order, the order of each `tags` list of
creative-inventory.json, and the addresses in `$$Lambda/0x...` class names don't count); logs, compiled classes,
the sounds (copied from the player's launcher by another step), the stamp naming the OUTPUT_VERSION
the sounds, the languages but the JAR's and the asset index (copied from the player's launcher by another step; CI
clones the languages into a preparation after the fact), the game's run-time caches (horizon-sprites.cache,
horizon-data.bin), the stamp naming the OUTPUT_VERSION
(horizon-prepare.json), the lock file (.horizon-prepare.lock) and the manifest's file hashes (raw bytes) don't count. The manifest's own scalar fields (schema, data version, jar hashes, file count) do.
"""
@@ -41,6 +43,12 @@ MANIFEST = 'horizon-resources.json'
STAMP = 'horizon-prepare.json'
# The empty file a preparation locks while it writes (crates/horizon-prepare/src/lib.rs, LOCK).
LOCK = '.horizon-prepare.lock'
# Written into a preparation by something other than the preparer's export: the sounds and languages step
# (crates/horizon-prepare/src/sounds.rs) and, at run time, the game's caches (horizon-client-render's sprite_cache.rs,
# horizon-core's prepared_files.rs). Whether a harness ran the game before the check must not change the digest.
DATA_FILE = 'horizon-data.bin'
FROM_ELSEWHERE = ('assets/minecraft/sounds.json', 'assets/pack.mcmeta', 'asset-index.json', 'horizon-sprites.cache',
DATA_FILE, STAMP, LOCK)
LAMBDA = re.compile(r'\$\$Lambda/0x[0-9a-fA-F]+')
CONSTANT = re.compile(r'^pub const OUTPUT_VERSION: u32 = (\d+);', re.M)
@@ -74,8 +82,11 @@ def canonical(rel: str, data: bytes) -> bytes:
def skipped(rel: str) -> bool:
parts = rel.split('/')
return rel.endswith(('.log', '.class')) or parts[:3] == ['assets', 'minecraft', 'sounds'] \
or rel in ('assets/minecraft/sounds.json', STAMP, LOCK)
# The languages but the client JAR's own come from the launcher's asset store like the sounds; CI clones them
# into a preparation after the fact (tools/ci/vanilla-data.sh).
languages = parts[:3] == ['assets', 'minecraft', 'lang'] and parts[3:] not in (['en_us.json'], ['deprecated.json'])
return rel.endswith(('.log', '.class')) or parts[:3] == ['assets', 'minecraft', 'sounds'] or languages \
or rel in FROM_ELSEWHERE or rel.startswith(f'{DATA_FILE}.')
def digest(resources: Path) -> tuple[str, int]:
+3 -2
View File
@@ -20,8 +20,9 @@ sent the same things over TLS, and every answer is compared exactly:
`server/stop`;
- the TLS version each server agrees on.
A parse error's `data` is Gson's message in vanilla and serde's in Horizon: left out. The notifications vanilla
sends meanwhile (`server/started`, `server/saving`...) are set aside: Horizon doesn't send them yet.
A parse error's `data` is Gson's message in vanilla and serde's in Horizon: left out. The notifications the
servers send meanwhile (`server/stopping`...) are set aside, and their names are kept in each side's report
(`notifications`); Horizon sends `server/started` and `server/stopping` so far, not yet the rest.
"""
import base64
import json