War3 Replay Overlay 技术实现原理
本文面向后续维护者,系统说明这个插件当前是如何工作的,包括数据采集、统一中间态、UI 渲染、窗口定位、图标资源、单文件发布,以及当前实现中的关键取舍。
1. 目标与边界
这个插件的目标不是修改游戏,也不是注入游戏进程,而是以"外部透明悬浮层"的方式,把观战 / replay 场景下的关键信息展示出来。
当前版本重点覆盖:
- 资源:金 / 木 / 人口
- 英雄:头像、等级、技能、物品
- 基地等级:
T1 / T2 / T3 - 可选双链路:
- 直接读取
War3StatsObserverSharedMemory - 或通过
Bridge.Agent -> MMF间接读取
- 直接读取
- 使用
WPF叠加渲染,不侵入游戏本体
当前不做的事情:
- 不做 DLL 注入
- 不修改游戏内存
- 不依赖游戏 UI Mod
- 不直接把最终发布产物提交进 Git 仓库
2. 整体架构
项目的核心思想是:先把各种数据源统一收敛成一个稳定的中间态 MatchSnapshot,再让 UI 只消费这个中间态。
War3StatsObserverSharedMemory
War3ObserverSharedMemoryStateSource
Bridge.Agent 输出的 MMF JSON
MemoryMappedJsonStateSource
JSON 文件 / Mock
JsonFileStateSource / MockReplayStateSource
MatchSnapshot
OverlayViewModel
MainWindow.xaml
CloseButtonWindow.xaml
可以把整个工程理解成 4 层:
- 数据源层
- 中间态层
- ViewModel 组装层
- Overlay 呈现层
3. 核心项目分工
src/War3ReplayOverlay.Core
负责通用能力:
MatchSnapshot以及相关子结构定义- 数据源抽象
IStateSource - 数据源选择器
StateSourceCatalog War3StatsObserverSharedMemory解析MMF JSON读取JSON 文件 / Mock读取
src/War3ReplayOverlay.Overlay
负责展示能力:
- 启动参数解析
- 透明顶层窗口
- 点击穿透
- 浏览器 / 指定窗口定位
- ViewModel 组织
- 游戏原生图标解析
- 单独的关闭按钮窗口
src/War3ReplayOverlay.Bridge.Agent
负责桥接能力:
- 把外部状态转成统一的
MatchSnapshot - 通过
memory mapped file发布给 Overlay
这条链路的意义是把"采集"和"显示"解耦,方便以后替换真实采集器。
4. 中间态为什么是 MatchSnapshot
这个工程最重要的设计点,是把所有来源的数据都统一成 MatchSnapshot。
对应代码在:
src/War3ReplayOverlay.Core/Snapshots.cs
它的职责不是贴近某个具体来源,而是贴近"Overlay 真正需要消费的语义数据":
- 比赛时间
- 队伍
- 玩家
- 资源
- 人口
- 英雄
- 技能
- 物品
- 科技
- 生产队列
这样做有几个直接好处:
- UI 不需要关心数据到底来自 observer 共享内存、MMF 还是 JSON
- 桥接进程和 Overlay 可以独立演进
- Mock / 样例文件 / 真实数据源都能复用同一套渲染逻辑
- 后面扩字段时,只需要先扩
MatchSnapshot,再逐个数据源补齐
5. 数据源抽象与自动选择
数据源统一通过 IStateSource 抽象:
ProbeAsync():探测源是否可用ReadSnapshotAsync():读取一帧MatchSnapshot
对应代码在:
src/War3ReplayOverlay.Core/StateSources.cs
当前已实现的数据源:
War3ObserverSharedMemoryStateSourceMemoryMappedJsonStateSourceJsonFileStateSourceMockReplayStateSource
StateSourceCatalog 的职责是:
- 构造可选数据源列表
- 根据
--source决定显式选择还是自动选择 - 在
auto模式下优先选真实源,再退回文件 / mock
当前 Overlay 默认:
--source observer--target-title browser
也就是说,单文件版默认会优先直接读游戏的 observer shared memory,并把 Overlay 锚定到前台浏览器窗口。
6. 真实数据链路:War3StatsObserverSharedMemory
6.1 为什么走这个源
这个项目最初最大的技术判断,是放弃大规模内存扫描作为主线,改走游戏已经暴露出来的 War3StatsObserverSharedMemory。
原因很直接:
- 语义更高
- 结构更稳定
- 不需要自己从海量内存里逆向所有观战字段
- 可直接拿到资源、人口、英雄、技能、物品、建筑、升级、研究等信息
6.2 解析方式
对应代码:
src/War3ReplayOverlay.Core/War3ObserverSharedMemoryStateSource.cs
这部分实现本质上是在手工解析一个二进制共享内存结构。
核心步骤:
- 用
MemoryMappedFile.OpenExisting()打开指定共享内存名 - 读取固定长度头部
- 从头部判断:
- 是否正在局内
- 当前游戏时间
- 玩家数量
- refresh rate
- 再按固定偏移批量读取玩家大块数据
- 在每个玩家大块中继续解析:
- 基本信息
- 资源
- 人口
- 英雄列表
- 英雄技能
- 英雄背包
- 建筑
- 已完成升级
- 进行中的研究
- 最终组装成
MatchSnapshot
6.3 为什么有大量 offset 常量
这个类里定义了很多类似下面的常量:
PlayerGoldOffsetPlayerSupplyUsedOffsetHeroAbilitiesOffsetBuildingArrayOffsetUpgradeArrayOffset
这些常量对应的是 observer shared memory 的固定布局。
可以把它理解成:
- 共享内存不是 JSON
- 也不是标准序列化对象
- 它就是一整块按固定字节布局组织的二进制缓冲区
所以实现上只能:
- 先知道起始偏移
- 再按 stride 跳到每个玩家 / 英雄 / 建筑条目
- 再按字段偏移读出数值
6.4 refresh rate 的处理
这个源有一个容易踩坑的点:
- shared memory 存在,不代表它正在持续刷新
所以实现里会在必要时主动写入 refresh rate,尝试启用持续更新。
这就是为什么 ProbeAsync() 和 ReadSnapshotAsync() 里都带有:
- 检测
RefreshRate == 0 - 必要时写回
_refreshRateMs
这样可以避免"共享内存存在,但内容一直不变"的假死状态。
6.5 主基地等级 T1 / T2 / T3 的推断
observer 源没有直接给出一个稳定的"当前几本"字段,所以项目里做了显式推断。
核心思路是同时综合两类信号:
- 已完成的主基地建筑
- 已完成 / 进行中的升本研究
并且遵循一个用户要求的规则:
- 升级未完成前,仍显示上一档
因此实现不是"看到升本开始就立刻升档",而是:
- 先判断已完成等级
- 再识别当前是否处于升档过程
- 如果还在过程中,则维持旧档位显示
这能避免:
- 正在从
T1 -> T2过渡时,UI 提前显示T2
7. 桥接链路:Bridge.Agent -> MMF
除了直接读 observer,这个项目还保留了一条更通用的桥接链路。
对应组件:
src/War3ReplayOverlay.Bridge.Agentsrc/War3ReplayOverlay.Core/MemoryMappedJsonStateSource.cs
这条链路的作用是:
- 让采集器和 UI 解耦
- 让未来真实 provider 不必直接耦合到 WPF Overlay
流程如下:
外部采集器 / Bridge.Agent
MatchSnapshot JSON
MemoryMappedJsonPublisher
War3ReplayOverlay.Live
MemoryMappedJsonStateSource
OverlayViewModel
MemoryMappedJsonStateSource 的实现很简单:
- 打开指定 MMF
- 读取字节流
- 取第一个
\0前的有效 UTF-8 内容 - 反序列化为
MatchSnapshot
这种设计虽然比直接读二进制 observer 多了一次 JSON 序列化 / 反序列化,但优点是:
- 调试方便
- 数据格式可视化
- 各进程边界更清晰
- 未来接别的采集器时成本更低
8. ViewModel 组装层
对应代码:
src/War3ReplayOverlay.Overlay/OverlayViewModel.cs
这层的职责不是"读数据",而是把 MatchSnapshot 转成"适合 XAML 直接绑定的展示模型"。
核心工作包括:
- 把
GameTimeMs格式化成mm:ss - 把
gold / lumber做千分位格式化 - 把 race 映射成更适合显示的中文标签
- 把
TownHallTier映射成T1 / T2 / T3 - 对英雄按等级和血量排序,确定主英雄展示顺序
- 过滤 observer 里的脏技能数据
- 把物品槽、技能槽补齐成固定展示结构
- 为 UI 生成
IconPath、LevelLabel、占位状态等字段
为什么要在这一层过滤脏数据
observer 原始数据里会出现一些不适合直接展示的项,例如:
- 临时技能占位
- 无意义的内部图标
- 未学习技能的空洞数据
如果不先在 ViewModel 层过滤:
- XAML 会出现"怪图标"
- 英雄技能栏会有脏位
- 布局会被脏数据撑乱
所以当前策略是:
- Core 尽量保留原始语义
- OverlayViewModel 负责展示口径收口
9. UI 层:为什么用 WPF Overlay
对应文件:
src/War3ReplayOverlay.Overlay/MainWindow.xamlsrc/War3ReplayOverlay.Overlay/MainWindow.xaml.cs
WPF 适合做这类工具,原因是:
- 桌面透明窗口成熟
- 数据绑定方便
- 样式和模板可快速迭代
- 不需要自己管理低层绘图
当前布局结构
当前主窗口本质上是一个:
WindowStyle=NoneAllowsTransparency=TrueTopmost=TrueShowInTaskbar=False
的透明顶层窗口。
内容层是一个三列 Grid:
- 左列:左方玩家卡片
- 中列:留白
- 右列:右方玩家卡片
每个玩家卡片内部:
- 第一行:玩家名、种族、金、木、人口、
T阶 - 后续每行:一个英雄
- 每个英雄一行内展示:
- 英雄头像 + 等级角标
- 技能图标 + 技能等级角标
- 物品图标
为什么英雄行强制单行
用户要求在极限情况下也能一行容纳:
1个英雄头像4个技能6个物品
所以当前实现显式用了:
- 横向
StackPanel - 更紧凑的 icon size
- 更小的 margin
- 不允许自动换行
这属于明显的产品约束,而不是随手排版。
10. 点击穿透与关闭按钮为什么分成两个窗口
这是当前 UI 实现里一个非常关键的设计点。
10.1 主窗口为什么要点击穿透
主 Overlay 需要悬浮在目标窗口上方,但不能挡住用户对底层画面的操作,所以实现里给主窗口加了:
WS_EX_TRANSPARENTWS_EX_TOOLWINDOWWS_EX_NOACTIVATE
对应代码:
src/War3ReplayOverlay.Overlay/NativeWindowTracker.cs
EnableClickThrough() 本质上是在改原生扩展窗口样式。
这样做的效果是:
- Overlay 能显示
- 但鼠标事件继续落到底层窗口
10.2 为什么不能直接把关闭按钮放进主窗口
因为主窗口已经点击穿透了。
如果把关闭按钮也放进去:
- 看得见
- 但点不到
所以当前方案是:
- 主窗口专门负责显示,保持完全点击穿透
- 另开一个极小的
CloseButtonWindow - 这个小窗口不做点击穿透,只保留一个淡化的
×按钮
这样就同时满足了两个目标:
- HUD 不挡操作
- 用户又能主动关闭插件
11. 窗口定位原理
当前定位逻辑在:
src/War3ReplayOverlay.Overlay/MainWindow.xaml.cssrc/War3ReplayOverlay.Overlay/NativeWindowTracker.cs
11.1 当前默认行为
当前默认 TargetWindowTitle = "browser"。
这不是让代码去找标题叫 browser 的窗口,而是一个约定值,代表"进入浏览器定位模式"。
11.2 浏览器定位模式如何工作
UpdateOverlayPosition() 里会判断:
- 如果目标值是
browser - 就调用
TryFindBrowserWindow()
而 TryFindBrowserWindow() 的策略是:
- 先看当前前台窗口是否是浏览器
- 如果是,直接用它
- 如果不是,就枚举所有顶层可见窗口
- 找第一个标题匹配常见浏览器特征的窗口
当前支持匹配:
- Chrome
- Edge
- Firefox
- Brave
- Opera
- Vivaldi
- Arc
11.3 定位到窗口后怎么摆 HUD
拿到目标窗口矩形后:
- 主 Overlay 宽度至少
860 - 左上角直接对齐目标窗口左上角
- 卡片本身贴 Overlay 左右上角
- 关闭按钮单独放在右上角
这就是当前"卡片顶在窗口左右上角"的实现基础。
11.4 为什么还保留 --target-title
虽然默认改成了浏览器模式,但仍保留:
--target-title "Warcraft III"--target-title "任意窗口标题片段"
原因是调试时仍然需要:
- 强制锚定游戏窗口
- 锚定别的宿主窗口
12. 图标资源是怎么工作的
对应代码:
src/War3ReplayOverlay.Overlay/War3IconCatalog.csscripts/extract-war3-icons.py
12.1 图标来源
图标不是手工截屏,而是从 Warcraft III 安装目录里提取原生资源。
提取脚本会把图标转成可直接给 WPF 使用的图片文件,落到:
assets/war3-icons
12.2 运行时如何解析图标
War3IconCatalog.Resolve(iconId, iconArtPath) 会按优先顺序尝试:
- 特殊覆盖规则
iconIdiconArtPathhd子目录 fallback
其中有一个专门的特例:
- 恶魔猎手
Edem
这是因为实际观感要求必须优先命中经典版 BTNHeroDemonHunter,不能让通用回退路径把它导向不合预期的图。
12.3 为什么要做缓存
一个 HUD 窗口里同一图标会反复出现很多次,如果每次都重新遍历文件系统:
- 会浪费 I/O
- 也会让轮询刷新时更抖
所以这里用了:
ConcurrentDictionary<string, string?>
把 iconId|iconArtPath 解析结果缓存起来。
13. 单文件发布为什么还能带图标资源
这是当前实现里另一个关键点。
13.1 问题本质
如果直接做 PublishSingleFile=true:
- 主程序可以变成一个 exe
- 但
assets/war3-icons这类外部资源目录默认不会自动变成"真正单文件"
于是会出现一个问题:
- 程序是单 exe
- 图标资源却还在外部目录
13.2 当前解决方案
当前发布脚本:
scripts/publish-single-exe.ps1
会先做两件事:
- 把
assets/war3-icons压成zip - 在
dotnet publish时通过War3IconsArchivePath把这个 zip 嵌进程序资源
运行时 War3IconCatalog 会:
- 先看外部图标目录是否存在
- 如果不存在,就尝试从程序集资源里读出内嵌 zip
- 解压到当前用户的本地缓存目录
- 后续直接从缓存目录读图
这样就达到了:
- 分发时只需要一个 exe
- 运行时仍然可以访问完整图标资源
13.3 为什么要解压到本地缓存而不是内存直读
因为 WPF Image.Source 更适合直接绑定文件路径,且图标数量很多。
如果全部改成内存流对象:
- 管理复杂度更高
- 缓存与复用会更麻烦
- 对现有 XAML 绑定模型也不划算
所以现在采用的是:
- 发布期嵌入
- 运行期解压到本地缓存
- 展示期按普通文件读取
这是一个比较务实的折中。
14. 为什么当前同时保留"直接 observer"和"Bridge.Agent"
两条链路都保留,不是冗余,而是职责不同。
直接 observer
优点:
- 路径最短
- 部署最简单
- 单文件 exe 最适合这条路径
适合:
- 最终用户直接运行
- 真实 replay / observer 场景
Bridge.Agent -> MMF
优点:
- 采集与展示彻底解耦
- 便于接入未来新的 provider
- 便于联调和录制中间数据
适合:
- 开发态
- 调试态
- 替换采集器实现
15. 当前实现中的关键取舍
15.1 选择共享内存 API,而不是全面继续内存逆向
这是整个项目能快速落地的前提。
15.2 使用统一中间态,而不是让 UI 直接依赖 observer 原始结构
这是整个系统可维护的前提。
15.3 主窗口点击穿透,但关闭按钮独立成窗
这是"可用性"和"交互性"之间的必要折中。
15.4 单文件发布时嵌入图标 zip,而不是把发布目录提交进 Git
这是"分发方便"和"仓库可维护"之间的折中。
16. 当前维护建议
如果后续还要继续演进,建议优先遵守下面这些原则:
- 新数据字段先加到
MatchSnapshot,不要直接把数据源细节塞进 XAML - 新的数据源优先实现
IStateSource,不要绕过StateSourceCatalog - 图标问题优先改
War3IconCatalog和 ViewModel,不要在 XAML 里写死特殊判断 - 交互按钮继续保持独立窗口,不要把可点击控件塞回点击穿透主窗口
- 发布产物继续留在
dist/本地目录,不要重新提交进 Git
17. 一句话总结
这个插件的实现本质上是:
把 Warcraft III 观战数据收敛为统一的 MatchSnapshot,再用一个透明、点击穿透、可跟随目标窗口的 WPF Overlay,把它渲染成紧凑的双侧 HUD;同时通过内嵌图标资源和单文件发布,把开发态原型收敛成一个可直接分发使用的桌面工具。