diff --git a/README.md b/README.md index 543c69fd..5f9306cf 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/data/scripts/flavor/cerulean_cave_b1f.lua b/data/scripts/flavor/cerulean_cave_b1f.lua index 43419216..d215cb4f 100644 --- a/data/scripts/flavor/cerulean_cave_b1f.lua +++ b/data/scripts/flavor/cerulean_cave_b1f.lua @@ -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" }, }, }, } diff --git a/data/scripts/flavor/power_plant.lua b/data/scripts/flavor/power_plant.lua index 6282f5d1..4bad591f 100644 --- a/data/scripts/flavor/power_plant.lua +++ b/data/scripts/flavor/power_plant.lua @@ -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" }, }, }, } diff --git a/data/scripts/flavor/saffron_city.lua b/data/scripts/flavor/saffron_city.lua new file mode 100644 index 00000000..b1b90881 --- /dev/null +++ b/data/scripts/flavor/saffron_city.lua @@ -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" }, + }, + }, + }, +} diff --git a/data/scripts/flavor/seafoam_islands_b4f.lua b/data/scripts/flavor/seafoam_islands_b4f.lua index 08a2f61e..bacc99be 100644 --- a/data/scripts/flavor/seafoam_islands_b4f.lua +++ b/data/scripts/flavor/seafoam_islands_b4f.lua @@ -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" }, }, }, } diff --git a/data/scripts/flavor/victory_road_2f.lua b/data/scripts/flavor/victory_road_2f.lua index c4af3259..0371a4c0 100644 --- a/data/scripts/flavor/victory_road_2f.lua +++ b/data/scripts/flavor/victory_road_2f.lua @@ -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" }, }, }, } diff --git a/data/scripts/flavor_all.lua b/data/scripts/flavor_all.lua index b1ba981f..fdb0e890 100644 --- a/data/scripts/flavor_all.lua +++ b/data/scripts/flavor_all.lua @@ -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", diff --git a/data/scripts/seafoam.lua b/data/scripts/seafoam.lua index 46f4f0bd..0f20c72a 100644 --- a/data/scripts/seafoam.lua +++ b/data/scripts/seafoam.lua @@ -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 }, diff --git a/data/scripts/story4.lua b/data/scripts/story4.lua index 7023a7b9..2376b546 100644 --- a/data/scripts/story4.lua +++ b/data/scripts/story4.lua @@ -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", diff --git a/docs/switch-install.md b/docs/switch-install.md index 282d5cc3..3dea8efa 100644 --- a/docs/switch-install.md +++ b/docs/switch-install.md @@ -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` diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 9166891c..366dfc8c 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -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 diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 53fe195c..c8792f4e 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -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) diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 7e2114fa..f8570aa6 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -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) diff --git a/src/core/GameVersion.lua b/src/core/GameVersion.lua index 59087ab2..997c6a9d 100644 --- a/src/core/GameVersion.lua +++ b/src/core/GameVersion.lua @@ -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 diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 6a4d3149..44c5523d 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -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() diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 4c51cc66..36048ae7 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -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 diff --git a/src/render/ShaderFX.lua b/src/render/ShaderFX.lua index 2be23f2d..665e5704 100644 --- a/src/render/ShaderFX.lua +++ b/src/render/ShaderFX.lua @@ -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. diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 0246dd3c..d99555f6 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -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 diff --git a/src/save_convert/Gen2Layout.lua b/src/save_convert/Gen2Layout.lua index bc6c082e..ceae3e79 100644 --- a/src/save_convert/Gen2Layout.lua +++ b/src/save_convert/Gen2Layout.lua @@ -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, + }, }, } diff --git a/src/save_convert/Gen2Save.lua b/src/save_convert/Gen2Save.lua index e499d5e6..75099adc 100644 --- a/src/save_convert/Gen2Save.lua +++ b/src/save_convert/Gen2Save.lua @@ -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 diff --git a/src/script/Commands.lua b/src/script/Commands.lua index e61975cf..8a1b7055 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -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 : 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. diff --git a/src/ui/IntroMovie.lua b/src/ui/IntroMovie.lua index f774dcd0..63e55194 100644 --- a/src/ui/IntroMovie.lua +++ b/src/ui/IntroMovie.lua @@ -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) diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index a0e9809b..dcbe6404 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -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/.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 diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 8db7cfe3..ee7db580 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -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() diff --git a/src/world/Player.lua b/src/world/Player.lua index 00b25efb..8e3480a8 100644 --- a/src/world/Player.lua +++ b/src/world/Player.lua @@ -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 diff --git a/tests/drivers/boulder_dust_bug2263.lua b/tests/drivers/boulder_dust_bug2263.lua new file mode 100644 index 00000000..cbeb23d9 --- /dev/null +++ b/tests/drivers/boulder_dust_bug2263.lua @@ -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 diff --git a/tests/drivers/boulder_trainer_bug809_test.lua b/tests/drivers/boulder_trainer_bug809_test.lua index 52229aee..7dbc571a 100644 --- a/tests/drivers/boulder_trainer_bug809_test.lua +++ b/tests/drivers/boulder_trainer_bug809_test.lua @@ -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 diff --git a/tests/drivers/fighting_dojo_bug197_test.lua b/tests/drivers/fighting_dojo_bug197_test.lua index b8efc525..2043df09 100644 --- a/tests/drivers/fighting_dojo_bug197_test.lua +++ b/tests/drivers/fighting_dojo_bug197_test.lua @@ -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 ------------------------------------------------------------------ diff --git a/tests/drivers/game_corner_coin_seat_bug2272.lua b/tests/drivers/game_corner_coin_seat_bug2272.lua new file mode 100644 index 00000000..1aa8d4df --- /dev/null +++ b/tests/drivers/game_corner_coin_seat_bug2272.lua @@ -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 diff --git a/tests/drivers/gold_import_scenes_bug2245.lua b/tests/drivers/gold_import_scenes_bug2245.lua new file mode 100644 index 00000000..6d1de7b0 --- /dev/null +++ b/tests/drivers/gold_import_scenes_bug2245.lua @@ -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 diff --git a/tests/drivers/intro_og_obj_palette_bug2264_test.lua b/tests/drivers/intro_og_obj_palette_bug2264_test.lua new file mode 100644 index 00000000..68250d62 --- /dev/null +++ b/tests/drivers/intro_og_obj_palette_bug2264_test.lua @@ -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 diff --git a/tests/drivers/party_icon_og_obj_bug2268_test.lua b/tests/drivers/party_icon_og_obj_bug2268_test.lua new file mode 100644 index 00000000..57772dff --- /dev/null +++ b/tests/drivers/party_icon_og_obj_bug2268_test.lua @@ -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 diff --git a/tests/drivers/saffron_pidgeot_cry_bug2262_test.lua b/tests/drivers/saffron_pidgeot_cry_bug2262_test.lua new file mode 100644 index 00000000..aac18ea3 --- /dev/null +++ b/tests/drivers/saffron_pidgeot_cry_bug2262_test.lua @@ -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 diff --git a/tests/drivers/seafoam_hole_current_2274.lua b/tests/drivers/seafoam_hole_current_2274.lua new file mode 100644 index 00000000..ae5985e9 --- /dev/null +++ b/tests/drivers/seafoam_hole_current_2274.lua @@ -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 diff --git a/tests/drivers/seafoam_holes_bug599_test.lua b/tests/drivers/seafoam_holes_bug599_test.lua index b3668dcb..1522f786 100644 --- a/tests/drivers/seafoam_holes_bug599_test.lua +++ b/tests/drivers/seafoam_holes_bug599_test.lua @@ -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 diff --git a/tests/drivers/shaderfx_grid_2235.lua b/tests/drivers/shaderfx_grid_2235.lua new file mode 100644 index 00000000..4a3bb622 --- /dev/null +++ b/tests/drivers/shaderfx_grid_2235.lua @@ -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 diff --git a/tests/drivers/static_encounter_sting_bug2275_2261.lua b/tests/drivers/static_encounter_sting_bug2275_2261.lua new file mode 100644 index 00000000..2d8d11c8 --- /dev/null +++ b/tests/drivers/static_encounter_sting_bug2275_2261.lua @@ -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 diff --git a/tests/drivers/surf_strength_submenu_2267_2273.lua b/tests/drivers/surf_strength_submenu_2267_2273.lua new file mode 100644 index 00000000..9ded4dbf --- /dev/null +++ b/tests/drivers/surf_strength_submenu_2267_2273.lua @@ -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 diff --git a/tests/drivers/thrash_menu_flash_bug2252.lua b/tests/drivers/thrash_menu_flash_bug2252.lua new file mode 100644 index 00000000..7367ee5c --- /dev/null +++ b/tests/drivers/thrash_menu_flash_bug2252.lua @@ -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 diff --git a/tests/engine/field_move_submenu_bug2273.lua b/tests/engine/field_move_submenu_bug2273.lua new file mode 100644 index 00000000..d9f4121b --- /dev/null +++ b/tests/engine/field_move_submenu_bug2273.lua @@ -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() diff --git a/tests/engine/gen2_residual_timing_bug2257.lua b/tests/engine/gen2_residual_timing_bug2257.lua new file mode 100644 index 00000000..6110c70b --- /dev/null +++ b/tests/engine/gen2_residual_timing_bug2257.lua @@ -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") diff --git a/tests/engine/gen2_save_import.lua b/tests/engine/gen2_save_import.lua index 395cbd5a..762df8ad 100644 --- a/tests/engine/gen2_save_import.lua +++ b/tests/engine/gen2_save_import.lua @@ -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. diff --git a/tests/engine/intro_og_obj_palette_bug2264.lua b/tests/engine/intro_og_obj_palette_bug2264.lua new file mode 100644 index 00000000..d0c61c0b --- /dev/null +++ b/tests/engine/intro_og_obj_palette_bug2264.lua @@ -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() diff --git a/tests/engine/overworld_slot_seat_over_hidden_coin.lua b/tests/engine/overworld_slot_seat_over_hidden_coin.lua new file mode 100644 index 00000000..103ad9a1 --- /dev/null +++ b/tests/engine/overworld_slot_seat_over_hidden_coin.lua @@ -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") diff --git a/tests/engine/party_icon_og_obj_bug2268.lua b/tests/engine/party_icon_og_obj_bug2268.lua new file mode 100644 index 00000000..628b1ef1 --- /dev/null +++ b/tests/engine/party_icon_og_obj_bug2268.lua @@ -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() diff --git a/tests/engine/saffron_city_pidgeot_cry_bug2262.lua b/tests/engine/saffron_city_pidgeot_cry_bug2262.lua new file mode 100644 index 00000000..54a0d518 --- /dev/null +++ b/tests/engine/saffron_city_pidgeot_cry_bug2262.lua @@ -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") diff --git a/tests/engine/seafoam_hole_current_bug2274.lua b/tests/engine/seafoam_hole_current_bug2274.lua new file mode 100644 index 00000000..2266f88c --- /dev/null +++ b/tests/engine/seafoam_hole_current_bug2274.lua @@ -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") diff --git a/tests/engine/shaderfx_grid_alignment_2235.lua b/tests/engine/shaderfx_grid_alignment_2235.lua new file mode 100644 index 00000000..d073725d --- /dev/null +++ b/tests/engine/shaderfx_grid_alignment_2235.lua @@ -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") diff --git a/tests/engine/static_encounter_sting_bug2275_2261.lua b/tests/engine/static_encounter_sting_bug2275_2261.lua new file mode 100644 index 00000000..90301056 --- /dev/null +++ b/tests/engine/static_encounter_sting_bug2275_2261.lua @@ -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") diff --git a/tests/engine/thrash_menu_flash_bug2252.lua b/tests/engine/thrash_menu_flash_bug2252.lua new file mode 100644 index 00000000..ec6ecdc9 --- /dev/null +++ b/tests/engine/thrash_menu_flash_bug2252.lua @@ -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)") diff --git a/tests/parity_I_M.lua b/tests/parity_I_M.lua index 84400482..8fdea3ec 100644 --- a/tests/parity_I_M.lua +++ b/tests/parity_I_M.lua @@ -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 diff --git a/tests/parity_fighting_dojo_master_talk.lua b/tests/parity_fighting_dojo_master_talk.lua new file mode 100644 index 00000000..e97f5c0b --- /dev/null +++ b/tests/parity_fighting_dojo_master_talk.lua @@ -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() diff --git a/tools/gen2_sram_offsets.py b/tools/gen2_sram_offsets.py index 1a8e2e7b..78ff3258 100644 --- a/tools/gen2_sram_offsets.py +++ b/tools/gen2_sram_offsets.py @@ -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" 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 " + "wSceneID 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: