mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Merge remote-tracking branch 'origin/main' into fix/7080-entity-world-border
This commit is contained in:
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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"))]);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
+14
-3
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user