Skip to content

Latest commit

 

History

5 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

DOOM in Microsoft Excel

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.


Is this really Doom?

Real frames the engine rendered and handed to the sheet — E1M1, the title, the menu

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.


Is it the first?

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.


Is it safe?

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.WAD into %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 run CleanUpTempFiles from 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.


Layout

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

Building it

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.py

Or 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 200

To 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.

Engine changes

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.

  1. exit() never terminates Excel. dg_panic.h is force-included into every translation unit with -include, so every exit() in the engine becomes dg_exit(), which longjmps back to the host entry point that armed it. Doom calls exit() on a missing IWAD, on any I_Error, and when you quit from its own menu; inside a DLL that would take Excel and your other workbooks down with it.
  2. i_system.c — one line, so the text of a fatal error is kept for the host to show. Stock Doom only writes it to stderr, which is invisible in Excel.
  3. d_main.c — one line, so default.cfg and 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.

The C API

__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

How the payload gets in

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 Put statements 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.


Design choices worth knowing

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.


Verification

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.exe exactly as VBA drives it — LoadLibrary by 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.png is the title screen; sample_399.png is 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.png shows 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, msvcrt and USER32, 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, -Os and -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.bin is 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 OnAction target 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.

The one bug real Excel caught

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.

Confirmed in Excel

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.

Re-running the tests yourself

# 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.xlsm

build/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.


Limitations

  • 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 own mus2mid and 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; oleid already 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, but LoadLibrary on 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.


Licensing

  • 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.

About

The first self-contained Excel workbook that IS Doom: the real engine + shareware IWAD sealed in a single .xlsm, no install, with sound. Windows + desktop Excel.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages