超级马力欧衍生作品
Super Mario Derivations

原始链接: https://fzakaria.com/2026/08/05/super-mario-derivations

Nix 的语言定义基于“惰性求值”(laziness),这意味着它仅计算被访问的特定属性。因此,开发者可以创建无限或递归的数据结构,只有在被遍历时这些结构才会“存在”。 这一特性带来了传统软件包管理之外的独特可能性。例如,在 `nes-nix` 项目中,一个属性路径(如 `.right.jump.right` 这样的序列)被视为《超级马力欧兄弟 3》的一系列输入。在这个“属性树”中,每个节点代表游戏的一帧,而每个子节点都是由按键操作产生的新状态。 由于 Nix 的派生(derivations)是可复现的并存储在 Nix 存储中,每一帧游戏画面都成为一个可缓存、可重用的派生。存储本质上充当了持久化的存档历史;从游戏序列的分支开始几乎不需要任何成本,因为之前的帧只是被引用,而无需重新模拟。 归根结底,这表明 Nix 的属性路径不仅仅是目录中的静态名称,而是函数式程序。利用该语言的惰性求值特性以及存储持久化状态的能力,人们可以将软件包管理器重新用于系统地模拟和记录复杂的、可复现的状态机。

Hacker News 最新 | 往期 | 评论 | 提问 | 展示 | 招聘 | 提交 登录 超级马力欧推导 (fzakaria.com) 16 点,由 domenkozar 于 1 小时前发布 | 隐藏 | 往期 | 收藏 | 2 条评论 帮助 MiroslavPokorny 34 分钟前 [–] 我很困惑,这有什么意义吗? 回复 ludr 22 分钟前 | 父评论 [–] 这只是一个有趣的黑客行为。我觉得它很巧妙。 回复 指南 | 常见问题 | 列表 | API | 安全 | 法律 | 申请 YC | 联系 搜索:
相关文章

原文

One of the most surprising aspects of the Nix language is that it is lazy, especially if you have never used a lazy language before. This laziness is what makes much of Nixpkgs possible, and its complexity.

One of the simplest ways to observe the laziness is by understanding that only the attributes you access are evaluated.

$ nix eval --expr 'let pkgs = 
   { hello = "hi"; broken = throw "never forced"; }; 
   in pkgs.hello'
"hi"

The more whackier version of this is you can have endless recursion in an attribute set. Nixpkgs is filled with these bottomless attribute sets:

$ nix eval -f '<nixpkgs>' 'pkgs.hello' --raw
/nix/store/18bbdvag5v2f3d4y37pdbkzvh7s71cw4-hello-2.12.2

$ nix eval -f '<nixpkgs>' 'pkgs.pkgs.pkgs.hello' --raw
/nix/store/18bbdvag5v2f3d4y37pdbkzvh7s71cw4-hello-2.12.2

$ nix eval -f '<nixpkgs>' 'pkgs.python3Packages.pkgs.hello' --raw
/nix/store/18bbdvag5v2f3d4y37pdbkzvh7s71cw4-hello-2.12.2

The same store path every time. pkgs contains itself, and so does every package set inside it. 🤯

If laziness is what lets a recursive attribute set terminate, then the recursion doesn’t have to bottom out at all:

$ nix eval --expr \
    'let countdown = n: { value = n; next = countdown (n + 1); };
     in (countdown 0).next.next.next.value'
3

That attribute set is infinitely deep. Indexing three levels into it costs exactly three levels of evaluation, and the rest of the infinite tree is never built because nobody asked.

So an attribute path is a walk through a lazily-generated tree. Which made me wonder: what if the attribute path were input to something? 🤔

I decided to take that idea and make the attribute path a sequence of button presses in Super Mario Bros. 3. Each node in the tree is a frame of the game, and each child is a button press that produces a new frame. Game states are recursive by nature.

$ nix build '.#level1.rightb.rightb.rightab.rightb'
$ file -L result
result: PNG image data, 256 x 240, 8-bit/color RGB, non-interlaced

.rightb is right + B, which in Super Mario Bros. 3 is “run right”. .rightab is run and jump. The output is the frame you’d be looking at if you’d pressed those buttons in that order, on real hardware, in that game.11The prefix .#level1 is a precanned sequence of button presses that gets you to the start of level 1-1. 

Append .play anywhere along the path and you get the whole run stitched into a recording:

Super Mario Bros. 3 running in an emulator: the title screen, the 1/2-player menu, the World 1 map, then Mario running right and jumping in level 1-1

The coolest thing though is that every one of those frames is a separate derivation in my store.

The code is at fzakaria/nes-nix. It is generalized and the ROM is a flake input you point wherever you like for any other game.

The flake computes a derivation based on the attribute path such that each press is its own derivation, and it takes the previous press’s savestate as an input. Each derivation never re-emulates its ancestors’ frames.22A screenshot of the frame is also produced, which is used when we want to stitch a video sequence together. 

trunk1 level1 2y1qjbk7…-nes-wait16 trunk2 .rightb gdbgfpdk…-nes-rightb trunk1->trunk2 run .rightb kpjlw529…-nes-rightb trunk2->run jump .rightab iv6asl0i…-nes-rightab trunk2->jump a .a q02kp71k…-nes-a run->a righta .righta nb87m9ss…-nes-righta run->righta

The practical consequence is that the store becomes the emulator’s savestate history:

# 3 derivations, cold
$ nix build '.#game.start4.wait2.right'
# 1 derivation, prefix reused
$ nix build '.#game.start4.wait2.left'
# 1 derivation, all of it reused
$ nix build '.#game.start4.wait2.right.right'

Branching off the middle of a hundred-press run costs one press as does appending to the end of it.

We can look at it the other way. The dependency graph is the input sequence, so we can ask Nix what buttons produced a frame:

$ nix-store --query --tree 
     $(nix eval --raw '.#game.start.wait4.start.drvPath')
/nix/store/32n4ni0zg01b9c9v64x67am37rdmmr9y-nes-start.drv
└───/nix/store/j5vy3385pgs9dzw0y7sdrdmn7xnrxgji-nes-wait4.drv
    └───/nix/store/w4zz5aqj5zxqhnialabdc7p3sy80v6dc-nes-start.drv
        └───/nix/store/k9wfz8w5157d0xdwaw1vvhf019dvw5s0-nes-boot.drv

So what is .play actually doing?

Almost nothing. Every frame along the path is already sitting in the store as the output of its own press, so the recording never emulates anything. It is a directory of symlinks to the frames for ffmpeg to process.

$ nix build '.#level1.rightb.rightb.rightab.play'
$ ls -l result/frames | head -4
0000.png -> /nix/store/3p2fxwngh…-nes-boot
0001.png -> /nix/store/4ha88l0dk…-nes-start
0002.png -> /nix/store/nh4zfsq6x…-nes-wait4
0003.png -> /nix/store/ghbgn28f1…-nes-start
cluster_play result/frames : the play derivation cluster_store /nix/store : one derivation per press f0 0000.png p0 3p2fxwngh…-nes-boot f0->p0 symlink f1 0001.png p1 4ha88l0dk…-nes-start f1->p1 f2 0002.png p2 nh4zfsq6x…-nes-wait4 f2->p2 f3 0003.png p3 ghbgn28f1…-nes-start f3->p3

How far can we take this input-sequence game input idea?

Nix by default gives out at around 2,400 presses, with:

$ nix eval --raw ".#game.right.right.right…drvPath"
error: stack overflow; max-call-depth exceeded

max-call-depth defaults to 10,000 and evaluating each press costs roughly four nested calls.

It’s a guard against runaway recursion, not a structural limit, and we can raise it to 10 million and get 20,000 presses:

$ ulimit -s unlimited
$ nix eval --raw --option max-call-depth 10000000 \
      ".#game.$(
        python3 -c 'print(".".join(["right"]*20000))')
      .drvPath"
/nix/store/p4nm0a4p4k9bdjqsag1jj0baah9mj6hb-nes-right.drv

20,000 presses, takes roughly fourteen seconds to evaluate on my laptop. The cost is linear in the number of presses, and it is roughly 0.7ms “per press”.

1980-01-01T00:00:00+00:00 image/svg+xml Matplotlib v3.10.5, https://matplotlib.org/

The next bottleneck though is that the kernel gives out at 21,845 presses on my machine. An attribute path is a single argv element, and Linux caps the size of the argument list in total and individual arguments.

The per-argument limit is 131,072 bytes (MAX_ARG_STRLEN), and each press is six bytes long (right.), so 21,845 presses is the maximum that can be passed to nix eval as a single argument.

The escape hatch is to stop passing the run as an argument. and we can feed in the input-sequence as from a file:

$ nix build --impure --expr \
    '(builtins.getFlake (toString ./.))
      .packages.x86_64-linux.game.sequenceFile 
        ./runs/world1-1.txt'

This produces the byte-identical derivation to the equivalent attribute path, so a run kept in a file still shares the same store paths.

All of this was to simply evaluate the Nix expression. Now we have to build it. Although Nix is great at building derivations in parallel, the recursion here is tail-recursive and therefore serial.

I benchmarked the build time of a growing list of button presses and the cost is also linear, as we would expect, with the number of presses. The cost per press is roughly 1.27 seconds with substituters enabled and 0.28 seconds with them disabled. The round-trips cost for checking whether the derivation is in the cache costs noticeably more than emulating the frames does.33We can set preferLocalBuild or allowSubstitutes if we want to avoid this cost. 

1980-01-01T00:00:00+00:00 image/svg+xml Matplotlib v3.10.5, https://matplotlib.org/

We’re used to the attribute path being a name, simply a coordinate into a catalogue of things that exist. Laziness means it’s really a program: a sequence of steps the evaluator walks, generating whatever it needs as it goes.

Nixpkgs happens to use that machinery to describe software, but nothing about it requires that the tree be a catalogue at all. Coupled with the fact that the store turns out to be a decent persistence layer for reproducible state-machines, makes a our “package manager” reasonable to use for playing Mario. 🍄

联系我们 contact @ memedata.com