#!/usr/bin/env luajit package.path = "./?.lua;./?/init.lua;" .. package.path local GameVersion = require("src.core.GameVersion") GameVersion.set("firered") local Slots = require("src.core.game3.slot_machine") local Rng = require("src.core.game3.rng") local Opcodes = require("src.core.game3.scripting.opcodes") local failed = 0 local function check(cond, msg) if cond then print("[ok] " .. msg) else failed = failed + 1 print("[FAIL] " .. msg) end end local function eq(a, b, msg) check(a == b, string.format("%s (%s == %s)", msg, tostring(a), tostring(b))) end local PRET = "../pokefirered/src/slot_machine.c" local PRET_SPECIALS = "../pokefirered/src/field_specials.c" local function slurp(path) local f = io.open(path, "rb") if not f then return nil end local d = f:read("*a") f:close() return d end local ICON_NAMES = { ICON_7 = Slots.ICON.SEVEN, ICON_ROCKET = Slots.ICON.ROCKET, ICON_PIKACHU = Slots.ICON.PIKACHU, ICON_PSYDUCK = Slots.ICON.PSYDUCK, ICON_CHERRIES = Slots.ICON.CHERRIES, ICON_MAGNEMITE = Slots.ICON.MAGNEMITE, ICON_SHELLDER = Slots.ICON.SHELLDER, } local PAYOUT_NAMES = { PAYOUT_NONE = 0, PAYOUT_CHERRIES2 = 1, PAYOUT_CHERRIES3 = 2, PAYOUT_MAGSHELL = 3, PAYOUT_PIKAPSY = 4, PAYOUT_ROCKET = 5, PAYOUT_7 = 6, } local function body_after(src, decl) local i = src:find(decl, 1, true) if not i then return nil end local open = src:find("{", i, true) if not open then return nil end local depth, j = 0, open while j <= #src do local c = src:sub(j, j) if c == "{" then depth = depth + 1 end if c == "}" then depth = depth - 1 if depth == 0 then return src:sub(open + 1, j - 1) end end j = j + 1 end return nil end local src = slurp(PRET) local specials = slurp(PRET_SPECIALS) if not src or not specials then print("[skip] pret sources not found at " .. PRET .. " (nothing to compare against)") os.exit(0) end print("[test] 1. Reel strips match pret symbol for symbol") do local body = body_after(src, "static const u8 sReelIconAnimByReelAndPos[NUM_REELS][REEL_LENGTH]") check(body ~= nil, "sReelIconAnimByReelAndPos found in pret") local reel, pos = 0, 0 local seen = 0 for token in body:gmatch("[%w_}{]+") do if token == "{" then pos = 0 elseif token == "}" then if pos > 0 then eq(pos, Slots.REEL_LENGTH, "reel " .. reel .. " has REEL_LENGTH icons") reel = reel + 1 end elseif ICON_NAMES[token] ~= nil then eq(Slots.iconAt(reel, pos), ICON_NAMES[token], string.format("reel %d pos %d is %s", reel, pos, token)) pos = pos + 1 seen = seen + 1 end end eq(seen, 63, "every reel icon compared") end print("[test] 2. Payout table pays exactly pret's amounts") do local body = body_after(src, "static const u16 sPayoutTable[]") check(body ~= nil, "sPayoutTable found in pret") local n = 0 for name, value in body:gmatch("%[(PAYOUT_[%w_]+)%]%s*=%s*(%d+)") do local rank = PAYOUT_NAMES[name] eq(Slots.payoutFor(rank), tonumber(value), "payout for " .. name) n = n + 1 end eq(n, Slots.NUM_PAYOUT_TYPES, "every payout rank compared") end print("[test] 3. Row attributes and the pay lines each bet opens") do local body = body_after(src, "static const u8 sRowAttributes[NUM_MATCH_LINES][4]") check(body ~= nil, "sRowAttributes found in pret") local line = 0 for row in body:gmatch("{([^}]+)}") do local cols = {} for v in row:gmatch("0x%x+") do cols[#cols + 1] = tonumber(v) end eq(#cols, 4, "line " .. line .. " has four attributes") for i = 1, 4 do eq(Slots.ROW_ATTRIBUTES[line][i - 1], cols[i], string.format("line %d attribute %d", line, i - 1)) end line = line + 1 end eq(line, Slots.NUM_MATCH_LINES, "five match lines") local function lineSet(bet) local t = {} for _, id in ipairs(Slots.linesForBet(bet)) do t[id] = true end return t end local b1, b2, b3 = lineSet(1), lineSet(2), lineSet(3) eq(#Slots.linesForBet(1), 1, "bet 1 opens one line") check(b1[2], "bet 1 opens the middle row only") eq(#Slots.linesForBet(2), 3, "bet 2 opens three lines") check(b2[1] and b2[2] and b2[3], "bet 2 opens the three horizontal rows") check(not b2[0] and not b2[4], "bet 2 leaves both diagonals dark") eq(#Slots.linesForBet(3), 5, "bet 3 opens all five lines") check(b3[0] and b3[4], "bet 3 lights both diagonals") end print("[test] 4. CalcPayout pays pret's amount for each combination") -- pokefirered/src/slot_machine.c:1712 local function positionFor(reel, icon, row) for pos = 0, Slots.REEL_LENGTH - 1 do if Slots.iconAt(reel, (pos + 1 + row) % Slots.REEL_LENGTH) == icon then return pos end end return nil end local function payoutOf(bet, icons, row) local st = Slots.newState(0) st.bet = bet for reel = 0, 2 do local p = positionFor(reel, icons[reel + 1], row) if p == nil then return nil end st.reelPositions[reel] = p end local rank = Slots.calcPayout(st) return rank, st.payout end do local I = Slots.ICON -- pokefirered/src/slot_machine.c:253 local rank, coins = payoutOf(1, { I.SEVEN, I.SEVEN, I.SEVEN }, 1) eq(rank, Slots.PAYOUT.SEVEN, "three sevens on the middle row is PAYOUT_7") eq(coins, 300, "three sevens pay 300") rank, coins = payoutOf(1, { I.ROCKET, I.ROCKET, I.ROCKET }, 1) eq(rank, Slots.PAYOUT.ROCKET, "three rockets is PAYOUT_ROCKET") eq(coins, 100, "three rockets pay 100") rank, coins = payoutOf(1, { I.PIKACHU, I.PIKACHU, I.PIKACHU }, 1) eq(rank, Slots.PAYOUT.PIKAPSY, "three Pikachu is PAYOUT_PIKAPSY") eq(coins, 15, "three Pikachu pay 15") rank, coins = payoutOf(1, { I.MAGNEMITE, I.MAGNEMITE, I.MAGNEMITE }, 1) eq(rank, Slots.PAYOUT.MAGSHELL, "three Magnemite is PAYOUT_MAGSHELL") eq(coins, 8, "three Magnemite pay 8") rank, coins = payoutOf(1, { I.MAGNEMITE, I.MAGNEMITE, I.SHELLDER }, 1) eq(rank, Slots.PAYOUT.NONE, "Magnemite and Shellder do not match each other") eq(coins, 0, "a mixed line pays nothing") rank, coins = payoutOf(1, { I.CHERRIES, I.SEVEN, I.SEVEN }, 1) eq(rank, Slots.PAYOUT.CHERRIES2, "one cherry on reel 1 is PAYOUT_CHERRIES2") eq(coins, 2, "one cherry pays 2") rank, coins = payoutOf(1, { I.CHERRIES, I.CHERRIES, I.SEVEN }, 1) eq(rank, Slots.PAYOUT.CHERRIES3, "cherries on reels 1 and 2 is PAYOUT_CHERRIES3") eq(coins, 6, "two cherries pay 6") rank, coins = payoutOf(1, { I.SEVEN, I.SEVEN, I.SEVEN }, 0) eq(rank, Slots.PAYOUT.NONE, "a bet of 1 does not pay the top row") eq(coins, 0, "an unlit line pays nothing") rank, coins = payoutOf(2, { I.SEVEN, I.SEVEN, I.SEVEN }, 0) eq(rank, Slots.PAYOUT.SEVEN, "a bet of 2 pays the top row") check(coins >= 300, "the top row pays the same 300, got " .. tostring(coins)) end print("[test] 5. Bias chance table and CalcSlotBias pick the machine's class") do local body = body_after(src, "static const u16 sReelBiasChances[][NUM_PAYOUT_TYPES]") check(body ~= nil, "sReelBiasChances found in pret") local chances = {} local idx = 0 for row in body:gmatch("{([^}]+)}") do local t = {} for name, value in row:gmatch("%[(PAYOUT_[%w_]+)%]%s*=%s*(0x%x+)") do t[PAYOUT_NAMES[name]] = tonumber(value) end chances[idx] = t idx = idx + 1 end eq(idx, Slots.NUM_MACHINE_CLASSES, "six machine classes in pret") for machine = 0, idx - 1 do for rank = 0, Slots.NUM_PAYOUT_TYPES - 1 do eq(Slots.BIAS_CHANCES[machine][rank], chances[machine][rank], string.format("machine %d chance for rank %d", machine, rank)) end end -- pokefirered/src/slot_machine.c:1680 local mismatches, sevens = 0, 0 for machine = 0, idx - 1 do for seed = 1, 400 do Rng.SeedRng(seed * 31 + machine) local st = Slots.newState(machine) local got = Slots.calcBias(st) Rng.SeedRng(seed * 31 + machine) local rval = math.floor(Rng.Random() / 4) local expect = Slots.NUM_PAYOUT_TYPES - 1 for i = 0, Slots.NUM_PAYOUT_TYPES - 2 do if rval < chances[machine][i] then expect = i break end end if got ~= expect then mismatches = mismatches + 1 end if got == Slots.PAYOUT.SEVEN then sevens = sevens + 1 end end end eq(mismatches, 0, "CalcSlotBias follows pret's threshold table on every machine") check(sevens > 0, "the 7 bias is reachable, hit " .. sevens .. " times") end print("[test] 6. The bias reaches the reels") local function playOnce(st) st.bet = 3 st.payout = 0 Slots.startReels(st) for reel = 0, Slots.NUM_REELS - 1 do for _ = 1, 5 + (Rng.Random() % 40) do Slots.spinStep(st) end Slots.stopCurrentReel(st, reel, reel) local guard = 0 while Slots.isReelSpinning(st, reel) and guard < 400 do Slots.spinStep(st) guard = guard + 1 end check(guard < 400, "reel " .. reel .. " settled on its stop position") end return Slots.calcPayout(st), st.payout end do Rng.SeedRng(0x51075) local st = Slots.newState(0) local wins, coins, jackpots = {}, {}, 0 for bias = 0, 6 do wins[bias], coins[bias] = 0, 0 end for play = 1, 700 do local bias = play % 7 st.machineBias = bias local rank, paid = playOnce(st) if rank ~= Slots.PAYOUT.NONE then wins[bias] = wins[bias] + 1 end coins[bias] = coins[bias] + paid if rank == Slots.PAYOUT.SEVEN then jackpots = jackpots + 1 end end eq(wins[0], 0, "a PAYOUT_NONE bias never lands a paying line") eq(coins[0], 0, "a PAYOUT_NONE bias pays nothing") for bias = 1, 6 do check(wins[bias] > 0, string.format("bias %d lands paying lines (%d in 100 plays)", bias, wins[bias])) end check(jackpots > 0, "a biased machine can land the 300 coin jackpot") end print("[test] 7. A stop never moves the reel more than pret's five positions") do Rng.SeedRng(0xBEEF) local st = Slots.newState(3) for _ = 1, 200 do Slots.calcBias(st) Slots.startReels(st) for reel = 0, Slots.NUM_REELS - 1 do for _ = 1, 3 + (Rng.Random() % 30) do Slots.spinStep(st) end local before = Slots.nextReelPosition(st, reel) Slots.stopCurrentReel(st, reel, reel) local delta = (before - st.destReelPos[reel]) % Slots.REEL_LENGTH check(delta <= 4, "stop offset " .. delta .. " is inside pret's five position window") local guard = 0 while Slots.isReelSpinning(st, reel) and guard < 400 do Slots.spinStep(st) guard = guard + 1 end end Slots.calcPayout(st) end end print("[test] 8. Machine ids map to pret's luck classes") do local body = body_after(specials, "static const u8 sSlotMachineIndices[]") check(body ~= nil, "sSlotMachineIndices found in pret") local n = 0 for value in body:gmatch("%d+") do eq(Slots.MACHINE_CLASS_BY_ID[n], tonumber(value), "machine id " .. n .. " luck class") n = n + 1 end eq(n, Slots.NUM_MACHINE_IDS, "every Game Corner machine id mapped") for id = 0, n - 1 do local class = Slots.MACHINE_CLASS_BY_ID[id] check(class >= 0 and class < Slots.NUM_MACHINE_CLASSES, "machine id " .. id .. " maps into the bias table") end -- pokefirered/src/slot_machine.c:871 eq(Slots.newState(99).machineIdx, 0, "an out of range machine id clamps to 0") end print("[test] 9. StopReel1 reads the bytes below the strip at a negative index") do -- pokefirered/src/slot_machine.c:261 if not src then print("[skip] no pret checkout at " .. PRET) else local bias = src:find("static const u16 sReelBiasChances", 1, true) local strip = src:find("static const u8 sReelIconAnimByReelAndPos", 1, true) check(bias ~= nil and strip ~= nil and bias < strip, "pret declares the bias table right before the reel strips") if bias and strip then local between = src:sub(src:find("};", bias, true) + 2, strip - 1) check(between:gsub("%s", "") == "", "nothing is declared between the two tables") end end -- pokefirered/src/slot_machine.c:1361 local tail = Slots.BIAS_CHANCES[Slots.NUM_MACHINE_CLASSES - 1] local last = tail[Slots.NUM_PAYOUT_TYPES - 1] local prev = tail[Slots.NUM_PAYOUT_TYPES - 2] eq(Slots.iconAt(0, -1), math.floor(last / 256), "one byte below the strip") eq(Slots.iconAt(0, -2), last % 256, "two bytes below the strip") eq(Slots.iconAt(0, -3), math.floor(prev / 256), "three bytes below the strip") eq(Slots.iconAt(0, -4), prev % 256, "four bytes below the strip") check(not Slots.testIconAttribute(1, Slots.iconAt(0, -1)), "the byte below is not a cherry") check(not Slots.testIconAttribute(1, Slots.iconAt(0, -2)), "the second byte below is not a cherry") check(not Slots.testIconAttribute(1, Slots.iconAt(0, -3)), "the third byte below is not a cherry") eq(Slots.iconAt(1, -3), Slots.iconAt(0, 18), "a negative index on reel 2 falls into reel 1") end print("[test] 10. playslotmachine is opcode 0x89") do local row = Opcodes.TABLE and Opcodes.TABLE[0x89] or nil check(row ~= nil, "opcode 0x89 is decoded") if row then eq(row.name, "playslotmachine", "opcode 0x89 is playslotmachine") eq(row.size, 3, "playslotmachine is three bytes") end end print("[test] 11. Betting moves coins through Bag.Coins") do local Bag = require("src.core.game3.bag") local session = { coins = 10 } local st = Slots.newState(0) check(Slots.betOne(st, session), "a bet of one coin is accepted") eq(st.bet, 1, "bet is 1") eq(Bag.Coins.get(session), 9, "one coin left the purse") check(Slots.betMax(st, session), "R fills the bet to three") eq(st.bet, Slots.MAX_BET, "bet is 3") eq(Bag.Coins.get(session), 7, "two more coins left the purse") Slots.refundBet(st, session) eq(Bag.Coins.get(session), 10, "quitting refunds the whole bet") eq(st.bet, 0, "the refunded bet is cleared") local poor = { coins = 2 } local st2 = Slots.newState(0) check(Slots.betMax(st2, poor), "R with two coins is accepted") eq(st2.bet, 2, "the bet is what the player could afford") eq(Bag.Coins.get(poor), 0, "the purse is empty") check(not Slots.betOne(st2, poor), "an empty purse cannot bet") local winner = { coins = 0 } local st3 = Slots.newState(0) st3.payout = 3 check(Slots.payCoin(st3, winner), "the payout pays one coin at a time") eq(Bag.Coins.get(winner), 1, "one coin arrived") eq(st3.payout, 2, "the payout counted down") check(Slots.payAll(st3, winner), "START pays the rest at once") eq(Bag.Coins.get(winner), 3, "every coin arrived") eq(st3.payout, 0, "the payout is empty") check(not Slots.payAll(st3, winner), "an empty payout pays nothing") end print("[test] 12. The playslotmachine opcode opens the machine and blocks the script") do local Flags = require("src.core.game3.scripting.flags") local Vm = require("src.core.game3.scripting.vm") local Adapters = require("src.core.game3.scripting.adapters") local SlotUi = require("src.ui.game3.slot_machine") local VAR_RESULT = 0x800D local store = Flags.newStore() local vm = Vm.new({ store = store, scripts = { t = { { op = "setvar", [1] = VAR_RESULT, [2] = 4 }, { op = "playslotmachine", [1] = VAR_RESULT }, { op = "setvar", [1] = 0x4001, [2] = 7 }, { op = "end" }, }, }, adapters = Adapters.host(nil, nil, nil), }) vm:start("t") check(SlotUi.isOpen(), "playslotmachine opened the slot machine screen") eq(SlotUi.state and SlotUi.state.machineIdx, 4, "the operand var chose the machine class") eq(vm.ctx.status, "waiting", "the script is blocked on the screen") eq(Flags.getVar(store, vm.ctx, 0x4001), 0, "the script has not run past the machine") vm:tick() check(SlotUi.isOpen(), "the script stays blocked while the machine is open") SlotUi.close() vm:tick() eq(Flags.getVar(store, vm.ctx, 0x4001), 7, "closing the machine resumes the script") end if failed > 0 then print("[test] FAILED " .. failed) os.exit(1) end print("[test] all passed")