Doom · Volume 2

DOOM — What a Port Must Satisfy

The sibling dive on “Bad Apple!!” works its subject through three budgets: storage, bandwidth and decode. Doom needs five, and the two extra ones are the whole difference between the phenomena.

Memory is how much RAM the engine needs to hold the world and work on it. Storage is where the game data lives, which is a separate problem because the data is larger than the code and cannot be thrown away. Display bandwidth is how fast finished pixels can be moved to a screen. Those three have counterparts in the animation’s budgets, and they behave similarly.

The other two do not exist for a video at all. Input is the requirement that the machine be able to tell the engine what a human just did, which a device with no buttons cannot do without someone inventing a way. And timing is the requirement that the simulation advance at a fixed thirty-five steps a second irrespective of how long drawing takes, which a device with no clock cannot do at all.

A recorded animation has no state to be affected by input and no simulation whose rate could drift. Doom has both, and every port in this dive had to solve them.

2.1 The shim, and what it conceals

Almost every modern port begins from doomgeneric, which is itself a fork of fbDOOM, which descends from the community engines that the GPL relicensing made possible. Its contribution is to reduce a new machine to the smallest contract anyone has managed: its header declares five functions to implement and one pointer to read.

Figure 1 — The entire contract between the Doom engine and a new machine, as declared in doomgeneric's header. Four of the five functions are about time and input rather than graphics — which is exactly the p…
Figure 1 — The entire contract between the Doom engine and a new machine, as declared in doomgeneric's header. Four of the five functions are about time and input rather than graphics — which is exactly the part a video has no equivalent of. — Vector diagram generated by make_shim_diagram.py in this project, from doomgeneric.h in the doomgeneric repository.

DG_Init makes a display exist. DG_DrawFrame puts the contents of DG_ScreenBuffer onto it. DG_GetKey hands over one key event. DG_GetTicksMs reports milliseconds since launch. DG_SleepMs gives time back to the system. There is an optional sixth, DG_SetWindowTitle, which does not matter.

That is a remarkable piece of engineering economy, and it is why a port that would have taken a season in 1998 is now a weekend. It is also why so many ports are worse than they need to be, because the shim’s defaults are generous in a way that its targets are not. DOOMGENERIC_RESX and DOOMGENERIC_RESY default to 640 and 400, and pixel_t is a 32-bit word unless CMAP256 is defined, in which case it is a single byte. An unconfigured build therefore asks for a framebuffer of 640 × 400 × 4 bytes — 1,024,000 bytes — where the original game used 320 × 200 single-byte palette indices, or 64,000.

That is a factor of sixteen, incurred by accepting defaults, and it is four times the entire RAM of every microcontroller discussed below. The first act of a serious constrained port is to refuse the shim’s generosity.

2.2 Budget one: memory

The original does not ask politely. i_system.c sets mb_used = 6 and allocates mb_used*1024*1024 in one call at startup, managing the resulting zone heap itself. Six megabytes, claimed before the game has decided it needs anything.

Figure 2 — What the engine asks for against what the target actually has, on a logarithmic scale. The three microcontrollers hold roughly a quarter of a megabyte each; the default zone heap is twenty-four tim…
Figure 2 — What the engine asks for against what the target actually has, on a logarithmic scale. The three microcontrollers hold roughly a quarter of a megabyte each; the default zone heap is twenty-four times that. Everything in this volume lives in the gap. — Vector diagram generated by make_port_budget_svg.py in this project, from constants in the released source and figures published by the cited ports.

Against that, the targets. The Raspberry Pi Pico port has 264 kB of RAM and 2 MB of flash. The nRF52840 dongle port has 256 kB of RAM and 1 MB of internal flash. The video walkie-talkie port, on a TXW818 part, has 272 kB of on-chip SRAM. Each is roughly a twenty-fourth of what the released source takes without being asked.

Even the modern reference engine is too big. The Pico port’s author records that Chocolate Doom wants about 300 kB of static data plus around 700 kB of dynamic allocation — a megabyte, against 264 kB. The gap is not a matter of trimming.

What closes it is a catalogue of techniques that recur across independent projects, which is the sign that they are forced rather than chosen.

Shrink the heap and then shrink what goes in it. The nRF52840 port runs a zone of 113,600 bytes, up from 78,600 in its author’s previous project — the direction of travel being unusual, and a consequence of having restored engine limits that the earlier port had cut.

Compress the pointers. The same port replaces 32-bit pointers with 16-bit “short pointers” through much of the engine. On a part with a quarter-megabyte of RAM, an offset into a known region carries the same information as an address and costs half as much, and the engine holds a great many of them.

Pool the objects. Monsters, projectiles and scenery are allocated from fixed pools rather than from the general heap, which removes both the fragmentation and the bookkeeping.

Push everything immutable into flash. Data that never changes during play — level geometry, immutable lookup tables, floor and ceiling textures — is cached in internal flash and read in place rather than copied into RAM.

Restore the original limits rather than inventing new ones. The nRF52840 port returns MAXDRAWSEG to 192 and the visible-sprite count to 128 for vanilla levels. This is the detail that repays attention: the target is not “as small as possible” but “exactly as large as the original”, because the original’s limits are what the original’s levels were built against. A port that is more frugal than vanilla renders scenes vanilla would not have.

2.3 Budget two: storage, which is mostly not code

Doom’s engine is small. Doom’s game is not, and the game is the part that cannot be rewritten, because it is copyrighted data in a fixed format.

The shareware DOOM1.WAD is about 4 MB. A Raspberry Pi Pico has 2 MB of flash, from which the program must also come. The arithmetic does not work, and no amount of careful coding changes it, so every small port is a data-compression project wearing an engine.

The Pico port’s answer is a custom container its author calls WHD — for “Where’s Half the Data” — which fits the whole nine-level shareware game into 2 MB with room for the code, and which has the property that matters more than its ratio: it supports random access without decompressing into RAM. A scheme that compressed better but required unpacking a level into memory would be useless, because there is no memory to unpack into. The same constraint that governs the sibling dive’s microcontroller ports governs this one, and produces the same conclusion: the winning scheme is the one that is cheap to read, not the one that is smallest.

The nRF52840 port takes a different route, and an instructive one, because part of its conversion makes the data larger. Doom’s wall textures are assembled at run time from multiple overlapping patches; the port’s converter flattens them into single rectangular patches, which costs about 1.2 MB of additional size on the commercial DOOM.WAD and buys the elimination of all that run-time assembly. The converter also rewrites patch columns to carry their own length, so that a display transfer can be handed straight to a DMA engine. The port then keeps the converted WAD on 16 MB of external QSPI flash, and the interesting consequence is that reading it becomes a bandwidth problem in its own right: the author copies column data into RAM buffers for rendering specifically to avoid random access to QSPI mid-frame.

This is the general shape of the storage budget. It is not “make the data small”. It is “arrange the data so that the thing reading it can keep up”.

2.4 Budget three: getting the pixels out

Figure 3 — A Raspberry Pi Pico. The RP2040 port drives VGA directly out of the chip at 320 × 200 and 60 Hz, with two 320 × 168 buffers — enough for the view above the status bar, and no more.
Figure 3 — A Raspberry Pi Pico. The RP2040 port drives VGA directly out of the chip at 320 × 200 and 60 Hz, with two 320 × 168 buffers — enough for the view above the status bar, and no more. — File:Raspberry Pi Pico oblique.jpg by Phiarc. License: CC BY-SA 4.0. Via Wikimedia Commons.

A finished frame has to reach a screen, and on small hardware the path to the screen is frequently the binding constraint rather than the processor.

