mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b17dce232e | |||
| e1b20723fd | |||
| fdab15a6dc | |||
| 4916fc8f4b | |||
| fc19e58678 |
@@ -1,4 +1,9 @@
|
||||
function love.conf(t)
|
||||
-- PhysFS ignores symlinks unless told otherwise, so a mod dev-linked into
|
||||
-- mods/ (ln -s, matching the mklink /J workflow on Windows) is invisible
|
||||
-- to love.filesystem.getDirectoryItems without this.
|
||||
love.filesystem.setSymlinksEnabled(true)
|
||||
|
||||
local editor = os.getenv("POKEPORT_EDITOR") == "1"
|
||||
local developer = os.getenv("POKEPORT_DEV") == "1"
|
||||
if arg then
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
-- Viridian School House's two readables (data/events/hidden_events.asm,
|
||||
-- hidden_events_for VIRIDIAN_SCHOOL_HOUSE):
|
||||
-- hidden_text_predef 3, 0 PrintBlackboardLinkCableText, ViridianSchoolBlackboard
|
||||
-- hidden_text_predef 3, 4 PrintNotebookText, ViridianSchoolNotebook
|
||||
-- tools/extract/field.py only parses `hidden_event` rows, so neither
|
||||
-- hidden_text_predef row reaches data/generated/field.lua and both tiles
|
||||
-- were dead A presses (#503). Same hook shape, and the same sibling asm
|
||||
-- file, as the Celadon roof house in data/scripts/celadon_eevee.lua (#391);
|
||||
-- hidden_text_predef spends the facing byte on the tx_pre id, so neither
|
||||
-- tile gates on facing.
|
||||
|
||||
local Menu = require("src.ui.Menu")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
-- ViridianSchoolBlackboard (engine/events/hidden_events/school_blackboard.asm):
|
||||
-- StatusAilmentText1/2 are the two columns of the 12x8 box at the top left
|
||||
-- (hlcoord 0, 0 + `lb bc, 6, 10`); picking a status prints its
|
||||
-- ViridianBlackboardStatusPointers entry and jumps back to .blackboardLoop,
|
||||
-- QUIT or B falls through to .exitBlackboard.
|
||||
local STATUS_LABELS = {
|
||||
{ " SLP", "_ViridianBlackboardSleepText" },
|
||||
{ " PSN", "_ViridianBlackboardPoisonText" },
|
||||
{ " PAR", "_ViridianBlackboardPrlzText" },
|
||||
{ " BRN", "_ViridianBlackboardBurnText" },
|
||||
{ " FRZ", "_ViridianBlackboardFrozenText" },
|
||||
}
|
||||
|
||||
local function blackboard(game)
|
||||
local text = game.data.text or {}
|
||||
local items, showMenu, askHeading
|
||||
function showMenu()
|
||||
game.stack:push(Menu.new(game, items,
|
||||
{ tx = 0, ty = 0, tw = 12, th = 8, rowStep = 1 }))
|
||||
end
|
||||
-- ViridianSchoolBlackboardText2 is reprinted on every .blackboardLoop
|
||||
-- pass, immediately before HandleMenuInput
|
||||
function askHeading()
|
||||
game.stack:push(TextBox.new(game,
|
||||
text._ViridianSchoolBlackboardText2 or "Which heading do\nyou want to read?",
|
||||
showMenu))
|
||||
end
|
||||
items = {}
|
||||
for i, row in ipairs(STATUS_LABELS) do
|
||||
local label, key = row[1], row[2]
|
||||
items[i] = { label = label, onSelect = function()
|
||||
game.stack:push(TextBox.new(game, text[key] or label, askHeading))
|
||||
end }
|
||||
end
|
||||
-- no onSelect: Menu's own pop closes the box, matching .exitBlackboard
|
||||
items[#items + 1] = { label = " QUIT" }
|
||||
game.stack:push(TextBox.new(game,
|
||||
text._ViridianSchoolBlackboardText1
|
||||
or "The blackboard\ndescribes POKéMON\vSTATUS changes\vduring battles.",
|
||||
askHeading))
|
||||
end
|
||||
|
||||
-- ViridianSchoolNotebook (engine/events/hidden_events/school_notebooks.asm):
|
||||
-- pages 1-3 each end in TurnPageSchoolNotebook (TurnPageText + YesNoChoice)
|
||||
-- and NO stops the read; page 4 turns without asking and runs straight into
|
||||
-- page 5, the girl catching you at it.
|
||||
local function notebook(game)
|
||||
local text = game.data.text or {}
|
||||
local function page(n, after)
|
||||
return TextBox.new(game, text["_ViridianSchoolNotebookText" .. n] or "", after)
|
||||
end
|
||||
local function turnPage(nextPage)
|
||||
return function()
|
||||
game.stack:push(TextBox.new(game, text._TurnPageText or "Turn the page?",
|
||||
nil, { choice = function(yes)
|
||||
if yes then game.stack:push(nextPage()) end
|
||||
end }))
|
||||
end
|
||||
end
|
||||
local function page5() return page(5) end
|
||||
local function page4() return page(4, function() game.stack:push(page5()) end) end
|
||||
local function page3() return page(3, turnPage(page4)) end
|
||||
local function page2() return page(2, turnPage(page3)) end
|
||||
game.stack:push(page(1, turnPage(page2)))
|
||||
end
|
||||
|
||||
return {
|
||||
VIRIDIAN_SCHOOL_HOUSE = {
|
||||
onInteract = function(game, ow, fx, fy)
|
||||
if fx == 3 and fy == 0 then
|
||||
blackboard(game)
|
||||
return true
|
||||
end
|
||||
if fx == 3 and fy == 4 then
|
||||
notebook(game)
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end,
|
||||
},
|
||||
}
|
||||
@@ -60,6 +60,7 @@ local files = {
|
||||
"data.scripts.flavor.victory_road_2f",
|
||||
"data.scripts.flavor.viridian_city",
|
||||
"data.scripts.flavor.viridian_nickname_house",
|
||||
"data.scripts.flavor.viridian_school_house", -- #503
|
||||
"data.scripts.flavor.wardens_house",
|
||||
}
|
||||
|
||||
|
||||
+14
-3
@@ -647,7 +647,12 @@ M.WARDENS_HOUSE = {
|
||||
TEXT_WARDENSHOUSE_WARDEN = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_HM04" }, -- 2
|
||||
{ "jump_if_true", 13 }, -- 3
|
||||
-- #535: previously jumped to the same silent-end target as the
|
||||
-- give-then-thank fallthrough (row 13), so the warden said nothing
|
||||
-- on every visit after the trade. pokered's .got_item branch
|
||||
-- (scripts/WardensHouse.asm) instead prints HM04ExplanationText,
|
||||
-- so route here to the new row 17 that does the same.
|
||||
{ "jump_if_true", 17 }, -- 3
|
||||
{ "check_item", "GOLD_TEETH" }, -- 4
|
||||
{ "jump_if_false", 15 }, -- 5
|
||||
{ "show_text", "_WardensHouseWardenGaveTheGoldTeethText" }, -- 6
|
||||
@@ -658,9 +663,15 @@ M.WARDENS_HOUSE = {
|
||||
{ "give_item", "HM_STRENGTH", 1, false }, -- 10
|
||||
{ "show_text", "_WardensHouseWardenReceivedHM04Text" }, -- 11
|
||||
{ "set_flag", "EVENT_GOT_HM04" }, -- 12
|
||||
{ "jump", 16 }, -- 13 (already got it)
|
||||
{ "jump", 16 }, -- 14 (unused)
|
||||
{ "jump", 18 }, -- 13 (already got it this convo; jp .done)
|
||||
{ "jump", 18 }, -- 14 (unused)
|
||||
{ "show_text", "_WardensHouseWardenGibberish1Text" }, -- 15
|
||||
{ "jump", 18 }, -- 16 (#535: skip the new explanation row below)
|
||||
-- #535: pokered .got_item branch (scripts/WardensHouse.asm) --
|
||||
-- printed on every subsequent talk once EVENT_GOT_HM04 is set.
|
||||
-- Text is _WardensHouseWardenHM04ExplanationText (text/WardensHouse.asm):
|
||||
-- HM04 teaches Strength, and hints at the Safari Zone secret house.
|
||||
{ "show_text", "_WardensHouseWardenHM04ExplanationText" }, -- 17
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
+12
-1
@@ -790,8 +790,19 @@ local function bikeGateGuard(coords, stopText, explainText)
|
||||
local t = text(game)
|
||||
push(game, t[stopText] or "Hey! Wait up!", function()
|
||||
push(game, t[explainText] or "You need a\nBICYCLE for\nCYCLING ROAD!", function()
|
||||
-- pokered's Route16Gate1FGuardScript / Route18Gate1FGuardScript
|
||||
-- (scripts/Route16Gate1F.asm, Route18Gate1F.asm) simulate one
|
||||
-- PAD_RIGHT step after the refusal text, and only clear
|
||||
-- wJoyIgnore / hand control back once that step finishes
|
||||
-- (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)
|
||||
end
|
||||
if dist > 0 then
|
||||
ow:scriptMove(ow.player, "up", dist)
|
||||
ow:scriptMove(ow.player, "up", dist, shoveRight)
|
||||
else
|
||||
shoveRight()
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
@@ -157,6 +157,13 @@ tradeoff vs. the relay). Headless tests drive the protocol over an
|
||||
in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file
|
||||
also exercises real UDP pairing.
|
||||
|
||||
Red, Blue, and Yellow copies link with each other, as the real cable
|
||||
does. The compatibility fingerprint hashes only data a link mode can
|
||||
actually read, so Yellow's Dragonair/Dragonite catch-rate retunes (the
|
||||
only R/B/Y link-surface difference) no longer read as different games
|
||||
(issue #511). Moving the fingerprint is a link parity change: builds
|
||||
from before this fix will refuse to pair with builds after it.
|
||||
|
||||
## Fair play in link and online matches
|
||||
|
||||
A link session is decided by the battle and nothing else, so for its
|
||||
|
||||
@@ -93,6 +93,12 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||
-- MoveHitTest's INVULNERABLE branch sets the same wMoveMissed as a
|
||||
-- failed accuracy roll (core.asm:5260), and the miss handler still
|
||||
-- runs the explode effect ("even if Explosion or Selfdestruct
|
||||
-- missed, its effect still needs to be activated", core.asm:3223),
|
||||
-- so the user faints against a mid-Fly/Dig target too (#528)
|
||||
if record and record.onMiss then record.onMiss(ctx, "invulnerable") end
|
||||
return
|
||||
end
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ local RomImporter = {}
|
||||
RomImporter.__index = RomImporter
|
||||
|
||||
-- Cache generation tag; bump to force every imported version to re-extract.
|
||||
local CACHE_FORMAT = "rom-cache-v8:"
|
||||
-- v9: Yellow audio re-anchored on pokeyellow.sym (#522) -- stale caches
|
||||
-- carry Red's bank $1f header, wave-table, and CryData offsets.
|
||||
local CACHE_FORMAT = "rom-cache-v9:"
|
||||
-- The completion marker is written under each version's cache prefix
|
||||
-- (rom-cache.complete for Red, blue/rom-cache.complete for Blue).
|
||||
local MARKER_PATH = "rom-cache.complete"
|
||||
|
||||
@@ -433,6 +433,13 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
if battle then
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
end
|
||||
-- FishingInit (engine/items/item_effects.asm): cp wWalkBikeSurfState, 2
|
||||
-- (surfing) sets carry, and every ItemUseXRod does jp c, ItemUseNotTime
|
||||
-- on that carry -- surfing refuses the rod with the same OAK text as
|
||||
-- the mid-battle case above, no rod-specific message (#533)
|
||||
if ow and ow.player and ow.player.surfing then
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
end
|
||||
return "fish", itemId
|
||||
end
|
||||
|
||||
|
||||
@@ -128,7 +128,14 @@ end
|
||||
|
||||
-- ------- the link surface
|
||||
|
||||
local SPECIES_FIELDS = { "baseStats", "types", "catchRate", "baseExp",
|
||||
-- catchRate stays out (#511): no link mode ever reads it -- a ball thrown
|
||||
-- in a link battle is a trainer-battle throw and always refused (pokered
|
||||
-- engine/items/item_effects.asm ItemUseBall), and the trade rebuild never
|
||||
-- touches it. Hashing it split Red/Blue from Yellow, whose only link
|
||||
-- surface delta is the Dragonair/Dragonite catch-rate bytes
|
||||
-- (data/pokemon/base_stats/dragonair.asm db 45 vs 27, dragonite.asm 45
|
||||
-- vs 9), when the real cable links R/B/Y freely.
|
||||
local SPECIES_FIELDS = { "baseStats", "types", "baseExp",
|
||||
"growthRate", "evolutions" }
|
||||
local MOVE_FIELDS = { "power", "type", "accuracy", "pp", "effect", "category",
|
||||
"priority", "highCrit", "fixedDamage", "multiHit",
|
||||
|
||||
+9
-12
@@ -178,19 +178,16 @@ end
|
||||
|
||||
-- ------- subset negotiation
|
||||
|
||||
-- the species and moves this party actually references, so the exchange
|
||||
-- stays small (six mons) instead of shipping the whole catalog
|
||||
-- every record hash, not just this party's slice: the receiver filters
|
||||
-- its OWN party against these (eligibleParty), so a message limited to the
|
||||
-- sender's party read "not on the other game" for every species the two
|
||||
-- parties did not happen to share, and a subset trade between different
|
||||
-- games showed neither side (#511). The full catalog is ~300 short hash
|
||||
-- strings -- still one message.
|
||||
function Protocol.recordsMessage(data, party)
|
||||
local species = Fingerprint.records(data, "pokemon")
|
||||
local moves = Fingerprint.records(data, "moves")
|
||||
local outSpecies, outMoves = {}, {}
|
||||
for _, mon in ipairs(party or {}) do
|
||||
if species[mon.species] then outSpecies[mon.species] = species[mon.species] end
|
||||
for _, mv in ipairs(mon.moves or {}) do
|
||||
if moves[mv.id] then outMoves[mv.id] = moves[mv.id] end
|
||||
end
|
||||
end
|
||||
return { type = "records", pokemon = outSpecies, moves = outMoves }
|
||||
return { type = "records",
|
||||
pokemon = Fingerprint.records(data, "pokemon"),
|
||||
moves = Fingerprint.records(data, "moves") }
|
||||
end
|
||||
|
||||
-- a mon may cross the wire only if both peers rebuild it identically: the
|
||||
|
||||
@@ -223,7 +223,9 @@ local function discover()
|
||||
for _, name in ipairs(fs.getDirectoryItems("mods")) do
|
||||
local path = "mods/" .. name
|
||||
local info = fs.getInfo(path)
|
||||
if info and info.type == "directory" then
|
||||
-- a dev-linked mod dir (ln -s) reports type "symlink" even with
|
||||
-- setSymlinksEnabled(true); see the matching note in Loader:_discover.
|
||||
if info and (info.type == "directory" or info.type == "symlink") then
|
||||
local raw = fs.read(path .. "/manifest.json")
|
||||
if raw then
|
||||
local manifest = decodeManifest(raw, path)
|
||||
|
||||
+5
-1
@@ -206,7 +206,11 @@ function Loader:_discover()
|
||||
for _, name in ipairs(self.fs.getDirectoryItems(root)) do
|
||||
local path = root .. "/" .. name
|
||||
local info = self.fs.getInfo(path)
|
||||
if info and info.type == "directory" then
|
||||
-- a dev-linked mod dir (ln -s) reports type "symlink" even with
|
||||
-- setSymlinksEnabled(true) -- PhysFS never resolves the symlink's
|
||||
-- own getInfo, only traversal into it. readManifest below still
|
||||
-- correctly no-ops on a symlink that isn't a directory.
|
||||
if info and (info.type == "directory" or info.type == "symlink") then
|
||||
local manifest, err = readManifest(self.fs, path)
|
||||
if manifest then
|
||||
if self.mods[manifest.id] then
|
||||
|
||||
+99
-12
@@ -148,6 +148,37 @@ local SPEED_BARS = {
|
||||
{ 0xE0, 0x80, 4 }, { 0xF0, 0x90, 2 },
|
||||
}
|
||||
|
||||
-- PlayIntroScene's .loop (pokeyellow engine/movie/intro_yellow.asm) ends
|
||||
-- every iteration with its own `call DelayFrame`, so one scene handler costs
|
||||
-- one frame plus whatever DelayFrame calls it makes itself. The port runs a
|
||||
-- handler inside a single update() tick, so every one of those internal
|
||||
-- DelayFrame calls has to be spent explicitly or the movie runs short of
|
||||
-- hardware. Three places owe frames:
|
||||
--
|
||||
-- SETUP_SCENE_DELAY scenes 2/4/6/8/10/12 open with
|
||||
-- YellowIntro_BlankPalsDelay2AndDisableLCD: rBGP/rOBP0/
|
||||
-- rOBP1 to $00 (color 0 in every shade -- the lightest,
|
||||
-- white on DMG), two DelayFrame calls, then DisableLCD.
|
||||
-- Two internal plus the loop's own is a 3-frame
|
||||
-- iteration, after which the next wait scene takes its
|
||||
-- first timer decrement. Func_f9e9a restores rBGP $e4
|
||||
-- at the end of the same handler.
|
||||
-- HEAD_FRAMES PlayIntroScene spends a DelayFrame between
|
||||
-- InitYellowIntroGFXAndMusic (which ends in PlayMusic)
|
||||
-- and the first .loop pass, and YellowIntroScene0 then
|
||||
-- owns a whole iteration of its own.
|
||||
-- EXIT_FRAMES .go_to_title_screen blanks the palettes and spends
|
||||
-- four DelayFrame calls clearing the tilemap and OAM
|
||||
-- before the title screen is built.
|
||||
--
|
||||
-- These restore the movie's real elapsed length; they do NOT lengthen the
|
||||
-- intro song. title.asm's StopAllMusic before MUSIC_TITLE_SCREEN is
|
||||
-- unconditional (only PikachuCry1 is waited on), so hardware cuts
|
||||
-- Music_YellowIntro off mid-phrase too (#523).
|
||||
local SETUP_SCENE_DELAY = 3
|
||||
local HEAD_FRAMES = 2
|
||||
local EXIT_FRAMES = 4
|
||||
|
||||
-- scene-6 sine (YellowIntro_Copy8BitSineWave.SineWave), signed SCY deltas
|
||||
local WAVE = { 0, 0, 1, 2, 2, 3, 3, 3, 4, 3, 3, 3, 2, 2, 1, 0,
|
||||
0, 0, -1, -2, -2, -3, -3, -3, -4, -3, -3, -3, -2, -2, -1, 0 }
|
||||
@@ -208,6 +239,8 @@ function YellowIntro.new(game, onDone)
|
||||
self.palName = "MEWMON" -- PalPacket_Generic
|
||||
self.objects = {}
|
||||
self.cloudFrame = 0
|
||||
self.pendingThen = nil -- what enterDelay runs once pendingDelay hits 0 (#523)
|
||||
self.pendingDelay = 0
|
||||
|
||||
self.atlas1 = tryImage("assets/generated/intro/yellow_intro_1.png")
|
||||
self.atlas2 = tryImage("assets/generated/intro/yellow_intro_2.png")
|
||||
@@ -559,6 +592,24 @@ function YellowIntro:startScene(scene)
|
||||
end
|
||||
end
|
||||
|
||||
-- Spend `frames` real frames, then run fn: the port's stand-in for the
|
||||
-- DelayFrame calls a pokeyellow scene handler makes inside its own
|
||||
-- PlayIntroScene .loop iteration (#523). fn may be nil to just burn time.
|
||||
function YellowIntro:enterDelay(frames, fn)
|
||||
self.pendingDelay = frames
|
||||
self.pendingThen = fn
|
||||
end
|
||||
|
||||
-- YellowIntro_BlankPalsDelay2AndDisableLCD blanks the palettes for the
|
||||
-- delay; Func_f9e9a puts rBGP back to $e4 at the tail of the same handler.
|
||||
function YellowIntro:enterSetupScene(scene)
|
||||
self.bgp = 0x00
|
||||
self:enterDelay(SETUP_SCENE_DELAY, function()
|
||||
self.bgp = 0xE4
|
||||
self:startScene(scene)
|
||||
end)
|
||||
end
|
||||
|
||||
function YellowIntro:enter()
|
||||
if self.pre then return end -- pre-roll first; beginScenes takes over
|
||||
self:beginScenes()
|
||||
@@ -571,7 +622,11 @@ function YellowIntro:beginScenes()
|
||||
local song = songs and (songs.Music_YellowIntro and "Music_YellowIntro"
|
||||
or songs.Music_IntroBattle and "Music_IntroBattle")
|
||||
if song then pcall(Music.play, data, song, false) end
|
||||
self:startScene(0)
|
||||
-- HEAD_FRAMES: the DelayFrame between PlayMusic and the first .loop pass
|
||||
-- plus YellowIntroScene0's own iteration. new() already laid down the
|
||||
-- Func_f9e5f letterbox that InitYellowIntroGFXAndMusic writes, so these
|
||||
-- frames show what hardware shows: the letterbox with no pika yet.
|
||||
self:enterDelay(HEAD_FRAMES, function() self:startScene(0) end)
|
||||
end
|
||||
|
||||
-- The movie's exit never stops the song (intro_yellow.asm PlayIntroScene
|
||||
@@ -586,16 +641,44 @@ function YellowIntro:finish()
|
||||
if self.onDone then self.onDone() end
|
||||
end
|
||||
|
||||
-- Both scene-loop exits (scene 17 running out, and the A/B/START skip) land
|
||||
-- on .go_to_title_screen, which calls YellowIntro_BlankPalettes and then
|
||||
-- spends EXIT_FRAMES DelayFrame calls clearing the tilemap and the OAM
|
||||
-- buffers before the title screen is built (#523). The pre-roll's own skip
|
||||
-- is IntroMovie's path, not this one, and still finishes immediately.
|
||||
function YellowIntro:exitToTitle()
|
||||
if self.finished or self.exiting then return end
|
||||
self.exiting = true
|
||||
self.bgp = 0x00
|
||||
self:clearObjects()
|
||||
self:enterDelay(EXIT_FRAMES, function() self:finish() end)
|
||||
end
|
||||
|
||||
function YellowIntro:update(dt)
|
||||
if self.finished then return end
|
||||
if self.pre then
|
||||
self.pre:update(dt)
|
||||
return
|
||||
end
|
||||
-- Ahead of the skip poll on purpose: JoypadLowSensitivity runs once at the
|
||||
-- top of each .loop iteration, never inside a handler, so hardware is deaf
|
||||
-- for the DelayFrame calls these ticks stand in for (#523).
|
||||
if self.pendingDelay > 0 then
|
||||
self.pendingDelay = self.pendingDelay - 1
|
||||
if self.pendingDelay == 0 then
|
||||
local fn = self.pendingThen
|
||||
self.pendingThen = nil
|
||||
if fn then fn() end
|
||||
end
|
||||
self:updateObjects()
|
||||
if self.bgDirty then self:rebuildBgCanvas() end
|
||||
return
|
||||
end
|
||||
|
||||
local input = self.game.input
|
||||
if input:wasPressed("a") or input:wasPressed("b")
|
||||
or input:wasPressed("start") then
|
||||
self:finish()
|
||||
self:exitToTitle()
|
||||
return
|
||||
end
|
||||
|
||||
@@ -605,7 +688,7 @@ function YellowIntro:update(dt)
|
||||
self.timer = self.timer - 1
|
||||
else
|
||||
self:clearObjects()
|
||||
self:startScene(scene + 1)
|
||||
self:enterSetupScene(scene + 1) -- BlankPalsDelay2AndDisableLCD (#523)
|
||||
end
|
||||
elseif scene == 3 then
|
||||
if self.timer > 0 then
|
||||
@@ -613,7 +696,7 @@ function YellowIntro:update(dt)
|
||||
if self.scx ~= 0x68 then self.scx = self.scx + 4 end
|
||||
else
|
||||
self:clearObjects()
|
||||
self:startScene(4)
|
||||
self:enterSetupScene(4) -- BlankPalsDelay2AndDisableLCD (#523)
|
||||
end
|
||||
elseif scene == 7 then
|
||||
if self.timer > 0 then
|
||||
@@ -625,7 +708,7 @@ function YellowIntro:update(dt)
|
||||
self.wave[255] = first
|
||||
else
|
||||
self:clearObjects()
|
||||
self:startScene(8)
|
||||
self:enterSetupScene(8) -- BlankPalsDelay2AndDisableLCD (#523)
|
||||
end
|
||||
elseif scene == 11 then
|
||||
if self.timer > 0 then
|
||||
@@ -640,7 +723,7 @@ function YellowIntro:update(dt)
|
||||
self.timer = self.timer - 1
|
||||
else
|
||||
self:clearObjects()
|
||||
self:startScene(12)
|
||||
self:enterSetupScene(12) -- BlankPalsDelay2AndDisableLCD (#523)
|
||||
end
|
||||
elseif scene == 13 then
|
||||
if self.timer > 0 then
|
||||
@@ -656,13 +739,17 @@ function YellowIntro:update(dt)
|
||||
if v then
|
||||
self.bgp = v
|
||||
else
|
||||
-- .expired: everything despawns, letterbox returns, logo/face
|
||||
-- object $7 appears for the strobe scene
|
||||
-- .expired: everything despawns and the letterbox returns, then the
|
||||
-- handler spends three DelayFrame calls with hAutoBGTransferEnabled
|
||||
-- pushing that rebuilt tilemap to VRAM before it restores rBGP $e4
|
||||
-- and spawns the logo/face object $7 for the strobe scene (#523).
|
||||
self:clearObjects()
|
||||
self:bgLetterbox()
|
||||
self.bgp = 0xE4
|
||||
self:spawn(7, 0x58, 0x58)
|
||||
self:startScene(15)
|
||||
self:enterDelay(3, function()
|
||||
self.bgp = 0xE4
|
||||
self:spawn(7, 0x58, 0x58)
|
||||
self:startScene(15)
|
||||
end)
|
||||
end
|
||||
elseif scene == 15 then
|
||||
if self.timer > 0 then
|
||||
@@ -687,7 +774,7 @@ function YellowIntro:update(dt)
|
||||
if self.timer > 0 then
|
||||
self.timer = self.timer - 1
|
||||
else
|
||||
self:finish()
|
||||
self:exitToTitle()
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
@@ -360,6 +360,36 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
else
|
||||
self.player = Player.new(Game.data, x, y, facing)
|
||||
end
|
||||
-- boot only: the original persists the surf state. wWalkBikeSurfState
|
||||
-- (ram/wram.asm) lives inside wMainDataStart..wMainDataEnd, which
|
||||
-- engine/menus/save.asm block-copies into sMainData on save and back out
|
||||
-- on load (sram.asm declares sMainData as `ds wMainDataEnd -
|
||||
-- wMainDataStart`), and Continue never clears it -- the only `xor a /
|
||||
-- ld [wWalkBikeSurfState], a` on that path is the cable club's. Restore
|
||||
-- it here, before Music.playMap reads it below and before
|
||||
-- PikachuFollower.onMapEntered, matching LoadMapData calling
|
||||
-- LoadPlayerSpriteGraphics ahead of PlayDefaultMusic (home/overworld.asm,
|
||||
-- home/audio.asm). Without this the player resumed on foot on a water
|
||||
-- cell, which softlocks here: Collision.canMove (src/world/Collision.lua)
|
||||
-- picks land tile-pairs whenever mover.surfing is falsy, and land
|
||||
-- tile-pairs never permit stepping off a water cell (#536). Same
|
||||
-- boot-only shape as the refreshStandingOnWarp door-mat restore (#378).
|
||||
if opts and opts.via == "boot" then
|
||||
local ps = Game.save and Game.save.player
|
||||
if ps and ps.surfing ~= nil then
|
||||
self.player.surfing = ps.surfing and true or false
|
||||
else
|
||||
-- saves written before #536 carry no flag. Map:isWaterCell alone is
|
||||
-- not self-sufficient (see src/world/Map.lua: water and shore share
|
||||
-- one lookup, and no tileset stamps waterTiles, so tile $14 -- a
|
||||
-- walkable floor in HOUSE/GATE/LOBBY/MANSION/MUSEUM -- reads as
|
||||
-- water), so gate it the way facingIsShoreOrWater does and require a
|
||||
-- cell you could not be standing on upright.
|
||||
self.player.surfing = self:tilesetHasWater()
|
||||
and not self.map:isWalkableCell(x, y)
|
||||
and self.map:isWaterCell(x, y)
|
||||
end
|
||||
end
|
||||
-- crossConnection re-arms this after setMap; clear so a warp/reload
|
||||
-- cannot leave a stale deferred PlayMapMusic pending
|
||||
self.pendingSeamMusic = nil
|
||||
@@ -1058,19 +1088,38 @@ function OverworldState:handleInput()
|
||||
-- OverworldLoop (home/overworld.asm) gates ALL of JoypadOverworld on
|
||||
-- wWalkCounter == 0 ("if the player sprite has not yet completed the
|
||||
-- walking animation" it jumps straight to .moveAhead): A, START and
|
||||
-- direction initiation are only ever looked at while the player stands
|
||||
-- on a tile, and a button pressed mid-step is simply never seen.
|
||||
-- Without this gate a mid-step A/START pushed its TextBox/StartMenu
|
||||
-- right there and froze Red between tiles, mid-animation (#286). Held
|
||||
-- directions need no buffering -- isDown below picks them up on the
|
||||
-- landing frame.
|
||||
if self.player.moving then return end
|
||||
-- direction initiation are only ever ACTED ON while the player stands on
|
||||
-- a tile. Without this gate a mid-step A/START pushed its TextBox/
|
||||
-- StartMenu right there and froze Red between tiles, mid-animation
|
||||
-- (#286). Held directions need no buffering -- isDown below picks them
|
||||
-- up on the landing frame.
|
||||
--
|
||||
-- The original defers the poll rather than discarding it, though. Joypad
|
||||
-- (engine/joypad.asm _Joypad) computes hJoyPressed against hJoyLast and
|
||||
-- advances hJoyLast only when something calls it; the mid-step path never
|
||||
-- does, and vblank's per-frame ReadJoypad refreshes hJoyInput alone.
|
||||
-- hJoyLast is frozen for the whole animation, so a button pressed
|
||||
-- mid-step and STILL HELD when the step lands reads as a fresh press at
|
||||
-- the next poll -- one released before then is genuinely lost. Dropping
|
||||
-- the edge outright made START a coin flip on the Cycling Road roll,
|
||||
-- where the pull below re-arms a step on the single idle frame in
|
||||
-- bikeStepFrames (#525).
|
||||
if self.player.moving then
|
||||
local held = self.joyLatch
|
||||
if not held then held = {}; self.joyLatch = held end
|
||||
if input:wasPressed("a") then held.a = true end
|
||||
if input:wasPressed("start") then held.start = true end
|
||||
return
|
||||
end
|
||||
local latch = self.joyLatch
|
||||
self.joyLatch = nil
|
||||
|
||||
if input:wasPressed("a") then
|
||||
if input:wasPressed("a") or (latch and latch.a and input:isDown("a")) then
|
||||
self:interact()
|
||||
return
|
||||
end
|
||||
if input:wasPressed("start") then
|
||||
if input:wasPressed("start")
|
||||
or (latch and latch.start and input:isDown("start")) then
|
||||
require("src.core.Sound").play(Game.data, "Start_Menu")
|
||||
Screens.push(Game, "StartMenu")
|
||||
return
|
||||
@@ -4561,6 +4610,11 @@ function OverworldState:captureSave(save)
|
||||
save.player.x = self.player.cellX
|
||||
save.player.y = self.player.cellY
|
||||
save.player.facing = self.player.facing
|
||||
-- wWalkBikeSurfState (ram/wram.asm) sits inside the wMainDataStart..
|
||||
-- wMainDataEnd range engine/menus/save.asm block-copies into sMainData,
|
||||
-- so the original saves and restores the surf state; setMap's boot path
|
||||
-- reads this back (#536).
|
||||
save.player.surfing = self.player.surfing and true or false
|
||||
end
|
||||
|
||||
return OverworldState
|
||||
|
||||
@@ -12,9 +12,13 @@ local Player = {}
|
||||
Player.__index = Player
|
||||
|
||||
local STEP_FRAMES = 16
|
||||
-- a turn in place holds for the ~2 frames the original spends on the
|
||||
-- extra OverworldLoop pass (home/overworld.asm .handleDirectionButtonPress
|
||||
-- returns to the loop without moving after a direction change)
|
||||
-- a turn in place blocks movement for the one extra OverworldLoop pass the
|
||||
-- original spends after a direction change: .handleDirectionButtonPress ends
|
||||
-- `jp OverworldLoop` (home/overworld.asm), and OverworldLoop burns two
|
||||
-- DelayFrame calls before the next JoypadOverworld, so the sample that can
|
||||
-- commit to a step lands exactly 2 fixed steps after the turn -- the same
|
||||
-- 2-frames-per-iteration cadence that makes STEP_FRAMES 16 above
|
||||
-- (wWalkCounter = 8, 2px per AdvancePlayerSprite) (#415)
|
||||
local TURN_FRAMES = 2
|
||||
|
||||
function Player.new(data, cx, cy, facing)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
-- Driver: opening the START menu while the bike force-rolls down Cycling
|
||||
-- Road (#525). src/world/OverworldController.lua handleInput() used to
|
||||
-- drop A/START outright while self.player.moving, so a press that landed
|
||||
-- mid-step and was still held when the step completed vanished -- on
|
||||
-- Cycling Road's forced roll, where a step re-arms on the single idle
|
||||
-- frame between steps, that made START a coin flip. The fix latches a
|
||||
-- still-held press and acts on it on the landing frame instead
|
||||
-- (see tests/parity_midstep_buttons.lua for the mechanical half).
|
||||
--
|
||||
-- POKEPORT_DRIVER=tests/drivers/cycling_road_menu_bug525_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug525 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \
|
||||
-- SHOT_DIR=/tmp/shots love .
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- Same clear stretch bug255 uses: x=2 is open road from y=8 past y=34.
|
||||
local MAP, START_X, START_Y = "ROUTE_17", 2, 20
|
||||
|
||||
game.save.onBike = true
|
||||
U.teleport(game, MAP, START_X, START_Y, "down")
|
||||
U.wait(15)
|
||||
local ow = game.overworld
|
||||
check("standing on Cycling Road, on the bike",
|
||||
ow.map.id == MAP and game.save.onBike)
|
||||
|
||||
-- hands off the pad: the slope pulls the bike south on its own
|
||||
-- (field.forcedMovement.slopeMaps, same mechanism bug255 proved)
|
||||
U.wait(20)
|
||||
check("the roll is already under way", ow.player.moving == true
|
||||
or ow.player.cellY > START_Y)
|
||||
U.shot(game, DIR .. "/bug525_1_rolling.png")
|
||||
|
||||
-- press START mid-step and keep it held through the landing frame, the
|
||||
-- exact shape a real thumb makes reaching for the menu while rolling
|
||||
local pressedMidStep = false
|
||||
local guard = 0
|
||||
while guard < 60 do
|
||||
guard = guard + 1
|
||||
if ow.player.moving and ow.player.progress
|
||||
and ow.player.progress > 2 and not pressedMidStep then
|
||||
table.insert(game.input.pressQueue, "start")
|
||||
game.input.state.start = true
|
||||
pressedMidStep = true
|
||||
U.log("pressed START mid-step at progress", ow.player.progress)
|
||||
end
|
||||
coroutine.yield()
|
||||
if getmetatable(game.stack:top()) ~= nil
|
||||
and game.stack:top() ~= ow and pressedMidStep then
|
||||
break
|
||||
end
|
||||
end
|
||||
U.wait(2)
|
||||
game.input.state.start = false
|
||||
|
||||
local top = game.stack:top()
|
||||
U.shot(game, DIR .. "/bug525_2_after_start.png")
|
||||
check("START held through the landing opened the start menu",
|
||||
top ~= ow and top ~= nil)
|
||||
check("the player is not frozen mid-tile (px/py land on a 16px cell)",
|
||||
ow.player.px % 16 == 0 and ow.player.py % 16 == 0)
|
||||
|
||||
-- close it and let the roll continue, proving the pull did not get
|
||||
-- eaten along with the buffered button
|
||||
if top ~= ow then
|
||||
while game.stack:top() ~= ow do
|
||||
U.tap(game, "b")
|
||||
U.wait(10)
|
||||
end
|
||||
end
|
||||
local yBeforeResume = ow.player.cellY
|
||||
U.wait(48)
|
||||
check("the roll resumes south after closing the menu",
|
||||
ow.player.cellY > yBeforeResume)
|
||||
U.shot(game, DIR .. "/bug525_3_resumed.png")
|
||||
|
||||
-- park with road left to roll, on the bike, before handing over
|
||||
U.teleport(game, MAP, START_X, START_Y, "down")
|
||||
U.wait(10)
|
||||
game.save.onBike = true
|
||||
|
||||
U.log("On the bike on Cycling Road at (" .. START_X .. "," .. START_Y ..
|
||||
"), rolling south hands-off.")
|
||||
U.log("Press START right as you see a step land, and again mid-step")
|
||||
U.log("while releasing before it lands. Right: a START you keep pressed")
|
||||
U.log("into the landing opens the menu every time, one you let go of")
|
||||
U.log("mid-step does nothing (same as standing still). #525 was the")
|
||||
U.log("held case sometimes doing nothing at all, a coin flip on this hill.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,107 @@
|
||||
-- Driver: Fighting Dojo Karate Master does not leave his post (#502),
|
||||
-- the opposite-direction report against the same script #495 fixed
|
||||
-- (data/scripts/story4.lua dojoMasterGate / M.FIGHTING_DOJO.onStep).
|
||||
--
|
||||
-- Before the fix his trainer header used range=4/DOWN sight aggro
|
||||
-- (src/core/Data.lua seedFightingDojoKarateMaster), so walking up his
|
||||
-- column from below put him in CheckFightingMapTrainers' generic path:
|
||||
-- the NPC walks toward the player before the pre-battle text, straight
|
||||
-- through FIGHTINGDOJO_BLACKBELT3/4's tiles at (5,5)/(5,7) -- the
|
||||
-- "merges with a pupil" glitch, and it fired regardless of whether the
|
||||
-- pupils in front of him had been beaten yet ("forces the fight early").
|
||||
-- The fix sets range=0 and gates him on the single tile at his left
|
||||
-- (4,3) instead (onStep = dojoMasterGate): he only ever turns to face
|
||||
-- the player there, never steps.
|
||||
--
|
||||
-- POKEPORT_DRIVER=tests/drivers/fighting_dojo_master_bug502_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug502 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local function masterIn(ow)
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.def and n.def.name == "FIGHTINGDOJO_KARATE_MASTER" then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- No pupil beaten, master not beaten: the exact state the report says
|
||||
-- triggered the early merge (walking up before clearing the pupils).
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_BEAT_KARATE_MASTER = nil
|
||||
game.save.defeatedTrainers = {}
|
||||
|
||||
------------------------------------------------------------------
|
||||
-- Direction 1 (#502): stand in the master's own sight column, below
|
||||
-- him, with his southern pupils (BLACKBELT3 at (5,5), BLACKBELT4 at
|
||||
-- (5,7)) still in the way -- exactly the "walk towards Karate Master"
|
||||
-- report. The old range=4/DOWN header put any of these cells in his
|
||||
-- sight; his first step toward the player collided with BLACKBELT3's
|
||||
-- tile. Collision blocks a real walk onto an NPC's cell, so probe by
|
||||
-- teleport (a scripted approach cannot walk through them either) and
|
||||
-- watch several fixed steps for the master moving or engaging at all.
|
||||
------------------------------------------------------------------
|
||||
local ow, master
|
||||
local function probe(px, py, label)
|
||||
U.teleport(game, "FIGHTING_DOJO", px, py, "up")
|
||||
U.wait(10)
|
||||
ow = game.stack:top()
|
||||
master = masterIn(ow)
|
||||
U.shot(game, DIR .. "/bug502_1_" .. label .. "_before.png")
|
||||
local moved, engaged = false, false
|
||||
for _ = 1, 40 do
|
||||
U.wait(1)
|
||||
if master.cellX ~= 5 or master.cellY ~= 3 then moved = true end
|
||||
if getmetatable(game.stack:top()) == TextBox then engaged = true; break end
|
||||
end
|
||||
U.shot(game, DIR .. "/bug502_1_" .. label .. "_after.png")
|
||||
check("Fighting Dojo is loaded (" .. label .. ")", ow.map.id == "FIGHTING_DOJO")
|
||||
check("Karate Master stayed at (5,3), did not walk toward (" .. label .. ")",
|
||||
not moved)
|
||||
check("standing at " .. label .. " started no battle on its own",
|
||||
not engaged)
|
||||
return moved or engaged
|
||||
end
|
||||
|
||||
-- (5,4): directly below the master, one tile from BLACKBELT3 (5,5).
|
||||
-- (5,6): the gap between BLACKBELT3 and BLACKBELT4, still in the old
|
||||
-- sight range and still boxed in by pupils on both sides.
|
||||
local bad1 = probe(5, 4, "below_master")
|
||||
local bad2 = probe(5, 6, "between_pupils")
|
||||
|
||||
------------------------------------------------------------------
|
||||
-- Direction 2 (#495, same script): the ONLY tile that should start his
|
||||
-- fight is one step left of him. Confirm the gate still works so the
|
||||
-- fix didn't just make him inert in both directions.
|
||||
------------------------------------------------------------------
|
||||
U.teleport(game, "FIGHTING_DOJO", 4, 2, "down")
|
||||
U.wait(10)
|
||||
ow = game.stack:top()
|
||||
master = masterIn(ow)
|
||||
U.shot(game, DIR .. "/bug502_3_gate_before.png")
|
||||
U.tap(game, "down")
|
||||
U.wait(30)
|
||||
local gateOpened = getmetatable(game.stack:top()) == TextBox
|
||||
U.shot(game, DIR .. "/bug502_4_gate_after.png")
|
||||
check("stepping onto (4,3) still starts the Master's challenge", gateOpened)
|
||||
check("he turned to face the player instead of walking to them",
|
||||
master.cellX == 5 and master.cellY == 3 and master.facing == "left")
|
||||
|
||||
U.log("Compare bug502_1/2: the Master's sprite should sit in the same")
|
||||
U.log("spot in both, never sliding down onto BLACKBELT3 or BLACKBELT4.")
|
||||
U.log("bug502_4 should show his pre-battle text box, with him still on")
|
||||
U.log("(5,3), just turned to face left -- that is the correct trigger,")
|
||||
U.log("distinct from the column walk that used to glitch him south.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,42 @@
|
||||
-- Driver: a quick directional tap should turn the player in place, not
|
||||
-- also take a step (#415). Player:tryMove (src/world/Player.lua) only
|
||||
-- steps once turnTimer has counted down; TURN_FRAMES/turnFrames set how
|
||||
-- many fixed steps a facing change blocks movement for before a held
|
||||
-- direction is allowed to commit to a step. This is an input-timing feel
|
||||
-- bug: no assertion here can tell a quick tap from a held one the way a
|
||||
-- human thumb can, so this hands the pad over rather than scripting taps
|
||||
-- at a fixed, unrealistic frame count.
|
||||
--
|
||||
-- POKEPORT_DRIVER=tests/drivers/turn_in_place_bug415_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug415 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- REDS_HOUSE_2F: the player's own bedroom, the default new-game spawn
|
||||
-- (src/core/SaveData.lua bedroom fallback). Small enough that an
|
||||
-- accidental step is obvious -- a stray step toward the stairs at
|
||||
-- (7,1)/(7,2) or into the desk/bed furniture is immediately visible.
|
||||
U.teleport(game, "REDS_HOUSE_2F", 5, 6, "down")
|
||||
U.wait(10)
|
||||
local ow = game.stack:top()
|
||||
check("Red's bedroom is loaded", ow.map.id == "REDS_HOUSE_2F")
|
||||
check("player starts facing down", ow.player.facing == "down")
|
||||
|
||||
U.log("Standing in the middle of the bedroom, facing down.")
|
||||
U.log("Tap Up once, quickly, then tap Right once, quickly.")
|
||||
U.log("Right: each tap only turns you to face that way -- your feet")
|
||||
U.log("stay on the same tile both times. Wrong (#415): a quick tap")
|
||||
U.log("still slides you one tile in the new direction, same as holding.")
|
||||
U.log("Now hold Up for a beat: turning into a real step, then a walk,")
|
||||
U.log("should feel identical to before -- no extra pause was added there.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,99 @@
|
||||
-- Manual check that Yellow's bank $1f music headers, wave table and cry
|
||||
-- data read from the right ROM addresses (#522). Before the fix every
|
||||
-- header past Music_YellowIntro (a 3-channel header where Red's
|
||||
-- Music_IntroBattle was 4) was read 3 bytes off, so Viridian Forest's
|
||||
-- Music_Dungeon2 lost 3 of its 4 channels and its tempo command; every
|
||||
-- song's channel 3 sampled engine code instead of a wave table.
|
||||
-- POKEPORT_DRIVER=tests/drivers/yellow_audio_bug522_test.lua POKEPORT_IDENTITY=bug522 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local ChipSynth = require("src.core.ChipSynth")
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local audio = game.data.audio or {}
|
||||
local header = audio.musicHeaders and audio.musicHeaders.Music_Dungeon2
|
||||
|
||||
-- pokeyellow.sym: Music_Dungeon2 = 1f:42a9 (bank 31, address 17065)
|
||||
check("Music_Dungeon2 header reads pokeyellow.sym's address",
|
||||
header and header.address == 17065 and header.bank == 31)
|
||||
|
||||
-- pokeyellow.sym: CryData = 0e:5462 (bank 14, address 21602)
|
||||
local cry = audio.cryData
|
||||
check("cryData reads pokeyellow.sym's CryData",
|
||||
cry and cry.address == 21602 and cry.bank == 14)
|
||||
|
||||
-- pokeyellow only has one wave table, Audio1_WavePointers.wave0 = 02:5a26
|
||||
local waves = audio.waveBanks or {}
|
||||
local waveOk = true
|
||||
for _, engine in ipairs({ "1", "2", "3" }) do
|
||||
local w = waves[engine]
|
||||
if not (w and w.address == 23078 and w.bank == 2) then waveOk = false end
|
||||
end
|
||||
check("all three waveBanks point at Audio1_WavePointers.wave0", waveOk)
|
||||
|
||||
-- Rebuild the engine exactly as ChipAudio does and count the channels a
|
||||
-- misread header would drop to 1 (see headerChannels, ChipSynth.lua).
|
||||
local engine, engErr
|
||||
if header then
|
||||
local ok
|
||||
ok, engine = pcall(ChipSynth.newEngine, game.data, header)
|
||||
if not ok then engErr = engine; engine = nil end
|
||||
end
|
||||
check("Music_Dungeon2 engine builds" .. (engErr and (": " .. tostring(engErr)) or ""),
|
||||
engine ~= nil)
|
||||
check("Music_Dungeon2 has all 4 channels, not the misread header's 1",
|
||||
engine and #engine.channels == 4)
|
||||
|
||||
-- Ch1 opens with the tempo command (dungeon2.asm "Music_Dungeon2_Ch1::
|
||||
-- tempo 144"); a header pointed at the wrong row drops it and the engine
|
||||
-- is left at the 0x100 default, ~1.78x slower.
|
||||
if engine and engine.channels[1] then
|
||||
engine.channels[1]:nextEvent()
|
||||
end
|
||||
check("Ch1's tempo command set engine.tempo to 144, not the 0x100 default",
|
||||
engine and engine.tempo == 144)
|
||||
|
||||
-- pokered data/maps/objects/ViridianForest.asm: the south gate warps land
|
||||
-- around (16-18, 47); one step north of that is inside the forest proper.
|
||||
local MAP = "VIRIDIAN_FOREST"
|
||||
local STAND = { x = 16, y = 46, facing = "up" }
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
|
||||
local ow = game.overworld
|
||||
if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
local function nearestWalkable()
|
||||
for dy = -3, 3 do
|
||||
for dx = -3, 3 do
|
||||
local cx, cy = STAND.x + dx, STAND.y + dy
|
||||
if ow.map:isWalkableCell(cx, cy) then return cx, cy end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
local cx, cy = nearestWalkable()
|
||||
if cx then
|
||||
U.log("start cell blocked, standing at", cx, cy, "instead")
|
||||
U.teleport(game, MAP, cx, cy, "up")
|
||||
end
|
||||
end
|
||||
|
||||
if game.save and game.save.options and game.save.options.musicVol == 0 then
|
||||
U.log("FAIL musicVol is 0 -- turn it up, this run is silent by design")
|
||||
end
|
||||
|
||||
U.log("Standing in Viridian Forest; Music_Dungeon2 is playing now.")
|
||||
U.log("Right: four voices at a brisk clip -- lead melody, a counter line")
|
||||
U.log("underneath it, a soft rounded bass, and a hi-hat tick on top.")
|
||||
U.log("Wrong (the bug): one thin lonely voice at a noticeably slower,")
|
||||
U.log("draggy tempo, with no bass or hi-hat at all.")
|
||||
U.log("Also listen to that bass note's timbre -- it should be a soft")
|
||||
U.log("rounded triangle-ish tone, not a harsh buzzy rasp (waveBanks).")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,73 @@
|
||||
-- Manual check that the Yellow intro now runs long enough for
|
||||
-- Music_YellowIntro's descending run and long final note to be audible
|
||||
-- before title.asm's unconditional StopAllMusic swaps in the title theme
|
||||
-- (#523). Never taps a button: Game:load() already pushed YellowIntro,
|
||||
-- so this driver only watches it play out at real, unaccelerated speed.
|
||||
-- POKEPORT_SPEED must stay unset -- Music runs on its own real-time 60Hz
|
||||
-- accumulator (Game:update), decoupled from the fast-forwardable logic
|
||||
-- clock, so speeding up the driver would desync the exact ordering under
|
||||
-- test here instead of just playing it back faster.
|
||||
-- POKEPORT_DRIVER=tests/drivers/yellow_intro_bug523_test.lua POKEPORT_IDENTITY=bug523 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Music = require("src.core.Music")
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
check("booted into a state (Yellow intro, per bootScreens)",
|
||||
game.stack:top() ~= nil)
|
||||
|
||||
if game.save and game.save.options and game.save.options.musicVol == 0 then
|
||||
U.log("FAIL musicVol is 0 -- turn it up, this run is silent by design")
|
||||
end
|
||||
|
||||
local sawIntroSong = false
|
||||
local sawTitleSong = false
|
||||
|
||||
-- Music.lua keeps the current song in a module-local `state` table with
|
||||
-- no public getter, so watch the one seam that is public: wrap Music.play
|
||||
-- itself for the life of this driver and log what it's handed. Both the
|
||||
-- YellowIntro:beginScenes() pcall and title.asm's StopAllMusic-then-
|
||||
-- PlaySound land here.
|
||||
local frame = 0
|
||||
local originalPlay = Music.play
|
||||
Music.play = function(data, song, loop, ctx)
|
||||
if song and song:find("Intro") and not sawIntroSong then
|
||||
sawIntroSong = true
|
||||
U.log("Music_YellowIntro started")
|
||||
end
|
||||
if song == "Music_TitleScreen" and not sawTitleSong then
|
||||
sawTitleSong = true
|
||||
U.log("Music_TitleScreen started")
|
||||
end
|
||||
return originalPlay(data, song, loop, ctx)
|
||||
end
|
||||
|
||||
-- 40s ceiling counted from driver start, not from the intro song: roughly 500 frames of
|
||||
-- boot and bootScreens run before Music_YellowIntro begins, and the title swap lands
|
||||
-- around frame 1680 (28s), so a 20s ceiling expired before the moment under test (#523).
|
||||
while frame < 40 * 60 and not sawTitleSong do
|
||||
frame = frame + 1
|
||||
coroutine.yield()
|
||||
end
|
||||
|
||||
check("Music_YellowIntro started", sawIntroSong)
|
||||
check("the movie reached the title screen and Music_TitleScreen took over",
|
||||
sawTitleSong)
|
||||
|
||||
U.log("Listen for Music_YellowIntro's ending: a short descending run of")
|
||||
U.log("notes (F#, F, F#...F#, E, D#, C#) followed by one long low note,")
|
||||
U.log("right before the title theme cuts in. Before #523 the swap landed")
|
||||
U.log("early and those descending notes never played -- the track just")
|
||||
U.log("stopped mid-phrase. The swap itself is still an abrupt cut by")
|
||||
U.log("design (pokeyellow's title.asm stops the intro song unconditionally,")
|
||||
U.log("it never waits for it to finish); what changed is how much of the")
|
||||
U.log("track plays before that cut lands.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -1 +1 @@
|
||||
9820543f7a223fab
|
||||
7de3051a7b3c108e
|
||||
|
||||
@@ -1 +1 @@
|
||||
54a52ea81751495c
|
||||
227fd737aba15763
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
-- Regression (#518): without a BICYCLE, the Route 16/18 gate guard must
|
||||
-- still shove the player one tile aside after refusing them, not leave
|
||||
-- them parked against the counter.
|
||||
--
|
||||
-- pokered's Route16Gate1FGuardScript / Route18Gate1FGuardScript (scripts/
|
||||
-- Route16Gate1F.asm, Route18Gate1F.asm) print the refusal text, then
|
||||
-- simulate one PAD_RIGHT step (StartSimulatingJoypadStates) and only clear
|
||||
-- wJoyIgnore once that step lands. data/scripts/story5.lua's bikeGateGuard
|
||||
-- walked the player up to the counter and stopped there -- the ported
|
||||
-- refusal text played, but nothing ever moved the player off the doorway.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local Data = require("src.core.Data")
|
||||
if not (Data.maps and Data.maps.ROUTE_16_GATE_1F) then Data:load() end
|
||||
|
||||
local Game = require("src.core.Game")
|
||||
local Input = require("src.core.Input")
|
||||
local MapLoader = require("src.world.MapLoader")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local OW = require("src.world.OverworldController")
|
||||
local S = require("tests.harness").suite("parity bike gate shove (#518)")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
-- ground truth: data/generated/maps.lua ROUTE_16_GATE_1F.objects, the guard
|
||||
-- sits at (4,5) behind a solid counter row (row 6); rows 7-10 south of it
|
||||
-- are the open waiting area bikeGateGuard's coords cover.
|
||||
local r16 = MapLoader.load(Data, "ROUTE_16_GATE_1F")
|
||||
check(r16:isWalkableCell(4, 7), "the counter-adjacent stop cell (4,7) is walkable")
|
||||
check(r16:isWalkableCell(5, 7), "the shove target (5,7) is walkable")
|
||||
check(not r16:isWalkableCell(4, 6), "row 6 (the counter) blocks straight through")
|
||||
|
||||
Game.data = Data
|
||||
Game.input = Input; Input:init()
|
||||
Game.renderer = Renderer; Renderer:init()
|
||||
Game.stack = StateStack; StateStack:init()
|
||||
|
||||
-- stub TextBox the way parity_A.lua drives gym leader text: a bare table
|
||||
-- capturing (text, onDone) so the test can dismiss it without pagination.
|
||||
local realTB = package.loaded["src.render.TextBox"]
|
||||
package.loaded["src.render.TextBox"] = {
|
||||
new = function(_, text, done) return { text = text, onDone = done } end,
|
||||
}
|
||||
|
||||
local mapScripts = require("data.scripts.init")
|
||||
local hooks = mapScripts.get("ROUTE_16_GATE_1F")
|
||||
check(hooks and hooks.onStep, "ROUTE_16_GATE_1F has an onStep hook")
|
||||
|
||||
-- one 16-frame step per fixed update, same as parity_ledge_seam_hop.lua
|
||||
local function runFrames(ow, n)
|
||||
for _ = 1, n do
|
||||
ow:updateScriptMoves()
|
||||
ow.player:update()
|
||||
end
|
||||
end
|
||||
|
||||
-- dismiss the top stubbed box the way TextBox:update does: pop, then onDone
|
||||
local function dismissTop()
|
||||
local box = Game.stack:pop()
|
||||
if box.onDone then box.onDone() end
|
||||
return box
|
||||
end
|
||||
|
||||
local function standNoBike(x, y, facing)
|
||||
Game.save = SaveData.newGame()
|
||||
Game.save.inventory.BICYCLE = nil
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
Game.stack:push(OW, "ROUTE_16_GATE_1F", x, y, facing or "up")
|
||||
local ow = Game.stack:top()
|
||||
ow.player.moving = false
|
||||
return ow
|
||||
end
|
||||
|
||||
-- --- approaching from three tiles out: walk-up then shove -----------------
|
||||
do
|
||||
local ow = standNoBike(4, 10)
|
||||
local handled = hooks.onStep(Game, ow, ow.player.cellX, ow.player.cellY)
|
||||
check(handled, "onStep claims the coordinate trigger without a BICYCLE")
|
||||
|
||||
local box1 = Game.stack:top()
|
||||
check(box1 ~= nil, "the wait-up refusal text box opened")
|
||||
check(box1.text:find("Wait up", 1, true) ~= nil
|
||||
or box1.text:find("Excuse me", 1, true) ~= nil,
|
||||
"first box is the guard's stop line")
|
||||
dismissTop()
|
||||
|
||||
local box2 = Game.stack:top()
|
||||
check(box2 ~= nil, "the second (explanation) box opened")
|
||||
eq(box2.text, Data.text._Route16Gate1FGuardNoPedestriansAllowedText,
|
||||
"second box is the guard's no-pedestrians explanation, verbatim")
|
||||
dismissTop()
|
||||
|
||||
check(#ow.scriptMoves > 0 or ow.player.moving,
|
||||
"closing the explanation queues the walk-up-then-shove movement")
|
||||
runFrames(ow, 200)
|
||||
check(not ow.player.moving, "the scripted movement has settled")
|
||||
eq(#ow.scriptMoves, 0, "no scripted move is left hanging")
|
||||
eq(ow.player.cellX, 5, "the player ends one tile right of the counter (#518 shove)")
|
||||
eq(ow.player.cellY, 7, "the player ends on the counter-adjacent row, not further")
|
||||
end
|
||||
|
||||
-- --- already standing at the counter-adjacent row: shove with no walk-up --
|
||||
do
|
||||
local ow = standNoBike(4, 7)
|
||||
local handled = hooks.onStep(Game, ow, ow.player.cellX, ow.player.cellY)
|
||||
check(handled, "onStep still claims it when already adjacent to the counter")
|
||||
dismissTop() -- wait-up
|
||||
dismissTop() -- explanation
|
||||
runFrames(ow, 200)
|
||||
check(not ow.player.moving, "the shove-only movement has settled")
|
||||
eq(#ow.scriptMoves, 0, "no scripted move is left hanging")
|
||||
eq(ow.player.cellX, 5, "still ends one tile right (#518 shove with dist=0)")
|
||||
eq(ow.player.cellY, 7, "y is unchanged since there was no walk-up needed")
|
||||
end
|
||||
|
||||
-- --- control: a BICYCLE in the bag lets the trigger pass through untouched
|
||||
do
|
||||
local ow = standNoBike(4, 10)
|
||||
ow.player.moving = false
|
||||
Game.save.inventory.BICYCLE = true
|
||||
local handled = hooks.onStep(Game, ow, ow.player.cellX, ow.player.cellY)
|
||||
check(not handled, "with a BICYCLE, onStep does not intercept the step")
|
||||
check(Game.stack:top() == ow, "no text box opened when riding a BICYCLE")
|
||||
end
|
||||
|
||||
package.loaded["src.render.TextBox"] = realTB
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,72 @@
|
||||
-- Parity test: Self-Destruct/Explosion against a mid-Fly/Dig
|
||||
-- (semi-invulnerable) target still faints the user (#528).
|
||||
--
|
||||
-- EffectRegistry.runDamaging's target.invulnerable branch prints the miss
|
||||
-- text and returned without ever calling record.onMiss, while the
|
||||
-- accuracy-roll, type-immunity and floored-damage miss branches all did.
|
||||
-- EXPLODE_EFFECT (src/battle/MoveEffects.lua) relies on onMiss to run
|
||||
-- battle:selfDestruct(user); skipping it left the user at full HP.
|
||||
--
|
||||
-- pokered engine/battle/core.asm MoveHitTest: "bit INVULNERABLE, [hl] /
|
||||
-- jp nz, .moveMissed" sets the same wMoveMissed as a failed accuracy roll,
|
||||
-- and the shared miss handler runs the explode effect regardless of which
|
||||
-- branch set it ("even if Explosion or Selfdestruct missed, its effect
|
||||
-- still needs to be activated"). JUMP_KICK_EFFECT's onMiss filters on
|
||||
-- reason == "accuracy", so a crash-damage kick must NOT also fire here.
|
||||
--
|
||||
-- Self-contained; run via `luajit tests/parity_explode_invulnerable.lua`.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
local S = require("tests.harness").suite("parity explode invulnerable")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local Data = require("src.core.Data")
|
||||
Data:load()
|
||||
|
||||
local Game = require("src.core.Game")
|
||||
local Input = require("src.core.Input")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
Game.data = Data
|
||||
Game.input = Input; Input:init()
|
||||
Game.stack = StateStack; StateStack:init()
|
||||
Game.save = require("src.core.SaveData").newGame()
|
||||
|
||||
-- Self-Destruct against a target mid-Fly (invulnerable, never-miss moves
|
||||
-- aside): the move never rolls accuracy at all, it just hits the
|
||||
-- invulnerable branch in EffectRegistry.runDamaging.
|
||||
local function selfDestructVsInvulnerable(moveId)
|
||||
Game.save.party = { Pokemon.new(Data, "GEODUDE", 30) }
|
||||
local b = BattleState.newWild(Game, "PIDGEY", 20)
|
||||
b.enemy.invulnerable = true -- mid-Fly/Dig, set by ChargeEffect on turn 1
|
||||
local hpBefore = b.enemy.mon.hp
|
||||
b:performMove(b.player, b.enemy, { id = moveId, pp = 5 })
|
||||
return b, hpBefore
|
||||
end
|
||||
|
||||
for _, moveId in ipairs({ "SELFDESTRUCT", "EXPLOSION" }) do
|
||||
local b, enemyHpBefore = selfDestructVsInvulnerable(moveId)
|
||||
eq(b.player.mon.hp, 0, moveId .. " vs an invulnerable target still faints the user")
|
||||
eq(b.enemy.mon.hp, enemyHpBefore,
|
||||
moveId .. " vs an invulnerable target deals the target no damage")
|
||||
local sawMiss = false
|
||||
for _, r in ipairs(b.queue) do
|
||||
if r.text and r.text:find("attack missed", 1, true) then sawMiss = true end
|
||||
end
|
||||
check(sawMiss, moveId .. " still prints the miss text against an invulnerable target")
|
||||
end
|
||||
|
||||
-- Companion: JUMP_KICK's onMiss must reject the "invulnerable" reason, so
|
||||
-- a mid-Fly target does not also take Jump Kick's crash damage (its
|
||||
-- onMiss guard only fires for reason == "accuracy").
|
||||
Game.save.party = { Pokemon.new(Data, "GEODUDE", 30) }
|
||||
local jk = BattleState.newWild(Game, "PIDGEY", 20)
|
||||
jk.enemy.invulnerable = true
|
||||
local jkHpBefore = jk.player.mon.hp
|
||||
jk:performMove(jk.player, jk.enemy, { id = "JUMP_KICK", pp = 5 })
|
||||
eq(jk.player.mon.hp, jkHpBefore,
|
||||
"JUMP KICK vs an invulnerable target takes no crash damage (reason filter holds)")
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,102 @@
|
||||
-- Parity test: the link fingerprint no longer hashes catchRate, so a
|
||||
-- Yellow-shaped dataset that differs from Red/Blue only at
|
||||
-- Dragonair/Dragonite's catch rate digests identical and the handshake
|
||||
-- reports "full" (#511).
|
||||
--
|
||||
-- Real cross-version link play (two processes, Red vs Yellow) is outside
|
||||
-- what this checkout can assert: it needs both a Red and a Yellow
|
||||
-- data/generated/ cache and two live UDP peers. What IS assertable from
|
||||
-- one process: (1) catchRate is excluded from the hashed species surface,
|
||||
-- so editing only that field never moves the digest, and (2) a synthetic
|
||||
-- Yellow stand-in built by copying the real Data and retuning just those
|
||||
-- two species' catchRate (the only real R/B vs Yellow link-surface delta,
|
||||
-- per data/pokemon/base_stats/dragonair.asm 45 vs 27 and dragonite.asm 45
|
||||
-- vs 9) fingerprints identically and clears Handshake.checkCompat as
|
||||
-- "full", not "subset".
|
||||
--
|
||||
-- A human still has to run the actual cross-version check: launch a Red
|
||||
-- instance and a Yellow instance (POKEPORT_VERSION=red / =yellow,
|
||||
-- distinct POKEPORT_IDENTITY sandboxes), host/join on localhost, and
|
||||
-- confirm no "game data is not the same" notice appears and a trade goes
|
||||
-- through both ways.
|
||||
--
|
||||
-- Self-contained; run via `luajit tests/parity_link_fingerprint_yellow.lua`.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
local S = require("tests.harness").suite("parity link fingerprint yellow")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local Data = require("src.core.Data")
|
||||
Data:load()
|
||||
|
||||
local Fingerprint = require("src.link.Fingerprint")
|
||||
local Handshake = require("src.link.Handshake")
|
||||
|
||||
local function copy(record)
|
||||
local out = {}
|
||||
for k, v in pairs(record) do out[k] = v end
|
||||
return out
|
||||
end
|
||||
|
||||
local function cloneData(base)
|
||||
local out = { pokemon = {}, moves = {}, type_chart = base.type_chart,
|
||||
constants = base.constants }
|
||||
for id, record in pairs(base.pokemon) do out.pokemon[id] = record end
|
||||
for id, record in pairs(base.moves) do out.moves[id] = record end
|
||||
return out
|
||||
end
|
||||
|
||||
-- (1) catchRate alone must not move the digest: this is the field
|
||||
-- allowlist change at src/link/Fingerprint.lua SPECIES_FIELDS.
|
||||
local vanilla = cloneData(Data)
|
||||
local first = Fingerprint.compute(vanilla, {})
|
||||
|
||||
local rateOnly = cloneData(Data)
|
||||
local pidgeyRate = copy(Data.pokemon.PIDGEY)
|
||||
pidgeyRate.catchRate = (pidgeyRate.catchRate or 0) + 1
|
||||
rateOnly.pokemon.PIDGEY = pidgeyRate
|
||||
eq(Fingerprint.compute(rateOnly, {}), first,
|
||||
"a catchRate-only edit does not move the link fingerprint")
|
||||
|
||||
-- (2) a Yellow stand-in: same records, Dragonair/Dragonite catch rate
|
||||
-- retuned to Yellow's values (the only real R/B vs Yellow link delta).
|
||||
Fingerprint.forget(vanilla)
|
||||
local yellowish = cloneData(Data)
|
||||
if Data.pokemon.DRAGONAIR then
|
||||
local dragonair = copy(Data.pokemon.DRAGONAIR)
|
||||
dragonair.catchRate = 27 -- pokeyellow data/pokemon/base_stats/dragonair.asm
|
||||
yellowish.pokemon.DRAGONAIR = dragonair
|
||||
end
|
||||
if Data.pokemon.DRAGONITE then
|
||||
local dragonite = copy(Data.pokemon.DRAGONITE)
|
||||
dragonite.catchRate = 9 -- pokeyellow data/pokemon/base_stats/dragonite.asm
|
||||
yellowish.pokemon.DRAGONITE = dragonite
|
||||
end
|
||||
check(Data.pokemon.DRAGONAIR ~= nil and Data.pokemon.DRAGONITE ~= nil,
|
||||
"fixture has Dragonair and Dragonite to retune")
|
||||
local yellowPrint = Fingerprint.compute(yellowish, {})
|
||||
eq(yellowPrint, first,
|
||||
"a Yellow-shaped dataset (only Dragonair/Dragonite catch rate differs) " ..
|
||||
"fingerprints identically to Red/Blue")
|
||||
|
||||
-- (3) the handshake actually reads "full" for that pairing, not "subset"
|
||||
local redHello = { protocol = 2, engineVersion = "0.1.0", fingerprint = first }
|
||||
local yellowHello = { protocol = 2, engineVersion = "0.1.0", fingerprint = yellowPrint }
|
||||
local verdict, reason = Handshake.checkCompat(redHello, yellowHello)
|
||||
eq(verdict, "full", "Red vs Yellow-shaped peer clears the handshake as full")
|
||||
check(reason == nil, "a full verdict carries no mismatch reason")
|
||||
|
||||
-- control: a real gameplay-affecting edit (base stats) still splits the
|
||||
-- two builds, so the fingerprint has not gone toothless
|
||||
local buffed = cloneData(Data)
|
||||
local strongPidgey = copy(Data.pokemon.PIDGEY)
|
||||
strongPidgey.baseStats = copy(Data.pokemon.PIDGEY.baseStats)
|
||||
strongPidgey.baseStats.attack = strongPidgey.baseStats.attack + 1
|
||||
buffed.pokemon.PIDGEY = strongPidgey
|
||||
local buffedPrint = Fingerprint.compute(buffed, {})
|
||||
check(buffedPrint ~= first, "a baseStats edit still moves the digest")
|
||||
local buffedHello = { protocol = 2, engineVersion = "0.1.0", fingerprint = buffedPrint }
|
||||
local verdict2 = Handshake.checkCompat(redHello, buffedHello)
|
||||
eq(verdict2, "subset", "a genuinely different dataset still reads subset, not full")
|
||||
|
||||
S.finish()
|
||||
@@ -6,17 +6,25 @@
|
||||
-- is nonzero ("the player sprite has not yet completed the walking
|
||||
-- animation"), jumps straight to .moveAhead -- JoypadOverworld, and with
|
||||
-- it the START check, the A check, and every direction initiation, only
|
||||
-- ever runs while the player stands on a tile. A button pressed mid-step
|
||||
-- is simply never seen.
|
||||
-- ever runs while the player stands on a tile.
|
||||
--
|
||||
-- The port ran handleInput() every frame regardless of player.moving, so a
|
||||
-- mid-step A/START press pushed its TextBox/StartMenu right there and
|
||||
-- froze Red between tiles, mid-animation (#286: running up to Nurse Joy
|
||||
-- and mashing A stops him half off the tile).
|
||||
--
|
||||
-- Second oracle, engine/joypad.asm _Joypad: hJoyPressed is
|
||||
-- (hJoyLast ^ hJoyInput) & hJoyInput, and hJoyLast only advances on an
|
||||
-- explicit `call Joypad`. vblank's per-frame ReadJoypad writes hJoyInput
|
||||
-- alone, and the mid-step path never calls Joypad, so hJoyLast is FROZEN
|
||||
-- for the whole animation. A button pressed mid-step and still held when
|
||||
-- the step lands therefore reads as a fresh press at the next poll; one
|
||||
-- released before the step lands is genuinely lost. The port used to drop
|
||||
-- both, which on the Cycling Road roll made START a coin flip (#525).
|
||||
--
|
||||
-- The invariant: while a step is in progress, A and START change nothing
|
||||
-- (no TextBox, no StartMenu, the step completes); once the player stands
|
||||
-- on the tile again, both work.
|
||||
-- (no TextBox, no StartMenu, the step completes). On the landing frame a
|
||||
-- still-held A or START is acted on, a released one is not.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
@@ -52,6 +60,15 @@ local function step(pressedBtn)
|
||||
ow:update(1 / 60)
|
||||
end
|
||||
|
||||
-- A synthetic pressQueue inject has no source entry, so Input:step sets
|
||||
-- state[btn] = true and nothing ever clears it (src/core/Input.lua) -- the
|
||||
-- harness models a HELD button. Most cases below want a tap, so release it
|
||||
-- explicitly; the held cases are called out where they matter.
|
||||
local function tap(btn)
|
||||
step(btn)
|
||||
Input.state[btn] = false
|
||||
end
|
||||
|
||||
-- start a step south (held direction, like hJoyHeld)
|
||||
Input.state.down = true
|
||||
step()
|
||||
@@ -67,14 +84,14 @@ ow.interact = function(self, ...)
|
||||
return baseInteract(self, ...)
|
||||
end
|
||||
|
||||
-- mid-step A press: nothing may happen (the original never sees it)
|
||||
step("a")
|
||||
-- mid-step A press: nothing may happen (the original acts on nothing here)
|
||||
tap("a")
|
||||
eq(interactCalls, 0, "mid-step A never reaches interact()")
|
||||
check(Game.stack:top() == ow, "mid-step A pushes no TextBox")
|
||||
check(ow.player.moving, "mid-step A does not interrupt the step")
|
||||
|
||||
-- mid-step START press: no start menu either
|
||||
step("start")
|
||||
tap("start")
|
||||
check(Game.stack:top() == ow, "mid-step START opens no menu")
|
||||
check(ow.player.moving, "mid-step START does not interrupt the step")
|
||||
|
||||
@@ -84,7 +101,10 @@ while ow.player.moving and guard < 60 do step(); guard = guard + 1 end
|
||||
eq(ow.player.cellY, startY + 1, "the step completes onto the next tile")
|
||||
|
||||
-- the issue's actual repro ("press A quickly/early" running up to Nurse
|
||||
-- Joy): start another step and press A on its FINAL mid-step frame
|
||||
-- Joy): start another step and press A on its FINAL mid-step frame, then
|
||||
-- RELEASE it before the step lands. hJoyLast is frozen through the
|
||||
-- animation, so the next poll sees the button already up and computes no
|
||||
-- edge (engine/joypad.asm) -- this press really is lost.
|
||||
Input.state.down = true
|
||||
step()
|
||||
Input.state.down = false
|
||||
@@ -93,17 +113,36 @@ guard = 0
|
||||
while ow.player.moving and guard < 60 do
|
||||
guard = guard + 1
|
||||
if guard == (ow.player.stepFramesCur or 16) - 1 then
|
||||
step("a") -- the last frame before landing
|
||||
tap("a") -- the last frame before landing, released immediately
|
||||
else
|
||||
step()
|
||||
end
|
||||
end
|
||||
check(not ow.player.moving, "the second step completes")
|
||||
eq(interactCalls, 0, "a last-frame A press is still swallowed (no buffering)")
|
||||
check(Game.stack:top() == ow, "last-frame A pushes no TextBox")
|
||||
step() -- the landing frame, where a still-held button would be polled
|
||||
eq(interactCalls, 0, "a mid-step A released before landing is still lost")
|
||||
check(Game.stack:top() == ow, "the released last-frame A pushes no TextBox")
|
||||
|
||||
-- ...but a mid-step A that is STILL HELD when the step lands is delivered
|
||||
-- on the landing frame, because hJoyLast never advanced (#525). Nothing
|
||||
-- happens mid-step either way: the poll is deferred, not the action.
|
||||
Input.state.down = true
|
||||
step()
|
||||
Input.state.down = false
|
||||
check(ow.player.moving, "third step starts")
|
||||
step("a") -- pressed mid-step and left held
|
||||
eq(interactCalls, 0, "the held A still does nothing mid-step")
|
||||
check(ow.player.moving, "the held A does not interrupt the step")
|
||||
guard = 0
|
||||
while ow.player.moving and guard < 60 do step(); guard = guard + 1 end
|
||||
eq(interactCalls, 0, "still nothing while the step runs out")
|
||||
step() -- landing frame
|
||||
eq(interactCalls, 1, "a held mid-step A is polled on the landing frame")
|
||||
Input.state.a = false
|
||||
|
||||
-- standing on the tile again, START and A work as always
|
||||
step("start")
|
||||
interactCalls = 0
|
||||
tap("start")
|
||||
check(Game.stack:top() ~= ow, "START opens the start menu on a tile")
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
Game.stack:push(OW, "PALLET_TOWN", 6, 9, "down")
|
||||
|
||||
@@ -176,7 +176,14 @@ for _, key in ipairs({ "_SilphCo11FSilphPresidentText",
|
||||
end
|
||||
|
||||
-- run the rows through the real interpreter with only the leaf commands
|
||||
-- stubbed, so the branch arithmetic is what is under test
|
||||
-- stubbed, so the branch arithmetic is what is under test. Commands is a
|
||||
-- shared module singleton read by every later suite dofile'd into this
|
||||
-- same process (tests/run_tests.lua), so the three stubs must be restored
|
||||
-- before this file falls off the end -- an unrestored give_item stub
|
||||
-- silently swallows any later test's item grants, matching the save/
|
||||
-- restore parity_gift_atomicity.lua already does for show_text.
|
||||
local origFacePlayer, origShowText, origGiveItem =
|
||||
Commands.face_player, Commands.show_text, Commands.give_item
|
||||
local shown, given
|
||||
Commands.face_player = function() end
|
||||
Commands.show_text = function(_, key) shown[#shown + 1] = key end
|
||||
@@ -214,4 +221,7 @@ do
|
||||
"an unbeaten-Giovanni save still gets the thank-you and the ball")
|
||||
end
|
||||
|
||||
Commands.face_player, Commands.show_text, Commands.give_item =
|
||||
origFacePlayer, origShowText, origGiveItem
|
||||
|
||||
S.finish()
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
-- Parity test: all three fishing rods are refused while surfing (#533).
|
||||
--
|
||||
-- pokered's FishingInit (engine/items/item_effects.asm) reads
|
||||
-- wWalkBikeSurfState after IsNextTileShoreOrWater passes, and refuses
|
||||
-- with carry set when it equals 2 (surfing). Every ItemUseXRod entry does
|
||||
-- `jp c, ItemUseNotTime` on that carry, so a surfing player gets the same
|
||||
-- "OAK: %s! This isn't the time to use that!" text used for the
|
||||
-- mid-battle refusal, and the rod is never consumed. The port's ow.player
|
||||
-- surf flag was never checked, so BagMenu's isWaterCell check alone let a
|
||||
-- surfing player fish (the faced cell while surfing is always water).
|
||||
--
|
||||
-- Self-contained; run via `luajit tests/parity_surf_rod_refusal.lua`.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
local S = require("tests.harness").suite("parity surf rod refusal")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local Data = require("src.core.Data")
|
||||
Data:load()
|
||||
|
||||
local ItemEffects = require("src.inventory.ItemEffects")
|
||||
local save = require("src.core.SaveData").newGame()
|
||||
|
||||
for _, rod in ipairs({ "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }) do
|
||||
-- surfing: refused even though ow/player exists and the faced cell
|
||||
-- would otherwise be fishable water
|
||||
local surfingOw = { player = { surfing = true } }
|
||||
local result, msgs = ItemEffects.use(Data, save, rod, nil, false, nil, surfingOw)
|
||||
eq(result, "failed", rod .. " is refused while surfing")
|
||||
check(msgs and msgs[1] and msgs[1]:find("isn't the", 1, true) ~= nil,
|
||||
rod .. " surfing refusal uses the OAK 'not the time' text")
|
||||
eq(save.inventory[rod], nil, rod .. " is not consumed while surfing")
|
||||
|
||||
-- not surfing: still allowed to fish
|
||||
local groundOw = { player = { surfing = false } }
|
||||
local r2 = ItemEffects.use(Data, save, rod, nil, false, nil, groundOw)
|
||||
eq(r2, "fish", rod .. " still fishes normally when not surfing")
|
||||
|
||||
-- no overworld handle at all (e.g. called from a context without ow):
|
||||
-- must not error, and must not silently allow fishing that a surfing
|
||||
-- player would be refused
|
||||
local r3 = ItemEffects.use(Data, save, rod, nil, false, nil, nil)
|
||||
eq(r3, "fish", rod .. " with no ow handle falls through to fish (no surf info available)")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,93 @@
|
||||
-- Regression (#536): a save made mid-surf must resume mid-surf.
|
||||
--
|
||||
-- wWalkBikeSurfState (ram/wram.asm) sits inside wMainDataStart..wMainDataEnd,
|
||||
-- the range engine/menus/save.asm block-copies into sMainData on save and
|
||||
-- back out on load, so the original persists the surf state across a save.
|
||||
-- OverworldState:captureSave now writes save.player.surfing and
|
||||
-- OverworldState:setMap's boot path (opts.via == "boot", only taken from
|
||||
-- :enter, which every StateStack push runs through) restores it before
|
||||
-- Music/PikachuFollower see the player. Before the fix, self.player.surfing
|
||||
-- was never serialized at all, so a reload always came back on foot;
|
||||
-- Collision.canMove (src/world/Collision.lua) then picks land tile-pairs for
|
||||
-- a non-surfing mover, which never permit standing on a water cell, so the
|
||||
-- reload could softlock on the water tile the save was made on.
|
||||
--
|
||||
-- Self-contained; run via `luajit tests/parity_surf_save_load_bug536.lua`.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local Data = require("src.core.Data")
|
||||
if not (Data.maps and Data.maps.ROUTE_20) then Data:load() end
|
||||
|
||||
local Game = require("src.core.Game")
|
||||
local Input = require("src.core.Input")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local OW = require("src.world.OverworldController")
|
||||
local S = require("tests.harness").suite("parity surf save/load (#536)")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
Game.data = Data
|
||||
Game.input = Input; Input:init()
|
||||
Game.renderer = Renderer; Renderer:init()
|
||||
Game.stack = StateStack
|
||||
StateStack:init()
|
||||
|
||||
-- ground truth from parity_cinnabar_east_surf.lua: ROUTE_20 (0,8) is water.
|
||||
local r20 = require("src.world.MapLoader").load(Data, "ROUTE_20")
|
||||
check(r20:isWaterCell(0, 8), "ROUTE_20 (0,8) is water")
|
||||
|
||||
-- --- a save made mid-surf on a water cell resumes surfing --------------
|
||||
Game.save = SaveData.newGame()
|
||||
Game.save.player.surfing = true
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
Game.stack:push(OW, "ROUTE_20", 0, 8, "left")
|
||||
local ow = Game.stack:top()
|
||||
eq(ow.player.surfing, true, "boot onto a water cell with surfing=true restores surfing")
|
||||
-- sprite/movement mode: Player:pose() (src/world/Player.lua) only ever
|
||||
-- selects surfSprite when self.surfing is truthy, so this is the same
|
||||
-- switch that put the walking sheet back on a surfing player pre-fix
|
||||
local sprite = ow.player:pose()
|
||||
check(sprite == ow.player.surfSprite,
|
||||
"restored surfing selects the surf sprite sheet, not the walking one")
|
||||
|
||||
-- --- a save made on foot on land resumes on foot ------------------------
|
||||
Game.save = SaveData.newGame()
|
||||
Game.save.player.surfing = false
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
Game.stack:push(OW, "PALLET_TOWN", 5, 6, "down")
|
||||
ow = Game.stack:top()
|
||||
eq(ow.player.surfing, false, "boot onto land with surfing=false stays on foot")
|
||||
|
||||
-- --- saves written before #536 (no surfing field at all) default safely -
|
||||
Game.save = SaveData.newGame()
|
||||
Game.save.player.surfing = nil
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
Game.stack:push(OW, "PALLET_TOWN", 5, 6, "down")
|
||||
ow = Game.stack:top()
|
||||
check(not ow.player.surfing, "a pre-#536 save with no surfing field boots on foot, not truthy-nil")
|
||||
|
||||
-- --- the true round trip: captureSave -> a fresh boot reads it back back ---
|
||||
Game.save = SaveData.newGame()
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
Game.stack:push(OW, "ROUTE_20", 0, 8, "left")
|
||||
ow = Game.stack:top()
|
||||
ow.player.surfing = true -- player mounted SURF while already standing on water
|
||||
local captured = SaveData.newGame()
|
||||
ow:captureSave(captured)
|
||||
eq(captured.player.map, "ROUTE_20", "captureSave records the map")
|
||||
eq(captured.player.x, 0, "captureSave records x")
|
||||
eq(captured.player.y, 8, "captureSave records y")
|
||||
eq(captured.player.surfing, true, "captureSave records surfing=true")
|
||||
|
||||
-- reload from that captured save: a brand-new boot must come back surfing
|
||||
Game.save = captured
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
Game.stack:push(OW, captured.player.map, captured.player.x, captured.player.y,
|
||||
captured.player.facing)
|
||||
ow = Game.stack:top()
|
||||
eq(ow.player.surfing, true,
|
||||
"round trip: captureSave -> reboot restores surfing on the same water cell")
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,171 @@
|
||||
-- Regression (#503): the Viridian School House blackboard and notebook
|
||||
-- were both dead A presses -- data/events/hidden_events.asm's two
|
||||
-- hidden_text_predef rows for this map never reached data/generated/
|
||||
-- field.lua (tools/extract/field.py only parses `hidden_event` rows), and
|
||||
-- there was no onInteract hook for this map at all. Same shape as the
|
||||
-- Celadon Mansion roof house fix (#391, tests/parity_celadon_roof_
|
||||
-- readables.lua): ViridianSchoolBlackboard (engine/events/hidden_events/
|
||||
-- school_blackboard.asm) prints the intro, then loops a "which heading"
|
||||
-- prompt and a 5-status menu (SLP/PSN/PAR/BRN/FRZ) plus QUIT until B/QUIT
|
||||
-- closes it; ViridianSchoolNotebook (school_notebooks.asm) is a 5-page
|
||||
-- book that asks to turn pages 1-3, auto-turns 4, and the girl catches you
|
||||
-- on page 5.
|
||||
--
|
||||
-- Self-contained; run via
|
||||
-- `luajit tests/parity_viridian_school_blackboard_bug503.lua`.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
local Data = require("src.core.Data")
|
||||
if not (Data.maps and Data.maps.VIRIDIAN_SCHOOL_HOUSE) then Data:load() end
|
||||
local Font = require("src.render.Font")
|
||||
if not pcall(Font.encode, "A") then Font.load(Data) end
|
||||
require("data.scripts.init")
|
||||
local MapScripts = require("src.script.MapScripts")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Menu = require("src.ui.Menu")
|
||||
local S = require("tests.harness").suite("parity Viridian School blackboard/notebook (#503)")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local MAP = "VIRIDIAN_SCHOOL_HOUSE"
|
||||
|
||||
for _, key in ipairs({
|
||||
"_ViridianSchoolBlackboardText1", "_ViridianSchoolBlackboardText2",
|
||||
"_ViridianBlackboardSleepText", "_ViridianBlackboardPoisonText",
|
||||
"_ViridianBlackboardPrlzText", "_ViridianBlackboardBurnText",
|
||||
"_ViridianBlackboardFrozenText",
|
||||
"_ViridianSchoolNotebookText1", "_ViridianSchoolNotebookText5",
|
||||
"_TurnPageText" }) do
|
||||
check(type(Data.text[key]) == "string" and Data.text[key] ~= "",
|
||||
key .. " is extracted")
|
||||
end
|
||||
|
||||
local hooks = MapScripts.get(MAP)
|
||||
check(hooks and type(hooks.onInteract) == "function",
|
||||
MAP .. " registers onInteract for the two readables")
|
||||
|
||||
local stack = {}
|
||||
local game = {
|
||||
data = Data,
|
||||
save = { player = { name = "RED" } },
|
||||
stack = {
|
||||
push = function(_, state) stack[#stack + 1] = state end,
|
||||
pop = function() return table.remove(stack) end,
|
||||
top = function() return stack[#stack] end,
|
||||
},
|
||||
}
|
||||
local ow = { player = { facing = "up" } }
|
||||
|
||||
local function pages(box)
|
||||
local out = {}
|
||||
for _, page in ipairs(box.pages or {}) do
|
||||
for _, line in ipairs(page) do out[#out + 1] = line end
|
||||
end
|
||||
return table.concat(out, "\n")
|
||||
end
|
||||
|
||||
-- cells that are neither the blackboard (3,0) nor the notebook (3,4) stay silent
|
||||
for _, cell in ipairs({ { 4, 0 }, { 3, 1 }, { 4, 4 }, { 3, 3 }, { 2, 0 } }) do
|
||||
eq(hooks.onInteract(game, ow, cell[1], cell[2]), false,
|
||||
("(%d,%d) is not one of the readables"):format(cell[1], cell[2]))
|
||||
end
|
||||
|
||||
-- === the blackboard: intro -> prompt -> 6-item menu (5 statuses + QUIT) ===
|
||||
stack = {}
|
||||
eq(hooks.onInteract(game, ow, 3, 0), true, "the blackboard at (3,0) is claimed")
|
||||
local intro = stack[#stack]
|
||||
check(getmetatable(intro) == TextBox, "the blackboard opens a TextBox")
|
||||
check(pages(intro):find("STATUS", 1, true) ~= nil,
|
||||
"the intro is _ViridianSchoolBlackboardText1 (describes STATUS changes)")
|
||||
check(type(intro.onDone) == "function", "the intro has a continuation")
|
||||
intro.onDone()
|
||||
|
||||
local prompt = stack[#stack]
|
||||
check(getmetatable(prompt) == TextBox
|
||||
and pages(prompt):find("heading", 1, true) ~= nil,
|
||||
"the intro leads into the which-heading prompt")
|
||||
prompt.onDone()
|
||||
|
||||
local menu = stack[#stack]
|
||||
check(getmetatable(menu) == Menu, "the prompt opens the status menu")
|
||||
eq(#menu.items, 6, "five statuses plus QUIT")
|
||||
local labels = {}
|
||||
for i, item in ipairs(menu.items) do labels[i] = (item.label or ""):gsub("^%s+", "") end
|
||||
eq(table.concat(labels, "/"), "SLP/PSN/PAR/BRN/FRZ/QUIT",
|
||||
"the statuses read SLP/PSN/PAR/BRN/FRZ, QUIT last")
|
||||
eq(menu.items[6].onSelect, nil, "QUIT has no onSelect; Menu's own B/pop closes it")
|
||||
|
||||
local STATUS_KEYS = {
|
||||
"_ViridianBlackboardSleepText", "_ViridianBlackboardPoisonText",
|
||||
"_ViridianBlackboardPrlzText", "_ViridianBlackboardBurnText",
|
||||
"_ViridianBlackboardFrozenText",
|
||||
}
|
||||
for i, key in ipairs(STATUS_KEYS) do
|
||||
stack = {}
|
||||
menu.items[i].onSelect()
|
||||
local blurb = stack[#stack]
|
||||
check(getmetatable(blurb) == TextBox, labels[i] .. " prints a text box")
|
||||
local want = Data.text[key]:match("^[^\n\011\012]+")
|
||||
check(pages(blurb):find(want, 1, true) ~= nil,
|
||||
labels[i] .. " prints " .. key)
|
||||
check(type(blurb.onDone) == "function",
|
||||
labels[i] .. " returns to the prompt instead of dropping out (loops)")
|
||||
blurb.onDone()
|
||||
check(getmetatable(stack[#stack]) == TextBox,
|
||||
"the prompt comes back after " .. labels[i])
|
||||
stack[#stack].onDone()
|
||||
check(getmetatable(stack[#stack]) == Menu,
|
||||
"the menu comes back after " .. labels[i] .. " (loop, not exit)")
|
||||
end
|
||||
|
||||
-- === the notebook: five pages, three yes/no turns, then the girl catches you ==
|
||||
stack = {}
|
||||
eq(hooks.onInteract(game, ow, 3, 4), true, "the notebook at (3,4) is claimed")
|
||||
local page1 = stack[#stack]
|
||||
check(getmetatable(page1) == TextBox, "the notebook opens on page 1")
|
||||
check(pages(page1):find("POKé BALL", 1, true) ~= nil,
|
||||
"page 1 is _ViridianSchoolNotebookText1")
|
||||
|
||||
-- pages 1-3 ask "Turn the page?" and only turn on yes. The real ChoiceBox
|
||||
-- pops the asking TextBox itself before invoking .choice (TextBox.lua:228-
|
||||
-- 235); mirror that pop-then-call order here.
|
||||
local function resolveChoice(box, yes)
|
||||
table.remove(stack) -- the ChoiceBox pops the asking TextBox first
|
||||
box.choice(yes)
|
||||
end
|
||||
|
||||
page1.onDone()
|
||||
local ask1 = stack[#stack]
|
||||
check(getmetatable(ask1) == TextBox and ask1.choice ~= nil,
|
||||
"page 1 asks _TurnPageText with a yes/no choice")
|
||||
resolveChoice(ask1, true)
|
||||
local page2 = stack[#stack]
|
||||
check(pages(page2):find("weaken", 1, true) ~= nil, "yes turns to page 2")
|
||||
|
||||
page2.onDone()
|
||||
resolveChoice(stack[#stack], true)
|
||||
local page3 = stack[#stack]
|
||||
check(pages(page3):find("engage", 1, true) ~= nil, "page 3 follows page 2")
|
||||
|
||||
page3.onDone()
|
||||
resolveChoice(stack[#stack], true)
|
||||
local page4 = stack[#stack]
|
||||
check(pages(page4):find("ELITE FOUR", 1, true) ~= nil, "page 4 follows page 3")
|
||||
|
||||
-- page 4 turns automatically into page 5, no prompt
|
||||
page4.onDone()
|
||||
local page5 = stack[#stack]
|
||||
check(getmetatable(page5) == TextBox, "page 4 auto-turns")
|
||||
check(pages(page5):find("GIRL", 1, true) ~= nil,
|
||||
"page 5 is the girl catching you reading her notes")
|
||||
|
||||
-- saying no on page 1 stops the read right there: no further page pushed
|
||||
stack = {}
|
||||
hooks.onInteract(game, ow, 3, 4)
|
||||
stack[#stack].onDone()
|
||||
local askAgain = stack[#stack]
|
||||
local before = #stack
|
||||
resolveChoice(askAgain, false)
|
||||
eq(#stack, before - 1,
|
||||
"saying no to the first turn pops the ask box and pushes nothing else")
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,97 @@
|
||||
-- Regression (#535): after handing over the GOLD TEETH and receiving
|
||||
-- HM04, every later talk to the Warden must still say something.
|
||||
--
|
||||
-- data/scripts/story.lua's TEXT_WARDENSHOUSE_WARDEN pointed the
|
||||
-- EVENT_GOT_HM04 branch (row 3, jump_if_true) at the same silent-end jump
|
||||
-- the give-then-thank fallthrough uses (row 13), so ScriptRunner's pc ran
|
||||
-- straight past the end of the row list with zero show_text calls -- the
|
||||
-- Warden went mute on every visit after the trade. pokered's .got_item
|
||||
-- branch (scripts/WardensHouse.asm) instead prints .HM04ExplanationText
|
||||
-- (text/WardensHouse.asm: "HM04 teaches STRENGTH ... SECRET HOUSE in
|
||||
-- SAFARI ZONE") on every subsequent talk.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
local Data = require("src.core.Data")
|
||||
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
|
||||
|
||||
local S = require("tests.harness").suite("parity wardens house (#535)")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local Commands = require("src.script.Commands")
|
||||
local Flags = require("src.script.Flags")
|
||||
local Game = require("src.core.Game")
|
||||
local Input = require("src.core.Input")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local ScriptRunner = require("src.script.ScriptRunner")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
|
||||
Game.data = Data
|
||||
Game.input = Input; Input:init()
|
||||
Game.stack = StateStack; StateStack:init()
|
||||
require("src.render.Font").load(Data)
|
||||
|
||||
local story = require("data.scripts.story")
|
||||
local script = story.WARDENS_HOUSE.talk.TEXT_WARDENSHOUSE_WARDEN
|
||||
|
||||
-- instrument show_text the way parity_gift_atomicity.lua does, to record
|
||||
-- exactly which text ids actually printed
|
||||
local shown = {}
|
||||
local origShow = Commands.show_text
|
||||
Commands.show_text = function(ctx, textId, subs)
|
||||
shown[#shown + 1] = textId
|
||||
return origShow(ctx, textId, subs)
|
||||
end
|
||||
|
||||
local function runScript()
|
||||
shown = {}
|
||||
StateStack:init()
|
||||
local ow = { map = { id = "WARDENS_HOUSE", def = { label = "WardensHouse" } },
|
||||
npcs = {}, entities = {} }
|
||||
local r = ScriptRunner.new(Game, ow)
|
||||
r:run(script, { npc = { def = {}, facePlayer = function() end },
|
||||
overworld = ow })
|
||||
local guard = 0
|
||||
while r:isRunning() and guard < 3000 do
|
||||
guard = guard + 1
|
||||
Input.pressed = { a = true }
|
||||
StateStack:update(1 / 60)
|
||||
r:update()
|
||||
end
|
||||
Input.pressed = {}
|
||||
return not r:isRunning()
|
||||
end
|
||||
|
||||
-- === 1) first talk, holding the GOLD TEETH: gives HM04, sets the flag ===
|
||||
Game.save = SaveData.newGame()
|
||||
Game.save.inventory.GOLD_TEETH = 1
|
||||
check(runScript(), "give-the-teeth talk completes")
|
||||
eq(table.concat(shown, ","),
|
||||
"_WardensHouseWardenGaveTheGoldTeethText,_WardensHouseWardenThanksText,"
|
||||
.. "_WardensHouseWardenReceivedHM04Text",
|
||||
"handing over the teeth shows the give/thanks/received sequence, nothing after")
|
||||
check(Flags.get(Game.save, "EVENT_GOT_HM04"), "EVENT_GOT_HM04 is set")
|
||||
check(Flags.get(Game.save, "EVENT_GAVE_GOLD_TEETH"), "EVENT_GAVE_GOLD_TEETH is set")
|
||||
check(Game.save.inventory.HM_STRENGTH ~= nil, "HM04 (Strength) lands in the bag")
|
||||
check(not Game.save.inventory.GOLD_TEETH, "the GOLD TEETH is taken")
|
||||
|
||||
-- === 2) the regression itself: every later talk, once EVENT_GOT_HM04 is
|
||||
-- set, must print the explanation text instead of nothing ===
|
||||
check(runScript(), "post-gift talk completes")
|
||||
eq(table.concat(shown, ","), "_WardensHouseWardenHM04ExplanationText",
|
||||
"every subsequent talk now prints the HM04/Safari Zone explanation (#535)")
|
||||
|
||||
-- run it again to confirm this is not a one-shot: it repeats every visit
|
||||
check(runScript(), "a third talk completes")
|
||||
eq(table.concat(shown, ","), "_WardensHouseWardenHM04ExplanationText",
|
||||
"the explanation text repeats on every later talk, not just the first")
|
||||
|
||||
-- === 3) unrelated path is unchanged: no GOLD TEETH, no flag yet ===
|
||||
Game.save = SaveData.newGame()
|
||||
check(runScript(), "empty-handed talk completes")
|
||||
eq(table.concat(shown, ","), "_WardensHouseWardenGibberish1Text",
|
||||
"without the GOLD TEETH the Warden's gibberish line is unchanged")
|
||||
check(not Flags.get(Game.save, "EVENT_GOT_HM04"), "no HM04 yet")
|
||||
|
||||
Commands.show_text = origShow
|
||||
|
||||
S.finish()
|
||||
@@ -528,5 +528,105 @@ do
|
||||
end
|
||||
end
|
||||
|
||||
do
|
||||
-- #515: badge read/write agreement between SaveData/the in-game grant
|
||||
-- and the editor. checkVictoryRewards (src/world/OverworldController.lua)
|
||||
-- writes save.inventory[badge] = 1, a truthy number, not the boolean
|
||||
-- `true` the editor used to compare against with `== true`. This drives
|
||||
-- the same field through a real save encode/decode round trip and checks
|
||||
-- src/inventory/Badges.count (what the in-game badge case/count reads)
|
||||
-- agrees with what the editor's own badge state shows.
|
||||
local Badges = require("src.inventory.Badges")
|
||||
|
||||
local save = SaveData.newGame()
|
||||
local id = Badges.list(Data)[1].id
|
||||
-- simulate the in-game grant's exact representation, not the editor's
|
||||
save.inventory[id] = 1
|
||||
eq(Badges.count(Data, save), 1, "Badges.count sees a numeric 1 grant as earned")
|
||||
|
||||
local encoded = SaveData.encode(save)
|
||||
local back = SaveData.decode(encoded)
|
||||
eq(back.inventory[id], 1, "save round trip preserves the numeric badge flag")
|
||||
eq(Badges.count(Data, back), 1, "Badges.count still agrees after the round trip")
|
||||
|
||||
local S = State.new()
|
||||
S.data = Data
|
||||
S.cat = Catalog.build(Data)
|
||||
S.save = back
|
||||
check(Ops.badgeIds(S)[1] ~= nil, "the editor's badge catalog is non-empty")
|
||||
check(S.save.inventory[id] and true or false,
|
||||
"the editor's own truthy read (panel badge chip state) sees the grant as earned")
|
||||
|
||||
-- toggling off then on again round-trips through the editor's own write
|
||||
-- shape and still agrees with Badges.count
|
||||
Ops.toggleBadge(S, id)
|
||||
eq(S.save.inventory[id], nil, "toggleBadge clears the badge (nil, not false)")
|
||||
eq(Badges.count(Data, S.save), 0, "Badges.count agrees once cleared")
|
||||
Ops.toggleBadge(S, id)
|
||||
eq(S.save.inventory[id], 1, "toggleBadge re-earns the badge as a truthy 1, matching the in-game grant")
|
||||
eq(Badges.count(Data, S.save), 1, "Badges.count agrees once re-earned")
|
||||
end
|
||||
|
||||
do
|
||||
-- #529: focusing a text field raises the OS soft keyboard on Android/iOS
|
||||
-- (love.keyboard.setTextInput(true, x, y, w, h)) and blurring lowers it;
|
||||
-- desktop raises the same way but never lowers, since setTextInput is
|
||||
-- global SDL state and the launcher's own text fields (RomImporter slot
|
||||
-- rename, ROM finder) depend on it staying enabled.
|
||||
--
|
||||
-- This is the closest honest check this checkout can run: the real soft
|
||||
-- keyboard is OS chrome outside the LOVE frame, unreachable by any
|
||||
-- driver. A human still has to verify on an Android build that the
|
||||
-- keyboard visibly rises over the Items search bar, typed characters
|
||||
-- filter the list, and Enter/Escape/switching tabs lowers it again.
|
||||
local Kit = require("Kit")
|
||||
local calls = {}
|
||||
local savedKeyboard, savedSystem = love.keyboard, love.system
|
||||
|
||||
local function stubOS(name)
|
||||
love.system = { getOS = function() return name end }
|
||||
love.keyboard = {
|
||||
isDown = function() return false end,
|
||||
setTextInput = function(...) calls[#calls + 1] = { ... } end,
|
||||
}
|
||||
end
|
||||
|
||||
-- Android: focusing raises with the field's rect, restaying focused on
|
||||
-- the same id does not re-raise, and blur lowers it.
|
||||
stubOS("Android")
|
||||
Kit.focus = nil
|
||||
Kit.beginFrame(15, 15, true)
|
||||
Kit.textfield("kb-test", 10, 10, 100, 20, "", "type here")
|
||||
eq(#calls, 1, "Android: focusing a field raises the soft keyboard")
|
||||
check(calls[1][1] == true, "Android: raise call passes enable=true")
|
||||
eq(calls[1][2], 10, "Android: raise call passes the field's x")
|
||||
eq(calls[1][3], 10, "Android: raise call passes the field's y")
|
||||
eq(calls[1][4], 100, "Android: raise call passes the field's w")
|
||||
eq(calls[1][5], 20, "Android: raise call passes the field's h")
|
||||
|
||||
Kit.beginFrame(15, 15, false)
|
||||
Kit.textfield("kb-test", 10, 10, 100, 20, "abc", "type here")
|
||||
eq(#calls, 1, "Android: staying focused on the same field does not re-raise")
|
||||
|
||||
Kit.blur()
|
||||
eq(#calls, 2, "Android: blur lowers the soft keyboard")
|
||||
eq(calls[2][1], false, "Android: lower call passes enable=false")
|
||||
check(Kit.focus == nil, "blur clears Kit.focus")
|
||||
|
||||
-- Desktop: focusing still raises (harmless there), but blur must not
|
||||
-- disable text input globally -- the launcher's own fields rely on it
|
||||
-- staying on.
|
||||
calls = {}
|
||||
stubOS("Mac OS X")
|
||||
Kit.beginFrame(65, 65, true)
|
||||
Kit.textfield("kb-test2", 60, 60, 80, 24, "", "")
|
||||
eq(#calls, 1, "desktop: focusing a field still raises setTextInput")
|
||||
Kit.blur()
|
||||
eq(#calls, 1, "desktop: blur does not call setTextInput(false)")
|
||||
|
||||
love.keyboard, love.system = savedKeyboard, savedSystem
|
||||
Kit.focus = nil
|
||||
end
|
||||
|
||||
print(string.format("save editor tests: %d passed, %d failed", passed, failed))
|
||||
if failed > 0 then os.exit(1) end
|
||||
|
||||
@@ -230,13 +230,14 @@ do
|
||||
end
|
||||
|
||||
do
|
||||
-- badges are boolean flags on inventory, toggled not stacked
|
||||
-- badges are truthy flags on inventory, toggled not stacked; the engine
|
||||
-- writes them as 1 (#515), so the editor must too
|
||||
local S = newState()
|
||||
local ids = Ops.badgeIds(S)
|
||||
check(#ids > 0, "the catalog exposes badge ids")
|
||||
local id = ids[1]
|
||||
Ops.toggleBadge(S, id)
|
||||
eq(S.save.inventory[id], true, "toggleBadge earns the badge")
|
||||
eq(S.save.inventory[id], 1, "toggleBadge earns the badge")
|
||||
Ops.toggleBadge(S, id)
|
||||
eq(S.save.inventory[id], nil, "toggleBadge removes the badge (nil, not false)")
|
||||
end
|
||||
|
||||
@@ -235,6 +235,37 @@ def _rebuild_yellow_sourced(yellow, pokeyellow):
|
||||
}
|
||||
|
||||
|
||||
def _remap_audio(yellow, yellow_symbols):
|
||||
"""Re-anchor the Red-copied audio section on pokeyellow.sym (#522).
|
||||
|
||||
Yellow's music bank $1f shifts after 1f:4294: Music_YellowIntro is a
|
||||
3-channel header (pokeyellow audio/headers/musicheaders3.asm) where
|
||||
Red's Music_IntroBattle had 4, so Red's addresses for every later
|
||||
header land on a channel-2 row (one voice, and no tempo command --
|
||||
Viridian Forest slow and hollow). Yellow also has a single
|
||||
wave-sample table at Audio1_WavePointers (audio.asm "Music 1"), not
|
||||
Red's per-engine 0x4373 copies, and CryData moved to 0e:5462. Names
|
||||
absent from the sym (Music_IntroBattle, which Yellow only knows as
|
||||
Music_YellowIntro) keep their positional mapping.
|
||||
"""
|
||||
for name, header in yellow["audio"]["musicHeaders"].items():
|
||||
symbol = yellow_symbols.by_name.get(name)
|
||||
if symbol is not None:
|
||||
header["bank"] = symbol.bank
|
||||
header["address"] = symbol.address
|
||||
wave = yellow_symbols.by_name.get("Audio1_WavePointers.wave0")
|
||||
if wave is None:
|
||||
raise SystemExit("pokeyellow.sym missing Audio1_WavePointers.wave0")
|
||||
for engine in yellow["audio"]["waveBanks"]:
|
||||
yellow["audio"]["waveBanks"][engine] = {
|
||||
"bank": wave.bank, "address": wave.address,
|
||||
}
|
||||
cry = yellow_symbols.by_name.get("CryData")
|
||||
if cry is None:
|
||||
raise SystemExit("pokeyellow.sym missing CryData")
|
||||
yellow["audio"]["cryData"] = {"bank": cry.bank, "address": cry.address}
|
||||
|
||||
|
||||
def derive(red, pokeyellow, symbols_path):
|
||||
"""Return the Yellow manifest derived from the Red manifest dict."""
|
||||
yellow = copy.deepcopy(red)
|
||||
@@ -261,6 +292,7 @@ def derive(red, pokeyellow, symbols_path):
|
||||
yellow = _drop_strings(yellow, dropped)
|
||||
|
||||
rebuilt = _rebuild_yellow_sourced(yellow, pokeyellow)
|
||||
_remap_audio(yellow, yellow_symbols) # #522
|
||||
|
||||
for label in YELLOW_EXTRA_TEXT_LABELS:
|
||||
if label not in yellow["text"]["labels"]:
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"wildWin": "Music_DefeatedWildMon"
|
||||
},
|
||||
"cryData": {
|
||||
"address": 21574,
|
||||
"address": 21602,
|
||||
"bank": 14
|
||||
},
|
||||
"cryHeaders": {
|
||||
@@ -473,7 +473,7 @@
|
||||
"engine": 1
|
||||
},
|
||||
"Music_CinnabarMansion": {
|
||||
"address": 17092,
|
||||
"address": 17089,
|
||||
"bank": 31,
|
||||
"engine": 3
|
||||
},
|
||||
@@ -508,17 +508,17 @@
|
||||
"engine": 2
|
||||
},
|
||||
"Music_Dungeon1": {
|
||||
"address": 17056,
|
||||
"address": 17053,
|
||||
"bank": 31,
|
||||
"engine": 3
|
||||
},
|
||||
"Music_Dungeon2": {
|
||||
"address": 17068,
|
||||
"address": 17065,
|
||||
"bank": 31,
|
||||
"engine": 3
|
||||
},
|
||||
"Music_Dungeon3": {
|
||||
"address": 17080,
|
||||
"address": 17077,
|
||||
"bank": 31,
|
||||
"engine": 3
|
||||
},
|
||||
@@ -573,12 +573,12 @@
|
||||
"engine": 1
|
||||
},
|
||||
"Music_MeetEvilTrainer": {
|
||||
"address": 17122,
|
||||
"address": 17119,
|
||||
"bank": 31,
|
||||
"engine": 3
|
||||
},
|
||||
"Music_MeetFemaleTrainer": {
|
||||
"address": 17131,
|
||||
"address": 17128,
|
||||
"bank": 31,
|
||||
"engine": 3
|
||||
},
|
||||
@@ -588,7 +588,7 @@
|
||||
"engine": 3
|
||||
},
|
||||
"Music_MeetMaleTrainer": {
|
||||
"address": 17140,
|
||||
"address": 17137,
|
||||
"bank": 31,
|
||||
"engine": 3
|
||||
},
|
||||
@@ -628,7 +628,7 @@
|
||||
"engine": 1
|
||||
},
|
||||
"Music_PokemonTower": {
|
||||
"address": 17104,
|
||||
"address": 17101,
|
||||
"bank": 31,
|
||||
"engine": 3
|
||||
},
|
||||
@@ -663,7 +663,7 @@
|
||||
"engine": 1
|
||||
},
|
||||
"Music_SilphCo": {
|
||||
"address": 17113,
|
||||
"address": 17110,
|
||||
"bank": 31,
|
||||
"engine": 3
|
||||
},
|
||||
@@ -1526,16 +1526,16 @@
|
||||
},
|
||||
"waveBanks": {
|
||||
"1": {
|
||||
"address": 17267,
|
||||
"address": 23078,
|
||||
"bank": 2
|
||||
},
|
||||
"2": {
|
||||
"address": 17267,
|
||||
"bank": 8
|
||||
"address": 23078,
|
||||
"bank": 2
|
||||
},
|
||||
"3": {
|
||||
"address": 17267,
|
||||
"bank": 31
|
||||
"address": 23078,
|
||||
"bank": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -219,6 +219,11 @@ function App.unload()
|
||||
S = nil
|
||||
mods = nil
|
||||
App.dataVersion = nil
|
||||
-- Kit is never evicted from package.loaded, so a Close taken while a text
|
||||
-- field still owns focus would leak Kit.focus and a raised soft keyboard
|
||||
-- (against a rect that is gone) into the launcher and the next session
|
||||
-- (#529).
|
||||
Kit.blur()
|
||||
end
|
||||
|
||||
function App.save()
|
||||
|
||||
@@ -24,6 +24,34 @@ Kit.scale = 1
|
||||
|
||||
local G = love and love.graphics or nil
|
||||
local edits = {} -- queued textinput / backspace since the last frame
|
||||
local kbField = nil -- id of the field the OS soft keyboard is raised for
|
||||
|
||||
-- Mobile LOVE only delivers love.textinput while setTextInput(true) is
|
||||
-- active, and that call is what raises the Android/iOS soft keyboard; the
|
||||
-- rect keeps the focused field visible above it. Desktop has text input on
|
||||
-- by default and the launcher hosting this editor depends on that -- nothing
|
||||
-- in src/import/RomImporter.lua (slot rename #205, ROM finder, mod index
|
||||
-- prompt) ever enables it -- so the editor only ever raises there and never
|
||||
-- lowers, since setTextInput is global SDL state, not per-widget (#529).
|
||||
local function mobile()
|
||||
local osName = love and love.system and love.system.getOS
|
||||
and love.system.getOS()
|
||||
return osName == "Android" or osName == "iOS"
|
||||
end
|
||||
|
||||
local function syncSoftKeyboard(id, x, y, w, h)
|
||||
if not (love and love.keyboard and love.keyboard.setTextInput) then return end
|
||||
if id then
|
||||
if kbField ~= id then
|
||||
kbField = id
|
||||
love.keyboard.setTextInput(true, math.floor(x), math.floor(y),
|
||||
math.ceil(w), math.ceil(h))
|
||||
end
|
||||
elseif kbField then
|
||||
kbField = nil
|
||||
if mobile() then love.keyboard.setTextInput(false) end
|
||||
end
|
||||
end
|
||||
|
||||
local function canPrintf()
|
||||
return G and type(G.printf) == "function"
|
||||
@@ -81,7 +109,10 @@ function Kit.keypressed(key)
|
||||
return false
|
||||
end
|
||||
|
||||
function Kit.blur() Kit.focus = nil end
|
||||
function Kit.blur()
|
||||
Kit.focus = nil
|
||||
syncSoftKeyboard(nil) -- the soft keyboard follows focus down too (#529)
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- hit testing
|
||||
function Kit.hit(x, y, w, h)
|
||||
@@ -319,11 +350,13 @@ function Kit.textfield(id, x, y, w, h, value, placeholder)
|
||||
if Kit.press(x, y, w, h) then Kit.focus = id end
|
||||
local focused = (Kit.focus == id)
|
||||
if focused then
|
||||
-- raise (or hand off) the soft keyboard while this field owns focus (#529)
|
||||
syncSoftKeyboard(id, x, y, w, h)
|
||||
for _, e in ipairs(edits) do
|
||||
if e == "\b" then
|
||||
value = value:sub(1, -2)
|
||||
elseif e == "\r" then
|
||||
Kit.focus = nil
|
||||
Kit.blur() -- commit/cancel also lowers the soft keyboard (#529)
|
||||
focused = false
|
||||
else
|
||||
value = value .. e
|
||||
|
||||
@@ -411,7 +411,7 @@ function Ops.pcDrop(S, id)
|
||||
return Ops.mark(S, ("Dropped all %d %s from PC storage"):format(qty, id))
|
||||
end
|
||||
|
||||
-- Badges are boolean inventory flags, not stackable items, which is why the
|
||||
-- Badges are truthy inventory flags, not stackable items, which is why the
|
||||
-- design gives them toggle chips instead of quantity rows.
|
||||
function Ops.isBadgeId(id)
|
||||
return id:find("BADGE", 1, true) ~= nil
|
||||
@@ -426,8 +426,13 @@ function Ops.badgeIds(S)
|
||||
end
|
||||
|
||||
function Ops.toggleBadge(S, id)
|
||||
local on = S.save.inventory[id] == true
|
||||
S.save.inventory[id] = (not on) or nil
|
||||
-- #515: badges are truthy inventory entries written as 1 by the in-game
|
||||
-- grant (checkVictoryRewards, src/world/OverworldController.lua) and by
|
||||
-- GenSave's .sav import; read and write that same shape here, or a badge
|
||||
-- earned in game reads as unowned and an editor-written boolean blows up
|
||||
-- Bag.add's `(inv[id] or 0) + qty` (src/inventory/Bag.lua).
|
||||
local on = S.save.inventory[id] and true or false
|
||||
S.save.inventory[id] = (not on) and 1 or nil
|
||||
return Ops.mark(S, ("%s %s"):format(id, on and "removed" or "earned"))
|
||||
end
|
||||
|
||||
|
||||
@@ -154,7 +154,10 @@ function M.draw(S, Kit, x, y, w, h)
|
||||
Kit.card(x, badgeY, leftW, badgeH)
|
||||
local earned = 0
|
||||
for _, id in ipairs(badgeIds) do
|
||||
if S.save.inventory[id] == true then earned = earned + 1 end
|
||||
-- #515: truthy check, not `== true` -- the in-game grant path stores a
|
||||
-- number (see OverworldController.lua checkVictoryRewards), matching
|
||||
-- src/inventory/Badges.lua's own truthy read.
|
||||
if S.save.inventory[id] then earned = earned + 1 end
|
||||
end
|
||||
Kit.caption(x + pad, badgeY + pad, "BADGES")
|
||||
Kit.textRight("mono", ("%d/%d"):format(earned, #badgeIds), x + leftW - pad,
|
||||
@@ -164,7 +167,7 @@ function M.draw(S, Kit, x, y, w, h)
|
||||
for i, id in ipairs(badgeIds) do
|
||||
local bc = (i - 1) % badgeCols
|
||||
local br = math.floor((i - 1) / badgeCols)
|
||||
local on = S.save.inventory[id] == true
|
||||
local on = S.save.inventory[id]
|
||||
local short = id:gsub("BADGE$", "")
|
||||
if Kit.chip(x + pad + bc * (bW + 7 * s), bTop + br * (28 * s + 7 * s),
|
||||
bW, 28 * s, Kit.ellipsize("micro", short, bW - 8 * s), on,
|
||||
|
||||
Reference in New Issue
Block a user