Berglet / go deeper
Go deeper.
You don't need this page to enjoy Berglet. It's here because the simple outside rests on something, and you asked.
Numbers measured on Berglet: ESP32-S3, 8 MB flash, 8 MB PSRAM, October 2026. Scroll. It keeps going.
−4.1 Loadable players
The system doesn't know what it can run
The system (the launcher) is one firmware image. Every player (an emulator, a language runtime, the WebAssembly runner) is a separate signed file on the memory card.
Pick a game and the launcher loads the right player, either as a small module into the running system or as a whole image copied into flash (only if it changed). The player gets the whole chip. When you quit, you're back in the library. If it crashes, you land in the launcher, which tells you which game did it.
The launcher has no table of systems. Each image carries a manifest saying what it plays and how to recognise those files; the library is built from whatever is on the card. Adding a system means dropping a file.
- Players
- About 25, covering some 26 kinds of game
- Where
system/cores/<id>.binon the card- Shared by all
- Display, sound mixer, buttons, saves, the quick menu, power: one runtime layer every player is built on
- Trust
- System, players and modules are signed. The console only runs them if they come from an author it trusts.
−4.2 The translators
Rewriting other processors' programs, live
An interpreter reads a foreign instruction, works out what it means, does it, and repeats. On a 240 MHz microcontroller that is often too slow. So for four processors the console does the other thing: it translates blocks of the game's code into Xtensa machine code, the chip's own, and runs that.
| From | For | How | Measured |
|---|---|---|---|
| ARM7TDMI | Game Boy Advance games | Dynamic recompiler, with the picture finished on the other core | 99–100% |
| Thumb (Cortex-M0+) | Gamebuino META | Translated ahead of play; sound interrupts handled natively in batches | 117–284% |
| AVR | Arduboy, Gamebuino Classic | Translated as each part of a game is first reached | 113–138% |
| AVR, clock-exact | Uzebox, whose video signal is made by the program itself | Counts processor clocks statically, so every write to the video port knows its pixel | 36% → 122–135% |
| 65816 | Super Nintendo | Works. Is exact. Is slower than the interpreter. | off |
How you know a translator is right
You run the same program both ways and compare everything. For the Uzebox translator: 28 programs on a desktop harness, interpreter against translator, with the machine's state, the picture, the sound samples and the saved data compared after every frame for 2,500 to 12,000 frames each, in nine set-ups. No difference.
Where the code goes
Translated code lands in 65 KB of internal RAM first. What doesn't fit and runs often goes to eight 120 KB segments in PSRAM. When a game moves on to something else, everything is thrown away and translated again.
The one that lost
The 65816 translator was checked against the interpreter on 62 test programs, and on the console across 5.4 million instructions of a real game. Identical. It takes 15.6 ms a frame where the interpreter takes about 12.
The reason, measured: that processor only runs some 10,000 instructions a frame, and translated code sitting in PSRAM pays a cache miss about as large as the work it saves. So it is compiled in, switched off, and kept, because being wrong about performance is information too.
−4.3 32 kilobytes
Most of the speed is about where things live
The chip's two cores share one 32 KB instruction cache and one 64 KB data cache, sitting in front of slow flash and slow PSRAM. Most answers to “why is this slower than it should be” were there. Two cores working at once slow each other down through it: drawing a Game Boy Advance picture took 5 ms alone and 8 to 9 ms beside the running game.
So the work is placement. Hot loops run from internal RAM. Pictures are drawn a line at a time into a small buffer instead of a frame in PSRAM, and handed to the display row by row.
- Mega Drive
- The 68000's two per-instruction tables (256 KB of handlers, 64 KB of cycle counts, both in flash) became one 16-bit entry per opcode in front of two small tables in internal RAM. 30 pictures a second shown became 60.
- WonderSwan
- The game's side only notes the display registers per line; a task on the other core draws from the notes. A frame's drawing went from 16.5 ms to 7.4.
- Super Nintendo
- The picture is drawn by a second copy of the picture chip, fed by a log of register writes, so the emulation never waits for drawing. Both cores take turns playing the log back.
- A bug found on the way
- Blank tiles were being converted again every time they were drawn. A third of the drawing time on a mostly empty screen.
−4.4 Games as modules
Three ways to be a game
A script. Lua 5.3 or MicroPython over the game kit. The editor compiles to bytecode in your browser (the two compilers are themselves built to WebAssembly) and ships it beside the source; the console only trusts bytecode whose checksum matches the source next to it.
A WebAssembly module. It imports from console and nothing else, exports update(), and tells the console where its frame is in its own memory. Every pointer it passes is checked. Save states and resuming after power-off work with no code in the game, because the runner simply keeps the module's memory.
Firmware. The game is built with the platform layer into its own image, exactly like a player that runs one game. It may call the chip's SDK and its real-time kernel directly. The quick menu, power and saves still belong to the system.
| Small benchmark | Interpreted | Compiled |
|---|---|---|
| 400,000 rounds of an integer loop | 417 ms | 20 ms |
| 75,000 calls | 215 ms | 18 ms |
| 512 KB of byte reads and writes | 289 ms | 34 ms |
- Limits
- 2 MB of memory, a 2 MB module, 64 images and 32 sounds at once
- Watchdog
- An
update()that runs for 5 seconds is ended: “The game stopped responding”
−4.5 Save without starting over
Changing a program that is running
When you save in the editor, the file's top-level code is run again inside the running game, and a set of rules decides what that means:
- Functions are replaced from the next frame.
- Variables keep the value the game has given them, unless you changed the value the code gives them.
speed = 100edited to 150 becomes 150. Left at 100 while the game has made it 130, it stays 130. - Sprites are matched with the one the same line made last time, and treated value by value.
- Lists are compared by a fingerprint of their contents: untouched in the code, the game's list stays; edited, yours replaces it.
- If the new code stops on a mistake, everything it changed is put back.
- The mirror
- A whole frame is 134,400 bytes, and the console's Wi-Fi carries about three of those a second. So the editor asks only for what changed on the screen since the picture it has.
- The protocol
- Plain HTTP on the local network, about a dozen requests: files, run, stop, log, screen, button. Small enough to read in one sitting, and a stand-in console for desktops speaks the same one.
−4.6 Fifteen pins
The hardware is out of pins, on purpose
The processor module has fifteen usable pins. A display, a memory card, a sound amplifier, twelve buttons, a backlight, a power switch and a battery gauge want more than that. So pins do two or three jobs.
The three button-matrix columns are also the display's chip-select, the display's data/command line, and the memory card's chip-select. It works because those are only ever outputs towards those chips and rest high. The card's line goes through a diode: without it, a powered-down card would drag the Start button's line low, and Start is the button that wakes the console, so it would wake itself up forever.
- One bus
- Display and memory card share a single SPI bus, run at 80 MHz
- No tearing pin
- The display has no signal to say when it is between frames. Timing is done without it.
- Sleep
- A transistor cuts power to the display, the card and the amplifier. Only the Start button's line is watched.
- Not measured
- Sleep current. So: no battery-life figure, anywhere on this site.
−4.7 How fast
What runs at full speed
Measured on the console itself, mostly on title screens, demos and free homebrew. “Shown” is how many of the game's pictures per second reach the screen.
| Kind of game file | Measured | In plain words |
|---|---|---|
| Game Boy and Game Boy Color, NES, Master System, Game Gear | 61 / 61 | Full speed |
| Mega Drive, PC Engine | 60 / 60 | Full speed |
| Neo Geo Pocket, Atari 2600 and 7800, Lynx | 60 / 60 | Full speed (test programs) |
| WonderSwan | 75 / 75 | Full speed |
| Game Boy Advance | 99–100% | Full speed in the homebrew measured; a few frames drop while new code is first translated |
| Super Nintendo, light games and homebrew | 60 / 60 | Full speed |
| Super Nintendo, heavy scenes | 60 / 15–47 | Right speed inside, fewer pictures shown: rotating-floor racing 38–47 of 60, a cartridge with its own 3D chip 15–22 |
| Arduboy, Gamebuino Classic and META | 113–284% | Faster than the originals need |
| Uzebox | 122–135% | Full speed in the games tried |
| PICO-8 carts, WASM-4, LowRes NX | 60 / 60 | Full speed (test carts) |
| TIC-80 | 43–60 | Light carts at 60; carts heavy on Lua at about 43 |
| Doom WADs | 29–35 of 35 | Mostly full speed (Freedoom's demo) |
| CHIP-8 | – | Built into the system |
System names are trademarks of their owners, used only to say which game files Berglet reads. Games for other systems are not included.
−4.8 Still here?
A shell on the USB port
shot, burst, press, hold, ls, put, rmr, mem. Screenshots and button presses from a script, which is how most of the numbers on this page were taken. There is a toy copy on this site: press ~.
A sampling profiler
For either processor core of any player, switched on by a folder's name on the card. It was once left on by accident: a 4 kHz interrupt in every game. That is in the notes too.
What we're still tuning
The heaviest Super Nintendo scenes still draw too few pictures, and TIC-80 carts that lean hard on Lua run at about 43 of 60. The whole table.
That's the bottom, for now. It keeps moving.