Merge pull request #2387 from bryanthaboi/dev

This commit is contained in:
bryanthaboi
2026-09-21 18:40:13 -04:00
committed by GitHub
58 changed files with 6378 additions and 502 deletions
+20
View File
@@ -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)
+2 -9
View File
@@ -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
+2 -8
View File
@@ -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")
+287
View File
@@ -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
+301 -2
View File
@@ -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()
+194 -130
View File
@@ -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
+5
View File
@@ -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
+3
View File
@@ -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
+6 -1
View File
@@ -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)
+150 -1
View File
@@ -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]
+11 -2
View File
@@ -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
+109
View File
@@ -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
+215
View File
@@ -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
+8
View File
@@ -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
+6
View File
@@ -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")
+81 -1
View File
@@ -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
+8
View File
@@ -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)
+2
View File
@@ -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",
}
+226 -43
View File
@@ -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,
}
@@ -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
+1 -1
View File
@@ -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")
@@ -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
+8
View File
@@ -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
+21
View File
@@ -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
+3
View File
@@ -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
+385
View File
@@ -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
+2
View File
@@ -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",
+187 -35
View File
@@ -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
+82 -37
View File
@@ -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"
-95
View File
@@ -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
+71
View File
@@ -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
+141 -36
View File
@@ -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
+129 -16
View File
@@ -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
+14 -1
View File
@@ -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).
+288
View File
@@ -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
+598
View File
@@ -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")
+203
View File
@@ -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!")
+1 -2
View File
@@ -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()
+214
View File
@@ -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
+140
View File
@@ -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
+234
View File
@@ -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)
+195
View File
@@ -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
+2 -1
View File
@@ -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()
+13 -1
View File
@@ -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)
+306
View File
@@ -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
+57
View File
@@ -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()
+294 -1
View File
@@ -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
+1
View File
@@ -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
+15
View File
@@ -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
+33
View File
@@ -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)
+261
View File
@@ -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
+167
View File
@@ -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)))
+258 -28
View File
@@ -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)
+13 -2
View File
@@ -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]
+166 -43
View File
@@ -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
+70 -2
View File
@@ -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
+5 -3
View File
@@ -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)
+2 -1
View File
@@ -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)