Skip to content

Instantly share code, notes, and snippets.

@Zinfidel
Last active January 15, 2020 22:02
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Zinfidel/7dec567d186508f56f5d14509c1a88d6 to your computer and use it in GitHub Desktop.
Save Zinfidel/7dec567d186508f56f5d14509c1a88d6 to your computer and use it in GitHub Desktop.
SNES Harvest Moon BizHawk Scripts
--[[
Harvest Moon RNG Prediction Script for BizHawk
By Zinfidel
For Harvest Moon [SNES] [NTSC]
Harvest Moon's RNG Function:
The game's program uses a 24-bit, overlapped linear-feedback XORshift register for RNG values. It is
updated at least once per game engine loop. It is also updated an extra 1 time if there are any
actors on the screen, and once per vblank of the screen. The RNG assembler is presented at the bottom
of this script. The full RNG value is 24-bits long, but only the top 8 bits are returned and used. The
period of the RNG function is on the order of ~5.6 million values.
This Script's UI:
Displays the current RNG value, and a number of predicted RNG values on the left side of the game UI.
Above the displayed RNG list is the number of cycles that ocurred between frames. For example, if a
"2" is displayed, that means the RNG function was updated twice between the last frame and the current
frame. Keep in mind when trying to manipulate the RNG that many things in the game affect how the RNG
is updated, especially actors on the screen.
The NMI-RAND Problem:
There is interesting/unfortunate bug/feature in the game where the non-maskable interrupt (NMI) function
calls the RAND() function. What this means is that when the NMI interrupt occurs, which is around when
the horizotal line register (H) is at 225, the RAND function is called, which modifies the 24-bit rand
value. This interrupt ocurrs immediately, and without regard to where in the program the PC is, which means
that it can happen while the main program loop is already calculating a new RNG value. What happens here is
the program stops in the middle of an RNG calculation, performs another calculation, then returns to where
it left off in the original calculation and finishes.
This will break all of the predictions, because the RAND value in memory will be in an inderterminate state
when the normal game loop resumes. When this happens, the rand predictions will turn red to indicate they
were force updated from the game's current RAM, and previous predictions are no longer valid.
--]]
Mem100 = 0;
Mem101 = 0;
Mem102 = 0;
Accumulator = 0;
Carry = 0;
Zero = 0;
NUM_RAND_LINES = 20;
RandUpdates = 0;
RandCounter = 0;
RandCounterEnabled = true;
NMIDiscrepancy = false;
-- Returns the full *predicted* 24-bit rand value
function WholeRand()
local ret = Mem102;
ret = bit.bor(ret, bit.lshift(Mem101, 8));
ret = bit.bor(ret, bit.lshift(Mem102, 16));
return ret;
end
-- Returns the full *in-memory* 24-bit rand value
function ReadWholeRand()
local ret = mainmemory.read_u8(0x100);
ret = bit.bor(ret, mainmemory.read_u8(0x101));
ret = bit.bor(ret, mainmemory.read_u8(0x102));
return ret;
end
-- Mimics 65C816 ROR (rotate right with carry) instruction
function ROR(val)
local temp = bit.band(val, 0x1);
val = bit.rshift(val, 1);
if (Carry == 1) then
val = bit.bor(val, 0x80);
end
Carry = temp;
return val;
end
-- Mimics 65C816 ADC (add with carry) instruction
function ADC(val)
Accumulator = Accumulator + val + Carry;
Carry = bit.rshift(bit.band(Accumulator, 0x100), 8);
Accumulator = bit.band(Accumulator, 0xFF);
end
-- Read the actual rand values currently in memory and primes current registers
function ReadRAND()
Mem100 = mainmemory.read_u8(0x100);
Mem101 = mainmemory.read_u8(0x101);
Mem102 = mainmemory.read_u8(0x102);
Accumulator = emu.getregister('A');
Carry = emu.getregister('Flag C');
Zero = emu.getregister('Flag Z');
end
-- The game's random function. Sets accumulator to top 8-bits of 24-bit rand value.
function RAND()
Accumulator = Mem101;
Accumulator = bit.bxor(Accumulator, Mem100);
Accumulator = bit.band(Accumulator,0x2);
Carry = 0;
if Accumulator == 0 then Zero = 1 else Zero = 0 end;
if Zero == 0 then
Carry = 0x1;
end
Mem101 = ROR(Mem101);
Mem100 = ROR(Mem100);
Mem102 = ROR(Mem102);
Carry = 0;
Accumulator = Mem101;
ADC(0x47);
Accumulator = ROR(Accumulator);
Accumulator = ROR(Accumulator);
Accumulator = bit.bxor(Accumulator, Mem100);
if Accumulator == 0 then Zero = 1 else Zero = 0 end;
ADC(Mem102);
Mem100 = Accumulator;
Accumulator = bit.band(Accumulator, 0xFF);
return Accumulator;
end
local mem100Backup, mem101Backup, mem102Backup;
function SaveRAND()
mem100Backup = Mem100;
mem101Backup = Mem101;
mem102Backup = Mem102;
end
function LoadRAND()
Mem100 = mem100Backup;
Mem101 = mem101Backup;
Mem102 = mem102Backup;
end
-- The game's "dice roll" function. Seems to be a really roundabout way of calculating whether
-- the inverse of the given chance value is less than the rand value. It is presented here in
-- mostly faithful recreation, simply because it's possible there are functions in the game that
-- actually use the x register (counts) instead of the carry flag.
function RandRoll(randVal, chance)
local quotient = 255 / chance;
local x = 0;
local i = quotient;
while (i < randVal) do
x = x + 1;
if x == chance - 1 then
break;
else
i = i + quotient;
end
end
return x == 0;
end
-- Calculates the chance of a successful roll for the RandRoll function, given a chance value.
function GetRollChance(chance)
if chance == 0 then
return 0;
end
local hits = 0;
for rand in 0,255 do
if RandRoll(rand, chance) then
hits = hits + 1;
end
end
return hits/256;
end
-- Watch for rand access so we know how many times the rand values have been advanced.
local randhook = event.onmemoryexecute(
function()
RandUpdates = RandUpdates + 1;
if RandCounterEnabled then
RandCounter = RandCounter + 1;
end
end
, 0x838138, "Rand Updates", "System Bus" );
local randPredictions = {};
local randColor = 0xFFFFFFFF;
while true do
-- Read the game's RAM and acquire the current RAND value.
ReadRAND();
randPredictions[0] = WholeRand();
-- If our prediction from the last frame is wrong, set the discrepancy flag and change the color
-- of the rand predictions. This means that an NMI interrupt ocurred during a RAND call, and our
-- predictions from the frame before are no longer valid. The NMIDiscrepancy flag is set until
-- another script resets it.
if randPredictions[RandUpdates] ~= WholeRand() then
NMIDiscrepancy = true;
randColor = 0xFFFF0000;
end
-- Predict rand values, store them for discrepancy detection, and create a string to print to
-- the screen.
local randDisplay = RandUpdates .. " Updates";
randDisplay = randDisplay .. "\nNow: 0x" .. string.format("%02x", Mem100);
for i = 1,NUM_RAND_LINES-1 do
local newRand = RAND();
randPredictions[i] = WholeRand();
randDisplay = randDisplay .. "\n" .. string.format("%3i", i+1) .. ": 0x" .. string.format("%02x", newRand);
end
gui.text(0, 0, randDisplay, randColor, "topleft");
-- If the text color was changed to red due to a discrepancy, add GB channels to them frame-by-
-- frame until we're at white again. This acts as an aid to detect visually when discrepancies
-- occur rather than having to use scripts every time.
if randColor < 0xFFFFFFFF then randColor = randColor + 0x00001111; end
RandUpdates = 0;
emu.frameadvance();
end
-- RAND
-- 838138 sep #$20
-- 83813a lda $0101
-- 83813d eor $0100
-- 838140 and #$02
-- 838142 clc
-- 838143 beq $8146
-- 838145 sec
-- 838146 ror $0101
-- 838149 ror $0100
-- 83814c ror $0102
-- 83814f clc
-- 838150 lda $0101
-- 838153 adc #$47
-- 838155 ror a
-- 838156 ror a
-- 838157 eor $0100
-- 83815a adc $0102
-- 83815d sta $0100
-- 838160 rep #$20
-- 838162 and #$00ff
-- 838165 rtl
-- RAND Roll
-- 8089f9 sep #$30
-- 8089fb sta $92
-- 8089fd pha
-- 8089fe stz $93
-- 808a00 rep #$20
-- 808a02 lda #$00ff
-- 808a05 sta $7e
-- 808a07 lda $92
-- 808a09 sta $80
-- 808a0b jsl $838082 ; Divides 0x7e by 0x80, a=quotient
-- 808a0f sep #$20
-- 808a11 sta $93
-- 808a13 jsl $838138 ; RAND
-- 808a17 sep #$30
-- 808a19 sta $94
-- 808a1b pla
-- 808a1c dec
-- 808a1d sta $92
-- 808a1f ldx #$00
-- 808a21 lda $93
-- 808a23 cmp $94
-- 808a25 bcs $8a31
-- 808a31 txa
-- 808a32 rtl
--[[
Harvest Moon Weather Prediction Script for BizHawk
By Zinfidel
For Harvest Moon [SNES] [NTSC]
What This Script Does:
This script places weather predictions next to the predicted RNG values that are displayed by the
RNG script. The predictions are calculated based on the RNG value of the frame that the player presses
the B button to go to sleep.
How To Use This Script:
Load the RNG script first! Then load this script afterwards, but do not activate it. On the frame that the
B button is pressed to go to sleep, activate the script. A dialog will appear that will be used to calculate
an RNG cycle offset required for accurate prediction. On the dialog, click the "Start" button. The button's
text will change to "ACTIVE". Run the game, and the script should automatically pause the game when the
offset has been discovered.
Now, reset the game back to the B input, and the weather predictions displayed next to the RNG values should
be accurate. The weather prediction next to the "NOW" RNG value will be the weather selected for tomorrow's
forecast. When done with forecasting for a night, deactivate the forecast script (it is CPU intensive) and
close the dialog.
If the dialog displays the acronym "NMI" instead of a number, that means that an NMI discrepancy occurs between
the B button press and weather prediction routine. Unfortunately, this script can not account for this problem
and can not predict the forecast.
The forecast offset can be set manually by typing a number into the dialog textbox and clicking the "Set" button.
How This Script Works:
The weather forecast routine is run sometime after activating the next day, but not always on the same frame, and
definitely not after the same number of RNG cycles. There are a lot of things the game might calculate during the night,
and if any of them use the RNG, then those increments need to be accounted for in order to know the right place in
the RNG sequence to start the predictions. The tool dialog monitors for the weather prediction function to be executed,
and counts the number of times the RNG function is called until then. When it detects the weather prediction function,
the number of recorded cycles is then known. The prediction routine then cycles the RNG the known amount of times, then
determines the forecast from the input frame.
Sometimes, the prediction routine fails because of an NMI discrepancy. This occurs because Harvest Moon calls the RNG
function inside of its NMI vector routine, which means that it is possible for it to call the RNG routine while it is
already inside the RNG routine. This causes the RNG prediction script to become incorrect, and it does not currently
have a way to account for this problem (it's a complicated problem to solve). When this occurs, the prediction function
simply can't be used for that night.
--]]
SPRING = 0; SUMMER = 1; FALL = 2; WINTER = 3;
SUNNY = 0; RAIN = 1; SNOW = 2; HURRICANE = 3; EARTHQUAKE = 4; LOUD_NOISE = 5;
FLOWER_FESTIVAL = 6; HARVEST_FESITVAL = 7; THANKSGIVING_FESTIVAL = 8;
STAR_NIGHT_FESTIVAL = 9; NEW_YEAR_FESTIVAL = 10; EGG_FESTIVAL = 11;
local ForecastStrings = {
[SUNNY] = "Sunny", [RAIN] = "Rain", [SNOW] = "Snow", [HURRICANE] = "Hurricane", [EARTHQUAKE] = "Earthquake", [LOUD_NOISE] = "Loud Noise (tree)",
[FLOWER_FESTIVAL] = "Flower Festival", [HARVEST_FESITVAL] = "Harvest Festival", [THANKSGIVING_FESTIVAL] = "Thanksgiving",
[STAR_NIGHT_FESTIVAL] = "Starry Night Festival", [NEW_YEAR_FESTIVAL] = "New Year Festival", [EGG_FESTIVAL] = "Egg Festival"
}
local year; -- 0-indexed
local eventFlags; -- 1-bit = loud noise, 2-bit = earthquake
local hurricaneFlags; -- Causes chances of hurricane to double if set. Maybe it gets cleared when one has happened already?
local season, date;
local hurricaneChance; local seasonalHurricaneChances = {0x0, 0x1e, 0x0, 0x0}; --0x828eb2
local rainChance; local seasonalRainChances = {0x6, 0xa, 0xa, 0x0}; --0x828eb6
local loudNoiseChance; local seasonalLoudNoiseChances = {0x0, 0x1e, 0x0, 0x0}; --0x828ebe
local earthquakeChance; local seasonalEarthquakeChances = {0x0, 0x0, 0x0, 0x8}; --0x828ec2
local snowChance; local seasonalSnowChances = {0x0, 0x0, 0x0, 0x3}; --0x828ec6
function NormalDayPredict(rand)
if rainChance == 0 and snowChance ~= 0 then
if RandRoll(rand, snowChance) then
return SNOW;
end
else
if RandRoll(rand, rainChance) then
return RAIN;
end
end
return SUNNY;
end
function PredictWeather()
year = memory.read_u8(0x7f1f18);
season = memory.read_u8(0x7f1f19);
date = memory.read_u8(0x7f1f1b);
eventFlags = memory.read_u8(0x7f1f64);
hurricaneFlags = memory.read_u16_le(0x7f1f6c);
hurricaneChance = seasonalHurricaneChances[season + 1];
rainChance = seasonalRainChances[season + 1];
loudNoiseChance = seasonalLoudNoiseChances[season + 1];
earthquakeChance = seasonalEarthquakeChances[season + 1];
snowChance = seasonalSnowChances[season + 1];
if season == SPRING then
if date == 22 then
return FLOWER_FESTIVAL;
else
return NormalDayPredict(RAND());
end
elseif season == SUMMER then
if year == 0 then
-- The Loud Noise event can happen before the 30th of the first Summer,
-- but will always occur on the 30th if not before.
if date == 29 then
return LOUD_NOISE;
elseif loudNoiseChance ~= 0 then
if RandRoll(RAND(), loudNoiseChance) then
return LOUD_NOISE
end
end
end
if date ~= 30 and hurricaneChance > 0 then
-- Checks some flag here. If the flag isn't set, hurricane chance is doubled.
-- Might be a flag that gets set when the first hurricane of the Summer happens
-- so that at least one hurricane is likely during a Summer, but less likely afterwards.
if bit.band(hurricaneFlags, 0x1000) ~= 0 then
hurricaneChance = hurricaneChance * 2;
end
if RandRoll(RAND(), hurricaneChance) then
return HURRICANE;
end
end
return NormalDayPredict(RAND())
elseif season == FALL then
if date == 11 then
return HARVEST_FESITVAL;
elseif date == 19 then
return EGG_FESTIVAL;
else
return NormalDayPredict(RAND());
end
else -- Winter
if date == 9 then
return THANKSGIVING_FESTIVAL;
elseif date == 23 then
return STAR_NIGHT_FESTIVAL;
elseif date == 30 then
return NEW_YEAR_FESTIVAL;
else
if year == 0 then
-- If the earthquake hasn't happened, there's a chance it will happen before
-- the 8th of Winter. It will always happen on the 8th otherwise.
if bit.band(eventFlags, 0x2) ~= 0 then
if date == 7 then
return EARTHQUAKE;
else
if RandRoll(RAND(), earthquakeChance) then
return EARTHQUAKE;
end
end
end
end
return NormalDayPredict(RAND());
end
end
end
-- Set up the forecast helper.
ForecastForm, ForecastLabel, ForecastTextbox, ForecastStartBtn, ForecastSetBtn = nil;
local forecastGap = 0;
local forecasthook = event.onmemoryexecute(
function()
if RandCounterEnabled then
RandCounterEnabled = false;
if NMIDiscrepancy then
NMIDiscrepancy = false;
forecastGap = 0;
forms.settext(ForecastLabel, "NMI Discrepancy");
console.writeline("NMI RAND discrepancy detected between activation and weather prediction function.");
else
forecastGap = RandCounter;
forms.settext(ForecastLabel, forecastGap);
console.writeline(forecastGap .. " RAND cycles detected between activation and weather prediction function.");
end
forms.settext(ForecastStartBtn, "Start");
client.pause();
end
end
, 0x828cf9, "Forecast Hook", "System Bus" );
ForecastForm = forms.newform(400, 70, "Forecast Helper");
ForecastStartBtn = forms.button(ForecastForm, "Start",
function()
if not RandCounterEnabled then
RandCounterEnabled = true;
RandCounter = 0;
forecastGap = 0
forms.settext(ForecastStartBtn, "ACTIVE");
forms.settext(ForecastLabel, "0");
else
RandCounterEnabled = false;
forms.settext(ForecastLabel, RandCounter);
forms.settext(ForecastStartBtn, "Start");
end
end, nil, nil, nil, nil);
ForecastLabel = forms.label(ForecastForm, "0", nil, nil, 50, nil, true);
forms.setlocation(ForecastLabel, 80, 0);
ForecastTextbox = forms.textbox(ForecastForm, "0", 100);
forms.setlocation(ForecastTextbox, 200,0);
ForecastSetBtn = forms.button(ForecastForm, "Set",
function()
forecastGap = tonumber(forms.gettext(ForecastTextbox));
forms.settext(ForecastLabel, forecastGap);
end, 300, 0, 100);
while true do
ReadRAND();
-- Offset rand by calculated gap to align predictions with current rand.
local rand = 0;
for i = 1, forecastGap do
rand = RAND();
end
-- Display the predictions next to the rand results.
local forecastDisplay = "";
forecastDisplay = forecastDisplay .. "Gap: " .. forecastGap;
for i = 1, NUM_RAND_LINES do
SaveRAND();
forecastDisplay = forecastDisplay .. "\n" .. ForecastStrings[PredictWeather()] .. " r:" .. string.format("%02x", rand);
LoadRAND();
rand = RAND();
end
gui.text(100, 0, forecastDisplay, nil, "topleft");
emu.frameadvance();
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment