11 January 2025
If you’re into retro computing, you probably know about Oregon Trail; a simulation of the hardships faced by a group of colonists in 1848 as they travel by covered wagon from Independence Missouri to the Willamette Valley in Oregon. The game was wildly successful in the US education market, with the various editions selling 65 million copies. What you probably don’t know is the game’s great untold secret.
Two years ago, Twitch streamer albrot discovered a bug in the code for crossing rivers. One of the options is to “wait to see if conditions improve”; waiting a day will consume food but not recalculate any health conditions, granting your party immortality.
From this conceit the Oregon Trail Time Machine was born; a multiday livestream of the game as the party waits for conditions to improve at the final Snake River crossing until the year 10000, to see if the withered travellers can make it to the ruins of ancient Oregon. The first attempt ended in tragedy; no matter what albrot tried, the party would succumb to disease and die almost immediately.
A couple of days before New Years Eve 2025, albrot reached out and asked if I knew anything about Apple II hacking. In reality the answer was no, I knew three things about the Apple II:
- It has a MOS 6502 processor
- It was popular in the US educational market
- Something something Carmen Sandiego something Prince of Persia?
But all old computers are basically the same right? Specialist knowledge is for cowards.
Where to start
First I loaded Oregon Trail into MAME’s Apple II emulator. MAME is an emulator that was originally targeted at arcade hardware, however it has support for hundreds of game consoles and home computer systems thanks to merging with the MESS project. It has one of the nicest debuggers I’ve ever used, with plenty of documentation, which is why I strongly recommend it for reversing work. You will need a copy of the Apple II ROMs, listed in the MAME ROM index as apple2e.zip.
We are using the most common disk image of the game (aka “peperony and chease” edition). To open the game:
mame apple2e -debug -flop1 oregonmod_a.do
This will open a separate Debug window and pause execution until you start running the machine (F5). In the game window you can activate the MAME menu keys by pressing Scroll Lock; useful ones to know are TAB for the main menu, F6 for saving a state, F7 for loading a state.
This was my thinking for how to get a first toehold into what was going on. As you travel, the game gives your party one of four health ratings:
It follows that the game code must read the text string “poor” from somewhere in memory so it can be printed on the screen, and this code would be attached to the routine that determines how sick everyone is. So I sent the party off from Independence, Missouri with 1 ox, no food and no clothes. As soon as we were on the road, I made a save state. I then called “Step Into” in the debugger (F11) which paused execution, and made a dump of the Apple II’s memory to a file with the following command:
save /tmp/mem_before,0,0x10000
I loaded this dump into a hex editor and searched for the word “poor”, which I found at address 0xA6AE. Then in the debugger I added a watchpoint for any code attempting to read the first byte of “poor”:
As expected, once my party became emaciated enough the debugger halted execution at the following instruction:
The Apple II CPU is a 6502, which has a nice instruction reference here. Looking it up, we can see that LDA basically means “load a byte from the address stored at 0x005E into register A”. Because the 6502 is a very basic 8-bit CPU, you are limited in how you access the 16-bit address space. Generally speaking you have the choice of using a fixed offset (up to 16-bit, baked in the instruction) plus whatever 8-bit value is in a register, or using a Zero Page pointer. The above instruction is using a Zero Page address; for this mode you have to write a 16-bit pointer containing the location you want to somewhere in the first 0x100 bytes of memory, then use an instruction which references that pointer.
So we know that the pointer to the text describing the player’s health is at 0x005E, which we can confirm by opening a memory window in the debugger and see AE A6 at address 0x005E (the 6502 is little endian, so addresses are stored backwards). It’s fair to say that this is just for updating the display, and not for checking how ill everyone is.
We need to find the code that sets this status, so I added another watchpoint for any writes to the pointer at 0x005E:
wpset 0x005E,1,w,wpdata == 0xAE
After reload the save state, the game stopped in another area:
E630 STX $5E ; mem[0x005E] = X
I started using the debugger’s Step Out feature (Shift+F11) to move up the call stack and see what was calling what. From further poking around it became apparent that we were in some sort of generic high level text printing function. Then a penny dropped, and I started wondering about the memory layout of the Apple II. A memory map shows the D000–F7FF range to be the part of the ROM containing the BASIC interpreter. Oh.
In fact there’s a very helpful annotated Apple II ROM disassembly, showing that function is indeed a high-level print function for text.
Well that just got complicated
So 1985 Oregon Trail is written in Applesoft BASIC, and the program is stored as some sort of bytecode. My heart sank a little at this news; 6502 assembly is cumbersome to understand at the best of times, and throwing a BASIC virtual machine on top of that makes live debugging even worse. I didn’t have time to hack together a way to trace the BASIC code as it executed.
However this did open up a new angle of attack. A BASIC program uses variables, which are stored in a known location in a known format. What if we let the game play, then kept an eye on the debugger’s live memory view to see what variables change over time?
The good news is that Applesoft BASIC keeps the original names for each variable; a critical clue. The downside is that those names are (at most) two letters long. Also the memory is managed dynamically, so variables will move around as the program chugs on.
From poking around the ROM disassembly, the pointer to the variable store is at address 0x0069 of the Zero Page (usually 0x9902), followed by a pointer to the array store, and a pointer to all of the string data. Each variable is 7 bytes long; 2 bytes for the name (using the high bits of each to encode type), and 5 bytes data. The default numeric type in Applesoft BASIC is a cursed 40-bit floating point format, with an 8-bit exponent and a 31-bit mantissa.
After a bunch of test runs I found a variable called H, which increased in line with my health status getting worse. Even better, setting the H data to all zeros reset the status back to “good”, after which it seemed to decay back to “very poor” at the previous rate. There was also a H1 array that seemed to keep track of party members that had died (0 for alive, -1 for dead), and a H2 array which seemed to keep track of whatever exciting disease each party member had. Easy! Open and shut!
Did we make it?
New Years Eve 2025 had arrived, and with it attempt 2 to reach the barren wastelands of future Oregon. This time, the time machine stopped at 16120 AD.
A dismal failure. Even when zeroing out the memory, every day H would reset to 139, dooming the party to a short-but-agonizing fate. Cheating death is harder than expected.
We can still save this
Several days later, I tried writing a scrappy decompiler for the Applesoft BASIC bytecode. From past experience I was worried this would be real complex, but in the mother of all lucky breaks the “bytecode” is the original program text with certain keywords replaced with 1-byte tokens. After nicking the list of tokens from the Apple II ROM disassembly I had a half-decent decompiler after a few goes.
(This ultimately turned out to be unnecessary. If I had looked more closely at the options for CiderPress II, I would’ve seen that you can use the “import” and “export” commands to convert Applesoft BASIC files to and from plain text).
So of course we had to look for the code that calculates the player’s health. Here it is, from OREGON TRAIL:
...
# 3200
LET H0 = C0
FOR L = C0 TO C4
IF H1(L) > C0 THEN H2(L) = H2(L) - C1
LET H0 = H0 + C1
IF H2(L) < C1 THEN H1(L) = C0
# 3205
NEXT
IF RND (C1) < P5 THEN TM = QT + INT ( RND (C1) * 41)
LET PP = ( RND (C1) < QP)
LET W = INT ((TM + 10) / 20)
LET TM = W
# 3206
LET TR = C0
LET TS = C0
IF PP THEN Z = ( RND (C1) < .3)
LET W = 6 + Z + Z
LET TR = .2 + .6 * Z
IF TM < C2 THEN W = W + C1
LET TS = 8 * TR
LET TR = C0
# 3207
LET ZT = TM - C3
IF ZT < C0 THEN ZT = C2 - TM
# 3210
LET ZC = 5 - TM - TM - OP
IF ZC < C0 THEN ZC = C0
# 3215
LET X = (ZC > P5)
LET Y = (PF = C0)
LET ZF = F0
IF Y THEN ZF = 8
# 3220
LET ZP = (W > 5) + (W > 7) + P + P
# 3225
LET Z = FS * P5
IF X OR Y THEN Z = FS + .8
# 3230
LET FS = Z
LET H = .9 * H + ZT + ZC + ZF + ZP + FS + H0 + HR
LET PF = PF - FC
IF PF < C0 THEN PF = C0
# 3235
IF NOT W1 AND H > 139 THEN GOSUB 10300
INVERSE
...
Constants:
- C0: 0.0
- C1: 1.0
- C2: 2.0
- C3: 3.0
- C4: 4.0
- P5: 0.5
Inputs:
- NP: number of party members alive.
- P: pace. 1: steady, 2: strenuous, 3: gruelling
- PF: pounds of food left. Copied over from I(8).
- R: rations. 1: filling, 2: meagre, 3: bare-bones
- W: weather. 0: very cold, 1: cold, 2: cool, 3: warm, 4: hot, 5: very hot, 6: rainy, 7: snowy, 8: very rainy, 9: very snowy
Calculated factors:
- FC: pounds of food consumed. FC = NP * (4 - R)
- ZC: clothing misery factor. ZC = 5 - W * 2 - (clothes/NP)
- ZF: food misery factor. ZF = 2 * (R - 1), or = 8 if you have no food left
- ZP: pace misery factor. ZP = (W > 5) + (W > 7) + P + P
- ZT: temperature misery factor. ZT = 0 if W is warm or cool, otherwise the number of steps away from warm or cool.
- FS: food starvation factor. FS = FS + 0.8 if there’s no food or if ZC > 0.5, otherwise FS = FS * 0.5.
- H0: seems to be H0 = 5.0 at calc time?
- HR: hardship factor. HR = 10 if the trail is rough, HR = 20 if someone just died, 20% chance of HR = 20 if the water is bad, 10% chance of HR = 10 if there’s very little water
Output:
- H: party health score. 0-34 is good, 35-69 is fair, 70-104 is poor, 105-139 is very poor. Capped at 139. H = .9 * H + ZT + ZC + ZF + ZP + FS + H0 + HR
The inputs were figured out by checking what text the game prints on the screen for those variables; e.g. W is used to fetch a string from an array W$ that looks like this:
[0xa0ee] 5780250001000a W$[10] =
[0xa0f8] 09efa6 - b'very cold'
[0xa0fb] 04eba6 - b'cold'
[0xa0fe] 04e7a6 - b'cool'
[0xa101] 04e3a6 - b'warm'
[0xa104] 03e0a6 - b'hot'
[0xa107] 08d8a6 - b'very hot'
[0xa10a] 05d3a6 - b'rainy'
[0xa10d] 05cea6 - b'snowy'
[0xa110] 0ac4a6 - b'very rainy'
[0xa113] 0abaa6 - b'very snowy'
The health factors are all conjecture based on the inputs, but I think they make sense. We have the formula for H, and there’s only one misery factor which would be affected by waiting 14272 years: the food starvation factor. In case you’re wondering what that looks like:
[0x9bb0] 4653967e4f4234 FS = 4166608.55078125
Yikes. We can see that every day after crossing Snake River, H would increase by at least 4.1 million, and then be rounded down to the worst possible health score of 139. It would take 15 days of doing nothing but sitting and eating to bring the food starvation factor down to 127, by which point everyone in the party would be dead.
But finally, FINALLY we know how to distort reality enough to get this train back on the rails. The party is back to full health, all that’s left is to get past the final fork at The Dalles and…
Did we make it to Oregon???
Piss. I couldn’t find a technical contact for MECC, so I dug around and saw line 50050 was in TRADE.LIB; namely the code responsible for drawing the inventory. Error 53 is the Apple II error code for GS/OS: parameter out of range. But what could possibly be wrong with the inventory? It was fine on the road!
Oh. Wait.
[0x9d29] 49802200010009 I$[9] =
[0x9d33] 000000 - b''
[0x9d36] 0512a9 - b'Wagon'
[0x9d39] 040ea9 - b'oxen'
[0x9d3c] 10fea8 - b'sets of clothing'
[0x9d3f] 07f7a8 - b'bullets'
[0x9d42] 0ceba8 - b'wagon wheels'
[0x9d45] 0be0a8 - b'wagon axles'
[0x9d48] 0dd3a8 - b'wagon tongues'
[0x9d4b] 0ec5a8 - b'pounds of food'
[0x9d4b] 49003400010009 I[9] =
[0x9d57] ffffffffff - -1.7014118342085515e+38
[0x9d5c] ffffffffff - -1.7014118342085515e+38
[0x9d61] 8310000000 - 4.5
[0x9d66] 8528000000 - 21.0
[0x9d6b] 8a09000000 - 548.0
[0x9d70] 8100000000 - 1.0
[0x9d75] 8100000000 - 1.0
[0x9d7a] 8100000000 - 1.0
[0x9d7f] 8200000000 - 2.0
The game was kind enough to gift me -170 billion billion billion billion wagons.
Actually this was a red herring. Line 50050 is also on Side B inside END.LIB, aka. the screen before the crash (choosing between floating down the Columbia River or taking the Barlow Toll Road).
And guess what.
It reads the date:
# 50050
[0x023d] POKE 900,NP
[0x0245] POKE 901,AY - 1800
[0x0252] POKE 902,AM
[0x025a] POKE 903,AD
AY is the variable containing the year, and the POKE instruction tries to shove the value of AY - 1800 into a single byte of the regular Apple II memory, causing a crash as it is much larger than the maximum of 255.
These POKEs seem to be how Oregon Trail saves key information when it switches between BASIC programs. Most of the game is handled by the main program “OREGON TRAIL” (written by John Krenz), which loads in modules like “RIVER.LIB” and keeps access to all the variables. Whenever a new main program is loaded in, all the BASIC variables are lost except for whatever data you happened to copy to regular memory with POKE. At the end of the game you have the option of floating down the river, which uses a new main program “FLOAT” (written by Steven Splinter) before switching again to the “WIN” program, so it follows that the game would need to use the POKEd numbers.
What happens if we mod the BASIC code in memory to say something else?
Finally we have a definitive answer about whether this whole ordeal is possible. Unlike every other progress screen in the game, the ending screen has the year hardcoded to start with “18”, followed by the printed text of whatever PEEK 901 is. The maximum number of years you can take without crashing the game is 207, and any attempts longer than 51 years will end in the wrong century.
Really, there wasn’t much point going on; it felt like further trickery went against the spirit of the challenge. Maybe there’s closure enough in knowing that if you somehow avoid starvation for 142 centuries, the game dicks you at the last possible moment by expecting the year to be sensible.
Then again we’re 98% of the way there
I lied. Here’s a improved version of Oregon Trail side B with the following quality-of-life changes:
- Waiting by a river for conditions to improve resets the food starvation factor to 0.
- The final sequence uses two bytes to store the year.
Many thanks to the CiderPress II team for making it easy to load replacement code onto the floppy.
Conclusions
Applesoft BASIC is majestically slow, but thanks to 40-bit floating point and standard commands for drawing and text manipulation, Oregon Trail works much better than I could have expected after running the simulation for 14272 years. The final screen being broken is a bit of a letdown, but understandable given how unlikely it is that you’d take 14272 years to finish the game.
Honestly, one of my aims has been to create a reverse engineering approach that will work for any system, regardless of knowledge or experience, and give you the basic steps for how to understand and modify it. This was a bit of a test run of this, and I think it could be considered a success. Eventually I’ll write it down properly, but hopefully this effort will give you some ideas for your next reversing project.
And albrot? Well, he was able to fulfill his dream and become the first person to survive for 15000 years on the Oregon Trail. A well deserved victory.
If you’re interested in the tools I wrote for this work, the Applesoft BASIC decompiler and variable scraper I wrote are available here. Happy trails!
Premium IPTV Experience with line4k
Experience the ultimate entertainment with our premium IPTV service. Watch your favorite channels, movies, and sports events in stunning 4K quality. Enjoy seamless streaming with zero buffering and access to over 10,000+ channels worldwide.
