LAN discovery as vanilla's list keeps it; test_lan_parity against LanServerPinger and LanServerDetection

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ludovic Marie
2026-10-13 01:11:55 +02:00
parent de3a285cb3
commit 42cdea74be
13 changed files with 389 additions and 77 deletions
@@ -0,0 +1,46 @@
//! Horizon's LAN world discovery for tools/test_lan_parity.py: the cases of
//! VanillaLanOracle (announcements sent, datagrams heard, list resets) run
//! through `lan::discovery`, printing the same lines.
use horizon_client::lan::discovery::{
LanGame, add_server, parse_address, parse_motd, ping_string_text,
};
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn unhex(s: &str) -> Vec<u8> {
(0..s.len() / 2)
.map(|i| u8::from_str_radix(&s[2 * i..2 * i + 2], 16).unwrap())
.collect()
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let cases = std::fs::read_to_string(&args[1]).unwrap();
let mut out = String::new();
let mut games: Vec<LanGame> = Vec::new();
for line in cases.lines() {
let f: Vec<&str> = line.split('\t').collect();
match f[0] {
"ping" => out += &format!("ping {}\n", hex(ping_string_text(f[1], f[2]).as_bytes())),
"heard" => {
let received = String::from_utf8_lossy(&unhex(f[2])).into_owned();
out += &format!(
"heard motd={} address={}\n",
parse_motd(&received),
parse_address(&received).unwrap_or_else(|| "null".into())
);
if add_server(&mut games, &received, f[1]) {
out += "list";
for g in &games {
out += &format!(" [{} @ {}]", g.motd, g.address);
}
out += "\n";
}
}
"reset" => games.clear(),
other => panic!("{other}"),
}
}
std::fs::write(&args[2], out).unwrap();
}
+2 -2
View File
@@ -21,9 +21,8 @@ mod control;
mod copper_golems;
mod creaking;
mod creatures;
mod dedicated;
mod net_harness;
mod crossbow;
mod dedicated;
mod dispense_fixture;
mod dispenser;
mod dragon;
@@ -60,6 +59,7 @@ mod music;
mod names;
mod natural_spawning;
mod nautili;
mod net_harness;
mod options;
mod packs;
mod paintings;
+57 -13
View File
@@ -37,7 +37,11 @@ pub(super) fn java_double(v: f64) -> String {
} else {
let s = format!("{v:E}");
let (m, e) = s.split_once('E').unwrap_or((&s, "0"));
let m = if m.contains('.') { m.to_owned() } else { format!("{m}.0") };
let m = if m.contains('.') {
m.to_owned()
} else {
format!("{m}.0")
};
format!("{m}E{e}")
}
}
@@ -75,8 +79,14 @@ pub(super) fn preflight(options: &mut Options) -> Result<bool> {
while let Some(arg) = it.next() {
let name = arg.trim_start_matches('-');
match name {
"nogui" | "demo" | "bonusChest" | "forceUpgrade" | "eraseCache"
| "recreateRegionFiles" | "safeMode" | "jfrProfile" => {}
"nogui"
| "demo"
| "bonusChest"
| "forceUpgrade"
| "eraseCache"
| "recreateRegionFiles"
| "safeMode"
| "jfrProfile" => {}
"initSettings" => init_settings = true,
"help" => {
eprintln!(
@@ -296,7 +306,10 @@ impl Game<'_> {
let stdin = std::io::stdin();
let mut line = String::new();
while stdin.read_line(&mut line).is_ok_and(|n| n > 0) {
if tx.send(line.trim_end_matches(['\r', '\n']).to_owned()).is_err() {
if tx
.send(line.trim_end_matches(['\r', '\n']).to_owned())
.is_err()
{
break;
}
line.clear();
@@ -632,13 +645,24 @@ impl Game<'_> {
let guests: Vec<(String, u128)> = self
.lan
.as_ref()
.map(|h| h.hub.guests.iter().map(|g| (g.name.clone(), g.uuid)).collect())
.map(|h| {
h.hub
.guests
.iter()
.map(|g| (g.name.clone(), g.uuid))
.collect()
})
.unwrap_or_default();
let max = self.lan.as_ref().map_or(0, |h| h.hub.lan.shared.max_players());
let max = self
.lan
.as_ref()
.map_or(0, |h| h.hub.lan.shared.max_players());
let names: Vec<String> = if rest.first() == Some(&"uuids") {
guests
.iter()
.map(|(n, u)| format!("{n} ({})", horizon_protocol::crypto::uuid_string(*u)))
.map(|(n, u)| {
format!("{n} ({})", horizon_protocol::crypto::uuid_string(*u))
})
.collect()
} else {
guests.iter().map(|(n, _)| n.clone()).collect()
@@ -697,7 +721,12 @@ impl Game<'_> {
&[
lit(&user.name),
reason.as_deref().map_or_else(
|| Text::translate("multiplayer.disconnect.banned.reason.default", &[]),
|| {
Text::translate(
"multiplayer.disconnect.banned.reason.default",
&[],
)
},
lit,
),
],
@@ -820,7 +849,12 @@ impl Game<'_> {
lit(&name),
lit(&source),
reason.as_deref().map_or_else(
|| Text::translate("multiplayer.disconnect.banned.reason.default", &[]),
|| {
Text::translate(
"multiplayer.disconnect.banned.reason.default",
&[],
)
},
lit,
),
],
@@ -932,7 +966,11 @@ impl Game<'_> {
(false, "commands.whitelist.remove.failed")
};
drop(l);
let args = if ok { vec![lit(&user.name)] } else { Vec::new() };
let args = if ok {
vec![lit(&user.name)]
} else {
Vec::new()
};
self.console_reply(Text::translate(key, &args));
}
_ => self.console_reply(Text::translate("command.unknown.argument", &[])),
@@ -968,7 +1006,11 @@ impl Game<'_> {
/// MinecraftServer.saveAllChunks, not silent: one line per level, in
/// the server's level order.
fn log_saved_levels(&self) {
for dimension in ["minecraft:overworld", "minecraft:the_nether", "minecraft:the_end"] {
for dimension in [
"minecraft:overworld",
"minecraft:the_nether",
"minecraft:the_end",
] {
log(
"INFO",
&format!(
@@ -988,8 +1030,10 @@ impl Game<'_> {
}
let conns: Vec<u32> = h.hub.guests.iter().map(|g| g.conn).collect();
for conn in conns {
h.hub
.kick(conn, translatable("multiplayer.disconnect.server_shutdown", &[]));
h.hub.kick(
conn,
translatable("multiplayer.disconnect.server_shutdown", &[]),
);
}
}
std::thread::sleep(Duration::from_millis(100));
@@ -517,7 +517,13 @@ impl Game<'_> {
let me = (self.options.dedicated.is_none() && self.player.is_some())
.then_some((host.host_uuid, super::chat::PLAYER_NAME));
let content = host.hub.list_reply(me, words.len() == 2);
host.hub.send_to(conn, &C::SystemChat(play::SystemChat { content, overlay: false }));
host.hub.send_to(
conn,
&C::SystemChat(play::SystemChat {
content,
overlay: false,
}),
);
return;
}
if !host.settings.guest_commands || !self.cheats {
@@ -45,7 +45,10 @@ impl Game<'_> {
quiet_since = now;
}
match step {
0 if spawned_at.is_some() && count > 0 && now - quiet_since > Duration::from_secs(3) => {
0 if spawned_at.is_some()
&& count > 0
&& now - quiet_since > Duration::from_secs(3) =>
{
chunks = count;
self.send_chat("hello from the horizon client");
step = 1;
+23 -7
View File
@@ -203,9 +203,9 @@ impl ChunkEncoder {
for (i, s) in chunk.sections.iter().enumerate() {
let mut unpacked = [0u16; BLOCKS];
s.unpack(&mut unpacked);
let any = unpacked.iter().any(|&v| {
self.flags.get(usize::from(v)).copied().unwrap_or(0) & NON_AIR != 0
});
let any = unpacked
.iter()
.any(|&v| self.flags.get(usize::from(v)).copied().unwrap_or(0) & NON_AIR != 0);
if any {
bits |= 1 << i;
}
@@ -296,7 +296,11 @@ impl ChunkEncoder {
/// (`items` in section order) went through.
fn java_hash_order<T>(items: &mut Vec<T>, pos: impl Fn(&T) -> (i32, i32, i32)) {
// Vec3i.hashCode.
let hash = |&(x, y, z): &(i32, i32, i32)| y.wrapping_add(z.wrapping_mul(31)).wrapping_mul(31).wrapping_add(x);
let hash = |&(x, y, z): &(i32, i32, i32)| {
y.wrapping_add(z.wrapping_mul(31))
.wrapping_mul(31)
.wrapping_add(x)
};
let keys: Vec<i32> = items.iter().map(|i| hash(&pos(i))).collect();
// java.util.HashMap: buckets in index order, insertion order within one.
let mut capacity = 16usize;
@@ -304,7 +308,9 @@ fn java_hash_order<T>(items: &mut Vec<T>, pos: impl Fn(&T) -> (i32, i32, i32)) {
capacity *= 2;
}
let mut order: Vec<usize> = (0..keys.len()).collect();
order.sort_by_key(|&i| ((keys[i] ^ ((keys[i] as u32) >> 16) as i32) as u32 as usize) & (capacity - 1));
order.sort_by_key(|&i| {
((keys[i] ^ ((keys[i] as u32) >> 16) as i32) as u32 as usize) & (capacity - 1)
});
// Object2ObjectOpenHashMap.
let mix = |h: i32| {
let h = h.wrapping_mul(0x9E37_79B9_u32 as i32) as u32;
@@ -356,7 +362,15 @@ fn saved_update_tag(chunk: &Chunk, pos: (i32, i32, i32)) -> Option<Tag> {
let Tag::Compound(mut c) = crate::block_entity_nbt::canonical(tag)? else {
return None;
};
for key in ["id", "x", "y", "z", "keepPacked", "SpawnPotentials", "components"] {
for key in [
"id",
"x",
"y",
"z",
"keepPacked",
"SpawnPotentials",
"components",
] {
c.remove(&key.into());
}
Some(Tag::Compound(c))
@@ -466,7 +480,9 @@ mod tests {
#[test]
fn light_masks_cover_the_world_plus_borders() {
let chunk = Chunk::empty(0, 0);
let data = light_data(&chunk);
// A column with nothing around it has no light layers at all.
assert!(light_data(&chunk, 0).sky.is_empty());
let data = light_data(&chunk, u64::MAX);
// Default sections are full sky light and no block light.
assert_eq!(data.sky.len(), SECTIONS + 1);
assert_eq!(data.block.len(), 0);
+39 -6
View File
@@ -52,10 +52,37 @@ fn component_value(tag: &horizon_world::nbt::Tag) -> crate::server::json::Json {
use crate::server::json::Json;
use horizon_world::nbt::Tag;
const ORDER: &[&str] = &[
"type", "text", "translate", "fallback", "with", "score", "selector", "separator",
"keybind", "nbt", "interpret", "block", "entity", "storage", "source", "object",
"sprite", "atlas", "player", "hat", "extra", "color", "shadow_color", "bold", "italic",
"underlined", "strikethrough", "obfuscated", "click_event", "hover_event", "insertion",
"type",
"text",
"translate",
"fallback",
"with",
"score",
"selector",
"separator",
"keybind",
"nbt",
"interpret",
"block",
"entity",
"storage",
"source",
"object",
"sprite",
"atlas",
"player",
"hat",
"extra",
"color",
"shadow_color",
"bold",
"italic",
"underlined",
"strikethrough",
"obfuscated",
"click_event",
"hover_event",
"insertion",
"font",
];
match tag {
@@ -125,7 +152,10 @@ fn serve_legacy(mut stream: TcpStream, shared: &Shared) -> Result<(), String> {
let bytes = d.get(*at + 2..*at + 2 + len * 2)?;
*at += 2 + len * 2;
Some(String::from_utf16_lossy(
&bytes.chunks(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect::<Vec<_>>(),
&bytes
.chunks(2)
.map(|c| u16::from_be_bytes([c[0], c[1]]))
.collect::<Vec<_>>(),
))
};
let motd = shared.motd();
@@ -181,7 +211,10 @@ pub fn serve(
let e = |e: horizon_protocol::Error| e.to_string();
// LegacyQueryHandler, ahead of the frame decoder while status is on.
let mut first = [0u8; 1];
if shared.config.enable_status && stream.peek(&mut first).is_ok_and(|n| n == 1) && first[0] == 0xFE {
if shared.config.enable_status
&& stream.peek(&mut first).is_ok_and(|n| n == 1)
&& first[0] == 0xFE
{
return serve_legacy(stream, &shared);
}
let mut c = Connection::from_stream(stream).map_err(e)?;
+58 -35
View File
@@ -10,34 +10,61 @@ use std::{
atomic::{AtomicBool, Ordering},
},
thread::JoinHandle,
time::{Duration, Instant},
time::Duration,
};
pub const GROUP: Ipv4Addr = Ipv4Addr::new(224, 0, 2, 60);
pub const PORT: u16 = 4445;
/// LanServerPinger sleeps 1500 ms between announcements.
const INTERVAL: Duration = Duration::from_millis(1500);
/// LanServerDetection.LanServerList drops entries not refreshed for 5 s.
const EXPIRY: Duration = Duration::from_secs(5);
/// LanServerPinger.createPingString.
pub fn ping_string(motd: &str, port: u16) -> String {
format!("[MOTD]{motd}[/MOTD][AD]{port}[/AD]")
ping_string_text(motd, &port.to_string())
}
fn between<'a>(s: &'a str, open: &str, close: &str) -> Option<&'a str> {
let start = s.find(open)? + open.len();
let end = s[start..].find(close)? + start;
Some(&s[start..end])
/// LanServerPinger.createPingString with the address as text.
pub fn ping_string_text(motd: &str, address: &str) -> String {
format!("[MOTD]{motd}[/MOTD][AD]{address}[/AD]")
}
/// LanServerPinger.parseMotd; vanilla shows "missing no" when absent.
/// String.indexOf(needle, from) on a Rust string (byte offsets).
fn index_of(s: &str, needle: &str, from: usize) -> Option<usize> {
s.get(from..)?.find(needle).map(|i| i + from)
}
/// LanServerPinger.parseMotd; "missing no" when there is none.
pub fn parse_motd(s: &str) -> String {
between(s, "[MOTD]", "[/MOTD]")
.unwrap_or("missing no")
.to_owned()
let Some(start) = s.find("[MOTD]") else {
return "missing no".to_owned();
};
match index_of(s, "[/MOTD]", start + "[MOTD]".len()) {
Some(end) => s[start + "[MOTD]".len()..end].to_owned(),
None => "missing no".to_owned(),
}
}
/// LanServerPinger.parseAddress: the announced port, if present and valid.
pub fn parse_port(s: &str) -> Option<u16> {
between(s, "[AD]", "[/AD]")?.parse().ok().filter(|p| *p > 0)
/// LanServerPinger.parseAddress: the text between [AD] and [/AD] after the
/// only [/MOTD], as announced (vanilla does not check it is a port).
pub fn parse_address(s: &str) -> Option<String> {
let end_motd = s.find("[/MOTD]")?;
if index_of(s, "[/MOTD]", end_motd + "[/MOTD]".len()).is_some() {
return None;
}
let start = index_of(s, "[AD]", end_motd + "[/MOTD]".len())?;
let end = index_of(s, "[/AD]", start + "[AD]".len())?;
Some(s[start + "[AD]".len()..end].to_owned())
}
/// LanServerDetection.LanServerList.addServer: an announcement from `ip`.
/// A new address joins the list; a known one keeps its first MOTD. True
/// when the list changed.
pub fn add_server(games: &mut Vec<LanGame>, ping: &str, ip: &str) -> bool {
let motd = parse_motd(ping);
let Some(address) = parse_address(ping) else {
return false;
};
let address = format!("{ip}:{address}");
if games.iter().any(|g| g.address == address) {
return false;
}
games.push(LanGame { motd, address });
true
}
/// Announces one hosted world until dropped.
@@ -48,7 +75,6 @@ pub struct Pinger {
impl Pinger {
pub fn start(motd: String, port: u16) -> io::Result<Self> {
let socket = UdpSocket::bind(("0.0.0.0", 0))?;
let _ = socket.set_multicast_ttl_v4(4);
let stop = Arc::new(AtomicBool::new(false));
let flag = stop.clone();
let message = ping_string(&motd, port);
@@ -86,7 +112,7 @@ pub struct LanGame {
pub motd: String,
pub address: String,
}
type Games = Arc<Mutex<Vec<(LanGame, Instant)>>>;
type Games = Arc<Mutex<Vec<LanGame>>>;
/// Listens for announcements until the last clone is dropped.
#[derive(Clone, Debug)]
@@ -118,11 +144,10 @@ impl Scanner {
inner: Arc::new(ScanInner { games, stop }),
}
}
/// The live announcements, oldest first.
/// The worlds announced since the scan started, in order (vanilla's
/// list keeps them while the multiplayer screen is open).
pub fn games(&self) -> Vec<LanGame> {
let mut games = self.inner.games.lock().unwrap();
games.retain(|(_, seen)| seen.elapsed() < EXPIRY);
games.iter().map(|(g, _)| g.clone()).collect()
self.inner.games.lock().unwrap().clone()
}
}
fn bind_listener() -> io::Result<UdpSocket> {
@@ -138,18 +163,7 @@ fn listen(socket: UdpSocket, games: Games, stop: Arc<AtomicBool>) {
continue;
};
let text = String::from_utf8_lossy(&buf[..n]);
let Some(port) = parse_port(&text) else {
continue;
};
let game = LanGame {
motd: parse_motd(&text),
address: format!("{}:{port}", from.ip()),
};
let mut games = games.lock().unwrap();
match games.iter_mut().find(|(g, _)| g.address == game.address) {
Some(entry) => *entry = (game, Instant::now()),
None => games.push((game, Instant::now())),
}
add_server(&mut games.lock().unwrap(), &text, &from.ip().to_string());
}
}
@@ -162,8 +176,17 @@ mod tests {
let s = ping_string("Steve - World", 25565);
assert_eq!(s, "[MOTD]Steve - World[/MOTD][AD]25565[/AD]");
assert_eq!(parse_motd(&s), "Steve - World");
assert_eq!(parse_port(&s), Some(25565));
assert_eq!(parse_port("[AD]0[/AD]"), None);
assert_eq!(parse_address(&s).as_deref(), Some("25565"));
assert_eq!(parse_address("[AD]1[/AD]"), None);
assert_eq!(parse_address("[/MOTD][AD]x[/AD][/MOTD]"), None);
assert_eq!(parse_motd("junk"), "missing no");
let mut games = Vec::new();
assert!(add_server(&mut games, &s, "10.0.0.2"));
assert!(!add_server(
&mut games,
&ping_string("Other", 25565),
"10.0.0.2"
));
assert_eq!(games[0].motd, "Steve - World");
}
}
+20 -4
View File
@@ -129,7 +129,12 @@ pub fn yellow_translation(key: &str, args: &[&str]) -> Tag {
/// Player.getDisplayName for a player in no team: the name, suggesting
/// `/tell NAME `, showing the player on hover and inserting the name.
pub fn display_name(uuid: u128, name: &str) -> Tag {
let words = [(uuid >> 96) as i32, (uuid >> 64) as i32, (uuid >> 32) as i32, uuid as i32];
let words = [
(uuid >> 96) as i32,
(uuid >> 64) as i32,
(uuid >> 32) as i32,
uuid as i32,
];
Tag::compound([
("text", string(name)),
(
@@ -275,7 +280,12 @@ impl Hub {
pub fn list_reply(&self, host: Option<(u128, &str)>, uuids: bool) -> Tag {
let players: Vec<(u128, &str)> = host
.into_iter()
.chain(self.guests.iter().filter(|g| g.placed).map(|g| (g.uuid, g.name.as_str())))
.chain(
self.guests
.iter()
.filter(|g| g.placed)
.map(|g| (g.uuid, g.name.as_str())),
)
.collect();
let format = |(uuid, name): (u128, &str)| {
if uuids {
@@ -300,7 +310,10 @@ impl Hub {
let mut extra = Vec::new();
for (k, p) in players.iter().enumerate() {
if k > 0 {
extra.push(Tag::compound([("text", string(", ")), ("color", string("gray"))]));
extra.push(Tag::compound([
("text", string(", ")),
("color", string("gray")),
]));
}
extra.push(format(*p));
}
@@ -536,7 +549,10 @@ impl Hub {
// ServerLevel.addNewPlayer: the chunk tracking starts at the player.
let g = &mut self.guests[i];
let (cx, cz) = g.chunk();
g.send(&C::SetChunkCacheCenter(play::SetChunkCacheCenter { x: cx, z: cz }));
g.send(&C::SetChunkCacheCenter(play::SetChunkCacheCenter {
x: cx,
z: cz,
}));
g.center_sent = Some((cx, cz));
g.placed_at = self.ticks;
// ServerPlayer.sendPostEffects and initInventoryMenu.
+28 -7
View File
@@ -73,7 +73,8 @@ pub enum Out {
/// PlayerList.canPlayerLogin for a dedicated server: the disconnect reason
/// (bans, whitelist) for a profile connecting from an address, if refused.
pub type LoginGate = Arc<dyn Fn(SocketAddr, u128, &str) -> Option<horizon_world::nbt::Tag> + Send + Sync>;
pub type LoginGate =
Arc<dyn Fn(SocketAddr, u128, &str) -> Option<horizon_world::nbt::Tag> + Send + Sync>;
/// How the server side of the network behaves: an integrated server's LAN
/// uses the defaults, a dedicated server takes them from server.properties.
@@ -199,7 +200,9 @@ impl Shared {
}
fn next_random(&self, bound: usize) -> usize {
let mut r = self.random.lock().unwrap();
*r = r.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
*r = r
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
((*r >> 33) as usize) % bound.max(1)
}
/// The status response (MinecraftServer.buildServerStatus encoded with
@@ -211,7 +214,10 @@ impl Shared {
let online: Vec<(u128, String)> = self.online.lock().unwrap().clone();
let count = self.online();
let mut players = vec![
("max".to_owned(), Json::Number(self.max_players().to_string())),
(
"max".to_owned(),
Json::Number(self.max_players().to_string()),
),
("online".to_owned(), Json::Number(count.to_string())),
];
if !self.config.hide_online_players && !online.is_empty() {
@@ -244,7 +250,10 @@ impl Shared {
"version".to_owned(),
Json::Object(vec![
("name".to_owned(), Json::String(GAME_VERSION.to_owned())),
("protocol".to_owned(), Json::Number(PROTOCOL_VERSION.to_string())),
(
"protocol".to_owned(),
Json::Number(PROTOCOL_VERSION.to_string()),
),
]),
),
];
@@ -266,12 +275,24 @@ pub fn base64(data: &[u8]) -> String {
const A: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for chunk in data.chunks(3) {
let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
let b = [
chunk[0],
*chunk.get(1).unwrap_or(&0),
*chunk.get(2).unwrap_or(&0),
];
let n = u32::from(b[0]) << 16 | u32::from(b[1]) << 8 | u32::from(b[2]);
out.push(char::from(A[(n >> 18) as usize & 63]));
out.push(char::from(A[(n >> 12) as usize & 63]));
out.push(if chunk.len() > 1 { char::from(A[(n >> 6) as usize & 63]) } else { '=' });
out.push(if chunk.len() > 2 { char::from(A[n as usize & 63]) } else { '=' });
out.push(if chunk.len() > 1 {
char::from(A[(n >> 6) as usize & 63])
} else {
'='
});
out.push(if chunk.len() > 2 {
char::from(A[n as usize & 63])
} else {
'='
});
}
out
}
+5 -1
View File
@@ -651,7 +651,11 @@ pub fn client_heightmaps(chunk: &Chunk, tables: &SaveTables) -> Vec<(i32, Vec<i6
];
let mut maps: Vec<(i32, Heightmap)> = kinds
.iter()
.filter_map(|(id, k)| Heightmap::new(*k, MIN_Y, SECTIONS as i32 * 16).ok().map(|m| (*id, m)))
.filter_map(|(id, k)| {
Heightmap::new(*k, MIN_Y, SECTIONS as i32 * 16)
.ok()
.map(|m| (*id, m))
})
.collect();
let view = HeightmapView { chunk, tables };
let mut refs: Vec<&mut Heightmap> = maps.iter_mut().map(|(_, m)| m).collect();
@@ -0,0 +1,52 @@
package dev.horizonlab.parity;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HexFormat;
import java.util.List;
import net.minecraft.client.server.LanServer;
import net.minecraft.client.server.LanServerDetection;
import net.minecraft.client.server.LanServerPinger;
/**
* Vanilla's LAN world discovery for tools/test_lan_parity.py. Each input
* line is a case: `ping MOTD PORT` prints the announcement
* LanServerPinger sends (its UTF-8 bytes); `heard IP HEX` decodes a received
* datagram as LanServerDetection does and adds it to the list
* (LanServerList.addServer), printing what it parsed and the list when it
* changed; `reset` starts a new list (a new multiplayer screen).
*/
public final class VanillaLanOracle {
public static void main(String[] args) throws Exception {
List<String> cases = Files.readAllLines(Path.of(args[0]), StandardCharsets.UTF_8);
try (PrintWriter out = new PrintWriter(Files.newBufferedWriter(Path.of(args[1]), StandardCharsets.UTF_8))) {
LanServerDetection.LanServerList list = new LanServerDetection.LanServerList();
for (String line : cases) {
String[] f = line.split("\t", -1);
switch (f[0]) {
case "ping" -> {
String ping = LanServerPinger.createPingString(f[1], f[2]);
out.println("ping " + HexFormat.of().formatHex(ping.getBytes(StandardCharsets.UTF_8)));
}
case "heard" -> {
byte[] bytes = HexFormat.of().parseHex(f[2]);
String received = new String(bytes, 0, bytes.length, StandardCharsets.UTF_8);
out.println("heard motd=" + LanServerPinger.parseMotd(received) + " address=" + LanServerPinger.parseAddress(received));
list.addServer(received, InetAddress.getByName(f[1]));
List<LanServer> dirty = list.takeDirtyServers();
if (dirty != null) {
StringBuilder b = new StringBuilder("list");
for (LanServer s : dirty) b.append(" [").append(s.getMotd()).append(" @ ").append(s.getAddress()).append(']');
out.println(b);
}
}
case "reset" -> list = new LanServerDetection.LanServerList();
default -> throw new IllegalArgumentException(line);
}
}
}
}
}
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""LAN world discovery against the pinned client's LanServerPinger and
LanServerDetection: the announcement an opened world multicasts
(`[MOTD]...[/MOTD][AD]port[/AD]` in UTF-8), and how the multiplayer screen
parses what it hears (parseMotd, parseAddress, UTF-8 decoding of malformed
bytes) and keeps its list (LanServerList.addServer: one entry per
address, the first MOTD kept, never expired while the screen is open).
VanillaLanOracle and the `lan_probe` example must print the same lines."""
import random
try:
from . import oracle_harness as oh
except ImportError:
import oracle_harness as oh
NAME = 'lan-parity'
def make_input(build):
rng = random.Random(4445)
lines = []
motds = ['Player - New World', 'Steve - §aGreen §lWorld', 'été 日本語 ✓', '', '[MOTD]', 'a[/MOTD]b', 'x' * 300,
'tab\\there', '[AD]25565[/AD]']
for motd in motds:
for port in ['25565', '1024', '65535', '0', 'abc', '']:
lines.append(f'ping\t{motd}\t{port}')
fragments = [b'[MOTD]', b'[/MOTD]', b'[AD]', b'[/AD]', b'25565', b'1234', b'Steve - World', b'\xc3\xa9', b'\xe6\x97',
b'\xff', b'\xc0\x80', b'\xed\xa0\x80', b'\xf0\x9f\x98\x80', b'\xf0\x9f', b'abc', b' ', b'\x00', b'-1']
ips = ['192.168.1.10', '10.0.0.2', '127.0.0.1']
good = lambda motd, port: f'[MOTD]{motd}[/MOTD][AD]{port}[/AD]'.encode()
for i in range(400):
if i % 50 == 0:
lines.append('reset')
if rng.random() < 0.4:
data = good(rng.choice(['A - W', 'B - W', 'é', '']), rng.choice(['25565', '25566', 'x', '']))
else:
data = b''.join(rng.choice(fragments) for _ in range(rng.randint(0, 9)))
lines.append(f'heard\t{rng.choice(ips)}\t{data.hex()}')
path = build / 'cases.txt'
path.write_text('\n'.join(lines) + '\n', encoding='utf-8')
return path, len(lines)
if __name__ == '__main__':
oh.differential(NAME, 'VanillaLanOracle', 'lan_probe', make_input,
'LanServerPinger.createPingString/parseMotd/parseAddress, LanServerDetection decoding and '
'LanServerList.addServer/takeDirtyServers',
sources=[oh.lab.ROOT / 'crates/horizon-client/src/lan/discovery.rs'])