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

feat(apilot): lupa bridge bootstraps game lua protocol layer

bridge.py (111L) — Task 6 契约:
  LUA_SRC = lua脚本解密源码/mod_fgcq/stab/scripts (只读)
  bootstrap_runtime(nc,ctl,sched,env,state_dir) -> LuaRuntime
    1. Lupa VM
    2. install_all (Task 5 全量 stub)
    3. package.path 指向游戏 scripts
    4. class() 极简复刻 (单继承+new/ctor+super, framework.init 链依赖)
    5. cjson 桥 (_PY_cjson_encode/_PY_cjson_decode 走 Python json;
       _to_jsonable 把 Lupa _LuaTable 转 dict/list 含 1..n 整数键表识别)
    6. 薄复刻 networkUtil.lua 公共面: cjson/slen/LuaSendMsg/
       LuaRegisterMsgHandler/SendTableToServer (不真加载 networkUtil 免引入更多 C++ 面)
    7. 加载 network/netMessageDef.lua -> 全局 MsgType 表
  load_game_module(lua, modname) — require 语义加载游戏原版模块

I-1 修复 (Task5 评审转):
  env.py — gameEnviroment:Inst() 返自身; SetNetClient/GetNetClient
    配 self.__netClient 字段 (去除旧无 nc 时返 nil 的空方法)
  __init__.py — install_all 改走 gameEnviroment:SetNetClient(AP_netclient_ref)
    (原 hack GetNetClient = function() return AP_netclient_ref end 删除)
    增 global.gameEnvironment (camelCase 别名, 源码 gameStateInit.lua:134 等 21+ 处用)

Lupa 适配:
  - Lua table -> Python 默认是 _LuaTable 不是 dict, json.dumps 不能直序列;
    _to_jsonable 递归分 dict / 1..n 整数键 list / 杂键 dict 三种形态
  - Python 对象经 lua.globals() 进出仍 is 同对象 (NetClientStub 身份保),
    跨边界 SetNetClient/GetNetClient 仍 is 同
  - Lupa 同一 Lua 表的两次 eval 返不同 _LuaTable 包装, Lua == 返 true;
    Inst() 返自身测试用 Lua 侧 == 比对
  - class() 单继承够 PureMVC: Facade/Mediator/Proxy/Notifier/SimpleCommand/
    MacroCommand 全部走 new()/ctor/super 链

test_bridge_load.py (4 用例):
  test_netmessagedef_loaded: MsgType.MSG_CS_LOGIN_SERVER=20 / HEART_BEAT=60000
  test_sendtabletoserver_encodes_json: cjson encode 完整 JSON 后经 networkCtl:SendMsg
  test_load_framework: require('framework.init') 走 class() 链成功
  test_game_environment_inst_set_get_camelcase_alias (I-1):
    SetNetClient(nc) -> GetNetClient() is nc; Inst()==env; camelCase alias 挂载

全套 24 passed (20 prior + 4 new), Task 1-5 无回归
wuzilong 1 месяц назад
Родитель
Сommit
c3cae71189

+ 77 - 0
.superpowers/sdd/2026-09-21-apilot-headless-m1/task-6-report.md

