Claude Skill

game-client-to-server-reverse

从游戏客户端(安装包/APK/IPA/EXE 或 dump.cs、lua、usmap、抓包等)反推服务端协议并复现可部署服务端。含阅读路径分派、原理层(primer:三要素/数据包协议/协议表/热更源码)、四阶段路线图(workflow-roadmap:静态分析→建工具+登录链→重定向→补包循环→清单迭代)、11 种反推方法选择器(含内联服务端路线)、接口清单提取器(tools/)、协议规格模板(protocol.spec.yaml)、wire 级定点改写(不等 schema 齐就能跑)、客户端地址来源清查、三轴状态与验收体系、发布运维清单、进度清单(T

LLM Mart · 0 points · 0 views 7 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download shrugyu-game-client-to-server-reverse-game-client-to-server-reverse-65c7a74.zip · 328 KB

Install

skills CLI npx skills add https://github.com/ShrugYu/game-client-to-server-reverse/tree/main/game-client-to-server-reverse
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install shrugyu-game-client-to-server-reverse@llmmart
Git git clone https://github.com/ShrugYu/game-client-to-server-reverse.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole shrugyu/game-client-to-server-reverse collection as a plugin from our marketplace. Git is the plain clone.

README

游戏客户端 → 离线本地化 Skill

完整支持 Unity(IL2CPP / Mono / Lua) 与 Unreal Engine(UE4 / UE5),也覆盖 C#/AS3/Lua/JS/Java 等各类客户端。 从客户端反推服务端协议 → 复现本地离线服务端 → 部署 → 让原版客户端本地离线运行。

[x] 本包自带的服务端已实测跑通:握手 → 登录 → 建角 → 选角 → 进场景 → 移动 → 心跳, 并验证 XOR 加密 + zlib 压缩 + 充值/邮件发放。

服务端参考实现原Github地址:https://github.com/Nanako660/peach-haven

2026-09-22 更新(v2.0) —— 本版主题:原理层 + 四阶段路线 + 换服务端(重定向): ⓪ 版本升至 2.0:整合 1.x 全部成果 + 以下 ①⑪; ① 原理层 references/primer.md:三要素(配置=数值 / 协议=格式 / 代码=用法)、 数据包协议本质、协议号与协议表、热更新代码是比 dump 更好读的源码; ② 四阶段路线总纲 references/workflow-roadmap.md(静态分析 → 建工具+登录链 → 重定向 → 补包循环 → 清单迭代)+ 四层重定向表; ③ 模板:templates/login-chain.md(登录链条)、templates/function-checklist.md(功能清单); ④ SKILL §0 开头新增阅读地图(三条主线); ⑤ 反推对象地图 references/server-architecture-basics.md(逆向视角):你在还原哪几类服、 网关留下的隐藏层(合并/加解密/压缩 flag)、从包里认 Protobuf/KCP、同步模型决定"要还原多少逻辑" (来源:平台云架构演进、GameDevAndOps、KCP/protobuf 官方、Skynet/Pomelo/KBEngine/NF); ⑥ 取长补短(折进现有文件):methods.md M8 补入现成实现速查(按类型) + "先查别人做过没" + 先锁版本;protocol-spec.md 补入人类可读协议文档格式; ⑦ SKILL.md 瘦身:与 references 重复的 §1§8、§11、§14~§19 压成"要点+指针"(1122 → ~650 行);§11 常见坑并入 closure-verification.md §附; ⑧ 跨模型阅读优化(GLM / DeepSeek / Claude):SKILL 顶部改为模型无关的显式阅读协议("打开哪个文件"写死); 去掉顶部大段变更历史;给 5 篇 >300 行的 reference 补章节目录;reading-path.md 顶部声明按它分派; ⑨ 去除 emoji:全库清理(状态标记转 ASCII、装饰 emoji 删除、箭头保留); ⑩ 框架修正 + 资源: §0 改为"主动改客户端对接本地离线服务端"(按重定向四层表、优先跑起来); ⑪ 换服务端手法补全(v2.0 新增):client-address-sources.md §3.0b 加入 Xposed / LSPatch 模块重定向 (第三方代理模块实测范例,免 root);methods.md M8 加"多人/联机复活项目"检索入口; ⑫ 签名绕过:repack-rename.md §8 加入 LSPatch Signature Bypass(等级 2)机制 / 独立签名破解 (ApkSignatureKiller / 核心破解) / 真正不改签名的虚拟容器(VirtualXposed / 太极);§7 落点表述改为"按代价从低到高"; 接入点:account.md §6、release-and-ops.md §7、SKILL §16;

2026-09-15 实战沉淀(v1.8 新增): ⑬ 内联服务端:不起外部服务端,在客户端进程内拦截网络门面合成响应 (三类入口 / 回调投递纪律 / 延迟派发 / 双通路 / 内联版验收与假阳性)→ references/inline-server.md ⑭ 运行时对象合成与字段发现:用客户端自己的类型系统当 schema,先 dump 再合成 → references/runtime-object-synthesis.md ⑮ 平台 SDK 登录态复用 + 目录服→区服两段准入 → references/platform-sdk-and-admission.md ⑯ 真实案例:Unity IL2CPP + 平台 SDK 的内联服务端 → references/case-il2cpp-inline.md

2026-09-13 实战沉淀(新增): ② 改包名 / 重打包 / 保留原签名全清单 → references/repack-rename.md ④ 闭环验证与 6 类假阳性(端口在听≠服务可用、自环≠客户端兼容)→ references/closure-verification.md ⑤ 真实案例:Unity IL2CPP + ECDH 登录服(帧格式 / SPKI / 已证伪项)→ references/case-il2cpp-ecdh.md ⑥ SKILL 铁律 3 → 4 条:不把「能跑」当成「跑通」 ⑦ 工程范式(实机跑通项目的做法:fixture 回放 / 具名完成点 / 证据分档 / ADR)→ references/engineering-practices.md ⑧ 文档模板:templates/adr-template.md、templates/e2e-evidence-template.md ⑨ 实现层核心:不依赖 protobuf 运行时的 wire 级定点改写(帧 codec + 字段遍历 + splice 替换 + 空子消息必须保留)→ references/wire-level-patching.md ⑩ 客户端地址来源清查(六类来源 + 落点优先级 + 重签后果 + 阶段验收)→ references/client-address-sources.md ⑪ 三轴状态与验收体系(实现 / 自动测试 / 客户端验收 + 可达性五分类 + 提交门禁)→ references/verification-and-status.md ⑫ 发布、部署与运营(监听 vs 对外地址 / 端口族 / 启动期冻结配置 / CDN 版本策略 / 备份)→ references/release-and-ops.md


目录映射(本 skill 的结构)

本 skill 遵循 Agent Skills 规范:一个文件夹 = 一个 skill,且 frontmatter 的 name 必须与文件夹名一致。本文件夹即 game-client-to-server-reverse/;内部目录是对规范约定目录的映射。

规范约定 本 skill 实际 说明
SKILL.md SKILL.md 必需:元数据 + 主干(已瘦身到 < 500 行,符合规范建议)
references/ references/ 按需加载的详细文档(正文下沉于此)
assets/ templates/、schema/、server/ 模板 / 中间产物模板 / 可运行参考实现
scripts/ tools/ 可执行脚本(extract_interfaces.py / `` / repack_zip.py)
— examples/、extensions/ 推演示例 / 可选拓展

阅读顺序:SKILL.md(导航+主干)→ references/reading-path.md(按任务分派)→ 按需打开具体文件。


先读什么(别一次读完整包)

本包有 37 篇参考 + 一份长主文档。通读完再动手 = 上下文耗尽 + 在半懂的地方开始猜。

1. references/reading-path.md      ← 按任务类型拿到 3~5 个文件的阅读路径
2. SKILL.md §0.0 ~ §0.6            ← 方法选择 + 铁律 + 工作流主干
3. 按路径读完场景层文件 → 动手
收工前:reading-path.md §2 的「收工前检查」

上下文极少时的优先级: SKILL §0 → reading-path.md → wire-level-patching.md → closure-verification.md → client-address-sources.md

skill 是查询手册,不是必读教材。


如何使用本 Skill

一句话

把游戏(安装包 / 客户端 / 辅助文件)交给 AI,说清「要什么」, AI 会自己取证 → 产出协议规格 → 生成并部署本地离线服务端 → 让客户端本地离线运行。

你最少只需给一样

给什么 推荐度 AI 会做什么
一个安装包(.apk / .ipa / .exe) 最推荐 自己解包、判引擎、跑 dump、抓包(见 references/from-installer.md)
客户端目录 / dump.cs / *.lua / *.usmap 直接进静态分析
抓包 *.pcap / mitm 导出 直接做协议分层
已有服务端样本 / 协议文档 对照分析
只有一个游戏名 [x] 没有证据,建议先提供安装包

提问模板(直接复制改)

① 最简
帮我反推这个游戏的服务端:/文件路径/game.apk

② 带目标
用这个 APK 反推服务端协议,先跑通登录,然后部署到本地。

③ 带约束
这是 Unity 手游,服务端请分析根据当前用户给出客户端来用什么编写(如Go编写),要支持局域网联机链接到我们当前写的服务端上,先做协议分析.md文档再写代码。

④ 指定阶段
先别写代码,只做协议分层,产出 protocol.spec.yaml 和证据清单。

一个好提问包含 4 个要素

要素 说明 例子
输入 你给什么 game.apk / dump.cs / capture.pcap
目标 做到哪一步 只分析 / 跑通登录 / 完整服务端+部署
约束 语言/平台/版本 Go / 安卓局域网 / 客户端 1.8.1
环境 跑在哪 本地 / 云服务器 / Termux

缺哪个都行,AI 会用占位符推进;但输入最好给一个。

AI 会按这个顺序回应(重要)

1. project-profile.yaml    ← 我读了什么、还缺什么        (项目档案)
2. evidence-inventory      ← 每条结论的证据 + 置信度     (证据清单)
3. protocol.spec.yaml      ← 协议规格(唯一事实来源)
4. 服务端代码 + 部署 + 验证 ← 由 Spec 派生

注意: 如果 AI 直接甩给你一堆代码,却没有前 3 样,让它重来。 「先规格,后代码」是本 skill 的硬性要求(见 references/ai-contract.md)。

另有两份"开工/收工"契约(详见 references/ai-contract.md):

AGENTS.md                  ← 工作区边界 + 验证命令 + 收工状态(放在项目根)
docs/status/support-matrix ← 三轴状态:实现 / 自动测试 / 客户端验收

可以这样追问

  • 「先给我证据清单」
  • 「先只做协议分层」
  • 「为什么判定是 4 字节大端?证据是哪条?」
  • 「服务端换成 Go 重写」
  • 「把 opcode 表填进去」
  • 「客户端连不上,帮我定位是哪一步」
  • 「先把账号/登录接口列出来,登记到 TRACKER」

常见问题指路

问题 看哪
不懂"在反推什么"/第一次做 references/primer.md(原理层)
不知道先做什么、在哪验收 references/workflow-roadmap.md(四阶段路线)
只有安装包怎么办 references/from-installer.md
怎么注册账号 / 替换登录接口 references/account.md(§16)
需要人机(假玩家) extensions/(§18,可选)
不想起服务端 / 想单机化 references/inline-server.md(M11 内联服务端)
进度/接口清单 TRACKER.md

不要这样问

  • 只给游戏名就让 AI 凭空写服务端 —— 没有证据 = 必错
  • 「直接给我能用的服务端」却不给任何输入

使用守则与限制

完整守则见 references/usage-policy.md;每次动手前先读。

可做(允许):自研 / 已授权 / 离线单机目标的互操作性研究、协议文档化、学习与教学、已停服游戏的本地化存档(非商业)、授权范围内的安全评估。

不做(禁止):未授权架设 / 运营他人游戏的服务器、绕过或破坏他人的技术保护 / 完整性校验 / 反作弊、商业化牟利、侵犯他人权益、破坏性攻击。

硬性约束:证据驱动(无证据 = 假设)、先规格后代码、只给方法不给具体游戏的成品 / 密钥、不提供保护绕过、fail closed(缺信息就说缺什么)、越界即停并给出合规替代。

  • 使用参考项目 / 代码前先看其许可证。

目录结构

离线本地化/
├── SKILL.md                主流程(18 节,核心方法论)
├── README.md               本文件
├── TRACKER.md               进度与接口清单(每模块更新,从简)
├── schema/                  中间产物模板(AI 填这些)
│   ├── project-profile.yaml   项目档案
│   └── protocol.spec.yaml     协议规格(唯一事实来源)
├── references/
│   ├── adaptation.md        自适应方法论:项目→证据→决策→Spec
│   ├── reading-path.md      最小必读路径(按任务类型分派)
│   ├── primer.md            原理层:三要素/数据包协议/协议表/热更源码/双证据链
│   ├── workflow-roadmap.md  四阶段路线总纲(静态分析→建工具+登录链→重定向→补包循环→清单迭代)
│   ├── phases-detail.md     §1~§10 反推主流程详细版(命令/工具/判断/坑)
│   ├── ai-contract.md       AI 行为契约(强制产物/禁止/终点/回滚)
│   ├── usage-policy.md      使用守则与限制(允许/禁止用途 + 硬性约束 + 自检)
│   ├── server-architecture-basics.md  反推对象地图(逆向视角:你在还原哪几类服/网关隐藏层/同步模型)
│   ├── methods.md           10 种反推方法 + 选择矩阵(动手前先看)
│   ├── closure-verification.md  闭环验证 6 类假阳性(宣布成功前必看)
│   ├── engineering-practices.md  实机跑通范式:fixture 回放 + 完成点 + 证据分档 + ADR
│   ├── wire-level-patching.md  实现层核心:不依赖 pb runtime 的 wire 级定点改写
│   ├── client-address-sources.md  客户端地址来源清查 + 落点策略
│   ├── verification-and-status.md  三轴状态 + 可达性分类 + 测试门禁
│   ├── release-and-ops.md   发布 / 部署 / 运营 / CDN / 备份
│   ├── case-il2cpp-ecdh.md  真实案例:IL2CPP + ECDH 登录服 + 卡点复核
│   ├── inline-server.md     内联服务端:进程内合成响应(与外部服务端并列的第二条路)
│   ├── runtime-object-synthesis.md  运行时对象合成与字段发现(对象级 schema 自举)
│   ├── platform-sdk-and-admission.md  平台 SDK 登录态复用 + 目录服→区服两段准入
│   ├── case-il2cpp-inline.md  真实案例:IL2CPP + 平台 SDK 的内联服务端
│   ├── account.md           账号体系与接口还原(登录/注册/接口替换)
│   ├── combat.md            房间与战斗(局内、同步模型、服务端权威)
│   ├── drops.md             掉落与物资(掉落表、掷骰、背包满转邮件)
│   ├── gacha.md             抽卡/扭蛋(服务端掷骰、保底、重复转换)
│   ├── decision-tree.md     逐层决策树(引擎/传输/封装/加密/序列化/架构)
│   ├── protocol-spec.md     协议规格规范 + codegen 映射
│   ├── codegen.md           由 Spec 生成/改造服务端(多语言)
│   ├── client-languages.md  客户端语言分支(C#/AS3/Lua/JS/Java/C++)
│   ├── from-installer.md    零输入自举:只有安装包怎么自产证据
│   ├── cases.md             两个真实成功项目案例(Go / Node)
│   ├── unity.md            Unity(IL2CPP/Mono/Lua) 深潜
│   ├── unreal.md           Unreal(UE4/UE5) 深潜
│   ├── windows.md          端游:Windows 运行服务端
│   ├── termux.md           手游:Android/Termux 运行服务端
│   └── repack-rename.md     改包名/重打包/签名/包名派生密钥
├── examples/                推演示例(同 skill,不同项目→不同方案)
│   ├── A-unity-il2cpp-protobuf.md
│   ├── B-ue-kcp-custom.md
│   ├── C-unity-lua-http.md
│   └── D-real-lua-client.md     真实 Lua 源码分析(1794 个 opCode)
├── tools/
│   └── extract_interfaces.py    接口清单提取器(扒 opCode/路由)
├── templates/              参考代码与文档模板
│   ├── mock_server.py      极简桩
│   ├── frida_bypass_ssl.js / kcp_sniff.py
│   ├── frida-redirect.js   客户端重定向:hook getaddrinfo/connect(native/il2cpp)
│   ├── xposed-redirect/    LSPosed 模块骨架:改写 URL(Java/OkHttp 客户端)
│   ├── register-site/      注册网站(index.html + 最小 Flask 后端)
│   ├── AGENTS.md            工作区 AI 协作契约(放在项目根)
│   ├── adr-template.md      架构决策记录(含「当前不做的事情」+ 回滚)
│   ├── e2e-evidence-template.md  实机端到端证据(帧序表 + 差异解释 + 重连快照)
│   ├── status-matrix.md     三轴状态矩阵(实现 / 测试 / 客户端验收)
│   ├── login-chain.md       登录链条文档(启动到主场景每一步)
│   └── function-checklist.md  功能清单(按子系统登记实通/未通)
├── extensions/              后续拓展(可选,不影响核心运行)
│   ├── README.md           拓展说明与判断标准
│   ├── bots.md             服务端人机(假玩家)
│   └── bot-reverse.md      反推人机机制再复刻
└── server/                 参考实现:长连接二进制协议服务端(Python)
                               (GitHub: https://github.com/ShrugYu/game-client-to-server-reverse/tree/main/game-client-to-server-reverse/server)

端游 / 手游怎么跑

平台 启动 常驻 客户端对接
Windows(端游) deploy/start_windows.bat NSSM 服务 改 hosts → 127.0.0.1
Android(手游) bash start_termux.sh wake-lock + tmux hosts / DNS 重定向
Linux 服务器 deploy/deploy.sh systemd 域名解析 / 端口转发
Docker docker compose up -d 容器策略 同上

客户端语言不只有 Unity/UE

客户端语言 反编译工具 能拿到源码? 客户端补丁方式
C#(Unity Mono / .NET) dnSpy / ILSpy [x] 近源码 配置 / 重编译 / hook
C#(Unity IL2CPP) Il2CppDumper + IDA [x] 仅签名 Frida hook
ActionScript3 / Flash JPEXS FFDec [x] 反编译 改源码重编译
Lua 热更 unluac / luadec [x] 改 lua
Java/Kotlin jadx [x] smali patch / hook
JS / H5 beautify / sourcemap [x] 改 js
C++ / UE SDK dump 注意: 结构 hook / patch

详见 references/client-languages.md。 真实项目案例(Go 服务端 + C# 启动器;Node/TS 服务端 + AS3 客户端)见 references/cases.md。

服务端语言也不固定

Python 只是本仓库的参考实现。真实案例里:Go(某 Unity 手游)、Node/TS(某 Cocos 手游)。 选型见 references/codegen.md,由 Spec 决定,不是照抄。

充值 / 发放(自托管支付)

  • game.pay_grant_mode: direct(直接进背包)/ mail(发邮件领取)
  • game.pay_auto_success: true → 点击购买直接成功
  • 商品表 PAY_PRODUCTS 键 = 客户端真实 product_id

后续拓展(可选,不影响核心运行)

判断标准:去掉它,游戏还能不能正常玩?能 → 就是拓展。

注意: 内联服务端不是拓展:它是与「外部服务端」并列的核心路线(M11,见 references/inline-server.md), 不适用本判断标准。

拓展 文件 一句话
服务端人机(假玩家) extensions/bots.md 多人游戏凑不齐人时用 AI 补位
反推人机机制 extensions/bot-reverse.md 无参考项时,从客户端反推它的人机实现

人机为什么是拓展:单人也能玩的游戏完全不需要;多人游戏缺人只是体验受损, 服务端本身能跑,不影响「本地离线运行 / 登录 / 进游戏」。

  • 参考骨架:server/app/logic/bots.py(只是骨架,默认 auto_fill: 0 不生成假玩家)
  • 联调:python server/test_bots.py

注意: 拓展不参与核心验收。没做拓展 ≠ 交付不完整。

两种服务端模板怎么选

templates/mock_server.py server/
用途 抓包后快速验证协议结论 真正部署、长期运营
规模 单文件 分层工程
能力 帧收发 + opcode 桩 数据库/状态机/客户端保护/部署
何时用 逆向中期试探 逆向完成、要跑起来

最快上手

# 1. 起服务端
cd server && chmod +x start.sh && ./start.sh

# 2. 联调自测(另开一个终端)
./venv/bin/python client_test.py --host 127.0.0.1 --port 8888 \
    --user alice --password 123456 --name Hero01
# 期望输出结尾:[+] full flow OK

# 3. 部署到服务器
sudo bash server/deploy/deploy.sh

工作流

[只有安装包?] 解包 → 判引擎/语言 → 自产 dump/lua/资源 → 抓包 → 动态dump
        ↓
侦察引擎 → 协议分层 → 客户端静态定位 → 字段推断
        → 服务端建模 → 部署 → 客户端对接 → 闭环验证

只有 .apk/.ipa/.exe 也能开始 —— 见 references/from-installer.md。

给 AI 的使用方式

  1. 把辅助文件(dump.cs / *.lua / *.usmap / SDK .h)丢进来。
  2. AI 按 SKILL.md §1 分类,自动进入 Unity 或 UE 分支。
  3. 逆向结论落到 server/app/proto/(消息号 + 结构)与 config.yaml(协议参数)。
  4. 起点验证用 templates/mock_server.py,终点交付用 server/。

Skill manifest

游戏客户端 → 离线本地化 Skill

定位:把「只有客户端」的游戏,还原出「服务端协议 + 本地离线服务端」,并让原版客户端本地离线运行。 适用:互操作性研究、本地离线化 / 离线版建模、协议文档化、安全评估、游戏存档研究。 边界:仅用于自研、已授权或离线/单机目标。禁止未授权破坏性测试与商业化他人资产。

版本 v2.0(完整变更历史见 README.md)。2.0 较 1.x 新增:原理层 primer.md、四阶段路线图 workflow-roadmap.md、反推对象地图 server-architecture-basics.md、重定向落点(含 Xposed/LSPatch 模块)、 复活 / 本地离线项目合集、登录链/功能清单模板、全库去除 emoji、跨模型阅读优化,并对 SKILL.md 大幅瘦身。


阅读协议(任何模型通用:GLM / DeepSeek / Claude / …)

本包 = 1 份长文档(SKILL.md)+ 37 篇 references/ + templates/ + tools/ + 参考实现 server/。 不要一次读完——会耗尽上下文,结果在没读完的地方开始猜。 注意:多数模型不会自动加载 references,必须显式打开文件。 先读 references/usage-policy.md(使用守则与限制)——确认用途合规、不越界,再动手。

标准三步(照做即可):

1. 先读完本文件 §0(方法选择 + 铁律 + 工作流 + 补包循环)—— 必读核心
2. 打开 references/reading-path.md → 按你的任务类型拿到 3~5 个文件的阅读顺序
3. 按顺序打开并读完那些文件 → 再动手
收工前:打开 references/reading-path.md §2,过一遍「收工前检查」

上下文很少时(只能读 1~2 篇),按此优先级取: SKILL §0 → reading-path.md → wire-level-patching.md → closure-verification.md → client-address-sources.md

一句话:SKILL.md 是导航,references 是正文。先读导航,再按需读正文。


阅读地图:三条主线,先认清自己在哪条

这个包讲三件事。先确认自己卡在哪条主线,再去读对应文件,不要混着读。

主线 回答的问题 先读 关键产物
A. 原理 「到底在反推什么?」 references/primer.md、references/server-architecture-basics.md (认知:三要素 / 协议表 / 热更源码 / 反推对象地图)
B. 路线 「先做什么、后做什么、在哪验收?」 references/workflow-roadmap.md login-chain.md、进度
C. 方法 「用哪个手段取证?」 references/methods.md 选定方法 → project-profile.yaml
D. 规格 「怎么把结论固定下来?」 references/protocol-spec.md protocol.spec.yaml(唯一事实来源)

一句话:A 讲「找什么」,B 讲「什么顺序」,C 讲「怎么找」,D 讲「怎么记」。

  • 新手 / 第一次接触某客户端 → 先读 A(primer.md + server-architecture-basics.md),再看 B。
  • 知道自己要干什么、只是缺手法 → 直接看 C(methods.md)+ §0.0。
  • 已经拿到证据、要落盘 → 直接看 D(protocol-spec.md)。

0. 本 Skill 的定位:通用适配器,不是固定方案

核心认知(先读三遍):

这个 skill 里附带的任何代码(server/、templates/、examples/)都只是参考实现, 不是对某个游戏的答案。每个游戏的引擎、传输、封装、加密、序列化、消息号、字段布局 都不一样。AI 必须先读取用户实际给的项目,产出协议规格(Protocol Spec), 再据此生成或改造服务端。

0.0 第一步:选对方法 (最容易做错的一步)

方法选错 = 后面全白干。 完整方法论见 references/methods.md。

先回答 3 个问题:

  1. 有什么? PKG(只有安装包) / CAP(抓包) / SRC(源码或反编译产物) / RUN(能运行) / DBG(能注入) / MANIP(能改客户端或代理) / REF(有同类实现)
  2. 要什么? DOC(只要文档) / FLOW(跑通主流程) / FULL(完整服务端) / PATCH(改客户端)
  3. 什么约束? 客户端保护 / 时间 / 权限

十一种方法速览(详见 methods.md):

代号 方法 何时用
M1 白盒源码法 有 SRC → 首选
M2 黑盒抓包法 有 CAP → 通用
M3 灰盒 Hook 法 有 RUN+DBG → 破加密
M4 辅助 Artifact 法 有 .proto/.usmap/SDK → 一步到位
M5 代理透传+逐接口替换 有 CAP+MANIP → 最稳落地
M6 差分探测法 定字段
M7 回放/录像法 有录像 → 战斗协议
M8 已知实现移植法 有 REF → 抄作业
M9 自环法 起桩看客户端要什么
M10 穷举校验法 兜底(优先 M3)
M11 内联服务端法 有 RUN+DBG/MANIP 且可单机化 → 不起外部服务端

默认路线(不知选什么就用它):

M1/M4(有源码就抄)→ M2+M3(拿明文)→ M6(定字段)→ M9(跑通连接)
→ M5(透传+逐接口替换)→ 闭环验证

另有一条主干分叉:目标可单机化 + 客户端能注入/能改 → 走 M11 内联服务端(inline-server.md), 传输 / 封装 / 加密层不必还原。先判分叉,再选方法。

注意: 现实中都是组合使用(如 M1+M6+M5)。 选定后记进 project-profile.yaml 的 method 段;发现更好路径可中途切换。

0.1 五条铁律

  1. 证据驱动,不臆测:所有结论必须来自用户项目里的实际证据(dump.cs / lua / 抓包 / SDK / 资源文件 / 客户端行为)。 没有证据 → 标注为「假设」并写进 Spec 的 unresolved 列表。
  2. 先产出规格,再产出代码:中间必须有一个语言无关的协议规格文件 (见 references/protocol-spec.md 与 schema/protocol.spec.yaml)。 代码是从规格派生出来的,换游戏 = 换规格,不是重写一堆散代码。
  3. 闭环验证:服务端必须能让原版客户端跑通到某个状态(握手/登录/进场景),否则推断就是错的。
  4. 不把「能跑」当成「跑通」:端口在 listen、自环脚本报 OK、日志打了 , 都不是闭环证据。宣布成功前必须逐条排除假阳性 → 见 references/closure-verification.md(接手别人项目时尤其必读)。 内联路线另有一套判据:界面截图不算,要看网络 / 状态层的完成点 → inline-server.md §6。
  5. 三件事分开记:「服务端实现了」「自动测试覆盖了」「客户端验收了」。 用一个状态列会掩盖后两项 → 见 references/verification-and-status.md。 不支持的输入要 fail closed,不要猜一个值。

0.2 双证据链

任何字段结论 = 流量证据(抓包里字节变化) + 代码证据(客户端里的结构体/序列化代码)。 单侧只算假设。

0.3 四层剥离(先分层,再解字段)

不要一上来抠某个字节。先弄清 传输层 → 封装层 → 加密/压缩层 → 序列化层,每层剥离后再看内容。

0.4 自适应工作流(本 skill 的主干)

输入可能是零:如果用户只给了一个安装包(apk/ipa/exe),先走 references/from-installer.md 的自举流水线把证据造出来,再进入下面的步骤。

[第0步] 读取输入
   ├─ 只有安装包 ? ──▶ references/from-installer.md
   │    解包 → 判引擎/语言 → 自产 dump.cs/dll/lua/资源 → 抓包 → 动态 dump
   └─ 已有 dump/lua/抓包 ? ──▶ 直接进第1步
        ↓
[第0.5步] 建立工作区契约 → 在项目根放 AGENTS.md(边界/目录分区/验证命令)  ← templates/AGENTS.md
        ↓
[第1步] 产出「项目档案 project-profile」              ← references/adaptation.md
   扫描目录/文件,识别引擎、语言、网络库、资源格式、已知工具产物
        ↓
[第2步] 证据清点 → 证据清单 evidence-inventory
   每条证据:来源、类型、能得出什么结论、置信度
        ↓
[第3步] 决策引擎 → 逐层决策(引擎/传输/封装/加密/序列化/架构)  ← references/decision-tree.md
   每个决策点都有"若…则…"的分支,禁止默认套用参考实现
        ↓
[第4步] 产出协议规格 protocol.spec.yaml                     ← references/protocol-spec.md
   语言无关,含 opcodes / frames / crypto / serialize / state_machine / client / subsystems
        ↓
[第5步] 由规格生成/改造服务端(多语言参考)                   ← references/codegen.md
   选一个最贴近目标协议的语言;参考实现只提供"套路",不提供"答案"
        ↓
[第6步] 平台部署(端游/手游/服务器)                         ← §14
        ↓
[第7步] 客户端对接 + 闭环验证                                 ← §8/§9

可选分叉(M11 内联服务端):若目标可单机化且客户端能注入 / 能改,可跳过传输与加密层的还原, 直接在客户端进程内合成响应 → references/inline-server.md。

换个视角看同一件事——四阶段路线(见 references/workflow-roadmap.md):

阶段0 静态分析(清点线索) → 阶段1 建工具+登录链文档 → 阶段2 重定向(让请求到达本地离线服务端)
→ 阶段3 补包循环(上游→下游,客户端能走到下一步才算通) → 阶段4 功能清单迭代

§0.4 是"按产物推进的流水线",四阶段是"按顺序+验收推进的路线图",两者是同一件事的两种记法。 不确定从哪下手时,先看 workflow-roadmap.md 的全景图。

0.4b 补包循环 (进游戏阶段的核心工作模式)

这是**"让客户端一步步前进"的唯一主线**。四阶段的阶段 3 就是它。 前两步(静态分析、重定向)都只是为它铺路。

方向:从【登录链最上游】往下游补回包

① 先回【服务器列表】     ← 地址来自这里
② 再回【登录握手】       ← 鉴权 / token / hash
③ 最后补【游戏需要的数据】← 角色 / 背包 / 任务 / VIP / 签到 …

通过标准:每补一块,以「客户端能走到下一步」为准

不是"服务端没报错",而是客户端真的前进了一步(界面前进 / 状态推进)。 —— 这是铁律 4「不把『能跑』当『跑通』」在补包阶段的落地。

出错怎么查:对照报错 + 两侧日志

拿 客户端报错、网络日志、服务端日志 三边对齐,判断属于哪一种:

症状 大概率原因
客户端没反应 / 静默断开 格式错了(帧 / 字节序 / 字段布局 + 长度)
明确报错 / 空指针 数据缺失(少字段、空子消息被省略)
走到了下一步但还是不对 逻辑根本没有写(该给的条件数据没给)

里程碑:能进入游戏主场景 = 成功一大半。

之后工作流程就只有这一条路线——不断让用户测试工作区目标,循环:

测试 →(看日志)→ 定位 →(改)→ 修改 → 再测试   ↺

收尾:整理【功能清单文档】放到工作区,反复迭代直到基本完善 → templates/function-checklist.md。

详细版(含三边对齐的排错顺序、假阳性排查):references/workflow-roadmap.md §阶段 3~4。

0.5 参考实现的正确用法

参考物 是什么 怎么用
references/primer.md 原理层:三要素(配置/协议/代码)、数据包协议本质、协议号与协议表、热更源码、双证据链 第一次接触某客户端时先读,避免"不知道在找什么"
references/workflow-roadmap.md 四阶段路线总纲(静态分析→建工具+登录链→重定向→补包循环→清单迭代)+ 四层重定向表 动手前看全景图;它把 §0.4 与 M1~M11 串成一条有验收的线
references/server-architecture-basics.md 反推对象地图(逆向视角):你在还原哪几类服、网关留下的隐藏层、从包里认 Protobuf/KCP、同步模型决定"要还原多少逻辑" 判断"你在反推哪几类服、去哪找证据"时读;避免在网关层迷路
server/ 长连接二进制协议的参考实现(Python) 只借鉴分层结构与套路,协议参数全部按 Spec 改
templates/mock_server.py 极简单文件桩 逆向早期快速验证用
examples/*.md 不同游戏类型的推演示例 看"给定这类证据会怎么决策",不要照搬结论
references/cases.md 两个真实成功的服务端项目(Go / Node) 看真实项目的子系统与取舍,校准自己的方案
references/client-languages.md 各客户端语言的反编译与打补丁 先判语言,再选工具
references/cocos2d.md Cocos2d-x / Cocos Creator 深潜(.jsc/.lua 解包 / 自加密 HTTP-RPC / 多端口 / 热更 / 语言表还原 / §H2 进服后的 Live 运营:note 双层包裹、客户端本地存档合并、双表版本差异、自愈设计、部署纪律) 目标是 cocos 客户端时必读(另见 examples/E-cocos2dx-http-js.md §8 Live 运营阶段)
references/live-ops.md Live 运营手册(进服后 30+ 子系统按投诉频率排序的补全次序 / 契约反推法 / 自愈设计 / 双表差异处置) 进服之后读,与 cocos2d.md §H2 互补、引擎无关
references/from-installer.md 零输入自举:只有安装包怎么自产证据 最常见的入口,必读
references/reading-path.md 最小必读路径(按任务类型给 3~5 个文件的阅读顺序) 打开 skill 的第一件事
references/methods.md 10 种反推方法 + 选择矩阵 动手前先看(§0.0)
references/closure-verification.md 闭环验证 6 类假阳性 + 检查表 宣布"成功"前必看;接手别人项目时第一条
references/engineering-practices.md 实机跑通项目的工程范式(fixture 回放 / 具名完成点 / 证据分档 / ADR) 动手前定策略;配套 templates/adr-*.md、templates/e2e-*.md
references/wire-level-patching.md 实现层核心:不依赖 protobuf 运行时的 wire 级定点改写 写服务端之前必读;决定"先跑起来还是先凑 schema"
references/client-address-sources.md 客户端地址来源清查(六类来源 + 落点策略 + 重签后果) 改包/对接客户端之前必读;解决"改了 URL 还连官方"
references/verification-and-status.md 三轴状态 + 可达性分类 + 测试门禁 写 TRACKER / 宣称完成之前必读
references/release-and-ops.md 发布、部署与运营(配置冻结 / 端口族 / CDN / 备份) 跑通之后要交付时看
references/case-il2cpp-ecdh.md 真实案例:Unity IL2CPP + ECDH 登录服(进行中) 看"分层结论怎么写 + 卡点怎么复核"
references/repack-rename.md 改包名 / 重打包 / 签名 / 包名派生密钥 想产独立安装包时看
references/inline-server.md 内联服务端:在客户端进程内合成响应(门面三类入口 / 回调投递纪律 / 延迟派发 / 双通路 / 内联版验收与假阳性) 能注入且要单机化时先读这个 —— 它决定你要不要起外部服务端
references/runtime-object-synthesis.md 运行时对象合成与字段发现(对象级 schema 自举 / dump 循环 / 填值纪律 / 对象级→wire 级切换) 走内联路线时必读;也可用来先拿一份可信字段清单
references/platform-sdk-and-admission.md 平台 SDK 登录态复用 + 目录服→区服两段准入 大厂手游(第三方平台账号 SDK)登录卡点时读
references/case-il2cpp-inline.md 真实案例:Unity IL2CPP + 平台 SDK 的内联服务端 看「不起服务端」这条路怎么走、哪些结论已被证伪
schema/*.yaml 规格/档案模板 直接复制填,作为 AI 的中间产物
extensions/ 后续拓展(可选),不影响核心运行 跑通之后再考虑,见 §19

注意: 反例:直接把 server/config/config.yaml 的 opcode_size=2 拿去套一个 1 字节消息号的游戏 —— 必错。 正确做法:Spec 里写明 opcode_size: 1,再改代码。

0.6 启动阶段的两把钥匙

来自实机跑通到「主界面」的真实项目范式,详见 references/engineering-practices.md。 先读这两条,能少走几周弯路。

钥匙一:fixture 回放(抓包 → 原样重放 + 定点改写)

启动时客户端要一次性吃下几十个字段的嵌套状态(角色/背包/任务/VIP/签到…), 纯靠反推字段拼几乎必卡在某个页面。更快的路径:

原版客户端连原版服务器 → 抓一次完整启动序列 → 落成 fixture(含方向/消息号/seq/flag/原始 body)
→ 本地服务端按【原始顺序】重放 → 未知字段【原样保留】,只对已确认字段做 wire-level 定点改写

具体怎么实现(不引入 protobuf 运行时的字段遍历 + splice 替换) → references/wire-level-patching.md(含帧 codec、字段遍历器、 patch_varint/bytes/string、"空子消息也要保留"等实测经验)

注意: 两个红线:① 不要用「缺字段的伪造对象」代替真实启动数据; ② 一个账号的 fixture 带着它的 UID/角色数据,不能直接发给别的账号。

钥匙二:具名「完成点」做验收

不要写「登录成功」。要写客户端上的一个可观察状态,例如: 「收到启动结束消息后进入主界面,不是只保持 TCP 连接」。 再补一次强制停止 + 重启 + 重连,确认状态仍在。

配合使用:templates/e2e-evidence-template.md(帧序表 + 主动解释帧长差异 + 重连快照)、 templates/adr-template.md(写清「当前不做的事情」与回滚条件)。


1. 输入分类与预处理

详细步骤(命令 / 工具 / 判断 / 常见坑)见 references/phases-detail.md。

2. 阶段一:侦察与引擎识别

详细步骤(命令 / 工具 / 判断 / 常见坑)见 references/phases-detail.md。

3. 阶段二:流量获取

详细步骤(命令 / 工具 / 判断 / 常见坑)见 references/phases-detail.md。

4. 阶段三:协议分层识别

详细步骤(命令 / 工具 / 判断 / 常见坑)见 references/phases-detail.md。

5. 阶段四:客户端静态分析(分引擎)

详细步骤(命令 / 工具 / 判断 / 常见坑)见 references/phases-detail.md。

6. 阶段五:字段语义推断

详细步骤(命令 / 工具 / 判断 / 常见坑)见 references/phases-detail.md。

7. 阶段六:服务端建模与实现

详细步骤(命令 / 工具 / 判断 / 常见坑)见 references/phases-detail.md。

8. 阶段七:部署(本地 / 服务器 / 容器)

详细步骤(命令 / 工具 / 判断 / 常见坑)见 references/phases-detail.md。

9. 阶段八:验证与回滚

详细步骤(命令 / 工具 / 判断 / 常见坑)见 references/phases-detail.md。

10. 输出物清单(交付模板)

详细步骤(命令 / 工具 / 判断 / 常见坑)见 references/phases-detail.md。

11. 常见坑(速记)

完整清单见 references/closure-verification.md §附「高频常见坑速查」。这里只留最要记住的几条:

  • 改包顺序:先"不改包 + 端口劫持"跑通协议,最后才改包(动手前读 )。
  • raw deflate ≠ 加密:先在 decision-tree.md §4.0 做 30 秒快筛,别在错误的加密假设上耗数小时。
  • 别把"能跑"当"跑通":端口在听 / 自环 OK / 日志打勾都不是闭环证据(铁律 4)。
  • 只跑通登录不算完:状态机未闭环,客户端进场景即崩。

12. 工具速查

用途 工具
Unity IL2CPP dump Il2CppDumper / Il2CppInspector / r2unity
Unity Mono dnSpy / ILSpy
Lua 反编译 unluac / luadec
反汇编 IDA Pro / Ghidra / Hopper
动态 Frida / x64dbg / lldb
UE 资源 FModel / UnrealPak / umodel / AesFinder
UE SDK/mapping Dumper-7 / UE4SS / usmap dumper
抓包 mitmproxy / Charles / Wireshark / tcpdump
协议试解 protoc --decode_raw / binwalk / ent
接口清单提取 tools/extract_interfaces.py(本 skill 自带)
so 结构 / 依赖 / 符号 readelf -d(NEEDED)、readelf --dyn-syms --wide(FUNC + OBJECT 都要看)、strings -a(找硬编码常量)
APK 编辑 / 资源 / 重打包 / 签名 MT 管理器(含 MCP:http://127.0.0.1:8787/mcp,可 dex/资源/构建/签名一条龙)、apktool、apksigner
ZIP 原样复制重打包 本 skill tools/ 的 repack 脚本(2GB 包秒级,只替换目标条目)
动态调试 / 内存 Frida、/proc/<pid>/maps(看加载了哪些 so)

13. 使用本 Skill 的 AI 行为契约(必读)

完整行为契约(强制产物 / 硬性禁止 / 缺信息做法 / 决策可追溯 / 终点定义 / 回滚)见 references/ai-contract.md。

使用守则与限制(允许 / 禁止用途 + 硬性约束 + 自检清单)见 references/usage-policy.md——每次动手前先读。

14. 运行环境与工具链(端游 / 手游)

服务端代码同一套,差别只在运行平台 + 保活 + 客户端对接 → 详见 references/termux.md(手游/Android)、references/windows.md(端游/Windows)。

环境选择:

只用 Python+SQLite      → Termux / 裸 Windows
要 AES 或大量 Linux 工具 → Termux+proot Ubuntu / WSL2
要长期对外在线           → 云服务器(Ubuntu + deploy.sh) / Windows NSSM
仅本机自测              → 直接跑,客户端连 127.0.0.1

注意: 保活是最容易翻车的点:Termux 要 termux-wake-lock + 关电池优化;Windows 用服务/计划任务,别用前台窗口。 脚本:server/start_termux.sh、server/deploy/start_windows.bat。


15. 充值 / 邮件 / 道具奖励发放

客户端连的是我们自己的服务端,第三方支付 SDK 不存在 → 下单直接判成功并发放("点击购买即成功")。 参考实现:logic/handlers/pay.py / mail.py;商品表 store/models.py: PAY_PRODUCTS(键 = 客户端真实 product_id)。

  • 两条路径(game.pay_grant_mode):direct 货币直接进角色 / mail 发附件邮件自助领取;pay_auto_success:true 跳过真实校验。
  • 协议:PAY_PRODUCT_LIST 0x0501/2、PAY 0x0503/4、MAIL_{LIST,READ,CLAIM} 0x0401~0x0406、MAIL_NEW_NTF 0x0407。
  • 踩过的坑:① 邮件推送必须在 PayRes 之后发(否则被当成充值回包);② 领取/已读/删除共用结构但必须用各自 opcode;③ order_no UNIQUE 保幂等;④ 发放全在服务端。
  • 边界:仅自建/离线/已授权;不接真实支付渠道、不伪造凭证。

16. 账号体系与接口还原

目标:官方客户端用我们自己注册的账号登录我们的服务端。方法论 → references/account.md。

  • 账号从哪来:A 客户端内注册 / B 独立注册网站(写同一 DB,客户端只登录;模板 → templates/register-site/)/ C 脚本批量建号。
  • 注意: 最大的坑——密码预处理:客户端常先 MD5(pwd+salt) 再发包,必须复刻其哈希,否则注册的密码登不上。找法:读登录函数 / 抓两次包看密文是否固定;落 Spec 的 account.password_hash/salt/client_side_hashing。
  • 接口替换:优先改配置 / hosts,不动二进制;有服务器列表就返回我们自己的地址(→ §8.6、client-address-sources.md)。
  • 典型链路:版本/公告 → 登录(账号+密文) → token → 服务器列表 → 带 token 连游戏服(TCP)。
  • 账号接口逐条登记进 TRACKER.md。

17. 房间 / 战斗 / 掉落(核心玩法)

没有战斗和掉落,游戏就不成立。 方法论 → references/combat.md、references/drops.md; 参考实现 → server/app/logic/rooms.py、loot.py、handlers/{room,battle,drop}.py。

  • 关系:大厅/场景 → 房间(匹配/组队) → 战斗(回合/实时) → 结算 → 掉落(掷骰) → 背包/邮件。
  • 房间:创建→加入/退出→准备→房主开始→战斗→回等待;必备槽位上限、房主权限、全准备才开、房主退出移交、空房回收。
  • 战斗(铁律:结算在服务端):客户端只发意图;回合制=服务端算结果并广播,实时=服务端定权威状态。必备回合推进/伤害/超时判负/异常退出;结束广播 BATTLE_RESULT_NTF。
  • 掉落:table_id → {rolls, entries:[{item_id, count[min,max], rate}]}(从客户端配置表反推);服务端掷骰;背包满 → 自动转邮件补发。
  • 协议:房间 0x0601~0x060B、战斗 0x0701~0x0704、掉落/背包 0x0801~0x0805(详表见 server/app/proto/opcodes.py)。
  • 经济系统复用:抽卡(references/gacha.md) / 商店 / 背包 共用同一套「随机→结算→入库」,不要写两套。

18. 快速上手(AI 第一次拿到项目的动作序列)

[第 0 步 · 最重要] 选对方法
  · 回答 3 个问题:有什么 / 要什么 / 什么约束
  · 对照 references/methods.md §4/§5 选定方法 → 记进 project-profile.yaml 的 method 段
  · 默认路线:M1/M4 → M2+M3 → M6 → M9 → M5

[如果只有安装包]
  0. 走 references/from-installer.md:解包 → 判引擎/语言 → 自产证据 → 抓包
     (详见该文件第 9 节的 10 项动作清单)

[通用主流程]
  1. 列目录 → 跑 references/adaptation.md 的探测命令
  2. 填 out/project-profile.yaml(包括 needs 清单)
  3. 逐条记录证据 → out/evidence-inventory.md
  4. 走 references/decision-tree.md → 填 out/protocol.spec.yaml
     (含 client / subsystems 两段)
  5. 检查 protocol-spec.md 的「Spec 完成度清单」
  5.5 账号/接口:读 references/account.md → 在 TRACKER.md 登记接口清单
  6. 按 references/codegen.md 选语言 + 改造/新建服务端
  7. 增量验证(连接→登录→…)→ 回填置信度
  8. 部署(§14)+ 客户端对接(§8)→ 闭环

看 examples/ 里的三个推演,理解"同样流程、不同结论"。


19. 后续拓展(可选,不影响核心运行)

判断标准:去掉它,游戏还能不能正常玩?能 → 它就是拓展(放 extensions/),不写进核心流程。

| 拓展 | 文件 | 一句话 | |------|------|--------| | 服务端人机(假玩家) | extensions/bots.md | 多人副本凑不齐人时用 AI 自动补位 | | 反推人机机制 | extensions/bot-reverse.md | 无参考项时从客户端获取信息反推它的人机实现 | 人机为什么是拓展:单人游戏完全不需要;多人缺人只损体验、服务端本身能跑;参考实现默认 auto_fill: 0(不生成假玩家)。 用法(跑通后再做):核心链路先通 → 确认是否"必须多人" → bot-reverse.md 反推机制写进 Spec → bots.md 实现 → 调试(bots spawn/clear/difficulty)→ 原版客户端验收。

注意: 拓展不参与核心验收(§13 的强制产物与闭环)。没做拓展 ≠ 交付不完整。 不要因为拓展没做就认为交付不完整;也不要把它塞进核心流程。

Files (game-client-to-server-reverse)
  • examples
    • A-unity-il2cpp-protobuf.md 3.5 KB
      # 示例 A:Unity IL2CPP + protobuf + TCP
      
      > **注意:这是推演示例,不是通用结论。** 你的项目未必长这样。
      
      ## 1. 用户给了什么
      - `dump.cs`(Il2CppDumper 产物,~8 万行)
      - 一份 `capture.pcap`(登录时的抓包)
      - 说明:安卓手游,Unity
      
      ## 2. 证据清单
      
      ```
      [E01] dump.cs 有 class NetManager { void Send(int msgId, IMessage msg) }   -> 有独立 msgId     高
      [E02] dump.cs 有 ProtoBuf.Serializer.Deserialize<T>() 调用                -> 序列化=protobuf   高
      [E03] dump.cs 有 enum MsgId { Login=1001, CharList=1002, ... }             -> 消息号表可直接抄   高
      [E04] pcap: TCP 到 124.x.x.x:9400,负载前 4 字节 = 长度(大端)              -> 4字节大端长度头    高
      [E05] 负载去掉长度头后首 2 字节 = 0x03E9(=1001)                            -> opcode 2字节小端   高
      [E06] 负载熵值低,无压缩特征                                               -> 无压缩             高
      [E07] 负载里 protobuf 字段可见                                             -> 无加密             高
      [E08] 未抓到选角后的包                                                     -> 后续消息未知        低
      ```
      
      ## 3. 逐层决策(走决策树)
      
      | 层 | 树节点 | 结论 |
      |----|--------|------|
      | 引擎 | 有 libil2cpp.so + global-metadata.dat | unity-il2cpp |
      | 传输 | pcap 有 TCP 握手 | tcp |
      | 封装 | 第1字段=长度,合理值≤MTU | length_size=4, endian=big, includes_self=false |
      | 封装 | 去头后首2字节=0x03E9 | opcode_size=2, opcode_endian=little |
      | 加密 | 熵低 + protobuf 可见 | 无加密 |
      | 压缩 | 无 78 9C/1F 8B | 无压缩 |
      | 序列化 | 有 ProtoBuf.Serializer | protobuf |
      
      ## 4. 产出 Spec 关键片段
      
      ```yaml
      transport: {type: tcp, port: 9400, confidence: high, evidence:[E04]}
      frame:
        mode: length_prefix
        length_size: 4
        length_endian: big
        includes_self: false
        opcode_size: 2
        opcode_endian: little
        confidence: high
        evidence: [E04, E05]
      crypto: {enabled: false, confidence: high, evidence:[E07]}
      compress: {enabled: false, confidence: high, evidence:[E06]}
      serialize: {format: protobuf, confidence: high, evidence:[E02]}
      opcodes:
        source: dump.cs
        table: [{value: 1001, name: Login}, {value: 1002, name: CharList}]
        confidence: high
        evidence: [E03]
      unresolved:
        - {item: "选角后的消息", need: "抓取一次选角操作", blocking: false}
      ```
      
      ## 5. 参考实现要改哪里
      
      | 位置 | 改动 |
      |------|------|
      | `config.yaml` frame | `length_size: 4`、`length_endian: big`、`opcode_size: 2` |
      | `config.yaml` crypto | `enabled: false` |
      | `config.yaml` serialize | `format: protobuf`(启用 protobuf 分支) |
      | `proto/opcodes.py` | 用 E03 的表整表替换 |
      | `proto/messages.py` | 改成 `.proto` 生成类(protobuf)或手写 |
      | `codec.py` | 参数驱动,已支持;protobuf 分支走 `SerializeToString()` |
      | 新增 | 放 `.proto` 文件并生成 Python 类 |
      
      **技术栈建议**:继续用 Python 参考实现即可(协议简单、明文)。
      
      ## 6. 闭环验证路径
      ```
      连接 → 握手(如有) → 登录(1001) → 收 1002 角色列表 → 建角 → 选角
      ```
      先用 `templates/mock_server.py` 造对应帧验证长连接,再落到 `server/`。
      
      ## 7. 坑与回退
      - 大端/小端判错 → 用「长度值 ≤ 实际负载」验证,两种都试。
      - 长度是否含头部 → 用两条不同长度包反算。
      - protobuf 字段名丢失(只有 tag)→ 对照 `dump.cs` 里 IMessage 定义还原名字。
      - 若某段包熵突然变高 → 可能登录后启用了加密,回退决策树第 4 节重判。
    • B-ue-kcp-custom.md 3.3 KB
      # 示例 B:UE5 + KCP + 自定义二进制
      
      > **注意:这是推演示例,不是通用结论。**
      
      ## 1. 用户给了什么
      - `FModel` 导出目录(`uasset`/`uexp`)
      - Dumper-7 生成的 `SDK.hpp` / `SDK.cpp`
      - `capture.pcap`(对战中的抓包)
      - 说明:PC 端游,UE5
      
      ## 2. 证据清单
      
      ```
      [E01] 目录有 *.utoc/*.ucas + libUnreal → UE5 IoStore                     高
      [E02] SDK.hpp 有 NetDriver / PacketHandler 类                           高
      [E03] SDK.hpp 有 class FPacketHeader { uint32 Id; uint32 Size; }        高
      [E04] pcap: UDP 到 *:7777,每包前 4 字节几乎相同(conv)                    高
      [E05] 包结构符合 KCP: conv/cmd/frg/wnd/ts/sn/una/len                     高
      [E06] 剥掉 KCP 后负载高熵,16字节对齐                                     中→疑似 AES
      [E07] SDK 里搜到 FString Length 前置 int32 用法                          高 → 自定义二进制
      [E08] 密钥未在 SDK 明文中出现                                             低
      ```
      
      ## 3. 逐层决策
      
      | 层 | 树节点 | 结论 |
      |----|--------|------|
      | 引擎 | *.utoc + libUnreal | unreal(UE5) |
      | 传输 | UDP + conv 头 | udp → kcp |
      | 封装 | KCP 头 + 内层 FPacketHeader | KCP 外层 + UE FPacketHeader 内层 |
      | 加密 | 高熵 + 16 对齐 + 无明文 key | 疑似 AES,key 需动态 dump |
      | 序列化 | FString/FPacketHeader 特征 | 自定义二进制(小端) |
      | 消息号 | SDK PacketHandler | 从 SDK 抄 |
      
      ## 4. 产出 Spec 关键片段
      
      ```yaml
      transport: {type: kcp, port: 7777, confidence: high, evidence:[E04,E05]}
      frame:
        mode: length_prefix
        length_size: 4
        length_endian: little
        opcode_size: 4          # FPacketHeader.Id 是 uint32
        note: "KCP 外层 + UE 内层包头"
        confidence: medium
        evidence: [E03, E05]
      crypto:
        enabled: true
        algorithm: aes
        key_source: derived     # 需动态 dump
        key: null
        confidence: low
        evidence: [E06, E08]
      serialize: {format: binary, endian: little, confidence: high, evidence:[E07]}
      opcodes: {source: sdk.h, confidence: medium, evidence:[E02]}
      unresolved:
        - {item: "AES key", need: "运行时内存扫描(AES_finder)或 hook 解密函数", blocking: true}
        - {item: "KCP conv 来源", need: "抓握手包", blocking: false}
      ```
      
      ## 5. 参考实现要改哪里
      
      | 位置 | 改动 |
      |------|------|
      | `net/server.py` | **TCP → UDP/KCP**:引入 kcp 库,改事件循环 |
      | `config.yaml` frame | `opcode_size: 4`,明确"KCP 外层 + UE 内层"两级 |
      | `net/crypto.py` | 实现 AES(key 从 dump 来)+ 逐连接 key 逻辑 |
      | `proto/opcodes.py` | 从 SDK 抄 FPacketHandler 的 Id 表 |
      | `proto/messages.py` | 按 UE FString/TArray 规则实现(int32 长度、UTF-16 负长度) |
      | 技术栈 | 性能要求高 → 建议 **C++/Rust** 重写,贴近 UE 语义 |
      
      ## 6. 闭环验证路径
      ```
      先只做 KCP 握手回包 → 客户端能建立连接
      → 再解第一个明文包(登录)→ 确认 AES 参数
      → 逐步扩展
      ```
      **难度提示**:UE 原生 Replication 很难整体复现,优先 **Mock 关键包**让客户端过流程。
      
      ## 7. 坑与回退
      - AES key 拿不到 → 先只做「解密可读的握手/登录包」,其余透传。
      - KCP 与 TCP 混淆 → 看 cmd 字段取值与重传行为。
      - UE 版本差异 → FProperty 链跨版本不同,以 SDK dump 为准。
      - 若发现不是 KCP → 可能是自定义 UChannel,回退到纯二进制分析。
    • C-unity-lua-http.md 2.9 KB
      # 示例 C:Unity + Lua 热更 + HTTP
      
      > **注意:这是推演示例,不是通用结论。**
      
      ## 1. 用户或我们解析出来了什么
      - 一堆 `.lua`
      - `mitm` 抓包(HTTP)
      - 说明:休闲手游,Unity,业务逻辑在 Lua
      
      ## 2. 证据清单
      
      ```
      [E01] assets 里有 xlua 相关 so/字符串                         高
      [E02] lua 里 Net.send("login", {user=..., pwd=...})           高 → 消息名即语义
      [E03] 抓包 HTTP POST /api/login,body 是 JSON                 高
      [E04] 响应 JSON {code:0, token:"...", uid:123}                高
      [E05] 后续请求带 Header Authorization: Bearer <token>         高
      [E06] 有个轮询 /api/pull 每 3 秒                              高
      [E07] 未发现长连接                                            中
      ```
      
      ## 3. 逐层决策
      
      | 层 | 树节点 | 结论 |
      |----|--------|------|
      | 引擎 | 有 xlua + Unity | unity-lua |
      | 传输 | HTTP | http |
      | 封装 | HTTP 本身 | 无自定义长度头 |
      | 加密 | 明文 JSON | 无(HTTPS 层另说) |
      | 序列化 | JSON | json |
      | 消息号 | 用路径/动作名 | `login` / `pull` / ... |
      | 架构 | HTTP + 轮询 | HTTP 网关,无长连接 |
      
      ## 4. 产出 Spec 关键片段
      
      ```yaml
      transport: {type: http, port: 443, tls: true, confidence: high, evidence:[E03]}
      frame: {mode: "http_body", opcode_size: 0, confidence: high}   # 无自定义帧
      crypto: {enabled: false, confidence: high, evidence:[E04]}
      serialize: {format: json, confidence: high, evidence:[E03,E04]}
      opcodes:
        source: lua
        table:
          - {name: login,  route: "POST /api/login",  dir: c2s}
          - {name: pull,   route: "POST /api/pull",   dir: c2s}
        confidence: high
        evidence: [E02]
      state_machine:
        - {state: LOGGED_IN, on: [pull], next: IN_GAME}
      auth: {type: bearer_token, from: "login response .token", evidence:[E04,E05]}
      ```
      
      ## 5. 参考实现要改哪里
      
      | 位置 | 改动 |
      |------|------|
      | `net/server.py` | **不用 TCP 长连接** → 换成 **HTTP 服务**(FastAPI/Express) |
      | `net/codec.py` | 不需要(HTTP + JSON) |
      | `proto/opcodes.py` | 换成 **路由表** `{"login": "/api/login", ...}` |
      | `logic/handlers/` | 每个路由一个 handler,返回 JSON |
      | 鉴权 | 增加 Bearer token 校验中间件 |
      | 参考实现 | `server/` 的**会话/心跳/广播**基本用不上,只用它的**分层思路** |
      
      **技术栈建议**:Python **FastAPI** 或 Node **Express**,而不是 asyncio TCP。
      
      ## 6. 闭环验证路径
      ```
      改 hosts → /api/login 返回我们造的 {code:0, token, uid}
      → 客户端进入主界面 → /api/pull 返回场景数据
      → 打通主流程
      ```
      
      ## 7. 坑与回退
      - HTTPS + 证书固定 → 自签 CA 或 Frida 绕过(自测)。
      - Token 校验逻辑在 Lua 里 → 先读 Lua 的 `Net` 封装,照它的字段做。
      - 若其实还有长连接 → 补抓包,回到决策树判断是否要 TCP 网关。
      - 若响应有签名/时间戳校验 → 按抓包实现同样签名算法。
    • D-real-lua-client.md 8.3 KB
      # 实例 D:真实 Lua 客户端源码分析(横版动作手游)
      
      > 注意: **这只是「一个」真实项目的分析记录,不是通用结论。**
      > 目的是演示**方法**:拿到客户端源码后怎么系统性扒出接口清单。
      > 换一个游戏,协议模式、opCode 形式、子系统划分**都可能完全不同**。
      
      ---
      
      ## 1. 项目概况
      
      | 项 | 值 |
      |----|----|
      | 客户端 | Unity + **xLua**(Lua 承载业务逻辑) |
      | 源码规模 | 2429 文件 / 2365 个 `.lua` |
      | 协议文件 | `message/Lua_MessageUtil.lua`(**18573 行 / 632KB**) |
      | 配置表 | `config/Cfg*`(92+ 张) |
      | 客户端保护 | 保护库 / 自研 + 文件校验 |
      | 热更 | 遍地 `*Hotfix.lua` + HotfixManager |
      
      ---
      
      ## 2. 协议模式(本项目特有)
      
      ```
      msg = GetMessage("LOGIN_INFO")      -- 1) 按 opCode 建信封
      msg.request = { ... }               -- 2) 填请求体(字段随 opCode 而变)
      SendMessage(msg, callback)          -- 3)
          ├─ protobuf.encode("GameMessage.Message", msg)   -- protobuf 序列化
          └─ NetworkingManager:SendMessage(bytes, msg.opCode, callback)  -- opCode 随帧发送
      ```
      
      **特征总结**:
      - **信封统一**:`GameMessage.Message`
      - **opCode 是字符串**(`"LOGIN_INFO"` / `"COMBAT_START"`),不是数字
      - **序列化 protobuf**(`pbc/protobuf.lua`)
      - 另有 `json` / `msgpack` 库备用
      
      > 对比:其它项目可能是 `数字 opCode + 自定义二进制`、`HTTP 路由 + JSON`、
      > `KCP + protobuf`…… **必须自行判定**(见 `decision-tree.md`)。
      
      ---
      
      ## 3. 反推六步法(可复用)
      
      ```
      1. 定位协议文件
         grep -rl "protobuf\|opCode\|SendMessage\|Socket" --include=*.lua | head
         → 本项目: message/Lua_MessageUtil.lua
      
      2. 判定协议模式
         grep -n "protobuf.encode\|SendMessage(" 协议文件
         → 信封名 + opCode 形式 + 序列化方式
      
      3. 扒出全部 opCode(接口总表)
         python3 tools/extract_interfaces.py <源码目录> --out interfaces.md
         → 本项目: 1794 个 opCode
      
      4. 按前缀聚类 → 得到子系统划分
         EVENT(207) FAMILY(119) WORKSHOP(102) CHAR(67) PVP(58) TEAM(55) MATCH(44) …
      
      5. 抽取请求字段(payload schema)
         看每个 Send 函数里 msg.request.xxx = 的赋值 → 得到该 opCode 的字段
      
      6. 写进 Spec → 按子系统逐块实现
         protocol.spec.yaml 的 opcodes.table + messages
      ```
      
      ---
      
      ## 4. 实测产出
      
      ```bash
      python3 tools/extract_interfaces.py ./lua_src --out interfaces.md --json interfaces.json
      # 扫描文件: 2391
      # 合计: opcode_str=1794, route=121, proto_msg=2
      ```
      
      报告按前缀分组,直接可当**待实现清单**用。
      
      ---
      
      ## 5. 子系统清单(按 opCode 前缀)
      
      | 子系统 | 前缀 | 数量 | 关键接口样例 |
      |--------|------|------|-------------|
      | **活动** | `EVENT_*` | 207 | `EVENT_BAGHERO_*` / `EVENT_BOSSRUSH_*` |
      | **家族** | `FAMILY_*` | 119 | 家族 BOSS / 联赛 / 祭坛 |
      | **工坊/自制** | `WORKSHOP_*` | 102 | `PUGC` 玩家自制关卡 |
      | **角色** | `CHAR_*` | 67 | 培养 / 皮肤 / 天赋 |
      | **PVP** | `PVP_*` | 58 | 排位 / 赛季 |
      | **队伍** | `TEAM_*` | 55 | `TEAM_MATCH_*` 招募与匹配 |
      | **匹配** | `MATCH_*` | 44 | `MATCH_ROOM_INFO` / `MATCH_ROUND_*` / `MATCH_SCORE_*` |
      | **Boss** | `BOSS_*` | 36 | `TEAMBOSS_*` / `REWARD_BOSS_*` |
      | **好友/组队房** | `FRIENDLY_*` | 25 | `FRIENDLY_3V3_{JOIN,READY,START,KICK,LEAVE,CHANGE_SIDE}` |
      | **好友** | `FRIEND_*` | 15 | 好友增删查 |
      | **黑名单** | `BLOCK_*` | 3 | `BLOCK_ADD/DELETE/GET` |
      | **多人大乱斗** | `MULTICOMBAT_*` / `MULTI_BRAWL_*` | 14+ | 禁卡 / 房间 / 观战 |
      | **战斗** | `COMBAT_*` | 13 | `COMBAT_START/FINISH/QUIT/REBORN/RELAY` |
      | **充值** | `PAY_*` / `VIP_*` / `REBATE_*` | ~10 | `PAY_PACKAGE_GET_SHOP_LIST` / `PAY_LIMIT_STATISTICS` |
      | **邮件** | `MAIL_*` | 3 | `MAIL_GET_LIST` / `MAIL_READ_MSG` / `MAIL_REMOVE_MSG` |
      | **背包** | `BAG_*` | 8 | `BAG_GET_ITEM_LIST` / `BAG_OPEN_PACKAGE` / `BAG_ITEM_COMPOSE` |
      | **抽卡** | `LOTTERY_*` | 10 | 保底 / 历史 / 选池 / 重复奖励 |
      | 抽卡(其他池) | `WEAPON_LOTTERY_*` / `SKIN_LOTTERY` / `RUNELOTTERY` / `GACHAPON_*` / `STEP_GACHA_*` / `NOVICE_DRAW` | ~20 | 武器/皮肤/咒印/扭蛋/阶梯/新手池 |
      | 抽卡(活动侧) | `DO_GASHAPON_LOTTERY` / `DO_LOTTERY_EVENT` / `ANNIVERSARY_*_LOTTERY` / `FAMILY_LOTTERY` | ~10 | 活动池、家族抽奖 |
      | **掉落/奖励** | `QUEST_DROP` / `COMBAT_LOTTERY_*` / `REWARD_*` | ~29 | 掉落与战令 |
      | **任务** | `QUEST_*` | 11 | `QUEST_GET_LIST` / `QUEST_DONE` |
      | **防沉迷** | `ANTI_INDULGE_*` | 3 | 实名 / 绑定手机 |
      | **热更** | `CLIENT_HOTFIX` | 1 | 客户端热更 |
      | **客户端存档** | `CLIENT_SAVE_*` | 4 | 设置类 |
      | **心跳** | `NONE` | 1 | 空包保活 |
      | **QA** | `QACMD` | 1 | 服务端执行指令 |
      
      ---
      
      ## 6. 关键发现(对通用 skill 的启示)
      
      ### 6.1 注意: 「人机」可能根本没有协议
      
      搜索 `robot|bot|npc|AI|dummy`:
      
      ```
      GET_NPC_INFO / NPC_VOTE / SEND_NPC_GIFT / UP_NPC_SKIN …(都是社交/投票)
      ```
      
      **没有任何"人机队友"的 opCode。**
      → 说明该游戏的战斗是 PVE / PVP,**不需要服务端人机**。
      → 印证 `extensions/README.md` 的判断:**人机是拓展,游戏本来没有就不用做**。
      
      > 这是「[-] 游戏本身没有」的教科书案例:**不要凭空造需求**。
      
      ### 6.2 充值:客户端只"拉商品列表",不碰支付
      
      ```
      PAY_PACKAGE_GET_SHOP_LIST     -- 拉商品
      PAY_LIMIT_STATISTICS          -- 限额统计
      IOSNEWPAY_* / REBATE_*        -- 渠道与返利
      ```
      
      真正的支付走 **SDK**,发货在服务端 → 与 `account.md` / §15 的自托管支付设计一致。
      
      ### 6.2b 抽卡:客户端只有"概率展示",没有掷骰 
      
      **抽卡在本项目共 62 个接口,分散在多个族**:
      
      ```
      LOTTERY_*                     主抽卡:SELECT_ROLE / SELECT_POOL_TYPE /
                                    GUARANTEE_CHOOSE / HISTORY / GET_REPLICA_AWARD
      WEAPON_LOTTERY_* / SKIN_LOTTERY / RUNELOTTERY / GACHAPON_*
      STEP_GACHA_* / NOVICE_DRAW
      DO_GASHAPON_LOTTERY / DO_LOTTERY_EVENT / ANNIVERSARY_*_LOTTERY / FAMILY_LOTTERY
      COMBAT_LOTTERY_*              (战斗内的抽取玩法)
      ```
      
      **关键验证**:在 `UI/Draw/` 里搜随机:
      
      ```bash
      grep -rnE 'math.random|Mathf.Random|概率|probability' UI/Draw/
      # 只命中:_getProbabilityColor / "SSR概率提升10%"  → 全是展示逻辑
      ```
      
      → **客户端没有任何掷骰**,抽卡**完全在服务端**。
      → 与 `references/gacha.md` 的「第一铁律」完全吻合。
      
      **额外发现**:
      - 有**多池类型**(`SELECT_POOL_TYPE`)
      - 有**保底自选**(`GUARANTEE_CHOOSE` / `_CANCEL`)
      - 有**重复转换**(`GET_REPLICA_AWARD`,重复角色换奖励)
      - 有**抽卡历史**(`HISTORY` / `HISTORY_TOKEN`)
      
      ### 6.3 房间/匹配是「一族」接口
      
      ```
      TEAMBOSS_NEW_ROOM / JOIN_ROOM / LEAVE_ROOM / READY / START / KICK / ROOM_INFO
      FRIENDLY_3V3_JOIN / READY / START / KICK / LEAVE / CHANGE_SIDE
      MATCH_ROOM_INFO / MATCH_ROUND_* / MATCH_SCORE_*
      ```
      → 与 §17「房间」设计的规则(槽位/准备/开始/踢人/房主)**完全吻合**,可互相印证。
      
      ### 6.5 热更后门
      
      遍地 `*Hotfix.lua` = 官方线上补丁机制。
      → 但**我们做的是服务端**,客户端的 Hotfix 与我们无关(除非要 patch 客户端)。
      
      ---
      
      ## 7. 对「反推服务端」的启示
      
      | 发现 | 对服务端的行动 |
      |------|---------------|
      | 1794 个 opCode | 不可能一次做完全部;**按子系统分批**,先做能跑通主流程的 |
      | 字符串 opCode | 服务端需建 `opCode → handler` 映射表(不是数字 switch) |
      | protobuf 信封 | 需要 `.proto` 定义或按 `msg.request.xxx` 还原字段 |
      | 有 `NONE` 心跳 | 必须先实现心跳应答,否则上线秒掉 |
      | 有 `QACMD` | 服务端可保留但**不实现**(或只做调试用) |
      | 无人机协议 | **不做人机**(游戏本身没有) |
      
      ---
      
      ## 8. 通用方法总结(这才是要带走的东西)
      
      1. **先找协议文件**(一次 grep 就能定位)
      2. **判协议模式**(信封 / opCode 形式 / 序列化)
      3. **用工具扒接口总表**(`tools/extract_interfaces.py`)
      4. **按前缀聚类 = 子系统划分**
      5. **看请求字段 = payload schema**
      6. **按子系统分批实现**,先主流程后周边
      7. **不存在的东西不要造**(如本项目无人机协议)
      
      > 换一个游戏:先跑第 1~4 步,你会得到**完全不同的**一份接口清单。
      > 方法不变,结论必变。
    • E-cocos2dx-http-js.md 10.2 KB
      # 示例 E:Cocos2d-x(JS) + 热更 + 多端口 HTTP + AES 自加密
      
      > **注意:这是推演示例,不是通用结论。** 参考实现只提供"套路",参数按你的项目定。
      > 本示例取自一个真实项目(cocos2d-x + JSB JS 脚本 + AES 自加密 HTTP-RPC),关键结论可对照 `references/cocos2d.md`。
      
      ## 1. 用户给了什么
      - 一个 APK(`libcocos2djs.so` + `libEncryptorP.so` + `assets/` 里的 `index.jsc` / `scripts*.jsc.js`)
      - 解出的脚本(JS,几十万行,可 grep)
      - 已经能跑起来的客户端 + 一份抓包(HTTP,请求体是 hex 密文)
      - 目标:反推出能**正常进服**的服务端,并逐步还原背包/技能/商城/后台
      
      ## 2. 证据清单
      
      ```
      [E01] lib/ 有 libcocos2djs.so + libEncryptorP.so,assets 有 index.jsc         高 → cocos2d-x JSB
      [E02] 脚本里 HttpConst.XXX = "User.method",sendRequest(常量, params, cb)     高 → HTTP-RPC,do 即方法名
      [E03] 抓包:请求体 = 长 hex 串,乱码不可读                                    高 → 自加密(非明文 JSON)
      [E04] libEncryptorP.so 内字符串含 AES / cbc / pkcs,常量 32B/16B hex           高 → AES-256-CBC + KEY/IV
      [E05] 响应外壳 {"errorCode":0,"MsgData":"<hex>"}                             高 → 明文在 MsgData 里
      [E06] 解出明文请求 {"mod":"User","do":"LoadGame","p":{...}}                  高 → 明文壳确定
      [E07] 三个端口:选服 9898 / 业务 8002 / WS 9002                              高 → 端口族分工
      [E08] 客户端脚本里 MessageCenter.on(HttpConst.XXX, cb),cb 读响应字段         高 → 响应契约来源
      [E09] 常量 GET_DUNGEON_INFO = "User.getCopyData\n"(尾部有换行)              高 → 服务端必须 strip
      [E10] tables/*.json + lantab_zh.json,item.Name 是语言表 key                  高 → 文本需还原
      [E11] project.manifest 存在                                                   中 → 有热更,注意资源来源
      ```
      
      ## 3. 逐层决策
      
      | 层 | 树节点 | 结论 |
      |----|--------|------|
      | 引擎 | 有 `libcocos2djs.so` + `.jsc` | **cocos2d-x (JSB)** → 见 `cocos2d.md` |
      | 传输 | 抓包是 HTTP(80/自定义端口),多端口 | **HTTP-RPC**(非 protobuf 长连接) |
      | 封装 | HTTP body 即整个密文 | 无自定义长度头(`opcode_size=0`) |
      | 加密 | hex 密文 + `libEncryptorP.so` 的 AES 常量 | **AES-256-CBC + Pkcs7,hex 传输** |
      | 序列化 | 解密后是 `{"mod","do","p"}` | **JSON**,`do` 当 RPC 名 |
      | 消息号 | `do` 字段 | 路由名(`LoadGame`/`GetMarketList`…) |
      | 架构 | 多端口 HTTP + 轮询 + 一个 WS | 网关型,非长连接 |
      
      > 关键判断:**它不是"游戏服务器协议",而是"HTTP RPC + 整体加密"**。用 Unity/TCP 那套会走偏。
      
      ## 4. 产出 Spec 关键片段
      
      ```yaml
      transport: {type: http, ports: {select: 9898, biz: 8002, ws: 9002}, tls: false, confidence: high, evidence: [E06,E07]}
      frame: {mode: "http_body", opcode_size: 0, confidence: high}          # do 字段即 opcode
      crypto:
        enabled: true
        algo: AES-256-CBC
        padding: pkcs7
        key: "<32B hex>"
        iv:  "<16B hex>"
        wire: hex
        response_wrapper: {errorCode: 0, payload_field: MsgData}
        evidence: [E03,E04,E05]
      serialize: {format: json, plaintext_shape: {mod: User, do: <name>, p: {...}}, confidence: high, evidence: [E06]}
      opcodes:
        source: client_js            # 从 HttpConst.XXX 抓
        note: "客户端常量可能含尾部 \\n,服务端入口必须 strip()"
        confidence: high
        evidence: [E02,E09]
      state_machine:                 # 登录链(用"下一条请求"夹逼)
        - {state: SELECT_SERVER, on: [ChooseServer], next: LOGIN}
        - {state: LOGIN,         on: [quicklogin],   next: LOAD}
        - {state: LOAD,          on: [LoadGame],     next: IN_GAME}   # 失败则退回重试
        - {state: IN_GAME,       on: [GetChatAddress], next: IN_SCENE}
      client:
        index:                       # 函数名 -> 文件:行号(排障全靠它)
          - {fn: LoginMgr.loadGame,        loc: "scripts__index.jsc.js:66510"}
          - {fn: SaveDataMgr.updatePlayerData, loc: "scripts__index.jsc.js:106692"}
          - {fn: InventoryModel.parseEqData,   loc: "scripts__index.jsc.js:61238"}
      data:
        tables: "tables/*.json"
        l10n:   "lantab_zh.json"     # item.Name / Description 是 key
      ```
      
      ## 5. 参考实现要改哪里
      
      | 位置 | 改动 |
      |------|------|
      | `net/server.py` | **不用 TCP 长连接** → 起 **HTTP 服务**(aiohttp/Flask),按端口族起多个 listener |
      | `net/codec.py` | 换成 **AES-CBC 加解密 + hex**;响应包成 `{"errorCode":0,"MsgData":...}` |
      | `net/dispatcher.py` | 用 `do`(**先 strip**)当路由名分派;`mod` 通常恒 `"User"` |
      | `proto/opcodes.py` | 换成 **路由表**:`{"loadgame": handler, "getmarketlist": handler, ...}` |
      | `logic/handlers/` | 每个 `do` 一个 handler,**只喂客户端回调会读的字段**,默认 `eRet:1` |
      | 存档 | 按 `userAccount` **分桶**(物品/技能/装备),避免串号 |
      | `server/` 参考实现 | 会话/战斗/广播/心跳基本用不上,**只借它的分层思路** |
      
      **技术栈建议**:Python **aiohttp** 或 **FastAPI**;自测直接 `import` 服务端模块造/解包。
      
      ## 6. 闭环验证路径
      
      ```
      部署 HTTP-RPC 服务端(先回最小 eRet:1)
      → 服务端日志记录客户端发的每个 do
      → 逐 do:找到它的回调 → 补回调要读的字段
      → 真机跑:登录 → 选服 → 进服
          验收标准 = 客户端发出【下一条请求】(如 LoadGame 后出现 GetChatAddress),不是"服务端返回 200"
      → 进服后再补:背包/技能/商城/后台
      → 强制停止 + 重启 + 重连,确认状态仍在
      ```
      
      **真机 vs 自测必须分开看**:
      ```
      grep 'Dalvik' server.log            # 真机
      grep 'Python-urllib' server.log     # 自测(只证明服务端没崩)
      ```
      
      ## 7. 坑与回退
      
      | 坑 | 现象 | 回退/处置 |
      |----|------|-----------|
      | `do` 带 `\n` | 某接口永不匹配 | 入口 `strip()` |
      | **自测全绿、真机进不去** | 自测过了但进服转圈 | 自测≠真机;以"下一条请求"为验收 |
      | **一次改太多** | 崩了无法定位,只能整体回滚 | **最小增量、一次一变量**;保留"最后一次可进服"基线 |
      | 字段类型错 | 回调 `.indexOf/.length/.split` 抛异常 | 回回调里核对期望类型(`equipLocks` 要数组不是对象) |
      | 回滚不彻底 | 删了几个 key 仍崩 | **整文件对齐基线**,而不是只删字段 |
      | 配置表是数字 | 名称显示为数字 | `lantab_zh` 还原 |
      | 角色串号 | 技能/物品跨角色 | 按账号分桶 |
      | 大文件拖死会话/打包 | 复制/压缩卡住 | 排除 100M+ 的 `.so`、APK |
      | WS 心跳 ID 猜错 | 聊天 30~80s 必断重连 | 从 APK 解 `protoidmap` 逐号实证(本项目 5288=Pong,非 5279) |
      | 登录回包带事件 | 进服即崩、无报错循环 | LoadGame 只放数据;事件(eco 等)放战斗结算 |
      | 伪物品入背包 | 后台发经验后进不去 | 4001/4002/4003 按属性路由,LoadGame 过滤 itemObj |
      | 穿戴契约猜错 | 装备穿上打怪空手、重登被脱 | 从 REQ 日志反推真实字段(EquipmentType+itemID) |
      
      > 回退锚点:**"最后一次真实可进服"的服务端版本**(连同它的**回包大小**一起记)。
      > 本项目基线 ≈ 2200B;加了一堆新字段后到 2944B → 真机崩。大小变化本身就是报警信号。
      
      ## 8. 进服之后:Live 运营阶段(本项目 v28~v33 实录)
      
      > 进服 ≈ 项目完成 30%。下面是"能进"到"能长期玩"的实跑经验,机制细节见 `cocos2d.md §H2`。
      
      ### 8.1 子系统补全的优先级(按玩家投诉频率排序)
      
      ```
      1. 任务系统(主线卡死=玩家流失) → 2. 装备穿戴一致性 → 3. 商店购买(多套契约!)
      → 4. 武学修炼闭环(饥饿→练功→结算) → 5. 好友/查看玩家 → 6. 排行榜
      → 7. 副本 → 8. 打坐/治疗 → 9. 坐骑 → 10. 称号
      ```
      
      ### 8.2 本项目踩出的三条通用规律
      
      1. **每个"没效果"的接口,一半概率是字段名/契约错**,不是逻辑没写。
         例:穿戴等 `equipments` 字典(客户端从不发)→ 实际发 `{EquipmentType, itemID}`;
         打坐回包缺 `beginTime` → 客户端计时器不启动。
         **第一动作永远是 grep 客户端发送端代码,而不是改服务端逻辑。**
      
      2. **note 双层包裹 + 绝对值**是 UI 刷新的开关(见 cocos2d.md §H2.1)。
         服务端数据对了但客户端"看不见变化",九成是缺包裹或发了增量。
      
      3. **客户端本地有状态**(localStorage 的任务史/聊天记录),
         服务端下发"空"或"部分"都会**覆盖**它——要么更全才发,要么省略键。
         客户端请求里的 count 型 flag(taskFlag/finishTaskFlag)就是它告诉你"我有多少"。
      
      ### 8.3 长线运营的自愈设计
      
      - **LoadGame 自愈**:每次登录对账(任务物品缺→补、幽灵装备→剔、毒化血量→修、伪物品→滤),
        任何历史脏数据在玩家下次登录时自动修复,无需人工
      - **接发收闭环**:剧情物品接任务发、交任务收、登录对账补——三层兜底后该类工单归零
      - **后台=运维工具**:强推完成任务/直设货币经验/装备走实例,线上问题分钟级闭环
      
      ### 8.4 验收口径的变化
      
      进服后的验收不再是"下一条请求",而是**玩家可感知的正确性**:
      穿剑→战斗实体带剑系武功;买A→背包到账A且扣款对;练功结束→等级+1 且 UI 即刷。
      配合双账号用例(A 的操作不影响 B)+ 重登一致性(重登后状态不变)。
      
      ### 8.5 后续迭代补充(v34 实录 → 已抽象进 live-ops.md)
      
      - **金条商城双发冲突**("弹窗对物品不对"的终局根因):客户端 onPaySuccess 用本地 mall 表自加货,
        服务端又按自己表发一份并用 note 绝对值覆盖 → 两表版本不同即表现为买A到B。
        修复=服务端只扣钱,货物客户端自加(读回调确认加货方是关键一步)。
      - **回血卡旧上限**:客户端 useItem 上报的 maxhpLimit 是本地缓存(510),服务端当权威值用
        → 高属性号(真实上限10224)永远回不满还把存档上限打回去。修复=上限以存档为准。
      - **键名拼写**:注册 getalchemytdata(多t) vs 真名 getAlchemyData → 整个接口落保底桶。
      - **化妆镜/时装持久化**:BeginTitivate{itemObj} → makeup 桶;LoadGame 下发 titivateObj。
      
    • README.md 1.3 KB
      # 示例索引
      
      这些示例演示**同一个 skill 面对不同项目时如何得出不同方案**。
      它们**不是结论,是推演过程**——你的项目不一定和它们一样。
      
      | 示例 | 项目类型 | 关键差异 |
      |------|---------|---------|
      | [A. Unity + protobuf + TCP](A-unity-il2cpp-protobuf.md) | 手游,IL2CPP,protobuf,TCP 长连接 | 明文 protobuf,`dump.cs` 直接给消息号 |
      | [B. UE + KCP + 自定义二进制](B-ue-kcp-custom.md) | 端游,UE5,UDP/KCP,自定义序列化 | 需要 SDK dump,KCP 上再套加密 |
      | [C. Unity + Lua + HTTP](C-unity-lua-http.md) | 手游,业务在 Lua,HTTP+轮询 | JSON 明文,鉴权 token,无长连接 |
      | [D. 真实 Lua 源码分析](D-real-lua-client.md) | 真实横版动作手游(Unity+xLua) | **1794 个 opCode**、协议模式、无人机协议 |
      | [E. Cocos2d-x(JS) + AES HTTP-RPC](E-cocos2dx-http-js.md) | 手游,cocos JSB,AES-256-CBC 自加密 HTTP,多端口 | **进服前 7 节 + §8 进服后 Live 运营实录**(40+ 轮线上迭代) |
      
      > 读法:重点看「证据 → 决策 → Spec 差异 → 代码改动」,而不是记住最终参数。
      
      每个示例的结构:
      1. 用户给了什么
      2. 证据清单
      3. 逐层决策(走了决策树哪条分支)
      4. 产出的 Spec 关键片段
      5. 参考实现要改哪里
      6. 闭环验证路径
      7. 坑与回退
  • extensions
    • bot-reverse.md 8.9 KB
      # 反推游戏人机(Bot)机制,然后复刻
      
      > **核心原则**:不要套模板。
      > 先反推「**这个游戏的人机是怎么实现的**」,再**复刻**出来。
      > 复刻的验收标准:**原版客户端连上我们的服务端,人机表现与官方一致**。
      >
      > 注意: `server/app/logic/bots.py` 只是**通用骨架**(会走动/会 tick),
      > 它**不代表**任何具体游戏的人机实现。真实实现必须来自本文件的流程。
      
      ---
      
      ## 0. 为什么不能直接套模板
      
      人机不是"服务端随便造几个实体"就行。客户端对它有**硬性预期**:
      
      | 客户端期待 | 如果不满足 |
      |-----------|-----------|
      | 房间槽位里有"某个玩家" | 开局人数校验不过 |
      | 该玩家的身份字段(等级/职业/卡组/头像) | 客户端渲染崩/显示空白 |
      | 一个"这是 AI"的标记字段 | 客户端可能拒绝/报错(或反过来,没标记就当作真人处理错) |
      | AI 的动作走**同一套消息** | 客户端不认这个动作,不同步 |
      | 特定的开始/准备/结算流程 | 卡在某个界面 |
      
      > 所以:**先反推,再复刻**。凭空造 = 客户端表现不正常 = 部署了也玩不了。
      
      ---
      
      ## 1. 三步法
      
      ```
      A. 客户端里找"人机的痕迹"(关键字 / 配置表 / 字段)
              ↓
      B. 判定"人机归属模型"(服务端驱动 / 客户端驱动 / 混合)
              ↓
      C. 还原"人机所走的消息流 + 身份字段 + 标记字段"
              ↓
      D. 复刻到我们的服务端(本地 / 服务器两种部署都要能跑)
      ```
      
      ---
      
      ## 2. 第一步:找痕迹(关键字清单)
      
      ### 2.1 通用关键字(中英都搜)
      
      ```
      英文:AI  Robot  Bot  NPC  NonPlayer  Computer  Auto  AutoPlay
            MatchRobot  RobotLevel  AILevel  AIType  AIRole
            IsAI  isRobot  isBot  isNpc  FakePlayer  Dummy
            seat  slot  camp  team  side  pos
      中文:机器人  人机  托管  自动  电脑  电脑玩家  挂机  自动战斗
      ```
      
      ### 2.2 按客户端语言的搜索方式
      
      ```bash
      # Unity IL2CPP(dump.cs 是文本,直接 grep)
      grep -nE 'Robot|IsAI|isRobot|Npc|FakePlayer|AutoPlay|AIType|MatchRobot' dump.cs | head -40
      
      # Unity Mono(反编译后的 .cs 源码)
      grep -rnE 'Robot|IsAI|Npc|AutoPlay|AIType' ./decompiled/ | head -40
      
      # Lua 热更
      grep -rnE 'robot|ai|npc|托管|机器人' ./lua/ | head -40
      
      # AS3 / Flash(FFDec 导出的 .as)
      grep -rnE 'Robot|AI|Npc|Computer' ./as3_export/ | head -40
      
      # 资源/配置表(Windows/Android 通用)
      find . -iname '*robot*' -o -iname '*ai_*' -o -iname '*npc*' -o -iname '*match*'
      ```
      
      ### 2.3 重点看这几类位置
      
      | 位置 | 想找什么 |
      |------|---------|
      | **玩家/角色实体类** | 有没有 `IsAI` / `PlayerType` / `Camp` 字段 |
      | **房间/队伍结构** | 槽位数、开始条件、有没有"补位"逻辑 |
      | **匹配/开始流程** | 人数不足时会不会塞 AI |
      | **配置表** | `robot_*` / `ai_*` / `match_*` 的数值表 |
      | **动作/战斗消息** | AI 是否复用真人同样的 opcode |
      | **结算** | AI 是否参与结算、是否有特殊奖励 |
      
      ---
      
      ## 3. 第二步:判定归属模型
      
      ```
      客户端里有完整的 AI 决策代码(选牌/寻路/技能释放算法)?
         ├─ 是 ──▶ 客户端 AI:服务端只需标记"这个槽位是 AI",客户端自己驱动
         └─ 否 ──▶ 服务端 AI:服务端负责决策并下发动作
                    └─ 若两者都有 ──▶ 混合(常见:服务端决策 + 客户端动画)
      
      判定方法:
        1. 在客户端搜 AI 决策相关函数名(Decide/SelectCard/FindTarget/AutoPlay)
        2. 若这些函数**有完整实现** → 倾向客户端 AI
        3. 若只有"接收动作并播放" → 服务端 AI
        4. 抓包看:真人操作时上行包结构 vs 人机是否也走同样的上行
           (人机通常**没有上行**,动作直接由服务端下发)
      ```
      
      **结论写进 Spec**(见 §6)。
      
      ---
      
      ## 4. 第三步:还原协议
      
      ### 4.1 需要还原的四类信息
      
      | 类别 | 具体 | 怎么找 |
      |------|------|--------|
      | **身份字段** | 名字/等级/职业/卡组/头像/称号 | 玩家实体类字段;房间成员结构 |
      | **标记字段** | `is_ai` / `player_type` / `camp` | 玩家实体类里的 bool/enum |
      | **动作消息** | 出牌/移动/技能 的 opcode 与字段 | 与真人对比,看是否共用 |
      | **流程消息** | 加入/准备/开始/离开/结算 | 房间相关 opcode |
      
      ### 4.2 对比法(最有效)
      
      ```
      抓两份包:
        [真人对局]  A 真人 vs B 真人
        [人机对局]  A 真人 vs AI        ← 若游戏本身有人机模式
      
      逐字段 diff → 找出:
        * AI 的身份字段有什么不同
        * AI 的动作消息比真人少了什么(比如没有上行)
        * 是否有额外的 AI 专用消息
      ```
      
      > 有些游戏自带"人机对战/练习模式" —— 那是最好的证据源,直接抓它。
      
      ### 4.3 若游戏本身没有 AI 模式
      
      那就只能**从客户端代码推断**:
      - 找房间结构里的槽位数量与开始条件 → 推断"最少几人"
      - 找玩家实体的 `IsAI` 字段 → 说明协议支持 AI(即使官方没用)
      - 找配置表里有没有 robot 表 → 说明设计上支持
      
      如果客户端**完全不支持 AI**(没有字段、没有补位逻辑):
      → 那就只能在**客户端侧不下发 AI**,改为"降低开局人数门槛"或"允许单人+空位开始"。
      → 这属于**打补丁**而非复刻,需在 Spec 里明确标注。
      
      ---
      
      ## 5. 复刻到我们的服务端
      
      ### 5.1 复刻原则
      
      1. **协议一致**:人机的动作/身份/流程消息,与官方客户端预期完全一致。
      2. **复用真人路径**:人机的动作走与真人相同的校验与广播(见 `bots.md`)。
      3. **字段缺一不可**:客户端要的身份字段、标记字段都要填。
      4. **不引入新 opcode**:除非官方协议里本来就有 AI 专用消息。
      
      ### 5.2 本地部署 vs 服务器部署
      
      | 场景 | 注意 |
      |------|------|
      | **本地部署**(单进程) | 人机由本进程 tick 驱动即可;状态在内存 |
      | **服务器部署**(多进程/多区服) | 人机状态需跨区一致 → 用共享存储(Redis/DB)或独立 AI 服务 |
      | **多人联机** | 人机不能只在一端存在;要么由房主端驱动,要么由中心服务驱动 |
      | **重启/热更** | 人机状态要能恢复或安全丢弃(不要让客户端卡住) |
      
      ### 5.3 落地步骤
      
      ```
      1. 先做"傀儡":按官方字段填满一个假成员,让客户端不报错、能开局
      2. 再加"身份":名字/等级/卡组/头像,让客户端显示正常
      3. 再复刻"动作":用官方相同的 opcode 下发 AI 行为
      4. 最后加"拟人化":延迟/抖动/失误(见 bots.md §5)
      5. 本地验证 → 服务器验证 → 多开验证
      ```
      
      ---
      
      ## 6. 输出到 Spec
      
      ```yaml
      bots:
        # —— 反推结论(来自本文件流程)——
        ownership: server          # server | client | hybrid | unsupported
        supported_by_client: true  # 客户端协议是否支持 AI
        ai_flag_field: "is_ai"     # 标记字段(来自客户端实体类)
        ai_flag_values: {human: 0, ai: 1}
        identity_fields: ["name", "level", "job", "deck", "avatar", "title"]
        action_opcodes: [0x0303, 0x0304]     # AI 动作复用哪些消息
        flow_opcodes: [0x0201, 0x0203]       # 加入/准备/开始
        min_players: 2                        # 开局最少人数
        auto_fill: 0                          # 是否自动补位
        evidence: [E20, E21]                  # 证据编号
        confidence: medium
        # —— 复刻配置 ——
        mode: logic
        difficulty: normal
        humanize: true
      ```
      
      ---
      
      ## 7. 复刻验收清单
      
      - [ ] 客户端**能开局**(人数校验通过)
      - [ ] 人机在**房间列表/队伍界面显示正常**(名字/头像/等级)
      - [ ] 人机的**动作在客户端正确播放**
      - [ ] **结算**流程正常(谁赢谁输、奖励)
      - [ ] 人机**加入/离开**不导致客户端卡住
      - [ ] 本地部署与服务器部署**表现一致**
      - [ ] 长时间多局**稳定**(无泄漏、无状态错乱)
      
      ---
      
      ## 8. 常见情况与对策
      
      | 情况 | 对策 |
      |------|------|
      | 客户端有 AI 字段,但官方没用 | 仍可按官方字段复刻,风险低 |
      | 客户端完全无 AI 支持 | 改为「降低开局门槛」或 patch 客户端;Spec 标注 |
      | 客户端自带 AI 决策代码 | 服务端只需标记槽位,不驱动动作 |
      | 抓不到人机包(游戏无人机模式) | 纯静态推断 + 逐步试错,标注 confidence: low |
      | 人机走了真人没有的专用消息 | 必须实现该消息,否则客户端不认 |
      | 服务器多区服 | 人机状态跨区一致,或限制人机只在单区 |
      
      ---
      
      ## 9. 给 AI 的行为约定
      
      1. **先搜关键字段**(§2),拿到证据再动手,不要直接套 `bots.py`。
      2. 判定归属模型(§3)后,**写进 Spec** 再实现。
      3. 若客户端**不支持 AI**,明确告知用户并给出替代方案,不假装能做。
      4. 复刻以「**客户端表现正常**」为验收标准,不是"代码跑通"。
      5. 本地与服务器两种部署都要验证。
    • bots.md 8.5 KB
      # 服务端人机(假玩家 / Bot)系统
      
      > **为什么必须有**:很多游戏强依赖多人 —— 凑不够人就开不了局、进不了本、打不了团。
      > 自托管服务在线人数少,**没有人机 = 空房间 = 玩不了**。
      > 案例 A 明确「普通多人房至少需要 2 名在线真人才能开始」,并让 **AI 补位**;
      > 案例 B 的服务端结构里有 **NPC 队友** 模块。可见这是自托管服务端的**必备子系统**。
      
      ---
      
      ## 0. 先判断:你的游戏需不需要 Bot
      
      | 特征 | 需要 Bot? |
      |------|-----------|
      | 开局要求 N≥2 人 | [x] 必须 |
      | 副本/团本强制多人 | [x] 必须 |
      | 匹配/排位有最低人数 | [x] 必须 |
      | 公会/交易/聊天依赖活人 | 注意: 建议(经济与氛围) |
      | 单机也可通关 | [x] 可选 |
      
      > 判定方法:看客户端里「开始游戏」按钮的**前置条件**(人数、职业、队伍)。
      
      ---
      
      ## 注意: 必读:先反推,再复刻
      
      本文讲的是「人机系统**怎么做**」的通用方法论。
      但**每个游戏的人机实现都不一样**,客户端对它有自己的硬性预期。
      
      > **正确顺序**:
      > 1. 先读 `bot-reverse.md`,**从用户给的客户端反推出这个人机机制**
      > 2. 把结论写进 `protocol.spec.yaml` 的 `bots` 段
      > 3. 再按本文的方法实现
      >
      > [x] 错误做法:直接拿 `server/app/logic/bots.py` 当答案用。
      > 它只是**通用骨架**(会 tick、会走动),不代表任何具体游戏的人机逻辑。
      
      ---
      
      ## 1. 四种实现层级(从轻到重)
      
      | 层级 | 做法 | 真实度 | 成本 | 适用 |
      |------|------|--------|------|------|
      | **A. 傀儡** | 直接把数据塞进游戏状态,不跑逻辑 | 低 | 极低 | 只为了"能开局" |
      | **B. 逻辑 Bot(推荐)** | 在服务端**内部**实例化,复用同一套 handler/state | 中高 | 中 | 绝大多数场景 |
      | **C. 伪客户端** | 跑一个无画面的客户端真连上来 | 最高 | 高 | 客户端保护/反人机检测强的游戏 |
      | **D. 外部进程** | 独立进程 + 真实网络协议连接 | 高 | 高 | 需要跨机分布式加载 |
      
      **推荐起手:B(逻辑 Bot)** —— 不需要走网络编解码,直接调用服务端逻辑,
      既省事又能被客户端"看到"(因为服务端会把 Bot 状态同步给客户端)。
      
      ---
      
      ## 2. 架构(逻辑 Bot)
      
      ```
      BotManager(单例,挂在 GameServer 上)
       ├─ bots: dict[bot_id, Bot]
       ├─ tick():按固定频率(如 10~20Hz)驱动所有 Bot
       └─ 与房间/匹配系统联动
              │
              ▼
      Bot 实例
       ├─ 身份:bot_id / 名字 / 职业 / 卡组 / 头像
       ├─ 状态:位置、HP、技能 CD、行动队列(复用游戏状态结构)
       ├─ 感知 sense():读房间/队友/敌人状态
       ├─ 决策 decide():行为树 / 状态机 / 规则
       ├─ 执行 act():调用与真实玩家**相同的**逻辑函数
       └─ 拟人 humanize():延迟、抖动、失误
              │
              ▼
      复用:logic/handlers/* 、logic/state.py 、房间管理
      (不经过 net/codec,省掉编解码与网络开销)
      ```
      
      ### 2.1 关键原则
      1. **复用真实玩家的逻辑路径**:Bot 的每次行动都走和玩家相同的校验与广播,
         客户端才会把它当"真队友"。
      2. **独立 tick**:不要挂在网络事件里,用单独的循环,频率可调。
      3. **可开关/可调数量**:匹配时按需生成,满员后自动退出。
      4. **不占真实 Session**:除非用层级 C,否则别给 Bot 建 TCP 连接。
      
      ---
      
      ## 3. 感知-决策-执行循环
      
      ```
      每 tick:
        sense()   → 读当前局面(队友状态、房间阶段、倒计时、目标)
        decide()  → 选动作(出牌/移动/放技能/等待)
        act()     → 执行(复用玩家逻辑)
        humanize()-> 加入延迟/误差,写入下次行动时间
      ```
      
      **决策实现选型**:
      | 方式 | 适合 | 复杂度 |
      |------|------|--------|
      | 规则 / 优先级表 | 简单游戏、固定套路 | 低 |
      | 有限状态机(FSM) | 回合制、卡牌 | 中 |
      | 行为树(BT) | 动作/实时 | 中高 |
      | 效用/评分 | 卡牌、选目标 | 中 |
      | 脚本(数据驱动) | 想让运营改 | 低 |
      
      ---
      
      ## 4. 按游戏类型的行为清单
      
      | 类型 | Bot 要做的事 |
      |------|-------------|
      | 卡牌/回合制(案例 A) | 按职业配卡组、按回合出牌、跟火力/回复节奏、按阶段切换策略 |
      | 弹幕/动作(案例 B) | 移动走位、技能释放、躲避、接力/救援、跟随领队 |
      | MMORPG | 挂机打怪、跟随、拾取、回血、有限交易 |
      | MOBA/FPS | 寻路、瞄准、团战站位、撤退 |
      | 大富翁/休闲 | 轮流操作、按规则行动 |
      | 房间/大厅类 | 只需"占位 + 准备 + 跟随房主开始" |
      
      > **先用"能让流程走通"的最小行为**,再逐步加智能。
      
      ---
      
      ## 5. 拟人化(让玩家看不出是假人)
      
      这是体验的分水岭。至少做这几样:
      
      | 维度 | 做法 |
      |------|------|
      | **反应延迟** | 不要 0ms 秒回;按难度给 150~800ms 随机延迟 |
      | **操作抖动** | 坐标/选卡加入小随机,别精确到像素 |
      | **失误率** | 一定概率放错技能/选错目标(按难度调) |
      | **节奏感** | 有时快有时慢,不要恒定间隔 |
      | **身份感** | 有名字、头像、称号、等级;名字从词库随机 |
      | **上下线** | 不是永远在线;有进出房间的动作 |
      | **社交** | 会发预设短语/表情(可选,接 LLM 更自然) |
      | **学习曲线** | 前几回合保守,后续渐入佳境 |
      
      ---
      
      ## 6. 难度分级(让玩家有输有赢)
      
      ```yaml
      difficulty:
        easy:   {reaction_ms: [600, 900], mistake_rate: 0.25, strategy: "随机/保守"}
        normal: {reaction_ms: [300, 600], mistake_rate: 0.12, strategy: "规则"}
        hard:   {reaction_ms: [150, 300], mistake_rate: 0.05, strategy: "激进/最优"}
      ```
      
      - 难度应**可配置**(可配置),并且可按房间设置。
      - 避免"完美 Bot":会让玩家觉得不公平。
      
      ---
      
      ## 7. 匹配 / 补位策略
      
      ```
      玩家点"开始"
         ├─ 组队人数达标 ? ──▶ 正常开局
         └─ 人数不足 ? ──▶ BotManager.spawn(补位数量, 难度, 职业)
                                    │
                                    ▼
                              模拟"其他玩家加入"的延迟与动画
                                    │
                             开局 → Bot 参与 → 结束后回收
      ```
      
      **注意事项**:
      - 补位要有**加入感**(延迟几秒再显示"XX 加入了"),不能瞬间满员。
      - 缺什么职业补什么(案例 A:按房主选的职业卡组补 AI)。
      - 玩家离开后,Bot 可顶上,保证局内不中断。
      
      ---
      
      ## 8. 协议视角(Bot 要不要发包?)
      
      | 层级 | 是否走网络 | 客户端能看到吗 |
      |------|-----------|---------------|
      | A 傀儡 | 否 | 能(服务端直接同步) |
      | B 逻辑 Bot | 否 | 能(服务端广播 Bot 状态) |
      | C 伪客户端 | 是(真实协议) | 能,且最难识破 |
      | D 外部进程 | 是 | 能 |
      
      > 对**反人机检测**较强的游戏,考虑 C/D;否则 B 足够。
      > 无论哪种,Bot 的"加入/行动/离开"都应通过**与玩家相同的下行消息**通知客户端。
      
      ---
      
      ## 9. 验证清单
      
      - [ ] 单人 + Bot 能否正常开局?
      - [ ] Bot 行动是否在客户端正确显示(位置/血量/技能)?
      - [ ] Bot 离场后房间是否正常回收,无内存泄漏?
      - [ ] 难度切换是否生效?
      - [ ] 玩家退出,Bot 是否顶位?
      - [ ] 长时间运行(多局)是否稳定?
      
      ---
      
      ## 10. 常见坑
      
      - **Bot 不走校验** → 出现"作弊级"行为,客户端怀疑。→ 让 Bot 也走同一套校验。
      - **tick 太频繁** → CPU 爆。→ 按需 tick,空闲房间不跑。
      - **Bot 状态不同步** → 客户端看不到。→ 每次 act 后广播。
      - **永远满员** → 玩家感觉被监视。→ 加入/离开要有节奏。
      - **完美操作** → 不公平。→ 加延迟与失误。
      - **Bot 写死数量** → 难调。→ 配置化。
      - **忘了回收** → 内存涨。→ 房间结束统一清理。
      
      ---
      
      ## 11. 与其它模块的关系
      
      | 模块 | 关系 |
      |------|------|
      | `logic/state.py` | Bot 状态并入同一状态容器 |
      | `logic/handlers/*` | Bot 复用同一套逻辑函数 |
      | 房间/匹配 | Bot 由匹配触发,房间结束回收 |
      | Spec | 在 `protocol.spec.yaml` 里登记 `bots` 段 |
      
      ---
      
      ## 12. 快速落地建议(增量)
      
      ```
      阶段1  傀儡补位:让"开始游戏"能过人数校验
      阶段2  最小行为:Bot 会跟随/会出牌/会移动
      阶段3  拟人化:延迟 + 失误 + 身份
      阶段4  难度与配置化
      阶段5  (可选)接 LLM 做对话/更聪明决策
      ```
    • README.md 2.3 KB
      # 后续拓展(可选)
      
      > **这里的内容不影响核心目标。**
      > 核心目标是:**反推协议 → 复刻服务端 → 部署 → 让原版客户端连上并跑通**。
      > 本目录是"跑通之后"再考虑的增强功能。
      
      ---
      
      ## 为什么单独放
      
      | 对比 | 核心链路 | 本目录(拓展) |
      |------|---------|--------------|
      | 不做的后果 | **游戏跑不起来** | 游戏照常跑,只是少个功能 |
      | 优先级 | 必做 | 有空再做 |
      | 依赖 | 必须先完成 | 依赖核心链路已通 |
      
      > 判断标准很简单:**去掉它,游戏还能不能正常玩?**
      > 能 → 它就是拓展。
      
      ---
      
      ## 现有拓展
      
      | 拓展 | 文件 | 一句话 |
      |------|------|--------|
      | **服务端人机(假玩家)** | `bots.md` | 多人游戏凑不齐人时,用 AI 补位 |
      | **反推人机机制** | `bot-reverse.md` | 没有参考项时,从客户端反推它的人机实现 |
      
      ### 人机为什么算拓展
      
      - 单人也能玩的游戏 → 完全不需要
      - 多人游戏凑不齐人 → **体验受损,但服务端本身能跑**
      - 复刻人机需要额外反推(客户端里的 `IsAI`/`robot` 字段、动作消息…)
      - 实现质量不影响"能连上、能登录、能进游戏"
      
      参考实现 `server/app/logic/bots.py` 默认**不生成任何假玩家**
      (`bots.auto_fill: 0`),所以它不会干扰核心流程。
      
      ---
      
      ## 怎么用
      
      ```
      1. 先把核心链路跑通(客户端能连上、能玩)
      2. 确认目标游戏是否"必须多人"
      3. 若必须:
         a. 读 bot-reverse.md → 从客户端反推人机机制 → 写进 Spec
         b. 读 bots.md → 按方法论实现
         c. 用调试命令: bots spawn / bots clear / bots difficulty
      4. 验收:原版客户端里人机表现正常
      ```
      
      ---
      
      ## 其它可能的拓展(预留)
      
      | 拓展 | 说明 |
      |------|------|
      | 客户端保护 / 服务端权威校验 | 已有基础(`server_authoritative`),可加强 |
      | 多区服 / 负载均衡 | 网关与逻辑分离 |
      | 排行榜 / 活动系统 | 业务拓展 |
      | 资源 CDN 化 | 把资源从服务端剥离到 CDN |
      | 存档迁移 / 备份 | 运营向 |
      
      > 新增拓展时请:放到本目录 + 在 `README` 与 `SKILL.md` 的「后续拓展」清单里登记。
      > **不要**把拓展写进核心流程,避免让 AI 误以为它影响"能否运行"。
  • references
    • account.md 4.3 KB
      # 账号体系与接口还原(登录 / 注册 / 接口替换)
      
      > 目标:让**官方客户端**能用**我们自己注册的账号**登录我们的服务端。
      > 核心是两件事:**账号从哪来** + **怎么把客户端的接口指向我们**。
      
      ---
      
      ## 1. 账号来源:三种模式
      
      | 模式 | 做法 | 适用 |
      |------|------|------|
      | **A. 客户端内注册** | 实现客户端本来就有的注册接口(如 `REGISTER_REQ`) | 客户端有注册 UI |
      | **B. 独立注册网站** | 自己写网页,写进**同一份数据库**;客户端只负责登录 | 客户端无注册入口 / 需要后台发号 / 邀请制 |
      | **C. 预置账号** | 脚本批量建号 | 内测、单机 |
      
      > 三个模式可共存:网站注册 + 客户端登录 是常见组合。
      
      ---
      
      ## 2. 注意: 关键坑:密码预处理
      
      **很多客户端在发包前会先处理密码**(不是明文):
      
      ```
      明文 → MD5( password + salt ) → 再发
      明文 → SHA1 / 自定义混淆 → 再发
      ```
      
      - **必须复刻客户端的哈希**,否则:
        - 注册网站算出的密码 ≠ 客户端发来的密文 → 永远登不上。
      - 怎么找:
        1. 客户端登录函数里看 password 的处理(C#/Lua/AS3 都能读)
        2. 抓包:同一密码登录两次,看密文是否**固定**(固定=无随机盐;变化=带时间戳/nonce)
      - 结论写进 Spec:`account.password_hash` + `salt` + `client_side_hashing: true/false`
      
      ---
      
      ## 3. 接口替换:让客户端连我们
      
      | 客户端的连接方式 | 替换方法 |
      |-----------------|---------|
      | 域名(HTTP/HTTPS) | 改 hosts / DNS 指向我们的注册-登录站 |
      | 硬编码 IP/端口 | 改 hosts,或改客户端二进制/配置(见 `client-languages.md` §7) |
      | 配置文件里的 API URL | 直接改配置文件(最省事) |
      | 有服务器列表接口 | 我们返回自己的服务器地址 |
      
      > 原则:**优先改配置/ hosts,不动客户端二进制**。
      
      ---
      
      ## 4. 典型登录链路
      
      ```
      客户端 ──① 版本/公告──▶ 我们
             ──② 登录(账号+密文)──▶ 我们 → 校验 → 返回 token/session
             ──③ 服务器列表──▶ 我们 → 返回自己的游戏服地址
             ──④ 带 token 连游戏服(TCP)──▶ 校验 token → 进游戏
      ```
      
      > HTTP 登录 + 长连接游戏 是主流组合;也有全走长连接的。
      
      ---
      
      ## 5. 需要还原的账号接口清单(模板)
      
      状态用 `TRACKER.md` 维护([x]已实现 / [~]部分 / [ ]未实现 / [?]未验证 / [-]本没有)。
      
      | 接口 | 方法/opcode | 说明 |
      |------|------------|------|
      | 版本检查 | GET /version 或 VER_REQ | 决定客户端是否强制更新 |
      | 公告 | GET /notice | |
      | 注册 | POST /register 或 REGISTER_REQ | 模式 A 才需要 |
      | 登录 | POST /login 或 LOGIN_REQ | 返回 token |
      | 游客登录 | POST /guest | 有的游戏有 |
      | token 校验 | 游戏服内部 | |
      | 服务器列表 | GET /serverlist | 多区服时 |
      | 找回密码 | POST /reset | 可后置 |
      | 封号/踢线 | 内部 | |
      
      ---
      
      ## 6. 注册网站最小实现
      
      > **模板**:`templates/register-site/` —— `index.html`(注册页)+ `server.py`(最小 Flask 后端,已内置"共用同一 DB / 复用客户端密码哈希"两个要点)。
      > 详见 `templates/register-site/README.md`。
      
      - 技术:FastAPI / Express(几十行)
      - **与游戏服共用同一数据库**(同一张 `account` 表)→ 无需同步
      - **复用同一套密码哈希**(与客户端一致,见 §2)
      - 流程:网页注册 → 写 `account` 表 → 客户端直接登录
      - 安全:加验证码/限流;生产加 HTTPS
      
      ```
      浏览器 → /register → 校验 → 写 account 表(同一 DB)
      客户端 → /login   → 读 account 表 → 校验密文 → 发 token
      ```
      
      ---
      
      ## 7. 与游戏服的一致性
      
      | 项 | 必须一致 |
      |----|---------|
      | 数据库 | 注册站与游戏服指向同一份(同库或同表) |
      | 密码哈希 | 与客户端发包前的处理一致 |
      | 账号唯一性 | 网站注册时就要查重 |
      | token 密钥 | 登录签发与游戏服校验用同一 secret |
      
      ---
      
      ## 8. 快速落地顺序
      
      ```
      1. 确定账号来源模式(A / B / C)
      2. 反推客户端的登录接口与密码预处理 → 写 Spec
      3. 实现登录;先用预置账号验证能进游戏
      4. 再补注册(网站或 opcode)
      5. 加版本检查/服务器列表(若客户端要求)
      6. 更新 TRACKER.md
      ```
    • adaptation.md 5.5 KB
      # 自适应方法论:从用户项目到方案
      
      > 目标:**不同项目 → 不同方案**。本文件告诉 AI「怎么读项目、怎么形成证据、怎么决策」。
      
      ---
      
      ## A. 第 0 步:项目档案(Project Profile)
      
      AI 拿到用户给的任何东西后,**先扫描、后推论**。按下面顺序探测:
      
      ### A1. 目录/文件级探测
      
      ```bash
      # 1) 判断引擎
      find <project> -maxdepth 4 -iname "*.pak" -o -iname "*.utoc" -o -iname "*.uasset" \
           -o -iname "global-metadata.dat" -o -iname "Assembly-CSharp.dll" \
           -o -iname "libil2cpp.so" -o -iname "libUE4.so" -o -iname "*.luac" | head -50
      
      # 2) 判断语言/网络库(从文件名/字符串)
      grep -rIl -E "protobuf|flatbuffers|msgpack|cbor|kcp|enet|raknet|lidgren|websocket|socket\.io" <project> | head
      ```
      
      ### A2. 要产出的档案字段
      
      对照 `schema/project-profile.yaml` 填写,关键字段:
      
      | 字段 | 取值示例 | 来源 |
      |------|---------|------|
      | `engine` | unity-il2cpp / unity-mono / unity-lua / unreal / cocos / custom | 文件特征 |
      | `client_language` | csharp / cpp / lua / js | dump/SDK/资源 |
      | `platform` | android / ios / windows | 用户说明/包类型 |
      | `transport_hint` | tcp / udp / kcp / quic / ws / http | 抓包/字符串 |
      | `serialize_hint` | protobuf / json / msgpack / binary / flatbuffers | 字符串/结构 |
      | `crypto_hint` | none / xor / rc4 / aes / custom | 熵/字符串/代码 |
      | `provided_artifacts` | dump.cs / lua / usmap / pcap / sdk.h / 截图 … | 用户实际给的 |
      | `known_tools_output` | il2cppdumper / fmodel / ue4ss / wireshark … | 文件名/内容 |
      
      > 关键:把「用户**给了什么**」和「我们**还需要什么**」分开列。
      > 缺证据不要瞎猜,写成 `needs` 清单,或按占位符推进。
      
      ---
      
      ## B. 第 1 步:证据清单(Evidence Inventory)
      
      每条证据一行,含:`来源 / 类型 / 能推出什么 / 置信度(高/中/低)`。
      
      示例:
      ```
      [E01] dump.cs 里 class LoginReq { string user; string pwd; }   -> 登录字段与顺序   置信度:高
      [E02] dump.cs 里 switch(msgId) case 0x101 -> LoginHandler      -> LOGIN opcode=0x101 置信度:高
      [E03] 抓包 长度头 2字节小端,值=负载长                        -> frame.length_size=2 LE 置信度:高
      [E04] 抓包 payload 高熵、16字节对齐                           -> 可能 AES                 置信度:中
      [E05] 未抓到登录后的包                                         -> 后续状态机               置信度:低(需补)
      ```
      
      **规则**:
      - 置信度「低」的结论,进入 Spec 的 `unresolved`,由第 5 步闭环验证纠正。
      - 两个独立来源指向同结论 → 升为「高」。
      - 流量证据与代码证据冲突 → 以**闭环验证**为准,先记冲突。
      
      ---
      
      ## C. 第 2 步:决策引擎
      
      对**每个协议层**做一次决策,走 `references/decision-tree.md` 的对应分支。
      禁止「因为参考实现是 X,所以用 X」。
      
      决策记录格式:
      ```
      决策点: frame.length_size
      证据:   E03
      结论:   2 (小端, 不含自身)
      备选:   4字节大端(若不匹配再退回)
      影响:   codec.len_size / len_endian / len_includes_self
      ```
      
      ---
      
      ## D. 第 3 步:产出协议规格
      
      照 `references/protocol-spec.md` 与 `schema/protocol.spec.yaml` 填写。
      **Spec 是唯一事实来源**;后续所有代码、文档、测试都从它派生。
      
      ---
      
      ## E. 第 4 步:由规格派生服务端
      
      见 `references/codegen.md`。核心原则:
      
      1. **选最贴近目标协议的技术栈**(不一定要 Python):
         - 目标客户端是 C++/UE,且协议复杂 → 可用 C++/Rust/Go
         - 快速验证 → Python/Node
         - 高并发长连接 → Go/Rust
      2. **参考实现只提供结构套路**:网关/会话/分发/编解码/逻辑/存储的分层照搬,
         但**所有协议参数、消息号、字段布局**按 Spec 重写。
      3. **不要一次写全**:先跑通「连接+握手+登录」,再逐 opcode 扩展。
      
      ---
      
      ## F. 第 5 步:闭环验证与回填
      
      每跑通一个状态,把 Spec 里对应的 `confidence: low` 升级为 `high`,
      并修正被证伪的假设。若客户端在某步失败:
      
      ```
      定位: 是哪个 opcode / 哪个字段导致的
      回看: 对应 Spec 条目 + 它的证据
      动作: 补证据(再抓包 / 再看代码)→ 修正 Spec → 再验证
      ```
      
      ---
      
      ## G. 常见「项目类型 → 方案」速查
      
      > 完整推演见 `examples/`。这里是索引,不是结论。
      
      | 项目特征 | 大概率方案方向 | 关键决策点 |
      |---------|--------------|-----------|
      | `libil2cpp.so`+`global-metadata.dat` | Unity IL2CPP → dump.cs → 找网络类 | 是否加密 metadata |
      | `Assembly-CSharp.dll` | Unity Mono → dnSpy 直读 | 是否混淆 |
      | 大量 `.luac` + xlua | 业务逻辑在 Lua | Lua 是否加密/魔改 opcode |
      | `.pak`/`.utoc` + `libUE4.so` | UE → FModel/Dumper-7 → SDK | AES key / unversioned |
      | 抓包全是二进制、高熵 | 自定义+加密 | 加密算法与密钥来源 |
      | 抓包是明文 JSON + HTTP | 简单,直连 | 鉴权 token |
      | UDP + conv 头 | KCP | 是否 KCP 上再加密 |
      | 有 `.proto` 文件 | protobuf | .proto 可直接反序列化 |
      
      ---
      
      ## H. AI 行为约束(重要)
      
      1. **禁止**在没有证据时直接套用 `server/` 的默认配置。
      2. **必须**先产出 `project-profile.yaml` 与 `protocol.spec.yaml`,再动代码。
      3. 用户只给了一部分 → 用**占位符**把 Spec 补全(`TODO_EVIDENCE`),并列出「还需什么证据」。
      4. 用户没说游戏类型 → 按 A1 主动扫描,扫不出来就**列出探测命令**让用户跑。
      5. 每个结论标注**置信度 + 证据编号**,可追溯。
    • ai-contract.md 3 KB
      # AI 行为契约(本 skill 的强制产物与硬性规则)
      
      > 本文件是 `SKILL.md §13` 的**完整版**。每次交付前过一遍。
      
      ---
      
      ## 13. 使用本 Skill 的 AI 行为契约(必读)
      
      ### 13.1 强制产物(缺一不可)
      
      AI 处理任何项目,**必须按顺序产出**:
      1. `out/project-profile.yaml`(读了什么 / 还缺什么)→ 2. `out/evidence-inventory.md`(证据编号 + 置信度)→ 3. `out/protocol.spec.yaml`(**唯一事实来源**)→ 4. 由 Spec 派生的服务端。
      
      **另需落文件**(模板在 `templates/`):
      `TRACKER.md`(进度/接口清单)· `verify/log.md`(验证 + 回滚)· `docs/decisions/*.md`(ADR:决定了什么 / **当前不做的事** / 何时推进)· `docs/evidence/*.md`(端到端证据:帧序表 / 差异解释 / 重连快照)· `AGENTS.md`(工作区协作契约)· `docs/status/support-matrix.md`(三轴状态:实现 / 测试 / 客户端验收)· `login-chain.md`(登录链条)· `function-checklist.md`(功能清单)。
      
      > ADR / 证据两项来自实机跑通范式:**「为什么先不做」和「怎么证明跑通了」必须落文件**。详见 `engineering-practices.md`。
      >
      > 结论段一律挂**证据档位**:`Confirmed by static analysis` /
      > `Confirmed by packet capture or runtime observation` / `Inferred and still requiring validation`。
      > 不确定的写 `Inferred`,不要写「已实现」。
      
      > `TRACKER.md` 用简单状态标记:[x]已实现 / [~]部分 / [ ]未实现 / [?]未验证 / [-]游戏本身没有。
      > **保持简短**,不要写长文,省 token。
      
      > 没有 1~3 就写代码 = 违规。**先规格,后代码。**
      >
      > 另外:`project-profile.yaml` 的 `method` 段必须**在动手前填**(选了什么方法、为什么)。
      
      ### 13.2 硬性禁止
      
      1. [x] 把参考实现(`server/`、`templates/`)的**默认协议常量**当作目标游戏的常量。
      2. [x] 在没有任何证据时,直接给出"服务端应该长这样"。
      3. [x] 忽略 `unresolved` 列表强行推进(要么标注假设,要么索要证据)。
      4. [x] 只给口头结论、不落文件。
      5. [x] 声称"已生成/已验证"但拿不出产物或路径。
      
      ### 13.3 缺信息时的正确做法
      
      - **不追问、不空转**:用占位符推进(`TARGET_HOST`、`PORT`、`OPCODE`、`CHECK_FN`、`TODO_EVIDENCE`)。
      - **主动给探测命令**:用户没说清项目类型时,先给出 `find`/`grep` 探测命令让用户跑。
      - **把"还需要什么"写进 Spec 的 `unresolved`**,而不是凭空补全。
      
      ### 13.4 决策必须可追溯
      
      每个结论都要能回答三问:
      1. 这条结论的**证据编号**是什么?
      2. 走的决策树**哪条分支**?
      3. 置信度是**高/中/低**?低置信的验证计划是什么?
      
      ### 13.5 终点定义
      
      **原版客户端连上自建服务端并跑通到某个状态 = 成功。**
      跑通后,把 Spec 里对应项置信度回填为 `high`,并更新证据清单。
      
      ### 13.6 回滚
      
      所有对客户端的改动,先在副本上做,保留原始哈希与还原命令。
      
      ---
      
    • case-il2cpp-ecdh.md 6.4 KB
      # 案例:Unity IL2CPP + ECDH 登录服(项目A / 某 Unity IL2CPP 手游 官服)
      
      > **这是什么**:一个**真实进行中**的项目档案,用来演示「分层结论长什么样」与
      > 「卡点怎么复核」。**不是**答案——各游戏协议不同,请照流程自己推。
      >
      > 状态:服务端能跑、协议分层已实测、客户端对接**未闭环**。
      > 相关:`closure-verification.md`(本案例的教训沉淀)、、`repack-rename.md`。
      
      ---
      
      ## 1. 项目档案(摘要)
      
      | 项 | 值 |
      |---|---|
      | 客户端 | Unity **IL2CPP** + **xLua** 热更(C# + Lua) |
      | 版本 | 官服 `2.0.109` |
      | 已知输入 | `dump.cs`(49 万行签名)、`lua_src/`(2489 文件,全业务逻辑)、真机 `tcpdump` |
      | 目标 | 本地自建服 + 客户端对接,跑通登录 |
      | 方法 | `M1`(Lua 白盒)+ `M2`(抓包)+ `M3`(Hook/反汇编)+ `M9`(自环) |
      
      > 有 Lua 源码 = opCode 与请求字段名可 100% 还原;
      > 但**帧格式、密钥协商在 native**(`libil2cpp.so`)→ 必须 M2+M3 补。
      
      ---
      
      ## 2. 分层结论(每条都带证据类型)
      
      ```
      [1] 传输   TCP(主通道/登录/资源/Node)+ KCP(UDP)(多人战)
      [2] 封装   u32BE 长度前缀切帧;心跳 opCode = "NONE"(空包)
      [3] 加密   AES-128-ECB + HMAC-SHA1(NetCrypter);登录走 OpenSSL 变体
      [4] 压缩   资源服 payload 为 gzip
      [5] 序列化 protobuf,信封 GameMessage.Message(opCode 为 string)
      ```
      
      **帧格式(真机 tcpdump,u32BE 切帧成功率 100%)**:
      
      ```
      ┌ u32 BE  totalLen            整帧长度(含自身)
      ├ u32 BE  0x0000000C         常量 12(头部长度)
      ├ u32 BE  0x01000000         常量
      ├ [u8 len + len bytes] * N   u8 长度前缀的字段串
      │     客户端:字段1 = 0x5C(92B) 客户端公钥;字段2 = 0x0D(13B) 会话字段
      │     服务端:字段1 = 0x00(空);字段2 = 0x0D(13B)
      │     字段2 = 9 字节 ASCII 数字 + 4 字节每帧变化(nonce?)
      └ 剩余 = 高熵载荷(加密)
      ```
      
      > 注意: 字段长度是 **u8**,不是 u16 —— 套参考实现的默认值必错。
      
      ---
      
      ## 3. 登录服(:8001):**服务器先发包**的 ECDH 握手
      
      很多项目默认“客户端先发”,这个项目是反的,**实测时序**:
      
      ```
      1. S2C  [u16BE=12] 12B base64 token        ←  服务器先发(8B 随机 → base64)
      2. C2S  [u16BE=92] 客户端 EC 公钥(X.509 SPKI, P-256)
      3. S2C  [u16BE=91] 服务器 EC 公钥
      4. S2C  [u16BE=56] 挑战  [16B 随机][16B 随机][16B 派生值][8B]
      5. C2S  [u16BE=56] 确认  [16B 随机][16B 随机][16B 派生值][8B]  ← 派生值两端相同
      6. C2S  [u16BE=456] 业务请求(加密)
      7. S2C  [u16BE=90] 业务应答(含 base64 节点信息)
      ```
      
      **SPKI 结构**(可直接构造,本项目用纯 Python 实现 P-256):
      
      ```
      3059 3013 0607 2a8648ce3d0201 0608 2a8648ce3d030107 0342 0004 <64B 点>
      ```
      
      **56B 里的 16B 派生值 = 会话级**(两端相同),但**算法未解**:
      已穷举 34 种 KDF 候选全部失败 → 下一步应读
      `NetworkingClientNormal.ProcessHandShakeMessage`(dump 偏移 `0x40AFB58`)。
      
      > 教训:盲猜哈希 = 伪工作,见 `closure-verification.md` 假阳性 ⑥。
      
      ### 3.1 自建登录服的最小骨架(可复用)
      
      ```python
      # 纯 Python P-256(无第三方依赖):点加/倍乘/求逆 + SPKI 拼装
      class ECDH:
          def pub_spki(self) -> bytes: ...        # 生成 91B X.509 SPKI
          def shared_secret(self, peer_spki) -> bytes:
              # 从对端 SPKI 找 b"\x03\x42\x00\x04" 或 b"\x04",取 65B 点,算出 X 坐标
      ```
      
      **必做的健壮性**(都是本项目踩过的):
      
      | 项 | 做法 | 不做的后果 |
      |---|---|---|
      | 会话态清理 | `finally: self.sessions.pop(peer, None)` | 客户端 3s 重连复用旧 secret |
      | 异常可见 | `log.exception(...)` 带 step | `NameError` 被吞 → 误判成“算法不对” |
      | 日志措辞 | 命中只写「候选命中(未认证)」 | 把“自己算的值相等”当成认证通过 |
      
      ---
      
      ## 4. 已证伪的结论(别重复踩)
      
      | 曾以为 | 实际 | 证伪方式 |
      |---|---|---|
      | `:8081` 的 TUP/WUP 是本游戏的业务通道 | **不是** | APK 内 grep `bea_key/TYPE_COMPRESS/rsapost` **0 命中**;按 uid 看 `/proc/net/tcp` 无 8081 |
      | 删掉保护 so 就能改包运行 | 不能 | 删 so / 留 so 两个对照**都秒退** → 不是缺库,是自校验 |
      | 改包秒退是**唯一**卡点 | 不成立 | 复核服务端:登录响应**根本没实现**(见 §5) |
      
      > **“证伪”也要落盘**:写在 Spec 的 `wup_business.status: NOT_THIS_GAME` 之类的位置,
      > 避免下一个 AI 花同样的时间。
      
      ---
      
      ## 5. 接手时的真实缺陷清单(复核产出)
      
      ```
      · login_server.py step==2 分支引用未定义变量 _h → 外层 except 吞成 warning
      · 比较对象是“自己临时算的公式”,从未发给客户端 → 永远不等
      · 第 3 包起只调用 _try_decrypt() 做“尝试解密 + 打日志”,全文件无业务响应构造
      · sessions 在断开时未清理
      ```
      
      **结论**:卡点是**两个**(客户端侧自校验 + 服务端侧登录响应未实现),
      不是交接文档写的“唯一”。
      
      ---
      
      ## 6. 客户端对接的落点选择(本项目)
      
      | 落点 | 做法 | 备注 |
      |---|---|---|
      | **不改包**(推荐先做) | 按 uid DNAT 到本地;或 hosts 重定向域名 | 不触发自校验;要 root |
      | 改包内地址 | 地址在 Lua/明文配置里时直接改字节 | 包名变了要连带改 |
      
      **登录服地址不在 APK 里**(Lua 运行时下载)→ 必须靠 **hosts / iptables 重定向**。
      
      ```sh
      iptables -t nat -A OUTPUT -m owner --uid-owner <uid> -p tcp --dport <原端口> \
               -j DNAT --to-destination 127.0.0.1:<本地端口>
      ```
      
      > 注意: 别把资源服/CDN 端口一起劫持(`repack-rename.md` §7):首启要下载 Lua/资源,
      > 劫持了会一直黑屏。
      
      ---
      
      ## 7. 可复用的产出物清单(本项目实际生成)
      
      ```
      out/project-profile.yaml    读了什么/还缺什么
      out/evidence-inventory.md   证据编号 + 置信度
      out/protocol.spec.yaml      分层结论(帧/加密/序列化/状态机)
      out/interfaces/opcodes_unique.txt   1788 个 opCode
      out/interfaces/opcode_fields.md     逐接口请求字段名
      verify/capture-analysis.md  真机抓包完整报告
      verify/closure...           闭环与假阳性排查
      ```
      
      > **1788 个 opCode 是怎么来的**:Lua 源码全量提取(`tools/extract_interfaces.py`)。
      > 拿到清单后按前缀聚类 = 子系统划分,再按主流程排序实现。
    • case-il2cpp-inline.md 4.3 KB
      # 真实案例:Unity IL2CPP + 平台 SDK 的「内联服务端」
      
      > 目标:平台系 Unity IL2CPP 手游(`com.tencent.KiHan`,arm64)。
      > 做法:**不改协议、不起外部服务端**,在客户端进程内拦截网络门面合成响应,
      > 让客户端在本地模式下走完登录 → 进场景 → 局内。
      >
      > 规则同 `case-il2cpp-ecdh.md`:**没在真实客户端上确认过的,一律不写 [x]**。
      
      ---
      
      ## 1. 事实清单(可验证的输入)
      
      | 项 | 值 |
      |----|----|
      | 包名 / 引擎 | `com.tencent.KiHan` / Unity **IL2CPP** |
      | 分析产物 | `dump.cs`、`libil2cpp.so`(含 IDA 数据库) |
      | 注入方式 | 原生注入 + **Dobby** 内联 hook(非 Frida) |
      | 业务命名空间 | `KH`、`KH.Network`、`KH.Remote`、`KHSceneConnectHelper` 等 |
      | 协议形态 | 强类型协议类(对象级读写)+ **Lua 通道**(字节 / 表) |
      | 账号体系 | 第三方平台账号 SDK→ 目录服 → 区服 |
      
      ---
      
      ## 2. 内联截获的落点
      
      拦截 `NetworkManager` 的四个方法(**两个命名空间各装一遍**):
      
      ```
      KH.NetworkManager.SendMessage / SendUnicast / AddMessageCallback / RemoveMessageCallback
      KH.Network.NetworkManager.*        ← 实测活的是这一份,两份都装
      ```
      
      | 机制 | 实现 |
      |------|------|
      | 请求 → 合成响应 | 一张 `cmd → 构造函数` 总表,每条一个函数 |
      | 响应投递 | `std::deque<PendingUnicast>`(存 gchandle)+ 主线程 tick 泵 |
      | 重入 | `thread_local int g_networkSendDepth`;`> 0` 时放行原函数 |
      | 广播 | `cmd → 回调 gchandle 列表` 的表,注册 / 注销都要管 |
      | 延迟派发 | 帧计数器(`2` 帧;未就绪改 `30` 帧重试) |
      
      ---
      
      ## 3. 登录 / 进入链路(观测到的顺序)
      
      ```
      平台 SDK 登录态 → 目录服进入 → 区服登录 → 取登录后信息 → 建角 / 选角 → 进场景
      ```
      
      | 阶段 | 观察到的东西 |
      |------|-------------|
      | 目录服进入 | `DirCSEnterZoneReq`(cmd `134217731`) |
      | 区服登录后取信息 | 相关 cmd `51384528` |
      | 客户端本地路径 | `KHZoneConnection.ZoneLogin`、`DoReqLoginServer`、`GetInfoAfterLoginReq` |
      
      实测做法:**复用客户端的原生登录路径**(构造登录响应后调用客户端自己的
      `OnLoginDataSucc`),而不是绕开它自己怼一个界面 —— 后者会漏掉后续状态机。
      
      ---
      
      ## 4. 账号桥接(取官方数据)
      
      见 `platform-sdk-and-admission.md` §2。实测形态:
      
      - 动态建 `OfficialBridge` GameObject,挂客户端自己的 `ConnectorComponent`
      - 逐字段复制官方实例的 `DH` / `Uin` / `Password` / `Url` / `VersionServerUrl` /
        `EncryptMethod` / `KeyMaking`,**只改 `ZoneUrl`**
      - 调用官方 `Connect`(非默认 connId),用 `ApolloConnection.IsConnected` 判就绪
      - 有超时(实测约 900 帧)与三态日志(进行中 / 已连接 / 超时)
      
      ---
      
      ## 5. 已证伪 / 已踩的坑
      
      | 结论 | 证据 |
      |------|------|
      | 「客户端资源检查放行 = 能进服」**错误** | 资源检查改为恒真后,仍卡在目录服准入 |
      | 「提示维护中」**不是**客户端资源逻辑产生 | 该阶段只有目录服请求,无 ZoneLogin |
      | 门面有**两个命名空间副本**,只装一处会漏请求 | 安装清单里两份都在 |
      | 回调**不能**在拦截函数里同步调用 | 改为入队 + 主线程泵后才稳定 |
      | 进场广播**不能**随响应立即发 | 需延迟数帧,且先确认监听方已注册 |
      
      ---
      
      ## 6. 当前状态(按三轴记)
      
      口径见 `verification-and-status.md`。
      
      | 项 | 实现 | 自动测试 | 客户端验收 |
      |----|------|---------|-----------|
      | 进程内拦截 + 合成响应框架 | [x] | [ ] | [~] 局内路径已到位 |
      | 本地登录 / 进入链路 | [x] | [ ] | [~] |
      | 账号桥接连接 | [x] | [ ] | [?] 连接探测通过,业务取数未验 |
      | 官方区服准入 | [-] | [-] | [x] 已证伪(见 §5) |
      | wire 级协议规格 | [ ] | [ ] | [-] 本路线不急 |
      
      > 这份状态表的用途:**别把 [~] 当 [x] 往外交付**。
      
      ---
      
      ## 7. 这个案例教会 skill 什么
      
      1. 反推服务端有**第二条路**:不起服务端,在进程内合成响应(`inline-server.md`)
      2. 对象级比 wire 级**先跑通**更划算(`runtime-object-synthesis.md`)
      3. 大厂账号体系要按**三段**分别定位卡点(`platform-sdk-and-admission.md`)
      4. 验收必须落在**网络 / 状态层完成点**,界面截图不算
      
    • cases.md 5.1 KB
      # 真实案例研究(Case Studies)
      
      > 两个**公开的、成功的**第三方服务端实现。用于理解「通用 skill 落到真实项目」的形态。
      > 本文件只做**事实性归纳**与**方法论提炼**,不复制其代码。请遵守各自许可证:
      > `项目B` = PolyForm Noncommercial 1.0.0;`项目C` = GPL-3.0。
      
      ---
      
      ## 案例 A:项目B(某 Unity 手游 CN)
      
      - 仓库:(社区开源实现,按目标游戏检索)
      - 客户端:Unity(Android APK,某大厂,`com.<vendor>.<game>`)
      - 服务端语言:**Go**(含 Windows C# WinForms 启动器)
      - 资源:`resource-set/` 本地目录,或 Cloudflare R2 / S3 兼容 CDN
      
      ### 关键工程事实
      
      | 维度 | 做法 |
      |------|------|
      | 服务端语言 | **Go**(不是 Python) |
      | 启动器 | **C# WinForms** GUI,选"模拟器/局域网",填地址后一键启停 |
      | 端口约定 | 主 `TCP 26020`;战斗 `26021`(=主+1);后台 `26022`(=主+2,仅本机) |
      | 客户端对接 | **改配置文件**:`Android/data/<package>/files/local_server.txt` 写 `IP:PORT` |
      | 存档 | `_local/data/<game>-save-state.sqlite3` |
      | 后台 | HTTP `127.0.0.1:26022`,运营台可配卡池/活动/掉落/商店/发礼/账号绑定 |
      | 资源分发 | 本地 `resource-set/` 或自建 CDN(`cdn-sync.json` → `cdn.json`) |
      | 发布形态 | 预编译三端(Win x64 / Linux x64 / ARM64),**用户无需装 Go/Python/Unity** |
      
      ### 对 skill 的启发
      
      1. **服务端语言由协议与发布需求决定**,Go 完全合理(→ `codegen.md` A 节)。
      2. **"配置文件法"是最省事的客户端对接**:不改客户端二进制,只写一个 txt。
      3. **端口三件套约定**(主/战斗/后台)值得写成规范。
      4. **发布要预编译**:给最终用户的是一键包,不是源码。
      5. **付费/水晶**:README 明确写「购买默认返回成功但不增加水晶,管理员可在运营设置开启」
         —— 与 §15 的 `pay_auto_success` 模式完全一致。
      6. **CDN 分离**:登录/战斗连自建服务端,资源可走独立 CDN。
      
      ---
      
      ## 案例 B:项目C(某 Cocos 手游 CN)
      
      - 仓库:(社区开源实现,按目标游戏检索)
      - 客户端:**ActionScript3 / Flash**(`pinball/config/.../DevConfig.as`)
      - 服务端语言:**TypeScript / Node.js**(>= 20.12)
      - 存储:SQLite(`.database/`)
      - 资源:官方 CDN 放 `.cdn/cn/`,含 Content Sync 与增量补丁 Overlay
      
      ### 关键工程事实
      
      | 维度 | 做法 |
      |------|------|
      | 服务端语言 | **Node/TypeScript** |
      | 客户端语言 | **AS3/Flash**(非常见 Unity/UE!) |
      | 客户端补丁 | 改 `.as` 源码 **重新编译**:`client-patch/apply.sh <AS3_EXPORT_DIR> <HOST>:8001` |
      | 跳过登录 | 在 `DevConfig.as` 启用 **SDK Dummy** |
      | 改地址 | `DevConfig_gf_android.as` 改 API 地址 |
      | 端口 | HTTP `8001`;游戏 TCP `8003`;多人 Hub `8004` |
      | 抓包 | 仓库带 `.mitmproxy/` 配置,说明**基于抓包驱动开发** |
      | 后台 | React SPA,挂 `/admin/` |
      | 多语言支持 | CN / 全球服(`starpoint`)双实现 |
      
      ### 对 skill 的启发
      
      1. **客户端语言判定是第一步**:AS3 用 FFDec,而不是 dnSpy(→ `client-languages.md`)。
      2. **"源码重编译法"**:能拿到 AS3 源码时,改源码重编比二进制 patch 干净。
      3. **抓包驱动**:`.mitmproxy/` 说明整个开发是 **capture-first**(与 §3 一致)。
      4. **路由即消息号**:HTTP 游戏里 `/api/login`、`/api/pull` 就是消息号,不需要 opcode 表。
      5. **内容/资源同步**是独立子系统(Content Sync + patch overlay),值得单列。
      
      ---
      
      ## 横向对比:同一个 skill,两种落地
      
      | 维度 | 案例 A | 案例 B |
      |------|--------|--------|
      | 客户端语言 | C# / Unity | AS3 / Flash |
      | 传输 | TCP 长连接(+战斗端口) | HTTP + TCP |
      | 服务端语言 | Go | Node/TS |
      | 客户端对接 | 配置文件法 | 源码重编译法 |
      | 存档 | SQLite | SQLite |
      | 后台 | 内置 HTTP 运营台 | React `/admin/` |
      | 资源 | 本地/CDN | CDN + 增量补丁 |
      
      **共同点(= 通用规律)**:
      - 服务端都有:账号/存档(SQLite)、后台、资源分发、端口约定
      - 客户端对接非侵入(配置或最小 patch)
      - 都以**抓包 + 反编译**为证据来源
      
      **差异点(= 必须由 Spec 决定的)**:
      - 语言、传输、帧格式、序列化、客户端补丁方式
      
      ---
      
      ## 补充参考项目(相关)
      
      | 项目 | 说明 |
      |------|------|
      | `Duosion/starpoint` | 全球服服务端基础 |
      | `wdfp-extractor` | 资源提取 |
      | `wfax` | 资源转换与修改 |
      | `starview` | APK 补丁工具 |
      
      > 这些都印证了通用 skill 需要的**工具链广度**:提取、转换、补丁、服务端、后台缺一不可。
      
      ---
      
      ## 怎么用这些案例(给 AI 的指令)
      
      1. **不要照搬**案例的技术选型 —— 它们只是证明"选型可以很不同"。
      2. 用它们对照检查自己的 Spec 是否遗漏了这些**通用子系统**:
         账号、存档、后台、资源分发、端口约定、客户端补丁、发布打包。
      3. 遇到同类游戏时,可以引用它们的**方法论**(capture-first、最小 patch、预编译发布)。
      4. 使用任何参考代码前**先看许可证**。
    • client-address-sources.md 17.5 KB
      # 客户端地址来源清查:为什么「只改一个 URL」永远不够
      
      > **这份文档解决的事**:客户端到底从哪里拿到服务器地址。
      > 改错一处 → 表现为「能进区服列表但登录还是走官方」「重启/热更后又回去了」。
      >
      > 来源:三个已跑通的成品服务端的实战记录抽象。
      
      ---
      
      ## 目录
      
      > 0 铁律 · 1 六类地址来源 · 2 改包后仍连官方的六条验收 · **3 落点策略(含网络层三板斧 )** · 4 改包三层可行性 · 5 动手前必查三件事 · 6 重打包现实后果 · 7 阶段化推进 · 8 未解决问题要落盘
      
      ---
      
      ## 0. 铁律
      
      > **地址不是"一个常量",而是一条链。** 任何一处遗漏都可能让流量回到官方服务器。
      > **先枚举全部来源,再决定改哪一层。**
      
      >  **在整体流程里的位置**:本文对应 `references/workflow-roadmap.md` 的**阶段 2 · 重定向**。
      > 那张"四层重定向表"(DNS/寻址 → 传输/TLS → SDK/平台 → 业务协议)是本文的**鸟瞰版**,
      > 本文是**落地版**。先看哪个取决于你卡在"选层"还是"改哪"。 
      
      ---
      
      ## 1. 六类地址来源(逐一排查,别跳)
      
      | # | 来源 | 典型位置 | 不改的后果 |
      |---|---|---|---|
      | 1 | **硬编码基地址列表** | SDK 的 `DomainManager` 类,常是**多个候选域名** | 只改第一个 → 失败转移用到第二个 |
      | 2 | **本地缓存键** | KVStore / SharedPreferences 里存的域名数组 | **旧缓存会在启动时被重新加入候选** |
      | 3 | **失败转移 / 轮换逻辑** | 网络客户端的重试代码 | 请求失败时自动回退到官方域名 |
      | 4 | **服务端下发的地址** | 区服列表接口返回的 `addr` / `port` | 改完客户端,又被服务端响应覆盖成官方地址 |
      | 5 | **运行时配置字段** | `AppConfig.runtimeXxxUrl` 之类,被 ④ 覆盖 | 你改的静态值在运行时被冲掉 |
      | 6 | **native / 热更 DLL** | `libil2cpp.so`、`global-metadata.dat`、热更程序集 | 静态搜索找不到,改完无效果 |
      
      **附:URL 规范化差异**(尾部斜杠、`https` vs `http`、大小写)也会让"改了但没生效"。
      
      ### 排查命令
      
      ```bash
      # 明文域名/IP/端口
      grep -a -E '([0-9]{1,3}\.){3}[0-9]{1,3}|https?://' <binary_or_so>
      # smali / 反编译源码里搜域名管理类
      grep -rn "DomainManager\|baseUrl\|ServerListUrl\|apiHost" <decompiled_dir>
      # 缓存键
      grep -rn "sdk_domains\|domain_cache\|serverlist" <decompiled_dir>
      # 资源/热更目录(改包后可能被覆盖)
      ls assets/bin/Data/Managed/  assets/**/DLL/*.bytes 2>/dev/null
      ```
      
      ### 清点表(填进项目档案)
      
      ```
      [ ] 1 硬编码基地址列表:<文件:行> = <域名们>
      [ ] 2 缓存键:<名称> 清空方式:<...>
      [ ] 3 失败转移:<文件:行> 是否可禁用:
      [ ] 4 区服列表响应字段:<接口> → addr/port
      [ ] 5 运行时字段:<名称> 被谁写:
      [ ] 6 native/热更:<是否含地址> 判定依据:
      ```
      
      ---
      
      ## 2. 「改包之后还是连官方」的六条验收
      
      改包前把这张表填满;**有一条不满足就还会回到官方**:
      
      ```
      [ ] SDK 登录基址        -> 本地 HTTP 端口
      [ ] 区服列表接口        -> 返回本机 TCP 地址 + 端口
      [ ] 游戏 TCP            -> 本地端口
      [ ] 启动数据             -> 与客户端实际消息顺序一致
      [ ] 支付/订单接口        -> 能识别游戏订单
      [ ] 落库                -> 结算与货币变更可持久化
      ```
      
      只改区服列表 URL 的典型结果(都是实测过的现象):
      
      - 能看到本地区服,但 **SDK 登录仍访问原域名**;
      - SDK 登录成功,但**失败转移又回原始候选域名**;
      - TCP 已连上,但**启动数据缺失导致客户端断开**;
      - 支付返回成功,但**角色没收到可识别的货币更新**;
      - **重启或热更后恢复原地址**。
      
      ---
      
      ## 3. 重定向落点策略(从代价低的层往上做)
      
      > **本节是「第三步 重定向」的落地。** 目标:**主动改 / 注入客户端,把请求引到我们的服务端**,
      > 并**优先让客户端跑起来**。原则:从**代价最低的层**往上做(见下表),不追求一步到位。
      
      | 优先级 | 落点 | 做法 | 代价 |
      |---|---|---|---|
      | **1(先做)** | 网络层重定向 | 见 §3.0 三板斧 | 要 root;不能与官服双开 |
      | **1.5** | **Xposed / LSPatch 模块重定向** | 注入模块改请求目标(见 §3.0b) | 需框架;不改包,无 root 配 `LSPatch`/`NPatch` |
      | **2** | **应用私有目录的地址文件** | 见 §3.1 | 需先让客户端生成目录 |
      | **3** | 明文配置 / 脚本 / AS3 源码 | 直接改文本(注意包名连带) | 要重打包+重签 |
      | **4** | smali / 托管程序集 | 改字节码 | 重签;可能被缓存/热更覆盖 |
      | **5** | native | 逆向与 ABI 风险高 | 见 `repack-rename.md` |
      
      ### 手段全景(不止 Xposed)
      
      > 把请求引到我们服务端的方法很多,按「要不要 root / 要不要改包」选。
      
      **A. 不改客户端(纯网络层)**
      
      | 手段 | 要 root | 说明 |
      |---|---|---|
      | hosts / DNS 重定向 | 多数要 | 地址是域名时最省事;Android 改 hosts 需 root/bind-mount |
      | iptables DNAT(按 uid) | 是 | 全协议,只劫这一个游戏(§3.0 三板斧) |
      | 透明代理(mitmproxy tproxy) | 是 | 按 Host 分发:游戏域名走本地、官方 OSS 透传 |
      | **本地 VPN 重定向(Android VpnService)** | **否** | **免 root 首选**:自建只管目标包名的 VPN,把游戏流量转发本地 |
      | 自建 DNS(DoT/DoH)/ 手机"私人 DNS" | 否 | 免 root 改域名解析 |
      | TCP 中继 relay | 是 | 模拟器 NAT 坏掉时兜底 |
      | 路由器 / 网关层 | 否 | 路由器改 DNS 或端口转发 |
      
      **B. 运行时注入(不改包)**
      
      | 手段 | 要 root | 说明 |
      |---|---|---|
      | Frida / objection | 否* | hook 取址/SSL/登录函数;免 root 需把 frida-gadget 打进 APK |
      | Xposed / LSPosed | 是 | — |
      | **LSPatch / NPatch** | **否** | **免 root 用 Xposed 模块**:把模块补进 APK(不需 root) |
      | VirtualApp / VirtualXposed / 太极 | 否 | 应用虚拟化容器,在容器里 hook |
      | Magisk / Zygisk 模块 | 是 | 系统级注入 |
      | Shizuku | 否(授权) | 借用系统 API |
      
      **C. 改包(重新打包)**:改 smali/dex、改托管程序集(dnSpy)、改 native so、改配置/资源(`serverlist.json`/`local_server.txt`/assets)、改包名/重签(`repack-rename.md`)、客户端自带测试开关(`sdkDummy`)。
      
      **D. 端游 / PC 特有**:改 hosts / 改 exe 字符串、**DLL 注入 + Detours**(hook `connect`/`WS2_32`)、**WinDivert / npcap** 重定向、**LD_PRELOAD**(Linux)。
      
      > 另有一条并列路线:**内联服务端(M11)**——不起外部服务端,在进程内合成响应 → `inline-server.md`。
      
      ### 3.0 网络层劫持三板斧(零改包,全部实测)
      
      > 来源:自研 C++ 引擎 MMO(无任何客户端配置可改)的全链路实战。
      > 适用前提:模拟器/设备有 root。**这是优先级 1 的完整展开——零改动客户端、
      > 零触发签名/完整性校验、随时可回滚(删规则即回滚)。**
      
      **先拿到游戏 uid**(DNAT 按它过滤,不伤系统其它流量):
      ```sh
      adb shell stat -c %u /data/data/<包名>          # 例: 10052
      ```
      
      **板斧一:iptables 按 uid DNAT(TCP/UDP 全协议,最常用)**
      ```sh
      # 把游戏的 80/10001/9541 端口流量全部转到 PC 侧 (10.0.2.2 = Android 模拟器的宿主机)
      adb root
      adb shell "iptables -t nat -A OUTPUT -p tcp --dport 80   -m owner --uid-owner 10052 -j DNAT --to-destination 10.0.2.2:80"
      adb shell "iptables -t nat -A OUTPUT -p tcp --dport 10001 -m owner --uid-owner 10052 -j DNAT --to-destination 10.0.2.2:10001"
      # 查看与回滚:
      adb shell "iptables -t nat -L OUTPUT -n"
      adb shell "iptables -t nat -F OUTPUT"     # 全清 = 回滚
      ```
      PC 侧再起一个 **transparent HTTP 代理**(读原始 `Host` 头按域名分发:
      游戏域名→本地 mock、官方 OSS→透传),就同时解决了「劫持自己 + 放行官方」。
      > 实测要点:代理必须**按 Host 分发**——被 DNAT 后目标 IP 已丢失,但 Host 头还在;
      > 转发上游时要显式连「域名解析出的 IP + 带 Host 头」(http.client 直连 IP,勿让
      > urllib 走系统代理)。
      
      **板斧二:TCP 中继 relay(模拟器 NAT 数据面异常时的兜底)**
      > 真实事故:模拟器直连某官方端口被 RST(PC 直连同端口正常)——
      > 模拟器**用户态 NAT 对特定端口的数据面会坏**。表现:`Connection::ConnectServer
      > success` 后协议超时、抓包只见 ACK 无数据。对策:PC 起双向透传中继,DNAT 指中继:
      ```python
      # relay.py 核心: 每连接双向 pump, 双线程
      import socket, threading
      def pump(a, b):
          try:
              while (d := a.recv(65536)): b.sendall(d)
          finally: a.close(); b.close()
      def handle(c, real):
          up = socket.create_connection(real, timeout=10)
          threading.Thread(target=pump, args=(c, up), daemon=True).start()
          pump(up, c)
      # listen 10001 → ('官方center_ip', 10001);9541 同理
      ```
      > 附带红利:中继位置天然是**抓包/改包注入点**(mock 过渡期用它录「官方标准答案」,
      > 与自建实现对拍)。
      
      **板斧三:bind mount 覆盖只读分区的 hosts**
      > `/system/etc/hosts` 在 ro 分区 + AVB,`mount -o remount,rw` 必失败。但 **bind mount
      > 可以覆盖只读文件**(同分区可写路径即可):
      ```sh
      adb root
      adb shell "echo '10.0.2.2 user-v4.example.com oss.example.com' > /data/local/tmp/hosts"
      adb shell "mount -o bind /data/local/tmp/hosts /system/etc/hosts"
      # 回滚: umount /system/etc/hosts 或直接重启
      ```
      > 适用:客户端连的是**域名**而非 IP(DNAT 板斧一管不到 UDP DNS)。
      > 但注意:**很多模拟器(MuMu 等)的用户态 NAT 自带 DNS 代理**,会先于 hosts 劫持
      > UDP:53(内核 iptables 计数器在涨、包却进了 NAT 上游)。此时改 hosts 也没用,
      > 要么走 DNAT(TCP 层),要么起自己的 DNS(UDP+TCP 53,可用 5353/15353 测试后切换)。
      
      **三板斧组合的典型拓扑**(实测全链路):
      ```
      模拟器游戏(uid 10052)
        ├─ DNS(53)      → [模拟器NAT自带代理,不可改] → 用 bind-mount hosts 或自建DNS
        ├─ HTTP(80)     → DNAT → PC transparent proxy (按Host分发: mock登录/OSS透传)
        └─ TCP(10001/9541) → DNAT → PC relay / PC mock server
      ```
      
      **三个坑提前说**:
      1. 模拟器重启后 iptables/挂载**全丢**——把设置命令写成脚本,重启后重跑。
      2. DNAT 的目标写 `10.0.2.2`(标准模拟器宿主地址);真机场景写局域网 PC IP。
      3. 模拟器自带的 adb 与系统 adb **server 版本互杀**(32/41 互不兼容)——统一用
         模拟器自带 adb,否则设备频繁掉线。
      
      ### 3.0b Xposed / LSPatch 模块重定向(不改包,免 root 也能用)
      
      > 在 Zygote 层 hook 目标游戏的网络层,把请求目标改到我们的服务端。
      > 代表项目:面向手游的代理模块(面向动漫手游的代理模块)。
      
      - **能做什么**(按游戏分别打补丁,都封装在模块里):
        - 把登录 / 游戏请求**重定向到指定本地离线服务端**;
        - 需要时顺带**禁用该游戏的完整性校验**(具体做法按目标游戏而定)。
      - **用法**:装模块 → 匹配游戏包名 → 菜单「配置管理」填写我们的服务器地址 →(固定场景)导出配置。
        **静默模式**:把 `agp_config.xml` 放进 APK 的 `assets/`,再用 **LSPatch** 修补 APK → 不弹窗、始终按配置连我们的服。
      - **无 root 可用**:`LSPatch` / `NPatch(推荐)`。
      - 注意:**不支持 PC 模拟器**;部分功能在模拟器会失效。
      
      > 适用:想"让客户端像连官方一样连我们服务端"、又不想反编译改 so 时,这是**最贴近目标**的一类手法。
      > (第三方代理模块 已内置多款游戏的禁用校验补丁 —— 说明这条路是**业界已验证**的。)
      
      > **可直接用的模板**(本 skill 自带):
      > - `templates/xposed-redirect/` —— LSPosed 模块骨架(**Java / OkHttp 客户端**:hook `okhttp3.Request$Builder.url` / `java.net.URL`),
      >   配置走 `/data/local/tmp/redirect_config.txt`;无 root 用 LSPatch/NPatch。
      > - `templates/frida-redirect.js` —— **native / il2cpp / Unity 客户端**:hook `getaddrinfo` / `connect`。
      >
      > 无 root 用 **LSPatch / NPatch** 修补时会**重签名** → 在 LSPatch 里把 **Signature Bypass 调到等级 2**
      > (`repack-rename.md §8`)让校验仍看到原签名;不够再用签名破解/虚拟容器。
      
      ### 3.1  应用私有目录地址文件(最容易忽略的落点)
      
      有的客户端把服务器地址放在**自己 `files/` 目录下的一个文本文件**里,格式极简:
      
      ```text
      192.168.1.100:26020
      ```
      
      路径形如:
      
      ```text
      Android/data/<包名>/files/local_server.txt
      ```
      
      **操作顺序很关键**:
      
      1. **先安装并启动一次游戏**,让它自动创建 `Android/data/<包名>/` 目录;
      2. **退出游戏**;
      3. 把地址文件写进去(覆盖同名文件);
      4. 重新打开游戏。
      
      > 放在 `Download/` 或 `sdcard/` 根目录**无效** —— 客户端只读自己 `files/` 下的那个文件。
      > 手机文件管理器访问不了 `Android/data/` 时用 ADB:
      > ```sh
      > adb push local_server.txt /sdcard/Android/data/<包名>/files/local_server.txt
      > ```
      
      **优点**:完全不动 APK,不触发签名/完整性校验。
      
      ### 3.2 免登录(比伪造登录更简单)
      
      有些客户端(尤其 AS3/Flash 打包的)带一个开发开关,直接跳过平台 SDK:
      
      ```as3
      // 反编译出的配置类
      public static var sdkDummy:Boolean = false;   →   true
      ```
      
      效果:跳过 SDK 登录、使用假 userId;支付/推送/实名等真实 SDK 功能变为 stub。
      配合把域名 + 协议(`https` → `http`)改成自建地址即可。
      
      > 思路:**能"关掉"就不要"骗过"**。伪造登录态容易在后续接口被校验掉。
      
      ---
      
      ## 4. 改包的三层可行性(先分级再动手)
      
      | 层次 | 位置 | 可行性 | 主要风险 |
      |---|---|---|---|
      | Java/Kotlin(smali) | SDK 域名、缓存键、故障转移 | **高** | 域名缓存与回退仍可能覆盖修改 |
      | 托管程序集 | 区服发现、登录流程 | **中** | 定义位置可能在热更资源里 |
      | native(IL2CPP / UE) | `libil2cpp.so`、metadata | **低** | 重定位与 ABI 风险,改动面大 |
      | 热更资源(DLL/Bundle) | 下载得到的程序集 | **不建议** | 会被下一次热更覆盖 |
      
      ---
      
      ## 5. 动手前必查的三件事
      
      ### 5.1 能不能用明文 HTTP?
      
      ```xml
      <!-- AndroidManifest.xml -->
      android:usesCleartextTraffic="true"
      android:networkSecurityConfig="@xml/network_security_config"
      ```
      
      ```xml
      <!-- res/xml/network_security_config.xml -->
      <base-config cleartextTrafficPermitted="true" />
      ```
      
      允许 → 可以直接用 `http://<局域网IP>:<端口>/`,不必折腾 TLS。
      另外顺手确认有没有对目标域名做**证书固定(pinning)**——有的话要先解决,否则抓包和自建 HTTPS 都会失败。
      
      ### 5.2 当前运行的到底是哪个版本?
      
      若客户端带 **HybridCLR / 热更 DLL**(程序集清单里出现 `HotUpdate.dll` 之类):
      
      - 你改 APK **内置** 的程序集,可能被下载的新版覆盖;
      - 设备**已有热更缓存**时,改内置资源**完全无效**。
      
      > 判定方法:对比「首次启动」与「已有缓存启动」的行为;看 `files/` 下有没有生成热更目录。
      > **结论没拿到之前,不要动 CDN。**
      
      ### 5.3 热更/CDN 要不要一起改?
      
      **不要。** 热更资源(Manifest/Bundle/版本目录)与业务 API 是**不同协议、不同故障边界**:
      
      - 热更保留原站 → 客户端能正常拿到资源和代码;
      - 只有认证/业务走本地 → 范围可控、可回滚。
      
      > 只改业务地址而保留热更原站,是**第一阶段的正解**,不是妥协。
      
      ---
      
      ## 6. 重打包的现实后果(先说清楚,别事后发现)
      
      | 情况 | 结果 |
      |---|---|
      | **同包名**重签 | 通常**装不上**已装的原包 → 需先卸载 → **可能丢应用数据** |
      | **异包名** | 可并行安装;但 `Android/data/<包名>`、缓存、部分 SDK 绑定状态**不复用** |
      
      重签流程固定四步:
      
      ```bash
      zipalign -p 4 in.apk aligned.apk
      apksigner sign --ks <keystore> --out signed.apk aligned.apk
      apksigner verify --verbose signed.apk
      # 装到设备后逐条对 §2 的清单
      ```
      
      > 「静态搜索没找到签名校验」**不能**说明没有校验 —— 只能说明搜索范围里没有。
      > 仍然要实机验证。
      
      ---
      
      ## 7. 阶段化推进(每阶段只判定一件事)
      
      ```
      阶段 0  保留原始输入:APK 副本 + SHA-256;只在副本上改
      阶段 1  定位托管代码里的地址定义(确认是不是热更版本)
      阶段 2  最小改动:把所有 SDK 候选基地址收敛到本地
      阶段 3  区服发现:/server/list 返回本机 addr:port
      阶段 4  重打包安装,按顺序观察:区服列表 → SDK 登录 → TCP 连接 → 登录请求 → 启动序列
      阶段 5  确认热更影响(对比首次启动 vs 有缓存启动)
      ```
      
      **第一阶段的验收只应判定到「TCP 连接 + 收到登录请求 + 启动数据按序发送」**,
      不要在这一步就要求支付/钻石到账闭环。
      
      ---
      
      ## 8. 未解决问题要显式落盘
      
      这份文档的末尾必须留一节「当前未解决」,例如:
      
      ```
      1. 某地址的定义与最终赋值位置尚未定位
      2. 当前设备加载的是 APK 内置程序集还是热更程序集,未确认
      3. 重签测试包是否触发签名/包名校验,未实机验证
      4. 修改后是否会被域名缓存/失败转移覆盖,未实机验证
      ```
      
      > 写下来 = 下一个接手的人不用重新发现一遍。
    • client-languages.md 6.3 KB
      # 客户端语言分支:不同语言怎么读协议、怎么改客户端
      
      > 客户端**不一定**是 Unity/C#,也**不一定**是 UE。先把语言判对,再选工具。
      > 判定方法:看 `assets` / `lib` / 文件扩展名 / 字符串。
      
      ---
      
      ## 0. 语言判定速查
      
      | 线索 | 客户端语言 | 主工具 |
      |------|-----------|--------|
      | `Assembly-CSharp.dll` / `*.dll` + `Managed/` | C# (Unity Mono / .NET) | dnSpy / ILSpy |
      | `global-metadata.dat` + `libil2cpp.so` | C#→IL2CPP(无源码,只有签名) | Il2CppDumper + IDA |
      | `*.swf` / `*.as` / `*.abc` / `Starling` | ActionScript3 / Flash | JPEXS FFDec |
      | `*.luac` / `xlua` / `tolua` | Lua 热更 | unluac / luadec |
      | `classes.dex` 业务重 | Java/Kotlin | jadx |
      | `lib*.so` 主逻辑 + 大量 C++ 符号 | 原生 C++(含 UE) | IDA/Ghidra + SDK dump |
      | `*.js` / `webpack` / `.h5` | JS/H5 | beautify + sourcemap |
      | `*.wasm` | WebAssembly | wasm2wat / wasm-decompile |
      
      ---
      
      ## 1. C# 客户端(重点:可反编译出源码)
      
      > 用户提到「游戏可能是 C# 的,能解出客户端 C# 源码」——这是**最舒服**的情况:
      > 反编译接近原始源码,协议字段名、消息号、序列化逻辑几乎白给。
      
      ### 1.1 两种 C# 形态
      
      | 形态 | 特征 | 能拿到什么 |
      |------|------|-----------|
      | **Mono / .NET** | `Assembly-CSharp.dll`(未加密) | **近乎完整源码**(dnSpy 反编译) |
      | **IL2CPP** | `libil2cpp.so` + `global-metadata.dat` | 只有签名(dump.cs),函数体要看汇编 |
      
      ### 1.2 Mono/.NET 反编译流程
      
      ```bash
      # 工具:dnSpy / ILSpy / dotPeek / JetBrains dotPeek
      # 直接拖入 Assembly-CSharp.dll 即可
      ```
      - 若被混淆(ConfuserEx 等)→ 先 `de4dot` 去混淆,再 dnSpy。
      - 找网络层关键词:
      ```
      Socket / TcpClient / UdpClient / NetworkStream
      WebRequest / HttpClient / WebSocketSharp / BestHTTP
      ProtoBuf / MessagePack / Newtonsoft.Json / LitJson
      SendMessage / Send / Recv / Packet / Protocol / PacketHandler
      opcode / msgId / protocolId / Cmd
      ```
      - **重点**:C# 客户端里通常有
        - `PacketHandler` 字典 / `switch(msgId)` → **消息号全集**
        - `[ProtoContract]` / `[Message]` 标注 → 字段顺序
        - 序列化/加密工具类 → 算法与密钥来源
      
      ### 1.3 IL2CPP 反编译
      
      ```bash
      Il2CppDumper.exe libil2cpp.so global-metadata.dat out/   # -> dump.cs
      ```
      - `dump.cs` 只有「类/方法/字段签名」,**没有函数体**。
      - 要看实现:IDA/Ghidra 加载 `.so` + `script.json` 恢复符号。
      - 想还原成近似源码:`Il2CppInspector` 可生成伪 C# 桩;复杂逻辑仍需汇编。
      
      ### 1.4 从 C# 源码里「抄」什么
      
      | 目标 | 在源码里找 |
      |------|-----------|
      | 传输 | `Socket`/`TcpClient` 初始化、`Connect(host,port)` |
      | 帧封装 | `Write(byte[])`/自定义 `Packet` 类/长度写入处 |
      | 加密 | `Encrypt`/`Decrypt` 工具类、密钥常量 |
      | 压缩 | `GZipStream`/`DeflateStream`/`LZ4` |
      | 序列化 | `ProtoBuf.Serializer`/`JsonConvert`/手写 `Write*` |
      | 消息号 | `enum MsgId`/`Dictionary<ushort, Action<...>>` |
      | 字段布局 | 消息类字段声明顺序(配合序列化方式判断 wire 顺序) |
      
      ### 1.5 改 C# 客户端的三种方式
      
      1. **配置法**(最轻):把服务器地址写进客户端读的配置文件/ini/txt。
      2. **重编译法**:反编译 → 改 → 用**同版本 .NET/Unity** 重新编译回 dll(需注意签名与版本)。
      3. **运行时 Hook**:Frida(Unity)/ dnSpy 调试断点改内存 / Harmony 打补丁。
      
      > 若客户端是 Unity,优先 `Frida` + `il2cpp` 模式;若 Mono,dnSpy 直接改 dll 更简单。
      
      ---
      
      ## 2. ActionScript3 / Flash 客户端
      
      > 案例:`项目C`(某 Cocos 手游 CN)就是 AS3 客户端。
      
      ### 2.1 反编译
      ```bash
      # JPEXS Free Flash Decompiler (FFDec)
      # 打开 .swf / .abc,导出 ActionScript (.as) 源与资源
      ```
      - 导出后可读 `.as` 源码 → 找 `URLRequest` / `URLLoader` / `Socket` / `NetConnection`。
      - 配置类常见命名:`DevConfig`、`Config`、`ServerConfig`。
      
      ### 2.2 典型改动(参考 项目C 的做法)
      ```as
      // pinball/config/core/DevConfig.as —— 启用 SDK Dummy,跳过官方登录
      // pinball/config/gbits/DevConfig_gf_android.as —— 改 API 地址
      public static const API_URL:String = "http://<你的服务端>:8001";
      ```
      - 改完用 **Apache Flex / Animate** 重新编译 `.swf`。
      - 仓库常提供补丁脚本:`client-patch/apply.sh <AS3_EXPORT_DIR> <SERVER_HOST>:8001`。
      
      ### 2.3 读协议
      - 看 `.as` 里构造 `URLVariables` / `JSON` / `AMF` 的地方 → 请求字段。
      - HTTP 路由即「消息号」(如 `/api/login`)。
      
      ---
      
      ## 3. Lua 热更客户端
      
      - 判明文:`1B 4C 75 61`(luac 头)→ 直接 `unluac`。
      - 加密/魔改 opcode → 先还原 opcode 表。
      - 找 `sendMsg` / `Net.` / `Cmd` → 业务协议与字段名。
      
      ---
      
      ## 4. JS / H5 客户端
      
      - 打包产物 → `webpack` 解包 / `beautify`。
      - 有 sourcemap 更好(`*.js.map`)。
      - 找 `fetch` / `XMLHttpRequest` / `WebSocket` / `socket.io`。
      - 改动:直接改 js 或改运行时配置。
      
      ---
      
      ## 5. Java / Kotlin(原生 Android)
      
      - `jadx -d out/ app.apk` → 读 Java 源码。
      - 找 `OkHttp` / `Socket` / `WebSocket`。
      - 改动:smali patch(apktool + baksmali),或 Frida hook。
      
      ---
      
      ## 6. 原生 C++ / UE
      
      - SDK dump(Dumper-7 / UE4SS)→ 结构体与 RPC。
      - 见 `unreal.md`。
      
      ---
      
      ## 7. 客户端补丁的四种通用手法(跨语言)
      
      | 手法 | 适用 | 例子 |
      |------|------|------|
      | **配置文件法** | 客户端把地址放外部文件 | `local_server.txt` 填 `IP:PORT` |
      | **源码重编译法** | 能拿到源码/伪源码 | 改 `.as` 重编;改 `.dll` 重编 |
      | **二进制 Patch** | 只改常量/跳转 | smali patch、il2cpp patch、hex patch |
      | **运行时 Hook** | 不落地改动 | Frida / dnSpy / Harmony |
      
      > 优先顺序:**配置法 > 运行时 Hook > 二进制 Patch > 重编译**(越往后越重、越易踩版本坑)。
      
      ---
      
      ## 8. 常见坑
      
      - **C# 混淆**:`de4dot` 未必能全还原,字段名可能变 `a`,`b`。
      - **强名称签名**:改完 dll 需去掉强名称校验或重签。
      - **Unity 版本**:重编译 dll 必须与目标 Unity 的 API 兼容。
      - **AS3 版本**:不同 SDK 编译产物不互通,需匹配原工程。
      - **混淆 vs 加密**:混淆是改名,加密是改字节,处理方式不同。
      - **许可证**:参考项目可能带 Noncommercial / GPL 等限制,遵守其条款。
    • closure-verification.md 11.6 KB
      # 闭环验证与「假阳性」排查手册
      
      > **什么时候看**:你(或上一个 AI)准备宣布“已经跑通”之前。
      > 本文件只讲一件事:**怎么判断“真的通了”,而不是“看着像通了”。**
      >
      > 来源:2026-09-13 项目A(某 Unity IL2CPP 手游 官服 2.0.109)接手实战复盘。
      > 那次交接文档写「唯一卡点是客户端自校验」,复核后发现**服务端本身也还没实现登录响应**。
      
      ---
      
      ## 0. 一句话
      
      > **“能连上”“日志有输出”“脚本跑过了”——这三种都不等于“原版客户端跑通”。**
      > 判定标准只有一个:**真实客户端**完成了某个可命名的状态迁移(握手成功 / 登录成功 / 进入场景),
      > 且这个迁移在**服务端日志里有对应的收发记录**。
      
      ---
      
      ## 1. 六类假阳性(按踩坑频率排序)
      
      ### 假阳性 ①:端口在 listen ≠ 服务可用
      
      ```bash
      ss -lnt                     # 只能证明“有 socket 在 listen”
      ```
      
      `ss`/`netstat` 的输出**不包含**:
      - 这个端口是不是**本项目**的进程(可能是别的 App、别的残留实例)
      - 协议是否正确(错误的协议也会 accept 连接)
      - 客户端是否真的连上了它
      
      **正确核实顺序**:
      1. 进程身份:`ss -lntp` 看 pid → `cat /proc/<pid>/cmdline` 对得上项目吗
      2. 是否是**旧的**运行实例:对比进程启动时间与本次修改时间(旧进程不会加载你刚改的代码)
      3. 端到端:`ss -ntp | grep <客户端uid>` 或 `logcat` 里看到**真实连接建立**
      4. 报文级:服务端日志出现**客户端实际发出的字节**
      
      > 注意: 典型误判:改了源码、`ss` 里有端口、就宣布“服务已按新协议运行”。
      > 实际上跑的是**修改前启动的旧进程**。
      
      ### 假阳性 ②:自环测试 ≠ 原版客户端兼容(共谋假阳性)
      
      本项目 `client_test.py` 与服务端 `app/codec.py` **共用同一份** `Framing/Crypter/Envelope`,
      字段号也是**同一份猜测值**(`field_opcode: 1 / request: 2 / response: 3`)。
      
      ```python
      # client_test.py
      from app.codec import Framing, Crypter, Envelope
      CFG = {"frame": {...}, "serialize": {"field_opcode": 1, ...}}
      ```
      
      → 两端用同一套**未经证实的假设**,测出 `[+] full flow OK` 是**必然**的,
      **不构成任何**对真实协议的验证。
      
      **判定法**:
      | 测试方式 | 能证明什么 |
      |---|---|
      | 自环(同 codec) | 只能证明“代码不崩、封装自洽” |
      | **抓包重放**(把真实客户端字节喂进服务端) | 能证明拆包/解密对得上 |
      | **真实客户端连上来** | 唯一能证明“闭环”的方式 |
      
      > 规则:**只要测试两端共享同一份未验证假设,就不能作为闭环证据。**
      > 写进 `verify/log.md` 时必须标注「自环 / 非客户端验证」。
      
      ### 假阳性 ③:把卡点归因为“单一原因”,而不复核代码
      
      交接类文档为了简洁,常写成「现在唯一卡点是 X」。**接手第一件事是逐条复核**,而不是直接攻关 X。
      
      本项目的复核过程(可当模板):
      
      ```
      文档结论:唯一卡点 = 客户端签名自校验(改包后秒退)
      逐条核对服务端代码:
        · login_server.py 第 3 个包开始 → 只调用 _try_decrypt() 做“尝试解密 + 打日志”
        · 全文件搜不到任何业务响应的构造/发送
        ⇒ 结论:即使客户端过了自校验,也拿不到登录响应 → 卡点至少有两个
      ```
      
      **复核清单**:
      - [ ] 每个状态迁移点,服务端**真的构造并发出了**响应包吗(不是只 log)
      - [ ] `grep -nE 'TODO|NotImplemented|pass$|仅记录|只 ack' <服务端目录>`
      - [ ] Spec 的 `unresolved` 里未解决项,是否正好对应必需的响应
      - [ ] 客户端侧代码(dump.cs/lua)里那段逻辑,是否被真正读过
      
      ### 假阳性 ④:异常被 `except` 吞掉 → 表现为“客户端不继续”
      
      本项目真实缺陷:
      
      ```python
      elif step == 2:
          cdev = payload[32:48]
          exp = _h.sha256(b'kdf' + secret).digest()[:16]   # ← _h 从未 import
          ...
      except Exception as e:
          log.warning("[登录服] 异常: %s", e)              # ← 只留一行 warning 就断开
      ```
      
      `NameError` 被外层 `except` 吞成一条 `warning`,对外表现是
      「客户端发完确认包就没了」——**极易误判成“派生算法不对”**,从而去穷举不该穷举的算法。
      
      **防御写法**:
      ```python
      # 1) 每个 step 打「我发了什么 / 我期望收到什么」
      # 2) 异常日志级别 ≥ ERROR,并带上 step 与包长
      except Exception:
          log.exception("[登录服] step=%s 处理失败", step)   # 带栈
      ```
      **排查手法**:模拟一个握手连接,看日志里**有没有异常**;有栈就先修栈,再谈算法。
      
      ### 假阳性 ⑤:日志里的 [x] 不能当结论
      
      本项目原代码:
      
      ```python
      exp = _h.sha256(b'kdf' + secret).digest()[:16]    # 自己临时算的
      log.info("... %s", "[x]一致" if cdev == exp else "[x]不同(需换KDF)")
      ```
      
      - 被比较的 `exp` 是**另一套从未发给客户端的公式** → 永远不可能相等
      - 日志里出现 `[x]一致` 时,读者会以为“验证通过”
      
      **规则**:
      1. 比较对象必须是**实际发出去的那份候选值**,不是重新算的
      2. 命中时只允许写「候选命中(未认证)」,**不能**写「验证通过 / 认证成功」
      3. 任何“成功”措辞都要能对应到**客户端侧的下一步动作**
      
      ### 假阳性 ⑥:穷举失败 ≠ 无法推进,只是该换方法了
      
      本项目为 56B 派生值穷举了 34 种 KDF 候选(`secret[:16]`、`sha256(secret)[:16]`、
      `md5(token)`、`hmac(secret, pub+pub)` …)**全部失败**。
      
      **正确反应**:停止加候选,切回 **M3(读客户端实现)**——
      `ProcessHandShakeMessage` 的地址已经拿到了(`dump.cs` 偏移 `0x40AFB58`),
      反汇编它比再猜 30 种哈希便宜得多。
      
      > 穷举只适合**候选空间已被代码证据限定**的场景(如 `repack-rename.md` §3 的包名派生密钥)。
      > 盲猜哈希 = 伪工作。
      
      ---
      
      ## 2. 会话状态泄漏(长连接常见 bug)
      
      ```python
      self.sessions[peer] = secret          # 握手时写入
      # finally 里必须 pop,否则客户端 3s 重连会复用旧 secret
      self.sessions.pop(peer, None)
      ```
      
      客户端**掉线自动重连**(本项目 Lua 里 `auto_reconnect_after_s = 3`)时,
      握手会重来一遍。若 `sessions` 不清理:
      - 旧 secret 覆盖新 secret → 派生值全错
      - `sessions` 无限增长
      
      **检查点**:`finally:` 里是否清空了本连接的所有会话态(secret、密钥、序号)。
      
      ---
      
      ## 3. 验证记录模板(`verify/log.md`)
      
      每条结论一行都不许省:**命令 / 输入 / 原始输出 / 退出码 / 是否客户端验证**。
      
      ```markdown
      ## V-003 握手确认分支诊断(构造报文回归)
      - 命令: python3 fixture (asyncio, importlib.spec_from_file_location)
      - 输入: 帧1 = 客户端 EC 公钥(91B SPKI);帧2 = 服务端 56B 挑战(合成“确认”);然后 EOF
      - 原始输出:
          BASELINE: reproduced undefined _h on confirmation
          PATCHED: confirmation diagnostic passed; session cleaned; authentication NOT verified
      - 退出码: 0
      - 客户端验证: [x] 否(构造报文,非真实客户端;不证明 KDF 正确)
      - 回滚: cp verify/login_server.before.py server/app/login_server.py
              基线 sha256 = bc71c2a1...e61f08
      ```
      
      > 注意: 多行命令的输出有时由执行工具“提前返回”,务必**另行取回真实屏幕输出**再落盘,
      > 不要凭“exitCode 0”推断命令跑过。
      
      ---
      
      ## 4. 宣布“成功”前的检查表
      
      - [ ] 是**真实客户端**发起,不是我写的测试脚本
      - [ ] 服务端日志有**客户端实际字节**(收发双向)
      - [ ] 变更**只在一处**,且改动前后各有一条验证记录
      - [ ] 修改过的服务端代码**已被重新加载**(进程不是旧的)
      - [ ] `unresolved` 清单与 TRACKER 已同步(没做的就写 [ ],别写 [x])
      - [ ] 有回滚命令,且回滚文件/哈希还在
      - [ ] 没有把「端口在听」「脚本 OK」「日志打了 [x]」写进成果里
      
      ---
      
      ## 5. 假阳性 ⑦:数据"发对了",但发的时机杀死客户端(来自 cocos HTTP-RPC 项目)
      
      **形态**:服务端回包内容全部正确(解密可见、字段齐全),客户端却在登录阶段崩溃并无限重试,
      **且没有任何 JS 报错**(错误上报通道本身尚未初始化)。
      
      **本项目实例**:LoadGame 回包里带 `note.eco.exp`(想登录即同步江湖经验)。
      客户端对带 note 的回包会**立即派发事件**,而此时角色实体(`playerInfo.entity`)还没建完
      → 空引用 → 崩 → 客户端重试 LoadGame → 死循环。
      
      **判定法**:
      - 崩溃发生在「LoadGame 之后、下一条业务请求之前」且无报错 → 检查回包是否带**事件型字段**
      - 同一份数据改成**纯字段**(无 note 包裹)就能进 → 时机问题确认
      
      **规则**:
      1. 登录/加载阶段回包**只放数据,不放事件**(note/autoObj 等)
      2. 事件同步放到**客户端已就绪的时机**(本项目=战斗结算响应)
      3. 新增回包字段后登录崩溃、无报错 → **二分排除**:撤下全部新字段→能进→逐个加回
      
      **同族坑**:给客户端下发"空数组覆盖本地存档"(客户端 localStorage 有更全历史时)。
      服务端"没数据"的合法表达是**省略键**,不是发 `[]`。详见 `live-ops.md §4/§5`。
      
      ---
      
      ## 附:高频常见坑速查(原 SKILL §11)
      
      > 这些是"看着成功、其实是假阳性"或"反复浪费时间"的高频坑。宣布完成 / 卡壳时扫一眼。
      
      - **只抓包不读代码** → 字段靠猜、错位;**忽略压缩层** → 把 zlib 头当加密;**忽略序号/时间戳** → 重放/长连接失败;**忽略心跳** → 几秒被踢;**只跑通登录就收工** → 状态机未闭环,进场景即崩。
      - **证书固定没绕** → 一直抓不到包;**抓包不过滤** → 被视频/其它 App 淹没(用 `not port 443` + 按 uid 过滤)。
      - **只凭端口猜协议** → 归属要用「APK/源码能否搜到特征串」+「按 uid 过滤的流量」双向确认。
      - UE **忽略 unversioned 包** → FModel 全乱;IL2CPP **`dump.cs` 函数体为空** → 必须配 IDA/Ghidra。
      - **raw deflate 当加密**(最高频深度坑)→ 无 magic 的 zlib 流差分出"静态头尾+变化中段",极像流加密;30 秒快筛 `zlib.decompressobj(-15)`(decision-tree §4.0)。
      - **静态读 so 找 vtable 全是 0** → relocation 运行时才填 → 走 Ghidra/手工链 `.rela.dyn`(windows.md §I.3);**Ghidra 地址没加 image base** → 全 `NO FUNCTION`。
      - **链了 OpenSSL 就当在用** → 统计 PLT BL 调用点,0 调用=死代码。
      - **模拟器直连官方端口被 RST** → 用户态 NAT 数据面坏 → 上 TCP 中继(client-address-sources §3.0)。
      - **客户端卡在热更** → 官方 OSS 删了版本文件(404) → transparent proxy mock 该文件返回本地版本号即可跳过。
      - **回放帧只 patch 首处** → 帧内数据块常重复 N 份 → 帧内不一致、静默重试(engineering-practices §11.1);**场景初始化窗口推实体帧** → native crash,广播前查 peer `init_done`(§11.2)。
      - **改包相关**(动手前先读 ):顺序是**从代价低的落点往上做、优先把客户端推到能走到下一步**;一上来就改包名 → 触发自校验秒退;批量替换包名会改到 Activity 完整类名 → 闪退(只改 manifest `package` + arsc 包名 + 权限/authorities);只改 manifest 不改 `resources.arsc` → SDK 反查资源失败;忘了"包名派生密钥"资源 → `BAD_DECRYPT`;**只看前 5 秒** → 延迟自校验要观察 60~90 秒。
    • cocos2d.md 15.3 KB
      # Cocos2d-x / Cocos Creator 分支深潜(JS / Lua 热更 + 自加密 HTTP)
      
      > 适用:`libcocos2d*.so`(cocos2d-x 原生)或 Cocos Creator(`assets/` + `src/`)客户端。
      > 特征:**业务逻辑大量在 JS/Lua 脚本层**,且脚本常被**编译/加密**;协议常见 **HTTP-RPC + 自加密**(不是 protobuf 长连接)。
      > 本文件解决三件事:**① 脚本怎么解包/读;② 网络与加密怎么定位;③ 用它反推服务端怎么最快。**
      
      ---
      
      ## A. 判定与形态
      
      ```
      APK 解压看 lib/ 与 assets/
        libcocos2djs.so + assets/src/*.js 或 index.jsc     → cocos2d-x (JSBinding, JS 业务)
        libcocos2dlua.so + assets/src/*.lua               → cocos2d-x (LuaBinding, Lua 业务)
        libcocos2d.so  + assets/ 下 cocos 资源            → cocos2d-x 老版本
        cocos creator: assets/ + settings + src/ + *.jsc   → Cocos Creator (2.x/3.x)
        另有 libEncryptor*.so / libgame.so                 → 业务/加密在原生层(重点看它)
      ```
      
      | 线索 | 结论 | 主工具 |
      |------|------|--------|
      | `libcocos2djs.so` + `index.jsc` | cocos2d-x JSB,脚本编译成 `.jsc` | 解 jsc(见 B) |
      | `assets/src/**/*.lua` | cocos2d-x LuaBinding | unluac/luadec |
      | `jsb-builtin.js` / `jsb-engine.js` | JSB 运行时绑定层 | 直接读源码定位原生桥 |
      | `project.manifest` / `version.manifest` | **热更**存在,资源可能不来自包内 | 见 G |
      | `libEncryptorP.so` / `libcocos2djs.so` 内 `AES` 字符串 | 自加密协议 | IDA/Ghidra + 动态 |
      
      > 注意: 关键区分:cocos2d-x 的资源**可能走热更下载**,包里那份只是初始版本。
      > "改包后无效" 十有八九是**改的包内旧资源,客户端却加载了热更的新资源**(见 G)。
      
      ---
      
      ## B. 脚本层解包(这是 cocos 分支的核心)
      
      ### B1. JSB 脚本 bundle
      - cocos2d-x 把 JS 编成 `.jsc`(本质是 **xxtea 加密 + JSC 编译**),cocos creator 更常见 `index.jsc` + `assets/**/*.jsc`。
      - 判定:`.jsc` 头通常是 `XXTEA`(creator 默认)或自定义 magic。
      - 解包思路:
      ```bash
      # 1) 在 libcocos2djs.so / libcocos2dlua.so 里找 xxtea key 与解密入口
      strings -a libcocos2djs.so | grep -iE 'xxtea|decrypt|jsc|signature'
      # 2) 常见 key 来源:硬编码常量 / "xxtea key" / 由包名派生
      # 3) 解出后得到可读 JS,再按函数名 grep
      ```
      - **本项目经验**:脚本解出来往往是 `scripts__index.jsc.js` / `scripts2__*.jsc.js` 这种"混编大文件"(几十万行),**能直接 grep**,不必追求还原成模块。
      
      ### B2. 建立"函数名 → 文件:行号"索引(最重要的一步)
      ```bash
      # 定位入口与关键回调
      grep -n 'LoadGame\|loadGame = function\|updatePlayerData' scripts__index.jsc.js
      grep -n 'MessageCenter.on' scripts__index.jsc.js | head
      grep -n 'HttpConst\.' scripts__index.jsc.js | head        # 请求常量清单
      ```
      - 把 `LoginMgr.loadGame → scripts__index.jsc.js:66510` 这种映射记进 Spec 的 `client` 段。
      - 后面每次排障都靠它。
      
      ### B3. 读协议的两种"白给"位置
      1. **请求常量表**:`HttpConst.XXX = "User.method"` → **RPC 方法全集**,即你的"待实现接口表"。
      2. **回调消费端**:`MessageCenter.on(HttpConst.XXX, function(t,e){...})` → 回调里**读哪些字段、怎么分支**,就是响应契约。
      
      > 注意: 常量可能**带脏字符**:`GET_DUNGEON_INFO = "User.getCopyData\n"`(尾部换行)。
      > 服务端入口必须 `do.strip()`,否则该接口**永远匹配不上**(本项目实坑)。
      
      ---
      
      ## C. 网络层定位
      
      cocos 的网络路径比 Unity 分散,按下面顺序找:
      
      | 路径 | 位置 | 特征 |
      |------|------|------|
      | JS 层 `XMLHttpRequest` / `fetch` | 脚本内封装的 `NetMgr/HttpMgr` | **HTTP-RPC 型**(本文件主线) |
      | JS 层 `WebSocket` / `socket.io` | `NetMgr.http` / `Net.*` | 长连接/聊天 |
      | 原生 socket 经 jsb 桥 | `jsb.NET.HttpRequest` / `websocket` | 走 `libcocos2djs.so` |
      | 自研 TCP | 原生 `.so` | 少见,按 Unity TCP 那套分析 |
      
      判"HTTP 轮询型"的标志:多端口 + 请求体是**结构化字符串/JSON** + 有 `sendRequest(常量, params, cb)`。
      > 本项目:**三个端口分工**(选服 `9898`、登录/业务 `8002`、WS `9002`),典型 HTTP-RPC。
      
      ---
      
      ## D. 自加密协议(cocos 最常见形态)
      
      ### D1. 定位加密
      ```bash
      strings -a libEncryptor*.so libcocos2djs.so | grep -iE 'aes|cbc|pkcs|key|iv|md5|base64'
      # 找 encrypt/decrypt 导出函数 → IDA/Ghidra 看参数(key/iv 常量)
      ```
      ### D2. 典型结构(HTTP-RPC + AES)
      ```
      请求体 = hex( AES-CBC( "{\"mod\":\"User\",\"do\":\"Method\",\"p\":{...}}" ) )
      响应体 = {"errorCode":0,"MsgData":"<hex( AES-CBC( 明文JSON ) )>"}
      ```
      - 参数常见:`AES-256-CBC` + `Pkcs7`,`KEY/IV` 为 16/32 字节 hex 常量。
      - 服务端要能**双向**处理:`looks_encrypted()` 判别 → 解密;响应再加密回 hex。
      - 明文里 `mod` 常恒为 `"User"`,`do` 是方法名,`p` 是参数对象 —— **把 `do` 当 RPC 名**。
      
      ### D3. 加密边界自测(不要靠手抓包)
      ```python
      import server_module as M
      body = M.aes_encrypt_hex({'mod':'User','do':'LoadGame','p':{...}}).encode()
      # POST → 取回 {"errorCode":0,"MsgData":...} → M.aes_decrypt_body(MsgData) 得明文 dict
      ```
      > 直接 `import` 你的服务端模块造/解包,比反复抓包快一个数量级。
      
      ---
      
      ## E. 热更与资源来源(cocos 特有的坑)
      
      - `project.manifest` / `version.manifest` 存在 → 客户端启动会**比对版本并可能下载热更资源**。
      - 后果:
        1. 你改的**包内资源可能被热更覆盖**;要改就改**热更源**或**屏蔽热更**。
        2. 版本号对不上 → 客户端可能卡在"检查更新"。
      - 处置优先级:先**让客户端不更新**(改 manifest 版本/地址/断掉热更域),再谈改包。
      
      ---
      
      ## F. 改包与重签(确实要改客户端时)
      
      ```bash
      apktool d app.apk -o out        # 解包
      # 改 assets/src/*.jsc.js 里的地址常量 / DevConfig / 服务器 URL
      apktool b out -o app-mod.apk    # 回包
      # 重签(必须,否则装不上)
      keytool -genkey ... ; apksigner sign --ks ks.jks app-mod.apk
      ```
      - **优先"不改包"落点**(见 `client-address-sources.md`):改客户端读的**配置/热更 manifest** 把地址指向自建服。
      - 注意**包名派生密钥**:有的加密 KEY/签名由**包名**派生,改包名会导致解密失败 → 保持原包名最稳。
      - cocos 的 `.jsc` 改完若仍是密文形态,需**按原算法重新加密**(key 应与运行时一致)。
      
      ---
      
      ## G. 配置表与本地化(反推服务端的数据基础)
      
      - cocos 手游普遍**纯客户端算逻辑**,服务端只要提供**存档投影**(属性/背包/技能/任务)。
      - 配置表常在 `tables/*.json`(或 csv/xlsx 导出)。**文本字段是语言表 key**:
      ```python
      zh = json.load(open('lantab_zh.json'))     # {key: 文本}
      name = zh.get(str(item['Name']))           # item['Name'] 是 key,不是文字
      ```
      - 把 `ID + 名称 + 关键属性` 导出成 CSV,后台发物品/暗号兑换/商店全要用它。
      
      ---
      
      ## H. Cocos 分支实坑清单(本项目真实踩过)
      
      | # | 现象 | 根因 | 处置 |
      |---|------|------|------|
      | 1 | 某接口永远匹配不上 | `do` 常量带 `\n` | 入口 `strip()` |
      | 2 | **自测全绿,真机进不去** | 只验证了 HTTP 通,没跑客户端脚本 | 真机 UA vs `Python-urllib` **分开看日志**;以"下一条请求"为准 |
      | 3 | 进服转圈后退出 | LoadGame 回包字段让客户端回调抛异常 | 用"后续请求是否发出"夹逼;**回包大小对账** |
      | 4 | 一次改多字段=不可定位 | 同时上线 5+ 新字段 | **最小增量 + 一次一变量**,保"最后一次可进服"基线 |
      | 5 | 字段类型错就崩 | 回调无保护 `.length/.indexOf/.split`;`equipLocks` 客户端要数组却收到对象 | 每个字段**回回调里核对期望类型** |
      | 6 | 配置表全是数字 | 文本是语言表 key | `lantab_zh` 还原 |
      | 7 | 角色数据串号 | 物品/技能存成扁平结构 | 按账户分桶 |
      | 8 | WS 心跳应答后仍 30~80s 断线循环 | **protobuf 消息号是猜的**(5279 当 Pong) | 从 APK 解 `protoidmap` 表核对:本项目 5288=Pong、5279=TeamBattleRoomChatMessageRes;消息号表必须**逐号实证**,不能按语义猜 |
      | 9 | 改完功能玩家仍报"没用" | 玩家在线时改的服务端/发的物品,**客户端不重登不刷新** | 存档型变更(装备/物品/称号)一律提示重登;在线推送只对带 note 事件的响应生效 |
      | 10 | 登录后立刻崩、反复重试无报错 | **登录阶段派发 eco 事件**(角色实体未初始化,空引用) | LoadGame 回包**只放数据不放事件**;经验/货币同步放战斗结算(客户端已就绪的时机) |
      | 11 | 后台发"经验/铜钱"玩家进不去 | 这些是**伪物品**(ID 4001/4002/4003),进了 itemObj 客户端做 `+=` 累加爆掉 | 后台发放接口按 ID 路由到对应属性桶;LoadGame 永久过滤伪物品出 itemObj |
      | 12 | 装备穿上打怪空手、重登被脱 | 客户端穿戴契约是 `{EquipmentType:槽位, itemID:实例ID}`,服务端等的是别的字段名 | **从 REQ 日志反推真实请求体**,不靠猜;穿戴/卸下(itemID=0)都持久化 |
      | 13 | 玩家反馈 A 实际发生 B(买A到账B) | 商店族多接口共用处理器,参数语义不同(商城发商品ID vs 杂货铺发物品ID) | 每个购买接口**独立实现**并核对客户端 `reqBuy` 的字段含义 |
      | 14 | 主线卡死在"找XX对话" | 送物型任务需要背包有剧情物品(SpRequire),接取时没发 | 接任务发放 + 交任务收回 + **LoadGame 自愈**(每次登录对账补齐)三层兜底 |
      | 15 | 改了 A 功能 B 功能坏了 | 存档读-改-写竞态:中途另一处 `_save_store(旧快照)` 回滚了新写入 | 同一请求内**改完立即落库**,或统一"重读→合并→写";禁止持旧引用跨函数写 |
      | 16 | 崩溃无 JS 报错、无法定位 | 多个新字段同时上线,崩在加载早期(错误上报都未初始化) | **二分排除法**:撤下全部新增字段→能进→逐个加回;每次只验证一个 |
      | 17 | 提取的 JS 行号与崩溃栈对不上 | 解出的脚本与设备实际运行的 **APK 版本有偏差** | 行号只做参考;**函数名 + grep 内容**定位;拿设备同版 APK 复核 |
      | 18 | 服务器表和客户端表互相怀疑 | 双方各持一份配置表 | 用 APK 解包比对(`zipfile` + uuid 解码定位 `startres/import/**.json`;`.mbin`=msgpack、语言表=lz-string);本项目实测 35/6310/106768 条**零差异**,据此排除"表不一致"假设 |
      | 19 | 金条商城"弹窗对物品不对" | 客户端本地表自加货 + 服务端按自己表再发一份(note绝对值覆盖) — **双表版本差异** | 服务端只扣钱, 货物客户端自加(读回调确认谁加货); 详见 live-ops.md §3 |
      | 20 | 接口落保底桶 eRet=0 | 注册键名拼写错(getalchemytdata 多了个t) | 自定义键与接口清单**逐字符核对** |
      | 21 | 回血永远到不了真实上限 | 客户端发的 maxhpLimit 是本地缓存旧值, 服务端拿它当上限还覆盖存档 | 上限以存档(战斗hpmax同步值)为准; 自愈只清精确作弊值 |
      
      > 与 Unity/UE 最大的不同在 **②**:cocos 脚本层信息量大但**客户端更脆**,
      > 且"服务端返回 200"与"客户端能消费"之间落差更大,**验证必须更严**(见 `closure-verification.md`)。
      
      ---
      
      ## H2. 进服之后的 Live 运营经验(本项目 v28~v33 实践)
      
      > 引擎无关的完整方法论已抽出为 `live-ops.md`(子系统次序/契约反推法/双表版本差异/
      > 客户端本地状态/事件时机/自愈/后台工具)。本节保留 cocos 专属细节。
      
      > 进服只是开始。以下是把"能进游戏"做成"能长期玩"的关键机制,全部来自本项目线上实跑。
      
      ### H2.1 note 变更通知协议(cocos RPC 的"推送")
      
      客户端对**带 note 的响应**要求**双层包裹**,否则 note 子事件永远不派发:
      
      ```json
      {"p": "User.UseItem", "User.UseItem": {"eRet":1, "note": {"ivch": {...}}}}
      ```
      
      - note 键 → 客户端事件:`ivch`(物品, **绝对数量**) / `eqch`(装备实例) / `skch`(技能 [lv,pgr,brlv]) / `eco`(货币, 绝对值) / `attrch`(血蓝/修为/饥饿)
      - 值是**绝对值不是增量**——发增量会导致客户端显示翻倍
      - 没有双层包裹 = 客户端收到数据但不刷新 UI(表现是"接口没生效")
      
      ### H2.2 客户端本地存档与服务端历史的合并
      
      cocos 客户端把任务完成史存在 localStorage(key 如 `<roleID>$#&A`)。
      LoadGame 回包若带 `finishTaskObj`(哪怕是**空数组**)会**整体覆盖**本地历史。
      客户端会在请求里上报本地数量(`finishTaskFlag` / `taskFlag`)——
      **服务端只在自己更全时才下发该字段,否则省略键**。省略键 ≠ 发空值。
      
      ### H2.3 位置数组编码(baseValueAy 类)
      
      角色属性是**按位置编码的数组**(不是字典)。字段序号即语义:
      本项目 `[27]=hp [28]=hpLimit [29]=mp [30]=mpLimit [31]=饥饿 [34]=潜能 [35]=铜钱 [37..40]=四维 [42]=年龄 [47]=出师门派列表`。
      **坑**:个别位置要求特定类型([47] 必须是数组,填 0 → 客户端 `.indexOf` 崩)。
      给位置数组补默认值时,逐位核对客户端解析代码的类型期望。
      
      ### H2.4 后台即排障工具
      
      给管理后台做这些能力,线上问题分钟级闭环(都按账号分桶):
      - 角色全量数据查看(roledata API:背包/装备/穿戴/任务/货币/技能)
      - 物品/装备/技能/称号发放(**装备走实例、伪物品走属性路由**)
      - **强制完成任务**(卡任务救援:标记完成+收回剧情物品+发奖励)
      - 货币与经验直设
      - 兑换码(暗号)系统
      经验类数值**不能在登录回包推送**(见坑 10),通过"打一场战斗→结算同步"到账。
      
      ### H2.5 部署纪律
      
      - 部署脚本向 `/tmp` 追加式传文件 → **先 `rm -f` 再传**(本项目曾因拼接出"双模块缝合"服务端,编译通过但行为怪异)
      - 传完 **md5 对账** 本地与远端
      - 服务起不来时部署脚本会中断 → 准备 **SFTP 直传 + 手动 start** 的应急路径
      - 改动前保留"最后一次可进服"版本的副本与回包大小记录(回包大小突变=报警)
      
      ### H2.6 多账号隔离的兜底自查
      
      任何"按账号分桶"的存储,上线前跑一遍双账号用例:
      A 穿装备/学技能/做任务 ≠ 影响 B 的对应数据;
      新注册号走建角流程(quicklogin 回 `userName:''` 触发客户端建角,**绝不回退 default 存档**——
      本项目曾因新号回退 default 拿到别人角色导致跳过建角)。
      
      ---
      
      ## I. 工具与命令速查
      
      ```bash
      # 判引擎
      unzip -l app.apk | grep -E 'libcocos|\.jsc|\.lua|manifest'
      # 找地址/域名/端口
      grep -aoE '(https?|wss?)://[^"'\'' ]+|[0-9]{1,3}(\.[0-9]{1,3}){3}:[0-9]+' libcocos2djs.so | sort -u
      # 脚本定位
      grep -n 'HttpConst\.\|MessageCenter.on\|loadGame' assets/src/scripts*.jsc.js | head
      # 配置表还原
      python3 -c "import json;zh=json.load(open('lantab_zh.json'));print(zh.get('506'))"  # → 金条
      ```
      
      ---
      
      ## J. 一张图:cocos 分支的最快路线
      
      ```
      解 APK → 判 libcocos2djs/2dlua → 解脚本(.jsc/.lua)
      → grep HttpConst.* 拿 RPC 全集
      → 找 AES(key/iv) + 明文壳(mod/do/p + MsgData)
      → 起 HTTP-RPC 服务端(逐 do 回 eRet:1 + 回调要的字段)
      → 真机进服(以"下一条请求"为验收) → 再逐功能补数据
      ```
    • codegen.md 4.9 KB
      # 由 Spec 生成/改造服务端(Codegen & Adaptation)
      
      > 原则:**参考实现提供套路,Spec 提供答案。**
      > 不同游戏 → 不同语言、不同结构、不同协议参数。
      
      ---
      
      ## A. 先选技术栈(不要默认 Python)
      
      | 目标特征 | 推荐栈 | 现实参考 |
      |---------|--------|---------|
      | 快速验证 / 脚本化 | Python (asyncio) | 本仓库 `server/` |
      | 高并发长连接 / 要发布二进制 | Go | **案例 A**(某 Unity 手游,Go 服务端 + 预编译三端) |
      | HTTP + 内容分发 / 前后端同栈 | Node.js / TypeScript | **案例 B**(某 Cocos 手游,Node/TS + React 后台) |
      | 极致性能 / 二进制协议复杂 | Rust / C++ | 与 UE/C++ 客户端语义贴近 |
      | 客户端是 Java 生态 | Java/Kotlin (Netty) | 复用客户端协议库 |
      | 已有 C# 资产 / 桌面启动器 | C# (.NET) | 案例 A 的 WinForms 启动器即 C# |
      
      > 参考实现 `server/` 是 **Python/asyncio** 的一种落地;换成 Go/TS/Rust 时,
      > **分层结构与 Spec 不变**,只是语法和并发模型不同。
      > 两个真实案例见 `cases.md` —— 它们证明"选型可以完全不同"。
      
      ---
      
      ## B. 通用分层(任何语言都按这个骨架搭)
      
      ```
      接入层  监听 / 连接限制 / 心跳清理
      会话层  每连接状态机 + 上下文(uid/cid/state)
      分发层  opcode -> handler 路由
      编解码  长度头 + opcode + 解密 + 解压 + 反序列化
      协议层  opcodes 表 + message encode/decode
      逻辑层  各业务 handler
      存储层  账号/角色/存档
      运维层  日志 / 指标
      ```
      
      **换游戏时,只有「编解码参数」和「协议层内容」变,骨架不变。**
      
      ---
      
      ## C. 从 Spec 生成代码的步骤
      
      1. **读 Spec** → 生成 `opcodes` 常量表
      2. 生成 `messages` 的 encode/decode(按字段类型映射)
      3. 生成 `codec`(按 frame/crypto/compress)
      4. 生成 `state_machine` 骨架 → 每个状态注册 handler 桩
      5. 填业务逻辑(登录/角色/场景…)
      6. 跑闭环验证 → 回填 Spec
      
      ### C1. 字段类型映射表
      
      | Spec type | Python | Go | Rust | Node |
      |-----------|--------|----|------|------|
      | u8/i8 | `B`/`b` | uint8/int8 | u8/i8 | Buffer.readUInt8 |
      | u16/i16 | `H`/`h` | uint16/int16 | u16/i16 | readUInt16LE |
      | u32/i32 | `I`/`i` | uint32/int32 | u32/i32 | readUInt32LE |
      | u64/i64 | `Q`/`q` | uint64/int64 | u64/i64 | readBigUInt64LE |
      | f32/f64 | `f`/`d` | float32/64 | f32/f64 | readFloatLE |
      | str(len-prefix) | 手写 | 手写 | 手写 | 手写 |
      | bytes(fixed) | slice | []byte | &[u8] | slice |
      
      ### C2. 服务端骨架生成(伪代码)
      ```
      for op in spec.opcodes.table:
          if op.dir == c2s:
              register_handler(op.value, handler_stub(op.name))
      ```
      
      ---
      
      ## D. 改造参考实现的「影响矩阵」
      
      | 你若改了 Spec 的… | 就要动参考实现的… |
      |------------------|-----------------|
      | transport.type | `net/server.py`(换协议栈) |
      | frame.length_size / endian | `config.yaml` + `codec.py` |
      | frame.opcode_size | `config.yaml` + `codec.py` + `messages` 的 opcode 常量 |
      | crypto.algorithm / key_source | `net/crypto.py` + 握手/登录逻辑 |
      | compress.* | `codec.py` |
      | serialize.format | `codec.py` + `messages.py` |
      | opcodes.table | `proto/opcodes.py` 整表 |
      | messages.* | `proto/messages.py` 逐条 |
      | state_machine | `logic/handlers/*` 注册与状态校验 |
      
      ---
      
      ## E. 增量策略(强烈建议)
      
      ```
      阶段1  连接 + 握手 + 心跳          ← 先确认 frame/crypto 正确
      阶段2  登录 + 账号                 ← 确认鉴权与 token
      阶段3  角色列表 + 选角             ← 确认长连接消息序
      阶段4  进场景 + 移动 + 广播        ← 确认状态机与并发
      阶段5  充值 + 邮件 + 商店          ← 业务闭环
      ```
      
      每个阶段:改 Spec → 改代码 → 跑 `client_test.py` 式的联调 → 回填置信度。
      
      ---
      
      ## F. 多语言参考实现路线(建议)
      
      | 参考 | 语言 | 适用 | 状态 |
      |------|------|------|------|
      | `server/` | Python/asyncio | 通用、快速 | [x] 已提供并实测 |
      | `server-go/` | Go | 高并发/预编译发布 | 按需生成(参考案例 A) |
      | `server-ts/` | Node/TypeScript | HTTP + 后台 | 按需生成(参考案例 B) |
      | `server-cs/` | C#/.NET | 客户端同生态 | 按需生成 |
      
      > AI 应根据 Spec 的 `transport` 与 `serialize` 选择最合适的参考,
      > 必要时**新建**一个该语言的实现,而不是硬套 Python。
      > 真实世界的选型差异见 `cases.md`。
      >
      > **发布形态提示**:若目标是给普通用户使用,应构建**预编译多平台二进制 + 启动器**,
      > 而不是让用户装运行时(案例 A 的做法)。
      
      ---
      
      ## G. 自律条款
      
      1. 不把参考实现的**协议常量**当作目标的协议常量。
      2. 生成代码前必须先有 Spec(哪怕是占位版的)。
      3. 每次代码变更,同步更新 Spec 的 `confidence` 与 `evidence`。
      4. 交付物 = Spec + 代码 + 验证记录 + 回滚方案。
    • combat.md 3.8 KB
      # 房间与战斗(局内)
      
      > 核心玩法层。参考实现:`server/app/logic/rooms.py` + `logic/handlers/{room,battle}.py`。
      
      ---
      
      ## 1. 层次关系
      
      ```
      大厅/场景 → 房间(ROOM) → 战斗(BATTLE) → 结算 → 掉落 → 背包/邮件
      ```
      
      - **房间**:局内容器(组队/匹配),与大厅解耦
      - **战斗**:房间内的一次对局
      - **结算/掉落**:战斗产出(见 `drops.md`)
      
      ---
      
      ## 2. 房间
      
      ### 2.1 生命周期
      ```
      创建(create) → 加入(join) / 退出(leave) → 准备(ready) → 房主开始(start)
            → BATTLE → 结算 → 回到 WAITING(或解散)
      ```
      
      ### 2.2 必备规则
      | 规则 | 说明 |
      |------|------|
      | 槽位上限 | 满员拒绝加入 |
      | 房主权限 | 只有房主能开始 |
      | 准备检查 | 非房主需全部准备 |
      | 房主退出 | **移交给下一个成员**,而非解散 |
      | 空房回收 | 无人即删除,防泄漏 |
      | 战斗中禁止加入 | `state != WAITING` 时拒绝 |
      
      ### 2.3 要反推的点(从客户端)
      - 房间/队伍**结构体字段**(槽位、UI 显示的等级/头像)
      - **开始条件**(几人?是否全部准备?是否有职业要求?)
      - opcode 与字段(`ROOM_*`)
      
      ---
      
      ## 3. 战斗
      
      ### 3.1 同步模型(先判定用哪种)
      
      | 模型 | 特征 | 服务端职责 | 典型 |
      |------|------|-----------|------|
      | **回合制** | 客户端发"我要出X",等结果 | 算结果→广播 | 卡牌、回合 RPG |
      | **状态同步** | 服务端持权威状态,客户端插值 | 定状态→下发 | MMO、动作 |
      | **帧同步(lockstep)** | 只同步输入,各端同算 | 收集输入→广播 | RTS、MOBA |
      
      > **判定方法**:抓包看客户端发的是"**意图**"(我要攻击)还是"**结果**"(我造成100伤害)。
      > 发结果 = 客户端算 → 危险,服务端要重算(客户端保护)。
      
      ### 3.2 铁律
      
      1. **结算在服务端**。客户端只发意图。
      2. **状态以服务端为准**(客户端只做表现/预测)。
      3. **异常处理**:掉线、超时、中途退出都要有结果(不能卡死)。
      
      ### 3.3 必备要素
      - 回合推进 / 行动队列
      - 伤害与血量计算(服务端)
      - 胜负判定 + **超时判负**
      - 玩家异常退出 → 由 Bot 接管或判负(见 `extensions/bots.md`)
      
      ---
      
      ## 4. 协议(参考实现)
      
      ```
      ROOM_CREATE_REQ  0x0601 → ROOM_CREATE_RES  0x0602
      ROOM_JOIN_REQ    0x0603 → ROOM_JOIN_RES    0x0604
      ROOM_LEAVE_REQ   0x0605 → ROOM_LEAVE_RES   0x0606
      ROOM_READY_REQ   0x0607 → ROOM_READY_RES   0x0608
      ROOM_START_REQ   0x0609 → ROOM_START_RES   0x060A
      ROOM_INFO_NTF    0x060B                       房间信息广播
      BATTLE_ACTION_REQ 0x0701 → BATTLE_ACTION_RES 0x0702
      BATTLE_STATE_NTF  0x0703                      战斗状态广播
      BATTLE_RESULT_NTF 0x0704                      结算 + 掉落列表
      ```
      
      ---
      
      ## 5. 客户端的角色(反推要点)
      
      | 要反推 | 在哪看 |
      |--------|--------|
      | 房间槽位与字段 | 房间/队伍结构体 |
      | 开始条件 | 开始按钮的前置校验 |
      | 战斗动作类型 | 动作枚举(攻击/技能/道具) |
      | 状态字段 | 血量/回合/ buff 结构体 |
      | 结算界面字段 | 胜负、掉落展示列表 |
      
      ---
      
      ## 6. 落地顺序
      
      ```
      1. 房间:建房→加入→准备→开始(先允许单人开始便于调试)
      2. 战斗:行动 → 服务端算 → 广播状态(先不做动画)
      3. 结算:胜负 + 超时
      4. 掉落:接 drops.md
      5. 多人联调:多开客户端验证广播
      6. 更新 TRACKER.md
      ```
      
      ---
      
      ## 7. 常见坑
      
      - 客户端发"结果"而不发"意图" → 必须服务端重算,否则可作弊。
      - 房间状态机缺失 → 战斗中还能加入/退出,状态错乱。
      - 房主退出没移交 → 房间锁死。
      - 战斗中掉线没处理 → 对局永久卡住。
      - 结算与掉落不在同一事务 → 出现"赢了没奖励"。
    • decision-tree.md 10 KB
      # 决策树:逐层判定协议
      
      > 用法:对目标项目逐层走树,把结果写进 `protocol.spec.yaml`。
      > 每个节点都是「若…则…」,**不要默认套用参考实现**。
      
      ---
      
      ## 0. 路径分叉:外部服务端还是内联服务端
      
      ```
      能注入 / 能改客户端(DBG / MANIP)?
       ├─ 是 ──▶ 目标只是单机化 / 离线可玩?
       │           ├─ 是 ──▶ 【内联服务端】inline-server.md(不起外部服务端)
       │           └─ 否 ──▶ 【外部服务端】继续走 §1
       └─ 否 ──▶ 【外部服务端】继续走 §1
      ```
      
      > 内联路线**跳过 §2~§4**(不碰传输 / 封装 / 加密),直接从 §5 的「业务协议层」切入。
      > 判据与取舍见 `inline-server.md` §0;响应对象怎么造见 `runtime-object-synthesis.md`。
      
      ---
      
      ## 1. 引擎 / 客户端语言层
      
      ```
      有 libil2cpp.so 或 GameAssembly.dll ? ──是──▶ Unity IL2CPP(C#,只有签名)
          └ 有 global-metadata.dat ? 是→标准; 否→metadata 加密,需解密
          └ 有 Assembly-CSharp.dll ? ──是──▶ Unity Mono(C#,可反编译出源码)
      有 *.swf / *.abc / Starling 痕迹 ? ──是──▶ ActionScript3 / Flash(JPEXS FFDec)
      有 libUE4.so / libUnreal.so / *.pak / *.utoc ? ──是──▶ Unreal Engine
          └ 有 .usmap / SDK dump ? 是→直接用; 否→需 Dumper-7/UE4SS
      有大量 .lua/.luac ? ──▶ Unity + Lua 热更(业务可能在 Lua)
      有 classes.dex 且业务在 Java/Kotlin ? ──▶ 原生 Android(jadx)
      有 *.js/webpack/sourcemap ? ──▶ JS / H5
      有 libcocos2d*.so / assets/src/*.js ? ──▶ Cocos
      *.dll 但非 Unity(Xamarin/.NET 应用)? ──▶ C# 桌面/移动(dnSpy 直读)
      都没有 ? ──▶ 自研引擎,走纯抓包 + 二进制分析
      ```
      
      > 客户端语言直接决定**反编译工具**与**能否拿到源码**。
      > 详见 `client-languages.md`。C# Mono / AS3 是「能拿到源码」的两种最舒服情况。
      
      ## 2. 传输层
      
      ```
      抓包有 TCP 三次握手 ? ──是──▶ TCP
          └ 有明显 UDP 流 ? ──是──▶ 看第 3 节 KCP 判定
      有 TLS ClientHello ? ──是──▶ HTTPS/WSS(需处理证书固定)
      端口 80/443 且负载文本 ? ──▶ HTTP/WS,优先直接抓明文
      ```
      
      ## 3. 封装层(帧边界)
      
      ```
      UDP 流,且每包前 4 字节像是 conv ? ──是──▶ 极可能 KCP(见 3.1)
      TCP 流,第 1 个字段是长度 ?
          ├─ 1/2/4 字节 ?         → frame.length_size = 1/2/4
          ├─ 大端还是小端 ?        → 用数值合理性判定(长度<负载)
          └─ 长度是否含头部自身 ?   → 用两条不同长度包反推
      无显式长度,用分隔符(如 \r\n) ? ──▶ frame.delimiter
      protobuf 无长度前缀(流式)?    → 需 varint 解析
      ```
      
      ### 3.1 KCP 判定特征
      - 头 24 字节:`conv(u32) cmd(u8) frg(u8) wnd(u16) ts(u32) sn(u32) una(u32) len(u32)`
      - cmd ∈ {81=push,82=ack,83=wask,84=win,85=cmd}
      - 频繁小包、快速重传、ts 递增
      
      ## 4. 加密 / 压缩层
      
      ```
      负载熵值 ?
        ├─ 接近 8 bits/byte(高熵) → 加密 或 已压缩
        └─ 有明显结构(低熵)       → 明文,直接看序列化
      头字节特征 ?
        ├─ 78 9C / 78 01 / 78 DA → zlib 压缩,先解压
        ├─ 1F 8B                 → gzip
        └─ 无规律                → 疑似「加密 或 raw-deflate」→ 先走 4.0 快筛
      加密算法猜测顺序(用已知明文/长度关系验证):
        单字节 XOR → RC4 → AES-CBC/ECB → 自定义(XXTEA/魔改)
      密钥来源判定:
        ├─ 硬编码常量        → 从二进制/Lua 里找
        ├─ 握手协商          → 看握手包是否传 key/种子
        ├─ 登录返回          → 登录响应里含密钥字段
        └─ 设备指纹派生      → 少见,需动态 dump
      ```
      
      > 注意: 「高熵」不等于加密,也可能是压缩;**先试解压,再试解密**。
      
      ### 4.0  raw deflate 快筛(谈加密之前必做,血泪教训)
      
      **真实事故**:一条 TCP 长连接流被判定为「流加密」——首包头 14B + 尾 57B 跨会话
      完全相同("静态密钥填充")、中段 37B 每会话变化("会话密钥交换");RC4 置换表
      内存扫描 0 命中、全部候选密钥试解失败。**最终答案:raw deflate(zlib `wbits=-15`),
      根本没有加密。**"静态头尾" = 相同明文前缀/后缀的固定压缩输出;
      "变化中段" = 压缩过程对变化内容(token/时间戳)的正常响应。
      
      游戏客户端压缩最常用的就是 **raw deflate(无 78 9C magic 头)**,
      表现与流加密几乎一模一样。30 秒排除它:
      
      ```python
      import zlib
      stream = open('reverse/world_C2S.bin','rb').read()
      plain = zlib.decompressobj(-15).decompress(stream)   # wbits=-15 = raw deflate
      # 解出来了 → 没有加密;抛 zlib.error → 再按下面的表试其他无 magic 压缩
      ```
      
      zlib 失败后的其他无 magic 压缩候选(按序试):
      
      | 格式 | 判定法 |
      |------|--------|
      | raw deflate | `decompressobj(-15)`(多数引擎首选) |
      | zlib 带 preset dict | `decompressobj()` 抛 `need dictionary` → 从握手包/客户端找 dict |
      | LZ4 block | 无 magic;python-lz4 `decompress` 试解 |
      | snappy | 无 magic;python-snappy 试解 |
      | zstd | v0.9+ 有 magic `28 B5 2F FD`;老格式无 magic,用 zstd 库试解 |
      
      **确认为双向压缩流后,服务端 codec 三要点**:
      1. **decompressobj 全程增量喂**(`dec.decompress(chunk)` 逐段调用)——zlib 流跨
         TCP 段,不能攒整包解;中途出错**不能重建 decompressor**(流状态已毁)。
      2. 压缩侧 `compressobj(wbits=-15)` 输出累积后直接 send,**不要逐帧 flush**
         (会切断压缩上下文、效率也差)。
      3. 解压出的明文再按封装层切帧。注意:**同游戏多类服务器常共用同一种帧格式,
         只有传输层滤镜不同**(本例:center=明文,world=raw-deflate,帧格式完全一致)。
      
      ### 4.1 加密假信号(别被差分分析骗了)
      
      跨会话差分(两次连接的密文 XOR 对比)是判定加密的常用手段,但以下情形制造假信号:
      
      | 假信号 | 真相 | 识别 |
      |--------|------|------|
      | 头尾跨会话相同 | 压缩输出:相同明文前缀压出相同字节 | 4.0 快筛 |
      | 中段每会话变化 | 压缩对变化内容(token/时间戳)的正常响应 | 解压成功 |
      | 内存搜到"密钥样"字节串 | 时区库/资源表等无关数据(实测踩坑) | 用已知明文(角色名/域名)验证 |
      
      真正的流加密差分特征:**连固定结构字段(长度头)每次都不同**;
      压缩流的小包压缩后依然短小、且与明文结构相关。
      **任何"疑似加密"先过 4.0 快筛,再上 Ghidra/动态分析。**
      
      ## 5. 序列化层
      
      ```
      字符串可读(键值对) ? ──▶ JSON
      字段 tag 形如 (field<<3|wire) ? ──▶ protobuf(用 protoc --decode_raw)
      有 0x93 等 msgpack 标记 ? ──▶ MessagePack
      定长字段、无分隔、含 int32 长度前缀字符串 ? ──▶ 自定义二进制
        └ 对照客户端结构体字段顺序还原
      ```
      
      ## 6. 消息号 / 分发
      
      ```
      客户端有 switch(opcode)/Dictionary<id,handler> ? ──▶ 直接抄消息号表
      Lua 里有 sendMsg(cmd, ...) ?                ──▶ 从 Lua 抄
      UE 有 ProcessEvent / RPC 表 ?               ──▶ 从 SDK dump 抄
      都拿不到 ?                                   ──▶ 由抓包首字段聚类反推
      ```
      
      ## 7. 架构层(服务端形态)
      
      ```
      目标只是"能跑通" ? ──▶ 单进程 Python/Node(参考实现套路)
      要长期在线/多人 ?   ──▶ Go/Rust + 网关/逻辑分离 + Redis
      客户端强制 HTTP ?    ──▶ HTTP 网关(FastAPI/Express)+ 长连接网关混合
      协议是 UE 原生复制 ? ──▶ 需实现 UE 的 NetDriver 语义(难度高,优先 Mock 关键包)
      要发布给普通用户用 ? ──▶ **预编译多平台二进制 + GUI 启动器**(参考案例 A)
      有战斗/大厅分离 ?    ──▶ 多端口(主端口+1 战斗,+2 后台)
      ```
      
      ### 7.1 通用子系统清单(无论什么游戏都要想)
      
      | 子系统 | 说明 | 常见形态 |
      |--------|------|---------|
      | 账号 | 注册/登录/token | SQLite/MySQL |
      | 存档 | 进度持久化 | SQLite 文件 |
      | 后台 | 运营/发奖/封号 | HTTP,仅本机端口 |
      | 资源分发 | 客户端下载资源 | 本地目录 / CDN |
      | 端口约定 | 主/战斗/后台 | 主、主+1、主+2 |
      | 客户端对接 | 改地址/跳过登录 | 配置文件 / 源码 patch |
      | 发布打包 | 给用户的东西 | 预编译 exe + 启动器 |
      | **人机(假玩家)** | **多人游戏凑人数** | 服务端内部逻辑 Bot(`bots.md`) |
      
      > 详细案例见 `cases.md`(Go 服务端、Node 服务端各一例)。
      
      ### 7.2 人机(Bot)判定 ——  属后续拓展
      
      > 人机**不影响核心运行**(见 §17)。这里只在"确认要做"时用。
      
      ```
      目标游戏是否"必须多人才能开局" ?
         ├─ 否 ──▶ 不需要(核心链路里直接跳过)
         └─ 是 ──▶ 仍属拓展:先跑通核心,再做
                    ├─ 客户端有 IsAI/robot 字段 ? ──有──▶ 可按官方字段复刻
                    └─ 完全没有 AI 痕迹 ? ──▶ 降低开局门槛或 patch(标注)
                    再判归属:客户端有决策代码 ? ──▶ 客户端AI(服务端只标记槽位)
                                                └──▶ 服务端AI(服务端驱动动作)
      ```
      
      详见 `extensions/bot-reverse.md` 与 `extensions/bots.md`。
      
      ## 8. 决策输出模板
      
      对每层写一行到 Spec:
      ```yaml
      transport: { type: tcp, evidence: E03, confidence: high }
      frame:     { length_size: 2, length_endian: little, includes_self: false,
                   opcode_size: 2, evidence: [E03,E07], confidence: high }
      crypto:    { enabled: true, algorithm: xor, key_source: hardcoded,
                   evidence: E09, confidence: medium }
      serialize: { format: binary, endian: little, evidence: E11, confidence: high }
      opcodes:   { source: dump.cs, count: 137, evidence: E02, confidence: high }
      ```
      
      > 任何 `confidence: low/medium` 的项,都必须在闭环验证阶段重点核对。
    • drops.md 3.1 KB
      # 掉落与物资(Drop)
      
      > 参考实现:`server/app/logic/loot.py` + `logic/handlers/drop.py`。
      
      ---
      
      ## 1. 掉落表(配置驱动)
      
      ```yaml
      table_id:
        name: "新手森林"
        rolls: 2                 # 掷几次
        entries:
          - {item_id: 1001, count: [1,3], rate: 0.80}   # 80% 掉 1~3 个
          - {item_id: 2001, count: [1,1], rate: 0.10}   # 10% 掉 1 个
      ```
      
      - **真实项目**:掉落表在客户端配置表里(`drop_*.csv` / `drops.json` / TextAsset),
        反推后填进服务端。
      - 参考实现的 `DROP_TABLES` 只是示例,**必须替换成目标的真实表**。
      
      ---
      
      ## 2. 三条铁律
      
      1. **服务端掷骰**:客户端只收结果,绝不能自己决定掉什么。
      2. **入库可追溯**:掉什么、给谁、什么时候给,都要能查(`oplog`)。
      3. **不丢物资**:任何情况下掉落都不能凭空消失 → 装不下就**转邮件**。
      
      ---
      
      ## 3. 发放流程
      
      ```
      战斗胜利 / 开箱 / 任务奖励
              ↓
        服务端掷骰 roll_drops(table_id)
              ↓
        发放 grant_items(cid, items)
              ├─ 背包该物品已存在 → 叠加
              ├─ 背包还有空位     → 新增一格
              └─ 背包已满         → 转邮件补发(send_system_mail)
              ↓
        下发 DROP_NTF(通知客户端刷新)
      ```
      
      ---
      
      ## 4. 分配方式(多人在同一局)
      
      | 方式 | 说明 | 适用 |
      |------|------|------|
      | **各得一份** | 每人独立掷骰/各拿一份 | 大多数手游 |
      | 共享掉落池 | 掷一次,玩家分配/Roll 点 | MMO、副本 |
      | 队长分配 | 归属队长再分配 | 硬核 MMO |
      
      参考实现用「各得一份」,按需替换。
      
      ---
      
      ## 5. 背包满了怎么办(重要)
      
      ```
      背包有位置 → 直接进包
      背包满了   → 自动发邮件(附件=该物资),并在邮件里说明原因
      ```
      
      - 邮件是**兜底**,保证玩家不亏。
      - 参考实现:`loot.grant_items()` 自动判断并调用 `handlers/mail.send_system_mail()`。
      - 邮件领取逻辑见 `server/app/logic/handlers/mail.py`。
      
      ---
      
      ## 6. 要反推的点(从客户端)
      
      | 要反推 | 在哪看 |
      |--------|--------|
      | 掉落表 | 资源里的 `drop_*` / 配置表 |
      | 物品 id 与名称 | 物品配置表 |
      | 背包结构 | 背包结构体、下发字段 |
      | 背包上限 | 配置里的 `bag_max` |
      | 掉落展示 | 结算界面字段(决定 `BATTLE_RESULT_NTF` 的字段) |
      | 拾取方式 | 自动入包 / 手动拾取(决定是否需要 `PICK` 消息) |
      
      ---
      
      ## 7. 协议(参考实现)
      
      ```
      DROP_TEST_REQ 0x0801 → DROP_TEST_RES 0x0802   按表试掷(调试用)
      DROP_NTF      0x0803                          掉落通知(含转邮件数)
      BAG_LIST_REQ  0x0804 → BAG_LIST_RES  0x0805   背包列表
      ```
      
      ---
      
      ## 8. 常见坑
      
      - 掉落表写死示例值 → 与实际不符,玩家拿不到该拿的东西。
      - 客户端自己算掉落 → 可作弊,必须服务端算。
      - 背包满直接丢弃 → 玩家投诉,必须转邮件。
      - 掉落与战斗结算不同步 → 赢了没奖励 / 重复发奖。
      - 缺幂等 → 重连/重发导致**重复掉落**(用唯一订单/结算 id 去重)。
    • engineering-practices.md 15.9 KB
      # 工程范式:把「实验室能跑」做成「真的能跑」
      
      > **来源**:一个**已实机跑通**的成品服务端实现(客户端为 Unity IL2CPP 卡牌手游,
      > 服务端 Python,本机部署)。本文只抽象**通用做法**,不含任何具体项目的协议参数。
      >
      > 配套模板:`templates/adr-template.md`、`templates/e2e-evidence-template.md`。
      > **实现代码怎么写** → 见 `wire-level-patching.md`(本文件只讲范式与流程)。
      
      ---
      
      ## 目录
      
      > 0 一句话 · **1 Fixture 回放法 ** · 2 显式「完成点」 · 3 证据三档 · 4 端到端证据包 · 5 ADR · 6 状态模型 · 7 原始账号数据泄漏 注意: · 8 TODO/TRACKER · 9 测试与运维配置 · 10 应固化结论 · **11 回放帧定点改写陷阱 **
      
      ---
      
      ## 0. 一句话
      
      > 大多数 AI 反推项目**死在同一件事**:把「协议形状对上」当成「游戏能进」。
      > 而这类**跑通的项目**的共同做法是:把**真实抓包变成可回放的 fixture**,
      > 用**一个明确的完成点**做验收,并把**证据分档**写进文档。
      
      对比一下:
      
      | | 常见失败做法 | 跑通项目的做法 |
      |---|---|---|
      | 起服验证 | 自环脚本 `OK` | **真实设备**走到某个具名界面 + 截图 + logcat |
      | 启动数据 | 自己拼 protobuf | **抓包 fixture 原样回放**,只定点改字段 |
      | 未知字段 | 猜 / 丢掉 | **按原始 wire body 原样保留** |
      | 文档结论 | “已实现” | `Confirmed by…` / `Inferred…` **三档标注** |
      | 边界 | 什么都往回指 | 各条链路**分开验证**,热更/CDN **保留原站** |
      
      ---
      
      ## 1.  Fixture 回放法(最值钱的一招)
      
      **问题**:客户端启动时要一次性吃下 `25×4 / 26 / 27 / 28`(几百字节的 protobuf
      嵌套结构:角色、背包、任务、VIP、签到…)。纯靠反推字段拼,几乎必卡在某个页面。
      
      **做法**:
      
      ```
      1. 用【未修改的原版客户端】连【原版服务器】,被动抓一次完整启动序列(pcap)
      2. 把重组后的帧序列落成 fixture(JSON:方向 / 消息号 / seq / flag / body 原始字节)
      3. 本地服务端按 fixture 的【原始顺序】重放这些帧
      4. 未知字段一律【原样保留】,只对少数已确认字段做 wire-level 定点改写
      ```
      
      **定点改写的粒度**(这是关键):
      
      > “只对含 field 4 `RoleBase` 的 `25` 做 wire-level 钻石替换,
      > 未知字段和其他启动数据保持原样。”
      
      即:**不重新序列化整个消息**,只在原始字节流里替换那一个字段的值。
      好处是**不需要理解 99% 的字段**就能让客户端正常跑。
      
      **配套工具**(值得照抄的形态):
      - `tools/recover_game_tcp_protocol.py`:扫反编译程序集,校验协议定义
      - `tools/analyze_game_tcp_pcap.py`:pcap 拆帧
      - `server/fixture_tool from-capture`:从被动抓包生成 fixture
      
      **明确的边界纪律**:
      > 不要构造“缺字段的伪造启动对象”代替真实启动数据 —— 客户端后续页面会因缺
      > 任务/商店/VIP/背包状态而继续阻断。
      > 一个账号的 fixture **不能直接给所有账号用**(里面有真实 UID/角色数据)。
      
      **落地检查表**:
      - [ ] fixture 里保留 `seq/flag` 与**帧顺序**,不只保留 body
      - [ ] 允许同一消息号出现**多个分片**(同一消息号分多片下发很常见)
      - [ ] 未知字段原样透传
      - [ ] 改写只发生在**已确认字段号**上
      - [ ] fixture 与账号身份显式映射,**禁止按账号名猜角色**
      
      ---
      
      ## 2.  显式「完成点」做验收
      
      不要写「登录成功」。要写**客户端上的一个可观察状态**:
      
      ```
      server/list → SDK token 校验 → CSLoginReq(3)
        → SCHandShakeNtf(7) → SCLoginAck(4)
        → SCStartupInfoNtf(25)×4 → SCStartupInfoEquipNtf(26)
        → SCStartupInfoHeroNtf(27) → SCStartupInfoEndNtf(28)
        → 主界面初始化请求 → 主界面
      ```
      
      **验收判据(原话级别的严格)**:
      
      > “设备最终停留在**游戏主界面**,**不是只保持 TCP 连接**。”
      
      并且要**做两次**(含强制停止后重启 + 重连),确认状态持久化。
      
      > 对应到本 skill 的 `TRACKER.md`:状态列里
      > 「TCP 连上」≈ [~],「收到完成点消息并进入下一界面」才允许 [x]。
      
      ---
      
      ## 3. 证据三档标注(写文档的固定词汇)
      
      | 标注 | 含义 | 来源 |
      |---|---|---|
      | **Confirmed by static analysis** | 反编译代码 / protobuf 定义 / 服务端代码直接确认 | `dump.cs`、`*.cs`、`protoMsgId.cs` |
      | **Confirmed by packet capture or runtime observation** | pcap、重组帧、logcat、运行时数据库直接确认 | 抓包 / 设备日志 |
      | **Inferred and still requiring validation** | 由前两者推断,仍需实机验证 | 推断 |
      
      **用法**:文档**每个结论段**开头都挂一个档位;代码里也照写(连 TODO 清单都用它)。
      
      > 这比「高/中/低置信度」更好用:它同时交代了**证据的类别**,
      > 读者一眼知道该去补抓包还是该去读代码。
      
      ---
      
      ## 4. 端到端证据包(E2E evidence)
      
      一次「跑通」要留下这些**文件**,而不是一段文字(见 `templates/e2e-evidence-template.md`):
      
      | 产物 | 内容 |
      |---|---|
      | 帧序表 | 方向 / 消息号 / **body 长度**,逐帧列出 |
      | 与原始抓包的对比 | 哪些帧长度**一致**、哪些**必然不同**(如登录 body 含本地账号) |
      | 状态快照前后对比 | 断线前 vs 重连后(等级/金币/钻石/编队/进度…) |
      | device logcat | 两份:首次 + 重连 |
      | 截图 | 主界面(证明真的进去了) |
      | trace 摘要 json | 两个 TCP peer + 完整帧顺序 |
      | **非阻塞问题清单** | 明确列出「已 404 / 未实现」但**不影响本次验收**的项 |
      
      **关键写法:主动解释"长度对不上"** ——
      > “登录 body 为本地账号和本地轨迹,长度 `893`,**不应**与原始账号的 `899` 字节相同。”
      
      把「必然的差异」提前说清楚,读者才不会把它误判成 bug。
      
      ---
      
      ## 5. ADR:把「为什么先不做」也写下来
      
      `docs/decisions/00X-*.md` 的固定结构(见 `templates/adr-template.md`):
      
      ```
      状态 → 决策 → 原因 → 当前不做的事情 → 后续接入条件 → 风险 → 回滚
      ```
      
      一个 ADR 实例:**先做服务端兼容,先不改 APK**;
      热更新(YooAsset / CDN)**继续走原站**。
      
      它把边界写成了硬规则:
      
      - 不修改 APK、不替换 SDK 内置域名、不动域名缓存
      - **不把 CDN 指向局域网**
      - 不实现充值/邮件/VIP 等未确认接口
      - **SDK HTTP / 游戏 TCP / 热更新 CDN 三条流量分开验证**(不同协议、不同故障边界)
      
      >  这条正好补上本 skill 之前的空白:**「不改包」不是妥协,而是第一阶段的正解。**
      > 只有在「服务端协议自测持续通过 + 等价加密请求能在局域网登录成功 + 备用域名行为已确认
      > + 有可回滚的补丁方案」四条都满足后,才动客户端。
      
      **回滚写法**(照抄这个精神):
      > “当前阶段没有 APK 修改,因此回滚只需停止本地服务并恢复客户端原始域名配置。
      > 任何后续 APK 修改都必须单独记录补丁文件、目标类和回滚方法。”
      
      ---
      
      ## 6. 状态模型:字段级合并 + 幂等 + outbox
      
      | 机制 | 规则 |
      |---|---|
      | **字段级合并** | 状态更新按字段合并,**不用整段原始 body 覆盖本地状态** |
      | **幂等收据** | 战斗结算 / 抽卡 / 升级 / 支付 = 可重复请求 → 必须有幂等键,重复请求不重复发奖 |
      | **outbox** | 支付/奖励事件先入 outbox;断线时保留,**重连后只发未确认的** |
      | **启动重建** | 启动时从持久化状态重建已确认字段;**未确认字段保留原始 wire 快照** |
      | **重连恢复** | 断线重连后,昵称/等级/金币/钻石/英雄/编队/进度必须一致 |
      
      ---
      
      ## 7. 注意: 模板回放最大的隐患:原始账号数据泄漏
      
      写了「模板回放」之后,**最容易忽略**的一条:
      **模板里带着原账号的真实数据,会被发给本地新角色。**
      
      跑通的项目通常把它列成 P0 专项(“消除原始账号通知泄漏”):
      
      - [ ] 为英雄/背包/任务/星级/体力等通知**按本地状态构造 body**,不再回放原 body
      - [ ] 扫描所有模板响应的 **UID / OpenId / 订单号 / 资源数**
      - [ ] 加测试:**账号 A 的抽卡/升级/战斗结果不能出现在账号 B 的响应里**
      
      > 判据一句话:**能回放 ≠ 能交付**。回放只允许存在于「未知字段」上,
      > **已知的业务数据必须来自本地状态。**
      
      ---
      
      ## 8. TODO / TRACKER 的写法
      
      一份**跑通项目的 TODO 文档**值得照抄的形态:
      
      - **状态标记**:`[x]` 有代码或测试证据 / `[~]` 最小兼容或抓包回放,**不是完整业务实现** / `[ ]` 未实现
      - **一节「当前实现的本质」**:主动写清「它还不是完整独立服务端」,并列 4 条局限
      - **按 P0/P1/P2 分级**,每条下面挂**具体到字段和消息号**的子任务
      - **验收标准**:全部写成**可证伪的句子**
      
      验收标准样例(可直接套):
      ```
      - 新账号能够从 SDK 登录进入游戏主界面
      - 两个账号同时测试时,角色/英雄/背包/任务/剧情状态完全隔离
      - 完成一关战斗后,金币/经验/体力/物品/任务/星级在重连后保持一致
      - 重复请求、断线重连、服务重启都不会重复发放奖励
      - 本地 TCP trace 与协议定义一致,不依赖原始账号的未改写 body
      ```
      
      ---
      
      ## 9. 测试与运维配置(可复制)
      
      ```powershell
      # 单元测试(覆盖加密、状态机、计费幂等)
      python -m unittest discover -s tests -v
      python -m compileall -q server tests
      # 独立端到端 smoke 客户端
      python -m server.client
      ```
      
      CLI(同一个入口管服务与数据):
      ```
      status          看运行状态与端口占用
      health          双服务健康检查
      logs            实时日志
      account list / create <u> <p> / credit <u> <n>   账号与虚拟钱包
      ```
      
      > 由服务端直接判定成功并入账(与本 skill §15 一致)。
      
      ---
      
      ## 10. 本 skill 应固化的结论
      
      1. **抓包 → fixture → 原样回放 + 定点改写**,是启动阶段最快的可达路径,
         优先级高于「完整反序列化 protobuf」。
      2. 验收必须钉在 **一个具名完成点**(进入某界面),不能是「连接保持」。
      3. 文档结论一律带 **三档证据标注**;不确定的写 `Inferred`。
      4. **不改客户端 + 分开验证三条流量** 是第一阶段的正确姿势;
         热更/CDN **保留原站**。
      5. 只要引入回放,就必须同时做 **数据隔离**(禁止原账号 body 外泄)。
      6. 主动写 **「当前实现的本质/局限」** 与 **非阻塞问题清单** —— 诚实是省时间的。
      ---
      
      ## 11.  回放帧定点改写的实战陷阱(补 §1 / §7)
      
      > 来源:自研 C++ 引擎 MMO 全链路跑通项目(中心服 + 世界服 + HTTP 网关 + DNS,
      > 真机进场景 + 200 人压测)。以下每条都是实测踩坑。
      
      ### 11.1 帧内数据块会重复——patch 必须同长全量替换
      
      回放帧里同一份数据可能出现 **N 份**(如:角色数据块在每个「服务器组」里各嵌一份)。
      只 patch 第一处 → 帧内数据**不一致** → 客户端校验失败,表现为
      「center 登录无限重试 ×3 → world 连接超时」,且**日志完全看不出原因**(二分定位法才找到)。
      
      ```
      规则:替换前先数清目标 pattern 在帧内的出现次数 N;
            N>1 时全部替换(保持等长),绝不只改首处。
      变长值(如角色名)优先用【同长】替换(截断/补零),避免重算长度字段;
            变长 patch 必须同时重写所有长度字段(外层嵌套都要改)。
      ```
      
      ### 11.2 场景初始化窗口保护(收 join 帧 → native crash)
      
      客户端进场景要消化几百帧初始化回放(实测 395 帧 × 40ms ≈ 16 秒)。
      在此窗口内推给它「其他玩家实体出现帧」→ **客户端 native crash**(SIGSEGV,无弹窗)。
      
      对策(两件都要):
      1. **peer 就绪过滤**:给每个连接打 `init_done` 标记(初始化序列回放完毕才置位),
         广播时跳过未就绪 peer;
      2. **反向补偿**:新玩家 init 完成后,把「已在场玩家」的实体帧**补推**给它
         (否则它看不到先来的人——互见是双向的)。
      
      ### 11.3 多人广播:N² 放大是最大杀手(200 人压测实测)
      
      | 项 | 数据 |
      |---|---|
      | 事故 | 200 人上线后 p95=1297ms、33 人登录失败 |
      | 根因 | 状态同步帧同场景互转 → **≈10 万帧/秒**(N² 放大) |
      | 修复A | **每来源 1Hz 转发节流**(客户端 60fps 上报、服务端 1Hz 转发足够) |
      | 修复B | **AOI 距离过滤**(半径外的实体不转发;压测用网格分布坐标验证) |
      | 结果 | 200/200 登录、1805 msg/s、p95=31ms / p99=62ms、93MB / 0.24 核 |
      
      > 服务端每秒消息数公式:`帧率 × N × (可见邻居数)`。不做 AOI,
      > 在线人数上限 ≈ `带宽/帧率` 的开方——几十人就是天花板。
      
      ### 11.4 移动/状态协议的最小语义(够用就好)
      
      回放型 mock 不需要完整解析实体协议,认这几个字段就能跑:
      - `op3`(客户端移动):`u32 seq(递增) + u32 epoch + f32 坐标×2~3`
      - `op4`(服务端确认):**回显 seq** + 少量字段(实测 Δ 字段可取观测均值)
      - `op92/op91`(状态同步):同构广播帧,**裁掉尾部时间戳**即得 s2c 形态
      - 心跳类 opcode **不是逐帧应答**(实测 46 帧只回 12 次 ≈ 1/4)——**按抓包统计
        应答率**,别自作主张每帧都回(多回帧也可能触发客户端异常)。
      
      ### 11.5 测试金字塔(自下而上,每层独立可跑)
      
      ```
      L1 fake_client    协议级: 起 server + 脚本客户端, 断言帧序/回显     (秒级, 16断言)
      L2 fake_e2e       全链路: HTTP→center→world 走完, 断言状态流转      (秒级)
      L3 fake_stateful  有状态: 新账号端到端, uid自增/动态uuid/名字patch   (秒级)
      L4 fake_bots      压测:   200 bot × 60s, 登录率/消息率/内存/p95      (分钟级)
      L5 真机           验收:   原版客户端进场景 + 截图 + 强杀重启重连      (人工)
      ```
      > 每层的失败信息要**指到帧**(op 号 + hexdump),否则调回放时无从下手。
      > **多进程残留互踩**是这套金字塔的头号干扰源:旧 server 占着 DNS/HTTP 端口、
      > 旧的端口转发还指向旧进程 → 日志混杂、断言假失败。**重启前先按端口清进程
      > + 清 adb forward**,写成固定脚本。
      
      ### 11.6 官方基础设施还在线时的「对拍」红利
      
      很多停服游戏的官方服务器/CDN 仍在线(实测:中心服、世界服、账号 HTTP、OSS 全活)。
      这带来一条捷径:
      - **官方服 = 标准答案发生器**:中继模式录下官方全流程响应 → 自建实现逐帧 diff;
      - **官方也是 mock 依赖**:客户端冷启动会校验热更版本文件(如 `libmmo.zipver`),
        官方 OSS 可能已删(404)→ 客户端卡死在更新。此时 transparent proxy 里
        **mock 该文件返回本地版本号**即可跳过(其余继续透传官方);
      - 官方响应可能随时间变化(如网关地址换了后缀)——fixture 里对这类字段
        **留活口**(配置化),别焊死。
      
      ### 11.7 工程杂坑清单
      
      | 坑 | 现象 | 解法 |
      |---|---|---|
      | Android 12+ `input text` | 含 `%s`(空格)直接失败 | 用 `%s` 占位符转义或分段输入 |
      | `/sdcard` noexec | 脚本 push 上去无法执行 | 放 `/data/data/<termux>/files/home`;bash exec 受 W^X 限但 **python 直跑不受** |
      | uiautomator 对自绘 UI 无效 | dump 出来全是空节点 | 坐标操作(input tap)+ 截图 diff 判断界面状态 |
      | exec-out 拉二进制损坏 | PNG 打不开 | 设备端落文件 + `adb pull`(exec-out 有 CRLF 污染) |
      | 模拟器分辨率不统一 | 双开截图坐标对不上 | `wm size` 统一(注意强设会遗留,记得还原) |
      | pkg 源 DNS 污染 | Termux 装包失败 | hosts 写镜像源 IP(tuna 等)换国内源 |
    • from-installer.md 10.7 KB
      # 零输入自举:只有一个安装包怎么办
      
      > **最常见的情况**:用户只丢来一个 `.apk` / `.ipa` / `.exe`,什么都没给。
      > 这时不能等证据 —— 要**自己把证据造出来**。
      > 本文是完整的「安装包 → 证据 → Spec」流水线。
      
      ---
      
      ## 目录
      
      > 0 总原则 · 1 隔离环境 · 2 解包 · 3 判引擎/语言 · **4 自产静态证据(核心)** · 5 运行抓包 · 6 动态 dump · 7 加壳/加固 · 8 跑不起来的降级 · **9 动作清单** · 10 常见卡点速查 · 11 给 AI 的行为约定
      
      ---
      
      ## 0. 总原则
      
      ```
      安装包 ──① 解包 ──▶ 原始文件 ──② 判引擎/语言 ──▶ 选工具
            ──③ 静态产物(dump.cs/lua/asset) ──▶ 证据
            ──④ 运行抓包 ──▶ 协议证据
            ──⑤ 动态 dump ──▶ 密钥/运行时数据
            ──⑥ 汇总 ──▶ protocol.spec.yaml ──▶ 服务端
      ```
      
      **关键心态**:用户给一个包 = 把「取证」这件事也交给了你。
      不要反问"你能给我 dump.cs 吗",而是**自己跑 Il2CppDumper**。
      
      ---
      
      ## 1. 准备工作(隔离环境)
      
      | 类型 | 建议 |
      |------|------|
      | 运行环境 | **独立模拟器**(MuMu/雷电/Waydroid)或备用机;不要用主力机 |
      | 抓包 | mitmproxy(HTTP/WS) + tcpdump/Wireshark(TCP/UDP) |
      | 动态 | Frida(推荐)、x64dbg、IDA/Ghidra |
      | 解包 | apktool、unzip、7z、AssetStudio、FModel、Il2CppDumper |
      | 设备工具 | adb(安卓)、ideviceinstaller(iOS)、一切在虚拟机/沙箱 |
      
      > 注意: 全程只在**自研/已授权/学习**目标上操作;隔离环境既为安全也为可回滚。
      
      ---
      
      ## 2. 第一步:解包
      
      ### 2.1 Android APK
      
      ```bash
      # 基础解包
      unzip -o game.apk -d game_apk/
      
      # 看结构
      ls game_apk/
      #   AndroidManifest.xml   (可 apktool d 反编译)
      #   classes*.dex          (Java/Kotlin 代码)
      #   lib/<abi>/*.so        (原生库:il2cpp/UE/自研)
      #   assets/               (资源、lua、配置)
      #   res/
      #   META-INF/
      ```
      
      **分裂 APK / OBB**(很多大游戏):
      ```
      app.apk + split_config.arm64_v8a.apk + ... + main.obb
      ```
      - 把所有 split 都解包,`lib/` 与 `assets/` 常在 split 里。
      - OBB 是资源包,放到设备 `Android/obb/<pkg>/` 才能运行。
      
      ### 2.2 iOS IPA
      
      ```bash
      unzip -o game.ipa -d game_ipa/
      ls game_ipa/Payload/*.app/
      #   主二进制(Mach-O)、Frameworks/、Assets.car、*.bundle
      ```
      
      ### 2.3 Windows EXE
      
      - 直接看安装目录:`GameName_Data/`(Unity) / `Engine/`+`Binaries/`(UE) / `*.pak`
      - 若是安装器,先安装到沙箱,再分析安装目录。
      
      ---
      
      ## 3. 第二步:判引擎 / 语言
      
      对照 `client-languages.md` §0 与 `decision-tree.md` §1:
      
      ```bash
      # Unity IL2CPP
      ls game_apk/lib/*/libil2cpp.so
      ls game_apk/assets/bin/Data/Managed/Metadata/global-metadata.dat
      
      # Unity Mono
      ls game_apk/assets/bin/Data/Managed/Assembly-CSharp.dll
      
      # UE
      ls game_apk/lib/*/libUE4.so ; ls game_apk/assets/*.pak
      
      # Lua / 配置
      find game_apk/assets -iname '*.lua*' -o -iname '*.luac' | head
      
      # Flash/AS3 (少见但有)
      find game_apk -iname '*.swf' -o -iname '*.abc'
      ```
      
      ---
      
      ## 4. 第三步:自产静态证据(核心)
      
      ### 4.1 Unity IL2CPP → dump.cs
      
      IL2CPP 需要**两个文件配对**:`libil2cpp.so` + `global-metadata.dat`(都在包里)。
      
      ```bash
      Il2CppDumper.exe game_apk/lib/arm64-v8a/libil2cpp.so \
                      game_apk/assets/bin/Data/Managed/Metadata/global-metadata.dat \
                      out/il2cpp/
      # 产出:dump.cs / script.json / il2cpp.h / DummyDll/
      ```
      
      - `global-metadata.dat` 头不是 `AF 1B B1 FA` → 被加密,见 §6 动态 dump。
      - 拿到 `dump.cs` 后按 `unity.md` §A4 搜网络关键词建索引。
      
      ### 4.2 Unity Mono → 直接反编译
      
      ```bash
      # Assembly-CSharp.dll → dnSpy / ILSpy,几乎拿到源码
      ```
      
      ### 4.3 Unity 资源 / 配置表
      
      ```bash
      # AssetStudio (GUI) 打开 assets/bin/Data/ 或 .bundle
      #   导出:TextAsset、MonoBehaviour、配置(csv/json/bytes)
      # 或 AssetRipper 导出工程
      ```
      - 配置表常在 `assets/` 的 `TextAsset` 里,导出即得数值表。
      
      ### 4.4 Unreal → 资源与 SDK
      
      ```bash
      AES_finder.exe            # 从运行中的进程找 pak AES key
      # FModel 打开 .pak/.utoc,加载 .usmap 后导出
      # UE4SS / Dumper-7 运行时 dump SDK(需要能跑起来)
      ```
      
      ### 4.5 原生 Android → jadx
      
      ```bash
      jadx -d out/java/ game.apk     # 或对 apktool 后的 dex
      ```
      
      ### 4.6 Lua / 配置
      
      ```bash
      # 先判是否加密
      xxd assets/xxx.lua | head
      # 明文 luac 头 1B 4C 75 61 → unluac
      unluac assets/xxx.luac > xxx.lua
      ```
      
      ---
      
      ## 5. 第四步:运行抓包(协议证据的来源)
      
      纯静态只能给你字段定义,**帧格式与消息序必须抓包**。
      
      ### 5.1 搭环境
      ```bash
      # 模拟器里装游戏;PC 上起代理
      mitmproxy -p 8080
      # 模拟器 Wi-Fi 代理指向 PC:8080;安装 mitm CA 到系统信任
      ```
      
      ### 5.2 绕证书固定
      ```bash
      frida -U -f <package> -l templates/frida_bypass_ssl.js
      ```
      
      ### 5.3 非 HTTP(TCP/UDP/KCP)抓包
      ```bash
      # 模拟器/设备走 VPN 或 PC 热点,PC 上用 tcpdump
      tcpdump -i any -w capture.pcap host <server_ip>
      # 或 Android 上
      adb shell tcpdump -i any -w /sdcard/c.pcap
      adb pull /sdcard/c.pcap
      ```
      
      ### 5.4 操作清单(每次只改一个变量)
      | 操作 | 目的 |
      |------|------|
      | 冷启动到登录页 | 抓握手/版本号 |
      | 登录 | 抓鉴权与 token |
      | 建角/选角 | 抓状态机 |
      | 进场景/移动 | 抓长连接心跳与同步 |
      | 点一次商店/充值 | 抓支付路由 |
      
      > 把每次操作**单独抓一段**并命名,便于差分(见 `adaptation.md` §B)。
      
      ### 5.5 找不到服务器地址?
      ```bash
      # 从 .so 里搜 IP/域名
      grep -a -E '([0-9]{1,3}\.){3}[0-9]{1,3}|https?://|ws://' lib/*.so | head
      # 或在抓包工具里看 DNS/SNI
      ```
      
      ---
      
      ## 6. 第五步:动态 dump(静态拿不到时的兜底)
      
      | 目标 | 方法 |
      |------|------|
      | 加密的 `global-metadata.dat` | Frida hook `MetadataLoader::LoadMetadataFile` 出口,dump 明文 |
      | pak AES key | AES_finder / hook `FAES::DecryptData` |
      | XOR/RC4 密钥 | Frida hook `Encrypt`/`Decrypt` 或直接内存搜 |
      | 解密后的 lua | hook lua 加载器 |
      | 运行时结构体 | Frida + il2cpp 模式按类名读字段 |
      
      ```javascript
      // 示例:hook 常见加解密函数
      Interceptor.attach(Module.findExportByName(null, "XXTEA_Decrypt"), {
        onLeave(retval) { console.log(hexdump(retval)); }
      });
      ```
      
      ---
      
      ## 7. 加壳 / 加固怎么办
      
      原生 Android 游戏常见加固(360/平台乐固/梆梆/爱加密):
      
      1. 判断是否加固:看 `assets/` 是否有 `libjiagu*.so` / 壳特征 / dex 异常小。
      2. 脱壳思路:
         - **Frida 脱壳**(内存 dump dex / 主动调用)
         - **静态脱壳机**(针对特定壳)
         - 直接找源 APK(很多游戏能下到未加固版本 —— 优先)
      3. 若 `libil2cpp.so` 被加密 → 走 §6 动态 dump 内存中的 so。
      4. 加固只影响 `dex`/`so` 提取,**不影响抓包**。
      
      > 优先策略:**能抓到包 + 能拿到 metadata,就够起服务端**;不必强求完整脱壳。
      
      ---
      
      ## 8. 完全跑不起来(无法动态)时的降级路径
      
      ```
      1. 静态解包 → 拿 dump.cs / assets / 配置
      2. 若 metadata 加密且无法动态 → 目标改为「只还原可读的部分」
      3. 用客户端字符串/资源里的协议名反推路由
      4. 服务端先做「能连上+握手」,用占位字段推进
      5. 在 Spec 里把未确认项标 unresolved,阻塞项说明
      ```
      
      **仍然能产出**:project-profile + 部分 spec + 服务端骨架。不阻塞交付。
      
      ---
      
      ## 9. 从安装包到 Spec:动作清单
      
      ```
      [ ] 1. 解包(含 split/obb)
      [ ] 2. 判引擎与客户端语言
      [ ] 3. 自产静态证据:dump.cs / dll / lua / 资源 / 配置
      [ ] 4. 搭抓包环境,绕 pinning
      [ ] 5. 按操作清单抓包(每操作一段)
      [ ] 6. 需要时动态 dump 密钥/metadata
      [ ] 7. 填 out/project-profile.yaml(含 needs)
      [ ] 8. 填 out/evidence-inventory.md
      [ ] 9. 走 decision-tree 填 out/protocol.spec.yaml
      [ ] 10. 进 codegen 写服务端
      ```
      
      ---
      
      ## 10. 常见卡点速查
      
      | 现象 | 原因 | 对策 |
      |------|------|------|
      | `global-metadata.dat` 头不对 | 加密 | §6 动态 dump |
      | 抓包全是乱码 | 加密/压缩 | **先过 raw-deflate 快筛**(`decision-tree` §4.0,30 秒排除压缩),再谈加密 |
      | 装上就闪退 | 模拟器检测/加固 | 用真机或换模拟器;脱壳 |
      | 连不上服务器 | 有版本强制更新 | 抓更新接口,Mock 它返回当前版本 |
      | 冷启动卡更新/闪退回桌面 | **官方 OSS 删了热更版本文件(404)** | transparent proxy mock 该文件(如 `*.zipver`)返回 APK 内置版本号,其余透传官方(实测:官方删档 ≠ 官方全下架,逐个文件探) |
      | 模拟器连官方端口被 RST(PC 直连正常) | 模拟器用户态 NAT 对特定端口数据面坏 | PC 起 TCP 中继 + DNAT 指中继(`client-address-sources.md` §3.0) |
      | 只有 OBB 没主包 | 分裂 APK | 补下 split |
      | 抓不到包 | 证书固定 | `frida_bypass_ssl.js` |
      | 游戏要求登录 | 有 SDK | Mock 登录接口 或 patch 客户端跳过(`client-languages.md` §7) |
      | 资源下载失败 | CDN 校验 | 本地起资源服务,或关掉校验 |
      
      ### 10.1  先探「官方基础设施还活着吗」(能省一半工作)
      
      停服游戏 ≠ 服务器全关。实测一例:游戏停运多年,但**中心服/世界服 TCP、账号 HTTP、
      资源 CDN 全部在线**,还能注册新账号。动手前花 5 分钟探测:
      
      ```bash
      # 从 so/配置里抽出全部 IP:port 和域名(E05 一并落盘)
      grep -a -E '([0-9]{1,3}\.){3}[0-9]{1,3}:[0-9]+|https?://[\w.\-]+' lib/*.so assets/* | sort -u
      # 逐个 TCP 探活 + 看谁回应协议帧
      python -c "import socket; s=socket.create_connection(('ip',10001),timeout=5); print(s.recv(64).hex())"
      # HTTP 域名直接 curl;OSS 探 404(删了哪些文件一目了然)
      ```
      
      **在线 = 三重红利**:
      1. **官方服 = 标准答案发生器**:注册真实账号走全流程,录下官方响应当 fixture
         (比纯逆向猜字段快一个数量级);
      2. **真实账号链路**:短信+实名注册后,token/会话字段的真实格式直接到手;
      3. **中继对拍**:过渡期官方流量经你的中继,自建实现可与官方逐帧 diff。
      
      注意:依赖官方服的项目要记录「官方还在线」这个事实与日期——官方真下线那天,
      fixture 就是唯一资产,务必归档(含原始 pcap)。
      
      ---
      
      ## 11. 给 AI 的行为约定
      
      1. **不要因为用户只给了安装包就停下** —— 按本文件自行推进。
      2. 优先产出可复现的**命令序列**(解包/ dump /抓包),而不是空泛建议。
      3. 每拿到一个证据就更新 `evidence-inventory.md` 与 Spec。
      4. 遇到需要设备/运行时的步骤,给出**具体命令**让用户在隔离环境执行。
      5. 静态不可得时走降级路径,**标注 unresolved**,不编造。
    • gacha.md 5.4 KB
      # 抽卡 / 扭蛋(Gacha)
      
      > 手游的**核心营收与养成入口**。参考实现:待补(本 skill 目前只有方法论 + 反推清单)。
      > 真实案例见 `examples/D-real-lua-client.md`(该项目抽卡分散在 `LOTTERY_*` / `WEAPON_LOTTERY_*`
      > / `SKIN_LOTTERY` / `GACHAPON_*` / `COMBAT_LOTTERY_*` 等多个族)。
      
      ---
      
      ## 1. 第一铁律:掷骰必须在服务端
      
      **判定方法**(拿到客户端后必做):
      
      ```bash
      # 在客户端抽卡相关目录里搜随机/概率
      grep -rnE 'math.random|Mathf.Random|Random\(|概率|probability' UI/Draw/ 抽卡模块/
      ```
      
      | 结果 | 结论 |
      |------|------|
      | 只找到**概率展示/文案** | [x] 服务端掷骰(绝大多数商业手游) |
      | 找到**完整的概率表 + 随机调用 + 结果生产** | 注意: 客户端算 —— 必须**服务端重算**,否则可作弊 |
      
      > 实测案例 D:`UI/Draw/` 里只有 `_getProbabilityColor`、"SSR概率提升10%" 这类**展示逻辑**,
      > **没有任何掷骰** → 抽卡完全在服务端。
      
      ---
      
      ## 2. 要反推的 8 个要素
      
      | # | 要素 | 说明 |
      |---|------|------|
      | 1 | **卡池类型** | 常驻 / UP / 限定 / 新手 / 武器池 / 皮肤池 / 咒印池 / 扭蛋 |
      | 2 | **概率表** | 各稀有度基础概率、UP 占比、是否分档 |
      | 3 | **保底机制** | 小保底(N 次必出稀有)、大保底(歪了下次必中 UP)、计数重置规则 |
      | 4 | **连抽** | 单抽 / 十连(是否有十连保底、折扣) |
      | 5 | **消耗** | 免费次数、抽卡货币 id、单抽/十连价格 |
      | 6 | **重复转换** | 重复角色 → 碎片/通用货币(**最容易被漏掉**) |
      | 7 | **历史记录** | 抽卡历史(分页、token)、用于客服与风控 |
      | 8 | **结果展示** | 抽卡动画所需字段(决定结果包结构) |
      
      ---
      
      ## 3. 接口模式(从真实清单归纳)
      
      ```
      # 信息类
      GET_*_INFO / *_GET_INFO              -- 拉卡池信息(概率、保底计数、剩余免费次数)
      *_SELECT_POOL_TYPE                   -- 选择池子/档位
      *_HISTORY / *_HISTORY_TOKEN          -- 抽卡历史(分页)
      
      # 抽取类
      DO_*_LOTTERY / *_LOTTERY             -- 执行抽卡(带 isTen / times 参数)
      NOVICE_DRAW                          -- 新手池
      DO_GASHAPON_LOTTERY                  -- 扭蛋(另一套池子)
      
      # 保底/选择类
      *_GUARANTEE_CHOOSE / *_GUARANTEE_CHOOSE_CANCEL   -- 自选/保底选择
      *_SELECT_ROLE / *_SET_PREHEAT_OPTIONAL_RESULT     -- 预先选择可选项
      
      # 重复转换类
      *_GET_REPLICA_AWARD                  -- 重复角色换奖励
      ```
      
      **特征**:抽取接口通常只传「池 id + 次数/是否十连 + 选池参数」,
      **结果由服务端返回**(客户端拿不到掷骰过程)。
      
      ---
      
      ## 4. 服务端实现要点
      
      1. **服务端掷骰**,客户端只发意图。
      2. **事务性**:扣货币 + 掷骰 + 发货 + 记保底计数,必须**同一事务**(不能扣了钱没给货)。
      3. **幂等 / 防重放**:同一次抽卡请求带唯一 id,重发只返回同一结果(防"连点刷抽")。
      4. **先定后播**:先把**结果算完并落库**,再下发让客户端播动画(断线重连也能恢复)。
      5. **保底计数落库**,不能只存内存(重启丢保底 = 玩家暴怒)。
      6. **概率可审计**:抽卡日志必留存(客服/合规/风控)。
      7. **重复转换**:出货前先查玩家是否已有 → 已有则转碎片/货币,并**在下发结果里体现**。
      8. **免费次数**与**每日重置**的时区/边界要明确。
      
      ---
      
      ## 5. 与其它模块的关系
      
      | 模块 | 关系 |
      |------|------|
      | 背包(`drops.md`) | 抽到的物品**走同一套入库逻辑**(背包满 → 邮件) |
      | 邮件 | 背包满时的兜底;补偿发放 |
      | 充值(§15) | 抽卡货币的来源之一 |
      | 掉落 | 共用"随机 + 入库"的底层能力 |
      | 活动 | 活动池、限定池、扭蛋常挂在活动系统下 |
      | 排行/展示 | 抽到稀有物品的世界公告(如果有) |
      
      > **复用**:抽卡与掉落的"随机 → 结算 → 入库"链路应抽成**同一个服务**,
      > 避免两套随机逻辑各写一遍。
      
      ---
      
      ## 6. 常见坑
      
      - 客户端掷骰 → 可作弊,必须服务端算。
      - 保底计数只存内存 → 重启丢失。
      - 扣费与发货不同事务 → 出现「扣了没给」或「给了没扣」。
      - 缺幂等 → 连点/重放导致**多抽**。
      - 漏掉**重复转换** → 玩家拿到重复角色却没补偿。
      - 忘记录**抽卡日志** → 客服无法查证、合规风险。
      - 概率表硬编码在代码里 → 策划改不了(应配置化)。
      - 十连的"保底"和"折扣"与单抽不同 → 需单独规则。
      
      ---
      
      ## 7. 反推检查表(拿到客户端后逐项确认)
      
      - [ ] 抽卡接口族有哪些(`grep -iE 'lottery|gacha|draw|recruit'` 清单)
      - [ ] 池类型与切换接口
      - [ ] 概率表在哪(客户端配置表 `Cfg*` 里通常有展示用的概率)
      - [ ] 保底规则(接口名/字段能看出计数)
      - [ ] 重复转换接口与货币 id
      - [ ] 抽卡历史接口
      - [ ] 消耗的货币 id 与价格
      - [ ] 客户端是否掷骰(决定服务端是否必须重算)
      - [ ] 登记进 `TRACKER.md`
      
      ---
      
      ## 8. 落地顺序
      
      ```
      1. 拉卡池信息接口(GET_*_INFO)→ 让客户端能打开抽卡界面
      2. 单抽(扣费 + 服务端掷骰 + 入库 + 结果下发)
      3. 十连(同上的批量版本)
      4. 保底计数(落库)
      5. 重复转换
      6. 抽卡历史
      7. 活动池 / 扭蛋 / 皮肤池(按需)
      ```
    • inline-server.md 10.9 KB
      # 内联服务端:把「服务端」做进客户端进程内
      
      > **本文解决的事**:不是每个反推目标都要起一个独立进程的服务端。
      > 当客户端**能注入 / 能改**(`DBG` / `MANIP`)时,可以直接在客户端**自己的网络门面上**
      > 取走请求、**就地合成响应对象**。客户端永远不会真的发出去,
      > 传输 / 封包 / 加密 / 序列化这几层**根本不用还原**。
      >
      > 实测来源:Unity IL2CPP + 带第三方账号 SDK 的手游(见 `case-il2cpp-inline.md`)。
      > 与 `wire-level-patching.md` 是同一问题的两条路:那篇管「外部服务端怎么少猜字段」,
      > 这篇管「干脆不起外部服务端」。
      
      ---
      
      ## 0. 先分叉:外部服务端还是内联服务端
      
      | 判据 | 外部服务端 | 内联服务端 |
      |------|-----------|-----------|
      | 要不要还原传输 / 封包 / 加密 | [x] 必须 | [x] 不用(拦在明文对象层) |
      | 要不要注入 / 调试能力 | [x] 不用 | [x] 必须(Dobby / Frida / 改包) |
      | 服务端权威性 | 真权威,可多端一致 | 假权威,只对本机成立 |
      | 与客户端保护 / 完整性校验的关系 | 客户端不改,影响小 | **直接冲突**:要注入就过不了强校验 |
      | 能不能多人联机 | 能 | **不能**(每台机器一份状态) |
      | 跑通主流程的速度 | 慢(先分层、再定字段) | **快**(客户端对象模型就是 schema) |
      | 适合的目标 | `DOC` / `FULL` / 多人 | `FLOW` / 单机化 / 离线版 |
      
      > 注意: **内联不等于免于协议分析**。你仍然要知道「这条 cmd 的响应里客户端会读哪些字段」,
      > 只是这份知识可以从**客户端自己的类型定义**里读出来(`runtime-object-synthesis.md`),
      > 而不必从字节里猜。
      
      ### 0.1 它是对的,当
      
      - 目标是**单机化 / 离线可玩**,不要求与他人互通
      - 客户端**能改能注入**,且完整性校验已处理或可处理()
      - 你要跑通的是**主流程**(登录 → 建角 → 选角 → 进场景 → 局内),不是全量子系统
      - 卡在**加密 / 私有二进制协议**上,想先从协议层绕开
      
      ### 0.2 它一定是错的,当
      
      - 目标是**完整可部署服务端**给别人用 → 走 `codegen.md` + `server/`
      - 目标是**多人联机**(真同服、真对战)→ 内联天然做不到
      - 目标是**只产出协议文档** → 内联跳过传输层,产不出 wire 规格
      - 客户端**无法修改**(无 root、强签名校验、云游戏)→ 只能走外部服务端
      
      ---
      
      ## 1. 拦截点:网络门面的三类入口
      
      不要一上来 hook `send` / `recv`。客户端几乎都有一个**统一的网络门面类**,
      业务代码只和它打交道;抓住它 = 抓住全部请求。
      
      | 入口 | 语义 | 内联时的处理 |
      |------|------|-------------|
      | `SendMessage(cmd, request, callback, …)` | 请求 → **回调**拿响应 | 合成响应,**入队**交给主线程回调 |
      | `SendUnicast(cmd, request, callback, …)` | 请求 → **同步返回** ret 结构,响应另走回调 | 返回 `{ret:true, serial:0}`,响应仍走队列 |
      | `Add/RemoveMessageCallback(cmd, cb)` | 注册**广播**监听 | 维护回调表,之后按 cmd 派发 |
      
      > 实测签名(IL2CPP,8 参数):
      > `SendMessage(self, uint32 cmd, Il2CppObject* request, Il2CppObject* callback,`
      > `bool displayTip, Il2CppObject* timeoutCallback, double timeout, int32 connId,`
      > `Il2CppString* tag, const MethodInfo* method)`
      > `SendUnicast` 同参,**差别只在返回值**。
      
      ### 1.1 门面可能有多个命名空间副本 注意:
      
      实测客户端里同一个门面同时存在 `KH.NetworkManager` 与 `KH.Network.NetworkManager`
      两处,且**活的是后者**。只装一处 = 请求直接从眼皮下穿过去。
      做法:**两处都装**,并在日志里区分是哪一处被命中。
      
      ---
      
      ## 2. 回调投递纪律(最容易写错的一节)
      
      **绝不在拦截函数里直接调用回调。**
      
      理由(实测踩过):
      
      1. 拦截点处在客户端的**发送调用栈**上;同步回调会让业务代码在「刚发起请求」的栈里收到响应,
         很多状态机会因此重入(例如回调里再发一条请求)。
      2. 回调对象是托管对象,同步栈里它可能还没被注册进它自己的表。
      3. 真服务端的响应本来就有网络往返延迟;**同步完成本身就是一种行为差异**。
      
      正确形态:
      
      ```
      拦截 → 合成响应 → (cmd, callback, response) 入队 → return(当作已发送)
      主线程 tick → 取出队首 → 调用回调 → 释放 gchandle
      ```
      
      | 要素 | 要点 |
      |------|------|
      | 队列 | 线程安全(`std::deque` + `mutex`) |
      | 泵 | 挂在**客户端主线程 tick** 上(每帧都跑的 `OnGUI` / `Update`) |
      | 排队内容 | 只存 **gchandle**,不存裸指针(GC 会搬对象) |
      | 重入守卫 | `thread_local int depth`;`depth > 0` 时**放行给原函数**,不再内联处理 |
      | 一次性 | 取出即从队列移除,避免重复派发 |
      | 释放 | 调用完回调立刻 `gchandle_free`(回调与响应两个都要) |
      
      > 注意: 重入守卫用 `thread_local` 而不是全局:发送线程与主线程的深度必须**分别**计数。
      
      ---
      
      ## 3. 延迟派发:当顺序本身是契约
      
      有些消息**不能在响应到达时立刻广播**。实测场景:
      「匹配成功 → 进入游戏」这条广播必须在**阵容提交完成之后**发,
      早发会被客户端的进场流程丢掉或走错分支。
      
      做法:**帧计数器**延迟。
      
      ```
      g_deferFrames = 2;                                   // 武装
      每帧 tick: if (--g_deferFrames == 0) { 派发; g_deferFrames = -1; }
      未就绪时:  g_deferFrames = 30;                        // 30 帧后重试
      ```
      
      要点:
      
      - 用**帧**而不是 `sleep` / 时间戳:客户端的时序契约是帧序,不是墙钟
      - 派发前**先确认监听方已注册**(回调表里该 cmd 非空);没注册就延迟重试,别把广播丢进空表
      - 设**重试上限**并打日志,否则会静默死循环
      
      ---
      
      ## 4. 双通路:同一个客户端可能有两条协议通道
      
      实测客户端同时存在两条通道,**必须分别处理**:
      
      | 通道 | 特征 | 处理 |
      |------|------|------|
      | 强类型 C# 通道 | 请求 / 响应是客户端自己的协议类对象 | 直接合成对象 |
      | 脚本通道(Lua 等) | 请求入参是**序列化后的字节 / 表**,回调也期望字节 | 反序列化 → 合成 → **再序列化回去** |
      
      ```
      脚本通道处理链:
      DeserializeProtocolFromLua(cmd, request) → BuildResponse(cmd, decoded)
          → SerializeProtocolForLua(response) → 入队
      ```
      
      > 注意: 脚本通道里,**返回给调用方的返回值也要对**:除了回调,函数自身的返回值
      > (实测是 `SendUnicastRetInfo{ ret, serial }`)也必须构造并填好,
      > 否则业务侧会以为发送失败。
      
      ---
      
      ## 5. 对象合成交给 `runtime-object-synthesis.md`
      
      响应对象的构造、字段读写、嵌套 / 列表 / 枚举、字段发现循环,
      全部在 **`runtime-object-synthesis.md`**,本篇不重复。
      
      这里只强调一条**内联特有的**分工:
      
      | 你在哪一层 | 用什么手段 |
      |-----------|-----------|
      | 进程内拦截(本文) | **对象级**:直接 new 客户端的协议类、按字段名赋值 |
      | 外部服务端 | **wire 级**:`wire-level-patching.md` |
      
      > 两者共用同一份结论(cmd → 字段),只是**表达方式**不同。
      > 先对象级跑通、再翻译成 wire 规格,是一条高效的顺序。
      
      ---
      
      ## 6. 验收口径:内联版的「闭环」不一样
      
      外部服务端的红线是「原版客户端连上了」。内联模式下**客户端根本没连**,
      所以判据必须换:
      
      | 不能算 [x] | 为什么 |
      |----------|--------|
      | 「日志打了 handled cmd=…」 | 只是你的代码被执行了 |
      | 「响应对象构造成功」 | 客户端还没消费它 |
      | 「界面看起来对了」 | 可能是离线兜底分支,没经过你的响应 |
      
      | 才能算 [x] | 说明 |
      |----------|------|
      | 客户端走到**具名界面**(主界面 / 指定面板) | 状态真的推进了 |
      | **原生路径被走通**(如客户端自己的 `ZoneLogin` / `DoReqLoginServer` 执行完) | 复用官方状态机,而非绕过它 |
      | 强制停止 → 重启 → 仍能到位 | 不是一次性巧合 |
      
      > 内联项目最容易犯的错:**在 UI 层造一个「看起来对了」的界面**,
      > 而网络 / 状态层其实没跑通。验收必须落在网络 / 状态层的完成点。
      
      ---
      
      ## 7. 内联特有的假阳性
      
      除 `closure-verification.md` 的通用六类外,再加这几条:
      
      | 假阳性 | 真相 |
      |--------|------|
      | 客户端进了主界面 | 可能走的是**离线 / 单机兜底分支**,没经过你的合成响应 |
      | 某 cmd 从未命中 | 门面命名空间装错了(§1.1),或该请求走的是**另一条连接** |
      | 局内对手正常行动 | 那是客户端自带机器人,与你合成的数据无关 |
      | 段位 / 擂台入口点错面板 | 同一回调被多个入口共用;应让**捕获的原流程**决定路由,别自己猜 |
      
      ---
      
      ## 8. 工程组织(大 hook 项目怎么不烂)
      
      实测项目把所有内联逻辑放在**单个翻译单元**里,用 `.inl` 分片按职责切:
      
      ```
      src/
      ├── main.cpp                      入口 / 注入 / 线程
      ├── LocalEnterStatic.inl           核心:状态机、合成响应总表、每帧泵
      ├── LocalEnterStaticInstall.inl   全部 hook 的安装清单(一屏看清装了哪些)
      ├── LocalNetworkHooks.inl         网络门面拦截
      ├── LocalFeatureHooks.inl         具体子系统(商店 / 抽卡 / 好友…)
      ├── LocalBattleDebugTools.inl     局内调试工具
      ├── KiHanStruct.h                 IL2CPP 结构 / 反射辅助
      └── NetworkServerHttp.h           可选:本机 HTTP 桥接
      ```
      
      规矩:
      
      - `.inl` 只有**一个**翻译单元包含;文件头写清「被谁包含、顺序不能变」
      - **安装清单独立成文件**:装了什么 hook、参数个数、原函数指针,一眼可查
      - 合成响应做成**一张 cmd → 构造函数总表**,每条一个函数;不要写成巨型 if-else
      - 追踪日志**分通道**(recruit / shop / network…)且可开关
      
      > 注意: 发布构建实测会 `#undef LOGI` 把它变成空宏:
      > **日志字符串本身会泄漏协议与字段名**,等于给逆向者一张地图。
      
      ---
      
      ## 9. 收工前检查(内联版)
      
      ```
      [ ] 门面所有命名空间副本都装了?
      [ ] 回调全部走队列 + 主线程泵?没有同步调用?
      [ ] 重入守卫是 thread_local?放行路径回到原函数?
      [ ] 延迟派发有上限与日志?
      [ ] gchandle 有对应的 free?队列清空时会泄漏吗?
      [ ] 验收用的是网络 / 状态层完成点,不是界面截图?
      [ ] 追踪日志在发布构建被编译掉?
      ```
      
      > 相关:`runtime-object-synthesis.md`(对象怎么造)、`case-il2cpp-inline.md`(完整案例)、
      > `platform-sdk-and-admission.md`(账号与准入)、`closure-verification.md`(通用假阳性)。
      
    • live-ops.md 6.7 KB
      # Live 运营手册:进服之后的接口补全与排障(引擎无关)
      
      > **什么时候看**:客户端已经能进游戏了。进服只是 30%——把"能进"做成"能长期玩",
      > 才是本地离线服务端的主要工作量。本文件从两个 cocos HTTP-RPC 本地离线实跑项目(v27→v34,40+ 轮线上迭代)
      > 抽象出**引擎无关**的方法论。cocos 专属细节见 `cocos2d.md §H2`;本文讲通用规律。
      
      ---
      
      ## 0. 一句话
      
      > 进服后的每个"没效果",一半概率是**字段名/契约错**,不是逻辑没写。
      > 第一动作永远是 grep 客户端发送端代码,而不是改服务端逻辑。
      
      ---
      
      ## 1. 子系统补全次序(按玩家投诉频率排序)
      
      ```
      1. 任务系统(主线卡死=玩家流失)     → 2. 装备穿戴一致性
      → 3. 商店购买(注意: 多套契约!)     → 4. 武学/技能修炼闭环(资源→修炼→结算)
      → 5. 好友/查看玩家                 → 6. 排行榜
      → 7. 副本                          → 8. 打坐/治疗(回血回蓝)
      → 9. 坐骑                          → 10. 称号/时装
      → 11. 家园/建房                    → 12. 侠客/伙伴 → 13. 门派
      ```
      
      > 每完成一个子系统:本地测试 → 部署 → **线上真实加密请求冒烟** → 观察客户端 js error 归零。
      > 自测全绿不等于真机能用(见 closure-verification.md 假阳性②)。
      
      ---
      
      ## 2. 契约反推法(每个"没效果"接口的标准流程)
      
      ```
      1. grep 客户端发送端: sendRequest(HttpConst.XXX, {...}) → 记录真实字段名
      2. grep 回调: MessageCenter.on(HttpConst.XXX, cb) → 记录 cb 读取的响应字段
      3. 对照服务端当前实现读的字段名 → 十有八九不一致
      4. 按客户端真实字段重写 → 冒烟
      ```
      
      **实测案例**(同一项目内):
      - 穿戴:服务端等 `equipments` 字典,客户端发 `{EquipmentType:槽位, itemID:实例ID}`
      - 打坐:回包缺 `beginTime` → 客户端计时器不启动
      - 好友搜索:客户端发 `roleName`,服务端读 `name`;申请好友客户端发 `fName`(昵称)
      - 突破:客户端发 `onHookOperate`,服务端回 `operate`(读错字段→回包恒0→静默无效)
      
      > **键名拼写敏感**:注册 `getalchemytdata`(多t) vs 真名 `getAlchemyData` → 落到保底桶。
      > 自定义键必须与接口清单逐字符核对。
      
      ---
      
      ## 3. 双表版本差异(最阴的坑:显示对、到账错)
      
      **现象**:金条商城买A,弹窗显示A,背包到账B。
      
      **根因**:客户端 APK 里打包了一份**本地配置表**(商城/物品/语言),
      服务端也有一份从交接包提取的同名表——**两份表可能版本不同**(ID→内容映射有差异)。
      如果客户端购买后**本地自加货**(用它自己的表),服务端又**按自己的表发货**并用
      note 把背包设成绝对值 → 最终背包=服务端表的B,弹窗=客户端表的A。
      
      **判定法**:读客户端购买回调——如果 `getItem(id, 'mall').Data[0]` 出现在回调里
      (客户端本地加货),服务端就**绝不能再发同一份货**。
      
      **处置分两类**:
      | 商店类型 | 客户端行为 | 服务端职责 |
      |---|---|---|
      | 金条商城(客户端本地加货) | 本地表自加 | **只扣钱**,货物完全不发 |
      | 铜钱系商店(服务端权威) | 等 note 同步 | 扣钱+发货+note.ivch 绝对值 |
      
      > 同一项目里两类并存——必须逐接口读回调确认属于哪类,不能统一处理。
      
      ---
      
      ## 4. 客户端本地有状态(下发会覆盖)
      
      cocos/JS 客户端常把进度存 localStorage(任务完成史、聊天记录)。
      服务端下发该字段时——**哪怕发空数组**——都会整体覆盖客户端本地更全的历史。
      
      - 客户端会在请求里带 count 型 flag(如 `finishTaskFlag: 23` = 我本地有23条)
      - **规则**:服务端记录 ≥ 客户端数量才下发;否则**省略键**(省略 ≠ 发空值)
      - 省略键后客户端走 localStorage 恢复分支
      
      ---
      
      ## 5. 事件时机(登录期是雷区)
      
      带 note 的回包会让客户端**立即派发事件**。登录/加载阶段角色实体未初始化 →
      事件处理空引用 → 崩溃且无报错(错误上报通道本身未就绪)→ 无限重试加载。
      
      - 登录回包**只放数据不放事件**(note/autoObj)
      - 数值同步放到客户端就绪时机(战斗结算响应)
      - 排查法:崩溃在 LoadGame 后、下一条请求前、无报错 → 二分撤新字段→能进→逐个加回
      
      ---
      
      ## 6. 伪物品路由(防后台发"经验"发崩玩家)
      
      配置表里存在**数值型伪物品ID**(如 1=金条、4001=铜板、4002=江湖经验、4003=潜能)。
      它们一旦进 itemObj,客户端做 `+=` 累加(经验+9998万)→ 溢出崩溃 → 卡登录循环。
      
      - 后台发放接口按伪物品ID路由到对应属性桶,**永不入背包**
      - LoadGame 的 itemObj 永久过滤伪物品ID(双保险)
      - 属性上限以**客户端战斗上报的真实值**为准(高属性号合法上限可过万),
        自愈逻辑只能清精确的作弊残留值,不能用固定阈值
      
      ---
      
      ## 7. 自愈设计(治未病)
      
      每次登录对账修复,任何历史脏数据在玩家下次登录时自动归零:
      - 任务:进行中送物任务缺剧情物品 → 补齐(接任务发放/交任务收回/登录对账三层闭环)
      - 装备:穿戴引用了背包不存在的实例 → 剔除;出生穿戴引用背包真实实例
      - 属性:血蓝倒挂/精确999999 → 修
      - 伪物品残留 → 滤
      - 自愈后该类工单归零,不再需要人工补发
      
      ---
      
      ## 8. 后台即排障工具
      
      管理后台不只是发东西,它是**分钟级闭环的排障面板**:
      - 角色全量数据查看(背包/装备/穿戴/任务/货币/技能,按账号分桶)
      - 物品/装备/技能/称号发放(装备走实例、伪物品走属性路由)
      - **强制完成任务**(卡任务救援:标记完成+收回剧情物品+发奖励)
      - 货币与经验直设(注意经验到账时机,见 §5)
      - 兑换码系统
      - 图鉴(全物品/装备/武学对照表,中文名可搜索)
      
      ---
      
      ## 9. 验收口径的升级
      
      进服后的验收不再是"下一条请求发出"(那是登录链的判据),而是**玩家可感知的正确性**:
      
      | 维度 | 判据 |
      |---|---|
      | 战斗 | 穿剑→战斗实体带剑系武功;输赢→掉落/经验/血量变化正确 |
      | 商店 | 买A→背包到账A且扣款对(弹窗/到账/扣款三对齐) |
      | 修炼 | 结束→等级+1 且 UI 即刷;吃丹→修为/血量按真实上限恢复 |
      | 隔离 | 双账号用例:A 的任何操作不影响 B 的对应数据 |
      | 持久 | 重登一致性:所有状态重登不变 |
      
      配合:**线上真实加密请求冒烟**(自测脚本构造的请求只证明服务端没崩)。
      
    • methods.md 11.5 KB
      # 反推服务端的 11 种思路 & 选择矩阵
      
      > **先选对方法,再干活。** 方法选错 = 后面全白干。
      > 本文件给「输入条件 → 该用哪种方法」的判定,以及现实中的**组合用法**。
      > 本文回答「**用什么手段**取证」;「**先做什么、后做什么**」见 `references/workflow-roadmap.md`;「**在反推什么**」见 `references/primer.md`。
      
      ---
      
      ## 0. 选择器:先回答 3 个问题
      
      ### Q1 你手里有什么?(可多选)
      
      | 条件 | 记作 |
      |------|------|
      | 只有安装包(apk/ipa/exe) | `PKG` |
      | 有抓包(pcap / mitm) | `CAP` |
      | 有客户端源码或反编译产物(dump.cs / lua / as3 / sdk dump / .usmap / .proto) | `SRC` |
      | 能运行客户端(模拟器/真机/PC) | `RUN` |
      | 能注入/调试(Frida / 调试器 / root) | `DBG` |
      | 能改客户端或中间人(改配置 / 代理 / hosts) | `MANIP` |
      | 有已知的同类实现(全球服 / 旧版本 / 姊妹项目) | `REF` |
      
      ### Q2 目标是什么?
      
      | 目标 | 说明 |
      |------|------|
      | `DOC` | 只产出协议文档 / Spec |
      | `FLOW` | 跑通主流程(登录→进游戏→战斗→结算) |
      | `FULL` | 完整可部署服务端 |
      | `PATCH` | 主要想改客户端行为(跳过登录 / 改地址) |
      | `OFFLINE` | 单机化 / 离线可玩(**不需要外部服务端**) |
      
      ### Q3 约束?
      
      | 约束 | 影响 |
      |------|------|
      | 有客户端保护 / 文件校验 | **少改客户端**,优先 MITM/自建服务端 |
      | 有时间压力 | 优先「透传 + 逐接口替换」 |
      | 无 root / 无调试权限 | 只能黑盒 + 配置法 |
      
      ---
      
      ## 1. 十一种方法
      
      ### M1 白盒源码法(Source-driven)最省事
      
      | 项 | 内容 |
      |----|------|
      | 适用 | 有 `SRC`(dump.cs / lua / as3 / sdk dump) |
      | 一句话 | **直接读代码**,把 opCode、字段、序列化全抄出来 |
      | 步骤 | 定位协议文件 → 抽接口清单(`tools/extract_interfaces.py`)→ 看 `msg.request.*` 得字段 |
      | 产出 | 完整接口表 + payload schema |
      | 坑 | IL2CPP 只有签名没函数体;混淆后字段变 `a/b` |
      
      ### M2 黑盒抓包法(Traffic-driven)最通用
      
      | 项 | 内容 |
      |----|------|
      | 适用 | 有 `CAP`(+`RUN`) |
      | 一句话 | **只看包**,从字节规律推断分层与字段 |
      | 步骤 | 分层剥离(传输→封装→加密→序列化)→ 操作差分 → 猜字段 |
      | 产出 | 协议分层 + 部分字段 |
      | 坑 | 加密/压缩不剥就永远看不懂;没有代码证据字段只能猜 |
      
      ### M3 灰盒动态 Hook 法(Runtime-hook)破加密利器
      
      | 项 | 内容 |
      |----|------|
      | 适用 | 有 `RUN`+`DBG` |
      | 一句话 | Hook 收发/加解密函数,**直接拿明文和密钥** |
      | 步骤 | hook `send/recv`、`Encrypt/Decrypt`、`MetadataLoader` 出口;dump 明文/密钥 |
      | 产出 | 明文流量 + 密钥 + 解密后的资源 |
      | 坑 | 有反调试/反注入会失败;需版本匹配的 Frida 脚本 |
      
      ### M4 辅助 Artifact 法(Artifact-driven)一步到位
      
      | 项 | 内容 |
      |----|------|
      | 适用 | 有 `.proto` / `.usmap` / SDK dump / 配置表 |
      | 一句话 | **别人已经整理好的结构**,直接用 |
      | 步骤 | `.proto` → 生成类;`.usmap` → FModel;SDK dump → 读结构体 |
      | 产出 | 消息结构 + 类布局 |
      | 坑 | 版本不匹配会错位;`.proto` 可能缺字段 |
      
      ### M5 代理透传 + 逐接口替换法(MITM shadow)最稳的落地路径
      
      | 项 | 内容 |
      |----|------|
      | 适用 | 有 `CAP`+`MANIP`,目标是 `FULL` |
      | 一句话 | **先全透传到真服务器**,再把接口**一个个换成我们的** |
      | 步骤 | ①起代理透传 ②记录每个接口的真实响应 ③按优先级逐个 mock ④直到全替换 |
      | 产出 | 渐进式可用的服务端(中途一直可玩) |
      | 坑 | 要保证替换后的响应**结构与真响应一致**(否则客户端崩) |
      
      ### M6 差分探测法(Differential probing)定字段
      
      | 项 | 内容 |
      |----|------|
      | 适用 | 有 `RUN`+`MANIP` |
      | 一句话 | **只改一个变量**,对比响应/请求字节差异 → 定位字段 |
      | 步骤 | 买 1 个 vs 买 5 个;走 1 步 vs 2 步;换一个角色 → 逐字节 diff |
      | 产出 | 字段偏移与语义 |
      | 坑 | 一次改多个变量 → 无法归因 |
      
      ### M7 回放/录像法(Replay-driven)
      
      | 项 | 内容 |
      |----|------|
      | 适用 | 游戏自带战斗回放/录像(很多动作/竞技游戏有) |
      | 一句话 | 回放数据里含**完整战斗过程**,可反推战斗协议 |
      | 步骤 | 找录像文件/接口 → 解析 → 对应战斗消息 |
      | 产出 | 战斗协议 + 状态同步结构 |
      | 坑 | 录像可能是压缩/自定义格式 |
      
      ### M8 已知实现移植法(Port-known)抄作业
      
      | 项 | 内容 |
      |----|------|
      | 适用 | 有 `REF`(全球服 / 旧版本 / 同引擎姊妹项目 / **已跑通的模拟器**) |
      | 一句话 | 已有别人的服务端实现,**对照移植** |
      | 步骤 | 读其协议实现 → 对比自己抓的包 → 适配差异 |
      | 产出 | 框架 + 大部分协议 |
      | 坑 | 注意: **必须看许可证**;版本/区域差异大时不能照搬 |
      
      >  **动手前先花 10 分钟查"有没有人做过"**——别人反推出来的 opcode/包结构,
      > 等于**已经替你逆向完的成果**。同引擎 / 同厂商 / 同世代的姐妹项目优先。
      
      **现成实现速查(按类型,不点名具体游戏)**:
      
      | 类型 | 是否有成熟开源服务端 | 常见实现语言 |
      |------|----------------------|--------------|
      | 经典 PC 端 MMORPG(大世界 / 多人) | 有,社区维护多年 | C++ / Java / C# |
      | 老式回合制 / 2D 客户端网游 | 有 | C# / Java |
      | 部分手游(卡牌 / 动作) | 有社区实现(版本绑定强) | Java / C# |
      | 单机游戏的多人模式 | 有自建客户端 / 联机框架 | C++ |
      
      >  两条经验:① 这类 MMO **基本只用 TCP**(→ 假设包有序);② **客户端版本↔服务端版本强绑定**,
      > 反推前**先锁版本**。有现成 → 抄;部分 → 拼装;没有 → 纯逆向。
      
      **找同类项目的入口**:
      
      - 在 GitHub 搜 `awesome-game-revivals`、`awesome-reverse-engineered-games`、`awesome-game-remakes`、
        `game file format reversing`、`game hacking` 等 **awesome 合集**,按目标类型找同类「客户端 + 自建服务端」项目。
      
      > 共同点:**都是"客户端 + 自建服务端"能跑起来**的成品,可参考它们的重定向 / 协议 / 服务端取舍。
      
      ### M9 自环法(Self-loop)
      
      | 项 | 内容 |
      |----|------|
      | 适用 | 有 `RUN`,想快速知道"客户端期待什么" |
      | 一句话 | 让客户端连**自己搭的桩**,从它报错/行为反推需求 |
      | 步骤 | 起 `templates/mock_server.py` → 客户端连上来 → 看它发什么、缺什么 |
      | 产出 | 主流程最小协议集 |
      | 坑 | 只能拿到"它主动问的",拿不到"它预期的响应结构" |
      
      ### M10 穷举校验法(Brute-verify)
      
      | 项 | 内容 |
      |----|------|
      | 适用 | 只剩加密/编码这一层卡住 |
      | 一句话 | 对算法/密钥/参数**做字典式试解**,用校验位验证 |
      | 步骤 | 试 XOR/RC4/AES 常见参数 → 用 CRC/length/可见字段验证 |
      | 产出 | 破解的编码层 |
      | 坑 | 组合爆炸;**优先用 M3 拿密钥,别硬猜** |
      
      ### M11 内联服务端法(In-process / 免服务端)要单机化时的捷径
      
      | 项 | 内容 |
      |----|------|
      | 适用 | 有 `RUN`+`DBG`(能注入)或 `MANIP`(能改包),且目标可**单机化** |
      | 一句话 | **不起外部服务端**:在客户端**进程内**拦它的网络门面,直接合成响应对象 |
      | 步骤 | 定位网络门面 → 拦收发之上的业务入口 → 合成响应 → 队列 + 主线程投递 → 逐 cmd 补齐 |
      | 产出 | 可玩的单机 / 离线客户端(**不是服务端**) |
      | 坑 | 与客户端保护 / 完整性校验直接冲突;**不能联机**;产不出 wire 规格(要补 M1/M2) |
      | 详见 | `references/inline-server.md` + `runtime-object-synthesis.md` |
      
      ---
      
      ## 2. 现实是组合用法
      
      没有哪个项目只用一种方法。典型组合:
      
      ```
      有源码:       M1(抄接口) + M6(确认字段) + M5(落地)
      有包无源码:   M2(分层) + M3(拿明文/密钥) + M1-(若 IL2CPP 有 dump.cs) + M6 + M5
      有国际服:     M8(移植) + M2/M6(校验差异) + M5
      只想改客户端: M9(看它要什么) + 配置法 / patch
      单机化/离线:  M11(进程内合成响应) + M3(取明文/凭据) + M1(补字段名)
      ```
      
      ---
      
      ## 3. 默认推荐路线(不知道选什么时用这条)
      
      ```
      ① M1/M4 有源码或 artifact → 直接抄接口清单
         ↓ 没有
      ② M2 抓包 + M3 Hook → 拿明文流量与密钥
         ↓
      ③ M6 差分 → 确认关键字段
         ↓
      ④ M9 起桩自环 → 跑通「连接+登录」
         ↓
      ⑤ M5 透传 + 逐接口替换 → 渐进式替换全部接口
         ↓
      ⑥ 闭环验证(原版客户端完整可玩)
      ```
      
      > 这条路线**任何时候中断都能用**(M5 的透传阶段客户端一直能正常玩)。
      
      ---
      
      ## 4. 按输入条件查表
      
      | 条件 | 首选 | 备选 | 说明 |
      |------|------|------|------|
      | `SRC` | **M1** | M4 → M6 | 源码在手,别舍近求远 |
      | `SRC`(仅签名) | **M1+** | M3 | dump.cs 只有签名时配合 Hook 看实现 |
      | `PKG`+`RUN` | **M9→M3** | M2 | 先自环摸底,再 Hook 拿明文 |
      | `CAP` | **M2** | M6 | 直接分层分析 |
      | `CAP`+`RUN`+`MANIP` | **M5** | M6 | 这组合最舒服,直接透传替换 |
      | 有 `REF` | **M8** | M2 | 抄作业最快(注意许可证) |
      | 有录像 | **M7** | M2 | 战斗协议的金矿 |
      | `RUN`+`DBG`+可单机化 | **M11** | M3 / M1 | 不起服务端,直接在客户端进程内合成响应 |
      
      ---
      
      ## 5. 按目标查表
      
      | 目标 | 方法 | 最小交付 |
      |------|------|---------|
      | `DOC` | M1 / M2 / M4 | 接口清单 + 分层文档 |
      | `FLOW` | M9 → M5 | 能连上并能进游戏 |
      | `FULL` | 默认路线 ①~⑥ | 可部署服务端 + 闭环 |
      | `PATCH` | 配置法 / M9 | 改地址 / 跳登录 |
      | `OFFLINE` | **M11** → M3 | 可玩的单机 / 离线客户端 + 合成响应表 |
      
      ---
      
      ## 6. 反模式(别这么干)
      
      | [x] 反模式 | 为什么错 | 正确做法 |
      |----------|---------|---------|
      | 有源码却硬啃抓包 | 浪费十倍时间 | 先 M1 |
      | 抓包看不懂就上暴力解密 | 组合爆炸 | 先 M3 拿密钥 |
      | 一次性替换所有接口 | 客户端直接崩且无法定位 | M5 逐个替换 |
      | 一次改多个变量做差分 | 无法归因 | M6 一次一个 |
      | 照搬国际服实现 | 版本差异导致错位 | M8 后必须 M6 校验 |
      | 没跑通主流程就做周边系统 | 地基不稳 | 先 `FLOW` 再扩展 |
      | 只做客户端能"看起来"通过 | 服务端才是权威 | 服务端必须真算 |
      | 能注入却硬要做外部服务端 | 白做传输/加密层的还原 | 先判分叉,可单机化就走 M11 |
      
      ---
      
      ## 7. 方法选错的表现(自查)
      
      | 现象 | 说明你该换方法 |
      |------|--------------|
      | 啃了半天抓包还是看不懂 | 该上 M3 Hook 或找 M1 源码 |
      | dump.cs 全是空函数体 | 该配 M3 或 IDA |
      | 字段全靠猜、改了就对不上 | 该做 M6 差分 |
      | 每次改一点客户端就崩 | 该切到 M5 渐进替换 |
      | 反复重构越写越乱 | 该先落 Spec(`protocol-spec.md`) |
      
      ---
      
      ## 8. 给 AI 的行为约定
      
      1. **先跑 §0 选择器**,明确输入/目标/约束,再动手。
      2. 在 `project-profile.yaml` 里记下**选定方法与理由**。
      3. 方法可**中途切换**(发现更好路径就切,并记录原因)。
      4. 缺条件时**给出获得该条件的步骤**(如"要 Frida Hook,需先 root/装 frida-server")。
      5. 不要在不具备条件时硬用该方法的结论。
    • phases-detail.md 10.2 KB
      # 反推主流程(阶段 1~10 · 详细版)
      
      > 本文件是 `SKILL.md §1~§10` 的**详细版**:每步的命令、工具、判断与常见坑。
      > 只用骨架时看 SKILL.md 的对应小节;要动手时打开这里。
      
      ---
      
      ## 1. 输入分类与预处理
      
      > 本步属工作流「第 0~1 步」。**先扫描、先建档,再进分支**;不要一上来就套参考实现。
      
      **按输入判定引擎/语言** → 完整判定表见 `client-languages.md §0`:
      `dump.cs/libil2cpp.so`=IL2CPP | `Assembly-CSharp.dll`=Mono(可出源码) | `*.lua/luac`=Lua 热更 |
      `*.usmap/.pak/.utoc`=UE | `classes.dex`=Java | `*.js/webpack`=JS | 只有 APK/EXE/IPA=未知 → §2。
      
      **三条预处理原则**:
      - `dump.cs` 是 IL2CPP 的"地图":先 grep 网络关键词(`Socket/Send/Recv/Message/Proto/Packet/Cmd/MsgId`)建索引,再精读。
      - Lua 先判加密(`luac` 头 `1B 4C 75 61`=明文)→ 再 unluac/luadec 反编译。
      - `.usmap` 恢复 FModel 的 unversioned 结构;SDK dump 直接给类偏移。
      
      ---
      
      ---
      
      ## 2. 阶段一:侦察与引擎识别
      
      **引擎判别**(解压看 `lib/` 或安装目录):
      ```
      Android(lib/)  libil2cpp.so+global-metadata.dat → IL2CPP | libmono.so+Assembly-CSharp.dll → Mono
                     libUE4.so → UE | libcocos2d*.so → Cocos(见 cocos2d.md)
      Windows        Game_Data/Managed → Unity | */Binaries/Win64+Engine → UE | GameAssembly.dll → IL2CPP
      ```
      **网络情报**:`grep -a -E '([0-9]{1,3}\.){3}[0-9]{1,3}|https?://|ws://' <binary>`;抓一次启动流量看连了哪些 host → 判 HTTP / 长连接 / KCP / QUIC。
      **产出**:`recon/engine.md`、`recon/endpoints.md`。
      
      ---
      
      ---
      
      ## 3. 阶段二:流量获取
      
      1. 起代理(mitmproxy/Charles)并配置客户端。
      2. **握手失败** → 有证书固定:Frida hook `SSL_CTX_set_verify` / `X509TrustManager` / `CertificatePinner`;桌面 hook `WinHttpQueryOption` / `libssl`;自测可重打包注入信任。
      3. **流量乱码** → 记原始字节,进入 §4 分层。
      4. 经验:UE 常见自研 UDP + 自定义序列化;Unity 常见 TCP/WS + protobuf。
      **产出**:`capture/*.pcap`、`capture/flows/`。
      
      ---
      
      ---
      
      ## 4. 阶段三:协议分层识别
      
      按顺序剥离,每层写证据:
      ```
      [1] 传输    TCP / UDP / KCP(conv头+快重传) / QUIC / WebSocket
      [2] 封装    长度前缀(LE/BE) / 分隔符 / protobuf tag / UE FPacketHeader
      [3] 加解密  ent/直方图高熵→压缩或加密;RC4 / XOR / AES(块对齐16) / UE CRC+XXTEA
      [4] 压缩    zlib(78 9C) / lz4 / gzip   ← 注意: raw-deflate 最易被误判成加密,先过 decision-tree §4.0 快筛
      [5] 序列化  JSON / protobuf / MessagePack / 自定义二进制
      ```
      **关键判定**:KCP 头 `u32 conv + u8 cmd + u8 frg + u16 wnd`(UDP);protobuf tag `(field<<3)|wire_type`(`protoc --decode_raw` 试解);UE 用 `FArchive` 小端 + 字符串带 int32 长度。
      **产出**:`protocol/layers.md`(每层:字节样本 + 判定理由)。
      
      ---
      
      ---
      
      ## 5. 阶段四:客户端静态分析(分引擎)
      
      > 各引擎的完整命令与坑见 `client-languages.md`、`unity.md`、`unreal.md`、`from-installer.md`。这里只留要点。
      
      | 引擎 / 语言 | 工具 | 要点 |
      |-----------|------|------|
      | Unity IL2CPP | Il2CppDumper(+IDA/Ghidra) | `global-metadata.dat` magic 应 `AF 1B B1 FA`;不符=加密,搜 `MetadataLoader::LoadMetadataFile` 回溯解密点。dump.cs 先 grep 网络/序列化关键词建索引 |
      | Unity Mono | dnSpy / ILSpy | 近源码;加密则定位 Mono `image` 解密点 |
      | Unity + Lua | unluac / luadec | 找 xlua/tolua/slua 加载器与解密;字节码魔改需先还原 opcode 表;Lua 常承载业务字段与消息号 → 与 dump.cs 交叉验证 |
      | Unreal | FModel / UE4SS / Dumper-7 | pak 密钥用 AES_finder;UE5 unversioned 先出 `.usmap` 再喂 FModel;grep `NetDriver/Replication/RPC/SendPacket/PacketHandler`;`UPROPERTY(Replicated)` 属性顺序 = 序列化顺序 |
      | Lua / JS 源码 | grep + `tools/extract_interfaces.py` | 拿到源码 ≈ 拿到接口总表 |
      
      **脚本源码客户端(Lua/JS)**:`grep -rl "protobuf\|opCode\|SendMessage\|Socket" --include=*.lua` → 判协议模式 → 扒全部 opCode → 按前缀聚类 → 看 `msg.request.*` 得 payload 字段。
      常见形态 `GetMessage("OPCODE") → msg.request → protobuf.encode()`;**opCode 可能是字符串也可能是数字,必须实测**。实例见 `examples/D-real-lua-client.md`。
      
      **接口清单提取(必做一步)**:
      ```bash
      python3 tools/extract_interfaces.py <源码目录> --out out/interfaces.md --json out/interfaces.json
      ```
      支持字符串/数字 opCode、HTTP 路由、protobuf 信封、C2S_/S2C_ 方法名;产出**按子系统前缀分组**的清单。
      拿到后:按前缀聚类得子系统 → 逐条登记 `TRACKER.md`([x]/[~]/[ ]/[?]/[-])→ **先做主流程,周边后置** → 清单里没有的(如人机)标 `[-]`,别凭空造。
      
      **自研 C++ 引擎(无源码无符号)**:
      ```
      1. 字符串侦察:提 so 可打印串按关键词分组(网络 / 会话类名 N7…Session / 加密 / 调试 printf 格式串)
      2. typeinfo→vtable:文件里 vtable 全是 0(relocation 运行时填)→ 走 .rela.dyn 或 Ghidra(windows.md §I.3)
      3. Ghidra headless 批量反编译(windows.md §I.2 有模板);先探 image base
      4. 传输层:先过 decision-tree §4.0 raw-deflate 快筛
      5. opcode 表:反编译 opcode 分发 switch + 抓包聚类双向确认
      ```
      > 这类游戏 Java 层常只剩渠道 SDK 壳;HTTP 登录的 appKey/secret 常明文在 `assets/channel_config.properties`。
      **产出**:`reverse/` 下 opcodes、structs、crypto、sdk。
      
      ---
      
      ---
      
      ## 6. 阶段五:字段语义推断
      
      对每个字段产出四元组 `offset / 类型 / 含义 / 证据`。方法:
      1. **差分法**:一次只改一个变量(买 1 个 vs 5 个 / 走 1 步 vs 2 步)→ 对比字节差异。
      2. **结构体对照**:客户端结构体字段顺序 + 序列化顺序 → 还原 wire 布局。
      3. **回灌验证**:把真实抓包喂给客户端的解密/反序列化函数,看能否还原结构体。
      4. **Lua/SDK 交叉**:配置表 + 热更脚本常直接暴露字段名。
      
      ---
      
      ---
      
      ## 7. 阶段六:服务端建模与实现
      
      1. **建模**:建 opcode 配对表(`请求 → 响应` + 触发条件);画状态机(`连接/握手 → 登录 → 选角 → 进场景 → 战斗 → 结算`,标每步 opcode 与前置状态);标注长连接依赖(心跳、重连、序列号、时间戳)。
      2. **工程化实现**:本 skill 自带可运行服务端 `server/`(已实测跑通 握手→登录→建角→选角→进场景→移动→心跳 + XOR + zlib)。**分层架构 / "适配目标游戏只改 4 处" / 必备能力清单** → 详见 `server/README.md`;由 Spec 派生的映射 → `protocol-spec.md §D`。
      
      
      **产出**:`server/`、`docs/statemachine.md`、`docs/protocol.md`。
      
      ---
      
      ---
      
      ## 8. 阶段七:部署(本地 / 服务器 / 容器)
      
      ### 8.1 本地 / 服务器 / 容器(细节见 `server/README.md`)
      
      | 方式 | 命令 | 说明 |
      |------|------|------|
      | 本地 | `cd server && ./start.sh` | 看 `listening on ('0.0.0.0', 8888)` + `handlers loaded: N opcodes` |
      | 联调自测 | `python client_test.py --host 127.0.0.1 --port 8888 --user alice --password 123456 --name Hero01` | 期望结尾 `[+] full flow OK` |
      | systemd | `sudo bash server/deploy/deploy.sh` | 自动建用户/venv/服务;`journalctl -u gsrv -f` |
      | Docker | `cd server/deploy && docker compose up -d --build` | — |
      
      - **端口**:游戏 `8888/tcp`(KCP 再放行 `udp`);云上需放行安全组。
      - **数据库**:默认 SQLite(`data/game.db`)→ 生产改 MySQL(`database.url`)。
      - **端游/手游运行环境差异**(Termux 保活 / Windows NSSM)→ `references/termux.md`、`references/windows.md`;**发布/端口族/配置冻结/备份** → `references/release-and-ops.md`。
      
      ### 8.6 客户端对接(让原版客户端连上自建服务端)
      | 客户端校验 | 处理方式 |
      |-----------|---------|
      | 硬编码 IP/域名 | 改 `hosts`(Android 需 root 或用 Frida/模块重定向 DNS) |
      | 服务端列表文件 | 改客户端配置副本里的 `serverlist.json` |
      | 域名解析 | iptables DNAT / nginx 四层转发:`iptables -t nat -A OUTPUT -d 原IP -j DNAT --to-dest 自建IP` |
      | 证书固定 | 自签 CA 让客户端信任;或 Frida 绕过(自测) |
      | 版本号校验 | 改 `config.yaml` 的 `game.expected_version` 匹配客户端 |
      | 资源/热更 URL | 重定向到自建 HTTP,提供客户端需要的资源清单 |
      | **改包名 / 重打包 / 签名** | 见 `references/repack-rename.md`(AXML + arsc 等长替换、包名派生密钥资源、保留原签名) |
      
      > 注意: **动手改包之前,先读 **。
      > **推荐顺序:先"不改包 + 端口劫持"把协议跑通,最后再考虑改包。**
      ### 8.7 验证与回归
      1. 启动服务端 → 启动原版客户端 → 观察到「成功握手 / 进入登录界面 / 进入游戏」。
      2. 服务端日志逐包对照,确认 opcode 与字段解析正确。
      3. 关键数值操作(买药、移动、升级)走一遍,核对服务端落库。
      4. 断开重连、切换角色、多开,验证状态机完整。
      
      **产出**:`deploy/`(Dockerfile / docker-compose / systemd / deploy.sh)、`verify/log.md`。
      
      ---
      
      ---
      
      ## 9. 阶段八:验证与回滚
      
      - **验证记录** `verify/log.md`:命令 + 输入 + 原始输出 + 退出码。
      - **回滚方案**:
        - 客户端原文件先做哈希备份,所有改动在副本上进行。
        - 服务端用配置开关,可一键回到"透传/只读"模式。
        - 记录每个修改点与还原命令。
      
      ---
      
      ---
      
      ## 10. 输出物清单(交付模板)
      
      ```
      recon/engine.md            引擎与端点评测
      recon/endpoints.md         服务器地址
      capture/flows/             原始流量
      protocol/layers.md         协议分层 + 证据
      reverse/dump.cs            或 sdk.h(IL2CPP / UE dump)
      reverse/opcodes.json       消息号表
      reverse/structs.md         结构体定义
      reverse/crypto.md          加解密说明
      docs/protocol.md           协议文档
      docs/statemachine.md       状态机
      server/mock/               最小可运行服务端
      deploy/                    systemd / docker / 部署脚本
      verify/log.md              验证与回滚记录
      ```
      
      ---
      
    • platform-sdk-and-admission.md 4.8 KB
      # 平台 SDK 登录态复用与「目录服 → 区服」两段准入
      
      > **本文解决的事**:国内大厂手游(第三方平台账号 SDK)的登录不是一段,
      > 而是**平台 SDK → 目录服 → 区服**三段。自建 / 内联服务端时,
      > 最常见的误解是「把版本检查糊弄过去就能进」——实际会卡在第二段,且**看不出原因**。
      >
      > 实测来源见 `case-il2cpp-inline.md`。
      
      ---
      
      ## 0. 先把账号链路切成三段
      
      | 段 | 谁在把关 | 客户端侧表现 |
      |----|---------|-------------|
      | ① 平台 SDK 登录 | **平台服务器**(换 openid / token / pf / pfKey) | 拉起 SDK、返回登录态 |
      | ② 目录服(区服列表 / 准入) | **厂商目录服**(client_version / res_version / 白名单…) | 拿到区服 `ip:port` |
      | ③ 区服登录(ZoneLogin) | **区服**(用 ① 的凭据换区服会话) | 角色列表 → 选角 → 进场景 |
      
      > 关键:**三段由不同服务器分别校验**。改客户端只影响它自己那一段的判断,
      > 不存在任何「连带放行」。
      
      ---
      
      ## 1. 复用官方登录:借,不重写
      
      想以真实账号身份拿到**官方**给出的东西(例如某类 key / token),
      不需要重写 TCP 加密、protobuf 或整条登录协议。做法是**让客户端的官方登录流程真的跑一遍**,
      你只负责把它引到你需要的连接上:
      
      ```
      ① 复用已有登录记录里的凭据(openid / token / pf / pfKey)—— 来自平台 SDK 的登录结果
      ② 用目录服给出的官方区服地址
      ③ 建一个【非默认】连接对象(不要动客户端正在用的那条)
      ④ 调用客户端**已有的**连接认证逻辑(不要自己实现握手)
      ⑤ 在该连接上发送登录 / 业务请求,拿到成功响应
      ⑥ 用同一个连接继续后续取数
      ```
      
      要点:**能力全部来自客户端已有的类**,你只是「复制一份配置 + 换个目标地址 + 调用」。
      
      ---
      
      ## 2. 桥接连接的实现形态(Unity 实测)
      
      实测做法:动态建一个 GameObject,挂上客户端自己的连接组件,**逐字段复制**官方实例的配置:
      
      | 复制什么 | 为什么 |
      |---------|--------|
      | `DH` / `Uin` / `Password` | 连接身份与会话材料 |
      | `Url` / `VersionServerUrl` / `VersionTestAppId` | 版本 / 寻址相关配置 |
      | `EncryptMethod` / `KeyMaking` | **加密方式与密钥派生方式**(照抄,不要自己实现) |
      | `ZoneUrl` | **改成你的目标地址**(唯一真正改动的字段) |
      
      ```
      建 GameObject → AddComponent(官方连接组件类型) → 复制上面字段
      → 设 ZoneUrl = tcp://host:port → DontDestroyOnLoad → 调用官方 Connect
      → 轮询 connection.IsConnected 直到就绪(设时限,别死等)
      ```
      
      > 注意: 三个纪律:**不动默认连接**、**只改副本不改原对象**、
      > **就绪判定用官方属性**(`IsConnected`)而不是自己猜时间。
      
      ---
      
      ## 3. 卡点定位:没进区服 ≠ 密码错
      
      症状:「提示维护中 / 请稍后重试 / 一直转圈」,但**没有 ZoneLogin 请求发出**。
      
      | 观察到 | 说明卡在哪 |
      |--------|-----------|
      | 反复发目录服请求,**始终没有 ZoneLogin** | 卡在 ② 目录服准入(不是密码问题) |
      | 有 ZoneLogin,但无响应 / 超时 | 卡在 ③ 区服(地址、端口或协议参数) |
      | 连 SDK 都起不来 | 卡在 ① 平台登录 |
      
      > 复现方法:抓一次包或打日志,看**最后一条成功发出的请求是哪个**。
      > 缺哪一段,就在哪一段。不要凭提示文案猜。
      
      ---
      
      ## 4. 铁律:客户端放行不能替代服务端准入
      
      实测教训(**已证伪**):把客户端的资源检查函数(`IsPreResUpdated` 之类)改成恒 `true`,
      客户端**不再弹更新提示**了 —— 但随后卡在目录服准入,
      因为准入是**目录服**独立校验的,客户端怎么想无关。
      
      - 改客户端只能改变**客户端自己的判断**(提示、分支、界面)
      - 服务端准入必须在**服务端那侧**满足(版本号、资源版本、白名单、签名)
      - 内联路线必须把「绕过准入」当成一个**独立课题**,别指望顺手带过
      
      ---
      
      ## 5. 凭据处理(安全)
      
      - `token` / `pfKey` / `Password` 这类**只允许在内存中传递**:不写日志、不落盘、不进仓库
      - 调试需要比对时,只打印**长度 / 哈希前几位**,不打印原值
      - 桥接对象的字段复制在内存里完成,**不要**序列化出来做「配置」
      
      ---
      
      ## 6. 自检
      
      ```
      [ ] 三段链路各自的卡点都定位过了?
      [ ] 有没有「改客户端 = 以为服务端也放行」的假设?
      [ ] 桥接连接没有动客户端默认连接?
      [ ] 有无时限与超时日志?(不会无限等)
      [ ] 凭据没有进日志 / 文件?
      ```
      
      > 相关:`account.md`(账号接口还原)、`case-il2cpp-inline.md`(本案例)、
      > `release-and-ops.md`(地址与端口策略)。
      
    • primer.md 10.8 KB
      # 原理层:先懂这三件事,再动手
      
      > **这份文档解决的事**:很多失败不是"方法选错",而是**一开始就没搞懂"在反推什么"**。
      > 抓了一堆包、dump 了一堆代码,却不知道要找什么 → 于是把压缩当加密、把协议号当固定值、
      > 忽略热更里的真源码。
      >
      > **什么时候读**:第一次接触某个客户端、或对话里出现"看不懂数据包/不知道协议号从哪来"时。
      > 老手可直接跳过,去 `methods.md` / `workflow-roadmap.md`。
      
      ---
      
      ## A. 一切反推 = 找回三份「真相」
      
      一个游戏的客户端,本质上是**三份信息的组合体**。服务端要跑起来,这三份一份都不能缺。
      
      | # | 三要素 | 里面装什么 | 在客户端里的形态 | 在服务端里对应什么 |
      |---|--------|-----------|-----------------|------------------|
      | 1 | **配置文件**(数值参数) | 规则里的"数字":掉落概率、商品价格、关卡血量、等级经验、语言文本 | csv / json / bytes / TextAsset / 脚本里的常量 | **数据层**(掉落表、商品表、配置表、语言表) |
      | 2 | **协议文件 / 协议表**(消息格式) | 消息长什么样:字段顺序、类型、长度、字节序 | proto / 枚举定义 / 热更脚本里的 opCode 表 / 抓包推断 | **codec + `protocol.spec.yaml`** |
      | 3 | **代码**(使用方式) | 这些数字和消息**怎么用**:收到包做什么、什么条件发什么包 | dump.cs / lua / js / dll / 反编译产物 | **handlers 业务逻辑** |
      
      > **一句话记住**:
      > 配置告诉你「**是多少**」,协议告诉你「**长什么样**」,代码告诉你「**干什么用**」。
      > 三者缺一,服务端就会在某个地方卡住——而且症状往往互相掩盖(详见 §F)。
      
      > 注意: **常见误判**:以为"能连上、能登录"就成功了。
      > 其实那只是**协议**这份真相的一部分。真正进主场景、能玩,还要**配置**和**代码**都到位。
      
      ---
      
      ## B. 数据包协议到底是什么
      
      ### B1. 定义:一串"没有表头的字节"
      
      数据包 = 把一堆数据**按固定顺序**排在一起的**纯字节串**。对每一项:
      
      - 不记录**名字**(没有字段名)
      - 不记录**类型**(没有 int/string 标记)
      - 不记录**边界**(除非你自己约定)
      
      读取方必须**事先知道**"第 1~4 字节是 int、接着 2 字节是长度、再后面是 UTF-8 字符串……",
      才能读对。**这个事先约定,就是协议。**
      
      > 比喻:一份没有表头的 CSV——列数不固定、每列类型还不同,只有拿到"列定义"的人才能读。
      
      ### B2. 为什么网游用 Socket,而不用 HTTP / WebSocket
      
      | | HTTP / WebSocket | 自研 Socket 协议 |
      |---|---|---|
      | 格式开销 | 自带 header、分帧、文本语义,**格式冗余** | 紧凑二进制,**每字节都有用** |
      | 交互频率 | 请求-响应为主 | **高频、双向、推送密集** |
      | 延迟/带宽 | 偏重 | 敏感 → 越省越好 |
      | 服务器推 | 需要额外长连接/轮询 | 天生全双工 |
      
      因此网游开发者的惯例是:**客户端与服务端直接建立偏底层的 Socket(TCP / UDP / KCP / QUIC),
      再自己定义一套紧凑的二进制数据包协议。**
      
      > 例外:**登录/公告/热更/支付**这类非高频接口,常用 HTTP(S)——所以一个游戏里
      > **往往同时存在两种协议**(HTTP 接口 + 长连接二进制)。别把两者混为一谈。
      
      ### B3. 一条消息的典型骨架
      
      ```
      ┌──────────┬────────────┬──────────────────┬────────────────────┐
      │ 长度头    │ 消息号      │ (可选)加密/压缩  │  消息体 body        │
      │ len(2/4B) │ opcode      │                  │  按协议顺序排列的字段 │
      └──────────┴────────────┴──────────────────┴────────────────────┘
      ```
      
      - **长度头**:告诉接收方"这条消息到哪结束",用于 **TCP 粘包/拆包**。字节序(LE/BE)、
        是否含头自身 → 必须实测定(见 `protocol-spec.md` C1)。
      - **消息号(opcode)**:标记消息类型(见 §C)。
      - **加密 / 压缩**:可能对 body 整体做一次。**先判压缩(raw deflate 极易被误判成加密,
        见 `decision-tree.md` §4.0)。**
      - **消息体**:变长字段(字符串/数组/子消息)用**长度前缀**分隔;定长字段直接按宽度排。
      
      > 注意: 只要有一处规则对不上(字节序、类型宽度、变长前缀宽度、字符串编码),
      > **后面全部错位**。这就是为什么必须「双证据链」(§E)。
      
      ### B4. 读取的前提:两侧规则一致
      
      服务端要能**读懂**客户端发的包、并**生成**客户端能懂的包,必须逐项复刻:
      `传输 → 封装 → 加密/压缩 → 序列化`(四层剥离,见 SKILL §0.3)。
      任何一层没复刻,症状都是"客户端连上了却没反应 / 静默断开 / 卡在某个界面"。
      
      ---
      
      ## C. 协议号与协议表(服务端的灵魂)
      
      ### C1. 协议号是什么
      
      协议号(opcode / cmd / msgId)是**消息的类型标签**。客户端和服务端收到消息后:
      
      ```
      解析协议号 → 找到对应协议 → 按该协议把字节解析为可用对象 → 交给负责的代码处理
      ```
      
      **协议号可能是**:数字(`0x0101`)、英文名(`"LoginReq"`)、长串校验码(少见)、
      或它们的组合。**不要假定它是某种固定形态——必须实测。**
      
      ### C2. 协议表 = 一张"消息总账"
      
      把所有协议号与其格式汇总,就是**协议表**:
      
      | 协议号 | 方向 | 消息名 | 字段布局 | 触发条件 | 证据 |
      |--------|------|--------|---------|---------|------|
      | 0x0001 | c2s | HandshakeReq | ver:u32, nonce:8B | 连接后立即发 | E02 |
      | 0x0002 | s2c | HandshakeRes | code:u8, key:4B | 收到握手后 | E02 |
      | 0x0101 | c2s | LoginReq | user:str, pwd:str, ver:u32 | 握手完成后 | E05 |
      
      > **没有协议表,数据包就是纯粹的乱码。**
      > 想重建服务端逻辑,**读取和生成数据包是硬前提**——所以**挖掘协议表是整个项目的核心任务**。
      > 产出落进 `schema/protocol.spec.yaml` 的 `opcodes:` 段。
      
      ### C3. 协议表从哪来(按可靠性排序)
      
      | 来源 | 可靠性 | 做法 |
      |------|--------|------|
      | 1. 客户端源码 / 反编译 |  | 找协议文件,grep `opCode` / `protobuf` / `SendMessage`,抽全表(`tools/extract_interfaces.py`) |
      | 2. dump.cs / SDK dump |  | grep 网络关键词建索引,找常量与消息类 |
      | 3. **热更脚本 / 程序集**(§D) |  | lua/js 里常直接写出 opCode 映射表 |
      | 4. 抓包差分 |  | 只改一个操作对比字节 → 定位协议号与字段(M6) |
      | 5. 字符串 / 常量表 |  | 消息名常量、枚举、路由字符串 |
      | 6. 纯穷举 |  | 兜底(优先用 M3 Hook 拿明文) |
      
      ---
      
      ## D. 热更新代码:比 dump 更好读的"源码"
      
      ### D1. 为什么它重要
      
      引擎 dump(IL2CPP 的 `dump.cs` / UE 的 SDK)**往往只有签名或晦涩的伪代码**,
      可读性差、函数体还是汇编。**热更新下载的程序集/脚本更接近真正的源码**——
      包含可读的变量名、业务逻辑、字段赋值。
      
      > 在网络游戏中,热更新是**除引擎 dump 之外,另一个能提供"易读代码"的来源**。
      > 很多商业手游把**业务逻辑**放在热更脚本里,**网络底层**留在原生 so——
      > 所以:**热更给业务,dump 给底层,两者互补。**
      
      ### D2. 热更新资源的形态
      
      运行游戏、等更新界面下载完,`files/`(或热更缓存目录)里通常会有:
      
      - **脚本**:`lua` / `js` / `ts` / `luac` / `jsc`
      - **程序集**:`dll`(.NET IL,可反编译)
      - 以及**图片 / 模型 / 贴图 / 动画**
      
      常见方案:`xLua` / `tolua` / `slua` / `HybridCLR` / `ILRuntime` / `Puerts` / `QuickJS` / 自研 JIT-AOT。
      **一个项目可能同时用多种方案组合**——要逐一确认。
      
      ### D3. 它在哪
      
      | 位置 | 说明 |
      |------|------|
      | 设备 `files/` / 热更缓存目录 | 运行一次后生成(先启动一次游戏再看) |
      | `assets/` 内置 | 首包自带的脚本/程序集 |
      | CDN / OSS | 版本清单(Manifest)指向线上地址 |
      
      > 判定"当前跑的是内置版还是热更版"很重要——**改了内置版可能被热更覆盖**
      > (见 `client-address-sources.md` §5.2)。
      
      ### D4. 怎么读
      
      | 形态 | 判定 | 工具 |
      |------|------|------|
      | `lua` 明文 | 头 `1B 4C 75 61` | 直接读 |
      | `lua` 字节码 | 头 `1B 4C 75 61` + 指令 | `unluac` / `luadec` |
      | 魔改 luac | 反编译报错 | **先还原 opcode 表**,再反编译 |
      | `.dll`(.NET) | PE 头 `MZ` | `dnSpy` / `ILSpy` |
      | `js` / `ts` | 文本 | beautify + sourcemap |
      | 加密脚本 | 高熵、无明文头 | Hook 加载器 / 找解密函数(M3) |
      
      ### D5. 用它做什么
      
      1. **扒 opCode 表**与字段赋值 → 直接填 `protocol.spec.yaml`
      2. **理解字段语义**(`"等级" = msg.level`)→ 填 `messages` 段
      3. **还原子系统划分**(按前缀聚类)
      4. **交叉验证 dump.cs 的结论**(双证据链的一半)
      
      ---
      
      ## E. 双证据链:为什么单侧结论只能算"假设"
      
      ```
      任何字段/协议结论  =  流量证据(抓包里字节变化)
                         +  代码证据(客户端里的结构体/序列化代码)
      ```
      
      - 只有流量 → 字段靠猜,改一个操作就崩。
      - 只有代码 → 不知道线上真实取值、可选字段、默认值。
      - **两者相交 = 高置信**。缺一侧 → 写进 Spec 的 `unresolved`,标 `Inferred`。
      
      > 这是 `SKILL.md` §0.2 铁律的落地解释。
      
      ---
      
      ## F. 新手最常见的 6 个认知误区
      
      | [x] 误区 | [x] 事实 |
      |--------|--------|
      | "二进制 = 加密" | 二进制只是紧凑排列;**先按四层剥离**,最常见的是 **raw deflate 压缩**被当成加密 |
      | "协议号是固定的 2 字节" | 协议号形态(数字/名字/校验码)、宽度(0/1/2/4B)**因游戏而异**,必须实测 |
      | "只看抓包就能写服务端" | 没有代码证据,字段全靠猜,必错位 |
      | "dump.cs 就是源码" | IL2CPP 的 dump 只有签名;**真源码常在热更脚本里** |
      | "HTTP 就是协议" | HTTP 只是**一种**协议;网游长连接用的是**自研二进制协议** |
      | "能登录就成功了" | 登录只是协议的一部分;进主场景还要**配置 + 代码**都还原 |
      
      ---
      
      ## G. 读完去哪
      
      ```
      已理解原理 → server-architecture-basics.md (真实服务端长什么样,你在还原哪几类服)
                 → workflow-roadmap.md    (四阶段路线总纲,先看这个)
                 → methods.md             (M1~M11 选方法)
                 → protocol-spec.md       (把协议表落成 Spec)
                 → 具体引擎:unity.md / unreal.md / cocos2d.md / client-languages.md
      ```
    • protocol-spec.md 7.7 KB
      # 协议规格(Protocol Spec):语言无关的中间表示
      
      > **Spec 是整个流程的唯一事实来源。** 先有 Spec,再有代码。
      > 换游戏 = 换一份 Spec,而不是重写一堆散代码。
      
      ---
      
      ## A. 为什么要 Spec
      
      1. **可追溯**:每个字段都挂 `evidence` 编号与 `confidence`。
      2. **可评审**:人类能直接读,AI 能直接生成代码。
      3. **可增量**:没证据的先写 `TODO_EVIDENCE`,不阻塞后续。
      4. **可验证**:闭环验证的结果回填到 Spec,形成闭环。
      
      模板见 `schema/protocol.spec.yaml`。
      
      ---
      
      ## B. Spec 结构总览
      
      ```yaml
      meta:          # 项目/引擎/来源
      project:       # 引擎、平台、客户端语言
      transport:     # tcp/udp/kcp/quic/ws/http
      frame:         # 长度头、消息号、字节序
      crypto:        # 加密算法、密钥来源、是否逐连接
      compress:      # 压缩算法、阈值
      serialize:     # json/protobuf/binary/msgpack + 字段定义
      opcodes:       # 消息号表(数值 → 名称/方向/结构)
      state_machine: # 连接→握手→登录→选角→进场景…
      messages:      # 每条消息的字段布局
      unresolved:    # 未确认项 + 需要的证据
      ```
      
      ---
      
      ## C. 各段填写要点
      
      ### C1. frame(封装层)
      ```yaml
      frame:
        length_size: 2            # 1/2/4
        length_endian: little     # little/big
        includes_self: false      # 长度是否含头部自身
        opcode_size: 2            # 0 = 无独立消息号
        opcode_endian: little
        evidence: [E03, E07]
        confidence: high
      ```
      
      ### C2. crypto / compress
      ```yaml
      crypto:
        enabled: true
        algorithm: xor            # none/xor/rc4/aes/custom
        key_source: hardcoded     # hardcoded/handshake/login/derived
        key: "0x11223344"         # 已知则写,未知写 null
        per_connection: false
        evidence: [E09]
        confidence: medium
      compress:
        enabled: false
        algorithm: zlib           # zlib/gzip/lz4
        min_size: 256
      ```
      
      ### C3. serialize(两种写法)
      **结构化(推荐)**——字段有名字和类型:
      ```yaml
      serialize:
        format: binary
        endian: little
        strings: length_prefixed_u16   # 或 fixed / cstring
      ```
      
      **声明式**——用 `fields` 描述固定布局(适合简单消息):
      ```yaml
      messages:
        LoginReq:
          opcode: 0x0101
          fields:
            - {name: username, type: str}      # 长度前缀字符串
            - {name: password, type: str}
            - {name: client_ver, type: u32}
      ```
      
      ### C4. opcodes
      ```yaml
      opcodes:
        source: dump.cs            # dump.cs / lua / sdk.h / inferred
        table:
          - {value: 0x0001, name: HANDSHAKE_REQ, dir: c2s, msg: HandshakeReq}
          - {value: 0x0002, name: HANDSHAKE_RES, dir: s2c, msg: HandshakeRes}
          - {value: 0x0101, name: LOGIN_REQ,     dir: c2s, msg: LoginReq}
          # ...
        evidence: [E02]
        confidence: high
      ```
      
      ### C5. state_machine
      ```yaml
      state_machine:
        - name: CONNECT
          on: [HANDSHAKE_REQ] -> HANDSHAKED
        - name: HANDSHAKED
          on: [LOGIN_REQ] -> LOGGED_IN
        - name: LOGGED_IN
          on: [CHAR_SELECT_REQ] -> IN_GAME
        - name: IN_GAME
          on: [MOVE_REQ, ENTER_SCENE_REQ]
        evidence: [E12]
        confidence: medium
      ```
      
      ### C6. unresolved
      ```yaml
      unresolved:
        - item: crypto.key
          need: 二进制内 XOR 密钥常量 / 握手包中的 key 字段
          blocking: true
        - item: 消息 0x0305 的字段布局
          need: 抓取一次触发该操作
          blocking: false
      ```
      
      ### C7. client(客户端语言与打补丁方式)
      ```yaml
      client:
        language: csharp          # csharp | il2cpp | as3 | lua | js | java | cpp
        source_available: true    # 能否反编译出源码(Mono/AS3=true, IL2CPP=false)
        decompiler: dnSpy         # dnSpy | jadx | FFDec | unluac | ida
        patch_method: config_file # config_file | source_rebuild | binary_patch | runtime_hook
        patch_target: "Android/data/<pkg>/files/local_server.txt"
        skip_login: false         # 是否需要跳过官方登录(如 SDK Dummy)
      ```
      
      ### C8. subsystems(通用子系统,别漏)
      ```yaml
      subsystems:
        account:   {enabled: true, store: sqlite}
        save:      {enabled: true, path: "./data/game.db"}
        admin:     {enabled: true, bind: "127.0.0.1", port: 0}   # 仅本机
        resources: {mode: local}   # local | cdn | hybrid
        ports:                     # 主端口 + 战斗 + 后台
          main: 0
          battle: 0                # 常用 main+1
          admin: 0                 # 常用 main+2
        client_patch: {method: config_file}
      ```
      
      ### C9. bots(服务端人机)——  可选拓展
      
      > **不影响核心运行**,跑通之后再考虑。先反推(`extensions/bot-reverse.md`),再复刻。
      > 不需要就整段留空。
      
      ```yaml
      bots:
        # —— 反推结论(来自客户端)——
        needed: true                # 开局是否要求多人
        ownership: server           # server | client | hybrid | unsupported
        supported_by_client: true   # 客户端协议是否支持 AI
        ai_flag_field: "is_ai"      # 客户端里的"这是AI"标记字段
        ai_flag_values: {human: 0, ai: 1}
        identity_fields: ["name", "level", "job", "deck", "avatar", "title"]
        action_opcodes: [0x0303, 0x0304]   # AI 动作复用哪些 opcode
        flow_opcodes: [0x0201, 0x0203]     # 加入/准备/开始/离开
        min_players: 2              # 开局最少人数
        evidence: [E20, E21]        # 证据编号
        confidence: medium
        # —— 复刻配置 ——
        mode: logic                 # puppet | logic | fake_client | external
        auto_fill: 3                # 缺人时自动补几个
        difficulty: normal          # easy | normal | hard
        humanize: true              # 拟人化(延迟/抖动/失误)
        behaviors: [wander, follow, use_card]
        recycle: true               # 房间结束回收
      ```
      > 详见 `extensions/bot-reverse.md`(怎么反推)与 `extensions/bots.md`(怎么实现)。
      
      ---
      
      ## D. 从 Spec 到代码的映射(速查)
      
      | Spec 字段 | 参考实现位置 | 生成/改造动作 |
      |-----------|-------------|--------------|
      | `transport.type` | `net/server.py` | 换 asyncio TCP→UDP/WS |
      | `frame.*` | `config.yaml` / `codec.py` | 直接改参数 |
      | `crypto.*` | `net/crypto.py` | 实现对应算法/密钥来源 |
      | `compress.*` | `codec.py` | 启用并选算法 |
      | `serialize.format` | `codec.py` | 切 json/protobuf/binary |
      | `opcodes.table` | `proto/opcodes.py` | 整表替换 |
      | `messages.*` | `proto/messages.py` | 逐条实现 encode/decode |
      | `state_machine` | `logic/handlers/` | 按状态注册 handler |
      
      ---
      
      ## E. 校验清单(Spec 完成度)
      
      - [ ] `transport` / `frame` / `crypto` / `serialize` 四层都有结论
      - [ ] `opcodes.table` 至少覆盖「连接→登录」所需消息
      - [ ] 每条消息有字段布局(或标注 TODO)
      - [ ] 每个结论有 `evidence` 且置信度标注
      - [ ] `unresolved` 列清了还缺什么证据
      - [ ] 能画出 `state_machine`
      
      > 全部打勾 = 可以开始写服务端;否则先把 Spec 补完,或在闭环中回填。
      
      ---
      
      ## F. 配套:人类可读的协议文档(协议表)
      
      > `protocol.spec.yaml` 是**机器可读**的;还需要一份**人可读**的协议文档,方便评审与交接。
      > **格式参考**:`lan-dot-party/game-protocols` 的条目结构。
      
      每条协议按此结构写(对应 `docs/protocol.md`):
      
      ```
      ### <协议号> <名称>(方向 c2s/s2c)
      - 默认端口:
      - 触发条件:
      - 包结构(hex + 字段表:偏移/长度/类型/字段/含义/证据)
      - 请求 / 响应示例(hex dump)
      - 实现备注(如:长度头含加密/压缩 flag)
      - 证据编号 + 状态
      ```
      
      **必备三块**:① 分层总览(传输/封装/加密/压缩/序列化)② 连接与握手 ③ 按子系统的协议表。
      **验收**:这份文档能让人(或 AI)**照着复原一条消息的收发**,即达标。
      
      > 注意: **先锁版本**:写进文档头部——`客户端版本 X.Y.Z`。
      > 反推时**客户端↔服务端版本强绑定**(参考实现里 社区服务端实现 明确警告"不能混用")。
    • reading-path.md 8.8 KB
      # 最小必读路径:别把整个 skill 一次读完
      
      > **任何模型(GLM / DeepSeek / Claude)都按本文件分派**:
      > 打开本文件 → 选你的任务类型(§1)→ **按顺序打开并读完**指定的那 3~5 个文件 → 再动手。
      > **多数模型不会自动加载文件,必须显式打开。**
      
      > **这份文档解决的事**:这个 skill 有 37 篇参考 + 一份长主文档。
      > 上下文有限的 AI **一次读完再动手**,结果通常是「读了前几节就开始写代码」。
      > 正确做法:**按任务类型只读 3~5 个文件,其余按需查。**
      >
      > 用法:先在这里定位任务类型 → 按路径读 → 动手 → 宣称完成前过一遍「收工前必读」。
      
      ---
      
      ## 0. 三层清单
      
      ### 零层(第一次接触某客户端 / 不懂协议原理时,先读这 1 个)
      
      | 文件 | 为什么必读 |
      |---|---|
      | `references/primer.md` | **原理层**:三要素(配置/协议/代码)、数据包协议本质、协议号与协议表、热更源码。**不懂"在反推什么"时,读它会省掉几周弯路。** |
      | `references/server-architecture-basics.md` | **反推对象地图(逆向视角)**:你在还原哪几类服、网关的隐藏层、从包里认 Protobuf/KCP、同步模型决定"要还原多少逻辑"。**想知道"你在反推的东西由什么组成、去哪找证据"时读。** |
      
      > 已懂原理 → 直接跳到「核心层」。第一次做、或对话里出现"看不懂数据包" → 先读它。
      
      ### 核心层(几乎所有任务都读,3 个)
      
      | 文件 | 为什么必读 |
      |---|---|
      | `SKILL.md` §0(只读 §0.0~§0.6) | 方法选择、铁律、工作流主干、**阅读地图** |
      | `references/workflow-roadmap.md` | **四阶段路线总纲**:先做什么、后做什么、每步在哪验收(+ 四层重定向表) |
      | `schema/project-profile.yaml` | 填了它才知道"读了什么、还缺什么" |
      | `references/ai-contract.md` | **AI 行为契约**:强制产物清单 + 硬性禁止 + 决策可追溯 + 终点/回滚(原 SKILL §13 的完整版) |
      
      > 注意: **`SKILL.md` 其余章节按需读**,不要线性通读。
      
      ### 场景层(按任务类型挑,见 §1)
      
      ### 查阅层(遇到具体问题时再翻)
      
      ```
      references/account.md            账号/注册/密码预处理/接口替换
      references/combat.md             房间与战斗同步模型
      references/drops.md              掉落表与入库
      references/gacha.md              抽卡/保底/重复转换
      references/codegen.md            由 Spec 生成服务端(多语言)
      references/cases.md              真实项目取舍
      references/unity.md / unreal.md  引擎深潜
      references/unity.md / unreal.md / cocos2d.md  引擎深潜(cocos 含 §H2 Live 运营)
      references/live-ops.md           进服后的运营手册(子系统次序/契约反推/双表差异)
      references/inline-server.md      内联服务端(进程内合成响应)第二条路线
      references/runtime-object-synthesis.md 运行时对象合成与字段发现
      references/platform-sdk-and-admission.md 平台 SDK 登录态复用+两段准入
      references/case-il2cpp-inline.md  真实案例:内联服务端
      references/windows.md / termux.md 端游 / 手游运行环境
      references/decision-tree.md      逐层决策树(拿不定主意时)
      references/phases-detail.md      §1~§10 反推主流程详细版(命令/工具/判断/坑)
      references/ai-contract.md        AI 行为契约(强制产物/禁止/终点/回滚)
      references/primer.md             原理层(第一次接触某客户端时)
      references/workflow-roadmap.md   四阶段路线总纲(先做什么后做什么)
      extensions/                      可选拓展(不参与核心验收)
      ```
      
      ---
      
      ## 1. 按任务类型选路径
      
      ### ① 只有安装包,从零开始
      
      ```
      SKILL §0 → from-installer.md → client-languages.md
      → adaptation.md(建档)→ methods.md(选方法)
      ```
      产出:`project-profile.yaml`、引擎/语言判定、证据获取计划。
      
      ### ② 已有 dump/lua/抓包,要定协议
      
      ```
      SKILL §0 → protocol-spec.md → decision-tree.md
      → (有 Lua/源码时)tools/extract_interfaces.py 扒接口清单
      → §4 分层 → §5 静态分析 → §6 字段语义
      ```
      产出:`protocol.spec.yaml`、证据清单、未解决清单。
      
      ### ③ 要写服务端(最关键的分叉)
      
      ```
      wire-level-patching.md   ←  先读这个再决定路线
      → protocol-spec.md(把确认的字段落进 Spec)
      → engineering-practices.md(fixture 回放 + 具名完成点)
      → combat.md / drops.md / gacha.md(对应子系统)
      ```
      **它决定你的路线是「先跑起来」还是「先凑 schema」** —— 读错这一篇,后面全白干。
      
      ### ④ 要把客户端对接过来(改包/重定向)
      
      ```
      client-address-sources.md   ←  先读这个(地址有六类来源)
      → (秒退/黑屏)
      → repack-rename.md(真要改包名/重签时)
      ```
      **顺序很重要**:先做「不改包」的落点,再考虑改包。
      
      ### ⑤ 卡住了 / 客户端不进游戏
      
      ```
      closure-verification.md   ← 6 类假阳性(端口在听、自环通过、日志打勾…)
      → 回到 protocol-spec.yaml 的 unresolved 逐条排查
      → 需要解密/字段时用 methods.md 的 M3
      ```
      
      ### ⑥ 要交付 / 部署 / 给别人用
      
      ```
      release-and-ops.md       ← 监听 vs 对外地址、端口族、配置冻结、备份
      → engineering-practices.md(ADR、E2E 证据包)
      → templates/AGENTS.md、templates/status-matrix.md
      ```
      
      ### ⑦ 接手别人(或上一个 AI)的项目
      
      ```
      closure-verification.md   ←  第一条
      → 对方文档里的结论逐条复核(尤其"唯一卡点是 X"这种)
      → engineering-practices.md(看数据隔离/完成点)
      ```
      
      ---
      
      ### ⑧ 想「不起服务端」:单机化 / 离线版
      
      ```
      inline-server.md              ←  先读这个(先判断该不该走这条路)
      → runtime-object-synthesis.md(响应对象怎么造)
      → platform-sdk-and-admission.md(账号与准入怎么借官方)
      → case-il2cpp-inline.md(完整案例)
      ```
      产出:拦截清单 + 合成响应总表 + 内联版验收完成点。
      
      ---
      
      ### ⑨ 搞懂原理 / 第一次接触某个客户端
      
      ```
      primer.md                     ←  先读这个(在反推什么:三要素 / 协议表 / 热更源码)
      → workflow-roadmap.md(四阶段:先做什么、在哪验收)
      → methods.md(选手段)
      ```
      
      ### ⑩ 动手前:先查"别人有没有做过"(抄作业)
      
      ```
      methods.md 的 M8(已知实现移植法)←  现成实现速查表:同引擎/同厂商/同世代的模拟器、同类 skill
      ```
      有现成 → 抄;只有部分 → 拼装;完全没有 → 纯逆向。**动手前先锁客户端版本。**
      
      ---
      
      ## 2. 无论什么任务:开读前 / 收工前
      
      ### 开读前(30 秒)
      
      ```
      [ ] 目标是什么?(只分析 / 跑通某里程碑 / 完整服务端 / 改客户端)
      [ ] 手上有什么证据?(安装包 / dump / 抓包 / 能运行 / 能注入)
      [ ] 有什么约束?(客户端保护 / 时间 / 权限 / 平台)
      ```
      → 记进 `project-profile.yaml` 的 `method` 段。
      
      ### 收工前(必须过一遍)
      
      ```
      [ ] 产出了 project-profile / evidence-inventory / protocol.spec ?
      [ ] 结论挂证据档位了吗?(static analysis / runtime observation / Inferred)
      [ ] 未解决清单写了吗?
      [ ] 三轴状态更新了吗?(实现 / 自动测试 / 客户端验收)
      [ ] 回滚命令和基线哈希还在吗?
      [ ] 有没有把「端口在听」「自环 OK」「日志打勾」写进成果?
      ```
      
      ---
      
      ## 3. 上下文极小时的应急路径
      
      只能读 1~2 个文件时,按这个优先级取:
      
      | 优先级 | 文件 | 能解决什么 |
      |---|---|---|
      | 1 | `SKILL.md` §0.0~§0.6 | 方法与铁律,避免方向性错误 |
      | 2 | **本文件** | 知道该去读哪个文件 |
      | 3 | `wire-level-patching.md` | 让"先跑起来"成为可能 |
      | 4 | `closure-verification.md` | 避免把假阳性当成功 |
      | 5 | `client-address-sources.md` | 避免"改了 URL 还连官方" |
      
      > 剩下的都可以在**需要时**再读。skill 是查询手册,不是必读教材。
      
      ---
      
      ## 4. 六个反模式(都真实发生过)
      
      1. **线性通读整个 skill 再动手** → 上下文耗尽,写到一半开始猜。
      2. **只读 §0.4 工作流就写代码** → 跳过 Spec,字段全靠猜。
      3. **跳过 `wire-level-patching.md`** → 陷进"必须先解出全部字段号"的路线。
      4. **宣称完成前不读 `closure-verification.md`** → 把"端口在听"写成"已跑通"。
      5. **对接客户端前不读 `client-address-sources.md`** → 改了一个 URL,流量还是回官方。
      6. **能注入却硬要做外部服务端** → 明明可以在进程内合成响应,却先去还原加密 / 封包(`inline-server.md` §0)。
      
      ---
      
      ## 5. 一句话
      
      > **先读"决定路线"的那一篇,再读"干活要用"的那几篇,最后读过"防自欺"的那一篇。**
      > 其余按需查 —— 不要试图一次读完。
    • release-and-ops.md 11.8 KB
      # 发布、部署与运营:跑起来之后的事
      
      > **解决的事**:服务端跑通之后,怎么发出去、怎么让人连上、怎么改数值、怎么不出事故。
      > 来源:三个已跑通的成品服务端的运维/发布实践抽象。
      
      ---
      
      ## 目录
      
      
      ---
      
      ## 1. 三类地址必须分开(最常被合并的坑)
      
      | 变量 | 作用 | 常见错误 |
      |---|---|---|
      | `LISTEN_HOST` | **本机绑定**哪个网卡 | 以为 `0.0.0.0` 就等于"能被访问" |
      | `PUBLIC_HOST` | **告诉客户端的**可达地址 | 没设置 → 客户端拿到内网/回环地址 |
      | `CDN_BASE_URL` | 客户端下载资源的根 URL | 与业务地址混用 |
      
      **规则**:
      
      - 绑定 `0.0.0.0` **只表示监听所有本机接口**,不等于自动获得安全能力,也不等于客户端能访问到;
      - **多网卡 / VPN / 虚拟网卡**环境下,自动推导出的地址**很可能是错的**(会挑到 Docker 网桥那种)
        → 跨设备部署时**必须显式设置** PUBLIC_HOST;
      - 推导顺序要固定并写进文档,例如:`PUBLIC_HOST` → `COMPAT_HOST` → `LISTEN_HOST`;
        都未设置且绑定是 `0.0.0.0` 时,取**首个非回环 IPv4**,再不行回退 `127.0.0.1`;
      - **不要从 HTTP 请求头推测地址**(反代场景会得到错的)。
      
      > 排查金句:「电脑/浏览器能访问该域名,不代表设备也能访问。」
      
      ---
      
      ## 2. 端口族约定(让人一眼记住)
      
      ```
      主端口            P         游戏 HTTP
      对战/战斗端口      P + 1     TCP
      ```
      
      **好处**:换主端口时其余跟着走,文档和脚本不用各自维护一套常量。
      
      配套规则:
      
      - **后台只绑回环**。无桌面的机器用 SSH 端口转发访问:
        ```sh
        ssh -N -L <本地端口>:127.0.0.1:<后台端口> 用户@服务器
        # 然后浏览器开 http://127.0.0.1:<本地端口>/
        ```
      - 模拟器连本机常用别名地址(如 `10.0.2.2`),要在文档里写明。
      
      ---
      
      ## 3.  启动期冻结配置(避免"改了不生效"的假结论)
      
      **做法**:所有运行时参数在**进程启动时解析一次**,冻结成一个不可变配置对象,之后不再读环境变量。
      
      **为什么**:保证同一进程内的行为稳定(例如资源版本、每日边界、演出参数不会中途变)。
      
      **对外承诺(必须写进文档)**:
      
      ```
      运行中修改 .env / 配置 → 不生效
      修改后必须重启服务
      后台不提供在线修改入口
      ```
      
      ### 参数校验要 fail-fast
      
      - 上限之间有**约束关系**(例:单连接缓冲上限 ≥ 单帧上限);
      - 数值必须为正安全整数,且有上下界;
      - **非法值 → 抛明确错误、服务不启动**,而不是用默认值兜过去。
      
      > 这条直接消灭"我改了配置但行为没变"和"配置写错了却静默生效"两类问题。
      
      ### 默认值表要落文档
      
      传输/战斗类的调优参数(握手超时、最大帧、缓冲上限、keepalive、发送队列的消息数/字节数/最长背压等待、
      战斗租约、房间过期时间、清理间隔、重连宽限、NPC 加入延迟…)**列成一张有默认值的表**。
      
      要点:
      - 普通部署**保留默认值**,把它们标成"可选高级配置";
      - 写明「**明确 Bye 不适用重连宽限**」这类例外;
      - 写明哪些值**只在启动时读取**。
      
      ---
      
      ## 4. 多端分发与运行目录
      
      **发布形态**:一套源码交叉编译出多平台可执行文件,**共用同一份资源目录**。
      
      ```
      <运行目录>/
      ├── <server-exe>            Windows x64
      ├── <server-linux-amd64>    Linux x64
      ├── <server-linux-arm64>    Linux ARM64
      ├── launcher                (可选)图形启动器
      ├── start/stop 脚本(各平台各一份)
      ├── <资源目录>/              客户端要的资源 + 资源清单
      ├── 部署配置                 保留包内配套文件
      ├── cdn.json                (可选)CDN 配置
      └── _local/data/            首次启动生成的数据库与存档
      ```
      
      **规则**:
      
      - **资源目录必须与服务端程序同级**;不用拷进源码目录,也不要拆开资源目录;
      - **源码目录只用于编译**;编译产物**替换运行目录里的同名文件**即可;
      - 交叉编译通过 **≠** 目标设备动态验收完成;
      - 测试分两类:**不需要资源的合成测试**默认跑;**需要完整运行资源**的检查明确跳过
        (用环境变量指向资源目录时才跑)。
      
      ---
      
      ## 5. 数据、日志、备份的目录纪律
      
      | 目录 | 内容 |
      |---|---|
      | `<数据目录>/data/` | 数据库与存档 |
      | `<数据目录>/runtime/` | 运行日志 |
      | `<数据目录>/cdn-sync/` | 同步记录(**不含密钥/代理密码**) |
      
      **备份三步**:
      
      1. **先正常停止服务**(不要边写边拷);
      2. 复制**整个数据目录**;
      3. 同时保留自己的 `cdn.json`、同步配置和连接设置。
      
      **更新程序时**:停服 → 备份数据目录 → 换程序(必要时换资源)→ 保持存档/连接配置。
      **数据库 schema 要带版本**(例 `schema3`):同版本更新免清档,**更早版本明确不支持加载**。
      
      > 反例(实测事故):更新后进度不见了 —— 一般是**启动了新目录里的空存档**。
      > 正确处理:保留旧目录备份后核对,不要反复清数据。
      
      ---
      
      ## 6. CDN 是可选项,且必须能一键关掉
      
      **设计**:
      
      - 默认**从本地服务端下载**,不配置任何 CDN 也能玩;
      - 需要时给出一个 `cdn.json`,**只有公开下载地址**:
        ```json
        {"base_url": "https://<公开下载根>/<prefix>"}
        ```
        **`base_url` 置空即恢复本地下载**,重启服务 + 客户端重新登录后生效。
      
      **边界**:CDN 只改变"资源下载根地址",**API、对战、版本目录、后台保持原地址**。
      服务端仍需保留完整资源目录。
      
      ### 对象命名与版本(最关键的一条)
      
      ```
      补丁对象:patch/<平台>/patch/<原始路径>.v<CRC32 大写>
      语音对象:cpk/CPK/<逻辑名>.v<整数版本>
      ```
      
      - **内容变了就必须提升版本号**(或换 prefix),否则客户端/CDN 的旧缓存继续生效;
      - 版本来源要**唯一**(一个解析函数同时服务本地版本接口与 CDN 导出),**不要另建一套算法**;
      - 别名必须**直指物理对象**,不允许多级别名或仅大小写不同的重名;
      - **文件名/内部名/逻辑名不要混成一个名字**;
      - 摘要(SHA-256)是**审计身份**,**不替代**客户端用的 CRC/整数版本;
      - 上传冲突检查是**最后一道保护**,不代替版本登记。
      
      ### 同步工具的行为约定(照抄即可)
      
      - 内置在服务端程序里(不需要额外装 Python/Go/AWS CLI);
      - 默认 8 并发、流式上传、不复制资源、不整包进内存;
      - **大小/摘要一致就跳过** → 中断后可直接重跑;
      - 先 `--dry-run` 只检查输入(不联网、不上传、不改配置);
      - 上传完成后**抽查公开 HEAD + Range 206 + 原始字节**,全部成功才更新 `cdn.json`;
      - 失败保留原配置;**不自动重启正在运行的服务**;
      - **远端同名但内容不同 → 拒绝覆盖**,不删旧文件;
      - 代理只认进程环境变量(大小写都认,**大写优先**),且注意:
        **HTTPS 目标必须用 `HTTPS_PROXY`**(值可以是 HTTP 代理走 CONNECT);
        只有 `HTTP_PROXY` **不会**代理 HTTPS;**不读系统代理面板,也不读 `ALL_PROXY`**;
      - 公开源站要求:匿名 GET/HEAD 可用、长度正确、**保留 Range 206**、
        **禁用 gzip/转码/内容重写**;
      - 已版本化的对象用**长期不可变缓存**,不缓存错误响应;
      - 客户端地址里**不要填带临时签名的 URL**。
      
      ---
      
      ## 8. 数值改动要定义「变更语义」
      
      改配置时,**必须说清对已有数据的影响**,否则玩家会遇到"改了但没生效/生效过头":
      
      | 规则类型 | 示例 |
      |---|---|
      | **不重置已领取记录** | 调整签到奖励后,已领过的不补发、也不重算 |
      | **不补发** | 首通奖励改动后,已通关账号不追溯发放 |
      | **不回收** | 停售商品不回收已购买的内容 |
      | **沿用原计划** | 已开始的副本沿用开始时的奖励计划 |
      | **只发一次** | 毕业邮件按"当时保存的配置"发一次,重试不重复发 |
      | **背包满可稍后领** | 奖励不丢,转为稍后领取 |
      
      推论:**奖励发放是一个一次性事件,配置只是它的输入**。别把"配置"当成"实时计算函数"。
      
      ---
      
      ## 9. 账号与身份的几个硬规则
      
      - **一个账号名只能绑一个角色,一个角色也只能绑一个账号**(唯一性要双向保证);
      - **解绑**:保留角色进度、原账号密码失效、**不强制踢掉已登录设备**;
      - 找回依赖**该服务器仍保留账号数据**;不能恢复已删除的服务器存档;
      - 密码/账号名要**明确字节与字符规则**(长度区间、允许字符、是否区分大小写);
      - 首次进入提供**游客**模式;绑定后才可跨设备找回;
      - **不同独立服务器的账号与进度不互通**。
      
      ### 性能相关:空闲对象缓存
      
      可选环境变量控制空闲账号/会话处理器数量(默认值 + 上限 + `0` 表示禁用):
      
      ```
      请求结束 → 进入空闲缓存
      超出数量 → 淘汰最久未用
      长时间未用(如 5 分钟)→ 回收
      请求执行中 → 不淘汰
      ```
      
      > 这不是"在线人数上限",只是**内存换延迟**的权衡。文档要写清这一点。
      
      ---
      
      ## 10. 后台/服务器时间与"业务日"
      
      - 有**每日/每周周期边界小时**的配置(例:默认凌晨 5 点),**只在启动时读取**;
      - 结算按**真实业务日**推进,**连续离线不补发**(或按明确规则补发 —— 但必须写清);
      - 服务器时间与"业务日"是两件事:**时间可以跨节点不同**,各节点按自己的时间判断开放期;
      - 多节点场景:某成员所在节点不在开放期 → **只拒绝该成员,不删除房间**。
      
      ---
      
      ## 11. 多节点/多服(如果你要做)
      
      **原则**:**每个节点保留自己的账号、存档、进度与结算。**
      
      ```
      Host:    本地客户端 -> Host:主端口 -> 本地 DB
               所有参与者 ------------> Host:对战端口
               Client 服务 -----------> Host:控制端口
      Client:  本地客户端 -> Client:主端口 -> Client 自己的 DB
               降级后的新房间 -------> Client:对战端口
      ```
      
      - **只有房主所属数据库扣入场成本**;每个参与者的结算**只写自己的库**;
      - 节点**不复制、不合并**其他节点的存档;
      - 节点间**严格比较**客户端版本 / 资源版本 / 内容摘要 → 差异映射为客户端**已有的**失败码;
        **不触发资源更新、不下载/切换/修复另一节点的 CDN**;
      - 控制端口**不提供**后台、主游戏 API、CDN;
      - **令牌明文只在创建时输出一次**。
      
      > 这套设计的好处:任一时间只有一个节点对某份存档有写权 → 不会出现跨节点覆盖。
      
      ---
      
      ## 12. 运维文档的必备章节
      
      ```
      getting-started/   安装、启动、连上(含模拟器/手机两种地址)
      network-boundary/  监听地址 vs 对外地址、端口、公网不提供的能力
      operations/        后台能改什么、改了什么时候生效
      backup/            停服 → 备份 → 更新 → 恢复 的顺序
      troubleshooting/   连不上 / 进度不见 / 更新没生效 的排查顺序
      ```
      
      **FAQ 里最值钱的几条**(都是真实踩过的):
      
      - 浏览器能打开主页**只说明 HTTP 端口可达**,不代表游戏能连;
      - CDN 下载停在 0% → 查**设备的** DNS/TLS 连通性,不是电脑的;
      - 安装后仍是旧功能 → 确认装的是新包(别只看文件名/版本号);
      - 购买显示成功但余额没变 → 检查"购买到账"开关是否关闭(关闭时**返回成功但不发货**);
      - 卸载或清除应用数据会**移除本机登录信息**,别把它当常规更新步骤
        (服务端存档与手机应用数据是**两处独立数据**)。
    • repack-rename.md 8 KB
      # 改包名 / 重打包 / 资源与签名(实战清单)
      
      > 目的:**改包名(便于双开)** 或 **让客户端默认连自建服**。
      > 改包名会触发**一串**连锁问题,按下面的顺序一次修完,否则就是无限"秒退"。
      
      ---
      
      ## 1. 改包名要动的地方(缺一个就崩)
      
      | # | 位置 | 怎么改 | 不改的后果 |
      |---|---|---|---|
      | 1 | `AndroidManifest.xml` 的 `package` 属性 | AXML 字符串池,**等长原地替换**(**只改 package,别批量替换类名!**) | Activity/组件找不到 → 闪退 |
      | 2 | `resources.arsc` 的 `ResTable_package.name` | UTF-16LE 等长替换 + 修 local/CD **两处 CRC32** | `getIdentifier(..., getPackageName())` 返回 0 → SDK 初始化失败 |
      | 3 | "包名派生密钥"加密过的资源 | 用新包名**重新加密**(见 §3) | 解密失败 `BadPaddingException: BAD_DECRYPT` |
      | 4 | 权限名 / provider `authorities` | AXML 里含旧包名的字符串 | 安装冲突 / provider 崩溃 |
      | 5 | 签名 | 改包后原签名失效 → 重签,或按  §4 保留原签名 | 装不上 / 被自校验杀 |
      
      > 注意: **坑**:`AndroidManifest.xml` 在 zip 里通常是 **deflate 压缩**的,直接读 ZIP 数据看不到明文。
      > 想**保持文件偏移不变**:解压 → 改 → 重压 → **补齐到原压缩长度**(后面追加空 stored block:`00 00 00 FF FF`)。
      > 长度补不上时可换压缩级别重试;**如果压缩后比原来大**,就只能整包重建(偏移会变)。
      
      ---
      
      ## 2. 等长替换技巧(保持 zip 偏移、保留原签名有效范围)
      
      ```python
      # resources.arsc 的包名在 ResTable_package 里,UTF-16LE
      i = data.find(OLD.encode('utf-16-le'))
      assert i > 0
      data[i:i+len(OLD)*2] = NEW.encode('utf-16-le')   #  必须等长!
      # 之后更新该 entry 的 CRC32(local header +14 / central directory +16 两处都要改)
      ```
      - **为什么要等长**:名字变长会移动后面所有字节 → zip 偏移全乱、原签名彻底废掉。
      - 目标包名长度对不上时,挑一个**同长度的替代包名**(例:31 字符 → 31 字符)。
      - 改完用 `zipfile.ZipFile(p).testzip()` 应该返回 `None`。
      
      ---
      
      ## 3. 资源是「包名派生密钥」加密的(YH SDK 实例)
      
      ```python
      S    = 包名 + "#@()!%&#' + <版本串>          # 版本串取自资源 yhdataset_YHCORESDKVERSION
      key  = SHA256(S)      # 32B → AES-256
      iv   = MD5(S)         # 16B
      算法  = AES/CBC/PKCS7Padding
      资源值 = "//$yhdataset$//" + Base64(密文)
      ```
      
      **排查流程**
      1. 遍历同类资源,看**哪些值以 `//$yhdataset$//` 开头** —— 只有少数是密文,其余是明文。
      2. 用**原包名**解出明文 → 用**新包名**重新加密 → 写回该资源。
      3. 密文长度通常不变(同明文 + PKCS7),所以能**等长原地替换**,偏移不变。
      
      **找不到 KDF 怎么办**
      - 反汇编提供该 native 方法的 so(本例 `libyhcomponent.so`),看
        `strings` 里的 **格式串**(`%s%s%s%s`)、**盐**(`#@()!%&#'`)、调用的 Java helper 名(`sha256`/`md5`/`getAesP`)。
      - 拿 **`getPackageName()` + 版本串 + 盐** 做排列组合 × 常见哈希 ×(key/iv 取法)×(密文自带 IV / 固定 IV),
        用小脚本爆破;判定成功的条件:**解密结果能通过 PKCS7 且是合法 JSON/文本**。
      
      ---
      
      ## 4. 签名:想保留"原证书指纹"
      
      1. 构建时**不要用自己的签名**(或签完再替换掉)。
      2. 从原包取出 `META-INF/*.RSA`、`*.SF`、`MANIFEST.MF` 塞进新包。
         > 证书文件名**可能是非标准的**(例子见 ),别只找 `CERT.RSA`。
      3. **删掉 APK Signing Block(v2/v3)** —— 否则系统报告的仍是你的新证书。
      4. 前提:目标设备**已禁用签名校验**(否则装不上)。装完立刻用
         `dumpsys package <新包名> | grep -i sig` 或 `MT 的 mt_apk_read_signature` 确认证书摘要是不是原包的。
      
      ---
      
      ## 5. 重打包的正确姿势(2GB 包秒级完成)
      
      - [x] 不要 apktool 全解全 build:慢,而且会把所有条目重压一遍(偏移全变)。
      - [x] **ZIP 原样复制**:
        1. 解析原 **Central Directory** 拿到每条 entry 的 `header_offset` / `compress_size`;
        2. 逐条把**压缩数据原样搬运**(不重压),只**替换目标条目**;
        3. 重写 CD + EOCD(注意 `flg & ~0x8` 清掉 data-descriptor 位,长度按自己写的填)。
      - 参考实现(本 skill 自带):
      ```bash
      python3 tools/repack_zip.py game.apk game_stub.apk \
      
      # 塞回原包签名、顺手删掉自己的签名
      python3 tools/repack_zip.py mod.apk mod_origsig.apk \
          --delete META-INF/ANDROID.RSA --delete META-INF/ANDROID.SF --delete META-INF/MANIFEST.MF \
          --add-from orig.apk META-INF/APP.RSA \
          --add-from orig.apk META-INF/APP.SF \
          --add-from orig.apk META-INF/MANIFEST.MF
      # 结束后它会打印 testzip(None 才对)和"是否含 Signing Block"
      ```
      
      ---
      
      ## 6. 验证清单
      
      - [ ] `zipfile.testzip()` 返回 `None`(CRC 全对)
      - [ ] `pm install -r -d <apk>` 成功
      - [ ] 装机前先 `pm uninstall` 旧包:避免旧数据/旧 uid 造成误判
      - [ ] **每轮只改一个变量**,并记录:改了什么 → 新症状 → 结论
      
      ---
      
      ## 7. 让客户端连自建服的落点(按代价从低到高)
      
      | 落点 | 做法 | 适用 |
      |---|---|---|
      | **网络层重定向**(代价最低) | `iptables -t nat -A OUTPUT -m owner --uid-owner <uid> -p tcp --dport <p> -j DNAT --to-destination 127.0.0.1:<lp>`;或 hosts 重定向域名 | 只要 root;不触发自校验 |
      | **LSPatch / NPatch 模块 + Signature Bypass** | 注入"重定向模块"改请求目标;同时用 **Signature Bypass** 骗过签名校验(见 §8) | 无 root / 想模块化 |
      | **改包内地址** | 若地址在**明文配置文件/Lua**里(例:Unity 的 `ab_lua`、`gameversion.lua`),直接改字节;注意**包名变了要连带** | 想做成独立安装包 |
      
      > 注意: **别把资源服/CDN 端口也劫持**(例:`:9999`、`443`)—— 首启要下载 Lua/资源,劫持了会**一直黑屏**。
      
      ---
      
      ## 8. 签名校验与绕过(重签名 / 无 root 场景)
      
      > 重签名后,若游戏校验签名 → **秒退**。两条路:**让校验"看到原签名"**,或 **根本不改签名**。
      
      ### 8.1 LSPatch 自带 Signature Bypass(首选)
      
      LSPatch 修补 APK 时会**保存原签名信息**,运行时按等级 hook,让系统/应用读到的**仍是原签名**:
      
      | 等级 | 值 | 做什么 |
      |------|----|--------|
      | Off | 0 | 不绕过 |
      | Bypass PM | 1 | hook `PackageParser.generatePackageInfo` + 替换 `PackageInfo.CREATOR`,改写返回的签名 |
      | **Bypass PM + openat** | **2** | 再加 **native `openat` hook**:把对 patched APK 的读取重定向到 cache 里的**原 APK**,文件级签名读取也拿到原块 |
      
      > **用 LSPatch 时优先把 Signature Bypass 调到等级 2**(多数签名校验直接过)。
      > 这正是"LSPatch 免 root 注入还能绕过签名"的原因;对应的类在 LSPatch 源码 `SigBypass.java`。
      
      ### 8.2 不依赖 LSPatch:独立"签名破解"
      
      - `L-JINBIN/ApkSignatureKiller` —— 往 `Application` 入口插代码,**hook `PackageManager.getPackageInfo`**,把签名改回原包。
      - Xposed 模块 **核心破解(CorePatch)** —— 去系统签名校验、直装改包、降级安装。
      - 自己写:hook `PackageInfo.signatures` / `Signature.toByteArray()` / `getPackageInfo(..., GET_SIGNATURES)`。
      
      ### 8.3 真正"不改签名":虚拟容器(VirtualApp 系)
      
      - **VirtualXposed / 太极(TaiChi) / 应用转生**:把 App 装进**虚拟空间**运行,**APK 本身不被改动** → 签名不变,模块在容器内注入。
      - 代价:**兼容性有限**(新版 Android / 重 native / 强客户端保护的游戏常跑不起来)。
      - 对 Unity/il2cpp 这类,**通常不如"LSPatch + Signature Bypass"稳**。
      
      > **选型**:先试 **LSPatch(Sig Bypass 等级 2)** → 不行再独立签名破解 → 虚拟容器兜底。
      > 判断"是不是还在下载":看 `files/` 下有没有生成热更目录(如 `lua_src`),以及是否还有到 CDN 的连接。
    • runtime-object-synthesis.md 5.2 KB
      # 运行时对象合成与字段发现
      
      > **本文解决的事**:在进程内做服务端(或做局部替换)时,响应不是「字节」,
      > 而是**客户端自己的协议类对象**。你需要知道该 new 哪个类、填哪些字段。
      > 这份知识**不用猜** —— 客户端自己的类型系统就是 schema。
      >
      > 与 `wire-level-patching.md` 互补:那篇对付「字节」,本篇对付「对象」。
      > 实测来源见 `case-il2cpp-inline.md`。
      
      ---
      
      ## 0. 核心思路:让客户端自己交代 schema
      
      外部服务端路线里,字段名要从字节差分里**推**出来。
      进程内路线里,字段名可以**直接读**:
      
      | 你要的东西 | 从哪里读 |
      |-----------|---------|
      | 这条 cmd 对应哪个响应类 | 客户端网络门面的分发代码 / 已有的响应构造点 |
      | 这个类有哪些字段、什么类型 | 反射遍历字段(名字 + 类型名) |
      | 嵌套对象 / 列表元素长什么样 | new 一个空对象再遍历它 |
      | 枚举有哪些合法值 | 读枚举类型或客户端常量表 |
      
      一句话:**先 dump,再合成。**
      
      ---
      
      ## 1. 最小反射工具集
      
      项目里值得先写出来的几个原语(实测都必要):
      
      | 工具 | 作用 |
      |------|------|
      | `GetClass(ns, name)` / `GetMethodInfo(ns, type, method, argc)` | 拿类型与方法(**带参数个数**,重载必须靠它区分) |
      | `NewObject(typeName)` | new 一个客户端协议对象 |
      | `TryGetField(obj, name, &out)` / `TrySetField(obj, name, value)` | **按字段名**读写,返回是否成功 |
      | `GetObjectField(obj, name)` | 取引用类型字段(嵌套对象) |
      | `DumpObjectFields(tag, obj, depth)` | 递归打印字段名 / 类型 / 值 ← **本路线的核心工具** |
      | `GetManagedTypeName(obj)` | 运行时真实类型名(比静态类型可靠) |
      | `ListSize` / `ListGetObject` / `ListGetInt32` | 容器遍历 |
      
      > 注意: 重载靠**参数个数**区分;同名不同参数的方法拿错 = 拿到另一个函数。
      
      ---
      
      ## 2. 字段发现循环(遇到没实现的 cmd 时)
      
      ```
      ① 客户端发来未处理的 cmd
      ② DumpObjectFields("XxxReq", request)          → 请求字段清单(可反推语义)
      ③ resp = NewObject("XxxResp")                  → 新建空响应
      ④ DumpObjectFields("XxxResp.schema", resp)     → 响应字段清单(含嵌套结构)
      ⑤ 对每个容器字段:DumpObjectFields("Elem", ListElement(resp, field))
                                                     → 列表元素的字段清单
      ⑥ 按 ②④⑤ 的清单填值 → 先让它「不崩」,再逐字段补真实语义
      ```
      
      这套循环的价值:**你不需要 schema 文档,客户端自己会告诉你**。
      第 ④ 步新建的空对象是「合法空实例」,它的字段就是权威结构。
      
      ### 2.1 记录在哪里
      
      把 dump 结果按 cmd 归档(实测做法:`out/interfaces/` 下按 cmd 一个文件)。
      它同时是你 `protocol.spec.yaml` 的**字段部分初稿**。
      
      ---
      
      ## 3. 填值的三条纪律
      
      **① 先「结构合法」,再「语义正确」**
      
      顺序永远是:对象能构造 → 容器非空 → 字段填默认合法值 → 客户端不崩 → **再**逐条补真实语义。
      反过来(先抠语义)会让你分不清「崩」是结构错还是值错。
      
      **② 引用类型字段必须给非空对象**
      
      客户端读嵌套字段前**通常不判空**。少给一个子对象 = 空指针崩溃。
      做法:`NewObject` 之后把它的容器字段也填成空容器(而不是 `null`)。
      
      **③ 默认值不等于合法值**
      
      `0` / `""` / 空列表在业务层往往代表「不存在」,
      可能被客户端当成「该功能未开放」而直接隐藏,表现为**你没实现的功能悄悄消失**。
      判断一个字段是真 `0` 还是「没填」,看客户端**怎么用**它,不要看类型。
      
      ---
      
      ## 4. 需要保命的三件事
      
      | 事项 | 做法 |
      |------|------|
      | 托管对象生命周期 | 跨帧 / 入队的对象必须 `gchandle_new` 持有,用完 `gchandle_free`(GC 会搬对象) |
      | `MethodInfo*` 缓存 | `GetMethodInfo` 结果**缓存成静态**,别每帧查 |
      | 托管字符串 | 是 UTF-16;由来回复转换,别把 C 字符串直接塞进去 |
      
      ---
      
      ## 5. 对象级 → wire 级:什么时候该切换
      
      | 情况 | 该做什么 |
      |------|---------|
      | 只要本机跑通 | 停在对象级即可 |
      | 要产出协议文档 / 要外部服务端 | 把对象级结论(cmd + 字段名 + 类型)**翻译成 wire 规格** |
      | 抓包里字段号对不上字段名 | 用一条请求 / 响应做**对象 ↔ 字节**对照,建立字段号映射 |
      
      > 顺序建议:**对象级先跑通,再回头补 wire**。
      > 直接啃 wire 往往卡在「字段还没定完」,而对象级能立刻给出一份**可信字段清单**。
      
      ---
      
      ## 6. 反模式
      
      | [x] | 为什么错 |
      |----|---------|
      | 不看类型定义,凭 cmd 编号猜结构 | 客户端有现成答案,猜是浪费 |
      | 一次填完所有字段再测 | 崩了不知道是哪个字段的错 |
      | 用 `null` 填嵌套对象 | 客户端普遍不判空 → 直接崩 |
      | 全填 `0` 交差 | 功能静默消失,看起来像「没实现」 |
      | 忘记 free gchandle | 长跑后内存增长且对象不回收 |
      
      > 相关:`inline-server.md`(在哪拦)、`wire-level-patching.md`(怎么改字节)、
      > `protocol-spec.md`(结论落到哪)。
      
    • server-architecture-basics.md 9.9 KB
      # 反推对象地图:真实服务端长什么样(逆向视角)
      
      > 注意: **本文不是教你怎么开发服务端。**
      > 它告诉你——**你要反推的那个东西**,在真实项目里由哪些部件组成;
      > **每个部件会在客户端留下什么痕迹**;**你该去找什么证据、在第几阶段用**。
      >
      > **一句话**:把"服务端架构知识"翻译成**逆向时的线索清单**。
      >
      > **知识来源**(真实项目/一手):平台云《游戏服务器的架构演进》、`jzyong/GameDevAndOps` MMORPG 架构、
      > KCP 官方文档、Protocol Buffers 官方 Encoding 文档、Skynet / Pomelo / KBEngine / NoahGameFrame。
      > **反向用法**:它们的架构 = 你反推时的"地图"。
      
      ---
      
      ## 0. 为什么"逆向"要先懂架构
      
      因为不知道对手的设计,就**不知道该去哪找证据**。不懂架构会出现这些典型误判:
      
      | 误判 | 真相 | 去哪查 |
      |------|------|--------|
      | 把**网关合并包**当一条消息 | 同刻多条合并成一条(≤MTU) | §3 |
      | 把**分段压缩**当加密 | 只对超 MTU 的部分压缩 | §3、§7 |
      | 以为**只有一台服** | 登录服/网关/逻辑服/DB 是多级 | §1、§2 |
      | 在**错误的层**找协议字段 | 外层(客户端↔网关) ≠ 内层(网关↔逻辑服) | §4 |
      | 把"缺对象"当成**字段错了** | 可能是 AOI 裁剪 | §7 |
      
      >  **原则**:本文的每一条架构知识,都要能回答——"**所以客户端上/抓包里,我该看到什么?**"
      
      ---
      
      ## 1. 反推对象地图:你在还原哪几类服
      
      真实游戏服务端是**多个角色的进程**。反推前先确定"客户端到底直接连谁"。
      
      | 服务器角色 | 它管什么 | 客户端会**直接**连它吗 | 反推时找什么证据 |
      |-----------|---------|:---:|-----------------|
      | **登录服 / 账号服** | 登录校验、**服务器列表**、网关列表 | [x](多为 HTTP) | `getServerList` 类接口 / 域名+端口 |
      | **网关服 (gate/connector)** | 转发、会话、**消息合并**、加解密、压缩、限流 | [x](长连接) | 长连接地址、连接后首个下发密钥/会话 |
      | **游戏逻辑服** | 玩家操作逻辑 | [x](经网关) | 经网关转发的消息 ID 表 |
      | **场景服 / 非场景服** | 场景内(行走战斗)/ 无关(公会聊天) | [x] | 消息按前缀聚类的子系统 |
      | **世界服** | 跨服/跨区 | [x] | 跨服玩法相关 opcode |
      | **DB 代理 / 日志 / 充值服** | 落库 / 日志 / 校验充值 | [x] | 充值接口、日志上报 |
      
      > [x] **逆向动作**:抓一次启动流量,标注**每一个**被连接的 host:port,判断它属于哪一类。
      > 典型链路:`HTTP登录服 → 拿网关地址 → 连网关(TCP) → 网关转发到逻辑服`。
      
      ---
      
      ## 2. 架构三代 —— 各自在客户端留下的痕迹
      
      | 代际 | 特征 | **客户端/抓包会留下的痕迹** |
      |------|------|--------------------------|
      | 第 1 代 · 单线程 tick | 固定频率 tick 更新世界 | **心跳/帧率/刷新节奏**;心跳包周期 = 反推 tick 的线索 |
      | 第 2 代 · 分区分服(滚服) | 每服一个独立世界;跨服/合服 | **"服务器列表"接口**(阶段 2 重定向必须 mock 它);多区服字段 |
      | 第 3 代 · 世界服 | 网关前置 / cluster / 无缝地图 | 客户端**只连网关**;协议**分内外两层**;可能出现"跳节点/迁移"消息 |
      
      > [x] **逆向动作**:
      > - 有区服列表 → 目标是**滚服结构**,先去找 `登录服` 的列表接口。
      > - 连上后马上收到密钥/会话 → 客户端连的是**网关**,协议有封装层。
      > - 出现"scene/zone/node 切换"字段 → 可能是**世界服/无缝地图**。
      
      ---
      
      ## 3. 网关:逆向时最容易撞的隐藏层 
      
      **多数联网游戏,客户端连的是网关。** 网关做的每件事,都会在你抓的包上留下"伪装",必须识别:
      
      | 网关行为 | **抓包时的表现** | 你该怎么处理 |
      |---------|----------------|-------------|
      | **消息合并**(同刻多条合并,≤MTU,有超时) | 一个 TCP 段里**多条消息**,或两条逻辑消息粘在一起 | 按**长度头**循环拆,不能假设"一包一条" |
      | **会话绑定**(连接→玩家) | 连接期 / 消息里的**玩家标识** | 别当成协议业务字段 |
      | **数据加解密**(如 RC4,**建连时下发密钥**) | 连接后先收到一个短包(密钥/随机数) | 从这里找**密钥来源** |
      | **数据压缩**(如 Snappy,**只压超 MTU 的部分**) | 长短包混合,部分高熵 | 注意: **别整体解压**;逐段判 |
      | **长度头内 flag 位** | 长度值"不合常理" | 长度里的高位可能是**加密/压缩标记** |
      | **频率限制 / IP 黑名单** | 请求被拒 / 连接被断 | 解释"为什么这个操作没响应" |
      
      > [x] **逆向动作**:拿到长连接后,**先假定有网关层**——按长度头拆包、找连接期的密钥下发、
      > 检查长度头有没有 flag 位。这三件事做对了,后面才谈得上解字段。
      
      ---
      
      ## 4. 从客户端痕迹反推协议形态
      
      以真实 MMORPG 为例,**照这个形状去套你抓的包**:
      
      ```
      【客户端 → 网关】        消息长度[4] + 消息ID[4] + Protobuf字节流
         · 长度 32 位可能含 flag:某位=加密、某位=压缩、低位=真实长度
         · 加密:对称(如RC4),密钥建连时下发
         · 压缩:只压超 MTU 的部分
      
      【网关 → 逻辑服(内部)】 消息长度[4] + 消息ID[4] + 玩家ID[8] + Protobuf字节流
                                                ↑ 内层多这一截
      ```
      
      > [x] **逆向核对清单**:
      > 1. 头部是不是 `长度 + 消息ID`?还是 `长度 + opcode` 变体?
      > 2. 长度值里有没有 **flag 位**?(决定你之前"解不开"是不是没先摘 flag)
      > 3. 你抓的是**外层**协议——**别拿它直接当内层**(内部可能多"玩家ID")。
      > 4. **消息ID 表 = 你要挖的协议表**;网关"按消息 ID 分发" = opcode 路由。
      
      ---
      
      ## 5. 序列化 / 传输:在包里怎么认
      
      ### 5.1 认 Protobuf(真实 wire format)
      
      | 特征 | 在包里长这样 | 判据 |
      |------|-------------|------|
      | **varint**(变长整数) | 小值 1 字节;每字节最高位=续位 | `08 96 01` = 字段1=150 |
      | **TLV** | `tag + length + value` | tag = `(field<<3) \| wire_type` |
      | **LEN 字段** | `0A <len> 字符串` | 0x0A = 字段1、wire_type=2 |
      
      - wire type:`0=VARINT` `1=I64` `2=LEN` `3/4=GROUP(弃)` `5=I32`。
      - 为什么"少字段能跑、错字段必崩":protobuf **老解析器能跳过不认识的新字段**。
      
      > [x] **逆向动作**:先 `protoc --decode_raw < body` 试解(`decision-tree.md`)。
      > 解出可读字段 = 序列化层基本判对。
      
      ### 5.2 认 KCP(可靠 UDP)
      
      > 真实协议:纯算法 ARQ,架在 UDP 上,**自己不管 UDP 收发**。
      
      | 特征 | 表现 |
      |------|------|
      | 头字段 | `conv(会话号) + cmd + frg + wnd`;**conv 双方必须一致** |
      | 快速重传 | 收到跳过的 ACK 就重传(不用等超时) |
      | RTO | ×1.5(不像 TCP ×2),丢包恢复更快 |
      | MTU | 默认 1400 |
      | 承载 | **UDP** |
      
      > [x] **逆向动作**:看到 UDP + 固定头 20 字节左右 + "有重传但不像 TCP" → 想 KCP。
      
      ---
      
      ## 6. 同步模型 —— 决定"你要还原多少逻辑"
      
      这是**反推工作量的分水岭**:同样是"打一下",两种模型下服务端要还原的东西完全不同。
      
      | | **状态同步** | **帧同步 (Lockstep)** |
      |---|---|---|
      | 逻辑谁算 | **服务端算**,下发结果状态 | **客户端各自算**,服务器只收/广播指令 |
      | **你要还原服务端多少** | **多**:伤害/掉落/移动都要真算 | **少**:多为转发 + 校验 |
      | 客户端里的**识别证据** | 大量"结果状态"下发 | **随机种子 / 帧号 / 校验和 / 确定性模拟代码** |
      | 客户端保护 | 强 | 弱(靠一致性校验) |
      | 常见类型 | MMO / RPG / 卡牌 / 多数手游 | RTS / MOBA / 格斗 / 强对抗 |
      
      > [x] **逆向判据**:在客户端里 **grep「种子 seed / 帧号 frame / 校验和 checksum / lockstep / deterministic」**。
      > - 命中 → 大概率**帧同步**,服务端逻辑相对轻,重点还原**指令转发 + 校验**。
      > - 未命中、且看到大量"状态下发" → **状态同步**,服务端逻辑必须还原到位。
      
      ---
      
      ## 7. "抓到的包/现象对不上" 的架构解释
      
      反推卡壳时,先怀疑架构层,而不是字段:
      
      | 现象 | 可能不是字段问题,而是… | 对应 |
      |------|----------------------|------|
      | 一包多条 / 消息粘连 | **网关消息合并** | §3 |
      | 少了一堆对象/玩家 | **AOI 裁剪**(只同步附近) | §7 |
      | 部分包高熵、部分能读 | **分段压缩** | §3 |
      | 连接后被断 | 网关**限流/IP 黑名单** | §3 |
      | 跨场景后消息序列变了 | **节点迁移 / 无缝地图** | §2 |
      | 客户端自己算伤害 | **帧同步**,服务端本就不算 | §6 |
      
      >  这些都要写进 `closure-verification.md` 的排查顺序里——**先排架构,再排字段**。
      
      ---
      
      ## 8. 证据对照速查表(架构部件 → 客户端线索)
      
      | 架构部件 | 你要在客户端/抓包里找的 | 落到哪 |
      |---------|----------------------|--------|
      | 登录服 | 服务器列表接口、网关地址 | `login-chain.md`、阶段 2 |
      | 网关 | 长连接地址、密钥下发、长度头 flag、粘包 | `protocol.spec.yaml` frame/crypto |
      | 逻辑服 | **消息 ID 表(=协议表)** | `protocol.spec.yaml` opcodes |
      | 分区/滚服 | 区服 id、合服/跨服字段 | Spec `subsystems` |
      | 序列化 | varint/TLV 特征 | Spec `serialize` |
      | 同步模型 | 种子/帧号/校验和 | Spec `state_machine` |
      
      ---
      
      ## 9. 一句话
      
      > **你反推的从来不是"一个程序",而是"登录服 + 网关 + 逻辑服 + DB"这套系统里、
      > 客户端真正接触的那段协议和那部分逻辑。**
      > 先按本文认清**对手怎么分层**(§1~§3),再去抠**协议字段**(§4~§5),
      > 最后才判断**服务端要还原多少逻辑**(§6)。**顺序反了,就会在错的层里找证据。**
    • termux.md 4 KB
      # 手游环境:Android / Termux 运行服务端
      
      > 场景:把手机本身当作服务端跑游戏服务端,客户端(同一台或局域网)连它。
      > 免 root 可行;有 root 则更方便(直接改 hosts)。
      
      ## A. 两种运行方式
      
      | 方式 | 适用 | 说明 |
      |------|------|------|
      | **纯 Termux** | 依赖少(纯 Python + sqlite) | 最快,`pkg install python` 直接用 |
      | **Termux + proot-distro Ubuntu** | 需要完整 Linux 工具链 / 编译原生库 | 等价于一台 Ubuntu,`pip` 兼容性最好 |
      
      本工程只用 `PyYAML + aiosqlite`,**纯 Termux 即可**;若要编 AES(`cryptography`) 或跑其它 Linux 工具,用 proot Ubuntu。
      
      ## B. 纯 Termux 部署
      
      ```bash
      # 1. 装 Termux(从 F-Droid 或 GitHub 官方,不要用 Play 商店旧版)
      pkg update && pkg upgrade -y
      pkg install -y python git tmux openssh
      
      # 2. 拿代码(示例:从手机存储拷过来)
      cp -r /sdcard/服务端反推/server ~/gsrv
      cd ~/gsrv
      
      # 3. 建 venv 装依赖
      python -m venv venv
      ./venv/bin/pip install -U pip
      ./venv/bin/pip install PyYAML aiosqlite
      
      # 4. 起服务端
      ./venv/bin/python -m app.main -c ./config/config.yaml
      ```
      
      启动后监听 `0.0.0.0:8888`。
      
      ## C. Termux + proot-distro Ubuntu(需要完整环境时)
      
      ```bash
      pkg install -y proot-distro
      proot-distro install ubuntu
      proot-distro login ubuntu
      # —— 进入 Ubuntu 后 ——
      apt update && apt install -y python3 python3-venv python3-pip git
      cp -r /sdcard/服务端反推/server /root/gsrv && cd /root/gsrv
      python3 -m venv venv && ./venv/bin/pip install -r requirements.txt
      ./venv/bin/python -m app.main -c ./config/config.yaml
      ```
      > proot 内可见 `/sdcard`(需先在 Termux 里 `termux-setup-storage`)。
      
      ## D. 常驻与保活(关键!)
      
      Android 会杀后台,必须三件套:
      
      ```bash
      # 1. 申请唤醒锁,阻止 CPU 休眠
      termux-wake-lock
      
      # 2. 用 tmux 让进程不随会话退出
      tmux new -s gsrv
      cd ~/gsrv && ./venv/bin/python -m app.main -c ./config/config.yaml
      # 按 Ctrl-B 再按 D 脱离;重连: tmux attach -t gsrv
      
      # 3. 关闭系统对 Termux 的电池优化
      #    设置 → 应用 → Termux → 电池 → 无限制 / 允许后台运行
      ```
      
      **开机自启**(可选,需 Termux:Boot 插件):
      ```bash
      mkdir -p ~/.termux/boot
      cat > ~/.termux/boot/start-gsrv.sh <<'EOF'
      #!/data/data/com.termux/files/usr/bin/sh
      termux-wake-lock
      tmux new-session -d -s gsrv 'cd ~/gsrv && ./venv/bin/python -m app.main -c ./config/config.yaml >> ~/gsrv/logs/boot.log 2>&1'
      EOF
      chmod +x ~/.termux/boot/start-gsrv.sh
      ```
      
      ## E. 远端访问服务端
      
      | 需求 | 做法 |
      |------|------|
      | 同一台手机上的客户端 | 连 `127.0.0.1:8888` |
      | 局域网内其它设备 | 连手机局域网 IP(`ifconfig` 看 wlan0),路由器需允许 |
      | 公网访问 | 路由器端口转发 8888,或用内网穿透:`cloudflared` / `frp` / `ngrok` |
      | 需要 SSH 管理 | `pkg install openssh; passwd; sshd`(默认端口 8022) |
      
      ## F. 客户端对接到 Termux 服务端
      
      - **同机客户端**:改 hosts 把游戏域名指向 `127.0.0.1`(需 root)。
        - 无 root:用抓包工具做 DNS 映射,或用支持 DNS 重定向的模块(Frida / LSPosed)。
      - **局域网**:改 hosts → 手机局域网 IP。
      - **公网穿透**:把客户端域名解析到穿透域名。
      
      ## G. 可被 agent 驱动的 Termux 工具
      
      | 工具 | 用途 |
      |------|------|
      | `termux-wake-lock` | 保活 |
      | `tmux` | 常驻会话,agent 可 attach/发送命令 |
      | `sshd` | 让 PC 上的 agent 通过 SSH 直接操作手机 |
      | `termux-api` | 调用手机能力(通知、电量、短信…) |
      | `code-server` | 手机浏览器里跑 VS Code,agent 可改代码 |
      
      > Operit 的 `super_admin:terminal` 本身就运行在 proot Ubuntu 中,可直接驱动同一套流程。
      
      ## H. 常见坑
      
      - 用 Play 商店版 Termux → 包源失效,务必用 F-Droid 版。
      - 忘了 `termux-wake-lock` → 锁屏后进程被杀。
      - 电池优化没关 → 后台几分钟就断。
      - 端口 8888 被占 → 改 `config.yaml` 的 `server.port`。
      - proot 里看不到 /sdcard → 先在 Termux 执行 `termux-setup-storage`。
    • unity.md 2.4 KB
      # Unity 分支深潜(IL2CPP / Mono / Lua)
      
      ## A. IL2CPP
      
      ### A1. 结构
      - `libil2cpp.so`(代码) + `global-metadata.dat`(元数据)必须配对。
      - `global-metadata.dat` 标准 magic:`AF 1B B1 FA`(小端)。头 4 字节不符 = 加密/魔改。
      
      ### A2. Dump
      ```bash
      Il2CppDumper.exe libil2cpp.so global-metadata.dat out/
      # 产出: dump.cs / script.json / il2cpp.h / DummyDll/
      ```
      - 自动检测 Unity 版本失败时用 `--select-mode` 手动指定。
      - `dump.cs` 只有签名、函数体为空 → 用 IDA/Ghidra + `script.json` 看实现。
      
      ### A3. metadata 加密的定位
      1. 搜字符串 `global-metadata.dat`。
      2. 回溯调用链:`il2cpp_init → Runtime::Init → MetadataCache::Initialize → MetadataLoader::LoadMetadataFile`。
      3. 在 `LoadMetadataFile` 前找解密/解压(常见:AES / XXTEA / 分块 XOR + 自定义头)。
      4. 自测可 Frida hook 解密函数出口,dump 明文。
      
      ### A4. 定位网络层
      ```bash
      grep -nE 'Send|Recv|Socket|Network|Message|Protocol|Packet|Cmd|MsgId|Opcode' out/dump.cs
      grep -nE 'ProtoBuf|MessagePack|JsonUtility|Newtonsoft' out/dump.cs
      ```
      - 找 `Dictionary<opcode, handler>` 或 `switch(msgId)` → 消息号全集。
      - 找序列化属性标注 → 字段顺序。
      
      ### A5. 动态辅助
      - Frida:`il2cpp` 模式直接按类名/方法名 hook。
      - 常用:hook `Socket.Send/Recv` 或业务层 `NetManager.Send` 直接拿明文。
      
      ---
      
      ## B. Mono
      
      1. 直接 dnSpy / ILSpy 打开 `Assembly-CSharp.dll`。
      2. 加密时定位 Mono `image` 解密(`mono_image_open_from_data` 附近)。
      3. 网络库常见:`System.Net.Sockets`、`WebSocketSharp`、`BestHTTP`。
      
      ---
      
      ## C. Lua 热更(xLua / tolua / slua)
      
      ### C1. 判定
      ```bash
      grep -rl 'xlua\|tolua\|slua' <assets>/
      ```
      - 明文 luac 头:`1B 4C 75 61`。
      - 否则找解密函数(常是异或/RC4 + 文件头)。
      
      ### C2. 反编译
      ```bash
      unluac file.luac > file.lua
      luadec -dis file.luac
      ```
      - 魔改字节码 → 先 dump 自定义 opcode 表再喂工具。
      
      ### C3. 价值
      - Lua 常直接写业务协议:`sendMsg(cmd, {field=...})` → 字段名与语义白给。
      - 与 `dump.cs` 交叉验证 opcode 与字段。
      
      ---
      
      ## D. Unity 网络协议常见形态
      
      | 形态 | 特征 | 处理 |
      |------|------|------|
      | TCP + 长度前缀 + protobuf | 前 2/4 字节长度 | protoc --decode_raw |
      | WebSocket + JSON | 明文可读 | 直接读 |
      | UDP + KCP | conv 头 | kcp_sniff.py |
      | 自定义 | 头+加密体 | 分块分析 |
      
    • unreal.md 2.2 KB
      # Unreal 分支深潜(UE4 / UE5)
      
      ## A. 资源层(.pak / .utoc / .uasset)
      
      ### A1. 找 AES key
      ```bash
      AES_finder.exe                 # 运行时内存扫描密钥
      # 或:从 exe 里搜 32/64 字节疑似 key,用 FModel 试
      ```
      - UE4 用 AES-256;UE5 IoStore 用 `.utoc + .ucas`。
      
      ### A2. 解包
      - **FModel**:主力。加载 `.usmap` 后浏览导出。
      - **UnrealPak.exe -Extract**:官方工具,需要 key。
      - **umodel**:老版本兜底。
      
      ### A3. UE5 unversioned 包
      - 现象:FModel 报无法解析属性。
      - 解法:先用 **Dumper-7 / UE4SS / usmap dumper** 生成 `.usmap`(含类/结构体 schema),FModel 加载即可。
      
      ---
      
      ## B. 代码 / 网络层
      
      ### B1. SDK Dump
      - **UE4SS**:注入,Lua/C++ 脚本枚举 `UObject`、hook `UFunction::ProcessEvent`。
      - **Dumper-7**:运行时生成 SDK(类、结构体、成员偏移、函数签名)。
      - 产出 `.h/.cpp`,等于拿到开发环境视图。
      
      ### B2. 网络定位(grep SDK / dump)
      ```
      NetDriver / NetConnection / PacketHandler / SendPacket / ProcessPacket
      Replication / Replicate / RPC
      Server_* / Client_* / Multicast_*
      FArchive / Serialize / NetSerialize
      ```
      
      ### B3. 字段语义金矿
      - `UPROPERTY(Replicated)` 的属性在 SDK 里带标记 → 顺序即序列化顺序。
      - RPC 函数参数 → 直接给出对应请求字段。
      - `FPacketHandler` 子类 → packet id 与结构。
      
      ### B4. UE 序列化细节
      - `FArchive` 小端。
      - `FString`:int32 长度(含 null 终止),负数=UTF-16。
      - `TArray`:int32 count + 元素。
      - 压缩块:`FCompressedChunkHeader`。
      
      ---
      
      ## C. UE 自研协议常见形态
      
      | 形态 | 特征 | 处理 |
      |------|------|------|
      | UE 原生 Replication | 二进制,NetGUID | 看 SDK + 抓包对照 |
      | 自研 UDP | 自定义包头 + CRC | 找 SendPacket 实现 |
      | HTTP/WS 网关 | 明文 | 直接抓 |
      | KCP 之上再加密 | conv 头 + 密文 | 先剥 KCP 再解 |
      
      ---
      
      ## D. 与 Unity 的差异要点
      
      - UE 强依赖运行时反射 → SDK dump 是首选,静态逆向次之。
      - UE 网络字段顺序由 C++ 内存布局 + 序列化函数共同决定,不能只看声明顺序。
      - UE5 的 IoStore(`.utoc/.ucas`) 与 unversioned 是新坑。
      
    • usage-policy.md 3.4 KB
      # 使用守则与限制(Usage Policy & Restrictions)
      
      > 本文件是 `game-client-to-server-reverse` skill 的**使用守则与硬性限制**,与仓库 README 的免责声明互补。
      > 任何模型 / 使用者在调用本 skill 时都应先读本文件,并全程遵守。
      > 一句话:**只做合规的互操作性研究,不做越界的事。**
      
      ---
      
      ## 一、允许的用途(守则 · 可做)
      
      1. **自研 / 已授权 / 离线单机目标**的互操作性研究:协议文档化、客户端 ↔ 自建服务端的互通分析。
      2. **学习与教学**:理解网络协议、序列化、加密 / 压缩分层、服务端状态机与同步模型。
      3. **存档与考古**:对已停止运营游戏的**本地化、非商业**留存与研究。
      4. **授权范围内的安全评估**:对自有或受委托项目做客户端安全分析。
      5. **私有化维护**:在权利人默许 / 无争议前提下,为**既有社区**延续已停服游戏。
      
      ---
      
      ## 二、禁止的用途(限制 · 不做)
      
      1. **未授权运营**:未经权利人许可,架设 / 运营他人游戏的服务端、牟利或冒充官方。
      2. **绕过与破坏**:破解、绕过或破坏他人的**技术保护措施 / 完整性校验 / 反作弊机制**(本 skill 不提供此类方案)。
      3. **商业化**:以本 skill 或其产物进行商业化运营、收费服务或对外牟利。
      4. **侵犯权益**:侵犯著作权、商标权、商业秘密或用户隐私。
      5. **破坏性行为**:对他人系统 / 服务实施攻击、入侵、篡改或数据窃取。
      
      ---
      
      ## 三、硬性行为约束(模型必须遵守)
      
      1. **证据驱动**:所有结论必须来自使用者提供的**实际证据**(dump / lua / 抓包 / SDK / 资源 / 客户端行为);无证据的内容一律标注为「假设」,不得当作事实。
      2. **先规格后代码**:先产出语言无关的协议规格(`protocol.spec.yaml`),再据此生成 / 改造服务端。
      3. **只给方法,不给成品**:不提供任何**具体游戏**的服务端实现、密钥、协议号表或现成破解步骤;参考实现只提供「套路」,不提供「答案」。
      4. **不提供反作弊 / 保护绕过**:不输出针对第三方的反作弊、加固壳、完整性校验的绕过或剥离方案。
      5. **fail closed**:输入不足时,明确说明**缺什么**,不臆测、不猜值。
      6. **越界即止**:一旦识别请求超出本守则(见第二节),**说明限制**并给出**合规替代路径**,而不是照做。
      7. **不臆造证据**:不得伪造抓包、dump、日志或验收记录。
      
      ---
      
      ## 四、责任与边界
      
      - 使用者须**自行确保**其行为符合所在地法律法规及目标软件的服务条款,并**独立承担全部后果**。
      - 作者不参与任何具体项目、不提供背书、不承担任何责任;权利人提出异议时将配合删除相关资料。
      - 本 skill 是**方法论与框架**,不含任何具体游戏资产;使用引用的第三方项目时,请遵守其各自许可证。
      
      ---
      
      ## 五、快速自检(每次动手前过一遍)
      
      - [ ] 目标是**自研 / 已授权 / 离线单机**之一吗?
      - [ ] 是否涉及**绕过 / 破坏**他人的保护措施?如是 → **停**。
      - [ ] 是否用于**商业化或牟利**?如是 → **停**。
      - [ ] 结论是否都有**实际证据**支撑?无证据的已标注为「假设」吗?
      - [ ] 是否只交付了**方法**,而没有交出某个游戏的具体实现 / 密钥?
    • verification-and-status.md 8.4 KB
      # 状态表达与验收体系:别用「[x]」掩盖三件事
      
      > **解决的事**:一个模块到底算不算「完成」?
      > 三个跑通的项目给出的答案都一致:**「实现了」「测过了」「客户端验过了」是三件不同的事**。
      >
      > 来源:三个已跑通的成品服务端的文档体系抽象。
      
      ---
      
      ## 0. 三条不可混用的判断
      
      ```
      ① 服务端实现了   —— 代码存在、能注册路由/处理消息
      ② 自动测试覆盖了 —— 有测试证明形状、事务、边界、幂等
      ③ 客户端验收了   —— 真实客户端走通了这个流程(人工)
      ```
      
      >  **自动测试通过不能替代客户端/宿主验收。**
      > 反过来也成立:客户端能用 ≠ 事务和幂等有保障。
      
      **典型误判**:把 ① 写成 [x] → 上线后发现 ③ 从没做过。
      
      ---
      
      ## 1. 三轴状态矩阵(TRACKER 的正确形态)
      
      不要用一个状态列,用**三列 + 一列权威文档**:
      
      | 模块 | 服务端实现 | 自动测试 | 客户端验收 | 权威文档 |
      |---|---|---|---|---|
      | 登录 | [x] / [~] / [x] | [x] / [~] / [x] | [x] / [~] / — / [x] | `docs/systems/login.md` |
      
      - **客户端验收列必须允许写 `—`**(有些模块根本不由客户端验收,例如后台、部署链路)
      - 权威文档列 = 这件事以哪份文档为准。**避免同一事实在多处各说一套。**
      
      模板见 `templates/status-matrix.md`。
      
      ### 状态四档(比 [x][~][ ] 更准)
      
      | 档 | 含义 | 关键差别 |
      |---|---|---|
      | **Complete** | 核心路径、持久状态、主要错误路径都已实现 | 边界内做完 |
      | **Partial** | 有主流程,但有缺分支/事务/通知/数据覆盖 | **有坑但能用** |
      | **Stub** | 只返回"让客户端不报错"的兼容响应 | **不提供真实业务能力** |
      | **Missing** | 客户端可能有入口,服务端没有可用实现 | 明确没做 |
      
      > `Stub` 必须**单独成档**。否则「返回空数组骗过菜单」会被当成「社交系统已实现」。
      
      ### 有效空响应 vs 假装实现
      
      允许 Stub,但必须**登记**:
      
      ```
      某接口:Stub —— 只返回客户端可接受的空数据,用于消除入口报错;
              不建立真实关系。见 stub-route-audit
      ```
      
      并配一份**桩审计清单**:逐条列出哪些路由只返回固定值/空对象/空列表。
      
      ---
      
      ## 2.  客户端可达性分类(决定测试深度)
      
      > **核心原则**:不要为「客户端不可能产生的状态」编写假想业务流程。
      > 服务端仍要对这些状态 **fail closed**,但不要把测试写成伪客户端功能矩阵。
      
      在写任何协议/功能测试前,先给场景分类:
      
      | 分类 | 含义 | 默认验证方式 |
      |---|---|---|
      | **client-reachable** | 真实客户端正常流程会发出 | 真实协议 → 路由 → 数据库 → 响应,必要时端到端 |
      | **transport-replay** | UI 不主动产生,但超时/重试会重复发送 | 真实路由 + 事务 + 幂等 + 重复写入检查 |
      | **server-boundary** | 只能绕过客户端直接构造 | 纯校验器/命令的完整边界 + 少量代表性 HTTP 映射 |
      | **save-integrity** | 存档导入、后台操作、数据库损坏可达 | 输入边界 + 事务回滚 + 有限错误,不模拟客户端流程 |
      | **client-characterization** | 用反编译证据锁定客户端解析/合并语义 | 最小行为表征,**不复制整套客户端实现** |
      
      **用法**:
      
      1. 先查反编译代码确认「请求条件、按钮/场景门控、请求字段、响应合并语义」;
      2. 无法由客户端证据证明可达 → 标 `server-boundary` 或「待抓包」,**而不是编一条假想流程**;
      3. 纯逻辑已经穷举同一不变量时,协议层只保留**能证明适配层没绕过校验**的代表用例;
      4. 边界测试通过 **≠** 官方客户端路径可达,更 **≠** 已实机验收。
      
      ### 必须 fail closed 的场景
      
      - 枚举里**没有权威语义**的类型(例:邮件附件类型里几个未知枚举)→ **明确拒绝**,不要猜;
      - 条件/前置不满足 → 返回客户端能识别的失败,**不要发奖**;
      - 只有部分候选有权威来源时(例:奖励来源只确认了商店和活动箱)→ 其余**返回空列表**,不推测。
      
      > 「不使用未经验证的占位回退」是一条通用纪律:**宁可明确失败,不要静默降级。**
      
      ---
      
      ## 3. 测试分组与门禁
      
      把测试分成几组,让「改了什么」能自动决定「跑什么」:
      
      | 组 | 内容 | 何时跑 |
      |---|---|---|
      | `quick` | 纯逻辑、工作流、协议单元、抽卡/关卡/联机协议 | 改少量代码 |
      | `integration` | 需要编译产物、临时数据库、回环端口的 | 改数据库/路由/资源/运行时 |
      | `admin` | 管理后台源码与接口契约 | 改后台 |
      | `full` | 全部(不含依赖仓库外原始数据的"外部"组) | 提交前 |
      
      **changed → 测试选择**的规则:
      
      ```
      1. 命中明确映射 → 跑对应 quick / integration 组
      2. 一个文件可映射多个组(例如路由同时覆盖纯逻辑、框架和数据库行为)
      3. 测试基础设施或无法识别的文件 → 提升到 full
      4. changed 通过 ≠ 提交前的 full 通过
      ```
      
      ### 提交门禁(一个命令串起来)
      
      ```
      typecheck → docs:check → test:full → hygiene(卫生检查) → build
      git diff --check
      git status --short
      ```
      
      ### 注意: 三条容易违反的纪律
      
      1. **权限/环境失败不能记作通过。** 受限沙箱里 `listen EPERM` → 换到允许回环端口的
         环境**重跑同一条命令**,不能写成「已通过」也不能跳过。
      2. **测试必须用临时数据目录**:不得修改真实数据库、内容目录、随机种子状态或玩家存档。
      3. **运行时产物不进提交**:抓包、临时基准 JSON、真实数据库、资源归档、一次性计划文档
         → 都放仓库外或 ignore;只有**长期有效的**架构/协议/边界才进 tracked 文档。
      
      ---
      
      ## 4. 阶段验收:一次只判定一件事
      
      大型任务要**明确「本阶段只判定什么」**,并允许其余项处于"待验收"。
      
      示例(改包对接的第一阶段):
      
      ```
      本阶段判定:
        [x] 客户端能安装并启动
        [x] 请求进入本地 HTTP 服务
        [x] 区服列表被调用并返回本地地址
        [x] 连接本地 TCP 端口
        [x] 收到登录请求
        [x] 启动数据按顺序发送
        [x] 不访问原始回调地址
        [x] 未改动热更/CDN 地址
      不判定:订单、支付结算、货币消息、断线重连
      ```
      
      > 把「不判定」写出来,可以防止"顺手改一半"导致范围失控。
      
      ---
      
      ## 5. 进度文档的三种分工(别揉成一份)
      
      | 文档 | 只记什么 | 不记什么 |
      |---|---|---|
      | **支持矩阵** | 三轴状态 + 权威文档 | 排查过程、时间线 |
      | **已知问题** | 尚未解决 / 明确受限 / 待复核 | 已修复的历史问题(交给版本控制) |
      | **验收进度** | 人工验收:已通过 / 待测 / 低优先级 / 未实现 / 非客户端验收 | 自动测试细节 |
      
      **三条写作纪律**:
      
      1. **已修复的写进版本控制,不要继续堆在"已知问题"里** —— 否则文档越读越没用。
      2. 「已实现但未验收」要单列一节,**不能混进"已通过"**。
      3. 复核中的现象要标注「缺少稳定复现」,**不要据此改业务逻辑**。
      
      ---
      
      ## 6. 每个模块的文档骨架(可直接套)
      
      ```markdown
      # <模块名>
      
      > 分类:系统实现 / 协议分析 / 验收证据
      > 状态:已确认 / 部分实现 / 待验证
      
      ## 结论(挂证据档位)
      ## 协议 / 数据模型(字段号 + 类型 + 来源)
      ## 事务与幂等边界(哪些操作必须原子;重复请求怎么办)
      ## 失败路径(fail closed 的清单)
      ## 证据(源码路径 / 抓包 / 测试文件)
      ## 本模块不包含(明确边界)
      ## 待客户端验收的清单
      ```
      
      > 「本模块不包含」这一节和「已实现」同样重要 —— 它是防止范围误解的唯一手段。
      
      ---
      
      ## 7. 反向检查清单
      
      宣布某模块完成前问自己:
      
      - [ ] 三轴分别是什么状态?(实现 / 自动测试 / 客户端验收)
      - [ ] 有没有把 `Stub` 当成 `Complete`?
      - [ ] 这个场景真实客户端**会不会发**?(可达性分类)
      - [ ] 不支持的枚举/条件是否 **fail closed**(而不是猜一个值)?
      - [ ] 重复请求会不会重复发奖?(幂等)
      - [ ] 断线重连/服务重启后状态是否一致?
      - [ ] 「本模块不包含」写了吗?
      - [ ] 权威文档是哪一份?(不会有两份互相矛盾的说法)
      - [ ] 有没有把环境权限失败写成"测试通过"?
    • windows.md 9.1 KB
      # 端游环境:Windows 运行服务端
      
      > 场景:Windows 上跑游戏服务端,同机客户端连 `127.0.0.1`,或局域网/公网客户端连。
      
      ## A. 快速启动(开发)
      
      ```bat
      :: 1. 装 Python 3.10+(勾选 Add to PATH)
      :: 2. 进目录
      cd /d D:\服务端反推\server
      
      :: 3. 建虚拟环境
      python -m venv venv
      venv\Scripts\pip install -r requirements.txt
      
      :: 4. 启动
      venv\Scripts\python -m app.main -c config\config.yaml
      ```
      
      配套脚本:`server/deploy/start_windows.bat`
      
      ## B. 后台常驻(生产级)
      
      ### 方式一:NSSM(推荐,最稳)
      ```bat
      :: 下载 nssm.exe 放到 PATH 或当前目录
      nssm install gsrv "D:\服务端反推\server\venv\Scripts\python.exe" "-m app.main -c D:\服务端反推\server\config\config.yaml"
      nssm set gsrv AppDirectory "D:\服务端反推\server"
      nssm set gsrv AppStdout "D:\服务端反推\server\logs\stdout.log"
      nssm set gsrv AppStderr "D:\服务端反推\server\logs\stderr.log"
      nssm set gsrv AppExit Default Restart
      nssm set gsrv Start SERVICE_AUTO_START
      nssm start gsrv
      :: 管理
      nssm status gsrv
      nssm stop gsrv
      nssm remove gsrv confirm
      ```
      
      ### 方式二:WinSW(XML 配置)
      ```xml
      <!-- gsrv.xml -->
      <service>
        <id>gsrv</id>
        <name>Game Mock Server</name>
        <executable>D:\服务端反推\server\venv\Scripts\python.exe</executable>
        <arguments>-m app.main -c D:\服务端反推\server\config\config.yaml</arguments>
        <workingdirectory>D:\服务端反推\server</workingdirectory>
        <logmode>roll</logmode>
        <onfailure action="restart" delay="5 sec"/>
      </service>
      ```
      ```bat
      winsw install gsrv.xml
      winsw start gsrv
      ```
      
      ### 方式三:任务计划程序(无需第三方)
      ```
      taskschd.msc → 创建任务
        触发器:系统启动时
        操作:启动程序 venv\Scripts\pythonw.exe
             参数 -m app.main -c config\config.yaml
             起始于 D:\服务端反推\server
        勾选"不管用户是否登录都要运行"
      ```
      
      ## C. 防火墙与端口
      
      ```powershell
      # 放行游戏端口(管理员 PowerShell)
      New-NetFirewallRule -DisplayName "GameMockServer" -Direction Inbound `
        -Protocol TCP -LocalPort 8888 -Action Allow
      # 若用 UDP/KCP
      New-NetFirewallRule -DisplayName "GameMockServerUDP" -Direction Inbound `
        -Protocol UDP -LocalPort 8888 -Action Allow
      ```
      
      ## D. 公网访问
      - 路由器端口转发:外部端口 → 内网主机 IP:8888
      - 或内网穿透:`cloudflared` / `frp` / `ngrok`
      - 有云服务器:直接把 `server/` 拷到云主机,用 Linux 的 `deploy.sh`
      
      ## E. 客户端对接到 Windows 服务端
      
      | 场景 | 做法 |
      |------|------|
      | 同机客户端 | 改 `C:\Windows\System32\drivers\etc\hosts`,把游戏域名→127.0.0.1 |
      | 局域网客户端 | hosts → 服务端局域网 IP |
      | 需重定向 | `netsh interface portproxy` 或抓包工具做 DNS 映射 |
      | 证书固定 | 自签 CA 装进系统信任库;或 Frida 绕过(自测) |
      
      改 hosts 示例(管理员编辑):
      ```
      127.0.0.1  game.example.com
      127.0.0.1  login.example.com
      ```
      
      ## F. 可被 agent 驱动的 Windows 工具
      
      | 工具 | 用途 |
      |------|------|
      | PowerShell / CMD | 启动/停止服务、看日志、改配置 |
      | `nssm` / `winsw` | 注册为系统服务、崩溃自重启 |
      | 任务计划程序 (`schtasks`) | 开机自启 |
      | WSL2 | 需要 Linux 工具链时(抓包/逆向工具) |
      | `sc.exe` | 原生服务控制 |
      | AutoHotkey / WinAppDriver | 需要 UI 自动化(点启动器、模拟登录) |
      | PsExec | 远程/提权执行命令 |
      
      > 若 Windows 上装了 OpenSSH Server,则可让 Linux 侧 agent 通过 SSH 直接操作 Windows。
      
      ## G. WSL2 注意
      - WSL2 默认 NAT,Windows 端口不会自动暴露到局域网:
        - 用 `netsh interface portproxy add v4tov4 ...` 做端口转发
        - 或在 `.wslconfig` 里确保 `localhostForwarding=true`(仅本机可用)
      - WSL2 里跑服务端,客户端在 WSL 外访问需用 Windows 主机 IP。
      
      ## H. 常见坑
      
      - Python 没加 PATH → `python` 找不到。
      - 用了 Store 版 python 别名 → 建议关掉"应用执行别名",用官方安装版。
      - NSSM 服务起不来 → 看 `AppStderr` 日志,多半是工作目录/路径含中文空格(用引号包住)。
      - 防火墙没放行 → 局域网客户端连不上。
      ---
      
      ## I.  逆向工具链在 Windows/Git Bash 的坑(实测)
      
      > 在 Windows 上跑 adb/radare2/Ghidra 做协议反推时的特有问题,Linux 上不存在。
      
      ### I.1 MSYS/Git Bash 的路径转换(最高频)
      
      Git Bash 会把 `/xxx` 开头的参数当路径转换成 Windows 路径,破坏一切类 Unix 命令:
      
      | 现象 | 原因 | 解法 |
      |---|---|---|
      | `adb pull /data/local/tmp/x.pcap` 报 `D:/Git/data/... does not exist` | `/data/...` 被转成 `D:\Git\data\...` | 命令前加 `MSYS_NO_PATHCONV=1` |
      | radare2 的 `/r 0x1234`(xref 搜索)报 `Invalid command 'D:/Git/r ...'` | `/r` 被当路径吃掉 | 同上;或用 `-c` 时双保险 |
      | heredoc/脚本换行符 | Windows 写出的脚本是 CRLF | 写入时 `open(f,'w',newline='\n')`;或 `tr -d '\r'` |
      
      > 推论:设备端 shell 脚本要么 PC 生成(控制换行符)后 push,
      > 要么纯单行命令。CRLF 脚本在 mksh 里会静默失败(连目录都建不出)。
      
      ### I.2 Ghidra headless(无 GUI 批量反编译的正确姿势)
      
      Ghidra 11+ 需要 **JDK 21**;**JRE 不行**(校验 javac)——直接下 JDK zip 解压版
      (Temurin `.../jdk/.../eclipse` 接口给 zip),**不要用 MSI**(静默安装会被提权问题卡死)。
      
      ```bat
      @echo off
      set JAVA_HOME=E:\tools\jdk-21.x.x+x
      set PATH=%JAVA_HOME%;%PATH%
      :: 首次: -import 分析(20MB so 约10-30分钟); 之后: -process 复用(秒开)
      call ghidra\support\analyzeHeadless.bat <proj_dir> <proj_name> ^
        -process libmmo.so -noanalysis ^
        -scriptPath <scripts_dir> -postScript DecompFuncs.py
      ```
      
      Jython 后处理脚本模板(批量反编译指定地址到文件):
      ```python
      # DecompFuncs.py —— 记得: Ghidra 地址 = ELF vaddr + image_base!
      from ghidra.app.decompiler import DecompInterface
      from ghidra.util.task import ConsoleTaskMonitor
      di = DecompInterface(); di.openProgram(currentProgram)
      fm = currentProgram.getFunctionManager()
      af = currentProgram.getAddressFactory().getDefaultAddressSpace()
      out = []
      for label, av in [('fn1', 0x28c358), ('fn2', 0x293964)]:   # 填 Ghidra 地址
          fn = fm.getFunctionContaining(af.getAddress(av))
          if fn:
              r = di.decompileFunction(fn, 180, ConsoleTaskMonitor())
              if r.decompileCompleted():
                  out.append('==== %s ====\n%s\n' % (label, r.getDecompiledFunction().getC()))
      open(r'E:\work\decomp.c','w').write(''.join(out))
      ```
      
      **两个必踩的坑**:
      1. **image base 偏移**:PIE so 导入后 Ghidra 地址 = ELF vaddr + image_base
         (如 +0x100000)。用 `currentProgram.getImageBase()` 先探测。不换算 → 全部
         `NO FUNCTION`,白跑一轮。
      2. `-process` 复用项目时目标必须叫 `/libmmo.so`(导入名),
         目录不存在会报 `Directory not found`——先 `mkdir`。
      
      ### I.3 Android so 静态分析的 relocation 陷阱
      
      **so 文件里的 vtable 指针字段全是 0**(Android RELA/packed relocs:运行时才填)。
      静态读文件找 vtable → 全空 → 误判"没有类信息"。三个出路:
      
      1. **Ghidra**(自动应用 relocation)——首选;
      2. **手工链 .rela.dyn**:解析 `SHT_RELA` 的 `R_AARCH64_RELATIVE`(1027) 重定位,
         `addend → 目标字符串地址` 反查 typeinfo 名,`typeinfo ← vtable` 建指针链
         (pyelftools 30 行搞定,适合只要一两个类的场合);
      3. 运行时 dump(设备上读进程内存)。
      
      > 另注意区分:so 可能用标准 `DT_RELA`,也可能是 **packed relocs(Android 专有
      > SHT_ANDROID_RELA)**——pyelftools 不解后者,静态手工链会失败,只能走 Ghidra。
      
      ### I.4 找自研加密/压缩函数的特征扫描(不用 Ghidra 全量分析)
      
      | 目标 | 扫描特征 | 说明 |
      |---|---|---|
      | 加密/哈希热点 | **EOR 指令密集簇**(每 4B 指令窗内 EOR 聚集) | 但 MD5 也是 EOR 密集——**再用 T 常量识别**:`0xFFFA3942/0x8771F681/0x6D9D6122…` → 是 MD5(多半用于 HTTP 签名,不是会话加密) |
      | TEA/XXTEA | delta `0x9E3779B9` 的 movz/movk 指令编码(扫 `movk #0x9e37, lsl#16`) | 3 处以内,逐个看调用者 |
      | zlib | 直接搜字符串/导入:`inflateInit2_`、`"need dictionary"`、`0x1f8b` 常量、CRC32 表 | **能命中 → 大概率传输层是压缩不是加密(见 decision-tree §4.0)** |
      | OpenSSL 全家桶 | 导出符号一堆 EVP/AES/RSA | 注意: **链了 ≠ 用了**:全量统计 BL 调用点到这些 PLT stub,0 调用 = 死代码(实测常见:RSA 只用于 HTTP 登录,会话层根本不用 OpenSSL) |
      
      ### I.5 进程内存 dump(设备端,root)
      
      ```sh
      adb root && adb shell
      PID=$(pidof <包名>)
      kill -STOP $PID                      # 冻结, 防缓冲被覆盖
      # 遍历 /proc/$PID/maps 的 rw-p 匿名段
      dd if=/proc/$PID/mem bs=4096 skip=$((VA/4096)) count=$((SZ/4096)) of=/data/local/tmp/m_xxx.bin
      kill -CONT $PID
      ```
      **坑**:mksh 的 `$(( ))` 对 64 位地址(0x7xxxxxxxxx)**算术溢出** → dump 全是空文件。
      必须 **PC 端预计算好十进制偏移**生成脚本再 push 执行。
      **验证 dump 有效**:先搜一个已知明文(角色名/域名/协议字符串)——搜不到说明 dump 是空的。
      
    • wire-level-patching.md 13.1 KB
      # Wire 级定点改写:不等 schema 齐就能跑通
      
      > **来源**:从**已实机跑通**的成品服务端实现中抽象出的通用技术。
      > 适用于任何「protobuf + 长度前缀帧」的长连接游戏协议。
      >
      >  **这是把「协议 30% 反推完」变成「客户端已经能进游戏」的关键技术。**
      > 配套:`engineering-practices.md`(范式层)、`closure-verification.md`(验证层)。
      >
      > 注意: 文中出现的字段名(`RoleBase` 等)与消息号(25/26/27/28)**只是示例**,
      > 换游戏必须按自己的抓包重新确认 —— 别把示例当常量。
      
      ---
      
      ## 目录
      
      > 0 核心思想 · **1 三层积木** · 2 启动序列打补丁 · **3 空子消息也要保留 注意:** · 4 结构化+原样保存 · 5 幂等(请求体哈希) · 6 HTTP 侧 AES-ECB+JSON · 7 按登录请求选 fixture · 8 落地顺序 · 9 与其它文档关系
      
      ---
      
      ## 0. 核心思想
      
      ```
      [x] 常规思路:先反推完整 protobuf schema → 生成代码 → 构造消息 → 发出去
         (字段几百个、嵌套极深 → 永远凑不齐,卡死在某个页面)
      
      [x] 可行思路:把抓包字节【原样保留】,只对【已确认的少数字段】做字节级替换
         (不需要 descriptor、不需要了解 99% 的字段,就能让客户端跑起来)
      ```
      
      原实现的文件头注释写得很直白:
      
      > “The client uses Google Protobuf, but the repository does not currently carry a
      > Python protobuf runtime. This module works at the **wire level** and **preserves
      > unknown fields** when patching captured messages.”
      
      代码注释甚至写明:**故意不带 protobuf 运行时**。
      ——**不引入 pb runtime 是一个决策,不是偷懒。**
      
      ---
      
      ## 1. 三层积木
      
      ### 第一层:帧头(10 字节,大端)
      
      ```python
      HEADER_SIZE = 10
      MAX_BODY_LENGTH = 0xFFFF
      
      # 偏移0: u16 body 长度 / 偏移2: u16 消息号 / 偏移4: i32 序号 / 偏移8: u16 标志
      def encode_frame(frame) -> bytes:
          return struct.pack(">HHiH", len(frame.body), frame.msg_id, frame.seq, frame.flag) + frame.body
      
      def decode_frame(data: bytes) -> Frame:
          body_len, msg_id, seq, flag = struct.unpack(">HHiH", data[:HEADER_SIZE])
          assert len(data) == HEADER_SIZE + body_len      # 长度必须自洽
          return Frame(body=data[HEADER_SIZE:], msg_id=msg_id, seq=seq, flag=flag)
      ```
      
      > 注意 `seq` 是**有符号** i32、`flag` 是 u16 —— 判错类型会在某些帧上炸。
      
      ### 第二层:protobuf 字段遍历器(不依赖任何 pb 库)
      
      > 只做四件事:读 tag → 判 wire_type → 取值 → **记住字段在原 buffer 里的 start/end**。
      
      ```python
      @dataclass(frozen=True)
      class Field:
          number: int; wire_type: int; value: int | bytes
          start: int; end: int          #  关键:保留原始字节区间
      
      def iter_fields(data: bytes) -> Iterable[Field]:
          offset = 0
          while offset < len(data):
              start = offset
              tag, offset = _decode_varint(data, offset)
              number, wire_type = tag >> 3, tag & 7
              # wire_type 0=varint 1=fixed64 2=length-delimited 5=fixed32
              ...  # 依次推进 offset
              yield Field(number, wire_type, value, start, offset)
      ```
      
      读值 API(按字段号取,取不到给默认值 —— **不抛异常**,这是能跑通的前提):
      
      ```python
      get_varint(data, number, default=0)
      get_string(data, number, default="")
      get_bytes(data, number) -> bytes | None
      get_bytes_all(data, number) -> list[bytes]      # repeated
      get_repeated_varints(data, number) -> list[int] # 兼容 packed
      ```
      
      ### 第三层: 定点替换(splice,不是重新序列化)
      
      ```python
      def _replace_field(data, number, replacement, *, wire_type=None) -> bytes:
          for field in iter_fields(data):
              if field.number == number and (wire_type is None or field.wire_type == wire_type):
                  # 只替换这一段字节,其余(含未知字段)原样保留
                  return data[:field.start] + replacement + data[field.end:]
          return data + replacement        # 字段不存在 → 追加(protobuf 允许)
      ```
      
      对外三个便捷函数 + 一个语义化封装:
      
      ```python
      patch_varint(data, number, value)
      patch_bytes (data, number, value)
      patch_string(data, number, value)
      
      def patch_role_base_diamond(role_base: bytes, diamond: int) -> bytes:
          # Confirmed by static analysis: RoleBase.Diamond is field 8.
          if diamond < 0: raise ProtoError("diamond cannot be negative")
          return patch_varint(role_base, 8, diamond)
      ```
      
      **为什么 splice 合法**:protobuf 是顺序自描述流,字段只靠 tag 定位。
      长度变化会让后续字节位移,但**解析器跟着走,语义不变**。
      
      ---
      
      ## 2. 启动序列:静态模板 + 按状态打补丁
      
      ### 模板怎么来的(离线一次性)
      
      ```
      原版客户端 ↔ 原版服务器 的抓包 records(c2s/s2c + msg_id + body)
                          +
      本地 fixture(已结构化的 25/26/27/28)
               ↓  merge
      startup_template.json   ← 运行时不再需要原始抓包文件
      ```
      
      合并规则(`build_startup_template`,值得照抄):
      
      ```python
      1. 找到 s2c 的 SCLoginAck(4),只从它【之后】开始取
      2. 遇到 c2s 就停(启动序列是纯下行段)
      3. 丢掉 {7, 4}(握手/登录应答由服务端实时生成)
      4. {25, 26, 27, 28} → 用【fixture 里对应的帧】替换(顺序消费,支持同一消息号多片)
      5. 其余帧 → 原样保留
      ```
      
      ### 模板要过校验(缺一个就直接报错,别等客户端崩)
      
      ```python
      def load_startup_template(path):
          for frame in frames:
              if frame.msg_id in {3, 4, 7}:
                  raise ProtoError("startup template must only contain frames after SCLoginAck")
          if not any(f.msg_id == 25 for f in frames): raise ProtoError("... missing SCStartupInfoNtf(25)")
          if not any(f.msg_id == 26 for f in frames): raise ProtoError("... missing (26)")
          if not any(f.msg_id == 27 for f in frames): raise ProtoError("... missing (27)")
          if not any(f.msg_id == 28 for f in frames): raise ProtoError("... missing (28)")
          if not any(f.msg_id == 25 and get_bytes(f.body, 6) is not None for f in frames):
              raise ProtoError("... missing RoleRiskBattle (field 6)")
      ```
      
      >  **把「客户端会崩的缺失」在启动时校验掉**,而不是等客户端黑屏。
      > 这是把调试成本从「猜」变成「读报错」的关键。
      
      ### 发送时按本地状态打补丁(`_send_startup`)
      
      ```python
      for frame in startup_frames:
          body = frame.body
          if frame.msg_id == 25 and get_bytes(body, 4) is not None:     # RoleBase
              body = patch_bytes(body, 4, patch_role_base_from_state(role_base, state))
          if frame.msg_id == 25 and role_bag and get_bytes(body, 5) is not None:   # RoleBag
              ...
          if frame.msg_id == 25 and get_bytes(body, 6) is not None:     # RoleRiskBattle
              ...
          if frame.msg_id == 27:                                        # 英雄/编队
              body = encode_startup_hero_ntf(...)
          await self._send(writer, Frame(body=body, msg_id=frame.msg_id, seq=frame.seq, flag=frame.flag))
      ```
      
      **原则**:**每片 25 只改它自己带的那部分**(这片的 field 4 就改 4,field 5 就改 5),
      不认识的部分一个字节都不动。
      
      ---
      
      ## 3. 注意: 最值钱的一条经验:空子消息也要保留
      
      `encode_role_risk_battle` 的注释(原文):
      
      > “The original startup carries an **empty-but-present** RoleRiskBattle
      > (`Tower` and `StarReward` as empty sub-messages). Keeping those sub-messages
      > **present** is important because the client's 149 settlement handler
      > **dereferences** UserDataComponent.RoleRiskBattle **without a null check**.”
      
      ```python
      # Preserve the original empty sub-messages so the client sees a non-null object
      result += encode_bytes_field(6, b"")
      result += encode_bytes_field(7, b"")
      ```
      
      **教训**:
      > 你重建对象时把「空但存在」的字段省掉,客户端就会拿到 `null` → 空指针崩溃 / 卡死。
      > **字段的存在性(presence)和字段的值一样重要。**
      > 重建任何嵌套消息时,先照着原抓包**把字段骨架抄全**,再填值。
      
      ---
      
      ## 4. 状态模型:结构化已确认 + 原样保存未确认
      
      ```python
      state = {
        "schema_version": 1,
        "role_base": {...},          # 已确认字段 → 结构化,可读可改
        "role_bag": {                #  双轨制
            "items": {...},
            "wire_b64": "<原始字节 base64>",   # 未确认部分原样留底
            "wire_dirty": False,             # 是否被本地改过(决定用重建还是回放原字节)
        },
        "heroes": {}, "lineups": {...}, "risk_battle": {...},
        "strength_state": {...}, "tasks": {...}, "story": {...},
        "gacha": {...}, "social": {...},
        "operations": {"battle": {}, "gacha": {}, "payment": {}},   # 幂等收据
      }
      ```
      
      **合并规则**(`merge_role_state`):按 key 走**字段级**分支,而不是整段覆盖。
      
      ```python
      if key == "role_base":        _merge_dict(...)      # 浅合并
      elif key == "role_bag":       _merge_keyed(items)   # 满合并 + wire_dirty = True
      elif key in {"heroes","operations"}: _merge_keyed(...)   # 值 None/False = 删除
      else:
          # 未知状态 retains under extensions,不静默替换已知分区
          result.setdefault("extensions", {})[key] = _copy(value)
      ```
      
      > 「未知的进 `extensions`,**绝不覆盖已知分区**」—— 这条能防止后续补 schema 时数据互相污染。
      
      **未确认阶段的处理**:`normalize_role_state` 在迁移老数据时
      `if equipment_body and not state["equipment"].get("wire_b64"):` 才写入 → **不覆盖已有的 wire 快照**。
      
      ---
      
      ## 5. 幂等:用请求体哈希当收据 key
      
      ```python
      def operation_key(namespace: str, body: bytes) -> str:
          return f"{namespace}:{hashlib.sha256(body).hexdigest()}"
      
      record_operation(state, "battle", key, result)   # 存下上次的结算结果
      if (cached := get_operation(state, "battle", key)): return cached   # 重放同一请求 → 返回原结果
      MAX_OPERATION_RECEIPTS = 256     # 环形淘汰,防无限增长
      ```
      
      **优点**:不需要客户端配合传订单号;同一个请求体 = 同一次操作。
      (代价:客户端真重复点击时也会被当成重放 —— 需按业务判断是否可接受。)
      
      > 另注:`role_version` 这类**单调递增版本号**要单独维护,
      > 别让它因为幂等缓存而停在旧值(客户端可能用它判断数据新旧)。
      
      ---
      
      ## 6. HTTP 侧:AES-ECB + JSON 信封(实战要点)
      
      ```python
      KEY = b"<16 字节硬编码密钥>"      # 例:从 APK 的 OkHttp interceptor / smali 里抠出来
      BLOCK_SIZE = 16                  # AES-128-ECB + PKCS5/7 padding
      
      def decode_request(ciphertext):
          value = decode_json(ciphertext)            # AES-ECB 解密 → JSON
          return {"token": ..., "deviceId": ..., "data": value.get("data", {})}
      ```
      
      **要点**:
      - **密钥硬编码是常见的**(从 APK 的 OkHttp interceptor / smali 里就能抠出来),不要一上来假设是协商密钥。
      - 信封结构固定:`{token, deviceId, data}` → 认证信息在**外层**,业务在 `data`。
      - JSON 序列化要跟客户端对齐:`separators=(",", ":")`、`ensure_ascii=False`
        (差一个空格就可能被客户端的签名/校验拒绝)。
      
      ---
      
      ## 7. 按登录请求选 fixture(防串号)
      
      ```python
      def _fixture_for_login(self, request):
          # 用 login 请求里的 open_id / account / user_id 去匹配 fixture 身份
          ...
      ```
      
      **必须显式映射**,禁止「按账号名猜角色」,否则会把别人的角色数据发给你。
      
      配套的**去泄漏**手法(`_patch_nested_uid`):模板里嵌套着原账号的 UID,
      要按 `(outer_field, inner_field)` 路径**逐层挖出来替换成当前账号的 game_uid**。
      
      ```python
      def _patch_nested_uid(body, outer_field, inner_field, game_uid) -> bytes:
          # 进入 outer_field 子消息 → 替换 inner_field 的 uid → 装回去
      ```
      
      **上线前扫描**:对所有回放模板做一遍 UID / open_id / 订单号 / 资源数扫描。
      
      ---
      
      ## 8. 落地顺序(可直接照跑)
      
      ```
      ① 抓一次原版完整启动序列(pcap)→ 重组帧 → 存 records(json)
      ② 写帧 codec(10B 头)+ protobuf 字段遍历器 + patch_* 三件套
      ③ 用【已确认字段号】把 fixture 结构化(先只要 RoleBase/RoleBag/RoleRiskBattle/英雄)
      ④ 生成 startup_template.json(fixture ∪ capture,规则见 §2)+ 启动校验
      ⑤ 服务端:实时生成 7/4 → 回放 template(按片打补丁)→ 跑起来看能到哪一步
      ⑥ 卡住时看客户端**下一个请求的消息号**,按同样手法给它加一条【回放模板 + 定点改写】
      ⑦ 每加一条业务,就把结构化字段从 wire_b64 迁到 state 的正式分区
      ```
      
      **第 ⑤ 步的判定**:能走到「收到启动结束消息 → 主界面」就算这一阶段成功;
      之后每个模块都是重复 ⑥⑦,**不需要一次反推完**。
      
      ---
      
      ## 9. 与其它文档的关系
      
      | 文档 | 层次 |
      |------|------|
      | 本文 | **实现层**:怎么写代码让它跑起来 |
      | `engineering-practices.md` | 范式层:怎么组织工作、怎么验收、怎么写文档 |
      | `closure-verification.md` | 验证层:怎么证明真的通了 |
      | `protocol-spec.md` | 规格层:确认下来的字段要落进 Spec |
      | `case-il2cpp-ecdh.md` | 反例参照:卡在「必须先把算法解出来」的思路里 |
    • workflow-roadmap.md 12.3 KB
      # 反推路线总纲:四阶段
      
      > **这份文档解决的事**:`SKILL.md §0.4` 是"一份流水线",`methods.md` 讲"怎么取证",
      > 但**没有一张把两者串起来、能贴在墙上看的路线图**。本文补上这块。
      >
      > **分工**:
      > - `methods.md` 回答「**用什么手段**取证」(M1~M11)
      > - **本文**回答「**先做什么、后做什么、在哪一步验收**」
      > - `primer.md` 回答「**到底在反推什么**」
      >
      > **一句话路线**:**清点 → 点灯 → 改向 → 补包 → 列表迭代。**
      
      ---
      
      ## 全景图
      
      ```
      ┌─ 阶段 0 · 静态分析 ────────────────────────────────────────────┐
      │  清点现有线索:文件完整性 / 登录流程 / 配置表 / 协议表 / 热更方案   │
      │  产出:project-profile.yaml + evidence-inventory.md             │
      └──────────────────────────┬─────────────────────────────────────┘
                                 ▼
      ┌─ 阶段 1 · 构建分析工具 + 登录链文档 ────────────────────────────┐
      │  注入日志钩子,把「引擎 / 热更 / 网络」日志统一分区输出             │
      │  整理一份「登录链条」md,标清每一步                                        │
      │  产出:logs/ 分区 + login-chain.md + 可监听的基础服务端           │
      └──────────────────────────┬─────────────────────────────────────┘
                                 ▼
      ┌─ 阶段 2 · 重定向(让请求到达自建服务端)─────────────────────────┐
      │  DNS/寻址 → 传输/TLS → SDK/平台 → 业务协议  (四层,代价递增)     │
      │  验收:客户端请求**能到达**你的服务端并留下日志                     │
      └──────────────────────────┬─────────────────────────────────────┘
                                 ▼
      ┌─ 阶段 3 · 补包循环(核心玩法)──────────────────────────────────┐
      │  从登录链**最上游**往下游补回包:先回服务器列表 → 再回登录握手      │
      │  → 再补游戏需要的数据。每补一块,以「客户端能走到下一步」为通过标准   │
      │  循环:测试 → 日志 → 定位 → 修改 → 再测试                          │
      │  到达:能进入主场景 = 成功一大半                                   │
      └──────────────────────────┬─────────────────────────────────────┘
                                 ▼
      ┌─ 阶段 4 · 功能清单与迭代 ───────────────────────────────────────┐
      │  把已通 / 未通功能登记成清单文档,反复迭代直到基本完善              │
      │  产出:function-checklist.md + TRACKER.md + 三轴状态矩阵          │
      └─────────────────────────────────────────────────────────────────┘
      ```
      
      > 注意: **两条主干分叉**(提前判断,影响后面全部):
      > - 目标可单机化 + 客户端能注入 → 可走 **M11 内联服务端**,**跳过阶段 2 的传输/加密还原**
      >   (把上面换成"进程内合成响应")→ `inline-server.md`
      > - 只有安装包、什么都没有 → **先跑一次 `from-installer.md` 的自举流水线**,再进阶段 0
      
      ---
      
      ## 阶段 0 · 静态分析(清点现有线索)
      
      > **目标**:在动任何手之前,先弄清"手上有什么、还缺什么"。
      > 用户工作区里已有的线索优先于一切臆测。
      
      **五项确认**(逐条打钩,缺的写进 `needs`):
      
      ```
      [ ] 1. 文件完整性 —— 热更资源是否完整?
              (※ 可能需先"清理游戏全部数据"再重下,让客户端重新拉全资源。
                这一步会丢本地存档 / 登录态,必须让用户确认!)
      [ ] 2. 登录流程 —— 走渠道 SDK 登录,还是直接账号登录?
      [ ] 3. 配置表 —— 能否解析出数值与文本?(→ 数据层)
      [ ] 4. 协议表 —— 能否整理出消息格式?(→ Spec 的 opcodes)
      [ ] 5. 热更方案 —— 脚本/程序集是什么、在哪、是否被加密?
              (常见:lua / js / ts / dll / 组合)
      ```
      
      **产出**:`out/project-profile.yaml`、`out/evidence-inventory.md`
      
      **对应文档**:`primer.md`(搞懂在找什么)→ `adaptation.md`(建档)
      → `from-installer.md`(零输入自举)→ `client-languages.md`(判语言)
      
      ---
      
      ## 阶段 1 · 构建分析工具 + 登录链文档
      
      > **目标**:给自己装上"眼睛",并把流程用文档固定下来。
      > 没有日志钩子,后面每一步都是盲猜。
      
      ### 1.1 注入日志钩子
      
      把三类日志**统一分类输出到固定目录**,便于后续定位:
      
      ```
      logs/
      ├── engine/     引擎日志(Unity/UE/自研 runtime)
      ├── hotupdate/  热更运行日志(lua/js/dll 打印)
      └── network/    网络日志(收发 / 加解密前后 / 连接事件)
      ```
      
      手段:`logcat`、Frida hook(`send`/`recv`/`Encrypt`/`Decrypt`/日志函数)、
      客户端自带调试开关、引擎的 console 重定向。
      
      > **为什么要分类**:出问题时需要"三边对齐"——
      > 引擎说它发了、热更说它调了、网络说它没收到,**分区才能一眼看出断在哪**。
      
      ### 1.2 整理「登录链条」文档 
      
      > 用 `templates/login-chain.md`,按工作区**实际目标**填写,**不要照抄通用模板**。
      
      它把"客户端从启动到进主场景"的每一步登清,是**阶段 3 补包顺序的依据**。
      一个典型的链条(**仅示例,实际形状必须按目标调整**):
      
      ```
      SDK login → getServerList → getLastServerList → 选服
      → player.GetUserList → player.Login → Connect(host,port) → getHash
      → [player.CreateUser] → user.UserLogin → user.GetUserInfo → Loginok → 主场景
      ```
      
      **每一步记 5 项**:`谁发起 / 发什么(协议号或 URL)/ 期待什么回包 / 证据编号 / 状态`。
      链条整理不清,后面的补包就会没有顺序 → 四处乱试。
      
      ### 1.3 起一个能监听、能记日志的基础服务端
      
      不必是完整服务端——先能 `listen` + 打印收到的字节即可。
      (`templates/mock_server.py` 或 `server/` 的透传模式。)
      
      **产出**:`logs/` 分区 + `login-chain.md` + 可监听的基础服务端。
      
      **对应文档**:`adaptation.md`、`client-address-sources.md`、`engineering-practices.md`
      
      ---
      
      ## 阶段 2 · 重定向(让请求到达自建服务端)
      
      > **目标**:让客户端把请求发给**我们**,而不是官方服。
      > 顺序原则:**代价低的先做,改包留到最后。**
      
      ### 四层重定向表
      
      | 层 | 手段 | 何时用 | 代价 |
      |---|---|---|---|
      | **DNS / 寻址** | 对取地址函数做注入,域名 → `127.0.0.1`;端口可覆盖 | 地址来自**域名** | **最低** |
      | **传输 / TLS** | HTTPS 降级 HTTP、阻断客户端证书校验;本地 CA 注入、TLS 代理 | 原协议是 TLS **且无本地证书** | 中 |
      | **SDK / 平台** | inline hook 登录函数,伪造成功回调 | 平台登录**无法离线**(第三方账号 SDK) | 中高 |
      | **业务协议** | 服务端按**真实 wire format** 应答 | **始终需要** | 高(但必须) |
      
      > 详细落点与实战三板斧(iptables DNAT / TCP 中继 / bind-mount hosts)
      > → `client-address-sources.md` §1、§3。
      
      ### 拓扑注意
      
      ```
      客户端与服务端在同一台机器   → 地址用 127.0.0.1
      手机 / 模拟器连电脑            → 用电脑的局域网 IP(模拟器常是 10.0.2.2)
      ```
      
      > **本阶段的目标就一句话**:搭一个**能监听、能记日志**的基础服务端,
      > 让客户端发送的请求**真的到达**它。
      
      **验收(只判这一件事)**:客户端请求到达你的服务端,且你在日志里**看得见**它。
      
      **对应文档**:`client-address-sources.md`(六类地址来源 + 落点策略)、
      (改了就被杀先看这个)、`repack-rename.md`(真要改包名时)
      
      ---
      
      ## 阶段 3 · 补包循环(核心玩法)
      
      > **目标**:从登录链**最上游**往下游,一块一块把回包补上,直到客户端进主场景。
      
      ### 3.1 补包方向:上游 → 下游
      
      ```
      ① 先回【服务器列表】        (地址来自这里)
      ② 再回【登录握手】          (鉴权、token、hash)
      ③ 最后补【游戏需要的数据】  (角色 / 背包 / 任务 / VIP / 签到…)
      ```
      
      ### 3.2 通过标准:**客户端能走到下一步**
      
      > 每补一块回包,判据不是"服务端没报错",而是"**客户端前进了一步**"。
      > (这是本 skill 铁律 4「不把能跑当跑通」在补包阶段的落地。)
      
      ### 3.3 出错时怎么查
      
      **三边对齐**:`客户端报错` + `网络日志` + `服务端日志`,判断属于哪一种:
      
      | 症状 | 大概率原因 |
      |------|-----------|
      | 客户端没反应 / 静默断开 | **格式错**(帧/字节序/字段布局)+ 长度不对 |
      | 明确报错 / 空指针 | **数据缺失**(少字段、空子消息被省略) |
      | 走到了下一步但还是不对 | **逻辑根本没写**(该给的条件数据没给) |
      
      > 帧级定点改写(不依赖 protobuf runtime)→ `wire-level-patching.md`
      > 假阳性排查("看着成功了其实没有")→ `closure-verification.md`
      
      ### 3.4 循环
      
      ```
      测试 →(看日志)→ 定位 →(改)→ 修改 → 再测试   ↺
      ```
      
      **到达里程碑**:**能进入游戏主场景 = 成功一大半。**
      之后就只有这一条路线在跑——不断让用户测试工作区目标。
      
      **对应文档**:`wire-level-patching.md`、`engineering-practices.md`、
      `closure-verification.md`、子系统篇(`account.md` / `combat.md` / `drops.md` / `gacha.md`)
      
      ---
      
      ## 阶段 4 · 功能清单与迭代
      
      > **目标**:把"哪些通了、哪些没通"写成文档,反复迭代直到基本完善。
      
      - 用 `templates/function-checklist.md` 按子系统登记功能与协议号。
      - 与 `TRACKER.md`、`templates/status-matrix.md`(三轴状态)保持同步。
      - 每完成一个大模块就更新——**不要等全做完才写文档**。
      
      **对应文档**:`verification-and-status.md`、`live-ops.md`(进服后的运营)、
      `release-and-ops.md`(要交付/部署时)
      
      ---
      
      ## 阶段 × 方法 × 文档 对照表
      
      | 阶段 | 常用方法 | 主文档 | 验收 |
      |------|---------|--------|------|
      | 0 静态分析 | M1 / M4 / M9 | `primer.md` `adaptation.md` `from-installer.md` | 有 project-profile + 证据清单 |
      | 1 建工具 + 登录链 | M3(hook 日志) | `client-address-sources.md` `templates/login-chain.md` | 日志分区 + 登录链文档 |
      | 2 重定向 | M5 / 三板斧 | `client-address-sources.md`  | 请求到达自建服务端 |
      | 3 补包循环 | M6 / M5 / M2 | `wire-level-patching.md` `closure-verification.md` | 客户端进主场景 |
      | 4 清单迭代 | — | `verification-and-status.md` `live-ops.md` | 功能清单基本完善 |
      
      > 注意: **分叉**:可单机化 + 能注入 → 阶段 2/3 换成 **M11 内联服务端**(`inline-server.md`)。
      
      ---
      
      ## 反模式(这条路上真实踩过的)
      
      1. **跳过阶段 0 直接抓包** → 不知道找什么,抓一堆也没用。
      2. **没有日志钩子就补包** → 全靠猜,定位不了。
      3. **没写登录链就补包** → 没有顺序,四处乱试。
      4. **先改包再想协议** → 触发签名/完整性校验,秒退(应先"不改包 + 端口劫持")。
      5. **补包判据用"服务端不报错"** → 客户端根本没前进,却以为通了。
      6. **阶段 2 就要求"支付到账闭环"** → 顺序错了;第一阶段只该判到"TCP 连接 + 收到登录请求"。
      
      ---
      
      ## 一句话
      
      > **先清点,再点灯,再改向,再补包,最后列表迭代。**
      > 每一步都有**唯一的验收动作**;没通过就不要往下走。
  • schema
    • project-profile.yaml 2.8 KB
      # =========================================================
      # 项目档案模板(Project Profile)
      # AI 读取用户项目后填此文件,作为所有后续决策的输入
      # 复制到 out/project-profile.yaml 再填
      # =========================================================
      
      meta:
        project_name: "TODO"
        created_at: "TODO"
        analyst: "AI"
      
      # ---- 引擎与环境 ----
      project:
        engine: "TODO"            # unity-il2cpp | unity-mono | unity-lua | unreal | cocos | custom | unknown
        engine_version: "TODO"    # 如 UE5.3 / Unity2021.3
        client_language: "TODO"   # csharp | cpp | lua | js | java | unknown
        platform: "TODO"          # android | ios | windows | web | multiple
        arch: "TODO"              # arm64 | x86_64 | il2cpp-arm64 ...
        protection: "TODO"        # none | packer | anti-debug | string-encrypt | ...
      
      # ---- 用户实际提供了什么 ----
      provided_artifacts:
        # 填实际有的;没有的留空
        dump_cs: false            # Unity IL2CPP dump
        global_metadata: false
        libil2cpp: false
        assembly_csharp: false    # Unity Mono
        lua_scripts: false
        luac: false
        usmap: false              # UE mapping
        sdk_dump: false           # UE SDK (.h/.cpp)
        pak: false                # UE pak/utoc/ucas
        pcap: false               # 抓包
        proto_files: false        # .proto
        config_tables: false      # 游戏配置表(csv/excel/json)
        screenshots: false
        other: []
      
      # ---- 还需要什么证据 ----
      needs:
        - "TODO"                  # 例: 登录后的抓包 / XOR 密钥 / 0x0305 字段布局
      
      # ---- 初判(可留空,由决策树补全)----
      hints:
        transport_hint: "unknown"   # tcp | udp | kcp | quic | ws | http | unknown
        serialize_hint: "unknown"   # protobuf | json | msgpack | binary | unknown
        crypto_hint: "unknown"      # none | xor | rc4 | aes | custom | unknown
        known_tools_output: []      # 例: [il2cppdumper, fmodel, ue4ss]
      
      # ---- 目标 ----
      goal:
        primary: "reverse_and_rebuild_server"   # 反推并重建服务端
        deliverables:
          - protocol_spec
          - server
          - deploy
          - verification
        constraints:
          - "仅自研/已授权/离线目标"
      
      # ---- 反推方法(见 references/methods.md,动手前必填)----
      method:
        inputs: []                # PKG / CAP / SRC / RUN / DBG / MANIP / REF
        goal_type: "TODO"         # DOC | FLOW | FULL | PATCH
        constraints: []
        picked: "TODO"            # M1..M10 主方法
        combo: []                 # 组合使用的方法
        reason: "TODO"            # 为什么选它
        switches: []              # 中途切换记录:{from, to, why}
      
      # ---- 探测命令(可复现)----
      scan_commands:
        engine_probe: "find <project> -maxdepth 4 -iname '*.pak' -o -iname 'global-metadata.dat' -o -iname 'libil2cpp.so' -o -iname 'libUE4.so'"
        lib_probe: "grep -rIl -E 'protobuf|kcp|msgpack|websocket|socket\\.io' <project> | head"
    • protocol.spec.yaml 5.5 KB
      # =========================================================
      # 协议规格模板(Protocol Spec)
      # 唯一事实来源:所有代码/文档/测试都从这份派生
      # 复制到 out/protocol.spec.yaml 再填
      # 每个结论必须带 evidence(证据编号) 与 confidence(high/medium/low)
      # =========================================================
      
      meta:
        project_name: "TODO"
        spec_version: "0.1"
        updated_at: "TODO"
      
      project:
        engine: "TODO"
        platform: "TODO"
        client_language: "TODO"
      
      # ---- 传输层 ----
      transport:
        type: "TODO"              # tcp | udp | kcp | quic | ws | http
        port: 0                   # 若已知
        tls: false
        evidence: []
        confidence: "low"
      
      # ---- 封装层 ----
      frame:
        mode: "length_prefix"     # length_prefix | delimiter | raw
        length_size: 0            # 1 | 2 | 4
        length_endian: "little"   # little | big
        includes_self: false
        opcode_size: 0            # 0 = 无独立消息号
        opcode_endian: "little"
        delimiter: null           # 若 mode=delimiter,如 "0d0a"
        evidence: []
        confidence: "low"
      
      # ---- 加密层 ----
      crypto:
        enabled: false
        algorithm: "none"         # none | xor | rc4 | aes | custom
        key_source: "unknown"     # hardcoded | handshake | login | derived | unknown
        key: null                 # 已知则填(hex/字符串)
        iv: null
        per_connection: false
        custom_note: ""           # 自定义算法说明
        evidence: []
        confidence: "low"
      
      # ---- 压缩层 ----
      compress:
        enabled: false
        algorithm: "zlib"         # zlib | gzip | lz4
        min_size: 0
        evidence: []
        confidence: "low"
      
      # ---- 序列化层 ----
      serialize:
        format: "binary"          # json | protobuf | msgpack | binary | flatbuffers
        endian: "little"
        strings: "length_prefixed_u16"   # length_prefixed_u8/u16/u32 | fixed | cstring
        evidence: []
        confidence: "low"
      
      # ---- 消息号表 ----
      opcodes:
        source: "unknown"         # dump.cs | lua | sdk.h | inferred
        table:
          # - {value: 0x0001, name: HANDSHAKE_REQ, dir: c2s, msg: HandshakeReq}
          # - {value: 0x0002, name: HANDSHAKE_RES, dir: s2c, msg: HandshakeRes}
        evidence: []
        confidence: "low"
      
      # ---- 消息字段布局 ----
      messages:
        # LoginReq:
        #   opcode: 0x0101
        #   direction: c2s
        #   fields:
        #     - {name: username, type: str}
        #     - {name: password, type: str}
        #     - {name: client_ver, type: u32}
        #   evidence: [E01]
        #   confidence: high
      
      # ---- 状态机 ----
      state_machine:
        # - {state: CONNECT,   on: [HANDSHAKE_REQ], next: HANDSHAKED}
        # - {state: HANDSHAKED, on: [LOGIN_REQ],    next: LOGGED_IN}
        # - {state: LOGGED_IN,  on: [CHAR_SELECT_REQ], next: IN_GAME}
        evidence: []
        confidence: "low"
      
      # ---- 业务模块 ----
      modules:
        account: false            # 账号/登录
        character: false          # 角色
        scene: false              # 场景/移动
        mail: false               # 邮件
        shop: false               # 商店
        pay: false                # 充值
        chat: false               # 聊天
        guild: false              # 公会
        notes: ""
      
      # ---- 客户端(语言 + 打补丁方式)----
      client:
        language: "TODO"          # csharp | il2cpp | as3 | lua | js | java | cpp
        source_available: false   # 能否反编译出源码
        decompiler: "TODO"        # dnSpy | jadx | FFDec | unluac | ida
        patch_method: "TODO"      # config_file | source_rebuild | binary_patch | runtime_hook
        patch_target: "TODO"
        skip_login: false
      
      # ---- 通用子系统(别漏)----
      subsystems:
        account:   {enabled: false, store: "sqlite"}
        save:      {enabled: false, path: "./data/game.db"}
        admin:     {enabled: false, bind: "127.0.0.1", port: 0}
        resources: {enabled: false, mode: "local"}   # local | cdn | hybrid
        ports:
          main: 0
          battle: 0               # 常为 main+1
          admin: 0                # 常为 main+2
        client_patch: {method: "config_file"}
      
      # ---- 服务端人机( 可选拓展,不影响核心运行)----
      # 跑通之后再考虑;不需要就整段留空。
      # 先反推(extensions/bot-reverse.md),再复刻。
      bots:
        enabled: false            # 是否启用该拓展
        # —— 反推结论(来自客户端)——
        needed: false             # 开局是否要求多人
        ownership: "unknown"      # server | client | hybrid | unsupported
        supported_by_client: false  # 客户端协议是否支持 AI
        ai_flag_field: null       # 客户端里的"这是AI"标记字段,如 "is_ai"
        ai_flag_values: {}        # {human: 0, ai: 1}
        identity_fields: []       # AI 需要的身份字段 name/level/job/deck/avatar
        action_opcodes: []        # AI 动作复用哪些 opcode
        flow_opcodes: []          # 加入/准备/开始/离开
        min_players: 0            # 开局最少人数
        evidence: []              # 证据编号
        # —— 复刻配置 ——
        mode: "logic"             # puppet | logic | fake_client | external
        auto_fill: 0              # 缺人时自动补几个
        difficulty: "normal"      # easy | normal | hard
        humanize: true            # 拟人化(延迟/抖动/失误)
        behaviors: []             # 需要的行为,如 wander/follow/use_card
        recycle: true             # 房间结束回收
      
      # ---- 未确认项 ----
      unresolved:
        # - item: "crypto.key"
        #   need: "二进制内 XOR 密钥常量 / 握手包中的 key 字段"
        #   blocking: true
        - item: "TODO"
          need: "TODO"
          blocking: true
      
      # ---- 参考实现选择 ----
      implementation:
        language: "python"        # python | go | rust | node | java | cpp
        reference: "server/"      # 用哪个参考实现作骨架
        reason: "TODO"
        impact_matrix: []         # 需要改动的参考实现位置
  • server
    • app
      • logic
        • handlers
          • auth.py 4.2 KB
            """app.logic.handlers.auth —— 握手 / 登录 / 注册 / 心跳"""
            from __future__ import annotations
            
            import logging
            
            from ...net.dispatcher import dispatch
            from ...proto.opcodes import OP, ERR, ERR_TEXT
            from ...proto.messages import (
                HandshakeReq, HandshakeRes, LoginReq, LoginRes, ErrorNtf,
            )
            from ...store.db import get_db
            from ..security import hash_password, verify_password, sign_token, RateLimiter
            
            log = logging.getLogger("gsrv.auth")
            
            _login_limiter = RateLimiter(limit=10, window=60.0)
            
            
            @dispatch(OP.HANDSHAKE_REQ)
            async def on_handshake(session, codec, body):
                try:
                    req = HandshakeReq.decode(body, codec)
                except Exception:
                    await session.send_message(ErrorNtf(ERR.BAD_PACKET, ERR_TEXT[ERR.BAD_PACKET]))
                    return
                from ...config import get_config
                expect = get_config().get("game.expected_version", "")
                require = get_config().get("game.require_version", True)
                code = ERR.OK
                if require and expect and req.version != expect:
                    code = ERR.BAD_VERSION
                    log.warning("version mismatch: client=%s expect=%s", req.version, expect)
                session.state = "HANDSHAKED"
                session.attrs["nonce"] = req.nonce
                from ...config import get_config as gc
                await session.send_message(HandshakeRes(code, gc().get("game.version", "1.0.0")))
                log.info("handshake peer=%s version=%s nonce=%d -> code=%d",
                         session.peer, req.version, req.nonce, code)
            
            
            @dispatch(OP.HEARTBEAT_REQ)
            async def on_heartbeat(session, codec, body):
                session.touch()
                await session.send(OP.HEARTBEAT_RES, body)  # 原样回显
            
            
            @dispatch(OP.REGISTER_REQ)
            async def on_register(session, codec, body):
                db = get_db()
                try:
                    req = LoginReq.decode(body, codec)
                except Exception:
                    await session.send_message(ErrorNtf(ERR.BAD_PACKET, ERR_TEXT[ERR.BAD_PACKET]))
                    return
                if not req.username or not req.password:
                    await session.send_message(ErrorNtf(ERR.AUTH_FAILED, ERR_TEXT[ERR.AUTH_FAILED]))
                    return
                existing = await db.get_account(req.username)
                if existing:
                    await session.send_message(ErrorNtf(ERR.ACCOUNT_EXISTS, ERR_TEXT[ERR.ACCOUNT_EXISTS]))
                    return
                uid = await db.create_account(req.username, hash_password(req.password))
                token = sign_token(uid, _secret(), _ttl())
                await session.send(OP.REGISTER_RES, LoginRes(ERR.OK, token, uid).encode(codec))
                log.info("registered uid=%d username=%s", uid, req.username)
            
            
            @dispatch(OP.LOGIN_REQ)
            async def on_login(session, codec, body):
                if not _login_limiter.allow(str(session.peer)):
                    await session.send_message(ErrorNtf(ERR.RATE_LIMITED, ERR_TEXT[ERR.RATE_LIMITED]))
                    return
                db = get_db()
                try:
                    req = LoginReq.decode(body, codec)
                except Exception:
                    await session.send_message(ErrorNtf(ERR.BAD_PACKET, ERR_TEXT[ERR.BAD_PACKET]))
                    return
            
                acc = await db.get_account(req.username)
                if acc is None:
                    # 首次登录自动注册(自托管常见便利行为;生产可关)
                    uid = await db.create_account(req.username, hash_password(req.password))
                    acc = await db.get_account(req.username)
                elif not verify_password(req.password, acc["password_hash"]):
                    await session.send_message(ErrorNtf(ERR.AUTH_FAILED, ERR_TEXT[ERR.AUTH_FAILED]))
                    log.warning("login failed username=%s", req.username)
                    return
                if acc["banned"]:
                    await session.send_message(ErrorNtf(ERR.AUTH_FAILED, "banned"))
                    return
            
                uid = acc["id"]
                session.server.bind_uid(session, uid)
                session.username = req.username
                session.state = "LOGGED_IN"
                token = sign_token(uid, _secret(), _ttl())
                session.token = token
                await db.touch_login(uid)
                await session.send(OP.LOGIN_RES, LoginRes(ERR.OK, token, uid).encode(codec))
                log.info("login ok uid=%d username=%s", uid, req.username)
            
                from ...config import get_config
                if get_config().get("game.auto_push_char_list", True):
                    from .char import push_char_list
                    await push_char_list(session)
            
            
            def _secret():
                from ...config import get_config
                return get_config().get("security.token_secret", "secret")
            
            
            def _ttl():
                from ...config import get_config
                return int(get_config().get("security.token_ttl", 86400))
          • battle.py 3.2 KB
            """app.logic.handlers.battle —— 战斗(服务端权威)
            
            流程:
              BATTLE_ACTION_REQ(act) → 结算伤害 → 广播 BATTLE_STATE_NTF
              全员行动完 → 回合结束(Boss 反击)→ 广播
              结束 → 掷掉落 → 发放(背包满转邮件)→ BATTLE_RESULT_NTF
            """
            from __future__ import annotations
            
            import logging
            import struct
            
            from ...net.dispatcher import dispatch
            from ...proto.opcodes import OP, ERR
            from ...proto.messages import ErrorNtf
            from ..rooms import ROOMS, Room
            from .. import loot
            
            log = logging.getLogger("gsrv.battle")
            
            
            def encode_state(room: Room) -> bytes:
                b = room.battle
                out = struct.pack("<IHiiB", room.id, b.turn, b.boss_hp, b.boss_max_hp,
                                  len(b.players))
                for cid, hp in b.players.items():
                    out += struct.pack("<Qi", cid, hp)
                return out
            
            
            async def broadcast_state(server, room: Room):
                payload = encode_state(room)
                for uid in list(room.members.keys()):
                    sess = server.by_uid.get(uid)
                    if sess and sess.alive:
                        await sess.send(OP.BATTLE_STATE_NTF, payload)
            
            
            async def _broadcast_result(server, room: Room, win: bool, drops: list):
                out = struct.pack("<IBHB", room.id, 1 if win else 0,
                                  room.battle.turn if room.battle else 0,
                                  min(len(drops), 255))
                for item_id, count in drops[:255]:
                    out += struct.pack("<II", int(item_id), int(count))
                for uid in list(room.members.keys()):
                    sess = server.by_uid.get(uid)
                    if sess and sess.alive:
                        await sess.send(OP.BATTLE_RESULT_NTF, out)
            
            
            @dispatch(OP.BATTLE_ACTION_REQ)
            async def on_action(session, codec, body):
                room = ROOMS.room_of(session.uid)
                if room is None or room.battle is None or room.battle.finished:
                    await session.send_message(ErrorNtf(ERR.BATTLE_NOT_ACTIVE,
                                                        "battle not active"))
                    return
                act = body[0] if body else 1
                dmg = room.battle.action(session.char_id, act)
            
                m = room.members.get(session.uid)
                if m:
                    m.acted = True
                await session.send(OP.BATTLE_ACTION_RES, struct.pack("<BI", ERR.OK, dmg))
            
                if room.battle.all_acted():
                    room.battle.end_turn()
                    log.info("battle room=%d turn -> %d boss_hp=%d",
                             room.id, room.battle.turn, room.battle.boss_hp)
            
                await broadcast_state(session.server, room)
            
                if room.battle.finished:
                    await _settle(session.server, room)
            
            
            async def _settle(server, room: Room):
                win = room.battle.win
                drops: list = []
                if win:
                    table_id = room.scene or 1
                    drops = loot.roll_drops(table_id)          # 服务端权威掉落
                    db = _db()
                    for m in room.members.values():
                        await loot.grant_items(db, server, m.cid, drops)   # 每人一份
                    log.info("battle victory room=%d drops=%s", room.id, drops)
                else:
                    log.info("battle defeat room=%d", room.id)
            
                await _broadcast_result(server, room, win, drops)
            
                # 战斗结束 → 房间回到等待态
                room.state = "WAITING"
                room.battle = None
                for m in room.members.values():
                    m.ready = False
                from .room import broadcast_room
                await broadcast_room(server, room)
            
            
            def _db():
                from ...store.db import get_db
                return get_db()
          • char.py 3.6 KB
            """app.logic.handlers.char —— 角色列表 / 创建 / 选择 / 删除"""
            from __future__ import annotations
            
            import logging
            
            from ...net.dispatcher import dispatch
            from ...proto.opcodes import OP, ERR, ERR_TEXT
            from ...proto.messages import (
                CharInfo, CharListRes, CharCreateReq, CharCreateRes,
                CharSelectReq, CharSelectRes, ErrorNtf,
            )
            from ...store.db import get_db
            from ..state import STATE
            
            log = logging.getLogger("gsrv.char")
            
            
            def _need_login(session) -> bool:
                return session.state not in ("LOGGED_IN", "IN_GAME")
            
            
            async def _send_err(session, code):
                await session.send_message(ErrorNtf(code, ERR_TEXT.get(code, "error")))
            
            
            async def push_char_list(session):
                db = get_db()
                rows = await db.list_chars(session.uid)
                chars = [CharInfo(r["id"], r["name"], r["level"], r["job"]) for r in rows]
                await session.send_message(CharListRes(chars))
            
            
            @dispatch(OP.CHAR_LIST_REQ)
            async def on_char_list(session, codec, body):
                if _need_login(session):
                    await _send_err(session, ERR.NEED_LOGIN)
                    return
                await push_char_list(session)
            
            
            @dispatch(OP.CHAR_CREATE_REQ)
            async def on_char_create(session, codec, body):
                if _need_login(session):
                    await _send_err(session, ERR.NEED_LOGIN)
                    return
                db = get_db()
                try:
                    req = CharCreateReq.decode(body, codec)
                except Exception:
                    await _send_err(session, ERR.BAD_PACKET)
                    return
                if not req.name or len(req.name) > 16:
                    await _send_err(session, ERR.BAD_PACKET)
                    return
                limit = int(await db.get_config("max_char_per_account", "4"))
                rows = await db.list_chars(session.uid)
                if len(rows) >= limit:
                    await _send_err(session, ERR.CHAR_LIMIT)
                    return
                if await db.get_char_by_name(req.name):
                    await _send_err(session, ERR.ACCOUNT_EXISTS)
                    return
                cid = await db.create_char(session.uid, req.name, req.job)
                row = await db.get_char(cid)
                char = CharInfo(row["id"], row["name"], row["level"], row["job"])
                await session.send_message(CharCreateRes(ERR.OK, char))
                log.info("char created uid=%d cid=%d name=%s", session.uid, cid, req.name)
                await push_char_list(session)
            
            
            @dispatch(OP.CHAR_SELECT_REQ)
            async def on_char_select(session, codec, body):
                if _need_login(session):
                    await _send_err(session, ERR.NEED_LOGIN)
                    return
                db = get_db()
                try:
                    req = CharSelectReq.decode(body, codec)
                except Exception:
                    await _send_err(session, ERR.BAD_PACKET)
                    return
                row = await db.get_char(req.cid)
                if row is None or row["uid"] != session.uid:
                    await _send_err(session, ERR.NO_CHAR)
                    return
                session.char_id = req.cid
                session.state = "IN_GAME"
                STATE.enter(req.cid, {
                    "cid": req.cid, "uid": session.uid, "name": row["name"],
                    "level": row["level"], "scene": row["scene"], "x": row["x"], "y": row["y"],
                })
                await session.send_message(CharSelectRes(ERR.OK, req.cid, row["scene"]))
                log.info("char selected uid=%d cid=%d scene=%d", session.uid, req.cid, row["scene"])
            
            
            @dispatch(OP.CHAR_DELETE_REQ)
            async def on_char_delete(session, codec, body):
                if _need_login(session):
                    await _send_err(session, ERR.NEED_LOGIN)
                    return
                db = get_db()
                try:
                    req = CharSelectReq.decode(body, codec)  # 复用 cid 解包
                except Exception:
                    await _send_err(session, ERR.BAD_PACKET)
                    return
                row = await db.get_char(req.cid)
                if row is None or row["uid"] != session.uid:
                    await _send_err(session, ERR.NO_CHAR)
                    return
                await db.delete_char(req.cid)
                await push_char_list(session)
          • drop.py 1.9 KB
            """app.logic.handlers.drop —— 背包 / 掉落查询与测试
            
              BAG_LIST_REQ   → BAG_LIST_RES   列出背包
              DROP_TEST_REQ  → DROP_TEST_RES  按掉落表试掷并发放(调试用;真实项目可删)
            """
            from __future__ import annotations
            
            import logging
            import struct
            
            from ...net.dispatcher import dispatch
            from ...proto.opcodes import OP, ERR, ERR_TEXT
            from ...proto.messages import ErrorNtf
            from ...store.db import get_db
            from .. import loot
            
            log = logging.getLogger("gsrv.drop")
            
            
            @dispatch(OP.BAG_LIST_REQ)
            async def on_bag_list(session, codec, body):
                if session.char_id is None:
                    await session.send_message(ErrorNtf(ERR.NEED_LOGIN, ERR_TEXT[ERR.NEED_LOGIN]))
                    return
                db = get_db()
                rows = await db.list_items(session.char_id)
                out = struct.pack("<H", len(rows))
                for r in rows:
                    out += struct.pack("<II", r["item_id"], r["count"])
                await session.send(OP.BAG_LIST_RES, out)
            
            
            @dispatch(OP.DROP_TEST_REQ)
            async def on_drop_test(session, codec, body):
                """按 table_id 掷一次掉落并发放(联调 / 调试用)"""
                if session.char_id is None:
                    await session.send_message(ErrorNtf(ERR.NEED_LOGIN, ERR_TEXT[ERR.NEED_LOGIN]))
                    return
                table_id = struct.unpack_from("<I", body, 0)[0] if len(body) >= 4 else 1
                drops = loot.roll_drops(table_id)
                db = get_db()
                added, mailed = await loot.grant_items(db, session.server, session.char_id, drops)
            
                out = struct.pack("<HB", table_id, min(len(drops), 255))
                for item_id, count in drops[:255]:
                    out += struct.pack("<II", item_id, count)
                await session.send(OP.DROP_TEST_RES, out)
            
                # 掉落通知(含走邮件的部分)
                ntf = struct.pack("<HBB", len(drops), len(added), len(mailed))
                for item_id, count in drops:
                    ntf += struct.pack("<II", item_id, count)
                await session.send(OP.DROP_NTF, ntf)
                log.info("drop test cid=%d table=%d added=%s mailed=%s",
                         session.char_id, table_id, added, mailed)
          • mail.py 5.1 KB
            """app.logic.handlers.mail —— 邮件系统
            
            能力:
              - 邮箱列表 / 已读 / 删除
              - 附件领取(金币、钻石、道具)—— 服务端权威发放
              - 新邮件实时推送(MAIL_NEW_NTF)
              - 供其它模块调用:send_system_mail(session_server, cid, ...)
            """
            from __future__ import annotations
            
            import json
            import logging
            
            from ...net.dispatcher import dispatch
            from ...proto.opcodes import OP, ERR, ERR_TEXT
            from ...proto.messages import MailInfo, MailListRes, MailOpReq, MailOpRes, MailNewNtf, ErrorNtf
            from ...store.db import get_db
            from ...store.models import CURRENCY_NAME
            
            log = logging.getLogger("gsrv.mail")
            
            
            def _row_to_mailinfo(row) -> MailInfo:
                try:
                    atts = json.loads(row["attachments"] or "[]")
                except Exception:
                    atts = []
                return MailInfo(
                    mid=row["id"],
                    title=row["title"] or "",
                    content=row["content"] or "",
                    attachments=atts,
                    claimed=bool(row["claimed"]),
                    read=bool(row["is_read"]),
                    ts=row["created_at"] or 0,
                )
            
            
            async def push_new_mail(session, cid: int):
                """把某角色最新一封邮件推给在线会话(可选)"""
                db = get_db()
                rows = await db.list_mails(cid)
                if rows:
                    await session.send_message(MailNewNtf(_row_to_mailinfo(rows[0])))
            
            
            async def push_mail(session, mid: int):
                """按 mail id 推送一封邮件通知"""
                db = get_db()
                row = await db.get_mail(mid)
                if row:
                    await session.send_message(MailNewNtf(_row_to_mailinfo(row)))
            
            
            async def send_system_mail(server, cid: int, title: str, content: str,
                                       attachments=None, push: bool = True):
                """给角色发一封系统邮件;若在线则实时推送。"""
                db = get_db()
                mid = await db.send_mail(cid, title, content, attachments or [])
                log.info("mail sent cid=%d mid=%d title=%s att=%s",
                         cid, mid, title, attachments)
                if push:
                    row = await db.get_mail(mid)
                    # 找到该角色对应的在线会话
                    for s in list(server.sessions.values()):
                        if s.char_id == cid:
                            await s.send_message(MailNewNtf(_row_to_mailinfo(row)))
                            break
                return mid
            
            
            @dispatch(OP.MAIL_LIST_REQ)
            async def on_mail_list(session, codec, body):
                if session.char_id is None:
                    await session.send_message(ErrorNtf(ERR.NEED_LOGIN, ERR_TEXT[ERR.NEED_LOGIN]))
                    return
                db = get_db()
                rows = await db.list_mails(session.char_id)
                await session.send_message(MailListRes([_row_to_mailinfo(r) for r in rows]))
            
            
            @dispatch(OP.MAIL_READ_REQ)
            async def on_mail_read(session, codec, body):
                if session.char_id is None:
                    return
                db = get_db()
                req = MailOpReq.decode(body, codec)
                row = await db.get_mail(req.mid)
                if row is None or row["cid"] != session.char_id:
                    await session.send(OP.MAIL_READ_RES, MailOpRes(ERR.MAIL_NOT_FOUND, req.mid).encode(codec))
                    return
                await db.mark_mail_read(req.mid)
                await session.send(OP.MAIL_READ_RES, MailOpRes(ERR.OK, req.mid).encode(codec))
            
            
            @dispatch(OP.MAIL_CLAIM_REQ)
            async def on_mail_claim(session, codec, body):
                """领取附件:服务端权威,逐个发放,标记已领,防重复。"""
                if session.char_id is None:
                    return
                db = get_db()
                req = MailOpReq.decode(body, codec)
                row = await db.get_mail(req.mid)
                if row is None or row["cid"] != session.char_id:
                    await session.send(OP.MAIL_CLAIM_RES, MailOpRes(ERR.MAIL_NOT_FOUND, req.mid).encode(codec))
                    return
                if row["claimed"]:
                    await session.send(OP.MAIL_CLAIM_RES, MailOpRes(ERR.MAIL_ALREADY_CLAIMED, req.mid).encode(codec))
                    return
            
                try:
                    atts = json.loads(row["attachments"] or "[]")
                except Exception:
                    atts = []
            
                for a in atts:
                    typ = a.get("type")
                    count = int(a.get("count", 0))
                    if typ in ("gold", "diamond"):
                        cur = 1 if typ == "gold" else 2
                        await db.grant_currency(session.char_id, cur, count)
                    elif typ == "item":
                        # 道具:入库到 item 表
                        item_id = int(a.get("id", 0))
                        await db.execute(
                            "INSERT INTO item(cid, item_id, count) VALUES(?, ?, ?)",
                            (session.char_id, item_id, count))
                    log.info("mail claim cid=%d mid=%d +%s x%d",
                             session.char_id, req.mid, typ, count)
            
                await db.claim_mail(req.mid)
                gold, diamond = await db.get_currency(session.char_id)
                log.info("after claim cid=%d gold=%d diamond=%d",
                         session.char_id, gold, diamond)
                await session.send(OP.MAIL_CLAIM_RES, MailOpRes(ERR.OK, req.mid).encode(codec))
                # 通知客户端刷新货币
                await push_new_mail(session, session.char_id)
            
            
            @dispatch(OP.MAIL_DELETE_REQ)
            async def on_mail_delete(session, codec, body):
                if session.char_id is None:
                    return
                db = get_db()
                req = MailOpReq.decode(body, codec)
                row = await db.get_mail(req.mid)
                if row is None or row["cid"] != session.char_id:
                    await session.send(OP.MAIL_CLAIM_RES, MailOpRes(ERR.MAIL_NOT_FOUND, req.mid).encode(codec))
                    return
                await db.delete_mail(req.mid)
                await session.send(OP.MAIL_CLAIM_RES, MailOpRes(ERR.OK, req.mid).encode(codec))
          • pay.py 4.9 KB
            """app.logic.handlers.pay —— 充值 / 支付(自托管模式:点击购买直接成功)
            
            设计要点:
              * 客户端连的是我们自己的服务端,真实的第三方支付 SDK 在本服务端里不存在。
                所以下单请求直接由本服务端判定"成功",并发放货币/道具。
              * 幂等:order_no 唯一,重复回调只会发放一次(防重放)。
              * 两种发放方式(config: game.pay_grant_mode):
                  direct —— 直接把货币加到角色
                  mail   —— 发到邮箱,玩家自行领取
              * 商品表在 store/models.py 的 PAY_PRODUCTS,用客户端真实 product_id 做键。
            
            注意: 仅用于自建/离线/已授权服务器。不要用它去伪造真实支付渠道的凭证。
            """
            from __future__ import annotations
            
            import logging
            import time
            import uuid
            
            from ...net.dispatcher import dispatch
            from ...proto.opcodes import OP, ERR, ERR_TEXT
            from ...proto.messages import (
                PayReq, PayRes, PayProductListRes, ErrorNtf, MailInfo,
            )
            from ...store.db import get_db
            from ...store.models import PAY_PRODUCTS, CURRENCY_NAME
            
            log = logging.getLogger("gsrv.pay")
            
            
            def _cfg(path, default=None):
                from ...config import get_config
                return get_config().get(path, default)
            
            
            async def _grant(db, session, currency: int, amount: int, product_id: str):
                """按配置发放;返回 (by_mail, new_balance, mail_mid)
            
                注意:邮件模式下**不在此处推送**,由调用方在回包之后推送,
                避免"推送早于请求回包"造成客户端消息错位。
                """
                mode = str(_cfg("game.pay_grant_mode", "direct")).lower()
                title = "充值到账"
                content = f"您购买的【{product_id}】已到账:{CURRENCY_NAME.get(currency, '?')} x{amount}"
                att = [{"type": CURRENCY_NAME.get(currency, "gold"), "id": 0, "count": amount}]
            
                if mode == "mail":
                    from .mail import send_system_mail
                    mid = await send_system_mail(session.server, session.char_id,
                                                 title, content, att, push=False)
                    return True, None, mid
                # direct
                gold, diamond = await db.grant_currency(session.char_id, currency, amount)
                return False, (gold if currency == 1 else diamond), None
            
            
            @dispatch(OP.PAY_PRODUCT_LIST_REQ)
            async def on_product_list(session, codec, body):
                products = []
                for pid, p in PAY_PRODUCTS.items():
                    products.append((pid, p.get("name", pid), int(p.get("price", 0)),
                                     int(p.get("currency", 1)), int(p.get("amount", 0))))
                await session.send_message(PayProductListRes(products))
            
            
            @dispatch(OP.PAY_REQ)
            async def on_pay(session, codec, body):
                if session.char_id is None:
                    await session.send_message(ErrorNtf(ERR.NEED_LOGIN, ERR_TEXT[ERR.NEED_LOGIN]))
                    return
                db = get_db()
                try:
                    req = PayReq.decode(body, codec)
                except Exception:
                    await session.send_message(ErrorNtf(ERR.BAD_PACKET, ERR_TEXT[ERR.BAD_PACKET]))
                    return
            
                product = PAY_PRODUCTS.get(req.product_id)
                if product is None:
                    log.warning("unknown product_id=%s", req.product_id)
                    await session.send_message(PayRes(ERR.PAY_PRODUCT_NOT_FOUND, req.product_id,
                                                      req.order_no))
                    return
            
                # 生成 / 复用订单号
                order_no = req.order_no or f"{session.uid}-{int(time.time())}-{uuid.uuid4().hex[:6]}"
            
                # 幂等:已处理过的订单直接返回成功(不重复发放)
                existing = await db.get_order(order_no)
                if existing:
                    log.info("duplicate order %s -> return success (idempotent)", order_no)
                    await session.send_message(PayRes(
                        ERR.OK, req.product_id, order_no,
                        int(existing["currency"]), int(existing["amount"]), False))
                    return
            
                currency = int(product.get("currency", 1))
                amount = int(product.get("amount", 0))
                price = int(product.get("price", 0))
            
                # === 自托管支付判定:点击购买直接成功 ===
                auto = bool(_cfg("game.pay_auto_success", True))
                if not auto:
                    # 若要接真实校验,在这里验证第三方回调凭证(本模板不带)
                    await session.send_message(PayRes(ERR.PAY_FAILED, req.product_id, order_no))
                    return
            
                # 记账 + 发放
                await db.create_order(session.uid, session.char_id, order_no,
                                      req.product_id, price, currency, amount)
                by_mail, balance, mail_mid = await _grant(db, session, currency, amount,
                                                          req.product_id)
            
                log.info("PAY OK uid=%d cid=%d product=%s order=%s +%s x%d by_mail=%s",
                         session.uid, session.char_id, req.product_id, order_no,
                         CURRENCY_NAME.get(currency), amount, by_mail)
            
                # 先回请求,再推邮件通知(保证客户端消息顺序正确)
                await session.send_message(PayRes(ERR.OK, req.product_id, order_no,
                                                  currency, amount, by_mail))
                if by_mail and mail_mid is not None:
                    from .mail import push_mail
                    await push_mail(session, mail_mid)
          • room.py 4.4 KB
            """app.logic.handlers.room —— 房间 / 匹配
            
            生命周期:创建 → 加入/退出 → 准备 → 房主开始 → 进入战斗 → 结算后解散
            每次变更都向房间内所有在线成员广播 ROOM_INFO_NTF。
            """
            from __future__ import annotations
            
            import logging
            import struct
            
            from ...net.dispatcher import dispatch
            from ...proto.opcodes import OP, ERR, ERR_TEXT
            from ...proto.messages import ErrorNtf
            from ..rooms import ROOMS, Member, Room
            
            log = logging.getLogger("gsrv.room")
            
            _ERR_MAP = {
                "room_not_found": ERR.ROOM_NOT_FOUND,
                "room_full": ERR.ROOM_FULL,
                "room_not_owner": ERR.ROOM_NOT_OWNER,
                "room_not_ready": ERR.ROOM_NOT_READY,
                "already_in_room": ERR.ALREADY_IN_ROOM,
            }
            
            
            async def _send(session, session_server, uid, opcode, payload):
                sess = session_server.by_uid.get(uid)
                if sess and sess.alive:
                    await sess.send(opcode, payload)
            
            
            async def broadcast_room(server, room: Room):
                """向房间内所有在线成员推送房间信息"""
                payload = encode_room_info(room)
                for uid in list(room.members.keys()):
                    await _send(None, server, uid, OP.ROOM_INFO_NTF, payload)
                log.debug("room %d broadcast members=%d", room.id, len(room.members))
            
            
            def encode_room_info(room: Room) -> bytes:
                out = struct.pack("<IBIBB", room.id, 1 if room.state == "BATTLE" else 0,
                                  room.owner_uid, room.max_size, len(room.members))
                for m in room.members.values():
                    nm = m.name.encode("utf-8")
                    out += (struct.pack("<Q", m.cid) + struct.pack("<I", m.uid)
                            + struct.pack("<HB", m.level, 1 if m.ready else 0)
                            + struct.pack("<H", len(nm)) + nm)
                return out
            
            
            def _member_of(session) -> Member:
                return Member(session.uid, session.char_id or 0,
                              session.username or f"P{session.uid}", level=1)
            
            
            @dispatch(OP.ROOM_CREATE_REQ)
            async def on_create(session, codec, body):
                if session.char_id is None:
                    await session.send_message(ErrorNtf(ERR.NEED_LOGIN, ERR_TEXT[ERR.NEED_LOGIN]))
                    return
                max_size = body[0] if body else 4
                try:
                    room = ROOMS.create(session.uid, session.char_id,
                                        session.username or f"P{session.uid}", 1, max_size)
                except ValueError as e:
                    code = _ERR_MAP.get(str(e), ERR.INTERNAL)
                    await session.send(OP.ROOM_CREATE_RES, struct.pack("<BI", code, 0))
                    return
                await session.send(OP.ROOM_CREATE_RES, struct.pack("<BI", ERR.OK, room.id))
                await broadcast_room(session.server, room)
            
            
            @dispatch(OP.ROOM_JOIN_REQ)
            async def on_join(session, codec, body):
                if session.char_id is None:
                    await session.send_message(ErrorNtf(ERR.NEED_LOGIN, ERR_TEXT[ERR.NEED_LOGIN]))
                    return
                (rid,) = struct.unpack_from("<I", body, 0) if len(body) >= 4 else (0,)
                try:
                    room = ROOMS.join(rid, session.uid, session.char_id,
                                      session.username or f"P{session.uid}", 1)
                except ValueError as e:
                    code = _ERR_MAP.get(str(e), ERR.INTERNAL)
                    await session.send(OP.ROOM_JOIN_RES, struct.pack("<BI", code, rid))
                    return
                await session.send(OP.ROOM_JOIN_RES, struct.pack("<BI", ERR.OK, room.id))
                await broadcast_room(session.server, room)
            
            
            @dispatch(OP.ROOM_LEAVE_REQ)
            async def on_leave(session, codec, body):
                room = ROOMS.leave(session.uid)
                rid = room.id if room else 0
                await session.send(OP.ROOM_LEAVE_RES, struct.pack("<BI", ERR.OK, rid))
                if room:
                    await broadcast_room(session.server, room)
            
            
            @dispatch(OP.ROOM_READY_REQ)
            async def on_ready(session, codec, body):
                ready = bool(body[0]) if body else True
                try:
                    room = ROOMS.set_ready(session.uid, ready)
                except ValueError as e:
                    await session.send(OP.ROOM_READY_RES,
                                       struct.pack("<B", _ERR_MAP.get(str(e), ERR.INTERNAL)))
                    return
                await session.send(OP.ROOM_READY_RES, struct.pack("<B", ERR.OK))
                await broadcast_room(session.server, room)
            
            
            @dispatch(OP.ROOM_START_REQ)
            async def on_start(session, codec, body):
                try:
                    room = ROOMS.start(session.uid)
                except ValueError as e:
                    code = _ERR_MAP.get(str(e), ERR.INTERNAL)
                    await session.send(OP.ROOM_START_RES, struct.pack("<B", code))
                    return
                await session.send(OP.ROOM_START_RES, struct.pack("<B", ERR.OK))
                await broadcast_room(session.server, room)
                # 开局即推送一次战斗状态
                from .battle import broadcast_state
                await broadcast_state(session.server, room)
          • scene.py 3 KB
            """app.logic.handlers.scene —— 进入场景 / 移动 / 玩家信息推送"""
            from __future__ import annotations
            
            import logging
            import struct
            
            from ...net.dispatcher import dispatch
            from ...proto.opcodes import OP, ERR, ERR_TEXT
            from ...proto.messages import ErrorNtf
            from ...store.db import get_db
            from ..state import STATE
            
            log = logging.getLogger("gsrv.scene")
            
            
            @dispatch(OP.ENTER_SCENE_REQ)
            async def on_enter_scene(session, codec, body):
                if session.char_id is None:
                    await session.send_message(ErrorNtf(ERR.NEED_LOGIN, ERR_TEXT[ERR.NEED_LOGIN]))
                    return
                db = get_db()
                row = await db.get_char(session.char_id)
                if row is None:
                    await session.send_message(ErrorNtf(ERR.NO_CHAR, ERR_TEXT[ERR.NO_CHAR]))
                    return
                scene = row["scene"]
                # 返回场景快照:scene + x + y + 同场景玩家数(占位协议)
                payload = struct.pack("<HIIH", scene, row["x"], row["y"],
                                      len(STATE.scenes.get(scene, set())))
                await session.send(OP.ENTER_SCENE_RES, payload)
                log.info("enter scene uid=%d cid=%d scene=%d", session.uid, session.char_id, scene)
            
            
            @dispatch(OP.MOVE_REQ)
            async def on_move(session, codec, body):
                if session.char_id is None:
                    return
                # 客户端上报:x(u32) y(u32) —— 服务端权威校验
                try:
                    x, y = struct.unpack_from("<II", body, 0)
                except struct.error:
                    return
            
                from ...config import get_config
                snap = STATE.players.get(session.char_id)
                if snap and get_config().get("game.server_authoritative", True):
                    # 速度校验:单帧位移不能超过 speed * 容差
                    speed = 5
                    max_step = speed * 4
                    dx = abs(int(x) - int(snap.get("x", x)))
                    dy = abs(int(y) - int(snap.get("y", y)))
                    if dx > max_step or dy > max_step:
                        log.warning("speedhack? cid=%d dx=%d dy=%d", session.char_id, dx, dy)
                        await session.send(OP.MOVE_NTF, struct.pack("<QII", session.char_id,
                                                                    snap["x"], snap["y"]))
                        return  # 拒绝非法位移,回推旧位置
            
                STATE.move(session.char_id, x=x, y=y)
                db = get_db()
                await db.update_char_state(session.char_id, x=x, y=y)
            
                # 广播给同场景其他玩家
                snap = STATE.players.get(session.char_id, {})
                scene = snap.get("scene", 1)
                pkt = struct.pack("<QII", session.char_id, x, y)
                cids = STATE.scenes.get(scene, set())
                for cid in list(cids):
                    other = STATE.players.get(cid)
                    if not other:
                        continue
                    s = session.server.by_uid.get(other.get("uid"))
                    if s and s is not session:
                        await s.send(OP.MOVE_NTF, pkt)
            
            
            @dispatch(OP.PLAYER_INFO_NTF)
            async def on_player_info(session, codec, body):
                # 客户端主动请求自身信息(占位)
                if session.char_id is None:
                    return
                snap = STATE.players.get(session.char_id, {})
                name = (snap.get("name") or "").encode("utf-8")
                payload = struct.pack("<QH", session.char_id, len(name)) + name
                await session.send(OP.PLAYER_INFO_NTF, payload)
          • __init__.py 313 B
            """handlers 包 —— 导入即注册"""
            from . import auth   # noqa: F401
            from . import char   # noqa: F401
            from . import scene  # noqa: F401
            from . import mail   # noqa: F401
            from . import pay    # noqa: F401
            from . import room   # noqa: F401
            from . import battle # noqa: F401
            from . import drop   # noqa: F401
        • bots.py 7.8 KB
          """app.logic.bots —— 服务端人机(假玩家 / Bot)——  后续拓展(可选骨架)
          
          注意: 本模块属「后续拓展」:**不做也不影响核心运行**。
             默认 auto_fill=0,不会生成任何假玩家。需要用配置手动生成。
          
          注意:注意: 这只是**通用骨架**,不是任何具体游戏的答案。 注意:注意:
          
          复刻真实人机必须:
            1. 先读 extensions/bot-reverse.md,从用户的客户端**反推**人机机制
               (找 IsAI/robot 字段、判归属模型、还原身份/标记/动作/流程消息)
            2. 把结论写进 protocol.spec.yaml 的 bots 段
            3. 再用本骨架去**实现那个游戏的**人机
          
          本文件提供的是可复用的结构(tick / 拟人化 / 广播),
          不代表目标游戏的人机逻辑。
          
          设计要点(详见 extensions/bots.md):
            * 逻辑 Bot:在服务端内部实例化,**复用同一套状态与广播**,不走网络编解码
            * 独立 tick:固定频率驱动,不挂在网络事件里
            * 拟人化:反应延迟 + 坐标抖动 + 失误率
            * 可通过配置增减、切换难度
            * Bot 的移动通过**与真人相同的 MOVE_NTF** 下发给客户端,所以客户端能"看到"它们
          
          用法:
              from app.logic.bots import MANAGER
              MANAGER.attach(server)
              MANAGER.spawn(scene=1, count=3, difficulty="normal")
          """
          from __future__ import annotations
          
          import asyncio
          import logging
          import random
          import time
          
          log = logging.getLogger("gsrv.bots")
          
          # 难度档位:反应延迟区间(秒)、失误率、漫游步长
          DIFFICULTY = {
              "easy":   {"reaction": (0.9, 1.8), "mistake": 0.30, "step": (1, 3)},
              "normal": {"reaction": (0.5, 1.1), "mistake": 0.12, "step": (2, 5)},
              "hard":   {"reaction": (0.25, 0.6), "mistake": 0.05, "step": (3, 7)},
          }
          
          # 随机名字词库(拟人化:有身份感)
          NAME_POOL = [
              "夜风", "青柠", "阿凯", "小满", "白露", "星屑", "迟夏", "雾都",
              "银烛", "南栀", "无铭", "北岛", "拾光", "浅川", "暮色", "橙子",
              "流浪猫", "半糖", "不眠", "远山", "折枝", "月见", "青鸟", "琥珀",
          ]
          
          # 地图边界(demo 用;真实项目按场景配置)
          MAP_W, MAP_H = 1000, 1000
          
          
          class Bot:
              def __init__(self, bot_id: int, scene: int, difficulty: str = "normal"):
                  cfg = DIFFICULTY.get(difficulty, DIFFICULTY["normal"])
                  self.bot_id = 0x40000000 + bot_id      # 用高位段与真人 cid 区分
                  self.name = random.choice(NAME_POOL) + str(random.randint(10, 99))
                  self.scene = scene
                  self.difficulty = difficulty
                  self.cfg = cfg
                  self.x = random.randint(0, MAP_W)
                  self.y = random.randint(0, MAP_H)
                  self.tx, self.ty = self.x, self.y          # 目标点
                  self.next_act = time.time() + random.uniform(*cfg["reaction"])
                  self.job = random.randint(0, 3)
                  self.level = random.randint(1, 30)
          
              def humanize(self):
                  """拟人化:下一次行动的间隔,带随机抖动"""
                  lo, hi = self.cfg["reaction"]
                  return random.uniform(lo, hi)
          
              def pick_target(self):
                  """选一个新目标点(漫游)"""
                  self.tx = max(0, min(MAP_W, self.x + random.randint(-120, 120)))
                  self.ty = max(0, min(MAP_H, self.y + random.randint(-120, 120)))
          
              def step_towards(self):
                  """向目标点走一步,带坐标抖动"""
                  lo, hi = self.cfg["step"]
                  step = random.randint(lo, hi)
                  dx, dy = self.tx - self.x, self.ty - self.y
                  dist = max(1, int((dx * dx + dy * dy) ** 0.5))
                  # 失误:有一定概率走反/走偏
                  if random.random() < self.cfg["mistake"]:
                      nx = self.x + random.randint(-step, step)
                      ny = self.y + random.randint(-step, step)
                  else:
                      nx = self.x + int(dx / dist * step)
                      ny = self.y + int(dy / dist * step)
                  self.x = max(0, min(MAP_W, nx))
                  self.y = max(0, min(MAP_H, ny))
                  if abs(self.x - self.tx) < 8 and abs(self.y - self.ty) < 8:
                      self.pick_target()
          
              def __repr__(self):
                  return f"<Bot {self.bot_id} {self.name} scene={self.scene} @({self.x},{self.y})>"
          
          
          class BotManager:
              def __init__(self):
                  self.bots: dict[int, Bot] = {}
                  self.server = None
                  self._seq = 0
                  self._task: asyncio.Task | None = None
                  self._running = False
                  self.tick_hz = 5          # tick 频率(每秒几次)
                  self.enabled = True
          
              # ---------- 生命周期 ----------
              def attach(self, server):
                  self.server = server
          
              async def start(self):
                  if self._running or self.server is None:
                      return
                  self._running = True
                  self._task = asyncio.create_task(self._tick_loop())
                  log.info("bot manager started (tick=%dHz)", self.tick_hz)
          
              async def stop(self):
                  self._running = False
                  if self._task:
                      self._task.cancel()
                  self.bots.clear()
          
              # ---------- 增删 ----------
              def spawn(self, scene: int = 1, count: int = 3, difficulty: str = "normal",
                        stagger: bool = True) -> list[Bot]:
                  """生成 Bot。stagger=True 时模拟"陆续加入",不瞬间满员。"""
                  created = []
                  for _ in range(count):
                      self._seq += 1
                      b = Bot(self._seq, scene, difficulty)
                      self.bots[b.bot_id] = b
                      created.append(b)
                      log.info("bot spawn %s", b)
                  return created
          
              def despawn(self, bot_id: int) -> bool:
                  b = self.bots.pop(bot_id, None)
                  if b:
                      log.info("bot despawn %s", b)
                      return True
                  return False
          
              def clear(self, scene: int | None = None) -> int:
                  if scene is None:
                      n = len(self.bots)
                      self.bots.clear()
                      return n
                  ids = [b.bot_id for b in self.bots.values() if b.scene == scene]
                  for i in ids:
                      self.bots.pop(i, None)
                  return len(ids)
          
              def set_difficulty(self, difficulty: str) -> int:
                  cfg = DIFFICULTY.get(difficulty)
                  if not cfg:
                      return 0
                  for b in self.bots.values():
                      b.difficulty = difficulty
                      b.cfg = cfg
                  return len(self.bots)
          
              def stats(self) -> dict:
                  per_scene: dict[int, int] = {}
                  for b in self.bots.values():
                      per_scene[b.scene] = per_scene.get(b.scene, 0) + 1
                  return {"total": len(self.bots), "per_scene": per_scene,
                          "running": self._running}
          
              # ---------- tick ----------
              async def _tick_loop(self):
                  interval = 1.0 / max(1, self.tick_hz)
                  while self._running:
                      try:
                          await self._tick()
                      except Exception:
                          log.exception("bot tick error")
                      await asyncio.sleep(interval)
          
              async def _tick(self):
                  if not self.enabled or not self.bots:
                      return
                  now = time.time()
                  import struct
                  from ..proto.opcodes import OP
                  for b in list(self.bots.values()):
                      if now < b.next_act:
                          continue
                      b.step_towards()
                      b.next_act = now + b.humanize()
                      # 与真人相同的下行消息:MOVE_NTF(cid, x, y)
                      pkt = struct.pack("<QII", b.bot_id, b.x, b.y)
                      await self._broadcast_to_scene(b.scene, OP.MOVE_NTF, pkt)
          
              async def _broadcast_to_scene(self, scene: int, opcode: int, payload: bytes):
                  """只发给该场景内的真人在线会话"""
                  server = self.server
                  if server is None:
                      return
                  from .state import STATE
                  cids = STATE.scenes.get(scene, set())
                  for cid in list(cids):
                      snap = STATE.players.get(cid)
                      if not snap:
                          continue
                      sess = server.by_uid.get(snap.get("uid"))
                      if sess and sess.alive:
                          await sess.send(opcode, payload)
          
          
          MANAGER = BotManager()
        • loot.py 3.4 KB
          """app.logic.loot —— 掉落 / 物资发放
          
          职责:
            * 掉落表(DROP_TABLES):按副本/场景 id 配置掉落
            * 服务端权威掷骰(roll_drops)
            * 入库(grant_items):背包满 → 自动转邮件,避免物资丢失
          
          掉落表结构:
            {table_id: {"name": str, "rolls": int,
                        "entries": [{"item_id": int, "count": [min,max], "rate": 0~1}]}}
          真机上把 DROP_TABLES 换成从客户端配置表反推出来的真实掉落表。
          """
          from __future__ import annotations
          
          import logging
          import random
          
          log = logging.getLogger("gsrv.loot")
          
          BAG_LIMIT_DEFAULT = 50
          
          # ---- 掉落表(示例,替换为真实配置)----
          DROP_TABLES: dict[int, dict] = {
              1: {   # 新手森林
                  "name": "新手森林",
                  "rolls": 2,
                  "entries": [
                      {"item_id": 1001, "count": [1, 3], "rate": 0.80},   # 普通素材
                      {"item_id": 1002, "count": [1, 1], "rate": 0.35},   # 稀有素材
                      {"item_id": 2001, "count": [1, 1], "rate": 0.10},   # 装备
                  ],
              },
              2: {   # 精英副本
                  "name": "精英副本",
                  "rolls": 3,
                  "entries": [
                      {"item_id": 1002, "count": [1, 2], "rate": 0.70},
                      {"item_id": 2001, "count": [1, 1], "rate": 0.30},
                      {"item_id": 3001, "count": [1, 1], "rate": 0.05},   # 稀有
                  ],
              },
          }
          
          
          def roll_drops(table_id: int, rolls: int | None = None) -> list[tuple[int, int]]:
              """服务端掷骰;返回 [(item_id, count)](同类合并)"""
              table = DROP_TABLES.get(table_id)
              if not table:
                  return []
              n = rolls if rolls is not None else table.get("rolls", 1)
              bucket: dict[int, int] = {}
              for _ in range(n):
                  for e in table.get("entries", []):
                      if random.random() <= float(e.get("rate", 0)):
                          lo, hi = e.get("count", [1, 1])
                          cnt = random.randint(int(lo), int(hi))
                          bucket[e["item_id"]] = bucket.get(e["item_id"], 0) + cnt
              out = sorted(bucket.items())
              log.info("drops rolled table=%s -> %s", table_id, out)
              return out
          
          
          async def grant_items(db, server, cid: int, items: list[tuple[int, int]]):
              """按背包容量入库;装不下的转邮件。
          
              返回 (added, mailed):added=[(item_id,count)] 已进背包;mailed=[(item_id,count)] 走邮件
              """
              if not items:
                  return [], []
              limit = int(await db.get_config("max_bag_slots", str(BAG_LIMIT_DEFAULT)))
              rows = await db.list_items(cid)
              used_ids = {r["item_id"] for r in rows}
              slots = len(used_ids)
          
              added: list[tuple[int, int]] = []
              mailed: list[tuple[int, int]] = []
              for item_id, count in items:
                  if item_id in used_ids:
                      await db.add_item(cid, item_id, count)
                      added.append((item_id, count))
                  elif slots < limit:
                      await db.add_item(cid, item_id, count)
                      used_ids.add(item_id)
                      slots += 1
                      added.append((item_id, count))
                  else:
                      mailed.append((item_id, count))
          
              if mailed:
                  from .handlers.mail import send_system_mail
                  atts = [{"type": "item", "id": i, "count": c} for i, c in mailed]
                  await send_system_mail(server, cid, "背包已满,物资已邮件补发",
                                         "以下物资因背包已满,改由邮件发放,请及时领取。", atts)
                  log.info("bag full -> %d kinds mailed cid=%d", len(mailed), cid)
          
              return added, mailed
        • rooms.py 7.4 KB
          """app.logic.rooms —— 房间 / 匹配 / 战斗会话(服务端权威)
          
          模型:
              RoomManager
                └ Room(id, scene, owner_uid, max_size, members, state, battle)
                      └ Battle(turn, boss_hp, players{cid:hp}, log, finished, win)
          
          战斗是"服务端权威"的简化回合制:每个回合所有成员各行动一次,
          结算 Boss 伤害;Boss 反击;Boss 死 → 胜利 → 掉落;全员死/超时 → 失败。
          真实游戏按自身协议替换 decide/resolve 即可。
          """
          from __future__ import annotations
          
          import logging
          import random
          import time
          
          log = logging.getLogger("gsrv.rooms")
          
          ROOM_MAX = 4
          BATTLE_MAX_TURN = 20
          
          
          class Member:
              __slots__ = ("uid", "cid", "name", "ready", "level", "acted")
          
              def __init__(self, uid: int, cid: int, name: str, level: int = 1):
                  self.uid = uid
                  self.cid = cid
                  self.name = name
                  self.level = level
                  self.ready = False
                  self.acted = False
          
          
          class Battle:
              def __init__(self, room: "Room"):
                  self.room = room
                  self.room_id = room.id
                  self.turn = 1
                  self.boss_max_hp = 500 + 250 * len(room.members)
                  self.boss_hp = self.boss_max_hp
                  self.players = {m.cid: 100 + 20 * m.level for m in room.members.values()}
                  self.levels = {m.cid: m.level for m in room.members.values()}
                  self.finished = False
                  self.win = False
                  self.started_at = time.time()
                  self.rng = random.Random()
          
              # ---- 玩家行动 ----
              def action(self, cid: int, act: int = 1) -> int:
                  """返回本次造成的伤害"""
                  if self.finished or cid not in self.players or self.players[cid] <= 0:
                      return 0
                  lv = self.levels.get(cid, 1)
                  base = 20 + lv * 6
                  dmg = base if act == 1 else base * 2          # act=2 视为技能
                  dmg = int(dmg * self.rng.uniform(0.85, 1.15))  # 伤害浮动
                  self.boss_hp = max(0, self.boss_hp - dmg)
                  return dmg
          
              def all_acted(self) -> bool:
                  return all(m.acted for m in self.room.members.values() if self.players.get(m.cid, 0) > 0)
          
              # ---- 回合结算 ----
              def end_turn(self) -> list[tuple[int, int]]:
                  """Boss 反击;返回 [(cid, 受伤)]"""
                  hits = []
                  alive = [cid for cid, hp in self.players.items() if hp > 0]
                  for cid in alive:
                      dmg = self.rng.randint(5, 15)
                      self.players[cid] = max(0, self.players[cid] - dmg)
                      hits.append((cid, dmg))
                  for m in self.room.members.values():
                      m.acted = False
                  self.turn += 1
                  if self.boss_hp <= 0:
                      self.finished, self.win = True, True
                  elif not any(hp > 0 for hp in self.players.values()):
                      self.finished, self.win = True, False
                  elif self.turn > BATTLE_MAX_TURN:
                      self.finished, self.win = True, False
                  return hits
          
          
          class Room:
              def __init__(self, rid: int, owner_uid: int, max_size: int = ROOM_MAX, scene: int = 1):
                  self.id = rid
                  self.owner_uid = owner_uid
                  self.max_size = max(1, min(max_size, ROOM_MAX))
                  self.scene = scene
                  self.members: dict[int, Member] = {}
                  self.state = "WAITING"          # WAITING | BATTLE | CLOSED
                  self.battle: Battle | None = None
                  self.created_at = time.time()
          
              # ---- 成员操作 ----
              def add(self, m: Member) -> bool:
                  if len(self.members) >= self.max_size or self.state != "WAITING":
                      return False
                  self.members[m.uid] = m
                  return True
          
              def remove(self, uid: int) -> bool:
                  ok = self.members.pop(uid, None) is not None
                  if self.owner_uid == uid and self.members:
                      self.owner_uid = next(iter(self.members))     # 移交房主
                  return ok
          
              def all_ready(self) -> bool:
                  # 房主默认视为已准备
                  return all(m.ready or m.uid == self.owner_uid for m in self.members.values())
          
              def snapshot(self) -> dict:
                  return {
                      "room_id": self.id, "state": self.state, "owner_uid": self.owner_uid,
                      "scene": self.scene, "max_size": self.max_size,
                      "members": [
                          {"uid": m.uid, "cid": m.cid, "name": m.name,
                           "ready": m.ready, "level": m.level}
                          for m in self.members.values()
                      ],
                  }
          
          
          class RoomManager:
              def __init__(self):
                  self.rooms: dict[int, Room] = {}
                  self.uid_room: dict[int, int] = {}     # uid -> room_id
                  self._seq = 0
          
              # ---------- 查询 ----------
              def get(self, rid: int) -> Room | None:
                  return self.rooms.get(rid)
          
              def room_of(self, uid: int) -> Room | None:
                  rid = self.uid_room.get(uid)
                  return self.rooms.get(rid) if rid else None
          
              # ---------- 生命周期 ----------
              def create(self, uid: int, cid: int, name: str, level: int = 1,
                         max_size: int = ROOM_MAX, scene: int = 1) -> Room:
                  if self.room_of(uid):
                      raise ValueError("already_in_room")
                  self._seq += 1
                  room = Room(self._seq, uid, max_size, scene)
                  room.add(Member(uid, cid, name, level))
                  self.rooms[room.id] = room
                  self.uid_room[uid] = room.id
                  log.info("room create id=%d owner=%d", room.id, uid)
                  return room
          
              def join(self, rid: int, uid: int, cid: int, name: str, level: int = 1) -> Room:
                  room = self.get(rid)
                  if room is None:
                      raise ValueError("room_not_found")
                  if self.room_of(uid):
                      raise ValueError("already_in_room")
                  if not room.add(Member(uid, cid, name, level)):
                      raise ValueError("room_full")
                  self.uid_room[uid] = rid
                  log.info("room join id=%d uid=%d (%d/%d)", rid, uid, len(room.members), room.max_size)
                  return room
          
              def leave(self, uid: int) -> Room | None:
                  room = self.room_of(uid)
                  if room is None:
                      return None
                  room.remove(uid)
                  self.uid_room.pop(uid, None)
                  if not room.members:
                      room.state = "CLOSED"
                      self.rooms.pop(room.id, None)
                      log.info("room closed id=%d (empty)", room.id)
                  log.info("room leave id=%d uid=%d", room.id, uid)
                  return room
          
              def set_ready(self, uid: int, ready: bool = True) -> Room:
                  room = self.room_of(uid)
                  if room is None:
                      raise ValueError("room_not_found")
                  if uid in room.members:
                      room.members[uid].ready = ready
                  return room
          
              def start(self, uid: int) -> Room:
                  room = self.room_of(uid)
                  if room is None:
                      raise ValueError("room_not_found")
                  if room.owner_uid != uid:
                      raise ValueError("room_not_owner")
                  if not room.all_ready():
                      raise ValueError("room_not_ready")
                  room.state = "BATTLE"
                  room.battle = Battle(room)
                  log.info("room start id=%d members=%d boss_hp=%d",
                           room.id, len(room.members), room.battle.boss_hp)
                  return room
          
              def finish(self, rid: int):
                  room = self.get(rid)
                  if not room:
                      return
                  for uid in list(room.members.keys()):
                      self.uid_room.pop(uid, None)
                  room.state = "CLOSED"
                  self.rooms.pop(rid, None)
                  log.info("room finish/closed id=%d", rid)
          
              def stats(self) -> dict:
                  return {
                      "rooms": len(self.rooms),
                      "in_battle": sum(1 for r in self.rooms.values() if r.state == "BATTLE"),
                      "players": len(self.uid_room),
                  }
          
          
          ROOMS = RoomManager()
        • security.py 2.1 KB
          """app.logic.security —— 密码哈希 / token 签发校验 / 频率限制"""
          from __future__ import annotations
          
          import hashlib
          import hmac
          import json
          import base64
          import time
          
          
          def hash_password(password: str, salt: str = "gsrv") -> str:
              """生产请换 bcrypt/argon2;此处用 PBKDF2 保证开箱即用"""
              dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 100_000)
              return dk.hex()
          
          
          def verify_password(password: str, stored: str, salt: str = "gsrv") -> bool:
              return hmac.compare_digest(hash_password(password, salt), stored)
          
          
          def _b64e(b: bytes) -> str:
              return base64.urlsafe_b64encode(b).decode().rstrip("=")
          
          
          def _b64d(s: str) -> bytes:
              s += "=" * (-len(s) % 4)
              return base64.urlsafe_b64decode(s)
          
          
          def sign_token(uid: int, secret: str, ttl: int = 86400) -> str:
              payload = {"uid": uid, "exp": int(time.time()) + ttl, "iat": int(time.time())}
              body = _b64e(json.dumps(payload, separators=(",", ":")).encode())
              sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest()
              return f"{body}.{sig}"
          
          
          def verify_token(token: str, secret: str) -> dict | None:
              try:
                  body, sig = token.split(".", 1)
              except ValueError:
                  return None
              expect = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest()
              if not hmac.compare_digest(sig, expect):
                  return None
              try:
                  payload = json.loads(_b64d(body))
              except Exception:
                  return None
              if payload.get("exp", 0) < time.time():
                  return None
              return payload
          
          
          class RateLimiter:
              """简单滑动窗口限流(单进程内存版)"""
          
              def __init__(self, limit: int = 10, window: float = 60.0):
                  self.limit = limit
                  self.window = window
                  self._hits: dict[str, list[float]] = {}
          
              def allow(self, key: str) -> bool:
                  now = time.time()
                  arr = [t for t in self._hits.get(key, []) if now - t < self.window]
                  if len(arr) >= self.limit:
                      self._hits[key] = arr
                      return False
                  arr.append(now)
                  self._hits[key] = arr
                  return True
        • state.py 1.1 KB
          """app.logic.state —— 全局游戏状态(在线玩家、场景)"""
          
          
          class GameState:
              def __init__(self):
                  # cid -> 玩家快照
                  self.players: dict[int, dict] = {}
                  # scene -> set(cid)
                  self.scenes: dict[int, set] = {}
          
              def enter(self, cid: int, snapshot: dict):
                  self.players[cid] = snapshot
                  scene = snapshot.get("scene", 1)
                  self.scenes.setdefault(scene, set()).add(cid)
          
              def leave(self, cid: int):
                  snap = self.players.pop(cid, None)
                  if snap:
                      self.scenes.get(snap.get("scene", 1), set()).discard(cid)
          
              def move(self, cid: int, scene: int | None = None, x=None, y=None):
                  snap = self.players.get(cid)
                  if not snap:
                      return
                  if scene is not None and scene != snap.get("scene"):
                      self.scenes.get(snap["scene"], set()).discard(cid)
                      snap["scene"] = scene
                      self.scenes.setdefault(scene, set()).add(cid)
                  if x is not None:
                      snap["x"] = x
                  if y is not None:
                      snap["y"] = y
          
          
          STATE = GameState()
        • __init__.py 15 B
          """logic 包"""
      • net
        • codec.py 6.5 KB
          """app.net.codec —— 帧编解码(对应协议第 [2][4][5] 层)
          
          链路(出站): payload -> [压缩] -> [加密] -> [长度头 + opcode] -> bytes
          链路(入站): bytes -> 拆长度/opcode -> [解密] -> [解压] -> payload
          """
          from __future__ import annotations
          
          import struct
          import zlib
          import gzip
          import json
          
          from .crypto import CryptoBase, build_crypto
          
          
          class CodecError(Exception):
              pass
          
          
          class Codec:
              def __init__(self, frame_cfg: dict, crypto_cfg: dict, compress_cfg: dict,
                           serialize_cfg: dict, max_frame: int = 1 << 20):
                  self.frame = frame_cfg
                  self.compress_cfg = compress_cfg
                  self.serialize_cfg = serialize_cfg
                  self.max_frame = max_frame
                  self.crypto: CryptoBase = build_crypto(crypto_cfg)
          
                  self.len_size = int(frame_cfg.get("length_size", 4))
                  self.len_endian = frame_cfg.get("length_endian", "big")
                  self.len_fmt = (">" if self.len_endian == "big" else "<") + {1: "B", 2: "H", 4: "I"}[self.len_size]
                  self.len_includes_self = bool(frame_cfg.get("length_includes_self", False))
                  self.op_size = int(frame_cfg.get("opcode_size", 1))
                  self.op_endian = frame_cfg.get("opcode_endian", "little")
                  self.op_fmt = ("<" if self.op_endian == "little" else ">") + {1: "B", 2: "H", 4: "I"}[max(self.op_size, 1)]
          
              # ---------- 压缩 ----------
              def _compress(self, data: bytes) -> bytes:
                  if not self.compress_cfg.get("enabled"):
                      return data
                  if len(data) < int(self.compress_cfg.get("min_size", 256)):
                      return data
                  algo = self.compress_cfg.get("algorithm", "zlib")
                  if algo == "zlib":
                      return zlib.compress(data)
                  if algo == "gzip":
                      return gzip.compress(data)
                  try:
                      import lz4.frame as lz4f
                      return lz4f.compress(data)
                  except ImportError:
                      return zlib.compress(data)
          
              def _decompress(self, data: bytes) -> bytes:
                  if not self.compress_cfg.get("enabled"):
                      return data
                  algo = self.compress_cfg.get("algorithm", "zlib")
                  if algo == "gzip" or data[:2] == b"\x1f\x8b":
                      return gzip.decompress(data)
                  if algo == "lz4":
                      try:
                          import lz4.frame as lz4f
                          return lz4f.decompress(data)
                      except Exception:
                          pass
                  try:
                      return zlib.decompress(data)
                  except zlib.error:
                      return data
          
              # ---------- 组帧 ----------
              def encode(self, opcode: int, payload: bytes) -> bytes:
                  body = self._compress(payload)
                  body = self.crypto.encrypt(body)
          
                  inner = b""
                  if self.op_size > 0:
                      inner += struct.pack(self.op_fmt, opcode)
                  inner += body
          
                  n = len(inner)
                  if self.len_includes_self:
                      n += self.len_size
                  header = struct.pack(self.len_fmt, n)
                  return header + inner
          
              def decode_head(self, head: bytes):
                  """返回 (frame_len, opcode, body_or_None)"""
                  if len(head) < self.len_size:
                      raise CodecError("head too short")
                  (n,) = struct.unpack(self.len_fmt, head[:self.len_size])
                  if self.len_includes_self:
                      n -= self.len_size
                  if n <= 0 or n > self.max_frame:
                      raise CodecError(f"bad frame len {n}")
                  rest = head[self.len_size:]
                  need = n - len(rest)  # 还需读取的 body 字节
                  return n, rest, need
          
              def parse(self, inner: bytes):
                  """inner = opcode + encrypted(compressed(payload))"""
                  if self.op_size > 0:
                      op_len = 1 if self.op_size <= 1 else self.op_size
                      opcode = struct.unpack(self.op_fmt, inner[:op_len])[0]
                      body = inner[op_len:]
                  else:
                      opcode = 0
                      body = inner
                  body = self.crypto.decrypt(body)
                  body = self._decompress(body)
                  return opcode, body
          
              # ---------- 序列化 ----------
              def serialize(self, obj) -> bytes:
                  fmt = self.serialize_cfg.get("format", "binary")
                  if fmt == "json":
                      return json.dumps(obj, ensure_ascii=False).encode("utf-8")
                  if fmt == "protobuf":
                      # 使用已编译的 pb 类:obj.SerializeToString()
                      return obj.SerializeToString()
                  # binary:按 fields 定义打包 dict
                  fields = self.serialize_cfg.get("fields", [])
                  if not fields:
                      if isinstance(obj, (bytes, bytearray)):
                          return bytes(obj)
                      return b""
                  parts = []
                  for fdef in fields:
                      name = fdef["name"]
                      parts.append(_pack_field(fdef, obj.get(name)))
                  return b"".join(parts)
          
              def deserialize(self, data: bytes) -> dict:
                  fmt = self.serialize_cfg.get("format", "binary")
                  if fmt == "json":
                      return json.loads(data.decode("utf-8"))
                  fields = self.serialize_cfg.get("fields", [])
                  out = {}
                  off = 0
                  for fdef in fields:
                      val, off = _unpack_field(fdef, data, off)
                      out[fdef["name"]] = val
                  return out
          
          
          _TYPE_MAP = {
              "u8": ("B", 1), "i8": ("b", 1),
              "u16": ("H", 2), "i16": ("h", 2),
              "u32": ("I", 4), "i32": ("i", 4),
              "u64": ("Q", 8), "i64": ("q", 8),
              "f32": ("f", 4), "f64": ("d", 8),
          }
          
          
          def _pack_field(fdef: dict, val) -> bytes:
              t = fdef.get("type", "u8")
              if t in _TYPE_MAP:
                  fmt, _ = _TYPE_MAP[t]
                  return struct.pack("<" + fmt, val if val is not None else 0)
              if t == "bytes":
                  n = int(fdef.get("size", 0))
                  b = bytes(val or b"")
                  return b[:n].ljust(n, b"\x00")
              if t == "str":
                  n = int(fdef.get("size", 0))
                  raw = str(val or "").encode("utf-8")
                  if n:
                      return raw[:n].ljust(n, b"\x00")
                  return struct.pack("<I", len(raw)) + raw  # 长度前缀字符串
              raise ValueError(f"未知字段类型 {t}")
          
          
          def _unpack_field(fdef: dict, data: bytes, off: int):
              t = fdef.get("type", "u8")
              if t in _TYPE_MAP:
                  fmt, sz = _TYPE_MAP[t]
                  val = struct.unpack_from("<" + fmt, data, off)[0]
                  return val, off + sz
              if t == "bytes":
                  n = int(fdef.get("size", 0))
                  return data[off:off + n], off + n
              if t == "str":
                  n = int(fdef.get("size", 0))
                  if n:
                      raw = data[off:off + n].split(b"\x00", 1)[0]
                      return raw.decode("utf-8", "replace"), off + n
                  (ln,) = struct.unpack_from("<I", data, off)
                  off += 4
                  raw = data[off:off + ln]
                  return raw.decode("utf-8", "replace"), off + ln
              raise ValueError(f"未知字段类型 {t}")
        • crypto.py 3.9 KB
          """app.net.crypto —— 可插拔加解密层(对应协议第 [3] 层)
          
          从客户端逆向出的算法在这里 1:1 复现。
          支持:xor(单字节/多字节循环)、rc4、aes(cbc/ecb)。
          密钥来源:config.crypto.key 或握手协商(per_connection)。
          """
          from __future__ import annotations
          
          import struct
          
          
          def _key_bytes(key) -> bytes:
              if isinstance(key, bytes):
                  return key
              if isinstance(key, str):
                  s = key.strip()
                  if s.startswith("0x") or s.startswith("0X"):
                      return bytes.fromhex(s[2:])
                  # 尝试纯 hex
                  try:
                      return bytes.fromhex(s)
                  except ValueError:
                      return s.encode("utf-8")
              return bytes(key)
          
          
          class CryptoBase:
              def encrypt(self, data: bytes) -> bytes:
                  return data
          
              def decrypt(self, data: bytes) -> bytes:
                  return data
          
          
          class XorCrypto(CryptoBase):
              """循环 XOR —— 游戏里最常见的轻量混淆"""
          
              def __init__(self, key):
                  self.key = _key_bytes(key) or b"\x00"
          
              def _x(self, data: bytes) -> bytes:
                  k = self.key
                  n = len(k)
                  return bytes(b ^ k[i % n] for i, b in enumerate(data))
          
              def encrypt(self, data: bytes) -> bytes:
                  return self._x(data)
          
              def decrypt(self, data: bytes) -> bytes:
                  return self._x(data)
          
          
          class Rc4Crypto(CryptoBase):
              def __init__(self, key):
                  self.key = _key_bytes(key) or b"\x00"
          
              def _ksa(self):
                  key = self.key
                  S = list(range(256))
                  j = 0
                  for i in range(256):
                      j = (j + S[i] + key[i % len(key)]) & 0xFF
                      S[i], S[j] = S[j], S[i]
                  return S
          
              def _crypt(self, data: bytes) -> bytes:
                  S = self._ksa()
                  i = j = 0
                  out = bytearray()
                  for b in data:
                      i = (i + 1) & 0xFF
                      j = (j + S[i]) & 0xFF
                      S[i], S[j] = S[j], S[i]
                      out.append(b ^ S[(S[i] + S[j]) & 0xFF])
                  return bytes(out)
          
              def encrypt(self, data: bytes) -> bytes:
                  return self._crypt(data)
          
              def decrypt(self, data: bytes) -> bytes:
                  return self._crypt(data)
          
          
          class AesCrypto(CryptoBase):
              """AES-CBC/ECB(需要 cryptography 包)"""
          
              def __init__(self, key, iv=b"\x00" * 16, mode="cbc"):
                  try:
                      from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
                  except ImportError as e:  # noqa
                      raise RuntimeError("AES 需要安装 cryptography: pip install cryptography") from e
                  self._key = _key_bytes(key)
                  self._iv = iv if isinstance(iv, bytes) else _key_bytes(iv)
                  self._mode = mode
                  if mode == "cbc":
                      self._cipher = Cipher(algorithms.AES(self._key), modes.CBC(self._iv))
                  else:
                      self._cipher = Cipher(algorithms.AES(self._key), modes.ECB())
          
              def encrypt(self, data: bytes) -> bytes:
                  from cryptography.hazmat.primitives.padding import PKCS7
                  pad = PKCS7(128).padder()
                  data = pad.update(data) + pad.finalize()
                  enc = self._cipher.encryptor()
                  return enc.update(data) + enc.finalize()
          
              def decrypt(self, data: bytes) -> bytes:
                  from cryptography.hazmat.primitives.padding import PKCS7
                  dec = self._cipher.decryptor()
                  out = dec.update(data) + dec.finalize()
                  unpad = PKCS7(128).unpadder()
                  return unpad.update(out) + unpad.finalize()
          
          
          def build_crypto(cfg: dict, key_override=None) -> CryptoBase:
              """根据配置构建加解密器;key_override 用于 per_connection 协商"""
              if not cfg.get("enabled"):
                  return CryptoBase()
              algo = str(cfg.get("algorithm", "xor")).lower()
              key = key_override if key_override is not None else cfg.get("key", "")
              if algo == "xor":
                  return XorCrypto(key)
              if algo == "rc4":
                  return Rc4Crypto(key)
              if algo == "aes":
                  return AesCrypto(key)
              raise ValueError(f"未知加密算法: {algo}")
        • dispatcher.py 1.5 KB
          """app.net.dispatcher —— opcode 路由
          
          用法:
              @dispatch(OP.LOGIN_REQ)
              async def on_login(session, codec, body): ...
          """
          from __future__ import annotations
          
          import logging
          import inspect
          from typing import Awaitable, Callable
          
          log = logging.getLogger("gsrv.dispatch")
          
          Handler = Callable[..., Awaitable[None]]
          
          
          class Dispatcher:
              def __init__(self):
                  self._handlers: dict[int, Handler] = {}
          
              def register(self, opcode: int, fn: Handler):
                  self._handlers[opcode] = fn
          
              def get(self, opcode: int):
                  return self._handlers.get(opcode)
          
              async def dispatch(self, session, codec, opcode: int, body: bytes):
                  fn = self._handlers.get(opcode)
                  if fn is None:
                      log.warning("no handler for opcode=0x%04X len=%d", opcode, len(body))
                      await session.send(opcode, b"")  # 回空包,避免客户端卡住
                      return
                  try:
                      res = fn(session, codec, body)
                      if inspect.isawaitable(res):
                          await res
                  except Exception:
                      log.exception("handler error opcode=0x%04X", opcode)
                      await session.close("handler_error")
          
          
          DISPATCH = Dispatcher()
          
          
          def dispatch(opcode: int):
              """装饰器注册"""
              def deco(fn: Handler):
                  DISPATCH.register(opcode, fn)
                  return fn
              return deco
          
          
          def load_handlers():
              """导入 handlers 包,触发装饰器注册"""
              from ..logic import handlers  # noqa: F401
              log.info("handlers loaded: %d opcodes", len(DISPATCH._handlers))
        • server.py 5.9 KB
          """app.net.server —— asyncio TCP 服务器主循环
          
          负责:接受连接 → 逐帧读取 → 解码 → 分发 → 广播 → 心跳清理 → 连接数限制
          """
          from __future__ import annotations
          
          import asyncio
          import logging
          import time
          
          from .codec import Codec
          from .dispatcher import DISPATCH, load_handlers
          from .session import Session
          from ..logic.bots import MANAGER as BOT_MANAGER
          
          log = logging.getLogger("gsrv.server")
          
          
          class GameServer:
              def __init__(self, cfg):
                  self.cfg = cfg
                  self.sessions: dict[str, Session] = {}
                  self.by_uid: dict[int, Session] = {}
                  self._ip_count: dict[str, int] = {}
                  self.server: asyncio.AbstractServer | None = None
                  self._heartbeat_task: asyncio.Task | None = None
                  self._running = False
          
              # ---------- 生命周期 ----------
              def build_codec(self) -> Codec:
                  return Codec(
                      frame_cfg=self.cfg.get("frame", {}),
                      crypto_cfg=self.cfg.get("crypto", {}),
                      compress_cfg=self.cfg.get("compress", {}),
                      serialize_cfg=self.cfg.get("serialize", {}),
                      max_frame=int(self.cfg.get("server.max_frame", 1 << 20)),
                  )
          
              async def start(self):
                  load_handlers()
                  host = self.cfg.get("server.host", "0.0.0.0")
                  port = int(self.cfg.get("server.port", 8888))
                  self.server = await asyncio.start_server(
                      self._on_client, host, port, backlog=int(self.cfg.get("server.backlog", 128))
                  )
                  self._running = True
                  self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
                  # ---- 人机(假玩家 / Bot)----
                  if self.cfg.get("bots.enabled", True):
                      BOT_MANAGER.tick_hz = int(self.cfg.get("bots.tick_hz", 5))
                      BOT_MANAGER.attach(self)
                      await BOT_MANAGER.start()
                      auto = int(self.cfg.get("bots.auto_fill", 0))
                      if auto > 0:
                          BOT_MANAGER.spawn(
                              scene=int(self.cfg.get("bots.default_scene", 1)),
                              count=auto,
                              difficulty=self.cfg.get("bots.difficulty", "normal"))
                          log.info("auto-filled %d bots", auto)
                  addrs = ", ".join(str(s.getsockname()) for s in self.server.sockets)
                  log.info("[%s] listening on %s", self.cfg.get("game.name"), addrs)
          
              async def stop(self):
                  self._running = False
                  try:
                      await BOT_MANAGER.stop()
                  except Exception:
                      pass
                  if self._heartbeat_task:
                      self._heartbeat_task.cancel()
                  for s in list(self.sessions.values()):
                      await s.close("server_stop")
                  if self.server:
                      self.server.close()
                      await self.server.wait_closed()
                  log.info("server stopped")
          
              # ---------- 连接处理 ----------
              async def _on_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
                  peer = writer.get_extra_info("peername") or ("?", 0)
                  ip = peer[0] if isinstance(peer, tuple) else str(peer)
          
                  # 连接数限制
                  max_conn = int(self.cfg.get("server.max_connections", 5000))
                  if len(self.sessions) >= max_conn:
                      log.warning("max connections reached, reject %s", ip)
                      writer.close()
                      return
                  per_ip = int(self.cfg.get("security.max_conn_per_ip", 32))
                  if self._ip_count.get(ip, 0) >= per_ip:
                      log.warning("too many conn from %s", ip)
                      writer.close()
                      return
          
                  codec = self.build_codec()
                  sess = Session(reader, writer, codec, peer, self)
                  self.sessions[sess.id] = sess
                  self._ip_count[ip] = self._ip_count.get(ip, 0) + 1
                  log.info("+ connect %s (total=%d)", ip, len(self.sessions))
          
                  try:
                      await self._read_loop(sess)
                  except asyncio.IncompleteReadError:
                      pass
                  except (ConnectionResetError, BrokenPipeError):
                      pass
                  except Exception:
                      log.exception("client loop error %s", peer)
                  finally:
                      await sess.close("eof")
                      self._ip_count[ip] = max(0, self._ip_count.get(ip, 1) - 1)
          
              async def _read_loop(self, sess: Session):
                  codec = sess.codec
                  reader = sess.reader
                  ls = codec.len_size
                  while sess.alive and self._running:
                      head = await reader.readexactly(ls)
                      frame_len, rest, need = codec.decode_head(head)
                      inner = rest
                      while need > 0:
                          chunk = await reader.readexactly(need)
                          inner += chunk
                          need -= len(chunk)
                      sess.touch()
                      opcode, body = codec.parse(inner)
                      log.debug("< %s op=0x%04X len=%d", sess.id, opcode, len(body))
                      await DISPATCH.dispatch(sess, codec, opcode, body)
          
              # ---------- 会话回收 ----------
              async def on_session_closed(self, sess: Session):
                  self.sessions.pop(sess.id, None)
                  if sess.uid is not None and self.by_uid.get(sess.uid) is sess:
                      self.by_uid.pop(sess.uid, None)
          
              def bind_uid(self, sess: Session, uid: int):
                  """登录成功后绑定 uid(顶号处理)"""
                  old = self.by_uid.get(uid)
                  if old and old is not sess:
                      asyncio.create_task(old.close("kicked"))
                  self.by_uid[uid] = sess
                  sess.uid = uid
          
              # ---------- 广播 ----------
              async def broadcast(self, opcode: int, payload: bytes, predicate=None):
                  for s in list(self.sessions.values()):
                      if predicate is None or predicate(s):
                          await s.send(opcode, payload)
          
              # ---------- 心跳清理 ----------
              async def _heartbeat_loop(self):
                  idle = float(self.cfg.get("server.idle_timeout", 30))
                  while self._running:
                      await asyncio.sleep(max(idle / 2, 1))
                      now = time.time()
                      for s in list(self.sessions.values()):
                          if now - s.last_active > idle * 4:
                              log.info("idle timeout %s", s.id)
                              await s.close("idle_timeout")
        • session.py 2.2 KB
          """app.net.session —— 连接会话(状态 + 信封发送 + 心跳)"""
          from __future__ import annotations
          
          import asyncio
          import logging
          import time
          import uuid
          
          from .codec import Codec
          
          log = logging.getLogger("gsrv.session")
          
          
          class Session:
              def __init__(self, reader, writer, codec: Codec, peer, server):
                  self.id = uuid.uuid4().hex[:12]
                  self.reader = reader
                  self.writer = writer
                  self.codec = codec
                  self.peer = peer
                  self.server = server
                  self.alive = True
                  self.last_active = time.time()
                  self.closed = False
          
                  # —— 业务状态(登录/选角后填充)——
                  self.uid: int | None = None          # 账号 id
                  self.username: str | None = None
                  self.char_id: int | None = None      # 当前角色
                  self.state = "CONNECTED"             # 状态机:CONNECTED→HANDSHAKED→LOGGED_IN→IN_GAME
                  self.token: str | None = None
                  self.attrs: dict = {}                # 临时属性袋
          
              async def send(self, opcode: int, payload: bytes = b""):
                  if self.closed:
                      return
                  data = self.codec.encode(opcode, payload)
                  try:
                      self.writer.write(data)
                      await self.writer.drain()
                  except (ConnectionError, RuntimeError):
                      await self.close("send_failed")
          
              async def send_json(self, opcode: int, obj: dict):
                  await self.send(opcode, self.codec.serialize(obj))
          
              async def send_message(self, msg) -> None:
                  """自动从消息对象取 opcode 与 payload"""
                  opcode = getattr(msg, "OPCODE", 0)
                  payload = msg.encode(self.codec)
                  await self.send(opcode, payload)
          
              def touch(self):
                  self.last_active = time.time()
          
              async def close(self, reason: str = ""):
                  if self.closed:
                      return
                  self.closed = True
                  self.alive = False
                  try:
                      self.writer.close()
                      await self.writer.wait_closed()
                  except Exception:
                      pass
                  if reason:
                      log.info("session %s closed: %s", self.id, reason)
                  await self.server.on_session_closed(self)
          
              def __repr__(self):
                  return f"<Session {self.id} {self.peer} state={self.state} uid={self.uid}>"
        • __init__.py 14 B
          """net 包"""
          
      • proto
        • messages.py 9.8 KB
          """app.proto.messages —— 消息结构
          
          两种用法:
            1) binary:用 codec 的 fields 配置自动打包(简单场景)
            2) 结构化:每个消息实现 encode(codec)/decode(body, codec)(复杂场景,推荐)
          """
          from __future__ import annotations
          
          import struct
          
          
          class Message:
              OPCODE = 0
          
              def encode(self, codec) -> bytes:
                  return b""
          
              @classmethod
              def decode(cls, body: bytes, codec):
                  return cls()
          
          
          # ---------- 握手 ----------
          class HandshakeReq(Message):
              OPCODE = 0x0001
          
              def __init__(self, version: str = "", nonce: int = 0):
                  self.version = version
                  self.nonce = nonce
          
              def encode(self, codec):
                  v = self.version.encode("utf-8")
                  return struct.pack("<H", len(v)) + v + struct.pack("<I", self.nonce)
          
              @classmethod
              def decode(cls, body, codec):
                  (n,) = struct.unpack_from("<H", body, 0)
                  ver = body[2:2 + n].decode("utf-8", "replace")
                  (nonce,) = struct.unpack_from("<I", body, 2 + n)
                  return cls(ver, nonce)
          
          
          class HandshakeRes(Message):
              OPCODE = 0x0002
          
              def __init__(self, code: int = 0, server_ver: str = "1.0.0", session_key: bytes = b""):
                  self.code = code
                  self.server_ver = server_ver
                  self.session_key = session_key
          
              def encode(self, codec):
                  v = self.server_ver.encode("utf-8")
                  return (struct.pack("<B", self.code) + struct.pack("<H", len(v)) + v
                          + struct.pack("<H", len(self.session_key)) + self.session_key)
          
          
          # ---------- 登录 ----------
          class LoginReq(Message):
              OPCODE = 0x0101
          
              def __init__(self, username: str = "", password: str = ""):
                  self.username = username
                  self.password = password
          
              def encode(self, codec):
                  u = self.username.encode("utf-8")
                  p = self.password.encode("utf-8")
                  return struct.pack("<H", len(u)) + u + struct.pack("<H", len(p)) + p
          
              @classmethod
              def decode(cls, body, codec):
                  (n,) = struct.unpack_from("<H", body, 0)
                  u = body[2:2 + n].decode("utf-8", "replace")
                  (m,) = struct.unpack_from("<H", body, 2 + n)
                  p = body[4 + n:4 + n + m].decode("utf-8", "replace")
                  return cls(u, p)
          
          
          class LoginRes(Message):
              OPCODE = 0x0102
          
              def __init__(self, code: int = 0, token: str = "", uid: int = 0):
                  self.code = code
                  self.token = token
                  self.uid = uid
          
              def encode(self, codec):
                  t = self.token.encode("utf-8")
                  return (struct.pack("<B", self.code) + struct.pack("<Q", self.uid)
                          + struct.pack("<H", len(t)) + t)
          
          
          # ---------- 角色列表 ----------
          class CharInfo:
              def __init__(self, cid: int, name: str, level: int = 1, job: int = 0):
                  self.cid = cid
                  self.name = name
                  self.level = level
                  self.job = job
          
              def encode(self) -> bytes:
                  n = self.name.encode("utf-8")
                  return (struct.pack("<Q", self.cid) + struct.pack("<H", len(n)) + n
                          + struct.pack("<HB", self.level, self.job))
          
          
          class CharListRes(Message):
              OPCODE = 0x0202
          
              def __init__(self, chars: list[CharInfo] | None = None):
                  self.chars = chars or []
          
              def encode(self, codec):
                  out = struct.pack("<H", len(self.chars))
                  for c in self.chars:
                      out += c.encode()
                  return out
          
          
          class CharCreateReq(Message):
              OPCODE = 0x0203
          
              def __init__(self, name: str = "", job: int = 0):
                  self.name = name
                  self.job = job
          
              def encode(self, codec):
                  n = self.name.encode("utf-8")
                  return struct.pack("<H", len(n)) + n + struct.pack("<B", self.job)
          
              @classmethod
              def decode(cls, body, codec):
                  (n,) = struct.unpack_from("<H", body, 0)
                  name = body[2:2 + n].decode("utf-8", "replace")
                  (job,) = struct.unpack_from("<B", body, 2 + n)
                  return cls(name, job)
          
          
          class CharCreateRes(Message):
              OPCODE = 0x0204
          
              def __init__(self, code: int = 0, char: CharInfo | None = None):
                  self.code = code
                  self.char = char
          
              def encode(self, codec):
                  out = struct.pack("<B", self.code)
                  if self.char:
                      out += self.char.encode()
                  return out
          
          
          class CharSelectReq(Message):
              OPCODE = 0x0205
          
              def __init__(self, cid: int = 0):
                  self.cid = cid
          
              def encode(self, codec):
                  return struct.pack("<Q", self.cid)
          
              @classmethod
              def decode(cls, body, codec):
                  (cid,) = struct.unpack_from("<Q", body, 0)
                  return cls(cid)
          
          
          class CharSelectRes(Message):
              OPCODE = 0x0206
          
              def __init__(self, code: int = 0, cid: int = 0, scene: int = 1):
                  self.code = code
                  self.cid = cid
                  self.scene = scene
          
              def encode(self, codec):
                  return struct.pack("<BQH", self.code, self.cid, self.scene)
          
          
          # ---------- 错误 ----------
          class ErrorNtf(Message):
              OPCODE = 0x7F01
          
              def __init__(self, code: int = 99, text: str = ""):
                  self.code = code
                  self.text = text
          
              def encode(self, codec):
                  t = self.text.encode("utf-8")
                  return struct.pack("<BH", self.code, len(t)) + t
          
          
          # ---------- 邮件 ----------
          class MailInfo:
              """邮件条目。attachments 形如 [{"type":"gold","id":0,"count":1000}]"""
              def __init__(self, mid: int, title: str, content: str, attachments=None,
                           claimed: bool = False, read: bool = False, ts: int = 0):
                  self.mid = mid
                  self.title = title
                  self.content = content
                  self.attachments = attachments or []
                  self.claimed = claimed
                  self.read = read
                  self.ts = ts
          
              def _att_blob(self) -> bytes:
                  # type(1B) id(4B) count(4B) 逐条
                  out = struct.pack("<H", len(self.attachments))
                  for a in self.attachments:
                      t = {"gold": 1, "diamond": 2, "item": 3}.get(a.get("type"), 0)
                      out += struct.pack("<BI I", t & 0xFF, int(a.get("id", 0)),
                                         int(a.get("count", 0)))
                  return out
          
              def encode(self) -> bytes:
                  t = self.title.encode("utf-8")
                  c = self.content.encode("utf-8")
                  return (struct.pack("<Q", self.mid)
                          + struct.pack("<B", (1 if self.claimed else 0) | (2 if self.read else 0))
                          + struct.pack("<I", self.ts)
                          + struct.pack("<H", len(t)) + t
                          + struct.pack("<H", len(c)) + c
                          + self._att_blob())
          
          
          class MailListRes(Message):
              OPCODE = 0x0402
          
              def __init__(self, mails: list[MailInfo] | None = None):
                  self.mails = mails or []
          
              def encode(self, codec):
                  out = struct.pack("<H", len(self.mails))
                  for m in self.mails:
                      out += m.encode()
                  return out
          
          
          class MailOpReq(Message):
              """通用邮件操作请求:mid(u64)"""
              def __init__(self, mid: int = 0):
                  self.mid = mid
          
              def encode(self, codec):
                  return struct.pack("<Q", self.mid)
          
              @classmethod
              def decode(cls, body, codec):
                  (mid,) = struct.unpack_from("<Q", body, 0)
                  return cls(mid)
          
          
          class MailOpRes(Message):
              def __init__(self, code: int = 0, mid: int = 0):
                  self.code = code
                  self.mid = mid
          
              def encode(self, codec):
                  return struct.pack("<BQ", self.code, self.mid)
          
          
          class MailNewNtf(Message):
              OPCODE = 0x0407
          
              def __init__(self, mail: MailInfo | None = None):
                  self.mail = mail
          
              def encode(self, codec):
                  return self.mail.encode() if self.mail else b""
          
          
          # ---------- 充值 / 支付 ----------
          class PayReq(Message):
              """客户端发起购买。product_id 来自客户端商品表。"""
              OPCODE = 0x0503
          
              def __init__(self, product_id: str = "", order_no: str = "", pay_type: int = 0):
                  self.product_id = product_id
                  self.order_no = order_no
                  self.pay_type = pay_type
          
              def encode(self, codec):
                  p = self.product_id.encode("utf-8")
                  o = self.order_no.encode("utf-8")
                  return (struct.pack("<H", len(p)) + p
                          + struct.pack("<H", len(o)) + o
                          + struct.pack("<B", self.pay_type))
          
              @classmethod
              def decode(cls, body, codec):
                  (n,) = struct.unpack_from("<H", body, 0)
                  pid = body[2:2 + n].decode("utf-8", "replace")
                  (m,) = struct.unpack_from("<H", body, 2 + n)
                  order = body[4 + n:4 + n + m].decode("utf-8", "replace")
                  (pt,) = struct.unpack_from("<B", body, 4 + n + m)
                  return cls(pid, order, pt)
          
          
          class PayRes(Message):
              OPCODE = 0x0504
          
              def __init__(self, code: int = 0, product_id: str = "", order_no: str = "",
                           currency: int = 0, granted: int = 0, by_mail: bool = False):
                  self.code = code
                  self.product_id = product_id
                  self.order_no = order_no
                  self.currency = currency       # 1=gold 2=diamond
                  self.granted = granted         # 实际发放数量
                  self.by_mail = by_mail         # 走邮件发放
          
              def encode(self, codec):
                  p = self.product_id.encode("utf-8")
                  o = self.order_no.encode("utf-8")
                  return (struct.pack("<B", self.code)
                          + struct.pack("<B", self.currency)
                          + struct.pack("<I", self.granted)
                          + struct.pack("<B", 1 if self.by_mail else 0)
                          + struct.pack("<H", len(p)) + p
                          + struct.pack("<H", len(o)) + o)
          
          
          class PayProductListRes(Message):
              OPCODE = 0x0502
          
              def __init__(self, products: list[tuple] | None = None):
                  # (product_id, name, price_cents, currency, amount)
                  self.products = products or []
          
              def encode(self, codec):
                  out = struct.pack("<H", len(self.products))
                  for pid, name, price, cur, amt in self.products:
                      p = pid.encode("utf-8")
                      n = name.encode("utf-8")
                      out += (struct.pack("<H", len(p)) + p
                              + struct.pack("<H", len(n)) + n
                              + struct.pack("<I", price)
                              + struct.pack("<BI", cur, amt))
                  return out
        • opcodes.py 3.6 KB
          """app.proto.opcodes —— 消息号表
          
          这里是"反推出来的协议地图"的落点。
          从 dump.cs 的 switch(msgId) / Lua 的 cmd 常量 / UE 的 packet id 提取后填这里。
          命名约定:<模块>_<方向>,REQ=客户端上行,RES=服务端下行,NTF=服务端推送。
          """
          from __future__ import annotations
          
          from enum import IntEnum
          
          
          class OP(IntEnum):
              # ---- 连接 / 握手 ----
              HANDSHAKE_REQ = 0x0001
              HANDSHAKE_RES = 0x0002
              HEARTBEAT_REQ = 0x0003
              HEARTBEAT_RES = 0x0004
              KICK_NTF      = 0x0005
          
              # ---- 账号 ----
              LOGIN_REQ     = 0x0101
              LOGIN_RES     = 0x0102
              REGISTER_REQ  = 0x0103
              REGISTER_RES  = 0x0104
          
              # ---- 角色 ----
              CHAR_LIST_REQ = 0x0201
              CHAR_LIST_RES = 0x0202
              CHAR_CREATE_REQ = 0x0203
              CHAR_CREATE_RES = 0x0204
              CHAR_SELECT_REQ = 0x0205
              CHAR_SELECT_RES = 0x0206
              CHAR_DELETE_REQ = 0x0207
          
              # ---- 场景 / 进入游戏 ----
              ENTER_SCENE_REQ = 0x0301
              ENTER_SCENE_RES = 0x0302
              MOVE_REQ      = 0x0303
              MOVE_NTF      = 0x0304
              PLAYER_INFO_NTF = 0x0305
          
              # ---- 邮件 ----
              MAIL_LIST_REQ   = 0x0401
              MAIL_LIST_RES   = 0x0402
              MAIL_READ_REQ   = 0x0403
              MAIL_READ_RES   = 0x0404
              MAIL_CLAIM_REQ  = 0x0405
              MAIL_CLAIM_RES  = 0x0406
              MAIL_NEW_NTF    = 0x0407
              MAIL_DELETE_REQ = 0x0408
          
              # ---- 充值 / 支付 ----
              PAY_PRODUCT_LIST_REQ = 0x0501
              PAY_PRODUCT_LIST_RES = 0x0502
              PAY_REQ              = 0x0503
              PAY_RES              = 0x0504
          
              # ---- 房间 / 匹配 ----
              ROOM_CREATE_REQ = 0x0601
              ROOM_CREATE_RES = 0x0602
              ROOM_JOIN_REQ   = 0x0603
              ROOM_JOIN_RES   = 0x0604
              ROOM_LEAVE_REQ  = 0x0605
              ROOM_LEAVE_RES  = 0x0606
              ROOM_READY_REQ  = 0x0607
              ROOM_READY_RES  = 0x0608
              ROOM_START_REQ  = 0x0609
              ROOM_START_RES  = 0x060A
              ROOM_INFO_NTF   = 0x060B
          
              # ---- 战斗 ----
              BATTLE_ACTION_REQ = 0x0701
              BATTLE_ACTION_RES = 0x0702
              BATTLE_STATE_NTF  = 0x0703
              BATTLE_RESULT_NTF = 0x0704
          
              # ---- 掉落 / 物资 ----
              DROP_TEST_REQ = 0x0801
              DROP_TEST_RES = 0x0802
              DROP_NTF      = 0x0803
              BAG_LIST_REQ  = 0x0804
              BAG_LIST_RES  = 0x0805
          
              # ---- 系统 ----
              ERROR_NTF     = 0x7F01
          
          
          # 错误码
          class ERR:
              OK = 0
              BAD_PACKET = 1
              BAD_VERSION = 2
              NEED_LOGIN = 3
              AUTH_FAILED = 4
              ACCOUNT_EXISTS = 5
              NO_CHAR = 6
              CHAR_LIMIT = 7
              RATE_LIMITED = 8
              MAIL_NOT_FOUND = 9
              MAIL_ALREADY_CLAIMED = 10
              MAIL_BAG_FULL = 11
              PAY_PRODUCT_NOT_FOUND = 12
              PAY_FAILED = 13
              ROOM_NOT_FOUND = 14
              ROOM_FULL = 15
              ROOM_NOT_OWNER = 16
              ROOM_NOT_READY = 17
              ALREADY_IN_ROOM = 18
              BATTLE_NOT_ACTIVE = 19
              BAG_FULL = 20
              INTERNAL = 99
          
          
          ERR_TEXT = {
              ERR.OK: "ok",
              ERR.BAD_PACKET: "malformed packet",
              ERR.BAD_VERSION: "client version mismatch",
              ERR.NEED_LOGIN: "please login first",
              ERR.AUTH_FAILED: "invalid account or password",
              ERR.ACCOUNT_EXISTS: "account already exists",
              ERR.NO_CHAR: "character not found",
              ERR.CHAR_LIMIT: "character limit reached",
              ERR.RATE_LIMITED: "too many attempts",
              ERR.MAIL_NOT_FOUND: "mail not found",
              ERR.MAIL_ALREADY_CLAIMED: "reward already claimed",
              ERR.MAIL_BAG_FULL: "bag is full",
              ERR.PAY_PRODUCT_NOT_FOUND: "product not found",
              ERR.PAY_FAILED: "payment failed",
              ERR.ROOM_NOT_FOUND: "room not found",
              ERR.ROOM_FULL: "room is full",
              ERR.ROOM_NOT_OWNER: "only owner can start",
              ERR.ROOM_NOT_READY: "some members not ready",
              ERR.ALREADY_IN_ROOM: "already in a room",
              ERR.BATTLE_NOT_ACTIVE: "battle not active",
              ERR.BAG_FULL: "bag is full",
              ERR.INTERNAL: "internal error",
          }
        • __init__.py 15 B
          """proto 包"""
      • store
        • db.py 8.1 KB
          """app.store.db —— 数据库抽象(默认 aiosqlite,生产可换 MySQL/PG)
          
          表结构在 models.SCHEMA 里定义,启动时自动建表(幂等)。
          """
          from __future__ import annotations
          
          import logging
          import os
          import asyncio
          
          log = logging.getLogger("gsrv.db")
          
          try:
              import aiosqlite
              HAS_SQLITE = True
          except ImportError:
              HAS_SQLITE = False
          
          from .models import SCHEMA, DEFAULT_CONFIG, MIGRATIONS
          
          
          class Database:
              def __init__(self, cfg):
                  self.cfg = cfg
                  self.url = cfg.get("database.url", "sqlite:///./data/game.db")
                  self._conn = None
                  self._lock = asyncio.Lock()
          
              async def connect(self):
                  if not HAS_SQLITE:
                      raise RuntimeError("请安装 aiosqlite: pip install aiosqlite")
                  path = self.url.replace("sqlite:///", "").replace("sqlite://", "")
                  if not path.startswith(":memory:"):
                      os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
                  self._conn = await aiosqlite.connect(path)
                  self._conn.row_factory = aiosqlite.Row
                  await self._conn.execute("PRAGMA journal_mode=WAL")
                  await self._conn.execute("PRAGMA foreign_keys=ON")
                  await self._init_schema()
                  log.info("database ready: %s", path)
          
              async def _init_schema(self):
                  cur = await self._conn.cursor()
                  for stmt in SCHEMA:
                      await cur.execute(stmt)
                  # 自动迁移(忽略"列已存在"错误)
                  for stmt in MIGRATIONS:
                      try:
                          await cur.execute(stmt)
                      except Exception:
                          pass
                  # 初始化配置表
                  for k, v in DEFAULT_CONFIG.items():
                      await cur.execute(
                          "INSERT OR IGNORE INTO config(k, v) VALUES(?, ?)", (k, str(v))
                      )
                  await self._conn.commit()
                  await cur.close()
          
              async def close(self):
                  if self._conn:
                      await self._conn.close()
          
              # ---------- 通用 ----------
              async def execute(self, sql, params=()):
                  async with self._lock:
                      cur = await self._conn.execute(sql, params)
                      await self._conn.commit()
                      rid = cur.lastrowid
                      await cur.close()
                      return rid
          
              async def fetchone(self, sql, params=()):
                  cur = await self._conn.execute(sql, params)
                  row = await cur.fetchone()
                  await cur.close()
                  return row
          
              async def fetchall(self, sql, params=()):
                  cur = await self._conn.execute(sql, params)
                  rows = await cur.fetchall()
                  await cur.close()
                  return rows
          
              # ---------- 账号 ----------
              async def get_account(self, username: str):
                  return await self.fetchone(
                      "SELECT * FROM account WHERE username=?", (username,)
                  )
          
              async def create_account(self, username: str, password_hash: str):
                  return await self.execute(
                      "INSERT INTO account(username, password_hash, created_at) "
                      "VALUES(?, ?, strftime('%s','now'))",
                      (username, password_hash),
                  )
          
              async def touch_login(self, uid: int):
                  await self.execute(
                      "UPDATE account SET last_login=strftime('%s','now') WHERE id=?", (uid,)
                  )
          
              # ---------- 角色 ----------
              async def list_chars(self, uid: int):
                  return await self.fetchall(
                      "SELECT * FROM character WHERE uid=? ORDER BY id", (uid,)
                  )
          
              async def get_char(self, cid: int):
                  return await self.fetchone("SELECT * FROM character WHERE id=?", (cid,))
          
              async def get_char_by_name(self, name: str):
                  return await self.fetchone("SELECT * FROM character WHERE name=?", (name,))
          
              async def create_char(self, uid: int, name: str, job: int = 0):
                  return await self.execute(
                      "INSERT INTO character(uid, name, level, job, scene, x, y, "
                      "hp, mp, created_at) VALUES(?, ?, 1, ?, 1, 100, 100, 100, 50, "
                      "strftime('%s','now'))",
                      (uid, name, job),
                  )
          
              async def delete_char(self, cid: int):
                  await self.execute("DELETE FROM character WHERE id=?", (cid,))
          
              async def update_char_state(self, cid: int, scene=None, x=None, y=None,
                                          hp=None, mp=None, level=None):
                  sets, args = [], []
                  for col, val in (("scene", scene), ("x", x), ("y", y),
                                   ("hp", hp), ("mp", mp), ("level", level)):
                      if val is not None:
                          sets.append(f"{col}=?")
                          args.append(val)
                  if not sets:
                      return
                  args.append(cid)
                  await self.execute(
                      f"UPDATE character SET {', '.join(sets)} WHERE id=?", tuple(args)
                  )
          
              # ---------- 配置表 ----------
              async def get_config(self, k: str, default=None):
                  row = await self.fetchone("SELECT v FROM config WHERE k=?", (k,))
                  return row["v"] if row else default
          
              # ---------- 货币 ----------
              async def get_currency(self, cid: int):
                  row = await self.fetchone(
                      "SELECT gold, diamond FROM character WHERE id=?", (cid,))
                  return (row["gold"], row["diamond"]) if row else (0, 0)
          
              async def grant_currency(self, cid: int, currency: int, amount: int):
                  """currency: 1=gold 2=diamond"""
                  col = "gold" if currency == 1 else "diamond"
                  await self.execute(
                      f"UPDATE character SET {col}={col}+? WHERE id=?", (amount, cid))
                  return await self.get_currency(cid)
          
              # ---------- 邮件 ----------
              async def send_mail(self, cid: int, title: str, content: str,
                                  attachments=None):
                  import json
                  att = json.dumps(attachments or [], ensure_ascii=False)
                  return await self.execute(
                      "INSERT INTO mail(cid, title, content, attachments, created_at) "
                      "VALUES(?, ?, ?, ?, strftime('%s','now'))",
                      (cid, title, content, att))
          
              async def list_mails(self, cid: int):
                  return await self.fetchall(
                      "SELECT * FROM mail WHERE cid=? ORDER BY id DESC", (cid,))
          
              async def get_mail(self, mid: int):
                  return await self.fetchone("SELECT * FROM mail WHERE id=?", (mid,))
          
              async def mark_mail_read(self, mid: int):
                  await self.execute("UPDATE mail SET is_read=1 WHERE id=?", (mid,))
          
              async def claim_mail(self, mid: int):
                  await self.execute("UPDATE mail SET claimed=1 WHERE id=?", (mid,))
          
              async def delete_mail(self, mid: int):
                  await self.execute("DELETE FROM mail WHERE id=?", (mid,))
          
              # ---------- 充值订单 ----------
              async def create_order(self, uid, cid, order_no, product_id,
                                     price, currency, amount):
                  return await self.execute(
                      "INSERT INTO recharge_order(uid, cid, order_no, product_id, price, "
                      "currency, amount, status, created_at) "
                      "VALUES(?, ?, ?, ?, ?, ?, ?, 1, strftime('%s','now'))",
                      (uid, cid, order_no, product_id, price, currency, amount))
          
              async def get_order(self, order_no: str):
                  return await self.fetchone(
                      "SELECT * FROM recharge_order WHERE order_no=?", (order_no,))
          
              # ---------- 背包 ----------
              async def list_items(self, cid: int):
                  return await self.fetchall(
                      "SELECT * FROM item WHERE cid=? ORDER BY item_id", (cid,))
          
              async def add_item(self, cid: int, item_id: int, count: int = 1):
                  """存在则叠加,否则新增一格"""
                  row = await self.fetchone(
                      "SELECT id FROM item WHERE cid=? AND item_id=?", (cid, item_id))
                  if row:
                      await self.execute(
                          "UPDATE item SET count=count+? WHERE id=?", (count, row["id"]))
                      return row["id"]
                  return await self.execute(
                      "INSERT INTO item(cid, item_id, count) VALUES(?, ?, ?)",
                      (cid, item_id, count))
          
              async def bag_slots(self, cid: int) -> int:
                  row = await self.fetchone(
                      "SELECT COUNT(DISTINCT item_id) AS n FROM item WHERE cid=?", (cid,))
                  return int(row["n"]) if row else 0
          
          
          DB: Database | None = None
          
          
          async def init_db(cfg):
              global DB
              DB = Database(cfg)
              await DB.connect()
              return DB
          
          
          def get_db() -> Database:
              assert DB is not None, "数据库未初始化"
              return DB
        • models.py 4.5 KB
          """app.store.models —— 表结构与默认游戏配置表
          
          游戏配置表(config)就是客户端依赖的"数值表"的服务端镜像:
          掉率、经验曲线、道具、技能等都可以放这里,由服务端热改。
          """
          
          SCHEMA = [
              # 账号
              """
              CREATE TABLE IF NOT EXISTS account (
                  id            INTEGER PRIMARY KEY AUTOINCREMENT,
                  username      TEXT NOT NULL UNIQUE,
                  password_hash TEXT NOT NULL,
                  created_at    INTEGER DEFAULT 0,
                  last_login    INTEGER DEFAULT 0,
                  banned        INTEGER DEFAULT 0
              )
              """,
              # 角色
              """
              CREATE TABLE IF NOT EXISTS character (
                  id         INTEGER PRIMARY KEY AUTOINCREMENT,
                  uid        INTEGER NOT NULL,
                  name       TEXT NOT NULL UNIQUE,
                  level      INTEGER DEFAULT 1,
                  exp        INTEGER DEFAULT 0,
                  job        INTEGER DEFAULT 0,
                  scene      INTEGER DEFAULT 1,
                  x          INTEGER DEFAULT 100,
                  y          INTEGER DEFAULT 100,
                  hp         INTEGER DEFAULT 100,
                  mp         INTEGER DEFAULT 50,
                  gold       INTEGER DEFAULT 0,
                  diamond    INTEGER DEFAULT 0,
                  created_at INTEGER DEFAULT 0,
                  FOREIGN KEY(uid) REFERENCES account(id) ON DELETE CASCADE
              )
              """,
              # 背包
              """
              CREATE TABLE IF NOT EXISTS item (
                  id       INTEGER PRIMARY KEY AUTOINCREMENT,
                  cid      INTEGER NOT NULL,
                  item_id  INTEGER NOT NULL,
                  count    INTEGER DEFAULT 1,
                  FOREIGN KEY(cid) REFERENCES character(id) ON DELETE CASCADE
              )
              """,
              # 配置表(客户端数值镜像)
              """
              CREATE TABLE IF NOT EXISTS config (
                  k TEXT PRIMARY KEY,
                  v TEXT
              )
              """,
              # 操作日志(服务端权威校验/审计)
              """
              CREATE TABLE IF NOT EXISTS oplog (
                  id      INTEGER PRIMARY KEY AUTOINCREMENT,
                  uid     INTEGER,
                  cid     INTEGER,
                  opcode  INTEGER,
                  detail  TEXT,
                  ts      INTEGER DEFAULT 0
              )
              """,
              # 邮件
              """
              CREATE TABLE IF NOT EXISTS mail (
                  id          INTEGER PRIMARY KEY AUTOINCREMENT,
                  cid         INTEGER NOT NULL,
                  title       TEXT DEFAULT '',
                  content     TEXT DEFAULT '',
                  attachments TEXT DEFAULT '[]',
                  claimed     INTEGER DEFAULT 0,
                  is_read     INTEGER DEFAULT 0,
                  created_at  INTEGER DEFAULT 0,
                  FOREIGN KEY(cid) REFERENCES character(id) ON DELETE CASCADE
              )
              """,
              # 充值订单(幂等:同一 order_no 只发一次)
              """
              CREATE TABLE IF NOT EXISTS recharge_order (
                  id         INTEGER PRIMARY KEY AUTOINCREMENT,
                  uid        INTEGER NOT NULL,
                  cid        INTEGER,
                  order_no   TEXT NOT NULL UNIQUE,
                  product_id TEXT NOT NULL,
                  price      INTEGER DEFAULT 0,
                  currency   INTEGER DEFAULT 0,
                  amount     INTEGER DEFAULT 0,
                  status     INTEGER DEFAULT 1,
                  created_at INTEGER DEFAULT 0
              )
              """,
          ]
          
          # 自动迁移:给老库补列(已存在则忽略错误)
          MIGRATIONS = [
              "ALTER TABLE character ADD COLUMN diamond INTEGER DEFAULT 0",
          ]
          
          # 默认游戏配置(按需扩展;这些值直接影响客户端表现)
          DEFAULT_CONFIG = {
              "exp_curve_base": 100,        # 升级所需经验 = base * level^2
              "exp_curve_pow": 2,
              "max_char_per_account": 4,
              "initial_scene": 1,
              "start_gold": 0,
              "max_level": 100,
              "hp_per_level": 20,
              "mp_per_level": 10,
              "default_speed": 5,
              "drop_rate": 1.0,
              "max_bag_slots": 50,          # 背包格数上限(满则掉落转邮件)
              "room_max_size": 4,           # 房间默认人数上限
              "battle_max_turn": 20,        # 战斗回合上限(超时判负)
              # 充值发放方式:direct=直接加货币  mail=发邮件领取
              "pay_grant_mode": "direct",
              # 是否模拟支付成功(自托管:跳过真实支付渠道,点击即成功)
              "pay_auto_success": True,
          }
          
          # ---- 充值商品表(独立于 DB 配置,改这里即可)----
          # 键 = 客户端真实的 product_id;从客户端商品表/充值 SDK 反推后填这里
          PAY_PRODUCTS: dict[str, dict] = {
              "com.demo.gold_60":    {"name": "60金",   "price": 600,  "currency": 1, "amount": 60},
              "com.demo.gold_300":   {"name": "300金",  "price": 3000, "currency": 1, "amount": 300},
              "com.demo.diamond_60": {"name": "60钻石", "price": 600,  "currency": 2, "amount": 60},
          }
          
          # 货币类型
          CURRENCY_GOLD = 1
          CURRENCY_DIAMOND = 2
          CURRENCY_NAME = {CURRENCY_GOLD: "gold", CURRENCY_DIAMOND: "diamond"}
        • __init__.py 15 B
          """store 包"""
      • config.py 3.2 KB
        """app.config —— 配置加载(分层:默认值 → config.yaml → 环境变量 → 命令行)"""
        from __future__ import annotations
        
        import os
        from dataclasses import dataclass, field
        from typing import Any
        
        import yaml
        
        DEFAULTS: dict[str, Any] = {
            "server": {"host": "0.0.0.0", "port": 8888, "transport": "tcp",
                       "backlog": 128, "max_connections": 5000,
                       "idle_timeout": 30, "max_frame": 1 << 20},
            "frame": {"length_size": 4, "length_endian": "big",
                      "length_includes_self": False, "opcode_size": 2,
                      "opcode_endian": "little"},
            "crypto": {"enabled": False, "algorithm": "xor", "key": "",
                       "per_connection": False},
            "compress": {"enabled": False, "algorithm": "zlib", "min_size": 256},
            "serialize": {"format": "binary", "fields": []},
            "database": {"url": "sqlite:///./data/game.db", "echo": False, "pool_size": 5},
            "cache": {"enabled": False, "url": "redis://127.0.0.1:6379/0"},
            "game": {"name": "MyGame", "version": "1.0.0", "require_version": True,
                     "expected_version": "1.0.0", "server_authoritative": True,
                     "auto_push_char_list": True},
            "log": {"level": "INFO", "file": "./logs/gsrv.log",
                    "rotate_mb": 32, "keep_files": 7},
            "bots": {"enabled": True, "tick_hz": 5, "auto_fill": 0,
                     "default_scene": 1, "difficulty": "normal"},
            "security": {"token_secret": "change_me", "token_ttl": 86400,
                         "max_conn_per_ip": 32, "login_rate_limit": 10},
        }
        
        
        def _deep_merge(base: dict, override: dict) -> dict:
            out = dict(base)
            for k, v in (override or {}).items():
                if isinstance(v, dict) and isinstance(out.get(k), dict):
                    out[k] = _deep_merge(out[k], v)
                else:
                    out[k] = v
            return out
        
        
        class Config:
            def __init__(self, data: dict):
                self._data = data
        
            def __getitem__(self, key):
                return self._data[key]
        
            def get(self, path: str, default=None):
                cur: Any = self._data
                for part in path.split("."):
                    if not isinstance(cur, dict) or part not in cur:
                        return default
                    cur = cur[part]
                return cur
        
            @property
            def raw(self) -> dict:
                return self._data
        
            @classmethod
            def load(cls, path: str | None = None) -> "Config":
                data = dict(DEFAULTS)
                if path is None:
                    path = os.environ.get("GSRV_CONFIG", "./config/config.yaml")
                if os.path.exists(path):
                    with open(path, "r", encoding="utf-8") as f:
                        data = _deep_merge(data, yaml.safe_load(f) or {})
        
                # 环境变量覆盖(GSRV_SERVER__PORT=9999 形式)
                for env_key, env_val in os.environ.items():
                    if not env_key.startswith("GSRV_"):
                        continue
                    parts = env_key[5:].lower().split("__")
                    if len(parts) < 2:
                        continue
                    cur = data
                    for p in parts[:-1]:
                        cur = cur.setdefault(p, {})
                    val = yaml.safe_load(env_val)
                    cur[parts[-1]] = val
        
                return cls(data)
        
        
        CFG: Config | None = None
        
        
        def get_config() -> Config:
            global CFG
            if CFG is None:
                CFG = Config.load()
            return CFG
      • log.py 919 B
        """app.log —— 日志(控制台 + 轮转文件)"""
        from __future__ import annotations
        
        import logging
        import os
        import sys
        from logging.handlers import RotatingFileHandler
        
        
        def setup_logging(level="INFO", file=None, rotate_mb=32, keep_files=7):
            root = logging.getLogger()
            root.setLevel(getattr(logging, str(level).upper(), logging.INFO))
            root.handlers.clear()
        
            fmt = logging.Formatter(
                "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
                datefmt="%Y-%m-%d %H:%M:%S",
            )
        
            sh = logging.StreamHandler(sys.stdout)
            sh.setFormatter(fmt)
            root.addHandler(sh)
        
            if file:
                os.makedirs(os.path.dirname(os.path.abspath(file)), exist_ok=True)
                fh = RotatingFileHandler(
                    file, maxBytes=rotate_mb * 1024 * 1024,
                    backupCount=keep_files, encoding="utf-8",
                )
                fh.setFormatter(fmt)
                root.addHandler(fh)
            return root
        
      • main.py 1.9 KB
        """app.main —— 服务端入口
        
        用法:
            python -m app.main                          # 默认 ./config/config.yaml
            python -m app.main -c /etc/gsrv/config.yaml
            GSRV_SERVER__PORT=9000 python -m app.main   # 环境变量覆盖
        """
        from __future__ import annotations
        
        import argparse
        import asyncio
        import logging
        import signal
        
        from .config import get_config, Config
        from .log import setup_logging
        from .net.server import GameServer
        from .store.db import init_db, get_db, Database
        
        log = logging.getLogger("gsrv.main")
        
        
        async def run(cfg):
            setup_logging(
                level=cfg.get("log.level", "INFO"),
                file=cfg.get("log.file"),
                rotate_mb=int(cfg.get("log.rotate_mb", 32)),
                keep_files=int(cfg.get("log.keep_files", 7)),
            )
            log.info("===== %s server starting =====", cfg.get("game.name"))
            await init_db(cfg)
        
            server = GameServer(cfg)
            await server.start()
        
        
            stop_event = asyncio.Event()
        
            def _sig(*_):
                log.info("signal received, shutting down...")
                stop_event.set()
        
            loop = asyncio.get_running_loop()
            for sig in (signal.SIGINT, signal.SIGTERM):
                try:
                    loop.add_signal_handler(sig, _sig)
                except NotImplementedError:
                    pass  # Windows
        
            try:
                await stop_event.wait()
            except (KeyboardInterrupt, asyncio.CancelledError):
                pass
            finally:
                await server.stop()
                db = get_db()
                await db.close()
                log.info("===== server stopped =====")
        
        
        def main():
            ap = argparse.ArgumentParser(description="Game Mock Server")
            ap.add_argument("-c", "--config", default=None, help="config.yaml 路径")
            args = ap.parse_args()
        
            cfg = Config.load(args.config)
            from . import config as cfgmod
            cfgmod.CFG = cfg
            try:
                asyncio.run(run(cfg))
            except KeyboardInterrupt:
                pass
        
        
        if __name__ == "__main__":
            main()
      • __init__.py 35 B
        """app 包"""
        __version__ = "1.0.0"
    • config
      • config.yaml 3.2 KB
        # =========================================================
        # 游戏服务端配置
        # 所有"从客户端逆向出来的协议参数"都集中在这里,改这里即可适配目标
        # =========================================================
        
        server:
          host: "0.0.0.0"
          port: 8888
          transport: "tcp"          # tcp | udp(kcp 需额外依赖) | ws
          backlog: 128
          max_connections: 5000
          # 心跳:服务端多久没收到包就断开(秒)
          idle_timeout: 30
          # 单帧最大字节
          max_frame: 1048576
        
        # ---- 帧封装层(对应 SKILL.md §4 第 [2] 层)----
        frame:
          length_size: 4            # 长度字段字节数
          length_endian: "big"      # big | little
          length_includes_self: false
          opcode_size: 2            # 消息号字段字节数(0 = 无独立 opcode;本协议表用 16 位)
          opcode_endian: "little"
        
        # ---- 加密层(第 [3] 层)----
        crypto:
          enabled: false
          algorithm: "xor"          # xor | rc4 | aes
          key: "0x11223344"         # hex 或字符串
          # 是否对每个连接使用独立密钥(握手协商)
          per_connection: false
        
        # ---- 压缩层(第 [4] 层)----
        compress:
          enabled: false
          algorithm: "zlib"         # zlib | lz4 | gzip
          min_size: 256             # 小于该值不压缩
        
        # ---- 序列化层(第 [5] 层)----
        serialize:
          format: "binary"          # binary | json | protobuf | msgpack
          # binary 模式下:相对 payload 首字节的字段布局定义
          # name,type,size  type: u8/u16/u32/u64/i*/f32/f64/bytes/str
          fields: []
        
        # ---- 数据库 ----
        database:
          url: "sqlite:///./data/game.db"
          # 生产建议 MySQL: "mysql://user:pass@127.0.0.1:3306/game"
          echo: false
          pool_size: 5
        
        # ---- 缓存(可选 Redis)----
        cache:
          enabled: false
          url: "redis://127.0.0.1:6379/0"
        
        # ---- 业务开关 ----
        game:
          name: "MyGame"
          version: "1.0.0"
          # 客户端版本校验(逆向得到的期望版本号)
          require_version: true
          expected_version: "1.0.0"
          # 是否开启服务端权威校验(服务端权威校验)
          server_authoritative: true
          # 登录后是否自动推送角色列表
          auto_push_char_list: true
          # 充值发放方式:direct=直接加货币  mail=发邮件领取
          pay_grant_mode: "direct"
          # 自托管支付:点击购买直接成功(true)/需真实校验(false)
          pay_auto_success: true
          # 背包格数上限(满则掉落转邮件)
          max_bag_slots: 50
          # 房间默认人数上限
          room_max_size: 4
          # 战斗回合上限(超时判负)
          battle_max_turn: 20
        
        # ---- 日志 ----
        log:
          level: "INFO"
          file: "./logs/gsrv.log"
          rotate_mb: 32
          keep_files: 7
        
        
        # ---- 服务端人机( 后续拓展,可选)----
        # 不影响核心运行;默认 enabled=true 但 auto_fill=0,即不生成任何假玩家。
        # 详见 extensions/bots.md
        bots:
          enabled: true
          tick_hz: 5            # AI 驱动频率(每秒几次)
          auto_fill: 0          # 启动时自动补多少人机(0 = 不自动)
          default_scene: 1
          difficulty: "normal"  # easy | normal | hard
        
        # ---- 安全 ----
        security:
          # 登录 token 签名密钥
          token_secret: "change_me_in_production"
          token_ttl: 86400
          # 单 IP 最大连接数
          max_conn_per_ip: 32
          # 登录失败节流
          login_rate_limit: 10        # 每分钟
    • deploy
      • deploy.sh 1.4 KB
        #!/usr/bin/env bash
        # 一键部署脚本(Ubuntu/Debian 服务器)
        # 用法: sudo bash deploy.sh [install_dir]
        set -euo pipefail
        
        APP_DIR="${1:-/opt/gsrv}"
        SRC_DIR="$(cd "$(dirname "$0")/.." && pwd)"
        
        echo "=== [1/6] 安装系统依赖 ==="
        if ! command -v python3 >/dev/null; then
          apt-get update && apt-get install -y python3 python3-venv python3-pip
        fi
        
        echo "=== [2/6] 创建用户与目录 ==="
        id -u gsrv >/dev/null 2>&1 || useradd -r -s /usr/sbin/nologin gsrv
        mkdir -p "$APP_DIR"/{data,logs,config}
        
        echo "=== [3/6] 拷贝程序 ==="
        cp -r "$SRC_DIR/app" "$APP_DIR/"
        cp "$SRC_DIR/requirements.txt" "$APP_DIR/"
        cp "$SRC_DIR/client_test.py" "$APP_DIR/" 2>/dev/null || true
        if [ ! -f "$APP_DIR/config/config.yaml" ]; then
          cp "$SRC_DIR/config/config.yaml" "$APP_DIR/config/config.yaml"
        fi
        
        echo "=== [4/6] 建立虚拟环境并安装依赖 ==="
        python3 -m venv "$APP_DIR/venv"
        "$APP_DIR/venv/bin/pip" install --upgrade pip
        "$APP_DIR/venv/bin/pip" install -r "$APP_DIR/requirements.txt"
        
        echo "=== [5/6] 权限 ==="
        chown -R gsrv:gsrv "$APP_DIR"
        
        echo "=== [6/6] 安装 systemd 服务 ==="
        cp "$SRC_DIR/deploy/gsrv.service" /etc/systemd/system/gsrv.service
        systemctl daemon-reload
        systemctl enable --now gsrv
        
        echo
        echo "[x] 部署完成"
        echo "   状态: systemctl status gsrv"
        echo "   日志: journalctl -u gsrv -f"
        echo "   配置: $APP_DIR/config/config.yaml"
        echo "   测试: $APP_DIR/venv/bin/python $APP_DIR/client_test.py --host 127.0.0.1"
      • docker-compose.yml 1.2 KB
        version: "3.8"
        
        services:
          gsrv:
            build:
              context: ..
              dockerfile: deploy/Dockerfile
            image: gsrv:latest
            container_name: gsrv
            restart: unless-stopped
            ports:
              - "8888:8888"     # 游戏协议端口 TCP
              # - "8888:8888/udp"  # 若用 KCP/UDP 打开这行
              - "9900:9900"     # (生产请勿对外暴露)
            volumes:
              - ../config:/app/config:ro
              - ../data:/app/data
              - ../logs:/app/logs
            environment:
              - GSRV_CONFIG=/app/config/config.yaml
              - GSRV_LOG__LEVEL=INFO
              - TZ=Asia/Shanghai
            healthcheck:
              test: ["CMD", "python", "-c", "import socket;socket.create_connection(('127.0.0.1',8888),2)"]
              interval: 30s
              timeout: 5s
              retries: 3
        
          # 可选:MySQL(把 config.yaml 的 database.url 换成 mysql://...)
          # mysql:
          #   image: mysql:8.0
          #   restart: unless-stopped
          #   environment:
          #     MYSQL_ROOT_PASSWORD: gamepass
          #     MYSQL_DATABASE: game
          #   volumes:
          #     - mysql_data:/var/lib/mysql
          #   ports:
          #     - "3306:3306"
        
          # 可选:Redis
          # redis:
          #   image: redis:7-alpine
          #   restart: unless-stopped
          #   ports:
          #     - "6379:6379"
        
        # volumes:
        #   mysql_data:
      • Dockerfile 448 B · in bundle
      • gsrv.service 593 B · in bundle
      • install_windows_service.bat 842 B · in bundle
      • start_windows.bat 450 B · in bundle
    • client_test.py 4.8 KB
      #!/usr/bin/env python3
      # -*- coding: utf-8 -*-
      """测试客户端 / 联调工具
      
      用途:
        1. 验证服务端能跑通「握手 → 登录 → 建角 → 选角 → 进场景 → 移动」
        2. 作为"客户端协议行为的参照实现",反向核对你的逆向结论
        3. 重放抓包数据(--replay)
      
      用法:
        python client_test.py --host 127.0.0.1 --port 8888 --user test --pass 123456
      """
      from __future__ import annotations
      
      import argparse
      import asyncio
      import struct
      import sys
      import os
      
      sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
      from app.config import Config                      # noqa: E402
      from app.net.codec import Codec                    # noqa: E402
      from app.proto.opcodes import OP                   # noqa: E402
      from app.proto.messages import (                   # noqa: E402
          HandshakeReq, LoginReq, CharCreateReq, CharSelectReq,
      )
      
      
      async def recv_frame(reader, codec):
          ls = codec.len_size
          head = await reader.readexactly(ls)
          n, rest, need = codec.decode_head(head)
          inner = rest
          while need > 0:
              chunk = await reader.readexactly(need)
              inner += chunk
              need -= len(chunk)
          return codec.parse(inner)
      
      
      async def send(reader, writer, codec, opcode, payload=b""):
          writer.write(codec.encode(opcode, payload))
          await writer.drain()
      
      
      async def run(args):
          cfg = Config.load(args.config)
          codec = Codec(cfg.get("frame", {}), cfg.get("crypto", {}),
                        cfg.get("compress", {}), cfg.get("serialize", {}),
                        int(cfg.get("server.max_frame", 1 << 20)))
      
          reader, writer = await asyncio.open_connection(args.host, args.port)
          print(f"[+] connected {args.host}:{args.port}")
      
          # 1) 握手
          await send(reader, writer, codec, OP.HANDSHAKE_REQ,
                     HandshakeReq(cfg.get("game.expected_version", "1.0.0"), 0x1234).encode(codec))
          op, body = await recv_frame(reader, codec)
          print(f"[<] handshake res: op=0x{op:04X} code={body[0] if body else '?'}")
      
          # 2) 登录
          await send(reader, writer, codec, OP.LOGIN_REQ,
                     LoginReq(args.user, args.password).encode(codec))
          op, body = await recv_frame(reader, codec)
          print(f"[<] login res: op=0x{op:04X} code={body[0] if body else '?'}")
      
          # 登录后服务端会推送角色列表
          op, body = await recv_frame(reader, codec)
          print(f"[<] push: op=0x{op:04X} len={len(body)}")
          if op == OP.CHAR_LIST_RES:
              (cnt,) = struct.unpack_from("<H", body, 0)
              print(f"    char count = {cnt}")
      
          # 3) 创建角色
          cname = args.name
          await send(reader, writer, codec, OP.CHAR_CREATE_REQ,
                     CharCreateReq(cname, 0).encode(codec))
          op, body = await recv_frame(reader, codec)
          print(f"[<] char create: op=0x{op:04X} code={body[0] if body else '?'}")
          cid = None
          if op == OP.CHAR_CREATE_RES and body[0] == 0:
              (cid,) = struct.unpack_from("<Q", body, 1)
              print(f"    created cid={cid}")
      
          # 可能又收到一次角色列表推送
          try:
              op, body = await asyncio.wait_for(recv_frame(reader, codec), timeout=1)
              print(f"[<] push: op=0x{op:04X} len={len(body)}")
              if op == OP.CHAR_LIST_RES:
                  (cnt,) = struct.unpack_from("<H", body, 0)
                  for i in range(cnt):
                      off = 2 + i * 0  # 简单起见只读第一个
                      break
          except asyncio.TimeoutError:
              pass
      
          if cid is None:
              print("[!] no char created; try different --name")
              writer.close()
              return
      
          # 4) 选角
          await send(reader, writer, codec, OP.CHAR_SELECT_REQ,
                     CharSelectReq(cid).encode(codec))
          op, body = await recv_frame(reader, codec)
          print(f"[<] char select: op=0x{op:04X} code={body[0] if body else '?'}")
      
          # 5) 进场景
          await send(reader, writer, codec, OP.ENTER_SCENE_REQ, b"")
          op, body = await recv_frame(reader, codec)
          print(f"[<] enter scene: op=0x{op:04X} len={len(body)}")
      
          # 6) 移动
          await send(reader, writer, codec, OP.MOVE_REQ, struct.pack("<II", 110, 120))
          print("[+] move sent")
          await asyncio.sleep(0.3)
      
          # 7) 心跳
          await send(reader, writer, codec, OP.HEARTBEAT_REQ, b"ping")
          op, body = await recv_frame(reader, codec)
          print(f"[<] heartbeat echo: op=0x{op:04X} {body!r}")
      
          print("[+] full flow OK")
          writer.close()
          await writer.wait_closed()
      
      
      def main():
          ap = argparse.ArgumentParser()
          ap.add_argument("--host", default="127.0.0.1")
          ap.add_argument("--port", type=int, default=8888)
          ap.add_argument("--user", default="tester")
          ap.add_argument("--password", default="123456")
          ap.add_argument("--name", default="Hero01")
          ap.add_argument("--config", default="./config/config.yaml")
          args = ap.parse_args()
          try:
              asyncio.run(run(args))
          except KeyboardInterrupt:
              pass
      
      
      if __name__ == "__main__":
          main()
    • README.md 5.1 KB
      # Game Mock Server — 参考实现(长连接二进制协议)
      
      > 注意: **这是参考实现,不是对某个游戏的答案。**
      > 它提供的是**分层结构和套路**;协议参数(长度头/消息号/加密/序列化/字段布局)
      > 必须按 `../schema/protocol.spec.yaml` 重新填。详见 `../templates/README.md`。
      >
      > 已实测:`python -m app.main` 启动 → 客户端跑通 握手→登录→建角→选角→进场景→移动→心跳,
      > 并验证 XOR 加密 + zlib 压缩 + 充值/邮件发放。
      
      ## 目录
      
      ```
      server/
      ├── app/
      │   ├── main.py            入口(-c 指定配置)
      │   ├── config.py          配置加载(yaml + 环境变量覆盖)
      │   ├── log.py             日志(控制台 + 轮转)
      │   ├── net/
      │   │   ├── server.py      asyncio 服务器主循环
      │   │   ├── session.py     会话(状态/发送/心跳)
      │   │   ├── dispatcher.py  opcode 路由
      │   │   ├── codec.py       帧编解码(长度头/opcode/加密/压缩/序列化)
      │   │   └── crypto.py      加解密(xor/rc4/aes)
      │   ├── proto/
      │   │   ├── opcodes.py     消息号表(反推结果落点)
      │   │   └── messages.py    消息结构
      │   ├── logic/
      │   │   ├── state.py       在线玩家/场景
      │   │   ├── security.py    密码哈希/token/限流
      │   │   └── handlers/      auth / char / scene / mail / pay
      │   ├── store/
      │   │   ├── db.py          数据库(aiosqlite,可换 MySQL)
      │   │   └── models.py      表结构 + 游戏配置表 + 充值商品表
      ├── config/config.yaml     协议+业务配置(改这里适配目标)
      ├── client_test.py         联调测试客户端
      ├── test_pay_mail.py       充值+邮件联调测试
      ├── test_bots.py            人机(假玩家)联调测试(拓展,可选)
      ├── test_battle.py         房间+战斗+掉落 联调测试(双客户端)
      ├── deploy/                Docker / systemd / Windows(NSSM) / 一键部署
      ├── start.sh               本地一键启动(Linux/Mac)
      ├── start_termux.sh        手游(Termux) 一键启动(含 wake-lock + tmux)
      └── requirements.txt
      ```
      
      ## 快速开始(本地)
      
      ```bash
      chmod +x start.sh
      ./start.sh
      # 或手动:
      python3 -m venv venv && ./venv/bin/pip install -r requirements.txt
      ./venv/bin/python -m app.main -c ./config/config.yaml
      ```
      
      启动后监听 `0.0.0.0:8888`, `127.0.0.1:9900`。
      
      ## 联调测试
      
      ```bash
      ./venv/bin/python client_test.py --host 127.0.0.1 --port 8888 \
          --user alice --password 123456 --name Hero01
      ```
      
      输出应包含 `[+] full flow OK`。
      
      ## 
      
      ```bash
      python3 -c "import socket;s=socket.create_connection(('127.0.0.1',9900));print(s.recv(999))"
      # 或用任意 tcp 客户端(telnet/nc/socat)
      ```
      命令:`help stats online kick <uid> ban <name> setlevel <cid> <lvl> givegold <cid> <n> cfg <k> setcfg <k> <v> broadcast <op-hex> <hex> stop`
      
      ## 部署
      
      ### Docker
      ```bash
      cd deploy && docker compose up -d --build
      docker compose logs -f gsrv
      ```
      
      ### 服务器(systemd)
      ```bash
      sudo bash deploy/deploy.sh           # 默认装到 /opt/gsrv
      systemctl status gsrv
      journalctl -u gsrv -f
      ```
      
      ### 端口 / 防火墙
      - 游戏端口:8888/tcp(用 KCP 则同时放行 8888/udp)
      
      ## 适配你的目标游戏(核心 4 步)
      
      1. **协议层** → 改 `config/config.yaml` 的 `frame / crypto / compress / serialize`
      2. **消息号** → 填 `app/proto/opcodes.py` 的 `OP`
      3. **消息结构** → 在 `app/proto/messages.py` 实现 `encode/decode`
      4. **业务逻辑** → 在 `app/logic/handlers/` 注册 `@dispatch(OP.xxx)`
      
      改完重启即可。
      
      ## 生产建议
      - 数据库换 MySQL:`database.url = mysql://user:pass@host/db`
      - 密码哈希换 bcrypt/argon2
      - 多进程/多节点:网关与逻辑分离,用 Redis 做共享会话
      - 客户端保护:开启 `game.server_authoritative`,所有客户端数值服务端重算
      
      ---
      
      ## 充值 / 邮件发放
      
      - **发放模式**:`config.yaml` → `game.pay_grant_mode`
        - `direct` 直发:充值成功货币直接进角色
        - `mail` 邮件:充值成功发带附件邮件,玩家自行领取
      - **支付**:`game.pay_auto_success: true` → 点击购买直接成功(禁止接入真实渠道)
      - **商品表**:`app/store/models.py` 的 `PAY_PRODUCTS`,键 = 客户端真实 `product_id`
      - **幂等**:`recharge_order.order_no` 唯一,重复回调只发一次
      
      联调测试:
      ```bash
      ./venv/bin/python test_pay_mail.py --user u1 --name H1 --product com.demo.diamond_60
      # 切换发放模式: GSRV_GAME__PAY_GRANT_MODE=mail ./venv/bin/python ...
      ```
      
      ---
      
      ## 平台支持
      
      | 平台 | 启动方式 | 常驻方案 |
      |------|---------|---------|
      | Linux 服务器 | `deploy/deploy.sh` | systemd |
      | Docker | `deploy/docker-compose.yml` | 容器 restart 策略 |
      | Windows(端游) | `deploy/start_windows.bat` | NSSM / WinSW / 计划任务 |
      | Android(手游) | `start_termux.sh` | termux-wake-lock + tmux |
      
      详见 `../references/windows.md` 与 `../references/termux.md`。
      
    • requirements.txt 271 B
      # 游戏服务端工程依赖
      # 核心(必需)
      PyYAML>=6.0
      aiofiles>=23.0
      
      # 数据库
      aiosqlite>=0.19
      
      # 可选:AES / protobuf 按需启用
      # cryptography>=42.0
      # protobuf>=4.25
      # redis>=5.0
      # pymysql>=1.1
      # asyncpg>=0.29
      
      # 测试
      pytest>=8.0
      pytest-asyncio>=0.23
    • start.sh 354 B
      #!/usr/bin/env bash
      # 本地一键启动(开发用)
      set -e
      cd "$(dirname "$0")"
      
      if [ ! -d venv ]; then
        echo "[*] 创建虚拟环境..."
        python3 -m venv venv
        ./venv/bin/pip install -U pip
        ./venv/bin/pip install -r requirements.txt
      fi
      
      mkdir -p data logs
      echo "[*] 启动服务端..."
      exec ./venv/bin/python -m app.main -c ./config/config.yaml
    • start_termux.sh 1010 B
      #!/data/data/com.termux/files/usr/bin/bash
      # Termux 一键启动(Android)
      # 用法: bash start_termux.sh
      set -e
      cd "$(dirname "$0")"
      
      # 1) 唤醒锁,防止锁屏被杀
      command -v termux-wake-lock >/dev/null 2>&1 && termux-wake-lock
      
      # 2) 依赖
      if [ ! -d venv ]; then
        echo "[*] 创建虚拟环境..."
        python -m venv venv
        ./venv/bin/pip install -U pip
        ./venv/bin/pip install PyYAML aiosqlite
      fi
      
      mkdir -p data logs
      
      # 3) 优先用 tmux 常驻
      if command -v tmux >/dev/null 2>&1; then
        if tmux has-session -t gsrv 2>/dev/null; then
          echo "[*] gsrv 会话已存在,attach: tmux attach -t gsrv"
          exit 0
        fi
        echo "[*] 在 tmux 会话 gsrv 中启动服务端"
        tmux new-session -d -s gsrv \
          "cd $(pwd) && ./venv/bin/python -m app.main -c ./config/config.yaml 2>&1 | tee -a logs/gsrv.log"
        echo "[+] 已启动。查看: tmux attach -t gsrv"
      else
        echo "[*] 未装 tmux,前台运行(建议先 pkg install tmux)"
        exec ./venv/bin/python -m app.main -c ./config/config.yaml
      fi
    • test_battle.py 5.6 KB
      #!/usr/bin/env python3
      # -*- coding: utf-8 -*-
      """验证 房间 + 战斗 + 掉落 全流程。
      
      两个客户端:A 建房 → B 加入 → B 准备 → A 开始 → 双方连续攻击 → Boss 死 → 结算掉落
      用法:python test_battle.py --host 127.0.0.1 --port 8888
      """
      import argparse, asyncio, struct, sys, os
      
      sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
      from app.config import Config
      from app.net.codec import Codec
      from app.proto.opcodes import OP
      from app.proto.messages import (HandshakeReq, LoginReq, CharCreateReq, CharSelectReq)
      
      
      class Cli:
          def __init__(self, name, cfg):
              self.name = name
              self.cfg = cfg
              self.codec = Codec(cfg.get("frame", {}), cfg.get("crypto", {}),
                                 cfg.get("compress", {}), cfg.get("serialize", {}),
                                 int(cfg.get("server.max_frame", 1 << 20)))
              self.r = None
              self.w = None
              self.cid = 0
              self.events = []
      
          async def recv(self):
              head = await self.r.readexactly(self.codec.len_size)
              n, rest, need = self.codec.decode_head(head)
              inner = rest
              while need > 0:
                  c = await self.r.readexactly(need)
                  inner += c
                  need -= len(c)
              return self.codec.parse(inner)
      
          async def recv_op(self, want, maxn=12):
              for _ in range(maxn):
                  op, body = await asyncio.wait_for(self.recv(), timeout=4)
                  self.events.append(op)
                  if op == want:
                      return body
              raise RuntimeError(f"{self.name}: opcode 0x{want:04X} not received")
      
          async def send(self, op, payload=b""):
              self.w.write(self.codec.encode(op, payload))
              await self.w.drain()
      
          async def connect(self, host, port, user, charname):
              self.r, self.w = await asyncio.open_connection(host, port)
              await self.send(OP.HANDSHAKE_REQ, HandshakeReq("1.0.0", 1).encode(self.codec))
              await self.recv_op(OP.HANDSHAKE_RES)
              await self.send(OP.LOGIN_REQ, LoginReq(user, "123456").encode(self.codec))
              await self.recv_op(OP.LOGIN_RES)
              body = await self.recv_op(OP.CHAR_LIST_RES)
              cnt = struct.unpack_from("<H", body, 0)[0]
              if cnt:
                  self.cid = struct.unpack_from("<Q", body, 2)[0]
              else:
                  await self.send(OP.CHAR_CREATE_REQ, CharCreateReq(charname, 0).encode(self.codec))
                  body = await self.recv_op(OP.CHAR_CREATE_RES)
                  self.cid = struct.unpack_from("<Q", body, 1)[0]
              await self.send(OP.CHAR_SELECT_REQ, CharSelectReq(self.cid).encode(self.codec))
              await self.recv_op(OP.CHAR_SELECT_RES)
              print(f"[+] {self.name} cid={self.cid}")
      
      
      async def wait_battle_end(c, timeout=15):
          """持续攻击直到战斗结束;返回 (win, drops)"""
          deadline = asyncio.get_event_loop().time() + timeout
          while asyncio.get_event_loop().time() < deadline:
              await c.send(OP.BATTLE_ACTION_REQ, struct.pack("<B", 2))  # 技能
              try:
                  while True:
                      op, body = await asyncio.wait_for(c.recv(), timeout=0.6)
                      c.events.append(op)
                      if op == OP.BATTLE_RESULT_NTF:
                          rid, win, turn, n = struct.unpack_from("<IBHB", body, 0)
                          drops = []
                          off = 8
                          for _ in range(n):
                              item, cnt = struct.unpack_from("<II", body, off)
                              drops.append((item, cnt)); off += 8
                          return bool(win), turn, drops
              except asyncio.TimeoutError:
                  pass
          raise RuntimeError("battle not finished in time")
      
      
      async def run(a):
          cfg = Config.load(a.config)
          A = Cli("A", cfg); B = Cli("B", cfg)
          await A.connect(a.host, a.port, "battle_a", "FighterA")
          await B.connect(a.host, a.port, "battle_b", "FighterB")
      
          # 1) A 建房
          await A.send(OP.ROOM_CREATE_REQ, struct.pack("<B", 4))
          body = await A.recv_op(OP.ROOM_CREATE_RES)
          code, rid = struct.unpack_from("<BI", body, 0)
          print(f"[+] A create room: code={code} room_id={rid}")
          await A.recv_op(OP.ROOM_INFO_NTF)
      
          # 2) B 加入
          await B.send(OP.ROOM_JOIN_REQ, struct.pack("<I", rid))
          body = await B.recv_op(OP.ROOM_JOIN_RES)
          print(f"[+] B join: code={body[0]}")
          await A.recv_op(OP.ROOM_INFO_NTF); await B.recv_op(OP.ROOM_INFO_NTF)
      
          # 3) B 准备 → A 开始
          await B.send(OP.ROOM_READY_REQ, struct.pack("<B", 1))
          await B.recv_op(OP.ROOM_READY_RES)
          await A.send(OP.ROOM_START_REQ, b"")
          body = await A.recv_op(OP.ROOM_START_RES)
          print(f"[+] A start battle: code={body[0]}")
      
          # 4) 双方打到结束
          ta = asyncio.create_task(wait_battle_end(A))
          tb = asyncio.create_task(wait_battle_end(B))
          (win_a, turn_a, drops_a), (win_b, turn_b, drops_b) = await asyncio.gather(ta, tb)
          print(f"[+] battle end: win={win_a} turn={turn_a} drops={drops_a}")
      
          # 5) 查背包
          await A.send(OP.BAG_LIST_REQ, b"")
          body = await A.recv_op(OP.BAG_LIST_RES)
          n = struct.unpack_from("<H", body, 0)[0]
          items = []
          off = 2
          for _ in range(n):
              i, c = struct.unpack_from("<II", body, off); items.append((i, c)); off += 8
          print(f"[+] A bag: {items}")
      
          assert win_a and win_b, "[x] 战斗未胜利"
          assert items, "[x] 掉落未入背包"
          print("[+] room & battle & drop flow OK")
          A.w.close(); B.w.close()
      
      
      def main():
          ap = argparse.ArgumentParser()
          ap.add_argument("--host", default="127.0.0.1")
          ap.add_argument("--port", type=int, default=8888)
          ap.add_argument("--config", default="./config/config.yaml")
          asyncio.run(run(ap.parse_args()))
      
      
      if __name__ == "__main__":
          main()
    • test_pay_mail.py 4.4 KB
      #!/usr/bin/env python3
      # -*- coding: utf-8 -*-
      """验证 充值直发 + 邮件系统(opcode 感知,容忍异步推送)。
      
      流程:
        连接 → 握手 → 登录 → 建角/选角 → 拉商品 → 充值 → 邮件(可选) → 领取
      """
      import argparse, asyncio, struct, sys, os
      
      sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
      from app.config import Config
      from app.net.codec import Codec
      from app.proto.opcodes import OP
      from app.proto.messages import (
          HandshakeReq, LoginReq, CharCreateReq, CharSelectReq, PayReq, MailOpReq,
      )
      
      
      async def recv(reader, codec):
          head = await reader.readexactly(codec.len_size)
          n, rest, need = codec.decode_head(head)
          inner = rest
          while need > 0:
              c = await reader.readexactly(need)
              inner += c
              need -= len(c)
          return codec.parse(inner)
      
      
      async def recv_op(reader, codec, want, maxn=8):
          """读到指定 opcode,跳过其间的异步推送"""
          for _ in range(maxn):
              op, body = await asyncio.wait_for(recv(reader, codec), timeout=3)
              if op == want:
                  return body
          raise RuntimeError(f"opcode 0x{want:04X} not received")
      
      
      async def send(writer, codec, op, payload=b""):
          writer.write(codec.encode(op, payload))
          await writer.drain()
      
      
      async def run(a):
          cfg = Config.load(a.config)
          codec = Codec(cfg.get("frame", {}), cfg.get("crypto", {}),
                        cfg.get("compress", {}), cfg.get("serialize", {}),
                        int(cfg.get("server.max_frame", 1 << 20)))
          r, w = await asyncio.open_connection(a.host, a.port)
      
          await send(w, codec, OP.HANDSHAKE_REQ,
                     HandshakeReq(cfg.get("game.expected_version", "1.0.0"), 1).encode(codec))
          await recv(r, codec)
      
          await send(w, codec, OP.LOGIN_REQ, LoginReq(a.user, a.password).encode(codec))
          body = await recv_op(r, codec, OP.LOGIN_RES)
          print("login code =", body[0])
      
          body = await recv_op(r, codec, OP.CHAR_LIST_RES)
          cnt = struct.unpack_from("<H", body, 0)[0]
          cid = struct.unpack_from("<Q", body, 2)[0] if cnt > 0 else None
          if cid:
              print("existing cid =", cid)
          else:
              await send(w, codec, OP.CHAR_CREATE_REQ, CharCreateReq(a.name, 0).encode(codec))
              body = await recv_op(r, codec, OP.CHAR_CREATE_RES)
              cid = struct.unpack_from("<Q", body, 1)[0]
              print("created cid =", cid, "code =", body[0])
          print("cid =", cid)
      
          await send(w, codec, OP.CHAR_SELECT_REQ, CharSelectReq(cid).encode(codec))
          body = await recv_op(r, codec, OP.CHAR_SELECT_RES)
          print("select code =", body[0])
      
          # 商品列表
          await send(w, codec, OP.PAY_PRODUCT_LIST_REQ, b"")
          body = await recv_op(r, codec, OP.PAY_PRODUCT_LIST_RES)
          pcnt = struct.unpack_from("<H", body, 0)[0]
          print("products =", pcnt)
      
          # 充值
          await send(w, codec, OP.PAY_REQ, PayReq(a.product, "", 0).encode(codec))
          body = await recv_op(r, codec, OP.PAY_RES)
          code = body[0]
          cur = body[1]
          granted = struct.unpack_from("<I", body, 2)[0]
          by_mail = body[6]
          print(f"PAY res: code={code} currency={cur} granted={granted} by_mail={by_mail}")
      
          # 邮件列表
          await send(w, codec, OP.MAIL_LIST_REQ, b"")
          body = await recv_op(r, codec, OP.MAIL_LIST_RES)
          mcnt = struct.unpack_from("<H", body, 0)[0]
          print("mail count =", mcnt)
      
          if mcnt > 0:
              mid = struct.unpack_from("<Q", body, 2)[0]
              await send(w, codec, OP.MAIL_CLAIM_REQ, MailOpReq(mid).encode(codec))
              body = await recv_op(r, codec, OP.MAIL_CLAIM_RES)
              print("claim code =", body[0], "mid =", struct.unpack_from("<Q", body, 1)[0])
      
              # 再拉一次邮件,确认已标记领取
              await send(w, codec, OP.MAIL_LIST_REQ, b"")
              body = await recv_op(r, codec, OP.MAIL_LIST_RES)
              flags = body[10] if len(body) > 10 else 0
              print("after claim flags(bit0=claimed) =", flags)
      
          print("[+] pay & mail flow OK")
          w.close()
          await w.wait_closed()
      
      
      def main():
          ap = argparse.ArgumentParser()
          ap.add_argument("--host", default="127.0.0.1")
          ap.add_argument("--port", type=int, default=8888)
          ap.add_argument("--user", default="payer")
          ap.add_argument("--password", default="123456")
          ap.add_argument("--name", default="PayHero")
          ap.add_argument("--product", default="com.demo.diamond_60")
          ap.add_argument("--config", default="./config/config.yaml")
          args = ap.parse_args()
          asyncio.run(run(args))
      
      
      if __name__ == "__main__":
          main()
  • templates
    • register-site
      • index.html 15.8 KB · in bundle
      • README.md 2.6 KB
        # 注册网站模板(通用 · 响应式)
        
        成品页面:`index.html`(自包含,无 CDN)。后端:`server.py`(最小 Flask)。
        
        ## 特点
        - **注册 / 登录**切换、密码强度、显示/隐藏密码、深/浅色主题、表单校验。
        - **响应式**:桌面左右分栏;手机隐藏左栏、表单铺满(含安全区)。
        - 借用了成熟注册页的通用做法:**注册引导区**、**账号前缀**(如 `svr_`)、
          **第三方校验字段**、成功后**醒目标出账号 + 复制**、底部**注意事项**。
        
        ## 注册校验:三选一(`CFG.verifyMode` / 环境变量 `VERIFY_MODE`)
        | 模式 | 前端字段 | 服务端校验 |
        |------|---------|-----------|
        | `invite`(默认) | 邀请码 | 命中 `invite_code` 表:未过期、`used<quota`;成功后 `used+1` |
        | `group` | QQ 号 | 命中 `group_members` 表(可批量导入群成员) |
        | `whitelist` | 账号 | 命中 `whitelist` 表 |
        | `none` | 无 | 不校验 |
        
        > 新玩家用该码注册。奖励闭环:`注册 → 得码 → 邀请 → 新人注册 → 码 used+1`。
        
        ## 可配置项(页面 `CFG` 一处改完)
        | 键 | 作用 |
        |----|------|
        | `appName/title/sub/hero*` | 品牌与文案 |
        | `prefix` | 账号前缀;留空 `""` 即无前缀 |
        | `userLabel/userHint/userPattern` | 账号标签/提示/正则 |
        | `guide/guideTitle` | 注册引导(空数组隐藏) |
        | **`verifyMode` + `verify{}`** | **三选一校验**(label/placeholder/pattern/hint/key) |
        | `notes` | 注意事项 |
        | `apiRegister/apiLogin` | 后端地址 |
        
        ## 运行
        ```bash
        pip install flask
        # 邀请码模式:
        GAME_DB=../server/data/game.db PWD_SALT=你的盐 VERIFY_MODE=invite ACCOUNT_PREFIX=svr_ python3 server.py
        # QQ群模式:VERIFY_MODE=group   白名单模式:VERIFY_MODE=whitelist
        # 新用户自动带一个邀请码:AUTO_GRANT_INVITE=1
        ```
        浏览器打开 `http://127.0.0.1:8080/`。
        
        ## 接口契约(可换 FastAPI/Express)
        ```
        POST /api/register {username,password,confirm?,verify_mode?,invite?,qq?} -> {ok:true,username:"svr_xxx"} / 4xx {ok:false,msg}
        POST /api/login    {username,password}                                    -> {ok:true} / 401 {ok:false,msg}
        ```
        
        ## 表(与游戏服同一 DB)
        ```
        account(username, password_hash)
        group_members(qq PK, note)                                              # QQ群名单
        whitelist(username PK)                                                  # 白名单
        ```
        
        > 注意:若客户端发包前先处理密码(如 `MD5(pwd+salt)`),注册站必须用**同一套哈希**,否则注册的密码登不上(`references/account.md §16.2`)。
      • server.py 6.2 KB
        #!/usr/bin/env python3
        # -*- coding: utf-8 -*-
        """
        注册网站最小后端(Flask + SQLite)。
        
        要点(否则"注册的账号登不上"):
         1. 与游戏服**共用同一份数据库**(同 account 表)—— references/account.md §16.5;
         2. 与客户端**复用同一套密码哈希** —— references/account.md §16.2。
        
        注册校验:三选一(环境变量 VERIFY_MODE)
          invite    邀请码:必须提交 invite,且命中 invite_code 表(未过期、未用尽),成功后 used+1;
          group     QQ群验证:必须提交 qq,且命中 group_members 表(可用文件导入);
          whitelist 白名单:username 必须在 whitelist 表内;
          none      不校验。
        
          account(id, username, password_hash)
          invite_code(code PK, created_by, quota, used, expire_at, created_at)
          group_members(qq PK, note)
          whitelist(username PK)
        
        运行:
          pip install flask
          GAME_DB=../server/data/game.db PWD_SALT=你的盐 VERIFY_MODE=invite ACCOUNT_PREFIX=svr_ python3 server.py
        接口:
          POST /api/register {username,password,confirm?,verify_mode?,invite?,qq?} -> {ok:true,username:"svr_xxx"}
          POST /api/login    {username,password}                                    -> {ok:true}
        """
        import os
        import time
        import sqlite3
        import hashlib
        
        from flask import Flask, request, jsonify, send_from_directory
        
        DB = os.environ.get("GAME_DB", "./data/game.db")
        SALT = os.environ.get("PWD_SALT", "")
        HASH_MODE = os.environ.get("HASH_MODE", "md5")          # md5 | sha256 | plain
        PREFIX = os.environ.get("ACCOUNT_PREFIX", "svr_")        # 账号前缀;不需要设空串
        VERIFY_MODE = os.environ.get("VERIFY_MODE", "invite")    # invite | group | whitelist | none
        AUTO_GRANT_INVITE = os.environ.get("AUTO_GRANT_INVITE", "0") == "1"  # 新用户注册后自带一个邀请码
        SITE_DIR = os.path.dirname(os.path.abspath(__file__))
        
        app = Flask(__name__)
        
        
        def client_side_hash(pwd: str) -> str:
            if HASH_MODE == "plain":
                return pwd
            raw = (pwd + SALT).encode("utf-8")
            return hashlib.md5(raw).hexdigest() if HASH_MODE == "md5" else hashlib.sha256(raw).hexdigest()
        
        
        def db() -> sqlite3.Connection:
            c = sqlite3.connect(DB)
            c.executescript("""
              CREATE TABLE IF NOT EXISTS account(id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL);
              CREATE TABLE IF NOT EXISTS invite_code(code TEXT PRIMARY KEY, created_by TEXT, quota INTEGER DEFAULT 1, used INTEGER DEFAULT 0, expire_at INTEGER DEFAULT 0, created_at INTEGER DEFAULT 0);
              CREATE TABLE IF NOT EXISTS group_members(qq TEXT PRIMARY KEY, note TEXT);
              CREATE TABLE IF NOT EXISTS whitelist(username TEXT PRIMARY KEY);
            """)
            return c
        
        
        def gen_code() -> str:
            import secrets
            return secrets.token_hex(4).upper()   # 8位大写16进制,如 9F3A1C2E
        
        
        def make_invite(c, created_by, quota=1, days=7):
            code = gen_code()
            now = int(time.time())
            c.execute("INSERT OR IGNORE INTO invite_code(code,created_by,quota,used,expire_at,created_at) VALUES(?,?,?,0,?,?)",
                      (code, created_by, quota, 0 if days <= 0 else now + days * 86400, now))
            return code
        
        
        @app.get("/")
        def index():
            return send_from_directory(SITE_DIR, "index.html")
        
        
        @app.post("/api/register")
        def register():
            d = request.get_json(silent=True) or {}
            u = (d.get("username") or "").strip()
            p = d.get("password") or ""
            confirm = d.get("confirm")
            mode = (d.get("verify_mode") or VERIFY_MODE)
        
            if not (3 <= len(u) <= 16) or not all(ch.isalnum() or ch == "_" for ch in u):
                return jsonify(ok=False, msg="账号需为 3-16 位字母/数字/下划线"), 400
            if not (6 <= len(p) <= 64):
                return jsonify(ok=False, msg="密码需为 6-64 位"), 400
            if confirm is not None and confirm != p:
                return jsonify(ok=False, msg="两次输入的密码不一致"), 400
        
            c = db()
            try:
                # —— 校验(三选一)——
                if mode == "invite":
                    code = (d.get("invite") or "").strip().upper()
                    row = c.execute("SELECT quota,used,expire_at FROM invite_code WHERE code=?", (code,)).fetchone()
                    if not row:
                        return jsonify(ok=False, msg="邀请码无效"), 403
                    quota, used, expire_at = row
                    if expire_at and expire_at < int(time.time()):
                        return jsonify(ok=False, msg="邀请码已过期"), 403
                    if used >= quota:
                        return jsonify(ok=False, msg="邀请码已被使用"), 403
                    c.execute("UPDATE invite_code SET used=used+1 WHERE code=?", (code,))
                elif mode == "group":
                    qq = (d.get("qq") or "").strip()
                    if not c.execute("SELECT 1 FROM group_members WHERE qq=?", (qq,)).fetchone():
                        return jsonify(ok=False, msg="该QQ号不在玩家群名单内"), 403
                elif mode == "whitelist":
                    if not c.execute("SELECT 1 FROM whitelist WHERE username=?", (u,)).fetchone() \
                       and not c.execute("SELECT 1 FROM whitelist WHERE username=?", (PREFIX + u,)).fetchone():
                        return jsonify(ok=False, msg="该账号不在白名单内"), 403
                # none: 不校验
        
                # —— 建号 ——
                full = PREFIX + u
                c.execute("INSERT INTO account(username,password_hash) VALUES(?,?)", (full, client_side_hash(p)))
                # 可选:新用户自带一个邀请码("已注册用户可生成"的最小实现)
                if AUTO_GRANT_INVITE:
                    make_invite(c, full, quota=1, days=7)
                c.commit()
                return jsonify(ok=True, username=full)
            except sqlite3.IntegrityError:
                return jsonify(ok=False, msg="用户名已存在"), 409
            finally:
                c.close()
        
        
        @app.post("/api/login")
        def login():
            d = request.get_json(silent=True) or {}
            u = (d.get("username") or "").strip()
            p = d.get("password") or ""
            full = u if u.startswith(PREFIX) else PREFIX + u
            try:
                c = db()
                row = c.execute("SELECT password_hash FROM account WHERE username=?", (full,)).fetchone()
                c.close()
                ok = bool(row) and row[0] == client_side_hash(p)
                return jsonify(ok=ok, msg="" if ok else "账号或密码错误"), (200 if ok else 401)
            except Exception as e:
                return jsonify(ok=False, msg=str(e)), 500
        
        
        if __name__ == "__main__":
            app.run(host="0.0.0.0", port=8080)
    • xposed-redirect
      • AndroidManifest.xml 583 B · in bundle
      • build.gradle 302 B · in bundle
      • MainHook.java 4.6 KB · in bundle
      • README.md 2.6 KB
        # xposed-redirect —— 客户端重定向模块模板(Java / OkHttp 客户端)
        
        > 目的:**把游戏客户端连的服务器,换成我们自建的服务端**(skill「第三步 重定向」)。
        > 这是**模板/骨架**,不是成品。按目标客户端改 `TARGET_PKGS` 与 hook 点即可。
        
        ## 什么时候用它 / 用哪个
        
        | 客户端网络栈 | 用哪个模板 |
        |--------------|-----------|
        | Java / OkHttp / HttpURLConnection(原生 Android) | **本模块**(`MainHook.java`) |
        | native / il2cpp / Unity(socket 直连) | `templates/frida-redirect.js`(本模块 hook 不到 native) |
        
        > 拿不准就先跑一遍抓包:**能抓到明文 URL** → Java 层;**只有裸字节/连 IP** → native 层。
        
        ## 文件
        
        ```
        xposed-redirect/
        ├── MainHook.java        核心:读配置 + 改写 URL(okhttp / java.net.URL)
        ├── AndroidManifest.xml  模块声明(xposedmodule / scope)
        ├── xposed_init          入口类名(assets/xposed_init)
        ├── redirect_config.txt  配置模板(adb push 到 /data/local/tmp/)
        └── build.gradle         依赖(compileOnly Xposed API)
        ```
        
        ## 用法
        
        1. 改 `MainHook.java`:
           - `TARGET_PKGS` 填目标游戏包名;
           - 需要的话补 hook 点(见下)。
        2. 把 `MainHook.java` / `AndroidManifest.xml` / `xposed_init` / `build.gradle` 放进一个 Android 工程编译成模块 APK。
        3. 装模块 → 在 **LSPosed** 里**勾选目标游戏** → 重启游戏。
        4. 配置重定向:
           ```bash
           adb push redirect_config.txt /data/local/tmp/redirect_config.txt
           # 改完重启游戏生效
           ```
        5. 看日志:`adb logcat | grep '\[rdr\]'`(应见 attach + 每条改写)。
        
        ## 无 root 也能用
        
        用 **LSPatch** 把模块**修补进**目标 APK(或 `NPatch`),无需 root/框架。
        
        ## 常见 hook 点(按需补进 MainHook)
        
        | 客户端用的 | 建议 hook |
        |-----------|----------|
        | OkHttp | `okhttp3.Request$Builder.url(String)`(已含) |
        | HttpURLConnection | `java.net.URL(String)`(已含) |
        | 自研 URL 工具 / 配置里的 baseUrl | hook 对应的 `getXxxUrl()` / `setHost()` |
        | Unity 的 `UnityWebRequest`(Java 桥) | `com.unity3d.player.UnityPlayer` 相关方法 |
        | 服务器地址存在 SharedPreferences | 直接改该 key(见 `client-address-sources.md §3.1`) |
        | **native socket** | 换 `frida-redirect.js`(hook `getaddrinfo`/`connect`) |
        
        ## 边界与风险
        
        - 这是**换服务端**的正当用途(自研/已授权/离线自测)。
        - 注入可能触发客户端保护;被秒退/黑屏时先把注入摘掉定位。
        - 端口改写用**正则**只是简化实现,跨端口不一致时请改成按 `URI` 精确重建。
        
      • redirect_config.txt 129 B
        # 原host=我们的host/IP
        api.example.com=192.168.1.100
        game.example.com=192.168.1.100
        # 可选:全局端口覆盖
        #PORT=8888
        
      • xposed_init 26 B · in bundle
    • adr-template.md 2.8 KB
      # 架构决策记录 NNN:<一句话标题,如「SDK 局域网化,热更新保留原站」>
      
      > **分类**:架构决策记录 (ADR)
      > **状态**:提议 (Proposed) / 已采纳 (Adopted) / 已废弃 (Deprecated) / 被取代 (Superseded by NNN)
      > **日期**:YYYY-MM-DD
      
      ---
      
      ## 状态
      
      <已采纳 / 待验证 / 仅适用于第 N 阶段。>
      
      ## 决策
      
      <用 2~3 句写清「我们决定怎么做」。必须是可执行的动作,不是愿景。>
      
      示例:
      > 先将 SDK 登录和用户会话能力兼容到局域网 Python 服务端,再评估客户端域名重定向。
      > 热更新资源(YooAsset / HotUpdate)继续使用原始 CDN。
      
      ## 原因
      
      <为什么这么定。每条都要挂证据档位:>
      
      - **Confirmed by static analysis**:<反编译/协议定义里看到的>
      - **Confirmed by packet capture or runtime observation**:<抓包/设备日志里看到的>
      - **Inferred and still requiring validation**:<推断,还没实机验证的>
      
      示例:
      > - SDK AES 协议和登录接口已经从 smali 中恢复。(static analysis)
      > - 本地服务端已能完成登录/Token/资料/系统信息的最小链路。(runtime observation)
      > - 热更新域名的完整路径、Manifest 和版本规则尚未确认 → **协议不同、故障边界不同,
      >   应该分开验证。**(inferred)
      
      ## 当前不做的事情
      
      >  这一节最重要:**明确划出不碰的东西**,防止范围膨胀。
      
      - 不修改 APK。
      - 不替换 SDK 内置域名 / 不清除域名缓存。
      - 不把 CDN 域名指向局域网。
      - 不实现充值 / 邮件 / VIP / 交易等**尚未确认**的接口。
      - 不改动运行中的服务进程(改动需重启才生效,见 `verify/log.md`)。
      
      ## 后续接入条件
      
      > 满足**全部**条件才推进到下一步。写不清楚 = 不该推进。
      
      1. 服务端协议测试持续通过(测试清单:____)。
      2. **等价加密请求**能在局域网服务端完成登录。
      3. 备用域名与域名缓存行为已确认。
      4. 确认热更新地址不需要同步修改。
      5. 已准备**可回滚**的客户端修改方案 + 验证步骤。
      
      ## 风险
      
      - 热更新后的模块可能调用当前未实现的接口。
      - 服务端返回的最小模型可能不足以覆盖所有 UI/业务分支。
      - 原服务端可能在登录后通过其他接口校验 Token / 资格 / 版本。
      - CDN 可能使用动态路径、签名、平台参数或版本校验。
      
      ## 回滚
      
      <写清「怎么退回去」。分两种情况:>
      
      - **本阶段未修改客户端** → 回滚 = 停止本地服务 + 恢复客户端原始域名配置(`____`)。
      - **后续若修改客户端** → 必须单独记录:补丁文件、目标类/方法、字节偏移、还原命令、基线哈希。
      
      ```
      # 回滚命令(示例,按实际填)
      cp <backup> <target>
      sha256sum -c <baseline.sha256>
      ```
    • AGENTS.md 5.3 KB
      # AGENTS.md —— 工作区 AI 协作契约(模板)
      
      > **用法**:把这份文件放到**项目根目录**,按实际内容替换 `<>` 占位符。
      > 它的作用是:**下一个 AI(或人)打开这个工作区时,不需要重新问一遍边界。**
      >
      > 三个已跑通的成品服务端都在仓库根放了等价文件 —— 这是它们「交接成本低」的关键原因。
      > 建议在项目第 0 天就建立,并随项目演进更新。
      
      ---
      
      ## 项目范围
      
      <一句话说清这是什么。>
      
      - **工作区根目录**:包含 `<server/>`、`<tools/>`、`<docs/>`、`<tests/>` 的目录
      - **目标**:<例:客户端 ↔ 自建服务端 互操作性研究与本地部署>
      - **目标客户端**:`<包名 / 版本>`
      - **本仓库包含**:<服务端源码、工具、文档>
      - **本仓库不包含**:<原始安装包、游戏资源、账号数据、密钥 —— 由使用者自备>
      
      ---
      
      ## 重要边界与合规
      
      - **不提交专有二进制**:原始安装包、美术/音频资源、`build/`、`dist/` 一律 ignore。
      - **工具只操作用户提供的原始包**:输出到独立的 `dist/`,不覆盖输入。
      - **热更/CDN 隔离**:`<CDN 域名>` 视为**上游**,热更流量与本地业务服务**分开**,不指向局域网。
      - **脱敏**:不要硬编码真实用户名、本机绝对路径、私网 IP(用 `127.0.0.1` / `192.168.1.100`)、
        真实凭据或 token。抓包与日志进入文档前先脱敏。
      - **非破坏性版本控制**:不要在公共分支上执行 `git reset --hard` 之类破坏性命令。
      
      ---
      
      ##  目录分区(读 / 写分离)
      
      ### 输入与分析目录(**只读**)
      
      ```
      <input_dir>/        反编译产物 / 解包结果(分析输入,ignore)
      <dump_dir>/         SDK dump / 元数据 dump(分析输入,ignore)
      <runtime_dir>/      运行时缓存、设备抓取的数据(分析输入,ignore)
      ```
      
      > **不要修改输入目录里的任何东西**。所有改动发生在输出目录或副本上。
      
      ### 产出目录
      
      ```
      src/ 或 server/     服务端源码
      tools/              辅助脚本与工具
      docs/               结构化文档(见下方文档规范)
      tests/              自动化测试
      build/              构建中间产物(ignore)
      dist/               最终导出的签名包与摘要(ignore)
      data/               数据库、抓包、日志(ignore)
      ```
      
      ---
      
      ## 一键入口
      
      ```
      <入口脚本 1>   启动
      <入口脚本 2>   停止
      <入口脚本 3>   打包/打补丁
      <入口脚本 4>   管理 CLI
      ```
      
      ---
      
      ## 代码与编辑规范
      
      - JSON / SQLite / protobuf wire / 包元数据 尽量用**结构化解析器**,不要正则硬切。
      - 全仓库使用**相对路径**(代码、脚本、文档链接)。
      - 代码标识符与文件路径用 ASCII;文档可写中文。
      - **改动协议或状态模型时,必须同步更新 `tests/`**。
      
      ---
      
      ## 验证命令
      
      ```bash
      # 从工作区根目录执行
      <测试命令>
      <编译检查命令>
      <端到端冒烟客户端命令>
      ```
      
      - 服务 A:`<协议/端口>`
      - 服务 B:`<协议/端口>`
      - 默认测试账号:`<user / password>`
      
      > 注意: 环境权限失败(如回环端口不可用)**不能记作通过** —— 换环境重跑同一条命令。
      
      ---
      
      ## 证据档位(每个结论都要挂)
      
      - **Confirmed by static analysis** —— 反编译代码 / 协议定义 / 服务端代码直接确认
      - **Confirmed by packet capture or runtime observation** —— pcap、重组帧、logcat、运行时数据库确认
      - **Inferred and still requiring validation** —— 由前两者推断,仍需实机验证
      
      > 不确定的写 `Inferred`,**不要写「已实现」**。
      
      ---
      
      ## 文档规范
      
      ```
      docs/analysis/      逆向与协议分析
      docs/implementation/ 服务端实现说明
      docs/decisions/     架构决策记录(ADR)
      docs/evidence/      实机验收证据
      docs/status/        支持矩阵 / 已知问题 / 验收进度
      docs/todo/          兼容性清单与验收标准
      ```
      
      写作纪律:
      
      - **已修复的问题交给版本控制**,不要继续堆在「已知问题」里;
      - 「已实现但未验收」必须单列,不能混进「已通过」;
      - 每个模块文档末尾都要有「**本模块不包含**」。
      
      ---
      
      ## AI 工作时必须遵守的 8 条
      
      1. **先规格后代码**:没有 `protocol.spec` / 项目档案 / 证据清单,不要写业务代码。
      2. **不确定就标注**,不要猜字段、不要猜枚举语义;不支持的输入 **fail closed**。
      3. **改动只在副本上做**,保留原始文件哈希与回滚命令。
      4. **声明"通过"必须有产物**(日志、截图、trace、测试输出),不接受口头结论。
      5. **一轮只改一个变量**,否则无法归因。
      6. **改了配置要重启**(启动期冻结,运行中修改不生效)。
      7. **不要为客户端不可能产生的状态**编假想业务流程 —— 标为 `server-boundary` 或待抓包。
      8. **离开前更新**:`TRACKER`/支持矩阵、`docs/status/known-issues`、以及本文末尾的「当前环境状态」。
      
      ---
      
      ## 当前环境状态(每次收工前更新)
      
      ```
      本地服务端: <未运行 / 运行中 + 端口>
      客户端:     <未安装 / 已安装 + 包名 + 是否改包>
      已生效的重定向:<无 / uid=N 的 DNAT 规则 / 地址文件>
      最近一次验证:<日期 + 结论>
      已知未完成:  <条目>
      ```
    • e2e-evidence-template.md 4.2 KB
      # P0-X 本地设备端到端验证
      
      > **分类**:实机与分析证据 (Evidence)
      > **状态**:待验证 (Pending) / **已确认 (Confirmed by device E2E runtime)**
      > **验证日期**:YYYY-MM-DD
      > **环境**:设备 / ADB 地址 / 本地服务端口 / fixture 路径 / trace 路径
      
      ---
      
      ## 结论
      
      **<档位标注>**:<一句话说清跑到哪一步。必须点名**客户端最终停留的界面**。>
      
      示例:
      > **Confirmed by packet capture or runtime observation**:本地 SDK、区服列表、
      > 游戏 TCP 登录、启动数据和主界面链路已在设备上完成**两次**,均通过。
      > **设备最终停留在游戏主界面,不是只保持 TCP 连接。**
      
      ```
      <完整链路,逐消息号写出>
      server/list -> SDK token validate -> CSLoginReq(3)
       -> SCHandShakeNtf(7) -> SCLoginAck(4)
       -> SCStartupInfoNtf(25) x4 -> SCStartupInfoEquipNtf(26)
       -> SCStartupInfoHeroNtf(27) -> SCStartupInfoEndNtf(28)
       -> 主界面初始化请求 -> 主界面
      ```
      
      > 说明:第二次是**强制停止应用后重启**再点「开始游戏」;两次都要记录。
      > 注意: 若设备时钟与记录日期不一致,注明「结论按帧顺序与状态结果判断,不依赖时钟」。
      
      ---
      
      ## 环境
      
      - APK 包名:`____`
      - 设备:`____`(ADB 地址 / 模拟器)
      - 测试账号:`____`(`user_id=__`)
      - 转发:设备 `____` → 开发机 `____`
      - fixture:`____`
      - trace:`____`;本轮筛选摘要:`____`
      
      ---
      
      ## 首次启动
      
      **<档位>**:服务端 trace 记录的登录字段:`open_id=__`、`account=__`、`auth_type=__`;
      登录被接受,角色匹配到 `role_id=__`。
      
      | 方向 | 消息号 | body 长度 |
      | --- | ---: | ---: |
      | C -> S | 3 | ___ |
      | S -> C | 7 | ___ |
      | S -> C | 4 | ___ |
      | S -> C | 25 | ___ |
      | S -> C | 25 | ___ |
      | S -> C | 26 | ___ |
      | S -> C | 27 | ___ |
      | S -> C | 28 | ___ |
      
      ### 与原始抓包的差异( 必填)
      
      > 主动解释「哪些长度**必然**不同」,否则会被误判成 bug。
      
      | 项 | 原始抓包 | 本地 | 为什么不同 / 是否阻塞 |
      |---|---|---|---|
      | 登录 body | ___ | ___ | 本地账号与本地轨迹不同 → **必然不同,不阻塞** |
      | 第一段 `25` | ___ | ___ | 被本地持久化角色状态与动态字段改写 |
      | 其余 `25`/`26`/`27`/`28` | ___ | ___ | 长度一致 |
      | 辅助通知(如 `24176`/`376`) | 有其他 | 本地未发 | 客户端仍能完成初始化 → **非阻塞** |
      
      ---
      
      ## 主界面与重连
      
      **<档位>**:收到 `28` 后,设备继续发起并收到:
      
      ```
      <列出消息号对,如 23/24, 349/350, 170/171, ..., 1/2 heartbeat>
      ```
      
      - 截图 `____` 显示主界面已加载:等级 `Lv.__`、金币 `__`、钻石 `__`、体力 `__/__`
      - logcat `____` 记录到 `<关键日志行>`
      
      随后执行(**必须做**):
      
      1. 强制停止 `____`,断开已有连接。
      2. 重启应用,SDK token 校验成功,重新显示区服。
      3. 再次点「开始游戏」,服务端**第二次**接受登录。
      4. 第二轮再次收到完整启动序列并进入主界面。
      
      ---
      
      ## 状态持久化
      
      **<档位>**:断线前后角色状态字段保持一致。
      
      | 字段 | 断线前 | 重连后 |
      | --- | ---: | ---: |
      | 昵称 | | |
      | 等级 | | |
      | 金币 | | |
      | 钻石 | | |
      | 编队 | | |
      | 关卡进度 | | |
      | 教程状态条目 | | |
      
      > 若某版本号(如 `role_version`)增长,**说明增长原因**(例:客户端重连后重发教程保存请求),
      > 并强调「上述业务字段没有变化」。
      
      快照文件:`____before.json` / `____after.json`
      
      ---
      
      ## 非阻塞问题
      
      **<档位>**:主界面显示**之后**客户端请求 `____`,当前返回 `404`。
      该请求发生在主界面已显示之后,**不影响本次验收点**;后续按 P1 接口工作补齐。
      
      > 这一节必须写。它把「已知未做」和「本次失败」分开,避免下次回归时重复排查。
      
      ---
      
      ## 证据文件
      
      ```
      设备日志        ____
      重连设备日志    ____
      首次 trace      ____
      两次 trace 摘要 ____
      首次截图        ____
      重连截图        ____
      服务端完整日志  ____
      ```
      
      ---
      
      ## 复现命令
      
      ```bash
      # 1. 起服务
      ____
      
      # 2. 转发 / 重定向
      ____
      
      # 3. 启动客户端并观察
      ____
      
      # 4. 抓 trace 与 logcat
      ____
      
      # 5. 收尾(还原网络规则)
      ____
      ```
    • frida-redirect.js 3.4 KB
      /*
       * frida-redirect.js —— 把游戏客户端的网络目标,改到我们自建的服务端
       *
       * 适用:native / il2cpp / Unity 客户端(点这里最通用)。
       *      Java/OkHttp 客户端也可用(getaddrinfo/connect 一样生效),
       *      若要更精细地按 URL 改,用 templates/xposed-redirect/。
       *
       * 用法:
       *   frida -U -f <游戏包名> -l frida-redirect.js
       *   (有 root:frida-server;无 root:LSPatch + Frida gadget)
       *
       * 注意:这是"换服务端"的注入手法之一,属于 skill 的「第三步 重定向」。
       *       注入可能被客户端保护检测;自测目标再用。
       */
      
      // ===== 配置:原 host  ->  我们的 host/IP =====
      const HOST_MAP = {
        "api.example.com":  "192.168.1.100",
        "game.example.com": "192.168.1.100",
        "login.example.com":"192.168.1.100",
      };
      // 端口覆盖:原端口 -> 新端口(不需要就留空 {})
      const PORT_MAP = {
        // 8888: 8888,
      };
      const DEBUG = true;
      
      function log(s){ if (DEBUG) console.log("[rdr] " + s); }
      function mapHost(h){ return (h && HOST_MAP[h]) ? HOST_MAP[h] : null; }
      
      /* ---------------------------------------------------------------
       * 1) getaddrinfo —— 域名解析层(覆盖面最广,建议先开这个)
       *    把"原域名"解析成"我们的 IP",客户端后续 connect 就打到我们这边。
       * ------------------------------------------------------------- */
      (function hookGetaddrinfo(){
        const p = Module.findExportByName(null, "getaddrinfo");
        if (!p) { log("getaddrinfo not found"); return; }
        Interceptor.attach(p, {
          onEnter(args){
            this.node = args[0].isNull() ? null : args[0].readCString();
            const to = mapHost(this.node);
            if (to) { args[0] = Memory.allocUtf8String(to); this.to = to; }
          },
          onLeave(ret){
            if (this.to) log("getaddrinfo " + this.node + " -> " + this.to);
          }
        });
      })();
      
      /* ---------------------------------------------------------------
       * 2) connect —— 客户端直接连 IP 的情况
       *    仅当"目标端口命中 PORT_MAP"时才改写,避免误伤其它连接。
       * ------------------------------------------------------------- */
      (function hookConnect(){
        const p = Module.findExportByName(null, "connect");
        if (!p) { log("connect not found"); return; }
        Interceptor.attach(p, {
          onEnter(args){
            const sa = args[1];
            if (sa.isNull()) return;
            const fam = sa.readU16();
            if (fam === 2) { // AF_INET
              const port = (sa.add(2).readU8() << 8) | sa.add(3).readU8();
              const b = sa.add(4);
              const ip = b.readU8()+"."+b.add(1).readU8()+"."+b.add(2).readU8()+"."+b.add(3).readU8();
              const newPort = PORT_MAP[port];
              if (newPort) {
                sa.add(2).writeU8((newPort >> 8) & 0xff);
                sa.add(3).writeU8(newPort & 0xff);
                log("connect " + ip + ":" + port + " -> :" + newPort + " (需配合 hosts/DNAT 换 IP)");
              }
            }
          }
        });
      })();
      
      /* ---------------------------------------------------------------
       * 3) 可选:HTTPS 降级 / 证书校验绕过(当原协议是 TLS 且无本地证书时)
       *    见 templates/frida_bypass_ssl.js;原协议是 TLS 时也可改走本地 CA。
       * ------------------------------------------------------------- */
      // require 不对,这里只是提示:
      //   frida -U -f <pkg> -l templates/frida_bypass_ssl.js -l frida-redirect.js
      
      log("frida-redirect loaded, host rules = " + JSON.stringify(HOST_MAP));
      
    • frida_bypass_ssl.js 2.5 KB
      /*
       * 证书固定(Pinning)绕过 —— 仅用于自测/已授权目标
       * 用法: frida -U -f <package> -l frida_bypass_ssl.js
       *       frida -p <pid> -l frida_bypass_ssl.js
       */
      Java.perform(function () {
          // 1) 通用 TrustManager
          try {
              var X509TrustManager = Java.use('javax.net.ssl.X509TrustManager');
              var TrustManager = Java.registerClass({
                  name: 'com.research.TrustAll',
                  implements: [X509TrustManager],
                  methods: {
                      checkClientTrusted: function () {},
                      checkServerTrusted: function () {},
                      getAcceptedIssuers: function () { return []; }
                  }
              });
              var SSLContext = Java.use('javax.net.ssl.SSLContext');
              var ctx = SSLContext.getInstance('TLS');
              ctx.init(null, [TrustManager.$new()], null);
              var SSLSocketFactory = Java.use('javax.net.ssl.SSLSocketFactory');
              var factory = ctx.getSocketFactory();
              SSLContext.init.overload('[Ljavax.net.ssl.KeyManager;',
                  '[Ljavax.net.ssl.TrustManager;', 'java.security.SecureRandom')
                  .implementation = function (k, t, s) {
                      this.init(k, [TrustManager.$new()], s);
                  };
              console.log('[+] TrustManager bypass installed');
          } catch (e) { console.log('[-] TrustManager: ' + e); }
      
          // 2) OkHttp CertificatePinner
          try {
              var Pinner = Java.use('okhttp3.CertificatePinner');
              Pinner.check.overload('java.lang.String', 'java.util.List').implementation = function () {
                  console.log('[+] OkHttp pinner bypass: ' + arguments[0]);
              };
          } catch (e) { console.log('[-] OkHttp: ' + e); }
      
          // 3) 常见自研校验函数(按需替换类名/方法名)
          var hooks = [
              ['javax.net.ssl.HttpsURLConnection', 'setDefaultHostnameVerifier'],
              ['android.net.http.X509TrustManagerExtensions', 'checkServerTrusted']
          ];
          hooks.forEach(function (h) {
              try {
                  var C = Java.use(h[0]);
                  if (C[h[1]]) C[h[1]].implementation = function () { return true; };
                  console.log('[+] hooked ' + h[0] + '.' + h[1]);
              } catch (e) {}
          });
      });
      
      // 4) Native 层(libssl / BoringSSL)
      try {
          var SSL_CTX_set_verify = Module.findExportByName(null, 'SSL_CTX_set_verify');
          if (SSL_CTX_set_verify) {
              Interceptor.replace(SSL_CTX_set_verify, new NativeCallback(function () {
                  return;
              }, 'void', ['pointer', 'int', 'pointer']));
              console.log('[+] native SSL_CTX_set_verify bypass');
          }
      } catch (e) { console.log('[-] native: ' + e); }
    • function-checklist.md 3.5 KB
      # 功能清单(Function Checklist)— <游戏名>
      
      > **用途**:把"哪些功能通了、哪些没通"写成一张可维护的表,**反复迭代直到基本完善**。
      > 与 `TRACKER.md`(进度/接口)、`docs/status/support-matrix.md`(三轴状态)保持同步。
      > **配套**:`references/workflow-roadmap.md` 阶段 4、`references/live-ops.md`。
      
      > **状态图例**:`[x] 已实现` / `[~] 部分` / `[ ] 未做` / `[?] 未验证` / `[-] 游戏本身没有`
      
      > 注意: **三件事分开记**(铁律 5):**服务端实现了** / **自动测试覆盖了** / **客户端验收了**。
      > 只用一列会掩盖后两项。下表用三列区分。
      
      ---
      
      ## 0. 总览
      
      ```
      已完成:<n>    部分:<n>    未做:<n>    未验证:<n>
      主线进度:登录 [x] → 选服 [ ] → 进场景 [ ] → 战斗 [ ] → 结算 [ ]
      更新时间:YYYY-MM-DD
      ```
      
      ---
      
      ## 1. 主流程(优先级最高,先全通)
      
      | 功能 | 协议号 / 接口 | 服务端实现 | 自动测试 | 客户端验收 | 备注 |
      |------|--------------|-----------|---------|-----------|------|
      | 版本/公告 | `GET /notice` | [ ] | [ ] | [ ] | |
      | 登录 | `player.Login` | [ ] | [ ] | [ ] | |
      | 服务器列表 | `getServerList` | [ ] | [ ] | [ ] | |
      | 握手 | `0x0001/0x0002` | [ ] | [ ] | [ ] | |
      | 建角/选角 | `0x0103…` | [ ] | [ ] | [ ] | |
      | 进场景 | `0x0201…` | [ ] | [ ] | [ ] | |
      | 心跳 | `0x00FF` | [ ] | [ ] | [ ] | |
      | 移动 | `0x0301…` | [ ] | [ ] | [ ] | |
      
      ---
      
      ## 2. 按子系统(用 `tools/extract_interfaces.py` 的清单逐条登记)
      
      > 前缀聚类 = 子系统划分。常见顺序:主流程 → 周边。
      
      ### 账号
      | 功能 | 协议号 | 服务端实现 | 自动测试 | 客户端验收 | 备注 |
      |------|--------|-----------|---------|-----------|------|
      | 注册 | | [ ] | [ ] | [ ] | 密码预处理见 `account.md` |
      
      ### 角色 / 背包
      | 功能 | 协议号 | 服务端实现 | 自动测试 | 客户端验收 | 备注 |
      |------|--------|-----------|---------|-----------|------|
      | 背包列表 | | [ ] | [ ] | [ ] | |
      
      ### 房间 / 战斗 / 掉落
      | 功能 | 协议号 | 服务端实现 | 自动测试 | 客户端验收 | 备注 |
      |------|--------|-----------|---------|-----------|------|
      | 建房 | | [ ] | [ ] | [ ] | |
      | 战斗结算 | | [ ] | [ ] | [ ] | 服务端权威 |
      | 掉落入库 | | [ ] | [ ] | [ ] | 背包满→邮件 |
      
      ### 抽卡 / 商店
      | 功能 | 协议号 | 服务端实现 | 自动测试 | 客户端验收 | 备注 |
      |------|--------|-----------|---------|-----------|------|
      | | | [ ] | [ ] | [ ] | 与掉落共用入库 |
      
      ### 充值 / 邮件
      | 功能 | 协议号 | 服务端实现 | 自动测试 | 客户端验收 | 备注 |
      |------|--------|-----------|---------|-----------|------|
      | 商品列表 | | [ ] | [ ] | [ ] | |
      | 下单 | | [ ] | [ ] | [ ] | 自托管直成功 |
      | 邮件领取 | | [ ] | [ ] | [ ] | |
      
      > 其余子系统(活动/家族/好友/排行…)按需增段。
      
      ---
      
      ## 3. 未实现 / 未验证清单
      
      ```
      [ ] <功能> —— 阻塞原因:<缺证据 / 缺逻辑 / 优先级低>
      [?] <功能> —— 已实现但未在客户端验证
      [-] <功能> —— 游戏本身没有(不要凭空造)
      ```
      
      ---
      
      ## 4. 迭代日志
      
      | 日期 | 完成 | 现象 / 教训 |
      |------|------|------------|
      | | | |
      
      ---
      
      > 注意: **每完成一个大模块就更新**——不要等全做完才写。
      > 结论一律挂证据档位:`Confirmed by static analysis` /
      > `Confirmed by packet capture or runtime observation` / `Inferred and still requiring validation`。
    • kcp_sniff.py 1.8 KB
      #!/usr/bin/env python3
      # -*- coding: utf-8 -*-
      """
      KCP / 自定义 UDP 流量还原骨架
      - 从 pcap 里按 (src_ip, src_port, dst_ip, dst_port) 重组流
      - 按 KCP 头结构剥离,输出每段载荷
      
      用法: python3 kcp_sniff.py capture.pcap out/flows/
      """
      import sys, os, struct
      from collections import defaultdict
      
      try:
          from scapy.all import rdpcap, IP, UDP
      except ImportError:
          print("请先 pip install scapy")
          sys.exit(1)
      
      # KCP 头: conv(4) cmd(1) frg(1) wnd(2) ts(4) sn(4) una(4) len(4)
      KCP_HDR = ">IBB HIIII".replace(" ", "")
      KCP_HDR_SIZE = 24
      
      
      def parse_kcp(payload: bytes):
          if len(payload) < KCP_HDR_SIZE:
              return None
          conv, cmd, frg, wnd, ts, sn, una, length = struct.unpack(KCP_HDR, payload[:KCP_HDR_SIZE])
          data = payload[KCP_HDR_SIZE:KCP_HDR_SIZE + length]
          return dict(conv=conv, cmd=cmd, frg=frg, wnd=wnd, ts=ts, sn=sn, una=una, data=data)
      
      
      def main(pcap_path, outdir):
          os.makedirs(outdir, exist_ok=True)
          pkts = rdpcap(pcap_path)
          flows = defaultdict(list)
          for p in pkts:
              if IP in p and UDP in p:
                  key = (p[IP].src, p[UDP].sport, p[IP].dst, p[UDP].dport)
                  flows[key].append(bytes(p[UDP].payload))
      
          for i, (key, segs) in enumerate(flows.items()):
              name = f"{key[0]}_{key[1]}-{key[2]}_{key[3]}"
              path = os.path.join(outdir, name + ".bin")
              with open(path, "wb") as f:
                  for s in segs:
                      k = parse_kcp(s)
                      if k and k["data"]:
                          f.write(k["data"])
                      else:
                          f.write(s)  # 非 KCP,原样落盘
              print(f"[+] {name}: {len(segs)} packets -> {path}")
      
      
      if __name__ == "__main__":
          if len(sys.argv) < 3:
              print(__doc__)
              sys.exit(1)
          main(sys.argv[1], sys.argv[2])
    • login-chain.md 3.6 KB
      # 登录链条(Login Chain)— <游戏名>
      
      > **用途**:把"客户端从启动到进主场景"的每一步**登清楚**,作为**补包顺序**的依据。
      > **规则**:必须按**工作区实际目标**填写,**不要照抄示例**;每步记 5 项
      > (谁发起 / 发什么 / 期待什么回包 / 证据 / 状态)。
      > **配套**:`references/workflow-roadmap.md` 阶段 1、`templates/mock_server.py`。
      
      - 项目:`<游戏名 / 包名>`
      - 输入:`<apk / dump.cs / lua / pcap …>`
      - 登录类型:`渠道 SDK / 直接账号 / 游客`
      - 更新时间:`YYYY-MM-DD`
      
      ---
      
      ## 0. 链条总览(一句话版)
      
      > 按实际目标调整——**下面只是形状示例,不是答案**。
      
      ```
      SDK login → getServerList → getLastServerList → 选服
      → player.GetUserList → player.Login → Connect(host,port) → getHash
      → [player.CreateUser] → user.UserLogin → user.GetUserInfo → Loginok → 主场景
      ```
      
      **分界**:`Connect(host,port)` 之前多为 **HTTP(S) 接口**;之后是 **长连接二进制协议**。
      
      ---
      
      ## 1. 分步明细
      
      | # | 阶段 | 发起方 | 请求(协议号 / URL / 方法) | 期待响应 | 关键字段 | 证据 | 状态 |
      |---|------|--------|---------------------------|---------|---------|------|------|
      | 1 | 启动/公告 | client | `GET /notice` | 公告 + 版本 | `version` | E01 | [ ] |
      | 2 | SDK 登录 | client | `sdk.login()` | 平台 token | `openid`, `token` | E02 | [ ] |
      | 3 | 服务器列表 | client | `getServerList` | 区服数组 | `id,name,addr,port,state` | E03 | [ ] |
      | 4 | 上次选服 | client | `getLastServerList` | 上次区服 | `last_server` | E04 | [ ] |
      | 5 | 选服 | client | `player.GetUserList` | 角色列表 | `char_list[]` | E05 | [ ] |
      | 6 | 登录 | client | `player.Login` | 登录结果 | `uid, token` | E06 | [ ] |
      | 7 | 建连 | client | `Connect(host,port)` | TCP 建连 | — | E07 | [ ] |
      | 8 | 握手 | client | `<0x0001> HandshakeReq` | `<0x0002> HandshakeRes` | `ver, nonce, key` | E08 | [ ] |
      | 9 | 取 hash | client | `getHash` | hash 值 | `hash` | E09 | [ ] |
      | 10 | 建角(首登) | client | `[player.CreateUser]` | 建角结果 | 略 | E10 | [ ] |
      | 11 | 用户登录 | client | `user.UserLogin` | 登录会话 | `session` | E11 | [ ] |
      | 12 | 拉用户信息 | client | `user.GetUserInfo` | 角色/背包/任务… | 大量字段 | E12 | [ ] |
      | 13 | 登录完成 | server | `Loginok` | 进主场景 | — | E13 | [ ] |
      
      > 状态:`[x] 已通 / [~] 部分 / [ ] 未做 / [?] 未验证 / [-] 该游戏没有`
      
      ---
      
      ## 2. 关键字段与取值
      
      | 字段 | 来源 | 类型 | 取值示例 | 备注 |
      |------|------|------|---------|------|
      | `version` | 客户端内置 | str | `1.8.1` | 服务端需匹配 |
      | `token` | SDK | str | — | 是否需服务端二次校验 |
      | `addr/port` | 服务器列表 | str/u16 | `127.0.0.1:8888` | 重定向落点 |
      | `hash` | 握手后 | 整数 | — | 用途待定 → unresolved |
      
      ---
      
      ## 3. 接口分类(HTTP vs 长连接)
      
      ```
      HTTP(S) 接口:  <列表>
      长连接二进制:  <列表>
      其他(CDN/热更/支付):  <列表>
      ```
      
      ---
      
      ## 4. 未解决项(unresolved)
      
      ```
      1. <某字段含义未知,缺什么证据>
      2. <某步骤的协议号未确认>
      3. <是否走热更程序集未验证>
      ```
      
      ---
      
      ## 5. 备注
      
      - **SDK**:`<渠道名 / 是否可离线 / dummy 开关>`
      - **热更**:`<方案 / 目录 / 是否覆盖内置>`
      - **加密**:`<已知的加密/压缩层>`
      - **重定向落点**:`<hosts / 私有目录文件 / DNAT / 改包>`
      
      ---
      
      > 注意: **链条整理不清 = 补包没有顺序**。这份文档每通一步就更新一次状态,
      > 并同步到 `TRACKER.md` 与 `function-checklist.md`。
    • mock_server.py 3.7 KB
      #!/usr/bin/env python3
      # -*- coding: utf-8 -*-
      """
      最小可运行服务端骨架(Mock Server)
      - 只实现:握手 + 长连接帧收发 + 可插拔 opcode 处理
      - 用法: python3 mock_server.py --host 0.0.0.0 --port 8888
      
      适配说明:
        * 把 FRAME_* / DECRYPT / ENCRYPT 换成从客户端逆向出的真实实现
        * 每个 opcode 在 HANDLERS 里注册
      """
      import argparse, asyncio, struct, logging
      
      logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
      log = logging.getLogger("gsrv")
      
      # ---------------- 协议层(占位,按逆向结果替换) ----------------
      # 例:4 字节大端长度前缀 + 负载
      LEN_FMT = ">I"
      LEN_SIZE = 4
      MAX_FRAME = 1 << 20
      
      
      def decode_frame(buf: bytes) -> bytes:
          """剥离外层封装(去掉长度头/解压/解密后的业务体)"""
          return buf
      
      
      def encode_frame(payload: bytes) -> bytes:
          """加外层封装"""
          return struct.pack(LEN_FMT, len(payload)) + payload
      
      
      def decrypt(data: bytes) -> bytes:
          """占位:换成真实解密(RC4/XOR/AES...)"""
          return data
      
      
      def encrypt(data: bytes) -> bytes:
          """占位:换成真实加密"""
          return data
      
      
      # ---------------- opcode 处理器 ----------------
      HANDLERS = {}
      
      
      def handler(opcode: int):
          def deco(fn):
              HANDLERS[opcode] = fn
              return fn
          return deco
      
      
      @handler(0x0001)
      async def on_handshake(session, body: bytes) -> bytes:
          log.info("[0x0001] handshake body=%s", body.hex())
          # 真实实现:校验客户端版本/随机数,返回握手确认
          return b"\x01"
      
      
      @handler(0x0002)
      async def on_login(session, body: bytes) -> bytes:
          log.info("[0x0002] login body=%s", body.hex())
          # 真实实现:返回 token / 角色列表
          return b"\x02"
      
      
      # ---------------- 连接处理 ----------------
      class Session:
          def __init__(self, writer):
              self.writer = writer
              self.alive = True
      
      
      async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
          peer = writer.get_extra_info("peername")
          log.info("client connected: %s", peer)
          session = Session(writer)
          try:
              while True:
                  head = await reader.readexactly(LEN_SIZE)
                  (n,) = struct.unpack(LEN_FMT, head)
                  if n <= 0 or n > MAX_FRAME:
                      log.warning("bad frame len=%s", n)
                      break
                  raw = await reader.readexactly(n)
                  plain = decrypt(decode_frame(raw))
                  if not plain:
                      continue
                  # 约定:首字节=opcode(按真实协议替换)
                  opcode = plain[0]
                  body = plain[1:]
                  fn = HANDLERS.get(opcode)
                  if fn is None:
                      log.info("unhandled opcode=0x%04X body=%s", opcode, body.hex())
                      continue
                  resp = await fn(session, body)
                  out = encode_frame(encrypt(bytes([opcode]) + resp))
                  writer.write(out)
                  await writer.drain()
          except asyncio.IncompleteReadError:
              pass
          except Exception as e:  # noqa
              log.exception("session error: %s", e)
          finally:
              session.alive = False
              writer.close()
              log.info("client closed: %s", peer)
      
      
      async def main(host, port):
          server = await asyncio.start_server(handle_client, host, port)
          addrs = ", ".join(str(s.getsockname()) for s in server.sockets)
          log.info("listening on %s", addrs)
          async with server:
              await server.serve_forever()
      
      
      if __name__ == "__main__":
          ap = argparse.ArgumentParser()
          ap.add_argument("--host", default="0.0.0.0")
          ap.add_argument("--port", type=int, default=8888)
          args = ap.parse_args()
          try:
              asyncio.run(main(args.host, args.port))
          except KeyboardInterrupt:
              pass
    • README.md 4.4 KB
      # templates/ —— 参考实现与模板总览
      
      > 注意: **本目录(以及 `server/`)里的所有代码都是「参考实现」,不是对某个游戏的答案。**
      > 每个游戏的协议/字段/架构都不一样,**用它提供的"套路",不要照抄它的"答案"**。
      
      ---
      
      
      ```bash
      # 0) 依赖
      pip install flask
      
      # 1) 起「注册网站」(与游戏服共用同一 DB / 同一密码哈希)
      cd templates/register-site
      GAME_DB=../../server/data/game.db PWD_SALT=你的盐 VERIFY_MODE=invite ACCOUNT_PREFIX=svr_ \
        python3 server.py            # http://127.0.0.1:8080/
      
      GAME_DB=../../server/data/game.db PWD_SALT=你的盐 \
      
      # 4) 打开注册站 → 用该邀请码注册账号 → 客户端即可用该账号登录
      
      # 5) 客户端“换服”可选:把请求重定向到自建服务端
      #    Java/OkHttp 客户端 → templates/xposed-redirect/(LSPosed 模块骨架)
      #    native/il2cpp 客户端 → templates/frida-redirect.js
      ```
      
      
      ---
      
      ## 二、目录总览
      
      ### 脚本 / 桩(可执行)
      | 文件 | 性质 | 用法 |
      |------|------|------|
      | `mock_server.py` | 极简单文件服务端桩 | 逆向早期验证"帧能不能收发" |
      | `frida_bypass_ssl.js` | 证书固定绕过脚本 | 受控环境抓包用;类名/方法名按需替换 |
      | `frida-redirect.js` | **客户端重定向**(native/il2cpp) | hook `getaddrinfo`/`connect` 把请求引到自建服 |
      | `kcp_sniff.py` | KCP/UDP 流量还原骨架 | 按目标 KCP 头结构微调 |
      
      ### 界面模板(成品 HTML + 最小后端)
      | 目录 | 是什么 | 用法 |
      |------|--------|------|
      | `register-site/` | **注册网站**(响应式,自包含) | 账号注册/登录,支持**邀请码 / QQ群 / 白名单 三选一**(`VERIFY_MODE`) |
      | `xposed-redirect/` | **LSPosed 模块骨架**(Java/OkHttp) | 改写 URL 换服;无 root 用 LSPatch/NPatch |
      
      ### 文档模板
      | 文件 | 是什么 | 何时用 |
      |------|--------|--------|
      | `AGENTS.md` | 工作区 AI 协作契约(范围/边界/目录分区/验证命令/收工状态) | **项目第 0 天**放到项目根 |
      | `adr-template.md` | 架构决策记录(含"当前不做的事情"+回滚) | 决定「先做什么、不做什么、何时才动客户端」时 |
      | `e2e-evidence-template.md` | 实机端到端证据(帧序表 + 差异解释 + 重连快照) | 每次宣称"跑通"时填一份 |
      | `status-matrix.md` | 三轴状态矩阵(实现 / 自动测试 / 客户端验收) | 写进度时用 |
      | `login-chain.md` | **登录链条**(启动到主场景每一步) | 进服阶段必产,是补包顺序的依据 |
      | `function-checklist.md` | **功能清单**(按子系统登记实通/未通) | 持续迭代 |
      
      > 文档模板配套 `../references/engineering-practices.md`;
      > **验收钉在具名完成点 + 证据分档 + 主动解释帧长差异**。
      
      ---
      
      ## 三、为什么叫"参考"
      
      不同游戏的协议**千差万别**:
      
      - 长度头:1 / 2 / 4 字节,大端 / 小端,含 / 不含自身
      - 消息号:0 / 1 / 2 / 4 字节,有的还带压缩标记位
      - 加密:无 / XOR / RC4 / AES / 自定义
      - 压缩:无 / zlib / lz4
      - 序列化:JSON / protobuf / MessagePack / 自定义二进制
      - 架构:TCP 长连接 / UDP+KCP / HTTP / WS
      
      **把参考实现的默认值当成目标值 = 必错。**
      
      ## 四、正确用法
      
      ```
      1) 先跑 references/adaptation.md 的流程,产出 protocol.spec.yaml
      2) 用 spec 的值去"改"参考实现,而不是"用"参考实现
      3) 参考实现只提供:
         - 分层结构(会话/分发/编解码/逻辑)
         - 参数化设计(哪些是配置项)
         - 踩坑经验(消息顺序、opcode 回包、幂等…)
      ```
      
      ## 五、举例
      
      参考实现默认 `frame.opcode_size=2`。若你的目标游戏是 1 字节消息号:
      
      - [x] 错:直接跑参考实现 → `struct.error: 'B' format requires 0 <= number <= 255`
      - [x] 对:Spec 写 `opcode_size: 1` → 改 `config.yaml` → 跑通
      
      ## 六、完整列表
      
      - 长连接二进制协议参考:`../server/`(Python/asyncio,已实测)
      - 桩 / 脚本:`mock_server.py`、`frida_bypass_ssl.js`、`frida-redirect.js`、`kcp_sniff.py`
      - 重定向:`xposed-redirect/`、`frida-redirect.js`
      - 文档模板:`AGENTS.md`、`adr-template.md`、`e2e-evidence-template.md`、`status-matrix.md`、`login-chain.md`、`function-checklist.md`
      
      > 若你的目标协议差异过大(如 UE 原生复制、HTTP 网关),
      > **应当新建一个该语言的实现**,而不是硬改 `server/`。
      > 见 `../references/codegen.md` 的"多语言参考实现路线"。
    • status-matrix.md 3 KB
      # 支持矩阵 —— 三轴状态模板
      
      > **用法**:复制到 `docs/status/support-matrix.md`。
      > **核心**:**不要用一个状态列**。把「实现了 / 测过了 / 客户端验过了」分开记。
      >
      > 规则见 `../references/verification-and-status.md`。
      
      ---
      
      ## 状态定义(先写清,别让读者猜)
      
      ### 三轴
      
      | 轴 | 取值 |
      |---|---|
      | 服务端实现 | `Complete` / `Partial` / `Stub` / `Missing` |
      | 自动测试 | `强覆盖` / `部分覆盖` / `无` |
      | 客户端验收 | `已通过` / `待测` / `低优先级` / `未实现` / **`—`(非客户端验收)** |
      
      | 档 | 含义 |
      |---|---|
      | **Complete** | 核心路径、持久状态、主要错误路径都已实现 |
      | **Partial** | 有主流程,但有缺分支/事务/通知/数据覆盖 |
      | **Stub** | 只返回"让客户端不报错"的兼容响应,**不提供真实业务能力** |
      | **Missing** | 客户端可能有入口,服务端没有可用实现 |
      
      > 注意: **自动测试通过不能替代客户端/宿主验收。**
      
      ---
      
      ## 主表
      
      > 「权威文档」列 = 这件事**以哪份文档为准**。避免同一事实在多处各说一套。
      
      | 模块 | 服务端实现 | 自动测试 | 客户端验收 | 权威文档 |
      |---|---|---|---|---|
      | 运行时启动 | | | `—` | |
      | 账号与登录 | | | | |
      | 角色 / 存档 | | | | |
      | 场景 / 移动 | | | | |
      | 房间 / 匹配 | | | | |
      | 战斗 / 结算 | | | | |
      | 掉落 / 背包 | | | | |
      | 抽卡 | | | | |
      | 商店 / 兑换 | | | | |
      | 邮件 | | | | |
      | 任务 / 活动 | | | | |
      | 社交(好友/公会/聊天) | | | | |
      | 公告 | | | | |
      | 管理后台 | | | `—` | |
      | 资源 / CDN | | | `—` | |
      | 部署 / 打包 | | | `—` | |
      
      ---
      
      ## 按路由族/子系统展开(可选,粒度更细)
      
      | 路由族 | 状态 | 当前边界(一段话) | 源码入口 | 文档 |
      |---|---|---|---|---|
      | | | | | |
      
      **状态列用**:`Complete` / `Partial` / `Stub` / `Missing`
      
      > 写作要求:**「当前边界」要写清"做到哪、没做到哪"**,不要只写"已实现"。
      > 例:「列表/详情/分页/后台 CRUD 已实现;系统公告、强制公告、已读与红点保持兼容空响应或延期」。
      
      ---
      
      ## 桩审计清单(Stub 必须逐条登记)
      
      > 只返回固定值 / 空对象 / 空列表的路由,全部列在这里。
      
      | 路由 | 返回 | 目的 | 不做什么 |
      |---|---|---|---|
      | | 空对象 | 消除客户端入口报错 | 不建立真实业务关系 |
      
      ---
      
      ## 使用规则
      
      1. 先从本矩阵确定**模块**与**权威文档**;
      2. 再检查**注册源码**是否真的注册了该路由;
      3. 读处理函数、领域模块与测试,确认**请求字段、持久状态、事务与错误路径**;
      4. 协议字段优先核对**反编译代码**;需要网络证据时只用**本地自备且已脱敏**的抓包;
      5. 客户端是否通过,以**验收进度**文档为准,**本矩阵不代替**。
      
      ---
      
      ## 更细的未解决项
      
      → 写进 `known-issues.md`(只记**尚未解决**的,已修复的交给版本控制)
      → 人工验收顺序 → 写进 `test-progress.md`
  • tools
    • extract_interfaces.py 5.4 KB
      #!/usr/bin/env python3
      # -*- coding: utf-8 -*-
      """extract_interfaces.py —— 通用「接口清单」提取器
      
      用途:从客户端源码里把**所有服务端接口(opCode / 路由 / 消息名)**扒出来,
            生成可维护的清单,作为反推服务端的「待实现列表」。
      
      支持模式(可叠加,自动识别):
        1) 字符串 opCode:GetMessage("LOGIN_INFO") / opCode = "LOGIN_INFO"
        2) 数字 opCode  :case 0x101: / MSG_ID = 1001 / const int LOGIN = 0x101
        3) HTTP 路由    :"/api/login" / $http.post('/login')
        4) protobuf 信封:protobuf.encode("GameMessage.Message", msg)
        5) 类/方法式    :C2S_Login / SendLoginReq(按前缀聚类)
      
      用法:
        python3 extract_interfaces.py <源码目录> [--out 报告.md] [--json out.json]
        python3 extract_interfaces.py ./lua_src --out interfaces.md
      
      输出:按子系统前缀分组的清单 + 统计 + 未分类项。
      """
      from __future__ import annotations
      
      import argparse
      import collections
      import io
      import json
      import os
      import re
      import sys
      
      # ---- 提取模式(正则 -> 说明)----
      PATTERNS = [
          # 1) 字符串 opCode
          ("opcode_str", re.compile(r'GetMessage\(\s*"([A-Za-z0-9_]+)"\s*\)')),
          ("opcode_str", re.compile(r'\bopCode\s*=\s*"([A-Za-z0-9_]+)"')),
          ("opcode_str", re.compile(r'\bopcode\s*=\s*"([A-Za-z0-9_]+)"')),
          ("opcode_str", re.compile(r'\bcmd\s*=\s*"([A-Za-z0-9_]+)"')),
          # 2) 数字 opCode / 常量
          ("opcode_num", re.compile(r'\bcase\s+(0x[0-9A-Fa-f]{2,6}|\d{3,6})\s*:')),
          ("opcode_num", re.compile(r'\b[A-Z][A-Z0-9_]*\s*=\s*(0x[0-9A-Fa-f]{3,6})\b')),
          # 3) HTTP 路由
          ("route", re.compile(r'["\'](/(?:api/)?[a-zA-Z][a-zA-Z0-9_/]{2,60})["\']')),
          # 4) protobuf 信封
          ("proto_msg", re.compile(r'protobuf\.encode\(\s*"([A-Za-z0-9_.]+)"')),
          # 5) 类/方法式(C++/C# 常见)
          ("method", re.compile(r'\b(?:void|virtual|public|private|protected)\s+\w*\s*'
                                r'(C2S_[A-Za-z0-9_]+|S2C_[A-Za-z0-9_]+)\s*\(')),
      ]
      
      SKIP_DIRS = {".git", "node_modules", "venv", "__pycache__", ".idea", "dist", "build"}
      TEXT_EXT = {".lua", ".cs", ".js", ".ts", ".java", ".cpp", ".h", ".hpp", ".py",
                  ".go", ".as", ".json", ".txt", ".xml", ".yaml", ".yml"}
      
      # 明显的噪音(路由/常量误报)
      ROUTE_NOISE = re.compile(r'^/(?:[a-z]+/)*$|^/(?:index|favicon|static|assets|js|css|img)')
      
      
      def walk(root: str):
          for dirpath, dirnames, filenames in os.walk(root):
              dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
              for fn in filenames:
                  if os.path.splitext(fn)[1].lower() in TEXT_EXT:
                      yield os.path.join(dirpath, fn)
      
      
      def scan_file(path: str) -> dict:
          try:
              s = io.open(path, encoding="utf-8", errors="replace").read()
          except Exception:
              return {}
          found = collections.defaultdict(set)
          for kind, pat in PATTERNS:
              for m in pat.findall(s):
                  v = m if isinstance(m, str) else m
                  if not v:
                      continue
                  if kind == "route" and ROUTE_NOISE.match(v):
                      continue
                  if kind == "opcode_str" and len(v) < 3:
                      continue
                  found[kind].add(v)
          return found
      
      
      def main():
          ap = argparse.ArgumentParser()
          ap.add_argument("source", help="客户端源码目录")
          ap.add_argument("--out", default=None, help="输出 Markdown 报告路径")
          ap.add_argument("--json", default=None, help="输出 JSON 路径")
          ap.add_argument("--min-group", type=int, default=1)
          args = ap.parse_args()
      
          if not os.path.isdir(args.source):
              print(f"目录不存在: {args.source}", file=sys.stderr)
              sys.exit(1)
      
          all_found = collections.defaultdict(set)
          files = 0
          for fp in walk(args.source):
              files += 1
              for kind, vals in scan_file(fp).items():
                  all_found[kind] |= vals
      
          lines = []
          lines.append("# 接口清单(自动提取)\n")
          lines.append(f"- 扫描目录:`{args.source}`")
          lines.append(f"- 扫描文件:{files}")
          lines.append("")
      
          summary = {}
          for kind in ("opcode_str", "opcode_num", "route", "proto_msg", "method"):
              vals = sorted(all_found.get(kind, set()))
              summary[kind] = vals
              if not vals:
                  continue
              lines.append(f"## {kind}({len(vals)})\n")
              # 按前缀分组
              groups = collections.defaultdict(list)
              for v in vals:
                  key = re.split(r"[_./]", v.lstrip("/"))[0].upper() if kind != "route" \
                      else v.lstrip("/").split("/")[0].upper()
                  groups[key].append(v)
              for g in sorted(groups, key=lambda k: -len(groups[k])):
                  items = groups[g]
                  if len(items) < args.min_group:
                      continue
                  lines.append(f"### {g} ({len(items)})")
                  for it in items[:60]:
                      lines.append(f"- `{it}`")
                  if len(items) > 60:
                      lines.append(f"- … 其余 {len(items) - 60} 项")
                  lines.append("")
      
          report = "\n".join(lines)
          if args.out:
              io.open(args.out, "w", encoding="utf-8").write(report)
              print(f"[+] 报告 -> {args.out}")
          else:
              print(report[:4000])
          if args.json:
              io.open(args.json, "w", encoding="utf-8").write(
                  json.dumps(summary, ensure_ascii=False, indent=2))
              print(f"[+] JSON -> {args.json}")
      
          print(f"[*] 合计: " + ", ".join(f"{k}={len(v)}" for k, v in summary.items()))
      
      
      if __name__ == "__main__":
          main()
    • repack_zip.py 4.9 KB
      #!/usr/bin/env python3
      # -*- coding: utf-8 -*-
      """
      repack_zip.py —— APK/ZIP「原样复制」重打包(秒级,2GB 包也不慢)
      
      为什么不用 apktool:
          apktool 会全解全 build,把所有条目重新压缩一遍 → 偏移全变、原签名彻底作废,
          对 2GB 的包还非常慢。
          本脚本只做「搬运 + 定点替换」:除被替换/删除的条目外,其余**压缩数据原样拷贝**。
      
      支持:
          --replace <zip内路径>=<本地文件>     替换(不存在则新增)
          --delete  <zip内路径>                删除条目
          --add-from <另一个apk> <zip内路径>   从别的包拷贝条目(例:原包的 META-INF 签名文件)
      
      注意:
          · 会清掉 data-descriptor 位(flg & ~0x8),长度/CRC 写在 local header 里
          · **不会**写 APK Signing Block(v2/v3)→ 需要签名请最后用 apksigner 签
          · 条目数需 < 65535、单条目 < 4GB(普通 APK 都满足)
      
      用法示例:
          python3 repack_zip.py game.apk game_stub.apk \\
      
          # 塞回原包签名、顺手删掉自己的签名
          python3 repack_zip.py mod.apk mod_origsig.apk \\
              --delete META-INF/ANDROID.RSA --delete META-INF/ANDROID.SF --delete META-INF/MANIFEST.MF \\
              --add-from orig.apk META-INF/APP.RSA \\
              --add-from orig.apk META-INF/APP.SF \\
              --add-from orig.apk META-INF/MANIFEST.MF
      """
      import os
      import struct
      import sys
      import zipfile
      import zlib
      
      LOCAL_SIG = 0x04034B50
      CD_SIG = 0x02014B50
      EOCD_SIG = 0x06054B50
      
      
      def raw_entries(src):
          """按 central directory 顺序产出 (name, local_header_offset)。"""
          z = zipfile.ZipFile(src)
          out = []
          for info in z.infolist():
              out.append((info.filename, info.header_offset))
          return out
      
      
      def main(argv):
          if len(argv) < 3:
              print(__doc__)
              return 1
          src, dst = argv[1], argv[2]
          replace, delete, addfrom = {}, set(), []
          i = 3
          while i < len(argv):
              a = argv[i]
              if a == '--replace':
                  path, f = argv[i + 1].split('=', 1)
                  replace[path] = open(f, 'rb').read()
                  i += 2
              elif a == '--delete':
                  delete.add(argv[i + 1])
                  i += 2
              elif a == '--add-from':
                  addfrom.append((argv[i + 1], argv[i + 2]))
                  i += 3
              else:
                  print('未知参数: ' + a)
                  return 1
      
          src_f = open(src, 'rb')
          out = open(dst, 'wb')
          cd = []
          copied = 0
          for name, hoff in raw_entries(src):
              if name in delete or name in replace:
                  continue
              src_f.seek(hoff)
              hdr = src_f.read(30)
              sig, ver, flg, meth, mt, md, crc, cs, us, fnl, xl = struct.unpack('<IHHHHHIIIHH', hdr)
              assert sig == LOCAL_SIG, 'bad local header @%d' % hoff
              src_f.seek(hoff + 30)
              fn = src_f.read(fnl)
              ex = src_f.read(xl)
              src_f.seek(hoff + 30 + fnl + xl)
              data = src_f.read(cs)
              flg &= ~0x8
              off = out.tell()
              out.write(struct.pack('<IHHHHHIIIHH', LOCAL_SIG, ver, flg, meth, mt, md, crc, cs, us, fnl, len(ex)))
              out.write(fn)
              out.write(ex)
              out.write(data)
              cd.append((fn, ver, flg, meth, mt, md, crc, cs, us, ex, off))
              copied += 1
          print('[i] 原样搬运 %d 个条目' % copied)
      
          def add(name, data, meth=8):
              crc = zlib.crc32(data) & 0xffffffff
              if meth == 8:
                  co = zlib.compressobj(9, zlib.DEFLATED, -15)
                  blob = co.compress(data) + co.flush()
              else:
                  blob = data
              if not blob:
                  blob, meth = data, 0
              fn = name.encode()
              off = out.tell()
              out.write(struct.pack('<IHHHHHIIIHH', LOCAL_SIG, 20, 0, meth, 0, 0,
                                    crc, len(blob), len(data), len(fn), 0))
              out.write(fn)
              out.write(blob)
              cd.append((fn, 20, 0, meth, 0, 0, crc, len(blob), len(data), b'', off))
              print('    [+/~] %s (%d B)' % (name, len(data)))
      
          for path, data in replace.items():
              add(path, data)
          for apk, path in addfrom:
              data = zipfile.ZipFile(apk).read(path)
              add(path, data)
      
          cd_off = out.tell()
          for (fn, ver, flg, meth, mt, md, crc, cs, us, ex, off) in cd:
              out.write(struct.pack('<IHHHHHHIIIHHHHHII', CD_SIG, 20, 20, flg, meth, mt, md,
                                    crc, cs, us, len(fn), len(ex), 0, 0, 0, 0, off))
              out.write(fn)
              out.write(ex)
          cd_size = out.tell() - cd_off
          out.write(struct.pack('<IHHHHIIH', EOCD_SIG, 0, 0, len(cd), len(cd), cd_size, cd_off, 0))
          out.close()
          src_f.close()
          print('[+] 写出 %s (%d bytes),条目 %d' % (dst, os.path.getsize(dst), len(cd)))
          bad = zipfile.ZipFile(dst).testzip()
          print('[i] testzip = %s (None 即 CRC 全对)' % bad)
          with open(dst, 'rb') as f:
              head = f.read()
          print('[i] 含 APK Signing Block: %s' % (b'APK Sig Block 42' in head))
          return 0
      
      
      if __name__ == '__main__':
          sys.exit(main(sys.argv))
  • README.md 20.9 KB
    # 游戏客户端 → 离线本地化 Skill
    
    完整支持 **Unity(IL2CPP / Mono / Lua)** 与 **Unreal Engine(UE4 / UE5)**,也覆盖 C#/AS3/Lua/JS/Java 等各类客户端。
    从客户端反推服务端协议 → 复现本地离线服务端 → 部署 → 让原版客户端**本地离线运行**。
    
    > [x] 本包自带的服务端已**实测跑通**:握手 → 登录 → 建角 → 选角 → 进场景 → 移动 → 心跳,
    > 并验证 XOR 加密 + zlib 压缩 + 充值/邮件发放。
    >
    > 服务端参考实现原Github地址:https://github.com/Nanako660/peach-haven
    >
    >  **2026-09-22 更新(v2.0)** —— 本版主题:原理层 + 四阶段路线 + **换服务端(重定向)**:
    > ⓪ **版本升至 2.0**:整合 1.x 全部成果 + 以下 ①~⑪;
    > ① **原理层** `references/primer.md`:三要素(配置=数值 / 协议=格式 / 代码=用法)、
    >   数据包协议本质、协议号与协议表、**热更新代码是比 dump 更好读的源码**;
    > ② **四阶段路线总纲** `references/workflow-roadmap.md`(静态分析 → 建工具+登录链 → 重定向 → 补包循环 → 清单迭代)+ **四层重定向表**;
    > ③ 模板:`templates/login-chain.md`(登录链条)、`templates/function-checklist.md`(功能清单);
    > ④ SKILL §0 开头新增**阅读地图(三条主线)**;
    > ⑤ **反推对象地图** `references/server-architecture-basics.md`(逆向视角):你在还原哪几类服、
    >   网关留下的隐藏层(合并/加解密/压缩 flag)、从包里认 Protobuf/KCP、同步模型决定"要还原多少逻辑"
    >   (来源:平台云架构演进、GameDevAndOps、KCP/protobuf 官方、Skynet/Pomelo/KBEngine/NF);
    > ⑥ **取长补短(折进现有文件)**:`methods.md` M8 补入**现成实现速查(按类型)** + "先查别人做过没" + **先锁版本**;`protocol-spec.md` 补入人类可读协议文档格式;
    > ⑦ **SKILL.md 瘦身**:与 references 重复的 §1~§8、§11、§14~§19 压成"要点+指针"(1122 → ~650 行);§11 常见坑并入 `closure-verification.md §附`;
    > ⑧ **跨模型阅读优化(GLM / DeepSeek / Claude)**:SKILL 顶部改为**模型无关的显式阅读协议**("打开哪个文件"写死);
    >    去掉顶部大段变更历史;给 5 篇 >300 行的 reference 补**章节目录**;`reading-path.md` 顶部声明按它分派;
    > ⑨ **去除 emoji**:全库清理(状态标记转 ASCII、装饰 emoji 删除、箭头保留);
    > ⑩ **框架修正 + 资源**:` §0` 改为"**主动改客户端对接本地离线服务端**"(按重定向四层表、优先跑起来);
    > ⑪ **换服务端手法补全(v2.0 新增)**:`client-address-sources.md §3.0b` 加入 **Xposed / LSPatch 模块重定向**
    >    (第三方代理模块实测范例,免 root);`methods.md` M8 加"**多人/联机复活项目**"检索入口;
    > ⑫ **签名绕过**:`repack-rename.md §8` 加入 **LSPatch Signature Bypass(等级 2)机制** / 独立签名破解
    >    (`ApkSignatureKiller` / 核心破解) / 真正不改签名的虚拟容器(VirtualXposed / 太极);§7 落点表述改为"按代价从低到高";
    >    接入点:`account.md §6`、`release-and-ops.md §7`、`SKILL §16`;
    >
    >  **2026-09-15 实战沉淀(v1.8 新增)**:
    > ⑬ **内联服务端**:不起外部服务端,在客户端**进程内**拦截网络门面合成响应
    >   (三类入口 / 回调投递纪律 / 延迟派发 / 双通路 / 内联版验收与假阳性)→ `references/inline-server.md`
    > ⑭ **运行时对象合成与字段发现**:用客户端自己的类型系统当 schema,先 dump 再合成 → `references/runtime-object-synthesis.md`
    > ⑮ **平台 SDK 登录态复用 + 目录服→区服两段准入** → `references/platform-sdk-and-admission.md`
    > ⑯ 真实案例:Unity IL2CPP + 平台 SDK 的内联服务端 → `references/case-il2cpp-inline.md`
    >
    >  **2026-09-13 实战沉淀(新增)**:
    > ② **改包名 / 重打包 / 保留原签名**全清单 → `references/repack-rename.md`
    > ④ **闭环验证与 6 类假阳性**(端口在听≠服务可用、自环≠客户端兼容)→ `references/closure-verification.md`
    > ⑤ **真实案例:Unity IL2CPP + ECDH 登录服**(帧格式 / SPKI / 已证伪项)→ `references/case-il2cpp-ecdh.md`
    > ⑥ SKILL 铁律 3 → 4 条:**不把「能跑」当成「跑通」**
    > ⑦ **工程范式**(实机跑通项目的做法:fixture 回放 / 具名完成点 / 证据分档 / ADR)→ `references/engineering-practices.md`
    > ⑧ 文档模板:`templates/adr-template.md`、`templates/e2e-evidence-template.md`
    > ⑨ **实现层核心**:不依赖 protobuf 运行时的 **wire 级定点改写**(帧 codec + 字段遍历 + splice 替换 + 空子消息必须保留)→ `references/wire-level-patching.md`
    > ⑩ **客户端地址来源清查**(六类来源 + 落点优先级 + 重签后果 + 阶段验收)→ `references/client-address-sources.md`
    > ⑪ **三轴状态与验收体系**(实现 / 自动测试 / 客户端验收 + 可达性五分类 + 提交门禁)→ `references/verification-and-status.md`
    > ⑫ **发布、部署与运营**(监听 vs 对外地址 / 端口族 / 启动期冻结配置 / CDN 版本策略 / 备份)→ `references/release-and-ops.md`
    
    ---
    
    ## 目录映射(本 skill 的结构)
    
    > 本 skill 遵循 **Agent Skills** 规范:**一个文件夹 = 一个 skill**,且 frontmatter 的 `name`
    > 必须与文件夹名一致。本文件夹即 `game-client-to-server-reverse/`;内部目录是对规范约定目录的映射。
    
    | 规范约定 | 本 skill 实际 | 说明 |
    |----------|--------------|------|
    | `SKILL.md` | `SKILL.md` | 必需:元数据 + 主干(已瘦身到 < 500 行,符合规范建议) |
    | `references/` | `references/` | 按需加载的详细文档(正文下沉于此) |
    | `assets/` | `templates/`、`schema/`、`server/` | 模板 / 中间产物模板 / 可运行参考实现 |
    | `scripts/` | `tools/` | 可执行脚本(`extract_interfaces.py` / `` / `repack_zip.py`) |
    | — | `examples/`、`extensions/` | 推演示例 / 可选拓展 |
    
    > 阅读顺序:`SKILL.md`(导航+主干)→ `references/reading-path.md`(按任务分派)→ 按需打开具体文件。
    
    ---
    
    ##  先读什么(别一次读完整包)
    
    本包有 37 篇参考 + 一份长主文档。**通读完再动手 = 上下文耗尽 + 在半懂的地方开始猜。**
    
    ```
    1. references/reading-path.md      ← 按任务类型拿到 3~5 个文件的阅读路径
    2. SKILL.md §0.0 ~ §0.6            ← 方法选择 + 铁律 + 工作流主干
    3. 按路径读完场景层文件 → 动手
    收工前:reading-path.md §2 的「收工前检查」
    ```
    
    **上下文极少时**的优先级:
    `SKILL §0` → `reading-path.md` → `wire-level-patching.md` → `closure-verification.md` → `client-address-sources.md`
    
    > skill 是**查询手册**,不是必读教材。
    
    ---
    
    #  如何使用本 Skill
    
    ## 一句话
    
    把游戏(安装包 / 客户端 / 辅助文件)交给 AI,说清「要什么」,
    AI 会**自己取证 → 产出协议规格 → 生成并部署本地离线服务端 → 让客户端本地离线运行**。
    
    ## 你最少只需给一样
    
    | 给什么 | 推荐度 | AI 会做什么 |
    |--------|--------|------------|
    | **一个安装包**(`.apk` / `.ipa` / `.exe`) |  最推荐 | 自己解包、判引擎、跑 dump、抓包(见 `references/from-installer.md`) |
    | 客户端目录 / `dump.cs` / `*.lua` / `*.usmap` |  | 直接进静态分析 |
    | 抓包 `*.pcap` / mitm 导出 |  | 直接做协议分层 |
    | 已有服务端样本 / 协议文档 |  | 对照分析 |
    | 只有一个游戏名 | [x] | 没有证据,建议先提供安装包 |
    
    ## 提问模板(直接复制改)
    
    ```
    ① 最简
    帮我反推这个游戏的服务端:/文件路径/game.apk
    
    ② 带目标
    用这个 APK 反推服务端协议,先跑通登录,然后部署到本地。
    
    ③ 带约束
    这是 Unity 手游,服务端请分析根据当前用户给出客户端来用什么编写(如Go编写),要支持局域网联机链接到我们当前写的服务端上,先做协议分析.md文档再写代码。
    
    ④ 指定阶段
    先别写代码,只做协议分层,产出 protocol.spec.yaml 和证据清单。
    ```
    
    ## 一个好提问包含 4 个要素
    
    | 要素 | 说明 | 例子 |
    |------|------|------|
    | **输入** | 你给什么 | `game.apk` / `dump.cs` / `capture.pcap` |
    | **目标** | 做到哪一步 | 只分析 / 跑通登录 / 完整服务端+部署 |
    | **约束** | 语言/平台/版本 | Go / 安卓局域网 / 客户端 1.8.1 |
    | **环境** | 跑在哪 | 本地 / 云服务器 / Termux |
    
    > 缺哪个都行,AI 会用占位符推进;但**输入**最好给一个。
    
    ## AI 会按这个顺序回应(重要)
    
    ```
    1. project-profile.yaml    ← 我读了什么、还缺什么        (项目档案)
    2. evidence-inventory      ← 每条结论的证据 + 置信度     (证据清单)
    3. protocol.spec.yaml      ← 协议规格(唯一事实来源)
    4. 服务端代码 + 部署 + 验证 ← 由 Spec 派生
    ```
    
    > 注意: **如果 AI 直接甩给你一堆代码,却没有前 3 样,让它重来。**
    > 「先规格,后代码」是本 skill 的硬性要求(见 `references/ai-contract.md`)。
    
    **另有两份"开工/收工"契约**(详见 `references/ai-contract.md`):
    
    ```
    AGENTS.md                  ← 工作区边界 + 验证命令 + 收工状态(放在项目根)
    docs/status/support-matrix ← 三轴状态:实现 / 自动测试 / 客户端验收
    ```
    
    ## 可以这样追问
    
    - 「先给我证据清单」
    - 「先只做协议分层」
    - 「为什么判定是 4 字节大端?证据是哪条?」
    - 「服务端换成 Go 重写」
    - 「把 opcode 表填进去」
    - 「客户端连不上,帮我定位是哪一步」
    - 「先把账号/登录接口列出来,登记到 TRACKER」
    
    ## 常见问题指路
    
    | 问题 | 看哪 |
    |------|------|
    | 不懂"在反推什么"/第一次做 | `references/primer.md`(原理层) |
    | 不知道先做什么、在哪验收 | `references/workflow-roadmap.md`(四阶段路线) |
    | 只有安装包怎么办 | `references/from-installer.md` |
    | 怎么注册账号 / 替换登录接口 | `references/account.md`(§16) |
    | 需要人机(假玩家) | `extensions/`(§18,可选) |
    | 不想起服务端 / 想单机化 | `references/inline-server.md`(M11 内联服务端) |
    | 进度/接口清单 | `TRACKER.md` |
    
    ## 不要这样问
    
    - [x] 只给游戏名就让 AI 凭空写服务端 —— 没有证据 = 必错
    - [x] 「直接给我能用的服务端」却不给任何输入
    
    ## 使用守则与限制
    
    > 完整守则见 [`references/usage-policy.md`](references/usage-policy.md);每次动手前先读。
    
    **可做(允许)**:自研 / 已授权 / 离线单机目标的互操作性研究、协议文档化、学习与教学、已停服游戏的本地化存档(非商业)、授权范围内的安全评估。
    
    **不做(禁止)**:未授权架设 / 运营他人游戏的服务器、绕过或破坏他人的技术保护 / 完整性校验 / 反作弊、商业化牟利、侵犯他人权益、破坏性攻击。
    
    **硬性约束**:证据驱动(无证据 = 假设)、先规格后代码、只给方法不给具体游戏的成品 / 密钥、不提供保护绕过、fail closed(缺信息就说缺什么)、越界即停并给出合规替代。
    
    - 使用参考项目 / 代码前先看其许可证。
    
    ---
    
    ## 目录结构
    
    ```
    离线本地化/
    ├── SKILL.md                主流程(18 节,核心方法论)
    ├── README.md               本文件
    ├── TRACKER.md               进度与接口清单(每模块更新,从简)
    ├── schema/                  中间产物模板(AI 填这些)
    │   ├── project-profile.yaml   项目档案
    │   └── protocol.spec.yaml     协议规格(唯一事实来源)
    ├── references/
    │   ├── adaptation.md        自适应方法论:项目→证据→决策→Spec
    │   ├── reading-path.md      最小必读路径(按任务类型分派)
    │   ├── primer.md            原理层:三要素/数据包协议/协议表/热更源码/双证据链
    │   ├── workflow-roadmap.md  四阶段路线总纲(静态分析→建工具+登录链→重定向→补包循环→清单迭代)
    │   ├── phases-detail.md     §1~§10 反推主流程详细版(命令/工具/判断/坑)
    │   ├── ai-contract.md       AI 行为契约(强制产物/禁止/终点/回滚)
    │   ├── usage-policy.md      使用守则与限制(允许/禁止用途 + 硬性约束 + 自检)
    │   ├── server-architecture-basics.md  反推对象地图(逆向视角:你在还原哪几类服/网关隐藏层/同步模型)
    │   ├── methods.md           10 种反推方法 + 选择矩阵(动手前先看)
    │   ├── closure-verification.md  闭环验证 6 类假阳性(宣布成功前必看)
    │   ├── engineering-practices.md  实机跑通范式:fixture 回放 + 完成点 + 证据分档 + ADR
    │   ├── wire-level-patching.md  实现层核心:不依赖 pb runtime 的 wire 级定点改写
    │   ├── client-address-sources.md  客户端地址来源清查 + 落点策略
    │   ├── verification-and-status.md  三轴状态 + 可达性分类 + 测试门禁
    │   ├── release-and-ops.md   发布 / 部署 / 运营 / CDN / 备份
    │   ├── case-il2cpp-ecdh.md  真实案例:IL2CPP + ECDH 登录服 + 卡点复核
    │   ├── inline-server.md     内联服务端:进程内合成响应(与外部服务端并列的第二条路)
    │   ├── runtime-object-synthesis.md  运行时对象合成与字段发现(对象级 schema 自举)
    │   ├── platform-sdk-and-admission.md  平台 SDK 登录态复用 + 目录服→区服两段准入
    │   ├── case-il2cpp-inline.md  真实案例:IL2CPP + 平台 SDK 的内联服务端
    │   ├── account.md           账号体系与接口还原(登录/注册/接口替换)
    │   ├── combat.md            房间与战斗(局内、同步模型、服务端权威)
    │   ├── drops.md             掉落与物资(掉落表、掷骰、背包满转邮件)
    │   ├── gacha.md             抽卡/扭蛋(服务端掷骰、保底、重复转换)
    │   ├── decision-tree.md     逐层决策树(引擎/传输/封装/加密/序列化/架构)
    │   ├── protocol-spec.md     协议规格规范 + codegen 映射
    │   ├── codegen.md           由 Spec 生成/改造服务端(多语言)
    │   ├── client-languages.md  客户端语言分支(C#/AS3/Lua/JS/Java/C++)
    │   ├── from-installer.md    零输入自举:只有安装包怎么自产证据
    │   ├── cases.md             两个真实成功项目案例(Go / Node)
    │   ├── unity.md            Unity(IL2CPP/Mono/Lua) 深潜
    │   ├── unreal.md           Unreal(UE4/UE5) 深潜
    │   ├── windows.md          端游:Windows 运行服务端
    │   ├── termux.md           手游:Android/Termux 运行服务端
    │   └── repack-rename.md     改包名/重打包/签名/包名派生密钥
    ├── examples/                推演示例(同 skill,不同项目→不同方案)
    │   ├── A-unity-il2cpp-protobuf.md
    │   ├── B-ue-kcp-custom.md
    │   ├── C-unity-lua-http.md
    │   └── D-real-lua-client.md     真实 Lua 源码分析(1794 个 opCode)
    ├── tools/
    │   └── extract_interfaces.py    接口清单提取器(扒 opCode/路由)
    ├── templates/              参考代码与文档模板
    │   ├── mock_server.py      极简桩
    │   ├── frida_bypass_ssl.js / kcp_sniff.py
    │   ├── frida-redirect.js   客户端重定向:hook getaddrinfo/connect(native/il2cpp)
    │   ├── xposed-redirect/    LSPosed 模块骨架:改写 URL(Java/OkHttp 客户端)
    │   ├── register-site/      注册网站(index.html + 最小 Flask 后端)
    │   ├── AGENTS.md            工作区 AI 协作契约(放在项目根)
    │   ├── adr-template.md      架构决策记录(含「当前不做的事情」+ 回滚)
    │   ├── e2e-evidence-template.md  实机端到端证据(帧序表 + 差异解释 + 重连快照)
    │   ├── status-matrix.md     三轴状态矩阵(实现 / 测试 / 客户端验收)
    │   ├── login-chain.md       登录链条文档(启动到主场景每一步)
    │   └── function-checklist.md  功能清单(按子系统登记实通/未通)
    ├── extensions/              后续拓展(可选,不影响核心运行)
    │   ├── README.md           拓展说明与判断标准
    │   ├── bots.md             服务端人机(假玩家)
    │   └── bot-reverse.md      反推人机机制再复刻
    └── server/                 参考实现:长连接二进制协议服务端(Python)
                                   (GitHub: https://github.com/ShrugYu/game-client-to-server-reverse/tree/main/game-client-to-server-reverse/server)
    ```
    
    ## 端游 / 手游怎么跑
    
    | 平台 | 启动 | 常驻 | 客户端对接 |
    |------|------|------|-----------|
    | Windows(端游) | `deploy/start_windows.bat` | NSSM 服务 | 改 hosts → 127.0.0.1 |
    | Android(手游) | `bash start_termux.sh` | wake-lock + tmux | hosts / DNS 重定向 |
    | Linux 服务器 | `deploy/deploy.sh` | systemd | 域名解析 / 端口转发 |
    | Docker | `docker compose up -d` | 容器策略 | 同上 |
    
    ## 客户端语言不只有 Unity/UE
    
    | 客户端语言 | 反编译工具 | 能拿到源码? | 客户端补丁方式 |
    |-----------|-----------|------------|--------------|
    | C#(Unity Mono / .NET) | dnSpy / ILSpy | [x] 近源码 | 配置 / 重编译 / hook |
    | C#(Unity IL2CPP) | Il2CppDumper + IDA | [x] 仅签名 | Frida hook |
    | ActionScript3 / Flash | JPEXS FFDec | [x] 反编译 | 改源码重编译 |
    | Lua 热更 | unluac / luadec | [x] | 改 lua |
    | Java/Kotlin | jadx | [x] | smali patch / hook |
    | JS / H5 | beautify / sourcemap | [x] | 改 js |
    | C++ / UE | SDK dump | 注意: 结构 | hook / patch |
    
    详见 `references/client-languages.md`。
    真实项目案例(Go 服务端 + C# 启动器;Node/TS 服务端 + AS3 客户端)见 `references/cases.md`。
    
    ## 服务端语言也不固定
    
    Python 只是本仓库的参考实现。真实案例里:**Go**(某 Unity 手游)、**Node/TS**(某 Cocos 手游)。
    选型见 `references/codegen.md`,由 Spec 决定,不是照抄。
    
    ## 充值 / 发放(自托管支付)
    - `game.pay_grant_mode`: `direct`(直接进背包)/ `mail`(发邮件领取)
    - `game.pay_auto_success: true` → 点击购买直接成功
    - 商品表 `PAY_PRODUCTS` 键 = 客户端真实 `product_id`
    
    ##  后续拓展(可选,不影响核心运行)
    
    > **判断标准**:去掉它,游戏还能不能正常玩?**能 → 就是拓展。**
    >
    > 注意: **内联服务端不是拓展**:它是与「外部服务端」并列的**核心路线**(M11,见 `references/inline-server.md`),
    > 不适用本判断标准。
    
    | 拓展 | 文件 | 一句话 |
    |------|------|--------|
    | 服务端人机(假玩家) | `extensions/bots.md` | 多人游戏凑不齐人时用 AI 补位 |
    | 反推人机机制 | `extensions/bot-reverse.md` | 无参考项时,从客户端反推它的人机实现 |
    
    **人机为什么是拓展**:单人也能玩的游戏完全不需要;多人游戏缺人只是**体验受损,
    服务端本身能跑**,不影响「本地离线运行 / 登录 / 进游戏」。
    
    - 参考骨架:`server/app/logic/bots.py`(**只是骨架**,默认 `auto_fill: 0` 不生成假玩家)
    - 联调:`python server/test_bots.py`
    
    > 注意: 拓展**不参与**核心验收。没做拓展 ≠ 交付不完整。
    
    ## 两种服务端模板怎么选
    
    | | templates/mock_server.py | server/ |
    |---|---|---|
    | 用途 | 抓包后快速验证协议结论 | 真正部署、长期运营 |
    | 规模 | 单文件 | 分层工程 |
    | 能力 | 帧收发 + opcode 桩 | 数据库/状态机/客户端保护/部署 |
    | 何时用 | 逆向中期试探 | 逆向完成、要跑起来 |
    
    ## 最快上手
    
    ```bash
    # 1. 起服务端
    cd server && chmod +x start.sh && ./start.sh
    
    # 2. 联调自测(另开一个终端)
    ./venv/bin/python client_test.py --host 127.0.0.1 --port 8888 \
        --user alice --password 123456 --name Hero01
    # 期望输出结尾:[+] full flow OK
    
    # 3. 部署到服务器
    sudo bash server/deploy/deploy.sh
    ```
    
    ## 工作流
    
    ```
    [只有安装包?] 解包 → 判引擎/语言 → 自产 dump/lua/资源 → 抓包 → 动态dump
            ↓
    侦察引擎 → 协议分层 → 客户端静态定位 → 字段推断
            → 服务端建模 → 部署 → 客户端对接 → 闭环验证
    ```
    
    > 只有 `.apk`/`.ipa`/`.exe` 也能开始 —— 见 `references/from-installer.md`。
    
    ## 给 AI 的使用方式
    
    1. 把辅助文件(`dump.cs` / `*.lua` / `*.usmap` / SDK `.h`)丢进来。
    2. AI 按 `SKILL.md §1` 分类,自动进入 Unity 或 UE 分支。
    3. 逆向结论落到 `server/app/proto/`(消息号 + 结构)与 `config.yaml`(协议参数)。
    4. 起点验证用 `templates/mock_server.py`,终点交付用 `server/`。
    
  • SKILL.md 29.5 KB
    ---
    name: game-client-to-server-reverse
    description: 从游戏客户端(安装包/APK/IPA/EXE 或 dump.cs、lua、usmap、抓包等)反推服务端协议并复现可部署服务端。含阅读路径分派、原理层(primer:三要素/数据包协议/协议表/热更源码)、四阶段路线图(workflow-roadmap:静态分析→建工具+登录链→重定向→补包循环→清单迭代)、11 种反推方法选择器(含内联服务端路线)、接口清单提取器(tools/)、协议规格模板(protocol.spec.yaml)、wire 级定点改写(不等 schema 齐就能跑)、客户端地址来源清查、三轴状态与验收体系、发布运维清单、进度清单(TRACKER.md)、登录链条与功能清单模板。覆盖:账号登录注册、房间匹配、战斗、掉落物资、抽卡、充值与邮件、部署(本地/服务器/Termux/Windows)、客户端对接、闭环验证;并支持 Unity(IL2CPP/Mono/Lua)、Unreal(UE4/UE5)、Cocos2d-x/Cocos Creator(JS/Lua 热更)、C#、AS3、Java、JS 等客户端;含进服后的 Live 运营手册;并含「内联服务端」路线(客户端进程内合成响应、运行时对象合成与字段发现、平台 SDK 登录态复用与目录服/区服两段准入)。
    license: 仅限自研 / 已授权 / 离线单机目标用于互操作性研究与本地化部署;不得用于未授权破坏性测试或商业化他人资产(边界详见 README.md)
    compatibility: 需要 Python 3.10+(pip 可装 flask / PyYAML / aiosqlite);参考服务端可在本机、Termux、WSL2 或 Windows PowerShell 运行;联网可选(查资料、下载工具时)。跨平台:android / ios / windows / linux。
    metadata:
      version: "2.0"
      platforms: "android, ios, windows, linux"
      spec: 遵循 Agent Skills 规范(name 与父目录名一致;SKILL.md < 500 行;references 按需加载)
    ---
    
    # 游戏客户端 → 离线本地化 Skill
    
    > **定位**:把「只有客户端」的游戏,还原出「服务端协议 + 本地离线服务端」,并让原版客户端**本地离线运行**。
    > **适用**:互操作性研究、本地离线化 / 离线版建模、协议文档化、安全评估、游戏存档研究。
    > **边界**:仅用于自研、已授权或离线/单机目标。禁止未授权破坏性测试与商业化他人资产。
    
    > **版本 v2.0**(完整变更历史见 `README.md`)。2.0 较 1.x 新增:**原理层** `primer.md`、**四阶段路线图**
    > `workflow-roadmap.md`、**反推对象地图** `server-architecture-basics.md`、**重定向落点(含 Xposed/LSPatch 模块)**、
    > 复活 / 本地离线项目合集、登录链/功能清单模板、全库去除 emoji、跨模型阅读优化,并对 SKILL.md 大幅瘦身。
    
    ---
    
    ##  阅读协议(任何模型通用:GLM / DeepSeek / Claude / …)
    
    > 本包 = 1 份长文档(`SKILL.md`)+ 37 篇 `references/` + `templates/` + `tools/` + 参考实现 `server/`。
    > **不要一次读完**——会耗尽上下文,结果在没读完的地方开始猜。
    > **注意:多数模型不会自动加载 references,必须显式打开文件。**
    > **先读 `references/usage-policy.md`(使用守则与限制)——确认用途合规、不越界,再动手。**
    
    **标准三步(照做即可)**:
    ```
    1. 先读完本文件 §0(方法选择 + 铁律 + 工作流 + 补包循环)—— 必读核心
    2. 打开 references/reading-path.md → 按你的任务类型拿到 3~5 个文件的阅读顺序
    3. 按顺序打开并读完那些文件 → 再动手
    收工前:打开 references/reading-path.md §2,过一遍「收工前检查」
    ```
    
    **上下文很少时**(只能读 1~2 篇),按此优先级取:
    `SKILL §0` → `reading-path.md` → `wire-level-patching.md` → `closure-verification.md` → `client-address-sources.md`
    
    > 一句话:**SKILL.md 是导航,references 是正文**。先读导航,再按需读正文。
    
    ---
    
    ## 阅读地图:三条主线,先认清自己在哪条
    
    > 这个包讲三件事。**先确认自己卡在哪条主线,再去读对应文件**,不要混着读。
    
    | 主线 | 回答的问题 | 先读 | 关键产物 |
    |------|-----------|------|---------|
    | **A. 原理** | 「到底在反推什么?」 | `references/primer.md`、`references/server-architecture-basics.md` | (认知:三要素 / 协议表 / 热更源码 / 反推对象地图) |
    | **B. 路线** | 「先做什么、后做什么、在哪验收?」 | `references/workflow-roadmap.md` | `login-chain.md`、进度 |
    | **C. 方法** | 「用哪个手段取证?」 | `references/methods.md` | 选定方法 → `project-profile.yaml` |
    | **D. 规格** | 「怎么把结论固定下来?」 | `references/protocol-spec.md` | `protocol.spec.yaml`(唯一事实来源) |
    
    **一句话**:A 讲「找什么」,B 讲「什么顺序」,C 讲「怎么找」,D 讲「怎么记」。
    
    - **新手 / 第一次接触某客户端** → 先读 A(`primer.md` + `server-architecture-basics.md`),再看 B。
    - **知道自己要干什么、只是缺手法** → 直接看 C(`methods.md`)+ §0.0。
    - **已经拿到证据、要落盘** → 直接看 D(`protocol-spec.md`)。
    
    ---
    
    ## 0. 本 Skill 的定位:通用适配器,不是固定方案
    
    **核心认知(先读三遍)**:
    
    > 这个 skill 里附带的任何代码(`server/`、`templates/`、`examples/`)都只是**参考实现**,
    > **不是**对某个游戏的答案。每个游戏的引擎、传输、封装、加密、序列化、消息号、字段布局
    > **都不一样**。AI 必须先**读取用户实际给的项目**,产出**协议规格(Protocol Spec)**,
    > 再据此**生成或改造**服务端。
    
    ### 0.0 第一步:选对方法 (最容易做错的一步)
    
    > **方法选错 = 后面全白干。** 完整方法论见 `references/methods.md`。
    
    **先回答 3 个问题**:
    
    1. **有什么?** `PKG`(只有安装包) / `CAP`(抓包) / `SRC`(源码或反编译产物) /
       `RUN`(能运行) / `DBG`(能注入) / `MANIP`(能改客户端或代理) / `REF`(有同类实现)
    2. **要什么?** `DOC`(只要文档) / `FLOW`(跑通主流程) / `FULL`(完整服务端) / `PATCH`(改客户端)
    3. **什么约束?** 客户端保护 / 时间 / 权限
    
    **十一种方法速览**(详见 `methods.md`):
    
    | 代号 | 方法 | 何时用 |
    |------|------|--------|
    | M1 | 白盒源码法 | 有 `SRC` → **首选** |
    | M2 | 黑盒抓包法 | 有 `CAP` → 通用 |
    | M3 | 灰盒 Hook 法 | 有 `RUN`+`DBG` → 破加密 |
    | M4 | 辅助 Artifact 法 | 有 `.proto`/`.usmap`/SDK → 一步到位 |
    | M5 | 代理透传+逐接口替换 | 有 `CAP`+`MANIP` → **最稳落地** |
    | M6 | 差分探测法 | 定字段 |
    | M7 | 回放/录像法 | 有录像 → 战斗协议 |
    | M8 | 已知实现移植法 | 有 `REF` → 抄作业 |
    | M9 | 自环法 | 起桩看客户端要什么 |
    | M10 | 穷举校验法 | 兜底(优先 M3) |
    | M11 | 内联服务端法 | 有 `RUN`+`DBG`/`MANIP` 且可单机化 → **不起外部服务端** |
    
    **默认路线**(不知选什么就用它):
    ```
    M1/M4(有源码就抄)→ M2+M3(拿明文)→ M6(定字段)→ M9(跑通连接)
    → M5(透传+逐接口替换)→ 闭环验证
    ```
    
    > **另有一条主干分叉**:目标可单机化 + 客户端能注入/能改 → 走 **M11 内联服务端**(`inline-server.md`),
    > 传输 / 封装 / 加密层**不必还原**。先判分叉,再选方法。
    
    > 注意: 现实中都是**组合使用**(如 M1+M6+M5)。
    > 选定后记进 `project-profile.yaml` 的 `method` 段;发现更好路径可**中途切换**。
    
    ### 0.1 五条铁律
    
    1. **证据驱动,不臆测**:所有结论必须来自用户项目里的实际证据(dump.cs / lua / 抓包 / SDK / 资源文件 / 客户端行为)。
       没有证据 → 标注为「假设」并写进 Spec 的 `unresolved` 列表。
    2. **先产出规格,再产出代码**:中间必须有一个**语言无关的协议规格文件**
       (见 `references/protocol-spec.md` 与 `schema/protocol.spec.yaml`)。
       代码是从规格派生出来的,换游戏 = 换规格,不是重写一堆散代码。
    3. **闭环验证**:服务端必须能让**原版客户端**跑通到某个状态(握手/登录/进场景),否则推断就是错的。
    4. **不把「能跑」当成「跑通」**:端口在 listen、自环脚本报 `OK`、日志打了 [x],
       **都不是**闭环证据。宣布成功前必须逐条排除假阳性 →
       **见 `references/closure-verification.md`**(接手别人项目时尤其必读)。
       内联路线另有一套判据:**界面截图不算**,要看网络 / 状态层的完成点 → `inline-server.md` §6。
    5. **三件事分开记**:「服务端实现了」「自动测试覆盖了」「客户端验收了」。
       用一个状态列会掩盖后两项 → **见 `references/verification-and-status.md`**。
       不支持的输入要 **fail closed**,不要猜一个值。
    
    ### 0.2 双证据链
    
    任何字段结论 = **流量证据**(抓包里字节变化) + **代码证据**(客户端里的结构体/序列化代码)。
    单侧只算假设。
    
    ### 0.3 四层剥离(先分层,再解字段)
    
    不要一上来抠某个字节。先弄清
    `传输层 → 封装层 → 加密/压缩层 → 序列化层`,每层剥离后再看内容。
    
    ### 0.4 自适应工作流(本 skill 的主干)
    
    > **输入可能是零**:如果用户只给了一个安装包(apk/ipa/exe),先走
    > `references/from-installer.md` 的**自举流水线**把证据造出来,再进入下面的步骤。
    
    ```
    [第0步] 读取输入
       ├─ 只有安装包 ? ──▶ references/from-installer.md
       │    解包 → 判引擎/语言 → 自产 dump.cs/dll/lua/资源 → 抓包 → 动态 dump
       └─ 已有 dump/lua/抓包 ? ──▶ 直接进第1步
            ↓
    [第0.5步] 建立工作区契约 → 在项目根放 AGENTS.md(边界/目录分区/验证命令)  ← templates/AGENTS.md
            ↓
    [第1步] 产出「项目档案 project-profile」              ← references/adaptation.md
       扫描目录/文件,识别引擎、语言、网络库、资源格式、已知工具产物
            ↓
    [第2步] 证据清点 → 证据清单 evidence-inventory
       每条证据:来源、类型、能得出什么结论、置信度
            ↓
    [第3步] 决策引擎 → 逐层决策(引擎/传输/封装/加密/序列化/架构)  ← references/decision-tree.md
       每个决策点都有"若…则…"的分支,禁止默认套用参考实现
            ↓
    [第4步] 产出协议规格 protocol.spec.yaml                     ← references/protocol-spec.md
       语言无关,含 opcodes / frames / crypto / serialize / state_machine / client / subsystems
            ↓
    [第5步] 由规格生成/改造服务端(多语言参考)                   ← references/codegen.md
       选一个最贴近目标协议的语言;参考实现只提供"套路",不提供"答案"
            ↓
    [第6步] 平台部署(端游/手游/服务器)                         ← §14
            ↓
    [第7步] 客户端对接 + 闭环验证                                 ← §8/§9
    ```
    
    > **可选分叉(M11 内联服务端)**:若目标可单机化且客户端能注入 / 能改,可跳过传输与加密层的还原,
    > 直接在客户端进程内合成响应 → `references/inline-server.md`。
    
    **换个视角看同一件事——四阶段路线(见 `references/workflow-roadmap.md`)**:
    ```
    阶段0 静态分析(清点线索) → 阶段1 建工具+登录链文档 → 阶段2 重定向(让请求到达本地离线服务端)
    → 阶段3 补包循环(上游→下游,客户端能走到下一步才算通) → 阶段4 功能清单迭代
    ```
    > §0.4 是"**按产物**推进的流水线",四阶段是"**按顺序+验收**推进的路线图",两者是同一件事的两种记法。
    > 不确定从哪下手时,先看 `workflow-roadmap.md` 的全景图。
    
    ### 0.4b 补包循环 (进游戏阶段的核心工作模式)
    
    > 这是**"让客户端一步步前进"的唯一主线**。四阶段的**阶段 3** 就是它。
    > 前两步(静态分析、重定向)都只是为它铺路。
    
    **方向:从【登录链最上游】往下游补回包**
    
    ```
    ① 先回【服务器列表】     ← 地址来自这里
    ② 再回【登录握手】       ← 鉴权 / token / hash
    ③ 最后补【游戏需要的数据】← 角色 / 背包 / 任务 / VIP / 签到 …
    ```
    
    **通过标准:每补一块,以「客户端能走到下一步」为准**
    
    > 不是"服务端没报错",而是**客户端真的前进了一步**(界面前进 / 状态推进)。
    > —— 这是铁律 4「不把『能跑』当『跑通』」在补包阶段的落地。
    
    **出错怎么查:对照报错 + 两侧日志**
    
    拿 `客户端报错`、`网络日志`、`服务端日志` **三边对齐**,判断属于哪一种:
    
    | 症状 | 大概率原因 |
    |------|-----------|
    | 客户端没反应 / 静默断开 | **格式错了**(帧 / 字节序 / 字段布局 + 长度) |
    | 明确报错 / 空指针 | **数据缺失**(少字段、空子消息被省略) |
    | 走到了下一步但还是不对 | **逻辑根本没有写**(该给的条件数据没给) |
    
    **里程碑:能进入游戏主场景 = 成功一大半。**
    
    之后**工作流程就只有这一条路线**——不断让用户测试工作区目标,循环:
    
    ```
    测试 →(看日志)→ 定位 →(改)→ 修改 → 再测试   ↺
    ```
    
    **收尾:整理【功能清单文档】放到工作区,反复迭代直到基本完善** → `templates/function-checklist.md`。
    
    > 详细版(含三边对齐的排错顺序、假阳性排查):`references/workflow-roadmap.md` §阶段 3~4。
    
    ### 0.5 参考实现的正确用法
    
    | 参考物 | 是什么 | 怎么用 |
    |--------|--------|--------|
    | `references/primer.md` | **原理层**:三要素(配置/协议/代码)、数据包协议本质、协议号与协议表、热更源码、双证据链 | **第一次接触某客户端时先读**,避免"不知道在找什么" |
    | `references/workflow-roadmap.md` | **四阶段路线总纲**(静态分析→建工具+登录链→重定向→补包循环→清单迭代)+ 四层重定向表 | **动手前看全景图**;它把 §0.4 与 M1~M11 串成一条有验收的线 |
    | `references/server-architecture-basics.md` | **反推对象地图(逆向视角)**:你在还原哪几类服、网关留下的隐藏层、从包里认 Protobuf/KCP、同步模型决定"要还原多少逻辑" | **判断"你在反推哪几类服、去哪找证据"时读**;避免在网关层迷路 |
    | `server/` | 长连接二进制协议的参考实现(Python) | **只借鉴分层结构与套路**,协议参数全部按 Spec 改 |
    | `templates/mock_server.py` | 极简单文件桩 | 逆向早期快速验证用 |
    | `examples/*.md` | 不同游戏类型的**推演示例** | 看"给定这类证据会怎么决策",不要照搬结论 |
    | `references/cases.md` | **两个真实成功的服务端项目**(Go / Node) | 看真实项目的子系统与取舍,校准自己的方案 |
    | `references/client-languages.md` | 各客户端语言的反编译与打补丁 | 先判语言,再选工具 |
    | `references/cocos2d.md` | **Cocos2d-x / Cocos Creator 深潜**(.jsc/.lua 解包 / 自加密 HTTP-RPC / 多端口 / 热更 / 语言表还原 / **§H2 进服后的 Live 运营**:note 双层包裹、客户端本地存档合并、双表版本差异、自愈设计、部署纪律) | 目标是 cocos 客户端时必读(另见 `examples/E-cocos2dx-http-js.md` §8 Live 运营阶段) |
    | `references/live-ops.md` | **Live 运营手册**(进服后 30+ 子系统按投诉频率排序的补全次序 / 契约反推法 / 自愈设计 / 双表差异处置) | **进服之后**读,与 cocos2d.md §H2 互补、引擎无关 |
    | `references/from-installer.md` | **零输入自举**:只有安装包怎么自产证据 | 最常见的入口,必读 |
    | `references/reading-path.md` | **最小必读路径**(按任务类型给 3~5 个文件的阅读顺序) | **打开 skill 的第一件事** |
    | `references/methods.md` | **10 种反推方法 + 选择矩阵** | **动手前先看**(§0.0) |
    | `references/closure-verification.md` | **闭环验证 6 类假阳性 + 检查表** | **宣布"成功"前必看**;接手别人项目时第一条 |
    | `references/engineering-practices.md` | **实机跑通项目的工程范式**(fixture 回放 / 具名完成点 / 证据分档 / ADR) | **动手前定策略**;配套 `templates/adr-*.md`、`templates/e2e-*.md` |
    | `references/wire-level-patching.md` | **实现层核心**:不依赖 protobuf 运行时的 wire 级定点改写 | **写服务端之前必读**;决定"先跑起来还是先凑 schema" |
    | `references/client-address-sources.md` | **客户端地址来源清查**(六类来源 + 落点策略 + 重签后果) | **改包/对接客户端之前必读**;解决"改了 URL 还连官方" |
    | `references/verification-and-status.md` | **三轴状态 + 可达性分类 + 测试门禁** | 写 TRACKER / 宣称完成之前必读 |
    | `references/release-and-ops.md` | **发布、部署与运营**(配置冻结 / 端口族 / CDN / 备份) | 跑通之后要交付时看 |
    | `references/case-il2cpp-ecdh.md` | 真实案例:Unity IL2CPP + ECDH 登录服(进行中) | 看"分层结论怎么写 + 卡点怎么复核" |
    | `references/repack-rename.md` | 改包名 / 重打包 / 签名 / 包名派生密钥 | 想产独立安装包时看 |
    | `references/inline-server.md` | **内联服务端**:在客户端进程内合成响应(门面三类入口 / 回调投递纪律 / 延迟派发 / 双通路 / 内联版验收与假阳性) | **能注入且要单机化时先读这个** —— 它决定你要不要起外部服务端 |
    | `references/runtime-object-synthesis.md` | **运行时对象合成与字段发现**(对象级 schema 自举 / dump 循环 / 填值纪律 / 对象级→wire 级切换) | 走内联路线时必读;也可用来先拿一份可信字段清单 |
    | `references/platform-sdk-and-admission.md` | **平台 SDK 登录态复用 + 目录服→区服两段准入** | 大厂手游(第三方平台账号 SDK)登录卡点时读 |
    | `references/case-il2cpp-inline.md` | 真实案例:Unity IL2CPP + 平台 SDK 的内联服务端 | 看「不起服务端」这条路怎么走、哪些结论已被证伪 |
    | `schema/*.yaml` | 规格/档案模板 | 直接复制填,作为 AI 的中间产物 |
    | `extensions/` | **后续拓展(可选)**,不影响核心运行 | 跑通之后再考虑,见 §19 |
    
    > 注意: 反例:直接把 `server/config/config.yaml` 的 `opcode_size=2` 拿去套一个 1 字节消息号的游戏 —— 必错。
    > 正确做法:Spec 里写明 `opcode_size: 1`,再改代码。
    
    ### 0.6 启动阶段的两把钥匙 
    
    > 来自实机跑通到「主界面」的真实项目范式,详见 `references/engineering-practices.md`。
    > **先读这两条,能少走几周弯路。**
    
    **钥匙一:fixture 回放(抓包 → 原样重放 + 定点改写)**
    
    启动时客户端要一次性吃下几十个字段的嵌套状态(角色/背包/任务/VIP/签到…),
    纯靠反推字段拼几乎必卡在某个页面。更快的路径:
    
    ```
    原版客户端连原版服务器 → 抓一次完整启动序列 → 落成 fixture(含方向/消息号/seq/flag/原始 body)
    → 本地服务端按【原始顺序】重放 → 未知字段【原样保留】,只对已确认字段做 wire-level 定点改写
    ```
    
    **具体怎么实现**(不引入 protobuf 运行时的字段遍历 + splice 替换)
    → **`references/wire-level-patching.md`**(含帧 codec、字段遍历器、
    `patch_varint/bytes/string`、"空子消息也要保留"等实测经验)
    
    > 注意: 两个红线:① 不要用「缺字段的伪造对象」代替真实启动数据;
    > ② 一个账号的 fixture 带着它的 UID/角色数据,**不能直接发给别的账号**。
    
    **钥匙二:具名「完成点」做验收**
    
    不要写「登录成功」。要写**客户端上的一个可观察状态**,例如:
    「收到启动结束消息后进入**主界面**,**不是只保持 TCP 连接**」。
    再补一次**强制停止 + 重启 + 重连**,确认状态仍在。
    
    配合使用:`templates/e2e-evidence-template.md`(帧序表 + 主动解释帧长差异 + 重连快照)、
    `templates/adr-template.md`(写清「当前不做的事情」与回滚条件)。
    
    ---
    
    ## 1. 输入分类与预处理
    
    > 详细步骤(命令 / 工具 / 判断 / 常见坑)见 `references/phases-detail.md`。
    
    ## 2. 阶段一:侦察与引擎识别
    
    > 详细步骤(命令 / 工具 / 判断 / 常见坑)见 `references/phases-detail.md`。
    
    ## 3. 阶段二:流量获取
    
    > 详细步骤(命令 / 工具 / 判断 / 常见坑)见 `references/phases-detail.md`。
    
    ## 4. 阶段三:协议分层识别
    
    > 详细步骤(命令 / 工具 / 判断 / 常见坑)见 `references/phases-detail.md`。
    
    ## 5. 阶段四:客户端静态分析(分引擎)
    
    > 详细步骤(命令 / 工具 / 判断 / 常见坑)见 `references/phases-detail.md`。
    
    ## 6. 阶段五:字段语义推断
    
    > 详细步骤(命令 / 工具 / 判断 / 常见坑)见 `references/phases-detail.md`。
    
    ## 7. 阶段六:服务端建模与实现
    
    > 详细步骤(命令 / 工具 / 判断 / 常见坑)见 `references/phases-detail.md`。
    
    ## 8. 阶段七:部署(本地 / 服务器 / 容器)
    
    > 详细步骤(命令 / 工具 / 判断 / 常见坑)见 `references/phases-detail.md`。
    
    ## 9. 阶段八:验证与回滚
    
    > 详细步骤(命令 / 工具 / 判断 / 常见坑)见 `references/phases-detail.md`。
    
    ## 10. 输出物清单(交付模板)
    
    > 详细步骤(命令 / 工具 / 判断 / 常见坑)见 `references/phases-detail.md`。
    
    ## 11. 常见坑(速记)
    
    > **完整清单见 `references/closure-verification.md` §附「高频常见坑速查」**。这里只留最要记住的几条:
    
    - **改包顺序**:先"不改包 + 端口劫持"跑通协议,**最后**才改包(动手前读 )。
    - **raw deflate ≠ 加密**:先在 `decision-tree.md §4.0` 做 30 秒快筛,别在错误的加密假设上耗数小时。
    - **别把"能跑"当"跑通"**:端口在听 / 自环 OK / 日志打勾都不是闭环证据(铁律 4)。
    - **只跑通登录不算完**:状态机未闭环,客户端进场景即崩。
    
    ---
    
    ## 12. 工具速查
    
    | 用途 | 工具 |
    |------|------|
    | Unity IL2CPP dump | Il2CppDumper / Il2CppInspector / r2unity |
    | Unity Mono | dnSpy / ILSpy |
    | Lua 反编译 | unluac / luadec |
    | 反汇编 | IDA Pro / Ghidra / Hopper |
    | 动态 | Frida / x64dbg / lldb |
    | UE 资源 | FModel / UnrealPak / umodel / AesFinder |
    | UE SDK/mapping | Dumper-7 / UE4SS / usmap dumper |
    | 抓包 | mitmproxy / Charles / Wireshark / tcpdump |
    | 协议试解 | protoc --decode_raw / binwalk / ent |
    | **接口清单提取** | `tools/extract_interfaces.py`(本 skill 自带) |
    | **so 结构 / 依赖 / 符号** | `readelf -d`(NEEDED)、`readelf --dyn-syms --wide`(**FUNC + OBJECT 都要看**)、`strings -a`(找硬编码常量) |
    | **APK 编辑 / 资源 / 重打包 / 签名** | MT 管理器(含 MCP:`http://127.0.0.1:8787/mcp`,可 dex/资源/构建/签名一条龙)、apktool、apksigner |
    | **ZIP 原样复制重打包** | 本 skill `tools/` 的 repack 脚本(2GB 包秒级,只替换目标条目) |
    | **动态调试 / 内存** | Frida、`/proc/<pid>/maps`(看加载了哪些 so) |
    
    ---
    
    ## 13. 使用本 Skill 的 AI 行为契约(必读)
    
    > 完整行为契约(强制产物 / 硬性禁止 / 缺信息做法 / 决策可追溯 / 终点定义 / 回滚)见 `references/ai-contract.md`。
    >
    > **使用守则与限制(允许 / 禁止用途 + 硬性约束 + 自检清单)见 `references/usage-policy.md`——每次动手前先读。**
    
    ## 14. 运行环境与工具链(端游 / 手游)
    
    服务端代码同一套,差别只在**运行平台 + 保活 + 客户端对接** → 详见 `references/termux.md`(手游/Android)、`references/windows.md`(端游/Windows)。
    
    **环境选择**:
    ```
    只用 Python+SQLite      → Termux / 裸 Windows
    要 AES 或大量 Linux 工具 → Termux+proot Ubuntu / WSL2
    要长期对外在线           → 云服务器(Ubuntu + deploy.sh) / Windows NSSM
    仅本机自测              → 直接跑,客户端连 127.0.0.1
    ```
    > 注意: **保活**是最容易翻车的点:Termux 要 `termux-wake-lock` + 关电池优化;Windows 用**服务/计划任务**,别用前台窗口。
    > 脚本:`server/start_termux.sh`、`server/deploy/start_windows.bat`。
    
    ---
    
    ## 15. 充值 / 邮件 / 道具奖励发放
    
    > 客户端连的是**我们自己的服务端**,第三方支付 SDK 不存在 → **下单直接判成功并发放**("点击购买即成功")。
    > 参考实现:`logic/handlers/pay.py` / `mail.py`;商品表 `store/models.py: PAY_PRODUCTS`(键 = 客户端真实 product_id)。
    
    - **两条路径**(`game.pay_grant_mode`):`direct` 货币直接进角色 / `mail` 发附件邮件自助领取;`pay_auto_success:true` 跳过真实校验。
    - **协议**:`PAY_PRODUCT_LIST 0x0501/2`、`PAY 0x0503/4`、`MAIL_{LIST,READ,CLAIM} 0x0401~0x0406`、`MAIL_NEW_NTF 0x0407`。
    - **踩过的坑**:① 邮件推送必须在 PayRes **之后**发(否则被当成充值回包);② 领取/已读/删除共用结构但**必须用各自 opcode**;③ `order_no UNIQUE` 保**幂等**;④ 发放全在服务端。
    - **边界**:仅自建/离线/已授权;不接真实支付渠道、不伪造凭证。
    
    ---
    
    ## 16. 账号体系与接口还原
    
    > 目标:**官方客户端**用**我们自己注册的账号**登录我们的服务端。方法论 → `references/account.md`。
    
    - **账号从哪来**:A 客户端内注册 / **B 独立注册网站**(写同一 DB,客户端只登录;模板 → `templates/register-site/`)/ C 脚本批量建号。
    - **注意: 最大的坑——密码预处理**:客户端常先 `MD5(pwd+salt)` 再发包,**必须复刻其哈希**,否则注册的密码登不上。找法:读登录函数 / 抓两次包看密文是否固定;落 Spec 的 `account.password_hash/salt/client_side_hashing`。
    - **接口替换**:优先**改配置 / hosts**,不动二进制;有服务器列表就返回我们自己的地址(→ §8.6、`client-address-sources.md`)。
    - **典型链路**:`版本/公告 → 登录(账号+密文) → token → 服务器列表 → 带 token 连游戏服(TCP)`。
    - 账号接口逐条登记进 `TRACKER.md`。
    
    ---
    
    ## 17. 房间 / 战斗 / 掉落(核心玩法)
    
    > **没有战斗和掉落,游戏就不成立。** 方法论 → `references/combat.md`、`references/drops.md`;
    > 参考实现 → `server/app/logic/rooms.py`、`loot.py`、`handlers/{room,battle,drop}.py`。
    
    - **关系**:`大厅/场景 → 房间(匹配/组队) → 战斗(回合/实时) → 结算 → 掉落(掷骰) → 背包/邮件`。
    - **房间**:创建→加入/退出→准备→房主开始→战斗→回等待;必备**槽位上限、房主权限、全准备才开、房主退出移交、空房回收**。
    - **战斗(铁律:结算在服务端)**:客户端只发意图;回合制=服务端算结果并广播,实时=服务端定权威状态。必备回合推进/伤害/超时判负/异常退出;结束广播 `BATTLE_RESULT_NTF`。
    - **掉落**:`table_id → {rolls, entries:[{item_id, count[min,max], rate}]}`(从客户端配置表反推);**服务端掷骰**;背包满 → **自动转邮件补发**。
    - **协议**:房间 `0x0601~0x060B`、战斗 `0x0701~0x0704`、掉落/背包 `0x0801~0x0805`(详表见 `server/app/proto/opcodes.py`)。
    - **经济系统复用**:抽卡(`references/gacha.md`) / 商店 / 背包 **共用同一套「随机→结算→入库」**,不要写两套。
    
    ---
    
    ## 18. 快速上手(AI 第一次拿到项目的动作序列)
    
    ```
    [第 0 步 · 最重要] 选对方法
      · 回答 3 个问题:有什么 / 要什么 / 什么约束
      · 对照 references/methods.md §4/§5 选定方法 → 记进 project-profile.yaml 的 method 段
      · 默认路线:M1/M4 → M2+M3 → M6 → M9 → M5
    
    [如果只有安装包]
      0. 走 references/from-installer.md:解包 → 判引擎/语言 → 自产证据 → 抓包
         (详见该文件第 9 节的 10 项动作清单)
    
    [通用主流程]
      1. 列目录 → 跑 references/adaptation.md 的探测命令
      2. 填 out/project-profile.yaml(包括 needs 清单)
      3. 逐条记录证据 → out/evidence-inventory.md
      4. 走 references/decision-tree.md → 填 out/protocol.spec.yaml
         (含 client / subsystems 两段)
      5. 检查 protocol-spec.md 的「Spec 完成度清单」
      5.5 账号/接口:读 references/account.md → 在 TRACKER.md 登记接口清单
      6. 按 references/codegen.md 选语言 + 改造/新建服务端
      7. 增量验证(连接→登录→…)→ 回填置信度
      8. 部署(§14)+ 客户端对接(§8)→ 闭环
    ```
    
    看 `examples/` 里的三个推演,理解"同样流程、不同结论"。
    
    ---
    
    ## 19. 后续拓展(可选,不影响核心运行)
    
    > **判断标准**:去掉它,游戏还能不能正常玩?**能 → 它就是拓展**(放 `extensions/`),**不写进核心流程**。
    
    | 拓展 | 文件 | 一句话 |
    |------|------|--------|
    | **服务端人机(假玩家)** | `extensions/bots.md` | 多人副本凑不齐人时用 AI 自动补位 |
    | **反推人机机制** | `extensions/bot-reverse.md` | 无参考项时从客户端获取信息反推它的人机实现 |
    **人机为什么是拓展**:单人游戏完全不需要;多人缺人只损体验、**服务端本身能跑**;参考实现默认 `auto_fill: 0`(不生成假玩家)。
    **用法**(跑通后再做):核心链路先通 → 确认是否"必须多人" → `bot-reverse.md` 反推机制写进 Spec → `bots.md` 实现 → 调试(`bots spawn/clear/difficulty`)→ 原版客户端验收。
    
    
    > 注意: 拓展**不参与**核心验收(§13 的强制产物与闭环)。没做拓展 ≠ 交付不完整。
    > 不要因为拓展没做就认为交付不完整;也不要把它塞进核心流程。
  • TRACKER.md 10.4 KB
    # TRACKER —— 进度与接口清单
    
    > **简单维护,别写长。** 每完成/确认一个功能模块,就改对应行的「状态」。
    > 目的:一眼看清 **哪些没做 / 没验证 / 游戏本来就没有**。
    
    ## 状态图例
    
    | 符号 | 含义 | 要不要做 |
    |------|------|---------|
    | [x] | 已实现并验证 | 完成 |
    | [~] | 部分实现 | 继续 |
    | [ ] | 未实现 | 要做 |
    | [?] | 已实现但未验证 | 去验证 |
    | [-] | **游戏本身就没有** | **不做**
    
    >  **三轴分离(重要)**:`[x]` 只允许表示**一轴**。
    > 「服务端实现了」「自动测试覆盖了」「客户端验收了」必须分开记 ——
    > 完整规则与模板见 `references/verification-and-status.md` 与 `templates/status-matrix.md`。
    >
    > 严格档位(推荐用于矩阵):`Complete / Partial / Stub / Missing`。
    > **`Stub`(只返回兼容空响应)必须单独成档**,否则会被误当成已实现。
    >
    > 写测试或宣称完成前先给场景分类:
    > `client-reachable / transport-replay / server-boundary / save-integrity / client-characterization`。
    
    >  **验收判据(来自实机跑通范式,详见 `references/engineering-practices.md`)**:
    > 「端口在听 / TCP 连上」**只算 [~]**;必须收到**流程完成点消息**并进入**具名界面**
    > (如「启动结束消息 → 主界面」)才允许 [x]。
    > 结论行请挂证据档位:`static analysis` / `runtime observation` / `Inferred`。
    
    ---
    
    ## 接口 / 模块清单
    
    > 每次做完一个大模块,更新这里。
    
    ### 账号与登录(见 `references/account.md`)
    | 接口 | 状态 | 备注 |
    |------|------|------|
    | 版本检查 | [-] | TODO |
    | 注册 | [ ] | TODO |
    | 登录 | [ ] | TODO |
    | token 校验 | [ ] | TODO |
    | 服务器列表 | [-] | TODO |
    | 注册网站html | [ ] | TODO |
    
    > **先跑接口清单**:`python3 tools/extract_interfaces.py <源码目录> --out interfaces.md`
    > 把返回的清单按子系统贴到下面各表,再逐条标注状态。
    
    ### 游戏
    | 模块 | 状态 | 备注 |
    |------|------|------|
    | 角色列表 / 建角 / 选角 | [ ] | |
    | 进场景 / 移动 | [ ] | |
    | **房间 / 匹配** | [ ] | 建房/加入/准备/开始(`combat.md`) |
    | **战斗** | [ ] | 服务端权威结算(`combat.md`) |
    | **掉落 / 物资** | [ ] | 掉落表+掷骰+背包满转邮件(`drops.md`) |
    | **抽卡 / 扭蛋** | [ ] | 服务端掷骰+保底落库+重复转换(`gacha.md`) |
    | 商店 / 背包 | [ ] | |
    | 邮件 | [ ] | |
    | 任务 / 活动 | [ ] | |
    | 聊天 / 好友 / 公会 | [ ] | [-] 若游戏没有则标 [-] |
    
    ### 系统 / 拓展
    | 模块 | 状态 | 备注 |
    |------|------|------|
    | 客户端保护 / 服务端校验 | [ ] | 服务端侧仍要做权威校验 |
    | 客户端对接(改包名 / 重打包 / 签名) | [ ] | 见 `references/repack-rename.md`;**优先"不改包 + 端口劫持"** |
    | 资源分发 / CDN | [ ] | |
    | 人机(假玩家) | [-] |  拓展;游戏没有就不做 |
    
    ---
    
    ## 实战项目进度:项目A(某 Unity IL2CPP 手游 官服 2.0.109)
    
    > 案例细节见 `references/case-il2cpp-ecdh.md`。规则:**没在真实客户端上确认的,一律不写 [x]**。
    
    | 模块 | 状态 | 备注 |
    |------|------|------|
    | 协议分层(u32BE 帧 / 12B 头) | [x] | 真机 tcpdump,切帧 100% |
    | opCode 全量提取(1788 条) | [x] | `out/interfaces/` |
    | 登录服 ECDH 时序 | [x] | 服务器**先**发 base64 token |
    | 56B 挑战派生值算法 | [ ] | 34 种 KDF 候选全败 → 需读 `ProcessHandShakeMessage` |
    | 登录**业务响应** | [ ] | `login_server.py` 后续包只做“尝试解密+打日志” |
    | 自建服 8 端口监听 | [~] | 端口在听 ≠ 协议正确,进程身份待核 |
    | 客户端改包(新包名) | [~] | 能装、启动秒退(自校验);**未实机跑通** |
    | 原版客户端连自建服 | [?] | 未完成 |
    | `:8081` TUP/WUP | [-] | **已证伪**,非本游戏(APK 0 命中 + uid 无连接) |
    
    ## 实战项目进度:KiHan(平台 Unity IL2CPP 手游,内联服务端路线)
    
    > 案例细节见 `references/case-il2cpp-inline.md`。
    > 规则同样:**没在真实客户端上确认的,一律不写 [x]**。
    
    | 模块 | 状态 | 备注 |
    |------|------|------|
    | 网络门面拦截(两个命名空间) | [x] | SendMessage / SendUnicast / Add / RemoveMessageCallback |
    | 合成响应总表 + 主线程投递泵 | [x] | 队列存 gchandle,`thread_local` 重入守卫 |
    | 本地登录 / 进入链路 | [x] | 复用客户端原生 `ZoneLogin` 路径 |
    | 官方账号桥接连接 | [~] | 连接探测通过;业务取数未验 |
    | 官方区服准入 | [-] | **已证伪**:客户端放行 ≠ 服务端准入 |
    | wire 级协议规格 | [ ] | 本路线不急 |
    
    ---
    
    ## 未验证清单([?])
    
    > 列出"实现了但没在真实客户端上确认过"的项。
    
    - 自建服务端的自环测试(`client_test.py`)——与 `codec.py` 共用同一份**猜测**字段号,
      `[+] full flow OK` **不构成**对真实协议的验证(见 `closure-verification.md` 假阳性 ②)
    - 8 个监听端口的**进程归属**与协议正确性(监听存在 ≠ 服务可用)
    
    ---
    
    ## 确定不做([-])
    
    > 游戏本身就没有,除非用户提出需求。
    
    - (空)
    
    ---
    
    ## 更新日志(一行一条)
    
    ```
    YYYY-MM-DD  模块  状态变化
    ```
    ```
    2026-09-12  账号-登录  [ ] → [x]  (示例:跑通真实客户端登录)
    2026-09-13  知识库新增  —        references/repack-rename.md(改包名全清单 + 签名 + 重打包)
    2026-09-13  知识库新增  —        references/closure-verification.md(闭环 6 类假阳性 + 检查表)
    2026-09-13  知识库新增  —        references/case-il2cpp-ecdh.md(项目A 真实案例:帧格式/ECDH/已证伪项)
    2026-09-13  方法论升级  —        SKILL.md §0.1 三条铁律 → 四条(新增"不把能跑当跑通")
    2026-09-13  参考项目A(https://github.com/Nanako660/peach-haven)-登录服    [~] → [~]  (修 step2 未定义变量 + 会话清理;派生值与业务响应仍缺)
    2026-09-13  知识库新增  —        references/engineering-practices.md(实机跑通范式:fixture 回放/完成点/证据分档)
    2026-09-13  模板新增    —        templates/adr-template.md、templates/e2e-evidence-template.md
    2026-09-13  方法论升级  —        SKILL §0.6「启动阶段两把钥匙」;§13.1 强制产物 6 → 8 项
    2026-09-13  知识库新增  —        references/wire-level-patching.md(实现层核心:帧 codec + pb 字段遍历 + splice 定点改写 + presence 陷阱 + 幂等收据 + fixture 选号)
    2026-09-13  知识库新增  —        references/client-address-sources.md(地址六类来源 + 落点优先级 + 重签后果 + 阶段验收)
    2026-09-13  知识库新增  —        references/verification-and-status.md(三轴状态 + 可达性五分类 + 测试分组门禁 + fail closed)
    2026-09-13  知识库新增  —        references/release-and-ops.md(监听VS对外地址 / 端口族 / 启动期冻结配置 / CDN 版本策略 / 备份 / 变更语义)
    2026-09-13  模板新增    —        templates/AGENTS.md(工作区 AI 协作契约)、templates/status-matrix.md(三轴状态矩阵)
    2026-09-14  方法论升级  —        SKILL §0.1 四条铁律 → 五条(新增"三件事分开记");§13.1 强制产物 8 → 10 项
    2026-09-14  知识库新增  —        references/reading-path.md(最小必读路径:按任务类型分派 3~5 个文件;开读前/收工前检查;5 个反模式)
    2026-09-14  入口优化    —        SKILL 顶部 + README 顶部 增加"先读这个,不要一次读完"引导块
    2026-09-15  知识库新增  —        references/inline-server.md(内联服务端:门面三类入口 / 回调投递纪律 / 延迟派发 / 双通路 / 验收与假阳性)
    2026-09-15  知识库新增  —        references/runtime-object-synthesis.md(对象级 schema 自举 + 字段发现循环 + 填值纪律 + 对象级→wire 级切换)
    2026-09-15  知识库新增  —        references/platform-sdk-and-admission.md(平台 SDK 登录态复用 + 目录服→区服两段准入 + 卡点定位)
    2026-09-15  知识库新增  —        references/case-il2cpp-inline.md(真实案例:Unity IL2CPP + 平台 SDK 内联服务端)
    2026-09-15  方法论升级  —        SKILL §0.0 方法表 10 → 11 种(新增 M11 内联服务端法)、§0.4 主干加分叉、§0.5 参考表 +4、铁律 4 增内联判据
    2026-09-15  方法论升级  —        methods.md 10 → 11 种、decision-tree 顶部增加「外部 vs 内联」路径分叉、reading-path 增加任务类型 ⑧
    2026-09-15  版本        —        1.7 → 1.8
    2026-09-22  版本        —        1.8 → 1.9 → 2.0
    2026-09-22  知识库新增  —        references/primer.md(原理层:三要素 / 数据包协议 / 协议表 / 热更源码 / 双证据链)
    2026-09-22  知识库新增  —        references/workflow-roadmap.md(四阶段路线总纲 + 四层重定向表)
    2026-09-22  知识库新增  —        references/server-architecture-basics.md(反推对象地图:还原哪几类服 / 网关隐藏层 / 同步模型)
    2026-09-22  知识库新增  —        references/phases-detail.md(§1~§10 反推主流程详细版:命令/工具/判断/坑)
    2026-09-22  知识库新增  —        references/ai-contract.md(AI 行为契约完整版:强制产物/禁止/终点/回滚)
    2026-09-22  模板新增    —        templates/login-chain.md、templates/function-checklist.md
    2026-09-22  模板新增    —        templates/register-site/
    2026-09-22  模板新增    —        templates/frida-redirect.js、templates/xposed-redirect/(客户端重定向:native / Java)
    2026-09-22  方法论升级  —        methods.md M8 增「现成实现速查 + 复活 / 本地离线项目合集」;protocol-spec.md 增「人类可读协议文档格式」
    2026-09-22  结构合规    —        skill 目录重命名为 game-client-to-server-reverse(frontmatter name == 父目录名)
    2026-09-22  元数据合规  —        frontmatter:version/platforms 移入 metadata;补 license / compatibility
    2026-09-22  体量优化    —        SKILL.md 656 → 467 行(§1~§10、§13 下沉到 references,标题/编号保留 + 指针)
    2026-09-22  入口优化    —        全库去 emoji;新增跨模型阅读协议;README 加「目录映射」表;篇数同步
    2026-09-22  维护契约    —        工作区根 AGENTS.md(契约 + 守则合并:完善内容放哪 / 如何接入 / 硬约束 / 自检脚本)
    ```
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related