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..ae7eb00b --- /dev/null +++ b/src/core/game3/deoxys.lua @@ -0,0 +1,287 @@ +-- 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 } + +-- 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 = { + { { 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) + 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), Deoxys.sourcePalette()) +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..b85bc171 100644 --- a/src/core/game3/field_effects.lua +++ b/src/core/game3/field_effects.lua @@ -14,6 +14,26 @@ 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 + +-- 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 @@ -22,6 +42,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 +394,130 @@ function FieldEffects.startSweetScent(onDone) table.insert(FieldEffects._anims, anim) end +-- 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 = "bg_flash", + timer = 0, + 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). +--- 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: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 + 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 = {} + local dirs = { { -1, -1 }, { 1, -1 }, { -1, 1 }, { 1, 1 } } + for i = 1, 4 do + frags[i] = { + frame = i - 1, + 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, + 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 +607,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 @@ -722,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 @@ -843,6 +1025,77 @@ 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_* + local FieldView = fieldView() + if anim.state == "shake" then + -- Task_DeoxysRockCameraShake (data[7]==0): full amplitude, sign flips + -- when data[0] passes 1, i.e. every other frame. + if FieldView and FieldView.setCameraPanning then + FieldView.setCameraPanning(0, (math.floor(anim.timer / 2) % 2 == 0) and 4 or -4) + end + -- 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 + eo.px, eo.py = anim.x, anim.y + eo.invisible = true + eo.hidden = true + eo.visible = false + end + FieldEffects.startBgFlash() + play_se(SE_THUNDER) + anim.state = "shatter" + anim.timer = 0 + anim.amp = 4 + end + 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 + 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 > 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 + 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 +1110,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 +1260,20 @@ function FieldEffects.drawFront(camX, camY, playerPy) end end end + 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 + 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 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 +1356,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 +1379,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 +1398,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/field_view.lua b/src/core/game3/field_view.lua index 4d6a12be..fa8045bc 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,29 @@ 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 + -- 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 - 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 + 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 +1243,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 +1301,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/ow_sprites.lua b/src/core/game3/ow_sprites.lua index 1bed8e31..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, @@ -117,6 +120,142 @@ 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 + +-- 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) 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 + return nil + end + if not (spr and spr.image) 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 = {}, {} + 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 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 + 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 + copy.imageData = data + 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 +302,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/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/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/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..ea5bb0f1 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, @@ -181,8 +347,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/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/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..9dc15af4 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 @@ -91,6 +96,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 @@ -124,16 +130,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/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/src/core/game3/trainer_fan_club.lua b/src/core/game3/trainer_fan_club.lua new file mode 100644 index 00000000..5d11716f --- /dev/null +++ b/src/core/game3/trainer_fan_club.lua @@ -0,0 +1,385 @@ +-- 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) + local _unused, newGotInitial, newFanFlags = TrainerFanClub.getFanClubData(session, ctx) + gotInitialFans = newGotInitial + fanFlags = newFanFlags + 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/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/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..a21a040c 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 = 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 +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..46b02ffb 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 = 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 +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/src/import/gba/versions.lua b/src/import/gba/versions.lua index e5d9464f..5d89758b 100644 --- a/src/import/gba/versions.lua +++ b/src/import/gba/versions.lua @@ -27,7 +27,12 @@ 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 = 111 +-- v111: hidden-item bitfield fix in extract_map_events (id % 256, qty % 128). +-- v112: deoxys_rock_fragments field effect — the Birth Island meteorite shatter +-- had no artwork, so the rock simply vanished instead of breaking apart. +-- Both branches had taken 111 for unrelated cache layouts, so this merge +-- moves the Deoxys artwork onto its own number instead of sharing one. +Versions.CACHE_VERSION = 112 Versions.NATIVE_VERSION = 6 Versions.OW_VERSION = 1 Versions.ANIM_VERSION = 1 @@ -1384,6 +1389,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/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 diff --git a/tests/game3_deoxys_test.lua b/tests/game3_deoxys_test.lua new file mode 100644 index 00000000..814a7406 --- /dev/null +++ b/tests/game3_deoxys_test.lua @@ -0,0 +1,598 @@ +#!/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") +-- 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") + +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") + -- 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), + "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 + +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) +end +print("[test] all passed") 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_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() 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)