dreamports is my home for enhanced fan ports of classic games: the port fans always dreamed of, the game you remember with the extras you wished it had. what holds it together is a set of tools that prove each port. they play the same recorded button presses in the original game and in the port, then compare every frame, picture and sound. those tools run after almost every change, so whatever they waste, they waste hundreds of times. i wanted them to ask less of a computer, to work for consoles beyond the game boy advance, and to give exactly the same answers. so i measured everything first, changed the tools, and measured again.
the biggest waste was time spent doing nothing. the tools played every game at 60 frames a second, in a browser with no screen.
the original runs in mgba, an emulator, and the port runs in chrome with no window. a browser hands a page its frames at the pace of the display, and a browser with no display still keeps to 60 a second. so checking a game took exactly as long as playing it: 109,358 frames across 28 recordings, 1,826 seconds, 60 frames a second. nearly all of that time the computer was waiting.
now the tool asks for one frame, compares it, and asks for the next. the same 28 recordings take 105 seconds, about 1,000 frames a second. a port's clock counts frames, not seconds, so the game can't tell the difference, and a --paced switch keeps the old pace for anyone who wants to check that.
| job | before | after |
|---|---|---|
| sonic advance, all 21 recordings | 32:04 | 3:16 |
| its longest recording, 11,500 frames | 3:35 | 0:17.2 |
| sonic battle, all 7 recordings | 2:21 | 0:14.0 |
| one title screen, 780 frames | 0:23 | 0:02.6 |
memory falls by a third to a half. the tool stopped starting a new browser for each recording, and told the one it keeps to care about size.
each recording used to get a new browser with a new profile. now one browser stays open for the whole run, and each recording gets a clean context inside it, with nothing of the last one left. one setting did more than i expected: --optimize-for-size for chrome's javascript engine takes more than a third off the page's memory, and a frame takes no longer. the sound being compared is now kept as a rolling window, not as the whole recording.
| job | before | after |
|---|---|---|
| building the emulator | 1,487 | 363 |
| sonic advance, all | 1,193 | 796 |
| sonic battle, all | 770 | 416 |
| one level | 750 | 466 |
| the longest recording | 697 | 481 |
| one title screen | 657 | 368 |
building the emulator changed the most, and for a plain reason. it ran a compiler for every thread, 175 processes at the peak on my computer, and each takes a few hundred megabytes. eight at a time, fewer if memory is short, finishes in the same five seconds. that matters most on a small computer, where the old way could ask for gigabytes it would never get.
the disk tells the same story. every new browser wrote a new profile, about 100 MB each, and a run with 21 recordings wrote 21 of them.
| job | before | after |
|---|---|---|
| sonic advance, all | 4,241 | 927 |
| sonic battle, all | 658 | 39 |
| the longest recording | 293 | 97 |
| one level | 150 | 30 |
| one title screen | 109 | 4 |
i set out to lower its use, and found the proof tools spent zero milliseconds on it. the windows do use it, and there a lower setting turned out worse.
the operating system counts each program's time on the chip, and for every proof it read 0 milliseconds, before and after. the tools draw pictures on the processor and hash them, and a browser that nobody sees is now told plainly not to use the chip.
the windows a person plays in are different, so i tried drawing those on the processor too. it saved almost nothing and made them worse, so they keep the chip.
| measure | chip | processor |
|---|---|---|
| frames a second | 59.67 | 58.4 |
| late, in 15 seconds | 5 | 24 |
| slowest 1 in 100 | 16.8 ms | 33.3 ms |
| card's memory | 118 MB | 2 to 4 MB |
| card's power | 19.2 W | 19.0 W |
what did help is rest. the window that picks a game's capture kept drawing at full speed when nobody was touching it, for as long as it stayed open. now it stops after 30 seconds without a press.
| measure | before | after |
|---|---|---|
| at rest, processor | 29.0% | 1.5% |
| at rest, chip | 20.3 ms | 3.7 ms |
| playing, processor | 5.48 s | 4.11 s |
| playing, chip | 473 ms | 524 ms |
| playing, memory | 293 MB | 284 MB |
| playing, disk | 47.9 MB | 1.0 MB |
this computer won't tell me what its processor draws, so this part is arithmetic. the processor's share of a run halves. the rest depends on what the computer would be doing otherwise.
what i can measure is processor time, and it fell by half or more on every proof. to turn that into energy i took the chip's base power, 125 W, and split it across its 28 threads, about 4.46 W a busy thread.2 trust the ratios more than the watt-hours.
| job | before | after |
|---|---|---|
| sonic advance, all | 490 | 239 |
| the longest recording | 54.3 | 20.2 |
| sonic battle, all | 45.5 | 17.4 |
| one level | 18.8 | 8.7 |
| one title screen | 9.8 | 3.0 |
those are small numbers. the larger part is the rest of the computer staying on. my graphics card alone draws about 19 watts doing nothing, which over 32 minutes is about 10 Wh and over 3 minutes 16 about 1 Wh. that only counts if the computer would otherwise be off or asleep, so i don't add it to the total. the capture window at rest goes from about 1.29 W of processor to 0.07 W, for as long as someone leaves it open.
agents make ports here too. cutting what they read first by half didn't lower what they read in all, because they then took more small steps to find it.
a coding agent reads its whole conversation again at every step, so a whole task's cost is its steps multiplied by everything it holds. the project's instructions and its one command were long, and every agent read all of it at every step. i cut AGENTS.md from 33.9 KB to 5.8 KB and the command from 18.3 KB to 14.8 KB, moved the rest to where it can be looked up, and added a help tool that prints one section of a long document. the agent's first request fell from 13,742 tokens to 6,797.
then i ran whole tasks to see what that bought: opencode with a free model, sealed in a sandbox, doing the same small job on a made-up game, one with the old tools and one with the new, side by side.
| pair and tools | steps | per step | in all |
|---|---|---|---|
| first pair, old | 48 | 48,003 | 2,304,140 |
| first pair, new | 56 | 34,133 | 1,911,472 |
| second pair, old | 42 | 53,555 | 2,249,305 |
| second pair, new | 57 | 42,412 | 2,417,505 |
each step costs about a quarter less, and there are 17 to 36% more of them. one total fell by 17% and the other rose by 7%, which is to say about even. reading the tool calls one by one showed why: a long read had been answering questions the agent now went looking for, such as what a recording file looks like, which buttons exist and when a foundation counts as done. so those answers moved into the command, at the point where it needs them.
i changed that wording after these runs, and when i tried to run it the free model refused every request with a usage limit. so what that last change saves is not measured yet.
the tools also say less, and whatever a tool prints stays in the conversation for every later step. a check that passes now prints almost nothing.
| job | before | after |
|---|---|---|
| the site's checks | 25,205 | 658 |
| sonic advance, all | 22,771 | 15,011 |
| sonic battle, all | 6,463 | 2,585 |
| one title screen | 1,166 | 294 |
most rounds only need to know the first thing that's wrong, and a finished step needs one command.
prove --quick stops at the first frame that differs and writes almost nothing: one title screen in 2.1 seconds, 4 MB on disk and 60 bytes printed, where a check used to take 23 seconds, 109 MB and 1,166 bytes. it's the cheapest way to see whether a change did what it should.
ready runs everything a step has to pass before it goes out, the port map, every recording, the site's checks and its build, and prints only what's wrong. on sonic battle it takes 23 seconds. run by hand with the old tools, those same jobs added up to about two and a half minutes.
the tools no longer assume a game boy advance. no real console beyond it has run yet, and i'd rather say so plainly.
one file now lists each console: its screen, how long a frame lasts, its sound rate, the kinds of file a copy comes in and its buttons. a game names its console, and everything follows from that. every other console's original runs in a libretro core, a small emulator with one way of being driven, through a little program of the tools' own that gives them the same pictures, sound and memory that mgba does.
to test that with nothing borrowed, i wrote an emulator for a console that doesn't exist, with its own screen, frame time and sound rate. on every pull request, a test makes a game for it and takes it through all the tools: recognising a copy, mapping its code, proving it frame by frame, catching a wrong colour at the frame where it first shows, catching a wrong sound under a right picture, and keeping a capture.
| console | where it stands |
|---|---|
| game boy advance | used for real games, in mgba |
| a made-up console | a whole game on every pull request, in an emulator written for the test |
| nes, super nintendo, game boy, game boy color, mega drive, playstation, nintendo 64 | an emulator chosen for each and fixed at a commit, not run yet |
| playstation 2, playstation portable | none yet, as their emulators need a graphics context the host doesn't offer |
some things stay with the game boy advance for now. mgba can say which of the original's functions ran in each frame, and a libretro core can't, so on other consoles code ported counts only what the port names. the tool that reads a function's instructions knows only that console's.
three times the numbers looked fine and something was wrong. each was found by checking another way.
for runs that needed no function tracking i swapped my step-by-step loop for mgba's own, which is faster. the reports matched. but mgba's loop ends a frame one instruction sooner, and that small shift changes what the game does: from frame 46, 428 of the 780 frames in a title screen came out differently, and the pictures and console state the tool shows when asked came from those. only comparing every result, not just the reports, found it. both runs now use the same loop.
running a browser's graphics in its main process, not a process of their own, saves one process and about 18 MB. it also means that when the graphics can't start, the whole browser dies, in under a second. my computer has a working display and a working chip, so every test here passed. the first sealed agent task, in a sandbox with a display variable and nothing behind it, got a browser that didn't start. i worked around that by hiding the display from the browser. then the pull request's checks failed on github's machines in the same way, for a different reason, and i could reproduce it here by taking away a few things a desktop provides. that settled it. 18 MB isn't worth a tool that dies on someone else's computer, so the flag is gone, and both failures with it.
a title screen's check was taking ten seconds when the work was done in one and a half. every close was waiting out a limit, because a browser from a measuring run i had stopped was still alive, with the same profile. it had been running for 46 minutes at 160% of a core. the old tools left one behind in the same way: stop a proof partway and its browser just stays. by the same estimate as above, that one browser spent about 5.6 Wh, as much processor energy as 18 whole checks of sonic advance. it would have gone on until someone noticed.
a stopped tool now takes its browser with it, and if it was killed too hard to do that, the next run ends what it left. those ten seconds were also in my first set of results, so i threw them away and measured everything again. the numbers in this article are from that second set.
all of it, which is the only reason the rest is worth anything.
after a whole run of both games, the old tools and the new ones produced 265 results: the reports with their timing left out, every picture and every clip, and each game's progress. 0 of them differ, byte for byte, and the same holds for each smaller job on its own. sonic advance's run still ends with differences in both versions, since the port isn't finished, and they're the same differences at the same frames. the tools ask less and say the same.
one computer, each job alone in its own control group, while my other work went on around it.
each job ran in a systemd scope, so its processor time, its memory and what it wrote could be counted for it alone, with the kernel's own counters. time on the graphics chip came from the kernel's drm counters, and the card's power from its sensor. each window was measured in headless chrome on the real graphics chip, since a window that's covered on my screen stops drawing.
other work was running on the computer throughout, a load of about 4 to 7 of its 28 threads, so times are noisier than processor time. memory moves by about 30 MB between runs of the same job, partly because the count splits what's shared with whatever else is running. the site's own build, which i didn't change, went from 5.6 to 8.2 seconds, which gives an idea how far to trust a small difference. the short tools, the ones that finish in under a second and hold under 80 MB, didn't change at all, and i left them alone. the electricity is the one part that isn't measured directly, which is why its section says so.
real consoles, and a measure of the last wording.
the next step is the first real console: building its emulator, playing a real game's recording, and finding what the made-up one couldn't show. smaller things remain too. chrome still starts a helper for on-device models about three minutes into a long run, which holds 30 to 40 MB, and the flags i tried don't stop it.
the code is at github.com/NahuelGenchi/dreamports, this work is its pull request 530, and docs/CONSOLES.md there says how to add a console.
smaps_rollup, added up across every process of the job at each sample. what several processes share is split between them, so it isn't counted twice.