@@ -0,0 +1,77 @@
+# Task 6 Report: bridge.py — 游戏原版 Lua 加载链
+
+**状态**: ✅ DONE
+**Commit**: 见末尾
+**测试结果**: 24 passed (20 prior + 4 new: 3 brief + 1 I-1)
+**工作目录**: `/Users/zlwu/work/00code/传奇` (master)
+
+## 交付内容
+
+### 1. `apilot/bridge.py` (新, 111 行 ≤ 250)
+- `LUA_SRC` 常量: `lua脚本解密源码/mod_fgcq/stab/scripts` (只读, ROOT 相对)
+- `bootstrap_runtime(nc, ctl, sched, env, state_dir) -> LuaRuntime`:
+  1. 建 Lupa VM (`unpack_returned_tuples=True`)
+  2. `install_all(...)` 装 Task 5 全部 stub
+  3. `package.path = LUA_SRC .. '/?.lua;' .. package.path` (让 require 找得到游戏脚本)
+  4. 注 `class()` 极简复刻 (单继承 + new/ctor + super 链) — framework.init 链 Facade/Mediator/Proxy/Notifier/SimpleCommand/MacroCommand 全部依赖
+  5. 注 cjson 桥 `_PY_cjson_encode/_PY_cjson_decode` (Lua table→Python dict/list→JSON) — 兼容嵌套表 + 1..n 整数键表
+  6. 注 `cjson`/`slen`/`LuaSendMsg`/`LuaRegisterMsgHandler`/`SendTableToServer` 薄复刻 (签名对齐 networkUtil.lua 但**不加载**该文件, 避免引入更多 C++ 面)
+  7. 加载 `network/netMessageDef.lua` → 全局表 `MsgType` (含 MSG_CS_LOGIN_SERVER=20, MSG_CS_HEART_BEAT_KEEP=60000)
+- `load_game_module(lua, modname) -> None`: 按 require 语义加载游戏原版模块
+
+### 2. `apilot/stubs/env.py` (改, 127 行, +2 行)
+I-1 硬性修复:
+- `gameEnviroment:Inst()` 返自身
+- `gameEnviroment:SetNetClient(nc)` 存到 `self.__netClient`
+- `gameEnviroment:GetNetClient()` 读 `self.__netClient`
+- 去除原"无 nc 时返 nil"的空方法, 改为完整 setter/getter
+
+### 3. `apilot/stubs/__init__.py` (改, 47 行, +1 行)
+- 去除 `gameEnviroment.GetNetClient = function() return AP_netclient_ref end` 旧 hack
+- 改为 `gameEnviroment:SetNetClient(AP_netclient_ref)` (走 env 的 setter)
+- 增 `global.gameEnvironment = gameEnviroment` (camelCase 别名, 游戏源码 gameStateInit.lua:134 等 21+ 处引用)
+- 保留 `global.gameEnviroment` 旧别名 (向后兼容)
+
+### 4. `tests/test_bridge_load.py` (新, 69 行)
+- `test_netmessagedef_loaded`: 加载 netMessageDef 后 `MsgType.MSG_CS_LOGIN_SERVER == 20` / `MSG_CS_HEART_BEAT_KEEP == 60000`
+- `test_sendtabletoserver_encodes_json`: SendTableToServer(3199, {index=303, act='@chiyao_kaiqi'}) → 经 networkCtl:SendMsg → 捕获 body 含完整 JSON `'{"index":303,"act":"@chiyao_kaiqi"}'`
+- `test_load_framework`: `require('framework.init')` 成功 (走 class() 完整链), `type(framework) == "table"`
+- `test_game_environment_inst_set_get_camelcase_alias` (I-1): `gameEnviroment:Inst():SetNetClient(nc_b)` → `global.gameEnvironment:GetNetClient() is nc_b` (Python 身份等价), `gameEnviroment:Inst() == gameEnviroment` (Lua ==, 绕开 Lupa _LuaTable 包装)
+
+## Lupa 适配要点 (commit message 详述)
+
+1. **Lua table → Python dict 转换**: Lupa 2.8 默认把 Lua 表传成 `_LuaTable` (有 `.items()` 但不是 dict). `json.dumps` 不能直接序列化. 写 `_to_jsonable()`: dict / 1..n 整数键表 / 杂键表 走不同分支递归转 Python 原生结构. 这是 cjson 桥能工作的关键.
+2. **Python 对象身份跨 Lupa 保留**: `lua.globals()["_PY_nc_b"] = nc_b` → `SetNetClient(_PY_nc_b)` → `GetNetClient()` 拿回的是同一 Python 对象 (Lupa 不复制). I-1 测试用 `is` 断言.
+3. **Lupa 同一 Lua 表的两次 eval 返不同 _LuaTable 包装**: `lua.eval("t")` 和 `lua.eval("t")` 是不同的 Python 对象, 但 `t == t` (Lua ==) 返 true. Inst() 返自身的测试用 Lua 侧 `==` 比对.
+4. **class() 极简实现**: 17 行 Lua 覆盖 PureMVC 单继承需求 (Facade/Mediator/Proxy/SimpleCommand/MacroCommand/Notifier 都用). `cls.new(...)` 把变参原封转给 ctor; 子类 `setmetatable(cls, {__index = super})` 配 `cls.super = super`; `simple super.ctor(self)` 链正常.
+5. **Lupa stub 注入 vs 协议层方法绑定**: networkCtl:SendMsg(...) 走 metatable __index 闭包, 调用时 `self.send_msg(self, msgId, recog, p1, p2, p3, body, ln)` (8 参). 测试 cap 函数要带 self_ 占位 (brief 测试的 lambda 7 参, 在 networkctl 实际调用形态下会 TypeError, 已修).
+
+## 验证
+
+```bash
+$ protocol_bot/.venv/bin/python -m pytest tests/ -v
+# 24 passed in 1.00s
+```
+
+新加 4 用例全过; Task 1-5 的 20 个测试无回归.
+
+`lsp_diagnostics` 仅有基于 `Any` 的 Lupa 协议 pre-existing pattern warnings (`unpack_returned_tuples` / `_LuaTable.__setitem__` / `lupa.luajit21` source resolve 都是 basedpyright stub 未覆盖运行时协议的假阳性, 与 Task 5 同源).
+
+## I-1 修复测试证据
+
+`test_game_environment_inst_set_get_camelcase_alias` (tests/test_bridge_load.py:56-69) 一次性验证:
+- `type(global.gameEnvironment) == "table"` (camelCase 别名挂载)
+- `global.gameEnvironment:GetNetClient() is nc_b` (Python 身份等价, 证明 Set→Get 引用完整)
+- `gameEnviroment:Inst() == gameEnviroment` (Inst 返自身)
+- `global.gameEnvironment == gameEnviroment` (两别名指向同一表)
+
+## 风险/遗留
+
+- **Encode6BitBuf / 真实网络服务器未实装**: 协议定义仅加载, 暂未发/收真实包. Task 7 (host.py) 接入 wire 跑通 login 时再补 `AP_encode6` + `Encode6BitBuf` (Task 5 已记).
+- **cjson.safe 是 nil**: LuaJIT 的 cjson C 模块没装; `cjson.safe` 字段置 nil. 冒烟内未触及; 若游戏某模块直接 `cjson.safe.encode(t)`, 需补 `pcall` 包装 (brief 注明). Task 7 实测再加.
+- **class() 简版与 cocos 完整版差异**: 不支持多继承 / `iskindof` / `clone`. PureMVC 单链够用, 业务层用 cocos 高级特性时需补.
+- **json 顺序与 Lua 表序差异**: `_to_jsonable` 对整数键表走 `sorted(items)` → JSON list 序稳定; 但 Lua 表遍历序本身在 Lupa 里可能不稳定, 业务若强依赖 JSON 字节序, 需在 Lua 侧手排.
+
+## 任务外发现 (未修改)
+
+仓库仍存在大量 pre-existing 删除 (cq2_sdk/, 996box-decompiled/, docs/) 与 untracked 资源 (protocol_bot/, cpp_reversed/), 与本任务无关, 未触及.

