War3 Replay Overlay 技术实现原理

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 层:

  1. 数据源层
  2. 中间态层
  3. ViewModel 组装层
  4. 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

当前已实现的数据源:

  • War3ObserverSharedMemoryStateSource
  • MemoryMappedJsonStateSource
  • JsonFileStateSource
  • MockReplayStateSource

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

这部分实现本质上是在手工解析一个二进制共享内存结构。

核心步骤:

  1. MemoryMappedFile.OpenExisting() 打开指定共享内存名
  2. 读取固定长度头部
  3. 从头部判断:
    • 是否正在局内
    • 当前游戏时间
    • 玩家数量
    • refresh rate
  4. 再按固定偏移批量读取玩家大块数据
  5. 在每个玩家大块中继续解析:
    • 基本信息
    • 资源
    • 人口
    • 英雄列表
    • 英雄技能
    • 英雄背包
    • 建筑
    • 已完成升级
    • 进行中的研究
  6. 最终组装成 MatchSnapshot

6.3 为什么有大量 offset 常量

这个类里定义了很多类似下面的常量:

  • PlayerGoldOffset
  • PlayerSupplyUsedOffset
  • HeroAbilitiesOffset
  • BuildingArrayOffset
  • UpgradeArrayOffset

这些常量对应的是 observer shared memory 的固定布局。

可以把它理解成:

  • 共享内存不是 JSON
  • 也不是标准序列化对象
  • 它就是一整块按固定字节布局组织的二进制缓冲区

所以实现上只能:

  • 先知道起始偏移
  • 再按 stride 跳到每个玩家 / 英雄 / 建筑条目
  • 再按字段偏移读出数值

6.4 refresh rate 的处理

这个源有一个容易踩坑的点:

  • shared memory 存在,不代表它正在持续刷新

所以实现里会在必要时主动写入 refresh rate,尝试启用持续更新。

这就是为什么 ProbeAsync()ReadSnapshotAsync() 里都带有:

  • 检测 RefreshRate == 0
  • 必要时写回 _refreshRateMs

这样可以避免"共享内存存在,但内容一直不变"的假死状态。

6.5 主基地等级 T1 / T2 / T3 的推断

observer 源没有直接给出一个稳定的"当前几本"字段,所以项目里做了显式推断。

核心思路是同时综合两类信号:

  • 已完成的主基地建筑
  • 已完成 / 进行中的升本研究

并且遵循一个用户要求的规则:

  • 升级未完成前,仍显示上一档

因此实现不是"看到升本开始就立刻升档",而是:

  1. 先判断已完成等级
  2. 再识别当前是否处于升档过程
  3. 如果还在过程中,则维持旧档位显示

这能避免:

  • 正在从 T1 -> T2 过渡时,UI 提前显示 T2

7. 桥接链路:Bridge.Agent -> MMF

除了直接读 observer,这个项目还保留了一条更通用的桥接链路。

对应组件:

  • src/War3ReplayOverlay.Bridge.Agent
  • src/War3ReplayOverlay.Core/MemoryMappedJsonStateSource.cs

这条链路的作用是:

  • 让采集器和 UI 解耦
  • 让未来真实 provider 不必直接耦合到 WPF Overlay

流程如下:
外部采集器 / Bridge.Agent
MatchSnapshot JSON
MemoryMappedJsonPublisher
War3ReplayOverlay.Live
MemoryMappedJsonStateSource
OverlayViewModel

MemoryMappedJsonStateSource 的实现很简单:

  1. 打开指定 MMF
  2. 读取字节流
  3. 取第一个 \0 前的有效 UTF-8 内容
  4. 反序列化为 MatchSnapshot

这种设计虽然比直接读二进制 observer 多了一次 JSON 序列化 / 反序列化,但优点是:

  • 调试方便
  • 数据格式可视化
  • 各进程边界更清晰
  • 未来接别的采集器时成本更低

8. ViewModel 组装层

对应代码:

  • src/War3ReplayOverlay.Overlay/OverlayViewModel.cs

这层的职责不是"读数据",而是把 MatchSnapshot 转成"适合 XAML 直接绑定的展示模型"。

核心工作包括:

  • GameTimeMs 格式化成 mm:ss
  • gold / lumber 做千分位格式化
  • 把 race 映射成更适合显示的中文标签
  • TownHallTier 映射成 T1 / T2 / T3
  • 对英雄按等级和血量排序,确定主英雄展示顺序
  • 过滤 observer 里的脏技能数据
  • 把物品槽、技能槽补齐成固定展示结构
  • 为 UI 生成 IconPathLevelLabel、占位状态等字段

为什么要在这一层过滤脏数据

observer 原始数据里会出现一些不适合直接展示的项,例如:

  • 临时技能占位
  • 无意义的内部图标
  • 未学习技能的空洞数据

如果不先在 ViewModel 层过滤:

  • XAML 会出现"怪图标"
  • 英雄技能栏会有脏位
  • 布局会被脏数据撑乱

所以当前策略是:

  • Core 尽量保留原始语义
  • OverlayViewModel 负责展示口径收口

9. UI 层:为什么用 WPF Overlay

对应文件:

  • src/War3ReplayOverlay.Overlay/MainWindow.xaml
  • src/War3ReplayOverlay.Overlay/MainWindow.xaml.cs

WPF 适合做这类工具,原因是:

  • 桌面透明窗口成熟
  • 数据绑定方便
  • 样式和模板可快速迭代
  • 不需要自己管理低层绘图

