Fix Gen 1/2 battle, menu, PC and audio parity gaps; wire luacheck into CI

CLOSES #1484, CLOSES #1486, CLOSES #1513, CLOSES #1517, CLOSES #1556, CLOSES #1564, CLOSES #1567
This commit is contained in:
bryanthaboi
2026-08-21 19:22:34 -04:00
parent 087a275189
commit c0f4315cd8
45 changed files with 2487 additions and 168 deletions
+21
View File
@@ -468,3 +468,24 @@ jobs:
if [ "$found" = "0" ]; then
echo "no committed mods to lint"
fi
luacheck:
name: engine lint (luacheck)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: install luacheck
run: |
set -e
sudo apt-get update
sudo apt-get install -y lua5.4 liblua5.4-dev luarocks
sudo luarocks install luacheck || sudo apt-get install -y lua-check
luacheck --version
- name: luacheck gate (undefined globals, unreachable code)
run: ./scripts/lint.sh --gate
- name: luacheck full report (advisory)
continue-on-error: true
run: ./scripts/lint.sh
+5
View File
@@ -23,6 +23,10 @@ read_globals = {
-- LuaJIT 2.1 ships table.unpack even though the bare 5.1 `table` std lacks
-- it; without this, every `table.unpack` reads as an undefined field.
table = { fields = { "unpack" } },
"POKEPORT_DISPLAY_COMPANION",
"POKEPORT_EDITOR_MODE",
"rawlen",
package = { fields = { "searchers" } },
}
-- Vendored/native trees and the test suites have their own conventions.
@@ -30,6 +34,7 @@ exclude_files = {
"mobile/",
"tests/",
"tools/save-editor/",
"tools/save_convert/vendor/",
}
ignore = {
+5 -4
View File
@@ -240,7 +240,7 @@ local function stepGate(opts)
push(game, text(game)[opts.text] or opts.fallback, function()
ow.player.facing = opts.push
if not ow:checkLedgeHop(opts.push) then
ow:scriptMove(ow.player, opts.push, 1)
ow:scriptMove(ow.player, opts.push, 1, nil, { collide = true })
end
end)
return true
@@ -820,7 +820,8 @@ M.PEWTER_POKECENTER = {
-- on the west-side cells and walks you back
local function bikeGateGuard(coords, stopText, explainText)
return function(game, ow, x, y)
if game.save.inventory.BICYCLE then return false end
local bike = game.save.inventory.BICYCLE
if bike and bike ~= 0 then return false end
if not inCoords(coords, x, y) then return false end
-- walk the player up to the tile beside the counter, no further:
-- (matchedY - closestY) tiles, 0 when already next to it
@@ -842,10 +843,10 @@ local function bikeGateGuard(coords, stopText, explainText)
-- (PlayerMovingRightScript). Without it the player was left
-- parked beside the guard's counter with no way past. #518
local function shoveRight()
ow:scriptMove(ow.player, "right", 1)
ow:scriptMove(ow.player, "right", 1, nil, { collide = true })
end
if dist > 0 then
ow:scriptMove(ow.player, "up", dist, shoveRight)
ow:scriptMove(ow.player, "up", dist, shoveRight, { collide = true })
else
shoveRight()
end
+3 -3
View File
@@ -24,10 +24,10 @@ local GameViewport = require("src.render.GameViewport")
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
do
local defaultErrorHandler = love.errorhandler
local defaultErrorHandler = love.errorhandler or love.errhand
function love.errorhandler(msg)
local hint = SwitchDiagnostics.logLuaError(msg)
if hint and type(msg) == "string" then
local ok, hint = pcall(SwitchDiagnostics.logLuaError, msg)
if ok and hint and type(msg) == "string" then
msg = msg .. "\n\n" .. hint
end
if defaultErrorHandler then
+21 -2
View File
@@ -7,7 +7,8 @@
# the nil global), unused values, unreachable code. The .luacheckrc mutes the
# cosmetic categories the codebase lives with, so what prints is worth a look.
#
# scripts/lint.sh lint src/
# scripts/lint.sh full advisory report over every shipped tree
# scripts/lint.sh --gate only the codes CI blocks on (0xx, 1xx, 511)
# scripts/lint.sh src tools lint specific paths
#
# Install once with: luarocks install luacheck
@@ -15,9 +16,27 @@
set -uo pipefail
cd "$(dirname "$0")/.."
DEFAULT_PATHS=(main.lua conf.lua src data/scripts mods tools)
GATE=0
if [ "${1:-}" = "--gate" ]; then
GATE=1
shift
fi
if ! command -v luacheck >/dev/null 2>&1; then
echo "luacheck not found on PATH (install: luarocks install luacheck)" >&2
exit 2
fi
luacheck "${@:-src}"
if [ "$#" -gt 0 ]; then
PATHS=("$@")
else
PATHS=("${DEFAULT_PATHS[@]}")
fi
if [ "$GATE" = "1" ]; then
exec luacheck "${PATHS[@]}" -q --codes --only 0 1 511
fi
luacheck "${PATHS[@]}"
+9
View File
@@ -66,6 +66,15 @@ run_tier() {
# ------- ROM-free tiers: these are what CI runs
if command -v luacheck >/dev/null 2>&1; then
run_tier "T0 luacheck gate (undefined globals, unreachable code)" \
./scripts/lint.sh --gate
else
echo ""
echo "-- T0 luacheck gate: skipped (no luacheck on PATH --"
echo " luarocks install luacheck; CI installs and gates on it regardless)"
fi
run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py
run_tier "T0 ROM manifest generator pin/overrides" python3 tests/rom_manifest_generator_test.py
run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua
+1
View File
@@ -538,6 +538,7 @@ end
local function makeBattler(data, mon, isPlayer, save)
local def = data.pokemon[mon.species]
require("src.pokemon.Stats").ensure(def, mon)
local badgeBoosts = data.constants and data.constants.badgeBoosts
local badges = nil
if isPlayer and save then
+3 -1
View File
@@ -645,7 +645,9 @@ MoveEffects.full = {
user.bideTurns = ctx.rng(2, 3)
user.bideDamage = 0
ctx.battle:cancelMoveAnim()
ctx.anim(user.isPlayer and "XSTATITEM_ANIM" or "XSTATITEM_DUPLICATE_ANIM")
ctx.battle:animBeforeMove(
user.isPlayer and "XSTATITEM_ANIM" or "XSTATITEM_DUPLICATE_ANIM",
user.isPlayer)
ctx.say(Strings("%s\nis storing energy!", displayName(user)))
end,
},
+18 -2
View File
@@ -2417,6 +2417,8 @@ Battle.MOVE_EFFECTS.EFFECT_BATON_PASS = function(self, attacker)
self.enemyIndex = target
self.enemy = party[target]
self.enemy.volatile = carried
-- engine/battle/move_effects/baton_pass.asm:59
self:resetParticipants()
end
local sent = side == "player" and self.player or self.enemy
self:emit({ kind = "send", side = side, mon = sent,
@@ -2679,6 +2681,8 @@ Battle.MOVE_EFFECTS.EFFECT_FORCE_SWITCH = function(self, attacker, defender,
self.enemyIndex = pick
self.enemy = incoming
self.stages.enemy = Battle.newStages()
-- ForceEnemySwitch (engine/battle/core.asm:2937)
self:resetParticipants()
end
self:emit({ kind = "send", side = self:sideOf(incoming), mon = incoming,
hp = incoming.hp or 0, status = incoming.status or false,
@@ -3341,7 +3345,9 @@ function Battle:giveExperiencePass(loser, def, recipients, count, halved)
local moveDef = self:moveDef(moveId)
local moveName = (moveDef and moveDef.name) or moveId
if ok then
-- data/text/common_3.asm:119
self:emit({ kind = "message",
sfx = "Sfx_DexFanfare5079", waitSfx = true,
text = self:monName(mon) .. " learned " .. moveName .. "!" })
elseif reason == "full" then
-- LearnMove's full-moveset arm calls ForgetMove, which asks with
@@ -3450,8 +3456,7 @@ function Battle:awardExperience(loser)
-- GiveExperiencePoints .done falls through ResetBattleParticipants into
-- AddBattleParticipant (engine/battle/core.asm:7116 and :3033).
self.participants = {}
if self.playerIndex then self.participants[self.playerIndex] = true end
self:resetParticipants()
end
-- The answer to a `choose-forget`: drop the move in `slot` and put the
@@ -3472,7 +3477,9 @@ function Battle:resolveForget(index, slot, entry, moveName)
end
self:emit({ kind = "message",
text = "1, 2 and… " .. self:monName(mon) .. " forgot " .. oldName .. "!" })
-- engine/pokemon/learn.asm:115, data/text/common_3.asm:119
self:emit({ kind = "message",
sfx = "Sfx_DexFanfare5079", waitSfx = true,
text = self:monName(mon) .. " learned "
.. (moveName or (entry and entry.id) or "?") .. "!" })
-- The forget path writes the slot itself rather than going through
@@ -3511,6 +3518,13 @@ function Battle:switchLocked()
return self:volatile(self.enemy).trapsTarget == true
end
-- ResetBattleParticipants falls through into AddBattleParticipant
-- (engine/battle/core.asm:3033 and :3037).
function Battle:resetParticipants()
self.participants = {}
if self.playerIndex then self.participants[self.playerIndex] = true end
end
-- EnemySwitch's shift arm zeroes both participant bitfields before PlayerSwitch
-- (engine/battle/core.asm:2959-2961).
function Battle:shiftSwitch(index)
@@ -3999,6 +4013,8 @@ function Battle:enemyTrySwitchOrItem()
.. self:monName(outgoing) .. "!" })
self.enemyIndex = target
self.enemy = self.enemyParty[target]
-- AI_Switch (engine/battle/ai/items.asm:697)
self:resetParticipants()
-- ResetEnemyBattleVars (engine/battle/core.asm:3016) zeroes wCurEnemyMove
-- and wLastEnemyMove and NewEnemyMonStatus wipes the substatus bytes, so
-- the mon coming IN starts from an empty area -- the same pair of clears
+14 -6
View File
@@ -483,8 +483,10 @@ function Game2:learnMoveOn(mon, moveId, onDone)
if onDone then onDone(learned) end
end
if ok then
-- data/text/common_3.asm:119
return self:say(("%s learned\n%s!"):format(name, moveName),
function() finish(true) end)
function() finish(true) end,
TextBox.soundOpts(self, "Sfx_DexFanfare5079"))
end
if reason ~= "full" then return finish(false) end
local askForget, pickMove, askStop
@@ -539,9 +541,11 @@ function Game2:learnMoveOn(mon, moveId, onDone)
-- The slot is written here rather than through Mon.learnMove, so
-- pokemon.move_learned is raised here too.
ModRuntime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
-- engine/pokemon/learn.asm:115, data/text/common_3.asm:119
self:say(("1, 2 and… Poof!\f%s forgot\n%s.\fAnd…\f%s learned\n%s!")
:format(name, oldName, name, moveName),
function() finish(true) end)
function() finish(true) end,
TextBox.soundOpts(self, "Sfx_DexFanfare5079"))
end,
})
end
@@ -706,7 +710,9 @@ function Game2:usePartyItem(itemId)
})
elseif action == "candy" then
self:consumeItem(itemId)
self:say(result.text, function() self:afterRareCandy(mon, result) end)
-- data/text/common_1.asm:86
self:say(result.text, function() self:afterRareCandy(mon, result) end,
result.sfx and TextBox.soundOpts(self, result.sfx) or nil)
else
self:consumeItem(itemId)
self:say(result.text)
@@ -765,8 +771,10 @@ function Game2:useSelectItem()
local name = (items[itemId] and items[itemId].name) or itemId
self:say(Strings("{PLAYER} used the\n%s.", name))
elseif outcome == "trophy_sent" then
-- data/text/common_3.asm:372
self:say(Strings(
"There was a trophy\ninside!\fThe trophy was\nsent home."))
"There was a trophy\ninside!\fThe trophy was\nsent home."),
nil, TextBox.soundOpts(self, "Sfx_DexFanfare5079"))
end
-- Anything else (a fishing bite, the ITEMFINDER's queued script) already
-- drives its own presentation off World:step -- nothing left to print here.
@@ -778,9 +786,9 @@ end
-- onDone that popped again ate the state UNDER the box: dismissing a message
-- over the PACK closed the PACK with it, and over an empty overworld stack it
-- was a silent extra pop.
function Game2:say(text, onDone)
function Game2:say(text, onDone, opts)
local TextBox = require("src.render.TextBox")
self.stack:push(TextBox.new(self, text, onDone))
self.stack:push(TextBox.new(self, text, onDone, opts))
end
-- The landmark the player is standing in, for the Pokegear map's marker.
+2
View File
@@ -240,6 +240,8 @@ local function rareCandy(mon, data)
used = true,
level = newLevel,
learned = learned,
-- data/text/common_1.asm:86
sfx = "Sfx_DexFanfare5079",
text = ("%s grew to\nlevel %d!"):format(monName(mon), newLevel),
}
end
+20
View File
@@ -5151,6 +5151,7 @@ function RomExtractorGen2:extractMenuGfx()
}
out.pack = pack
out.pokedex = self:pokedexGfx()
out.billsPc = self:billsPcGfx()
out.pokegear = self:pokegearGfx()
out.trainerCard = self:trainerCardGfx()
out.unownPuzzle = self:unownPuzzleGfx()
@@ -5697,6 +5698,25 @@ function RomExtractorGen2:pokedexGfx()
return dex
end
-- BillsPC_InitGFX's four tiles at vTiles2 $5c
-- (engine/pokemon/bills_pc.asm:2170-2173)
function RomExtractorGen2:billsPcGfx()
if not self.symbols["PCMailGFX"] then return nil end
local pc = {}
local gfx = self:symbol("PCMailGFX")
self:write2bpp(self.rom:bytes(gfx.bank, gfx.address, 4 * 16),
32, 8, "pc/mail_item.png")
pc.icons = "assets/generated/pc/mail_item.png"
pc.firstTile = 0x5c
pc.palette = self:predefPal(PREDEFPAL_POKEDEX)
-- gfx/pc/orange.pal
if self.symbols["BillsPCOrangePalette"] then
local orange = self:symbol("BillsPCOrangePalette")
pc.orangePalette = self:colors(orange.bank, orange.address, 4)
end
return pc
end
-- Reads a `tile, count` RLE tilemap (Pokegear_LoadTilemapRLE). The routine's
-- own comment says "repeat count, tile ID" and has it backwards: it loads b
-- from the first byte, c from the second, and writes `b` c times. $ff ends
+3
View File
@@ -146,6 +146,9 @@ local VERSION_REQUIRED_FILES_OVERRIDE = {
-- complete and SlotMachine crashed on its labelled-cell fallback.
"assets/generated/slots/gold_slots_1.png",
"assets/generated/card_flip/card_flip_1.png",
-- PCMailGFX (engine/pokemon/bills_pc.asm:2170-2173): Bill's PC has no
-- held-item or mail icon in a cache built before the symbol was listed.
"assets/generated/pc/mail_item.png",
},
}
-- Same Gen 2 extract, so a Silver cache is complete when the same files exist.
+34 -4
View File
@@ -808,6 +808,31 @@ local function buildOverworld()
local Movement = rawRequire("src.script.gen2.Movement")
local HiddenItems = rawRequire("src.world.gen2.HiddenItems")
local Bike = rawRequire("src.world.gen2.Bike")
local FieldMoves = rawRequire("src.world.gen2.FieldMoves")
-- home/map.asm:1869
local function stepAllowed(world, entity, dir)
local d = Map2.DELTA[dir]
if not (world.map and d and entity and entity.cellX) then return true end
if not Permissions.stepPermitted(
function(cx, cy) return world:cellCollisionAcross(world.map, cx, cy) end,
entity.cellX, entity.cellY, dir) then
return false
end
local map = world.map
if entity == world.player and FieldMoves.isSurfing(world.playerState) then
map = world:surfMap(world.map)
end
local tx, ty = entity.cellX + d[1], entity.cellY + d[2]
if not map:inBounds(tx, ty) or not map:isWalkable(tx, ty) then return false end
for _, e in ipairs(world.entities or {}) do
if e ~= entity and not e.passable then
if e.cellX == tx and e.cellY == ty then return false end
if e.moving and e.targetX == tx and e.targetY == ty then return false end
end
end
return true
end
local api = nil
-- one WorldAPI instance, so queueScript reuses the five-verb allow list
@@ -1011,20 +1036,25 @@ local function buildOverworld()
-- Gold has ONE movement slot; a second concurrent call is refused with a
-- reason rather than dropped (src/world/gen2/WorldAPI.lua:171's recipe).
function ow.scriptMove(entity, dir, tiles, onDone)
function ow.scriptMove(entity, dir, tiles, onDone, opts)
local world = w("scriptMove")
if not world then return nil, "no overworld" end
if world.moveState then return nil, "a movement is already running" end
local step = Movement.stepByte(dir)
if not step then return nil, "unknown direction: " .. tostring(dir) end
local bytes = {}
for _ = 1, math.max(0, tiles or 1) do bytes[#bytes + 1] = step end
bytes[#bytes + 1] = Movement.STEP_END
local objectId = objectIdOf(world, entity)
if not objectId then
return nil, "no Gen 2 objectId for that entity: only the player and a "
.. "mapped object (def.index) can be moved"
end
local n = math.max(0, tiles or 1)
if opts and opts.collide and n > 0 and not stepAllowed(world, entity, dir) then
entity.facing = dir
n = 0
end
local bytes = {}
for _ = 1, n do bytes[#bytes + 1] = step end
bytes[#bytes + 1] = Movement.STEP_END
world:beginMovement(objectId, bytes, onDone)
return true
end
+12
View File
@@ -119,6 +119,17 @@ local GEN1_ONLY_MODULES = {
["src.ui.OptionsMenu"] = true,
}
local function crossGenerationDenial(name, generation)
if type(name) ~= "string" or generation ~= 1 then return nil end
if not (name:find("^src%.[%w_]+%.gen2%.") or name == "src.core.Game2") then
return nil
end
return ("%s is a Gen 2 engine module and this is a Gen 1 game; the structs "
.. "it reads and writes are not this game's, so anything it stores lands "
.. "on the save in the wrong shape. Take the game from mod.game and the "
.. "world from mod.world, which resolve per generation"):format(name)
end
-- the src.* modules the mod surface points authors at: another mod's
-- exports carry a version string that wants range-checking before use, and
-- ChipAsm is the authoring path for chip music and sfx
@@ -214,6 +225,7 @@ function Loader:_installDevShim()
if owner or callerIsMod(3) then
local id = type(owner) == "string" and owner or nil
local denial = Sandbox.moduleDenial(name, devShim.permissions[id])
or (id and crossGenerationDenial(name, devShim.generation))
if denial then error(("[%s] %s"):format(id or "mod", denial), 0) end
end
if devShim.dev or devShim.generation ~= 1 then scanRequire(name) end
+11 -4
View File
@@ -51,11 +51,18 @@ end
-- (engine/pokemon/status_screen.asm:66-76, "mon is in a box or daycare" ->
-- CalcStats) and when one is moved back into the party
-- (engine/pokemon/add_mon.asm _MoveMon tail). The stored current HP is
-- kept (box_struct does hold it) but clamped to the recalculated maximum so
-- a tampered save cannot overfill the bar. A mon that already has stats is
-- returned untouched, so a vanilla save round-trips. #233, #304
-- kept (box_struct does hold it) but clamped to the recalculated maximum.
-- CalcStats writes all NUM_STATS stats or none (home/move_mon.asm:33-48).
local function statsComplete(stats)
for _, key in ipairs(ORDER) do
if type(stats[key]) ~= "number" then return false end
end
return true
end
function Stats.ensure(speciesDef, mon)
if type(mon) ~= "table" or type(mon.stats) == "table" then return mon end
if type(mon) ~= "table" then return mon end
if type(mon.stats) == "table" and statsComplete(mon.stats) then return mon end
if type(speciesDef) ~= "table" or type(speciesDef.baseStats) ~= "table" then
return mon
end
+2
View File
@@ -2707,7 +2707,9 @@ function BattleState:pushCaught(enemy, itemId)
-- so the contest branch is BELOW them (item_effects.asm:528-546), and
-- CheckReceivedDex gates the pair (:532-533).
if not knew and self:hasPokedex() then
-- data/text/common_3.asm:285
self:push({ kind = "message",
sfx = "Sfx_SlotMachineStart", waitSfx = true,
text = self:name(enemy) .. "'s data was newly added to the #DEX." })
self:push({ kind = "dex-entry", species = enemy.species })
end
+86 -12
View File
@@ -47,6 +47,7 @@ local Font = require("src.render.Font")
local GbcPalette = require("src.render.GbcPalette")
local Mail = require("src.core.gen2.Mail")
local Palettes = require("src.world.gen2.Palettes")
local PartyMenu = require("src.ui.gen2.PartyMenu")
local Screens = require("src.ui.Screens")
local Sound = require("src.core.Sound")
local Unown = require("src.core.gen2.Unown")
@@ -65,6 +66,15 @@ local PIC_X, PIC_Y = 1, 4
-- 7-size blank tiles per column (engine/gfx/load_pics.asm:342-386).
local PIC_PAD = { [7] = { 0, 0 }, [6] = { 1, 1 }, [5] = { 1, 2 } }
-- gfx/pc/orange.pal, for a cache that predates menu_gfx.billsPc.
local BILLS_PC_ORANGE = {
{ 255, 123, 0 }, { 189, 99, 0 }, { 123, 58, 0 }, { 0, 0, 0 },
}
-- PCMonInfo prints the held-item icon at hlcoord 7, 12
-- (engine/pokemon/bills_pc.asm:1093).
local ICON_X, ICON_Y = 7, 12
-- wBillsPC_LoadedBox: 0 is the PARTY, 1..NUM_BOXES are the boxes. Only the
-- MOVE screen ever loads box 0; the withdraw and deposit lists are one list
-- each (BillsPC_BoxName reads the same byte for all three).
@@ -272,6 +282,8 @@ function BoxMenu:beginMove()
-- to the submenu; here the message holds until a button clears it and the
-- list comes back, which is the same place the player ends up.
self.phase = nil
-- engine/pokemon/bills_pc.asm:1607
self:playSfx("Sfx_Wrong")
self.message = reason
return
end
@@ -299,9 +311,13 @@ end
function BoxMenu:doWithdraw()
local ok, result = Boxes.withdraw(self.save, self.boxIndex, self.index)
if not ok then
-- engine/pokemon/bills_pc.asm:1845
self:playSfx("Sfx_Wrong")
self.message = result
return
end
-- engine/pokemon/bills_pc.asm:1817
self:playMonCry(result)
self.message = nil
self.phase = nil
self:clampIndex()
@@ -311,9 +327,13 @@ end
function BoxMenu:doDeposit()
local ok, result = Boxes.deposit(self.save, self.index, self.boxIndex)
if not ok then
-- engine/pokemon/bills_pc.asm:1790
self:playSfx("Sfx_Wrong")
self.message = result
return
end
-- engine/pokemon/bills_pc.asm:1762
self:playMonCry(result)
self.message = nil
self.phase = nil
self.index, self.scroll = 1, 0
@@ -486,6 +506,8 @@ function BoxMenu:update(_dt)
if not ok then
-- .no_space: `dec [hl]` puts the jumptable back on .PrepInsertCursor,
-- so the refusal leaves the cursor exactly where it was.
-- engine/pokemon/bills_pc.asm:1567
self:playSfx("Sfx_Wrong")
self.message = reason
else
self:insertMon()
@@ -528,6 +550,14 @@ function BoxMenu:playSfx(name)
if sfx and sfx[Sound.resolve(data, name)] then Sound.play(data, name) end
end
-- PlayMonCry: `call GetCryIndex / jr c, .done` (home/pokemon.asm:101)
function BoxMenu:playMonCry(mon)
local data = self.game and self.game.data
if not (data and mon and mon.species) or mon.isEgg then return end
local cries = data.audio and data.audio.cries
if cries and cries[mon.species] then Sound.playCry(data, mon.species) end
end
-- BillsPC's RELEASE, which the model has always supported and nothing on
-- screen reached. The cart asks first and starts the prompt on NO, the way
-- every irreversible choice in the game does.
@@ -541,6 +571,8 @@ function BoxMenu:askRelease()
local allowed, refusal = self:checkMailPreventBlackout()
if not allowed then
self.phase = nil
-- engine/pokemon/bills_pc.asm:1607
self:playSfx("Sfx_Wrong")
self.message = refusal
return
end
@@ -568,6 +600,8 @@ function BoxMenu:askRelease()
self.message = err
return
end
-- engine/pokemon/bills_pc.asm:1866
self:playMonCry(mon)
self.message = name .. " was released."
self.phase = nil
self:clampIndex()
@@ -627,14 +661,31 @@ function BoxMenu:picFor(mon)
return self:image(path)
end
-- engine/gfx/cgb_layouts.asm:284-300, engine/pokemon/bills_pc.asm:356-369
function BoxMenu:panelColors(speciesId, shiny)
if self.phase == "submenu" or self.phase == "insert" then
return self.palettes
and Palettes.monColors(self.palettes, speciesId, shiny)
end
local gfx = (self.menuGfx or {}).billsPc
return (gfx and gfx.orangePalette) or BILLS_PC_ORANGE
end
-- ClearBox runs before `cp -1 / ret z` (engine/pokemon/bills_pc.asm:1009-1021)
function BoxMenu:fillPicBlock(colors)
local G = love.graphics
local blank = colors and GbcPalette.color(colors, 1) or { 255, 255, 255 }
G.setColor(blank[1] / 255, blank[2] / 255, blank[3] / 255, 1)
G.rectangle("fill", PIC_X * 8, PIC_Y * 8, 7 * 8, 7 * 8)
G.setColor(1, 1, 1, 1)
end
-- PCMonInfo lays the padded pic as one 7x7 block at hlcoord 1, 4
-- (engine/pokemon/bills_pc.asm:1023-1042), the pad tiles at the palette's 0.
function BoxMenu:drawPicBlock(image, colors)
if not image then return end
local G = love.graphics
local blank = colors and GbcPalette.color(colors, 1) or { 255, 255, 255 }
G.setColor(blank[1] / 255, blank[2] / 255, blank[3] / 255, 1)
G.rectangle("fill", PIC_X * 8, PIC_Y * 8, 7 * 8, 7 * 8)
self:fillPicBlock(colors)
local pad = PIC_PAD[math.floor(image:getWidth() / 8)] or PIC_PAD[7]
G.setColor(1, 1, 1, 1)
@@ -650,12 +701,12 @@ function BoxMenu:drawPicBlock(image, colors)
end
function BoxMenu:drawPic(mon)
local image = self:picFor(mon)
if not image then return end
-- _CGB_BillsPC hands wTempMonDVs to GetPlayerOrMonPalettePointer, so the box
-- pic takes the shiny row (engine/gfx/cgb_layouts.asm:292-293).
local colors = self.palettes
and Palettes.monColors(self.palettes, mon.species, mon.shiny)
local colors = self:panelColors(mon.species, mon.shiny)
local image = self:picFor(mon)
-- engine/pokemon/bills_pc.asm:1009-1011
if not image then return self:fillPicBlock(colors) end
self:drawPicBlock(image, colors)
end
@@ -664,17 +715,14 @@ end
-- with the party list's ICON_EGG standing in for a cache built before that.
function BoxMenu:drawEggPic(mon)
local G = love.graphics
local colors = self.palettes
and Palettes.monColors(self.palettes, "EGG", mon and mon.shiny)
local colors = self:panelColors("EGG", mon and mon.shiny)
local gfx = (self.menuGfx or {}).eggHatch
local image = self:image(gfx and gfx.egg)
if image then return self:drawPicBlock(image, colors) end
self:fillPicBlock(colors)
local entry = self.icons and self.icons.icons and self.icons.icons.ICON_EGG
image = self:image(entry and entry.image)
if not image then return end
local blank = colors and GbcPalette.color(colors, 1) or { 255, 255, 255 }
G.setColor(blank[1] / 255, blank[2] / 255, blank[3] / 255, 1)
G.rectangle("fill", PIC_X * 8, PIC_Y * 8, 7 * 8, 7 * 8)
-- The ICON_EGG sheet stacks its frames; the first is the egg at rest.
local w = entry.width or 16
local h = math.min(entry.height or 16, image:getHeight())
@@ -694,6 +742,29 @@ function BoxMenu:drawEggPic(mon)
G.setColor(1, 1, 1, 1)
end
-- ItemIsMail picks $5c over $5d at hlcoord 7, 12
-- (engine/pokemon/bills_pc.asm:1079-1094)
function BoxMenu:drawHeldIcon(mon)
local row = PartyMenu.heldMarkerRow(mon)
if not row then return end
local gfx = (self.menuGfx or {}).billsPc
local image = self:image(gfx and gfx.icons)
if not image then return end
local ok, quad = pcall(love.graphics.newQuad, row * 8, 0, 8, 8,
image:getDimensions())
if not ok then return end
local G = love.graphics
G.setColor(1, 1, 1, 1)
local function body() G.draw(image, quad, ICON_X * 8, ICON_Y * 8) end
local colors = gfx and gfx.palette
if colors and GbcPalette.available() then
GbcPalette.with(colors, body)
else
body()
end
G.setColor(1, 1, 1, 1)
end
-- The PC does not mark the selected row with a ▶: BillsPC_UpdateSelectionCursor
-- lays 20 OBJs as a frame *around* the row -- ten tiles wide by two tall, top
-- left at pixel (71, 25), stepping 16 pixels per row. Those cursor tiles are
@@ -793,7 +864,10 @@ function BoxMenu:drawPanel()
Chrome.print("\xe2\x99\x80", 5, 12)
end
Chrome.print(mon.name or mon.species or "?", PIC_X, 14)
self:drawHeldIcon(mon)
end
else
self:fillPicBlock(self:panelColors())
end
-- BillsPC_PlaceString: Textbox at (0,15) with a one-row interior, string at
+10 -4
View File
@@ -320,12 +320,15 @@ function ItemPcMenu:leaveDeposit()
end
function ItemPcMenu:offerToDeposit(id, count)
-- .TryDepositItem's `.no_toss` arm is a bare ret: a KEY ITEM or HM stays in
-- the bag with no message at all.
if self:cantToss(id) then return end
if (count or 0) < 1 then return end
local def = self:def(id)
local name = (def and def.name) or id
-- .DepositItem (engine/events/pokecenter_pc.asm:504): an item with no
-- quantity is always x1 and never reaches .AskQuantity.
if self:cantToss(id) then
self:deposit(id, name, 1)
return
end
self:askQuantity(count,
{ "How many do you", "want to deposit?" },
function(qty) self:deposit(id, name, qty) end)
@@ -519,8 +522,11 @@ function ItemPcMenu:drawList()
if i == self.listIndex then Chrome.cursor(5, ty) end
Chrome.print(entry.name, 6, ty)
-- PlaceMenuItemQuantity (engine/menus/menu_2.asm:24): the xNN is the
-- entry's second line, right-aligned in a blank-padded 2-digit field.
-- entry's second line, right-aligned in a blank-padded 2-digit field,
-- and an item with no quantity draws none at all (menu_2.asm:18).
if not self:cantToss(entry.id) then
Chrome.print(TIMES .. Chrome.number(entry.count, 2), 7, ty + 1)
end
elseif i == self:listTotal() then
if i == self.listIndex then Chrome.cursor(5, ty) end
Chrome.print("CANCEL", 6, ty)
+25
View File
@@ -192,6 +192,14 @@ function PackMenu:pocketOf(itemId)
return (def and def.pocket) or "ITEM"
end
-- engine/items/tmhm.asm:341
function PackMenu:tmhmKey(itemId)
local def = self.items and self.items[itemId]
local n = def and tonumber(def.tmNumber)
if n then return n end
return 1000 + ((def and tonumber(def.index)) or 0)
end
-- The name on the row. An inventory key with no ItemAttributes row behind it
-- (an older cache, a mod's own item, a driver seeding an id that is not in
-- items.lua) still has to draw something a person can read, so the id stands
@@ -239,6 +247,15 @@ function PackMenu:rebuild()
}
end
end
-- engine/items/tmhm.asm:341 -- wTMsHMs is walked 1..57, so the TM/HM pocket
-- has no acquisition order to preserve.
if pocket == "TM_HM" then
table.sort(rows, function(a, b)
local ka, kb = self:tmhmKey(a.id), self:tmhmKey(b.id)
if ka ~= kb then return ka < kb end
return a.id < b.id
end)
end
self.rows = rows
self.index = math.min(self.index, #rows + 1)
if self.index < 1 then self.index = 1 end
@@ -359,6 +376,9 @@ function PackMenu:useSelected()
-- with sound_dex_fanfare_50_79 between them; the PACK's box here holds
-- all four rows at once, the way OAK_THIS_ISNT_THE_TIME's three fit.
-- World has already set the decoration's flag and taken the box.
if world.playSfxNamed then
world:playSfxNamed("Sfx_DexFanfare5079")
end
self.message = { "There was a trophy", "inside!",
"{PLAYER} sent the", "trophy home." }
self:rebuild()
@@ -641,6 +661,9 @@ function PackMenu:openTeachParty(row)
if id == moveId then allowed = true end
end
if not allowed then
-- engine/items/tmhm.asm:131
local world = game.world
if world and world.playSfxNamed then world:playSfxNamed("Sfx_Wrong") end
if game.say then
game:say(("%s can't learn %s!"):format(
require("src.battle.gen2.Mon").displayName(mon), moveName))
@@ -740,7 +763,9 @@ function PackMenu:update(_dt)
end
-- engine/items/pack.asm:1290 Pack_InterpretJoypad .select
-- engine/items/tmhm.asm:207 -- the TM/HM pocket's joypad filter drops SELECT.
function PackMenu:armSwitch()
if self:pocket().id == "TM_HM" then return end
if self:isCancel() then return end
if not self.rows[self.index] then return end
self.switching = self.index
+37 -5
View File
@@ -150,6 +150,21 @@ local function pooledNPC(pool, data, mapId, obj)
end
OverworldState.pooledNPC = pooledNPC -- exposed for tests
local function nearestWalkableCell(map, x, y)
for r = 1, 8 do
for dy = -r, r do
for dx = -r, r do
if math.abs(dx) == r or math.abs(dy) == r then
local nx, ny = x + dx, y + dy
if map:inBounds(nx, ny) and map:isWalkableCell(nx, ny) then
return nx, ny
end
end
end
end
end
end
-- connection hops rendered around the current map: two, so
-- corner-adjacent maps (connections of connections) don't pop in and
-- out of the survey zoom at the seams (constants.world.neighborHops)
@@ -414,6 +429,15 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
table.insert(self.npcs, npc)
end
end
if opts and opts.via == "boot" and self.map:inBounds(x, y)
and not self.map:isWalkableCell(x, y) and not self.map:isWaterCell(x, y) then
local rx, ry = nearestWalkableCell(self.map, x, y)
if rx then
Logger.warn("saved position %s (%d,%d) is not walkable; moved to (%d,%d)",
mapId, x, y, rx, ry)
x, y = rx, ry
end
end
if self.player then
self.player.cellX, self.player.cellY = x, y
self.player.px, self.player.py = x * 16, y * 16
@@ -4113,7 +4137,7 @@ function OverworldState:checkBadgeGate()
Game.stack:push(TextBox.new(Game,
(t["_" .. g.failText] or Strings("You don't have the\nBOULDERBADGE yet!"))
.. (t._Route22GateGuardICantLetYouPassText or ""), function()
self:scriptMove(p, "down", 1)
self:scriptMove(p, "down", 1, nil, { collide = true })
end))
return true
end
@@ -4141,7 +4165,7 @@ function OverworldState:checkBadgeGate()
local text = (t["_" .. g.failText] or
Strings("You don't have the\n{RAM} yet!")):gsub("{RAM:wNameBuffer}", badgeName)
Game.stack:push(TextBox.new(Game, text, function()
self:scriptMove(p, "down", 1)
self:scriptMove(p, "down", 1, nil, { collide = true })
end))
return true
end
@@ -4181,7 +4205,7 @@ function OverworldState:checkForcedMovement()
function()
local back = ({ up = "down", down = "up",
left = "right", right = "left" })[p.facing]
self:scriptMove(p, back, 1)
self:scriptMove(p, back, 1, nil, { collide = true })
end))
return true
end
@@ -4222,6 +4246,7 @@ function OverworldState:checkSeafoamCurrent()
-- push so the B3F stair warps underfoot cannot bounce you back.
self.forcedWarp = false
require("src.core.Sound").play(Game.data, "Collision")
-- home/overworld.asm:1891
self:scriptMove(p, "up", c.y == 17 and 2 or 1)
return true
end
@@ -4780,9 +4805,10 @@ end
-- scripted movement
-- -------------------------------------------------------------------------
function OverworldState:scriptMove(entity, dir, tiles, onDone)
function OverworldState:scriptMove(entity, dir, tiles, onDone, opts)
table.insert(self.scriptMoves, {
entity = entity, dir = dir, remaining = tiles, onDone = onDone,
collide = opts and opts.collide or nil,
})
end
@@ -4822,16 +4848,22 @@ function OverworldState:updateScriptMoves()
e.moving = true
e.marching = true
e.progress = 0
mv.remaining = mv.remaining - 1
elseif mv.collide
and not Collision.canMove(self.map, self.entities, e, mv.dir) then
-- home/overworld.asm:1224
e.facing = mv.dir
mv.remaining = 0
else
e.facing = mv.dir
local tx, ty = Collision.target(e.cellX, e.cellY, mv.dir)
e.targetX, e.targetY = tx, ty
e.moving = true
e.progress = 0
end
mv.remaining = mv.remaining - 1
end
end
end
-- march_in_place toggles: re-arm the in-place cycle each time it ends.
-- Not a scriptMove, so an ambient marcher never trips the input lockout.
for entity in pairs(self.marchers or {}) do
+13 -1
View File
@@ -83,6 +83,7 @@ local SFX = {
WARP_TO = 19,
EXIT_BUILDING = 35,
JUMP_OVER_LEDGE = 0x16,
BUMP = 0x24,
}
local EMOTE_SHOCK = 0
@@ -2183,6 +2184,13 @@ function World:playSfxNamed(want, fallbackId)
self:playSfx(self:sfxIdNamed(want, fallbackId))
end
-- .BumpSound (engine/overworld/player_movement.asm:771): `call CheckSFX /
-- ret c` is the whole rate limit (home/audio.asm:477), not a frame counter.
function World:bumpSound()
if Sound.sfxBusy() then return end
self:playSfxNamed("Sfx_Bump", SFX.BUMP)
end
-- Script_specialsound (engine/overworld/scripting.asm:476) is not a fixed cue:
-- it farcalls CheckItemPocket (engine/items/items.asm:512), which writes
-- wCurItem's pocket into wItemAttributeValue, and rings SFX.GET_TM for the
@@ -9031,6 +9039,9 @@ function World:movePlayer(dir)
elseif result == "blocked" or result == "edge" then
self.turningDirection = nil
end
-- .NotMoving and .Ice's own arm (player_movement.asm:102 and :87), which
-- .TryJump's carry (:376) returns above.
if result == "blocked" then self:bumpSound() end
-- NormalStep, in order (engine/overworld/movement.asm:657-674): InitStep has
-- already moved OBJECT_TILE_COLLISION onto the destination.
if result == "moved" then self:playerStepGrass() end
@@ -9738,7 +9749,8 @@ function World:stepBody()
local result = self:movePlayer(dir)
if result == "edge" then
self:tryConnection(dir)
-- A border block is a wall: engine/overworld/player_movement.asm:264
if not self:tryConnection(dir) then self:bumpSound() end
elseif result == "blocked" and p.facing == dir then
-- .CheckNPC came back 2: something movable is in the way. The step is
-- lost either way, and the boulder is what moves.
+43 -7
View File
@@ -83,15 +83,36 @@ return function(game)
-- the announcement-time row performMove inserts directly as well as the
-- ones animNext adds, which is the whole difference the fix makes.
local function watchAnims(battle)
local seen, mark = {}, {}
local seen, mark, types = {}, {}, {}
return seen, function()
for _, row in ipairs(battle.queue) do
if row.anim and not mark[row] then
mark[row] = true
seen[#seen + 1] = row.anim
types[row.anim] = row.hit and row.hit.animType or false
end
end
end, types
end
-- engine/battle/animations.asm:427-437
local function rideAndSample(battle, poll, frames, shotPath)
local peak, shot = 0, false
for i = 1, frames do
poll()
if i % 6 == 0 then U.tap(battle.game, "a") end
local fx = battle.fx
if fx and fx.shakeX and not battle.animPlaying then
local dx = math.abs(fx.shakeX)
if dx > peak then peak = dx end
if dx > 0 and not shot then
shot = true
U.shot(battle.game, shotPath)
end
end
U.wait(1)
end
return peak
end
local function listOf(seen)
@@ -136,23 +157,28 @@ return function(game)
local battle = BattleState.newWild(game, "PIDGEY", 8)
battle.onFinish = function() end
ow:pushBattle(battle)
local seen, poll = watchAnims(battle)
local seen, poll, types = watchAnims(battle)
check("the battle reached the menu", waitPhase(battle, "menu", 60, poll))
check("BIDE is the move on the list", pickBide(battle, poll))
for _ = 1, 20 do poll() U.wait(1) end
U.shot(game, DIR .. "/bug375_store_spiral.png")
for _ = 1, 30 do poll() U.wait(1) end
local peak = rideAndSample(battle, poll, 180,
DIR .. "/bug1564_store_shake.png")
U.shot(game, DIR .. "/bug375_store_text.png")
check("the storing turn queued XSTATITEM_ANIM", has(seen, "XSTATITEM_ANIM"))
check("and not BIDE's hit animation: " .. listOf(seen), not has(seen, "BIDE"))
check("BIDE locked in for " .. tostring(battle.player.bideTurns) .. " turns",
battle.player.bideTurns ~= nil)
check("the storing spiral carries wAnimationType 6 (#1564)",
types["XSTATITEM_ANIM"] == 6)
check(("the screen shook, peak dx = %d px (want 3)"):format(peak), peak == 3)
-- ride out the locked turns: the storing text needs A presses and the menu
-- never comes back until BIDE unleashes
local released = false
for _ = 1, 120 do
for _ = 1, 220 do
U.tap(game, "a")
for _ = 1, 6 do poll() U.wait(1) end
if has(seen, "BIDE") then
@@ -179,7 +205,7 @@ return function(game)
foeBattle.enemy.mon.moves = { { id = "BIDE", pp = 10 } }
foeBattle.enemy.curMoves = foeBattle.enemy.mon.moves
ow:pushBattle(foeBattle)
local foeSeen, foePoll = watchAnims(foeBattle)
local foeSeen, foePoll, foeTypes = watchAnims(foeBattle)
check("the foe's battle reached the menu",
waitPhase(foeBattle, "menu", 60, foePoll))
pickBide(foeBattle, foePoll) -- ours does not matter here, the foe's does
@@ -192,10 +218,20 @@ return function(game)
check("the foe's storing turn queued XSTATITEM_DUPLICATE_ANIM: "
.. listOf(foeSeen), has(foeSeen, "XSTATITEM_DUPLICATE_ANIM"))
local foePeak = rideAndSample(foeBattle, foePoll, 180,
DIR .. "/bug1564_enemy_store_shake.png")
check("the foe's spiral carries wAnimationType 3 (#1564)",
foeTypes["XSTATITEM_DUPLICATE_ANIM"] == 3)
check(("the foe's shake peaked at %d px (want 6)"):format(foePeak),
foePeak == 6)
U.log("The pad is yours in a battle where both sides know only BIDE, so")
U.log("pick FIGHT then BIDE and watch turn one: the screen palette flashes")
U.log("white and balls spiral inward, silently, and then it says storing")
U.log("energy. No flash on the RATTATA and no BIDE thud until the turn it")
U.log("white and balls spiral inward, silently. Then, still in silence, the")
U.log("whole battle screen creeps sideways 1 px at a time out to 3 px and")
U.log("back, twice, and only then does it say storing energy. On the foe's")
U.log("turn the same creep goes out to 6 px and takes twice as long.")
U.log("No flash on the RATTATA and no BIDE thud until the turn it")
U.log("unleashes, where the thud and its animation come after the text and")
U.log("before the enemy HP bar slides down. The foe's spiral looks the same.")
@@ -0,0 +1,101 @@
-- scripts/Route16Gate1F.asm:38 / home/overworld.asm:1224
-- data/maps/force_bike_surf.asm:5
-- POKEPORT_DRIVER=tests/drivers/bike_gate_wall_bug1548_test.lua \
-- POKEPORT_IDENTITY=bug1548 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
-- Watch the sprite after each teleport: it must never overlap the gate
-- building's black outline. Compare .bazinga/august21p2/media/1548-1.jpg.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local OverworldState = require("src.world.OverworldController")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
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 settle()
for _ = 1, 12 do
if game.stack:top() == game.overworld then break end
U.tap(game, "a")
U.wait(25)
end
U.wait(60)
end
local function landed()
local ow = game.overworld
local p = ow.player
return p.cellX, p.cellY, ow.map:isWalkableCell(p.cellX, p.cellY)
end
local function bikeless()
game.save.inventory.BICYCLE = nil
game.save.onBike, game.save.forcedBike = false, nil
end
game.save.player.name = "PROBE"
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
bikeless()
local fm = game.data.field.forcedMovement
check("field.forcedMovement carries the Route 16 cells",
fm and fm.tiles and fm.tiles.ROUTE_16 ~= nil)
for _, c in ipairs({ { "ROUTE_16", 17, 10 }, { "ROUTE_16", 17, 11 },
{ "ROUTE_18", 33, 8 }, { "ROUTE_18", 33, 9 } }) do
bikeless()
U.teleport(game, c[1], c[2], c[3], "left")
U.wait(20)
settle()
local x, y, walk = landed()
U.log(("%s (%d,%d) facing left -> (%d,%d) walkable=%s")
:format(c[1], c[2], c[3], x, y, tostring(walk)))
check(("%s (%d,%d): the refusal leaves the player on a walkable cell")
:format(c[1], c[2], c[3]), walk)
end
bikeless()
U.teleport(game, "ROUTE_16", 17, 10, "left")
U.wait(20)
settle()
U.shot(game, SHOT_DIR .. "/bug1548_route16_after.png")
bikeless()
U.teleport(game, "ROUTE_16", 16, 10, "right")
U.wait(20)
U.hold(game, "right", 24)
settle()
local bx, by = landed()
U.log(("stepped onto ROUTE_16 (17,10) from the west -> (%d,%d)"):format(bx, by))
check("a shove with open ground behind it still moves one cell",
bx == 16 and by == 10)
bikeless()
while game.stack:top() do game.stack:pop() end
game.stack:push(OverworldState, "ROUTE_16", 18, 10, "left")
game.overworld:setMap("ROUTE_16", 18, 10, "left", { via = "boot" })
U.wait(20)
local cx, cy, cwalk = landed()
U.log(("boot inside the wall (18,10) -> (%d,%d) walkable=%s")
:format(cx, cy, tostring(cwalk)))
check("a save parked inside the gate wall is lifted out on load", cwalk)
settle()
local dx, dy, dwalk = landed()
U.log(("after the refusal that follows -> (%d,%d) walkable=%s")
:format(dx, dy, tostring(dwalk)))
check("and the refusal it lands on cannot push it back in", dwalk)
U.shot(game, SHOT_DIR .. "/bug1548_repaired.png")
game.save.inventory.BICYCLE = 1
game.save.onBike, game.save.forcedBike = false, nil
U.teleport(game, "ROUTE_16", 17, 10, "left")
U.wait(30)
local ex, ey, ewalk = landed()
check("with a BICYCLE the forced tile mounts instead of shoving",
game.save.onBike == true and ex == 17 and ey == 10 and ewalk)
U.log(ok and "all clear" or "a check failed")
while true do coroutine.yield() end
end
@@ -0,0 +1,46 @@
-- Driver: #1517's other half, the error handler that hid the crash.
--
-- boot.lua's deferErrhand picks `love.errorhandler or love.errhand`, so a
-- love.errorhandler that returns nil ends love.run's `while func do` loop and
-- the process leaves to the OS with no error screen and nothing on stdout.
-- LOVE 11.5 pre-populates love.errhand only, so main.lua capturing
-- love.errorhandler captured nil and every Lua error in every shipped build
-- exited silently. Measured on the installed 11.5:
-- love.errorhandler type: nil / love.errhand type: function
--
-- Nothing here can be a unit test: the deliverable is whether a human sees
-- LOVE's blue error screen or an app that vanishes.
--
-- POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/errorhandler_probe_bug1517_test.lua love .
--
-- Pre-fix: the window closes on the first drawn frame after the probe arms,
-- exit 1, no output. Post-fix: the blue screen reads "bug1517: error-handler
-- probe" plus the lua-error.log hint, and stays up until it is dismissed.
return function(game)
local U = dofile("tests/drivers/util.lua")
U.log("[1517] love.errorhandler at boot was: "
.. (rawget(love, "errorhandler") and "a function" or "nil"))
U.log("[1517] love.errhand is: " .. type(rawget(love, "errhand")))
U.wait(30)
-- Raise from a draw callback, not from this coroutine: main.lua resumes the
-- driver under coroutine.resume and prints its errors itself, which is the
-- one path that does NOT reach love.errorhandler. StateStack:draw only
-- calls the states it is actually holding, so the probe goes on the live
-- top state rather than on game.overworld, which is not on the stack while
-- the title screen or the load menu is up.
local target = game.stack:top()
U.log("[1517] arming the probe on " .. tostring(target and target.screenId))
local realDraw = target.draw
target.draw = function(self, ...)
if realDraw then realDraw(self, ...) end
error("bug1517: error-handler probe")
end
U.log("[1517] armed; the next drawn frame raises from love.draw")
U.log("[1517] you should now see LOVE's blue error screen, not a closed app")
while true do coroutine.yield() end
end
+8 -5
View File
@@ -70,15 +70,18 @@ return function(game)
U.tap(game, "right")
shot("03-tmhm") -- TMs carry ×NN, the two HMs carry none
-- SELECT arms the row instead of registering it (#1427): the hollow ▷ marks
-- TM_HEADBUTT while "Where should this be moved to?" holds the box.
-- SELECT armed the row here for #1427; since #1567 it is filtered out of the
-- TM/HM pocket entirely (engine/items/tmhm.asm:207), so these taps must leave
-- the list exactly as shot 03 has it: no hollow ▷, no "Where should this be
-- moved to?", no reorder.
U.tap(game, "down")
U.tap(game, "select")
shot("04-tmhm-armed")
shot("04-tmhm-select-ignored")
U.tap(game, "up")
shot("05-tmhm-destination")
shot("05-tmhm-still-numbered")
U.tap(game, "a")
shot("06-tmhm-moved") -- HEADBUTT is now the first TM row
U.tap(game, "b")
shot("06-tmhm-unchanged")
U.log("[driver] bag order:", table.concat(Bag.order(save), " "))
+112
View File
@@ -0,0 +1,112 @@
-- Assertion driver: a KEY ITEM and an HM through the player's item PC.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1486_test.lua \
-- perl -e 'alarm 300; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
--
-- Issue #1486. What the unit tier cannot show: the two screens a player
-- actually looks at. .DepositItem (engine/events/pokecenter_pc.asm:504) reads
-- CANT_TOSS only to force x1 and skip .AskQuantity, and PlaceMenuItemQuantity
-- (engine/menus/menu_2.asm:18) draws no ×NN under such a row.
--
-- Shots land in /tmp/gold-bug1486:
--
-- 01-key-items-pocket.png the DEPOSIT chooser on the KEY ITEMS pocket
-- 02-deposited.png "Deposited 1 BICYCLE(S)." -- before the fix this
-- is the unchanged pocket with no message at all
-- 03-pc-list.png the PC list: POTION with its ×03, BICYCLE and
-- HM01 with no ×NN at all
local U = require("tests.drivers.util")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1486"
local fails = 0
local function ok(cond, msg)
if cond then print("[1486] ok " .. msg)
else fails = fails + 1 print("[1486] FAIL " .. msg) end
return cond
end
local function tap(button, frames)
game.input.pressQueue[#game.input.pressQueue + 1] = button
game.input.state[button] = true
U.wait(2)
game.input.state[button] = false
U.wait(frames or 4)
end
U.wait(45)
local w = game.world
assert(w and w.map, "gold world did not boot")
local save = game.save
local Bag = require("src.inventory.Bag")
local Mon = require("src.battle.gen2.Mon")
save.party = { Mon.new(game.data, "CYNDAQUIL", 10) }
save.inventory = {}
save.pcItems = { POTION = 3 }
assert(Bag.add(save, "BICYCLE", 1, game.data), "BICYCLE into the bag")
assert(Bag.add(save, "HM_CUT", 1, game.data), "HM01 into the bag")
local function topId()
local top = game.stack:top()
return top and top.screenId or nil
end
assert(w:setMap("CHERRYGROVE_POKECENTER_1F", 4, 4, "up"),
"setMap CHERRYGROVE_POKECENTER_1F failed")
U.wait(5)
local pcX, pcY
for cy = 0, w.map.heightCells - 1 do
for cx = 0, w.map.widthCells - 1 do
if w.map:cellCollision(cx, cy) == 0x93 then pcX, pcY = cx, cy end
end
end
assert(pcX, "no COLL_PC tile in the Pokecenter")
assert(w:setMap("CHERRYGROVE_POKECENTER_1F", pcX, pcY + 1, "up"),
"setMap onto the PC tile failed")
U.wait(5)
tap("a", 8) -- PCScript
tap("a", 4) -- the turn-on line
tap("down", 4)
tap("a", 4) -- <PLAYER>'s PC
tap("a", 4)
tap("a", 6) -- both PokecenterPlayersPCText pages
ok(topId() == "Gen2ItemPcMenu",
"<PLAYER>'s PC opens the item PC (top: " .. tostring(topId()) .. ")")
-- DEPOSIT ITEM -> the PACK chooser -> the KEY ITEMS pocket.
tap("down", 4)
tap("a", 6)
tap("right", 4) -- ITEM -> BALL
tap("right", 4) -- BALL -> KEY ITEMS
U.shot(game, out .. "/01-key-items-pocket.png")
tap("a", 6) -- the BICYCLE row: x1, no prompt
ok(save.pcItems.BICYCLE == 1,
"the BICYCLE landed in the PC (" .. tostring(save.pcItems.BICYCLE) .. ")")
ok(save.inventory.BICYCLE == nil,
"and left the bag (" .. tostring(save.inventory.BICYCLE) .. ")")
U.shot(game, out .. "/02-deposited.png")
tap("a", 4) -- the Deposited line
-- The same, one pocket over: an HM.
tap("right", 4) -- KEY ITEMS -> TM/HM
tap("a", 6)
ok(save.pcItems.HM_CUT == 1,
"HM01 landed in the PC (" .. tostring(save.pcItems.HM_CUT) .. ")")
ok(save.inventory.HM_CUT == nil,
"and left the bag (" .. tostring(save.inventory.HM_CUT) .. ")")
tap("a", 4)
tap("b", 6) -- close the PACK
-- WITHDRAW ITEM: the list is where PlaceMenuItemQuantity shows.
tap("up", 4)
tap("a", 6)
U.shot(game, out .. "/03-pc-list.png")
tap("a", 6) -- the first row, x1 or the selector
print(("[1486] %d failures"):format(fails))
love.event.quit(fails == 0 and 0 or 1)
end
+142
View File
@@ -0,0 +1,142 @@
-- #1556, the two battle jingles: _NewDexDataText's sound_slot_machine_start
-- (data/text/common_3.asm:285) on a first catch, and _LearnedMoveText's
-- sound_dex_fanfare_50_79 (data/text/common_3.asm:119) on a level-up move.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1556_battle.lua \
-- perl -e 'alarm 420; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1556-battle (default)
--
-- Both ride the message queue's `sfx` + `waitSfx` keys, which the "Gotcha!"
-- line and the "grew to level" line already used -- the two lines next door
-- to them did not. So the ear is comparing neighbours: the catch jingle must
-- be followed by a SECOND, different one over the #DEX line, and the level-up
-- fanfare by a second over "learned".
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local Sound = require("src.core.Sound")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1556-battle"
local heard = {}
local realPlay = Sound.play
Sound.play = function(data, name)
heard[#heard + 1] = name
return realPlay(data, name)
end
local function reset() heard = {} end
local function report(label, want)
U.log(label, #heard > 0 and table.concat(heard, ", ") or "(silence)")
U.log(" want:", want)
end
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local save, data = game.save, game.data
local function battleScreen()
local top = game.stack:top()
return (top and top.battle) and top or nil
end
local function tapUntil(predicate, tries, btn)
for _ = 1, tries or 400 do
if predicate() then return true end
U.tap(game, btn or "a")
U.wait(2)
end
return predicate()
end
-- ---- 1. a first catch: "Gotcha!" then the #DEX line ---------------------
save.party = { Mon.new(data, "CYNDAQUIL", 20) }
save.inventory = { MASTER_BALL = 1 }
save.pokedexReceived = true
save.pokedex = { seen = {}, caught = {} }
local wild = Mon.new(data, "PIDGEY", 3)
assert(wild, "the cache carries no PIDGEY")
assert(world:startBattle({ wild = wild }), "the wild battle refused to start")
assert(tapUntil(function()
local screen = battleScreen()
return screen ~= nil and screen.phase == "menu"
end), "the battle never reached BattleMenu")
local screen = battleScreen()
-- BattleMenuHeader's 2x2 grid: 1 FIGHT / 2 PkMn, 3 PACK / 4 RUN.
if screen.menuIndex % 2 == 0 then U.tap(game, "left") U.wait(3) end
if screen.menuIndex <= 2 then U.tap(game, "down") U.wait(3) end
U.tap(game, "a")
U.wait(6)
local pack = game.stack:top()
assert(pack and pack.rows, "the battle PACK did not open")
local ballRow
for index, row in ipairs(pack.rows) do
if row.id == "MASTER_BALL" then ballRow = index end
end
assert(ballRow, "the battle PACK does not show the MASTER BALL")
for _ = 2, ballRow do U.tap(game, "down") U.wait(2) end
reset()
U.tap(game, "a")
U.wait(180) -- the throw, the wobbles, "Gotcha!"
report("01 catch:",
"Sfx_CaughtMon, then Sfx_SlotMachineStart over the #DEX line")
U.shot(game, out .. "/01-caught.png")
for _ = 1, 6 do U.tap(game, "a") U.wait(20) end
U.shot(game, out .. "/02-dex-line.png")
for _ = 1, 30 do
if not battleScreen() then break end
U.tap(game, "a")
U.wait(10)
end
-- ---- 2. a level-up that teaches a move ---------------------------------
local SPECIES = "CYNDAQUIL"
local def = data.pokemon and data.pokemon[SPECIES]
local target
for _, entry in ipairs((def and def.levelMoves) or {}) do
if entry.level and entry.level > 6 and not target then target = entry.level end
end
if not target then
U.log("SKIP 02 -- no level-up move row for " .. SPECIES)
else
local hero = Mon.new(data, SPECIES, target - 1)
local growth = Mon.growthFor(data, def.growthRate)
hero.experience = Mon.experienceForLevel(growth, target) - 1
-- Only three moves, so LearnMove takes the free slot rather than ForgetMove
-- (engine/pokemon/learn.asm:23-29).
while hero.moves and #hero.moves > 3 do table.remove(hero.moves) end
save.party = { hero }
save.inventory = {}
U.log(("levelling %s %d -> %d for its %s row")
:format(SPECIES, hero.level, target, tostring(target)))
local prey = Mon.new(data, "PIDGEY", 2)
prey.hp = 1
assert(world:startBattle({ wild = prey }), "the second battle refused")
assert(tapUntil(function()
local s = battleScreen()
return s ~= nil and s.phase == "menu"
end), "the second battle never reached BattleMenu")
screen = battleScreen()
if screen.menuIndex % 2 == 0 then U.tap(game, "left") U.wait(3) end
if screen.menuIndex > 2 then U.tap(game, "up") U.wait(3) end
reset()
U.tap(game, "a") -- FIGHT
U.wait(6)
U.tap(game, "a") -- the first move
U.wait(240)
report("02 level-up move:",
"Sfx_DexFanfare5079 twice: \"grew to level\" and \"learned\"")
U.shot(game, out .. "/03-level-up.png")
for _ = 1, 8 do U.tap(game, "a") U.wait(30) end
U.shot(game, out .. "/04-learned.png")
end
Sound.play = realPlay
U.log("done -- the controls are yours")
end
+196
View File
@@ -0,0 +1,196 @@
-- #1556: Bill's PC -- the deposit / withdraw / release cry, and the six
-- SFX_WRONG refusals (engine/pokemon/bills_pc.asm).
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1556_billspc.lua \
-- perl -e 'alarm 420; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1556-billspc (default)
--
-- Eight moments, each seeded, driven through the real screen and screenshotted.
-- The log names what sounded; the ear is the point. DepositPokemon and
-- TryWithdrawPokemon both `call PlayMonCry` right after RemoveMonFromPartyOrBox
-- (:1762 and :1817), and PlayMonCry itself bails on an EGG
-- (home/pokemon.asm:113), so a boxed egg must move in silence.
--
-- The run ends with the PC screen open so a human takes the controls there.
local U = require("tests.drivers.util")
local Boxes = require("src.core.gen2.Boxes")
local Mon = require("src.battle.gen2.Mon")
local Screens = require("src.ui.Screens")
local Sound = require("src.core.Sound")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1556-billspc"
local heard = {}
local realPlay, realCry = Sound.play, Sound.playCry
Sound.play = function(data, name)
heard[#heard + 1] = name
return realPlay(data, name)
end
Sound.playCry = function(data, species, clip)
heard[#heard + 1] = "cry:" .. tostring(species)
return realCry(data, species, clip)
end
local function reset() heard = {} end
local function report(label, want)
U.log(label, #heard > 0 and table.concat(heard, ", ") or "(silence)")
U.log(" want:", want)
end
local function tap(button, frames)
game.input.pressQueue[#game.input.pressQueue + 1] = button
game.input.state[button] = true
U.wait(2)
game.input.state[button] = false
U.wait(frames or 6)
end
U.wait(45)
local w = game.world
assert(w and w.map, "gold world did not boot")
local save = game.save
local data = game.data
local function mon(species, level)
return Mon.new(data, species, level)
end
local function openBox(mode)
Screens.push(game, "Gen2BoxMenu", {
save = save, mode = mode,
onClose = function() game.stack:pop() end,
})
U.wait(8)
return game.stack:top()
end
local function close()
while game.stack:top() and game.stack:top().submenuRows do
game.stack:pop()
end
U.wait(4)
end
local function fillBox(index, n, species)
local box = Boxes.box(save, index)
for i = #box, 1, -1 do box[i] = nil end
for _ = 1, n do box[#box + 1] = mon(species or "RATTATA", 5) end
end
local function setParty(n, species)
save.party = {}
for _ = 1, n do save.party[#save.party + 1] = mon(species or "CYNDAQUIL", 10) end
end
save.currentBox = 1
-- ---- 1. WITHDRAW a mon: its cry ------------------------------------------
setParty(1)
fillBox(1, 1, "PIDGEY")
local menu = openBox("withdraw")
reset()
tap("a") -- the submenu
tap("a", 12) -- WITHDRAW
report("01 withdraw:", "cry:PIDGEY")
U.shot(game, out .. "/01-withdraw-cry.png")
tap("a") -- clear anything on screen
close()
-- ---- 2. DEPOSIT it back: the cry again -----------------------------------
setParty(3)
save.party[#save.party + 1] = mon("PIDGEY", 5)
fillBox(1, 0)
menu = openBox("deposit")
for _ = 1, 3 do tap("down") end
reset()
tap("a")
tap("a", 12) -- DEPOSIT
report("02 deposit:", "cry:PIDGEY")
U.shot(game, out .. "/02-deposit-cry.png")
tap("a")
close()
-- ---- 3. WITHDRAW into a full party: SFX_WRONG (:1845) --------------------
setParty(Boxes.PARTY_SIZE)
fillBox(1, 1, "PIDGEY")
menu = openBox("withdraw")
reset()
tap("a")
tap("a", 12)
report("03 withdraw, party full:", "Sfx_Wrong + \"You can't take any more\"")
U.shot(game, out .. "/03-party-full.png")
tap("a")
close()
-- ---- 4. DEPOSIT into a full box: SFX_WRONG (:1790) ----------------------
setParty(3)
fillBox(1, Boxes.MONS_PER_BOX)
menu = openBox("deposit")
reset()
tap("a")
tap("a", 12)
report("04 deposit, box full:", "Sfx_Wrong + \"The BOX is full.\"")
U.shot(game, out .. "/04-box-full.png")
tap("a")
close()
-- ---- 5. DEPOSIT the last healthy mon: SFX_WRONG -------------------------
setParty(2)
save.party[2].hp = 0
fillBox(1, 0)
menu = openBox("deposit")
reset()
tap("a")
tap("a", 12)
report("05 deposit, last healthy:", "Sfx_Wrong + \"can't deposit the last\"")
U.shot(game, out .. "/05-last-mon.png")
tap("a")
close()
-- ---- 6. RELEASE a boxed mon: its cry (:1866) ----------------------------
setParty(3)
fillBox(1, 1, "SENTRET")
menu = openBox("withdraw")
reset()
tap("select", 8) -- RELEASE asks first
tap("up") -- the YES/NO box defaults to NO
tap("a", 14)
report("06 release:", "cry:SENTRET before \"was released.\"")
U.shot(game, out .. "/06-release-cry.png")
tap("a")
close()
-- ---- 7. RELEASE an EGG: SFX_WRONG, no cry (:1625) -----------------------
setParty(3)
fillBox(1, 0)
local egg = mon("TOGEPI", 5)
egg.isEgg = true
Boxes.box(save, 1)[1] = egg
menu = openBox("withdraw")
reset()
tap("select", 10)
report("07 release an EGG:", "Sfx_Wrong, and NO cry:TOGEPI")
U.shot(game, out .. "/07-release-egg.png")
tap("a")
close()
-- ---- 8. MOVE into a full box: SFX_WRONG (:1567) -------------------------
setParty(3)
fillBox(1, 1, "HOOTHOOT")
fillBox(2, Boxes.MONS_PER_BOX)
menu = openBox("move")
reset()
tap("a") -- the MOVE/STATS/CANCEL submenu
tap("a", 8) -- MOVE -> the insert cursor
tap("right", 8) -- box 2, which is full
tap("a", 10)
report("08 move into a full box:", "Sfx_Wrong + \"There's no room!\"")
U.shot(game, out .. "/08-no-room.png")
Sound.play, Sound.playCry = realPlay, realCry
U.log("done -- the PC is open; the controls are yours")
end
+203
View File
@@ -0,0 +1,203 @@
-- #1556: the bump sound, which the Gen 2 port never wired.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1556_bump.lua \
-- perl -e 'alarm 420; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1556-bump (default)
--
-- Nothing here can be asserted by a unit test: the deliverable is the sound.
-- What the log gives a reader is the RATE, which is the half that is easy to
-- get wrong -- .BumpSound is `call CheckSFX / ret c`
-- (engine/overworld/player_movement.asm:771), a busy-channel test rather than
-- a frame counter, so a held direction re-rings only once the previous Sfx_Bump
-- has stopped. A per-frame count means Sound.sfxBusy() is not seeing it.
--
-- Five moments, in the cart's own terms:
-- 1. a wall .CheckLandPerms carry -> .bump (:264-265)
-- 2. an NPC .CheckNPC = 0 -> .bump (:267-269)
-- 3. a ledge hop .TryJump's carry returns ABOVE .NotMoving (:80-81),
-- so the hop is Sfx_JumpOverLedge and NO bump
-- 4. a map connection the step is taken; silence
-- 5. a map edge with no connection behind it: the border block is a wall
--
-- The run ends in the overworld so a human takes the controls where it stops.
local U = require("tests.drivers.util")
local Permissions = require("src.world.gen2.Permissions")
local Sound = require("src.core.Sound")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1556-bump"
local heard = {}
local realPlay = Sound.play
Sound.play = function(data, name)
heard[#heard + 1] = name
return realPlay(data, name)
end
local function reset() heard = {} end
local function report(label)
local counts, order = {}, {}
for _, name in ipairs(heard) do
if not counts[name] then order[#order + 1] = name end
counts[name] = (counts[name] or 0) + 1
end
local parts = {}
for _, name in ipairs(order) do
parts[#parts + 1] = ("%s x%d"):format(name, counts[name])
end
U.log(label, #parts > 0 and table.concat(parts, ", ") or "(silence)")
return counts
end
U.wait(45)
local w = game.world
assert(w and w.map, "gold world did not boot")
local function walkable(map, x, y)
return map:inBounds(x, y) and map:isWalkable(x, y)
end
local DELTA = { up = { 0, -1 }, down = { 0, 1 },
left = { -1, 0 }, right = { 1, 0 } }
-- A standing cell whose neighbour in `dir` is a wall.
local function findWall(map)
for y = 0, map.heightCells - 1 do
for x = 0, map.widthCells - 1 do
if walkable(map, x, y) then
for dir, d in pairs(DELTA) do
local nx, ny = x + d[1], y + d[2]
if map:inBounds(nx, ny) and not map:isWalkable(nx, ny) then
return x, y, dir
end
end
end
end
end
end
-- ---- 1. a wall -----------------------------------------------------------
assert(w:setMap("PLAYERS_HOUSE_1F", 4, 4, "down"), "setMap PLAYERS_HOUSE_1F")
U.wait(5)
local wx, wy, wdir = findWall(w.map)
if wx then
assert(w:setMap("PLAYERS_HOUSE_1F", wx, wy, wdir))
U.wait(8)
reset()
U.hold(game, wdir, 90)
U.wait(5)
local counts = report("01 wall (" .. wdir .. ", 90 frames held):")
U.log(" Sfx_Bump over 90 held frames:", counts.Sfx_Bump or 0,
"-- a per-frame count here means the CheckSFX gate is not working")
U.shot(game, out .. "/01-wall.png")
else
U.log("SKIP 01 wall -- no wall cell found in PLAYERS_HOUSE_1F")
end
-- ---- 2. an NPC -----------------------------------------------------------
local npc, npcDir
for _, e in ipairs(w.entities or {}) do
if e ~= w.player and e.cellX and not e.bigObject then
for dir, d in pairs(DELTA) do
local sx, sy = e.cellX - d[1], e.cellY - d[2]
if walkable(w.map, sx, sy) then npc, npcDir = { sx, sy }, dir break end
end
end
if npc then break end
end
if npc then
assert(w:setMap("PLAYERS_HOUSE_1F", npc[1], npc[2], npcDir))
U.wait(8)
reset()
U.hold(game, npcDir, 45)
U.wait(5)
report("02 NPC (" .. npcDir .. "):")
U.shot(game, out .. "/02-npc.png")
else
U.log("SKIP 02 NPC -- no reachable NPC in PLAYERS_HOUSE_1F")
end
-- ---- 3. a ledge ----------------------------------------------------------
local ROUTES = { "ROUTE_29", "ROUTE_30", "ROUTE_31", "ROUTE_32",
"ROUTE_33", "ROUTE_34", "ROUTE_35", "ROUTE_36" }
local hopped = false
for _, mapId in ipairs(ROUTES) do
if w:setMap(mapId, 5, 5, "down") then
U.wait(5)
local map = w.map
for y = 0, map.heightCells - 1 do
for x = 0, map.widthCells - 1 do
local facings = Permissions.ledgeFacings(map:cellCollision(x, y))
if facings then
for dir, on in pairs(facings) do
local d = on and DELTA[dir]
if d and walkable(map, x + d[1] * 2, y + d[2] * 2) then
assert(w:setMap(mapId, x, y, dir))
U.wait(8)
reset()
U.hold(game, dir, 40)
U.wait(20)
local counts = report(
("03 ledge hop (%s %s):"):format(mapId, dir))
U.log(" want Sfx_JumpOverLedge and NO Sfx_Bump; bumps:",
counts.Sfx_Bump or 0)
U.shot(game, out .. "/03-ledge.png")
hopped = true
break
end
end
end
if hopped then break end
end
if hopped then break end
end
end
if hopped then break end
end
if not hopped then U.log("SKIP 03 ledge -- no hoppable ledge found") end
-- ---- 4 and 5. map edges --------------------------------------------------
assert(w:setMap("NEW_BARK_TOWN", 5, 5, "down"), "setMap NEW_BARK_TOWN")
U.wait(5)
local DIR_CONN = { up = "north", down = "south", left = "west",
right = "east" }
local map = w.map
local function edgeCell(dir)
local d = DELTA[dir]
for y = 0, map.heightCells - 1 do
for x = 0, map.widthCells - 1 do
if walkable(map, x, y)
and not map:inBounds(x + d[1], y + d[2]) then
return x, y
end
end
end
end
for _, dir in ipairs({ "up", "down", "left", "right" }) do
local conn = map:connection(DIR_CONN[dir])
local ex, ey = edgeCell(dir)
if ex then
assert(w:setMap("NEW_BARK_TOWN", ex, ey, dir))
U.wait(8)
reset()
U.hold(game, dir, 40)
U.wait(20)
local counts = report(("%s edge %s (connection: %s):")
:format(conn and "04" or "05", dir,
tostring(conn and conn.mapId or "none")))
U.log(" want", conn and "silence" or "Sfx_Bump", "-- bumps:",
counts.Sfx_Bump or 0)
U.shot(game, out .. ("/0%s-edge-%s.png"):format(conn and "4" or "5", dir))
assert(w:setMap("NEW_BARK_TOWN", ex, ey, dir))
U.wait(5)
end
end
Sound.play = realPlay
assert(w:setMap("NEW_BARK_TOWN", 5, 5, "down"))
U.log("done -- the controls are yours; walk into anything")
end
+207
View File
@@ -0,0 +1,207 @@
-- #1556: the field jingles that ride a TX_SOUND at the end of a string --
-- RARE CANDY's level-up, "X learned MOVE!", the trophy -- plus the TM/HM
-- refusals, which are `call PlaySFX` next to the message instead.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1556_fanfare.lua \
-- perl -e 'alarm 420; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1556-fanfare (default)
--
-- The one deliberate SILENCE is moment 4: TeachTMHM's `.compatible` arm is
-- `callfar KnowsMove / jr c, .nope` (engine/items/tmhm.asm:139-140) with no
-- PlaySFX at all, so "X already knows MOVE!" must stay mute. Only the
-- INCOMPATIBLE arm rings (:131).
--
-- The box is supposed to HOLD for the fanfare before it takes a button:
-- TextCommand_SOUND is PlaySFX then WaitSFX (home/text.asm), which
-- TextBox.soundOpts models with auto.wait.
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local PackMenu = require("src.ui.gen2.PackMenu")
local Sound = require("src.core.Sound")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1556-fanfare"
local heard = {}
local realPlay = Sound.play
Sound.play = function(data, name)
heard[#heard + 1] = name
return realPlay(data, name)
end
local function reset() heard = {} end
local function report(label, want)
U.log(label, #heard > 0 and table.concat(heard, ", ") or "(silence)")
U.log(" want:", want)
end
local function tap(button, frames)
game.input.pressQueue[#game.input.pressQueue + 1] = button
game.input.state[button] = true
U.wait(2)
game.input.state[button] = false
U.wait(frames or 6)
end
U.wait(45)
local w = game.world
assert(w and w.map, "gold world did not boot")
local save, data = game.save, game.data
local SPECIES = "CYNDAQUIL"
local def = data.pokemon and data.pokemon[SPECIES]
assert(def, "the cache carries no " .. SPECIES)
-- Which TMs this species may and may not learn, straight off its BASE_TMHM.
local canLearn, cannotLearn = nil, nil
local allowed = {}
for _, moveId in ipairs(def.tmhm or {}) do allowed[moveId] = true end
for itemId, item in pairs(data.items or {}) do
if item.teaches and tostring(itemId):sub(1, 3) == "TM_" then
if allowed[item.teaches] then
canLearn = canLearn or itemId
else
cannotLearn = cannotLearn or itemId
end
end
end
U.log("TM that fits:", tostring(canLearn),
"-- TM that does not:", tostring(cannotLearn))
local function seed()
local mon = Mon.new(data, SPECIES, 12)
save.party = { mon }
return mon
end
local function openPack()
local pack = PackMenu.new(game, {
save = save, world = w, onClose = function() end })
game.stack:push(pack)
U.wait(6)
return pack
end
local field = game.overworld or game.stack:top()
local function popToField()
for _ = 1, 12 do
if game.stack:top() == field or not game.stack:top() then break end
game.stack:pop()
U.wait(2)
end
end
-- ---- 1. RARE CANDY: "X grew to level N!" + the level-up fanfare ---------
local mon = seed()
save.inventory = { RARE_CANDY = 2 }
reset()
game:usePartyItem("RARE_CANDY")
U.wait(8)
tap("a", 30) -- the party pick
report("01 rare candy:", "Sfx_DexFanfare5079 under \"grew to level 13!\"")
U.shot(game, out .. "/01-rare-candy.png")
tap("a", 10)
popToField()
-- ---- 2. a TM the mon CAN learn: "X learned MOVE!" + the fanfare ---------
if canLearn then
mon = seed()
save.inventory = { [canLearn] = 1 }
local pack = openPack()
reset()
pack:openTeachParty({ id = canLearn })
U.wait(10)
tap("a", 30) -- the party pick
report("02 TM " .. canLearn .. ":",
"Sfx_DexFanfare5079 under \"learned ...!\"")
U.shot(game, out .. "/02-tm-learned.png")
tap("a", 10)
popToField()
else
U.log("SKIP 02 -- no TM in this cache that " .. SPECIES .. " can learn")
end
-- ---- 3. a TM the mon CANNOT learn: SFX_WRONG (tmhm.asm:131) ------------
if cannotLearn then
mon = seed()
save.inventory = { [cannotLearn] = 1 }
local pack = openPack()
reset()
pack:openTeachParty({ id = cannotLearn })
U.wait(10)
tap("a", 20)
report("03 TM " .. cannotLearn .. ":",
"Sfx_Wrong under \"can't learn ...!\"")
U.shot(game, out .. "/03-tm-refused.png")
tap("a", 10)
popToField()
else
U.log("SKIP 03 -- every TM in this cache fits " .. SPECIES)
end
-- ---- 4. a TM the mon ALREADY KNOWS: SILENCE ----------------------------
if canLearn then
mon = seed()
local moveId = data.items[canLearn].teaches
mon.moves = mon.moves or {}
mon.moves[1] = { id = moveId, pp = 10, maxPp = 10 }
save.inventory = { [canLearn] = 1 }
local pack = openPack()
reset()
pack:openTeachParty({ id = canLearn })
U.wait(10)
tap("a", 20)
report("04 TM already known:", "SILENCE -- KnowsMove has no PlaySFX")
U.shot(game, out .. "/04-tm-already-known.png")
tap("a", 10)
popToField()
end
-- ---- 5. a TM on an EGG: SFX_WRONG (tmhm.asm:104-111), a regression -----
if canLearn then
mon = seed()
mon.isEgg = true
save.inventory = { [canLearn] = 1 }
local pack = openPack()
reset()
pack:openTeachParty({ id = canLearn })
U.wait(10)
tap("a", 20)
report("05 TM on an EGG:", "Sfx_Wrong, and the list stays up")
U.shot(game, out .. "/05-tm-egg.png")
tap("b", 6)
popToField()
end
-- ---- 6. the trophy box: _SentTrophyHomeText's fanfare ------------------
if data.items and data.items.NORMAL_BOX then
seed()
save.inventory = { NORMAL_BOX = 1 }
save.decorations = save.decorations or {}
local pack = openPack()
pack:rebuild()
local row
for index, entry in ipairs(pack.rows) do
if entry.id == "NORMAL_BOX" then row = index end
end
if row then
pack.index = row
reset()
pack:useSelected()
U.wait(20)
report("06 trophy box:", "Sfx_DexFanfare5079 with the trophy line")
U.shot(game, out .. "/06-trophy.png")
tap("a", 6)
else
U.log("SKIP 06 -- the NORMAL BOX is not in the ITEM pocket here")
end
popToField()
else
U.log("SKIP 06 -- this cache has no NORMAL_BOX")
end
Sound.play = realPlay
U.log("done -- the controls are yours")
end
+105
View File
@@ -0,0 +1,105 @@
-- #1567: the TM/HM pocket's row order.
--
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1567_test.lua love .
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1567 (default)
--
-- Nothing here can be asserted: the bug IS the order rows land in.
-- TMHM_DisplayPocketItems walks the fixed 57-byte wTMsHMs array from 1 to 57
-- and prints every non-zero slot (engine/items/tmhm.asm:341), so the cart's
-- pocket is always TM01..TM50 then HM01..HM07 no matter what the player picked
-- up first. The port drew it from the acquisition-ordered bag list instead.
--
-- The seed is deliberately scrambled -- gold_bug1425_test.lua seeds its TMs
-- already in numeric order, which is why its screenshots never showed this.
--
-- The run ends with the PACK still open on the TM pocket, so a human takes the
-- controls exactly where the screenshots stop.
local U = require("tests.drivers.util")
local Bag = require("src.inventory.Bag")
local PackMenu = require("src.ui.gen2.PackMenu")
-- Picked up back to front: TM/HM numbers 57, 50, 51, 5, 1, 53. TM_ROAR is the
-- one on the far side of the ITEM_C3 hole (id $c4, number 5), so it also pins
-- that the number and not the item id is what sorts.
local SEED = {
{ "HM_WATERFALL", 1 },
{ "TM_NIGHTMARE", 2 },
{ "HM_CUT", 1 },
{ "TM_ROAR", 3 },
{ "TM_DYNAMICPUNCH", 12 },
{ "HM_SURF", 1 },
}
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1567"
local function shot(name)
U.wait(3)
U.shot(game, ("%s/%s.png"):format(out, name))
end
U.wait(45)
assert(game.world and game.world.map, "gold world did not boot")
local save = game.save
save.inventory = {}
save.bagOrder = {}
for _, entry in ipairs(SEED) do
local id, count = entry[1], entry[2]
if not (game.data.items and game.data.items[id]) then
U.log("[driver] SKIP", id, "-- not in this cache")
else
save.inventory[id] = count
table.insert(Bag.order(save), id)
end
end
-- A POTION and a SUPER POTION so the ITEM pocket can be compared against the
-- TM pocket at the end: SELECT must still arm a row over there.
for _, entry in ipairs({ { "POTION", 5 }, { "SUPER_POTION", 2 } }) do
if game.data.items and game.data.items[entry[1]] then
save.inventory[entry[1]] = entry[2]
table.insert(Bag.order(save), entry[1])
end
end
U.log("[driver] pickup order:", table.concat(Bag.order(save), " "))
local pack = PackMenu.new(game, { save = save, world = game.world,
onClose = function() end })
game.stack:push(pack)
shot("00-items") -- POTION over SUPER POTION, pickup order
U.tap(game, "right")
U.tap(game, "right")
U.tap(game, "right")
-- TM01 DYNAMICPUNCH ×12, TM05 ROAR ×03, TM50 NIGHTMARE ×02, then HM01 CUT,
-- HM03 SURF, HM07 WATERFALL with no ×NN at all (engine/items/tmhm.asm:390).
-- Before the fix this read HM07, TM50, HM01, TM05, TM01, HM03.
shot("01-tmhm-numbered")
local order = {}
for i = 1, #pack.rows do order[i] = pack.rows[i].id end
U.log("[driver] TM/HM rows:", table.concat(order, " "))
-- engine/items/tmhm.asm:207 filters SELECT out of this pocket: no hollow ▷,
-- no "Where should this be moved to?".
U.tap(game, "select")
shot("02-tmhm-select-ignored")
U.tap(game, "down")
U.tap(game, "select")
shot("03-tmhm-select-ignored-row2")
-- The ITEM pocket, where SELECT still arms (engine/items/pack.asm:1290).
U.tap(game, "right")
U.tap(game, "select")
shot("04-items-select-arms")
U.tap(game, "b")
-- Back to the TM pocket for the human.
U.tap(game, "right")
U.tap(game, "right")
U.tap(game, "right")
U.log("[driver] shots in " .. out .. " -- the PACK is yours")
end
@@ -0,0 +1,197 @@
-- #1513: exp credit has to be per THIS appearance of the enemy's active mon.
-- ResetBattleParticipants falls through into AddBattleParticipant
-- (engine/battle/core.asm:3033 and :3037), and AI_Switch farcalls it right
-- after EnemySwitch (engine/battle/ai/items.asm:697), so the moment the rival
-- rotates, the only mon still credited is the one the player has on the field.
--
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_IDENTITY=gold-dev \
-- POKEPORT_DRIVER=tests/drivers/gold_exp_participants_bug1513.lua love .
--
-- What to look for, in order:
-- 00 CYNDAQUIL is out against JOEY's GEODUDE.
-- 01 TOTODILE has been switched in through the real party menu, so BOTH
-- party mons have now "met" GEODUDE.
-- 02 "JOEY withdrew GEODUDE!" -- the rotation the ticket is about.
-- 03 the exp lines for the KO of the mon that came IN (PIDGEY). Only
-- TOTODILE may be named here. A "CYNDAQUIL gained N EXP. Points!" line
-- in this shot is the bug.
-- 04 the party screen: CYNDAQUIL's level and EXP bar are pixel-identical to
-- shot 01's, TOTODILE's have moved.
-- 05 GEODUDE comes back out and is KO'd. CYNDAQUIL still gains nothing --
-- that half is correct cart behavior (ram/wram.asm:775-776, "All bits
-- cleared if the enemy faints") and must NOT be "fixed".
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
if screen.battle.over then return false end
U.tap(game, "a")
U.wait(3)
end
return false
end
-- Page the queue out, shooting the first box that carries `needle`.
local function pageUntilMenu(game, screen, out, needles)
local shot = {}
for _ = 1, 1200 do
local message = screen.message
if message then
for needle, path in pairs(needles) do
if not shot[needle] and message:find(needle, 1, true) then
U.shot(game, out .. "/" .. path)
shot[needle] = message
end
end
end
if screen.phase == "menu" or screen.phase == "done"
or screen.battle.over then
break
end
U.tap(game, "a")
U.wait(3)
end
return shot
end
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1513"
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
local lead = Mon.new(game.data, "CYNDAQUIL", 20)
lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
local bench = Mon.new(game.data, "TOTODILE", 20)
bench.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
game.save.party = { lead, bench }
local foe1 = Mon.new(game.data, "GEODUDE", 8)
foe1.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
local foe2 = Mon.new(game.data, "PIDGEY", 8)
foe2.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
-- attributes[6] is the low byte of TRNATTR_AI_ITEM_SWITCH: OFTEN
-- (src/battle/gen2/Ai.lua:1307). The real Rival1 class is SWITCH_SOMETIMES
-- (pokegold:data/trainers/attributes.asm), the same path at a lower roll.
assert(world:startBattle({ trainer = { class = "YOUNGSTER", name = "JOEY",
party = { foe1, foe2 }, attributes = { 0, 0, 0, 0, 0, 0x01, 0 } } }),
"trainer startBattle failed")
local screen = battleScreen(game)
assert(runToPhase(game, screen, "menu", 400), "never reached the menu")
U.shot(game, out .. "/00-lead-out.png")
------------------------------------------------- the player's own switch
-- Through the real party menu, so the human sees the same path they used.
screen:chooseMenu("party")
for _ = 1, 240 do
if screen.battle.player == bench then break end
local top = game.stack:top()
if top ~= screen then
U.tap(game, "down")
U.wait(3)
U.tap(game, "a")
U.wait(6)
U.tap(game, "a")
U.wait(6)
else
U.wait(2)
end
end
check(screen.battle.player == bench, "TOTODILE was switched in")
check(screen.battle.participants[1] and screen.battle.participants[2],
"both party mons are credited against GEODUDE")
pageUntilMenu(game, screen, out, {})
U.shot(game, out .. "/01-switched.png")
print(("[driver] before the rotation: cyndaquil %d totodile %d")
:format(lead.experience, bench.experience))
local leadExp = lead.experience
local benchExp = bench.experience
------------------------------------------------------- the AI's rotation
-- Perish Song at one turn left is CheckAbleToSwitch's maximum score, the
-- cheapest way to make the AI commit to the rotation on demand.
screen.battle:volatile(screen.battle.enemy).perish = 1
screen:submit({ kind = "move", move = "TACKLE" })
local seen = pageUntilMenu(game, screen, out, {
["withdrew"] = "02-rival-withdrew.png",
["gained"] = "03-exp-lines.png",
})
check(seen["withdrew"] ~= nil, "JOEY withdrew GEODUDE")
check(screen.battle.enemy == foe2, "and sent PIDGEY out")
check(screen.battle.participants[1] == nil,
"CYNDAQUIL lost its credit when the rival rotated")
check(screen.battle.participants[2] == true,
"and TOTODILE, the mon on the field, kept it")
------------------------------------------------------------ the KO payout
if not screen.battle.over and (foe2.hp or 0) > 0 then
if screen.phase ~= "menu" then
runToPhase(game, screen, "menu", 400)
end
foe2.hp = 1
screen:submit({ kind = "move", move = "TACKLE" })
local paid = pageUntilMenu(game, screen, out, {
["gained"] = "03-exp-lines.png",
})
if paid["gained"] then print("[driver] exp box: " .. paid["gained"]) end
end
print(("[driver] after the PIDGEY KO: cyndaquil %d totodile %d")
:format(lead.experience, bench.experience))
check(lead.experience == leadExp,
"CYNDAQUIL earned NOTHING from the mon it never faced")
check(bench.experience > benchExp, "TOTODILE was paid for it")
if screen.phase == "menu" then
screen:chooseMenu("party")
U.wait(20)
U.shot(game, out .. "/04-party-exp.png")
U.tap(game, "b")
U.wait(10)
if game.stack:top() ~= screen then
U.tap(game, "b")
U.wait(10)
end
end
------------------------------------------- GEODUDE comes back and faints
-- Correct cart behavior: no residual credit for a previous appearance.
leadExp = lead.experience
benchExp = bench.experience
if not screen.battle.over then
runToPhase(game, screen, "menu", 600)
if screen.phase == "menu" and screen.battle.enemy == foe1 then
foe1.hp = 1
screen:submit({ kind = "move", move = "TACKLE" })
pageUntilMenu(game, screen, out, { ["gained"] = "05-geodude-ko.png" })
print(("[driver] after the GEODUDE KO: cyndaquil %d totodile %d")
:format(lead.experience, bench.experience))
check(lead.experience == leadExp,
"CYNDAQUIL still earns nothing when GEODUDE comes back (correct)")
check(bench.experience > benchExp, "and TOTODILE takes the whole share")
else
print("[driver] GEODUDE never came back out; second half skipped")
end
end
if #failures > 0 then
error(#failures .. " exp participant checks failed")
end
print("[driver] #1513 fixed; shots in " .. out)
end
+23 -6
View File
@@ -221,6 +221,13 @@ return function(game)
stored[i] = mon(species, 10 + i)
end
Boxes.rename(save, 2, "GRASS")
-- $5d for a held item, $5c for MAIL (engine/pokemon/bills_pc.asm:1079-1094).
-- A boxed mon can never hold mail, so the letter goes on a party mon.
stored[1].item = "BERRY"
save.party[2].item = "FLOWER_MAIL"
local Mail = require("src.core.gen2.Mail")
Mail.set(save, 2, Mail.entry("FLOWER_MAIL", "HI THERE!",
save.player.name or "GOLD", save.player.id or 0, save.party[2].species))
local pc = PcMenu.new(game, { save = save })
show("20-pc-menu", pc)
@@ -228,12 +235,22 @@ return function(game)
pc.pickIndex = 2
show("21-pc-changebox", pc)
show("22-pc-withdraw", BoxMenu.new(game, {
save = save, mode = "withdraw",
}))
show("23-pc-deposit", BoxMenu.new(game, {
save = save, mode = "deposit",
}))
-- Browsing paints the 7x7 block in BillsPCOrangePalette and only .PrepSubmenu
-- swaps the mon's colours in (engine/pokemon/bills_pc.asm:305-309, :356-369,
-- engine/gfx/cgb_layouts.asm:284-289); the icon is BG palette 0 either way.
local wd = BoxMenu.new(game, { save = save, mode = "withdraw" })
show("22-pc-withdraw", wd)
wd.index = wd:total() -- CANCEL
wd:ensureVisible()
show("22b-pc-withdraw-cancel", wd)
wd.index = 1
wd:ensureVisible()
wd.phase = "submenu"
show("22c-pc-withdraw-submenu", wd)
local dep = BoxMenu.new(game, { save = save, mode = "deposit" })
show("23-pc-deposit", dep)
dep.index = 2 -- the mon holding FLOWER MAIL
show("23b-pc-deposit-mail", dep)
-- The two clock screens NEW GAME and Mom open (timeset.asm InitClock and
-- SetDayOfWeek), each at its picker rather than at its opening page.
@@ -0,0 +1,120 @@
-- Driver: #1517, a Gen 2 stat block in a Gen 1 party.
--
-- Gen 1's party_struct carries one Special word (macros/ram.asm:28-37) and
-- PrintStatsBox reads four fixed cells, wLoadedMonAttack/Defense/Speed/Special
-- (engine/pokemon/status_screen.asm:255-296). Gen 2 splits that word into
-- SpclAtk/SpclDef (pokegold macros/ram.asm:29-42), so a mod that wrote a Gen 2
-- block over a Yellow party leaves the port with no `special` to print.
--
-- cp -R ~/Library/Application\ Support/LOVE/pokemon-love2d/yellow \
-- ~/Library/Application\ Support/LOVE/bug1517/yellow
-- POKEPORT_VERSION=yellow POKEPORT_GAME=yellow POKEPORT_IDENTITY=bug1517 \
-- POKEPORT_TOUCH=0 POKEPORT_SPEED=8 SHOT_DIR=/tmp/bug1517 \
-- POKEPORT_DRIVER=tests/drivers/summary_stats_bug1517_test.lua love .
--
-- The copy is not optional: a fresh POKEPORT_IDENTITY has no cache and the
-- boot hangs with no output.
--
-- Pre-fix the LOVE window vanishes the moment STATS is chosen, with no error
-- screen and nothing on stdout (that silence is the other half of #1517).
-- Post-fix the status screen opens and SPECIAL reads the CalcStats value
-- logged below. The run ends on the open screen, so a human takes the
-- controls at exactly the frame the reporter's build died on.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/bug1517"
local Pokemon = require("src.pokemon.Pokemon")
local Stats = require("src.pokemon.Stats")
local SaveData = require("src.core.SaveData")
local SummaryMenu = require("src.ui.SummaryMenu")
local function top() return game.stack:top() end
U.newGame(game)
U.wait(20)
-- BUTTERFREE because the reporter's party led with one, and CONFUSION is a
-- special move, so the same block would have crashed Damage.applyStage.
local species = game.data.pokemon.BUTTERFREE and "BUTTERFREE" or "PIDGEY"
local def = game.data.pokemon[species]
local mon = Pokemon.new(game.data, species, 21)
local want = Stats.calc(def, 21, mon.dvs, mon.statExp)
-- the shape src/battle/gen2/Mon.lua Mon.stats produces: no `special`
mon.stats = {
hp = want.hp, attack = want.attack, defense = want.defense,
speed = want.speed, specialAttack = want.special, specialDefense = want.special,
}
mon.hp = mon.stats.hp
game.save.party = { mon }
U.log(("[1517] fixture: %s :L21, stats.special = %s, CalcStats says %d")
:format(species, tostring(mon.stats.special), want.special))
SaveData.validate(game.save, game.data)
local after = game.save.party[1].stats
U.log("[1517] after SaveData.validate, special = " .. tostring(after.special)
.. ", specialAttack = " .. tostring(after.specialAttack))
U.log("[1517] the repair layer is SaveData.validate -> scrubKnownMon -> "
.. "Stats.ensure; a nil above means it did not run")
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(10)
local function cursorTo(menu, field, wanted)
for _ = 1, 40 do
if not menu or menu[field] == wanted then return menu and menu[field] == wanted end
U.tap(game, menu[field] < wanted and "down" or "up")
U.wait(3)
end
return menu[field] == wanted
end
U.tap(game, "start")
U.wait(10)
local menu = top()
if not (menu and menu.screenId == "StartMenu") then
U.log("[1517] FAIL no start menu; top = " .. tostring(menu and menu.screenId))
while true do coroutine.yield() end
end
local row
for i, it in ipairs(menu.items or {}) do
if it.label == "POKéMON" then row = i break end
end
if not (row and cursorTo(menu, "index", row)) then
U.log("[1517] FAIL could not reach the POKéMON row")
while true do coroutine.yield() end
end
U.tap(game, "a")
U.wait(10)
local party = top()
if not (party and party.screenId == "PartyMenu") then
U.log("[1517] FAIL no party menu; top = " .. tostring(party and party.screenId))
while true do coroutine.yield() end
end
U.shot(game, DIR .. "/01-party-list.png")
U.tap(game, "a")
U.wait(8)
local subRow
for i, it in ipairs(party.subItems or {}) do
if it.action == "stats" then subRow = i break end
end
if not (subRow and cursorTo(party, "subIndex", subRow)) then
U.log("[1517] FAIL could not reach the STATS row")
while true do coroutine.yield() end
end
U.shot(game, DIR .. "/02-stats-selected.png")
U.log("[1517] opening STATS -- pre-fix the app dies here, silently")
U.tap(game, "a")
U.wait(30)
local screen = top()
local opened = getmetatable(screen) == SummaryMenu or screen.screenId == "SummaryMenu"
U.log("[1517] SURVIVED, top = " .. tostring(screen and screen.screenId))
U.shot(game, DIR .. "/03-status-screen.png")
U.log(("[1517] %s: SPECIAL on screen must read %d")
:format(opened and "read it off the frame" or "WRONG SCREEN", want.special))
U.log("[1517] shots in " .. DIR .. " -- the status screen is yours")
while true do coroutine.yield() end
end
@@ -0,0 +1,100 @@
-- A Gen 1 party mon carries one Special word (macros/ram.asm:28-37) and
-- CalcStats writes all NUM_STATS stats or none (home/move_mon.asm:33-48,
-- constants/battle_constants.asm:11-17), so a block missing a key is not a
-- Gen 1 record and the load-time repair must rebuild it. Gen 2 splits it
-- into SpclAtk/SpclDef (pokegold macros/ram.asm:29-42). #1517
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 Stats = require("src.pokemon.Stats")
local SaveData = require("src.core.SaveData")
local data = {
pokemon = { FIXMON_A = { id = "FIXMON_A", name = "FIXMON A", types = { "GRASS" },
baseStats = { hp = 45, attack = 49, defense = 49, speed = 45, special = 65 } } },
moves = { FIX_TACKLE = { id = "FIX_TACKLE", name = "TACKLE", pp = 35 } },
items = {}, maps = { FIXMAP = { id = "FIXMAP" } },
constants = { fallbackMove = "FIX_TACKLE" },
}
local DEF = data.pokemon.FIXMON_A
local DVS = { hp = 15, attack = 9, defense = 8, speed = 8, special = 8 }
-- src/battle/gen2/Mon.lua Mon.stats' shape: no `special`
local function gen2Shaped()
return {
species = "FIXMON_A", level = 21, hp = 62, exp = 9000,
dvs = { hp = 15, attack = 9, defense = 8, speed = 8, special = 8 },
statExp = {},
stats = { hp = 62, attack = 27, defense = 29, speed = 37,
specialAttack = 8, specialDefense = 8 },
moves = { { id = "FIX_TACKLE", pp = 35 } },
}
end
do
local mon = gen2Shaped()
eq(mon.stats.special, nil, "premise: a Gen 2 block has no `special`")
Stats.ensure(DEF, mon)
local want = Stats.calc(DEF, 21, DVS, {})
for _, key in ipairs(Stats.ORDER) do
eq(mon.stats[key], want[key], "ensure rebuilds " .. key .. " from CalcStats")
end
eq(mon.stats.specialAttack, nil, "and drops the Gen 2 keys")
eq(mon.stats.specialDefense, nil, "both of them")
end
do
local mon = gen2Shaped()
local save = { party = { mon }, boxes = {}, inventory = {}, pcItems = {},
player = { map = "FIXMAP", x = 1, y = 1, name = "RED", id = 1 } }
SaveData.validate(save, data)
check(type(save.party[1].stats.special) == "number",
"validate repairs a party mon carrying a Gen 2 stat block")
local ok = pcall(string.format, "%3d", save.party[1].stats.special)
check(ok, "SummaryMenu's ('%3d'):format over stats.special no longer raises")
ok = pcall(Stats.applyStage, save.party[1].stats.special, 0)
check(ok, "and Damage's applyStage over curStats.special no longer raises")
end
-- a box mon reached through the same pass
do
local save = { party = {}, boxes = { { gen2Shaped() } }, inventory = {},
pcItems = {},
player = { map = "FIXMAP", x = 1, y = 1, name = "RED", id = 1 } }
SaveData.validate(save, data)
check(type(save.boxes[1][1].stats.special) == "number",
"the repair reaches box mons too")
end
do -- a complete block is never rewritten
local want = Stats.calc(DEF, 21, DVS, {})
local mon = { species = "FIXMON_A", level = 21, hp = 5, dvs = DVS, statExp = {},
stats = { hp = want.hp, attack = 999, defense = want.defense,
speed = want.speed, special = want.special },
moves = { { id = "FIX_TACKLE", pp = 35 } } }
Stats.ensure(DEF, mon)
eq(mon.stats.attack, 999, "a complete block is returned untouched")
eq(mon.hp, 5, "and a stored current HP below the max is kept")
end
do -- a mon with no block at all still gets one (#233, #304 unchanged)
local mon = { species = "FIXMON_A", level = 21, dvs = DVS, statExp = {} }
Stats.ensure(DEF, mon)
local want = Stats.calc(DEF, 21, DVS, {})
eq(mon.stats.special, want.special, "a box mon with no stats still gets them")
eq(mon.hp, want.hp, "and a missing current HP fills to the maximum")
end
do -- no species definition: nothing to rebuild from, so leave it alone
local mon = { species = "MISSINGNO", level = 21,
stats = { hp = 62, attack = 27 } }
Stats.ensure(nil, mon)
eq(mon.stats.attack, 27, "an unknown species leaves a partial block as-is")
eq(mon.stats.special, nil, "rather than inventing one")
end
T.finish()
+77
View File
@@ -851,6 +851,83 @@ do
check("the trainer's PP is untouched", refuseParty[1].moves[1].pp, 35)
end
-- ResetBattleParticipants falls through into AddBattleParticipant
-- (engine/battle/core.asm:3033 and :3037), so every ENEMY-initiated mon change
-- wipes both bitfields and re-credits the mon the player has out. The player's
-- own send-outs only ever call AddBattleParticipant (core.asm:2655, :2681,
-- :3783, :4989, :5014).
do
local function creditBattle()
local party = {
Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect }),
Mon.new(DATA, "TOTODILE", 20, { dvs = perfect }),
}
local foes = {
Mon.new(DATA, "GEODUDE", 8, { dvs = perfect }),
Mon.new(DATA, "PIDGEY", 8, { dvs = perfect }),
}
for _, mon in ipairs(party) do
mon.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
end
for _, mon in ipairs(foes) do
mon.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
end
local battle = Battle.new({
data = DATA, party = party,
-- attributes[6] is the low byte of the switch flags: OFTEN.
trainer = { class = "YOUNGSTER", name = "JOEY", party = foes,
attributes = { 0, 0, 0, 0, 0, 0x01, 0 } },
random = zeroRandom,
})
battle:switch(2)
return battle, party, foes
end
-- AI_Switch (engine/battle/ai/items.asm:697).
local rotate, rotateParty, rotateFoes = creditBattle()
check("both mons are credited before the rotation",
rotate.participants[1] and rotate.participants[2], true)
rotate:volatile(rotate.enemy).perish = 1
check("the AI rotated", rotate:enemyTrySwitchOrItem(), true)
check("the rotation installed the second foe", rotate.enemy, rotateFoes[2])
check("the bench mon lost its credit", rotate.participants[1], nil)
check("only the mon on the field keeps it", rotate.participants[2], true)
local benchExp = rotateParty[1].experience
local activeExp = rotateParty[2].experience
rotate:awardExperience(rotate.enemy)
check("the bench mon earns nothing from the new foe",
rotateParty[1].experience, benchExp)
check("and the mon that faced it is still paid",
rotateParty[2].experience > activeExp, true)
-- ForceEnemySwitch (engine/battle/core.asm:2937), reached only from
-- BattleCommand_ForceSwitch (effect_commands.asm:4999).
local roar, _, roarFoes = creditBattle()
roar.firstMover = "enemy"
Battle.MOVE_EFFECTS.EFFECT_FORCE_SWITCH(roar, roar.player, roar.enemy,
nil, "ROAR", true)
check("Roar dragged the second foe out", roar.enemy, roarFoes[2])
check("the bench mon lost its credit to Roar", roar.participants[1], nil)
check("and the mon on the field keeps it", roar.participants[2], true)
-- engine/battle/move_effects/baton_pass.asm:59.
local baton, _, batonFoes = creditBattle()
Battle.MOVE_EFFECTS.EFFECT_BATON_PASS(baton, baton.enemy)
check("the baton passed to the second foe", baton.enemy, batonFoes[2])
check("the bench mon lost its credit to the baton",
baton.participants[1], nil)
check("and the mon on the field keeps it", baton.participants[2], true)
-- PassedBattleMonEntrance only adds (engine/battle/core.asm:5014): the
-- player's own baton pass must NOT wipe the set.
local playerBaton, playerBatonParty = creditBattle()
Battle.MOVE_EFFECTS.EFFECT_BATON_PASS(playerBaton, playerBaton.player)
check("the player's baton pass moved the lead back in",
playerBaton.player, playerBatonParty[1])
check("and credited both mons",
playerBaton.participants[1] and playerBaton.participants[2], true)
end
-- Switching costs the turn and adds the newcomer to the participant set.
local switchParty = {
Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }),
+66
View File
@@ -483,6 +483,72 @@ packInput:press("right")
pack:update(0)
check("pocket wraps around", pack:pocket().id, "ITEM")
-- engine/items/tmhm.asm:341 -- TMHM_DisplayPocketItems walks wTMsHMs 1..57, so
-- the pocket is TM01..TM50 then HM01..HM07 whatever order the player picked
-- them up in, and tmhm.asm:207 keeps SELECT out of it entirely.
do
local tmSave2 = Save.newGame()
tmSave2.inventory = {
HM_WATERFALL = 1, TM_NIGHTMARE = 2, HM_CUT = 1, TM_ROAR = 3,
TM_DYNAMICPUNCH = 12, TM_LEGACY = 1,
}
tmSave2.bagOrder = {
"HM_WATERFALL", "TM_NIGHTMARE", "HM_CUT", "TM_ROAR", "TM_DYNAMICPUNCH",
"TM_LEGACY",
}
local tmGame2, tmInput2 = newGame(tmSave2)
tmGame2.data.items = {
TM_DYNAMICPUNCH = { id = "TM_DYNAMICPUNCH", name = "TM01",
pocket = "TM_HM", index = 191, tmNumber = 1 },
TM_ROAR = { id = "TM_ROAR", name = "TM05", pocket = "TM_HM",
index = 195, tmNumber = 5 },
TM_NIGHTMARE = { id = "TM_NIGHTMARE", name = "TM50", pocket = "TM_HM",
index = 240, tmNumber = 50 },
HM_CUT = { id = "HM_CUT", name = "HM01", pocket = "TM_HM",
index = 241, tmNumber = 51 },
HM_WATERFALL = { id = "HM_WATERFALL", name = "HM07", pocket = "TM_HM",
index = 247, tmNumber = 57 },
-- A cache (or a mod) with no tmNumber falls back to the ItemNames index.
TM_LEGACY = { id = "TM_LEGACY", name = "TM??", pocket = "TM_HM",
index = 300 },
}
local tmPack = PackMenu.new(tmGame2, { pocket = "TM_HM" })
local function ids(rows)
local out = {}
for i = 1, #rows do out[i] = rows[i].id end
return table.concat(out, ",")
end
check("TM/HM pocket is TM-number ordered, not pickup ordered",
ids(tmPack.rows),
"TM_DYNAMICPUNCH,TM_ROAR,TM_NIGHTMARE,HM_CUT,HM_WATERFALL,TM_LEGACY")
check("the first row is TM01", tmPack.rows[1].id, "TM_DYNAMICPUNCH")
check("and a tmNumber-less item lands after HM07",
tmPack.rows[#tmPack.rows].id, "TM_LEGACY")
check("the TM keeps its count", tmPack.rows[1].showCount, true)
check("the HM shows none", tmPack.rows[4].showCount, false)
tmPack.index = 1
tmInput2:press("select")
tmPack:update(0)
check("SELECT cannot arm a TM/HM row", tmPack.switching, nil)
check("and prints no move prompt", tmPack.message, nil)
-- The other three pockets still reorder on SELECT (pack.asm:1290).
tmSave2.inventory.POTION = 1
tmSave2.inventory.SUPER_POTION = 1
tmSave2.bagOrder = { "POTION", "SUPER_POTION" }
tmGame2.data.items.POTION =
{ id = "POTION", name = "POTION", pocket = "ITEM", index = 1 }
tmGame2.data.items.SUPER_POTION =
{ id = "SUPER_POTION", name = "SUPER POTION", pocket = "ITEM", index = 2 }
tmPack.pocketIndex = 1
tmPack:rebuild()
tmPack.index = 1
tmInput2:press("select")
tmPack:update(0)
check("but the ITEM pocket still arms", tmPack.switching, 1)
end
-- CANCEL sits one past the last row.
check("cancel is past the end", pack:total(), #pack.rows + 1)
pack.index = pack:total()
+59 -4
View File
@@ -310,7 +310,8 @@ do
eq(save.inventory.TM_HEADBUTT, nil, "and leaves the bag")
end
-- .TryDepositItem's .no_toss: a KEY ITEM stays in the bag, silently.
-- .DepositItem: CANT_TOSS only skips .AskQuantity. A KEY ITEM deposits x1,
-- and .Submenu withdraws it back the same way (issue #1486).
do
local save = newSave(1)
local game, input = newGame(save)
@@ -320,9 +321,63 @@ do
press(pc, input, "right", "right") -- ITEM -> BALL -> KEY_ITEM pocket
press(pc, input, "a") -- choose the BICYCLE
eq(pc.qtyState, nil, "no quantity selector for a KEY ITEM")
eq(pc.message, nil, "no message either: .no_toss is a bare ret")
eq(save.pcItems.BICYCLE, nil, "and the BICYCLE never leaves the bag")
eq(save.inventory.BICYCLE, 1, "still there")
eq(save.pcItems.BICYCLE, 1, "the BICYCLE lands in the PC")
eq(save.inventory.BICYCLE, nil, "and leaves the bag")
eq(pc.message.pages[1][1], "Deposited 1", "_PlayersPCDepositItemsText")
press(pc, input, "a") -- clear it
press(pc, input, "b") -- close the PACK
press(pc, input, "up", "a") -- WITHDRAW ITEM
eq(pc.phase, "withdraw", "the PC item list opens on the KEY ITEM")
press(pc, input, "a") -- the BICYCLE row
eq(pc.qtyState, nil, "no quantity selector on the way back either")
eq(save.inventory.BICYCLE, 1, "the BICYCLE is back in the bag")
eq(save.pcItems.BICYCLE, nil, "and out of the PC")
end
-- The TM_HM pocket's HMs are CANT_TOSS too, and deposit prompt-free x1.
do
local save = newSave(1)
local game, input = newGame(save)
Bag.add(save, "HM_CUT", 1, game.data)
local pc = ItemPcMenu.new(game, { save = save, items = ITEMS })
press(pc, input, "down", "a") -- DEPOSIT ITEM
press(pc, input, "right", "right", "right") -- ITEM -> BALL -> KEY_ITEM -> TM_HM
press(pc, input, "a") -- choose HM01
eq(pc.qtyState, nil, "no quantity selector for an HM")
eq(save.pcItems.HM_CUT, 1, "HM01 lands in the PC")
eq(save.inventory.HM_CUT, nil, "and leaves the bag")
end
-- PlaceMenuItemQuantity: the PC list draws no xNN for a CANT_TOSS row.
do
local save = newSave(1)
local game = newGame(save)
save.pcItems = { POTION = 3, HM_CUT = 1 }
local pc = ItemPcMenu.new(game, { save = save, items = ITEMS })
local Chrome = require("src.ui.gen2.Chrome")
local TIMES = "\xc3\x97"
local saved = { print = Chrome.print, box = Chrome.box,
cursor = Chrome.cursor }
local printed = {}
Chrome.print = function(text, x, y)
printed[#printed + 1] = { text = text, x = x, y = y }
end
Chrome.box = function() end
Chrome.cursor = function() end
pc.phase = "withdraw"
pc:rebuild()
local drew, err = pcall(function() pc:drawList() end)
Chrome.print, Chrome.box, Chrome.cursor = saved.print, saved.box, saved.cursor
check(drew, "the PC list draws: " .. tostring(err))
local rowY = {}
for i, row in ipairs(pc.rows) do rowY[row.id] = i * 2 end
local counts = {}
for _, p in ipairs(printed) do
if p.x == 7 then counts[p.y] = p.text end
end
eq(counts[rowY.POTION + 1], TIMES .. " 3", "the POTION stack keeps its xNN")
eq(counts[rowY.HM_CUT + 1], nil,
"and the HM row draws none (PlaceMenuItemQuantity .done)")
end
-- An empty bag never opens the PACK (.CheckItemsInBag).
@@ -0,0 +1,144 @@
-- T4: a mod may not reach a Gen 2 engine module from a Gen 1 game.
--
-- src/battle/gen2/Mon.lua returns { hp, attack, defense, speed, specialAttack,
-- specialDefense }; a Gen 1 party mon carries `special` instead. A Yellow mod
-- that required that module and ran refreshStats over save.party wrote the Gen
-- 2 block onto Gen 1 mons, and every reader of stats.special raised from then
-- on (#1517). The require shim refuses it now, whatever the manifest declares:
-- engine_internals is a disclosure, not a generation gate.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local PROBE = [[
local mod = ...
local out = mod.exports
local function attempt(name)
local ok, result = pcall(require, name)
if ok then return nil, type(result) end
return tostring(result), nil
end
out.attempt = attempt
out.monErr, out.monType = attempt("src.battle.gen2.Mon")
out.saveErr = attempt("src.core.gen2.Save")
out.worldErr = attempt("src.ui.gen2.Chrome")
out.game2Err = attempt("src.core.Game2")
out.semverErr, out.semverType = attempt("src.mods.Semver")
out.statsErr, out.statsType = attempt("src.pokemon.Stats")
out.compatErr, out.compatType = attempt("src.mods.Gen2Compat")
out.loggerErr, out.loggerType = attempt("src.core.Logger")
]]
local function manifest(id, permissions)
return ('{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua",'
.. '"api":2,"games":["all"]%s}'):format(id, id, permissions or "")
end
-- ------- Gen 1 boot: refused, and the permission does not unlock it
local FILES = {
["mods/gen2_declared/manifest.json"] =
manifest("gen2_declared", ',"permissions":["engine_internals"]'),
["mods/gen2_declared/main.lua"] = PROBE,
["mods/gen2_undeclared/manifest.json"] = manifest("gen2_undeclared"),
["mods/gen2_undeclared/main.lua"] = PROBE,
}
T.eq(package.loaded["src.battle.gen2.Mon"], nil,
"the Gen 2 mon module is not loaded before the Gen 1 boot")
local run = T.sdk.loadMods({ "mods/gen2_declared", "mods/gen2_undeclared" },
{ fs = T.sdk.memfs(FILES), data = {}, generation = 1 })
T.eq(#run.errors, 0,
"a mod that only probes still loads clean (" .. tostring(run.errors[1]) .. ")")
local declared = run.loader.exports.gen2_declared or {}
local undeclared = run.loader.exports.gen2_undeclared or {}
T.check(declared.monErr, "src.battle.gen2.Mon is refused on a Gen 1 game")
T.check(declared.monErr and declared.monErr:find("src.battle.gen2.Mon", 1, true),
"the refusal names the module: " .. tostring(declared.monErr))
T.check(declared.monErr and declared.monErr:find("gen2_declared", 1, true),
"and the mod that asked for it")
T.check(declared.monErr and declared.monErr:find("mod.game", 1, true),
"and the surface to use instead")
T.eq(declared.monType, nil, "nothing came back for the mod to call")
T.eq(package.loaded["src.battle.gen2.Mon"], nil,
"and the module was never loaded at all")
T.check(declared.saveErr, "src.core.gen2.Save is refused the same way")
T.check(declared.worldErr, "so is src.ui.gen2.Chrome")
T.check(declared.game2Err and declared.game2Err:find("src.core.Game2", 1, true),
"and src.core.Game2, the Gen 2 service owner: " .. tostring(declared.game2Err))
T.check(undeclared.monErr,
"a mod with no permissions is refused for the same reason")
T.eq(declared.monErr and declared.monErr:gsub("gen2_declared", "X"),
undeclared.monErr and undeclared.monErr:gsub("gen2_undeclared", "X"),
"engine_internals is a disclosure, not a generation gate: same refusal")
-- ------- what the gate does NOT touch
T.eq(declared.semverErr, nil, "the supported requires still resolve")
T.eq(declared.semverType, "table", "and hand back the module")
T.eq(declared.statsErr, nil, "src.pokemon.Stats is still published to mods")
T.eq(declared.compatErr, nil,
"src.mods.Gen2Compat is not a Gen 2 engine module: it is the adapter table")
T.eq(declared.compatType, "table", "so it still answers")
T.eq(declared.loggerErr, nil,
"an undeclared engine require is still a warning, not a block")
T.eq(declared.loggerType, "table", "and still resolves")
T.eq(undeclared.loggerErr, nil,
"including for a mod that declared no permissions at all")
-- a lazy require made long after the entry chunk ran is gated too: the mod's
-- own require is what the shim identifies, not the load phase
do
local err = declared.attempt and declared.attempt("src.world.gen2.World")
T.check(err and err:find("src.world.gen2.World", 1, true),
"a require made after load is refused too: " .. tostring(err))
end
-- the shim only judges a mod's own require; engine and harness callers keep
-- the module they asked for
do
local ok, module = pcall(require, "src.world.gen2.WorldAPI")
T.check(ok, "a non-mod caller still reaches a Gen 2 module: " .. tostring(module))
T.eq(type(module), "table", "and gets the real one")
end
run.release()
-- ------- an unguarded reach fails the whole mod, on the boot error feed
local HARD = {
["mods/gen2_hard/manifest.json"] = manifest("gen2_hard",
',"permissions":["engine_internals"]'),
["mods/gen2_hard/main.lua"] = [[
local mod = ...
local Mon = require("src.battle.gen2.Mon")
mod.exports.reached = Mon ~= nil
]],
}
local hard = T.sdk.loadMods({ "mods/gen2_hard" },
{ fs = T.sdk.memfs(HARD), data = {}, generation = 1 })
T.eq(hard.loader.mods.gen2_hard.state, "failed",
"a mod that reaches across the generation does not load")
T.eq(hard.loader.exports.gen2_hard, nil, "and publishes nothing")
T.check(hard.errors[1] and hard.errors[1]:find("src.battle.gen2.Mon", 1, true),
"the boot error feed names the module: " .. tostring(hard.errors[1]))
hard.release()
-- ------- Gen 2 boot: the same require is the mod's own generation
local gen2 = T.sdk.loadMods({ "mods/gen2_declared" },
{ fs = T.sdk.memfs(FILES), data = {}, generation = 2 })
local onGold = gen2.loader.exports.gen2_declared or {}
T.eq(onGold.monErr, nil,
"on a Gen 2 game the same require is answered: " .. tostring(onGold.monErr))
T.eq(onGold.monType, "table", "with the real Gen 2 mon module")
T.eq(onGold.game2Err, nil, "and src.core.Game2 is this game's service owner")
gen2.release()
T.finish("cross_generation_require")
+40
View File
@@ -311,6 +311,46 @@ ow.transitioning = false -- undo the queued warp transition
Game.save.onBike = false
Game.save.inventory.BICYCLE = nil
-- home/overworld.asm:1224
local function settle(o)
drainText()
for _ = 1, 60 do o:updateScriptMoves(); o.player:update() end
end
for _, c in ipairs({ { "ROUTE_16", 17, 10 }, { "ROUTE_16", 17, 11 },
{ "ROUTE_18", 33, 8 }, { "ROUTE_18", 33, 9 } }) do
Game.save.inventory.BICYCLE = nil
Game.save.onBike, Game.save.forcedBike = false, nil
pushOW(c[1], c[2], c[3], "left")
ow = OW
check(not ow.map:isWalkableCell(c[2] + 1, c[3]),
("%s (%d,%d): the cell behind a left-facing arrival is the gate wall")
:format(c[1], c[2], c[3]))
settle(ow)
eq(#ow.scriptMoves, 0,
("%s (%d,%d): the refusal shove settles"):format(c[1], c[2], c[3]))
check(ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY),
("%s (%d,%d): the bikeless Cycling Road refusal never shoves into the "
.. "gate wall"):format(c[1], c[2], c[3]))
end
Game.save.onBike, Game.save.forcedBike = false, nil
pushOW("ROUTE_16", 17, 10, "right")
ow = OW
settle(ow)
eq(ow.player.cellX, 16, "an unobstructed refusal shove still moves one cell")
eq(ow.player.cellY, 10, "and only along the arrival axis")
Game.save.onBike, Game.save.forcedBike = false, nil
ow:setMap("ROUTE_16", 18, 10, "left", { via = "boot" })
check(ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY),
"a save parked inside the Route 16 gate wall is lifted out on load")
eq(ow.player.cellY, 10, "the repair stays on the row it was saved on")
settle(ow)
check(ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY),
"and the refusal it lands on cannot push it back in")
popToOW()
Game.save.onBike, Game.save.forcedBike = false, nil
-- Seafoam B4F: only the stairs square (dbmapcoord 7,11) refuses, and only
-- until BOTH boulders are down (CheckBothEventsSet)
Game.save.flags["EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE"] = nil
+11 -1
View File
@@ -42,7 +42,8 @@ local function trace(tb)
if row.text then
out[#out + 1] = { text = (row.text:gsub("\n", " ")) }
elseif row.anim then
out[#out + 1] = { anim = row.anim, isPlayer = row.attackerIsPlayer }
out[#out + 1] = { anim = row.anim, isPlayer = row.attackerIsPlayer,
hit = row.hit }
elseif row.drain then
out[#out + 1] = { drain = true }
end
@@ -95,6 +96,9 @@ do
check(spiral ~= nil, "the storing turn plays XSTATITEM_ANIM")
eq(rows[spiral] and rows[spiral].isPlayer, true,
"on the player's side of the field")
-- effects.asm:1461-1471
eq(rows[spiral] and rows[spiral].hit and rows[spiral].hit.animType, 6,
"PlayBattleAnimation2 stamps wAnimationType 6 on the player's turn (#1564)")
local used = indexOfText(rows, "used BIDE!")
local storing = indexOfText(rows, "storing energy!")
check(used and spiral and used < spiral,
@@ -119,6 +123,8 @@ do
local dup = indexOfAnim(rows, "XSTATITEM_DUPLICATE_ANIM")
check(dup ~= nil, "the foe's storing turn plays XSTATITEM_DUPLICATE_ANIM")
check(dup and not rows[dup].isPlayer, "attributed to the enemy side")
eq(rows[dup] and rows[dup].hit and rows[dup].hit.animType, 3,
"and wAnimationType 3 on the enemy's turn (#1564)")
end
-- locked turns: .BideCheck decrements with no text of its own in pokered and
@@ -151,6 +157,10 @@ do
check(unleashed ~= nil, "the release prints UnleashedEnergyText")
check(hit ~= nil, "the release plays BIDE's own animation (#375)")
eq(rows[hit] and rows[hit].isPlayer, true, "from the player's side")
-- core.asm:3526-3528 -> effects.asm:1476-1477
check(rows[hit] and rows[hit].hit == nil,
".UnleashEnergy rejoins PlayCurrentMoveAnimation, which zeroes "
.. "wAnimationType")
check(unleashed and hit and unleashed < hit,
"the animation follows the text")
local drainAt
+3
View File
@@ -699,6 +699,9 @@ REQUIRED_SYMBOLS = {
# marker without this symbol.
"MonMenuIcons", "Icons", "IconPointers", "HeldItemIcons",
"PokedexDataPointerTable",
# engine/pokemon/bills_pc.asm:2170-2173 (the four vTiles2 $5c tiles
# PCMonInfo prints at :1086/:1091) and engine/gfx/cgb_layouts.asm:287.
"PCMailGFX", "BillsPCOrangePalette",
# New-game / Pokecenter respawn table (data/maps/spawn_points.asm)
"SpawnPoints",
"NewPokedexOrder", "AlphabeticalPokedexOrder", "Landmarks",
+72 -56
View File
@@ -12912,6 +12912,10 @@
105,
16837
],
"BillsPCOrangePalette": [
2,
21965
],
"BlastoiseBackpic": [
25,
30882
@@ -12936,6 +12940,10 @@
107,
21761
],
"BoltEmote": [
5,
17737
],
"BugCatchingContestantEventFlagTable": [
4,
32186
@@ -12968,6 +12976,30 @@
26,
26667
],
"CardFlipLZ01": [
56,
21795
],
"CardFlipLZ02": [
56,
22197
],
"CardFlipLZ03": [
56,
21736
],
"CardFlipOffButtonGFX": [
56,
21763
],
"CardFlipOnButtonGFX": [
56,
21779
],
"CardFlipTilemap": [
56,
22809
],
"CardStatusGFX": [
9,
22287
@@ -13612,6 +13644,10 @@
106,
19800
],
"FishEmote": [
5,
17865
],
"FishGroups": [
36,
27127
@@ -13956,6 +13992,10 @@
4,
26664
],
"HeartEmote": [
5,
17673
],
"HeldItemIcons": [
35,
26843
@@ -15420,6 +15460,10 @@
5,
18398
],
"PCMailGFX": [
56,
31768
],
"PackGFX": [
4,
21553
@@ -16096,22 +16140,6 @@
5,
17417
],
"HeartEmote": [
5,
17673
],
"BoltEmote": [
5,
17737
],
"SleepEmote": [
5,
17801
],
"FishEmote": [
5,
17865
],
"Shrink1Pic": [
62,
30142
@@ -16156,6 +16184,26 @@
106,
22935
],
"SleepEmote": [
5,
17801
],
"Slots1LZ": [
36,
31138
],
"Slots2LZ": [
36,
31522
],
"Slots3LZ": [
36,
32130
],
"SlotsTilemap": [
36,
30898
],
"SlowbroBackpic": [
26,
32198
@@ -16356,6 +16404,14 @@
105,
22509
],
"StatsScreenPagePals": [
2,
21715
],
"StatsScreenPageTilesGFX": [
62,
19106
],
"StatsScreen_PlaceFrontpic": [
20,
20675
@@ -17667,46 +17723,6 @@
"wBaseUnusedFrontpic": [
1,
53554
],
"Slots1LZ": [
36,
31138
],
"Slots2LZ": [
36,
31522
],
"Slots3LZ": [
36,
32130
],
"SlotsTilemap": [
36,
30898
],
"CardFlipLZ01": [
56,
21795
],
"CardFlipLZ02": [
56,
22197
],
"CardFlipLZ03": [
56,
21736
],
"CardFlipOnButtonGFX": [
56,
21779
],
"CardFlipOffButtonGFX": [
56,
21763
],
"CardFlipTilemap": [
56,
22809
]
},
"tilesets": {
+56 -40
View File
@@ -12912,6 +12912,10 @@
105,
16840
],
"BillsPCOrangePalette": [
2,
21965
],
"BlastoiseBackpic": [
25,
31647
@@ -12972,6 +12976,30 @@
26,
27406
],
"CardFlipLZ01": [
56,
21795
],
"CardFlipLZ02": [
56,
22197
],
"CardFlipLZ03": [
56,
21736
],
"CardFlipOffButtonGFX": [
56,
21763
],
"CardFlipOnButtonGFX": [
56,
21779
],
"CardFlipTilemap": [
56,
22809
],
"CardStatusGFX": [
9,
22287
@@ -15432,6 +15460,10 @@
5,
18398
],
"PCMailGFX": [
56,
31768
],
"PackGFX": [
4,
21553
@@ -16156,6 +16188,22 @@
5,
17801
],
"Slots1LZ": [
36,
31138
],
"Slots2LZ": [
36,
31522
],
"Slots3LZ": [
36,
32130
],
"SlotsTilemap": [
36,
30898
],
"SlowbroBackpic": [
26,
31900
@@ -16356,6 +16404,14 @@
105,
22501
],
"StatsScreenPagePals": [
2,
21715
],
"StatsScreenPageTilesGFX": [
62,
19106
],
"StatsScreen_PlaceFrontpic": [
20,
20675
@@ -17667,46 +17723,6 @@
"wBaseUnusedFrontpic": [
1,
53554
],
"Slots1LZ": [
36,
31138
],
"Slots2LZ": [
36,
31522
],
"Slots3LZ": [
36,
32130
],
"SlotsTilemap": [
36,
30898
],
"CardFlipLZ01": [
56,
21795
],
"CardFlipLZ02": [
56,
22197
],
"CardFlipLZ03": [
56,
21736
],
"CardFlipOnButtonGFX": [
56,
21779
],
"CardFlipOffButtonGFX": [
56,
21763
],
"CardFlipTilemap": [
56,
22809
]
},
"tilesets": {