diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 3dae44b93..150ee7aa9 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -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; diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index b5472871c..8948347aa 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -57,6 +57,10 @@ pub(super) struct LanHost { pub mob_players: HashMap, /// An integrated server's owner (game/integrated.rs). pub owner: Option, + /// 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, } /// 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(); diff --git a/crates/horizon-client/src/game/lan_waypoints.rs b/crates/horizon-client/src/game/lan_waypoints.rs new file mode 100644 index 000000000..0d0ab69e4 --- /dev/null +++ b/crates/horizon-client/src/game/lan_waypoints.rs @@ -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)> { + 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); + } + } + } +} diff --git a/crates/horizon-client/src/lan/mod.rs b/crates/horizon-client/src/lan/mod.rs index 58badc010..f074506b5 100644 --- a/crates/horizon-client/src/lan/mod.rs +++ b/crates/horizon-client/src/lan/mod.rs @@ -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; diff --git a/crates/horizon-client/src/lan/waypoints.rs b/crates/horizon-client/src/lan/waypoints.rs new file mode 100644 index 000000000..a8797dbaf --- /dev/null +++ b/crates/horizon-client/src/lan/waypoints.rs @@ -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 { + 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, +} + +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, + 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)>, + /// 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)>, +} + +/// ServerWaypointManager for one level. +#[derive(Default)] +pub struct Waypoints { + /// The transmitters, in the order they were tracked. + waypoints: Vec, + /// The receivers, in the order they joined. + players: Vec, + /// Connections by receiver, then source. + connections: HashMap>, + memo: HashMap, + /// The `locator_bar` game rule as last seen. + enabled: Option, +} + +/// One tick's players, and the packets for their connections. +struct Tick<'a> { + players: HashMap, + view: HashMap, + /// 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 { + 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::() > 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 { + 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 = 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); + } +} diff --git a/crates/horizon-protocol/src/packets/play.rs b/crates/horizon-protocol/src/packets/play.rs index 2d9f7e629..f14f8f817 100644 --- a/crates/horizon-protocol/src/packets/play.rs +++ b/crates/horizon-protocol/src/packets/play.rs @@ -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`. +#[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, + pub target: WaypointTarget, +} + +impl Codec for TrackedWaypoint { + fn read(r: &mut Reader) -> Result { + 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![ diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java b/mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java new file mode 100644 index 000000000..e41c693ee --- /dev/null +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java @@ -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. + * + *

{@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 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 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 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); + } +} diff --git a/tools/test_locator_bar_parity.py b/tools/test_locator_bar_parity.py new file mode 100644 index 000000000..bc7d21750 --- /dev/null +++ b/tools/test_locator_bar_parity.py @@ -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}')