当前布局结构

当前主窗口本质上是一个:

  • WindowStyle=None
  • AllowsTransparency=True
  • Topmost=True
  • ShowInTaskbar=False

的透明顶层窗口。

内容层是一个三列 Grid

  • 左列:左方玩家卡片
  • 中列:留白
  • 右列:右方玩家卡片

每个玩家卡片内部:

  • 第一行:玩家名、种族、金、木、人口、T阶
  • 后续每行:一个英雄
  • 每个英雄一行内展示:
    • 英雄头像 + 等级角标
    • 技能图标 + 技能等级角标
    • 物品图标

为什么英雄行强制单行

用户要求在极限情况下也能一行容纳:

  • 1 个英雄头像
  • 4 个技能
  • 6 个物品

所以当前实现显式用了:

  • 横向 StackPanel
  • 更紧凑的 icon size
  • 更小的 margin
  • 不允许自动换行

这属于明显的产品约束,而不是随手排版。

10. 点击穿透与关闭按钮为什么分成两个窗口

这是当前 UI 实现里一个非常关键的设计点。

10.1 主窗口为什么要点击穿透

主 Overlay 需要悬浮在目标窗口上方,但不能挡住用户对底层画面的操作,所以实现里给主窗口加了:

  • WS_EX_TRANSPARENT
  • WS_EX_TOOLWINDOW
  • WS_EX_NOACTIVATE

对应代码:

  • src/War3ReplayOverlay.Overlay/NativeWindowTracker.cs

EnableClickThrough() 本质上是在改原生扩展窗口样式。

这样做的效果是:

  • Overlay 能显示
  • 但鼠标事件继续落到底层窗口

10.2 为什么不能直接把关闭按钮放进主窗口

因为主窗口已经点击穿透了。

如果把关闭按钮也放进去:

  • 看得见
  • 但点不到

所以当前方案是:

  • 主窗口专门负责显示,保持完全点击穿透
  • 另开一个极小的 CloseButtonWindow
  • 这个小窗口不做点击穿透,只保留一个淡化的 × 按钮

这样就同时满足了两个目标:

  • HUD 不挡操作
  • 用户又能主动关闭插件

11. 窗口定位原理

当前定位逻辑在:

  • src/War3ReplayOverlay.Overlay/MainWindow.xaml.cs
  • src/War3ReplayOverlay.Overlay/NativeWindowTracker.cs

11.1 当前默认行为

当前默认 TargetWindowTitle = "browser"

这不是让代码去找标题叫 browser 的窗口,而是一个约定值,代表"进入浏览器定位模式"。

11.2 浏览器定位模式如何工作

UpdateOverlayPosition() 里会判断:

  • 如果目标值是 browser
  • 就调用 TryFindBrowserWindow()

TryFindBrowserWindow() 的策略是:

  1. 先看当前前台窗口是否是浏览器
  2. 如果是,直接用它
  3. 如果不是,就枚举所有顶层可见窗口
  4. 找第一个标题匹配常见浏览器特征的窗口

当前支持匹配:

  • 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.cs
  • scripts/extract-war3-icons.py

12.1 图标来源

图标不是手工截屏,而是从 Warcraft III 安装目录里提取原生资源。

提取脚本会把图标转成可直接给 WPF 使用的图片文件,落到:

  • assets/war3-icons

12.2 运行时如何解析图标

War3IconCatalog.Resolve(iconId, iconArtPath) 会按优先顺序尝试:

  1. 特殊覆盖规则
  2. iconId
  3. iconArtPath
  4. hd 子目录 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

会先做两件事:

  1. assets/war3-icons 压成 zip
  2. dotnet publish 时通过 War3IconsArchivePath 把这个 zip 嵌进程序资源

运行时 War3IconCatalog 会:

  1. 先看外部图标目录是否存在
  2. 如果不存在,就尝试从程序集资源里读出内嵌 zip
  3. 解压到当前用户的本地缓存目录
  4. 后续直接从缓存目录读图

这样就达到了:

  • 分发时只需要一个 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;同时通过内嵌图标资源和单文件发布,把开发态原型收敛成一个可直接分发使用的桌面工具。

相关推荐
sam.li4 天前
GhidraMCP 原理与使用部署
ai·逆向·插件·mcp·ghidra
sqmw7 天前
MFCMouseEffect:把桌面输入反馈这件事,做成一个真正可扩展的引擎
c++·插件·引擎·鼠标特效·键鼠指示·鼠标伴宠
l1t10 天前
DeepSeek总结的用 C# 构建 DuckDB 插件说明
前端·数据库·c#·插件·duckdb
海兰11 天前
【原理】OpenClaw插件系统深度解析
人工智能·插件·skill·openclaw
装疯迷窍_A14 天前
ArcGISPro国土超级工具集简介
插件·arcgispro·变更调查·尖锐角·举证照片
bu_shuo21 天前
在Edge浏览器中安装Google Chrome扩展
chrome·edge·插件
YZ0991 个月前
2026年如何批量保存小红书作者主页的视频、图片和文案?
经验分享·浏览器·插件
今夕资源网2 个月前
Bantu Image Fixer禁止 WordPress 生成缩略图,并将现有文章的特色图片替换回原图wordpress插件
插件·wordpress·缩略图·wordpress插件·wordpress缩略图
Ama_tor2 个月前
obsidian插件|tasknotesの任务管理系统?
插件·obsidian·tasknotes