styly-netsync-server 0.14.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.14.0 → styly_netsync_server-0.15.0}/PKG-INFO +11 -8
  2. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/README.md +10 -7
  3. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/pyproject.toml +1 -1
  4. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync/__init__.py +1 -1
  5. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync/binary_serializer.py +43 -4
  6. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync/client.py +116 -70
  7. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync/client_simulator.py +101 -16
  8. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync/config.py +52 -4
  9. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync/default.toml +7 -1
  10. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync/rest_bridge.py +37 -6
  11. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync/server.py +417 -193
  12. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync_server.egg-info/PKG-INFO +11 -8
  13. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync_server.egg-info/SOURCES.txt +2 -1
  14. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/tests/test_binary_serializer.py +32 -6
  15. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/tests/test_client_variable_device_store.py +7 -2
  16. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/tests/test_config.py +52 -9
  17. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/tests/test_discovery_probe.py +1 -1
  18. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/tests/test_multi_nic.py +35 -4
  19. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/tests/test_object_sync.py +8 -2
  20. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/tests/test_python_client.py +98 -0
  21. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/tests/test_reconnect_identity.py +3 -3
  22. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/tests/test_stealth_heartbeat.py +20 -0
  23. styly_netsync_server-0.15.0/tests/test_transport_split.py +330 -0
  24. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/LICENSE +0 -0
  25. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/setup.cfg +0 -0
  26. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync/__main__.py +0 -0
  27. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync/adapters.py +0 -0
  28. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync/cli.py +0 -0
  29. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync/events.py +0 -0
  30. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync/logging_utils.py +0 -0
  31. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync/network_utils.py +0 -0
  32. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync/nv_sync.py +0 -0
  33. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync/types.py +0 -0
  34. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync_server.egg-info/dependency_links.txt +0 -0
  35. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync_server.egg-info/entry_points.txt +0 -0
  36. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync_server.egg-info/requires.txt +0 -0
  37. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync_server.egg-info/top_level.txt +0 -0
  38. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/tests/test_all_run_methods.py +0 -0
  39. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/tests/test_logging_cli.py +0 -0
  40. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/tests/test_nv_protocol.py +0 -0
  41. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/tests/test_nv_server_ordering.py +0 -0
  42. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/tests/test_port_error_message.py +0 -0
  43. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/tests/test_rest_bridge.py +0 -0
  44. {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/tests/test_room_expiry.py +0 -0
  45. {styly_netsync_server-0.14.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.14.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.14.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,10 +6,11 @@ from typing import Any
6
6
  logger = logging.getLogger(__name__)
7
7
 
8
8
  # Message type identifiers
9
- # v6: Network Variable wire messages dropped the per-write timestamp field.
9
+ # v7: Control and transform traffic use separate sockets, and clients register
10
+ # their control identity with MSG_CLIENT_HELLO.
10
11
  # Bumped so mixed old/new builds fail the handshake instead of silently
11
12
  # misparsing NV traffic (the version byte rides on transform/object messages).
12
- PROTOCOL_VERSION = 6
13
+ PROTOCOL_VERSION = 7
13
14
  MSG_CLIENT_TRANSFORM = 1
14
15
  MSG_ROOM_TRANSFORM = 2 # Legacy room transform with short IDs only
15
16
  MSG_RPC = 3 # Remote procedure call
@@ -28,6 +29,9 @@ MSG_OBJECT_OWNERSHIP_REQUEST = 15 # Client → Server: RequestOwnership/Release
28
29
  MSG_OBJECT_OWNERSHIP_CHANGED = 16 # Server → Clients (ROUTER): ownership changed
29
30
  MSG_OBJECT_OWNERSHIP_REJECTED = 17 # Server → Client (ROUTER): request rejected
30
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
31
35
 
32
36
  # Transform data type identifiers (deprecated - kept for reference)
33
37
 
@@ -36,7 +40,7 @@ MSG_CLIENT_VAR_CLEAR = 18 # Clear all client variables for the sender
36
40
  _max_virtual_transforms = 50
37
41
  MAX_VIRTUAL_TRANSFORMS = _max_virtual_transforms # Legacy alias for backward compat
38
42
 
39
- # Protocol v5 transform encoding constants
43
+ # Protocol v7 pose encoding constants
40
44
  ABS_POS_SCALE = 0.01
41
45
  LOCO_POS_SCALE = 0.01
42
46
  REL_POS_SCALE = 0.005
@@ -632,6 +636,17 @@ def serialize_rpc_message(data: dict[str, Any]) -> bytes:
632
636
  return bytes(buffer)
633
637
 
634
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
+
635
650
  def parse_version(version_str: str) -> tuple[int, int, int]:
636
651
  """Parse semantic version string into (major, minor, patch) tuple.
637
652
 
@@ -825,7 +840,7 @@ def deserialize(data: bytes) -> tuple[int, dict[str, Any] | None, bytes]:
825
840
  offset += 1
826
841
 
827
842
  # Validate message type is within valid range
828
- 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:
829
844
  # Return invalid message type with None data instead of raising exception
830
845
  return message_type, None, b""
831
846
 
@@ -856,6 +871,8 @@ def deserialize(data: bytes) -> tuple[int, dict[str, Any] | None, bytes]:
856
871
  return message_type, _deserialize_client_var_sync(data, offset), b""
857
872
  elif message_type == MSG_CLIENT_VAR_CLEAR:
858
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""
859
876
  elif message_type == MSG_OBJECT_POSE:
860
877
  return message_type, _deserialize_object_pose(data, offset), b""
861
878
  elif message_type == MSG_ROOM_OBJECTS:
@@ -890,6 +907,22 @@ def deserialize(data: bytes) -> tuple[int, dict[str, Any] | None, bytes]:
890
907
  return message_type, None, b""
891
908
 
892
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
+
893
926
  def _deserialize_client_body(data: bytes, offset: int) -> tuple[dict[str, Any], int]:
894
927
  """Deserialize protocol v5 compact pose body."""
895
928
  result: dict[str, Any] = {}
@@ -1336,6 +1369,10 @@ def serialize_object_pose(data: dict[str, Any]) -> bytes:
1336
1369
  buffer = bytearray()
1337
1370
  buffer.append(MSG_OBJECT_POSE)
1338
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", "")))
1339
1376
  object_id = int(data.get("objectId", 0)) & 0xFFFFFFFF
1340
1377
  buffer.extend(struct.pack("<I", object_id))
1341
1378
  buffer.extend(struct.pack("<H", int(data.get("poseSeq", 0)) & 0xFFFF))
@@ -1433,6 +1470,8 @@ def _deserialize_object_pose(data: bytes, offset: int) -> dict[str, Any]:
1433
1470
  offset += 1
1434
1471
  if protocol_version != PROTOCOL_VERSION:
1435
1472
  raise ValueError(f"Unsupported protocol version: {protocol_version}")
1473
+ device_id, offset = _unpack_string(data, offset)
1474
+ result["deviceId"] = device_id
1436
1475
  result["objectId"] = struct.unpack("<I", data[offset : offset + 4])[0]
1437
1476
  offset += 4
1438
1477
  result["poseSeq"] = struct.unpack("<H", data[offset : offset + 2])[0]
@@ -9,7 +9,8 @@ Key features (mirroring Unity ConnectionManager PR #311 and #316):
9
9
  - Latest-wins transform sending: Only the most recent transform is sent
10
10
  - Control message queue with TTL expiration
11
11
  - SUB receive draining: Only process the last payload to reduce CPU
12
- - DEALER receive for control messages from server (ROUTER unicast)
12
+ - Separate control and transform DEALER sockets
13
+ - DEALER receive for control messages from server (ROUTER unicast)
13
14
  """
14
15
 
15
16
  import json
@@ -36,7 +37,6 @@ from . import binary_serializer
36
37
  from .adapters import (
37
38
  client_transform_from_wire,
38
39
  client_transform_to_wire,
39
- create_stealth_transform,
40
40
  is_stealth_transform,
41
41
  )
42
42
  from .events import EventHandler
@@ -140,6 +140,7 @@ class net_sync_manager:
140
140
  self,
141
141
  server: str = "tcp://localhost",
142
142
  dealer_port: int = 5555,
143
+ transform_port: int | None = None,
143
144
  sub_port: int = 5556,
144
145
  room: str = "default_room",
145
146
  auto_dispatch: bool = True,
@@ -152,7 +153,8 @@ class net_sync_manager:
152
153
 
153
154
  Args:
154
155
  server: ZeroMQ base address (e.g., "tcp://localhost")
155
- dealer_port: Server ROUTER port for uplink
156
+ dealer_port: Deprecated alias for the server control ROUTER port
157
+ transform_port: Server ROUTER port for transform uplink
156
158
  sub_port: Server PUB port for downlink
157
159
  room: Room topic to subscribe to
158
160
  auto_dispatch: If True, callbacks fire on receive thread
@@ -162,17 +164,21 @@ class net_sync_manager:
162
164
  (default: 30.0; pass None to disable silence detection)
163
165
  """
164
166
  self._server = server
165
- self._dealer_port = dealer_port
167
+ self._control_port = dealer_port
168
+ self._dealer_port = self._control_port # Deprecated compatibility alias
169
+ self._transform_port = (
170
+ transform_port if transform_port is not None else self._control_port + 2
171
+ )
166
172
  self._sub_port = sub_port
167
173
  self._room = room
168
174
  self._auto_dispatch = auto_dispatch
169
- self._queue_max = queue_max
170
175
  self._reconnect_delay = reconnect_delay
171
176
  self._receive_timeout = receive_timeout
172
177
 
173
178
  # ZeroMQ context and sockets
174
179
  self._context: zmq.Context | None = None
175
180
  self._dealer_socket: zmq.Socket | None = None
181
+ self._transform_socket: zmq.Socket | None = None
176
182
  self._sub_socket: zmq.Socket | None = None
177
183
 
178
184
  # Threading
@@ -225,6 +231,7 @@ class net_sync_manager:
225
231
  # Priority-based sending (mirroring Unity ConnectionManager)
226
232
  # Control messages (RPC, NV) have priority over transforms
227
233
  self._ctrl_outbox: Queue[OutboundPacket] = Queue(maxsize=256)
234
+ self._pending_control: OutboundPacket | None = None
228
235
  self._latest_transform: OutboundPacket | None = None
229
236
  self._transform_lock = threading.Lock()
230
237
 
@@ -321,9 +328,19 @@ class net_sync_manager:
321
328
 
322
329
  @property
323
330
  def dealer_port(self) -> int:
324
- """Dealer port."""
331
+ """Deprecated alias for the control port."""
325
332
  return self._dealer_port
326
333
 
334
+ @property
335
+ def control_port(self) -> int:
336
+ """Control socket port."""
337
+ return self._control_port
338
+
339
+ @property
340
+ def transform_port(self) -> int:
341
+ """Transform uplink socket port."""
342
+ return self._transform_port
343
+
327
344
  @property
328
345
  def sub_port(self) -> int:
329
346
  """Subscriber port."""
@@ -339,18 +356,22 @@ class net_sync_manager:
339
356
  # Initialize ZeroMQ
340
357
  self._context = zmq.Context()
341
358
 
342
- # DEALER socket for uplink and control message receive
359
+ # DEALER socket for control uplink and control message receive
343
360
  self._dealer_socket = self._context.socket(zmq.DEALER)
344
361
  self._dealer_socket.setsockopt(zmq.LINGER, 0)
345
- self._dealer_socket.setsockopt(
346
- zmq.SNDHWM, 10
347
- ) # Low HWM for backpressure
348
- self._dealer_socket.setsockopt(
349
- zmq.RCVHWM, 10
350
- ) # Bound receive queue for control messages
362
+ self._dealer_socket.setsockopt(zmq.SNDHWM, 1024)
363
+ self._dealer_socket.setsockopt(zmq.RCVHWM, 1024)
351
364
  self._dealer_socket.setsockopt(zmq.RCVTIMEO, 0) # Non-blocking receive
352
- dealer_addr = self._build_connect_addr(self._dealer_port)
353
- self._dealer_socket.connect(dealer_addr)
365
+ control_addr = self._build_connect_addr(self._control_port)
366
+ self._dealer_socket.connect(control_addr)
367
+
368
+ # DEALER socket for transform uplink only. Low HWM preserves
369
+ # latest-wins freshness under slow receivers.
370
+ self._transform_socket = self._context.socket(zmq.DEALER)
371
+ self._transform_socket.setsockopt(zmq.LINGER, 0)
372
+ self._transform_socket.setsockopt(zmq.SNDHWM, 2)
373
+ transform_addr = self._build_connect_addr(self._transform_port)
374
+ self._transform_socket.connect(transform_addr)
354
375
 
355
376
  # SUB socket for downlink (transform broadcasts)
356
377
  # Low RCVHWM (2) to prefer recent updates and drop stale messages
@@ -361,6 +382,8 @@ class net_sync_manager:
361
382
  self._sub_socket.connect(sub_addr)
362
383
  self._sub_socket.setsockopt(zmq.SUBSCRIBE, self._room.encode("utf-8"))
363
384
 
385
+ self._enqueue_client_hello(is_stealth=False)
386
+
364
387
  # Start receive thread
365
388
  self._running = True
366
389
  self._reconnect_at = None # Clear stale reconnect state
@@ -371,7 +394,8 @@ class net_sync_manager:
371
394
  self._receive_thread.start()
372
395
 
373
396
  logger.info(
374
- f"NetSync client started: {dealer_addr}, {sub_addr}, room={self._room}"
397
+ "NetSync client started: "
398
+ f"{control_addr}, {transform_addr}, {sub_addr}, room={self._room}"
375
399
  )
376
400
 
377
401
  except Exception as e:
@@ -416,6 +440,9 @@ class net_sync_manager:
416
440
  if self._dealer_socket:
417
441
  self._dealer_socket.close()
418
442
  self._dealer_socket = None
443
+ if self._transform_socket:
444
+ self._transform_socket.close()
445
+ self._transform_socket = None
419
446
  if self._sub_socket:
420
447
  self._sub_socket.close()
421
448
  self._sub_socket = None
@@ -470,7 +497,7 @@ class net_sync_manager:
470
497
  logger.info("Attempting socket reconnect...")
471
498
 
472
499
  # Close old sockets
473
- for sock_attr in ("_dealer_socket", "_sub_socket"):
500
+ for sock_attr in ("_dealer_socket", "_transform_socket", "_sub_socket"):
474
501
  sock = getattr(self, sock_attr, None)
475
502
  if sock is not None:
476
503
  try:
@@ -488,14 +515,21 @@ class net_sync_manager:
488
515
  return False
489
516
 
490
517
  try:
491
- # DEALER socket (same options as start())
518
+ # Control DEALER socket (same options as start())
492
519
  self._dealer_socket = self._context.socket(zmq.DEALER)
493
520
  self._dealer_socket.setsockopt(zmq.LINGER, 0)
494
- self._dealer_socket.setsockopt(zmq.SNDHWM, 10)
495
- self._dealer_socket.setsockopt(zmq.RCVHWM, 10)
521
+ self._dealer_socket.setsockopt(zmq.SNDHWM, 1024)
522
+ self._dealer_socket.setsockopt(zmq.RCVHWM, 1024)
496
523
  self._dealer_socket.setsockopt(zmq.RCVTIMEO, 0)
497
- dealer_addr = self._build_connect_addr(self._dealer_port)
498
- self._dealer_socket.connect(dealer_addr)
524
+ control_addr = self._build_connect_addr(self._control_port)
525
+ self._dealer_socket.connect(control_addr)
526
+
527
+ # Transform DEALER socket (same options as start())
528
+ self._transform_socket = self._context.socket(zmq.DEALER)
529
+ self._transform_socket.setsockopt(zmq.LINGER, 0)
530
+ self._transform_socket.setsockopt(zmq.SNDHWM, 2)
531
+ transform_addr = self._build_connect_addr(self._transform_port)
532
+ self._transform_socket.connect(transform_addr)
499
533
 
500
534
  # SUB socket (same options as start())
501
535
  self._sub_socket = self._context.socket(zmq.SUB)
@@ -510,9 +544,11 @@ class net_sync_manager:
510
544
 
511
545
  logger.info(
512
546
  f"Reconnected (#{self._stats['reconnect_count']}): "
513
- f"{dealer_addr}, {sub_addr}, room={self._room}"
547
+ f"{control_addr}, {transform_addr}, {sub_addr}, room={self._room}"
514
548
  )
515
549
 
550
+ self._enqueue_client_hello(is_stealth=self._is_stealth_mode)
551
+
516
552
  # Restart discovery if it was active before the connection error
517
553
  if self._discovery_port is not None:
518
554
  self.start_discovery(self._discovery_port)
@@ -522,7 +558,7 @@ class net_sync_manager:
522
558
  except Exception as e:
523
559
  logger.error(f"Socket reconnect failed: {e}")
524
560
  # Cleanup partial setup
525
- for sock_attr in ("_dealer_socket", "_sub_socket"):
561
+ for sock_attr in ("_dealer_socket", "_transform_socket", "_sub_socket"):
526
562
  sock = getattr(self, sock_attr, None)
527
563
  if sock is not None:
528
564
  try:
@@ -540,6 +576,7 @@ class net_sync_manager:
540
576
  self._ctrl_outbox.get_nowait()
541
577
  except Empty:
542
578
  break
579
+ self._pending_control = None
543
580
 
544
581
  # Clear latest transform
545
582
  with self._transform_lock:
@@ -759,7 +796,7 @@ class net_sync_manager:
759
796
 
760
797
  # Priority-based send drain:
761
798
  # 1. Drain control messages first (higher priority)
762
- # 2. Then try to send latest transform (lower priority)
799
+ # 2. Then try to send latest transform through transform socket
763
800
  did_work |= self._drain_control_sends()
764
801
  did_work |= self._try_send_latest_transform()
765
802
 
@@ -778,41 +815,34 @@ class net_sync_manager:
778
815
  now = time.monotonic()
779
816
 
780
817
  while sent < self._ctrl_drain_batch:
781
- try:
782
- packet = self._ctrl_outbox.get_nowait()
783
- except Empty:
784
- break
818
+ if self._pending_control is not None:
819
+ packet = self._pending_control
820
+ else:
821
+ try:
822
+ packet = self._ctrl_outbox.get_nowait()
823
+ except Empty:
824
+ break
785
825
 
786
826
  # TTL check - skip expired packets
787
827
  if now - packet.enqueued_at > self._ctrl_ttl_seconds:
788
828
  logger.warning(
789
829
  f"Control packet expired (TTL {self._ctrl_ttl_seconds}s exceeded)"
790
830
  )
831
+ self._pending_control = None
791
832
  continue
792
833
 
793
834
  # Try to send
794
- outcome = self._try_send_dealer(packet.room_id, packet.payload)
835
+ outcome = self._try_send_socket(
836
+ self._dealer_socket, packet.room_id, packet.payload
837
+ )
795
838
  if outcome.is_sent:
839
+ self._pending_control = None
796
840
  did_work = True
797
841
  sent += 1
798
842
  elif outcome.is_backpressure:
799
- # Backpressure - increment counter and attempt to re-queue the packet
800
843
  with self._lock:
801
844
  self._stats["would_block_count"] += 1
802
- try:
803
- # Re-queue the control packet so it can be retried later
804
- self._ctrl_outbox.put_nowait(packet)
805
- except Full:
806
- # Queue is full: this control packet is dropped; log prominently
807
- with self._lock:
808
- self._stats["ctrl_queue_drops"] += 1
809
- logger.warning(
810
- "Control packet dropped due to backpressure and full queue; "
811
- "would_block=%s, queue_drops=%s",
812
- self._stats.get("would_block_count"),
813
- self._stats.get("ctrl_queue_drops"),
814
- )
815
- # Stop draining on backpressure to respect socket backpressure signal
845
+ self._pending_control = packet
816
846
  break
817
847
  else:
818
848
  # Fatal error
@@ -826,7 +856,7 @@ class net_sync_manager:
826
856
 
827
857
  Returns True if a transform was sent.
828
858
  """
829
- if not self._dealer_socket:
859
+ if not self._transform_socket:
830
860
  return False
831
861
 
832
862
  with self._transform_lock:
@@ -835,7 +865,9 @@ class net_sync_manager:
835
865
  return False
836
866
 
837
867
  # Try to send
838
- outcome = self._try_send_dealer(packet.room_id, packet.payload)
868
+ outcome = self._try_send_socket(
869
+ self._transform_socket, packet.room_id, packet.payload
870
+ )
839
871
  if outcome.is_sent:
840
872
  # Success - clear the packet
841
873
  self._latest_transform = None
@@ -850,17 +882,19 @@ class net_sync_manager:
850
882
  logger.error(f"Fatal send error: {outcome.error}")
851
883
  return False
852
884
 
853
- def _try_send_dealer(self, room_id: str, payload: bytes) -> SendOutcome:
854
- """Try to send a message via DEALER socket.
885
+ def _try_send_socket(
886
+ self, socket_obj: zmq.Socket | None, room_id: str, payload: bytes
887
+ ) -> SendOutcome:
888
+ """Try to send a message via a DEALER socket.
855
889
 
856
890
  Returns SendOutcome indicating success, backpressure, or fatal error.
857
891
  """
858
- if not self._dealer_socket:
892
+ if not socket_obj:
859
893
  return SendOutcome.fatal("Socket not available")
860
894
 
861
895
  try:
862
896
  # Use NOBLOCK to detect backpressure
863
- self._dealer_socket.send_multipart(
897
+ socket_obj.send_multipart(
864
898
  [room_id.encode("utf-8"), payload], flags=zmq.NOBLOCK
865
899
  )
866
900
  return SendOutcome.sent()
@@ -1109,7 +1143,7 @@ class net_sync_manager:
1109
1143
  Only the most recent transform is retained. Calling this multiple times
1110
1144
  before the network thread sends will only send the last one.
1111
1145
  """
1112
- if not self._running or not self._dealer_socket:
1146
+ if not self._running or not self._transform_socket:
1113
1147
  return False
1114
1148
 
1115
1149
  try:
@@ -1146,20 +1180,14 @@ class net_sync_manager:
1146
1180
  return result
1147
1181
 
1148
1182
  def _send_stealth_heartbeat(self) -> bool:
1149
- """Serialize and enqueue one stealth heartbeat packet.
1183
+ """Serialize and enqueue one stealth control hello packet.
1150
1184
 
1151
- Uses the control queue (matching Unity's TryEnqueueControl for stealth handshakes).
1185
+ Uses the control queue so stealth clients maintain membership without
1186
+ sending pose-shaped transform payloads.
1152
1187
  Updates _last_stealth_heartbeat_time on success to drive the 1 Hz interval.
1153
1188
  """
1154
- stealth_tx = create_stealth_transform()
1155
- stealth_tx.device_id = self._device_id
1156
-
1157
1189
  try:
1158
- wire_data = client_transform_to_wire(stealth_tx)
1159
- message = binary_serializer.serialize_client_transform(wire_data)
1160
- result = self._enqueue_control(
1161
- self._room, message, msg_type="stealth_heartbeat"
1162
- )
1190
+ result = self._enqueue_client_hello(is_stealth=True)
1163
1191
  if result:
1164
1192
  self._last_stealth_heartbeat_time = time.monotonic()
1165
1193
  return result
@@ -1304,6 +1332,13 @@ class net_sync_manager:
1304
1332
  )
1305
1333
  return False
1306
1334
 
1335
+ def _enqueue_client_hello(self, is_stealth: bool) -> bool:
1336
+ """Enqueue a client hello control message."""
1337
+ message = binary_serializer.serialize_client_hello(
1338
+ self._device_id, is_stealth=is_stealth
1339
+ )
1340
+ return self._enqueue_control(self._room, message, msg_type="client_hello")
1341
+
1307
1342
  def get_global_variable(self, name: str, default: str | None = None) -> str | None:
1308
1343
  """Get global variable from local cache."""
1309
1344
  return self._global_variables.get(name, default)
@@ -1577,26 +1612,32 @@ class net_sync_manager:
1577
1612
  data, addr = sock.recvfrom(1024)
1578
1613
  response = data.decode("utf-8")
1579
1614
 
1580
- if response.startswith("STYLY-NETSYNC|"):
1615
+ if response.startswith("STYLY-NETSYNC2|"):
1581
1616
  parts = response.split("|")
1582
- if len(parts) >= 4:
1583
- dealer_port = int(parts[1])
1584
- sub_port = int(parts[2])
1585
- server_name = parts[3]
1617
+ if len(parts) >= 5:
1618
+ control_port = int(parts[1])
1619
+ transform_port = int(parts[2])
1620
+ sub_port = int(parts[3])
1621
+ server_name = parts[4]
1586
1622
 
1587
1623
  logger.info(
1588
1624
  f"Discovered server: {server_name} at "
1589
- f"{addr[0]}:{dealer_port}/{sub_port}"
1625
+ f"{addr[0]}:{control_port}/{transform_port}/{sub_port}"
1590
1626
  )
1591
1627
  server_address = f"tcp://{addr[0]}"
1592
1628
  self.on_server_discovered.invoke(
1593
- server_address, dealer_port, sub_port
1629
+ server_address,
1630
+ control_port,
1631
+ transform_port,
1632
+ sub_port,
1594
1633
  )
1595
1634
 
1596
1635
  # Auto-connect after discovery
1597
1636
  if not self._running:
1598
1637
  self._server = server_address
1599
- self._dealer_port = dealer_port
1638
+ self._control_port = control_port
1639
+ self._dealer_port = control_port
1640
+ self._transform_port = transform_port
1600
1641
  self._sub_port = sub_port
1601
1642
  try:
1602
1643
  self.start()
@@ -1608,6 +1649,11 @@ class net_sync_manager:
1608
1649
  break
1609
1650
  self._stop_discovery_internal()
1610
1651
  return
1652
+ elif response.startswith("STYLY-NETSYNC|"):
1653
+ logger.warning(
1654
+ "Ignoring incompatible legacy discovery response from %s",
1655
+ addr[0],
1656
+ )
1611
1657
  except TimeoutError:
1612
1658
  pass
1613
1659
  except Exception: