《大航海家4》国王满意度 100% 锁定工具:从逆向到开源

最近在玩《大航海家 4》(Patrician IV: Rise of a Dynasty),游戏里最折磨人的设定莫过于国王满意度:完成他们的任务、送礼才能涨,但随时间流逝又会慢慢掉回去。全地图七八个国王要挨个哄,实在太麻烦。

于是我想:能不能写个工具,一键把所有国王的满意度锁死在 100%?

本以为这是个"搜数值 → 锁定"的十分钟小活,结果折腾了整整一天才搞定。这篇文章记录完整过程,给同样想改这类"实时计算数值"的朋友做个参考。

前置:先把"AI 驱动 Cheat Engine"的环境搭起来

这次我全程用的不是纯手动点 CE,而是通过 cheatengine-mcp-tcp-bridge 这个开源项目,把 Cheat Engine 暴露成 MCP 工具,让 AI 代理可以直接调用 CE 的 173 个工具(扫描内存、读写、反汇编、断点……)。

配置流程(对应作者博客里的教程):

  1. ce_mcp_tcp_x64.dll 复制到 CE 安装根目录
  2. 管理员打开 CE,执行 ce_mcp_bridge.lua,桥接在 17171 端口监听
  3. 装 Python 的 mcp 包,把 mcp_cheatengine.py 配进 AI 客户端的 MCP 服务器列表
  4. 附加游戏进程,开搞

折腾到一半我还发现这个桥接有个 bug:between(范围扫描)永远返回 0 结果,因为 Lua 脚本里 firstScan/nextScan 的第二个边界值被硬编码成了 nil。修好它之后顺手给上游提了个 PR,这又是后话了。

第一回合:精确扫描,全军覆没

游戏界面显示"瓦特斯劳公爵 93%“这样的整数。我按照经典套路开始扫描:

  • 4 字节整数扫描 93:命中 21922 个地址
  • 等满意度变成 92,next scan 精确过滤:剩 14 个
  • 再等变 91……0 个。全灭了。

换 float 精确值扫描、换范围扫描、换"减小了"扫描、换送礼跳变瞬间抓取……每一种方法都遵循同样的剧本:筛到个位数后,下一轮归零。

当时一度怀疑:这个游戏是不是把满意度做成"实时计算的派生值”,内存里根本没有这个数?

转折:0-1 浮点数 + 每帧重算

就在我要放弃时,转机来自一个细节。我无意中发现:

满意度对象在内存里是一个结构体,其中一个字段的值是 0.7381 这种 0-1 之间的浮点数 —— 界面显示的 73% 其实是它 ×100 取整的结果!

也就是说,游戏里存的是 0-1 的 float(73% = 0.7381),不是 0-100 的整数。这就是之前所有扫描失败的根本原因 —— 我一直在 55~100 这个区间里找,而这个值从来不在那里。

更"惊喜"的是:这个 float 字段被游戏每帧重算写入。我手动把 1.0 写进去,下一秒就被覆盖回 0.72。

单次写入没用,那就高频写入:用 Lua 起一个 10ms 定时器持续写 1.0,压过游戏的重算频率。

界面显示 100% 了。

从"一个国王"到"所有国王"

锁住一个国王不够,游戏里有 17 个国王对象。每次重启游戏地址还会变,总不能每次都手动扫吧?

突破口在对象结构。所有国王对象共享同一个虚函数表指针(vtable):

  • vtable 地址:0x00B36DF0(在 exe 的固定数据段,每次启动都不变)
  • 对象布局:+0x00 vtable,+0x10 满意度(0-1 float),+0x14 伴随值(0-1 float)

于是思路变成:扫描所有指向这个 vtable 的地址 → 就是全部国王对象 → 全部锁定

实测一次性找到 17 个对象,与 CE 手动观察完全吻合。

最终工具:双击即用

为了让工具脱离 CE 也能用,我把它写成了独立的 Python 脚本(纯标准库,无第三方依赖):

# 核心逻辑(简化版)
VTABLE = 0x00B36DF0            # 国王对象 vtable,每次启动固定
SAT_OFF1, SAT_OFF2 = 0x10, 0x14  # 满意度字段偏移

# 1. 附加进程
pid = find_process("Patrician4_addon.exe")
h = open_process(pid)

# 2. 全内存扫描 vtable 特征 → 定位所有国王对象
objs = find_vtable_objects(h)      # 17 个

# 3. 高频线程锁 1.0
def locker():
    while not stop.is_set():
        for o in objs:
            write_mem(h, o + SAT_OFF1, FLOAT_1_0)
            write_mem(h, o + SAT_OFF2, FLOAT_1_0)
        time.sleep(0.01)           # 10ms 压过游戏每帧重算

工具全流程(附加 → 扫描 → 锁定)实测 2.4 秒完成:

[+] 找到游戏进程 PID = 22544
[*] 正在扫描国王对象 (vtable=0xB36DF0)...
[+] 发现 17 个国王满意度对象:
    对象 0x100ABA08  当前满意度 100.0%
    对象 0x11461B50  当前满意度 100.0%
    ...(全部 17 个)
[+] 正在锁定全部满意度为 100%(每 10ms 写入)...

几个小坑记录一下:

  • 扫描范围要覆盖到 0xFFFEFFFF。有 3 个国王对象在 0xFA90xxxx 这种高地址区,默认只扫到 0x7FFEFFFF 会漏掉
  • 匹配用 bytes.find 而不是逐字节 unpack。全内存逐字节解包要卡好几分钟,bytes.find 定位候选 + 4 字节对齐校验只要 2 秒
  • 需要管理员权限才能写游戏进程内存(游戏通常也是管理员运行)
  • 别对高频字段下硬件断点,每帧触发会把游戏打崩

附加收获:顺手给开源仓库提了个 PR

前面提到桥接的 between 扫描 bug,修复之后我干脆把 PR 也提了:

总结

这次逆向主要收获了一套解决"实时计算数值"问题的通用方法:

现象 结论
界面显示 73%,但怎么扫都找不到 73 真实值可能是 0.73(0-1 float),显示值只是取整
改完数值马上被还原 游戏每帧重算,需要高频写入压过它
地址每次重启都会变 不变的锚点(vtable / AOB 特征)自动重定位
网上没有现成方案 不是没人做过,而是这类"派生值"确实难搞

如果你也在跟"实时计算数值"较劲,记住这三板斧:换存储格式(0-1 float)、高频冻结、找不变特征

工具成品可下载:patrician4-lock.zip(解压后双击 run_lock.bat 即用,需管理员权限)。