想象一下:Balatro 崩溃后重新打开,生涯数据没了,解锁也没了,却没有任何报错。一位独立测试者决定在自己的存档上实测这类问题,结果比崩溃本身更奇怪:他逐个比特翻转自己的三份真实 Balatro 存档,约五分之一损坏后的文件仍然悄悄加载成功,里面的数据却变了。本文只报道这次第三方测试,既不是 Balatro 开发者 LocalThunk 的表态,也不是官方复盘。
快速信息
来源:Matt Busel 的独立第三方测试,2026年10月19日发表于 DEV,原载 tensorust.com,用的是他自己的 Balatro 1.0.1o-FULL 存档。这不是 LocalThunk 的表态,也不是官方复盘。
定性:本文只报道一位测试者在自己文件上测到的结果,再把它转成读者自己项目的存档做法,不对 Balatro 玩家整体做任何断言。
测试发现:每个 .jkr 文件都是用 loadstring 加载的 Lua 表格,以无校验和的原始 DEFLATE 压缩;61,672 个逐比特翻转的文件里,12,694 个带着不同数据加载成功。
第二项发现:存档既然是代码,就会带着 Lua 状态能触及的东西一起跑,所以安全做法是只解析数据子集,绝不要靠执行存档来读取它。
本文不声称的内容:没有官方回应、没有修复、没有恶意软件事件、也没有受影响玩家数的估计,因为打开过的测试都没有这些内容。作者对未来更新的猜测也已删去。
最后核实:2026年10月30日。
存档是个程序,不是数据文件
Balatro 是独立开发者 LocalThunk 用 LOVE 引擎做的扑克肉鸽游戏,存档后缀是 .jkr。这次独立测试里,作者用的是自己 Steam 上 Balatro 1.0.1o-FULL 版本的存档,2026年10月18日解码。他写道,保存时会生成一段 Lua 源字符串,形如 return 后面跟一个键值表,再用 love.data.compress 以 deflate 格式、等级 1 压缩。加载时则经过一个叫 STR_UNPACK 的小函数,靠 loadstring 把字符串编译成 Lua 代码块再执行。换句话说,存档不是 Lua 读取的数据,而是 Lua 执行的程序,它的返回值恰好就是存档。压缩形式是原始 DEFLATE,没有 zlib 头、没有 gzip 头、也没有校验和。作者检索了游戏的 Lua 代码,共 98,558 行、47 个文件,crc32、adler、checksum 三个词出现了 0 次。他的三份文件都不大:磁盘上 settings.jkr 为 856 字节,meta.jkr 为 2,809 字节,profile.jkr 为 4,044 字节。
接下来是损坏测试,用游戏自己的解压器和 LuaJIT 跑。作者先把每个文件逐段截短,共 7,709 次撕裂读取,模拟游戏还没写完、读取器就打开文件的情形。结果全部在解压一步失败,因为被截断的 DEFLATE 流永远到不了最终块标记。在这次测试里,写了一半的文件绝不会被误认成完整文件。逐比特翻转的扫描才是坏消息:把三份文件的每个比特逐个翻转,共得到 61,672 个损坏文件。约 31.7% 解压失败,约 47.7% 解压出的文本被 Lua 拒绝加载,32 个因为翻转位落在填充位而解回了原样。剩下的 12,694 个,即 20.6%,没有任何报错、带着不同数据加载成功,没有任何东西提醒玩家。作者又用 zlib 封装重跑同一扫描,只多了 2 字节头和 4 字节 Adler-32 校验和,共 6 字节:61,816 次翻转里,带着错误数据加载成功的为 0。
为什么一个比特翻转改的是你的数据,而不是直接报错
原因在于 Lua 表格字面量是一种很宽容的格式:键名改一个字母,仍然是合法键名;数字改一位,仍然是数字。作者报告,三份文件里 93.9% 的静默翻转先改的是名字,5.1% 先改的是数字,中位翻转只改动了解压后文本的 2 到 7 个字节。他给出的 profile.jkr 例子很具体:第 638 位翻转把 career_stats 改成了 career_stabs,于是所有生涯数据都读不到了;第 793 位翻转把弃牌数 4616 改成了 8616;第 790 位翻转把 4616 改成了 zt16,Lua 会把它当成不存在的全局变量,于是值变成空,统计项消失;还有一次翻转改了累计赚钱键名,从此游戏里没有任何东西再读它。落在玩家眼里,这和游戏清空进度一模一样。但在这次测试里,没有任何进度是被游戏逻辑清空的,是文件本身变了,而格式没有办法察觉。
同一测试还有第二项发现,关于读取存档而不是损坏存档。存档既然是程序,里面的任何东西都会带着 Lua 状态能触及的东西一起跑;作者写道,LOVE 引擎会打开 Lua 的标准库,包括 os 和 io。他用游戏自己的 Lua 构建、加载标准库做的检查里,一段读取环境值的测试代码块正常加载并返回了值;同一代码块放在空环境里则失败。本文不对任何真实下载文件下结论,也不声称有玩家因此受害。能带走的教训要窄得多:绝不要靠执行存档来读取存档。作者自己的读取器是先解压、再用严格解析器只解析表格字面量,拒绝函数调用和语句,这种形状的读取器才是自己项目工具的安全范式。对任何以这种方式加载存档的游戏,下载来的存档都值得同样谨慎:不要把来路不明的文件丢进游戏、让游戏去执行它。
能复用的部分
对自己项目的教训,好就好在它很无聊:存档格式需要一种知道自己已损坏的办法、一种大声失败的办法、一个绝不执行所读内容的读取器。作者的校验和对比给第一点标了价:6 字节的 zlib 封装让静默错误加载从 12,694 次降到 0。他的撕裂读取结果给第二点加了条件:带结束标记的格式被截短时会大声失败,而纯文本格式可能把一段前缀解析成一个更小但合法的存档。他的模糊测试结果覆盖第三点:只在好输入上测过的解析器,在 12,000 个变异存档上与游戏加载器有 916 次不一致;只有收紧检查、分隔符规则、转义处理和尾部垃圾拒绝,才把不一致降到 161 次。测坏输入,而不只是好输入。
周一早上照着做
动手之前先备份。编辑或测试前,把自己项目的存档复制一份单独保存,绝不要直接动唯一的一份。
给自己写的每个存档打标记。给自己的格式加上校验和与版本标记,让损坏文件大声失败,而不是以另一份存档的面目加载成功。
先写临时文件再重命名。绝不要原地覆盖正在用的存档,这样写到一半崩溃时,上一份好文件还完好无损。
只解析、不执行。用只懂数据子集的严格解析器读自己的存档,拒绝函数调用、语句和尾部垃圾。
测坏输入。翻转比特、截短文件、给读取器喂畸形文本,并要求它拒绝所有本该拒绝的东西。
绝不要手工改正在用的存档。非看不可时,在游戏或工具关闭的状态下看复制件,原件保持不动。

