CLOSES #1091, CLOSES #1097, CLOSES #1100, CLOSES #1104, CLOSES #1135, CLOSES #1155, CLOSES #1157, CLOSES #1158, CLOSES #1159, CLOSES #1160, CLOSES #1167, CLOSES #1208

This commit is contained in:
bryanthaboi
2026-08-13 05:30:33 -04:00
parent f4497b4dbb
commit e9d431b3ff
19 changed files with 1119 additions and 82 deletions
+7 -2
View File
@@ -197,14 +197,19 @@ end
local touchEditorHost local touchEditorHost
local closeTouchControlsEditor -- forward declaration local closeTouchControlsEditor -- forward declaration
local function openTouchControlsEditor() -- `version` is the launcher tab the gear was opened on, and it decides which
-- option block the layout lands in (src/ui/TouchControlsEditor.lua persist).
local function openTouchControlsEditor(version)
touchEditorHost = Importer touchEditorHost = Importer
if Importer and Importer.prepareOverlayHandoff then if Importer and Importer.prepareOverlayHandoff then
Importer:prepareOverlayHandoff() Importer:prepareOverlayHandoff()
end end
Importer = nil Importer = nil
TouchEditor = require("src.ui.TouchControlsEditor") TouchEditor = require("src.ui.TouchControlsEditor")
TouchEditor.load({ onClose = function() closeTouchControlsEditor() end }) TouchEditor.load({
version = version,
onClose = function() closeTouchControlsEditor() end,
})
end end
function closeTouchControlsEditor() function closeTouchControlsEditor()
+18 -2
View File
@@ -1604,9 +1604,25 @@ function Battle:useMove(attacker, defender, moveId)
return return
end end
-- Damage that skips the formula entirely. -- Damage that skips the formula entirely. The move's own power goes with
local fixed = Effects.fixedDamage(def.effect, attacker, defender, self.random) -- it: EFFECT_STATIC_DAMAGE's arm of BattleCommand_ConstantDamage reads
-- BATTLE_VARS_MOVE_POWER as the damage (effect_commands.asm:3157-3161).
local fixed = Effects.fixedDamage(def.effect, attacker, defender, self.random,
def.power)
if fixed then if fixed then
-- The constant-damage effect list carries `resettypematchup` instead of
-- `stab`, and that command misses the move outright when the matchup byte
-- is 0 (effect_commands.asm:1480-1493) -- an immune target is the one
-- thing that stops SONIC BOOM, NIGHT SHADE or SUPER FANG.
local defenderTypes = (self:speciesDef(defender) or {}).types
or defender.types
local matchups = self.data.type_chart and self.data.type_chart.matchups
if Damage.typeMultiplier(def.type, defenderTypes, matchups) == 0 then
self:markMissed()
self:emit({ kind = "message",
text = "It doesn't affect " .. self:monName(defender) .. "..." })
return
end
self:dealDamage(attacker, defender, fixed, { move = def, moveId = moveId }) self:dealDamage(attacker, defender, fixed, { move = def, moveId = moveId })
return return
end end
+14 -6
View File
@@ -179,9 +179,9 @@ end
-- --------------------------------------------------------------- fixed damage -- --------------------------------------------------------------- fixed damage
-- BattleCommand_LevelDamage / SuperFang / Psywave, all of which skip the -- BattleCommand_ConstantDamage (engine/battle/effect_commands.asm:3131-3205),
-- damage formula entirely. -- one command for the whole SuperFang / Psywave / StaticDamage list.
function Effects.fixedDamage(effect, attacker, defender, random) function Effects.fixedDamage(effect, attacker, defender, random, power)
if effect == "EFFECT_LEVEL_DAMAGE" then if effect == "EFFECT_LEVEL_DAMAGE" then
return math.max(1, attacker.level or 1) return math.max(1, attacker.level or 1)
end end
@@ -189,9 +189,17 @@ function Effects.fixedDamage(effect, attacker, defender, random)
return math.max(1, math.floor((defender.hp or 1) / 2)) return math.max(1, math.floor((defender.hp or 1) / 2))
end end
if effect == "EFFECT_PSYWAVE" then if effect == "EFFECT_PSYWAVE" then
-- 1..(level * 1.5), rerolled until it is in range; one roll is enough here. -- .psywave rerolls until the byte is nonzero AND below level * 1.5, so the
local ceiling = math.max(1, math.floor((attacker.level or 1) * 3 / 2)) -- top of the range is that ceiling minus one (effect_commands.asm:3163).
return math.max(1, (random and random(ceiling) or 0) + 1) local ceiling = math.max(2, math.floor((attacker.level or 1) * 3 / 2))
return math.max(1, (random and random(ceiling - 1) or 0) + 1)
end
-- SONIC BOOM and DRAGON RAGE share EFFECT_STATIC_DAMAGE, whose arm reads
-- BATTLE_VARS_MOVE_POWER straight into the damage word: their stored power
-- (20 and 40) IS the damage, never a formula input
-- (effect_commands.asm:3157-3161).
if effect == "EFFECT_STATIC_DAMAGE" then
return math.max(1, math.floor(power or 0))
end end
return nil return nil
end end
+26 -3
View File
@@ -818,9 +818,19 @@ end
function SaveData.slotSummary(save) function SaveData.slotSummary(save)
if type(save) ~= "table" then return nil, nil end if type(save) ~= "table" then return nil, nil end
local name = save.player and save.player.name or nil local name = save.player and save.player.name or nil
-- A Gen 2 slot carries no badge items and fills pokedex.caught, so both
-- counts come off wJohtoBadges/wPokedexCaught (engine/menus/intro_menu.asm:461).
local vinfo = type(save.version) == "string" and GameVersion.info(save.version)
local gen2 = save.generation == 2 or (vinfo and vinfo.generation == 2) or false
local dexCount = 0 local dexCount = 0
for _ in pairs((save.pokedex and save.pokedex.owned) or {}) do if gen2 then
dexCount = dexCount + 1 for _, has in pairs((save.pokedex and save.pokedex.caught) or {}) do
if has then dexCount = dexCount + 1 end
end
else
for _ in pairs((save.pokedex and save.pokedex.owned) or {}) do
dexCount = dexCount + 1
end
end end
-- playTime is a plain seconds count in a Gen 1 save but a -- playTime is a plain seconds count in a Gen 1 save but a
-- { hours, minutes, seconds, frames } table in a Gen 2 (Gold) save, matching -- { hours, minutes, seconds, frames } table in a Gen 2 (Gold) save, matching
@@ -838,8 +848,21 @@ function SaveData.slotSummary(save)
end end
local timeText = ("%d:%02d"):format(math.floor(t / 3600), local timeText = ("%d:%02d"):format(math.floor(t / 3600),
math.floor(t / 60) % 60) math.floor(t / 60) % 60)
local badges
if gen2 then
-- Continue_DisplayBadgeCount walks TWO bytes, Johto then Kanto
-- (engine/menus/intro_menu.asm:461-469).
badges = 0
local p = save.player or {}
for _, has in pairs(p.badges or {}) do if has then badges = badges + 1 end end
for _, has in pairs(p.kantoBadges or {}) do
if has then badges = badges + 1 end
end
else
badges = Badges.count(nil, save)
end
return name, { return name, {
badges = Badges.count(nil, save), badges = badges,
timeText = timeText, timeText = timeText,
dexCount = dexCount, dexCount = dexCount,
} }
+5
View File
@@ -764,9 +764,14 @@ end
function Save.summary(save) function Save.summary(save)
if type(save) ~= "table" then return nil end if type(save) ~= "table" then return nil end
local badges = 0 local badges = 0
-- Continue_DisplayBadgeCount counts TWO bytes, wJohtoBadges then wKantoBadges
-- (engine/menus/intro_menu.asm:461-469).
for _, has in pairs(save.player and save.player.badges or {}) do for _, has in pairs(save.player and save.player.badges or {}) do
if has then badges = badges + 1 end if has then badges = badges + 1 end
end end
for _, has in pairs(save.player and save.player.kantoBadges or {}) do
if has then badges = badges + 1 end
end
local caught = 0 local caught = 0
for _, has in pairs(save.pokedex and save.pokedex.caught or {}) do for _, has in pairs(save.pokedex and save.pokedex.caught or {}) do
if has then caught = caught + 1 end if has then caught = caught + 1 end
+67 -56
View File
@@ -62,6 +62,68 @@ local FILTERS = { "OFF", "1X", "2X", "3X" }
-- The core rows. Helper modules are required lazily under pcall: they are -- The core rows. Helper modules are required lazily under pcall: they are
-- pure label/cycle tables, but the launcher must never die because a render -- pure label/cycle tables, but the launcher must never die because a render
-- module grew a dependency on live game data. -- module grew a dependency on live game data.
-- TOUCH PAD, VIBRATION and the layout editor, shared by both row sets.
--
-- Gold reads these out of its own `gold` block (src/core/gen2/Save.lua:297),
-- so `opts` is whichever table the gear is editing and the rows never have to
-- know which game they belong to. #1100 / #1135: the Gold gear carried none
-- of them, so a phone player could turn the pad and the buzz off in Red and
-- had no way to reach either in Gold.
local function addTouchRows(rows, add, opts, hooks)
-- TOUCH PAD only where the overlay can appear, mirroring OptionsMenu's
-- gate (mobile, or desktop forced by POKEPORT_TOUCH=1).
local env = os.getenv("POKEPORT_TOUCH")
local osName = love.system and love.system.getOS and love.system.getOS()
local show = env == "1"
or (env ~= "0" and (osName == "Android" or osName == "iOS"))
if show then
add(Strings("TOUCH PAD"),
function()
local tc = opts.touchControls
local on = not (type(tc) == "table" and tc.enabled == false)
return on and Strings("ON") or Strings("OFF")
end,
function()
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
tc.enabled = tc.enabled == false
opts.touchControls = tc
return true
end)
-- VIBRATION sits with it (#806): same gate, same subsystem. Stepping
-- the row buzzes once at the level being selected.
local okTC, TC = pcall(require, "src.core.TouchControls")
if okTC then
add(Strings("VIBRATION"),
function() return Strings(TC.hapticLabel(opts.haptics)) end,
function(dir)
opts.haptics = TC.cycleHaptics(opts.haptics, dir)
TC.buzz(opts.haptics)
return true
end)
end
end
-- TOUCH CONTROLS, the on-screen pad's layout editor. It used to be a
-- button on the game panel, once per game -- but the overlay layout is
-- global (options.touchControls.layouts), so three tabs offered three
-- buttons that edited the same thing while crowding the column that has to
-- hold Play. It belongs with the other control rows, behind the gear.
-- The host owns the editor screen, so the row only fires when a hook was
-- supplied (the standalone save editor opens this model with none).
if hooks and hooks.editTouchControls then
rows[#rows + 1] = {
label = Strings("TOUCH CONTROLS"),
actionLabel = Strings("Edit"),
action = function()
hooks.editTouchControls()
-- The editor replaces the whole screen: nothing left to persist here
-- beyond what the caller already saved on the way out.
return false
end,
}
end
end
local function coreRows(opts, hooks) local function coreRows(opts, hooks)
local rows = {} local rows = {}
local function add(label, value, step) local function add(label, value, step)
@@ -236,60 +298,7 @@ local function coreRows(opts, hooks)
end) end)
end end
-- TOUCH PAD only where the overlay can appear, mirroring OptionsMenu's addTouchRows(rows, add, opts, hooks)
-- gate (mobile, or desktop forced by POKEPORT_TOUCH=1).
do
local env = os.getenv("POKEPORT_TOUCH")
local osName = love.system and love.system.getOS and love.system.getOS()
local show = env == "1"
or (env ~= "0" and (osName == "Android" or osName == "iOS"))
if show then
add(Strings("TOUCH PAD"),
function()
local tc = opts.touchControls
local on = not (type(tc) == "table" and tc.enabled == false)
return on and Strings("ON") or Strings("OFF")
end,
function()
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
tc.enabled = tc.enabled == false
opts.touchControls = tc
return true
end)
-- VIBRATION sits with it (#806): same gate, same subsystem. Stepping
-- the row buzzes once at the level being selected.
local okTC, TC = pcall(require, "src.core.TouchControls")
if okTC then
add(Strings("VIBRATION"),
function() return Strings(TC.hapticLabel(opts.haptics)) end,
function(dir)
opts.haptics = TC.cycleHaptics(opts.haptics, dir)
TC.buzz(opts.haptics)
return true
end)
end
end
end
-- TOUCH CONTROLS, the on-screen pad's layout editor. It used to be a
-- button on the game panel, once per game -- but the overlay layout is
-- global (options.touchControls.layouts), so three tabs offered three
-- buttons that edited the same thing while crowding the column that has to
-- hold Play. It belongs with the other control rows, behind the gear.
-- The host owns the editor screen, so the row only fires when a hook was
-- supplied (the standalone save editor opens this model with none).
if hooks and hooks.editTouchControls then
rows[#rows + 1] = {
label = Strings("TOUCH CONTROLS"),
actionLabel = Strings("Edit"),
action = function()
hooks.editTouchControls()
-- The editor replaces the whole screen: nothing left to persist here
-- beyond what the caller already saved on the way out.
return false
end,
}
end
-- RESET REBINDS, directly under the touch-pad row. Rebinds are additive -- RESET REBINDS, directly under the touch-pad row. Rebinds are additive
-- (src/core/Input.lua:applyBindings layers options.bindings over the -- (src/core/Input.lua:applyBindings layers options.bindings over the
@@ -462,7 +471,7 @@ end
-- src/ui/gen2/OptionsMenu.lua's ROWS; when editing one, keep the two in sync. -- src/ui/gen2/OptionsMenu.lua's ROWS; when editing one, keep the two in sync.
local GEN2_KEY = "gold" local GEN2_KEY = "gold"
local function gen2Rows(opts) local function gen2Rows(opts, hooks)
local rows = {} local rows = {}
local function add(label, value, step) local function add(label, value, step)
rows[#rows + 1] = { label = label, value = value, step = step } rows[#rows + 1] = { label = label, value = value, step = step }
@@ -551,6 +560,8 @@ local function gen2Rows(opts)
end) end)
end end
addTouchRows(rows, add, opts, hooks)
return rows return rows
end end
@@ -574,7 +585,7 @@ function LauncherSettings.open(hooks, version)
opts[GEN2_KEY] = block opts[GEN2_KEY] = block
end end
sections = { sections = {
{ title = Strings("OPTIONS"), rows = gen2Rows(block) }, { title = Strings("OPTIONS"), rows = gen2Rows(block, hooks) },
} }
else else
sections = { sections = {
+4 -1
View File
@@ -2652,11 +2652,14 @@ function RomImporter:_openSettings()
-- hook rather than reaching for main.lua's handler itself. Closing the -- hook rather than reaching for main.lua's handler itself. Closing the
-- settings panel FIRST persists the pending edits (_closeSettings saves) -- settings panel FIRST persists the pending edits (_closeSettings saves)
-- and leaves no modal behind the editor to return to. -- and leaves no modal behind the editor to return to.
-- The tab rides along: the editor persists the layout into that game's own
-- option block, and Gold's is not the flat Gen 1 one (#1100).
local hooks = {} local hooks = {}
if self.onEditTouchControls then if self.onEditTouchControls then
local version = self.tab
hooks.editTouchControls = function() hooks.editTouchControls = function()
self:_closeSettings() self:_closeSettings()
self.onEditTouchControls() self.onEditTouchControls(version)
end end
end end
-- The tab the gear was opened on decides the row set: Gold reads a -- The tab the gear was opened on decides the row set: Gold reads a
+54 -11
View File
@@ -106,6 +106,10 @@ local TEXT_ASK_FORGET_SLOT = Strings.source("Which move should\nbe forgotten?")
local TEXT_CANT_FORGET_HM = Strings.source("HM moves can't be\nforgotten now.") local TEXT_CANT_FORGET_HM = Strings.source("HM moves can't be\nforgotten now.")
local TEXT_STOP_LEARNING = Strings.source("Stop learning\n%s?") local TEXT_STOP_LEARNING = Strings.source("Stop learning\n%s?")
-- BattleText_EnemyIsAboutToUseWillPlayerChangeMon (data/text/battle.asm:222-231).
local TEXT_ENEMY_ABOUT_TO_USE = Strings.source(
"%s\nis about to use\v%s.\fWill %s\nchange POKéMON?")
-- _AskForgetMoveText, all three paragraphs (data/text/common_3.asm:141-165). -- _AskForgetMoveText, all three paragraphs (data/text/common_3.asm:141-165).
local TEXT_ASK_FORGET_MOVE = Strings.source( local TEXT_ASK_FORGET_MOVE = Strings.source(
"%s is\ntrying to learn\v%s.\fBut %s\ncan't learn more\vthan four moves." "%s is\ntrying to learn\v%s.\fBut %s\ncan't learn more\vthan four moves."
@@ -548,6 +552,14 @@ BattleState.PLAYER_PIC_TILES = 6
-- box's own bottom centre. -- box's own bottom centre.
local PIC_RESIZE_TILES = { [0] = 6, [1] = 4, [2] = 2, [3] = 7, [4] = 5, [5] = 3 } local PIC_RESIZE_TILES = { [0] = 6, [1] = 4, [2] = 2, [3] = 7, [4] = 5, [5] = 3 }
-- SUBSTATUS_UNDERGROUND / SUBSTATUS_FLYING, which BattleCommand_Charge sets on
-- FLY and DIG only (engine/battle/effect_commands.asm:5478-5485); the port
-- carries both as the volatile `vanished` flag.
function BattleState.isVanished(mon)
local volatiles = mon and mon.volatile
return (volatiles and volatiles.vanished) and true or false
end
function BattleState:drawPic(mon, back) function BattleState:drawPic(mon, back)
-- During the intro slide the player-side pic belongs to presentSlide's -- During the intro slide the player-side pic belongs to presentSlide's
-- backpic overlay, not to the baked bands (see BattleAnimView). -- backpic overlay, not to the baked bands (see BattleAnimView).
@@ -571,6 +583,13 @@ function BattleState:drawPic(mon, back)
-- The box is empty either because the animation running right now has -- The box is empty either because the animation running right now has
-- cleared it, or because the last one ENDED with it cleared (picHidden). -- cleared it, or because the last one ENDED with it cleared (picHidden).
if (anim and anim.hidden) or self.picHidden[side] then return end if (anim and anim.hidden) or self.picHidden[side] then return end
-- Mid FLY / DIG the box is empty: DisappearUser ClearBoxes it
-- (engine/battle/misc.asm:1-13), AppearUserRaiseSub puts it back on the
-- stored attack (engine/battle/effect_commands.asm:2113-2117).
if not (trainerBack or enemyTrainer) and BattleState.isVanished(mon)
and not (self.vanishAnim and self.vanishAnim == self.anim) then
return
end
local G = love.graphics local G = love.graphics
local w, h = image:getDimensions() local w, h = image:getDimensions()
local px, py local px, py
@@ -582,10 +601,12 @@ function BattleState:drawPic(mon, back)
py = BattleState.PLAYER_PIC_TILE_Y * 8 + (box - h) py = BattleState.PLAYER_PIC_TILE_Y * 8 + (box - h)
boxTiles = BattleState.PLAYER_PIC_TILES boxTiles = BattleState.PLAYER_PIC_TILES
else else
-- Bottom-aligned and horizontally centred inside the 7x7 box. -- PadFrontpic pads a short pic and never a long one
-- (engine/gfx/load_pics.asm:342-386), so an oversized mod pic pins to the
-- box's own corner at hlcoord 12, 0 rather than to a negative offset.
local box = BattleState.ENEMY_PIC_TILES * 8 local box = BattleState.ENEMY_PIC_TILES * 8
px = BattleState.ENEMY_PIC_TILE_X * 8 + math.floor((box - w) / 2) px = BattleState.ENEMY_PIC_TILE_X * 8 + math.max(0, math.floor((box - w) / 2))
py = BattleState.ENEMY_PIC_TILE_Y * 8 + (box - h) py = BattleState.ENEMY_PIC_TILE_Y * 8 + math.max(0, box - h)
boxTiles = BattleState.ENEMY_PIC_TILES boxTiles = BattleState.ENEMY_PIC_TILES
end end
-- One tile per two frames to the right, SlideBattlePicOut's own step. -- One tile per two frames to the right, SlideBattlePicOut's own step.
@@ -903,11 +924,12 @@ function BattleState:startAnim(key, opts)
param = opts.param or 0, param = opts.param or 0,
sfxOrder = audio.sfxOrder, sfxOrder = audio.sfxOrder,
ballPalette = opts.ballPalette, ballPalette = opts.ballPalette,
-- BGEffect_CheckFlyDigStatus reads wPlayerSubStatus3 / wEnemySubStatus3
-- (engine/battle_anims/bg_effects.asm:2838-2851); the port keeps that bit
-- on the mon's volatile table, not on the mon itself.
flying = { flying = {
player = self.battle and self.battle.player player = BattleState.isVanished(self.battle and self.battle.player),
and self.battle.player.vanished or false, enemy = BattleState.isVanished(self.battle and self.battle.enemy),
enemy = self.battle and self.battle.enemy
and self.battle.enemy.vanished or false,
}, },
hooks = { hooks = {
-- anim_sound (engine/battle_anims/anim_commands.asm:1105) calls -- anim_sound (engine/battle_anims/anim_commands.asm:1105) calls
@@ -1365,6 +1387,13 @@ function BattleState:advanceQueue()
end end
end end
end end
-- BattleCommand_Charge runs LoadMoveAnim BEFORE DisappearUser
-- (engine/battle/effect_commands.asm:5459-5470), so FLY / DIG still draw
-- the take-off or the burrow on the turn the substatus goes up; the box
-- only empties once THIS animation is done with.
if BattleState.isVanished(self:activeMon(event.side)) then
self.vanishAnim = self.anim
end
elseif event.kind == "damage" and event.side then elseif event.kind == "damage" and event.side then
-- ANIM_x_DAMAGE is the MOVE's after-anim (effect_commands.asm:1963-1972), -- ANIM_x_DAMAGE is the MOVE's after-anim (effect_commands.asm:1963-1972),
-- so only a move hit gets it; `animMove` is HandleWrap's (core.asm:1198-1203). -- so only a move hit gets it; `animMove` is HandleWrap's (core.asm:1198-1203).
@@ -1864,6 +1893,20 @@ function BattleState:update(_dt)
return return
end end
-- The prompt's earlier pages: PlaceYesNoBox only follows the LAST one
-- (engine/battle/core.asm:3302-3305), so the mon is named and read first.
if self.phase == "shift-intro" then
if self.messageTimer > 0 then
if input:wasPressed("a") or input:wasPressed("b") then
self.messageTimer = 0
end
return
end
self:nextPage()
if not self.messagePages then self.phase = "ask-shift" end
return
end
-- OfferSwitch's YesNoBox: YES opens PickSwitchMonInBattle, NO (and B) falls -- OfferSwitch's YesNoBox: YES opens PickSwitchMonInBattle, NO (and B) falls
-- straight through to the enemy's send-out (engine/battle/core.asm:3305-3310). -- straight through to the enemy's send-out (engine/battle/core.asm:3305-3310).
if self.phase == "ask-shift" then if self.phase == "ask-shift" then
@@ -2464,13 +2507,13 @@ end
-- (engine/battle/core.asm:3298-3304, data/text/battle.asm:222-231). -- (engine/battle/core.asm:3298-3304, data/text/battle.asm:222-231).
function BattleState:offerShiftSwitch(mon) function BattleState:offerShiftSwitch(mon)
self.shiftIndex = 1 self.shiftIndex = 1
self.phase = "ask-shift"
local trainer = (self.battle.trainer and self.battle.trainer.name) or "Foe" local trainer = (self.battle.trainer and self.battle.trainer.name) or "Foe"
local player = (self.save and self.save.player and self.save.player.name) local player = (self.save and self.save.player and self.save.player.name)
or "GOLD" or "GOLD"
self.message = trainer .. " is about to use " .. self:name(mon) -- The `para` splits this in two (data/text/battle.asm:222-231): the incoming
.. ". Will " .. player .. " change POKéMON?" -- mon is NAMED on its own page, and only the second carries the yes/no box.
self.messageTimer = MESSAGE_FRAMES self:showPages(Strings(TEXT_ENEMY_ABOUT_TO_USE, trainer, self:name(mon), player))
self.phase = self.messagePages and "shift-intro" or "ask-shift"
end end
function BattleState:answerUseNextMon(yes) function BattleState:answerUseNextMon(yes)
+80
View File
@@ -0,0 +1,80 @@
-- #1091: does GAME SPEED change the wild encounter rate per STEP?
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1091_test.lua love .
local Encounter = require("src.battle.gen2.Encounter")
local Player = require("src.world.gen2.Player")
local Mon = require("src.battle.gen2.Mon")
return function(game)
local rolls, hits, steps = 0, 0, 0
local realTriggers = Encounter.triggers
Encounter.triggers = function(rate, random)
rolls = rolls + 1
if realTriggers(rate, random) then hits = hits + 1 end
return false
end
local World = require("src.world.gen2.World")
local ticks = 0
local realWorldStep = World.step
World.step = function(self)
ticks = ticks + 1
return realWorldStep(self)
end
local realUpdate = Player.update
Player.update = function(self)
local landed = realUpdate(self)
if landed then steps = steps + 1 end
return landed
end
local function wait(n) for _ = 1, n do coroutine.yield() end end
local function clearDirs()
for _, d in ipairs({ "up", "down", "left", "right" }) do
game.input.state[d] = false
end
end
wait(30)
local world = game.world
game.save.party = { Mon.new(game.data, "PIDGEY", 5) }
local function measure(speed, yields)
world:setMap("ROUTE_29", 44, 12, "right")
wait(20)
world.wildCooldown = 0
game.speedOverride = speed
rolls, hits, steps, ticks = 0, 0, 0, 0
local dir = "right"
for i = 1, yields do
if i % 8 == 0 then dir = (dir == "right") and "left" or "right" end
clearDirs()
game.input.state[dir] = true
table.insert(game.input.pressQueue, dir)
coroutine.yield()
end
clearDirs()
game.speedOverride = 1
wait(5)
return rolls, hits, steps, ticks
end
local r1, h1, s1, t1 = measure(1, 900)
local r2, h2, s2, t2 = measure(200, 60)
print(("[driver] 1X frames=%d cells=%d rolls=%d hits=%d rolls/cell=%.3f")
:format(t1, s1, r1, h1, s1 > 0 and r1 / s1 or 0))
print(("[driver] 200X frames=%d cells=%d rolls=%d hits=%d rolls/cell=%.3f")
:format(t2, s2, r2, h2, s2 > 0 and r2 / s2 or 0))
Encounter.triggers = realTriggers
Player.update = realUpdate
World.step = realWorldStep
local rate1 = s1 > 0 and r1 / s1 or 0
local rate2 = s2 > 0 and r2 / s2 or 0
assert(s1 > 20 and s2 > 20, "not enough walked cells to judge")
assert(math.abs(rate1 - rate2) < 0.05,
("rolls per cell changed with GAME SPEED: %.3f vs %.3f"):format(rate1, rate2))
print("[driver] PASS encounter rolls are one per walked cell at every speed")
end
+79
View File
@@ -0,0 +1,79 @@
-- #1097 (and the absorbed #1116): walking out of Dark Cave and pressing UP
-- puts the player inside the mountain on Route 31.
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1097_test.lua love .
local Mon = require("src.battle.gen2.Mon")
return function(game)
local function wait(n) for _ = 1, n do coroutine.yield() end end
local function clearDirs()
for _, d in ipairs({ "up", "down", "left", "right" }) do
game.input.state[d] = false
end
end
local function hold(dir, frames, trace)
local last
for _ = 1, frames do
clearDirs()
game.input.state[dir] = true
table.insert(game.input.pressQueue, dir)
coroutine.yield()
local w = game.world
local at = ("%s (%d,%d)"):format(w.map.id, w.player.cellX, w.player.cellY)
if trace and at ~= last then print(" " .. at); last = at end
end
clearDirs()
end
wait(30)
local world = game.world
game.save.party = { Mon.new(game.data, "PIDGEY", 5) }
-- Route31CheckMomCallCallback (maps/Route31.asm:14) fires on every NEWMAP
-- arrival until the errand is over; set its event so the walk is not spent
-- behind a phone call. tests/drivers/gold/flag_names.lua:1132.
if world.events then world.events:set(64, true) end
-- maps/DarkCaveVioletEntrance.asm warp 1 is (3,15) -> ROUTE_31 warp 3.
world:setMap("DARK_CAVE_VIOLET_ENTRANCE", 3, 14, "down")
wait(20)
hold("down", 40)
-- the ROUTE_31 arrival runs Route31CheckMomCallCallback (maps/Route31.asm:14)
for _ = 1, 900 do
if not world:busy() then break end
coroutine.yield()
end
wait(20)
print(("[driver] out of the cave at %s (%d,%d)")
:format(world.map.id, world.player.cellX, world.player.cellY))
assert(world.map.id == "ROUTE_31", "did not reach ROUTE_31")
print("[driver] holding UP:")
hold("up", 120, true)
wait(30)
local m, x, y = world.map.id, world.player.cellX, world.player.cellY
print(("[driver] ended at %s (%d,%d)"):format(m, x, y))
-- Anything above y=5 on Route 31 in that column is the inside of the
-- mountain; the honest outcomes are re-entering the cave or bumping.
local inside = (m == "ROUTE_31" and y < 5)
assert(not inside, ("walked into the mountain at (%d,%d)"):format(x, y))
-- The other two Dark Cave mouths, same shape: maps/Route46.asm warp 3 is
-- (14,5) and maps/Route45.asm warp 1 is (2,5), both COLL_CAVE tiles with
-- ordinary FLOOR above them in the collision data.
for _, c in ipairs({ { "ROUTE_46", 14, 5 }, { "ROUTE_45", 2, 5 } }) do
local mapId, wx, wy = c[1], c[2], c[3]
world:setMap(mapId, wx, wy, "up")
wait(20)
hold("up", 90)
wait(20)
if world.map.id == mapId then
assert(world.player.cellY >= wy,
("%s: walked above the cave mouth to (%d,%d)")
:format(mapId, world.player.cellX, world.player.cellY))
end
print(("[driver] %s mouth ended at %s (%d,%d)"):format(
mapId, world.map.id, world.player.cellX, world.player.cellY))
end
print("[driver] PASS the cave mouth cannot be walked through")
end
+78
View File
@@ -0,0 +1,78 @@
-- #1104: the Ruins of Alph ladders warp somewhere else.
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1104_test.lua love .
local Mon = require("src.battle.gen2.Mon")
-- maps/RuinsOfAlphInnerChamber.asm:76, maps/RuinsOfAlphOutside.asm:126,
-- maps/RuinsOfAlphKabutoChamber.asm and its three siblings.
local CASES = {
{ "RUINS_OF_ALPH_INNER_CHAMBER", 10, 13, "RUINS_OF_ALPH_OUTSIDE", 10, 13 },
{ "RUINS_OF_ALPH_OUTSIDE", 10, 13, "RUINS_OF_ALPH_INNER_CHAMBER", 10, 13 },
{ "RUINS_OF_ALPH_OUTSIDE", 2, 17, "RUINS_OF_ALPH_HO_OH_CHAMBER", 3, 9 },
{ "RUINS_OF_ALPH_OUTSIDE", 14, 7, "RUINS_OF_ALPH_KABUTO_CHAMBER", 3, 9 },
{ "RUINS_OF_ALPH_OUTSIDE", 2, 29, "RUINS_OF_ALPH_OMANYTE_CHAMBER", 3, 9 },
{ "RUINS_OF_ALPH_OUTSIDE", 16, 33, "RUINS_OF_ALPH_AERODACTYL_CHAMBER", 3, 9 },
{ "RUINS_OF_ALPH_OUTSIDE", 17, 11, "RUINS_OF_ALPH_RESEARCH_CENTER", 2, 7 },
{ "RUINS_OF_ALPH_KABUTO_CHAMBER", 3, 9, "RUINS_OF_ALPH_OUTSIDE", 14, 7, "down" },
{ "RUINS_OF_ALPH_HO_OH_CHAMBER", 3, 9, "RUINS_OF_ALPH_OUTSIDE", 2, 17, "down" },
{ "RUINS_OF_ALPH_OMANYTE_CHAMBER", 3, 9, "RUINS_OF_ALPH_OUTSIDE", 2, 29, "down" },
{ "RUINS_OF_ALPH_AERODACTYL_CHAMBER", 3, 9, "RUINS_OF_ALPH_OUTSIDE", 16, 33, "down" },
{ "RUINS_OF_ALPH_RESEARCH_CENTER", 2, 7, "RUINS_OF_ALPH_OUTSIDE", 17, 11, "down" },
}
return function(game)
local function wait(n) for _ = 1, n do coroutine.yield() end end
local function clearDirs()
for _, d in ipairs({ "up", "down", "left", "right" }) do
game.input.state[d] = false
end
end
-- Hold until the map changes, then let the load settle and STOP: holding
-- past the arrival would walk the player off the landing tile.
local function walkUntilWarp(dir, frames, fromMap)
for _ = 1, frames do
clearDirs()
game.input.state[dir] = true
table.insert(game.input.pressQueue, dir)
coroutine.yield()
if game.world.map and game.world.map.id ~= fromMap then
clearDirs()
-- The ARRIVAL cell is the one the warp table names. Standing on a
-- door/cave/staircase tile then forces one step DOWN off it
-- (engine/overworld/player_movement.asm:201-213), so the settled cell
-- is legitimately one row lower on an outdoor mouth.
local ax, ay = game.world.player.cellX, game.world.player.cellY
for _ = 1, 45 do coroutine.yield() end
return game.world.map.id, ax, ay,
game.world.player.cellX, game.world.player.cellY
end
end
clearDirs()
return game.world.map and game.world.map.id, nil, nil
end
wait(30)
local world = game.world
game.save.party = { Mon.new(game.data, "PIDGEY", 5) }
local bad = 0
for _, c in ipairs(CASES) do
local src, wx, wy, wantMap, wantX, wantY = c[1], c[2], c[3], c[4], c[5], c[6]
-- default approach is from below, the way the report describes facing the
-- ladder from below; a chamber's exit sits on the bottom row instead
local dir = c[7] or "up"
local sy = (dir == "up") and wy + 1 or wy - 1
world:setMap(src, wx, sy, dir)
wait(12)
local from = ("%s (%d,%d)"):format(src, world.player.cellX, world.player.cellY)
local m, ax, ay, sx, sy = walkUntilWarp(dir, 90, src)
local ok = (m == wantMap and ax == wantX and ay == wantY)
if not ok then bad = bad + 1 end
print(("[driver] %-12s %s -> %s (%s,%s) settled (%s,%s) want %s (%d,%d)")
:format(ok and "OK" or "MISMATCH", from, tostring(m),
tostring(ax), tostring(ay), tostring(sx), tostring(sy),
wantMap, wantX, wantY))
end
print(("[driver] %s ruins warps: %d mismatches")
:format(bad == 0 and "PASS" or "FAIL", bad))
end
+100
View File
@@ -0,0 +1,100 @@
-- #1155: "MAGNITUDE always rolls a 4".
--
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1155_test.lua love .
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local ROLLS = 15
local function battleScreen(game)
for _ = 1, 900 do
local top = game.stack:top()
if top and top.battle then return top end
U.wait(1)
end
error("battle screen never came up")
end
local function drain(game, screen, frames)
for _ = 1, (frames or 400) do
if screen.phase == "menu" and #screen.queue == 0 and not screen.anim then
return true
end
U.tap(game, "a")
U.wait(3)
end
return false
end
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1155"
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local caster = Mon.new(game.data, "DUNSPARCE", 50)
local def = assert(game.data.moves.MAGNITUDE, "MAGNITUDE is not in moves.lua")
caster.moves = { { id = "MAGNITUDE", pp = def.pp, maxPp = def.pp } }
print("[driver] MAGNITUDE stored effect = " .. tostring(def.effect)
.. ", stored power = " .. tostring(def.power))
game.save.party = { caster }
local seen, damages, shown = {}, {}, 0
local screen
for i = 1, ROLLS do
if not (screen and game.stack:top() == screen and not screen.battle.over) then
local dummy = Mon.new(game.data, "SNORLAX", 60)
dummy.moves = { { id = "SPLASH", pp = 40, maxPp = 40 } }
assert(world:startBattle({ wild = dummy }), "startBattle failed")
screen = battleScreen(game)
drain(game, screen, 300)
end
local foe = screen.battle.enemy
local before = foe.hp
caster.moves[1].pp = def.pp
screen:submit({ kind = "move", move = "MAGNITUDE" })
for _, event in ipairs(screen.queue) do
local number = event.text and event.text:match("^Magnitude (%d+)")
if number then
seen[number] = (seen[number] or 0) + 1
if shown < 1 then
U.wait(1)
U.shot(game, out .. "/01-magnitude-text.png")
shown = 1
end
end
end
drain(game, screen, 400)
local dealt = before - foe.hp
if dealt > 0 then damages[dealt] = true end
if i % 10 == 0 then U.wait(5) end
end
local numbers = {}
for number, count in pairs(seen) do
numbers[#numbers + 1] = number .. "x" .. count
end
table.sort(numbers)
print("[driver] magnitudes over " .. ROLLS .. " uses: "
.. table.concat(numbers, " "))
local distinctPowers = 0
for _ in pairs(damages) do distinctPowers = distinctPowers + 1 end
local failures = {}
local function check(ok, what)
print((ok and "[ok] " or "[FAIL] ") .. what)
if not ok then failures[#failures + 1] = what end
end
check(#numbers > 1, "MAGNITUDE rolls more than one number")
check(seen["4"] == nil or seen["4"] < ROLLS,
"and is not stuck on MAGNITUDE 4")
check(distinctPowers > 1, "the rolled power reaches the damage (" ..
distinctPowers .. " distinct damage figures)")
if #failures > 0 then
error(#failures .. " magnitude checks failed")
end
print("[driver] #1155 clear; shot in " .. out)
end
+113
View File
@@ -0,0 +1,113 @@
-- #1157: SONIC BOOM has to deal a flat 20, not a rolled 8-10.
--
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1157_test.lua love .
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local function battleScreen(game)
for _ = 1, 900 do
local top = game.stack:top()
if top and top.battle then return top end
U.wait(1)
end
error("battle screen never came up")
end
local function drain(game, screen, frames)
for _ = 1, (frames or 400) do
if screen.phase == "menu" and #screen.queue == 0 and not screen.anim then
return true
end
U.tap(game, "a")
U.wait(3)
end
return false
end
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1157"
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local failures = {}
local function check(ok, what)
print((ok and "[ok] " or "[FAIL] ") .. what)
if not ok then failures[#failures + 1] = what end
end
-- The reporter's own matchup, three times over.
local caster = Mon.new(game.data, "MAGNEMITE", 20)
local boom = assert(game.data.moves.SONICBOOM, "SONICBOOM missing")
print("[driver] SONICBOOM effect = " .. tostring(boom.effect)
.. ", stored power = " .. tostring(boom.power))
caster.moves = { { id = "SONICBOOM", pp = boom.pp, maxPp = boom.pp } }
game.save.party = { caster }
-- SONIC BOOM is a 90% move, so a miss (0) is legal and simply does not
-- count: what matters is that every use that CONNECTS is 20.
local dealt, hits, wrong = {}, 0, 0
for i = 1, 6 do
local rattata = Mon.new(game.data, "RATTATA", 11)
rattata.moves = { { id = "SPLASH", pp = 40, maxPp = 40 } }
assert(world:startBattle({ wild = rattata }), "startBattle failed")
local screen = battleScreen(game)
drain(game, screen, 300)
local before = rattata.hp
caster.hp = caster.maxHp or caster.hp
caster.moves[1].pp = boom.pp
screen:submit({ kind = "move", move = "SONICBOOM" })
drain(game, screen, 400)
dealt[i] = before - rattata.hp
if dealt[i] > 0 then
hits = hits + 1
if dealt[i] ~= 20 then wrong = wrong + 1 end
end
if i == 1 then U.shot(game, out .. "/01-sonicboom.png") end
for _ = 1, 400 do
if not game.stack:top() or not game.stack:top().battle then break end
U.tap(game, "a")
U.wait(3)
end
U.wait(45)
end
print("[driver] SONIC BOOM dealt " .. table.concat(dealt, ", ")
.. " (0 is a miss)")
check(hits >= 3, "SONIC BOOM connected at least three times")
check(wrong == 0, "and every hit was exactly 20")
-- The two siblings on the same command.
local dragon = Mon.new(game.data, "DRATINI", 25)
local rage = assert(game.data.moves.DRAGON_RAGE, "DRAGON_RAGE missing")
local toss = assert(game.data.moves.SEISMIC_TOSS, "SEISMIC_TOSS missing")
dragon.moves = {
{ id = "DRAGON_RAGE", pp = rage.pp, maxPp = rage.pp },
{ id = "SEISMIC_TOSS", pp = toss.pp, maxPp = toss.pp },
}
game.save.party = { dragon }
local target = Mon.new(game.data, "SNORLAX", 60)
target.moves = { { id = "SPLASH", pp = 40, maxPp = 40 } }
assert(world:startBattle({ wild = target }), "startBattle failed")
local screen = battleScreen(game)
drain(game, screen, 300)
local before = target.hp
screen:submit({ kind = "move", move = "DRAGON_RAGE" })
drain(game, screen, 400)
local rageDealt = before - target.hp
before = target.hp
screen:submit({ kind = "move", move = "SEISMIC_TOSS" })
drain(game, screen, 400)
local tossDealt = before - target.hp
U.shot(game, out .. "/02-siblings.png")
print(("[driver] DRAGON RAGE %d, SEISMIC TOSS %d (user is L%d)")
:format(rageDealt, tossDealt, dragon.level))
check(rageDealt == 40, "DRAGON RAGE deals exactly 40")
check(tossDealt == dragon.level, "SEISMIC TOSS deals the user's level")
if #failures > 0 then
error(#failures .. " constant-damage checks failed")
end
print("[driver] #1157 fixed; shots in " .. out)
end
+96
View File
@@ -0,0 +1,96 @@
-- #1158: you could not see the trainer's incoming mon before the switch offer.
--
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1158_test.lua love .
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local function battleScreen(game)
for _ = 1, 900 do
local top = game.stack:top()
if top and top.battle then return top end
U.wait(1)
end
error("battle screen never came up")
end
local function runToPhase(game, screen, phase, frames)
for _ = 1, (frames or 600) do
if screen.phase == phase then return true end
U.tap(game, "a")
U.wait(3)
end
return false
end
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1158"
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local failures = {}
local function check(ok, what)
print((ok and "[ok] " or "[FAIL] ") .. what)
if not ok then failures[#failures + 1] = what end
end
-- SHIFT is the only style that offers at all (CheckWhetherToAskSwitch).
game.options = game.options or {}
game.options.battleStyle = "SHIFT"
local lead = Mon.new(game.data, "TYPHLOSION", 40)
lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
local bench = Mon.new(game.data, "FERALIGATR", 40)
bench.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
game.save.party = { lead, bench }
local foe1 = Mon.new(game.data, "PIDGEY", 5)
foe1.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
local foe2 = Mon.new(game.data, "PIDGEOTTO", 20)
foe2.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
assert(world:startBattle({ trainer = { class = "FALKNER", name = "FALKNER",
party = { foe1, foe2 } } }), "trainer startBattle failed")
local screen = battleScreen(game)
runToPhase(game, screen, "menu", 400)
foe1.hp = 1
screen:submit({ kind = "move", move = "TACKLE" })
-- Page by page, collecting what the box actually showed.
local pages, sawBoxWithName = {}, false
local incoming = foe2.nickname or foe2.species or "PIDGEOTTO"
for _ = 1, 900 do
local message = screen.message
if message and pages[#pages] ~= message
and (screen.phase == "shift-intro" or screen.phase == "ask-shift") then
pages[#pages + 1] = message
if #pages == 1 then U.shot(game, out .. "/01-first-page.png") end
if message:find(incoming, 1, true) then
U.shot(game, out .. "/02-names-the-mon.png")
sawBoxWithName = true
end
end
if screen.phase == "ask-shift" and (screen.messageTimer or 0) <= 0 then
break
end
U.tap(game, "a")
U.wait(4)
end
U.shot(game, out .. "/03-yes-no.png")
for i, page in ipairs(pages) do
print(("[driver] page %d: %s"):format(i, (page:gsub("\n", " / "))))
end
check(sawBoxWithName,
"the incoming " .. incoming .. " is named in the box before the yes/no")
check(screen.phase == "ask-shift",
"and the prompt still ends on the YES/NO question")
check(#pages > 1, "the offer runs as pages, not one over-long line")
if #failures > 0 then
error(#failures .. " send-out order checks failed")
end
print("[driver] #1158 fixed; shots in " .. out)
end
+131
View File
@@ -0,0 +1,131 @@
-- #1159 (Fly / Dig leave the user on the field) and #1160 (64x64 front pics).
--
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1159_test.lua love .
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local BattleState = require("src.ui.gen2.BattleState")
local function battleScreen(game)
for _ = 1, 900 do
local top = game.stack:top()
if top and top.battle then return top end
U.wait(1)
end
error("battle screen never came up")
end
local function drain(game, screen, frames)
for _ = 1, (frames or 400) do
if screen.phase == "menu" and #screen.queue == 0 and not screen.anim then
return true
end
U.tap(game, "a")
U.wait(3)
end
return false
end
-- One drawPic call with love.graphics.draw intercepted: how many blits it made
-- and where the first one landed.
local function probePic(screen, mon, back)
local G = love.graphics
local real = G.draw
local count, x, y = 0, nil, nil
G.draw = function(image, a, b, ...)
count = count + 1
if count == 1 then
-- draw(image, x, y, ...) or draw(image, quad, x, y, ...)
if type(a) == "number" then x, y = a, b else x, y = b, (...) end
end
end
local ok, err = pcall(screen.drawPic, screen, mon, back)
G.draw = real
if not ok then error(err) end
return count, x, y
end
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1159"
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local failures = {}
local function check(ok, what)
print((ok and "[ok] " or "[FAIL] ") .. what)
if not ok then failures[#failures + 1] = what end
end
------------------------------------------------------------------ #1159
local digger = Mon.new(game.data, "SANDSHREW", 30)
local dig = assert(game.data.moves.DIG, "DIG missing from the Gold cache")
digger.moves = { { id = "DIG", pp = dig.pp, maxPp = dig.pp } }
game.save.party = { digger }
local target = Mon.new(game.data, "SNORLAX", 40)
target.moves = { { id = "SPLASH", pp = 40, maxPp = 40 } }
assert(world:startBattle({ wild = target }), "startBattle failed")
local screen = battleScreen(game)
drain(game, screen, 300)
local before = probePic(screen, screen.battle.player, true)
check(before > 0, "the back pic is on the field before DIG")
U.shot(game, out .. "/01-before-dig.png")
screen:submit({ kind = "move", move = "DIG" })
drain(game, screen, 600)
check(BattleState.isVanished(screen.battle.player) == true,
"SUBSTATUS_UNDERGROUND is set after the charge turn")
local underground = probePic(screen, screen.battle.player, true)
print("[driver] back-pic draws while underground: " .. underground)
check(underground == 0, "the back pic is GONE for the charge turn")
U.shot(game, out .. "/02-underground.png")
-- The enemy front pic is untouched by the player's own dig.
local foe = probePic(screen, screen.battle.enemy, false)
check(foe > 0, "the enemy front pic is unaffected")
-- Turn two: CheckCharge clears the bit and AppearUserRaiseSub redraws it.
screen:submit({ kind = "move", move = "DIG" })
drain(game, screen, 600)
check(BattleState.isVanished(screen.battle.player) == false,
"the substatus is cleared when the stored attack lands")
local after = probePic(screen, screen.battle.player, true)
print("[driver] back-pic draws after the attack: " .. after)
check(after > 0, "the back pic is back on the field")
U.shot(game, out .. "/03-resurfaced.png")
------------------------------------------------------------------ #1160
-- A stubbed pic of each size through the same placement code. trueColor is
-- set so the probe never enters the GBC palette shader.
local realPic = screen.pic
local function place(size, back)
local data = love.image.newImageData(size, size)
local image = love.graphics.newImage(data)
screen.pic = function() return image, true, "mods/front" .. size .. ".png" end
local n, x, y = probePic(screen, screen.battle.enemy, back)
screen.pic = realPic
return n, x, y
end
local _, x56, y56 = place(56, false)
print(("[driver] 56x56 front at (%s, %s)"):format(tostring(x56), tostring(y56)))
check(x56 == 96 and y56 == 0, "vanilla 56x56 front pic is unmoved at (96, 0)")
local _, x40, y40 = place(40, false)
print(("[driver] 40x40 front at (%s, %s)"):format(tostring(x40), tostring(y40)))
check(x40 == 104 and y40 == 16,
"a small 40x40 front pic still stands on the box's ground line")
local _, x64, y64 = place(64, false)
print(("[driver] 64x64 front at (%s, %s)"):format(tostring(x64), tostring(y64)))
check(x64 == 96 and y64 == 0,
"a 64x64 front pic pins to the box corner and overlaps the HUD")
if #failures > 0 then
error(#failures .. " checks failed")
end
print("[driver] #1159 / #1160 fixed; shots in " .. out)
end
+82
View File
@@ -0,0 +1,82 @@
-- #1167: the Ilex Forest hidden Ether and hidden Super Potion.
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1167_test.lua love .
local Mon = require("src.battle.gen2.Mon")
local HiddenItems = require("src.world.gen2.HiddenItems")
local CASES = {
{ name = "ETHER", x = 27, y = 1, event = 136 },
{ name = "SUPER_POTION", x = 17, y = 7, event = 137 },
{ name = "FULL_HEAL", x = 9, y = 17, event = 138 },
}
local FACE = {
up = { 0, 1 }, down = { 0, -1 }, left = { 1, 0 }, right = { -1, 0 },
}
return function(game)
local function wait(n) for _ = 1, n do coroutine.yield() end end
local function tap(btn)
table.insert(game.input.pressQueue, btn)
game.input.state[btn] = true
coroutine.yield()
game.input.state[btn] = false
coroutine.yield()
end
wait(30)
local world = game.world
game.save.party = { Mon.new(game.data, "PIDGEY", 5) }
world:setMap("ILEX_FOREST", 9, 18, "up")
wait(30)
assert(world.map.id == "ILEX_FOREST", "not in ILEX_FOREST")
-- what the engine itself thinks it has for this map
local listed = HiddenItems.unfound(world.map.def, world.events)
print("[driver] engine hidden items on ILEX_FOREST:")
for _, row in ipairs(listed) do
print((" (%2d,%2d) item=%s flag=%d")
:format(row.x, row.y, tostring(row.item), row.event))
end
local function bagCount(name)
local n = 0
for _, row in ipairs((game.save.bag and game.save.bag.items) or {}) do
if row.item == name or row.id == name then n = n + (row.count or 1) end
end
return n
end
local bad = 0
for _, c in ipairs(CASES) do
local got = false
for dir, d in pairs(FACE) do
local sx, sy = c.x + d[1], c.y + d[2]
if world.map:isWalkable(sx, sy) then
world:setMap("ILEX_FOREST", sx, sy, dir)
wait(20)
world.player.facing = dir
local before = bagCount(c.name)
tap("a")
wait(60)
for _ = 1, 240 do
if not world:busy() then break end
tap("a")
end
wait(20)
if world.events:get(c.event) or bagCount(c.name) > before then
got = true
print(("[driver] OK %-13s at (%2d,%2d) picked up facing %s")
:format(c.name, c.x, c.y, dir))
break
end
end
end
if not got then
bad = bad + 1
print(("[driver] MISSING %-13s at (%2d,%2d)"):format(c.name, c.x, c.y))
end
end
print(("[driver] %s ilex hidden items: %d missing")
:format(bad == 0 and "PASS" or "FAIL", bad))
end
+48
View File
@@ -0,0 +1,48 @@
-- #1208: the launcher's save-slot line read a Gold save with Gen 1 eyes
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1208_test.lua love .
local U = require("tests.drivers.util")
local SaveData = require("src.core.SaveData")
local GoldSave = require("src.core.gen2.Save")
return function(game)
U.wait(45)
local save = game.save
assert(save and save.player, "gold save did not boot")
save.player.badges = { ZEPHYR = true, HIVE = true, PLAIN = true, FOG = true,
STORM = true, MINERAL = true, GLACIER = true, RISING = true }
save.player.kantoBadges = { BOULDER = true, CASCADE = true }
save.pokedex = save.pokedex or {}
save.pokedex.caught = { [155] = true, [158] = true, [152] = true }
local ok, err = game:writeSave()
if not ok then
U.log("FAIL gold writeSave:", tostring(err))
else
local want = GoldSave.summary(save)
local active = SaveData.activeSlot("gold")
local row
for _, slot in ipairs(SaveData.listSlots("gold")) do
if slot.id == active then row = slot end
end
if not (row and row.meta) then
U.log("FAIL launcher has no slot row for", tostring(active))
else
U.log(("launcher: %d badges - %s - %d caught"):format(
row.meta.badges, row.meta.timeText, row.meta.dexCount))
U.log(("continue: %d badges - %d:%02d - %d caught"):format(
want.badges, want.hours, want.minutes, want.caught))
if row.meta.badges == want.badges and row.meta.dexCount == want.caught then
U.log("PASS launcher summary matches CONTINUE for slot", tostring(active))
else
U.log("FAIL launcher summary disagrees with CONTINUE")
end
end
end
while true do
coroutine.yield()
end
end
+114
View File
@@ -0,0 +1,114 @@
-- #1100 / #1135: the launcher gear offered TOUCH PAD, VIBRATION and the
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")
-- The rows are mobile-gated the same way OptionsMenu's are, so the suite has
-- to look like a phone; POKEPORT_TOUCH=1 is the other way in.
local realGetOS = love.system.getOS
love.system.getOS = function() return "Android" end
local LauncherSettings = require("src.import.LauncherSettings")
local TouchControls = require("src.core.TouchControls")
local function labels(model)
local out = {}
for _, section in ipairs(model.sections) do
for _, row in ipairs(section.rows) do out[#out + 1] = row.label end
end
return out
end
local function findRow(model, label)
for _, section in ipairs(model.sections) do
for _, row in ipairs(section.rows) do
if row.label == label then return row end
end
end
end
local function has(model, label)
for _, l in ipairs(labels(model)) do
if l == label then return true end
end
return false
end
local edited = 0
local hooks = { editTouchControls = function() edited = edited + 1 end }
local gold = LauncherSettings.open(hooks, "gold")
check(has(gold, "TOUCH PAD"), "Gold's gear offers TOUCH PAD")
check(has(gold, "VIBRATION"), "and VIBRATION")
check(has(gold, "TOUCH CONTROLS"), "and the layout editor")
-- Every write has to land in the gold block: the flat keys beside it are
-- Red's, and Gold's boot never reads them (src/core/gen2/Save.lua:299).
-- loadOptions seeds the flat Gen 1 defaults, so the check is that the Gold
-- rows leave them exactly as they found them.
local flatPad = gold.opts.touchControls
local flatBuzz = gold.opts.haptics
local pad = findRow(gold, "TOUCH PAD")
local before = pad.value()
pad.step(1)
check(pad.value() ~= before, "stepping TOUCH PAD flips it")
check(type(gold.opts.gold) == "table", "into the gold block")
eq(gold.opts.gold.touchControls.enabled, false, "which now carries enabled")
eq(gold.opts.touchControls, flatPad, "leaving the flat Gen 1 key alone")
local buzz = findRow(gold, "VIBRATION")
local buzzBefore = buzz.value()
buzz.step(1)
check(buzz.value() ~= buzzBefore, "stepping VIBRATION moves the level")
eq(gold.opts.gold.haptics, TouchControls.normalizeHaptics(gold.opts.gold.haptics),
"VIBRATION stores a level the shared module knows")
eq(gold.opts.haptics, flatBuzz, "also without touching Red's")
findRow(gold, "TOUCH CONTROLS").action()
eq(edited, 1, "the editor row reaches the host hook")
-- The Gen 1 gear is untouched by the extraction: same three rows, still on
-- the flat table.
local red = LauncherSettings.open(hooks, "red")
check(has(red, "TOUCH PAD"), "Red still offers TOUCH PAD")
check(has(red, "VIBRATION"), "and VIBRATION")
check(has(red, "TOUCH CONTROLS"), "and the layout editor")
findRow(red, "TOUCH PAD").step(1)
eq(red.opts.touchControls.enabled, false, "writing to the flat key")
eq(red.opts.gold, nil, "with no gold block invented")
eq(findRow(red, "TOUCH PAD").value() ~= nil, true, "and reading it back")
-- With no hook (the standalone save editor's shape) the editor row is gone
-- rather than dead, on both sides.
eq(has(LauncherSettings.open(nil, "gold"), "TOUCH CONTROLS"), false,
"no hook, no editor row on Gold")
eq(has(LauncherSettings.open(nil, "red"), "TOUCH CONTROLS"), false,
"nor on Red")
-- The Edit row hands the screen to the host, and the host has to know WHICH
-- game's block to write the dragged layout into: TouchControlsEditor persists
-- to opts.gold only when it was loaded with version = "gold". Source-shape
-- checks, the same way tests/engine/touch_controls_pad_cursor_test.lua pins
-- the handoff either side of this one.
local function read(path)
local f = assert(io.open(path, "r"))
local src = f:read("*a")
f:close()
return src
end
local importerSrc = read("src/import/RomImporter.lua")
check(importerSrc:find("self.onEditTouchControls(version)", 1, true) ~= nil,
"the gear hands the launcher tab to the host")
local mainSrc = read("main.lua")
local body = mainSrc:match("local function openTouchControlsEditor%((.-)%)")
eq(body, "version", "main.lua's opener takes that tab")
check(mainSrc:match("TouchEditor%.load%({%s*version = version") ~= nil,
"and loads the editor with it, so Gold's layout lands in the gold block")
love.system.getOS = realGetOS
T.finish("launcher gold touch rows")
@@ -51,7 +51,9 @@ check(mainSrc:find("prepareOverlayHandoff", 1, true) ~= nil
and mainSrc:find("openTouchControlsEditor", 1, true) ~= nil, and mainSrc:find("openTouchControlsEditor", 1, true) ~= nil,
"main.lua mentions prepareOverlayHandoff + touch editor") "main.lua mentions prepareOverlayHandoff + touch editor")
-- prepare must run inside openTouchControlsEditor, not only openEditor -- prepare must run inside openTouchControlsEditor, not only openEditor
local touchOpen = mainSrc:match("local function openTouchControlsEditor%(%)(.-)\nend") -- The signature takes the launcher tab since #1100, so match any parameter
-- list rather than pinning the arity.
local touchOpen = mainSrc:match("local function openTouchControlsEditor%(.-%)(.-)\nend")
check(touchOpen ~= nil, "openTouchControlsEditor body found") check(touchOpen ~= nil, "openTouchControlsEditor body found")
check(touchOpen:find("prepareOverlayHandoff", 1, true) ~= nil, check(touchOpen:find("prepareOverlayHandoff", 1, true) ~= nil,
"openTouchControlsEditor prepares overlay handoff like the save editor") "openTouchControlsEditor prepares overlay handoff like the save editor")