mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-22 13:36:14 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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[@]}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Chrome.print(TIMES .. Chrome.number(entry.count, 2), 7, ty + 1)
|
||||
-- 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,14 +4848,20 @@ 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
|
||||
mv.remaining = mv.remaining - 1
|
||||
end
|
||||
mv.remaining = mv.remaining - 1
|
||||
end
|
||||
end
|
||||
-- march_in_place toggles: re-arm the in-place cycle each time it ends.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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), " "))
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user