#9 닌텐도 DS의 대가

2026-09-07 · method

I have an AI that plays Pokémon Red on a two-core cloud server, and it runs at roughly thirty-eight times real time. That number is why the whole project works: an hour of wall clock buys thirty-eight hours of game, the broadcast has to be deliberately slowed down to be watchable, and the emulator idles at about 2.6% of one core while it does it.

So when I started on a Nintendo DS game — Pokémon HeartGold — I assumed the shape of the project would carry over. It does not. The DS runs at 1.06 times real time on the same machine, and almost everything I had planned depended on a number thirty-six times larger.

This is the write-up of that measurement and the two bugs I hit getting to it, because "I measured the thing before designing around it" is the only reason I did not spend a fortnight building around an assumption that was wrong by a factor of thirty-six.

It is also, in places, a write-up of being wrong in public: I had this post reviewed adversarially after publishing it, and the corrections are marked inline rather than quietly edited out.

The number

I wrote the benchmark before writing any of the environment, which in hindsight is the single best decision in this post. It reports four costs separately, because they have different fixes: raw emulation is the emulator, frame extraction is the observation pipeline, memory reads are the reward function, and save/load is what every episode reset pays.

⚠️ Two caveats I should have led with. The memory-read figure runs over 600 frames where the others use 1800, and it comes out faster than raw emulation — which is not possible if the figures cleanly isolated their overheads, so treat the three throughput rows as one measurement with noise rather than three comparable ones.

emulate only                      66.3 /s     1.11x realtime
emulate + read both screens       63.3 /s     1.06x realtime
emulate + 40 memory reads         67.2 /s     1.12x realtime

Game Boy env, same machine      2266   /s    38x    realtime

The DS is about thirty-six times heavier per frame. That is not surprising in itself — it is two screens and a 3D pipeline running through a software rasteriser on a machine with no GPU — but the consequences split into two that are worth keeping separate.

Training on this server is impractical by a wide margin. Reinforcement learning against a game this long needs tens of millions of steps. At thirty-eight times real time that is merely expensive; at 1.06 an hour of compute buys an hour of game.

I first wrote "impossible" there, and an adversarial review of this post's own numbers correctly made me take it back — with an argument worth repeating, because it is about using the wrong unit. Frames are not the denominator that matters; agent decisions are. The Game Boy environment takes one action per 24 frames, so 2266 fps is really 94 decisions a second. If the DS used the same repeat, 63 fps is about 2.6 decisions a second, and ten million transitions lands around 44 days of continuous compute before you add preprocessing, inference or training updates. That is a bad plan, not a physical impossibility, and the difference matters because "impossible" ends an investigation while "44 days" invites you to ask what budget you actually have.

DS training still moves to a desktop machine with a real GPU. The measurement made that call; it just did not make it as dramatically as I first claimed.

Broadcasting is possible, with about 6% headroom. This is the subtler one. The Game Boy stream is throttled down from 38x to about 2.5 steps a second, so it sits at a couple of percent of a core and nothing else on the box can disturb it. The DS produces 63.3 frames a second against a console that wants 59.8 — roughly 0.9 ms of slack per frame. That is thin rather than zero, which is a distinction I blurred in the first version of this post, but it is thin enough that competing load is a real risk rather than a theoretical one.

⚠️ And I have to be honest about what the benchmark actually ran: it boots the ROM and measures the title sequence. It never loads a save, never supplies input, and never reaches an overworld or a battle. Those are the workloads a broadcast would spend its life in, and they are not the one I measured. The number is a first bound, not a characterisation.

Save and load cost 70 ms and 139 ms respectively. On the Game Boy those are free by comparison. Here, every episode reset pays 139 ms, which matters because the technique I most want to use — broadcasting a good save state to every parallel environment, the approach the pokemonred_puffer project credits for finally beating the game — is built entirely out of save-state loads.

Trap one: the button press that pressed every button

Before any of that I had to make the emulator accept input, and this cost most of a day. I was using py-desmume, whose keypad API offers keypad_add_key and keypad_rm_key. Pressing X and releasing it should return the pad to where it started. It did not:

press("X")     keypad 4095 -> 3999
release("X")   keypad 3999 -> 2975

My first three theories were all about my own key mapping, because a delta of 96 is nothing like the 1024 you would expect from a single button's bit. I checked the mapping twice. It was correct both times.

