CLOSES #2235, CLOSES #2245, CLOSES #2252, CLOSES #2253, CLOSES #2257, CLOSES #2261, CLOSES #2262, CLOSES #2263, CLOSES #2264, CLOSES #2267, CLOSES #2268, CLOSES #2272, CLOSES #2273, CLOSES #2274, CLOSES #2275

This commit is contained in:
bryanthaboi
2026-09-13 11:49:05 -04:00
parent 2cc86d599d
commit feec1d43fd
53 changed files with 3705 additions and 164 deletions
+1 -2
View File
@@ -64,8 +64,7 @@ not copied into the cache. Later launches load the private generated cache and
do not ask for the ROM again. Red, Blue, Yellow, Gold, Silver, and Crystal can
all be imported side by side. Gold, Silver, and Crystal are Gen 2 Phase 1
(import + launcher; see `docs/gold-phase1.md`): the Gen 2 engine is still under
construction, and Crystal is the newest of the three, so the launcher lists it
as Crystal (Beta).
construction.
## Quick Start
+10 -5
View File
@@ -15,11 +15,16 @@ local M = {}
M.CERULEAN_CAVE_B1F = {
talk = {
TEXT_CERULEANCAVEB1F_MEWTWO = {
{ "play_cry", "MEWTWO" }, -- 1 text_asm PlayCry
{ "show_text", "_MewtwoBattleText" }, -- 2 "Mew!"
{ "check_flag", "EVENT_BEAT_MEWTWO" }, -- 3
{ "jump_if_true", 6 }, -- 4 already beaten: text only
{ "static_battle", "MEWTWO", 70, "EVENT_BEAT_MEWTWO" }, -- 5
{ "check_flag", "EVENT_BEAT_MEWTWO" },
{ "jump_if_true", "beaten" },
{ "play_cry", "MEWTWO", true },
{ "engage_music", "Music_MeetMaleTrainer" }, -- home/trainers.asm:123
{ "show_text", "_MewtwoBattleText" },
{ "static_battle", "MEWTWO", 70, "EVENT_BEAT_MEWTWO" },
{ "jump", "end" },
{ "label", "beaten" },
{ "play_cry", "MEWTWO", true },
{ "show_text", "_MewtwoBattleText" },
},
},
}
+18 -9
View File
@@ -15,10 +15,14 @@
-- header order in scripts/PowerPlant.asm: text_asm n uses header n-1
local function ballMon(species, level, flag)
return {
{ "show_text", "_PowerPlantVoltorbBattleText" }, -- 1 "Bzzzt!"
{ "check_flag", flag }, -- 2
{ "jump_if_true", 5 }, -- 3 already beaten: text only
{ "static_battle", species, level, flag }, -- 4
{ "check_flag", flag },
{ "jump_if_true", "beaten" },
{ "engage_music", "Music_MeetMaleTrainer" }, -- home/trainers.asm:123
{ "show_text", "_PowerPlantVoltorbBattleText" },
{ "static_battle", species, level, flag },
{ "jump", "end" },
{ "label", "beaten" },
{ "show_text", "_PowerPlantVoltorbBattleText" },
}
end
@@ -35,11 +39,16 @@ M.POWER_PLANT = {
TEXT_POWERPLANT_ELECTRODE2 = ballMon("ELECTRODE", 43, "EVENT_BEAT_POWER_PLANT_VOLTORB_6"),
TEXT_POWERPLANT_VOLTORB6 = ballMon("VOLTORB", 40, "EVENT_BEAT_POWER_PLANT_VOLTORB_7"),
TEXT_POWERPLANT_ZAPDOS = {
{ "play_cry", "ZAPDOS" }, -- 1 text_asm PlayCry
{ "show_text", "_PowerPlantZapdosBattleText" }, -- 2 "Gyaoo!"
{ "check_flag", "EVENT_BEAT_ZAPDOS" }, -- 3
{ "jump_if_true", 6 }, -- 4 already beaten: text only
{ "static_battle", "ZAPDOS", 50, "EVENT_BEAT_ZAPDOS" }, -- 5
{ "check_flag", "EVENT_BEAT_ZAPDOS" },
{ "jump_if_true", "beaten" },
{ "play_cry", "ZAPDOS", true },
{ "engage_music", "Music_MeetMaleTrainer" }, -- home/trainers.asm:123
{ "show_text", "_PowerPlantZapdosBattleText" },
{ "static_battle", "ZAPDOS", 50, "EVENT_BEAT_ZAPDOS" },
{ "jump", "end" },
{ "label", "beaten" },
{ "play_cry", "ZAPDOS", true },
{ "show_text", "_PowerPlantZapdosBattleText" },
},
},
}
+12
View File
@@ -0,0 +1,12 @@
-- pokered/scripts/SaffronCity.asm:76
return {
SAFFRON_CITY = {
talk = {
TEXT_SAFFRONCITY_PIDGEOT = {
{ "face_player" },
{ "play_cry", "PIDGEOT", true },
{ "show_text", "_SaffronCityPidgeotText" },
},
},
},
}
+10 -5
View File
@@ -17,11 +17,16 @@ local M = {}
M.SEAFOAM_ISLANDS_B4F = {
talk = {
TEXT_SEAFOAMISLANDSB4F_ARTICUNO = {
{ "play_cry", "ARTICUNO" }, -- 1 text_asm PlayCry
{ "show_text", "_SeafoamIslandsB4FArticunoBattleText" }, -- 2 "Gyaoo!"
{ "check_flag", "EVENT_BEAT_ARTICUNO" }, -- 3
{ "jump_if_true", 6 }, -- 4 already beaten: text only
{ "static_battle", "ARTICUNO", 50, "EVENT_BEAT_ARTICUNO" }, -- 5
{ "check_flag", "EVENT_BEAT_ARTICUNO" },
{ "jump_if_true", "beaten" },
{ "play_cry", "ARTICUNO", true },
{ "engage_music", "Music_MeetMaleTrainer" }, -- home/trainers.asm:123
{ "show_text", "_SeafoamIslandsB4FArticunoBattleText" },
{ "static_battle", "ARTICUNO", 50, "EVENT_BEAT_ARTICUNO" },
{ "jump", "end" },
{ "label", "beaten" },
{ "play_cry", "ARTICUNO", true },
{ "show_text", "_SeafoamIslandsB4FArticunoBattleText" },
},
},
}
+10 -5
View File
@@ -15,11 +15,16 @@ local M = {}
M.VICTORY_ROAD_2F = {
talk = {
TEXT_VICTORYROAD2F_MOLTRES = {
{ "play_cry", "MOLTRES" }, -- 1 text_asm PlayCry
{ "show_text", "_VictoryRoad2FMoltresBattleText" }, -- 2 "Gyaoo!"
{ "check_flag", "EVENT_BEAT_MOLTRES" }, -- 3
{ "jump_if_true", 6 }, -- 4 already beaten: text only
{ "static_battle", "MOLTRES", 50, "EVENT_BEAT_MOLTRES" }, -- 5
{ "check_flag", "EVENT_BEAT_MOLTRES" },
{ "jump_if_true", "beaten" },
{ "play_cry", "MOLTRES", true },
{ "engage_music", "Music_MeetMaleTrainer" }, -- home/trainers.asm:123
{ "show_text", "_VictoryRoad2FMoltresBattleText" },
{ "static_battle", "MOLTRES", 50, "EVENT_BEAT_MOLTRES" },
{ "jump", "end" },
{ "label", "beaten" },
{ "play_cry", "MOLTRES", true },
{ "show_text", "_VictoryRoad2FMoltresBattleText" },
},
},
}
+1
View File
@@ -42,6 +42,7 @@ local files = {
"data.scripts.flavor.route_23",
"data.scripts.flavor.route_2_trade_house",
"data.scripts.flavor.safari_zone_gate",
"data.scripts.flavor.saffron_city",
"data.scripts.flavor.saffron_pidgey_house",
"data.scripts.flavor.seafoam_islands_b4f",
"data.scripts.flavor.silph_co_10f",
+1 -1
View File
@@ -38,7 +38,7 @@ M.VERMILION_GYM = {
-- sets wDungeonWarpDestinationMap and calls IsPlayerOnDungeonWarp, and
-- wCoordIndex picks that floor's DungeonWarpData row. Unconditional in
-- the original: a plugged hole still drops the player. The B3F and B4F
-- landings are water; setMap's CheckForceBikeOrSurf pass
-- landings are water; the CheckForceBikeOrSurf pass after the drop
-- (OverworldState:checkForcedMovement) mounts SURF on arrival.
local HOLE_FALLS = {
SEAFOAM_ISLANDS_1F = { { 17, 6, "SEAFOAM_ISLANDS_B1F", 18, 7 },
+21
View File
@@ -238,8 +238,29 @@ local function dojoMasterGate(game, ow, x, y)
return true
end
local function dojoMaster(game, ow, npc, done)
local t = text(game)
local flags = game.save.flags
-- ../pokered/scripts/FightingDojo.asm:108
if flags.EVENT_DEFEATED_FIGHTING_DOJO then
push(game, t._FightingDojoKarateMasterStayAndTrainWithUsText
or "Ho!\fStay and train at\nKarate with us!", done)
return
end
-- ../pokered/scripts/FightingDojo.asm:110
if flags.EVENT_BEAT_KARATE_MASTER or ow:trainerDefeated(npc) then
push(game, t._FightingDojoKarateMasterIWillGiveYouAPokemonText
or "Indeed, I have\nlost!", done)
return
end
-- ../pokered/scripts/FightingDojo.asm:122
ow:engageTrainer(npc, done, t._FightingDojoKarateMasterDefeatedText,
nil, nil, false)
end
M.FIGHTING_DOJO = {
talk = {
TEXT_FIGHTINGDOJO_KARATE_MASTER = dojoMaster,
TEXT_FIGHTINGDOJO_HITMONLEE_POKE_BALL =
dojoBall("HITMONLEE", "FIGHTINGDOJO_HITMONLEE_POKE_BALL",
"FIGHTINGDOJO_HITMONCHAN_POKE_BALL",
+2 -2
View File
@@ -97,8 +97,8 @@ This project ships **no** game data. On first launch:
same folder.
2. Use **Scan again** on that game's tab (Red / Blue / Yellow / Gold /
Silver). Rescan matches by ROM SHA-1 for the open tab only. A Red dump
never imports from the Yellow tab (and vice versa). Gold and Silver are
Beta in the launcher; a clean US dump of either is enough to Play.
never imports from the Yellow tab (and vice versa). A clean US Gold or
Silver dump is enough to Play.
## 5. Import / Export a raw `.sav`
+28 -11
View File
@@ -2111,6 +2111,19 @@ function BattleState:menuLockedAction(battler)
return nil
end
-- engine/battle/core.asm:293
function BattleState:enterCommandMenu()
self.phase = "menu"
if self.kind == "link" or self.safari or self.demo then return end
if not (self.player and self.player.mon and self.player.mon.hp > 0) then return end
local locked = self:menuLockedAction(self.player)
if locked then
self:snapIdleBars()
self:clearTurnFlinches()
self:resolveTurn(locked)
end
end
-- After FIGHT: skip MoveSelectionMenu (core.asm:320-329). Own
-- trapping/Bide continues; foe trapping forces CANNOT_MOVE ($ff).
function BattleState:fightLockedAction(battler)
@@ -2284,6 +2297,19 @@ function BattleState:tickFx()
self:updateFx()
end
function BattleState:snapIdleBars()
for _, b in ipairs({ self.player, self.enemy }) do
if b then
if b.shownHP then
b.shownHP = b.mon.hp
b.shownPx = Timing.hpBarPixels(b.mon.hp, math.max(1, b.mon.stats.hp))
end
b.drainFloor = nil
b.shownStatus = b.mon.status
end
end
end
function BattleState:update(dt)
self:tickFx()
local input = self.game.input
@@ -2291,16 +2317,7 @@ function BattleState:update(dt)
-- safety net: HP/status changed outside a queued drain (level-up heals,
-- field effects, bag cures) snaps once the queue is idle
if self.phase == "menu" then
for _, b in ipairs({ self.player, self.enemy }) do
if b then
if b.shownHP then
b.shownHP = b.mon.hp
b.shownPx = Timing.hpBarPixels(b.mon.hp, math.max(1, b.mon.stats.hp))
end
b.drainFloor = nil
b.shownStatus = b.mon.status
end
end
self:snapIdleBars()
end
if self.phase == "messages" then
@@ -2327,7 +2344,7 @@ function BattleState:update(dt)
-- engine/battle/core.asm:2007
self.msgHold = nil
self.shown = nil
self.phase = "menu"
self:enterCommandMenu()
elseif destination == "finish" then
self:finish()
end
+20 -16
View File
@@ -3485,7 +3485,7 @@ Battle.RESIDUAL_ANIM = {
burn = "ANIM_BRN", poison = "ANIM_PSN", toxic = "ANIM_PSN",
}
-- End of turn: burn and poison chip damage, through the merged record's
-- ResidualDamage: burn and poison chip damage, through the merged record's
-- `residual`. The record computes and advances its own counter; the emit pair
-- stays here because the event shape belongs to this engine, not to the status.
function Battle:tickStatus(mon)
@@ -4961,6 +4961,15 @@ local function runTurn(self, action, enemyAction)
self:useMove(self.enemy, self.player, enemyMoveId)
end
local function residualHalf(mon)
if self.faintInterrupt or self.forcedSwitch then return true end
-- engine/battle/core.asm:1005
self:tickStatus(mon)
self:tickSeedAndCurse(mon)
if self:resolveFaints() then return true end
return self.faintInterrupt and true or false
end
if playerFirst then
playerAttack()
-- A wild Roar or Whirlwind ends the battle from THIS half of the turn the
@@ -4977,10 +4986,12 @@ local function runTurn(self, action, enemyAction)
-- happens to be standing there now. The end-of-turn block below still
-- runs -- HandleEnemyMonFaint returns into BattleTurn's `.proceed`, which
-- calls HandleBetweenTurnEffects (core.asm:196).
if self.faintInterrupt then
self.faintInterrupt = nil
elseif (self.player.hp or 0) > 0 then
if not residualHalf(self.player) and not self.over
and (self.player.hp or 0) > 0 then
enemyAttack()
if self.over then return self:takeEvents() end
if self:resolveFaints() then return self:takeEvents() end
residualHalf(self.enemy)
end
else
enemyAttack()
@@ -4991,10 +5002,12 @@ local function runTurn(self, action, enemyAction)
if self:resolveFaints() then return self:takeEvents() end
-- Same `jp` (core.asm:834-837): a mon that fainted to the enemy's move
-- takes the rest of the attack phase with it.
if self.faintInterrupt then
self.faintInterrupt = nil
elseif (self.player.hp or 0) > 0 then
if not residualHalf(self.enemy) and not self.over
and (self.player.hp or 0) > 0 then
playerAttack()
if self.over then return self:takeEvents() end
if self:resolveFaints() then return self:takeEvents() end
residualHalf(self.player)
end
end
if self.over then return self:takeEvents() end
@@ -5009,18 +5022,9 @@ local function runTurn(self, action, enemyAction)
return self:takeEvents()
end
-- End of turn, in the cart's own order (HandleWeather runs before the
-- residual damage, and the counters that end a mon come last):
-- weather, then status chip and the Leech Seed / Curse residuals, then
-- the wrap ticks, then held items, then Future Sight and Perish Song,
-- then the screens and the per-turn counters.
local firstMon, secondMon = self.player, self.enemy
if self.mirrored then firstMon, secondMon = self.enemy, self.player end
self:tickWeather()
self:tickStatus(firstMon)
self:tickSeedAndCurse(firstMon)
self:tickStatus(secondMon)
self:tickSeedAndCurse(secondMon)
self:tickWrap(firstMon)
self:tickWrap(secondMon)
self:tickHeldItem(firstMon)
+27 -4
View File
@@ -1517,6 +1517,24 @@ function Game2:compose(scene, zones, w, h)
return handled
end
function Game2:fxWorldOrigin(w, h, scale)
local cam = self.world and self.world.camera
if not cam then return nil end
local fx, fy = Playfield.rect(w, h)
return fx + math.floor(-cam.x * scale), fy + math.floor(-cam.y * scale)
end
function Game2:fxSplitsUi(w, h)
local world = self.world
if not (world and world.map) then return false end
local fs = world:fitScale()
if world:zoomScale() ~= fs then return true end
local wx, wy = self:fxWorldOrigin(w, h, fs)
if not wx then return false end
local ox, oy = Chrome.fitOrigin(w, h, fs)
return (ox - wx) % fs ~= 0 or (oy - wy) % fs ~= 0
end
-- Gold's frame, and then the passes that run over it.
--
-- The Gen 1 path gets these for free because everything it draws goes through
@@ -1591,8 +1609,7 @@ function Game2:drawViewportFrame()
-- sits over it is drawn onto a transparent layer and shaded at FIT instead.
self.fxUiLayer = nil
self.fxUiDrawn = false
if shaderfx and self.world and self.world.map
and self.world:zoomScale() ~= self.world:fitScale() then
if shaderfx and self:fxSplitsUi(w, h) then
self.fxUiLayer = self:presentCanvas(3, w, h)
end
self:drawContained(w, h)
@@ -1662,11 +1679,17 @@ function Game2:drawViewportFrame()
-- when the UI was split off, otherwise everything at FIT.
local s = scale * dpi
local ws = uiLayer and self.world:zoomScale() * dpi or s
local wox, woy = ox * dpi, oy * dpi
if self.frameWorldActive then
local wx, wy = self:fxWorldOrigin(w, h, ws / dpi)
if wx then wox, woy = wx * dpi, wy * dpi end
end
ShaderFX.render(source, { x = 0, y = 0, w = pw, h = ph, scale = ws },
{ w = pw / ws, h = ph / ws }, dpi, dpi)
{ w = pw / ws, h = ph / ws }, dpi, dpi, { originX = wox, originY = woy })
if uiLayer then
ShaderFX.render(uiLayer, { x = 0, y = 0, w = pw, h = ph, scale = s },
{ w = pw / s, h = ph / s }, dpi, dpi, { layer = "ui", mask = true })
{ w = pw / s, h = ph / s }, dpi, dpi,
{ layer = "ui", mask = true, originX = ox * dpi, originY = oy * dpi })
end
else
G.setColor(1, 1, 1, 1)
+1 -3
View File
@@ -87,9 +87,7 @@ GameVersion.VERSIONS = {
id = "crystal",
label = "Crystal",
displayName = "Pokemon Crystal",
-- Still Gen 2 Phase work; the launcher panel / Play button say Beta so
-- players do not treat it like the shipped Gold and Silver columns.
launcherName = "Crystal (Beta)",
launcherName = "Crystal",
sha1 = "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133",
manifest = "tools/rom_manifest_crystal.json",
cachePrefix = "crystal/", -- crystal/data/generated, crystal/assets/generated
+18 -2
View File
@@ -167,6 +167,18 @@ function PaletteFX.ogObj()
return PaletteFX.fadeObp(PaletteFX.darkObp(PaletteFX.GBC_OBJ, "gbcobj"))
end
local function obp3100(c) return { c[1], c[1], c[2], c[4] } end
local OG_OBJ_NORMAL = obp3100(PaletteFX.GBC_OBJ)
local OG_OBJ_NORMAL_BLUE = obp3100(PaletteFX.GBC_OBJ_BLUE)
-- home/palettes.asm:24
function PaletteFX.ogObjNormal()
if GameVersion.isBlue() then
return OG_OBJ_NORMAL_BLUE, "gbcobjnormal_blue"
end
return OG_OBJ_NORMAL, "gbcobjnormal"
end
-- The DMG object ramp every mode except OG RED bakes onto overworld sprites,
-- plus its cache group (same two-value contract as ogObj). Entry 1 is never
-- read -- SpriteRenderer.getObpImage keys OBJ color 0 to alpha, the hardware's
@@ -443,10 +455,14 @@ function PaletteFX.spriteRedraws()
return spriteRedraws
end
function PaletteFX.markUiSpriteRedraw(image, quad, x, y)
function PaletteFX.markUiSpriteRedraw(image, quad, x, y, opts)
if currentPass ~= "ui" then return end
local clip = opts and opts.clip
if clip then clip = { clip[1] + markOffsetX, clip[2], clip[3], clip[4] } end
uiSpriteRedraws[#uiSpriteRedraws + 1] =
{ image = image, quad = quad, x = x + markOffsetX, y = y }
{ image = image, quad = quad, x = x + markOffsetX, y = y,
sx = opts and opts.sx or 1, sy = opts and opts.sy or 1,
clip = clip, color = opts and opts.color }
end
function PaletteFX.uiSpriteRedraws()
+28 -7
View File
@@ -1037,6 +1037,7 @@ function Renderer:endFrame(zones, worldZones)
-- the UI shaded separately on a transparent layer and masked over it.
local fxScale = Up
local fxWorldScale = nil
local fxWorldOx, fxWorldOy = nil, nil
if self.worldOverride then
-- A render pipeline already produced the whole world -- terrain,
@@ -1069,7 +1070,9 @@ function Renderer:endFrame(zones, worldZones)
local woxPx = vx + math.floor((pw - wvw * sp) / 2)
local woyPx = vy + math.floor((ph - wvh * sp) / 2) - R.lift
local wox, woy = woxPx / dpiX, woyPx / dpiY
if present and ShaderFX.active() and sp ~= Up then fxWorldScale = sp end
if present and ShaderFX.active() and sp ~= Up then
fxWorldScale, fxWorldOx, fxWorldOy = sp, woxPx, woyPx
end
-- Tilt mode projects the ground world pass through the perspective mesh
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
-- scissoring here). drawTiltedWorld returns false when tilt is off or
@@ -1248,16 +1251,34 @@ function Renderer:endFrame(zones, worldZones)
end
local uiRedraws = PaletteFX.uiSpriteRedraws()
if uiRedraws[1] then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor(clipToView(uox, uoy, uvpw, uvph))
local bx, by, bw, bh = clipToView(uox, uoy, uvpw, uvph)
for _, r in ipairs(uiRedraws) do
local c = r.clip
if c then
local x1 = math.max(bx, uox + c[1] * Ux)
local y1 = math.max(by, uoy + c[2] * Uy)
local x2 = math.min(bx + bw, uox + (c[1] + c[3]) * Ux)
local y2 = math.min(by + bh, uoy + (c[2] + c[4]) * Uy)
love.graphics.setScissor(x1, y1, math.max(0, x2 - x1),
math.max(0, y2 - y1))
else
love.graphics.setScissor(bx, by, bw, bh)
end
local col = r.color
if col then
love.graphics.setColor(col[1], col[2], col[3], col[4] or 1)
else
love.graphics.setColor(1, 1, 1, 1)
end
local rsx, rsy = Ux * (r.sx or 1), Uy * (r.sy or 1)
if r.quad then
love.graphics.draw(r.image, r.quad, uox + r.x * Ux, uoy + r.y * Uy,
0, Ux, Uy)
0, rsx, rsy)
else
love.graphics.draw(r.image, uox + r.x * Ux, uoy + r.y * Uy, 0, Ux, Uy)
love.graphics.draw(r.image, uox + r.x * Ux, uoy + r.y * Uy, 0, rsx, rsy)
end
end
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor()
end
@@ -1332,12 +1353,12 @@ function Renderer:endFrame(zones, worldZones)
ShaderFX.render(composed,
{ x = 0, y = 0, w = fxPxW, h = fxPxH, scale = worldScale },
{ w = fxPxW / worldScale, h = fxPxH / worldScale },
dpiX, dpiY)
dpiX, dpiY, { originX = fxWorldOx or uox * dpiX, originY = fxWorldOy or uoy * dpiY })
if uiLayer then
ShaderFX.render(uiLayer,
{ x = 0, y = 0, w = fxPxW, h = fxPxH, scale = fxScale },
{ w = fxPxW / fxScale, h = fxPxH / fxScale },
dpiX, dpiY, { layer = "ui", mask = true })
dpiX, dpiY, { layer = "ui", mask = true, originX = uox * dpiX, originY = uoy * dpiY })
end
else
-- the present canvas only existed for the post-process, so put the
+59 -9
View File
@@ -506,6 +506,8 @@ local function defaultEs()
end
local function roundDim(x) return math.max(1, math.floor(x + 0.5)) end
local VIEWPORT_TIE_BIAS = 0.25
local CROP_SAMPLE_BIAS = 0.25
local function resolveScale(scaling, viewportDim, inputDim, originalDim)
local st = scaling.scale_type
@@ -684,6 +686,10 @@ local function runPass(state, i, pass, outputs, frameSource, lutByName, viewport
local srcImg = (i == 0) and frameSource or outputs[i - 1]
local srcDims = passInputDims(state, i, original)
local drawScaleX, drawScaleY = dims.w / srcDims.w, dims.h / srcDims.h
local biasX = (pass.scale_x.scale_type == "viewport" and drawScaleX >= 2
and drawScaleX == math.floor(drawScaleX)) and VIEWPORT_TIE_BIAS or 0
local biasY = (pass.scale_y.scale_type == "viewport" and drawScaleY >= 2
and drawScaleY == math.floor(drawScaleY)) and VIEWPORT_TIE_BIAS or 0
-- Reallocated only when this pass's resolved size actually changes; each
-- layer keeps its own set so a split world/UI frame does not thrash.
@@ -711,7 +717,7 @@ local function runPass(state, i, pass, outputs, frameSource, lutByName, viewport
else
love.graphics.setBlendMode("replace")
end
love.graphics.draw(srcImg, 0, 0, 0, drawScaleX, drawScaleY)
love.graphics.draw(srcImg, biasX, biasY, 0, drawScaleX, drawScaleY)
love.graphics.pop()
outputs[i] = canvas
@@ -1064,13 +1070,39 @@ end
-- Crops the playfield rect out of `canvas` at `renderScale`. The output canvas
-- is cached module-wide and reallocated only on a real size change.
local cropCanvasCache = {}
local function cropToGbSource(canvas, rect, srcW, srcH, renderScale, layer)
local function gridGeometry(rect, renderScale, originX, originY)
local s = tonumber(rect.scale)
if not s or s < 1 or s ~= math.floor(s) then return nil end
local phaseX = (math.floor((tonumber(originX) or rect.x) + 0.5) - rect.x) % s
local phaseY = (math.floor((tonumber(originY) or rect.y) + 0.5) - rect.y) % s
local x0 = phaseX > 0 and phaseX - s or 0
local y0 = phaseY > 0 and phaseY - s or 0
local cols = math.max(1, math.ceil((rect.w - x0) / s))
local rows = math.max(1, math.ceil((rect.h - y0) / s))
return {
scale = s, x0 = x0, y0 = y0, cols = cols, rows = rows,
crop = { w = roundDim(cols * renderScale), h = roundDim(rows * renderScale) },
viewport = { w = roundDim(cols * s * renderScale), h = roundDim(rows * s * renderScale) },
}
end
ShaderFX._gridGeometry = gridGeometry
local function cropToGbSource(canvas, rect, srcW, srcH, renderScale, layer, geo)
-- Cheap insurance against a read-after-write hazard between this draw and
-- whatever last rendered into `canvas`.
love.graphics.flushBatch()
local outW, outH = roundDim(srcW * renderScale), roundDim(srcH * renderScale)
local quad = love.graphics.newQuad(rect.x, rect.y, rect.w, rect.h,
canvas:getPixelWidth(), canvas:getPixelHeight())
local outW, outH, quad
if geo then
outW, outH = geo.crop.w, geo.crop.h
quad = love.graphics.newQuad(rect.x + geo.x0 + CROP_SAMPLE_BIAS,
rect.y + geo.y0 + CROP_SAMPLE_BIAS, geo.cols * geo.scale, geo.rows * geo.scale,
canvas:getPixelWidth(), canvas:getPixelHeight())
else
outW, outH = roundDim(srcW * renderScale), roundDim(srcH * renderScale)
quad = love.graphics.newQuad(rect.x, rect.y, rect.w, rect.h,
canvas:getPixelWidth(), canvas:getPixelHeight())
end
local out
local cached = cropCanvasCache[layer]
if cached and cached.w == outW and cached.h == outH then
@@ -1105,9 +1137,10 @@ local function maskedDraw()
if maskShader == nil then
local ok, sh = pcall(love.graphics.newShader, [[
extern Image mask;
extern vec4 maskRect;
vec4 effect(vec4 color, Image tex, vec2 uv, vec2 sc) {
vec4 c = Texel(tex, uv);
return vec4(c.rgb, Texel(mask, uv).a) * color;
return vec4(c.rgb, Texel(mask, maskRect.xy + uv * maskRect.zw).a) * color;
}
]])
maskShader = ok and sh or false
@@ -1139,7 +1172,9 @@ function ShaderFX.render(canvas, rect, source, dpiX, dpiY, opts)
}
-- Runs the chain at a reduced internal resolution on tiers that ask for one.
local scale = chainRenderScale()
local viewport = { w = roundDim(uRect.w * scale), h = roundDim(uRect.h * scale) }
local geo = gridGeometry(rect, scale, opts and opts.originX, opts and opts.originY)
local viewport = geo and geo.viewport
or { w = roundDim(uRect.w * scale), h = roundDim(uRect.h * scale) }
if layer == "main" then
-- exposed for tests only (tests/drivers/gold_shaderfx_zoom_sizing_test.lua)
@@ -1158,7 +1193,7 @@ function ShaderFX.render(canvas, rect, source, dpiX, dpiY, opts)
local chainOut, chainPreset
local clipX, clipY, clipW, clipH = love.graphics.getScissor()
love.graphics.setScissor()
local okCrop, frameSource = pcall(cropToGbSource, canvas, rect, source.w, source.h, scale, layer)
local okCrop, frameSource = pcall(cropToGbSource, canvas, rect, source.w, source.h, scale, layer, geo)
if okCrop then
lastCropError = nil
-- exposed for tests only (gold_shaderfx_menu_black_crop_test.lua)
@@ -1208,12 +1243,27 @@ function ShaderFX.render(canvas, rect, source, dpiX, dpiY, opts)
chainOut:setFilter(mode, mode)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setBlendMode("alpha")
local dx, dy, dw, dh = uRect.x, uRect.y, uRect.w, uRect.h
local maskRect = { 0, 0, 1, 1 }
if geo then
local gx, gy = rect.x + geo.x0, rect.y + geo.y0
local gw, gh = geo.cols * geo.scale, geo.rows * geo.scale
dx, dy, dw, dh = gx / dpiX, gy / dpiY, gw / dpiX, gh / dpiY
local mw, mh = canvas:getPixelWidth(), canvas:getPixelHeight()
maskRect = { gx / mw, gy / mh, gw / mw, gh / mh }
if not clipX then
love.graphics.setScissor(uRect.x, uRect.y,
math.ceil(rect.w / dpiX), math.ceil(rect.h / dpiY))
end
end
if masked then
love.graphics.setShader(masked)
masked:send("mask", canvas)
pcall(masked.send, masked, "maskRect", maskRect)
end
love.graphics.draw(chainOut, uRect.x, uRect.y, 0, uRect.w / cw, uRect.h / ch)
love.graphics.draw(chainOut, dx, dy, 0, dw / cw, dh / ch)
if masked then love.graphics.setShader() end
if geo and not clipX then love.graphics.setScissor() end
end
love.graphics.setBlendMode("alpha")
-- Test seam; set only after the real chain ran.
+5
View File
@@ -516,6 +516,11 @@ function TextBox:update(dt)
pcall(self.autoSrc.stop, self.autoSrc)
end
end
-- home/trainers.asm:123
if self.auto.afterSound and not self.afterSoundFired then
self.afterSoundFired = true
self.auto.afterSound()
end
-- auto.wait: the pet-NPC cries (PewterNidoranHouseNidoranText,
-- ViridianNicknameHouseSpearowText) have nothing queued behind the
-- cry, so DisplayTextID's trailing WaitForTextScrollButtonPress still
+223
View File
@@ -48,6 +48,67 @@ Gen2Layout.goldSilver = {
wGameTimeMinutes = 0x2055,
-- The 14 archived boxes, listed rather than strided (see BOX_COUNT).
boxes = { 0x4000, 0x4450, 0x48A0, 0x4CF0, 0x5140, 0x5590, 0x59E0, 0x6000, 0x6450, 0x68A0, 0x6CF0, 0x7140, 0x7590, 0x79E0 },
sceneVars = {
POKECENTER_2F = 0x251F,
TRADE_CENTER = 0x2520,
COLOSSEUM = 0x2521,
TIME_CAPSULE = 0x2522,
POWER_PLANT = 0x2523,
CERULEAN_GYM = 0x2524,
ROUTE_25 = 0x2525,
TRAINER_HOUSE_B1F = 0x2526,
VICTORY_ROAD_GATE = 0x2527,
SAFFRON_MAGNET_TRAIN_STATION = 0x2528,
ROUTE_16_GATE = 0x2529,
ROUTE_17_ROUTE_18_GATE = 0x252A,
INDIGO_PLATEAU_POKECENTER_1F = 0x252B,
WILLS_ROOM = 0x252C,
KOGAS_ROOM = 0x252D,
BRUNOS_ROOM = 0x252E,
KARENS_ROOM = 0x252F,
LANCES_ROOM = 0x2530,
HALL_OF_FAME = 0x2531,
ROUTE_27 = 0x2532,
NEW_BARK_TOWN = 0x2533,
ELMS_LAB = 0x2534,
PLAYERS_HOUSE_1F = 0x2535,
ROUTE_29 = 0x2536,
CHERRYGROVE_CITY = 0x2537,
MR_POKEMONS_HOUSE = 0x2538,
ROUTE_32 = 0x2539,
ROUTE_35_NATIONAL_PARK_GATE = 0x253A,
ROUTE_36_NATIONAL_PARK_GATE = 0x253B,
AZALEA_TOWN = 0x253C,
GOLDENROD_GYM = 0x253D,
GOLDENROD_MAGNET_TRAIN_STATION = 0x253E,
OLIVINE_CITY = 0x253F,
ROUTE_34 = 0x2540,
ECRUTEAK_TIN_TOWER_ENTRANCE = 0x2541,
ECRUTEAK_POKECENTER_1F = 0x2542,
MAHOGANY_TOWN = 0x2543,
ROUTE_43_GATE = 0x2544,
MOUNT_MOON = 0x2545,
SPROUT_TOWER_3F = 0x2546,
BURNED_TOWER_1F = 0x2547,
BURNED_TOWER_B1F = 0x2548,
RADIO_TOWER_5F = 0x2549,
RUINS_OF_ALPH_OUTSIDE = 0x254A,
RUINS_OF_ALPH_RESEARCH_CENTER = 0x254B,
RUINS_OF_ALPH_INNER_CHAMBER = 0x254C,
MAHOGANY_MART_1F = 0x254D,
TEAM_ROCKET_BASE_B1F = 0x254E,
TEAM_ROCKET_BASE_B2F = 0x254F,
TEAM_ROCKET_BASE_B3F = 0x2550,
GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES = 0x2551,
SILVER_CAVE_ROOM_3 = 0x2552,
VICTORY_ROAD = 0x2553,
DRAGONS_DEN_B1F = 0x2554,
OLIVINE_PORT = 0x2555,
VERMILION_PORT = 0x2556,
FAST_SHIP_1F = 0x2557,
FAST_SHIP_B1F = 0x2558,
MOUNT_MOON_SQUARE = 0x2559,
},
}
Gen2Layout.crystal = {
@@ -96,6 +157,87 @@ Gen2Layout.crystal = {
wPlayerGender = 0x3E3D,
-- The 14 archived boxes, listed rather than strided (see BOX_COUNT).
boxes = { 0x4000, 0x4450, 0x48A0, 0x4CF0, 0x5140, 0x5590, 0x59E0, 0x6000, 0x6450, 0x68A0, 0x6CF0, 0x7140, 0x7590, 0x79E0 },
sceneVars = {
POKECENTER_2F = 0x2500,
TRADE_CENTER = 0x2501,
COLOSSEUM = 0x2502,
TIME_CAPSULE = 0x2503,
POWER_PLANT = 0x2504,
CERULEAN_GYM = 0x2505,
ROUTE_25 = 0x2506,
TRAINER_HOUSE_B1F = 0x2507,
VICTORY_ROAD_GATE = 0x2508,
SAFFRON_MAGNET_TRAIN_STATION = 0x2509,
ROUTE_16_GATE = 0x250A,
ROUTE_17_ROUTE_18_GATE = 0x250B,
INDIGO_PLATEAU_POKECENTER_1F = 0x250C,
WILLS_ROOM = 0x250D,
KOGAS_ROOM = 0x250E,
BRUNOS_ROOM = 0x250F,
KARENS_ROOM = 0x2510,
LANCES_ROOM = 0x2511,
HALL_OF_FAME = 0x2512,
ROUTE_27 = 0x2513,
NEW_BARK_TOWN = 0x2514,
ELMS_LAB = 0x2515,
PLAYERS_HOUSE_1F = 0x2516,
ROUTE_29 = 0x2517,
CHERRYGROVE_CITY = 0x2518,
MR_POKEMONS_HOUSE = 0x2519,
ROUTE_32 = 0x251A,
ROUTE_35_NATIONAL_PARK_GATE = 0x251B,
ROUTE_36 = 0x251C,
ROUTE_36_NATIONAL_PARK_GATE = 0x251D,
AZALEA_TOWN = 0x251E,
GOLDENROD_GYM = 0x251F,
GOLDENROD_MAGNET_TRAIN_STATION = 0x2520,
GOLDENROD_POKECENTER_1F = 0x2521,
OLIVINE_CITY = 0x2522,
ROUTE_34 = 0x2523,
ROUTE_34_ILEX_FOREST_GATE = 0x2524,
ECRUTEAK_TIN_TOWER_ENTRANCE = 0x2525,
WISE_TRIOS_ROOM = 0x2526,
ECRUTEAK_POKECENTER_1F = 0x2527,
ECRUTEAK_GYM = 0x2528,
MAHOGANY_TOWN = 0x2529,
ROUTE_42 = 0x252A,
CIANWOOD_CITY = 0x252B,
BATTLE_TOWER_1F = 0x252C,
BATTLE_TOWER_BATTLE_ROOM = 0x252D,
BATTLE_TOWER_ELEVATOR = 0x252E,
BATTLE_TOWER_HALLWAY = 0x252F,
BATTLE_TOWER_OUTSIDE = 0x2530,
ROUTE_43_GATE = 0x2531,
MOUNT_MOON = 0x2532,
SPROUT_TOWER_3F = 0x2533,
TIN_TOWER_1F = 0x2534,
BURNED_TOWER_1F = 0x2535,
BURNED_TOWER_B1F = 0x2536,
RADIO_TOWER_5F = 0x2537,
RUINS_OF_ALPH_OUTSIDE = 0x2538,
RUINS_OF_ALPH_RESEARCH_CENTER = 0x2539,
RUINS_OF_ALPH_HO_OH_CHAMBER = 0x253A,
RUINS_OF_ALPH_KABUTO_CHAMBER = 0x253B,
RUINS_OF_ALPH_OMANYTE_CHAMBER = 0x253C,
RUINS_OF_ALPH_AERODACTYL_CHAMBER = 0x253D,
RUINS_OF_ALPH_INNER_CHAMBER = 0x253E,
MAHOGANY_MART_1F = 0x253F,
TEAM_ROCKET_BASE_B1F = 0x2540,
TEAM_ROCKET_BASE_B2F = 0x2541,
TEAM_ROCKET_BASE_B3F = 0x2542,
GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES = 0x2543,
SILVER_CAVE_ROOM_3 = 0x2544,
VICTORY_ROAD = 0x2545,
DRAGONS_DEN_B1F = 0x2546,
DRAGON_SHRINE = 0x2547,
OLIVINE_PORT = 0x2548,
VERMILION_PORT = 0x2549,
FAST_SHIP_1F = 0x254A,
FAST_SHIP_B1F = 0x254B,
MOUNT_MOON_SQUARE = 0x254C,
MOBILE_TRADE_ROOM = 0x254D,
MOBILE_BATTLE_ROOM = 0x254E,
},
-- The backup copy the game falls back to when the primary
-- checksum fails. Same shape, shifted.
backup = {
@@ -143,6 +285,87 @@ Gen2Layout.crystal = {
wGameTimeMinutes = 0x1254,
wPlayerGender = 0x3E3D,
boxes = { 0x4000, 0x4450, 0x48A0, 0x4CF0, 0x5140, 0x5590, 0x59E0, 0x6000, 0x6450, 0x68A0, 0x6CF0, 0x7140, 0x7590, 0x79E0 },
sceneVars = {
POKECENTER_2F = 0x1700,
TRADE_CENTER = 0x1701,
COLOSSEUM = 0x1702,
TIME_CAPSULE = 0x1703,
POWER_PLANT = 0x1704,
CERULEAN_GYM = 0x1705,
ROUTE_25 = 0x1706,
TRAINER_HOUSE_B1F = 0x1707,
VICTORY_ROAD_GATE = 0x1708,
SAFFRON_MAGNET_TRAIN_STATION = 0x1709,
ROUTE_16_GATE = 0x170A,
ROUTE_17_ROUTE_18_GATE = 0x170B,
INDIGO_PLATEAU_POKECENTER_1F = 0x170C,
WILLS_ROOM = 0x170D,
KOGAS_ROOM = 0x170E,
BRUNOS_ROOM = 0x170F,
KARENS_ROOM = 0x1710,
LANCES_ROOM = 0x1711,
HALL_OF_FAME = 0x1712,
ROUTE_27 = 0x1713,
NEW_BARK_TOWN = 0x1714,
ELMS_LAB = 0x1715,
PLAYERS_HOUSE_1F = 0x1716,
ROUTE_29 = 0x1717,
CHERRYGROVE_CITY = 0x1718,
MR_POKEMONS_HOUSE = 0x1719,
ROUTE_32 = 0x171A,
ROUTE_35_NATIONAL_PARK_GATE = 0x171B,
ROUTE_36 = 0x171C,
ROUTE_36_NATIONAL_PARK_GATE = 0x171D,
AZALEA_TOWN = 0x171E,
GOLDENROD_GYM = 0x171F,
GOLDENROD_MAGNET_TRAIN_STATION = 0x1720,
GOLDENROD_POKECENTER_1F = 0x1721,
OLIVINE_CITY = 0x1722,
ROUTE_34 = 0x1723,
ROUTE_34_ILEX_FOREST_GATE = 0x1724,
ECRUTEAK_TIN_TOWER_ENTRANCE = 0x1725,
WISE_TRIOS_ROOM = 0x1726,
ECRUTEAK_POKECENTER_1F = 0x1727,
ECRUTEAK_GYM = 0x1728,
MAHOGANY_TOWN = 0x1729,
ROUTE_42 = 0x172A,
CIANWOOD_CITY = 0x172B,
BATTLE_TOWER_1F = 0x172C,
BATTLE_TOWER_BATTLE_ROOM = 0x172D,
BATTLE_TOWER_ELEVATOR = 0x172E,
BATTLE_TOWER_HALLWAY = 0x172F,
BATTLE_TOWER_OUTSIDE = 0x1730,
ROUTE_43_GATE = 0x1731,
MOUNT_MOON = 0x1732,
SPROUT_TOWER_3F = 0x1733,
TIN_TOWER_1F = 0x1734,
BURNED_TOWER_1F = 0x1735,
BURNED_TOWER_B1F = 0x1736,
RADIO_TOWER_5F = 0x1737,
RUINS_OF_ALPH_OUTSIDE = 0x1738,
RUINS_OF_ALPH_RESEARCH_CENTER = 0x1739,
RUINS_OF_ALPH_HO_OH_CHAMBER = 0x173A,
RUINS_OF_ALPH_KABUTO_CHAMBER = 0x173B,
RUINS_OF_ALPH_OMANYTE_CHAMBER = 0x173C,
RUINS_OF_ALPH_AERODACTYL_CHAMBER = 0x173D,
RUINS_OF_ALPH_INNER_CHAMBER = 0x173E,
MAHOGANY_MART_1F = 0x173F,
TEAM_ROCKET_BASE_B1F = 0x1740,
TEAM_ROCKET_BASE_B2F = 0x1741,
TEAM_ROCKET_BASE_B3F = 0x1742,
GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES = 0x1743,
SILVER_CAVE_ROOM_3 = 0x1744,
VICTORY_ROAD = 0x1745,
DRAGONS_DEN_B1F = 0x1746,
DRAGON_SHRINE = 0x1747,
OLIVINE_PORT = 0x1748,
VERMILION_PORT = 0x1749,
FAST_SHIP_1F = 0x174A,
FAST_SHIP_B1F = 0x174B,
MOUNT_MOON_SQUARE = 0x174C,
MOBILE_TRADE_ROOM = 0x174D,
MOBILE_BATTLE_ROOM = 0x174E,
},
},
}
+17
View File
@@ -385,6 +385,16 @@ local function decodeVariableSprites(bytes, at, count)
return out
end
-- data/maps/scenes.asm:7
local function decodeMapScenes(bytes, L)
local out = {}
for mapId, at in pairs(L.sceneVars or {}) do
local b = u8(bytes, at)
if b and b ~= 0 then out[mapId] = b end
end
return out
end
Gen2Save.NUM_SPECIES = 251
Gen2Save.EVENT_BYTES = 256
@@ -458,6 +468,7 @@ function Gen2Save.decode(bytes, gameVersion, data)
seen = decodeDex(bytes, L.wPokedexSeen, x),
},
events = decodeFlagBytes(bytes, L.wEventFlags, Gen2Save.EVENT_BYTES),
mapScenes = decodeMapScenes(bytes, L),
engineFlags = decodeEngineFlags(bytes, L, gameVersion),
variableSprites = decodeVariableSprites(bytes, L.wVariableSprites,
Gen2Save.VARIABLE_SPRITES),
@@ -784,6 +795,12 @@ function Gen2Save.encode(save, gameVersion, template, data)
local byte = (save.events or {})[i]
if type(byte) == "number" then putU8(t, L.wEventFlags + i, byte) end
end
if L.sceneVars and type(save.mapScenes) == "table" then
for mapId, at in pairs(L.sceneVars) do
local scene = tonumber(save.mapScenes[mapId])
if scene then putU8(t, at, math.floor(scene)) end
end
end
if type(save.engineFlags) == "table" then
putEngineFlags(t, L, save.engineFlags, gameVersion)
end
+16 -2
View File
@@ -111,6 +111,16 @@ function Commands.show_text(ctx, textId, subs, extraOpts)
return require("src.core.Sound").playCry(ctx.game.data, species)
end, delay = 0, wait = waitForButton } }
end
if ctx.pendingEngageMusic then
local song = ctx.pendingEngageMusic
ctx.pendingEngageMusic = nil
opts = opts or {}
opts.auto = opts.auto or { delay = 0, wait = true }
-- home/trainers.asm:399
opts.auto.afterSound = function()
require("src.core.Music").play(ctx.game.data, song)
end
end
-- text_opts armed the next box: auto = true is the plain no-button-wait
-- form, overlap folds under auto, everything else passes through
if ctx.textOpts then
@@ -676,8 +686,7 @@ end
-- (no A press) the instant the cry finishes, rather than firing immediately
-- alongside the typewriter effect. Script rows run strictly in order, so
-- this stashes the species on ctx for the show_text row that always
-- immediately follows it (Power Plant Zapdos, Seafoam Articuno, Victory
-- Road Moltres, Cerulean Cave Mewtwo battle text) to play once its box is
-- immediately follows it to play once its box is
-- done typing (see show_text's opts.auto). Headless-safe no-op there.
--
-- waitForButton is the pet-NPC form (scripts/PewterNidoranHouse.asm and
@@ -691,6 +700,11 @@ function Commands.play_cry(ctx, species, waitForButton)
ctx.pendingCryWait = waitForButton or nil
end
-- home/trainers.asm:327
function Commands.engage_music(ctx, songId)
ctx.pendingEngageMusic = songId
end
-- mark_seen <species>: DisplayPokedex (pokedex.asm) records the species as
-- seen before opening its entry. Map scripts use this for NPC-driven
-- Pokédex previews that do not begin a battle or give the player a Pokémon.
+41 -2
View File
@@ -177,9 +177,14 @@ function IntroMovie.new(game, onDone)
self.smallStar = img(intro.fallingStar)
self.smallStarBlink = img(intro.fallingStarBlink)
self.gengarFrames, self.nidoFrames = {}, {}
self.gengarPaths, self.nidoPaths = {}, {}
for i = 1, 3 do
self.gengarFrames[i] = img(intro.gengar and intro.gengar["frame" .. i])
self.nidoFrames[i] = img(intro.nidorino and intro.nidorino["frame" .. i])
local g = intro.gengar and intro.gengar["frame" .. i]
local n = intro.nidorino and intro.nidorino["frame" .. i]
self.gengarFrames[i] = img(g)
self.nidoFrames[i] = img(n)
self.gengarPaths[i] = g and g.path
self.nidoPaths[i] = n and n.path
end
-- fight state (PlayIntroScene entry, intro.asm:30-39): Gengar BG pose at
@@ -418,6 +423,40 @@ function IntroMovie:drawFight()
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(1, 1, 1, 1)
end
self:replayObjLayer(nido, gengar)
end
local whitePixel
local FIGHT_CLIP = { 0, 32, 160, 80 }
function IntroMovie:replayObjLayer(nido, gengar)
local P = require("src.render.PaletteFX")
local nidoPath = self.nidoPaths and self.nidoPaths[self.nidoFrame]
if not (nido and nidoPath and P.usesSpriteObp()) then return end
local SR = require("src.render.SpriteRenderer")
local blue = GameVersion.isBlue()
local opts = { clip = FIGHT_CLIP }
-- engine/movie/intro.asm:28
P.markUiSpriteRedraw(SR.obpImage(nidoPath,
blue and P.GBC_OBJ_BLUE or P.GBC_OBJ, blue and "gbcobj_blue" or "gbcobj"),
nil, self.nidoX, self.nidoY, opts)
local gengarPath = self.gengarPaths[self.gengarPose]
if gengar and gengarPath then
-- engine/movie/intro.asm:195
P.markUiSpriteRedraw(SR.obpImage(gengarPath, P.ogBg(),
blue and "ogbg_blue" or "ogbg"), nil, self.gengarX, self.gengarY, opts)
end
if self.fade > 0 then
if not whitePixel then
local id = love.image.newImageData(1, 1)
id:setPixel(0, 0, 1, 1, 1, 1)
whitePixel = love.graphics.newImage(id)
end
P.markUiSpriteRedraw(whitePixel, nil, FIGHT_CLIP[1], FIGHT_CLIP[2],
{ clip = FIGHT_CLIP, sx = FIGHT_CLIP[3], sy = FIGHT_CLIP[4],
color = { 1, 1, 1, math.min(1, self.fade) } })
end
end
function IntroMovie:drawCopyPrefix(x, y)
+23 -3
View File
@@ -175,6 +175,8 @@ function PartyMenu.mirrorsIcon(name)
end
local iconImages = {}
-- engine/items/town_map.asm:514
local OAM_XFLIP = { sx = -1 }
-- Party icons are OBJs (engine/gfx/mon_icons.asm WriteMonPartySpriteOAM
-- writes OAM blocks), so they render through OBP0, and GBPalNormal
@@ -249,13 +251,21 @@ function PartyMenu.drawIcon(game, mon, x, y, selected, counter, forceAlt)
-- overrides `name`, because a pokemon.icon hook can substitute full-colour
-- art for a path that resolved to a built-in class and still carries one.
local baked = name ~= nil and not trueColor
local key = baked and (path .. "#obp") or path
local PaletteFX = require("src.render.PaletteFX")
local ogColors, ogGroup
if baked and PaletteFX.usesSpriteObp() then
ogColors, ogGroup = PaletteFX.ogObjNormal()
end
local key = baked and (path .. "#obp" .. (ogGroup or "")) or path
if iconImages[key] == nil then
-- resolve through Assets so an overrides/ or transform-derived icon
-- (e.g. a per-species image at assets/generated/icons/<name>.png) is
-- picked up the same way battle sprites are
local ok, img
if baked then
if ogColors then
ok, img = pcall(require("src.render.SpriteRenderer").obpImage,
path, ogColors, ogGroup)
elseif baked then
ok, img = pcall(obpIcon, path)
else
ok, img = pcall(love.graphics.newImage, Assets.resolve(path))
@@ -292,12 +302,19 @@ function PartyMenu.drawIcon(game, mon, x, y, selected, counter, forceAlt)
-- sx = -1 about the block's right edge, so the flipped copy lands on
-- x+8..x+16: the OAM_XFLIP half
love.graphics.draw(img, half, x + 16, y, 0, -1, 1)
if ogColors then
PaletteFX.markUiSpriteRedraw(img, half, x, y)
PaletteFX.markUiSpriteRedraw(img, half, x + 16, y, OAM_XFLIP)
end
elseif ih > 16 then
love.graphics.draw(img, love.graphics.newQuad(0, frame * 16, 16, 16, iw, ih), x, y)
local quad = love.graphics.newQuad(0, frame * 16, 16, 16, iw, ih)
love.graphics.draw(img, quad, x, y)
if ogColors then PaletteFX.markUiSpriteRedraw(img, quad, x, y) end
else
-- HELIX and any mod art that is a single frame: drawn whole, at
-- whatever size the file is (unchanged path)
love.graphics.draw(img, x, y)
if ogColors then PaletteFX.markUiSpriteRedraw(img, nil, x, y) end
end
-- Report the covering rect so Renderer:endFrame can re-blit it unshaded
-- over the colorized pass. Every branch above lays a frame into a 16x16
@@ -531,6 +548,7 @@ function PartyMenu:update(dt)
refuseBadge(self)
return
end
self.submenu = nil -- engine/menus/start_sub_menus.asm:66
ow:useFlashFieldMove(function() self:close() end)
return
elseif action == "surf" then
@@ -549,6 +567,7 @@ function PartyMenu:update(dt)
-- GBPalWhiteOutWithDelay3 + jp .goBackToMap only follow it, so
-- trySurf closes this menu when its text does (#385)
local fx, fy = ow.player:facingCell()
self.submenu = nil -- engine/menus/start_sub_menus.asm:66
ow:trySurf(fx, fy, function() self:close() end)
return
end
@@ -621,6 +640,7 @@ function PartyMenu:update(dt)
refuseBadge(self)
return
end
self.submenu = nil -- engine/menus/start_sub_menus.asm:66
ow:useStrengthFieldMove(mon, function() self:close() end)
return
elseif ow and ow.useFieldMove then
+111 -55
View File
@@ -570,6 +570,15 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
-- ponytail: re-derived rather than persisted.
self:syncSurfingPikachu()
end
local dungeonWarp = opts and opts.dungeonWarp
if dungeonWarp then
-- home/overworld.asm:788
self.player.surfing = false
Game.save.onBike = false
Game.save.forcedBike = nil
self:syncSurfingPikachu()
end
self.pendingEnterMapTail = dungeonWarp or nil
-- crossConnection re-arms this after setMap; clear so a warp/reload
-- cannot leave a stale deferred PlayMapMusic pending
self.pendingSeamMusic = nil
@@ -589,6 +598,8 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
self.keepMusicOnce = nil
-- home/overworld.asm ln 2340, player_animations.asm ln 64
if opts and opts.via == "fly" then keepMusic = true end
-- home/overworld.asm:2341
if dungeonWarp then keepMusic = true end
if not keepMusic then
-- ..(home/overworld.asm ln 2346)
local Music = require("src.core.Music")
@@ -611,13 +622,17 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
-- (home/overworld.asm) -- a warp can land directly on one (the Route
-- 16/18 gate exits), and the scripted door-mat walkout that follows
-- suppresses onStepComplete, so waiting for a plain step never mounts
if not (opts and opts.checkpoint) then self:checkForcedMovement() end
if not (opts and (opts.checkpoint or opts.dungeonWarp)) then
self:checkForcedMovement()
end
-- Seafoam B4F's map script pushes off the B3F stair warps every frame
-- while the upper plugs are out (SeafoamIslandsB4FDefaultScript); the
-- B3F/B4F force-surf mouths also arm their MOVE_OBJECT current scripts
-- from CheckForceBikeOrSurf. Re-check here so a warp-in does not sit
-- idle on those cells waiting for a player step.
if not (opts and opts.checkpoint) then self:checkSeafoamCurrent() end
if not (opts and (opts.checkpoint or opts.dungeonWarp)) then
self:checkSeafoamCurrent()
end
-- snap the camera immediately: the overworld doesn't update while a
-- Transition is on top, so a stale camera would show the new map at
@@ -1208,6 +1223,10 @@ function OverworldState:update(dt)
if self.dustAnim then
local da = self.dustAnim
da.frames = da.frames - 1
if da.step then
local k = math.min(8, math.floor((da.total - da.frames) / 3) + 1)
da.ox, da.oy, da.faded = da.step[1] * k, da.step[2] * k, k % 2 == 1
end
if da.frames <= 0 then
self.dustAnim = nil
if da.onDone then da.onDone() end
@@ -1331,11 +1350,6 @@ function OverworldState:update(dt)
Game.data, self.map.id, Game.save.onBike, self.player.surfing, nil)
end
end
if self.spinArrive and not self.player.spinFrames then
self.spinArrive = nil
self.player.inputLocked = false
end
-- EnterMapAnim's .done tail re-enables the companion once the swoop or the
-- spin-down has landed (player_animations.asm:40)
if self.pikachuWarpHidden and not (self.flyAnim or self.flyArrive
@@ -1389,18 +1403,20 @@ function OverworldState:update(dt)
self.player.holeSink = nil
self.playerHidden = true
self.arriveWarp = "hole"
self:startWarpTo(f.map, f.x, f.y, f.facing)
self:startWarpTo(f.map, f.x, f.y, f.facing, nil, { dungeonWarp = true })
return
end
end
-- offscreen, then spins him down -- player_animations.asm:41-45
-- offscreen, then drops him down -- player_animations.asm:41-45
if self.holeArrive then
self.holeArrive.frames = self.holeArrive.frames - 1
if self.holeArrive.frames <= 0 then
self.holeArrive = nil
self.playerHidden = false
self.player.spinning = true
-- engine/overworld/player_animations.asm:90
self.player.spinImageIndex = 3
self.player.spinTimer = 0
self.player.spinFrames = HOLE_IN_FRAMES
self.player.spinTotal = HOLE_IN_FRAMES
@@ -1462,6 +1478,7 @@ function OverworldState:update(dt)
or self.flyAnim or self.flyArrive or self.spinArrive
or self.holeFall or self.holeArrive
or self.holeFall or self.holeArrive
or (self.dustAnim and self.dustAnim.boulder)
if not scripted and not self.transitioning then
self:checkTrainerSight()
-- CheckFightingMapTrainers (home/trainers.asm) zeroes hJoyHeld and
@@ -1473,6 +1490,7 @@ function OverworldState:update(dt)
or self.engaging or self.emote or self.teleportOut
or self.flyAnim or self.flyArrive or self.spinArrive
or self.holeFall or self.holeArrive
or (self.dustAnim and self.dustAnim.boulder)
end
-- a scriptMove's onDone can push a text box on the frame it retires, and
-- DisplayTextID owns the loop from there (home/text_script.asm:3)
@@ -1482,6 +1500,17 @@ function OverworldState:update(dt)
if (self.hopLand or 0) > 0 then self.hopLand = self.hopLand - 1 end
local stepped = self.player:update()
-- engine/overworld/player_animations.asm:329
if self.spinArrive and not self.player.spinFrames then
self.spinArrive = nil
self.player.inputLocked = false
if self.pendingEnterMapTail then
self.pendingEnterMapTail = nil
-- home/overworld.asm:31
self:checkForcedMovement()
self:checkSeafoamCurrent()
end
end
-- the warp-arrival cell goes stale the instant the player's real cell
-- leaves it, scripted walk-outs included -- pokered re-checks warps
-- after simulated steps too (CheckWarpsNoCollision), so a forced
@@ -1624,6 +1653,7 @@ function OverworldState:handleInput()
if self:checkBoulderPush(dir) then return end
end
local result = self.player:tryMove(dir, self.map, self.entities)
if result == "moved" then self:stopSurfingOntoLand() end
-- a collision while standing on a warp square fires the warp when the
-- extra check passes (CheckWarpsCollision: route-gate doorways, dock
-- entrances, ...), and only while BIT_STANDING_ON_WARP is set (issue
@@ -1740,9 +1770,9 @@ function OverworldState:checkBoulderPush(dir)
self:scriptMove(npc, dir, 1, function()
self.boulderTried = nil
-- dust smoke + SFX_CUT once the boulder settles (DoBoulderDustAnimation)
self:startDustAnim(fx, fy, function()
self:startDustAnim(bx, by, function()
require("src.core.Sound").play(Game.data, "Cut")
end)
end, dir)
if self:boulderIntoHole(npc) then return end
Runtime.emit("world.boulder_moved", { mapId = self.map.id, npcId = npc.id,
x = npc.cellX, y = npc.cellY })
@@ -1754,11 +1784,18 @@ function OverworldState:checkBoulderPush(dir)
return true
end
-- The dust puff (engine/overworld/dust_smoke.asm AnimateBoulderDust):
-- the 8x8 smoke tile drawn as a 2x2 block over the vacated cell,
-- flickering for 8 steps of ~4 frames.
function OverworldState:startDustAnim(cx, cy, onDone)
self.dustAnim = { x = cx, y = cy, frames = 32, onDone = onDone }
-- engine/overworld/dust_smoke.asm:1
function OverworldState:startDustAnim(cx, cy, onDone, dir)
-- engine/overworld/dust_smoke.asm:59
local step = ({ down = { 0, -1 }, up = { 0, 1 },
left = { 1, 0 }, right = { -1, 0 } })[dir or ""]
if step then
self.dustAnim = { x = cx, y = cy, frames = 24, total = 24, step = step,
boulder = true, ox = step[1], oy = step[2], faded = true,
onDone = onDone }
else
self.dustAnim = { x = cx, y = cy, frames = 32, onDone = onDone }
end
end
-- scripts/VermilionDock.asm:39 VermilionDockSSAnneLeavesScript: snapshot her
@@ -2000,6 +2037,7 @@ function OverworldState:crossConnection(dir, conn)
-- (mid-cycle stand phase would otherwise look like a slide)
p.animClock = 0
p.stepFramesCur = p:stepLength(dir)
self:stopSurfingOntoLand()
require("src.core.FixedStep"):discardCatchup()
return true
end
@@ -2600,24 +2638,10 @@ function OverworldState:tryHiddenObject(fx, fy)
end
end
for _, h in ipairs(field.hiddenCoins and field.hiddenCoins[self.map.id] or {}) do
if h.x == fx and h.y == fy then
save.hiddenTaken = save.hiddenTaken or {}
if save.hiddenTaken[key] then return false end
if not save.inventory.COIN_CASE then return false end
save.hiddenTaken[key] = true
local paid = OverworldState.hiddenCoinPayout(h.coins)
save.coins = math.min(9999, (save.coins or 0) + paid)
Game.stack:push(TextBox.new(Game,
Strings("%s found\n%d coins!", save.player.name, paid),
nil, TextBox.soundOpts(Game, "Get_Item2")))
return true
end
end
-- broken-machine and can't-play texts are pokered's exact strings
-- (_GameCornerOutOfOrderText etc., data/text/text_2.asm)
local txt = Game.data.text or {}
-- data/events/hidden_events.asm:262
for seatIndex, h in ipairs(field.slotMachines and field.slotMachines[self.map.id] or {}) do
if h.x == fx and h.y == fy then
if h.state == "out_of_order" then
@@ -2657,6 +2681,21 @@ function OverworldState:tryHiddenObject(fx, fy)
end
end
for _, h in ipairs(field.hiddenCoins and field.hiddenCoins[self.map.id] or {}) do
if h.x == fx and h.y == fy then
save.hiddenTaken = save.hiddenTaken or {}
if save.hiddenTaken[key] then return false end
if not save.inventory.COIN_CASE then return false end
save.hiddenTaken[key] = true
local paid = OverworldState.hiddenCoinPayout(h.coins)
save.coins = math.min(9999, (save.coins or 0) + paid)
Game.stack:push(TextBox.new(Game,
Strings("%s found\n%d coins!", save.player.name, paid),
nil, TextBox.soundOpts(Game, "Get_Item2")))
return true
end
end
-- Bill's cell-separator PC (data/events/hidden_events.asm: hidden_event
-- 1,4 BillsHousePC SPRITE_FACING_UP)
if self.map.id == "BILLS_HOUSE" and fx == 1 and fy == 4
@@ -3087,24 +3126,25 @@ function OverworldState:trySurf(fx, fy, onClose)
-- (start_sub_menus.asm .surf), so the text reads over the menu and the
-- blink is the menu closing, not a flashbang on the empty map (#320,
-- #385). The mount rides the blink, so nothing paddles on land.
-- walking / biking / surfing is ONE state byte in the original:
-- ItemUseSurfboard (engine/items/item_effects.asm) writes 2 over
-- whatever wWalkBikeSurfState held, so mounting a surf ends the bike
-- outright -- no bike step cadence in Player:tryMove and no bike
-- theme on the water (#846). Music.playMap re-picks the override
-- with BOTH flags, which setSurfing alone cannot do: effectiveMapSong
-- (src/core/Music.lua) prefers state.onBike over state.surfing.
Game.save.onBike = false
local Music = require("src.core.Music")
-- engine/items/item_effects.asm:686
if self.map then
Music.playMap(Game.data, self.map.id, false, true)
else
Music.setSurfing(Game.data, true)
end
Game.stack:push(TextBox.new(Game, text, function()
if onClose then onClose() end
p.surfing = true
-- walking / biking / surfing is ONE state byte in the original:
-- ItemUseSurfboard (engine/items/item_effects.asm) writes 2 over
-- whatever wWalkBikeSurfState held, so mounting a surf ends the bike
-- outright -- no bike step cadence in Player:tryMove and no bike
-- theme on the water (#846). Music.playMap re-picks the override
-- with BOTH flags, which setSurfing alone cannot do: effectiveMapSong
-- (src/core/Music.lua) prefers state.onBike over state.surfing.
Game.save.onBike = false
self:syncSurfingPikachu()
local Music = require("src.core.Music")
if self.map then
Music.playMap(Game.data, self.map.id, false, true)
else
Music.setSurfing(Game.data, true) -- headless harness with no map loaded
end
Game.stack:push(require("src.render.Transition").whiteFlash(Game, nil,
function() self:stepForwardOrCrossEdge(p.facing) end))
end))
@@ -3119,6 +3159,16 @@ function OverworldState:stopSurfing(onClose)
end))
end
-- home/overworld.asm:1934
function OverworldState:stopSurfingOntoLand()
local p = self.player
if not (p.surfing and p.moving and p.targetX) then return end
if not self.map:isWalkableCell(p.targetX, p.targetY) then return end
p.surfing = false
self:syncSurfingPikachu()
require("src.core.Music").setSurfing(Game.data, false)
end
function OverworldState:tryCut(fx, fy)
-- UsedCut (engine/overworld/cut.asm) gates on the TILESET before
-- anything else: only OVERWORLD (tree tile $3d) and GYM (plant tile
@@ -4636,7 +4686,7 @@ function OverworldState:checkSpinner()
return false
end
function OverworldState:runSpinnerMoves(moves, i)
function OverworldState:runSpinnerMoves(moves, i, noSpin)
local mv = moves[i]
if not mv then
self.player.spinning = false
@@ -4648,11 +4698,13 @@ function OverworldState:runSpinnerMoves(moves, i)
self:onStepComplete()
return
end
self.player.spinning = true -- spin the sprite while sliding
-- home/overworld.asm:268-273
self.spinnerSliding = true
if not noSpin then
self.player.spinning = true
-- home/overworld.asm:268-273
self.spinnerSliding = true
end
self:scriptMove(self.player, mv.dir, mv.count, function()
self:runSpinnerMoves(moves, i + 1)
self:runSpinnerMoves(moves, i + 1, noSpin)
end)
end
@@ -4856,7 +4908,7 @@ function OverworldState:checkSeafoamCurrent()
"setsForcedWarp") then
self.forcedWarp = true
end
self:runSpinnerMoves(c.moves, 1)
self:runSpinnerMoves(c.moves, 1, true)
return true
end
end
@@ -5869,9 +5921,10 @@ function OverworldState:drawWorld()
end
if self.smokeImg then
local da = self.dustAnim
local dx = da.x * 16 - cam.x
local dy = da.y * 16 - cam.y
local dx = da.x * 16 + (da.ox or 0) - cam.x
local dy = da.y * 16 + (da.oy or 0) - cam.y
local flicker = math.floor(da.frames / 4) % 2 == 0
if da.boulder then flicker = not da.faded end
love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55)
for i = 0, 1 do
for j = 0, 1 do
@@ -6105,7 +6158,8 @@ function OverworldState:drawWorld()
end
-- ground-hugging effects sit on the cell they belong to
if self.dustAnim then
at(fxDust, self.dustAnim.x * 16 + 8, self.dustAnim.y * 16 + 8)
local da = self.dustAnim
at(fxDust, da.x * 16 + 8 + (da.ox or 0), da.y * 16 + 8 + (da.oy or 0))
end
if self.cutAnim then
at(fxCutTree, self.cutAnim.x * 16 + 8, self.cutAnim.y * 16 + 16)
@@ -6157,6 +6211,8 @@ function OverworldState:drawWorld()
-- the final one.
local grassColors = PaletteFX.usesSpriteObp()
and PaletteFX.pal(Game.data, self:paletteNameFor(self.map)) or nil
local boulderDust = self.dustAnim and self.dustAnim.boulder
if boulderDust then fxDust() end
for _, g in ipairs(self.ghosts) do
if self.battleOamKeep == nil then
g.npc:draw(cam.x - g.ox, cam.y - g.oy)
@@ -6186,7 +6242,7 @@ function OverworldState:drawWorld()
end
end
fxHeal()
fxDust()
if not boulderDust then fxDust() end
fxCutTree()
fxEmote()
fxBird()
+10
View File
@@ -211,6 +211,7 @@ function Player:update()
self.spinHold = nil
self.spinRiseFrom = nil
self.spinDropSteps = nil
self.spinImageIndex = nil
end
end
-- wall-bonk walk-in-place (issue #230): while pushing into a wall the
@@ -275,6 +276,8 @@ function Player:walkPhase()
end
local SPIN_ORDER = { "down", "left", "up", "right" }
-- constants/sprite_data_constants.asm:3
local IMAGE_FACING = { [0] = "down", [1] = "up", [2] = "left", [3] = "right" }
-- What this frame renders to: the sheet, where it sits, which way it faces
-- and how far through a step it is. Shared by the 2D draw below and by a
@@ -311,6 +314,13 @@ function Player:pose()
local step = self.spinStep or 0
facing = SPIN_ORDER[step % 4 + 1]
phase, flip = 0, false
-- engine/overworld/player_animations.asm:286
local img = self.spinImageIndex
if img then
local frame = img % 4
facing = IMAGE_FACING[math.floor(img / 4) % 4]
phase, flip = frame % 2, frame == 3
end
-- engine/overworld/player_animations.asm:319
if self.spinRiseFrom and step > self.spinRiseFrom then
py = py - (step - self.spinRiseFrom) * 16
+150
View File
@@ -0,0 +1,150 @@
-- engine/overworld/dust_smoke.asm:1
-- POKEPORT_DRIVER=tests/drivers/boulder_dust_bug2263.lua POKEPORT_IDENTITY=red-sep04 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
local MAP = "SEAFOAM_ISLANDS_1F"
local DIRS = { "right", "left", "down", "up" }
local STEP = { down = { 0, 1 }, up = { 0, -1 }, left = { -1, 0 }, right = { 1, 0 } }
local ok = true
local function check(label, pass)
U.log(pass and "PASS" or "FAIL", label)
if not pass then ok = false end
return pass
end
local function finish()
for _, d in ipairs(DIRS) do game.input.state[d] = false end
U.log(ok and "PASS boulder_dust_2263" or "FAIL boulder_dust_2263")
love.event.quit(ok and 0 or 1)
while true do coroutine.yield() end
end
U.teleport(game, MAP, 17, 10, "right")
U.wait(10)
local ow = game.overworld
game.data.encounters[MAP] = nil
local rock = ow and ow:npcAtCell(18, 10)
if not check("boulder present at (18,10) on " .. MAP,
rock ~= nil and rock.def.sprite == "SPRITE_BOULDER") then
finish()
end
ow.strengthActive = true
local function free(x, y)
if not (ow.map:inBounds(x, y) and ow.map:isWalkableCell(x, y)) then return false end
local n = ow:npcAtCell(x, y)
return n == nil or n == rock
end
local cx, cy
for y = 9, 40 do
for x = 0, 60 do
if not cx and free(x, y) then
local all = true
for _, d in ipairs(DIRS) do
local s = STEP[d]
if not (free(x + s[1], y + s[2]) and free(x + 2 * s[1], y + 2 * s[2])) then
all = false
end
end
if all then cx, cy = x, y end
end
end
end
if not check("found an open cross for pushes in all four directions", cx ~= nil) then
finish()
end
U.log("push center", cx, cy)
local function setup(dir)
for _, d in ipairs(DIRS) do game.input.state[d] = false end
ow.dustAnim = nil
ow.boulderTried = nil
local s = STEP[dir]
rock.cellX, rock.cellY = cx + s[1], cy + s[2]
rock.px, rock.py = rock.cellX * 16, rock.cellY * 16
rock.moving, rock.targetX, rock.targetY = false, nil, nil
local p = ow.player
p.cellX, p.cellY = cx, cy
p.px, p.py = cx * 16, cy * 16
p.moving, p.targetX, p.targetY = false, nil, nil
p.facing = dir
U.wait(8)
end
local function pushUntilDust(dir)
local first = true
for _ = 1, 300 do
if ow.dustAnim and ow.dustAnim.boulder then return ow.dustAnim end
if first then table.insert(game.input.pressQueue, dir); first = false end
game.input.state[dir] = true
U.wait(1)
end
return nil
end
local marks = {}
for _, dir in ipairs(DIRS) do
setup(dir)
local s = STEP[dir]
local da = pushUntilDust(dir)
if check(dir .. ": boulder dust starts after the push", da ~= nil) then
check(dir .. ": dust anchored on the boulder's new cell",
da.x == cx + 2 * s[1] and da.y == cy + 2 * s[2])
check(dir .. ": first step already 1px toward the player, faded",
da.ox == -s[1] and da.oy == -s[2] and da.faded == true)
local n, lockHeld, maxStep, order = 0, true, 0, true
local m = {}
while ow.dustAnim == da and n < 120 do
local k = math.abs(da.ox) + math.abs(da.oy)
if k < maxStep then order = false end
maxStep = math.max(maxStep, k)
if not m[k] then m[k] = n end
if ow.player.moving or ow.player.cellX ~= cx or ow.player.cellY ~= cy then
lockHeld = false
end
game.input.state[dir] = true
n = n + 1
U.wait(1)
end
game.input.state[dir] = false
marks[dir] = m
U.log(dir, "dust frames", n)
check(dir .. ": dust lasts 24 frames (8 x Delay3)", n == 24)
check(dir .. ": dust slides 1px per step, 8px total", order and maxStep == 8)
check(dir .. ": player cannot step while the dust plays", lockHeld)
end
U.wait(20)
end
for _, dir in ipairs(DIRS) do
local m = marks[dir]
if m then
setup(dir)
local da = pushUntilDust(dir)
if da then
local t0 = U.frame()
U.shot(game, ("%s/2263_%s_01_dust_step1_under_boulder.png"):format(DIR, dir))
while ow.dustAnim == da and U.frame() - t0 < (m[4] or 9) do
game.input.state[dir] = true
U.wait(1)
end
U.shot(game, ("%s/2263_%s_02_dust_mid_slide.png"):format(DIR, dir))
while ow.dustAnim == da and U.frame() - t0 < (m[8] or 21) do
game.input.state[dir] = true
U.wait(1)
end
U.shot(game, ("%s/2263_%s_03_dust_half_over_old_cell.png"):format(DIR, dir))
game.input.state[dir] = false
end
U.wait(40)
end
end
ow:startDustAnim(cx, cy, nil)
check("cut puff keeps its static 32-frame timing",
ow.dustAnim.frames == 32 and ow.dustAnim.ox == nil and not ow.dustAnim.boulder)
ow.dustAnim = nil
finish()
end
@@ -145,8 +145,8 @@ return function(game)
-- around to its south side
holdUntil("down", playerAt(18, 7), 120)
holdUntil("left", playerAt(17, 7), 120)
-- up column 17 onto her row
holdUntil("up", boulderAt(17, 3), 400)
-- up column 17 onto her row -- engine/overworld/push_boulder.asm:66
holdUntil("up", playerAt(17, 4), 400)
check("boulder pushed up column 17 onto row 3 at (17,3)",
rock.cellX == 17 and rock.cellY == 3)
if not pass then
+26 -6
View File
@@ -118,11 +118,8 @@ return function(game)
check(engaged, "BUG1: Karate Master stops the player at his left")
------------------------------------------------------------------
-- BUG3: talk to the already-beaten master -> "Stay and train..." and
-- NOT the "I am the LEADER here!" pre-battle challenge.
-- ../pokered/scripts/FightingDojo.asm:106-136
------------------------------------------------------------------
-- talk from (4,3) facing right: beside the master (5,3), off his DOWN
-- sight line so he can't (post-fix) aggro before we set him defeated
ow = resetDojo(4, 3, "right", { EVENT_BEAT_KARATE_MASTER = true })
game.save.defeatedTrainers["FIGHTING_DOJO_obj_1"] = true
local master = npcByName(ow, "FIGHTINGDOJO_KARATE_MASTER")
@@ -131,9 +128,23 @@ return function(game)
local first = waitReadyPage()
check(not first:find("LEADER", 1, true) and not first:find("Grunt", 1, true),
"BUG3: beaten master no longer shows the challenge (page1='" .. first .. "')")
check(first:find("Indeed, I have", 1, true) ~= nil,
"#2253: beaten master before a prize repeats 'Indeed, I have lost!' (page1='" .. first .. "')")
check(not first:find("Stay and train", 1, true),
"#2253: no 'Stay and train' before a prize is chosen")
U.shot(game, DIR .. "/2253_01_master_prize_offer_before_choosing.png")
mashUntil(function() return game.stack:top() == ow end)
end
ow = resetDojo(4, 3, "right",
{ EVENT_BEAT_KARATE_MASTER = true, EVENT_DEFEATED_FIGHTING_DOJO = true })
game.save.defeatedTrainers["FIGHTING_DOJO_obj_1"] = true
master = npcByName(ow, "FIGHTINGDOJO_KARATE_MASTER")
if check(master ~= nil, "#2253: master npc present after the prize") then
ow:talkTo(master)
check(sawText("Stay and train"),
"BUG3: beaten master says 'Stay and train at Karate with us!'")
U.shot(game, DIR .. "/dojo_3_retalk.png")
"#2253: after the prize the master says 'Stay and train at Karate with us!'")
U.shot(game, DIR .. "/2253_02_master_stay_and_train_after_prize.png")
mashUntil(function() return game.stack:top() == ow end)
end
@@ -184,6 +195,15 @@ return function(game)
U.shot(game, DIR .. "/dojo_5_greedy.png")
mashUntil(function() return game.stack:top() == ow end)
end
local flowMaster = npcByName(ow, "FIGHTINGDOJO_KARATE_MASTER")
if check(flowMaster ~= nil, "#2253: master still present after taking a prize") then
ow:talkTo(flowMaster)
local page = waitReadyPage()
check(page:find("Ho!", 1, true) ~= nil or page:find("Stay and train", 1, true) ~= nil,
"#2253: after taking HITMONLEE the master says 'Stay and train' (page1='" .. page .. "')")
U.shot(game, DIR .. "/2253_03_master_after_taking_hitmonlee.png")
mashUntil(function() return game.stack:top() == ow end)
end
end
------------------------------------------------------------------
@@ -0,0 +1,42 @@
-- POKEPORT_IDENTITY=red-sep04 POKEPORT_DRIVER=tests/drivers/game_corner_coin_seat_bug2272.lua POKEPORT_TOUCH=0 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
local failed = false
local function check(label, ok)
print((ok and "PASS " or "FAIL ") .. label)
if not ok then failed = true end
return ok
end
local KEY = "GAME_CORNER_12_15"
U.teleport(game, "GAME_CORNER", 12, 16, "up")
U.wait(20)
local save = game.save
save.inventory.COIN_CASE = 1
save.coins = 50
save.hiddenTaken = save.hiddenTaken or {}
save.hiddenTaken[KEY] = nil
U.shot(game, DIR .. "/2272_01_facing_seat_12_15.png")
local ow = game.stack:top()
U.tap(game, "a")
local frames = 0
for _ = 1, 120 do
if game.stack:top() ~= ow then break end
U.wait(1)
frames = frames + 1
end
local box = game.stack:top()
check("2272 seat 12_15 opens a box", box ~= ow)
check("2272 seat 12_15 is the slot prompt", type(box) == "table" and type(box.choice) == "function")
check("2272 seat 12_15 pays no coins", save.coins == 50)
check("2272 seat 12_15 sets no hidden coin flag", not save.hiddenTaken[KEY])
U.wait(frames + 60)
U.shot(game, DIR .. "/2272_02_slot_prompt_not_coins.png")
love.event.quit(failed and 1 or 0)
end
@@ -0,0 +1,109 @@
-- POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-sep04 POKEPORT_DRIVER=tests/drivers/gold_import_scenes_bug2245.lua love .
local U = require("tests.drivers.util")
local Gen2Layout = require("src.save_convert.Gen2Layout")
local SaveConvert = require("src.save_convert.SaveConvert")
local SIZE = 32768
local function buildCart(maps)
local L = Gen2Layout.goldSilver
local b = {}
for i = 0, SIZE - 1 do b[i] = 0 end
local name = { 0x86, 0x8E, 0x8B, 0x83, 0x50 }
for i, c in ipairs(name) do b[L.wPlayerName + i - 1] = c end
local lab = assert(maps and maps.ELMS_LAB, "no ELMS_LAB in this cache")
b[L.wMapGroup], b[L.wMapNumber] = lab.group, lab.map
b[L.wXCoord], b[L.wYCoord] = 4, 8
b[L.wPartySpecies] = 0xFF
b[L.wItems], b[L.wKeyItems], b[L.wBalls] = 0xFF, 0xFF, 0xFF
for _, base in ipairs(L.boxes) do b[base + 1] = 0xFF end
-- engine/events/std_scripts.asm:557
b[L.wEventFlags + 6] = 0x40
-- maps/VictoryRoad.asm:58
b[L.wEventFlags + 216] = 0x04
-- data/maps/scenes.asm:7
b[L.sceneVars.ELMS_LAB] = 2
b[L.sceneVars.NEW_BARK_TOWN] = 1
b[L.sceneVars.ROUTE_27] = 1
b[L.sceneVars.VICTORY_ROAD] = 1
b[L.sCheckValue1], b[L.sCheckValue2] = 0x63, 0x7F
local sum = 0
for i = L.sGameData, L.sGameDataEnd - 1 do sum = (sum + b[i]) % 65536 end
b[L.sChecksum], b[L.sChecksum + 1] = sum % 256, math.floor(sum / 256)
local out = {}
for i = 0, SIZE - 1 do out[i + 1] = string.char(b[i]) end
return table.concat(out)
end
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-import-scenes-2245"
local failed = 0
local function pass(ok, line)
if not ok then failed = failed + 1 end
U.log((ok and "PASS " or "FAIL ") .. line)
end
local function settle(world)
for _ = 1, 240 do
if not world:busy() then break end
U.wait(1)
end
U.wait(30)
end
U.wait(45)
local ok, err = pcall(function()
assert(game.world and game.world.maps, "gold world did not boot")
local save, why = SaveConvert.importSav(buildCart(game.world.maps), "gold", "gold")
assert(save, "import failed: " .. tostring(why))
pass(save.mapScenes and save.mapScenes.ELMS_LAB == 2, "2245 import carries ELMS_LAB scene 2")
game:continueGame(save)
U.wait(10)
local world = assert(game.world and game.world.map and game.world, "CONTINUE did not boot a world")
pass(world.map.id == "ELMS_LAB", "2245 continue lands in ELMS_LAB (got " .. tostring(world.map.id) .. ")")
settle(world)
pass(world:scene() == 2, "2245 ELMS_LAB scene is 2 (got " .. tostring(world:scene()) .. ")")
pass(not world:scriptRunning(), "2245 Elm's intro does not replay")
U.shot(game, out .. "/2245_01_elms_lab_no_intro.png")
local stops = {
{ "NEW_BARK_TOWN", 1, 8, "left", 1, "2245_02_new_bark_no_teacher.png" },
{ "ROUTE_27", 18, 10, "left", 1, "2245_03_route27_no_fisher.png" },
{ "VICTORY_ROAD", 12, 8, "up", 1 },
}
for _, s in ipairs(stops) do
local mapId, x, y, facing, want, shot = s[1], s[2], s[3], s[4], s[5], s[6]
assert(world:setMap(mapId, x, y, facing), "setMap " .. mapId)
settle(world)
pass(world:scene() == want,
("2245 %s scene is %d (got %s)"):format(mapId, want, tostring(world:scene())))
pass(not world:tryCoordScript(), "2245 " .. mapId .. " coord_event stays quiet")
U.wait(30)
pass(not world:scriptRunning(), "2245 " .. mapId .. " no script running")
if mapId ~= "VICTORY_ROAD" then U.shot(game, out .. "/" .. shot) end
end
-- maps/VictoryRoad.asm:263
local rival
for _, obj in ipairs(world.map.def.objects or {}) do
if obj.x == 18 and obj.y == 13 then rival = obj end
end
assert(rival, "no object at (18,13) in VICTORY_ROAD")
local flagSet = world.events:get(rival.eventFlag)
pass(flagSet, ("2245 VICTORY_ROAD rival flag %s is set (got %s)"):format(
tostring(rival.eventFlag), tostring(flagSet)))
assert(world:setMap("VICTORY_ROAD", 15, 11, "up"), "setMap VICTORY_ROAD recentre")
settle(world)
local npc = world:npcAt(18, 13)
pass(npc == nil, "2245 VICTORY_ROAD no object sprite at (18,13) (got "
.. tostring(npc and (npc.sprite or npc.name or "npc")) .. ")")
U.shot(game, out .. "/2245_04_victory_road_rival_cell_empty.png")
end)
if not ok then
failed = failed + 1
U.log("FAIL 2245 driver error: " .. tostring(err))
end
U.log((failed == 0 and "PASS" or "FAIL") .. " gold_import_scenes_bug2245 shots in " .. out)
love.event.quit(failed == 0 and 0 or 1)
end
@@ -0,0 +1,178 @@
-- POKEPORT_IDENTITY=red-sep04 POKEPORT_DRIVER=tests/drivers/intro_og_obj_palette_bug2264_test.lua POKEPORT_SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local P = require("src.render.PaletteFX")
local GameVersion = require("src.core.GameVersion")
local IntroMovie = require("src.ui.IntroMovie")
local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
local version = GameVersion.get()
local tag = "2264_" .. tostring(version)
local fails = 0
local function check(label, ok, detail)
U.log(ok and "PASS" or "FAIL", label, detail or "")
if not ok then fails = fails + 1 end
return ok
end
local function finish()
P.applyOptions({ colors = "ogred" })
if fails > 0 then
U.log("FAIL " .. tag .. ": " .. fails .. " check(s), shots in " .. SHOT_DIR)
love.event.quit(1)
else
U.log("PASS " .. tag .. ": all checks, shots in " .. SHOT_DIR)
love.event.quit(0)
end
while true do coroutine.yield() end
end
local rendered = 0
local hostDraw = love.draw
love.draw = function(...)
local r = hostDraw(...)
rendered = rendered + 1
return r
end
local function waitRenderedFrames(frames)
local target = rendered + (frames or 2)
for _ = 1, 2400 do
if rendered >= target then return true end
U.wait(1)
end
return false
end
local function waitFor(pred, limit)
for _ = 1, limit or 3000 do
if pred() then return true end
U.wait(1)
end
return false
end
local function near(R, G, B, c)
return math.abs(R - c[1]) <= 12 and math.abs(G - c[2]) <= 12
and math.abs(B - c[3]) <= 12
end
local objRamp, bgRamp
local function letterbox(img)
local W, H = img:getWidth(), img:getHeight()
local x1, y1, x2, y2 = W, H, -1, -1
for y = 0, H - 1 do
for x = 0, W - 1 do
local r, g, b = img:getPixel(x, y)
if r > 0.02 or g > 0.02 or b > 0.02 then
if x < x1 then x1 = x end
if x > x2 then x2 = x end
if y < y1 then y1 = y end
if y > y2 then y2 = y end
end
end
end
local bw, bh = x2 - x1 + 1, y2 - y1 + 1
if x2 >= x1 and math.abs(bw / 160 - bh / 144) < 0.05 then
return x1, y1, bh / 144
end
local s = math.min(W / 160, H / 144)
return math.floor((W - 160 * s) / 2), math.floor((H - 144 * s) / 2), s
end
local shotImg = {}
local function load(path)
if shotImg[path] ~= nil then return shotImg[path] end
local f = io.open(path, "rb")
if not f then shotImg[path] = false return false end
local bytes = f:read("*a")
f:close()
local ok, img = pcall(function()
return love.image.newImageData(love.filesystem.newFileData(bytes, "shot.png"))
end)
shotImg[path] = ok and img or false
return shotImg[path]
end
local function census(path, bx1, by1, bx2, by2)
local img = load(path)
if not img then return nil end
local W, H = img:getWidth(), img:getHeight()
local ox, oy, scale = letterbox(img)
local x1 = ox + math.floor(bx1 * scale + 0.5) + 1
local y1 = oy + math.floor(by1 * scale + 0.5) + 1
local x2 = ox + math.floor(bx2 * scale + 0.5) - 2
local y2 = oy + math.floor(by2 * scale + 0.5) - 2
local c = { obj = 0, bg = 0, black = 0, total = 0 }
for y = math.max(0, y1), math.min(H - 1, y2) do
for x = math.max(0, x1), math.min(W - 1, x2) do
local r, g, b = img:getPixel(x, y)
local R = math.floor(r * 255 + 0.5)
local G = math.floor(g * 255 + 0.5)
local B = math.floor(b * 255 + 0.5)
c.total = c.total + 1
if R < 20 and G < 20 and B < 20 then c.black = c.black + 1 end
if near(R, G, B, objRamp[1]) or near(R, G, B, objRamp[2]) then c.obj = c.obj + 1 end
if near(R, G, B, bgRamp[1]) or near(R, G, B, bgRamp[2]) then c.bg = c.bg + 1 end
end
end
local d = scale * scale
c.obj = math.floor(c.obj / d + 0.5)
c.bg = math.floor(c.bg / d + 0.5)
return c
end
local movie
check(tag .. " intro is on the stack", waitFor(function()
movie = game.stack:top()
return getmetatable(movie) == IntroMovie
end, 600))
if getmetatable(movie) ~= IntroMovie then finish() end
game.save.options.colors = "ogred"
P.applyOptions(game.save.options)
check(tag .. " COLORS is ogred", P.mode == "ogred", P.mode)
local blue = GameVersion.isBlue()
local obj = blue and P.GBC_OBJ_BLUE or P.GBC_OBJ
local bg = P.ogBg()
objRamp = { obj[2], obj[3] }
bgRamp = { bg[2], bg[3] }
check(tag .. " scrollIn finished", waitFor(function()
return movie.phase == 3 and movie.opIndex > 1 and movie.nidoX == 72
end, 3000), tostring(movie.nidoX))
waitRenderedFrames(3)
local nidoX, nidoY, gengarX = movie.nidoX, movie.nidoY, movie.gengarX
local shot1 = SHOT_DIR .. "/" .. tag .. "_01_fight_after_scroll_in.png"
check(tag .. " replay recorded", #P.uiSpriteRedraws() >= 2, "#" .. #P.uiSpriteRedraws())
if check(tag .. " shot 01", U.shot(game, shot1) and load(shot1)) then
local nido = census(shot1, math.max(nidoX, gengarX + 56), nidoY, nidoX + 48, 112)
local gengar = census(shot1, gengarX, 56, math.min(gengarX + 56, nidoX), 112)
local topBar = census(shot1, 0, 0, 160, 32)
local botBar = census(shot1, 0, 112, 160, 144)
U.log(string.format("nido band obj=%d bg=%d | gengar band obj=%d bg=%d",
nido.obj, nido.bg, gengar.obj, gengar.bg))
check(tag .. " front mon wears the boot-ROM OBJ ramp", nido.obj > 150, nido.obj)
check(tag .. " front mon carries no BG ramp", nido.bg < 20, nido.bg)
check(tag .. " Gengar keeps the BG ramp", gengar.bg > 150, gengar.bg)
check(tag .. " Gengar carries no OBJ ramp", gengar.obj < 20, gengar.obj)
check(tag .. " top bar stays solid black", topBar.black == topBar.total,
topBar.black .. "/" .. topBar.total)
check(tag .. " bottom bar covers the OAM_PRIO sprite", botBar.black == botBar.total,
botBar.black .. "/" .. botBar.total)
end
check(tag .. " reached the fade", waitFor(function()
return movie.fade >= 0.5 or movie.phase == 4
end, 3000), tostring(movie.fade))
local shot2 = SHOT_DIR .. "/" .. tag .. "_02_fade_to_white_mid.png"
if movie.phase == 3 and check(tag .. " shot 02", U.shot(game, shot2) and load(shot2)) then
local botBar = census(shot2, 0, 112, 160, 144)
local topBar = census(shot2, 0, 0, 160, 32)
check(tag .. " fade wash never paints OBJ pixels into the bars",
botBar.obj == 0 and topBar.obj == 0, botBar.obj .. "/" .. topBar.obj)
end
finish()
end
@@ -0,0 +1,120 @@
-- POKEPORT_DRIVER=tests/drivers/party_icon_og_obj_bug2268_test.lua POKEPORT_IDENTITY=red-sep04 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots"
local GameVersion = require("src.core.GameVersion")
local PaletteFX = require("src.render.PaletteFX")
local PartyMenu = require("src.ui.PartyMenu")
local Pokemon = require("src.pokemon.Pokemon")
local Screens = require("src.ui.Screens")
local tag = GameVersion.isBlue() and "blue" or "red"
local ok = true
local function check(label, pass)
U.log(pass and "PASS" or "FAIL", label)
if not pass then ok = false end
return pass
end
game.save.options = game.save.options or {}
local prevColors = game.save.options.colors
local function setColors(mode)
game.save.options.colors = mode
PaletteFX.setMode(mode)
end
local function finish()
setColors(prevColors or "gbc")
U.log(ok and "PASS party_icon_og_obj_2268" or "FAIL party_icon_og_obj_2268")
love.event.quit(ok and 0 or 1)
while true do coroutine.yield() end
end
local function shotPixels(path)
if not U.shot(game, path) then return nil end
local f = io.open(path, "rb")
if not f then return nil end
local bytes = f:read("*a")
f:close()
local good, id = pcall(function()
return love.image.newImageData(love.filesystem.newFileData(bytes, "shot.png"))
end)
return good and id or nil
end
local function count(id, c)
if not (id and c) then return -1 end
local n = 0
local w, h = id:getDimensions()
for y = 0, h - 1 do
for x = 0, w - 1 do
local r, g, b = id:getPixel(x, y)
if math.abs(r * 255 - c[1]) < 6 and math.abs(g * 255 - c[2]) < 6
and math.abs(b * 255 - c[3]) < 6 then
n = n + 1
end
end
end
return n
end
local SPECIES = { "NIDOKING", "PIDGEOTTO", "CLEFAIRY", "BULBASAUR", "OMANYTE" }
local party = {}
local mirrored = 0
for i, species in ipairs(SPECIES) do
party[i] = Pokemon.new(game.data, species, 30)
local def = game.data.pokemon[species]
local name = def and def.dex and game.data.icons and game.data.icons.byDex
and game.data.icons.byDex[def.dex]
if PartyMenu.mirrorsIcon(name) then mirrored = mirrored + 1 end
end
game.save.party = party
game.save.player.name = game.save.player.name or "RED"
setColors("ogred")
check("COLORS is OG (" .. PaletteFX.modeLabel() .. ")", PaletteFX.mode == "ogred")
check("OG mode takes the OBJ bake path on " .. tag, PaletteFX.usesSpriteObp())
U.teleport(game, "PALLET_TOWN", 10, 12, "down")
U.wait(10)
Screens.push(game, "PartyMenu", {})
U.wait(16)
local pm = game.stack:top()
if not check("the party list is open", getmetatable(pm) == PartyMenu) then
return finish()
end
local redraws = PaletteFX.uiSpriteRedraws()
local flips = 0
for _, r in ipairs(redraws) do
if r.sx == -1 then flips = flips + 1 end
end
U.log(" ui redraws:", #redraws, "flipped:", flips, "mirrored icons:", mirrored)
check("every icon replays after the zone pass",
#redraws == mirrored * 2 + (#party - mirrored))
check("each mirrored icon replays its OAM_XFLIP half", flips == mirrored)
local objRamp = GameVersion.isBlue() and PaletteFX.GBC_OBJ_BLUE or PaletteFX.GBC_OBJ
local bgRamp = PaletteFX.ogBg()
local id = shotPixels(DIR .. "/2268_01_party_og_" .. tag .. ".png")
check("OG party screenshot captured", id ~= nil)
if id then
local obj1 = count(id, objRamp[2])
local bg2 = count(id, bgRamp[3])
U.log(" OBJ shade-1 pixels:", obj1, " BG shade-2 (HP bar) pixels:", bg2)
check("icons wear the boot-ROM OBJ shade 1 on " .. tag, obj1 > 0)
check("HP bars keep the BG ramp", bg2 > 0)
end
setColors("gbc")
U.wait(10)
check("SGB records no OBJ replays", #PaletteFX.uiSpriteRedraws() == 0)
local sgb = shotPixels(DIR .. "/2268_02_party_sgb_" .. tag .. ".png")
check("SGB party screenshot captured", sgb ~= nil)
if sgb and not GameVersion.isBlue() then
check("SGB icons never wear the boot-ROM OBJ green",
count(sgb, PaletteFX.GBC_OBJ[2]) == 0)
end
return finish()
end
@@ -0,0 +1,77 @@
-- pokered/scripts/SaffronCity.asm:76
-- pokered/home/text.asm:525
-- POKEPORT_DRIVER=tests/drivers/saffron_pidgeot_cry_bug2262_test.lua POKEPORT_IDENTITY=red-sep04 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots"
local Sound = require("src.core.Sound")
local TextBox = require("src.render.TextBox")
local ok = true
local function check(label, pass)
U.log(pass and "PASS" or "FAIL", label)
if not pass then ok = false end
return pass
end
local function finish()
U.log(ok and "PASS saffron_pidgeot_cry_2262" or "FAIL saffron_pidgeot_cry_2262")
love.event.quit(ok and 0 or 1)
while true do coroutine.yield() end
end
U.newGame(game)
if not check("new game reached the overworld", game.overworld ~= nil) then finish() end
local cries = {}
local realPlayCry = Sound.playCry
Sound.playCry = function(data, species, ...)
cries[#cries + 1] = { species = species, frame = U.frame() }
return realPlayCry(data, species, ...)
end
game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI = true
U.teleport(game, "SAFFRON_CITY", 31, 13, "up")
U.wait(30)
local pidgeot
for _, n in ipairs(game.overworld.npcs or {}) do
if n.def and n.def.name == "SAFFRONCITY_PIDGEOT" then pidgeot = n end
end
if not check("pidgeot is on SAFFRON_CITY after Silph", pidgeot ~= nil) then finish() end
U.tap(game, "a")
local box, typedFrame
for _ = 1, 600 do
local top = game.stack:top()
if getmetatable(top) == TextBox then
box = top
if top.done then typedFrame = U.frame() break end
end
U.wait(1)
end
if not check("pidgeot line opened a text box", box ~= nil) then finish() end
check("pidgeot line finished typing", typedFrame ~= nil)
for _ = 1, 30 do
if #cries > 0 then break end
U.wait(1)
end
check("PIDGEOT cry played", #cries == 1 and cries[1].species == "PIDGEOT")
check("cry started after typing finished",
cries[1] ~= nil and typedFrame ~= nil and cries[1].frame >= typedFrame)
U.shot(game, DIR .. "/2262_01_pidgeot_cry_box.png")
U.wait(180)
check("box holds for A/B after the cry", game.stack:top() == box)
U.shot(game, DIR .. "/2262_02_pidgeot_box_waits_for_a.png")
U.tap(game, "a")
for _ = 1, 120 do
if game.stack:top() == game.overworld then break end
U.wait(1)
end
check("A closes the pidgeot box", game.stack:top() == game.overworld)
Sound.playCry = realPlayCry
finish()
end
+139
View File
@@ -0,0 +1,139 @@
-- POKEPORT_IDENTITY=red-sep04 POKEPORT_SHOT_DIR=/tmp/shots/2274 POKEPORT_DRIVER=tests/drivers/seafoam_hole_current_2274.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local Music = require("src.core.Music")
local FROM, HX, HY = "SEAFOAM_ISLANDS_B2F", 19, 6
local TO, LX, LY = "SEAFOAM_ISLANDS_B3F", 18, 7
local failures = 0
local function check(label, ok)
if ok then
U.log("PASS", label)
else
U.log("FAIL", label)
failures = failures + 1
end
return ok
end
if not game.save.party or #game.save.party == 0 then
game.save.party = { Pokemon.new(game.data, "LAPRAS", 40),
Pokemon.new(game.data, "SNORLAX", 45) }
end
game.save.flags = game.save.flags or {}
for k in pairs(game.save.flags) do
if k:match("^EVENT_SEAFOAM[34]_BOULDER") then game.save.flags[k] = nil end
end
local realPlayMap = Music.playMap
local musicLog = {}
Music.playMap = function(data, mapId, onBike, surfing, ...)
musicLog[#musicLog + 1] = { mapId = mapId, surfing = surfing and true or false,
t = U.frame() }
return realPlayMap(data, mapId, onBike, surfing, ...)
end
local function fall(shots)
U.teleport(game, FROM, HX, HY + 1, "up")
U.wait(10)
musicLog = {}
U.hold(game, "up", 18)
local rec = { dropFrames = 0, badDrop = 0, earlySurf = 0, earlyMoves = 0,
slideFrames = 0, slideSpin = 0 }
for _ = 1, 900 do
local ow = game.overworld
local p = ow and ow.player
if ow and ow.map.id == TO then
if not rec.arrival then
rec.arrival = U.frame()
rec.ax, rec.ay = p.cellX, p.cellY
end
local t = U.frame() - rec.arrival
local landing = not rec.landedAt
and (ow.transitioning or ow.holeArrive or ow.spinArrive
or game.stack:top() ~= ow)
if landing then
if p.surfing then rec.earlySurf = rec.earlySurf + 1 end
if #ow.scriptMoves > 0 or p.cellX ~= LX or p.cellY ~= LY then
rec.earlyMoves = rec.earlyMoves + 1
end
if ow.holeArrive and not rec.holdAt then rec.holdAt = t end
if p.spinning and not ow.holeArrive then
rec.dropStart = rec.dropStart or t
rec.dropEnd = t
rec.dropFrames = rec.dropFrames + 1
local _, _, _, facing, phase, flip = p:pose()
if facing ~= "down" or phase ~= 1 or not flip then
rec.badDrop = rec.badDrop + 1
end
end
else
if not rec.landedAt then
rec.landedAt = t
rec.landedAbs = U.frame()
end
if p.surfing and not rec.surfAt then rec.surfAt = t end
if #ow.scriptMoves > 0 or p.moving then
rec.slideStart = rec.slideStart or t
rec.slideFrames = rec.slideFrames + 1
if p.spinning or ow.spinnerSliding then rec.slideSpin = rec.slideSpin + 1 end
if t - rec.slideStart > 60 then break end
elseif rec.slideStart then
break
end
end
for _, s in ipairs(shots or {}) do
if not s.done and s.at and t >= s.at then
s.done = true
U.shot(game, DIR .. "/" .. s.name)
U.log("captured", DIR .. "/" .. s.name)
end
end
end
U.wait(1)
end
rec.music = musicLog
return rec
end
local rec = fall(nil)
check("2274 fell to B3F (18,7)", rec.arrival ~= nil and rec.ax == LX and rec.ay == LY)
check("2274 on foot through the hold and drop", rec.earlySurf == 0)
check("2274 current waits for the landing", rec.earlyMoves == 0)
check(("2274 drop faces down without spinning (%d frames)"):format(rec.dropFrames),
rec.dropFrames > 0 and rec.badDrop == 0)
check("2274 surf mounts on the landing frame",
rec.surfAt ~= nil and rec.landedAt ~= nil and rec.surfAt == rec.landedAt)
check("2274 current starts after the landing",
rec.slideStart ~= nil and rec.landedAt ~= nil and rec.slideStart >= rec.landedAt)
check(("2274 current does not spin (%d slide frames)"):format(rec.slideFrames),
rec.slideFrames > 0 and rec.slideSpin == 0)
local early, late = 0, 0
for _, m in ipairs(rec.music) do
if m.surfing then
if rec.landedAbs and m.t >= rec.landedAbs then late = late + 1 else early = early + 1 end
end
end
check("2274 no surf music before the landing", early == 0)
check("2274 surf music after the landing", late > 0)
if rec.holdAt and rec.dropStart and rec.landedAt and rec.slideStart then
fall({
{ at = rec.holdAt + 25, name = "2274_01_hold_no_player.png" },
{ at = rec.dropStart + math.floor((rec.dropEnd - rec.dropStart) / 2),
name = "2274_02_drop_walking_down.png" },
{ at = rec.landedAt, name = "2274_03_landed_surf_sprite.png" },
{ at = rec.slideStart + 20, name = "2274_04_current_no_spin.png" },
})
else
check("2274 frame marks for the shooting pass", false)
end
Music.playMap = realPlayMap
love.event.quit(failures == 0 and 0 or 1)
while true do coroutine.yield() end
end
+9 -8
View File
@@ -127,7 +127,7 @@ return function(game)
if landed and not o.transitioning then break end
U.wait(1)
end
-- spins the player down -- player_animations.asm:41-45
-- drops the player down -- player_animations.asm:41-45
local hidden = false
for _ = 1, 200 do
local o = game.overworld
@@ -135,6 +135,7 @@ return function(game)
if not (o.holeArrive or o.player.spinning) then break end
U.wait(1)
end
local surfedAfterDrop = game.overworld.player.surfing == true
U.wait(20)
watching = false
@@ -149,9 +150,9 @@ return function(game)
check("facing carried through the fall (" .. tostring(landed.facing) .. ")",
landed.facing == dir)
if f.water then
-- the landing is sea, so setMap's CheckForceBikeOrSurf pass
-- (OverworldState:checkForcedMovement) has to mount SURF on arrival
check("arrived already surfing on the B3F water", landed.surfing == true)
-- home/overworld.asm:31
check("dropped onto the B3F water on foot", landed.surfing ~= true)
check("SURF mounted once the drop landed", surfedAfterDrop)
end
local doors = {}
for _, c in ipairs(cues) do
@@ -184,13 +185,13 @@ return function(game)
U.log("Both falls have already run; press up from where you are standing to")
U.log("do the 1F one again. It should sink the sprite, fade to WHITE, hold")
U.log("about a second on an empty B1F (18,7) with the player offscreen, ring")
U.log("Teleport_Enter1 and spin him down into the cell still facing up -- no")
U.log("Teleport_Enter1 and drop him into the cell facing down, no spin -- no")
U.log("faint sound, no door chime, and no")
U.log("step out of a doorway on the far side. The near miss to watch for is")
U.log("landing one cell off, on the boulder's own spot from field.seafoam")
U.log("landsAt, which strands you inside the rock; and dropping onto the B3F")
U.log("sea standing up instead of surfing, which shows as the player on top")
U.log("of the water until the next step. The other three holes are 1F (24,6),")
U.log("landsAt, which strands you inside the rock; and on B3F, the surf sprite")
U.log("or the current showing up before the drop has landed. The other three")
U.log("holes are 1F (24,6),")
U.log("B1F (18,6)/(23,6) and B2F (22,6) if you want to walk the cascade down.")
while true do
+165
View File
@@ -0,0 +1,165 @@
-- POKEPORT_IDENTITY=red-sep04 POKEPORT_TOUCH=0 POKEPORT_SHOT_DIR=/tmp/2235 POKEPORT_DRIVER=tests/drivers/shaderfx_grid_2235.lua love .
local U = require("tests.drivers.util")
local ShaderFX = require("src.render.ShaderFX")
local Zoom = require("src.render.Zoom")
local GameVersion = require("src.core.GameVersion")
local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/2235"
local WANT = {
{ file = "retro-v3.slangp", tag = "retro" },
{ file = "sameboy-lcd.slangp", tag = "sameboy" },
}
local ZOOMS = {
{ offset = 0, tag = "fit" },
{ offset = -1, tag = "out1" },
{ offset = -2, tag = "out2" },
}
return function(game)
local fails = 0
local function ok(cond, label)
if not cond then fails = fails + 1 end
print((cond and "PASS " or "FAIL ") .. label)
end
love.window.setMode(1024, 768, { resizable = true })
U.wait(30)
local gen2 = GameVersion.generation() == 2
if gen2 then
game.world:warpToMapId("NEW_BARK_TOWN", 6, 6, "down")
else
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
end
U.wait(60)
local byName = {}
for _, e in ipairs(ShaderFX.list()) do byName[e.name] = e end
local ready = {}
for _, want in ipairs(WANT) do
local entry = byName[want.file]
if entry and not entry.converted then
local converted = ShaderFX.convert(entry)
if not converted then entry = nil end
end
if entry then
ready[#ready + 1] = { entry = entry, tag = want.tag }
else
print("SKIP " .. want.file .. " is not in this identity's shaders/")
end
end
ok(#ready > 0, "2235 a grid preset is installed")
if #ready == 0 then love.event.quit(1) return end
local captured = {}
local realRunChain = ShaderFX.runChain
ShaderFX.runChain = function(state, img, luts, viewport, original, layer)
captured[layer or "main"] = { vw = viewport.w, vh = viewport.h, ow = original.w, oh = original.h }
return realRunChain(state, img, luts, viewport, original, layer)
end
local function fitScale()
if gen2 then return game.world:fitScale() end
return game.renderer:fitScale()
end
local function scanlineBands(path, s)
local f = io.open(path, "rb")
if not f then return nil, "no shot" end
local bytes = f:read("*a")
f:close()
local okImg, id = pcall(love.image.newImageData, love.filesystem.newFileData(bytes, "shot.png"))
if not okImg then return nil, tostring(id) end
local w, h = id:getWidth(), id:getHeight()
local rows = {}
for y = 0, h - 1 do
local sum, n = 0, 0
for x = 0, w - 1, 2 do
local r, g, b = id:getPixel(x, y)
sum, n = sum + (r + g + b) / 3, n + 1
end
rows[y] = sum / n * 255
end
local best
for off = 0, s - 1 do
local groups, total = {}, 0
for y0 = off, h - s, s do
local lo, hi = rows[y0], rows[y0]
for y = y0 + 1, y0 + s - 1 do
if rows[y] < lo then lo = rows[y] end
if rows[y] > hi then hi = rows[y] end
end
local c = hi >= 40 and (hi - lo) / hi or nil
groups[#groups + 1] = c
total = total + (c or 0)
end
if not best or total < best.total then best = { off = off, groups = groups, total = total } end
end
local blocks = {}
for b = 1, #best.groups, 16 do
local sum, n = 0, 0
for g = b, math.min(b + 15, #best.groups) do
local c = best.groups[g]
if c then sum, n = sum + c, n + 1 end
end
if n >= 4 then blocks[#blocks + 1] = sum / n end
end
local lo, hi = math.huge, 0
for _, v in ipairs(blocks) do
if v < lo then lo = v end
if v > hi then hi = v end
end
return { off = best.off, blocks = blocks, lo = lo, hi = hi }
end
local gtag = tostring(GameVersion.get())
for _, r in ipairs(ready) do
ShaderFX.deactivate("secondary")
local on, err = ShaderFX.activate("main", r.entry)
ok(on, ("2235 %s activates %s"):format(r.tag, tostring(err or "")))
if on then
for _, z in ipairs(ZOOMS) do
Zoom.offset = Zoom.clampOffset(z.offset, fitScale())
captured = {}
U.wait(6)
local rect = ShaderFX._lastRect
local m = captured.main
ok(rect ~= nil and m ~= nil
and m.vw == m.ow * rect.scale and m.vh == m.oh * rect.scale,
("2235 %s %s world viewport is an exact multiple of its source"):format(r.tag, z.tag))
local shot = ("%s/2235_%s_%s_%s_map.png"):format(SHOT_DIR, gtag, r.tag, z.tag)
U.shot(game, shot)
if r.tag == "sameboy" and z.tag == "out2" and rect then
local bands, err = scanlineBands(shot, rect.scale)
local parts = {}
for i, v in ipairs(bands and bands.blocks or {}) do parts[i] = ("%.3f"):format(v) end
print(("info sameboy out2 scanline depth per 48px row band (phase %s): %s"):format(
tostring(bands and bands.off), #parts > 0 and table.concat(parts, " ") or tostring(err)))
ok(bands ~= nil and #bands.blocks >= 8 and bands.lo >= 0.015 and bands.lo >= 0.5 * bands.hi,
"2235 sameboy out2 every 48px row band carries scanlines of similar depth")
end
end
Zoom.offset = Zoom.clampOffset(-2, fitScale())
U.tap(game, "start")
U.wait(20)
captured = {}
U.wait(4)
local ui = captured.ui or captured.main
ok(ui ~= nil and ui.vw % ui.ow == 0 and ui.vh % ui.oh == 0
and ui.vw / ui.ow == ui.vh / ui.oh,
("2235 %s out2 menu layer viewport is an exact multiple of its source"):format(r.tag))
U.shot(game, ("%s/2235_%s_%s_out2_start_menu.png"):format(SHOT_DIR, gtag, r.tag))
U.tap(game, "b")
U.wait(20)
end
end
ShaderFX.runChain = realRunChain
ShaderFX.deactivate("main")
Zoom.offset = 0
print(fails == 0 and "PASS 2235 shaderfx grid alignment" or "FAIL 2235 shaderfx grid alignment")
love.event.quit(fails == 0 and 0 or 1)
end
@@ -0,0 +1,86 @@
-- home/trainers.asm:123
-- POKEPORT_DRIVER=tests/drivers/static_encounter_sting_bug2275_2261.lua POKEPORT_IDENTITY=red-sep04 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
local Music = require("src.core.Music")
local Pokemon = require("src.pokemon.Pokemon")
local TextBox = require("src.render.TextBox")
local ok = true
local function check(label, pass)
U.log(pass and "PASS" or "FAIL", label)
if not pass then ok = false end
return pass
end
local function finish()
U.log(ok and "PASS static_encounter_sting_2275_2261"
or "FAIL static_encounter_sting_2275_2261")
love.event.quit(ok and 0 or 1)
while true do coroutine.yield() end
end
local function isBox(s) return getmetatable(s) == TextBox end
local function waitTypedBox()
for _ = 1, 300 do
local top = game.stack:top()
if isBox(top) and top.done then return top end
U.wait(1)
end
return nil
end
local function encounter(tag, mapId, x, y)
U.teleport(game, mapId, x, y, "up")
U.wait(30)
local ow = game.stack:top()
local mapSong = Music.current()
U.tap(game, "a")
local box = waitTypedBox()
if not check(tag .. " talking opens the battle text", box ~= nil) then
return
end
local stingAt, battleEarly
for f = 1, 240 do
local top = game.stack:top()
if top ~= box then battleEarly = true break end
if not stingAt and Music.current() == "Music_MeetMaleTrainer" then
stingAt = f
end
U.wait(1)
end
check(tag .. " box holds 240 frames with no button", not battleEarly)
check(tag .. " sting replaced " .. tostring(mapSong) .. " while the box waits",
stingAt ~= nil)
if battleEarly then return end
U.shot(game, ("%s/2275_%s_01_box_held_sting_playing.png"):format(DIR, tag))
U.tap(game, "a")
local battle
for _ = 1, 300 do
local top = game.stack:top()
if top ~= box and top ~= ow and not isBox(top) then battle = top break end
U.wait(1)
end
check(tag .. " battle starts after A", battle ~= nil)
local settled = 0
for _ = 1, 90 do
settled = settled + 1
U.wait(1)
end
if battle then
U.shot(game, ("%s/2275_%s_02_battle_after_a_%df.png"):format(DIR, tag, settled))
end
end
U.newGame(game)
if not check("new game reached the overworld", game.overworld ~= nil) then finish() end
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 60) }
encounter("voltorb", "POWER_PLANT", 9, 21)
encounter("articuno", "SEAFOAM_ISLANDS_B4F", 6, 2)
encounter("zapdos", "POWER_PLANT", 4, 10)
finish()
end
@@ -0,0 +1,129 @@
-- POKEPORT_IDENTITY=red-sep04 POKEPORT_SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/surf_strength_submenu_2267_2273.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local PartyMenu = require("src.ui.PartyMenu")
local Music = require("src.core.Music")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots"
local fails = 0
local function check(ok, label)
print((ok and "PASS " or "FAIL ") .. label)
if not ok then fails = fails + 1 end
return ok
end
local function giveMon(species, move)
local mon = Pokemon.new(game.data, species, 30)
mon.moves = { { id = move, pp = 15 } }
game.save.party = { mon }
return mon
end
local function openFieldMove(action)
local pm = PartyMenu.new(game)
game.stack:push(pm)
U.wait(4)
U.tap(game, "a")
U.wait(4)
for i, item in ipairs(pm.subItems or {}) do
if item.action == action then pm.subIndex = i end
end
U.shot(game, DIR .. "/2273_00_" .. action .. "_submenu_open.png")
U.tap(game, "a")
return pm
end
local function drainText()
for _ = 1, 400 do
local top = game.stack:top()
if top == game.overworld then return end
U.tap(game, "a")
U.wait(2)
end
end
U.newGame(game)
game.save.options = game.save.options or {}
game.save.options.textSpeed = 5
game.save.inventory.SOULBADGE = true
game.save.inventory.RAINBOWBADGE = true
game.save.inventory.BOULDERBADGE = true
giveMon("MACHOP", "STRENGTH")
U.teleport(game, "SEAFOAM_ISLANDS_1F", 17, 10, "right")
local pmStr = openFieldMove("strength")
U.wait(6)
check(game.stack:top() ~= pmStr and not pmStr.submenu, "2273_strength_submenu_erased")
U.shot(game, DIR .. "/2273_01_strength_text_no_options_box.png")
drainText()
U.wait(40)
giveMon("PIKACHU", "FLASH")
game.save.flashLit = false
U.teleport(game, "ROCK_TUNNEL_1F", 15, 4, "down")
local pmFlash = openFieldMove("flash")
U.wait(6)
check(game.stack:top() ~= pmFlash and not pmFlash.submenu, "2273_flash_submenu_erased")
U.shot(game, DIR .. "/2273_02_flash_text_no_options_box.png")
drainText()
U.wait(40)
giveMon("SQUIRTLE", "SURF")
U.teleport(game, "PALLET_TOWN", 4, 13, "down")
game.overworld.player.surfing = false
local pmSurf = openFieldMove("surf")
U.wait(6)
check(game.stack:top() ~= pmSurf and not pmSurf.submenu, "2267_surf_submenu_erased")
check(Music.current() == Music.special(game.data, "surf"), "2267_surf_music_with_got_on_text")
U.shot(game, DIR .. "/2267_03_got_on_text_surf_music_no_options_box.png")
drainText()
U.wait(60)
local function parkSurfing()
U.teleport(game, "PALLET_TOWN", 4, 14, "up")
local ow = game.overworld
ow.player.surfing = true
ow:syncSurfingPikachu()
Music.playMap(game.data, ow.map.id, false, true)
U.wait(30)
return ow
end
local ow = parkSurfing()
local startFrame, landFrame, swapFrame
for _ = 1, 60 do
table.insert(game.input.pressQueue, "up")
game.input.state.up = true
U.wait(1)
local p = ow.player
if not startFrame and p.moving then startFrame = U.frame() end
if startFrame and not swapFrame and not p.surfing then swapFrame = U.frame() end
if startFrame and not landFrame and p.cellY == 13 then landFrame = U.frame(); break end
end
game.input.state.up = false
check(startFrame ~= nil and landFrame ~= nil, "2267_walk_off_step_ran")
check(swapFrame ~= nil and startFrame ~= nil and swapFrame <= startFrame
and (landFrame == nil or swapFrame < landFrame), "2267_walk_off_swap_before_landing")
U.wait(30)
ow = parkSurfing()
local shotTaken = false
for _ = 1, 60 do
table.insert(game.input.pressQueue, "up")
game.input.state.up = true
U.wait(1)
if ow.player.moving and not shotTaken then
game.input.state.up = false
check(not ow.player.surfing and ow.player.cellY == 14, "2267_walk_off_mid_step_walking")
U.shot(game, DIR .. "/2267_04_walk_off_mid_step_walking_sprite.png")
shotTaken = true
break
end
end
game.input.state.up = false
check(shotTaken, "2267_walk_off_mid_step_shot")
print(fails == 0 and "PASS surf_strength_submenu_2267_2273" or "FAIL surf_strength_submenu_2267_2273")
love.event.quit(fails == 0 and 0 or 1)
end
@@ -0,0 +1,98 @@
-- engine/battle/core.asm:293
-- POKEPORT_IDENTITY=red-sep04 POKEPORT_SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/thrash_menu_flash_bug2252.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local fails = 0
local function ok(cond, line)
print((cond and "PASS " or "FAIL ") .. line)
if not cond then fails = fails + 1 end
end
U.teleport(game, "ROUTE_1", 5, 5, "down")
local ow = game.overworld
local function startBattle()
love.math.setRandomSeed(2252)
local king = Pokemon.new(game.data, "NIDOKING", 60)
king.moves = { { id = "THRASH", pp = 20 } }
king.stats.hp, king.hp = 999, 999
game.save.party = { king }
local battle = BattleState.newWild(game, "MAGIKARP", 60)
battle.onFinish = function() end
battle.enemy.mon.stats.hp, battle.enemy.mon.hp = 999, 999
ow:pushBattle(battle)
for _ = 1, 200 do
if battle.phase == "menu" then break end
U.tap(game, "a")
U.wait(4)
end
return battle
end
local function selectThrash(battle)
U.tap(game, "a"); U.wait(12)
U.tap(game, "a")
end
local function locked(battle)
return (battle.player.thrashTurns or 0) > 0
end
local battle = startBattle()
ok(battle.phase == "menu", "2252 pass 1 reached the command menu")
local startTurn = battle.turnCount or 0
selectThrash(battle)
local f, drainFrame, flashes, sawLocked = 0, nil, 0, false
local lastSig, logged = nil, 0
for _ = 1, 2400 do
f = f + 1
if f % 20 == 0 then U.tap(game, "a") else U.wait(1) end
local sig = tostring(battle.phase) .. " thrash=" .. tostring(battle.player.thrashTurns)
.. " turn=" .. tostring(battle.turnCount) .. " top=" .. tostring(game.stack:top() == battle)
if sig ~= lastSig and logged < 80 then
U.log("f", f, sig)
lastSig, logged = sig, logged + 1
end
if locked(battle) then sawLocked = true end
if not drainFrame and (battle.turnCount or 0) >= startTurn + 2 then drainFrame = f end
if battle.phase == "menu" and locked(battle) then flashes = flashes + 1 end
if battle.phase == "menu" and not locked(battle) and f > 30 then break end
end
U.log("turns", (battle.turnCount or 0) - startTurn, "drainFrame", drainFrame,
"status", battle.player.mon.status, "confused", battle.player.confusedTurns)
ok(sawLocked, "2252 player locked into thrash")
ok(drainFrame ~= nil, "2252 locked turn resolved without input")
ok(flashes == 0, "2252 no command menu frame while locked (" .. flashes .. ")")
if game.stack:top() == battle then game.stack:pop() end
if drainFrame then
battle = startBattle()
selectThrash(battle)
local g, shotBefore, shotDrain, shotAfter = 0, false, false, false
local phaseAtDrain
while g < drainFrame + 60 do
if not shotBefore and g >= drainFrame - 12 then
shotBefore = U.shot(game, DIR .. "/2252_01_thrash_text_before_drain.png")
g = g + 3
elseif not shotDrain and g >= drainFrame then
phaseAtDrain = battle.phase
shotDrain = U.shot(game, DIR .. "/2252_02_drain_frame_no_menu.png")
g = g + 3
elseif not shotAfter and g >= drainFrame + 40 then
shotAfter = U.shot(game, DIR .. "/2252_03_thrashing_about_text.png")
g = g + 3
else
g = g + 1
if g % 20 == 0 then U.tap(game, "a") else U.wait(1) end
end
end
ok(phaseAtDrain ~= "menu", "2252 pass 2 drain frame is not the command menu")
ok(shotBefore and shotDrain and shotAfter, "2252 shots written")
end
love.event.quit(fails == 0 and 0 or 1)
end
@@ -0,0 +1,90 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local PartyMenu = require("src.ui.PartyMenu")
local function newGame(moveId, ow)
local mon = { species = "MACHOP", hp = 50, stats = { hp = 50 },
level = 30, moves = { { id = moveId, pp = 15 } } }
ow.map = ow.map or { def = { tileset = "OVERWORLD" }, id = "PALLET_TOWN" }
ow.dark = ow.dark or false
ow.partyKnows = function() return mon end
local game = {
data = { pokemon = { MACHOP = { name = "MACHOP" } } },
save = { party = { mon }, inventory = {}, options = {}, flags = {} },
overworld = ow,
}
game.stack = {
states = {},
push = function(self, s) table.insert(self.states, s) end,
pop = function(self) return table.remove(self.states) end,
top = function(self) return self.states[#self.states] end,
}
game.input = {
queue = {},
wasPressed = function(self, btn) return self.queue[btn] or false end,
isDown = function() return false end,
}
return game
end
local function press(pm, btn)
pm.game.input.queue = { [btn] = true }
pm:update(1 / 60)
pm.game.input.queue = {}
end
local function choose(game, action)
local pm = PartyMenu.new(game, {})
game.stack:push(pm)
press(pm, "a")
check(pm.submenu, action .. ": A on the mon opens the submenu")
local row
for i, item in ipairs(pm.subItems or {}) do
if item.action == action then row = i end
end
check(row ~= nil, action .. " is listed in the submenu")
pm.subIndex = row or 1
press(pm, "a")
return pm
end
local function expectErased(label, game, pm, captured)
check(captured.onClose ~= nil, label .. " reaches the overworld handler")
check(not pm.submenu, label .. ": the options box is gone under the text")
eq(game.stack:top(), pm, label .. ": the party list stays as the text backdrop")
if captured.onClose then captured.onClose() end
eq(#game.stack.states, 0, label .. ": the menu closes when the text ends")
end
do
local captured = {}
local game = newGame("STRENGTH", {
useStrengthFieldMove = function(_, _, onClose) captured.onClose = onClose end,
})
expectErased("STRENGTH", game, choose(game, "strength"), captured)
end
do
local captured = {}
local game = newGame("SURF", {
player = { facingCell = function() return 4, 14 end },
useSurfFieldMove = function() return "ok" end,
trySurf = function(_, _, _, onClose) captured.onClose = onClose end,
})
expectErased("SURF", game, choose(game, "surf"), captured)
end
do
local captured = {}
local game = newGame("FLASH", {
dark = true,
useFlashFieldMove = function(_, onClose) captured.onClose = onClose end,
})
expectErased("FLASH", game, choose(game, "flash"), captured)
end
T.finish()
@@ -0,0 +1,199 @@
-- engine/battle/core.asm:869, engine/battle/core.asm:912, engine/battle/core.asm:1005
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local Battle = require("src.battle.gen2.Battle")
local Mon = require("src.battle.gen2.Mon")
local TYPES = {
NORMAL = { id = "NORMAL", index = 0, category = "physical" },
}
local MOVES = {
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
}
local POKEMON = {
growthRates = {
GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0,
linear = 0, constant = 0 },
},
MACHOP = { id = "MACHOP", index = 66, name = "MACHOP",
baseStats = { hp = 70, attack = 80, defense = 50, speed = 35,
specialAttack = 35, specialDefense = 35 },
types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75,
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63,
levelMoves = {}, evolutions = {} },
}
local DATA = { pokemon = POKEMON, moves = MOVES,
type_chart = { types = TYPES, matchups = {} }, items = {} }
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
perfect.hp = Mon.hpDV(perfect)
local function rng() return 0 end
local function mon(level)
local m = Mon.new(DATA, "MACHOP", level or 50, { dvs = perfect })
m.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
return m
end
local TACKLE = { kind = "move", move = "TACKLE" }
local function indexOf(events, pred, from)
for i = from or 1, #events do
if pred(events[i]) then return i end
end
end
local function moveBy(side)
return function(e) return e.kind == "move" and e.side == side end
end
local function tickOf(side, anim)
return function(e)
return e.kind == "damage" and e.side == side and e.anim == anim
end
end
local function count(events, pred)
local n = 0
for _, e in ipairs(events) do if pred(e) then n = n + 1 end end
return n
end
local function hasText(events, sub)
for _, e in ipairs(events) do
if e.text and e.text:find(sub, 1, true) then return true end
end
return false
end
do
local lead, bench = mon(), mon()
local player = mon()
local battle = Battle.new({ data = DATA, party = { player },
trainer = { class = "YOUNGSTER", name = "JOEY", party = { lead, bench } },
random = rng })
player.status = "poison"
player.stats.speed, lead.stats.speed = 200, 1
lead.hp = 1
local hpBefore = player.hp
local events = battle:takeTurn(TACKLE)
T.eq(battle.enemy, bench, "a: the trainer's second mon is sent out")
T.check(not hasText(events, "hurt by poison"),
"a: an OHKO jumps past the attacker's own poison tick")
T.eq(player.hp, hpBefore, "a: the poisoned player loses no HP that round")
end
do
local p1, p2 = mon(), mon()
local lead = mon()
local battle = Battle.new({ data = DATA, party = { p1, p2 },
trainer = { class = "YOUNGSTER", name = "JOEY", party = { lead, mon() } },
random = rng })
lead.status = "poison"
lead.stats.speed, p1.stats.speed = 200, 1
p1.hp = 1
local hpBefore = lead.hp
local events = battle:takeTurn(TACKLE)
T.check((p1.hp or 0) <= 0, "b: the player's lead fainted")
T.check(not hasText(events, "hurt by poison"),
"b: the enemy that KOd skips its poison tick")
T.eq(lead.hp, hpBefore, "b: the poisoned enemy loses no HP that round")
end
do
local player, wild = mon(), mon()
local battle = Battle.new({ data = DATA, party = { player }, wild = wild,
random = rng })
player.status, wild.status = "poison", "poison"
player.stats.speed, wild.stats.speed = 1, 200
local events = battle:takeTurn(TACKLE)
local em = indexOf(events, moveBy("enemy"))
local et = indexOf(events, tickOf("enemy", "ANIM_PSN"))
local pm = indexOf(events, moveBy("player"))
local pt = indexOf(events, tickOf("player", "ANIM_PSN"))
T.check(em and et and pm and pt, "c: both moves and both poison ticks happen")
T.check(em and et and pm and pt and em < et and et < pm and pm < pt,
"c: enemy move, enemy poison, player move, player poison")
end
do
local player, wild = mon(), mon()
local battle = Battle.new({ data = DATA, party = { player }, wild = wild,
random = rng })
player.status = "poison"
player.stats.speed, wild.stats.speed = 200, 1
player.hp = 1
local events = battle:takeTurn(TACKLE)
T.eq(player.hp, 0, "d: the player faints to its own poison")
T.eq(indexOf(events, moveBy("enemy")), nil,
"d: the slower enemy never gets its move")
end
do
local player, wild = mon(), mon()
local battle = Battle.new({ data = DATA, party = { player }, wild = wild,
random = rng })
player.stats.speed, wild.stats.speed = 200, 1
battle:volatile(player).leechSeed = true
local events = battle:takeTurn(TACKLE)
local pm = indexOf(events, moveBy("player"))
local sap = indexOf(events, tickOf("player", "ANIM_SAP"))
local em = indexOf(events, moveBy("enemy"))
T.check(pm and sap and em and pm < sap and sap < em,
"e: Leech Seed drains between the faster mon's move and the slower one's")
end
do
local p1, p2 = mon(), mon()
local wild = mon()
local battle = Battle.new({ data = DATA, party = { p1, p2 }, wild = wild,
random = rng })
p2.status = "poison"
local events = battle:takeTurn({ kind = "switch", index = 2 })
local pt = indexOf(events, tickOf("player", "ANIM_PSN"))
local em = indexOf(events, moveBy("enemy"))
T.eq(count(events, tickOf("player", "ANIM_PSN")), 1,
"f: the switched-in mon's poison ticks exactly once")
T.check(pt and em and pt < em,
"f: after a switch the player's tick lands before the enemy's move")
end
do
local player, wild = mon(), mon()
local battle = Battle.new({ data = DATA, party = { player }, wild = wild,
random = rng })
player.status = "poison"
local events = battle:takeTurn({ kind = "item", item = "POTION" })
local pt = indexOf(events, tickOf("player", "ANIM_PSN"))
local em = indexOf(events, moveBy("enemy"))
T.eq(count(events, tickOf("player", "ANIM_PSN")), 1,
"f: an item turn ticks the player's poison exactly once")
T.check(pt and em and pt < em,
"f: after an item the player's tick lands before the enemy's move")
end
do
local player, wild = mon(), mon()
local battle = Battle.new({ data = DATA, party = { player }, wild = wild,
random = rng })
player.status, wild.status = "poison", "poison"
player.stats.speed, wild.stats.speed = 200, 1
battle.weather, battle.weatherTurns = "sandstorm", 5
local events = battle:takeTurn(TACKLE)
local pt = indexOf(events, tickOf("player", "ANIM_PSN"))
local et = indexOf(events, tickOf("enemy", "ANIM_PSN"))
local sand = indexOf(events, function(e)
return e.kind == "damage" and e.anim == "ANIM_IN_SANDSTORM"
end)
T.check(pt and et and sand and pt < sand and et < sand,
"g: HandleBetweenTurnEffects' sandstorm lands after both poison ticks")
end
T.finish("gen2 residual timing bug 2257")
+54
View File
@@ -102,6 +102,10 @@ local function build(version, bare, female)
put(b, L.wVisitedSpawns + 1, 0x40)
put(b, L.wVisitedSpawns + 2, 0x05)
put(b, L.wVariableSprites + WEIRD_TREE_SLOT, SPRITE_TWIN)
put(b, L.sceneVars.ELMS_LAB, 2)
put(b, L.sceneVars.NEW_BARK_TOWN, 1)
put(b, L.sceneVars.ROUTE_27, 1)
put(b, L.sceneVars.VICTORY_ROAD, 1)
end
if L.wPlayerGender then put(b, L.wPlayerGender, female and 1 or 0) end
put(b, L.wNumItems, 1); put(b, L.wItems, 20, 3); put(b, L.wItems + 2, 0xFF)
@@ -189,6 +193,14 @@ for _, version in ipairs({ "gold", "silver", "crystal" }) do
eq(save.variableSprites[0], nil,
version .. ": a slot the cart never filled stays absent for World's seed")
-- data/maps/scenes.asm:7
eq(save.mapScenes.ELMS_LAB, 2, version .. ": Elm's lab is past the starter")
eq(save.mapScenes.NEW_BARK_TOWN, 1, version .. ": the teacher no longer stops you")
eq(save.mapScenes.ROUTE_27, 1, version .. ": the Route 27 fisher stays put")
eq(save.mapScenes.VICTORY_ROAD, 1, version .. ": the Victory Road rival is done")
eq(save.mapScenes.POKECENTER_2F, nil, version .. ": a zero scene byte is no entry")
eq(next(bare.mapScenes), nil, version .. ": a fresh cart has every map on scene 0")
-- constants/ram_constants.asm:177 (#1907)
eq(save.player.gender, version == "crystal" and "male" or nil,
version .. ": Gold and Silver have no gender byte to read")
@@ -301,6 +313,9 @@ do
save.engineFlags[66] = nil
save.engineFlags[69] = true
save.variableSprites[6] = 0x35
save.mapScenes.ELMS_LAB = 6
save.mapScenes.NEW_BARK_TOWN = 0
save.mapScenes.MAHOGANY_TOWN = 1
local out = assert(Gen2Save.encode(save, "gold", cart, data))
eq(#out, #cart, "the image keeps its size")
@@ -340,6 +355,23 @@ do
eq(back.variableSprites[WEIRD_TREE_SLOT], SPRITE_TWIN,
"the twins' slot survives the round trip")
eq(back.variableSprites[6], 0x35, "and a slot filled inside the port is written")
-- data/maps/scenes.asm:7
eq(back.mapScenes.ELMS_LAB, 6, "a scene advanced in the port is written")
eq(back.mapScenes.NEW_BARK_TOWN, nil, "a cleared scene clears the cart byte")
eq(back.mapScenes.MAHOGANY_TOWN, 1, "and a newly set one reaches the cart")
eq(back.mapScenes.ROUTE_27, 1, "with the untouched ones left as they were")
local again = assert(Gen2Save.encode(back, "gold", out, data))
local L = Gen2Save.layoutFor("gold")
local startAt, stopAt = L.sceneVars.POKECENTER_2F, L.sceneVars.MOUNT_MOON_SQUARE
eq(again:sub(startAt + 1, stopAt + 1), out:sub(startAt + 1, stopAt + 1),
"import, export, import gives the same scene bytes")
back.mapScenes = {}
local untouched = assert(Gen2Save.encode(back, "gold", out, data))
eq(untouched:byte(L.sceneVars.ELMS_LAB + 1), 6,
"a save with no scene entries leaves the cart's scene bytes alone")
end
-- ram/sram.asm:138-144
@@ -364,6 +396,26 @@ do
"and reads back as Chris")
end
-- data/maps/scenes.asm:7
do
local function shape(L, want, label)
local n = 0
for mapId, at in pairs(L.sceneVars) do
n = n + 1
check(at >= L.sGameData and at < L.sGameDataEnd,
("%s: %s scene byte 0x%04X is inside the checksummed block"):format(label, mapId, at))
end
eq(n, want, label .. ": one scene byte per scene_var row")
end
shape(Gen2Layout.goldSilver, 59, "gold/silver")
shape(Gen2Layout.crystal, 79, "crystal")
shape(Gen2Layout.crystal.backup, 79, "crystal backup")
eq(Gen2Layout.goldSilver.sceneVars.ELMS_LAB, 0x2534, "gold wElmsLabSceneID")
eq(Gen2Layout.crystal.sceneVars.ELMS_LAB, 0x2515, "crystal wElmsLabSceneID")
eq(Gen2Layout.crystal.backup.sceneVars.ELMS_LAB,
Gen2Layout.crystal.sceneVars.ELMS_LAB - 0xE00, "the backup copy is the same byte shifted")
end
-- engine/pokemon/stats_screen.asm (#1899)
do
@@ -438,6 +490,7 @@ do
check(save ~= nil, "so the save still opens -- " .. tostring(err))
if save then
eq(save.player.name, "ASH", "and reads the same player out of the backup")
eq(save.mapScenes.ELMS_LAB, 2, "and the same map scenes out of the backup")
end
end
@@ -469,6 +522,7 @@ do
check(type(save.mail) == "table", "mail falls back to the new-game default")
check(type(save.hallOfFame) == "table", "so does the hall of fame")
check(type(save.phoneContacts) == "table", "and the phone book")
eq(save.mapScenes.ELMS_LAB, 2, "and the cart's map scenes survive the merge")
end
-- Export needs the cartridge image the save came from. Without one it is
-- refused rather than built from nothing.
@@ -0,0 +1,142 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local GameVersion = require("src.core.GameVersion")
local PaletteFX = require("src.render.PaletteFX")
local SpriteRenderer = require("src.render.SpriteRenderer")
local IntroMovie = require(os.getenv("INTRO_MOVIE_MODULE") or "src.ui.IntroMovie")
local prevVersion, prevMode = GameVersion.get(), PaletteFX.mode
local function frames(prefix)
local t = {}
for i = 1, 3 do
t["frame" .. i] = { path = "tests/fixtures/missing_" .. prefix .. i .. "_2264.png" }
end
return t
end
local game = {
data = { field = { intro = { nidorino = frames("nido"), gengar = frames("gengar") } } },
stack = { pop = function() end },
}
local bakes = {}
local realObp = SpriteRenderer.obpImage
SpriteRenderer.obpImage = function(path, colors, group)
bakes[#bakes + 1] = { path = path, colors = colors, group = group }
return realObp(path, colors, group)
end
local function drawFight(mode, version, setup)
GameVersion.set(version)
PaletteFX.setMode(mode)
local movie = IntroMovie.new(game)
movie.phase = 3
movie.nidoX, movie.nidoY = 72, 72
movie.gengarX, movie.gengarY = 24, 56
if setup then setup(movie) end
local before = #bakes
PaletteFX.clearSpriteRedraws()
PaletteFX.setPass("ui")
local ok, err = pcall(movie.draw, movie)
PaletteFX.setPass(nil)
check(ok, mode .. "/" .. version .. " draw runs headless" .. (ok and "" or (": " .. tostring(err))))
local out, newBakes = {}, {}
for i, r in ipairs(PaletteFX.uiSpriteRedraws()) do out[i] = r end
for i = before + 1, #bakes do newBakes[#newBakes + 1] = bakes[i] end
PaletteFX.clearSpriteRedraws()
return out, newBakes, movie
end
local function sameClip(c)
return c and c[1] == 0 and c[2] == 32 and c[3] == 160 and c[4] == 80
end
do
local r, b, movie = drawFight("ogred", "red")
eq(#r, 2, "ogred red: Nidorino and the Gengar restack are replayed")
local nido, gengar = r[1] or {}, r[2] or {}
eq(b[1] and b[1].path, movie.nidoPaths[1], "Nidorino bakes its current frame")
eq(b[1] and b[1].colors, PaletteFX.GBC_OBJ, "red Nidorino bakes the boot-ROM OBJ ramp")
eq(b[1] and b[1].group, "gbcobj", "red Nidorino bakes under the red OBJ group")
eq(nido.image, SpriteRenderer.obpImage(movie.nidoPaths[1], PaletteFX.GBC_OBJ, "gbcobj"),
"the replayed Nidorino image is the OBJ bake")
eq(nido.x, 72, "Nidorino replays at nidoX")
eq(nido.y, 72, "Nidorino replays at nidoY")
check(sameClip(nido.clip), "Nidorino replay is clipped to the playfield under the bars")
eq(b[2] and b[2].path, movie.gengarPaths[1], "Gengar restack bakes its current pose")
eq(b[2] and b[2].colors, PaletteFX.GBC_BG, "red Gengar restack bakes the boot-ROM BG ramp")
eq(gengar.x, 24, "Gengar replays at gengarX")
eq(gengar.y, 56, "Gengar replays at gengarY")
check(sameClip(gengar.clip), "Gengar restack is clipped to the playfield")
end
do
local r, b = drawFight("ogred", "blue")
eq(#r, 2, "ogred blue: Jigglypuff and the Gengar restack are replayed")
eq(b[1] and b[1].colors, PaletteFX.GBC_OBJ_BLUE, "blue Jigglypuff bakes Blue's pink OBJ ramp")
eq(b[1] and b[1].group, "gbcobj_blue", "blue bake uses its own cache group")
eq(b[2] and b[2].colors, PaletteFX.GBC_BG_BLUE, "blue Gengar restack bakes Blue's BG ramp")
check(b[2] and b[2].group ~= "ogbg", "blue Gengar bake never shares the red cache group")
end
do
local r, b, movie = drawFight("ogred", "red", function(m)
m.nidoFrame, m.gengarPose = 3, 2
end)
eq(b[1] and b[1].path, movie.nidoPaths[3], "lunge frame bakes frame 3")
eq(b[2] and b[2].path, movie.gengarPaths[2], "raised pose bakes pose 2")
eq(#r, 2, "no fade entry while fade is 0")
end
do
local r = drawFight("ogred", "red", function(m) m.fade = 0.5 end)
eq(#r, 3, "GBFadeOutToWhite adds a white wash over the replayed layer")
local w = r[3] or {}
eq(w.color and w.color[4], 0.5, "the wash carries the fade alpha")
eq(w.sx, 160, "the wash spans the playfield width")
eq(w.sy, 80, "the wash spans the playfield height")
eq(w.x, 0, "the wash starts at the playfield left")
eq(w.y, 32, "the wash starts under the top bar")
check(sameClip(w.clip), "the wash is clipped to the playfield")
end
do
PaletteFX.setDarkWorld(true)
PaletteFX.setFadeObp({ [0] = 3, 3, 3, 3 })
local _, b = drawFight("ogred", "red")
PaletteFX.setDarkWorld(false)
PaletteFX.setFadeObp(nil)
eq(b[1] and b[1].colors, PaletteFX.GBC_OBJ, "stale dark / fade OBP state never reaches the identity rOBP0 bake")
end
do
local r, b = drawFight("gbc", "red")
eq(#r, 0, "SGB keeps the PURPLEMON zone Nidorino with no OBJ replay")
eq(#b, 0, "SGB never bakes the boot-ROM ramps")
r = drawFight("gbc", "blue")
eq(#r, 0, "SGB blue keeps the zone Jigglypuff")
for _, mode in ipairs({ "redpp", "og", "classic" }) do
r = drawFight(mode, "red")
eq(#r, 0, mode .. " records no intro OBJ replay")
end
r = drawFight("ogred", "yellow")
eq(#r, 0, "OG YELLOW keeps the CGB zone path")
end
do
local r = drawFight("ogred", "red", function(m) m.phase = 2 end)
eq(#r, 0, "the splash never records the fight replay")
r = drawFight("ogred", "red", function(m) m.phase = 4 end)
eq(#r, 0, "the post-fade white hold records nothing")
end
SpriteRenderer.obpImage = realObp
GameVersion.set(prevVersion)
PaletteFX.setMode(prevMode)
T.finish()
@@ -0,0 +1,111 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
local SaveData = require("src.core.SaveData")
local OW = require("src.world.OverworldController")
local function setUpvalue(fn, name, val)
local i = 1
while true do
local n = debug.getupvalue(fn, i)
if not n then return false end
if n == name then debug.setupvalue(fn, i, val); return true end
i = i + 1
end
end
local pushed = {}
local textBoxStub = {
new = function(_, text, onDone, opts)
return { text = text, onDone = onDone, opts = opts }
end,
soundOpts = function() return {} end,
}
local MAP_ID = "FIX_CORNER"
Data.field.hiddenCoins = Data.field.hiddenCoins or {}
Data.field.slotMachines = Data.field.slotMachines or {}
-- data/events/hidden_events.asm:262
Data.field.slotMachines[MAP_ID] = {
{ x = 12, y = 14, state = "ok" },
{ x = 12, y = 15, state = "ok" },
}
-- data/events/hidden_events.asm:298
Data.field.hiddenCoins[MAP_ID] = {
{ x = 10, y = 16, coins = 10 },
{ x = 12, y = 15, coins = 10 },
}
local function mkGame(opts)
local save = SaveData.newGame()
save.player.name = "FAKEPLAYER"
if opts.coinCase then save.inventory.COIN_CASE = 1 end
save.coins = opts.coins or 0
if opts.taken then save.hiddenTaken = { [MAP_ID .. "_12_15"] = true } end
pushed = {}
local game = {
data = Data, save = save,
stack = { push = function(_, item) pushed[#pushed + 1] = item end },
}
setUpvalue(OW.tryHiddenObject, "Game", game)
return game
end
T.check(setUpvalue(OW.tryHiddenObject, "TextBox", textBoxStub), "TextBox upvalue on tryHiddenObject")
local fakeSelf = setmetatable({
map = { id = MAP_ID }, player = { facing = "up" },
}, { __index = OW })
local function text(i) return (pushed[i] and pushed[i].text) or "" end
do
local game = mkGame({ coinCase = true, coins = 0 })
local handled = fakeSelf:tryHiddenObject(12, 15)
T.check(handled == true, "shared seat handles A with a COIN CASE and no coins")
T.eq(game.save.coins, 0, "shared seat pays no hidden coins")
T.check(not (game.save.hiddenTaken or {})[MAP_ID .. "_12_15"], "shared seat sets no hiddenTaken key")
T.check(text(1):find("any coins", 1, true) ~= nil, "shared seat prints the no-coins slot text")
end
do
local game = mkGame({ coinCase = true, coins = 50 })
fakeSelf:tryHiddenObject(12, 15)
T.eq(game.save.coins, 50, "shared seat with coins leaves the balance alone")
T.check(pushed[1] and pushed[1].opts and type(pushed[1].opts.choice) == "function",
"shared seat opens the slot machine prompt")
end
do
mkGame({ coins = 0 })
local handled = fakeSelf:tryHiddenObject(12, 15)
T.check(handled == true, "shared seat without a COIN CASE still answers")
T.check(text(1):find("COIN CASE", 1, true) ~= nil, "shared seat asks for a COIN CASE")
end
do
mkGame({ coinCase = true, coins = 50, taken = true })
local handled = fakeSelf:tryHiddenObject(12, 15)
T.check(handled == true, "shared seat still works after the coin flag was set")
T.check(pushed[1] and pushed[1].opts and type(pushed[1].opts.choice) == "function",
"shared seat with a stale coin flag opens the slot prompt")
end
do
local game = mkGame({ coinCase = true, coins = 0 })
local handled = fakeSelf:tryHiddenObject(10, 16)
T.check(handled == true, "coin-only cell is found")
T.check(game.save.coins > 0, "coin-only cell pays coins")
T.check(game.save.hiddenTaken[MAP_ID .. "_10_16"] == true, "coin-only cell sets its flag")
end
do
mkGame({ coinCase = true, coins = 50 })
fakeSelf:tryHiddenObject(12, 14)
T.check(pushed[1] and pushed[1].opts and type(pushed[1].opts.choice) == "function",
"slot-only seat opens the slot prompt")
end
T.finish("overworld_slot_seat_over_hidden_coin")
+169
View File
@@ -0,0 +1,169 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local GameVersion = require("src.core.GameVersion")
local PaletteFX = require("src.render.PaletteFX")
local SpriteRenderer = require("src.render.SpriteRenderer")
local PartyMenu = require("src.ui.PartyMenu")
local prevVersion, prevMode = GameVersion.get(), PaletteFX.mode
local MON_PATH = "tests/fixtures/missing_icon_mon_2268.png"
local HELIX_PATH = "tests/fixtures/missing_icon_helix_2268.png"
local MOD_PATH = "tests/fixtures/missing_icon_mod_2268.png"
local game = {
data = {
icons = {
icons = { MON = MON_PATH, HELIX = HELIX_PATH },
byDex = { [34] = "MON", [138] = "HELIX" },
bySpecies = { MEWTWO = { image = MOD_PATH, trueColor = true } },
},
pokemon = {
NIDOKING = { dex = 34 }, OMANYTE = { dex = 138 }, MEWTWO = { dex = 150 },
},
},
}
local function mon(species)
return { species = species, hp = 10, stats = { hp = 10 } }
end
local bakes = {}
local realObp = SpriteRenderer.obpImage
SpriteRenderer.obpImage = function(path, colors, group)
bakes[#bakes + 1] = { path = path, colors = colors, group = group }
return realObp(path, colors, group)
end
local function same(a, b)
return a and b and a[1] == b[1] and a[2] == b[2] and a[3] == b[3]
end
local function drawIn(species, mode, version)
GameVersion.set(version or "red")
PaletteFX.setMode(mode)
PaletteFX.clearSpriteRedraws()
PaletteFX.clearTrueColor()
PaletteFX.setPass("ui")
local draws = {}
local realDraw = love.graphics.draw
love.graphics.draw = function(img) draws[#draws + 1] = img end
local ok, err = pcall(PartyMenu.drawIcon, game, mon(species), 8, 8, false, 0)
love.graphics.draw = realDraw
PaletteFX.setPass(nil)
check(ok, species .. " drawIcon runs headless" .. (ok and "" or (": " .. tostring(err))))
local out = {}
for i, r in ipairs(PaletteFX.uiSpriteRedraws()) do out[i] = r end
return out, draws
end
do
GameVersion.set("red")
local c, g = PaletteFX.ogObjNormal()
check(same(c and c[2], PaletteFX.GBC_OBJ[1]), "red: OBJ color 1 -> boot OBJ shade 0")
check(same(c and c[3], PaletteFX.GBC_OBJ[2]), "red: OBJ color 2 -> boot OBJ shade 1 (light green)")
check(same(c and c[4], PaletteFX.GBC_OBJ[4]), "red: OBJ color 3 -> boot OBJ shade 3")
GameVersion.set("blue")
local cb, gb = PaletteFX.ogObjNormal()
check(same(cb and cb[3], PaletteFX.GBC_OBJ_BLUE[2]), "blue: OBJ color 2 -> pink OBJ shade 1")
check(g ~= nil and gb ~= nil and g ~= gb, "red and blue bakes use distinct cache groups")
GameVersion.set("red")
PaletteFX.setDarkWorld(true)
PaletteFX.setFadeObp({ [0] = 3, 3, 3, 3 })
local cd, gd = PaletteFX.ogObjNormal()
PaletteFX.setDarkWorld(false)
PaletteFX.setFadeObp(nil)
eq(cd, c, "dark / fade OBP state never reaches the GBPalNormal ramp")
eq(gd, g, "dark / fade OBP state never changes the cache group")
end
do
local redraws, draws = drawIn("NIDOKING", "ogred", "red")
eq(#redraws, 2, "ogred red: mirrored icon records both OAM halves")
local a, b = redraws[1] or {}, redraws[2] or {}
eq(a.x, 8, "left half replays at x")
eq(a.sx, 1, "left half is unflipped")
eq(b.x, 24, "flipped half replays about the block's right edge")
eq(b.sx, -1, "flipped half carries sx = -1")
check(a.quad ~= nil and a.quad == b.quad, "both halves replay the same 8x16 quad")
check(a.image ~= nil and a.image == draws[1] and a.image == draws[2],
"the replayed image is the one drawn into the canvas")
local bake = bakes[#bakes] or {}
eq(bake.path, MON_PATH, "ogred red bakes the icon art")
local c, g = PaletteFX.ogObjNormal()
eq(bake.colors, c, "ogred red bakes the GBPalNormal OBJ ramp")
eq(bake.group, g, "ogred red bakes under its own cache group")
end
do
local before = #bakes
local redraws = drawIn("NIDOKING", "ogred", "blue")
eq(#redraws, 2, "ogred blue: mirrored icon records both OAM halves")
local bake = bakes[before + 1] or {}
check(bake.colors and same(bake.colors[3], PaletteFX.GBC_OBJ_BLUE[2]),
"ogred blue bakes Blue's pink OBJ ramp")
end
do
local redraws = drawIn("OMANYTE", "ogred", "red")
eq(#redraws, 1, "ogred: single-frame HELIX records one redraw")
local r = redraws[1] or {}
eq(r.quad, nil, "HELIX replays the whole image")
eq(r.sx, 1, "HELIX is unflipped")
end
do
local before = #bakes
local redraws = drawIn("NIDOKING", "gbc", "red")
eq(#redraws, 0, "SGB keeps the zone-colored icon with no OBJ replay")
eq(#bakes, before, "SGB never bakes the boot OBJ ramp")
redraws = drawIn("NIDOKING", "redpp", "red")
eq(#redraws, 0, "RED++ keeps the zone-colored icon with no OBJ replay")
redraws = drawIn("NIDOKING", "ogred", "yellow")
eq(#redraws, 0, "OG YELLOW keeps the CGB zone path")
end
do
local before = #bakes
local redraws = drawIn("MEWTWO", "ogred", "red")
eq(#redraws, 0, "trueColor mod art records no OBJ replay under ogred")
eq(#bakes, before, "trueColor mod art is never OBP-baked")
eq(#PaletteFX.trueColorRects("ui"), 1, "trueColor mod art keeps its unshaded rect")
end
do
PaletteFX.clearSpriteRedraws()
PaletteFX.clearTrueColor()
PaletteFX.setPass("ui")
PaletteFX.setMarkOffset(72)
local img = {}
PaletteFX.markUiSpriteRedraw(img, nil, 4, 5)
PaletteFX.markUiSpriteRedraw(img, nil, 0, 32,
{ sx = 160, sy = 80, clip = { 0, 32, 160, 80 }, color = { 1, 1, 1, 0.5 } })
PaletteFX.setMarkOffset(0)
PaletteFX.setPass(nil)
local r = PaletteFX.uiSpriteRedraws()
local p, q = r[1] or {}, r[2] or {}
eq(p.x, 76, "plain mark shifts by the mark offset")
eq(p.sx, 1, "plain mark defaults sx to 1")
eq(p.sy, 1, "plain mark defaults sy to 1")
eq(p.clip, nil, "plain mark has no clip")
eq(p.color, nil, "plain mark has no tint")
eq(q.sx, 160, "opts.sx is stored")
eq(q.sy, 80, "opts.sy is stored")
eq(q.clip and q.clip[1], 72, "opts.clip shifts by the mark offset")
eq(q.clip and q.clip[4], 80, "opts.clip keeps its size")
eq(q.color and q.color[4], 0.5, "opts.color is stored")
PaletteFX.clearSpriteRedraws()
end
SpriteRenderer.obpImage = realObp
GameVersion.set(prevVersion)
PaletteFX.setMode(prevMode)
T.finish()
@@ -0,0 +1,35 @@
-- pokered/scripts/SaffronCity.asm:76
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local function findRow(rows, cmd)
for i, row in ipairs(rows) do
if row[1] == cmd then return i end
end
return nil
end
local chunk = loadfile("data/scripts/flavor/saffron_city.lua")
T.check(chunk ~= nil, "saffron_city flavor file exists")
local mod = chunk and chunk() or {}
local rows = mod.SAFFRON_CITY and mod.SAFFRON_CITY.talk
and mod.SAFFRON_CITY.talk.TEXT_SAFFRONCITY_PIDGEOT or {}
local cry = findRow(rows, "play_cry")
local text = findRow(rows, "show_text")
T.check(cry ~= nil, "saffron_city pidgeot has a play_cry row")
T.check(text ~= nil, "saffron_city pidgeot has a show_text row")
T.check(cry ~= nil and text ~= nil and cry < text,
"saffron_city pidgeot play_cry precedes show_text")
T.eq(cry and rows[cry][2], "PIDGEOT", "saffron_city pidgeot plays PIDGEOT cry")
T.eq(cry and rows[cry][3], true, "saffron_city pidgeot cry holds for A/B")
T.eq(text and rows[text][2], "_SaffronCityPidgeotText",
"saffron_city pidgeot shows _SaffronCityPidgeotText")
local src = assert(io.open("data/scripts/flavor_all.lua", "r"))
local index = src:read("*a")
src:close()
T.check(index:find('"data.scripts.flavor.saffron_city"', 1, true) ~= nil,
"flavor_all indexes saffron_city")
T.finish("saffron_city_pidgeot_cry_bug2262")
@@ -0,0 +1,139 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local T = require("tests.modkit")
local check, eq = T.check, T.eq
local Data = T.fixtures.fresh()
Data.tilesets.FIX_OUT.tilesPerRow = 16
Data.field.flyWarps = Data.field.flyWarps or {}
Data.field.playerSprites = { walk = "SPRITE_FIX_PLAYER" }
Data.field.waterTilesets = {}
Data.field.forcedMovement = { tiles = {
FIX_ROUTE = { { x = 4, y = 6, mode = "surf" } },
} }
Data.field.seafoam = {
FIX_ROUTE = {
currentsDisabledByEvents = { "EVENT_FIX_CURRENT_PLUGGED" },
currents = { { x = 4, y = 6, moves = { { dir = "down", count = 2 } } } },
},
}
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local StateStack = require("src.core.StateStack")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local Pokemon = require("src.pokemon.Pokemon")
local Music = require("src.core.Music")
local OW = require("src.world.OverworldController")
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame()
Game.save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
local stack = Game.stack
local ow
local surfMusicEarly, surfMusicLate = 0, 0
Music.playMap = function(_, _, _, surfing)
if not surfing or not ow then return end
if ow.transitioning or ow.holeArrive or ow.spinArrive or stack:top() ~= ow then
surfMusicEarly = surfMusicEarly + 1
else
surfMusicLate = surfMusicLate + 1
end
end
local function newOW()
stack:push(OW, "FIX_TOWN", 5, 6, "down")
local o = stack:top()
Game.overworld = o
return o
end
ow = newOW()
ow.player.surfing = true
ow:fallThroughHole("FIX_ROUTE", 4, 6, "up")
local mid
local earlySurf, earlyMoves, dropFrames, badDrop = 0, 0, 0, 0
local landedSurfing
local slideFrames, slideSpin = 0, 0
local finalY
local spinEndedAt, surfAt, currentAt
for tick = 1, 500 do
stack:update()
local p = ow.player
if ow.map.id == "FIX_ROUTE" then
if not mid then
mid = { surfing = p.surfing, moves = #ow.scriptMoves }
end
if dropFrames > 0 and not p.spinning and not spinEndedAt then spinEndedAt = tick end
if p.surfing and not surfAt then surfAt = tick end
if #ow.scriptMoves > 0 and not currentAt then currentAt = tick end
local landing = ow.transitioning or ow.holeArrive or ow.spinArrive
or stack:top() ~= ow
if landing then
if p.surfing then earlySurf = earlySurf + 1 end
if #ow.scriptMoves > 0 or p.cellY ~= 6 then earlyMoves = earlyMoves + 1 end
if p.spinning then
dropFrames = dropFrames + 1
local _, _, _, facing, phase, flip = p:pose()
if facing ~= "down" or phase ~= 1 or flip ~= true then
badDrop = badDrop + 1
end
end
else
if landedSurfing == nil then landedSurfing = p.surfing end
if #ow.scriptMoves > 0 or p.moving then
slideFrames = slideFrames + 1
if p.spinning or ow.spinnerSliding then slideSpin = slideSpin + 1 end
elseif slideFrames > 0 then
finalY = p.cellY
break
end
end
end
end
check(mid ~= nil, "the hole fall reaches the destination map")
eq(mid and mid.surfing, false, "the dungeon warp lands on foot, not surfing")
eq(mid and mid.moves, 0, "the current is not armed at the warp midpoint")
eq(earlySurf, 0, "no surf sprite through the hold and the drop")
eq(earlyMoves, 0, "the current never moves the player before the landing")
eq(surfMusicEarly, 0, "no surf music before the landing")
check(dropFrames > 0, "the drop animation ran (" .. dropFrames .. " frames)")
eq(badDrop, 0, "the drop keeps image index 3 (walking down, flipped), no rotation")
eq(landedSurfing, true, "SURF mounts once the drop lands")
-- engine/overworld/player_animations.asm:329
eq(surfAt, spinEndedAt, "SURF mounts on the frame the drop's last step lands")
eq(currentAt, spinEndedAt, "the current is armed on that same frame")
check(surfMusicLate > 0, "surf music starts after the landing")
check(slideFrames > 0, "the current slides the player after the landing")
eq(slideSpin, 0, "the current does not spin the sprite")
eq(finalY, 8, "the current carried the player two cells south")
do
local o = newOW()
o:runSpinnerMoves({ { dir = "down", count = 1 } }, 1)
eq(o.player.spinning, true, "a spinner arrow still spins the sprite")
eq(o.spinnerSliding, true, "a spinner arrow still freezes tile animation")
end
do
local o = newOW()
o.arriveWarp = "teleport"
o:startWarpTo("FIX_ROUTE", 4, 6, "down")
for _ = 1, 200 do
stack:update()
if o.player.spinDrop then break end
end
check(o.player.spinDrop == true, "a teleport arrival arms its spin-down")
eq(o.player.spinImageIndex, nil, "a teleport arrival keeps its own spin")
eq(o.pendingEnterMapTail, nil, "a teleport arrival does not defer EnterMap")
end
T.finish("seafoam_hole_current_bug2274")
@@ -0,0 +1,242 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
package.loaded["src.core.Logger"] = {
info = function() end, warn = function() end, error = function() end,
}
love.filesystem.write = function() return true end
love.graphics.getRendererInfo = function() return "OpenGL", "4.1 stub", "stub", "stub" end
love.graphics.getSupported = function() return { glsl3 = true } end
love.graphics.flushBatch = function() end
love.graphics.validateShader = function() return true end
love.timer = love.timer or {}
love.timer.getDelta = love.timer.getDelta or function() return 1 / 60 end
local sends = {}
love.graphics.newShader = function(frag, vert)
local sh = { frag = frag, vert = vert }
function sh.send(self, name, value) sends[#sends + 1] = { shader = self, name = name, value = value } end
return sh
end
local quads, draws, scissors = {}, {}, {}
love.graphics.newQuad = function(x, y, w, h, sw, sh)
local q = { x = x, y = y, w = w, h = h, sw = sw, sh = sh }
quads[#quads + 1] = q
return q
end
love.graphics.draw = function(img, a, b, c, d, e, f)
if type(a) == "table" then
draws[#draws + 1] = { img = img, quad = a, x = b, y = c, sx = e, sy = f }
else
draws[#draws + 1] = { img = img, x = a, y = b, sx = d, sy = e }
end
end
love.graphics.setScissor = function(x, y, w, h)
scissors[#scissors + 1] = x and { x, y, w, h } or false
end
local ShaderFX = require("src.render.ShaderFX")
local function entry(name)
return { name = name .. ".slangp", fullPath = "tests/data/shaderfx/gl/" .. name .. ".slangp", converted = true }
end
local function windowCanvas(w, h)
return setmetatable({ w = w, h = h }, { __index = {
getWidth = function(self) return self.w end,
getHeight = function(self) return self.h end,
getPixelWidth = function(self) return self.w end,
getPixelHeight = function(self) return self.h end,
} })
end
local ok = ShaderFX.activate("main", entry("lcd3x"))
eq(ok, true, "lcd3x activates under the stub")
local realRunChain = ShaderFX.runChain
local captured
ShaderFX.runChain = function(state, img, luts, viewport, original, layer)
local out = realRunChain(state, img, luts, viewport, original, layer)
captured = { viewport = viewport, original = original, out = out, img = img }
return out
end
local function render(W, H, s, originX, originY, dpi, opts)
quads, draws, scissors, sends, captured = {}, {}, {}, {}, nil
dpi = dpi or 1
opts = opts or {}
opts.originX, opts.originY = originX, originY
local canvas = windowCanvas(W, H)
ShaderFX.render(canvas, { x = 0, y = 0, w = W, h = H, scale = s },
{ w = W / s, h = H / s }, dpi, dpi, opts)
return canvas
end
local function findDraw(img)
for _, d in ipairs(draws) do
if d.img == img then return d end
end
end
local function near(a, b) return math.abs(a - b) < 1e-9 end
local cases = {
{ label = "FIT", W = 1024, H = 768, s = 5, ox = 112, oy = 24 },
{ label = "OUT1", W = 1024, H = 768, s = 4, ox = 2, oy = 1 },
{ label = "OUT2", W = 1024, H = 768, s = 3, ox = 0, oy = 0 },
{ label = "OUT2 camera phase", W = 1024, H = 768, s = 3, ox = -7, oy = 5 },
{ label = "FIT 1280x720", W = 1280, H = 720, s = 5, ox = 240, oy = 0 },
}
for _, c in ipairs(cases) do
local canvas = render(c.W, c.H, c.s, c.ox, c.oy)
check(captured ~= nil, c.label .. ": the chain ran")
if captured then
local vp, orig = captured.viewport, captured.original
eq(vp.w, orig.w * c.s, c.label .. ": viewport width is exactly scale x source width")
eq(vp.h, orig.h * c.s, c.label .. ": viewport height is exactly scale x source height")
local q = quads[1]
check(q ~= nil, c.label .. ": the crop uses a quad")
local x0, y0 = math.floor(q.x), math.floor(q.y)
eq((c.ox - x0) % c.s, 0, c.label .. ": crop starts on a GB pixel column edge")
eq((c.oy - y0) % c.s, 0, c.label .. ": crop starts on a GB pixel row edge")
check(x0 <= 0 and x0 > -c.s and y0 <= 0 and y0 > -c.s,
c.label .. ": crop starts within one GB pixel above/left of the window")
eq(q.w, orig.w * c.s, c.label .. ": crop quad spans whole GB pixel columns")
eq(q.h, orig.h * c.s, c.label .. ": crop quad spans whole GB pixel rows")
check(x0 + q.w >= c.W and y0 + q.h >= c.H, c.label .. ": crop covers the whole window")
check(q.x - x0 > 0 and q.x - x0 < 0.5 and q.y - y0 > 0 and q.y - y0 < 0.5,
c.label .. ": crop samples inside a physical pixel, never on its edge")
local cropDraw = findDraw(canvas)
check(cropDraw and near(cropDraw.sx, 1 / c.s), c.label .. ": crop draws at exactly 1/scale")
local passDraw = findDraw(captured.img)
check(passDraw and near(passDraw.sx, c.s) and near(passDraw.sy, c.s),
c.label .. ": the viewport pass stretches the source by the integer scale")
check(passDraw and passDraw.x >= 1 / 16 and passDraw.x < 0.5 and passDraw.y >= 1 / 16 and passDraw.y < 0.5,
c.label .. ": the integer viewport pass bias is at least a rasterizer subpixel and under half a pixel")
if passDraw then
for j = 0, c.s * 4 do
eq(math.floor((j + 0.5 - passDraw.y) / c.s), math.floor((j + 0.5) / c.s),
c.label .. ": the bias never changes which texel output row " .. j .. " samples")
end
end
local final = findDraw(captured.out)
check(final ~= nil, c.label .. ": chain output reaches the screen")
if final then
eq(final.x, x0, c.label .. ": chain output lands where the crop was taken (x)")
eq(final.y, y0, c.label .. ": chain output lands where the crop was taken (y)")
local cw, ch = captured.out:getWidth(), captured.out:getHeight()
check(near(final.sx * cw, orig.w * c.s) and near(final.sy * ch, orig.h * c.s),
c.label .. ": chain output is exactly scale physical px per GB pixel")
end
local sc = scissors[#scissors - 1]
check(type(sc) == "table" and sc[1] == 0 and sc[2] == 0 and sc[3] == c.W and sc[4] == c.H,
c.label .. ": the overhanging output is clipped to the window")
end
end
do
local canvas = render(1024, 768, 3, 1, 2, 1, { layer = "ui", mask = true })
local maskRect
for _, s in ipairs(sends) do
if s.name == "maskRect" then maskRect = s.value end
end
check(maskRect ~= nil, "masked UI layer sends the mask rect")
if maskRect and captured then
local x0, y0 = -2, -1
check(near(maskRect[1], x0 / 1024) and near(maskRect[2], y0 / 768),
"mask rect starts at the crop origin")
check(near(maskRect[3], captured.original.w * 3 / 1024) and near(maskRect[4], captured.original.h * 3 / 768),
"mask rect spans the drawn chain output")
end
check(canvas ~= nil, "masked render ran")
end
do
render(2048, 1536, 6, 224, 48, 2)
local q = quads[1]
local final = captured and findDraw(captured.out)
check(q and final, "HiDPI render ran")
if q and final then
local x0 = math.floor(q.x)
eq((224 - x0) % 6, 0, "HiDPI: crop aligned to the physical UI origin")
eq(final.x, x0 / 2, "HiDPI: chain output placed in logical units")
eq(captured.viewport.w, captured.original.w * 6, "HiDPI: viewport is exactly scale x source")
end
end
do
render(1024, 768, 4.8, 12, 0)
local q = quads[1]
check(q and q.x == 0 and q.y == 0 and q.w == 1024 and q.h == 768,
"fractional scale keeps the whole-window crop")
eq(captured.viewport.w, 1024, "fractional scale keeps the window viewport width")
eq(captured.viewport.h, 768, "fractional scale keeps the window viewport height")
eq(captured.original.w, math.floor(1024 / 4.8 + 0.5), "fractional scale keeps the rounded source width")
local passDraw = findDraw(captured.img)
check(passDraw and passDraw.x == 0 and passDraw.y == 0,
"a non-integer viewport stretch draws unbiased")
local final = findDraw(captured.out)
check(final and final.x == 0 and final.y == 0
and near(final.sx * captured.out:getWidth(), 1024),
"fractional scale draws the chain output over the window as before")
for _, sc in ipairs(scissors) do
check(sc == false, "fractional scale adds no scissor")
end
end
ShaderFX.runChain = realRunChain
ShaderFX.deactivate("main")
do
draws = {}
local canvas = windowCanvas(1024, 768)
ShaderFX.render(canvas, { x = 0, y = 0, w = 1024, h = 768, scale = 3 },
{ w = 1024 / 3, h = 256 }, 1, 1, { originX = 1, originY = 1 })
eq(#draws, 1, "shader off: one draw")
check(draws[1] and draws[1].img == canvas and draws[1].x == 0 and draws[1].y == 0 and draws[1].sx == nil,
"shader off: the canvas is drawn untouched at the origin")
end
do
local Game2 = require("src.core.Game2")
local Chrome = require("src.ui.gen2.Chrome")
local function game(zoom, fit, cam)
return setmetatable({
world = {
map = {}, camera = cam or { x = 9, y = 27 },
zoomScale = function() return zoom end,
fitScale = function() return fit end,
},
}, { __index = Game2 })
end
eq(Chrome.fitScale(1024, 768), 5, "1024x768 fits at 5")
local ox, oy = Chrome.fitOrigin(1024, 768, 5)
eq(ox, 112, "1024x768 fit origin x")
eq(oy, 24, "1024x768 fit origin y")
local wx, wy = game(5, 5):fxWorldOrigin(1024, 768, 5)
eq(wx, -45, "world grid origin follows the camera x")
eq(wy, -135, "world grid origin follows the camera y")
eq(game(5, 5):fxSplitsUi(1024, 768), true,
"Gen 2 at FIT splits the UI layer when the menu grid phase differs from the world camera grid")
eq(game(5, 5, { x = 0, y = 0 }):fxSplitsUi(1024, 768), true,
"1024x768 never aligns at FIT: the fit origin is off the camera grid by 2,4 for every integer camera")
eq(game(5, 5, { x = 4, y = 11 }):fxSplitsUi(1280, 720), false,
"1280x720 (origin 240,0) shares phase with the camera grid, no split at FIT")
eq(game(3, 5):fxSplitsUi(1280, 720), true, "a zoomed world always splits the UI layer")
eq(setmetatable({ world = { map = nil } }, { __index = Game2 }):fxSplitsUi(1024, 768), false,
"no map, no split")
eq(setmetatable({}, { __index = Game2 }):fxSplitsUi(1024, 768), false, "no world, no split")
end
T.finish("shaderfx_grid_alignment_2235")
@@ -0,0 +1,144 @@
-- pokered/home/trainers.asm:123
-- pokered/home/text_script.asm:96
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local check, eq = T.check, T.eq
local Data = T.fixtures.fresh()
require("src.render.Font").load(Data)
Data.text = {}
local Sound = require("src.core.Sound")
local Music = require("src.core.Music")
local Commands = require("src.script.Commands")
local ScriptRunner = require("src.script.ScriptRunner")
local frame = 0
local CRY_FRAMES = 30
local cryEndedAt
local cries = {}
Sound.playCry = function(_, species)
local endAt = frame + CRY_FRAMES
cries[#cries + 1] = species
return {
getDuration = function() return CRY_FRAMES / 60 end,
getPitch = function() return 1 end,
stop = function() end,
isPlaying = function()
local playing = frame < endAt
if not playing and not cryEndedAt then cryEndedAt = frame end
return playing
end,
}
end
Sound.play = function() return nil end
Sound.playPress = function() return nil end
local songs = {}
Music.play = function(_, song)
songs[#songs + 1] = { song = song, frame = frame, cryEndedAt = cryEndedAt }
end
local battles = {}
Commands.start_battle = function(ctx, kind, species, level)
battles[#battles + 1] = { kind = kind, species = species, level = level,
songs = #songs }
ctx.lastBattleResult = "win"
end
local stack = { states = {} }
function stack:push(s) self.states[#self.states + 1] = s end
function stack:pop() return table.remove(self.states) end
function stack:top() return self.states[#self.states] end
local pressed = {}
local game = {
data = Data,
save = { flags = {}, player = { name = "RED" }, options = { textSpeed = 1 } },
stack = stack,
input = {
wasPressed = function(_, key) return pressed[key] or false end,
isDown = function() return false end,
},
}
local runner
local function step(btn)
frame = frame + 1
pressed = btn and { [btn] = true } or {}
local top = stack:top()
if top then top:update(1 / 60) end
pressed = {}
runner:update()
end
local function load(path, mapId, textId)
local mod = assert(loadfile(path))()
return mod[mapId].talk[textId]
end
local function start(script)
stack.states, songs, battles, cries, cryEndedAt = {}, {}, {}, {}, nil
runner = ScriptRunner.new(game, nil)
runner:run(script, { npc = { def = {} } })
end
local CASES = {
{ "data/scripts/flavor/seafoam_islands_b4f.lua", "SEAFOAM_ISLANDS_B4F",
"TEXT_SEAFOAMISLANDSB4F_ARTICUNO", "ARTICUNO", "EVENT_BEAT_ARTICUNO" },
{ "data/scripts/flavor/power_plant.lua", "POWER_PLANT",
"TEXT_POWERPLANT_ZAPDOS", "ZAPDOS", "EVENT_BEAT_ZAPDOS" },
{ "data/scripts/flavor/victory_road_2f.lua", "VICTORY_ROAD_2F",
"TEXT_VICTORYROAD2F_MOLTRES", "MOLTRES", "EVENT_BEAT_MOLTRES" },
{ "data/scripts/flavor/cerulean_cave_b1f.lua", "CERULEAN_CAVE_B1F",
"TEXT_CERULEANCAVEB1F_MEWTWO", "MEWTWO", "EVENT_BEAT_MEWTWO" },
{ "data/scripts/flavor/power_plant.lua", "POWER_PLANT",
"TEXT_POWERPLANT_VOLTORB1", nil, "EVENT_BEAT_POWER_PLANT_VOLTORB_0",
"VOLTORB" },
}
for _, c in ipairs(CASES) do
local path, mapId, textId, crySpecies, flag = c[1], c[2], c[3], c[4], c[5]
local species = c[6] or crySpecies
local script = load(path, mapId, textId)
check(type(script) == "table", textId .. " resolves")
game.save.flags = {}
start(script)
for _ = 1, 400 do step() end
eq(#battles, 0, textId .. ": no battle before A/B")
check(stack:top() ~= nil, textId .. ": box still up without a press")
eq(#songs, 1, textId .. ": one engage sting while the box waits")
eq(songs[1] and songs[1].song, "Music_MeetMaleTrainer",
textId .. ": sting is Music_MeetMaleTrainer")
if crySpecies then
eq(cries[1], crySpecies, textId .. ": cry plays")
check(songs[1] and songs[1].cryEndedAt ~= nil
and songs[1].frame >= songs[1].cryEndedAt,
textId .. ": sting starts after the cry finishes")
else
eq(#cries, 0, textId .. ": no cry")
end
step("a")
for _ = 1, 10 do step() end
check(not runner:isRunning(), textId .. ": script finishes after A")
eq(#battles, 1, textId .. ": one battle after A")
eq(battles[1] and battles[1].species, species, textId .. ": battle species")
eq(battles[1] and battles[1].songs, 1, textId .. ": sting precedes battle")
check(game.save.flags[flag] == true, textId .. ": beat flag set")
start(script)
for _ = 1, 400 do step() end
check(stack:top() ~= nil, textId .. " beaten: box waits for A/B")
if crySpecies then
eq(cries[1], crySpecies, textId .. " beaten: cry still plays")
end
step("a")
for _ = 1, 10 do step() end
check(not runner:isRunning(), textId .. " beaten: script finishes after A")
eq(#songs, 0, textId .. " beaten: no sting")
eq(#battles, 0, textId .. " beaten: no battle")
end
T.finish("static_encounter_sting_bug2275_2261")
+118
View File
@@ -0,0 +1,118 @@
-- engine/battle/core.asm:293
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
require("src.render.Font").load(Data)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
Data.moves.FIX_THRASH = {
id = "FIX_THRASH", index = 91, name = "FIX THRASH", type = "NORMAL",
power = 90, accuracy = 100, pp = 20, effect = "THRASH_PETAL_DANCE_EFFECT",
}
local function newBattle()
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 40) }
local game = { data = Data, save = save,
input = { wasPressed = function() return false end,
isDown = function() return false end },
stack = { top = function() return nil end, push = function() end } }
local battle = BattleState.newWild(game, "FIXMON_C", 40)
battle.rng = function(a) if a then return a end return 0 end
battle.introSlide = nil
return battle
end
local function drainToMenu(battle)
battle.queue, battle.nextInsert = { { wait = 2 } }, 0
battle.phase = "messages"
battle.afterQueue = "menu"
end
local function stepNeverMenu(battle, label)
local sawMenu, turns = nil, 0
local startTurn = battle.turnCount or 0
for i = 1, 10 do
battle:update(1 / 60)
if battle.phase == "menu" and not sawMenu then sawMenu = i end
end
turns = (battle.turnCount or 0) - startTurn
T.check(sawMenu == nil, label .. ": no frame leaves phase == menu (first seen frame "
.. tostring(sawMenu) .. ")")
T.check(turns >= 1, label .. ": the locked turn still resolves")
end
do
local battle = newBattle()
battle.player.thrashTurns = 2
battle.player.thrashMove = battle.player.curMoves[1]
battle.player.thrashAnnounced = true
drainToMenu(battle)
stepNeverMenu(battle, "thrash")
end
do
local battle = newBattle()
battle.player.mustRecharge = true
drainToMenu(battle)
stepNeverMenu(battle, "recharge")
end
do
local battle = newBattle()
battle.player.charging = battle.player.curMoves[1]
drainToMenu(battle)
stepNeverMenu(battle, "charging")
end
do
local battle = newBattle()
battle.player.flinched = true
battle.player.thrashTurns = 2
battle.player.thrashMove = battle.player.curMoves[1]
battle.player.thrashAnnounced = true
battle.player.mon.hp = 40
battle.player.shownHP = 10
drainToMenu(battle)
battle:update(1 / 60)
battle:update(1 / 60)
battle:update(1 / 60)
battle:update(1 / 60)
T.check(battle.player.flinched == false, "the locked drain clears the player's flinch")
end
do
local battle = newBattle()
drainToMenu(battle)
local reached
for i = 1, 10 do
battle:update(1 / 60)
if battle.phase == "menu" then reached = reached or i end
end
T.check(reached ~= nil, "an unlocked player reaches the command menu")
T.eq(battle.phase, "menu", "and stays on it")
end
do
local battle = newBattle()
battle.kind = "link"
battle.player.thrashTurns = 2
battle.player.thrashMove = battle.player.curMoves[1]
battle.player.thrashAnnounced = true
local resolved = 0
battle.resolveTurn = function() resolved = resolved + 1 end
drainToMenu(battle)
for _ = 1, 4 do
if battle.phase ~= "messages" then break end
battle:update(1 / 60)
end
T.eq(battle.phase, "menu", "a link battle's drain still lands on phase == menu")
T.eq(resolved, 0, "and leaves the locked action to the menu branch")
end
T.finish("locked turn skips the command menu frame (#2252)")
+56
View File
@@ -118,6 +118,7 @@ eq(boulder.cellX, 18, "boulder unmoved while STRENGTH is inactive")
clearCaptured()
local pmStr = PartyMenu.new(Game)
selectSubItem(pmStr, 1)
check(not pmStr.submenu, "options box erased before _UsedStrengthText")
eq(Game.overworld.strengthActive, true, "party-menu STRENGTH sets strengthActive")
check(onStack(pmStr), "party menu stays under the STRENGTH texts (#385)")
check(sawText("used") and sawText("STRENGTH"), "_UsedStrengthText shown")
@@ -129,6 +130,32 @@ check(not onStack(pmStr), "party menu closes with the blink after the texts")
eq(ow:checkBoulderPush("right"), false, "first bump arms the push after activation")
eq(ow:checkBoulderPush("right"), true, "boulder pushes after activation")
-- engine/overworld/cut.asm:170
for _ = 1, 60 do
if ow.dustAnim then break end
frame()
end
local dust = ow.dustAnim or {}
check(ow.dustAnim ~= nil and dust.boulder, "boulder dust starts once the push settles")
eq(dust.x, 19, "boulder dust anchored on the boulder's new cell")
eq(dust.y, 10, "boulder dust stays on the push row")
eq(dust.frames, 24, "boulder dust runs 8 steps x Delay3")
eq(dust.ox, -1, "first dust step slides 1px toward the player")
eq(dust.faded, true, "first dust step shows the XORed palette")
frame({ "right" })
eq(ow.player.moving, false, "no player step while the boulder dust plays")
eq(ow.player.cellX, 17, "player holds position during the boulder dust")
for _ = 1, 20 do frame() end
eq(dust.ox, -8, "boulder dust slid 8px by the last step")
eq(dust.faded, false, "palette restored on the last dust step")
for _ = 1, 3 do frame() end
eq(ow.dustAnim, nil, "boulder dust ends after 24 frames")
ow:startDustAnim(5, 5, nil)
eq(ow.dustAnim.frames, 32, "cut puff keeps its static timing")
eq(ow.dustAnim.ox, nil, "cut puff never slides")
check(not ow.dustAnim.boulder, "cut puff is not boulder dust")
ow.dustAnim = nil
-- every real map load clears the flag (ResetUsingStrengthOutOfBattleBit)
ow:setMap("SEAFOAM_ISLANDS_1F", 17, 10, "right")
eq(ow.strengthActive, false, "setMap re-entry resets strengthActive")
@@ -166,10 +193,19 @@ popToOW()
ow.player.facing = "down"; ow.player.surfing = false
clearCaptured()
local pmSurf = PartyMenu.new(Game)
local MusicSpy = require("src.core.Music")
local realPlayMap, surfSongAsked = MusicSpy.playMap, nil
MusicSpy.playMap = function(data, mapId, onBike, surfing, ...)
surfSongAsked = surfing
return realPlayMap(data, mapId, onBike, surfing, ...)
end
selectSubItem(pmSurf, 1)
MusicSpy.playMap = realPlayMap
-- the got-on text prints over the menu (#385); dismissing it closes the
-- menu and mounts, and the blink that follows carries the step
check(onStack(pmSurf), "party menu stays under the got-on text")
check(not pmSurf.submenu, "options box erased before _SurfingGotOnText")
eq(surfSongAsked, true, "surf music is already playing while the got-on text prints")
Game.stack:pop().onDone() -- a TextBox pops itself before firing onDone
eq(ow.player.surfing, true, "SURF from the party menu sets player.surfing")
check(not onStack(pmSurf), "party menu closes after a successful SURF")
@@ -410,6 +446,26 @@ for _ = 1, 60 do frame({}) end -- blink pops, the queued step walks out
eq(Game.stack:top(), ow, "back on the map after the blink")
eq(ow.player.cellY, 13, "the player stepped forward onto land")
ow.player.cellX, ow.player.cellY = 4, 14
ow.player.px, ow.player.py = 4 * 16, 14 * 16
ow.player.facing = "up"
ow.player.surfing = true
local WalkOffMusic = require("src.core.Music")
WalkOffMusic.setSurfing(Data, true)
local realSetSurfing, surfMusicNow = WalkOffMusic.setSurfing, true
WalkOffMusic.setSurfing = function(data, surfing)
surfMusicNow = surfing
return realSetSurfing(data, surfing)
end
frame({ "up" })
WalkOffMusic.setSurfing = realSetSurfing
eq(ow.player.moving, true, "the d-pad walk-off step has started")
eq(ow.player.cellY, 14, "and has not landed yet")
eq(ow.player.surfing, false, "walking sprite is back before the step onto land")
eq(surfMusicNow, false, "map music is back before the step onto land")
for _ = 1, 30 do frame({}) end
eq(ow.player.cellY, 13, "the walk-off step lands ashore")
-- "no place to get off": the text shows, and the menu STILL closes
-- (.cannotStopSurfing leaves wActionResultOrTookBattleTurn at 1)
ow.player.surfing = true
+103
View File
@@ -0,0 +1,103 @@
-- ../pokered/scripts/FightingDojo.asm:106-136
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not Data.maps then Data:load() end
local S = require("tests.harness").suite("parity Fighting Dojo master talk")
local check, eq = S.check, S.eq
local realTextBox = package.loaded["src.render.TextBox"]
local shownTexts = {}
package.loaded["src.render.TextBox"] = {
new = function(_, text, onDone)
shownTexts[#shownTexts + 1] = text
if onDone then onDone() end
return { text = text }
end,
soundOpts = function() return nil end,
}
package.loaded["data.scripts.story4"] = nil
local dojo = require("data.scripts.story4").FIGHTING_DOJO
local talk = dojo.talk.TEXT_FIGHTINGDOJO_KARATE_MASTER
local function saw(needle)
for _, s in ipairs(shownTexts) do
if s:find(needle, 1, true) then return true end
end
return false
end
local function run(flags, defeated)
shownTexts = {}
local states = {}
local game = {
data = Data,
save = { flags = flags, defeatedTrainers = {} },
stack = { push = function(_, s) states[#states + 1] = s end },
}
local master = { id = "FIGHTING_DOJO_obj_1",
def = { name = "FIGHTINGDOJO_KARATE_MASTER" } }
local ow = {
trainerDefeated = function() return defeated == true end,
engageTrainer = function(self, npc, onDone, endText, skip, sound, isReward)
self.engaged = { npc = npc, onDone = onDone, endText = endText,
skip = skip, isReward = isReward }
end,
}
local doneCalled = false
local onDone = function() doneCalled = true end
talk(game, ow, master, onDone)
return ow, doneCalled, onDone, master
end
check(type(talk) == "function",
"the Karate Master has a hand-ported talk handler")
if type(talk) == "function" then
do
local ow, doneCalled = run({ EVENT_BEAT_KARATE_MASTER = true })
check(saw("Indeed, I have"),
"beaten but no prize taken repeats the prize offer")
check(not saw("Stay and train"),
"and not the post-prize 'Stay and train' line")
check(ow.engaged == nil, "and does not re-engage him")
check(doneCalled, "and releases the player")
end
do
local ow, doneCalled = run({ EVENT_BEAT_KARATE_MASTER = true,
EVENT_DEFEATED_FIGHTING_DOJO = true })
check(saw("Stay and train"), "after the prize he says 'Stay and train'")
check(not saw("Indeed, I have"), "and not the prize offer")
check(ow.engaged == nil, "and does not re-engage him")
check(doneCalled, "and releases the player")
end
do
local ow, doneCalled = run({}, true)
check(saw("Indeed, I have"),
"a defeatedTrainers-only save still gets the prize offer")
check(ow.engaged == nil, "and does not re-engage him")
check(doneCalled, "and releases the player")
end
do
local ow, doneCalled, onDone, master = run({})
eq(#shownTexts, 0, "an unbeaten master prints no re-talk line")
check(ow.engaged ~= nil, "an unbeaten master engages his battle")
if ow.engaged then
eq(ow.engaged.npc, master, "with himself as the trainer")
eq(ow.engaged.onDone, onDone, "handing the talk's release to the battle")
eq(ow.engaged.endText, Data.text._FightingDojoKarateMasterDefeatedText,
"SaveEndBattleTextPointers .DefeatedText")
eq(ow.engaged.isReward, false,
"the armed line is not the victory reward dialogue")
end
check(not doneCalled, "and the player stays frozen until the battle ends")
end
end
package.loaded["src.render.TextBox"] = realTextBox
S.finish()
+60 -5
View File
@@ -14,6 +14,9 @@ Nothing here is transcribed. Build pret/pokegold and pret/pokecrystal, then:
python3 tools/gen2_sram_offsets.py \
--gold path/to/pokegold.sym \
--crystal path/to/pokecrystal.sym \
--charmap path/to/pokegold/constants/charmap.asm \
--gold-scenes path/to/pokegold/data/maps/scenes.asm \
--crystal-scenes path/to/pokecrystal/data/maps/scenes.asm \
> src/save_convert/Gen2Layout.lua
Gold and Silver share a layout (pokesilver.sym agrees byte-exact); Crystal does
@@ -58,15 +61,24 @@ def load(path):
# out exactly like the primary, so it is the same table shifted. Gold and
# Silver split theirs across three sections and are not derivable this way,
# which is why only Crystal gets one.
BACKUP_NEED = ["sBackupGameData", "sBackupGameDataEnd", "sBackupCheckValue1",
"sBackupCheckValue2", "sBackupChecksum", "sGameData"]
def backup_delta(sym):
if any(n not in sym for n in BACKUP_NEED):
return None
off = lambda n: sym[n][0] * 0x2000 + (sym[n][1] - 0xA000)
return off("sBackupGameData") - off("sGameData")
def backup_table(sym, rows, label):
need = ["sBackupGameData", "sBackupGameDataEnd", "sBackupCheckValue1",
"sBackupCheckValue2", "sBackupChecksum", "sGameData"]
if any(n not in sym for n in need):
if any(n not in sym for n in BACKUP_NEED):
return None
off = lambda n: sym[n][0] * 0x2000 + (sym[n][1] - 0xA000)
# File offsets, not raw addresses: the backup lives in SRAM bank 0 and the
# primary in bank 1, so an address-only delta is off by a bank.
delta = off("sBackupGameData") - off("sGameData")
delta = backup_delta(sym)
guards = {"sCheckValue1": off("sBackupCheckValue1"),
"sCheckValue2": off("sBackupCheckValue2"),
"sChecksum": off("sBackupChecksum"),
@@ -127,6 +139,37 @@ def table(sym, label):
return rows, skipped, boxes
SCENE_RE = re.compile(r"^\s*scene_var\s+(\w+),\s*(\w+)")
def scene_rows(sym, path, label):
base = sym["sPlayerData"][1] - 0xA000 + 0x2000
anchor = sym["wPlayerData"][1]
lo, hi = sym["sGameData"][1], sym["sGameDataEnd"][1]
out = []
for line in open(path):
m = SCENE_RE.match(line)
if not m:
continue
mapId, var = m.group(1), m.group(2)
w = sym.get(var)
if not w:
sys.exit(f"{label}: {var} for {mapId} is missing from the symbol file")
if not (lo <= w[1] - anchor + sym["sPlayerData"][1] < hi):
sys.exit(f"{label}: {var} is outside sGameData..sGameDataEnd")
out.append((mapId, base + (w[1] - anchor)))
if not out:
sys.exit(f"{label}: no scene_var rows in {path}")
return out
def print_scenes(rows, indent):
print(f"{indent}sceneVars = {{")
for mapId, off in rows:
print(f"{indent} {mapId} = 0x{off:04X},")
print(f"{indent}}},")
# The cart's own text table, so a name with an apostrophe, an accent or the PK
# glyph in it survives the round trip. Hand-keeping this list is how a player
# called "Mattia<PK>" comes back as "Mattia?".
@@ -162,20 +205,29 @@ def main():
ap.add_argument("--charmap", required=False,
help="path to pokegold constants/charmap.asm; emits the "
"text table too when given")
ap.add_argument("--gold-scenes", required=False,
help="path to pokegold data/maps/scenes.asm; emits the "
"w<Map>SceneID offsets when given")
ap.add_argument("--crystal-scenes", required=False,
help="path to pokecrystal data/maps/scenes.asm")
a = ap.parse_args()
print("-- GENERATED by tools/gen2_sram_offsets.py. Do not edit by hand.")
print("-- Regenerate from a pret/pokegold + pret/pokecrystal build; see that")
print("-- script's header for the derivation and the assertion behind it.")
print("local Gen2Layout = {}\n")
for key, path in (("goldSilver", a.gold), ("crystal", a.crystal)):
for key, path, scenesPath in (("goldSilver", a.gold, a.gold_scenes),
("crystal", a.crystal, a.crystal_scenes)):
sym = load(path)
rows, skipped, boxes = table(sym, key)
backup = backup_table(sym, rows, key)
scenes = scene_rows(sym, scenesPath, key) if scenesPath else None
print(f"Gen2Layout.{key} = {{")
for n, off in rows:
print(f" {n} = 0x{off:04X},")
print(" -- The 14 archived boxes, listed rather than strided (see BOX_COUNT).")
print(" boxes = { " + ", ".join("0x%04X" % b for b in boxes) + " },")
if scenes:
print_scenes(scenes, " ")
if backup:
print(" -- The backup copy the game falls back to when the primary")
print(" -- checksum fails. Same shape, shifted.")
@@ -183,6 +235,9 @@ def main():
for n, off in backup:
print(f" {n} = 0x{off:04X},")
print(" boxes = { " + ", ".join("0x%04X" % b for b in boxes) + " },")
if scenes:
delta = backup_delta(sym)
print_scenes([(m, off + delta) for m, off in scenes], " ")
print(" },")
print("}")
for s2 in skipped: