Jelajahi Sumber

feat: read real TCP wire frames

wuzilong 1 bulan lalu
induk
melakukan
be0b291e74

+ 13 - 9
cq2_sdk/src/cq2_sdk/client.py

@@ -4,15 +4,16 @@ Real protocol (2026-09-15 reverse-engineered):
   1. Box996Login (api.jiyuhz.cn) — RSA loginInfo + HMAC X-BOX-AUTHORIZATION
   2. get_today_open (pc-api.996hezi.com) — today's servers
   3. enter_game_client — the 启动 button
-  4. TCP game login (gfgame:12000) — BLOCKED on C++ frame encryption (Step 2: Frida)
+  4. TCP game login (gfgame:12000) — frame layout implemented; game login body/state remains unverified
 
 Usage (HTTP part works today):
     async with Cq2Client(account="longwu988", password="...") as cli:
         servers = await cli.launcher.box.get_today_open()     # 今天开了几个区
         await cli.launcher.enter_game(14414738)               # 启动
 
-The TCP part (run_until_enter_game) raises NotImplementedError until the
-Frida frame-crypt hook lands (Step 2).
+The TCP part (run_until_enter_game) remains unavailable until a matching live
+session validates the CS2001 body, initial sequence, body codec, and state
+machine.
 """
 from __future__ import annotations
 
@@ -55,7 +56,8 @@ class LauncherFacade:
     """High-level pre-game flow using the REAL 996box protocol.
 
     HTTP portion (login → today's servers → EnterGameClient) is complete.
-    TCP portion (wait_for_game_ready) is blocked on frame encryption.
+    TCP wire framing is implemented, but its login payload and session state
+    remain unverified.
     """
 
     def __init__(self, box: Box996Login) -> None:
@@ -78,10 +80,11 @@ class LauncherFacade:
         server_name: str = "玖扬复古176",
         zone_index: int = 0,
     ) -> GameLaunchInfo:
-        """Full flow incl. TCP connect — BLOCKED on frame encryption (Step 2).
+        """Full flow incl. TCP connect — blocked on unverified game login data.
 
         HTTP parts (find server, EnterGameClient) work; the TCP game login
-        requires the C++ frame crypt algorithm (Frida hook, pending).
+        requires a validated CS2001 body, production initial sequence, body
+        codec, and state machine.
         """
         servers = await self.box.get_today_open()
         if not servers:
@@ -89,7 +92,8 @@ class LauncherFacade:
         target = servers[min(zone_index, len(servers) - 1)]
         await self.box.enter_game_client(serverid=target.serverid)
         raise NotImplementedError(
-            "TCP game login blocked on C++ frame encryption — run Step 2 (Frida hook) first. "
+            "TCP wire framing is implemented, but game login requires a validated "
+            "CS2001 body, initial sequence, body codec, and state machine. "
             "HTTP parts completed: getTodayOpen + EnterGameClient."
         )
 
@@ -164,8 +168,8 @@ class Cq2Client:
     async def run_newbie_flow(self, *, resume_from: str | None = None) -> FlowResult:
         """Run the 13 newbie tasks. Requires run_until_enter_game first (Step 2)."""
         assert self.task is not None, (
-            "TCP session not established. Step 2 (Frida frame crypt) is required "
-            "before run_newbie_flow can work."
+        "TCP session not established. A validated game-login protocol is required "
+        "before run_newbie_flow can work."
         )
         ctx = self.task.ctx
         completed: list[str] = []

+ 17 - 12
cq2_sdk/src/cq2_sdk/transport/tcp.py

@@ -1,10 +1,9 @@
 """Async TCP client for the cq2 game protocol.
 
 Wraps asyncio.open_connection with:
-  - Frame-level send/recv (24-byte header + body)
+  - Frame-level send/recv (48-byte encoded header + body)
   - Internal queue to feed inbound frames to consumers
   - Heartbeat task (CS 60000 every N seconds)
-  - Optional XXTEA encryption on the wire (placeholder, see xxtea.py)
 
 The recv loop reads from the socket, accumulates bytes, and emits
 complete frames into an asyncio.Queue for the application to consume
@@ -13,8 +12,7 @@ via `await recv(timeout=...)`.
 from __future__ import annotations
 import asyncio
 from typing import Optional
-from .framing import encode_frame, decode_frame, decode_header, HEADER_SIZE, FrameHeader
-from . import xxtea
+from .framing import WIRE_HEADER_SIZE, FrameHeader, decode_frame, decode_header, encode_frame
 
 
 class TcpClient:
@@ -23,11 +21,11 @@ class TcpClient:
         host: str,
         port: int,
         *,
-        encryption_key: bytes | None = None,
+        initial_sequence: int = 0,
     ) -> None:
         self.host = host
         self.port = port
-        self.encryption_key = encryption_key
+        self._next_sequence = initial_sequence
         self._reader: Optional[asyncio.StreamReader] = None
         self._writer: Optional[asyncio.StreamWriter] = None
         self._buffer = bytearray()
@@ -67,9 +65,9 @@ class TcpClient:
                     return
                 self._buffer.extend(chunk)
                 # Drain complete frames from the buffer.
-                while len(self._buffer) >= HEADER_SIZE and not self._closed:
-                    hdr = decode_header(bytes(self._buffer))
-                    total = HEADER_SIZE + hdr.body_len
+                while len(self._buffer) >= WIRE_HEADER_SIZE and not self._closed:
+                    hdr = decode_header(bytes(self._buffer[:WIRE_HEADER_SIZE]))
+                    total = WIRE_HEADER_SIZE + hdr.body_len
                     if len(self._buffer) < total:
                         break
                     frame = bytes(self._buffer[:total])
@@ -92,9 +90,16 @@ class TcpClient:
     ) -> None:
         if self._writer is None:
             raise RuntimeError("not connected")
-        raw = encode_frame(msg_id, recog, param1, param2, param3, body)
-        if self.encryption_key:
-            raw = xxtea.encrypt(raw, self.encryption_key)
+        raw = encode_frame(
+            msg_id,
+            recog,
+            param1,
+            param2,
+            param3,
+            body,
+            sequence=self._next_sequence,
+        )
+        self._next_sequence += 1
         self._writer.write(raw)
         await self._writer.drain()
 

+ 54 - 0
cq2_sdk/tests/unit/test_tcp_client.py

@@ -1,6 +1,7 @@
 import asyncio
 import pytest
 from cq2_sdk.transport.tcp import TcpClient
+from cq2_sdk.transport.framing import WIRE_HEADER_SIZE, decode_header, encode_frame
 
 
 async def _echo_handler(reader, writer):
@@ -75,3 +76,56 @@ async def test_send_before_connect_raises():
     cli = TcpClient("127.0.0.1", 1)
     with pytest.raises(RuntimeError, match="not connected"):
         await cli.send(1, 0, 0, 0, 0, b"")
+
+
+@pytest.mark.asyncio
+async def test_recv_reassembles_split_wire_header_and_body():
+    async def handler(reader, writer):
+        wire = encode_frame(529, 1, 0, 0, 0, b"split", sequence=17)
+        for chunk in (wire[:11], wire[11:WIRE_HEADER_SIZE], wire[WIRE_HEADER_SIZE:]):
+            writer.write(chunk)
+            await writer.drain()
+        writer.close()
+
+    server = await asyncio.start_server(handler, "127.0.0.1", 0)
+    port = server.sockets[0].getsockname()[1]
+    client = TcpClient("127.0.0.1", port)
+    await client.connect()
+    try:
+        header, body = await client.recv(timeout=1.0)
+        assert header.msg_id == 529
+        assert header.sequence == 17
+        assert body == b"split"
+    finally:
+        await client.close()
+        server.close()
+        await server.wait_closed()
+
+
+@pytest.mark.asyncio
+async def test_send_uses_and_increments_initial_sequence():
+    received: list[int] = []
+    complete = asyncio.Event()
+
+    async def handler(reader, writer):
+        for _ in range(2):
+            header = decode_header(await reader.readexactly(WIRE_HEADER_SIZE))
+            if header.body_len:
+                await reader.readexactly(header.body_len)
+            received.append(header.sequence)
+        complete.set()
+        writer.close()
+
+    server = await asyncio.start_server(handler, "127.0.0.1", 0)
+    port = server.sockets[0].getsockname()[1]
+    client = TcpClient("127.0.0.1", port, initial_sequence=9)
+    await client.connect()
+    try:
+        await client.send(2001, 0, 0, 0, 0, b"first")
+        await client.send(2002, 0, 0, 0, 0, b"second")
+        await asyncio.wait_for(complete.wait(), timeout=1.0)
+        assert received == [9, 10]
+    finally:
+        await client.close()
+        server.close()
+        await server.wait_closed()