mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
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:
8 files changed
+1538
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}')
|
||||
Reference in new issue
Block a user