Просмотр исходного кода

feat: encode verified TCP wire frames

wuzilong 1 месяц назад
Родитель
Сommit
691178dc47

+ 6 - 0
cq2_sdk/src/cq2_sdk/transport/__init__.py

@@ -7,6 +7,9 @@ from .framing import (
     decode_header,
     HEADER_SIZE,
     HEADER_FORMAT,
+    PLAIN_HEADER_SIZE,
+    PLAIN_HEADER_FORMAT,
+    WIRE_HEADER_SIZE,
 )
 from . import xxtea
 
@@ -19,5 +22,8 @@ __all__ = [
     "decode_header",
     "HEADER_SIZE",
     "HEADER_FORMAT",
+    "PLAIN_HEADER_SIZE",
+    "PLAIN_HEADER_FORMAT",
+    "WIRE_HEADER_SIZE",
     "xxtea",
 ]

+ 57 - 23
cq2_sdk/src/cq2_sdk/transport/framing.py

@@ -1,56 +1,90 @@
-"""TCP frame codec for the cq2 game protocol.
+"""Verified TCP frame codec for the cq2 game protocol.
 
-Per spec §3.1 / cq2-01 §2:
-  - 24-byte header (little-endian): msg_id, recog, param1, param2, param3, body_len
-  - Followed by body of `body_len` bytes
-  - On the wire the header may be encrypted (XXTEA + SessionId key) — see xxtea.py
+The client emits a nine-word, 36-byte little-endian header through its 6-bit
+wire encoder.  The resulting 48-byte printable wire header is followed by an
+unencoded body.  XXTEA is not part of this TCP framing layer.
 """
 from __future__ import annotations
 import struct
 from pydantic import BaseModel
+from .encode6 import decode6, encode6
 
-HEADER_FORMAT = "<6i"  # msg_id, recog, param1, param2, param3, body_len
-HEADER_SIZE = struct.calcsize(HEADER_FORMAT)  # 24
+PLAIN_HEADER_FORMAT = "<9i"
+PLAIN_HEADER_SIZE = struct.calcsize(PLAIN_HEADER_FORMAT)
+WIRE_HEADER_SIZE = 48
+HEADER_FORMAT = PLAIN_HEADER_FORMAT
+HEADER_SIZE = WIRE_HEADER_SIZE
 
 
 class FrameHeader(BaseModel):
+    pre: int
     msg_id: int
     recog: int
     param1: int
     param2: int
     param3: int
+    codec: int
     body_len: int
+    sequence: int
 
 
 def encode_frame(
-    msg_id: int, recog: int, param1: int, param2: int, param3: int, body: bytes
+    msg_id: int,
+    recog: int,
+    param1: int,
+    param2: int,
+    param3: int,
+    body: bytes,
+    *,
+    pre: int = 0,
+    codec: int = 0,
+    sequence: int = 0,
 ) -> bytes:
-    """Pack a 24-byte header + body into a single frame."""
+    """Pack a verified encoded wire header and its plaintext body."""
     if len(body) > 0x7FFFFFFF:
         raise ValueError(f"body too large: {len(body)} bytes")
-    header = struct.pack(HEADER_FORMAT, msg_id, recog, param1, param2, param3, len(body))
-    return header + body
+    plain_header = struct.pack(
+        PLAIN_HEADER_FORMAT,
+        pre,
+        msg_id,
+        recog,
+        param1,
+        param2,
+        param3,
+        codec,
+        len(body),
+        sequence,
+    )
+    return encode6(plain_header) + body
 
 
 def decode_header(raw: bytes) -> FrameHeader:
-    """Parse the first 24 bytes as a FrameHeader."""
-    if len(raw) < HEADER_SIZE:
-        raise ValueError(f"Frame too short: {len(raw)} < {HEADER_SIZE}")
-    fields = struct.unpack(HEADER_FORMAT, raw[:HEADER_SIZE])
+    """Decode one 48-byte TCP wire header into its nine plain fields."""
+    if len(raw) < WIRE_HEADER_SIZE:
+        raise ValueError(f"Frame too short: {len(raw)} < {WIRE_HEADER_SIZE}")
+    fields = struct.unpack(
+        PLAIN_HEADER_FORMAT,
+        decode6(raw[:WIRE_HEADER_SIZE], expected_size=PLAIN_HEADER_SIZE),
+    )
     return FrameHeader(
-        msg_id=fields[0],
-        recog=fields[1],
-        param1=fields[2],
-        param2=fields[3],
-        param3=fields[4],
-        body_len=fields[5],
+        pre=fields[0],
+        msg_id=fields[1],
+        recog=fields[2],
+        param1=fields[3],
+        param2=fields[4],
+        param3=fields[5],
+        codec=fields[6],
+        body_len=fields[7],
+        sequence=fields[8],
     )
 
 
 def decode_frame(raw: bytes) -> tuple[FrameHeader, bytes]:
     """Parse a complete frame; returns (header, body)."""
-    hdr = decode_header(raw)
-    body = raw[HEADER_SIZE:HEADER_SIZE + hdr.body_len]
+    hdr = decode_header(raw[:WIRE_HEADER_SIZE])
+    body = raw[WIRE_HEADER_SIZE:WIRE_HEADER_SIZE + hdr.body_len]
     if len(body) != hdr.body_len:
         raise ValueError(f"Body truncated: expected {hdr.body_len}, got {len(body)}")
+    if hdr.codec != 0:
+        raise ValueError(f"unsupported body codec: {hdr.codec}")
     return hdr, body

+ 42 - 8
cq2_sdk/tests/unit/test_framing.py

@@ -1,5 +1,13 @@
 import pytest
-from cq2_sdk.transport.framing import encode_frame, decode_frame, FrameHeader, HEADER_SIZE
+from cq2_sdk.transport.framing import (
+    HEADER_SIZE,
+    PLAIN_HEADER_SIZE,
+    WIRE_HEADER_SIZE,
+    FrameHeader,
+    decode_frame,
+    decode_header,
+    encode_frame,
+)
 from cq2_sdk.transport.encode6 import decode6, encode6
 from cq2_sdk.messages import constants as C
 
@@ -26,23 +34,37 @@ def test_decode6_rejects_non_wire_character():
         decode6(b"<~")
 
 
-def test_header_is_24_bytes():
+def test_header_is_48_bytes_on_wire():
     raw = encode_frame(1, 0, 0, 0, 0, b"")
-    assert len(raw) == 24
-    assert HEADER_SIZE == 24
+    assert len(raw) == WIRE_HEADER_SIZE
+    assert PLAIN_HEADER_SIZE == 36
+    assert WIRE_HEADER_SIZE == 48
+    assert HEADER_SIZE == WIRE_HEADER_SIZE
 
 
 def test_encode_decode_roundtrip():
     body = b"hello"
-    raw = encode_frame(C.CS_2001, recog=900000, param1=12345, param2=0, param3=0, body=body)
-    assert len(raw) == 24 + len(body)
+    raw = encode_frame(
+        C.CS_2001,
+        recog=900000,
+        param1=12345,
+        param2=0,
+        param3=0,
+        body=body,
+        pre=7,
+        sequence=42,
+    )
+    assert len(raw) == WIRE_HEADER_SIZE + len(body)
     hdr, decoded_body = decode_frame(raw)
+    assert hdr.pre == 7
     assert hdr.msg_id == C.CS_2001
     assert hdr.recog == 900000
     assert hdr.param1 == 12345
     assert hdr.param2 == 0
     assert hdr.param3 == 0
     assert hdr.body_len == 5
+    assert hdr.codec == 0
+    assert hdr.sequence == 42
     assert decoded_body == body
 
 
@@ -61,13 +83,12 @@ def test_empty_body():
 
 def test_truncated_body_raises():
     raw = encode_frame(1, 0, 0, 0, 0, b"hello world")
-    truncated = raw[:30]  # cut off in the middle of the body
+    truncated = raw[:WIRE_HEADER_SIZE + 4]  # cut off in the middle of the body
     with pytest.raises(ValueError, match="truncated"):
         decode_frame(truncated)
 
 
 def test_too_short_raises():
-    from cq2_sdk.transport.framing import decode_header
     with pytest.raises(ValueError, match="too short"):
         decode_header(b"\x00" * 10)
 
@@ -79,6 +100,19 @@ def test_negative_recog_allowed():
     assert hdr.recog == -1
 
 
+def test_unsupported_body_codec_raises():
+    raw = encode_frame(1, 0, 0, 0, 0, b"compressed", codec=1)
+    with pytest.raises(ValueError, match="unsupported body codec: 1"):
+        decode_frame(raw)
+
+
+def test_decode_header_reads_the_first_wire_header_from_a_complete_frame():
+    raw = encode_frame(529, 1, 0, 0, 0, b"response", sequence=8)
+    header = decode_header(raw)
+    assert header.msg_id == 529
+    assert header.sequence == 8
+
+
 def test_constants_loaded():
     # Smoke check: a few known constants are importable and distinct
     assert C.CS_2001 == 2001

+ 5 - 8
cq2_sdk/tests/unit/test_recorder.py

@@ -1,13 +1,12 @@
 """Tests for the recording infrastructure (Recorder + RecordedSession)."""
 import asyncio
 import json
-import struct
 import pytest
 from pathlib import Path
 
 from cq2_sdk.testing import Recorder, RecordedSession
 from cq2_sdk.transport.tcp import TcpClient
-from cq2_sdk.transport.framing import encode_frame, HEADER_SIZE
+from cq2_sdk.transport.framing import WIRE_HEADER_SIZE, decode_header, encode_frame
 
 
 async def _scripted_echo_server(reader, writer, scripted_sc):
@@ -16,20 +15,18 @@ async def _scripted_echo_server(reader, writer, scripted_sc):
         idx = 0
         while True:
             try:
-                hdr_raw = await reader.readexactly(HEADER_SIZE)
+                hdr_raw = await reader.readexactly(WIRE_HEADER_SIZE)
             except asyncio.IncompleteReadError:
                 break
-            msg_id, recog, p1, p2, p3, body_len = struct.unpack("<6i", hdr_raw)
-            body = await reader.readexactly(body_len) if body_len else b""
+            header = decode_header(hdr_raw)
+            body = await reader.readexactly(header.body_len) if header.body_len else b""
             # Echo the CS frame back as an SC
             sc = scripted_sc[idx] if idx < len(scripted_sc) else None
             idx += 1
             if sc is None:
                 break
             sc_msg_id, sc_recog, sc_body = sc
-            resp = struct.pack(
-                "<6i", sc_msg_id, sc_recog, 0, 0, 0, len(sc_body)
-            ) + sc_body
+            resp = encode_frame(sc_msg_id, sc_recog, 0, 0, 0, sc_body)
             writer.write(resp)
             await writer.drain()
     finally: