A single .xlsm file you can email to someone. They open it, enable macros, and the
real Doom engine runs inside the spreadsheet — it auto-plays about a second after the
workbook opens (there's a PLAY DOOM button too).
out/DOOM.xlsm — 3.65 MB, self-contained. No installer, no runtime, no
internet, nothing left behind.
▶ Download it and play — Windows with desktop Excel 2010+. If it arrived from the internet, right-click the file → Properties → tick Unblock first, then open and click Enable Content. (Why? It scans clean on VirusTotal.)
This is the Excel counterpart to Wojciech Graj's 2025 Word port (doom-docm), rebuilt for Excel with a cleaner engine API, working strafe/run controls, sound and music with a mute toggle, an exit path, error handling, and both 32- and 64-bit Office support.
Yes, and the distinction matters — this is not a raycaster lookalike and not a formula trick. (Those are real frames the engine drew and handed to Excel — E1M1 with its nukage pool, the title screen, the menu.)
| Engine | doomgeneric (Chocolate Doom lineage) compiled to a Windows DLL |
| Game data | DOOM1.WAD, MD5 f0cefca49926d00903cf57551d901abe — the canonical shareware v1.9 IWAD, 4,196,020 bytes |
| Who simulates | The DLL. P_Ticker, R_RenderPlayerView, the BSP walk, the sprite clipper — all of it, unmodified |
| What Excel does | Calls DoomTick(), then puts the bitmap the engine's renderer produced onto the sheet |
| Resolution | 640×400 (Doom's native 320×200 at the engine's own 2× integer scale) |
| Measured | 35 fps in real Excel (Doom's native tick rate), frames paced at 28.5 ms (σ 6.6) — from the built-in DoomSpeedTest (Alt+F8) |
Every frame follows the same path:
VBA: DoomTick()
-> doomgeneric_Tick()
-> TryRunTics() real game logic, paced at Doom's native 35 Hz
-> D_Display()
-> R_RenderPlayerView()
-> I_FinishUpdate() -> DG_DrawFrame() our backend: framebuffer -> BMP
VBA: img.Picture = LoadPicture(frame.bmp)
VBA: DoEvents
Excel is the window and the input device. It computes nothing.
Not the first Doom in Excel — and it's worth being precise, because a loose claim collapses on one search:
- DOOM-in-excel already ran the real doomgeneric engine in Excel, painting cells — but it needs the paid PyXLL add-in plus a Python install.
- doom-docm is the same embedded-DLL technique, but in Word, and with no sound.
- Linux-in-Excel runs native code from VBA in Excel — but it boots Linux in an emulator, isn't Doom, and isn't self-contained.
What's new here is the conjunction: the first self-contained Excel file that is
Doom — a single emailable .xlsm with the real engine and IWAD sealed inside,
nothing to install, pure VBA, rendering to an in-sheet picture, with sound. Drop
any one of those qualifiers and it's someone else's project.
VirusTotal: zero detections on every file it ships — scanned across the major engines, nothing flags any of them as malicious, even though the workbook deliberately does something that looks like malware (a document that unpacks a program to disk and runs it):
| Scanned | Result |
|---|---|
DOOM.xlsm — the workbook |
0 / 64 |
doomxl-x64.dll — the 64-bit engine it unpacks |
0 / 71 |
doomxl-x86.dll — the 32-bit engine |
0 / 71 |
The deeper reason it's safe is that the entire source is in this repo — you can read exactly what it does:
- On play, the VBA base64-decodes the engine DLL and
DOOM1.WADinto%TEMP%\DoomXL,LoadLibrarys the DLL, and calls into it. That is the whole trick. - Nothing hidden, nothing installed: no network access, no registry changes, no
autostart. Delete
%TEMP%\DoomXL(or runCleanUpTempFilesfrom Alt+F8) and every trace is gone; the DLL unloads when Excel closes.
One honest caveat: a clean VirusTotal sheet is about signature detection. Your machine's real-time protection or SmartScreen may still be wary of an unsigned DLL it has never seen before — that's a reputation prompt on first run, not a malware verdict.
Verify the file you have is the official build (SHA-256):
| File | SHA-256 |
|---|---|
DOOM.xlsm |
619027e3fbe9ccdb3bf3a691d931b83dc18d2d2ff979eff58c9deb8d9ed50cc2 |
doomxl-x64.dll (what it unpacks on 64-bit Office) |
56aed0eb6e328d640535516740122db58cdde9c264ceaec33e73c64b3bc14c6b |
doomxl-x86.dll (32-bit Office) |
984cc9a5d4eac2b3a9b4529aa669eb768229ba0d18a35b42ec4814683350ac8b |
Or skip the trust question entirely and build it yourself: powershell -File build.ps1
reproduces the workbook from this source.
src/
doomgeneric_xlsm.c the platform backend + the C API VBA calls
dg_panic.h / .c keeps a fatal Doom error from killing Excel
testdoom.c headless harness that drives the DLL without Excel
doomgeneric_headless.c diagnostic-only POSIX backend
tools/
vba/ hand-written VBA (ThisWorkbook, host, base64, UI)
pylib/msovba.py MS-OVBA compression, both directions
pylib/cfb.py compound-file writer
pylib/vbaproject.py vbaProject.bin generator
build_xlsm.py assembles the workbook
verify_xlsm.py 66 structural checks on the result
bmp2png.py look at a rendered frame
build/
Dockerfile mingw-w64 cross-compiler
build-dll.sh builds both DLLs
Dockerfile.native diagnostic: same engine built for Linux
Dockerfile.wine* diagnostic: run the Windows DLL headless
docs/
SEND-TO-FRIENDS.md what to tell people, and why AV will complain
frames/ frames captured out of the running engine
build.ps1 one command: compile, assemble, verify
out/
DOOM.xlsm ← the deliverable
Everything runs in Docker, so the only prerequisites are Docker and Python 3.
docker build -t doomxlsm-build:latest build/
docker run --rm -v C:\Dev\Doom:/work -w /work doomxlsm-build:latest bash build/build-dll.sh
python tools/build_xlsm.py
python tools/verify_xlsm.pyOr powershell -File build.ps1, which does all four and stops on the first failure.
To build at 320×200 instead (roughly 4× less per-frame work):
docker run --rm -e RESX=320 -e RESY=200 -v C:\Dev\Doom:/work -w /work \
doomxlsm-build:latest bash build/build-dll.sh
python tools/build_xlsm.py --resx 320 --resy 200To use Freedoom instead of the shareware IWAD: python tools/build_xlsm.py --wad path/to/freedoom1.wad.
Note Freedoom is ~26 MB, which makes the workbook too big to email.
The engine is vendored unmodified and patched at build time by three small, asserted edits — the build fails loudly if a future doomgeneric moves the code.
exit()never terminates Excel.dg_panic.his force-included into every translation unit with-include, so everyexit()in the engine becomesdg_exit(), whichlongjmps back to the host entry point that armed it. Doom callsexit()on a missing IWAD, on anyI_Error, and when you quit from its own menu; inside a DLL that would take Excel and your other workbooks down with it.i_system.c— one line, so the text of a fatal error is kept for the host to show. Stock Doom only writes it tostderr, which is invisible in Excel.d_main.c— one line, sodefault.cfgand savegames go to the directory the host chose. Excel's working directory is frequently not writable.
dg_panic.h also replaces the CRT's locale-aware strncasecmp with an
ASCII-only one. WAD names are ASCII, the comparison sits in the texture-lookup
hot path, and this removes a CRT dependency that turned out to deadlock under
Wine.
__stdcall, undecorated (-Wl,--kill-at), so Declare PtrSafe binds by plain
name on both architectures.
| Export | Purpose |
|---|---|
DoomStart(iwad, workDir, flags) |
Unpack-and-go. 0 = fresh start, 1 = already running, negative = failed |
DoomTick() |
Runs one real Doom frame. Returns which of 4 rotating BMP slots is current |
DoomGetFramePath(slot, buf, cch) |
Path of a slot's bitmap |
DoomLastError(buf, cch) |
Text of the last engine error |
DoomGetFps() / DoomFrameCount() / DoomGetTitle() |
Status |
DoomWantsQuit() / DoomClearQuit() |
F10 handling |
DoomSuspend() |
Leave the loop, release the keyboard, keep the game state |
DoomShutdown() |
Release the keyboard and delete the frame files |
The DLLs and the WAD live as base64 text inside generated VBA modules, the same technique as the Word port, with the same two constants — both of which exist to stay inside hard VBA limits:
- 738 raw bytes per chunk → 984 base64 characters → a 1005-character line,
under VBA's 1023-character limit. 738 is a multiple of 3, so only the last
chunk of each payload carries
=padding. - 1024
Putstatements per procedure, under VBA's 64 KB-per-procedure limit.
That is 7,140 Put statements over 6.89 MB of VBA source, which MS-OVBA
compresses to a 4.22 MB vbaProject.bin and Zip deflates to a 3.65 MB workbook.
vbaProject.bin is generated from scratch — there is no library that writes one,
so tools/pylib/ contains an MS-OVBA compressor, a compound-file writer, and a
dir-stream generator. The project-identity fields (ID, CMG, DPB, GC) and
the two type-library references are copied verbatim from a real unprotected
Office project (XlsxWriter's BSD-2 test fixture). They must travel together: the
obfuscation key for CMG/DPB/GC is derived from the project ID, so mixing a
fresh ID with those blobs would decode to garbage and Excel could read it as a
password-locked project.
_VBA_PROJECT declares version 0xFFFF, which Excel does not recognise, so it
discards the (absent) performance cache and recompiles every module from source.
Input is captured in the DLL, twice over. A WH_KEYBOARD_LL hook swallows
keystrokes while the game runs and Excel is in the foreground — without it, Ctrl
(fire) plus a movement key fires Excel shortcuts, and Ctrl+W closes the workbook.
Key state comes from GetAsyncKeyState polling inside DoomTick, which cannot
be starved by a busy message pump, so input survives even if the hook is delayed
or fails to install. The hook self-uninstalls if the host stops calling
DoomTick for 3 seconds, so a VBA crash can never leave the keyboard dead.
Several physical keys map to one Doom key (W and Up are both KEY_UPARROW),
so presses are refcounted — otherwise releasing Up would stop movement while W is
still held.
Frames rotate over four filenames so LoadPicture can never collide with the
write of the next frame.
Buttons get their macro bound at runtime in Workbook_Open rather than via a
macro= attribute in the drawing XML — hand-written XML cannot get it subtly
wrong, and it cannot fire before macros are enabled.
The viewport is an MSForms.Image control created at runtime, so the shipped
file contains no ActiveX. If the Trust Center blocks controls, the code falls
back to a picture-filled shape.
Audio is split in two on purpose. dg_wave.c and dg_midi.c are pure Win32
and include no Doom header; i_dgsound.c is pure Doom and includes no
<windows.h>. They have to be separated because doomtype.h and <windows.h>
both define boolean, at different widths, and cannot coexist in one
translation unit. The same clash is why the mouse path in the backend mirrors
two declarations from d_event.h behind compile-time size assertions rather
than including it.
Sound effects are DMX lumps — an 8-byte header, then unsigned 8-bit mono PCM. Measured across the shareware IWAD: 54 lumps at 11025 Hz and one at 22050, so the mixer resamples with a 16.16 fixed-point step. Each lump is padded at both ends with 16 copies of the edge sample, which are stripped; leaving them in puts an audible click on every sound.
The engine is verified headlessly, and the whole workbook is now verified in real
Excel — played end to end, with the built-in DoomSpeedTest reporting Doom's full
35 fps and even frame pacing.
Verified in real Excel (Microsoft 365, 64-bit): E1M1 played through with sound;
DoomSpeedTest reports engineFps 35 and a 28.5 ms frame gap (σ 6.6) — full speed
and smooth — with the mute toggle, runtime screen-fit and pause/resume all working.
Verified headlessly, by running the DLL:
- The Windows x64 DLL, driven by
testdoom.exeexactly as VBA drives it —LoadLibraryby absolute path,GetProcAddress,DoomStart,DoomTick— rendered 442 frames at 34 fps, 204 of them distinct. - The 32-bit DLL passes the same harness under 32-bit Wine: 242 frames at 27.7 fps. Both architectures shipped in the workbook are runtime-verified.
- Soak: 1,542 frames over 50 seconds at a sustained 29.7 fps, 1,277 of them distinct, running through several attract demos with no crash and no degradation.
- Suspend/resume — what the STOP and PLAY buttons do — returns 1 (resumed, not restarted) and carries on rendering with the game state intact.
- The frames are real Doom.
docs/frames/sample_010.pngis the title screen;sample_399.pngis E1M1 attract-demo gameplay with the shotgun pickup, an imp, a fireball and the full status bar. - Input reaches the game. The harness injects an Escape keystroke into the
same queue the keyboard feeds, and
docs/frames/sample_menu.pngshows Doom's main menu opening in response. That exercises the whole chain below the OS: queue →DG_GetKey→I_GetEvent→M_Responder→ renderer. - Both DLLs import only
KERNEL32,msvcrtandUSER32, so there is no third-party runtime to go missing on someone else's machine. - The same engine sources build and run natively on Linux at
-O0,-Osand-O2, as a control.
Content: the WAD's own directory lists E1M1–E1M9 — the complete Episode 1,
Knee-Deep in the Dead, including the secret level — with 483 sprite lumps, 167
patches, 56 flats, 55 sounds and the three attract demos. That is the whole
shareware game as id shipped it, not all of retail Doom.
Verified structurally (tools/verify_xlsm.py, 74 checks):
- Every OPC part Excel requires is present and every XML part parses.
vbaProject.binis read back by olefile — an independent implementation, not the writer that produced it — and every module stream decompresses to exactly the source that went in.- No VBA line exceeds 1023 characters; no procedure exceeds 1024 statements.
- Decoding the base64 the same way the VBA will, in the same order, reproduces both DLLs and the WAD with matching SHA-256.
- Cross-layer: every sheet, shape and
OnActiontarget the VBA names actually exists in the sheet XML — a typo there would fail silently at runtime.
Verified by third-party tools (oletools): olevba 0.60.2, oleid 0.60.1 and
olefile 0.47 — three independent implementations, written by malware analysts
to read real Office files — all parse the generated project cleanly. They find
exactly the 8 expected module streams, decompress every one including the 5.7 MB
modDoomWad, and report no corruption, truncation or malformed-stream warnings.
Notably olevba classifies ThisWorkbook as a document module and the other
seven as procedural purely from the PROJECT and dir records, which means
those records are not merely parseable but semantically right.
oleid rates the file "VBA Macros: Yes, suspicious (HIGH)". That is the expected
verdict for a macro that calls Shell, CreateObject, Environ and Lib, and
it is a fair preview of how antivirus will react — see
docs/SEND-TO-FRIENDS.md.
Verified by LibreOffice Calc 7.4.7 (shares no code with Microsoft's
implementation): it imported the workbook with no repair prompt and no oox,
vba or basic warnings, and its ODS output contains a Basic/DoomProject/
library listing all 8 modules. Reaching that point requires LibreOffice's own
MS-OVBA code to walk the compound file, parse PROJECT, decompress the dir
stream and RLE-decompress every module at its MODULEOFFSET. The recovered
source is byte-identical to the files in tools/vba/, and the base64 in the
generated modules decodes to exactly the declared payload sizes.
That test was run with negative controls, which matters: a truncated
vbaProject.bin yields zero modules and a corrupted one yields truncated
modules, while LibreOffice still exits 0 in both cases. The pass is the module
recovery, not the exit code.
One deliberate difference from what Excel itself would write: the sheets carry no
codeName, because the project declares exactly one document module
(ThisWorkbook) and seven standard modules. Excel accepts this and adds sheet
code names itself on first save.
Everything above passed while the workbook was still broken, so it is worth recording what none of it caught.
Excel refused to compile the project with "Sub or Function not defined" on
the call into modDoomDll64. The cause was not that module: it was the MS-OVBA
compressor. When a 4096-byte chunk will not compress, the format says to store
it verbatim with CompressedChunkFlag = 0, and that is what the compressor did.
Excel's VBA loader rejects any module containing such a chunk, and then
reports the failure at the call site in a different module, which points
investigation in entirely the wrong direction.
The correlation was exact — the only three modules Excel rejected were the only three containing raw chunks:
| module | compressed chunks | raw chunks | compiled |
|---|---|---|---|
| ThisWorkbook, dir, modDoomB64, modDoomHost, modDoomManifest, modDoomUI | all | 0 | yes |
| modDoomDll64 | 150 | 2 | no |
| modDoomDll32 | 147 | 7 | no |
| modDoomWad | 1332 | 66 | no |
Nothing else could have found this. olefile, olevba and LibreOffice all read
raw chunks correctly, so every independent validator passed the broken file.
Probe workbooks did not reproduce it either, because their synthetic payloads
compressed perfectly and never took the raw path — only real, already-compressed
binaries do.
The fix is in compress(): never emit a raw chunk. Incompressible data now
shrinks the decompressed chunk until its token stream fits in 4096 bytes. A
chunk of pure literals costs n + ceil(n/8) bytes, so 3640 is the largest size
whose worst case still fits. verify_xlsm.py now asserts zero raw chunks so
this cannot come back.
Microsoft Excel 16.0 (ClickToRun x64, build 20228) opens the workbook, reports
HasVBProject = True, compiles the whole VBA project, and runs WireButtons,
which binds all three buttons to their macros:
README/btnPlay onAction='PlayDoom'
DOOM/btnPlay2 onAction='PlayDoom'
DOOM/btnStop onAction='StopDoom'
Not verified: the game loop itself end-to-end inside Excel — extraction,
LoadLibrary, the ActiveX viewport and the DoEvents loop. Given
the VBA project now passes three independent readers, the remaining risk is
mostly in the runtime behaviour of the host code — OLEObjects.Add,
LoadPicture, and the DoEvents loop — rather than in the file format.
# engine, 64-bit: renders frames, taps Escape, checks suspend/resume
docker build -q -t doomxlsm-wine2 -f build/Dockerfile.wine2 build/
docker run --rm -v doomwine2:/wine -e WINEPREFIX=/wine -v C:\Dev\Doom:/work -w /work \
doomxlsm-wine2 bash -c 'cd /work/build/soak && cp /work/out/doomxl-x64.dll doomxl.dll \
&& cp /work/out/testdoom-x64.exe . && cp /work/wad/DOOM1.WAD . \
&& wine testdoom-x64.exe DOOM1.WAD . 400 6'Raise the tick count for a longer soak. Pass 2 instead of 6 as the last
argument to capture the engine's own log to doomxl.log instead of the console.
python tools/bmp2png.py sample_399.bmp out.png turns any captured frame into
something you can look at.
python tools/verify_xlsm.py # 74 structural checks on out/DOOM.xlsmbuild/lo-test/ holds the LibreOffice validation harness, including
negative-control.sh, which deliberately damages copies of the workbook to
confirm the test can actually tell a good file from a bad one. Worth re-running
after any change to the generator.
-
Sound and music both work (unlike the Word port, which has neither). Effects are mixed in software and pushed through
waveOut; music is converted from Doom's MUS format to MIDI with the engine's ownmus2midand played by the Windows sequencer. Volume comes from Doom's own menu. -
One game per Excel session. The engine cannot be re-initialised in-process, so Stop/Play resumes where you left off; a genuine restart means restarting Excel.
-
Windows only, Excel 2010 or later (VBA7). Both 32- and 64-bit Office work.
-
Antivirus may object. A spreadsheet that unpacks a DLL and loads it is textbook malware behaviour;
oleidalready rates the file "suspicious (HIGH)". Defender or a mail gateway may quarantine it. There is no fix short of a code-signing certificate.Smart App Control is not a blocker, contrary to what you might expect. Measured on an enforcing machine (25H2, build 26200.8875,
VerifiedAndReputablePolicyState = 1): SAC refuses to launch an unsigned.exe, butLoadLibraryon this unsigned DLL succeeded — with and without a Mark-of-the-Web. SAC gates process creation, not DLL loads into an already trusted process. And since a recent cumulative update it can be switched off and back on from Windows Security without reinstalling Windows anyway. -
Files that arrive by email are marked with the Mark of the Web, and Excel then refuses to run macros with no prompt at all. The recipient must right-click the file → Properties → Unblock.
- doomgeneric / Doom source — GPL-2.0. The engine changes above are described in full and the build applies them from vendored, unmodified sources.
- DOOM1.WAD — id Software's shareware IWAD, freely redistributable under id's shareware terms as an unmodified copy. DOOM is a trademark of id Software LLC; this is a hobby port with no affiliation.
- VBA project skeleton fields — from XlsxWriter's test fixture, BSD-2-Clause
(
ref/LICENSE.XlsxWriter.txt). - Because the workbook embeds a GPL-2.0 engine, distributing it carries the GPL's source-availability obligation. This repository is that source.
