how Civilization & Colonization ran a planet in real-mode DOS

CHAPTER 5 · TIME

THE HEARTBEAT

The same boxed copy of Colonization had to behave identically on a 16 MHz 386SX and a Pentium several times faster — with no operating system worth the name to referee. Its answer is a cooperative micro-kernel hanging off the reprogrammed system timer: an interrupt at 608.8 Hz, measured live, divided down by 2, by 5, by 3 — and at the bottom of the cascade, the untouched 1981 BIOS, kept on very nearly its ancient 18.2 Hz schedule by arithmetic.

608.8 Hz
the system timer after Colonization's retune — reload 07A8h = 1,960, captured ~300 ms into boot
70.09 Hz
Civ's retune: reload 4280h = 17,024 — the DOS tick re-pinned to the VGA frame rate
÷2·÷5·÷3
the ISR's divider cascade; the compound ÷30 path hands the BIOS its heartbeat back
1169:0010
Colonization's INT 08h handler, read live from the interrupt vector table mid-game
≈55 ms
the free one-shot both games mine from the BIOS floppy-motor counter at 0040:0040
0
use made of IVT[1C], the polite user tick hook — the timer is seized at the vector itself

The metronome in every PC

A real-mode PC has exactly one general-purpose timepiece: the 8253/8254 Programmable Interval Timer. Channel 0 counts down from a 16-bit reload value at 1,193,182 Hz — a clock rate the machine inherited in 1981 and never shook off — and every time the count expires it raises IRQ 0, reloads, and starts again. At power-up the BIOS leaves the reload at its maximum, 65,536 counts, which works out to 18.2065 Hz: one interrupt roughly every 55 ms. The firmware is built around that number. The BIOS's INT 08h handler advances the time-of-day count in the BIOS Data Area, runs the floppy-motor shut-off countdown, and IRETs, 18.2 times a second, forever.

But the divisor is three port writes away from being anything you like. Both games take the offer. Here is Colonization doing it, captured by the PIT model inside our emulator during an instrumented boot — the only channel-0 reload in the entire run, fired about 300 ms in:

07:58:27.85 DEBUG mddosem_hw::timer: PIT: mode set channel=0 mode=3 access=3 bcd=false
07:58:27.85 DEBUG mddosem_hw::timer: PIT: counter loaded channel=0 reload=07A8 mode=3

Reload 07A8 is 1,960, and 1,193,182 ÷ 1,960 = 608.8 Hz — the DOS tick retuned thirty-three-fold, in mode 3, once, at startup, and never touched again through title, load dialog and open gameplay. Civilization, three years earlier, plays the same trick to a different beat: it loads channel 0 with 4280 = 17,024 — 70.09 Hz, the VGA vertical refresh rate — with a proper mode command before each load, twenty times across its boot-and-intro trace. Civ's system tick is its frame clock.

One interrupt, four jobs

What runs 608.8 times a second? Not the game — the game's scheduler. The handler Colonization installs at 1169:0010 is a cooperative micro-kernel in perhaps a hundred bytes, and we disassembled it over IPC while it ran. Every interrupt, it switches to a private ISR stack — so whatever stack the interrupted code happened to be on cannot overflow — and increments a 32-bit tick counter at 2297:82E8. Then the dividing starts. On even ticks it advances the engine clock and dispatches worker A: a far call through a pointer held in RAM (1796:0065 in our capture), gated by a byte flag at 2297:92A4. Swap the pointer and the timer runs your service three hundred-odd times a second — the slot cinematics use to install per-tick services; in our capture it sat empty (a stale one of these RAM vectors stars in one of Chapter 7's war stories). Every tenth tick, a countdown byte fires worker B, still on the private stack. And every thirtieth tick comes the elegant part: instead of calling anything, the handler executes jmp far cs:[0Ch] — a tail-jump through the saved BIOS vector, kept at offset 0C of its own code segment, straight into the BIOS handler at F000:FEA5.

