mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-11 02:21:38 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5411923887 | ||
|
|
120ac32311 |
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Entity tracking settings per entity type (EntityType.Builder
|
||||
//! clientTrackingRange in chunks, updateInterval in ticks with 0 for never,
|
||||
//! trackDeltas). Generated by tools/generate_entity_tracking.py from the
|
||||
//! decompiled EntityTypes.java; do not edit by hand.
|
||||
|
||||
/// (entity type, tracking range in chunks, update interval, track deltas), sorted.
|
||||
pub const TRACKING: &[(&str, u8, u32, bool)] = &[
|
||||
("minecraft:acacia_boat", 10, 3, true),
|
||||
("minecraft:acacia_chest_boat", 10, 3, true),
|
||||
("minecraft:allay", 8, 2, true),
|
||||
("minecraft:area_effect_cloud", 10, 0, true),
|
||||
("minecraft:armadillo", 10, 3, true),
|
||||
("minecraft:armor_stand", 10, 3, true),
|
||||
("minecraft:arrow", 4, 20, true),
|
||||
("minecraft:axolotl", 10, 3, true),
|
||||
("minecraft:bamboo_chest_raft", 10, 3, true),
|
||||
("minecraft:bamboo_raft", 10, 3, true),
|
||||
("minecraft:bat", 5, 3, false),
|
||||
("minecraft:bee", 8, 3, true),
|
||||
("minecraft:birch_boat", 10, 3, true),
|
||||
("minecraft:birch_chest_boat", 10, 3, true),
|
||||
("minecraft:blaze", 8, 3, true),
|
||||
("minecraft:block_display", 10, 1, true),
|
||||
("minecraft:bogged", 8, 3, true),
|
||||
("minecraft:breeze", 10, 3, true),
|
||||
("minecraft:breeze_wind_charge", 4, 10, true),
|
||||
("minecraft:camel", 10, 3, true),
|
||||
("minecraft:camel_husk", 10, 3, true),
|
||||
("minecraft:cat", 8, 3, true),
|
||||
("minecraft:cave_spider", 8, 3, true),
|
||||
("minecraft:cherry_boat", 10, 3, true),
|
||||
("minecraft:cherry_chest_boat", 10, 3, true),
|
||||
("minecraft:chest_minecart", 8, 3, true),
|
||||
("minecraft:chicken", 10, 3, true),
|
||||
("minecraft:cod", 4, 3, true),
|
||||
("minecraft:command_block_minecart", 8, 3, true),
|
||||
("minecraft:copper_golem", 10, 3, true),
|
||||
("minecraft:cow", 10, 3, true),
|
||||
("minecraft:creaking", 8, 3, true),
|
||||
("minecraft:creeper", 8, 3, true),
|
||||
("minecraft:cushion", 10, 0, false),
|
||||
("minecraft:dark_oak_boat", 10, 3, true),
|
||||
("minecraft:dark_oak_chest_boat", 10, 3, true),
|
||||
("minecraft:dolphin", 5, 3, true),
|
||||
("minecraft:donkey", 10, 3, true),
|
||||
("minecraft:dragon_fireball", 4, 10, true),
|
||||
("minecraft:drowned", 8, 3, true),
|
||||
("minecraft:egg", 4, 10, true),
|
||||
("minecraft:elder_guardian", 10, 3, true),
|
||||
("minecraft:end_crystal", 16, 0, false),
|
||||
("minecraft:ender_dragon", 10, 3, true),
|
||||
("minecraft:ender_pearl", 4, 10, true),
|
||||
("minecraft:enderman", 8, 3, true),
|
||||
("minecraft:endermite", 8, 3, true),
|
||||
("minecraft:evoker", 8, 3, true),
|
||||
("minecraft:evoker_fangs", 6, 2, false),
|
||||
("minecraft:experience_bottle", 4, 10, true),
|
||||
("minecraft:experience_orb", 6, 20, true),
|
||||
("minecraft:eye_of_ender", 4, 4, true),
|
||||
("minecraft:falling_block", 10, 20, true),
|
||||
("minecraft:fireball", 4, 10, true),
|
||||
("minecraft:firework_rocket", 4, 10, true),
|
||||
("minecraft:fishing_bobber", 4, 5, true),
|
||||
("minecraft:fox", 8, 3, true),
|
||||
("minecraft:frog", 10, 3, true),
|
||||
("minecraft:furnace_minecart", 8, 3, true),
|
||||
("minecraft:ghast", 10, 3, true),
|
||||
("minecraft:giant", 10, 3, true),
|
||||
("minecraft:glow_item_frame", 10, 0, false),
|
||||
("minecraft:glow_squid", 10, 3, true),
|
||||
("minecraft:goat", 10, 3, true),
|
||||
("minecraft:guardian", 8, 3, true),
|
||||
("minecraft:happy_ghast", 10, 3, true),
|
||||
("minecraft:hoglin", 8, 3, true),
|
||||
("minecraft:hopper_minecart", 8, 3, true),
|
||||
("minecraft:horse", 10, 3, true),
|
||||
("minecraft:husk", 8, 3, true),
|
||||
("minecraft:illusioner", 8, 3, true),
|
||||
("minecraft:interaction", 10, 3, true),
|
||||
("minecraft:iron_golem", 10, 3, true),
|
||||
("minecraft:item", 6, 20, true),
|
||||
("minecraft:item_display", 10, 1, true),
|
||||
("minecraft:item_frame", 10, 0, false),
|
||||
("minecraft:jungle_boat", 10, 3, true),
|
||||
("minecraft:jungle_chest_boat", 10, 3, true),
|
||||
("minecraft:leash_knot", 10, 0, false),
|
||||
("minecraft:lightning_bolt", 16, 0, true),
|
||||
("minecraft:lingering_potion", 4, 10, true),
|
||||
("minecraft:llama", 10, 3, true),
|
||||
("minecraft:llama_spit", 4, 10, false),
|
||||
("minecraft:magma_cube", 8, 3, true),
|
||||
("minecraft:mangrove_boat", 10, 3, true),
|
||||
("minecraft:mangrove_chest_boat", 10, 3, true),
|
||||
("minecraft:mannequin", 32, 2, true),
|
||||
("minecraft:marker", 0, 3, true),
|
||||
("minecraft:minecart", 8, 3, true),
|
||||
("minecraft:mooshroom", 10, 3, true),
|
||||
("minecraft:mule", 8, 3, true),
|
||||
("minecraft:nautilus", 10, 3, true),
|
||||
("minecraft:oak_boat", 10, 3, true),
|
||||
("minecraft:oak_chest_boat", 10, 3, true),
|
||||
("minecraft:ocelot", 10, 3, true),
|
||||
("minecraft:ominous_item_spawner", 8, 3, true),
|
||||
("minecraft:painting", 10, 0, false),
|
||||
("minecraft:pale_oak_boat", 10, 3, true),
|
||||
("minecraft:pale_oak_chest_boat", 10, 3, true),
|
||||
("minecraft:panda", 10, 3, true),
|
||||
("minecraft:parched", 8, 3, true),
|
||||
("minecraft:parrot", 8, 3, true),
|
||||
("minecraft:phantom", 8, 3, true),
|
||||
("minecraft:pig", 10, 3, true),
|
||||
("minecraft:piglin", 8, 3, true),
|
||||
("minecraft:piglin_brute", 8, 3, true),
|
||||
("minecraft:pillager", 8, 3, true),
|
||||
("minecraft:player", 32, 2, false),
|
||||
("minecraft:polar_bear", 10, 3, true),
|
||||
("minecraft:poplar_boat", 10, 3, true),
|
||||
("minecraft:poplar_chest_boat", 10, 3, true),
|
||||
("minecraft:pufferfish", 4, 3, true),
|
||||
("minecraft:rabbit", 8, 3, true),
|
||||
("minecraft:ravager", 10, 3, true),
|
||||
("minecraft:salmon", 4, 3, true),
|
||||
("minecraft:sheep", 10, 3, true),
|
||||
("minecraft:shulker", 10, 3, true),
|
||||
("minecraft:shulker_bullet", 8, 3, true),
|
||||
("minecraft:silverfish", 8, 3, true),
|
||||
("minecraft:skeleton", 8, 3, true),
|
||||
("minecraft:skeleton_horse", 10, 3, true),
|
||||
("minecraft:slime", 10, 3, true),
|
||||
("minecraft:small_fireball", 4, 10, true),
|
||||
("minecraft:sniffer", 10, 3, true),
|
||||
("minecraft:snow_golem", 8, 3, true),
|
||||
("minecraft:snowball", 4, 10, true),
|
||||
("minecraft:spawner_minecart", 8, 3, true),
|
||||
("minecraft:spectral_arrow", 4, 20, true),
|
||||
("minecraft:spider", 8, 3, true),
|
||||
("minecraft:splash_potion", 4, 10, true),
|
||||
("minecraft:spruce_boat", 10, 3, true),
|
||||
("minecraft:spruce_chest_boat", 10, 3, true),
|
||||
("minecraft:squid", 8, 3, true),
|
||||
("minecraft:stray", 8, 3, true),
|
||||
("minecraft:strider", 10, 3, true),
|
||||
("minecraft:sulfur_cube", 10, 3, true),
|
||||
("minecraft:tadpole", 10, 3, true),
|
||||
("minecraft:text_display", 10, 1, true),
|
||||
("minecraft:tnt", 10, 10, true),
|
||||
("minecraft:tnt_minecart", 8, 3, true),
|
||||
("minecraft:trader_llama", 10, 3, true),
|
||||
("minecraft:trident", 4, 20, true),
|
||||
("minecraft:tropical_fish", 4, 3, true),
|
||||
("minecraft:turtle", 10, 3, true),
|
||||
("minecraft:vex", 8, 3, true),
|
||||
("minecraft:villager", 10, 3, true),
|
||||
("minecraft:vindicator", 8, 3, true),
|
||||
("minecraft:wandering_trader", 10, 3, true),
|
||||
("minecraft:warden", 16, 3, true),
|
||||
("minecraft:wind_charge", 4, 10, true),
|
||||
("minecraft:witch", 8, 3, true),
|
||||
("minecraft:wither", 10, 3, false),
|
||||
("minecraft:wither_skeleton", 8, 3, true),
|
||||
("minecraft:wither_skull", 4, 10, true),
|
||||
("minecraft:wolf", 10, 3, true),
|
||||
("minecraft:zoglin", 8, 3, true),
|
||||
("minecraft:zombie", 8, 3, true),
|
||||
("minecraft:zombie_horse", 10, 3, true),
|
||||
("minecraft:zombie_nautilus", 10, 3, true),
|
||||
("minecraft:zombie_villager", 8, 3, true),
|
||||
("minecraft:zombified_piglin", 8, 3, true),
|
||||
];
|
||||
|
||||
/// The settings of one entity type; unknown types get the builder defaults.
|
||||
pub fn settings(kind: &str) -> (u8, u32, bool) {
|
||||
TRACKING
|
||||
.binary_search_by(|row| row.0.cmp(kind))
|
||||
.map_or((5, 3, true), |i| (TRACKING[i].1, TRACKING[i].2, TRACKING[i].3))
|
||||
}
|
||||
@@ -26,6 +26,8 @@ pub enum Value {
|
||||
OptUuid(Option<u128>),
|
||||
/// Any VarInt-valued serializer (direction, block state, pose, variants ...).
|
||||
Id(i32),
|
||||
/// Several VarInts (VillagerData).
|
||||
Ids(Vec<i32>),
|
||||
}
|
||||
/// Serializer ids in EntityDataSerializers registration order.
|
||||
pub mod serializer {
|
||||
@@ -45,12 +47,34 @@ pub mod serializer {
|
||||
pub const OPTIONAL_LIVING_ENTITY_REFERENCE: i32 = 13;
|
||||
pub const BLOCK_STATE: i32 = 14;
|
||||
pub const OPTIONAL_BLOCK_STATE: i32 = 15;
|
||||
pub const PARTICLE: i32 = 16;
|
||||
pub const PARTICLES: i32 = 17;
|
||||
pub const VILLAGER_DATA: i32 = 18;
|
||||
pub const OPTIONAL_UNSIGNED_INT: i32 = 19;
|
||||
pub const POSE: i32 = 20;
|
||||
pub const VECTOR3: i32 = 40;
|
||||
pub const QUATERNION: i32 = 41;
|
||||
pub const HUMANOID_ARM: i32 = 43;
|
||||
pub const DYE_COLOR: i32 = 44;
|
||||
pub const CAT_VARIANT: i32 = 21;
|
||||
pub const CAT_SOUND_VARIANT: i32 = 22;
|
||||
pub const COW_VARIANT: i32 = 23;
|
||||
pub const COW_SOUND_VARIANT: i32 = 24;
|
||||
pub const WOLF_VARIANT: i32 = 25;
|
||||
pub const WOLF_SOUND_VARIANT: i32 = 26;
|
||||
pub const FROG_VARIANT: i32 = 27;
|
||||
pub const PIG_VARIANT: i32 = 28;
|
||||
pub const PIG_SOUND_VARIANT: i32 = 29;
|
||||
pub const CHICKEN_VARIANT: i32 = 30;
|
||||
pub const CHICKEN_SOUND_VARIANT: i32 = 31;
|
||||
pub const ZOMBIE_NAUTILUS_VARIANT: i32 = 32;
|
||||
pub const OPTIONAL_GLOBAL_POS: i32 = 33;
|
||||
pub const PAINTING_VARIANT: i32 = 34;
|
||||
pub const SNIFFER_STATE: i32 = 35;
|
||||
pub const ARMADILLO_STATE: i32 = 36;
|
||||
pub const COPPER_GOLEM_STATE: i32 = 37;
|
||||
pub const WEATHERING_COPPER_STATE: i32 = 38;
|
||||
pub const VECTOR3: i32 = 39;
|
||||
pub const QUATERNION: i32 = 40;
|
||||
pub const RESOLVABLE_PROFILE: i32 = 41;
|
||||
pub const HUMANOID_ARM: i32 = 42;
|
||||
pub const DYE_COLOR: i32 = 43;
|
||||
}
|
||||
fn read_value(kind: i32, r: &mut Reader) -> Result<Option<Value>> {
|
||||
use serializer::*;
|
||||
@@ -75,15 +99,18 @@ fn read_value(kind: i32, r: &mut Reader) -> Result<Option<Value>> {
|
||||
OPTIONAL_LIVING_ENTITY_REFERENCE => {
|
||||
Value::OptUuid(if r.bool()? { Some(r.uuid()?) } else { None })
|
||||
}
|
||||
// VillagerData: type, profession and level.
|
||||
VILLAGER_DATA => Value::Ids(vec![r.varint()?, r.varint()?, r.varint()?]),
|
||||
// VarInt-encoded: direction, block states, optional ints, pose, the
|
||||
// holder-registry variants (21..=33) and enum states (36..=39), arm, dye.
|
||||
// holder-registry variants (cat to zombie nautilus, painting) and the
|
||||
// enum states (sniffer to weathering copper), arm, dye.
|
||||
DIRECTION
|
||||
| BLOCK_STATE
|
||||
| OPTIONAL_BLOCK_STATE
|
||||
| OPTIONAL_UNSIGNED_INT
|
||||
| POSE
|
||||
| 21..=33
|
||||
| 36..=39
|
||||
| CAT_VARIANT..=ZOMBIE_NAUTILUS_VARIANT
|
||||
| PAINTING_VARIANT..=WEATHERING_COPPER_STATE
|
||||
| HUMANOID_ARM
|
||||
| DYE_COLOR => Value::Id(r.varint()?),
|
||||
_ => return Ok(None),
|
||||
@@ -93,6 +120,7 @@ fn write_value(kind: i32, v: &Value, w: &mut Writer) {
|
||||
match v {
|
||||
Value::Byte(b) => w.i8(*b),
|
||||
Value::Int(i) | Value::Id(i) => w.varint(*i),
|
||||
Value::Ids(ids) => ids.iter().for_each(|i| w.varint(*i)),
|
||||
Value::Long(l) => w.varlong(*l),
|
||||
Value::Float(f) => w.f32(*f),
|
||||
Value::String(s) => w.string(s),
|
||||
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
+39
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate crates/horizon-client/src/lan/tracking.rs: every entity type's
|
||||
tracking settings (EntityType.Builder clientTrackingRange, updateInterval and
|
||||
trackDeltas, which ChunkMap.TrackedEntity and ServerEntity use to decide who
|
||||
sees an entity and how often its movement is sent), read from the decompiled
|
||||
EntityTypes.java.
|
||||
|
||||
generate_entity_tracking.py [--sources .local/minecraft/26.3/sources/client]
|
||||
"""
|
||||
import argparse
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
INT_MAX = 2147483647
|
||||
|
||||
|
||||
def parse(sources):
|
||||
text = (sources / "net/minecraft/world/entity/EntityTypes.java").read_text()
|
||||
out = []
|
||||
for m in re.finditer(r"public static final EntityType<[\w.]+> (\w+) = register\((.*?)\n \);", text, re.S):
|
||||
name, body = m.group(1).lower(), m.group(2)
|
||||
range_ = re.findall(r"\.clientTrackingRange\((\d+)\)", body)
|
||||
interval = re.findall(r"\.updateInterval\((\w+(?:\.\w+)?)\)", body)
|
||||
track = ".dontTrackDeltas()" not in body
|
||||
r = int(range_[-1]) if range_ else 5
|
||||
if ".noUpdateInterval()" in body or (interval and interval[-1] == "Integer.MAX_VALUE"):
|
||||
i = 0
|
||||
else:
|
||||
i = int(interval[-1]) if interval else 3
|
||||
out.append((f"minecraft:{name}", r, i, track))
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--sources", type=Path, default=ROOT / ".local/minecraft/26.3/sources/client")
|
||||
parser.add_argument("--out", type=Path, default=ROOT / "crates/horizon-client/src/lan/tracking.rs")
|
||||
args = parser.parse_args()
|
||||
rows = parse(args.sources)
|
||||
if len(rows) < 100:
|
||||
raise SystemExit(f"only {len(rows)} entity types found")
|
||||
lines = [
|
||||
"//! Entity tracking settings per entity type (EntityType.Builder",
|
||||
"//! clientTrackingRange in chunks, updateInterval in ticks with 0 for never,",
|
||||
"//! trackDeltas). Generated by tools/generate_entity_tracking.py from the",
|
||||
"//! decompiled EntityTypes.java; do not edit by hand.",
|
||||
"",
|
||||
"/// (entity type, tracking range in chunks, update interval, track deltas), sorted.",
|
||||
"pub const TRACKING: &[(&str, u8, u32, bool)] = &[",
|
||||
]
|
||||
lines += [f' ("{n}", {r}, {i}, {str(t).lower()}),' for n, r, i, t in rows]
|
||||
lines += [
|
||||
"];",
|
||||
"",
|
||||
"/// The settings of one entity type; unknown types get the builder defaults.",
|
||||
"pub fn settings(kind: &str) -> (u8, u32, bool) {",
|
||||
" TRACKING",
|
||||
" .binary_search_by(|row| row.0.cmp(kind))",
|
||||
" .map_or((5, 3, true), |i| (TRACKING[i].1, TRACKING[i].2, TRACKING[i].3))",
|
||||
"}",
|
||||
"",
|
||||
]
|
||||
args.out.write_text("\n".join(lines))
|
||||
print(f"wrote {len(rows)} entity types to {args.out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A decoding TCP proxy for the Minecraft 26.3 protocol (offline mode, no
|
||||
encryption): clients connect to LISTEN, it forwards to TARGET and records
|
||||
every packet in both directions as JSON lines (time, direction, state, id,
|
||||
name, size and the payload), following the connection state through the
|
||||
handshake, login, configuration and play (and back to configuration).
|
||||
|
||||
net_capture.py --listen 25570 --target localhost:25565 --out capture.jsonl
|
||||
|
||||
The packet names come from crates/horizon-protocol/src/ids.rs, which is
|
||||
generated from vanilla's packets report. `summary` prints what a capture
|
||||
contains, `diff` compares the packet sequences of two captures (for example
|
||||
Horizon hosting and a vanilla server serving the same world).
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import zlib
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
STATES = ["handshake", "status", "login", "configuration", "play"]
|
||||
|
||||
|
||||
def load_names(path=ROOT / "crates/horizon-protocol/src/ids.rs"):
|
||||
"""{(state, direction): [name by id]} from the generated id tables."""
|
||||
text = path.read_text()
|
||||
names = {}
|
||||
for state in STATES:
|
||||
block = re.search(r"pub mod %s \{(.*?)\n\}" % state, text, re.S)
|
||||
if not block:
|
||||
continue
|
||||
for direction in ("clientbound", "serverbound"):
|
||||
part = re.search(r"pub mod %s \{.*?NAMES: &\[&str\] = &\[(.*?)\];" % direction,
|
||||
block.group(1), re.S)
|
||||
if part:
|
||||
names[(state, direction)] = re.findall(r'"([^"]+)"', part.group(1))
|
||||
return names
|
||||
|
||||
|
||||
def read_varint(data, pos):
|
||||
value = shift = 0
|
||||
while True:
|
||||
if pos >= len(data):
|
||||
raise IndexError("truncated varint")
|
||||
b = data[pos]
|
||||
pos += 1
|
||||
value |= (b & 0x7F) << shift
|
||||
shift += 7
|
||||
if not b & 0x80:
|
||||
if value & 0x80000000:
|
||||
value -= 1 << 32
|
||||
return value, pos
|
||||
if shift > 35:
|
||||
raise ValueError("varint too long")
|
||||
|
||||
|
||||
class Session:
|
||||
"""The state both directions of one connection share."""
|
||||
|
||||
def __init__(self, conn_id, out, names, lock, full):
|
||||
self.id = conn_id
|
||||
self.state = {"clientbound": "handshake", "serverbound": "handshake"}
|
||||
self.threshold = -1
|
||||
self.out = out
|
||||
self.names = names
|
||||
self.lock = lock
|
||||
self.full = full
|
||||
self.start = time.time()
|
||||
self.closed = threading.Event()
|
||||
|
||||
def name(self, direction, packet_id):
|
||||
table = self.names.get((self.state[direction], direction), [])
|
||||
return table[packet_id] if 0 <= packet_id < len(table) else f"unknown_{packet_id}"
|
||||
|
||||
def record(self, direction, packet_id, payload):
|
||||
state = self.state[direction]
|
||||
name = self.name(direction, packet_id)
|
||||
entry = {
|
||||
"conn": self.id,
|
||||
"t": round(time.time() - self.start, 4),
|
||||
"dir": "s2c" if direction == "clientbound" else "c2s",
|
||||
"state": state,
|
||||
"id": packet_id,
|
||||
"name": name,
|
||||
"size": len(payload),
|
||||
}
|
||||
if self.full or len(payload) <= 4096:
|
||||
entry["data"] = payload.hex()
|
||||
else:
|
||||
entry["head"] = payload[:512].hex()
|
||||
with self.lock:
|
||||
self.out.write(json.dumps(entry) + "\n")
|
||||
self.out.flush()
|
||||
self.transition(direction, name, payload)
|
||||
|
||||
def transition(self, direction, name, payload):
|
||||
s = self.state
|
||||
if direction == "serverbound":
|
||||
if s["serverbound"] == "handshake" and name == "intention":
|
||||
pos = read_varint(payload, 0)[1]
|
||||
length, pos = read_varint(payload, pos)
|
||||
intent = read_varint(payload, pos + length + 2)[0]
|
||||
nxt = {1: "status", 2: "login", 3: "login"}.get(intent, "login")
|
||||
s["serverbound"] = s["clientbound"] = nxt
|
||||
elif s["serverbound"] == "login" and name == "login_acknowledged":
|
||||
s["serverbound"] = s["clientbound"] = "configuration"
|
||||
elif s["serverbound"] == "configuration" and name == "finish_configuration":
|
||||
s["serverbound"] = s["clientbound"] = "play"
|
||||
elif s["serverbound"] == "play" and name == "configuration_acknowledged":
|
||||
s["serverbound"] = s["clientbound"] = "configuration"
|
||||
elif s["clientbound"] == "login" and name == "login_compression":
|
||||
self.pending_threshold = read_varint(payload, 0)[0]
|
||||
|
||||
|
||||
class Pipe(threading.Thread):
|
||||
def __init__(self, session, direction, src, dst):
|
||||
super().__init__(daemon=True)
|
||||
self.session, self.direction, self.src, self.dst = session, direction, src, dst
|
||||
|
||||
def run(self):
|
||||
buffer = b""
|
||||
s = self.session
|
||||
try:
|
||||
while True:
|
||||
chunk = self.src.recv(1 << 16)
|
||||
if not chunk:
|
||||
break
|
||||
buffer += chunk
|
||||
frames = []
|
||||
while True:
|
||||
try:
|
||||
length, pos = read_varint(buffer, 0)
|
||||
except IndexError:
|
||||
break
|
||||
if len(buffer) < pos + length:
|
||||
break
|
||||
frames.append(buffer[:pos + length])
|
||||
body, buffer = buffer[pos:pos + length], buffer[pos + length:]
|
||||
threshold = s.threshold
|
||||
if threshold >= 0:
|
||||
size, p = read_varint(body, 0)
|
||||
body = zlib.decompress(body[p:]) if size else body[p:]
|
||||
packet_id, p = read_varint(body, 0)
|
||||
s.record(self.direction, packet_id, body[p:])
|
||||
# The compression threshold applies after its packet, in
|
||||
# both directions; the client answers only after reading it.
|
||||
pending = getattr(s, "pending_threshold", None)
|
||||
if pending is not None:
|
||||
s.threshold = pending
|
||||
s.pending_threshold = None
|
||||
# State switches must be in effect before the peer can
|
||||
# answer, so forward each frame after recording it.
|
||||
self.dst.sendall(frames.pop())
|
||||
except (OSError, ValueError, zlib.error) as e:
|
||||
with s.lock:
|
||||
s.out.write(json.dumps({"conn": s.id, "event": "error", "dir": self.direction,
|
||||
"error": str(e)}) + "\n")
|
||||
s.out.flush()
|
||||
finally:
|
||||
for sock in (self.src, self.dst):
|
||||
try:
|
||||
sock.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
s.closed.set()
|
||||
|
||||
|
||||
def serve(listen, target, out_path, full=False, ready=None, stop=None):
|
||||
names = load_names()
|
||||
host, port = target.rsplit(":", 1)
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server.bind(("127.0.0.1", listen))
|
||||
server.listen()
|
||||
server.settimeout(0.5)
|
||||
lock = threading.Lock()
|
||||
conn_id = 0
|
||||
with open(out_path, "a") as out:
|
||||
if ready:
|
||||
ready.set()
|
||||
while not (stop and stop.is_set()):
|
||||
try:
|
||||
client, _ = server.accept()
|
||||
except socket.timeout:
|
||||
continue
|
||||
conn_id += 1
|
||||
try:
|
||||
upstream = socket.create_connection((host, int(port)))
|
||||
except OSError as e:
|
||||
client.close()
|
||||
with lock:
|
||||
out.write(json.dumps({"conn": conn_id, "event": "error",
|
||||
"error": f"upstream: {e}"}) + "\n")
|
||||
continue
|
||||
for sock in (client, upstream):
|
||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
session = Session(conn_id, out, names, lock, full)
|
||||
Pipe(session, "serverbound", client, upstream).start()
|
||||
Pipe(session, "clientbound", upstream, client).start()
|
||||
server.close()
|
||||
|
||||
|
||||
def start_background(listen, target, out_path, full=False):
|
||||
"""Run the proxy on a thread; returns the stop event."""
|
||||
ready, stop = threading.Event(), threading.Event()
|
||||
threading.Thread(target=serve, args=(listen, target, out_path, full, ready, stop),
|
||||
daemon=True).start()
|
||||
ready.wait(5)
|
||||
return stop
|
||||
|
||||
|
||||
def load(path):
|
||||
return [json.loads(line) for line in Path(path).read_text().splitlines() if line.strip()]
|
||||
|
||||
|
||||
def play_sessions(entries):
|
||||
"""Connections that reached the play state."""
|
||||
return sorted({e["conn"] for e in entries if e.get("state") == "play"})
|
||||
|
||||
|
||||
def summary(entries, conn=None):
|
||||
lines = []
|
||||
conns = [conn] if conn else sorted({e["conn"] for e in entries})
|
||||
for c in conns:
|
||||
mine = [e for e in entries if e["conn"] == c and "name" in e]
|
||||
lines.append(f"connection {c}: {len(mine)} packets")
|
||||
counts = Counter((e["dir"], e["state"], e["name"]) for e in mine)
|
||||
for (d, s, n), k in sorted(counts.items()):
|
||||
size = sum(e["size"] for e in mine if (e["dir"], e["state"], e["name"]) == (d, s, n))
|
||||
lines.append(f" {d} {s:13} {n:40} x{k:<6} {size} bytes")
|
||||
for e in entries:
|
||||
if e["conn"] == c and e.get("event") == "error":
|
||||
lines.append(f" error ({e.get('dir')}): {e['error']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def sequence(entries, conn, direction="s2c", until="player_position"):
|
||||
"""Packet names in order, up to and including the first `until`."""
|
||||
out = []
|
||||
for e in entries:
|
||||
if e["conn"] != conn or e.get("dir") != direction or "name" not in e:
|
||||
continue
|
||||
out.append(f"{e['state']}/{e['name']}")
|
||||
if e["name"] == until and e["state"] == "play":
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
p = sub.add_parser("proxy")
|
||||
p.add_argument("--listen", type=int, required=True)
|
||||
p.add_argument("--target", required=True)
|
||||
p.add_argument("--out", type=Path, required=True)
|
||||
p.add_argument("--full", action="store_true", help="keep every payload in full")
|
||||
s = sub.add_parser("summary")
|
||||
s.add_argument("capture", type=Path)
|
||||
d = sub.add_parser("diff")
|
||||
d.add_argument("a", type=Path)
|
||||
d.add_argument("b", type=Path)
|
||||
d.add_argument("--until", default="player_position")
|
||||
args = parser.parse_args()
|
||||
if args.command == "proxy":
|
||||
serve(args.listen, args.target, args.out, args.full)
|
||||
elif args.command == "summary":
|
||||
print(summary(load(args.capture)))
|
||||
else:
|
||||
import difflib
|
||||
a, b = load(args.a), load(args.b)
|
||||
sa = sequence(a, play_sessions(a)[0], until=args.until)
|
||||
sb = sequence(b, play_sessions(b)[0], until=args.until)
|
||||
sys.stdout.writelines(difflib.unified_diff([x + "\n" for x in sa], [x + "\n" for x in sb],
|
||||
str(args.a), str(args.b)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,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()
|
||||
Reference in new issue
Block a user