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
   1. Box996Login (api.jiyuhz.cn) — RSA loginInfo + HMAC X-BOX-AUTHORIZATION
   2. get_today_open (pc-api.996hezi.com) — today's servers
   2. get_today_open (pc-api.996hezi.com) — today's servers
   3. enter_game_client — the 启动 button
   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):
 Usage (HTTP part works today):
     async with Cq2Client(account="longwu988", password="...") as cli:
     async with Cq2Client(account="longwu988", password="...") as cli:
         servers = await cli.launcher.box.get_today_open()     # 今天开了几个区
         servers = await cli.launcher.box.get_today_open()     # 今天开了几个区
         await cli.launcher.enter_game(14414738)               # 启动
         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
 from __future__ import annotations
 
 
@@ -55,7 +56,8 @@ class LauncherFacade:
     """High-level pre-game flow using the REAL 996box protocol.
     """High-level pre-game flow using the REAL 996box protocol.
 
 
     HTTP portion (login → today's servers → EnterGameClient) is complete.
     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:
     def __init__(self, box: Box996Login) -> None:
@@ -78,10 +80,11 @@ class LauncherFacade:
         server_name: str = "玖扬复古176",
         server_name: str = "玖扬复古176",
         zone_index: int = 0,
         zone_index: int = 0,
     ) -> GameLaunchInfo:
     ) -> 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
         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()
         servers = await self.box.get_today_open()
         if not servers:
         if not servers:
@@ -89,7 +92,8 @@ class LauncherFacade:
         target = servers[min(zone_index, len(servers) - 1)]
         target = servers[min(zone_index, len(servers) - 1)]
         await self.box.enter_game_client(serverid=target.serverid)
         await self.box.enter_game_client(serverid=target.serverid)
         raise NotImplementedError(
         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."
             "HTTP parts completed: getTodayOpen + EnterGameClient."
         )
         )
 
 
@@ -164,8 +168,8 @@ class Cq2Client:
     async def run_newbie_flow(self, *, resume_from: str | None = None) -> FlowResult:
     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)."""
         """Run the 13 newbie tasks. Requires run_until_enter_game first (Step 2)."""
         assert self.task is not None, (
         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
         ctx = self.task.ctx
         completed: list[str] = []
         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.
 """Async TCP client for the cq2 game protocol.
 
 
 Wraps asyncio.open_connection with:
 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
   - Internal queue to feed inbound frames to consumers
   - Heartbeat task (CS 60000 every N seconds)
   - 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
 The recv loop reads from the socket, accumulates bytes, and emits
 complete frames into an asyncio.Queue for the application to consume
 complete frames into an asyncio.Queue for the application to consume
@@ -13,8 +12,7 @@ via `await recv(timeout=...)`.
 from __future__ import annotations
 from __future__ import annotations
 import asyncio
 import asyncio
 from typing import Optional
 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:
 class TcpClient:
@@ -23,11 +21,11 @@ class TcpClient:
         host: str,
         host: str,
         port: int,
         port: int,
         *,
         *,
-        encryption_key: bytes | None = None,
+        initial_sequence: int = 0,
     ) -> None:
     ) -> None:
         self.host = host
         self.host = host
         self.port = port
         self.port = port
-        self.encryption_key = encryption_key
+        self._next_sequence = initial_sequence
         self._reader: Optional[asyncio.StreamReader] = None
         self._reader: Optional[asyncio.StreamReader] = None
         self._writer: Optional[asyncio.StreamWriter] = None
         self._writer: Optional[asyncio.StreamWriter] = None
         self._buffer = bytearray()
         self._buffer = bytearray()
@@ -67,9 +65,9 @@ class TcpClient:
                     return
                     return
                 self._buffer.extend(chunk)
                 self._buffer.extend(chunk)
                 # Drain complete frames from the buffer.
                 # 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:
                     if len(self._buffer) < total:
                         break
                         break
                     frame = bytes(self._buffer[:total])
                     frame = bytes(self._buffer[:total])
@@ -92,9 +90,16 @@ class TcpClient:
     ) -> None:
     ) -> None:
         if self._writer is None:
         if self._writer is None:
             raise RuntimeError("not connected")
             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)
         self._writer.write(raw)
         await self._writer.drain()
         await self._writer.drain()
 
 

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

@@ -1,6 +1,7 @@
 import asyncio
 import asyncio
 import pytest
 import pytest
 from cq2_sdk.transport.tcp import TcpClient
 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):
 async def _echo_handler(reader, writer):
@@ -75,3 +76,56 @@ async def test_send_before_connect_raises():
     cli = TcpClient("127.0.0.1", 1)
     cli = TcpClient("127.0.0.1", 1)
     with pytest.raises(RuntimeError, match="not connected"):
     with pytest.raises(RuntimeError, match="not connected"):
         await cli.send(1, 0, 0, 0, 0, b"")
         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()