The nRF52840 port is the clearest demonstration. It drives a 240 × 240 ST7789 panel and reports 34.5 frames per second on its early levels — and its author is explicit that this figure is a cap imposed by SPI speed and display resolution, not by the software. The processor is finishing frames the display cannot accept. Deeper levels fall below it: 27.5 fps on E1M6, 22.1 fps on an Ultimate Doom level. The engine is the limit there; on the easy levels the wire is.

That port also shows what the budget costs in memory: double buffering with DMA-driven updates takes more than 56 kB per buffer, out of 256 kB total. A fifth of the machine’s RAM exists to keep the display fed.

The Pico port avoids the panel altogether by generating VGA directly from the chip, at 320 × 200 output and 60 Hz, holding two 320 × 168 framebuffers — which is the picture area above the status bar and nothing more, because there is no room for the rest. It reports generally 30 to 35 frames per second, with the processors overclocked to 270 MHz.

The most interesting case inverts the problem entirely. The Pinebuds Pro earbud port runs the engine on the earbuds’ own Cortex-M4F, pushed from 100 MHz to 300 MHz with low-power mode disabled — and then has nowhere to put the picture, because an earbud has no screen. Its author’s solution is to compress each frame as MJPEG and push it out of the chip’s UART at 2.4 megabits per second, up to 18 frames per second at 320 × 200, to a web server running elsewhere that also feeds keypresses back in. The computation is entirely local; the display is entirely remote. Volume three has to have a category for that, and does.

At the other end, when the display path is bad enough it stops mattering how well the engine runs. The Sansa Clip port, built on the Rockbox firmware’s Rockdoom plugin, is described in its own coverage as unplayable: the player’s monochrome screen is dithered in an attempt to convey the picture, and the result is that little beyond the weapon at the bottom of the frame can be made out. The engine is running correctly. Nobody can see it.

2.5 Budget four: input, which has no counterpart in a video

DG_GetKey hands the engine one key event at a time. On a device with a keyboard this is trivial. Most of the devices in this dive do not have one, and what the porters do instead is one of the more revealing parts of the subject, because it is where the demonstration stops being about capability and starts being about ergonomics.

The Doom brick — an RP2040 and a small OLED cast in resin into the shape of a LEGO brick, built by James Brown — is steered by tilting it, using an accelerometer, and fires on capacitive touch. There is no other surface to use.

Husqvarna’s official Automower port turns with the mower’s control knob, moves forward on the start button, and fires when the knob is pressed.

The walkie-talkie port uses the radio’s side buttons, which its coverage notes are not conducive to gaming.

The earbuds accept keypresses over the network from a machine that has a keyboard, which is an honest admission that the input budget was not met locally and was exported.

These are not footnotes. Input is the budget that distinguishes a device that is running Doom from a device that is displaying Doom, and a port that cannot accept input has, in a strict sense, not finished. Volume three treats it as one of the four questions that decide a classification, and volume four shows what happens when the question goes unasked.

2.6 Budget five: time

TICRATE is 35, in doomdef.h, and everything follows from it. The simulation advances in thirty-fifths of a second. Powerup durations are written in the source as multiples of it. Network play depends on all participants agreeing about it.

This is why the frame rates quoted throughout this dive cluster where they do. The nRF52840 port’s cap is 34.5; the Pico port’s range is 30 to 35-plus. Ports do not aim at 60 because there is nothing above 35 to render — the world does not change more often than that. A video has no such structure: a Bad Apple port can be played at any rate one likes and will merely look fast or slow. A Doom port that gets the tick rate wrong plays a different game.

The shim’s provision for this is DG_GetTicksMs, and it is the only clock the engine gets. A device without a timer must synthesise one. The sibling dive’s Apple II port solves the equivalent problem by clocking playback from the rotation speed of a floppy drive, which works because nothing about the animation depends on the interval being correct — only on it being steady. That substitution is not available here. Doom’s tick has to be right, not merely regular, because the simulation’s behaviour is defined in terms of it.

2.7 When the budgets do not bind at all

