Compare commits

...
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 5411923887 Join guests as PlayerList.placeNewPlayer does and send vanilla's command tree
Send the join packets in vanilla's order with the permission level entity
event, recipe book, server data, ticking state and post effects; announce
the newcomer only to the others; list players with every info action;
use vanilla's level key order and border warning time; sync the game time
every 20 ticks without clocks and the overworld clock only when it jumps.
Build the command tree from the commands report for the player's
permission level and server kind, with vanilla's suggestion providers
(VanillaCommandSuggestionsExporter), and answer command suggestions.
The tree matches what a vanilla integrated server sends node for node.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:23:05 +02:00
Ludovic MarieandClaude Opus 5.5 120ac32311 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]>
2026-10-10 21:47:11 +02:00
19 changed files with 2153 additions and 338 deletions

No files matched your search

+4 -1
View File
@@ -87,6 +87,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
dimension: "minecraft:overworld".into(),
seed: 0,
max_players: 8,
dedicated: false,
},
);
let mut next_entity = 100u32;
@@ -110,13 +111,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
world.day_time += 1;
let mut ctx = TickCtx {
world: &mut world,
host: &host,
host: Some(&host),
entities: &[],
next_entity: &mut next_entity,
spawn: [8.5, 60.0, 8.5],
difficulty: 2,
difficulty_locked: false,
clock_rate: 1.0,
tick_rate: 20.0,
tick_frozen: false,
};
for event in hub.tick(&mut ctx) {
match event {
+218 -60
View File
@@ -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] = &[
@@ -205,6 +382,7 @@ impl Game<'_> {
dimension: self.dim.id().to_owned(),
seed: self.level.seed,
max_players: MAX_PLAYERS,
dedicated: false,
},
);
let port = hub.lan.port;
@@ -307,31 +485,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 +501,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 +519,6 @@ impl Game<'_> {
data: 0,
meta: vec![(8, serializer::ITEM_STACK, Value::Item(stack))],
equipment: Vec::new(),
send_velocity: true,
});
}
out
@@ -386,7 +540,7 @@ impl Game<'_> {
let events = {
let mut ctx = TickCtx {
world: &mut self.world,
host: &state,
host: Some(&state),
entities: &entities,
next_entity: &mut self.next_entity,
spawn,
@@ -397,6 +551,8 @@ impl Game<'_> {
} else {
0.0
},
tick_rate: self.clock.rate(),
tick_frozen: self.clock.frozen(),
};
host.hub.tick(&mut ctx)
};
@@ -454,13 +610,15 @@ impl Game<'_> {
}
let mut ctx = TickCtx {
world: &mut self.world,
host: state,
host: Some(state),
entities,
next_entity: &mut self.next_entity,
spawn,
difficulty: i32::from(self.ui.difficulty),
difficulty_locked: false,
clock_rate: self.ui.clock.rate,
tick_rate: self.clock.rate(),
tick_frozen: self.clock.frozen(),
};
host.hub.place_new_player(conn, &mut ctx);
}
@@ -7,6 +7,7 @@
//! block log, the level events and the entity tracker.
use super::{Game, Hit, Mining, Screen, lan::LanHost};
use horizon_client::{
command::vanilla,
containers::BlockEntity,
entities::Experience,
items::ItemStack,
@@ -405,6 +406,11 @@ impl Game<'_> {
self.guest_command(host, conn, &text);
}
}
S::Unknown(id, payload)
if id == horizon_protocol::ids::play::serverbound::COMMAND_SUGGESTION =>
{
self.guest_suggestions(host, conn, &payload);
}
S::ClientCommand(c) if c.action == 0 => self.guest_respawn(host, conn),
S::PickItemFromBlock(p) => {
let state = self.world.block(p.pos[0], p.pos[1], p.pos[2]);
@@ -532,6 +538,46 @@ impl Game<'_> {
}
}
/// ServerGamePacketListenerImpl.handleCustomCommandSuggestions: the
/// completions of the command typed so far (after an optional slash), for
/// the nodes the tree marks `ask_server`.
fn guest_suggestions(&mut self, host: &mut LanHost, conn: u32, payload: &[u8]) {
let mut r = horizon_protocol::buf::Reader::new(payload);
let (Ok(id), Ok(text)) = (r.varint(), r.string(32500)) else {
return;
};
let Some(permission) = host.hub.guest(conn).map(|g| g.permission) else {
return;
};
let skip = usize::from(text.starts_with('/'));
let command = &text[skip..];
let d = vanilla::dispatcher();
let level = std::mem::replace(&mut self.ui.registries.permission, permission);
let parse = d.parse(command, &self.ui.registries);
let ((start, end), suggestions) = d.suggestions(&parse, command.len(), &self.ui.registries);
self.ui.registries.permission = level;
let utf16 = |byte: usize| command[..byte.min(command.len())].encode_utf16().count() as i32;
let mut w = horizon_protocol::buf::Writer::new();
w.varint(id);
w.varint(utf16(start) + skip as i32);
w.varint(utf16(end) - utf16(start));
w.varint(suggestions.len() as i32);
for s in &suggestions {
w.string(&s.text);
w.bool(s.tooltip.is_some());
if let Some(tip) = &s.tooltip {
w.nbt(&literal(tip));
}
}
host.hub.send_to(
conn,
&C::Unknown(
horizon_protocol::ids::play::clientbound::COMMAND_SUGGESTIONS,
w.data,
),
);
}
fn guest_respawn(&mut self, host: &mut LanHost, conn: u32) {
let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]);
let Some(i) = host.hub.guests.iter().position(|g| g.conn == conn) else {
@@ -0,0 +1,138 @@
//! Argument nodes of vanilla's command tree with a custom suggestion
//! provider (path of node names from the root, provider network name).
//! Generated by tools/generate_command_suggestions.py from
//! VanillaCommandSuggestionsExporter; do not edit by hand.
pub const SUGGESTIONS: &[(&str, &str)] = &[
("advancement grant targets only advancement criterion", "minecraft:ask_server"),
("advancement revoke targets only advancement criterion", "minecraft:ask_server"),
("attribute target attribute modifier remove id", "minecraft:ask_server"),
("attribute target attribute modifier value get id", "minecraft:ask_server"),
("bossbar get id", "minecraft:ask_server"),
("bossbar remove id", "minecraft:ask_server"),
("bossbar set id", "minecraft:ask_server"),
("data get storage target", "minecraft:ask_server"),
("data merge storage target", "minecraft:ask_server"),
("data modify block target targetPath append from storage source", "minecraft:ask_server"),
("data modify block target targetPath append string storage source", "minecraft:ask_server"),
("data modify block target targetPath insert index from storage source", "minecraft:ask_server"),
("data modify block target targetPath insert index string storage source", "minecraft:ask_server"),
("data modify block target targetPath merge from storage source", "minecraft:ask_server"),
("data modify block target targetPath merge string storage source", "minecraft:ask_server"),
("data modify block target targetPath prepend from storage source", "minecraft:ask_server"),
("data modify block target targetPath prepend string storage source", "minecraft:ask_server"),
("data modify block target targetPath set from storage source", "minecraft:ask_server"),
("data modify block target targetPath set string storage source", "minecraft:ask_server"),
("data modify entity target targetPath append from storage source", "minecraft:ask_server"),
("data modify entity target targetPath append string storage source", "minecraft:ask_server"),
("data modify entity target targetPath insert index from storage source", "minecraft:ask_server"),
("data modify entity target targetPath insert index string storage source", "minecraft:ask_server"),
("data modify entity target targetPath merge from storage source", "minecraft:ask_server"),
("data modify entity target targetPath merge string storage source", "minecraft:ask_server"),
("data modify entity target targetPath prepend from storage source", "minecraft:ask_server"),
("data modify entity target targetPath prepend string storage source", "minecraft:ask_server"),
("data modify entity target targetPath set from storage source", "minecraft:ask_server"),
("data modify entity target targetPath set string storage source", "minecraft:ask_server"),
("data modify storage target", "minecraft:ask_server"),
("data modify storage target targetPath append from storage source", "minecraft:ask_server"),
("data modify storage target targetPath append string storage source", "minecraft:ask_server"),
("data modify storage target targetPath insert index from storage source", "minecraft:ask_server"),
("data modify storage target targetPath insert index string storage source", "minecraft:ask_server"),
("data modify storage target targetPath merge from storage source", "minecraft:ask_server"),
("data modify storage target targetPath merge string storage source", "minecraft:ask_server"),
("data modify storage target targetPath prepend from storage source", "minecraft:ask_server"),
("data modify storage target targetPath prepend string storage source", "minecraft:ask_server"),
("data modify storage target targetPath set from storage source", "minecraft:ask_server"),
("data modify storage target targetPath set string storage source", "minecraft:ask_server"),
("data remove storage target", "minecraft:ask_server"),
("datapack disable name", "minecraft:ask_server"),
("datapack enable name", "minecraft:ask_server"),
("datapack enable name after existing", "minecraft:ask_server"),
("datapack enable name before existing", "minecraft:ask_server"),
("debug function name", "minecraft:ask_server"),
("deop targets", "minecraft:ask_server"),
("execute if data storage source", "minecraft:ask_server"),
("execute if function name", "minecraft:ask_server"),
("execute if score target", "minecraft:ask_server"),
("execute if score target targetObjective < source", "minecraft:ask_server"),
("execute if score target targetObjective <= source", "minecraft:ask_server"),
("execute if score target targetObjective = source", "minecraft:ask_server"),
("execute if score target targetObjective > source", "minecraft:ask_server"),
("execute if score target targetObjective >= source", "minecraft:ask_server"),
("execute if stopwatch id", "minecraft:ask_server"),
("execute store result bossbar id", "minecraft:ask_server"),
("execute store result score targets", "minecraft:ask_server"),
("execute store result storage target", "minecraft:ask_server"),
("execute store success bossbar id", "minecraft:ask_server"),
("execute store success score targets", "minecraft:ask_server"),
("execute store success storage target", "minecraft:ask_server"),
("execute summon entity", "minecraft:summonable_entities"),
("execute unless data storage source", "minecraft:ask_server"),
("execute unless function name", "minecraft:ask_server"),
("execute unless score target", "minecraft:ask_server"),
("execute unless score target targetObjective < source", "minecraft:ask_server"),
("execute unless score target targetObjective <= source", "minecraft:ask_server"),
("execute unless score target targetObjective = source", "minecraft:ask_server"),
("execute unless score target targetObjective > source", "minecraft:ask_server"),
("execute unless score target targetObjective >= source", "minecraft:ask_server"),
("execute unless stopwatch id", "minecraft:ask_server"),
("function name", "minecraft:ask_server"),
("function name with storage source", "minecraft:ask_server"),
("op targets", "minecraft:ask_server"),
("pardon targets", "minecraft:ask_server"),
("pardon-ip target", "minecraft:ask_server"),
("place template template", "minecraft:ask_server"),
("playsound sound", "minecraft:available_sounds"),
("posteffect add targets posteffect", "minecraft:post_effects"),
("posteffect remove targets posteffect", "minecraft:post_effects"),
("random reset sequence", "minecraft:ask_server"),
("random roll range sequence", "minecraft:ask_server"),
("random value range sequence", "minecraft:ask_server"),
("schedule clear function", "minecraft:ask_server"),
("schedule function function", "minecraft:ask_server"),
("scoreboard players add targets", "minecraft:ask_server"),
("scoreboard players display name targets", "minecraft:ask_server"),
("scoreboard players display numberformat targets", "minecraft:ask_server"),
("scoreboard players enable targets", "minecraft:ask_server"),
("scoreboard players enable targets objective", "minecraft:ask_server"),
("scoreboard players get target", "minecraft:ask_server"),
("scoreboard players list target", "minecraft:ask_server"),
("scoreboard players operation targets", "minecraft:ask_server"),
("scoreboard players operation targets targetObjective operation source", "minecraft:ask_server"),
("scoreboard players remove targets", "minecraft:ask_server"),
("scoreboard players reset targets", "minecraft:ask_server"),
("scoreboard players set targets", "minecraft:ask_server"),
("stopsound targets * sound", "minecraft:available_sounds"),
("stopsound targets ambient sound", "minecraft:available_sounds"),
("stopsound targets block sound", "minecraft:available_sounds"),
("stopsound targets hostile sound", "minecraft:available_sounds"),
("stopsound targets master sound", "minecraft:available_sounds"),
("stopsound targets music sound", "minecraft:available_sounds"),
("stopsound targets neutral sound", "minecraft:available_sounds"),
("stopsound targets player sound", "minecraft:available_sounds"),
("stopsound targets record sound", "minecraft:available_sounds"),
("stopsound targets ui sound", "minecraft:available_sounds"),
("stopsound targets voice sound", "minecraft:available_sounds"),
("stopsound targets weather sound", "minecraft:available_sounds"),
("stopwatch query id", "minecraft:ask_server"),
("stopwatch remove id", "minecraft:ask_server"),
("stopwatch restart id", "minecraft:ask_server"),
("summon entity", "minecraft:summonable_entities"),
("tag targets remove name", "minecraft:ask_server"),
("team join team members", "minecraft:ask_server"),
("team leave members", "minecraft:ask_server"),
("test locate tests", "minecraft:ask_server"),
("test run tests", "minecraft:ask_server"),
("test runmultiple tests", "minecraft:ask_server"),
("test verify tests", "minecraft:ask_server"),
("tick rate rate", "minecraft:ask_server"),
("tick sprint time", "minecraft:ask_server"),
("tick step time", "minecraft:ask_server"),
("time of clock query timeline", "minecraft:ask_server"),
("time of clock set timemarker", "minecraft:ask_server"),
("time query timeline", "minecraft:ask_server"),
("time set timemarker", "minecraft:ask_server"),
("trigger objective", "minecraft:ask_server"),
("whitelist add targets", "minecraft:ask_server"),
("whitelist remove targets", "minecraft:ask_server"),
];
+337
View File
@@ -0,0 +1,337 @@
//! The command tree players receive (Commands.sendCommands and
//! ClientboundCommandsPacket): vanilla's tree from the data generator's
//! commands report, cut down to the nodes the player's permission level may
//! use and the commands of the server kind (dedicated servers have `op`,
//! `ban`, `stop` ...; an integrated server `publish`), numbered breadth first
//! as vanilla does, with the argument parsers' network properties and the
//! custom suggestion providers (`command_suggestions`).
use super::command_suggestions::SUGGESTIONS;
use horizon_protocol::{
buf::Writer,
packets::play::{CommandNode, Commands},
registry::Builtin,
};
use serde_json::Value;
use std::{
collections::{HashMap, VecDeque},
path::Path,
};
/// Commands.CommandSelection: registered only on dedicated servers.
const DEDICATED_ONLY: &[&str] = &[
"ban",
"ban-ip",
"banlist",
"deop",
"op",
"pardon",
"pardon-ip",
"perf",
"save-all",
"save-off",
"save-on",
"setidletimeout",
"stop",
"transfer",
"whitelist",
];
/// Registered only on integrated servers.
const INTEGRATED_ONLY: &[&str] = &["publish", "unpublish"];
/// Commands that check permissions only on dedicated servers
/// (`checkPermissions` in SeedCommand and VersionCommand).
const OPEN_WHEN_INTEGRATED: &[&str] = &["seed", "version"];
/// Game rules of experimental features (minecart improvements), which the
/// report lists but a vanilla-features world does not register.
const EXPERIMENTAL_RULES: &[&str] = &["max_minecart_speed", "minecraft:max_minecart_speed"];
pub struct CommandTree {
root: Value,
}
/// A node of the filtered tree.
struct Node {
name: String,
kind: u8,
executable: bool,
restricted: bool,
children: Vec<usize>,
redirect: Option<Vec<String>>,
parser: Option<(String, Value)>,
suggestions: Option<&'static str>,
}
/// PermissionLevel names in the report's requirements.
fn required_level(node: &Value) -> u8 {
match node["permissions"]["permission"]["level"].as_str() {
Some("moderators") => 1,
Some("gamemasters") => 2,
Some("admins") => 3,
Some("owners") => 4,
_ => 0,
}
}
impl CommandTree {
/// commands.json next to registries.json (the data generator's reports).
pub fn load(resources: &Path) -> Option<Self> {
let dir = Builtin::find(resources)?.parent()?.to_path_buf();
let text = std::fs::read_to_string(dir.join("commands.json")).ok()?;
Some(Self {
root: serde_json::from_str(&text).ok()?,
})
}
/// The ClientboundCommandsPacket for a player with `level` permissions.
pub fn packet(&self, level: u8, dedicated: bool, builtin: &Builtin) -> Commands {
let mut nodes = vec![Node {
name: String::new(),
kind: 0,
executable: false,
restricted: false,
children: Vec::new(),
redirect: None,
parser: None,
suggestions: None,
}];
let suggestions: HashMap<&str, &'static str> = SUGGESTIONS.iter().copied().collect();
let mut paths: HashMap<Vec<String>, usize> = HashMap::new();
paths.insert(Vec::new(), 0);
fill(
&self.root,
0,
&mut Vec::new(),
level,
dedicated,
&suggestions,
&mut nodes,
&mut paths,
);
// enumerateNodes: breadth first from the root, each node's children
// (in name order) then its redirect target.
let mut ids = vec![None; nodes.len()];
let mut order = Vec::new();
let mut queue = VecDeque::from([0usize]);
while let Some(n) = queue.pop_front() {
if ids[n].is_some() {
continue;
}
ids[n] = Some(order.len() as i32);
order.push(n);
queue.extend(nodes[n].children.iter().copied());
if let Some(target) = nodes[n].redirect.as_ref().and_then(|p| paths.get(p)) {
queue.push_back(*target);
}
}
let argument_types = builtin.get("minecraft:command_argument_type");
let out = order
.iter()
.map(|&n| {
let node = &nodes[n];
CommandNode {
kind: node.kind,
executable: node.executable,
restricted: node.restricted,
children: node.children.iter().filter_map(|c| ids[*c]).collect(),
redirect: node
.redirect
.as_ref()
.and_then(|p| paths.get(p))
.and_then(|t| ids[*t]),
name: node.name.clone(),
parser: node.parser.as_ref().map(|(parser, props)| {
let id = argument_types.and_then(|t| t.id(parser)).unwrap_or(0);
(id, properties(parser, props))
}),
suggestions: node.suggestions.map(str::to_owned),
}
})
.collect();
Commands {
nodes: out,
root: 0,
}
}
}
/// Commands.fillUsableCommands over the report: the children the source may use.
#[allow(clippy::too_many_arguments)]
fn fill(
source: &Value,
target: usize,
path: &mut Vec<String>,
level: u8,
dedicated: bool,
suggestions: &HashMap<&str, &'static str>,
nodes: &mut Vec<Node>,
paths: &mut HashMap<Vec<String>, usize>,
) {
let Some(children) = source["children"].as_object() else {
return;
};
let mut names: Vec<&String> = children.keys().collect();
names.sort();
for name in names {
let child = &children[name];
let required = if path.is_empty() && !dedicated && OPEN_WHEN_INTEGRATED.contains(&name.as_str()) {
0
} else {
required_level(child)
};
if required > level
|| path.len() == 1 && path[0] == "gamerule" && EXPERIMENTAL_RULES.contains(&name.as_str())
{
continue;
}
if path.is_empty()
&& (dedicated && INTEGRATED_ONLY.contains(&name.as_str())
|| !dedicated && DEDICATED_ONLY.contains(&name.as_str()))
{
continue;
}
path.push(name.clone());
let argument = child["type"] == "argument";
let index = nodes.len();
nodes.push(Node {
name: name.clone(),
kind: if argument { 2 } else { 1 },
executable: child["executable"].as_bool().unwrap_or(false),
restricted: required > 0,
children: Vec::new(),
redirect: redirect(child),
parser: argument.then(|| {
(
child["parser"].as_str().unwrap_or_default().to_owned(),
child["properties"].clone(),
)
}),
suggestions: if argument {
suggestions.get(path.join(" ").as_str()).copied()
} else {
None
},
});
nodes[target].children.push(index);
paths.insert(path.clone(), index);
fill(child, index, path, level, dedicated, suggestions, nodes, paths);
path.pop();
}
}
/// The redirect target's path. The report leaves out redirects to the root
/// (`execute ... run`, `return run`): a node that neither runs nor leads
/// anywhere forks to the root.
fn redirect(node: &Value) -> Option<Vec<String>> {
if let Some(path) = node["redirect"].as_array() {
return Some(
path.iter()
.filter_map(|s| s.as_str().map(str::to_owned))
.collect(),
);
}
let dead_end = node["children"].as_object().is_none_or(|c| c.is_empty())
&& !node["executable"].as_bool().unwrap_or(false);
dead_end.then(Vec::new)
}
/// ArgumentTypeInfo.serializeToNetwork for the report's properties.
fn properties(parser: &str, p: &Value) -> Vec<u8> {
let mut w = Writer::new();
let flags = |w: &mut Writer| {
w.u8(u8::from(p.get("min").is_some()) | u8::from(p.get("max").is_some()) << 1);
};
match parser {
"brigadier:integer" => {
flags(&mut w);
for key in ["min", "max"] {
if let Some(v) = p[key].as_i64() {
w.i32(v as i32);
}
}
}
"brigadier:long" => {
flags(&mut w);
for key in ["min", "max"] {
if let Some(v) = p[key].as_i64() {
w.i64(v);
}
}
}
"brigadier:float" => {
flags(&mut w);
for key in ["min", "max"] {
if let Some(v) = p[key].as_f64() {
w.f32(v as f32);
}
}
}
"brigadier:double" => {
flags(&mut w);
for key in ["min", "max"] {
if let Some(v) = p[key].as_f64() {
w.f64(v);
}
}
}
"brigadier:string" => w.varint(match p["type"].as_str() {
Some("phrase") => 1,
Some("greedy") => 2,
_ => 0,
}),
"minecraft:entity" => {
let single = u8::from(p["amount"] == "single");
let players = u8::from(p["type"] == "players");
w.u8(single | players << 1);
}
"minecraft:score_holder" => w.u8(u8::from(p["amount"] == "multiple")),
"minecraft:time" => w.i32(p["min"].as_i64().unwrap_or(0) as i32),
"minecraft:resource"
| "minecraft:resource_key"
| "minecraft:resource_or_tag"
| "minecraft:resource_or_tag_key"
| "minecraft:resource_selector" => w.string(p["registry"].as_str().unwrap_or_default()),
_ => {}
}
w.data
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn integer_bounds_and_entity_flags() {
let p: Value = serde_json::from_str(r#"{"min": 1, "max": 1000}"#).unwrap();
assert_eq!(
properties("brigadier:integer", &p),
vec![3, 0, 0, 0, 1, 0, 0, 3, 0xE8]
);
let p: Value = serde_json::from_str(r#"{"amount": "single", "type": "players"}"#).unwrap();
assert_eq!(properties("minecraft:entity", &p), vec![3]);
let p: Value = serde_json::from_str(r#"{"type": "greedy"}"#).unwrap();
assert_eq!(properties("brigadier:string", &p), vec![2]);
}
#[test]
fn levels_filter_and_redirects_resolve() {
let report: Value = serde_json::from_str(
r#"{"type":"root","children":{
"say":{"type":"literal","children":{"message":{"type":"argument","parser":"minecraft:message","executable":true}},
"permissions":{"type":"minecraft:require","permission":{"type":"minecraft:command_level","level":"gamemasters"}}},
"me":{"type":"literal","children":{"action":{"type":"argument","parser":"minecraft:message","executable":true}}},
"stop":{"type":"literal","executable":true},
"tell":{"type":"literal","redirect":["me"]}}}"#,
)
.unwrap();
let tree = CommandTree { root: report };
let builtin = Builtin::default();
let guest = tree.packet(0, false, &builtin);
let names: Vec<_> = guest.nodes.iter().map(|n| n.name.as_str()).collect();
assert_eq!(names, ["", "me", "tell", "action"]);
// `tell` redirects to `me` (node 1).
assert_eq!(guest.nodes[2].redirect, Some(1));
let op = tree.packet(2, true, &builtin);
let names: Vec<_> = op.nodes.iter().map(|n| n.name.as_str()).collect();
assert_eq!(names, ["", "me", "say", "stop", "tell", "action", "message"]);
assert!(op.nodes[2].restricted);
}
}
+3
View File
@@ -64,6 +64,8 @@ pub struct SyncData {
registries: Vec<(String, Entries)>,
pub tags: UpdateTags,
pub features: Vec<String>,
/// Vanilla's command tree (the commands report), when found.
pub commands: Option<super::commands::CommandTree>,
}
/// Every `*.json` under `dir`, as (path without extension relative to dir, value), sorted.
@@ -133,6 +135,7 @@ impl SyncData {
registries,
tags,
features: vec!["minecraft:vanilla".to_owned()],
commands: super::commands::CommandTree::load(resources),
})
}
+162 -113
View File
@@ -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);
+3
View File
@@ -36,6 +36,8 @@ pub struct Guest {
pub head_yaw: f32,
pub on_ground: bool,
pub game_mode: i32,
/// The permission level (0 none, 2 game masters, 4 owners).
pub permission: u8,
pub sneaking: bool,
pub sprinting: bool,
pub fall_flying: bool,
@@ -110,6 +112,7 @@ impl Guest {
head_yaw: 0.0,
on_ground: false,
game_mode: SURVIVAL,
permission: 0,
sneaking: false,
sprinting: false,
fall_flying: false,
+221 -157
View File
@@ -17,6 +17,7 @@ use horizon_protocol::{
packets::play::{self, Clientbound as C, Serverbound as S},
text::{literal, translatable},
};
use horizon_protocol::ids::play::clientbound as ids;
use horizon_world::nbt::{JavaString, Tag};
use std::sync::{Arc, atomic::Ordering};
@@ -41,6 +42,8 @@ pub struct HubConfig {
pub dimension: String,
pub seed: i64,
pub max_players: usize,
/// A dedicated server: operators from `ops`, the dedicated-only commands.
pub dedicated: bool,
}
/// The host player as the other players see them.
@@ -64,7 +67,8 @@ pub struct HostState {
/// Everything the hub needs from the game for one tick.
pub struct TickCtx<'a> {
pub world: &'a mut World,
pub host: &'a HostState,
/// The player playing on the host (none on a dedicated server).
pub host: Option<&'a HostState>,
pub entities: &'a [EntityState],
pub next_entity: &'a mut u32,
/// Where new players appear.
@@ -73,6 +77,9 @@ pub struct TickCtx<'a> {
pub difficulty_locked: bool,
/// The overworld clock's speed (0 when the day does not advance).
pub clock_rate: f32,
/// TickRateManager: the tick rate and whether the game is frozen.
pub tick_rate: f32,
pub tick_frozen: bool,
}
/// What the gameplay code has to do for a guest.
@@ -94,7 +101,13 @@ pub struct Hub {
pub(super) sync: Arc<SyncData>,
pub ticks: u64,
last_clock: i64,
last_rate: f32,
ticking: (f32, bool),
weather: (bool, f32, f32),
/// ClientboundUpdateRecipesPacket, when the game provides it.
pub recipes: Option<Vec<u8>>,
/// Operators and their permission levels (dedicated servers).
pub ops: std::collections::HashMap<u128, u8>,
chat_type: i32,
dimension_type: i32,
clock_ids: Vec<i32>,
@@ -130,6 +143,10 @@ impl Hub {
sync,
ticks: 0,
last_clock: i64::MIN,
last_rate: 0.0,
ticking: (20.0, false),
recipes: None,
ops: Default::default(),
weather: (false, 0.0, 0.0),
chat_type,
dimension_type,
@@ -146,6 +163,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;
@@ -223,13 +245,13 @@ impl Hub {
game_mode: Some(mode),
listed: Some(true),
latency: Some(latency),
display_name: None,
display_name: Some(None),
list_order: Some(0),
show_hat: Some(true),
}
}
/// ClientboundPlayerInfoUpdatePacket.createPlayerInitializing actions.
const INFO_ACTIONS: u8 = 1 | 4 | 8 | 16 | 64 | 128;
/// ClientboundPlayerInfoUpdatePacket.createPlayerInitializing: every action.
const INFO_ACTIONS: u8 = 0xFF;
// ---- joining -------------------------------------------------------
@@ -254,6 +276,7 @@ impl Hub {
let conn = j.conn;
let mut guest = Guest::new(j, entity_id, ctx.spawn, self.config.view_distance);
guest.game_mode = self.config.default_game_mode;
guest.permission = self.default_permission(guest.uuid);
guest.joined_tick = self.ticks;
guest.position = ctx.spawn;
guest.previous_position = ctx.spawn;
@@ -261,33 +284,41 @@ impl Hub {
conn
}
/// The permission level a player gets: an integrated server gives every
/// guest the game masters' level when commands are allowed
/// (IntegratedServer.setGuestCommandAccess); a dedicated server its
/// operators' level (`ops`).
fn default_permission(&self, uuid: u128) -> u8 {
if self.config.dedicated {
self.ops.get(&uuid).copied().unwrap_or(0)
} else if self.config.guest_commands {
2
} else {
0
}
}
/// The levels of a server, in MinecraftServer.levelKeys order.
fn level_keys() -> Vec<String> {
["overworld", "the_end", "the_nether"]
.map(|l| format!("minecraft:{l}"))
.to_vec()
}
/// PlayerList.placeNewPlayer for a guest whose saved state is loaded.
pub fn place_new_player(&mut self, conn: u32, ctx: &mut TickCtx) {
let Some(i) = self.guests.iter().position(|g| g.conn == conn) else {
return;
};
let (name, uuid, entity_id, mode, view) = {
let (name, uuid, entity_id, mode) = {
let g = &self.guests[i];
(
g.name.clone(),
g.uuid,
g.entity_id,
g.game_mode,
g.view_distance,
)
(g.name.clone(), g.uuid, g.entity_id, g.game_mode)
};
let _ = view;
let levels = vec![
"minecraft:overworld".to_owned(),
"minecraft:the_nether".to_owned(),
"minecraft:the_end".to_owned(),
];
self.guests[i].placed = true;
let g = &self.guests[i];
g.send(&C::Login(play::Login {
player_id: entity_id,
hardcore: self.config.hardcore,
levels,
levels: Self::level_keys(),
max_players: self.config.max_players as i32,
chunk_radius: self.config.view_distance,
simulation_distance: self.config.simulation_distance,
@@ -310,13 +341,38 @@ impl Hub {
g.send(&C::SetHeldSlot(play::SetHeldSlot {
slot: g.selected as i32,
}));
self.send_commands(i);
self.system_message(yellow_translation("multiplayer.player.joined", &[&name]));
if let Some(recipes) = &self.recipes {
g.sender.send(recipes.clone());
}
// PlayerList.sendPlayerPermissionLevel, then the recipe book.
self.send_permission_level(i);
let g = &self.guests[i];
g.send(&raw(
ids::RECIPE_BOOK_SETTINGS,
vec![0; 8], // four books, each closed and unfiltered
));
g.send(&raw(ids::RECIPE_BOOK_ADD, vec![0, 1])); // no entries, replace
// The others hear of the newcomer before it joins the list.
self.broadcast_except(
conn,
&C::SystemChat(play::SystemChat {
content: yellow_translation("multiplayer.player.joined", &[&name]),
overlay: false,
}),
);
self.guests[i].placed = true;
self.teleport(i, None);
let g = &self.guests[i];
g.send(&C::ServerData(play::ServerData {
motd: literal(&self.lan.shared.motd()),
icon: None,
}));
// The player list: everyone already here, then the newcomer to all.
let mut entries =
vec![self.player_info(ctx.host.uuid, &ctx.host.name, ctx.host.game_mode, 0)];
for o in self.guests.iter().filter(|o| o.conn != conn) {
let mut entries = Vec::new();
if let Some(host) = ctx.host {
entries.push(self.player_info(host.uuid, &host.name, host.game_mode, 0));
}
for o in self.guests.iter().filter(|o| o.conn != conn && o.placed) {
entries.push(self.player_info(
o.uuid,
&o.name,
@@ -324,26 +380,40 @@ impl Hub {
o.latency.load(Ordering::Relaxed),
));
}
entries.push(self.player_info(uuid, &name, mode, 0));
let own = play::PlayerInfoUpdate {
self.guests[i].send(&C::PlayerInfoUpdate(play::PlayerInfoUpdate {
actions: Self::INFO_ACTIONS,
entries,
};
self.guests[i].send(&C::PlayerInfoUpdate(own));
}));
let newcomer = play::PlayerInfoUpdate {
actions: Self::INFO_ACTIONS,
entries: vec![self.player_info(uuid, &name, mode, 0)],
};
self.broadcast_except(conn, &C::PlayerInfoUpdate(newcomer));
self.broadcast(&C::PlayerInfoUpdate(newcomer));
self.send_level_info(i, ctx);
// ServerPlayer.sendPostEffects and initInventoryMenu.
self.guests[i].send(&raw(ids::POST_EFFECTS, vec![0]));
self.send_inventory(i);
}
/// Send everyone the command tree again (the permissions changed).
pub fn resend_commands(&self) {
/// PlayerList.sendPlayerPermissionLevel: the op level as an entity event
/// (24 + level), then the commands that level may use.
fn send_permission_level(&self, i: usize) {
let g = &self.guests[i];
g.send(&C::EntityEvent(play::EntityEvent {
id: g.entity_id,
event: 24 + g.permission.min(4) as i8,
}));
self.send_commands(i);
}
/// The permissions changed: tell everyone their level and commands again.
pub fn resend_commands(&mut self) {
for i in 0..self.guests.len() {
if !self.config.dedicated {
self.guests[i].permission = self.default_permission(self.guests[i].uuid);
}
if self.guests[i].placed {
self.send_commands(i);
self.send_permission_level(i);
}
}
}
@@ -351,53 +421,26 @@ impl Hub {
/// Commands.sendCommands: the command tree for this player.
fn send_commands(&self, i: usize) {
let g = &self.guests[i];
// A root with every command taking free-form arguments.
let mut nodes = vec![play::CommandNode {
kind: 0,
executable: false,
restricted: false,
children: Vec::new(),
redirect: None,
name: String::new(),
parser: None,
suggestions: None,
}];
if self.config.guest_commands {
let argument = |name: &str| play::CommandNode {
kind: 2,
executable: true,
restricted: false,
children: Vec::new(),
redirect: None,
name: name.to_owned(),
// brigadier:string with the greedy phrase type.
parser: self
.sync
.builtin
.get("minecraft:command_argument_type")
.and_then(|ids| ids.id("brigadier:string"))
.map(|id| (id, vec![2])),
suggestions: None,
};
for name in COMMANDS {
nodes.push(play::CommandNode {
kind: 1,
executable: true,
let packet = match &self.sync.commands {
Some(tree) => tree.packet(g.permission, self.config.dedicated, &self.sync.builtin),
None => play::Commands {
nodes: vec![play::CommandNode {
kind: 0,
executable: false,
restricted: false,
children: vec![nodes.len() as i32 + 1],
children: Vec::new(),
redirect: None,
name: (*name).to_owned(),
name: String::new(),
parser: None,
suggestions: None,
});
nodes.push(argument("args"));
let literal_index = nodes.len() as i32 - 2;
nodes[0].children.push(literal_index);
}
}
g.send(&C::Commands(play::Commands { nodes, root: 0 }));
}],
root: 0,
},
};
g.send(&C::Commands(packet));
}
/// ServerGamePacketListenerImpl.teleport: move the client and wait for its confirmation.
pub fn teleport(&mut self, i: usize, position: Option<[f64; 3]>) {
let g = &mut self.guests[i];
@@ -430,7 +473,8 @@ impl Hub {
lerp_time: 0,
absolute_max_size: 29_999_984,
warning_blocks: 5,
warning_time: 15,
// WorldBorder.DEFAULT_WARNING_TIME, in ticks.
warning_time: 300,
}));
g.send(&self.time_packet(ctx.world, ctx.clock_rate));
g.send(&C::SetDefaultSpawnPosition(play::SetDefaultSpawnPosition {
@@ -464,8 +508,14 @@ impl Hub {
event: game_event::LEVEL_CHUNKS_LOAD_START,
param: 0.0,
}));
// TickRateManager.updateJoiningPlayer.
g.send(&ticking_state(ctx.tick_rate, ctx.tick_frozen));
g.send(&raw(ids::TICKING_STEP, vec![0]));
}
/// ServerClockManager.createFullSyncPacket: every clock. Horizon keeps the
/// overworld clock (the day time); the End's clock runs with the game
/// time. Clocks do not advance without the advance_time rule.
fn time_packet(&self, world: &World, rate: f32) -> C {
let overworld = self
.sync
@@ -476,11 +526,22 @@ impl Hub {
clocks: self
.clock_ids
.iter()
.map(|&id| play::ClockState {
clock: id,
total_ticks: if id == overworld { world.day_time } else { 0 },
partial_tick: 0.0,
rate: if id == overworld { rate } else { 0.0 },
.map(|&id| {
if id == overworld {
play::ClockState {
clock: id,
total_ticks: world.day_time,
partial_tick: 0.0,
rate,
}
} else {
play::ClockState {
clock: id,
total_ticks: world.game_time,
partial_tick: 0.0,
rate: if rate > 0.0 { 1.0 } else { 0.0 },
}
}
})
.collect(),
})
@@ -599,9 +660,40 @@ impl Hub {
/// Time and weather (ServerLevel.tickTime / advanceWeatherCycle broadcasts).
fn sync_world(&mut self, ctx: &TickCtx) {
let world = &*ctx.world;
if self.ticks.is_multiple_of(20) || self.last_clock == i64::MIN {
self.broadcast(&self.time_packet(world, ctx.clock_rate));
self.last_clock = world.day_time;
// MinecraftServer.tickServer: the game time every 20 ticks
// (forceGameTimeSynchronization, no clocks).
if self.ticks.is_multiple_of(20) {
self.broadcast(&C::SetTime(play::SetTime {
game_time: world.game_time,
clocks: Vec::new(),
}));
}
// ServerClockManager.modifyClock: a day that jumped (`time set`, `time
// add`) or a new rate updates the overworld clock.
let (day, rate) = (world.day_time, ctx.clock_rate);
let step = i64::from(rate.ceil() as i32).max(1);
if self.last_clock != i64::MIN
&& ((day - self.last_clock).abs() > step || rate != self.last_rate)
{
let overworld = self
.sync
.id("minecraft:world_clock", "minecraft:overworld")
.unwrap_or(0);
self.broadcast(&C::SetTime(play::SetTime {
game_time: world.game_time,
clocks: vec![play::ClockState {
clock: overworld,
total_ticks: day,
partial_tick: 0.0,
rate,
}],
}));
}
self.last_clock = day;
self.last_rate = rate;
if (ctx.tick_rate, ctx.tick_frozen) != self.ticking {
self.ticking = (ctx.tick_rate, ctx.tick_frozen);
self.broadcast(&ticking_state(ctx.tick_rate, ctx.tick_frozen));
}
let w = &world.weather;
let (raining, rain, thunder) = self.weather;
@@ -864,7 +956,6 @@ impl Hub {
data: 0,
meta,
equipment,
send_velocity: false,
}
}
@@ -896,22 +987,23 @@ impl Hub {
fn track_entities(&mut self, ctx: &mut TickCtx) {
let mut all: Vec<EntityState> =
Vec::with_capacity(ctx.entities.len() + self.guests.len() + 1);
let h = ctx.host;
let equipment = {
let e = &h.equipment;
self.equipment_of(&e[0], &e[1], [&e[5], &e[4], &e[3], &e[2]])
};
all.push(self.player_state(
h.entity_id,
h.uuid,
h.position,
(h.yaw, h.pitch, h.head_yaw),
h.on_ground,
(h.sneaking, h.sprinting, false, false),
0x7F,
h.health,
equipment,
));
if let Some(h) = ctx.host {
let equipment = {
let e = &h.equipment;
self.equipment_of(&e[0], &e[1], [&e[5], &e[4], &e[3], &e[2]])
};
all.push(self.player_state(
h.entity_id,
h.uuid,
h.position,
(h.yaw, h.pitch, h.head_yaw),
h.on_ground,
(h.sneaking, h.sprinting, false, false),
0x7F,
h.health,
equipment,
));
}
let placed: Vec<usize> = (0..self.guests.len())
.filter(|&i| self.guests[i].placed)
.collect();
@@ -950,20 +1042,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 +1077,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);
}
}
@@ -1109,11 +1207,6 @@ impl Hub {
events.push(HostEvent::Chat(format!("<{name}> {text}")));
}
}
S::Unknown(id, ref payload) => {
// Packets Horizon does not model: ignore the ones that only
// matter to vanilla's chat signing.
let _ = (id, payload);
}
other => events.push(HostEvent::Packet(conn, Box::new(other))),
}
}
@@ -1207,48 +1300,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",
"attribute",
"clear",
"clone",
"damage",
"data",
"difficulty",
"effect",
"enchant",
"experience",
"fill",
"gamemode",
"gamerule",
"give",
"kill",
"me",
"msg",
"particle",
"playsound",
"say",
"scoreboard",
"seed",
"setblock",
"spawnpoint",
"summon",
"tag",
"teleport",
"tell",
"time",
"title",
"tp",
"weather",
"xp",
];
impl Hub {
pub fn item_name(&self, id: i32) -> Option<std::sync::Arc<str>> {
self.sync
@@ -1284,3 +1335,16 @@ impl Hub {
self.send_inventory(i);
}
}
/// A packet Horizon sends as raw bytes (no fields worth a type).
fn raw(id: i32, payload: Vec<u8>) -> C {
C::Unknown(id, payload)
}
/// ClientboundTickingStatePacket: the tick rate and whether the game is frozen.
fn ticking_state(rate: f32, frozen: bool) -> C {
let mut w = horizon_protocol::buf::Writer::new();
w.f32(rate);
w.bool(frozen);
raw(ids::TICKING_STATE, w.data)
}
+6
View File
@@ -5,6 +5,8 @@
//! ever exchanges decoded packets with these threads through channels, so a
//! world that is not open to the network pays nothing.
pub mod chunks;
pub mod command_suggestions;
pub mod commands;
pub mod conn;
pub mod containers;
pub mod data;
@@ -14,6 +16,7 @@ pub mod guest;
pub mod hub;
pub mod menu;
pub mod playerdata;
pub mod tracking;
pub use conn::PlayerSender;
use horizon_protocol::packets::play;
@@ -87,6 +90,9 @@ impl Shared {
online.insert(uuid);
None
}
pub fn motd(&self) -> String {
self.motd.lock().unwrap().clone()
}
pub fn release(&self, uuid: u128) {
self.online.lock().unwrap().remove(&uuid);
}
+176
View File
@@ -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))
}
+35 -7
View File
@@ -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,42 @@
package dev.horizonlab.parity;
import net.minecraft.client.Minecraft;
import net.minecraft.client.server.IntegratedServer;
import net.minecraft.server.MinecraftServer;
/**
* The vanilla client as the reference LAN host for the multiplayer harness
* (tools/test_lan_vanilla_client.py): it runs net.minecraft.client.main.Main
* unchanged, and once the singleplayer world is up it turns off the
* integrated server's authentication (it always authenticates guests against
* Mojang's session servers, which offline test clients cannot pass) and opens
* the world to LAN on the port in -Dhorizon.lanPort, with commands allowed,
* as the "Open to LAN" screen does.
*/
public final class OfflineLanHost {
public static void main(String[] args) throws Exception {
int port = Integer.getInteger("horizon.lanPort", 0);
Thread watcher = new Thread(() -> {
boolean published = false;
while (!published) {
try {
Thread.sleep(500);
Minecraft mc = Minecraft.getInstance();
IntegratedServer server = mc == null ? null : mc.getSingleplayerServer();
if (server == null || mc.player == null || !server.isReady()) continue;
server.setUsesAuthentication(false);
mc.execute(() -> {
boolean ok = server.publishServer(MinecraftServer.MultiplayerScope.LAN, true, port);
System.out.println("[OfflineLanHost] published=" + ok + " port=" + server.getPort());
});
published = true;
} catch (Throwable t) {
// The game is still starting.
}
}
}, "horizon-offline-lan");
watcher.setDaemon(true);
watcher.start();
net.minecraft.client.main.Main.main(args);
}
}
@@ -0,0 +1,39 @@
package dev.horizonlab.parity;
import com.mojang.brigadier.tree.ArgumentCommandNode;
import com.mojang.brigadier.tree.CommandNode;
import java.io.PrintWriter;
import java.nio.file.Files;
import java.nio.file.Path;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.commands.synchronization.SuggestionProviders;
/**
* Lists the argument nodes of vanilla's full command tree (Commands with
* CommandSelection.ALL, the tree the commands report shows) that carry a
* custom suggestion provider, with the provider's network name
* (SuggestionProviders.getName: ask_server unless it is one of the named
* client-side providers). ClientboundCommandsPacket sends that name; the
* report does not include it. One line per node: the path of node names
* from the root, space separated, a tab, the provider id.
*/
public final class VanillaCommandSuggestionsExporter {
public static void main(String[] args) throws Exception {
ParityData.init();
Commands commands = new Commands(Commands.CommandSelection.ALL, Commands.createValidationContext(ParityData.lookup));
try (PrintWriter out = new PrintWriter(Files.newBufferedWriter(Path.of(args[0])))) {
walk(commands.getDispatcher().getRoot(), "", out);
}
}
static void walk(CommandNode<CommandSourceStack> node, String path, PrintWriter out) {
for (CommandNode<CommandSourceStack> child : node.getChildren()) {
String childPath = path.isEmpty() ? child.getName() : path + " " + child.getName();
if (child instanceof ArgumentCommandNode<CommandSourceStack, ?> arg && arg.getCustomSuggestions() != null) {
out.println(childPath + "\t" + SuggestionProviders.getName(arg.getCustomSuggestions()));
}
walk(child, childPath, out);
}
}
}
@@ -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;
}
}
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""Generate crates/horizon-client/src/lan/command_suggestions.rs: the argument
nodes of vanilla's command tree that have a custom suggestion provider, and
the provider's network name (ClientboundCommandsPacket sends it; the commands
report does not). The list comes from VanillaCommandSuggestionsExporter run
against the pinned client jar:
javac --release 25 -proc:none -sourcepath mods/horizon/src/test/java -cp CLIENT_CP \\
-d BUILD mods/horizon/src/test/java/dev/horizonlab/parity/VanillaCommandSuggestionsExporter.java
java -cp BUILD:CLIENT_CP dev.horizonlab.parity.VanillaCommandSuggestionsExporter suggestions.tsv
generate_command_suggestions.py suggestions.tsv
"""
import argparse
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("tsv", type=Path)
parser.add_argument("--out", type=Path, default=ROOT / "crates/horizon-client/src/lan/command_suggestions.rs")
args = parser.parse_args()
rows = sorted(tuple(line.split("\t")) for line in args.tsv.read_text().splitlines() if line.strip())
if len(rows) < 50:
raise SystemExit(f"only {len(rows)} nodes")
lines = [
"//! Argument nodes of vanilla's command tree with a custom suggestion",
"//! provider (path of node names from the root, provider network name).",
"//! Generated by tools/generate_command_suggestions.py from",
"//! VanillaCommandSuggestionsExporter; do not edit by hand.",
"",
"pub const SUGGESTIONS: &[(&str, &str)] = &[",
]
lines += [f' ("{path}", "{provider}"),' for path, provider in rows]
lines += ["];", ""]
args.out.write_text("\n".join(lines))
print(f"wrote {len(rows)} nodes to {args.out}")
if __name__ == "__main__":
main()
+69
View File
@@ -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()
+284
View File
@@ -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()
+163
View File
@@ -0,0 +1,163 @@
#!/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.world:
cmd += ["--quickPlaySingleplayer", args.world]
if args.lan_port is not None:
# The vanilla client as reference LAN host (OfflineLanHost).
cp = cmd.index("-cp") + 1
cmd[cp] = os.pathsep.join([str(args.classes), cmd[cp]])
main = cmd.index("net.minecraft.client.main.Main")
cmd[main:main + 1] = [f"-Dhorizon.lanPort={args.lan_port}", "dev.horizonlab.parity.OfflineLanHost"]
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("--world", help="open this saves/ folder in singleplayer 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("--lan-port", type=int,
help="open the --world to LAN on this port without authentication (needs --classes)")
parser.add_argument("--classes", type=Path, default=ROOT / ".local/mp/oracle",
help="compiled OfflineLanHost (mods/horizon/src/test/java)")
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()