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!