Featured image of post 615 张蓝图,6.6 秒:我把《异星工厂》的蓝图拆开了

615 张蓝图,6.6 秒:我把《异星工厂》的蓝图拆开了

未公开的蓝图格式、六 MB 的单文件查看器,和一个能算出「你这个工厂卡在哪」的计算内核。

我有一个文件夹,里面躺着 615 个 .txt 文件。每个文件里是一串以 0eJz... 开头的字符——这是《异星工厂》(Factorio)的蓝图字符串。

蓝图是玩家花了几个月搭出来的工厂。它现在只是一串文本,你看不出里面有什么,算不出它能产多少东西,也没法验证它是不是真的能跑。

我想让它变成能看、能算、能验证的东西。


一、字符串里到底装了什么

官方没有公开蓝图格式。资料里的说法是:Base64 编码的 zlib 流,解出来是 JSON。

照着做,第一步就失败了:

1
zlib.error: Error -3 while decompressing data: incorrect header check

我换了好几种字母表、试了 URL-safe 变体、补了不同数量的填充,全都不对。最后不猜了,直接读原始字节:

1
0e  4a  7a  5f  ...

0e 是 Base64 解码的第一个字节?不是。真相是——第一个字符 0 根本不属于负载。

0 是版本号,本身就是一个 Base64 字符(数值 52)。真正的负载从下标 1 开始。从第二个字符往后解,第一个字节正好是 78,那是 zlib 的合法头。

坑一:开头那个字符不是负载。

坑二:填充没被去掉。 好几处资料说 Factorio 会去掉 =,但三个文件全都以 = 或 == 结尾。去掉就刚好差两个字符,解出来是残的。

结论写在项目里的一句话:以文件为准,不以文档为准。

验证方式是往返:decode 之后再 encode,必须逐字符完全相同。三个原始文件、以及内部嵌的全部 615 个蓝图,逐字符都通过了。


二、数值不能抄,得读

蓝图里只有"这台机器在这里、朝这个方向",没有"它有多快"。速度表在游戏安装目录里。

所以我写了一个受限的 Lua 求值器(fablue/lua.py):它能处理 data:extend 语句、表达式、内置库和辅助函数,但绝不执行宿主代码——没有 require、没有文件系统、没有外部依赖。

第一版解出 1,367 个原型,还有 149 个 data:extend 解不出来。加了对跨文件、局部变量、函数返回值的支持之后:

修复前现在
解出的原型1,3674,633
解不出的 data:extend14925
传送带速度012
机器速度017

剩下 25 个全是跨文件 data.raw 派生的瓦片/装饰类,不含速度表——这个上限是我报出来的,不是藏起来的。

读出来的真值(全部来自 data.raw):

项目值
传送带速度0.03125 / 0.0625 / 0.09375 / 0.125 tile/tick
双 lane 吞吐15 / 30 / 45 / 60 items/s
单 lane 吞吐7.5 / 15 / 22.5 / 30 items/s
组装机 1/2/3crafting_speed 0.5 / 0.75 / 1.25
电炉 / 石炉 / 钢炉2 / 1 / 2

一个犯了两次的 2 倍错误

×480 是双 lane 合计。推导是 0.03125 × 60 / 0.25 = 7.5(单 lane),乘 2 才得 15。

早期版本把这个常数标成 “per lane”,同时 GameDB.belt_items_per_second 又乘了一次 lanes——同一个 2 倍错误,在两个地方各犯了一次。因为是"自洽的错",测试抓不到。

现在常量改名为"双 lane 因子",新增 belt_items_per_second_lane 属性,并把推导写进注释。单位写清楚,就不会再被隐含。


三、615 个蓝图,一个 canvas

查看器的约束很硬:单文件、离线、无外链。

615 个蓝图、334,856 个实体,如果每个实体画一个 SVG 矩形,是数十 MB 的 DOM。所以:

  • 一个 canvas,不是每蓝图一个
  • 每实体只存 4 个整数:x2, y2, 类别, 朝向。坐标翻倍是因为 Factorio 有大量实体放在半格上(整数存不下 0.5)
  • 全部数据一次性嵌进页面 → 6.04 MB

性能靠视口裁剪:懒建空间哈希 + Int32Array。一个 34,650 实体的蓝图,在深度缩放下一帧只画 13 个实体;全可见时 p95 是 8.8 ms,中位 0.6 ms。