A tail-jump, not a call: the original interrupt frame is still on the stack, so the BIOS services its Data Area — the clock count, the floppy-motor countdown — sends its own end-of-interrupt and IRETs on the game's behalf, none the wiser. On the twenty-nine ticks in thirty that it keeps, the game's handler acknowledges the interrupt controller itself. The BIOS never learns the timer was retuned; it simply gets called at very nearly the rate it was built for, reconstructed by division. The hook is installed exactly once — a single INT 21h AH=25h call in the whole run — and never unhooked, even though the segment it occupies sits inside the image region Chapter 1's pager recycles.

THE DIVIDER CASCADE — ONE TIMER, FOUR RATES ▶ play · ⏭ step one tick · hover any stage
pulse — one PIT interrupt stage fires BIOS tick serviced
Structure and addresses from live IPC disassembly of the ISR at 1169:0010 under mddosem (July 2026 campaign, bug ledger MDD-00036); PIT reload measured live 2026-07-18 on v0.148.0. Animation slowed ≈100×. * at the measured 608.8 Hz input the ÷30 output is ≈20.3 Hz; at the earlier campaign's recorded 546 Hz it is 18.2 Hz exactly — see the aside above. The cascade is the same either way.

Why build this? Because a 1994 DOS game needs services at several rates — a fine beat for animation and sound pacing, a coarser one for game logic, the BIOS's own for compatibility — and DOS offers exactly one timer and no scheduler. The cascade delivers all of them from a single interrupt, with priorities implicit in the division order, for the cost of a few countdown bytes. It is the smallest possible operating system: one input, a dispatch table you can rewire at runtime, and a strict contract with the firmware underneath.

The hook, observed live

None of the above requires trusting a disassembly listing. The interrupt vector table is the bottom kilobyte of guest memory — four bytes per vector — and our emulator's IPC debug channel can pause the guest and read it while the game runs. Here is the timer vector, watched across one Colonization session and one Civ session:

IVT and BDA reads, mid-run — mddosem v0.148.0, 2026-07-18
What we readRaw bytesDecodedMeaning
Colonization · IVT[8], during load (frame 33)A5 FE 00 F0F000:FEA5still the BIOS default handler — no hook yet
Colonization · IVT[8], at the title (frame ≈601)10 00 69 111169:0010the game's own INT 08h handler, installed during init
Colonization · IVT[8], in-game (frame ≈1568)10 00 69 111169:0010still installed during play
Colonization · IVT[1C]70 01 00 F0F000:0170the polite user-tick hook, untouched
Colonization · BDA 0040:0040000not in a motor-wait spin — the healthy state
Civilization · IVT[8], at the title (frame ≈1514)99 01 52 070752:0199Civ's own INT 08h handler

(The decode is little-endian offset:segment — A5 FE 00 F0 reads back as F000:FEA5.) The polite way to borrow the timer had existed since the original PC: the BIOS's INT 08h handler calls out through INT 1Ch, a user hook that is a do-nothing stub by default, put there precisely so programs could ride the tick without touching the hardware vector. Both games ignore it and seize INT 08h itself. They differ in manners after that: Civilization's handler chains on to the BIOS every interrupt, so the BIOS Data Area keeps advancing at Civ's chosen rate; Colonization rations the BIOS to every thirtieth beat, reconstructing the old clock by arithmetic. Same vector, two philosophies — and both depend utterly on the BIOS side staying alive, which is the next section's subject.

The cheapest timer in DOS

Byte 0040:0040 of the BIOS Data Area is the floppy-motor shut-off counter. The BIOS INT 08h handler decrements it once per tick, and when it reaches zero, switches the drive motor off — a courtesy so the floppy doesn't spin forever after an access. Both games noticed something lovely: that is a free hardware countdown timer, armed by a single byte write. Store 1 there, spin until it reads 0, and the BIOS itself has just timed you a delay of up to one tick — ≈55 ms — for zero bytes of timer code. No motor is running; nobody cares; the alarm still fires. We found the identical instruction pattern in both binaries by live disassembly — Civ's intro delay at 4195:06C106CE, and Colonization's post-audience spin at 145D:0032:

