styly-netsync-server 0.13.0__tar.gz → 0.15.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/PKG-INFO +11 -8
  2. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/README.md +10 -7
  3. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/pyproject.toml +1 -1
  4. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/src/styly_netsync/__init__.py +1 -1
  5. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/src/styly_netsync/binary_serializer.py +49 -33
  6. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/src/styly_netsync/client.py +116 -73
  7. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/src/styly_netsync/client_simulator.py +101 -17
  8. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/src/styly_netsync/config.py +52 -4
  9. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/src/styly_netsync/default.toml +7 -1
  10. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/src/styly_netsync/rest_bridge.py +37 -6
  11. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/src/styly_netsync/server.py +481 -252
  12. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/src/styly_netsync_server.egg-info/PKG-INFO +11 -8
  13. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/src/styly_netsync_server.egg-info/SOURCES.txt +3 -1
  14. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/tests/test_binary_serializer.py +33 -7
  15. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/tests/test_client_variable_device_store.py +16 -13
  16. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/tests/test_config.py +52 -9
  17. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/tests/test_discovery_probe.py +1 -1
  18. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/tests/test_multi_nic.py +35 -4
  19. styly_netsync_server-0.15.0/tests/test_nv_server_ordering.py +147 -0
  20. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/tests/test_object_sync.py +9 -2
  21. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/tests/test_python_client.py +98 -0
  22. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/tests/test_reconnect_identity.py +3 -3
  23. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/tests/test_stealth_heartbeat.py +20 -0
  24. styly_netsync_server-0.15.0/tests/test_transport_split.py +330 -0
  25. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/LICENSE +0 -0
  26. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/setup.cfg +0 -0
  27. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/src/styly_netsync/__main__.py +0 -0
  28. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/src/styly_netsync/adapters.py +0 -0
  29. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/src/styly_netsync/cli.py +0 -0
  30. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/src/styly_netsync/events.py +0 -0
  31. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/src/styly_netsync/logging_utils.py +0 -0
  32. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/src/styly_netsync/network_utils.py +0 -0
  33. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/src/styly_netsync/nv_sync.py +0 -0
  34. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/src/styly_netsync/types.py +0 -0
  35. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/src/styly_netsync_server.egg-info/dependency_links.txt +0 -0
  36. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/src/styly_netsync_server.egg-info/entry_points.txt +0 -0
  37. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/src/styly_netsync_server.egg-info/requires.txt +0 -0
  38. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/src/styly_netsync_server.egg-info/top_level.txt +0 -0
  39. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/tests/test_all_run_methods.py +0 -0
  40. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/tests/test_logging_cli.py +0 -0
  41. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/tests/test_nv_protocol.py +0 -0
  42. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/tests/test_port_error_message.py +0 -0
  43. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/tests/test_rest_bridge.py +0 -0
  44. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/tests/test_room_expiry.py +0 -0
  45. {styly_netsync_server-0.13.0 → styly_netsync_server-0.15.0}/tests/test_timing_monotonic.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: styly-netsync-server
3
- Version: 0.13.0
3
+ Version: 0.15.0
4
4
  Summary: STYLY NetSync Server - Multiplayer framework for Location-Based Entertainment VR/MR experiences
5
5
  Author-email: "STYLY, Inc." <info@styly.inc>
6
6
  License-Expression: Apache-2.0
@@ -81,13 +81,16 @@ styly-netsync-simulator --server tcp://localhost --room my_room --clients 50
81
81
 
82
82
  ## Wire protocol compatibility
83
83
 
