mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-28 23:00:21 -04:00
Merge pull request #2387 from bryanthaboi/dev
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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!")
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user