use std::fmt::Write as _;
use std::path::PathBuf;
use std::process::Command;
use bytes::{Bytes, BytesMut};
use ghast_protocol::codec::{McWrite, Reader};
use ghast_protocol::testgen::Gen;
use ghast_protocol::{PacketSet, RawPacket};
use serde_json::Value;
fn node_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/node")
}
fn node_available() -> bool {
let ok = node_dir().join("node_modules/minecraft-protocol").exists()
&& Command::new("node")
.arg("--version")
.output()
.is_ok_and(|o| o.status.success());
if !ok {
eprintln!("SKIPPED: node or tests/node/node_modules missing (run `npm ci` in tests/node)");
}
ok
}
fn per_id() -> usize {
std::env::var("GHAST_DIFF_N")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(100)
}
fn run_node(tag: &str, requests: &[Value]) -> Vec<Value> {
let dir = std::env::temp_dir().join(format!("ghast-diff-{}-{tag}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let req_file = dir.join("requests.jsonl");
let mut s = String::new();
for r in requests {
let _ = writeln!(s, "{r}");
}
std::fs::write(&req_file, s).unwrap();
let out = Command::new("node")
.arg(node_dir().join("diff.js"))
.arg(&req_file)
.current_dir(node_dir())
.output()
.expect("run node");
let _ = std::fs::remove_dir_all(&dir);
assert!(
out.status.success(),
"node failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let text = String::from_utf8(out.stdout).unwrap();
let resp: Vec<Value> = text
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert_eq!(resp.len(), requests.len(), "one response per request");
resp
}
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
fn unhex(s: &str) -> Vec<u8> {
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
.collect()
}
fn wire<S: PacketSet>(p: &S) -> Vec<u8> {
let mut w = BytesMut::new();
w.put_varint(p.id());
p.encode_body(&mut w).unwrap();
w.to_vec()
}
fn diverges_from_node(version: &str, state: &str, dir: &str, name: &str, bytes: &[u8]) -> bool {
if (version, state, dir, name) == ("1.8.9", "play", "toClient", "spawn_entity") {
let b = Bytes::copy_from_slice(bytes);
let mut r = Reader::new(&b);
let object_data = (|| {
r.read_varint()?;
r.read_varint()?;
r.skip(1 + 12 + 2)?;
r.read_i32()
})();
return object_data.is_ok_and(|d| d < 0);
}
false
}
struct SetInfo {
state: &'static str,
dir: &'static str,
ids: &'static [i32],
}
type Reencode = fn(&[u8]) -> Result<(&'static str, Vec<u8>), String>;
struct SetOps {
info: SetInfo,
generate: fn(i32, &mut Gen) -> (Vec<u8>, &'static str),
reencode: Reencode,
}
fn reencode_impl<S: PacketSet>(data: &[u8]) -> Result<(&'static str, Vec<u8>), String> {
let b = Bytes::copy_from_slice(data);
let mut r = Reader::new(&b);
let id = r.read_varint().map_err(|e| e.to_string())?;
let raw = RawPacket::new(id, b.slice(r.position()..));
let p = S::decode_raw(&raw).map_err(|e| e.to_string())?;
Ok((p.name(), wire(&p)))
}
fn rust_to_node(version: &str, sets: &[SetOps]) {
if !node_available() {
return;
}
let n = per_id();
let mut requests = Vec::new();
let mut expected = Vec::new();
let mut diverging = 0;
for set in sets {
for &id in set.info.ids {
for k in 0..n {
let mut g = Gen::new((k as u64) << 16 ^ id as u64 ^ 0xd1ff);
g.node_compat = true;
let (bytes, name) = (set.generate)(id, &mut g);
if diverges_from_node(version, set.info.state, set.info.dir, name, &bytes) {
diverging += 1;
continue;
}
requests.push(serde_json::json!({
"op": "reparse", "version": version, "state": set.info.state,
"dir": set.info.dir, "hex": hex(&bytes),
}));
expected.push((set.info.state, set.info.dir, name, hex(&bytes)));
}
}
}
if diverging > 0 {
eprintln!("{version} rust->node: {diverging} deliberately diverging packet(s) skipped");
}
let resp = run_node(&format!("r2n-{version}"), &requests);
let mut failures = Vec::new();
for (r, (state, dir, name, hx)) in resp.iter().zip(&expected) {
let ok = r["ok"].as_bool() == Some(true)
&& r["name"].as_str() == Some(name)
&& r["hex"].as_str() == Some(hx);
if !ok {
failures.push(format!("{state}.{dir}.{name}: {r}\n ours: {hx}"));
}
}
report(version, "rust->node", requests.len(), &failures);
}
fn node_to_rust(version: &str, sets: &[SetOps]) {
if !node_available() {
return;
}
let n = per_id();
let mut requests = Vec::new();
let mut meta = Vec::new();
for (si, set) in sets.iter().enumerate() {
for &id in set.info.ids {
let mut g = Gen::new(0);
let (_, name) = (set.generate)(id, &mut g);
requests.push(serde_json::json!({
"op": "gen", "version": version, "state": set.info.state, "dir": set.info.dir,
"name": name, "count": n, "seed": id as u64 * 7919 + 17,
}));
meta.push((si, name));
}
}
let resp = run_node(&format!("n2r-{version}"), &requests);
let mut failures = Vec::new();
let mut total = 0;
for (r, &(si, name)) in resp.iter().zip(&meta) {
let set = &sets[si];
if r["ok"].as_bool() != Some(true) {
failures.push(format!(
"{}.{}.{name}: node could not generate: {r}",
set.info.state, set.info.dir
));
continue;
}
for h in r["hexes"].as_array().unwrap() {
total += 1;
let h = h.as_str().unwrap();
match (set.reencode)(&unhex(h)) {
Ok((got, bytes)) if got == name && hex(&bytes) == h => {}
Ok((got, bytes)) => failures.push(format!(
"{}.{}.{name}: decoded as {got}\n node: {h}\n ours: {}",
set.info.state,
set.info.dir,
hex(&bytes)
)),
Err(e) => failures.push(format!(
"{}.{}.{name}: decode error {e}\n node: {h}",
set.info.state, set.info.dir
)),
}
}
}
report(version, "node->rust", total, &failures);
}
fn report(version: &str, what: &str, total: usize, failures: &[String]) {
eprintln!(
"{version} {what}: {total} packets, {} failure(s)",
failures.len()
);
for f in failures.iter().take(15) {
eprintln!("{f}");
}
assert!(
failures.is_empty(),
"{} {what} differential failures",
failures.len()
);
}
macro_rules! set_ops {
($v:ident, $state:ident, $dir:ident, $nodedir:literal, $set:ident) => {
SetOps {
info: SetInfo {
state: stringify!($state),
dir: $nodedir,
ids: ghast_protocol::$v::$state::$dir::$set::IDS,
},
generate: |id, g| {
let p = ghast_protocol::$v::$state::$dir::$set::generate_by_id(id, g).unwrap();
(wire(&p), p.name())
},
reencode: reencode_impl::<ghast_protocol::$v::$state::$dir::$set>,
}
};
}
macro_rules! version_sets {
($v:ident) => {
[
set_ops!(
$v,
handshaking,
serverbound,
"toServer",
ServerboundHandshakingPacket
),
set_ops!($v, status, clientbound, "toClient", ClientboundStatusPacket),
set_ops!($v, status, serverbound, "toServer", ServerboundStatusPacket),
set_ops!($v, login, clientbound, "toClient", ClientboundLoginPacket),
set_ops!($v, login, serverbound, "toServer", ServerboundLoginPacket),
set_ops!($v, play, clientbound, "toClient", ClientboundPlayPacket),
set_ops!($v, play, serverbound, "toServer", ServerboundPlayPacket),
]
};
}
#[cfg(feature = "v1_8")]
#[test]
fn v1_8_rust_to_node() {
rust_to_node("1.8.9", &version_sets!(v1_8));
}
#[cfg(feature = "v1_8")]
#[test]
fn v1_8_node_to_rust() {
node_to_rust("1.8.9", &version_sets!(v1_8));
}
#[cfg(feature = "v1_16_5")]
#[test]
fn v1_16_5_rust_to_node() {
rust_to_node("1.16.5", &version_sets!(v1_16_5));
}
#[cfg(feature = "v1_16_5")]
#[test]
fn v1_16_5_node_to_rust() {
node_to_rust("1.16.5", &version_sets!(v1_16_5));
}
fn contains(actual: &Value, expected: &Value) -> bool {
match (actual, expected) {
(Value::Object(a), Value::Object(e)) => e
.iter()
.all(|(k, v)| a.get(k).is_some_and(|x| contains(x, v))),
(Value::Array(a), Value::Array(e)) => {
a.len() == e.len() && a.iter().zip(e).all(|(x, y)| contains(x, y))
}
(Value::Number(a), Value::Number(e)) => a.as_f64() == e.as_f64(),
(a, e) => a == e,
}
}
fn semantic(version: &str, cases: Vec<(&'static str, Vec<u8>, Value)>) {
if !node_available() {
return;
}
let requests: Vec<Value> = cases
.iter()
.map(|(_, bytes, _)| serde_json::json!({"op": "parse", "version": version, "state": "play", "dir": "toClient", "hex": hex(bytes)}))
.collect();
let resp = run_node(&format!("sem-{version}"), &requests);
let mut failures = Vec::new();
for ((name, bytes, expected), r) in cases.iter().zip(&resp) {
let ok = r["ok"].as_bool() == Some(true)
&& r["name"].as_str() == Some(name)
&& r["size"].as_u64() == Some(bytes.len() as u64)
&& contains(&r["params"], expected);
if !ok {
failures.push(format!("{name}: expected {expected}\n node: {r}"));
}
}
report(version, "semantic", cases.len(), &failures);
}
#[cfg(feature = "v1_8")]
#[test]
fn v1_8_semantic_values() {
use ghast_protocol::BlockPos;
use ghast_protocol::nbt::{Compound, List, NamedTag, RawNbt, Tag, tag};
use ghast_protocol::types::slot::legacy::{ItemStack, Slot};
use ghast_protocol::types::{ByteString, Uuid};
use ghast_protocol::v1_8::play::clientbound::{self as cb, ClientboundPlayPacket as P};
use ghast_protocol::v1_8::types::{
EntityMetadataEntry, EntityMetadataValue, EntityMetadataValueBlockPos,
};
use serde_json::json;
let uuid = Uuid::parse_str("0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0").unwrap();
let ench = NamedTag {
name: String::new(),
tag: Tag::Compound(Compound(vec![(
"ench".into(),
Tag::List(List {
elem_type: tag::COMPOUND,
items: vec![Tag::Compound(Compound(vec![
("id".into(), Tag::Short(16)),
("lvl".into(), Tag::Short(5)),
]))],
}),
)])),
};
let cases = vec![
(
"block_change",
wire(&P::BlockChange(cb::BlockChange {
location: BlockPos::new(-123456, 200, 98765),
r#type: 274,
})),
json!({"location": {"x": -123456, "y": 200, "z": 98765}, "type": 274}),
),
(
"named_entity_spawn",
wire(&P::NamedEntitySpawn(cb::NamedEntitySpawn {
entity_id: 7,
player_uuid: uuid,
x: -3200,
y: 2048,
z: 96,
yaw: -64,
pitch: 32,
current_item: 0,
metadata: vec![
EntityMetadataEntry {
r#type: 0,
key: 0,
value: EntityMetadataValue::Byte(8),
},
EntityMetadataEntry {
r#type: 3,
key: 6,
value: EntityMetadataValue::Float(20.0),
},
EntityMetadataEntry {
r#type: 4,
key: 2,
value: EntityMetadataValue::String(ByteString::from_static("Name")),
},
EntityMetadataEntry {
r#type: 6,
key: 20,
value: EntityMetadataValue::BlockPos(EntityMetadataValueBlockPos {
x: 1,
y: -2,
z: 3,
}),
},
],
})),
json!({
"entityId": 7, "playerUUID": "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0", "x": -3200, "y": 2048, "z": 96,
"yaw": -64, "pitch": 32, "currentItem": 0,
"metadata": [
{"type": 0, "key": 0, "value": 8},
{"type": 3, "key": 6, "value": 20.0},
{"type": 4, "key": 2, "value": "Name"},
{"type": 6, "key": 20, "value": {"x": 1, "y": -2, "z": 3}},
],
}),
),
(
"set_slot",
wire(&P::SetSlot(cb::SetSlot {
window_id: 0,
slot: 36,
item: Slot(Some(ItemStack {
id: 276,
count: 1,
damage: 3,
nbt: Some(RawNbt::from_named(&ench).unwrap()),
})),
})),
json!({"windowId": 0, "slot": 36, "item": {"blockId": 276, "itemCount": 1, "itemDamage": 3, "nbtData": {
"type": "compound", "name": "", "value": {"ench": {"type": "list", "value": {"type": "compound", "value": [
{"id": {"type": "short", "value": 16}, "lvl": {"type": "short", "value": 5}}
]}}}
}}}),
),
(
"player_info",
wire(&P::PlayerInfo(cb::PlayerInfo {
action: cb::PlayerInfoAction::ADD_PLAYER,
data: vec![cb::PlayerInfoData {
uuid,
body: cb::PlayerInfoDataBody::AddPlayer(cb::PlayerInfoDataBodyAddPlayer {
name: ByteString::from_static("Steve"),
properties: vec![cb::PlayerInfoDataBodyAddPlayerProperties {
name: ByteString::from_static("textures"),
value: ByteString::from_static("abc"),
signature: None,
}],
gamemode: 1,
ping: 42,
display_name: Some(ByteString::from_static("{\"text\":\"S\"}")),
}),
}],
})),
json!({"action": "add_player", "data": [{"name": "Steve", "properties": [{"name": "textures", "value": "abc"}],
"gamemode": 1, "ping": 42, "displayName": "{\"text\":\"S\"}"}]}),
),
(
"update_time",
wire(&P::UpdateTime(cb::UpdateTime {
age: -2,
time: 1 << 40,
})),
json!({"age": [-1, -2], "time": [256, 0]}),
),
(
"spawn_position",
wire(&P::SpawnPosition(cb::SpawnPosition {
location: BlockPos::new(-1, -2048, 33554431),
})),
json!({"location": {"x": -1, "y": -2048, "z": 33554431}}),
),
];
semantic("1.8.9", cases);
}
#[cfg(feature = "v1_16_5")]
#[test]
fn v1_16_5_semantic_values() {
use ghast_protocol::BlockPos;
use ghast_protocol::types::slot::modern::{ItemStack, Slot};
use ghast_protocol::v1_16_5::play::clientbound::{self as cb, ClientboundPlayPacket as P};
use ghast_protocol::v1_16_5::types::{EntityMetadataEntry, EntityMetadataValue};
use serde_json::json;
let cases = vec![
(
"block_change",
wire(&P::BlockChange(cb::BlockChange {
location: BlockPos::new(-123456, 200, 98765),
r#type: 9,
})),
json!({"location": {"x": -123456, "y": 200, "z": 98765}, "type": 9}),
),
(
"multi_block_change",
wire(&P::MultiBlockChange(cb::MultiBlockChange {
chunk_coordinates: cb::MultiBlockChangeChunkCoordinates { x: -5, z: 7, y: 3 },
not_trust_edges: true,
records: vec![(17 << 12) | 0x123],
})),
json!({"chunkCoordinates": {"x": -5, "z": 7, "y": 3}, "notTrustEdges": true, "records": [(17 << 12) | 0x123]}),
),
(
"keep_alive",
wire(&P::KeepAlive(cb::KeepAlive { keep_alive_id: -2 })),
json!({"keepAliveId": [-1, -2]}),
),
(
"set_slot",
wire(&P::SetSlot(cb::SetSlot {
window_id: 1,
slot: 5,
item: Slot(Some(ItemStack {
id: 600,
count: 3,
nbt: None,
})),
})),
json!({"windowId": 1, "slot": 5, "item": {"present": true, "itemId": 600, "itemCount": 3}}),
),
(
"entity_metadata",
wire(&P::EntityMetadata(cb::EntityMetadata {
entity_id: 5,
metadata: vec![
EntityMetadataEntry {
key: 0,
r#type: 0,
value: EntityMetadataValue::Byte(1),
},
EntityMetadataEntry {
key: 9,
r#type: 9,
value: EntityMetadataValue::Position(BlockPos::new(1, -2, 3)),
},
EntityMetadataEntry {
key: 6,
r#type: 18,
value: EntityMetadataValue::Pose(5),
},
],
})),
json!({"entityId": 5, "metadata": [
{"key": 0, "type": 0, "value": 1},
{"key": 9, "type": 9, "value": {"x": 1, "y": -2, "z": 3}},
{"key": 6, "type": 18, "value": 5},
]}),
),
];
semantic("1.16.5", cases);
}