InBox: 知乎回答 - Unity Profiler Agent 真机性能采集工具逆向复盘 (尹鹏帅)

This commit is contained in:
2026-08-20 01:34:13 +08:00
parent eed3dbdee7
commit fc4ee14b6f

View File

@@ -0,0 +1,368 @@
---
source: 知乎回答
url: https://www.zhihu.com/question/28824284/answer/2073491183658067620
created: 2026-08-20
author: 尹鹏帅
tags: [zhihu, unity, 性能优化, 逆向工程, AI编程]
---
# 如何正确高效的查找游戏「卡顿」原因——Unity Profiler Agent 逆向复盘
> 来源:[知乎回答](https://www.zhihu.com/question/28824284/answer/2073491183658067620) · 作者:尹鹏帅
一个独立运行于 Unity Editor 之外的真机性能采集工具的诞生记:从 adb forward 到 PD3U 数据流,从逐位爆破调用栈开关到给 LLM Agent 装上 MCP 义肢。全文含大量二进制协议细节与踩坑实录,建议配咖啡阅读。
## 一、缘起:一个不算梦想的"梦想"
先交代一下背景。因为工作内容的原因,我一直没有深入研究过 Unity 的性能优化——即使偶尔涉及到,也是浅尝辄止:打开 Profiler 看一眼,"哦,这里卡了",截个图发到群里,然后就没有然后了。多年下来,技能树这一块始终是灰色的,无所成长。
但时代变了。随着常规开发任务逐渐被 AI 承担,我自然也想着让 AI 来帮我分析性能,也算随大潮重新站到起跑线上了——我有 GPT 和 Claude我怕谁
理想很丰满现实却有点骨感。Unity Editor 的 Profiler 是一个彻头彻尾的 GUI 程序,它和 AI Agent 的配合方式只有一种:**截图**。你截一张 Profiler 的图丢给大模型,它眯着眼睛(好吧它没有眼睛)辨认半天,告诉你"Rendering 那一栏好像有点高"。我们来算一笔账:一张 Profiler 全界面截图,分辨率稍微高一点就是一两千 token一次像样的性能分析怎么也得看 CPU 时间轴、Hierarchy 展开、Memory 明细、Rendering 计数器好几张图;再追问两轮,上下文里就堆了上万 token 的图片,模型还只能从像素里"猜"数字。token 吃不消不说,这种"看图说话"的分析精度,实在对不起"性能优化"这四个严谨的字。更要命的是截图是有损压缩的信息——Hierarchy 里折叠的节点、表格里滚出屏幕的行,截图里根本没有。
与此同时,我心里一直有个埋藏已久的"梦想"**Editor Profiler 到底是怎么和手机通信的?那些数据又是如何组织的?** 每次用 USB 连上手机、在 Editor 里点下 Record看着帧曲线跳动我都会好奇这根数据线里跑的到底是什么字节。Unity 从不公开这些协议,网上能搜到的只言片语也大多过时或者以讹传讹。
于是两件事合流了:我决定开发一个 **Unity Profiler Agent**——一个独立于 Unity Editor 的性能分析工具,直接和手机上的 Unity Player 对话,拿到和 Editor Profiler 同源的原始数据,再把这些结构化数据喂给 AI Agent让 AI 性能分析的效率最大化。截图是模拟信号,协议数据才是数字信号,我要给 AI 接上数字信号。
当然,这些协议都是 Unity 闭源的,没有任何公开文档,目标很模糊,只能一点点"PUA" AI 去搞了。所谓 PUA就是不断给它压担子、提要求、否定它的错误答案、逼它设计实验验证——后面会看到这套玩法贯穿了整个项目。
这件事,本来做也可,不做也可。中间也几度放弃过,所幸大致完成了目标,也算有所收获。这篇文章就是整个开发过程的复盘,关键技术点一个不落,坑也一个不留地交代。文中所有协议细节——魔数、位语义、字节布局——都来自对 Unity 2022.3 的真机实测,代码注释里保留了完整的"证据链";如果你拿着别的 Unity 版本对不上号,那很正常,逆向结论从来都是有保质期的。
## 二、技术选型与逆向方法论
| 层 | 选型 |
| --- | --- |
| 后端 | Go 1.25 + Wails v3.0.0-beta.8 |
| 前端 | React 18 + TypeScript + Vite |
| 图标 | lucide-react |
| 虚拟化 | @tanstack/react-virtual十万级帧数据流畅滚动 |
| LLM 接入 | ACPAgent Client Protocolvia acp-go-sdk |
选型的逻辑,一条条说:
**1. 协议解析是核心Go 是舒适区。** 二进制流解析、goroutine 读写循环、环形缓冲、channel 事件泵,这些都是 Go 的主场,`encoding/binary` 一把梭。更重要的是测试——`go test` 秒级跑完全部用例,这个反馈速度对 AI 协作至关重要,后面会反复强调。
**2. 要桌面应用,不要命令行。** 性能数据需要堆叠图、虚拟化表格、层级树、时间轴,命令行展示不了。那为什么不直接做 Unity Editor 插件?因为初衷就是"脱离 Editor"——Editor 又重又慢,而且我要的恰恰是搞清楚 Editor 底下发生了什么寄生在它体内还怎么观察它。Electron 太重Wails 用 Go 做后端、系统 WebView 做前端,产物就是一个几 MB 的 exe正合适。至于用 v3 的 beta 版有没有风险?有,但 beta.8 已经相当稳,而且 v3 的服务绑定和事件机制比 v2 干净不少,值得冒险。
**3. LLM 接入走 ACP而不是自己调 API。** 这是最容易被质疑的一个决策:直接调 OpenAI/Claude 的 API 不是更简单吗?简单,但我没有 API——我手里只有 Codex 和 Cursor 的订阅,一个 API Key 都没有。这听起来是限制,想清楚之后反而成了最正确的路线:订阅制 Agent 本来就是我每天都在用、已经付过费的算力,自己调 API 不仅要额外掏钱,还得管 Key、管上下文窗口、管工具调用循环——等于自己重写一个 Agent。而 ACPAgent Client ProtocolZed 编辑器主导的开放协议)让我可以直接复用这些现成的 AgentGemini CLI 加 `--experimental-acp`Claude Code 有社区适配器 `claude-code-acp`Codex 也能以 app-server 模式接入。我零配置成本、零维护成本Agent 能力还随官方升级自动变强。站在协议的肩膀上,比站在 API 的泥地里好——何况我根本没有 API 的泥地可站。
**4. 方向反过来行不行?不行,而且是刻意不行。** 也有人问:为什么不把 UnityPerfAgent 做成一个 MCP Server让 Codex、Cursor 这些现成的 Agent 客户端来接?这样连聊天 UI 都不用写了。我认真考虑过,最后否决了,理由有两个。一是**不想管理繁多的 MCP**:每换一个 Agent 客户端就要配一遍 MCP配置散落各处版本升级、路径变更、端口冲突全是维护负担工具没做成先成了 MCP 管理员。二是**环境隔离**Codex 和 Cursor 里已经挂着一堆别的 MCP 和 Skill真把性能数据工具挂进去Agent 每次分析都要在一大堆工具描述里挑挑拣拣,上下文被污染、工具被误调,分析质量反而下降。所以最终的选择是反过来的——**在本工具中内嵌一个干净的 Agent 环境**Agent 由工具拉起,能看到的 MCP 只有内置的那一个,系统提示词里只有性能优化这一件事。专一,才能专业。
![Image 1](https://picx.zhimg.com/50/v2-32abcfb35c470065e71e518818f80546_720w.jpg?source=2c26e567)
### 2.1 逆向方法论:没有文档,就制造证据
在正式进入技术细节之前,先把这次逆向的工作方法交代清楚,因为后面每一章都在用它。
面对闭源协议,我们的标准动作是四步循环:**抓包 → 比对 → 假设 → 实验**。
抓包是地基。让 AI 写了一个 TCP 中间人:架在 adb forward 的本机端口和真实连接之间,双向转发的同时把字节流原样 dump 成文件。然后用 Editor 连真机,做一组"标准动作"——打开 Profiler 窗口、勾选/取消 Call Stacks、断开重连——每个动作对应一份抓包文件。动作之间要隔开几秒方便按时间戳把报文和操作对上号。
比对是放大镜。两份抓包做 diff勾选 Call Stacks 前后Editor 多发了哪条报文?报文体的哪几个字节变了?变化往往就集中在一个 u32 上,剩下的就是语义猜测。假设要大胆,实验要小心——每个假设都设计成"只改一个变量"的真机实验改一个位、看一次行为、记一条结论。AI 在这个循环里的角色是"实验仪器制造商"抓包工具、diff 工具、假 Player、测试用例全是它写的而"下一步验什么"永远由人拍板。
还有一条原则贯穿始终:**所有结论必须落成代码注释里的"实测记录"**。你会在代码里看到大量"实测自 Unity 2022.3 Editor"、"真机逐位实测"、"实测帧率直接减半"这样的注释。逆向项目的代码注释不是解释代码的,是保存证据的——三个月后没人记得某个魔数是哪来的,注释里的实测记录就是唯一的考古层。
## 三、第一关USB 数据线里的秘密
### 3.1 adb forward 的正确姿势
要让电脑和手机上的 Unity Player 通信,第一步是搞清楚通道。很多人(包括一开始的 AI以为 Unity Profiler 走的是设备 `tcp:55000` 直连——这是流传最广的以讹传讹。55000 确实是 Unity 早期远程 profiling 用过的端口,但 Editor 对 Android USB 设备的真实做法是:
`adb forward tcp:<本机端口> localabstract:Unity-{应用包名}`
`localabstract:` 指向的是手机上一个 Unix Domain Socket名字是 `Unity-` 加包名。这是 Unity Player 在 Development Build 下启动时创建的监听套接字USB 调试开启后由 adbd 桥接出来。所以工具的第一块代码 `internal/adbforward` 要做三件事:
1. 执行 `adb devices -l` 枚举设备,只认状态为 `device` 的行(`unauthorized``offline` 统统跳过),机型从 `model:xxx` 字段里抠出来、下划线换成空格;
2. 解析手机的 `/proc/net/unix`,找到 `Unity-{bundleId}` 这个 socket 名——包名不一定等于我以为的那个,所以还要结合前台应用信息兜底;
3. 执行 `adb forward tcp:0 localabstract:Unity-{包名}``tcp:0` 让系统自动分配本机端口),再连 `127.0.0.1:<分配到的端口>`
这里容易和另一个知识点打架Unity Player 在设备上确实有一个固定的 TCP 监听端口范围5500055016Player 启动时在这个闭区间里挑一个空闲端口,默认 55000Editor 的 Wi-Fi 直连Direct Connection扫的就是它。但请注意那是**设备侧**的端口,而且属于 Wi-Fi 通道USB 通道走的是上面的 Unix 抽象套接字Player 那一端根本没有端口的概念。而 `tcp:0` 里的 0 是**本机侧**端口——它只是 adb 在本机开的转发隧道入口,让 adb 自动分配一个空闲端口并打印出来即可Player 永远看不到它,自然也不受 5500055016 的约束。两个端口,一个在手机上、一个在电脑上,中间隔着 adb 这条隧道,互不相关。
![Image 2](https://picx.zhimg.com/50/v2-806dc67ce89b555841fcdafab636b3f6_720w.jpg?source=2c26e567)
这种脏活累活 AI 写得又快又好,但**解析规则必须人来定**——AI 不知道你的测试机上 `adb devices` 输出到底长什么样,`/proc/net/unix` 里到底有几个候选 socket只有实测才知道。这是整个项目的第一个方法论结论**AI 负责把规则写成代码,人负责用真机把规则逼出来。**
### 3.2 PlayerConnection一切协议之上的协议
通道建好后,连上本机端口,就进入了 Unity 的 **PlayerConnection** 世界。这是 Editor 和 Player 之间的通用消息通道Profiler 只是跑在它上面的一种业务脚本调试、Memory Profiler 等也走这里)。
逆向的第一步永远是抓包。让 AI 写个 TCP 中间人脚本,架在 adb forward 和真实连接之间,用 Editor 连一次真机,把双向字节流完整 dump 下来。对着十六进制发呆半小时后,好消息出现了:这个协议的报文头非常规整——
`magic(4) = 0x67A54E8F | messageId(16) | length(4) | body(length)`
4 字节魔数 + 16 字节消息 ID + 4 字节小端长度 + 负载。那个 16 字节的消息 ID 是一组固定的 GUID 常量Editor 和 Player 用它区分消息种类。从抓包里一条条抠出来,写进代码:
* Player → Editor`MsgIDPlayerInfo`(引擎信息)、`MsgIDAnnounce`(宣告文本)、`MsgIDProfilerData`Profiler 原始数据流);
* Editor → Player`MsgIDProfilerSetup`(标志位)、`MsgIDProfilerAreas`(采集区域掩码)、`MsgIDProfilerCategories`(分类名单)。
这些 GUID 没有任何规律,就是硬编码常量。逆向工程的魅力和枯燥都在这里:**没有文档,只有证据。** 每条 GUID 背后都是一次"抓包—比对—确认"的循环。
![Image 3](https://pica.zhimg.com/50/v2-f150f80d6cf408a28ac1ae994cf7d5d6_720w.jpg?source=2c26e567)
### 3.3 伪装成 Editor
连上之后Player 会主动推送两条关键消息。
**引擎信息**`MsgIDPlayerInfo`31 字节定长):协议版本(实测为 2、能力位是否支持 Deep Profile、是否支持托管调用栈、平台枚举Android 是 11、引擎版本号。版本号的编码很有意思是 5 个 u32major、minor、patch、releaseType、releaseNumber其中 releaseType 是枚举,`a/b/f/p/x` 对应 `0/1/2/3/4`——所以 `2022.3.47f1` 就是 `(2022, 3, 47, 2, 1)`。第一次把这个位运算还原成版本字符串的时候,有种破译密码的快感。
**宣告文本**`MsgIDAnnounce`):一段 `[IP] ... [ProjectName] ...` 的人类可读文本,里面有 `Flags``[Debug]` 标记。这里要纠正一个广泛流传的误解:**`[Debug]` 只表示脚本调试managed debugger和能不能 Attach Profiler 无关;`Flags` 的 bit1 才是 SupportsProfile。** 所以 Development Build 即使 `[Debug] 0` 也可以 Attach Profiler。这个结论是真机逐次试出来的网上很多教程把两者混为一谈。
而我们要做的,是以 `UnityEditor` 的身份完成握手。抓包显示Editor 连上后**立即**下发一串报文,根本不等 Player 打招呼:
```
ProfilerSetup(flags=0) // 先清零调用栈开关
ProfilerAreas(mask=0x7FFD) // 采集区域掩码:除 GPU 位以外全开
ProfilerCategories(...) // 9 个分类名单Video, Physics2D, Physics, Render,
// Virtual Texturing, Audio, Memory, Lighting, Gui
Announce("UnityEditor...") // 自报家门
```
`0x7FFD` 这个掩码是实测值——Editor 打开 Profiler 窗口时发的就是它。照着发Player 就把你当成 Editor开始按帧推送 Profiler 数据。我们的工具自报身份为 `UnityEditor.Profiler(主机名)`,前缀与 Editor 一致,方便在 Player 侧日志里区分连接来源。第一帧数据解出来的那一刻,成就感不亚于第一次 Hello World。
### 3.4 假连接Player 的独占槽位
这里必须记录一个工程大坑:**Player 同时只服务一个 Profiler 客户端。** 如果 Unity Editor 的 Profiler 还连着手机,或者上次进程残留了没断开的连接,你的新连接就会一直排在 Player 的等待队列里——TCP 是通的,开启报文也发出去了,但 Player 一个字节都不回。
这种"假连接"最容易让人怀疑人生:代码明明没错,网络明明通的,数据就是没有。排查了一下午才确认是槽位被占。所以代码里专门定义了 `ErrPlayerSilent`:握手 5 秒无响应就报错,并明确提示"Player 同时只服务一个 Profiler 客户端,请确认 Unity Editor 的 Profiler 已断开、且本工具没有残留连接;仍不行就重启游戏进程"。好的错误提示,是逆向工具对使用者最大的温柔——你永远可以相信,下一个踩坑的人就是三个月后的你自己。
## 四、PD3UProfiler 数据流的解剖
握手完成后,真正的主菜上桌:`MsgIDProfilerData` 消息里装的 Profiler 原始数据流。这是整个项目最硬核的部分8 月 14 日一整天的两个提交都耗在这里。
### 4.1 流头与分块
数据流的第一包是 56 字节流头:
`magic "PD3U"(4) | 保留(4) | formatVersion(4) | ... | mainThreadID(8) | 引擎版本(5×u32)`
`PD3U` 四个字符(小端读出 `0x55334450`)大概是 "Profiler Data 3 Unity" 的缩写。`formatVersion` 是个日期戳Unity 2022 是 `0x20220328`——这个值后面会救我们一命,先记住它。`mainThreadID` 指出哪条线程流承载主线程的逐帧事件这个字段至关重要4.4 节细说。
流头之后是分块chunk结构为
```
头(20)magic 0xB10C7EAD | seq(4) | threadIdLow(4) | threadIdHigh(4) | size(4)
负载(size)
尾(8)seq+1(4) | magic 0xB10CF007
```
注意头尾两个魔数——你品,你细品:`0xB10C7EAD` 是 "BLOCK READ"`0xB10CF007` 是 "BLOCK FOOT"。Unity 的工程师在二进制协议里埋了 leetspeak 彩蛋,逆向出来的一瞬间我笑了半天。这种小发现是逆向工程里为数不多的乐趣,也是确认"我理解对了"的路标——彩蛋能对上,说明字节布局八九不离十。
分块设计有两个要点:
1. **按线程多路复用。** threadId 是 `high<<32|low` 拼出的 64 位值,与线程元数据里的 ID 一致;`threadId = -1` 的是全局元数据流。主线程、渲染线程、Job 工作线程各自一条流,在同一个 TCP 连接里交织到达。
2. **记录可跨块。** 同一线程的负载要按 seq 顺序拼成连续字节流才能解析一条记录可能前半截在这个块、后半截在下一个块。所以解析器必须先缓冲、再消费消费不完的尾部留给下一块。seq 校验是唯一的完整性保障:序号断裂就意味着中间丢了字节,这条流从此不可信。
![Image 4](https://picx.zhimg.com/50/v2-7234c8e1a35e09b47adc2feb2ef2b01d_720w.jpg?source=2c26e567)
### 4.2 解析器的骨架cursor 与 panic/recover
逐字节解析这种嵌套变长结构,代码很容易写成"每读一个字段检查一次长度"的防御性屎山。这里用了一个干净的模式:定义一个带边界检查的 `cursor` 小端读取器,`take(n)` 越界时直接 `panic(errShortRecord)`;每条记录的解析函数外层用 `recover` 兜住——数据不足就回滚游标、等下一块,其他错误才视为失步。这样记录解析代码可以写成线性的"读、读、读"一个边界检查都不用写可读性和正确性双赢。Go 的 panic/recover 用在解析器这种"深度递归 + 统一出口"的场景,是教科书级的合理用法。
### 4.3 元数据流与事件流
拼接好的字节流里是两种记录,各自独立编号。
**元数据流**threadId = -1负责建立查询表marker 定义ID → 名字、分类、标志位、元数据个数、分类定义ID → "Scripts"/"GC" 这类名字、线程定义ID → 线程名)、以及——开启 Call Stacks 后才会出现的——**托管方法符号表**。我们项目实测 Unity 2022 手机包的方法表约 33 万条Player 会一次性全量下发,用来把调用栈里的代码地址还原成方法名。
**事件流**是逐帧的采样事件:`0x22` 帧边界、`0x24/0x27/0x2A/0x2B` 四种 Begin 采样(普通/带扩展/带元数据/带对象上下文)、`0x25` End 采样、`0x2C` 计数器、`0x03` 调用栈、`0x38` 线程时钟戳……Begin/End 构成一棵采样树,计数器则是 Draw Calls、内存分项这些数值。
然后是这个协议最恶心的设计(或者说,最二进制协议的设计):**记录类型会撞号。** 事件流里 `0x01``0x0F` 这些类型号,同时也表示"随后跟着 N 个元数据取值"——比如 Begin(GC.Alloc) 之后会跟一条 `0x01` 记录,里面装着这次分配的字节数。于是 `0x03` 既是"3 个取值"又是调用栈记录,`0x04` 既是"4 个取值"又是 LegacyStats 定长统计。怎么消歧?看上下文:调用栈记录的第二个字节固定为 0而取值类型枚举里 0 是 None、不会出现。代码里就是靠 `peek()` 偷看下一个字节来分流的。逆向这种协议,本质上是和引擎作者的编码习惯博弈——他偷懒复用了类型号空间,你就得替他擦屁股。
### 4.4 帧边界:只有主线程说了算
每条线程流里都有帧记录(`0x22`),但**帧边界只能由主线程流划定**。为什么因为渲染统计Draw Calls、Triangles 这些是渲染线程上报的如果每条线程都能切帧一帧的数据会被切成好几段UI 上就没法看了。所以解码器的分工是:
* 主线程流:决定帧边界、根采样时长、各 ProfilerCategory 的自耗时;
* 其他所有线程流:只贡献计数器,按"当前帧"归集。渲染线程滞后主线程不足一帧,这么归集和 Editor 的显示行为一致。
![Image 5](https://pic1.zhimg.com/50/v2-7ba1c0ae7ecfc4f8c71473c28ceaccdb_720w.jpg?source=2c26e567)
对应的容错策略也是分级的:非主线程流 seq 断裂(失步),只放弃那一条流——它无非少报几个计数器,逐帧指标不受影响;但主线程流或元数据流一旦失步,缓冲区已丢弃、字节流无法重新对齐,就是致命错误。此时宁可主动断开并报错,也不能让界面显示"已连接"却永远停在同一帧,还白占着 Player 唯一的 Profiler 槽位。**Fail loudly, not silently**——工具软件的基本素养。
### 4.5 跨帧的幽灵采样
还有一个只有实测才会发现的坑Unity 的 `Main Thread``PlayerLoop` 这类根采样是**跨帧保持打开**的——Begin 在第 N 帧End 在第 N+1 帧。如果只在栈弹空时才记录节点,那么卡顿帧(恰恰是你最想分析的帧!)的 Hierarchy 就是空的,只剩分类自耗时,我点进去一脸懵。
解法是在帧边界做一次"快照":把仍未闭合的采样链原样写入当前帧的 Hierarchy每层的时长截断到帧边界自耗时要扣掉已闭合子节点和未闭合内层链然后把栈上所有 entry 的起始时间重置为帧末、清空子节点和元数据让它们在新的一帧里继续累积。另外必须给快照深度加上限64 层)——卡顿帧上未配对的 Begin 可能堆到上万层,全量展开会撑爆递归和 UI。这种防御性限制在逆向项目里不是过度设计是保命你永远不知道黑盒对面会吐出什么。
![Image 6](https://pic1.zhimg.com/50/v2-726845fec16d30a803b065d0c5f1dfb1_720w.jpg?source=2c26e567)
### 4.6 版本校验:数据流版本才是真相
项目要求只支持 Unity 2022AGENTS.md 里写死的规矩)。但握手时怎么判断?看 Player 自报的版本号?
实测给了我们一记响亮的耳光:测试用的游戏包是内部改装引擎(基于 Unity 2022.3.47f1),它自报的版本号居然是 `2018.4.12f1`!如果按版本号拦截,这个包会被当场误判出局。
但数据不会撒谎:它的 Profiler 数据流头里,`formatVersion` 依然是 `0x20220328`。所以最终的判定逻辑是——**优先看数据流格式版本,数据流版本未知时才退回看版本号**。改装引擎可以随手改版本字符串,但只要它没动 Profiler 子系统,数据流格式就还是 2022 的格式,就能正常解析。这个经验值得加粗:**逆向项目里,行为证据永远优先于自我声明。** 后面在错误处理、能力探测上,这条原则反复应验。
## 五、逐位爆破Call Stacks 开关之谜
8 月 14 日下午到晚上的两个提交之间,穿插着一场精彩的"位运算探案",值得单独一章。
`MsgIDProfilerSetup` 的报文体是一个 u32 标志位。它的语义是什么?直觉(以及 AI 的第一反应bit0 = Deep Profilebit1 = Call Stacks。多合理的猜测啊Editor 工具栏上就这两个开关。然后真机实测直接打脸:按这个映射勾选 Call Stacks 后,我想看的 GC.Alloc 调用栈**一条都没有**,倒是冒出来一堆原生分配的栈。
没办法只能逐位爆破一次只置一个位连真机看行为变化。32 个位,挨个试。最终结论写在代码注释里,堪称本项目的"镇库之宝"
* **bit0**:采集**托管分配**GC.Alloc的调用栈——这才是 Editor 工具栏 "Call Stacks" 勾选后我真正要看的东西;
* **bit1**:采集引擎**原生分配**UnsafeUtility.Malloc 等)的调用栈,额外开销很小,可以顺带打开;
* **bit3**:采集原生**分配器**Native.Alloc/Dealloc/Realloc的调用栈——实测**帧率直接减半**,采集开销大到影响被测对象,坚决不随 Call Stacks 一起下发;
* 其余位:无任何可观测效果。
![Image 7](https://pica.zhimg.com/50/v2-32b13dc18d7a76f19bde83ad5c3450a2_720w.jpg?source=2c26e567)
那 Deep Profile 呢?整个 u32 逐位测完,**没有一位**能让采样树变深。结论只能写在注释里Deep Profile 应是另一条尚未逆向出的报文——它需要 Editor 侧插桩配合Player 光收一个标志位是变不出完整调用树的。之前把 Deep Profile 塞进 bit0 的做法,反而悄悄改掉了调用栈模式,属于典型的"以为懂了其实没懂"。最后工具栏里的 Deep Profile 按钮直接禁用,并在 UI 上注明"需 Editor 插桩,独立工具中禁用"。诚实面对未知,比假装支持重要。
## 六、测试策略:养一只假 Player
逆向项目最大的痛苦是:没有真机就什么都验证不了,而真机测试又慢又麻烦——插线、开游戏、点连接、等数据,一轮下来五分钟,改一行代码验证一次,心态会崩。
解法是 8 月 14 日就建立的,也是整个项目最正确的投资:**在单测里养一只假 Player**`internal/unityprofiler/player.go`)。
这只假 Player 不是简单的 mock而是一个完整的协议对端实现它走真实的 PlayerConnection 报文、发真实的 PD3U 流头和分块、按线程多路复用、发 Begin/End/计数器/调用栈记录,甚至故意把 ChunkSize 设得很小,专门覆盖"记录跨块重组"的路径。它还能模拟各种真机幺蛾子:
* `HeaderBeforeAnnounce`:模拟"连接时 Player 已经在采集"——数据流头先于宣告到达。这种情况握手期间收到的数据流报文必须原样留存,丢掉流头就再也拿不到主线程 ID整个会话报废
* `paused`:模拟 Android 弹系统对话框导致的 OnApplicationPause——Player 暂停上报但连接不断,对话框关掉后同一条连接继续吐帧。所以读取循环的超时策略是"继续等"而不是"断开"10 秒一轮,永不放弃;
* 渲染统计单独走渲染线程上报,和真机一致,逼解码器正确处理多线程归集。
![Image 8](https://pic1.zhimg.com/50/v2-73d6c593f6858e65eba78581d354c908_720w.jpg?source=2c26e567)
因为假 Player 和真机走的是**同一套解码路径**单测置信度非常高。8 月 18 日那次 +1676/-835 的大重构能一次改完不翻车,全靠这张安全网。
对 AI 协作来说,这更是生死攸关的基础设施。**有了秒级运行的测试闭环AI 的每次改动都能立刻验证**"PUA AI"才有抓手——它给出解析逻辑跑测试红了就打回去重写绿了就进入下一轮审查。没有这个闭环它给你编一段看似合理的二进制解析你只能瞪着眼睛猜对错那才是真正的灾难。前端同理vitest + testing-library帧格式化、层级布局、时间轴合并这些纯函数全部有单测。项目从头到尾保持 `go test ./...``npm test` 全绿,这是 AGENTS.md 里写死的规矩,也是对 AI 的紧箍咒。
## 七、性能设计:采集工具自己不能成为性能问题
性能分析工具如果自己卡顿,那就是行为艺术了。几个关键的性能决策:
![Image 9](https://pic1.zhimg.com/50/v2-936c7c77a9c359cbcc6acef6839d6445_720w.jpg?source=2c26e567)
**1. 定长二进制帧。** 帧数据用固定长度二进制编码,单帧负载 164 字节v2兼容 v1 的 68 字节)。字段按 Unity Profiler 模块分组基础FPS、CPU/GPU/MainThread/Render 耗时、GC Alloc、CPU Usage 八分类耗时Rendering/Scripts/Physics/Animation/GC/VSync/UI/Others、Memory 九项细分GC/Gfx/Audio/Video/Profiler/System 的 Used/Reserved、Rendering/Audio/Physics/UI 计数器。定长编码意味着零反射、零序列化开销、零 GC 压力,`binary.LittleEndian` 按偏移量直写直读。
**2. 环形缓冲。** 会话存储是固定容量环形缓冲,默认 10.8 万帧——约 30 分钟 @60fps。追加 O(1),范围读取 O(n),内存有硬上界,长时间采集无所畏惧。原始数据流单独设 1 GiB 上限,导出存档时再把流头补回去。
**3. 双重节流。** 后端事件按 250ms 节流推送,前端按 500ms 节流刷新。60fps 的帧数据不需要 60fps 地刷 UI——人眼看不出来但 React 会被活活累死。
**4. 虚拟化 + 懒加载。** 帧表格用 @tanstack/react-virtual 全虚拟化渲染,十万行数据流畅滚动;数据按 200 帧分块从后端懒加载,不一次灌给前端。
**5. Canvas 直绘。** Profiler 的堆叠时间轴不用任何图表库Canvas 直接画堆叠柱。图表库在"每帧一根柱子、十万帧"的场景下全是开销,直绘几十行代码搞定,还能精确控制配色对齐 Unity 的分类色。
**6. 并发纪律:锁里绝不回调。** 采集会话的读取循环要把解码出的帧回调给服务层,而服务层会在自己的锁里查询会话状态。如果读取循环持有会话锁时回调,就形成"会话锁 → 服务层锁"和"服务层锁 → 会话锁"两条相反路径,撞上了就是整个界面卡死、连日志都不再动。解法很朴素:解码出的帧先攒在局部切片里,放开会话锁之后再统一回调。这条纪律写在 `readLoop` 的注释里,因为这种死锁在测试里很难复现,只能靠纪律防住。并发代码的坑,三分靠工具,七分靠规矩。
## 八、UI像素级致敬 Unity Profiler
8 月 15 日到 17 日的几个提交,主题是把 UI 做到"对齐 Unity Profiler 2022"——我希望打开工具就有肌肉记忆般的熟悉感:
* 工具栏Record 状态、Deep Profile / Call Stacks前者因第五章的原因禁用并注明、Clear、帧前进/后退、Current 跳转、当前帧耗时与 FPS
* 模块开关CPU Usage、GPU Usage、Rendering、Memory、Audio、Video、Physics、UI
* 堆叠时间轴:点击选帧,录制时自动跟随最新帧;
* 详情面板CPU Hierarchy / Timeline、Memory Simple、各模块计数器表。
### 8.1 前后端之间Wails 服务层与事件泵
顺带说说 Wails 这层胶水的设计,它决定了整个应用的"手感"。后端 `services` 包暴露一个 `PerfService`Wails 自动生成 TypeScript 绑定,前端像调本地函数一样调 Go 方法——连接设备、查询帧范围、取单帧 Hierarchy、启动 Agent 会话,全是类型安全的跨语言调用。反向的数据推送走事件机制:采集循环产出的帧经服务层按 250ms 节流后,以事件广播给前端;前端再按 500ms 节流刷新视图。两层节流之间是帧数据的环形缓冲UI 任何时候要历史数据都是现查现取,不依赖事件流重放。
这套"拉取为主、推送为辅"的分工是刻意为之:推送只负责告诉前端"有新数据了",真正的数据永远通过查询接口拿。这样即使前端卡顿丢了几个事件,视图状态也能从后端完整重建——事件是提醒,不是真相。做实时数据类应用,这条原则能避开一大半"界面和实际对不上"的灵异 bug。
## 九、.data 文件:第二条逆向战线
8 月 16 日到 18 日,开辟第二战场:支持导入 Unity Editor Profiler 保存的 `.data` 文件。动机有两个:我以前用 Editor 采的数据不能浪费;更重要的是,离线文件让 AI 分析可以脱离真机环境——把文件发给同事,同事的工具和 Agent 就能复盘。
`.data` 是另一种二进制格式,和 USB 数据流完全不同28 字节块头(格式版本 + body 大小 + 引擎版本五元组)、帧结束标记 `0xAFAFAFAF`、文件结束标记 `0xDEADFEED`——又一个彩蛋,"dead feed"文件喂完了Unity 工程师的冷幽默无处不在。字符串是 NUL 结尾且按 4 字节对齐的;采样是 `diskSample` 定长结构markerID + 总耗时 float32 + 起始时间戳 + 子节点数 + GC 分配字节),子节点紧跟其后递归排列。解析起来比 USB 流简单——没有多路复用、没有跨块——但边界检查一个不能少:单帧 body 上限 128MB、marker 定义上限 10 万条、每帧线程上限 512、字符串上限 1MB全是防畸形文件的保险丝。
![Image 10](https://pica.zhimg.com/50/v2-dbabb5d879d8347889a49ceb813bbc4e_720w.jpg?source=2c26e567)
8 月 18 日晚上的 `Add Unity 2022 Profiler data support` 是全项目最大的一次提交44 个文件,+1676/-835。而它真正的价值在 16 分钟后的下一个提交 `Unify Live and Offline Profile Frames` 里完成——**统一内存帧模型**`profileframe.Frame`实时采集PlayerConnection 解码、存档回放Replay、.data 导入,三条数据源全部归一化成同一个内存模型,再进 UI 会话。
这个重构的意义怎么强调都不为过。之前三条链路各搞各的投影UI 层要处理三种数据形态每加一个展示字段要改三处统一之后UI 只认一种帧,采集、回放、导入只是三个不同的"生产者"。架构上这叫防腐层,工程上这叫"终于不用写三遍相似代码了"。大文件导入还配了字节级进度回调和进度弹窗(`ImportProgressEvent` 事件),几百 MB 的 .data 导入时 UI 不假死,我随时能取消。
## 十、规则诊断AI 的"前菜"与"锚点"
在把数据交给 LLM 之前,先做一层规则化诊断(`internal/analyzer`)。规则不复杂,但每条阈值都是移动端 Unity 2022 的经验值:
* **帧尖峰**CPU 耗时超过中位数 2 倍且不低于 33.4ms(约 30fps 一帧),超阈值 2 倍升级为严重,最多报告 20 帧;
* **GC 尖峰**:单帧 GC 分配超 256KB
* **低帧率**:平均 FPS 低于 30 告警;
* **DrawCall**500 告警、1000 严重;
* **内存增长**:每分钟涨超 30MB 视为疑似泄漏(少于 30 帧不做趋势分析,避免小样本误报)。
为什么有了 LLM 还要规则分析?更主要的原因是**可定制**:不同项目的性能预算天差地别——休闲游戏 30fps 就及格,竞技游戏 60fps 都嫌少;有的项目单帧 GC 分配 256KB 就要拉警报,有的项目 1MB 才算事。这些标准写在规则里,每个项目可以按自己的预算定制阈值和规则集,诊断口径是确定的、可评审的、可进版本库跟着项目演化的;而丢给大模型自由发挥,同样的数据今天说"偏高"明天说"可接受",结论没法复现。在此之上还有两个附带好处:一是**便宜**,规则分析零 token先把明显的问题筛出来没问题的会话根本不用惊动大模型二是**锚定**结构化的规则报告JSON通过 MCP 工具提供给 Agent下一章细说能显著减少大模型"看图说话"式的臆测——它再天马行空,也得解释为什么规则报告里第 12345 帧有个 4 倍中位数的尖峰。规则报告是 LLM 诊断的前菜,更是它的锚点。
## 十一、系统提示词:给 Agent 立规矩
Agent 不是接上就好用的,系统提示词反复打磨过很多轮,最终版本的核心条款:
> 你是 Unity 移动游戏性能优化专家,目标平台为 Unity 2022。涉及具体性能结论时应先查询数据不要臆测。这是一个持久会话后续用户消息可能是对前文的追问应结合已有上下文仅查询完成当前请求所需的数据。用户指定帧号时优先查询该帧及其 CPU Hierarchy除非用户明确要求总体概览否则不要重新进行全量性能概览。每次收到实际用户请求后必须立即调用完成任务所需的 MCP 工具并在同一回合给出结论、数据证据以及按优先级排序的优化建议;不要只回复确认、复述请求或给出执行计划。
每一条都是踩坑踩出来的:"不要臆测"是因为 Agent 会对着空气分析内存泄漏,编得有鼻子有眼;"不要重新全量概览"是因为追问时它会傻乎乎地把几万帧再拉一遍token 原地爆炸;"不要只回复确认"是因为它会礼貌地说"好的,我来帮您分析",然后就没有然后了。**写提示词和写代码一样,都是把模糊意图变成确定行为**——只不过编译器换成了概率模型,报错方式变成了"它就是不照做"。
## 十二、MCP给 Agent 装上数据的义肢
8 月 19 日的 `Add MCP performance tools and Agent logging`,是整个项目画龙点睛的一笔:让 Agent **按需查询**数据。通过 ACP 的 `session/new.mcpServers` 机制,创建会话时注入一个内置的 stdio MCP Server暴露五个工具
* `performance_session_summary`连接状态、聚合指标与规则分析Agent 的"第一眼看全貌"
* `performance_frames`:范围帧摘要,单次最多 200 帧,防止它一口吃成胖子;
* `performance_frame`:单帧完整计数器;
* `performance_cpu_hierarchy`:单帧 CPU 层级树,定位热点函数靠它;
* `performance_analysis`:重新运行内置规则分析。
架构上有个巧妙之处MCP Server 进程就是工具自己的 exe`unityperfagent.exe mcp` 子命令),它只拥有 JSON-RPC stdio 传输这一层皮;真正的数据在桌面主进程的内存里(实时采集会话),所以子进程把 `tools/call` 通过 **loopback HTTP** 转发给主进程处理。桥接只监听 `127.0.0.1`,并用每次运行随机生成的 32 字节令牌做 Bearer 鉴权——本地工具也不能裸奔谁知道我机器上还跑着什么。链路是Agent ↔ stdio ↔ MCP 子进程 ↔ 带令牌的 loopback HTTP ↔ 主进程采集会话。每一跳都可观测、可鉴权、可替换。
![Image 11](https://picx.zhimg.com/50/v2-37008a928915dbcb8f7ef2a251c6446e_720w.jpg?source=2c26e567)
同时加了 **Agent Log** 功能:菜单栏一点,打开独立原生窗口,实时显示 Agent 对话和 MCP 请求/响应的完整 payload上限 1000 条环形存储)。调试 Agent 行为时,这个窗口就是 X 光机——Agent 到底查了哪帧、MCP 到底回了什么、提示词哪条被无视了,一目了然。和 AI 协作开发的经验同样适用于 AI 产品的开发:**可观测性优先,不然你分不清是工具错了、协议错了还是模型错了。**
### 12.1 一次真实的 AI 诊断长什么样
光说架构不过瘾,来看一眼这条链路跑起来之后的实际效果。我连上手机、采了几分钟数据,然后在聊天面板里敲一句:"刚才过场动画掉帧严重,帮我看看。"
接下来发生的事情,在 Agent Log 窗口里一览无余Agent 先调 `performance_session_summary` 拿到全貌——平均 FPS、P95 帧耗时、规则报告里躺着三条"帧耗时尖峰";它注意到尖峰集中在某个区间,于是调 `performance_frames` 拉了那 200 帧的摘要,锁定最痛的一帧;再调 `performance_cpu_hierarchy` 展开那一帧的采样树,发现 `AssetBundle.Load` 的子树占了主线程 60% 的时间,下面挂着解压和 IO 两个大头。最后它给出结论:过场动画的卡顿是同步加载资源导致的,建议改为异步加载并预加载,附上了每一帧的数据证据。全程三次工具调用,几千 token结论精确到函数名。
对比一下旧世界:同样的问题,你要截五张图、发两万 token模型告诉你"加载好像有点慢,建议看看资源管理"。从"看图说话"到"精确制导",差的不是模型聪明程度,而是数据通道。这就是 MCP 义肢的意义。
![Image 12](https://picx.zhimg.com/50/v2-5f5c36e11d4b2b0d3ea50251eb334683_720w.jpg?source=2c26e567)
## 十三、最后一个提交:换掉 NLUX
8 月 19 日傍晚,收官提交 `Replace NLUX with assistant-ui Agent Chat`+4056 行,把聊天 UI 从 NLUX 整体换成 assistant-ui同时把 Codex 后端从简单封装升级为持久的 app-server 模式一个进程一个线程Prompt 追加 turn会话状态由服务端维护
为什么要换NLUX 上手快、demo 漂亮,但定制能力有限——聊天气泡里要渲染工具调用过程、要支持右键上下文菜单复制、要导出对话、要细粒度控制主题,这些真实需求叠上去之后,与其在 NLUX 的 API 缝隙里雕花,不如换到组件化程度更高的 assistant-ui。这个决策本身也是个教训**UI 库选型时,"demo 好看"和"能承载真实交互复杂度"是两回事**。好在替换发生在项目早期、聊天面板还没长太大的时候,成本可控;再晚一周,这就是一次伤筋动骨的重构了。
至此12 个提交一周时间工具成型USB 直连真机、逆向协议采集、Unity Profiler 同款 UI、规则诊断、ACP 接入任意 Agent、MCP 按需查数、Agent Log 全程可观测。
## 十四、复盘:和 AI 结对逆向的一手经验
最后聊聊方法论,这才是这篇文章真正想留下的东西。
**1. AI 是逆向工程的超级杠杆,但方向盘必须在你手里。** 抓包解析、假 Player、测试用例、UI 组件AI 的产出速度是人的十倍以上——first commit 一上午 130 个文件就是证明。但"bit0 到底是什么语义"这种问题AI 只能给假设,真机实测必须人来设计和裁决。逆向的本质是"提出假设 → 设计实验 → 验证/证伪"的循环AI 能把每一步的执行成本压低一个数量级,但实验设计的品味无法外包。它最擅长的是把"我怀疑第二个字节是标志位"变成可运行的验证代码,而"为什么怀疑"永远来自人。
**2. 测试闭环是 PUA AI 的基础设施。** 没有假 Player 之前AI 改协议解析我只能干瞪眼;有了假 Player每次改动 `go test ./...` 秒级出结果AI 自己也能跑测试自我修正。和 AI 协作的效率上限,不取决于模型多聪明,取决于你的验证闭环多快。闭环越快,你敢让 AI 动的刀就越大——+1676/-835 的重构敢一把过,靠的就是全绿的安全网。
**3. 防御性限制不是过度设计。** 16MB 报文上限、64 层快照深度、1 GiB 原始流上限、128MB 单帧上限……面对闭源黑盒,你永远不知道下一个字节流里有什么妖魔鬼怪。每一个上限都是给未知买的保险丝,烧断了保险丝,总比烧了整个屋子强。
**4. 错误信息是产品的一部分。**`ErrPlayerSilent` 那句"请确认 Unity Editor 的 Profiler 已断开",比协议解析本身更救我的命。逆向工具的使用者(首先就是三个月后的我自己)面对的都是未知现场,错误提示里多一句上下文,就少一次怀疑人生。
**5. 几度想放弃的时候,是"已完成的部分"救了项目。** 这个项目中途确实几次想撂挑子——PD3U 流解不出帧的时候、Call Stacks 位语义对不上的时候。但每次回头看看adb 通道是通的、握手是成功的、测试是绿的,"已经完成的部分"构成了继续下去的惯性。把大目标切成能独立验证的小里程碑,不只是工程实践,也是心理建设。
**6. 给 AI 的需求要像给编译器的代码一样确定。** "帮我分析性能"这种需求丢给 Agent它会给你一篇正确的废话"先查 session summary发现尖峰帧后展开该帧 Hierarchy结论必须带帧号和数据证据"才能跑出可用的结果。提示词工程的本质不是咒语,是需求工程——你在群里给实习生派活怎么说,给 Agent 就怎么说,只不过 Agent 永远不会不好意思追问,所以你的模糊会被它忠实地放大。
**7. 一周做完 ≠ 一周能做。** 这个项目能一周成型,前提是 Go/React/二进制协议/桌面应用每一块我都有积累AI 补齐的是速度和体力不是判断力。如果面对完全陌生的领域AI 产出的"看似合理"反而会成为最大的风险源——你无法审查你看不懂的东西。所以我的结论是AI 时代最保值的能力,恰恰是那些能让你"审得动 AI 产出"的老本行。
## 十五、已知边界与未来的坑
诚实是逆向项目的美德,最后把已知的边界交代清楚:
**只支持 Unity 2022。** 这是 AGENTS.md 里写死的第一条规矩,不是技术上限而是精力上限——每多支持一个大版本,数据流格式、消息 ID、能力位都可能变测试矩阵直接翻倍。握手和数据流头两道关卡都会校验版本不支持的版本会明确报错而不是静默解出错误数据。
**Deep Profile 仍是未解之谜。** 前面说过,`MsgIDProfilerSetup` 的 32 个位里没有它。它大概率需要 Editor 侧的配合报文,或者干脆是构建期插桩的开关。这个坑留给未来——也许哪天抓一次 Editor 勾选 Deep Profile 时的完整报文序列,就能水落石出。
**GPU 计数器是缺的。** Editor 下发的区域掩码 `0x7FFD` 里 GPU 位是关的——真机上 GPU Profiler 需要额外的驱动层支持,很多 Android 设备上 Editor 自己也拿不到。UI 里 GPU Usage 模块保留了开关,但大多数情况下它是空的,这一点文档里写明了,免得日后的自己以为是 bug。
**AI 分析的质量上限取决于数据粒度。** 目前 MCP 暴露的是帧级计数器和 CPU 采样树,对内存泄漏这类问题只能看趋势、不能看对象引用链——那是 Memory Profiler 的领域,走的是另一套快照协议。要不要逆向它?老实说,先让我歇一个月。
## 结语
回到最初的那个"梦想"Editor Profiler 是怎么和手机通信的现在我可以回答了——PlayerConnection 报文、GUID 消息 ID、PD3U 分块流、按线程多路复用、元数据/事件双流,以及藏在二进制里的 `B10C7EAD` 彩蛋。数据又是如何组织的?采样树、计数器、方法符号表、跨帧幽灵采样,一清二楚。
而比答案更重要的是这些数据现在以结构化的形式流淌在一个开放工具里AI Agent 可以通过 MCP 随时查询任意一帧的完整现场——哪一帧卡了、卡在哪个函数、那帧分配了多少 GC、DrawCall 是多少,全是精确的数字,不是从截图里猜的。截图分析性能的时代,在我这里翻篇了。
这件事本来做也可,不做也可。所幸做了。