A while ago I listened to a Stay Forever trivia episode about RollerCoaster Tycoon, a game from my youth. One piece of trivia stuck: in its original release, switching the PC between summer and winter time would wipe your scenario progress. So I pulled the binaries into a disassembler to find out why the bug happened originally, how Chris Sawyer fixed it, and what the mysterious date 20 December 1998 in the code has to do with all of it.
This is the English, RE-focused writeup of the the German video (eng subtitles) I made off the back of that episode. All the disassembly below is real HLIL from Binary Ninja, taken from the patched build (the version string reads Version 1.08.183 13th January 2000 11:13). Function names are mine; addresses are as-is. If you like FAT quirks, FILETIME, and hand-rolled x86 optimizations, this is for you.
The bug
On the original release, if the system clock crossed a DST boundary (summer to winter or back), or if you moved your save data to another machine, the game reset your scenario unlock progress. Every scenario you had beaten was locked again.
Two conditions are needed to reproduce it reliably:
- Windows 95/98 with a FAT filesystem. This matters. The bug is a FAT-timestamp interaction, and it is only really reproducible there.
-
The original, unpatched executable. Concretely version
1.08.164, built 26 February 1999. That one still carries the bug. The patched build is1.08.183, built 13 January 2000. Both version strings are readable straight out of the binary. A genuine unpatched original was relatively hard to find, but I eventually got one off eBay.
To see it: beat the first scenario (Forest Frontiers), confirm the next one (Bumbly Beach) unlocks, move the system clock from June to late December, restart, and the progress is gone.
Players hit this back then. In a forum thread from 2001 you find the workaround: copy css0.dat over, set the system date to 20.12.98, start the game, and the save games and progress work again. That date is the loose thread I want to pull on.
Some context
One fact shapes everything here: Chris Sawyer wrote almost all of RollerCoaster Tycoon in x86 assembly. That is worth keeping in mind, because the bug and the shape of the fix both come from a hand-rolled, byte-counting optimization that no compiler would emit and no one would reach for today.
Where the progress lives: sc.idx
In the install directory there is a Scenarios folder with the 21 base scenarios, sc0 to sc20. The progress itself is not in the scenarios. It lives in a file the game creates and maintains at runtime:
<install>/Scenarios/sc.idx
This is the scenario index, and RollerCoaster Tycoon tries to guarantee its integrity on several levels. On disk it is only about 2 KB, because it is compressed and encrypted; unpacked it is 0x3A10 = 14,864 bytes. In a raw hex editor the bytes are noise and the ASCII column means nothing.
I read the decode routine out of the binary and reimplemented it in Python. The pipeline is: verify a checksum trailer, run Sawyer's RLE decompression, then a per-dword decode transform. The transform is the interesting part:
# decode: applied to every dword of the decompressed block
DECODE_CONSTANT = 0x39393939 # note: the ASCII bytes for "9999"
def rol32(value, bits):
value &= 0xFFFFFFFF
return ((value << bits) | (value >> (32 - bits))) & 0xFFFFFFFF
value = (rol32(value, 5) - DECODE_CONSTANT) & 0xFFFFFFFF
The checksum is a rotate-accumulate over 1 KB chunks, compared against a stored trailer with a fixed salt:
def rct_low_byte_checksum(data): # over 0x400-byte chunks
checksum = 0
for byte in data:
checksum = (checksum & 0xFFFFFF00) | (((checksum & 0xFF) + byte) & 0xFF)
checksum = rol32(checksum, 3)
return checksum
# stored trailer == raw + 0x1A67C (or the alternate salt 0x1A655)
Decoded, the file has a fixed layout. This is my ImHex pattern for it, with the real offsets and the Binary Ninja base address the tables live at in memory:
// BN base: 0x99C16C (ImHex file offset = BN address - 0x99C16C)
struct ScenarioIndexCacheSlot { char fileName[0x10]; };
struct ScenarioIndexNameSlot { char scenarioName[0x40]; };
struct ScenarioSlotProgressSlot { u8 data[0x24]; };
ScenarioIndexCacheSlot cache[0x80] @ 0x0000; // 128 file-name slots
ScenarioIndexNameSlot names[0x80] @ 0x0800; // 128 scenario names
ScenarioSlotProgressSlot progress[0x80] @ 0x2800;
u8 scenarioIndexEntryCount @ 0x3A00;
u16 referenceFileTimestampLow @ 0x3A02; // <-- the 2-byte timestamp slice
u32 scenarioFilesTotalSizeLow @ 0x3A04;
u32 scenarioFilesTotalSizeHigh@ 0x3A08;
s32 errorStatus @ 0x3A0C;
There are 128 (0x80) slots. Progress per scenario is four bytes, little-endian. A locked scenario is marked with the sentinel 0x80000000 (the smallest signed 32-bit value); a solved one stores the score. You will see both of those constants appear verbatim in the disassembly below. The referenceFileTimestampLow field, only 2 bytes wide, is the whole story of this bug.
What happens at startup
When the game boots, mcp_load_or_rebuild_scenario_index (@ 0x42fdf4) runs sc.idx through a sequence of checks:
- Exists? If the file is not there, create it.
-
Checksum.
verify_file_checksum()over the trailer. Change one byte and the file is invalid. -
Decompress (
decompress_bytes_loop(0x3a10, ...)), thenmcp_decode_scenario_index_block(). -
Timestamp check. Verify the stored slice of
Data/css1.dat's creation date still matches. This is the one that bites us. -
Scenario-set check. Sum the total size of all scenario files (
nFileSizeLow/nFileSizeHighviafind_next_file) and compare against the storedscenarioFilesTotalSizeLow/High.
If any check fails, the index is discarded, the folder is re-scanned, and a fresh index is written with every scenario locked again. That reset loop is literally this, straight from the function:
// mcp_load_or_rebuild_scenario_index @ 0x42ff47 (rebuild path)
int32_t i_2 = 0;
while (i_2 u< 0x80) // all 128 slots
*((i_2 << 2) + &scenario_completion_score_table) = 0x80000000; // = locked
i_2 += 1;
Step 4 is the culprit. Let's look at why comparing a file's creation date is a landmine on FAT.
The cause: FindFirstFile, FILETIME, and FAT local time
To read css1.dat's creation date, the game calls FindFirstFile, which fills a WIN32_FIND_DATA. The field it uses is ftCreationTime, a FILETIME:
-
FILETIMEis a 64-bit value: the number of 100-nanosecond intervals since 1 January 1601, UTC. - Because it is anchored to a fixed epoch, it is a UTC timestamp. It carries time-zone meaning and can be converted to any local time.
The trap is in the MSDN docs for the API: on a FAT volume the file's timestamp is stored as local time, and when you request it, the API converts that local time to a FILETIME using the currently active time zone and DST setting.
FAT stores only local wall-clock time, with no zone information. So:
- The bytes on disk never change.
- But the
FILETIMEthe API returns changes depending on whether DST is currently active, by exactly one hour.
I confirmed this in a Windows 95 VM with a small program that calls FindFirstFile on css1.dat and prints the raw ftCreationTime. In summer time I got one 64-bit value. I screenshotted it, moved the clock into December, and ran it again. The low dword changed completely and the high dword's least significant byte moved (…E5 to …EE). Fed into a Windows-timestamp converter, the second value is exactly one hour later.
So the stored reference under one DST setting no longer matches what FindFirstFile returns under the other. The check fails, the index is rebuilt, the progress is wiped. Same mechanism for the "copied my saves to another PC" reports: a different machine in a different zone produces a different converted FILETIME.
The obvious fix is to allow a tolerance of one hour. But the original code cannot do that, and the reason is a space-saving optimization.
Why a one-hour tolerance was impossible
The game stores only 2 bytes of that 64-bit timestamp. Here is the original extraction, as HLIL:
// get_reference_install_lowdate @ 0x430081
HANDLE h = find_first_file_wrapper(2, &scenarioFindData); // 2 == css1.dat
if (h == 0xffffffff) return 0;
int16_t result = (scenarioFindData.ftCreationTime.dwLowDateTime u>> 0x10).w; // <-- take LOW dword, >> 16
return result;
It takes the low dword of the 64-bit value and shifts right by 16, keeping its top 16 bits. Chris Sawyer decided he did not need all 8 bytes to detect a change: 16 bits, and save the space in the file.
Which 16 bits, though? That choice defines two things: resolution (the smallest change you can detect) and period (how long until the slice wraps and repeats).
A short decimal analogy. Take a four-digit number of seconds and keep only one digit. Keep the ones digit and you get resolution 1 s, but it wraps every 10 s: you cannot tell 105 s from 115 s. Keep the tens digit and you get resolution 10 s, wrapping every 100 s. Move left, coarser resolution, larger range.
Same idea in hex. Split the 64-bit FILETIME into four 16-bit words, least to most significant (right to left):
HH HL LH LL
[63:48] [47:32] [31:16] [15:0]
LL is the bottom 16 bits, one tick = 100 ns.
| Slice | Resolution (smallest change) | Period (wraps after) |
|---|---|---|
LL |
100 ns | ≈ 6.5 ms |
LH |
≈ 6.5 ms | ≈ 7 min 9 s (429.5 s) |
HL |
≈ 7 min 9 s (429.5 s) | ≈ 325 days |
HH |
≈ 325 days | ~57,000 years |
(Check: LL wraps at 2¹⁶ × 100 ns ≈ 6.55 ms. LH sits 16 bits higher, so it wraps at 2³² × 100 ns ≈ 429.5 s. HL wraps at 2⁴⁸ × 100 ns ≈ 325 days.)
dwLowDateTime >> 16 is the LH slice. And that is the problem. LH wraps every 7 minutes 9 seconds. When the code subtracts two LH values, the difference is always smaller than 7 min 9 s. A one-hour DST shift, seen only through LH, cannot be told apart from anything else. The information is not in this slice, so widening the comparison to tolerate an hour does not help. A real fix has to change which bytes get stored.
The fix: migrate to a coarser slice, then add tolerance
The pragmatic fix, and the reason a patch was needed rather than a one-liner, is to move up one slice, from LH to HL. The patch adds a sibling function. Compare it to the one above:
// get_reference_install_highdate @ 0x4300b7 (NEW in the patch)
HANDLE h = find_first_file_wrapper(2, &scenarioFindData);
if (h == 0xffffffff) return 0;
uint16_t result = (scenarioFindData.ftCreationTime.dwHighDateTime).w; // <-- HIGH dword, NO shift
return result;
It reads the high dword and truncates to 16 bits with no shift. That is the HL slice: resolution ~7 min 9 s, range ~325 days. Now a one-hour difference is expressible (one hour ≈ 8 units of 429.5 s), so there is room for a real tolerance.
Here is the migration and tolerance logic, the heart of the fix, straight out of mcp_load_or_rebuild_scenario_index:
// mcp_load_or_rebuild_scenario_index @ 0x42fe86
is_dev_bypass_date();
if (&stackCheckValue u< 0xfffffffc) { // (decompiler artifact — see below)
if (get_reference_install_lowdate() == referenceFileTimestamp)
goto label_is_ok; // old LH value still matches
int16_t timeDifference = get_reference_install_highdate() - referenceFileTimestamp;
if (timeDifference s< 0)
timeDifference = neg.w(timeDifference); // abs()
if (timeDifference u<= 0x78) // 0x78 = 120 units
goto label_is_ok;
referenceFileTimestamp = get_reference_install_highdate();
locked_scenarios_bitmask = 0xffffffe0; // lock scenarios
goto InvalidHandle; // rebuild the index
}
label_is_ok:
referenceFileTimestamp = get_reference_install_highdate(); // migrate: store the HL slice
Two things to read out of this. First, the migration is backward-compatible: the old LH check still runs, and whether it passes or fails, the code ends up writing the new HL value into referenceFileTimestamp. So the first launch after patching quietly migrates the on-disk format, and every launch afterwards compares on HL.
Second, the tolerance is that u<= 0x78. 0x78 is 120, and one HL unit is 429.5 s:
120 units × 429.5 s = 51,540 s ÷ 3600 ≈ 14.3 hours
So Chris Sawyer did not settle for one hour. He built in about 14.3 hours of tolerance in either direction. Comfortably more than a DST hour, comfortably less than a day.
I checked both halves in the VM. The patched build keeps progress across a DST flip. For the tolerance, forcing a 12-hour difference on the file's creation time keeps the index; forcing about 15 hours rebuilds it.
The date 20.12.1998
Now the forum date. Notice the is_dev_bypass_date() call at the very top of that block, gating the whole timestamp check. Here is the helper:
// is_dev_bypass_date @ 0x4300ea
int32_t result = get_and_store_system_time();
currentYear == 0x7ce && currentMonth == 0xc && currentDay == 0x14; // <-- dangling expression
return result;
0x7ce = 1998, 0xc = 12, 0x14 = 20. So the bypass triggers on 20 December 1998 (and note it checks the full year 1998, not just 98). When it hits, the timestamp check is skipped entirely and the current date is just written back into the index.
This is also a nice HLIL-lies example. Look at that middle line: a comparison whose result goes nowhere, and a return result that returns the raw system time instead of the boolean. The reason is that the helper does not return its result in AX the way the ABI expects, it returns it in the carry flag. The decompiler did not model that, so it produced a dangling comparison here and, at the call site, rendered the test as the nonsensical if (&stackCheckValue u< 0xfffffffc) you saw above. Only the disassembly shows what is really going on. Lesson: when HLIL shows you nonsense around a tiny helper, suspect a flag-based return and check the assembly.
Why that date? This is necessarily speculation. One option is that Chris Sawyer used it during development to avoid losing his own progress. Against that: it checks an exact single day, which he would have to keep bumping; a developer would more likely gate this behind a build flag, not a hard-coded calendar day. My guess is a release or QA date. Most plausibly, he told his QA team to set their machines to 20.12.1998 before reinstalling or importing save games, so their progress would not get wiped by exactly the index-invalidation we have been dissecting. QA reinstalls constantly, every reinstall changes css1.dat's creation date, and every changed date wiped the progress. A "freeze the clock to this date and the check is bypassed" escape hatch is what you would hand your testers. The 2001 forum post is players having rediscovered it.
A few more finds from the same review
While I had the binary open, a few things I did not want to leave out.
Peeps: a flat, index-shifted array. The visitor ("peep") array is addressed by shifting the index left by 8, i.e. 256 bytes per peep, added to a fixed base:
// mcp_allocate_peep @ 0x444bd4
esi_2 = (zx.d(g_peep_list_heads_by_type) << 8) + &g_peep_array; // base + (index << 8) == × 256
That << 8 shows up all over the peep code. Space is reserved up front for 5,000 peeps, so 256 × 5000 ≈ 1.22 MB statically allocated, a sizeable chunk for the late '90s. Today you would do this with a heap allocator or a GC; here it buys no fragmentation, no leaks, and extremely fast indexing.
The easter-egg names are a Caesar cipher. Name a guest Michael Schumacher, Mr Bean, or Chris Sawyer (among others) and flags flip, so that peep always takes photos, is faster on the go-karts, and so on. You will not find those strings by grepping the binary, because they are stored shifted. The matcher uppercases the peep name, then compares it to a table entry byte-by-byte, decrypting each stored byte with + 1:
// mcp_peep_name_matches_easter_egg_name @ 0x441596
ebp = (&g_peep_easter_egg_name_pointer_table)[easter_egg_name_index];
// ... uppercase the peep's name in place ...
if (i u>= 0x61 && i u<= 0x7a) // 'a'..'z'
*edi -= 0x20; // to uppercase
// ... then compare, decrypting the table one char at a time:
i = *ebp + 1; // <-- Caesar +1
if (i != *edi_1) return ...; // mismatch
So a table entry of L H B G ... decrypts to M I C H ... → "Michael". A shift of one letter, trivial to decode by hand, and, the point, trivial to decrypt in a couple of instructions, which is why it was used over storing plaintext.
Reserved gaps in the scenario index. The rebuild loop only indexes a scenario if its slot index falls outside a reserved hole:
// mcp_load_or_rebuild_scenario_index @ 0x43000e
if (currentByte.b u>= 0 && (current_scenario_index u< 0x28 || current_scenario_index u>= 0x64))
// ... add scenario to the index ...
0x28 = 40, 0x64 = 100. So slots 40–99 are skipped and never enter the index, clearly reserved for add-ons. Indices 0–21 are the base scenarios plus the Megapark, then the Loopy Landscapes set from index 100 up.
The Megapark is a nibble swap away. The Megapark is not shipped as a normal scenario file. mcp_create_mega_park_scenario_file (@ 0x4308c3) checks the base scenarios are present, builds the path SC21.SC4, converts mp.dat into it, then clears the Megapark's lock bit (locked_scenarios_bitmask &= 0xffdfffff). The conversion is one rotate per byte, in 64 KB chunks:
// mcp_convert_mp_dat_to_sc21_sc4 @ 0x42ef2d
decode_ptr->field_00 = ror.b(decode_ptr->field_00, 4); // rotate each byte right by 4 = nibble swap
A nibble is 4 bits, half a byte; ROR 4 swaps the two nibbles of each byte, and doing that byte for byte produces a valid scenario file. On my install mp.dat is not even on disk; it is in the Data directory on the CD, about 300 KB. So you could reproduce the Megapark with a small script: nibble-swap mp.dat, then drop it in as sc21.sc4.
Conclusion
The bug was set off by an API behaving differently than the developer assumed: FindFirstFile converting FAT local time to UTC using the current DST setting, so a file date that looks constant was not constant across a clock change. The fix could not be a one-line tolerance, because a space-saving 16-bit slice (LH) of the timestamp physically could not represent an hour. Chris Sawyer's fix kept the on-disk format and migrated it under the hood to a coarser slice (HL) with a 325-day range, then applied about 14.3 hours of tolerance, all while staying backward-compatible through a silent migration on the first patched launch. Pragmatic, and about what you would expect from a game written almost entirely in assembly.
Along the way: a 1.22 MB flat peep array, Caesar-cipher easter eggs, reserved index gaps for DLC, and a nibble-swapped Megapark.
Tools: Binary Ninja for disassembly and HLIL, and a few throwaway Python scripts for the sc.idx and mp.dat formats.The full walkthrough, with the VM demos and the live repro, is in the original video (in German, auto-subtitles available). Thanks to Stay Forever for the trivia episode that started this. Questions or corrections, write me in the comments.




Top comments (0)