Post subject: Finding the RNG memory address in Dune 2 for JPC-RR
btl
He/Him
Joined: 3/20/2018
Posts: 6
I was doing a test TAS of Dune 2 and in level 2 you need to harvest spice to beat the level, and harvesting is based on RNG and bruteforcing just didn't seem viable after several failed attempts. I've read some information about RNG prediction and various guides on luck manipulation scattered on tasvideos.org, but none of this information applied directly to JPC-RR. There's also an open source reverse engineered version of Dune 2 available, which made it easy to see how RNG is done in Dune 2! When a harvester harvests it makes two decisions using RNG, in the function Script_Unit_Harvest. The first check is if it will be adding 1 spice to its total harvested amount, and as you see from the code here it only harvests when the RNG gives it an even number.
Language: c

u->amount += Tools_Random_256() & 1;
After that, though I'm not 100% sure here, it does another check where it's decided if the amount of spice on the ground is decreased by 1:
Language: c

if ((Tools_Random_256() & 0x1F) != 0) return 1; // If the RNG-value is divisible by 32 it returns from the function which skips the next line where the spice on the ground is decreased. Map_ChangeSpiceAmount(packed, -1); // Decrease the spice on the ground by 1 spice.
I couldn't figure out what the return values 0 and 1 means, but locking the RNG-seed resulted in the harvester eating through a patch of spice in record time. The RNG-method is in the function Tools_Random_256, where you can see that the RNG-value is stored in a static variable. JPC-RR doesn't have any means of debugging that I've found, however DOSBox got a built-in debugger, that can be used. So to follow along here you need to install DOSBox 0.74, download the precompiled debug version and put it in the folder with the regular executable. After DOSBox is ready you'll need to get Dune 2 running, start a new game and get to the point where a harvester arrives and starts harvesting. Other assumptions of knowledge is, but no limited to, assembler, working with hexadecimal values and memory addressing. When the harvester gets at least 1 percent full, you press Alt+Pause to break in the debugger, and leave the DOSBox window with Ctrl+F10. Activate the debug window and press ENTER to show the text. To dump the memory you need to use the command MEMDUMPBIN. Run the command "DOS MCBS" to find which segment and size you need to dump. Output from the "DOS MCBS"-command:
 210972115: MISC:MCB Seg  Size (bytes)  PSP Seg (notes)  Filename
 210972115: MISC:Conventional memory:
 210972115: MISC:   016F            16     0008 (DOS)
 210972115: MISC:   0171            64     0000 (free)
 210972115: MISC:   0176           256     0040
 210972115: MISC:   0187           144     0192
 210972115: MISC:   0191        580608     0192          DUNE2
 210972115: MISC:   (data addr 34EA:76A2 is 240674 bytes past this MCB)
 210972115: MISC:   8F52         12272     0000 (free)   ^L^L^L^L^L^L^L^L
 210972115: MISC:   9252         56000     0000 (free)
 210972115: MISC:Upper memory:
 210972115: MISC:   9FFF        196608     0008 (DOS)    SC
 210972115: MISC:   D000         65520     0000 (free)
In this case I had to do a memory dump for the segment "0191" with a size of "580608" which translates to "MEMDUMPBIN 0191:0 580608". The zero after "0191" is the start address. The dump is stored as MEMDUMP.BIN in %AppData%\..\Local\VirtualStore\Program Files (x86)\DOSBox-0.74, and you got make a copy of that file in another folder; I named them "memdump<times>-<percentage>.bin". After you've done that, press F5 to resume Dune 2 again, wait for the harvested amount to increase and break again by pressing Alt+Pause, and take another memory dump. Repeat this at least 5 times. I couldn't find any programs that allowed me to search for changes in memory dumps, so I hacked together a Python-script that could compare memory dumps and compare to a value.
Language: python

import sys import os EXIT_CODE_DIFFERENT_SIZE = -1 def main(initial_filename, changed_filename, diff_filename, compare_method): initial_dump = open(initial_filename, 'rb') changed_dump = SingleValueFileImpostor(changed_filename) \ if is_int(changed_filename) else open(changed_filename, 'rb') diff_output = open(diff_filename, 'wb') compare = get_compare_method(compare_method.strip('\'')) if not is_diff_file(initial_filename) and not (is_diff_file(changed_filename) or is_int(changed_filename)) \ and not same_size(initial_dump, changed_dump): eprint('When comparing raw files they must be equal in size.') exit(EXIT_CODE_DIFFERENT_SIZE) elif is_diff_file(initial_filename) and not is_diff_file(changed_filename): changed_dump.seek(0, 2) changed_max_address = changed_dump.tell() while True: bytes_address_and_value = initial_dump.read(5) if at_end_of_file(bytes_address_and_value): break address = int.from_bytes(bytes_address_and_value[0:4], byteorder='big') initial_value = bytes_address_and_value[4:5] if changed_max_address <= address: continue changed_dump.seek(address) changed_value = changed_dump.read(1) if compare(initial_value, changed_value): diff_output.write(address.to_bytes(4, byteorder='big')) diff_output.write(changed_value if not is_int(changed_filename) else initial_value) else: while True: address = initial_dump.tell() initial_value = initial_dump.read(1) if at_end_of_file(initial_value): break changed_dump.seek(address, 0) changed_value = changed_dump.read(1) if compare(initial_value, changed_value): diff_output.write(address.to_bytes(4, byteorder='big')) diff_output.write(changed_value if not is_int(changed_filename) else initial_value) initial_dump.close() changed_dump.close() diff_output.close() def is_int(value): try: int(value) return True except ValueError: return False def get_compare_method(compare_method): comparisons = { '==': lambda x, y: x == y, '<': lambda x, y: x < y, '>': lambda x, y: x > y, '<=': lambda x, y: x <= y, '>=': lambda x, y: x >= y, '!=': lambda x, y: x != y } return comparisons.get(compare_method, lambda x, y: False) def at_end_of_file(byte): return byte == b'' def same_size(file1, file2): initial_size = get_file_size(file1) changed_size = get_file_size(file2) return initial_size == changed_size def is_diff_file(filename): return filename[-4:].lower() == '.mdd' def get_file_size(file): current_position = file.tell() file.seek(0, 2) position = file.tell() file.seek(current_position, 0) return position def eprint(*args, **kwargs): print(*args, file=sys.stderr, **kwargs) class SingleValueFileImpostor: def __init__(self, value): self.value = int(value) def seek(self, address, position=0): pass def tell(self): return sys.maxsize def read(self, size): return bytes([self.value]) def close(self): pass if len(sys.argv) != 5: eprint("Usage: " + os.path.basename( sys.argv[0]) + " <initial dump> '<method:==|<|>|<=|>=|!=)>' <changed dump|integer> <output diff[.mdd]>") else: main(sys.argv[1], sys.argv[3], sys.argv[4], sys.argv[2])
Using this script you can find the address for the harvester's current spice percentage by comparing them like this, but you'll have to change the compare method and values to match your harvester spice percentage:
.\memdumpdiff.py .\MEMDUMP1-1.BIN '<' .\MEMDUMP2-5.BIN mem12.mdd
.\memdumpdiff.py .\mem12.mdd '<=' 8 .\mem12le8.mdd
.\memdumpdiff.py .\mem12le8.mdd '<' .\MEMDUMP3-8.BIN mem23.mdd
.\memdumpdiff.py .\mem23.mdd '<' .\MEMDUMP4-11.BIN mem34.mdd
.\memdumpdiff.py .\mem34.mdd '>' 10 mem34b10.mdd
.\memdumpdiff.py .\mem34b10.mdd '<' 15 mem34l15.mdd
.\memdumpdiff.py .\mem34l15.mdd '<' MEMDUMP5-22.BIN mem45.mdd
.\memdumpdiff.py .\mem45.mdd '>=' 22 mem45b22.mdd
This left me with the file "mem45b22.mdd" which now contained the address and current harvester percentage value in hex. The mdd-file is a collection of 4+1 bytes, 4 bytes for the address and 1 byte for the value. To view its contents I used HxD that you can download from here: https://mh-nexus.de/en/hxd/ The memory address in my case was 0007986C, which isn't quite the way DOSBox addresses it, but you can set a memory change breakpoint on that address and it will work. The breakpoint command is as follows, "BPM 0191:7986C", and after you've added it you resume Dune 2 by pressing F5. After a little while it should break and you should be in the function I mentioned earlier - the assembly version of it. After a few seconds the debugger stops because the memory address value was changed (1), and from the address the breakpoint hit I scrolled up a bit and recognized the "harvest 1 percent spice" line (2):
1717:272E  9A04005F2B          call 2B5F:0004 <-- The address of the function where the RNG is calculated.
1717:2733  2401                and  al,01 <-- (2) The "& 1" part that adds spice to the harvester if the number is even.
1717:2735  C41E6862            les  bx,[6268]              ds:[6268]=0B84
1717:2739  268A5758            mov  dl,es:[bx+58]          es:[0BDC]=001D
1717:273D  02D0                add  dl,al
1717:273F  C41E6862            les  bx,[6268]              ds:[6268]=0B84
1717:2743  26885758            mov  es:[bx+58],dl          es:[0BDC]=001D <-- Actual instruction that changes the spice amount in the harvester.
1717:2747  C41E6862            les  bx,[6268]              ds:[6268]=0B84 <-- (1) Where the debugger stopped.
From here you can se that the proper address for the harvester spice amount is at "es:[bx+58]", the ES value is at the top in the debugger "ES=7A5A" and BX contains the value "0B84" as set on instruction "1717:2735". This means the "correct" address in memory can be viewed by adding BX to 58, which is 0B84+58=0BDC, and run the "view memory command" on "es:[bx+58]": D 7A5A:0BDC But in this case the value of the harvester is not that important, since you're trying to find the RNG-seed, and from the source code you know that it's a static value which resides on the same memory address every time. The call to the RNG-function is at instruction 1717:272E, which means the RNG-function is located at 2B5F:0004. By running the command "C 2B5F:0004" DOSBox will jump to that address in the instruction window, or if you want to step through the function you select the line at 1717:272E, press F9 to set a breakpoint there and press F5 to start Dune 2 again. When the breakpoint hits now, you press F11 to step into the call and press it five more times to get to 2B5F:000E. I won't go in depth on the actual implementation, but from this function you can extract the memory addresses for the RNG-seed:
2B5F:0006  B8EA34              mov  ax,34EA
2B5F:0009  8ED8                mov  ds,ax <-- Sets the DS-register to 34EA
2B5F:000B  BEA276              mov  si,76A2
2B5F:000E  8A04                mov  al,[si]                ds:[76A2]=8058 <-- This retrieves the byte "58" from the address ds:[si], which is at 34EA:76A2 in memory
2B5F:0010  D0E8                shr  al,1
2B5F:0012  D0E8                shr  al,1
2B5F:0014  D05402              rcl  byte [si+02],1         ds:[76A4]=0076 <-- \
2B5F:0017  D05401              rcl  byte [si+01],1         ds:[76A3]=7680 <-- These two instructions retrieves the next two bytes of the RNG-value.
This means that the RNG-value for Dune 2 is stored in memory in the addresses 34EA:76A2, 34EA:76A3 and 34EA:76A4. But that's segmented addresses and do you no good in JPC-RR, so you have to "convert" them to linear addresses; you can read more about that addressing here. The conversion is done by multiplying the segment address with 10 (hexadecimal) and adding the address to that value. Since the DOSBox address is in segment 0191, you have to "normalize" it by subracting that segment adress from the final address. So for my RNG-addresses the calculation of the "normalized" linear addresses are as follows: DOSBox segment address: 0191 * 10 = 1910 RNG segment address: 34EA * 10 = 34EA0 1st byte: 34EA0 + 76A2 - 1910 = 3AC32 2nd byte: 34EA0 + 76A3 - 1910 = 3AC33 3rd byte: 34EA0 + 76A4 - 1910 = 3AC34 Now all that remains to find those values in JPC-RR, which means you have to "translate" the DOSBox address to JPC-RR. To do this I made a memory dump from JPC-RR via Snapshot -> RAM Dump -> Binary and put it in the same folder as the DOSBox dumps. Then I opened the first DOSBox dump and the JPC-RR dump in HxD. I tiled the two windows side by side and searched for "dune2", an educated guess, and searched in both windows until I found a place where the memory matched for a big chunk of memory. I found that memory around the addresses 3B0D0 and 34D40 were equal and used that as an reference point. The difference between 3B0D0 and 34D40, 3B0D0-34D40, is 6390. This means you need to add 6390 to the RNG address from DOSBox to find it in JPC-RR. Adding this offset to your DOSBox RNG address gives the corresponding address in JPC-RR as follows: 1st byte: 3AC32 + 6390 = 40FC2 2nd byte: 3AC33 + 6390 = 40FC3 3rd byte: 3AC34 + 6390 = 40FC4 You can then read them with this simple lua-script while running Dune 2:
Language: lua

