Merge pull request #2442 from 1Jamie/grandpas-garage

feat(game3): implement roaming legendary beasts, Seagallop ferry, poison sfx, battle trapping moves, field weather, and overworld fixes
This commit is contained in:
bryanthaboi
2026-09-23 16:38:59 -04:00
committed by GitHub
36 changed files with 2265 additions and 87 deletions
-11
View File
@@ -400,17 +400,6 @@ function Game3:_handleBootAction(action)
local modsDiff = SaveData.modsDiff and SaveData.modsDiff(save, activeMods) or nil
local session = Schema.fromSaveTable(save)
Options.bind(session, self.options)
local legacy = Profile.of(session.version).map.legacyPrefixes or {}
local legacyMap = false
if type(session.map) == "string" then
for _, prefix in ipairs(legacy) do
if session.map:sub(1, #prefix) == prefix then legacyMap = true break end
end
end
if legacyMap then
print("[game3] ignoring legacy Sevii save map " .. session.map)
session = Schema.newGame({ gender = 0 })
end
self:adoptSave(session, not self._modSaveAdopted)
self._modSaveAdopted = true
self.sessionStartedAt = os.time()
+3
View File
@@ -196,6 +196,9 @@ Audio.LEGENDARY_BATTLE_SONGS = {
[144] = { "battleLegend", 341 }, -- SPECIES_ARTICUNO
[145] = { "battleLegend", 341 }, -- SPECIES_ZAPDOS
[146] = { "battleLegend", 341 }, -- SPECIES_MOLTRES
[243] = { "battleDeoxys", 339 }, -- SPECIES_RAIKOU
[244] = { "battleDeoxys", 339 }, -- SPECIES_ENTEI
[245] = { "battleDeoxys", 339 }, -- SPECIES_SUICUNE
[249] = { "battleLegend", 341 }, -- SPECIES_LUGIA
[250] = { "battleLegend", 341 }, -- SPECIES_HO_OH
}
+23 -4
View File
@@ -14,8 +14,17 @@ function Ai.loadPack(opts)
if Ai._pack and not opts.force then return Ai._pack end
Ai._packTried = true
local rel = "data/generated/gba/battle_ai/pack.lua"
local src = require("src.core.game3.dataset").cache():read(rel)
if not src then error("battle AI: " .. rel .. " is missing from the cache") end
local src
local ok, cache = pcall(function() return require("src.core.game3.dataset").cache() end)
if ok and cache and cache.read then
src = cache:read(rel)
end
if not src then
if opts.required then
error("battle AI: " .. rel .. " is missing from the cache")
end
return nil
end
local t = assert(load(src, "@" .. rel, "t", {}))()
if type(t) ~= "table" or type(t.table) ~= "table" or type(t.scripts) ~= "table" then
error("battle AI: " .. rel .. " is not a script pack")
@@ -115,8 +124,8 @@ end
local function move_target_byte(mv)
if move_num(mv) == 0 then return 0 end
local Moves = require("src.core.game3.battle.moves")
local m = Moves.get(mv)
return tonumber(m and m.target) or 0
local ok, m = pcall(Moves.get, mv)
return tonumber(ok and m and m.target) or 0
end
local function random_u16(rng)
@@ -268,6 +277,16 @@ function choose_move_core(st, id, opts)
local aiAction = 0
if aiFlags ~= 0 and pack and pack.table and pack.scripts and target then
aiAction = run_scripts(pack, aiFlags, st, b, target, userSide, targetSide, scores, simulatedRNG, rng)
elseif st.roamer then
-- data/battle_ai_scripts.s: BattleAI_Roaming checks if_can_escape
local okCmd, Cmds = pcall(require, "src.core.game3.battle.ai_cmds")
local canEscape = true
if okCmd and Cmds and Cmds.canEscape then
canEscape = Cmds.canEscape(b, target)
end
if canEscape then
aiAction = 0x2
end
elseif st.safari then
-- data/battle_ai_scripts.s:3242
local okR, Rules = pcall(require, "src.core.game3.battle.rules")
+54 -3
View File
@@ -163,11 +163,12 @@ end
local function status2_bits(battler)
if not battler then return 0 end
local b = 0
if battler.status2 then b = bit_or_local(b, tonumber(battler.status2) or 0) end
if battler.confusionTurns and battler.confusionTurns > 0 then b = bit_or_local(b, STATUS2.CONFUSION) end
if battler.focusEnergy or battler.expFocusEnergy then b = bit_or_local(b, STATUS2.FOCUS_ENERGY) end
if (battler.substituteHP or 0) > 0 then b = bit_or_local(b, STATUS2.SUBSTITUTE) end
if battler.wrapped or battler.trapped then b = bit_or_local(b, STATUS2.WRAPPED) end
if battler.meanLook or battler.escapePrevention then b = bit_or_local(b, STATUS2.ESCAPE_PREVENTION) end
if battler.wrapped or battler.trapped or battler.expWrapped then b = bit_or_local(b, STATUS2.WRAPPED) end
if battler.meanLook or battler.escapePrevention or battler.expTrapped or battler.expTrappedBy then b = bit_or_local(b, STATUS2.ESCAPE_PREVENTION) end
if battler.bideTurns then b = bit_or_local(b, STATUS2.BIDE) end
if battler.recharge then b = bit_or_local(b, STATUS2.RECHARGE) end
if battler.rage then b = bit_or_local(b, STATUS2.RAGE) end
@@ -253,7 +254,15 @@ local function ability_of(battler)
if battler.mon then
a = a or battler.mon.ability or battler.mon.abilityId
end
return tonumber(a) or 0
if type(a) == "number" then return a end
if type(a) == "string" then
local ok, Abilities = pcall(require, "src.core.game3.battle.abilities")
if ok and Abilities and Abilities.id then
local okId, id = pcall(Abilities.id, a)
if okId and id then return id end
end
end
return 0
end
local function hp_percent(battler)
@@ -932,6 +941,48 @@ function CMD.if_target_not_taunted(vm, op)
branch(vm, op.target) -- always true (taunt unsupported)
end
local function can_escape_check(user, target)
if not user then return true end
if user.meanLook or user.escapePrevention or user.expTrapped or user.expTrappedBy or (user.expTrapTurns or 0) > 0 or user.wrapped or user.expIngrain then
return false
end
if target and not (target.fainted or (target.mon and (tonumber(target.mon.hp) or 0) <= 0)) then
local tab = ability_of(target)
local uab = ability_of(user)
-- SHADOW_TAG: 23
if tab == 23 and uab ~= 23 then
return false
end
-- ARENA_TRAP: 71, LEVITATE: 26, FLYING: 2
if tab == 71 and uab ~= 26 then
local t1, t2 = mon_types(user)
if t1 ~= Types.ID.FLYING and t2 ~= Types.ID.FLYING then
return false
end
end
-- MAGNET_PULL: 42, STEEL: 8
if tab == 42 then
local t1, t2 = mon_types(user)
if t1 == Types.ID.STEEL or t2 == Types.ID.STEEL then
return false
end
end
end
return true
end
function CMD.if_can_escape(vm, op)
if can_escape_check(vm.user, vm.target) then branch(vm, op.target) else next_ip(vm) end
end
function CMD.if_cant_escape(vm, op)
if not can_escape_check(vm.user, vm.target) then branch(vm, op.target) else next_ip(vm) end
end
function AiCmds.canEscape(user, target)
return can_escape_check(user, target)
end
function AiCmds.dispatch(vm, op)
if not op or not op.op then
vm.done = true
+57 -30
View File
@@ -2081,14 +2081,19 @@ function Engine.canSwitch(st, adapter, battler)
if not battler then return true end
if battler.expTrapped or battler.escapePrevention or (battler.expTrapTurns or 0) > 0 or battler.expIngrain then
-- src/party_menu.c:5964
return false, RomText.ascii("gText_PkmnCantSwitchOut", { stringVars = { adapter:displayName(battler) } })
local name = (adapter and adapter.displayName and adapter:displayName(battler)) or "POKéMON"
local ok, txt = pcall(RomText.ascii, "gText_PkmnCantSwitchOut", { stringVars = { name } })
if ok and txt then return false, txt end
return false, name .. " can't be switched out!"
end
local holder, ab = Abilities.escapeBlocker(adapter, battler)
if holder then
-- src/pokemon.c:6029
return false, State.text(st, "gText_PkmnsXPreventsSwitching", {
local ok, txt = pcall(State.text, st, "gText_PkmnsXPreventsSwitching", {
buff1 = State.prefixedName(st, holder), lastAbility = Abilities.id(ab),
})
if ok and txt then return false, txt end
return false, "Can't escape!"
end
return true
end
@@ -2441,31 +2446,15 @@ function Engine.planTurnFromActions(st, adapter, playerAct, enemyAct)
move = enemyAct.move, slot = enemyAct.slot,
battler = 1, kind = "move",
}
else
if st.enemy then st.enemy.expTurnOrder = 2 end
actions[#actions + 1] = enemy_meta_row()
end
st.turnOrder = { 0, 1 }
st.turnActions = actions
return actions, playerAct
end
local pMove, pSlot = playerAct.move, playerAct.slot
if enemyMeta then
-- pokefirered/src/battle_main.c:3586
pMove, pSlot = forced_move(st.player, pMove, pSlot)
if st.enemy then st.enemy.expTurnOrder = 1 end
if st.player then st.player.expTurnOrder = 2 end
actions[1] = enemy_meta_row()
actions[2] = { user = st.player, target = st.enemy, move = pMove, slot = pSlot, battler = 0, kind = "move" }
local mv = Moves.get(pMove)
st._focusPunchSetup = nil
if tonumber(mv and mv.effect) == E.FOCUS_PUNCH and not st.player.expLockedMove
and not adapter:hasStatus(st.player, "SLP") then
st._focusPunchSetup = { st.player }
end
st.turnOrder = { 1, 0 }
st.turnActions = actions
return actions, nil
end
local eMove, eSlot = enemyAct.move, enemyAct.slot
local function forced(b, mv, slot)
if not b then return mv, slot end
if b.expLockedMove then return b.expLockedMove, slot end
@@ -2478,8 +2467,31 @@ function Engine.planTurnFromActions(st, adapter, playerAct, enemyAct)
end
return mv, slot
end
local pMove, pSlot = playerAct.move, playerAct.slot
if enemyMeta then
-- pokefirered/src/battle_main.c:3586
pMove, pSlot = forced(st.player, pMove, pSlot)
if st.enemy then st.enemy.expTurnOrder = 1 end
if st.player then st.player.expTurnOrder = 2 end
actions[1] = enemy_meta_row()
if pMove then
actions[2] = { user = st.player, target = st.enemy, move = pMove, slot = pSlot, battler = 0, kind = "move" }
local mv = Moves.get(pMove)
st._focusPunchSetup = nil
if tonumber(mv and mv.effect) == E.FOCUS_PUNCH and not st.player.expLockedMove
and not adapter:hasStatus(st.player, "SLP") then
st._focusPunchSetup = { st.player }
end
end
st.turnOrder = { 1, 0 }
st.turnActions = actions
return actions, nil
end
local eMove, eSlot = enemyAct.move, enemyAct.slot
pMove, pSlot = forced(st.player, pMove, pSlot)
eMove, eSlot = forced(st.enemy, eMove, eSlot)
if eMove then eMove, eSlot = forced(st.enemy, eMove, eSlot) end
-- pokefirered/src/battle_main.c:2926
st.randomTurnNumber = roll(adapter, 0, 0xFFFF)
local pPri = Moves.priority(pMove)
@@ -2505,22 +2517,37 @@ function Engine.planTurnFromActions(st, adapter, playerAct, enemyAct)
-- pokefirered/src/battle_main.c:3682
local focus = {}
for _, row in ipairs({ { st.player, pMove }, { st.enemy, eMove } }) do
local mv = Moves.get(row[2])
if tonumber(mv and mv.effect) == E.FOCUS_PUNCH and not row[1].expLockedMove
and not adapter:hasStatus(row[1], "SLP") then
focus[#focus + 1] = row[1]
if row[2] then
local okM, mv = pcall(Moves.get, row[2])
if okM and mv and tonumber(mv.effect) == E.FOCUS_PUNCH and not row[1].expLockedMove
and not adapter:hasStatus(row[1], "SLP") then
focus[#focus + 1] = row[1]
end
end
end
st._focusPunchSetup = (#focus > 0) and focus or nil
local function enemy_turn_action()
if enemyAct.kind == "move" then
return { user = st.enemy, target = st.player, move = eMove, slot = eSlot, battler = 1, kind = "move" }
else
return enemy_meta_row()
end
end
local function player_turn_action()
return { user = st.player, target = st.enemy, move = pMove, slot = pSlot, battler = 0, kind = "move" }
end
if playerFirst then
st.player.expTurnOrder, st.enemy.expTurnOrder = 1, 2
actions[#actions + 1] = { user = st.player, target = st.enemy, move = pMove, slot = pSlot, battler = 0, kind = "move" }
actions[#actions + 1] = { user = st.enemy, target = st.player, move = eMove, slot = eSlot, battler = 1, kind = "move" }
actions[#actions + 1] = player_turn_action()
actions[#actions + 1] = enemy_turn_action()
st.turnOrder = { 0, 1 }
else
st.player.expTurnOrder, st.enemy.expTurnOrder = 2, 1
actions[#actions + 1] = { user = st.enemy, target = st.player, move = eMove, slot = eSlot, battler = 1, kind = "move" }
actions[#actions + 1] = { user = st.player, target = st.enemy, move = pMove, slot = pSlot, battler = 0, kind = "move" }
actions[#actions + 1] = enemy_turn_action()
actions[#actions + 1] = player_turn_action()
st.turnOrder = { 1, 0 }
end
st.turnActions = actions
+65
View File
@@ -1554,6 +1554,68 @@ local function step_safari_enemy(act)
Battle._phase = "animating"
end
local function step_enemy_flee(act)
local st, ad = Battle._st, Battle._adapter
if not st or not st.enemy then return end
if State.isFainted(st.enemy) then
if not Battle._actions[Battle._actionI] then after_actions() end
return
end
if ad:hasStatus(st.enemy, "SLP") or ad:hasStatus(st.enemy, "FRZ") then
if not Battle._actions[Battle._actionI] then after_actions() end
return
end
local canEscape = Engine.canSwitch(st, ad, st.enemy)
if not canEscape then
local mark = ad:eventMark()
local prev = ad._say
ad._say = function() end
-- pokefirered/src/battle_main.c:4321, battle_message.c:909
ad:sayText("STRINGID_ATTACKERCANTESCAPE", { atk = st.enemy })
ad._say = prev
local evs = ad:eventsSince(mark)
if Battle._headless then
for _, e in ipairs(evs) do
if e.kind == "msg" then Ui.push(e.text) end
end
if not Battle._actions[Battle._actionI] then after_actions() end
return
end
AnimSeq.beginEvents(evs, seq_push)
Battle._phase = "animating"
return
end
local mark = ad:eventMark()
local prev = ad._say
ad._say = function() end
-- pokefirered/src/battle_main.c:3819
local monName = State.displayName(st.enemy) or "The wild Pokémon"
ad:sayText("STRINGID_WILDPKMNFLED", { buff1 = monName })
ad._say = prev
local evs = ad:eventsSince(mark)
pcall(function()
local SE = require("src.core.game3.se_ids")
if SE and SE.SE_FLEE then
require("src.core.game3.audio").playSe(SE.SE_FLEE)
end
end)
st.over = true
st.result = "fled"
st.endReason = "enemy_fled"
if Battle._headless then
for _, e in ipairs(evs) do
if e.kind == "msg" then Ui.push(e.text) end
end
Battle._pendingEnd = "fled"
Battle._phase = "ending"
return
end
AnimSeq.beginEvents(evs, seq_push)
Battle._pendingEnd = "fled"
Battle._phase = "ending"
end
local function step_action()
local st = Battle._st
local ad = Battle._adapter
@@ -1785,6 +1847,9 @@ local function step_action()
if st.safari and (act.kind == "watch" or act.kind == "run") then
return step_safari_enemy(act)
end
if (act.kind == "run" or act.kind == "flee") and (act.battler == 1 or act.user == "enemy" or (type(act.user) == "table" and act.user.side == "enemy")) then
return step_enemy_flee(act)
end
if act.meta then
if not Battle._actions[Battle._actionI] then after_actions() end
return
+28 -2
View File
@@ -116,6 +116,7 @@ end
local function from_rom(numId)
Moves.romReady()
if not Moves._rom then return nil end
local row = Moves._rom[numId]
if not row then return nil end
local cat = Types.isPhysical(row.type) and "physical" or "special"
@@ -162,9 +163,34 @@ function Moves.displayName(moveId)
return require("src.core.game3.pokemon").moveName(num or moveId)
end
local PRIORITY_FALLBACK = {
[98] = 1, -- QUICK_ATTACK
[182] = 2, -- PROTECT
[197] = 2, -- DETECT
[183] = 1, -- MACH_PUNCH
[245] = 2, -- EXTREMESPEED
[252] = 1, -- FAKE_OUT
[283] = 3, -- HELPING_HAND
[264] = 4, -- MAGIC_COAT
[268] = 4, -- SNATCH
[279] = -3, -- REVENGE
[263] = -3, -- FOCUS_PUNCH
[233] = -5, -- VITAL_THROW
[46] = -6, -- ROAR
[18] = -6, -- WHIRLWIND
[309] = -6, -- COUNTER
[310] = -6, -- MIRROR_COAT
}
function Moves.priority(moveId)
local m = Moves.get(moveId)
return tonumber(m and m.priority) or 0
if not moveId or moveId == 0 or moveId == "" then return 0 end
local ok, m = pcall(Moves.get, moveId)
if ok and m and m.priority ~= nil then return tonumber(m.priority) or 0 end
local num = tonumber(moveId)
if not num and type(moveId) == "string" then
num = Moves.numForName(Moves.normalizeId(moveId))
end
return (num and PRIORITY_FALLBACK[num]) or 0
end
return Moves
+2 -2
View File
@@ -389,13 +389,13 @@ function State.text(st, id, fill)
return require("src.core.game3.battle.battle_text").get(id, fill)
end
-- src/battle_message.c:2248
function State.prefixedName(st, battler, name)
name = name or State.displayName(battler)
if battler and battler.side == "player" then return name end
local RomText = require("src.core.game3.rom_text")
local prefix = (st ~= nil and not st.wild) and "sText_FoePkmnPrefix" or "sText_WildPkmnPrefix"
return RomText.plain(prefix) .. name
local ok, pre = pcall(RomText.plain, prefix)
return (ok and pre or (st ~= nil and not st.wild and "Foe " or "Wild ")) .. name
end
function State.isFainted(battler)
+8
View File
@@ -307,6 +307,14 @@ function BattleBridge.start(mod, game, foe, opts)
session.battleOutcome = Natives.outcome_to_code(result or "win")
end
writeback(session, battleParty, remap, result, save, opts)
if opts.roamer or (foe and foe.roamer) then
local okR, Roamer = pcall(require, "src.core.game3.roamer")
if okR and Roamer and Roamer.onBattleEnd then
local st = package.loaded["src.core.game3.battle"] and package.loaded["src.core.game3.battle"].getState and package.loaded["src.core.game3.battle"].getState()
local enemyMon = (st and st.enemy and st.enemy.mon) or foe
Roamer.onBattleEnd(session, enemyMon, result, st and st.endReason)
end
end
-- pokefirered/src/battle_main.c:3861
if ModRuntime.wants("battle.ended") then
local B = package.loaded["src.core.game3.battle"]
+1 -1
View File
@@ -513,6 +513,7 @@ end
local ARRIVAL_FACING = {
[MB_CAVE_DOOR] = "down",
[MB_WARP_DOOR] = "down",
[MB_LADDER] = "down",
[MB_SOUTH_ARROW_WARP] = "up",
[MB_NORTH_ARROW_WARP] = "down",
[MB_WEST_ARROW_WARP] = "right",
@@ -526,7 +527,6 @@ local ARRIVAL_FACING = {
function Collision.arrivalFacing(destBeh, storedDir)
local f = ARRIVAL_FACING[destBeh]
if f then return f end
if destBeh == MB_LADDER then return storedDir or "down" end
return "down"
end
+4 -2
View File
@@ -308,8 +308,10 @@ function Dataset.attachMidLayouts(maps, cache)
if decoded then
local pair = (info and info.pair) or def.pair
def.midLayout = LayoutNative.fromDecoded(decoded, mapId, pair)
if decoded.width and decoded.width > 0 then def.width = decoded.width end
if decoded.height and decoded.height > 0 then def.height = decoded.height end
local tw = decoded.trueWidth or decoded.width
local th = decoded.trueHeight or decoded.height
if tw and tw > 0 then def.width = tw end
if th and th > 0 then def.height = th end
if pair then def.pair = pair end
attached = attached + 1
end
+21 -1
View File
@@ -330,6 +330,7 @@ end
--- derived from the area's own encounter rate. Rates at 80+ get no grace period
--- at all; below that the wait grows as the rate drops.
function Encounters.mapBaseCooldown(terrain, rate)
if terrain == "grass" or terrain == "cave" or terrain == "tall_grass" then terrain = "land" end
if terrain ~= "land" and terrain ~= "water" then return COOLDOWN_NONE end
if rate == nil then return COOLDOWN_NONE end
rate = tonumber(rate) or 0
@@ -523,6 +524,17 @@ local function roll_area(mapId, areaKey, weights, enterFromOther, fallbackRate)
return nil
end
-- pokefirered/src/wild_encounter.c:645 TryStartRoamerEncounter
local okR, Roamer = pcall(require, "src.core.game3.roamer")
if okR and Roamer and Roamer.tryEncounter then
local okRt, Runtime = pcall(require, "src.core.game3.runtime")
local session = okRt and Runtime and Runtime.getSession and Runtime.getSession()
local roamerEnc = Roamer.tryEncounter(session, mapId, areaKey)
if roamerEnc then
return roamerEnc
end
end
local entry = pick_slot(area.slots, weights)
if type(entry) ~= "table" then
-- pret banks here too: the rate test passed but TryGenerateWildMon found
@@ -663,6 +675,8 @@ local function mod_encounter(enc)
speciesId = id or Pokemon.speciesFromName(enc.species),
level = enc.level,
item = enc.item,
roamer = enc.roamer,
foe = enc.foe,
}
end
@@ -675,7 +689,13 @@ local function engine_encounter(enc)
end
id = id or tonumber(enc.speciesId)
if not id then return nil end
return { species = id, level = tonumber(enc.level) or 5, item = enc.item }
return {
species = id,
level = tonumber(enc.level) or 5,
item = enc.item,
roamer = enc.roamer,
foe = enc.foe,
}
end
local function same_encounter(enc) return enc end
+12
View File
@@ -1081,6 +1081,14 @@ function FieldView.draw(game, canvasW, canvasH, opts)
canvasH = canvasH or Display.H
opts = opts or {}
local okSea, SeagallopUi = pcall(require, "src.ui.game3.seagallop")
if okSea and SeagallopUi and SeagallopUi.isActive and SeagallopUi.isActive() then
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", 0, 0, canvasW, canvasH)
love.graphics.setColor(1, 1, 1, 1)
return
end
local mapId = currentMapId(game)
local mapDef = resolveMapDef(game, mapId)
if FieldView._flashMapId ~= mapId then
@@ -1283,6 +1291,10 @@ function FieldView.draw(game, canvasW, canvasH, opts)
if okSS and SSAnne and SSAnne.drawSmoke then
SSAnne.drawSmoke(camX, camY)
end
local okW, FieldWeather = pcall(require, "src.core.game3.field_weather")
if okW and FieldWeather and FieldWeather.draw then
FieldWeather.draw(camX, camY, canvasW, canvasH)
end
end
drawFlashMask(canvasW, canvasH)
+195
View File
@@ -0,0 +1,195 @@
-- Field Weather System (Fog, Shade, Rain)
-- pokefirered/src/field_weather.c
-- pokefirered/src/field_weather_effects.c
local Dataset = require("src.core.game3.dataset")
local Weather = require("src.core.game3.weather")
local FieldWeather = {}
local W, H = 240, 160
local CACHE_ROOT = "data/generated/gba/weather"
FieldWeather._current = Weather.NONE
FieldWeather._assets = nil
FieldWeather._fogScrollOffset = 0
FieldWeather._fogScrollCounter = 0
FieldWeather._rainSprites = {}
FieldWeather._rainTimer = 0
local function loadAssets()
if FieldWeather._assets then return FieldWeather._assets end
local cache = Dataset.cache()
if not (cache and cache.read) then return nil end
local fogRaw = cache:read(CACHE_ROOT .. "/fog_horizontal.rgba")
local rainRaw = cache:read(CACHE_ROOT .. "/rain.rgba")
local function imgFromRgba(raw, w, h)
if not raw or #raw < w * h * 4 then return nil end
local imgData = love.image.newImageData(w, h)
local ptr = 1
for y = 0, h - 1 do
for x = 0, w - 1 do
local r = raw:byte(ptr) or 0
local g = raw:byte(ptr + 1) or 0
local b = raw:byte(ptr + 2) or 0
local a = raw:byte(ptr + 3) or 0
imgData:setPixel(x, y, r / 255, g / 255, b / 255, a / 255)
ptr = ptr + 4
end
end
local img = love.graphics.newImage(imgData)
img:setFilter("nearest", "nearest")
img:setWrap("repeat", "repeat")
return img
end
local fogImg = fogRaw and imgFromRgba(fogRaw, 64, 64)
local rainImg = rainRaw and imgFromRgba(rainRaw, 16, 192)
FieldWeather._assets = {
fog = fogImg,
rain = rainImg,
}
return FieldWeather._assets
end
function FieldWeather.setWeather(weatherId)
FieldWeather._current = tonumber(weatherId) or Weather.NONE
if FieldWeather._current == Weather.FOG_HORIZONTAL then
FieldWeather._fogScrollOffset = 0
FieldWeather._fogScrollCounter = 0
elseif FieldWeather._current == Weather.RAIN or FieldWeather._current == Weather.RAIN_THUNDERSTORM then
FieldWeather._rainSprites = {}
FieldWeather._rainTimer = 0
end
end
function FieldWeather.getWeather()
return FieldWeather._current
end
function FieldWeather.update(dt)
if Weather.isSuspended() then return end
local w = FieldWeather._current
-- pokefirered/src/field_weather_effects.c:1332 FogHorizontal_Main
if w == Weather.FOG_HORIZONTAL then
FieldWeather._fogScrollCounter = FieldWeather._fogScrollCounter + 1
if FieldWeather._fogScrollCounter > 3 then
FieldWeather._fogScrollCounter = 0
FieldWeather._fogScrollOffset = (FieldWeather._fogScrollOffset + 1) % 256
end
elseif w == Weather.RAIN or w == Weather.RAIN_THUNDERSTORM or w == Weather.DOWNPOUR then
-- Rain particle generation & simulation
FieldWeather._rainTimer = FieldWeather._rainTimer + (dt or (1 / 60))
if #FieldWeather._rainSprites < 24 and math.random() < 0.35 then
table.insert(FieldWeather._rainSprites, {
x = math.random(-20, W + 20),
y = math.random(-40, -10),
speed = math.random(7, 10),
groundY = math.random(20, H),
splashTick = 0,
state = "falling",
})
end
local i = 1
while i <= #FieldWeather._rainSprites do
local r = FieldWeather._rainSprites[i]
if r.state == "falling" then
r.x = r.x - 2
r.y = r.y + r.speed
if r.y >= r.groundY then
r.state = "splash"
r.splashTick = 0
end
elseif r.state == "splash" then
r.splashTick = r.splashTick + 1
if r.splashTick >= 6 then
table.remove(FieldWeather._rainSprites, i)
i = i - 1
end
end
i = i + 1
end
end
end
--- Render weather atmospheric layer over the field (before UI/dialogues)
function FieldWeather.draw(camX, camY, canvasW, canvasH)
if Weather.isSuspended() then return end
local w = FieldWeather._current
if w == Weather.NONE or w == Weather.SUNNY then return end
canvasW = canvasW or W
canvasH = canvasH or H
camX = camX or 0
camY = camY or 0
local assets = loadAssets()
-- 1. WEATHER_SHADE: Atmospheric gamma dimming without color washout
-- pokefirered/src/field_weather_effects.c:2172 Shade_InitVars (gammaTargetIndex = 3)
if w == Weather.SHADE then
love.graphics.push("all")
love.graphics.setBlendMode("multiply", "premultiplied")
-- Dim factor ~70% (0.70, 0.70, 0.75) for authentic cool shadow tint
love.graphics.setColor(0.68, 0.68, 0.74, 1)
love.graphics.rectangle("fill", 0, 0, canvasW, canvasH)
love.graphics.pop()
end
-- 2. WEATHER_FOG_HORIZONTAL: 64x64 fog sprites with GBA BLDALPHA (12/16 sprite, 8/16 backdrop)
-- pokefirered/src/field_weather_effects.c:1343 Weather_SetTargetBlendCoeffs(12, 8, 3)
if w == Weather.FOG_HORIZONTAL then
love.graphics.push("all")
love.graphics.setBlendMode("alpha")
-- Alpha blend weight: 12/16 = 0.75
love.graphics.setColor(1, 1, 1, 0.70)
if assets and assets.fog then
-- pokefirered/src/field_weather_effects.c:1332 FogHorizontal_Update
-- In vanilla FireRed, fog sprites are fixed in screen space and drift horizontally
-- (+1 px every 4 frames) with zero camera/map parallax.
local scrollX = FieldWeather._fogScrollOffset % 64
local startX = -scrollX
for py = 0, canvasH, 64 do
for px = startX, canvasW + 64, 64 do
love.graphics.draw(assets.fog, px, py)
end
end
else
-- Procedural atmospheric horizontal fog bands fallback
for py = 0, canvasH, 16 do
local offset = (FieldWeather._fogScrollOffset * 2 + py * 4) % 64
love.graphics.setColor(0.92, 0.95, 1.0, 0.28)
love.graphics.rectangle("fill", -offset, py, canvasW + 64, 12)
end
end
love.graphics.pop()
end
-- 3. WEATHER_RAIN: Falling raindrops and splash particles
if w == Weather.RAIN or w == Weather.RAIN_THUNDERSTORM or w == Weather.DOWNPOUR then
love.graphics.push("all")
love.graphics.setBlendMode("alpha")
for _, r in ipairs(FieldWeather._rainSprites) do
if r.state == "falling" then
love.graphics.setColor(0.75, 0.85, 1.0, 0.85)
love.graphics.line(r.x, r.y, r.x - 2, r.y + 7)
elseif r.state == "splash" then
love.graphics.setColor(0.85, 0.92, 1.0, 0.65 - (r.splashTick * 0.1))
love.graphics.circle("line", r.x, r.groundY, r.splashTick + 1)
end
end
love.graphics.pop()
end
love.graphics.setColor(1, 1, 1, 1)
end
return FieldWeather
+20 -9
View File
@@ -33,12 +33,14 @@ function LayoutNative:cellAt(cx, cy)
local key = cy * 1024 + cx
local ov = self.overrides[key]
if ov then return ov end
if cx >= 0 and cy >= 0 and cx < self.width and cy < self.height then
local tw = self.trueWidth or self.width
local th = self.trueHeight or self.height
if cx >= 0 and cy >= 0 and cx < tw and cy < th then
return self.cells[cy * self.width + cx + 1]
or { mid = 0, coll = 0xff, elev = 0 }
end
local bx, by = wrap_border(
cx, cy, self.width, self.height, self.borderWidth, self.borderHeight)
cx, cy, tw, th, self.borderWidth, self.borderHeight)
local mid = self.borderMids[by * self.borderWidth + bx + 1] or 0
return { mid = mid, coll = 0xff, elev = 0 }
end
@@ -57,15 +59,24 @@ end
--- Flat 1-based COLL_* array for Collision.bindMap.
function LayoutNative:collArray()
local n = self.width * self.height
local w = self.width
local h = self.height
local tw = self.trueWidth or w
local th = self.trueHeight or h
local n = w * h
if n <= 0 then return nil end
local out = {}
for i = 1, n do
local cx = (i - 1) % self.width
local cy = math.floor((i - 1) / self.width)
local ov = self.overrides[cy * 1024 + cx]
local c = self.cells[i]
out[i] = (ov and ov.coll) or (c and c.coll) or 0xff
for cy = 0, h - 1 do
for cx = 0, w - 1 do
local i = cy * w + cx + 1
if cx < tw and cy < th then
local ov = self.overrides[cy * 1024 + cx]
local c = self.cells[i]
out[i] = (ov and ov.coll) or (c and c.coll) or 0xff
else
out[i] = 0xff
end
end
end
return out
end
+24
View File
@@ -276,6 +276,20 @@ function Map.load(mod, game, mapId, opts)
if okE and Encounters and Encounters.resetRateModifiers then
Encounters.resetRateModifiers()
end
local okR, Roamer = pcall(require, "src.core.game3.roamer")
if okR and Roamer and Roamer.move then
local okRt, Runtime = pcall(require, "src.core.game3.runtime")
local session = okRt and Runtime and Runtime.getSession and Runtime.getSession()
if session and session.roamer and session.roamer.active then
local fromMapId = Map._announced
if fromMapId ~= nil and fromMapId ~= mapId then
local moveReason = (opts.teleport or opts.fly or opts.whiteout) and "warp_random" or "map_transition"
Roamer.move(session, moveReason)
elseif opts.teleport or opts.fly or opts.whiteout then
Roamer.move(session, "warp_random")
end
end
end
end
local Ghosts = require("src.core.game3.ghosts")
local fromMapId = Map._announced
@@ -440,6 +454,16 @@ function Map.load(mod, game, mapId, opts)
Objects.loadMap(game, mapId, def)
Ghosts.adopt(mapId)
end
-- pokefirered/src/overworld.c:771 / :808 TryRegenerateRenewableHiddenItems
local okRen, Renewable = pcall(require, "src.core.game3.renewable_hidden_items")
if okRen and Renewable and Renewable.tryRegenerate then
Renewable.tryRegenerate(session, def and (def.group or (def.pair and def.pair[1])), def and (def.num or (def.pair and def.pair[2])), mapId)
end
-- pokefirered/src/overworld.c:809 SetCurrentAndNextWeather
if def and def.weather ~= nil then
local Weather = require("src.core.game3.weather")
Weather.apply(def.weather)
end
-- pokefirered/src/overworld.c:769
-- pokefirered/src/overworld.c:806
require("src.core.game3.audio").setSavedSong(nil)
-4
View File
@@ -468,10 +468,6 @@ function Player.tryMove(dir, game, run)
end
end
end
if (why == "bounds" or why == "solid" or why == "tile") and Collision.tryWarpAt
and Collision.tryWarpAt(game, Player.cellX, Player.cellY, dir) then
return "warp"
end
-- Outdoor map connection (Pallet north → Route 1, etc.).
if why == "bounds" and Collision.tryConnection
and Collision.tryConnection(game, Player.cellX, Player.cellY, dir, run) then
+319
View File
@@ -0,0 +1,319 @@
-- Renewable hidden items engine (Treasure Beach, Berry Forest, etc.)
-- pokefirered/src/renewable_hidden_items.c
local Flags = require("src.core.game3.scripting.flags")
local F = Flags.IDS
local RenewableHiddenItems = {}
local VAR_RENEWABLE_ITEM_STEP_COUNTER = 0x4023 -- pokefirered/include/constants/vars.h:40
local STEP_THRESHOLD = 1500
-- Map definitions and item tables matching pokefirered/src/renewable_hidden_items.c
local RENEWABLE_ZONES = {
{
name = "ROUTE20",
group = 3, map = 36,
mapIds = { "ROUTE_20", "FR_ROUTE_20", "LG_ROUTE_20" },
rare = {},
uncommon = { F.FLAG_HIDDEN_ITEM_ROUTE20_STARDUST },
common = {},
},
{
name = "ROUTE21_NORTH",
group = 3, map = 37,
mapIds = { "ROUTE_21_NORTH", "FR_ROUTE_21_NORTH", "LG_ROUTE_21_NORTH", "ROUTE_21" },
rare = {},
uncommon = { F.FLAG_HIDDEN_ITEM_ROUTE21_NORTH_PEARL },
common = {},
},
{
name = "UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL",
group = 1, map = 31,
mapIds = { "UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL", "FR_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL" },
rare = { F.FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_ETHER },
uncommon = {
F.FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_POTION,
F.FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_ANTIDOTE,
F.FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_PARALYZE_HEAL,
F.FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_AWAKENING,
F.FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_BURN_HEAL,
F.FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_ICE_HEAL,
},
common = {},
},
{
name = "UNDERGROUND_PATH_EAST_WEST_TUNNEL",
group = 1, map = 34,
mapIds = { "UNDERGROUND_PATH_EAST_WEST_TUNNEL", "FR_UNDERGROUND_PATH_EAST_WEST_TUNNEL" },
rare = { F.FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_ETHER },
uncommon = {
F.FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_POTION,
F.FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_ANTIDOTE,
F.FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_PARALYZE_HEAL,
F.FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_AWAKENING,
F.FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_BURN_HEAL,
F.FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_ICE_HEAL,
},
common = {},
},
{
name = "SEVEN_ISLAND_TANOBY_RUINS",
group = 3, map = 45,
mapIds = { "SEVII_SEVEN_ISLAND_TANOBY_RUINS", "SEVEN_ISLAND_TANOBY_RUINS", "FR_SEVEN_ISLAND_TANOBY_RUINS" },
rare = {
F.FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE_4,
F.FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE,
F.FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE_2,
F.FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE_3,
},
uncommon = {},
common = {},
},
{
name = "MT_MOON_B1F",
group = 1, map = 2,
mapIds = { "MT_MOON_B1F", "FR_MT_MOON_B1F" },
rare = {
F.FLAG_HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM,
F.FLAG_HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM_2,
F.FLAG_HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM_3,
F.FLAG_HIDDEN_ITEM_MT_MOON_B1F_BIG_MUSHROOM,
F.FLAG_HIDDEN_ITEM_MT_MOON_B1F_BIG_MUSHROOM_2,
F.FLAG_HIDDEN_ITEM_MT_MOON_B1F_BIG_MUSHROOM_3,
},
uncommon = {
F.FLAG_HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM,
F.FLAG_HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM_2,
F.FLAG_HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM_3,
},
common = {},
},
{
name = "THREE_ISLAND_BERRY_FOREST",
group = 1, map = 109,
mapIds = { "SEVII_THREE_ISLAND_BERRY_FOREST", "THREE_ISLAND_BERRY_FOREST", "FR_THREE_ISLAND_BERRY_FOREST" },
rare = {
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_BLUK_BERRY,
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_WEPEAR_BERRY,
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_ORAN_BERRY,
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_CHERI_BERRY,
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_ASPEAR_BERRY,
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_PERSIM_BERRY,
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_PINAP_BERRY,
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_LUM_BERRY,
},
uncommon = {
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_BLUK_BERRY,
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_WEPEAR_BERRY,
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_ORAN_BERRY,
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_CHERI_BERRY,
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_ASPEAR_BERRY,
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_PERSIM_BERRY,
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_PINAP_BERRY,
},
common = {
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_RAZZ_BERRY,
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_NANAB_BERRY,
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_CHESTO_BERRY,
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_PECHA_BERRY,
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_RAWST_BERRY,
},
},
{
name = "ONE_ISLAND_TREASURE_BEACH",
group = 3, map = 46,
mapIds = { "SEVII_ONE_ISLAND_TREASURE_BEACH", "ONE_ISLAND_TREASURE_BEACH", "FR_ONE_ISLAND_TREASURE_BEACH" },
rare = {
F.FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_ULTRA_BALL,
F.FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_ULTRA_BALL_2,
F.FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_STAR_PIECE,
F.FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_BIG_PEARL,
},
uncommon = {
F.FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_STARDUST,
F.FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_STARDUST_2,
F.FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_PEARL,
F.FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_PEARL_2,
F.FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_ULTRA_BALL,
F.FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_ULTRA_BALL_2,
},
common = {
F.FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_ULTRA_BALL,
F.FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_ULTRA_BALL_2,
},
},
{
name = "THREE_ISLAND_BOND_BRIDGE",
group = 3, map = 48,
mapIds = { "SEVII_THREE_ISLAND_BOND_BRIDGE", "THREE_ISLAND_BOND_BRIDGE", "FR_THREE_ISLAND_BOND_BRIDGE" },
rare = {},
uncommon = {
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BOND_BRIDGE_PEARL,
F.FLAG_HIDDEN_ITEM_THREE_ISLAND_BOND_BRIDGE_STARDUST,
},
common = {},
},
{
name = "FOUR_ISLAND",
group = 3, map = 5,
mapIds = { "SEVII_FOUR_ISLAND", "FOUR_ISLAND", "FR_FOUR_ISLAND" },
rare = {},
uncommon = { F.FLAG_HIDDEN_ITEM_FOUR_ISLAND_PEARL },
common = { F.FLAG_HIDDEN_ITEM_FOUR_ISLAND_ULTRA_BALL },
},
{
name = "FIVE_ISLAND_MEMORIAL_PILLAR",
group = 3, map = 51,
mapIds = { "SEVII_FIVE_ISLAND_MEMORIAL_PILLAR", "FIVE_ISLAND_MEMORIAL_PILLAR", "FR_FIVE_ISLAND_MEMORIAL_PILLAR" },
rare = { F.FLAG_HIDDEN_ITEM_FIVE_ISLAND_MEMORIAL_PILLAR_BIG_PEARL },
uncommon = {},
common = {},
},
{
name = "FIVE_ISLAND_RESORT_GORGEOUS",
group = 3, map = 52,
mapIds = { "SEVII_FIVE_ISLAND_RESORT_GORGEOUS", "FIVE_ISLAND_RESORT_GORGEOUS", "FR_FIVE_ISLAND_RESORT_GORGEOUS" },
rare = {
F.FLAG_HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_NEST_BALL,
F.FLAG_HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_STAR_PIECE,
},
uncommon = {
F.FLAG_HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_STARDUST,
F.FLAG_HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_STARDUST_2,
},
common = {},
},
{
name = "SIX_ISLAND_OUTCAST_ISLAND",
group = 3, map = 53,
mapIds = { "SEVII_SIX_ISLAND_OUTCAST_ISLAND", "SIX_ISLAND_OUTCAST_ISLAND", "FR_SIX_ISLAND_OUTCAST_ISLAND" },
rare = {
F.FLAG_HIDDEN_ITEM_SIX_ISLAND_OUTCAST_ISLAND_STAR_PIECE,
F.FLAG_HIDDEN_ITEM_SIX_ISLAND_OUTCAST_ISLAND_NET_BALL,
},
uncommon = {},
common = {},
},
{
name = "SIX_ISLAND_GREEN_PATH",
group = 3, map = 54,
mapIds = { "SEVII_SIX_ISLAND_GREEN_PATH", "SIX_ISLAND_GREEN_PATH", "FR_SIX_ISLAND_GREEN_PATH" },
rare = {},
uncommon = {},
common = { F.FLAG_HIDDEN_ITEM_SIX_ISLAND_GREEN_PATH_ULTRA_BALL },
},
{
name = "SEVEN_ISLAND_TRAINER_TOWER",
group = 3, map = 56,
mapIds = { "SEVII_SEVEN_ISLAND_TRAINER_TOWER", "SEVEN_ISLAND_TRAINER_TOWER", "FR_SEVEN_ISLAND_TRAINER_TOWER" },
rare = { F.FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TRAINER_TOWER_BIG_PEARL },
uncommon = { F.FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TRAINER_TOWER_PEARL },
common = {},
},
}
RenewableHiddenItems.ZONES = RENEWABLE_ZONES
local function resolveStore(ctx)
local Space = package.loaded["src.core.game3.scripting.space"]
if Space and Space.store then return Space.store end
local rt = package.loaded["src.core.game3.runtime"]
local session = rt and rt.getSession and rt.getSession()
if session and session.store then return session.store end
if type(ctx) == "table" then
if ctx.vars or ctx.flags then return ctx end
if ctx.store then return ctx.store end
end
return nil
end
function RenewableHiddenItems.isRenewableMap(mapGroup, mapNum, mapId)
mapGroup = tonumber(mapGroup)
mapNum = tonumber(mapNum)
for _, zone in ipairs(RENEWABLE_ZONES) do
if mapGroup and mapNum and zone.group == mapGroup and zone.map == mapNum then
return true, zone
end
if mapId then
for _, mid in ipairs(zone.mapIds) do
if mid == mapId then return true, zone end
end
end
end
return false, nil
end
--- pokefirered/src/renewable_hidden_items.c:557
function RenewableHiddenItems.onStep(ctx, mapGroup, mapNum, mapId)
-- Requirement 1: If player is inside one of the renewable item maps, do NOT increment
if RenewableHiddenItems.isRenewableMap(mapGroup, mapNum, mapId) then
return false
end
local store = resolveStore(ctx)
local var = tonumber(Flags.getVar(store, ctx, VAR_RENEWABLE_ITEM_STEP_COUNTER)) or 0
if var < STEP_THRESHOLD then
Flags.setVar(store, ctx, VAR_RENEWABLE_ITEM_STEP_COUNTER, var + 1)
end
return true
end
--- pokefirered/src/renewable_hidden_items.c:536
local function setAllRenewableFlags(ctx)
local store = resolveStore(ctx)
for _, zone in ipairs(RENEWABLE_ZONES) do
for _, flagId in ipairs(zone.rare) do
if flagId then Flags.setFlag(store, ctx, flagId, true) end
end
for _, flagId in ipairs(zone.uncommon) do
if flagId then Flags.setFlag(store, ctx, flagId, true) end
end
for _, flagId in ipairs(zone.common) do
if flagId then Flags.setFlag(store, ctx, flagId, true) end
end
end
end
--- pokefirered/src/renewable_hidden_items.c:587
local function sampleRenewableFlags(ctx, rngFn)
local store = resolveStore(ctx)
local rand = rngFn or function() return math.random(0, 99) end
for _, zone in ipairs(RENEWABLE_ZONES) do
local rval = rand() % 100
local flags
if rval >= 90 then
flags = zone.rare
elseif rval >= 60 then
flags = zone.uncommon
else
flags = zone.common
end
for _, flagId in ipairs(flags) do
if flagId then
-- FlagClear makes the item exist/spawn on the map
Flags.setFlag(store, ctx, flagId, false)
end
end
end
end
--- pokefirered/src/renewable_hidden_items.c:566
function RenewableHiddenItems.tryRegenerate(ctx, mapGroup, mapNum, mapId, rngFn)
local isRenewable, zone = RenewableHiddenItems.isRenewableMap(mapGroup, mapNum, mapId)
if not isRenewable then return false end
local store = resolveStore(ctx)
local var = tonumber(Flags.getVar(store, ctx, VAR_RENEWABLE_ITEM_STEP_COUNTER)) or 0
if var >= STEP_THRESHOLD then
Flags.setVar(store, ctx, VAR_RENEWABLE_ITEM_STEP_COUNTER, 0)
setAllRenewableFlags(ctx)
sampleRenewableFlags(ctx, rngFn)
return true
end
return false
end
return RenewableHiddenItems
+321
View File
@@ -0,0 +1,321 @@
-- Roaming Legendary Beast system matching pokefirered (src/roamer.c)
-- Raikou, Entei, and Suicune in Kanto.
local Rng = require("src.core.game3.rng")
local Pokemon = require("src.core.game3.pokemon")
local Roamer = {}
Roamer.SPECIES_RAIKOU = 243
Roamer.SPECIES_ENTEI = 244
Roamer.SPECIES_SUICUNE = 245
Roamer.ROAMER_LEVEL = 50
-- include/constants/vars.h:47
local VAR_REPEL_STEP_COUNT = 0x4020
local VAR_STARTER_MON = 0x4031
-- pokefirered/src/roamer.c:19 sRoamerLocations (Kanto routes)
Roamer.LOCATIONS = {
"FR_ROUTE_1",
"FR_ROUTE_2",
"FR_ROUTE_3",
"FR_ROUTE_4",
"FR_ROUTE_5",
"FR_ROUTE_6",
"FR_ROUTE_7",
"FR_ROUTE_8",
"FR_ROUTE_9",
"FR_ROUTE_10",
"FR_ROUTE_11",
"FR_ROUTE_12",
"FR_ROUTE_13",
"FR_ROUTE_14",
"FR_ROUTE_15",
"FR_ROUTE_16",
"FR_ROUTE_17",
"FR_ROUTE_18",
"FR_ROUTE_19",
"FR_ROUTE_20",
"FR_ROUTE_21_NORTH",
"FR_ROUTE_21_SOUTH",
"FR_ROUTE_24",
"FR_ROUTE_25",
}
-- Adjacency table matching pokefirered/src/roamer.c:30 sRoamerLocationHistory
Roamer.ADJACENCY = {
["FR_ROUTE_1"] = { "FR_ROUTE_2", "FR_ROUTE_21_NORTH", "FR_ROUTE_21_SOUTH" },
["FR_ROUTE_2"] = { "FR_ROUTE_1", "FR_ROUTE_3", "FR_ROUTE_22" },
["FR_ROUTE_3"] = { "FR_ROUTE_2", "FR_ROUTE_4" },
["FR_ROUTE_4"] = { "FR_ROUTE_3", "FR_ROUTE_9", "FR_ROUTE_24" },
["FR_ROUTE_5"] = { "FR_ROUTE_6", "FR_ROUTE_7", "FR_ROUTE_8", "FR_ROUTE_24" },
["FR_ROUTE_6"] = { "FR_ROUTE_5", "FR_ROUTE_7", "FR_ROUTE_8", "FR_ROUTE_11" },
["FR_ROUTE_7"] = { "FR_ROUTE_5", "FR_ROUTE_6", "FR_ROUTE_8", "FR_ROUTE_16" },
["FR_ROUTE_8"] = { "FR_ROUTE_5", "FR_ROUTE_6", "FR_ROUTE_7", "FR_ROUTE_10", "FR_ROUTE_12" },
["FR_ROUTE_9"] = { "FR_ROUTE_4", "FR_ROUTE_10", "FR_ROUTE_24" },
["FR_ROUTE_10"] = { "FR_ROUTE_8", "FR_ROUTE_9", "FR_ROUTE_12" },
["FR_ROUTE_11"] = { "FR_ROUTE_6", "FR_ROUTE_12" },
["FR_ROUTE_12"] = { "FR_ROUTE_8", "FR_ROUTE_10", "FR_ROUTE_11", "FR_ROUTE_13" },
["FR_ROUTE_13"] = { "FR_ROUTE_12", "FR_ROUTE_14" },
["FR_ROUTE_14"] = { "FR_ROUTE_13", "FR_ROUTE_15" },
["FR_ROUTE_15"] = { "FR_ROUTE_14", "FR_ROUTE_18" },
["FR_ROUTE_16"] = { "FR_ROUTE_7", "FR_ROUTE_17" },
["FR_ROUTE_17"] = { "FR_ROUTE_16", "FR_ROUTE_18" },
["FR_ROUTE_18"] = { "FR_ROUTE_15", "FR_ROUTE_17", "FR_ROUTE_19" },
["FR_ROUTE_19"] = { "FR_ROUTE_18", "FR_ROUTE_20" },
["FR_ROUTE_20"] = { "FR_ROUTE_19", "FR_ROUTE_21_SOUTH" },
["FR_ROUTE_21_NORTH"] = { "FR_ROUTE_1", "FR_ROUTE_21_SOUTH" },
["FR_ROUTE_21_SOUTH"] = { "FR_ROUTE_20", "FR_ROUTE_21_NORTH" },
["FR_ROUTE_24"] = { "FR_ROUTE_4", "FR_ROUTE_5", "FR_ROUTE_9", "FR_ROUTE_25" },
["FR_ROUTE_25"] = { "FR_ROUTE_24" },
}
local function normalize_map_id(mapId)
if type(mapId) ~= "string" then return nil end
local s = tostring(mapId):upper()
-- Check MapCatalog if available
local ok, MapCatalog = pcall(require, "src.import.gba.map_catalog")
if ok and MapCatalog and MapCatalog.resolve then
local res = MapCatalog.resolve(s)
if res then s = tostring(res):upper() end
end
if s:find("ROUTE_?21_?NORTH") or s:find("ROUTE_?21_?SOUTH") then
if s:find("SOUTH") then return "FR_ROUTE_21_SOUTH" end
return "FR_ROUTE_21_NORTH"
end
if s == "FR_ROUTE21" or s == "ROUTE_21" or s == "FR_ROUTE_21" or s == "ROUTE21" then
return "FR_ROUTE_21_NORTH"
end
local num = s:match("ROUTE_?(%d+)")
if num then
local target = "FR_ROUTE_" .. num
for _, loc in ipairs(Roamer.LOCATIONS) do
if loc == target then return target end
end
end
for _, loc in ipairs(Roamer.LOCATIONS) do
if s == loc or s == loc:gsub("^FR_", "") or s == loc:gsub("_", "") or s == ("FR_" .. s:gsub("_", "")) then
return loc
end
end
return s
end
Roamer.normalizeMapId = normalize_map_id
--- Determine species based on starter:
--- Bulbasaur (0) -> Entei (244)
--- Squirtle (1) -> Raikou (243)
--- Charmander (2) -> Suicune (245)
function Roamer.speciesForStarter(starter)
starter = tonumber(starter) or 0
if starter == 1 then
return Roamer.SPECIES_RAIKOU
elseif starter == 2 then
return Roamer.SPECIES_SUICUNE
else
return Roamer.SPECIES_ENTEI
end
end
local function random_32bit()
local hi = Rng.Random() % 65536
local lo = Rng.Random() % 65536
return hi * 65536 + lo
end
--- Generates standard 0-31 IV block (fixing the vanilla Gen 3 hardware truncation bug)
function Roamer.generateMon(species, level)
level = level or Roamer.ROAMER_LEVEL
local pid = random_32bit()
local ivs = {
hp = Rng.Random() % 32,
attack = Rng.Random() % 32,
defense = Rng.Random() % 32,
speed = Rng.Random() % 32,
spAtk = Rng.Random() % 32,
spDef = Rng.Random() % 32,
}
local moves, pp = nil, nil
if Pokemon and Pokemon.movesAtLevel then
moves, pp = Pokemon.movesAtLevel(species, level)
end
local hpBase = (species == Roamer.SPECIES_RAIKOU and 90)
or (species == Roamer.SPECIES_ENTEI and 115)
or (species == Roamer.SPECIES_SUICUNE and 100) or 100
local maxHp = math.floor(((2 * hpBase + ivs.hp) * level) / 100) + level + 10
return {
species = species,
speciesId = species,
level = level,
hp = maxHp,
maxHp = maxHp,
status = 0,
statusNum = 0,
pid = pid,
ivs = ivs,
moves = moves or {},
pp = pp or {},
}
end
--- Initialize roamer on Celio Ruby/Sapphire completion (special InitRoamer)
function Roamer.init(session, starterChoice)
if not session then return false end
local species = Roamer.speciesForStarter(starterChoice)
local mon = Roamer.generateMon(species, Roamer.ROAMER_LEVEL)
-- Initial location: pick random Kanto route
local locIndex = (Rng.Random() % #Roamer.LOCATIONS) + 1
local initialMap = Roamer.LOCATIONS[locIndex]
session.roamer = {
active = true,
species = species,
level = Roamer.ROAMER_LEVEL,
hp = mon.hp or mon.maxHp,
maxHp = mon.maxHp or mon.hp,
status = mon.status or 0,
statusNum = mon.statusNum or 0,
pid = mon.pid,
ivs = mon.ivs,
moves = mon.moves,
pp = mon.pp,
map = initialMap,
}
return true
end
--- Jump roamer to a completely random Kanto route (Fly, Teleport, Escape Rope, Whiteout)
function Roamer.jump(session)
if not (session and session.roamer and session.roamer.active) then return end
local cur = session.roamer.map
local locs = Roamer.LOCATIONS
local pick
for _ = 1, 10 do
pick = locs[(Rng.Random() % #locs) + 1]
if pick ~= cur then break end
end
session.roamer.map = pick or locs[1]
end
--- Step roamer across connected routes (on map transition)
function Roamer.move(session, reason)
if not (session and session.roamer and session.roamer.active) then return end
if reason == "warp_random" then
Roamer.jump(session)
return
end
local cur = session.roamer.map
local adj = Roamer.ADJACENCY[cur]
if not adj or #adj == 0 then
Roamer.jump(session)
return
end
-- ~16/256 chance to stay on same route, otherwise pick adjacent route
if (Rng.Random() % 16) == 0 then
return
end
local pick = adj[(Rng.Random() % #adj) + 1]
session.roamer.map = pick or cur
end
--- Check party slot 1 level for Repel check (even if fainted!)
local function getLeadMonLevel(session)
local party = session and session.party
if type(party) ~= "table" then return 0 end
for i = 1, #party do
local mon = party[i]
-- Eggs are ignored; fainted lead Pokémon in slot 1 IS evaluated by Repel!
if type(mon) == "table" and not mon.isEgg and not mon.egg then
return tonumber(mon.level) or tonumber(mon.lvl) or 1
end
end
return 0
end
--- Intercept wild encounter if roamer is active on current map
function Roamer.tryEncounter(session, mapId, terrain)
if not (session and session.roamer and session.roamer.active) then return nil end
local roamer = session.roamer
if roamer.hp <= 0 then return nil end
local normMap = normalize_map_id(mapId)
local roamerMap = normalize_map_id(roamer.map)
if normMap ~= roamerMap then return nil end
-- Only wild land/grass or surf water triggers
if terrain ~= "land" and terrain ~= "grass" and terrain ~= "water" then
return nil
end
-- Repel check (respects fainted slot-1 Pokémon level)
local Space = package.loaded["src.core.game3.scripting.space"]
local store = (Space and Space.store) or session.store or session
local Flags = require("src.core.game3.scripting.flags")
local repelSteps = tonumber(Flags.getVar(store, nil, VAR_REPEL_STEP_COUNT)) or 0
if repelSteps > 0 then
local leadLv = getLeadMonLevel(session)
if leadLv > roamer.level then
return nil
end
end
-- Construct wild foe encounter descriptor
local foe = {
species = roamer.species,
speciesId = roamer.species,
level = roamer.level,
hp = roamer.hp,
maxHp = roamer.maxHp,
status = roamer.status,
statusNum = roamer.statusNum,
pid = roamer.pid,
ivs = roamer.ivs,
moves = roamer.moves,
pp = roamer.pp,
roamer = true,
}
return {
species = roamer.species,
level = roamer.level,
roamer = true,
foe = foe,
}
end
--- Update roamer status and HP on battle completion
function Roamer.onBattleEnd(session, foeState, battleResult, endReason)
if not (session and session.roamer and session.roamer.active) then return end
local roamer = session.roamer
if foeState then
roamer.hp = math.max(0, tonumber(foeState.hp) or roamer.hp)
roamer.status = foeState.status or roamer.status
roamer.statusNum = foeState.statusNum or roamer.statusNum
end
-- Caught or defeated -> permanently deactivate
if battleResult == "caught" or (foeState and foeState.hp and foeState.hp <= 0) then
roamer.active = false
return
end
-- If battle ended by fleeing, player running, or Roar/Whirlwind:
-- Prevent Gen 3 Roar despawn bug: keep roamer alive and migrate to new location
Roamer.jump(session)
end
return Roamer
+14
View File
@@ -251,6 +251,10 @@ function Runtime.update(dt)
local okF, Fade = pcall(require, "src.ui.game3.fade")
if okF and Fade.tick then Fade.tick(dt) end
local okSea, SeagallopUi = pcall(require, "src.ui.game3.seagallop")
if okSea and SeagallopUi and SeagallopUi.isActive and SeagallopUi.isActive() then
SeagallopUi.update(dt)
end
local okTr, BattleTransition = pcall(require, "src.core.game3.battle_transition")
if okTr and BattleTransition.isActive and BattleTransition.isActive() then
BattleTransition.tick(dt)
@@ -264,8 +268,15 @@ function Runtime.update(dt)
Audio.tickCry(dt)
end
local okW, FieldWeather = pcall(require, "src.core.game3.field_weather")
if okW and FieldWeather and FieldWeather.update then
FieldWeather.update(dt)
end
local Battle = require("src.core.game3.battle")
if Battle.isActive() then
local Weather = require("src.core.game3.weather")
Weather.suspend()
Battle.update(dt, game)
-- Keep script VM + message typewriter alive while battle runs.
local Space = package.loaded["src.core.game3.scripting.space"]
@@ -278,6 +289,9 @@ function Runtime.update(dt)
if Message and Message.tick then Message.tick() end
Hud.update(game, dt, inputTop)
return
else
local Weather = require("src.core.game3.weather")
Weather.resume()
end
if not inMenu then
+20
View File
@@ -217,6 +217,7 @@ function Schema.newGame(opts)
secretId = nil,
rng = nil,
vsSeeker = { steps = 0, charging = 0, rematches = {} },
roamer = nil,
}
-- pret new_game.c: SeedWildEncounterRng(Random()) after title SeedRngAndSetTrainerId.
local Rng = require("src.core.game3.rng")
@@ -305,6 +306,7 @@ function Schema.toSaveTable(session)
secretId = session.secretId,
rng = session.rng,
vsSeeker = session.vsSeeker,
roamer = session.roamer,
-- GAME_STAT_* counters: slot-machine jackpots, hatched eggs, link W/L/D,
-- link trades, and the sticker-man brags that read them.
gameStats = session.gameStats or {},
@@ -414,6 +416,7 @@ function Schema.fromSaveTable(save)
playerId = save.trainerId,
rng = save.rng,
vsSeeker = type(save.vsSeeker) == "table" and save.vsSeeker or { steps = 0, charging = 0, rematches = {} },
roamer = type(save.roamer) == "table" and save.roamer or nil,
-- Additive: a save written before this key exists loads as an empty table.
gameStats = type(save.gameStats) == "table" and save.gameStats or {},
-- Additive: older saves load these as empty tables.
@@ -439,6 +442,7 @@ function Schema.fromSaveTable(save)
Schema.repairOwnMons(session)
local Flags = require("src.core.game3.scripting.flags")
Flags.repairSaveState(session)
Schema.repairRoamer(session)
if type(save.options) == "table" then
Options.bind(session, save.options)
else
@@ -447,6 +451,22 @@ function Schema.fromSaveTable(save)
return session
end
function Schema.repairRoamer(session)
if not session or session.roamer then return end
local FLAG_SYS_CAN_LINK_WITH_RS = 0x844
local VAR_MAP_SCENE_ONE_ISLAND_POKEMON_CENTER_1F = 0x4076
local VAR_STARTER_MON = 0x4031
local flags = session.flags or {}
local hasLink = (flags[FLAG_SYS_CAN_LINK_WITH_RS] == true) or (flags["FLAG_SYS_CAN_LINK_WITH_RS"] == true)
local vars = session.vars or {}
local sceneVal = tonumber(vars[VAR_MAP_SCENE_ONE_ISLAND_POKEMON_CENTER_1F] or vars["VAR_MAP_SCENE_ONE_ISLAND_POKEMON_CENTER_1F"]) or 0
if hasLink or sceneVal >= 6 then
local Roamer = require("src.core.game3.roamer")
local starter = tonumber(vars[VAR_STARTER_MON] or vars["VAR_STARTER_MON"]) or 0
Roamer.init(session, starter)
end
end
function Schema.ensureMonBall(mon)
if type(mon) ~= "table" then return end
-- pokefirered/src/pokemon.c:1820
+10 -1
View File
@@ -362,7 +362,16 @@ Events.HANDLERS = {
return false
end,
-- src/roamer.c:120
[Std.SPECIAL.InitRoamer] = noop,
[Std.SPECIAL.InitRoamer] = function(ctx)
local session = sessionOf(ctx)
local VAR_STARTER_MON = 0x4031 -- pokefirered/include/constants/vars.h:98
local starter = varGet(ctx, VAR_STARTER_MON)
local okR, Roamer = pcall(require, "src.core.game3.roamer")
if okR and Roamer and Roamer.init then
Roamer.init(session, starter)
end
return false
end,
-- src/field_specials.c:679-690
[Std.SPECIAL.SampleResortGorgeousMonAndReward] = function(ctx, adapters)
local session = sessionOf(ctx)
+18 -1
View File
@@ -146,9 +146,26 @@ Seagallop.MENU_LIST_ID = 0xF001
-- pokefirered/src/seagallop.c:174
function Seagallop.ferryTask(ctx, adapters, destId)
local originId = varGet(ctx, VAR_ORIGIN)
local warp = WARPS[destId]
local done = false
local okS, SeagallopUi = pcall(require, "src.ui.game3.seagallop")
local hasGraphics = _G.love and type(_G.love.graphics) == "table" and _G.love.window ~= nil
if okS and SeagallopUi and SeagallopUi.start and hasGraphics then
SeagallopUi.start(originId, destId, function()
if warp and adapters and adapters.warp then
adapters.warp(warp[1], warp[2], -1, warp[3], warp[4], function() end, "seagallop")
elseif adapters and adapters.log then
adapters.log(string.format("[game3] seagallop has no warp for dest %s", tostring(destId)))
end
end, function()
done = true
end)
return function() return done end
end
local frames = 0
local phase = "cross"
local warp = WARPS[destId]
local waited, arrived = 0, false
local okA, Audio = pcall(require, "src.core.game3.audio")
if not okA then Audio = nil end
+7 -1
View File
@@ -157,6 +157,12 @@ function StepEvents.onStepTaken(session, game)
-- pokefirered/src/field_specials.c:2433 IncrementBirthIslandRockStepCount
require("src.core.game3.deoxys").incrementStepCount(session)
-- pokefirered/src/field_control_avatar.c:219 IncrementRenewableHiddenItemStepCounter
local okRen, Renewable = pcall(require, "src.core.game3.renewable_hidden_items")
if okRen and Renewable and Renewable.onStep then
Renewable.onStep(session, session.mapGroup, session.mapNum, session.map)
end
-- pokefirered/src/field_control_avatar.c:658
local forced = forced_step()
local poisonFainted = false
@@ -207,7 +213,7 @@ function StepEvents.onStepTaken(session, game)
if anyPoisonDamage then
-- Trigger 4-frame reddish screen flash and poison SE
StepEvents._poisonFlashTimer = 4 / 60
se(35) -- SE_FIELD_POISON
se(72) -- SE_FIELD_POISON (72)
-- pokefirered/src/field_control_avatar.c:727 FLDPSN_FNT
poisonFainted = #faintedMons > 0
+49 -11
View File
@@ -1,25 +1,42 @@
-- Field weather session state (H5). Ops setweather / doweather / resetweather.
-- pokefirered/include/constants/weather.h
-- pokefirered/src/field_weather.c
local Weather = {}
Weather.NONE = 0
Weather.RAIN = 1
Weather.ASH = 2
Weather.FOG = 3
Weather.SANDSTORM = 4
Weather.SUNNY = 5
Weather.SUNNY_CLOUDS = 1
Weather.SUNNY = 2
Weather.RAIN = 3
Weather.SNOW = 4
Weather.RAIN_THUNDERSTORM = 5
Weather.FOG_HORIZONTAL = 6
Weather.VOLCANIC_ASH = 7
Weather.SANDSTORM = 8
Weather.FOG_DIAGONAL = 9
Weather.UNDERWATER = 10
Weather.SHADE = 11
Weather.DROUGHT = 12
Weather.DOWNPOUR = 13
Weather.UNDERWATER_BUBBLES = 14
Weather.ABNORMAL = 15
Weather.current = 0
-- Aliases
Weather.FOG = Weather.FOG_HORIZONTAL
Weather.ASH = Weather.VOLCANIC_ASH
Weather.current = Weather.NONE
Weather._pending = nil
Weather._active = false
Weather._suspended = false
function Weather.set(id)
Weather._pending = tonumber(id) or 0
Weather._pending = tonumber(id) or Weather.NONE
end
function Weather.doWeather()
Weather.current = Weather._pending or Weather.current
Weather._active = Weather.current ~= Weather.NONE
Weather._active = Weather.current ~= Weather.NONE and Weather.current ~= Weather.SUNNY
Weather.apply(Weather.current)
end
@@ -31,9 +48,14 @@ function Weather.reset()
end
function Weather.apply(id)
Weather.current = tonumber(id) or 0
Weather._active = Weather.current ~= Weather.NONE
-- Overlay hook: host World may expose weather palette; optional.
Weather.current = tonumber(id) or Weather.NONE
Weather._active = Weather.current ~= Weather.NONE and Weather.current ~= Weather.SUNNY
local okFw, FieldWeather = pcall(require, "src.core.game3.field_weather")
if okFw and FieldWeather and FieldWeather.setWeather then
FieldWeather.setWeather(Weather.current)
end
local Runtime = package.loaded["src.core.game3.runtime"]
local game = Runtime and Runtime._game
local world = game and (game.overworld or game.world)
@@ -46,4 +68,20 @@ function Weather.get()
return Weather.current
end
function Weather.isActive()
return Weather._active
end
function Weather.suspend()
Weather._suspended = true
end
function Weather.resume()
Weather._suspended = false
end
function Weather.isSuspended()
return Weather._suspended
end
return Weather
+1
View File
@@ -278,6 +278,7 @@ function RomExtractorGen3:runPokemonExtract(sha1)
local SeagallopExtract = require("src.import.gba.seagallop_extract")
SeagallopExtract.run(rom, cache, { cacheRoot = GBA_ROOT })
require("src.import.gba.cave_transition_extract").run(rom, cache, { cacheRoot = GBA_ROOT })
require("src.import.gba.weather_extract").run(rom, cache, { cacheRoot = GBA_ROOT })
require("src.import.gba.ingame_trades_extract").run(rom, cache, { cacheRoot = GBA_ROOT })
require("src.import.gba.union_room_classes_extract").run(rom, cache, { cacheRoot = GBA_ROOT })
local MapPreviewExtract = require("src.import.gba.map_preview_extract")
+142
View File
@@ -0,0 +1,142 @@
-- Weather graphics and palette extraction (Fog, Rain, default weather palette)
-- pokefirered/src/field_weather.c
local Versions = require("src.import.gba.versions")
local WeatherExtract = {}
WeatherExtract.CACHE_SUB = "weather"
WeatherExtract.FORMAT_VERSION = 1
WeatherExtract.BLOBS = {
{ key = "default_pal", rel = "default.gbapal", offset = 0x3C2CE0, size = 32 },
{ key = "fog_h_gfx", rel = "fog_horizontal.4bpp", offset = 0x3C3540, size = 2048 },
{ key = "rain_gfx", rel = "rain.4bpp", offset = 0x3C55E0, size = 1536 },
}
local function bgr555_to_rgb8(c)
c = (tonumber(c) or 0) % 32768
local r5 = c % 32
local g5 = math.floor(c / 32) % 32
local b5 = math.floor(c / 1024) % 32
return math.floor(r5 * 255 / 31 + 0.5),
math.floor(g5 * 255 / 31 + 0.5),
math.floor(b5 * 255 / 31 + 0.5)
end
local function bytes_to_array(v)
if type(v) == "table" then return v end
local out = {}
if type(v) == "string" then
for i = 1, #v do out[i] = v:byte(i) end
end
return out
end
local function load_pal(bytes, count)
bytes = bytes_to_array(bytes)
local pal = {}
for i = 0, count - 1 do
pal[i] = (bytes[i * 2 + 1] or 0) + (bytes[i * 2 + 2] or 0) * 256
end
return pal
end
local function decode_tile_4bpp(gfx, base, out)
for row = 0, 7 do
for col = 0, 7 do
local b = gfx[base + row * 4 + math.floor(col / 2) + 1] or 0
local idx
if col % 2 == 0 then idx = b % 16 else idx = math.floor(b / 16) end
out[row * 8 + col + 1] = idx
end
end
end
function WeatherExtract.bakeSheet(gfxBytes, palBytes, cols)
local gfx = bytes_to_array(gfxBytes)
local pal = load_pal(palBytes, 16)
local tileCount = math.floor(#gfx / 32)
cols = math.max(1, cols or 8)
local rows = math.ceil(tileCount / cols)
local W, H = cols * 8, rows * 8
local chunks = {}
for i = 1, W * H do chunks[i] = "\0\0\0\0" end
local tmp = {}
for t = 0, tileCount - 1 do
decode_tile_4bpp(gfx, t * 32, tmp)
local ox, oy = (t % cols) * 8, math.floor(t / cols) * 8
for row = 0, 7 do
for col = 0, 7 do
local idx = tmp[row * 8 + col + 1] or 0
if idx ~= 0 then
local r, g, b = bgr555_to_rgb8(pal[idx] or 0)
chunks[(oy + row) * W + ox + col + 1] = string.char(r, g, b, 255)
end
end
end
end
return table.concat(chunks), W, H, tileCount
end
function WeatherExtract.readBlobs(rom, cfg)
cfg = cfg or {}
local out = {}
for _, blob in ipairs(WeatherExtract.BLOBS) do
local off = cfg[blob.key] or Versions.address(blob.offset)
local bytes = rom:readBytes(off, blob.size)
local chars = {}
for i = 1, blob.size do chars[i] = string.char(bytes[i] or 0) end
out[blob.key] = table.concat(chars)
end
return out
end
local function write_cache(cache, rel, data)
if type(cache) ~= "table" or type(cache.write) ~= "function" then return end
local ok, res, err = pcall(function() return cache:write(rel, data) end)
if not ok or res == nil then res, err = cache.write(rel, data) end
return res
end
local function default_cache_root()
local ok, Extract = pcall(require, "src.import.gba.extract_island1")
if ok and Extract and Extract.CACHE_ROOT then return Extract.CACHE_ROOT end
return "data/generated/gba"
end
function WeatherExtract.ready(cache, cacheRoot)
local root = (cacheRoot or default_cache_root()) .. "/" .. WeatherExtract.CACHE_SUB
if not (cache and cache.read) then return false end
local fog = cache:read(root .. "/fog_horizontal.rgba")
return type(fog) == "string" and #fog == 64 * 64 * 4
end
function WeatherExtract.run(rom, cache, opts)
opts = opts or {}
local root = (opts.cacheRoot or default_cache_root()) .. "/" .. WeatherExtract.CACHE_SUB
local cfg = opts.offsets or {}
local blobs = WeatherExtract.readBlobs(rom, cfg)
for _, blob in ipairs(WeatherExtract.BLOBS) do
write_cache(cache, root .. "/" .. blob.rel, blobs[blob.key])
end
local fogRgba, fogW, fogH = WeatherExtract.bakeSheet(blobs.fog_h_gfx, blobs.default_pal, 8)
write_cache(cache, root .. "/fog_horizontal.rgba", fogRgba)
local rainRgba, rainW, rainH = WeatherExtract.bakeSheet(blobs.rain_gfx, blobs.default_pal, 2)
write_cache(cache, root .. "/rain.rgba", rainRgba)
write_cache(cache, root .. "/manifest.lua", string.format([[
return {
format = %d,
fog_h = { file = "fog_horizontal.rgba", w = %d, h = %d },
rain = { file = "rain.rgba", w = %d, h = %d },
}
]], WeatherExtract.FORMAT_VERSION, fogW, fogH, rainW, rainH))
return { format = WeatherExtract.FORMAT_VERSION }
end
return WeatherExtract
+303
View File
@@ -0,0 +1,303 @@
-- Seagallop high-speed ferry cutscene (FRLG 1:1)
-- pokefirered/src/seagallop.c
local Fade = require("src.ui.game3.fade")
local Seagallop = {}
local W, H = 240, 160
local SE_SHIP = 19 -- pokefirered/include/constants/songs.h:23
local SE_EXIT = 9 -- pokefirered/include/constants/songs.h:13
local CROSSING_FRAMES = 140
local DIRN_WESTBOUND = 0
local DIRN_EASTBOUND = 1
-- pokefirered/src/seagallop.c:88
local TRAVEL_DIRECTIONS = {
[0] = 0x6fe, -- VERMILION_CITY
[1] = 0x6fc, -- ONE_ISLAND
[2] = 0x6f8, -- TWO_ISLAND
[3] = 0x6f0, -- THREE_ISLAND
[4] = 0x6e0, -- FOUR_ISLAND
[5] = 0x4c0, -- FIVE_ISLAND
[6] = 0x400, -- SIX_ISLAND
[7] = 0x440, -- SEVEN_ISLAND
[8] = 0x7ff, -- CINNABAR_ISLAND
[9] = 0x6e0, -- NAVEL_ROCK
[10] = 0x000, -- BIRTH_ISLAND
}
local CACHE_ROOT = "data/generated/gba/seagallop"
Seagallop._assets = nil
Seagallop._active = false
Seagallop._run = nil
function Seagallop.directionOfTravel(originId, destId)
originId = tonumber(originId) or 0
destId = tonumber(destId) or 0
local mask = TRAVEL_DIRECTIONS[originId]
if not mask then return DIRN_EASTBOUND end
return (math.floor(mask / (2 ^ destId)) % 2 == 1) and DIRN_EASTBOUND or DIRN_WESTBOUND
end
local function read_bytes(rel)
local ok, CacheFs = pcall(require, "src.import.CacheFs")
if ok and CacheFs and CacheFs.readActive then
local d = CacheFs.readActive(rel)
if type(d) == "string" and #d > 0 then return d end
end
local okD, Dataset = pcall(require, "src.core.game3.dataset")
if okD and Dataset and Dataset.cache then
local d = Dataset.cache():read(rel)
if type(d) == "string" and #d > 0 then return d end
end
if love and love.filesystem and love.filesystem.read then
local d = love.filesystem.read(rel)
if type(d) == "string" and #d > 0 then return d end
local alt = "data/generated/gba/" .. (rel:gsub("^data/generated/gba/", ""))
d = love.filesystem.read(alt)
if type(d) == "string" and #d > 0 then return d end
end
local home = os.getenv("HOME") or ""
local candidates = {
rel,
"data/generated/gba/" .. (rel:gsub("^data/generated/gba/", "")),
home .. "/.local/share/love/pokemon-love2d/firered/" .. rel,
home .. "/.local/share/love/pokemon-love2d/leafgreen/" .. rel,
home .. "/.local/share/love/pokemon-love2d/" .. rel,
}
for _, p in ipairs(candidates) do
local f = io.open(p, "rb")
if f then
local d = f:read("*a")
f:close()
if d and #d > 0 then return d end
end
end
return nil
end
local function rgba_to_image(raw, w, h)
if not (love and love.image and love.graphics) then return nil end
if not raw or #raw < w * h * 4 then return nil end
local ok, imgData = pcall(love.image.newImageData, w, h, "rgba8", raw)
if not ok or not imgData then return nil end
local img = love.graphics.newImage(imgData)
if img.setFilter then img:setFilter("nearest", "nearest") end
if img.setWrap then img:setWrap("repeat", "repeat") end
return img
end
local function loadAssets()
if Seagallop._assets then return Seagallop._assets end
local wbRaw = read_bytes(CACHE_ROOT .. "/wb.rgba")
local ebRaw = read_bytes(CACHE_ROOT .. "/eb.rgba")
local ferryRaw = read_bytes(CACHE_ROOT .. "/ferry.rgba")
local wakeRaw = read_bytes(CACHE_ROOT .. "/wake.rgba")
local wbImg = rgba_to_image(wbRaw, 256, 256)
local ebImg = rgba_to_image(ebRaw, 256, 256)
local ferryImg = rgba_to_image(ferryRaw, 64, 40)
local wakeImg = rgba_to_image(wakeRaw, 32, 128)
local wakeQuads = nil
if wakeImg and love and love.graphics and love.graphics.newQuad then
wakeQuads = {
love.graphics.newQuad(0, 0, 32, 32, 32, 128),
love.graphics.newQuad(0, 32, 32, 32, 32, 128),
love.graphics.newQuad(0, 64, 32, 32, 32, 128),
}
end
Seagallop._assets = {
wb = wbImg,
eb = ebImg,
ferry = ferryImg,
wake = wakeImg,
wakeQuads = wakeQuads,
}
return Seagallop._assets
end
function Seagallop.start(originId, destId, onWarp, onDone)
local dir = Seagallop.directionOfTravel(originId, destId)
local run = {
origin = originId,
dest = destId,
direction = dir,
onWarp = onWarp,
onDone = onDone,
tick = 0,
accum = 0,
bgX = 0,
ferryX = (dir == DIRN_EASTBOUND) and 0 or 240,
ferryY = 92,
wakes = {},
state = "running",
}
local okA, Audio = pcall(require, "src.core.game3.audio")
if okA and Audio and Audio.playSe then
pcall(Audio.playSe, SE_SHIP)
end
Seagallop._run = run
Seagallop._active = true
loadAssets()
-- pokefirered/src/seagallop.c:229: Fade in from black as cutscene starts
Fade.begin(Fade.MODE.FROM_BLACK, 0.3)
return true
end
function Seagallop.isActive()
return Seagallop._active and Seagallop._run ~= nil
end
function Seagallop.stop()
Seagallop._active = false
Seagallop._run = nil
end
local function stepTick(run)
run.tick = run.tick + 1
-- 6.0 pixels per tick for water background (0x600 8.8 fixed-point displacement)
-- 3.0 pixels per tick for ferry (48 subpixels: 48 >> 4)
if run.direction == DIRN_EASTBOUND then
run.bgX = (run.bgX + 6.0) % 256
run.ferryX = run.ferryX + 3.0
else
run.bgX = (run.bgX - 6.0) % 256
run.ferryX = run.ferryX - 3.0
end
-- Spawn wake every 5 ticks
if run.tick % 5 == 0 then
table.insert(run.wakes, {
x = run.ferryX,
y = run.ferryY,
frame = 1,
tick = 0,
})
end
-- Update wake animation (20 frames, 20 frames, 15 frames)
local i = 1
while i <= #run.wakes do
local w = run.wakes[i]
w.tick = w.tick + 1
if w.tick < 20 then
w.frame = 1
elseif w.tick < 40 then
w.frame = 2
elseif w.tick < 55 then
w.frame = 3
else
table.remove(run.wakes, i)
i = i - 1
end
i = i + 1
end
-- pokefirered/src/seagallop.c:286
if run.tick >= CROSSING_FRAMES and run.state == "running" then
run.state = "fading"
local okA, Audio = pcall(require, "src.core.game3.audio")
if okA and Audio and Audio.fadeOutBgm then
pcall(Audio.fadeOutBgm, 4)
end
Fade.begin(Fade.MODE.TO_BLACK, 0.35, function()
run.state = "warping"
if Audio and Audio.playSe then
pcall(Audio.playSe, SE_EXIT)
end
if run.onWarp then
run.onWarp()
end
Fade.begin(Fade.MODE.FROM_BLACK, 0.35, function()
run.state = "done"
Seagallop.stop()
if run.onDone then
run.onDone()
end
end)
end)
end
end
function Seagallop.update(dt)
local run = Seagallop._run
if not run then return end
run.accum = run.accum + (dt or (1 / 60))
local maxTicks = 10
while run.accum >= (1 / 60) and maxTicks > 0 do
run.accum = run.accum - (1 / 60)
maxTicks = maxTicks - 1
stepTick(run)
end
end
function Seagallop.draw()
local run = Seagallop._run
if not run then return end
local assets = loadAssets()
local cw = (love.graphics.getWidth and love.graphics.getWidth()) or W
local ch = (love.graphics.getHeight and love.graphics.getHeight()) or H
local maxW = math.max(cw, W, 800)
local maxH = math.max(ch, H, 600)
-- Solid black full-screen background
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", -maxW, -maxH, maxW * 3, maxH * 3)
love.graphics.setColor(1, 1, 1, 1)
-- GBA WIN0 letterbox scissor (Y: 24 to 136)
love.graphics.setScissor(0, 24, W, 112)
if assets and assets.wb and assets.eb then
local bg = (run.direction == DIRN_EASTBOUND) and assets.eb or assets.wb
local ox = math.floor(run.bgX)
local startX = -ox
while startX > 0 do startX = startX - 256 end
for x = startX, W, 256 do
love.graphics.draw(bg, x, 0)
end
-- Wakes
if assets.wake and assets.wakeQuads then
local scaleX = (run.direction == DIRN_EASTBOUND) and -1 or 1
for _, w in ipairs(run.wakes) do
local q = assets.wakeQuads[w.frame] or assets.wakeQuads[1]
love.graphics.draw(assets.wake, q, math.floor(w.x), math.floor(w.y), 0, scaleX, 1, 16, 16)
end
end
-- Ferry
if assets.ferry then
local scaleX = (run.direction == DIRN_EASTBOUND) and -1 or 1
love.graphics.draw(assets.ferry, math.floor(run.ferryX), math.floor(run.ferryY), 0, scaleX, 1, 32, 20)
end
else
love.graphics.setColor(0.18, 0.44, 0.75, 1)
love.graphics.rectangle("fill", 0, 24, W, 112)
end
love.graphics.setScissor()
-- Top and bottom black letterbox bars (and side borders)
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", -maxW, -maxH, maxW * 3, maxH + 24)
love.graphics.rectangle("fill", -maxW, 136, maxW * 3, maxH * 2)
love.graphics.rectangle("fill", -maxW, 0, maxW, H)
love.graphics.rectangle("fill", W, 0, maxW, H)
love.graphics.setColor(1, 1, 1, 1)
end
return Seagallop
+5
View File
@@ -123,6 +123,11 @@ function UiPass.drawUi()
BattleTransition.draw()
end
local okSea, SeagallopUi = pcall(require, "src.ui.game3.seagallop")
if okSea and SeagallopUi and SeagallopUi.isActive and SeagallopUi.isActive() then
tryDraw(SeagallopUi)
end
local okF, Fade = pcall(require, "src.ui.game3.fade")
local isNamingOpen = okN and Naming.isOpen and Naming.isOpen()
local okR, RegionMap = pcall(require, "src.ui.game3.region_map")
+2 -2
View File
@@ -121,8 +121,8 @@ local function loadExtractorWithStubs(pokeRun, sectionsRun)
for _, sibling in ipairs({
"items_extract", "pokedex_chrome_extract", "storage_chrome_extract",
"text_chrome_extract", "trainer_card_extract", "seagallop_extract",
"cave_transition_extract", "ingame_trades_extract", "union_room_classes_extract",
"map_preview_extract",
"cave_transition_extract", "weather_extract", "ingame_trades_extract",
"union_room_classes_extract", "map_preview_extract",
}) do
package.loaded["src.import.gba." .. sibling] = { run = function() return true end }
end
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env luajit
-- Tests ladder warp behaviors, arrival facing, and prevention of bump-warp loops.
package.path = "./?.lua;./?/init.lua;" .. package.path
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 finish()
if failed > 0 then
print("[test] FAILED " .. failed)
os.exit(1)
end
print("[test] all passed")
os.exit(0)
end
require("src.core.GameVersion").set("firered")
local gfx = setmetatable({}, { __index = function() return function() end end })
gfx.newQuad = function() return {} end
_G.love = { graphics = gfx }
package.loaded["src.core.game3.audio"] = {
install = function() return true end,
playSe = function() end,
playSong = function() end,
playFanfare = function() end,
stopAll = function() end,
stopSurfMusic = function() end,
waitSe = function(_, cb) if cb then cb() end end,
}
local Cache = require("tests.game3_cache")
local cacheRoot = Cache.mount("scripts/events.lua", { native = true })
if not cacheRoot then
print("[skip] game3_ladder_warp_test: " .. tostring(Cache.reason))
finish()
end
local Dataset = require("src.core.game3.dataset")
local game = { data = {} }
Dataset.hydrate(game)
local Map = require("src.core.game3.map")
local Collision = require("src.core.game3.collision")
local Player = require("src.core.game3.player")
local Warp = require("src.core.game3.warp")
local mapId = "FR_FIVE_ISLAND_LOST_CAVE_ROOM1"
local def = game.data.maps[mapId]
check(def ~= nil, "FR_FIVE_ISLAND_LOST_CAVE_ROOM1 exists")
pcall(Map.ensureMidLayout, game, mapId, def)
Collision.bindMap(game, mapId, def)
game.currentMap = mapId
-- Ladder is at (8, 2) with MB_LADDER (0x61)
local beh = Collision.behavior(8, 2)
check(beh == 0x61, "tile (8,2) is MB_LADDER")
check(Collision.isLadder(beh) == true, "tile (8,2) is ladder")
check(Collision.warpAt(8, 2) ~= nil, "tile (8,2) has a warp defined")
-- Wall above ladder is at (8, 1) and is solid tile (coll = 7)
local okUp, whyUp = Collision.canEnter(game, 8, 1, { fromX = 8, fromY = 2, dir = "up" })
check(okUp == false, "(8,1) above ladder is solid wall")
-- 1. Arrival facing on ladder
local arrivalFace = Collision.destArrivalFacing(game, mapId, 8, 2, "up")
check(arrivalFace == "down", "arrival facing on ladder is down (was " .. tostring(arrivalFace) .. ")")
-- 2. Standing on ladder at (8, 2) facing down
Player.cellX = 8
Player.cellY = 2
Player.facing = "up"
Player.moving = false
Player.turnTimer = 0
-- 3. Bumping solid wall (pressing UP) while on ladder tile MUST NOT warp!
local moveRes, moveWhy = Player.tryMove("up", game, false)
check(moveRes == "blocked",
"pressing UP into cave wall while on ladder returns blocked (got " .. tostring(moveRes) .. ", why=" .. tostring(moveWhy) .. ")")
-- 4. Stepping DOWN off the ladder into the open cave floor at (8, 3)
Player.facing = "down"
local okDown = Collision.canEnter(game, 8, 3, { fromX = 8, fromY = 2, dir = "down" })
check(okDown == true, "(8,3) below ladder is walkable")
local moveDown = Player.tryMove("down", game, false)
check(moveDown == "step", "pressing DOWN on ladder steps off the ladder (got " .. tostring(moveDown) .. ")")
finish()
@@ -0,0 +1,69 @@
-- Tests for Renewable Hidden Items system (Treasure Beach, Berry Forest, etc.)
local Renewable = require("src.core.game3.renewable_hidden_items")
local Flags = require("src.core.game3.scripting.flags")
local FlagsTable = require("src.core.game3.scripting.flags_table")
local tests = {}
function tests.test_exclusion_zone_and_step_counter()
local ctx = { vars = {}, flags = {} }
-- Step in normal map (e.g. Pallet Town, group 3, map 0) -> increments counter
Renewable.onStep(ctx, 3, 0, "PALLET_TOWN")
assert(Flags.getVar(ctx, nil, 0x4023) == 1, "Counter should be 1 after 1 step in non-renewable map")
Renewable.onStep(ctx, 3, 0, "PALLET_TOWN")
assert(Flags.getVar(ctx, nil, 0x4023) == 2, "Counter should be 2 after 2 steps in non-renewable map")
-- Step inside Treasure Beach (Group 3, Map 46) -> MUST NOT increment counter (Exclusion zone)
Renewable.onStep(ctx, 3, 46, "SEVII_ONE_ISLAND_TREASURE_BEACH")
assert(Flags.getVar(ctx, nil, 0x4023) == 2, "Counter must NOT increment while walking inside Treasure Beach")
-- Step inside Berry Forest (Group 1, Map 109) -> MUST NOT increment counter
Renewable.onStep(ctx, 1, 109, "SEVII_THREE_ISLAND_BERRY_FOREST")
assert(Flags.getVar(ctx, nil, 0x4023) == 2, "Counter must NOT increment while walking inside Berry Forest")
end
function tests.test_regeneration_on_map_enter()
local ctx = { vars = {}, flags = {} }
-- Set step counter to 1499 (< 1500)
Flags.setVar(ctx, nil, 0x4023, 1499)
-- Enter Treasure Beach -> should NOT regenerate yet
local regenerated = Renewable.tryRegenerate(ctx, 3, 46, "SEVII_ONE_ISLAND_TREASURE_BEACH")
assert(not regenerated, "Should not regenerate under 1500 steps")
assert(Flags.getVar(ctx, nil, 0x4023) == 1499, "Counter should stay 1499")
-- Accumulate to 1500
Flags.setVar(ctx, nil, 0x4023, 1500)
-- Enter a non-renewable map -> should NOT regenerate
local nonRen = Renewable.tryRegenerate(ctx, 3, 0, "PALLET_TOWN")
assert(not nonRen, "Non-renewable map should not trigger regeneration")
assert(Flags.getVar(ctx, nil, 0x4023) == 1500, "Counter should remain 1500 when entering non-renewable map")
-- Test RNG roll for Rare tier (roll = 95 >= 90)
regenerated = Renewable.tryRegenerate(ctx, 3, 46, "SEVII_ONE_ISLAND_TREASURE_BEACH", function() return 95 end)
assert(regenerated, "Entering Treasure Beach with >= 1500 steps should trigger regeneration")
assert(Flags.getVar(ctx, nil, 0x4023) == 0, "Step counter should reset to 0 after regeneration")
-- In Treasure Beach rare tier: Ultra Ball, Star Piece, Big Pearl should be cleared (spawned)
local starPieceFlag = Flags.IDS.FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_STAR_PIECE
local bigPearlFlag = Flags.IDS.FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_BIG_PEARL
assert(Flags.getFlag(ctx, nil, starPieceFlag) == false, "Rare Star Piece should be spawned (flag false)")
assert(Flags.getFlag(ctx, nil, bigPearlFlag) == false, "Rare Big Pearl should be spawned (flag false)")
-- Test RNG roll for Common tier (roll = 30 < 60)
Flags.setVar(ctx, nil, 0x4023, 1500)
Renewable.tryRegenerate(ctx, 3, 46, "SEVII_ONE_ISLAND_TREASURE_BEACH", function() return 30 end)
-- Rare items should now be reset to hidden/collected (flag true)
assert(Flags.getFlag(ctx, nil, starPieceFlag) == true, "Star piece should be hidden in common roll (flag true)")
assert(Flags.getFlag(ctx, nil, bigPearlFlag) == true, "Big pearl should be hidden in common roll (flag true)")
-- Common ultra ball should be spawned (flag false)
local ultraBallFlag = Flags.IDS.FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_ULTRA_BALL
assert(Flags.getFlag(ctx, nil, ultraBallFlag) == false, "Common Ultra Ball should be spawned (flag false)")
end
return tests
+254
View File
@@ -0,0 +1,254 @@
#!/usr/bin/env luajit
package.path = "./?.lua;./?/init.lua;" .. package.path
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 done()
if failed > 0 then
print("[test] FAILED " .. failed)
os.exit(1)
end
print("[test] all passed")
os.exit(0)
end
local Roamer = require("src.core.game3.roamer")
local Audio = require("src.core.game3.audio")
local Schema = require("src.core.game3.save_schema_firered")
local Flags = require("src.core.game3.scripting.flags")
local Cache = require("tests.game3_cache")
Cache.mount("scripts/events.lua", { native = true })
-- 1. Starter Species Mapping
check(Roamer.speciesForStarter(0) == 244, "Bulbasaur (0) -> Entei (244)")
check(Roamer.speciesForStarter(1) == 243, "Squirtle (1) -> Raikou (243)")
check(Roamer.speciesForStarter(2) == 245, "Charmander (2) -> Suicune (245)")
-- 2. Fixed IV Generation (Modern 0-31 IVs, not truncated to 0 or max 7)
local ivCheckOk = true
for _ = 1, 50 do
local mon = Roamer.generateMon(243, 50)
if type(mon.ivs) ~= "table" then
ivCheckOk = false
break
end
for stat, val in pairs(mon.ivs) do
if val < 0 or val > 31 then
ivCheckOk = false
break
end
end
end
check(ivCheckOk, "Roamer generates full 0-31 IVs across all 6 stats without truncation")
-- 3. Audio Override
check(Audio.legendaryBattleSong(243) == 339, "Raikou uses Legendary Battle BGM (339)")
check(Audio.legendaryBattleSong(244) == 339, "Entei uses Legendary Battle BGM (339)")
check(Audio.legendaryBattleSong(245) == 339, "Suicune uses Legendary Battle BGM (339)")
-- 4. Initialization via InitRoamer
local session = {
flags = {},
vars = {
[0x4031] = 2, -- VAR_STARTER_MON = Charmander
},
party = {
{ species = 6, level = 50, hp = 0, maxHp = 100 }, -- fainted Charizard lead
{ species = 150, level = 100, hp = 300, maxHp = 300 }, -- conscious Mewtwo
},
}
local okInit = Roamer.init(session, 2)
check(okInit == true, "Roamer.init succeeds")
check(session.roamer ~= nil and session.roamer.active == true, "session.roamer initialized and active")
check(session.roamer.species == 245, "Roamer species is Suicune (245)")
check(session.roamer.level == 50, "Roamer level is 50")
check(session.roamer.map ~= nil and #session.roamer.map > 0, "Roamer assigned initial Kanto route: " .. tostring(session.roamer.map))
-- 5. Adjacency Movement vs Warp Jump
local currentMap = session.roamer.map
local adjList = Roamer.ADJACENCY[currentMap]
check(adjList and #adjList > 0, "Current roamer route has valid adjacency list")
-- Step movement
Roamer.move(session, "map_transition")
local newMap = session.roamer.map
local isAdjOrSame = (newMap == currentMap)
if not isAdjOrSame and adjList then
for _, m in ipairs(adjList) do
if m == newMap then isAdjOrSame = true break end
end
end
check(isAdjOrSame, "Roamer.move('map_transition') only moves to adjacent route or stays")
-- Warp jump (Fly / Teleport / Whiteout)
local jumped = false
for _ = 1, 20 do
Roamer.move(session, "warp_random")
if session.roamer.map ~= newMap then
jumped = true
break
end
end
check(jumped, "Roamer.move('warp_random') randomizes route")
-- 6. Encounter Rate Gating & Fainted Lead Repel Trick
session.roamer.map = "FR_ROUTE2"
session.vars[0x4020] = 250 -- VAR_REPEL_STEP_COUNT = 250 (Repel is active)
-- Scenario A: Lead slot 1 is Level 50 (fainted), slot 2 is Level 100
-- Repel checks Slot 1 (Lv 50 <= Roamer Lv 50) -> Encounter MUST pass!
local encA = Roamer.tryEncounter(session, "FR_ROUTE2", "grass")
check(encA ~= nil and encA.roamer == true and encA.species == 245, "Fainted Slot 1 Lv 50 passes Repel gate")
-- Scenario B: Lead slot 1 is Level 51 (fainted)
-- Repel checks Slot 1 (Lv 51 > Roamer Lv 50) -> Repelled!
session.party[1].level = 51
local encB = Roamer.tryEncounter(session, "FR_ROUTE2", "grass")
check(encB == nil, "Lead Slot 1 Lv 51 triggers Repel and repels roamer")
-- Reset lead level to 50
session.party[1].level = 50
-- Scenario C: Wrong map
local encC = Roamer.tryEncounter(session, "FR_ROUTE3", "grass")
check(encC == nil, "Different map returns no encounter")
-- 7. Battle End Handling & Roar Despawn Prevention
local foeState = {
hp = 60,
maxHp = 150,
status = "paralysis",
statusNum = 0x40,
}
-- Flee / Roar end
local prevLocation = session.roamer.map
Roamer.onBattleEnd(session, foeState, "fled", "roar")
check(session.roamer.active == true, "Roar / Flee keeps roamer ACTIVE (Roar despawn bug fixed)")
check(session.roamer.hp == 60, "Roamer HP damage persisted (60)")
check(session.roamer.status == "paralysis", "Roamer status condition persisted (paralysis)")
-- Defeated
foeState.hp = 0
Roamer.onBattleEnd(session, foeState, "win", "faint")
check(session.roamer.active == false, "Defeating roamer sets active to false")
-- 8. Save/Load Schema Persistence
session.roamer = {
active = true,
species = 243,
level = 50,
hp = 85,
maxHp = 160,
status = 0,
statusNum = 0,
pid = 0x12345678,
ivs = { hp = 31, atk = 30, def = 29, spe = 28, spa = 27, spd = 26 },
map = "FR_ROUTE1",
}
local saved = Schema.toSaveTable(session)
check(saved.roamer ~= nil and saved.roamer.species == 243, "Save schema exports roamer")
local loadedSession = Schema.fromSaveTable(saved)
check(loadedSession.roamer ~= nil, "Save schema restores roamer")
check(loadedSession.roamer.species == 243, "Restored roamer has correct species")
check(loadedSession.roamer.hp == 85, "Restored roamer has correct HP")
check(loadedSession.roamer.ivs.hp == 31, "Restored roamer has correct IVs")
-- 9. Turn Order Against Roaming Flee (Speed / Priority checks matching pokefirered)
local Engine = require("src.core.game3.battle.engine")
local Adapter = require("src.core.game3.battle.adapter")
local pMon = { species = 150, level = 50, speed = 200, moves = { 33 }, pp = { 30 }, maxHp = 200, hp = 200 }
local eMon = { species = 244, level = 50, speed = 100, moves = { 52 }, pp = { 25 }, maxHp = 175, hp = 175, roamer = true }
local battleSt = {
player = { mon = pMon, side = "player", stages = {} },
enemy = { mon = eMon, side = "enemy", stages = {} },
wild = true,
roamer = true,
}
local battleAd = Adapter.new(battleSt, function() end)
local pAct = { kind = "move", move = 33, slot = 1, user = battleSt.player, battler = 0 }
local eAct = { kind = "run", user = battleSt.enemy, battler = 1 }
-- Faster player attacks before roamer flees
local actFaster = Engine.planTurnFromActions(battleSt, battleAd, pAct, eAct)
check(actFaster[1].user.side == "player", "Faster player attacks before roamer flees")
check(actFaster[2].user.side == "enemy" and actFaster[2].kind == "run", "Roamer flees second on turn")
-- Slower player
pMon.speed = 50
local actSlower = Engine.planTurnFromActions(battleSt, battleAd, pAct, eAct)
check(actSlower[1].user.side == "enemy" and actSlower[1].kind == "run", "Faster roamer flees before slower player")
-- Priority move on slower player
pMon.moves[1] = 98 -- Quick Attack (+1)
pAct.move = 98
local actPri = Engine.planTurnFromActions(battleSt, battleAd, pAct, eAct)
check(actPri[1].user.side == "player", "Priority move on slower player attacks before roamer flees")
-- Bag item (Ball)
local bAct = { kind = "bag", itemId = 1, user = battleSt.player, battler = 0 }
local _, bagMeta = Engine.planTurnFromActions(battleSt, battleAd, bAct, eAct)
check(bagMeta and bagMeta.kind == "bag", "Bag action executes before roamer flees")
-- 10. Trapping Mechanics vs Roaming Flee
local Ai = require("src.core.game3.battle.ai")
-- Untrapped roamer chooses run
battleSt.enemy.escapePrevention = nil
battleSt.enemy.expTrapped = nil
battleSt.enemy.expTrapTurns = nil
local untrappedAct = Ai.chooseMove(battleSt, { battler = 1 })
check(untrappedAct and untrappedAct.kind == "run", "Untrapped roamer chooses flee")
-- Mean Look / Escape Prevention
battleSt.enemy.escapePrevention = true
battleSt.enemy.expTrapped = true
local trappedAct = Ai.chooseMove(battleSt, { battler = 1 })
check(trappedAct and trappedAct.kind == "move", "Mean Look trapped roamer cannot flee and chooses attack move")
check(Engine.canSwitch(battleSt, battleAd, battleSt.enemy) == false, "Engine.canSwitch returns false for trapped roamer")
-- Shadow Tag
battleSt.enemy.escapePrevention = nil
battleSt.enemy.expTrapped = nil
battleSt.player.ability = "SHADOW_TAG"
pMon.ability = "SHADOW_TAG"
local shadowTagAct = Ai.chooseMove(battleSt, { battler = 1 })
check(shadowTagAct and shadowTagAct.kind == "move", "Shadow Tag prevents roamer from fleeing and forces attack")
-- Arena Trap (Entei is grounded -> trapped)
battleSt.player.ability = "ARENA_TRAP"
pMon.ability = "ARENA_TRAP"
local arenaTrapAct = Ai.chooseMove(battleSt, { battler = 1 })
check(arenaTrapAct and arenaTrapAct.kind == "move", "Arena Trap traps grounded beast and forces attack")
-- Partial trapping (Wrap / Fire Spin)
battleSt.player.ability = "PRESSURE"
pMon.ability = "PRESSURE"
battleSt.enemy.expTrapTurns = 4
battleSt.enemy.wrapped = true
local wrapAct = Ai.chooseMove(battleSt, { battler = 1 })
check(wrapAct and wrapAct.kind == "move", "Partial trap (Fire Spin / Wrap) prevents roamer flee")
-- Switch-out releases trap
battleSt.enemy.expTrapTurns = nil
battleSt.enemy.wrapped = nil
battleSt.enemy.expTrapped = true
battleSt.enemy.expTrappedBy = battleSt.player
battleSt.playerParty = { pMon, { species = 25, level = 50, hp = 100, maxHp = 100 } }
Engine.performSwitch(battleSt, battleAd, "player", 2)
Engine.refreshLinks(battleSt)
check(battleSt.enemy.expTrapped == nil and battleSt.enemy.expTrappedBy == nil, "Switching out player clears enemy trap")
done()
+50
View File
@@ -0,0 +1,50 @@
-- Tests for Seagallop ferry logic, direction matrix, and animation tick simulation
local Seagallop = require("src.ui.game3.seagallop")
local NativeSeagallop = require("src.core.game3.scripting.natives_seagallop")
local tests = {}
function tests.test_direction_of_travel()
-- From Vermilion (0): all Sevii islands are Eastbound (1)
assert(Seagallop.directionOfTravel(0, 1) == 1, "Vermilion -> One Island should be Eastbound")
assert(Seagallop.directionOfTravel(0, 2) == 1, "Vermilion -> Two Island should be Eastbound")
assert(Seagallop.directionOfTravel(0, 3) == 1, "Vermilion -> Three Island should be Eastbound")
assert(Seagallop.directionOfTravel(0, 7) == 1, "Vermilion -> Seven Island should be Eastbound")
-- From One Island (1): to Vermilion (0) is Westbound (0)
assert(Seagallop.directionOfTravel(1, 0) == 0, "One Island -> Vermilion should be Westbound")
-- From Birth Island (10): 0x000 -> all destinations Westbound (0)
assert(Seagallop.directionOfTravel(10, 0) == 0, "Birth Island -> Vermilion should be Westbound")
end
function tests.test_seagallop_numbers()
assert(NativeSeagallop.seagallopNumber(8, 0) == 1, "Cinnabar should be Seagallop 1")
assert(NativeSeagallop.seagallopNumber(1, 2) == 2, "One -> Two Island should be Seagallop 2")
assert(NativeSeagallop.seagallopNumber(4, 5) == 3, "Four -> Five Island should be Seagallop 3")
assert(NativeSeagallop.seagallopNumber(6, 7) == 5, "Six -> Seven Island should be Seagallop 5")
assert(NativeSeagallop.seagallopNumber(0, 1) == 7, "Vermilion -> Island should be Seagallop 7")
assert(NativeSeagallop.seagallopNumber(9, 1) == 10, "Navel Rock -> Island should be Seagallop 10")
assert(NativeSeagallop.seagallopNumber(10, 1) == 12, "Birth Island -> Island should be Seagallop 12")
end
function tests.test_simulation_ticks()
local warped = false
local done = false
Seagallop.start(0, 1, function() warped = true end, function() done = true end)
assert(Seagallop.isActive(), "Seagallop cutscene should be active")
-- Update through 10 frames (10/60 seconds)
Seagallop.update(10 / 60)
local run = Seagallop._run
assert(run.tick == 10, "Should have simulated exactly 10 ticks (got " .. tostring(run.tick) .. ")")
assert(run.ferryX == 30, "Eastbound ferry should move +3 px per tick (at x=30)")
assert(run.bgX == 60, "Eastbound BG should scroll +6 px per tick (at bgX=60)")
assert(#run.wakes == 2, "Should have spawned 2 wakes at tick 5 and 10 (got " .. tostring(#run.wakes) .. ")")
Seagallop.stop()
assert(not Seagallop.isActive(), "Seagallop should be inactive after stop")
end
return tests
+2 -2
View File
@@ -125,8 +125,8 @@ else
check(Collision.arrivalFacing(beh, "left") == want,
string.format("landing on 0x%02X faces %s", beh, want))
end
check(Collision.arrivalFacing(MB.LADDER, "left") == "left",
"a ladder keeps the direction the player left with")
check(Collision.arrivalFacing(MB.LADDER, "left") == "down",
"a ladder lands facing south")
check(Collision.arrivalFacing(0x00, "left") == "down",
"hasDirectionSet is cleared, so a plain cell faces south")
check(Collision.arrivalFacing(MB.FALL, "up") == "down",
+63
View File
@@ -0,0 +1,63 @@
-- Tests for Weather constants, lifecycle, and FieldWeather atmospheric states
local Weather = require("src.core.game3.weather")
local FieldWeather = require("src.core.game3.field_weather")
local tests = {}
function tests.test_weather_constants()
assert(Weather.NONE == 0, "WEATHER_NONE should be 0")
assert(Weather.SUNNY == 2, "WEATHER_SUNNY should be 2")
assert(Weather.RAIN == 3, "WEATHER_RAIN should be 3")
assert(Weather.FOG_HORIZONTAL == 6, "WEATHER_FOG_HORIZONTAL should be 6")
assert(Weather.SHADE == 11, "WEATHER_SHADE should be 11")
assert(Weather.FOG == 6, "Weather.FOG alias should be 6")
end
function tests.test_weather_lifecycle()
Weather.reset()
assert(Weather.get() == Weather.NONE, "Weather should be NONE after reset")
assert(not Weather.isActive(), "Weather should be inactive")
-- Script opcode sequence: setweather -> doweather
Weather.set(Weather.FOG_HORIZONTAL)
assert(Weather.get() == Weather.NONE, "Weather should remain NONE until doweather is called")
Weather.doWeather()
assert(Weather.get() == Weather.FOG_HORIZONTAL, "Weather should be FOG_HORIZONTAL after doweather")
assert(Weather.isActive(), "Weather should be active")
assert(FieldWeather.getWeather() == Weather.FOG_HORIZONTAL, "FieldWeather should be synchronized")
-- Test suspension (e.g. entering battle/menu)
assert(not Weather.isSuspended(), "Weather should not be suspended initially")
Weather.suspend()
assert(Weather.isSuspended(), "Weather should be suspended during battle")
Weather.resume()
assert(not Weather.isSuspended(), "Weather should resume after battle")
-- Test Shade
Weather.apply(Weather.SHADE)
assert(Weather.get() == Weather.SHADE, "Weather should be SHADE")
assert(FieldWeather.getWeather() == Weather.SHADE, "FieldWeather should be SHADE")
-- Reset back to NONE
Weather.reset()
assert(Weather.get() == Weather.NONE, "Weather should be reset to NONE")
end
function tests.test_fog_horizontal_scroll()
FieldWeather.setWeather(Weather.FOG_HORIZONTAL)
assert(FieldWeather._fogScrollOffset == 0, "Initial scroll offset should be 0")
-- 4 ticks per pixel of scroll
for i = 1, 4 do
FieldWeather.update(1 / 60)
end
assert(FieldWeather._fogScrollOffset == 1, "Scroll offset should advance by 1 after 4 ticks")
for i = 1, 4 do
FieldWeather.update(1 / 60)
end
assert(FieldWeather._fogScrollOffset == 2, "Scroll offset should advance by 2 after 8 ticks")
end
return tests