Merge pull request #692 from ludovic111/feat/locator-bar

Locator bar for vanilla clients on Horizon servers (waypoint packets as vanilla sends them)
This commit is contained in:
Ludovic Marie authored and GitHub committed 2026-10-17 14:56:09 +02:00
commit 002f14ce53
8 files changed
+1538

No files matched your search

+1
View File
@@ -68,6 +68,7 @@ mod lan_mobs;
mod lan_pvp;
mod lan_screen;
mod lan_trading;
mod lan_waypoints;
mod leads;
mod level_particles;
mod lightning;
+7
View File
@@ -57,6 +57,10 @@ pub(super) struct LanHost {
pub mob_players: HashMap<u32, u32>,
/// An integrated server's owner (game/integrated.rs).
pub owner: Option<Owner>,
/// The players' locator bars (game/lan_waypoints.rs), and the guests who
/// are dead, whose respawn replaces their waypoint.
pub waypoints: lan::waypoints::Waypoints,
pub waypoint_dead: std::collections::HashSet<u128>,
}
/// The player who owns an integrated server's world: a guest whose state is
@@ -502,6 +506,8 @@ impl Game<'_> {
mob_state: HashMap::new(),
mob_players: HashMap::new(),
owner: None,
waypoints: Default::default(),
waypoint_dead: Default::default(),
});
self.audio.capture = Some(Vec::new());
Ok(())
@@ -858,6 +864,7 @@ impl Game<'_> {
self.lan_swings(&mut host);
self.lan_update_streaming(&host);
self.lan_update_models(&mut host);
self.lan_update_waypoints(&mut host, state.as_ref());
drop(holding);
for g in &host.hub.guests {
g.sender.flush();
@@ -0,0 +1,95 @@
//! The locator bar of the players on a served world (lan/waypoints.rs): what
//! each player looks like to the waypoint manager this tick.
use super::{Game, lan::LanHost};
use horizon_client::{
items::ItemStack,
lan::{
hub::HostState,
waypoints::{self, HIDING_HEADS, Icon, PLAYER_RANGE},
},
};
/// `waypoint_transmit_range`: hidden by crouching, invisibility or a head worn.
fn transmit_range(crouching: bool, invisible: bool, head: Option<&ItemStack>) -> f64 {
let hiding_head = head.is_some_and(|s| HIDING_HEADS.contains(&&*s.item));
if crouching || invisible || hiding_head {
0.0
} else {
PLAYER_RANGE
}
}
impl Game<'_> {
/// The player's team and its colour, as the locator bar shows it.
fn waypoint_team(&self, name: &str) -> Option<(String, Option<i32>)> {
let team = self.score.board.players_team(name)?;
let color = team.color.as_deref().and_then(waypoints::team_rgb);
Some((team.name.clone(), color))
}
pub(super) fn lan_update_waypoints(&mut self, host: &mut LanHost, state: Option<&HostState>) {
let mut players = Vec::new();
if let Some(h) = state {
let icon = self
.cmds
.waypoint_icons
.get(&super::commands::Target::Player)
.map(|i| Icon {
style: i.style.clone(),
color: i.color,
})
.unwrap_or_default();
players.push(waypoints::Player {
uuid: h.uuid,
conn: None,
position: h.position,
spectator: self.spectator(),
transmit_range: transmit_range(
h.sneaking,
self.potions.effects.get("invisibility").is_some(),
h.equipment[5].as_ref(),
),
receive_range: PLAYER_RANGE,
first_tick: false,
view_distance: host.hub.config.view_distance,
team: self.waypoint_team(&h.name),
icon,
});
}
let mut respawned = Vec::new();
for g in host.hub.guests.iter().filter(|g| g.placed) {
// PlayerList.respawn makes a new ServerPlayer.
if g.dead {
host.waypoint_dead.insert(g.uuid);
} else if host.waypoint_dead.remove(&g.uuid) {
respawned.push(g.uuid);
}
// Player.updatePlayerPose: the shift key crouches unless flying.
let crouching = g.sneaking && !g.flying && !g.fall_flying && !g.sleeping;
players.push(waypoints::Player {
uuid: g.uuid,
conn: Some(g.conn),
position: g.position,
spectator: g.spectator(),
transmit_range: transmit_range(
crouching,
g.effects.get("invisibility").is_some(),
g.armor[0].as_ref(),
),
receive_range: PLAYER_RANGE,
first_tick: g.ticks == 0,
view_distance: g.view_distance,
team: self.waypoint_team(&g.name),
icon: Icon::default(),
});
}
host.waypoint_dead
.retain(|uuid| players.iter().any(|p| p.uuid == *uuid));
let locator_bar = self.ui.rules.bool("locator_bar");
for (conn, packet) in host.waypoints.tick(&players, &respawned, locator_bar) {
if let Some(g) = host.hub.guest(conn) {
g.send(&packet);
}
}
}
}
+1
View File
@@ -20,6 +20,7 @@ pub mod menu;
pub mod playerdata;
mod recipes;
pub mod tracking;
pub mod waypoints;
pub use conn::{Holding, PlayerSender};
use horizon_protocol::packets::play;
+643
View File
@@ -0,0 +1,643 @@
//! The locator bar for remote players: ServerWaypointManager and
//! WaypointTransmitter's connections, for players (the transmitters vanilla has
//! by default: `waypoint_transmit_range` is 0 for every other entity).
//!
//! Vanilla updates a player's connections each time the player's position
//! changes (`Entity.setPosRaw`, then `ServerChunkCache.move`). The hub sees the
//! position once a tick, after the moves it handled, and updates from there:
//! the same packets, since a client sends one move a tick. Hiding (crouching,
//! invisibility, a carved pumpkin or a mob head worn) changes
//! `waypoint_transmit_range` and tracks or untracks the waypoint, as
//! `LivingEntity.onAttributeUpdated` does; joining or leaving a team remakes the
//! connections with the team's colour (`ServerScoreboard.updatePlayerWaypoint`).
use horizon_protocol::packets::play::{
Clientbound as C, TrackedWaypoint, WaypointId, WaypointTarget, waypoint_operation,
};
use std::collections::HashMap;
/// `Player.createAttributes`: both waypoint ranges.
pub const PLAYER_RANGE: f64 = 6.0e7;
/// `WaypointTransmitter.REALLY_FAR_DISTANCE`.
const REALLY_FAR: f32 = 332.0;
/// `EntityAzimuthConnection.update`: half a degree.
const AZIMUTH_STEP: f32 = 0.008_726_646;
/// Items that hide their wearer (`Waypoint.addHideAttribute` on the head slot).
pub const HIDING_HEADS: [&str; 8] = [
"minecraft:carved_pumpkin",
"minecraft:skeleton_skull",
"minecraft:wither_skeleton_skull",
"minecraft:player_head",
"minecraft:zombie_head",
"minecraft:creeper_head",
"minecraft:dragon_head",
"minecraft:piglin_head",
];
/// A team colour as the locator bar draws it (`Waypoint.Icon.cloneAndAssignStyle`:
/// `TeamColor.rgb()`, black lightened so it shows).
pub fn team_rgb(color: &str) -> Option<i32> {
let rgb = match color {
"black" => return Some(-13_619_152),
"dark_blue" => 0x0000AA,
"dark_green" => 0x00AA00,
"dark_aqua" => 0x00AAAA,
"dark_red" => 0xAA0000,
"dark_purple" => 0xAA00AA,
"gold" => 0xFFAA00,
"gray" => 0xAAAAAA,
"dark_gray" => 0x555555,
"blue" => 0x5555FF,
"green" => 0x55FF55,
"aqua" => 0x55FFFF,
"red" => 0xFF5555,
"light_purple" => 0xFF55FF,
"yellow" => 0xFFFF55,
"white" => 0xFFFFFF,
_ => return None,
};
Some(rgb)
}
/// Waypoint.Icon.
#[derive(Clone, Debug, PartialEq)]
pub struct Icon {
pub style: String,
pub color: Option<i32>,
}
impl Default for Icon {
fn default() -> Self {
Self {
style: "minecraft:default".into(),
color: None,
}
}
}
/// A player as the waypoint manager sees them this tick.
#[derive(Clone, Debug)]
pub struct Player {
pub uuid: u128,
/// The connection that receives the player's locator bar (None: the host,
/// whose own game draws it).
pub conn: Option<u32>,
pub position: [f64; 3],
pub spectator: bool,
/// `waypoint_transmit_range`: 0 while hidden.
pub transmit_range: f64,
pub receive_range: f64,
/// `Entity.firstTick`: the player has not ticked since joining.
pub first_tick: bool,
/// The chunk view radius (`ChunkMap.getPlayerViewDistance`).
pub view_distance: i32,
/// The player's team and its colour (`TeamColor.rgb`).
pub team: Option<(String, Option<i32>)>,
/// The icon `/waypoint modify` set.
pub icon: Icon,
}
impl Player {
fn block(&self) -> [i32; 3] {
self.position.map(|v| v.floor() as i32)
}
fn chunk(&self) -> [i32; 2] {
let [x, _, z] = self.block();
[x >> 4, z >> 4]
}
/// `Entity.distanceTo`.
fn distance_to(&self, other: &Player) -> f32 {
let d2: f64 = (0..3)
.map(|k| (self.position[k] - other.position[k]).powi(2))
.sum();
d2.sqrt() as f32
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Kind {
Block([i32; 3]),
Chunk([i32; 2]),
Azimuth(f32),
}
#[derive(Clone, Debug)]
struct Connection {
kind: Kind,
icon: Icon,
}
/// What the manager remembers of a player between ticks.
struct Memo {
position: [f64; 3],
/// The centre of the player's ChunkTrackingView (`ChunkMap.move`).
view_center: [i32; 2],
transmitting: bool,
team: Option<(String, Option<i32>)>,
}
/// ServerWaypointManager for one level.
#[derive(Default)]
pub struct Waypoints {
/// The transmitters, in the order they were tracked.
waypoints: Vec<u128>,
/// The receivers, in the order they joined.
players: Vec<u128>,
/// Connections by receiver, then source.
connections: HashMap<u128, Vec<(u128, Connection)>>,
memo: HashMap<u128, Memo>,
/// The `locator_bar` game rule as last seen.
enabled: Option<bool>,
}
/// One tick's players, and the packets for their connections.
struct Tick<'a> {
players: HashMap<u128, &'a Player>,
view: HashMap<u128, ([i32; 2], i32)>,
/// Players a respawn replaced this tick: a new ServerPlayer, on its first tick.
fresh: &'a [u128],
enabled: bool,
out: Vec<(u32, C)>,
}
impl Tick<'_> {
fn send(
&mut self,
receiver: u128,
source: u128,
operation: i32,
icon: &Icon,
target: WaypointTarget,
) {
let Some(conn) = self.players.get(&receiver).and_then(|p| p.conn) else {
return;
};
self.out.push((
conn,
C::TrackedWaypoint(TrackedWaypoint {
operation,
id: WaypointId::Uuid(source),
style: icon.style.clone(),
color: icon.color,
target,
}),
));
}
fn connect(&mut self, receiver: u128, source: u128, c: &Connection) {
let target = match c.kind {
Kind::Block(p) => WaypointTarget::Block(p),
Kind::Chunk(p) => WaypointTarget::Chunk(p),
Kind::Azimuth(a) => WaypointTarget::Azimuth(a),
};
self.send(receiver, source, waypoint_operation::TRACK, &c.icon, target);
}
/// `ClientboundTrackedWaypointPacket.removeWaypoint`: an empty waypoint with the null icon.
fn disconnect(&mut self, receiver: u128, source: u128) {
self.send(
receiver,
source,
waypoint_operation::UNTRACK,
&Icon::default(),
WaypointTarget::Empty,
);
}
/// `ChunkTrackingView.isInViewDistance` of the receiver's view.
fn chunk_visible(&self, [x, z]: [i32; 2], receiver: u128) -> bool {
let Some(&([cx, cz], r)) = self.view.get(&receiver) else {
return false;
};
let dx = i64::from(((x - cx).abs() - 1).max(0));
let dz = i64::from(((z - cz).abs() - 1).max(0));
dx * dx + dz * dz < i64::from(r) * i64::from(r)
}
/// `WaypointTransmitter.doesSourceIgnoreReceiver`.
fn ignores(&self, source: &Player, receiver: &Player) -> bool {
if receiver.spectator {
return false;
}
if source.spectator {
return true;
}
let range = source.transmit_range.min(receiver.receive_range);
f64::from(source.distance_to(receiver)) >= range
}
fn really_far(source: &Player, receiver: &Player) -> bool {
source.distance_to(receiver) > REALLY_FAR
}
/// `EntityAzimuthConnection`: the angle of the receiver seen from the source,
/// `Mth.atan2` of the offset turned clockwise.
fn azimuth(source: &Player, receiver: &Player) -> f32 {
let d = [0, 1, 2].map(|k| receiver.position[k] - source.position[k]);
horizon_core::geometry::trig::atan2(d[0], -d[2]) as f32
}
/// `LivingEntity.makeWaypointConnectionWith`.
fn make(&self, source: u128, receiver: u128) -> Option<Connection> {
let (s, r) = (self.players.get(&source)?, self.players.get(&receiver)?);
if s.first_tick || self.fresh.contains(&source) || source == receiver || self.ignores(s, r)
{
return None;
}
// Waypoint.Icon.cloneAndAssignStyle: the icon's colour, else the team's.
let mut icon = s.icon.clone();
if icon.color.is_none() {
icon.color = s.team.as_ref().and_then(|t| t.1);
}
let kind = if Self::really_far(s, r) {
Kind::Azimuth(Self::azimuth(s, r))
} else if !self.chunk_visible(s.chunk(), receiver) {
Kind::Chunk(s.chunk())
} else {
Kind::Block(s.block())
};
Some(Connection { kind, icon })
}
/// `Connection.isBroken` for each kind.
fn broken(&self, c: &Connection, source: u128, receiver: u128) -> bool {
let (Some(s), Some(r)) = (self.players.get(&source), self.players.get(&receiver)) else {
return true;
};
match c.kind {
Kind::Block(last) => {
let now = s.block();
(0..3).map(|k| (now[k] - last[k]).abs()).sum::<i32>() > 1 || self.ignores(s, r)
}
Kind::Chunk(last) => {
let now = s.chunk();
let moved = (now[0] - last[0]).abs().max((now[1] - last[1]).abs());
moved > 1 || self.ignores(s, r) || self.chunk_visible(last, receiver)
}
Kind::Azimuth(_) => {
self.ignores(s, r)
|| self.chunk_visible(s.chunk(), receiver)
|| !Self::really_far(s, r)
}
}
}
/// `Connection.update`: the packet when the waypoint moved.
fn update(&mut self, c: &mut Connection, source: u128, receiver: u128) {
let (Some(s), Some(r)) = (self.players.get(&source), self.players.get(&receiver)) else {
return;
};
let target = match &mut c.kind {
Kind::Block(last) => {
let now = s.block();
if now == *last {
return;
}
*last = now;
WaypointTarget::Block(now)
}
Kind::Chunk(last) => {
let now = s.chunk();
if now == *last {
return;
}
*last = now;
WaypointTarget::Chunk(now)
}
Kind::Azimuth(last) => {
let now = Self::azimuth(s, r);
if (now - *last).abs() <= AZIMUTH_STEP {
return;
}
*last = now;
WaypointTarget::Azimuth(now)
}
};
let icon = c.icon.clone();
self.send(receiver, source, waypoint_operation::UPDATE, &icon, target);
}
}
impl Waypoints {
fn connection(&mut self, receiver: u128, source: u128) -> Option<&mut Connection> {
self.connections
.get_mut(&receiver)?
.iter_mut()
.find(|(s, _)| *s == source)
.map(|(_, c)| c)
}
fn put(&mut self, receiver: u128, source: u128, c: Connection) {
let row = self.connections.entry(receiver).or_default();
match row.iter_mut().find(|(s, _)| *s == source) {
Some(slot) => slot.1 = c,
None => row.push((source, c)),
}
}
fn take(&mut self, receiver: u128, source: u128) -> Option<Connection> {
let row = self.connections.get_mut(&receiver)?;
let i = row.iter().position(|(s, _)| *s == source)?;
Some(row.remove(i).1)
}
/// `createConnection`.
fn create_connection(&mut self, t: &mut Tick, receiver: u128, source: u128) {
if receiver == source || !t.enabled {
return;
}
match t.make(source, receiver) {
Some(c) => {
t.connect(receiver, source, &c);
self.put(receiver, source, c);
}
None => {
if self.take(receiver, source).is_some() {
t.disconnect(receiver, source);
}
}
}
}
/// `updateConnection`.
fn update_connection(&mut self, t: &mut Tick, receiver: u128, source: u128) {
if receiver == source || !t.enabled {
return;
}
let Some(mut c) = self.connection(receiver, source).cloned() else {
return;
};
if !t.broken(&c, source, receiver) {
t.update(&mut c, source, receiver);
self.put(receiver, source, c);
return;
}
match t.make(source, receiver) {
Some(new) => {
t.connect(receiver, source, &new);
self.put(receiver, source, new);
}
None => {
t.disconnect(receiver, source);
self.take(receiver, source);
}
}
}
/// `trackWaypoint`.
fn track(&mut self, t: &mut Tick, source: u128) {
if !self.waypoints.contains(&source) {
self.waypoints.push(source);
}
for receiver in self.players.clone() {
self.create_connection(t, receiver, source);
}
}
/// `updateWaypoint`.
fn update_waypoint(&mut self, t: &mut Tick, source: u128) {
if !self.waypoints.contains(&source) {
return;
}
for receiver in self.players.clone() {
if self.connection(receiver, source).is_some() {
self.update_connection(t, receiver, source);
} else {
self.create_connection(t, receiver, source);
}
}
}
/// `untrackWaypoint`.
fn untrack(&mut self, t: &mut Tick, source: u128) {
for receiver in self.players.clone() {
if self.take(receiver, source).is_some() {
t.disconnect(receiver, source);
}
}
self.waypoints.retain(|w| *w != source);
}
/// `addPlayer`.
fn add_player(&mut self, t: &mut Tick, player: u128, transmitting: bool) {
if !self.players.contains(&player) {
self.players.push(player);
}
for source in self.waypoints.clone() {
self.create_connection(t, player, source);
}
if transmitting {
self.track(t, player);
}
}
/// `updatePlayer`.
fn update_player(&mut self, t: &mut Tick, player: u128) {
for source in self.waypoints.clone() {
if self.connection(player, source).is_some() {
self.update_connection(t, player, source);
} else {
self.create_connection(t, player, source);
}
}
}
/// `removePlayer`.
fn remove_player(&mut self, t: &mut Tick, player: u128) {
for (source, _) in self.connections.remove(&player).unwrap_or_default() {
t.disconnect(player, source);
}
self.untrack(t, player);
self.players.retain(|p| *p != player);
}
/// `breakAllConnections`.
fn break_all(&mut self, t: &mut Tick) {
for receiver in self.players.clone() {
for (source, _) in self.connections.remove(&receiver).unwrap_or_default() {
t.disconnect(receiver, source);
}
}
self.connections.clear();
}
/// One tick: players who left, joined, moved, hid or changed team, and the
/// `locator_bar` game rule. `respawned` players are removed and added again,
/// as `PlayerList.respawn` replaces the ServerPlayer. Returns the packets for
/// each connection.
pub fn tick(
&mut self,
players: &[Player],
respawned: &[u128],
locator_bar: bool,
) -> Vec<(u32, C)> {
let mut t = Tick {
players: players.iter().map(|p| (p.uuid, p)).collect(),
view: HashMap::new(),
fresh: respawned,
enabled: locator_bar,
out: Vec::new(),
};
// Vanilla reads the rule when it connects; changing it breaks or remakes
// every connection (MinecraftServer.onGameRuleChanged).
let was = self.enabled.replace(locator_bar);
for (uuid, memo) in &self.memo {
if let Some(p) = t.players.get(uuid) {
t.view.insert(*uuid, (memo.view_center, p.view_distance));
}
}
// Players who left, and the ServerPlayer a respawn replaced.
let gone: Vec<u128> = self
.players
.iter()
.copied()
.filter(|uuid| !t.players.contains_key(uuid) || respawned.contains(uuid))
.collect();
for uuid in gone {
self.remove_player(&mut t, uuid);
self.memo.remove(&uuid);
}
if was == Some(true) && !locator_bar {
self.break_all(&mut t);
}
// Players who joined (PlayerList.placeNewPlayer: addPlayer).
for p in players {
if self.memo.contains_key(&p.uuid) {
continue;
}
let center = p.chunk();
t.view.insert(p.uuid, (center, p.view_distance));
let transmitting = p.transmit_range > 0.0;
self.memo.insert(
p.uuid,
Memo {
position: p.position,
view_center: center,
transmitting,
team: p.team.clone(),
},
);
if p.receive_range > 0.0 {
self.add_player(&mut t, p.uuid, transmitting);
} else if transmitting {
self.track(&mut t, p.uuid);
}
}
if was == Some(false) && locator_bar {
for player in self.players.clone() {
self.update_player(&mut t, player);
}
}
for p in players {
let memo = &self.memo[&p.uuid];
// Entity.setPosRaw, then ServerChunkCache.move.
if memo.position != p.position {
let moved_view = memo.view_center != p.chunk();
let transmitting = memo.transmitting;
self.memo.get_mut(&p.uuid).unwrap().position = p.position;
if !p.first_tick {
if transmitting {
self.update_waypoint(&mut t, p.uuid);
}
if p.receive_range > 0.0 {
self.update_player(&mut t, p.uuid);
}
}
if moved_view {
self.memo.get_mut(&p.uuid).unwrap().view_center = p.chunk();
t.view.insert(p.uuid, (p.chunk(), p.view_distance));
}
if p.receive_range > 0.0 {
self.update_player(&mut t, p.uuid);
}
}
// LivingEntity.onAttributeUpdated(WAYPOINT_TRANSMIT_RANGE).
let transmitting = p.transmit_range > 0.0;
if transmitting != self.memo[&p.uuid].transmitting {
self.memo.get_mut(&p.uuid).unwrap().transmitting = transmitting;
if transmitting {
self.track(&mut t, p.uuid);
} else {
self.untrack(&mut t, p.uuid);
}
}
// ServerScoreboard: a team joined, left or recoloured remakes the connections.
if p.team != self.memo[&p.uuid].team {
self.memo.get_mut(&p.uuid).unwrap().team = p.team.clone();
for receiver in self.players.clone() {
self.create_connection(&mut t, receiver, p.uuid);
}
}
}
t.out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn player(uuid: u128, conn: u32, position: [f64; 3]) -> Player {
Player {
uuid,
conn: Some(conn),
position,
spectator: false,
transmit_range: PLAYER_RANGE,
receive_range: PLAYER_RANGE,
first_tick: false,
view_distance: 4,
team: None,
icon: Icon::default(),
}
}
/// The packets to `conn`, as (operation, target).
fn to(out: &[(u32, C)], conn: u32) -> Vec<(i32, WaypointTarget)> {
out.iter()
.filter(|(c, _)| *c == conn)
.map(|(_, p)| match p {
C::TrackedWaypoint(w) => (w.operation, w.target),
other => panic!("{other:?}"),
})
.collect()
}
#[test]
fn a_new_player_shows_once_it_moves() {
let mut w = Waypoints::default();
let a = player(1, 10, [0.5, 100.0, 0.5]);
let mut b = player(2, 20, [5.5, 100.0, 0.5]);
b.first_tick = true;
w.tick(std::slice::from_ref(&a), &[], true);
let out = w.tick(&[a.clone(), b.clone()], &[], true);
// B gets A at once; A sees nothing of B while B hasn't ticked.
assert_eq!(to(&out, 20), [(0, WaypointTarget::Block([0, 100, 0]))]);
assert!(to(&out, 10).is_empty());
b.first_tick = false;
assert!(w.tick(&[a.clone(), b.clone()], &[], true).is_empty());
b.position[0] += 1.0;
let out = w.tick(&[a.clone(), b.clone()], &[], true);
assert_eq!(to(&out, 10), [(0, WaypointTarget::Block([6, 100, 0]))]);
// One block: an update. Three: a new connection. Out of view: a chunk.
b.position[0] += 1.0;
let out = w.tick(&[a.clone(), b.clone()], &[], true);
assert_eq!(to(&out, 10), [(2, WaypointTarget::Block([7, 100, 0]))]);
b.position[0] += 3.0;
let out = w.tick(&[a.clone(), b.clone()], &[], true);
assert_eq!(to(&out, 10), [(0, WaypointTarget::Block([10, 100, 0]))]);
b.position[0] = 100.5;
let out = w.tick(&[a.clone(), b.clone()], &[], true);
assert_eq!(to(&out, 10), [(0, WaypointTarget::Chunk([6, 0]))]);
// Really far: an angle.
b.position[0] = 1000.5;
let out = w.tick(&[a.clone(), b.clone()], &[], true);
assert!(matches!(
to(&out, 10)[..],
[(0, WaypointTarget::Azimuth(_))]
));
// Hidden: untracked; the rule off: every connection broken.
b.transmit_range = 0.0;
let out = w.tick(&[a.clone(), b.clone()], &[], true);
assert_eq!(to(&out, 10), [(1, WaypointTarget::Empty)]);
let out = w.tick(&[a.clone(), b.clone()], &[], false);
assert_eq!(to(&out, 20), [(1, WaypointTarget::Empty)]);
assert!(to(&out, 10).is_empty());
// Leaving untracks what was left.
let out = w.tick(&[a.clone(), b.clone()], &[], true);
assert!(matches!(
to(&out, 20)[..],
[(0, WaypointTarget::Azimuth(_))]
));
let out = w.tick(&[b.clone()], &[], true);
assert_eq!(to(&out, 20), [(1, WaypointTarget::Empty)]);
}
#[test]
fn team_colours() {
assert_eq!(team_rgb("red"), Some(0xFF5555));
assert_eq!(team_rgb("black"), Some(0xFF30_3030_u32 as i32));
assert_eq!(team_rgb("reset"), None);
}
}
+150
View File
@@ -1269,12 +1269,126 @@ packet_enum!(Clientbound, crate::ids::play::clientbound {
UpdateAdvancements(UpdateAdvancements) = UPDATE_ADVANCEMENTS,
UpdateMobEffect(UpdateMobEffect) = UPDATE_MOB_EFFECT,
UpdateTags(UpdateTags) = UPDATE_TAGS,
TrackedWaypoint(TrackedWaypoint) = WAYPOINT,
});
packet!(
/// ClientboundClearTitlesPacket: whether to also reset the timings.
Bool1 { value: bool => Bool }
);
/// ClientboundTrackedWaypointPacket.Operation, by ordinal.
pub mod waypoint_operation {
pub const TRACK: i32 = 0;
pub const UNTRACK: i32 = 1;
pub const UPDATE: i32 = 2;
}
/// TrackedWaypoint's identifier: `Either<UUID, String>`.
#[derive(Clone, Debug, PartialEq)]
pub enum WaypointId {
Uuid(u128),
Name(String),
}
/// What a TrackedWaypoint points at (its `Type` and contents).
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum WaypointTarget {
/// `TrackedWaypoint.empty`: an untracked waypoint.
Empty,
/// A block position (`Vec3iWaypoint`).
Block([i32; 3]),
/// A chunk (`ChunkWaypoint`).
Chunk([i32; 2]),
/// An angle in radians (`AzimuthWaypoint`).
Azimuth(f32),
}
/// ClientboundTrackedWaypointPacket: the locator bar's waypoints.
#[derive(Clone, Debug, PartialEq)]
pub struct TrackedWaypoint {
/// `waypoint_operation`.
pub operation: i32,
pub id: WaypointId,
/// Waypoint.Icon: the style (a `waypoint_style_asset` key) and an RGB colour.
pub style: String,
pub color: Option<i32>,
pub target: WaypointTarget,
}
impl Codec<TrackedWaypoint> for TrackedWaypoint {
fn read(r: &mut Reader) -> Result<TrackedWaypoint> {
let operation = r.varint()?;
if !(0..=2).contains(&operation) {
return Err(Error::new(format!("waypoint operation {operation}")));
}
let id = if r.bool()? {
WaypointId::Uuid(r.uuid()?)
} else {
WaypointId::Name(r.string(32767)?)
};
let style = Ident::read(r)?;
// ByteBufCodecs.RGB_COLOR: three bytes, read back as an opaque ARGB colour.
let color = if r.bool()? {
let [red, green, blue] = [r.u8()?, r.u8()?, r.u8()?].map(i32::from);
Some(-16_777_216 | red << 16 | green << 8 | blue)
} else {
None
};
let target = match r.varint()? {
0 => WaypointTarget::Empty,
1 => WaypointTarget::Block([r.varint()?, r.varint()?, r.varint()?]),
2 => WaypointTarget::Chunk([r.varint()?, r.varint()?]),
3 => WaypointTarget::Azimuth(r.f32()?),
other => return Err(Error::new(format!("waypoint type {other}"))),
};
Ok(TrackedWaypoint {
operation,
id,
style,
color,
target,
})
}
fn write(v: &TrackedWaypoint, w: &mut Writer) {
w.varint(v.operation);
match &v.id {
WaypointId::Uuid(uuid) => {
w.bool(true);
w.uuid(*uuid);
}
WaypointId::Name(name) => {
w.bool(false);
w.string(name);
}
}
w.string(&v.style);
w.bool(v.color.is_some());
if let Some(color) = v.color {
for shift in [16, 8, 0] {
w.u8((color >> shift) as u8);
}
}
match v.target {
WaypointTarget::Empty => w.varint(0),
WaypointTarget::Block([x, y, z]) => {
w.varint(1);
w.varint(x);
w.varint(y);
w.varint(z);
}
WaypointTarget::Chunk([x, z]) => {
w.varint(2);
w.varint(x);
w.varint(z);
}
WaypointTarget::Azimuth(angle) => {
w.varint(3);
w.f32(angle);
}
}
}
}
// ---- serverbound -------------------------------------------------------
packet!(AcceptTeleportation { id: i32 => VarInt, x: f64 => Double, y: f64 => Double, z: f64 => Double, yaw: f32 => Float, pitch: f32 => Float });
@@ -1489,6 +1603,42 @@ mod tests {
}
}
#[test]
fn tracked_waypoint_bytes() {
// TRACK, a UUID, the default style, red (#FF5555), a block position.
let track = Clientbound::TrackedWaypoint(TrackedWaypoint {
operation: waypoint_operation::TRACK,
id: WaypointId::Uuid(0x0102_0304_0506_0708_090a_0b0c_0d0e_0f10),
style: "minecraft:default".into(),
color: Some(0xFFFF_5555_u32 as i32),
target: WaypointTarget::Block([1, -2, 300]),
});
let mut expected = vec![141, 1, 0, 1];
expected.extend(1..=16u8);
expected.push(17);
expected.extend(b"minecraft:default");
expected.extend([
1, 0xFF, 0x55, 0x55, 1, 1, 0xFE, 0xFF, 0xFF, 0xFF, 0x0F, 0xAC, 0x02,
]);
assert_eq!(track.encode(), expected);
assert_eq!(Clientbound::decode(141, &expected[2..]).unwrap(), track);
for target in [
WaypointTarget::Empty,
WaypointTarget::Chunk([-3, 4]),
WaypointTarget::Azimuth(-1.25),
] {
let packet = Clientbound::TrackedWaypoint(TrackedWaypoint {
operation: waypoint_operation::UPDATE,
id: WaypointId::Name("spawn".into()),
style: "minecraft:bowtie".into(),
color: None,
target,
});
let bytes = packet.encode();
assert_eq!(Clientbound::decode(141, &bytes[2..]).unwrap(), packet);
}
}
#[test]
fn clientbound_round_trips() {
let packets = vec![
@@ -0,0 +1,382 @@
package dev.horizonlab.parity;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.DataInputStream;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.zip.Deflater;
import java.util.zip.Inflater;
import net.minecraft.SharedConstants;
import net.minecraft.core.UUIDUtil;
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.common.ClientboundDisconnectPacket;
import net.minecraft.network.protocol.common.ClientboundKeepAlivePacket;
import net.minecraft.network.protocol.common.ClientboundPingPacket;
import net.minecraft.network.protocol.common.ServerboundClientInformationPacket;
import net.minecraft.network.protocol.common.ServerboundCustomPayloadPacket;
import net.minecraft.network.protocol.common.ServerboundKeepAlivePacket;
import net.minecraft.network.protocol.common.ServerboundPongPacket;
import net.minecraft.network.protocol.common.custom.BrandPayload;
import net.minecraft.network.protocol.configuration.ClientboundFinishConfigurationPacket;
import net.minecraft.network.protocol.configuration.ClientboundSelectKnownPacks;
import net.minecraft.network.protocol.configuration.ConfigurationProtocols;
import net.minecraft.network.protocol.configuration.ServerboundFinishConfigurationPacket;
import net.minecraft.network.protocol.configuration.ServerboundSelectKnownPacks;
import net.minecraft.network.protocol.game.ClientboundChunkBatchFinishedPacket;
import net.minecraft.network.protocol.game.ClientboundPlayerPositionPacket;
import net.minecraft.network.protocol.game.ClientboundSystemChatPacket;
import net.minecraft.network.protocol.game.ClientboundTrackedWaypointPacket;
import net.minecraft.network.protocol.game.GameProtocols;
import net.minecraft.network.protocol.game.ServerboundAcceptTeleportationPacket;
import net.minecraft.network.protocol.game.ServerboundChatCommandPacket;
import net.minecraft.network.protocol.game.ServerboundChunkBatchReceivedPacket;
import net.minecraft.network.protocol.game.ServerboundMovePlayerPacket;
import net.minecraft.network.protocol.game.ServerboundPlayerInputPacket;
import net.minecraft.network.protocol.game.ServerboundPlayerLoadedPacket;
import net.minecraft.network.protocol.handshake.ClientIntent;
import net.minecraft.network.protocol.handshake.ClientIntentionPacket;
import net.minecraft.network.protocol.handshake.HandshakeProtocols;
import net.minecraft.network.protocol.login.ClientboundLoginCompressionPacket;
import net.minecraft.network.protocol.login.ClientboundLoginDisconnectPacket;
import net.minecraft.network.protocol.login.ClientboundLoginFinishedPacket;
import net.minecraft.network.protocol.login.LoginProtocols;
import net.minecraft.network.protocol.login.ServerboundHelloPacket;
import net.minecraft.network.protocol.login.ServerboundLoginAcknowledgedPacket;
import net.minecraft.server.level.ClientInformation;
import net.minecraft.world.entity.PositionMoveRotation;
import net.minecraft.world.entity.player.Input;
import net.minecraft.world.phys.Vec3;
/**
* Players made of vanilla's own protocol code, for the locator bar
* (tools/test_locator_bar_parity.py). Each joins in offline mode, answers
* keep-alives, pings, teleports and chunk batches, and records every
* ClientboundTrackedWaypointPacket it receives, decoded by vanilla's codec
* (a byte vanilla would not read is an error) and summarised from its bytes.
*
* <p>{@code LocatorBarClient HOST PORT OUT} takes its script on stdin, one
* line at a time: {@code step LABEL} (the label the next packets are recorded
* under), {@code join NAME}, {@code cmd NAME COMMAND...}, {@code shift NAME
* on|off} (the shift key, as ServerboundPlayerInputPacket), {@code fly NAME
* on|off} (ServerboundPlayerAbilitiesPacket), {@code quit NAME} and {@code
* end}, which writes each player's lines to OUT.
*/
public final class LocatorBarClient {
private static volatile String step = "start";
private static final Map<UUID, String> NAMES = new LinkedHashMap<>();
public static void main(String[] args) throws Exception {
ParityData.init();
String host = args[0];
int port = Integer.parseInt(args[1]);
Path output = Path.of(args[2]);
Map<String, Player> players = new LinkedHashMap<>();
BufferedReader script = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8));
String line;
while ((line = script.readLine()) != null) {
String[] w = line.trim().split(" ", 3);
try {
switch (w[0]) {
case "step" -> step = w[1];
case "join" -> {
Player p = new Player(w[1]);
players.put(w[1], p);
NAMES.put(UUIDUtil.createOfflinePlayerUUID(w[1]), w[1]);
p.join(host, port);
}
case "cmd" -> players.get(w[1]).command(w[2]);
case "shift" -> players.get(w[1]).send(new ServerboundPlayerInputPacket(
new Input(false, false, false, false, false, w[2].equals("on"), false)));
case "fly" -> {
net.minecraft.world.entity.player.Abilities abilities = new net.minecraft.world.entity.player.Abilities();
abilities.flying = w[2].equals("on");
players.get(w[1]).send(new net.minecraft.network.protocol.game.ServerboundPlayerAbilitiesPacket(abilities));
}
case "quit" -> players.get(w[1]).close();
case "end" -> {
try (PrintWriter out = new PrintWriter(Files.newBufferedWriter(output, StandardCharsets.UTF_8))) {
for (Player p : players.values()) {
synchronized (p.lines) {
for (String l : p.lines) out.println(p.name + " " + l);
}
}
}
for (Player p : players.values()) p.close();
System.exit(0);
}
default -> throw new IllegalArgumentException("unknown script line " + line);
}
} catch (Exception e) {
Player p = w.length > 1 ? players.get(w[1]) : null;
if (p != null) p.record("error " + w[0] + " " + e);
else System.err.println("error " + line + ": " + e);
}
}
System.exit(1);
}
/** The waypoint packet's fields, read back from its bytes (the operation and type are private enums). */
static String waypoint(byte[] payload) {
FriendlyByteBuf b = new FriendlyByteBuf(Unpooled.wrappedBuffer(payload));
b.readVarInt(); // the packet id
String operation = switch (b.readVarInt()) {
case 0 -> "track";
case 1 -> "untrack";
case 2 -> "update";
default -> "operation?";
};
String id = b.readBoolean() ? NAMES.getOrDefault(b.readUUID(), "uuid?") : "name:" + b.readUtf();
String style = b.readUtf();
String color = b.readBoolean() ? String.format("#%02x%02x%02x", b.readUnsignedByte(), b.readUnsignedByte(), b.readUnsignedByte()) : "-";
String target = switch (b.readVarInt()) {
case 0 -> "empty";
case 1 -> "block " + b.readVarInt() + " " + b.readVarInt() + " " + b.readVarInt();
case 2 -> "chunk " + b.readVarInt() + " " + b.readVarInt();
case 3 -> "azimuth " + b.readFloat();
default -> "type?";
};
return operation + " " + id + " " + style + " " + color + " " + target;
}
/** One connection: login and configuration in the caller's thread, then play on its own. */
private static final class Player {
final String name;
final List<String> lines = new ArrayList<>();
private Socket socket;
private DataInputStream in;
private OutputStream out;
private int threshold = -1;
private ProtocolInfo<?> inbound;
private ProtocolInfo<?> outbound;
private PositionMoveRotation position = new PositionMoveRotation(Vec3.ZERO, Vec3.ZERO, 0, 0);
private boolean loaded;
private final Object replyLock = new Object();
private int replies;
Player(String name) {
this.name = name;
}
/** Runs a command and waits for its feedback, so a slow server still takes each step in its step. */
void command(String command) throws Exception {
synchronized (replyLock) {
int before = replies;
send(new ServerboundChatCommandPacket(command));
long until = System.nanoTime() + 30_000_000_000L;
while (replies == before) {
long left = (until - System.nanoTime()) / 1_000_000L;
if (left <= 0) {
record("error no reply to /" + command);
return;
}
replyLock.wait(left);
}
}
}
void record(String line) {
synchronized (lines) {
lines.add("step=" + step + " " + line);
}
}
void close() throws Exception {
if (socket != null) socket.close();
}
void join(String host, int port) throws Exception {
socket = new Socket();
socket.connect(new InetSocketAddress(host, port), 10_000);
socket.setTcpNoDelay(true);
in = new DataInputStream(socket.getInputStream());
out = socket.getOutputStream();
outbound = HandshakeProtocols.SERVERBOUND;
send(new ClientIntentionPacket(SharedConstants.getProtocolVersion(), host, port, ClientIntent.LOGIN));
outbound = LoginProtocols.SERVERBOUND;
inbound = LoginProtocols.CLIENTBOUND;
send(new ServerboundHelloPacket(name, UUIDUtil.createOfflinePlayerUUID(name)));
while (true) {
Packet<?> p = read().packet;
if (p instanceof ClientboundLoginCompressionPacket c) {
threshold = c.getCompressionThreshold();
} else if (p instanceof ClientboundLoginFinishedPacket) {
send(ServerboundLoginAcknowledgedPacket.INSTANCE);
break;
} else if (p instanceof ClientboundLoginDisconnectPacket d) {
throw new IllegalStateException("login refused: " + d.reason().getString());
}
}
outbound = ConfigurationProtocols.SERVERBOUND;
inbound = ConfigurationProtocols.CLIENTBOUND;
send(new ServerboundCustomPayloadPacket(new BrandPayload("vanilla")));
send(new ServerboundClientInformationPacket(ClientInformation.createDefault()));
while (true) {
Packet<?> p = read().packet;
if (p instanceof ClientboundSelectKnownPacks k) {
send(new ServerboundSelectKnownPacks(k.knownPacks()));
} else if (p instanceof ClientboundKeepAlivePacket k) {
send(new ServerboundKeepAlivePacket(k.getId()));
} else if (p instanceof ClientboundPingPacket k) {
send(new ServerboundPongPacket(k.getId()));
} else if (p instanceof ClientboundFinishConfigurationPacket) {
send(ServerboundFinishConfigurationPacket.INSTANCE);
break;
} else if (p instanceof ClientboundDisconnectPacket d) {
throw new IllegalStateException("disconnected: " + d.reason().getString());
}
}
GameProtocols.Context context = new GameProtocols.Context() {
@Override
public boolean hasInfiniteMaterials() {
return true;
}
@Override
public boolean canUseCommandBlocks() {
return true;
}
};
outbound = GameProtocols.SERVERBOUND_TEMPLATE.bind(RegistryFriendlyByteBuf.decorator(ParityData.access), context);
inbound = GameProtocols.CLIENTBOUND_TEMPLATE.bind(RegistryFriendlyByteBuf.decorator(ParityData.access));
Thread reader = new Thread(this::play, name);
reader.setDaemon(true);
reader.start();
}
private void play() {
try {
while (true) {
Received r = read();
Packet<?> p = r.packet;
if (p instanceof ClientboundTrackedWaypointPacket) {
record(waypoint(r.payload));
} else if (p instanceof ClientboundSystemChatPacket) {
synchronized (replyLock) {
replies++;
replyLock.notifyAll();
}
} else if (p instanceof ClientboundKeepAlivePacket k) {
send(new ServerboundKeepAlivePacket(k.getId()));
} else if (p instanceof ClientboundPingPacket k) {
send(new ServerboundPongPacket(k.getId()));
} else if (p instanceof ClientboundPlayerPositionPacket t) {
position = PositionMoveRotation.calculateAbsolute(position, t.change(), t.relatives());
Vec3 at = position.position();
send(new ServerboundAcceptTeleportationPacket(t.id(), at.x, at.y, at.z, position.yRot(), position.xRot()));
// Only the spawn teleport is answered with a position too (VanillaProtocolClient).
if (!loaded) {
send(new ServerboundMovePlayerPacket.PosRot(at, position.yRot(), position.xRot(), false, false));
send(new ServerboundPlayerLoadedPacket());
loaded = true;
}
} else if (p instanceof ClientboundChunkBatchFinishedPacket) {
send(new ServerboundChunkBatchReceivedPacket(64.0F));
} else if (p instanceof ClientboundDisconnectPacket d) {
record("disconnected " + d.reason().getString());
return;
}
}
} catch (Exception e) {
if (!socket.isClosed()) record("error play " + e);
}
}
private record Received(Packet<?> packet, byte[] payload) {}
private Received read() throws Exception {
byte[] payload = readFrame();
ByteBuf buf = Unpooled.wrappedBuffer(payload);
Packet<?> packet = inbound.codec().decode(buf);
if (buf.readableBytes() > 0) {
throw new IllegalStateException(packet.type().id() + " was larger than expected: " + buf.readableBytes() + " bytes left");
}
return new Received(packet, payload);
}
synchronized void send(Packet<?> packet) throws Exception {
ByteBuf body = Unpooled.buffer();
((ProtocolInfo) outbound).codec().encode(body, packet);
byte[] raw = new byte[body.readableBytes()];
body.readBytes(raw);
ByteBuf frame = Unpooled.buffer();
if (threshold >= 0) {
ByteBuf inner = Unpooled.buffer();
if (raw.length >= threshold) {
writeVarInt(inner, raw.length);
Deflater d = new Deflater();
d.setInput(raw);
d.finish();
byte[] tmp = new byte[raw.length + 64];
while (!d.finished()) inner.writeBytes(tmp, 0, d.deflate(tmp));
d.end();
} else {
writeVarInt(inner, 0);
inner.writeBytes(raw);
}
writeVarInt(frame, inner.readableBytes());
frame.writeBytes(inner);
} else {
writeVarInt(frame, raw.length);
frame.writeBytes(raw);
}
byte[] bytes = new byte[frame.readableBytes()];
frame.readBytes(bytes);
out.write(bytes);
out.flush();
}
private byte[] readFrame() throws Exception {
byte[] frame = new byte[readVarInt(in)];
in.readFully(frame);
if (threshold < 0) return frame;
ByteArrayInputStream header = new ByteArrayInputStream(frame);
int dataLength = readVarInt(header);
int skip = frame.length - header.available();
if (dataLength == 0) return java.util.Arrays.copyOfRange(frame, skip, frame.length);
Inflater inflater = new Inflater();
inflater.setInput(frame, skip, frame.length - skip);
byte[] raw = new byte[dataLength];
int n = inflater.inflate(raw);
inflater.end();
if (n != dataLength) throw new IllegalStateException("decompressed " + n + " of " + dataLength + " bytes");
return raw;
}
}
private static int readVarInt(InputStream in) throws Exception {
int value = 0;
for (int shift = 0; shift < 35; shift += 7) {
int b = in.read();
if (b < 0) throw new java.io.EOFException();
value |= (b & 0x7F) << shift;
if ((b & 0x80) == 0) return value;
}
throw new IllegalStateException("VarInt too big");
}
private static void writeVarInt(ByteBuf buf, int value) {
while ((value & ~0x7F) != 0) {
buf.writeByte((value & 0x7F) | 0x80);
value >>>= 7;
}
buf.writeByte(value);
}
}
+259
View File
@@ -0,0 +1,259 @@
#!/usr/bin/env python3
"""The locator bar on a dedicated server, against vanilla's, seen by vanilla clients.
A vanilla dedicated server and Horizon's (`horizon-client --dedicated --nogui`) each serve a copy of one flat
world vanilla generated first (players appear at its spawn), with the same server.properties (offline,
creative, view distance 4) and two operators. LocatorBarClient, two
players made of vanilla's protocol code (any byte vanilla would not read is an error), joins Alice and then
Bob, who fly as creative players do (a player floating without flying falls on the server every tick), and
records every ClientboundTrackedWaypointPacket each of them receives. Bob then:
- teleports one block, three blocks, out of Alice's view, one chunk on, really far (an azimuth) and turns
around her, and back;
- turns invisible and visible, joins a red team (by the console), has it turned blue, leaves it;
- crouches and stands up, wears a carved pumpkin and takes it off;
- turns spectator and moves, then creative again;
the console turns the `locator_bar` game rule off and on, and Bob leaves. The waypoint packets each player
received in each step are compared exactly: operation, player, style, colour and the block, chunk or angle.
"""
import os
from pathlib import Path
import platform
import shutil
import subprocess
import sys
import time
try:
from . import lab, test_rust_parity as base
from .test_dedicated_server_parity import Server, free_port
except ImportError:
import lab
import test_rust_parity as base
from test_dedicated_server_parity import Server, free_port
NAME = 'locator-bar-parity'
PROPERTIES = {
'online-mode': 'false',
'white-list': 'false',
'level-seed': '12345',
'level-type': 'minecraft\\:flat',
'generate-structures': 'false',
'gamemode': 'creative',
'force-gamemode': 'true',
'view-distance': '4',
'simulation-distance': '4',
'spawn-protection': '0',
'max-players': '5',
'pause-when-empty-seconds': '-1',
}
PLAYERS = ['Alice', 'Bob']
# (label, [what the console does], [what the players do]); every step then waits for its packets.
SCRIPT = [
('alice-joins', [], ['join Alice', 'fly Alice on', 'cmd Alice tp @s 0.5 100 0.5']),
('bob-joins', [], ['join Bob', 'fly Bob on', 'cmd Bob tp @s 2.5 100 0.5']),
('near', [], ['cmd Bob tp @s 5.5 100 0.5']),
('one-block', [], ['cmd Bob tp @s 6.5 100 0.5']),
('three-blocks', [], ['cmd Bob tp @s 9.5 100 0.5']),
('out-of-view', [], ['cmd Bob tp @s 100.5 100 0.5']),
('next-chunk', [], ['cmd Bob tp @s 116.5 100 0.5']),
('really-far', [], ['cmd Bob tp @s 1000.5 100 0.5']),
('turn-around', [], ['cmd Bob tp @s 1000.5 100 300.5']),
('half-degree', [], ['cmd Bob tp @s 1000.5 100 302.5']),
('invisible', [], ['cmd Bob effect give @s minecraft:invisibility infinite 0 true']),
('visible', [], ['cmd Bob effect clear @s']),
('back-near', [], ['cmd Bob tp @s 3.5 100 0.5']),
('red-team', ['team add red', 'team modify red color red', 'team join red Bob'], []),
('blue-team', ['team modify red color blue'], []),
('no-team', ['team leave Bob'], []),
# Crouching needs the ground: a flying player who holds shift goes down instead.
('landed', [], ['cmd Bob tp @s 3.5 -60 0.5', 'fly Bob off']),
('crouch', [], ['shift Bob on']),
('stand', [], ['shift Bob off']),
('flying-again', [], ['fly Bob on', 'cmd Bob tp @s 3.5 100 0.5']),
('pumpkin', [], ['cmd Bob item replace entity @s armor.head with minecraft:carved_pumpkin']),
('no-pumpkin', [], ['cmd Bob item replace entity @s armor.head with minecraft:air']),
('rule-off', ['gamerule locator_bar false'], []),
('moves-without', [], ['cmd Bob tp @s 4.5 100 0.5']),
('rule-on', ['gamerule locator_bar true'], []),
('spectator', [], ['cmd Bob gamemode spectator', 'cmd Bob tp @s 5.5 100 0.5']),
('creative', [], ['cmd Bob gamemode creative', 'cmd Bob tp @s 6.5 100 0.5']),
('bob-leaves', [], ['quit Bob']),
]
STEP_SECONDS = 2.0
# Vanilla's server moves a player who just joined on their own first ticks (a tiny step and back), which tracks
# them where they spawned; Horizon does not simulate its guests. These steps compare what the bar shows at
# their end (Bob has moved by then) rather than every packet.
SETTLING_STEPS = {'alice-joins', 'bob-joins'}
def shown(lines):
"""The transcript with each settling step reduced to what each player's bar shows at its end."""
out, state = [], {}
def flush(step):
for (receiver, source), rest in sorted(state.items()):
out.append(f'{receiver} step={step} shows {source} {rest}')
state.clear()
current = None
for line in lines:
receiver, step, *packet = line.split(' ')
step = step.removeprefix('step=')
if current in SETTLING_STEPS and step != current:
flush(current)
current = step
if step not in SETTLING_STEPS or len(packet) < 3:
out.append(line)
elif packet[0] == 'untrack':
state.pop((receiver, packet[1]), None)
else:
state[(receiver, packet[1])] = ' '.join(packet[2:])
if current in SETTLING_STEPS:
flush(current)
return out
def ops_json():
"""Both players at level 4, by their offline UUIDs."""
import hashlib
import json
import uuid as uuidlib
entries = []
for name in PLAYERS:
digest = bytearray(hashlib.md5(f'OfflinePlayer:{name}'.encode()).digest())
digest[6] = (digest[6] & 0x0F) | 0x30
digest[8] = (digest[8] & 0x3F) | 0x80
entries.append({'uuid': str(uuidlib.UUID(bytes=bytes(digest))), 'name': name, 'level': 4,
'bypassesPlayerLimit': False})
return json.dumps(entries, indent=2)
def server_dir(directory, world=None):
if directory.exists():
shutil.rmtree(directory)
directory.mkdir(parents=True)
port = free_port()
props = dict(PROPERTIES, **{'server-port': str(port)})
(directory / 'server.properties').write_text(''.join(f'{k}={v}\n' for k, v in props.items()),
encoding='iso-8859-1')
(directory / 'eula.txt').write_text('eula=true\n')
(directory / 'ops.json').write_text(ops_json())
if world is not None:
shutil.copytree(world, directory / 'world')
(directory / 'world' / 'session.lock').unlink(missing_ok=True)
return port
def generate_world(server_command, build):
"""The flat world both servers load, made once by vanilla: players appear at its spawn itself
(respawn_radius 0), on the ground at 0 -60 0, and the chunks around it are saved."""
directory = build / 'generate'
server_dir(directory)
server = Server(server_command, directory, 'generate')
try:
server.wait_for(r'Done \(')
for command, reply in [('gamerule respawn_radius 0', 'respawn_radius'),
('setworldspawn 0 -60 0', 'Set the world spawn'),
('forceload add -96 -96 96 96', 'force loaded'),
('save-all flush', 'Saved the game')]:
server.console(command)
server.wait_for(reply, timeout=120)
server.console('forceload remove all')
finally:
server.stop()
return directory / 'world'
def run_side(label, server_command, client_command, build, world):
directory = build / label
port = server_dir(directory, world)
out = build / f'{label}.txt'
out.unlink(missing_ok=True)
server = Server(server_command, directory, label)
try:
server.wait_for(r'Done \(')
with (build / f'{label}-client.log').open('w') as log:
client = subprocess.Popen([*map(str, client_command), '127.0.0.1', str(port), str(out)],
cwd=lab.ROOT, stdin=subprocess.PIPE, stdout=log, stderr=subprocess.STDOUT,
text=True)
try:
for step, console, actions in SCRIPT:
client.stdin.write(f'step {step}\n')
client.stdin.flush()
for command in console:
server.console(command)
for action in actions:
client.stdin.write(action + '\n')
client.stdin.flush()
if action.startswith('join'):
# Joining takes the world's first chunks: wait for the server to place the player.
server.wait_for(rf'{action.split()[1]} joined the game', timeout=120)
time.sleep(STEP_SECONDS)
client.stdin.write('end\n')
client.stdin.flush()
client.wait(timeout=60)
finally:
if client.poll() is None:
client.kill()
finally:
server.stop()
if not out.exists():
raise AssertionError(f'{label}: the client wrote no transcript (see {build}/{label}-client.log)')
return out.read_text(encoding='utf-8').splitlines()
def main():
lock, jars, libraries = base.verify_artifacts()
build = lab.LOCAL / NAME
build.mkdir(parents=True, exist_ok=True)
report = build / 'report.json'
report.unlink(missing_ok=True)
oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java'
classes = build / 'classes'
shutil.rmtree(classes, ignore_errors=True)
classes.mkdir()
dependencies = os.pathsep.join(map(str, [jars['server'], *libraries]))
subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none', '-nowarn', '-sourcepath',
lab.ROOT / 'mods/horizon/src/test/java', '-cp', dependencies, '-d', classes, oracle],
cwd=lab.ROOT, check=True, capture_output=True)
subprocess.run(['cargo', 'build', '--release', '-p', 'horizon-client', '--bin', 'horizon-client'], cwd=lab.ROOT,
check=True, capture_output=True)
import json
metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--no-deps', '--format-version', '1'],
cwd=lab.ROOT, text=True))
binary = Path(metadata['target_directory']) / 'release' / ('horizon-client.exe' if os.name == 'nt' else 'horizon-client')
vanilla_server = ['java', '-Xmx2g', '-Duser.timezone=UTC', '-cp', dependencies, 'net.minecraft.server.Main', 'nogui']
horizon_server = [binary, '--resources', lab.LOCAL / 'horizon-client/26.3', '--dedicated', '--nogui']
client = ['java', '-Xmx1g', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.LocatorBarClient']
world = generate_world(vanilla_server, build)
vanilla = run_side('vanilla', vanilla_server, client, build, world)
horizon = run_side('horizon', horizon_server, client, build, world)
errors = [l for l in vanilla + horizon if ' error ' in f' {l} ' or 'disconnected' in l]
vanilla, horizon = shown(vanilla), shown(horizon)
# Each player's packets in their order; which player's line comes first in a step doesn't matter.
if errors or vanilla != horizon:
import difflib
diff = '\n'.join(difflib.unified_diff(vanilla, horizon, 'vanilla', 'horizon', n=1, lineterm=''))
raise AssertionError(f'{len(errors)} errors, transcripts differ (see {build}/vanilla.txt and horizon.txt):\n'
+ '\n'.join(errors[:10]) + '\n' + diff[:6000])
if len(vanilla) < 20:
raise AssertionError(f'only {len(vanilla)} waypoint packets: the script did not run (see {build})')
lab.write_json(report, dict(
schema=1, minecraft=lock['minecraft']['version'], platform=platform.platform(),
java=subprocess.check_output(['java', '--version'], text=True).splitlines()[0],
packets=len(vanilla), output_sha256=lab.digest(build / 'horizon.txt', 'sha256'),
scope='Locator bar waypoints (ClientboundTrackedWaypointPacket) two vanilla-codec players receive on a '
'dedicated server: block, chunk and azimuth connections as one moves, invisibility, team colours, '
'crouching, a carved pumpkin, the locator_bar game rule, spectator mode and leaving.'))
print(f'locator bar: {len(vanilla)} waypoint packets identical')
if __name__ == '__main__':
try:
main()
except AssertionError as error:
sys.exit(f'{NAME}: {error}')