前段时间,我把一个 Spore 太空阶段修改器从 Python 桌面应用改写成了纯 C + Win32 + WebView2。那次的业务逻辑并不复杂,真正耗时的是 COM 对象生命周期、回调接口、窗口样式、线程切换和资源加载。功能最终完成了,但也深刻体会到:使用纯 C 直接驱动 WebView2,很多时间都花在了业务之外。
这次我换了一条路线,使用 强强 QiangQiang 重新实现。强强采用 C++20、Win32、WebView2、Bun 和 TypeScript,原生支持 JSON IPC、无边框窗口以及单 exe 打包。原来的 C 版本完整保留,新版本则放在独立目录中。
最后的结果是一个约 882 KiB 的单文件程序:
SporeTrainer_QiangQiang.exe
它保留了五项功能:
- 生命:持续锁定;
- 能量:持续锁定;
- 金钱:立即写入或持续锁定;
- 航程:回车修改一次;
- 可视范围:回车修改一次。
本文记录这次实现的完整链路:TypeScript 怎样变成 JavaScript、JavaScript 怎样调用 C++、修改器功能怎样暴露给前端,以及 C++ 如何读写游戏进程内存。
一、整体架构:一个 exe 里的两个世界
这个程序可以分成两部分:
SporeTrainer_QiangQiang.exe
│
├─ C++ 原生部分
│ ├─ 创建 Win32 窗口
│ ├─ 托管 WebView2
│ ├─ 处理 JSON IPC
│ ├─ 查找并附加 SporeApp.exe
│ └─ 读取、写入游戏内存
│
└─ 网页部分
├─ HTML:表格、输入框、开关和弹窗
├─ CSS:布局、渐变头部和动画
└─ JavaScript:处理交互、调用原生命令、刷新界面
WebView2 使用 Chromium 内核,因此界面最终还是 HTML、CSS 和 JavaScript。C++ 不负责绘制表格,而是负责窗口、系统能力和游戏内存操作。
需要注意,Bun 只是开发和构建工具,不会被打进最终程序。运行这个 exe 不需要安装 Bun、Node.js、Python 或 JavaScript 运行时,但目标机器仍然需要 WebView2 Runtime。Windows 10/11 通常已经具备这个组件。
项目结构如下:
qiangqiang/SporeTrainer/
├─ native/
│ ├─ main.cpp # 强强原生壳、IPC 注册、后台线程
│ ├─ trainer.cpp # Spore 内存读写和锁定逻辑
│ ├─ trainer.hpp # 修改器原生接口
│ └─ app.ico # 应用图标
├─ src/
│ ├─ index.html # 页面结构和样式
│ ├─ main.ts # 界面行为
│ ├─ api.ts # 暴露给界面的类型化原生 API
│ └─ ipc.ts # 请求、响应和事件通信
├─ scripts/
│ └─ build.ts # 前端打包、资源嵌入和原生编译
└─ app.config.json # 窗口标题、尺寸和颜色等配置
二、界面为什么使用 TypeScript
修改器的界面并不复杂:一张表格、几个输入框和三个可锁定开关。TypeScript 的价值主要不是“写出更炫的页面”,而是给前端与原生层之间的边界增加约束。
例如,修改器支持的变量名被定义成联合类型:
export type TrainerName =
| '生命'
| '能量'
| '金钱'
| '航程'
| '可视范围';
前端暴露的修改器接口是:
export const trainer = {
toggle: (name: TrainerName, on: boolean, text: string) =>
invoke<string>('trainer.toggle', { name, on, text }),
enter: (name: TrainerName, text: string) =>
invoke<string>('trainer.enter', { name, text }),
onUpdate: (handler: (data: TrainerUpdate) => void) =>
on('trainer.update', handler),
};
这样,界面只需要写:
const message = await trainer.enter('金钱', '1000');
setStatus(message);
不需要关心 JSON 格式、WebView2 消息对象或请求编号。如果变量名拼错,TypeScript 会在构建前给出错误。
三、TypeScript 怎样变成 JavaScript
浏览器不会直接执行 TypeScript。项目中有两个容易混淆的步骤:
tsc --noEmit:只做类型检查,不生成 JavaScript
Bun.build:转换并打包真正的浏览器 JavaScript
构建脚本使用 src/main.ts 作为入口:
const result = await Bun.build({
entrypoints: [join(ROOT, 'src', 'main.ts')],
outdir: DIST,
minify: true,
target: 'browser',
});
main.ts 导入 api.ts,api.ts 又导入 ipc.ts。Bun 会沿着依赖关系把这些模块打包到一个 main.js 中,同时完成:
- 删除接口、类型标注等 TypeScript 类型信息;
- 转换 TypeScript 和较新的 JavaScript 语法;
- 合并模块;
- 压缩输出,减小体积。
例如:
function setStatus(text: string) {
status.textContent = text;
}
转换后,运行时只需要类似这样的 JavaScript:
function setStatus(text) {
status.textContent = text;
}
类型是给开发者和编译器看的,Chromium 执行的是普通 JavaScript。
单 exe 构建还会把生成的 JavaScript 内联到 HTML:
<script type="module">
// 打包后的 main.js
</script>
随后,HTML、JavaScript 和配置作为资源嵌入 exe。C++ 源码则交给 MSVC 编译成原生机器代码。也就是说,TypeScript 没有被翻译成 C++,两部分是分别构建后组合到一起的。
四、JavaScript 和 C++ 如何通信
强强使用 WebView2 自带的 JSON 消息通道:
JavaScript:window.chrome.webview.postMessage(...)
C++: ICoreWebView2::add_WebMessageReceived
反方向则是:
C++: ICoreWebView2::PostWebMessageAsJson(...)
JavaScript:window.chrome.webview.addEventListener('message', ...)
这不是 JavaScript 直接调用 C++ 函数,而是两个世界之间传递结构化消息。
1. 前端发送请求
假设用户在金钱输入框中输入 1000 并回车,界面会调用:
await trainer.enter('金钱', '1000');
trainer.enter() 进一步调用:
invoke('trainer.enter', {
name: '金钱',
text: '1000'
});
invoke() 会创建一个 Promise,分配请求编号,然后发送:
{
"id": 0,
"cmd": "trainer.enter",
"args": {
"name": "金钱",
"text": "1000"
}
}
请求编号对应的 Promise 被暂存起来:
pending.set(id, { resolve, reject, timeout });
webview.postMessage({ id, cmd, args });
这样即使多个命令同时执行,也能根据 id 把结果交还给正确的调用者。
2. C++ 接收和分发命令
原生壳注册了 WebView2 消息监听器。收到消息后,先检查消息来源是否属于应用自己的可信页面,再取得 JSON 并交给 ipc_dispatch()。
分发器解析出 cmd,从命令表中寻找同名处理器:
if (auto it = g_cmds.find(cmd); it != g_cmds.end()) {
resp["result"] = it->second(args);
}
可以把这张表理解成:
trainer.enter → 修改器的回车写入处理
trainer.toggle → 修改器的锁定开关处理
window.close → 关闭窗口
window.minimize → 最小化窗口
3. 原生结果返回前端
C++ 执行完成后,把结果包装成:
{
"id": 0,
"result": "金钱 已写入地址1a2b3c,数值1000 (未锁定)"
}
再通过 PostWebMessageAsJson() 发回页面。
JavaScript 收到响应后,根据 id 找到之前保存的 Promise:
p.resolve(msg.result);
于是原来的 await trainer.enter(...) 得到 C++ 返回的字符串,界面再把它显示到底部状态栏。
完整链路是:
用户回车
→ main.ts
→ trainer.enter()
→ invoke()
→ WebView2 JSON 消息
→ C++ ipc_dispatch()
→ trainer_enter()
→ 返回字符串
→ PostWebMessageAsJson()
→ Promise resolve
→ 更新状态栏
五、修改器功能怎样暴露给 TypeScript
一个原生功能暴露给前端,需要三层连接。
第一层:实现原生功能
native/trainer.cpp 提供真正的 C++ 函数:
std::string trainer_toggle(
const std::string& name,
bool on,
const std::string& text
);
std::string trainer_enter(
const std::string& name,
const std::string& text
);
这里负责参数解析、指针链解析、游戏进程读写、锁定状态和错误提示。
第二层:注册 IPC 命令
在 native/main.cpp 中,把命令名映射到这些函数:
ipc_on("trainer.toggle", [](const json& args) -> json {
const auto name = args.value("name", std::string{});
const auto text = args.value("text", std::string{});
if (text.size() > 128) {
throw std::runtime_error("text is too long");
}
return spore::trainer_toggle(
name,
args.value("on", false),
text
);
});
ipc_on("trainer.enter", [](const json& args) -> json {
const auto name = args.value("name", std::string{});
const auto text = args.value("text", std::string{});
if (text.size() > 128) {
throw std::runtime_error("text is too long");
}
return spore::trainer_enter(name, text);
});
这一步的含义是:当前端发送 trainer.enter 时,调用 spore::trainer_enter()。
第三层:提供 TypeScript 包装
最后在 src/api.ts 中定义同名命令的 TS 接口:
enter: (name: TrainerName, text: string) =>
invoke<string>('trainer.enter', { name, text }),
这三层依靠相同的命令字符串连接起来:
TypeScript:invoke("trainer.enter")
↓
C++ 注册: ipc_on("trainer.enter")
↓
功能实现: spore::trainer_enter()
它不是自动反射,也不是跨语言直接调用,而是一套清晰、可检查的命令协议。
六、C++ 如何修改游戏内存
修改器的目标进程是:
SporeApp.exe
后台逻辑首先通过进程快照找到 PID,再使用 OpenProcess 获取句柄:
OpenProcess(
PROCESS_VM_READ |
PROCESS_VM_WRITE |
PROCESS_VM_OPERATION |
PROCESS_QUERY_INFORMATION,
FALSE,
pid
);
接下来解析指针链。以生命为例,配置大致是:
{
"生命",
0x12DBA14,
{0x40, 0x12C, 0xF8, 0x8, 0x54},
5,
true,
0,
true
}
计算过程可以简化为:
游戏模块基址 + 模块偏移
→ 读取指针 + 第 1 级偏移
→ 读取指针 + 第 2 级偏移
→ ...
→ 最终数值地址
由于目标是 32 位游戏,指针按 4 字节读取。最终地址确定后:
ReadProcessMemory读取当前生命、能量、金钱等数值;WriteProcessMemory写入新值;- 浮点数按
float处理,金钱按 32 位整数处理。
如果指针链失效或游戏退出,当前句柄会被关闭,状态切换为“连接中断, 重连中...”,之后重新查找游戏。
七、锁定功能的本质:持续写回
“锁定生命”并不是设置一个 Windows 属性,而是保存目标值,然后由后台线程不断写回。
打开开关时,trainer_toggle() 记录:
locked[生命] = true
target[生命] = 用户输入值
如果输入框留空,则在下一轮成功读取游戏数值后,把当时的当前值作为目标。
后台线程每 100 ms 执行一次:
解析最终地址
→ 读取当前值
→ 更新界面快照
→ 如果处于锁定状态,写入目标值
因此,即使游戏逻辑让生命减少,下一轮也会把目标值重新写入。
回车写入则分为三种情况:
- 已锁定:更新锁定目标值,由后台线程持续写入;
- 未锁定且已附加:立即写入一次;
- 尚未附加:暂存请求,附加成功后补写。
八、当前值怎样从 C++ 主动推送到界面
请求—响应适合“回车写入”,但不适合持续刷新。当前值更新采用的是另一种模式:原生事件推送。
原生壳启动一个后台线程:
while (WaitForSingleObject(g_trainerStop, 0) == WAIT_TIMEOUT) {
spore::trainer_poll_once();
PostMessageW(g_hwnd, WM_TRAINER_UPDATE, 0, 0);
WaitForSingleObject(
g_trainerStop,
spore::trainer_attached() ? 100 : 2000
);
}
附加游戏后每 100 ms 轮询一次;没有找到游戏时每 2 秒重试一次。
这里有一个重要的线程边界:后台线程不直接调用 WebView2。它先通过 PostMessageW 通知窗口 UI 线程,UI 线程再调用 PostWebMessageAsJson()。
发送的事件类似:
{
"event": "trainer.update",
"data": {
"status": "已附加到游戏",
"values": {
"生命": "100.00",
"能量": "80.00",
"金钱": "5000"
},
"attached": true
}
}
前端订阅这个事件:
trainer.onUpdate(applyUpdate);
收到后更新表格中的当前值和底部状态。
所以两种通信模式各有用途:
trainer.enter、trainer.toggle:请求—响应,有请求id,返回 Promise;trainer.update:原生主动推送,没有请求id,由订阅回调处理。
九、单 exe 是怎样打出来的
构建命令是:
npx --yes bun@1.3.14 run build:single
整个过程分为四步:
1. Bun 把 TypeScript 打包成浏览器 JavaScript
2. 单文件模式把 JavaScript 内联进 HTML
3. HTML 和应用配置打入资源包
4. MSVC 编译 main.cpp、trainer.cpp,并链接资源和 WebView2 Loader
原生编译使用 /MT,将 MSVC 运行库静态链接进程序,避免另外携带 CRT DLL。WebView2 Loader 也使用静态库。
最终 HTML、JavaScript、CSS、配置和图标都包含在 exe 内,不需要把前端文件放在旁边,也不需要先释放到临时目录。
但“单 exe”仍然有两个边界:
- 目标机器需要 WebView2 Runtime;
- WebView2 正常运行时仍会在用户数据目录保存浏览器数据,这是运行缓存,不是程序缺少文件。
十、这次为什么比纯 C 顺利
强强带来的最大帮助,不是替我们写修改器业务,而是提前解决了大量基础设施问题:
- 使用 C++ RAII 管理 COM 和 Win32 对象;
- 封装 WebView2 初始化和事件回调;
- 提供无边框窗口、DWM 圆角和原生窗口动画;
- 提供带请求编号的 Promise IPC;
- 提供原生事件推送和 UI 线程调度;
- 使用成熟 JSON 库处理结构化消息;
- 提供 Bun 前端构建和单 exe 资源嵌入;
- 使用 TypeScript 约束前端与原生 API。
这次仍然遇到了 TypeScript 启动脚本和多源文件 /Fo 参数的小兼容问题,但都属于构建阶段,容易定位。没有再陷入“所有 COM 调用都成功,页面却不显示”这类底层故障。
也要承认,之前纯 C 版本已经完成并验证了指针链、锁定逻辑和交互需求。这次是在成熟实现上迁移,并不是从零开始。纯 C 版本依然是有价值的功能基准和备用方案。
写在最后
这个修改器把不同技术放在各自最合适的位置:
HTML / CSS 负责界面
TypeScript 负责交互和类型约束
Bun 负责开发期构建
WebView2 负责执行 JS 和传递 JSON
C++ / Win32 负责窗口、线程和进程内存操作
MSVC 负责生成最终原生 exe
真正值得复用的是那条清晰的边界:前端不接触进程句柄,原生层不关心表格样式,双方只通过少量、明确、带类型的命令合作。
对于 Windows 小型桌面工具来说,这种组合既保留了 Web 前端的开发效率,也保留了原生代码的系统能力。强强把WebView2 周边的大量细节收进框架之后,开发者终于可以把主要精力放回到真正重要的事情上——在这个项目里,就是让一艘飞船安全地飞向下一颗星星。