把戴森球计划的 Tick 拆开看:一次 Harmony 插件考古

DSPYurikoPlugin 是一个面向《戴森球计划》的个人功能增强插件,也发布在 Thunderstore 上。它的功能列表看起来很像一张“把游戏调快一点”的愿望清单,但真正有意思的部分在于:游戏里的速度、产量、运输和星系生成并不都由同一个字段决定,想改得稳定,就得顺着运行时机制找到正确的入口。

换句话说,这不是把几个数字乘以 10 再打包的项目,而是一次对 Unity 游戏内部系统的运行时考古。考古过程中当然也挖到过“这个字段改了为什么完全没效果”的小化石。(:з」∠)

插件是怎样进入游戏的

项目是一个 .NET Framework 4.8 类库,跑在 BepInEx 提供的插件环境里,用 Harmony 对游戏类型和方法打补丁。入口 YurikoPlugin.cs 负责初始化并统一注册各个 Patch 类,具体补丁按游戏领域拆分:GameMain、GameHistoryData、FactorySystem、StationComponent、RecipeProto、UniverseGen 各管各的系统。

1
2
3
4
5
6
7
BepInEx 加载 YurikoPlugin
↓
Harmony 注册 Prefix / Postfix / 原方法替换
↓
游戏原型数据 + 运行时 Tick + 存档读写
↓
移动、生产、物流、研究、星球和戴森球行为变化

入口只负责装配,行为放在独立 Patch 里;倍率和开关集中在 YurikoConstants 这些位置。好处很朴素:游戏更新以后,至少能快速知道是哪个类型、哪个方法、哪个常量受了影响,不用在一个巨型 Hook 文件里玩寻宝游戏。

功能范围其实分成三种

插件覆盖的内容很多:机甲移动、工作和采矿速度,物流运输与生产速度,电力感应塔和运输站范围,研究与配方效率,地基铺设、戴森球建造以及星系生成参数等。

但它们的生效方式并不一样,大致分三类:

  1. 原型或历史数据调整。 物品、配方、建筑和部分游戏历史参数在初始化阶段修改,适合影响基础配置。
  2. 运行时 Tick 介入。 工厂、运输站、分拣器和研究系统每个 Tick 都会重新计算状态,只改初始字段是不够的。
  3. 生成、建造与存档路径。 星系和戴森节点在生成或建造阶段决定行为,自定义速度还要在导出和导入存档时保持一致。

这三类边界解释了为什么 Patch 文件这么多:不是为了显得“模块化”,而是游戏自己就把行为分散在不同生命周期里。

Patch 不是简单的倍率表

项目里确实有移动、生产、研究等倍率常量,但倍率只是输入,真正决定结果的是游戏运行时怎么消费它们。比如传送带和分拣器的速度统一到 3600,是因为游戏 Tick 里存在物品处理上限;数字继续往上加的话,货物可能不再连续,视觉上反而变成一串瞬移。

装配类建筑则用固定生产速度 60000,再按建筑类型换算。生产逻辑还会考虑配方、建筑数量和当前状态,所以只改原型上的显示值,往往盖不住工厂的实际产出。FactorySystemPatch 会在 Tick 中重新计算矿物与运输站物品的产出,读取生产统计,再把实际结果写回生产寄存器。

运输站、轨道采集器和行星矿机也有类似问题:获取速度跟供应、需求和当前运输状态挂钩。想让它们“更快”,就得在正确的运行时计算位置动手,而不是盯着一个看起来很像速度的字段发呆。

主要补丁按谁来负责什么

入口注册的 Patch 大致覆盖这些对象:

  • GameMain / GameHistoryData:初始化机甲、物流、研究速度和历史数据,并处理自定义字段的持久化。
  • ItemProto / RecipeProto / ModelProto:调整物品、配方和建筑原型参数。
  • InserterComponent / FactorySystem / LabComponent:介入分拣、生产和矩阵研究的运行时计算。
  • StationComponent / Mecha / PowerGeneratorComponent:扩展运输站、机甲和发电设施的行为。
  • DysonNode / StarGen / UniverseGen:处理戴森节点建造、星系生成和相关参数。
  • AchievementLogic / GameAbnormalityCheck:在功能增强与游戏成就、异常检测之间做适配。

Patch 方法既有 Prefix、Postfix,也有在必要场景下替换原方法的实现。Prefix 适合在原逻辑前修改输入或直接拦截,Postfix 适合根据原结果补充行为;完整替换虽然威力大,但也更容易跟游戏新版本冲突,所以必须清楚自己究竟覆盖了什么。

为什么同一个功能常常需要多个 Patch

拿“生产速度变快”来说:原型数据可能决定建筑的基础参数,工厂 Tick 决定这一帧真正处理了多少物品,生产统计又决定界面和历史数据显示什么。只改第一层,建筑面板可能显示变快了,实际产出纹丝不动;只改 Tick,读档或重新生成对象时又可能被打回原形。

所以 Patch 的边界要跟着游戏生命周期走:初始化阶段处理默认值,运行阶段处理实时计算,导入导出阶段处理持久化。多个补丁不是重复劳动,而是在不同时间点维护同一项自定义规则。真正危险的是多个 Patch 对同一个字段互相覆盖,却没人记录执行顺序。

