From 8621d024dcab965614b7f391b547e9366013ffba Mon Sep 17 00:00:00 2001 From: 1jamie Date: Wed, 23 Sep 2026 13:46:57 -0500 Subject: [PATCH 1/6] feat(game3): implement roaming legendary beasts, Seagallop ferry, field weather, and overworld fixes - Implement roaming legendary beast system (Raikou, Entei, Suicune) matching pokefirered (src/roamer.c), including 31-cap IV fix, Roar despawn fix, battle flee logic, and trapping interactions (Mean Look, Shadow Tag, Arena Trap, Wrap). - Implement Seagallop ferry crossing cutscene and transit between Sevii Islands and Kanto. - Implement field weather engine, weather extraction, and particle rendering. - Implement renewable hidden items and step events. - Fix poison step sound effect (SE_FIELD_POISON). - Fix encounter cooldown lookup for grass/cave terrain. - Add comprehensive test suites for roamers, weather, Seagallop animations, and ladders. --- src/core/Game3.lua | 11 - src/core/game3/audio.lua | 3 + src/core/game3/battle/ai_cmds.lua | 15 +- src/core/game3/battle/engine.lua | 78 +++-- src/core/game3/battle/init.lua | 65 ++++ src/core/game3/battle/moves.lua | 5 +- src/core/game3/battle_bridge.lua | 8 + src/core/game3/collision.lua | 2 +- src/core/game3/dataset.lua | 6 +- src/core/game3/encounters.lua | 22 +- src/core/game3/field_view.lua | 12 + src/core/game3/field_weather.lua | 195 +++++++++++ src/core/game3/layout_native.lua | 29 +- src/core/game3/map.lua | 24 ++ src/core/game3/player.lua | 4 - src/core/game3/renewable_hidden_items.lua | 319 +++++++++++++++++ src/core/game3/roamer.lua | 321 ++++++++++++++++++ src/core/game3/runtime.lua | 14 + src/core/game3/save_schema_firered.lua | 19 ++ src/core/game3/scripting/natives_events.lua | 11 +- .../game3/scripting/natives_seagallop.lua | 19 +- src/core/game3/step_events.lua | 8 +- src/core/game3/weather.lua | 60 +++- src/import/RomExtractorGen3.lua | 1 + src/import/gba/weather_extract.lua | 142 ++++++++ src/ui/game3/seagallop.lua | 303 +++++++++++++++++ src/ui/game3/ui_pass.lua | 5 + tests/game3_import_hardening_test.lua | 4 +- tests/game3_ladder_warp_test.lua | 99 ++++++ tests/game3_renewable_hidden_items_test.lua | 69 ++++ tests/game3_roamer_test.lua | 254 ++++++++++++++ tests/game3_seagallop_anim_test.lua | 50 +++ tests/game3_warp_behaviors_test.lua | 4 +- tests/game3_weather_test.lua | 63 ++++ 34 files changed, 2165 insertions(+), 79 deletions(-) create mode 100644 src/core/game3/field_weather.lua create mode 100644 src/core/game3/renewable_hidden_items.lua create mode 100644 src/core/game3/roamer.lua create mode 100644 src/import/gba/weather_extract.lua create mode 100644 src/ui/game3/seagallop.lua create mode 100644 tests/game3_ladder_warp_test.lua create mode 100644 tests/game3_renewable_hidden_items_test.lua create mode 100644 tests/game3_roamer_test.lua create mode 100644 tests/game3_seagallop_anim_test.lua create mode 100644 tests/game3_weather_test.lua diff --git a/src/core/Game3.lua b/src/core/Game3.lua index 93d0e7a7..29bd4773 100644 --- a/src/core/Game3.lua +++ b/src/core/Game3.lua @@ -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() diff --git a/src/core/game3/audio.lua b/src/core/game3/audio.lua index b8cb9a53..f5258842 100644 --- a/src/core/game3/audio.lua +++ b/src/core/game3/audio.lua @@ -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 } diff --git a/src/core/game3/battle/ai_cmds.lua b/src/core/game3/battle/ai_cmds.lua index 9576f62f..78cff03d 100644 --- a/src/core/game3/battle/ai_cmds.lua +++ b/src/core/game3/battle/ai_cmds.lua @@ -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) diff --git a/src/core/game3/battle/engine.lua b/src/core/game3/battle/engine.lua index 4178bb2b..3cb64397 100644 --- a/src/core/game3/battle/engine.lua +++ b/src/core/game3/battle/engine.lua @@ -2439,31 +2439,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 @@ -2476,8 +2460,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) @@ -2503,22 +2510,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 diff --git a/src/core/game3/battle/init.lua b/src/core/game3/battle/init.lua index 409643df..8ab27585 100644 --- a/src/core/game3/battle/init.lua +++ b/src/core/game3/battle/init.lua @@ -1516,6 +1516,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 @@ -1740,6 +1802,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 diff --git a/src/core/game3/battle/moves.lua b/src/core/game3/battle/moves.lua index 84480398..6622d944 100644 --- a/src/core/game3/battle/moves.lua +++ b/src/core/game3/battle/moves.lua @@ -163,8 +163,9 @@ function Moves.displayName(moveId) end 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) + return (ok and m and tonumber(m.priority)) or 0 end return Moves diff --git a/src/core/game3/battle_bridge.lua b/src/core/game3/battle_bridge.lua index b1df90e0..5dc38285 100644 --- a/src/core/game3/battle_bridge.lua +++ b/src/core/game3/battle_bridge.lua @@ -303,6 +303,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"] diff --git a/src/core/game3/collision.lua b/src/core/game3/collision.lua index 10749acd..ac9910da 100644 --- a/src/core/game3/collision.lua +++ b/src/core/game3/collision.lua @@ -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 diff --git a/src/core/game3/dataset.lua b/src/core/game3/dataset.lua index 72983a73..cecc8f73 100644 --- a/src/core/game3/dataset.lua +++ b/src/core/game3/dataset.lua @@ -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 diff --git a/src/core/game3/encounters.lua b/src/core/game3/encounters.lua index 8274180c..636d876b 100644 --- a/src/core/game3/encounters.lua +++ b/src/core/game3/encounters.lua @@ -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 diff --git a/src/core/game3/field_view.lua b/src/core/game3/field_view.lua index 57b0bc05..babc307e 100644 --- a/src/core/game3/field_view.lua +++ b/src/core/game3/field_view.lua @@ -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) diff --git a/src/core/game3/field_weather.lua b/src/core/game3/field_weather.lua new file mode 100644 index 00000000..a4ac4e47 --- /dev/null +++ b/src/core/game3/field_weather.lua @@ -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 diff --git a/src/core/game3/layout_native.lua b/src/core/game3/layout_native.lua index 6a18c2b9..b8f5c104 100644 --- a/src/core/game3/layout_native.lua +++ b/src/core/game3/layout_native.lua @@ -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 diff --git a/src/core/game3/map.lua b/src/core/game3/map.lua index f163d906..6998d392 100644 --- a/src/core/game3/map.lua +++ b/src/core/game3/map.lua @@ -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) diff --git a/src/core/game3/player.lua b/src/core/game3/player.lua index 0ef57280..1c126b30 100644 --- a/src/core/game3/player.lua +++ b/src/core/game3/player.lua @@ -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 diff --git a/src/core/game3/renewable_hidden_items.lua b/src/core/game3/renewable_hidden_items.lua new file mode 100644 index 00000000..b0b5568d --- /dev/null +++ b/src/core/game3/renewable_hidden_items.lua @@ -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 diff --git a/src/core/game3/roamer.lua b/src/core/game3/roamer.lua new file mode 100644 index 00000000..5d496e7d --- /dev/null +++ b/src/core/game3/roamer.lua @@ -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 diff --git a/src/core/game3/runtime.lua b/src/core/game3/runtime.lua index e894a981..641e9005 100644 --- a/src/core/game3/runtime.lua +++ b/src/core/game3/runtime.lua @@ -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 diff --git a/src/core/game3/save_schema_firered.lua b/src/core/game3/save_schema_firered.lua index 4a3551ba..fa4509d1 100644 --- a/src/core/game3/save_schema_firered.lua +++ b/src/core/game3/save_schema_firered.lua @@ -162,6 +162,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") @@ -245,6 +246,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 {}, @@ -350,6 +352,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. @@ -374,6 +377,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 @@ -382,6 +386,21 @@ function Schema.fromSaveTable(save) return session end +function Schema.repairRoamer(session) + if not session or session.roamer then return end + local Flags = require("src.core.game3.scripting.flags") + local FLAG_SYS_CAN_LINK_WITH_RS = 0x844 + local VAR_MAP_SCENE_ONE_ISLAND_POKEMON_CENTER_1F = 0x4076 + local VAR_STARTER_MON = 0x4031 + local hasLink = Flags.get(session, FLAG_SYS_CAN_LINK_WITH_RS) + local sceneVal = tonumber(session.vars and session.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 = (session.vars and session.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 diff --git a/src/core/game3/scripting/natives_events.lua b/src/core/game3/scripting/natives_events.lua index 1f1a71ea..8f648476 100644 --- a/src/core/game3/scripting/natives_events.lua +++ b/src/core/game3/scripting/natives_events.lua @@ -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) diff --git a/src/core/game3/scripting/natives_seagallop.lua b/src/core/game3/scripting/natives_seagallop.lua index 6e0b1418..ec96ade0 100644 --- a/src/core/game3/scripting/natives_seagallop.lua +++ b/src/core/game3/scripting/natives_seagallop.lua @@ -144,9 +144,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) + 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] return function() if phase == "cross" then frames = frames + 1 diff --git a/src/core/game3/step_events.lua b/src/core/game3/step_events.lua index fc66e8cd..3ace1a8e 100644 --- a/src/core/game3/step_events.lua +++ b/src/core/game3/step_events.lua @@ -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 diff --git a/src/core/game3/weather.lua b/src/core/game3/weather.lua index 1d78d81b..236390c7 100644 --- a/src/core/game3/weather.lua +++ b/src/core/game3/weather.lua @@ -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 diff --git a/src/import/RomExtractorGen3.lua b/src/import/RomExtractorGen3.lua index 5370e11f..732451ef 100644 --- a/src/import/RomExtractorGen3.lua +++ b/src/import/RomExtractorGen3.lua @@ -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") diff --git a/src/import/gba/weather_extract.lua b/src/import/gba/weather_extract.lua new file mode 100644 index 00000000..dadcffbd --- /dev/null +++ b/src/import/gba/weather_extract.lua @@ -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 diff --git a/src/ui/game3/seagallop.lua b/src/ui/game3/seagallop.lua new file mode 100644 index 00000000..15f02d2f --- /dev/null +++ b/src/ui/game3/seagallop.lua @@ -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 diff --git a/src/ui/game3/ui_pass.lua b/src/ui/game3/ui_pass.lua index fec0bf66..2ee7c2f2 100644 --- a/src/ui/game3/ui_pass.lua +++ b/src/ui/game3/ui_pass.lua @@ -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") diff --git a/tests/game3_import_hardening_test.lua b/tests/game3_import_hardening_test.lua index f7a84cd5..f0bde717 100644 --- a/tests/game3_import_hardening_test.lua +++ b/tests/game3_import_hardening_test.lua @@ -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 diff --git a/tests/game3_ladder_warp_test.lua b/tests/game3_ladder_warp_test.lua new file mode 100644 index 00000000..a78a56db --- /dev/null +++ b/tests/game3_ladder_warp_test.lua @@ -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() diff --git a/tests/game3_renewable_hidden_items_test.lua b/tests/game3_renewable_hidden_items_test.lua new file mode 100644 index 00000000..2055f228 --- /dev/null +++ b/tests/game3_renewable_hidden_items_test.lua @@ -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 diff --git a/tests/game3_roamer_test.lua b/tests/game3_roamer_test.lua new file mode 100644 index 00000000..76ec2cee --- /dev/null +++ b/tests/game3_roamer_test.lua @@ -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() diff --git a/tests/game3_seagallop_anim_test.lua b/tests/game3_seagallop_anim_test.lua new file mode 100644 index 00000000..63309e60 --- /dev/null +++ b/tests/game3_seagallop_anim_test.lua @@ -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 diff --git a/tests/game3_warp_behaviors_test.lua b/tests/game3_warp_behaviors_test.lua index 791c0b75..33b123b9 100644 --- a/tests/game3_warp_behaviors_test.lua +++ b/tests/game3_warp_behaviors_test.lua @@ -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", diff --git a/tests/game3_weather_test.lua b/tests/game3_weather_test.lua new file mode 100644 index 00000000..a71eb275 --- /dev/null +++ b/tests/game3_weather_test.lua @@ -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 From bb3a1cf3a62773a38892172901ec6e867177ac1b Mon Sep 17 00:00:00 2001 From: 1jamie Date: Wed, 23 Sep 2026 13:52:24 -0500 Subject: [PATCH 2/6] fix(game3): fix Flags.get lookup in save repairRoamer and add Moves.priority fallbacks --- src/core/game3/battle/moves.lua | 26 +++++++++++++++++++++++++- src/core/game3/save_schema_firered.lua | 9 +++++---- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/core/game3/battle/moves.lua b/src/core/game3/battle/moves.lua index 6622d944..b1be4e8d 100644 --- a/src/core/game3/battle/moves.lua +++ b/src/core/game3/battle/moves.lua @@ -162,10 +162,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) if not moveId or moveId == 0 or moveId == "" then return 0 end local ok, m = pcall(Moves.get, moveId) - return (ok and m and tonumber(m.priority)) or 0 + 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 diff --git a/src/core/game3/save_schema_firered.lua b/src/core/game3/save_schema_firered.lua index fa4509d1..163170b5 100644 --- a/src/core/game3/save_schema_firered.lua +++ b/src/core/game3/save_schema_firered.lua @@ -388,15 +388,16 @@ end function Schema.repairRoamer(session) if not session or session.roamer then return end - local Flags = require("src.core.game3.scripting.flags") local FLAG_SYS_CAN_LINK_WITH_RS = 0x844 local VAR_MAP_SCENE_ONE_ISLAND_POKEMON_CENTER_1F = 0x4076 local VAR_STARTER_MON = 0x4031 - local hasLink = Flags.get(session, FLAG_SYS_CAN_LINK_WITH_RS) - local sceneVal = tonumber(session.vars and session.vars[VAR_MAP_SCENE_ONE_ISLAND_POKEMON_CENTER_1F]) or 0 + 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 = (session.vars and session.vars[VAR_STARTER_MON]) or 0 + local starter = tonumber(vars[VAR_STARTER_MON] or vars["VAR_STARTER_MON"]) or 0 Roamer.init(session, starter) end end From 703e103e3ca967a48b92572b13ef007c7ad396c3 Mon Sep 17 00:00:00 2001 From: 1jamie Date: Wed, 23 Sep 2026 13:55:54 -0500 Subject: [PATCH 3/6] fix(game3): make battle AI pack loading graceful when running outside full ROM cache --- src/core/game3/battle/ai.lua | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/core/game3/battle/ai.lua b/src/core/game3/battle/ai.lua index 006a0057..580779fc 100644 --- a/src/core/game3/battle/ai.lua +++ b/src/core/game3/battle/ai.lua @@ -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") @@ -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.if_can_escape then + canEscape = Cmds.if_can_escape(st, b, target, userSide, targetSide, ad) + 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") From d74c43a71bb481d41463e27c699ea78c2f9299bf Mon Sep 17 00:00:00 2001 From: 1jamie Date: Wed, 23 Sep 2026 13:59:20 -0500 Subject: [PATCH 4/6] fix(game3): make Engine.canSwitch and State.prefixedName resilient to unpopulated RomText cache --- src/core/game3/battle/engine.lua | 9 +++++++-- src/core/game3/battle/state.lua | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/core/game3/battle/engine.lua b/src/core/game3/battle/engine.lua index 3cb64397..eb64fa8f 100644 --- a/src/core/game3/battle/engine.lua +++ b/src/core/game3/battle/engine.lua @@ -2079,14 +2079,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 diff --git a/src/core/game3/battle/state.lua b/src/core/game3/battle/state.lua index cbdd8ddb..d8da8396 100644 --- a/src/core/game3/battle/state.lua +++ b/src/core/game3/battle/state.lua @@ -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) From ce79dc86d5f3c0eda3b76049e8d4797603951936 Mon Sep 17 00:00:00 2001 From: 1jamie Date: Wed, 23 Sep 2026 14:04:03 -0500 Subject: [PATCH 5/6] fix(game3): implement if_can_escape and if_cant_escape AI script commands --- src/core/game3/battle/ai.lua | 4 +-- src/core/game3/battle/ai_cmds.lua | 42 +++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/core/game3/battle/ai.lua b/src/core/game3/battle/ai.lua index 580779fc..903d4436 100644 --- a/src/core/game3/battle/ai.lua +++ b/src/core/game3/battle/ai.lua @@ -281,8 +281,8 @@ function choose_move_core(st, id, opts) -- 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.if_can_escape then - canEscape = Cmds.if_can_escape(st, b, target, userSide, targetSide, ad) + if okCmd and Cmds and Cmds.canEscape then + canEscape = Cmds.canEscape(b, target) end if canEscape then aiAction = 0x2 diff --git a/src/core/game3/battle/ai_cmds.lua b/src/core/game3/battle/ai_cmds.lua index 78cff03d..e7100f95 100644 --- a/src/core/game3/battle/ai_cmds.lua +++ b/src/core/game3/battle/ai_cmds.lua @@ -941,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 From a366339873345fbf382945e22b77f83d856f3445 Mon Sep 17 00:00:00 2001 From: 1jamie Date: Wed, 23 Sep 2026 14:12:44 -0500 Subject: [PATCH 6/6] fix(game3/battle): guard from_rom and move_target_byte against nil ROM pack in headless tests --- src/core/game3/battle/ai.lua | 4 ++-- src/core/game3/battle/moves.lua | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/core/game3/battle/ai.lua b/src/core/game3/battle/ai.lua index 903d4436..7ca77115 100644 --- a/src/core/game3/battle/ai.lua +++ b/src/core/game3/battle/ai.lua @@ -124,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) diff --git a/src/core/game3/battle/moves.lua b/src/core/game3/battle/moves.lua index b1be4e8d..73b15c7c 100644 --- a/src/core/game3/battle/moves.lua +++ b/src/core/game3/battle/moves.lua @@ -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"