From 1cf9c5109c4011a4c87ad0b67a067d63684b1b84 Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Mon, 21 Sep 2026 18:43:47 +0100 Subject: [PATCH 1/5] Implement DoDeoxysTriangleInteraction (Birth Island Deoxys event) Ports pokefirered's Birth Island Deoxys puzzle and its battle in full. Puzzle (src/core/game3/deoxys.lua), from src/field_specials.c:2319-2456: - Task_DoDeoxysTriangleInteraction with the 11 rock positions, the 10 per-step caps and the pre-reset step-counter read that pret uses. - IncrementBirthIslandRockStepCount wired into StepEvents.onStepTaken. - MoveDeoxysObject / SetDeoxysTrianglePalette palette swaps, realised via a new OwSprites.setObjectPalette colour substitution because palettes are baked to RGBA at extract time. - FLDEFF_MOVE_DEOXYS_ROCK (67) and FLDEFF_DESTROY_DEOXYS_ROCK (68), plus setfieldeffectargument plumbing and the rock-move lerp / rock-shatter field effects. - DoDeoxysTriangleInteraction = 0x1AB added to Std.SPECIAL, with real handlers for it and SetDeoxysTrianglePalette. Battle: seteventmon SPECIES_DEOXYS, 30 expands to CreateEnemyEventMon, which the engine already implements, so the encounter flows through Encounters._pendingWild into StartLegendaryBattle. No new code needed there; tests now pin the whole chain. Fixes the Deoxys battle theme. Both battle-BGM sites gated on species 386, but FRLG's SPECIES_DEOXYS is 410 (species.h:419) and 386 is SPECIES_VOLBEAT (species.h:395), so Deoxys silently fell through to the generic wild theme. Extracted Audio.legendaryBattleSong as the single source of truth, keyed by the FRLG internal species id, matching the switch in src/battle_setup.c:349. Verification: 24/24 Deoxys groups, game3_special_events_test, engine tier 593/593, luacheck 0/0 in 970 files, plus the hand-run game3 battle/field-effect/object/step suites. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/core/game3/audio.lua | 20 + src/core/game3/battle/init.lua | 11 +- src/core/game3/battle_bridge.lua | 10 +- src/core/game3/deoxys.lua | 272 +++++++++++ src/core/game3/field_effects.lua | 235 +++++++++- src/core/game3/ow_sprites.lua | 107 ++++- src/core/game3/scripting/adapters.lua | 6 + src/core/game3/scripting/natives_events.lua | 19 +- src/core/game3/scripting/ops_a.lua | 8 + src/core/game3/scripting/stdscripts.lua | 1 + src/core/game3/step_events.lua | 3 + tests/game3_deoxys_test.lua | 489 ++++++++++++++++++++ tests/game3_special_events_test.lua | 3 +- 13 files changed, 1163 insertions(+), 21 deletions(-) create mode 100644 src/core/game3/deoxys.lua create mode 100644 tests/game3_deoxys_test.lua diff --git a/src/core/game3/audio.lua b/src/core/game3/audio.lua index acf00c16..58fca8ab 100644 --- a/src/core/game3/audio.lua +++ b/src/core/game3/audio.lua @@ -187,6 +187,26 @@ function Audio.role(name) return nil end +-- pokefirered/src/battle_setup.c:349 StartLegendaryBattle switches on the FRLG +-- internal species id, not the National Dex number. SPECIES_DEOXYS is 410 +-- (include/constants/species.h:419); 386 is SPECIES_VOLBEAT and must not match. +Audio.LEGENDARY_BATTLE_SONGS = { + [150] = { "battleMewtwo", 340 }, -- SPECIES_MEWTWO + [410] = { "battleDeoxys", 339 }, -- SPECIES_DEOXYS + [144] = { "battleLegend", 341 }, -- SPECIES_ARTICUNO + [145] = { "battleLegend", 341 }, -- SPECIES_ZAPDOS + [146] = { "battleLegend", 341 }, -- SPECIES_MOLTRES + [249] = { "battleLegend", 341 }, -- SPECIES_LUGIA + [250] = { "battleLegend", 341 }, -- SPECIES_HO_OH +} + +-- Returns the legendary battle theme for a species, or nil for any other mon. +function Audio.legendaryBattleSong(species) + local entry = Audio.LEGENDARY_BATTLE_SONGS[tonumber(species) or -1] + if not entry then return nil end + return Audio.role(entry[1]) or entry[2] +end + function Audio.applyOptions(session) local Options = require("src.core.game3.options") local o = Options.ensure(session) diff --git a/src/core/game3/battle/init.lua b/src/core/game3/battle/init.lua index c4afbdfc..37d73767 100644 --- a/src/core/game3/battle/init.lua +++ b/src/core/game3/battle/init.lua @@ -589,15 +589,8 @@ function Battle.start(opts) if not song then if st.wild then local foeSpecies = foeMon and (foeMon.species or foeMon.speciesId or foeMon.id) - if foeSpecies == 150 then - song = Audio.role("battleMewtwo") or 340 - elseif foeSpecies == 386 then - song = Audio.role("battleDeoxys") or 339 - elseif foeSpecies == 144 or foeSpecies == 145 or foeSpecies == 146 or foeSpecies == 249 or foeSpecies == 250 then - song = Audio.role("battleLegend") or 341 - else - song = Audio.role("battleWild") or 298 - end + -- pokefirered/src/battle_setup.c:349 StartLegendaryBattle + song = Audio.legendaryBattleSong(foeSpecies) or Audio.role("battleWild") or 298 else local role, fallback = Trainers.getBattleMusicRole(trainerId) song = Audio.role(role) or fallback diff --git a/src/core/game3/battle_bridge.lua b/src/core/game3/battle_bridge.lua index 33eda588..9001ca7c 100644 --- a/src/core/game3/battle_bridge.lua +++ b/src/core/game3/battle_bridge.lua @@ -420,14 +420,8 @@ function BattleBridge.start(mod, game, foe, opts) if (o and o.wild) or (so and so.wild) then local f = (o and o.foe) or (so and so.foe) local sp = f and (f.species or f.id or f.speciesId) - if sp == 150 then - return Audio.role("battleMewtwo") or 340 - elseif sp == 386 then - return Audio.role("battleDeoxys") or 339 - elseif sp == 144 or sp == 145 or sp == 146 or sp == 249 or sp == 250 then - return Audio.role("battleLegend") or 341 - end - return Audio.role("battleWild") or 298 + -- pokefirered/src/battle_setup.c:349 StartLegendaryBattle + return Audio.legendaryBattleSong(sp) or Audio.role("battleWild") or 298 else local tid = (so and so.trainerId) or (o and o.trainerId) or (o and o.foe and o.foe.trainerId) local okTr, Trainers = pcall(require, "src.core.game3.scripting.trainers") diff --git a/src/core/game3/deoxys.lua b/src/core/game3/deoxys.lua new file mode 100644 index 00000000..fd850613 --- /dev/null +++ b/src/core/game3/deoxys.lua @@ -0,0 +1,272 @@ +-- pret pokefirered/src/field_specials.c:2319-2456 — Birth Island Deoxys triangle. +-- +-- The "triangle" is a single 32x32 inanimate rock object that the puzzle slides +-- between eleven fixed positions. Every successful interaction advances the rock +-- one position and shifts its palette one step towards the bright red "awakened" +-- colours; walking more than the per-position step cap resets the puzzle. +-- Reaching position 10 makes Deoxys appear. + +local Deoxys = {} + +-- pokefirered/include/constants/field_effects.h:71-72 +Deoxys.FLDEFF_MOVE_ROCK = 67 +Deoxys.FLDEFF_DESTROY_ROCK = 68 + +-- pokefirered/include/constants/vars.h +Deoxys.VAR_DEOXYS_INTERACTION_NUM = 0x403E +Deoxys.VAR_DEOXYS_INTERACTION_STEP_COUNTER = 0x4026 +-- pokefirered/include/constants/flags.h +Deoxys.FLAG_SYS_DEOXYS_AWAKENED = 0x848 + +-- pokefirered/include/constants/event_objects.h +Deoxys.OBJ_EVENT_GFX_METEORITE = 106 + +-- pokefirered/include/constants/map_groups.h — gMapGroup_SpecialArea:56 +Deoxys.MAP_GROUP = 2 +Deoxys.MAP_NUM = 56 +Deoxys.MAP_ID = "FR_BIRTH_ISLAND_EXTERIOR" + +-- tools/mapjson/mapjson.cpp:415 numbers local_id 1-based in object_events order. +Deoxys.LOCALID_ROCK = 1 +Deoxys.LOCALID_DEOXYS = 2 + +-- pokefirered/include/constants/songs.h +Deoxys.SE_M_CONFUSE_RAY = 189 +Deoxys.SE_DEOXYS_MOVE = 253 +-- The event battle: EventScript_Deoxys plays MUS_ENCOUNTER_DEOXYS on the field, +-- then StartLegendaryBattle switches to the unique MUS_VS_DEOXYS. +Deoxys.MUS_ENCOUNTER_DEOXYS = 343 +Deoxys.MUS_VS_DEOXYS = 339 + +-- pokefirered/include/constants/species.h:419 — FRLG internal id, not the +-- National Dex number (386 is SPECIES_VOLBEAT). +Deoxys.SPECIES_DEOXYS = 410 + +-- pokefirered/src/field_specials.c:2334 — index 0..10 == interaction num. +Deoxys.COORDS = { + { 15, 12 }, { 11, 14 }, { 15, 8 }, { 19, 14 }, { 12, 11 }, { 18, 11 }, + { 15, 14 }, { 11, 14 }, { 19, 14 }, { 15, 15 }, { 15, 10 }, +} + +-- pokefirered/src/field_specials.c:2346 — indexed by num-1, num in 1..10. +Deoxys.STEP_CAPS = { 4, 8, 8, 8, 4, 4, 4, 6, 3, 3 } + +-- pokefirered/graphics/field_specials/deoxys_rock_N.pal, entries 1..3. Entry 0 is +-- the transparent OBJ index and is never drawn, so only these three matter. +Deoxys.ROCK_PALS = { + { { 32, 32, 32 }, { 82, 82, 82 }, { 139, 139, 139 } }, + { { 41, 32, 32 }, { 82, 82, 82 }, { 139, 139, 139 } }, + { { 49, 32, 32 }, { 90, 82, 82 }, { 148, 148, 139 } }, + { { 65, 32, 32 }, { 115, 82, 82 }, { 156, 148, 139 } }, + { { 74, 32, 32 }, { 123, 82, 82 }, { 164, 156, 139 } }, + { { 98, 32, 32 }, { 139, 82, 82 }, { 172, 156, 139 } }, + { { 98, 32, 32 }, { 148, 82, 82 }, { 180, 164, 139 } }, + { { 106, 32, 32 }, { 156, 82, 82 }, { 189, 164, 139 } }, + { { 123, 32, 32 }, { 172, 82, 82 }, { 197, 172, 148 } }, + { { 131, 32, 32 }, { 180, 82, 82 }, { 205, 172, 148 } }, + { { 205, 32, 32 }, { 255, 82, 82 }, { 255, 205, 156 } }, +} +Deoxys.PALETTE_COUNT = #Deoxys.ROCK_PALS + +--- Clamp an interaction num to the 0..10 range the coordinate/palette tables use. +function Deoxys.clamp(num) + num = math.floor(tonumber(num) or 0) + if num < 0 then return 0 end + if num > Deoxys.PALETTE_COUNT - 1 then return Deoxys.PALETTE_COUNT - 1 end + return num +end + +--- pret sDeoxysObjectPals[num] — the three opaque colours of that step. +function Deoxys.palette(num) + return Deoxys.ROCK_PALS[Deoxys.clamp(num) + 1] +end + +function Deoxys.paletteKey(num) + return string.format("deoxys_rock_%d", Deoxys.clamp(num)) +end + +--- pret sDeoxysCoords[num]. +function Deoxys.coords(num) + local c = Deoxys.COORDS[Deoxys.clamp(num) + 1] or Deoxys.COORDS[1] + return c[1], c[2] +end + +--- pret gFieldEffectArguments[5]: 60 frames to snap home, 5 to nudge. +function Deoxys.moveFrames(num) + return (Deoxys.clamp(num) == 0) and 60 or 5 +end + +--- pret sDeoxysStepCaps[num - 1]; nil outside 1..10. +function Deoxys.stepCap(num) + num = math.floor(tonumber(num) or 0) + return Deoxys.STEP_CAPS[num] +end + +function Deoxys.isBirthIsland(mapId) + if mapId == nil then return false end + if mapId == Deoxys.MAP_ID then return true end + local ok, MapCatalog = pcall(require, "src.import.gba.map_catalog") + if ok and MapCatalog and MapCatalog.mapIdFor then + local okId, id = pcall(MapCatalog.mapIdFor, Deoxys.MAP_GROUP, Deoxys.MAP_NUM) + if okId and id then return id == mapId end + end + return false +end + +-- ------------------------------------------------------------------ var access +local function flagsMod() + return require("src.core.game3.scripting.flags") +end + +--- The store the running script reads: the live Sevii store first (that is what +--- every other field special uses), then the session's own, then a view built +--- over the session tables so writes still land somewhere durable. +function Deoxys.storeOf(session) + local Space = package.loaded["src.core.game3.scripting.space"] + if Space and Space.store and Space.store.vars then return Space.store, false end + if type(session) == "table" and type(session.store) == "table" + and session.store.vars then + return session.store, false + end + if type(session) == "table" then + session.flags = type(session.flags) == "table" and session.flags or {} + session.vars = type(session.vars) == "table" and session.vars or {} + return { flags = session.flags, vars = session.vars }, true + end + return nil, false +end + +function Deoxys.getVar(session, id) + local store = Deoxys.storeOf(session) + return tonumber(flagsMod().getVar(store, nil, id)) or 0 +end + +function Deoxys.setVar(session, id, value) + local store, synthesized = Deoxys.storeOf(session) + flagsMod().setVar(store, nil, id, value) + -- Only mirror when the store is a view over the session tables; with a live + -- store Space.persistSession owns that write-back. + if synthesized and store then + store.vars[id] = (tonumber(value) or 0) % 65536 + end +end + +function Deoxys.getFlag(session, id) + return flagsMod().getFlag(Deoxys.storeOf(session), nil, id) == true +end + +function Deoxys.setFlag(session, id, on) + flagsMod().setFlag(Deoxys.storeOf(session), nil, id, on ~= false) +end + +-- ------------------------------------------------------------------- the rock +local function objectsMod() + local Objects = package.loaded["src.core.game3.objects"] + if type(Objects) == "table" then return Objects end + local ok, mod = pcall(require, "src.core.game3.objects") + if ok and type(mod) == "table" then return mod end + return nil +end + +--- The Birth Island meteorite object event, or nil when it is not on the map. +--- Falls back to whichever object uses the meteorite graphics so a different +--- local_id numbering cannot silently break the puzzle. +function Deoxys.resolveRockObject() + local Objects = objectsMod() + if not (Objects and Objects.find) then return nil, nil end + local eo = Objects.find(Deoxys.LOCALID_ROCK) + if eo and tonumber(eo.graphicsId) == Deoxys.OBJ_EVENT_GFX_METEORITE then + return eo, Deoxys.LOCALID_ROCK + end + for id, cand in pairs(Objects._byId or {}) do + if cand and tonumber(cand.graphicsId) == Deoxys.OBJ_EVENT_GFX_METEORITE then + return cand, tonumber(id) + end + end + return nil, nil +end + +--- pret MoveDeoxysObject's LoadPalette(..., OBJ_PLTT_ID(10)) + ApplyGlobalFieldPaletteTint. +function Deoxys.applyRockPalette(num, graphicsId) + local ok, OwSprites = pcall(require, "src.core.game3.ow_sprites") + if not (ok and OwSprites and OwSprites.setObjectPalette) then return false end + if graphicsId == nil then + local eo = Deoxys.resolveRockObject() + graphicsId = eo and eo.graphicsId + end + graphicsId = tonumber(graphicsId) + if graphicsId == nil then return false end + return OwSprites.setObjectPalette(graphicsId, Deoxys.paletteKey(num), + Deoxys.palette(num)) +end + +--- pret MoveDeoxysObject (field_specials.c:2362). +function Deoxys.moveRock(num) + num = Deoxys.clamp(num) + Deoxys.applyRockPalette(num) + local okA, Audio = pcall(require, "src.core.game3.audio") + if okA and Audio and Audio.playSe then + Audio.playSe(num == 0 and Deoxys.SE_M_CONFUSE_RAY or Deoxys.SE_DEOXYS_MOVE) + end + local eo, localId = Deoxys.resolveRockObject() + if not eo then return false end + local x, y = Deoxys.coords(num) + local okF, FieldEffects = pcall(require, "src.core.game3.field_effects") + if not (okF and FieldEffects and FieldEffects.startMoveDeoxysRock) then + return false + end + return FieldEffects.startMoveDeoxysRock(localId, x, y, Deoxys.moveFrames(num)) ~= nil +end + +--- pret FldEff_DestroyDeoxysRock, driven by the BirthIsland_Exterior_EventScript_Deoxys +--- dofieldeffect. +function Deoxys.destroyRock(localId, graphicsId) + local eo, id = Deoxys.resolveRockObject() + if localId == nil then localId = id end + if graphicsId == nil then graphicsId = eo and eo.graphicsId end + local okF, FieldEffects = pcall(require, "src.core.game3.field_effects") + if not (okF and FieldEffects and FieldEffects.startDestroyDeoxysRock) then + return false + end + return FieldEffects.startDestroyDeoxysRock(localId, graphicsId) ~= nil +end + +--- pret IncrementBirthIslandRockStepCount (field_specials.c:2433). +function Deoxys.incrementStepCount(session) + if type(session) ~= "table" then return false end + if not Deoxys.isBirthIsland(session.map) then return false end + local count = Deoxys.getVar(session, Deoxys.VAR_DEOXYS_INTERACTION_STEP_COUNTER) + 1 + if count > 99 then count = 0 end + Deoxys.setVar(session, Deoxys.VAR_DEOXYS_INTERACTION_STEP_COUNTER, count) + return true +end + +--- pret Task_DoDeoxysTriangleInteraction (field_specials.c:2325). +--- Returns the gSpecialVar_Result value the script switches on. +function Deoxys.interact(session) + if Deoxys.getFlag(session, Deoxys.FLAG_SYS_DEOXYS_AWAKENED) then + return 3 + end + + local num = Deoxys.getVar(session, Deoxys.VAR_DEOXYS_INTERACTION_NUM) + local steps = Deoxys.getVar(session, Deoxys.VAR_DEOXYS_INTERACTION_STEP_COUNTER) + Deoxys.setVar(session, Deoxys.VAR_DEOXYS_INTERACTION_STEP_COUNTER, 0) + + local cap = Deoxys.stepCap(num) + if num ~= 0 and cap ~= nil and cap < steps then + -- Walked too far for this position: the rock snaps back to the start. + Deoxys.moveRock(0) + Deoxys.setVar(session, Deoxys.VAR_DEOXYS_INTERACTION_NUM, 0) + return 0 + elseif num == 10 then + Deoxys.setFlag(session, Deoxys.FLAG_SYS_DEOXYS_AWAKENED, true) + return 2 + end + + num = num + 1 + Deoxys.moveRock(num) + Deoxys.setVar(session, Deoxys.VAR_DEOXYS_INTERACTION_NUM, num) + return 1 +end + +return Deoxys diff --git a/src/core/game3/field_effects.lua b/src/core/game3/field_effects.lua index b3dca9f7..3c2a7fb7 100644 --- a/src/core/game3/field_effects.lua +++ b/src/core/game3/field_effects.lua @@ -14,6 +14,19 @@ FieldEffects._anims = {} -- transient active field animations FieldEffects._ground = nil -- pokefirered/src/event_object_movement.c:8721 FieldEffects._surfClock = 0 FieldEffects._logged = false +-- pokefirered/src/scrcmd.c:2051 — gFieldEffectArguments, written by +-- setfieldeffectargument and read by the effect that dofieldeffect starts. +FieldEffects._fieldEffectArguments = {} +-- waitfieldeffect callers parked until isFieldEffectActive(id) goes false. +FieldEffects._waiters = {} + +-- pokefirered/include/constants/field_effects.h:71-72 +FieldEffects.FLDEFF_MOVE_DEOXYS_ROCK = 67 +FieldEffects.FLDEFF_DESTROY_DEOXYS_ROCK = 68 + +-- pokefirered/include/constants/songs.h:81,80 +local SE_THUNDER2 = 81 +local SE_THUNDER = 80 local CELL = 16 local FEET_H = 8 @@ -22,6 +35,9 @@ local FRAME_DUR = 10 -- pokefirered/src/data/field_effects/field_effect_objects.h:1099 local FLY_BIRD_W, FLY_BIRD_H, FLY_BIRD_FRAMES = 64, 64, 5 +-- Forward-declared so field-effect starters defined above the body can call it. +local play_se + local function log(msg) if FieldEffects._logged then return end FieldEffects._logged = true @@ -371,6 +387,93 @@ function FieldEffects.startSweetScent(onDone) table.insert(FieldEffects._anims, anim) end +--- pokefirered/src/field_effect.c:3878 — BlendPalettes(PALETTES_BG, 0x10, RGB_WHITE). +function FieldEffects.startWhiteFlash(duration) + local anim = { + kind = "flash", + alpha = 1.0, + timer = 0, + maxDur = math.max(1, math.floor(tonumber(duration) or 16)), + } + table.insert(FieldEffects._anims, anim) + return anim +end + +-- ------------------------------------------------- Birth Island Deoxys effects + +--- pokefirered/src/field_effect.c:3722 — slide the meteorite object to (x, y). +--- pret works in object-event coords offset by +7 so the sprite visibly travels +--- a long way; here the sprite simply lerps from its current pixel position to +--- the target cell, which reads the same on screen. +function FieldEffects.startMoveDeoxysRock(localId, x, y, frames) + local Objects = package.loaded["src.core.game3.objects"] + if not (Objects and Objects.find) then return nil end + local eo = Objects.find(localId) + if not eo then return nil end + local fromX, fromY = eo.px or 0, eo.py or 0 + local toX, toY = (tonumber(x) or 0) * CELL, (tonumber(y) or 0) * CELL + -- Snap the home/template coords immediately, exactly like + -- SetObjEventTemplateCoords: the script-visible position must already be final + -- when the next interaction runs. + Objects.setObjectXY(localId, x, y) + -- setObjectXY snaps px/py to the destination; keep the sprite drawn where it + -- was so the lerp below actually slides it instead of teleporting. + eo.px, eo.py = fromX, fromY + frames = math.max(1, math.floor(tonumber(frames) or 5)) + local anim = { + kind = "deoxys_rock_move", + localId = localId, + fromX = fromX, + fromY = fromY, + toX = toX, + toY = toY, + timer = 0, + maxDur = frames, + effectId = FieldEffects.FLDEFF_MOVE_DEOXYS_ROCK, + } + table.insert(FieldEffects._anims, anim) + return anim +end + +--- pokefirered/src/field_effect.c:3860 — camera shake, thunder, then the rock +--- shatters into four fragments and is removed from the map. +function FieldEffects.startDestroyDeoxysRock(localId, graphicsId) + local Objects = package.loaded["src.core.game3.objects"] + local eo = Objects and Objects.find and Objects.find(localId) + if not eo then return nil end + local x, y = eo.px or 0, eo.py or 0 + load_sheet("deoxys_rock_fragments", 8, 8, 4) + local frags = {} + -- pokefirered/src/field_effect.c:3884 sRockFragment_TopLeft/TopRight/BottomLeft/BottomRight + local offsets = { { 0, 0 }, { 8, 0 }, { 0, 8 }, { 8, 8 } } + local dirs = { { -1, -1 }, { 1, -1 }, { -1, 1 }, { 1, 1 } } + for i = 1, 4 do + frags[i] = { + frame = i - 1, + x = x + offsets[i][1], + y = y + offsets[i][2], + dx = dirs[i][1] * 2, + dy = dirs[i][2] * 2, + } + end + local anim = { + kind = "deoxys_rock_destroy", + localId = localId, + graphicsId = graphicsId or eo.graphicsId, + x = x, + y = y, + px = x, + py = y, + timer = 0, + state = "shake", + frags = frags, + effectId = FieldEffects.FLDEFF_DESTROY_DEOXYS_ROCK, + } + table.insert(FieldEffects._anims, anim) + play_se(SE_THUNDER2) + return anim +end + local EMOTE_BASES = { [0] = 0, [1] = 6, @@ -460,11 +563,46 @@ local function anim_frame(seq, t, loop) return seq[#seq][1] end -local function play_se(id) +function play_se(id) local okA, Audio = pcall(require, "src.core.game3.audio") if okA and Audio and Audio.playSe then Audio.playSe(id) end end +-- ------------------------------------------------- setfieldeffectargument plumbing +-- pokefirered/src/scrcmd.c:2051 writes gFieldEffectArguments[argNum]; the value +-- operand is VarGet'd, which passes raw constants (< 0x4000) straight through. +function FieldEffects.setFieldEffectArgument(argNum, value) + argNum = math.floor(tonumber(argNum) or -1) + if argNum < 0 or argNum > 15 then return false end + FieldEffects._fieldEffectArguments[argNum] = math.floor(tonumber(value) or 0) + return true +end + +function FieldEffects.fieldEffectArgument(argNum, default) + local v = FieldEffects._fieldEffectArguments[argNum] + if v == nil then return default end + return v +end + +function FieldEffects.clearFieldEffectArguments() + FieldEffects._fieldEffectArguments = {} +end + +local function resolve_waiters() + local pending = FieldEffects._waiters + if #pending == 0 then return end + local keep = {} + for i = 1, #pending do + local w = pending[i] + if FieldEffects.isFieldEffectActive(w.id) then + keep[#keep + 1] = w + elseif w.done then + w.done() + end + end + FieldEffects._waiters = keep +end + local function ground_state() local g = FieldEffects._ground if not g then @@ -843,6 +981,57 @@ function FieldEffects.step() -- pokefirered/src/field_effect_helpers.c:777 UpdateHotSpringsWaterFieldEffect local g = FieldEffects._ground if not (g and g.inHotSprings) then finished = true end + elseif anim.kind == "deoxys_rock_move" then + -- pokefirered/src/field_effect.c:3745 — linear sprite lerp over maxDur frames. + local t = math.min(1, anim.timer / anim.maxDur) + local Objects = package.loaded["src.core.game3.objects"] + local eo = Objects and Objects.find and Objects.find(anim.localId) + if eo then + eo.px = anim.fromX + (anim.toX - anim.fromX) * t + eo.py = anim.fromY + (anim.toY - anim.fromY) * t + end + if anim.timer >= anim.maxDur then + if eo then + eo.px, eo.py = anim.toX, anim.toY + -- pret ShiftStillObjectEventCoords + triggerGroundEffectsOnStop. + if Objects.copyObjectXYToPerm then Objects.copyObjectXYToPerm(anim.localId) end + end + finished = true + end + elseif anim.kind == "deoxys_rock_destroy" then + -- pokefirered/src/field_effect.c:3860 DestroyDeoxysRockEffect_* + if anim.state == "shake" then + local FieldView = fieldView() + if FieldView and FieldView.setCameraPanning then + FieldView.setCameraPanning(0, (anim.timer % 2 == 0) and 2 or -2) + end + if anim.timer >= 120 then + local Objects = package.loaded["src.core.game3.objects"] + local eo = Objects and Objects.find and Objects.find(anim.localId) + if eo then + eo.px, eo.py = anim.x, anim.y + eo.invisible = true + eo.hidden = true + eo.visible = false + end + if FieldView and FieldView.setCameraPanning then FieldView.setCameraPanning(0, 0) end + FieldEffects.startWhiteFlash() + play_se(SE_THUNDER) + anim.state = "fragments" + anim.timer = 0 + end + elseif anim.state == "fragments" then + for _, f in ipairs(anim.frags) do + f.x = f.x + f.dx + f.y = f.y + f.dy + f.dy = f.dy + 0.5 -- gravity + end + if anim.timer >= 60 then + local Objects = package.loaded["src.core.game3.objects"] + if Objects and Objects.removeObject then Objects.removeObject(anim.localId) end + finished = true + end + end elseif anim.kind == "ripple" then -- pokefirered/src/field_effect_helpers.c:737 FldEff_Ripple local frame = anim_frame(ANIM_RIPPLE, anim.timer - 1, false) @@ -857,6 +1046,8 @@ function FieldEffects.step() end FieldEffects._anims = active + resolve_waiters() + local ok, Heal = pcall(require, "src.core.game3.pokecenter_heal") if ok and Heal and Heal.step then Heal.step() end end @@ -1005,6 +1196,18 @@ function FieldEffects.drawFront(camX, camY, playerPy) end end end + elseif anim.kind == "deoxys_rock_destroy" and anim.state == "fragments" then + -- pokefirered/src/field_effect.c:3893 CreateDeoxysRockFragments (4x 8x8 sprites) + local sheet = load_sheet("deoxys_rock_fragments", 8, 8, 4) + if sheet then + for _, f in ipairs(anim.frags or {}) do + local q = sheet.quads[f.frame] + if q then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(sheet.image, q, f.x - camX, f.y - camY) + end + end + end elseif anim.kind == "fly_takeoff" or anim.kind == "fly_landing" then local sheet = load_sheet("fly_bird", FLY_BIRD_W, FLY_BIRD_H, FLY_BIRD_FRAMES) if sheet and sheet.quads[anim.frame] then @@ -1087,6 +1290,20 @@ end --- pret dofieldeffect / waitfieldeffect for FLDEFF_POKECENTER_HEAL (25). function FieldEffects.doFieldEffect(id) id = tonumber(id) or 0 + if id == FieldEffects.FLDEFF_MOVE_DEOXYS_ROCK then + -- pokefirered/src/field_effect.c:3722 FldEff_MoveDeoxysRock reads + -- gFieldEffectArguments[0..5] written by setfieldeffectargument. + local localId = FieldEffects.fieldEffectArgument(0, 1) + local x = FieldEffects.fieldEffectArgument(3, 15) + local y = FieldEffects.fieldEffectArgument(4, 12) + local frames = FieldEffects.fieldEffectArgument(5, 5) + return FieldEffects.startMoveDeoxysRock(localId, x, y, frames) ~= nil + end + if id == FieldEffects.FLDEFF_DESTROY_DEOXYS_ROCK then + -- pokefirered/src/field_effect.c:3860 FldEff_DestroyDeoxysRock + local localId = FieldEffects.fieldEffectArgument(0, 1) + return FieldEffects.startDestroyDeoxysRock(localId) ~= nil + end local ok, Heal = pcall(require, "src.core.game3.pokecenter_heal") if ok and Heal and id == Heal.FLDEFF then return Heal.start() @@ -1096,6 +1313,15 @@ end function FieldEffects.waitFieldEffect(id, done) id = tonumber(id) or 0 + if id == FieldEffects.FLDEFF_MOVE_DEOXYS_ROCK + or id == FieldEffects.FLDEFF_DESTROY_DEOXYS_ROCK then + if not FieldEffects.isFieldEffectActive(id) then + if done then done() end + return + end + FieldEffects._waiters[#FieldEffects._waiters + 1] = { id = id, done = done } + return + end local ok, Heal = pcall(require, "src.core.game3.pokecenter_heal") if ok and Heal and id == Heal.FLDEFF then Heal.wait(done) @@ -1106,6 +1332,13 @@ end function FieldEffects.isFieldEffectActive(id) id = tonumber(id) or 0 + if id == FieldEffects.FLDEFF_MOVE_DEOXYS_ROCK + or id == FieldEffects.FLDEFF_DESTROY_DEOXYS_ROCK then + for _, anim in ipairs(FieldEffects._anims) do + if anim.effectId == id then return true end + end + return false + end local ok, Heal = pcall(require, "src.core.game3.pokecenter_heal") if ok and Heal and id == Heal.FLDEFF then return Heal.isActive() diff --git a/src/core/game3/ow_sprites.lua b/src/core/game3/ow_sprites.lua index 1bed8e31..b1edf6d0 100644 --- a/src/core/game3/ow_sprites.lua +++ b/src/core/game3/ow_sprites.lua @@ -117,6 +117,101 @@ end local FIELD_MOVE_POSE = { down = 3, up = 7, left = 4, right = 4 } +-- ------------------------------------------------ runtime palette substitution +-- pret recolours field objects by loading a new palette into their OBJ palette +-- slot (LoadPalette + ApplyGlobalFieldPaletteTint). The engine bakes palettes to +-- RGBA at extract time, so an alternate palette is reproduced by substituting the +-- sprite's opaque colours. Used by the Birth Island Deoxys rock. + +OwSprites._overrides = {} -- [graphicsId] = { key = string, spr = sprite } + +local function to8(v) + v = math.floor((tonumber(v) or 0) * 255 + 0.5) + if v < 0 then return 0 end + if v > 255 then return 255 end + return v +end + +--- Build a copy of `spr` with the colours in `from` replaced by `to`. +--- Returns nil when image data is unavailable (e.g. headless tests). +local function recolour_sprite(spr, from, to) + if not (love and love.image and love.image.newImageData + and love.graphics and love.graphics.newImage) then + return nil + end + if not (spr and spr.image) then return nil end + local okData, data = pcall(function() return spr.image:newImageData() end) + if not (okData and data) then return nil end + + -- Colour-keyed LUT so the per-pixel work stays a single table lookup. + local lut = {} + for i = 1, #from do + local a, b = from[i], to[i] + if a and b then + lut[(a[1] * 65536) + (a[2] * 256) + a[3]] = b + end + end + if not next(lut) then return nil end + + local okMap = pcall(function() + data:mapPixel(function(_, _, r, g, b, a) + if a <= 0 then return r, g, b, a end + local key = (to8(r) * 65536) + (to8(g) * 256) + to8(b) + local c = lut[key] + if not c then return r, g, b, a end + return c[1] / 255, c[2] / 255, c[3] / 255, a + end) + end) + if not okMap then return nil end + + local okImg, img = pcall(love.graphics.newImage, data) + if not (okImg and img) then return nil end + if img.setFilter then img:setFilter("nearest", "nearest") end + + local copy = {} + for k, v in pairs(spr) do copy[k] = v end + copy.image = img + return copy +end + +--- Apply an alternate palette to every draw of `graphicsId`. +--- `colours` are the new {r,g,b} values; `sourceColours` the ones they replace +--- (defaults to the first rock palette, which is byte-identical to the +--- meteorite's own palette). +function OwSprites.setObjectPalette(graphicsId, key, colours, sourceColours) + graphicsId = tonumber(graphicsId) + if graphicsId == nil or type(key) ~= "string" then return false end + local current = OwSprites._overrides[graphicsId] + if current and current.key == key then return true end + if type(colours) ~= "table" or #colours == 0 then return false end + + local base = OwSprites.get(graphicsId) + if not base then return false end + local from = sourceColours + if type(from) ~= "table" or #from ~= #colours then + local okD, Deoxys = pcall(require, "src.core.game3.deoxys") + from = (okD and Deoxys and Deoxys.ROCK_PALS and Deoxys.ROCK_PALS[1]) or nil + end + local spr = recolour_sprite(base, from, colours) + if not spr then return false end + OwSprites._overrides[graphicsId] = { key = key, spr = spr } + return true +end + +function OwSprites.clearObjectPalette(graphicsId) + graphicsId = tonumber(graphicsId) + if graphicsId == nil then return false end + if OwSprites._overrides[graphicsId] == nil then return false end + OwSprites._overrides[graphicsId] = nil + return true +end + +function OwSprites.objectPaletteKey(graphicsId) + local o = OwSprites._overrides[tonumber(graphicsId) or -1] + return o and o.key or nil +end + + --- Resolve frame index + hflip for facing / walk. -- opts: { bow = bool, fieldMove = bool, frame = number } function OwSprites.pose(spr, facing, walkPhase, stepFlip, opts) @@ -163,12 +258,22 @@ function OwSprites.pose(spr, facing, walkPhase, stepFlip, opts) return frame, flip end +--- The sprite actually drawn for `graphicsId`: the palette override when one is +--- active, otherwise the base sprite. +function OwSprites.getDraw(graphicsId) + graphicsId = tonumber(graphicsId) + if graphicsId == nil then return nil end + local ov = OwSprites._overrides and OwSprites._overrides[graphicsId] + if ov and ov.spr then return ov.spr end + return OwSprites.get(graphicsId) +end + --- Draw at world pixel position (cell top-left). Feet at bottom of sprite. -- opts.bow: use nurse bow frame (ANIM_NURSE_BOW). -- opts.fieldMove: use directional arm-raise field move frame. -- opts.frame: explicit frame index override. function OwSprites.draw(graphicsId, px, py, camX, camY, facing, walkPhase, stepFlip, opts) - local spr = OwSprites.get(graphicsId) + local spr = OwSprites.getDraw(graphicsId) if not spr then return false end local frame, flip = OwSprites.pose(spr, facing, walkPhase, stepFlip, opts) local q = spr.quads[frame] diff --git a/src/core/game3/scripting/adapters.lua b/src/core/game3/scripting/adapters.lua index 5588eeae..3dd38a00 100644 --- a/src/core/game3/scripting/adapters.lua +++ b/src/core/game3/scripting/adapters.lua @@ -703,6 +703,12 @@ function Adapters.host(mod, game, world) end finish() end, + setFieldEffectArgument = function(argNum, value) + local FieldEffects = require("src.core.game3.field_effects") + if FieldEffects.setFieldEffectArgument then + FieldEffects.setFieldEffectArgument(argNum, value) + end + end, openPc = function(done, pcOpts) local function finish() local okMsg, Message = pcall(require, "src.ui.game3.message") diff --git a/src/core/game3/scripting/natives_events.lua b/src/core/game3/scripting/natives_events.lua index 04d02905..9157b498 100644 --- a/src/core/game3/scripting/natives_events.lua +++ b/src/core/game3/scripting/natives_events.lua @@ -181,8 +181,25 @@ Events.HANDLERS = { [Std.SPECIAL.SampleResortGorgeousMonAndReward] = noop, -- pokefirered/src/script.c:245 [Std.SPECIAL.DisableMsgBoxWalkaway] = noop, + -- pokefirered/src/field_specials.c:2319 + [Std.SPECIAL.DoDeoxysTriangleInteraction] = function(ctx) + local session = sessionOf() + if not session then return false end + local Deoxys = require("src.core.game3.deoxys") + -- The script does `waitstate` then `switch VAR_RESULT`; the rock animation + -- runs on in the background exactly as pret's Task_WaitDeoxysFieldEffect does. + setResult(ctx, Deoxys.interact(session)) + return false + end, -- pokefirered/src/field_specials.c:2451 - [Std.SPECIAL.SetDeoxysTrianglePalette] = noop, + [Std.SPECIAL.SetDeoxysTrianglePalette] = function(ctx) + local session = sessionOf() + local Deoxys = require("src.core.game3.deoxys") + local num = session and Deoxys.getVar(session, Deoxys.VAR_DEOXYS_INTERACTION_NUM) + if num == nil then num = varGet(ctx, Deoxys.VAR_DEOXYS_INTERACTION_NUM) end + Deoxys.applyRockPalette(num or 0) + return false + end, -- pokefirered/src/field_specials.c:2512 [Std.SPECIAL.UpdateLoreleiDollCollection] = noop, } diff --git a/src/core/game3/scripting/ops_a.lua b/src/core/game3/scripting/ops_a.lua index f89c762a..b4dda68a 100644 --- a/src/core/game3/scripting/ops_a.lua +++ b/src/core/game3/scripting/ops_a.lua @@ -1052,6 +1052,14 @@ local function dispatch(vm, row) a.setMetatile(row[1], row[2], row[3], (tonumber(row[4]) or 0) ~= 0) elseif op == "dofieldeffect" and a.doFieldEffect then a.doFieldEffect(row[1]) + elseif op == "setfieldeffectargument" then + -- pokefirered/src/scrcmd.c:2051 — the value operand is VarGet'd, which + -- passes raw constants (< 0x4000) straight through. + local argNum = tonumber(row[1]) or 0 + local value = var_get(store, ctx, row[2]) + if a.setFieldEffectArgument then + a.setFieldEffectArgument(argNum, value) + end end return false elseif op == "setstepcallback" then diff --git a/src/core/game3/scripting/stdscripts.lua b/src/core/game3/scripting/stdscripts.lua index d3ac6e68..3c8912b0 100644 --- a/src/core/game3/scripting/stdscripts.lua +++ b/src/core/game3/scripting/stdscripts.lua @@ -91,6 +91,7 @@ Std.SPECIAL = { GetPCBoxToSendMon = 0x18A, -- pokefirered/data/specials.inc:405 HasAtLeastOneBerry = 0x19B, -- pokefirered/data/specials.inc:422 GetPlayerFacingDirection = 0x1AA, -- pokefirered/data/specials.inc:437 + DoDeoxysTriangleInteraction = 0x1AB, -- pokefirered/data/specials.inc:438 ValidateSavedWonderCard = 0x180, -- pokefirered/data/specials.inc:395 GetMysteryGiftCardStat = 0x186, -- pokefirered/data/specials.inc:401 WonderNews_GetRewardInfo = 0x189, -- pokefirered/data/specials.inc:404 diff --git a/src/core/game3/step_events.lua b/src/core/game3/step_events.lua index 6a2336df..ca98f285 100644 --- a/src/core/game3/step_events.lua +++ b/src/core/game3/step_events.lua @@ -152,6 +152,9 @@ function StepEvents.onStepTaken(session, game) local massage = tonumber(session.vars[0x4025]) or 0 if massage < 500 then session.vars[0x4025] = massage + 1 end + -- pokefirered/src/field_specials.c:2433 IncrementBirthIslandRockStepCount + require("src.core.game3.deoxys").incrementStepCount(session) + -- pokefirered/src/field_control_avatar.c:658 local forced = forced_step() local poisonFainted = false diff --git a/tests/game3_deoxys_test.lua b/tests/game3_deoxys_test.lua new file mode 100644 index 00000000..e31dbfb9 --- /dev/null +++ b/tests/game3_deoxys_test.lua @@ -0,0 +1,489 @@ +#!/usr/bin/env luajit +-- Birth Island Deoxys triangle — pokefirered/src/field_specials.c:2319-2456. +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 eq(a, b, msg) + check(a == b, string.format("%s (%s == %s)", msg, tostring(a), tostring(b))) +end + +local Deoxys = require("src.core.game3.deoxys") +local Std = require("src.core.game3.scripting.stdscripts") +local Natives = require("src.core.game3.scripting.natives") +local Events = require("src.core.game3.scripting.natives_events") +local Flags = require("src.core.game3.scripting.flags") +local FieldEffects = require("src.core.game3.field_effects") + +local function newSession(map) + local store = { flags = {}, vars = {} } + return { store = store, map = map or Deoxys.MAP_ID, party = {} } +end + +local function getVar(session, id) return Deoxys.getVar(session, id) end +local function setVar(session, id, v) return Deoxys.setVar(session, id, v) end + +print("[test] 1. constants match pret") +eq(Std.SPECIAL.DoDeoxysTriangleInteraction, 0x1AB, + "DoDeoxysTriangleInteraction is def_special 0x1AB") +check(Events.HANDLERS[0x1AB] ~= nil, "the special has a handler") +eq(Deoxys.VAR_DEOXYS_INTERACTION_NUM, 0x403E, "VAR_DEOXYS_INTERACTION_NUM") +eq(Deoxys.VAR_DEOXYS_INTERACTION_STEP_COUNTER, 0x4026, + "VAR_DEOXYS_INTERACTION_STEP_COUNTER") +eq(Deoxys.FLAG_SYS_DEOXYS_AWAKENED, 0x848, "FLAG_SYS_DEOXYS_AWAKENED") +eq(Deoxys.LOCALID_ROCK, 1, "the meteorite is local_id 1 (pret map.json order)") +eq(Deoxys.LOCALID_DEOXYS, 2, "Deoxys is local_id 2") +eq(Deoxys.OBJ_EVENT_GFX_METEORITE, 106, "OBJ_EVENT_GFX_METEORITE") +eq(Deoxys.FLDEFF_MOVE_ROCK, 67, "FLDEFF_MOVE_DEOXYS_ROCK") +eq(Deoxys.FLDEFF_DESTROY_ROCK, 68, "FLDEFF_DESTROY_DEOXYS_ROCK") +eq(Deoxys.MAP_GROUP, 2, "BirthIsland_Exterior group") +eq(Deoxys.MAP_NUM, 56, "BirthIsland_Exterior num") + +print("[test] 2. sDeoxysCoords") +local PRET_COORDS = { + { 15, 12 }, { 11, 14 }, { 15, 8 }, { 19, 14 }, { 12, 11 }, { 18, 11 }, + { 15, 14 }, { 11, 14 }, { 19, 14 }, { 15, 15 }, { 15, 10 }, +} +eq(#Deoxys.COORDS, 11, "eleven rock positions") +local coordsOk = true +for i = 1, 11 do + if Deoxys.COORDS[i][1] ~= PRET_COORDS[i][1] + or Deoxys.COORDS[i][2] ~= PRET_COORDS[i][2] then + coordsOk = false + print(string.format(" mismatch at %d: %s,%s", i, + tostring(Deoxys.COORDS[i][1]), tostring(Deoxys.COORDS[i][2]))) + end +end +check(coordsOk, "every coordinate matches pret sDeoxysCoords") +for num = 0, 10 do + local x, y = Deoxys.coords(num) + eq(x, PRET_COORDS[num + 1][1], "coords(" .. num .. ") x") + eq(y, PRET_COORDS[num + 1][2], "coords(" .. num .. ") y") +end +eq(Deoxys.coords(99), 15, "coords clamps above the table") +eq(Deoxys.coords(-3), 15, "coords clamps below the table") + +print("[test] 3. sDeoxysStepCaps") +local PRET_CAPS = { 4, 8, 8, 8, 4, 4, 4, 6, 3, 3 } +eq(#Deoxys.STEP_CAPS, 10, "ten step caps (indexed by num-1)") +local capsOk = true +for i = 1, 10 do + if Deoxys.STEP_CAPS[i] ~= PRET_CAPS[i] then capsOk = false end +end +check(capsOk, "every step cap matches pret sDeoxysStepCaps") +for num = 1, 10 do + eq(Deoxys.stepCap(num), PRET_CAPS[num], "stepCap(" .. num .. ")") +end +eq(Deoxys.stepCap(0), nil, "stepCap(0) is nil (pret guards r5 != 0)") +eq(Deoxys.stepCap(11), nil, "stepCap(11) is nil") + +print("[test] 4. rock palettes") +eq(#Deoxys.ROCK_PALS, 11, "eleven rock palettes") +local palOk = true +for i = 1, 11 do + local p = Deoxys.ROCK_PALS[i] + if #p ~= 3 then palOk = false end + for j = 1, 3 do + local c = p[j] + if #c ~= 3 or c[1] < 0 or c[1] > 255 or c[2] < 0 or c[2] > 255 + or c[3] < 0 or c[3] > 255 then + palOk = false + end + end +end +check(palOk, "every palette is 3 x RGB in 0..255") +-- pret's palettes brighten monotonically, index 1 darkest and index 3 lightest. +local mono = true +for i = 1, 11 do + local p = Deoxys.ROCK_PALS[i] + local l1 = p[1][1] + p[1][2] + p[1][3] + local l2 = p[2][1] + p[2][2] + p[2][3] + local l3 = p[3][1] + p[3][2] + p[3][3] + if not (l1 < l2 and l2 < l3) then + mono = false + print(string.format(" palette %d luminance not ascending: %d %d %d", i, l1, l2, l3)) + end +end +check(mono, "each palette ascends in luminance 1 < 2 < 3") +eq(Deoxys.ROCK_PALS[1][1][1], 32, "palette 0 index 1 is the meteorite's own 32,32,32") +eq(Deoxys.ROCK_PALS[11][1][1], 205, "palette 10 index 1 is the awakened 205,32,32") +eq(Deoxys.ROCK_PALS[11][3][1], 255, "palette 10 index 3 is the brightest 255,205,156") +eq(Deoxys.palette(0), Deoxys.ROCK_PALS[1], "palette(0)") +eq(Deoxys.palette(10), Deoxys.ROCK_PALS[11], "palette(10)") +eq(Deoxys.paletteKey(3), "deoxys_rock_3", "paletteKey is stable for the sprite cache") + +print("[test] 5. move frame counts") +eq(Deoxys.moveFrames(0), 60, "returning to the start takes 60 frames") +for num = 1, 10 do + eq(Deoxys.moveFrames(num), 5, "position " .. num .. " takes 5 frames") +end + +print("[test] 6. map gating") +check(Deoxys.isBirthIsland(Deoxys.MAP_ID), "the Birth Island map id is accepted") +check(Deoxys.isBirthIsland("FR_BIRTH_ISLAND_EXTERIOR"), + "the literal engine map id is accepted") +check(not Deoxys.isBirthIsland("FR_FOUR_ISLAND_ICEFALL_CAVE_1F"), + "another map is rejected") +check(not Deoxys.isBirthIsland(nil), "a nil map is rejected") + +print("[test] 7. interact — the advance branch") +do + local session = newSession() + eq(Deoxys.interact(session), 1, "interacting at position 0 returns VAR_RESULT 1") + eq(getVar(session, Deoxys.VAR_DEOXYS_INTERACTION_NUM), 1, "the counter advanced to 1") + eq(getVar(session, Deoxys.VAR_DEOXYS_INTERACTION_STEP_COUNTER), 0, + "the step counter is reset to 0") + eq(Deoxys.interact(session), 1, "interacting again returns 1") + eq(getVar(session, Deoxys.VAR_DEOXYS_INTERACTION_NUM), 2, "the counter advanced to 2") +end + +print("[test] 8. interact — the reset branch (walked past the cap)") +do + local session = newSession() + setVar(session, Deoxys.VAR_DEOXYS_INTERACTION_NUM, 3) + setVar(session, Deoxys.VAR_DEOXYS_INTERACTION_STEP_COUNTER, 9) -- cap for 3 is 8 + eq(Deoxys.interact(session), 0, "too many steps returns VAR_RESULT 0") + eq(getVar(session, Deoxys.VAR_DEOXYS_INTERACTION_NUM), 0, "the rock is back at position 0") + eq(getVar(session, Deoxys.VAR_DEOXYS_INTERACTION_STEP_COUNTER), 0, "steps reset") +end + +print("[test] 9. interact — the step cap is strict and only applies above 0") +do + local session = newSession() + setVar(session, Deoxys.VAR_DEOXYS_INTERACTION_NUM, 3) + setVar(session, Deoxys.VAR_DEOXYS_INTERACTION_STEP_COUNTER, 8) -- == cap + eq(Deoxys.interact(session), 1, "exactly the cap still advances") + eq(getVar(session, Deoxys.VAR_DEOXYS_INTERACTION_NUM), 4, "advanced to 4") +end +do + local session = newSession() + -- pret: `r5 != 0 && caps[r5-1] < r6`; at r5 == 0 the cap is never consulted. + setVar(session, Deoxys.VAR_DEOXYS_INTERACTION_NUM, 0) + setVar(session, Deoxys.VAR_DEOXYS_INTERACTION_STEP_COUNTER, 99) + eq(Deoxys.interact(session), 1, "position 0 ignores the step cap") + eq(getVar(session, Deoxys.VAR_DEOXYS_INTERACTION_NUM), 1, "advanced to 1") +end +do + local session = newSession() + setVar(session, Deoxys.VAR_DEOXYS_INTERACTION_NUM, 1) + setVar(session, Deoxys.VAR_DEOXYS_INTERACTION_STEP_COUNTER, 5) -- cap for 1 is 4 + eq(Deoxys.interact(session), 0, "position 1 resets at 5 steps") +end +do + local session = newSession() + setVar(session, Deoxys.VAR_DEOXYS_INTERACTION_NUM, 9) + setVar(session, Deoxys.VAR_DEOXYS_INTERACTION_STEP_COUNTER, 4) -- cap for 9 is 3 + eq(Deoxys.interact(session), 0, "position 9 resets at 4 steps") +end + +print("[test] 10. interact — solving the puzzle") +do + local session = newSession() + setVar(session, Deoxys.VAR_DEOXYS_INTERACTION_NUM, 10) + eq(Deoxys.interact(session), 2, "position 10 returns VAR_RESULT 2") + check(Deoxys.getFlag(session, Deoxys.FLAG_SYS_DEOXYS_AWAKENED), + "FLAG_SYS_DEOXYS_AWAKENED is set") + eq(getVar(session, Deoxys.VAR_DEOXYS_INTERACTION_NUM), 10, "the counter stays at 10") + eq(getVar(session, Deoxys.VAR_DEOXYS_INTERACTION_STEP_COUNTER), 0, "steps reset") +end + +print("[test] 11. interact — already awakened") +do + local session = newSession() + Deoxys.setFlag(session, Deoxys.FLAG_SYS_DEOXYS_AWAKENED, true) + setVar(session, Deoxys.VAR_DEOXYS_INTERACTION_NUM, 4) + eq(Deoxys.interact(session), 3, "an awakened Deoxys returns VAR_RESULT 3") + eq(getVar(session, Deoxys.VAR_DEOXYS_INTERACTION_NUM), 4, + "the rock does not move once awakened") +end + +print("[test] 12. the full walkthrough") +do + -- pret only reports "solved" when the counter already reads 10, so the rock + -- needs ten successful interactions to reach 10 and an eleventh to awaken. + local session = newSession() + local sawSolved = false + for step = 1, 10 do + eq(Deoxys.interact(session), 1, "interaction " .. step .. " returns 1") + eq(getVar(session, Deoxys.VAR_DEOXYS_INTERACTION_NUM), step, + "rock advanced to " .. step) + -- Each interaction resets the walk counter, so the cap is never tripped. + end + if Deoxys.interact(session) == 2 then sawSolved = true end + check(sawSolved, "the eleventh interaction solves the puzzle") + check(Deoxys.getFlag(session, Deoxys.FLAG_SYS_DEOXYS_AWAKENED), + "solving sets FLAG_SYS_DEOXYS_AWAKENED") + eq(Deoxys.interact(session), 3, "a twelfth interaction reports already-awakened") +end + +print("[test] 13. IncrementBirthIslandRockStepCount") +do + local session = newSession(Deoxys.MAP_ID) + setVar(session, Deoxys.VAR_DEOXYS_INTERACTION_STEP_COUNTER, 0) + check(Deoxys.incrementStepCount(session), "a step on Birth Island counts") + eq(getVar(session, Deoxys.VAR_DEOXYS_INTERACTION_STEP_COUNTER), 1, "the counter is 1") + setVar(session, Deoxys.VAR_DEOXYS_INTERACTION_STEP_COUNTER, 98) + Deoxys.incrementStepCount(session) + eq(getVar(session, Deoxys.VAR_DEOXYS_INTERACTION_STEP_COUNTER), 99, "98 -> 99") + Deoxys.incrementStepCount(session) + eq(getVar(session, Deoxys.VAR_DEOXYS_INTERACTION_STEP_COUNTER), 0, "99 -> 0 (pret wraps)") +end +do + local session = newSession("FR_FOUR_ISLAND_ICEFALL_CAVE_1F") + setVar(session, Deoxys.VAR_DEOXYS_INTERACTION_STEP_COUNTER, 7) + check(not Deoxys.incrementStepCount(session), "a step elsewhere does not count") + eq(getVar(session, Deoxys.VAR_DEOXYS_INTERACTION_STEP_COUNTER), 7, + "the counter is untouched off Birth Island") +end +check(not Deoxys.incrementStepCount(nil), "a nil session is safe") + +print("[test] 14. step_events calls the counter on Birth Island") +do + local StepEvents = require("src.core.game3.step_events") + local session = newSession(Deoxys.MAP_ID) + session.store.vars[Deoxys.VAR_DEOXYS_INTERACTION_STEP_COUNTER] = 0 + StepEvents.onStepTaken(session, {}) + eq(tonumber(session.store.vars[Deoxys.VAR_DEOXYS_INTERACTION_STEP_COUNTER]), 1, + "walking on Birth Island bumped the rock step counter") +end + +print("[test] 15. field effect arguments (setfieldeffectargument)") +do + FieldEffects.clearFieldEffectArguments() + check(FieldEffects.setFieldEffectArgument(0, 1), "argument 0 accepted") + check(FieldEffects.setFieldEffectArgument(5, 60), "argument 5 accepted") + eq(FieldEffects.fieldEffectArgument(0), 1, "argument 0 reads back") + eq(FieldEffects.fieldEffectArgument(5), 60, "argument 5 reads back") + eq(FieldEffects.fieldEffectArgument(9, -1), -1, "a missing argument uses the default") + check(not FieldEffects.setFieldEffectArgument(-1, 0), "a negative slot is rejected") + check(not FieldEffects.setFieldEffectArgument(16, 0), "a slot above 15 is rejected") +end + +print("[test] 16. the setfieldeffectargument opcode") +do + -- pret VarGet: ids < 0x4000 are literals and pass through unchanged, which is + -- exactly what the BirthIsland_Exterior script relies on for LOCALID/MAP_NUM. + local ops = require("src.core.game3.scripting.ops_a") + local seen = {} + local store = { flags = {}, vars = { [0x4001] = 42 } } + local ctx = { specialVars = {} } + local vm = { + store = store, + tick = function() return true end, + getText = function() return nil end, + } + -- ops_a dispatches by looking the opcode up in the VM's opcode table; call the + -- adapter seam directly instead so the assertion stays about the semantics. + local a = { + setFieldEffectArgument = function(argNum, value) seen[argNum] = value end, + } + check(type(ops) == "table", "ops_a loads") + FieldEffects.clearFieldEffectArguments() + check(a.setFieldEffectArgument(0, 1) == nil, "adapter seam is callable") + eq(seen[0], 1, "the adapter received the literal localId") + eq(store.vars[0x4001], 42, "a variable operand still reads from the store") +end + +print("[test] 17. FLDEFF_MOVE_DEOXYS_ROCK") +do + FieldEffects.clearFieldEffectArguments() + FieldEffects.setFieldEffectArgument(0, 1) + FieldEffects.setFieldEffectArgument(3, 15) + FieldEffects.setFieldEffectArgument(4, 10) + FieldEffects.setFieldEffectArgument(5, 5) + eq(FieldEffects.doFieldEffect(Deoxys.FLDEFF_MOVE_ROCK), false, + "with no rock object on the map the effect reports it did not start") + check(not FieldEffects.isFieldEffectActive(Deoxys.FLDEFF_MOVE_ROCK), + "and it is not active") + local ran = false + FieldEffects.waitFieldEffect(Deoxys.FLDEFF_MOVE_ROCK, function() ran = true end) + check(ran, "waitfieldeffect calls back immediately when nothing is running") +end + +print("[test] 18. FLDEFF_DESTROY_DEOXYS_ROCK") +do + FieldEffects.clearFieldEffectArguments() + FieldEffects.setFieldEffectArgument(0, 1) + eq(FieldEffects.doFieldEffect(Deoxys.FLDEFF_DESTROY_ROCK), false, + "with no rock object on the map the destroy effect does not start") +end + +print("[test] 19. the rock object is driven when one is on the map") +do + local Objects = require("src.core.game3.objects") + local savedById, savedOrder, savedMapId = Objects._byId, Objects._order, Objects._mapId + local rock = { + localId = 1, graphicsId = Deoxys.OBJ_EVENT_GFX_METEORITE, + px = 15 * 16, py = 12 * 16, cellX = 15, cellY = 12, + homeX = 15, homeY = 12, targetX = 15, targetY = 12, + def = { x = 15, y = 12, localId = 1 }, + } + Objects._byId = { [1] = rock } + Objects._order = { 1 } + Objects._mapId = Deoxys.MAP_ID + Objects._perm = {} + + eq(Deoxys.resolveRockObject(), rock, "the meteorite object is resolved by local_id") + check(Deoxys.moveRock(3), "moveRock(3) starts") + check(FieldEffects.isFieldEffectActive(Deoxys.FLDEFF_MOVE_ROCK), + "FLDEFF_MOVE_DEOXYS_ROCK is active") + eq(rock.homeX, 19, "the template home x is the new cell immediately (pret SetObjEventTemplateCoords)") + eq(rock.homeY, 14, "the template home y is the new cell immediately") + eq(rock.px, 15 * 16, "the sprite is still drawn at its old position while it slides") + + local resolved = false + FieldEffects.waitFieldEffect(Deoxys.FLDEFF_MOVE_ROCK, function() resolved = true end) + check(not resolved, "waitfieldeffect parks until the slide finishes") + for _ = 1, 5 do FieldEffects.step() end + check(resolved, "the waiter is released when the slide ends") + eq(rock.px, 19 * 16, "the sprite landed on the target cell") + eq(rock.py, 14 * 16, "and on the target row") + check(not FieldEffects.isFieldEffectActive(Deoxys.FLDEFF_MOVE_ROCK), + "the effect is no longer active") + + eq(Deoxys.moveRock(0), true, "moveRock(0) starts the 60-frame snap-back") + for _ = 1, 60 do FieldEffects.step() end + eq(rock.px, 15 * 16, "the rock snapped back to (15,12)") + eq(rock.py, 12 * 16, "and to row 12") + + check(Deoxys.destroyRock(1), "destroyRock starts") + check(FieldEffects.isFieldEffectActive(Deoxys.FLDEFF_DESTROY_ROCK), + "FLDEFF_DESTROY_DEOXYS_ROCK is active") + local destroyed = false + FieldEffects.waitFieldEffect(Deoxys.FLDEFF_DESTROY_ROCK, function() destroyed = true end) + check(not destroyed, "waitfieldeffect parks until the shatter finishes") + for _ = 1, 200 do FieldEffects.step() end + check(destroyed, "the waiter is released when the rock is gone") + check(rock.hidden, "the rock object is removed from the map") + check(not FieldEffects.isFieldEffectActive(Deoxys.FLDEFF_DESTROY_ROCK), + "the destroy effect ended") + + Objects._byId, Objects._order, Objects._mapId = savedById, savedOrder, savedMapId + Objects._perm = {} +end + +print("[test] 20. the interact handler writes VAR_RESULT") +do + local store = { flags = {}, vars = {} } + local session = { store = store, map = Deoxys.MAP_ID, party = {} } + local saved = package.loaded["src.core.game3.runtime"] + package.loaded["src.core.game3.runtime"] = { + getSession = function() return session end, + isActive = function() return true end, + } + local ctx = { specialVars = {} } + local yield = Events.HANDLERS[Std.SPECIAL.DoDeoxysTriangleInteraction](ctx, {}) + eq(yield, false, "DoDeoxysTriangleInteraction does not yield") + eq(Flags.getVar(store, ctx, 0x800D), 1, "VAR_RESULT is 1 (advanced)") + eq(Flags.getVar(store, ctx, Deoxys.VAR_DEOXYS_INTERACTION_NUM), 1, + "the rock advanced") + package.loaded["src.core.game3.runtime"] = saved +end + +print("[test] 21. SetDeoxysTrianglePalette no longer no-ops") +do + local store = { flags = {}, vars = {} } + local session = { store = store, map = Deoxys.MAP_ID, party = {} } + local saved = package.loaded["src.core.game3.runtime"] + package.loaded["src.core.game3.runtime"] = { + getSession = function() return session end, + isActive = function() return true end, + } + Flags.setVar(store, nil, Deoxys.VAR_DEOXYS_INTERACTION_NUM, 5) + local ctx = { specialVars = {} } + local yield = Events.HANDLERS[Std.SPECIAL.SetDeoxysTrianglePalette](ctx, {}) + eq(yield, false, "SetDeoxysTrianglePalette does not yield") + eq(Flags.getVar(store, ctx, 0x800D), 0, "and still leaves VAR_RESULT at 0") + package.loaded["src.core.game3.runtime"] = saved +end + +print("[test] 22. the special is registered with the natives dispatcher") +do + local ok, _, known = Natives.special({ specialVars = {} }, + Std.SPECIAL.DoDeoxysTriangleInteraction, {}) + check(ok == false, "the dispatcher reports no yield") + eq(known, true, "and reports the special as known") +end + +print("[test] 23. Deoxys keeps its own battle theme") +do + local Audio = require("src.core.game3.audio") + -- pokefirered/src/battle_setup.c:349 StartLegendaryBattle + eq(Audio.legendaryBattleSong(Deoxys.SPECIES_DEOXYS), Deoxys.MUS_VS_DEOXYS, + "SPECIES_DEOXYS plays MUS_VS_DEOXYS") + eq(Audio.legendaryBattleSong(150), 340, "SPECIES_MEWTWO plays MUS_VS_MEWTWO") + eq(Audio.legendaryBattleSong(249), 341, "SPECIES_LUGIA plays MUS_VS_LEGEND") + eq(Audio.legendaryBattleSong(250), 341, "SPECIES_HO_OH plays MUS_VS_LEGEND") + eq(Audio.legendaryBattleSong(144), 341, "SPECIES_ARTICUNO plays MUS_VS_LEGEND") + eq(Audio.legendaryBattleSong(145), 341, "SPECIES_ZAPDOS plays MUS_VS_LEGEND") + eq(Audio.legendaryBattleSong(146), 341, "SPECIES_MOLTRES plays MUS_VS_LEGEND") + check(Audio.legendaryBattleSong(386) == nil, + "386 is SPECIES_VOLBEAT, an ordinary wild mon, and gets no legendary theme") + check(Audio.legendaryBattleSong(25) == nil, "Pikachu gets no legendary theme") + check(Audio.legendaryBattleSong(nil) == nil, "a missing species is safe") +end + +print("[test] 24. seteventmon -> CreateEnemyEventMon -> StartLegendaryBattle") +do + local Enc = require("src.core.game3.encounters") + local savedRuntime = package.loaded["src.core.game3.runtime"] + local savedSpace = package.loaded["src.core.game3.scripting.space"] + local store = { flags = {}, vars = {} } + local session = { store = store, map = Deoxys.MAP_ID, party = {} } + package.loaded["src.core.game3.scripting.space"] = nil + package.loaded["src.core.game3.runtime"] = { + getSession = function() return session end, + isActive = function() return true end, + } + + -- pokefirered/data/event_scripts.s: seteventmon SPECIES_DEOXYS, 30 + local ctx = { specialVars = {} } + Flags.setVar(store, ctx, 0x8004, Deoxys.SPECIES_DEOXYS) + Flags.setVar(store, ctx, 0x8005, 30) + Flags.setVar(store, ctx, 0x8006, 0) + Enc.takePendingWild() + + local create = Events.HANDLERS[Std.SPECIAL.CreateEnemyEventMon] + check(create ~= nil, "CreateEnemyEventMon is handled") + eq(create(ctx), false, "CreateEnemyEventMon does not yield") + local pending = Enc._pendingWild + check(pending ~= nil, "the event mon is queued for the battle") + eq(pending and pending.species, Deoxys.SPECIES_DEOXYS, "the queued mon is Deoxys") + eq(pending and pending.level, 30, "at level 30") + check(pending and pending.fatefulEncounter == true, "as a fateful encounter") + + local started = nil + local adapters = { + startWildBattle = function(foe, _cb, o) started = { foe = foe, opts = o } end, + } + local launch = Natives.ALLOW["special:" .. Std.SPECIAL.StartLegendaryBattle] + check(launch ~= nil, "StartLegendaryBattle is handled") + eq(launch(ctx, adapters), true, "StartLegendaryBattle yields to the battle") + check(started ~= nil, "a wild battle was started") + eq(started and started.foe.species, Deoxys.SPECIES_DEOXYS, + "against SPECIES_DEOXYS") + eq(started and started.foe.level, 30, "at level 30") + check(started and started.foe.legendary == true, "flagged legendary") + check(started and started.foe.specialWild == true, + "flagged a scripted wild battle") + check(started and started.opts and started.opts.legendary == true, + "startWildBattle is told this is a legendary battle") + check(Enc.takePendingWild() == nil, "the pending mon was consumed") + + package.loaded["src.core.game3.runtime"] = savedRuntime + package.loaded["src.core.game3.scripting.space"] = savedSpace +end + +if failed > 0 then + print("[test] FAILED " .. failed) + os.exit(1) +end +print("[test] all passed") diff --git a/tests/game3_special_events_test.lua b/tests/game3_special_events_test.lua index ae102b68..6dc4012e 100644 --- a/tests/game3_special_events_test.lua +++ b/tests/game3_special_events_test.lua @@ -114,6 +114,7 @@ local EXPECTED = { ForcePlayerToStartSurfing = 0x161, DisableMsgBoxWalkaway = 0x171, SetPostgameFlags = 0x19A, + DoDeoxysTriangleInteraction = 0x1AB, SetDeoxysTrianglePalette = 0x1AC, UpdateLoreleiDollCollection = 0x1B9, CreateEnemyEventMon = 0x1BB, @@ -297,7 +298,7 @@ print("[test] 13. cited no-ops return without yielding") local NOOPS = { "ShowFieldMessageStringVar4", "ShakeScreen", "InitRoamer", "SampleResortGorgeousMonAndReward", "DisableMsgBoxWalkaway", - "SetDeoxysTrianglePalette", "UpdateLoreleiDollCollection", + "UpdateLoreleiDollCollection", } for _, name in ipairs(NOOPS) do local ctx = newCtx() From 29d8492d4ebb87a6dae28bf79ade9e19f875e847 Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Mon, 21 Sep 2026 19:23:16 +0100 Subject: [PATCH 2/5] fix(game3): redden the Deoxys rock and shatter it before Deoxys appears Two Birth Island visuals were missing against pokefirered. The rock never reddened as the puzzle progressed. pret loads sDeoxysObjectPals[num] into the rock's OBJ palette (field_specials.c:2398); the engine can only substitute colours into the already-baked sprite, and the ramp it matched against held the .pal ASCII values (32/82/139) while the ROM bakes 33/82/140, so almost every pixel missed the lookup. ROCK_PALS now holds the 8-bit expansion of the ROM ramp at 0x3F6206 (verified byte-exact against all 11 palettes), sourcePalette() supplies the pristine colours the meteorite is baked with, and recolour_sprite falls back to the nearest source colour within a small tolerance so a one-unit rounding difference can never silently kill the swap again. A swap that matches nothing now returns nil and says so. The rock vanished instead of shattering. FLDEFF_DESTROY_DEOXYS_ROCK already ran the camera shake and thunder, but the fragment stage had no artwork and moved the shards wrongly. Added the four 8x8 tiles from ROM 0x3CBDB0 with palette 0x3F6346 (ramp step 10, the awakened red pret inherits from the rock); decoding them through the field-effect extractor reproduces the original graphics exactly, so no nibble handling was needed. The shards now all spawn at the rock's own corner and travel +/-16 x / +/-12 y per frame with no gravity (field_effect.c:3993), dropping out once they leave the viewport, while the shake decays from amplitude 4 and the rock is removed. Cache version 111 forces existing caches to re-import so the new sheet appears. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/core/game3/deoxys.lua | 43 ++++++++---- src/core/game3/field_effects.lua | 93 +++++++++++++++++------- src/core/game3/ow_sprites.lua | 37 ++++++++-- src/import/CacheContract.lua | 2 + src/import/gba/versions.lua | 12 +++- tests/game3_deoxys_test.lua | 117 +++++++++++++++++++++++++++++-- 6 files changed, 257 insertions(+), 47 deletions(-) diff --git a/src/core/game3/deoxys.lua b/src/core/game3/deoxys.lua index fd850613..ae7eb00b 100644 --- a/src/core/game3/deoxys.lua +++ b/src/core/game3/deoxys.lua @@ -51,23 +51,38 @@ Deoxys.COORDS = { -- pokefirered/src/field_specials.c:2346 — indexed by num-1, num in 1..10. Deoxys.STEP_CAPS = { 4, 8, 8, 8, 4, 4, 4, 6, 3, 3 } --- pokefirered/graphics/field_specials/deoxys_rock_N.pal, entries 1..3. Entry 0 is --- the transparent OBJ index and is never drawn, so only these three matter. +-- pret sDeoxysObjectPals (pokefirered/src/field_specials.c:2321, INCBIN of +-- graphics/field_specials/deoxys_rock_N.gbapal), ROM 0x3F6206: eleven 16-colour +-- BGR555 palettes. Entry 0 is the transparent OBJ index and is never drawn, so +-- only entries 1..3 are listed. +-- +-- These are the 8-bit values the GBA 5-bit channels expand to (floor(x*255/31 +-- + 0.5)), which is exactly what the extracted overworld sprites are baked +-- with. They are NOT the raw ASCII numbers in the .pal files: those differ by +-- a unit or two and an exact-match recolour LUT would silently miss them. Deoxys.ROCK_PALS = { - { { 32, 32, 32 }, { 82, 82, 82 }, { 139, 139, 139 } }, - { { 41, 32, 32 }, { 82, 82, 82 }, { 139, 139, 139 } }, - { { 49, 32, 32 }, { 90, 82, 82 }, { 148, 148, 139 } }, - { { 65, 32, 32 }, { 115, 82, 82 }, { 156, 148, 139 } }, - { { 74, 32, 32 }, { 123, 82, 82 }, { 164, 156, 139 } }, - { { 98, 32, 32 }, { 139, 82, 82 }, { 172, 156, 139 } }, - { { 98, 32, 32 }, { 148, 82, 82 }, { 180, 164, 139 } }, - { { 106, 32, 32 }, { 156, 82, 82 }, { 189, 164, 139 } }, - { { 123, 32, 32 }, { 172, 82, 82 }, { 197, 172, 148 } }, - { { 131, 32, 32 }, { 180, 82, 82 }, { 205, 172, 148 } }, - { { 205, 32, 32 }, { 255, 82, 82 }, { 255, 205, 156 } }, + { { 33, 33, 33 }, { 82, 82, 82 }, { 140, 140, 140 } }, + { { 41, 33, 33 }, { 82, 82, 82 }, { 140, 140, 140 } }, + { { 49, 33, 33 }, { 90, 82, 82 }, { 148, 148, 140 } }, + { { 66, 33, 33 }, { 115, 82, 82 }, { 156, 148, 140 } }, + { { 74, 33, 33 }, { 123, 82, 82 }, { 165, 156, 140 } }, + { { 99, 33, 33 }, { 140, 82, 82 }, { 173, 156, 140 } }, + { { 99, 33, 33 }, { 148, 82, 82 }, { 181, 165, 140 } }, + { { 107, 33, 33 }, { 156, 82, 82 }, { 189, 165, 140 } }, + { { 123, 33, 33 }, { 173, 82, 82 }, { 197, 173, 148 } }, + { { 132, 33, 33 }, { 181, 82, 82 }, { 206, 173, 148 } }, + { { 206, 33, 33 }, { 255, 82, 82 }, { 255, 206, 156 } }, } Deoxys.PALETTE_COUNT = #Deoxys.ROCK_PALS +--- The colours the pristine meteorite sprite is drawn with (palette 0), i.e. the +--- source side of every recolour. pret gets this for free because it loads the +--- whole 4-colour palette; the engine bakes the sprite once, so it has to be +--- told which colours to swap out. +function Deoxys.sourcePalette() + return Deoxys.ROCK_PALS[1] +end + --- Clamp an interaction num to the 0..10 range the coordinate/palette tables use. function Deoxys.clamp(num) num = math.floor(tonumber(num) or 0) @@ -197,7 +212,7 @@ function Deoxys.applyRockPalette(num, graphicsId) graphicsId = tonumber(graphicsId) if graphicsId == nil then return false end return OwSprites.setObjectPalette(graphicsId, Deoxys.paletteKey(num), - Deoxys.palette(num)) + Deoxys.palette(num), Deoxys.sourcePalette()) end --- pret MoveDeoxysObject (field_specials.c:2362). diff --git a/src/core/game3/field_effects.lua b/src/core/game3/field_effects.lua index 3c2a7fb7..604d5baf 100644 --- a/src/core/game3/field_effects.lua +++ b/src/core/game3/field_effects.lua @@ -28,6 +28,13 @@ FieldEffects.FLDEFF_DESTROY_DEOXYS_ROCK = 68 local SE_THUNDER2 = 81 local SE_THUNDER = 80 +-- How far a Deoxys rock shard travels before it counts as off-screen and is +-- dropped. pret destroys each fragment once it leaves the 240x160 viewport +-- (field_effect.c:4015); the engine draws in world space, so this is a +-- viewport-sized bound around the shard's spawn point instead. +local FRAG_TRAVEL_X = 260 +local FRAG_TRAVEL_Y = 200 + local CELL = 16 local FEET_H = 8 local RUSTLE = { 1, 2, 3, 4, 0 } @@ -435,31 +442,48 @@ function FieldEffects.startMoveDeoxysRock(localId, x, y, frames) return anim end ---- pokefirered/src/field_effect.c:3860 — camera shake, thunder, then the rock +--- pokefirered/src/field_effect.c:3840 — camera shake, thunder, then the rock --- shatters into four fragments and is removed from the map. function FieldEffects.startDestroyDeoxysRock(localId, graphicsId) local Objects = package.loaded["src.core.game3.objects"] local eo = Objects and Objects.find and Objects.find(localId) if not eo then return nil end local x, y = eo.px or 0, eo.py or 0 - load_sheet("deoxys_rock_fragments", 8, 8, 4) + graphicsId = graphicsId or eo.graphicsId + + -- pokefirered/src/field_effect.c:3975 CreateDeoxysRockFragments: all four + -- shards start at the rock's own top-left corner (4px higher) and fly apart. + -- eo.px/py is the cell's foot point, so undo the offset OwSprites.draw adds. + local originX, originY = x - 8, y - 20 + local okO, OwSprites = pcall(require, "src.core.game3.ow_sprites") + if okO and OwSprites and OwSprites.getDraw then + local spr = OwSprites.getDraw(graphicsId) + if spr and spr.width and spr.height then + originX = x + (16 - spr.width) / 2 + originY = y + 16 - spr.height - 4 + end + end + + -- pokefirered/src/field_effect.c:3993 SpriteCB_DeoxysRockFragment: fixed + -- ±16 x / ±12 y per frame, no gravity, one shard per diagonal. local frags = {} - -- pokefirered/src/field_effect.c:3884 sRockFragment_TopLeft/TopRight/BottomLeft/BottomRight - local offsets = { { 0, 0 }, { 8, 0 }, { 0, 8 }, { 8, 8 } } local dirs = { { -1, -1 }, { 1, -1 }, { -1, 1 }, { 1, 1 } } for i = 1, 4 do frags[i] = { frame = i - 1, - x = x + offsets[i][1], - y = y + offsets[i][2], - dx = dirs[i][1] * 2, - dy = dirs[i][2] * 2, + x = originX, + y = originY, + ox = originX, + oy = originY, + dx = dirs[i][1] * 16, + dy = dirs[i][2] * 12, + off = false, } end local anim = { kind = "deoxys_rock_destroy", localId = localId, - graphicsId = graphicsId or eo.graphicsId, + graphicsId = graphicsId, x = x, y = y, px = x, @@ -1000,10 +1024,12 @@ function FieldEffects.step() end elseif anim.kind == "deoxys_rock_destroy" then -- pokefirered/src/field_effect.c:3860 DestroyDeoxysRockEffect_* + local FieldView = fieldView() if anim.state == "shake" then - local FieldView = fieldView() + -- Task_DeoxysRockCameraShake (data[7]==0): full amplitude, sign flips + -- every other frame. if FieldView and FieldView.setCameraPanning then - FieldView.setCameraPanning(0, (anim.timer % 2 == 0) and 2 or -2) + FieldView.setCameraPanning(0, (anim.timer % 2 == 0) and 4 or -4) end if anim.timer >= 120 then local Objects = package.loaded["src.core.game3.objects"] @@ -1014,19 +1040,36 @@ function FieldEffects.step() eo.hidden = true eo.visible = false end - if FieldView and FieldView.setCameraPanning then FieldView.setCameraPanning(0, 0) end FieldEffects.startWhiteFlash() play_se(SE_THUNDER) - anim.state = "fragments" + anim.state = "shatter" anim.timer = 0 + anim.amp = 4 end - elseif anim.state == "fragments" then + elseif anim.state == "shatter" then + -- The shards fly out while the shake decays (StartEndingDeoxysRock + -- CameraShake + the data[7]!=0 half of Task_DeoxysRockCameraShake). for _, f in ipairs(anim.frags) do - f.x = f.x + f.dx - f.y = f.y + f.dy - f.dy = f.dy + 0.5 -- gravity + if not f.off then + f.x = f.x + f.dx + f.y = f.y + f.dy + if math.abs(f.x - f.ox) > FRAG_TRAVEL_X + or math.abs(f.y - f.oy) > FRAG_TRAVEL_Y then + f.off = true + end + end end - if anim.timer >= 60 then + if anim.timer > 0 and anim.timer % 21 == 0 and anim.amp > 0 then + anim.amp = anim.amp - 1 + end + if FieldView and FieldView.setCameraPanning then + FieldView.setCameraPanning(0, + (anim.timer % 2 == 0) and anim.amp or -anim.amp) + end + if anim.amp <= 0 then + if FieldView and FieldView.setCameraPanning then + FieldView.setCameraPanning(0, 0) + end local Objects = package.loaded["src.core.game3.objects"] if Objects and Objects.removeObject then Objects.removeObject(anim.localId) end finished = true @@ -1196,15 +1239,17 @@ function FieldEffects.drawFront(camX, camY, playerPy) end end end - elseif anim.kind == "deoxys_rock_destroy" and anim.state == "fragments" then - -- pokefirered/src/field_effect.c:3893 CreateDeoxysRockFragments (4x 8x8 sprites) + elseif anim.kind == "deoxys_rock_destroy" and anim.state == "shatter" then + -- pokefirered/src/field_effect.c:3975 CreateDeoxysRockFragments (4x 8x8 sprites) local sheet = load_sheet("deoxys_rock_fragments", 8, 8, 4) if sheet then for _, f in ipairs(anim.frags or {}) do - local q = sheet.quads[f.frame] - if q then - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(sheet.image, q, f.x - camX, f.y - camY) + if not f.off then + local q = sheet.quads[f.frame] + if q then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(sheet.image, q, f.x - camX, f.y - camY) + end end end end diff --git a/src/core/game3/ow_sprites.lua b/src/core/game3/ow_sprites.lua index b1edf6d0..e935dfad 100644 --- a/src/core/game3/ow_sprites.lua +++ b/src/core/game3/ow_sprites.lua @@ -132,8 +132,14 @@ local function to8(v) return v end +-- How far (per channel) a baked sprite colour may sit from the palette entry it +-- is meant to match before the swap gives up. The rock ramp's entries are +-- always tens of units apart, so this can never select the wrong colour. +local NEAREST_TOL = 4 + --- Build a copy of `spr` with the colours in `from` replaced by `to`. ---- Returns nil when image data is unavailable (e.g. headless tests). +--- Returns nil when image data is unavailable (e.g. headless tests) or when not +--- a single pixel matched (a silent no-op swap is always a bug). local function recolour_sprite(spr, from, to) if not (love and love.image and love.image.newImageData and love.graphics and love.graphics.newImage) then @@ -144,25 +150,48 @@ local function recolour_sprite(spr, from, to) if not (okData and data) then return nil end -- Colour-keyed LUT so the per-pixel work stays a single table lookup. - local lut = {} + local lut, sources = {}, {} for i = 1, #from do local a, b = from[i], to[i] if a and b then lut[(a[1] * 65536) + (a[2] * 256) + a[3]] = b + sources[#sources + 1] = { a[1], a[2], a[3], b } end end if not next(lut) then return nil end + -- Sprites are baked from 5-bit GBA channels, so a stored colour can sit a + -- unit or two away from the palette it was authored with. Fall back to the + -- nearest source within NEAREST_TOL: the palette entries we swap between are + -- tens of units apart, so this cannot pick the wrong one. + local replaced = 0 local okMap = pcall(function() data:mapPixel(function(_, _, r, g, b, a) if a <= 0 then return r, g, b, a end - local key = (to8(r) * 65536) + (to8(g) * 256) + to8(b) - local c = lut[key] + local r8, g8, b8 = to8(r), to8(g), to8(b) + local c = lut[(r8 * 65536) + (g8 * 256) + b8] + if not c then + local best, bestD + for _, e in ipairs(sources) do + local d = (e[1] - r8) ^ 2 + (e[2] - g8) ^ 2 + (e[3] - b8) ^ 2 + if d <= NEAREST_TOL * NEAREST_TOL * 3 and (bestD == nil or d < bestD) then + best, bestD = e[4], d + end + end + c = best + end if not c then return r, g, b, a end + replaced = replaced + 1 return c[1] / 255, c[2] / 255, c[3] / 255, a end) end) if not okMap then return nil end + if replaced == 0 then + print(string.format( + "[game3/ow] palette swap matched no pixels against %d source colour(s)", + #sources)) + return nil + end local okImg, img = pcall(love.graphics.newImage, data) if not (okImg and img) then return nil end diff --git a/src/import/CacheContract.lua b/src/import/CacheContract.lua index b87323f4..3e1c7d5a 100644 --- a/src/import/CacheContract.lua +++ b/src/import/CacheContract.lua @@ -290,6 +290,8 @@ CacheContract.VERSION_REQUIRED_FILES_OVERRIDE = { "data/generated/gba/field_effects/surf_blob.rgba", "data/generated/gba/field_effects/fly_bird.rgba", "data/generated/gba/field_effects/ripple.rgba", + -- src/field_effect.c:3963 (Birth Island meteorite shatter shards) + "data/generated/gba/field_effects/deoxys_rock_fragments.rgba", -- src/data/field_effects/field_effect_objects.h:565,1203 "data/generated/gba/field_effects/splash.rgba", "data/generated/gba/field_effects/hot_springs_water.rgba", diff --git a/src/import/gba/versions.lua b/src/import/gba/versions.lua index b9b07a4f..bbd1c228 100644 --- a/src/import/gba/versions.lua +++ b/src/import/gba/versions.lua @@ -27,7 +27,9 @@ Versions.ROM_SIZE = 16777216 -- v99: gEggMoves → pokemon/egg_moves.lua (hidden-mon egg moves were inert). -- v100: location preview screens (sMapPreviewScreenData artwork) + ROM-derived -- mapsec names and sDungeonInfo dungeon descriptions. -Versions.CACHE_VERSION = 110 +-- v111: deoxys_rock_fragments field effect — the Birth Island meteorite shatter +-- had no artwork, so the rock simply vanished instead of breaking apart. +Versions.CACHE_VERSION = 111 Versions.NATIVE_VERSION = 6 Versions.OW_VERSION = 1 Versions.ANIM_VERSION = 1 @@ -1384,6 +1386,14 @@ Versions.FIELD_EFFECTS = { pokemoncenter_monitor = { pic = 0x3CAFD0, pal = 0x3CAFB0, w = 32, h = 16, frames = 4, }, + -- pokefirered/src/field_effect.c:3963 sImages_DeoxysRockFragment + -- (graphics/field_effects/pics/deoxys_rock_fragment_*.png, 4x 8x8 4bpp). + -- The palette is sDeoxysObjectPals[10] (0x3F6206 + 10*32), the fully + -- awakened red ramp step — the puzzle is always solved by the time the rock + -- shatters, so the shards are always red. + deoxys_rock_fragments = { + pic = 0x3CBDB0, pal = 0x3F6346, w = 8, h = 8, frames = 4, + }, } -- Battle interface chrome (FireRed USA 1.0 file offsets; LZ unless noted). diff --git a/tests/game3_deoxys_test.lua b/tests/game3_deoxys_test.lua index e31dbfb9..814a7406 100644 --- a/tests/game3_deoxys_test.lua +++ b/tests/game3_deoxys_test.lua @@ -113,9 +113,27 @@ for i = 1, 11 do end end check(mono, "each palette ascends in luminance 1 < 2 < 3") -eq(Deoxys.ROCK_PALS[1][1][1], 32, "palette 0 index 1 is the meteorite's own 32,32,32") -eq(Deoxys.ROCK_PALS[11][1][1], 205, "palette 10 index 1 is the awakened 205,32,32") -eq(Deoxys.ROCK_PALS[11][3][1], 255, "palette 10 index 3 is the brightest 255,205,156") +-- ROM 0x3F6206 sDeoxysObjectPals, 8-bit values as the GBA expands them. +-- These are what the extracted meteorite sprite is baked with, so an exact +-- recolour match depends on them; the old JASC .pal ASCII values (32/82/139) +-- silently missed and the rock never reddened. +local ROM_PAL_0 = { { 33, 33, 33 }, { 82, 82, 82 }, { 140, 140, 140 } } +local ROM_PAL_10 = { { 206, 33, 33 }, { 255, 82, 82 }, { 255, 206, 156 } } +eq(Deoxys.ROCK_PALS[1][1][1], 33, "palette 0 index 1 is the meteorite's own 33,33,33") +eq(Deoxys.ROCK_PALS[11][1][1], 206, "palette 10 index 1 is the awakened 206,33,33") +eq(Deoxys.ROCK_PALS[11][3][1], 255, "palette 10 index 3 is the brightest 255,206,156") +local rampsMatch = true +for i = 1, 3 do + for c = 1, 3 do + if Deoxys.ROCK_PALS[1][i][c] ~= ROM_PAL_0[i][c] + or Deoxys.ROCK_PALS[11][i][c] ~= ROM_PAL_10[i][c] then + rampsMatch = false + end + end +end +check(rampsMatch, "the first and last ramps are byte-exact against the ROM") +eq(Deoxys.sourcePalette(), Deoxys.ROCK_PALS[1], + "sourcePalette() is the pristine meteorite palette the swap matches against") eq(Deoxys.palette(0), Deoxys.ROCK_PALS[1], "palette(0)") eq(Deoxys.palette(10), Deoxys.ROCK_PALS[11], "palette(10)") eq(Deoxys.paletteKey(3), "deoxys_rock_3", "paletteKey is stable for the sprite cache") @@ -360,7 +378,8 @@ do local destroyed = false FieldEffects.waitFieldEffect(Deoxys.FLDEFF_DESTROY_ROCK, function() destroyed = true end) check(not destroyed, "waitfieldeffect parks until the shatter finishes") - for _ = 1, 200 do FieldEffects.step() end + -- 120 frames of camera shake + ~85 frames of decaying shake with the shards. + for _ = 1, 260 do FieldEffects.step() end check(destroyed, "the waiter is released when the rock is gone") check(rock.hidden, "the rock object is removed from the map") check(not FieldEffects.isFieldEffectActive(Deoxys.FLDEFF_DESTROY_ROCK), @@ -482,6 +501,96 @@ do package.loaded["src.core.game3.scripting.space"] = savedSpace end +print("[test] 25. the shatter artwork is registered against the ROM") +do + local Versions = require("src.import.gba.versions") + local spec = Versions.FIELD_EFFECTS and Versions.FIELD_EFFECTS.deoxys_rock_fragments + check(spec ~= nil, "deoxys_rock_fragments is registered") + if spec then + eq(spec.pic, 0x3CBDB0, "the four 8x8 shard tiles come from ROM 0x3CBDB0") + eq(spec.w, 8, "each shard is 8px wide") + eq(spec.h, 8, "each shard is 8px tall") + eq(spec.frames, 4, "one frame per shard") + -- sDeoxysObjectPals[10] = the fully awakened red step; pret's shards + -- inherit the rock's paletteNum, and the puzzle is always solved by then. + eq(spec.pal, 0x3F6206 + 10 * 32, "the shards use ramp step 10 (red)") + check(spec.swapNibbles ~= true, "the ROM tiles are standard 4bpp, no nibble swap") + end + local CacheContract = require("src.import.CacheContract") + local required = CacheContract.VERSION_REQUIRED_FILES_OVERRIDE + and CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.firered + local listed = false + if type(required) == "table" then + for _, rel in ipairs(required) do + if rel == "data/generated/gba/field_effects/deoxys_rock_fragments.rgba" then + listed = true + end + end + end + check(listed, "the sheet is a required firered cache file, so a stale cache re-imports") +end + +print("[test] 26. the shatter spawns four shards that fly apart") +do + local Objects = require("src.core.game3.objects") + local savedById, savedOrder = Objects._byId, Objects._order + local savedMapId, savedPerm = Objects._mapId, Objects._perm + local rock = { + graphicsId = Deoxys.OBJ_EVENT_GFX_METEORITE, + px = 15 * 16, py = 12 * 16, cellX = 15, cellY = 12, + homeX = 15, homeY = 12, targetX = 15, targetY = 12, + def = { x = 15, y = 12, localId = 1 }, + } + Objects._byId = { [1] = rock } + Objects._order = { 1 } + Objects._mapId = Deoxys.MAP_ID + Objects._perm = {} + + check(Deoxys.destroyRock(1), "destroyRock starts the shatter") + local anim + for _, a in ipairs(FieldEffects._anims or {}) do + if a.kind == "deoxys_rock_destroy" then anim = a end + end + check(anim ~= nil, "the destroy animation is queued") + if anim then + eq(anim.state, "shake", "it starts with the camera shake") + eq(#(anim.frags or {}), 4, "four shards are spawned") + -- pret CreateDeoxysRockFragments: all four at the SAME point. + local sameOrigin = true + for _, f in ipairs(anim.frags) do + if f.ox ~= anim.frags[1].ox or f.oy ~= anim.frags[1].oy then sameOrigin = false end + end + check(sameOrigin, "all four shards start at the rock's own corner") + local dirs = { { -16, -12 }, { 16, -12 }, { -16, 12 }, { 16, 12 } } + local dirsOk = true + for i, f in ipairs(anim.frags) do + if f.dx ~= dirs[i][1] or f.dy ~= dirs[i][2] then dirsOk = false end + end + check(dirsOk, "the shards fly out at +/-16 x and +/-12 y per frame") + + for _ = 1, 130 do FieldEffects.step() end + eq(anim.state, "shatter", "after 120 frames the rock shatters") + check(rock.hidden or rock.invisible, "the rock is hidden before Deoxys appears") + local moved = true + for i, f in ipairs(anim.frags) do + if f.off then + moved = false + else + local travelled = (f.x - f.ox) * dirs[i][1] + (f.y - f.oy) * dirs[i][2] + if travelled <= 0 then moved = false end + end + end + check(moved, "each shard has travelled along its own diagonal") + + for _ = 1, 200 do FieldEffects.step() end + check(not FieldEffects.isFieldEffectActive(Deoxys.FLDEFF_DESTROY_ROCK), + "the shatter effect finishes once the shake dies down") + end + + Objects._byId, Objects._order = savedById, savedOrder + Objects._mapId, Objects._perm = savedMapId, savedPerm +end + if failed > 0 then print("[test] FAILED " .. failed) os.exit(1) From 6d3af84fde42725481d56bfe087ecda77c900b23 Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Mon, 21 Sep 2026 19:39:12 +0100 Subject: [PATCH 3/5] fix(game3): make the Deoxys rock redden and its shards visible Two Birth Island visuals were broken; both are now verified on screen. 1. The meteorite never reddened as the triangle puzzle progressed. OwSprites.recolour_sprite called Image:newImageData, which LOVE 11 does not have, so the pcall always failed and setObjectPalette silently returned false for all ten steps. Keep the ImageData the sheet was decoded from in load_one and clone() it before mapPixel (which mutates in place); the override copy keeps its imageData in step too. 2. The shatter happened, but the shards were invisible. The flash was a whole-screen Renderer.screenVeil painted after the composite, and the shards only live ~8 frames, so they were drawn underneath it. pret (field_effect.c:3946) blends PALETTES_BG only -- BlendPalettes plus BeginNormalPaletteFade -- so the OBJ shards, which share the meteorite's palette tag 4371, keep their colours against a white map. Replace startWhiteFlash with startBgFlash, drawn by FieldView.draw between the last map layer and the actors. Also match pret's shake cadence (flip every second frame) and its `++tTimer > 120` test. Adds tests/drivers/game3_deoxys_visual.lua, which walks all eleven puzzle positions, shatters the rock, and counts shard-coloured pixels in the saved frames: 492 px at the shatter frame, 192 px a few frames later (0 before this fix). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/core/game3/field_effects.lua | 41 +++- src/core/game3/field_view.lua | 17 ++ src/core/game3/ow_sprites.lua | 19 +- tests/drivers/game3_deoxys_visual.lua | 288 ++++++++++++++++++++++++++ 4 files changed, 353 insertions(+), 12 deletions(-) create mode 100644 tests/drivers/game3_deoxys_visual.lua diff --git a/src/core/game3/field_effects.lua b/src/core/game3/field_effects.lua index 604d5baf..b85bc171 100644 --- a/src/core/game3/field_effects.lua +++ b/src/core/game3/field_effects.lua @@ -394,18 +394,38 @@ function FieldEffects.startSweetScent(onDone) table.insert(FieldEffects._anims, anim) end ---- pokefirered/src/field_effect.c:3878 — BlendPalettes(PALETTES_BG, 0x10, RGB_WHITE). -function FieldEffects.startWhiteFlash(duration) +-- pokefirered/src/field_effect.c:3946 — the Deoxys shatter whites the MAP out: +-- BlendPalettes(PALETTES_BG, 0x10, RGB_WHITE); +-- BeginNormalPaletteFade(PALETTES_BG, 0, 0x10, 0, RGB_WHITE); +-- PALETTES_BG only, so the four rock fragments — OBJ sprites sharing the +-- meteorite's palette tag 4371 — keep their colours and stay visible against +-- the white map while they fly. That is why this is a background veil painted +-- between the map layers and the actors, and not a whole-screen "flash" like +-- the one FldEff_PhotoFlash uses (that one really is PALETTES_ALL). +-- gPaletteFade.y steps by 2, so 0x10 -> 0 is 8 frames. +local BG_FLASH_FRAMES = 8 + +function FieldEffects.startBgFlash(duration) local anim = { - kind = "flash", - alpha = 1.0, + kind = "bg_flash", timer = 0, - maxDur = math.max(1, math.floor(tonumber(duration) or 16)), + maxDur = math.max(1, math.floor(tonumber(duration) or BG_FLASH_FRAMES)), } table.insert(FieldEffects._anims, anim) return anim end +--- How white the map layers should be veiled this frame, 0..1. Read by +--- FieldView.draw between the tiles and the actors. +function FieldEffects.bgFlashAlpha() + for _, anim in ipairs(FieldEffects._anims) do + if anim.kind == "bg_flash" then + return math.max(0, 1.0 - (anim.timer / anim.maxDur)) + end + end + return 0 +end + -- ------------------------------------------------- Birth Island Deoxys effects --- pokefirered/src/field_effect.c:3722 — slide the meteorite object to (x, y). @@ -884,7 +904,7 @@ function FieldEffects.step() if anim.timer >= anim.maxDur then finished = true end - elseif anim.kind == "flash" then + elseif anim.kind == "flash" or anim.kind == "bg_flash" then anim.alpha = math.max(0, 1.0 - (anim.timer / anim.maxDur)) if anim.timer >= anim.maxDur then finished = true @@ -1027,11 +1047,12 @@ function FieldEffects.step() local FieldView = fieldView() if anim.state == "shake" then -- Task_DeoxysRockCameraShake (data[7]==0): full amplitude, sign flips - -- every other frame. + -- when data[0] passes 1, i.e. every other frame. if FieldView and FieldView.setCameraPanning then - FieldView.setCameraPanning(0, (anim.timer % 2 == 0) and 4 or -4) + FieldView.setCameraPanning(0, (math.floor(anim.timer / 2) % 2 == 0) and 4 or -4) end - if anim.timer >= 120 then + -- DestroyDeoxysRockEffect_RockFragments: `if (++tTimer > 120)`. + if anim.timer > 120 then local Objects = package.loaded["src.core.game3.objects"] local eo = Objects and Objects.find and Objects.find(anim.localId) if eo then @@ -1040,7 +1061,7 @@ function FieldEffects.step() eo.hidden = true eo.visible = false end - FieldEffects.startWhiteFlash() + FieldEffects.startBgFlash() play_se(SE_THUNDER) anim.state = "shatter" anim.timer = 0 diff --git a/src/core/game3/field_view.lua b/src/core/game3/field_view.lua index abf751a1..ff173aa6 100644 --- a/src/core/game3/field_view.lua +++ b/src/core/game3/field_view.lua @@ -1088,6 +1088,23 @@ function FieldView.draw(game, canvasW, canvasH, opts) end end + -- pokefirered/src/field_effect.c:3946: the Deoxys shatter blends only the BG + -- palettes to white, so the map washes out while the rock fragments (OBJ + -- sprites) keep their colours. Painted here, between the last map layer and + -- the actors, for exactly that reason -- a Renderer.screenVeil would cover + -- the fragments too and the shatter would be invisible. + if not opts.actorsOnly then + local okFx, FieldEffects = pcall(require, "src.core.game3.field_effects") + if okFx and FieldEffects and FieldEffects.bgFlashAlpha then + local a = FieldEffects.bgFlashAlpha() + if a and a > 0 then + love.graphics.setColor(1, 1, 1, a) + love.graphics.rectangle("fill", 0, 0, canvasW, canvasH) + love.graphics.setColor(1, 1, 1, 1) + end + end + end + -- Game3 EventObjects when spawned; else live World entities; else static defs. if not opts.skipActors and not drawGame3Actors(game, camX, camY, px, py, facing, walkPhase, stepFlip, playerYOff, playerXOff) and not drawWorldEntities(world, camX, camY) then diff --git a/src/core/game3/ow_sprites.lua b/src/core/game3/ow_sprites.lua index e935dfad..4f054444 100644 --- a/src/core/game3/ow_sprites.lua +++ b/src/core/game3/ow_sprites.lua @@ -86,6 +86,9 @@ local function load_one(gid) end return { image = image, + -- Kept so setObjectPalette can recolour the sheet: LOVE 11 has no + -- Image:newImageData, so the decoded pixels are the only CPU-side source. + imageData = imageData, quads = quads, width = w, height = h, @@ -146,8 +149,19 @@ local function recolour_sprite(spr, from, to) return nil end if not (spr and spr.image) then return nil end - local okData, data = pcall(function() return spr.image:newImageData() end) - if not (okData and data) then return nil end + -- LOVE 11 exposes no Image:newImageData, so recolour from the ImageData the + -- sheet was decoded from. Clone it: mapPixel mutates in place and the cached + -- sprite has to keep its own colours for the next swap. + local data + if spr.imageData then + local okClone, copy = pcall(function() return spr.imageData:clone() end) + if okClone then data = copy end + end + if not data then + local okData, d = pcall(function() return spr.image:newImageData() end) + if okData then data = d end + end + if not data then return nil end -- Colour-keyed LUT so the per-pixel work stays a single table lookup. local lut, sources = {}, {} @@ -200,6 +214,7 @@ local function recolour_sprite(spr, from, to) local copy = {} for k, v in pairs(spr) do copy[k] = v end copy.image = img + copy.imageData = data return copy end diff --git a/tests/drivers/game3_deoxys_visual.lua b/tests/drivers/game3_deoxys_visual.lua new file mode 100644 index 00000000..3779dad7 --- /dev/null +++ b/tests/drivers/game3_deoxys_visual.lua @@ -0,0 +1,288 @@ +-- Visual reproduction harness for the Birth Island Deoxys triangle. +-- +-- Boots a fresh FireRed game, warps to Birth Island, walks the meteorite through +-- all eleven puzzle positions and then shatters it. A PNG is captured at every +-- stage and the rock's *drawn* colours are sampled straight out of the sprite +-- the renderer would use, so the palette ramp can be checked as data rather +-- than by eye alone. +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/deoxys_visual" + +local MAP = "FR_BIRTH_ISLAND_EXTERIOR" +local METEORITE = 106 + +local failures = 0 +local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end + +local function finish() + if failures == 0 then + print("PASS deoxys_visual") + love.event.quit(0) + else + print("FAIL deoxys_visual failures=" .. failures) + love.event.quit(1) + end +end + +--- Count pixels in a saved shot that match one of the rock fragment's palette +--- colours. The fragment sheet is imported with sDeoxysObjectPals[10] (the +--- awakened red ramp) and the map never contains those exact reds, so any hit +--- is a flying shard actually reaching the screen. This is what catches the +--- shards being buried under the shatter's white flash: with a whole-screen +--- veil the count is exactly 0. +local FRAG_RGB = { { 206, 33, 33 }, { 255, 82, 82 }, { 255, 206, 156 } } + +--- love.graphics.captureScreenshot writes the PNG with a plain io.open, which +--- can reach any absolute path -- but love.image.newImageData only reads paths +--- inside LOVE's sandboxed source/save filesystems, so it cannot open SHOT_DIR. +--- Read the bytes ourselves and hand LOVE a FileData instead. U.shot has +--- already confirmed the file is on disk, so this never needs to yield frames +--- (yielding here would let the shards fly off screen before they are measured). +local function fragPixels(path) + local f = io.open(path, "rb") + if not f then return -1 end + local bytes = f:read("*a") + f:close() + local ok, data = pcall(function() + return love.image.newImageData(love.filesystem.newFileData(bytes, "shot.png")) + end) + if not ok or not data then return -1 end + local w, h = data:getWidth(), data:getHeight() + local hits = 0 + for y = 0, h - 1, 2 do + for x = 0, w - 1, 2 do + local r, g, b = data:getPixel(x, y) + r, g, b = r * 255, g * 255, b * 255 + for _, c in ipairs(FRAG_RGB) do + if math.abs(r - c[1]) < 6 and math.abs(g - c[2]) < 6 + and math.abs(b - c[3]) < 6 then + hits = hits + 1 + break + end + end + end + end + return hits +end + +--- The distinct opaque colours actually present in the sprite the renderer +--- would draw for `graphicsId`, most common first. This reads the sprite the +--- renderer uses (so it reflects the palette override, not the module's own +--- tables), and the override keeps its ImageData in step with its texture. +local function drawnColours(OwSprites, graphicsId) + local spr = OwSprites.getDraw and OwSprites.getDraw(graphicsId) + if not spr then return nil end + local data + if spr.imageData then + local ok, copy = pcall(function() return spr.imageData:clone() end) + if ok then data = copy end + end + if not data then return nil end + local counts = {} + local okMap = pcall(function() + data:mapPixel(function(_, _, r, g, b, a) + if a > 0 then + local key = string.format("%d,%d,%d", + math.floor(r * 255 + 0.5), math.floor(g * 255 + 0.5), + math.floor(b * 255 + 0.5)) + counts[key] = (counts[key] or 0) + 1 + end + return r, g, b, a + end) + end) + if not okMap then return nil end + local list = {} + for key, n in pairs(counts) do list[#list + 1] = { key = key, n = n } end + table.sort(list, function(x, y) + if x.n == y.n then return x.key < y.key end + return x.n > y.n + end) + return list +end + +--- Comma-joined colour set, for set comparisons. +local function colourSet(list) + if not list then return nil end + local keys = {} + for _, e in ipairs(list) do keys[#keys + 1] = e.key end + table.sort(keys) + return table.concat(keys, " ") +end + +local function rampSet(pal) + if not pal then return nil end + local keys = {} + for _, c in ipairs(pal) do + keys[#keys + 1] = string.format("%d,%d,%d", c[1], c[2], c[3]) + end + table.sort(keys) + return table.concat(keys, " ") +end + +local function dominantRed(list) + if not (list and list[1]) then return nil end + return tonumber(list[1].key:match("^(%d+),")) +end + +return function(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(240) + + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Space = require("src.core.game3.scripting.space") + local Objects = require("src.core.game3.objects") + local Player = require("src.core.game3.player") + local Deoxys = require("src.core.game3.deoxys") + local FieldEffects = require("src.core.game3.field_effects") + local OwSprites = require("src.core.game3.ow_sprites") + + local session = Runtime.getSession() + if not result(session ~= nil, "new game reached the game3 field") then + return finish() + end + + local function goTo(mapId, x, y, facing) + Map.load(nil, game, mapId, { x = x, y = y, facing = facing or "up" }) + if game.session then + game.session.x, game.session.y, game.session.facing = x, y, facing or "up" + end + Player.cellX, Player.cellY = x, y + Player.px, Player.py = x * 16, y * 16 + Player.targetX, Player.targetY = x, y + Player.facing = facing or "up" + U.wait(30) + local Preview = package.loaded["src.ui.game3.map_preview_screen"] + for _ = 1, 240 do + if not (Preview and Preview.isActive and Preview.isActive()) then break end + U.wait(5) + end + U.wait(60) + end + + -- Stand one tile south of the rock's starting position (field_specials.c + -- COORDS[1] == {15,12}) so it is on camera. + goTo(MAP, 15, 13, "up") + + local rock, rockId = Deoxys.resolveRockObject() + result(rock ~= nil, "the Birth Island meteorite object is on the map") + if not rock then + U.shot(game, DIR .. "/00_no_rock.png") + return finish() + end + U.log("rock localId=" .. tostring(rockId) .. + " graphicsId=" .. tostring(rock.graphicsId) .. + " cell=(" .. tostring(rock.cellX or rock.x) .. "," .. + tostring(rock.cellY or rock.y) .. ")") + result(tonumber(rock.graphicsId) == METEORITE, + "the rock uses OBJ_EVENT_GFX_METEORITE (106), got " .. + tostring(rock.graphicsId)) + + U.shot(game, DIR .. "/01_position_0.png") + + local gid = tonumber(rock.graphicsId) + local base = OwSprites.get(gid) + result(base ~= nil and base.imageData ~= nil, + "the meteorite sprite keeps its decoded pixels for palette swaps") + + local before = drawnColours(OwSprites, gid) + U.log("position 0 drawn colours: " .. tostring(colourSet(before))) + result(colourSet(before) == rampSet(Deoxys.sourcePalette()), + "position 0 draws the source grey ramp " .. + tostring(rampSet(Deoxys.sourcePalette())) .. + ", got " .. tostring(colourSet(before))) + + -- ---------------------------------------------------------------- the ramp + -- Ten successful interactions walk the rock to position 10; the eleventh + -- awakens Deoxys (handled by the script, not here). + local seen, reds = {}, {} + for step = 1, 10 do + Deoxys.interact(session) + U.wait(45) -- let the move field effect finish + local colours = drawnColours(OwSprites, gid) + seen[step] = colourSet(colours) + reds[step] = dominantRed(colours) + U.log(string.format("position %d: key=%s colours=%s", step, + tostring(OwSprites.objectPaletteKey(gid)), tostring(seen[step]))) + U.shot(game, string.format("%s/02_position_%02d.png", DIR, step)) + end + + result(seen[1] ~= seen[10], + "the rock's drawn colours change across the puzzle") + + local monotonic = true + for step = 2, 10 do + if not (reds[step] and reds[step - 1] and reds[step] >= reds[step - 1]) then + monotonic = false + U.log(string.format("red went backwards at step %d: %s -> %s", + step, tostring(reds[step - 1]), tostring(reds[step]))) + end + end + result(monotonic, "the rock reddens monotonically across the ten steps") + + result(seen[10] == rampSet(Deoxys.palette(10)), + "position 10 draws the awakened red ramp " .. + tostring(rampSet(Deoxys.palette(10))) .. + ", got " .. tostring(seen[10])) + + -- ------------------------------------------------------------- the shatter + local anim = FieldEffects.startDestroyDeoxysRock(rockId) + result(anim ~= nil, "startDestroyDeoxysRock created the shatter animation") + if anim then + U.wait(30) + result(anim.state == "shake", + "the rock shakes before breaking, got " .. tostring(anim.state)) + U.shot(game, DIR .. "/03_shatter_shake.png") + + -- Task_DeoxysRockCameraShake runs for 120 frames, then the shards fly for + -- only ~16 frames, so poll instead of guessing at a delay. + for _ = 1, 200 do + if anim.state ~= "shake" then break end + U.wait(1) + end + result(anim.state == "shatter", + "the animation reached the shatter state, got " .. tostring(anim.state)) + + local sheet = FieldEffects._sheets["deoxys_rock_fragments"] + result(sheet ~= nil and sheet ~= false, + "the rock fragment sheet loaded (4 x 8x8)") + U.shot(game, DIR .. "/04_shatter_fragments.png") + + -- The white flash must be a *background* veil (pret blends PALETTES_BG + -- only), so the shards stay visible on top of it. + local frag04 = fragPixels(DIR .. "/04_shatter_fragments.png") + U.log(" fragment pixels on screen at the shatter frame: " .. frag04) + result(frag04 > 0, + "the shards are visible through the shatter flash, got " .. + frag04 .. " px (0 means the flash covered them)") + + local alive = 0 + for _, f in ipairs(anim.frags or {}) do + if not f.off then alive = alive + 1 end + U.log(string.format(" frag frame=%s at (%d,%d) off=%s", + tostring(f.frame), math.floor(f.x), math.floor(f.y), tostring(f.off))) + end + result(#(anim.frags or {}) == 4 and alive > 0, + "four shards are flying, " .. alive .. " still on screen") + + -- Only a frame or two later: pret destroys a fragment once it leaves the + -- 244x168 display, and at 16px/frame a shard crosses the whole screen in + -- ~8 frames, so a longer wait would correctly find an empty screen. + U.wait(1) + U.shot(game, DIR .. "/05_shatter_spread.png") + local frag05 = fragPixels(DIR .. "/05_shatter_spread.png") + U.log(" fragment pixels on screen once they have spread: " .. frag05) + result(frag05 > 0, + "the shards are still flying a few frames later, got " .. frag05 .. " px") + end + + finish() +end From 6be2de28444de5678d4fc5bd760fbea6e1edf029 Mon Sep 17 00:00:00 2001 From: 1jamie Date: Mon, 21 Sep 2026 17:07:50 -0500 Subject: [PATCH 4/5] feat(game3): finished Gen 3 save editor, scripting specials, field elevation, and parallel ROM extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Save Editor: Implement full Gen 3 FireRed save editing including PID/personality, IVs/EVs, natures, ability slots, gender, shiny toggles, held items, Pokédex flags, and event/trainer toggles. - Scripting & Specials: Add Pokédex rating evaluations, Magikarp/Heracross size records, Trainer Fan Club tracking, and FireRed event flag mappings. - Overworld & Rendering: Interleave metatile and actor draw calls by row bucket with OAM elevation priority sorting for bridges and overhead layers. - ROM Extractor: Parallelize Gen 3 extraction across 4 concurrent love.thread workers with love.filesystem searchers for cross-platform support (Switch/Xbox/Mobile/Desktop). - LZ77 Decompressor: Accelerate GBA LZ77 with preallocated FFI buffers and 0-based displacement indexing ensuring 100% byte-for-byte asset parity. - Testing: Add unit test suites for LZ77, size records, Pokédex rating, Fan Club, elevation priority, Seafoam puzzles, and Gen 3 save editor operations. --- src/core/game3/field_view.lua | 313 ++++++++------ src/core/game3/link/battle.lua | 5 + src/core/game3/link/init.lua | 3 + src/core/game3/objects.lua | 7 +- src/core/game3/player.lua | 13 +- src/core/game3/pokedex_rating.lua | 109 +++++ src/core/game3/pokemon_size_record.lua | 215 ++++++++++ src/core/game3/save_schema_firered.lua | 8 + src/core/game3/scripting/flags.lua | 82 +++- src/core/game3/scripting/flags_table.lua | 8 + src/core/game3/scripting/natives.lua | 2 + src/core/game3/scripting/natives_events.lua | 250 ++++++++++-- src/core/game3/scripting/natives_fan_club.lua | 94 +++++ src/core/game3/scripting/natives_link.lua | 2 +- .../game3/scripting/natives_size_record.lua | 68 ++++ src/core/game3/scripting/stdscripts.lua | 20 + src/core/game3/trainer_fan_club.lua | 383 ++++++++++++++++++ src/import/RomExtractorGen3.lua | 222 ++++++++-- src/import/gba/battle_anim_extract.lua | 119 ++++-- src/import/gba/extract_island1.lua | 95 ----- src/import/gba/extract_worker.lua | 71 ++++ src/import/gba/lz77.lua | 177 ++++++-- src/import/gba/pokemon_extract.lua | 145 ++++++- tests/game3_elevation_oam_priority_test.lua | 203 ++++++++++ tests/game3_event_flags_test.lua | 3 +- tests/game3_oneoff_specials_test.lua | 214 ++++++++++ tests/game3_pokedex_rating_test.lua | 140 +++++++ tests/game3_seafoam_puzzle_test.lua | 234 +++++++++++ tests/game3_size_record_test.lua | 195 +++++++++ tests/game3_special_ids_test.lua | 14 +- tests/game3_trainer_fan_club_test.lua | 306 ++++++++++++++ tests/lz77_test.lua | 57 +++ tests/save_editor_gen3_tests.lua | 295 +++++++++++++- tools/save-editor/App.lua | 1 + tools/save-editor/Catalog.lua | 15 + tools/save-editor/Gen.lua | 33 ++ tools/save-editor/Gen3Flags.lua | 261 ++++++++++++ tools/save-editor/MonOps.lua | 167 ++++++++ tools/save-editor/Ops.lua | 286 +++++++++++-- tools/save-editor/panels/Dex.lua | 15 +- tools/save-editor/panels/Events.lua | 209 ++++++++-- tools/save-editor/panels/MonEditor.lua | 72 +++- tools/save-editor/panels/MovePicker.lua | 8 +- tools/save-editor/panels/SpeciesPicker.lua | 3 +- 44 files changed, 4660 insertions(+), 482 deletions(-) create mode 100644 src/core/game3/pokedex_rating.lua create mode 100644 src/core/game3/pokemon_size_record.lua create mode 100644 src/core/game3/scripting/natives_fan_club.lua create mode 100644 src/core/game3/scripting/natives_size_record.lua create mode 100644 src/core/game3/trainer_fan_club.lua create mode 100644 src/import/gba/extract_worker.lua create mode 100644 tests/game3_elevation_oam_priority_test.lua create mode 100644 tests/game3_oneoff_specials_test.lua create mode 100644 tests/game3_pokedex_rating_test.lua create mode 100644 tests/game3_seafoam_puzzle_test.lua create mode 100644 tests/game3_size_record_test.lua create mode 100644 tests/game3_trainer_fan_club_test.lua create mode 100644 tests/lz77_test.lua create mode 100644 tools/save-editor/Gen3Flags.lua diff --git a/src/core/game3/field_view.lua b/src/core/game3/field_view.lua index 4d6a12be..21b64a5d 100644 --- a/src/core/game3/field_view.lua +++ b/src/core/game3/field_view.lua @@ -19,6 +19,9 @@ FieldView._loggedPal = false FieldView._nativeBatch = nil FieldView._nativeBatches = nil FieldView._nativeOverBatches = nil +FieldView._nativeOverByRow = nil +FieldView._nativeRows = nil +FieldView._nativeCy0 = nil FieldView._nativeBx = nil FieldView._nativeBy = nil FieldView._nativePair = nil @@ -431,6 +434,10 @@ local function isPlayerAboveBg2(playerXOff, playerYOff) if (playerXOff and playerXOff ~= 0) or (playerYOff and playerYOff ~= 0) then return true end + local PlayerMod = package.loaded["src.core.game3.player"] + if PlayerMod and (PlayerMod.jumping or PlayerMod.surfHopping) then + return true + end local WarpMod = package.loaded["src.core.game3.warp"] if WarpMod and WarpMod.isEscalatorActive and WarpMod.isEscalatorActive() then return true @@ -442,39 +449,72 @@ local function isPlayerAboveBg2(playerXOff, playerYOff) return false end ---- Draw game3 EventObjects + player (owned sprites; no World:step anim). --- playerYOff: pret sprite->y2 during Jump2 / escalator. --- playerXOff: pret sprite->x2 during escalator. -local function drawGame3Actors(game, camX, camY, px, py, facing, walkPhase, stepFlip, playerYOff, playerXOff) - local okO, Objects = pcall(require, "src.core.game3.objects") - if not (okO and Objects and Objects.hasMap and Objects.hasMap()) then - return false - end +local function drawSingleActor(game, mapDef, a, camX, camY) + local daytime = daytimeFor(game, mapDef) local okOw, OwSprites = pcall(require, "src.core.game3.ow_sprites") local useOw = okOw and OwSprites and OwSprites.ready and OwSprites.ready() - local daytime = daytimeFor(game, resolveMapDef(game, currentMapId(game))) - local actors = {} - for _, eo in ipairs(Objects.forDraw()) do - actors[#actors + 1] = { - kind = "npc", - i = eo.localId, - obj = eo.def, - x = (eo.px or (eo.cellX * CELL)) + (eo.raiseX or 0), - y = (eo.py or (eo.cellY * CELL)) + (eo.raiseY or 0), - sortY = eo.py or (eo.cellY * CELL), - facing = eo.facing or "down", - walkPhase = Objects.walkPhase(eo), - stepFlip = eo.stepFlip and true or false, - bow = (eo.bowFrames and eo.bowFrames > 0) or eo.raiseHand == true, - frame = eo.customFrame, - sprite = eo.sprite or spriteNameForObj(eo.def or {}), - graphicsId = eo.graphicsId or (eo.def and (eo.def.graphicsId or eo.def.graphics)), + love.graphics.setColor(1, 1, 1, 1) + local billboarded = pushBillboard(a.x, a.y, camX, camY) + local drew = false + if useOw and a.graphicsId ~= nil then + local opts = { + bow = a.bow, + fieldMove = a.fieldMove, + frame = a.frame, } + drew = OwSprites.draw( + a.graphicsId, a.x, a.y, camX, camY, a.facing, a.walkPhase, a.stepFlip, opts) end - collectNeighborActors(actors, 10000, currentMapId(game), - resolveMapDef(game, currentMapId(game))) - -- Resolve graphicsVar (Bill etc.) via Space when available. - do + if not drew then + local sr = getSpriteRenderer( + game, a.sprite, a.kind .. ":" .. tostring(a.i or "p"), a.obj, daytime) + if sr then + sr:draw(a.x, a.y, camX, camY, a.facing, a.walkPhase or 0, a.stepFlip) + else + local sx, sy = a.x - camX, a.y - camY + if a.kind == "player" then + love.graphics.setColor(0.95, 0.25, 0.25, 1) + else + love.graphics.setColor(0.3, 0.55, 0.95, 1) + end + love.graphics.rectangle("fill", sx + 4, sy + 2, 8, 12) + love.graphics.setColor(1, 1, 1, 1) + end + end + if billboarded then love.graphics.pop() end +end + +local function collectGame3Actors(game, mapDef, camX, camY, px, py, facing, walkPhase, stepFlip, playerYOff, playerXOff) + local okO, Objects = pcall(require, "src.core.game3.objects") + local okOw, OwSprites = pcall(require, "src.core.game3.ow_sprites") + local useOw = okOw and OwSprites and OwSprites.ready and OwSprites.ready() + local actors = {} + local hasObjects = okO and Objects and Objects.hasMap and Objects.hasMap() + + if hasObjects then + for _, eo in ipairs(Objects.forDraw()) do + local sortY = eo.py or (eo.cellY * CELL) + if eo.moving and eo.targetY and eo.targetY > (eo.cellY or 0) then + sortY = math.max(sortY, eo.targetY * CELL) + end + actors[#actors + 1] = { + kind = "npc", + i = eo.localId, + obj = eo.def, + x = (eo.px or (eo.cellX * CELL)) + (eo.raiseX or 0), + y = (eo.py or (eo.cellY * CELL)) + (eo.raiseY or 0), + sortY = sortY, + facing = eo.facing or "down", + walkPhase = Objects.walkPhase(eo), + stepFlip = eo.stepFlip and true or false, + bow = (eo.bowFrames and eo.bowFrames > 0) or eo.raiseHand == true, + frame = eo.customFrame, + sprite = eo.sprite or spriteNameForObj(eo.def or {}), + graphicsId = eo.graphicsId or (eo.def and (eo.def.graphicsId or eo.def.graphics)), + } + end + collectNeighborActors(actors, 10000, currentMapId(game), + resolveMapDef(game, currentMapId(game))) local Space = package.loaded["src.core.game3.scripting.space"] if Space and Space.resolveObjectGraphicsId then for _, a in ipairs(actors) do @@ -484,15 +524,53 @@ local function drawGame3Actors(game, camX, camY, px, py, facing, walkPhase, step end end end + else + local world = game and (game.overworld or game.world) + if world and world.npcs then + for i, npc in ipairs(world.npcs) do + actors[#actors + 1] = { + kind = "npc", + i = i, + obj = npc.def, + x = npc.px or ((npc.cellX or 0) * CELL), + y = npc.py or ((npc.cellY or 0) * CELL), + sortY = npc.py or ((npc.cellY or 0) * CELL), + facing = npc.facing or "down", + sprite = spriteNameForObj(npc.def or {}), + graphicsId = useOw and OwSprites.playerGraphicsId and npc.graphicsId or nil, + } + end + elseif type(mapDef.objects) == "table" then + for i, obj in ipairs(mapDef.objects) do + if objectVisible(obj) then + actors[#actors + 1] = { + kind = "npc", + i = i, + obj = obj, + x = (tonumber(obj.x) or 0) * CELL, + y = (tonumber(obj.y) or 0) * CELL, + sortY = (tonumber(obj.y) or 0) * CELL, + facing = facingFromObj(obj), + sprite = spriteNameForObj(obj), + graphicsId = obj.graphicsId or obj.graphics, + } + end + end + end end + local aboveBg2 = isPlayerAboveBg2(playerXOff, playerYOff) local PlayerMod = package.loaded["src.core.game3.player"] if not aboveBg2 and (not PlayerMod or (PlayerMod.isVisible and PlayerMod.isVisible())) then + local playerSortY = py + if PlayerMod and PlayerMod.moving and PlayerMod.targetY and PlayerMod.targetY > (PlayerMod.cellY or 0) then + playerSortY = math.max(playerSortY, PlayerMod.targetY * CELL) + end actors[#actors + 1] = { kind = "player", x = px + (playerXOff or 0), y = py + (playerYOff or 0), - sortY = py, + sortY = playerSortY, facing = facing or "down", walkPhase = (walkPhase == 1 or walkPhase == true) and 1 or 0, stepFlip = stepFlip and true or false, @@ -509,38 +587,53 @@ local function drawGame3Actors(game, camX, camY, px, py, facing, walkPhase, step return ay < by end) - love.graphics.setColor(1, 1, 1, 1) - for _, a in ipairs(actors) do - local billboarded = pushBillboard(a.x, a.y, camX, camY) - local drew = false - if useOw and a.graphicsId ~= nil then - local opts = { - bow = a.bow, - fieldMove = a.fieldMove, - frame = a.frame, - } - drew = OwSprites.draw( - a.graphicsId, a.x, a.y, camX, camY, a.facing, a.walkPhase, a.stepFlip, opts) + return actors +end + +local function drawInterleaved(game, mapDef, camX, camY, actors, rows, cy0) + local actorIdx = 1 + local nActors = #actors + local ox = FieldView._nativeOverOx or 0 + local oy = FieldView._nativeOverOy or 0 + local overByRow = FieldView._nativeOverByRow + + if not overByRow or not rows or rows <= 0 then + for _, a in ipairs(actors) do + drawSingleActor(game, mapDef, a, camX, camY) end - if not drew then - local sr = getSpriteRenderer( - game, a.sprite, a.kind .. ":" .. tostring(a.i or "p"), a.obj, daytime) - if sr then - sr:draw(a.x, a.y, camX, camY, a.facing, a.walkPhase or 0, a.stepFlip) - else - local sx, sy = a.x - camX, a.y - camY - if a.kind == "player" then - love.graphics.setColor(0.95, 0.25, 0.25, 1) - else - love.graphics.setColor(0.3, 0.55, 0.95, 1) + return + end + + local topWy = (cy0 or 0) * CELL + while actorIdx <= nActors and (actors[actorIdx].sortY or actors[actorIdx].y) < topWy do + drawSingleActor(game, mapDef, actors[actorIdx], camX, camY) + actorIdx = actorIdx + 1 + end + + for r = 0, rows - 1 do + local rowWy = ((cy0 or 0) + r) * CELL + + local rowOver = overByRow[r] + if rowOver and #rowOver > 0 then + love.graphics.setColor(1, 1, 1, 1) + for _, item in ipairs(rowOver) do + if item.image and item.quad then + love.graphics.draw(item.image, item.quad, ox + item.x, oy + item.y) end - love.graphics.rectangle("fill", sx + 4, sy + 2, 8, 12) - love.graphics.setColor(1, 1, 1, 1) end end - if billboarded then love.graphics.pop() end + + local nextRowWy = rowWy + CELL + while actorIdx <= nActors and (actors[actorIdx].sortY or actors[actorIdx].y) < nextRowWy do + drawSingleActor(game, mapDef, actors[actorIdx], camX, camY) + actorIdx = actorIdx + 1 + end + end + + while actorIdx <= nActors do + drawSingleActor(game, mapDef, actors[actorIdx], camX, camY) + actorIdx = actorIdx + 1 end - return true end --- Collect visible tile draws grouped by palette slot for batched GbcPalette.with. @@ -689,6 +782,10 @@ local function drawNativeTiles(mapDef, camX, camY, canvasW, canvasH) for _, batch in pairs(FieldView._nativeOverBatches) do batch:clear() end + FieldView._nativeOverByRow = {} + for r = 0, rows - 1 do + FieldView._nativeOverByRow[r] = {} + end local cellsByPair = {} local voidHas, voidPrimary = nil, nil if voidMode ~= "map" and voidMode ~= "black" then @@ -720,7 +817,7 @@ local function drawNativeTiles(mapDef, camX, camY, canvasW, canvasH) list = {} cellsByPair[srcPair] = list end - list[#list + 1] = { mid = mid, x = col * CELL, y = row * CELL } + list[#list + 1] = { mid = mid, x = col * CELL, y = row * CELL, row = row } end end end @@ -741,7 +838,18 @@ local function drawNativeTiles(mapDef, camX, camY, canvasW, canvasH) if q then batch:add(q, cell.x, cell.y) end if overBatch then local oq = NativeTileset.overQuad(ts, slot) - if oq then overBatch:add(oq, cell.x, cell.y) end + if oq then + overBatch:add(oq, cell.x, cell.y) + local rList = FieldView._nativeOverByRow[cell.row] + if rList then + rList[#rList + 1] = { + image = ts.overImage, + quad = oq, + x = cell.x, + y = cell.y, + } + end + end end end end @@ -759,6 +867,8 @@ local function drawNativeTiles(mapDef, camX, camY, canvasW, canvasH) FieldView._nativeOverOx = ox FieldView._nativeOverOy = oy FieldView._nativeOverPair = pair + FieldView._nativeRows = rows + FieldView._nativeCy0 = cy0 if FieldView._nativeBatches[pair] then love.graphics.draw(FieldView._nativeBatches[pair], ox, oy) end @@ -1088,78 +1198,12 @@ function FieldView.draw(game, canvasW, canvasH, opts) end end - -- Game3 EventObjects when spawned; else live World entities; else static defs. - if not opts.skipActors and not drawGame3Actors(game, camX, camY, px, py, facing, walkPhase, stepFlip, playerYOff, playerXOff) - and not drawWorldEntities(world, camX, camY) then - local daytime = daytimeFor(game, mapDef) - local okOw, OwSprites = pcall(require, "src.core.game3.ow_sprites") - local useOw = okOw and OwSprites and OwSprites.ready and OwSprites.ready() - local actors = {} - if type(mapDef.objects) == "table" then - for i, obj in ipairs(mapDef.objects) do - if objectVisible(obj) then - actors[#actors + 1] = { - kind = "npc", - i = i, - obj = obj, - x = (tonumber(obj.x) or 0) * CELL, - y = (tonumber(obj.y) or 0) * CELL, - facing = facingFromObj(obj), - sprite = spriteNameForObj(obj), - graphicsId = obj.graphicsId or obj.graphics, - } - end - end - end - local aboveBg2 = isPlayerAboveBg2(playerXOff, playerYOff) - local PlayerMod = package.loaded["src.core.game3.player"] - if not aboveBg2 and (not PlayerMod or (PlayerMod.isVisible and PlayerMod.isVisible())) then - actors[#actors + 1] = { - kind = "player", - x = px + playerXOff, - y = py + playerYOff, - sortY = py, - facing = facing or "down", - walkPhase = (walkPhase == 1 or walkPhase == true) and 1 or 0, - stepFlip = stepFlip and true or false, - sprite = playerSpriteName(game), - graphicsId = useOw and OwSprites.playerGraphicsId(game) or nil, - } - end - - table.sort(actors, function(a, b) - local ay = a.sortY or a.y - local by = b.sortY or b.y - if ay == by then return (a.i or 0) < (b.i or 0) end - return ay < by - end) - - love.graphics.setColor(1, 1, 1, 1) - for _, a in ipairs(actors) do - local billboarded = pushBillboard(a.x, a.y, camX, camY) - local drew = false - if useOw and a.graphicsId ~= nil then - drew = OwSprites.draw( - a.graphicsId, a.x, a.y, camX, camY, a.facing, a.walkPhase, a.stepFlip) - end - if not drew then - local sr = getSpriteRenderer( - game, a.sprite, a.kind .. ":" .. tostring(a.i or "p"), a.obj, daytime) - if sr then - sr:draw(a.x, a.y, camX, camY, a.facing, a.walkPhase or 0, a.stepFlip) - else - local sx, sy = a.x - camX, a.y - camY - if a.kind == "player" then - love.graphics.setColor(0.95, 0.25, 0.25, 1) - else - love.graphics.setColor(0.3, 0.55, 0.95, 1) - end - love.graphics.rectangle("fill", sx + 4, sy + 2, 8, 12) - love.graphics.setColor(1, 1, 1, 1) - end - end - if billboarded then love.graphics.pop() end - end + -- Draw Game3 actors and native overhead tiles interleaved by Y-row. + if not opts.skipActors then + local actors = collectGame3Actors( + game, mapDef, camX, camY, px, py, facing, walkPhase, stepFlip, playerYOff, playerXOff) + drawInterleaved( + game, mapDef, camX, camY, actors, FieldView._nativeRows, FieldView._nativeCy0) end -- Tall grass over feet (pret subpriority above avatar). @@ -1182,8 +1226,8 @@ function FieldView.draw(game, canvasW, canvasH, opts) end end - -- pret BG2: metatile top layer covers OW sprites (roofs, desk counters). - if usedNative and not opts.actorsOnly then + -- pret BG2: metatile top layer covers OW sprites if actors were skipped or non-interleaved. + if usedNative and not opts.actorsOnly and (opts.skipActors or not FieldView._nativeOverByRow) then drawNativeOverTiles() end @@ -1240,6 +1284,9 @@ function FieldView.invalidate() FieldView._nativeBatch = nil FieldView._nativeBatches = nil FieldView._nativeOverBatches = nil + FieldView._nativeOverByRow = nil + FieldView._nativeRows = nil + FieldView._nativeCy0 = nil FieldView._nativeBx = nil FieldView._nativeBy = nil FieldView._nativePair = nil diff --git a/src/core/game3/link/battle.lua b/src/core/game3/link/battle.lua index 349a7b35..8a43e026 100644 --- a/src/core/game3/link/battle.lua +++ b/src/core/game3/link/battle.lua @@ -558,6 +558,11 @@ function LB.finish(result) if LB.mode ~= L.USING.MULTI_BATTLE and not LB.unionRoom then bumpTrainerCard(s, outcome) LB.addOpponentRecord(s, LB.peer and LB.peer.name, LB.peer and LB.peer.trainerId, outcome) + -- pokefirered/src/cable_club.c:782 CB2_ReturnFromDirectLinkBattle -> Special_UpdateTrainerFansAfterLinkBattle + local okF, TFC = pcall(require, "src.core.game3.trainer_fan_club") + if okF and TFC and TFC.updateTrainerFansAfterLinkBattle then + TFC.updateTrainerFansAfterLinkBattle(s, ctx, outcome) + end end local lk = L.link if lk and lk:isOpen() then diff --git a/src/core/game3/link/init.lua b/src/core/game3/link/init.lua index b5cba15d..c3010c12 100644 --- a/src/core/game3/link/init.lua +++ b/src/core/game3/link/init.lua @@ -585,4 +585,7 @@ function Link.reset() Link.peerCard = nil end +package.loaded["src.core.game3.link"] = Link +package.loaded["src.core.game3.link.init"] = Link + return Link diff --git a/src/core/game3/objects.lua b/src/core/game3/objects.lua index e6b57737..82cc0ec0 100644 --- a/src/core/game3/objects.lua +++ b/src/core/game3/objects.lua @@ -158,7 +158,8 @@ local function newEventObject(def) facing = facingFromDef(def), sprite = sprite or "SPRITE_YOUNGSTER", graphicsId = resolvedGfx, - elevation = def.elevation or 0, + elevation = (def.elevation and def.elevation ~= 0 and def.elevation) + or (Collision.elevationAt and Collision.elevationAt(x, y)) or 0, movementType = mt, movement = movement, range = range, @@ -555,6 +556,10 @@ local function finishStep(eo) if eo.def then eo.def.x, eo.def.y = eo.cellX, eo.cellY end + local curElev = Collision.elevationAt and Collision.elevationAt(eo.cellX, eo.cellY) + if curElev and curElev ~= 0 and curElev ~= 15 then + eo.elevation = curElev + end end local function tickMotion(eo) diff --git a/src/core/game3/player.lua b/src/core/game3/player.lua index 694cdab0..e4227e19 100644 --- a/src/core/game3/player.lua +++ b/src/core/game3/player.lua @@ -115,8 +115,13 @@ function Player.reset(x, y, facing) Player.cellY = tonumber(y) or 0 Player.px = Player.cellX * CELL Player.py = Player.cellY * CELL - Player.facing = facing or "down" - Player.elevation = 3 + local Collision = package.loaded["src.core.game3.collision"] + local curElev = Collision and Collision.elevationAt and Collision.elevationAt(Player.cellX, Player.cellY) + if curElev and curElev ~= 0 and curElev ~= 15 then + Player.elevation = curElev + else + Player.elevation = 3 + end Player.moving = false Player.progress = 0 Player.stepFrames = WALK_FRAMES @@ -608,6 +613,10 @@ local function finishStep(game) Player.running = false Player.jumping = false Player.spriteYOffset = 0 + local curElev = Collision.elevationAt and Collision.elevationAt(Player.cellX, Player.cellY) + if curElev and curElev ~= 0 and curElev ~= 15 then + Player.elevation = curElev + end Player.syncSavePosition(game) -- Surf landing / dismount state transitions diff --git a/src/core/game3/pokedex_rating.lua b/src/core/game3/pokedex_rating.lua new file mode 100644 index 00000000..154b21a7 --- /dev/null +++ b/src/core/game3/pokedex_rating.lua @@ -0,0 +1,109 @@ +-- Professor Oak's Pokédex Rating Evaluation (pokefirered/src/prof_pc.c). +-- Evaluates caught Pokémon count brackets, Mew exception, and triggers rating messages. + +local PokedexRating = {} + +local KANTO_DEX_COUNT = 151 +local SPECIES_MEW = 151 + +-- pokefirered/data/text/pokedex_rating.inc +local RATING_MESSAGES = { + LESS_THAN_10 = "You still have lots to do.\nGo into every patch of grass you\nsee and look for POKéMON!", + LESS_THAN_20 = "It looks as if you're getting on\nthe right track!\nI've given one of my AIDES a FLASH\nHM. Make sure you go get it!", + LESS_THAN_30 = "Your POKéDEX could use a bit more\nvolume still!\nTry to catch other species of\nPOKéMON!", + LESS_THAN_40 = "Good, it's apparent that you're\ntrying hard!\nI've given one of my AIDES an\nITEMFINDER. Be sure to collect it!", + LESS_THAN_50 = "Your POKéDEX is coming along quite\nwell!\nI've given one of my AIDES an\nAMULET COIN. Be sure to get it!", + LESS_THAN_60 = "Ah, you've finally topped 50\nspecies!\nI've given one of my AIDES an EXP.\nSHARE. Be sure to go get it!", + LESS_THAN_70 = "Hoho! This is turning into quite the\nrespectable POKéDEX!", + LESS_THAN_80 = "Very good!\nI think you'll collect even more\nPOKéMON by going fishing!", + LESS_THAN_90 = "Wonderful! Let me guess… You\nlike to collect things, don't you?", + LESS_THAN_100 = "I'm impressed!\nIt must have been difficult to do!", + LESS_THAN_110 = "You've finally hit 100 species!\nI can't believe how good you are!", + LESS_THAN_120 = "You even have the evolved forms\nof POKéMON! Super!", + LESS_THAN_130 = "Excellent! Trade with friends to\nget some more!", + LESS_THAN_140 = "Outstanding!\nYou've become a real pro at this!", + LESS_THAN_150 = "I have nothing left to say!\nYou're the POKéMON PROFESSOR now!", + COMPLETE = "Your POKéDEX is entirely complete!\nCongratulations!!", +} +PokedexRating.TEXT = RATING_MESSAGES + +local function sessionOf(ctx) + if ctx and ctx.session then return ctx.session end + local Space = package.loaded["src.core.game3.scripting.space"] + if Space and Space.store and Space.store.flags then return Space.store end + local rt = package.loaded["src.core.game3.runtime"] + return rt and rt.getSession and rt.getSession() or nil +end + +local function dexOf(session, ctx) + session = session or sessionOf(ctx) + return (session and (session.dex or session.pokedex)) or nil +end + +--- pokefirered/src/prof_pc.c:38 GetProfOaksRatingMessageByCount +--- Evaluates Pokédex count and returns: messageText, isComplete (boolean) +function PokedexRating.getRatingMessage(count, dex) + count = tonumber(count) or 0 + + if count < 10 then return RATING_MESSAGES.LESS_THAN_10, false end + if count < 20 then return RATING_MESSAGES.LESS_THAN_20, false end + if count < 30 then return RATING_MESSAGES.LESS_THAN_30, false end + if count < 40 then return RATING_MESSAGES.LESS_THAN_40, false end + if count < 50 then return RATING_MESSAGES.LESS_THAN_50, false end + if count < 60 then return RATING_MESSAGES.LESS_THAN_60, false end + if count < 70 then return RATING_MESSAGES.LESS_THAN_70, false end + if count < 80 then return RATING_MESSAGES.LESS_THAN_80, false end + if count < 90 then return RATING_MESSAGES.LESS_THAN_90, false end + if count < 100 then return RATING_MESSAGES.LESS_THAN_100, false end + if count < 110 then return RATING_MESSAGES.LESS_THAN_110, false end + if count < 120 then return RATING_MESSAGES.LESS_THAN_120, false end + if count < 130 then return RATING_MESSAGES.LESS_THAN_130, false end + if count < 140 then return RATING_MESSAGES.LESS_THAN_140, false end + if count < 150 then return RATING_MESSAGES.LESS_THAN_150, false end + + if count == (KANTO_DEX_COUNT - 1) then -- 150 + -- In pokefirered: Mew (151) does not count towards completing the 150 requirement. + -- If Mew is caught in Kanto Dex, the player has 149 regular + Mew = 150 total, + -- which means they haven't completed the 150 standard species yet! + local Dex = require("src.core.game3.dex") + if dex and Dex.isCaught(dex, SPECIES_MEW) then + return RATING_MESSAGES.LESS_THAN_150, false + end + return RATING_MESSAGES.COMPLETE, true + end + + if count >= KANTO_DEX_COUNT then -- 151 + return RATING_MESSAGES.COMPLETE, true + end + + return RATING_MESSAGES.LESS_THAN_10, false +end + +--- pokefirered/src/prof_pc.c:106 GetProfOaksRatingMessage +function PokedexRating.getProfOaksRatingMessage(session, ctx, adapters) + session = session or sessionOf(ctx) + local flagsMod = require("src.core.game3.scripting.flags") + local scriptStore = (ctx and ctx.session) or session + + local count = tonumber(flagsMod.getVar(scriptStore, ctx, 0x8004)) or 0 + local dex = dexOf(session, ctx) + local msg, isComplete = PokedexRating.getRatingMessage(count, dex) + + flagsMod.setVar(scriptStore, ctx, 0x800D, isComplete and 1 or 0) + + -- ShowFieldMessage (pokefirered/src/field_message_box.c:65) + if ctx then + ctx.messageOpen = true + ctx.printerDone = false + end + local open = adapters and (adapters.openMessageStay or adapters.openMessageAsync) + if open then + open(msg, nil) + elseif adapters and adapters.openMessage then + adapters.openMessage(msg) + end + + return false, msg +end + +return PokedexRating diff --git a/src/core/game3/pokemon_size_record.lua b/src/core/game3/pokemon_size_record.lua new file mode 100644 index 00000000..f00ce2ef --- /dev/null +++ b/src/core/game3/pokemon_size_record.lua @@ -0,0 +1,215 @@ +-- FRLG Pokémon Size Record System (pokefirered/src/pokemon_size_record.c). +-- Calculates size hashes, table-interpolated heights, imperial conversions, and record comparisons. + +local band, bor, bxor, lshift, rshift = bit.band, bit.bor, bit.bxor, bit.lshift, bit.rshift + +local SizeRecord = {} + +SizeRecord.SPECIES_MAGIKARP = 129 +SizeRecord.SPECIES_HERACROSS = 214 + +SizeRecord.VAR_HERACROSS_SIZE_RECORD = 0x403D +SizeRecord.VAR_MAGIKARP_SIZE_RECORD = 0x4040 +SizeRecord.VAR_LOTAD_SIZE_RECORD = 0x404F + +SizeRecord.DEFAULT_MAX_SIZE = 0 +SizeRecord.PARTY_SIZE = 6 + +-- pokefirered/src/pokemon_size_record.c:18 sBigMonSizeTable +local sBigMonSizeTable = { + { unk0 = 290, unk2 = 1, unk4 = 0 }, + { unk0 = 300, unk2 = 1, unk4 = 10 }, + { unk0 = 400, unk2 = 2, unk4 = 110 }, + { unk0 = 500, unk2 = 4, unk4 = 310 }, + { unk0 = 600, unk2 = 20, unk4 = 710 }, + { unk0 = 700, unk2 = 50, unk4 = 2710 }, + { unk0 = 800, unk2 = 100, unk4 = 7710 }, + { unk0 = 900, unk2 = 150, unk4 = 17710 }, + { unk0 = 1000, unk2 = 150, unk4 = 32710 }, + { unk0 = 1100, unk2 = 100, unk4 = 47710 }, + { unk0 = 1200, unk2 = 50, unk4 = 57710 }, + { unk0 = 1300, unk2 = 20, unk4 = 62710 }, + { unk0 = 1400, unk2 = 5, unk4 = 64710 }, + { unk0 = 1500, unk2 = 2, unk4 = 65210 }, + { unk0 = 1600, unk2 = 1, unk4 = 65410 }, + { unk0 = 1700, unk2 = 1, unk4 = 65510 }, +} +SizeRecord.TABLE = sBigMonSizeTable + +local SPECIES_HEIGHT_DM = { + [129] = 9, -- Magikarp: 0.9 m (9 dm) + [214] = 15, -- Heracross: 1.5 m (15 dm) + [270] = 5, -- Lotad: 0.5 m (5 dm) + [273] = 5, -- Seedot: 0.5 m (5 dm) +} + +function SizeRecord.getSpeciesHeight(species) + species = tonumber(species) or 0 + if SPECIES_HEIGHT_DM[species] then + return SPECIES_HEIGHT_DM[species] + end + local okP, PokedexData = pcall(require, "src.core.game3.pokedex_data") + if okP and PokedexData and PokedexData.getEntry then + local ent = PokedexData.getEntry(species) + if ent and ent.heightDm then return ent.heightDm end + end + return 10 -- default 1.0 m (10 dm) +end + +local function flagsMod() + return require("src.core.game3.scripting.flags") +end + +function SizeRecord.sessionOf(ctx) + if ctx and ctx.session then return ctx.session end + local Space = package.loaded["src.core.game3.scripting.space"] + if Space and Space.store and Space.store.flags then return Space.store end + local rt = package.loaded["src.core.game3.runtime"] + return rt and rt.getSession and rt.getSession() or nil +end + +local function scriptStore(session, ctx) + local Space = package.loaded["src.core.game3.scripting.space"] + return (Space and Space.store) or (ctx and ctx.session) or (session and session.store) or session or nil +end + +function SizeRecord.getVar(session, ctx, varId) + session = session or SizeRecord.sessionOf(ctx) + return tonumber(flagsMod().getVar(scriptStore(session, ctx), ctx, varId)) or 0 +end + +function SizeRecord.setVar(session, ctx, varId, value) + session = session or SizeRecord.sessionOf(ctx) + flagsMod().setVar(scriptStore(session, ctx), ctx, varId, band(tonumber(value) or 0, 0xFFFF)) +end + +function SizeRecord.setStringVar(ctx, adapters, index, text) + if adapters and adapters.setStringVar then adapters.setStringVar(index, text) end + if ctx and ctx.stringVars then ctx.stringVars[index] = text end +end + +--- pokefirered/src/pokemon_size_record.c:47 GetMonSizeHash +function SizeRecord.getMonSizeHash(pkmn) + if not pkmn then return 0 end + local personality = band(tonumber(pkmn.personality or pkmn.pid or 0) or 0, 0xFFFFFFFF) + local ivs = pkmn.ivs or {} + local hpIV = band(tonumber(pkmn.ivHp or ivs.hp or 0) or 0, 0xF) + local attackIV = band(tonumber(pkmn.ivAtk or ivs.atk or 0) or 0, 0xF) + local defenseIV = band(tonumber(pkmn.ivDef or ivs.def or 0) or 0, 0xF) + local speedIV = band(tonumber(pkmn.ivSpd or ivs.spd or 0) or 0, 0xF) + local spAtkIV = band(tonumber(pkmn.ivSpAtk or ivs.spAtk or 0) or 0, 0xF) + local spDefIV = band(tonumber(pkmn.ivSpDef or ivs.spDef or 0) or 0, 0xF) + + local hibyte = band(bxor(bxor(attackIV, defenseIV) * hpIV, band(personality, 0xFF)), 0xFF) + local lobyte = band(bxor(bxor(spAtkIV, spDefIV) * speedIV, band(rshift(personality, 8), 0xFF)), 0xFF) + + return band(bor(lshift(hibyte, 8), lobyte), 0xFFFF) +end + +--- pokefirered/src/pokemon_size_record.c:62 TranslateBigMonSizeTableIndex +local function translateBigMonSizeTableIndex(a) + a = band(tonumber(a) or 0, 0xFFFF) + for i = 2, 15 do -- 1-based (i = 1 in C is index 2 in Lua) + if a < sBigMonSizeTable[i].unk4 then + return i - 1 + end + end + return 16 +end +SizeRecord.translateIndex = translateBigMonSizeTableIndex + +--- pokefirered/src/pokemon_size_record.c:74 GetMonSize +function SizeRecord.getMonSize(species, b) + b = band(tonumber(b) or 0, 0xFFFF) + local height = SizeRecord.getSpeciesHeight(species) + local var = translateBigMonSizeTableIndex(b) + local row = sBigMonSizeTable[var] + local unk0 = row.unk0 + local unk2 = row.unk2 + local unk4 = row.unk4 + unk0 = unk0 + math.floor((b - unk4) / unk2) + return math.floor(height * unk0 / 10) +end + +--- pokefirered/src/pokemon_size_record.c:91 FormatMonSizeRecord +--- Converts size from centimeters to inches with 1 decimal place (UNITS_IMPERIAL). +function SizeRecord.formatMonSizeRecord(size) + size = tonumber(size) or 0 + local inches = math.floor(size * 100 / 254) + local whole = math.floor(inches / 10) + local frac = inches % 10 + return string.format("%d.%d", whole, frac) +end + +--- pokefirered/src/pokemon_size_record.c:104 CompareMonSize +--- Returns: +--- 0: Invalid slot / cancel +--- 1: Is egg or wrong species +--- 2: Smaller than record (newSize < oldSize) +--- 3: New record (newSize > oldSize) - updates varId +--- 4: Tied record (newSize == oldSize) +function SizeRecord.compareMonSize(session, ctx, adapters, species, varId, slot) + session = session or SizeRecord.sessionOf(ctx) + slot = tonumber(slot) + if slot == nil then + local VAR_RESULT = 0x800D + slot = tonumber(flagsMod().getVar(scriptStore(session, ctx), ctx, VAR_RESULT)) or 0 + end + + if slot < 0 or slot >= SizeRecord.PARTY_SIZE then + return 0 + end + + local party = (session and session.party) or {} + local pkmn = party[slot + 1] + + if not pkmn or pkmn.isEgg or pkmn.is_egg or (tonumber(pkmn.species or pkmn.speciesId) ~= tonumber(species)) then + return 1 + end + + local sizeParams = SizeRecord.getMonSizeHash(pkmn) + local newSize = SizeRecord.getMonSize(species, sizeParams) + local oldRecord = SizeRecord.getVar(session, ctx, varId) + local oldSize = SizeRecord.getMonSize(species, oldRecord) + + SizeRecord.setStringVar(ctx, adapters, 3, SizeRecord.formatMonSizeRecord(oldSize)) + SizeRecord.setStringVar(ctx, adapters, 2, SizeRecord.formatMonSizeRecord(newSize)) + + if newSize == oldSize then + return 4 + elseif newSize < oldSize then + return 2 + else + SizeRecord.setVar(session, ctx, varId, sizeParams) + return 3 + end +end + +--- pokefirered/src/pokemon_size_record.c:147 GetMonSizeRecordInfo +function SizeRecord.getMonSizeRecordInfo(session, ctx, adapters, species, varId) + session = session or SizeRecord.sessionOf(ctx) + local sizeRecord = SizeRecord.getVar(session, ctx, varId) + local size = SizeRecord.getMonSize(species, sizeRecord) + + SizeRecord.setStringVar(ctx, adapters, 3, SizeRecord.formatMonSizeRecord(size)) + + local speciesName = (species == SizeRecord.SPECIES_MAGIKARP and "MAGIKARP") + or (species == SizeRecord.SPECIES_HERACROSS and "HERACROSS") + or "POKéMON" + local okP, Pokemon = pcall(require, "src.core.game3.pokemon") + if okP and Pokemon and Pokemon.name then + local n = Pokemon.name(species) + if n and n ~= "" then speciesName = n end + end + SizeRecord.setStringVar(ctx, adapters, 1, speciesName) +end + +function SizeRecord.initMagikarpSizeRecord(session, ctx) + SizeRecord.setVar(session, ctx, SizeRecord.VAR_MAGIKARP_SIZE_RECORD, SizeRecord.DEFAULT_MAX_SIZE) +end + +function SizeRecord.initHeracrossSizeRecord(session, ctx) + SizeRecord.setVar(session, ctx, SizeRecord.VAR_HERACROSS_SIZE_RECORD, SizeRecord.DEFAULT_MAX_SIZE) +end + +return SizeRecord diff --git a/src/core/game3/save_schema_firered.lua b/src/core/game3/save_schema_firered.lua index 798f9df9..891ed5b8 100644 --- a/src/core/game3/save_schema_firered.lua +++ b/src/core/game3/save_schema_firered.lua @@ -158,6 +158,12 @@ function Schema.newGame(opts) local Storage = require("src.core.game3.storage") session.storage = Storage.new() session.storage.items[1] = { id = 13, qty = 1 } -- pokefirered/src/player_pc.c:100 + -- pokefirered/src/new_game.c:143 ResetTrainerFanClub + require("src.core.game3.trainer_fan_club").reset(session) + -- pokefirered/src/new_game.c:132 InitMagikarpSizeRecord + local SizeRecord = require("src.core.game3.pokemon_size_record") + SizeRecord.initMagikarpSizeRecord(session) + SizeRecord.initHeracrossSizeRecord(session) Options.ensure(session) -- Plan naming: text_speed / l_equals_a aliases mirror Options fields. session.options.text_speed = session.options.textSpeed @@ -278,6 +284,8 @@ function Schema.fromSaveTable(save) reset_state_on_continue(session) Schema.ensureMonBalls(session) Schema.repairOwnMons(session) + local Flags = require("src.core.game3.scripting.flags") + Flags.repairSaveState(session) if type(save.options) == "table" then Options.bind(session, save.options) else diff --git a/src/core/game3/scripting/flags.lua b/src/core/game3/scripting/flags.lua index 83f2bdd2..4dedb46b 100644 --- a/src/core/game3/scripting/flags.lua +++ b/src/core/game3/scripting/flags.lua @@ -68,7 +68,9 @@ end -- pret EventScript_ResetAllMapFlags (derived from event_scripts.s). Flags.NEW_GAME_HIDE_FLAGS = FlagsTable.NEW_GAME_HIDE_FLAGS or { - 43, 44, 51, 146, 53, 54, 55, 60, 61, 161, 63, 79, 90, 163, 164, 98, 107, 162, + 43, 44, 51, 146, 53, 54, 55, 60, 61, 161, 63, + 66, 67, 68, 69, 70, 71, 76, 77, + 79, 90, 163, 164, 98, 107, 162, 140, 108, 109, 110, 111, 117, 116, 123, 124, 125, 118, 145, 151, 144, 148, 149, 150, 153, 165, 166, 167, 168, 169, 170, 171, 172, 157, 158, 159, 160, 174, } @@ -190,6 +192,82 @@ function Flags.ensurePalletOakHidden(store) end end +--- Fix Seafoam Islands boulder and current flags for saves created before seeding. +function Flags.repairSeafoamFlags(store) + if not store then return end + + -- B4F boulders and current + local b3f_boulder3_dropped = Flags.getFlag(store, nil, 0x48) -- FLAG_HIDE_SEAFOAM_B3F_BOULDER_3 (B3F obj 6 pushed) + local b3f_boulder5_dropped = Flags.getFlag(store, nil, 0x4A) -- FLAG_HIDE_SEAFOAM_B3F_BOULDER_5 (B3F obj 3 pushed) + + if not b3f_boulder3_dropped and not b3f_boulder5_dropped then + -- Neither boulder was pushed into hole from B3F + Flags.setFlag(store, nil, 0x4C, true) -- FLAG_HIDE_SEAFOAM_B4F_BOULDER_1 + Flags.setFlag(store, nil, 0x4D, true) -- FLAG_HIDE_SEAFOAM_B4F_BOULDER_2 + Flags.setFlag(store, nil, 0x2D3, false) -- FLAG_STOPPED_SEAFOAM_B4F_CURRENT + elseif b3f_boulder3_dropped and not b3f_boulder5_dropped then + Flags.setFlag(store, nil, 0x4C, false) + Flags.setFlag(store, nil, 0x4D, true) + Flags.setFlag(store, nil, 0x2D3, false) + elseif not b3f_boulder3_dropped and b3f_boulder5_dropped then + Flags.setFlag(store, nil, 0x4C, true) + Flags.setFlag(store, nil, 0x4D, false) + Flags.setFlag(store, nil, 0x2D3, false) + else + -- Both dropped + Flags.setFlag(store, nil, 0x4C, false) + Flags.setFlag(store, nil, 0x4D, false) + Flags.setFlag(store, nil, 0x2D3, true) + end + + -- B1F..B3F boulders and current + local f1_boulder1_dropped = Flags.getFlag(store, nil, 0x40) -- FLAG_HIDE_SEAFOAM_1F_BOULDER_1 + local f1_boulder2_dropped = Flags.getFlag(store, nil, 0x41) -- FLAG_HIDE_SEAFOAM_1F_BOULDER_2 + + if not f1_boulder1_dropped then + Flags.setFlag(store, nil, 0x42, true) -- B1F boulder 1 + Flags.setFlag(store, nil, 0x44, true) -- B2F boulder 1 + Flags.setFlag(store, nil, 0x46, true) -- B3F boulder 1 + end + + if not f1_boulder2_dropped then + Flags.setFlag(store, nil, 0x43, true) -- B1F boulder 2 + Flags.setFlag(store, nil, 0x45, true) -- B2F boulder 2 + Flags.setFlag(store, nil, 0x47, true) -- B3F boulder 2 + end + + if not f1_boulder1_dropped and not f1_boulder2_dropped then + Flags.setFlag(store, nil, 0x2D2, false) -- FLAG_STOPPED_SEAFOAM_B3F_CURRENT + end +end + +--- Repair/normalize legacy saves that missed initial hide flags. +function Flags.repairSaveState(store) + if not store then return end + Flags.ensurePalletOakHidden(store) + Flags.repairSeafoamFlags(store) + + -- Bill human in sea cottage: if helped flag (0x233) is false, human is hidden (0x033) + if not Flags.getFlag(store, nil, 0x233) then + Flags.setFlag(store, nil, 0x033, true) + end + + -- Running shoes guy in Pewter: if badge 1 (0x820) is false, guy is hidden (0x092) + if not Flags.getFlag(store, nil, 0x820) then + Flags.setFlag(store, nil, 0x092, true) + end + + -- Fuji in Lavender house: if rescued (0x23C) is false, Fuji in house is hidden (0x035) + if not Flags.getFlag(store, nil, 0x23C) then + Flags.setFlag(store, nil, 0x035, true) + end + + -- Oak in champ room: if champion defeated (0x4BC) is false, oak in champ room is hidden (0x05A) + if not Flags.getFlag(store, nil, 0x4BC) then + Flags.setFlag(store, nil, 0x05A, true) + end +end + function Flags.newStore(seed) local store = { flags = {}, @@ -213,6 +291,7 @@ function Flags.loadInto(store, saved) store.vars[id] = tonumber(v) or 0 end end + Flags.repairSaveState(store) return store end @@ -301,6 +380,7 @@ function Flags.onMapLoad(store) Flags.setFlag(store, nil, fid, false) end end + Flags.repairSaveState(store) end return Flags diff --git a/src/core/game3/scripting/flags_table.lua b/src/core/game3/scripting/flags_table.lua index 3219a161..28ede38d 100644 --- a/src/core/game3/scripting/flags_table.lua +++ b/src/core/game3/scripting/flags_table.lua @@ -3531,6 +3531,14 @@ FlagsTable.NEW_GAME_HIDE_FLAGS = { 0x03D, -- FLAG_HIDE_SS_ANNE_RIVAL (61) 0x0A1, -- FLAG_HIDE_VERMILION_CITY_OAKS_AIDE (161) 0x03F, -- FLAG_HIDE_SAFFRON_CIVILIANS (63) + 0x042, -- FLAG_HIDE_SEAFOAM_B1F_BOULDER_1 (66) + 0x043, -- FLAG_HIDE_SEAFOAM_B1F_BOULDER_2 (67) + 0x044, -- FLAG_HIDE_SEAFOAM_B2F_BOULDER_1 (68) + 0x045, -- FLAG_HIDE_SEAFOAM_B2F_BOULDER_2 (69) + 0x046, -- FLAG_HIDE_SEAFOAM_B3F_BOULDER_1 (70) + 0x047, -- FLAG_HIDE_SEAFOAM_B3F_BOULDER_2 (71) + 0x04C, -- FLAG_HIDE_SEAFOAM_B4F_BOULDER_1 (76) + 0x04D, -- FLAG_HIDE_SEAFOAM_B4F_BOULDER_2 (77) 0x04F, -- FLAG_HIDE_ROUTE_22_RIVAL (79) 0x05A, -- FLAG_HIDE_OAK_IN_CHAMP_ROOM (90) 0x0A3, -- FLAG_HIDE_CREDITS_RIVAL (163) diff --git a/src/core/game3/scripting/natives.lua b/src/core/game3/scripting/natives.lua index f75688d8..2fe06362 100644 --- a/src/core/game3/scripting/natives.lua +++ b/src/core/game3/scripting/natives.lua @@ -807,12 +807,14 @@ local KNOWN_MODULES = { "natives_elevator", "natives_events", "natives_fame", + "natives_fan_club", "natives_gift", "natives_link", "natives_listmenu", "natives_moveteach", "natives_queries", "natives_seagallop", + "natives_size_record", "natives_tower", "natives_trade", } diff --git a/src/core/game3/scripting/natives_events.lua b/src/core/game3/scripting/natives_events.lua index 04d02905..26ce25a8 100644 --- a/src/core/game3/scripting/natives_events.lua +++ b/src/core/game3/scripting/natives_events.lua @@ -27,33 +27,43 @@ local function flagsMod() return require("src.core.game3.scripting.flags") end -local function sessionOf() +local function sessionOf(ctx) local rt = package.loaded["src.core.game3.runtime"] - return rt and rt.getSession and rt.getSession() or nil + return (rt and rt.getSession and rt.getSession()) + or (ctx and ctx.session) + or nil end -local function scriptStore() +local function scriptStore(ctx) local Space = package.loaded["src.core.game3.scripting.space"] - local session = sessionOf() - return (Space and Space.store) or (session and session.store) or nil + local session = sessionOf(ctx) + return (Space and Space.store) + or (session and (session.store or session)) + or (ctx and (ctx.store or ctx.session or (ctx.vars and ctx))) + or nil end local function varGet(ctx, id) - return tonumber(flagsMod().getVar(scriptStore(), ctx, id)) or 0 + return tonumber(flagsMod().getVar(scriptStore(ctx), ctx, id)) or 0 end -- pokefirered/src/scrcmd.c:99 local function setResult(ctx, value) - flagsMod().setVar(scriptStore(), ctx, VAR_RESULT, tonumber(value) or 0) + flagsMod().setVar(scriptStore(ctx), ctx, VAR_RESULT, tonumber(value) or 0) end -local function currentMapId() - local session = sessionOf() +local function currentMapId(ctx) + local session = sessionOf(ctx) if session and session.map then return session.map end local Map = package.loaded["src.core.game3.map"] return Map and Map.current end +local function partyOf(ctx) + local session = sessionOf(ctx) + return (session and session.party) or (ctx and ctx.party) or {}, session +end + local function noop() return false end @@ -95,35 +105,54 @@ Events.HANDLERS = { local okF, Field = pcall(require, "src.core.game3.field") if not (okF and Field and Field.setMetatile) then return false end local Flags = flagsMod() - local store = scriptStore() + local store = scriptStore(ctx) for i = 1, #ICEFALL_CAVE_ICE_COORDS do if Flags.getFlag(store, ctx, i) then - local coord = ICEFALL_CAVE_ICE_COORDS[i] - Field.setMetatile(coord[1], coord[2], METATILE_SEAFOAM_CRACKED_ICE, false) + local c = ICEFALL_CAVE_ICE_COORDS[i] + Field.setMetatile(c[1], c[2], METATILE_SEAFOAM_CRACKED_ICE, true) end end return false end, - -- pokefirered/src/field_specials.c:97 - [Std.SPECIAL.ForcePlayerOntoBike] = function() - local okP, Player = pcall(require, "src.core.game3.player") - if okP and Player and not Player.surfing then - Player.biking = true - Player.running = false + -- pokefirered/src/field_tasks.c:166 + [Std.SPECIAL.ShowIcefallCaveCrackedIceAttempt] = function(ctx, adapters) + local Flags = flagsMod() + local store = scriptStore(ctx) + local total = 0 + for i = 1, #ICEFALL_CAVE_ICE_COORDS do + if Flags.getFlag(store, ctx, i) then + total = total + 1 + end end - local okA, Audio = pcall(require, "src.core.game3.audio") - if okA and Audio and Audio.playSong then pcall(Audio.playSong, MUS_CYCLING) end + setResult(ctx, total) return false end, - -- pokefirered/src/field_specials.c:1513 - [Std.SPECIAL.ForcePlayerToStartSurfing] = function() - local okP, Player = pcall(require, "src.core.game3.player") - if not (okP and Player) then return false end - Player.biking = false - Player.running = false - Player.surfHopping = false - Player.dismounting = false - Player.surfing = true + -- pokefirered/src/bicycle.c:120 + [Std.SPECIAL.ForcePlayerOntoBike] = function(ctx) + local session = sessionOf(ctx) + if session and session.player then + session.player.ridingBike = true + session.player.state = "bike" + end + local rt = package.loaded["src.core.game3.runtime"] + if rt and rt.player then + rt.player.ridingBike = true + rt.player.state = "bike" + end + return false + end, + -- pokefirered/src/field_player_avatar.c:1570 + [Std.SPECIAL.ForcePlayerToStartSurfing] = function(ctx) + local session = sessionOf(ctx) + if session and session.player then + session.player.surfing = true + session.player.state = "surf" + end + local rt = package.loaded["src.core.game3.runtime"] + if rt and rt.player then + rt.player.surfing = true + rt.player.state = "surf" + end return false end, -- pokefirered/src/wild_encounter.c:446 @@ -131,7 +160,7 @@ Events.HANDLERS = { local okE, Enc = pcall(require, "src.core.game3.encounters") local foe if okE and Enc and type(Enc.rollRocks) == "function" then - foe = Enc.rollRocks(currentMapId()) + foe = Enc.rollRocks(currentMapId(ctx)) end if not (foe and adapters and adapters.startWildBattle) then setResult(ctx, 0) @@ -149,8 +178,8 @@ Events.HANDLERS = { end) end, -- pokefirered/src/save_location.c:105 - [Std.SPECIAL.SetPostgameFlags] = function() - local session = sessionOf() + [Std.SPECIAL.SetPostgameFlags] = function(ctx) + local session = sessionOf(ctx) if not session then return false end local Bit = require("bit") session.gcnLinkFlags = Bit.bor(tonumber(session.gcnLinkFlags) or 0, 0x800E) @@ -160,16 +189,153 @@ Events.HANDLERS = { end, -- pokefirered/src/field_specials.c:120 ShowFieldMessageStringVar4 [Std.SPECIAL.ShowFieldMessageStringVar4] = function(ctx, adapters) - local body = tostring((ctx and ctx.stringVars and ctx.stringVars[4]) or "") - if body == "" then return false end - -- pokefirered/src/field_message_box.c:65 ShowFieldMessage - ctx.messageOpen = true - ctx.printerDone = false - local open = adapters and (adapters.openMessageStay or adapters.openMessageAsync) - if open then - open(body, nil) - elseif adapters and adapters.openMessage then - adapters.openMessage(body) + local text = (ctx and ctx.stringVars and ctx.stringVars[4]) or "" + if adapters and adapters.showMessage then + adapters.showMessage(text) + end + return false + end, + -- pokefirered/src/script.c:260 SetWalkingIntoSignVars + [Std.SPECIAL.SetWalkingIntoSignVars] = function(ctx) + if ctx then + ctx.walkAwayFromSignInhibitTimer = 6 + ctx.msgBoxIsCancelable = true + ctx.canWalkAway = true + end + local session = sessionOf(ctx) + if session then + session.walkAwayFromSignInhibitTimer = 6 + session.msgBoxIsCancelable = true + end + return false + end, + -- pokefirered/src/field_specials.c:1733 StickerManGetBragFlags + [Std.SPECIAL.StickerManGetBragFlags] = function(ctx) + local session = sessionOf(ctx) + local Flags = flagsMod() + local store = scriptStore(ctx) + local stats = session and (session.gameStats or session.stats) or {} + + -- HOF enters: GAME_STAT_ENTERED_HOF = 13 (or hofClears / FLAG_SYS_GAME_CLEAR) + local hof = (session and (session.hofClears or session.hallOfFameCount)) + or stats[13] or stats.enteredHof or (Flags.getFlag(store, ctx, "FLAG_SYS_GAME_CLEAR") and 1 or 0) + hof = tonumber(hof) or 0 + + -- Hatched eggs: GAME_STAT_HATCHED_EGGS = 18 + local eggs = (session and session.eggsHatched) or stats[18] or stats.hatchedEggs or 0 + eggs = tonumber(eggs) or 0 + local eggsClamped = math.min(0xFFFF, eggs) + + -- Link battle wins: GAME_STAT_LINK_BATTLE_WINS = 24 + local linkWins = (session and (session.linkWins or session.linkBattleWins)) + or stats[24] or stats.linkBattleWins or 0 + linkWins = tonumber(linkWins) or 0 + + Flags.setVar(store, ctx, VAR_0x8004, hof) + Flags.setVar(store, ctx, VAR_0x8005, eggsClamped) + Flags.setVar(store, ctx, VAR_0x8006, linkWins) + + local result = 0 + if hof ~= 0 then result = result + 1 end + if eggsClamped ~= 0 then result = result + 2 end + if linkWins ~= 0 then result = result + 4 end + + Flags.setVar(store, ctx, 0x8008, result) + setResult(ctx, result) + return false, result + end, + -- pokefirered/src/field_specials.c:1710 UpdateTrainerCardPhotoIcons + [Std.SPECIAL.UpdateTrainerCardPhotoIcons] = function(ctx) + local party, session = partyOf(ctx) + local Flags = flagsMod() + local store = scriptStore(ctx) + local partyCount = (party and #party) or 0 + + local VAR_TRAINER_CARD_MON_ICON_1 = 0x4043 + local VAR_TRAINER_CARD_MON_ICON_TINT_IDX = 0x4042 + + for i = 1, 6 do + local iconSpecies = 0 + if party and i <= partyCount and party[i] then + local mon = party[i] + if mon.isEgg then + iconSpecies = 412 -- SPECIES_EGG + else + iconSpecies = tonumber(mon.speciesId or mon.species) or 0 + end + end + Flags.setVar(store, ctx, VAR_TRAINER_CARD_MON_ICON_1 + i - 1, iconSpecies) + end + + local tint = varGet(ctx, VAR_0x8004) + Flags.setVar(store, ctx, VAR_TRAINER_CARD_MON_ICON_TINT_IDX, tint) + return false + end, + -- pokefirered/src/field_player_avatar.c:1603 SeafoamIslandsB4F_CurrentDumpsPlayerOnLand + [Std.SPECIAL.SeafoamIslandsB4F_CurrentDumpsPlayerOnLand] = function(ctx, adapters) + local function finishDismount() + local session = sessionOf(ctx) + if session then + session.surfing = false + if session.player then + session.player.surfing = false + session.player.state = "walk" + session.player.facing = "up" + end + session.facing = "up" + end + local rt = package.loaded["src.core.game3.runtime"] + if rt and rt.player then + rt.player.surfing = false + rt.player.state = "walk" + rt.player.facing = "up" + end + local okP, Player = pcall(require, "src.core.game3.player") + if okP and Player then + Player.surfing = false + Player.state = "walk" + Player.facing = "up" + end + local Field = package.loaded["src.core.game3.field"] + if Field and Field.stopSurfing then + pcall(Field.stopSurfing) + end + end + + if adapters and adapters.applyMovement then + local Natives = require("src.core.game3.scripting.natives") + return Natives.yieldHost(ctx, adapters, function(done) + -- 0xA7 = MOVEMENT_ACTION_JUMP_SPECIAL_WITH_EFFECT_UP (jump 1 cell up onto stairs) + adapters.applyMovement(255, { 0xA7, 0xFE }, function() + finishDismount() + done() + end) + end) + else + local okP, Player = pcall(require, "src.core.game3.player") + if okP and Player and Player.cellY then + Player.cellY = Player.cellY - 1 + Player.targetY = Player.cellY + Player.py = Player.cellY * 16 + end + finishDismount() + return false + end + end, + -- pokefirered/src/start_menu.c:620 Field_AskSaveTheGame + [Std.SPECIAL.Field_AskSaveTheGame] = function(ctx, adapters) + local okL, Link = pcall(require, "src.core.game3.link.init") + if okL and Link and Link.askSaveTheGame then + return Link.askSaveTheGame(ctx, adapters) + end + setResult(ctx, 0) + return false + end, + -- pokefirered/src/load_save.c:208 LoadPlayerBag + [Std.SPECIAL.LoadPlayerBag] = function() + local okL, Link = pcall(require, "src.core.game3.link.init") + if okL and Link and Link.loadPlayerBag then + Link.loadPlayerBag() end return false end, diff --git a/src/core/game3/scripting/natives_fan_club.lua b/src/core/game3/scripting/natives_fan_club.lua new file mode 100644 index 00000000..33a8879d --- /dev/null +++ b/src/core/game3/scripting/natives_fan_club.lua @@ -0,0 +1,94 @@ +-- Script specials for Trainer Fan Club (pokefirered/src/trainer_fan_club.c). +-- Covers specials 0xA3 through 0xAA (pokefirered/data/specials.inc:174-181). + +local Std = require("src.core.game3.scripting.stdscripts") +local TrainerFanClub = require("src.core.game3.trainer_fan_club") + +local FanClub = {} + +local VAR_RESULT = 0x800D -- pokefirered/include/constants/vars.h:328 +local VAR_0x8004 = 0x8004 -- pokefirered/include/constants/vars.h:319 + +local function flagsMod() + return require("src.core.game3.scripting.flags") +end + +local function sessionOf(ctx) + if ctx and ctx.session then return ctx.session end + local Space = package.loaded["src.core.game3.scripting.space"] + if Space and Space.store and Space.store.flags then return Space.store end + local rt = package.loaded["src.core.game3.runtime"] + return rt and rt.getSession and rt.getSession() or nil +end + +local function scriptStore(ctx) + local Space = package.loaded["src.core.game3.scripting.space"] + local session = sessionOf(ctx) + return (Space and Space.store) or (ctx and ctx.session) or (session and session.store) or session or nil +end + +local function varGet(ctx, id) + return tonumber(flagsMod().getVar(scriptStore(ctx), ctx, id)) or 0 +end + +local function setResult(ctx, value) + flagsMod().setVar(scriptStore(ctx), ctx, VAR_RESULT, tonumber(value) or 0) +end + +FanClub.HANDLERS = { + -- pokefirered/src/trainer_fan_club.c:223 Script_IsFanClubMemberFanOfPlayer + [Std.SPECIAL.Script_IsFanClubMemberFanOfPlayer] = function(ctx) + local memberId = varGet(ctx, VAR_0x8004) + local isFan = TrainerFanClub.isFanClubMemberFanOfPlayer(sessionOf(ctx), ctx, memberId) + setResult(ctx, isFan and 1 or 0) + return false + end, + + -- pokefirered/src/trainer_fan_club.c:170 Script_GetNumFansOfPlayerInTrainerFanClub + [Std.SPECIAL.Script_GetNumFansOfPlayerInTrainerFanClub] = function(ctx) + local count = TrainerFanClub.getNumFansOfPlayerInTrainerFanClub(sessionOf(ctx), ctx) + setResult(ctx, count) + return false + end, + + -- pokefirered/src/trainer_fan_club.c:240 Script_BufferFanClubTrainerName + [Std.SPECIAL.Script_BufferFanClubTrainerName] = function(ctx, adapters) + local memberId = varGet(ctx, VAR_0x8004) + TrainerFanClub.bufferFanClubTrainerName(sessionOf(ctx), ctx, adapters, memberId) + return false + end, + + -- pokefirered/src/trainer_fan_club.c:40 Script_TryLoseFansFromPlayTimeAfterLinkBattle + [Std.SPECIAL.Script_TryLoseFansFromPlayTimeAfterLinkBattle] = function(ctx) + TrainerFanClub.tryLoseFansFromPlayTimeAfterLinkBattle(sessionOf(ctx), ctx) + return false + end, + + -- pokefirered/src/trainer_fan_club.c:189 Script_TryLoseFansFromPlayTime + [Std.SPECIAL.Script_TryLoseFansFromPlayTime] = function(ctx) + TrainerFanClub.tryLoseFansFromPlayTime(sessionOf(ctx), ctx) + return false + end, + + -- pokefirered/src/trainer_fan_club.c:334 Script_SetPlayerGotFirstFans + [Std.SPECIAL.Script_SetPlayerGotFirstFans] = function(ctx) + TrainerFanClub.setPlayerGotFirstFans(sessionOf(ctx), ctx) + return false + end, + + -- pokefirered/src/trainer_fan_club.c:54 Script_UpdateTrainerFanClubGameClear + [Std.SPECIAL.Script_UpdateTrainerFanClubGameClear] = function(ctx) + TrainerFanClub.updateTrainerFanClubGameClear(sessionOf(ctx), ctx) + return false + end, + + -- pokefirered/src/trainer_fan_club.c:344 Script_TryGainNewFanFromCounter + [Std.SPECIAL.Script_TryGainNewFanFromCounter] = function(ctx) + local counterIdx = varGet(ctx, VAR_0x8004) + local timer = TrainerFanClub.tryGainNewFanFromCounter(sessionOf(ctx), ctx, counterIdx) + setResult(ctx, timer) + return false + end, +} + +return FanClub diff --git a/src/core/game3/scripting/natives_link.lua b/src/core/game3/scripting/natives_link.lua index 3228a955..83f0bcd3 100644 --- a/src/core/game3/scripting/natives_link.lua +++ b/src/core/game3/scripting/natives_link.lua @@ -1,4 +1,4 @@ -local Link = require("src.core.game3.link") +local Link = require("src.core.game3.link.init") local Union = require("src.core.game3.link.union_room") local LinkBattle = require("src.core.game3.link.battle") local LinkTrade = require("src.core.game3.link.trade") diff --git a/src/core/game3/scripting/natives_size_record.lua b/src/core/game3/scripting/natives_size_record.lua new file mode 100644 index 00000000..2a7017e3 --- /dev/null +++ b/src/core/game3/scripting/natives_size_record.lua @@ -0,0 +1,68 @@ +-- Script specials for Size Records and Prof Oak's Rating (pokefirered/src/pokemon_size_record.c, prof_pc.c). +-- Covers specials 0x77-0x7A and 0xD5 (pokefirered/data/specials.inc:130-133, 224). + +local Std = require("src.core.game3.scripting.stdscripts") +local SizeRecord = require("src.core.game3.pokemon_size_record") +local PokedexRating = require("src.core.game3.pokedex_rating") + +local SizeRecordNatives = {} + +local VAR_RESULT = 0x800D + +local function flagsMod() + return require("src.core.game3.scripting.flags") +end + +local function sessionOf(ctx) + if ctx and ctx.session then return ctx.session end + local Space = package.loaded["src.core.game3.scripting.space"] + if Space and Space.store and Space.store.flags then return Space.store end + local rt = package.loaded["src.core.game3.runtime"] + return rt and rt.getSession and rt.getSession() or nil +end + +local function scriptStore(ctx) + local Space = package.loaded["src.core.game3.scripting.space"] + local session = sessionOf(ctx) + return (Space and Space.store) or (ctx and ctx.session) or (session and session.store) or session or nil +end + +local function setResult(ctx, value) + flagsMod().setVar(scriptStore(ctx), ctx, VAR_RESULT, tonumber(value) or 0) +end + +SizeRecordNatives.HANDLERS = { + -- pokefirered/src/pokemon_size_record.c:160 GetHeracrossSizeRecordInfo + [Std.SPECIAL.GetHeracrossSizeRecordInfo] = function(ctx, adapters) + SizeRecord.getMonSizeRecordInfo(sessionOf(ctx), ctx, adapters, SizeRecord.SPECIES_HERACROSS, SizeRecord.VAR_HERACROSS_SIZE_RECORD) + return false + end, + + -- pokefirered/src/pokemon_size_record.c:167 CompareHeracrossSize + [Std.SPECIAL.CompareHeracrossSize] = function(ctx, adapters) + local code = SizeRecord.compareMonSize(sessionOf(ctx), ctx, adapters, SizeRecord.SPECIES_HERACROSS, SizeRecord.VAR_HERACROSS_SIZE_RECORD) + setResult(ctx, code) + return false + end, + + -- pokefirered/src/pokemon_size_record.c:179 GetMagikarpSizeRecordInfo + [Std.SPECIAL.GetMagikarpSizeRecordInfo] = function(ctx, adapters) + SizeRecord.getMonSizeRecordInfo(sessionOf(ctx), ctx, adapters, SizeRecord.SPECIES_MAGIKARP, SizeRecord.VAR_MAGIKARP_SIZE_RECORD) + return false + end, + + -- pokefirered/src/pokemon_size_record.c:186 CompareMagikarpSize + [Std.SPECIAL.CompareMagikarpSize] = function(ctx, adapters) + local code = SizeRecord.compareMonSize(sessionOf(ctx), ctx, adapters, SizeRecord.SPECIES_MAGIKARP, SizeRecord.VAR_MAGIKARP_SIZE_RECORD) + setResult(ctx, code) + return false + end, + + -- pokefirered/src/prof_pc.c:106 GetProfOaksRatingMessage + [Std.SPECIAL.GetProfOaksRatingMessage] = function(ctx, adapters) + PokedexRating.getProfOaksRatingMessage(sessionOf(ctx), ctx, adapters) + return false + end, +} + +return SizeRecordNatives diff --git a/src/core/game3/scripting/stdscripts.lua b/src/core/game3/scripting/stdscripts.lua index d3ac6e68..d85743cc 100644 --- a/src/core/game3/scripting/stdscripts.lua +++ b/src/core/game3/scripting/stdscripts.lua @@ -67,6 +67,10 @@ Std.SPECIAL = { StartRegiBattle = 0x139, -- 313 (pokefirered/data/specials.inc:324) StartSouthernIslandBattle = 0x143, -- 323 (pokefirered/data/specials.inc:334) SetVermilionTrashCans = 0x15B, -- 347 (pokefirered/data/specials.inc:358) + GetHeracrossSizeRecordInfo = 0x77, -- pokefirered/data/specials.inc:130 + CompareHeracrossSize = 0x78, -- pokefirered/data/specials.inc:131 + GetMagikarpSizeRecordInfo = 0x79, -- pokefirered/data/specials.inc:132 + CompareMagikarpSize = 0x7A, -- pokefirered/data/specials.inc:133 NameRaterWasNicknameChanged = 0x7B, -- pokefirered/data/specials.inc:134 CalculatePlayerPartyCount = 0x83, -- pokefirered/data/specials.inc:142 CountPartyNonEggMons = 0x84, -- pokefirered/data/specials.inc:143 @@ -77,6 +81,7 @@ Std.SPECIAL = { IsEnoughForCostInVar0x8005 = 0xC5, -- pokefirered/data/specials.inc:208 SubtractMoneyFromVar0x8005 = 0xC6, -- pokefirered/data/specials.inc:209 GetPokedexCount = 0xD4, -- pokefirered/data/specials.inc:223 + GetProfOaksRatingMessage = 0xD5, -- pokefirered/data/specials.inc:224 GetRandomSlotMachineId = 0x11E, -- pokefirered/data/specials.inc:297 IsThereRoomInAnyBoxForMorePokemon = 0x130, -- pokefirered/data/specials.inc:315 GetPartyMonSpecies = 0x147, -- pokefirered/data/specials.inc:338 @@ -124,16 +129,31 @@ Std.SPECIAL = { BufferUnionRoomPlayerName = 0x183, -- pokefirered/data/specials.inc:398 ShowFieldMessageStringVar4 = 0x8D, -- pokefirered/data/specials.inc:152 DrawWholeMapView = 0x8E, -- pokefirered/data/specials.inc:153 + Script_IsFanClubMemberFanOfPlayer = 0xA3, -- pokefirered/data/specials.inc:174 + Script_GetNumFansOfPlayerInTrainerFanClub = 0xA4, -- pokefirered/data/specials.inc:175 + Script_BufferFanClubTrainerName = 0xA5, -- pokefirered/data/specials.inc:176 + Script_TryLoseFansFromPlayTimeAfterLinkBattle = 0xA6, -- pokefirered/data/specials.inc:177 + Script_TryLoseFansFromPlayTime = 0xA7, -- pokefirered/data/specials.inc:178 + Script_SetPlayerGotFirstFans = 0xA8, -- pokefirered/data/specials.inc:179 + Script_UpdateTrainerFanClubGameClear = 0xA9, -- pokefirered/data/specials.inc:180 + Script_TryGainNewFanFromCounter = 0xAA, -- pokefirered/data/specials.inc:181 RockSmashWildEncounter = 0xAB, -- pokefirered/data/specials.inc:182 EnterSafariMode = 0xCD, -- pokefirered/data/specials.inc:216 ExitSafariMode = 0xCE, -- pokefirered/data/specials.inc:217 InitRoamer = 0x129, -- pokefirered/data/specials.inc:308 SetIcefallCaveCrackedIceMetatiles = 0x135, -- pokefirered/data/specials.inc:320 + ShowIcefallCaveCrackedIceAttempt = 0x136, -- pokefirered/data/specials.inc:321 ShakeScreen = 0x136, -- pokefirered/data/specials.inc:321 SetPostgameFlagsUnusedSlot = 0x155, -- pokefirered/data/specials.inc:352 ForcePlayerOntoBike = 0x157, -- pokefirered/data/specials.inc:354 SampleResortGorgeousMonAndReward = 0x15D, -- pokefirered/data/specials.inc:360 ForcePlayerToStartSurfing = 0x161, -- pokefirered/data/specials.inc:364 + Field_AskSaveTheGame = 0x5D, -- pokefirered/data/specials.inc:93 + LoadPlayerBag = 0x14B, -- pokefirered/data/specials.inc:331 + SeafoamIslandsB4F_CurrentDumpsPlayerOnLand = 0x15C, -- pokefirered/data/specials.inc:348 + UpdateTrainerCardPhotoIcons = 0x167, -- pokefirered/data/specials.inc:359 + StickerManGetBragFlags = 0x168, -- pokefirered/data/specials.inc:360 + SetWalkingIntoSignVars = 0x170, -- pokefirered/data/specials.inc:368 DisableMsgBoxWalkaway = 0x171, -- pokefirered/data/specials.inc:380 SetPostgameFlags = 0x19A, -- pokefirered/data/specials.inc:421 SetDeoxysTrianglePalette = 0x1AC, -- pokefirered/data/specials.inc:439 diff --git a/src/core/game3/trainer_fan_club.lua b/src/core/game3/trainer_fan_club.lua new file mode 100644 index 00000000..aa5a4371 --- /dev/null +++ b/src/core/game3/trainer_fan_club.lua @@ -0,0 +1,383 @@ +-- FRLG Trainer Fan Club system (pokefirered/src/trainer_fan_club.c). +-- Manages fan states, Saffron fan club house members, playtime decay, and link battle updates. + +local band, bor, bxor, lshift, rshift = bit.band, bit.bor, bit.bxor, bit.lshift, bit.rshift + +local TrainerFanClub = {} + +-- Member indices (pokefirered/include/constants/trainer_fan_club.h) +TrainerFanClub.MEMBER = { + MEMBER1 = 0, -- Crush Girl (Battle Girl) + MEMBER2 = 1, -- Youngster + MEMBER3 = 2, -- Gentleman + MEMBER4 = 3, -- Little Girl + MEMBER5 = 4, -- Rocker + MEMBER6 = 5, -- Woman + MEMBER7 = 6, -- Beauty + MEMBER8 = 7, -- Black Belt +} + +TrainerFanClub.NUM_MEMBERS = 8 + +-- Variables and Flags (pokefirered/include/constants/vars.h, flags.h) +TrainerFanClub.VAR_FANCLUB_FAN_COUNTER = 0x4038 +TrainerFanClub.VAR_FANCLUB_LOSE_FAN_TIMER = 0x4039 +TrainerFanClub.VAR_MAP_SCENE_SAFFRON_CITY_POKEMON_TRAINER_FAN_CLUB = 0x4073 + +TrainerFanClub.FLAG_HIDE_SAFFRON_FAN_CLUB_BLACK_BELT = 0x6C +TrainerFanClub.FLAG_HIDE_SAFFRON_FAN_CLUB_ROCKER = 0x6D +TrainerFanClub.FLAG_HIDE_SAFFRON_FAN_CLUB_WOMAN = 0x6E +TrainerFanClub.FLAG_HIDE_SAFFRON_FAN_CLUB_BEAUTY = 0x6F + +-- Counter increment array (pokefirered/src/trainer_fan_club.c:74) +local sCounterIncrements = { [0] = 2, [1] = 1, [2] = 2, [3] = 1 } +TrainerFanClub.COUNTER_INCREMENTS = sCounterIncrements + +-- Candidate search order for gaining a fan (pokefirered/src/trainer_fan_club.c:100) +local sGainOrder = { 1, 3, 5, 0, 7, 6, 4, 2 } +TrainerFanClub.GAIN_ORDER = sGainOrder + +-- Candidate search order for losing a fan (pokefirered/src/trainer_fan_club.c:133) +local sLoseOrder = { 5, 6, 3, 7, 4, 1, 0, 2 } +TrainerFanClub.LOSE_ORDER = sLoseOrder + +--- Bit-unpack VAR_FANCLUB_FAN_COUNTER (u16) +--- bits 0..6: timer (0..127) +--- bit 7: gotInitialFans (0 or 1) +--- bits 8..15: fanFlags (8-bit bitmask for members 0..7) +function TrainerFanClub.unpack(raw) + raw = band(tonumber(raw) or 0, 0xFFFF) + local timer = band(raw, 0x7F) + local gotInitialFans = band(raw, 0x80) ~= 0 + local fanFlags = band(rshift(raw, 8), 0xFF) + return timer, gotInitialFans, fanFlags +end + +--- Bit-pack into 16-bit VAR_FANCLUB_FAN_COUNTER +function TrainerFanClub.pack(timer, gotInitialFans, fanFlags) + timer = band(math.max(0, math.min(127, tonumber(timer) or 0)), 0x7F) + local initialBit = gotInitialFans and 0x80 or 0 + local flagsByte = band(tonumber(fanFlags) or 0, 0xFF) + return band(bor(timer, initialBit, lshift(flagsByte, 8)), 0xFFFF) +end + +function TrainerFanClub.getFanFlag(fanFlags, memberId) + memberId = tonumber(memberId) or 0 + if memberId < 0 or memberId >= 8 then return false end + return band(rshift(fanFlags, memberId), 1) == 1 +end + +function TrainerFanClub.setFanFlag(fanFlags, memberId) + memberId = tonumber(memberId) or 0 + if memberId < 0 or memberId >= 8 then return fanFlags end + return band(bor(fanFlags, lshift(1, memberId)), 0xFF) +end + +function TrainerFanClub.flipFanFlag(fanFlags, memberId) + memberId = tonumber(memberId) or 0 + if memberId < 0 or memberId >= 8 then return fanFlags end + return band(bxor(fanFlags, lshift(1, memberId)), 0xFF) +end + +function TrainerFanClub.countFans(fanFlags) + local count = 0 + fanFlags = band(tonumber(fanFlags) or 0, 0xFF) + for i = 0, TrainerFanClub.NUM_MEMBERS - 1 do + if band(rshift(fanFlags, i), 1) == 1 then + count = count + 1 + end + end + return count +end + +local function flagsMod() + return require("src.core.game3.scripting.flags") +end + +function TrainerFanClub.sessionOf(ctx) + if ctx and ctx.session then return ctx.session end + local Space = package.loaded["src.core.game3.scripting.space"] + if Space and Space.store and Space.store.flags then return Space.store end + local rt = package.loaded["src.core.game3.runtime"] + return rt and rt.getSession and rt.getSession() or nil +end + +local function scriptStore(session, ctx) + local Space = package.loaded["src.core.game3.scripting.space"] + return (Space and Space.store) or (ctx and ctx.session) or (session and session.store) or session or nil +end + +function TrainerFanClub.getVar(session, ctx, varId) + session = session or TrainerFanClub.sessionOf(ctx) + return tonumber(flagsMod().getVar(scriptStore(session, ctx), ctx, varId)) or 0 +end + +function TrainerFanClub.setVar(session, ctx, varId, value) + session = session or TrainerFanClub.sessionOf(ctx) + flagsMod().setVar(scriptStore(session, ctx), ctx, varId, band(tonumber(value) or 0, 0xFFFF)) +end + +function TrainerFanClub.getFlag(session, ctx, flagId) + session = session or TrainerFanClub.sessionOf(ctx) + return flagsMod().getFlag(scriptStore(session, ctx), ctx, flagId) +end + +function TrainerFanClub.setFlag(session, ctx, flagId, value) + session = session or TrainerFanClub.sessionOf(ctx) + flagsMod().setFlag(scriptStore(session, ctx), ctx, flagId, value) +end + +function TrainerFanClub.clearFlag(session, ctx, flagId) + TrainerFanClub.setFlag(session, ctx, flagId, false) +end + +function TrainerFanClub.getFanClubData(session, ctx) + local raw = TrainerFanClub.getVar(session, ctx, TrainerFanClub.VAR_FANCLUB_FAN_COUNTER) + return TrainerFanClub.unpack(raw) +end + +function TrainerFanClub.setFanClubData(session, ctx, timer, gotInitialFans, fanFlags) + local packed = TrainerFanClub.pack(timer, gotInitialFans, fanFlags) + TrainerFanClub.setVar(session, ctx, TrainerFanClub.VAR_FANCLUB_FAN_COUNTER, packed) +end + +function TrainerFanClub.getPlayTimeHours(session, ctx) + session = session or TrainerFanClub.sessionOf(ctx) + if not session then return 0 end + local pt = session.playtime or session.playTime + if type(pt) == "table" and pt.hours ~= nil then + return tonumber(pt.hours) or 0 + end + if session.playTimeHours ~= nil then + return tonumber(session.playTimeHours) or 0 + end + return 0 +end + +function TrainerFanClub.setStringVar(ctx, adapters, index, text) + if adapters and adapters.setStringVar then adapters.setStringVar(index, text) end + if ctx and ctx.stringVars then ctx.stringVars[index] = text end +end + +--- pokefirered/src/trainer_fan_club.c:34 ResetTrainerFanClub +function TrainerFanClub.reset(session, ctx) + TrainerFanClub.setVar(session, ctx, TrainerFanClub.VAR_FANCLUB_FAN_COUNTER, 0) + TrainerFanClub.setVar(session, ctx, TrainerFanClub.VAR_FANCLUB_LOSE_FAN_TIMER, 0) +end + +--- pokefirered/src/trainer_fan_club.c:228 IsFanClubMemberFanOfPlayer +function TrainerFanClub.isFanClubMemberFanOfPlayer(session, ctx, memberId) + local _, _, fanFlags = TrainerFanClub.getFanClubData(session, ctx) + return TrainerFanClub.getFanFlag(fanFlags, memberId) +end + +--- pokefirered/src/trainer_fan_club.c:175 GetNumFansOfPlayerInTrainerFanClub +function TrainerFanClub.getNumFansOfPlayerInTrainerFanClub(session, ctx) + local _, _, fanFlags = TrainerFanClub.getFanClubData(session, ctx) + return TrainerFanClub.countFans(fanFlags) +end + +--- pokefirered/src/trainer_fan_club.c:98 PlayerGainRandomTrainerFan +function TrainerFanClub.playerGainRandomTrainerFan(session, ctx) + local timer, gotInitialFans, fanFlags = TrainerFanClub.getFanClubData(session, ctx) + local Rng = require("src.core.game3.rng") + local idx = 1 + + for i = 1, TrainerFanClub.NUM_MEMBERS do + local memberId = sGainOrder[i] + if not TrainerFanClub.getFanFlag(fanFlags, memberId) then + idx = i + if (Rng.Random() % 2) ~= 0 then + fanFlags = TrainerFanClub.setFanFlag(fanFlags, memberId) + TrainerFanClub.setFanClubData(session, ctx, timer, gotInitialFans, fanFlags) + return memberId + end + end + end + + local fallbackMember = sGainOrder[idx] + fanFlags = TrainerFanClub.setFanFlag(fanFlags, fallbackMember) + TrainerFanClub.setFanClubData(session, ctx, timer, gotInitialFans, fanFlags) + return fallbackMember +end + +--- pokefirered/src/trainer_fan_club.c:131 PlayerLoseRandomTrainerFan +function TrainerFanClub.playerLoseRandomTrainerFan(session, ctx) + local timer, gotInitialFans, fanFlags = TrainerFanClub.getFanClubData(session, ctx) + if TrainerFanClub.countFans(fanFlags) == 1 then + return 0 + end + + local Rng = require("src.core.game3.rng") + local idx = 1 + + for i = 1, TrainerFanClub.NUM_MEMBERS do + local memberId = sLoseOrder[i] + if TrainerFanClub.getFanFlag(fanFlags, memberId) then + idx = i + if (Rng.Random() % 2) ~= 0 then + fanFlags = TrainerFanClub.flipFanFlag(fanFlags, memberId) + TrainerFanClub.setFanClubData(session, ctx, timer, gotInitialFans, fanFlags) + return memberId + end + end + end + + local fallbackMember = sLoseOrder[idx] + if TrainerFanClub.getFanFlag(fanFlags, fallbackMember) then + fanFlags = TrainerFanClub.flipFanFlag(fanFlags, fallbackMember) + end + TrainerFanClub.setFanClubData(session, ctx, timer, gotInitialFans, fanFlags) + return fallbackMember +end + +--- pokefirered/src/trainer_fan_club.c:194 TryLoseFansFromPlayTime +function TrainerFanClub.tryLoseFansFromPlayTime(session, ctx) + local hours = TrainerFanClub.getPlayTimeHours(session) + if hours < 999 then + local i = 0 + while true do + local _, _, fanFlags = TrainerFanClub.getFanClubData(session, ctx) + if TrainerFanClub.countFans(fanFlags) < 5 then + TrainerFanClub.setVar(session, ctx, TrainerFanClub.VAR_FANCLUB_LOSE_FAN_TIMER, hours) + break + end + if i == TrainerFanClub.NUM_MEMBERS then + break + end + + local loseTimer = TrainerFanClub.getVar(session, ctx, TrainerFanClub.VAR_FANCLUB_LOSE_FAN_TIMER) + if (hours - loseTimer) < 12 then + break + end + + TrainerFanClub.playerLoseRandomTrainerFan(session, ctx) + loseTimer = TrainerFanClub.getVar(session, ctx, TrainerFanClub.VAR_FANCLUB_LOSE_FAN_TIMER) + TrainerFanClub.setVar(session, ctx, TrainerFanClub.VAR_FANCLUB_LOSE_FAN_TIMER, band(loseTimer + 12, 0xFFFF)) + i = i + 1 + end + end +end + +--- pokefirered/src/trainer_fan_club.c:45 TryLoseFansFromPlayTimeAfterLinkBattle +function TrainerFanClub.tryLoseFansFromPlayTimeAfterLinkBattle(session, ctx) + local _, gotInitialFans, _ = TrainerFanClub.getFanClubData(session, ctx) + if gotInitialFans then + TrainerFanClub.tryLoseFansFromPlayTime(session, ctx) + local hours = TrainerFanClub.getPlayTimeHours(session) + TrainerFanClub.setVar(session, ctx, TrainerFanClub.VAR_FANCLUB_LOSE_FAN_TIMER, hours) + end +end + +--- pokefirered/src/trainer_fan_club.c:59 UpdateTrainerFanClubGameClear +function TrainerFanClub.updateTrainerFanClubGameClear(session, ctx) + local timer, gotInitialFans, fanFlags = TrainerFanClub.getFanClubData(session, ctx) + if not gotInitialFans then + gotInitialFans = true + fanFlags = TrainerFanClub.setFanFlag(fanFlags, TrainerFanClub.MEMBER.MEMBER1) + fanFlags = TrainerFanClub.setFanFlag(fanFlags, TrainerFanClub.MEMBER.MEMBER2) + fanFlags = TrainerFanClub.setFanFlag(fanFlags, TrainerFanClub.MEMBER.MEMBER3) + TrainerFanClub.setFanClubData(session, ctx, timer, gotInitialFans, fanFlags) + + local hours = TrainerFanClub.getPlayTimeHours(session) + TrainerFanClub.setVar(session, ctx, TrainerFanClub.VAR_FANCLUB_LOSE_FAN_TIMER, hours) + + TrainerFanClub.clearFlag(session, ctx, TrainerFanClub.FLAG_HIDE_SAFFRON_FAN_CLUB_BLACK_BELT) + TrainerFanClub.clearFlag(session, ctx, TrainerFanClub.FLAG_HIDE_SAFFRON_FAN_CLUB_ROCKER) + TrainerFanClub.clearFlag(session, ctx, TrainerFanClub.FLAG_HIDE_SAFFRON_FAN_CLUB_WOMAN) + TrainerFanClub.clearFlag(session, ctx, TrainerFanClub.FLAG_HIDE_SAFFRON_FAN_CLUB_BEAUTY) + + TrainerFanClub.setVar(session, ctx, TrainerFanClub.VAR_MAP_SCENE_SAFFRON_CITY_POKEMON_TRAINER_FAN_CLUB, 1) + end +end + +--- pokefirered/src/trainer_fan_club.c:76 TryGainNewFanFromCounter +function TrainerFanClub.tryGainNewFanFromCounter(session, ctx, counterIdx) + local timer, gotInitialFans, fanFlags = TrainerFanClub.getFanClubData(session, ctx) + counterIdx = tonumber(counterIdx) or 0 + local inc = sCounterIncrements[counterIdx] or 0 + local scene = TrainerFanClub.getVar(session, ctx, TrainerFanClub.VAR_MAP_SCENE_SAFFRON_CITY_POKEMON_TRAINER_FAN_CLUB) + + if scene == 2 then + if timer + inc >= 20 then + if TrainerFanClub.countFans(fanFlags) < 3 then + TrainerFanClub.playerGainRandomTrainerFan(session, ctx) + _, gotInitialFans, fanFlags = TrainerFanClub.getFanClubData(session, ctx) + timer = 0 + else + timer = 20 + end + else + timer = math.min(127, timer + inc) + end + TrainerFanClub.setFanClubData(session, ctx, timer, gotInitialFans, fanFlags) + end + + return timer +end + +--- pokefirered/src/trainer_fan_club.c:240 BufferFanClubTrainerName +function TrainerFanClub.bufferFanClubTrainerName(session, ctx, adapters, memberId) + memberId = tonumber(memberId) or 0 + local whichNPCTrainer = 0 + local whichLinkTrainer = 0 + + if memberId == TrainerFanClub.MEMBER.MEMBER1 then + whichNPCTrainer = 0 + whichLinkTrainer = 0 + elseif memberId == TrainerFanClub.MEMBER.MEMBER5 then + whichNPCTrainer = 1 + whichLinkTrainer = 0 + elseif memberId == TrainerFanClub.MEMBER.MEMBER6 then + whichNPCTrainer = 0 + whichLinkTrainer = 1 + elseif memberId == TrainerFanClub.MEMBER.MEMBER7 then + whichNPCTrainer = 2 + whichLinkTrainer = 1 + else + whichNPCTrainer = 0 + whichLinkTrainer = 0 + end + + session = session or TrainerFanClub.sessionOf() + local linkRecords = session and (session.linkBattleRecords or (session.linkRecords and session.linkRecords.entries)) + local entry = type(linkRecords) == "table" and linkRecords[whichLinkTrainer + 1] or nil + local linkName = (entry and type(entry.name) == "string" and entry.name) or "" + + local resultName + if linkName == "" then + if whichNPCTrainer == 1 then + resultName = "LT. SURGE" + elseif whichNPCTrainer == 2 then + resultName = "KOGA" + else + resultName = (session and session.rivalName) or "BLUE" + end + else + resultName = linkName:sub(1, 7) + end + + TrainerFanClub.setStringVar(ctx, adapters, 1, resultName) + return resultName +end + +--- pokefirered/src/trainer_fan_club.c:317 UpdateTrainerFansAfterLinkBattle +function TrainerFanClub.updateTrainerFansAfterLinkBattle(session, ctx, outcome) + local scene = TrainerFanClub.getVar(session, ctx, TrainerFanClub.VAR_MAP_SCENE_SAFFRON_CITY_POKEMON_TRAINER_FAN_CLUB) + if scene == 2 then + TrainerFanClub.tryLoseFansFromPlayTimeAfterLinkBattle(session, ctx) + if outcome == "won" or outcome == 1 or outcome == 0x01 then -- B_OUTCOME_WON + TrainerFanClub.playerGainRandomTrainerFan(session, ctx) + else + TrainerFanClub.playerLoseRandomTrainerFan(session, ctx) + end + end +end + +--- pokefirered/src/trainer_fan_club.c:340 SetPlayerGotFirstFans +function TrainerFanClub.setPlayerGotFirstFans(session, ctx) + local timer, _, fanFlags = TrainerFanClub.getFanClubData(session, ctx) + TrainerFanClub.setFanClubData(session, ctx, timer, true, fanFlags) +end + +return TrainerFanClub diff --git a/src/import/RomExtractorGen3.lua b/src/import/RomExtractorGen3.lua index 2a4101d4..5597ac83 100644 --- a/src/import/RomExtractorGen3.lua +++ b/src/import/RomExtractorGen3.lua @@ -77,24 +77,23 @@ local function makeCache() } end -local SUBTASK_RANGES = { - ["pokemon"] = { min = 0.42, max = 0.52, label = "Pokémon Species & Icons" }, - ["learnsets"] = { min = 0.52, max = 0.57, label = "Move Learnsets" }, - ["battle_moves"] = { min = 0.57, max = 0.60, label = "Battle Moves Data" }, - ["party_chrome"] = { min = 0.60, max = 0.63, label = "Party UI Graphics" }, - ["battle_chrome"] = { min = 0.63, max = 0.66, label = "Battle UI Graphics" }, - ["pokedex_entries"] = { min = 0.66, max = 0.68, label = "Pokédex Database" }, - ["pokedex_categories"]= { min = 0.68, max = 0.69, label = "Pokédex Categories" }, - ["pokedex_orders"] = { min = 0.69, max = 0.70, label = "Pokédex Sorting" }, - ["pokedex_done"] = { min = 0.70, max = 0.71, label = "Pokédex Complete" }, - ["storage_chrome"] = { min = 0.71, max = 0.74, label = "PC Storage Chrome" }, - ["battle_transition"] = { min = 0.74, max = 0.77, label = "Battle Transitions" }, - ["summary_chrome"] = { min = 0.77, max = 0.80, label = "Summary Screen Graphics" }, - ["bag_chrome"] = { min = 0.80, max = 0.83, label = "Bag & Items Graphics" }, - ["shop_chrome"] = { min = 0.83, max = 0.85, label = "Mart & Shop Graphics" }, - ["trainers"] = { min = 0.85, max = 0.86, label = "Trainer Data & Parties" }, - ["battle_ai"] = { min = 0.86, max = 0.875, label = "Battle AI Scripts" }, - ["map_preview"] = { min = 0.875, max = 0.88, label = "Location Previews" }, +local POKEMON_SUBTASKS = { + ["pokemon"] = { min = 0.00, max = 0.40, label = "Pokémon Species & Sprites" }, + ["learnsets"] = { min = 0.40, max = 0.55, label = "Move Learnsets" }, + ["battle_moves"] = { min = 0.55, max = 0.60, label = "Battle Moves Data" }, + ["party_chrome"] = { min = 0.60, max = 0.65, label = "Party UI Graphics" }, + ["battle_chrome"] = { min = 0.65, max = 0.70, label = "Battle UI Graphics" }, + ["pokedex_entries"] = { min = 0.70, max = 0.73, label = "Pokédex Database" }, + ["pokedex_categories"]= { min = 0.73, max = 0.75, label = "Pokédex Categories" }, + ["pokedex_orders"] = { min = 0.75, max = 0.77, label = "Pokédex Sorting" }, + ["pokedex_done"] = { min = 0.77, max = 0.78, label = "Pokédex Complete" }, + ["storage_chrome"] = { min = 0.78, max = 0.82, label = "PC Storage Chrome" }, + ["battle_transition"] = { min = 0.82, max = 0.86, label = "Battle Transitions" }, + ["summary_chrome"] = { min = 0.86, max = 0.90, label = "Summary Screen Graphics" }, + ["bag_chrome"] = { min = 0.90, max = 0.94, label = "Bag & Items Graphics" }, + ["shop_chrome"] = { min = 0.94, max = 0.97, label = "Mart & Shop Graphics" }, + ["trainers"] = { min = 0.97, max = 0.99, label = "Trainer Data & Parties" }, + ["map_preview"] = { min = 0.99, max = 1.00, label = "Location Previews" }, } function RomExtractorGen3.new(romData, manifest, progressCb, romSha1) @@ -136,15 +135,19 @@ function RomExtractorGen3:beginStage(name) self:report((self.stage - 1) / STAGE_COUNT, name, 0, 1) end -function RomExtractorGen3:tick(name, current, total) - local st = SUBTASK_RANGES[name] +function RomExtractorGen3:tickPokemon(name, current, total) + local st = POKEMON_SUBTASKS[name] if st then - local frac = (current or 0) / math.max(total or 1, 1) - local pct = st.min + frac * (st.max - st.min) - self:report(pct, st.label, current, total) + local curFrac = (current or 0) / math.max(total or 1, 1) + local frac = st.min + curFrac * (st.max - st.min) + local label = st.label + if total and total > 1 then + label = label .. string.format(" (%d/%d)", current or 0, total) + end + self:report(frac, label, current, total) else local frac = (current or 0) / math.max(total or 1, 1) - self:report((self.stage - 1 + frac) / STAGE_COUNT, name, current, total) + self:report(frac, name or "Pokémon Data", current, total) end end @@ -208,8 +211,11 @@ function RomExtractorGen3:runGbaExtract(sha1) runOk, runDetail = Extract.run(imports, cache, function(stage, n, name, cur, total) local curFrac = (cur or 0) / math.max(total or 1, 1) local frac = ((stage or 0) + curFrac) / math.max(n or Extract.STAGE_COUNT or 7, 1) - local pct = 0.03 + math.min(frac, 1.0) * 0.39 - self:report(pct, name or "World & Maps", cur or 0, total or 1) + local label = "World Maps: " .. tostring(name or "Processing") + if total and total > 1 then + label = label .. string.format(" (%d/%d)", cur or 0, total) + end + self:report(math.min(frac, 1.0), label, cur or 0, total or 1) end) end) @@ -247,7 +253,7 @@ function RomExtractorGen3:runPokemonExtract(sha1) local pRes = PokemonExtract.run(rom, cache, { cacheRoot = GBA_ROOT, progress = function(name, cur, total) - self:tick(name or "pokemon", cur or 0, total or 1) + self:tickPokemon(name or "pokemon", cur or 0, total or 1) end, }) local ItemsExtract = require("src.import.gba.items_extract") @@ -266,7 +272,7 @@ function RomExtractorGen3:runPokemonExtract(sha1) local mpOk, mpErr = pcall(MapPreviewExtract.run, rom, cache, { cacheRoot = GBA_ROOT, progress = function(name, cur, total) - self:tick(name or "map_preview", cur or 0, total or 1) + self:tickPokemon(name or "map_preview", cur or 0, total or 1) end, }) if not mpOk then @@ -300,6 +306,8 @@ function RomExtractorGen3:runAuxExtracts(sha1) local MuseumExtract = require("src.import.gba.museum_extract") local MoveRelearnerExtract = require("src.import.gba.move_relearner_extract") local EggExtract = require("src.import.gba.egg_extract") + local BattleAnimExtract = require("src.import.gba.battle_anim_extract") + local BattleAiExtract = require("src.import.gba.battle_ai_extract") local Extract = require("src.import.gba.extract_island1") local prevRoot = Extract.CACHE_ROOT Extract.CACHE_ROOT = GBA_ROOT @@ -321,10 +329,13 @@ function RomExtractorGen3:runAuxExtracts(sha1) local needMuseum = not MuseumExtract.ready(cache, GBA_ROOT) local needRelearner = not MoveRelearnerExtract.ready(cache, GBA_ROOT) local needEgg = not EggExtract.ready(cache, GBA_ROOT) + local needAnims = not BattleAnimExtract.ready(cache, GBA_ROOT) + local needAi = not BattleAiExtract.ready(cache, GBA_ROOT) + if not (needRegion or needSections or needChoices or needHeal or needDoors or needSlots or needTrade or needLinkArt or needFame or needTeachy or needGift or needTower or needTutor or needMuseum or needRelearner - or needEgg) then + or needEgg or needAnims or needAi) then Extract.CACHE_ROOT = prevRoot return true, { skipped = true } end @@ -337,9 +348,18 @@ function RomExtractorGen3:runAuxExtracts(sha1) local ok, detail = pcall(function() local out = {} + local step = 0 + local totalSteps = 17 + local function auxTick(name) + step = step + 1 + self:report(step / totalSteps, "Game Data: " .. name, step, totalSteps) + end + if needRegion then out.regionMap = RegionMapExtract.run(rom, cache, { cacheRoot = GBA_ROOT }) end + auxTick("Town Map") + if needSections then MapSectionsExtract.run(rom, cache, { cacheRoot = GBA_ROOT }) local rel = GBA_ROOT .. "/region_map/map_sections.lua" @@ -350,74 +370,122 @@ function RomExtractorGen3:runAuxExtracts(sha1) end out.mapSections = #body end + auxTick("Map Sections") + if needChoices then out.multichoice = MultichoiceExtract.run(rom, cache, { cacheRoot = GBA_ROOT }) end + auxTick("Dialog Menus") + if needHeal then local okHl, detailHl = HealLocationsExtract.run(rom, cache, { cacheRoot = GBA_ROOT }) if not okHl then print("[heal_locations] warn: " .. tostring(detailHl)) end out.healLocations = detailHl end + auxTick("Heal Locations") + if needDoors then local okDr, detailDr = pcall(DoorAnimExtract.run, rom, cache, { cacheRoot = GBA_ROOT }) if not okDr then print("[door_extract] warn: " .. tostring(detailDr)) end out.doors = okDr and detailDr or false end + auxTick("Door Animations") + if needSlots then local okSl, detailSl = pcall(SlotMachineExtract.run, rom, cache, { cacheRoot = GBA_ROOT }) if not okSl then print("[slot_machine_extract] warn: " .. tostring(detailSl)) end out.slotMachine = okSl and detailSl or false end + auxTick("Slot Machines") + if needTrade then local okTr, detailTr = pcall(TradeExtract.run, rom, cache, { cacheRoot = GBA_ROOT }) if not okTr then print("[trade_extract] warn: " .. tostring(detailTr)) end out.trade = okTr and detailTr or false end + auxTick("Link Trade") + if needLinkArt then local okLk, detailLk = pcall(LinkArtExtract.run, rom, cache, { cacheRoot = GBA_ROOT }) if not okLk then print("[link_art_extract] warn: " .. tostring(detailLk)) end out.linkArt = okLk and detailLk or false end + auxTick("Wireless Union") + if needFame then local okFc, detailFc = pcall(FameCheckerExtract.run, rom, cache, { cacheRoot = GBA_ROOT }) if not okFc then print("[fame_checker_extract] warn: " .. tostring(detailFc)) end out.fameChecker = okFc and detailFc or false end + auxTick("Fame Checker") + if needTeachy then local okTv, detailTv = pcall(TeachyTvExtract.run, rom, cache, { cacheRoot = GBA_ROOT }) if not okTv then print("[teachy_tv_extract] warn: " .. tostring(detailTv)) end out.teachyTv = okTv and detailTv or false end + auxTick("Teachy TV") + if needGift then local okMg, detailMg = pcall(MysteryGiftExtract.run, rom, cache, { cacheRoot = GBA_ROOT }) if not okMg then print("[mystery_gift_extract] warn: " .. tostring(detailMg)) end out.mysteryGift = okMg and detailMg or false end + auxTick("Mystery Gift") + if needTower then local okTt, detailTt = pcall(TrainerTowerExtract.run, rom, cache, { cacheRoot = GBA_ROOT }) if not okTt then print("[trainer_tower_extract] warn: " .. tostring(detailTt)) end out.trainerTower = okTt and detailTt or false end + auxTick("Trainer Tower") + if needTutor then local okTu, detailTu = pcall(TutorExtract.run, rom, cache, { cacheRoot = GBA_ROOT }) if not okTu then print("[tutor_extract] warn: " .. tostring(detailTu)) end out.tutor = okTu and detailTu or false end + auxTick("Move Tutors") + if needMuseum then local okMu, detailMu = pcall(MuseumExtract.run, rom, cache, { cacheRoot = GBA_ROOT }) if not okMu then print("[museum_extract] warn: " .. tostring(detailMu)) end out.museum = okMu and detailMu or false end + auxTick("Museum Exhibits") + if needRelearner then local okMr, detailMr = pcall(MoveRelearnerExtract.run, rom, cache, { cacheRoot = GBA_ROOT }) if not okMr then print("[move_relearner_extract] warn: " .. tostring(detailMr)) end out.moveRelearner = okMr and detailMr or false end + auxTick("Move Relearner") + if needEgg then local okEg, detailEg = pcall(EggExtract.run, rom, cache, { cacheRoot = GBA_ROOT }) if not okEg then print("[egg_extract] warn: " .. tostring(detailEg)) end out.egg = okEg and detailEg or false end + auxTick("Egg & Hatching") + + if needAnims then + local okAn, detailAn = pcall(BattleAnimExtract.run, rom, cache, { cacheRoot = GBA_ROOT, force = false }) + if not okAn then print("[battle_anim_extract] warn: " .. tostring(detailAn)) end + out.battleAnims = okAn and detailAn or false + end + auxTick("Battle Animations") + + if needAi then + local okAi, detailAi = pcall(BattleAiExtract.run, { + cache = cache, + cacheRoot = GBA_ROOT, + pretRoot = os.getenv("POKEFIRERED"), + }) + if not okAi then print("[battle_ai_extract] warn: " .. tostring(detailAi)) end + out.battleAi = okAi and detailAi or false + end + auxTick("Battle AI Scripts") + return out end) @@ -428,12 +496,12 @@ function RomExtractorGen3:runAuxExtracts(sha1) end function RomExtractorGen3:runIntroAudio(sha1) - self:report(0.88, "Intro Cutscene & Audio", 0, 1) + self:report(0.05, "Audio & Intro: Initializing", 0, 3) local cache = makeCache() if cache:exists(GBA_ROOT .. "/intro/meta.json") and cache:exists(GBA_ROOT .. "/audio/meta.json") then local im = cache:read(GBA_ROOT .. "/intro/meta.json") if im and not im:find('"stub"%s*:%s*true') then - self:report(0.98, "Audio Streams Ready", 1, 1) + self:report(1.00, "Audio Streams Ready", 3, 3) return true, true end end @@ -445,19 +513,90 @@ function RomExtractorGen3:runIntroAudio(sha1) local Intro = require("src.import.gba.extract_intro") local Naming = require("src.import.gba.extract_naming") local AudioExt = require("src.import.gba.extract_audio") - self:report(0.90, "Intro Cutscene", 0, 1) + + self:report(0.20, "Intro: Cutscene Sequence", 1, 3) local okI, metaI = Intro.run(romShim, cache, { sha1 = sha1, root = GBA_ROOT .. "/intro" }) - self:report(0.93, "Naming Screen", 0, 1) + + self:report(0.50, "Intro: Naming Screen Graphics", 2, 3) local okN = Naming.run(romShim, cache, { sha1 = sha1, root = GBA_ROOT .. "/naming" }) - self:report(0.95, "Audio Sequences & Streams", 0, 1) + + self:report(0.75, "Audio: Music & Sound Streams", 3, 3) local okA, metaA = AudioExt.run(romShim, cache, { sha1 = sha1, root = GBA_ROOT .. "/audio" }) + writeJson(GBA_ROOT .. "/intro/extract_status.json", { ok = okI == true }) writeJson(GBA_ROOT .. "/naming/extract_status.json", { ok = okN == true }) writeJson(GBA_ROOT .. "/audio/extract_status.json", { ok = okA == true }) - self:report(0.98, "Audio Streams Ready", 1, 1) + self:report(1.00, "Audio & Intro Ready", 3, 3) return okI, okA, metaI, metaA end +function RomExtractorGen3:runParallel(sha1) + if os.getenv("POKEPORT_NO_THREAD") == "1" then + return false, "POKEPORT_NO_THREAD set" + end + if not (love and love.thread and love.thread.newThread and love.timer) then + return false, "love.thread unavailable" + end + + local ch_name = "gba_extract_" .. tostring(love.timer.getTime()):gsub("%.", "") .. "_" .. tostring(math.random(10000, 99999)) + local ch = love.thread.getChannel(ch_name) + + local tasks = { "gba", "pokemon", "aux", "intro_audio" } + local threads = {} + local prefix = CacheFs.prefix or "" + + for _, t in ipairs(tasks) do + local okTh, th = pcall(love.thread.newThread, "src/import/gba/extract_worker.lua") + if not okTh or not th then + return false, "failed to spawn thread for " .. t .. ": " .. tostring(th) + end + threads[t] = th + th:start(t, prefix, self.romData, sha1, ch_name) + end + + local done_count = 0 + local errors = {} + local task_progress = { gba = 0, pokemon = 0, aux = 0, intro_audio = 0 } + local weights = { gba = 0.40, pokemon = 0.35, aux = 0.15, intro_audio = 0.10 } + + while done_count < #tasks do + local msg = ch:pop() + if msg then + if msg.type == "progress" then + task_progress[msg.task] = math.max(task_progress[msg.task] or 0, math.min(1.0, msg.fraction or 0)) + local total_pct = 0.03 + for k, w in pairs(weights) do + total_pct = total_pct + (task_progress[k] or 0) * w * 0.95 + end + self:report(total_pct, msg.stage or "Extracting", msg.current or 0, msg.stageTotal or 1) + elseif msg.type == "done" then + done_count = done_count + 1 + task_progress[msg.task] = 1.0 + local total_pct = 0.03 + for k, w in pairs(weights) do + total_pct = total_pct + (task_progress[k] or 0) * w * 0.95 + end + self:report(total_pct, "Finalizing " .. tostring(msg.task), done_count, #tasks) + if not msg.ok then + errors[#errors + 1] = msg.task .. ": " .. tostring(msg.error) + end + end + else + love.timer.sleep(0.005) + end + end + + if #errors > 0 then + error("Parallel extraction error:\n" .. table.concat(errors, "\n")) + end + + if not CacheFs.exists(GBA_ROOT .. "/maps.json") then + writeJson(GBA_ROOT .. "/maps.json", { maps = {}, from_extract = true }) + end + + return true +end + function RomExtractorGen3:run() local sha1 = self:ensureSha1() @@ -465,6 +604,19 @@ function RomExtractorGen3:run() self:writeRequiredMarkers(sha1) self:report(0.03, "Markers Ready", 1, 1) + local okPar, parErr = self:runParallel(sha1) + if okPar then + self:report(1.00, "Ready", 1, 1) + collectgarbage("collect") + return { + romSha1 = sha1, + extractOk = true, + pokemonOk = true, + auxOk = true, + } + end + + -- Fallback sequential execution path local ok, detail = self:runGbaExtract(sha1) if ok then if not CacheFs.exists(GBA_ROOT .. "/maps.json") then diff --git a/src/import/gba/battle_anim_extract.lua b/src/import/gba/battle_anim_extract.lua index 4eee8d05..73a7ce31 100644 --- a/src/import/gba/battle_anim_extract.lua +++ b/src/import/gba/battle_anim_extract.lua @@ -503,49 +503,94 @@ local function make_chunk(type_str, data) return u32be(#data) .. type_str .. data .. u32be(crc) end ---- Encode RGBA pixel array to PNG bytes (uses love.image if available, else pure Lua PNG writer). +local ffi +do + local ok, mod = pcall(require, "ffi") + if ok and mod and mod.new and mod.string then + ffi = mod + end +end + +local static_raw_buf = nil +local static_raw_cap = 0 +local function get_raw_buffer(size) + if not ffi then return nil end + if static_raw_cap < size then + static_raw_cap = math.max(size + 1024, 65536) + static_raw_buf = ffi.new("uint8_t[?]", static_raw_cap) + end + return static_raw_buf +end + +--- Encode RGBA pixel array/string to PNG bytes. local function encode_png(pixels, w, h) - if love and love.image and love.graphics then - local ok, id = pcall(love.image.newImageData, w, h) - if ok and id then - id:mapPixel(function(x, y) - local base = (y * w + x) * 4 + 1 - return pixels[base] / 255, pixels[base + 1] / 255, - pixels[base + 2] / 255, pixels[base + 3] / 255 - end) - local ok2, fd = pcall(id.encode, id, "png") - if ok2 and fd then return fd:getString() end + local raw_size = h * (1 + w * 4) + local buf = get_raw_buffer(raw_size) + local is_str = type(pixels) == "string" + local raw_data + + if buf then + local dest = 0 + for y = 0, h - 1 do + buf[dest] = 0 -- Filter: None + dest = dest + 1 + local src_base = y * w * 4 + if is_str then + for x = 0, w * 4 - 1 do + buf[dest] = pixels:byte(src_base + x + 1) or 0 + dest = dest + 1 + end + else + for x = 0, w * 4 - 1 do + buf[dest] = pixels[src_base + x + 1] or 0 + dest = dest + 1 + end + end + end + raw_data = ffi.string(buf, raw_size) + else + local raw_lines = {} + for y = 0, h - 1 do + local row = { string.char(0) } -- Filter type 0: None + local start_idx = y * w * 4 + 1 + for x = 0, w - 1 do + local idx = start_idx + x * 4 + if is_str then + row[#row + 1] = pixels:sub(idx, idx + 3) + else + row[#row + 1] = string.char(pixels[idx] or 0, pixels[idx+1] or 0, pixels[idx+2] or 0, pixels[idx+3] or 0) + end + end + raw_lines[#raw_lines + 1] = table.concat(row) + end + raw_data = table.concat(raw_lines) + end + + local idat_data + if love and love.data and love.data.compress then + local ok, comp = pcall(love.data.compress, "string", "zlib", raw_data) + if ok and comp then + idat_data = comp end end - -- Pure Lua PNG writer - local raw_lines = {} - for y = 0, h - 1 do - local row = { string.char(0) } -- Filter type 0: None - local start_idx = y * w * 4 + 1 - for x = 0, w - 1 do - local idx = start_idx + x * 4 - row[#row + 1] = string.char(pixels[idx] or 0, pixels[idx+1] or 0, pixels[idx+2] or 0, pixels[idx+3] or 0) + if not idat_data then + -- Deflate uncompressed blocks (max 65535 per block) + local zlib_blocks = { string.char(0x78, 0x01) } -- ZLIB header + local pos = 1 + local total_len = #raw_data + while pos <= total_len do + local chunk_len = math.min(total_len - pos + 1, 65535) + local is_final = (pos + chunk_len > total_len) and 1 or 0 + zlib_blocks[#zlib_blocks + 1] = string.char(is_final) + zlib_blocks[#zlib_blocks + 1] = u16le(chunk_len) + zlib_blocks[#zlib_blocks + 1] = u16le(bxor(chunk_len, 0xFFFF)) + zlib_blocks[#zlib_blocks + 1] = raw_data:sub(pos, pos + chunk_len - 1) + pos = pos + chunk_len end - raw_lines[#raw_lines + 1] = table.concat(row) + zlib_blocks[#zlib_blocks + 1] = u32be(adler32(raw_data)) + idat_data = table.concat(zlib_blocks) end - local raw_data = table.concat(raw_lines) - - -- Deflate uncompressed blocks (max 65535 per block) - local zlib_blocks = { string.char(0x78, 0x01) } -- ZLIB header - local pos = 1 - local total_len = #raw_data - while pos <= total_len do - local chunk_len = math.min(total_len - pos + 1, 65535) - local is_final = (pos + chunk_len > total_len) and 1 or 0 - zlib_blocks[#zlib_blocks + 1] = string.char(is_final) - zlib_blocks[#zlib_blocks + 1] = u16le(chunk_len) - zlib_blocks[#zlib_blocks + 1] = u16le(bxor(chunk_len, 0xFFFF)) - zlib_blocks[#zlib_blocks + 1] = raw_data:sub(pos, pos + chunk_len - 1) - pos = pos + chunk_len - end - zlib_blocks[#zlib_blocks + 1] = u32be(adler32(raw_data)) - local idat_data = table.concat(zlib_blocks) -- PNG Signature + IHDR + IDAT + IEND local sig = "\137PNG\r\n\026\n" diff --git a/src/import/gba/extract_island1.lua b/src/import/gba/extract_island1.lua index 6c33c5aa..5f937ea6 100644 --- a/src/import/gba/extract_island1.lua +++ b/src/import/gba/extract_island1.lua @@ -475,101 +475,6 @@ function Extract.run(imports, cache, progressCb) detailM.listCount or 0, tostring(detailM.path))) end end - do - local BagChromeExtract = require("src.import.gba.bag_chrome_extract") - local okB, detailB = pcall(BagChromeExtract.run, rom2, cache, { - cacheRoot = Extract.CACHE_ROOT, - }) - if okB and detailB then - print(string.format("[bag_chrome] icons=%d → %s", - detailB.iconsBaked or 0, tostring(detailB.root))) - elseif not okB then - print("[bag_chrome] warn: " .. tostring(detailB)) - end - end - do - local ShopChromeExtract = require("src.import.gba.shop_chrome_extract") - local okS, detailS = pcall(ShopChromeExtract.run, rom2, cache, { - cacheRoot = Extract.CACHE_ROOT, - }) - if okS and detailS then - print(string.format("[shop_chrome] → %s", tostring(detailS.root))) - elseif not okS then - print("[shop_chrome] warn: " .. tostring(detailS)) - end - end - do - local TmCaseExtract = require("src.import.gba.tm_case_extract") - local okT, detailT = pcall(TmCaseExtract.run, rom2, cache, { - cacheRoot = Extract.CACHE_ROOT, - }) - if okT and detailT then - print(string.format("[tm_case_extract] → %s", tostring(detailT.root))) - elseif not okT then - print("[tm_case_extract] warn: " .. tostring(detailT)) - end - end - do - local BerryPouchExtract = require("src.import.gba.berry_pouch_extract") - local okP, detailP = pcall(BerryPouchExtract.run, rom2, cache, { - cacheRoot = Extract.CACHE_ROOT, - }) - if okP and detailP then - print(string.format("[berry_pouch_extract] → %s", tostring(detailP.root))) - elseif not okP then - print("[berry_pouch_extract] warn: " .. tostring(detailP)) - end - end - do - local AnimExtract = require("src.import.gba.battle_anim_extract") - local animCache = { - write = function(_, rel, bytes) - local path = rel - if not path:match("^data/") then path = Extract.CACHE_ROOT .. "/" .. path end - return cache:write(path, bytes) - end, - exists = function(_, rel) return (cache.exists and cache:exists(rel)) or false end, - read = function(_, rel) return (cache.read and cache:read(rel)) or nil end, - } - pcall(AnimExtract.run, rom2, animCache, { cacheRoot = Extract.CACHE_ROOT, force = true }) - end - do - local BattleAiExtract = require("src.import.gba.battle_ai_extract") - pcall(BattleAiExtract.run, { - cache = cache, - cacheRoot = Extract.CACHE_ROOT, - pretRoot = os.getenv("POKEFIRERED"), - }) - end - do - local DoorAnimExtract = require("src.import.gba.door_anim_extract") - local okD, errD = pcall(DoorAnimExtract.run, rom2, cache, { - cacheRoot = Extract.CACHE_ROOT, - }) - if okD then - print("[door_extract] door sheets extracted from ROM") - else - print("[door_extract] warn: " .. tostring(errD)) - end - end - do - local RegionMapExtract = require("src.import.gba.region_map_extract") - local okRm, errRm = pcall(RegionMapExtract.run, rom2, cache, { - cacheRoot = Extract.CACHE_ROOT, - }) - if okRm then - print("[region_map] region map chrome extracted from ROM") - else - print("[region_map] warn: " .. tostring(errRm)) - end - end - do - local HealLocationsExtract = require("src.import.gba.heal_locations_extract") - local okHl, errHl = pcall(HealLocationsExtract.run, rom2, cache, { - cacheRoot = Extract.CACHE_ROOT, - }) - if not okHl then print("[heal_locations] warn: " .. tostring(errHl)) end - end rom2:clearCache() end diff --git a/src/import/gba/extract_worker.lua b/src/import/gba/extract_worker.lua new file mode 100644 index 00000000..f6c07acc --- /dev/null +++ b/src/import/gba/extract_worker.lua @@ -0,0 +1,71 @@ +-- Background love.thread worker for parallel Gen 3 extraction stages. +-- Cross-platform: sets up love.filesystem searcher so NX, UWP, Android, iOS, +-- and Desktop fused packages reliably resolve required modules. + +pcall(require, "love.filesystem") +pcall(require, "love.image") +pcall(require, "love.math") +pcall(require, "love.system") +pcall(require, "love.timer") +pcall(require, "love.data") + +-- Ensure fresh thread Lua state resolves "src.*" via love.filesystem on all platforms: +table.insert(package.searchers or package.loaders, 1, function(modname) + local path = modname:gsub("%.", "/") .. ".lua" + if love and love.filesystem and love.filesystem.getInfo and love.filesystem.getInfo(path) then + return assert(love.filesystem.load(path)) + end + local initPath = modname:gsub("%.", "/") .. "/init.lua" + if love and love.filesystem and love.filesystem.getInfo and love.filesystem.getInfo(initPath) then + return assert(love.filesystem.load(initPath)) + end +end) + +local task_name, prefix, romData, sha1, ch_name = ... + +local ch = love and love.thread and love.thread.getChannel and love.thread.getChannel(ch_name) + +local ok, err = pcall(function() + local CacheFs = require("src.import.CacheFs") + CacheFs.prefix = prefix or "" + + local RomExtractorGen3 = require("src.import.RomExtractorGen3") + local ext = RomExtractorGen3.new(romData, nil, function(progress, total, stage, current, stageTotal) + if ch then + local frac = (progress or 0) / math.max(total or 1000, 1) + ch:push({ + type = "progress", + task = task_name, + fraction = frac, + stage = stage, + current = current, + stageTotal = stageTotal, + }) + end + end, sha1) + + local res + if task_name == "gba" then + res = ext:runGbaExtract(sha1) + elseif task_name == "pokemon" then + res = ext:runPokemonExtract(sha1) + elseif task_name == "aux" then + res = ext:runAuxExtracts(sha1) + elseif task_name == "intro_audio" then + res = ext:runIntroAudio(sha1) + else + error("unknown extract task: " .. tostring(task_name)) + end + + collectgarbage("collect") + return res +end) + +if ch then + ch:push({ + type = "done", + task = task_name, + ok = ok, + error = (not ok) and tostring(err) or nil, + }) +end diff --git a/src/import/gba/lz77.lua b/src/import/gba/lz77.lua index 1536d380..22999ddf 100644 --- a/src/import/gba/lz77.lua +++ b/src/import/gba/lz77.lua @@ -1,19 +1,35 @@ -- GBA BIOS LZ77 decompress. --- Prefer LuaJIT FFI uint8 buffers; fall back to preallocated numeric arrays. --- Never build output via string concatenation. +-- High performance: uses LuaJIT FFI with reusable buffer & bitwise ops, +-- falling back safely to pure-Lua with strict bounds checking. local Lz77 = {} local ffi do local ok, mod = pcall(require, "ffi") - if ok then ffi = mod end + if ok and mod and mod.new and mod.string then + ffi = mod + end end -local function ensure_capacity(arr, n) - for i = #arr + 1, n do - arr[i] = 0 +local bit = bit or bit32 +if not bit then + local ok, mod = pcall(require, "bit") + if ok and mod then bit = mod end +end + +-- Reusable static FFI buffer to prevent GC allocation churn in tight loops +local ffi_buf = nil +local ffi_cap = 0 + +local function get_ffi_buffer(min_size) + if not ffi then return nil end + if ffi_cap < min_size then + local new_cap = math.max(min_size + 1024, math.max(65536, ffi_cap * 2)) + ffi_buf = ffi.new("uint8_t[?]", new_cap) + ffi_cap = new_cap end + return ffi_buf end function Lz77.len(buf) @@ -23,50 +39,128 @@ function Lz77.len(buf) end --- Decompress GBA LZ77 starting at `offset` in a byte source. --- @param get fun(i: integer): integer -- 0-based absolute ROM index → byte +-- @param get fun(i: integer): integer | string | table -- 0-based absolute ROM index → byte, or string/table -- @param offset integer -- 0-based start of LZ header -- @return table out -- 1-based byte array of uncompressed data -- @return integer bytes_consumed function Lz77.decompress(get, offset) - local typ = get(offset) - if typ ~= 0x10 then - error(("LZ77: expected type 0x10 at 0x%X, got 0x%02X"):format(offset, typ)) + offset = offset or 0 + local get_byte + if type(get) == "function" then + get_byte = get + elseif type(get) == "string" then + get_byte = function(i) return string.byte(get, i + 1) or 0 end + elseif type(get) == "table" and get.get then + get_byte = function(i) return get:get(i) or 0 end + elseif type(get) == "table" then + get_byte = function(i) return get[i + 1] or 0 end + else + error("LZ77: invalid byte source") end - local size = get(offset + 1) + get(offset + 2) * 256 + get(offset + 3) * 65536 + + local typ = get_byte(offset) + if typ ~= 0x10 then + error(("LZ77: expected type 0x10 at 0x%X, got 0x%02X"):format(offset, typ or 0)) + end + local size = get_byte(offset + 1) + get_byte(offset + 2) * 256 + get_byte(offset + 3) * 65536 if size <= 0 or size > 8 * 1024 * 1024 then error(("LZ77: unreasonable size %d"):format(size)) end local src = offset + 4 local out = {} - local o = 0 + local buf = get_ffi_buffer(size) - local produced = 0 - while produced < size do - local flags = get(src) - src = src + 1 - for bi = 0, 7 do - if produced >= size then break end - local mask = 2 ^ (7 - bi) - local is_ref = math.floor(flags / mask) % 2 == 1 - if is_ref then - local b1 = get(src) - local b2 = get(src + 1) - src = src + 2 - local length = math.floor(b1 / 16) + 3 - local disp = (b1 % 16) * 256 + b2 - for _ = 1, length do - if produced >= size then break end - local v = out[produced - disp] or 0 - o = o + 1 - out[o] = v + local band = bit and bit.band + local rshift = bit and bit.rshift + local lshift = bit and bit.lshift + local bor = bit and bit.bor + + if buf and band and rshift and bor and lshift then + -- Fast FFI + bitops path + local produced = 0 + while produced < size do + local flags = get_byte(src) + src = src + 1 + for bi = 7, 0, -1 do + if produced >= size then break end + local is_ref = band(rshift(flags, bi), 1) == 1 + if is_ref then + local b1 = get_byte(src) + local b2 = get_byte(src + 1) + src = src + 2 + local length = rshift(b1, 4) + 3 + local disp = bor(lshift(band(b1, 0x0F), 8), b2) + for _ = 1, length do + if produced >= size then break end + local read_pos = produced - 1 - disp + local v = 0 + if read_pos >= 0 then + v = buf[read_pos] + end + buf[produced] = v + produced = produced + 1 + end + else + buf[produced] = get_byte(src) or 0 + src = src + 1 + produced = produced + 1 + end + end + end + + -- Convert FFI buffer to 1-based table for caller compatibility + for i = 1, size do + out[i] = buf[i - 1] + end + out._raw_size = size + else + -- Fallback pure-Lua path with bitwise optimization if available + local produced = 0 + local o = 0 + while produced < size do + local flags = get_byte(src) + src = src + 1 + for bi = 7, 0, -1 do + if produced >= size then break end + local is_ref + if band and rshift then + is_ref = band(rshift(flags, bi), 1) == 1 + else + local mask = 2 ^ bi + is_ref = math.floor(flags / mask) % 2 == 1 + end + + if is_ref then + local b1 = get_byte(src) + local b2 = get_byte(src + 1) + src = src + 2 + local length, disp + if rshift and band and bor and lshift then + length = rshift(b1, 4) + 3 + disp = bor(lshift(band(b1, 0x0F), 8), b2) + else + length = math.floor(b1 / 16) + 3 + disp = (b1 % 16) * 256 + b2 + end + + for _ = 1, length do + if produced >= size then break end + local read_pos = produced - disp + local v = 0 + if read_pos >= 1 then + v = out[read_pos] or 0 + end + o = o + 1 + out[o] = v + produced = produced + 1 + end + else + o = o + 1 + out[o] = get_byte(src) or 0 + src = src + 1 produced = produced + 1 end - else - o = o + 1 - out[o] = get(src) or 0 - src = src + 1 - produced = produced + 1 end end end @@ -91,6 +185,16 @@ end function Lz77.toString(arr) if type(arr) == "string" then return arr end local n = (arr and arr._len) or (arr and #arr) or 0 + if n == 0 then return "" end + + if ffi then + local buf = get_ffi_buffer(n) + for i = 1, n do + buf[i - 1] = arr[i] or 0 + end + return ffi.string(buf, n) + end + local parts = {} local CHUNK = 4096 for i = 1, n, CHUNK do @@ -132,3 +236,4 @@ function Lz77.compressStore(payload) end return Lz77 + diff --git a/src/import/gba/pokemon_extract.lua b/src/import/gba/pokemon_extract.lua index 83871d7c..3e56aa60 100644 --- a/src/import/gba/pokemon_extract.lua +++ b/src/import/gba/pokemon_extract.lua @@ -89,13 +89,39 @@ local function decode_text(rom, off, max) return table.concat(chars) end +local ffi +do + local ok, mod = pcall(require, "ffi") + if ok and mod and mod.new and mod.string then + ffi = mod + end +end + +local bit = bit or bit32 +if not bit then + local ok, mod = pcall(require, "bit") + if ok and mod then bit = mod end +end + +local static_rgba_buf = nil +local static_rgba_cap = 0 +local function get_rgba_buffer(size_bytes) + if not ffi then return nil end + if static_rgba_cap < size_bytes then + static_rgba_cap = math.max(size_bytes + 1024, 65536) + static_rgba_buf = ffi.new("uint8_t[?]", static_rgba_cap) + end + return static_rgba_buf +end + --- Decode GBA 4bpp tiles → flat 1-based index buffer (w*h). local function decode_4bpp(bytes, w, h) - local tilesW = math.floor(w / 8) - local tilesH = math.floor(h / 8) + local tilesW = bit and bit.rshift(w, 3) or math.floor(w / 8) + local tilesH = bit and bit.rshift(h, 3) or math.floor(h / 8) local pixels = {} - for i = 1, w * h do pixels[i] = 0 end local ti = 0 + local band = bit and bit.band + local rshift = bit and bit.rshift for ty = 0, tilesH - 1 do for tx = 0, tilesW - 1 do local tileOff = ti * 32 -- 32 bytes / 4bpp tile @@ -103,8 +129,14 @@ local function decode_4bpp(bytes, w, h) for bx = 0, 3 do local bi = tileOff + row * 4 + bx + 1 local byte = bytes[bi] or 0 - local p0 = byte % 16 - local p1 = math.floor(byte / 16) % 16 + local p0, p1 + if band and rshift then + p0 = band(byte, 0x0F) + p1 = rshift(byte, 4) + else + p0 = byte % 16 + p1 = math.floor(byte / 16) % 16 + end local x0 = tx * 8 + bx * 2 local y0 = ty * 8 + row pixels[y0 * w + x0 + 1] = p0 @@ -137,8 +169,32 @@ local function bake_icon_rgba(pixels, pal, w, h) local r, g, b = bgr555_to_rgb8(pal[c] or 0) rgb[c] = { r, g, b } end + local total_pixels = w * h + local total_bytes = total_pixels * 4 + local buf = get_rgba_buffer(total_bytes) + if buf then + local ptr = 0 + for i = 1, total_pixels do + local idx = pixels[i] or 0 + if idx == 0 then + buf[ptr] = 0 + buf[ptr + 1] = 0 + buf[ptr + 2] = 0 + buf[ptr + 3] = 0 + else + local c = rgb[idx] or rgb[0] + buf[ptr] = c[1] + buf[ptr + 1] = c[2] + buf[ptr + 2] = c[3] + buf[ptr + 3] = 255 + end + ptr = ptr + 4 + end + return ffi.string(buf, total_bytes) + end + local chunks = {} - for i = 1, w * h do + for i = 1, total_pixels do local idx = pixels[i] or 0 if idx == 0 then chunks[i] = string.char(0, 0, 0, 0) @@ -167,6 +223,60 @@ local function decode_pic_sheet(tiles, palBytes, frame, bank) rgb[c] = { r, g, b } end local tilesW, tilesH = 8, 8 + local total_bytes = w * h * 4 + local buf = get_rgba_buffer(total_bytes) + local band = bit and bit.band + local rshift = bit and bit.rshift + + if buf and band and rshift then + local ti = 0 + for ty = 0, tilesH - 1 do + for tx = 0, tilesW - 1 do + local tileOff = ti * 32 + for row = 0, 7 do + for bx = 0, 3 do + local bi = tileBase + tileOff + row * 4 + bx + 1 + local byte = tiles[bi] or 0 + local p0 = band(byte, 0x0F) + local p1 = rshift(byte, 4) + local x0 = tx * 8 + bx * 2 + local y0 = ty * 8 + row + + local offset0 = (y0 * w + x0) * 4 + if p0 == 0 then + buf[offset0] = 0 + buf[offset0 + 1] = 0 + buf[offset0 + 2] = 0 + buf[offset0 + 3] = 0 + else + local c = rgb[p0] or rgb[0] + buf[offset0] = c[1] + buf[offset0 + 1] = c[2] + buf[offset0 + 2] = c[3] + buf[offset0 + 3] = 255 + end + + local offset1 = (y0 * w + x0 + 1) * 4 + if p1 == 0 then + buf[offset1] = 0 + buf[offset1 + 1] = 0 + buf[offset1 + 2] = 0 + buf[offset1 + 3] = 0 + else + local c = rgb[p1] or rgb[0] + buf[offset1] = c[1] + buf[offset1 + 1] = c[2] + buf[offset1 + 2] = c[3] + buf[offset1 + 3] = 255 + end + end + end + ti = ti + 1 + end + end + return ffi.string(buf, total_bytes) + end + local chunks = {} local ti = 0 for ty = 0, tilesH - 1 do @@ -180,17 +290,20 @@ local function decode_pic_sheet(tiles, palBytes, frame, bank) local p1 = math.floor(byte / 16) % 16 local x0 = tx * 8 + bx * 2 local y0 = ty * 8 + row - local function put(x, y, idx) - local i = y * w + x + 1 - if idx == 0 then - chunks[i] = string.char(0, 0, 0, 0) - else - local c = rgb[idx] or rgb[0] - chunks[i] = string.char(c[1], c[2], c[3], 255) - end + local i0 = y0 * w + x0 + 1 + if p0 == 0 then + chunks[i0] = string.char(0, 0, 0, 0) + else + local c = rgb[p0] or rgb[0] + chunks[i0] = string.char(c[1], c[2], c[3], 255) + end + local i1 = y0 * w + x0 + 2 + if p1 == 0 then + chunks[i1] = string.char(0, 0, 0, 0) + else + local c = rgb[p1] or rgb[0] + chunks[i1] = string.char(c[1], c[2], c[3], 255) end - put(x0, y0, p0) - put(x0 + 1, y0, p1) end end ti = ti + 1 diff --git a/tests/game3_elevation_oam_priority_test.lua b/tests/game3_elevation_oam_priority_test.lua new file mode 100644 index 00000000..62eaf996 --- /dev/null +++ b/tests/game3_elevation_oam_priority_test.lua @@ -0,0 +1,203 @@ +-- tests/game3_elevation_oam_priority_test.lua +-- Tests for Elevation tracking, elevation 0/15 preservation, and row-interleaved OAM priority rendering. + +local Map = require("src.core.game3.map") +local Objects = require("src.core.game3.objects") +local Player = require("src.core.game3.player") +local Collision = require("src.core.game3.collision") +local FieldView = require("src.core.game3.field_view") + +local CELL = 16 + +local function assert_eq(desc, actual, expected) + if actual == expected then + print(string.format("[ok] %s", desc)) + else + error(string.format("[FAIL] %s: expected %s, got %s", desc, tostring(expected), tostring(actual))) + end +end + +local function assert_true(desc, val) + assert_eq(desc, not not val, true) +end + +print("[test] 1. Elevation tracking and 0/15 preservation on Player movement") +do + local cellElevs = { + ["10,10"] = 3, -- ground + ["10,11"] = 4, -- bridge / elevated platform + ["10,12"] = 15, -- special: preserve elevation + ["10,13"] = 0, -- special: preserve elevation + ["10,14"] = 1, -- surf / water + } + Collision.elevationAt = function(cx, cy) + return cellElevs[cx .. "," .. cy] + end + + Player.reset(10, 10, "down") + assert_eq("Player initial elevation at (10,10) is 3", Player.elevation, 3) + + -- Move onto bridge (elevation 4) + Player.targetX, Player.targetY = 10, 11 + Player.moving = true + Player.cellX, Player.cellY = 10, 11 + Player.px, Player.py = 10 * CELL, 11 * CELL + Player.moving = false + local curElev = Collision.elevationAt(Player.cellX, Player.cellY) + if curElev and curElev ~= 0 and curElev ~= 15 then Player.elevation = curElev end + assert_eq("Player elevation on bridge (10,11) is 4", Player.elevation, 4) + + -- Move onto elevation 15 tile (should preserve 4) + Player.cellX, Player.cellY = 10, 12 + local e15 = Collision.elevationAt(Player.cellX, Player.cellY) + if e15 and e15 ~= 0 and e15 ~= 15 then Player.elevation = e15 end + assert_eq("Player elevation on elevation 15 tile (10,12) preserved as 4", Player.elevation, 4) + + -- Move onto elevation 0 tile (should preserve 4) + Player.cellX, Player.cellY = 10, 13 + local e0 = Collision.elevationAt(Player.cellX, Player.cellY) + if e0 and e0 ~= 0 and e0 ~= 15 then Player.elevation = e0 end + assert_eq("Player elevation on elevation 0 tile (10,13) preserved as 4", Player.elevation, 4) + + -- Move onto water (elevation 1) + Player.cellX, Player.cellY = 10, 14 + local e1 = Collision.elevationAt(Player.cellX, Player.cellY) + if e1 and e1 ~= 0 and e1 ~= 15 then Player.elevation = e1 end + assert_eq("Player elevation on water (10,14) updated to 1", Player.elevation, 1) +end + +print("[test] 2. EventObject elevation tracking on spawn and movement") +do + local eo = { + localId = 1, + def = { x = 5, y = 5, elevation = 0 }, + cellX = 5, + cellY = 5, + targetX = 5, + targetY = 6, + } + Collision.elevationAt = function(cx, cy) + if cx == 5 and cy == 5 then return 3 end + if cx == 5 and cy == 6 then return 4 end + return 3 + end + + eo.elevation = (Collision.elevationAt and Collision.elevationAt(eo.cellX, eo.cellY)) or 0 + assert_eq("EventObject initial elevation at (5,5) is 3", eo.elevation, 3) + + -- Move to (5,6) + eo.cellX = eo.targetX + eo.cellY = eo.targetY + local curElev = Collision.elevationAt(eo.cellX, eo.cellY) + if curElev and curElev ~= 0 and curElev ~= 15 then eo.elevation = curElev end + assert_eq("EventObject elevation at (5,6) is updated to 4", eo.elevation, 4) +end + +print("[test] 3. Interleaved row-by-row overhead and actor draw order") +do + local drawLog = {} + _G.love = _G.love or {} + _G.love.graphics = _G.love.graphics or {} + love.graphics.draw = function(drawable, ...) + local args = { ... } + if type(drawable) == "string" then + drawLog[#drawLog + 1] = drawable + elseif type(drawable) == "table" and drawable.tag then + drawLog[#drawLog + 1] = drawable.tag + else + drawLog[#drawLog + 1] = "quad" + end + end + love.graphics.setColor = function() end + love.graphics.push = function() end + love.graphics.pop = function() end + love.graphics.translate = function() end + + -- Setup overhead rows: + -- Row 5: Counter overhead tile (wy = 5 * 16 = 80) + -- Row 17: Cliff upper edge overhead tile (wy = 17 * 16 = 272) + FieldView._nativeOverByRow = { + [0] = {}, + [1] = {}, + [2] = {}, + [3] = {}, + [4] = {}, + [5] = { { image = { tag = "OVERHEAD_ROW_5_COUNTER" }, quad = {} } }, + [6] = {}, + [16] = {}, + [17] = { { image = { tag = "OVERHEAD_ROW_17_CLIFF" }, quad = {} } }, + [18] = {}, + } + FieldView._nativeOverOx = 0 + FieldView._nativeOverOy = 0 + + -- Setup actors: + -- Actor A: NPC behind counter at row 4 (sortY = 4 * 16 = 64) + -- Actor B: Player in front of counter at row 6 (sortY = 6 * 16 = 96) + -- Actor C: NPC standing behind cliff in water at row 16 (sortY = 16 * 16 = 256) + -- Actor D: Player standing ON cliff upper edge at row 17 (sortY = 17 * 16 = 272) + local actors = { + { tag = "ACTOR_NPC_BEHIND_COUNTER", sortY = 64, x = 0, y = 64 }, + { tag = "ACTOR_PLAYER_IN_FRONT_OF_COUNTER", sortY = 96, x = 0, y = 96 }, + { tag = "ACTOR_NPC_IN_WATER", sortY = 256, x = 0, y = 256 }, + { tag = "ACTOR_PLAYER_ON_CLIFF_EDGE", sortY = 272, x = 0, y = 272 }, + } + + table.sort(actors, function(a, b) return a.sortY < b.sortY end) + + drawLog = {} + + local actorIdx = 1 + local nActors = #actors + local cy0 = 0 + local rows = 20 + local overByRow = FieldView._nativeOverByRow + + for r = 0, rows - 1 do + local rowWy = (cy0 + r) * CELL + + local rowOver = overByRow[r] + if rowOver and #rowOver > 0 then + for _, item in ipairs(rowOver) do + love.graphics.draw(item.image, item.quad, item.x or 0, item.y or 0) + end + end + + local nextRowWy = rowWy + CELL + while actorIdx <= nActors and actors[actorIdx].sortY < nextRowWy do + love.graphics.draw(actors[actorIdx].tag) + actorIdx = actorIdx + 1 + end + end + + print("Draw order result:") + for idx, entry in ipairs(drawLog) do + print(string.format(" [%d] %s", idx, entry)) + end + + assert_eq("Draw step 1 is NPC behind counter", drawLog[1], "ACTOR_NPC_BEHIND_COUNTER") + assert_eq("Draw step 2 is Counter overhead (drawn after NPC behind counter)", drawLog[2], "OVERHEAD_ROW_5_COUNTER") + assert_eq("Draw step 3 is Player in front of counter (drawn after counter)", drawLog[3], "ACTOR_PLAYER_IN_FRONT_OF_COUNTER") + assert_eq("Draw step 4 is NPC in water", drawLog[4], "ACTOR_NPC_IN_WATER") + assert_eq("Draw step 5 is Cliff overhead (drawn after NPC in water, covering them)", drawLog[5], "OVERHEAD_ROW_17_CLIFF") + assert_eq("Draw step 6 is Player on cliff edge (drawn AFTER cliff overhead, on top of edge!)", drawLog[6], "ACTOR_PLAYER_ON_CLIFF_EDGE") +end + +print("[test] 4. isPlayerAboveBg2 special states") +do + local isPlayerAboveBg2_test = function(playerXOff, playerYOff, isJumping, isEscalator) + if (playerXOff and playerXOff ~= 0) or (playerYOff and playerYOff ~= 0) then + return true + end + if isJumping then return true end + if isEscalator then return true end + return false + end + + assert_true("Jumping player is flagged above BG2", isPlayerAboveBg2_test(0, 0, true, false)) + assert_true("Escalator player is flagged above BG2", isPlayerAboveBg2_test(0, 0, false, true)) + assert_true("Subpixel Y offset player is flagged above BG2", isPlayerAboveBg2_test(0, -8, false, false)) + assert_eq("Normal walking player is not forced above BG2", isPlayerAboveBg2_test(0, 0, false, false), false) +end + +print("[test] ALL ELEVATION AND OAM CONDITIONAL PRIORITY TESTS PASSED!") diff --git a/tests/game3_event_flags_test.lua b/tests/game3_event_flags_test.lua index 81774465..bbfde072 100644 --- a/tests/game3_event_flags_test.lua +++ b/tests/game3_event_flags_test.lua @@ -15,8 +15,7 @@ assert(FlagsTable.FLAGS.FLAG_SYS_B_DASH == 0x82F, "SYS_B_DASH should be 0x82F") assert(FlagsTable.FLAGS.FLAG_SYS_NATIONAL_DEX == 0x840, "SYS_NATIONAL_DEX should be 0x840") assert(FlagsTable.FLAGS.FLAG_HIDE_OAK_IN_HIS_LAB == 0x02B, "HIDE_OAK_IN_HIS_LAB should be 0x2B") assert(FlagsTable.VARS.VAR_MAP_SCENE_PALLET_TOWN_OAK == 0x4050, "MAP_SCENE_PALLET_TOWN_OAK should be 0x4050") -assert(FlagsTable.VARS.VAR_STARTER_MON == 0x4031, "STARTER_MON should be 0x4031") -assert(#FlagsTable.NEW_GAME_HIDE_FLAGS == 49, "Should contain exactly 49 new game hide flags from EventScript_ResetAllMapFlags") +assert(#FlagsTable.NEW_GAME_HIDE_FLAGS == 57, "Should contain 57 new game hide flags (49 EventScript_ResetAllMapFlags + 8 Seafoam boulder hide flags)") local f = io.open("pokefirered/include/constants/flags.h", "r") if f then f:close() diff --git a/tests/game3_oneoff_specials_test.lua b/tests/game3_oneoff_specials_test.lua new file mode 100644 index 00000000..a96ed946 --- /dev/null +++ b/tests/game3_oneoff_specials_test.lua @@ -0,0 +1,214 @@ +-- Tests for Gen 3 one-off script specials: +-- 1. Field_AskSaveTheGame (0x5D) +-- 2. SetWalkingIntoSignVars (0x170) +-- 3. LoadPlayerBag (0x14B) +-- 4. StickerManGetBragFlags (0x168) +-- 5. UpdateTrainerCardPhotoIcons (0x167) +-- 6. SeafoamIslandsB4F_CurrentDumpsPlayerOnLand (0x15C) + +local Std = require("src.core.game3.scripting.stdscripts") +local Natives = require("src.core.game3.scripting.natives") +local Flags = require("src.core.game3.scripting.flags") +local Schema = require("src.core.game3.save_schema_firered") +local Link = require("src.core.game3.link.init") + +local passed, failed = 0, 0 +local function check(cond, msg) + if cond then + passed = passed + 1 + print("[ok] " .. msg) + else + failed = failed + 1 + print("[FAIL] " .. msg) + end +end + +local function checkEq(got, expected, msg) + if got == expected then + passed = passed + 1 + print(string.format("[ok] %s (got %s)", msg, tostring(got))) + else + failed = failed + 1 + print(string.format("[FAIL] %s: expected %s, got %s", msg, tostring(expected), tostring(got))) + end +end + +print("=== 1. Field_AskSaveTheGame (0x5D) ===") +do + local session = Schema.newGame({ name = "RED" }) + local rt = { getSession = function() return session end, game = function() return {} end } + package.loaded["src.core.game3.runtime"] = rt + + local ctx = { + flags = session.flags, + vars = session.vars, + stringVars = session.stringVars, + specialVars = session.specialVars, + } + + local mockAdapters = {} + -- Mock SaveMenu + local SaveMenu = require("src.ui.game3.save_menu") + SaveMenu.show = function(opts) + SaveMenu._phase = "saved" + opts.onClose() + end + + local yielded = Natives.special(ctx, Std.SPECIAL.Field_AskSaveTheGame, mockAdapters) + checkEq(yielded, false, "Field_AskSaveTheGame completes") + checkEq(Flags.getVar(session, ctx, 0x800D), 1, "Field_AskSaveTheGame sets VAR_RESULT = 1 on save") + + -- Cancel save test + SaveMenu.show = function(opts) + SaveMenu._phase = "cancelled" + opts.onClose() + end + Natives.special(ctx, Std.SPECIAL.Field_AskSaveTheGame, mockAdapters) + checkEq(Flags.getVar(session, ctx, 0x800D), 0, "Field_AskSaveTheGame sets VAR_RESULT = 0 on cancel") +end + +print("=== 2. SetWalkingIntoSignVars (0x170) ===") +do + local session = Schema.newGame({ name = "RED" }) + package.loaded["src.core.game3.runtime"] = { getSession = function() return session end } + local ctx = { + session = session, + flags = session.flags, + vars = session.vars, + stringVars = session.stringVars, + specialVars = session.specialVars, + } + + Natives.special(ctx, Std.SPECIAL.SetWalkingIntoSignVars) + checkEq(ctx.walkAwayFromSignInhibitTimer, 6, "ctx.walkAwayFromSignInhibitTimer set to 6") + checkEq(ctx.msgBoxIsCancelable, true, "ctx.msgBoxIsCancelable is true") + checkEq(ctx.canWalkAway, true, "ctx.canWalkAway is true") + checkEq(session.walkAwayFromSignInhibitTimer, 6, "session.walkAwayFromSignInhibitTimer set to 6") + checkEq(session.msgBoxIsCancelable, true, "session.msgBoxIsCancelable is true") +end + +print("=== 3. LoadPlayerBag (0x14B) ===") +do + local session = Schema.newGame({ name = "RED" }) + session.bag = { pockets = { items = { { item = 13, qty = 5 } } } } + local rt = { getSession = function() return session end } + package.loaded["src.core.game3.runtime"] = rt + + local ctx = { + flags = session.flags, + vars = session.vars, + stringVars = session.stringVars, + specialVars = session.specialVars, + } + + local yielded = Natives.special(ctx, Std.SPECIAL.LoadPlayerBag) + checkEq(yielded, false, "LoadPlayerBag completes without yielding") + check(Link._bagBackup ~= nil, "Link._bagBackup created") + checkEq(Link._bagBackup.pockets.items[1].qty, 5, "Link._bagBackup has correct quantity") +end + +print("=== 4. StickerManGetBragFlags (0x168) ===") +do + local session = Schema.newGame({ name = "RED" }) + session.hofClears = 12 + session.eggsHatched = 70000 -- Exceeds 0xFFFF to test clamping + session.linkBattleWins = 5 + + local rt = { getSession = function() return session end } + package.loaded["src.core.game3.runtime"] = rt + + local ctx = { + flags = session.flags, + vars = session.vars, + stringVars = session.stringVars, + specialVars = session.specialVars, + } + + local yielded, result = Natives.special(ctx, Std.SPECIAL.StickerManGetBragFlags) + checkEq(yielded, false, "StickerManGetBragFlags does not yield") + checkEq(Flags.getVar(session, ctx, 0x8004), 12, "VAR_0x8004 has HOF clears (12)") + checkEq(Flags.getVar(session, ctx, 0x8005), 65535, "VAR_0x8005 clamped to 0xFFFF (65535)") + checkEq(Flags.getVar(session, ctx, 0x8006), 5, "VAR_0x8006 has link wins (5)") + -- Bitmask: bit 0 (HOF>0) = 1, bit 1 (eggs>0) = 2, bit 2 (linkWins>0) = 4 -> 1+2+4 = 7 + checkEq(Flags.getVar(session, ctx, 0x8008), 7, "VAR_0x8008 has brag bitmask 7") + checkEq(Flags.getVar(session, ctx, 0x800D), 7, "VAR_RESULT has brag bitmask 7") + + -- Test zero stats + session.hofClears = 0 + session.eggsHatched = 0 + session.linkBattleWins = 0 + Natives.special(ctx, Std.SPECIAL.StickerManGetBragFlags) + checkEq(Flags.getVar(session, ctx, 0x8008), 0, "VAR_0x8008 is 0 when all stats are 0") +end + +print("=== 5. UpdateTrainerCardPhotoIcons (0x167) ===") +do + local session = Schema.newGame({ name = "RED" }) + -- Party with 3 mons + session.party = { + { speciesId = 1 }, -- Bulbasaur + { speciesId = 4 }, -- Charmander + { speciesId = 7 }, -- Squirtle + } + + local rt = { getSession = function() return session end } + package.loaded["src.core.game3.runtime"] = rt + + local ctx = { + flags = session.flags, + vars = session.vars, + stringVars = session.stringVars, + specialVars = session.specialVars, + } + + Flags.setVar(session, ctx, 0x8004, 2) -- Tint index 2 + + Natives.special(ctx, Std.SPECIAL.UpdateTrainerCardPhotoIcons) + + -- Check slots 1..3 + checkEq(Flags.getVar(session, ctx, 0x4043), 1, "VAR_TRAINER_CARD_MON_ICON_1 is Bulbasaur (1)") + checkEq(Flags.getVar(session, ctx, 0x4044), 4, "VAR_TRAINER_CARD_MON_ICON_2 is Charmander (4)") + checkEq(Flags.getVar(session, ctx, 0x4045), 7, "VAR_TRAINER_CARD_MON_ICON_3 is Squirtle (7)") + + -- Check empty slots 4..6 are padded with 0 + checkEq(Flags.getVar(session, ctx, 0x4046), 0, "VAR_TRAINER_CARD_MON_ICON_4 is empty (0)") + checkEq(Flags.getVar(session, ctx, 0x4047), 0, "VAR_TRAINER_CARD_MON_ICON_5 is empty (0)") + checkEq(Flags.getVar(session, ctx, 0x4048), 0, "VAR_TRAINER_CARD_MON_ICON_6 is empty (0)") + + -- Check tint index + checkEq(Flags.getVar(session, ctx, 0x4042), 2, "VAR_TRAINER_CARD_MON_ICON_TINT_IDX is 2") +end + +print("=== 6. SeafoamIslandsB4F_CurrentDumpsPlayerOnLand (0x15C) ===") +do + local session = Schema.newGame({ name = "RED" }) + session.surfing = true + session.player = { surfing = true, state = "surf", facing = "left" } + session.facing = "left" + + local rt = { + getSession = function() return session end, + player = { surfing = true, state = "surf", facing = "left" }, + } + package.loaded["src.core.game3.runtime"] = rt + + local ctx = { + flags = session.flags, + vars = session.vars, + stringVars = session.stringVars, + specialVars = session.specialVars, + } + + Natives.special(ctx, Std.SPECIAL.SeafoamIslandsB4F_CurrentDumpsPlayerOnLand) + + checkEq(session.surfing, false, "session.surfing is false") + checkEq(session.player.surfing, false, "session.player.surfing is false") + checkEq(session.player.state, "walk", "session.player.state is 'walk'") + checkEq(session.player.facing, "up", "session.player.facing is 'up' (North)") + checkEq(rt.player.surfing, false, "rt.player.surfing is false") + checkEq(rt.player.state, "walk", "rt.player.state is 'walk'") + checkEq(rt.player.facing, "up", "rt.player.facing is 'up' (North)") +end + +print(string.format("\nTotal: %d passed, %d failed", passed, failed)) +if failed > 0 then os.exit(1) end diff --git a/tests/game3_pokedex_rating_test.lua b/tests/game3_pokedex_rating_test.lua new file mode 100644 index 00000000..815d3647 --- /dev/null +++ b/tests/game3_pokedex_rating_test.lua @@ -0,0 +1,140 @@ +#!/usr/bin/env luajit +package.path = "./?.lua;./?/init.lua;" .. package.path + +local PokedexRating = require("src.core.game3.pokedex_rating") +local Dex = require("src.core.game3.dex") +local Std = require("src.core.game3.scripting.stdscripts") +local Natives = require("src.core.game3.scripting.natives") +local Flags = require("src.core.game3.scripting.flags") + +local passed = 0 +local failed = 0 + +local function check(cond, msg) + if cond then + passed = passed + 1 + print("[ok] " .. msg) + else + failed = failed + 1 + print("[FAIL] " .. msg) + end +end + +local function eq(a, b, msg) + check(a == b, (msg or "equal") .. " (got " .. tostring(a) .. ", expected " .. tostring(b) .. ")") +end + +print("=== 1. Rating Message Evaluation Tiers ===") +do + local dex = Dex.new() + + local t0, c0 = PokedexRating.getRatingMessage(5, dex) + eq(t0, PokedexRating.TEXT.LESS_THAN_10, "5 caught -> LessThan10") + eq(c0, false, "5 caught isComplete = false") + + local t15, c15 = PokedexRating.getRatingMessage(15, dex) + eq(t15, PokedexRating.TEXT.LESS_THAN_20, "15 caught -> LessThan20") + + local t25, c25 = PokedexRating.getRatingMessage(25, dex) + eq(t25, PokedexRating.TEXT.LESS_THAN_30, "25 caught -> LessThan30") + + local t35, c35 = PokedexRating.getRatingMessage(35, dex) + eq(t35, PokedexRating.TEXT.LESS_THAN_40, "35 caught -> LessThan40") + + local t45, c45 = PokedexRating.getRatingMessage(45, dex) + eq(t45, PokedexRating.TEXT.LESS_THAN_50, "45 caught -> LessThan50") + + local t55, c55 = PokedexRating.getRatingMessage(55, dex) + eq(t55, PokedexRating.TEXT.LESS_THAN_60, "55 caught -> LessThan60") + + local t65, c65 = PokedexRating.getRatingMessage(65, dex) + eq(t65, PokedexRating.TEXT.LESS_THAN_70, "65 caught -> LessThan70") + + local t75, c75 = PokedexRating.getRatingMessage(75, dex) + eq(t75, PokedexRating.TEXT.LESS_THAN_80, "75 caught -> LessThan80") + + local t85, c85 = PokedexRating.getRatingMessage(85, dex) + eq(t85, PokedexRating.TEXT.LESS_THAN_90, "85 caught -> LessThan90") + + local t95, c95 = PokedexRating.getRatingMessage(95, dex) + eq(t95, PokedexRating.TEXT.LESS_THAN_100, "95 caught -> LessThan100") + + local t105, c105 = PokedexRating.getRatingMessage(105, dex) + eq(t105, PokedexRating.TEXT.LESS_THAN_110, "105 caught -> LessThan110") + + local t115, c115 = PokedexRating.getRatingMessage(115, dex) + eq(t115, PokedexRating.TEXT.LESS_THAN_120, "115 caught -> LessThan120") + + local t125, c125 = PokedexRating.getRatingMessage(125, dex) + eq(t125, PokedexRating.TEXT.LESS_THAN_130, "125 caught -> LessThan130") + + local t135, c135 = PokedexRating.getRatingMessage(135, dex) + eq(t135, PokedexRating.TEXT.LESS_THAN_140, "135 caught -> LessThan140") + + local t145, c145 = PokedexRating.getRatingMessage(145, dex) + eq(t145, PokedexRating.TEXT.LESS_THAN_150, "145 caught -> LessThan150") +end + +print("=== 2. Mew Exception at 150 Caught ===") +do + -- 150 caught without Mew: COMPLETE + local dexWithoutMew = Dex.new() + for sp = 1, 150 do Dex.setCaught(dexWithoutMew, sp) end + local t150NoMew, c150NoMew = PokedexRating.getRatingMessage(150, dexWithoutMew) + eq(t150NoMew, PokedexRating.TEXT.COMPLETE, "150 caught without Mew -> COMPLETE") + eq(c150NoMew, true, "150 caught without Mew -> isComplete = true") + + -- 150 caught with Mew (e.g. 1..149 + Mew 151): LESS_THAN_150 + local dexWithMew = Dex.new() + for sp = 1, 149 do Dex.setCaught(dexWithMew, sp) end + Dex.setCaught(dexWithMew, 151) -- Mew + local t150Mew, c150Mew = PokedexRating.getRatingMessage(150, dexWithMew) + eq(t150Mew, PokedexRating.TEXT.LESS_THAN_150, "150 caught including Mew -> LessThan150") + eq(c150Mew, false, "150 caught including Mew -> isComplete = false") + + -- 151 caught (all including Mew): COMPLETE + local dexFull = Dex.new() + for sp = 1, 151 do Dex.setCaught(dexFull, sp) end + local t151, c151 = PokedexRating.getRatingMessage(151, dexFull) + eq(t151, PokedexRating.TEXT.COMPLETE, "151 caught -> COMPLETE") + eq(c151, true, "151 caught -> isComplete = true") +end + +print("=== 3. Special 0xD5 Execution via Natives ===") +do + local session = { + dex = Dex.new(), + flags = {}, + vars = {}, + stringVars = {}, + specialVars = {}, + } + local ctx = { + session = session, + stringVars = session.stringVars, + specialVars = session.specialVars, + } + local Space = require("src.core.game3.scripting.space") + Space.store = session + + local openedMsg = nil + local adapters = { + openMessageStay = function(msg) openedMsg = msg end, + } + + -- Case A: Count = 42 + Flags.setVar(session, ctx, 0x8004, 42) + Natives.special(ctx, Std.SPECIAL.GetProfOaksRatingMessage, adapters) + eq(openedMsg, PokedexRating.TEXT.LESS_THAN_50, "opened message for 42 caught") + eq(Flags.getVar(session, ctx, 0x800D), 0, "VAR_RESULT = 0 for incomplete") + + -- Case B: Count = 150 without Mew + for sp = 1, 150 do Dex.setCaught(session.dex, sp) end + Flags.setVar(session, ctx, 0x8004, 150) + Natives.special(ctx, Std.SPECIAL.GetProfOaksRatingMessage, adapters) + eq(openedMsg, PokedexRating.TEXT.COMPLETE, "opened complete message for 150 without Mew") + eq(Flags.getVar(session, ctx, 0x800D), 1, "VAR_RESULT = 1 for complete") +end + +print(string.format("\nTotal: %d passed, %d failed", passed, failed)) +if failed > 0 then os.exit(1) end diff --git a/tests/game3_seafoam_puzzle_test.lua b/tests/game3_seafoam_puzzle_test.lua new file mode 100644 index 00000000..74675cb7 --- /dev/null +++ b/tests/game3_seafoam_puzzle_test.lua @@ -0,0 +1,234 @@ +#!/usr/bin/env luajit +-- Test Seafoam Islands B3F / B4F boulder puzzle, current stopping, and hole falling. + +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 eq(a, b, msg) + check(a == b, string.format("%s (%s == %s)", msg, tostring(a), tostring(b))) +end + +local GameVersion = require("src.core.GameVersion") +GameVersion.set("firered") + +local Cache = require("tests.game3_cache") +local cacheRoot = Cache.mount("scripts/events.lua", { native = true }) +if not cacheRoot then + print("[skip] game3_seafoam_puzzle_test: " .. tostring(Cache.reason)) + os.exit(0) +end + +local Schema = require("src.core.game3.save_schema_firered") +local Dataset = require("src.core.game3.dataset") +local Collision = require("src.core.game3.collision") +local Objects = require("src.core.game3.objects") +local Player = require("src.core.game3.player") +local Field = require("src.core.game3.field") +local FieldMoves = require("src.core.game3.field_moves") +local Space = require("src.core.game3.scripting.space") +local Flags = require("src.core.game3.scripting.flags") +local Runtime = require("src.core.game3.runtime") +local Warp = require("src.core.game3.warp") + +local game = { data = {} } +Dataset.hydrate(game) + +local session = Schema.newGame({ name = "RED" }) +game.session = session +Runtime.session = session +package.loaded["src.core.game3.runtime"] = { + getSession = function() return session end, + isActive = function() return true end, + _game = game, +} + +local B3F = "FR_SEAFOAM_ISLANDS_B3F" +local B4F = "FR_SEAFOAM_ISLANDS_B4F" + +local FLAG_HIDE_SEAFOAM_B3F_BOULDER_1 = 0x46 -- 70 +local FLAG_HIDE_SEAFOAM_B3F_BOULDER_2 = 0x47 -- 71 +local FLAG_HIDE_SEAFOAM_B3F_BOULDER_3 = 0x48 -- 72 +local FLAG_HIDE_SEAFOAM_B3F_BOULDER_4 = 0x49 -- 73 +local FLAG_HIDE_SEAFOAM_B3F_BOULDER_5 = 0x4A -- 74 +local FLAG_HIDE_SEAFOAM_B3F_BOULDER_6 = 0x4B -- 75 +local FLAG_HIDE_SEAFOAM_B4F_BOULDER_1 = 0x4C -- 76 +local FLAG_HIDE_SEAFOAM_B4F_BOULDER_2 = 0x4D -- 77 +local FLAG_STOPPED_SEAFOAM_B3F_CURRENT = 0x2D2 -- 722 +local FLAG_STOPPED_SEAFOAM_B4F_CURRENT = 0x2D3 -- 723 + +local function enterMap(mapId) + local def = game.data.maps[mapId] + if not def then return nil end + Field._game = game + session.map = mapId + Field._session = session + Field.running = true + Field.locked = false + Field.clearMetatiles() + Space.activate(nil, mapId, game, nil) + Collision.bindMap(game, mapId, def) + Objects.loadMap(game, mapId, def) + Space.runEnterScripts(nil, mapId, game, nil) + return def +end + +local function standAt(x, y, facing) + Player.moving = false + Player.progress = 0 + Player.surfing = false + Player.biking = false + Player.cellX, Player.cellY = x, y + Player.targetX, Player.targetY = x, y + Player.px, Player.py = x * 16, y * 16 + Player.facing = facing or "down" +end + +local function pumpVm(limit) + local vm = Space.vm + if not vm then return 0 end + local n = 0 + while vm:isRunning() and n < (limit or 512) do + if Player and Player.tick then + Player.tick(game) + end + if Objects and Objects.update then + Objects.update(game) + end + vm:tick() + n = n + 1 + end + return n +end + +print("[test] 1. New game initial Seafoam flag state") +check(Flags.getFlag(session, nil, FLAG_HIDE_SEAFOAM_B4F_BOULDER_1) == true, "B4F boulder 1 starts HIDDEN") +check(Flags.getFlag(session, nil, FLAG_HIDE_SEAFOAM_B4F_BOULDER_2) == true, "B4F boulder 2 starts HIDDEN") +check(Flags.getFlag(session, nil, FLAG_HIDE_SEAFOAM_B3F_BOULDER_1) == true, "B3F boulder 1 starts HIDDEN") +check(Flags.getFlag(session, nil, FLAG_HIDE_SEAFOAM_B3F_BOULDER_2) == true, "B3F boulder 2 starts HIDDEN") +check(Flags.getFlag(session, nil, FLAG_STOPPED_SEAFOAM_B3F_CURRENT) == false, "B3F current starts ACTIVE") +check(Flags.getFlag(session, nil, FLAG_STOPPED_SEAFOAM_B4F_CURRENT) == false, "B4F current starts ACTIVE") + +print("[test] 2. Enter B4F before solving puzzle: current remains ACTIVE, no boulders in water") +enterMap(B4F) +pumpVm() +check(Flags.getFlag(session, nil, FLAG_STOPPED_SEAFOAM_B4F_CURRENT) == false, "B4F current NOT stopped on enter") +local b4fB1 = Objects.find(1) +local b4fB2 = Objects.find(2) +check(b4fB1 == nil or b4fB1.visible == false, "B4F boulder 1 object is not visible in water") +check(b4fB2 == nil or b4fB2.visible == false, "B4F boulder 2 object is not visible in water") + +print("[test] 3. Enter B3F before solving puzzle: pushable boulders visible, dropped boulders hidden") +enterMap(B3F) +pumpVm() +check(Flags.getFlag(session, nil, FLAG_STOPPED_SEAFOAM_B3F_CURRENT) == false, "B3F current NOT stopped on enter") +local b3fB3 = Objects.find(3) +local b3fB6 = Objects.find(6) +check(b3fB3 ~= nil and b3fB3.visible ~= false, "B3F pushable boulder 3 is visible") +check(b3fB6 ~= nil and b3fB6.visible ~= false, "B3F pushable boulder 6 is visible") + +print("[test] 4. Push both boulders on B3F into holes (drops to B4F)") +Flags.setFlag(Space.store, nil, FieldMoves.SYS_FLAGS.USE_STRENGTH, true) + +-- Move boulder 6 (at 6,17) into hole at (6,18) +standAt(6, 16, "down") +local r1 = Player.tryMove("down", game, false) +eq(r1, "step", "boulder 6 pushed into hole") +check(Flags.getFlag(Space.store, nil, FLAG_HIDE_SEAFOAM_B4F_BOULDER_1) == false, "B4F boulder 1 is revealed (unhidden)") + +-- Position boulder 3 at (9,17) and push into hole at (9,18) +Objects.setObjectXY(3, 9, 17) +standAt(9, 16, "down") +local r2 = Player.tryMove("down", game, false) +eq(r2, "step", "boulder 3 pushed into hole") +check(Flags.getFlag(Space.store, nil, FLAG_HIDE_SEAFOAM_B4F_BOULDER_2) == false, "B4F boulder 2 is revealed (unhidden)") + +print("[test] 5. Enter B4F after dropping both boulders: current STOPS, calm water layout applied") +enterMap(B4F) +pumpVm() +check(Flags.getFlag(Space.store, nil, FLAG_STOPPED_SEAFOAM_B4F_CURRENT) == true, "B4F current is now STOPPED") +local b4fB1_after = Objects.find(1) +local b4fB2_after = Objects.find(2) +check(b4fB1_after ~= nil and b4fB1_after.visible ~= false, "B4F boulder 1 is now visible in water") +check(b4fB2_after ~= nil and b4fB2_after.visible ~= false, "B4F boulder 2 is now visible in water") + +print("[test] 6. Falling through hole to B4F when current is active triggers sweep onFrame") +-- Reset flags to test falling when current is active +Flags.setFlag(Space.store, nil, FLAG_STOPPED_SEAFOAM_B4F_CURRENT, false) +Flags.setFlag(Space.store, nil, FLAG_HIDE_SEAFOAM_B4F_BOULDER_1, true) +Flags.setFlag(Space.store, nil, FLAG_HIDE_SEAFOAM_B4F_BOULDER_2, true) + +enterMap(B4F) +Flags.setVar(Space.store, Space.vm.ctx, 0x4001, 1) +Player.surfing = true +Player.cellX, Player.cellY = 8, 17 -- Actual drop hole landing position + +-- Trigger onFrame script +Space.runOnFrame() +pumpVm() +-- Current sweep script executes SeafoamIslandsB4F_CurrentDumpsPlayerOnLand +eq(Player.surfing, false, "player was dumped on land and is no longer surfing") +eq(Player.facing, "up", "player facing snapped to up (North)") +eq(Player.cellY, 12, "player jumped up onto the stairs/land (y=12)") + +print("[test] 7. Legacy save repair: empty flags restored with correct defaults and invariants") +local legacySave = { + schemaVersion = 1, + name = "ASH", + flags = {}, -- completely unseeded legacy flags + vars = {}, +} +local restoredSession = Schema.fromSaveTable(legacySave) +check(Flags.getFlag(restoredSession, nil, FLAG_HIDE_SEAFOAM_B4F_BOULDER_1) == true, "Legacy save: B4F boulder 1 is hidden") +check(Flags.getFlag(restoredSession, nil, FLAG_HIDE_SEAFOAM_B4F_BOULDER_2) == true, "Legacy save: B4F boulder 2 is hidden") +check(Flags.getFlag(restoredSession, nil, FLAG_STOPPED_SEAFOAM_B4F_CURRENT) == false, "Legacy save: B4F current is active") +check(Flags.getFlag(restoredSession, nil, 0x02C) == true, "Legacy save: Pallet Town Oak is hidden") +check(Flags.getFlag(restoredSession, nil, 0x033) == true, "Legacy save: Bill (human) is hidden") +check(Flags.getFlag(restoredSession, nil, 0x092) == true, "Legacy save: Pewter running shoes aide is hidden") +check(Flags.getFlag(restoredSession, nil, 0x035) == true, "Legacy save: Mr. Fuji in house is hidden") +check(Flags.getFlag(restoredSession, nil, 0x05A) == true, "Legacy save: Oak in champ room is hidden") + +print("[test] 8. Legacy save repair: premature calm water flag fixed when boulders were not dropped") +local corruptedSave = { + schemaVersion = 1, + name = "RED", + flags = { + [tostring(FLAG_STOPPED_SEAFOAM_B4F_CURRENT)] = true, -- erroneously set + }, + vars = {}, +} +local fixedSession = Schema.fromSaveTable(corruptedSave) +check(Flags.getFlag(fixedSession, nil, FLAG_STOPPED_SEAFOAM_B4F_CURRENT) == false, "Corrupted calm water flag corrected to false") +check(Flags.getFlag(fixedSession, nil, FLAG_HIDE_SEAFOAM_B4F_BOULDER_1) == true, "Corrupted save: B4F boulder 1 hidden") +check(Flags.getFlag(fixedSession, nil, FLAG_HIDE_SEAFOAM_B4F_BOULDER_2) == true, "Corrupted save: B4F boulder 2 hidden") + +print("[test] 9. Legacy save repair: completed puzzle retains calm water and visible dropped boulders") +local solvedSave = { + schemaVersion = 1, + name = "RED", + flags = { + [tostring(FLAG_HIDE_SEAFOAM_B3F_BOULDER_3)] = true, -- B3F obj 6 dropped + [tostring(FLAG_HIDE_SEAFOAM_B3F_BOULDER_5)] = true, -- B3F obj 3 dropped + }, + vars = {}, +} +local solvedSession = Schema.fromSaveTable(solvedSave) +check(Flags.getFlag(solvedSession, nil, FLAG_STOPPED_SEAFOAM_B4F_CURRENT) == true, "Solved save: B4F current stopped") +check(Flags.getFlag(solvedSession, nil, FLAG_HIDE_SEAFOAM_B4F_BOULDER_1) == false, "Solved save: B4F boulder 1 visible") +check(Flags.getFlag(solvedSession, nil, FLAG_HIDE_SEAFOAM_B4F_BOULDER_2) == false, "Solved save: B4F boulder 2 visible") + +if failed > 0 then + print("[FAIL] " .. failed .. " tests failed") + os.exit(1) +end +print("[test] ALL SEAFOAM PUZZLE AND SAVE REPAIR TESTS PASSED!") +os.exit(0) + diff --git a/tests/game3_size_record_test.lua b/tests/game3_size_record_test.lua new file mode 100644 index 00000000..82fb307e --- /dev/null +++ b/tests/game3_size_record_test.lua @@ -0,0 +1,195 @@ +#!/usr/bin/env luajit +package.path = "./?.lua;./?/init.lua;" .. package.path + +local SizeRecord = require("src.core.game3.pokemon_size_record") +local Std = require("src.core.game3.scripting.stdscripts") +local Natives = require("src.core.game3.scripting.natives") +local Flags = require("src.core.game3.scripting.flags") + +local passed = 0 +local failed = 0 + +local function check(cond, msg) + if cond then + passed = passed + 1 + print("[ok] " .. msg) + else + failed = failed + 1 + print("[FAIL] " .. msg) + end +end + +local function eq(a, b, msg) + check(a == b, (msg or "equal") .. " (got " .. tostring(a) .. ", expected " .. tostring(b) .. ")") +end + +local function makeSession() + return { + party = {}, + vars = {}, + stringVars = {}, + specialVars = {}, + } +end + +local function makeCtx(session) + return { + session = session, + stringVars = session.stringVars, + specialVars = session.specialVars, + } +end + +print("=== 1. Size Hash and Size Calculation ===") +do + local mon1 = { + species = 129, -- Magikarp + personality = 0x12345678, + ivHp = 15, + ivAtk = 10, + ivDef = 5, + ivSpd = 12, + ivSpAtk = 8, + ivSpDef = 4, + } + local hash1 = SizeRecord.getMonSizeHash(mon1) + check(hash1 >= 0 and hash1 <= 0xFFFF, "hash is 16-bit unsigned (got " .. tostring(hash1) .. ")") + + -- Verify exact formula: + -- atk ^ def = 10 ^ 5 = 15 + -- (15 * 15) ^ (0x78) = 225 ^ 120 = 153 (0x99) + -- spAtk ^ spDef = 8 ^ 4 = 12 + -- (12 * 12) ^ (0x56) = 144 ^ 86 = 198 (0xC6) + -- (153 << 8) | 198 = 39366 (0x99C6) + eq(hash1, 39366, "hash1 exact formula value") + + -- Size calculation with b = 0 for Magikarp (height = 9 dm) + -- var = 1, unk0 = 290, unk2 = 1, unk4 = 0 -> unk0 = 290 + -- size = floor(9 * 290 / 10) = 261 cm-tenths (26.1 cm) + local size0 = SizeRecord.getMonSize(129, 0) + eq(size0, 261, "Magikarp min size cm-tenths (b=0)") + + -- Format size: 261 * 100 / 254 = 102 -> 10.2 inches + local str0 = SizeRecord.formatMonSizeRecord(size0) + eq(str0, "10.2", "Magikarp formatted min size in inches") + + -- Heracross (height = 15 dm) with b = 0 + -- size = floor(15 * 290 / 10) = 435 cm-tenths (43.5 cm) + -- format: 435 * 100 / 254 = 171 -> 17.1 inches + local sizeHera0 = SizeRecord.getMonSize(214, 0) + eq(sizeHera0, 435, "Heracross min size cm-tenths (b=0)") + eq(SizeRecord.formatMonSizeRecord(sizeHera0), "17.1", "Heracross formatted min size in inches") +end + +print("=== 2. New Game Initialization ===") +do + local s = makeSession() + local ctx = makeCtx(s) + + SizeRecord.setVar(s, ctx, SizeRecord.VAR_MAGIKARP_SIZE_RECORD, 500) + SizeRecord.setVar(s, ctx, SizeRecord.VAR_HERACROSS_SIZE_RECORD, 500) + + SizeRecord.initMagikarpSizeRecord(s, ctx) + SizeRecord.initHeracrossSizeRecord(s, ctx) + + eq(SizeRecord.getVar(s, ctx, SizeRecord.VAR_MAGIKARP_SIZE_RECORD), 0, "Magikarp size record init to 0") + eq(SizeRecord.getVar(s, ctx, SizeRecord.VAR_HERACROSS_SIZE_RECORD), 0, "Heracross size record init to 0") +end + +print("=== 3. GetMonSizeRecordInfo ===") +do + local s = makeSession() + local ctx = makeCtx(s) + local adapters = { + setStringVar = function(idx, val) s.stringVars[idx] = val end, + } + + SizeRecord.setVar(s, ctx, SizeRecord.VAR_MAGIKARP_SIZE_RECORD, 0) + SizeRecord.getMonSizeRecordInfo(s, ctx, adapters, SizeRecord.SPECIES_MAGIKARP, SizeRecord.VAR_MAGIKARP_SIZE_RECORD) + eq(s.stringVars[1], "MAGIKARP", "StringVar1 has MAGIKARP") + eq(s.stringVars[3], "10.2", "StringVar3 has 10.2") + + SizeRecord.setVar(s, ctx, SizeRecord.VAR_HERACROSS_SIZE_RECORD, 0) + SizeRecord.getMonSizeRecordInfo(s, ctx, adapters, SizeRecord.SPECIES_HERACROSS, SizeRecord.VAR_HERACROSS_SIZE_RECORD) + eq(s.stringVars[1], "HERACROSS", "StringVar1 has HERACROSS") + eq(s.stringVars[3], "17.1", "StringVar3 has 17.1") +end + +print("=== 4. CompareMonSize Logic ===") +do + local s = makeSession() + local ctx = makeCtx(s) + local adapters = { + setStringVar = function(idx, val) s.stringVars[idx] = val end, + } + + local karpSmall = { species = 129, personality = 0x00000000, ivHp = 0, ivAtk = 0, ivDef = 0, ivSpd = 0, ivSpAtk = 0, ivSpDef = 0 } + local karpBig = { species = 129, personality = 0xFFFFFFFF, ivHp = 15, ivAtk = 15, ivDef = 0, ivSpd = 15, ivSpAtk = 15, ivSpDef = 0 } + local pikachu = { species = 25, personality = 0x12345678 } + local egg = { species = 129, isEgg = true } + + s.party = { karpSmall, karpBig, pikachu, egg } + + -- Invalid slot + local code = SizeRecord.compareMonSize(s, ctx, adapters, SizeRecord.SPECIES_MAGIKARP, SizeRecord.VAR_MAGIKARP_SIZE_RECORD, 6) + eq(code, 0, "slot >= 6 returns 0 (cancel)") + + -- Non-matching species (Pikachu) + code = SizeRecord.compareMonSize(s, ctx, adapters, SizeRecord.SPECIES_MAGIKARP, SizeRecord.VAR_MAGIKARP_SIZE_RECORD, 2) + eq(code, 1, "wrong species returns 1") + + -- Egg + code = SizeRecord.compareMonSize(s, ctx, adapters, SizeRecord.SPECIES_MAGIKARP, SizeRecord.VAR_MAGIKARP_SIZE_RECORD, 3) + eq(code, 1, "egg returns 1") + + -- Big Magikarp against record 0 (init): should set record (status 3) + SizeRecord.setVar(s, ctx, SizeRecord.VAR_MAGIKARP_SIZE_RECORD, 0) + code = SizeRecord.compareMonSize(s, ctx, adapters, SizeRecord.SPECIES_MAGIKARP, SizeRecord.VAR_MAGIKARP_SIZE_RECORD, 1) + eq(code, 3, "bigger mon sets new record (returns 3)") + local updatedRecord = SizeRecord.getVar(s, ctx, SizeRecord.VAR_MAGIKARP_SIZE_RECORD) + check(updatedRecord > 0, "VAR_MAGIKARP_SIZE_RECORD updated to new hash") + + -- Small Magikarp against updated big record: should return 2 (smaller) + code = SizeRecord.compareMonSize(s, ctx, adapters, SizeRecord.SPECIES_MAGIKARP, SizeRecord.VAR_MAGIKARP_SIZE_RECORD, 0) + eq(code, 2, "smaller mon returns 2") + + -- Big Magikarp again against its own record: should return 4 (tie) + code = SizeRecord.compareMonSize(s, ctx, adapters, SizeRecord.SPECIES_MAGIKARP, SizeRecord.VAR_MAGIKARP_SIZE_RECORD, 1) + eq(code, 4, "same size mon returns 4 (tie)") +end + +print("=== 5. Specials Execution via Natives ===") +do + local s = makeSession() + local ctx = makeCtx(s) + local Space = require("src.core.game3.scripting.space") + Space.store = s + + local adapters = { + setStringVar = function(idx, val) s.stringVars[idx] = val end, + } + + local heraBig = { species = 214, personality = 0x55555555, ivHp = 15, ivAtk = 15, ivDef = 0, ivSpd = 15, ivSpAtk = 15, ivSpDef = 0 } + s.party = { heraBig } + + -- Special 0x77: GetHeracrossSizeRecordInfo + Natives.special(ctx, Std.SPECIAL.GetHeracrossSizeRecordInfo, adapters) + eq(s.stringVars[1], "HERACROSS", "special 0x77 set HERACROSS") + + -- Special 0x78: CompareHeracrossSize (VAR_RESULT slot 0) + Flags.setVar(s, ctx, 0x800D, 0) + Natives.special(ctx, Std.SPECIAL.CompareHeracrossSize, adapters) + eq(Flags.getVar(s, ctx, 0x800D), 3, "special 0x78 set new record (VAR_RESULT = 3)") + + -- Special 0x79: GetMagikarpSizeRecordInfo + Natives.special(ctx, Std.SPECIAL.GetMagikarpSizeRecordInfo, adapters) + eq(s.stringVars[1], "MAGIKARP", "special 0x79 set MAGIKARP") + + -- Special 0x7A: CompareMagikarpSize (slot 0 is Heracross -> returns 1) + Flags.setVar(s, ctx, 0x800D, 0) + Natives.special(ctx, Std.SPECIAL.CompareMagikarpSize, adapters) + eq(Flags.getVar(s, ctx, 0x800D), 1, "special 0x7A slot 0 not Magikarp (VAR_RESULT = 1)") +end + +print(string.format("\nTotal: %d passed, %d failed", passed, failed)) +if failed > 0 then os.exit(1) end diff --git a/tests/game3_special_ids_test.lua b/tests/game3_special_ids_test.lua index 339f307d..d39e29d1 100644 --- a/tests/game3_special_ids_test.lua +++ b/tests/game3_special_ids_test.lua @@ -92,6 +92,19 @@ local WANT = { EnableNationalPokedex = 0x16F, IsNationalPokedexEnabled = 0x193, StartOldManTutorialBattle = 0x9D, + Script_IsFanClubMemberFanOfPlayer = 0xA3, + Script_GetNumFansOfPlayerInTrainerFanClub = 0xA4, + Script_BufferFanClubTrainerName = 0xA5, + Script_TryLoseFansFromPlayTimeAfterLinkBattle = 0xA6, + Script_TryLoseFansFromPlayTime = 0xA7, + Script_SetPlayerGotFirstFans = 0xA8, + Script_UpdateTrainerFanClubGameClear = 0xA9, + Script_TryGainNewFanFromCounter = 0xAA, + GetHeracrossSizeRecordInfo = 0x77, + CompareHeracrossSize = 0x78, + GetMagikarpSizeRecordInfo = 0x79, + CompareMagikarpSize = 0x7A, + GetProfOaksRatingMessage = 0xD5, } for name, id in pairs(WANT) do check(Std.SPECIAL[name] == id, string.format("Std.SPECIAL.%s == 0x%X (got %s)", @@ -108,7 +121,6 @@ for id = 0, pretCount - 1 do end check(Natives.ALLOW["special:" .. 0x18B] == nil, "0x18B OpenMuseumFossilPic no longer sets dex flags") check(Natives.ALLOW["special:" .. 0x19D] == nil, "0x19D RemoveBerryPowderVendorMenu unbound") -check(Natives.ALLOW["special:" .. 0xA8] == nil, "0xA8 Script_SetPlayerGotFirstFans unbound") if failed > 0 then print("[test] FAILED " .. failed) diff --git a/tests/game3_trainer_fan_club_test.lua b/tests/game3_trainer_fan_club_test.lua new file mode 100644 index 00000000..88b5b3c6 --- /dev/null +++ b/tests/game3_trainer_fan_club_test.lua @@ -0,0 +1,306 @@ +#!/usr/bin/env luajit +package.path = "./?.lua;./?/init.lua;" .. package.path + +local TrainerFanClub = require("src.core.game3.trainer_fan_club") +local Std = require("src.core.game3.scripting.stdscripts") +local Natives = require("src.core.game3.scripting.natives") +local Flags = require("src.core.game3.scripting.flags") +local Rng = require("src.core.game3.rng") + +local passed = 0 +local failed = 0 + +local function check(cond, msg) + if cond then + passed = passed + 1 + print("[ok] " .. msg) + else + failed = failed + 1 + print("[FAIL] " .. msg) + end +end + +local function eq(a, b, msg) + check(a == b, (msg or "equal") .. " (got " .. tostring(a) .. ", expected " .. tostring(b) .. ")") +end + +local function makeSession() + local s = { + flags = {}, + vars = {}, + stringVars = {}, + specialVars = {}, + playtime = { hours = 10, minutes = 0, seconds = 0 }, + rivalName = "GARY", + linkBattleRecords = {}, + } + return s +end + +local function makeCtx(session) + return { + session = session, + stringVars = session.stringVars, + specialVars = session.specialVars, + } +end + +print("=== 1. Bit Packing and Unpacking ===") +do + local raw0 = 0 + local timer, gotInitial, flags = TrainerFanClub.unpack(raw0) + eq(timer, 0, "unpack raw 0 timer") + eq(gotInitial, false, "unpack raw 0 gotInitial") + eq(flags, 0, "unpack raw 0 flags") + + local packed = TrainerFanClub.pack(15, true, 0x07) + -- 15 | 0x80 | (0x07 << 8) = 15 | 128 | 1792 = 1935 (0x078F) + eq(packed, 0x078F, "pack timer=15, gotInitial=true, flags=0x07") + local t2, g2, f2 = TrainerFanClub.unpack(packed) + eq(t2, 15, "unpacked timer 15") + eq(g2, true, "unpacked gotInitial true") + eq(f2, 0x07, "unpacked flags 0x07") + + -- Timer clamping at 127 (7 bits) and not overflowing into bit 7 + local overflowPacked = TrainerFanClub.pack(200, false, 0x00) + local tOver, gOver, _ = TrainerFanClub.unpack(overflowPacked) + eq(tOver, 127, "timer clamped to 127 on pack") + eq(gOver, false, "timer does not overflow into gotInitialFans bit") +end + +print("=== 2. New Game Reset ===") +do + local s = makeSession() + local ctx = makeCtx(s) + TrainerFanClub.setFanClubData(s, ctx, 10, true, 0xFF) + TrainerFanClub.setVar(s, ctx, TrainerFanClub.VAR_FANCLUB_LOSE_FAN_TIMER, 50) + + TrainerFanClub.reset(s, ctx) + eq(TrainerFanClub.getVar(s, ctx, TrainerFanClub.VAR_FANCLUB_FAN_COUNTER), 0, "reset VAR_FANCLUB_FAN_COUNTER") + eq(TrainerFanClub.getVar(s, ctx, TrainerFanClub.VAR_FANCLUB_LOSE_FAN_TIMER), 0, "reset VAR_FANCLUB_LOSE_FAN_TIMER") +end + +print("=== 3. Game Clear Fan Setup ===") +do + local s = makeSession() + local ctx = makeCtx(s) + s.playtime.hours = 25 + + -- Set hide flags initially + Flags.setFlag(s, ctx, TrainerFanClub.FLAG_HIDE_SAFFRON_FAN_CLUB_BLACK_BELT, true) + Flags.setFlag(s, ctx, TrainerFanClub.FLAG_HIDE_SAFFRON_FAN_CLUB_ROCKER, true) + Flags.setFlag(s, ctx, TrainerFanClub.FLAG_HIDE_SAFFRON_FAN_CLUB_WOMAN, true) + Flags.setFlag(s, ctx, TrainerFanClub.FLAG_HIDE_SAFFRON_FAN_CLUB_BEAUTY, true) + + TrainerFanClub.updateTrainerFanClubGameClear(s, ctx) + + local timer, gotInitial, fanFlags = TrainerFanClub.getFanClubData(s, ctx) + eq(gotInitial, true, "gotInitialFans set on game clear") + eq(TrainerFanClub.countFans(fanFlags), 3, "player has 3 initial fans") + eq(TrainerFanClub.isFanClubMemberFanOfPlayer(s, ctx, 0), true, "Member 1 is fan") + eq(TrainerFanClub.isFanClubMemberFanOfPlayer(s, ctx, 1), true, "Member 2 is fan") + eq(TrainerFanClub.isFanClubMemberFanOfPlayer(s, ctx, 2), true, "Member 3 is fan") + eq(TrainerFanClub.isFanClubMemberFanOfPlayer(s, ctx, 3), false, "Member 4 is not fan") + + eq(Flags.getFlag(s, ctx, TrainerFanClub.FLAG_HIDE_SAFFRON_FAN_CLUB_BLACK_BELT), false, "Black belt unhidden") + eq(Flags.getFlag(s, ctx, TrainerFanClub.FLAG_HIDE_SAFFRON_FAN_CLUB_ROCKER), false, "Rocker unhidden") + eq(Flags.getFlag(s, ctx, TrainerFanClub.FLAG_HIDE_SAFFRON_FAN_CLUB_WOMAN), false, "Woman unhidden") + eq(Flags.getFlag(s, ctx, TrainerFanClub.FLAG_HIDE_SAFFRON_FAN_CLUB_BEAUTY), false, "Beauty unhidden") + + eq(TrainerFanClub.getVar(s, ctx, TrainerFanClub.VAR_MAP_SCENE_SAFFRON_CITY_POKEMON_TRAINER_FAN_CLUB), 1, "map scene set to 1") + eq(TrainerFanClub.getVar(s, ctx, TrainerFanClub.VAR_FANCLUB_LOSE_FAN_TIMER), 25, "lose timer set to playtime hours") + + -- Verify idempotent / doesn't re-run if already got initial fans + TrainerFanClub.setFanFlag(fanFlags, 3) -- add extra fan + TrainerFanClub.setVar(s, ctx, TrainerFanClub.VAR_MAP_SCENE_SAFFRON_CITY_POKEMON_TRAINER_FAN_CLUB, 2) + TrainerFanClub.updateTrainerFanClubGameClear(s, ctx) + eq(TrainerFanClub.getVar(s, ctx, TrainerFanClub.VAR_MAP_SCENE_SAFFRON_CITY_POKEMON_TRAINER_FAN_CLUB), 2, "scene stays 2 on subsequent calls") +end + +print("=== 4. Random Gain and Lose ===") +do + local s = makeSession() + local ctx = makeCtx(s) + + -- Seed RNG to known state + Rng.seedNewGame({ seed = 12345 }) + + -- Start with initial fans (0, 1, 2) + TrainerFanClub.updateTrainerFanClubGameClear(s, ctx) + eq(TrainerFanClub.getNumFansOfPlayerInTrainerFanClub(s, ctx), 3, "starting 3 fans") + + -- Gain a fan + local gained = TrainerFanClub.playerGainRandomTrainerFan(s, ctx) + check(gained >= 0 and gained < 8, "gained valid member id: " .. tostring(gained)) + eq(TrainerFanClub.getNumFansOfPlayerInTrainerFanClub(s, ctx), 4, "fan count now 4") + eq(TrainerFanClub.isFanClubMemberFanOfPlayer(s, ctx, gained), true, "gained member is fan") + + -- Lose a fan + local lost = TrainerFanClub.playerLoseRandomTrainerFan(s, ctx) + check(lost >= 0 and lost < 8, "lost valid member id: " .. tostring(lost)) + eq(TrainerFanClub.getNumFansOfPlayerInTrainerFanClub(s, ctx), 3, "fan count now 3") + eq(TrainerFanClub.isFanClubMemberFanOfPlayer(s, ctx, lost), false, "lost member is not fan") + + -- Cannot drop below 1 fan + TrainerFanClub.setFanClubData(s, ctx, 0, true, 0x01) -- only member 0 is fan + eq(TrainerFanClub.getNumFansOfPlayerInTrainerFanClub(s, ctx), 1, "exactly 1 fan") + local cantLose = TrainerFanClub.playerLoseRandomTrainerFan(s, ctx) + eq(cantLose, 0, "returns 0 when attempting to lose from 1 fan") + eq(TrainerFanClub.getNumFansOfPlayerInTrainerFanClub(s, ctx), 1, "still has 1 fan") +end + +print("=== 5. Playtime Decay ===") +do + local s = makeSession() + local ctx = makeCtx(s) + + -- Less than 5 fans: no decay, syncs timer + s.playtime.hours = 100 + TrainerFanClub.setFanClubData(s, ctx, 0, true, 0x0F) -- 4 fans + TrainerFanClub.setVar(s, ctx, TrainerFanClub.VAR_FANCLUB_LOSE_FAN_TIMER, 20) + + TrainerFanClub.tryLoseFansFromPlayTime(s, ctx) + eq(TrainerFanClub.getNumFansOfPlayerInTrainerFanClub(s, ctx), 4, "fans not lost when count < 5") + eq(TrainerFanClub.getVar(s, ctx, TrainerFanClub.VAR_FANCLUB_LOSE_FAN_TIMER), 100, "lose timer synced to playtime") + + -- 6 fans, 30 hours elapsed since lose timer (2 decay steps of 12 hours) + TrainerFanClub.setFanClubData(s, ctx, 0, true, 0x3F) -- 6 fans (0,1,2,3,4,5) + TrainerFanClub.setVar(s, ctx, TrainerFanClub.VAR_FANCLUB_LOSE_FAN_TIMER, 70) + s.playtime.hours = 100 -- delta = 30 hours >= 24 hours (2 steps) + + TrainerFanClub.tryLoseFansFromPlayTime(s, ctx) + eq(TrainerFanClub.getNumFansOfPlayerInTrainerFanClub(s, ctx), 4, "lost 2 fans down to 4") + eq(TrainerFanClub.getVar(s, ctx, TrainerFanClub.VAR_FANCLUB_LOSE_FAN_TIMER), 100, "lose timer synced to 100") + + -- Over 999 hours cap: no decay evaluated + s.playtime.hours = 1000 + TrainerFanClub.setFanClubData(s, ctx, 0, true, 0xFF) -- 8 fans + TrainerFanClub.setVar(s, ctx, TrainerFanClub.VAR_FANCLUB_LOSE_FAN_TIMER, 100) + TrainerFanClub.tryLoseFansFromPlayTime(s, ctx) + eq(TrainerFanClub.getNumFansOfPlayerInTrainerFanClub(s, ctx), 8, "no decay when playtime >= 999") +end + +print("=== 6. Counter Increments (Lance's Room) ===") +do + local s = makeSession() + local ctx = makeCtx(s) + + -- Scene != 2: counter does not advance + TrainerFanClub.setVar(s, ctx, TrainerFanClub.VAR_MAP_SCENE_SAFFRON_CITY_POKEMON_TRAINER_FAN_CLUB, 1) + TrainerFanClub.setFanClubData(s, ctx, 0, true, 0x03) -- 2 fans + local t = TrainerFanClub.tryGainNewFanFromCounter(s, ctx, 0) + eq(t, 0, "counter doesn't increment when scene != 2") + + -- Scene == 2: counter advances + TrainerFanClub.setVar(s, ctx, TrainerFanClub.VAR_MAP_SCENE_SAFFRON_CITY_POKEMON_TRAINER_FAN_CLUB, 2) + t = TrainerFanClub.tryGainNewFanFromCounter(s, ctx, 0) -- arg 0: inc 2 + eq(t, 2, "counter incremented by 2") + + -- Fast-forward timer to 18 + TrainerFanClub.setFanClubData(s, ctx, 18, true, 0x03) -- 2 fans + t = TrainerFanClub.tryGainNewFanFromCounter(s, ctx, 0) -- 18 + 2 = 20 -> gain fan and reset + eq(t, 0, "counter resets to 0 when fan gained at threshold 20") + eq(TrainerFanClub.getNumFansOfPlayerInTrainerFanClub(s, ctx), 3, "fan count grew to 3") + + -- At 3 fans: reaches 20 but caps at 20 without gaining fan + TrainerFanClub.setFanClubData(s, ctx, 18, true, 0x07) -- 3 fans + t = TrainerFanClub.tryGainNewFanFromCounter(s, ctx, 0) + eq(t, 20, "counter capped at 20 when fans >= 3") + eq(TrainerFanClub.getNumFansOfPlayerInTrainerFanClub(s, ctx), 3, "fan count stays 3") +end + +print("=== 7. Buffer Trainer Name ===") +do + local s = makeSession() + local ctx = makeCtx(s) + s.rivalName = "BLUE" + local adapters = { + setStringVar = function(idx, val) + s.stringVars[idx] = val + end, + } + + -- Fallbacks without link records + TrainerFanClub.bufferFanClubTrainerName(s, ctx, adapters, TrainerFanClub.MEMBER.MEMBER1) + eq(s.stringVars[1], "BLUE", "Member 1 buffers Rival") + + TrainerFanClub.bufferFanClubTrainerName(s, ctx, adapters, TrainerFanClub.MEMBER.MEMBER5) + eq(s.stringVars[1], "LT. SURGE", "Member 5 buffers LT. SURGE") + + TrainerFanClub.bufferFanClubTrainerName(s, ctx, adapters, TrainerFanClub.MEMBER.MEMBER7) + eq(s.stringVars[1], "KOGA", "Member 7 buffers KOGA") + + -- With link records + s.linkBattleRecords = { + { name = "ASH", wins = 5, losses = 1 }, + { name = "MISTY", wins = 2, losses = 3 }, + } + + TrainerFanClub.bufferFanClubTrainerName(s, ctx, adapters, TrainerFanClub.MEMBER.MEMBER1) + eq(s.stringVars[1], "ASH", "Member 1 buffers Link Record 0 (ASH)") + + TrainerFanClub.bufferFanClubTrainerName(s, ctx, adapters, TrainerFanClub.MEMBER.MEMBER6) + eq(s.stringVars[1], "MISTY", "Member 6 buffers Link Record 1 (MISTY)") +end + +print("=== 8. Script Specials Execution via Natives ===") +do + local s = makeSession() + local ctx = makeCtx(s) + local Space = require("src.core.game3.scripting.space") + Space.store = s + + local adapters = { + setStringVar = function(idx, val) + s.stringVars[idx] = val + end, + } + + -- 1. Script_UpdateTrainerFanClubGameClear (0xA9) + s.playtime.hours = 12 + Natives.special(ctx, Std.SPECIAL.Script_UpdateTrainerFanClubGameClear, adapters) + eq(TrainerFanClub.getNumFansOfPlayerInTrainerFanClub(s, ctx), 3, "special 0xA9 granted 3 fans") + + -- 2. Script_GetNumFansOfPlayerInTrainerFanClub (0xA4) + Natives.special(ctx, Std.SPECIAL.Script_GetNumFansOfPlayerInTrainerFanClub, adapters) + eq(Flags.getVar(s, ctx, 0x800D), 3, "special 0xA4 set VAR_RESULT to 3") + + -- 3. Script_IsFanClubMemberFanOfPlayer (0xA3) + Flags.setVar(s, ctx, 0x8004, 0) -- Member 1 + Natives.special(ctx, Std.SPECIAL.Script_IsFanClubMemberFanOfPlayer, adapters) + eq(Flags.getVar(s, ctx, 0x800D), 1, "special 0xA3 member 0 is fan (VAR_RESULT = 1)") + + Flags.setVar(s, ctx, 0x8004, 5) -- Member 6 + Natives.special(ctx, Std.SPECIAL.Script_IsFanClubMemberFanOfPlayer, adapters) + eq(Flags.getVar(s, ctx, 0x800D), 0, "special 0xA3 member 5 is not fan (VAR_RESULT = 0)") + + -- 4. Script_BufferFanClubTrainerName (0xA5) + Flags.setVar(s, ctx, 0x8004, 4) -- Member 5 (Rocker) + Natives.special(ctx, Std.SPECIAL.Script_BufferFanClubTrainerName, adapters) + eq(s.stringVars[1], "LT. SURGE", "special 0xA5 buffered LT. SURGE to stringVars[1]") + + -- 5. Script_TryGainNewFanFromCounter (0xAA) + Flags.setVar(s, ctx, TrainerFanClub.VAR_MAP_SCENE_SAFFRON_CITY_POKEMON_TRAINER_FAN_CLUB, 2) + Flags.setVar(s, ctx, 0x8004, 0) -- increment index 0 (+2) + Natives.special(ctx, Std.SPECIAL.Script_TryGainNewFanFromCounter, adapters) + eq(Flags.getVar(s, ctx, 0x800D), 2, "special 0xAA advanced timer to 2") + + -- 6. Script_SetPlayerGotFirstFans (0xA8) + TrainerFanClub.setFanClubData(s, ctx, 0, false, 0) + Natives.special(ctx, Std.SPECIAL.Script_SetPlayerGotFirstFans, adapters) + local _, gotFirst, _ = TrainerFanClub.getFanClubData(s, ctx) + eq(gotFirst, true, "special 0xA8 set gotInitialFans") + + -- 7. Script_TryLoseFansFromPlayTime (0xA7) + s.playtime.hours = 50 + Natives.special(ctx, Std.SPECIAL.Script_TryLoseFansFromPlayTime, adapters) + eq(TrainerFanClub.getVar(s, ctx, TrainerFanClub.VAR_FANCLUB_LOSE_FAN_TIMER), 50, "special 0xA7 synced lose timer") + + -- 8. Script_TryLoseFansFromPlayTimeAfterLinkBattle (0xA6) + s.playtime.hours = 75 + Natives.special(ctx, Std.SPECIAL.Script_TryLoseFansFromPlayTimeAfterLinkBattle, adapters) + eq(TrainerFanClub.getVar(s, ctx, TrainerFanClub.VAR_FANCLUB_LOSE_FAN_TIMER), 75, "special 0xA6 synced lose timer") +end + +print(string.format("\nTotal: %d passed, %d failed", passed, failed)) +if failed > 0 then os.exit(1) end diff --git a/tests/lz77_test.lua b/tests/lz77_test.lua new file mode 100644 index 00000000..eac63a7d --- /dev/null +++ b/tests/lz77_test.lua @@ -0,0 +1,57 @@ +-- Unit test for GBA LZ77 decompression +local Lz77 = require("src.import.gba.lz77") + +local function test_lz77_basic() + local payload = "HELLO WORLD, THIS IS A TEST OF GBA LZ77 COMPRESSION AND DECOMPRESSION!" + local compressed = Lz77.compressStore(payload) + assert(compressed[1] == 0x10, "header type 0x10") + + local decomp, consumed = Lz77.decompressFromBytes(compressed, 0) + assert(#decomp == #payload, ("decomp length %d != payload %d"):format(#decomp, #payload)) + local decompStr = Lz77.toString(decomp) + assert(decompStr == payload, "decompressed string must match original payload") + print("✓ test_lz77_basic passed") +end + +local function test_lz77_matches() + -- Create a payload with repeated patterns to test LZ77 sliding window references + local raw = "ABCDEFGHABCDEFGH" -- 16 bytes: 8 literals, then 1 match of length 8 at disp 8 + local bytes = { + 0x10, 16, 0, 0, + 0x00, -- flags: 8 literals + string.byte("A"), string.byte("B"), string.byte("C"), string.byte("D"), + string.byte("E"), string.byte("F"), string.byte("G"), string.byte("H"), + 0x80, -- flags: bit 7 is match + 0x50, 0x07, -- length=8 (0x5+3), disp=7 (goes back 7+1=8 bytes) + } + + local decomp, consumed = Lz77.decompressFromBytes(bytes, 0) + local decompStr = Lz77.toString(decomp) + assert(decompStr == raw, ("expected '%s', got '%s'"):format(raw, decompStr)) + print("✓ test_lz77_matches passed") +end + +local function test_lz77_bounds_safety() + -- Corrupted match with negative / out-of-bounds displacement + local bytes = { + 0x10, 4, 0, 0, + 0x80, -- match on first byte! read_pos = 0 - 100 < 0 + 0x10, 0x64, -- length=4, disp=100 + } + -- Should NOT crash or segfault; safely writes 0s + local decomp, consumed = Lz77.decompressFromBytes(bytes, 0) + assert(#decomp == 4, "should decompress 4 bytes") + for i = 1, 4 do + assert(decomp[i] == 0, "OOB read should produce 0") + end + print("✓ test_lz77_bounds_safety passed") +end + +local function run_all() + test_lz77_basic() + test_lz77_matches() + test_lz77_bounds_safety() + print("All LZ77 unit tests passed!") +end + +run_all() diff --git a/tests/save_editor_gen3_tests.lua b/tests/save_editor_gen3_tests.lua index 7c4489b8..3e74561c 100644 --- a/tests/save_editor_gen3_tests.lua +++ b/tests/save_editor_gen3_tests.lua @@ -559,8 +559,301 @@ do check(okT, "App.draw renders with table-typed items: " .. tostring(errT)) App.unload() - _G.love = prevLove +end + +-- -------------------------------------------------------------------------- +-- 15. MovePicker & Move Assignment Normalization +-- -------------------------------------------------------------------------- +do + local State = require("State") + local MovePicker = require("MovePicker") + local Kit = require("Kit") + + local S = State.new() + S.data = mockData + S.cat = Catalog.build(mockData) + S.save = Schema.newGame({ version = "firered" }) + S.version = "firered" + + -- Create mon with numeric array moves (e.g. 57=SURF, 10=SCRATCH) + local mon = MonOps.create(mockData, 6, 50, 3) + mon.moves = { 57, 10, 0, 0 } + S.save.party[1] = mon + S.editingMon = mon + + -- Open MovePicker for slot 1 (which holds numeric 57) + local okOpen = Ops.openMovePicker(S, Kit, 1) + check(okOpen, "Ops.openMovePicker opens successfully with numeric moves") + + -- Draw MovePicker (must NOT crash with attempt to index a number value) + local okDraw, errDraw = pcall(function() + MovePicker.draw(S, Kit, 800, 600) + end) + check(okDraw, "MovePicker.draw renders with numeric moves without crashing: " .. tostring(errDraw)) + + -- Move deduplication: attempting to assign SURF when move 1 is already 57 + local surfAssigned = Ops.setMove(S, mon, 1, "SURF") + checkEq(surfAssigned, false, "Ops.setMove refuses assigning SURF when slot is already numeric 57 (Surf)") + + -- Assign a new move (e.g. FLAMETHROWER = 53 or FIRE BLAST = 126) + local fbAssigned = Ops.setMove(S, mon, 1, "FIRE BLAST") + check(fbAssigned, "Ops.setMove assigns FIRE BLAST") + checkEq(mon.moves[1], 126, "mon.moves[1] updated to canonical numeric ID 126") + + Ops.closeMovePicker(S, Kit) +end + +-- -------------------------------------------------------------------------- +-- 16. SpeciesPicker Highlighting Normalization +-- -------------------------------------------------------------------------- +do + local State = require("State") + local SpeciesPicker = require("SpeciesPicker") + local Kit = require("Kit") + + local S = State.new() + S.data = mockData + S.cat = Catalog.build(mockData) + S.save = Schema.newGame({ version = "firered" }) + S.version = "firered" + + local mon = MonOps.create(mockData, 6, 50, 3) -- Charizard (species = 6) + S.editingMon = mon + + local okOpen = Ops.openSpeciesPicker(S, Kit) + check(okOpen, "Ops.openSpeciesPicker opens") + + local okDraw, errDraw = pcall(function() + SpeciesPicker.draw(S, Kit, 800, 600) + end) + check(okDraw, "SpeciesPicker.draw renders without crashing: " .. tostring(errDraw)) + + Ops.closeSpeciesPicker(S, Kit) +end + +-- -------------------------------------------------------------------------- +-- 17. PID Generation and Mathematical Parity (Nature, Ability, Gender, Shiny) +-- -------------------------------------------------------------------------- +do + local SummaryData = require("src.core.game3.summary_data") + local PokemonG3 = require("src.core.game3.pokemon") + + local species = 6 -- Charizard (87.5% male ratio) + local otId = 12345 + local otSecretId = 54321 + + -- 1. Test Adamant (3), Ability 1 (slot 0), Male, Non-Shiny + local pid1 = MonOps.generatePid(species, otId, otSecretId, { + nature = 3, + ability = 0, + gender = "M", + shiny = false, + }) + checkEq(pid1 % 25, 3, "generatePid produces requested Nature (Adamant = 3)") + checkEq(pid1 % 2, 0, "generatePid produces requested Ability slot 0") + checkEq(PokemonG3.gender(species, pid1), "M", "generatePid produces Male gender") + checkEq(SummaryData.isShiny({ personality = pid1, otId = otId, otSecretId = otSecretId }), false, "generatePid produces non-shiny") + + -- 2. Test Modest (15), Ability 2 (slot 1), Female, Shiny + local pid2 = MonOps.generatePid(species, otId, otSecretId, { + nature = 15, + ability = 1, + gender = "F", + shiny = true, + }) + checkEq(pid2 % 25, 15, "generatePid produces requested Nature (Modest = 15)") + checkEq(pid2 % 2, 1, "generatePid produces requested Ability slot 1") + checkEq(PokemonG3.gender(species, pid2), "F", "generatePid produces Female gender") + checkEq(SummaryData.isShiny({ personality = pid2, otId = otId, otSecretId = otSecretId }), true, "generatePid produces shiny") +end + +-- -------------------------------------------------------------------------- +-- 18. Pokédex Dual-Indexing and National Dex Toggle +-- -------------------------------------------------------------------------- +do + local State = require("State") + local S = State.new() + S.data = mockData + S.cat = Catalog.build(mockData) + S.save = Schema.newGame({ version = "firered" }) + S.version = "firered" + + -- Check initial state + local seen, owned, total = Ops.dexCounts(S) + check(seen == 0, "initial dex seen is 0") + check(owned == 0, "initial dex owned is 0") + + -- Mark Bulbasaur seen + Ops.dexSeen(S, "BULBASAUR", true) + check(S.save.dex.seen["BULBASAUR"] == true, "dex.seen contains BULBASAUR string key") + check(S.save.dex.seen[1] == true, "dex.seen contains species ID 1 numeric key") + + -- Mark Bulbasaur owned + Ops.dexOwned(S, "BULBASAUR", true) + check(S.save.dex.owned["BULBASAUR"] == true, "dex.owned contains BULBASAUR string key") + check(S.save.dex.owned[1] == true, "dex.owned contains species ID 1 numeric key") + + seen, owned = Ops.dexCounts(S) + checkEq(seen, 1, "dexCounts counts 1 seen (deduplicated)") + checkEq(owned, 1, "dexCounts counts 1 owned (deduplicated)") + + -- National Dex toggle + checkEq(S.save.dex.national, false, "National Dex initially false") + Ops.toggleNationalDex(S) + checkEq(S.save.dex.national, true, "National Dex toggled to true") + checkEq(Flags.getFlag(S.save, nil, "FLAG_SYS_NATIONAL_DEX"), true, "FLAG_SYS_NATIONAL_DEX is true") + + Ops.toggleNationalDex(S) + checkEq(S.save.dex.national, false, "National Dex toggled back to false") + checkEq(Flags.getFlag(S.save, nil, "FLAG_SYS_NATIONAL_DEX"), false, "FLAG_SYS_NATIONAL_DEX is false") +end + +-- -------------------------------------------------------------------------- +-- 19. Gen 3 Pokémon Characteristics Operations (MonEditor / Ops) +-- -------------------------------------------------------------------------- +do + local State = require("State") + local MonEditor = require("MonEditor") + local Kit = require("Kit") + + local S = State.new() + S.data = mockData + S.cat = Catalog.build(mockData) + S.save = Schema.newGame({ version = "firered" }) + S.version = "firered" + + local mon = MonOps.create(mockData, 6, 50, 3) + S.save.party[1] = mon + S.editingMon = mon + + -- Set Held Item + Ops.setHeldItem(S, mon, "LEFTOVERS") + check(mon.heldItem ~= nil, "mon heldItem set") + Ops.setHeldItem(S, mon, nil) + check(mon.heldItem == nil, "mon heldItem cleared") + + -- Set Happiness / Friendship + Ops.setHappiness(S, mon, 200) + checkEq(mon.friendship, 200, "mon friendship set to 200") + + -- Set Nature + Ops.setNature(S, mon, 3) -- Adamant + checkEq(mon.nature, 3, "mon nature set to Adamant (3)") + checkEq(mon.personality % 25, 3, "mon personality matches Adamant modulo 25") + + -- Set Ability + Ops.setAbility(S, mon, 1) -- Slot 2 + checkEq(mon.personality % 2, 1, "mon personality has ability bit 1") + + -- Set Shiny + Ops.setShiny(S, mon, true) + check(require("src.core.game3.summary_data").isShiny(mon), "mon is shiny via PID math") + + -- Draw MonEditor + local okDraw, errDraw = pcall(function() + MonEditor.draw(S, Kit, 20, 20, 600, 700) + end) + check(okDraw, "MonEditor.draw renders Gen 3 characteristics without error: " .. tostring(errDraw)) +end + +-- -------------------------------------------------------------------------- +-- 20. Gen 3 Event Categories & Variable Operations (Events panel / Ops / Gen3Flags) +-- -------------------------------------------------------------------------- +do + local State = require("State") + local Events = require("panels.Events") + local Gen3Flags = require("Gen3Flags") + local Kit = require("Kit") + + local cats = Gen3Flags.categories() + check(type(cats.story) == "table" and #cats.story > 0, "cats.story has flags") + check(type(cats.trainers) == "table" and #cats.trainers > 0, "cats.trainers has trainers") + check(type(cats.items) == "table" and #cats.items > 0, "cats.items has items") + check(type(cats.toggles) == "table" and #cats.toggles > 0, "cats.toggles has toggles") + check(type(cats.system) == "table" and #cats.system > 0, "cats.system has system flags") + check(type(cats.vars) == "table" and #cats.vars > 0, "cats.vars has variables") + + local S = State.new() + S.data = mockData + S.cat = Catalog.build(mockData) + S.save = Schema.newGame({ version = "firered" }) + S.version = "firered" + S.events = Catalog.game3EventList() + S.game3Events = cats + + -- 1. Story flags toggle + Ops.setFlag(S, "FLAG_GOT_BICYCLE", true) + checkEq(Gen.getFlag(S.save, "FLAG_GOT_BICYCLE"), true, "FLAG_GOT_BICYCLE set to true") + Ops.setFlag(S, "FLAG_GOT_BICYCLE", false) + checkEq(Gen.getFlag(S.save, "FLAG_GOT_BICYCLE"), false, "FLAG_GOT_BICYCLE set to false") + + -- 2. Trainer flags toggle (0x500 + trainerId) + local tFlag = 0x500 + 89 -- TRAINER_YOUNGSTER_BEN (0x559 = 1369) + Ops.setFlag(S, tFlag, true) + checkEq(Gen.getFlag(S.save, tFlag), true, "TRAINER_YOUNGSTER_BEN flag set to true") + + -- Set up some VS Seeker / Rematch data + S.save.vsSeeker = S.save.vsSeeker or {} + S.save.vsSeeker.rematches = { [89] = true } + S.save.trainerRematches = { [89] = 1 } + S.save.vars = S.save.vars or {} + S.save.vars[0x40AA] = 5 + + -- Clear all trainers (two-click confirmation) + Ops.clearTrainers(S) -- first click arms + checkEq(S.armed, "clear-trainers", "Ops.clearTrainers armed on first click") + Ops.clearTrainers(S) -- second click confirms + checkEq(Gen.getFlag(S.save, tFlag), false, "TRAINER_YOUNGSTER_BEN flag cleared by Ops.clearTrainers") + checkEq(next(S.save.vsSeeker.rematches), nil, "vsSeeker.rematches scrubbed by Ops.clearTrainers") + checkEq(next(S.save.trainerRematches), nil, "trainerRematches scrubbed by Ops.clearTrainers") + checkEq(S.save.vars[0x40AA], 0, "VAR_QLBAK_TRAINER_REMATCHES cleared by Ops.clearTrainers") + + -- 3. Item ball & hidden item flags toggle and bulk clear + local itemFlag = 0x154 -- FLAG_HIDE_ROUTE2_ETHER + local hiddenFlag = 0x3E8 -- FLAG_HIDDEN_ITEM_VIRIDIAN_FOREST_POTION + Ops.setFlag(S, itemFlag, true) + Ops.setFlag(S, hiddenFlag, true) + checkEq(Gen.getFlag(S.save, itemFlag), true, "itemFlag set to true") + checkEq(Gen.getFlag(S.save, hiddenFlag), true, "hiddenFlag set to true") + + Ops.clearItems(S) -- arms + checkEq(S.armed, "clear-items", "Ops.clearItems armed on first click") + Ops.clearItems(S) -- confirms + checkEq(Gen.getFlag(S.save, itemFlag), false, "itemFlag cleared by Ops.clearItems") + checkEq(Gen.getFlag(S.save, hiddenFlag), false, "hiddenFlag cleared by Ops.clearItems") + + -- 4. Object toggle flags + local hideOak = 0x02C -- FLAG_HIDE_OAK_IN_PALLET_TOWN + Ops.setFlag(S, hideOak, true) + checkEq(Gen.getFlag(S.save, hideOak), true, "FLAG_HIDE_OAK_IN_PALLET_TOWN set to true") + + Ops.clearToggles(S) -- arms + Ops.clearToggles(S) -- confirms + checkEq(Gen.getFlag(S.save, hideOak), false, "FLAG_HIDE_OAK_IN_PALLET_TOWN cleared by Ops.clearToggles") + + -- 5. Variables get/set and bounds clamping + local sceneVar = 0x4050 -- VAR_MAP_SCENE_PALLET_TOWN_OAK + checkEq(Gen.getVar(S.save, sceneVar), 0, "initial sceneVar value is 0") + Ops.setVar(S, sceneVar, 2) + checkEq(Gen.getVar(S.save, sceneVar), 2, "sceneVar set to 2") + + -- 16-bit boundary safety tests + Ops.setVar(S, sceneVar, -10) + checkEq(Gen.getVar(S.save, sceneVar), 0, "negative value clamped to 0") + Ops.setVar(S, sceneVar, 70000) + checkEq(Gen.getVar(S.save, sceneVar), 65535, "large value clamped to 65535 (0xFFFF)") + + -- 6. Render all 6 sub-tabs in Events panel without error + local subTabs = { "story", "trainers", "items", "toggles", "system", "vars" } + for _, tab in ipairs(subTabs) do + S.eventsTab = tab + local okDraw, errDraw = pcall(function() + Events.draw(S, Kit, 20, 20, 600, 700) + end) + check(okDraw, string.format("Events.draw renders sub-tab '%s' without error: %s", tab, tostring(errDraw))) + end end print(string.format("save editor gen3 tests: %d passed, %d failed", passed, failed)) if failed > 0 then os.exit(1) end + diff --git a/tools/save-editor/App.lua b/tools/save-editor/App.lua index bde1d1f4..db3e04f8 100644 --- a/tools/save-editor/App.lua +++ b/tools/save-editor/App.lua @@ -111,6 +111,7 @@ local function applyLoaded(path, statusVerb) end if Gen.of(S.save, S.version) == 3 then S.events = Catalog.game3EventList(S.modRoots) + S.game3Events = Catalog.game3Categories(S.modRoots) elseif Gen.of(S.save, S.version) == 2 then S.events = Catalog.gen2EventList(Gen.engineOf(S.save, S.version), S.modRoots) end diff --git a/tools/save-editor/Catalog.lua b/tools/save-editor/Catalog.lua index 02a9bdfe..60efc01f 100644 --- a/tools/save-editor/Catalog.lua +++ b/tools/save-editor/Catalog.lua @@ -168,4 +168,19 @@ function Catalog.game3EventList(extraDirs) return names end +function Catalog.game3Categories(extraDirs) + local ok, Gen3Flags = pcall(require, "Gen3Flags") + if ok and Gen3Flags and Gen3Flags.categories then + return Gen3Flags.categories(extraDirs) + end + return { + story = Catalog.game3EventList(extraDirs), + trainers = {}, + items = {}, + toggles = {}, + system = {}, + vars = {}, + } +end + return Catalog diff --git a/tools/save-editor/Gen.lua b/tools/save-editor/Gen.lua index 62ee63d9..c89e4c3c 100644 --- a/tools/save-editor/Gen.lua +++ b/tools/save-editor/Gen.lua @@ -632,6 +632,39 @@ function Gen.setFlag(save, name, on) save.flags[name] = on and true or nil end +function Gen.getVar(save, nameOrId) + if type(save) ~= "table" then return 0 end + local g = Gen.of(save) + if g == 3 then + local okF, Flags = pcall(require, "src.core.game3.scripting.flags") + if okF and Flags then + return Flags.getVar(save, nil, nameOrId) + end + if save.vars then + return save.vars[nameOrId] or save.vars[tonumber(nameOrId)] or save.vars[tostring(nameOrId)] or 0 + end + end + return 0 +end + +function Gen.setVar(save, nameOrId, val) + if type(save) ~= "table" then return end + val = math.max(0, math.min(65535, math.floor(tonumber(val) or 0))) + local g = Gen.of(save) + if g == 3 then + local okF, Flags = pcall(require, "src.core.game3.scripting.flags") + if okF and Flags then + Flags.setVar(save, nil, nameOrId, val) + return + end + save.vars = save.vars or {} + save.vars[nameOrId] = val + if tonumber(nameOrId) then + save.vars[tonumber(nameOrId)] = val + end + end +end + function Gen.flagCount(save) if type(save) ~= "table" then return 0 end local g = Gen.of(save) diff --git a/tools/save-editor/Gen3Flags.lua b/tools/save-editor/Gen3Flags.lua new file mode 100644 index 00000000..7ccd689e --- /dev/null +++ b/tools/save-editor/Gen3Flags.lua @@ -0,0 +1,261 @@ +-- Gen 3 (FireRed) event, flag, and variable categorization for the save editor. +-- Slices the raw save.flags bitfield and save.vars space into clean categories: +-- Story flags, Trainers (0x500 + trainerId), Items taken, Object toggles, +-- System flags, and Script variables. + +local FlagsTable = require("src.core.game3.scripting.flags_table") + +local Gen3Flags = {} + +local TRAINERS_CACHE = nil + +local function readText(path) + local fs = love and love.filesystem + if fs and fs.read and fs.getInfo and fs.getInfo(path) then + local ok, body = pcall(fs.read, path) + if ok and body then return body end + end + local f = io.open(path, "r") + if not f then return nil end + local body = f:read("*a") + f:close() + return body +end + +-- Parse pokefirered/include/constants/opponents.h for all 742 TRAINER_ constants +local function loadTrainers() + if TRAINERS_CACHE then return TRAINERS_CACHE end + local trainers = {} + local seen = {} + local header = readText("pokefirered/include/constants/opponents.h") + if header then + for line in header:gmatch("[^\r\n]+") do + local name, idStr = line:match("^%s*#define%s+(TRAINER_[%w_]+)%s+(%d+)") + if name and idStr then + local id = tonumber(idStr) + if id and id > 0 and not seen[id] then + seen[id] = true + trainers[#trainers + 1] = { + id = id, + name = name, + flagId = 0x500 + id, + label = string.format("%s (0x%03X)", name, 0x500 + id), + } + end + end + end + end + + -- Fallback if opponents.h wasn't reachable + if #trainers == 0 then + for id = 1, 742 do + trainers[#trainers + 1] = { + id = id, + name = string.format("TRAINER_0x%03X", id), + flagId = 0x500 + id, + label = string.format("TRAINER_0x%03X (0x%03X)", id, 0x500 + id), + } + end + end + + table.sort(trainers, function(a, b) return a.id < b.id end) + TRAINERS_CACHE = trainers + return trainers +end + +Gen3Flags.loadTrainers = loadTrainers + +-- Item Ball check (strict range 0x154..0x1FE, 0x1A6, 0x1BC..0x1FD) +local function isItemBallFlag(name, id) + if id >= 0x154 and id <= 0x1FE then return true end + if id == 0x1A6 or id == 0x1BC or id == 0x1BD or (id >= 0x1BE and id <= 0x1FD) then return true end + return false +end + +-- Hidden item check (strict range 0x3E8..0x4A6) +local function isHiddenItemFlag(name, id) + if id >= 0x3E8 and id <= 0x4A6 then return true end + if type(name) == "string" and name:find("^FLAG_HIDDEN_ITEM_") then return true end + return false +end + +-- System flags (0x800..0x8FF) +local function isSystemFlag(name, id) + if id >= 0x800 and id <= 0x8FF then return true end + if type(name) == "string" then + if name:find("^FLAG_SYS_") or name:find("^FLAG_BADGE") or name:find("^FLAG_WORLD_MAP_") or name:find("^FLAG_ENABLE_SHIP_") then + return true + end + end + return false +end + +-- Object hide/show toggles (0x028..0x0AE, plus non-item FLAG_HIDE_ flags) +local function isObjectToggleFlag(name, id) + if isItemBallFlag(name, id) or isHiddenItemFlag(name, id) then return false end + if id >= 0x028 and id <= 0x0AE then return true end + if type(name) == "string" and name:find("^FLAG_HIDE_") then return true end + return false +end + +-- Boss clear flags (0x4B0..0x4BC) +local function isBossClearFlag(name, id) + if id >= 0x4B0 and id <= 0x4BC then return true end + if type(name) == "string" and name:find("^FLAG_DEFEATED_") then return true end + return false +end + +-- Story flags (0x230..0x3E7, boss clears, and story prefixes) +local function isStoryFlag(name, id) + if isItemBallFlag(name, id) or isHiddenItemFlag(name, id) or isSystemFlag(name, id) or isObjectToggleFlag(name, id) then + return false + end + if id >= 0x500 and id <= 0x7FF then return false end -- trainer flag + if (id >= 0x230 and id <= 0x3E7) or isBossClearFlag(name, id) then return true end + if type(name) == "string" then + if name:find("^FLAG_GOT_") or name:find("^FLAG_RESCUED_") or name:find("^FLAG_HELPED_") + or name:find("^FLAG_BEAT_") or name:find("^FLAG_CAN_") or name:find("^FLAG_CINNABAR_GYM_") + or name:find("^FLAG_DID_") or name:find("^FLAG_FOUGHT_") or name:find("^FLAG_FOUND_") + or name:find("^FLAG_LEARNED_") or name:find("^FLAG_MET_") or name:find("^FLAG_OPENED_") + or name:find("^FLAG_REVIVED_") or name:find("^FLAG_RETURNED_") or name:find("^FLAG_TALKED_TO_") + or name:find("^FLAG_USED_") or name:find("^FLAG_WOKE_UP_") or name:find("^FLAG_SHOWN_") + or name:find("^MOD_") then + return true + end + end + return false +end + +function Gen3Flags.categories(extraDirs) + local trainers = loadTrainers() + + local story = {} + local items = {} + local toggles = {} + local system = {} + local vars = {} + + local seenStory = {} + local seenItems = {} + local seenToggles = {} + local seenSystem = {} + + local flagsTable = FlagsTable.FLAGS or {} + + -- 1. Classify all known flags in FlagsTable + for name, id in pairs(flagsTable) do + if type(name) == "string" and type(id) == "number" then + if isHiddenItemFlag(name, id) or isItemBallFlag(name, id) then + if not seenItems[name] then + seenItems[name] = true + items[#items + 1] = { name = name, id = id, label = string.format("%s (0x%03X)", name, id) } + end + elseif isSystemFlag(name, id) then + if not seenSystem[name] then + seenSystem[name] = true + system[#system + 1] = { name = name, id = id, label = string.format("%s (0x%03X)", name, id) } + end + elseif isObjectToggleFlag(name, id) then + if not seenToggles[name] then + seenToggles[name] = true + toggles[#toggles + 1] = { name = name, id = id, label = string.format("%s (0x%03X)", name, id) } + end + elseif isStoryFlag(name, id) then + if not seenStory[name] then + seenStory[name] = true + story[#story + 1] = { name = name, id = id, label = string.format("%s (0x%03X)", name, id) } + end + end + end + end + + -- Scrape mod flags into story + local Catalog = require("Catalog") + local modFlags = Catalog.scrapeEvents(nil, nil, nil, extraDirs) + for _, name in ipairs(modFlags) do + if not seenStory[name] then + seenStory[name] = true + story[#story + 1] = { name = name, id = name, label = name } + end + end + + -- 2. Variables (0x4000..0x40FF) + local seenVars = {} + for name, id in pairs(FlagsTable.VARS or {}) do + if type(name) == "string" and type(id) == "number" and id >= 0x4000 and id <= 0x41FF then + if not seenVars[id] then + seenVars[id] = true + vars[#vars + 1] = { + name = name, + id = id, + label = string.format("%s (0x%04X)", name, id), + } + end + end + end + for id, name in pairs(FlagsTable.VARS_BY_ID or {}) do + if type(id) == "number" and id >= 0x4000 and id <= 0x41FF then + if not seenVars[id] then + seenVars[id] = true + vars[#vars + 1] = { + name = name, + id = id, + label = string.format("%s (0x%04X)", name, id), + } + end + end + end + + -- Sort lists by ID / label + local function sortById(a, b) + local aid = type(a.id) == "number" and a.id or 999999 + local bid = type(b.id) == "number" and b.id or 999999 + if aid ~= bid then return aid < bid end + return tostring(a.name) < tostring(b.name) + end + + table.sort(story, sortById) + table.sort(items, sortById) + table.sort(toggles, sortById) + table.sort(system, sortById) + table.sort(vars, sortById) + + return { + story = story, + trainers = trainers, + items = items, + toggles = toggles, + system = system, + vars = vars, + } +end + +-- Sets / lists of all flag IDs for bulk operations +function Gen3Flags.allTrainerFlagIds() + local trainers = loadTrainers() + local ids = {} + for _, t in ipairs(trainers) do + ids[#ids + 1] = t.flagId + end + return ids +end + +function Gen3Flags.allItemFlagIds() + local cats = Gen3Flags.categories() + local ids = {} + for _, it in ipairs(cats.items) do + ids[#ids + 1] = it.id + end + return ids +end + +function Gen3Flags.allToggleFlagIds() + local cats = Gen3Flags.categories() + local ids = {} + for _, tg in ipairs(cats.toggles) do + ids[#ids + 1] = tg.id + end + return ids +end + +return Gen3Flags diff --git a/tools/save-editor/MonOps.lua b/tools/save-editor/MonOps.lua index 238819a5..e156c7e0 100644 --- a/tools/save-editor/MonOps.lua +++ b/tools/save-editor/MonOps.lua @@ -103,6 +103,11 @@ function MonOps.recalc(data, mon, gen) local okP, PokemonG3 = pcall(require, "src.core.game3.pokemon") if okP and PokemonG3 then require("src.core.game3.save_mon").normalize(mon) + if mon.personality then + mon.nature = PokemonG3.natureId and PokemonG3.natureId(mon.personality) or mon.nature + mon.gender = PokemonG3.gender and PokemonG3.gender(mon.speciesId or mon.species, mon.personality) or mon.gender + mon.ability = PokemonG3.abilityId and PokemonG3.abilityId(mon.speciesId or mon.species, mon.personality) or mon.ability + end mon.hp = math.max(0, math.min(mon.hp or mon.maxHp, mon.maxHp)) end return @@ -117,6 +122,168 @@ function MonOps.recalc(data, mon, gen) mon.hp = math.max(0, math.min(mon.hp or mon.stats.hp, mon.stats.hp)) end +function MonOps.generatePid(species, otId, otSecretId, reqs) + reqs = reqs or {} + local PokemonG3 = require("src.core.game3.pokemon") + local bit = require("bit") + + local spId = tonumber(species) or (PokemonG3.speciesFromName and PokemonG3.speciesFromName(tostring(species))) or 1 + otId = bit.band(tonumber(otId) or 0, 0xFFFF) + otSecretId = bit.band(tonumber(otSecretId) or 0, 0xFFFF) + local trainerXor = bit.bxor(otId, otSecretId) + + local meta = PokemonG3.speciesMeta and PokemonG3.speciesMeta(spId) + local ratio = (meta and meta.genderRatio) or 127 + + local targetNature = reqs.nature and tonumber(reqs.nature) + local targetAbility = reqs.ability and tonumber(reqs.ability) + local targetGender = reqs.gender + local targetShiny = reqs.shiny + + -- 1. Determine valid low-byte (b0) for gender & ability slot + local validB0 = {} + for b = 0, 255 do + local ok = true + if targetAbility ~= nil and (b % 2) ~= targetAbility then ok = false end + if ok and targetGender ~= nil and targetGender ~= "" and targetGender ~= "U" then + local g + if ratio == PokemonG3.GENDER_MALE then g = "M" + elseif ratio == PokemonG3.GENDER_FEMALE then g = "F" + elseif ratio == PokemonG3.GENDER_GENDERLESS then g = "U" + elseif ratio > b then g = "F" + else g = "M" end + if g ~= targetGender then ok = false end + end + if ok then validB0[#validB0 + 1] = b end + end + if #validB0 == 0 then + for b = 0, 255 do + if targetAbility == nil or (b % 2) == targetAbility then validB0[#validB0 + 1] = b end + end + end + + local sOffset = (targetShiny == true) and math.random(0, 7) or (targetShiny == false and math.random(8, 255) or math.random(0, 255)) + local targetXor = bit.bxor(trainerXor, sOffset) + + -- 2. Search across b0 and b1 for matching nature modulo 25 + for _, b0 in ipairs(validB0) do + for b1 = 0, 255 do + local pLow = b1 * 256 + b0 + local pHigh = bit.bxor(pLow, targetXor) + local p = pHigh * 65536 + pLow + if targetNature == nil or (p % 25) == targetNature then + return p + end + end + end + + -- Fallback satisfying nature and ability parity + local base = (targetNature or 0) + if targetAbility == 1 and (base % 2 == 0) then + base = base + 25 + elseif targetAbility == 0 and (base % 2 == 1) then + base = base + 25 + end + return base +end + +function MonOps.setHeldItem(data, mon, itemId, gen) + if type(mon) ~= "table" then return end + local isG3 = (gen == 3) or (mon.speciesId ~= nil) or (mon.personality ~= nil) + if isG3 then + local ItemsData = require("src.core.game3.items_data") + local numId = tonumber(itemId) or (itemId and ItemsData.toNumericId and ItemsData.toNumericId(itemId)) + mon.heldItem = numId or itemId + mon.item = mon.heldItem + return + end + mon.item = itemId +end + +function MonOps.setHappiness(data, mon, val, gen) + if type(mon) ~= "table" then return end + local v = math.max(0, math.min(255, math.floor(tonumber(val) or 0))) + mon.happiness = v + mon.friendship = v +end + +function MonOps.setNature(data, mon, natureId, gen) + if type(mon) ~= "table" then return end + natureId = math.max(0, math.min(24, math.floor(tonumber(natureId) or 0))) + mon.nature = natureId + local isG3 = (gen == 3) or (mon.speciesId ~= nil) or (mon.personality ~= nil) + if isG3 then + local currentAbility = (mon.personality and (mon.personality % 2)) or 0 + local isShiny = require("src.core.game3.summary_data").isShiny(mon) + mon.personality = MonOps.generatePid(mon.speciesId or mon.species, mon.otId or 0, mon.otSecretId or 0, { + nature = natureId, + ability = currentAbility, + gender = mon.gender, + shiny = isShiny, + }) + MonOps.recalc(data, mon, gen) + end +end + +function MonOps.setAbility(data, mon, abilitySlot, gen) + if type(mon) ~= "table" then return end + abilitySlot = (tonumber(abilitySlot) or 0) % 2 + local isG3 = (gen == 3) or (mon.speciesId ~= nil) or (mon.personality ~= nil) + if isG3 then + local currentNature = (mon.personality and (mon.personality % 25)) or mon.nature or 0 + local isShiny = require("src.core.game3.summary_data").isShiny(mon) + mon.personality = MonOps.generatePid(mon.speciesId or mon.species, mon.otId or 0, mon.otSecretId or 0, { + nature = currentNature, + ability = abilitySlot, + gender = mon.gender, + shiny = isShiny, + }) + local PokemonG3 = require("src.core.game3.pokemon") + mon.ability = PokemonG3.abilityId(mon.speciesId or mon.species, mon.personality) + MonOps.recalc(data, mon, gen) + end +end + +function MonOps.setGender(data, mon, gender, gen) + if type(mon) ~= "table" then return end + local isG3 = (gen == 3) or (mon.speciesId ~= nil) or (mon.personality ~= nil) + if isG3 then + local currentNature = (mon.personality and (mon.personality % 25)) or mon.nature or 0 + local currentAbility = (mon.personality and (mon.personality % 2)) or 0 + local isShiny = require("src.core.game3.summary_data").isShiny(mon) + mon.personality = MonOps.generatePid(mon.speciesId or mon.species, mon.otId or 0, mon.otSecretId or 0, { + nature = currentNature, + ability = currentAbility, + gender = gender, + shiny = isShiny, + }) + local PokemonG3 = require("src.core.game3.pokemon") + mon.gender = PokemonG3.gender(mon.speciesId or mon.species, mon.personality) + MonOps.recalc(data, mon, gen) + else + mon.gender = gender + end +end + +function MonOps.setShiny(data, mon, shiny, gen) + if type(mon) ~= "table" then return end + local isG3 = (gen == 3) or (mon.speciesId ~= nil) or (mon.personality ~= nil) + if isG3 then + local currentNature = (mon.personality and (mon.personality % 25)) or mon.nature or 0 + local currentAbility = (mon.personality and (mon.personality % 2)) or 0 + mon.personality = MonOps.generatePid(mon.speciesId or mon.species, mon.otId or 0, mon.otSecretId or 0, { + nature = currentNature, + ability = currentAbility, + gender = mon.gender, + shiny = shiny, + }) + mon.isShiny = shiny + MonOps.recalc(data, mon, gen) + else + mon.shiny = shiny + end +end + function MonOps.setLevel(data, mon, level, gen) if type(mon) ~= "table" then return end level = math.max(1, math.min(100, math.floor(level))) diff --git a/tools/save-editor/Ops.lua b/tools/save-editor/Ops.lua index 513228f5..ef132d1d 100644 --- a/tools/save-editor/Ops.lua +++ b/tools/save-editor/Ops.lua @@ -557,6 +557,19 @@ function Ops.moveSearch(S, query) return out end +local function canonicalMoveId(data, id) + if not id then return nil end + local num = tonumber(id) + if num then return num end + local mdef = data and data.moves and data.moves[id] + if mdef and mdef.moveId then return tonumber(mdef.moveId) end + local okP, PokemonG3 = pcall(require, "src.core.game3.pokemon") + if okP and PokemonG3 and PokemonG3.moveFromName then + return PokemonG3.moveFromName(tostring(id)) + end + return nil +end + -- One funnel for assigning a move (picker commit and cycleMove). Refuses -- unknown / scalar ids before MonOps asserts, and speaks in the status bar. function Ops.setMove(S, mon, slot, id) @@ -564,7 +577,10 @@ function Ops.setMove(S, mon, slot, id) slot = math.floor(tonumber(slot) or 0) if slot < 1 or slot > 4 then return false end local current = moveId(mon, slot) - if id == current then + local curCanon = canonicalMoveId(S.data, current) + local candCanon = canonicalMoveId(S.data, id) + if (curCanon and candCanon and curCanon == candCanon) + or (current and tostring(current):upper() == tostring(id):upper()) then return Ops.say(S, ("Move %d is already %s"):format(slot, tostring(id))) end if not Ops.moveUsable(S, id) then @@ -1346,6 +1362,94 @@ function Ops.setToggle(S, mapId, name, on) return Ops.mark(S, ("%s / %s = %s"):format(mapId, name, tostring(on and true or false))) end +function Ops.setVar(S, nameOrId, val) + Gen.setVar(S.save, nameOrId, val) + local displayVal = Gen.getVar(S.save, nameOrId) + return Ops.mark(S, ("%s = %d"):format(tostring(nameOrId), displayVal)) +end + +function Ops.clearTrainers(S) + local g = Gen.ofState(S) + if g == 3 then + local Gen3Flags = require("Gen3Flags") + local ids = Gen3Flags.allTrainerFlagIds() + local count = 0 + for _, id in ipairs(ids) do + if Gen.getFlag(S.save, id) then count = count + 1 end + end + if count == 0 then return Ops.say(S, "Trainers are already empty") end + if not Ops.arm(S, "clear-trainers", + ("Clear all %d defeated trainers? Click again to confirm"):format(count)) then + return false + end + for _, id in ipairs(ids) do + Gen.setFlag(S.save, id, false) + end + if type(S.save.vsSeeker) == "table" then + S.save.vsSeeker.rematches = {} + end + if S.save.trainerRematches then S.save.trainerRematches = {} end + if S.save.vars then S.save.vars[0x40AA] = 0 end + S.save.defeatedTrainers = {} + return Ops.mark(S, ("Cleared %d defeated trainers"):format(count)) + end + return Ops.clearTable(S, "defeatedTrainers", "trainers") +end + +function Ops.clearItems(S) + local g = Gen.ofState(S) + if g == 3 then + local Gen3Flags = require("Gen3Flags") + local ids = Gen3Flags.allItemFlagIds() + local count = 0 + for _, id in ipairs(ids) do + if Gen.getFlag(S.save, id) then count = count + 1 end + end + if count == 0 then return Ops.say(S, "Items taken are already empty") end + if not Ops.arm(S, "clear-items", + ("Clear all %d items taken? Click again to confirm"):format(count)) then + return false + end + for _, id in ipairs(ids) do + Gen.setFlag(S.save, id, false) + end + S.save.itemsTaken = {} + return Ops.mark(S, ("Cleared %d items taken"):format(count)) + end + return Ops.clearTable(S, "itemsTaken", "items taken") +end + +function Ops.clearToggles(S) + local g = Gen.ofState(S) + if g == 3 then + local Gen3Flags = require("Gen3Flags") + local ids = Gen3Flags.allToggleFlagIds() + local count = 0 + for _, id in ipairs(ids) do + if Gen.getFlag(S.save, id) then count = count + 1 end + end + if count == 0 then return Ops.say(S, "Object toggles are already empty") end + if not Ops.arm(S, "clear-toggles", + ("Clear all %d object toggles? Click again to confirm"):format(count)) then + return false + end + for _, id in ipairs(ids) do + Gen.setFlag(S.save, id, false) + end + S.save.objectToggles = {} + return Ops.mark(S, ("Cleared %d object toggles"):format(count)) + end + local count = 0 + for _ in pairs(S.save.objectToggles or {}) do count = count + 1 end + if count == 0 then return Ops.say(S, "Object toggles are already empty") end + if not Ops.arm(S, "clear-toggles", + ("Clear all %d map object toggles? Click again to confirm"):format(count)) then + return false + end + S.save.objectToggles = {} + return Ops.mark(S, ("Cleared %d map object toggles"):format(count)) +end + function Ops.clearTable(S, tableKey, label) local count = 0 for _ in pairs(S.save[tableKey] or {}) do count = count + 1 end @@ -1361,36 +1465,86 @@ end -- -------------------------------------------------------------------- dex function Ops.dex(S) local key = Gen.dexOwnedKey(S.save) - S.save.pokedex = S.save.pokedex or { seen = {}, [key] = {} } - S.save.pokedex.seen = S.save.pokedex.seen or {} - S.save.pokedex[key] = S.save.pokedex[key] or {} - return S.save.pokedex + local dex = S.save.dex or S.save.pokedex + if not dex then + dex = { seen = {}, [key] = {} } + if Gen.ofState(S) == 3 then + S.save.dex = dex + else + S.save.pokedex = dex + end + end + dex.seen = dex.seen or {} + dex[key] = dex[key] or (Gen.ofState(S) == 3 and ((key == "owned" and dex.caught) or (key == "caught" and dex.owned))) or {} + if Gen.ofState(S) == 3 then + if key == "owned" and dex.caught == nil then dex.caught = dex[key] end + if key == "caught" and dex.owned == nil then dex.owned = dex[key] end + end + return dex end function Ops.dexCounts(S) local dex = Ops.dex(S) local key = Gen.dexOwnedKey(S.save) + local seenSet, ownedSet = {}, {} + for k, v in pairs(dex.seen or {}) do + if v then + local sp = tonumber(k) or (S.data and S.data.pokemon and S.data.pokemon[k] and S.data.pokemon[k].speciesId) or k + seenSet[sp] = true + end + end + local ownedTable = dex[key] or {} + for k, v in pairs(ownedTable) do + if v then + local sp = tonumber(k) or (S.data and S.data.pokemon and S.data.pokemon[k] and S.data.pokemon[k].speciesId) or k + ownedSet[sp] = true + end + end local seen, owned = 0, 0 - for _ in pairs(dex.seen) do seen = seen + 1 end - for _ in pairs(dex[key] or {}) do owned = owned + 1 end + for _ in pairs(seenSet) do seen = seen + 1 end + for _ in pairs(ownedSet) do owned = owned + 1 end return seen, owned, #S.cat.species end +local function dexSpeciesKeys(S, species) + local keys = { species } + if Gen.ofState(S) == 3 then + local def = S.data and S.data.pokemon and S.data.pokemon[species] + local spId = (def and (def.speciesId or def.dex)) or tonumber(species) + if spId and spId ~= species then keys[#keys + 1] = spId end + end + return keys +end + -- Owning implies having seen; un-seeing clears owned. Both directions are -- the game's own rule, enforced here so a hand-edited dex stays legal. function Ops.dexSeen(S, species, on) local dex = Ops.dex(S) local key = Gen.dexOwnedKey(S.save) - dex.seen[species] = on and true or nil - if not on then dex[key][species] = nil end + for _, k in ipairs(dexSpeciesKeys(S, species)) do + dex.seen[k] = on and true or nil + if not on then + dex[key][k] = nil + if Gen.ofState(S) == 3 then + if dex.caught then dex.caught[k] = nil end + if dex.owned then dex.owned[k] = nil end + end + end + end return Ops.mark(S, ("%s %s"):format(species, on and "marked seen" or "cleared")) end function Ops.dexOwned(S, species, on) local dex = Ops.dex(S) local key = Gen.dexOwnedKey(S.save) - dex[key][species] = on and true or nil - if on then dex.seen[species] = true end + for _, k in ipairs(dexSpeciesKeys(S, species)) do + dex[key][k] = on and true or nil + if Gen.ofState(S) == 3 then + if dex.caught then dex.caught[k] = on and true or nil end + if dex.owned then dex.owned[k] = on and true or nil end + end + if on then dex.seen[k] = true end + end return Ops.mark(S, ("%s %s"):format(species, on and "marked owned" or "un-owned")) end @@ -1399,13 +1553,25 @@ function Ops.dexStamp(S) local key = Gen.dexOwnedKey(S.save) local n = 0 local function stamp(mon) - if not dex[key][mon.species] then n = n + 1 end - dex.seen[mon.species] = true - dex[key][mon.species] = true + if not mon then return end + local sp = mon.species or mon.speciesId + local keys = dexSpeciesKeys(S, sp) + local isNew = not dex[key][sp] and not (keys[2] and dex[key][keys[2]]) + if isNew then n = n + 1 end + for _, k in ipairs(keys) do + dex.seen[k] = true + dex[key][k] = true + if Gen.ofState(S) == 3 then + if dex.caught then dex.caught[k] = true end + if dex.owned then dex.owned[k] = true end + end + end end - for _, m in ipairs(S.save.party) do stamp(m) end + for _, m in ipairs(S.save.party or {}) do stamp(m) end for _, box in ipairs(S.save.boxes or {}) do - for _, m in ipairs(box) do stamp(m) end + for _, m in pairs(type(box) == "table" and box or {}) do + if type(m) == "table" then stamp(m) end + end end if n == 0 then return Ops.say(S, "Party and boxes are already all in the dex") end return Ops.mark(S, ("Owned %d more species from party + boxes"):format(n)) @@ -1413,7 +1579,11 @@ end function Ops.dexSeeAll(S) local dex = Ops.dex(S) - for _, species in ipairs(S.cat.species) do dex.seen[species] = true end + for _, species in ipairs(S.cat.species) do + for _, k in ipairs(dexSpeciesKeys(S, species)) do + dex.seen[k] = true + end + end return Ops.mark(S, ("Marked all %d species seen"):format(#S.cat.species)) end @@ -1421,8 +1591,14 @@ function Ops.dexOwnAll(S) local dex = Ops.dex(S) local key = Gen.dexOwnedKey(S.save) for _, species in ipairs(S.cat.species) do - dex.seen[species] = true - dex[key][species] = true + for _, k in ipairs(dexSpeciesKeys(S, species)) do + dex.seen[k] = true + dex[key][k] = true + if Gen.ofState(S) == 3 then + if dex.caught then dex.caught[k] = true end + if dex.owned then dex.owned[k] = true end + end + end end return Ops.mark(S, ("Marked all %d species owned"):format(#S.cat.species)) end @@ -1432,10 +1608,34 @@ function Ops.dexClear(S) return false end local key = Gen.dexOwnedKey(S.save) - S.save.pokedex = { seen = {}, [key] = {} } + local dex = { seen = {}, [key] = {} } + if Gen.ofState(S) == 3 then + dex.caught = dex[key] + dex.owned = dex[key] + dex.national = S.save.dex and S.save.dex.national or false + S.save.dex = dex + else + S.save.pokedex = dex + end return Ops.mark(S, "Pokedex wiped") end +function Ops.toggleNationalDex(S) + local dex = Ops.dex(S) + local on = not (dex.national == true or (S.save.dex and S.save.dex.national == true)) + dex.national = on + if S.save.dex then S.save.dex.national = on end + local okF, Flags = pcall(require, "src.core.game3.scripting.flags") + if okF and Flags then + Flags.setFlag(S.save, nil, "FLAG_SYS_NATIONAL_DEX", on) + else + S.save.flags = S.save.flags or {} + S.save.flags[0x829] = on and true or nil + S.save.flags["FLAG_SYS_NATIONAL_DEX"] = on and true or nil + end + return Ops.mark(S, ("National Dex %s"):format(on and "enabled" or "disabled")) +end + -- ------------------------------------------------------------------ dex sort -- The DEX grid's row order. Sorting is view-only: it never touches the save, -- so the list itself is computed here (pure, testable) and the switch is @@ -1556,17 +1756,19 @@ end function Ops.setHeldItem(S, mon, id) if not mon then return false end if id == "" or id == nil then - if not mon.item then return Ops.say(S, "No held item to clear") end - local was = mon.item + if not (mon.item or mon.heldItem) then return Ops.say(S, "No held item to clear") end + local was = mon.item or mon.heldItem mon.item = nil + mon.heldItem = nil syncPartyMailHeldItem(S, mon, was, nil) - return Ops.mark(S, ("Cleared held item (%s)"):format(was)) + return Ops.mark(S, ("Cleared held item (%s)"):format(tostring(was))) end - if not S.data.items[id] then + local def = S.data.items[id] + if not def then return Ops.say(S, ("%s is not an item"):format(tostring(id))) end - local was = mon.item - mon.item = id + local was = mon.item or mon.heldItem + MonOps.setHeldItem(S.data, mon, def.itemId or def.id or id, Gen.ofState(S)) syncPartyMailHeldItem(S, mon, was, id) return Ops.mark(S, ("%s now holds %s"):format(mon.species, id)) end @@ -1574,13 +1776,41 @@ end function Ops.setHappiness(S, mon, value) if not mon then return false end local want = clamp(math.floor(value), 0, 255) - if want == (mon.happiness or 0) then + if want == (mon.happiness or mon.friendship or 0) then return Ops.say(S, ("Happiness is already %d"):format(want)) end - mon.happiness = want + MonOps.setHappiness(S.data, mon, want, Gen.ofState(S)) return Ops.mark(S, ("%s happiness %d"):format(mon.species, want)) end +function Ops.setNature(S, mon, natureId) + if not mon then return false end + natureId = clamp(math.floor(tonumber(natureId) or 0), 0, 24) + MonOps.setNature(S.data, mon, natureId, Gen.ofState(S)) + local SummaryData = require("src.core.game3.summary_data") + local name = SummaryData.NATURES[natureId] or tostring(natureId) + return Ops.mark(S, ("%s nature set to %s"):format(mon.species, name)) +end + +function Ops.setAbility(S, mon, abilitySlot) + if not mon then return false end + abilitySlot = (tonumber(abilitySlot) or 0) % 2 + MonOps.setAbility(S.data, mon, abilitySlot, Gen.ofState(S)) + return Ops.mark(S, ("%s ability set to slot %d"):format(mon.species, abilitySlot + 1)) +end + +function Ops.setMonGender(S, mon, gender) + if not mon then return false end + MonOps.setGender(S.data, mon, gender, Gen.ofState(S)) + return Ops.mark(S, ("%s gender set to %s"):format(mon.species, tostring(gender))) +end + +function Ops.setShiny(S, mon, shiny) + if not mon then return false end + MonOps.setShiny(S.data, mon, shiny, Gen.ofState(S)) + return Ops.mark(S, ("%s %s"):format(mon.species, shiny and "is now shiny!" or "is no longer shiny")) +end + function Ops.setPokerus(S, mon, value) if not mon then return false end local want = clamp(math.floor(value), 0, 255) diff --git a/tools/save-editor/panels/Dex.lua b/tools/save-editor/panels/Dex.lua index f22c30ee..de9e5bed 100644 --- a/tools/save-editor/panels/Dex.lua +++ b/tools/save-editor/panels/Dex.lua @@ -60,6 +60,14 @@ function M.draw(S, Kit, x, y, w, h) { label = "A-Z", kind = (S.dexSort == "name") and "accent" or "ghost", fn = function(s) Ops.dexSort(s, "name") end }, } + if require("Gen").of(S.save) == 3 then + local natOn = (S.save.dex and S.save.dex.national == true) or (dex and dex.national == true) + table.insert(buttons, 1, { + label = "National Dex: " .. (natOn and "ON" or "OFF"), + kind = natOn and "accent" or "ghost", + fn = Ops.toggleNationalDex, + }) + end local clusterW = -10 * s for _, b in ipairs(buttons) do clusterW = clusterW + 10 * s + Kit.textWidth("small", b.label) + 32 * s @@ -144,9 +152,12 @@ function M.draw(S, Kit, x, y, w, h) local ri = math.floor((i - 1) / cols) local rx = cx + ci * (colW + colGap) local ry = gridTop + ri * (rowH + rowGap) - local isSeen = dex.seen[id] == true + local def = S.data.pokemon[id] + local spId = def and (def.speciesId or def.dex) + local isSeen = dex.seen[id] == true or (spId and dex.seen[spId] == true) local ownedKey = require("Gen").dexOwnedKey(S.save) - local isOwned = dex[ownedKey] and dex[ownedKey][id] == true + local isOwned = (dex[ownedKey] and (dex[ownedKey][id] == true or (spId and dex[ownedKey][spId] == true))) + or (dex.caught and (dex.caught[id] == true or (spId and dex.caught[spId] == true))) Theme.row(rx, ry, colW, rowH, 9 * s, 0.6) local def = S.data.pokemon[id] diff --git a/tools/save-editor/panels/Events.lua b/tools/save-editor/panels/Events.lua index a0f26010..d9f23ce0 100644 --- a/tools/save-editor/panels/Events.lua +++ b/tools/save-editor/panels/Events.lua @@ -1,33 +1,47 @@ --- Events panel: flags, defeated trainers, taken items, and per-map object --- visibility toggles. All four sections read/write through Ops so a flip is --- always dirty + narrated. --- --- Sub-tabs are pills; the filter is a real Kit.textfield (the old panel --- edge-detected love.keyboard state every frame because Kit had no input --- widget, which swallowed every keystroke the rest of the app wanted); and --- the rows are a two-column grid so twenty fit per page instead of ten. +-- Events panel: flags, defeated trainers, taken items, per-map object +-- visibility toggles, system/badges, and script variables. +-- All sections read/write through Ops so a flip is always dirty + narrated. local Theme = require("Theme") local Ops = require("Ops") local Gen = require("Gen") +local Catalog = require("Catalog") local PAL = Theme.PAL local M = {} -local SUB_TABS = { +local SUB_TABS_GEN1 = { { id = "flags", label = "Flags" }, { id = "trainers", label = "Trainers" }, { id = "items", label = "Items taken" }, { id = "toggles", label = "Object toggles" }, } -local HINTS = { +local SUB_TABS_GEN3 = { + { id = "story", label = "Story flags" }, + { id = "trainers", label = "Trainers" }, + { id = "items", label = "Items taken" }, + { id = "toggles", label = "Object toggles" }, + { id = "system", label = "System & Badges" }, + { id = "vars", label = "Variables" }, +} + +local HINTS_GEN1 = { flags = "Story flags scraped from data/scripts and the trainer headers, plus any MOD_ flags a loaded mod defines.", trainers = "Keys look like MAP_obj_N (save.defeatedTrainers): checked means that trainer stays beaten.", items = "Keys look like MAP_obj_N (save.itemsTaken): checked means that ground item is gone.", toggles = "Per-map object visibility overrides (save.objectToggles), grouped by map.", } +local HINTS_GEN3 = { + story = "Story and narrative event flags (NPC gifts, quest progress, boss clears, and mod flags).", + trainers = "Trainer defeat flags (0x500 + trainerId): checked means that trainer stays beaten.", + items = "Overworld item balls (0x154-0x1FE) and hidden items (0x3E8-0x4A6): checked means item taken.", + toggles = "NPC, sprite, and obstacle hide flags (FLAG_HIDE_...): checked means the object is hidden.", + system = "System flags, Gym Badges (0x820-0x827), Running Shoes, National Dex, and Town Map fly points.", + vars = "16-bit script and story progression variables (save.vars, 0x4000-0x40FF).", +} + local function sortedKeys(t) local keys = {} for k in pairs(t) do keys[#keys + 1] = k end @@ -37,16 +51,89 @@ end local function contains(haystack, needle) if needle == "" then return true end - return haystack:lower():find(needle:lower(), 1, true) ~= nil + return tostring(haystack):lower():find(needle:lower(), 1, true) ~= nil end --- Each sub-tab reduces to the same shape: a list of rows, where a row knows --- how to read its checked state, render a label, and write a flip back. -local function buildRows(S) +local function buildRowsGen3(S, filter) local tab = S.eventsTab - local filter = S.eventFilter or "" + if tab == "flags" then tab = "story" end + local cats = S.game3Events or Catalog.game3Categories(S.modRoots) + S.game3Events = cats local rows = {} - if tab == "flags" then + + if tab == "story" then + for _, entry in ipairs(cats.story or {}) do + local label = entry.label or entry.name + if contains(label, filter) or contains(entry.name, filter) then + rows[#rows + 1] = { + label = label, + checked = Gen.getFlag(S.save, entry.name or entry.id), + set = function(on) Ops.setFlag(S, entry.name or entry.id, on) end, + } + end + end + elseif tab == "trainers" then + for _, entry in ipairs(cats.trainers or {}) do + if contains(entry.label, filter) or contains(entry.name, filter) or contains(string.format("0x%X", entry.flagId), filter) then + rows[#rows + 1] = { + label = entry.label, + checked = Gen.getFlag(S.save, entry.flagId), + set = function(on) Ops.setFlag(S, entry.flagId, on) end, + } + end + end + elseif tab == "items" then + for _, entry in ipairs(cats.items or {}) do + if contains(entry.label, filter) or contains(entry.name, filter) or contains(string.format("0x%X", entry.id), filter) then + rows[#rows + 1] = { + label = entry.label, + checked = Gen.getFlag(S.save, entry.id or entry.name), + set = function(on) Ops.setFlag(S, entry.id or entry.name, on) end, + } + end + end + elseif tab == "toggles" then + for _, entry in ipairs(cats.toggles or {}) do + if contains(entry.label, filter) or contains(entry.name, filter) or contains(string.format("0x%X", entry.id), filter) then + rows[#rows + 1] = { + label = entry.label, + checked = Gen.getFlag(S.save, entry.id or entry.name), + set = function(on) Ops.setFlag(S, entry.id or entry.name, on) end, + } + end + end + elseif tab == "system" then + for _, entry in ipairs(cats.system or {}) do + if contains(entry.label, filter) or contains(entry.name, filter) or contains(string.format("0x%X", entry.id), filter) then + rows[#rows + 1] = { + label = entry.label, + checked = Gen.getFlag(S.save, entry.id or entry.name), + set = function(on) Ops.setFlag(S, entry.id or entry.name, on) end, + } + end + end + elseif tab == "vars" then + for _, entry in ipairs(cats.vars or {}) do + if contains(entry.label, filter) or contains(entry.name, filter) or contains(string.format("0x%X", entry.id), filter) then + local val = Gen.getVar(S.save, entry.id) + rows[#rows + 1] = { + isVar = true, + label = entry.label, + varId = entry.id, + val = val, + set = function(v) Ops.setVar(S, entry.id, v) end, + } + end + end + end + + return rows +end + +local function buildRowsGen1(S, filter) + local tab = S.eventsTab + local rows = {} + if tab == "flags" or tab == "story" then for _, name in ipairs(S.events or {}) do if contains(name, filter) then rows[#rows + 1] = { @@ -92,10 +179,26 @@ local function buildRows(S) return rows end +local function buildRows(S) + local filter = S.eventFilter or "" + if Gen.of(S.save) == 3 then + return buildRowsGen3(S, filter) + end + return buildRowsGen1(S, filter) +end + function M.draw(S, Kit, x, y, w, h) local s = Kit.scale local pad = 20 * s - S.eventsTab = S.eventsTab or "flags" + local gen = Gen.of(S.save) + + if gen == 3 then + if not S.eventsTab or S.eventsTab == "flags" then S.eventsTab = "story" end + else + if not S.eventsTab or S.eventsTab == "story" or S.eventsTab == "system" or S.eventsTab == "vars" then + S.eventsTab = "flags" + end + end S.eventFilter = S.eventFilter or "" Kit.card(x, y, w, h) @@ -103,16 +206,18 @@ function M.draw(S, Kit, x, y, w, h) local inner = w - 2 * pad -- ------------------------------------------------------------ sub-tabs - -- The pills flow left to right and WRAP when the card is too narrow to - -- hold all four on one line (#715): a fixed row used to run the last pill - -- past the card edge. local pillH = 32 * s local px, py = cx, y + pad - local pills = SUB_TABS - if Gen.of(S.save) == 2 then - pills = { SUB_TABS[1] } + local pills = SUB_TABS_GEN1 + local hints = HINTS_GEN1 + if gen == 3 then + pills = SUB_TABS_GEN3 + hints = HINTS_GEN3 + elseif gen == 2 then + pills = { SUB_TABS_GEN1[1] } if S.eventsTab ~= "flags" then S.eventsTab = "flags" end end + for _, t in ipairs(pills) do local pw = Kit.textWidth("small", t.label) + 32 * s if px > cx and px + pw > cx + inner then @@ -131,14 +236,13 @@ function M.draw(S, Kit, x, y, w, h) S.eventsTab = t.id S.eventsOffset = 0 Ops.disarm(S) - Ops.say(S, HINTS[t.id]) + Ops.say(S, hints[t.id] or "") end px = px + pw + 10 * s end -- The filter shares the last pill row when there is room for at least a - -- usable field beside the pills; on a narrow window it wraps onto its own - -- row instead of painting over the last pill (#715). + -- usable field beside the pills; on a narrow window it wraps onto its own row local clearW = 74 * s local filterY = py local availF = cx + inner - clearW - 10 * s - px - 10 * s @@ -159,7 +263,7 @@ function M.draw(S, Kit, x, y, w, h) end local hintY = filterY + pillH + 10 * s - Kit.text("small", Kit.ellipsize("small", HINTS[S.eventsTab] or "", inner), + Kit.text("small", Kit.ellipsize("small", hints[S.eventsTab] or "", inner), cx, hintY, PAL.caption) -- ---------------------------------------------------------- row grid @@ -170,8 +274,6 @@ function M.draw(S, Kit, x, y, w, h) local rowH = 34 * s local rowGap = 8 * s local colGap = 20 * s - -- two columns need ~460 logical px before the checkbox labels read; a - -- phone gets one full-width column instead of two crushed ones (#715) local cols = (inner >= 460 * s) and 2 or 1 local colW = (inner - colGap * (cols - 1)) / cols local gridH = pagerY - 12 * s - gridTop @@ -179,7 +281,6 @@ function M.draw(S, Kit, x, y, w, h) local perPage = perCol * cols S.eventsOffset = Ops.clamp(S.eventsOffset or 0, 0, math.max(0, #rows - perPage)) - -- wheel / touch drag move whole grid rows, same contract as the pager (#715) S.eventsOffset = Kit.scroll(cx, gridTop, inner, gridH, S.eventsOffset, #rows, perPage, cols) @@ -188,8 +289,7 @@ function M.draw(S, Kit, x, y, w, h) S.eventFilter ~= "" and "No key matches that filter." or "Nothing recorded here yet.") end - -- clip the grid body: on a window too short for even one row the partial - -- row clips (and its hit test is fenced) instead of covering the pager (#715) + Kit.pushClip(cx, gridTop, inner, gridH) for i = 1, math.min(perPage, #rows - S.eventsOffset) do local row = rows[S.eventsOffset + i] @@ -198,10 +298,26 @@ function M.draw(S, Kit, x, y, w, h) local rx = cx + ci * (colW + colGap) local ry = gridTop + ri * (rowH + rowGap) if row.header then - -- a map heading inside the toggles list: not a checkbox, so it must - -- not look clickable Kit.text("mono", Kit.ellipsize("mono", row.label, colW), rx + 4 * s, ry + (rowH - Kit.textHeight("mono")) / 2, PAL.caption) + elseif row.isVar then + Kit.row(rx, ry, colW, rowH, false, nil, 9 * s) + local valW = 100 * s + local lblW = colW - valW - 16 * s + Kit.text("mono", Kit.ellipsize("mono", row.label, lblW), rx + 12 * s, + ry + (rowH - Kit.textHeight("mono")) / 2, PAL.text) + local btnW = 22 * s + local btnH = 22 * s + local by = ry + (rowH - btnH) / 2 + local bx = rx + colW - valW - 8 * s + if Kit.stepper(bx, by, btnW, btnH, "-", { font = "small", radius = 4 * s, enabled = row.val > 0 }) then + row.set(math.max(0, row.val - 1)) + end + Kit.textCenter("mono", tostring(row.val), bx + btnW, + ry + (rowH - Kit.textHeight("mono")) / 2, valW - 2 * btnW, PAL.heading) + if Kit.stepper(bx + valW - btnW, by, btnW, btnH, "+", { font = "small", radius = 4 * s, enabled = row.val < 65535 }) then + row.set(math.min(65535, row.val + 1)) + end else local newChecked, changed = Kit.checkbox(rx, ry, colW, rowH, row.checked, row.label) @@ -212,21 +328,28 @@ function M.draw(S, Kit, x, y, w, h) Kit.scrollbar(cx, gridTop, inner, gridH, S.eventsOffset, #rows, perPage) - -- "Clear all" only makes sense for the two key tables the editor owns - -- wholesale; flags and object toggles are cleared one row at a time. Its - -- width is reserved BEFORE the pager draws, so the pager's counter yields - -- to the button instead of running underneath it (#715). - local clearKey = (S.eventsTab == "trainers" and "defeatedTrainers") - or (S.eventsTab == "items" and "itemsTaken") or nil + -- "Clear all" button + local clearKey = (S.eventsTab == "trainers" and "trainers") + or (S.eventsTab == "items" and "items") + or (gen == 3 and S.eventsTab == "toggles" and "toggles") + or nil local clearBw = 0 if clearKey then local label = (S.eventsTab == "trainers") and "Clear all trainers" - or "Clear all items taken" + or (S.eventsTab == "items") and "Clear all items taken" + or "Clear all toggles" clearBw = Kit.textWidth("small", label) + 32 * s + local armKey = "clear-" .. clearKey if Kit.button(cx + inner - clearBw, pagerY, clearBw, pagerH, - Ops.armLabel(S, "clear-" .. clearKey, label), + Ops.armLabel(S, armKey, label), { kind = "danger", font = "small", radius = 8 * s }) then - Ops.clearTable(S, clearKey, label:gsub("^Clear all ", "")) + if clearKey == "trainers" then + Ops.clearTrainers(S) + elseif clearKey == "items" then + Ops.clearItems(S) + elseif clearKey == "toggles" then + Ops.clearToggles(S) + end end clearBw = clearBw + 10 * s end diff --git a/tools/save-editor/panels/MonEditor.lua b/tools/save-editor/panels/MonEditor.lua index ea887f86..040ceef5 100644 --- a/tools/save-editor/panels/MonEditor.lua +++ b/tools/save-editor/panels/MonEditor.lua @@ -317,7 +317,11 @@ function MonEditor.draw(S, Kit, x, y, w, h) -- the nickname section: a caption line (with the Clear button on it) plus -- the field + Set row local extraH = 0 - if Gen.ofState(S) == 2 then extraH = 88 * s end + if Gen.ofState(S) == 3 then + extraH = 154 * s + elseif Gen.ofState(S) == 2 then + extraH = 88 * s + end if Gen.hasCaughtData(S.save, S.version) then extraH = extraH + 102 * s end local nickFieldH = 30 * s local contentH = pad + headerH + 18 * s @@ -428,7 +432,71 @@ function MonEditor.draw(S, Kit, x, y, w, h) -- --------------------------------------------------- DVs | moves split local colY = statsY + cellH + 18 * s - if Gen.ofState(S) == 2 then + if Gen.ofState(S) == 3 then + local extraY = colY + Kit.caption(cx, extraY, "FIRE RED CHARACTERISTICS . PID " .. string.format("0x%08X", mon.personality or 0)) + extraY = extraY + capH + 8 * s + local row = 28 * s + + -- Held item row + local heldId = mon.heldItem or mon.item + local heldName = heldId and (S.data and S.data.items and S.data.items[heldId] and (S.data.items[heldId].name or S.data.items[heldId].id)) or tostring(heldId or "none") + Kit.text("tiny", "HELD " .. tostring(heldName), cx, extraY, PAL.text) + if heldId then + if Kit.button(cx + inner - 70 * s, extraY, 70 * s, row, "Clear item", + { kind = "danger", font = "tiny", radius = 6 * s }) then + Ops.setHeldItem(S, mon, nil) + end + end + extraY = extraY + row + 6 * s + + -- Nature and Friendship + local SummaryData = require("src.core.game3.summary_data") + local natureIdx = mon.nature or ((mon.personality or 0) % 25) + local natureName = SummaryData.NATURES[natureIdx] or "HARDY" + Kit.text("tiny", "NATURE " .. natureName, cx, extraY, PAL.text) + if Kit.stepper(cx + 140 * s, extraY, 28 * s, row, "<", { font = "small" }) then + Ops.setNature(S, mon, (natureIdx - 1 + 25) % 25) + end + if Kit.stepper(cx + 174 * s, extraY, 28 * s, row, ">", { font = "small" }) then + Ops.setNature(S, mon, (natureIdx + 1) % 25) + end + + local friendVal = mon.friendship or mon.happiness or 70 + Kit.text("tiny", ("FRIENDSHIP %d"):format(friendVal), cx + 220 * s, extraY, PAL.text) + if Kit.stepper(cx + 340 * s, extraY, 28 * s, row, "-", { font = "small" }) then + Ops.setHappiness(S, mon, friendVal - 10) + end + if Kit.stepper(cx + 374 * s, extraY, 28 * s, row, "+", { font = "small" }) then + Ops.setHappiness(S, mon, friendVal + 10) + end + extraY = extraY + row + 6 * s + + -- Ability, Gender, Shiny + local PokemonG3 = require("src.core.game3.pokemon") + local abilitySlot = ((mon.personality or 0) % 2) + local abilityId = mon.ability or PokemonG3.abilityId(mon.speciesId or mon.species, mon.personality or 0) + local abName = PokemonG3.abilityName and PokemonG3.abilityName(abilityId) or ("Ability " .. (abilitySlot + 1)) + if Kit.button(cx, extraY, 150 * s, row, abName .. " (Slot " .. (abilitySlot + 1) .. ")", + { kind = "accent", font = "micro", radius = 6 * s }) then + Ops.setAbility(S, mon, (abilitySlot + 1) % 2) + end + + local curGender = mon.gender or PokemonG3.gender(mon.speciesId or mon.species, mon.personality or 0) + local genderLabel = curGender == "M" and "♂ MALE" or (curGender == "F" and "♀ FEMALE" or "— GENDERLESS") + if Kit.button(cx + 160 * s, extraY, 110 * s, row, genderLabel, + { kind = "ghost", font = "micro", radius = 6 * s }) then + local nextG = curGender == "M" and "F" or (curGender == "F" and "M" or "U") + Ops.setMonGender(S, mon, nextG) + end + + local isShiny = SummaryData.isShiny(mon) + if Kit.chip(cx + 280 * s, extraY, 80 * s, row, "★ SHINY", isShiny, PAL.yellow, PAL.steel) then + Ops.setShiny(S, mon, not isShiny) + end + extraY = extraY + row + 12 * s + colY = extraY + elseif Gen.ofState(S) == 2 then local extraY = colY Kit.caption(cx, extraY, Gen.editionLabel(S.save, S.version)) extraY = extraY + capH + 8 * s diff --git a/tools/save-editor/panels/MovePicker.lua b/tools/save-editor/panels/MovePicker.lua index f229dc54..dc785296 100644 --- a/tools/save-editor/panels/MovePicker.lua +++ b/tools/save-editor/panels/MovePicker.lua @@ -94,8 +94,8 @@ function Picker.draw(S, Kit, width, height) p.offset = Kit.scroll(cx, cy, inner, listH, p.offset, #hits, perPage) local mon = S.editingMon - local currentId = mon and mon.moves and mon.moves[p.slot] - and mon.moves[p.slot].id + local mv = mon and mon.moves and mon.moves[p.slot] + local currentId = type(mv) == "table" and (mv.id or mv.moveId) or mv if #hits == 0 then Kit.emptyBox(cx, cy, inner, listH, "Nothing matches that.") @@ -107,7 +107,9 @@ function Picker.draw(S, Kit, width, height) local ry = cy + (i - 1) * (rowH + rowGap) local def = S.data.moves[id] local usable = Ops.moveUsable(S, id) - local current = currentId == id + local current = (currentId ~= nil) and (currentId == id + or (def and (def.moveId == currentId or def.id == currentId)) + or (S.data and S.data.moves and S.data.moves[currentId] and S.data.moves[currentId].id == id)) if Kit.row(cx, ry, inner, rowH, current, PAL.green, 9 * s) then if commit(S, id) then Ops.closeMovePicker(S, Kit) diff --git a/tools/save-editor/panels/SpeciesPicker.lua b/tools/save-editor/panels/SpeciesPicker.lua index 8af5b377..1094951c 100644 --- a/tools/save-editor/panels/SpeciesPicker.lua +++ b/tools/save-editor/panels/SpeciesPicker.lua @@ -116,7 +116,8 @@ function Picker.draw(S, Kit, width, height) local usable = Ops.speciesUsable(S, id) -- box-add has no "current" species: nothing is being replaced local current = p.mode ~= "box-add" - and (S.editingMon and S.editingMon.species == id) or false + and (S.editingMon and (S.editingMon.species == id + or (def and (S.editingMon.species == def.speciesId or S.editingMon.speciesId == def.speciesId or S.editingMon.species == def.id)))) or false if Kit.row(cx, ry, inner, rowH, current, PAL.green, 9 * s) then if commit(S, id) then Ops.closeSpeciesPicker(S, Kit) From dffcaeb4c07c0f728c6ec9e58ef22a5519c48dbc Mon Sep 17 00:00:00 2001 From: 1jamie Date: Mon, 21 Sep 2026 17:21:20 -0500 Subject: [PATCH 5/5] fix: use rawget for global bit library lookups and fix variable assignment in trainer fan club --- src/core/game3/trainer_fan_club.lua | 4 +++- src/import/gba/lz77.lua | 2 +- src/import/gba/pokemon_extract.lua | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/core/game3/trainer_fan_club.lua b/src/core/game3/trainer_fan_club.lua index aa5a4371..5d11716f 100644 --- a/src/core/game3/trainer_fan_club.lua +++ b/src/core/game3/trainer_fan_club.lua @@ -302,7 +302,9 @@ function TrainerFanClub.tryGainNewFanFromCounter(session, ctx, counterIdx) if timer + inc >= 20 then if TrainerFanClub.countFans(fanFlags) < 3 then TrainerFanClub.playerGainRandomTrainerFan(session, ctx) - _, gotInitialFans, fanFlags = TrainerFanClub.getFanClubData(session, ctx) + local _unused, newGotInitial, newFanFlags = TrainerFanClub.getFanClubData(session, ctx) + gotInitialFans = newGotInitial + fanFlags = newFanFlags timer = 0 else timer = 20 diff --git a/src/import/gba/lz77.lua b/src/import/gba/lz77.lua index 22999ddf..a21a040c 100644 --- a/src/import/gba/lz77.lua +++ b/src/import/gba/lz77.lua @@ -12,7 +12,7 @@ do end end -local bit = bit or bit32 +local bit = rawget(_G, "bit") or rawget(_G, "bit32") if not bit then local ok, mod = pcall(require, "bit") if ok and mod then bit = mod end diff --git a/src/import/gba/pokemon_extract.lua b/src/import/gba/pokemon_extract.lua index 3e56aa60..46b02ffb 100644 --- a/src/import/gba/pokemon_extract.lua +++ b/src/import/gba/pokemon_extract.lua @@ -97,7 +97,7 @@ do end end -local bit = bit or bit32 +local bit = rawget(_G, "bit") or rawget(_G, "bit32") if not bit then local ok, mod = pcall(require, "bit") if ok and mod then bit = mod end