Harmony 的 Prefix / Postfix 还要考虑原方法会不会被其他插件改过。只在本地单插件环境成立的假设,放进玩家的完整 Mod 列表后可能就不成立了。这里没有绝对安全的 Hook,能做的是缩小 Patch 范围、记录目标方法,版本变化时重新验证。

用几个具体数字理解 Tick 的边界

README 里记录了传送带和分拣器统一速度 3600——游戏 Tick 的处理上限和每分钟 3600 个单位相关,超过这个边界物品就可能不连续。装配类建筑用 60000 的固定生产速度再折算。这些数字不是“越大越好”,它们必须和游戏内部的时间离散化方式匹配。

这类限制很像在做离散模拟:现实里速度是连续的,游戏里却要在一个个 Tick 中分配整数物品、能量和进度。倍率直接乘大,可能得到浮点误差、整数截断,或者一帧处理不完的队列。理解了 Tick,才能明白某个倍率为什么应该停在一个看似奇怪的数字上。

存档是最容易被忘记的那一层

运行时把速度调高,不等于它会跟着存档一起保存。GameHistoryDataPatch 会在导出和导入历史数据时同步自定义的速度、采矿、物流和研究字段。不然会出现两种很烦的情况:读档以后参数全部恢复默认,或者自定义字段改变了原有数据布局,旧存档读出不可预测的问题。

所以我测试插件从来不只看“新建一个星球有没有生效”,还要走完修改参数、保存、退出、重新加载、再检查这一整圈循环。任何涉及游戏状态的 Patch,都应该把读档当成正常路径,而不是发布以后才发现的售后服务。

游戏版本耦合:它不是一个普通 NuGet 包

项目引用了目标游戏的 Assembly-CSharp、UnityEngine 和 UnityEngine.CoreModule。这意味着它天然依赖特定游戏版本的类型、字段和方法签名,并不是换台机器就能保证一致的通用库。

构建要用目标版本的游戏 DLL,发布时要说明对应的 BepInEx 环境。游戏升级之后,第一件事是验证 Harmony 目标方法还在不在、字段布局变没变、调用顺序还成不成立、存档导入导出有没有受影响。很多插件不是“代码逻辑突然错了”,而是原来的方法已经被游戏更新悄悄换掉了。

这也是我觉得这类项目很适合当学习记录的原因:它会逼着人理解依赖不只是 csproj 里的版本号,运行时类型布局、引擎生命周期和宿主程序的更新同样是依赖。

我会怎样验证一个 Patch 没有偷偷伤害存档

验证时不能只挑最顺利的路径。至少要覆盖:新建游戏参数是否正确、已有存档加载后参数是否恢复、修改配置后是否影响下一次计算、导出再导入后自定义字段还在不在,以及关闭插件后原版数据能否正常读取。

如果 Patch 动了 GameHistoryData,还要比较字段长度、默认值和旧版本存档。能进主界面不代表数据兼容,真正的检查要继续走到工厂、研究、物流和历史统计页面。插件开发里,“没有立刻崩溃”只是第一道门槛。

同理,生产速度的验证不能只数物品,还要看运输站供应需求、分拣器队列、生产统计和长时间运行后的库存是否一致。运行时修改越深,越需要一组稳定的手工场景来做回归。

分发与使用时要留一点余地

项目通过 BepInEx 插件目录加载,也发布到 Thunderstore,README 里保留了功能 Roadmap。用这类插件前请备份存档,确认游戏版本和依赖环境,并遵守当前游戏、平台及社区规则。尤其是会影响成就、异常检测或多人环境的功能,不能因为“本地能运行”就认为它适合所有场景。

DSPYurikoPlugin 最后留下的并不只是几个更大的数字,而是一组关于“游戏系统在哪里真正做决定”的笔记:原型数据、运行时 Tick、存档和版本兼容性缺一不可。能把一个陌生系统拆成这些入口,已经比单纯把倍率写进配置文件有意思多了 (`・ω・´)

如果把它当成学习材料,我建议先从一个最小 Patch 开始:找到一个明确的方法,记录原始输入和输出,只改变一个变量,然后用新建存档和旧存档各跑一遍。等生命周期、调用顺序和恢复路径都弄明白了,再把多个实验规则组合起来。一步一步来,才不会被自己的倍率表反过来支配。

项目代码见 github.com/Acceyuriko/DSPYurikoPlugin。

文章目录
  1. 1. 插件是怎样进入游戏的
  2. 2. 功能范围其实分成三种
  3. 3. Patch 不是简单的倍率表
  4. 4. 主要补丁按谁来负责什么
  5. 5. 为什么同一个功能常常需要多个 Patch
  6. 6. 用几个具体数字理解 Tick 的边界
  7. 7. 存档是最容易被忘记的那一层
  8. 8. 游戏版本耦合:它不是一个普通 NuGet 包
  9. 9. 我会怎样验证一个 Patch 没有偷偷伤害存档
  10. 10. 分发与使用时要留一点余地
|