Join guests as PlayerList.placeNewPlayer does and send vanilla's command tree

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-10-10 22:23:05 +02:00
1 parent 120ac32311
commit 5411923887
13 files changed
+880 -145

No files matched your search

+4 -1
View File
@@ -87,6 +87,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
dimension: "minecraft:overworld".into(),
seed: 0,
max_players: 8,
dedicated: false,
},
);
let mut next_entity = 100u32;
@@ -110,13 +111,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
world.day_time += 1;
let mut ctx = TickCtx {
world: &mut world,
host: &host,
host: Some(&host),
entities: &[],
next_entity: &mut next_entity,
spawn: [8.5, 60.0, 8.5],
difficulty: 2,
difficulty_locked: false,
clock_rate: 1.0,
tick_rate: 20.0,
tick_frozen: false,
};
for event in hub.tick(&mut ctx) {
match event {
+7 -2
View File
@@ -382,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;
@@ -539,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,
@@ -550,6 +551,8 @@ impl Game<'_> {
} else {
0.0
},
tick_rate: self.clock.rate(),
tick_frozen: self.clock.frozen(),
};
host.hub.tick(&mut ctx)
};
@@ -607,13 +610,15 @@ impl Game<'_> {
}
let mut ctx = TickCtx {
world: &mut self.world,
host: state,
host: Some(state),
entities,
next_entity: &mut self.next_entity,
spawn,
difficulty: i32::from(self.ui.difficulty),
difficulty_locked: false,
clock_rate: self.ui.clock.rate,
tick_rate: self.clock.rate(),
tick_frozen: self.clock.frozen(),
};
host.hub.place_new_player(conn, &mut ctx);
}
@@ -7,6 +7,7 @@
//! block log, the level events and the entity tracker.
use super::{Game, Hit, Mining, Screen, lan::LanHost};
use horizon_client::{
command::vanilla,
containers::BlockEntity,
entities::Experience,
items::ItemStack,
@@ -405,6 +406,11 @@ impl Game<'_> {
self.guest_command(host, conn, &text);
}
}
S::Unknown(id, payload)
if id == horizon_protocol::ids::play::serverbound::COMMAND_SUGGESTION =>
{
self.guest_suggestions(host, conn, &payload);
}
S::ClientCommand(c) if c.action == 0 => self.guest_respawn(host, conn),
S::PickItemFromBlock(p) => {
let state = self.world.block(p.pos[0], p.pos[1], p.pos[2]);
@@ -532,6 +538,46 @@ impl Game<'_> {
}
}
/// ServerGamePacketListenerImpl.handleCustomCommandSuggestions: the
/// completions of the command typed so far (after an optional slash), for
/// the nodes the tree marks `ask_server`.
fn guest_suggestions(&mut self, host: &mut LanHost, conn: u32, payload: &[u8]) {
let mut r = horizon_protocol::buf::Reader::new(payload);
let (Ok(id), Ok(text)) = (r.varint(), r.string(32500)) else {
return;
};
let Some(permission) = host.hub.guest(conn).map(|g| g.permission) else {
return;
};
let skip = usize::from(text.starts_with('/'));
let command = &text[skip..];
let d = vanilla::dispatcher();
let level = std::mem::replace(&mut self.ui.registries.permission, permission);
let parse = d.parse(command, &self.ui.registries);
let ((start, end), suggestions) = d.suggestions(&parse, command.len(), &self.ui.registries);
self.ui.registries.permission = level;
let utf16 = |byte: usize| command[..byte.min(command.len())].encode_utf16().count() as i32;
let mut w = horizon_protocol::buf::Writer::new();
w.varint(id);
w.varint(utf16(start) + skip as i32);
w.varint(utf16(end) - utf16(start));
w.varint(suggestions.len() as i32);
for s in &suggestions {
w.string(&s.text);
w.bool(s.tooltip.is_some());
if let Some(tip) = &s.tooltip {
w.nbt(&literal(tip));
}
}
host.hub.send_to(
conn,
&C::Unknown(
horizon_protocol::ids::play::clientbound::COMMAND_SUGGESTIONS,
w.data,
),
);
}
fn guest_respawn(&mut self, host: &mut LanHost, conn: u32) {
let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]);
let Some(i) = host.hub.guests.iter().position(|g| g.conn == conn) else {
@@ -0,0 +1,138 @@
//! Argument nodes of vanilla's command tree with a custom suggestion
//! provider (path of node names from the root, provider network name).
//! Generated by tools/generate_command_suggestions.py from
//! VanillaCommandSuggestionsExporter; do not edit by hand.
pub const SUGGESTIONS: &[(&str, &str)] = &[
("advancement grant targets only advancement criterion", "minecraft:ask_server"),
("advancement revoke targets only advancement criterion", "minecraft:ask_server"),
("attribute target attribute modifier remove id", "minecraft:ask_server"),
("attribute target attribute modifier value get id", "minecraft:ask_server"),
("bossbar get id", "minecraft:ask_server"),
("bossbar remove id", "minecraft:ask_server"),
("bossbar set id", "minecraft:ask_server"),
("data get storage target", "minecraft:ask_server"),
("data merge storage target", "minecraft:ask_server"),
("data modify block target targetPath append from storage source", "minecraft:ask_server"),
("data modify block target targetPath append string storage source", "minecraft:ask_server"),
("data modify block target targetPath insert index from storage source", "minecraft:ask_server"),
("data modify block target targetPath insert index string storage source", "minecraft:ask_server"),
("data modify block target targetPath merge from storage source", "minecraft:ask_server"),
("data modify block target targetPath merge string storage source", "minecraft:ask_server"),
("data modify block target targetPath prepend from storage source", "minecraft:ask_server"),
("data modify block target targetPath prepend string storage source", "minecraft:ask_server"),
("data modify block target targetPath set from storage source", "minecraft:ask_server"),
("data modify block target targetPath set string storage source", "minecraft:ask_server"),
("data modify entity target targetPath append from storage source", "minecraft:ask_server"),
("data modify entity target targetPath append string storage source", "minecraft:ask_server"),
("data modify entity target targetPath insert index from storage source", "minecraft:ask_server"),
("data modify entity target targetPath insert index string storage source", "minecraft:ask_server"),
("data modify entity target targetPath merge from storage source", "minecraft:ask_server"),
("data modify entity target targetPath merge string storage source", "minecraft:ask_server"),
("data modify entity target targetPath prepend from storage source", "minecraft:ask_server"),
("data modify entity target targetPath prepend string storage source", "minecraft:ask_server"),
("data modify entity target targetPath set from storage source", "minecraft:ask_server"),
("data modify entity target targetPath set string storage source", "minecraft:ask_server"),
("data modify storage target", "minecraft:ask_server"),
("data modify storage target targetPath append from storage source", "minecraft:ask_server"),
("data modify storage target targetPath append string storage source", "minecraft:ask_server"),
("data modify storage target targetPath insert index from storage source", "minecraft:ask_server"),
("data modify storage target targetPath insert index string storage source", "minecraft:ask_server"),
("data modify storage target targetPath merge from storage source", "minecraft:ask_server"),
("data modify storage target targetPath merge string storage source", "minecraft:ask_server"),
("data modify storage target targetPath prepend from storage source", "minecraft:ask_server"),
("data modify storage target targetPath prepend string storage source", "minecraft:ask_server"),
("data modify storage target targetPath set from storage source", "minecraft:ask_server"),
("data modify storage target targetPath set string storage source", "minecraft:ask_server"),
("data remove storage target", "minecraft:ask_server"),
("datapack disable name", "minecraft:ask_server"),
("datapack enable name", "minecraft:ask_server"),
("datapack enable name after existing", "minecraft:ask_server"),
("datapack enable name before existing", "minecraft:ask_server"),
("debug function name", "minecraft:ask_server"),
("deop targets", "minecraft:ask_server"),
("execute if data storage source", "minecraft:ask_server"),
("execute if function name", "minecraft:ask_server"),
("execute if score target", "minecraft:ask_server"),
("execute if score target targetObjective < source", "minecraft:ask_server"),
("execute if score target targetObjective <= source", "minecraft:ask_server"),
("execute if score target targetObjective = source", "minecraft:ask_server"),
("execute if score target targetObjective > source", "minecraft:ask_server"),
("execute if score target targetObjective >= source", "minecraft:ask_server"),
("execute if stopwatch id", "minecraft:ask_server"),
("execute store result bossbar id", "minecraft:ask_server"),
("execute store result score targets", "minecraft:ask_server"),
("execute store result storage target", "minecraft:ask_server"),
("execute store success bossbar id", "minecraft:ask_server"),
("execute store success score targets", "minecraft:ask_server"),
("execute store success storage target", "minecraft:ask_server"),
("execute summon entity", "minecraft:summonable_entities"),
("execute unless data storage source", "minecraft:ask_server"),
("execute unless function name", "minecraft:ask_server"),
("execute unless score target", "minecraft:ask_server"),
("execute unless score target targetObjective < source", "minecraft:ask_server"),
("execute unless score target targetObjective <= source", "minecraft:ask_server"),
("execute unless score target targetObjective = source", "minecraft:ask_server"),
("execute unless score target targetObjective > source", "minecraft:ask_server"),
("execute unless score target targetObjective >= source", "minecraft:ask_server"),
("execute unless stopwatch id", "minecraft:ask_server"),
("function name", "minecraft:ask_server"),
("function name with storage source", "minecraft:ask_server"),
("op targets", "minecraft:ask_server"),
("pardon targets", "minecraft:ask_server"),
("pardon-ip target", "minecraft:ask_server"),
("place template template", "minecraft:ask_server"),
("playsound sound", "minecraft:available_sounds"),
("posteffect add targets posteffect", "minecraft:post_effects"),
("posteffect remove targets posteffect", "minecraft:post_effects"),
("random reset sequence", "minecraft:ask_server"),
("random roll range sequence", "minecraft:ask_server"),
("random value range sequence", "minecraft:ask_server"),
("schedule clear function", "minecraft:ask_server"),
("schedule function function", "minecraft:ask_server"),
("scoreboard players add targets", "minecraft:ask_server"),
("scoreboard players display name targets", "minecraft:ask_server"),
("scoreboard players display numberformat targets", "minecraft:ask_server"),
("scoreboard players enable targets", "minecraft:ask_server"),
("scoreboard players enable targets objective", "minecraft:ask_server"),
("scoreboard players get target", "minecraft:ask_server"),
("scoreboard players list target", "minecraft:ask_server"),
("scoreboard players operation targets", "minecraft:ask_server"),
("scoreboard players operation targets targetObjective operation source", "minecraft:ask_server"),
("scoreboard players remove targets", "minecraft:ask_server"),
("scoreboard players reset targets", "minecraft:ask_server"),
("scoreboard players set targets", "minecraft:ask_server"),
("stopsound targets * sound", "minecraft:available_sounds"),
("stopsound targets ambient sound", "minecraft:available_sounds"),
("stopsound targets block sound", "minecraft:available_sounds"),
("stopsound targets hostile sound", "minecraft:available_sounds"),
("stopsound targets master sound", "minecraft:available_sounds"),
("stopsound targets music sound", "minecraft:available_sounds"),
("stopsound targets neutral sound", "minecraft:available_sounds"),
("stopsound targets player sound", "minecraft:available_sounds"),
("stopsound targets record sound", "minecraft:available_sounds"),
("stopsound targets ui sound", "minecraft:available_sounds"),
("stopsound targets voice sound", "minecraft:available_sounds"),
("stopsound targets weather sound", "minecraft:available_sounds"),
("stopwatch query id", "minecraft:ask_server"),
("stopwatch remove id", "minecraft:ask_server"),
("stopwatch restart id", "minecraft:ask_server"),
("summon entity", "minecraft:summonable_entities"),
("tag targets remove name", "minecraft:ask_server"),
("team join team members", "minecraft:ask_server"),
("team leave members", "minecraft:ask_server"),
("test locate tests", "minecraft:ask_server"),
("test run tests", "minecraft:ask_server"),
("test runmultiple tests", "minecraft:ask_server"),
("test verify tests", "minecraft:ask_server"),
("tick rate rate", "minecraft:ask_server"),
("tick sprint time", "minecraft:ask_server"),
("tick step time", "minecraft:ask_server"),
("time of clock query timeline", "minecraft:ask_server"),
("time of clock set timemarker", "minecraft:ask_server"),
("time query timeline", "minecraft:ask_server"),
("time set timemarker", "minecraft:ask_server"),
("trigger objective", "minecraft:ask_server"),
("whitelist add targets", "minecraft:ask_server"),
("whitelist remove targets", "minecraft:ask_server"),
];
+337
View File
@@ -0,0 +1,337 @@
//! The command tree players receive (Commands.sendCommands and
//! ClientboundCommandsPacket): vanilla's tree from the data generator's
//! commands report, cut down to the nodes the player's permission level may
//! use and the commands of the server kind (dedicated servers have `op`,
//! `ban`, `stop` ...; an integrated server `publish`), numbered breadth first
//! as vanilla does, with the argument parsers' network properties and the
//! custom suggestion providers (`command_suggestions`).
use super::command_suggestions::SUGGESTIONS;
use horizon_protocol::{
buf::Writer,
packets::play::{CommandNode, Commands},
registry::Builtin,
};
use serde_json::Value;
use std::{
collections::{HashMap, VecDeque},
path::Path,
};
/// Commands.CommandSelection: registered only on dedicated servers.
const DEDICATED_ONLY: &[&str] = &[
"ban",
"ban-ip",
"banlist",
"deop",
"op",
"pardon",
"pardon-ip",
"perf",
"save-all",
"save-off",
"save-on",
"setidletimeout",
"stop",
"transfer",
"whitelist",
];
/// Registered only on integrated servers.
const INTEGRATED_ONLY: &[&str] = &["publish", "unpublish"];
/// Commands that check permissions only on dedicated servers
/// (`checkPermissions` in SeedCommand and VersionCommand).
const OPEN_WHEN_INTEGRATED: &[&str] = &["seed", "version"];
/// Game rules of experimental features (minecart improvements), which the
/// report lists but a vanilla-features world does not register.
const EXPERIMENTAL_RULES: &[&str] = &["max_minecart_speed", "minecraft:max_minecart_speed"];
pub struct CommandTree {
root: Value,
}
/// A node of the filtered tree.
struct Node {
name: String,
kind: u8,
executable: bool,
restricted: bool,
children: Vec<usize>,
redirect: Option<Vec<String>>,
parser: Option<(String, Value)>,
suggestions: Option<&'static str>,
}
/// PermissionLevel names in the report's requirements.
fn required_level(node: &Value) -> u8 {
match node["permissions"]["permission"]["level"].as_str() {
Some("moderators") => 1,
Some("gamemasters") => 2,
Some("admins") => 3,
Some("owners") => 4,
_ => 0,
}
}
impl CommandTree {
/// commands.json next to registries.json (the data generator's reports).
pub fn load(resources: &Path) -> Option<Self> {
let dir = Builtin::find(resources)?.parent()?.to_path_buf();
let text = std::fs::read_to_string(dir.join("commands.json")).ok()?;
Some(Self {
root: serde_json::from_str(&text).ok()?,
})
}
/// The ClientboundCommandsPacket for a player with `level` permissions.
pub fn packet(&self, level: u8, dedicated: bool, builtin: &Builtin) -> Commands {
let mut nodes = vec![Node {
name: String::new(),
kind: 0,
executable: false,
restricted: false,
children: Vec::new(),
redirect: None,
parser: None,
suggestions: None,
}];
let suggestions: HashMap<&str, &'static str> = SUGGESTIONS.iter().copied().collect();
let mut paths: HashMap<Vec<String>, usize> = HashMap::new();
paths.insert(Vec::new(), 0);
fill(
&self.root,
0,
&mut Vec::new(),
level,
dedicated,
&suggestions,
&mut nodes,
&mut paths,
);
// enumerateNodes: breadth first from the root, each node's children
// (in name order) then its redirect target.
let mut ids = vec![None; nodes.len()];
let mut order = Vec::new();
let mut queue = VecDeque::from([0usize]);
while let Some(n) = queue.pop_front() {
if ids[n].is_some() {
continue;
}
ids[n] = Some(order.len() as i32);
order.push(n);
queue.extend(nodes[n].children.iter().copied());
if let Some(target) = nodes[n].redirect.as_ref().and_then(|p| paths.get(p)) {
queue.push_back(*target);
}
}
let argument_types = builtin.get("minecraft:command_argument_type");
let out = order
.iter()
.map(|&n| {
let node = &nodes[n];
CommandNode {
kind: node.kind,
executable: node.executable,
restricted: node.restricted,
children: node.children.iter().filter_map(|c| ids[*c]).collect(),
redirect: node
.redirect
.as_ref()
.and_then(|p| paths.get(p))
.and_then(|t| ids[*t]),
name: node.name.clone(),
parser: node.parser.as_ref().map(|(parser, props)| {
let id = argument_types.and_then(|t| t.id(parser)).unwrap_or(0);
(id, properties(parser, props))
}),
suggestions: node.suggestions.map(str::to_owned),
}
})
.collect();
Commands {
nodes: out,
root: 0,
}
}
}
/// Commands.fillUsableCommands over the report: the children the source may use.
#[allow(clippy::too_many_arguments)]
fn fill(
source: &Value,
target: usize,
path: &mut Vec<String>,
level: u8,
dedicated: bool,
suggestions: &HashMap<&str, &'static str>,
nodes: &mut Vec<Node>,
paths: &mut HashMap<Vec<String>, usize>,
) {
let Some(children) = source["children"].as_object() else {
return;
};
let mut names: Vec<&String> = children.keys().collect();
names.sort();
for name in names {
let child = &children[name];
let required = if path.is_empty() && !dedicated && OPEN_WHEN_INTEGRATED.contains(&name.as_str()) {
0
} else {
required_level(child)
};
if required > level
|| path.len() == 1 && path[0] == "gamerule" && EXPERIMENTAL_RULES.contains(&name.as_str())
{
continue;
}
if path.is_empty()
&& (dedicated && INTEGRATED_ONLY.contains(&name.as_str())
|| !dedicated && DEDICATED_ONLY.contains(&name.as_str()))
{
continue;
}
path.push(name.clone());
let argument = child["type"] == "argument";
let index = nodes.len();
nodes.push(Node {
name: name.clone(),
kind: if argument { 2 } else { 1 },
executable: child["executable"].as_bool().unwrap_or(false),
restricted: required > 0,
children: Vec::new(),
redirect: redirect(child),
parser: argument.then(|| {
(
child["parser"].as_str().unwrap_or_default().to_owned(),
child["properties"].clone(),
)
}),
suggestions: if argument {
suggestions.get(path.join(" ").as_str()).copied()
} else {
None
},
});
nodes[target].children.push(index);
paths.insert(path.clone(), index);
fill(child, index, path, level, dedicated, suggestions, nodes, paths);
path.pop();
}
}
/// The redirect target's path. The report leaves out redirects to the root
/// (`execute ... run`, `return run`): a node that neither runs nor leads
/// anywhere forks to the root.
fn redirect(node: &Value) -> Option<Vec<String>> {
if let Some(path) = node["redirect"].as_array() {
return Some(
path.iter()
.filter_map(|s| s.as_str().map(str::to_owned))
.collect(),
);
}
let dead_end = node["children"].as_object().is_none_or(|c| c.is_empty())
&& !node["executable"].as_bool().unwrap_or(false);
dead_end.then(Vec::new)
}
/// ArgumentTypeInfo.serializeToNetwork for the report's properties.
fn properties(parser: &str, p: &Value) -> Vec<u8> {
let mut w = Writer::new();
let flags = |w: &mut Writer| {
w.u8(u8::from(p.get("min").is_some()) | u8::from(p.get("max").is_some()) << 1);
};
match parser {
"brigadier:integer" => {
flags(&mut w);
for key in ["min", "max"] {
if let Some(v) = p[key].as_i64() {
w.i32(v as i32);
}
}
}
"brigadier:long" => {
flags(&mut w);
for key in ["min", "max"] {
if let Some(v) = p[key].as_i64() {
w.i64(v);
}
}
}
"brigadier:float" => {
flags(&mut w);
for key in ["min", "max"] {
if let Some(v) = p[key].as_f64() {
w.f32(v as f32);
}
}
}
"brigadier:double" => {
flags(&mut w);
for key in ["min", "max"] {
if let Some(v) = p[key].as_f64() {
w.f64(v);
}
}
}
"brigadier:string" => w.varint(match p["type"].as_str() {
Some("phrase") => 1,
Some("greedy") => 2,
_ => 0,
}),
"minecraft:entity" => {
let single = u8::from(p["amount"] == "single");
let players = u8::from(p["type"] == "players");
w.u8(single | players << 1);
}
"minecraft:score_holder" => w.u8(u8::from(p["amount"] == "multiple")),
"minecraft:time" => w.i32(p["min"].as_i64().unwrap_or(0) as i32),
"minecraft:resource"
| "minecraft:resource_key"
| "minecraft:resource_or_tag"
| "minecraft:resource_or_tag_key"
| "minecraft:resource_selector" => w.string(p["registry"].as_str().unwrap_or_default()),
_ => {}
}
w.data
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn integer_bounds_and_entity_flags() {
let p: Value = serde_json::from_str(r#"{"min": 1, "max": 1000}"#).unwrap();
assert_eq!(
properties("brigadier:integer", &p),
vec![3, 0, 0, 0, 1, 0, 0, 3, 0xE8]
);
let p: Value = serde_json::from_str(r#"{"amount": "single", "type": "players"}"#).unwrap();
assert_eq!(properties("minecraft:entity", &p), vec![3]);
let p: Value = serde_json::from_str(r#"{"type": "greedy"}"#).unwrap();
assert_eq!(properties("brigadier:string", &p), vec![2]);
}
#[test]
fn levels_filter_and_redirects_resolve() {
let report: Value = serde_json::from_str(
r#"{"type":"root","children":{
"say":{"type":"literal","children":{"message":{"type":"argument","parser":"minecraft:message","executable":true}},
"permissions":{"type":"minecraft:require","permission":{"type":"minecraft:command_level","level":"gamemasters"}}},
"me":{"type":"literal","children":{"action":{"type":"argument","parser":"minecraft:message","executable":true}}},
"stop":{"type":"literal","executable":true},
"tell":{"type":"literal","redirect":["me"]}}}"#,
)
.unwrap();
let tree = CommandTree { root: report };
let builtin = Builtin::default();
let guest = tree.packet(0, false, &builtin);
let names: Vec<_> = guest.nodes.iter().map(|n| n.name.as_str()).collect();
assert_eq!(names, ["", "me", "tell", "action"]);
// `tell` redirects to `me` (node 1).
assert_eq!(guest.nodes[2].redirect, Some(1));
let op = tree.packet(2, true, &builtin);
let names: Vec<_> = op.nodes.iter().map(|n| n.name.as_str()).collect();
assert_eq!(names, ["", "me", "say", "stop", "tell", "action", "message"]);
assert!(op.nodes[2].restricted);
}
}
+3
View File
@@ -64,6 +64,8 @@ pub struct SyncData {
registries: Vec<(String, Entries)>,
pub tags: UpdateTags,
pub features: Vec<String>,
/// Vanilla's command tree (the commands report), when found.
pub commands: Option<super::commands::CommandTree>,
}
/// Every `*.json` under `dir`, as (path without extension relative to dir, value), sorted.
@@ -133,6 +135,7 @@ impl SyncData {
registries,
tags,
features: vec!["minecraft:vanilla".to_owned()],
commands: super::commands::CommandTree::load(resources),
})
}
+3
View File
@@ -36,6 +36,8 @@ pub struct Guest {
pub head_yaw: f32,
pub on_ground: bool,
pub game_mode: i32,
/// The permission level (0 none, 2 game masters, 4 owners).
pub permission: u8,
pub sneaking: bool,
pub sprinting: bool,
pub fall_flying: bool,
@@ -110,6 +112,7 @@ impl Guest {
head_yaw: 0.0,
on_ground: false,
game_mode: SURVIVAL,
permission: 0,
sneaking: false,
sprinting: false,
fall_flying: false,
+200 -141
View File
@@ -17,6 +17,7 @@ use horizon_protocol::{
packets::play::{self, Clientbound as C, Serverbound as S},
text::{literal, translatable},
};
use horizon_protocol::ids::play::clientbound as ids;
use horizon_world::nbt::{JavaString, Tag};
use std::sync::{Arc, atomic::Ordering};
@@ -41,6 +42,8 @@ pub struct HubConfig {
pub dimension: String,
pub seed: i64,
pub max_players: usize,
/// A dedicated server: operators from `ops`, the dedicated-only commands.
pub dedicated: bool,
}
/// The host player as the other players see them.
@@ -64,7 +67,8 @@ pub struct HostState {
/// Everything the hub needs from the game for one tick.
pub struct TickCtx<'a> {
pub world: &'a mut World,
pub host: &'a HostState,
/// The player playing on the host (none on a dedicated server).
pub host: Option<&'a HostState>,
pub entities: &'a [EntityState],
pub next_entity: &'a mut u32,
/// Where new players appear.
@@ -73,6 +77,9 @@ pub struct TickCtx<'a> {
pub difficulty_locked: bool,
/// The overworld clock's speed (0 when the day does not advance).
pub clock_rate: f32,
/// TickRateManager: the tick rate and whether the game is frozen.
pub tick_rate: f32,
pub tick_frozen: bool,
}
/// What the gameplay code has to do for a guest.
@@ -94,7 +101,13 @@ pub struct Hub {
pub(super) sync: Arc<SyncData>,
pub ticks: u64,
last_clock: i64,
last_rate: f32,
ticking: (f32, bool),
weather: (bool, f32, f32),
/// ClientboundUpdateRecipesPacket, when the game provides it.
pub recipes: Option<Vec<u8>>,
/// Operators and their permission levels (dedicated servers).
pub ops: std::collections::HashMap<u128, u8>,
chat_type: i32,
dimension_type: i32,
clock_ids: Vec<i32>,
@@ -130,6 +143,10 @@ impl Hub {
sync,
ticks: 0,
last_clock: i64::MIN,
last_rate: 0.0,
ticking: (20.0, false),
recipes: None,
ops: Default::default(),
weather: (false, 0.0, 0.0),
chat_type,
dimension_type,
@@ -228,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 -------------------------------------------------------
@@ -259,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;
@@ -266,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,
@@ -315,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,
@@ -329,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);
}
}
}
@@ -356,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];
@@ -435,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 {
@@ -469,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
@@ -481,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(),
})
@@ -604,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;
@@ -900,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();
@@ -1119,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))),
}
}
@@ -1217,43 +1300,6 @@ impl Hub {
}
}
/// 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
@@ -1289,3 +1335,16 @@ impl Hub {
self.send_inventory(i);
}
}
/// A packet Horizon sends as raw bytes (no fields worth a type).
fn raw(id: i32, payload: Vec<u8>) -> C {
C::Unknown(id, payload)
}
/// ClientboundTickingStatePacket: the tick rate and whether the game is frozen.
fn ticking_state(rate: f32, frozen: bool) -> C {
let mut w = horizon_protocol::buf::Writer::new();
w.f32(rate);
w.bool(frozen);
raw(ids::TICKING_STATE, w.data)
}
+6 -1
View File
@@ -5,16 +5,18 @@
//! 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;
pub mod discovery;
pub mod entity;
pub mod tracking;
pub mod guest;
pub mod hub;
pub mod menu;
pub mod playerdata;
pub mod tracking;
pub use conn::PlayerSender;
use horizon_protocol::packets::play;
@@ -88,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,42 @@
package dev.horizonlab.parity;
import net.minecraft.client.Minecraft;
import net.minecraft.client.server.IntegratedServer;
import net.minecraft.server.MinecraftServer;
/**
* The vanilla client as the reference LAN host for the multiplayer harness
* (tools/test_lan_vanilla_client.py): it runs net.minecraft.client.main.Main
* unchanged, and once the singleplayer world is up it turns off the
* integrated server's authentication (it always authenticates guests against
* Mojang's session servers, which offline test clients cannot pass) and opens
* the world to LAN on the port in -Dhorizon.lanPort, with commands allowed,
* as the "Open to LAN" screen does.
*/
public final class OfflineLanHost {
public static void main(String[] args) throws Exception {
int port = Integer.getInteger("horizon.lanPort", 0);
Thread watcher = new Thread(() -> {
boolean published = false;
while (!published) {
try {
Thread.sleep(500);
Minecraft mc = Minecraft.getInstance();
IntegratedServer server = mc == null ? null : mc.getSingleplayerServer();
if (server == null || mc.player == null || !server.isReady()) continue;
server.setUsesAuthentication(false);
mc.execute(() -> {
boolean ok = server.publishServer(MinecraftServer.MultiplayerScope.LAN, true, port);
System.out.println("[OfflineLanHost] published=" + ok + " port=" + server.getPort());
});
published = true;
} catch (Throwable t) {
// The game is still starting.
}
}
}, "horizon-offline-lan");
watcher.setDaemon(true);
watcher.start();
net.minecraft.client.main.Main.main(args);
}
}
@@ -0,0 +1,39 @@
package dev.horizonlab.parity;
import com.mojang.brigadier.tree.ArgumentCommandNode;
import com.mojang.brigadier.tree.CommandNode;
import java.io.PrintWriter;
import java.nio.file.Files;
import java.nio.file.Path;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.commands.synchronization.SuggestionProviders;
/**
* Lists the argument nodes of vanilla's full command tree (Commands with
* CommandSelection.ALL, the tree the commands report shows) that carry a
* custom suggestion provider, with the provider's network name
* (SuggestionProviders.getName: ask_server unless it is one of the named
* client-side providers). ClientboundCommandsPacket sends that name; the
* report does not include it. One line per node: the path of node names
* from the root, space separated, a tab, the provider id.
*/
public final class VanillaCommandSuggestionsExporter {
public static void main(String[] args) throws Exception {
ParityData.init();
Commands commands = new Commands(Commands.CommandSelection.ALL, Commands.createValidationContext(ParityData.lookup));
try (PrintWriter out = new PrintWriter(Files.newBufferedWriter(Path.of(args[0])))) {
walk(commands.getDispatcher().getRoot(), "", out);
}
}
static void walk(CommandNode<CommandSourceStack> node, String path, PrintWriter out) {
for (CommandNode<CommandSourceStack> child : node.getChildren()) {
String childPath = path.isEmpty() ? child.getName() : path + " " + child.getName();
if (child instanceof ArgumentCommandNode<CommandSourceStack, ?> arg && arg.getCustomSuggestions() != null) {
out.println(childPath + "\t" + SuggestionProviders.getName(arg.getCustomSuggestions()));
}
walk(child, childPath, out);
}
}
}
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""Generate crates/horizon-client/src/lan/command_suggestions.rs: the argument
nodes of vanilla's command tree that have a custom suggestion provider, and
the provider's network name (ClientboundCommandsPacket sends it; the commands
report does not). The list comes from VanillaCommandSuggestionsExporter run
against the pinned client jar:
javac --release 25 -proc:none -sourcepath mods/horizon/src/test/java -cp CLIENT_CP \\
-d BUILD mods/horizon/src/test/java/dev/horizonlab/parity/VanillaCommandSuggestionsExporter.java
java -cp BUILD:CLIENT_CP dev.horizonlab.parity.VanillaCommandSuggestionsExporter suggestions.tsv
generate_command_suggestions.py suggestions.tsv
"""
import argparse
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("tsv", type=Path)
parser.add_argument("--out", type=Path, default=ROOT / "crates/horizon-client/src/lan/command_suggestions.rs")
args = parser.parse_args()
rows = sorted(tuple(line.split("\t")) for line in args.tsv.read_text().splitlines() if line.strip())
if len(rows) < 50:
raise SystemExit(f"only {len(rows)} nodes")
lines = [
"//! Argument nodes of vanilla's command tree with a custom suggestion",
"//! provider (path of node names from the root, provider network name).",
"//! Generated by tools/generate_command_suggestions.py from",
"//! VanillaCommandSuggestionsExporter; do not edit by hand.",
"",
"pub const SUGGESTIONS: &[(&str, &str)] = &[",
]
lines += [f' ("{path}", "{provider}"),' for path, provider in rows]
lines += ["];", ""]
args.out.write_text("\n".join(lines))
print(f"wrote {len(rows)} nodes to {args.out}")
if __name__ == "__main__":
main()
+13
View File
@@ -118,6 +118,14 @@ def command(args):
"--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
@@ -127,11 +135,16 @@ 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")