; Civ intro delay — live disassembly at 4195:06C1;
; Colonization's twin spin sits at 145D:0032
      mov   al, 1
      mov   es:[0440h], al      ; ES = 0000 → BDA 0040:0040, the motor shut-off count
spin: cmp   es:[0440h], al      ; has the BIOS INT 08h handler decremented it yet?
      je    spin                ; still 1 — keep waiting
A ONE-SHOT TIMER FOR ONE BYTE — BDA 0040:0040 ▶ play · ⏭ step the phases · hover the parts
the game's write & spin the BIOS tick side
Instruction pattern from live disassembly under mddosem; that 0040:0040 is decremented only inside the BIOS INT 08h handler is verified against DOSBox-X source (INT8_Handler, src/ints/bios.cpp). Timing schematic — a real wait lasts anything up to one 18.2 Hz tick, ≈55 ms.

Calibration: measure the machine, once

Neither game ever asks what CPU it is running on — there was no reliable way to ask. Instead, both measure. Civilization runs a startup loop at 0652:030E0314 that counts PIT ticks across sixteen VGA retrace periods, polling the status port at 3DA: two independent crystal-driven references — the timer and the display — measured against each other, with the CPU's own speed cancelling out of the ratio. Colonization's calibration lives in overlay code and reads PIT channel 0 directly, via the latch command, to time a startup busy-loop. The engine is equally unsentimental about where results go: computed parameters get written straight into the instruction stream. The FAB decompressor of Chapters 2 and 3 ships with zeroed shift/mask fields at CS:045E0464 in the on-disk EXE; at runtime the engine patches the operative values into its own code segment — self-modifying code as a configuration store.

Calibration loops carry hidden assumptions, and emulation finds them. Colonization's startup measurement feeds an IDIV that stays in range only if the measured divisor reaches 87,580; on an early build of our emulator crawling at 3.6 million instructions per second it measured 4,033, the quotient overflowed, and Microsoft C announced "run-time error R6003: integer divide by 0" — a misdiagnosis of its own overflow, and a crash class we went on to meet in over forty DOS-era programs. Civ's sixteen-retrace loop caught us too: our VGA held the vertical-sync status bit for 8% of the frame where real hardware holds it for two scanlines in 449 — 0.45% — and Civ's measurement came back massively inflated.

The deepest lesson was economic. Civ re-times the tick to 70 Hz and then busy-waits on tick edges throughout its intro; on real hardware that costs nothing but electricity. Our emulator initially ran every guest at a fixed "Pentium" budget of 25,200 cycles per millisecond — which made each 70 Hz wait roughly twenty times the emulated work it would be on the 386-class machines the loop was sized for, and the intro crawled at 3.4 fps, 6% of real time. The A/B that proved it: at --speed 386 the same intro runs at 70.12 fps. The durable fix (bug MDD-00037) was DOSBox-X-style adaptive cycles — a 3,000 cycles/ms seed plus ramp — which took it to 64–70 fps; and "turbo" mode, which decouples CPU cycles from the PIT entirely, hangs these intros outright, because a spin counting tick edges never sees one. That is this chapter in one sentence: the games' time comes from crystals — the PIT's 1,193,182 Hz, the VGA's refresh — never from counting CPU. Calibrate the CPU against the crystal and one binary is correct on every machine of 1994; emulate the crystal side subtly wrong, and the software does not slow down politely. It breaks.

Sources for this chapter — byte-level & live disassembly: the Colonization ISR at 1169:0010, the 0040:0040 spin sites and the FAB self-modifying constants, via mddosem's IPC debugger (bug ledger MDD-00036, MDD-00037); runtime: the 2026-07-18 instrumented boot (PIT reload capture, mid-run IVT/BDA reads) under mddosem v0.148.0; oracle: DOSBox-X source (INT8_Handler) for BIOS tick semantics — full credits in How We Know.