game-client-to-server-reverse
从游戏客户端(安装包/APK/IPA/EXE 或 dump.cs、lua、usmap、抓包等)反推服务端协议并复现可部署服务端。含阅读路径分派、原理层(primer:三要素/数据包协议/协议表/热更源码)、四阶段路线图(workflow-roadmap:静态分析→建工具+登录链→重定向→补包循环→清单迭代)、11 种反推方法选择器(含内联服务端路线)、接口清单提取器(tools/)、协议规格模板(protocol.spec.yaml)、wire 级定点改写(不等 schema 齐就能跑)、客户端地址来源清查、三轴状态与验收体系、发布运维清单、进度清单(T
Install
npx skills add https://github.com/ShrugYu/game-client-to-server-reverse/tree/main/game-client-to-server-reverse
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install shrugyu-game-client-to-server-reverse@llmmart
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.mdM8 补入现成实现速查(按类型) + "先查别人做过没" + 先锁版本;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.mdM8 加"多人/联机复活项目"检索入口; ⑫ 签名绕过: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.md2026-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 的使用方式
- 把辅助文件(
dump.cs/*.lua/*.usmap/ SDK.h)丢进来。 - AI 按
SKILL.md §1分类,自动进入 Unity 或 UE 分支。 - 逆向结论落到
server/app/proto/(消息号 + 结构)与config.yaml(协议参数)。 - 起点验证用
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 个问题:
- 有什么?
PKG(只有安装包) /CAP(抓包) /SRC(源码或反编译产物) /RUN(能运行) /DBG(能注入) /MANIP(能改客户端或代理) /REF(有同类实现) - 要什么?
DOC(只要文档) /FLOW(跑通主流程) /FULL(完整服务端) /PATCH(改客户端) - 什么约束? 客户端保护 / 时间 / 权限
十一种方法速览(详见 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 五条铁律
- 证据驱动,不臆测:所有结论必须来自用户项目里的实际证据(dump.cs / lua / 抓包 / SDK / 资源文件 / 客户端行为)。
没有证据 → 标注为「假设」并写进 Spec 的
unresolved列表。 - 先产出规格,再产出代码:中间必须有一个语言无关的协议规格文件
(见
references/protocol-spec.md与schema/protocol.spec.yaml)。 代码是从规格派生出来的,换游戏 = 换规格,不是重写一堆散代码。 - 闭环验证:服务端必须能让原版客户端跑通到某个状态(握手/登录/进场景),否则推断就是错的。
- 不把「能跑」当成「跑通」:端口在 listen、自环脚本报
OK、日志打了 , 都不是闭环证据。宣布成功前必须逐条排除假阳性 → 见references/closure-verification.md(接手别人项目时尤其必读)。 内联路线另有一套判据:界面截图不算,要看网络 / 状态层的完成点 →inline-server.md§6。 - 三件事分开记:「服务端实现了」「自动测试覆盖了」「客户端验收了」。
用一个状态列会掩盖后两项 → 见
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.
Reviews (0)
No reviews yet.
No comments yet.