实体还按真实尺寸绘制——尺寸取自每个原型的 collision_box(3×3 的机器是 2.4,补齐到 3 格;火箭发射井 8.4 → 9 格;传送带 0.8 → 1 格),690 个尺寸表项随页面发出。


四、让它算

看图不够,我关心的是:这个工厂到底产多少、卡在哪。

计算内核输出四类东西:净产出速率、外部输入、配比失衡、传送带饱和。

一个真实例子(34,650 个实体):

1
2
3
4
传送带饱和  copper-cable 151.1/s,是 express-transport-belt 容量的 3.36 倍
净产出      copper-cable 151.13/s、sulfuric-acid 97.00/s、petroleum-gas 34.00/s
外部输入    water 1587.50/s、crude-oil 480.00/s、iron-plate 282.15/s
瓶颈        未知配方:assembling-machine-3 ×18 台无法确定配方,未计入产出速率

核心公式全部写在代码注释里,不从别处抄:

1
2
3
每秒次数 = crafting_speed ÷ recipe.energy
每秒产出 = 次数 × 单次产出量
每 lane   = belt_speed × 240,双 lane = belt_speed × 480

模块效应也计入了:同一个机器内的模块加成相加(三个 +50% 速度模块 = ×2.5,不是 1.5³)。有一条专门的断言守着:组装机 3 做铜缆,无模块 5.0/s,装三个速度模块 12.5/s。


五、几处"刻意做对"的判断

这些地方很容易写成"看起来对、其实错":

  • stone-furnace 这类实体在蓝图里没有 recipe 字段,但 Factorio 也不把默认配方写进蓝图。所以必须报成"未知配方(可用 overrides 指定)",而不是当成"这台机器闲置"。
  • 信标(59 个)持有模块但不执行配方,如果当机器算,每个信标都会变成一条假"未知配方"。
  • 实验室和采矿机的产出取决于科技/矿脉,单独报,不归入"未知配方"。
  • “蓝图没给配方"和"蓝图给了但游戏数据里查不到"分开报。后者我核实过 mods 目录确实没装 mod,所以那批 mod 蓝图算不出来是如实缺口,不是静默失败。

六、验证:334 项断言 + 三个浏览器终检

测试文件断言在验证什么
test_blueprint14编码往返、格式事实、拒绝畸形输入
test_all_blueprints8全部 615 个蓝图逐字符往返
test_gamedata79合成数据证明数值真来自文件
test_gamedata_independent45正则直接从 Lua 抠数值 + 物理独立推导
test_calc90精确数值、未知配方不静默丢弃、循环保护
test_integration22跨模块接线:模块效应→计算、尺寸→渲染
test_view76页面无外链、嵌入数据条数等于输入

test_gamedata_independent.py 是关键一条:它不经过提取器的 API,而是用正则直接从原始 Lua 文件里抠数值比对——提取器的 bug 无法靠"自洽"藏住。

三个真浏览器终检不是检查 HTML 文本,而是真的操作页面:缩放/平移真的改变渲染、点到饱和蓝图真的显示警告、放大后每实体像素数真的从 3.1 涨到 50,181.6。

这一层抓到了 5 个纯 Python 测试看不见的 bug:相机 NaN、缺边界、图例按数字索引查却按名字存、renderLegend() 重建时销毁了正在处理点击的节点、data-i="0" 的 falsy 边界。

这就是为什么要用真浏览器驱动页面。DOM 里的字符串说"我很正常”,不算数。

最终:334/334 通过,7/7 浏览器终检全过,端到端 6.6 秒。


七、还没做到的

  1. 信标效应没建模——需要空间分析(每台机器周围有几个信标),目前只在 notes 里计数。
  2. 传送带饱和是按物品总量、不是按段判定——蓝图 JSON 不记录机器和传送带的连接关系,要按段判就得从坐标和朝向重建拓扑,这是独立的一大块。
  3. K2SE 的 mod 配方读不到,因为这台机器没装 mod。装上、把 data_root 指过去就能算。
  4. 品质加成、插入机吞吐、电力网络都还没建模。

这些限制我都写在页面的结论里,不含糊过去。


打开蓝图平台(6 MB,含 615 张蓝图)→

使用 Hugo 构建
主题 Stack 由 Jimmy 设计