A large fraction of famous “Doom ports” satisfy every budget above trivially, because the device contains an ordinary computer.

The ATM that ran Doom was running Windows XP, and the work consisted of wiring its front-panel buttons to an I-PAC2 keyboard-emulation board. The Mediwatch ultrasound scanner is an AMD Geode running Windows XP Embedded from a CompactFlash card. The kitchen bump bar booted into DOS and ran the game, slowly, with no porting whatsoever.

None of these required a port in any technical sense. They required access. That does not make them uninteresting — they are excellent demonstrations of what is actually inside ordinary appliances, and in the tractor’s case a serious right-to-repair argument — but the engineering content is a security exercise rather than a systems one, and volume three files them separately for that reason.

And at the far end sits the case where a budget fails by so many orders of magnitude that the claim collapses under its own weight. CSS-DOS is a 300 MB stylesheet by Ahmed Amer that simulates an 8086 with 640 kB of RAM and VGA, executing roughly two instructions per second. Its coverage estimates three weeks to boot DOS and three months to load a level, and puts the resulting frame rate at about 0.0001 fps. Every budget in this volume is satisfied in principle and none is satisfied in practice. The article’s own verdict — that calling this “runs Doom” is an exaggeration — is the right one, and the interesting question it raises is where exactly the line sits. That is volume three’s problem.

2.8 What gets dropped, and in what order

A useful way to read a constrained port is to ask what its author gave up, because the order is remarkably consistent across projects that had no contact with each other.

Music goes first. The nRF52840 port’s repository lists music as absent. The iCE40 FPGA port by Sylvain Munaut has no music either, its coverage noting that MIDI output could be added but was not. Music is the easiest thing to lose because nothing depends on it: no other subsystem consults it, and its absence changes nothing about what is on screen.

Sound effects go second, and sometimes with music. Both of the firmware-level ports covered in Doom running from the BIOS — the coreboot payload by nic3-14159 and the UEFI application by Warfish and Cacodemon345 — lack sound, because at that layer there is no audio stack to call and writing one is a larger project than the port itself.

Multiplayer goes third. The nRF52840 port does not implement it. It is expendable because it is the one feature whose absence a single player never notices.

Demo compatibility goes fourth, and this is the meaningful loss. The same port restricts demo playback to the built-in demos of DOOM1.WAD v1.9. As volume one set out, demo playback is the field’s only real correctness test, so a port that abandons it has also abandoned the evidence that it computes what the original computed.

Against that ordering, the Raspberry Pi Pico port is the outlier that proves the rule, because its author refused the entire list. It keeps nine playable levels, faithful graphics, nine-channel OPL2 music synthesis at 49,716 Hz, eight channels of stereo sound effects with ADPCM compression, four-player networking over I2C, and demo-verified compatibility with Doom, Ultimate Doom and Doom II — on 264 kB of RAM. That is why it is the reference achievement in this field and why so much of this volume cites it: it demonstrates that the usual amputations are choices about effort rather than consequences of physics.

What none of these ports drop is the simulation. Music, sound, networking and demos can all go; the world model, the input path and the tick cannot, because without them there is nothing left that is recognisably the program. That is the floor, and where a claimed port falls below it the claim has changed into something else — which is volume four’s subject.

2.9 What the budgets establish

Meeting all five budgets is a strong claim about a machine. It means the device has enough memory to hold a mutable world, enough storage to carry the data or enough cleverness to compress it, a path to a display fast enough to matter, a way to learn what a human wants, and a clock it can trust.

That is a much narrower set of devices than can be made to emit a pattern on a schedule, and it is why the two phenomena in these sibling dives are not the same test. But meeting the budgets is not the same as the headline being true, because in a startling number of celebrated cases the budgets were met by some other machine than the one named in the headline. Volume three sets out how to tell.

Comments (0)

  1. Loading…

Comments are held for moderation — nothing appears until approved.