前段时间,我把一个 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 中,同时完成:

  1. 删除接口、类型标注等 TypeScript 类型信息;
  2. 转换 TypeScript 和较新的 JavaScript 语法;
  3. 合并模块;
  4. 压缩输出,减小体积。

例如:

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 执行一次:

解析最终地址
  → 读取当前值
  → 更新界面快照
  → 如果处于锁定状态,写入目标值

因此,即使游戏逻辑让生命减少,下一轮也会把目标值重新写入。

回车写入则分为三种情况:

  1. 已锁定:更新锁定目标值,由后台线程持续写入;
  2. 未锁定且已附加:立即写入一次;
  3. 尚未附加:暂存请求,附加成功后补写。

八、当前值怎样从 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 周边的大量细节收进框架之后,开发者终于可以把主要精力放回到真正重要的事情上——在这个项目里,就是让一艘飞船安全地飞向下一颗星星。