while true do a, b = jpcrr.wait_event(); if a == "lock" then if nowTick ~= jpcrr.clock_time() then nowTick=jpcrr.clock_time() print(jpcrr.read_byte(266178)) print(jpcrr.read_byte(266179)) print(jpcrr.read_byte(266180)) end jpcrr.release_vga(); end end
I also copypasted together this script that prints the next RNG-value that will be produced; it contains code that's borrowed from all over the place :):
Language: lua

addressList = {266178, 266179, 266180} function bitor(a,b)--Bitwise or p,c=1,0 while a+b>0 do ra,rb=a%2,b%2 if ra+rb>0 then c=c+p end a,b,p=(a-ra)/2,(b-rb)/2,p*2 end return c end function bitxor(a,b) r = 0 for i = 0, 31 do x = a / 2 + b / 2 if x ~= math.floor (x) then r = r + 2^i end a = math.floor (a / 2) b = math.floor (b / 2) end return math.floor(r) end function bitand(a, b) result = 0 bitval = 1 while a > 0 and b > 0 do if a % 2 == 1 and b % 2 == 1 then -- test the rightmost bits result = result + bitval -- set the current bit end bitval = bitval * 2 -- shift left a = math.floor(a/2) -- shift right b = math.floor(b/2) end return result end function lshift(x, by) return math.floor(x * 2 ^ by) end function rshift(x, by) return math.floor(x / 2 ^ by) end byteOld = {} if not searchList then searchList={} end if addressList then for i,v in ipairs(addressList) do table.insert(searchList, v) end end while true do a, b = jpcrr.wait_event(); if a == "lock" then if nowTick ~= jpcrr.clock_time() then nowTick=jpcrr.clock_time() s_randomSeed = {} currentSeed = 1 for i,v in ipairs(searchList) do byteValue = jpcrr.read_byte(v) if byteOld[v] == nil or byteOld[v] ~= byteValue then byteOld[v]=byteValue s_randomSeed[currentSeed] = byteValue currentSeed = currentSeed + 1 end end val16 = bitor(lshift(s_randomSeed[2],8),s_randomSeed[3]); val8 = bitand(rshift(bitxor(val16,32768),15), 1); val16 = bitor(lshift(val16, 1),bitand(rshift(s_randomSeed[1],1),1)) val8 = rshift(s_randomSeed[1],2) - s_randomSeed[1] - val8 -- Emulate overflow for a signed byte if val8 < 0 then val8 = 256 + val8 end s_randomSeed[1] = bitand(bitor(lshift(val8,7),rshift(s_randomSeed[1],1)), 255) s_randomSeed[2] = bitand(rshift(val16,8), 255) s_randomSeed[3] = bitand(val16,255) print("next random " .. bitxor(s_randomSeed[1], s_randomSeed[2])) end jpcrr.release_vga(); end end
That's it for this guide!