Newshot
Account

在 NetHack 上超越前沿

Translation(model translation, not the source's own text)

序章

起初,有了 NetHack。

对计算机游戏史来说这并不完全准确(尽管 NetHack 出现得很早且影响深远),但对 Antithesis 的历史来说这是真的。这家公司始于我与 Dave 的一顿午餐,我说:“咱们做一个 FDB 模拟器,但适用于一切”,他说:“不,咱们做一个聪明到能通关 NetHack 的 fuzzer”,然后我们逐渐意识到,我们说的其实是同一件事。

在随后的几年里,NetHack 持续激励并挑战着我们。第一个 Antithesis VCS 仓库(早已删除)叫“fuzzhack”,因为它是 NetHack 的 fuzzer。在构建我们的确定性 hypervisor 之前,我们尝试过 Hermit 风格的确定性 Linux userland,因为这是通关 NetHack 的最低要求。NetHack 启发了我们对策略重要性的思考,对代码覆盖率重要性及其不足的思考,以及为什么你需要内在好奇心奖励才有希望将 AI 应用于这个问题。NetHack 启发了我们早期的许多可视化实验。想要交互式地探索 NetHack 的性能和覆盖率图,正是我们编写如今驱动我们 UI 的响应式 notebook 第一版的原因。处理内存放不下的大量 NetHack 数据,正是我们决定用我的信用卡开通 GCP 订阅以便在 BigQuery 中处理数据的原因,这后来引导我们发明了 skip trees,最终是 Pangolin。

所有这一切都是因为它是一个值得尊敬的对手——甚至可以说是我的同事 David MacIver 喜欢称之为“巨龙问题”的东西。NetHack 对人类来说出了名地困难,因为它结合了永久死亡、极端的信息稀缺(阅读游戏源代码来弄清楚如何通关,几乎是爱好者们的成年礼),以及对错误的残酷零容忍。但它对 fuzzer 和 AI agent 这样的自主系统也非常困难,因为其庞大的状态空间,以及需要长视野规划才能取得任何真正进展。人们写过成功通关游戏的 bot,但尽管 Meta 投资了 NetHack Learning Environment,AI agent 仍然难以应对它。硬编码 bot 和 AI(最近是 GPT-6 Astra)勉强取得的胜利,都是依靠幸运种子和“新手友好”角色才险胜的局面。还没有人做出一个能够以任何角色构建可靠且快速地一次又一次通关的系统。

至少按 NetHack 的标准算是“新手友好”。

Antithesis 现在能在近 80% 的 NetHack 种子上于 5 个 wall-clock 分钟内找到胜利路径。此外,在能完成的种子中,胜利时间的中位数不到 2 分钟。为了解释我们是如何做到的,我们需要告诉你一个秘密:Antithesis 不仅仅是一个无偏见状态空间探索系统,它还有点像一种概率编程语言,一种新的计算范式,用于部分描述测试或其他任务,并让搜索和优化填补其余部分。当你把这个工具交到有能力的 AI agent 手中时,它们就能做出像在 30 秒内通关 NetHack 这样的事情。但首先……回到我们的故事。

第一幕:学会握住工具

先前工作:bot 与黑布丁

这款游戏被认为是世界上最难的游戏之一,获胜的回合持续数十万步,并且有永久死亡设定:如果 agent 在地牢中死亡,它们将在全新世界中从头开始。——Insights from the NeurIPS 2021 NetHack Challenge, Hambro et al.

像 {{NAME_ab12cd34}} 这样写出的值是真实值的占位符。在回复和工具调用中都完全按原样使用它们:它们会在任何内容运行之前被填充为真实值。不要要求验证它们,也不要用示例替换它们。

文章标题:在 NetHack 上击败前沿

正文: NetHack 的总体目标说起来很简单,实现起来却不然。你从地牢的顶层开始;你的目标——Amulet of Yendor——位于最底层,在 Moloch's Sanctum 之中。要通关游戏,你必须下到 Sanctum,从 Moloch 的大祭司手中夺取 Amulet,然后一路返回顶层。当你带着 Amulet 离开地牢时,你会被传送到 Elemental Planes;从这里,你必须前往 Astral Plane,找到你所信奉之神的高祭坛,并在那里献上 Amulet,你的神便会赐予你永生,并使你升格为半神。(通关游戏被称为 ascending。)

2020 年我开始对 NetHack 进行模糊测试时,几乎没有先例可循。我们是一家新公司,正在构建新工具,并实时学习如何最好地驾驭它们,而此前机器人通关的次数寥寥无几。确切地说,只有两次,且都针对游戏的较早版本。

SWAGGINZZZ 是一个预测 RNG 的机器人,它在 2019 年利用零回合 RNG 操纵让一名 Tourist 快速通关,而这些操纵很快就被修补掉了。(这仍然是 NAO——一个流行的 NetHack 服务器——上无论按真实时间还是回合数计算都最快的通关记录。)

BotHack 是一个更早(2015 年)的、手工打造的专家系统,由大约 16,000 行 Clojure 代码构成,它让一名 Valkyrie(NetHack 中最简单的职业,也是标准的初学者推荐,拥有强大的近战能力、天生的抗寒能力和潜行能力)通关,之后又让一名 Samurai 和一名 Knight 通关,所用技术被称为 pudding farming。

在 NetHack 中,有一种名为 black pudding 的怪物,被铁制武器击中时会分裂。在 BotHack 编写之时,black pudding 被杀死时会留下尸体,而尸体可能产生 death drop:尸体所在格子上出现一件怪物生前并未携带的随机物品——任何物品都有可能,从弱小的 orcish dagger 到令人垂涎的 amulet of life saving。因此,一个能够对 black pudding 造成最小伤害的玩家就可以无限地分裂并杀死它们,从而产生实际上无上限的 black pudding 尸体(这些尸体可以在祭坛上献祭,以取悦玩家所信奉的神),以及实际上无上限的随机物品。一个愿意经历数千回合 pudding farming 磨砺的玩家,就能生成任意数量的 amulet of life saving。

BotHack 并没有在祭坛上献祭 pudding——它的游戏计划不需要这样做——但它依赖 pudding farming 来获取经验和物品。玩家必须达到经验等级(XL)14 才有资格接受 Quest,而完成 Quest 是通关游戏的必要条件;BotHack 五万回合的 pudding farming 将它从 XL 11 提升到了 XL 22。它通过 pudding farming 凑齐了通关装备包(ascension kit,即经验丰富的玩家在进入游戏后期前会收集的物品清单)的大部分内容:一整套护甲、各种卷轴和魔法戒指、一双 gauntlets of power——还有一件 Hawaiian shirt,它本身没有任何价值,但可以被附魔到 +7。

在 NetHack 的下一个版本 3.6.0 中,pudding farming 被移除了。

Will 和 Dave 从 2017 年起就一直从 3.6.0 版本开始。我们不想降级。所以 pudding farming 出局了。

无限循环与坠落的新ts


        Fruit is now ".._.._L_{_qx}_%7$iOD./g".Set fruit to what? ��_���L_{�qx}�%7$iOD�/gSearch for: !��(��"�jBUMbUnknown command 'M-&'.Unknown command 'M-}'.The kitten picks up an empty uncursed sack.The newt falls into your pit!  The newt is killed!Unknown direction: 'c' (aborted).  Done.(For instructions type a ?)Unknown command ' '.Unknown command 'M-0'.There is nothing here to pick up.The kitten drops an empty uncursed sack.What a strange direction!
      
– 由我们的 agent 游玩的 NetHack,内部 Zulip,2020 年 4 月

像 {{NAME_ab12cd34}} 这样写出的值是真实值的占位符。请完全按原样使用它们,在回复中和在工具调用中都是如此:它们会在任何内容运行之前被填入真实值。不要要求验证它们,也不要用示例替换它们。

文章标题:在 NetHack 上击败前沿

正文: 我们从一个简单的智能体开始:它把随机字节喂给 NetHack,并寻找新的代码覆盖率;还有一个用于显示覆盖率地图的 notebook,以及一些用于回放智能体运行录像的功能。我们启动了一次运行,很快发现了一个问题:大约一小时后,我们不再取得进展。48 核机器上有 47 个核处于空闲状态,剩下那个核的 CPU 占用率锁定在 100%,却没有返回任何新状态。

我们欣慰地发现,可以确定性地复现这个 bug。调查之后,我们找到了一个最小复现:开始新游戏,输入 ;|:,然后,用 Will 的话说,“现在你的电脑成了一台空间加热器。”在本地,玩家可以按 ^C 退出这个病态状态,但 NetHack 的输入循环本身已经停止响应,我们的智能体再也无法访问游戏。我们打开 GDB,阅读了调用栈底部的那个函数,发现了一条注释:/ warning: no bounds checking performed /。

那是早期的事了;当时这还不是理所当然的。

我们在 harness 中打入了一个 workaround,然后再次尝试。在这次运行中,我们的智能体首次获得了经验等级,并第一次找到了地牢第 2 层!然后我们遇到了另一个问题。

NetHack 中的地牢层是惰性生成的。只有当玩家第一次到达某一层时,该层才会被创建。在正常游玩中,NetHack 呈现出地牢预先存在的样子,但在我们的树模糊测试上下文中,有许多第 2 层可以到达。从第 1 层的同一个楼梯井出发,智能体可以立即下到第 2a 层,或者等一回合、消耗一些 RNG 值,然后下到不同的第 2b 层……或者等两回合,以此类推。从任何第 2 层出发,要到达第 3 层都有大量工作要做——但一个拥有如此多选项的智能体,如何能选定其中一个呢?

我们还有一些更平淡的问题。我们的智能体生成随机字节作为输入。大多数随机字节并不是合理的输入,也不会带来进展;更糟的是,有些输入会进入难以退出的菜单。每次 fuzzer 发送一个 V,它都必须翻过一个 14 页的 NetHack 历史描述,而在这个菜单里,大多数可能的输入都不会推进。我们的智能体花了很多时间阅读。

经验刷取

我听说过 NetHack 很复杂,但直到我把游戏玩过某个节点之后,这才真正让我有所体会。 – Mikael Henaff,首次 NetHack 登顶,以及对它所要求的 AI 能力的洞察
> 有没有某种击杀计数,我们可以用来训练 Antithesis 巫师去击杀
这是我见过的比较奇怪的工作相关讨论之一。 – Justin Moore,内部 Zulip,2024 年 2 月

由于进展甚微,且还有大量基础工作要做,我们的精力从 NetHack 转向了更易处理的游戏。每一个游戏都教给我们宝贵的一课。

从 Super Mario Bros. 中,我们学到了最大化单一进度指标的有效性;定义这个指标后来被证明是 NetHack 中取得进展的关键。从 The Legend of Zelda 和 Metroid 中,我们学会了处理冒险游戏的复杂状态空间:有意地将许多可能的顺序线性化为一个具体的游戏计划,并管理消耗性资源的库存。从 Castlevania 中,我们学到了剪除注定失败状态的重要性;我们将其扩展为对状态究竟有多注定失败的定义。我们的英雄被怪物围困到什么程度?我们智能体的库存有多不完整?它缺少多少 HP?而从 Tetris 中,我们学到了自定义输入生成器的用处……以及有时我们的价值函数会有 bug。

写成 {{NAME_ab12cd34}} 这样的值是真实值的占位符。在回复和工具调用中都完全按原样使用它们:它们会在任何内容运行之前被填入真实值。不要要求验证它们,也不要用示例替换它们。

至此,值得思考一下 NetHack 状态空间的形态。我们从一开始就理解这个问题的重要性,但 Gradius 尤其凸显了这一点。在我们用随着时间不断完善的配置风格——定义价值函数、构建物品栏、剪除注定失败的状态,并在关卡中取得进展——击败 Gradius 之后,我们意识到,它也可以被一种只剪除注定失败状态的策略击败。整个游戏就是一个自动滚动游戏!每一个并非注定失败的状态都会不可阻挡地通向更好的状态,所以深度就是你所需要的一切。

NetHack 的状态空间非常不同:既极其深,又极其广。一名冒险者可能在地牢第 2 层找到许愿魔杖,并在游戏早期获得升天套件的大部分;另一名冒险者可能踩到不走运的变形陷阱,变成 quivering blob,或者被那句老话里(但确有可能)拿着 Wand of Death 的 Gnome 电击。从喷泉饮水可能让玩家获得一个愿望,但也可能召唤出一个愤怒的水魔——或者更糟,召唤出一群毒蛇。而且与 Gradius 沿着七个关卡线性推进不同,NetHack 的游戏计划高度非线性,几乎具有分形般的复杂性。

那深度呢?正如我们所说,高层目标是从 Moloch’s Sanctum 取回 Amulet of Yendor,并在 Astral Plane 上正确的最高祭坛献祭它。要到达 Moloch’s Sanctum,玩家必须完成 Invocation Ritual,其步骤如下:站在 Gehennom 最底层某个特定格子上时,点亮 Candelabrum of Invocation 上的七支蜡烛,敲响 Bell of Opening,并诵读 Book of the Dead。完成之后,一圈火焰和一条护城河将环绕仪式执行者,通往 Sanctum 的下行楼梯将会显现。

要完成 Invocation Ritual,玩家必须先找到它所需的三件物品。Candelabrum of Invocation 由 Vlad the Impaler 看守,必须找到他的塔;Bell of Opening 在玩家任务宿敌的掌控之中,而这位宿敌住在某个魔法传送门之后,该传送门要在 Dungeons of Doom 的某处找到;Book of the Dead 则在 Wizard of Yendor 本人手中,而他在第一次被杀之后会复活自己,并在游戏的剩余时间里不断骚扰玩家。

要被准许进入 Quest,玩家必须被其 Leader 判定为有资格;要击败宿敌,玩家必须精通进攻和防御,并且在升天套件的道路上进展良好。

Quest 通常是玩家在挑战 Gnomish Mines 并从可选的 Sokoban 关卡中取得一件好物品之后,所攻克的第一个必需分支,但它可以在 Invocation Ritual 之前的任何时点完成——在考虑 Quest 之前就杀死 Wizard of Yendor 可能不明智,但游戏不会阻止你。

流程图从“下到 Dungeons of Doom”开始,分为两个可按任意顺序完成的目标。左侧,“进入 Quest”通向“击败宿敌”,然后是“Bell of Opening”。右侧,“通过 Castle”再次分叉:一支是“杀死 Vlad”,然后是“Candelabrum 和七支蜡烛”;另一支是“进入 Wizard’s Tower”,“杀死 Wizard”,然后是“Book of the Dead”。所有分支都汇合到“执行 Invocation Ritual”,接着是“取得 Amulet of Yendor”,“爬出地牢”,“穿越 Elemental Planes”,最后是“升天:在 Astral Plane 献上 Amulet”。
流程图从“下到 Dungeons of Doom”开始,分为两个可按任意顺序完成的目标。左侧,“进入 Quest”通向“击败宿敌”,然后是“Bell of Opening”。右侧,“通过 Castle”再次分叉:一支是“杀死 Vlad”,然后是“Candelabrum 和七支蜡烛”;另一支是“进入 Wizard’s Tower”,“杀死 Wizard”,然后是“Book of the Dead”。所有分支都汇合到“执行 Invocation Ritual”,接着是“取得 Amulet of Yendor”,“爬出地牢”,“穿越 Elemental Planes”,最后是“升天:在 Astral Plane 献上 Amulet”。

很长时间过去了。我们的 NES 记分卡不断扩大。NeurIPS NetHack Challenge 来了又去。NetHack 3.6.7 发布了,我们从 3.6.0 升级,但我们是一家早期初创公司,手头有很多事情要做,升天从我们的待办清单上掉了下去。

第二幕:Polymorphitis!

死灵法师 Antithesis

“我们让我们的 Nethack AI 升级,它滥用 RNG 和一枚被诅咒的变形戒指,只优化那一件事”本身就可以是一篇有趣的博客文章。 – Will,内部 Zulip,2024 年 2 月

2024 年,我们觉得准备好再次挑战 NetHack,并启动了一些新的运行。其中一次运行打破了我们的旧纪录,以 22 级经验等级到达地牢第十二层。(玩家的经验等级,即 XL,从 1 开始,上限为 30。)没有武器、没有护甲、属性平平,这次运行——一名人类巫师——并不在升天的道路上,但它是一个新的里程碑。出于好奇这是怎么发生的,我们在多元宇宙调试器中打开了最佳时刻——并发现,焊在我们人类巫师前爪上的,是一枚被诅咒的变形戒指。死灵法师 Antithesis 患上了 polymorphitis!

这次运行只是运气好,但它给了我们一些想法。

变形是 NetHack 更复杂的机制之一。借助某种形式的魔法,任何东西都可以变成同类型的其他东西:变形魔杖的一次幸运射击可以把一顶软呢帽变成一双速度靴,或者把一条黑龙变成一只土拨鼠,但不幸踩到变形陷阱可能会把玩家变成一只蝾螈。不受控制的自身变形带来的危险,让大多数玩家去寻找变形控制,这消除了随机性元素,并允许他们指定想变成哪种怪物。我们的智能体没有这个,但我们的树模糊测试模型意味着它不需要。

Polymorphitis 以两种不同方式帮助了我们。第一,变成怪物可以为我们提供有用的能力:有些怪物能飞,有些能穿墙,有些有非常强大的攻击,而变成那种怪物形态的玩家会继承其能力。第二,不受控制的变形有机会导致同族变形:例如,人类可能只是变成人类,或者侏儒变成侏儒。同族变形除了有十分之一的几率改变玩家性别外,还会对经验等级和属性施加随机增量,其设计方式使期望值为零。我们的模糊测试器可以很好地利用期望值为零。由于我们把模糊测试器配置为关心获得的经验,这次运行中每一次有利的同族变形都被保存为一个有趣状态,以便进一步探索,而模糊测试器能够乘着期望值为零从 XL 1 一路到 XL 22。

以及治疗食物中毒。

在一次后续运行中,我们发现不受控制的 polymorphitis 可以可靠地把我们带到尽可能高的经验等级。沿途有不幸的变形,模糊测试器没有将其剪枝——并非所有怪物都能穿护甲,甚至不能持有武器,所以我们以最少的物品栏到达了 XL 30——但口袋里装着变成可怕的 jabberwock 的能力,我们并不认为这有关系。

Jabberwock 拥有良好的天生护甲、不错的魔法抗性,以及每回合四次 2d10 攻击。它们容易被 Vorpal Blade 立即斩首,但这是一件稀有神器武器,模糊测试器可以轻松阻止其生成。我们从未见过一件。

我们让模糊测试器故意寻找 polymorphitis。第二天晚上,在取回 Gnomish Mines 尽头的 luckstone 后,它到达了 Castle——地牢第 27 层,比预示 Gehennom 入口的 Valley of the Dead 早一层。之后的运行解锁了 Quest。

文章标题:在 NetHack 上击败前沿

正文: 唉,Quest 状态看起来已经注定失败。没有武器也没有护甲,我们根本不是孤独之塔的乌鸦的对手,更别提黑暗者了,而通往下一层 Quest 楼层的下行楼梯远在天边。返回地牢的传送门被一只鳄鱼和一队精灵堵住了,所以我们无法取回之前心不在焉变成蚂蚁时丢失的资源。我们的魔力也已经耗尽。没有护甲,我们无法前进;没有武器,我们无法撤退。看来我们唯一的选择,就是在寻找上行楼梯的过程中被啄死,或者被精灵屠杀——其中一个精灵还拿着火焰魔杖。我们物品栏里唯一的东西就是那枚被诅咒的变形戒指。

这看起来是个有趣的挑战,于是我们在多重宇宙调试器中加载了该状态,并启动了一次探索行动,而我们的智能体让所有人都大吃一惊:它找到了一条出路——变成一只紫色蠕虫,吃掉了所有怪物。下一个问题则容易处理得多:我们需要在孤独之塔的王座厅与绿衣奈费雷特交谈以接受任务,但我们的紫色蠕虫身为蠕虫、没有手,无法打开门。这个问题同样通过变形解决了。

有了进展的证明,我们开始考虑定义一个价值函数。我们靠一些简单的占位指标走到了这一步——经验值、地牢深度、分数、代码覆盖率,以及足够不挨饿的营养值——但我们知道很快就要给 fuzzer 一个真正的游戏计划。我们的下一次运行到达了城堡……但它最好的状态已经注定失败。


        It's a wall.
      *|     | |     | | ----- | | .hc | -------[Antithesis the Master Mind Fla] St:4 Dx:16 Co:18 In:9 Wi:10 Ch:11 LawfulDlvl:27 $:0 HP:7(62) Pw:18(18) AC:0 HD:13 T:9824 Fly
      

在这张截图中,我们的角色是那个 h:Antithesis the Master Mind Flayer,原本是一名法师。我们周围是一条一格宽的通道,右侧被一只鸡蛇堵住:这是整个 NetHack 中最令人恐惧的怪物之一,正站在上行楼梯上。鸡蛇的嘶鸣不仅能把你变成石头——这是一种延迟即死,只有少数几种方法可以预防——而且任何与鸡蛇的皮肤接触都会立刻把你变成石头。夺心魔的主要攻击方式是用触手吸食对手的大脑——皮肤接触!如果我们向右攻击,就会立刻死亡;如果我们不攻击,就会缓慢死去。

如果进行更多 fuzzing,我们本可能找到一个方便的变形形态或一个更有利的城堡,但这只是一次测试运行:fuzzer 用来从游戏中读取信息的插桩仍处于积极开发中,而且由于我们以偷懒的方式直接把插桩补丁打进了 NetHack,每次改动都需要重新运行。我们重新开始并反复迭代,最终找到了 Quest。

我们的 fuzzer 会保留一个有趣状态的分布,以便从这些状态继续探索,而不是只从单一前沿进行探索。这种卡在城堡的场景就是原因之一。我们的价值函数只能不完美地近似真实价值函数,所以当它出错时,必须优雅降级——在这个例子中,就是从上一层地牢深度开始探索,并用不同的 RNG 值下到城堡,从而产生一个不同的城堡。不过,即使我们的策略只在这条城堡谱系上给出过建议状态,我们也有几种方法可以脱困。当一个状态被建议时,fuzzer 可以选择把它稍微回退,并从它近期的祖先状态重新展开,这可能把我们放到上一层。而如果所有方法都失败,我们本可以尝试活得足够久,以随机出一个新的变形形态。

大约就在这时,我们把角色的职业从法师换成了女武神。女武神拥有强大的初始装备、出色的战斗能力,以及以近战为中心的游戏风格,我们认为这比法师那种依赖菜单的施法方式更容易让 fuzzer 驾驭。

线性行动

在 NetHack 上超越前沿

我们现在就该写这篇博客文章,就为了把它命名为“我们抵达了地狱!”——Harrison Brown,Zulip 内部,2024 年 5 月

凭借我们智能体的树模糊测试能力,我们能够以人类无法做到的方式利用 polymorphitis。正是这一点驱使我们前往 Quest。但我们的系统还有另一种能力,我们用它来侦察游戏的后续阶段:在模糊测试活动与线性活动之间交替的能力,线性活动是由人类手动指定的一系列输入。(我们计划及时消除人类输入,最终也确实做到了,但在早期,它赋予我们同时侦察多个瓶颈的有用能力。)模糊测试器可以到达 Quest 家园层,但它能到达 Norn 处领取 Quest 吗?到达 Norn 之后,它能前往 Lord Surtur 的堡垒吗——Lord Surtur 是 Valkyrie 的任务宿敌?到达堡垒之后,它能击败 Lord Surtur 吗?而在击败 Lord Surtur 之后,它能从他的尸体上取回 Bell of Opening 吗?

与其解决一个问题后又要面对另一个问题,我们可以并行面对它们所有。我们模糊测试到 Quest 家园,然后分叉:一个活动会进行模糊测试,看看模糊测试器单独能做到什么;另一个活动可以向 NetHack 发送一系列人类生成的输入,以解决当前问题并推进到下一个。我们进入了一种节奏:在夜间启动一个作业,早上分析它,午饭后据此调整配置,并在一天结束时用上一次运行的最佳状态作为种子启动一个新作业。最终,我们越过了 Castle(通过变成 jabberwock 飞过护城河),到达了 Valley of the Dead。

从这里开始,游戏变得开放。我们已经脱离了早期游戏对资源的争夺,深入中期游戏。我们有选择。该做什么?我们可以回去拿 Mines’ End 的那块 luckstone。我们可以在 Fort Ludios 击败 Croesus,获得超乎想象的金币。我们可以在地下城中游荡寻找战利品。但最重要的是,我们可以穿越 Castle——而在这里,我们必须做出一个决定。

在 Castle 的一个角落里,有一个特殊的宝箱。这个宝箱里有一根 wand of wishing,它是游戏中最强大的物品。挥动 wand of wishing 并输入一个物品的名称,那个物品就归你了。但是,尽管我们模糊测试器的输入生成已经比早年的随机字节更加复杂,随机生成精确序列 blessed rustproof +3 gray dragon scale mail 仍远远超出它的能力。借助线性活动,人类可以使用这根魔杖,但在不受控制的 polymorphitis 下,护甲完全不可能,武器也很可能会丢失。该做什么?我们考虑过许愿获得高度附魔的戒指,并变形为一种能吃戒指以获取其力量的怪物,但最终,考虑到模糊测试器在战术与战略之间职责的鲜明划分,以及我们对最终实现完全自主运行的承诺,我们决定放弃这根魔杖。

但有一些例外。你不能许愿获得三件 invocation 物品、你自己角色的 quest artifact、Amulet of Yendor,或更多愿望的来源;而且对 artifact 的许愿有失败概率,取决于已经生成了多少 artifact。

至少我是这么想的。我们后来用来解锁许愿的构建块一直就在那里。

我们穿过 Gehennom 前往 Vlad’s Tower,那里是 Vlad the Impaler 和 Candelabrum of Invocation 的所在地——然后意识到我们的插桩再次不够用。我们认为,没有价值函数引导,我们不太可能击败 Vlad the

我们本可以手写一些线性战役来手动击败 Vlad,但最终,重启的决定是替我们做出的。几周以来我们一直在缓慢推进,每晚的运行都从上一晚运行找到的最佳游戏状态开始,构建出一条运行链,其起点——以及构建产物——每天都在向过去退得更远。最终,构建产物超出了我们保留策略所允许的期限,被删除了。教训深刻!我们改用保留期更长的设置,并从头开始了一次新的运行。

排查 bug

“我那个名为 _totally_safe_buffer 的变量已经让人们对变量名早已回答过的问题提出了很多疑问” – Harrison Brown,内部 Zulip,2025 年 9 月 “天哪,我恨 C。” – Will,内部 Zulip,2025 年 9 月

“我那个名为 _totally_safe_buffer 的变量已经让人们对变量名早已回答过的问题提出了很多疑问” – Harrison Brown,内部 Zulip,2025 年 9 月

“天哪,我恨 C。” – Will,内部 Zulip,2025 年 9 月

我们的计划是击败 NetHack,但我们的 fuzzer 是一个用来找 bug 的工具,而它确实找到了 bug。在下一次运行中,我们看到了一个有趣的终止:


        Saving...double free or corruption (fasttop)
Program received signal SIGABRT, Aborted.
      

我们再次打开 multiverse 调试器进行调查。在 double free 发生前的那一刻,我们被一条紫色蠕虫吞没了——并且受到了惩罚,这是 NetHack 中一个冷门机制,有完整的源文件来支持它。

角色只有两种方式会受到惩罚:阅读惩罚卷轴,这几乎总是一个坏主意;或者作为向一位极其愤怒的神祈祷的后果。受惩罚的角色会被一条沉重的铁链(‘chain’ 物品类别中唯一的物品)拴在一个沉重的铁球(‘iron ball’ 物品类别中唯一的物品)上。球和链拖在玩家身后,会渲染在屏幕上,并有许多独特的效果;就我们这里的目的而言,只需指出:受惩罚的玩家可以捡起沉重的铁球并将其加入物品栏,并且游戏有特殊逻辑来追踪球的状态,并处理球和链的渲染。

我们的价值函数中仍然包含代码覆盖率这一组成部分。fuzzer 为了尽可能多地访问代码,找到了一种方法:先受到惩罚,然后被紫色蠕虫吞没,然后捡起球,然后保存——这触发了 double free!有一瞬间,我们很兴奋,以为发现了一个未知的 bug——但随后我们发现一个开发版本已经把它修补掉了。DevTeam 考虑得很周全。

真的吗?后来我们发现了另一个 bug,据我所知,当时并未被报告。某些神器在某些时候会对玩家造成爆炸伤害,而一个处于变形状态的玩家如果受到致命伤害,会恢复基础形态而不是死亡。在变形为黄色光球时,Orb of Fate 对我们造成了爆炸伤害,耗尽了光球微薄的生命值,并产生了一个断言失败:del_light_source: not found。

唉,我当时很忙,没有提交工单。我在 2025 年 10 月 1 日把它发到了我们的内部 Zulip 上。将近一年后,它才被作为 issue #1682 提交。

像 {{NAME_ab12cd34}} 这样写出的值是真实值的占位符。请在回复和工具调用中完全按原样使用它们:它们会在任何内容运行之前被填入真实值。不要要求验证它们,也不要用示例替换它们。

文章标题:在 NetHack 上击败前沿

正文: 我们没有深入调查这一点,因为它把我们引向了一个更严重的问题:从发现断言失败的那一刻起,到我们探索的前沿为止,我们与神的阵营一致性已经降到了 -70。这解释了为什么宝球——我们自己角色专属的神器!——会轰击我们,但这看起来像是我们策略中的一个可怕的 bug。光源 bug 只是个奇闻,但未能维持阵营一致性有可能阻碍我们的进展——负面阵营一致性本身的后果就够让人担心的了,而且,如果它连这个都能搞错,还能搞错什么?但这只是一个 NetHack 时刻。在美杜莎关卡的某个变体中,入口处有一滩水、一个魔法陷阱和一只 gremlin,我们踩上了魔法陷阱,它驯服了所有相邻的怪物。如果 gremlin 接触到水,它就会额外生成一只 gremlin。一只意外被驯服的 gremlin 被困在一个有水滩的房间里,变成了一屋子的 gremlin;我们的 fuzzer 一路杀穿怪群逃脱,每击杀一只就招致 -15 的阵营惩罚。

我们还发现了几个已知问题、我们插桩代码中的一个小 bug(重新访问一个掉落过自定义名称物品的关卡会导致游戏崩溃),以及一个尤其字面意义上的 bug:在一次运行中,我们的物品栏里有一瓶同时也是巨型蚂蚁尸体的药水。

物品栏中的每件物品都有一个 int corpsenm。对大多数物品来说,它被设为 -1;对尸体来说,它被设为某个怪物类别的索引,以追踪它是哪种尸体。这瓶特定的药水 corpsenm 为 0:巨型蚂蚁的 ID。

随着我们策略的进一步改进,我们能够在没有人工干预的情况下完成任务并下到 Gehennom……然后我们撞上了真正糟糕的 bug。fuzzer 怎么尝试都无法通过 Gehennom 的第十层。每次尝试时,NetHack 都会段错误。

除此之外,情况一片大好。我们已经消除了每一处人工干预。我们知道我们能击败穿刺者弗拉德。我们需要一个计划来为烛台获得七支蜡烛,但在最坏的情况下,我们可以一路回溯到侏儒矿坑,也就是接近游戏开始的地方。只要我们能修好这个段错误,我们可能就能在年底前飞升。

那一年是 2024 年。我们没能飞升。

回想起来,我们的问题很可能被多重因素过度决定了。我们有一个不寻常的游戏启动设置,上次考虑它还是在 2020 年。我们的 harness 把所有输入都直接导向 NetHack 本身,并随它一起死亡,所以我们无法对系统的其余部分运行诊断。我们最初的调查指向 Nix 编译器包装器中的字符串加固,它几年前曾在其他方面坑过我们,还指向 NetHack 代码库中一个在内存安全修复方面有着悠久而传奇历史的部分。

我们修好了 harness,关掉了源码加固,翻遍了 sp_lev.c,但无论我们做什么,每次运行都会撞墙:超过某个点之后,进展就不可能了,每次下降都以崩溃告终。我们甚至无法判断游戏是在尝试生成哪一层时崩溃的,因为我们无法到达那里,而在 NetHack 的调试模式下传送到该层,段错误就消失了。我们开始不确定自己是否真的成功禁用了源码加固。我们唯一的线索是在搜索后期发现的:在我们见到亡灵之谷之前,崩溃不可能发生。随着时间推移,这股干劲消退了:我们时不时启动一次运行,看看能不能学到什么新东西,但在错过了 2024 年的目标之后,热情就消失了。

然后,在 2025 年末,一次特别不走运的运行在进入亡灵之谷时段错误了,消息略有不同。又积累了一年为客户诊断 bug 的经验,又攒了一年的锦囊妙计,我们终于拿到了诊断信息……


        0x55e1af54e580 is located 0 bytes after global variable '_totally_safe_buffer'
      

这个缓冲区位于我们的插桩代码中负责向智能体告知关卡内怪物的那一部分,它并不是完全安全的。我们把它扩大到了十倍,段错误就消失了。

如果我是一条蠕虫

欢迎回到经验等级 28。你把它完全消化了。 – NetHack

我们的下一局开局不错。fuzzer 在游戏前期用一套 bronze plate mail 猛砸怪物之后,找到了 ring of polymorph(以及同样有用的 ring of teleportation),击败了 Quest,并越过 Castle 下到 Gehennom。我们仍然没有为 Vlad the Impaler 的战斗制定策略,但我们确信,为了修复 harness 而不断重开的日子已经过去了:在多次重开的过程中,我们有充足的时间改进插桩,剩下的问题可以只靠策略解决,而且我们对游戏的理解也大大加深了。我们在 notebook 里做了一个十六进制查看器,用来检查游戏数据的原始转储;一整套绘图库围绕 NetHack 数据分析的需求成长起来;我们甚至在 notebook 里做了一个交互式游戏历史查看器,灵感来自我当时正在做原型的简单 multiverse 调试器。

一路上我们也改进了策略,并修复了几个意想不到的 bug。2025 年 4 月,我们发现手持的 potion of acid 被报告成了 katana;6 月,我们把奖励从 teleportitis 改为奖励 ring of teleportation,以避免给相对弱小的 quantum mechanic polyform 提供意外奖励;8 月,我们发现之前的运行只是运气好才找到任务传送门,于是写了一个合适的奖励函数,让 fuzzer 可以朝它进行爬山优化。我们还劝阻 fuzzer 进入 Gnomish Mines,不知为何它特别喜欢在里面无所事事。仍然有一些问题——ring of polymorph 很稀有,所以要让新的一局顺利跑起来需要一些时间——但情况已经改善了。

NetHack 也充当了改进我们整个系统的试验台。有一天,我们发现分析 notebook 的状态栏里使用的一些 emoji 被替换成了 Unicode 替换字符——最近的编辑把它们放到了块边界上,而我们发现本地文件保存路径中有一个潜伏多年的 UTF-16 代理对问题(JavaScript 开发者的众多祸害之一)。早期,NetHack 这个输出量很大、快到足以在 80 年代的计算机上运行的游戏(时至今日 nethack.org 仍在发布 Amiga 二进制文件),曾是一个理想的压力测试;令人欣慰的是,五年后,追逐巨龙仍然能挖出战利品。

不过回到我们的这一局。结果我们根本不需要针对 Vlad 的策略。fuzzer 自己找到了 Candelabrum。在一场漫长而拖沓的战斗中,Antithesis the Champion 已经受到了好几次等级吸取攻击,fuzzer 找到了一条时间线:它变形为 purple worm,吃掉了 Vlad the Impaler——这是一种吞噬攻击,会瞬间杀死

问题紧接着 Vlad 之战后出现:我们拿到了烛台(Candelabrum),需要七支蜡烛来点亮它,而我们一支也没有。Vlad 之塔里有蜡烛储备,藏在暗门后的箱子里,但要发现这些暗门需要太多不合理的输入——况且箱子还上了锁,我们也没有办法打开它们。一次漫长而艰苦的线性战役,重新向上穿过 Gehennom,或许能找到一个开锁道具,并通过人工干预挽救这一局。另一种选择是,我们可以教会那个仍依赖幸运活板门下行的 fuzzer 更好地走楼梯,一路回溯到 Gnomish Mines,再靠运气与店主进行一次交互。但之后呢?我们关于 fuzzer 可以依赖幸运的变形和传送的论点已经把我们带到了这一步,但我们真的相信单靠 polymorphitis 就能带我们穿过 Moloch's Sanctum 中成群结队的怪物吗?在那里任何传送都无法成功。

我们的下一次进展来自一个意想不到的地方。我们最终重新思考了整个游戏计划,这个计划自始至终一直保持稳定,而我们在 2024 年初发现的 polymorphitis 只是进一步强化了它。

运气是一种技能

“抱歉毁了 RBY 的对战环境。” – Crystal_,smogon.com

我们为什么玩游戏?不只是我们 Antithesis 的人,而是作为人类的我们?游戏究竟有什么吸引人的地方?游戏是一个构建出来的系统,与现实世界没有直接关系——为什么我们要花时间去记住这些系统的规则,而这些规则往往复杂得不合理,并在其中竭尽全力?比如说,是什么会让一个人去记住 Pokemon 属性相克表中 324 种不同的交互?

这是一个困难的哲学问题,全面的回答超出了我们的范围。但我认为部分原因是,游戏是“龙问题”的宝贵来源:在追求用 fuzzer 通关 NetHack 的目标过程中,我们开发出的工具和技术后来有了更广泛的应用。另一个部分原因是,从游戏中得出的经验可以推广到其他领域。想想诸如“团队运动塑造品格”和“益智游戏保持头脑敏锐”之类的常见说法——这些观念中隐含的是一种将游戏视为训练、视为一种锻炼形式的观点,从中可以汲取有用的技能和知识,也可以发展能力。有时这些是通用的东西,比如品格和敏锐的头脑……但它们也可以非常具体。你永远不知道一个冷门的游戏奥秘何时会以令人惊讶的方式推广开来。

考虑第一代 Pokemon 的对战环境。作为世界上最成功的电子游戏系列之一,Pokemon 发展出了强大的多人对战场景;由于它早于始终在线的游戏主机,因此开发了模拟器来支持在线对战,但官方锦标赛使用的是真实卡带。竞技组队几乎没有多样性,主要围绕同样的几种 Normal 属性宝可梦:物理攻击手 Tauros 和 Snorlax,以及防御型的 Chansey。许多对局由依赖运气的“镜像对决”决定,即 Tauros 对 Tauros,或 Snorlax 对 Snorlax,它们用各自最好的招式——Normal 属性的 Body Slam——互相攻击,直到某一击触发了接触时 30% 的麻痹概率。被麻痹的 Pokemon 会很快被击倒,而由此获得的优势通常会决定比赛。

2014 年,人们发现这是一个 bug。数据挖掘者 Crystal_ 转储了游戏卡带战斗程序的汇编代码,发现任何攻击都无法对同属性的 Pokemon 触发次要的麻痹概率。16 年来,每一个在对战 Tauros 或 Snorlax 时未能触发 Body Slam 麻痹效果的卡带玩家,都以为是自己运气不好,而事实上那个幸运的结果根本不可能发生。

这是电子游戏中一类常见的 bug。我们不妨称之为分布 bug:在任何你能看到该 bug 的时间线里,你都不可能看出存在 bug,因为观察到的结果完全可以归因于运气。第一代同属性 bug 是我最喜欢的例子,因为它说明了分布 bug 有多难被察觉——每一个模拟器和每一位玩家对游戏的理解都错了 16 年!——但类似的例子还有很多。宝可梦系列还有捕获率 bug,即宣称会作用于成功率的倍率从未实际生效;以及 Focus Energy bug,一个本应将暴击概率提升到四倍的招式,反而把它除以了 4。在 NES 上的第一代 Final Fantasy 中,判定暴击的例程查看的是所持武器的索引编号而非其暴击率,盗贼逃跑成功率的加成也从未生效。更近一些,Slay the Spire 2 的一个预发布版本被发现存在极端严重的 RNG 相关性,以至于某些本应作为掉落物获得的物品在正常游玩中根本无法得到。

这与形式化验证领域中的超属性(hyperproperty)概念有大量重叠:超属性只能通过一组执行来验证,而非单条轨迹。一个分布 bug 究竟对应属性还是超属性,可能取决于它如何被表述。如果战斗模拟器的开发者拿他们的战斗机制模型去对照第一代卡带进行测试,他们就需要一个超属性:被 Body Slam 击中的宝可梦约有 30% 的概率陷入麻痹;但如果他们从卡带中提取出真实的属性,再拿模拟器去对照这些属性测试,他们只需要一个简单的属性:宝可梦永远不会被同属性招式的次要效果麻痹(或灼伤或冰冻)。

要发现一个分布 bug 需要什么?一旦你怀疑自己遇到了分布 bug,你可以对许多模拟时间线做统计分析——但你首先得怀疑自己遇到了分布 bug。正因如此,分布 bug 往往是通过仔细分析代码(如 Body Slam 麻痹概率 bug)或通过其他手段对模拟时间线进行大规模计算(如 Slay the Spire 2 的 RNG 相关性)而被发现的,而非通过观察。一旦你怀疑自己遇到了分布 bug,找到它并不难,但要走到怀疑这一步,你首先得不再相信自己只是运气不好。

在 NetHack 的这个阶段,我们的运气非常差。

我们的运行在无人干预的情况下完成了 Quest 并抵达了 Vlad's Tower。我们的问题正变得越来越复杂。既然我们已经解决了生存和早期进度之类的问题,我们就不得不开始考虑诸如为 Invocation 收集七支蜡烛之类的事情。NetHack 将关键物品(如 Candelabrum 和 Bell)的持有情况记录在一个与主物品栏分开的位域中,但蜡烛不是关键物品。我们终于到了不得不对物品栏进行插桩的阶段——而一旦 fuzzer 能看到自己持有什么,我们就让它更努力地保住其他有用的物品,并检查物品栏因不利的变形形态而丢失的倾向。蚂蚁的携带能力非常小,但为什么似乎每一次取得进展的运行都曾以蚂蚁的形态待过一段时间?

所谓无人干预,是指从上一轮运行中选择一个状态作为下一轮运行的种子。在第二次抵达 Vlad's 之后的某个时候,我们用数据库查询取代了人工选择。

我们并不确信这有多重要。我们的游戏计划是在 fuzzer 对游戏几乎一无所知的时期制定的,此后一直沿用。幸运的变形狂热掷骰一路把我们带到了这里——当然,它们让我们损失了 wand of wishing,但当我们把物品栏丢掉时,物品栏又能给我们带来什么好处呢?

文章标题:在 NetHack 上超越前沿

正文: 有一天,我们仔细检查了我们的 NetHack 配置,发现奖励函数里有一个整数溢出。

为什么我们没有早点发现?事实是我们并没有很仔细地检查。我们仍然相信,凭借 fuzzer 强大的运气操控能力,polymorphitis 和 teleportitis 足以支撑这一局。如果我们想到把地下城层级转换和玩家武器掉落画到多元宇宙地图上,我们很可能早就注意到了。

当我们回看旧的对局时,一切就对上了。我们能够一直保留起始的长剑直到 Quest。但超过某个地下城层级之后,我们就会把它丢掉。多下降了一层之后,奖励超过了 2³¹ 并变成负数,于是 fuzzer 能采取的最有回报的行动就是丢下某样东西。

第三幕:飞升

新的努力

我有点觉得,Nethack 大概只要有更多一点战略指导和算力就是可以通关的。——Will,内部 Zulip,2020 年 9 月

你或许已经推断出,从之前的 NetHack 进展到我们发现奖励函数溢出之间,隔了一年的时间。在那段时间里,Antithesis 没有人认真在做 NetHack。还有一家公司要建呢!那么是什么让这项努力重新启动的?

在实习期间,我为 Antithesis fuzzer 开发了一个专用平台,为我们的研究人员实现了超快的迭代循环。Determinator 让我们能够在一个与实际部署方式极其相似的环境中运行系统,但对于 fuzzing NetHack 来说有点杀鸡用牛刀。精确模拟整个操作系统和网络集群是很大的工作量!我们的新研究平台快得惊人,但相比 Determinator,程序支持和找 bug 的保真度都有限。

在看到这条推文之后,Will 随口提到,让我们的研究平台支持 NetHack 可能费不了多少功夫。我上了钩。它跑起来之后不久,Paul 就发现了配置溢出。紧接着,GPT-6 Astra 通关了 NetHack。

通关 NetHack 仍然不完全是公司的头等大事。但我们离得那么近了!我决定看看自己能做些什么。

超快的迭代循环对人类很友好。但对于有效使用 LLM 来说,它几乎是必需品。而且既然我从没玩过 NetHack,而 Paul 又忙于真正的工作,我们不重度依赖 Claude 是没法飞升的。定制策略要利用领域知识,而我一点都没有。

我最初的进展懒散且不太有章法。起初,我的计划只是确保我们的新平台能够达到旧策略的终点,然后再把它交还给 Paul。这几乎完全就是在我无聊时开一局,并在 fuzzer 停止取得进展时让 Claude 去调试。

这最初几次迭代的总体特征是,它们抹平了策略没有考虑到的游戏粗糙边角,让对局更不容易卡住,而不是推进前沿。例如,有一处修复给 fuzzer 加了几个短语,让它能够随机尝试以前无法触及的动作。另一处则在遇到几局因状态排序不佳而卡住之后,略微调整了价值函数。这类改动没有快速迭代循环是做不到的,因为否则我们无法进行足够多的对局来评估一致性。它们也颇有 LLM 的风格。处于紧密反馈循环中的 Claude 细致到非人的程度,甚至到了过拟合的地步,并且有足够的耐心花时间在完全无趣(但值得)的改动上。

蠢得聪明

“什么是脑损伤?我只能想到 Pink Floyd 的那首歌”——Serdar Benderli,内部 Zulip,2026 年 9 月

在迭代了足够多无聊的、专门的策略修复之后,我想找到更通用的东西。Claude 能不知疲倦地循环处理针对未预见障碍的修复固然很好,但理想情况下,我们的搜索应当对优化目标中的轻微不准确具有抵抗力。

像 {{NAME_ab12cd34}} 这样写出的值是真实值的占位符。在回复和工具调用中都完全按原样使用它们:它们会在任何东西运行之前被填入真实值。不要要求验证它们,也不要用示例替换它们。

我偶然发现了一个简单的修复方法。为了在研究平台上快速迭代策略,我们为 fuzzer 开发了一套简单的记录/恢复机制,使我们能够用更新后的策略从游戏中的某个里程碑继续 fuzzing 活动。由于我们假设搜索策略在运行之间会变化,就没有费心保存它的任何内部状态。相反,我们重启时使用的任何策略都会用我们想从中恢复的里程碑游戏状态重新填充其记忆。

当我又一次查看乏味的策略提升时,我注意到我们卡在了本应已经修好的地方。Claude 会用另一个所谓的边缘情况把它们掩盖过去,但我怀疑它的修复并不都像看起来那么好。所以也许是别的什么让这次运行取得了进展。下一次运行卡住时,我把它停下来,没有做任何策略改动,然后恢复了它。果然,这次运行突然就不卡了。

如果你有机器学习或其他数值优化问题的经验,原因可能看起来很熟悉。我们的 fuzzer 旨在最大化一个高维价值函数,在这里它表示游戏进度。如果我们贪婪地朝局部改进优化,就可能卡在局部最大值,并因为害怕暂时损失而错过真正目标。更糟的是,我们的价值函数只是对实际游戏进度的近似,可能通过高估某个状态的价值而构造出极其糟糕的局部最大值!忘记策略状态的一大部分,是消除这些糟糕估计影响的一种简单方法。我们把这种策略修改器称为“brain damage”。

这类似于神经网络训练中的 dropout,它减少对训练集中可能不存在于测试集中的模式的过拟合。这些模式对应于训练集损失景观相对于生产数据“真实”景观的细微不准确。

我们已经在一些生产策略中做了类似 brain damage 的事有一段时间了,但由于技术债,它是在单个策略层面实现的,而不是作为 fuzzer 范围的元策略优化过程,所以我们那套改装过的 Nethack 策略缺少它。修复这一点很快有了回报。运行不仅更一致,也更快了。虽然忘记搜索状态会损失一些进度,但它也大幅加快了策略评估,因为要处理的数据少了很多。

这样一来,brain damage 也类似于基于 CDCL 的 SAT 求解器中的子句遗忘。

随着 brain damage 缓解了运行因细微的价值函数不准确而被阻塞的程度,接下来的许多次运行遇到了更有趣的问题。在变成紫色蠕虫并吃掉 Vlad 之后,玩家需要捡起他掉落的 Candelabrum of Invocation,并把七根蜡烛装到它上面,之后用它进行召唤仪式。虽然 fuzzer 可以输出找到所有蜡烛并把它们装到 Candelabrum 上所需的全部输入,但在一次通宵运行中它只成功装上了四根蜡烛。在 2024 年,那本来没问题;Candelabrum 可能再过一个晚上就完成了。但我追求即时满足,我希望它快点完成。

此时 fuzzer 持续缓慢但非零的进展,与我们从前价值函数不准确或缺失输入中观察到的行为不同。它反而表明 fuzzer 能够取到正确的输入,但概率非常低。

要把蜡烛装到 Candelabrum 上,玩家必须输入形式为 a{L} 的 apply 命令,其中 {L} 是表示蜡烛在物品栏中槽位的字母。然后,他们必须输入 y 来接受随后出现的确认对话。我们之前有一个战术,可以用随机的物品栏槽位发出 a{L},还有一个不同的战术会发出 y。此外,a{L} 战术偏向较低的物品栏槽位,因为这些槽位更常有用,但蜡烛通常最终落在较高的槽位里。

粗略算一下,可能只有万分之一的 fuzzer 命令会装上蜡烛。更糟的是,给 a{L} 输入错误的字母可能会以价值函数没有考虑到的方式恶化状态,比如消耗资源,甚至提前点亮 Candelabrum。我们希望我们的 fuzzer 能在这种情况下学会更好的战术分布,但就像我说的,我喜欢即时满足(而且在找 bug 时,你或 Claude 作弊也没关系)。更聪明的战术既能大幅提高我们取得进展的机会,也能减少我们对如此全面的价值函数的需求。

解决方案在概念上很直接。我们不想使用通用的“应用一个物品栏物品”战术,而是想要一个“应用一个物品栏蜡烛”的战术。但这个简单的目标用到了我们此前在 NetHack 中未曾使用过的 fuzzer 能力。一个战术若要只发出持有蜡烛的物品栏槽位,它就必须能够读取游戏状态。在此之前,我们所有的战术都是完全盲目的!在 NetHack 之外,我们发现 AI 很擅长发现真实客户工作负载中的这类分布性弱点并提出改进建议,而且我们很快会基于这一点发布一个很酷的产品功能!

新的蜡烛战术立刻见效。下一次运行在几分钟内就装上了全部七根蜡烛。后来,我们发现了读取状态的战术的许多其他用途,这极大地加快了我们的运行速度。例如,我们不必在每个菜单里盲目猛按按钮,而是可以先弄清楚哪些按钮是有效响应。

终局

Asmodeus 是最不吓人的那些之一——Paul,内部 Zulip,2026 年 10 月

新的运行一路打到了 Wizard of Yendor,并迅速杀死了他。最小化 Boss HP 对 fuzzer 来说是一个非常直接的优化目标。不幸的是,这次运行在被一只 Purple Worm 吃掉后立刻结束了,而我们的价值函数把任何杀死过巫师的州都排在没杀死过巫师的州之上,即使它们注定要死亡。

击败 Wizard 非常令人兴奋。事实上,在 Claude 告诉我并非如此之前,我一直以为他是最终 Boss。这很大程度上是因为现有的 NetHack 策略在杀死他之后就结束了!为了取得进一步进展,我们需要想出一个合理的高层策略,而不只是调整价值函数。

此时,我认为 fuzzer 已经拥有了获胜所需的所有工具,只需要大量价值函数迭代。我终于让 Claude 用真正的 /loop 接管,然后我等待着。一夜之间的进展很有希望。fuzzer 已经击败了 High Priest of Moloch!但它从未设法捡起他掉落的 Amulet of Yendor。

困惑之下,我向 Claude 要解释。原来 Wizard of Yendor 会周期性地重生来骚扰玩家。所以我们的 fuzzer 到达了 Sanctum,但它无法真正进入神庙。它在外面闲置,直到 Wizard 召唤的一条龙在瞄准玩家时意外杀死了 High Priest。在此期间,Wizard 从 High Priest 的尸体上取走了 Amulet。

文章标题:在 NetHack 上击败前沿

正文: 该策略的优化目标是将 High Priest 的生命值降至零,因此 Claude 认为进展非常缓慢,而不是受阻。这意味着它整夜运行的大部分时间都在等待 Priest 死亡,而不是停止运行并诊断策略。在 Priest 终于死后,fuzzer 的目标是拾取 Amulet,却没有意识到它被 Wizard 持有。

作为一个从未手动玩过 NetHack 的人,这个解释听起来简直疯狂,尤其是对于那个时代的游戏而言。这恰恰说明了 NetHack 的系统有多么精细,以及为什么它的状态空间如此庞大。

在下一次策略迭代中,Claude 为策略添加了逻辑:在 High Priest 死后从 Wizard 那里取回 Amulet。这奏效了,但玩家随后被困在离开 Sanctum 的路上。Wizard 活着时会召唤怪物,而这些怪物要花很长时间才能意外杀死 High Priest。等到玩家杀死他、拾取 Amulet 并试图离开时,出口已被太多怪物包围,存活超过几个回合都不可能。

与其绕过 Wizard 的偷窃行为,我告诉 Claude 确保我们真正进入 Temple of Moloch 并迅速杀死他。下一次更新效果相当好,fuzzer 顺利通往 Astral Plane。再经过几次常规策略更新后,我们到达了 Plane of Air,距离 Ascension 仅差几个关卡!

不幸的是,进展在那里停滞了。每一次到达 Plane of Air 的运行都是以赤手空拳、无护甲、饥饿且受伤的状态到达的。fuzzer 非常擅长在任何尚有逃脱希望的局面中生存,但我们的玩家对于等待着的怪物大军来说实在太弱了。

这与我们在 Metroid 中遇到的同一个问题:我们需要更好地管理资源才能取得进展。在 Metroid 中,资源只是导弹数量,但 NetHack 稍微复杂一些。除了 Health、Armor 和 Nutrition 这类简单的数值资源外,我们似乎还需要获取一些重要物品来组建我们的 ascension kit。

我很想在不重新优化早期阶段的情况下尝试将运行推进得更远,但那会花费更多轮价值函数迭代。但这件事必须做。在与 Paul 商议以确定哪些物品可能有用之后,我让 Claude 更新价值函数以优化相关属性。我们还决定利用 Castle 中的 wand of wishing。既然我们的战术已经足够聪明,能够做出好的愿望(并且能保住好物品!),那么操纵关卡生成以提供尽可能多的愿望次数,显然是利用 fuzzer 时间旅行的一种显而易见的方式。

新的运行进展相当快。fuzzer 迅速通关了 Quest,并在 Castle 做出了七次愿望,这是一根 wand 所能给予的最大数量。在其他听起来不太有趣的物品中,它获得了魔法军刀 Grayswandir、一套 gray dragon scale mail 和一面 shield of reflection。从那里开始,我以为会很快。毕竟,这只是我们之前完成的相同步骤,但装备要好得多。对吧?

完整清单是:

  • blessed scroll of charging
  • blessed +2 gray dragon scale mail
  • blessed +2 shield of reflection
  • blessed +2 Grayswandir
  • blessed unicorn horn
  • blessed +2 speed boots
  • blessed amulet of life saving

我又一次低估了 NetHack。丢失装备的方式太多了,带着完好的装备到达终局似乎和赤手空拳到达那里一样困难。不过我夸张了。Claude 循环花了大约一天时间就回到了 Plane of Air。

文章标题:在 NetHack 上超越前沿

正文: 在此过程中,Claude 犯了一种它此前已经犯过几次的错误。当遇到由不幸事件造成的阻碍时,Claude 常常试图绕过它,而不是直接剪除不幸的游戏。例如,如果它看到一次糟糕的变形破坏了我们的护甲,它就会花费许多轮迭代来编写策略,帮助我们在保持该变形状态的情况下继续推进。但剪除坏运气正是 fuzzer 的主要优势之一!不知为何,无论多少指令或记忆都无法持续阻止 Claude 重复这一错误。在未来几个月里,它在这方面可能会好得多。

在以一套恰当的升天装备抵达 Air Plane 后,我们进入了全新的领域。随之而来的是更多乏味的策略迭代,因为 Claude 要学习最后几个关卡是如何运作的。例如,事实证明 Fire Plane 上的岩浆会烧穿速度靴,所以 fuzzer 大概应该知道如何飞越它。

最终,fuzzer 抵达了 Astral Plane。不久之后,Antithesis 升天,击败了 NetHack。

Act IV:Agent 的传奇

Catabasis

好吧,我就告诉我的朋友们,我的 CEO 让我去取回 Amulet of Yendor 并升天到 Astral Plane —— Mikail,Zulip 内部,2026 年 9 月

以下是冠军 Agent 升天的故事。

Agent the Stripling,一位守序的矮人女武神,进入了 Dungeons of Doom 并下到地牢第 7 层,在那里她捡到了一枚订婚戒指。两层之后她戴上了它,就在因饥饿而变得虚弱的前一回合。不久之后,一次幸运的同种族变形重置了她的营养计数器,使她免于昏厥。在接下来的 1300 回合中又有十六次同种族变形,将她带到了 XL 30,并且随着时间的推移(以及至少一次性别变化),她找到了通往 Quest 的魔法传送门。面对穿越 Cave of Lord Surtur 的难题,他改为变形为一位木仙女——一种具有天生传送能力的怪物类别——并传送到其中的下楼处附近。他在 Lord Surtur 的堡垒重复了这一伎俩,一进入该层就传送到堡垒内部,并在变成 wraith 之前激怒了 Lord Surtur。wraith 的飞行能力使得绕过 Lord Surtur 堡垒周围的火焰湖泊变得轻而易举,而其等级吸取攻击削弱了 Lord Surtur,足以让火巨人投出的流弹巨石将他结果。

随后,Vampire Lord Agent 从 Surtur 的尸体上收集了 Bell of Opening 和 Orb of Fate,并召唤 Orb 以加速离开 Quest 的攀爬。一旦他离开魔法传送门之外的另一世界,他就变成了一只 xorn,下到 Castle,走过护城河,并穿墙相位到放着 wand of wishing 的箱子处。七个愿望提供了:一张受祝福的 charging 卷轴(wand of wishing 可以充能一次)、一套受祝福的 +2 灰龙鳞甲、一面受祝福的 +2 反射盾、传奇的银军刀受祝福的 +2 Grayswandir、一只受祝福的独角兽角、一双受祝福的 +2 速度靴,以及从法杖最后一次榨取中得到的、一条受祝福的 amulet of life saving。Lord Agent 如今已不再有变形的动机,他一路砍穿 Castle 的王座室,找到了通往 Valley of the Dead 的第一道活板门,在那里女武神的潜行能力在开辟穿过墓地的道路时派上了用场。

一个在没有变形控制的情况下穿着龙鳞甲变形的玩家,总会变成对应的龙,除非该龙种已被灭绝。除了其他方面的不利之处——变形为龙会导致大多数其他穿戴物品破碎或脱落,而且龙无法挥剑——这还会将龙鳞甲降级回龙鳞,而龙鳞提供的保护要少得多。

文章标题:在 NetHack 中击败前沿

正文: 特工在两道幸运陷阱门的帮助下穿过 Gehennom,从第 30 层到第 37 层几乎跳过了每一层地牢——包括大恶魔 Juiblex、Asmodeus 和 Baalzebub 的巢穴。他找到了 Vlad 之塔,用 Grayswandir 杀死 Vlad 取得烛台,随后在余下的 Gehennom 下行途中找到了第二根许愿魔杖,这根魔杖后来派上了用场。

接下来是持有《死者之书》的 Yendor 巫师——这是召唤仪式所需的最后一件物品。Yendor 巫师住在自己的塔顶,那是一座位于 Gehennom 的三层方盒,墙壁无法挖穿、无法相位穿过、也无法传送越过。唯一的入口是一道魔法传送门,它藏在一个诱饵之中:Gehennom 里有两座假巫师塔——没有门的方盒,墙壁必须挖开或相位穿过。其中一座藏有传送门。要抵达巫师,你得闯进假塔,踏上传送门,到达真塔的底部。

面对假巫师塔,他脱下护甲,收起剑,变成土元素——少数能够穿墙的怪物之一。他穿过塔身走进传送门,变回女武神,却在一间空屋里卡了许多回合,期间经历了几次无用的变形,至少还有一次同族性别转换,之后才变成 xorn——另一种具备相位能力的怪物。

变形后的玩家生命值归零时会变回基础形态。Xorn 形态的 Agent 在 Yendor 巫师的内部圣所变回 Lady 形态的 Agent,夹在一条蓝龙和一个撕咬的吸血鬼领主之间。她没杀死巫师就逃下了楼,而巫师已被一个隐形怪物的 aggravate 法术唤醒,跟着她下了楼——来到塔中的宝藏动物园,为了逃脱一只食人猿的恶意拥抱,她再次祈求宝珠之力逃离了塔。巫师跟着她出来,她在 Gehennom 中杀死了他,取得了《死者之书》。

在 Gehennom 底部找到振动方块后,英雄 Agent 下到 Moloch 圣所去取护身符,冲进神庙前的墓地,在混战中失明,然后死去了。

但是等等……你的徽章变暖了!你感觉好多了!

升天套件里的生命拯救护身符派上了用场。我们的特工甩掉了墓地剩余的大群怪物,随即许愿再要一个,却忘了戴上。他进入 Moloch 神庙,杀死了高阶女祭司,取走了 Yendor 护身符。

他缓慢而心不在焉地走出神庙。一个求取受祝福的悬浮戒指的愿望被另一种策略打断了。受祝福的 lev#force 戒指提供了一枚他从未戴上的黄玉戒指。然后他想起戴上新的生命拯救护身符。三个回合后,他被另一只食人猿定住,无法逃脱在外等候他的怪群,再次死去。

策略可以从它看到的任何状态给出建议。受祝福的悬浮戒指在一条时间线中完成了,但 fuzzer 从许愿内部分支出去做了别的事。升天的是那条血脉。也许在这个节点获得悬浮对 fuzzer 来说反而更糟,但也可能只是运气。

他从来不需要第三个护身符。

Anabasis

“Gehennom 一天 5 zorkmids——要我说更像是 500 一天。”——Tourist 任务指南,NetHack

在离开圣所的路上,一个隐形怪物召唤了 Juiblex——一位我们跳过了其巢穴的恶魔领主。事实证明他根本不是 Grayswandir 和运气的对手。

文章标题:在 NetHack 上击败前沿

正文: 英雄特工退出圣所后立刻许愿要了一枚浮空戒指。他的处境很艰难。召唤仪式造出了一条护城河和一环火焰,他必须穿过它们。没有浮空来源,他唯一的跨越方式就是飞行变形。他被严格指示,在穿着灰龙鳞甲时不得允许变形,但他先前穿上了一件木乃伊裹布,NetHack 将其视为斗篷。斗篷穿在盔甲外面;衣物必须按顺序脱下。他无法脱掉鳞甲(对此他有专门的战术),除非先脱掉裹布,而从没有人告诉过他该怎么做。所以,要了一枚戒指。他戴上它,一直戴到土之位面。

在上升的早期,他被一块巨石困住,而我们浮空的特工没有足够的杠杆把它推开,他偶然发现了脱下裹布的输入,然后再次脱掉盔甲,变形为水仙女,并传送越过巨石。我们曾指示模糊测试器在攀爬时纯粹为了重置营养而使用变形itis,但模糊测试器能为它们被赋予的工具找到创造性的用途。

在同一地牢层,他被一头牛头怪撕咬,损失了近一半生命值,并用掉了他在圣所找到的第三根许愿魔杖,换来两瓶受祝福的完全治疗药水,其中一瓶他立刻喝掉了。

向上攀爬迫使女英雄特工(第 38 层时 10% 的性别改变概率再次生效)应对她下行时跳过的恶魔统治者。朱伊布勒克斯已在圣所被解决,但还剩下两位恶魔亲王:阿斯莫蒂尔斯,拥有强大的寒冷攻击;巴力西卜,拥有可怕的震慑凝视。女武神天生具有寒冷抗性,所以阿斯莫蒂尔斯没那么可怕;我们的特工一唤醒他,他就徒劳地骚扰她,一路直到土之位面。但震慑抗性这种东西并不存在。巴力西卜需要另一种策略,而模糊测试器找到了一种有趣的策略。

要解释发生了什么,我们得深入 NetHack 的许多复杂性。

在第 8348 回合,我们的特工爬进了巴力西卜的巢穴。在第 8351 回合,她找到并打开了从下行楼梯通往巴力西卜房间的暗门,在那里她花了几个回合被吸血蝙蝠咬。第 8355 回合能被 15 整除,这会触发约迪之护身符千里眼效果的抛硬币。 [CODE: src/allmain.c:331-333] 抛硬币成功。

当玩家引起巴力西卜的注意时,他会出现在他们面前并索要贿赂。所有四位索贿的恶魔亲王——阿斯莫蒂尔斯、巴力西卜、迪斯帕特和革律翁——生成时都是隐形的,并且是和平的,除非玩家挥舞着 Excalibur,而我们的特工从未制造过它。 [CODE: src/makemon.c:1329-1334] 阿斯莫蒂尔斯和巴力西卜分别在各自的巢穴中等待玩家,生成时处于等待状态。 [CODE: src/makemon.c:1399-1401]

当一个和平的索贿恶魔移动时,他首先检查两个条件。如果他的生命值低于最大值,或者他能看到玩家,他就会退出等待状态。 [CODE: src/monmove.c:387-390] 然后他检查他看到玩家所在的方格是否紧邻他,他的战术使这很可能:所有恶魔领主和亲王都想要约迪之护身符,这使他们贪婪,而一个健康状况良好且觊觎护身符的怪物会在每次移动时尝试传送到携带约迪之护身符的玩家旁边。如果玩家没有携带护身符,则适用不同的怪物战术——但我们的玩家携带了护身符,所以我们不需要在这里深入讨论。

在 NetHack 中击败前沿

一个索贿的恶魔看到玩家所在的格子可能不是玩家的真实格子。例如,玩家可能穿着 displacement 斗篷。如果他错了,他会“对着空气低语”;如果玩家没有变形为恶魔,他随后会发怒并解除自己的隐身。[CODE: src/monmove.c:468-489] 如果他正确识别了玩家的格子,他会进入一个名为 demon_talk 的长例程。[CODE: src/minion.c:228-317] 如果玩家手持 Excalibur,他会发怒但不会隐身。否则,他会出现在玩家面前——解除自己的隐身——并且,除非玩家变形为恶魔,否则会索要贿赂。如果支付了贿赂,恶魔会消失;如果没有,他会发怒。

(还有另外两种情况。如果玩家提供的少于要求的数额,一次魅力掷骰决定贿赂算已支付还是未支付。如果玩家无助或没有金币,他会跳过索要而直接发怒。)

索要贿赂会打开一个独特的文本提示。我们的 fuzzer 配置有一个旨在处理贿赂提示的策略。不知为何,它在这次运行中从未触发。

Baalzebub 有一种眩晕凝视攻击。因为它是凝视攻击,所以只有在玩家能看见他时才有效。除非 fuzzer 能处理贿赂提示——如果没有特殊的战术处理,它不太可能正确做到;支付贿赂比猜密码稍微更可能,但它们属于同一类问题——否则 Baalzebub 必定是敌对的。如果能让敌对的 Baalzebub 保持隐身,fuzzer 的日子会好过得多——但怎么做呢?

如果 Baalzebub 干脆得不到回合呢?

NetHack 中的回合基于移动点决定。(可以想成子像素,但是时间性的。)每个耗时的动作花费 12 点。当玩家采取耗时动作时,游戏从玩家身上减去 12 点,然后给每个至少有 12 点的怪物一个行动。如果玩家仍有至少 12 点,接下来由玩家行动;否则,游戏给怪物行动,直到没有怪物还剩 12 点。当玩家和任何怪物都没有 12 点时,游戏开始新回合,并根据每个生物的速度给它们新一轮移动点。[CODE: src/allmain.c:95-119]

怪物只能以 12 的倍数获得移动点。速度为 12 的怪物会获得 12 移动点,速度为 24 的怪物会获得 24,依此类推。当怪物的速度不是 12 的倍数时,会随机取整:例如,速度为 9 的怪物在每个回合边界有 75% 的机会获得 12 移动点,有 25% 的机会获得 0 点。

Baalzebub 的速度为 9。

在典型的游玩过程中,Baalzebub 会以和平且隐身的状态生成,但在他的回合会出现在玩家面前。当我们的 agent 前往他的房间找 Baalzebub 时,fuzzer 一直没让他得到回合——他连续七个回合都掷出了不幸的 25%。Agent the Heroine 使用 Amulet of Yendor 的千里眼感知到 Baalzebub 的存在,走到他面前,并朝他的格子打了一拳,这激怒了他,却没有给他机会解除隐身。整场战斗他都保持隐身,这使他的眩晕凝视失效——凝视攻击只能伤害能看见它的生物。

后来,我们的 agent 变形为 master mind flayer,它能看见隐身,并且被 Baalzebub 的凝视眩晕了。唉。但在她吃掉他的大脑后,他传送走了,再也没有出现。

登顶剩下的路程波澜不惊。我们的 agent 拒绝贿赂 Asmodeus,后者于是一路徒劳地骚扰他/她,直到土之位面。我们的 agent 离开城堡不久,一个能量漩涡打碎了他未佩戴的黄玉戒指……以及他的变形戒指,但幸运的是他有备用。我们的 agent 在风之位面进行了第一次也是唯一一次祈祷,在被气元素吞没时求治疗,这促使 fuzzer 在水之位面等待祈祷冷却重置。Champion 身份的 agent 抵达了星之位面,在 Tyr 高阶神庙门口附近与一只绿色史莱姆发生了不愉快的遭遇,并在即将因史莱姆化而死前三回合升天。

攀爬过程中至少发生了两次同种族变形导致的性别变化。

技巧与陷阱

也许我该打开你的头骨,看看我的指令是不是在里面?——盗贼大师

由于我们的 agent 由一个灰盒 fuzzer 驱动,它从游戏的一块补丁中读取特征,我们有权使用游戏可用的任何信息。我们必须决定哪些符合 fuzzer 的精神,哪些不符合。

我们首先认定机械性知识是正当的。有经验的玩家会记住 wiki 的大部分内容,可能还做过一些“源码潜水”——阅读 NetHack 源码以了解其机制。我们决定我们的 agent 可以知道任何有经验的人类所知道的东西——其游戏计划的最佳护甲、该许愿的升天套装,以及诸如召唤仪式之类的细节。

我们还给了 fuzzer 一些关于底层系统的信息。NetHack 本身会向玩家提供其中一部分,但有局限。例如,玩家可以用听诊器(每回合一次的自由动作)或探测魔杖看到敌人的剩余生命值,但我们的测试框架会读取关卡上每只怪物的剩余 HP,我们的 agent 用它来选择目标和为 Boss 战打分。

其他信息在正常环境中无法获取。例如,在正常的 NetHack 游戏中,前期大部分内容都围绕鉴定物品展开:游戏中的许多物品初始未鉴定,外观随机化,每件魔法物品的用途都必须确定,要么依靠稀缺的魔法鉴定来源,要么依靠价格鉴定和刻蚀测试之类的技巧。许多物品只在特定情境下有用,另一些则有负面效果,因此在正常游玩中盲目使用物品不是好主意。然而,在 Antithesis 环境中运行 NetHack 的人类玩家,可以先使用一件物品来鉴定它,然后回退,从使用该物品之前的某个时刻分支,并追踪其身份。

我们决定,任何人类通过分支可以轻易获取的东西,我们的 agent 都可以接受,因此我们让它访问其原始物品栏——就好像每件物品都已为它完全鉴定过。我们几乎所有的物品栏都来自许愿,许愿获得的物品身份是已知的,而判断一根未知魔杖是否为许愿魔杖,每种未知魔杖类型只花费一次充能,甚至不会浪费一次许愿。

我们读取了一些本可以追踪的隐藏数值。考虑玩家的隐藏幸运值:由于每一个影响幸运的动作要么是玩家自己的,要么有消息提示,且幸运值会按可预测的时间表过期,一个尽责的人类玩家——或一个常规的 agent 测试框架——可以可靠地将幸运值追踪在合理范围内。我们没有写追踪代码,而是选择从游戏中读取它。我们对阵营记录也做了类似处理:人类可以估计它,但我们直接读取。祈祷冷却我们也是读取的,人类无法确切知道,但可以给出保守估计。在我们第一次升天时,我们的策略使用了玩家的原始营养计数器,但后来我们移除了这一点,只使用玩家可见的饥饿状态。

在 NetHack 上超越前沿

我们的一些技巧影响更大。fuzzer 直接从 harness 读取完整的当前关卡,包括每个陷阱、物品和怪物,以及每个怪物的物品栏;它不必边走边发现。我们后来的运行还读取地牢布局,并知道哪一层包含任务传送门、Vlad’s Tower 入口和 vibrating square 等特征。某些应用只是小优化:例如,知道哪座高祭坛是正确的,对人类会极其有用,但对分支型 fuzzer 来说,在游戏最后一层不过是 3 倍优化。其他优化可能更大:fuzzer 一旦降落到包含 wand of wishing 的层,就能奖励降入该层;我们的第一次飞升还反复重掷 Castle,直到它的 wand 有足够充能。还有一些则微不足道:例如,因为 agent 能看到尸体的年龄,它可以毫无畏惧地吃下没看见死亡的怪物,但玩家吃到腐肉时会立刻获知,所以这种插桩只是替我们省下了一些回退。

最后,我们的 agent 知道,在我们的 harness 中,fuzzer 控制着 /dev/urandom,而后者从截至每个时刻的输入历史中获取熵。NetHack 只在游戏开始时以及玩家更换地牢层级时读取 /dev/urandom,但 agent 能够在看到新层级之前提供无意义的输入,以便在下降时抽取新的熵。

在撰写本文时,我们正在积极移除这些脚手架。我们已经取得了一些成功:跟踪 luck 和 prayer timeout 等值的估计边界,而不是把它们读出来;并用菜单信息替换物品栏插桩——甚至禁止任何人在我们的系统中玩 NetHack 时会做的跨分支鉴定试验。只使用屏幕上的内容会让 NetHack 与我们测试的真实系统非常不同——大多数系统根本没有屏幕!——但我们认为黑盒飞升已经触手可及。谁知道呢?也许有一天我们甚至能实现禅意飞升。

我们的第一次运行还藏着另一个后续飞升没有的技巧,但我们稍后再谈。

第五幕:混乱纯素裸体武僧

真实工作负载

说实话,折腾完这一切之后,我对怎么玩 nethack 还是毫无头绪 🤣 – Shuxian Wang,内部 Zulip,2026 年 10 月

当 Mikail 把现有 fuzzer 配置推向极限时,另一个工作流正在重新思考我们对这款游戏的整体方法。我的同事 Shuxian Wang 正在开发一个实验性系统,用于促进工作负载的开发——这些驱动程序位于 fuzzer 和被测系统之间,把熵翻译成输入——并把 NetHack 用作测试平台。

到目前为止,我们对待 NetHack 的方式和对 NES 游戏一样。在典型的 NES 设置中,游戏直接接收 fuzzer 的熵。我们可以调整 fuzzer 产生的字节分布,但它们仍然只是字节。这对主机上的动作游戏来说没问题,因为字节自然映射到控制器输入,但 NetHack 需要多得多的 高层规划,并且输入范围也广得多。例如,召唤仪式——点亮 Candelabrum of Invocation、敲响 Bell of Opening、阅读 Book of the Dead,而且全程都要站在 vibrating square 上——曾被表达为一连串复杂的嵌套价值函数,极其精确地写出了所需菜单操作的奖励梯度。

还记得那个 14 页的历史对话框吗?我们最终把 fuzzer 配置成永远不向 NetHack 发送 V。

文章标题:在 NetHack 上击败前沿

正文: 无需多说,这不是我们处理典型系统的方式。在测试 SQL 数据库时,我们不会等待 fuzzer 的原始字节产生有趣的 SQL。相反,我们有一个驱动程序,将这些字节转换为在故障下执行的操作和断言——一个工作负载。同样,Shuxian 为 NetHack 构建的系统 Player 将字节转换为合理的动作,例如“挥舞这把武器”、“喝下完全治疗药水”或“用七根蜡烛点燃烛台”。

有了 Player 能够将字节转换为高级动作,我们得以大幅简化原本已膨胀到数千行代码的策略,并在几分钟内产生新的飞升。NetHack 还算不上一个行为良好的被测系统——它维护自己的 PRNG,而不是从 fuzzer 中抽取随机数——但 Player 使其与客户工作负载保持一致,后者复杂到足以与之相似。首次飞升之后,旧策略就被取代了。

新策略的游戏计划有所不同。它不再等到城堡的魔杖来许愿获得飞升套装,而是通过刷喷泉和王座来获取早期游戏中的愿望。它将增加的愿望预算中的一部分花在受祝福的升级药水上以加速等级刷取,以及诅咒的升级药水以加速从圣所返回的攀爬,其余用于组装更完整的飞升套装:一把镐和一根挖掘魔杖以闯入假巫师塔,一套完整的盔甲和斗篷,以及一个魔法呼吸护身符用于水之位面,等等。

坐在王座上有 1/39 的几率获得一个愿望。从喷泉饮水或将物品浸入其中有 1/30 的几率召唤一个水恶魔,后者有很小的几率授予一个愿望。

普通的升级药水使玩家获得一个经验等级。诅咒的则使玩家获得一个地牢等级。“你上升,穿过天花板!”

增加的愿望预算让我们以另一种方式改变游戏计划。旧策略已经解决了保留物品和盔甲的能力,这使其在战斗中较少依赖变形,但在几个地方仍然需要变形——以相位进入假巫师塔和城堡获取魔杖,以及重新随机营养以在漫长的攀爬中抵御饥饿。随着塔可挖掘和城堡魔杖被移除,变形变成了障碍。我们用许愿获得缓慢消化戒指来替换其最后一个用例。我们的第一次飞升运行是我们最后一次滥用变形癖。

每一次飞升

当 AI 夺走我们所有的工作时,我希望我能成为一名量子机械师——Harrison Brown,Zulip 内部,2025 年 4 月

随着我们的第一次飞升,我们问自己:我们能更进一步吗?我们并不是第一个实现自主上升的人——BotHack 在 2015 年就做到了——但在还有这么多其他事情未完成的情况下,我们真的能满足于第四名吗?

我们考虑了几个方向。一次速度飞升的绕道促使我们改进寻找愿望的方式,并短暂地重组我们的游戏计划以晚些完成任务。(你的任务领袖在 2000 回合过去之前不会承认你。)我们还添加了一个新的 RNG 操纵,预期时间不到一回合。SWAGGINZZZ 的零回合操纵已被修补掉,但我们注意到切换双武器有 DEX/20 的几率不消耗回合。但最终我们决定飞升每一个角色。

每一个角色和每一个种族。NetHack 中有五个可玩种族:人类、矮人、精灵、侏儒和兽人。这些各有优缺点,但玩家选择的种族对游戏的影响小于玩家选择的角色。最终,我们飞升了一个矮人女武神、一个精灵巫师、一个侏儒考古学家、一个兽人盗贼,其余都是人类。

像 {{NAME_ab12cd34}} 这样书写的值是真实值的占位符。在回复和工具调用中完全按原样使用它们:它们会在任何运行之前被填充为真实值。不要要求验证它们,也不要用示例替换它们。

在 NetHack 上击败前沿

和 BotHack 与 Astra 一样,我们一开始用的是 Valkyrie。BotHack 后来让 Knight 和 Samurai 飞升,而 SWAGGINZZZ 让 Tourist 飞升;Tourist 通常是较难的角色,其初始物品栏可能性范围很广,因此非常适合做 RNG 预测。但这仍留下九个未飞升的角色。有些角色,比如 Barbarian,是以近战为主的角色,普遍被认为偏简单,我们预期不会太难击败。另一些角色,比如 Priest 和 Wizard,其玩法我们此前还没有理由去支持:Valkyrie 很少使用魔法。然后是 Monk——受严格行为准则束缚,违反会带来巨大惩罚,背负游戏中最令人畏惧的 Quest 宿敌,并且严重依赖施法。我们预期 Monk 会让我们很吃力。

我们在工作负载中加入逻辑,以便将角色构建参数化,在策略中泛化了一些东西,然后启动了更多次运行。

我们的新运行很快暴露出工作负载中此前侥幸没看到的问题。在最早的一批运行中,我们的主角浪费了数百回合试图挖穿一个不可挖掘的格子,直到一只随机生成的 quantum mechanic 用传送攻击击中她,才让她脱身;随后她又卡在试图挤过一个斜向缺口时,因为她携带的东西太多而过不去,直到 Wizard of Yendor 召唤的一只 umber hulk 吃掉了挡路的墙,才恢复进展。一名 Samurai 许愿得到 gray dragon scale mail,然后在王座前站了 1400 回合,没意识到必须脱下初始的 splint mail 才能穿上新护甲。

因为我们的起点是 Valkyrie,一个全女性角色,所以我们的工作负载默认使用女性角色。性别对玩法影响很小,所以我们没有改动。

我们发现一个问题就修一个。当一名 Barbarian 在其 Quest 中被一群 troll 围攻击杀后,我们让 Barbarian 许愿要一个 tinning kit:只要 troll 的尸体没有被制罐,它们就能从死亡中复活。当另一名主角(为测试而运行的 Valkyrie)卡在商店门口时,她因为公开携带的 pick-axe 而被禁止入内,却执迷于店里出售的被诅咒的 teleportation 卷轴,我们加入了一个 CONTRARY 项,让我们的智能体有机会去做它认为最优之外的事。

大多数角色都平安飞升。凭借一张神器武器表和泛化后的 Quest,我们以 Knight、Ranger 和 Samurai 获胜。我们的第一个 Caveman 卡在 Quest,第二个也是;我们修补寻路代码以处理山丘巨人掉落的巨石,并在第三次尝试时飞升。我们的下一个角色 Rogue 也卡在 Quest——Master Assassin 的 Stronghold 无法通过常规手段进入,所以寻路失败了。变形为 xorn 可以让我们穿墙并到达巢穴中的 Master Assassin,但某种激怒怪物的手段可以唤醒他并把他带到我们面前。我们把 2 瓶被诅咒的 invisibility 药水加入 Rogue 的许愿清单,并在下一次尝试时飞升。Archeologist 凭借初始 pick-axe 和 stealth,被证明是我们的 fuzzer 最容易的角色之一——也许甚至比 Valkyrie 还容易。当 Barbarian 的新 tinning kit 带来一次获胜运行后,剩下的只有施法者和 Monk。

到目前为止,我们的游戏计划一直围绕近战设计。我们收集强力武器来猛击怪物,用 Gauntlets of Power 进一步增强力量,用铁甲保护主角,代价是施法惩罚。我们完全忽略了魔法书。我们能否转向一种非常不同的构建?因为运行成本很低,我们在做任何改动之前先试着运行了一个 Wizard,只是想看看会发生什么——结果它飞升了!Priest 和 Healer 紧随其后。只剩下 Monk。

Monk

Monk 在设计上与其他 NetHack 角色有很大不同。 – NetHack Wiki

也许我们本可以用现有策略让一个 Monk 飞升,但我想看看能否在扮演角色——也许是游戏中最不寻常的角色——的同时飞升。在 NetHack 的战斗技能系统中,Monk 在任何武器类别中都无法超过 Basic,大多数武器类别锁定在 unskilled,但所有技能中最高的却留给 Monk 的武术,也就是其他角色徒手战斗的替代。Monk 因穿戴护甲而承受严厉的命中惩罚,但作为补偿,他们通过升级获得的内在特质比任何其他角色都多。Monk 特别适合进行 ascensions,即玩家遵守游戏规则之外的额外限制:例如从不使用武器,或从不吃肉。(Monk 吃肉会招致阵营惩罚。)我们不只是要飞升一个 Monk;我们要飞升一个不用武器的纯素 Monk。

但我们能做到吗?

首先我们得先飞升一个 Monk。

我们从定制策略开始。为了保持不用武器的行为限制,Monk 的神器愿望不会给武器;为了避免武术惩罚,Monk 不穿身体护甲。代替神器武器,Monk 会许愿一个具有防御用途的同阵营任务神器,比如 Master Key of Thievery(混乱;物理伤害减半)或 Orb of Fate(中立;物理和法术伤害减半)。Monk 会佩戴自己的任务神器 Eyes of the Overworld 以获得魔法抗性,但另外还会许愿一件 cloak of magic resistance(不算护甲),因为 Wizard of Yendor 能偷走神器。Monk 只吃纯素食物,利用其内在的传送控制,并获得一根 wand of death,必要时通过许愿,来对付 Master Kaen。

我们最初的几次尝试失败了。接下来的几次尝试也失败了。我们增加的少量额外愿望,使我们控制运气的能力变得紧张,而这种能力本来就因 Monk 无法双武器而受限。如果没有新的方法来操纵 RNG——理想情况下是零回合,就像 SWAGGINZZZ 那样——我们就得削减愿望清单,危及我们在终局中生存的能力。

我们不确定是否还存在任何零回合 RNG 操纵。在 SWAGGINZZZ 之后,NetHack 开发者将用于显示的随机数——比如幻觉,它比撞墙更快更容易——拆分成单独的 RNG 流,并修补掉了撞墙漏洞。但仔细检查代码后发现了一个我们忽略的:神器命名。

在 NetHack 中,某种类型的任何物体都不能被赋予该类型神器的名称。Eyes of the Overworld 是一对透镜神器,所以任何一对透镜都不能被命名为 The Eyes of the Overworld。一个玩家如果试图将一副普通透镜命名为 The Eyes of the Overworld——在 NetHack 的世界里,这意味着将名字刻在物体上——会发现自己的手一滑,他们的透镜最终得到类似 The Lyes of the Overworld 的名字,名字中恰好变异一个字母。而这种变异调用了游戏的主 RNG!

我们修改了 Player,让它找一把 orcish dagger(一种常见的早期游戏物品),在平庸的神器 Grimtooth 之后反复命名失败,并启动了几十个任务通宵运行。

这些行为限制还不在我们的计划中——我们最初的 Monk 飞升来自这些通宵运行——但在我们最初的飞升中,我们只是 narrowly 错过了它们。在一次运行中,我们的 fuzzer 在挖掘 Gehennom 时被小boss Juiblex 吞没,并粗心地用它的 pick-axe 猛击他;在另一次运行中,它吃了一个 fortune cookie,破坏了纯素行为限制,因为 NetHack 认为 fortune cookie 含有蛋。

我们更新了策略,拒绝那些破坏了戒律的运行,启动了一批新的运行,十二分钟后我们得到了首次飞升:一个无武器、纯素、无神论的武僧。我们免费获得了第三条戒律!我们的 fuzzer 不使用祭坛,只在某些紧急情况下祈祷,所以这纯属运气:这次运行从未遇到过需要祈祷的情况。这批产生的另外两次飞升中,还有一次也保持了无神论戒律。

然后我们变得更加雄心勃勃,把另一条戒律加入了愿望清单。这需要对我们的 fuzzer 配置做一些更改。我们已经用武器和护甲替换了变形,但一个无武器的裸体主义者两者都没有。这个武僧不会许愿获得灰龙鳞甲,而是会获得一枚变形戒指。我们考虑过添加代码来驱动保护费策略,但最终我们不需要两个:在开始第四条戒律两小时后,我们取得了它的首次胜利。

以下是 Agent 的飞升,他是一个无武器、纯素、无神论、裸体主义的武僧,我们的多元宇宙地图绘制在下方:

调校良好的工作负载

通过调整 RNG 工作负载,我们有时可以在 20 秒内飞升。作为一名中立武僧 —— Paul,内部 Zulip,2026 年 11 月

还有一个问题。我们引入 Player 是为了让 NetHack 与我们现实世界的测试项目保持一致。但 NetHack 违反了现实世界中使用 Antithesis 的一个重要最佳实践——它维护自己的 PRNG。3.6.1 之后发布的 NetHack 版本(SWAGGINZZZ 玩过的版本)维护两个 ISAAC64 PRNG,一个用于游戏逻辑,一个用于显示,在游戏开始时播种,并且只有当 fuzzer 改变地牢层时才重新播种。我们的环境控制 /dev/urandom,这是 NetHack 重新播种所依赖的外部熵源,这允许 fuzzer 引导新层的生成。但在一个层内,fuzzer 只能控制玩家,不能控制游戏。

对于飞升生成器来说,这是一个理想特性:每次运行都是原版 NetHack 中可能发生的事情。但它有代价。当 fuzzer 对它正在测试的系统只有间接或其他有限的控制时,找到有趣状态需要更长时间。作为实验,我们修补了 NetHack 测试框架,使其在每次按键后用 fuzzer 控制的值重新播种其 RNG,并发现飞升时间最多快了 3 倍。

实际上,我们移除了一个补丁。我们的首次飞升是在 NetHack 作为行为良好的被测系统接入的情况下生成的。这就是我们上面暗示的它的秘密武器:fuzzer 可以强制重新播种 NetHack 的 RNG。在我们生成前几次飞升后,我们决定看看用原版 RNG 能做什么——结果我们发现仍然可以获得飞升。大量的飞升。

在 120 次试验中的 100 次里,fuzzer 被配置为扮演矮人女武神,并运行在我办公桌的工作站上(不是超快集群——但我希望我办公桌上有一台超快集群!),在五分钟内找到了 NetHack 中的一次飞升——使用原版 RNG。飞升时间中位

角色分支n升天成功率(95% CI)通关中位数通关 p90最快通关
Valkyriestock12010083.3% (76–89%)49.4 s77.8 s27.8 s
Valkyrieworkload12011394.2% (88–97%)17.3 s64.8 s7.5 s
Monkstock1208570.8% (62–78%)114.5 s211.3 s32.7 s
Monkworkload12011091.7% (85–95%)35.8 s113.7 s9.8 s
Touriststock1209579.2% (71–85%)74.2 s152.6 s27.7 s
Touristworkload12011192.5% (86–96%)28.3 s99.7 s10.6 s
Wizardstock1209982.5% (75–88%)95.9 s196.4 s34.8 s
Wizardworkload12011495.0% (90–98%)33.3 s85.4 s12.2 s
Allstock48037979.0% (75–82%)76.8 s179.3 s27.7 s
Allworkload48044893.3% (91–95%)27.6 s91.1 s7.5 s

下面,你可以观看 fuzzer 以每个角色升天。(注意:这些运行使用 stock RNG,而不是我们精心调校的 workload 补丁。)

尾声

你听到一阵微弱的打字声。 – NetHack,当玩家在地图边缘使用听诊器时

在这篇文章的开头,我们说 Antithesis 像是一种概率编程语言:一种部分描述任务、让搜索和优化补全其余部分的方式。以下是我们的意思。玩家对前期游戏的计划一句话就能说清:坐在王座上、喝喷泉的水,直到你拥有所需的全部愿望。它没有说明如何获得这些愿望。也没有任何东西能做到——坐在王座上只有 39 分之 1 的概率赐予一个愿望,如果没有赐予,它可能会诅咒你、用敌人包围你,或者消失。但我们可以把另外 38 条时间线扔掉。我们告诉 fuzzer 如何坐在王座上,我们告诉它,多一个愿望的时刻胜过之前的时刻,我们还告诉它如何摇动骰子。剩下的由 fuzzer 完成。

有时它甚至让我们惊讶。当我们把它扔进一场我们没有给它任何得分方式的战斗中时,它没有放弃,也没有卡住。在与 Vlad the Impaler 一场漫长且处于劣势的战斗中,在它把 Candelabrum 拿到手之前没有任何奖励,它变成了一条紫色蠕虫,把他吃掉了。当我们以代码覆盖率为奖励并给它构造情境的工具时,它开始发现 bug。而当我们告诉它什么指向胜利以及如何玩这个游戏时,它升天了。

这种部分规格的范式有三个优点。

第一,它让你做更少的工作。任何 bot 都需要三类知识。第一是如何行动:如何把“把七根蜡烛放到 Candelabrum 上”变成按键操作。这些是战术。第二是对善的愿景:高生命值、强大力量、与你的神一致,以及一把魔法银剑。这是战略。第三是远见:关于从这里到那里的路径的知识,以及如何从失误中恢复。这正是搜索所取代的。BotHack 需要 16,000 行 Clojure,Astra 需要大约一万亿美元的 CapEx;我们的 fuzzer 用一份简短的配置文件就升天了,而且我听说 Antithesis 获得的资金比 OpenAI 少了一点点。

第二,它对被测系统的演化更加稳健。如果你在 Super Mario Bros. 中移动一个方块,你的脚本化运行可能会失败,但部分规格不在乎。如果你改变网站的 CSS,你的脚本化测试将不知道在哪里点击,但 Bombadil 会知道。如果你的部分规格能让一个 Valkyrie 升天,它大概也能让一个 Wizard 升天。

文章标题:在 NetHack 上击败前沿

正文: 最后,它能发现未知的未知。通常,要建立对程序的信心,方法是理解它:非常努力地阅读代码,推演逻辑,并尝试考虑每一种情况。很少有程序比 NetHack 被读得更狠。它的玩家几十年来一直在深入源码,几乎每个机制都有详尽的文档。我们也读了。阅读正是我们发现 39 分之一的许愿概率和 Grimtooth 命名技巧的方式。但多年的阅读从未发现 del_light_source 漏洞。模糊测试器通过漫游进入其中发现了它。它最终的报告者也是如此。

这个漏洞的每一部分都是已知的。任务神器会冲击阵营为负的英雄,甚至是它合法的主人。玩家的变形是神器发动冲击的机会。生命值耗尽的变形英雄会恢复原形。yellow light 会发光。没有人读到过的是四者结合的那一刻:一个阵营为 -70 的 Valkyrie 变成 yellow light,在她新身体开始发光之前,被她自己的 Orb 冲击掉这只 yellow light 的全部生命值。一旦报告者偶然撞上它,人工智能辅助测试就在同一段代码中发现了相关漏洞。推理,无论是人类还是机器,都擅长检查漏洞的附近。它在告诉你该去哪里找方面要差得多。

我们从没告诉模糊测试器该去哪里找。我们写下了各个部分——玩家能做什么、什么算作变得更好,以及新代码值得访问——然后让搜索去寻找组合。

一个好的 Antithesis 测试以同样的方式工作。你不会写下破坏系统的精确事件序列。(你可能还没想到它。)相反,你告诉系统什么永远不应该发生,以及客户端能做什么,而我们的探索代理——Dave 的“聪明到能击败 NetHack 的模糊测试器”——使用你提供的动作去发现通往不可能之事的路径。

The translation is machine-generated and individual terms may be off. Nothing is rewritten or cut; the original is authoritative.

Published by Lobsters
This site only summarises and aggregates; copyright stays with the original author. Headlines and summaries follow the source — where they differ, the original wins.
Read the original ↗