Last active
January 15, 2020 22:02
-
-
Save Zinfidel/7dec567d186508f56f5d14509c1a88d6 to your computer and use it in GitHub Desktop.
SNES Harvest Moon BizHawk Scripts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
--[[ | |
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
--[[ | |
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