From 84e34f61bd8642444a0dc183181ff95b77f59b06 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 11 Feb 2026 19:16:43 +0100 Subject: [PATCH] feat(client): add MessagePack library and cross-language fixtures Install Godot4MessagePack (pure GDScript) for MessagePack encode/decode. Add Rust fixture generator (gen_fixtures.rs) that produces canonical .msgpack files using rmp_serde::to_vec_named for cross-language testing. Fixtures cover: snapshots (empty, one NPC, multi-entity with all EntityKind variants) and player inputs (unit + data enum variants). Co-Authored-By: Claude Opus 4.6 --- client/addons/messagepack/LICENSE | 21 + client/addons/messagepack/messagepack.gd | 368 ++++++++++++++++++ client/addons/messagepack/messagepack.gd.uid | 1 + .../fixtures/msgpack/input_move_north.msgpack | 1 + .../msgpack/input_perception_mode.msgpack | 1 + .../fixtures/msgpack/snapshot_empty.msgpack | Bin 0 -> 17 bytes .../msgpack/snapshot_multi_entity.msgpack | Bin 0 -> 140 bytes .../fixtures/msgpack/snapshot_one_npc.msgpack | Bin 0 -> 55 bytes server/tests/gen_fixtures.rs | 63 +++ .../fixtures/msgpack/input_move_north.msgpack | 1 + .../msgpack/input_perception_mode.msgpack | 1 + tests/fixtures/msgpack/snapshot_empty.msgpack | Bin 0 -> 17 bytes .../msgpack/snapshot_multi_entity.msgpack | Bin 0 -> 140 bytes .../fixtures/msgpack/snapshot_one_npc.msgpack | Bin 0 -> 55 bytes 14 files changed, 457 insertions(+) create mode 100755 client/addons/messagepack/LICENSE create mode 100755 client/addons/messagepack/messagepack.gd create mode 100644 client/addons/messagepack/messagepack.gd.uid create mode 100644 client/tests/fixtures/msgpack/input_move_north.msgpack create mode 100644 client/tests/fixtures/msgpack/input_perception_mode.msgpack create mode 100644 client/tests/fixtures/msgpack/snapshot_empty.msgpack create mode 100644 client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack create mode 100644 client/tests/fixtures/msgpack/snapshot_one_npc.msgpack create mode 100644 server/tests/gen_fixtures.rs create mode 100644 tests/fixtures/msgpack/input_move_north.msgpack create mode 100644 tests/fixtures/msgpack/input_perception_mode.msgpack create mode 100644 tests/fixtures/msgpack/snapshot_empty.msgpack create mode 100644 tests/fixtures/msgpack/snapshot_multi_entity.msgpack create mode 100644 tests/fixtures/msgpack/snapshot_one_npc.msgpack diff --git a/client/addons/messagepack/LICENSE b/client/addons/messagepack/LICENSE new file mode 100755 index 000000000..6929b7c84 --- /dev/null +++ b/client/addons/messagepack/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Luis Chirlaque Hernández + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/client/addons/messagepack/messagepack.gd b/client/addons/messagepack/messagepack.gd new file mode 100755 index 000000000..3a7318db6 --- /dev/null +++ b/client/addons/messagepack/messagepack.gd @@ -0,0 +1,368 @@ +class_name Messagepack +## Messagepack implementation for Godot 4 in GDScript +## +## You can find the full spec at: https://github.com/msgpack/msgpack/blob/master/spec.md + +const FIRST3 = 0xe0 +const FIRST4 = 0xf0 +const LAST4 = 0x0f +const LAST5 = 0x1f + +const types = { + "nil": 0xc0, + "false": 0xc2, + "true": 0xc3, + "positive_fixint": [0x00, 0x7f], + "negative_fixint": [0xe0, 0xff], + "uint_8": 0xcc, + "uint_16": 0xcd, + "uint_32": 0xce, + "uint_64": 0xcf, + "int_8": 0xd0, + "int_16": 0xd1, + "int_32": 0xd2, + "int_64": 0xd3, + "float_32": 0xca, + "float_64": 0xcb, + "fixstr": [0xa0, 0xbf], + "str_8": 0xd9, + "str_16": 0xda, + "str_32": 0xdb, + "fixarray": [0x90, 0x9f], + "array_16": 0xdc, + "array_32": 0xdd, + "fixmap": [0x80, 0x8f], + "map_16": 0xde, + "map_32": 0xdf, + "bin_8": 0xc4, + "bin_16": 0xc5, + "bin_32": 0xc6 +} + +## This function takes a Variant and encodes it according to the Messagepack spec +## +## Parameters: +## - value: Variant to be encoded +## +## Returns: +## A dictionary containing the status of the encoding and the value as a PackedByteArray +static func encode(value) -> Dictionary: + var buffer = StreamPeerBuffer.new() + buffer.set_big_endian(true) + var err = _encode_message(buffer, value) + return { + value = buffer.data_array, + status = err + } + +static func _encode_message(buffer: StreamPeerBuffer, value): + match typeof(value): + TYPE_NIL: + buffer.put_u8(types["nil"]) + + TYPE_BOOL: + if value == true: + buffer.put_u8(types["true"]) + else: + buffer.put_u8(types["false"]) + + TYPE_INT: + if - (1 << 5) <= value and value <= (1 << 7) - 1: + buffer.put_8(value) + elif - (1 << 7) <= value and value <= (1 << 7): + buffer.put_u8(types["int_8"]) + buffer.put_8(value) + elif 0 <= value and value <= (1 << 8) - 1: + buffer.put_u8(types["uint_8"]) + buffer.put_u8(value) + elif - (1 << 15) <= value and value <= (1 << 15): + buffer.put_u8(types["int_16"]) + buffer.put_16(value) + elif 0 <= value and value <= (1 << 16) - 1: + buffer.put_u8(types["uint_16"]) + buffer.put_u16(value) + elif - (1 << 31) <= value and value <= (1 << 31): + buffer.put_u8(types["int_32"]) + buffer.put_32(value) + elif 0 <= value and value <= (1 << 32) - 1: + buffer.put_u8(types["uint_32"]) + buffer.put_u32(value) + elif - (1 << 63) <= value and value <= (1 << 63): + buffer.put_u8(types["int_64"]) + buffer.put_64(value) + else: + buffer.put_u8(types["uint_64"]) + buffer.put_u64(value) + + TYPE_FLOAT: + buffer.put_u8(types["float_32"]) + buffer.put_float(value) + + TYPE_STRING: + var bytes = value.to_utf8_buffer() + var size = bytes.size() + if size <= (1 << 5) - 1: + buffer.put_u8(types["fixstr"][0]|size) + elif size <= (1 << 8) - 1: + buffer.put_u8(types["str_8"]) + buffer.put_u8(size) + elif size <= (1 << 16) - 1: + buffer.put_u8(types["str_16"]) + buffer.put_u16(size) + elif size <= (1 << 32) - 1: + buffer.put_u8(types["str_32"]) + buffer.put_u32(size) + else: + printerr("Unsupported string: string is too big") + return ERR_INVALID_DATA + + buffer.put_data(bytes) + + TYPE_ARRAY: + var size = value.size() + if size <= 15: + buffer.put_u8(types["fixarray"][0]|size) + elif size <= (1 << 16) - 1: + buffer.put_u8(types["array_16"]) + buffer.put_u16(size) + elif size <= (1 << 32) - 1: + buffer.put_u8(types["array_32"]) + buffer.put_u32(size) + else: + printerr("Unsupported array: array is too long") + return ERR_INVALID_DATA + + for obj in value: + _encode_message(buffer, obj) + + TYPE_DICTIONARY: + var size = value.size() + if size <= 15: + buffer.put_u8(types["fixmap"][0]|size) + elif size <= (1 << 16) - 1: + buffer.put_u8(types["map_16"]) + buffer.put_u16(size) + elif size <= (1 << 32) - 1: + buffer.put_u8(types["map_32"]) + buffer.put_u32(size) + else: + printerr("Unsupported dictionary: dictionary is too big") + return ERR_INVALID_DATA + + for key in value: + _encode_message(buffer, key) + _encode_message(buffer, value[key]) + + TYPE_PACKED_BYTE_ARRAY: + var size = value.size() + if size <= (1 << 8) - 1: + buffer.put_u8(types["bin_8"]) + buffer.put_u8(size) + elif size <= (1 << 16) - 1: + buffer.put_u8(types["bin_16"]) + buffer.put_u16(size) + elif size <= (1 << 32) - 1: + buffer.put_u8(types["bin_32"]) + buffer.put_u32(size) + else: + printerr("Unsupported packed byte array: packed byte array is too big") + return ERR_INVALID_DATA + + buffer.put_data(value) + + _: + printerr("Unsupported data type: %s" % typeof(value)) + return ERR_UNAVAILABLE + + +## This function takes a PackedByteArray and decodes it according to the Messagepack spec +## +## Parameters: +## - bytes: PackedByteArray to be decoded +## +## Returns: +## A dictionary containing the status of the decoding and the value as Godot Variants +static func decode(bytes: PackedByteArray): + var buffer = StreamPeerBuffer.new() + buffer.set_big_endian(true) + buffer.set_data_array(bytes) + + var err = { + error = null + } + var message = _decode_message(buffer, err) + return { + value = message, + status = err.error + } + +static func _decode_message(buffer: StreamPeerBuffer, err: Dictionary): + var buffer_size = buffer.get_size() + var first_byte = buffer.get_u8() + + if first_byte & 0x80 == 0: # positive fixint + return first_byte + + elif first_byte & FIRST4 == 0x80: # fixmap + var size = first_byte & 0x0f + var dict = {} + for _x in range(size): + var key = _decode_message(buffer, err) + var val = _decode_message(buffer, err) + dict[key] = val + return dict + + elif first_byte & FIRST4 == 0x90: # fixarray + var size = first_byte & 0x0f + var array = [] + for _x in range(size): + var val = _decode_message(buffer, err) + array.append(val) + return array + + elif first_byte & FIRST3 == 0xa0: # fixstr + var size = first_byte & 0x1f + return buffer.get_utf8_string(size) + + elif first_byte == types["nil"]: # nil + print("null size:%s"%buffer_size) + return null + + elif first_byte == types["false"]: # false + print("false size:%s"%buffer_size) + return false + + elif first_byte == types["true"]: # true + return true + + elif first_byte == types["bin_8"]: # bin 8 + var length = buffer.get_u8() + return buffer.get_partial_data(length) + + elif first_byte == types["bin_16"]: # bin 16 + var length = buffer.get_u16() + return buffer.get_partial_data(length) + + elif first_byte == types["bin_32"]: # bin 32 + var length = buffer.get_u32() + return buffer.get_partial_data(length) + + elif first_byte == 0xc7: # ext 8 + print("Ext 8 type not implemented") + return null + + elif first_byte == 0xc8: # ext 16 + print("Ext 16 type not implemented") + return null + + elif first_byte == 0xc9: # ext 32 + print("Ext 32 type not implemented") + return null + + elif first_byte == types["float_32"]: # float 32 + return buffer.get_float() + + elif first_byte == types["float_64"]: # float 64 + return buffer.get_double() + + elif first_byte == types["uint_8"]: # uint 8 + return buffer.get_u8() + + elif first_byte == types["uint_16"]: # uint 16 + return buffer.get_u16() + + elif first_byte == types["uint_32"]: # uint 32 + return buffer.get_u32() + + elif first_byte == types["uint_64"]: # uint 64 + return buffer.get_u64() + + elif first_byte == types["int_8"]: # int 8 + return buffer.get_8() + + elif first_byte == types["int_16"]: # int 16 + return buffer.get_16() + + elif first_byte == types["int_32"]: # int 32 + return buffer.get_32() + + elif first_byte == types["int_64"]: # int 64 + return buffer.get_64() + + elif first_byte == 0xd4: # fixext 1 + print("Fixext 1 type not implemented") + err.error = ERR_UNAVAILABLE + return null + + elif first_byte == 0xd5: # fixext 2 + print("Fixext 2 type not implemented") + err.error = ERR_UNAVAILABLE + return null + + elif first_byte == 0xd6: # fixext 4 + print("Fixext 4 type not implemented") + err.error = ERR_UNAVAILABLE + return null + + elif first_byte == 0xd7: # fixext 8 + print("Fixext 8 type not implemented") + err.error = ERR_UNAVAILABLE + return null + + elif first_byte == 0xd8: # fixext 16 + print("Fixext 16 type not implemented") + err.error = ERR_UNAVAILABLE + return null + + elif first_byte == types["str_8"]: # str 8 + var size = buffer.get_u8() + return buffer.get_utf8_string(size) + + elif first_byte == types["str_16"]: # str 16 + var size = buffer.get_u16() + return buffer.get_utf8_string(size) + + elif first_byte == types["str_32"]: # str 32 + var size = buffer.get_u32() + return buffer.get_utf8_string(size) + + elif first_byte == types["array_16"]: # array 16 + var length = buffer.get_u16() + var array = [] + for _x in range(length): + var val = _decode_message(buffer, err) + array.append(val) + return array + + elif first_byte == types["array_32"]: # array 32 + var length = buffer.get_u32() + var array = [] + for _x in range(length): + var val = _decode_message(buffer, err) + array.append(val) + return array + + elif first_byte == types["map_16"]: # map 16 + var length = buffer.get_u16() + var dict = {} + for _x in range(length): + var key = _decode_message(buffer, err) + var val = _decode_message(buffer, err) + dict[key] = val + return dict + + elif first_byte == types["map_32"]: # map 32 + var length = buffer.get_u32() + var dict = {} + for _x in range(length): + var key = _decode_message(buffer, err) + var val = _decode_message(buffer, err) + dict[key] = val + return dict + + elif first_byte & FIRST3 == 0xe0: # negative fixint + return first_byte - 256 + else: + printerr("Unknown header") + err.error = ERR_UNAVAILABLE + return null diff --git a/client/addons/messagepack/messagepack.gd.uid b/client/addons/messagepack/messagepack.gd.uid new file mode 100644 index 000000000..f2186b8f0 --- /dev/null +++ b/client/addons/messagepack/messagepack.gd.uid @@ -0,0 +1 @@ +uid://komeseatyar0 diff --git a/client/tests/fixtures/msgpack/input_move_north.msgpack b/client/tests/fixtures/msgpack/input_move_north.msgpack new file mode 100644 index 000000000..937f385e1 --- /dev/null +++ b/client/tests/fixtures/msgpack/input_move_north.msgpack @@ -0,0 +1 @@ +‚¤tickd¦action©MoveNorth \ No newline at end of file diff --git a/client/tests/fixtures/msgpack/input_perception_mode.msgpack b/client/tests/fixtures/msgpack/input_perception_mode.msgpack new file mode 100644 index 000000000..3f922c7e3 --- /dev/null +++ b/client/tests/fixtures/msgpack/input_perception_mode.msgpack @@ -0,0 +1 @@ +‚¤tickÌȦaction±UsePerceptionMode§thermal \ No newline at end of file diff --git a/client/tests/fixtures/msgpack/snapshot_empty.msgpack b/client/tests/fixtures/msgpack/snapshot_empty.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..5435b2917bd5e065ec0af149fc763eb6025eb1a4 GIT binary patch literal 17 YcmZo#Qj(dR&9EXhuOzc1GqrdE07FCvZvX%Q literal 0 HcmV?d00001 diff --git a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..2c0217a4547eadf70ccbe33d72e3023431b87d0d GIT binary patch literal 140 zcmZo#Qj(dReU|z8iqyQ4%#zI1;>oQm!OY6|%oN6j6{j2)Ffc5vJmshWq^cN}WM}53 zEcPo%MpDHDROMI!R^{LTQpE^Xwah;$D>b(Ap;NqjRR9v|G^5Eholx2C1&OU E06`QvPXGV_ literal 0 HcmV?d00001 diff --git a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..f98303206327ef637ef23d4bada61349a76e1511 GIT binary patch literal 55 zcmZo#Qj(dRt+gUGuOzc1GqreP>q;=QGCnhfabd+NM+F9kg_Wlq7XYa$h9%jVc`1wi G3X%bojuylK literal 0 HcmV?d00001 diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs new file mode 100644 index 000000000..2f14af3a4 --- /dev/null +++ b/server/tests/gen_fixtures.rs @@ -0,0 +1,63 @@ +//! Generate MessagePack fixture files for cross-language testing (D-030 Layer 1). +//! Run with: cargo test --test gen_fixtures -- --ignored + +use settled_reach_server::bridge::types::*; +use std::fs; +use std::path::Path; + +fn write_fixture(name: &str, bytes: &[u8]) { + let dir = Path::new("../tests/fixtures/msgpack"); + fs::create_dir_all(dir).expect("create fixture dir"); + let path = dir.join(format!("{}.msgpack", name)); + fs::write(&path, bytes).expect("write fixture"); + eprintln!("Wrote {} ({} bytes)", path.display(), bytes.len()); +} + +#[test] +#[ignore] // Run manually: cargo test --test gen_fixtures -- --ignored +fn generate_msgpack_fixtures() { + // Snapshot with one NPC entity + let snapshot = ObserverSnapshot { + tick: 42, + entities: vec![VisibleEntity { + entity_id: 1, + x: 10.0, + y: 20.0, + z: 0, + kind: EntityKind::Npc, + }], + }; + write_fixture("snapshot_one_npc", &rmp_serde::to_vec_named(&snapshot).unwrap()); + + // Empty snapshot + let empty = ObserverSnapshot { + tick: 0, + entities: vec![], + }; + write_fixture("snapshot_empty", &rmp_serde::to_vec_named(&empty).unwrap()); + + // PlayerInput: MoveNorth + let input_north = PlayerInput { + tick: 100, + action: PlayerAction::MoveNorth, + }; + write_fixture("input_move_north", &rmp_serde::to_vec_named(&input_north).unwrap()); + + // PlayerInput: UsePerceptionMode + let input_perception = PlayerInput { + tick: 200, + action: PlayerAction::UsePerceptionMode("thermal".to_string()), + }; + write_fixture("input_perception_mode", &rmp_serde::to_vec_named(&input_perception).unwrap()); + + // Snapshot with multiple entities and all EntityKind variants + let snapshot_multi = ObserverSnapshot { + tick: 999, + entities: vec![ + VisibleEntity { entity_id: 1, x: 5.0, y: 10.0, z: 0, kind: EntityKind::Npc }, + VisibleEntity { entity_id: 2, x: 15.5, y: 3.0, z: 1, kind: EntityKind::Object }, + VisibleEntity { entity_id: 3, x: 0.0, y: 0.0, z: -1, kind: EntityKind::Terrain }, + ], + }; + write_fixture("snapshot_multi_entity", &rmp_serde::to_vec_named(&snapshot_multi).unwrap()); +} diff --git a/tests/fixtures/msgpack/input_move_north.msgpack b/tests/fixtures/msgpack/input_move_north.msgpack new file mode 100644 index 000000000..937f385e1 --- /dev/null +++ b/tests/fixtures/msgpack/input_move_north.msgpack @@ -0,0 +1 @@ +‚¤tickd¦action©MoveNorth \ No newline at end of file diff --git a/tests/fixtures/msgpack/input_perception_mode.msgpack b/tests/fixtures/msgpack/input_perception_mode.msgpack new file mode 100644 index 000000000..3f922c7e3 --- /dev/null +++ b/tests/fixtures/msgpack/input_perception_mode.msgpack @@ -0,0 +1 @@ +‚¤tickÌȦaction±UsePerceptionMode§thermal \ No newline at end of file diff --git a/tests/fixtures/msgpack/snapshot_empty.msgpack b/tests/fixtures/msgpack/snapshot_empty.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..5435b2917bd5e065ec0af149fc763eb6025eb1a4 GIT binary patch literal 17 YcmZo#Qj(dR&9EXhuOzc1GqrdE07FCvZvX%Q literal 0 HcmV?d00001 diff --git a/tests/fixtures/msgpack/snapshot_multi_entity.msgpack b/tests/fixtures/msgpack/snapshot_multi_entity.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..2c0217a4547eadf70ccbe33d72e3023431b87d0d GIT binary patch literal 140 zcmZo#Qj(dReU|z8iqyQ4%#zI1;>oQm!OY6|%oN6j6{j2)Ffc5vJmshWq^cN}WM}53 zEcPo%MpDHDROMI!R^{LTQpE^Xwah;$D>b(Ap;NqjRR9v|G^5Eholx2C1&OU E06`QvPXGV_ literal 0 HcmV?d00001 diff --git a/tests/fixtures/msgpack/snapshot_one_npc.msgpack b/tests/fixtures/msgpack/snapshot_one_npc.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..f98303206327ef637ef23d4bada61349a76e1511 GIT binary patch literal 55 zcmZo#Qj(dRt+gUGuOzc1GqreP>q;=QGCnhfabd+NM+F9kg_Wlq7XYa$h9%jVc`1wi G3X%bojuylK literal 0 HcmV?d00001