mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Test LAN hosting against the vanilla client and track entities as ServerEntity does
Add a decoding capture proxy (tools/net_capture.py), an offline vanilla client launcher (tools/vanilla_client.py) and a vanilla codec oracle that decodes every captured clientbound packet with vanilla's StreamCodecs. Fix the entity data serializer ids (vector3 onwards were off by one), the sheep wool and piglin baby indices, send mob variants, villager data and equipment, and send entity movement at each type's update interval and tracking range with position syncs, like ServerEntity.sendChanges. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
1e03d531eb
commit
120ac32311
10 files changed
+1274
-194
No files matched your search
@@ -58,38 +58,215 @@ fn entity_uuid(id: u32) -> u128 {
|
||||
0x4856_5a4e_0000_0000_0000_0000_0000_0000u128 | u128::from(id)
|
||||
}
|
||||
|
||||
/// Species that are AgeableMob (or have a baby flag at the same index).
|
||||
fn ageable(kind: &str) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
/// The SynchedEntityData index of the baby flag: AgeableMob.DATA_BABY_ID and
|
||||
/// Zombie.DATA_BABY_ID are both the first entry after Mob's (16); Piglin's
|
||||
/// comes after AbstractPiglin's zombification flag (17).
|
||||
fn baby_index(kind: &str) -> Option<u8> {
|
||||
match kind {
|
||||
"minecraft:piglin" => Some(17),
|
||||
"minecraft:cow"
|
||||
| "minecraft:mooshroom"
|
||||
| "minecraft:pig"
|
||||
| "minecraft:sheep"
|
||||
| "minecraft:chicken"
|
||||
| "minecraft:horse"
|
||||
| "minecraft:donkey"
|
||||
| "minecraft:mule"
|
||||
| "minecraft:llama"
|
||||
| "minecraft:cat"
|
||||
| "minecraft:wolf"
|
||||
| "minecraft:fox"
|
||||
| "minecraft:rabbit"
|
||||
| "minecraft:panda"
|
||||
| "minecraft:villager"
|
||||
| "minecraft:zombie"
|
||||
| "minecraft:husk"
|
||||
| "minecraft:drowned"
|
||||
| "minecraft:zombie_villager"
|
||||
| "minecraft:piglin"
|
||||
| "minecraft:hoglin"
|
||||
| "minecraft:goat"
|
||||
| "minecraft:ocelot"
|
||||
| "minecraft:turtle"
|
||||
| "minecraft:strider"
|
||||
| "minecraft:camel"
|
||||
| "minecraft:axolotl"
|
||||
)
|
||||
| "minecraft:mooshroom"
|
||||
| "minecraft:pig"
|
||||
| "minecraft:sheep"
|
||||
| "minecraft:chicken"
|
||||
| "minecraft:horse"
|
||||
| "minecraft:donkey"
|
||||
| "minecraft:mule"
|
||||
| "minecraft:skeleton_horse"
|
||||
| "minecraft:zombie_horse"
|
||||
| "minecraft:llama"
|
||||
| "minecraft:trader_llama"
|
||||
| "minecraft:cat"
|
||||
| "minecraft:wolf"
|
||||
| "minecraft:fox"
|
||||
| "minecraft:rabbit"
|
||||
| "minecraft:panda"
|
||||
| "minecraft:polar_bear"
|
||||
| "minecraft:bee"
|
||||
| "minecraft:frog"
|
||||
| "minecraft:sniffer"
|
||||
| "minecraft:armadillo"
|
||||
| "minecraft:villager"
|
||||
| "minecraft:zombie"
|
||||
| "minecraft:husk"
|
||||
| "minecraft:drowned"
|
||||
| "minecraft:zombie_villager"
|
||||
| "minecraft:zombified_piglin"
|
||||
| "minecraft:hoglin"
|
||||
| "minecraft:goat"
|
||||
| "minecraft:ocelot"
|
||||
| "minecraft:turtle"
|
||||
| "minecraft:strider"
|
||||
| "minecraft:camel"
|
||||
| "minecraft:axolotl"
|
||||
| "minecraft:dolphin"
|
||||
| "minecraft:squid"
|
||||
| "minecraft:glow_squid" => Some(16),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A mob's synched data as ServerEntity sends it (only the values that differ
|
||||
/// from the defaults the client constructs), with vanilla's entry indices:
|
||||
/// Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses.
|
||||
fn mob_meta(m: &Mob, hub: &Hub) -> Vec<(u8, i32, Value)> {
|
||||
let mut meta = Vec::new();
|
||||
let kind = m.kind.id.as_str();
|
||||
if m.fire_ticks > 0 {
|
||||
meta.push((0, serializer::BYTE, Value::Byte(1)));
|
||||
}
|
||||
if let Some(name) = &m.custom_name {
|
||||
meta.push((
|
||||
2,
|
||||
serializer::OPTIONAL_COMPONENT,
|
||||
Value::OptText(Some(horizon_protocol::text::literal(name))),
|
||||
));
|
||||
if m.name_visible {
|
||||
meta.push((3, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
}
|
||||
if m.health < m.kind.max_health {
|
||||
meta.push((9, serializer::FLOAT, Value::Float(m.health.max(0.0))));
|
||||
}
|
||||
if m.is_baby()
|
||||
&& let Some(index) = baby_index(kind)
|
||||
{
|
||||
meta.push((index, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
let e = &m.extra;
|
||||
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
|
||||
match kind {
|
||||
// Sheep.DATA_WOOL_ID: the colour in the low nibble, 16 when sheared.
|
||||
"minecraft:sheep" => {
|
||||
let value = (e.color & 15) | if e.sheared { 16 } else { 0 };
|
||||
if value != 0 {
|
||||
meta.push((18, serializer::BYTE, Value::Byte(value as i8)));
|
||||
}
|
||||
}
|
||||
"minecraft:cat" | "minecraft:wolf" => {
|
||||
// TamableAnimal.DATA_FLAGS_ID: sitting 1, tame 4.
|
||||
let flags = i8::from(e.sitting) | if e.tame { 4 } else { 0 };
|
||||
if flags != 0 {
|
||||
meta.push((18, serializer::BYTE, Value::Byte(flags)));
|
||||
}
|
||||
let (index, registry, name) = if kind == "minecraft:cat" {
|
||||
let v = horizon_client::mobs::tame::CAT_VARIANTS[(e.variant as usize).min(10)];
|
||||
(20, "minecraft:cat_variant", v)
|
||||
} else {
|
||||
let v = horizon_client::mobs::tame::WOLF_VARIANTS[(e.variant as usize).min(8)].0;
|
||||
(23, "minecraft:wolf_variant", v)
|
||||
};
|
||||
let serializer = if index == 20 {
|
||||
serializer::CAT_VARIANT
|
||||
} else {
|
||||
serializer::WOLF_VARIANT
|
||||
};
|
||||
if let Some(v) = id(registry, name) {
|
||||
meta.push((index, serializer, Value::Id(v)));
|
||||
}
|
||||
}
|
||||
"minecraft:horse"
|
||||
| "minecraft:donkey"
|
||||
| "minecraft:mule"
|
||||
| "minecraft:llama"
|
||||
| "minecraft:trader_llama"
|
||||
| "minecraft:skeleton_horse"
|
||||
| "minecraft:zombie_horse"
|
||||
| "minecraft:camel" => {
|
||||
// AbstractHorse.DATA_ID_FLAGS: tame 2.
|
||||
if e.tame {
|
||||
meta.push((18, serializer::BYTE, Value::Byte(2)));
|
||||
}
|
||||
if kind == "minecraft:horse" {
|
||||
let v = i32::from(e.variant) | i32::from(e.variant2) << 8;
|
||||
if v != 0 {
|
||||
meta.push((19, serializer::INT, Value::Int(v)));
|
||||
}
|
||||
} else if kind.ends_with("llama") {
|
||||
if e.chested {
|
||||
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
meta.push((20, serializer::INT, Value::Int(i32::from(e.variant2.max(1)))));
|
||||
if e.variant != 0 {
|
||||
meta.push((21, serializer::INT, Value::Int(i32::from(e.variant))));
|
||||
}
|
||||
} else if (kind == "minecraft:donkey" || kind == "minecraft:mule") && e.chested {
|
||||
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
}
|
||||
// Villager.DATA_VILLAGER_DATA and ZombieVillager's (after Zombie's three).
|
||||
"minecraft:villager" | "minecraft:zombie_villager" => {
|
||||
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
|
||||
let villager_type = VILLAGER_TYPES[e.variant as usize % VILLAGER_TYPES.len()];
|
||||
let profession = PROFESSIONS[e.variant2 as usize % PROFESSIONS.len()];
|
||||
if let (Some(t), Some(p)) = (
|
||||
id("minecraft:villager_type", villager_type),
|
||||
id("minecraft:villager_profession", profession),
|
||||
) {
|
||||
let index = if kind == "minecraft:villager" { 19 } else { 20 };
|
||||
let level = i32::from(e.variant3.max(1));
|
||||
meta.push((index, serializer::VILLAGER_DATA, Value::Ids(vec![t, p, level])));
|
||||
}
|
||||
}
|
||||
// AbstractCubeMob.ID_SIZE.
|
||||
"minecraft:slime" | "minecraft:magma_cube" => {
|
||||
if e.size > 1 {
|
||||
meta.push((18, serializer::INT, Value::Int(e.size)));
|
||||
}
|
||||
}
|
||||
// Creeper.DATA_IS_POWERED.
|
||||
"minecraft:creeper" if e.charged => {
|
||||
meta.push((17, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
// Enderman.DATA_CARRY_STATE.
|
||||
"minecraft:enderman" => {
|
||||
if let Some(state) = e.carried {
|
||||
meta.push((
|
||||
16,
|
||||
serializer::OPTIONAL_BLOCK_STATE,
|
||||
Value::Id(i32::from(state.0 as u16)),
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
meta
|
||||
}
|
||||
|
||||
/// The equipment a mob shows (ClientboundSetEquipmentPacket slots: main hand,
|
||||
/// off hand, feet, legs, chest, head, body, saddle).
|
||||
fn mob_equipment(m: &Mob, hub: &mut Hub) -> Vec<(u8, Option<horizon_protocol::item::Stack>)> {
|
||||
let e = &m.extra;
|
||||
let mut out = Vec::new();
|
||||
let slots = [
|
||||
(0u8, &e.main_hand),
|
||||
(1, &e.off_hand),
|
||||
(2, &e.feet),
|
||||
(3, &e.legs),
|
||||
(4, &e.chest),
|
||||
(5, &e.head),
|
||||
];
|
||||
for (slot, item) in slots {
|
||||
if let Some(item) = item {
|
||||
let stack = ItemStack {
|
||||
item: item.clone(),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
};
|
||||
out.push((slot, hub.stack(&Some(stack))));
|
||||
}
|
||||
}
|
||||
if e.saddled {
|
||||
let stack = e.saddle_stack.clone().unwrap_or(ItemStack {
|
||||
item: Arc::from("minecraft:saddle"),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
});
|
||||
out.push((7, hub.stack(&Some(stack))));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub const HELP: &[&str] = &[
|
||||
@@ -307,31 +484,9 @@ impl Game<'_> {
|
||||
fn lan_entities(&self, hub: &mut Hub) -> Vec<EntityState> {
|
||||
let mut out = Vec::with_capacity(self.mobs.len() + self.dropped.len());
|
||||
for m in self.mobs.iter().filter(|m| !m.removed) {
|
||||
let mut meta = Vec::new();
|
||||
if let Some(name) = &m.custom_name {
|
||||
meta.push((
|
||||
2,
|
||||
serializer::OPTIONAL_COMPONENT,
|
||||
Value::OptText(Some(horizon_protocol::text::literal(name))),
|
||||
));
|
||||
if m.name_visible {
|
||||
meta.push((3, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
}
|
||||
if m.fire_ticks > 0 {
|
||||
meta.push((0, serializer::BYTE, Value::Byte(1)));
|
||||
}
|
||||
if m.health < m.kind.max_health {
|
||||
meta.push((9, serializer::FLOAT, Value::Float(m.health.max(0.0))));
|
||||
}
|
||||
let kind = m.kind.id.as_str();
|
||||
if ageable(kind) && m.is_baby() {
|
||||
meta.push((16, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
if kind == "minecraft:sheep" {
|
||||
let value = (m.extra.color & 15) | if m.extra.sheared { 16 } else { 0 };
|
||||
meta.push((17, serializer::BYTE, Value::Byte(value as i8)));
|
||||
}
|
||||
let meta = mob_meta(m, hub);
|
||||
let equipment = mob_equipment(m, hub);
|
||||
let (yaw, pitch) = (m.yaw, m.head_pitch);
|
||||
out.push(EntityState {
|
||||
id: m.id as i32,
|
||||
@@ -345,8 +500,7 @@ impl Game<'_> {
|
||||
velocity: [m.velocity.x, m.velocity.y, m.velocity.z],
|
||||
data: 0,
|
||||
meta,
|
||||
equipment: Vec::new(),
|
||||
send_velocity: false,
|
||||
equipment,
|
||||
});
|
||||
}
|
||||
for d in &self.dropped {
|
||||
@@ -364,7 +518,6 @@ impl Game<'_> {
|
||||
data: 0,
|
||||
meta: vec![(8, serializer::ITEM_STACK, Value::Item(stack))],
|
||||
equipment: Vec::new(),
|
||||
send_velocity: true,
|
||||
});
|
||||
}
|
||||
out
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
//! Entity tracking (ServerEntity / TrackedEntity): the game describes every
|
||||
//! entity it simulates as an `EntityState` each tick; a `Tracker` remembers
|
||||
//! what one client has been told and produces the spawn, movement, metadata,
|
||||
//! equipment and removal packets that bring it up to date.
|
||||
//! Entity tracking (ChunkMap.TrackedEntity / ServerEntity): the game describes
|
||||
//! every entity it simulates as an `EntityState` each tick; a `Tracker`
|
||||
//! remembers what one client has been told and produces the spawn, movement,
|
||||
//! motion, metadata, equipment and removal packets that bring it up to date,
|
||||
//! at the entity type's update interval (`tracking`).
|
||||
use super::tracking;
|
||||
use horizon_protocol::{
|
||||
buf::pack_degrees,
|
||||
entity_data::Value,
|
||||
item::Stack,
|
||||
packets::play::{
|
||||
self, AddEntity, MoveEntity, MoveEntityPos, MoveEntityPosRot, MoveEntityRot, RotateHead,
|
||||
SetEntityData, SetEntityMotion, SetEquipment, TeleportEntity, VecDelta,
|
||||
self, AddEntity, EntityPositionSync, MoveEntity, MoveEntityPos, MoveEntityPosRot,
|
||||
MoveEntityRot, PositionPath, RotateHead, SetEntityData, SetEntityMotion, SetEquipment,
|
||||
VecDelta,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -30,24 +33,27 @@ pub struct EntityState {
|
||||
/// SynchedEntityData entries that differ from the defaults.
|
||||
pub meta: Vec<(u8, i32, Value)>,
|
||||
/// EquipmentSlot ordinals (0 main hand, 1 off hand, 2 feet, 3 legs,
|
||||
/// 4 chest, 5 head) and their stacks.
|
||||
/// 4 chest, 5 head, 6 body, 7 saddle) and their stacks.
|
||||
pub equipment: Vec<(u8, Option<Stack>)>,
|
||||
/// Whether movement packets should include a velocity (items, projectiles).
|
||||
pub send_velocity: bool,
|
||||
}
|
||||
|
||||
/// What a client was last told about one entity.
|
||||
/// What a client was last told about one entity (ServerEntity's fields).
|
||||
struct Sent {
|
||||
/// The position in 1/4096 blocks (VecDeltaCodec base).
|
||||
base: [i64; 3],
|
||||
position: [f64; 3],
|
||||
/// VecDeltaCodec's base: the last position sent.
|
||||
base: [f64; 3],
|
||||
/// lastSentYRot / lastSentXRot / lastSentYHeadRot.
|
||||
rotation: (i8, i8),
|
||||
head: i8,
|
||||
/// lastSentMovement.
|
||||
velocity: [f64; 3],
|
||||
meta: Vec<(u8, i32, Value)>,
|
||||
equipment: Vec<(u8, Option<Stack>)>,
|
||||
on_ground: bool,
|
||||
ticks: u32,
|
||||
was_on_ground: bool,
|
||||
tick: u32,
|
||||
teleport_delay: u32,
|
||||
/// The type's update interval (0: never) and whether velocity is tracked.
|
||||
interval: u32,
|
||||
track_deltas: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -55,19 +61,16 @@ pub struct Tracker {
|
||||
sent: std::collections::HashMap<i32, Sent>,
|
||||
}
|
||||
|
||||
fn quantize(p: [f64; 3]) -> [i64; 3] {
|
||||
[
|
||||
(p[0] * 4096.0).round() as i64,
|
||||
(p[1] * 4096.0).round() as i64,
|
||||
(p[2] * 4096.0).round() as i64,
|
||||
]
|
||||
/// VecDeltaCodec.encode.
|
||||
fn encode(v: f64) -> i64 {
|
||||
(v * 4096.0).round() as i64
|
||||
}
|
||||
fn dequantize(q: [i64; 3]) -> [f64; 3] {
|
||||
[
|
||||
q[0] as f64 / 4096.0,
|
||||
q[1] as f64 / 4096.0,
|
||||
q[2] as f64 / 4096.0,
|
||||
]
|
||||
|
||||
/// The horizontal range within which a client sees an entity of `kind`
|
||||
/// (TrackedEntity.updatePlayer: the type's range, capped by the view distance).
|
||||
pub fn visible_range(kind: &str, view_distance: i32) -> f64 {
|
||||
let chunks = i32::from(tracking::settings(kind).0);
|
||||
f64::from(chunks.min(view_distance) * 16)
|
||||
}
|
||||
|
||||
impl Tracker {
|
||||
@@ -94,6 +97,7 @@ impl Tracker {
|
||||
pub fn spawn(&mut self, e: &EntityState, entity_type: i32) -> Vec<play::Clientbound> {
|
||||
let rotation = (pack_degrees(e.pitch), pack_degrees(e.yaw));
|
||||
let head = pack_degrees(e.head_yaw);
|
||||
let (_, interval, track_deltas) = tracking::settings(&e.kind);
|
||||
let mut out = vec![play::Clientbound::AddEntity(AddEntity {
|
||||
id: e.id,
|
||||
uuid: e.uuid,
|
||||
@@ -120,114 +124,132 @@ impl Tracker {
|
||||
self.sent.insert(
|
||||
e.id,
|
||||
Sent {
|
||||
base: quantize(e.position),
|
||||
position: e.position,
|
||||
base: e.position,
|
||||
rotation,
|
||||
head,
|
||||
velocity: e.velocity,
|
||||
meta: e.meta.clone(),
|
||||
equipment: e.equipment.clone(),
|
||||
on_ground: e.on_ground,
|
||||
ticks: 0,
|
||||
was_on_ground: e.on_ground,
|
||||
tick: 0,
|
||||
teleport_delay: 0,
|
||||
interval,
|
||||
track_deltas,
|
||||
},
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
/// Changes since the last update (ServerEntity.sendChanges).
|
||||
/// Changes since the last update (ServerEntity.sendChanges): movement and
|
||||
/// rotation every update interval (positions at least every 60 ticks, a
|
||||
/// full position sync every 400 or when the entity lands or leaves the
|
||||
/// ground), velocity for types that track it, changed data at once.
|
||||
pub fn update(&mut self, e: &EntityState) -> Vec<play::Clientbound> {
|
||||
let Some(s) = self.sent.get_mut(&e.id) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
s.ticks += 1;
|
||||
let rotation = (pack_degrees(e.pitch), pack_degrees(e.yaw));
|
||||
let rotated = rotation != s.rotation;
|
||||
let now = quantize(e.position);
|
||||
let delta = [now[0] - s.base[0], now[1] - s.base[1], now[2] - s.base[2]];
|
||||
let moved = delta != [0; 3];
|
||||
let fits = delta.iter().all(|d| i16::try_from(*d).is_ok());
|
||||
if moved && !fits {
|
||||
out.push(play::Clientbound::TeleportEntity(TeleportEntity {
|
||||
let dirty = e.meta != s.meta;
|
||||
let due = s.interval != 0 && s.tick.is_multiple_of(s.interval);
|
||||
if due || dirty {
|
||||
let rotation = (pack_degrees(e.pitch), pack_degrees(e.yaw));
|
||||
let turned = |a: i8, b: i8| (i32::from(a) - i32::from(b)).abs() >= 1;
|
||||
let rotate = turned(rotation.1, s.rotation.1) || turned(rotation.0, s.rotation.0);
|
||||
s.teleport_delay += 1;
|
||||
let d: f64 = (0..3).map(|k| (e.position[k] - s.base[k]).powi(2)).sum();
|
||||
let send_position = d >= 7.629_394_5e-6 || s.tick.is_multiple_of(60);
|
||||
let sync = EntityPositionSync {
|
||||
id: e.id,
|
||||
position: e.position,
|
||||
delta: e.velocity,
|
||||
path: PositionPath::Linear(e.position),
|
||||
yaw: e.yaw,
|
||||
pitch: e.pitch,
|
||||
relatives: 0,
|
||||
on_ground: e.on_ground,
|
||||
}));
|
||||
s.base = now;
|
||||
} else if moved || rotated || e.on_ground != s.on_ground {
|
||||
let d = VecDelta::Linear([delta[0] as i16, delta[1] as i16, delta[2] as i16]);
|
||||
let rot = Some((rotation.1, rotation.0));
|
||||
let packet = if moved && rotated {
|
||||
play::Clientbound::MoveEntityPosRot(MoveEntityPosRot(MoveEntity {
|
||||
id: e.id,
|
||||
delta: Some(d),
|
||||
rotation: rot,
|
||||
on_ground: e.on_ground,
|
||||
}))
|
||||
} else if moved {
|
||||
play::Clientbound::MoveEntityPos(MoveEntityPos(MoveEntity {
|
||||
id: e.id,
|
||||
delta: Some(d),
|
||||
rotation: None,
|
||||
on_ground: e.on_ground,
|
||||
}))
|
||||
} else {
|
||||
play::Clientbound::MoveEntityRot(MoveEntityRot(MoveEntity {
|
||||
};
|
||||
// ServerEntity.createMovePacket.
|
||||
let packet = if s.teleport_delay > 400 || s.was_on_ground != e.on_ground {
|
||||
s.was_on_ground = e.on_ground;
|
||||
s.teleport_delay = 0;
|
||||
Some(play::Clientbound::EntityPositionSync(sync))
|
||||
} else if send_position {
|
||||
let delta = [0, 1, 2].map(|k| encode(e.position[k]) - encode(s.base[k]));
|
||||
if delta.iter().any(|v| i16::try_from(*v).is_err()) {
|
||||
Some(play::Clientbound::EntityPositionSync(sync))
|
||||
} else {
|
||||
let m = MoveEntity {
|
||||
id: e.id,
|
||||
delta: Some(VecDelta::Linear(delta.map(|v| v as i16))),
|
||||
rotation: rotate.then_some((rotation.1, rotation.0)),
|
||||
on_ground: e.on_ground,
|
||||
};
|
||||
Some(if rotate {
|
||||
play::Clientbound::MoveEntityPosRot(MoveEntityPosRot(m))
|
||||
} else {
|
||||
play::Clientbound::MoveEntityPos(MoveEntityPos(m))
|
||||
})
|
||||
}
|
||||
} else if rotate {
|
||||
Some(play::Clientbound::MoveEntityRot(MoveEntityRot(MoveEntity {
|
||||
id: e.id,
|
||||
delta: None,
|
||||
rotation: rot,
|
||||
rotation: Some((rotation.1, rotation.0)),
|
||||
on_ground: e.on_ground,
|
||||
}))
|
||||
})))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
out.push(packet);
|
||||
if moved {
|
||||
s.base = now;
|
||||
if s.track_deltas {
|
||||
let diff: f64 = (0..3).map(|k| (e.velocity[k] - s.velocity[k]).powi(2)).sum();
|
||||
let still = e.velocity.iter().all(|v| *v == 0.0);
|
||||
if diff > 1.0e-7 || diff > 0.0 && still {
|
||||
s.velocity = e.velocity;
|
||||
out.push(play::Clientbound::SetEntityMotion(SetEntityMotion {
|
||||
id: e.id,
|
||||
velocity: e.velocity,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
if moved || rotated || e.on_ground != s.on_ground {
|
||||
s.rotation = rotation;
|
||||
s.on_ground = e.on_ground;
|
||||
}
|
||||
s.position = dequantize(s.base);
|
||||
let head = pack_degrees(e.head_yaw);
|
||||
if head != s.head {
|
||||
out.push(play::Clientbound::RotateHead(RotateHead {
|
||||
id: e.id,
|
||||
head_yaw: head,
|
||||
}));
|
||||
s.head = head;
|
||||
}
|
||||
if e.send_velocity {
|
||||
let dv: f64 = (0..3)
|
||||
.map(|i| (e.velocity[i] - s.velocity[i]).powi(2))
|
||||
.sum();
|
||||
if dv > 1.0e-7 || (e.velocity == [0.0; 3] && s.velocity != [0.0; 3]) {
|
||||
out.push(play::Clientbound::SetEntityMotion(SetEntityMotion {
|
||||
if let Some(packet) = packet {
|
||||
let (position, rotated) = match &packet {
|
||||
play::Clientbound::EntityPositionSync(_)
|
||||
| play::Clientbound::MoveEntityPosRot(_) => (true, true),
|
||||
play::Clientbound::MoveEntityPos(_) => (true, false),
|
||||
_ => (false, true),
|
||||
};
|
||||
out.push(packet);
|
||||
if position {
|
||||
s.base = e.position;
|
||||
}
|
||||
if rotated {
|
||||
s.rotation = rotation;
|
||||
}
|
||||
}
|
||||
// ServerEntity.sendDirtyEntityData.
|
||||
if dirty {
|
||||
let changed: Vec<_> = e
|
||||
.meta
|
||||
.iter()
|
||||
.filter(|m| !s.meta.contains(m))
|
||||
.cloned()
|
||||
.collect();
|
||||
if !changed.is_empty() {
|
||||
out.push(play::Clientbound::SetEntityData(SetEntityData {
|
||||
id: e.id,
|
||||
values: changed,
|
||||
}));
|
||||
}
|
||||
s.meta = e.meta.clone();
|
||||
}
|
||||
let head = pack_degrees(e.head_yaw);
|
||||
if turned(head, s.head) {
|
||||
out.push(play::Clientbound::RotateHead(RotateHead {
|
||||
id: e.id,
|
||||
velocity: e.velocity,
|
||||
head_yaw: head,
|
||||
}));
|
||||
s.velocity = e.velocity;
|
||||
s.head = head;
|
||||
}
|
||||
}
|
||||
if e.meta != s.meta {
|
||||
let changed: Vec<_> = e
|
||||
.meta
|
||||
.iter()
|
||||
.filter(|m| !s.meta.contains(m))
|
||||
.cloned()
|
||||
.collect();
|
||||
if !changed.is_empty() {
|
||||
out.push(play::Clientbound::SetEntityData(SetEntityData {
|
||||
id: e.id,
|
||||
values: changed,
|
||||
}));
|
||||
}
|
||||
s.meta = e.meta.clone();
|
||||
}
|
||||
s.tick += 1;
|
||||
// LivingEntity.detectEquipmentUpdates: changed slots every tick.
|
||||
if e.equipment != s.equipment {
|
||||
// Cleared slots are sent as empty stacks.
|
||||
let mut slots: Vec<(u8, Option<Stack>)> = e
|
||||
@@ -271,20 +293,46 @@ mod tests {
|
||||
data: 0,
|
||||
meta: Vec::new(),
|
||||
equipment: Vec::new(),
|
||||
send_velocity: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_moves_are_relative_and_large_ones_teleport() {
|
||||
fn moves_go_out_every_update_interval() {
|
||||
let mut t = Tracker::default();
|
||||
let spawn = t.spawn(&state(0.0), 100);
|
||||
assert!(matches!(spawn[0], play::Clientbound::AddEntity(_)));
|
||||
assert!(t.update(&state(0.0)).is_empty());
|
||||
let step = t.update(&state(0.5));
|
||||
assert!(matches!(step[0], play::Clientbound::MoveEntityPos(_)));
|
||||
// Tick 0 is due and a multiple of 60: the position goes out unchanged.
|
||||
assert!(matches!(
|
||||
t.update(&state(0.0))[..],
|
||||
[play::Clientbound::MoveEntityPos(_)]
|
||||
));
|
||||
// A zombie updates every third tick.
|
||||
assert!(t.update(&state(0.5)).is_empty());
|
||||
assert!(t.update(&state(0.5)).is_empty());
|
||||
assert!(matches!(
|
||||
t.update(&state(0.5))[..],
|
||||
[play::Clientbound::MoveEntityPos(_)]
|
||||
));
|
||||
for _ in 0..2 {
|
||||
t.update(&state(40.0));
|
||||
}
|
||||
let jump = t.update(&state(40.0));
|
||||
assert!(matches!(jump[0], play::Clientbound::TeleportEntity(_)));
|
||||
assert!(matches!(jump[0], play::Clientbound::EntityPositionSync(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn landing_sends_a_position_sync() {
|
||||
let mut t = Tracker::default();
|
||||
let mut e = state(0.0);
|
||||
t.spawn(&e, 100);
|
||||
t.update(&e);
|
||||
e.on_ground = false;
|
||||
t.update(&e);
|
||||
t.update(&e);
|
||||
assert!(matches!(
|
||||
t.update(&e)[..],
|
||||
[play::Clientbound::EntityPositionSync(_)]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -292,6 +340,7 @@ mod tests {
|
||||
let mut t = Tracker::default();
|
||||
let mut e = state(0.0);
|
||||
t.spawn(&e, 100);
|
||||
t.update(&e);
|
||||
e.meta.push((0, 0, Value::Byte(1)));
|
||||
e.equipment.push((0, Some(Stack::new(5, 1))));
|
||||
let out = t.update(&e);
|
||||
|
||||
@@ -146,6 +146,11 @@ impl Hub {
|
||||
self.guests.iter_mut().find(|g| g.conn == conn)
|
||||
}
|
||||
|
||||
/// The network id of a name in a built-in or synchronized registry.
|
||||
pub fn registry_id(&self, registry: &str, name: &str) -> Option<i32> {
|
||||
self.sync.id(registry, name)
|
||||
}
|
||||
|
||||
pub fn item_id(&mut self, item: &Arc<str>) -> i32 {
|
||||
if let Some(id) = self.item_ids.get(item) {
|
||||
return *id;
|
||||
@@ -864,7 +869,6 @@ impl Hub {
|
||||
data: 0,
|
||||
meta,
|
||||
equipment,
|
||||
send_velocity: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -950,20 +954,26 @@ impl Hub {
|
||||
if !self.guests[gi].placed {
|
||||
continue;
|
||||
}
|
||||
let range = f64::from(self.guests[gi].view_distance * 16).min(128.0);
|
||||
let view = self.guests[gi].view_distance;
|
||||
let me = self.guests[gi].entity_id;
|
||||
let origin = self.guests[gi].position;
|
||||
// TrackedEntity.updatePlayer: within the type's range (capped by the
|
||||
// view distance) horizontally, in a chunk the client has.
|
||||
let visible = |g: &Guest, e: &EntityState| {
|
||||
let range = super::entity::visible_range(&e.kind, view);
|
||||
let (dx, dz) = (origin[0] - e.position[0], origin[2] - e.position[2]);
|
||||
let chunk = (
|
||||
(e.position[0].floor() as i32) >> 4,
|
||||
(e.position[2].floor() as i32) >> 4,
|
||||
);
|
||||
dx * dx + dz * dz <= range * range && g.sent_chunks.contains(&chunk)
|
||||
};
|
||||
// Forget what disappeared or left range.
|
||||
let mut removed = Vec::new();
|
||||
for id in self.guests[gi].tracker.ids() {
|
||||
match by_id.get(&id) {
|
||||
None => removed.push(id),
|
||||
Some(&ei) => {
|
||||
let e = &all[ei];
|
||||
if !in_range(origin, e.position, range + 8.0) {
|
||||
removed.push(id);
|
||||
}
|
||||
}
|
||||
Some(&ei) if visible(&self.guests[gi], &all[ei]) => {}
|
||||
_ => removed.push(id),
|
||||
}
|
||||
}
|
||||
if !removed.is_empty() {
|
||||
@@ -979,12 +989,12 @@ impl Hub {
|
||||
if self.guests[gi].tracker.is_tracking(e.id) {
|
||||
let packets = self.guests[gi].tracker.update(e);
|
||||
self.guests[gi].send_all(&packets);
|
||||
} else if in_range(origin, e.position, range) {
|
||||
} else if visible(&self.guests[gi], e) {
|
||||
let Some(kind) = self.entity_type(&e.kind) else {
|
||||
continue;
|
||||
};
|
||||
let packets = self.guests[gi].tracker.spawn(e, kind);
|
||||
// Players need their list entry first; guests got it at join.
|
||||
let packets = self.guests[gi].tracker.spawn(e, kind);
|
||||
self.guests[gi].send_all(&packets);
|
||||
}
|
||||
}
|
||||
@@ -1207,11 +1217,6 @@ impl Hub {
|
||||
}
|
||||
}
|
||||
|
||||
fn in_range(a: [f64; 3], b: [f64; 3], range: f64) -> bool {
|
||||
let (dx, dz) = (a[0] - b[0], a[2] - b[2]);
|
||||
dx * dx + dz * dz <= range * range && (a[1] - b[1]).abs() <= 400.0
|
||||
}
|
||||
|
||||
/// The commands guests may type when the host allows them.
|
||||
const COMMANDS: &[&str] = &[
|
||||
"advancement",
|
||||
|
||||
@@ -10,6 +10,7 @@ pub mod containers;
|
||||
pub mod data;
|
||||
pub mod discovery;
|
||||
pub mod entity;
|
||||
pub mod tracking;
|
||||
pub mod guest;
|
||||
pub mod hub;
|
||||
pub mod menu;
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Entity tracking settings per entity type (EntityType.Builder
|
||||
//! clientTrackingRange in chunks, updateInterval in ticks with 0 for never,
|
||||
//! trackDeltas). Generated by tools/generate_entity_tracking.py from the
|
||||
//! decompiled EntityTypes.java; do not edit by hand.
|
||||
|
||||
/// (entity type, tracking range in chunks, update interval, track deltas), sorted.
|
||||
pub const TRACKING: &[(&str, u8, u32, bool)] = &[
|
||||
("minecraft:acacia_boat", 10, 3, true),
|
||||
("minecraft:acacia_chest_boat", 10, 3, true),
|
||||
("minecraft:allay", 8, 2, true),
|
||||
("minecraft:area_effect_cloud", 10, 0, true),
|
||||
("minecraft:armadillo", 10, 3, true),
|
||||
("minecraft:armor_stand", 10, 3, true),
|
||||
("minecraft:arrow", 4, 20, true),
|
||||
("minecraft:axolotl", 10, 3, true),
|
||||
("minecraft:bamboo_chest_raft", 10, 3, true),
|
||||
("minecraft:bamboo_raft", 10, 3, true),
|
||||
("minecraft:bat", 5, 3, false),
|
||||
("minecraft:bee", 8, 3, true),
|
||||
("minecraft:birch_boat", 10, 3, true),
|
||||
("minecraft:birch_chest_boat", 10, 3, true),
|
||||
("minecraft:blaze", 8, 3, true),
|
||||
("minecraft:block_display", 10, 1, true),
|
||||
("minecraft:bogged", 8, 3, true),
|
||||
("minecraft:breeze", 10, 3, true),
|
||||
("minecraft:breeze_wind_charge", 4, 10, true),
|
||||
("minecraft:camel", 10, 3, true),
|
||||
("minecraft:camel_husk", 10, 3, true),
|
||||
("minecraft:cat", 8, 3, true),
|
||||
("minecraft:cave_spider", 8, 3, true),
|
||||
("minecraft:cherry_boat", 10, 3, true),
|
||||
("minecraft:cherry_chest_boat", 10, 3, true),
|
||||
("minecraft:chest_minecart", 8, 3, true),
|
||||
("minecraft:chicken", 10, 3, true),
|
||||
("minecraft:cod", 4, 3, true),
|
||||
("minecraft:command_block_minecart", 8, 3, true),
|
||||
("minecraft:copper_golem", 10, 3, true),
|
||||
("minecraft:cow", 10, 3, true),
|
||||
("minecraft:creaking", 8, 3, true),
|
||||
("minecraft:creeper", 8, 3, true),
|
||||
("minecraft:cushion", 10, 0, false),
|
||||
("minecraft:dark_oak_boat", 10, 3, true),
|
||||
("minecraft:dark_oak_chest_boat", 10, 3, true),
|
||||
("minecraft:dolphin", 5, 3, true),
|
||||
("minecraft:donkey", 10, 3, true),
|
||||
("minecraft:dragon_fireball", 4, 10, true),
|
||||
("minecraft:drowned", 8, 3, true),
|
||||
("minecraft:egg", 4, 10, true),
|
||||
("minecraft:elder_guardian", 10, 3, true),
|
||||
("minecraft:end_crystal", 16, 0, false),
|
||||
("minecraft:ender_dragon", 10, 3, true),
|
||||
("minecraft:ender_pearl", 4, 10, true),
|
||||
("minecraft:enderman", 8, 3, true),
|
||||
("minecraft:endermite", 8, 3, true),
|
||||
("minecraft:evoker", 8, 3, true),
|
||||
("minecraft:evoker_fangs", 6, 2, false),
|
||||
("minecraft:experience_bottle", 4, 10, true),
|
||||
("minecraft:experience_orb", 6, 20, true),
|
||||
("minecraft:eye_of_ender", 4, 4, true),
|
||||
("minecraft:falling_block", 10, 20, true),
|
||||
("minecraft:fireball", 4, 10, true),
|
||||
("minecraft:firework_rocket", 4, 10, true),
|
||||
("minecraft:fishing_bobber", 4, 5, true),
|
||||
("minecraft:fox", 8, 3, true),
|
||||
("minecraft:frog", 10, 3, true),
|
||||
("minecraft:furnace_minecart", 8, 3, true),
|
||||
("minecraft:ghast", 10, 3, true),
|
||||
("minecraft:giant", 10, 3, true),
|
||||
("minecraft:glow_item_frame", 10, 0, false),
|
||||
("minecraft:glow_squid", 10, 3, true),
|
||||
("minecraft:goat", 10, 3, true),
|
||||
("minecraft:guardian", 8, 3, true),
|
||||
("minecraft:happy_ghast", 10, 3, true),
|
||||
("minecraft:hoglin", 8, 3, true),
|
||||
("minecraft:hopper_minecart", 8, 3, true),
|
||||
("minecraft:horse", 10, 3, true),
|
||||
("minecraft:husk", 8, 3, true),
|
||||
("minecraft:illusioner", 8, 3, true),
|
||||
("minecraft:interaction", 10, 3, true),
|
||||
("minecraft:iron_golem", 10, 3, true),
|
||||
("minecraft:item", 6, 20, true),
|
||||
("minecraft:item_display", 10, 1, true),
|
||||
("minecraft:item_frame", 10, 0, false),
|
||||
("minecraft:jungle_boat", 10, 3, true),
|
||||
("minecraft:jungle_chest_boat", 10, 3, true),
|
||||
("minecraft:leash_knot", 10, 0, false),
|
||||
("minecraft:lightning_bolt", 16, 0, true),
|
||||
("minecraft:lingering_potion", 4, 10, true),
|
||||
("minecraft:llama", 10, 3, true),
|
||||
("minecraft:llama_spit", 4, 10, false),
|
||||
("minecraft:magma_cube", 8, 3, true),
|
||||
("minecraft:mangrove_boat", 10, 3, true),
|
||||
("minecraft:mangrove_chest_boat", 10, 3, true),
|
||||
("minecraft:mannequin", 32, 2, true),
|
||||
("minecraft:marker", 0, 3, true),
|
||||
("minecraft:minecart", 8, 3, true),
|
||||
("minecraft:mooshroom", 10, 3, true),
|
||||
("minecraft:mule", 8, 3, true),
|
||||
("minecraft:nautilus", 10, 3, true),
|
||||
("minecraft:oak_boat", 10, 3, true),
|
||||
("minecraft:oak_chest_boat", 10, 3, true),
|
||||
("minecraft:ocelot", 10, 3, true),
|
||||
("minecraft:ominous_item_spawner", 8, 3, true),
|
||||
("minecraft:painting", 10, 0, false),
|
||||
("minecraft:pale_oak_boat", 10, 3, true),
|
||||
("minecraft:pale_oak_chest_boat", 10, 3, true),
|
||||
("minecraft:panda", 10, 3, true),
|
||||
("minecraft:parched", 8, 3, true),
|
||||
("minecraft:parrot", 8, 3, true),
|
||||
("minecraft:phantom", 8, 3, true),
|
||||
("minecraft:pig", 10, 3, true),
|
||||
("minecraft:piglin", 8, 3, true),
|
||||
("minecraft:piglin_brute", 8, 3, true),
|
||||
("minecraft:pillager", 8, 3, true),
|
||||
("minecraft:player", 32, 2, false),
|
||||
("minecraft:polar_bear", 10, 3, true),
|
||||
("minecraft:poplar_boat", 10, 3, true),
|
||||
("minecraft:poplar_chest_boat", 10, 3, true),
|
||||
("minecraft:pufferfish", 4, 3, true),
|
||||
("minecraft:rabbit", 8, 3, true),
|
||||
("minecraft:ravager", 10, 3, true),
|
||||
("minecraft:salmon", 4, 3, true),
|
||||
("minecraft:sheep", 10, 3, true),
|
||||
("minecraft:shulker", 10, 3, true),
|
||||
("minecraft:shulker_bullet", 8, 3, true),
|
||||
("minecraft:silverfish", 8, 3, true),
|
||||
("minecraft:skeleton", 8, 3, true),
|
||||
("minecraft:skeleton_horse", 10, 3, true),
|
||||
("minecraft:slime", 10, 3, true),
|
||||
("minecraft:small_fireball", 4, 10, true),
|
||||
("minecraft:sniffer", 10, 3, true),
|
||||
("minecraft:snow_golem", 8, 3, true),
|
||||
("minecraft:snowball", 4, 10, true),
|
||||
("minecraft:spawner_minecart", 8, 3, true),
|
||||
("minecraft:spectral_arrow", 4, 20, true),
|
||||
("minecraft:spider", 8, 3, true),
|
||||
("minecraft:splash_potion", 4, 10, true),
|
||||
("minecraft:spruce_boat", 10, 3, true),
|
||||
("minecraft:spruce_chest_boat", 10, 3, true),
|
||||
("minecraft:squid", 8, 3, true),
|
||||
("minecraft:stray", 8, 3, true),
|
||||
("minecraft:strider", 10, 3, true),
|
||||
("minecraft:sulfur_cube", 10, 3, true),
|
||||
("minecraft:tadpole", 10, 3, true),
|
||||
("minecraft:text_display", 10, 1, true),
|
||||
("minecraft:tnt", 10, 10, true),
|
||||
("minecraft:tnt_minecart", 8, 3, true),
|
||||
("minecraft:trader_llama", 10, 3, true),
|
||||
("minecraft:trident", 4, 20, true),
|
||||
("minecraft:tropical_fish", 4, 3, true),
|
||||
("minecraft:turtle", 10, 3, true),
|
||||
("minecraft:vex", 8, 3, true),
|
||||
("minecraft:villager", 10, 3, true),
|
||||
("minecraft:vindicator", 8, 3, true),
|
||||
("minecraft:wandering_trader", 10, 3, true),
|
||||
("minecraft:warden", 16, 3, true),
|
||||
("minecraft:wind_charge", 4, 10, true),
|
||||
("minecraft:witch", 8, 3, true),
|
||||
("minecraft:wither", 10, 3, false),
|
||||
("minecraft:wither_skeleton", 8, 3, true),
|
||||
("minecraft:wither_skull", 4, 10, true),
|
||||
("minecraft:wolf", 10, 3, true),
|
||||
("minecraft:zoglin", 8, 3, true),
|
||||
("minecraft:zombie", 8, 3, true),
|
||||
("minecraft:zombie_horse", 10, 3, true),
|
||||
("minecraft:zombie_nautilus", 10, 3, true),
|
||||
("minecraft:zombie_villager", 8, 3, true),
|
||||
("minecraft:zombified_piglin", 8, 3, true),
|
||||
];
|
||||
|
||||
/// The settings of one entity type; unknown types get the builder defaults.
|
||||
pub fn settings(kind: &str) -> (u8, u32, bool) {
|
||||
TRACKING
|
||||
.binary_search_by(|row| row.0.cmp(kind))
|
||||
.map_or((5, 3, true), |i| (TRACKING[i].1, TRACKING[i].2, TRACKING[i].3))
|
||||
}
|
||||
@@ -26,6 +26,8 @@ pub enum Value {
|
||||
OptUuid(Option<u128>),
|
||||
/// Any VarInt-valued serializer (direction, block state, pose, variants ...).
|
||||
Id(i32),
|
||||
/// Several VarInts (VillagerData).
|
||||
Ids(Vec<i32>),
|
||||
}
|
||||
/// Serializer ids in EntityDataSerializers registration order.
|
||||
pub mod serializer {
|
||||
@@ -45,12 +47,34 @@ pub mod serializer {
|
||||
pub const OPTIONAL_LIVING_ENTITY_REFERENCE: i32 = 13;
|
||||
pub const BLOCK_STATE: i32 = 14;
|
||||
pub const OPTIONAL_BLOCK_STATE: i32 = 15;
|
||||
pub const PARTICLE: i32 = 16;
|
||||
pub const PARTICLES: i32 = 17;
|
||||
pub const VILLAGER_DATA: i32 = 18;
|
||||
pub const OPTIONAL_UNSIGNED_INT: i32 = 19;
|
||||
pub const POSE: i32 = 20;
|
||||
pub const VECTOR3: i32 = 40;
|
||||
pub const QUATERNION: i32 = 41;
|
||||
pub const HUMANOID_ARM: i32 = 43;
|
||||
pub const DYE_COLOR: i32 = 44;
|
||||
pub const CAT_VARIANT: i32 = 21;
|
||||
pub const CAT_SOUND_VARIANT: i32 = 22;
|
||||
pub const COW_VARIANT: i32 = 23;
|
||||
pub const COW_SOUND_VARIANT: i32 = 24;
|
||||
pub const WOLF_VARIANT: i32 = 25;
|
||||
pub const WOLF_SOUND_VARIANT: i32 = 26;
|
||||
pub const FROG_VARIANT: i32 = 27;
|
||||
pub const PIG_VARIANT: i32 = 28;
|
||||
pub const PIG_SOUND_VARIANT: i32 = 29;
|
||||
pub const CHICKEN_VARIANT: i32 = 30;
|
||||
pub const CHICKEN_SOUND_VARIANT: i32 = 31;
|
||||
pub const ZOMBIE_NAUTILUS_VARIANT: i32 = 32;
|
||||
pub const OPTIONAL_GLOBAL_POS: i32 = 33;
|
||||
pub const PAINTING_VARIANT: i32 = 34;
|
||||
pub const SNIFFER_STATE: i32 = 35;
|
||||
pub const ARMADILLO_STATE: i32 = 36;
|
||||
pub const COPPER_GOLEM_STATE: i32 = 37;
|
||||
pub const WEATHERING_COPPER_STATE: i32 = 38;
|
||||
pub const VECTOR3: i32 = 39;
|
||||
pub const QUATERNION: i32 = 40;
|
||||
pub const RESOLVABLE_PROFILE: i32 = 41;
|
||||
pub const HUMANOID_ARM: i32 = 42;
|
||||
pub const DYE_COLOR: i32 = 43;
|
||||
}
|
||||
fn read_value(kind: i32, r: &mut Reader) -> Result<Option<Value>> {
|
||||
use serializer::*;
|
||||
@@ -75,15 +99,18 @@ fn read_value(kind: i32, r: &mut Reader) -> Result<Option<Value>> {
|
||||
OPTIONAL_LIVING_ENTITY_REFERENCE => {
|
||||
Value::OptUuid(if r.bool()? { Some(r.uuid()?) } else { None })
|
||||
}
|
||||
// VillagerData: type, profession and level.
|
||||
VILLAGER_DATA => Value::Ids(vec![r.varint()?, r.varint()?, r.varint()?]),
|
||||
// VarInt-encoded: direction, block states, optional ints, pose, the
|
||||
// holder-registry variants (21..=33) and enum states (36..=39), arm, dye.
|
||||
// holder-registry variants (cat to zombie nautilus, painting) and the
|
||||
// enum states (sniffer to weathering copper), arm, dye.
|
||||
DIRECTION
|
||||
| BLOCK_STATE
|
||||
| OPTIONAL_BLOCK_STATE
|
||||
| OPTIONAL_UNSIGNED_INT
|
||||
| POSE
|
||||
| 21..=33
|
||||
| 36..=39
|
||||
| CAT_VARIANT..=ZOMBIE_NAUTILUS_VARIANT
|
||||
| PAINTING_VARIANT..=WEATHERING_COPPER_STATE
|
||||
| HUMANOID_ARM
|
||||
| DYE_COLOR => Value::Id(r.varint()?),
|
||||
_ => return Ok(None),
|
||||
@@ -93,6 +120,7 @@ fn write_value(kind: i32, v: &Value, w: &mut Writer) {
|
||||
match v {
|
||||
Value::Byte(b) => w.i8(*b),
|
||||
Value::Int(i) | Value::Id(i) => w.varint(*i),
|
||||
Value::Ids(ids) => ids.iter().for_each(|i| w.varint(*i)),
|
||||
Value::Long(l) => w.varlong(*l),
|
||||
Value::Float(f) => w.f32(*f),
|
||||
Value::String(s) => w.string(s),
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package dev.horizonlab.parity;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.PrintWriter;
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import net.minecraft.network.FriendlyByteBuf;
|
||||
import net.minecraft.network.ProtocolInfo;
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||
import net.minecraft.network.protocol.Packet;
|
||||
import net.minecraft.network.protocol.configuration.ConfigurationProtocols;
|
||||
import net.minecraft.network.protocol.game.ClientboundLevelChunkWithLightPacket;
|
||||
import net.minecraft.network.protocol.game.GameProtocols;
|
||||
import net.minecraft.network.protocol.login.LoginProtocols;
|
||||
import net.minecraft.network.protocol.status.StatusProtocols;
|
||||
import net.minecraft.world.level.chunk.LevelChunkSection;
|
||||
import net.minecraft.world.level.chunk.PalettedContainerFactory;
|
||||
|
||||
/**
|
||||
* Checks a capture written by tools/net_capture.py against vanilla's own
|
||||
* packet codecs: every clientbound packet a server sent (login,
|
||||
* configuration and play) must decode with vanilla's StreamCodec, consume
|
||||
* its whole payload, and encode back to the same bytes, so the bytes are
|
||||
* exactly what vanilla would have written for that packet. Chunk data is
|
||||
* decoded section by section and the stored block counts are checked
|
||||
* against a recount. Prints one line per packet:
|
||||
* conn dir state name ok|ERROR|REENCODE|SECTIONS detail
|
||||
* and a summary per packet type at the end.
|
||||
*/
|
||||
public final class VanillaPacketCodecOracle {
|
||||
public static void main(String[] args) throws Exception {
|
||||
ParityData.init();
|
||||
Path capture = Path.of(args[0]);
|
||||
boolean verbose = args.length > 2 && args[2].equals("--verbose");
|
||||
var play = GameProtocols.CLIENTBOUND_TEMPLATE.bind(RegistryFriendlyByteBuf.decorator(ParityData.access));
|
||||
var factory = PalettedContainerFactory.create(ParityData.access);
|
||||
Field nonEmpty = LevelChunkSection.class.getDeclaredField("nonEmptyBlockCount");
|
||||
Field fluids = LevelChunkSection.class.getDeclaredField("fluidCount");
|
||||
nonEmpty.setAccessible(true);
|
||||
fluids.setAccessible(true);
|
||||
Map<String, int[]> totals = new TreeMap<>();
|
||||
int failures = 0;
|
||||
try (BufferedReader in = Files.newBufferedReader(capture);
|
||||
PrintWriter out = new PrintWriter(Files.newBufferedWriter(Path.of(args[1])))) {
|
||||
String line;
|
||||
while ((line = in.readLine()) != null) {
|
||||
if (line.isBlank()) continue;
|
||||
JsonObject e = JsonParser.parseString(line).getAsJsonObject();
|
||||
if (!e.has("name") || !e.get("dir").getAsString().equals("s2c")) continue;
|
||||
String state = e.get("state").getAsString();
|
||||
String name = e.get("name").getAsString();
|
||||
String key = state + "/" + name;
|
||||
int[] t = totals.computeIfAbsent(key, k -> new int[2]);
|
||||
t[0]++;
|
||||
if (!e.has("data")) {
|
||||
out.println(e.get("conn") + " s2c " + key + " SKIPPED payload not captured");
|
||||
t[1]++;
|
||||
failures++;
|
||||
continue;
|
||||
}
|
||||
byte[] payload = HexFormat.of().parseHex(e.get("data").getAsString());
|
||||
ProtocolInfo<?> protocol = switch (state) {
|
||||
case "status" -> StatusProtocols.CLIENTBOUND;
|
||||
case "login" -> LoginProtocols.CLIENTBOUND;
|
||||
case "configuration" -> ConfigurationProtocols.CLIENTBOUND;
|
||||
case "play" -> play;
|
||||
default -> null;
|
||||
};
|
||||
if (protocol == null) continue;
|
||||
ByteBuf original = Unpooled.buffer();
|
||||
new FriendlyByteBuf(original).writeVarInt(e.get("id").getAsInt());
|
||||
original.writeBytes(payload);
|
||||
byte[] framed = new byte[original.readableBytes()];
|
||||
original.getBytes(0, framed);
|
||||
String verdict;
|
||||
String detail = "";
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
var codec = (net.minecraft.network.codec.StreamCodec<ByteBuf, Packet<?>>) (Object) protocol.codec();
|
||||
Packet<?> packet = codec.decode(original);
|
||||
if (original.readableBytes() != 0) {
|
||||
verdict = "ERROR";
|
||||
detail = original.readableBytes() + " bytes left after decoding " + packet;
|
||||
} else {
|
||||
ByteBuf again = Unpooled.buffer();
|
||||
codec.encode(again, packet);
|
||||
byte[] encoded = new byte[again.readableBytes()];
|
||||
again.getBytes(0, encoded);
|
||||
if (!java.util.Arrays.equals(encoded, framed)) {
|
||||
verdict = "REENCODE";
|
||||
detail = firstDifference(framed, encoded) + " " + abbreviate(packet.toString());
|
||||
} else if (packet instanceof ClientboundLevelChunkWithLightPacket chunk) {
|
||||
String problem = checkSections(chunk, factory, nonEmpty, fluids);
|
||||
verdict = problem == null ? "ok" : "SECTIONS";
|
||||
detail = problem == null ? "" : problem;
|
||||
} else {
|
||||
verdict = "ok";
|
||||
if (verbose) detail = abbreviate(packet.toString());
|
||||
}
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
verdict = "ERROR";
|
||||
detail = ex.toString();
|
||||
}
|
||||
if (!verdict.equals("ok")) {
|
||||
t[1]++;
|
||||
failures++;
|
||||
}
|
||||
if (!verdict.equals("ok") || verbose) {
|
||||
out.println(e.get("conn") + " s2c " + key + " " + verdict + " " + detail);
|
||||
}
|
||||
}
|
||||
out.println("# packet type, count, failures");
|
||||
for (var entry : totals.entrySet()) {
|
||||
out.println("# " + entry.getKey() + " " + entry.getValue()[0] + " " + entry.getValue()[1]);
|
||||
}
|
||||
out.println("# failures " + failures);
|
||||
}
|
||||
System.exit(failures == 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
static String checkSections(ClientboundLevelChunkWithLightPacket chunk, PalettedContainerFactory factory,
|
||||
Field nonEmpty, Field fluids) throws Exception {
|
||||
FriendlyByteBuf buf = chunk.chunkData().getReadBuffer();
|
||||
int index = 0;
|
||||
while (buf.isReadable()) {
|
||||
int start = buf.readerIndex();
|
||||
short storedBlocks = buf.getShort(start);
|
||||
short storedFluids = buf.getShort(start + 2);
|
||||
LevelChunkSection section = new LevelChunkSection(factory);
|
||||
section.read(buf);
|
||||
section.recalcBlockCounts();
|
||||
int blocks = (short) (int) nonEmpty.get(section);
|
||||
int fluidCount = (short) (int) fluids.get(section);
|
||||
if (blocks != storedBlocks || fluidCount != storedFluids) {
|
||||
return "section " + index + " of " + chunk.x() + "," + chunk.z() + " stores " + storedBlocks + " blocks/"
|
||||
+ storedFluids + " fluids, recount " + blocks + "/" + fluidCount;
|
||||
}
|
||||
index++;
|
||||
}
|
||||
if (index != 24 && index != 16) return index + " sections in " + chunk.x() + "," + chunk.z();
|
||||
return null;
|
||||
}
|
||||
|
||||
static String firstDifference(byte[] a, byte[] b) {
|
||||
int n = Math.min(a.length, b.length);
|
||||
for (int i = 0; i < n; i++) {
|
||||
if (a[i] != b[i]) return "differs at byte " + i + " of " + a.length + " (vanilla writes " + b.length + ")";
|
||||
}
|
||||
return "lengths " + a.length + " vs vanilla " + b.length;
|
||||
}
|
||||
|
||||
static String abbreviate(String s) {
|
||||
s = s.replace('\n', ' ');
|
||||
return s.length() > 400 ? s.substring(0, 400) + "..." : s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate crates/horizon-client/src/lan/tracking.rs: every entity type's
|
||||
tracking settings (EntityType.Builder clientTrackingRange, updateInterval and
|
||||
trackDeltas, which ChunkMap.TrackedEntity and ServerEntity use to decide who
|
||||
sees an entity and how often its movement is sent), read from the decompiled
|
||||
EntityTypes.java.
|
||||
|
||||
generate_entity_tracking.py [--sources .local/minecraft/26.3/sources/client]
|
||||
"""
|
||||
import argparse
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
INT_MAX = 2147483647
|
||||
|
||||
|
||||
def parse(sources):
|
||||
text = (sources / "net/minecraft/world/entity/EntityTypes.java").read_text()
|
||||
out = []
|
||||
for m in re.finditer(r"public static final EntityType<[\w.]+> (\w+) = register\((.*?)\n \);", text, re.S):
|
||||
name, body = m.group(1).lower(), m.group(2)
|
||||
range_ = re.findall(r"\.clientTrackingRange\((\d+)\)", body)
|
||||
interval = re.findall(r"\.updateInterval\((\w+(?:\.\w+)?)\)", body)
|
||||
track = ".dontTrackDeltas()" not in body
|
||||
r = int(range_[-1]) if range_ else 5
|
||||
if ".noUpdateInterval()" in body or (interval and interval[-1] == "Integer.MAX_VALUE"):
|
||||
i = 0
|
||||
else:
|
||||
i = int(interval[-1]) if interval else 3
|
||||
out.append((f"minecraft:{name}", r, i, track))
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--sources", type=Path, default=ROOT / ".local/minecraft/26.3/sources/client")
|
||||
parser.add_argument("--out", type=Path, default=ROOT / "crates/horizon-client/src/lan/tracking.rs")
|
||||
args = parser.parse_args()
|
||||
rows = parse(args.sources)
|
||||
if len(rows) < 100:
|
||||
raise SystemExit(f"only {len(rows)} entity types found")
|
||||
lines = [
|
||||
"//! Entity tracking settings per entity type (EntityType.Builder",
|
||||
"//! clientTrackingRange in chunks, updateInterval in ticks with 0 for never,",
|
||||
"//! trackDeltas). Generated by tools/generate_entity_tracking.py from the",
|
||||
"//! decompiled EntityTypes.java; do not edit by hand.",
|
||||
"",
|
||||
"/// (entity type, tracking range in chunks, update interval, track deltas), sorted.",
|
||||
"pub const TRACKING: &[(&str, u8, u32, bool)] = &[",
|
||||
]
|
||||
lines += [f' ("{n}", {r}, {i}, {str(t).lower()}),' for n, r, i, t in rows]
|
||||
lines += [
|
||||
"];",
|
||||
"",
|
||||
"/// The settings of one entity type; unknown types get the builder defaults.",
|
||||
"pub fn settings(kind: &str) -> (u8, u32, bool) {",
|
||||
" TRACKING",
|
||||
" .binary_search_by(|row| row.0.cmp(kind))",
|
||||
" .map_or((5, 3, true), |i| (TRACKING[i].1, TRACKING[i].2, TRACKING[i].3))",
|
||||
"}",
|
||||
"",
|
||||
]
|
||||
args.out.write_text("\n".join(lines))
|
||||
print(f"wrote {len(rows)} entity types to {args.out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A decoding TCP proxy for the Minecraft 26.3 protocol (offline mode, no
|
||||
encryption): clients connect to LISTEN, it forwards to TARGET and records
|
||||
every packet in both directions as JSON lines (time, direction, state, id,
|
||||
name, size and the payload), following the connection state through the
|
||||
handshake, login, configuration and play (and back to configuration).
|
||||
|
||||
net_capture.py --listen 25570 --target localhost:25565 --out capture.jsonl
|
||||
|
||||
The packet names come from crates/horizon-protocol/src/ids.rs, which is
|
||||
generated from vanilla's packets report. `summary` prints what a capture
|
||||
contains, `diff` compares the packet sequences of two captures (for example
|
||||
Horizon hosting and a vanilla server serving the same world).
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import zlib
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
STATES = ["handshake", "status", "login", "configuration", "play"]
|
||||
|
||||
|
||||
def load_names(path=ROOT / "crates/horizon-protocol/src/ids.rs"):
|
||||
"""{(state, direction): [name by id]} from the generated id tables."""
|
||||
text = path.read_text()
|
||||
names = {}
|
||||
for state in STATES:
|
||||
block = re.search(r"pub mod %s \{(.*?)\n\}" % state, text, re.S)
|
||||
if not block:
|
||||
continue
|
||||
for direction in ("clientbound", "serverbound"):
|
||||
part = re.search(r"pub mod %s \{.*?NAMES: &\[&str\] = &\[(.*?)\];" % direction,
|
||||
block.group(1), re.S)
|
||||
if part:
|
||||
names[(state, direction)] = re.findall(r'"([^"]+)"', part.group(1))
|
||||
return names
|
||||
|
||||
|
||||
def read_varint(data, pos):
|
||||
value = shift = 0
|
||||
while True:
|
||||
if pos >= len(data):
|
||||
raise IndexError("truncated varint")
|
||||
b = data[pos]
|
||||
pos += 1
|
||||
value |= (b & 0x7F) << shift
|
||||
shift += 7
|
||||
if not b & 0x80:
|
||||
if value & 0x80000000:
|
||||
value -= 1 << 32
|
||||
return value, pos
|
||||
if shift > 35:
|
||||
raise ValueError("varint too long")
|
||||
|
||||
|
||||
class Session:
|
||||
"""The state both directions of one connection share."""
|
||||
|
||||
def __init__(self, conn_id, out, names, lock, full):
|
||||
self.id = conn_id
|
||||
self.state = {"clientbound": "handshake", "serverbound": "handshake"}
|
||||
self.threshold = -1
|
||||
self.out = out
|
||||
self.names = names
|
||||
self.lock = lock
|
||||
self.full = full
|
||||
self.start = time.time()
|
||||
self.closed = threading.Event()
|
||||
|
||||
def name(self, direction, packet_id):
|
||||
table = self.names.get((self.state[direction], direction), [])
|
||||
return table[packet_id] if 0 <= packet_id < len(table) else f"unknown_{packet_id}"
|
||||
|
||||
def record(self, direction, packet_id, payload):
|
||||
state = self.state[direction]
|
||||
name = self.name(direction, packet_id)
|
||||
entry = {
|
||||
"conn": self.id,
|
||||
"t": round(time.time() - self.start, 4),
|
||||
"dir": "s2c" if direction == "clientbound" else "c2s",
|
||||
"state": state,
|
||||
"id": packet_id,
|
||||
"name": name,
|
||||
"size": len(payload),
|
||||
}
|
||||
if self.full or len(payload) <= 4096:
|
||||
entry["data"] = payload.hex()
|
||||
else:
|
||||
entry["head"] = payload[:512].hex()
|
||||
with self.lock:
|
||||
self.out.write(json.dumps(entry) + "\n")
|
||||
self.out.flush()
|
||||
self.transition(direction, name, payload)
|
||||
|
||||
def transition(self, direction, name, payload):
|
||||
s = self.state
|
||||
if direction == "serverbound":
|
||||
if s["serverbound"] == "handshake" and name == "intention":
|
||||
pos = read_varint(payload, 0)[1]
|
||||
length, pos = read_varint(payload, pos)
|
||||
intent = read_varint(payload, pos + length + 2)[0]
|
||||
nxt = {1: "status", 2: "login", 3: "login"}.get(intent, "login")
|
||||
s["serverbound"] = s["clientbound"] = nxt
|
||||
elif s["serverbound"] == "login" and name == "login_acknowledged":
|
||||
s["serverbound"] = s["clientbound"] = "configuration"
|
||||
elif s["serverbound"] == "configuration" and name == "finish_configuration":
|
||||
s["serverbound"] = s["clientbound"] = "play"
|
||||
elif s["serverbound"] == "play" and name == "configuration_acknowledged":
|
||||
s["serverbound"] = s["clientbound"] = "configuration"
|
||||
elif s["clientbound"] == "login" and name == "login_compression":
|
||||
self.pending_threshold = read_varint(payload, 0)[0]
|
||||
|
||||
|
||||
class Pipe(threading.Thread):
|
||||
def __init__(self, session, direction, src, dst):
|
||||
super().__init__(daemon=True)
|
||||
self.session, self.direction, self.src, self.dst = session, direction, src, dst
|
||||
|
||||
def run(self):
|
||||
buffer = b""
|
||||
s = self.session
|
||||
try:
|
||||
while True:
|
||||
chunk = self.src.recv(1 << 16)
|
||||
if not chunk:
|
||||
break
|
||||
buffer += chunk
|
||||
frames = []
|
||||
while True:
|
||||
try:
|
||||
length, pos = read_varint(buffer, 0)
|
||||
except IndexError:
|
||||
break
|
||||
if len(buffer) < pos + length:
|
||||
break
|
||||
frames.append(buffer[:pos + length])
|
||||
body, buffer = buffer[pos:pos + length], buffer[pos + length:]
|
||||
threshold = s.threshold
|
||||
if threshold >= 0:
|
||||
size, p = read_varint(body, 0)
|
||||
body = zlib.decompress(body[p:]) if size else body[p:]
|
||||
packet_id, p = read_varint(body, 0)
|
||||
s.record(self.direction, packet_id, body[p:])
|
||||
# The compression threshold applies after its packet, in
|
||||
# both directions; the client answers only after reading it.
|
||||
pending = getattr(s, "pending_threshold", None)
|
||||
if pending is not None:
|
||||
s.threshold = pending
|
||||
s.pending_threshold = None
|
||||
# State switches must be in effect before the peer can
|
||||
# answer, so forward each frame after recording it.
|
||||
self.dst.sendall(frames.pop())
|
||||
except (OSError, ValueError, zlib.error) as e:
|
||||
with s.lock:
|
||||
s.out.write(json.dumps({"conn": s.id, "event": "error", "dir": self.direction,
|
||||
"error": str(e)}) + "\n")
|
||||
s.out.flush()
|
||||
finally:
|
||||
for sock in (self.src, self.dst):
|
||||
try:
|
||||
sock.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
s.closed.set()
|
||||
|
||||
|
||||
def serve(listen, target, out_path, full=False, ready=None, stop=None):
|
||||
names = load_names()
|
||||
host, port = target.rsplit(":", 1)
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server.bind(("127.0.0.1", listen))
|
||||
server.listen()
|
||||
server.settimeout(0.5)
|
||||
lock = threading.Lock()
|
||||
conn_id = 0
|
||||
with open(out_path, "a") as out:
|
||||
if ready:
|
||||
ready.set()
|
||||
while not (stop and stop.is_set()):
|
||||
try:
|
||||
client, _ = server.accept()
|
||||
except socket.timeout:
|
||||
continue
|
||||
conn_id += 1
|
||||
try:
|
||||
upstream = socket.create_connection((host, int(port)))
|
||||
except OSError as e:
|
||||
client.close()
|
||||
with lock:
|
||||
out.write(json.dumps({"conn": conn_id, "event": "error",
|
||||
"error": f"upstream: {e}"}) + "\n")
|
||||
continue
|
||||
for sock in (client, upstream):
|
||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
session = Session(conn_id, out, names, lock, full)
|
||||
Pipe(session, "serverbound", client, upstream).start()
|
||||
Pipe(session, "clientbound", upstream, client).start()
|
||||
server.close()
|
||||
|
||||
|
||||
def start_background(listen, target, out_path, full=False):
|
||||
"""Run the proxy on a thread; returns the stop event."""
|
||||
ready, stop = threading.Event(), threading.Event()
|
||||
threading.Thread(target=serve, args=(listen, target, out_path, full, ready, stop),
|
||||
daemon=True).start()
|
||||
ready.wait(5)
|
||||
return stop
|
||||
|
||||
|
||||
def load(path):
|
||||
return [json.loads(line) for line in Path(path).read_text().splitlines() if line.strip()]
|
||||
|
||||
|
||||
def play_sessions(entries):
|
||||
"""Connections that reached the play state."""
|
||||
return sorted({e["conn"] for e in entries if e.get("state") == "play"})
|
||||
|
||||
|
||||
def summary(entries, conn=None):
|
||||
lines = []
|
||||
conns = [conn] if conn else sorted({e["conn"] for e in entries})
|
||||
for c in conns:
|
||||
mine = [e for e in entries if e["conn"] == c and "name" in e]
|
||||
lines.append(f"connection {c}: {len(mine)} packets")
|
||||
counts = Counter((e["dir"], e["state"], e["name"]) for e in mine)
|
||||
for (d, s, n), k in sorted(counts.items()):
|
||||
size = sum(e["size"] for e in mine if (e["dir"], e["state"], e["name"]) == (d, s, n))
|
||||
lines.append(f" {d} {s:13} {n:40} x{k:<6} {size} bytes")
|
||||
for e in entries:
|
||||
if e["conn"] == c and e.get("event") == "error":
|
||||
lines.append(f" error ({e.get('dir')}): {e['error']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def sequence(entries, conn, direction="s2c", until="player_position"):
|
||||
"""Packet names in order, up to and including the first `until`."""
|
||||
out = []
|
||||
for e in entries:
|
||||
if e["conn"] != conn or e.get("dir") != direction or "name" not in e:
|
||||
continue
|
||||
out.append(f"{e['state']}/{e['name']}")
|
||||
if e["name"] == until and e["state"] == "play":
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
p = sub.add_parser("proxy")
|
||||
p.add_argument("--listen", type=int, required=True)
|
||||
p.add_argument("--target", required=True)
|
||||
p.add_argument("--out", type=Path, required=True)
|
||||
p.add_argument("--full", action="store_true", help="keep every payload in full")
|
||||
s = sub.add_parser("summary")
|
||||
s.add_argument("capture", type=Path)
|
||||
d = sub.add_parser("diff")
|
||||
d.add_argument("a", type=Path)
|
||||
d.add_argument("b", type=Path)
|
||||
d.add_argument("--until", default="player_position")
|
||||
args = parser.parse_args()
|
||||
if args.command == "proxy":
|
||||
serve(args.listen, args.target, args.out, args.full)
|
||||
elif args.command == "summary":
|
||||
print(summary(load(args.capture)))
|
||||
else:
|
||||
import difflib
|
||||
a, b = load(args.a), load(args.b)
|
||||
sa = sequence(a, play_sessions(a)[0], until=args.until)
|
||||
sb = sequence(b, play_sessions(b)[0], until=args.until)
|
||||
sys.stdout.writelines(difflib.unified_diff([x + "\n" for x in sa], [x + "\n" for x in sb],
|
||||
str(args.a), str(args.b)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Launch the vanilla 26.3 client offline (no Microsoft account, no auth
|
||||
servers: --offlineDeveloperMode) and optionally join a server straight away
|
||||
(--quickPlayMultiplayer). Meant to run on a virtual screen:
|
||||
|
||||
vscreen start python3 tools/vanilla_client.py --name Guest --join localhost:25565
|
||||
|
||||
Sound goes to OpenAL's null backend. The game directory gets an options.txt
|
||||
that skips the first-run screens, keeps the game running unfocused and
|
||||
renders few chunks, so a harness can drive it with vscreen keys.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
GAME = ROOT / ".local/minecraft/26.3"
|
||||
|
||||
OPTIONS = {
|
||||
"onboardAccessibility": "false",
|
||||
"skipMultiplayerWarning": "true",
|
||||
"joinedFirstServer": "true",
|
||||
"tutorialStep": "none",
|
||||
"pauseOnLostFocus": "false",
|
||||
"renderDistance": "6",
|
||||
"simulationDistance": "6",
|
||||
"maxFps": "30",
|
||||
"narrator": "0",
|
||||
"soundCategory_master": "0.0",
|
||||
"realmsNotifications": "false",
|
||||
"hideServerAddress": "false",
|
||||
"autoJump": "false",
|
||||
"chatLinksPrompt": "false",
|
||||
"onlyShowSecureChat": "false",
|
||||
"rawMouseInput": "false",
|
||||
}
|
||||
|
||||
|
||||
def offline_uuid(name):
|
||||
digest = bytearray(hashlib.md5(("OfflinePlayer:" + name).encode()).digest())
|
||||
digest[6] = (digest[6] & 0x0F) | 0x30
|
||||
digest[8] = (digest[8] & 0x3F) | 0x80
|
||||
return uuid.UUID(bytes=bytes(digest))
|
||||
|
||||
|
||||
def linux_library(path):
|
||||
name = Path(path).name
|
||||
if "natives-" in name:
|
||||
return name.endswith("natives-linux.jar")
|
||||
if "netty-transport-native" in name and ("osx" in name or "aarch_64" in name):
|
||||
return False
|
||||
return "java-objc-bridge" not in name
|
||||
|
||||
|
||||
def classpath():
|
||||
prepared = json.loads((GAME / "prepared.json").read_text())
|
||||
jars = [ROOT / p for p in prepared["libraries"]["client"] if linux_library(p)]
|
||||
return [GAME / "jars/client.jar", *jars]
|
||||
|
||||
|
||||
def extract_natives(jars, target):
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
for jar in jars:
|
||||
if not jar.name.endswith("natives-linux.jar"):
|
||||
continue
|
||||
with zipfile.ZipFile(jar) as archive:
|
||||
for name in archive.namelist():
|
||||
if name.endswith(".so") and "arm" not in name and "aarch" not in name:
|
||||
out = target / Path(name).name
|
||||
if not out.exists():
|
||||
out.write_bytes(archive.read(name))
|
||||
|
||||
|
||||
def write_options(game_dir, extra):
|
||||
path = game_dir / "options.txt"
|
||||
current = {}
|
||||
if path.exists():
|
||||
for line in path.read_text().splitlines():
|
||||
if ":" in line:
|
||||
k, v = line.split(":", 1)
|
||||
current[k] = v
|
||||
current.update(OPTIONS)
|
||||
current.update(extra)
|
||||
path.write_text("".join(f"{k}:{v}\n" for k, v in current.items()))
|
||||
|
||||
|
||||
def environment():
|
||||
env = dict(os.environ)
|
||||
libs = [p for p in (os.environ.get("NIX_LD_LIBRARY_PATH", ""), "/run/opengl-driver/lib",
|
||||
"/run/current-system/sw/share/nix-ld/lib") if p]
|
||||
env["LD_LIBRARY_PATH"] = ":".join(libs + [env.get("LD_LIBRARY_PATH", "")]).strip(":")
|
||||
env["ALSOFT_DRIVERS"] = "null"
|
||||
return env
|
||||
|
||||
|
||||
def command(args):
|
||||
jars = classpath()
|
||||
natives = args.game_dir / "natives"
|
||||
extract_natives(jars, natives)
|
||||
assets = args.assets or (args.game_dir / "assets")
|
||||
(assets / "indexes").mkdir(parents=True, exist_ok=True)
|
||||
cmd = ["java", f"-Xmx{args.heap}", "-XX:StackShadowPages=32",
|
||||
"--enable-native-access=ALL-UNNAMED",
|
||||
"--add-exports", "java.base/jdk.internal.misc=ALL-UNNAMED",
|
||||
f"-Djava.library.path={natives}", f"-Dorg.lwjgl.system.SharedLibraryExtractPath={natives}",
|
||||
f"-Dio.netty.native.workdir={natives}",
|
||||
"-cp", os.pathsep.join(map(str, jars)), "net.minecraft.client.main.Main",
|
||||
"--username", args.name, "--uuid", offline_uuid(args.name).hex,
|
||||
"--accessToken", "0", "--offlineDeveloperMode",
|
||||
"--version", "26.3", "--gameDir", str(args.game_dir),
|
||||
"--assetsDir", str(assets), "--assetIndex", "34",
|
||||
"--width", str(args.width), "--height", str(args.height)]
|
||||
if args.join:
|
||||
cmd += ["--quickPlayMultiplayer", args.join]
|
||||
if args.backend:
|
||||
cmd += ["--graphicsBackend", args.backend]
|
||||
return cmd
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--name", default="Guest")
|
||||
parser.add_argument("--join", help="HOST:PORT to join on start")
|
||||
parser.add_argument("--game-dir", type=Path, default=ROOT / ".local/vanilla-client")
|
||||
parser.add_argument("--assets", type=Path)
|
||||
parser.add_argument("--heap", default="3G")
|
||||
parser.add_argument("--width", type=int, default=1280)
|
||||
parser.add_argument("--height", type=int, default=800)
|
||||
parser.add_argument("--backend", help="graphics backend (opengl or vulkan)")
|
||||
parser.add_argument("--option", action="append", default=[], help="KEY:VALUE for options.txt")
|
||||
parser.add_argument("--print", action="store_true", help="print the command and exit")
|
||||
args = parser.parse_args()
|
||||
args.game_dir = args.game_dir.resolve()
|
||||
args.game_dir.mkdir(parents=True, exist_ok=True)
|
||||
write_options(args.game_dir, dict(o.split(":", 1) for o in args.option))
|
||||
cmd = command(args)
|
||||
if args.print:
|
||||
print(" ".join(cmd))
|
||||
return
|
||||
sys.exit(subprocess.run(cmd, cwd=args.game_dir, env=environment()).returncode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user