For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 在无游戏客户端的机器上,用 Python+Lupa(LuaJIT 2.1)驱动游戏原版 Lua 的 LoginProxy 完成真实登录链并创建角色(逐鹿05区实测通过)。
Architecture: Python 宿主进程内嵌 Lupa LuaJIT;游戏原版 Lua(lua脚本解密源码/ 的 netMessageDef/framework/networkUtil/LoginProxy 等协议层)只读复用;Python stub 冒充 C++ 绑定面(netClient/networkCtl/Scheduler/环境类),网络重活直通已验证的 wire 层(Encode6BitBuf/帧编解码/socket)。
Tech Stack: Python 3.12、Lupa(源码构建 luajit21,tools/ 已验证)、LuaJIT 2.1、pytest。
Spec: docs/superpowers/specs/2026-09-21-apilot-headless-design.md
lua脚本解密源码/ 与其镜像 lua_src/ 禁止修改(设计 D3)protocol_bot/.venv,已装 lupa-luajit21 补丁版)s{zone}-jycq-206923.dhsf.xqhuyu.com@pytest.mark.live 标记,默认跳过(-m live 显式开启),单元测试零网络依赖Any 转换逃逸apilot/
├── __init__.py
├── wire.py # 协议线层:socket+帧编解码+Encode6BitBuf(从 cq2_pure_enter 提炼,类化)
├── bridge.py # Lupa 桥:LuaRuntime 构建+游戏Lua加载+stub注入+tick循环
├── stubs/
│ ├── __init__.py # install_all(lua, ctx) 装配入口
│ ├── noop.py # __index 万能 no-op 工厂(cc.Sprite 等 C 类)
│ ├── netclient.py # netClient 单例 stub(Connect/Disconnect/Send/Tick/sdata)
│ ├── networkctl.py # LuaNetworkController stub(SendMsg/RegisterLuaHandler)
│ ├── scheduler.py # Scheduler/PerformWithDelayGlobal 驱动
│ └── env.py # L_GameEnvManager/FileUtils/gameEnviroment/LuaBridgeCtl 环境注入
├── host.py # 宿主:装配一切,暴露 run(zone, account) / tick 循环
└── main.py # CLI: python -m apilot.main --zone 14481607 --create-role
tests/
├── test_wire.py # 帧编解码/Encode6BitBuf 回环(纯单测)
├── test_noop.py # no-op 工厂行为
├── test_scheduler.py # 计时轮
├── test_bridge_load.py# 游戏Lua加载冒烟(netMessageDef/framework/networkUtil)
└── test_live_t01.py # [live] 无客户端创角端到端
lua_src -> ../lua脚本解密源码 # 符号链接(M1 不复制,保持只读引用)
Files:
apilot/__init__.py(空)apilot/wire.pytests/test_wire.pyInterfaces:
protocol_bot/encode6bitbuf_final.py 的 encode6/decode6(直接 import,不复制)Produces:
@dataclass Frame: recog:int, msg_id:int, p1:int, p2:int, p3:int, tail:int, body:bytesdef pack_frame(f: Frame) -> bytes — 9×int 小端头 + body,整体 encode6def unpack_stream(buf: bytes) -> tuple[list[Frame], bytes] — 返回(完整帧列表, 剩余半包字节)class Wire: __init__(host, port, timeout=10); send(f: Frame) -> None; recv(deadline_s: float) -> list[Frame]; close() — 同步 socket,recv 循环收满 deadline[ ] Step 1: 写失败测试(帧回环)
# tests/test_wire.py
import pytest
from apilot.wire import Frame, pack_frame, unpack_stream
def test_frame_roundtrip():
f = Frame(recog=900000, msg_id=20, p1=123, p2=14481607, p3=8043,
tail=7, body=b"*#acct/role/12345/9/1/0/110585/0/1/1/1/1")
packed = pack_frame(f)
frames, rest = unpack_stream(packed)
assert rest == b""
assert len(frames) == 1
g = frames[0]
assert (g.recog, g.msg_id, g.p1, g.p2, g.p3, g.tail, g.body) == \
(f.recog, f.msg_id, f.p1, f.p2, f.p3, f.tail, f.body)
def test_unpack_partial_packet():
f = Frame(recog=0, msg_id=529, p1=0, p2=0, p3=0, tail=1, body=b"key32abc")
packed = pack_frame(f)
frames, rest = unpack_stream(packed[:-10]) # 掐掉10字节
assert frames == [] and len(rest) == len(packed) - 10
Run: cd /Users/zlwu/work/00code/传奇 && protocol_bot/.venv/bin/python -m pytest tests/test_wire.py -v
Expected: FAIL(ModuleNotFoundError: apilot)
# apilot/wire.py
"""协议线层: 24B头(9×i32)+体, 全帧 Encode6BitBuf。提炼自 protocol_bot/cq2_pure_enter.py(实测验证)。"""
from __future__ import annotations
import socket
import struct
import sys
import time
from dataclasses import dataclass
sys.path.insert(0, "protocol_bot")
from encode6bitbuf_final import encode6, decode6 # type: ignore
HDR = struct.Struct("<9i") # recog,msg,p1,p2,p3,zero?,len,tail → 对齐 cq2_pure_enter.send()
@dataclass
class Frame:
recog: int
msg_id: int
p1: int
p2: int
p3: int
tail: int
body: bytes = b""
def pack_frame(f: Frame) -> bytes:
head = HDR.pack(0, f.msg_id, f.recog, f.p1, f.p2, f.p3, 0, len(f.body), f.tail)
return encode6(head) + f.body
def unpack_stream(buf: bytes) -> tuple[list[Frame], bytes]:
out, pos = [], 0
while pos + 48 <= len(buf):
try:
m = HDR.unpack(decode6(buf[pos:pos + 48]))
except Exception:
break
body_len = m[7]
if pos + 48 + body_len > len(buf):
break
out.append(Frame(recog=m[2], msg_id=m[1], p1=m[3], p2=m[4], p3=m[5],
tail=m[8], body=buf[pos + 48:pos + 48 + body_len]))
pos += 48 + body_len
return out, buf[pos:]
class Wire:
"""同步 TCP 线客户端。"""
def __init__(self, host: str, port: int, timeout: float = 10) -> None:
self.sock = socket.create_connection((host, port), timeout=timeout)
def send(self, f: Frame) -> None:
self.sock.sendall(pack_frame(f))
def recv(self, deadline_s: float) -> list[Frame]:
deadline = time.time() + deadline_s
buf = b""
while time.time() < deadline:
self.sock.settimeout(max(0.2, deadline - time.time()))
try:
d = self.sock.recv(65536)
if not d:
break
buf += d
except (socket.timeout, ConnectionResetError, OSError):
break
frames, _ = unpack_stream(buf)
return frames
def close(self) -> None:
try:
self.sock.close()
except OSError:
pass
注意:头字段序对照 cq2_pure_enter.py send() 的 struct.pack("<9i", 0, mid, recog, p1, p2, p3, 0, len, tail)——HDR.pack(0, msg_id, recog, ...) 顺序一致;unpack 侧 m[0]=零位,m[1]=msg,m[2]=recog。实现时以 tests/test_wire.py 回环 + 下面的真实样本双验证。
# tests/test_wire.py 追加
def test_pack_matches_reference_impl():
"""与 cq2_pure_enter.send() 的原始拼包逐字节一致(防字段序回归)。"""
import struct, random
sys_path_hack = __import__("sys"); sys_path_hack.path.insert(0, "protocol_bot")
from encode6bitbuf_final import encode6
random.seed(42)
body = b"*#1186115037/role/1/2/3/1/0/110585/0/1/1/1/1"
ref = encode6(struct.pack("<9i", 0, 20, 900000, 111, 14481607, 8043, 0, len(body), 9)) + body
mine = pack_frame(Frame(recog=900000, msg_id=20, p1=111, p2=14481607, p3=8043, tail=9, body=body))
assert mine == ref
Run: protocol_bot/.venv/bin/python -m pytest tests/test_wire.py -v
Expected: 3 passed
git add apilot/ tests/test_wire.py && git commit -m "feat(apilot): wire layer with verified frame codec"
Files:
apilot/stubs/__init__.py(空)apilot/stubs/noop.pytests/test_noop.pyInterfaces:
def make_noop_table(name: str) -> dict — 任意属性访问/调用均安全的嵌套表;def install_noop_globals(lua) -> None — 向 Lua 全局注入 cc 骨架(Sprite/Node/Director 等 C 类的 no-op)与 ccui/ccs/ccexp 空命名空间Consumes: Lupa LuaRuntime(传入即可调用)
[ ] Step 1: 写失败测试
# tests/test_noop.py
import lupa.luajit21 as lj
def test_noop_table_safe_access():
from apilot.stubs.noop import make_noop_table
t = make_noop_table("cc")
assert t["Sprite"] is not None # 任意键存在
assert callable(t["Sprite"]["create"]) # 任意层级可调
obj = t["Sprite"]["create"]()
assert obj["getPosition"]() == (0, 0) # 调用返回 no-op 对象(继续安全)
def test_install_noop_globals_in_lua():
from apilot.stubs.noop import install_noop_globals
rt = lj.LuaRuntime()
install_noop_globals(rt)
rt.execute("local s = cc.Sprite:create(); s:setPosition(1,2); s:addChild(cc.Node:create())")
rt.execute("cc.Director:getInstance():getScheduler()")
# 无异常即通过
Run: protocol_bot/.venv/bin/python -m pytest tests/test_noop.py -v
Expected: FAIL(ModuleNotFoundError)
# apilot/stubs/noop.py
"""万能 no-op 工厂: 冒充 tolua/cc 渲染类。属性=同款工厂, 调用=返回同款对象。"""
from __future__ import annotations
from typing import Any
_NOOP_RETURN_DEFAULTS: tuple[tuple[tuple, Any], ...] = (
# (参数个数, 返回值) —— 需要非 nil 返回的接口按绑定面清单加
(("getPosition",), (0, 0)),
)
def make_noop_table(name: str = "?", _depth: int = 0) -> Any:
if _depth > 24:
return None
class _Noop:
def __init__(self, n: str) -> None:
self.__name = n
def __getitem__(self, k: str) -> Any:
return make_noop_table(f"{self.__name}.{k}", _depth + 1)
def __getattr__(self, k: str) -> Any:
return self[k]
def __call__(self, *a: Any, **kw: Any) -> Any:
return make_noop_table(f"{self.__name}()", _depth + 1)
def __repr__(self) -> str:
return f"<noop:{self.__name}>"
return _Noop(name)
class _ReturnZeroPair:
"""getPosition 等双值返回的特化包装, M1 暂不启用(绑定面清单核对后启用)。"""
def install_noop_globals(lua: Any) -> None:
"""注入 cc/ccui/ccs/ccexp 骨架。真实实现(netClient 等)在装完 no-op 后由各自模块覆盖。"""
lua.execute("""
cc = cc or {}
setmetatable(cc, {__index = function(t, k)
local v = setmetatable({}, {
__index = function(tt, kk)
local fn = function(...) return setmetatable({}, {
__index = function(t3, k3)
return function(...) return nil end
end}) end
return fn end,
__call = function(cls, ...) return setmetatable({}, {
__index = function(o, k)
return function(...) return nil end end}) end})
rawset(t, k, v)
return v
end})
ccui = ccui or cc.Sprite -- 同骨架
ccs = ccs or cc.Sprite
ccexp = ccexp or cc.Sprite
""")
注:_NOOP_RETURN_DEFAULTS M1 保留占位但不必使用——Lua 侧 local x, y = obj:getPosition() 收到两个 nil 在纯逻辑路径(登录链)无碍;若实测崩溃再特化。
Run: protocol_bot/.venv/bin/python -m pytest tests/test_noop.py -v → 2 passed
git add apilot/stubs/ tests/test_noop.py && git commit -m "feat(apilot): universal noop stub factory for tolua/cc classes"
Files:
apilot/stubs/scheduler.pytests/test_scheduler.pyInterfaces:
class Scheduler: tick(dt: float) -> None(驱动所有已注册回调);schedule(fn, interval_s: float) -> int 返回 entry id;unschedule(entry_id: int) -> None;delay(fn, delay_s: float) -> int(一次性)def install_scheduler(lua, sched) -> None — 提供 Lua 侧 global.Scheduler(方法面 scheduleScriptFunc/unscheduleScriptEntry)与全局 PerformWithDelayGlobal(fn, delay) / Schedule(fn, interval) / UnSchedule(id)Consumes: Task 2 的 install_noop_globals(先装,cc.Director:getInstance():getScheduler() 返回本对象——在 install 内覆盖)
[ ] Step 1: 写失败测试
# tests/test_scheduler.py
import lupa.luajit21 as lj
from apilot.stubs.scheduler import Scheduler, install_scheduler
def test_interval_and_oneshot():
s = Scheduler()
hits = []
s.schedule(lambda: hits.append("i"), 0.05)
s.delay(lambda: hits.append("d"), 0.1)
for _ in range(30):
s.tick(0.05)
assert hits.count("i") >= 2
assert hits.count("d") == 1
def test_lua_side_globals():
rt = lj.LuaRuntime()
from apilot.stubs.noop import install_noop_globals
install_noop_globals(rt)
s = Scheduler()
install_scheduler(rt, s)
rt.execute("global = global or {}; global.Scheduler = SL_getScheduler()")
# install_scheduler 需暴露一个 Lua 可达引用; 简化: 直接用注入的全局函数
rt.execute("n = 0; id = PerformWithDelayGlobal(function() n = n + 1 end, 0)")
for _ in range(3):
s.tick(0.05)
assert rt.eval("n") == 1
[ ] Step 2: 确认失败 → Run: pytest tests/test_scheduler.py -v → FAIL
[ ] Step 3: 实现 scheduler.py
# apilot/stubs/scheduler.py
"""计时轮: Python 侧驱动, Lua 侧全局函数注入。对齐游戏 50ms tick 节奏。"""
from __future__ import annotations
import itertools
from typing import Callable
class Scheduler:
def __init__(self) -> None:
self._ids = itertools.count(1)
self._timers: dict[int, tuple[Callable[[], None], float, float, bool]] = {}
# id -> (fn, interval, elapsed, repeat)
def schedule(self, fn: Callable[[], None], interval_s: float) -> int:
eid = next(self._ids)
self._timers[eid] = (fn, interval_s, 0.0, True)
return eid
def delay(self, fn: Callable[[], None], delay_s: float) -> int:
eid = next(self._ids)
self._timers[eid] = (fn, delay_s, 0.0, False)
return eid
def unschedule(self, eid: int) -> None:
self._timers.pop(eid, None)
def tick(self, dt: float) -> None:
for eid in list(self._timers):
fn, interval, elapsed, repeat = self._timers[eid]
elapsed += dt
if elapsed >= interval:
if repeat:
self._timers[eid] = (fn, interval, elapsed - interval, True)
else:
self._timers.pop(eid)
fn()
def install_scheduler(lua, sched: Scheduler) -> None:
py_sched = sched # Lupa: Python 对象直接可见
def lua_schedule(fn, interval: float) -> int:
return py_sched.delay(fn, float(interval)) # M1: 游戏里 Schedule 多为一次性轮询,统一 delay;需循环时改 schedule
def lua_perform_delay(fn, delay_s: float) -> int:
return py_sched.delay(fn, float(delay_s))
def lua_unschedule(eid: int) -> None:
py_sched.unschedule(int(eid))
lua.globals()["PerformWithDelayGlobal"] = lua_perform_delay
lua.globals()["Schedule"] = lua_schedule
lua.globals()["UnSchedule"] = lua_unschedule
lua.globals()["SL_getScheduler"] = lambda: py_sched
实现注意:Schedule() 在游戏 networkUtil/util 里既用于一次性也用于循环——M1 先统一按 delay,跑 T01 冒烟时若发现循环语义缺失,改 lua_schedule 加第三参判断(nil=循环),并在本任务测试补一个循环用例。
Run: pytest tests/test_scheduler.py -v → 2 passed
git add apilot/stubs/scheduler.py tests/test_scheduler.py && git commit -m "feat(apilot): python-driven scheduler wheel with lua globals"
Files:
apilot/stubs/netclient.pyapilot/stubs/networkctl.pytests/test_network_stubs.py(环回 socket 模拟网关)Interfaces:
Wire/FrameProduces:
class NetClientStub: connect(host, port); disconnect(); send_raw(bytes); tick(deadline_s)->list[Frame]; set_sdata(v)/get_sdata()->int; is_connected()->bool — Python 侧对象,持有 Wireclass NetworkCtlStub: send_msg(msg_id, recog, p1, p2, p3, data_str, data_len) -> None(组 Frame→netclient 发送);register_lua_handler(msg_id, cb)(cb 是 Lua 函数);unregister_all()def make_msg_object(lua, frame: Frame) — 构造 Lua 侧 msg stub table:GetMsgId()/GetHeader()→{recog,param1,param2,param3,msgId}/GetDataLength()/GetData():ReadString(len)def dispatch_frame(lua, ctl: NetworkCtlStub, frame: Frame) -> None — 查 handler→构造 msg→调用 Lua cb[ ] Step 1: 写失败测试(本地环回)
# tests/test_network_stubs.py
import socket, threading, struct, sys
import lupa.luajit21 as lj
sys.path.insert(0, "protocol_bot")
from encode6bitbuf_final import encode6
from apilot.wire import Frame
from apilot.stubs.netclient import NetClientStub
from apilot.stubs.networkctl import NetworkCtlStub, dispatch_frame
def _fake_gateway(server_sock):
"""收一帧, 回一帧 529。"""
conn, _ = server_sock.accept()
data = conn.recv(65536)
# 解析 client 帧(跳过编解码,直接回一个 529)
body = encode6(b"key32/session123")
head = struct.pack("<9i", 1, 529, 0, 0, 0, 0, 0, len(body), 1)
conn.sendall(encode6(head) + body)
conn.close()
def test_sendmsg_roundtrip_and_dispatch():
srv = socket.socket(); srv.bind(("127.0.0.1", 0)); srv.listen(1)
port = srv.getsockname()[1]
threading.Thread(target=_fake_gateway, args=(srv,), daemon=True).start()
rt = lj.LuaRuntime()
nc = NetClientStub(); nc.connect("127.0.0.1", port)
ctl = NetworkCtlStub(nc)
ctl.send_msg(2001, 123456, 1, 2, 3, b"uid/key/ch/1/0", 19) # 网关应收到
frames = nc.tick(1.0)
assert any(f.msg_id == 529 for f in frames)
# 注册 Lua handler 并分发
rt.execute("""
got = nil
function on_529(msg)
local h = msg:GetHeader()
local data = msg:GetData()
got = h.msgId .. ":" .. data:ReadString(5)
end
""")
ctl.register_lua_handler(529, rt.eval("on_529"))
dispatch_frame(rt, ctl, [f for f in frames if f.msg_id == 529][0])
assert rt.eval("got") == "529:key3"
[ ] Step 2: 确认失败 → FAIL
[ ] Step 3: 实现 netclient.py / networkctl.py
# apilot/stubs/netclient.py
"""netClient C++ 单例的 stub: 直接持有 Wire。"""
from __future__ import annotations
from apilot.wire import Frame, Wire
class NetClientStub:
def __init__(self) -> None:
self.wire: Wire | None = None
self._sdata: int = 0
def connect(self, host: str, port: int, timeout: float = 10) -> None:
self.wire = Wire(host, port, timeout)
def disconnect(self) -> None:
if self.wire:
self.wire.close()
self.wire = None
def is_connected(self) -> bool:
return self.wire is not None
def send_raw(self, data: bytes) -> None:
assert self.wire, "netClient not connected"
self.wire.sock.sendall(data)
def send_frame(self, f: Frame) -> None:
assert self.wire
self.wire.send(f)
def tick(self, deadline_s: float) -> list[Frame]:
if not self.wire:
return []
return self.wire.recv(deadline_s)
def set_sdata(self, v: int) -> None:
self._sdata = int(v)
def get_sdata(self) -> int:
return self._sdata
# apilot/stubs/networkctl.py
"""LuaNetworkController stub + msg 对象构造 + 分发。"""
from __future__ import annotations
from typing import Any, Callable
import lupa.luajit21 as lj # type: ignore
from apilot.wire import Frame
from apilot.stubs.netclient import NetClientStub
MSG_LUA = """
local function mkmsg(recog, msgId, p1, p2, p3, body)
local pos = 1
local reader = {}
function reader:ReadString(n)
local s = string.sub(body, pos, pos + n - 1)
pos = pos + n
return s
end
local m = {}
function m:GetMsgId() return msgId end
function m:GetHeader()
return {recog=recog, param1=p1, param2=p2, param3=p3, msgId=msgId}
end
function m:GetDataLength() return #body end
function m:GetData() return reader end
return m
end
return mkmsg
"""
class NetworkCtlStub:
def __init__(self, nc: NetClientStub) -> None:
self.nc = nc
self._tail = 0
self.handlers: dict[int, Any] = {} # msg_id -> lua function
def _next_tail(self) -> int:
self._tail += 1
return self._tail
def send_msg(self, msg_id: int, recog: int, p1: int, p2: int, p3: int,
data_str: bytes | str | None, data_len: int) -> None:
body = b"" if data_str is None else (
data_str.encode("utf-8") if isinstance(data_str, str) else bytes(data_str))
self.nc.send_frame(Frame(recog=recog, msg_id=msg_id, p1=p1, p2=p2, p3=p3,
tail=self._next_tail(), body=body))
def register_lua_handler(self, msg_id: int, cb: Any) -> None:
self.handlers[int(msg_id)] = cb
def unregister_all(self) -> None:
self.handlers.clear()
def install_to_lua(self, lua) -> None:
g = lua.globals()
g["AP_networkCtl"] = self
lua.execute("""
networkCtl = networkCtl or {}
setmetatable(networkCtl, {__index = function(_, k)
local self = AP_networkCtl
local map = {SendMsg="send_msg",
RegisterLuaHandler="register_lua_handler",
UnregisterAllLuaHandler="unregister_all"}
local pyname = map[k]
if pyname then
return function(_, ...) return self[pyname](self, ...) end
end
return function(...) return nil end
end})
LuaNetworkController = {Inst = function() return networkCtl end}
""")
# msg 构造器存进 lua 全局
g["AP_mkmsg"] = lua.execute("return " + MSG_LUA) if False else lua.eval(MSG_LUA)
def dispatch_frame(lua: Any, ctl: NetworkCtlStub, f: Frame) -> None:
cb = ctl.handlers.get(f.msg_id)
if cb is None:
return
mkmsg = lua.globals()["AP_mkmsg"]
body = f.body.decode("latin-1") # Lua 字符串按字节透传
msg = mkmsg(f.recog, f.msg_id, f.p1, f.p2, f.p3, body)
cb(msg)
注意两点(实现时核对):① game networkUtil.lua 的 LuaRegisterMsgHandler 调的是 global.networkCtl:RegisterLuaHandler(id, fn)(冒号调用→第一参 self),install_to_lua 的闭包签名 function(_, ...) 已兼容;② lua.eval(MSG_LUA) 返回闭包 mkmsg——若 Lupa 对 return function... 求值有出入,改用 lua.execute("AP_mkmsg = " + MSG_LUA) 落全局后在 dispatch 里取。
Run: pytest tests/test_network_stubs.py -v → 1 passed
git add apilot/stubs/netclient.py apilot/stubs/networkctl.py tests/test_network_stubs.py && git commit -m "feat(apilot): real network stubs - netclient/networkctl with lua msg dispatch"
Files:
apilot/stubs/env.pyapilot/stubs/__init__.pytests/test_env_stub.pyInterfaces:
Produces:
class EnvData: dict 载体(uid/token/new_boxtoken/signkey/channel/gameid/isBoxLogin=1/serverId/macAddress...)def install_env(lua, env: EnvData, state_dir: str) -> None — 注入 L_GameEnvManager(GetEnvDataByKey)、LuaBridgeCtl(switches dict,心跳开关默认 1)、FileUtils(限定 state_dir 根)、gameEnviroment(GetNetClient 等 5 方法面)、global.L_GameEnvManager 等全局引用、release_print/releasePrint/GET_STRING/JUMPTO/GET_SETTING/CHECK_SETTING/CHANGE_SETTING/Encode6BitBuf(直通 encode6) 等 C++ 全局函数def install_all(lua, nc, ctl, sched, env, state_dir) -> None — 装配顺序:noop → scheduler → env → networkctl[ ] Step 1: 写失败测试
# tests/test_env_stub.py
import lupa.luajit21 as lj
from apilot.stubs.env import EnvData, install_env
def test_env_manager_keys(tmp_path):
rt = lj.LuaRuntime()
env = EnvData({"uid": "1186115037", "token": "jwt.x", "isBoxLogin": 1, "signkey": "k"*32})
install_env(rt, env, str(tmp_path))
assert rt.eval("L_GameEnvManager:GetEnvDataByKey('uid')") == "1186115037"
assert rt.eval("L_GameEnvManager:GetEnvDataByKey('isBoxLogin')") == 1
assert rt.eval("L_GameEnvManager:GetEnvDataByKey('missing')") is None
rt.execute("global = global or {}; global.L_GameEnvManager = L_GameEnvManager")
assert rt.eval("global.L_GameEnvManager:GetEnvDataByKey('token')") == "jwt.x"
def test_bridge_ctl_and_fileutils(tmp_path):
rt = lj.LuaRuntime()
install_env(rt, EnvData({}), str(tmp_path))
rt.execute("LuaBridgeCtl:Inst():SetModulesSwitch(2, 1)")
assert rt.eval("LuaBridgeCtl:Inst():GetModulesSwitch(2)") == 1
rt.execute("cc.FileUtils:getInstance():writeStringToFile('hi', 'a.txt')")
assert (tmp_path / "a.txt").read_text() == "hi"
assert rt.eval("GET_STRING(99999)") == "" # 未命中返回空串
[ ] Step 2: 确认失败 → FAIL
[ ] Step 3: 实现 env.py(核心骨架)
# apilot/stubs/env.py
"""环境注入: L_GameEnvManager / FileUtils / LuaBridgeCtl / gameEnviroment / 杂项全局函数。"""
from __future__ import annotations
import os
from typing import Any
SWITCHES_DEFAULT = {2: 1} # Modules_Index_Enable_Check_Heart_Beat=1(心跳开)
class EnvData:
def __init__(self, data: dict[str, Any]) -> None:
self.data = dict(data)
self.switches: dict[int, int] = dict(SWITCHES_DEFAULT)
self.settings: dict[int, list] = {}
def install_env(lua: Any, env: EnvData, state_dir: str) -> None:
os.makedirs(state_dir, exist_ok=True)
g = lua.globals()
g["_PY_ENV"] = env
g["_PY_STATE_DIR"] = state_dir
lua.execute(r"""
-- L_GameEnvManager
L_GameEnvManager = {}
function L_GameEnvManager.GetEnvDataByKey(k) return _PY_ENV.data[k] end
-- LuaBridgeCtl
LuaBridgeCtl = {}
function LuaBridgeCtl.Inst() return LuaBridgeCtl end
function LuaBridgeCtl.GetModulesSwitch(i) return _PY_ENV.switches[i] or 0 end
function LuaBridgeCtl.SetModulesSwitch(i, v) _PY_ENV.switches[i] = v end
-- FileUtils(python filesystem via bridge)
_PY_FS = python.eval("__import__('apilot.stubs.env', fromlist=['x'])")
-- 杂项全局(绑定面清单 C 类)
function release_print(...) end
releasePrint = release_print
function GET_STRING(id) return "" end
function JUMPTO(...) end
function GET_SETTING(i) return _PY_ENV.settings[i] end
function CHECK_SETTING(i) local s = _PY_ENV.settings[i] return s and s[1] == 1 or false end
function CHANGE_SETTING(i, v) _PY_ENV.settings[i] = v end
""")
# FileUtils 用 Python 对象直挂(路径安全: join 后强制以 state_dir 开头)
class _FU:
@staticmethod
def getInstance(): # noqa: N802
return _FU
@staticmethod
def getWritablePath() -> str:
return state_dir + os.sep
@staticmethod
def writeStringToFile(s: str, rel: str) -> None:
p = os.path.join(state_dir, rel)
if os.path.commonpath([os.path.abspath(p), state_dir]) != state_dir:
raise ValueError("path escape")
with open(p, "w", encoding="utf-8") as f:
f.write(s)
@staticmethod
def isFileExist(rel: str) -> bool: # noqa: N802
return os.path.exists(os.path.join(state_dir, rel))
g["AP_FileUtils"] = _FU
lua.execute("cc.FileUtils = {getInstance = function() return AP_FileUtils end}")
lua.execute("""
gameEnviroment = gameEnviroment or {}
function gameEnviroment.GetNetClient() return nil end
function gameEnviroment.SetLoginAccount(a) end
function gameEnviroment.GetDownloadRootURL() return "" end
""")
实现注意:①python.eval/_PY_FS 行是残留草稿,实现时删除,FileUtils 走 AP_FileUtils 直挂;②Encode6BitBuf 若绑定面清单确认 Lua 侧直接调用,则挂 g["AP_encode6"](从 encode6bitbuf_final 包装 str↔bytes)并 lua.execute("Encode6BitBuf = function(s) return AP_encode6(s) end")——以 Task 7 冒烟实测为准。
# apilot/stubs/__init__.py
from __future__ import annotations
from typing import Any
from apilot.stubs.noop import install_noop_globals
from apilot.stubs.scheduler import Scheduler, install_scheduler
from apilot.stubs.env import EnvData, install_env
from apilot.stubs.netclient import NetClientStub
from apilot.stubs.networkctl import NetworkCtlStub, dispatch_frame
def install_all(lua: Any, nc: NetClientStub, ctl: NetworkCtlStub,
sched: Scheduler, env: EnvData, state_dir: str) -> None:
install_noop_globals(lua) # 1. cc.* 骨架
install_scheduler(lua, sched) # 2. 覆盖 Director 相关
install_env(lua, env, state_dir) # 3. 环境+FileUtils(覆盖 cc.FileUtils)
ctl.install_to_lua(lua) # 4. networkCtl/netClient(最后,真实实现覆盖 no-op)
lua.execute("global = global or {}")
lua.execute("global.networkCtl = networkCtl; global.LuaBridgeCtl = LuaBridgeCtl")
lua.execute("global.L_GameEnvManager = L_GameEnvManager; global.gameEnviroment = gameEnviroment")
test 追加:install_all 后 Lua 里 global.networkCtl:SendMsg(...) 到环回网关可通(复用 Task 4 的 fake gateway)。
Run: pytest tests/test_env_stub.py -v → 2 passed
git add apilot/stubs/ && git commit -m "feat(apilot): env injection stubs and install_all assembly"
Files:
apilot/bridge.pytests/test_bridge_load.pyInterfaces:
install_all;游戏 Lua 只读目录 lua脚本解密源码/mod_fgcq/stab/scripts/Produces:
LUA_SRC = "lua脚本解密源码/mod_fgcq/stab/scripts"(常量)def bootstrap_runtime(nc, ctl, sched, env, state_dir) -> LuaRuntime — 建 VM+装 stub+设 package.path(指向游戏 scripts 目录)+注入 LuaRegisterMsgHandler/LuaSendMsg/SendTableToServer(包装 networkCtl,语义对齐 networkUtil.lua 但不加载该文件,因其可能引用更多 C++ 面;此处按其签名以纯 Lua 重声明——这两个函数体极薄,复刻等价) + 加载 netMessageDef.luadef load_game_module(lua, relpath: str) -> None — 按相对模块名 require(如 framework.init)[ ] Step 1: 写失败测试(冒烟)
# tests/test_bridge_load.py
import lupa.luajit21 as lj
from apilot.bridge import bootstrap_runtime, LUA_SRC
from apilot.stubs.netclient import NetClientStub
from apilot.stubs.networkctl import NetworkCtlStub
from apilot.stubs.scheduler import Scheduler
from apilot.stubs.env import EnvData
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):
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):
lua = _boot(tmp_path)
sent = []
nc_stub_capture = lua.globals()["AP_networkCtl"]
orig = nc_stub_capture.send_msg
nc_stub_capture.send_msg = lambda mid, recog, p1, p2, p3, body, ln: sent.append((mid, body))
lua.execute("SendTableToServer(3199, {index=303, act='@chiyao_kaiqi'})")
assert sent[0][0] == 3199
assert b"chiyao" in sent[0][1]
def test_load_framework(tmp_path):
lua = _boot(tmp_path)
load_game_module(lua, "framework.init")
assert lua.eval("type(Facade)") == "table" or lua.eval("type(framework)") == "table"
[ ] Step 2: 确认失败 → FAIL
[ ] Step 3: 实现 bridge.py
# apilot/bridge.py
"""Lupa 桥: VM 构建 + stub 装配 + 游戏 Lua 只读加载。"""
from __future__ import annotations
import os
from typing import Any
import lupa.luajit21 as lj # type: ignore
from apilot.stubs import install_all
from apilot.stubs.netclient import NetClientStub
from apilot.stubs.networkctl import NetworkCtlStub
from apilot.stubs.scheduler import Scheduler
from apilot.stubs.env import EnvData
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LUA_SRC = os.path.join(ROOT, "lua脚本解密源码", "mod_fgcq", "stab", "scripts")
def bootstrap_runtime(nc: NetClientStub, ctl: NetworkCtlStub, sched: Scheduler,
env: EnvData, state_dir: str) -> Any:
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")
# 薄复刻 networkUtil.lua 的公共面(签名对齐, 实现走 stub)
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
""")
g = lua.globals()
import json
def _enc(t): return json.dumps(t, ensure_ascii=False, separators=(",", ":"))
def _dec(s):
d = json.loads(s)
return d if isinstance(d, dict) else {}
g["_PY_cjson_encode"] = _enc # dict→json str; Lupa 会把 Lua table 转 python dict
g["_PY_cjson_decode"] = _dec
# 加载协议定义(第一个游戏原文件)
src = open(os.path.join(LUA_SRC, "network", "netMessageDef.lua"), encoding="utf-8").read()
lua.execute(src)
return lua
def load_game_module(lua: Any, modname: str) -> None:
lua.execute(f"require('{modname}')")
关键点:cjson 用 Python json 桥接(Lupa table↔dict 自动转换);若冒烟中发现游戏某模块直接 require("cjson") 的 C 模块行为差异(如 cjson.safe),在本任务内补 safe={encode=function(t) local ok,r = pcall(...) 风格包装。
Run: pytest tests/test_bridge_load.py -v → 3 passed
git add apilot/bridge.py tests/test_bridge_load.py && git commit -m "feat(apilot): lupa bridge bootstraps game lua protocol layer"
Files:
apilot/host.pytests/test_host_unit.py(单测:登录链拼包参数,无网络)Interfaces:
protocol_bot/login_996pc.py 的 do_login;cq2_pure_enter.py 的 box_auth_header/enter_gameProduces:
class Host: __init__(zone_id, user, password).http_login() -> dict(JWT+uid).connect_login_gate(port=8041) -> None(建立 Wire,按 cq2_pure_enter.login_gate 已验证序列:2001→529→104→530→100→520,登录帧由 Lua 侧 SendTableToServer/LuaSendMsg 发出——2001 的 uid/encode6(jwt)/encode6(channel) 拼串在 host 侧构造为 body 传入 Lua 发送,保持帧头构造同源).run_create_role(name: str | None = None) -> dict(空角色列表→107/539→101→521;有角色→复用).tick(dt=0.05) -> None(nc.tick+Sched.tick 循环体)def run_zone(zone_id, user, password, name=None) -> dict — 一站式(HTTP→网关→创角→返回票据),CLI 用[ ] Step 1: 写失败单测(拼包参数,环回)
# tests/test_host_unit.py
import json
from apilot.host import Host
def test_login_2001_body_shape(tmp_path, monkeypatch):
h = Host(14481607, "longwu988", "Longwu1988", state_dir=str(tmp_path))
uid, jwt = "1186115037", "eyJfake.jwt.token"
body = h.build_2001_body(uid, jwt) # b"uid/" + encode6(jwt) + b"/" + encode6(channel) + b"/1/0"
import sys; sys.path.insert(0, "protocol_bot")
from encode6bitbuf_final import encode6
assert body == f"{uid}/".encode() + encode6(jwt.encode()) + b"/" + encode6(b"110585") + b"/1/0"
def test_create_role_101_body(tmp_path):
h = Host(14481607, "u", "p", state_dir=str(tmp_path))
body = json.loads(h.build_101_body("萌新1234", uid="u1", job=0, sex=0))
assert body["chr"] == "萌新1234" and body["sdkid"] == "110585"
assert body["server_name"] == 14481607
[ ] Step 2: 确认失败 → FAIL
[ ] Step 3: 实现 host.py(骨架)
# apilot/host.py
"""宿主: HTTP 登录 + 网关连接 + Lua 驱动创角。复用 protocol_bot 已验证资产。"""
from __future__ import annotations
import json
import os
import random
import sys
from typing import Any
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, "protocol_bot"))
import requests as rq # noqa: E402
import login_996pc as L # type: ignore # noqa: E402
import cq2_pure_enter as P # type: ignore # noqa: E402
from encode6bitbuf_final import encode6, decode6 # type: ignore # noqa: E402
from apilot.bridge import bootstrap_runtime
from apilot.stubs.netclient import NetClientStub
from apilot.stubs.networkctl import NetworkCtlStub, dispatch_frame
from apilot.stubs.scheduler import Scheduler
from apilot.stubs.env import EnvData
GW_HOST = "s{zone}-jycq-206923.dhsf.xqhuyu.com"
CHANNEL = "110585"
class Host:
def __init__(self, zone_id: int, user: str, password: str,
state_dir: str = ".apilot_state") -> None:
self.zone = zone_id
self.user, self.password = user, password
self.state_dir = state_dir
self.nc = NetClientStub()
self.ctl = NetworkCtlStub(self.nc)
self.sched = Scheduler()
self.env = EnvData({})
self.lua: Any = None
self.jwt = ""
self.uid = ""
def build_2001_body(self, uid: str, jwt: str) -> bytes:
return (f"{uid}/".encode() + encode6(jwt.encode()) + b"/" +
encode6(CHANNEL.encode()) + b"/1/0")
def build_101_body(self, name: str, uid: str, job: int = 0, sex: int = 0) -> bytes:
return json.dumps({"server_name": self.zone, "chr": name, "secsdk": "0",
"job": job, "sex": sex, "account": uid,
"sdkid": CHANNEL}, ensure_ascii=False).encode()
def http_login(self) -> dict:
s = rq.Session()
j, _ = L.do_login(s, self.user, self.password)
if j.get("code") != 200:
raise SystemExit(f"[!] 登录失败: {j.get('msg')}")
self.jwt = j["data"]["token"]
self.uid = str((j["data"].get("userInfo") or {}).get("id") or "")
self.env.data.update({"uid": self.uid, "token": self.jwt,
"new_boxtoken": self.jwt, "isBoxLogin": 1})
return j
def boot_lua(self) -> None:
self.lua = bootstrap_runtime(self.nc, self.ctl, self.sched, self.env, self.state_dir)
def connect_login_gate(self, port: int = 8041) -> None:
self.nc.connect(GW_HOST.format(zone=self.zone), port)
def pump(self, seconds: float, on_frame=None) -> list:
"""收帧→分发→调度, 驱动 seconds 墙钟。"""
import time as _t
end = _t.time() + seconds
got: list = []
while _t.time() < end:
for f in self.nc.tick(0.2):
dispatch_frame(self.lua, self.ctl, f)
got.append(f)
if on_frame:
on_frame(f)
self.sched.tick(0.05)
return got
run_create_role 与 run_zone 的完整逻辑在本任务 Step 3 内继续实现:登录链帧序严格对照 cq2_pure_enter.login_gate()(已验证):2001(tail=0,recog=random)→等 529 取 key32→104(body=encode6(key32),tail 自增)→等 530 取 sid(recog 位)→netClient:set_sdata(sid)→100(uid/sid/zone/channel/gameid/key32)→等 520 角色 JSON→空则 101→521/522。收包等待用 pump() 循环+消息号断言,超时 30s。
Run: pytest tests/test_host_unit.py -v → 2 passed
git add apilot/host.py tests/test_host_unit.py && git commit -m "feat(apilot): host with verified login-chain drivers"
Files:
apilot/main.py(CLI)tests/test_live_t01.pyInterfaces:
Host.run_zoneProduces: CLI protocol_bot/.venv/bin/python -m apilot.main --zone 14481607 --name 萌新XXXX
[ ] Step 1: 写 live 测试(默认跳过)
# tests/test_live_t01.py
import pytest
from apilot.host import Host
pytestmark = pytest.mark.live
def test_t01_create_role_headless():
import random
name = "萌新" + str(random.randint(1000, 9999))
h = Host(14481607, "longwu988", "Longwu1988", state_dir="/tmp/apilot_t01")
h.http_login()
h.boot_lua()
result = h.run_create_role(name=name) # 内部: connect→2001链→101→521
assert result["role_name"] == name
assert result["ticket"] # 521 票据非空
assert h.nc.get_sdata() != 0 # 530 sid 已注入
Run: cd /Users/zlwu/work/00code/传奇 && protocol_bot/.venv/bin/python -m pytest tests/test_live_t01.py -m live -v -s
Expected: PASS,输出含 521 票据。失败处理路径:①帧序错→对照 cq2_pure_enter.py 逐帧 diff;②Lua 崩(nil 索引)→按崩溃栈定位绑定面缺项→在对应 stub 补方法(这是预期内的收敛循环);③角色名重复(522)→run_create_role 内重试换名(≤5 次)。
# apilot/main.py
"""APilot CLI。M1: 无客户端创角。"""
import argparse
from apilot.host import run_zone
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--zone", type=int, required=True)
ap.add_argument("-u", "--user", default="longwu988")
ap.add_argument("-p", "--password", default="Longwu1988")
ap.add_argument("--name", default=None)
a = ap.parse_args()
r = run_zone(a.zone, a.user, a.password, a.name)
print(f"[M1完成] 区={a.zone} 角色={r['role_name']} 票据={r['ticket'][:24]}...")
if __name__ == "__main__":
main()
Run: protocol_bot/.venv/bin/python -m apilot.main --zone 14481607 → 打印创角成功
git add apilot/main.py tests/test_live_t01.py && git commit -m "feat(apilot): M1 e2e - headless create-role on live gateway (zone 14481607)"
_PY_FS 草稿行已标注"实现时删除";cjson.safe 留了明确的补法说明;无 TBD ✓dispatch_frame(lua, ctl, f) 在 Task 4 定义、Task 7 pump 使用一致;install_all 签名 Task 5 定义与 Task 6 使用一致;Host.boot_lua 内 bootstrap_runtime(nc, ctl, sched, env, state_dir) 与 bridge 签名一致 ✓Schedule 一次性/循环语义、cjson.safe 需求——三处都在对应任务里写了核对方法与改法。