84
- - Current transform wire protocol is `protocolVersion = 5`.
85
- - Transform messages use `MSG_CLIENT_POSE` (11) and `MSG_ROOM_POSE` (12) with the compact V5 pose body.
86
- - v5 adds an optional `MovingFloorLocal` pose flag. Bound avatars send head, hands, and virtual transforms in the registered moving floor's local coordinates, and reuse the existing 8-byte physical slot as direct physical position/yaw.
87
- - Unbound v5 poses keep the v4 `xrOriginDelta` semantics: `xrOriginDelta` carries a Y component as a 4th `int16` (`dx, dy, dz, dyaw` = 8 bytes vs. v3's 6), so receivers can reconstruct the sender's rig-Y motion.
84
+ - Current wire protocol is `protocolVersion = 7`.
85
+ - Transport uses three sockets: control (`control_port`, default `5555`) for RPC, Network Variables, ownership, ID mapping, and client hello; transform uplink (`transform_port`, default `5557`) for client/object poses; PUB/SUB (`pub_port`, default `5556`) for room pose and room object downlink.
86
+ - Discovery responses use `STYLY-NETSYNC2|controlPort|transformPort|pubPort|serverName`; legacy `STYLY-NETSYNC|...` responses are explicitly incompatible.
87
+ - `dealer_port` / `--dealer-port` remain a one-release compatibility alias for `control_port` / `--control-port`.
88
+ - Transform messages use `MSG_CLIENT_POSE` (11) and `MSG_ROOM_POSE` (12) with the compact pose body. Clients register control identity with `MSG_CLIENT_HELLO` (19).
89
+ - Moving-floor-local poses set the `MovingFloorLocal` pose flag. Bound avatars send head, hands, and virtual transforms in the registered moving floor's local coordinates, and reuse the existing 8-byte physical slot as direct physical position/yaw.
90
+ - Unbound poses keep the `xrOriginDelta` semantics: `xrOriginDelta` carries a Y component as a 4th `int16` (`dx, dy, dz, dyaw` = 8 bytes), so receivers can reconstruct the sender's rig-Y motion.
88
91
  - Legacy transform protocols (v2/v3) and JSON transform fallback are not supported.
89
92
  - Deploy Unity and Python updates together when changing transform protocol behavior.
90
- - Protocol v5 position quantization ranges:
93
+ - Protocol v7 position quantization ranges:
91
94
  - Absolute (`headPosAbs` only): signed `int24` at `0.01 m` per unit, per-axis range `[-83,886.08 m, 83,886.07 m]`.
92
95
  - XROrigin locomotion delta for unbound poses (`xrOriginDelta`, 4×`int16`: `dx, dy, dz, dyaw`): `0.01 m` per unit for translation, `0.1°` for yaw. Receivers reconstruct `physicalPos = invDeltaRot * (headPos − deltaPos)`; it is not on the wire as a separate absolute field.
93
96
  - Direct physical payload for moving-floor-local poses (`physical`, 4×`int16`: `x, y, z, yaw`): `0.01 m` per unit for translation, `0.1°` for yaw.
@@ -98,7 +101,7 @@ styly-netsync-simulator --server tcp://localhost --room my_room --clients 50
98
101
 
99
102
  The following options summarize trade-offs when expanding absolute-position range.
100
103
 
101
- Assumed unbound baseline (`protocolVersion=5`, `MovingFloorLocal` off):
104
+ Assumed unbound baseline (`protocolVersion=7`, `MovingFloorLocal` off):
102
105
  - Client pose body with `Physical+Head+Right+Left` valid and `virtualCount=0`: `46 bytes` (matches `test_client_body_size_with_full_pose_no_virtuals`).
103
106
  - Room per-client entry (`clientNo + poseTime + clientBody`): `56 bytes`.
104
107
 
@@ -267,4 +270,4 @@ The response includes the room ID and whether each key was `"applied"`, `"queued
267
270
 
268
271
  ### Read consistency
269
272
 
270
- GET endpoints return a snapshot of the REST bridge's in-process cache, which is populated by PUB-SUB broadcasts from the server. The first request to a room lazily creates a bridge and may return an empty snapshot until the initial broadcasts arrive — retry after a short delay if needed.
273
+ GET endpoints return a snapshot of the REST bridge's in-process cache, which is populated by control-lane sync messages from the server. The first request to a room lazily creates a bridge and may return an empty snapshot until the initial sync arrives — retry after a short delay if needed.
@@ -42,13 +42,16 @@ styly-netsync-simulator --server tcp://localhost --room my_room --clients 50
42
42
 
43
43
  ## Wire protocol compatibility
44
44
 
45
- - Current transform wire protocol is `protocolVersion = 5`.
46
- - Transform messages use `MSG_CLIENT_POSE` (11) and `MSG_ROOM_POSE` (12) with the compact V5 pose body.
47
- - v5 adds an optional `MovingFloorLocal` pose flag. Bound avatars send head, hands, and virtual transforms in the registered moving floor's local coordinates, and reuse the existing 8-byte physical slot as direct physical position/yaw.
48
- - Unbound v5 poses keep the v4 `xrOriginDelta` semantics: `xrOriginDelta` carries a Y component as a 4th `int16` (`dx, dy, dz, dyaw` = 8 bytes vs. v3's 6), so receivers can reconstruct the sender's rig-Y motion.
45
+ - Current wire protocol is `protocolVersion = 7`.
46
+ - Transport uses three sockets: control (`control_port`, default `5555`) for RPC, Network Variables, ownership, ID mapping, and client hello; transform uplink (`transform_port`, default `5557`) for client/object poses; PUB/SUB (`pub_port`, default `5556`) for room pose and room object downlink.
47
+ - Discovery responses use `STYLY-NETSYNC2|controlPort|transformPort|pubPort|serverName`; legacy `STYLY-NETSYNC|...` responses are explicitly incompatible.
48
+ - `dealer_port` / `--dealer-port` remain a one-release compatibility alias for `control_port` / `--control-port`.
49
+ - Transform messages use `MSG_CLIENT_POSE` (11) and `MSG_ROOM_POSE` (12) with the compact pose body. Clients register control identity with `MSG_CLIENT_HELLO` (19).
50
+ - Moving-floor-local poses set the `MovingFloorLocal` pose flag. Bound avatars send head, hands, and virtual transforms in the registered moving floor's local coordinates, and reuse the existing 8-byte physical slot as direct physical position/yaw.
51
+ - Unbound poses keep the `xrOriginDelta` semantics: `xrOriginDelta` carries a Y component as a 4th `int16` (`dx, dy, dz, dyaw` = 8 bytes), so receivers can reconstruct the sender's rig-Y motion.
49
52
  - Legacy transform protocols (v2/v3) and JSON transform fallback are not supported.
50
53
  - Deploy Unity and Python updates together when changing transform protocol behavior.
51
- - Protocol v5 position quantization ranges:
54
+ - Protocol v7 position quantization ranges:
52
55
  - Absolute (`headPosAbs` only): signed `int24` at `0.01 m` per unit, per-axis range `[-83,886.08 m, 83,886.07 m]`.
53
56
  - XROrigin locomotion delta for unbound poses (`xrOriginDelta`, 4×`int16`: `dx, dy, dz, dyaw`): `0.01 m` per unit for translation, `0.1°` for yaw. Receivers reconstruct `physicalPos = invDeltaRot * (headPos − deltaPos)`; it is not on the wire as a separate absolute field.
54
57
  - Direct physical payload for moving-floor-local poses (`physical`, 4×`int16`: `x, y, z, yaw`): `0.01 m` per unit for translation, `0.1°` for yaw.
@@ -59,7 +62,7 @@ styly-netsync-simulator --server tcp://localhost --room my_room --clients 50
59
62
 
60
63
  The following options summarize trade-offs when expanding absolute-position range.
61
64
 
62
- Assumed unbound baseline (`protocolVersion=5`, `MovingFloorLocal` off):
65
+ Assumed unbound baseline (`protocolVersion=7`, `MovingFloorLocal` off):
63
66
  - Client pose body with `Physical+Head+Right+Left` valid and `virtualCount=0`: `46 bytes` (matches `test_client_body_size_with_full_pose_no_virtuals`).
64
67
  - Room per-client entry (`clientNo + poseTime + clientBody`): `56 bytes`.
65
68
 
@@ -228,4 +231,4 @@ The response includes the room ID and whether each key was `"applied"`, `"queued
228
231
 
229
232
  ### Read consistency
230
233
 
231
- GET endpoints return a snapshot of the REST bridge's in-process cache, which is populated by PUB-SUB broadcasts from the server. The first request to a room lazily creates a bridge and may return an empty snapshot until the initial broadcasts arrive — retry after a short delay if needed.
234
+ GET endpoints return a snapshot of the REST bridge's in-process cache, which is populated by control-lane sync messages from the server. The first request to a room lazily creates a bridge and may return an empty snapshot until the initial sync arrives — retry after a short delay if needed.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "styly-netsync-server"
7
- version = "0.13.0"
7
+ version = "0.15.0"
8
8
  description = "STYLY NetSync Server - Multiplayer framework for Location-Based Entertainment VR/MR experiences"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -16,7 +16,7 @@ Examples:
16
16
 
17
17
  # Use server programmatically
18
18
  from styly_netsync import NetSyncServer
19
- server = NetSyncServer(dealer_port=5555, pub_port=5556)
19
+ server = NetSyncServer(control_port=5555, transform_port=5557, pub_port=5556)
20
20
  server.start()
21
21
 
22
22
  # Use client programmatically
@@ -6,7 +6,11 @@ from typing import Any
6
6
  logger = logging.getLogger(__name__)
7
7
 
8
8
  # Message type identifiers
9
- PROTOCOL_VERSION = 5
9
+ # v7: Control and transform traffic use separate sockets, and clients register
10
+ # their control identity with MSG_CLIENT_HELLO.
11
+ # Bumped so mixed old/new builds fail the handshake instead of silently
12
+ # misparsing NV traffic (the version byte rides on transform/object messages).
13
+ PROTOCOL_VERSION = 7
10
14
  MSG_CLIENT_TRANSFORM = 1
11
15
  MSG_ROOM_TRANSFORM = 2 # Legacy room transform with short IDs only
12
16
  MSG_RPC = 3 # Remote procedure call
@@ -25,6 +29,9 @@ MSG_OBJECT_OWNERSHIP_REQUEST = 15 # Client → Server: RequestOwnership/Release
25
29
  MSG_OBJECT_OWNERSHIP_CHANGED = 16 # Server → Clients (ROUTER): ownership changed
26
30
  MSG_OBJECT_OWNERSHIP_REJECTED = 17 # Server → Client (ROUTER): request rejected
27
31
  MSG_CLIENT_VAR_CLEAR = 18 # Clear all client variables for the sender
32
+ MSG_CLIENT_HELLO = 19 # Client → Server: bind control identity to device ID
33
+
34
+ CLIENT_HELLO_FLAG_STEALTH = 0x01
28
35
 
29
36
  # Transform data type identifiers (deprecated - kept for reference)
30
37
 
@@ -33,7 +40,7 @@ MSG_CLIENT_VAR_CLEAR = 18 # Clear all client variables for the sender
33
40
  _max_virtual_transforms = 50
34
41
  MAX_VIRTUAL_TRANSFORMS = _max_virtual_transforms # Legacy alias for backward compat
35
42
 
36
- # Protocol v5 transform encoding constants
43
+ # Protocol v7 pose encoding constants
37
44
  ABS_POS_SCALE = 0.01
38
45
  LOCO_POS_SCALE = 0.01
39
46
  REL_POS_SCALE = 0.005
@@ -629,6 +636,17 @@ def serialize_rpc_message(data: dict[str, Any]) -> bytes:
629
636
  return bytes(buffer)
630
637
 
631
638
 
639
+ def serialize_client_hello(device_id: str, is_stealth: bool = False) -> bytes:
640
+ """Serialize client hello for control-lane identity registration."""
641
+ buffer = bytearray()
642
+ buffer.append(MSG_CLIENT_HELLO)
643
+ buffer.append(PROTOCOL_VERSION)
644
+ flags = CLIENT_HELLO_FLAG_STEALTH if is_stealth else 0
645
+ buffer.append(flags)
646
+ _pack_string(buffer, device_id)
647
+ return bytes(buffer)
648
+
649
+
632
650
  def parse_version(version_str: str) -> tuple[int, int, int]:
633
651
  """Parse semantic version string into (major, minor, patch) tuple.
634
652
 
@@ -686,7 +704,7 @@ def serialize_global_var_set(data: dict[str, Any]) -> bytes:
686
704
  """Serialize global variable set message
687
705
 
688
706
  Args:
689
- data: Dictionary with senderClientNo, variableName, variableValue, timestamp
707
+ data: Dictionary with senderClientNo, variableName, variableValue
690
708
  """
691
709
  buffer = bytearray()
692
710
 
@@ -704,9 +722,6 @@ def serialize_global_var_set(data: dict[str, Any]) -> bytes:
704
722
  value = data.get("variableValue", "")[:1024]
705
723
  _pack_string(buffer, value, use_ushort=True)
706
724
 
707
- # Timestamp (8 bytes double)
708
- buffer.extend(struct.pack("<d", data.get("timestamp", 0.0)))
709
-
710
725
  return bytes(buffer)
711
726
 
712
727
 
@@ -729,7 +744,6 @@ def serialize_global_var_sync(data: dict[str, Any]) -> bytes:
729
744
  for var in variables:
730
745
  _pack_string(buffer, var.get("name", "")[:64])
731
746
  _pack_string(buffer, var.get("value", "")[:1024], use_ushort=True)
732
- buffer.extend(struct.pack("<d", var.get("timestamp", 0.0)))
733
747
  buffer.extend(struct.pack("<H", var.get("lastWriterClientNo", 0)))
734
748
 
735
749
  return bytes(buffer)
@@ -739,7 +753,8 @@ def serialize_client_var_set(data: dict[str, Any]) -> bytes:
739
753
  """Serialize client variable set message
740
754
 
741
755
  Args:
742
- data: Dictionary with senderClientNo, targetClientNo, variableName, variableValue, timestamp
756
+ data: Dictionary with senderClientNo, targetClientNo, variableName,
757
+ variableValue
743
758
  """
744
759
  buffer = bytearray()
745
760
 
@@ -760,9 +775,6 @@ def serialize_client_var_set(data: dict[str, Any]) -> bytes:
760
775
  value = data.get("variableValue", "")[:1024]
761
776
  _pack_string(buffer, value, use_ushort=True)
762
777
 
763
- # Timestamp (8 bytes double)
764
- buffer.extend(struct.pack("<d", data.get("timestamp", 0.0)))
765
-
766
778
  return bytes(buffer)
767
779
 
768
780
 
@@ -770,7 +782,7 @@ def serialize_client_var_clear(data: dict[str, Any]) -> bytes:
770
782
  """Serialize client variable clear message.
771
783
 
772
784
  Args:
773
- data: Dictionary with senderClientNo and timestamp.
785
+ data: Dictionary with senderClientNo.
774
786
  """
775
787
  buffer = bytearray()
776
788
 
@@ -780,9 +792,6 @@ def serialize_client_var_clear(data: dict[str, Any]) -> bytes:
780
792
  # Sender client number (2 bytes)
781
793
  buffer.extend(struct.pack("<H", data.get("senderClientNo", 0)))
782
794
 
783
- # Timestamp (8 bytes double)
784
- buffer.extend(struct.pack("<d", data.get("timestamp", 0.0)))
785
-
786
795
  return bytes(buffer)
787
796
 
788
797
 
@@ -811,7 +820,6 @@ def serialize_client_var_sync(data: dict[str, Any]) -> bytes:
811
820
  for var in variables:
812
821
  _pack_string(buffer, var.get("name", "")[:64])
813
822
  _pack_string(buffer, var.get("value", "")[:1024], use_ushort=True)
814
- buffer.extend(struct.pack("<d", var.get("timestamp", 0.0)))
815
823
  buffer.extend(struct.pack("<H", var.get("lastWriterClientNo", 0)))
816
824
 
817
825
  return bytes(buffer)
@@ -832,7 +840,7 @@ def deserialize(data: bytes) -> tuple[int, dict[str, Any] | None, bytes]:
832
840
  offset += 1
833
841
 
834
842
  # Validate message type is within valid range
835
- if message_type < MSG_CLIENT_TRANSFORM or message_type > MSG_CLIENT_VAR_CLEAR:
843
+ if message_type < MSG_CLIENT_TRANSFORM or message_type > MSG_CLIENT_HELLO:
836
844
  # Return invalid message type with None data instead of raising exception
837
845
  return message_type, None, b""
838
846
 
@@ -863,6 +871,8 @@ def deserialize(data: bytes) -> tuple[int, dict[str, Any] | None, bytes]:
863
871
  return message_type, _deserialize_client_var_sync(data, offset), b""
864
872
  elif message_type == MSG_CLIENT_VAR_CLEAR:
865
873
  return message_type, _deserialize_client_var_clear(data, offset), b""
874
+ elif message_type == MSG_CLIENT_HELLO:
875
+ return message_type, _deserialize_client_hello(data, offset), b""
866
876
  elif message_type == MSG_OBJECT_POSE:
867
877
  return message_type, _deserialize_object_pose(data, offset), b""
868
878
  elif message_type == MSG_ROOM_OBJECTS:
@@ -897,6 +907,22 @@ def deserialize(data: bytes) -> tuple[int, dict[str, Any] | None, bytes]:
897
907
  return message_type, None, b""
898
908
 
899
909
 
910
+ def _deserialize_client_hello(data: bytes, offset: int) -> dict[str, Any]:
911
+ """Deserialize client hello control message."""
912
+ protocol_version = data[offset]
913
+ offset += 1
914
+ if protocol_version != PROTOCOL_VERSION:
915
+ raise ValueError(f"Unsupported protocol version: {protocol_version}")
916
+ flags = data[offset]
917
+ offset += 1
918
+ device_id, offset = _unpack_string(data, offset)
919
+ return {
920
+ "deviceId": device_id,
921
+ "flags": flags,
922
+ "isStealthMode": bool(flags & CLIENT_HELLO_FLAG_STEALTH),
923
+ }
924
+
925
+
900
926
  def _deserialize_client_body(data: bytes, offset: int) -> tuple[dict[str, Any], int]:
901
927
  """Deserialize protocol v5 compact pose body."""
902
928
  result: dict[str, Any] = {}
@@ -1247,10 +1273,6 @@ def _deserialize_global_var_set(data: bytes, offset: int) -> dict[str, Any]:
1247
1273
  # Variable value
1248
1274
  result["variableValue"], offset = _unpack_string(data, offset, use_ushort=True)
1249
1275
 
1250
- # Timestamp (8 bytes double)
1251
- result["timestamp"] = struct.unpack("<d", data[offset : offset + 8])[0]
1252
- offset += 8
1253
-
1254
1276
  return result
1255
1277
 
1256
1278
 
@@ -1267,8 +1289,6 @@ def _deserialize_global_var_sync(data: bytes, offset: int) -> dict[str, Any]:
1267
1289
  var = {}
1268
1290
  var["name"], offset = _unpack_string(data, offset)
1269
1291
  var["value"], offset = _unpack_string(data, offset, use_ushort=True)
1270
- var["timestamp"] = struct.unpack("<d", data[offset : offset + 8])[0]
1271
- offset += 8
1272
1292
  var["lastWriterClientNo"] = struct.unpack("<H", data[offset : offset + 2])[0]
1273
1293
  offset += 2
1274
1294
  result["variables"].append(var)
@@ -1294,10 +1314,6 @@ def _deserialize_client_var_set(data: bytes, offset: int) -> dict[str, Any]:
1294
1314
  # Variable value
1295
1315
  result["variableValue"], offset = _unpack_string(data, offset, use_ushort=True)
1296
1316
 
1297
- # Timestamp (8 bytes double)
1298
- result["timestamp"] = struct.unpack("<d", data[offset : offset + 8])[0]
1299
- offset += 8
1300
-
1301
1317
  return result
1302
1318
 
1303
1319
 
@@ -1307,10 +1323,6 @@ def _deserialize_client_var_clear(data: bytes, offset: int) -> dict[str, Any]:
1307
1323
 
1308
1324
  # Sender client number (2 bytes)
1309
1325
  result["senderClientNo"] = struct.unpack("<H", data[offset : offset + 2])[0]
1310
- offset += 2
1311
-
1312
- # Timestamp (8 bytes double)
1313
- result["timestamp"] = struct.unpack("<d", data[offset : offset + 8])[0]
1314
1326
 
1315
1327
  return result
1316
1328
 
@@ -1336,8 +1348,6 @@ def _deserialize_client_var_sync(data: bytes, offset: int) -> dict[str, Any]:
1336
1348
  var = {}
1337
1349
  var["name"], offset = _unpack_string(data, offset)
1338
1350
  var["value"], offset = _unpack_string(data, offset, use_ushort=True)
1339
- var["timestamp"] = struct.unpack("<d", data[offset : offset + 8])[0]
1340
- offset += 8
1341
1351
  var["lastWriterClientNo"] = struct.unpack("<H", data[offset : offset + 2])[
1342
1352
  0
1343
1353
  ]
@@ -1359,6 +1369,10 @@ def serialize_object_pose(data: dict[str, Any]) -> bytes:
1359
1369
  buffer = bytearray()
1360
1370
  buffer.append(MSG_OBJECT_POSE)
1361
1371
  buffer.append(PROTOCOL_VERSION)
1372
+ # Sender device ID: lets the server attribute the pose by identity carried in
1373
+ # the payload rather than the transform-lane socket identity (which a stealth
1374
+ # owner never binds because it sends no MSG_CLIENT_POSE).
1375
+ _pack_string(buffer, str(data.get("deviceId", "")))
1362
1376
  object_id = int(data.get("objectId", 0)) & 0xFFFFFFFF
1363
1377
  buffer.extend(struct.pack("<I", object_id))
1364
1378
  buffer.extend(struct.pack("<H", int(data.get("poseSeq", 0)) & 0xFFFF))
@@ -1456,6 +1470,8 @@ def _deserialize_object_pose(data: bytes, offset: int) -> dict[str, Any]:
1456
1470
  offset += 1
1457
1471
  if protocol_version != PROTOCOL_VERSION:
1458
1472
  raise ValueError(f"Unsupported protocol version: {protocol_version}")
1473
+ device_id, offset = _unpack_string(data, offset)
1474
+ result["deviceId"] = device_id
1459
1475
  result["objectId"] = struct.unpack("<I", data[offset : offset + 4])[0]
1460
1476
  offset += 4
1461
1477
  result["poseSeq"] = struct.unpack("<H", data[offset : offset + 2])[0]