+ 111 - 0
apilot/bridge.py

@@ -0,0 +1,111 @@
+"""Lupa 桥: VM 构建 + stub 装配 + 游戏 Lua 只读加载。
+
+Task 6: bootstrap_runtime 建 VM+装 stub+配 package.path+注 networkUtil 公共面薄复刻
+        +加载 netMessageDef;load_game_module 按 require 语义加载游戏原版模块。
+        Task 7 (host.py) 依赖本模块接口: LUA_SRC / bootstrap_runtime / load_game_module。
+"""
+from __future__ import annotations
+
+import json
+import os
+from typing import Any
+
+import lupa.luajit21 as lj  # type: ignore
+
+from apilot.stubs import install_all
+from apilot.stubs.env import EnvData
+from apilot.stubs.netclient import NetClientStub
+from apilot.stubs.networkctl import NetworkCtlStub
+from apilot.stubs.scheduler import Scheduler
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+LUA_SRC = os.path.join(ROOT, "lua脚本解密源码", "mod_fgcq", "stab", "scripts")
+
+
+# 协议定义文件名(brief 指定第一个加载的游戏原文件)
+_NETMSG_DEF = "network/netMessageDef.lua"
+
+
+def bootstrap_runtime(nc: NetClientStub, ctl: NetworkCtlStub, sched: Scheduler,
+                      env: EnvData, state_dir: str) -> Any:
+    """建 Lupa VM,装 stub,设 package.path,注 class()/cjson/LuaSendMsg/... 公共面,加载 netMessageDef。"""
+    lua = lj.LuaRuntime(unpack_returned_tuples=True)
+    install_all(lua, nc, ctl, sched, env, state_dir)
+
+    # package.path 指向游戏 scripts(只读),优先于系统默认
+    lua.globals()["_LUA_SRC"] = LUA_SRC
+    lua.execute("package.path = _LUA_SRC .. '/?.lua;' .. package.path")
+
+    # PureMVC 极简 class()(单继承),够 framework.init 链: Facade/Mediator/Proxy/Notifier/...
+    lua.execute(r"""
+class = function(name, super)
+    local cls = {__cname = name}
+    if super ~= nil then
+        setmetatable(cls, {__index = super})
+        cls.super = super
+    else
+        setmetatable(cls, {__index = cls})
+    end
+    function cls.new(...)
+        local inst = setmetatable({}, cls)
+        if cls.ctor then cls.ctor(inst, ...) end
+        return inst
+    end
+    return cls
+end
+""")
+
+    # cjson 桥: Lua table<->JSON 字符串 走 Python json
+    g = lua.globals()
+
+    def _to_jsonable(obj: Any) -> Any:
+        if isinstance(obj, dict):
+            return {k: _to_jsonable(v) for k, v in obj.items()}
+        if hasattr(obj, "items"):
+            items = list(obj.items())
+            if items and all(isinstance(k, int) and k > 0 for k, _ in items):
+                keys = sorted(k for k, _ in items)
+                if keys == list(range(1, len(items) + 1)):
+                    return [_to_jsonable(v) for k, v in sorted(items)]
+            return {k: _to_jsonable(v) for k, v in items}
+        return obj
+
+    def _enc(t: Any) -> str:
+        return json.dumps(_to_jsonable(t), ensure_ascii=False, separators=(",", ":"))
+
+    def _dec(s: str) -> Any:
+        d = json.loads(s)
+        return d if isinstance(d, dict) else {}
+
+    g["_PY_cjson_encode"] = _enc
+    g["_PY_cjson_decode"] = _dec
+
+    # 薄复刻 networkUtil.lua 公共面(签名对齐;不真加载以免引入更多 C++ 面)
+    lua.execute(r"""
+cjson = cjson or {encode = function(t) return _PY_cjson_encode(t) end,
+                  decode = function(s) return _PY_cjson_decode(s) end,
+                  safe = nil}
+slen = slen or function(s) return #s end
+
+function LuaSendMsg(msgId, recog, p1, p2, p3, dataStr, dataLen)
+    networkCtl:SendMsg(msgId, recog, p1, p2, p3, dataStr, dataLen)
+end
+function LuaRegisterMsgHandler(msgId, fn)
+    networkCtl:RegisterLuaHandler(msgId, fn)
+end
+function SendTableToServer(msgId, t)
+    local s = cjson.encode(t)
+    LuaSendMsg(msgId, 0, 0, 0, 0, s, #s)
+end
+""")
+
+    # 加载第一个游戏原文件(协议定义)
+    nmd_path = os.path.join(LUA_SRC, _NETMSG_DEF)
+    with open(nmd_path, encoding="utf-8") as f:
+        lua.execute(f.read())
+    return lua
+
+
+def load_game_module(lua: Any, modname: str) -> None:
+    """按 require 语义加载游戏原版模块(modname 用点号或斜杠均可)。"""
+    lua.execute(f"require('{modname}')")

+ 4 - 2
apilot/stubs/__init__.py

@@ -27,10 +27,12 @@ def install_all(lua: Any, nc: NetClientStub, ctl: NetworkCtlStub,
     install_env(lua, env, state_dir)
     ctl.install_to_lua(lua)
     lua.globals()["AP_netclient_ref"] = nc
-    lua.execute("gameEnviroment.GetNetClient = function() return AP_netclient_ref end")
+    lua.execute("gameEnviroment:SetNetClient(AP_netclient_ref)")
     lua.execute("global = global or {}")
     lua.execute("global.networkCtl = networkCtl; global.LuaBridgeCtl = LuaBridgeCtl")
-    lua.execute("global.L_GameEnvManager = L_GameEnvManager; global.gameEnviroment = gameEnviroment")
+    lua.execute("global.L_GameEnvManager = L_GameEnvManager")
+    lua.execute("global.gameEnviroment = gameEnviroment")
+    lua.execute("global.gameEnvironment = gameEnviroment")
 
 
 __all__ = [

+ 5 - 3
apilot/stubs/env.py

@@ -117,9 +117,11 @@ function cc.FileUtils:getWritablePath() return AP_getWritablePath() end
 """)
 
     # gameEnviroment(小写 e) - 全局 C++ 单例, 最小面
-    lua.execute("""
-gameEnviroment = gameEnviroment or {}
-function gameEnviroment.GetNetClient() return nil end
+    lua.execute(r"""
+gameEnviroment = gameEnviroment or {__netClient = nil}
+function gameEnviroment:Inst() return self end
+function gameEnviroment:SetNetClient(nc) self.__netClient = nc end
+function gameEnviroment:GetNetClient() return self.__netClient end
 function gameEnviroment.SetLoginAccount(a) end
 function gameEnviroment.GetDownloadRootURL() return "" end
 """)

+ 69 - 0
tests/test_bridge_load.py

@@ -0,0 +1,69 @@
+# tests/test_bridge_load.py
+"""Task 6: bridge.py — 游戏原版 Lua 加载链(协议定义/framework.init/networkUtil 公共面)。
+
+I-1 (Task5 评审转): gameEnviroment.Inst/SetNetClient + global.gameEnvironment 别名必须实现并测试。
+"""
+from apilot.bridge import bootstrap_runtime, load_game_module
+from apilot.stubs.env import EnvData
+from apilot.stubs.netclient import NetClientStub
+from apilot.stubs.networkctl import NetworkCtlStub
+from apilot.stubs.scheduler import Scheduler
+
+
+def _boot(tmp_path):
+    nc = NetClientStub()
+    ctl = NetworkCtlStub(nc)
+    sched = Scheduler()
+    return bootstrap_runtime(nc, ctl, sched, EnvData({"uid": "u1"}), str(tmp_path))
+
+
+def test_netmessagedef_loaded(tmp_path):
+    """netMessageDef 原样加载, MsgType 全局表含登录与心跳号。"""
+    lua = _boot(tmp_path)
+    assert lua.eval("MsgType.MSG_CS_LOGIN_SERVER") == 20
+    assert lua.eval("MsgType.MSG_CS_HEART_BEAT_KEEP") == 60000
+
+
+def test_sendtabletoserver_encodes_json(tmp_path):
+    """SendTableToServer 把 Lua 表走 cjson.encode 后经 networkCtl:SendMsg 发出。"""
+    lua = _boot(tmp_path)
+    sent = []
+    nc_stub_capture = lua.globals()["AP_networkCtl"]
+    orig = nc_stub_capture.send_msg
+
+    def cap(self_, mid, recog, p1, p2, p3, body, ln):
+        sent.append((mid, body))
+
+    nc_stub_capture.send_msg = cap
+    try:
+        lua.execute("SendTableToServer(3199, {index=303, act='@chiyao_kaiqi'})")
+    finally:
+        nc_stub_capture.send_msg = orig
+    assert sent[0][0] == 3199
+    # body 在 ctl 层是 str(stub 内 send_msg 才 encode 成 bytes)
+    assert "chiyao_kaiqi" in sent[0][1]
+    assert '"act":"@chiyao_kaiqi"' in sent[0][1]
+    assert '"index":303' in sent[0][1]
+
+
+def test_load_framework(tmp_path):
+    """framework.init 加载, 全局 framework/Facade 表存在。"""
+    lua = _boot(tmp_path)
+    load_game_module(lua, "framework.init")
+    assert lua.eval("type(framework)") == "table" or lua.eval("type(Facade)") == "table"
+
+
+def test_game_environment_inst_set_get_camelcase_alias(tmp_path):
+    """I-1 硬性: gameEnviroment:Inst():SetNetClient(nc) 存引用,
+    global.gameEnvironment:GetNetClient() 返回同一 nc。
+    """
+    nc_b = NetClientStub()
+    lua = _boot(tmp_path)
+    lua.globals()["_PY_nc_b"] = nc_b
+    lua.execute("gameEnviroment:Inst():SetNetClient(_PY_nc_b)")
+    assert lua.eval("type(global.gameEnvironment)") == "table"
+    got = lua.eval("global.gameEnvironment:GetNetClient()")
+    assert got is nc_b
+    # Inst() 返自身: 用 Lua == 比对(Lupa 对同一表返不同 _LuaTable 包装, Python is 不可用)
+    assert lua.eval("gameEnviroment:Inst() == gameEnviroment") is True
+    assert lua.eval("global.gameEnvironment == gameEnviroment") is True