mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-26 21:35:42 -04:00
CLOSES #2235, CLOSES #2245, CLOSES #2252, CLOSES #2253, CLOSES #2257, CLOSES #2261, CLOSES #2262, CLOSES #2263, CLOSES #2264, CLOSES #2267, CLOSES #2268, CLOSES #2272, CLOSES #2273, CLOSES #2274, CLOSES #2275
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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" },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -97,8 +97,8 @@ This project ships **no** game data. On first launch:
|
||||
same folder.
|
||||
2. Use **Scan again** on that game's tab (Red / Blue / Yellow / Gold /
|
||||
Silver). Rescan matches by ROM SHA-1 for the open tab only. A Red dump
|
||||
never imports from the Yellow tab (and vice versa). Gold and Silver are
|
||||
Beta in the launcher; a clean US dump of either is enough to Play.
|
||||
never imports from the Yellow tab (and vice versa). A clean US Gold or
|
||||
Silver dump is enough to Play.
|
||||
|
||||
## 5. Import / Export a raw `.sav`
|
||||
|
||||
|
||||
+28
-11
@@ -2111,6 +2111,19 @@ function BattleState:menuLockedAction(battler)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- engine/battle/core.asm:293
|
||||
function BattleState:enterCommandMenu()
|
||||
self.phase = "menu"
|
||||
if self.kind == "link" or self.safari or self.demo then return end
|
||||
if not (self.player and self.player.mon and self.player.mon.hp > 0) then return end
|
||||
local locked = self:menuLockedAction(self.player)
|
||||
if locked then
|
||||
self:snapIdleBars()
|
||||
self:clearTurnFlinches()
|
||||
self:resolveTurn(locked)
|
||||
end
|
||||
end
|
||||
|
||||
-- After FIGHT: skip MoveSelectionMenu (core.asm:320-329). Own
|
||||
-- trapping/Bide continues; foe trapping forces CANNOT_MOVE ($ff).
|
||||
function BattleState:fightLockedAction(battler)
|
||||
@@ -2284,6 +2297,19 @@ function BattleState:tickFx()
|
||||
self:updateFx()
|
||||
end
|
||||
|
||||
function BattleState:snapIdleBars()
|
||||
for _, b in ipairs({ self.player, self.enemy }) do
|
||||
if b then
|
||||
if b.shownHP then
|
||||
b.shownHP = b.mon.hp
|
||||
b.shownPx = Timing.hpBarPixels(b.mon.hp, math.max(1, b.mon.stats.hp))
|
||||
end
|
||||
b.drainFloor = nil
|
||||
b.shownStatus = b.mon.status
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function BattleState:update(dt)
|
||||
self:tickFx()
|
||||
local input = self.game.input
|
||||
@@ -2291,16 +2317,7 @@ function BattleState:update(dt)
|
||||
-- safety net: HP/status changed outside a queued drain (level-up heals,
|
||||
-- field effects, bag cures) snaps once the queue is idle
|
||||
if self.phase == "menu" then
|
||||
for _, b in ipairs({ self.player, self.enemy }) do
|
||||
if b then
|
||||
if b.shownHP then
|
||||
b.shownHP = b.mon.hp
|
||||
b.shownPx = Timing.hpBarPixels(b.mon.hp, math.max(1, b.mon.stats.hp))
|
||||
end
|
||||
b.drainFloor = nil
|
||||
b.shownStatus = b.mon.status
|
||||
end
|
||||
end
|
||||
self:snapIdleBars()
|
||||
end
|
||||
|
||||
if self.phase == "messages" then
|
||||
@@ -2327,7 +2344,7 @@ function BattleState:update(dt)
|
||||
-- engine/battle/core.asm:2007
|
||||
self.msgHold = nil
|
||||
self.shown = nil
|
||||
self.phase = "menu"
|
||||
self:enterCommandMenu()
|
||||
elseif destination == "finish" then
|
||||
self:finish()
|
||||
end
|
||||
|
||||
+20
-16
@@ -3485,7 +3485,7 @@ Battle.RESIDUAL_ANIM = {
|
||||
burn = "ANIM_BRN", poison = "ANIM_PSN", toxic = "ANIM_PSN",
|
||||
}
|
||||
|
||||
-- End of turn: burn and poison chip damage, through the merged record's
|
||||
-- ResidualDamage: burn and poison chip damage, through the merged record's
|
||||
-- `residual`. The record computes and advances its own counter; the emit pair
|
||||
-- stays here because the event shape belongs to this engine, not to the status.
|
||||
function Battle:tickStatus(mon)
|
||||
@@ -4961,6 +4961,15 @@ local function runTurn(self, action, enemyAction)
|
||||
self:useMove(self.enemy, self.player, enemyMoveId)
|
||||
end
|
||||
|
||||
local function residualHalf(mon)
|
||||
if self.faintInterrupt or self.forcedSwitch then return true end
|
||||
-- engine/battle/core.asm:1005
|
||||
self:tickStatus(mon)
|
||||
self:tickSeedAndCurse(mon)
|
||||
if self:resolveFaints() then return true end
|
||||
return self.faintInterrupt and true or false
|
||||
end
|
||||
|
||||
if playerFirst then
|
||||
playerAttack()
|
||||
-- A wild Roar or Whirlwind ends the battle from THIS half of the turn the
|
||||
@@ -4977,10 +4986,12 @@ local function runTurn(self, action, enemyAction)
|
||||
-- happens to be standing there now. The end-of-turn block below still
|
||||
-- runs -- HandleEnemyMonFaint returns into BattleTurn's `.proceed`, which
|
||||
-- calls HandleBetweenTurnEffects (core.asm:196).
|
||||
if self.faintInterrupt then
|
||||
self.faintInterrupt = nil
|
||||
elseif (self.player.hp or 0) > 0 then
|
||||
if not residualHalf(self.player) and not self.over
|
||||
and (self.player.hp or 0) > 0 then
|
||||
enemyAttack()
|
||||
if self.over then return self:takeEvents() end
|
||||
if self:resolveFaints() then return self:takeEvents() end
|
||||
residualHalf(self.enemy)
|
||||
end
|
||||
else
|
||||
enemyAttack()
|
||||
@@ -4991,10 +5002,12 @@ local function runTurn(self, action, enemyAction)
|
||||
if self:resolveFaints() then return self:takeEvents() end
|
||||
-- Same `jp` (core.asm:834-837): a mon that fainted to the enemy's move
|
||||
-- takes the rest of the attack phase with it.
|
||||
if self.faintInterrupt then
|
||||
self.faintInterrupt = nil
|
||||
elseif (self.player.hp or 0) > 0 then
|
||||
if not residualHalf(self.enemy) and not self.over
|
||||
and (self.player.hp or 0) > 0 then
|
||||
playerAttack()
|
||||
if self.over then return self:takeEvents() end
|
||||
if self:resolveFaints() then return self:takeEvents() end
|
||||
residualHalf(self.player)
|
||||
end
|
||||
end
|
||||
if self.over then return self:takeEvents() end
|
||||
@@ -5009,18 +5022,9 @@ local function runTurn(self, action, enemyAction)
|
||||
return self:takeEvents()
|
||||
end
|
||||
|
||||
-- End of turn, in the cart's own order (HandleWeather runs before the
|
||||
-- residual damage, and the counters that end a mon come last):
|
||||
-- weather, then status chip and the Leech Seed / Curse residuals, then
|
||||
-- the wrap ticks, then held items, then Future Sight and Perish Song,
|
||||
-- then the screens and the per-turn counters.
|
||||
local firstMon, secondMon = self.player, self.enemy
|
||||
if self.mirrored then firstMon, secondMon = self.enemy, self.player end
|
||||
self:tickWeather()
|
||||
self:tickStatus(firstMon)
|
||||
self:tickSeedAndCurse(firstMon)
|
||||
self:tickStatus(secondMon)
|
||||
self:tickSeedAndCurse(secondMon)
|
||||
self:tickWrap(firstMon)
|
||||
self:tickWrap(secondMon)
|
||||
self:tickHeldItem(firstMon)
|
||||
|
||||
+27
-4
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -167,6 +167,18 @@ function PaletteFX.ogObj()
|
||||
return PaletteFX.fadeObp(PaletteFX.darkObp(PaletteFX.GBC_OBJ, "gbcobj"))
|
||||
end
|
||||
|
||||
local function obp3100(c) return { c[1], c[1], c[2], c[4] } end
|
||||
local OG_OBJ_NORMAL = obp3100(PaletteFX.GBC_OBJ)
|
||||
local OG_OBJ_NORMAL_BLUE = obp3100(PaletteFX.GBC_OBJ_BLUE)
|
||||
|
||||
-- home/palettes.asm:24
|
||||
function PaletteFX.ogObjNormal()
|
||||
if GameVersion.isBlue() then
|
||||
return OG_OBJ_NORMAL_BLUE, "gbcobjnormal_blue"
|
||||
end
|
||||
return OG_OBJ_NORMAL, "gbcobjnormal"
|
||||
end
|
||||
|
||||
-- The DMG object ramp every mode except OG RED bakes onto overworld sprites,
|
||||
-- plus its cache group (same two-value contract as ogObj). Entry 1 is never
|
||||
-- read -- SpriteRenderer.getObpImage keys OBJ color 0 to alpha, the hardware's
|
||||
@@ -443,10 +455,14 @@ function PaletteFX.spriteRedraws()
|
||||
return spriteRedraws
|
||||
end
|
||||
|
||||
function PaletteFX.markUiSpriteRedraw(image, quad, x, y)
|
||||
function PaletteFX.markUiSpriteRedraw(image, quad, x, y, opts)
|
||||
if currentPass ~= "ui" then return end
|
||||
local clip = opts and opts.clip
|
||||
if clip then clip = { clip[1] + markOffsetX, clip[2], clip[3], clip[4] } end
|
||||
uiSpriteRedraws[#uiSpriteRedraws + 1] =
|
||||
{ image = image, quad = quad, x = x + markOffsetX, y = y }
|
||||
{ image = image, quad = quad, x = x + markOffsetX, y = y,
|
||||
sx = opts and opts.sx or 1, sy = opts and opts.sy or 1,
|
||||
clip = clip, color = opts and opts.color }
|
||||
end
|
||||
|
||||
function PaletteFX.uiSpriteRedraws()
|
||||
|
||||
+28
-7
@@ -1037,6 +1037,7 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- the UI shaded separately on a transparent layer and masked over it.
|
||||
local fxScale = Up
|
||||
local fxWorldScale = nil
|
||||
local fxWorldOx, fxWorldOy = nil, nil
|
||||
|
||||
if self.worldOverride then
|
||||
-- A render pipeline already produced the whole world -- terrain,
|
||||
@@ -1069,7 +1070,9 @@ function Renderer:endFrame(zones, worldZones)
|
||||
local woxPx = vx + math.floor((pw - wvw * sp) / 2)
|
||||
local woyPx = vy + math.floor((ph - wvh * sp) / 2) - R.lift
|
||||
local wox, woy = woxPx / dpiX, woyPx / dpiY
|
||||
if present and ShaderFX.active() and sp ~= Up then fxWorldScale = sp end
|
||||
if present and ShaderFX.active() and sp ~= Up then
|
||||
fxWorldScale, fxWorldOx, fxWorldOy = sp, woxPx, woyPx
|
||||
end
|
||||
-- Tilt mode projects the ground world pass through the perspective mesh
|
||||
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
|
||||
-- scissoring here). drawTiltedWorld returns false when tilt is off or
|
||||
@@ -1248,16 +1251,34 @@ function Renderer:endFrame(zones, worldZones)
|
||||
end
|
||||
local uiRedraws = PaletteFX.uiSpriteRedraws()
|
||||
if uiRedraws[1] then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.setScissor(clipToView(uox, uoy, uvpw, uvph))
|
||||
local bx, by, bw, bh = clipToView(uox, uoy, uvpw, uvph)
|
||||
for _, r in ipairs(uiRedraws) do
|
||||
local c = r.clip
|
||||
if c then
|
||||
local x1 = math.max(bx, uox + c[1] * Ux)
|
||||
local y1 = math.max(by, uoy + c[2] * Uy)
|
||||
local x2 = math.min(bx + bw, uox + (c[1] + c[3]) * Ux)
|
||||
local y2 = math.min(by + bh, uoy + (c[2] + c[4]) * Uy)
|
||||
love.graphics.setScissor(x1, y1, math.max(0, x2 - x1),
|
||||
math.max(0, y2 - y1))
|
||||
else
|
||||
love.graphics.setScissor(bx, by, bw, bh)
|
||||
end
|
||||
local col = r.color
|
||||
if col then
|
||||
love.graphics.setColor(col[1], col[2], col[3], col[4] or 1)
|
||||
else
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
local rsx, rsy = Ux * (r.sx or 1), Uy * (r.sy or 1)
|
||||
if r.quad then
|
||||
love.graphics.draw(r.image, r.quad, uox + r.x * Ux, uoy + r.y * Uy,
|
||||
0, Ux, Uy)
|
||||
0, rsx, rsy)
|
||||
else
|
||||
love.graphics.draw(r.image, uox + r.x * Ux, uoy + r.y * Uy, 0, Ux, Uy)
|
||||
love.graphics.draw(r.image, uox + r.x * Ux, uoy + r.y * Uy, 0, rsx, rsy)
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.setScissor()
|
||||
end
|
||||
|
||||
@@ -1332,12 +1353,12 @@ function Renderer:endFrame(zones, worldZones)
|
||||
ShaderFX.render(composed,
|
||||
{ x = 0, y = 0, w = fxPxW, h = fxPxH, scale = worldScale },
|
||||
{ w = fxPxW / worldScale, h = fxPxH / worldScale },
|
||||
dpiX, dpiY)
|
||||
dpiX, dpiY, { originX = fxWorldOx or uox * dpiX, originY = fxWorldOy or uoy * dpiY })
|
||||
if uiLayer then
|
||||
ShaderFX.render(uiLayer,
|
||||
{ x = 0, y = 0, w = fxPxW, h = fxPxH, scale = fxScale },
|
||||
{ w = fxPxW / fxScale, h = fxPxH / fxScale },
|
||||
dpiX, dpiY, { layer = "ui", mask = true })
|
||||
dpiX, dpiY, { layer = "ui", mask = true, originX = uox * dpiX, originY = uoy * dpiY })
|
||||
end
|
||||
else
|
||||
-- the present canvas only existed for the post-process, so put the
|
||||
|
||||
+59
-9
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -385,6 +385,16 @@ local function decodeVariableSprites(bytes, at, count)
|
||||
return out
|
||||
end
|
||||
|
||||
-- data/maps/scenes.asm:7
|
||||
local function decodeMapScenes(bytes, L)
|
||||
local out = {}
|
||||
for mapId, at in pairs(L.sceneVars or {}) do
|
||||
local b = u8(bytes, at)
|
||||
if b and b ~= 0 then out[mapId] = b end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
Gen2Save.NUM_SPECIES = 251
|
||||
Gen2Save.EVENT_BYTES = 256
|
||||
|
||||
@@ -458,6 +468,7 @@ function Gen2Save.decode(bytes, gameVersion, data)
|
||||
seen = decodeDex(bytes, L.wPokedexSeen, x),
|
||||
},
|
||||
events = decodeFlagBytes(bytes, L.wEventFlags, Gen2Save.EVENT_BYTES),
|
||||
mapScenes = decodeMapScenes(bytes, L),
|
||||
engineFlags = decodeEngineFlags(bytes, L, gameVersion),
|
||||
variableSprites = decodeVariableSprites(bytes, L.wVariableSprites,
|
||||
Gen2Save.VARIABLE_SPRITES),
|
||||
@@ -784,6 +795,12 @@ function Gen2Save.encode(save, gameVersion, template, data)
|
||||
local byte = (save.events or {})[i]
|
||||
if type(byte) == "number" then putU8(t, L.wEventFlags + i, byte) end
|
||||
end
|
||||
if L.sceneVars and type(save.mapScenes) == "table" then
|
||||
for mapId, at in pairs(L.sceneVars) do
|
||||
local scene = tonumber(save.mapScenes[mapId])
|
||||
if scene then putU8(t, at, math.floor(scene)) end
|
||||
end
|
||||
end
|
||||
if type(save.engineFlags) == "table" then
|
||||
putEngineFlags(t, L, save.engineFlags, gameVersion)
|
||||
end
|
||||
|
||||
+16
-2
@@ -111,6 +111,16 @@ function Commands.show_text(ctx, textId, subs, extraOpts)
|
||||
return require("src.core.Sound").playCry(ctx.game.data, species)
|
||||
end, delay = 0, wait = waitForButton } }
|
||||
end
|
||||
if ctx.pendingEngageMusic then
|
||||
local song = ctx.pendingEngageMusic
|
||||
ctx.pendingEngageMusic = nil
|
||||
opts = opts or {}
|
||||
opts.auto = opts.auto or { delay = 0, wait = true }
|
||||
-- home/trainers.asm:399
|
||||
opts.auto.afterSound = function()
|
||||
require("src.core.Music").play(ctx.game.data, song)
|
||||
end
|
||||
end
|
||||
-- text_opts armed the next box: auto = true is the plain no-button-wait
|
||||
-- form, overlap folds under auto, everything else passes through
|
||||
if ctx.textOpts then
|
||||
@@ -676,8 +686,7 @@ end
|
||||
-- (no A press) the instant the cry finishes, rather than firing immediately
|
||||
-- alongside the typewriter effect. Script rows run strictly in order, so
|
||||
-- this stashes the species on ctx for the show_text row that always
|
||||
-- immediately follows it (Power Plant Zapdos, Seafoam Articuno, Victory
|
||||
-- Road Moltres, Cerulean Cave Mewtwo battle text) to play once its box is
|
||||
-- immediately follows it to play once its box is
|
||||
-- done typing (see show_text's opts.auto). Headless-safe no-op there.
|
||||
--
|
||||
-- waitForButton is the pet-NPC form (scripts/PewterNidoranHouse.asm and
|
||||
@@ -691,6 +700,11 @@ function Commands.play_cry(ctx, species, waitForButton)
|
||||
ctx.pendingCryWait = waitForButton or nil
|
||||
end
|
||||
|
||||
-- home/trainers.asm:327
|
||||
function Commands.engage_music(ctx, songId)
|
||||
ctx.pendingEngageMusic = songId
|
||||
end
|
||||
|
||||
-- mark_seen <species>: DisplayPokedex (pokedex.asm) records the species as
|
||||
-- seen before opening its entry. Map scripts use this for NPC-driven
|
||||
-- Pokédex previews that do not begin a battle or give the player a Pokémon.
|
||||
|
||||
+41
-2
@@ -177,9 +177,14 @@ function IntroMovie.new(game, onDone)
|
||||
self.smallStar = img(intro.fallingStar)
|
||||
self.smallStarBlink = img(intro.fallingStarBlink)
|
||||
self.gengarFrames, self.nidoFrames = {}, {}
|
||||
self.gengarPaths, self.nidoPaths = {}, {}
|
||||
for i = 1, 3 do
|
||||
self.gengarFrames[i] = img(intro.gengar and intro.gengar["frame" .. i])
|
||||
self.nidoFrames[i] = img(intro.nidorino and intro.nidorino["frame" .. i])
|
||||
local g = intro.gengar and intro.gengar["frame" .. i]
|
||||
local n = intro.nidorino and intro.nidorino["frame" .. i]
|
||||
self.gengarFrames[i] = img(g)
|
||||
self.nidoFrames[i] = img(n)
|
||||
self.gengarPaths[i] = g and g.path
|
||||
self.nidoPaths[i] = n and n.path
|
||||
end
|
||||
|
||||
-- fight state (PlayIntroScene entry, intro.asm:30-39): Gengar BG pose at
|
||||
@@ -418,6 +423,40 @@ function IntroMovie:drawFight()
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
self:replayObjLayer(nido, gengar)
|
||||
end
|
||||
|
||||
local whitePixel
|
||||
|
||||
local FIGHT_CLIP = { 0, 32, 160, 80 }
|
||||
|
||||
function IntroMovie:replayObjLayer(nido, gengar)
|
||||
local P = require("src.render.PaletteFX")
|
||||
local nidoPath = self.nidoPaths and self.nidoPaths[self.nidoFrame]
|
||||
if not (nido and nidoPath and P.usesSpriteObp()) then return end
|
||||
local SR = require("src.render.SpriteRenderer")
|
||||
local blue = GameVersion.isBlue()
|
||||
local opts = { clip = FIGHT_CLIP }
|
||||
-- engine/movie/intro.asm:28
|
||||
P.markUiSpriteRedraw(SR.obpImage(nidoPath,
|
||||
blue and P.GBC_OBJ_BLUE or P.GBC_OBJ, blue and "gbcobj_blue" or "gbcobj"),
|
||||
nil, self.nidoX, self.nidoY, opts)
|
||||
local gengarPath = self.gengarPaths[self.gengarPose]
|
||||
if gengar and gengarPath then
|
||||
-- engine/movie/intro.asm:195
|
||||
P.markUiSpriteRedraw(SR.obpImage(gengarPath, P.ogBg(),
|
||||
blue and "ogbg_blue" or "ogbg"), nil, self.gengarX, self.gengarY, opts)
|
||||
end
|
||||
if self.fade > 0 then
|
||||
if not whitePixel then
|
||||
local id = love.image.newImageData(1, 1)
|
||||
id:setPixel(0, 0, 1, 1, 1, 1)
|
||||
whitePixel = love.graphics.newImage(id)
|
||||
end
|
||||
P.markUiSpriteRedraw(whitePixel, nil, FIGHT_CLIP[1], FIGHT_CLIP[2],
|
||||
{ clip = FIGHT_CLIP, sx = FIGHT_CLIP[3], sy = FIGHT_CLIP[4],
|
||||
color = { 1, 1, 1, math.min(1, self.fade) } })
|
||||
end
|
||||
end
|
||||
|
||||
function IntroMovie:drawCopyPrefix(x, y)
|
||||
|
||||
+23
-3
@@ -175,6 +175,8 @@ function PartyMenu.mirrorsIcon(name)
|
||||
end
|
||||
|
||||
local iconImages = {}
|
||||
-- engine/items/town_map.asm:514
|
||||
local OAM_XFLIP = { sx = -1 }
|
||||
|
||||
-- Party icons are OBJs (engine/gfx/mon_icons.asm WriteMonPartySpriteOAM
|
||||
-- writes OAM blocks), so they render through OBP0, and GBPalNormal
|
||||
@@ -249,13 +251,21 @@ function PartyMenu.drawIcon(game, mon, x, y, selected, counter, forceAlt)
|
||||
-- overrides `name`, because a pokemon.icon hook can substitute full-colour
|
||||
-- art for a path that resolved to a built-in class and still carries one.
|
||||
local baked = name ~= nil and not trueColor
|
||||
local key = baked and (path .. "#obp") or path
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local ogColors, ogGroup
|
||||
if baked and PaletteFX.usesSpriteObp() then
|
||||
ogColors, ogGroup = PaletteFX.ogObjNormal()
|
||||
end
|
||||
local key = baked and (path .. "#obp" .. (ogGroup or "")) or path
|
||||
if iconImages[key] == nil then
|
||||
-- resolve through Assets so an overrides/ or transform-derived icon
|
||||
-- (e.g. a per-species image at assets/generated/icons/<name>.png) is
|
||||
-- picked up the same way battle sprites are
|
||||
local ok, img
|
||||
if baked then
|
||||
if ogColors then
|
||||
ok, img = pcall(require("src.render.SpriteRenderer").obpImage,
|
||||
path, ogColors, ogGroup)
|
||||
elseif baked then
|
||||
ok, img = pcall(obpIcon, path)
|
||||
else
|
||||
ok, img = pcall(love.graphics.newImage, Assets.resolve(path))
|
||||
@@ -292,12 +302,19 @@ function PartyMenu.drawIcon(game, mon, x, y, selected, counter, forceAlt)
|
||||
-- sx = -1 about the block's right edge, so the flipped copy lands on
|
||||
-- x+8..x+16: the OAM_XFLIP half
|
||||
love.graphics.draw(img, half, x + 16, y, 0, -1, 1)
|
||||
if ogColors then
|
||||
PaletteFX.markUiSpriteRedraw(img, half, x, y)
|
||||
PaletteFX.markUiSpriteRedraw(img, half, x + 16, y, OAM_XFLIP)
|
||||
end
|
||||
elseif ih > 16 then
|
||||
love.graphics.draw(img, love.graphics.newQuad(0, frame * 16, 16, 16, iw, ih), x, y)
|
||||
local quad = love.graphics.newQuad(0, frame * 16, 16, 16, iw, ih)
|
||||
love.graphics.draw(img, quad, x, y)
|
||||
if ogColors then PaletteFX.markUiSpriteRedraw(img, quad, x, y) end
|
||||
else
|
||||
-- HELIX and any mod art that is a single frame: drawn whole, at
|
||||
-- whatever size the file is (unchanged path)
|
||||
love.graphics.draw(img, x, y)
|
||||
if ogColors then PaletteFX.markUiSpriteRedraw(img, nil, x, y) end
|
||||
end
|
||||
-- Report the covering rect so Renderer:endFrame can re-blit it unshaded
|
||||
-- over the colorized pass. Every branch above lays a frame into a 16x16
|
||||
@@ -531,6 +548,7 @@ function PartyMenu:update(dt)
|
||||
refuseBadge(self)
|
||||
return
|
||||
end
|
||||
self.submenu = nil -- engine/menus/start_sub_menus.asm:66
|
||||
ow:useFlashFieldMove(function() self:close() end)
|
||||
return
|
||||
elseif action == "surf" then
|
||||
@@ -549,6 +567,7 @@ function PartyMenu:update(dt)
|
||||
-- GBPalWhiteOutWithDelay3 + jp .goBackToMap only follow it, so
|
||||
-- trySurf closes this menu when its text does (#385)
|
||||
local fx, fy = ow.player:facingCell()
|
||||
self.submenu = nil -- engine/menus/start_sub_menus.asm:66
|
||||
ow:trySurf(fx, fy, function() self:close() end)
|
||||
return
|
||||
end
|
||||
@@ -621,6 +640,7 @@ function PartyMenu:update(dt)
|
||||
refuseBadge(self)
|
||||
return
|
||||
end
|
||||
self.submenu = nil -- engine/menus/start_sub_menus.asm:66
|
||||
ow:useStrengthFieldMove(mon, function() self:close() end)
|
||||
return
|
||||
elseif ow and ow.useFieldMove then
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
-- engine/overworld/dust_smoke.asm:1
|
||||
-- POKEPORT_DRIVER=tests/drivers/boulder_dust_bug2263.lua POKEPORT_IDENTITY=red-sep04 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local MAP = "SEAFOAM_ISLANDS_1F"
|
||||
local DIRS = { "right", "left", "down", "up" }
|
||||
local STEP = { down = { 0, 1 }, up = { 0, -1 }, left = { -1, 0 }, right = { 1, 0 } }
|
||||
|
||||
local ok = true
|
||||
local function check(label, pass)
|
||||
U.log(pass and "PASS" or "FAIL", label)
|
||||
if not pass then ok = false end
|
||||
return pass
|
||||
end
|
||||
local function finish()
|
||||
for _, d in ipairs(DIRS) do game.input.state[d] = false end
|
||||
U.log(ok and "PASS boulder_dust_2263" or "FAIL boulder_dust_2263")
|
||||
love.event.quit(ok and 0 or 1)
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
U.teleport(game, MAP, 17, 10, "right")
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
game.data.encounters[MAP] = nil
|
||||
local rock = ow and ow:npcAtCell(18, 10)
|
||||
if not check("boulder present at (18,10) on " .. MAP,
|
||||
rock ~= nil and rock.def.sprite == "SPRITE_BOULDER") then
|
||||
finish()
|
||||
end
|
||||
ow.strengthActive = true
|
||||
|
||||
local function free(x, y)
|
||||
if not (ow.map:inBounds(x, y) and ow.map:isWalkableCell(x, y)) then return false end
|
||||
local n = ow:npcAtCell(x, y)
|
||||
return n == nil or n == rock
|
||||
end
|
||||
local cx, cy
|
||||
for y = 9, 40 do
|
||||
for x = 0, 60 do
|
||||
if not cx and free(x, y) then
|
||||
local all = true
|
||||
for _, d in ipairs(DIRS) do
|
||||
local s = STEP[d]
|
||||
if not (free(x + s[1], y + s[2]) and free(x + 2 * s[1], y + 2 * s[2])) then
|
||||
all = false
|
||||
end
|
||||
end
|
||||
if all then cx, cy = x, y end
|
||||
end
|
||||
end
|
||||
end
|
||||
if not check("found an open cross for pushes in all four directions", cx ~= nil) then
|
||||
finish()
|
||||
end
|
||||
U.log("push center", cx, cy)
|
||||
|
||||
local function setup(dir)
|
||||
for _, d in ipairs(DIRS) do game.input.state[d] = false end
|
||||
ow.dustAnim = nil
|
||||
ow.boulderTried = nil
|
||||
local s = STEP[dir]
|
||||
rock.cellX, rock.cellY = cx + s[1], cy + s[2]
|
||||
rock.px, rock.py = rock.cellX * 16, rock.cellY * 16
|
||||
rock.moving, rock.targetX, rock.targetY = false, nil, nil
|
||||
local p = ow.player
|
||||
p.cellX, p.cellY = cx, cy
|
||||
p.px, p.py = cx * 16, cy * 16
|
||||
p.moving, p.targetX, p.targetY = false, nil, nil
|
||||
p.facing = dir
|
||||
U.wait(8)
|
||||
end
|
||||
|
||||
local function pushUntilDust(dir)
|
||||
local first = true
|
||||
for _ = 1, 300 do
|
||||
if ow.dustAnim and ow.dustAnim.boulder then return ow.dustAnim end
|
||||
if first then table.insert(game.input.pressQueue, dir); first = false end
|
||||
game.input.state[dir] = true
|
||||
U.wait(1)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local marks = {}
|
||||
for _, dir in ipairs(DIRS) do
|
||||
setup(dir)
|
||||
local s = STEP[dir]
|
||||
local da = pushUntilDust(dir)
|
||||
if check(dir .. ": boulder dust starts after the push", da ~= nil) then
|
||||
check(dir .. ": dust anchored on the boulder's new cell",
|
||||
da.x == cx + 2 * s[1] and da.y == cy + 2 * s[2])
|
||||
check(dir .. ": first step already 1px toward the player, faded",
|
||||
da.ox == -s[1] and da.oy == -s[2] and da.faded == true)
|
||||
local n, lockHeld, maxStep, order = 0, true, 0, true
|
||||
local m = {}
|
||||
while ow.dustAnim == da and n < 120 do
|
||||
local k = math.abs(da.ox) + math.abs(da.oy)
|
||||
if k < maxStep then order = false end
|
||||
maxStep = math.max(maxStep, k)
|
||||
if not m[k] then m[k] = n end
|
||||
if ow.player.moving or ow.player.cellX ~= cx or ow.player.cellY ~= cy then
|
||||
lockHeld = false
|
||||
end
|
||||
game.input.state[dir] = true
|
||||
n = n + 1
|
||||
U.wait(1)
|
||||
end
|
||||
game.input.state[dir] = false
|
||||
marks[dir] = m
|
||||
U.log(dir, "dust frames", n)
|
||||
check(dir .. ": dust lasts 24 frames (8 x Delay3)", n == 24)
|
||||
check(dir .. ": dust slides 1px per step, 8px total", order and maxStep == 8)
|
||||
check(dir .. ": player cannot step while the dust plays", lockHeld)
|
||||
end
|
||||
U.wait(20)
|
||||
end
|
||||
|
||||
for _, dir in ipairs(DIRS) do
|
||||
local m = marks[dir]
|
||||
if m then
|
||||
setup(dir)
|
||||
local da = pushUntilDust(dir)
|
||||
if da then
|
||||
local t0 = U.frame()
|
||||
U.shot(game, ("%s/2263_%s_01_dust_step1_under_boulder.png"):format(DIR, dir))
|
||||
while ow.dustAnim == da and U.frame() - t0 < (m[4] or 9) do
|
||||
game.input.state[dir] = true
|
||||
U.wait(1)
|
||||
end
|
||||
U.shot(game, ("%s/2263_%s_02_dust_mid_slide.png"):format(DIR, dir))
|
||||
while ow.dustAnim == da and U.frame() - t0 < (m[8] or 21) do
|
||||
game.input.state[dir] = true
|
||||
U.wait(1)
|
||||
end
|
||||
U.shot(game, ("%s/2263_%s_03_dust_half_over_old_cell.png"):format(DIR, dir))
|
||||
game.input.state[dir] = false
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
end
|
||||
|
||||
ow:startDustAnim(cx, cy, nil)
|
||||
check("cut puff keeps its static 32-frame timing",
|
||||
ow.dustAnim.frames == 32 and ow.dustAnim.ox == nil and not ow.dustAnim.boulder)
|
||||
ow.dustAnim = nil
|
||||
|
||||
finish()
|
||||
end
|
||||
@@ -145,8 +145,8 @@ return function(game)
|
||||
-- around to its south side
|
||||
holdUntil("down", playerAt(18, 7), 120)
|
||||
holdUntil("left", playerAt(17, 7), 120)
|
||||
-- up column 17 onto her row
|
||||
holdUntil("up", boulderAt(17, 3), 400)
|
||||
-- up column 17 onto her row -- engine/overworld/push_boulder.asm:66
|
||||
holdUntil("up", playerAt(17, 4), 400)
|
||||
check("boulder pushed up column 17 onto row 3 at (17,3)",
|
||||
rock.cellX == 17 and rock.cellY == 3)
|
||||
if not pass then
|
||||
|
||||
@@ -118,11 +118,8 @@ return function(game)
|
||||
check(engaged, "BUG1: Karate Master stops the player at his left")
|
||||
|
||||
------------------------------------------------------------------
|
||||
-- BUG3: talk to the already-beaten master -> "Stay and train..." and
|
||||
-- NOT the "I am the LEADER here!" pre-battle challenge.
|
||||
-- ../pokered/scripts/FightingDojo.asm:106-136
|
||||
------------------------------------------------------------------
|
||||
-- talk from (4,3) facing right: beside the master (5,3), off his DOWN
|
||||
-- sight line so he can't (post-fix) aggro before we set him defeated
|
||||
ow = resetDojo(4, 3, "right", { EVENT_BEAT_KARATE_MASTER = true })
|
||||
game.save.defeatedTrainers["FIGHTING_DOJO_obj_1"] = true
|
||||
local master = npcByName(ow, "FIGHTINGDOJO_KARATE_MASTER")
|
||||
@@ -131,9 +128,23 @@ return function(game)
|
||||
local first = waitReadyPage()
|
||||
check(not first:find("LEADER", 1, true) and not first:find("Grunt", 1, true),
|
||||
"BUG3: beaten master no longer shows the challenge (page1='" .. first .. "')")
|
||||
check(first:find("Indeed, I have", 1, true) ~= nil,
|
||||
"#2253: beaten master before a prize repeats 'Indeed, I have lost!' (page1='" .. first .. "')")
|
||||
check(not first:find("Stay and train", 1, true),
|
||||
"#2253: no 'Stay and train' before a prize is chosen")
|
||||
U.shot(game, DIR .. "/2253_01_master_prize_offer_before_choosing.png")
|
||||
mashUntil(function() return game.stack:top() == ow end)
|
||||
end
|
||||
|
||||
ow = resetDojo(4, 3, "right",
|
||||
{ EVENT_BEAT_KARATE_MASTER = true, EVENT_DEFEATED_FIGHTING_DOJO = true })
|
||||
game.save.defeatedTrainers["FIGHTING_DOJO_obj_1"] = true
|
||||
master = npcByName(ow, "FIGHTINGDOJO_KARATE_MASTER")
|
||||
if check(master ~= nil, "#2253: master npc present after the prize") then
|
||||
ow:talkTo(master)
|
||||
check(sawText("Stay and train"),
|
||||
"BUG3: beaten master says 'Stay and train at Karate with us!'")
|
||||
U.shot(game, DIR .. "/dojo_3_retalk.png")
|
||||
"#2253: after the prize the master says 'Stay and train at Karate with us!'")
|
||||
U.shot(game, DIR .. "/2253_02_master_stay_and_train_after_prize.png")
|
||||
mashUntil(function() return game.stack:top() == ow end)
|
||||
end
|
||||
|
||||
@@ -184,6 +195,15 @@ return function(game)
|
||||
U.shot(game, DIR .. "/dojo_5_greedy.png")
|
||||
mashUntil(function() return game.stack:top() == ow end)
|
||||
end
|
||||
local flowMaster = npcByName(ow, "FIGHTINGDOJO_KARATE_MASTER")
|
||||
if check(flowMaster ~= nil, "#2253: master still present after taking a prize") then
|
||||
ow:talkTo(flowMaster)
|
||||
local page = waitReadyPage()
|
||||
check(page:find("Ho!", 1, true) ~= nil or page:find("Stay and train", 1, true) ~= nil,
|
||||
"#2253: after taking HITMONLEE the master says 'Stay and train' (page1='" .. page .. "')")
|
||||
U.shot(game, DIR .. "/2253_03_master_after_taking_hitmonlee.png")
|
||||
mashUntil(function() return game.stack:top() == ow end)
|
||||
end
|
||||
end
|
||||
|
||||
------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
-- POKEPORT_IDENTITY=red-sep04 POKEPORT_DRIVER=tests/drivers/game_corner_coin_seat_bug2272.lua POKEPORT_TOUCH=0 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local failed = false
|
||||
|
||||
local function check(label, ok)
|
||||
print((ok and "PASS " or "FAIL ") .. label)
|
||||
if not ok then failed = true end
|
||||
return ok
|
||||
end
|
||||
|
||||
local KEY = "GAME_CORNER_12_15"
|
||||
|
||||
U.teleport(game, "GAME_CORNER", 12, 16, "up")
|
||||
U.wait(20)
|
||||
local save = game.save
|
||||
save.inventory.COIN_CASE = 1
|
||||
save.coins = 50
|
||||
save.hiddenTaken = save.hiddenTaken or {}
|
||||
save.hiddenTaken[KEY] = nil
|
||||
U.shot(game, DIR .. "/2272_01_facing_seat_12_15.png")
|
||||
|
||||
local ow = game.stack:top()
|
||||
U.tap(game, "a")
|
||||
local frames = 0
|
||||
for _ = 1, 120 do
|
||||
if game.stack:top() ~= ow then break end
|
||||
U.wait(1)
|
||||
frames = frames + 1
|
||||
end
|
||||
local box = game.stack:top()
|
||||
check("2272 seat 12_15 opens a box", box ~= ow)
|
||||
check("2272 seat 12_15 is the slot prompt", type(box) == "table" and type(box.choice) == "function")
|
||||
check("2272 seat 12_15 pays no coins", save.coins == 50)
|
||||
check("2272 seat 12_15 sets no hidden coin flag", not save.hiddenTaken[KEY])
|
||||
|
||||
U.wait(frames + 60)
|
||||
U.shot(game, DIR .. "/2272_02_slot_prompt_not_coins.png")
|
||||
|
||||
love.event.quit(failed and 1 or 0)
|
||||
end
|
||||
@@ -0,0 +1,109 @@
|
||||
-- POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-sep04 POKEPORT_DRIVER=tests/drivers/gold_import_scenes_bug2245.lua love .
|
||||
local U = require("tests.drivers.util")
|
||||
local Gen2Layout = require("src.save_convert.Gen2Layout")
|
||||
local SaveConvert = require("src.save_convert.SaveConvert")
|
||||
|
||||
local SIZE = 32768
|
||||
|
||||
local function buildCart(maps)
|
||||
local L = Gen2Layout.goldSilver
|
||||
local b = {}
|
||||
for i = 0, SIZE - 1 do b[i] = 0 end
|
||||
local name = { 0x86, 0x8E, 0x8B, 0x83, 0x50 }
|
||||
for i, c in ipairs(name) do b[L.wPlayerName + i - 1] = c end
|
||||
local lab = assert(maps and maps.ELMS_LAB, "no ELMS_LAB in this cache")
|
||||
b[L.wMapGroup], b[L.wMapNumber] = lab.group, lab.map
|
||||
b[L.wXCoord], b[L.wYCoord] = 4, 8
|
||||
b[L.wPartySpecies] = 0xFF
|
||||
b[L.wItems], b[L.wKeyItems], b[L.wBalls] = 0xFF, 0xFF, 0xFF
|
||||
for _, base in ipairs(L.boxes) do b[base + 1] = 0xFF end
|
||||
-- engine/events/std_scripts.asm:557
|
||||
b[L.wEventFlags + 6] = 0x40
|
||||
-- maps/VictoryRoad.asm:58
|
||||
b[L.wEventFlags + 216] = 0x04
|
||||
-- data/maps/scenes.asm:7
|
||||
b[L.sceneVars.ELMS_LAB] = 2
|
||||
b[L.sceneVars.NEW_BARK_TOWN] = 1
|
||||
b[L.sceneVars.ROUTE_27] = 1
|
||||
b[L.sceneVars.VICTORY_ROAD] = 1
|
||||
b[L.sCheckValue1], b[L.sCheckValue2] = 0x63, 0x7F
|
||||
local sum = 0
|
||||
for i = L.sGameData, L.sGameDataEnd - 1 do sum = (sum + b[i]) % 65536 end
|
||||
b[L.sChecksum], b[L.sChecksum + 1] = sum % 256, math.floor(sum / 256)
|
||||
local out = {}
|
||||
for i = 0, SIZE - 1 do out[i + 1] = string.char(b[i]) end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-import-scenes-2245"
|
||||
local failed = 0
|
||||
local function pass(ok, line)
|
||||
if not ok then failed = failed + 1 end
|
||||
U.log((ok and "PASS " or "FAIL ") .. line)
|
||||
end
|
||||
|
||||
local function settle(world)
|
||||
for _ = 1, 240 do
|
||||
if not world:busy() then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(30)
|
||||
end
|
||||
|
||||
U.wait(45)
|
||||
local ok, err = pcall(function()
|
||||
assert(game.world and game.world.maps, "gold world did not boot")
|
||||
local save, why = SaveConvert.importSav(buildCart(game.world.maps), "gold", "gold")
|
||||
assert(save, "import failed: " .. tostring(why))
|
||||
pass(save.mapScenes and save.mapScenes.ELMS_LAB == 2, "2245 import carries ELMS_LAB scene 2")
|
||||
|
||||
game:continueGame(save)
|
||||
U.wait(10)
|
||||
local world = assert(game.world and game.world.map and game.world, "CONTINUE did not boot a world")
|
||||
pass(world.map.id == "ELMS_LAB", "2245 continue lands in ELMS_LAB (got " .. tostring(world.map.id) .. ")")
|
||||
settle(world)
|
||||
pass(world:scene() == 2, "2245 ELMS_LAB scene is 2 (got " .. tostring(world:scene()) .. ")")
|
||||
pass(not world:scriptRunning(), "2245 Elm's intro does not replay")
|
||||
U.shot(game, out .. "/2245_01_elms_lab_no_intro.png")
|
||||
|
||||
local stops = {
|
||||
{ "NEW_BARK_TOWN", 1, 8, "left", 1, "2245_02_new_bark_no_teacher.png" },
|
||||
{ "ROUTE_27", 18, 10, "left", 1, "2245_03_route27_no_fisher.png" },
|
||||
{ "VICTORY_ROAD", 12, 8, "up", 1 },
|
||||
}
|
||||
for _, s in ipairs(stops) do
|
||||
local mapId, x, y, facing, want, shot = s[1], s[2], s[3], s[4], s[5], s[6]
|
||||
assert(world:setMap(mapId, x, y, facing), "setMap " .. mapId)
|
||||
settle(world)
|
||||
pass(world:scene() == want,
|
||||
("2245 %s scene is %d (got %s)"):format(mapId, want, tostring(world:scene())))
|
||||
pass(not world:tryCoordScript(), "2245 " .. mapId .. " coord_event stays quiet")
|
||||
U.wait(30)
|
||||
pass(not world:scriptRunning(), "2245 " .. mapId .. " no script running")
|
||||
if mapId ~= "VICTORY_ROAD" then U.shot(game, out .. "/" .. shot) end
|
||||
end
|
||||
|
||||
-- maps/VictoryRoad.asm:263
|
||||
local rival
|
||||
for _, obj in ipairs(world.map.def.objects or {}) do
|
||||
if obj.x == 18 and obj.y == 13 then rival = obj end
|
||||
end
|
||||
assert(rival, "no object at (18,13) in VICTORY_ROAD")
|
||||
local flagSet = world.events:get(rival.eventFlag)
|
||||
pass(flagSet, ("2245 VICTORY_ROAD rival flag %s is set (got %s)"):format(
|
||||
tostring(rival.eventFlag), tostring(flagSet)))
|
||||
assert(world:setMap("VICTORY_ROAD", 15, 11, "up"), "setMap VICTORY_ROAD recentre")
|
||||
settle(world)
|
||||
local npc = world:npcAt(18, 13)
|
||||
pass(npc == nil, "2245 VICTORY_ROAD no object sprite at (18,13) (got "
|
||||
.. tostring(npc and (npc.sprite or npc.name or "npc")) .. ")")
|
||||
U.shot(game, out .. "/2245_04_victory_road_rival_cell_empty.png")
|
||||
end)
|
||||
if not ok then
|
||||
failed = failed + 1
|
||||
U.log("FAIL 2245 driver error: " .. tostring(err))
|
||||
end
|
||||
U.log((failed == 0 and "PASS" or "FAIL") .. " gold_import_scenes_bug2245 shots in " .. out)
|
||||
love.event.quit(failed == 0 and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,178 @@
|
||||
-- POKEPORT_IDENTITY=red-sep04 POKEPORT_DRIVER=tests/drivers/intro_og_obj_palette_bug2264_test.lua POKEPORT_SHOT_DIR=/tmp/shots love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local P = require("src.render.PaletteFX")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local IntroMovie = require("src.ui.IntroMovie")
|
||||
local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local version = GameVersion.get()
|
||||
local tag = "2264_" .. tostring(version)
|
||||
|
||||
local fails = 0
|
||||
local function check(label, ok, detail)
|
||||
U.log(ok and "PASS" or "FAIL", label, detail or "")
|
||||
if not ok then fails = fails + 1 end
|
||||
return ok
|
||||
end
|
||||
|
||||
local function finish()
|
||||
P.applyOptions({ colors = "ogred" })
|
||||
if fails > 0 then
|
||||
U.log("FAIL " .. tag .. ": " .. fails .. " check(s), shots in " .. SHOT_DIR)
|
||||
love.event.quit(1)
|
||||
else
|
||||
U.log("PASS " .. tag .. ": all checks, shots in " .. SHOT_DIR)
|
||||
love.event.quit(0)
|
||||
end
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
local rendered = 0
|
||||
local hostDraw = love.draw
|
||||
love.draw = function(...)
|
||||
local r = hostDraw(...)
|
||||
rendered = rendered + 1
|
||||
return r
|
||||
end
|
||||
|
||||
local function waitRenderedFrames(frames)
|
||||
local target = rendered + (frames or 2)
|
||||
for _ = 1, 2400 do
|
||||
if rendered >= target then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function waitFor(pred, limit)
|
||||
for _ = 1, limit or 3000 do
|
||||
if pred() then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function near(R, G, B, c)
|
||||
return math.abs(R - c[1]) <= 12 and math.abs(G - c[2]) <= 12
|
||||
and math.abs(B - c[3]) <= 12
|
||||
end
|
||||
|
||||
local objRamp, bgRamp
|
||||
|
||||
local function letterbox(img)
|
||||
local W, H = img:getWidth(), img:getHeight()
|
||||
local x1, y1, x2, y2 = W, H, -1, -1
|
||||
for y = 0, H - 1 do
|
||||
for x = 0, W - 1 do
|
||||
local r, g, b = img:getPixel(x, y)
|
||||
if r > 0.02 or g > 0.02 or b > 0.02 then
|
||||
if x < x1 then x1 = x end
|
||||
if x > x2 then x2 = x end
|
||||
if y < y1 then y1 = y end
|
||||
if y > y2 then y2 = y end
|
||||
end
|
||||
end
|
||||
end
|
||||
local bw, bh = x2 - x1 + 1, y2 - y1 + 1
|
||||
if x2 >= x1 and math.abs(bw / 160 - bh / 144) < 0.05 then
|
||||
return x1, y1, bh / 144
|
||||
end
|
||||
local s = math.min(W / 160, H / 144)
|
||||
return math.floor((W - 160 * s) / 2), math.floor((H - 144 * s) / 2), s
|
||||
end
|
||||
|
||||
local shotImg = {}
|
||||
local function load(path)
|
||||
if shotImg[path] ~= nil then return shotImg[path] end
|
||||
local f = io.open(path, "rb")
|
||||
if not f then shotImg[path] = false return false end
|
||||
local bytes = f:read("*a")
|
||||
f:close()
|
||||
local ok, img = pcall(function()
|
||||
return love.image.newImageData(love.filesystem.newFileData(bytes, "shot.png"))
|
||||
end)
|
||||
shotImg[path] = ok and img or false
|
||||
return shotImg[path]
|
||||
end
|
||||
|
||||
local function census(path, bx1, by1, bx2, by2)
|
||||
local img = load(path)
|
||||
if not img then return nil end
|
||||
local W, H = img:getWidth(), img:getHeight()
|
||||
local ox, oy, scale = letterbox(img)
|
||||
local x1 = ox + math.floor(bx1 * scale + 0.5) + 1
|
||||
local y1 = oy + math.floor(by1 * scale + 0.5) + 1
|
||||
local x2 = ox + math.floor(bx2 * scale + 0.5) - 2
|
||||
local y2 = oy + math.floor(by2 * scale + 0.5) - 2
|
||||
local c = { obj = 0, bg = 0, black = 0, total = 0 }
|
||||
for y = math.max(0, y1), math.min(H - 1, y2) do
|
||||
for x = math.max(0, x1), math.min(W - 1, x2) do
|
||||
local r, g, b = img:getPixel(x, y)
|
||||
local R = math.floor(r * 255 + 0.5)
|
||||
local G = math.floor(g * 255 + 0.5)
|
||||
local B = math.floor(b * 255 + 0.5)
|
||||
c.total = c.total + 1
|
||||
if R < 20 and G < 20 and B < 20 then c.black = c.black + 1 end
|
||||
if near(R, G, B, objRamp[1]) or near(R, G, B, objRamp[2]) then c.obj = c.obj + 1 end
|
||||
if near(R, G, B, bgRamp[1]) or near(R, G, B, bgRamp[2]) then c.bg = c.bg + 1 end
|
||||
end
|
||||
end
|
||||
local d = scale * scale
|
||||
c.obj = math.floor(c.obj / d + 0.5)
|
||||
c.bg = math.floor(c.bg / d + 0.5)
|
||||
return c
|
||||
end
|
||||
|
||||
local movie
|
||||
check(tag .. " intro is on the stack", waitFor(function()
|
||||
movie = game.stack:top()
|
||||
return getmetatable(movie) == IntroMovie
|
||||
end, 600))
|
||||
if getmetatable(movie) ~= IntroMovie then finish() end
|
||||
|
||||
game.save.options.colors = "ogred"
|
||||
P.applyOptions(game.save.options)
|
||||
check(tag .. " COLORS is ogred", P.mode == "ogred", P.mode)
|
||||
local blue = GameVersion.isBlue()
|
||||
local obj = blue and P.GBC_OBJ_BLUE or P.GBC_OBJ
|
||||
local bg = P.ogBg()
|
||||
objRamp = { obj[2], obj[3] }
|
||||
bgRamp = { bg[2], bg[3] }
|
||||
|
||||
check(tag .. " scrollIn finished", waitFor(function()
|
||||
return movie.phase == 3 and movie.opIndex > 1 and movie.nidoX == 72
|
||||
end, 3000), tostring(movie.nidoX))
|
||||
waitRenderedFrames(3)
|
||||
local nidoX, nidoY, gengarX = movie.nidoX, movie.nidoY, movie.gengarX
|
||||
local shot1 = SHOT_DIR .. "/" .. tag .. "_01_fight_after_scroll_in.png"
|
||||
check(tag .. " replay recorded", #P.uiSpriteRedraws() >= 2, "#" .. #P.uiSpriteRedraws())
|
||||
if check(tag .. " shot 01", U.shot(game, shot1) and load(shot1)) then
|
||||
local nido = census(shot1, math.max(nidoX, gengarX + 56), nidoY, nidoX + 48, 112)
|
||||
local gengar = census(shot1, gengarX, 56, math.min(gengarX + 56, nidoX), 112)
|
||||
local topBar = census(shot1, 0, 0, 160, 32)
|
||||
local botBar = census(shot1, 0, 112, 160, 144)
|
||||
U.log(string.format("nido band obj=%d bg=%d | gengar band obj=%d bg=%d",
|
||||
nido.obj, nido.bg, gengar.obj, gengar.bg))
|
||||
check(tag .. " front mon wears the boot-ROM OBJ ramp", nido.obj > 150, nido.obj)
|
||||
check(tag .. " front mon carries no BG ramp", nido.bg < 20, nido.bg)
|
||||
check(tag .. " Gengar keeps the BG ramp", gengar.bg > 150, gengar.bg)
|
||||
check(tag .. " Gengar carries no OBJ ramp", gengar.obj < 20, gengar.obj)
|
||||
check(tag .. " top bar stays solid black", topBar.black == topBar.total,
|
||||
topBar.black .. "/" .. topBar.total)
|
||||
check(tag .. " bottom bar covers the OAM_PRIO sprite", botBar.black == botBar.total,
|
||||
botBar.black .. "/" .. botBar.total)
|
||||
end
|
||||
|
||||
check(tag .. " reached the fade", waitFor(function()
|
||||
return movie.fade >= 0.5 or movie.phase == 4
|
||||
end, 3000), tostring(movie.fade))
|
||||
local shot2 = SHOT_DIR .. "/" .. tag .. "_02_fade_to_white_mid.png"
|
||||
if movie.phase == 3 and check(tag .. " shot 02", U.shot(game, shot2) and load(shot2)) then
|
||||
local botBar = census(shot2, 0, 112, 160, 144)
|
||||
local topBar = census(shot2, 0, 0, 160, 32)
|
||||
check(tag .. " fade wash never paints OBJ pixels into the bars",
|
||||
botBar.obj == 0 and topBar.obj == 0, botBar.obj .. "/" .. topBar.obj)
|
||||
end
|
||||
|
||||
finish()
|
||||
end
|
||||
@@ -0,0 +1,120 @@
|
||||
-- POKEPORT_DRIVER=tests/drivers/party_icon_og_obj_bug2268_test.lua POKEPORT_IDENTITY=red-sep04 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots"
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Screens = require("src.ui.Screens")
|
||||
|
||||
local tag = GameVersion.isBlue() and "blue" or "red"
|
||||
local ok = true
|
||||
local function check(label, pass)
|
||||
U.log(pass and "PASS" or "FAIL", label)
|
||||
if not pass then ok = false end
|
||||
return pass
|
||||
end
|
||||
|
||||
game.save.options = game.save.options or {}
|
||||
local prevColors = game.save.options.colors
|
||||
local function setColors(mode)
|
||||
game.save.options.colors = mode
|
||||
PaletteFX.setMode(mode)
|
||||
end
|
||||
|
||||
local function finish()
|
||||
setColors(prevColors or "gbc")
|
||||
U.log(ok and "PASS party_icon_og_obj_2268" or "FAIL party_icon_og_obj_2268")
|
||||
love.event.quit(ok and 0 or 1)
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
local function shotPixels(path)
|
||||
if not U.shot(game, path) then return nil end
|
||||
local f = io.open(path, "rb")
|
||||
if not f then return nil end
|
||||
local bytes = f:read("*a")
|
||||
f:close()
|
||||
local good, id = pcall(function()
|
||||
return love.image.newImageData(love.filesystem.newFileData(bytes, "shot.png"))
|
||||
end)
|
||||
return good and id or nil
|
||||
end
|
||||
|
||||
local function count(id, c)
|
||||
if not (id and c) then return -1 end
|
||||
local n = 0
|
||||
local w, h = id:getDimensions()
|
||||
for y = 0, h - 1 do
|
||||
for x = 0, w - 1 do
|
||||
local r, g, b = id:getPixel(x, y)
|
||||
if math.abs(r * 255 - c[1]) < 6 and math.abs(g * 255 - c[2]) < 6
|
||||
and math.abs(b * 255 - c[3]) < 6 then
|
||||
n = n + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
local SPECIES = { "NIDOKING", "PIDGEOTTO", "CLEFAIRY", "BULBASAUR", "OMANYTE" }
|
||||
local party = {}
|
||||
local mirrored = 0
|
||||
for i, species in ipairs(SPECIES) do
|
||||
party[i] = Pokemon.new(game.data, species, 30)
|
||||
local def = game.data.pokemon[species]
|
||||
local name = def and def.dex and game.data.icons and game.data.icons.byDex
|
||||
and game.data.icons.byDex[def.dex]
|
||||
if PartyMenu.mirrorsIcon(name) then mirrored = mirrored + 1 end
|
||||
end
|
||||
game.save.party = party
|
||||
game.save.player.name = game.save.player.name or "RED"
|
||||
|
||||
setColors("ogred")
|
||||
check("COLORS is OG (" .. PaletteFX.modeLabel() .. ")", PaletteFX.mode == "ogred")
|
||||
check("OG mode takes the OBJ bake path on " .. tag, PaletteFX.usesSpriteObp())
|
||||
|
||||
U.teleport(game, "PALLET_TOWN", 10, 12, "down")
|
||||
U.wait(10)
|
||||
Screens.push(game, "PartyMenu", {})
|
||||
U.wait(16)
|
||||
local pm = game.stack:top()
|
||||
if not check("the party list is open", getmetatable(pm) == PartyMenu) then
|
||||
return finish()
|
||||
end
|
||||
|
||||
local redraws = PaletteFX.uiSpriteRedraws()
|
||||
local flips = 0
|
||||
for _, r in ipairs(redraws) do
|
||||
if r.sx == -1 then flips = flips + 1 end
|
||||
end
|
||||
U.log(" ui redraws:", #redraws, "flipped:", flips, "mirrored icons:", mirrored)
|
||||
check("every icon replays after the zone pass",
|
||||
#redraws == mirrored * 2 + (#party - mirrored))
|
||||
check("each mirrored icon replays its OAM_XFLIP half", flips == mirrored)
|
||||
|
||||
local objRamp = GameVersion.isBlue() and PaletteFX.GBC_OBJ_BLUE or PaletteFX.GBC_OBJ
|
||||
local bgRamp = PaletteFX.ogBg()
|
||||
local id = shotPixels(DIR .. "/2268_01_party_og_" .. tag .. ".png")
|
||||
check("OG party screenshot captured", id ~= nil)
|
||||
if id then
|
||||
local obj1 = count(id, objRamp[2])
|
||||
local bg2 = count(id, bgRamp[3])
|
||||
U.log(" OBJ shade-1 pixels:", obj1, " BG shade-2 (HP bar) pixels:", bg2)
|
||||
check("icons wear the boot-ROM OBJ shade 1 on " .. tag, obj1 > 0)
|
||||
check("HP bars keep the BG ramp", bg2 > 0)
|
||||
end
|
||||
|
||||
setColors("gbc")
|
||||
U.wait(10)
|
||||
check("SGB records no OBJ replays", #PaletteFX.uiSpriteRedraws() == 0)
|
||||
local sgb = shotPixels(DIR .. "/2268_02_party_sgb_" .. tag .. ".png")
|
||||
check("SGB party screenshot captured", sgb ~= nil)
|
||||
if sgb and not GameVersion.isBlue() then
|
||||
check("SGB icons never wear the boot-ROM OBJ green",
|
||||
count(sgb, PaletteFX.GBC_OBJ[2]) == 0)
|
||||
end
|
||||
|
||||
return finish()
|
||||
end
|
||||
@@ -0,0 +1,77 @@
|
||||
-- pokered/scripts/SaffronCity.asm:76
|
||||
-- pokered/home/text.asm:525
|
||||
-- POKEPORT_DRIVER=tests/drivers/saffron_pidgeot_cry_bug2262_test.lua POKEPORT_IDENTITY=red-sep04 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots"
|
||||
local Sound = require("src.core.Sound")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
local ok = true
|
||||
local function check(label, pass)
|
||||
U.log(pass and "PASS" or "FAIL", label)
|
||||
if not pass then ok = false end
|
||||
return pass
|
||||
end
|
||||
local function finish()
|
||||
U.log(ok and "PASS saffron_pidgeot_cry_2262" or "FAIL saffron_pidgeot_cry_2262")
|
||||
love.event.quit(ok and 0 or 1)
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
U.newGame(game)
|
||||
if not check("new game reached the overworld", game.overworld ~= nil) then finish() end
|
||||
|
||||
local cries = {}
|
||||
local realPlayCry = Sound.playCry
|
||||
Sound.playCry = function(data, species, ...)
|
||||
cries[#cries + 1] = { species = species, frame = U.frame() }
|
||||
return realPlayCry(data, species, ...)
|
||||
end
|
||||
game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI = true
|
||||
U.teleport(game, "SAFFRON_CITY", 31, 13, "up")
|
||||
U.wait(30)
|
||||
|
||||
local pidgeot
|
||||
for _, n in ipairs(game.overworld.npcs or {}) do
|
||||
if n.def and n.def.name == "SAFFRONCITY_PIDGEOT" then pidgeot = n end
|
||||
end
|
||||
if not check("pidgeot is on SAFFRON_CITY after Silph", pidgeot ~= nil) then finish() end
|
||||
|
||||
U.tap(game, "a")
|
||||
|
||||
local box, typedFrame
|
||||
for _ = 1, 600 do
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) == TextBox then
|
||||
box = top
|
||||
if top.done then typedFrame = U.frame() break end
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
if not check("pidgeot line opened a text box", box ~= nil) then finish() end
|
||||
check("pidgeot line finished typing", typedFrame ~= nil)
|
||||
|
||||
for _ = 1, 30 do
|
||||
if #cries > 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("PIDGEOT cry played", #cries == 1 and cries[1].species == "PIDGEOT")
|
||||
check("cry started after typing finished",
|
||||
cries[1] ~= nil and typedFrame ~= nil and cries[1].frame >= typedFrame)
|
||||
U.shot(game, DIR .. "/2262_01_pidgeot_cry_box.png")
|
||||
|
||||
U.wait(180)
|
||||
check("box holds for A/B after the cry", game.stack:top() == box)
|
||||
U.shot(game, DIR .. "/2262_02_pidgeot_box_waits_for_a.png")
|
||||
|
||||
U.tap(game, "a")
|
||||
for _ = 1, 120 do
|
||||
if game.stack:top() == game.overworld then break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("A closes the pidgeot box", game.stack:top() == game.overworld)
|
||||
|
||||
Sound.playCry = realPlayCry
|
||||
finish()
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
-- POKEPORT_IDENTITY=red-sep04 POKEPORT_TOUCH=0 POKEPORT_SHOT_DIR=/tmp/2235 POKEPORT_DRIVER=tests/drivers/shaderfx_grid_2235.lua love .
|
||||
local U = require("tests.drivers.util")
|
||||
local ShaderFX = require("src.render.ShaderFX")
|
||||
local Zoom = require("src.render.Zoom")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/2235"
|
||||
|
||||
local WANT = {
|
||||
{ file = "retro-v3.slangp", tag = "retro" },
|
||||
{ file = "sameboy-lcd.slangp", tag = "sameboy" },
|
||||
}
|
||||
|
||||
local ZOOMS = {
|
||||
{ offset = 0, tag = "fit" },
|
||||
{ offset = -1, tag = "out1" },
|
||||
{ offset = -2, tag = "out2" },
|
||||
}
|
||||
|
||||
return function(game)
|
||||
local fails = 0
|
||||
local function ok(cond, label)
|
||||
if not cond then fails = fails + 1 end
|
||||
print((cond and "PASS " or "FAIL ") .. label)
|
||||
end
|
||||
|
||||
love.window.setMode(1024, 768, { resizable = true })
|
||||
U.wait(30)
|
||||
|
||||
local gen2 = GameVersion.generation() == 2
|
||||
if gen2 then
|
||||
game.world:warpToMapId("NEW_BARK_TOWN", 6, 6, "down")
|
||||
else
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
end
|
||||
U.wait(60)
|
||||
|
||||
local byName = {}
|
||||
for _, e in ipairs(ShaderFX.list()) do byName[e.name] = e end
|
||||
local ready = {}
|
||||
for _, want in ipairs(WANT) do
|
||||
local entry = byName[want.file]
|
||||
if entry and not entry.converted then
|
||||
local converted = ShaderFX.convert(entry)
|
||||
if not converted then entry = nil end
|
||||
end
|
||||
if entry then
|
||||
ready[#ready + 1] = { entry = entry, tag = want.tag }
|
||||
else
|
||||
print("SKIP " .. want.file .. " is not in this identity's shaders/")
|
||||
end
|
||||
end
|
||||
ok(#ready > 0, "2235 a grid preset is installed")
|
||||
if #ready == 0 then love.event.quit(1) return end
|
||||
|
||||
local captured = {}
|
||||
local realRunChain = ShaderFX.runChain
|
||||
ShaderFX.runChain = function(state, img, luts, viewport, original, layer)
|
||||
captured[layer or "main"] = { vw = viewport.w, vh = viewport.h, ow = original.w, oh = original.h }
|
||||
return realRunChain(state, img, luts, viewport, original, layer)
|
||||
end
|
||||
|
||||
local function fitScale()
|
||||
if gen2 then return game.world:fitScale() end
|
||||
return game.renderer:fitScale()
|
||||
end
|
||||
|
||||
local function scanlineBands(path, s)
|
||||
local f = io.open(path, "rb")
|
||||
if not f then return nil, "no shot" end
|
||||
local bytes = f:read("*a")
|
||||
f:close()
|
||||
local okImg, id = pcall(love.image.newImageData, love.filesystem.newFileData(bytes, "shot.png"))
|
||||
if not okImg then return nil, tostring(id) end
|
||||
local w, h = id:getWidth(), id:getHeight()
|
||||
local rows = {}
|
||||
for y = 0, h - 1 do
|
||||
local sum, n = 0, 0
|
||||
for x = 0, w - 1, 2 do
|
||||
local r, g, b = id:getPixel(x, y)
|
||||
sum, n = sum + (r + g + b) / 3, n + 1
|
||||
end
|
||||
rows[y] = sum / n * 255
|
||||
end
|
||||
local best
|
||||
for off = 0, s - 1 do
|
||||
local groups, total = {}, 0
|
||||
for y0 = off, h - s, s do
|
||||
local lo, hi = rows[y0], rows[y0]
|
||||
for y = y0 + 1, y0 + s - 1 do
|
||||
if rows[y] < lo then lo = rows[y] end
|
||||
if rows[y] > hi then hi = rows[y] end
|
||||
end
|
||||
local c = hi >= 40 and (hi - lo) / hi or nil
|
||||
groups[#groups + 1] = c
|
||||
total = total + (c or 0)
|
||||
end
|
||||
if not best or total < best.total then best = { off = off, groups = groups, total = total } end
|
||||
end
|
||||
local blocks = {}
|
||||
for b = 1, #best.groups, 16 do
|
||||
local sum, n = 0, 0
|
||||
for g = b, math.min(b + 15, #best.groups) do
|
||||
local c = best.groups[g]
|
||||
if c then sum, n = sum + c, n + 1 end
|
||||
end
|
||||
if n >= 4 then blocks[#blocks + 1] = sum / n end
|
||||
end
|
||||
local lo, hi = math.huge, 0
|
||||
for _, v in ipairs(blocks) do
|
||||
if v < lo then lo = v end
|
||||
if v > hi then hi = v end
|
||||
end
|
||||
return { off = best.off, blocks = blocks, lo = lo, hi = hi }
|
||||
end
|
||||
|
||||
local gtag = tostring(GameVersion.get())
|
||||
for _, r in ipairs(ready) do
|
||||
ShaderFX.deactivate("secondary")
|
||||
local on, err = ShaderFX.activate("main", r.entry)
|
||||
ok(on, ("2235 %s activates %s"):format(r.tag, tostring(err or "")))
|
||||
if on then
|
||||
for _, z in ipairs(ZOOMS) do
|
||||
Zoom.offset = Zoom.clampOffset(z.offset, fitScale())
|
||||
captured = {}
|
||||
U.wait(6)
|
||||
local rect = ShaderFX._lastRect
|
||||
local m = captured.main
|
||||
ok(rect ~= nil and m ~= nil
|
||||
and m.vw == m.ow * rect.scale and m.vh == m.oh * rect.scale,
|
||||
("2235 %s %s world viewport is an exact multiple of its source"):format(r.tag, z.tag))
|
||||
local shot = ("%s/2235_%s_%s_%s_map.png"):format(SHOT_DIR, gtag, r.tag, z.tag)
|
||||
U.shot(game, shot)
|
||||
if r.tag == "sameboy" and z.tag == "out2" and rect then
|
||||
local bands, err = scanlineBands(shot, rect.scale)
|
||||
local parts = {}
|
||||
for i, v in ipairs(bands and bands.blocks or {}) do parts[i] = ("%.3f"):format(v) end
|
||||
print(("info sameboy out2 scanline depth per 48px row band (phase %s): %s"):format(
|
||||
tostring(bands and bands.off), #parts > 0 and table.concat(parts, " ") or tostring(err)))
|
||||
ok(bands ~= nil and #bands.blocks >= 8 and bands.lo >= 0.015 and bands.lo >= 0.5 * bands.hi,
|
||||
"2235 sameboy out2 every 48px row band carries scanlines of similar depth")
|
||||
end
|
||||
end
|
||||
|
||||
Zoom.offset = Zoom.clampOffset(-2, fitScale())
|
||||
U.tap(game, "start")
|
||||
U.wait(20)
|
||||
captured = {}
|
||||
U.wait(4)
|
||||
local ui = captured.ui or captured.main
|
||||
ok(ui ~= nil and ui.vw % ui.ow == 0 and ui.vh % ui.oh == 0
|
||||
and ui.vw / ui.ow == ui.vh / ui.oh,
|
||||
("2235 %s out2 menu layer viewport is an exact multiple of its source"):format(r.tag))
|
||||
U.shot(game, ("%s/2235_%s_%s_out2_start_menu.png"):format(SHOT_DIR, gtag, r.tag))
|
||||
U.tap(game, "b")
|
||||
U.wait(20)
|
||||
end
|
||||
end
|
||||
|
||||
ShaderFX.runChain = realRunChain
|
||||
ShaderFX.deactivate("main")
|
||||
Zoom.offset = 0
|
||||
print(fails == 0 and "PASS 2235 shaderfx grid alignment" or "FAIL 2235 shaderfx grid alignment")
|
||||
love.event.quit(fails == 0 and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,86 @@
|
||||
-- home/trainers.asm:123
|
||||
-- POKEPORT_DRIVER=tests/drivers/static_encounter_sting_bug2275_2261.lua POKEPORT_IDENTITY=red-sep04 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Music = require("src.core.Music")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
local ok = true
|
||||
local function check(label, pass)
|
||||
U.log(pass and "PASS" or "FAIL", label)
|
||||
if not pass then ok = false end
|
||||
return pass
|
||||
end
|
||||
local function finish()
|
||||
U.log(ok and "PASS static_encounter_sting_2275_2261"
|
||||
or "FAIL static_encounter_sting_2275_2261")
|
||||
love.event.quit(ok and 0 or 1)
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
local function isBox(s) return getmetatable(s) == TextBox end
|
||||
local function waitTypedBox()
|
||||
for _ = 1, 300 do
|
||||
local top = game.stack:top()
|
||||
if isBox(top) and top.done then return top end
|
||||
U.wait(1)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function encounter(tag, mapId, x, y)
|
||||
U.teleport(game, mapId, x, y, "up")
|
||||
U.wait(30)
|
||||
local ow = game.stack:top()
|
||||
local mapSong = Music.current()
|
||||
|
||||
U.tap(game, "a")
|
||||
local box = waitTypedBox()
|
||||
if not check(tag .. " talking opens the battle text", box ~= nil) then
|
||||
return
|
||||
end
|
||||
|
||||
local stingAt, battleEarly
|
||||
for f = 1, 240 do
|
||||
local top = game.stack:top()
|
||||
if top ~= box then battleEarly = true break end
|
||||
if not stingAt and Music.current() == "Music_MeetMaleTrainer" then
|
||||
stingAt = f
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
check(tag .. " box holds 240 frames with no button", not battleEarly)
|
||||
check(tag .. " sting replaced " .. tostring(mapSong) .. " while the box waits",
|
||||
stingAt ~= nil)
|
||||
if battleEarly then return end
|
||||
|
||||
U.shot(game, ("%s/2275_%s_01_box_held_sting_playing.png"):format(DIR, tag))
|
||||
|
||||
U.tap(game, "a")
|
||||
local battle
|
||||
for _ = 1, 300 do
|
||||
local top = game.stack:top()
|
||||
if top ~= box and top ~= ow and not isBox(top) then battle = top break end
|
||||
U.wait(1)
|
||||
end
|
||||
check(tag .. " battle starts after A", battle ~= nil)
|
||||
local settled = 0
|
||||
for _ = 1, 90 do
|
||||
settled = settled + 1
|
||||
U.wait(1)
|
||||
end
|
||||
if battle then
|
||||
U.shot(game, ("%s/2275_%s_02_battle_after_a_%df.png"):format(DIR, tag, settled))
|
||||
end
|
||||
end
|
||||
|
||||
U.newGame(game)
|
||||
if not check("new game reached the overworld", game.overworld ~= nil) then finish() end
|
||||
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 60) }
|
||||
|
||||
encounter("voltorb", "POWER_PLANT", 9, 21)
|
||||
encounter("articuno", "SEAFOAM_ISLANDS_B4F", 6, 2)
|
||||
encounter("zapdos", "POWER_PLANT", 4, 10)
|
||||
finish()
|
||||
end
|
||||
@@ -0,0 +1,129 @@
|
||||
-- POKEPORT_IDENTITY=red-sep04 POKEPORT_SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/surf_strength_submenu_2267_2273.lua love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
local Music = require("src.core.Music")
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local fails = 0
|
||||
local function check(ok, label)
|
||||
print((ok and "PASS " or "FAIL ") .. label)
|
||||
if not ok then fails = fails + 1 end
|
||||
return ok
|
||||
end
|
||||
|
||||
local function giveMon(species, move)
|
||||
local mon = Pokemon.new(game.data, species, 30)
|
||||
mon.moves = { { id = move, pp = 15 } }
|
||||
game.save.party = { mon }
|
||||
return mon
|
||||
end
|
||||
|
||||
local function openFieldMove(action)
|
||||
local pm = PartyMenu.new(game)
|
||||
game.stack:push(pm)
|
||||
U.wait(4)
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
for i, item in ipairs(pm.subItems or {}) do
|
||||
if item.action == action then pm.subIndex = i end
|
||||
end
|
||||
U.shot(game, DIR .. "/2273_00_" .. action .. "_submenu_open.png")
|
||||
U.tap(game, "a")
|
||||
return pm
|
||||
end
|
||||
|
||||
local function drainText()
|
||||
for _ = 1, 400 do
|
||||
local top = game.stack:top()
|
||||
if top == game.overworld then return end
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
end
|
||||
end
|
||||
|
||||
U.newGame(game)
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.textSpeed = 5
|
||||
game.save.inventory.SOULBADGE = true
|
||||
game.save.inventory.RAINBOWBADGE = true
|
||||
game.save.inventory.BOULDERBADGE = true
|
||||
|
||||
giveMon("MACHOP", "STRENGTH")
|
||||
U.teleport(game, "SEAFOAM_ISLANDS_1F", 17, 10, "right")
|
||||
local pmStr = openFieldMove("strength")
|
||||
U.wait(6)
|
||||
check(game.stack:top() ~= pmStr and not pmStr.submenu, "2273_strength_submenu_erased")
|
||||
U.shot(game, DIR .. "/2273_01_strength_text_no_options_box.png")
|
||||
drainText()
|
||||
U.wait(40)
|
||||
|
||||
giveMon("PIKACHU", "FLASH")
|
||||
game.save.flashLit = false
|
||||
U.teleport(game, "ROCK_TUNNEL_1F", 15, 4, "down")
|
||||
local pmFlash = openFieldMove("flash")
|
||||
U.wait(6)
|
||||
check(game.stack:top() ~= pmFlash and not pmFlash.submenu, "2273_flash_submenu_erased")
|
||||
U.shot(game, DIR .. "/2273_02_flash_text_no_options_box.png")
|
||||
drainText()
|
||||
U.wait(40)
|
||||
|
||||
giveMon("SQUIRTLE", "SURF")
|
||||
U.teleport(game, "PALLET_TOWN", 4, 13, "down")
|
||||
game.overworld.player.surfing = false
|
||||
local pmSurf = openFieldMove("surf")
|
||||
U.wait(6)
|
||||
check(game.stack:top() ~= pmSurf and not pmSurf.submenu, "2267_surf_submenu_erased")
|
||||
check(Music.current() == Music.special(game.data, "surf"), "2267_surf_music_with_got_on_text")
|
||||
U.shot(game, DIR .. "/2267_03_got_on_text_surf_music_no_options_box.png")
|
||||
drainText()
|
||||
U.wait(60)
|
||||
|
||||
local function parkSurfing()
|
||||
U.teleport(game, "PALLET_TOWN", 4, 14, "up")
|
||||
local ow = game.overworld
|
||||
ow.player.surfing = true
|
||||
ow:syncSurfingPikachu()
|
||||
Music.playMap(game.data, ow.map.id, false, true)
|
||||
U.wait(30)
|
||||
return ow
|
||||
end
|
||||
|
||||
local ow = parkSurfing()
|
||||
local startFrame, landFrame, swapFrame
|
||||
for _ = 1, 60 do
|
||||
table.insert(game.input.pressQueue, "up")
|
||||
game.input.state.up = true
|
||||
U.wait(1)
|
||||
local p = ow.player
|
||||
if not startFrame and p.moving then startFrame = U.frame() end
|
||||
if startFrame and not swapFrame and not p.surfing then swapFrame = U.frame() end
|
||||
if startFrame and not landFrame and p.cellY == 13 then landFrame = U.frame(); break end
|
||||
end
|
||||
game.input.state.up = false
|
||||
check(startFrame ~= nil and landFrame ~= nil, "2267_walk_off_step_ran")
|
||||
check(swapFrame ~= nil and startFrame ~= nil and swapFrame <= startFrame
|
||||
and (landFrame == nil or swapFrame < landFrame), "2267_walk_off_swap_before_landing")
|
||||
U.wait(30)
|
||||
|
||||
ow = parkSurfing()
|
||||
local shotTaken = false
|
||||
for _ = 1, 60 do
|
||||
table.insert(game.input.pressQueue, "up")
|
||||
game.input.state.up = true
|
||||
U.wait(1)
|
||||
if ow.player.moving and not shotTaken then
|
||||
game.input.state.up = false
|
||||
check(not ow.player.surfing and ow.player.cellY == 14, "2267_walk_off_mid_step_walking")
|
||||
U.shot(game, DIR .. "/2267_04_walk_off_mid_step_walking_sprite.png")
|
||||
shotTaken = true
|
||||
break
|
||||
end
|
||||
end
|
||||
game.input.state.up = false
|
||||
check(shotTaken, "2267_walk_off_mid_step_shot")
|
||||
|
||||
print(fails == 0 and "PASS surf_strength_submenu_2267_2273" or "FAIL surf_strength_submenu_2267_2273")
|
||||
love.event.quit(fails == 0 and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,98 @@
|
||||
-- engine/battle/core.asm:293
|
||||
-- POKEPORT_IDENTITY=red-sep04 POKEPORT_SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/thrash_menu_flash_bug2252.lua love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
local fails = 0
|
||||
local function ok(cond, line)
|
||||
print((cond and "PASS " or "FAIL ") .. line)
|
||||
if not cond then fails = fails + 1 end
|
||||
end
|
||||
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
local ow = game.overworld
|
||||
|
||||
local function startBattle()
|
||||
love.math.setRandomSeed(2252)
|
||||
local king = Pokemon.new(game.data, "NIDOKING", 60)
|
||||
king.moves = { { id = "THRASH", pp = 20 } }
|
||||
king.stats.hp, king.hp = 999, 999
|
||||
game.save.party = { king }
|
||||
local battle = BattleState.newWild(game, "MAGIKARP", 60)
|
||||
battle.onFinish = function() end
|
||||
battle.enemy.mon.stats.hp, battle.enemy.mon.hp = 999, 999
|
||||
ow:pushBattle(battle)
|
||||
for _ = 1, 200 do
|
||||
if battle.phase == "menu" then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
return battle
|
||||
end
|
||||
|
||||
local function selectThrash(battle)
|
||||
U.tap(game, "a"); U.wait(12)
|
||||
U.tap(game, "a")
|
||||
end
|
||||
|
||||
local function locked(battle)
|
||||
return (battle.player.thrashTurns or 0) > 0
|
||||
end
|
||||
|
||||
local battle = startBattle()
|
||||
ok(battle.phase == "menu", "2252 pass 1 reached the command menu")
|
||||
local startTurn = battle.turnCount or 0
|
||||
selectThrash(battle)
|
||||
local f, drainFrame, flashes, sawLocked = 0, nil, 0, false
|
||||
local lastSig, logged = nil, 0
|
||||
for _ = 1, 2400 do
|
||||
f = f + 1
|
||||
if f % 20 == 0 then U.tap(game, "a") else U.wait(1) end
|
||||
local sig = tostring(battle.phase) .. " thrash=" .. tostring(battle.player.thrashTurns)
|
||||
.. " turn=" .. tostring(battle.turnCount) .. " top=" .. tostring(game.stack:top() == battle)
|
||||
if sig ~= lastSig and logged < 80 then
|
||||
U.log("f", f, sig)
|
||||
lastSig, logged = sig, logged + 1
|
||||
end
|
||||
if locked(battle) then sawLocked = true end
|
||||
if not drainFrame and (battle.turnCount or 0) >= startTurn + 2 then drainFrame = f end
|
||||
if battle.phase == "menu" and locked(battle) then flashes = flashes + 1 end
|
||||
if battle.phase == "menu" and not locked(battle) and f > 30 then break end
|
||||
end
|
||||
U.log("turns", (battle.turnCount or 0) - startTurn, "drainFrame", drainFrame,
|
||||
"status", battle.player.mon.status, "confused", battle.player.confusedTurns)
|
||||
ok(sawLocked, "2252 player locked into thrash")
|
||||
ok(drainFrame ~= nil, "2252 locked turn resolved without input")
|
||||
ok(flashes == 0, "2252 no command menu frame while locked (" .. flashes .. ")")
|
||||
if game.stack:top() == battle then game.stack:pop() end
|
||||
|
||||
if drainFrame then
|
||||
battle = startBattle()
|
||||
selectThrash(battle)
|
||||
local g, shotBefore, shotDrain, shotAfter = 0, false, false, false
|
||||
local phaseAtDrain
|
||||
while g < drainFrame + 60 do
|
||||
if not shotBefore and g >= drainFrame - 12 then
|
||||
shotBefore = U.shot(game, DIR .. "/2252_01_thrash_text_before_drain.png")
|
||||
g = g + 3
|
||||
elseif not shotDrain and g >= drainFrame then
|
||||
phaseAtDrain = battle.phase
|
||||
shotDrain = U.shot(game, DIR .. "/2252_02_drain_frame_no_menu.png")
|
||||
g = g + 3
|
||||
elseif not shotAfter and g >= drainFrame + 40 then
|
||||
shotAfter = U.shot(game, DIR .. "/2252_03_thrashing_about_text.png")
|
||||
g = g + 3
|
||||
else
|
||||
g = g + 1
|
||||
if g % 20 == 0 then U.tap(game, "a") else U.wait(1) end
|
||||
end
|
||||
end
|
||||
ok(phaseAtDrain ~= "menu", "2252 pass 2 drain frame is not the command menu")
|
||||
ok(shotBefore and shotDrain and shotAfter, "2252 shots written")
|
||||
end
|
||||
|
||||
love.event.quit(fails == 0 and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,90 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
|
||||
local function newGame(moveId, ow)
|
||||
local mon = { species = "MACHOP", hp = 50, stats = { hp = 50 },
|
||||
level = 30, moves = { { id = moveId, pp = 15 } } }
|
||||
ow.map = ow.map or { def = { tileset = "OVERWORLD" }, id = "PALLET_TOWN" }
|
||||
ow.dark = ow.dark or false
|
||||
ow.partyKnows = function() return mon end
|
||||
local game = {
|
||||
data = { pokemon = { MACHOP = { name = "MACHOP" } } },
|
||||
save = { party = { mon }, inventory = {}, options = {}, flags = {} },
|
||||
overworld = ow,
|
||||
}
|
||||
game.stack = {
|
||||
states = {},
|
||||
push = function(self, s) table.insert(self.states, s) end,
|
||||
pop = function(self) return table.remove(self.states) end,
|
||||
top = function(self) return self.states[#self.states] end,
|
||||
}
|
||||
game.input = {
|
||||
queue = {},
|
||||
wasPressed = function(self, btn) return self.queue[btn] or false end,
|
||||
isDown = function() return false end,
|
||||
}
|
||||
return game
|
||||
end
|
||||
|
||||
local function press(pm, btn)
|
||||
pm.game.input.queue = { [btn] = true }
|
||||
pm:update(1 / 60)
|
||||
pm.game.input.queue = {}
|
||||
end
|
||||
|
||||
local function choose(game, action)
|
||||
local pm = PartyMenu.new(game, {})
|
||||
game.stack:push(pm)
|
||||
press(pm, "a")
|
||||
check(pm.submenu, action .. ": A on the mon opens the submenu")
|
||||
local row
|
||||
for i, item in ipairs(pm.subItems or {}) do
|
||||
if item.action == action then row = i end
|
||||
end
|
||||
check(row ~= nil, action .. " is listed in the submenu")
|
||||
pm.subIndex = row or 1
|
||||
press(pm, "a")
|
||||
return pm
|
||||
end
|
||||
|
||||
local function expectErased(label, game, pm, captured)
|
||||
check(captured.onClose ~= nil, label .. " reaches the overworld handler")
|
||||
check(not pm.submenu, label .. ": the options box is gone under the text")
|
||||
eq(game.stack:top(), pm, label .. ": the party list stays as the text backdrop")
|
||||
if captured.onClose then captured.onClose() end
|
||||
eq(#game.stack.states, 0, label .. ": the menu closes when the text ends")
|
||||
end
|
||||
|
||||
do
|
||||
local captured = {}
|
||||
local game = newGame("STRENGTH", {
|
||||
useStrengthFieldMove = function(_, _, onClose) captured.onClose = onClose end,
|
||||
})
|
||||
expectErased("STRENGTH", game, choose(game, "strength"), captured)
|
||||
end
|
||||
|
||||
do
|
||||
local captured = {}
|
||||
local game = newGame("SURF", {
|
||||
player = { facingCell = function() return 4, 14 end },
|
||||
useSurfFieldMove = function() return "ok" end,
|
||||
trySurf = function(_, _, _, onClose) captured.onClose = onClose end,
|
||||
})
|
||||
expectErased("SURF", game, choose(game, "surf"), captured)
|
||||
end
|
||||
|
||||
do
|
||||
local captured = {}
|
||||
local game = newGame("FLASH", {
|
||||
dark = true,
|
||||
useFlashFieldMove = function(_, onClose) captured.onClose = onClose end,
|
||||
})
|
||||
expectErased("FLASH", game, choose(game, "flash"), captured)
|
||||
end
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,199 @@
|
||||
-- engine/battle/core.asm:869, engine/battle/core.asm:912, engine/battle/core.asm:1005
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local Battle = require("src.battle.gen2.Battle")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
local TYPES = {
|
||||
NORMAL = { id = "NORMAL", index = 0, category = "physical" },
|
||||
}
|
||||
|
||||
local MOVES = {
|
||||
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
|
||||
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
|
||||
}
|
||||
|
||||
local POKEMON = {
|
||||
growthRates = {
|
||||
GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0,
|
||||
linear = 0, constant = 0 },
|
||||
},
|
||||
MACHOP = { id = "MACHOP", index = 66, name = "MACHOP",
|
||||
baseStats = { hp = 70, attack = 80, defense = 50, speed = 35,
|
||||
specialAttack = 35, specialDefense = 35 },
|
||||
types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75,
|
||||
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63,
|
||||
levelMoves = {}, evolutions = {} },
|
||||
}
|
||||
|
||||
local DATA = { pokemon = POKEMON, moves = MOVES,
|
||||
type_chart = { types = TYPES, matchups = {} }, items = {} }
|
||||
|
||||
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
|
||||
perfect.hp = Mon.hpDV(perfect)
|
||||
|
||||
local function rng() return 0 end
|
||||
|
||||
local function mon(level)
|
||||
local m = Mon.new(DATA, "MACHOP", level or 50, { dvs = perfect })
|
||||
m.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
return m
|
||||
end
|
||||
|
||||
local TACKLE = { kind = "move", move = "TACKLE" }
|
||||
|
||||
local function indexOf(events, pred, from)
|
||||
for i = from or 1, #events do
|
||||
if pred(events[i]) then return i end
|
||||
end
|
||||
end
|
||||
|
||||
local function moveBy(side)
|
||||
return function(e) return e.kind == "move" and e.side == side end
|
||||
end
|
||||
|
||||
local function tickOf(side, anim)
|
||||
return function(e)
|
||||
return e.kind == "damage" and e.side == side and e.anim == anim
|
||||
end
|
||||
end
|
||||
|
||||
local function count(events, pred)
|
||||
local n = 0
|
||||
for _, e in ipairs(events) do if pred(e) then n = n + 1 end end
|
||||
return n
|
||||
end
|
||||
|
||||
local function hasText(events, sub)
|
||||
for _, e in ipairs(events) do
|
||||
if e.text and e.text:find(sub, 1, true) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
do
|
||||
local lead, bench = mon(), mon()
|
||||
local player = mon()
|
||||
local battle = Battle.new({ data = DATA, party = { player },
|
||||
trainer = { class = "YOUNGSTER", name = "JOEY", party = { lead, bench } },
|
||||
random = rng })
|
||||
player.status = "poison"
|
||||
player.stats.speed, lead.stats.speed = 200, 1
|
||||
lead.hp = 1
|
||||
local hpBefore = player.hp
|
||||
local events = battle:takeTurn(TACKLE)
|
||||
T.eq(battle.enemy, bench, "a: the trainer's second mon is sent out")
|
||||
T.check(not hasText(events, "hurt by poison"),
|
||||
"a: an OHKO jumps past the attacker's own poison tick")
|
||||
T.eq(player.hp, hpBefore, "a: the poisoned player loses no HP that round")
|
||||
end
|
||||
|
||||
do
|
||||
local p1, p2 = mon(), mon()
|
||||
local lead = mon()
|
||||
local battle = Battle.new({ data = DATA, party = { p1, p2 },
|
||||
trainer = { class = "YOUNGSTER", name = "JOEY", party = { lead, mon() } },
|
||||
random = rng })
|
||||
lead.status = "poison"
|
||||
lead.stats.speed, p1.stats.speed = 200, 1
|
||||
p1.hp = 1
|
||||
local hpBefore = lead.hp
|
||||
local events = battle:takeTurn(TACKLE)
|
||||
T.check((p1.hp or 0) <= 0, "b: the player's lead fainted")
|
||||
T.check(not hasText(events, "hurt by poison"),
|
||||
"b: the enemy that KOd skips its poison tick")
|
||||
T.eq(lead.hp, hpBefore, "b: the poisoned enemy loses no HP that round")
|
||||
end
|
||||
|
||||
do
|
||||
local player, wild = mon(), mon()
|
||||
local battle = Battle.new({ data = DATA, party = { player }, wild = wild,
|
||||
random = rng })
|
||||
player.status, wild.status = "poison", "poison"
|
||||
player.stats.speed, wild.stats.speed = 1, 200
|
||||
local events = battle:takeTurn(TACKLE)
|
||||
local em = indexOf(events, moveBy("enemy"))
|
||||
local et = indexOf(events, tickOf("enemy", "ANIM_PSN"))
|
||||
local pm = indexOf(events, moveBy("player"))
|
||||
local pt = indexOf(events, tickOf("player", "ANIM_PSN"))
|
||||
T.check(em and et and pm and pt, "c: both moves and both poison ticks happen")
|
||||
T.check(em and et and pm and pt and em < et and et < pm and pm < pt,
|
||||
"c: enemy move, enemy poison, player move, player poison")
|
||||
end
|
||||
|
||||
do
|
||||
local player, wild = mon(), mon()
|
||||
local battle = Battle.new({ data = DATA, party = { player }, wild = wild,
|
||||
random = rng })
|
||||
player.status = "poison"
|
||||
player.stats.speed, wild.stats.speed = 200, 1
|
||||
player.hp = 1
|
||||
local events = battle:takeTurn(TACKLE)
|
||||
T.eq(player.hp, 0, "d: the player faints to its own poison")
|
||||
T.eq(indexOf(events, moveBy("enemy")), nil,
|
||||
"d: the slower enemy never gets its move")
|
||||
end
|
||||
|
||||
do
|
||||
local player, wild = mon(), mon()
|
||||
local battle = Battle.new({ data = DATA, party = { player }, wild = wild,
|
||||
random = rng })
|
||||
player.stats.speed, wild.stats.speed = 200, 1
|
||||
battle:volatile(player).leechSeed = true
|
||||
local events = battle:takeTurn(TACKLE)
|
||||
local pm = indexOf(events, moveBy("player"))
|
||||
local sap = indexOf(events, tickOf("player", "ANIM_SAP"))
|
||||
local em = indexOf(events, moveBy("enemy"))
|
||||
T.check(pm and sap and em and pm < sap and sap < em,
|
||||
"e: Leech Seed drains between the faster mon's move and the slower one's")
|
||||
end
|
||||
|
||||
do
|
||||
local p1, p2 = mon(), mon()
|
||||
local wild = mon()
|
||||
local battle = Battle.new({ data = DATA, party = { p1, p2 }, wild = wild,
|
||||
random = rng })
|
||||
p2.status = "poison"
|
||||
local events = battle:takeTurn({ kind = "switch", index = 2 })
|
||||
local pt = indexOf(events, tickOf("player", "ANIM_PSN"))
|
||||
local em = indexOf(events, moveBy("enemy"))
|
||||
T.eq(count(events, tickOf("player", "ANIM_PSN")), 1,
|
||||
"f: the switched-in mon's poison ticks exactly once")
|
||||
T.check(pt and em and pt < em,
|
||||
"f: after a switch the player's tick lands before the enemy's move")
|
||||
end
|
||||
|
||||
do
|
||||
local player, wild = mon(), mon()
|
||||
local battle = Battle.new({ data = DATA, party = { player }, wild = wild,
|
||||
random = rng })
|
||||
player.status = "poison"
|
||||
local events = battle:takeTurn({ kind = "item", item = "POTION" })
|
||||
local pt = indexOf(events, tickOf("player", "ANIM_PSN"))
|
||||
local em = indexOf(events, moveBy("enemy"))
|
||||
T.eq(count(events, tickOf("player", "ANIM_PSN")), 1,
|
||||
"f: an item turn ticks the player's poison exactly once")
|
||||
T.check(pt and em and pt < em,
|
||||
"f: after an item the player's tick lands before the enemy's move")
|
||||
end
|
||||
|
||||
do
|
||||
local player, wild = mon(), mon()
|
||||
local battle = Battle.new({ data = DATA, party = { player }, wild = wild,
|
||||
random = rng })
|
||||
player.status, wild.status = "poison", "poison"
|
||||
player.stats.speed, wild.stats.speed = 200, 1
|
||||
battle.weather, battle.weatherTurns = "sandstorm", 5
|
||||
local events = battle:takeTurn(TACKLE)
|
||||
local pt = indexOf(events, tickOf("player", "ANIM_PSN"))
|
||||
local et = indexOf(events, tickOf("enemy", "ANIM_PSN"))
|
||||
local sand = indexOf(events, function(e)
|
||||
return e.kind == "damage" and e.anim == "ANIM_IN_SANDSTORM"
|
||||
end)
|
||||
T.check(pt and et and sand and pt < sand and et < sand,
|
||||
"g: HandleBetweenTurnEffects' sandstorm lands after both poison ticks")
|
||||
end
|
||||
|
||||
T.finish("gen2 residual timing bug 2257")
|
||||
@@ -102,6 +102,10 @@ local function build(version, bare, female)
|
||||
put(b, L.wVisitedSpawns + 1, 0x40)
|
||||
put(b, L.wVisitedSpawns + 2, 0x05)
|
||||
put(b, L.wVariableSprites + WEIRD_TREE_SLOT, SPRITE_TWIN)
|
||||
put(b, L.sceneVars.ELMS_LAB, 2)
|
||||
put(b, L.sceneVars.NEW_BARK_TOWN, 1)
|
||||
put(b, L.sceneVars.ROUTE_27, 1)
|
||||
put(b, L.sceneVars.VICTORY_ROAD, 1)
|
||||
end
|
||||
if L.wPlayerGender then put(b, L.wPlayerGender, female and 1 or 0) end
|
||||
put(b, L.wNumItems, 1); put(b, L.wItems, 20, 3); put(b, L.wItems + 2, 0xFF)
|
||||
@@ -189,6 +193,14 @@ for _, version in ipairs({ "gold", "silver", "crystal" }) do
|
||||
eq(save.variableSprites[0], nil,
|
||||
version .. ": a slot the cart never filled stays absent for World's seed")
|
||||
|
||||
-- data/maps/scenes.asm:7
|
||||
eq(save.mapScenes.ELMS_LAB, 2, version .. ": Elm's lab is past the starter")
|
||||
eq(save.mapScenes.NEW_BARK_TOWN, 1, version .. ": the teacher no longer stops you")
|
||||
eq(save.mapScenes.ROUTE_27, 1, version .. ": the Route 27 fisher stays put")
|
||||
eq(save.mapScenes.VICTORY_ROAD, 1, version .. ": the Victory Road rival is done")
|
||||
eq(save.mapScenes.POKECENTER_2F, nil, version .. ": a zero scene byte is no entry")
|
||||
eq(next(bare.mapScenes), nil, version .. ": a fresh cart has every map on scene 0")
|
||||
|
||||
-- constants/ram_constants.asm:177 (#1907)
|
||||
eq(save.player.gender, version == "crystal" and "male" or nil,
|
||||
version .. ": Gold and Silver have no gender byte to read")
|
||||
@@ -301,6 +313,9 @@ do
|
||||
save.engineFlags[66] = nil
|
||||
save.engineFlags[69] = true
|
||||
save.variableSprites[6] = 0x35
|
||||
save.mapScenes.ELMS_LAB = 6
|
||||
save.mapScenes.NEW_BARK_TOWN = 0
|
||||
save.mapScenes.MAHOGANY_TOWN = 1
|
||||
|
||||
local out = assert(Gen2Save.encode(save, "gold", cart, data))
|
||||
eq(#out, #cart, "the image keeps its size")
|
||||
@@ -340,6 +355,23 @@ do
|
||||
eq(back.variableSprites[WEIRD_TREE_SLOT], SPRITE_TWIN,
|
||||
"the twins' slot survives the round trip")
|
||||
eq(back.variableSprites[6], 0x35, "and a slot filled inside the port is written")
|
||||
|
||||
-- data/maps/scenes.asm:7
|
||||
eq(back.mapScenes.ELMS_LAB, 6, "a scene advanced in the port is written")
|
||||
eq(back.mapScenes.NEW_BARK_TOWN, nil, "a cleared scene clears the cart byte")
|
||||
eq(back.mapScenes.MAHOGANY_TOWN, 1, "and a newly set one reaches the cart")
|
||||
eq(back.mapScenes.ROUTE_27, 1, "with the untouched ones left as they were")
|
||||
|
||||
local again = assert(Gen2Save.encode(back, "gold", out, data))
|
||||
local L = Gen2Save.layoutFor("gold")
|
||||
local startAt, stopAt = L.sceneVars.POKECENTER_2F, L.sceneVars.MOUNT_MOON_SQUARE
|
||||
eq(again:sub(startAt + 1, stopAt + 1), out:sub(startAt + 1, stopAt + 1),
|
||||
"import, export, import gives the same scene bytes")
|
||||
|
||||
back.mapScenes = {}
|
||||
local untouched = assert(Gen2Save.encode(back, "gold", out, data))
|
||||
eq(untouched:byte(L.sceneVars.ELMS_LAB + 1), 6,
|
||||
"a save with no scene entries leaves the cart's scene bytes alone")
|
||||
end
|
||||
|
||||
-- ram/sram.asm:138-144
|
||||
@@ -364,6 +396,26 @@ do
|
||||
"and reads back as Chris")
|
||||
end
|
||||
|
||||
-- data/maps/scenes.asm:7
|
||||
do
|
||||
local function shape(L, want, label)
|
||||
local n = 0
|
||||
for mapId, at in pairs(L.sceneVars) do
|
||||
n = n + 1
|
||||
check(at >= L.sGameData and at < L.sGameDataEnd,
|
||||
("%s: %s scene byte 0x%04X is inside the checksummed block"):format(label, mapId, at))
|
||||
end
|
||||
eq(n, want, label .. ": one scene byte per scene_var row")
|
||||
end
|
||||
shape(Gen2Layout.goldSilver, 59, "gold/silver")
|
||||
shape(Gen2Layout.crystal, 79, "crystal")
|
||||
shape(Gen2Layout.crystal.backup, 79, "crystal backup")
|
||||
eq(Gen2Layout.goldSilver.sceneVars.ELMS_LAB, 0x2534, "gold wElmsLabSceneID")
|
||||
eq(Gen2Layout.crystal.sceneVars.ELMS_LAB, 0x2515, "crystal wElmsLabSceneID")
|
||||
eq(Gen2Layout.crystal.backup.sceneVars.ELMS_LAB,
|
||||
Gen2Layout.crystal.sceneVars.ELMS_LAB - 0xE00, "the backup copy is the same byte shifted")
|
||||
end
|
||||
|
||||
-- engine/pokemon/stats_screen.asm (#1899)
|
||||
|
||||
do
|
||||
@@ -438,6 +490,7 @@ do
|
||||
check(save ~= nil, "so the save still opens -- " .. tostring(err))
|
||||
if save then
|
||||
eq(save.player.name, "ASH", "and reads the same player out of the backup")
|
||||
eq(save.mapScenes.ELMS_LAB, 2, "and the same map scenes out of the backup")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -469,6 +522,7 @@ do
|
||||
check(type(save.mail) == "table", "mail falls back to the new-game default")
|
||||
check(type(save.hallOfFame) == "table", "so does the hall of fame")
|
||||
check(type(save.phoneContacts) == "table", "and the phone book")
|
||||
eq(save.mapScenes.ELMS_LAB, 2, "and the cart's map scenes survive the merge")
|
||||
end
|
||||
-- Export needs the cartridge image the save came from. Without one it is
|
||||
-- refused rather than built from nothing.
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local SpriteRenderer = require("src.render.SpriteRenderer")
|
||||
local IntroMovie = require(os.getenv("INTRO_MOVIE_MODULE") or "src.ui.IntroMovie")
|
||||
|
||||
local prevVersion, prevMode = GameVersion.get(), PaletteFX.mode
|
||||
|
||||
local function frames(prefix)
|
||||
local t = {}
|
||||
for i = 1, 3 do
|
||||
t["frame" .. i] = { path = "tests/fixtures/missing_" .. prefix .. i .. "_2264.png" }
|
||||
end
|
||||
return t
|
||||
end
|
||||
|
||||
local game = {
|
||||
data = { field = { intro = { nidorino = frames("nido"), gengar = frames("gengar") } } },
|
||||
stack = { pop = function() end },
|
||||
}
|
||||
|
||||
local bakes = {}
|
||||
local realObp = SpriteRenderer.obpImage
|
||||
SpriteRenderer.obpImage = function(path, colors, group)
|
||||
bakes[#bakes + 1] = { path = path, colors = colors, group = group }
|
||||
return realObp(path, colors, group)
|
||||
end
|
||||
|
||||
local function drawFight(mode, version, setup)
|
||||
GameVersion.set(version)
|
||||
PaletteFX.setMode(mode)
|
||||
local movie = IntroMovie.new(game)
|
||||
movie.phase = 3
|
||||
movie.nidoX, movie.nidoY = 72, 72
|
||||
movie.gengarX, movie.gengarY = 24, 56
|
||||
if setup then setup(movie) end
|
||||
local before = #bakes
|
||||
PaletteFX.clearSpriteRedraws()
|
||||
PaletteFX.setPass("ui")
|
||||
local ok, err = pcall(movie.draw, movie)
|
||||
PaletteFX.setPass(nil)
|
||||
check(ok, mode .. "/" .. version .. " draw runs headless" .. (ok and "" or (": " .. tostring(err))))
|
||||
local out, newBakes = {}, {}
|
||||
for i, r in ipairs(PaletteFX.uiSpriteRedraws()) do out[i] = r end
|
||||
for i = before + 1, #bakes do newBakes[#newBakes + 1] = bakes[i] end
|
||||
PaletteFX.clearSpriteRedraws()
|
||||
return out, newBakes, movie
|
||||
end
|
||||
|
||||
local function sameClip(c)
|
||||
return c and c[1] == 0 and c[2] == 32 and c[3] == 160 and c[4] == 80
|
||||
end
|
||||
|
||||
do
|
||||
local r, b, movie = drawFight("ogred", "red")
|
||||
eq(#r, 2, "ogred red: Nidorino and the Gengar restack are replayed")
|
||||
local nido, gengar = r[1] or {}, r[2] or {}
|
||||
eq(b[1] and b[1].path, movie.nidoPaths[1], "Nidorino bakes its current frame")
|
||||
eq(b[1] and b[1].colors, PaletteFX.GBC_OBJ, "red Nidorino bakes the boot-ROM OBJ ramp")
|
||||
eq(b[1] and b[1].group, "gbcobj", "red Nidorino bakes under the red OBJ group")
|
||||
eq(nido.image, SpriteRenderer.obpImage(movie.nidoPaths[1], PaletteFX.GBC_OBJ, "gbcobj"),
|
||||
"the replayed Nidorino image is the OBJ bake")
|
||||
eq(nido.x, 72, "Nidorino replays at nidoX")
|
||||
eq(nido.y, 72, "Nidorino replays at nidoY")
|
||||
check(sameClip(nido.clip), "Nidorino replay is clipped to the playfield under the bars")
|
||||
eq(b[2] and b[2].path, movie.gengarPaths[1], "Gengar restack bakes its current pose")
|
||||
eq(b[2] and b[2].colors, PaletteFX.GBC_BG, "red Gengar restack bakes the boot-ROM BG ramp")
|
||||
eq(gengar.x, 24, "Gengar replays at gengarX")
|
||||
eq(gengar.y, 56, "Gengar replays at gengarY")
|
||||
check(sameClip(gengar.clip), "Gengar restack is clipped to the playfield")
|
||||
end
|
||||
|
||||
do
|
||||
local r, b = drawFight("ogred", "blue")
|
||||
eq(#r, 2, "ogred blue: Jigglypuff and the Gengar restack are replayed")
|
||||
eq(b[1] and b[1].colors, PaletteFX.GBC_OBJ_BLUE, "blue Jigglypuff bakes Blue's pink OBJ ramp")
|
||||
eq(b[1] and b[1].group, "gbcobj_blue", "blue bake uses its own cache group")
|
||||
eq(b[2] and b[2].colors, PaletteFX.GBC_BG_BLUE, "blue Gengar restack bakes Blue's BG ramp")
|
||||
check(b[2] and b[2].group ~= "ogbg", "blue Gengar bake never shares the red cache group")
|
||||
end
|
||||
|
||||
do
|
||||
local r, b, movie = drawFight("ogred", "red", function(m)
|
||||
m.nidoFrame, m.gengarPose = 3, 2
|
||||
end)
|
||||
eq(b[1] and b[1].path, movie.nidoPaths[3], "lunge frame bakes frame 3")
|
||||
eq(b[2] and b[2].path, movie.gengarPaths[2], "raised pose bakes pose 2")
|
||||
eq(#r, 2, "no fade entry while fade is 0")
|
||||
end
|
||||
|
||||
do
|
||||
local r = drawFight("ogred", "red", function(m) m.fade = 0.5 end)
|
||||
eq(#r, 3, "GBFadeOutToWhite adds a white wash over the replayed layer")
|
||||
local w = r[3] or {}
|
||||
eq(w.color and w.color[4], 0.5, "the wash carries the fade alpha")
|
||||
eq(w.sx, 160, "the wash spans the playfield width")
|
||||
eq(w.sy, 80, "the wash spans the playfield height")
|
||||
eq(w.x, 0, "the wash starts at the playfield left")
|
||||
eq(w.y, 32, "the wash starts under the top bar")
|
||||
check(sameClip(w.clip), "the wash is clipped to the playfield")
|
||||
end
|
||||
|
||||
do
|
||||
PaletteFX.setDarkWorld(true)
|
||||
PaletteFX.setFadeObp({ [0] = 3, 3, 3, 3 })
|
||||
local _, b = drawFight("ogred", "red")
|
||||
PaletteFX.setDarkWorld(false)
|
||||
PaletteFX.setFadeObp(nil)
|
||||
eq(b[1] and b[1].colors, PaletteFX.GBC_OBJ, "stale dark / fade OBP state never reaches the identity rOBP0 bake")
|
||||
end
|
||||
|
||||
do
|
||||
local r, b = drawFight("gbc", "red")
|
||||
eq(#r, 0, "SGB keeps the PURPLEMON zone Nidorino with no OBJ replay")
|
||||
eq(#b, 0, "SGB never bakes the boot-ROM ramps")
|
||||
r = drawFight("gbc", "blue")
|
||||
eq(#r, 0, "SGB blue keeps the zone Jigglypuff")
|
||||
for _, mode in ipairs({ "redpp", "og", "classic" }) do
|
||||
r = drawFight(mode, "red")
|
||||
eq(#r, 0, mode .. " records no intro OBJ replay")
|
||||
end
|
||||
r = drawFight("ogred", "yellow")
|
||||
eq(#r, 0, "OG YELLOW keeps the CGB zone path")
|
||||
end
|
||||
|
||||
do
|
||||
local r = drawFight("ogred", "red", function(m) m.phase = 2 end)
|
||||
eq(#r, 0, "the splash never records the fight replay")
|
||||
r = drawFight("ogred", "red", function(m) m.phase = 4 end)
|
||||
eq(#r, 0, "the post-fade white hold records nothing")
|
||||
end
|
||||
|
||||
SpriteRenderer.obpImage = realObp
|
||||
GameVersion.set(prevVersion)
|
||||
PaletteFX.setMode(prevMode)
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,111 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.fresh()
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local OW = require("src.world.OverworldController")
|
||||
|
||||
local function setUpvalue(fn, name, val)
|
||||
local i = 1
|
||||
while true do
|
||||
local n = debug.getupvalue(fn, i)
|
||||
if not n then return false end
|
||||
if n == name then debug.setupvalue(fn, i, val); return true end
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
|
||||
local pushed = {}
|
||||
local textBoxStub = {
|
||||
new = function(_, text, onDone, opts)
|
||||
return { text = text, onDone = onDone, opts = opts }
|
||||
end,
|
||||
soundOpts = function() return {} end,
|
||||
}
|
||||
|
||||
local MAP_ID = "FIX_CORNER"
|
||||
Data.field.hiddenCoins = Data.field.hiddenCoins or {}
|
||||
Data.field.slotMachines = Data.field.slotMachines or {}
|
||||
-- data/events/hidden_events.asm:262
|
||||
Data.field.slotMachines[MAP_ID] = {
|
||||
{ x = 12, y = 14, state = "ok" },
|
||||
{ x = 12, y = 15, state = "ok" },
|
||||
}
|
||||
-- data/events/hidden_events.asm:298
|
||||
Data.field.hiddenCoins[MAP_ID] = {
|
||||
{ x = 10, y = 16, coins = 10 },
|
||||
{ x = 12, y = 15, coins = 10 },
|
||||
}
|
||||
|
||||
local function mkGame(opts)
|
||||
local save = SaveData.newGame()
|
||||
save.player.name = "FAKEPLAYER"
|
||||
if opts.coinCase then save.inventory.COIN_CASE = 1 end
|
||||
save.coins = opts.coins or 0
|
||||
if opts.taken then save.hiddenTaken = { [MAP_ID .. "_12_15"] = true } end
|
||||
pushed = {}
|
||||
local game = {
|
||||
data = Data, save = save,
|
||||
stack = { push = function(_, item) pushed[#pushed + 1] = item end },
|
||||
}
|
||||
setUpvalue(OW.tryHiddenObject, "Game", game)
|
||||
return game
|
||||
end
|
||||
|
||||
T.check(setUpvalue(OW.tryHiddenObject, "TextBox", textBoxStub), "TextBox upvalue on tryHiddenObject")
|
||||
|
||||
local fakeSelf = setmetatable({
|
||||
map = { id = MAP_ID }, player = { facing = "up" },
|
||||
}, { __index = OW })
|
||||
|
||||
local function text(i) return (pushed[i] and pushed[i].text) or "" end
|
||||
|
||||
do
|
||||
local game = mkGame({ coinCase = true, coins = 0 })
|
||||
local handled = fakeSelf:tryHiddenObject(12, 15)
|
||||
T.check(handled == true, "shared seat handles A with a COIN CASE and no coins")
|
||||
T.eq(game.save.coins, 0, "shared seat pays no hidden coins")
|
||||
T.check(not (game.save.hiddenTaken or {})[MAP_ID .. "_12_15"], "shared seat sets no hiddenTaken key")
|
||||
T.check(text(1):find("any coins", 1, true) ~= nil, "shared seat prints the no-coins slot text")
|
||||
end
|
||||
|
||||
do
|
||||
local game = mkGame({ coinCase = true, coins = 50 })
|
||||
fakeSelf:tryHiddenObject(12, 15)
|
||||
T.eq(game.save.coins, 50, "shared seat with coins leaves the balance alone")
|
||||
T.check(pushed[1] and pushed[1].opts and type(pushed[1].opts.choice) == "function",
|
||||
"shared seat opens the slot machine prompt")
|
||||
end
|
||||
|
||||
do
|
||||
mkGame({ coins = 0 })
|
||||
local handled = fakeSelf:tryHiddenObject(12, 15)
|
||||
T.check(handled == true, "shared seat without a COIN CASE still answers")
|
||||
T.check(text(1):find("COIN CASE", 1, true) ~= nil, "shared seat asks for a COIN CASE")
|
||||
end
|
||||
|
||||
do
|
||||
mkGame({ coinCase = true, coins = 50, taken = true })
|
||||
local handled = fakeSelf:tryHiddenObject(12, 15)
|
||||
T.check(handled == true, "shared seat still works after the coin flag was set")
|
||||
T.check(pushed[1] and pushed[1].opts and type(pushed[1].opts.choice) == "function",
|
||||
"shared seat with a stale coin flag opens the slot prompt")
|
||||
end
|
||||
|
||||
do
|
||||
local game = mkGame({ coinCase = true, coins = 0 })
|
||||
local handled = fakeSelf:tryHiddenObject(10, 16)
|
||||
T.check(handled == true, "coin-only cell is found")
|
||||
T.check(game.save.coins > 0, "coin-only cell pays coins")
|
||||
T.check(game.save.hiddenTaken[MAP_ID .. "_10_16"] == true, "coin-only cell sets its flag")
|
||||
end
|
||||
|
||||
do
|
||||
mkGame({ coinCase = true, coins = 50 })
|
||||
fakeSelf:tryHiddenObject(12, 14)
|
||||
T.check(pushed[1] and pushed[1].opts and type(pushed[1].opts.choice) == "function",
|
||||
"slot-only seat opens the slot prompt")
|
||||
end
|
||||
|
||||
T.finish("overworld_slot_seat_over_hidden_coin")
|
||||
@@ -0,0 +1,169 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local SpriteRenderer = require("src.render.SpriteRenderer")
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
|
||||
local prevVersion, prevMode = GameVersion.get(), PaletteFX.mode
|
||||
|
||||
local MON_PATH = "tests/fixtures/missing_icon_mon_2268.png"
|
||||
local HELIX_PATH = "tests/fixtures/missing_icon_helix_2268.png"
|
||||
local MOD_PATH = "tests/fixtures/missing_icon_mod_2268.png"
|
||||
|
||||
local game = {
|
||||
data = {
|
||||
icons = {
|
||||
icons = { MON = MON_PATH, HELIX = HELIX_PATH },
|
||||
byDex = { [34] = "MON", [138] = "HELIX" },
|
||||
bySpecies = { MEWTWO = { image = MOD_PATH, trueColor = true } },
|
||||
},
|
||||
pokemon = {
|
||||
NIDOKING = { dex = 34 }, OMANYTE = { dex = 138 }, MEWTWO = { dex = 150 },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
local function mon(species)
|
||||
return { species = species, hp = 10, stats = { hp = 10 } }
|
||||
end
|
||||
|
||||
local bakes = {}
|
||||
local realObp = SpriteRenderer.obpImage
|
||||
SpriteRenderer.obpImage = function(path, colors, group)
|
||||
bakes[#bakes + 1] = { path = path, colors = colors, group = group }
|
||||
return realObp(path, colors, group)
|
||||
end
|
||||
|
||||
local function same(a, b)
|
||||
return a and b and a[1] == b[1] and a[2] == b[2] and a[3] == b[3]
|
||||
end
|
||||
|
||||
local function drawIn(species, mode, version)
|
||||
GameVersion.set(version or "red")
|
||||
PaletteFX.setMode(mode)
|
||||
PaletteFX.clearSpriteRedraws()
|
||||
PaletteFX.clearTrueColor()
|
||||
PaletteFX.setPass("ui")
|
||||
local draws = {}
|
||||
local realDraw = love.graphics.draw
|
||||
love.graphics.draw = function(img) draws[#draws + 1] = img end
|
||||
local ok, err = pcall(PartyMenu.drawIcon, game, mon(species), 8, 8, false, 0)
|
||||
love.graphics.draw = realDraw
|
||||
PaletteFX.setPass(nil)
|
||||
check(ok, species .. " drawIcon runs headless" .. (ok and "" or (": " .. tostring(err))))
|
||||
local out = {}
|
||||
for i, r in ipairs(PaletteFX.uiSpriteRedraws()) do out[i] = r end
|
||||
return out, draws
|
||||
end
|
||||
|
||||
do
|
||||
GameVersion.set("red")
|
||||
local c, g = PaletteFX.ogObjNormal()
|
||||
check(same(c and c[2], PaletteFX.GBC_OBJ[1]), "red: OBJ color 1 -> boot OBJ shade 0")
|
||||
check(same(c and c[3], PaletteFX.GBC_OBJ[2]), "red: OBJ color 2 -> boot OBJ shade 1 (light green)")
|
||||
check(same(c and c[4], PaletteFX.GBC_OBJ[4]), "red: OBJ color 3 -> boot OBJ shade 3")
|
||||
GameVersion.set("blue")
|
||||
local cb, gb = PaletteFX.ogObjNormal()
|
||||
check(same(cb and cb[3], PaletteFX.GBC_OBJ_BLUE[2]), "blue: OBJ color 2 -> pink OBJ shade 1")
|
||||
check(g ~= nil and gb ~= nil and g ~= gb, "red and blue bakes use distinct cache groups")
|
||||
|
||||
GameVersion.set("red")
|
||||
PaletteFX.setDarkWorld(true)
|
||||
PaletteFX.setFadeObp({ [0] = 3, 3, 3, 3 })
|
||||
local cd, gd = PaletteFX.ogObjNormal()
|
||||
PaletteFX.setDarkWorld(false)
|
||||
PaletteFX.setFadeObp(nil)
|
||||
eq(cd, c, "dark / fade OBP state never reaches the GBPalNormal ramp")
|
||||
eq(gd, g, "dark / fade OBP state never changes the cache group")
|
||||
end
|
||||
|
||||
do
|
||||
local redraws, draws = drawIn("NIDOKING", "ogred", "red")
|
||||
eq(#redraws, 2, "ogred red: mirrored icon records both OAM halves")
|
||||
local a, b = redraws[1] or {}, redraws[2] or {}
|
||||
eq(a.x, 8, "left half replays at x")
|
||||
eq(a.sx, 1, "left half is unflipped")
|
||||
eq(b.x, 24, "flipped half replays about the block's right edge")
|
||||
eq(b.sx, -1, "flipped half carries sx = -1")
|
||||
check(a.quad ~= nil and a.quad == b.quad, "both halves replay the same 8x16 quad")
|
||||
check(a.image ~= nil and a.image == draws[1] and a.image == draws[2],
|
||||
"the replayed image is the one drawn into the canvas")
|
||||
local bake = bakes[#bakes] or {}
|
||||
eq(bake.path, MON_PATH, "ogred red bakes the icon art")
|
||||
local c, g = PaletteFX.ogObjNormal()
|
||||
eq(bake.colors, c, "ogred red bakes the GBPalNormal OBJ ramp")
|
||||
eq(bake.group, g, "ogred red bakes under its own cache group")
|
||||
end
|
||||
|
||||
do
|
||||
local before = #bakes
|
||||
local redraws = drawIn("NIDOKING", "ogred", "blue")
|
||||
eq(#redraws, 2, "ogred blue: mirrored icon records both OAM halves")
|
||||
local bake = bakes[before + 1] or {}
|
||||
check(bake.colors and same(bake.colors[3], PaletteFX.GBC_OBJ_BLUE[2]),
|
||||
"ogred blue bakes Blue's pink OBJ ramp")
|
||||
end
|
||||
|
||||
do
|
||||
local redraws = drawIn("OMANYTE", "ogred", "red")
|
||||
eq(#redraws, 1, "ogred: single-frame HELIX records one redraw")
|
||||
local r = redraws[1] or {}
|
||||
eq(r.quad, nil, "HELIX replays the whole image")
|
||||
eq(r.sx, 1, "HELIX is unflipped")
|
||||
end
|
||||
|
||||
do
|
||||
local before = #bakes
|
||||
local redraws = drawIn("NIDOKING", "gbc", "red")
|
||||
eq(#redraws, 0, "SGB keeps the zone-colored icon with no OBJ replay")
|
||||
eq(#bakes, before, "SGB never bakes the boot OBJ ramp")
|
||||
redraws = drawIn("NIDOKING", "redpp", "red")
|
||||
eq(#redraws, 0, "RED++ keeps the zone-colored icon with no OBJ replay")
|
||||
redraws = drawIn("NIDOKING", "ogred", "yellow")
|
||||
eq(#redraws, 0, "OG YELLOW keeps the CGB zone path")
|
||||
end
|
||||
|
||||
do
|
||||
local before = #bakes
|
||||
local redraws = drawIn("MEWTWO", "ogred", "red")
|
||||
eq(#redraws, 0, "trueColor mod art records no OBJ replay under ogred")
|
||||
eq(#bakes, before, "trueColor mod art is never OBP-baked")
|
||||
eq(#PaletteFX.trueColorRects("ui"), 1, "trueColor mod art keeps its unshaded rect")
|
||||
end
|
||||
|
||||
do
|
||||
PaletteFX.clearSpriteRedraws()
|
||||
PaletteFX.clearTrueColor()
|
||||
PaletteFX.setPass("ui")
|
||||
PaletteFX.setMarkOffset(72)
|
||||
local img = {}
|
||||
PaletteFX.markUiSpriteRedraw(img, nil, 4, 5)
|
||||
PaletteFX.markUiSpriteRedraw(img, nil, 0, 32,
|
||||
{ sx = 160, sy = 80, clip = { 0, 32, 160, 80 }, color = { 1, 1, 1, 0.5 } })
|
||||
PaletteFX.setMarkOffset(0)
|
||||
PaletteFX.setPass(nil)
|
||||
local r = PaletteFX.uiSpriteRedraws()
|
||||
local p, q = r[1] or {}, r[2] or {}
|
||||
eq(p.x, 76, "plain mark shifts by the mark offset")
|
||||
eq(p.sx, 1, "plain mark defaults sx to 1")
|
||||
eq(p.sy, 1, "plain mark defaults sy to 1")
|
||||
eq(p.clip, nil, "plain mark has no clip")
|
||||
eq(p.color, nil, "plain mark has no tint")
|
||||
eq(q.sx, 160, "opts.sx is stored")
|
||||
eq(q.sy, 80, "opts.sy is stored")
|
||||
eq(q.clip and q.clip[1], 72, "opts.clip shifts by the mark offset")
|
||||
eq(q.clip and q.clip[4], 80, "opts.clip keeps its size")
|
||||
eq(q.color and q.color[4], 0.5, "opts.color is stored")
|
||||
PaletteFX.clearSpriteRedraws()
|
||||
end
|
||||
|
||||
SpriteRenderer.obpImage = realObp
|
||||
GameVersion.set(prevVersion)
|
||||
PaletteFX.setMode(prevMode)
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,35 @@
|
||||
-- pokered/scripts/SaffronCity.asm:76
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
|
||||
local function findRow(rows, cmd)
|
||||
for i, row in ipairs(rows) do
|
||||
if row[1] == cmd then return i end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local chunk = loadfile("data/scripts/flavor/saffron_city.lua")
|
||||
T.check(chunk ~= nil, "saffron_city flavor file exists")
|
||||
local mod = chunk and chunk() or {}
|
||||
local rows = mod.SAFFRON_CITY and mod.SAFFRON_CITY.talk
|
||||
and mod.SAFFRON_CITY.talk.TEXT_SAFFRONCITY_PIDGEOT or {}
|
||||
local cry = findRow(rows, "play_cry")
|
||||
local text = findRow(rows, "show_text")
|
||||
T.check(cry ~= nil, "saffron_city pidgeot has a play_cry row")
|
||||
T.check(text ~= nil, "saffron_city pidgeot has a show_text row")
|
||||
T.check(cry ~= nil and text ~= nil and cry < text,
|
||||
"saffron_city pidgeot play_cry precedes show_text")
|
||||
T.eq(cry and rows[cry][2], "PIDGEOT", "saffron_city pidgeot plays PIDGEOT cry")
|
||||
T.eq(cry and rows[cry][3], true, "saffron_city pidgeot cry holds for A/B")
|
||||
T.eq(text and rows[text][2], "_SaffronCityPidgeotText",
|
||||
"saffron_city pidgeot shows _SaffronCityPidgeotText")
|
||||
|
||||
local src = assert(io.open("data/scripts/flavor_all.lua", "r"))
|
||||
local index = src:read("*a")
|
||||
src:close()
|
||||
T.check(index:find('"data.scripts.flavor.saffron_city"', 1, true) ~= nil,
|
||||
"flavor_all indexes saffron_city")
|
||||
|
||||
T.finish("saffron_city_pidgeot_cry_bug2262")
|
||||
@@ -0,0 +1,139 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local check, eq = T.check, T.eq
|
||||
|
||||
local Data = T.fixtures.fresh()
|
||||
Data.tilesets.FIX_OUT.tilesPerRow = 16
|
||||
Data.field.flyWarps = Data.field.flyWarps or {}
|
||||
Data.field.playerSprites = { walk = "SPRITE_FIX_PLAYER" }
|
||||
Data.field.waterTilesets = {}
|
||||
Data.field.forcedMovement = { tiles = {
|
||||
FIX_ROUTE = { { x = 4, y = 6, mode = "surf" } },
|
||||
} }
|
||||
Data.field.seafoam = {
|
||||
FIX_ROUTE = {
|
||||
currentsDisabledByEvents = { "EVENT_FIX_CURRENT_PLUGGED" },
|
||||
currents = { { x = 4, y = 6, moves = { { dir = "down", count = 2 } } } },
|
||||
},
|
||||
}
|
||||
|
||||
local Game = require("src.core.Game")
|
||||
local Input = require("src.core.Input")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Music = require("src.core.Music")
|
||||
local OW = require("src.world.OverworldController")
|
||||
|
||||
Game.data = Data
|
||||
Game.input = Input; Input:init()
|
||||
Game.renderer = Renderer; Renderer:init()
|
||||
Game.stack = StateStack; StateStack:init()
|
||||
Game.save = SaveData.newGame()
|
||||
Game.save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
|
||||
local stack = Game.stack
|
||||
|
||||
local ow
|
||||
local surfMusicEarly, surfMusicLate = 0, 0
|
||||
Music.playMap = function(_, _, _, surfing)
|
||||
if not surfing or not ow then return end
|
||||
if ow.transitioning or ow.holeArrive or ow.spinArrive or stack:top() ~= ow then
|
||||
surfMusicEarly = surfMusicEarly + 1
|
||||
else
|
||||
surfMusicLate = surfMusicLate + 1
|
||||
end
|
||||
end
|
||||
|
||||
local function newOW()
|
||||
stack:push(OW, "FIX_TOWN", 5, 6, "down")
|
||||
local o = stack:top()
|
||||
Game.overworld = o
|
||||
return o
|
||||
end
|
||||
|
||||
ow = newOW()
|
||||
ow.player.surfing = true
|
||||
ow:fallThroughHole("FIX_ROUTE", 4, 6, "up")
|
||||
|
||||
local mid
|
||||
local earlySurf, earlyMoves, dropFrames, badDrop = 0, 0, 0, 0
|
||||
local landedSurfing
|
||||
local slideFrames, slideSpin = 0, 0
|
||||
local finalY
|
||||
local spinEndedAt, surfAt, currentAt
|
||||
for tick = 1, 500 do
|
||||
stack:update()
|
||||
local p = ow.player
|
||||
if ow.map.id == "FIX_ROUTE" then
|
||||
if not mid then
|
||||
mid = { surfing = p.surfing, moves = #ow.scriptMoves }
|
||||
end
|
||||
if dropFrames > 0 and not p.spinning and not spinEndedAt then spinEndedAt = tick end
|
||||
if p.surfing and not surfAt then surfAt = tick end
|
||||
if #ow.scriptMoves > 0 and not currentAt then currentAt = tick end
|
||||
local landing = ow.transitioning or ow.holeArrive or ow.spinArrive
|
||||
or stack:top() ~= ow
|
||||
if landing then
|
||||
if p.surfing then earlySurf = earlySurf + 1 end
|
||||
if #ow.scriptMoves > 0 or p.cellY ~= 6 then earlyMoves = earlyMoves + 1 end
|
||||
if p.spinning then
|
||||
dropFrames = dropFrames + 1
|
||||
local _, _, _, facing, phase, flip = p:pose()
|
||||
if facing ~= "down" or phase ~= 1 or flip ~= true then
|
||||
badDrop = badDrop + 1
|
||||
end
|
||||
end
|
||||
else
|
||||
if landedSurfing == nil then landedSurfing = p.surfing end
|
||||
if #ow.scriptMoves > 0 or p.moving then
|
||||
slideFrames = slideFrames + 1
|
||||
if p.spinning or ow.spinnerSliding then slideSpin = slideSpin + 1 end
|
||||
elseif slideFrames > 0 then
|
||||
finalY = p.cellY
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
check(mid ~= nil, "the hole fall reaches the destination map")
|
||||
eq(mid and mid.surfing, false, "the dungeon warp lands on foot, not surfing")
|
||||
eq(mid and mid.moves, 0, "the current is not armed at the warp midpoint")
|
||||
eq(earlySurf, 0, "no surf sprite through the hold and the drop")
|
||||
eq(earlyMoves, 0, "the current never moves the player before the landing")
|
||||
eq(surfMusicEarly, 0, "no surf music before the landing")
|
||||
check(dropFrames > 0, "the drop animation ran (" .. dropFrames .. " frames)")
|
||||
eq(badDrop, 0, "the drop keeps image index 3 (walking down, flipped), no rotation")
|
||||
eq(landedSurfing, true, "SURF mounts once the drop lands")
|
||||
-- engine/overworld/player_animations.asm:329
|
||||
eq(surfAt, spinEndedAt, "SURF mounts on the frame the drop's last step lands")
|
||||
eq(currentAt, spinEndedAt, "the current is armed on that same frame")
|
||||
check(surfMusicLate > 0, "surf music starts after the landing")
|
||||
check(slideFrames > 0, "the current slides the player after the landing")
|
||||
eq(slideSpin, 0, "the current does not spin the sprite")
|
||||
eq(finalY, 8, "the current carried the player two cells south")
|
||||
|
||||
do
|
||||
local o = newOW()
|
||||
o:runSpinnerMoves({ { dir = "down", count = 1 } }, 1)
|
||||
eq(o.player.spinning, true, "a spinner arrow still spins the sprite")
|
||||
eq(o.spinnerSliding, true, "a spinner arrow still freezes tile animation")
|
||||
end
|
||||
|
||||
do
|
||||
local o = newOW()
|
||||
o.arriveWarp = "teleport"
|
||||
o:startWarpTo("FIX_ROUTE", 4, 6, "down")
|
||||
for _ = 1, 200 do
|
||||
stack:update()
|
||||
if o.player.spinDrop then break end
|
||||
end
|
||||
check(o.player.spinDrop == true, "a teleport arrival arms its spin-down")
|
||||
eq(o.player.spinImageIndex, nil, "a teleport arrival keeps its own spin")
|
||||
eq(o.pendingEnterMapTail, nil, "a teleport arrival does not defer EnterMap")
|
||||
end
|
||||
|
||||
T.finish("seafoam_hole_current_bug2274")
|
||||
@@ -0,0 +1,242 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
package.loaded["src.core.Logger"] = {
|
||||
info = function() end, warn = function() end, error = function() end,
|
||||
}
|
||||
|
||||
love.filesystem.write = function() return true end
|
||||
love.graphics.getRendererInfo = function() return "OpenGL", "4.1 stub", "stub", "stub" end
|
||||
love.graphics.getSupported = function() return { glsl3 = true } end
|
||||
love.graphics.flushBatch = function() end
|
||||
love.graphics.validateShader = function() return true end
|
||||
love.timer = love.timer or {}
|
||||
love.timer.getDelta = love.timer.getDelta or function() return 1 / 60 end
|
||||
|
||||
local sends = {}
|
||||
love.graphics.newShader = function(frag, vert)
|
||||
local sh = { frag = frag, vert = vert }
|
||||
function sh.send(self, name, value) sends[#sends + 1] = { shader = self, name = name, value = value } end
|
||||
return sh
|
||||
end
|
||||
|
||||
local quads, draws, scissors = {}, {}, {}
|
||||
love.graphics.newQuad = function(x, y, w, h, sw, sh)
|
||||
local q = { x = x, y = y, w = w, h = h, sw = sw, sh = sh }
|
||||
quads[#quads + 1] = q
|
||||
return q
|
||||
end
|
||||
love.graphics.draw = function(img, a, b, c, d, e, f)
|
||||
if type(a) == "table" then
|
||||
draws[#draws + 1] = { img = img, quad = a, x = b, y = c, sx = e, sy = f }
|
||||
else
|
||||
draws[#draws + 1] = { img = img, x = a, y = b, sx = d, sy = e }
|
||||
end
|
||||
end
|
||||
love.graphics.setScissor = function(x, y, w, h)
|
||||
scissors[#scissors + 1] = x and { x, y, w, h } or false
|
||||
end
|
||||
|
||||
local ShaderFX = require("src.render.ShaderFX")
|
||||
|
||||
local function entry(name)
|
||||
return { name = name .. ".slangp", fullPath = "tests/data/shaderfx/gl/" .. name .. ".slangp", converted = true }
|
||||
end
|
||||
|
||||
local function windowCanvas(w, h)
|
||||
return setmetatable({ w = w, h = h }, { __index = {
|
||||
getWidth = function(self) return self.w end,
|
||||
getHeight = function(self) return self.h end,
|
||||
getPixelWidth = function(self) return self.w end,
|
||||
getPixelHeight = function(self) return self.h end,
|
||||
} })
|
||||
end
|
||||
|
||||
local ok = ShaderFX.activate("main", entry("lcd3x"))
|
||||
eq(ok, true, "lcd3x activates under the stub")
|
||||
|
||||
local realRunChain = ShaderFX.runChain
|
||||
local captured
|
||||
ShaderFX.runChain = function(state, img, luts, viewport, original, layer)
|
||||
local out = realRunChain(state, img, luts, viewport, original, layer)
|
||||
captured = { viewport = viewport, original = original, out = out, img = img }
|
||||
return out
|
||||
end
|
||||
|
||||
local function render(W, H, s, originX, originY, dpi, opts)
|
||||
quads, draws, scissors, sends, captured = {}, {}, {}, {}, nil
|
||||
dpi = dpi or 1
|
||||
opts = opts or {}
|
||||
opts.originX, opts.originY = originX, originY
|
||||
local canvas = windowCanvas(W, H)
|
||||
ShaderFX.render(canvas, { x = 0, y = 0, w = W, h = H, scale = s },
|
||||
{ w = W / s, h = H / s }, dpi, dpi, opts)
|
||||
return canvas
|
||||
end
|
||||
|
||||
local function findDraw(img)
|
||||
for _, d in ipairs(draws) do
|
||||
if d.img == img then return d end
|
||||
end
|
||||
end
|
||||
|
||||
local function near(a, b) return math.abs(a - b) < 1e-9 end
|
||||
|
||||
local cases = {
|
||||
{ label = "FIT", W = 1024, H = 768, s = 5, ox = 112, oy = 24 },
|
||||
{ label = "OUT1", W = 1024, H = 768, s = 4, ox = 2, oy = 1 },
|
||||
{ label = "OUT2", W = 1024, H = 768, s = 3, ox = 0, oy = 0 },
|
||||
{ label = "OUT2 camera phase", W = 1024, H = 768, s = 3, ox = -7, oy = 5 },
|
||||
{ label = "FIT 1280x720", W = 1280, H = 720, s = 5, ox = 240, oy = 0 },
|
||||
}
|
||||
|
||||
for _, c in ipairs(cases) do
|
||||
local canvas = render(c.W, c.H, c.s, c.ox, c.oy)
|
||||
check(captured ~= nil, c.label .. ": the chain ran")
|
||||
if captured then
|
||||
local vp, orig = captured.viewport, captured.original
|
||||
eq(vp.w, orig.w * c.s, c.label .. ": viewport width is exactly scale x source width")
|
||||
eq(vp.h, orig.h * c.s, c.label .. ": viewport height is exactly scale x source height")
|
||||
|
||||
local q = quads[1]
|
||||
check(q ~= nil, c.label .. ": the crop uses a quad")
|
||||
local x0, y0 = math.floor(q.x), math.floor(q.y)
|
||||
eq((c.ox - x0) % c.s, 0, c.label .. ": crop starts on a GB pixel column edge")
|
||||
eq((c.oy - y0) % c.s, 0, c.label .. ": crop starts on a GB pixel row edge")
|
||||
check(x0 <= 0 and x0 > -c.s and y0 <= 0 and y0 > -c.s,
|
||||
c.label .. ": crop starts within one GB pixel above/left of the window")
|
||||
eq(q.w, orig.w * c.s, c.label .. ": crop quad spans whole GB pixel columns")
|
||||
eq(q.h, orig.h * c.s, c.label .. ": crop quad spans whole GB pixel rows")
|
||||
check(x0 + q.w >= c.W and y0 + q.h >= c.H, c.label .. ": crop covers the whole window")
|
||||
check(q.x - x0 > 0 and q.x - x0 < 0.5 and q.y - y0 > 0 and q.y - y0 < 0.5,
|
||||
c.label .. ": crop samples inside a physical pixel, never on its edge")
|
||||
|
||||
local cropDraw = findDraw(canvas)
|
||||
check(cropDraw and near(cropDraw.sx, 1 / c.s), c.label .. ": crop draws at exactly 1/scale")
|
||||
|
||||
local passDraw = findDraw(captured.img)
|
||||
check(passDraw and near(passDraw.sx, c.s) and near(passDraw.sy, c.s),
|
||||
c.label .. ": the viewport pass stretches the source by the integer scale")
|
||||
check(passDraw and passDraw.x >= 1 / 16 and passDraw.x < 0.5 and passDraw.y >= 1 / 16 and passDraw.y < 0.5,
|
||||
c.label .. ": the integer viewport pass bias is at least a rasterizer subpixel and under half a pixel")
|
||||
if passDraw then
|
||||
for j = 0, c.s * 4 do
|
||||
eq(math.floor((j + 0.5 - passDraw.y) / c.s), math.floor((j + 0.5) / c.s),
|
||||
c.label .. ": the bias never changes which texel output row " .. j .. " samples")
|
||||
end
|
||||
end
|
||||
|
||||
local final = findDraw(captured.out)
|
||||
check(final ~= nil, c.label .. ": chain output reaches the screen")
|
||||
if final then
|
||||
eq(final.x, x0, c.label .. ": chain output lands where the crop was taken (x)")
|
||||
eq(final.y, y0, c.label .. ": chain output lands where the crop was taken (y)")
|
||||
local cw, ch = captured.out:getWidth(), captured.out:getHeight()
|
||||
check(near(final.sx * cw, orig.w * c.s) and near(final.sy * ch, orig.h * c.s),
|
||||
c.label .. ": chain output is exactly scale physical px per GB pixel")
|
||||
end
|
||||
local sc = scissors[#scissors - 1]
|
||||
check(type(sc) == "table" and sc[1] == 0 and sc[2] == 0 and sc[3] == c.W and sc[4] == c.H,
|
||||
c.label .. ": the overhanging output is clipped to the window")
|
||||
end
|
||||
end
|
||||
|
||||
do
|
||||
local canvas = render(1024, 768, 3, 1, 2, 1, { layer = "ui", mask = true })
|
||||
local maskRect
|
||||
for _, s in ipairs(sends) do
|
||||
if s.name == "maskRect" then maskRect = s.value end
|
||||
end
|
||||
check(maskRect ~= nil, "masked UI layer sends the mask rect")
|
||||
if maskRect and captured then
|
||||
local x0, y0 = -2, -1
|
||||
check(near(maskRect[1], x0 / 1024) and near(maskRect[2], y0 / 768),
|
||||
"mask rect starts at the crop origin")
|
||||
check(near(maskRect[3], captured.original.w * 3 / 1024) and near(maskRect[4], captured.original.h * 3 / 768),
|
||||
"mask rect spans the drawn chain output")
|
||||
end
|
||||
check(canvas ~= nil, "masked render ran")
|
||||
end
|
||||
|
||||
do
|
||||
render(2048, 1536, 6, 224, 48, 2)
|
||||
local q = quads[1]
|
||||
local final = captured and findDraw(captured.out)
|
||||
check(q and final, "HiDPI render ran")
|
||||
if q and final then
|
||||
local x0 = math.floor(q.x)
|
||||
eq((224 - x0) % 6, 0, "HiDPI: crop aligned to the physical UI origin")
|
||||
eq(final.x, x0 / 2, "HiDPI: chain output placed in logical units")
|
||||
eq(captured.viewport.w, captured.original.w * 6, "HiDPI: viewport is exactly scale x source")
|
||||
end
|
||||
end
|
||||
|
||||
do
|
||||
render(1024, 768, 4.8, 12, 0)
|
||||
local q = quads[1]
|
||||
check(q and q.x == 0 and q.y == 0 and q.w == 1024 and q.h == 768,
|
||||
"fractional scale keeps the whole-window crop")
|
||||
eq(captured.viewport.w, 1024, "fractional scale keeps the window viewport width")
|
||||
eq(captured.viewport.h, 768, "fractional scale keeps the window viewport height")
|
||||
eq(captured.original.w, math.floor(1024 / 4.8 + 0.5), "fractional scale keeps the rounded source width")
|
||||
local passDraw = findDraw(captured.img)
|
||||
check(passDraw and passDraw.x == 0 and passDraw.y == 0,
|
||||
"a non-integer viewport stretch draws unbiased")
|
||||
local final = findDraw(captured.out)
|
||||
check(final and final.x == 0 and final.y == 0
|
||||
and near(final.sx * captured.out:getWidth(), 1024),
|
||||
"fractional scale draws the chain output over the window as before")
|
||||
for _, sc in ipairs(scissors) do
|
||||
check(sc == false, "fractional scale adds no scissor")
|
||||
end
|
||||
end
|
||||
|
||||
ShaderFX.runChain = realRunChain
|
||||
ShaderFX.deactivate("main")
|
||||
|
||||
do
|
||||
draws = {}
|
||||
local canvas = windowCanvas(1024, 768)
|
||||
ShaderFX.render(canvas, { x = 0, y = 0, w = 1024, h = 768, scale = 3 },
|
||||
{ w = 1024 / 3, h = 256 }, 1, 1, { originX = 1, originY = 1 })
|
||||
eq(#draws, 1, "shader off: one draw")
|
||||
check(draws[1] and draws[1].img == canvas and draws[1].x == 0 and draws[1].y == 0 and draws[1].sx == nil,
|
||||
"shader off: the canvas is drawn untouched at the origin")
|
||||
end
|
||||
|
||||
do
|
||||
local Game2 = require("src.core.Game2")
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local function game(zoom, fit, cam)
|
||||
return setmetatable({
|
||||
world = {
|
||||
map = {}, camera = cam or { x = 9, y = 27 },
|
||||
zoomScale = function() return zoom end,
|
||||
fitScale = function() return fit end,
|
||||
},
|
||||
}, { __index = Game2 })
|
||||
end
|
||||
eq(Chrome.fitScale(1024, 768), 5, "1024x768 fits at 5")
|
||||
local ox, oy = Chrome.fitOrigin(1024, 768, 5)
|
||||
eq(ox, 112, "1024x768 fit origin x")
|
||||
eq(oy, 24, "1024x768 fit origin y")
|
||||
local wx, wy = game(5, 5):fxWorldOrigin(1024, 768, 5)
|
||||
eq(wx, -45, "world grid origin follows the camera x")
|
||||
eq(wy, -135, "world grid origin follows the camera y")
|
||||
eq(game(5, 5):fxSplitsUi(1024, 768), true,
|
||||
"Gen 2 at FIT splits the UI layer when the menu grid phase differs from the world camera grid")
|
||||
eq(game(5, 5, { x = 0, y = 0 }):fxSplitsUi(1024, 768), true,
|
||||
"1024x768 never aligns at FIT: the fit origin is off the camera grid by 2,4 for every integer camera")
|
||||
eq(game(5, 5, { x = 4, y = 11 }):fxSplitsUi(1280, 720), false,
|
||||
"1280x720 (origin 240,0) shares phase with the camera grid, no split at FIT")
|
||||
eq(game(3, 5):fxSplitsUi(1280, 720), true, "a zoomed world always splits the UI layer")
|
||||
eq(setmetatable({ world = { map = nil } }, { __index = Game2 }):fxSplitsUi(1024, 768), false,
|
||||
"no map, no split")
|
||||
eq(setmetatable({}, { __index = Game2 }):fxSplitsUi(1024, 768), false, "no world, no split")
|
||||
end
|
||||
|
||||
T.finish("shaderfx_grid_alignment_2235")
|
||||
@@ -0,0 +1,144 @@
|
||||
-- pokered/home/trainers.asm:123
|
||||
-- pokered/home/text_script.asm:96
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local check, eq = T.check, T.eq
|
||||
local Data = T.fixtures.fresh()
|
||||
require("src.render.Font").load(Data)
|
||||
Data.text = {}
|
||||
|
||||
local Sound = require("src.core.Sound")
|
||||
local Music = require("src.core.Music")
|
||||
local Commands = require("src.script.Commands")
|
||||
local ScriptRunner = require("src.script.ScriptRunner")
|
||||
|
||||
local frame = 0
|
||||
local CRY_FRAMES = 30
|
||||
local cryEndedAt
|
||||
local cries = {}
|
||||
Sound.playCry = function(_, species)
|
||||
local endAt = frame + CRY_FRAMES
|
||||
cries[#cries + 1] = species
|
||||
return {
|
||||
getDuration = function() return CRY_FRAMES / 60 end,
|
||||
getPitch = function() return 1 end,
|
||||
stop = function() end,
|
||||
isPlaying = function()
|
||||
local playing = frame < endAt
|
||||
if not playing and not cryEndedAt then cryEndedAt = frame end
|
||||
return playing
|
||||
end,
|
||||
}
|
||||
end
|
||||
Sound.play = function() return nil end
|
||||
Sound.playPress = function() return nil end
|
||||
|
||||
local songs = {}
|
||||
Music.play = function(_, song)
|
||||
songs[#songs + 1] = { song = song, frame = frame, cryEndedAt = cryEndedAt }
|
||||
end
|
||||
|
||||
local battles = {}
|
||||
Commands.start_battle = function(ctx, kind, species, level)
|
||||
battles[#battles + 1] = { kind = kind, species = species, level = level,
|
||||
songs = #songs }
|
||||
ctx.lastBattleResult = "win"
|
||||
end
|
||||
|
||||
local stack = { states = {} }
|
||||
function stack:push(s) self.states[#self.states + 1] = s end
|
||||
function stack:pop() return table.remove(self.states) end
|
||||
function stack:top() return self.states[#self.states] end
|
||||
|
||||
local pressed = {}
|
||||
local game = {
|
||||
data = Data,
|
||||
save = { flags = {}, player = { name = "RED" }, options = { textSpeed = 1 } },
|
||||
stack = stack,
|
||||
input = {
|
||||
wasPressed = function(_, key) return pressed[key] or false end,
|
||||
isDown = function() return false end,
|
||||
},
|
||||
}
|
||||
|
||||
local runner
|
||||
local function step(btn)
|
||||
frame = frame + 1
|
||||
pressed = btn and { [btn] = true } or {}
|
||||
local top = stack:top()
|
||||
if top then top:update(1 / 60) end
|
||||
pressed = {}
|
||||
runner:update()
|
||||
end
|
||||
|
||||
local function load(path, mapId, textId)
|
||||
local mod = assert(loadfile(path))()
|
||||
return mod[mapId].talk[textId]
|
||||
end
|
||||
|
||||
local function start(script)
|
||||
stack.states, songs, battles, cries, cryEndedAt = {}, {}, {}, {}, nil
|
||||
runner = ScriptRunner.new(game, nil)
|
||||
runner:run(script, { npc = { def = {} } })
|
||||
end
|
||||
|
||||
local CASES = {
|
||||
{ "data/scripts/flavor/seafoam_islands_b4f.lua", "SEAFOAM_ISLANDS_B4F",
|
||||
"TEXT_SEAFOAMISLANDSB4F_ARTICUNO", "ARTICUNO", "EVENT_BEAT_ARTICUNO" },
|
||||
{ "data/scripts/flavor/power_plant.lua", "POWER_PLANT",
|
||||
"TEXT_POWERPLANT_ZAPDOS", "ZAPDOS", "EVENT_BEAT_ZAPDOS" },
|
||||
{ "data/scripts/flavor/victory_road_2f.lua", "VICTORY_ROAD_2F",
|
||||
"TEXT_VICTORYROAD2F_MOLTRES", "MOLTRES", "EVENT_BEAT_MOLTRES" },
|
||||
{ "data/scripts/flavor/cerulean_cave_b1f.lua", "CERULEAN_CAVE_B1F",
|
||||
"TEXT_CERULEANCAVEB1F_MEWTWO", "MEWTWO", "EVENT_BEAT_MEWTWO" },
|
||||
{ "data/scripts/flavor/power_plant.lua", "POWER_PLANT",
|
||||
"TEXT_POWERPLANT_VOLTORB1", nil, "EVENT_BEAT_POWER_PLANT_VOLTORB_0",
|
||||
"VOLTORB" },
|
||||
}
|
||||
|
||||
for _, c in ipairs(CASES) do
|
||||
local path, mapId, textId, crySpecies, flag = c[1], c[2], c[3], c[4], c[5]
|
||||
local species = c[6] or crySpecies
|
||||
local script = load(path, mapId, textId)
|
||||
check(type(script) == "table", textId .. " resolves")
|
||||
|
||||
game.save.flags = {}
|
||||
start(script)
|
||||
for _ = 1, 400 do step() end
|
||||
eq(#battles, 0, textId .. ": no battle before A/B")
|
||||
check(stack:top() ~= nil, textId .. ": box still up without a press")
|
||||
eq(#songs, 1, textId .. ": one engage sting while the box waits")
|
||||
eq(songs[1] and songs[1].song, "Music_MeetMaleTrainer",
|
||||
textId .. ": sting is Music_MeetMaleTrainer")
|
||||
if crySpecies then
|
||||
eq(cries[1], crySpecies, textId .. ": cry plays")
|
||||
check(songs[1] and songs[1].cryEndedAt ~= nil
|
||||
and songs[1].frame >= songs[1].cryEndedAt,
|
||||
textId .. ": sting starts after the cry finishes")
|
||||
else
|
||||
eq(#cries, 0, textId .. ": no cry")
|
||||
end
|
||||
step("a")
|
||||
for _ = 1, 10 do step() end
|
||||
check(not runner:isRunning(), textId .. ": script finishes after A")
|
||||
eq(#battles, 1, textId .. ": one battle after A")
|
||||
eq(battles[1] and battles[1].species, species, textId .. ": battle species")
|
||||
eq(battles[1] and battles[1].songs, 1, textId .. ": sting precedes battle")
|
||||
check(game.save.flags[flag] == true, textId .. ": beat flag set")
|
||||
|
||||
start(script)
|
||||
for _ = 1, 400 do step() end
|
||||
check(stack:top() ~= nil, textId .. " beaten: box waits for A/B")
|
||||
if crySpecies then
|
||||
eq(cries[1], crySpecies, textId .. " beaten: cry still plays")
|
||||
end
|
||||
step("a")
|
||||
for _ = 1, 10 do step() end
|
||||
check(not runner:isRunning(), textId .. " beaten: script finishes after A")
|
||||
eq(#songs, 0, textId .. " beaten: no sting")
|
||||
eq(#battles, 0, textId .. " beaten: no battle")
|
||||
end
|
||||
|
||||
T.finish("static_encounter_sting_bug2275_2261")
|
||||
@@ -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)")
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -14,6 +14,9 @@ Nothing here is transcribed. Build pret/pokegold and pret/pokecrystal, then:
|
||||
python3 tools/gen2_sram_offsets.py \
|
||||
--gold path/to/pokegold.sym \
|
||||
--crystal path/to/pokecrystal.sym \
|
||||
--charmap path/to/pokegold/constants/charmap.asm \
|
||||
--gold-scenes path/to/pokegold/data/maps/scenes.asm \
|
||||
--crystal-scenes path/to/pokecrystal/data/maps/scenes.asm \
|
||||
> src/save_convert/Gen2Layout.lua
|
||||
|
||||
Gold and Silver share a layout (pokesilver.sym agrees byte-exact); Crystal does
|
||||
@@ -58,15 +61,24 @@ def load(path):
|
||||
# out exactly like the primary, so it is the same table shifted. Gold and
|
||||
# Silver split theirs across three sections and are not derivable this way,
|
||||
# which is why only Crystal gets one.
|
||||
BACKUP_NEED = ["sBackupGameData", "sBackupGameDataEnd", "sBackupCheckValue1",
|
||||
"sBackupCheckValue2", "sBackupChecksum", "sGameData"]
|
||||
|
||||
|
||||
def backup_delta(sym):
|
||||
if any(n not in sym for n in BACKUP_NEED):
|
||||
return None
|
||||
off = lambda n: sym[n][0] * 0x2000 + (sym[n][1] - 0xA000)
|
||||
return off("sBackupGameData") - off("sGameData")
|
||||
|
||||
|
||||
def backup_table(sym, rows, label):
|
||||
need = ["sBackupGameData", "sBackupGameDataEnd", "sBackupCheckValue1",
|
||||
"sBackupCheckValue2", "sBackupChecksum", "sGameData"]
|
||||
if any(n not in sym for n in need):
|
||||
if any(n not in sym for n in BACKUP_NEED):
|
||||
return None
|
||||
off = lambda n: sym[n][0] * 0x2000 + (sym[n][1] - 0xA000)
|
||||
# File offsets, not raw addresses: the backup lives in SRAM bank 0 and the
|
||||
# primary in bank 1, so an address-only delta is off by a bank.
|
||||
delta = off("sBackupGameData") - off("sGameData")
|
||||
delta = backup_delta(sym)
|
||||
guards = {"sCheckValue1": off("sBackupCheckValue1"),
|
||||
"sCheckValue2": off("sBackupCheckValue2"),
|
||||
"sChecksum": off("sBackupChecksum"),
|
||||
@@ -127,6 +139,37 @@ def table(sym, label):
|
||||
return rows, skipped, boxes
|
||||
|
||||
|
||||
SCENE_RE = re.compile(r"^\s*scene_var\s+(\w+),\s*(\w+)")
|
||||
|
||||
|
||||
def scene_rows(sym, path, label):
|
||||
base = sym["sPlayerData"][1] - 0xA000 + 0x2000
|
||||
anchor = sym["wPlayerData"][1]
|
||||
lo, hi = sym["sGameData"][1], sym["sGameDataEnd"][1]
|
||||
out = []
|
||||
for line in open(path):
|
||||
m = SCENE_RE.match(line)
|
||||
if not m:
|
||||
continue
|
||||
mapId, var = m.group(1), m.group(2)
|
||||
w = sym.get(var)
|
||||
if not w:
|
||||
sys.exit(f"{label}: {var} for {mapId} is missing from the symbol file")
|
||||
if not (lo <= w[1] - anchor + sym["sPlayerData"][1] < hi):
|
||||
sys.exit(f"{label}: {var} is outside sGameData..sGameDataEnd")
|
||||
out.append((mapId, base + (w[1] - anchor)))
|
||||
if not out:
|
||||
sys.exit(f"{label}: no scene_var rows in {path}")
|
||||
return out
|
||||
|
||||
|
||||
def print_scenes(rows, indent):
|
||||
print(f"{indent}sceneVars = {{")
|
||||
for mapId, off in rows:
|
||||
print(f"{indent} {mapId} = 0x{off:04X},")
|
||||
print(f"{indent}}},")
|
||||
|
||||
|
||||
# The cart's own text table, so a name with an apostrophe, an accent or the PK
|
||||
# glyph in it survives the round trip. Hand-keeping this list is how a player
|
||||
# called "Mattia<PK>" comes back as "Mattia?".
|
||||
@@ -162,20 +205,29 @@ def main():
|
||||
ap.add_argument("--charmap", required=False,
|
||||
help="path to pokegold constants/charmap.asm; emits the "
|
||||
"text table too when given")
|
||||
ap.add_argument("--gold-scenes", required=False,
|
||||
help="path to pokegold data/maps/scenes.asm; emits the "
|
||||
"w<Map>SceneID offsets when given")
|
||||
ap.add_argument("--crystal-scenes", required=False,
|
||||
help="path to pokecrystal data/maps/scenes.asm")
|
||||
a = ap.parse_args()
|
||||
print("-- GENERATED by tools/gen2_sram_offsets.py. Do not edit by hand.")
|
||||
print("-- Regenerate from a pret/pokegold + pret/pokecrystal build; see that")
|
||||
print("-- script's header for the derivation and the assertion behind it.")
|
||||
print("local Gen2Layout = {}\n")
|
||||
for key, path in (("goldSilver", a.gold), ("crystal", a.crystal)):
|
||||
for key, path, scenesPath in (("goldSilver", a.gold, a.gold_scenes),
|
||||
("crystal", a.crystal, a.crystal_scenes)):
|
||||
sym = load(path)
|
||||
rows, skipped, boxes = table(sym, key)
|
||||
backup = backup_table(sym, rows, key)
|
||||
scenes = scene_rows(sym, scenesPath, key) if scenesPath else None
|
||||
print(f"Gen2Layout.{key} = {{")
|
||||
for n, off in rows:
|
||||
print(f" {n} = 0x{off:04X},")
|
||||
print(" -- The 14 archived boxes, listed rather than strided (see BOX_COUNT).")
|
||||
print(" boxes = { " + ", ".join("0x%04X" % b for b in boxes) + " },")
|
||||
if scenes:
|
||||
print_scenes(scenes, " ")
|
||||
if backup:
|
||||
print(" -- The backup copy the game falls back to when the primary")
|
||||
print(" -- checksum fails. Same shape, shifted.")
|
||||
@@ -183,6 +235,9 @@ def main():
|
||||
for n, off in backup:
|
||||
print(f" {n} = 0x{off:04X},")
|
||||
print(" boxes = { " + ", ".join("0x%04X" % b for b in boxes) + " },")
|
||||
if scenes:
|
||||
delta = backup_delta(sym)
|
||||
print_scenes([(m, off + delta) for m, off in scenes], " ")
|
||||
print(" },")
|
||||
print("}")
|
||||
for s2 in skipped:
|
||||
|
||||
Reference in New Issue
Block a user