The actual cause is a chain of three facts, none of which is wrong on its own:

Put together: the very first press reads 4095, treats it as a pressed mask, and holds down every button on the console simultaneously. Every subsequent call compounds it. And the reason the number looked like 96 rather than 4095 is that the emulator sanitises impossible D-pad states — you cannot press left and right at once — so it silently dropped the LEFT and UP bits, which are worth exactly 96 together.

The fix is to stop reading the register: zero the pad at construction, keep the pressed-mask in my own code, and call the setter directly. The general rule I took from it is worth more than the fix — never read-modify-write a register that rewrites its own contents, because a legitimate change can vanish from the value you read back.

⚠️ That fix was not sufficient, which I only learned when the same review went after it. A save state restores the input registers, so loading one silently re-holds whatever was down when it was captured while my Python mask still says nothing is pressed — the phantom press then survives every subsequent frame. Owning the mask is necessary and not enough; it has to be re-asserted after anything that reloads machine state. There was a second one too: because every press re-submits the whole mask, and the emulator resolves an impossible left-and-right with a hidden "most recent direction wins" counter, pressing A while two directions were held could reverse the character's movement. Both are fixed by keeping my own mask always physically legal, so the emulator's sanitiser never has to intervene.

What makes this worth publishing is the failure mode it would have had. It does not crash. An agent trained against it would simply have been pressing the wrong controls forever, and that presents as "the model cannot learn" — a diagnosis that sends you to look at your reward function, your network, your hyperparameters, and never at your keypad.

Trap two: the Game Boy's addresses do not exist here

The Red environment reads about forty fixed memory addresses — party at 0xD16B, badges at 0xD356, permanently, because the Game Boy has no memory allocator. Everything is where it was compiled to be.

HeartGold allocates its save block on a heap, so the data lives wherever it happened to land that boot. Every published HeartGold address is really two reads: a 32-bit anchor pointer at a fixed location, then documented offsets from whatever it points at. The community lists on Project Pokémon give the anchor as 0x0211186C, with trainer ID at +0xD064 and party data at +0xD088.

Those addresses come from Action Replay code lists rather than from the pokeheartgold decompilation, and an address like that is specific to one title and one region — HeartGold and SoulSilver, US and European and Japanese, are four different numbers, and nothing in a ROM header tells you which list you are holding.

Here is the part that decided how I built it: a wrong anchor does not fail. It returns a plausible-looking integer for every field you ask for, and a reward function will train perfectly happily against noise. That is the most expensive bug available in this project, because it presents identically to the keypad bug — as a model that cannot learn.

So the reader's actual product is not the reads, it is a verifier that refuses to hand back a working object until the anchor has proved itself. The strongest available check turned out to be a statistical one. Every Pokémon carries a "personality value", a 32-bit number that is effectively random per creature. A correct anchor therefore yields distinct, high-entropy values in the occupied party slots. A wrong one lands in zeros, in small counters, or in text — and each of those is a separate named check that reports which one it was.

I ran it against the real cartridge for the first time this weekend. The anchor became valid 180 frames into boot and held one stable address across 656 samples, so it is a real, early-populated pointer and the address is structurally right. The verifier still refused it, correctly: with no save loaded, the block is allocated but empty, so the trainer ID and every party slot read zero. It is not proven until it reads a real save, and a verifier that said "looks fine" there would have been worse than useless.

One last thing I got wrong in a useful direction. I wrote a brute-force fallback that scans all 16 MB of main memory looking for the anchor, and documented it as a slow last resort. Then I measured it: about thirty seconds. At that price there is no reason to stake a multi-day training run on a number from a forum post — deriving the anchor from the actual cartridge should be the default, and the published constant is only a first guess for the verifier to accept or reject. It reports every match rather than the first, because two matches would mean the value is not unique enough to identify anything, and quietly picking one is exactly the failure the whole module exists to prevent.

What I would take from this

The benchmark took an afternoon to write and it saved a fortnight. Both of the bugs above share a shape — they produce plausible output rather than an error, and both would have been diagnosed as a broken model rather than broken plumbing. The defence in each case was the same and it was cheap: measure the thing directly, and build the check that refuses to proceed rather than the check that reassures you.

← 목록으로