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.
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/PKG-INFO +11 -8
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/README.md +10 -7
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/pyproject.toml +1 -1
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync/__init__.py +1 -1
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync/binary_serializer.py +43 -4
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync/client.py +116 -70
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync/client_simulator.py +101 -16
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync/config.py +52 -4
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync/default.toml +7 -1
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync/rest_bridge.py +37 -6
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync/server.py +417 -193
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync_server.egg-info/PKG-INFO +11 -8
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync_server.egg-info/SOURCES.txt +2 -1
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/tests/test_binary_serializer.py +32 -6
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/tests/test_client_variable_device_store.py +7 -2
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/tests/test_config.py +52 -9
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/tests/test_discovery_probe.py +1 -1
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/tests/test_multi_nic.py +35 -4
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/tests/test_object_sync.py +8 -2
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/tests/test_python_client.py +98 -0
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/tests/test_reconnect_identity.py +3 -3
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/tests/test_stealth_heartbeat.py +20 -0
- styly_netsync_server-0.15.0/tests/test_transport_split.py +330 -0
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/LICENSE +0 -0
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/setup.cfg +0 -0
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync/__main__.py +0 -0
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync/adapters.py +0 -0
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync/cli.py +0 -0
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync/events.py +0 -0
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync/logging_utils.py +0 -0
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync/network_utils.py +0 -0
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync/nv_sync.py +0 -0
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync/types.py +0 -0
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync_server.egg-info/dependency_links.txt +0 -0
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync_server.egg-info/entry_points.txt +0 -0
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync_server.egg-info/requires.txt +0 -0
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync_server.egg-info/top_level.txt +0 -0
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/tests/test_all_run_methods.py +0 -0
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/tests/test_logging_cli.py +0 -0
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/tests/test_nv_protocol.py +0 -0
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/tests/test_nv_server_ordering.py +0 -0
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/tests/test_port_error_message.py +0 -0
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/tests/test_rest_bridge.py +0 -0
- {styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/tests/test_room_expiry.py +0 -0
- {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.
|
|
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
|
|
85
|
-
-
|
|
86
|
-
-
|
|
87
|
-
-
|
|
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
|
|
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=
|
|
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
|
|
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
|
|
46
|
-
-
|
|
47
|
-
-
|
|
48
|
-
-
|
|
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
|
|
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=
|
|
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
|
|
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.
|
|
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(
|
|
19
|
+
server = NetSyncServer(control_port=5555, transform_port=5557, pub_port=5556)
|
|
20
20
|
server.start()
|
|
21
21
|
|
|
22
22
|
# Use client programmatically
|
{styly_netsync_server-0.14.0 → styly_netsync_server-0.15.0}/src/styly_netsync/binary_serializer.py
RENAMED
|
@@ -6,10 +6,11 @@ from typing import Any
|
|
|
6
6
|
logger = logging.getLogger(__name__)
|
|
7
7
|
|
|
8
8
|
# Message type identifiers
|
|
9
|
-
#
|
|
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 =
|
|
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
|
|
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 >
|
|
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
|
-
-
|
|
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:
|
|
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.
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
353
|
-
self._dealer_socket.connect(
|
|
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
|
-
|
|
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,
|
|
495
|
-
self._dealer_socket.setsockopt(zmq.RCVHWM,
|
|
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
|
-
|
|
498
|
-
self._dealer_socket.connect(
|
|
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"{
|
|
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
|
|
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
|
-
|
|
782
|
-
packet = self.
|
|
783
|
-
|
|
784
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
854
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
1183
|
+
"""Serialize and enqueue one stealth control hello packet.
|
|
1150
1184
|
|
|
1151
|
-
Uses the control queue
|
|
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
|
-
|
|
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-
|
|
1615
|
+
if response.startswith("STYLY-NETSYNC2|"):
|
|
1581
1616
|
parts = response.split("|")
|
|
1582
|
-
if len(parts) >=
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
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]}:{
|
|
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,
|
|
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.
|
|
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:
|