If you have played the original Civilization (MicroProse, 1991) in DOSBox, you may have hit this one: you open a city, press CHANGE to pick something new to build, and the game freezes. No error, no crash, just a hung screen. The READ.ME that ships with version 474.03 knows about it (item 23) and suggests a workaround: turn off Instant Advice.
That avoids the symptom, but not the cause. I wanted to know what was actually happening, so I took it apart. The short version: the game corrupts its own data files, a little more every time it hits a specific code path, until a lookup loop has no way out.
TL;DR
The freeze is an infinite loop in the game's indexed text lookup. Three defects combine:
-
Incomplete shipped indexes. Each indexed
.TXTfile omits its last entry from its hash index. InPRODUCE.TXTthat entry is*Caravan. -
Off-by-one write-back. When a lookup falls back to a sequential scan, the game "helpfully" writes the found offset back into the index. But the offset comes from
ftell()on a text-mode stream and is usually 1 byte late, so it never matches. Every later lookup repeats the scan and burns another empty bucket. - Unbounded probing. The lookup probes buckets until it finds a match or an empty bucket. Once every bucket is full, a key that can't be matched loops forever.
The fix: rebuild the indexes of seven .TXT files (text unchanged), plus an optional 1-byte patch to CIV.EXE that disables the write-back so they can never fill up again.
The symptom
- Only happens with Instant Advice on.
- The last thing on screen is the Military Advisor recommendation. The Domestic Advisor box, normally shown next, never appears.
- Intermittent: some cities, some turns. Later saves of a game are affected while earlier ones are not. Forum folklore blames unit stacking in or near the city.
- Reproduced in DOSBox 0.74-3;
READ.MEsays it happens on real hardware too. - A useful clue:
PRODUCE.TXTandERROR.TXThad modification dates later than the install, even though nobody had edited them.
That last point turned out to be the whole story.
Setup and approach
| Item | Detail |
|---|---|
| Game | Civilization for DOS, 474.03 (shown at start-up; INTRO.TXT says 474.01) |
| Executable |
CIV.EXE, 302,982 bytes, MD5 8a574c67c722c34beb6f5c965742769f. Microsoft C runtime (1988), EXEPACK-compressed, overlay data appended |
| Emulator | DOSBox 0.74-3, 10,000 cycles |
| Test harness | Headless Linux + Xvfb, input driven by xdotool, screenshots after each step |
The investigation went from data, to code, and back to the running game:
-
Save-file comparison. I parsed the
.SVEformat (28-byte city records, 12-byte unit records including the per-tile stack link) and compared an affected city with the same city in an unaffected save. Every stack link was well formed, so stacking corruption was ruled out as a direct cause. -
Unpacking the executable. I wrote an EXEPACK decompressor. The stream unpacked cleanly to 201,520 bytes with its relocation table; the data segment base sits at linear
0x2A800, found via the C runtime copyright string. -
Locating the code. References to the strings
Military Advisor:andproduce.txtlead to the advisor routine and to a generic text lookup at1ECB:01B3, disassembled withndisasm. -
Format analysis and simulation. I decoded the index at the top of the
.TXTfiles, reimplemented the lookup in Python and ran it on all 73 unit, improvement and wonder names. - Reproduction. Load a save, open the city, press CHANGE, check for a freeze. Repeated with original and corrected files, diffing the files before and after to catch writes by the game.
Diagnosis
The advisor flow
With Instant Advice on, CHANGE computes a military and a domestic recommendation. For each one the game builds a key of * plus the item name (e.g. *Caravan) and looks it up in PRODUCE.TXT to get the advice text ("We should build a β¦"). The military lookup succeeds and its box is drawn. The domestic lookup never returns.
In every frozen case, the domestic recommendation was Caravan. Unit stacking only matters indirectly: the defenders in and around a city change what the advisors recommend.
The indexed text file format
PRODUCE.TXT, ERROR.TXT, KING.TXT, HELP.TXT and BLURB0βBLURB4.TXT all start with a 528-byte hash index: 256 little-endian 16-bit buckets, written as 8 lines of 32 buckets, each line ending in CR LF. Entries start at offset 0x212 (530) as lines beginning with *, and the file ends with *END.
A bucket holds the file offset of an entry's *. The value 0x3030 (ASCII 00) means empty. The hash of a key of n bytes is:
h = sum(i * c[i] for i in 0..n-1) mod 256
Collisions are resolved by linear probing. Bucket b lives at file offset 2 * (b + b/32) (the b/32 accounts for the CR LF after every 32 buckets).
The lookup routine (1ECB:01B3)
- Compute
h. Open the file in binary mode, read the 16-bit value at2 * (h + h/32), close it, reopen it in text mode ("rt"). - If the value is
<= 0x1FFor equals0x3030, the bucket is empty: go to the sequential scan. - Otherwise seek to the value, read one line (
%[^\n]\n) and compare it with the key. Match β found. - No match β
h = (h + 1) & 0xFF(1ECB:0398) and go back to step 1. There is no probe counter and no other exit. -
Sequential scan: seek to
0x200; for each line rememberftell(), read the line, compare. Stop on a match or at EOF. -
Write-back (
1ECB:03DC): if the scan found the key, reopen the file as"r+b"and write the rememberedftell()value into the empty bucket where probing stopped.
Step 6 is a self-healing cache: the index repairs itself the first time a missing key is looked up. In theory.
ββββββββββββββββββββββββββββββββββββββββββββββββ
βΌ β
read bucket[h] ββempty?ββyesβββΊ linear scan ββfoundβββΊ write ftell() into bucket[h]
β β (off by one byte!)
no not found
βΌ βΌ
line at offset == key? ββyesβββΊ return found return not found
β
no
βΌ
h = (h+1) & 0xFF ββββββββββββββββββββββββββββββββββββββββ
(no counter: if no bucket is ever empty, this never ends)
The three defects, together
Incomplete shipped index. The last entry of every indexed file is missing from its index. *Caravan is the last entry of PRODUCE.TXT. So the first Caravan lookup misses the hash, scans, finds it, and writes back.
Off-by-one write-back. The offset from ftell() on a text-mode stream is usually 1 byte past the *: 2099 instead of 2098 for *Caravan (and in one file, 1 byte early). From offset 2099 the game reads Caravan without the *, which never matches *Caravan. So the next lookup probes past that bucket, reaches the next empty one, scans again, and writes 2099 again. Each Caravan recommendation consumes one empty bucket.
Unbounded probing. After roughly 205 Caravan recommendations no empty bucket remains, and every one of them holds 0x0833 (2099). Now any key whose probe chain never matches spins forever, opening and closing the file on every step. In the fully corrupted file this happens for 23 of the 73 keys: Caravan, Palace, and all 21 Wonders.
That explains everything that looked random: it depends on how many times the game has recommended a Caravan since install, and on whether this particular city's advisor recommends one of the 23 doomed keys. It also explains why the problem seems to get worse the longer you play (the damage accumulates in PRODUCE.TXT itself, not in the save), and why the file's modification date changed.
Confirmation
Reproduction
| Save | Original files | Corrected files |
|---|---|---|
| Latest affected save | Freezes after the Military Advisor box | Works; Domestic Advisor recommends Caravan |
| Earlier affected save | Freezes | Works; recommends Caravan |
| A third reported save | No freeze in 3 tries (advisor recommended City Walls, which resolves through the hash) | Works |
| Unaffected save | Works | Works |
Watching the corruption happen
I started from a correct index with only the *Caravan bucket emptied, then pressed CHANGE three times on an affected city. After each press the game had written 2099 (0x0833) into one more bucket: 117, then 118, then 119. That's the corruption, step by step, live.
Audit of all indexed files
Every bucket was checked against the real entry offsets. Unindexed = entry missing from the index. Bad buckets = values not pointing at an entry's *, all off by one byte.
| File | Entries | Unindexed | Bad buckets |
|---|---|---|---|
PRODUCE.TXT |
51 | *Caravan |
206 (every empty bucket) |
ERROR.TXT |
12 |
*NOMINE, *NOIRR, *ADDCITY
|
4 (NOIRR twice) |
KING.TXT |
61 |
*UMAX, *PMAX
|
2 |
BLURB1.TXT |
90 | *CURE FOR CANCER2 |
1 |
BLURB2.TXT |
56 | *SETTLERS2 |
1 |
BLURB4.TXT |
42 | *TRADE ROUTES2 |
1 |
BLURB0.TXT |
68 | *HORSEBACK RIDING |
0 |
HELP.TXT |
18 | none | 0 |
The KING and BLURB traces were probably already in the distributed archive.
Fix #1: rebuild the indexes
Replace the 528-byte index of the seven damaged files with one rebuilt from their actual entries. The text is untouched; only bytes 0β527 change.
# Rebuild the hash index at the top of a CIV .TXT file (256 buckets, CRLF every 32)
import struct, sys, re
P = bytearray(open(sys.argv[1], 'rb').read())
keys = [(m.start() + 1, m.group(1)) for m in re.finditer(rb'\n(\*[^\r\n]+)', P)]
tab = [0x3030] * 256
for off, k in keys:
if k == b'*END':
continue
h = sum(c * i for i, c in enumerate(k)) & 0xff
while tab[h] != 0x3030:
h = (h + 1) & 0xff
tab[h] = off
hdr = b''.join(struct.pack('<32H', *tab[i*32:i*32+32]) + b'\r\n' for i in range(8))
assert len(hdr) == 528
P[:528] = hdr
open(sys.argv[2], 'wb').write(P)
print(len(keys) - 1, 'keys indexed')
- Fidelity check. The rebuilt indexes match every correct bucket of the originals exactly (50 in PRODUCE, 9 in ERROR, 58 in KING, and 67/89/55/41 in BLURB0/1/2/4). That confirms the hash and probing order are the game's own. The only differences are the bad buckets (fixed or cleared) and the missing entries (added).
- Why it works. Every key in the file is now found through the hash and never reaches the write-back. Keys absent from the file, like Palace and the Wonders, reach an empty bucket, scan to EOF, return "not found", and write nothing. The probe loop always terminates.
-
Simulation. 23 of 73 keys loop forever with the original
PRODUCE.TXT; zero with the rebuilt one. - Emulator. All saves, Instant Advice on: no freeze, and the Domestic Advisor shows its Caravan advice. Across six CHANGE presses the files stayed byte-identical: the game no longer writes to them.
Fix #2: a 1-byte patch to CIV.EXE
Rebuilt indexes stop the freeze, but the self-healing write-back is still a loaded gun. So CIVFIX.EXE is CIV.EXE with one byte changed so the lookup never writes back.
| Original | Patched | |
|---|---|---|
| File offset |
0x1F072 = 74 6B
|
0x1F072 = EB 6B
|
| Unpacked address | 1ECB:03D3 |
1ECB:03D3 |
| Instruction | jz 0x0440 |
jmp 0x0440 |
| Effect | Write back only when the scan found the key | Always skip the write-back block (03D5β043F) |
| MD5 | 8a574c67c722c34beb6f5c965742769f |
27eecc3fb17bb7dac950909445752962 |
The guard at 1ECB:03C7 skips the write-back when the hash found the key (jnz 0x0440) or when the scan didn't (jz 0x0440). Turning the second jump into an unconditional jmp skips it every time. The return value at 0x0440 is unaffected, so callers still get found/not-found.
Patching inside a compressed executable
CIV.EXE is EXEPACK-compressed, which normally means you can't just poke a byte. But EXEPACK is a run-length scheme that stores non-repeating bytes as literal copy blocks. Mapping each unpacked byte back to its source showed that unpacked 0x1F083 is stored literally at file offset 0x1F072. So it can be changed in place: the compressed stream, relocation table, header and overlays all stay the same, and EXEPACK has no checksum. Unpacking the patched file yields an image that differs from the original at exactly that one byte.
Why not just fix the offset?
The written offset comes from ftell() on a text-mode stream. It was 1 byte late in most cases and 1 byte early in one. Writing ftell() - 1 would fix most entries and break the rest. Skipping the write-back is simpler and always safe: unindexed entries are found by the sequential scan on each lookup, about 2 KB of reading, which is not noticeable.
Tests
- Starting from an index with
*Caravanremoved, CHANGE pressed three times on an affected city: advice shown, no freeze, file unchanged. - Run together with the rebuilt files: game starts, loads a save, shows advice, writes nothing.
How to apply it
- Back up your game folder (at least
CIV.EXEand the seven.TXTfiles). - Rebuild the index of
PRODUCE.TXT,ERROR.TXT,KING.TXT,BLURB0.TXT,BLURB1.TXT,BLURB2.TXTandBLURB4.TXTwith the script above. - Optionally, apply the 1-byte patch: check that offset
0x1F072holds74 6B, change74toEB. Keep the original asCIV_ORIG.EXEand keep the patched file namedCIV.EXE(the runtime refers to its own image asciv.exe).
The patch prevents new damage but doesn't repair an index that is already full, so use it with the rebuilt files, not instead of them. After that you can turn Instant Advice back on.
Limitations
- I didn't trace exactly why the C runtime's text-mode
ftell()is off by Β±1. The fix doesn't depend on it. - The probe loop is still unbounded in the patched executable. It's unreachable as long as indexes keep their empty buckets, but an index damaged by hand or another tool could still trigger it.
- Tested in DOSBox 0.74-3 only. The behaviour lives in the game's own code and data, so real hardware should behave the same.
- Version 474.03 only. Other releases may place the routine elsewhere: check for
74 6Bat the offset before patching.
Key addresses
Unpacked image; data segment base at linear 0x2A800.
| Address | What |
|---|---|
1ECB:01B3 |
Indexed text lookup: hash, probe, scan, write-back |
1ECB:026C |
Empty-bucket test (<= 0x1FF or 0x3030) |
1ECB:0398 |
Probe step: h = (h + 1) & 0xFF, loop |
1ECB:03C7 |
Write-back guard |
1ECB:03D3 |
Patched jump (jz β jmp) |
1ECB:03DC |
Write-back: open "r+b", seek to bucket, write 2 bytes |
linear 0x0B8EE
|
Military advice lookup |
linear 0x0B98A
|
Domestic advice lookup |
A cache that writes back a slightly wrong key, a hash table with no probe limit, and an index that ships one entry short. Each one is harmless on its own; together they make a game slowly eat its own help files until it hangs. A nice reminder that "self-healing" code needs a test for what happens when the healing is wrong.












