diff --git a/data/scripts/flavor/route_23.lua b/data/scripts/flavor/route_23.lua index 313edd78..644d0d11 100644 --- a/data/scripts/flavor/route_23.lua +++ b/data/scripts/flavor/route_23.lua @@ -28,7 +28,29 @@ local function badgeGuard(badge, passFlag) } end +-- Route23SetVictoryRoadBoulders (pokered scripts/Route23.asm:8): every entry +-- to Route 23 resets the Victory Road boulder puzzle behind you. The 2F/3F +-- switch events clear (so those barriers are closed again on the next floor +-- load) and the boulder that fell through 3F's hole goes back upstairs: +-- ShowObject TOGGLE_VICTORY_ROAD_3F_BOULDER / HideObject +-- TOGGLE_VICTORY_ROAD_2F_BOULDER, which are VICTORYROAD3F_BOULDER4 and +-- VICTORYROAD2F_BOULDER3 (data/maps/toggleable_objects.asm). 1F's event is +-- reset by the Indigo Plateau lobby and by Victory Road 2F, not here. This +-- is what makes the puzzle "completely reset" on a return trip (#258). +-- onEnter is the port's BIT_CUR_MAP_LOADED_2 equivalent: setMap runs it on +-- every map entry, connection crossings included, like EnterMap. M.ROUTE_23 = { + onEnter = function(game, ow) + local f = game.save.flags + f.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1 = nil + f.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2 = nil + f.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 = nil + f.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2 = nil + local Commands = require("src.script.Commands") + local ctx = { save = game.save, overworld = ow, game = game } + Commands.show_object(ctx, "VICTORY_ROAD_3F", "VICTORYROAD3F_BOULDER4") + Commands.hide_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER3") + end, talk = { -- Route23Guard1Text: EventFlagBit ..., EVENT_PASSED_EARTHBADGE_CHECK -- -> wWhichBadge = EARTHBADGE diff --git a/data/scripts/story.lua b/data/scripts/story.lua index 5aeb849e..0fa2c3db 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -94,6 +94,27 @@ M.VIRIDIAN_CITY = { { "show_text", "_ViridianCityOldManTimeIsMoneyText" }, -- 8 (9 = end) }, }, + -- Re-apply the Pokedex old-man swap for a save that already holds the flag + -- but was never standing here when it fired: a vanilla .sav imported + -- through src/save_convert, whose codec does not model pokered's + -- wToggleableObjectFlags array, so save.objectToggles arrives empty and + -- both objects fall back to their compiled-in defaults -- the sleeper ON + -- (data/maps/toggleable_objects.asm toggleable_objects_for VIRIDIAN_CITY), + -- still lying across the north path (#234). + -- OaksLabOakGivesPokedexScript (scripts/OaksLab.asm) sets EVENT_GOT_POKEDEX + -- and, with no branch in between, runs HideObject TOGGLE_LYING_OLD_MAN / + -- ShowObject TOGGLE_OLD_MAN, so that one flag settles both toggles exactly. + -- Same shape as M.OAKS_LAB.onEnter in data/scripts/oaks_lab.lua, which + -- re-applies the same script's other two HideObjects on entry (#106). + onEnter = function(game, ow) + if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then + return + end + local Commands = require("src.script.Commands") + local ctx = { save = game.save, game = game, overworld = ow } + Commands.hide_object(ctx, "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY") + Commands.show_object(ctx, "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN") + end, -- ViridianCityCheckGotPokedexScript: the north corridor is gated on -- EVENT_GOT_POKEDEX, NOT on the sleeper being hidden, and it triggers -- on exactly one cell -- (19,9), the gap east of the sleeper (18,9) @@ -719,11 +740,20 @@ local function boulderAt(ow, x, y) return npc and npc.def.sprite == "SPRITE_BOULDER" and npc or nil end +-- Each barrier is stamped BOTH ways on entry. pokered only ever stamps the +-- OPEN block (ReplaceTileBlock edits the loaded block map, which the next map +-- load throws away, so a fresh entry always shows the .blk's closed barrier), +-- but OverworldState:replaceBlock writes through Map:setBlock into the SHARED +-- map record in Game.data, so the port has to put the closed block back +-- itself or a solved barrier stays open for the rest of the session (#258). +-- The closed ids are the shipped .blk bytes: VictoryRoad1F.blk (4,6) = $25, +-- VictoryRoad2F.blk (3,4) = $37 and (11,7) = $25, VictoryRoad3F.blk +-- (3,5) = $25. Same both-ways pattern as the card-key doors in +-- OverworldState:setMap. M.VICTORY_ROAD_1F = { onEnter = function(game, ow) - if game.save.flags.EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH then - ow:replaceBlock(4, 6, 0x1D) - end + ow:replaceBlock(4, 6, + game.save.flags.EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH and 0x1D or 0x25) end, onBoulderMoved = function(game, ow, npc) if npc.cellX == 17 and npc.cellY == 13 @@ -736,12 +766,14 @@ M.VICTORY_ROAD_1F = { M.VICTORY_ROAD_2F = { onEnter = function(game, ow) - if game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1 then - ow:replaceBlock(3, 4, 0x15) - end - if game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2 then - ow:replaceBlock(11, 7, 0x1D) - end + -- VictoryRoad2FResetBoulderEventScript (scripts/VictoryRoad2F.asm:19): + -- entering 2F clears the 1F switch event, so 1F's barrier is closed + -- again the next time you climb down (#258) + game.save.flags.EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH = nil + ow:replaceBlock(3, 4, + game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1 and 0x15 or 0x37) + ow:replaceBlock(11, 7, + game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2 and 0x1D or 0x25) end, onBoulderMoved = function(game, ow, npc) if npc.cellX == 1 and npc.cellY == 16 @@ -759,9 +791,8 @@ M.VICTORY_ROAD_2F = { M.VICTORY_ROAD_3F = { onEnter = function(game, ow) - if game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 then - ow:replaceBlock(3, 5, 0x1D) - end + ow:replaceBlock(3, 5, + game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 and 0x1D or 0x25) end, onBoulderMoved = function(game, ow, npc) if npc.cellX == 3 and npc.cellY == 5 @@ -769,12 +800,23 @@ M.VICTORY_ROAD_3F = { game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 = true ow:replaceBlock(3, 5, 0x1D) end - -- the hole at (23,15): the boulder drops to 2F next to switch 2 - if npc.cellX == 23 and npc.cellY == 15 then + -- the hole at (23,15): the boulder drops to 2F next to switch 2. + -- VictoryRoad3FDefaultScript .handle_hole gates the swap on + -- CheckAndSetEvent EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2, then + -- HideObject TOGGLE_VICTORY_ROAD_3F_BOULDER / ShowObject + -- TOGGLE_VICTORY_ROAD_2F_BOULDER. Those two toggles are + -- VICTORYROAD3F_BOULDER4 and VICTORYROAD2F_BOULDER3 + -- (data/maps/toggleable_objects.asm); the old "VICTORYROAD2F_BOULDER" + -- matched no object_event name, so the toggle landed under a key + -- objectVisible never reads: harmless only while nothing hid that + -- boulder, and fatal once Route 23's reset does (#258). + if npc.cellX == 23 and npc.cellY == 15 + and not game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2 then + game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2 = true local Commands = require("src.script.Commands") local ctx = { save = game.save, overworld = ow, game = game } Commands.hide_object(ctx, "VICTORY_ROAD_3F", npc.def.name) - Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER") + Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER3") end end, -- scripts/VictoryRoad3F.asm VictoryRoad3FDefaultScript: the same hole diff --git a/data/scripts/story2.lua b/data/scripts/story2.lua index 695545b1..43d5ea27 100644 --- a/data/scripts/story2.lua +++ b/data/scripts/story2.lua @@ -761,7 +761,7 @@ M.CINNABAR_LAB_FOSSIL_ROOM = { -- #118: do not raise mon.level until a paid retrieve (pokered reverts -- wDayCareMonBoxLevel on .leaveMonInDayCare). Fold pending steps into -- mon.exp once and clear them so a second talk cannot re-apply the same --- walk. Fill {RAM:wNameBuffer}/{RAM:wDayCareMonName}/{NUM:...} here — +-- walk. Fill {RAM:wNameBuffer}/{RAM:wDayCareMonName}/{NUM:...} here -- -- TextBox.TOKENS.RAM only knows wStringBuffer. -- ------------------------------------------------------------------- diff --git a/data/scripts/story3.lua b/data/scripts/story3.lua index 5d1adbd4..d868761a 100644 --- a/data/scripts/story3.lua +++ b/data/scripts/story3.lua @@ -300,7 +300,7 @@ local function elevator(elevatorMapId, keyGate, preFrames) -- Rocket Hideout: without LIFT_KEY the panel only prints the need- -- a-key line (scripts/RocketHideoutElevator.asm). Exit warps are -- still seeded above so walking out returns to the entry floor - -- instead of the car's ROM default (B1F) — #90 / #105. + -- instead of the car's ROM default (B1F) -- #90 / #105. if keyGate and not game.save.inventory[keyGate.item] then local TextBox = require("src.render.TextBox") game.stack:push(TextBox.new(game, diff --git a/data/scripts/story5.lua b/data/scripts/story5.lua index a8f3f764..84aea24f 100644 --- a/data/scripts/story5.lua +++ b/data/scripts/story5.lua @@ -293,7 +293,11 @@ pewterEscort.guySteps = { "right", "right", "right", } --- Walk home: reverse of guySteps with opposite facings (gym → spawn). +-- Reverse of guySteps with opposite facings, i.e. the gym-to-spawn +-- mirror of RLEList_PewterGymGuy. Kept as the documented inverse that +-- tests/parity_pewter_escort.lua checks; the youngster does NOT walk it +-- home any more, because its first step is LEFT through the player +-- parked on (11,18) (#241). See walkHome below. do local opp = { up = "down", down = "up", left = "right", right = "left" } local ret = {} @@ -371,20 +375,44 @@ local function pewterGymEscort(game, ow) local head = plan.guyHeadStart -- After the walk: face the player, restore map music, "Go take on - -- BROCK", then retrace RLEList_PewterGymGuy back to his spawn (35,16). - -- (pokered teleports him via MovementData_PewterGymGuyExit; we walk - -- the same route home instead. Brock victory still HideObject's him.) + -- BROCK", then MovementData_PewterGymGuyExit -- five steps RIGHT out of + -- (12,18), the cell PewterCityYoungsterShowsPlayerGymScript pins him to + -- with SetSpritePosition1 (hSpriteMapXCoord 16 / hSpriteMapYCoord 22, + -- minus the +4 border offset object_event coords carry per + -- macros/scripts/maps.asm) and exactly where the escort leaves him. + -- That lands him on (17,18), the last walkable cell before the fence at + -- (18,18) and one column past the screen edge with the player parked on + -- (11,18). PewterCityHideYoungsterScript then HideObject's him and + -- PewterCityResetYoungsterScript's SetSpritePosition2 + ShowObject put + -- him back on his object_event spawn (35,16) facing DOWN, which is the + -- snap below (the vanish/reappear is off screen, same as the original). + -- + -- The old code retraced guyReturnSteps instead, whose first step is + -- LEFT into (11,18) -- the cell the player is standing on -- and + -- scriptMove is a pure tween with no entity test, so he walked straight + -- through Red (#241). There is no honest route home: (17,18) is a + -- dead-end pocket ((18,18) fence, (17,17) wall), which is precisely why + -- the original teleports. guyReturnSteps stays as the documented + -- mirror of RLEList_PewterGymGuy that parity_pewter_escort asserts. + -- Brock victory still HideObject's him. local function walkHome() if not guy then return end - local ret = pewterEscort.guyReturnSteps local i = 0 local function tick() i = i + 1 - if not ret[i] then + if i > 5 then + -- SetSpritePosition2: same field writes as Commands.place_npc, + -- including the target clear -- a stale targetX/targetY would + -- leave OverworldState:npcAtCell reserving (17,18) forever and + -- silently wall the player out of that pocket. + guy.cellX, guy.cellY = 35, 16 + guy.px, guy.py = 35 * 16, 16 * 16 + guy.moving = false + guy.targetX, guy.targetY = nil, nil guy.facing = "down" return end - ow:scriptMove(guy, ret[i], 1, tick) + ow:scriptMove(guy, "right", 1, tick) end tick() end @@ -468,23 +496,48 @@ end -- Gym) and again in Viridian Gym; we derive the same windows from the -- surrounding story flags so old saves work too. -- Rival1Exit → Viridian (right/down); Rival2Exit → League (left). +-- Both exit lists are keyed on wSavedCoordIndex -- WHICH entry of +-- Route22DefaultScript.Route22RivalBattleCoords the player matched -- not +-- on where the rival ended up. CheckCoords (home/map_objects.asm:107) +-- zeroes wCoordIndex and `inc [hl]` BEFORE each compare, so the first +-- entry reports 1: index 1 is (29,4), index 2 is (29,5). +-- Route22Rival1AfterBattleScript (Route22.asm:175) takes +-- ...ExitMovementData1 on index 1 and ...Data2 otherwise. From the top +-- tile the rival stands BELOW the player on (29,5) and leaves east along +-- row 5; from the bottom tile he stands LEFT of him on (28,5) and has to +-- step UP to row 4 to get around him. The port had the two branches +-- swapped, so the top tile started the walk with UP out of (28,4) into +-- the cliff cell (28,3), which is not walkable (#236). local function route22ExitDirs(n, py) if n == 2 then - if py == 5 then return { "left", "left", "left", "left" } end + -- Route22Rival2ExitMovementData1 falls through into ...Data2: LEFT x4 + -- from (29,5), LEFT x3 from (28,5), both back onto the spawn (25,5). + if py == 4 then return { "left", "left", "left", "left" } end return { "left", "left", "left" } end - if py == 5 then + -- Route22Rival1ExitMovementData1: (29,5) -> (31,5) -> (31,10) + if py == 4 then return { "right", "right", "down", "down", "down", "down", "down" } end + -- Route22Rival1ExitMovementData2: (28,5) -> (28,4) -> (31,4) -> (31,10) return { "up", "right", "right", "right", "down", "down", "down", "down", "down", "down" } end local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag, py) + -- Route22MoveRivalRightScript (Route22.asm:39) walks him RIGHT along his + -- own row from the object_event spawn (25,5): the full four-RIGHT + -- Route22RivalMovementData on coord index 1 (player on (29,4)), so he + -- stops BELOW the player on (29,5); `inc de` drops one RIGHT on index 2 + -- (player on (29,5)), so he stops LEFT of him on (28,5). He never + -- leaves row 5. Route22Rival{1,2}StartBattleScript (Route22.asm:110) + -- then faces him UP on index 1 and RIGHT otherwise (#236). + local rx = (py == 4) and 29 or 28 + local rivalFacing = (py == 4) and "up" or "right" return { { "show_object", "ROUTE_22", objName }, -- 1 - { "move_npc_to", objIndex, 28, py }, -- 2 - { "face_object", objIndex, "right" }, -- 3 + { "move_npc_to", objIndex, rx, 5 }, -- 2 + { "face_object", objIndex, rivalFacing }, -- 3 { "show_text", "_Route22RivalBeforeBattleText" .. n }, -- 4 { "rival_battle", oppClass, baseParty }, -- 5 { "jump_if_false", 11 }, -- 6 @@ -496,20 +549,25 @@ local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag, } end +-- Route22Rival{1,2}StartBattleScript turns the player toward him too: +-- PLAYER_DIR_DOWN on coord index 1 (the (29,4) tile, rival below him), +-- and the PLAYER_DIR_LEFT Route22DefaultScript already set on index 2 +-- (the (29,5) tile, rival to his left) (#236). M.ROUTE_22 = { onStep = function(game, ow, x, y) if not inCoords({ { 29, 4 }, { 29, 5 } }, x, y) then return false end local f = game.save.flags + local playerFacing = (y == 4) and "down" or "left" if f.EVENT_GOT_POKEDEX and not f.EVENT_BEAT_BROCK and not f.EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE then return runAmbush(game, ow, route22Scene(1, 1, "ROUTE22_RIVAL1", "OPP_RIVAL1", 4, - "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE", y), "left") + "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE", y), playerFacing) end if f.EVENT_BEAT_GIOVANNI and not f.EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE then return runAmbush(game, ow, route22Scene(2, 2, "ROUTE22_RIVAL2", "OPP_RIVAL2", 10, - "EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", y), "left") + "EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", y), playerFacing) end return false end, @@ -545,7 +603,7 @@ end -- scripts/CeruleanCity.asm CeruleanCityRocketText: fight the thief, -- then he returns TM28 (DIG) and hurries off. CeruleanHideRocket -- (CeruleanCity_2.asm) is GBFadeOutToBlack → Show GUARD1 / Hide GUARD2 / --- Hide ROCKET → GBFadeInFromBlack — not a bare hide_object. +-- Hide ROCKET → GBFadeInFromBlack -- not a bare hide_object. local rocketRows = { { "face_player" }, -- 1 { "check_flag", "EVENT_GOT_TM28" }, -- 2 @@ -564,7 +622,7 @@ local rocketRows = { { "fade", "out" }, -- 15 GBFadeOutToBlack -- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2 -- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south - -- door neighbour — the swap reconnects the city (Bill's ticket does + -- door neighbour -- the swap reconnects the city (Bill's ticket does -- the same in story.lua; either route is enough). { "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16 { "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17 @@ -628,13 +686,83 @@ M.MUSEUM_1F = { } -- The Pewter Center's singing JIGGLYPUFF (scripts/PewterPokecenter.asm --- plays MUSIC_JIGGLYPUFF_SONG, then the map theme resumes) +-- PewterPokecenterJigglypuffText). The text_asm sets +-- wDoNotWaitForButtonPressAfterDisplayingText before PrintText, so the box +-- prints with no A prompt and the script keeps running underneath it: +-- SFX_STOP_ALL_MUSIC, DelayFrames 32, PlayMusic MUSIC_JIGGLYPUFF_SONG, +-- then .spinMovementLoop writes the next of DOWN -> LEFT -> UP -> RIGHT (a +-- clockwise turn) every 24 frames for as long as the song's CHAN1/CHAN2 +-- still sound, DelayFrames 48, PlayDefaultMusic, TextScriptEnd. Only that +-- last step closes the box, so the whole dance plays out unskippably +-- (#249). Music.playOnce's pendingRestore stands in for both the channel +-- poll and PlayDefaultMusic, so the Center's theme comes back the frame +-- the song ends rather than 48 frames later. +local JIGGLYPUFF_SPIN = { "down", "left", "up", "right" } +local JIGGLYPUFF_SILENCE, JIGGLYPUFF_STEP, JIGGLYPUFF_TAIL = 32, 24, 48 + +-- Built as a TextBox `auto` table: auto.sound fires the frame the last +-- page has typed out (PrintText returning), and auto.tick then runs once +-- per frame while the gate it returns still reads as playing. +local function jigglypuffDance(game, npc) + local Music = require("src.core.Music") + -- .findMatchingFacingDirectionLoop: the rotation picks up at the entry + -- matching the sprite's current facing (showMapText has just turned it + -- toward the player), and the first write is that same facing, so the + -- first visible quarter turn lands 24 frames in + local step = 1 + for i, dir in ipairs(JIGGLYPUFF_SPIN) do + if npc and npc.facing == dir then step = i end + end + local frames, phase = 0, "silence" + return { + sound = function() + Music.stop() -- SFX_STOP_ALL_MUSIC + return { isPlaying = function() return phase ~= "done" end } + end, + tick = function() + frames = frames + 1 + if phase == "silence" then + if frames < JIGGLYPUFF_SILENCE then return end + frames = 0 + if Music.playOnce(game.data, "Music_JigglypuffSong") then + phase = "spin" + else + -- no song def (or headless): Music.stop above already took the + -- map theme down and nothing armed pendingRestore, so put it + -- back by hand and fall through to the tail rather than hold + -- the box on a poll that would never clear + Music.restoreMap(game.data) + phase = "tail" + end + return + end + if phase == "spin" then + if frames < JIGGLYPUFF_STEP then return end + frames = 0 + -- the loop tests the channels after the delay and before the next + -- write, so a song that just ended costs no extra quarter turn + if not Music.oneShotPlaying() then + phase = "tail" + return + end + step = step % #JIGGLYPUFF_SPIN + 1 + if npc then npc.facing = JIGGLYPUFF_SPIN[step] end + return + end + if frames >= JIGGLYPUFF_TAIL then phase = "done" end + end, + } +end + M.PEWTER_POKECENTER = { talk = { TEXT_PEWTERPOKECENTER_JIGGLYPUFF = function(game, ow, npc, done) - require("src.core.Music").playOnce(game.data, "Music_JigglypuffSong") - push(game, text(game)._PewterPokecenterJigglypuffText - or "JIGGLYPUFF: Puu\npupuu!", done) + -- not the local push() helper: this box needs opts.auto, which is + -- also what suppresses the blinking arrow and the A dismissal + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, + text(game)._PewterPokecenterJigglypuffText or "JIGGLYPUFF: Puu\npupuu!", + done, { auto = jigglypuffDance(game, npc) })) end, }, } diff --git a/data/scripts/victories.lua b/data/scripts/victories.lua index 65859d8e..56bd07df 100644 --- a/data/scripts/victories.lua +++ b/data/scripts/victories.lua @@ -16,7 +16,7 @@ -- `dialogue` is the end-battle + post-battle text chain each gym leader -- runs (SaveEndBattleTextPointers then the map's *PostBattle / ReceiveTM -- script). Leaders are not def_trainers entries, so engageTrainer has --- no header.won — checkVictoryRewards shows this chain instead of a +-- no header.won -- checkVictoryRewards shows this chain instead of a -- synthetic "received badge/TM" stub. local function range(prefix, first, last) diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 59871933..1f39040d 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,12 @@ + + diff --git a/scripts/build_android.sh b/scripts/build_android.sh index 30425a3b..39af4826 100755 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -113,11 +113,12 @@ import pathlib, re, sys path = pathlib.Path(sys.argv[1]) text = path.read_text() -# Drop network / mic / legacy storage, not needed for offline play. -# Keep VIBRATE (love.system.vibrate) and BLUETOOTH (optional gamepads). +# Drop mic / legacy storage, not needed by this game. +# Keep VIBRATE (love.system.vibrate), BLUETOOTH (optional gamepads) and +# INTERNET: link play is not offline-only any more, and stripping INTERNET +# made every LAN host and every relay connect fail with EPERM (issue #287). # Orientation / label come from gradle.properties placeholders. for perm in ( - "android.permission.INTERNET", "android.permission.RECORD_AUDIO", "android.permission.WRITE_EXTERNAL_STORAGE", ): diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index ae595242..e85faff0 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -53,16 +53,22 @@ local BALL_ANIMS = { } local imageCache = {} +-- The three tables below are keyed by the Image OBJECT, not by a path, and a +-- running battle holds the pics it built at enter() (battler.sprite, +-- playerBackPic, trainerPic). Weak keys let a dropped pic's row go with it, +-- so invalidate() can drop the path cache without orphaning what is on +-- screen right now (#316). +local WEAK_KEYS = { __mode = "k" } -- fully transparent rows below a pic's content (the extracted 32x32 back -- pics carry baked-in padding); used to sit the pic flush on the text box -local imagePadBottom = {} +local imagePadBottom = setmetatable({}, WEAK_KEYS) -- fully transparent columns left of a pic's content; at 2x (back pics) -- this is subtracted from hlcoord 1,5 so opaque pixels match hardware, -- where those columns were white-on-white rather than shifted content -local imagePadLeft = {} +local imagePadLeft = setmetatable({}, WEAK_KEYS) -- image -> { path, pal } so palette-fade variants (see fadeImage) can be -- rebuilt for any battle pic, whatever code loaded it -local imageMeta = {} +local imageMeta = setmetatable({}, WEAK_KEYS) -- pal = { name, colors } recolors the 4 GB shades like the Super Game Boy. -- trueColor art (14 §the 4-shade contract) opts out of the quantize -- entirely, so its palette variant collapses back onto the plain path. @@ -118,10 +124,19 @@ local function getImage(path, pal, trueColor) return imageCache[key] end --- hot reload: the next getImage re-resolves every pic through the asset --- search path and re-measures its ground padding +-- Hot reload / COLORS change (PaletteFX.setMode calls this): the next +-- getImage re-resolves every pic through the asset search path and +-- re-measures its ground padding. ONLY the path->image cache is dropped. +-- Wiping the three per-image tables as well orphaned the pics a running +-- battle already holds: imagePadBottom went nil, so backPlacement lost the +-- four transparent rows it grounds the back pic on and the pic jumped +-- pad * 2x = 8px UP the frame COLORS changed (#316), and imageMeta went nil, +-- so picImage's forced-mono grayImage (#207), fadeImage's BGP variants and +-- imagePathOf's battle_sprite_scales lookup all silently stopped resolving. +-- Those rows are weak-keyed, so entries for pics nothing references any more +-- are collected on their own rather than leaking. function BattleState.invalidate() - imageCache, imagePadBottom, imagePadLeft, imageMeta = {}, {}, {}, {} + imageCache = {} end Assets.register(BattleState.invalidate) @@ -174,6 +189,25 @@ local function grayImage(img) return getImage(meta.path) or img end +-- The blacked-out battle screen. HandlePlayerBlackOut (core.asm:1151) runs +-- SET_PAL_BATTLE_BLACK, i.e. SetPal_BattleBlack sends PalPacket_Black -- +-- PAL_BLACK in all four slots of BlkPacket_Battle (engine/gfx/palettes.asm: +-- 22-25). The mon pics are drawn OVER the zone pass with their palette +-- already baked in, so darkening them means re-baking through PAL_BLACK the +-- way fadeImage re-bakes through a BGP permutation (#292). Reads the palette +-- out of the active pack, exactly like sgbBattlePals, so the zone pass and +-- the pics can never disagree. trueColor art has no DMG shades to remap. +local function blackImage(data, img) + local meta = imageMeta[img] + if not meta or meta.trueColor then return img end + local PaletteFX = require("src.render.PaletteFX") + local pack = PaletteFX.pack(data) + local colors = pack and pack.palettes and pack.palettes.BLACK + if not colors then return img end + local name = PaletteFX.usesGbcPack() and "redpp:BLACK" or "BLACK" + return getImage(meta.path, { name = name, colors = colors }) or img +end + -- the asset path a loaded battle image came from (nil for the headless -- stub images), so the battle_sprite_scales registry can be looked up by -- the same path data references @@ -200,6 +234,11 @@ function BattleState:picImage(img) local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv" or PaletteFX.mode == "classic" if self.grayPics or mono then return grayImage(img) end + -- SET_PAL_BATTLE_BLACK covers every battle palette slot, so the pics go + -- dark with the HP bars while the blackout text is up (#292). Below the + -- mono check on purpose: the forced-mono modes re-threshold the whole + -- frame downstream, and the DMG had no SGB darkening to begin with. + if self.blackedOut then return blackImage(self.data, img) end return fadeImage(img, self:activeBgp()) end @@ -725,6 +764,7 @@ function BattleState:startMessage(item) -- it against self.total); the current line's revealed count is #shown[last] self.charIndex = 0 self.msgWaiting = nil + self.msgPrompt = nil self.scrollPx = nil self:beginMsgLine() end @@ -930,9 +970,18 @@ function BattleState:updateQueue() end)) return true end - if not (item and item.choice) - and (input:wasPressed("a") or input:wasPressed("b")) then - self.current = nil + if not (item and item.choice) then + -- The page is typed out and waiting on the player: PromptText + -- (home/text.asm:209-217) writes '▼' at (18,16) and ManualTextScroll + -- blinks it until A/B, so the arrow belongs on a finished page and not + -- only on a \v CONT hold (#317). A flag of its own, not msgWaiting: + -- that branch above scrolls the NEXT line in, which this page has not + -- got, so reusing it would call beginMsgLine on a drained message. + self.msgPrompt = true + if input:wasPressed("a") or input:wasPressed("b") then + self.msgPrompt = nil + self.current = nil + end end end return true @@ -1021,6 +1070,17 @@ function BattleState:enter() -- sides slide in; the trainer pics stay up until the send-outs self.introSlide = 40 self.showEnemyTrainer = self.kind == "trainer" and self.trainerPic ~= nil + -- DrawAllPokeballs (common_text.asm:27) puts the party ball rows AND the + -- HUD corner/underline tiles under them (PlacePlayerHUDTiles / + -- PlaceEnemyHUDTiles, draw_hud_pokeball_gfx.asm:119-165) on screen with + -- the intro text; _InitBattleCommon (core.asm:6755-6762) ClearScreenArea's + -- both HUD blocks and ClearSprites's the balls the moment that text is + -- dismissed. This flag is exactly that window: drawHUDs draws the intro + -- chrome while it is up and holds the real enemy HUD back, since a wild + -- battle's DrawEnemyHUDAndHPBar only runs after the text (#317). The + -- draw is still gated on the slide having landed, so nothing shows while + -- the silhouettes are still coming in. + self.introBalls = true -- SGB: the player-side battle palette while the back pic is up is -- MonsterPalettes[0] = PAL_MEWMON (wBattleMonSpecies is still 0 when -- the intro's SET_PAL_BATTLE runs -- SetPal_Battle, @@ -1052,12 +1112,27 @@ function BattleState:enter() queueEnemyCry() end self:say(self.introText) + -- _InitBattleCommon (core.asm:6755-6762): the instant the intro text is + -- dismissed both HUD blocks are cleared and ClearSprites drops the + -- pokeball OAM, so the intro chrome never returns for the rest of the + -- battle -- not on a switch, and not when the beaten trainer's pic + -- scrolls back in (#317, #282) + self:act(function() self.introBalls = nil end) if self.kind == "trainer" then + -- EnemySendOutFirstMon (core.asm:1308-1310): SlideTrainerPicOffScreen + -- walks the foe's pic off the RIGHT edge (hlcoord 18,0, a = 8 tiles, + -- one tile every 2 frames) BEFORE TrainerSentOutText -- the pic does + -- not blink out under the text (#317) + self:act(function() self:slidePic("foe", 0, 64, 4) end) + table.insert(self.queue, { wait = 16 }) + self:act(function() + self.showEnemyTrainer = false + self:slidePic("foe") + end) self:say(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name)) self:act(function() -- EnemySendOutFirstMon (core.asm:1421-1434): after the text the -- pic grows out of the ball (AnimateSendingOutMon), then the cry - self.showEnemyTrainer = false self:startGrowIn(self.enemy) end) queueEnemyCry() @@ -1075,13 +1150,20 @@ function BattleState:enter() queueEnemyCry() end if not self.safari and not self.demo then - self:say(self:sendOutText(self.player.name)) - -- Red's pic clears, the POOF plays, then the mon appears with its - -- cry (SendOutMon: message -> AnimateSendingOutMon -> PlayCry) + -- StartBattle .playerSendOutFirstMon (core.asm:236-240): the back pic + -- walks off the LEFT edge (SlideTrainerPicOffScreen, hlcoord 1,5, + -- a = 9 tiles, one tile every 2 frames) BEFORE SendOutMon prints + -- "Go! X!" -- Red does not simply vanish under the message (#317) + self:act(function() self:slidePic("back", 0, -72, 4) end) + table.insert(self.queue, { wait = 18 }) self:act(function() self.showPlayerBack = false self.sendingOut = true + self:slidePic("back") end) + self:say(self:sendOutText(self.player.name)) + -- then the POOF plays and the mon appears with its cry + -- (SendOutMon: message -> AnimateSendingOutMon -> PlayCry) table.insert(self.queue, { anim = "POOF_ANIM", attackerIsPlayer = false }) self:act(function() self.sendingOut = false @@ -2165,19 +2247,34 @@ end -- battle (EndLowHealthAlarm sets wLowHealthAlarmDisabled, mirrored by -- playVictoryMusic) and every other outcome tears it down in -- end_of_battle.asm -- self.result covers those. The damage drain --- gates the start (the HUD redraw runs after UpdateHPBar finishes), --- but healing out of the red stops it at once (item_effects.asm clears --- the alarm before the bar animates). No alarm before the player HUD --- first draws (send-out), nor in the safari/old-man battles, which --- have no player mon HUD. +-- gates the START (the HUD redraw runs after UpdateHPBar finishes) but +-- never the stop, and healing out of the red silences it at once +-- (item_effects.asm:991-994 clears the alarm before the bar animates). +-- No alarm before the player HUD first draws (send-out), nor in the +-- safari/old-man battles, which have no player mon HUD. function BattleState:lowHealthAlarmActive() local p = self.player if not p or self.safari or self.demo or self.result or self.lowHealthAlarmDisabled then return false end if self.showPlayerBack or (self.introSlide or 0) > 0 then return false end + if p.fainted then return false end + -- A siren that is ALREADY sounding follows the drawn bar, not the + -- model: wLowHealthAlarm is a latch DrawPlayerHUDAndHPBar only revisits + -- once UpdateHPBar2 has finished animating (core.asm:4727-4729 / + -- core.asm:4845-4847 both drain first, then jp DrawHUDsAndHPBars), and + -- a KO clears it in RemoveFaintedPlayerMon (core.asm:1011-1016), i.e. + -- after the bar has drained empty. applyDamage takes the HP off the + -- model while the turn is still being queued, so keying a running alarm + -- off mon.hp cut it dead for the whole "used X!" line + move animation + -- + drain window (#293). max() keeps a heal out of the red silencing + -- it on the spot, the way item_effects.asm does. local hp = p.mon.hp - if hp <= 0 or p.fainted then return false end - if p.shownHP and p.shownHP > hp then return false end -- drain running + if self.lowHealthAlarmOn then + hp = math.max(hp, shownHP(p)) + elseif p.shownHP and p.shownHP > hp then + return false -- drain running: the HUD redraw has not happened yet + end + if hp <= 0 then return false end local px = math.max(1, math.floor(hp * 48 / math.max(1, p.mon.stats.hp))) return px < 10 end @@ -2193,10 +2290,47 @@ local function stepProgram(prog) return head end +-- Trainer-pic slides. SlideTrainerPicOffScreen (core.asm:1235) walks a +-- trainer pic off its own screen edge one tile every 2 frames (9 tiles left +-- for the player back pic, 8 tiles right for the foe), and +-- _ScrollTrainerPicAfterBattle (engine/battle/scroll_draw_trainer_pic.asm) +-- brings the beaten foe back in from the right one column every 4 frames. +-- picOff holds the live programs by slot -- "foe" = the enemy trainer pic, +-- "back" = the player's back pic -- as a screen-pixel x offset stepped +-- toward `to`; updateFx advances them, drawPicsLayer adds them, and the +-- queue rows that start them park a { wait } of the matching length. Call +-- with no target to clear a slot (#317, #282). +function BattleState:slidePic(slot, from, to, step) + self.picOff = self.picOff or {} + if to == nil then + self.picOff[slot] = nil + return + end + self.picOff[slot] = { x = from or 0, to = to, step = step or 4 } +end + +-- the live x offset for a pic slot, 0 when nothing is sliding +function BattleState:picOffset(slot) + local p = self.picOff and self.picOff[slot] + return p and p.x or 0 +end + function BattleState:updateFx() if self.introSlide and self.introSlide > 0 then self.introSlide = self.introSlide - 1 end + -- step each live trainer-pic slide toward its target; a landed program + -- holds its offset (the after-battle scroll-in rests two tiles right of + -- the battle slot) until its owner clears the slot + if self.picOff then + for _, p in pairs(self.picOff) do + if p.x < p.to then + p.x = math.min(p.to, p.x + p.step) + elseif p.x > p.to then + p.x = math.max(p.to, p.x - p.step) + end + end + end local fx = self.fx if fx then if fx.shake and fx.shake > 0 then fx.shake = fx.shake - 1 end @@ -2285,7 +2419,12 @@ function BattleState:updateFx() -- low-HP alarm (audio/low_health_alarm.asm): the two-tone siren -- loops while the player's bar is red; see lowHealthAlarmActive local Sound = require("src.core.Sound") - if self:lowHealthAlarmActive() then + -- self.lowHealthAlarmOn mirrors wLowHealthAlarm's bit 7: a latch read + -- back inside lowHealthAlarmActive (the RHS sees last frame's value) + -- so a sounding siren rides out the next hit's HP drain instead of + -- dropping out mid-announcement (#293) + self.lowHealthAlarmOn = self:lowHealthAlarmActive() + if self.lowHealthAlarmOn then Sound.startLoop(self.data, "Low_Health_Alarm") else Sound.stopLoop("Low_Health_Alarm") @@ -2922,6 +3061,16 @@ function BattleState:enemyMonFainted() local style = tostring((self.game.save.options or {}).battleStyle or "shift") :lower() local partyCount = #self.game.save.party + -- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the + -- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays + -- down under it (draw_hud_pokeball_gfx.asm:9-11, 33-45, 134-141) -- into + -- the block FaintEnemyPokemon just cleared, after the exp text and + -- BEFORE the next send-out. It survives EnemySendOutFirstMon's + -- SlideTrainerPicOffScreen (core.asm:1308-1310, 8 steps x DelayFrames 2) + -- so SET style gets the brief flash, and stays up through the whole + -- SHIFT prompt below (#283). + self:act(function() self.showEnemyBalls = true end) + table.insert(self.queue, { wait = 16 }) -- SwitchPlayerMon runs AFTER TrainerSentOutText (core.asm:1436-1443) local shiftSwitchMon = nil if style ~= "set" and partyCount > 1 and self.player.mon.hp > 0 then @@ -2957,6 +3106,10 @@ function BattleState:enemyMonFainted() }) self.aiUses = self:aiUsesFor() markSeen(self.game, self.enemy.mon.species) + -- EnemySendOutFirstMon .next4 (core.asm:1413-1417): ClearSprites and + -- the 4x11 ClearScreenArea take the ball row away with the rest of + -- the enemy HUD block, right before TrainerSentOutText (#283) + self.showEnemyBalls = nil self:markParticipant() -- EnemySendOutFirstMon (core.asm:1413-1435): the enemy HUD area -- clears, TrainerSentOutText prints, THEN the pic appears @@ -2984,6 +3137,20 @@ function BattleState:enemyMonFainted() battle = self, side = self.sides[1], battler = self.player, previous = previous, }) + -- Taking the SHIFT offer ZEROES wPartyGainExpFlags and + -- wPartyFoughtCurrentEnemyFlags before jumping to SwitchPlayerMon + -- (EnemySendOutFirstMon tail, core.asm:1436-1443), and SwitchPlayerMon + -- then FLAG_SETs only the mon coming in (core.asm:2424-2433). Without + -- the reset the mon that was out when the enemy fainted -- marked by + -- the send-out act above, which mirrors EnemySendOut's own re-flag + -- (core.asm:1276-1289) -- stayed a participant, so the exp divisor in + -- enemyMonFainted counted two mons and the switch-in earned half the + -- next KO (#275). Voluntary switches (resolveSwitch) and post-faint + -- replacements (openReplacementMenu) must NOT do this: pokered's + -- party-menu SwitchPlayerMon keeps the outgoing mon flagged, which is + -- the deliberate exp-share, and a fainted mon is already dropped by + -- onFaint mirroring RemoveFaintedPlayerMon (core.asm:1002-1007). + self.participants = {} self:markParticipant() self.nextInsert = 0 self.sendingOut = true @@ -2999,16 +3166,39 @@ function BattleState:enemyMonFainted() end local prize = (self.trainer.baseMoney or 0) * self.enemy.mon.level self.game.save.money = self.game.save.money + prize - -- the beaten trainer's pic returns for the defeat text (pokered - -- DisplayBattleMenu's defeat flow) - self:act(function() self.showEnemyTrainer = self.trainerPic ~= nil end) - -- TrainerBattleVictory (core.asm:915-933): EndLowHealthAlarm, then - -- the victory theme starts BEFORE TrainerDefeatedText and the - -- prize money + -- TrainerBattleVictory (core.asm:915-949) in order: EndLowHealthAlarm + -- and the victory theme, TrainerDefeatedText, ScrollTrainerPicAfterBattle + -- (the beaten trainer scrolls back in from the right, one column every 4 + -- frames, resting two tiles right of the battle slot), DelayFrames 40, + -- PrintEndBattleText -- the trainer's OWN loss line, on the battle + -- screen -- and only then MoneyForWinningText. Every row rides the + -- *Next inserters so it keeps that order behind the running queue item; + -- the plain act() the pic used to ride appended to the END of the queue, + -- which is why the trainer only flashed up for a frame or two as the + -- battle popped and the loss line had to be printed by the overworld + -- afterwards, stranding any evolution between two cuts (#282). + -- endBattleText is filled in by whoever started the battle + -- (OverworldState:engageTrainer in src/world/OverworldController.lua); + -- scripted battles that print their own follow-up leave it nil. self:actNext(function() self:playVictoryMusic() end) -- _TrainerDefeatedText: " defeated\nTRAINER!" self:sayNext(Strings("%s defeated\n%s!", self.game.save.player.name, self.trainer.name)) + self:actNext(function() + self.showEnemyTrainer = self.trainerPic ~= nil + if self.showEnemyTrainer then self:slidePic("foe", 64, 16, 2) end + end) + -- the 24-frame scroll-in plus the DelayFrames 40 that follows it + self.nextInsert = (self.nextInsert or 0) + 1 + table.insert(self.queue, self.nextInsert, { wait = 64 }) + if self.endBattleText then + -- PrintEndBattleText prints one text box; a `para` (\f) inside it + -- starts a fresh page, which is a message row of its own here (five + -- EndBattleTexts carry one, e.g. _Route9Youngster1EndBattleText) + for page in (self.endBattleText .. "\f"):gmatch("(.-)\f") do + if page ~= "" then self:sayNext(page) end + end + end self:sayNext(Strings("%s got ¥%d\nfor winning!", self.game.save.player.name, prize)) end self.result = "win" @@ -3076,6 +3266,15 @@ function BattleState:playerMonFainted() -- Oak's Lab starter rival: Rival1WinText only (no blackout lines). -- Any other wipe, including Route 22 RIVAL1, still blacks out. if not BattleState.isOaksLabStarterRival(self) then + -- HandlePlayerBlackOut (core.asm:1150-1159): SET_PAL_BATTLE_BLACK runs + -- BEFORE PlayerBlackedOutText2, so the enemy pic and both HP bars are + -- already dark under the blackout lines (#292). The Oak's Lab starter + -- rival returns one line above that call and never darkens. Set here + -- rather than queued: this whole function already runs from a queued + -- act after " fainted!" was dismissed, which is where the palette + -- command sits. (The Route 22 RIVAL1 wipe darkens one box early, over + -- Rival1WinText, which pokered prints just before the same command.) + self.blackedOut = true self:sayNext(Strings("%s is out of\nuseable POKéMON!", self.game.save.player.name)) self:sayNext(Strings("%s blacked\nout!", self.game.save.player.name)) end @@ -3484,13 +3683,41 @@ end -- called by BagMenu when a ball is thrown function BattleState:throwBall(ball) - self:say(Strings("%s used\n%s!", self.game.save.player.name, - self.data.items[ball].name)) + -- ItemUseBall branches to ThrowBallAtTrainerMon on wIsInBattle != 1 + -- (item_effects.asm:109-113) BEFORE it reaches `ld hl, ItemUseText00 / + -- call PrintText` (:146-147), so a trainer battle never shows the + -- " used !" line (#291). Safari and the old man demo are + -- still wIsInBattle == 1, and this port models both as kind == "wild". + if self.kind == "wild" then + self:say(Strings("%s used\n%s!", self.game.save.player.name, + self.data.items[ball].name)) + end self:act(function() require("src.core.Sound").play(self.data, "Ball_Toss") if self.kind ~= "wild" then - self:sayNext(Strings("The TRAINER\nblocked the BALL!")) - self:sayNext(Strings("Don't be a thief!")) + -- ThrowBallAtTrainerMon (item_effects.asm:2292-2303) still animates the + -- toss: MoveAnimation routes TOSS_ANIM to TossBallAnimation, which takes + -- its .BlockBall branch in a trainer battle (animations.asm:2582-2585, + -- 2629-2637) -- the plain TOSS arc whatever the ball tier, then + -- SFX_FAINT_THUD and BLOCKBALL_ANIM, and only then the two texts. The + -- ball still counts as used: UseItem_ sets + -- wActionResultOrTookBattleTurn = 1 (item_effects.asm:1-3) and this path + -- never clears it, so UseBagItem does not fall back to the bag + -- (core.asm:2257-2259) and the turn is spent -- the foe moves (#291). + local t = self.data.text + self:animNext("TOSS_ANIM", true, nil, ball) + self:actNext(function() + require("src.core.Sound").play(self.data, "Faint_Thud") + end) + self:animNext("BLOCKBALL_ANIM", true) + self:sayNext(t._ThrowBallAtTrainerMonText1 + or Strings("The trainer\nblocked the BALL!")) + self:sayNext(t._ThrowBallAtTrainerMonText2 + or Strings("Don't be a thief!")) + self:act(function() + self:executeAction(self.enemy, self.player, self:enemyAction()) + end) + self:act(function() self:endOfTurn() end) return end if self.ghost then @@ -3941,6 +4168,16 @@ function BattleState:sgbBattlePals() local pack = PaletteFX.pack(self.data) local pals = pack and pack.palettes if not pals then return nil end + -- HandlePlayerBlackOut (core.asm:1151) runs SET_PAL_BATTLE_BLACK: + -- SetPal_BattleBlack sends PalPacket_Black, PAL_BLACK in all four slots of + -- BlkPacket_Battle (engine/gfx/palettes.asm:22-25), so every zone of the + -- battle screen -- both HP bars and both mon regions -- goes dark behind + -- the blackout text. picImage re-bakes the pics through the same palette, + -- since those draw over the zone pass rather than through it (#292). + if self.blackedOut and pals.BLACK then + local b = pals.BLACK + return { [0] = b, [1] = b, [2] = b, [3] = b } + end local function bar(b) if not b then return pals.GREENBAR end local hp = b.shownHP or b.mon.hp @@ -4180,7 +4417,8 @@ function BattleState:drawPicsLayer(slide, sx, sy) local img = self:picImage(self.trainerPic) love.graphics.setColor(1, 1, 1, 1) local ex, ey = enemyPicXY(img, slide, sx, sy) - love.graphics.draw(img, ex, ey) + -- SlideTrainerPicOffScreen / _ScrollTrainerPicAfterBattle offset (#317) + love.graphics.draw(img, ex + self:picOffset("foe"), ey) elseif self.enemy and self.enemy.sprite and not self.enemyHidden and not self.enemySendingOut and not self:fxHidden(self.enemy) then local img = self:picImage(self.enemy.sprite) @@ -4223,7 +4461,9 @@ function BattleState:drawPicsLayer(slide, sx, sy) love.graphics.setColor(1, 1, 1, 1) local dx, dy = BattleState.backPlacement(img:getWidth(), img:getHeight(), pad, padL, s) - love.graphics.draw(img, dx + slide + sx, dy + sy, 0, s, s) + -- picOffset: SlideTrainerPicOffScreen walking the back pic off the left + love.graphics.draw(img, dx + slide + sx + self:picOffset("back"), + dy + sy, 0, s, s) elseif self.player and self.player.sprite and not hidePlayer and not self.sendingOut and not self:fxHidden(self.player) then local img = self:picImage(self.player.sprite) @@ -4276,9 +4516,13 @@ function BattleState:drawHUDs(slide) local hudShake = (fx and fx.hudShakeX) or 0 -- FaintEnemyPokemon clears the enemy HUD area; it stays blank through -- TrainerAboutToUseText until DrawEnemyHUDAndHPBar after the next send-out + -- ...and it is not up yet during the intro text either: a wild battle's + -- DrawEnemyHUDAndHPBar is called from _InitBattleCommon (core.asm:6763) + -- AFTER PrintBeginningBattleText returns, so "Wild X appeared!" shows the + -- player's ball row with no enemy HUD beside it (#317) if self.enemy and not self.showEnemyTrainer and not self.enemySendingOut and not self:growInScale(self.enemy) and slide == 0 - and not self.enemy.fainted then + and not self.introBalls and not self.enemy.fainted then -- enemy HUD (DrawEnemyHUDAndHPBar): name row 0, +level (4,1), -- HP bar (2,2) with the vertical tick at (1,2), underline row 3; -- AnimationShakeEnemyHUD nudges just this block via SCX @@ -4306,28 +4550,60 @@ function BattleState:drawHUDs(slide) end end + -- ReplaceFaintedEnemyMon -> DrawEnemyPokeballs (core.asm:896, + -- draw_hud_pokeball_gfx.asm:9-11 -> SetupEnemyPartyPokeballs :33-45): + -- between a KO and the next send-out the foe's ball row sits in the enemy + -- HUD block FaintEnemyPokemon cleared, over the chrome PlaceEnemyHUDTiles + -- writes with it -- the same $73 (1,2) / $74 (1,3) / $76 run / $78 tiles + -- the live HUD draws, minus the HP bar (#283). Its own block rather than + -- a third arm of showIntroBalls below: that window is DrawAllPokeballs's + -- (#317) and clears for the rest of the battle, this one reopens on every + -- enemy faint. wBaseCoordX $48 / wBaseCoordY $20 stepping -8 is screen + -- (64,16) leftward, the same row the intro draws. + if self.showEnemyBalls and self.enemyParty and slide == 0 then + hudTile(0x73, 8, 16) + hudTile(0x74, 8, 24) + for i = 2, 9 do hudTile(0x76, i * 8, 24) end + hudTile(0x78, 80, 24) + love.graphics.setColor(1, 1, 1, 1) + self:drawBallRow(self.enemyParty, 64, 16, -8) + end + -- Safari shows only the ball count; the old man demo shows neither mon if self.safari then love.graphics.setColor(0, 0, 0, 1) Font.draw(("BALLx%2d"):format(self.safari.balls), 88, 72) end - -- trainer/link party pokeball rows during the intro - -- (SetupPlayerAndEnemyPokeballs, draw_hud_pokeball_gfx.asm) - local showIntroBalls = slide == 0 and ( - (self.kind == "trainer" and (self.showEnemyTrainer or self.showPlayerBack)) - or (self.kind == "link" and (self.showPlayerBack or self.enemySendingOut)) - ) + -- Party pokeball rows and the HUD chrome under them, for exactly the + -- window DrawAllPokeballs owns (common_text.asm:27, with the intro text). + -- SetupOwnPartyPokeballs runs in EVERY battle, so the player's row belongs + -- on the wild intro too -- keying it off the enemy trainer pic meant a + -- wild battle never drew one (#317) -- and SetupEnemyPartyPokeballs is + -- skipped when wIsInBattle == 1, so only a trainer/link battle gets the + -- foe's row. Gating on introBalls rather than on the pics also stops the + -- rows coming back when the beaten trainer scrolls in (#282): + -- _ScrollTrainerPicAfterBattle redraws tilemap columns and never touches + -- OAM, which ClearSprites emptied when the intro text was dismissed. + local showIntroBalls = self.introBalls and slide == 0 if showIntroBalls then - love.graphics.setColor(1, 1, 1, 1) - if self.enemyParty and ( - (self.kind == "trainer" and self.showEnemyTrainer) - or (self.kind == "link" and self.enemySendingOut) - ) then + if self.enemyParty and (self.kind == "trainer" or self.kind == "link") then + -- PlaceEnemyHUDTiles (hlcoord 1,2): $73, then $74 + 8x $76 + $78 + -- rightward along row 3 (draw_hud_pokeball_gfx.asm:133-165) + hudTile(0x73, 8, 16) + hudTile(0x74, 8, 24) + for i = 2, 9 do hudTile(0x76, i * 8, 24) end + hudTile(0x78, 80, 24) + love.graphics.setColor(1, 1, 1, 1) self:drawBallRow(self.enemyParty, 64, 16, -8) end - if self.showPlayerBack then - self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8) - end + -- PlacePlayerHUDTiles (hlcoord 18,10): $73, then $77 + 8x $76 + $6F + -- LEFTWARD along row 11 (draw_hud_pokeball_gfx.asm:119-131) + hudTile(0x73, 144, 80) + hudTile(0x77, 144, 88) + for i = 10, 17 do hudTile(0x76, i * 8, 88) end + hudTile(0x6F, 72, 88) + love.graphics.setColor(1, 1, 1, 1) + self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8) end local hidePlayer = self.safari or self.demo if self.player and not hidePlayer and not self.showPlayerBack @@ -4377,9 +4653,10 @@ function BattleState:drawTextArea() Font.drawCode(line[i], 8 + (i - 1) * 8, y) end end - -- the blinking down arrow ('▼', glyph $EE) while a \v CONT wait holds the - -- box, bottom-right of the box like TextBox / home/text.asm - if self.msgWaiting and self.frame % 60 < 30 then + -- the blinking down arrow ('▼', glyph $EE) while a \v CONT wait + -- (_ContText) or a typed-out page (PromptText) holds the box; both write + -- it at (18,16), bottom-right, like TextBox / home/text.asm (#317) + if (self.msgWaiting or self.msgPrompt) and self.frame % 60 < 30 then Font.drawCode(0xEE, (0 + 20 - 2) * 8, (12 + 6 - 1) * 8 - 4) end elseif self.phase == "menu" and self.demo then @@ -4419,6 +4696,18 @@ function BattleState:drawTextArea() -- the move box's top border ('─' at (4,12), '┘' at (10,12)). Font.drawBox(0, 8, 11, 5) Font.drawBox(4, 12, 16, 6) + -- Those two cells are REPLACED on hardware: MoveSelectionMenu writes them + -- straight into the tilemap over the border it just laid down + -- (core.asm:2492-2501), and PrintMenuItem's own TextBoxBorder then redraws + -- the whole row on top (core.asm:2838-2844). Font.drawCode blits a + -- black-on-transparent glyph instead, so the tile underneath survives: the + -- move box's '┌' keeps its Poké Ball corner showing through the '─', and + -- the '─' the move box drew at (10,12) pokes two dots out from under the + -- '┘' (#240). Wipe each cell back to box white first, the way a tilemap + -- write does. + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 32, 96, 8, 8) + love.graphics.rectangle("fill", 80, 96, 8, 8) Font.drawCode(Font.BORDER.h, 32, 96) Font.drawCode(Font.BORDER.br, 80, 96) love.graphics.setColor(0, 0, 0, 1) diff --git a/src/battle/TrainerAI.lua b/src/battle/TrainerAI.lua index 09308ed5..08b9aca0 100644 --- a/src/battle/TrainerAI.lua +++ b/src/battle/TrainerAI.lua @@ -16,7 +16,7 @@ -- the MINIMUM-scored move is chosen, ties broken uniformly among the -- tied minima (core.asm:2971-3002). A non-minimal move is never -- selectable. Respects Disable (and PP only when the ruleset depletes --- enemy PP — Gen 1 AI never reads wEnemyMonPP). +-- enemy PP -- Gen 1 AI never reads wEnemyMonPP). local TypeChart = require("src.battle.TypeChart") local Strings = require("src.core.Strings") diff --git a/src/core/Input.lua b/src/core/Input.lua index ed782be4..d10feef1 100644 --- a/src/core/Input.lua +++ b/src/core/Input.lua @@ -144,7 +144,7 @@ function Input:step() elseif next(sources) ~= nil then self.state[btn] = true end - -- sources == {}: real press fully released before this step — keep up + -- sources == {}: real press fully released before this step -- keep up end for btn, sources in pairs(self.sources) do if next(sources) == nil then diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 3d540cc7..6ad836c1 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -16,6 +16,7 @@ local SaveSerializer = require("src.core.SaveSerializer") local Runtime = require("src.mods.Runtime") local Semver = require("src.mods.Semver") local Boxes = require("src.pokemon.Boxes") +local Stats = require("src.pokemon.Stats") local Bag = require("src.inventory.Bag") local Badges = require("src.inventory.Badges") @@ -972,6 +973,17 @@ local function scrubKnownMon(mon, data) for stat, v in pairs(mon.statExp) do mon.statExp[stat] = clamp(v, 0, 65535, 0) end end mon.level = clamp(mon.level, 1, 100, 1) + -- Box mons imported from a real .sav carry NO stat block: box_struct stops + -- before MON_LEVEL/MON_STATS, so src/save_convert/GenSave.lua decodeMon + -- only fills `stats` for party slots. Every HP-bar draw then nil-indexes + -- mon.stats: the status screen opened in the box (#233) and the party list + -- after withdrawing one (#304). The original derives them on demand + -- (status_screen.asm:66-76, add_mon.asm _MoveMon); deriving once here means + -- every later reader (menus, battle, items, SGB bar zones, the link + -- fingerprint) sees a party-shaped mon. Runs after the level clamp above + -- so the derived stats use a sane level. A save that already has stats is + -- untouched. + Stats.ensure(data.pokemon and data.pokemon[mon.species], mon) local moves = mon.moves if type(moves) ~= "table" then return end local hadMoves = #moves > 0 @@ -1216,7 +1228,7 @@ function SaveData.newGame(boot) inventory = {}, -- Vanilla Gen1 seeds one Potion in the player's item PC -- (wBoxItems / players_pc.asm); existing saves keep whatever they - -- already have — this only applies to New Game. + -- already have -- this only applies to New Game. pcItems = { POTION = 1 }, party = {}, box = {}, diff --git a/src/import/CacheFs.lua b/src/import/CacheFs.lua index a6ce71e9..89166851 100644 --- a/src/import/CacheFs.lua +++ b/src/import/CacheFs.lua @@ -75,6 +75,32 @@ local function resolveMkdir() return mkdirFn end +-- Lazily-resolved windowless rmdir, the mirror of resolveMkdir above: +-- function(absolutePath) or false when FFI is unavailable. Both syscalls +-- refuse a non-empty directory, so a caller has to delete the files first. +local rmdirFn = nil + +local function resolveRmdir() + if rmdirFn ~= nil then return rmdirFn end + rmdirFn = false + local ok, ffi = pcall(require, "ffi") + if not ok then return rmdirFn end + if ffi.os == "Windows" then + pcall(ffi.cdef, "int RemoveDirectoryA(const char *lpPathName);") + local resolved = pcall(function() return ffi.C.RemoveDirectoryA end) + if resolved then + rmdirFn = function(path) pcall(ffi.C.RemoveDirectoryA, path) end + end + else + pcall(ffi.cdef, "int rmdir(const char *pathname);") + local resolved = pcall(function() return ffi.C.rmdir end) + if resolved then + rmdirFn = function(path) pcall(ffi.C.rmdir, path) end + end + end + return rmdirFn +end + -- Mount an external directory onto the physfs read path (appended, so the -- game's own source always wins a name clash). Returns true on success. -- @@ -229,6 +255,23 @@ function CacheFs.remove(rel) love.filesystem.remove(rel) end +-- Remove a single cache-relative directory once its files are gone. Needed +-- because os.remove cannot delete a directory on Windows and +-- love.filesystem.remove never reaches outside the save directory, so the +-- portable game folder gets the same FFI-syscall treatment as its mkdir +-- (issue #74: os.execute would flash a console window per call). Used by the +-- mod installer so an uninstall leaves nothing behind (#330). +function CacheFs.removeDir(rel) + rel = withPrefix(rel) + local root = CacheFs.root() + if root then + local rmdir = resolveRmdir() + if rmdir then rmdir(realPath(root, rel)) end + return + end + love.filesystem.remove(rel) +end + -- Remove the game-folder copy of a cache subtree before a fresh import, so a -- cache-format bump does not leave orphaned files behind. No-op when the -- portable cache is inactive (the save-directory copy is cleared by diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 147ff030..c79e2eb9 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -255,7 +255,36 @@ local function fileUrl(path) return "file://" .. encoded end +-- The native pickers below block the whole loop inside io.popen, and they are +-- opened straight out of mousepressed -- with the button still physically +-- down. SDL auto-captures the pointer for the length of a press (on X11 an +-- XGrabPointer with owner_events) and only drops that capture when it +-- processes the matching button-up, which it cannot do while we sit in popen +-- and never pump. The grab then outlives the click and every pointer event +-- over the file chooser is still routed to our window: the dialog draws and +-- keyboard-navigates (keyboard focus is a separate grab) but ignores the +-- mouse entirely -- issue #254 on Linux. Whether it bites is a race with how +-- long the click was held, which is why the same build picks one ROM fine and +-- then hangs the mouse on the next. So pump until no button is held, letting +-- SDL see the release and let go first; bounded, so a stuck button costs a +-- moment and never the launcher. pump() only drains OS events into LOVE's +-- queue -- it dispatches nothing -- so there is no reentry into mousepressed +-- and the release is still delivered normally on the next frame. +local function releasePointerGrab() + if not (love.mouse and love.mouse.isDown and love.event and love.event.pump + and love.timer) then + return + end + local deadline = love.timer.getTime() + 1 + while love.mouse.isDown(1, 2, 3) do + love.event.pump() + if love.timer.getTime() > deadline then break end + love.timer.sleep(0.005) + end +end + local function commandOutput(command) + releasePointerGrab() local pipe = io.popen(command, "r") if not pipe then return nil end local result = pipe:read("*a") @@ -2476,7 +2505,7 @@ function RomImporter:_drawModsPanel(x, y, w, h) love.graphics.printf(m.description, nx, ny + nameH + 6 * s, L.leftW, "left") end - -- right cluster: status chip, toggle, Delete — vertically centred + -- right cluster: status chip, toggle, Delete -- vertically centred local clusterX = x + w - padH - L.clusterW local clusterY = cy + (cardH - clusterH) / 2 local _, chipColor = modStatusChip(m.status) diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index 193b6ece..484bcc67 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -47,6 +47,17 @@ ItemEffects.BALLS = BALLS function ItemEffects.isBall(id) return BALLS[id] or false end function ItemEffects.isStone(id) return STONES[id] or false end +-- Does using this item take item_effects.asm's .healHP path, the one that +-- plays SFX_HEAL_HP and lengthens the party HP bar with UpdateHPBar2 before +-- the message (item_effects.asm .doneHealing)? The status-only cures branch +-- to .playStatusAilmentCuringSound instead and never touch the bar. BagMenu +-- keeps the party picker open for these so the fill has something to draw +-- on (#252). +function ItemEffects.healsHP(id) + return HEAL_AMOUNT[id] ~= nil or id == "MAX_POTION" or id == "FULL_RESTORE" + or id == "REVIVE" or id == "MAX_REVIVE" +end + -- Does this item need a party-member target? function ItemEffects.needsTarget(id, itemDef) return HEAL_AMOUNT[id] or STATUS_HEAL[id] or id == "MAX_POTION" @@ -257,6 +268,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) if not target or target.hp <= 0 or target.hp >= target.stats.hp then return "failed", { Strings("It won't have\nany effect.") } end + -- wHPBarOldHP: the bar animation starts from the HP the mon had BEFORE + -- the item landed (item_effects.asm latches it with the party menu still + -- up), so latch it here and hand it back as extra.healedFrom for the + -- party-menu fill (#252) + local before = target.hp if itemId == "MAX_POTION" or itemId == "FULL_RESTORE" then target.hp = target.stats.hp else @@ -268,7 +284,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) cureActiveToxic(battle, target) end require("src.core.Sound").play(data, "Heal_HP") - return "consumed", msgs + return "consumed", msgs, { healedFrom = before } end local cures = STATUS_HEAL[itemId] @@ -289,7 +305,10 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) target.status = nil target.hp = itemId == "REVIVE" and math.floor(target.stats.hp / 2) or target.stats.hp require("src.core.Sound").play(data, "Heal_HP") - return "consumed", { Strings("%s\nis revitalized!", monName(data, target)) } + -- a revive takes the same .healHP -> .doneHealing route, animating up + -- from the fainted mon's 0 HP (#252) + return "consumed", { Strings("%s\nis revitalized!", monName(data, target)) }, + { healedFrom = 0 } end if itemId == "RARE_CANDY" then diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index 47d90488..2e81e0a2 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -3,8 +3,18 @@ -- manifests only. The full loader (src/mods/Loader.lua) still owns the real -- load at boot; this reads the same options.mods enable-state the loader -- writes, derives per-mod status with the pure ManagerState.resolveToggle, --- installs a dropped/chosen .zip into the save-dir "mods//" tree, and --- uninstalls a mod by removing that tree + clearing options.mods[id]. +-- installs a dropped/chosen .zip into a "mods//" tree, and uninstalls a +-- mod by removing that tree + clearing options.mods[id]. +-- +-- Where that tree lives is CacheFs's call, not love.filesystem's: a portable +-- install (portable.txt beside the executable) keeps its mods in the game +-- folder like everything else it owns, and only the OS save directory +-- otherwise (#330 -- love.filesystem.write always resolves to the save dir, +-- so the installer used to strand every mod in appdata). Reads stay on +-- love.filesystem: the portable folder is on the physfs read path either way +-- (it IS the source for a `love ` run, and CacheFs mounts it for a +-- fused build), which is why those mods still loaded while landing in the +-- wrong place. -- -- Split in two: the pure derivation (deriveList, locateRoot) has no love and -- no filesystem, so the engine tier can table-drive it; the discovery, @@ -15,6 +25,7 @@ local ManagerState = require("src.mods.ManagerState") local Semver = require("src.mods.Semver") local Version = require("src.core.Version") local SaveData = require("src.core.SaveData") +local CacheFs = require("src.import.CacheFs") local LauncherMods = {} @@ -143,6 +154,13 @@ local function discover() local fs = love and love.filesystem local out = {} if not (fs and fs.getInfo and fs.getDirectoryItems) then return out end + -- A fused portable build keeps its mods in the game folder next to the + -- executable; resolving the cache root is what mounts that folder onto the + -- physfs read path, so this is what makes those mods enumerable at all + -- (#330). A source run needs nothing (the game folder IS the source), and + -- the launcher's readiness check has usually resolved it already; the call + -- is cached and idempotent. + CacheFs.root() if not fs.getInfo("mods") then return out end local seen = {} for _, name in ipairs(fs.getDirectoryItems("mods")) do @@ -233,11 +251,14 @@ local function topLevelPaths(mount) return paths end +-- Copy the mounted archive subtree at `src` to the install path `dst`. Reads +-- come from love.filesystem (the .zip is mounted there); every write goes +-- through CacheFs so it lands in the portable game folder when portable.txt is +-- in play and in the OS save directory otherwise (#330). No explicit mkdir: +-- CacheFs.write creates the parent chain on both paths, which also means an +-- empty folder inside the .zip is simply not carried over (it holds nothing). local function copyTree(src, dst) local fs = love.filesystem - if not fs.createDirectory(dst) then - return nil, "could not create " .. dst - end for _, name in ipairs(fs.getDirectoryItems(src)) do local s = src .. "/" .. name local d = dst .. "/" .. name @@ -248,13 +269,18 @@ local function copyTree(src, dst) else local data = fs.read(s) if data == nil then return nil, "could not read " .. name end - local ok, err = fs.write(d, data) + local ok, err = CacheFs.write(d, data) if not ok then return nil, "could not write " .. name .. ": " .. tostring(err) end end end return true end +-- Delete an installed mod subtree. Enumeration stays on love.filesystem (the +-- portable game folder is on its read path), but the deletes go through +-- CacheFs so a portable install's real files actually go away instead of +-- love.filesystem no-opping outside the save directory (#330). Directories +-- are removed after their children, since rmdir refuses a non-empty one. local function removeTree(path) local fs = love.filesystem local info = fs.getInfo(path) @@ -263,7 +289,15 @@ local function removeTree(path) for _, child in ipairs(fs.getDirectoryItems(path)) do removeTree(path .. "/" .. child) end + CacheFs.removeDir(path) + else + CacheFs.remove(path) end + -- A portable install can still be carrying a pre-#330 copy in the OS save + -- directory, which is where every install used to land and which physfs + -- searches first. CacheFs only touched the game folder, so clear the + -- save-directory twin too or that copy would keep the mod alive; outside + -- portable mode this repeats the delete CacheFs just did and no-ops. fs.remove(path) end @@ -322,10 +356,19 @@ function LauncherMods.installZip(source) return nil, "a mod named '" .. manifest.id .. "' is already installed" end - fs.createDirectory("mods") + -- CacheFs.prefix steers ROM-cache writes into a version subtree (blue/...); + -- the mods tree is shared by Red and Blue, so pin the prefix to the root for + -- the copy and the rollback, then hand back whatever the launcher had set + -- (an import coroutine leaves it pointed at that version -- RomImporter.lua). + -- No fs.createDirectory("mods") here any more: CacheFs.write creates the + -- parent chain in both homes, and doing it through love.filesystem would + -- only ever make the directory in the save dir (#330). + local savedPrefix = CacheFs.prefix + CacheFs.prefix = "" local copied, copyErr = copyTree(root, dest) + if not copied then removeTree(dest) end + CacheFs.prefix = savedPrefix if not copied then - removeTree(dest) cleanup() return nil, copyErr or "could not copy the mod files" end @@ -334,8 +377,9 @@ function LauncherMods.installZip(source) end -- uninstall(id) -> true | nil, errString --- Removes mods// from the save directory and clears options.mods[id] so the --- loader and in-game manager no longer see it. Rejects unknown / missing ids. +-- Removes mods// from wherever it was installed (the portable game folder +-- or the save directory, CacheFs decides -- #330) and clears options.mods[id] +-- so the loader and in-game manager no longer see it. Rejects missing ids. -- Does not touch other mods' enable state. function LauncherMods.uninstall(id) if type(id) ~= "string" or id == "" then @@ -352,7 +396,11 @@ function LauncherMods.uninstall(id) if not fs.getInfo(dest) then return nil, "mod '" .. id .. "' is not installed" end + -- same root pin as installZip: the mods tree is not version-prefixed (#330) + local savedPrefix = CacheFs.prefix + CacheFs.prefix = "" removeTree(dest) + CacheFs.prefix = savedPrefix -- Drop the enable flag so a reinstall of the same id starts from the -- loader's default (enabled) rather than a stale false. local options = SaveData.loadOptions() diff --git a/src/pokemon/Evolution.lua b/src/pokemon/Evolution.lua index a740ff50..c2df1741 100644 --- a/src/pokemon/Evolution.lua +++ b/src/pokemon/Evolution.lua @@ -187,7 +187,7 @@ end -- After-battle hook: evolve mons that leveled this battle and still -- qualify (queued one at a time, party order). Gen1 EvolveAfterBattle --- only considers mons that gained a level during the fight — a B-cancel +-- only considers mons that gained a level during the fight -- a B-cancel -- means "not this time", and the next offer waits for the next level-up -- (or Rare Candy / stone, which call Evolution.evolve directly). -- leveledUp is a set of party mon tables; nil/empty yields no evolutions. diff --git a/src/pokemon/Pokemon.lua b/src/pokemon/Pokemon.lua index 8330ed8b..6f634607 100644 --- a/src/pokemon/Pokemon.lua +++ b/src/pokemon/Pokemon.lua @@ -31,7 +31,7 @@ end -- Day Care retrieve (pokered WriteMonMoves + wLearningMovesFromDayCare): -- grant learnset moves with startLevel < moveLevel <= newLevel, shifting --- the oldest slot out when full. Silent — no LearnMove prompts. +-- the oldest slot out when full. Silent -- no LearnMove prompts. function Pokemon.learnMovesFromDayCare(data, mon, speciesDef, startLevel, newLevel) if not (speciesDef and speciesDef.learnset and mon) then return end mon.moves = mon.moves or {} diff --git a/src/pokemon/Stats.lua b/src/pokemon/Stats.lua index 169c275b..791778e4 100644 --- a/src/pokemon/Stats.lua +++ b/src/pokemon/Stats.lua @@ -42,6 +42,28 @@ function Stats.calc(speciesDef, level, dvs, statExp) return out end +-- Give a mon a stat block when it has none. A real Gen 1 box_struct is a +-- byte-for-byte PREFIX of party_struct that stops before MON_LEVEL and +-- MON_STATS (macros/ram.asm box_struct / party_struct), so mons decoded out +-- of an imported .sav arrive without one (src/save_convert/GenSave.lua +-- decodeMon, isParty = false). The original derives them on demand at +-- exactly two moments: when a box or daycare mon's status screen opens +-- (engine/pokemon/status_screen.asm:66-76, "mon is in a box or daycare" -> +-- CalcStats) and when one is moved back into the party +-- (engine/pokemon/add_mon.asm _MoveMon tail). The stored current HP is +-- kept (box_struct does hold it) but clamped to the recalculated maximum so +-- a tampered save cannot overfill the bar. A mon that already has stats is +-- returned untouched, so a vanilla save round-trips. #233, #304 +function Stats.ensure(speciesDef, mon) + if type(mon) ~= "table" or type(mon.stats) == "table" then return mon end + if type(speciesDef) ~= "table" or type(speciesDef.baseStats) ~= "table" then + return mon + end + mon.stats = Stats.calc(speciesDef, mon.level or 1, mon.dvs or {}, mon.statExp) + mon.hp = math.max(0, math.min(tonumber(mon.hp) or mon.stats.hp, mon.stats.hp)) + return mon +end + -- Battle stat stage multipliers (data/battle/stat_modifiers.asm): stages -- -6..+6 map to N/D pairs 25/100 .. 400/100. local STAGE_MULT = { diff --git a/src/render/BattleTransition.lua b/src/render/BattleTransition.lua index f83c6ac3..ab5d8e29 100644 --- a/src/render/BattleTransition.lua +++ b/src/render/BattleTransition.lua @@ -23,6 +23,20 @@ local FLASH_STEPS = { 1 / 3, 2 / 3, 1, 2 / 3, 1 / 3, 0, local FLASH_HOLD = 2 -- frames per palette step local FLASH_CYCLES = 3 +-- The screen stays black after the wipe lands. Shrink and Split ask for it +-- outright (BattleTransition_BlackScreen, then `ld c, 10 / jp DelayFrames`: +-- battle_transitions.asm:390-392 and 422-424); the other six get the same gap +-- for free, because BattleTransition_BlackScreen has already set rBGP/rOBP0/ +-- rOBP1 to $ff (:168-174) while DoBattleTransitionAndInitBattleVariables +-- reloads the HUD tile patterns and clears the screen (core.asm:6152-6185), +-- InitBattleCommon decompresses the front pic (core.asm:6694-6730), and +-- SlidePlayerAndEnemySilhouettesOnScreen rebuilds the whole tilemap between +-- DisableLCD and EnableLCD (core.asm:9-49) before anything moves. This port +-- has no load to hide behind, so the hold has to be explicit (#315). 30 is a +-- frame budget for that work, not a number pokered states; retune this one +-- constant against a reference recording if it reads long or short. +local BLACK_HOLD = 30 + local TILE = 8 local COLS, ROWS = 160 / TILE, 144 / TILE -- 20 x 18 tiles @@ -208,7 +222,7 @@ function BattleTransition:update(dt) self.t = 0 end else - if self.t >= self.wipeLen + 6 then + if self.t >= self.wipeLen + BLACK_HOLD then self.game.stack:pop() if self.onDone then self.onDone() end end diff --git a/src/render/HudTiles.lua b/src/render/HudTiles.lua index ebc24532..e813930b 100644 --- a/src/render/HudTiles.lua +++ b/src/render/HudTiles.lua @@ -2,6 +2,11 @@ -- screen: pokered overlays the $62-$7F font area with the HP bar / -- status sheet (font_battle_extra -> $62) and the HUD line tiles -- (battle_hud_1 -> $6D, battle_hud_2+3 -> $73). +-- +-- The two screens do NOT use the same overlay: the status screen scatters +-- hud_2 and hud_3 instead of copying them contiguously, which is what keeps +-- its № and glyphs alive. HudTiles.tile draws the battle layout, +-- HudTiles.statusTile the status one -- see STATUS_PAGES below. #280 local Assets = require("src.render.Assets") @@ -23,32 +28,62 @@ local PAGES = { image = "assets/generated/battle/battle_hud_3.png", base = 0x76 }, } -local tiles -function HudTiles.tile(code, x, y, tint) - if not tiles then - tiles = {} - local registered = require("src.core.Data").font - registered = registered and registered.pages or nil - local function add(path, base) - local ok, img = pcall(Assets.image, path) - if not ok then return end +-- The STATUS SCREEN overlays the SAME sheets differently, and the layout +-- above would break it: engine/pokemon/status_screen.asm:86-97 copies 3 +-- tiles of hud_1 to $6D, ONE tile of hud_2 to $78 and 2 tiles of hud_3 to +-- $76, which leaves $70/$73/$74 as font_battle_extra's , and № -- +-- the glyphs the screen prints "№." and "№/" from +-- (constants/charmap.asm:69-73). The battle overlay instead copies +-- hud_2+hud_3 contiguously over $73-$78 (engine/battle/core.asm:6520/6532), +-- burying № under a line tile, so the status screen needs its own table. +-- The line glyphs land identically either way -- $76 ─, $77 ┘, $6F the +-- halfarrow -- only the vertical bar moves ($73 in battle, $78 here). #280 +local STATUS_PAGES = { + { id = "font_battle_extra", + image = "assets/generated/battle/font_battle_extra.png", base = 0x62 }, + { id = "battle_hud_1", + image = "assets/generated/battle/battle_hud_1.png", base = 0x6D }, + { id = "battle_hud_3", + image = "assets/generated/battle/battle_hud_3.png", base = 0x76, count = 2 }, + { id = "battle_hud_2", + image = "assets/generated/battle/battle_hud_2.png", base = 0x78, count = 1 }, +} + +local tiles, statusTiles + +-- Build one code -> {img, quad} map from a page list. `count` caps a page +-- at the number of tiles the asm actually copies (the extracted sheets all +-- carry 3 tiles; the status overlay uses fewer). A mod's registered page +-- swaps the image in either table, but only the battle table honors its +-- `base`: the status layout is the asm's own placement, and sliding hud_2 +-- there would bury № again. +local function build(pages, fixedBase) + local out = {} + local registered = require("src.core.Data").font + registered = registered and registered.pages or nil + for _, page in ipairs(pages) do + local override = registered and registered[page.id] + local path, base = page.image, page.base + if override and override.image then path = override.image end + if not fixedBase and override and override.base then base = override.base end + local ok, img = pcall(Assets.image, path) + if ok then local iw, ih = img:getDimensions() local per = iw / 8 - for i = 0, per * (ih / 8) - 1 do - tiles[base + i] = { + local count = page.count or per * (ih / 8) + for i = 0, count - 1 do + out[base + i] = { img = img, quad = love.graphics.newQuad((i % per) * 8, math.floor(i / per) * 8, 8, 8, iw, ih), } end end - for _, page in ipairs(PAGES) do - local override = registered and registered[page.id] - add(override and override.image or page.image, - override and override.base or page.base) - end end - local t = tiles[code] + return out +end + +local function put(t, x, y, tint) if not t then return end local r, g, b, a = love.graphics.getColor() love.graphics.setColor(tint or { 1, 1, 1, 1 }) @@ -56,9 +91,23 @@ function HudTiles.tile(code, x, y, tint) love.graphics.setColor(r, g, b, a) end +function HudTiles.tile(code, x, y, tint) + if not tiles then tiles = build(PAGES) end + put(tiles[code], x, y, tint) +end + +-- The same sheets under the status screen's overlay (STATUS_PAGES). The HP +-- bar codes $62-$6D are identical in both layouts, so drawHPBar below keeps +-- using the battle table. #280 +function HudTiles.statusTile(code, x, y, tint) + if not statusTiles then statusTiles = build(STATUS_PAGES, true) end + put(statusTiles[code], x, y, tint) +end + -- lazy: the next tile() rebuilds every page from the search path function HudTiles.invalidate() tiles = nil + statusTiles = nil end Assets.register(HudTiles.invalidate) diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 15952f90..4891f446 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -93,6 +93,25 @@ function PaletteFX.ogObj() return PaletteFX.GBC_OBJ, "gbcobj" end +-- The DMG object ramp every mode except OG RED bakes onto overworld sprites, +-- plus its cache group (same two-value contract as ogObj). Entry 1 is never +-- read -- SpriteRenderer.getObpImage keys OBJ color 0 to alpha, the hardware's +-- unconditional OBJ transparency -- and entries 2..4 are OBJ colors 1..3 sent +-- through rOBP0 = $D0 (home/fade.asm FadePal4 `dc 3,1,0,0`, the entry +-- LoadGBPal reads while wMapPalOffset is 0): color 1 -> DMG shade 0, color 2 +-- -> shade 1, color 3 -> shade 3. Leaving the result in DMG shades is the +-- whole point: the zone shader then colors a character out of the same map +-- palette it colors the ground with, which is all the Super Game Boy can do to +-- an OBJ (#301), and the OBP0 lift is what puts Red's cap on the ROUTE +-- palette's grass green instead of its light-blue (#150). +PaletteFX.OBP0_SHADES = { + { 255, 255, 255 }, { 255, 255, 255 }, { 170, 170, 170 }, { 0, 0, 0 }, +} + +function PaletteFX.dmgObj() + return PaletteFX.OBP0_SHADES, "obp0" +end + local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 } function PaletteFX.shader() @@ -207,32 +226,45 @@ function PaletteFX.usesGbcPack(mode) end -- Whether the active mode bakes a per-OBJ palette onto overworld sprites --- (the OBP bake + post-zone redraw path). OG RED and SGB both do: characters --- wear the GBC boot-ROM object palette (PaletteFX.ogObj -- green over Red's red --- background, pink over Blue's blue background), so the player and NPCs carry a --- fixed object color instead of tinting with whatever region palette their --- feet stand over. On real hardware a sprite is an OBJ colored by an OBJ --- palette (color/sprites.asm ColorOverworldSprite), distinct from the BG it --- overlaps -- so Red's cap must stay green in tall grass, not turn the ROUTE --- palette's light-blue (issue #150: SGB region-tinting sent the cap to shade-2 --- = light-blue and the character clashed with the grass it should blend into). --- Terrain is unaffected -- pal() below still hands SGB its per-map BG palette; --- only OG RED short-circuits BG to the one global red palette. An EARLIER --- attempt at per-sprite SGB color baked GBC_BG (the RED background ramp) onto +-- (the OBP bake + post-zone redraw path). OG RED alone does: the Game Boy +-- Color boot ROM hands the game one global object palette (PaletteFX.ogObj -- +-- green over Red's red background, pink over Blue's blue background), so on +-- that machine the player and NPCs carry a fixed object color instead of +-- tinting with whatever region palette their feet stand over. An EARLIER +-- attempt at per-sprite color baked GBC_BG (the RED background ramp) onto -- characters -- that was the "reds coloring on the player/NPCs" bug; the object --- palette is GBC_OBJ (green), so baking it here is the fix, not that --- regression. RED++ colors sprites through the usesGbcPack() path in --- SpriteRenderer instead. +-- palette is GBC_OBJ (green), so baking that here is the fix, not that +-- regression. Terrain is unaffected -- pal() below still hands SGB its per-map +-- BG palette; only OG RED short-circuits BG to the one global red palette. +-- +-- SGB does NOT. The Super Game Boy colorizes the composited DMG picture it is +-- handed and cannot tell an OBJ pixel from a BG one; pokered never sends the +-- OBJ_TRN packet that would enable SGB sprite mode (data/sgb/sgb_packets.asm +-- defines ATTR_BLK / PAL_SET / PAL_TRN / MLT_REQ / CHR_TRN / PCT_TRN and +-- nothing else), so a character there wears the very palette its map does. +-- Baking GBC_OBJ over it was issue #301 ("people in SGB mode are green": the +-- boot-ROM greens sat on top of ROUTE's own greens and blues). What issue +-- #150 actually caught was the missing rOBP0 step, not a missing object +-- palette: overworld OBJs run through OBP0 = $D0 (home/fade.asm FadePal4 +-- `dc 3,1,0,0`), which lifts OBJ color 1 to DMG shade 0 and color 2 to shade 1, +-- so Red's cap lands on the ROUTE palette's shade-1 GRASS GREEN and blends into +-- the grass exactly as #150's reference shot shows. Drawn with an identity +-- shade map it landed on shade 2 = light-blue instead, which is the clash #150 +-- reported. SpriteRenderer bakes that OBP0 ramp (PaletteFX.dmgObj) and lets +-- the zone shader color the result. RED++ colors sprites through the +-- usesGbcPack() path in SpriteRenderer instead. function PaletteFX.usesSpriteObp(mode) mode = mode or PaletteFX.mode - return mode == "ogred" or mode == "gbc" + return mode == "ogred" end --- ------- post-zone sprite redraw (GBC mode) +-- ------- post-zone sprite redraw (OG RED) -- --- In GBC mode the world canvas still runs through the per-map zone +-- In OG RED the world canvas still runs through the whole-screen zone -- shade-remap shader, which would corrupt an OBP-baked sprite's true-color --- pixels. So SpriteRenderer draws the baked sprite into the canvas (its +-- pixels. (SGB used to come through here too; it no longer bakes an object +-- palette at all, so its characters are colorized by the zone like the ground +-- they stand on and never queue a replay -- see usesSpriteObp, #301.) So SpriteRenderer draws the baked sprite into the canvas (its -- pixels come out zone-tinted there) AND records the draw here; -- Renderer:endFrame replays the list on top of the finished zone pass, -- scaled into screen space -- the GBC's OBJ-over-BG compositing, one draw @@ -534,6 +566,34 @@ function PaletteFX.permute(colors, map) colors[map[2] + 1], colors[map[3] + 1] } end +-- ------- global shade map (rBGP) +-- +-- home/fade.asm's LoadGBPal writes ONE rBGP for the whole screen, indexing +-- FadePal4 - wMapPalOffset. A dark cave sets wMapPalOffset = 6 +-- (home/overworld.asm's ROCK_TUNNEL_1F check; the value rides in the extracted +-- field.darkMaps.palOffset), which lands on FadePal2 = `dc 3,3,3,2`: DMG white +-- drops to shade 2 and every darker shade goes to shade 3. So the WHOLE +-- screen darkens -- the original never cuts a window of light around the +-- player (#322) -- and FLASH clears wMapPalOffset again +-- (engine/menus/start_sub_menus.asm .flash). +-- +-- Renderer:beginFrame clears this every frame and the state that draws a dark +-- map re-arms it while it draws, so it can never outlive the map it belongs +-- to: a battle or a full-screen menu draws with no map beneath it and comes +-- out lit, exactly like init_battle_variables.asm's `ld [wMapPalOffset], a` +-- leaves the original. +PaletteFX.DARK_BGP = { [0] = 2, [1] = 3, [2] = 3, [3] = 3 } + +local shadeMap = nil + +function PaletteFX.setShadeMap(map) + shadeMap = map +end + +function PaletteFX.shadeMap() + return shadeMap +end + function PaletteFX.setMode(mode) local prev = PaletteFX.mode local ok = false @@ -623,16 +683,22 @@ end function PaletteFX.effectiveColors(c) if not c then return nil end local mode = PaletteFX.mode or "gbc" + local out = c if mode == "og" then - return PaletteFX.GRAYS + out = PaletteFX.GRAYS elseif mode == "og_inv" then - return PaletteFX.permute(PaletteFX.GRAYS, INV_MAP) + out = PaletteFX.permute(PaletteFX.GRAYS, INV_MAP) elseif mode == "classic" then - return PaletteFX.CLASSIC + out = PaletteFX.CLASSIC elseif mode == "gbc_inv" then - return PaletteFX.permute(c, INV_MAP) + out = PaletteFX.permute(c, INV_MAP) end - return c + -- The shade map goes on LAST: rBGP is a hardware register write, so it + -- composes on top of whatever colors the display mode settled on -- a dark + -- cave has to read dark in CLASSIC's pea greens and in plain DMG grays too + -- (#322). permute() is the identity (and returns `out` itself, which the + -- mod-graphics parity check leans on) while nothing has armed one. + return PaletteFX.permute(out, shadeMap) end -- send a 4-color (0-255 RGB) palette to the shade-remap shader, after diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 9c1513f4..1b96385a 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -174,6 +174,9 @@ function Renderer:beginFrame(transparent) -- draws this one PaletteFX.clearTrueColor() PaletteFX.clearSpriteRedraws() + -- rBGP is a per-frame register here: the state that draws a dark map + -- re-arms it while it draws (#322), so nothing inherits last frame's + PaletteFX.setShadeMap(nil) PaletteFX.setPass("ui") love.graphics.setCanvas(self.canvas) if transparent then diff --git a/src/render/SpriteRenderer.lua b/src/render/SpriteRenderer.lua index 6f30b6f8..12b1f910 100644 --- a/src/render/SpriteRenderer.lua +++ b/src/render/SpriteRenderer.lua @@ -18,10 +18,14 @@ local function getImage(path) return imageCache[path] end --- RED++ overworld sprite OBJ-palette recolor (color/sprites.asm --- ColorOverworldSprite), baked into an ImageData like BattleState's mon-pic --- palette bake (src/battle/BattleState.lua getImage): CPU-remap the 4 DMG --- shades to the resolved OBP colors, cached per (image path, group). +-- Overworld sprite OBJ-palette recolor, baked into an ImageData like +-- BattleState's mon-pic palette bake (src/battle/BattleState.lua getImage): +-- CPU-remap the 4 DMG shades to the resolved OBP colors, cached per +-- (image path, group). Every colour mode goes through it now (#301): RED++ +-- resolves real per-sprite colours (color/sprites.asm ColorOverworldSprite), +-- OG RED the one boot-ROM object palette, and everything else the plain +-- rOBP0 = $D0 shade lift (PaletteFX.dmgObj) that leaves the sprite in DMG +-- shades for the zone shader to colour. -- -- Sprite sheets carry no real alpha (every pixel, including the -- background, is opaque -- confirmed by sampling the extracted PNGs): the @@ -110,7 +114,13 @@ function SpriteRenderer:resolveImage() -- collide in obpCache) -- see issue #155 return getObpImage(self.def.image, PaletteFX.ogObj()) end - return self.image + -- Every other mode (SGB and the mono/inverted novelties) leaves the sprite + -- in DMG shades so the zone shader colors it out of the map's own palette, + -- but still bakes rOBP0 = $D0 in and keys OBJ color 0 to alpha -- the two + -- things a raw sheet blit cannot express (#301, #150). The sheets carry no + -- real alpha (see getObpImage), so returning self.image here would put an + -- opaque white box behind every character a pipeline textures. + return getObpImage(self.def.image, PaletteFX.dmgObj()) end -- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate @@ -152,6 +162,17 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip) -- top. image = getObpImage(self.def.image, PaletteFX.ogObj()) redraw = true + else + -- SGB and the mono/inverted modes (and OG RED's tilt upright pass, which + -- has no post-zone replay to restore a bake): the sprite stays in DMG + -- shades -- rOBP0 = $D0 baked in, OBJ color 0 keyed to alpha -- and the + -- whole-canvas zone shader colors it with the map's palette. That is the + -- only thing the Super Game Boy can do to an OBJ, since pokered never + -- sends the OBJ_TRN packet that would give sprites palettes of their own + -- (data/sgb/sgb_packets.asm defines ATTR_BLK / PAL_SET / PAL_TRN / + -- MLT_REQ / CHR_TRN / PCT_TRN and nothing else). No redraw is queued: + -- being colorized by the zone IS the point (#301). + image = getObpImage(self.def.image, PaletteFX.dmgObj()) end -- single-frame sprites (item balls, fossils...) have one fixed pose; -- still 3-frame sprites turn to face (the nurse at her machine, diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 6fb278e8..e311d30b 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -27,6 +27,11 @@ local MAX_COLS = 18 -- WaitForSoundToFinish; nil headless), then auto.delay frames pass -- (default 3, Delay3) and the box pops itself + calls onDone. No -- blinking cursor, no Press_AB beep. +-- opts.auto.tick, when given, runs once per frame for as long as the box +-- is held open. It is the only per-frame hook a script has while a box is +-- up: StateStack updates the top state only, so the overworld and its +-- ScriptRunner are frozen underneath (the Pewter JIGGLYPUFF dance drives +-- its spin off it, data/scripts/story5.lua, #249). function TextBox.new(game, text, onDone, opts) local self = setmetatable({}, TextBox) self.game = game @@ -179,6 +184,13 @@ function TextBox:update(dt) self.autoSrc = self.auto.sound and self.auto.sound() or nil self.autoTimer = 0 end + -- auto.tick: one call per frame for as long as the box is held open, + -- run before the autoSrc gate so a tick-driven gate can clear itself + -- on the same frame. It is the only per-frame hook a script gets + -- while a box is up, since StateStack updates the top state only and + -- the overworld underneath is frozen (the Pewter JIGGLYPUFF spin, + -- #249). + if self.auto.tick then self.auto.tick() end if self.autoSrc and self.autoSrc.isPlaying and self.autoSrc:isPlaying() then return -- the cry is still sounding (WaitForSoundToFinish) end diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index 935831d0..e10bff02 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -11,9 +11,9 @@ TileRenderer.__index = TileRenderer local BORDER_BLOCKS = 3 -- ring width; > half a screen (2.5 blocks) -- OVERWORLD maps fill beyond-edge space from save.options.voidFill: --- trees (default) — solid tree wall $0F (Viridian/Cerulean/Celadon) --- water — solid water $43 (Cinnabar/Route 19 border block) --- black — solid black (no tiled metatile) +-- trees (default) -- solid tree wall $0F (Viridian/Cerulean/Celadon) +-- water -- solid water $43 (Cinnabar/Route 19 border block) +-- black -- solid black (no tiled metatile) -- Other tilesets keep their designated border (interiors stay black/void). local TREE_WALL_BLOCK = 0x0F local WATER_BORDER_BLOCK = 0x43 diff --git a/src/save_convert/GenSave.lua b/src/save_convert/GenSave.lua index 7387a840..b41ef582 100644 --- a/src/save_convert/GenSave.lua +++ b/src/save_convert/GenSave.lua @@ -38,6 +38,8 @@ local NAME_LENGTH = 11 local PARTY_LENGTH = 6 local MONS_PER_BOX = 20 local NUM_BADGES = 8 +local NUM_CITY_MAPS = 11 -- PALLET_TOWN..SAFFRON_CITY, the bit width of + -- wTownVisitedFlag (constants/map_constants.asm) local BOX_STRUCT_SIZE = 33 -- Species,HP,Level,Status,Type1,Type2,CatchRate, -- Moves x4,OTID,Exp x3,HPExp,AtkExp,DefExp, -- SpdExp,SpcExp,DVs,PP x4 (macros/ram.asm box_struct) @@ -65,6 +67,22 @@ O.numPcItems = O.mainData + 579 -- 1B O.pcItems = O.mainData + 580 -- 101B (50 x (id,qty) + $FF term) O.currentBoxNum = O.mainData + 681 -- 1B (bits 0-6: box 0-11, bit 7: unused here) O.coins = O.mainData + 685 -- 2B BCD +-- wTownVisitedFlag (ram/wram.asm:2057): the FLY destination set, a +-- flag_array NUM_CITY_MAPS whose bit index IS the town's map index (see the +-- decode note). Triangulated from both neighbours, which agree exactly: +-- backwards from the checksum-covered, independently derived O.eventFlags +-- below by summing every wram.asm declaration between the two labels -- +-- 2 (wTownVisitedFlag) + 2 (wSafariSteps) + 1 + 1 + 2 + 1 + 1 + 1 + 1 + 1 +-- + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 8 + 1 + 1 + 1 (wBeatGymFlags) + 1 + 1 + 1 +-- (wStatusFlags3, aliased wCableClubDestinationMap) + 1 + 1 + 1 + 1 + 1 + 1 +-- + 1 + 1 + 1 (wMovementFlags) + 2 + 2 + 1 + 1 + 2 + 1 + 1 + 2 + 1 + 1 + 2 +-- = 60, so 1104 - 60 = 1044; and forwards from O.coins (mainData + 685) +-- with 2 (wPlayerCoins) + 32 (wToggleableObjectFlags, flag_array $100) + 7 +-- + 1 (wSavedSpriteImageIndex) + 33 (wToggleableObjectList) + 1 + 200 +-- (wGameProgressFlags..End) + 56 + 14 (wObtainedHiddenItemsFlags, +-- flag_array MAX_HIDDEN_ITEMS = 112) + 2 (wObtainedHiddenCoinsFlags) + 1 +-- (wWalkBikeSurfState) + 10 = 359, so 685 + 359 = 1044 as well. +O.townVisited = O.mainData + 1044 -- 2B (flag_array NUM_CITY_MAPS) O.eventFlags = O.mainData + 1104 -- 320B (flag_array NUM_EVENTS = 2560 bits) -- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the -- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into @@ -375,6 +393,40 @@ function GenSave.crosswalks(data) } end +-- Gen1 has no "is nicknamed" bit. An un-nicknamed mon literally stores its +-- species' standard name in the nickname slot: engine/menus/naming_screen.asm +-- AskName's .declinedNickname copies wNameBuffer (the MonsterNames entry +-- GetMonName just loaded) straight over the mon's nickname field. The game +-- recovers "was it nicknamed?" by comparing the two -- +-- engine/pokemon/evos_moves.asm RenameEvolvedMon rewrites the name on +-- evolution only while the stored one still equals the PRE-evolution +-- species' standard name ("Renames the mon to its new, evolved form's +-- standard name unless it had a nickname, in which case the nickname is +-- kept"). This project models that state as mon.nickname == nil instead: +-- every display site reads `mon.nickname or def.name` and +-- src/pokemon/Evolution.lua deliberately never touches the field. So the +-- two conventions must be translated at this boundary, or an imported +-- SQUIRTLE still reads "SQUIRTLE" after it becomes a WARTORTLE and an +-- engine-origin export writes the species CONSTANT ("NIDORAN_M", whose "_" +-- has no charmap glyph and encodes as "?") where the cartridge keeps the +-- display name. Both read back as a forced nickname (#257). +-- +-- def.name is byte-for-byte what the cartridge stores: tools/extract/ +-- pokemon.py parse_names reads pokered's data/pokemon/names.asm, the very +-- table GetMonName loads from, and all 151 names round-trip exactly through +-- src/save_convert/data/charmap.lua, so the equality test below is exact +-- and never mis-fires on a name the charmap mangles. +local function speciesName(cw, species) + local def = species and cw.speciesDefs[species] + return (def and def.name) or species or "" +end + +-- stored fixed-length name -> save.lua nickname (nil when never nicknamed) +local function importedNickname(cw, species, stored) + if stored == speciesName(cw, species) then return nil end + return stored +end + -- ------------------------------------------------------------------ -- Mon struct (box_struct is a byte-for-byte prefix of party_struct; -- decodeMon reads the box_struct fields, then Level+Stats if isParty). @@ -573,7 +625,10 @@ function GenSave.decode(bytes, data, opts) local mon = decodeMon(bytes, O.partyMons + i * PARTY_STRUCT_SIZE, true, cw) if mon then mon.ot = decodeName(bytes, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH) - mon.nickname = decodeName(bytes, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH) + -- a stored name equal to the species' standard name means NOT + -- nicknamed, which this project spells as nil (#257) + mon.nickname = importedNickname(cw, mon.species, + decodeName(bytes, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH)) save.party[#save.party + 1] = mon end end @@ -586,7 +641,8 @@ function GenSave.decode(bytes, data, opts) local mon = decodeMon(bytes, base + 22 + i * BOX_STRUCT_SIZE, false, cw) if mon then mon.ot = decodeName(bytes, base + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE + i * NAME_LENGTH, NAME_LENGTH) - mon.nickname = decodeName(bytes, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH) + mon.nickname = importedNickname(cw, mon.species, + decodeName(bytes, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH)) table.insert(save.boxes[boxNum], mon) end end @@ -610,6 +666,28 @@ function GenSave.decode(bytes, data, opts) end end + -- FLY destinations. wTownVisitedFlag's bit index IS the town's map index: + -- engine/items/town_map.asm BuildFlyLocationsList loads the 16-bit value + -- into de and rotates it right one bit per iteration with b counting up + -- from 0, storing b ("the map number of the town if it has been visited"), + -- so bit 0 = map 0 = PALLET_TOWN, LSB first; and + -- engine/overworld/toggleable_objects.asm + -- MarkTownVisitedAndLoadToggleableObjects sets bit [wCurMap] on entry for + -- any map below FIRST_ROUTE_MAP. Map indices 0-10 in + -- data/generated/maps.lua match PALLET_TOWN..SAFFRON_CITY one for one. + -- This project keeps the same set as save.visited[mapId] + -- (src/ui/FlyMenu.lua, src/ui/TownMap.lua, and the only writer, + -- src/world/OverworldController.lua's mark-on-map-entry), which an import + -- used to leave nil: FLY then listed only the town the player happened to + -- be standing in when the save was loaded (#263). + save.visited = {} + for townIdx = 0, NUM_CITY_MAPS - 1 do + if bitGet(bytes, O.townVisited, townIdx) then + local townId = cw.mapsByIndex[townIdx] + if townId then save.visited[townId] = true end + end + end + -- map + position local mapIdx = u8(bytes, O.curMap) local mapId = cw.mapsByIndex[mapIdx] @@ -700,6 +778,18 @@ function GenSave.encode(save, data, template) end end + -- FLY destinations back into wTownVisitedFlag (see the decode note), so a + -- save exported from this port is flyable on hardware (#263). A save + -- table with no `visited` key at all says nothing about the set, so leave + -- the template's bits exactly as they are rather than blanking every town. + if type(save.visited) == "table" then + for townIdx = 0, NUM_CITY_MAPS - 1 do + local townId = cw.mapsByIndex[townIdx] + bitSet(buf, O.townVisited, townIdx, + (townId and save.visited[townId]) and true or false) + end + end + -- party local party = save.party or {} local partyN = math.min(#party, PARTY_LENGTH) @@ -710,8 +800,10 @@ function GenSave.encode(save, data, template) setByte(buf, O.partySpecies + i, cw.pokemonIndex[mon.species] or 0) encodeName(buf, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH, mon.ot or (save.player and save.player.name) or "RED") + -- no nickname stores the species' DISPLAY name, not its ROM constant id + -- ("NIDORAN_M" would charmap the "_" to "?") (#257) encodeName(buf, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH, - mon.nickname or mon.species or "") + mon.nickname or speciesName(cw, mon.species)) end -- $FF-terminate the species index list right after the last real mon. The -- struct, OT-name and nickname bytes of the empty slots past partyN are left @@ -734,7 +826,7 @@ function GenSave.encode(save, data, template) encodeName(buf, base + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE + i * NAME_LENGTH, NAME_LENGTH, mon.ot or (save.player and save.player.name) or "RED") encodeName(buf, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH, - mon.nickname or mon.species or "") + mon.nickname or speciesName(cw, mon.species)) -- #257, as above end -- $FF-terminate the species list after the last real mon; empty slots past -- n keep their template bytes (byte-identical round-trip) or zero (fresh diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 78339750..e28f5773 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -41,10 +41,17 @@ local function showMessages(game, msgs, onDone) game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone)) end --- run the use-flow for an item on a chosen target -local function useOn(game, battle, id, target, list, moveIndex) +-- run the use-flow for an item on a chosen target. `picker` is the party +-- menu when it was opened with keepOpen (HP medicine only): it is still on +-- the stack, so every exit that prints has to close it afterwards. For +-- every other item the picker popped itself first and closePicker's identity +-- check makes it a no-op (#252). +local function useOn(game, battle, id, target, list, moveIndex, picker) local result, payload, extra = ItemEffects.use(game.data, game.save, id, target, battle, moveIndex, game.overworld) + local function closePicker() + if picker then picker:close() end + end -- field POKé FLUTE: play the tune, then the no-effect text if result == "flute_field" then @@ -288,16 +295,29 @@ local function useOn(game, battle, id, target, list, moveIndex) end end list.index = math.min(list.index, math.max(1, #list.items)) + -- HP medicine: fill the bar in the still-open picker first, then print + -- and close, the order item_effects.asm .doneHealing runs in + -- (SFX_HEAL_HP -> UpdateHPBar2 -> RedrawPartyMenu prints the message). + -- picker is nil for every other item and for in-battle use, which keeps + -- the pop-then-print path below. #252 + if picker and extra and extra.healedFrom and target then + picker:animateTo(target, extra.healedFrom, function() + showMessages(game, payload, closePicker) + end) + return + end if battle then list:close() showMessages(game, payload, function() battle:itemUsed({}) end) else - showMessages(game, payload) + showMessages(game, payload, closePicker) end return end - showMessages(game, payload) -- failed + -- .healingItemNoEffect prints over the still-drawn party menu too, so the + -- refusal closes the picker the same way (#252) + showMessages(game, payload, closePicker) -- failed end local function pickTargetAndUse(game, battle, id, list) @@ -308,9 +328,13 @@ local function pickTargetAndUse(game, battle, id, list) local def = game.data.items[id] local opts = { pickOnly = true, - onSwitch = function(mon) + -- HP medicine animates its bar with the picker still up (#252). Only + -- out of battle: the in-battle tail closes the bag list underneath + -- first, which needs the picker already gone. + keepOpen = (not battle) and ItemEffects.healsHP(id), + onSwitch = function(mon, picker) if not wantsMove then - useOn(game, battle, id, mon, list) + useOn(game, battle, id, mon, list, nil, picker) return end local rows = {} diff --git a/src/ui/BoxMenu.lua b/src/ui/BoxMenu.lua index ee8a1300..252969af 100644 --- a/src/ui/BoxMenu.lua +++ b/src/ui/BoxMenu.lua @@ -7,6 +7,7 @@ local Font = require("src.render.Font") local ListMenu = require("src.ui.ListMenu") local Menu = require("src.ui.Menu") local Party = require("src.pokemon.Party") +local Stats = require("src.pokemon.Stats") local TextBox = require("src.render.TextBox") local Strings = require("src.core.Strings") @@ -73,6 +74,14 @@ local function withdraw(game) list.footer = "The party is full!" return end + -- add_mon.asm _MoveMon's tail ("returning mon to party, compute + -- level and stats"): a box mon carries no stat block, because + -- box_struct stops before MON_LEVEL/MON_STATS, so the party copy + -- runs CalcStats. Without it a mon decoded out of an imported .sav + -- reaches the party menu with mon.stats nil and the HP bar draw + -- nil-indexes it (#304, same family as #233). Already-shaped mons + -- (everything the engine itself put in a box) pass through. + Stats.ensure(game.data.pokemon[mon.species], mon) table.remove(box, item.value) table.insert(game.save.party, mon) local name = monName(game, mon) diff --git a/src/ui/EvolutionState.lua b/src/ui/EvolutionState.lua index 70a3a9ba..fab1ac00 100644 --- a/src/ui/EvolutionState.lua +++ b/src/ui/EvolutionState.lua @@ -20,6 +20,22 @@ EvolutionState.isOpaque = true -- SGB: SetPal_PokemonWholeScreen for the mon on display function EvolutionState:sgbPalettes(game) local P = require("src.render.PaletteFX") + -- engine/movie/evolution.asm EvolveMon runs the back-and-forth flash with + -- the whole screen on PAL_BLACK -- `ld c, 1 ; set PAL_BLACK instead of mon + -- palette` right before .animLoop, then `ld c, 0` again at .done once the + -- loop is over -- so both forms read as silhouettes while they trade places + -- and only the settled form wears a mon palette (#279). PAL_BLACK is not + -- four blacks: data/sgb/sgb_palettes.asm gives it `RGB 31,29,31, 07,07,07, + -- 02,03,03, 03,02,02`, the usual paper white with the three darker shades + -- crushed, which is why a hardware capture shows a dark mon on an unchanged + -- background rather than an all-black screen. Going through P.pal keeps + -- every COLORS mode honest for free: OG RED short-circuits every name to the + -- one global boot-ROM palette (a Game Boy Color ignores the SGB packets, so + -- it never blacks out) and the mono modes replace it in effectiveColors. + if not self.done then + local black = P.pal(game.data, "BLACK") + if black then return { P.whole(black) } end + end -- a cancelled evolution keeps the old species (never applied), so only -- colorize with the new form once it has actually evolved local species = (self.done and not self.canceled) and self.newSpecies diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua index b6cd8d09..c5d213c4 100644 --- a/src/ui/ListMenu.lua +++ b/src/ui/ListMenu.lua @@ -75,7 +75,7 @@ function ListMenu.new(game, title, items, opts) self.dialogue = opts.dialogue -- PC item lists (players_pc.asm): PrintListMenuEntries shows 4 names -- and PrintText footers ("How many?", stored/withdrew) use the standard - -- bottom text box — same row budget as the mart, without the money box. + -- bottom text box -- same row budget as the mart, without the money box. self.messageBox = opts.messageBox self.money = opts.money -- () -> current money for the box self.rows = opts.rows or ((opts.dialogue or opts.messageBox) and 4 or ROWS) diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 6ee4212e..e20470c0 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -23,9 +23,51 @@ local PartyMenu = {} PartyMenu.__index = PartyMenu PartyMenu.isOpaque = true --- SGB: generic whole-screen palette (SET_PAL_GENERIC) +-- SGB (SetPal_PartyMenu, engine/gfx/palettes.asm:90): the party screen is +-- NOT a one-palette screen. data/sgb/sgb_packets.asm BlkPacket_PartyMenu +-- splits it into MEWMON over the mon-icon column with GREENBAR everywhere +-- else, plus one block per HP bar row whose palette +-- UpdatePartyMenuBlkPacket (engine/gfx/palettes.asm:299-325) sets from that +-- mon's GetHealthBarColor -- pal 1 GREENBAR / 2 YELLOWBAR / 3 REDBAR +-- (PalPacket_PartyMenu, sgb_packets.asm:219). Handing the whole screen +-- MEWMON instead painted every bar with MEWMON's shades, which is why a +-- full bar came out black and a low one purple (#274, absorbing #272). +-- +-- Two rects differ from the packet's, both because this port draws pixels +-- where the hardware drew OAM over BG: +-- * the icon block is rows 0-11, not the packet's 0-12 -- row 12 is the +-- message box's top edge, which on hardware was BG under an OBJ-free +-- part of the block; here it would take MEWMON instead of the base. +-- * the bar blocks sit one tile right of the packet's 05-11 because this +-- port's bar starts at tile 5 where party_menu.asm:71-76 starts it at +-- 4; the span is the same "left cap + six fill tiles". function PartyMenu:sgbPalettes(game) - return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON") + local P = require("src.render.PaletteFX") + local base = P.pal(game.data, "GREENBAR") + if not base then return nil end + local zones = { P.whole(base) } + local mew = P.pal(game.data, "MEWMON") + if mew then zones[#zones + 1] = P.zone(mew, 1, 0, 2, 11) end + -- the TM/HM list prints ABLE / NOT ABLE where the bar would be, so those + -- rows have no bar to color (party_menu.asm .teachMoveMenu; #210) + if not self.tmhm then + local party = self.party or (game.save and game.save.party) or {} + for i, mon in ipairs(party) do + -- While a medicine's bar fill runs the block palette is STALE, not + -- recomputed: SetPartyMenuHPBarColor (party_menu.asm:80/295) is only + -- reached from the party-menu redraw loop, never from hp_bar.asm, so + -- UpdateHPBar2 lengthens the bar under the PRE-heal color and + -- RedrawPartyMenu snaps it green when the message prints. Hold the + -- starting HP here for exactly that window (#252). + local hp = mon.hp + if self.heal and self.heal.mon == mon then hp = self.heal.from end + local bar = P.pal(game.data, P.barPalName(hp, mon.stats.hp)) + if bar then + zones[#zones + 1] = P.zone(bar, 6, i * 2 - 1, 12, i * 2 - 1) + end + end + end + return zones end local function sameItems(_, items) return items end @@ -50,7 +92,9 @@ local DIG_TILESETS = { FOREST = true, CEMETERY = true, CAVERN = true, -- Frame1; SNAKE/QUADRUPED are the reverse. Sprite-reused icons draw -- from 16x16x6 overworld sheets where index 3 is walk-down (tile 12): -- MON/FAIRY/BIRD rest on the walk frame and animate to standing --- (tile 0); WATER (Seel) is the reverse. +-- (tile 0); WATER (Seel) is the reverse. Only the frame's LEFT half +-- ever reaches the screen -- see PartyMenu.mirrorsIcon (#276) -- which +-- is why a walk frame does not look like a walk frame here. PartyMenu.iconFrames = { BUG = { rest = 1, alt = 0 }, -- BugIconFrame2 <-> BugIconFrame1 GRASS = { rest = 1, alt = 0 }, -- PlantIconFrame2 <-> PlantIconFrame1 @@ -71,7 +115,45 @@ function PartyMenu.frameFor(name, alt, ih) return alt and ((ih or 0) >= 64 and 3 or 1) or 0 end +-- HELIX is the one icon WriteMonPartySpriteOAM sends down the asymmetric +-- path (engine/gfx/mon_icons.asm:246 `cp ICON_HELIX << 2 / jr z, .helix`); +-- every other built-in icon is drawn as a mirrored left half (see +-- drawIcon). A mod that supplies its own image instead of a built-in icon +-- name has no vanilla counterpart, so its art draws whole. #276 +function PartyMenu.mirrorsIcon(name) + return name ~= nil and name ~= "HELIX" +end + local iconImages = {} + +-- Party icons are OBJs (engine/gfx/mon_icons.asm WriteMonPartySpriteOAM +-- writes OAM blocks), so they render through OBP0, and GBPalNormal +-- (home/palettes.asm:20-26 `ld a, %11010000 ; 3100 / ldh [rOBP0], a`) +-- holds OBP0 at "3100": OBJ color 1 shows as shade 0, color 2 as shade 1, +-- color 3 as shade 3. An object never displays shade 2. This canvas has +-- no OBJ layer, so bake that map into the icon art once per path (the same +-- CPU-remap trick as SpriteRenderer.getObpImage, and the same "#obp" cache +-- key convention) and let the screen's SGB zone color the result. Without +-- it every color-2 pixel took the zone palette's shade-2 color -- the +-- ADVANCED pack's MEWMON purple {115,33,165}, i.e. the "weirdly colored" +-- party sprites of #274. +local function obpIcon(path) + if not (love.image and love.image.newImageData) then + return love.graphics.newImage(Assets.resolve(path)) -- headless stub + end + local id = Assets.imageData(path) + id:mapPixel(function(_, _, r, _, _, a) + -- the extracted art is the four DMG grays, keyed off the red channel + -- exactly the way PaletteFX's shade-remap shader keys them + local v = 0 + if r > 0.5 then v = 1 -- OBJ colors 0 and 1 -> shade 0 + elseif r > 0.17 then v = 170 / 255 -- OBJ color 2 -> shade 1 + end -- OBJ color 3 -> shade 3 + return v, v, v, a + end) + return love.graphics.newImage(id) +end + local function drawIcon(game, mon, x, y, selected, counter) local icons = game.data.icons if not icons then return end @@ -98,14 +180,26 @@ local function drawIcon(game, mon, x, y, selected, counter) end path = require("src.pokemon.Sprites").iconPath(game.data, mon, path, { name = name }) if not path then return end - if iconImages[path] == nil then + -- Built-in icon classes are DMG 2bpp OBJ art and get the OBP0 bake; a + -- mod's own image (an entry table rather than an icon name) is authored + -- art with no hardware counterpart, so it loads untouched -- the same + -- split PartyMenu.mirrorsIcon makes for the OAM mirror. Both live in one + -- cache under different keys, so a mod pointing a table entry at a + -- built-in path still gets its unbaked copy. #274 + local key = name and (path .. "#obp") or path + if iconImages[key] == nil then -- resolve through Assets so an overrides/ or transform-derived icon -- (e.g. a per-species image at assets/generated/icons/.png) is -- picked up the same way battle sprites are - local ok, img = pcall(love.graphics.newImage, Assets.resolve(path)) - iconImages[path] = ok and img or false + local ok, img + if name then + ok, img = pcall(obpIcon, path) + else + ok, img = pcall(love.graphics.newImage, Assets.resolve(path)) + end + iconImages[key] = ok and img or false end - local img = iconImages[path] + local img = iconImages[key] if not img then return end local alt = false if selected then @@ -118,10 +212,28 @@ local function drawIcon(game, mon, x, y, selected, counter) alt = false end local iw, ih = img:getDimensions() - if ih > 16 then - local frame = PartyMenu.frameFor(name, alt, ih) + -- a 16x16 sheet (BALL, HELIX) is its own only frame + local frame = ih > 16 and PartyMenu.frameFor(name, alt, ih) or 0 + if PartyMenu.mirrorsIcon(name) then + -- WriteSymmetricMonPartySpriteOAM (engine/items/town_map.asm:494-534) + -- lays each icon out as 2x2 OAM blocks that use only the frame's LEFT + -- column of tiles (base+0, base+2): the inner loop writes the same + -- wOAMBaseTile twice with the attributes alternating 0 / OAM_XFLIP and + -- only then bumps the tile by 2, because "all the sprites other than + -- the helix one have a vertical line of symmetry". MON / FAIRY / BIRD + -- reuse overworld sheets whose walk-down frame is NOT symmetric, so + -- drawing the raw 16x16 showed a tucked-back foot the hardware never + -- displays (#276, absorbing #238). + local half = love.graphics.newQuad(0, frame * 16, 8, 16, iw, ih) + love.graphics.draw(img, half, x, y) + -- sx = -1 about the block's right edge, so the flipped copy lands on + -- x+8..x+16: the OAM_XFLIP half + love.graphics.draw(img, half, x + 16, y, 0, -1, 1) + elseif ih > 16 then love.graphics.draw(img, love.graphics.newQuad(0, frame * 16, 16, 16, iw, ih), x, y) else + -- HELIX and any mod art that is a single frame: drawn whole, at + -- whatever size the file is (unchanged path) love.graphics.draw(img, x, y) end end @@ -134,6 +246,11 @@ function PartyMenu.new(game, opts) self.onSwitch = opts.onSwitch self.onCancel = opts.onCancel self.pickOnly = opts.pickOnly + -- Medicine keeps the picker on screen: item_effects.asm .doneHealing + -- animates the party HP bar and then prints the message through + -- RedrawPartyMenu with the menu STILL up, so BagMenu asks for keepOpen and + -- calls :close() itself once the message is done (#252). + self.keepOpen = opts.keepOpen -- TM/HM teaching: opts.tmhm = { move, kind } switches the list to Gen 1's -- TM/HM display (ABLE / NOT ABLE per mon instead of the HP bar, and the -- "Use TM on which POKeMON?" prompt). Set by BagMenu.pickTargetAndUse. #210 @@ -148,9 +265,47 @@ function PartyMenu.new(game, opts) return self end +-- UpdateHPBar2 (engine/gfx/hp_bar.asm, predef'd from item_effects.asm's +-- .doneHealing): UpdateHPBar_AnimateHPBar is documented "for (a) ticks (two +-- waiting frames each)" over a 48-pixel bar, so the shown HP walks +-- maxHP/96 per frame -- the same rate the battle HUD drains at +-- (BattleState:stepHPDrain). onDone fires on the frame it lands, which is +-- when the caller prints its message. #252 +function PartyMenu:animateTo(mon, fromHP, onDone) + if not (mon and mon.stats) then + if onDone then onDone() end + return + end + local from = math.max(0, fromHP or mon.hp) + -- `from` outlives `shown`: sgbPalettes above needs the pre-heal HP for the + -- whole fill, because the SGB bar color does not move until the redraw. + self.heal = { mon = mon, from = from, shown = from, onDone = onDone } +end + +-- Close a picker the caller kept open (see self.keepOpen). A TextBox pops +-- itself BEFORE it fires onDone (src/render/TextBox.lua), so this menu is +-- the top state by then; the identity check makes it a no-op for the pickers +-- that already popped themselves, and stops a double close eating the bag +-- underneath. #252 +function PartyMenu:close() + if self.game.stack:top() == self then self.game.stack:pop() end +end + function PartyMenu:update(dt) -- icon animation counter; 320 = a whole cycle at every HP speed self.blink = ((self.blink or 0) + 1) % 320 + -- The bar fill owns the menu while it runs: UpdateHPBar2 is a blocking + -- predef in item_effects.asm, so no button is read until it lands (#252). + local heal = self.heal + if heal then + heal.shown = math.min(heal.mon.hp, + heal.shown + math.max(1, heal.mon.stats.hp) / 96) + if heal.shown >= heal.mon.hp then + self.heal = nil + if heal.onDone then heal.onDone() end + end + return + end local input = self.game.input local party = self.party or self.game.save.party @@ -369,8 +524,12 @@ function PartyMenu:update(dt) end self.swapFrom = nil elseif self.onSwitch and (self.forceSwitch or self.pickOnly or not self.battle) then - self.game.stack:pop() - self.onSwitch(mon) + -- keepOpen callers (HP medicine) need the menu still drawn while the + -- bar fills and the message prints, and close it themselves; everyone + -- else keeps the old pop-then-call order. Popping first is what made + -- a POTION snap the picker shut before the item had even run (#252). + if not self.keepOpen then self.game.stack:pop() end + self.onSwitch(mon, self) else self.submenu = true self.subIndex = 1 @@ -391,7 +550,7 @@ function PartyMenu:update(dt) -- Battle still excludes this list via `not self.battle`. Softboiled -- can appear for a fainted user; its heal transfer then no-ops. if not self.battle and ow then - -- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU — + -- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU -- -- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83) local outside = Map.isOutside(ow.map.def, FieldDefaults.field(self.game.data, "outsideTilesets")) @@ -486,6 +645,16 @@ function PartyMenu:draw() Font.draw(Strings("No POKéMON!"), 16, 64) end local HudTiles = require("src.render.HudTiles") + local PaletteFX = require("src.render.PaletteFX") + -- Each bar row carries its own GREENBAR / YELLOWBAR / REDBAR zone (see + -- sgbPalettes), so the fill must stay the raw DMG shade-2 gray and let + -- the zone color it -- but only when a zone pass will actually run. + -- Renderer's blit takes the shader path exactly when the zone list is + -- non-empty AND PaletteFX.shader() resolves, which is the same pair of + -- conditions tested here; with no shader the canvas blits unshaded and + -- drawHPBar's per-pixel tint is the only color the bar can get. #274 + local barZoned = PaletteFX.shader() ~= nil + and PaletteFX.pal(self.game.data, "GREENBAR") ~= nil for i, mon in ipairs(party) do local def = self.game.data.pokemon[mon.species] local y = PartyMenu.entryY(i) @@ -525,11 +694,25 @@ function PartyMenu:draw() elseif mon.status then Font.draw(mon.status, 136, y) end - -- the colored tile HP bar (DrawHP2 + SetPartyMenuHPBarColor) + -- the tile HP bar (DrawHP2 + SetPartyMenuHPBarColor). grayFill: + -- tinting the fill AND running it through the row's zone + -- double-applies -- a green fill has red channel 0, so the tint + -- zeroes the bar's red and the zone's red-keyed shade shader then + -- maps every pixel to color 3, i.e. black. That is the #229 hazard + -- HudTiles documents; #274 (with #272) is this screen's instance. + -- + -- While a medicine's UpdateHPBar2 fill runs, this row draws the HP the + -- animation has reached rather than the final value; drawHPBar reads + -- only .hp and .stats, so a shim table is enough and the real mon is + -- never mutated for display (#252). + local shown = mon + if self.heal and self.heal.mon == mon then + shown = { hp = math.floor(self.heal.shown), stats = mon.stats } + end love.graphics.setColor(1, 1, 1, 1) - HudTiles.drawHPBar(self.game.data, 5, (y + 8) / 8, mon) + HudTiles.drawHPBar(self.game.data, 5, (y + 8) / 8, shown, nil, barZoned) love.graphics.setColor(0, 0, 0, 1) - Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 104, y + 8) + Font.draw(("%3d/%3d"):format(shown.hp, mon.stats.hp), 104, y + 8) end -- home/pokemon.asm PartyMenuInit seeds wTopMenuItemY/X with 1/0, so the -- cursor sits on the entry's *second* tile row (the level/HP line), diff --git a/src/ui/SummaryMenu.lua b/src/ui/SummaryMenu.lua index 5f9bb088..10262f61 100644 --- a/src/ui/SummaryMenu.lua +++ b/src/ui/SummaryMenu.lua @@ -13,6 +13,7 @@ local Font = require("src.render.Font") -- battle move-type box already do (#214). local TypeChart = require("src.battle.TypeChart") local Strings = require("src.core.Strings") +local Stats = require("src.pokemon.Stats") local SummaryMenu = {} SummaryMenu.__index = SummaryMenu @@ -30,6 +31,16 @@ function SummaryMenu:sgbPalettes(game) end function SummaryMenu.new(game, mon) + -- status_screen.asm:66-76: StatusScreen recalculates the stat block before + -- it draws anything when the mon came from a box or the daycare ("mon is + -- in a box or daycare" -> CalcStats), because box_struct carries none. + -- Bill's PC hands us that mon table directly (src/ui/BoxMenu.lua's STATS + -- submenu entry), and for a .sav imported through + -- src/save_convert/GenSave.lua it really does arrive with mon.stats nil, + -- which crashed the HP bar draw below (#233). Redundant once + -- SaveData.validate has run over a loaded save, but this is the site the + -- original recomputes at, and it also covers a mon handed in by a mod. + Stats.ensure(game.data.pokemon[mon.species], mon) local self = setmetatable({ game = game, mon = mon, page = 1 }, SummaryMenu) local Sprites = require("src.pokemon.Sprites") local path = Sprites.path(game.data, mon.species, "front", @@ -59,10 +70,31 @@ end -- drawn from the same HUD tiles the original loads local function drawLineBox(tx, ty, b, c) local HudTiles = require("src.render.HudTiles") - for i = 0, b - 1 do HudTiles.tile(0x73, tx * 8, (ty + i) * 8) end - HudTiles.tile(0x77, tx * 8, (ty + b) * 8) - for i = 1, c do HudTiles.tile(0x76, (tx - i) * 8, (ty + b) * 8) end - HudTiles.tile(0x6F, (tx - c - 1) * 8, (ty + b) * 8) + -- Under the status screen's overlay the vertical is $78 -- DrawLineBox + -- writes `ld [hl], $78` (status_screen.asm:222), and :90-93 is what puts + -- hud_2's single bar tile there. $73 is the glyph on this screen, + -- not a line, so the whole box has to come off statusTile (#280). The + -- drawn shapes are unchanged: hud_2 tile 0 is the same bar the battle + -- layout parks at $73. + for i = 0, b - 1 do HudTiles.statusTile(0x78, tx * 8, (ty + i) * 8) end + HudTiles.statusTile(0x77, tx * 8, (ty + b) * 8) + for i = 1, c do HudTiles.statusTile(0x76, (tx - i) * 8, (ty + b) * 8) end + HudTiles.statusTile(0x6F, (tx - c - 1) * 8, (ty + b) * 8) +end + +-- home/pokemon.asm:335-345 PrintLevel: the "" (":L") tile at (tx,ty) +-- then the level LEFT_ALIGNed after it; at level 100 hl is decremented so +-- the third digit is written back OVER the ":L" tile. Both status pages +-- print a level this way, and src/ui/PartyMenu.lua models the same rule for +-- its rows. #280 +local function printLevel(tx, ty, level) + local HudTiles = require("src.render.HudTiles") + local x = tx * 8 + if level < 100 then + HudTiles.statusTile(0x6E, x, ty * 8) + x = x + 8 + end + Font.draw(tostring(level), x, ty * 8) end function SummaryMenu:draw() @@ -73,21 +105,33 @@ function SummaryMenu:draw() local data = game.data local def = data.pokemon[mon.species] - -- shared header: pic (1,0), name (9,1), (14,2), No. (1,7) + -- shared header: pic (1,0), name (9,1), № + dex number (1,7). The pic is + -- MIRRORED -- status_screen.asm:170 draws it through + -- LoadFlippedFrontSpriteByMonIndex (home/pokemon.asm sets wSpriteFlipped), + -- the same routine the intro's NIDORINO show-off uses (OakSpeech picFlip: + -- negative x scale anchored at the pic's right edge). #280 if self.sprite then - love.graphics.draw(self.sprite, 8, - math.max(0, 56 - self.sprite:getHeight())) + love.graphics.draw(self.sprite, 8 + self.sprite:getWidth(), + math.max(0, 56 - self.sprite:getHeight()), 0, -1, 1) end local HudTiles = require("src.render.HudTiles") love.graphics.setColor(0, 0, 0, 1) Font.draw(mon.nickname or def.name, 72, 8) - HudTiles.tile(0x6E, 112, 16) -- - Font.draw(tostring(mon.level), 120, 16) - Font.draw(("No.%03d"):format(def.dex or 0), 8, 56) + -- status_screen.asm:109-113 backs hl up from DrawLineBox's end to write + -- the single-tile '№' at (1,7) and '' at (2,7); :143-146 then + -- PrintNumbers the dex number (LEADING_ZEROES, 3 digits) at (3,7). + -- Spelling "No." out of three letter tiles pushed every digit a column + -- right of the original. #280 + HudTiles.statusTile(0x74, 8, 56) -- № + Font.drawCode(0xF2, 16, 56) -- (charmap.asm:182) + Font.draw(("%03d"):format(def.dex or 0), 24, 56) if self.page == 1 then -- HP bar (11,3) + numbers row 4, STATUS/ (9,6), the DrawLineBox - -- bracket around the name/HP block + -- bracket around the name/HP block, and PrintLevel at (14,2). The + -- level belongs to page 1 ONLY: StatusScreen2 opens with ClearScreenArea + -- over (9,2) 5x10 (status_screen.asm:303-305), which wipes it. #280 + printLevel(14, 2, mon.level) drawLineBox(19, 1, 6, 10) HudTiles.drawHPBar(data, 11, 3, mon, 1) -- wHPBarType 1 Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 96, 32) @@ -114,7 +158,12 @@ function SummaryMenu:draw() Font.draw(Strings("TYPE2/"), 80, 88) Font.draw(TypeChart.displayName(def.types[2]), 88, 96) end - Font.draw(Strings("IDNo/"), 80, 104) + -- TypesIDNoOTText's third row is "№/" (status_screen.asm:205-210): + -- two single-tile glyphs and a slash, three columns wide, not the five + -- letter tiles "IDNo/" this used to spell out. #280 + HudTiles.statusTile(0x73, 80, 104) -- + HudTiles.statusTile(0x74, 88, 104) -- № + Font.draw("/", 96, 104) -- the trainer ID is rolled at new game (SaveData.newGame) and -- backfilled on load for old saves Font.draw(("%05d"):format(mon.otId or game.save.player.id or 0), 96, 112) @@ -124,17 +173,21 @@ function SummaryMenu:draw() -- page 2: EXP + the moves with PP (StatusScreen2) drawLineBox(19, 1, 6, 10) Font.draw(Strings("EXP POINTS"), 72, 24) - Font.draw(("%d"):format(mon.exp), 96, 32) - -- StatusScreen2: "LEVEL UP" at (9,5); next-exp PrintNumber 7 cols - -- at (7,6); space at (14,6); PrintLevel at (16,6). The old - -- "%d to L%d" string at x=88 overflowed the DrawLineBox edge. + -- PrintNumber at (12,4) with 7 columns: the exp is RIGHT-aligned into + -- cols 12-18 (status_screen.asm:400-403), not left-aligned from col 12. + -- #280 + Font.draw(("%7d"):format(mon.exp), 96, 32) + -- StatusScreen2: "LEVEL UP" at (9,5); next-exp PrintNumber 7 cols at + -- (7,6); the narrow '' tile at (14,6); PrintLevel at (16,6) + -- (status_screen.asm:393-403). The old "%d to L%d" string at x=88 + -- overflowed the DrawLineBox edge. Font.draw(Strings("LEVEL UP"), 72, 40) local Growth = require("src.pokemon.Growth") local nextExp = mon.level < 100 and (Growth.expForLevel(def.growthRate, mon.level + 1) - mon.exp) or 0 Font.draw(("%7d"):format(math.max(0, nextExp)), 56, 48) - HudTiles.tile(0x6E, 128, 48) -- - Font.draw(tostring(math.min(100, mon.level + 1)), 136, 48) + HudTiles.statusTile(0x70, 112, 48) -- '' at (14,6), was missing (#280) + printLevel(16, 6, math.min(100, mon.level + 1)) Font.drawBox(0, 8, 20, 10) for i = 1, 4 do local mv = mon.moves[i] diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 1fe9b3ef..5f501f60 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -47,6 +47,24 @@ local HEAL_BALL_XY = { -- swaps the two middle shades of the monitor/ball art in place local HEAL_FLASH_MAP = { [0] = 0, [1] = 2, [2] = 1, [3] = 3 } +-- Fishing rod placement (FishingRodOAM, engine/overworld/player_animations +-- .asm). Those dbsprite rows are raw shadow-OAM bytes like HEAL_BALL_XY +-- above (screen = tile*8 + pixel - 8/16), measured against the player +-- sprite's fixed screen spot: ResetPlayerSpriteData parks it at $3c/$40 +-- (home/reset_player_sprite.asm), i.e. screen (64,60). So what ports over +-- is the delta from the sprite's top-left, which SpriteRenderer:draw puts at +-- (px, py - 4). `tile` indexes the three stacked 8x8 tiles of +-- assets/generated/fx/fishing_rod.png: FishingRodOAM only ever draws $fd +-- (row 0, up/down) and $fe (row 1, left/right), and RIGHT is the LEFT tile +-- x-flipped. Blitting the whole 8x24 sheet is what drew the rod as a +-- garbage strip (#321). +local ROD_OAM = { + down = { dx = 4, dy = 15, tile = 0 }, -- dbsprite 9, 11, 4, 3, $fd + up = { dx = 4, dy = -8, tile = 0 }, -- dbsprite 9, 8, 4, 4, $fd + left = { dx = -8, dy = 4, tile = 1 }, -- dbsprite 8, 10, 0, 0, $fe + right = { dx = 16, dy = 4, tile = 1, flip = true }, -- dbsprite 11, 10, 0, 0, $fe, XFLIP +} + -- object_event spawn filter (toggleable_objects, items taken, beaten -- static encounters), shared by the current map's real NPCs and the -- visual-only ghosts on connected neighbor maps @@ -555,6 +573,19 @@ function OverworldState:sgbWorldZones() return zones end +-- Whether the dark-map shade shift (PaletteFX.DARK_BGP, armed in drawWorld) +-- can actually reach this frame's world pass. It cannot in RED++: that mode +-- bakes real per-tile colour into the tileset atlas and sgbWorldZones returns +-- an EMPTY zone list above, so the world blits with no shade-remap shader at +-- all and there is no palette left to permute. Only then does fxDark +-- composite the darkness by hand (#322). +function OverworldState:darkNeedsOverlay() + if not self.dark then return false end + local renderer = self.map and self.map.renderer + return PaletteFX.usesGbcPack() and renderer ~= nil + and renderer.gbcAtlas ~= nil +end + function OverworldState:npcByIndex(index) for _, n in ipairs(self.npcs) do if n.def.index == index then return n end @@ -980,9 +1011,15 @@ function OverworldState:handleInput() end -- Cycling Road's downhill pull: with no d-pad held the bike rolls - -- south (home/overworld.asm JoypadOverworld's simulated PAD_DOWN) + -- south (home/overworld.asm JoypadOverworld's simulated PAD_DOWN). + -- The mask there is PAD_CTRL_PAD | PAD_B | PAD_A, so HOLDING A or B + -- brakes exactly like a held direction: what the Route 17 sign + -- promises ("Press the A or B Button to stay in place") and what the + -- edge-only wasPressed("a") above can never deliver, since a press + -- stalls the roll for one frame only (issue #255). local fm = Game.data.field.forcedMovement - if fm and Game.save.onBike and not self.player.moving then + local braking = input:isDown("a") or input:isDown("b") + if fm and Game.save.onBike and not braking and not self.player.moving then for _, m in ipairs(fm.slopeMaps or {}) do if m == self.map.id then self.player.facing = "down" @@ -1070,8 +1107,28 @@ function OverworldState:checkLedgeHop(dir) and ledge.facing == dir and ledge.input == dir and ledge.standingTile == standing and ledge.ledgeTile == front then local lx, ly = Collision.target(fx, fy, dir) - if self.map:inBounds(lx, ly) - and not Collision.occupied(self.entities, lx, ly, p) + if not self.map:inBounds(lx, ly) then + -- The landing is on the CONNECTED map. pokered never checks where a + -- hop lands (engine/overworld/ledges.asm HandleLedges just simulates + -- two presses in the hop direction) and the connection strip is + -- loaded, so ROUTE_4's bottom-row ledge at (12,17)/(13,17) really + -- does drop onto ROUTE_3 row 0 (south connection, offset -25 -> + -- destX = curX + 50; ROUTE_3 (62,0)/(63,0) are walkable $39/$23): + -- the one-way shortcut off the Mt Moon plaza that the in-bounds gate + -- was silently refusing, which is issue #223. Validate the seam + -- cell the way crossConnection does, hop the first cell onto the + -- ledge tile, and hand the second to checkEdgeExit, which owns the + -- crossing. + local dest, ts, cx, cy = self:connectionLanding(dir) + if not (dest and Map.defPassable(dest, ts, cx, cy, p.surfing)) then + return false + end + require("src.core.Sound").play(Game.data, "Ledge") + p.hopFrames, p.hopTotal = 32, 32 -- jump arc (cosmetic) + self:scriptMove(p, dir, 1, function() self:checkEdgeExit(dir) end) + return true + end + if not Collision.occupied(self.entities, lx, ly, p) and self.map:isWalkableCell(lx, ly) then require("src.core.Sound").play(Game.data, "Ledge") p.hopFrames, p.hopTotal = 32, 32 -- jump arc (cosmetic) @@ -1322,12 +1379,18 @@ function OverworldState:goFishing(rod) -- FishingInit dot animation); the rod pose draws in the meantime self.fishing = { facing = self.player.facing } Game.stack:push(TextBox.new(Game, ". . .", function() - self.fishing = nil + -- FishingAnim (engine/overworld/player_animations.asm) holds + -- BIT_LEDGE_OR_FISHING -- the rod OAM and the fishing pose -- through + -- PrintText and only clears it once the verdict box is done, so the rod + -- must NOT vanish with the dots box (#321). if not enc then - Game.stack:push(TextBox.new(Game, Strings("Not even a nibble!"))) + Game.stack:push(TextBox.new(Game, Strings("Not even a nibble!"), function() + self.fishing = nil + end)) return end Game.stack:push(TextBox.new(Game, Strings("Oh!\nIt's a bite!"), function() + self.fishing = nil local BattleState = require("src.battle.BattleState") local battle = BattleState.newWild(Game, enc.species, enc.level, { hooked = true }) if Game.save.safari and Map.inRegion(self.map.def, "SAFARI", "SAFARI_ZONE") then @@ -2528,22 +2591,27 @@ function OverworldState:engageTrainer(npc, onDone) local BattleState = require("src.battle.BattleState") Game.stack:push(TextBox.new(Game, battleText, function() local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty) + -- PrintEndBattleText (home/trainers.asm:341) is called from + -- TrainerBattleVictory (engine/battle/core.asm:942), i.e. ON the battle + -- screen once ScrollTrainerPicAfterBattle has brought the beaten trainer + -- back, and before MoneyForWinningText -- not in the overworld after the + -- battle screen has torn down. Handing the line to the battle also + -- stops a post-battle evolution being sandwiched between two overworld + -- cuts (#282). Substituted here because BattleState:say takes finished + -- text, while TextBox expanded the {PLAYER}/{RIVAL} tokens itself. + battle.endBattleText = wonText and TextBox.substitute(Game, wonText) or nil battle.onFinish = function(result) if result == "win" then Game.save.defeatedTrainers[npc.id] = true if header and header.event then Game.save.flags[header.event] = true end + -- checkVictoryRewards pushes the badge/prize box and starts the map's + -- onVictory script UNDER whatever runs next, so the player still sees + -- EndBattle (now inside the battle), then the reward, then AfterBattle self:checkVictoryRewards(d.trainerClass, d.trainerParty) - local after = function() - self:afterBattle(result, battle) - if onDone then onDone() end - end - if wonText then - Game.stack:push(TextBox.new(Game, wonText, after)) - else - after() - end + self:afterBattle(result, battle) + if onDone then onDone() end else self:afterBattle(result, battle) if onDone then onDone() end @@ -2556,7 +2624,7 @@ end -- Badges/items awarded after specific battles (data/scripts/victories.lua). -- `deactivate` retires unfought gym/dojo trainers the way the originals' -- SetEvent / SetEventRange do after the leader victory. --- `hide` is { { mapId, objName }, ... } — HideObject on those toggles +-- `hide` is { { mapId, objName }, ... } -- HideObject on those toggles -- (e.g. Brock victory clears PEWTERCITY_YOUNGSTER / ROUTE22_RIVAL1). function OverworldState:checkVictoryRewards(trainerClass, partyIndex) local victories = require("data.scripts.victories") @@ -2924,14 +2992,25 @@ function OverworldState:onStepComplete() self.warpEntryCell = nil entry = nil end - if self.justWarped then - self.justWarped = false - elseif entry then + -- The arrival disable is POSITIONAL: warpEntryCell above is the whole + -- test. justWarped only records that an arrival happened (it still + -- backs onWarpArrivalCell's bonk guard for issue #230), so consuming a + -- completed step with it swallowed the warp under the player's feet, + -- which is why a second ladder one cell from the first did nothing + -- (Seafoam B3F has warp tiles on (25,3) and (25,4)) -- issue #265. + -- pokered has no such counter: every completed step runs + -- CheckWarpsNoCollision (home/overworld.asm), and BIT_STANDING_ON_WARP, + -- the flag the bonk path needs, is only set by + -- CheckWarpsNoCollisionLoop itself or by IsPlayerStandingOnWarp from + -- MapEntryAfterBattle, never on a plain warp arrival -- which is + -- exactly what warpEntryCell reproduces. + self.justWarped = false + if entry then -- still standing on the warp we arrived through; do not re-trigger it else -- CheckWarpsNoCollision: door/warp tiles fire immediately; otherwise -- ExtraWarpCheck must pass AND either a d-pad is held or BIT_FORCED_WARP - -- is set (Seafoam B3F currents — home/overworld.asm). + -- is set (Seafoam B3F currents -- home/overworld.asm). local w = Warp.onArrive(self.map, p.cellX, p.cellY) if not w and (self:dirHeld() or self.forcedWarp) then w = Warp.onCollision(self.map, Game.data.field.warpCarpets, @@ -3017,7 +3096,7 @@ function OverworldState:runSpinnerMoves(moves, i) -- Scripted steps skip onStepComplete while they run; once the RLE -- finishes, re-enter the normal landing pipeline so chained spinners, -- Seafoam currents, and CheckWarpsNoCollision (incl. BIT_FORCED_WARP) - -- see the tile we stopped on — same as pokered after simulated joypad. + -- see the tile we stopped on -- same as pokered after simulated joypad. self:onStepComplete() return end @@ -3754,6 +3833,13 @@ function OverworldState:billboard(fx, fy, vw, vh, colors, keyed, drawFn) end function OverworldState:drawWorld() + -- Dark-map BG shade shift, armed for the whole frame before anything draws. + -- home/fade.asm's LoadGBPal writes ONE rBGP for the screen, so terrain, the + -- characters standing on it and any dialog over them darken together (#322); + -- Renderer:beginFrame cleared it, so a battle or a full-screen menu -- which + -- draws with no map beneath it -- stays lit exactly like + -- init_battle_variables.asm's `ld [wMapPalOffset], a` leaves the original. + PaletteFX.setShadeMap(self.dark and PaletteFX.DARK_BGP or nil) -- advance the water/flower tile animation (runs under dialogs too). -- TileRenderer.tick uses wall-clock 60Hz steps so display refresh rate -- does not speed or slow the cycle (issue #4). @@ -3970,19 +4056,20 @@ function OverworldState:drawWorld() end end - -- Rock Tunnel darkness: a small window of light around the player - -- until FLASH is used (the original darkens the palette instead); - -- fills the whole world view, so surveying doesn't peek past it + -- Rock Tunnel darkness. The original never cuts a window of light around + -- the player: it shifts the BG palette for the WHOLE screen (wMapPalOffset + -- = 6 -> home/fade.asm LoadGBPal -> FadePal2 `dc 3,3,3,2`) and FLASH shifts + -- it back (#322). PaletteFX.DARK_BGP does that for every shade-remapped + -- mode, armed at the top of drawWorld. RED++ is the one mode with no + -- palette left to shift -- TileRenderer bakes true colour into the tileset + -- atlas and sgbWorldZones hands the blit an EMPTY zone list, so no shader + -- runs over the world at all -- so there the darkness is composited instead: + -- a flat veil over the whole world view (surveying still cannot peek past + -- it) at the 85/255 brightness FadePal2 leaves DMG white on. local function fxDark() - if not self.dark then return end - local px = self.player.px - cam.x + 8 - local py = self.player.py - cam.y + 8 - local r = 28 - love.graphics.setColor(0, 0, 0, 1) - love.graphics.rectangle("fill", 0, 0, vw, math.max(0, py - r)) - love.graphics.rectangle("fill", 0, py + r, vw, vh - (py + r)) - love.graphics.rectangle("fill", 0, py - r, math.max(0, px - r), r * 2) - love.graphics.rectangle("fill", px + r, py - r, vw - (px + r), r * 2) + if not self:darkNeedsOverlay() then return end + love.graphics.setColor(0, 0, 0, 1 - 85 / 255) + love.graphics.rectangle("fill", 0, 0, vw, vh) love.graphics.setColor(1, 1, 1, 1) end @@ -4016,11 +4103,25 @@ function OverworldState:drawWorld() end if self.rodImg then local p = self.player - local vec = DIRVEC[self.fishing.facing] or DIRVEC.down - local rx = p.px - cam.x + 4 + vec[1] * 12 - local ry = p.py - cam.y + 4 + vec[2] * 12 + local oam = ROD_OAM[self.fishing.facing] or ROD_OAM.down + if not self.rodQuads then + -- one quad per 8x8 tile of the stacked sheet (ROD_OAM.tile) + local iw, ih = self.rodImg:getDimensions() + self.rodQuads = {} + for i = 0, math.floor(ih / 8) - 1 do + self.rodQuads[i] = love.graphics.newQuad(0, i * 8, 8, 8, iw, ih) + end + end + local quad = self.rodQuads[oam.tile] + -- the sprite's top-left is 4px above its cell (SpriteRenderer:draw) + local rx = p.px - cam.x + oam.dx + local ry = p.py - cam.y - 4 + oam.dy love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(self.rodImg, rx, ry) + if quad and oam.flip then + love.graphics.draw(self.rodImg, quad, rx + 8, ry, 0, -1, 1) + elseif quad then + love.graphics.draw(self.rodImg, quad, rx, ry) + end end end end @@ -4100,10 +4201,12 @@ function OverworldState:drawWorld() if self.fishing then at(fxRod, self.player.px + 8, self.player.py + 16) end - -- Rock Tunnel darkness is a screen-space light window, not a ground - -- object: draw it flat over the finished scene like the tilt path. - -- It fills the view in world-pixel units, so it only needs the scale. - if self.dark then + -- Rock Tunnel darkness is a screen-space veil, not a ground object: + -- draw it flat over the finished scene like the tilt path. It fills + -- the view in world-pixel units, so it only needs the scale, and it is + -- only needed at all in the mode whose palette cannot carry the + -- darkening itself (see fxDark). + if self:darkNeedsOverlay() then love.graphics.push() love.graphics.scale(scale, scale) fxDark() @@ -4134,9 +4237,12 @@ function OverworldState:drawWorld() -- the pipeline owns the whole frame; nothing else draws into the world elseif not tilt then -- === FLAT PATH: everything into the one world canvas, as before ===== - -- OBP-baked sprites replay after the zone pass in GBC mode, so their + -- OBP-baked sprites replay after the zone pass in OG RED mode, so their -- grass feet-overdraw must replay over them too, colorized with the - -- current map's palette (see PaletteFX.markSpriteRedraw) + -- current map's palette (see PaletteFX.markSpriteRedraw). SGB no longer + -- takes that path -- its characters are colorized by the zone just like + -- the ground under them (#301) -- so there the first overdraw is already + -- the final one. local grassColors = PaletteFX.usesSpriteObp() and PaletteFX.pal(Game.data, self:paletteNameFor(self.map)) or nil for _, g in ipairs(self.ghosts) do @@ -4257,10 +4363,10 @@ function OverworldState:drawWorld() self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxRod) end - -- Rock Tunnel darkness is a screen-space light window, not a ground - -- object -- draw it flat into the upright canvas so it darkens the - -- final composited scene uniformly (the subtle tilt keeps the - -- projected player near the flat light centre). + -- Rock Tunnel darkness is a screen-space veil, not a ground object -- + -- draw it flat into the upright canvas so it darkens the final + -- composited scene uniformly. It no-ops unless this mode needs the + -- composited fallback (see fxDark). fxDark() Game.renderer:endUprightPass() diff --git a/src/world/Player.lua b/src/world/Player.lua index 01aed2cc..c79c2e55 100644 --- a/src/world/Player.lua +++ b/src/world/Player.lua @@ -152,7 +152,7 @@ function Player:update() self.stepFlip = not self.stepFlip -- keep animClock's pose on this frame (issue #82): bike steps land -- mid-cycle (animClock % 16 == 8), and walkPhase used to snap to - -- stand whenever moving cleared — a stand flash every tile on the + -- stand whenever moving cleared -- a stand flash every tile on the -- bike, and sometimes after dismount when the clock is desynced self.stepLanded = true return true diff --git a/tests/drivers/bag_usetoss_bug284_test.lua b/tests/drivers/bag_usetoss_bug284_test.lua index 13cd77c2..674a08d9 100644 --- a/tests/drivers/bag_usetoss_bug284_test.lua +++ b/tests/drivers/bag_usetoss_bug284_test.lua @@ -1,27 +1,8 @@ --- Driver: the bag's USE/TOSS submenu box (#284). A manual eye check, not a --- pass/fail run. --- --- pokered evidence. data/text_boxes.asm pins the whole thing as one entry: --- --- text_box_text USE_TOSS_MENU_TEMPLATE, 13, 10, 19, 14, UseTossText, 15, 11 --- ; text box ID, upper-left X, upper-left Y, lower-right X, lower-right Y, --- ; text pointer, text X, text Y --- --- so the box covers tiles (13,10)-(19,14) and "USE" starts at tile (15,11). --- engine/menus/start_sub_menus.asm then sets wTopMenuItemY/X to 11/14 for --- the cursor, and UseTossText is "USE" + next "TOSS", i.e. two rows. --- --- The bug: the port opened the submenu with a (12,10) 8x6 box, one column --- too wide on the left and one row too tall at the bottom. The labels --- themselves were already on the right pixel rows, but the extra bottom row --- left them stranded near the top edge, which is what reads as "too high". --- The fix passes the real geometry (13,10,7,5); src/ui/Menu.lua derives the --- label and cursor columns from the box, so it needed no change and its --- eight other callers are untouched. --- --- Do NOT run this under POKEPORT_SPEED: fast-forward scales only the logic --- clock, so a sped-up run can capture a half-drawn frame. --- +-- Eye check on the bag's USE/TOSS submenu box (#284). +-- pokered data/text_boxes.asm: USE_TOSS_MENU_TEMPLATE covers tiles +-- (13,10)-(19,14) with "USE" at (15,11), and start_sub_menus.asm puts the +-- cursor at wTopMenuItemY/X 11/14. The port opened it one column wider and +-- one row taller, which stranded the labels near the top edge. -- POKEPORT_DRIVER=tests/drivers/bag_usetoss_bug284_test.lua POKEPORT_IDENTITY=bug284 love . return function(game) local U = dofile("tests/drivers/util.lua") @@ -33,10 +14,8 @@ return function(game) return ok end - -- ---- preconditions the eye cannot separate from the bug ---------------- - -- An empty bag, or an item whose branch skips the submenu, both end with - -- "no USE/TOSS box on screen", which looks identical to a broken box. - + -- an empty bag, or an item whose branch skips the submenu, leaves no box on + -- screen at all, which looks the same as a broken one game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) } game.save.player.name = "bryan" game.save.inventory = game.save.inventory or {} @@ -59,27 +38,14 @@ return function(game) U.wait(30) U.shot(game, "bug284_bag_list.png") - -- A on the first item opens USE/TOSS U.tap(game, "a") U.wait(30) U.shot(game, "bug284_usetoss.png") - U.log("........................................................") - U.log("LOOK NOW: the USE/TOSS box should be open over the bag list.") - U.log(" Screenshot: bug284_usetoss.png in the LOVE save dir.") - U.log(" RIGHT: a box hugging the bottom-right corner, its border running") - U.log(" tiles (13,10) to (19,14). USE and TOSS sit on alternating") - U.log(" rows with the cursor one column to their left, and there") - U.log(" is exactly one border row below TOSS -- no empty gap.") - U.log(" BUG #284 looks like: a roomier box whose labels crowd the top,") - U.log(" with a blank row of dead space under TOSS, and the whole") - U.log(" box starting one column further left than the original.") - U.log(" ALSO WRONG: labels pushed down but the box left oversized (that") - U.log(" moves the text off pokered's row 11), or the cursor column") - U.log(" no longer lining up one tile left of the labels.") - U.log("Compare against the original screenshot in issue #284.") - U.log("Input is yours from here: up/down moves between USE and TOSS.") - U.log("........................................................") + U.log("USE/TOSS is open over the bag list; shot in bug284_usetoss.png.") + U.log("It should run tiles (13,10)-(19,14) with the cursor one column left") + U.log("of the labels and a single border row under TOSS, no blank gap (#284).") + U.log("Up/down moves between USE and TOSS.") while true do coroutine.yield() diff --git a/tests/drivers/battle_colors_bug316_test.lua b/tests/drivers/battle_colors_bug316_test.lua new file mode 100644 index 00000000..8e3a7356 --- /dev/null +++ b/tests/drivers/battle_colors_bug316_test.lua @@ -0,0 +1,230 @@ +-- Driver: cycling COLORS mid-battle must not move a sprite (#316). pokered +-- SlidePlayerAndEnemySilhouettesOnScreen (engine/battle/core.asm:13-15) puts +-- the back pic at `hlcoord 1, 5` so its bottom row sits on the text box, and a +-- palette swap must not lift it off. +-- POKEPORT_DRIVER=tests/drivers/battle_colors_bug316_test.lua \ +-- POKEPORT_IDENTITY=bug316 POKEPORT_TOUCH=0 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 Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local PaletteFX = require("src.render.PaletteFX") + + -- Route 1 (5, 5) is open walkable ground; the battle is pushed straight in. + local MAP = "ROUTE_1" + local STAND = { x = 5, y = 5, facing = "down" } + -- ARTICUNO's back pic has left padding as well as bottom padding. + local PARTY = { { "BULBASAUR", 12 }, { "ARTICUNO", 40 } } + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- ---- preconditions the eye cannot check -------------------------------- + -- No transparent padding on the back pics means no jump to see, so a broken + -- fix would read as a pass. + local function padOf(path) + if not (love.image and love.image.newImageData) then return nil end + local ok, id = pcall(love.image.newImageData, path) + if not ok then return nil end + local w, h = id:getDimensions() + local bottom = h - 1 + while bottom >= 0 do + local opaque = false + for x = 0, w - 1 do + local _, _, _, a = id:getPixel(x, bottom) + if a > 0 then opaque = true break end + end + if opaque then break end + bottom = bottom - 1 + end + local left = 0 + while left < w do + local opaque = false + for y = 0, h - 1 do + local _, _, _, a = id:getPixel(left, y) + if a > 0 then opaque = true break end + end + if opaque then break end + left = left + 1 + end + return h - 1 - bottom, left, w, h + end + + for _, path in ipairs({ "assets/generated/battle/redb.png", + "assets/generated/battle/back/bulbasaurb.png", + "assets/generated/battle/back/articunob.png" }) do + local pad, padL, w, h = padOf(path) + if pad == nil then + check(path .. " could be measured", false) + else + U.log((" %s is %dx%d, %d transparent bottom rows, %d left columns") + :format(path, w, h, pad, padL)) + check(path .. " still carries bottom padding to lose", pad > 0) + end + end + check("BattleState.invalidate still exists (PaletteFX.setMode calls it)", + type(BattleState.invalidate) == "function") + U.log(" COLORS ladder:", table.concat(PaletteFX.MODES, ", ")) + + -- Where drawPicsLayer puts every pic this frame; love.graphics.draw is + -- shadowed so nothing reaches the screen. Both call shapes matter: + -- draw(img, x, y, r, sx, sy) and the faint-clip draw(img, quad, x, y, ...). + local function picGeometry(battle) + local out = {} + local realDraw = love.graphics.draw + love.graphics.draw = function(_, a, b, c, d, e) + if type(a) == "number" then + out[#out + 1] = { x = a, y = b, s = d } + else + out[#out + 1] = { x = b, y = c, s = e } + end + end + pcall(battle.drawPicsLayer, battle, 0, 0, 0) + love.graphics.draw = realDraw + return out + end + + local function describe(geo) + local parts = {} + for _, g in ipairs(geo) do + parts[#parts + 1] = ("(%s, %s x%s)"):format(tostring(g.x), tostring(g.y), + tostring(g.s or 1)) + end + return table.concat(parts, " ") + end + + local function same(a, b) + if #a ~= #b then return false end + for i = 1, #a do + if a[i].x ~= b[i].x or a[i].y ~= b[i].y or a[i].s ~= b[i].s then + return false + end + end + return true + end + + -- The back pic is the only thing drawn at 2x (BattleState.backPlacement's + -- third return); the enemy front pic is 1x. + local function backEntry(geo) + for _, g in ipairs(geo) do + if g.s == 2 then return g end + end + return nil + end + + -- Ground truth off the art on disk, not off the cache: comparing against an + -- earlier frame only catches a pad lost DURING the run, and this one is also + -- lost by any pic loaded after an invalidate(). + local bpad, bpadL, bw, bh = padOf("assets/generated/battle/back/bulbasaurb.png") + local expX, expY = nil, nil + if bpad then + expX, expY = BattleState.backPlacement(bw, bh, bpad, bpadL, 2) + U.log((" a %dx%d back pic with %d ground rows belongs at (%d, %d) at 2x") + :format(bw, bh, bpad, expX, expY)) + end + + local function grounded(label, geo) + if not expY then return end + local b = backEntry(geo) + check(label .. ": the back pic stands on the text box at y = " + .. tostring(expY) .. " (got " .. tostring(b and b.y) .. ")", + b ~= nil and b.y == expY and b.x == expX) + end + + -- ---- get a back pic on screen ------------------------------------------ + game.save.party = {} + for _, slot in ipairs(PARTY) do + table.insert(game.save.party, Pokemon.new(game.data, slot[1], slot[2])) + end + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(10) + local ow = game.overworld + check("the overworld is up on " .. MAP, ow ~= nil) + + local battle = BattleState.newWild(game, "PIDGEY", 6) + battle.onFinish = function() end + if ow then ow:pushBattle(battle) end + for _ = 1, 400 do + if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end + U.wait(1) + end + check("the battle reached the screen", game.stack:top() == battle) + + -- Red's own back pic is up during the intro, and that is the pic the report + -- names, so check it first. + check("Red's back pic is on screen", battle.showPlayerBack == true) + local introBefore = picGeometry(battle) + U.log(" intro pics before:", describe(introBefore)) + grounded("before any COLORS change", introBefore) + U.shot(game, DIR .. "/bug316_1_intro_before.png") + game:keypressed("2") + U.wait(4) + local introAfter = picGeometry(battle) + U.log(" intro pics after :", describe(introAfter)) + U.log(" COLORS is now", PaletteFX.modeLabel(PaletteFX.mode)) + check("cycling COLORS did not move Red's back pic (#316)", + same(introBefore, introAfter)) + grounded("after one COLORS change", introAfter) + U.shot(game, DIR .. "/bug316_2_intro_after.png") + + -- now the mon's own back pic, at the action menu, where a player actually + -- sits when they press 2 + for _ = 1, 80 do + if battle.phase == "menu" then break end + U.tap(game, "a") + U.wait(4) + end + check("the battle reached its action menu", battle.phase == "menu") + check("the mon's back pic replaced Red's", battle.showPlayerBack == false) + + local base = picGeometry(battle) + U.log(" menu pics baseline:", describe(base)) + grounded("the mon's own back pic at the menu", base) + U.shot(game, DIR .. "/bug316_3_menu_" .. tostring(PaletteFX.mode) .. ".png") + + -- walk the whole COLORS ladder; every mode has to leave the geometry alone + local moved, ungrounded = {}, {} + for i = 1, #PaletteFX.MODES do + game:keypressed("2") + U.wait(4) + local geo = picGeometry(battle) + local label = PaletteFX.modeLabel(PaletteFX.mode) + if not same(base, geo) then + moved[#moved + 1] = label + U.log(" MOVED under " .. label .. ":", describe(geo)) + end + local b = backEntry(geo) + if expY and not (b and b.y == expY and b.x == expX) then + ungrounded[#ungrounded + 1] = label + end + if i == 1 then + U.shot(game, DIR .. "/bug316_4_menu_" .. tostring(PaletteFX.mode) .. ".png") + end + end + check("no COLORS mode moved a pic (walked the whole ladder of " + .. #PaletteFX.MODES .. ")", #moved == 0) + check("the back pic is still grounded in every COLORS mode", + #ungrounded == 0) + if #ungrounded > 0 then + U.log(" modes where it floated:", table.concat(ungrounded, ", ")) + end + if #moved > 0 then + U.log(" modes that moved something:", table.concat(moved, ", ")) + end + U.log(" back on", PaletteFX.modeLabel(PaletteFX.mode)) + + -- ---- hand off ---------------------------------------------------------- + U.log("At the FIGHT/PKMN/ITEM/RUN menu: press 2 to cycle COLORS and watch the") + U.log("back sprite's feet. They stay planted on the top edge of the text box;") + U.log("#316 hopped the sprite 8 pixels up on the first press. Switching to") + U.log("ARTICUNO in slot 2 shows the sideways half of it.") + U.log("Sprites already on screen keep their baked palette, which is deliberate.") + U.log("Screenshots: " .. DIR .. "/bug316_*.png") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/battle_intro_bug317_test.lua b/tests/drivers/battle_intro_bug317_test.lua new file mode 100644 index 00000000..aef76614 --- /dev/null +++ b/tests/drivers/battle_intro_bug317_test.lua @@ -0,0 +1,255 @@ +-- Driver: the battle intro's pokeball rows, HUD chrome, prompt arrow and +-- trainer-pic slides (#317). engine/battle/common_text.asm:22-27, +-- draw_hud_pokeball_gfx.asm:1-45 (player row (88,80) +8, foe row (64,16) -8), +-- SlideTrainerPicOffScreen at core.asm:1235-1253. Ordering is asserted in +-- parity_battle_intro_chrome.lua. No POKEPORT_SPEED: audio has its own clock. +-- POKEPORT_DRIVER=tests/drivers/battle_intro_bug317_test.lua POKEPORT_IDENTITY=bug317 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 Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local HudTiles = require("src.render.HudTiles") + local Font = require("src.render.Font") + + -- pokered data/maps/objects/Route3.asm: object_event 10, 6, ... STAY, RIGHT, + -- OPP_BUG_CATCHER, 4. Range RIGHT means the sight line runs east along row + -- 6, so (13,6) is one cell outside it and walking west from there trips it. + local MAP = "ROUTE_3" + local TRAINER = "ROUTE3_YOUNGSTER1" + local STAND = { x = 13, y = 6, facing = "left" } + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- ---- preconditions the eye cannot check -------------------------------- + -- A missing sheet draws nothing, which looks identical to the bug. + + -- drawBallRow bails silently if this image will not load + local balls = io.open("assets/generated/battle/balls.png", "rb") + check("assets/generated/battle/balls.png exists", balls ~= nil) + if balls then balls:close() end + + -- HudTiles.tile is a silent no-op for a code the sheets do not carry, so + -- count the draws it issues rather than trusting the call + local function tileDraws(code) + local real = love.graphics.draw + local n = 0 + love.graphics.draw = function() n = n + 1 end + pcall(HudTiles.tile, code, 0, 0) + love.graphics.draw = real + return n + end + local CHROME = { + [0x73] = "corner tick", [0x74] = "bar left cap", [0x76] = "underline run", + [0x77] = "player underline left", [0x78] = "enemy underline right", + [0x6F] = "player underline end", + } + for code, what in pairs(CHROME) do + check(("HUD chrome tile $%02X (%s) resolves"):format(code, what), + tileDraws(code) > 0) + end + + local mapDef = game.data.maps[MAP] + local trainerDef + for _, o in ipairs(mapDef and mapDef.objects or {}) do + if o.name == TRAINER then trainerDef = o end + end + check(TRAINER .. " is still on " .. MAP, trainerDef ~= nil) + if trainerDef then + check("it is still a trainer object", + trainerDef.trainerClass ~= nil and trainerDef.trainerParty ~= nil) + U.log(" ", TRAINER, trainerDef.trainerClass, "party", trainerDef.trainerParty, + "at", trainerDef.x, trainerDef.y, "range", tostring(trainerDef.range)) + end + + -- Record what drawHUDs puts on screen for one frame without drawing it: + -- drawBallRow is shadowed on the instance, Font.draw on the module + -- BattleState routes its HUD strings through. + local function snapshot(battle) + local rows, strings = {}, {} + local realRow, realFont = battle.drawBallRow, Font.draw + local realDraw = love.graphics.draw + battle.drawBallRow = function(_, party, x, y, dx) + rows[#rows + 1] = { count = #party, x = x, y = y, step = dx } + end + Font.draw = function(text) strings[#strings + 1] = tostring(text) end + love.graphics.draw = function() end + pcall(battle.drawHUDs, battle, 0) + battle.drawBallRow, Font.draw = realRow, realFont + love.graphics.draw = realDraw + return rows, strings + end + + local function drewString(strings, want) + for _, s in ipairs(strings) do + if s:find(want, 1, true) then return true end + end + return false + end + + -- ---- part 1: a WILD intro, driven and photographed --------------------- + game.save.party = { + Pokemon.new(game.data, "BULBASAUR", 12), + Pokemon.new(game.data, "PIDGEY", 9), + } + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(10) + local ow = game.overworld + check("the overworld is up on " .. MAP, ow ~= nil) + + local wild = BattleState.newWild(game, "RATTATA", 3) + wild.onFinish = function() end + if ow then ow:pushBattle(wild) end + for _ = 1, 400 do + if game.stack:top() == wild and (wild.introSlide or 0) == 0 then break end + U.wait(1) + end + check("the wild battle reached the screen", + game.stack:top() == wild and (wild.introSlide or 0) == 0) + check("the DrawAllPokeballs window is open", wild.introBalls == true) + + local rows, strings = snapshot(wild) + check("a WILD intro draws exactly one ball row", #rows == 1) + if rows[1] then + check("it is the player's row at (88, 80) stepping +8", + rows[1].x == 88 and rows[1].y == 80 and rows[1].step == 8) + check("it carries all " .. #game.save.party .. " party slots", + rows[1].count == #game.save.party) + end + check("the enemy HUD is NOT up under the intro box", + not drewString(strings, wild.enemy.name)) + if not U.shot(game, DIR .. "/bug317_1_wild_intro.png") then + U.log("FAIL could not capture the wild intro") + end + + -- the page finishes typing, then PromptText's arrow blinks + local prompted = false + for _ = 1, 300 do + if wild.msgPrompt then prompted = true break end + U.wait(1) + end + check("the typed-out intro page raises the blinking prompt", prompted) + -- two shots half a blink apart; drawTextArea draws '▼' for frame % 60 < 30 + for _ = 1, 60 do + if (wild.frame % 60) < 6 then break end + U.wait(1) + end + U.shot(game, DIR .. "/bug317_2_arrow_on.png") + for _ = 1, 60 do + if (wild.frame % 60) >= 34 then break end + U.wait(1) + end + U.shot(game, DIR .. "/bug317_3_arrow_off.png") + + -- dismiss it: ClearSprites + both ClearScreenAreas, then the enemy HUD + U.tap(game, "a") + U.wait(4) + check("dismissing the box closes the window", wild.introBalls == nil) + local rows2, strings2 = snapshot(wild) + check("no ball row survives the dismissal", #rows2 == 0) + check("the enemy HUD appears once the box is gone", + drewString(strings2, wild.enemy.name)) + U.shot(game, DIR .. "/bug317_4_enemy_hud.png") + + -- The back pic walking off the left edge before "Go! X!". Keep watching + -- past the mid-slide shot: U.shot spins frames of its own, so breaking on + -- it would under-report how far the pic travelled. + local lowest, shot = 0, false + for _ = 1, 400 do + local off = wild:picOffset("back") + if off < lowest then lowest = off end + if not shot and off <= -24 and off >= -52 then + shot = true + U.shot(game, DIR .. "/bug317_5_back_slide.png") + end + if wild.phase == "menu" or wild.showPlayerBack == false then break end + U.wait(1) + end + check("a mid-slide frame was captured", shot) + check("the back pic walked the full 9 tiles off the left edge (reached " + .. lowest .. "px of -72)", lowest <= -72) + + -- ---- part 2: a LIVE trainer intro, handed over ------------------------- + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(10) + ow = game.overworld + + local function liveBattle() + for _, s in ipairs(game.stack.states or {}) do + if getmetatable(s) == BattleState then return s end + end + return nil + end + + local function trainerNpc() + for _, n in ipairs((ow and ow.npcs) or {}) do + if n.def and n.def.name == TRAINER then return n end + end + return nil + end + + local npc = trainerNpc() + check("the trainer object loaded on the live map", npc ~= nil) + + -- Walk WEST along row 6 into the sight line. If a map edit moved the + -- trainer, re-approach at whatever row it now sits on. + if npc and (npc.cellY ~= STAND.y) then + U.log("trainer moved to", npc.cellX, npc.cellY, "-- re-approaching") + U.teleport(game, MAP, math.min(npc.cellX + 3, (ow.map.widthCells or 60) - 1), + npc.cellY, "left") + U.wait(10) + end + + local battle + for _ = 1, 12 do + U.hold(game, "left", 12) + U.wait(20) + battle = liveBattle() + if battle then break end + -- the pre-battle line ("I like shorts!") holds the overworld; clear it + U.tap(game, "a") + U.wait(20) + battle = liveBattle() + if battle then break end + end + + if not battle then + -- rather than park the player facing nothing, push the same battle + U.log("FAIL the sight line did not trip; pushing the battle directly") + local cls = trainerDef and trainerDef.trainerClass or "OPP_BUG_CATCHER" + local pty = trainerDef and trainerDef.trainerParty or 4 + battle = BattleState.newTrainer(game, cls, pty) + battle.onFinish = function() end + if ow then ow:pushBattle(battle) end + end + + for _ = 1, 400 do + if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end + U.wait(1) + end + check("a trainer battle is on screen", liveBattle() ~= nil) + check("its intro window is open", battle.introBalls == true) + local rows3 = snapshot(battle) + check("a TRAINER intro draws BOTH ball rows", #rows3 == 2) + if rows3[1] and rows3[2] then + check("the foe's row is at (64, 16) stepping -8", + rows3[1].x == 64 and rows3[1].y == 16 and rows3[1].step == -8) + check("the player's row is at (88, 80) stepping +8", + rows3[2].x == 88 and rows3[2].y == 80 and rows3[2].step == 8) + end + U.shot(game, DIR .. "/bug317_6_trainer_intro.png") + + -- ---- hand off ---------------------------------------------------------- + U.log("The BUG CATCHER's intro box is up. Press A to walk the rest of it.") + U.log("Correct: six ball slots per side with a thin underline under each row,") + U.log("an arrow blinking bottom-right, and each trainer pic WALKING off its") + U.log("own edge before its send-out text prints (#317).") + U.log("Screenshots of the wild half: " .. DIR .. "/bug317_*.png") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/battle_transition_hold_bug315_test.lua b/tests/drivers/battle_transition_hold_bug315_test.lua new file mode 100644 index 00000000..17e64d9c --- /dev/null +++ b/tests/drivers/battle_transition_hold_bug315_test.lua @@ -0,0 +1,134 @@ +-- Driver: the black pause between the battle wipe and the battle screen (#315). +-- Shrink and Split end with BattleTransition_BlackScreen then DelayFrames 10 +-- (engine/battle/battle_transitions.asm:390-392, 422-424); the other six get +-- the same gap free from the load the original hides behind. BLACK_HOLD in +-- src/render/BattleTransition.lua is the knob. Not under POKEPORT_SPEED. +-- POKEPORT_DRIVER=tests/drivers/battle_transition_hold_bug315_test.lua 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 Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local BattleTransition = require("src.render.BattleTransition") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- BLACK_HOLD is a file-local, so this is the driver's own copy of it + local TARGET = 30 + + game.save.player.name = "bryan" + game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) } + + U.teleport(game, "ROUTE_1", 5, 5, "down") + U.wait(20) + local ow = game.overworld + check("overworld is up to push the battle from", ow ~= nil) + + -- ---- measure the hold -------------------------------------------------- + -- No screenshots in this pass: U.shot spins frames of its own and would + -- corrupt the count. + local function measure(opts) + local battle = BattleState.newWild(game, "PIDGEY", 8) + battle.onFinish = function() end + ow:pushBattle(battle) + local tr = game.stack:top() + if getmetatable(tr) ~= BattleTransition then return nil, "no transition" end + local style, wipeLen = tr.style, tr.wipeLen + local f, wipeDone, popped = 0, nil, nil + while f < 600 do + f = f + 1 + if not wipeDone and tr.phase == "wipe" and tr.t >= wipeLen then + wipeDone = f + end + if game.stack:top() ~= tr then popped = f break end + U.wait(1) + end + return { style = style, wipeLen = wipeLen, wipeDone = wipeDone, + popped = popped, battle = battle, tr = tr }, nil + end + + local m, err = measure() + check("a BattleTransition was pushed for the battle", m ~= nil) + if not m then + U.log("could not measure:", tostring(err)) + while true do coroutine.yield() end + end + U.log(("style %s: wipe is %d frames"):format(tostring(m.style), m.wipeLen)) + check("the wipe reached its last frame", m.wipeDone ~= nil) + check("the transition popped itself", m.popped ~= nil) + local hold = (m.wipeDone and m.popped) and (m.popped - m.wipeDone) or -1 + U.log(("black hold measured: %d frames (%.2f s at 60 Hz); target %d") + :format(hold, hold / 60, TARGET)) + check(("the screen holds black well past the old 6 frames (got %d) (#315)") + :format(hold), hold >= 20) + check(("the hold is not absurdly long either (got %d)"):format(hold), + hold <= 90) + + -- unwind that battle before the next one + while game.stack:top() ~= ow do game.stack:pop() end + U.wait(10) + + -- ---- screenshot the beat ------------------------------------------------ + -- One frame inside the hold (solid black edge to edge) and one after the pop. + local battle = BattleState.newWild(game, "PIDGEY", 8) + battle.onFinish = function() end + ow:pushBattle(battle) + local tr = game.stack:top() + check("second transition pushed", getmetatable(tr) == BattleTransition) + for _ = 1, 600 do + if tr.phase == "wipe" and tr.t >= tr.wipeLen then break end + U.wait(1) + end + check("hold screenshot reached disk", + U.shot(game, DIR .. "/bug315_black_hold.png")) + U.log("captured", DIR .. "/bug315_black_hold.png", + "-- this one must be SOLID BLACK") + for _ = 1, 300 do + if game.stack:top() ~= tr then break end + U.wait(1) + end + U.wait(2) + check("post-hold screenshot reached disk", + U.shot(game, DIR .. "/bug315_battle_appears.png")) + U.log("captured", DIR .. "/bug315_battle_appears.png") + + -- back to the overworld and into tall grass, so the hand-off is a real + -- encounter rather than a scripted push + while game.stack:top() ~= ow do game.stack:pop() end + U.wait(10) + + -- Grass cell comes off the loaded map, not a hard-coded pair, so a map edit + -- degrades to "somewhere else in the grass" instead of a wall. + local map = ow.map + local gx, gy + for cy = 0, map.heightCells - 1 do + for cx = 0, map.widthCells - 1 do + if map:isGrassCell(cx, cy) and map:isWalkableCell(cx, cy) + and map:isWalkableCell(cx, cy + 1) then + gx, gy = cx, cy + break + end + end + if gx then break end + end + check("found a tall-grass cell on ROUTE_1 to hand off in", gx ~= nil) + if gx then + U.log(("standing in the grass at (%d, %d)"):format(gx, gy)) + U.teleport(game, "ROUTE_1", gx, gy, "down") + U.wait(10) + end + + -- ---- hand off, then stay out of the way -------------------------------- + U.log("You are in the tall grass on ROUTE 1. Walk until an encounter fires and") + U.log("watch the join: the wipe should finish, the screen sit solid black for") + U.log(("about half a second (%d frames this run), and only then the battle") + :format(hold)) + U.log("screen appear (#315). Wipe style varies, so try it a few times.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/bills_pc_test.lua b/tests/drivers/bills_pc_test.lua index 8b4e57aa..2f9827be 100644 --- a/tests/drivers/bills_pc_test.lua +++ b/tests/drivers/bills_pc_test.lua @@ -1,4 +1,4 @@ --- Driver: Bill's PC (#177) — chrome (What? / BOX No. / ) and +-- Driver: Bill's PC (#177) -- chrome (What? / BOX No. / ) and -- withdraw/deposit returning to BillsPCMenu instead of closing the PC. return function(game) diff --git a/tests/drivers/blackout_palette_bug292_test.lua b/tests/drivers/blackout_palette_bug292_test.lua new file mode 100644 index 00000000..d1f0b71d --- /dev/null +++ b/tests/drivers/blackout_palette_bug292_test.lua @@ -0,0 +1,134 @@ +-- Driver: SET_PAL_BATTLE_BLACK darkens the whole battle screen while the +-- blackout text is up (#292). pokered engine/battle/core.asm:1147-1159 +-- runs the palette command before the text, and returns early on OAKS_LAB. +-- No POKEPORT_SPEED here: audio runs on its own real-time clock. +-- POKEPORT_DRIVER=tests/drivers/blackout_palette_bug292_test.lua \ +-- POKEPORT_IDENTITY=bug292 POKEPORT_TOUCH=0 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 Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local PaletteFX = require("src.render.PaletteFX") + + -- Route 1: no scripted battle, not the Oak's Lab exception, and (5,5) is + -- open floor there (data/generated/maps.lua). + local MAP = "ROUTE_1" + local STAND = { x = 5, y = 5, facing = "down" } + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- ---- preconditions the eye cannot check -------------------------------- + -- A missing PAL_BLACK looks exactly like the bug on screen. + local pack = PaletteFX.pack(game.data) + local BLACK = pack and pack.palettes and pack.palettes.BLACK + check("the active COLORS pack carries PAL_BLACK", BLACK ~= nil) + if BLACK then + U.log(" PAL_BLACK shades:", + ("0 = %d,%d,%d"):format(BLACK[1][1], BLACK[1][2], BLACK[1][3]), + ("1 = %d,%d,%d"):format(BLACK[2][1], BLACK[2][2], BLACK[2][3]), + ("3 = %d,%d,%d"):format(BLACK[4][1], BLACK[4][2], BLACK[4][3])) + check("shade 0 is the near-white paper", BLACK[1][1] > 200) + check("shades 1-3 are near-black ink", + BLACK[2][1] < 90 and BLACK[3][1] < 90 and BLACK[4][1] < 90) + end + + -- A DMG never had SGB darkening, so the fix deliberately does nothing in + -- the forced-mono modes and a run in one of them proves nothing. + local mode = PaletteFX.mode + local mono = mode == "og" or mode == "og_inv" or mode == "classic" + U.log("COLORS mode:", tostring(mode), "(" .. PaletteFX.modeLabel(mode) .. ")") + if mono then + U.log("WARNING: " .. PaletteFX.modeLabel(mode) .. " is a forced-mono mode.") + U.log("WARNING: PAL_BLACK is an SGB packet and a DMG never darkened, so") + U.log("WARNING: nothing below will look dark and that is CORRECT. Press 2") + U.log("WARNING: (or set COLORS in OPTION) to SGB / GBC first, then re-run.") + end + check("the COLORS mode is one that can show the darkening", not mono) + + -- ---- a losing battle --------------------------------------------------- + -- One mon on 1 HP against something that outspeeds it. + game.save.party = { Pokemon.new(game.data, "RATTATA", 3) } + game.save.party[1].hp = 1 + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(10) + local ow = game.overworld + check("the overworld is up on " .. MAP, ow ~= nil) + check("this is not the Oak's Lab starter-rival exception", + (ow and ow.map and ow.map.id) ~= "OAKS_LAB") + + local battle = BattleState.newWild(game, "RATICATE", 50) + battle.onFinish = function() end + if ow then ow:pushBattle(battle) end + for _ = 1, 400 do + if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end + U.wait(1) + end + check("the battle reached the screen", game.stack:top() == battle) + + -- photograph the full-color screen so the "after" shot has a reference + for _ = 1, 60 do + if battle.phase == "menu" then break end + U.tap(game, "a") + U.wait(4) + end + check("the battle reached its action menu", battle.phase == "menu") + check("nothing is darkened yet", battle.blackedOut == nil) + if not U.shot(game, DIR .. "/bug292_1_full_color.png") then + U.log("FAIL could not capture the full-color battle") + end + + -- ---- lose it ----------------------------------------------------------- + -- Stop the instant the wipe registers, so the blackout text is still ahead + -- of the player. + for _ = 1, 700 do + if battle.blackedOut then break end + if battle.result then break end + U.tap(game, "a") + U.wait(4) + end + if not battle.blackedOut and not battle.result then + -- the foe kept rolling status moves: take the engine's own faint path + U.log("the foe never landed a hit; forcing the KO through onFaint") + battle.player.mon.hp = 0 + battle.nextInsert = 0 + battle:onFaint(battle.player) + for _ = 1, 400 do + if battle.blackedOut then break end + U.tap(game, "a") + U.wait(4) + end + end + check("the party was wiped out and the screen blacked", battle.blackedOut == true) + + -- Two code paths darken the screen (the zone pass for HUD and bars, a + -- re-baked pic for the sprites) and both have to fire. + if BLACK then + local pals = battle:sgbBattlePals() + check("all four battle zones are PAL_BLACK", + pals ~= nil and pals[0] == BLACK and pals[1] == BLACK + and pals[2] == BLACK and pals[3] == BLACK) + end + + U.wait(20) + if not U.shot(game, DIR .. "/bug292_2_blacked_out.png") then + U.log("FAIL could not capture the blacked-out battle") + end + local cur = battle.current + U.log("box on screen:", cur and cur.text and (cur.text:gsub("\n", " / ")) + or "(between rows)") + + -- ---- hand off ---------------------------------------------------------- + U.log("Your last POKeMON has fainted; press A through the blackout text.") + U.log("Behind the box the enemy sprite, the HP bar and the HUD should all") + U.log("be near-black on near-white for both lines (#292). Compare") + U.log(DIR .. "/bug292_1_full_color.png with bug292_2_blacked_out.png.") + U.log("OG / OG INV / CLASSIC and the Oak's lab rival fight never darken.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/boulder_text_bug318_test.lua b/tests/drivers/boulder_text_bug318_test.lua index d8450cea..e41f237f 100644 --- a/tests/drivers/boulder_text_bug318_test.lua +++ b/tests/drivers/boulder_text_bug318_test.lua @@ -1,33 +1,14 @@ --- Driver: manual check that a boulder answers an A press (#318). --- --- pokered home/overworld_text.asm:16 is the whole of the behavior: --- BoulderText:: --- text_far _BoulderText --- text_end --- and _BoulderText (data/text/text_1.asm:44) is "This requires\nSTRENGTH to --- move!". Every boulder object on every map points its text at that one --- label, which the extractor records as `asm = true` with a label and no --- text -- so Data:resolveText returned nil and pressing A at a boulder did --- nothing at all, no box, no sound. --- --- The data half is asserted in tests/parity_asm_plain_text.lua. What this --- driver adds is the end-to-end path an assertion does not cover: that --- OverworldState:showMapText actually reaches the fallback for a real map --- object, and that the box looks right on screen. --- --- Do NOT add POKEPORT_SPEED: the text box types on the logic clock while --- Press_AB and the box sounds run on the real-time audio accumulator --- (src/core/Game.lua), so a fast run misrepresents the moment. --- --- POKEPORT_DRIVER=tests/drivers/boulder_text_bug318_test.lua \ --- POKEPORT_IDENTITY=bug318 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +-- Manual check that a boulder answers an A press (#318). +-- Every boulder points at BoulderText (pokered home/overworld_text.asm:16), +-- i.e. _BoulderText "This requires\nSTRENGTH to move!", which the extractor +-- stores as an asm label with no text: resolveText returned nil and A did +-- nothing. The data half is asserted in tests/parity_asm_plain_text.lua. +-- POKEPORT_DRIVER=tests/drivers/boulder_text_bug318_test.lua POKEPORT_IDENTITY=bug318 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . return function(game) local U = dofile("tests/drivers/util.lua") - -- pokered data/maps/objects/VictoryRoad1F.asm: BOULDER3 sits at (2, 10). - -- Its east and west neighbours are both wall, so the free approach is the - -- floor directly below it, facing up. (Verified against data/generated/ - -- maps.lua rather than assumed: the two side cells report walkable=false.) + -- pokered data/maps/objects/VictoryRoad1F.asm: BOULDER3 sits at (2, 10) with + -- wall to its east and west, so the only free approach is the floor below it. -- Talking to a boulder needs no STRENGTH and no badge. local MAP = "VICTORY_ROAD_1F" local BOULDER = "VICTORYROAD1F_BOULDER3" @@ -40,9 +21,8 @@ return function(game) return ok end - -- ---- preconditions the eye cannot check -------------------------------- - -- A missing string, a renamed label or a text entry the fallback does not - -- reach all produce the same nothing-happens as the bug did. + -- a missing string, a renamed label and an unreached fallback all show up as + -- the same nothing-happens the bug did local text = game.data:resolveText(MAP_LABEL, TEXT) check(MAP_LABEL .. "/" .. TEXT .. " resolves to a string", type(text) == "string" and text ~= "") @@ -53,7 +33,7 @@ return function(game) U.log("boulder text reads:", (text:gsub("\n", " / "))) end - -- ---- park the player against the boulder ------------------------------- + -- park the player against the boulder U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) U.wait(10) @@ -64,8 +44,8 @@ return function(game) return nil end - -- re-reads game.overworld every call: the fallback below teleports again, - -- which rebuilds the state and its npc list + -- re-reads game.overworld every call: the fallback below teleports again and + -- that rebuilds the state and its npc list local function facingTheBoulder() local ow = game.overworld local rock = ow and boulderIn(ow) @@ -79,10 +59,9 @@ return function(game) check("boulder object loaded on " .. MAP, rock ~= nil) if rock and not facingTheBoulder() then - -- the hard-coded approach cell stopped working (map edit, or a mod moved - -- the object): take any free walkable neighbour and turn back toward it. - -- {dx, dy, facing} is the offset from the boulder to the stand cell plus - -- the direction that looks back at it, so +1 on x means facing left. + -- a map edit or a mod moved the object: fall back to any free walkable + -- neighbour. {dx, dy, facing} is the offset from the boulder to the stand + -- cell plus the direction that looks back at it, so +1 on x means left. local sides = { { 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" }, } @@ -99,9 +78,8 @@ return function(game) end check("player is standing against the boulder", facingTheBoulder()) - -- ---- press A once, so the box is already open on hand-off --------------- - -- The eye cannot tell "no text entry" from "the A press never reached the - -- boulder", so open the box here and report which of the two happened. + -- press A here so the log can tell "no text entry" from "the press never + -- reached the boulder"; on screen the two look identical local TextBox = require("src.render.TextBox") U.tap(game, "a") U.wait(30) @@ -123,23 +101,10 @@ return function(game) U.log("captured", SHOT_DIR .. "/bug318_boulder.png") end - -- ---- hand off, then stay out of the way -------------------------------- - U.log("........................................................") - U.log("LOOK NOW: the boulder has already been talked to for you -- the") - U.log("box on screen is the result. Press A or B to close it, then press") - U.log("A again at the boulder to repeat it as often as you like.") - U.log(" RIGHT: the box types \"This requires\" / \"STRENGTH to move!\"") - U.log(" over two lines, then the arrow blinks and it waits for") - U.log(" your A or B.") - U.log(" BUG #318 looks like: pressing A does nothing whatsoever -- no") - U.log(" box, no sound, the boulder just sits there.") - U.log(" ALSO WRONG: the box opens empty or blank, opens and closes") - U.log(" itself without waiting, or the second line lands on the") - U.log(" box's bottom border instead of inside it (that one is") - U.log(" #314, fixed alongside this).") - U.log("Input is yours from here on -- press A as many times as you like.") - U.log("There are two more boulders on this floor, at (5,15) and (14,2).") - U.log("........................................................") + U.log("The boulder has been talked to already; the box on screen is that.") + U.log("It should type \"This requires\" / \"STRENGTH to move!\" and wait for") + U.log("A or B. Before #318 the press did nothing at all: no box, no sound.") + U.log("Two more boulders on this floor, at (5,15) and (14,2).") while true do coroutine.yield() diff --git a/tests/drivers/caterpillar_text_bug250_test.lua b/tests/drivers/caterpillar_text_bug250_test.lua index 5b639721..23f2d61e 100644 --- a/tests/drivers/caterpillar_text_bug250_test.lua +++ b/tests/drivers/caterpillar_text_bug250_test.lua @@ -1,32 +1,8 @@ --- Driver: the Viridian caterpillar speech waits for the player (#250). --- A manual eye check, not a pass/fail run -- nothing in this repo can judge +-- Eye check on the Viridian caterpillar speech (#250): no test can judge -- "the text went by too fast to read". --- --- pokered evidence. text/ViridianCity.asm spells the answer with three --- different break markers, and they are not interchangeable: --- --- ViridianCityYoungster2CaterpieAndWeedleDescriptionText:: --- text "CATERPIE has no" --- line "poison, but" --- cont "WEEDLE does." --- --- para "Watch out for its" --- line "POISON STING!" --- done --- --- src/render/TextBox.lua maps those to \n (second line), \v (scroll one --- line, after the down-arrow and a button press) and \f (page break, clear --- and wait). This text is not in data/generated/text.lua -- pokered --- declares it without the leading underscore the extractor keys on -- so --- the port carries a literal fallback, and that fallback had spelled both --- `cont` and `para` as plain \n. Six lines then landed on one page with --- nothing to wait on, so the whole speech scrolled past untouched. --- --- Do NOT run this under POKEPORT_SPEED. Fast-forward scales only the logic --- clock while the typewriter and its SFX run on their own real-time --- accumulator (src/core/Game.lua), which is precisely the pacing being --- judged here. --- +-- pokered text/ViridianCity.asm spells that answer with line, cont and para, +-- which TextBox maps to \n, \v and \f. The port's literal fallback (the label +-- has no leading underscore, so it is not in text.lua) had all three as \n. -- POKEPORT_DRIVER=tests/drivers/caterpillar_text_bug250_test.lua POKEPORT_IDENTITY=bug250 love . return function(game) local U = dofile("tests/drivers/util.lua") @@ -36,20 +12,17 @@ return function(game) return ok end - -- ---- preconditions the eye cannot separate from the bug ---------------- - -- A missing handler, or an NPC who wandered off, both end in "no text", - -- which is not the same failure as "text that does not wait". - local MAP = "VIRIDIAN_CITY" local NPC = "VIRIDIANCITY_YOUNGSTER2" local TEXT = "TEXT_VIRIDIANCITY_YOUNGSTER2" + -- a missing handler, or an NPC who wandered off, ends in "no text", which is + -- not the same failure as "text that does not wait" local mapScripts = require("data.scripts.init") check("a hand-ported handler exists for " .. TEXT, mapScripts.talkScript(MAP, TEXT) ~= nil) - -- the fix itself: the paginator has to find a page break and a scroll in - -- the description, because that is what makes it wait for a button + -- the page break and the scroll are what make the box wait for a button local TextBox = require("src.render.TextBox") local desc = "CATERPIE has no\npoison, but\vWEEDLE does.\fWatch out for its\nPOISON STING!" local pages = TextBox.paginate(desc) @@ -58,8 +31,7 @@ return function(game) pages[1] ~= nil and #pages[1] == 3) check("page 2 is two lines (para + line)", pages[2] ~= nil and #pages[2] == 2) - -- the wait itself: contBefore marks the line the box holds on until the - -- player presses a button, which is the whole point of the fix + -- contBefore marks the line the box holds on until a button press check("line 3 waits for a button before scrolling in", pages.contBefore and pages.contBefore[1] and pages.contBefore[1][3] == true) @@ -78,10 +50,9 @@ return function(game) -- pin the wander: he strolls out from under the A press between reads if npc then npc.wanders = false end - -- objects live at ../pokered/data/maps/objects/ViridianCity.asm and in - -- data/generated/maps.lua; he is at (30,25), so (30,26) faces him. If a - -- map edit ever moves him, stand on any free walkable neighbour instead - -- of parking the player at a wall. + -- pokered data/maps/objects/ViridianCity.asm puts him at (30,25), so (30,26) + -- faces him; if a map edit moves him, the fallback below picks a free + -- walkable neighbour instead of parking the player at a wall. local function facingTarget() local ow = game.overworld if not (ow and npc) then return false end @@ -104,22 +75,10 @@ return function(game) end check("player is standing in front of the youngster", facingTarget()) - U.log("........................................................") - U.log("READ NOW: press A to talk, then answer YES to his question.") - U.log(" RIGHT: the answer stops and waits for you three times. After") - U.log(" 'WEEDLE does.' the box waits with a down-arrow, then the") - U.log(" page CLEARS before 'Watch out for its POISON STING!'.") - U.log(" BUG #250 looks like: all six lines pouring past in one go, the") - U.log(" box scrolling itself, and the conversation ending before") - U.log(" you have pressed anything.") - U.log(" ALSO WRONG: it waits but never clears the page (that is \\v where") - U.log(" pokered has para), or it clears after every single line") - U.log(" (that is \\f where pokered has line/cont).") - U.log("Control case: answer NO instead and you should get the one-line") - U.log(" 'Oh, OK then!', which needs no waits at all.") - U.log("Input is yours from here, so you can re-run the talk as often as") - U.log("you like.") - U.log("........................................................") + U.log("Press A to talk, answer YES, and read the description.") + U.log("It should stop for you three times, and the page should CLEAR before") + U.log("'Watch out for its POISON STING!' -- under #250 all six lines poured") + U.log("past in one go. Answering NO is the control: one line, no waits.") while true do coroutine.yield() diff --git a/tests/drivers/cycling_road_brake_bug255_test.lua b/tests/drivers/cycling_road_brake_bug255_test.lua new file mode 100644 index 00000000..0e3838d7 --- /dev/null +++ b/tests/drivers/cycling_road_brake_bug255_test.lua @@ -0,0 +1,101 @@ +-- Driver: holding A or B on Cycling Road holds you in place (#255). pokered +-- home/overworld.asm:1825 masks PAD_A and PAD_B along with the d-pad before it +-- forces PAD_DOWN. Hands check, so no POKEPORT_SPEED. +-- POKEPORT_DRIVER=tests/drivers/cycling_road_brake_bug255_test.lua \ +-- POKEPORT_IDENTITY=bug255 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \ +-- SHOT_DIR=/tmp/shots love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- x=2 is open road from y=8 past y=34; y=20 keeps the demo roll clear of the + -- bikers at (4,18) and (7,32). + local MAP, START_X, START_Y = "ROUTE_17", 2, 20 + + -- ---- preconditions ----------------------------------------------------- + -- "the bike does not roll" and "the brake works" look the same, so prove the + -- hill is pulling first. + + local fm = game.data.field.forcedMovement + local isSlope = false + for _, id in ipairs((fm and fm.slopeMaps) or {}) do + if id == MAP then isSlope = true end + end + check("field.forcedMovement.slopeMaps names ROUTE_17", isSlope) + + game.save.onBike = true + check("the player is on the BICYCLE (no bike, no roll)", 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", ow.map.id == MAP) + local clear = true + for dy = 1, 8 do + if not ow.map:isWalkableCell(START_X, START_Y + dy) then clear = false end + end + check(("open road for 8 cells south of (%d,%d)"):format(START_X, START_Y), clear) + + -- ---- the hill pulls ---------------------------------------------------- + local y0 = ow.player.cellY + U.wait(48) + local rolled = ow.player.cellY - y0 + check(("hands off the pad, the bike rolls south (%d cells in 48 frames)") + :format(rolled), rolled > 0) + U.shot(game, SHOT_DIR .. "/bug255_rolling.png") + + -- ---- and A / B stop it ------------------------------------------------- + -- Input:beginStep rebuilds `pressed` from the queue every fixed step, so one + -- queued edge plus a sticky input.state is exactly a button held down. + local function holdFor(btn, frames) + local ow2 = game.overworld + table.insert(game.input.pressQueue, btn) -- the key-down edge + game.input.state[btn] = true + -- the mask only suppresses the NEXT simulated PAD_DOWN, so a step already + -- under way still lands; the brake is measured from where it puts you + for _ = 1, 24 do + if not ow2.player.moving then break end + coroutine.yield() + end + local start = ow2.player.cellY + local drift = 0 + for _ = 1, frames do + coroutine.yield() + if ow2.player.cellY ~= start then drift = ow2.player.cellY - start end + end + check(("holding %s for %d frames: the player does not drift south (%d cells)") + :format(btn:upper(), frames, drift), drift == 0) + U.shot(game, ("%s/bug255_braking_%s.png"):format(SHOT_DIR, btn)) + game.input.state[btn] = false + local held = ow2.player.cellY + U.wait(48) + check(("releasing %s resumes the roll (%d cells in 48 frames)") + :format(btn:upper(), ow2.player.cellY - held), + ow2.player.cellY > held) + end + + holdFor("a", 120) + holdFor("b", 120) + + -- park somewhere with road left to roll before handing over + U.teleport(game, MAP, START_X, START_Y, "down") + U.wait(10) + game.save.onBike = true + + -- ---- hand off ---------------------------------------------------------- + U.log(("On the bike on Cycling Road at (%d,%d), already rolling south.") + :format(START_X, START_Y)) + U.log("Hold A, then B, then let go, then hold A while pressing DOWN.") + U.log("Correct: dead stop while held, roll resumes the instant you release,") + U.log("a held direction still steers. #255 was B doing nothing and A") + U.log("stalling one frame. Shots in " .. SHOT_DIR .. "/bug255_*.png") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/dex_pic_bug307_test.lua b/tests/drivers/dex_pic_bug307_test.lua index 0bf2c0a0..493c9767 100644 --- a/tests/drivers/dex_pic_bug307_test.lua +++ b/tests/drivers/dex_pic_bug307_test.lua @@ -1,30 +1,15 @@ --- Driver: manual look at the Pokedex entry pic (#307). --- --- DexEntryMenu loaded its front pic through --- local ok, img = path and pcall(love.graphics.newImage, path) --- which reads as a guarded pcall but is not one: `path and pcall(...)` is an --- expression, so Lua adjusts it to a single value, `img` is always nil, and --- every dex page drew with an empty pic box. Both callers were hit -- the --- starter previews on Oak's lab balls (StarterDex, engine/events/ --- starter_dex.asm, which forces the owned bit so the page fills in) and --- every entry opened from the Pokedex list. --- --- tests/parity_dex_pic.lua asserts the page now holds an image. What it --- cannot judge is placement: pokedex.asm draws the pic at the top-left of --- the page and DexEntryMenu bottom-aligns it against y=60, so a wrong-sized --- or wrong-anchored sprite still "loads" while looking broken. --- --- Screenshots land in the scratch path given below; the run also parks a --- live Pokedex so the pages can be paged through by hand. --- --- POKEPORT_DRIVER=tests/drivers/dex_pic_bug307_test.lua \ --- POKEPORT_IDENTITY=bug307 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +-- Look at the Pokedex entry pic (#307). +-- `local ok, img = path and pcall(love.graphics.newImage, path)` is one +-- expression, not a guarded pcall: img was always nil and every dex page drew +-- an empty pic box. parity_dex_pic.lua asserts the image loads; placement +-- (top-left of the page, bottom-aligned to y=60) is the half only an eye can judge. +-- POKEPORT_DRIVER=tests/drivers/dex_pic_bug307_test.lua POKEPORT_IDENTITY=bug307 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . return function(game) local U = dofile("tests/drivers/util.lua") local Screens = require("src.ui.Screens") local Sprites = require("src.pokemon.Sprites") - -- the three starters are the reported case (Oak's lab preview), plus one + -- the starters are the reported case (Oak's lab preview); PIDGEY is an -- ordinary list entry as the control local CASES = { "BULBASAUR", "CHARMANDER", "SQUIRTLE", "PIDGEY" } @@ -33,9 +18,8 @@ return function(game) return ok end - -- ---- preconditions the eye cannot check -------------------------------- - -- A path that does not resolve, a PNG that is missing from the cache, and - -- the truncation bug all render as the same empty box. + -- an unresolved path, a PNG missing from the cache and the truncation bug + -- itself all render as the same empty box local DexEntryMenu = require("src.ui.DexEntryMenu") for _, species in ipairs(CASES) do local path = Sprites.path(game.data, species, "front", { kind = "dex" }) @@ -51,7 +35,7 @@ return function(game) end end - -- ---- mark the cases seen so the list can reach them --------------------- + -- mark the cases seen so the list can reach them local dex = game.save.pokedex if dex then for _, species in ipairs(CASES) do @@ -61,7 +45,7 @@ return function(game) U.log("flagged", #CASES, "species as seen+owned so the list can open them") end - -- ---- screenshots, one page at a time ------------------------------------ + -- screenshots, one page at a time local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" for _, species in ipairs(CASES) do while game.stack:top() do game.stack:pop() end @@ -71,24 +55,15 @@ return function(game) U.log("captured", SHOT_DIR .. "/dex_" .. species:lower() .. ".png") end - -- ---- hand off on a live page, then stay out of the way ------------------- + -- leave a live page up for the hand-off while game.stack:top() do game.stack:pop() end Screens.push(game, "DexEntryMenu", { species = "BULBASAUR", forceOwned = true }) U.wait(10) - U.log("........................................................") - U.log("LOOK NOW: a BULBASAUR Pokedex entry is open on screen.") - U.log(" RIGHT: the BULBASAUR front sprite sits in the top-left of the") - U.log(" page, sitting on the line above HT/WT, with the name,") - U.log(" SEED POKeMON, No.001, height, weight and description") - U.log(" filled in down the right and bottom.") - U.log(" BUG #307 looks like: the whole left side is blank white where") - U.log(" the sprite belongs, everything else drawn normally.") - U.log(" ALSO WRONG: the sprite is there but floats too high or too low,") - U.log(" overlaps the text, or is the wrong species entirely.") - U.log("Screenshots of all four cases were written to " .. SHOT_DIR) - U.log("Press A or B to close the page; input is yours from here on.") - U.log("........................................................") + U.log("A BULBASAUR dex entry is open; shots of all four cases are in " .. SHOT_DIR) + U.log("The front sprite belongs in the top-left, standing on the line above") + U.log("HT/WT, not floating or overlapping the text. Under #307 that whole") + U.log("side was blank white. A or B closes the page.") while true do coroutine.yield() diff --git a/tests/drivers/dialogue_cont_wait_test.lua b/tests/drivers/dialogue_cont_wait_test.lua index 306d2ddf..6353da56 100644 --- a/tests/drivers/dialogue_cont_wait_test.lua +++ b/tests/drivers/dialogue_cont_wait_test.lua @@ -56,7 +56,7 @@ return function(game) end -- Youngster5 @ (27,40): "I ran out of POKé / BALLs to catch" then - -- \v "POKéMON with!" — the bug auto-scrolled past this with no ▼. + -- \v "POKéMON with!" -- the bug auto-scrolled past this with no ▼. U.teleport(game, "VIRIDIAN_FOREST", 27, 41, "up") U.tap(game, "a") local box = waitForTextBox(90) diff --git a/tests/drivers/enemy_balls_bug283_test.lua b/tests/drivers/enemy_balls_bug283_test.lua new file mode 100644 index 00000000..1c012672 --- /dev/null +++ b/tests/drivers/enemy_balls_bug283_test.lua @@ -0,0 +1,122 @@ +-- Driver: the foe's party ball row between a KO and the next send-out (#283). +-- pokered ReplaceFaintedEnemyMon (engine/battle/core.asm:892-896) callfars +-- DrawEnemyPokeballs before it falls into EnemySendOut; the port never did. +-- POKEPORT_DRIVER=tests/drivers/enemy_balls_bug283_test.lua \ +-- POKEPORT_IDENTITY=bug283 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \ +-- SHOT_DIR=/tmp/shots love . (never under POKEPORT_SPEED: audio-timed) +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- roster 1 is RATTATA 11 / EKANS 11 in both versions: two slots, so the row + -- has a KO'd ball, a live one and four empties to tell apart. + local OPP, ROSTER = "OPP_YOUNGSTER", 1 + + -- ---- preconditions ------------------------------------------------------ + -- Each of these fails silently as "no ball row on screen", same as the bug. + + check("trainer class " .. OPP .. " is in the data", + game.data.trainers ~= nil and game.data.trainers[OPP] ~= nil) + + -- drawBallRow bails out silently when the sheet will not load, and the + -- underline comes off the $73/$74/$76/$78 HUD glyph pages + local ballSheet = love.filesystem.getInfo("assets/generated/battle/balls.png") + check("assets/generated/battle/balls.png is in the cache", ballSheet ~= nil) + local okImg = pcall(love.graphics.newImage, "assets/generated/battle/balls.png") + check("the ball sheet actually decodes", okImg) + check("BattleState:drawBallRow exists", + type(BattleState.drawBallRow) == "function") + local HudTiles = require("src.render.HudTiles") + check("HudTiles.tile is available for the underline", + type(HudTiles.tile) == "function") + + -- SHIFT keeps the row up through the whole YES/NO prompt (showEnemyBalls is + -- not cleared until the send-out act); SET only flashes it for 16 frames. + game.save.options = game.save.options or {} + game.save.options.battleStyle = "shift" + game.save.player.name = "bryan" + -- two party slots: SHIFT only offers the switch when wPartyCount > 1 + game.save.party = { + Pokemon.new(game.data, "MEWTWO", 70), + Pokemon.new(game.data, "CHARIZARD", 50), + } + check("a one-shot lead is in the party", game.save.party[1] ~= nil) + + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(20) + local ow = game.overworld + check("overworld is up to push the battle from", ow ~= nil) + + local ok, battle = pcall(BattleState.newTrainer, game, OPP, ROSTER) + check("trainer battle constructed", ok and battle ~= nil) + if not ok then + U.log("could not start", OPP, "->", tostring(battle)) + while true do coroutine.yield() end + end + check("the foe has at least two mons to swap between", #battle.enemyParty >= 2) + battle.onFinish = function() end + ow:pushBattle(battle) + + -- tap A until cond(), polling every frame so a one-frame window is caught + local function tapUntil(cond, taps, gap) + for _ = 1, (taps or 60) do + if cond() then return true end + U.tap(game, "a") + for _ = 1, (gap or 6) do + if cond() then return true end + U.wait(1) + end + end + return cond() + end + + check("reached the FIGHT/PKMN/ITEM/RUN menu", + tapUntil(function() return battle.phase == "menu" end, 60)) + + -- FIGHT, then the first move: L70 MEWTWO one-shots a L11 RATTATA, so the KO + -- is the real sequence and not a poked hp value. + U.tap(game, "a") + U.wait(10) + U.tap(game, "a") + U.wait(10) + + -- Stop the instant showEnemyBalls goes up: that flag is raised exactly where + -- ReplaceFaintedEnemyMon calls DrawEnemyPokeballs. + local reached = tapUntil(function() return battle.showEnemyBalls == true end, + 90, 4) + check("the foe's first mon was KO'd and the ball row went up (#283)", reached) + check("the KO'd slot really is fainted", + battle.enemyParty[1] ~= nil and battle.enemyParty[1].hp <= 0) + check("a live reserve is still in the row", + battle.enemyParty[2] ~= nil and battle.enemyParty[2].hp > 0) + + if reached then + check("row screenshot reached disk", + U.shot(game, DIR .. "/bug283_balls_after_ko.png")) + U.log("captured", DIR .. "/bug283_balls_after_ko.png") + -- a few more presses put the SHIFT prompt on top of the still-visible row + tapUntil(function() return battle.showEnemyBalls ~= true end, 3, 20) + check("row screenshot during the SHIFT prompt reached disk", + U.shot(game, DIR .. "/bug283_balls_with_prompt.png")) + U.log("captured", DIR .. "/bug283_balls_with_prompt.png") + U.log("showEnemyBalls is still up at hand-off:", + tostring(battle.showEnemyBalls)) + end + + -- ---- hand off ----------------------------------------------------------- + U.log("The foe's first mon is KO'd and we are paused before the send-out.") + U.log("Top left should carry six small Poke Balls running right to left from") + U.log("(64,16) on a HUD rule: one crossed out, one solid, four empty.") + U.log("#283 was that corner empty. Press A, the row clears on the send-out.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/evolution_black_bug279_test.lua b/tests/drivers/evolution_black_bug279_test.lua new file mode 100644 index 00000000..0d26a32b --- /dev/null +++ b/tests/drivers/evolution_black_bug279_test.lua @@ -0,0 +1,315 @@ +-- Driver: the evolution flash must put the WHOLE screen on PAL_BLACK (#279). +-- pokered engine/movie/evolution.asm EvolveMon flashes under PAL_BLACK; only the +-- settled form wears a mon palette, and PAL_BLACK keeps colour 0 as paper white +-- (sgb_palettes.asm:46), so the background behind the silhouettes stays put. +-- POKEPORT_DRIVER=tests/drivers/evolution_black_bug279_test.lua \ +-- POKEPORT_IDENTITY=bug279 SHOT_DIR=/tmp/shots love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Probe = dofile("tests/drivers/shot_probe.lua") + local PaletteFX = require("src.render.PaletteFX") + local EvolutionState = require("src.ui.EvolutionState") + local Pokemon = require("src.pokemon.Pokemon") + local Evolution = require("src.pokemon.Evolution") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local fails = 0 + local function check(ok, msg) + U.log(ok and "PASS" or "FAIL", msg) + if not ok then fails = fails + 1 end + return ok + end + local function rgb(c) + return c and ("(%d,%d,%d)"):format(c[1], c[2], c[3]) or "nil" + end + local function ramp(p) + if not p then return "nil" end + local s = {} + for i = 1, 4 do s[i] = rgb(p[i]) end + return table.concat(s, " ") + end + local function samePal(a, b) + if not a or not b then return false end + for i = 1, 4 do + if not a[i] or not b[i] then return false end + for k = 1, 3 do if a[i][k] ~= b[i][k] then return false end end + end + return true + end + + -- Text speed 1 keeps the "Congratulations!" box brisk; FLASH_FRAMES is a + -- frame count, so the flash itself is unaffected. + game.save.options = game.save.options or {} + game.save.options.textSpeed = 1 + + -- ===================================================================== + -- Part 1: the halves the eye cannot check. A missing BLACK entry or a zone + -- that does not cover the screen looks exactly like the bug. + -- ===================================================================== + game.save.options.colors = "gbc" + PaletteFX.setMode("gbc") + + local BLACK = PaletteFX.pal(game.data, "BLACK") + check(BLACK ~= nil, "data.palettes carries a BLACK entry (PAL_BLACK)") + U.log("BLACK ramp:", ramp(BLACK)) + -- sgb_palettes.asm RGB 31,29,31 / 07,07,07 / 02,03,03 / 03,02,02, scaled + -- to 8 bits by the extractor + check(BLACK and BLACK[1][1] == 255 and BLACK[1][2] == 239 and BLACK[1][3] == 255, + "PAL_BLACK colour 0 is still paper white (the background does NOT black out)") + check(BLACK and BLACK[2][1] == 58 and BLACK[3][1] == 16 and BLACK[4][1] == 25, + "PAL_BLACK shades 1..3 are crushed (58,58,58 / 16,25,25 / 25,16,16)") + + -- sgbPalettes is a pure function of the state's own fields, so drive it + -- directly for the cases the live run cannot hold still. MAGIKARP is REDMON + -- and GYARADOS BLUEMON: a CATERPIE line settles on GREENMON either way and + -- would prove nothing. + local function zonesFor(fields) + local fake = setmetatable(fields, EvolutionState) + return EvolutionState.sgbPalettes(fake, game) + end + local function colorsFor(fields) + local z = zonesFor(fields) + return z and z[1] and z[1].colors, z and z[1] + end + + local flashCols, flashZone = colorsFor({ + done = false, canceled = false, + mon = { species = "MAGIKARP" }, newSpecies = "GYARADOS", + }) + check(samePal(flashCols, BLACK), + "mid-flash the whole screen is PAL_BLACK, not a mon palette") + U.log("mid-flash zone resolves to:", ramp(flashCols)) + check(flashZone ~= nil and flashZone.x == 0 and flashZone.y == 0 + and flashZone.w == 160 and flashZone.h == 144, + "that zone covers the whole 160x144 screen (SetPal_PokemonWholeScreen)") + + local settled = colorsFor({ + done = true, canceled = false, + mon = { species = "MAGIKARP" }, newSpecies = "GYARADOS", + }) + check(samePal(settled, PaletteFX.monPal(game.data, "GYARADOS")), + "once .done, the NEW form wears its own palette again (GYARADOS/BLUEMON)") + local cancelled = colorsFor({ + done = true, canceled = true, + mon = { species = "MAGIKARP" }, newSpecies = "GYARADOS", + }) + check(samePal(cancelled, PaletteFX.monPal(game.data, "MAGIKARP")), + "a B-cancelled evolution settles on the OLD form (MAGIKARP/REDMON)") + + -- Mode guards: routing the blackout through PaletteFX.pal is what keeps the + -- other COLORS modes honest. A hardcoded {0,0,0} passes everything above. + PaletteFX.setMode("ogred") + local ogFlash = colorsFor({ + done = false, canceled = false, + mon = { species = "WEEDLE" }, newSpecies = "KAKUNA", + }) + check(samePal(ogFlash, PaletteFX.ogBg()), + "OG RED does NOT black out (a Game Boy Color never sees the SGB packet)") + PaletteFX.setMode("redpp") + check(PaletteFX.pal(game.data, "BLACK") ~= nil, + "RED++ resolves BLACK from data/palettes_gbc.lua (no ROM fallback needed)") + PaletteFX.setMode("og") + check(samePal(PaletteFX.effectiveColors(BLACK), PaletteFX.GRAYS), + "plain DMG replaces it wholesale, so the mono modes are unchanged") + PaletteFX.setMode("gbc") + game.save.options.colors = "gbc" + + -- The bytes the pixel probe hunts for, read out of the data and read while + -- the mode is SGB (under OG RED, PaletteFX.pal short-circuits every name to + -- the one palette). + local YELLOW = PaletteFX.monPal(game.data, "WEEDLE") -- YELLOWMON + local REDMON = PaletteFX.monPal(game.data, "MAGIKARP") -- REDMON + local BLUE = PaletteFX.monPal(game.data, "GYARADOS") -- BLUEMON + local OGBG = PaletteFX.GBC_BG + check(YELLOW ~= nil and REDMON ~= nil and BLUE ~= nil, + "WEEDLE/MAGIKARP/GYARADOS mon palettes resolve") + U.log("WEEDLE:", ramp(YELLOW), " MAGIKARP:", ramp(REDMON), + " GYARADOS:", ramp(BLUE)) + + local function probe(label, wanted) + local shot = Probe.grab() + if not shot then + U.log("WARN pixel probe unavailable (no screenshot capture); judge", + label, "by eye only") + return nil + end + local counts, total = Probe.count(shot, wanted) + local parts = {} + for name, n in pairs(counts) do + parts[#parts + 1] = ("%s=%d"):format(name, n) + end + table.sort(parts) + U.log(("probe[%s] %d px sampled: %s"):format(label, total, + table.concat(parts, " "))) + return counts + end + + -- ===================================================================== + -- Part 2: reach the moment and photograph it. + -- ===================================================================== + U.teleport(game, "ROUTE_1", 5, 5, "down") + U.wait(10) + + local function evoTop() + local t = game.stack:top() + return (t and t.screenId == "EvolutionState") and t or nil + end + local function waitFor(cond, max) + for _ = 1, max or 600 do + if cond() then return true end + U.wait(1) + end + return false + end + local function pagesText(st) + if not st or not st.pages then return nil end + local parts = {} + for _, page in ipairs(st.pages) do + if type(page) == "table" then + for _, line in ipairs(page) do + if type(line) == "string" then parts[#parts + 1] = line end + end + elseif type(page) == "string" then + parts[#parts + 1] = page + end + end + return table.concat(parts, " ") + end + local function findText(needle) + for _, st in ipairs(game.stack.states or {}) do + local blob = pagesText(st) + if blob and blob:find(needle, 1, true) then return st end + end + return nil + end + local function mashUntil(cond, max) + for _ = 1, max or 200 do + if cond() then return true end + U.tap(game, "a") + U.wait(3) + end + return cond() + end + + -- Jump the flash clock to just under FLASH_FRAMES (220) once the shot is + -- taken. EvolutionState:update only compares self.t against that constant, + -- so this ends the animation early without touching the palette logic. + local function skipToEnd() + local st = evoTop() + if st then st.t = 214 end + end + + local function startEvo(species, into) + local mon = Pokemon.new(game.data, species, 7) + table.insert(game.save.party, 1, mon) + Evolution.evolve(game, mon, into) + if not waitFor(evoTop, 300) then + check(false, "EvolutionState opened for " .. species .. " -> " .. into) + return nil + end + return mon + end + + -- ---- case 1: the reported case, SGB, mid-flash -------------------------- + local weedle = startEvo("WEEDLE", "KAKUNA") + U.wait(24) -- into the flash, far short of FLASH_FRAMES = 220 + U.shot(game, DIR .. "/evo279_1_flash_sgb.png") + local c1 = probe("flash/SGB", { + monYellow1 = YELLOW[2], monYellow2 = YELLOW[3], + black1 = BLACK[2], black2 = BLACK[3], paper = BLACK[1], + }) + if c1 then + check(c1.monYellow1 == 0 and c1.monYellow2 == 0, + "no WEEDLE/KAKUNA yellow anywhere on screen during the flash") + check(c1.black1 + c1.black2 > 0, + "the crushed PAL_BLACK shades ARE on screen (the silhouette)") + check(c1.paper > 0, + "paper white still fills the background (PAL_BLACK keeps colour 0)") + end + + -- ---- case 1b: it comes back once the flash is over ---------------------- + skipToEnd() + if not waitFor(function() return findText("evolved into") ~= nil end, 240) then + check(false, "the Congratulations text appears when the flash ends") + end + U.wait(50) -- let the box finish typing before the shot + U.shot(game, DIR .. "/evo279_2_congrats_sgb.png") + local c2 = probe("congrats/SGB", { + monYellow1 = YELLOW[2], monYellow2 = YELLOW[3], black1 = BLACK[2], + }) + if c2 then + check(c2.monYellow1 + c2.monYellow2 > 0, + "the settled KAKUNA is yellow again under the Congratulations box") + end + check(weedle == nil or weedle.species == "KAKUNA", + "the mon actually evolved (control: the fix touched no game logic)") + mashUntil(function() return not evoTop() and not findText("evolved into") end, 90) + U.wait(10) + + -- ---- case 2: B-cancel settles on the OLD form's colours ----------------- + local karp = startEvo("MAGIKARP", "GYARADOS") + U.wait(24) + U.hold(game, "b", 20) -- evolution.asm Evolution_CheckForCancel + if not waitFor(function() return findText("stopped evolving") ~= nil end, 240) then + check(false, "holding B prints \"stopped evolving\"") + end + U.wait(50) + U.shot(game, DIR .. "/evo279_3_cancelled_sgb.png") + local c3 = probe("cancelled/SGB", { + red1 = REDMON[2], red2 = REDMON[3], blue1 = BLUE[2], blue2 = BLUE[3], + }) + if c3 then + check(c3.red1 + c3.red2 > 0, + "a cancelled MAGIKARP settles back into MAGIKARP's own red") + check(c3.blue1 == 0 and c3.blue2 == 0, + "no GYARADOS blue leaks in (wEvoOldSpecies, not the new species)") + end + check(karp == nil or karp.species == "MAGIKARP", "the cancelled mon kept its species") + mashUntil(function() return not evoTop() and not findText("stopped evolving") end, 90) + U.wait(10) + + -- ---- case 3: OG RED must NOT black out --------------------------------- + -- A Game Boy Color never sees the SGB packet, so its one global BG palette + -- applies all the way through. Switch mode with only the overworld on the + -- stack: setMode's cache drop / map reload must not land on a live evolution. + game.save.options.colors = "ogred" + PaletteFX.setMode("ogred") + U.wait(20) + startEvo("WEEDLE", "KAKUNA") + U.wait(24) + U.shot(game, DIR .. "/evo279_4_flash_ogred.png") + local c4 = probe("flash/OG RED", { + ogPink = OGBG[2], ogRed = OGBG[3], black1 = BLACK[2], black2 = BLACK[3], + }) + if c4 then + check(c4.ogPink + c4.ogRed > 0, "OG RED stays red/pink through the flash") + check(c4.black1 == 0 and c4.black2 == 0, + "PAL_BLACK's crushed shades never appear in OG RED") + end + skipToEnd() + waitFor(function() return findText("evolved into") ~= nil end, 240) + mashUntil(function() return not evoTop() and not findText("evolved into") end, 90) + + game.save.options.colors = "gbc" + PaletteFX.setMode("gbc") + U.wait(20) + + U.log(fails == 0 and "all #279 preconditions passed" + or (fails .. " #279 precondition(s) FAILED -- read up")) + + -- ===================================================================== + -- Part 3: one more, live, for the human. + -- ===================================================================== + startEvo("WEEDLE", "KAKUNA") + + U.log("A WEEDLE is evolving now in SGB colours, about four seconds. While the") + U.log("two forms trade places the whole screen is on PAL_BLACK: near-black") + U.log("silhouettes on the unchanged off-white background, colour back only") + U.log("under the Congratulations box. #279 flashed both forms in full colour.") + U.log("Hold B mid-flash to cancel; it settles on the OLD form's colours.") + U.log("Screenshots: " .. DIR .. "/evo279_*.png") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/fishing_rod_bug321_test.lua b/tests/drivers/fishing_rod_bug321_test.lua new file mode 100644 index 00000000..1df1825c --- /dev/null +++ b/tests/drivers/fishing_rod_bug321_test.lua @@ -0,0 +1,189 @@ +-- Driver: the fishing rod is ONE 8x8 tile in the player's hands, mirrored +-- between left and right, and it stays up through the verdict text (#321). +-- engine/overworld/player_animations.asm:471 FishingRodOAM draws one tile per +-- facing ($fd up/down, $fe sides, OAM_XFLIP on right) out of the 8x24 sheet +-- at :489; :437 clears BIT_LEDGE_OR_FISHING only after PrintText. +-- POKEPORT_DRIVER=tests/drivers/fishing_rod_bug321_test.lua POKEPORT_IDENTITY=bug321 SHOT_DIR=/tmp/shots love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local Screens = require("src.ui.Screens") + local TextBox = require("src.render.TextBox") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- The Viridian City pond (water block x=8..13, y=24..27) is the one spot + -- where all four shores are a few steps apart, so every facing fits on one + -- screen. shoreFor() below re-derives each stand cell from the map data. + local MAP = "VIRIDIAN_CITY" + local SPOTS = { + { x = 10, y = 23, facing = "down", note = "north shore" }, + { x = 8, y = 28, facing = "up", note = "south shore" }, + { x = 14, y = 24, facing = "left", note = "east shore" }, + { x = 7, y = 24, facing = "right", note = "west shore" }, + } + local DELTA = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } + local ROD = "OLD_ROD" + + -- ---- preconditions the eye cannot separate from the bug ---------------- + -- A missing sheet, a rod that never reaches goFishing and a rod drawn at + -- garbage coordinates all look the same: no rod in the hands. + + local fx = game.data.field.overworldFx + local rodDef = fx and fx.fishingRod + check("field.overworldFx.fishingRod resolves", + rodDef ~= nil and type(rodDef.path) == "string") + if rodDef then U.log("rod sheet:", rodDef.path) end + + -- three stacked 8x8 tiles, of which exactly one is ever drawn; if the sheet + -- is not 8x24 the ROD_OAM tile indices mean nothing + if rodDef and rodDef.path then + local ok, img = pcall(love.graphics.newImage, rodDef.path) + if check("the rod sheet loads", ok and img ~= nil) then + local w, h = img:getDimensions() + U.log(("rod sheet is %dx%d"):format(w, h)) + check("it is 8 wide (one tile)", w == 8) + check("it is 24 tall (three stacked 8x8 tiles, RedFishingRodTiles)", h == 24) + end + end + + check("OLD ROD is a real item", game.data.items[ROD] ~= nil) + local ItemEffects = require("src.inventory.ItemEffects") + local result = ItemEffects.use(game.data, game.save, ROD, nil, nil, nil, game.overworld) + check("using it routes to the fishing branch", result == "fish") + + -- Old Rod always hooks a L5 MAGIKARP, so the verdict box is deterministic + local rodFishing = (game.data.field.fishing or {})[ROD] + check("OLD ROD always gets a bite (field.fishing.OLD_ROD.always)", + rodFishing ~= nil and rodFishing.always ~= nil) + + -- the bite ends in a battle once the box is dismissed + game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) } + game.save.inventory = { [ROD] = 1 } + game.save.bagOrder = { ROD } + check("the rod is the only thing in the bag (so it is item #1)", + (game.save.inventory[ROD] or 0) > 0) + + -- ---- helpers ----------------------------------------------------------- + + -- Walk the real path, START-menu bag -> rod -> USE: BagMenu's fish branch + -- is what calls goFishing, and it refuses unless the faced cell is water, + -- so this doubles as proof the stand cell is right. + local function useRod() + Screens.push(game, "BagMenu") + U.wait(20) + U.tap(game, "a") -- select OLD ROD -> USE/TOSS + U.wait(20) + U.tap(game, "a") -- USE + U.wait(30) + end + + local function topBox() + local top = game.stack:top() + if getmetatable(top) ~= TextBox then return nil end + local shown = {} + for _, page in ipairs(top.pages or {}) do + for _, line in ipairs(page) do shown[#shown + 1] = line end + end + return top, table.concat(shown, " / ") + end + + -- A press while the box is still typing only finishes the line, so tap + -- until the box on top is a different one (or we run out of patience). + local function advance(box) + for _ = 1, 12 do + U.tap(game, "a") + U.wait(12) + if game.stack:top() ~= box then return true end + end + return false + end + + -- if a map edit moved the pond, find any shore facing water the same way + -- rather than parking the player at a wall + local function shoreFor(map, spot) + local d = DELTA[spot.facing] + if map:inBounds(spot.x + d[1], spot.y + d[2]) + and map:isWaterCell(spot.x + d[1], spot.y + d[2]) + and map:isWalkableCell(spot.x, spot.y) then + return spot.x, spot.y + end + for y = 0, map.def.height * 2 - 1 do + for x = 0, map.def.width * 2 - 1 do + if map:isWalkableCell(x, y) and not map:isWaterCell(x, y) + and map:inBounds(x + d[1], y + d[2]) + and map:isWaterCell(x + d[1], y + d[2]) then + U.log(("(%d,%d) no longer faces water; using (%d,%d) for %s") + :format(spot.x, spot.y, x, y, spot.facing)) + return x, y + end + end + end + return spot.x, spot.y + end + + -- ---- the four facings -------------------------------------------------- + + for i, spot in ipairs(SPOTS) do + local last = (i == #SPOTS) + U.teleport(game, MAP, spot.x, spot.y, spot.facing) + U.wait(15) + local ow = game.overworld + local sx, sy = shoreFor(ow.map, spot) + if sx ~= spot.x or sy ~= spot.y then + U.teleport(game, MAP, sx, sy, spot.facing) + U.wait(15) + ow = game.overworld + end + local fcx, fcy = ow.player:facingCell() + check(("facing %s from (%d,%d), the %s: water in front") + :format(spot.facing, sx, sy, spot.note), + ow.map:inBounds(fcx, fcy) and ow.map:isWaterCell(fcx, fcy)) + + useRod() + + local dots, dotsText = topBox() + check(("%s: USE opened the fishing box"):format(spot.facing), dots ~= nil) + if dotsText then U.log("box reads:", dotsText) end + check(("%s: the rod is up (overworld.fishing is set)"):format(spot.facing), + ow.fishing ~= nil) + check(("%s: the rod remembers this facing"):format(spot.facing), + ow.fishing ~= nil and ow.fishing.facing == spot.facing) + if not U.shot(game, ("%s/bug321_rod_%s_dots.png"):format(SHOT_DIR, spot.facing)) then + check(("%s: dots screenshot reached disk"):format(spot.facing), false) + end + + -- the timing half of #321: the dots box closes, the verdict box opens, + -- and the rod must still be in the player's hands the whole time + if dots then advance(dots) end + local verdict, verdictText = topBox() + check(("%s: the verdict box opened"):format(spot.facing), verdict ~= nil) + if verdictText then U.log("box reads:", verdictText) end + check(("%s: the rod is STILL up while the verdict text shows"):format(spot.facing), + ow.fishing ~= nil) + if not U.shot(game, ("%s/bug321_rod_%s_verdict.png"):format(SHOT_DIR, spot.facing)) then + check(("%s: verdict screenshot reached disk"):format(spot.facing), false) + end + + -- The last facing is left live for the hand-off; the others are cleared + -- by the next teleport, which pops the stack, so no battle starts here. + if last then + U.log("leaving this box open for you") + end + end + + -- ---- hand off ---------------------------------------------------------- + U.log("West shore of the Viridian pond, facing right, mid-cast.") + U.log("Correct: one 8x8 rod stroke touching the player's hands, a mirror") + U.log("image left vs right, still up for the whole verdict box (#321).") + U.log("Shots: " .. SHOT_DIR .. "/bug321_rod__{dots,verdict}.png") + U.log("Still unported: the fishing pose, the sprite shake, the ! bubble.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/grass_overlay_bug150_test.lua b/tests/drivers/grass_overlay_bug150_test.lua index 4bda70ea..aec213ce 100644 --- a/tests/drivers/grass_overlay_bug150_test.lua +++ b/tests/drivers/grass_overlay_bug150_test.lua @@ -1,21 +1,27 @@ -- Visual + render-decision regression for #150 ("Grass transparency is off"). -- -- The reporter's should_be.png shows Red standing in Route 1 tall grass with a --- GREEN cap that blends into the grass. The default SGB color mode instead --- region-tints the player with the per-map SGB BG palette: Red's dark-gray cap --- (DMG shade 2) maps to the ROUTE palette's shade-2 = light-blue (165,214,255) --- (data/generated/palettes.lua ROUTE), so the character clashes with the grass. --- On real GBC/SGB an OBJ carries its own object palette; OG RED already bakes --- PaletteFX.ogObj() (green over Red, pink over Blue -- color/sprites.asm --- ColorOverworldSprite) onto overworld characters. The fix makes SGB mode do --- the same while leaving terrain on its per-map SGB palette. +-- GREEN cap that blends into the grass. That green is the ROUTE palette's own +-- entry 2 (173,230,90), not an object palette of the character's: overworld +-- OBJs run through rOBP0 = $D0 (home/fade.asm FadePal4 `dc 3,1,0,0`), which +-- lifts OBJ color 1 to DMG shade 0 and color 2 to shade 1, so the cap lands on +-- the very colour the grass beside it uses. Drawn with an identity shade map +-- the cap landed on shade 2 = light-blue (165,214,255) instead -- the clash +-- #150 reported. The first fix baked PaletteFX.ogObj() (the Game Boy Color +-- boot ROM's green) onto SGB characters, which is a different machine's answer +-- and became #301 ("people in SGB mode are green"): the Super Game Boy cannot +-- colour an OBJ apart from the BG at all, since pokered never sends OBJ_TRN +-- (data/sgb/sgb_packets.asm defines ATTR_BLK / PAL_SET / PAL_TRN / MLT_REQ / +-- CHR_TRN / PCT_TRN and nothing else). So SGB bakes the OBP0 ramp +-- (PaletteFX.dmgObj) and lets the zone shader colour the result. -- -- Gate (fails before the fix, passes after): --- * PaletteFX.usesSpriteObp("gbc") == true --- * the player SpriteRenderer bakes a distinct OBJ image in SGB mode --- (resolveImage() ~= the raw grayscale sheet) -- the pixel path that --- recolors the cap green --- * that baked OBJ palette's shade-2 (the cap) is green, not ROUTE light-blue +-- * PaletteFX.usesSpriteObp("gbc") == false -- SGB owns no object palette +-- * the player SpriteRenderer still bakes a distinct image in SGB mode +-- (resolveImage() ~= the raw grayscale sheet): rOBP0 plus the alpha key +-- * that bake puts the cap (sheet shade 2) on DMG shade 1, which the zone +-- shader then reads out of the map palette as its entry 2 -- ROUTE's grass +-- green, not its light-blue -- Regression guard (must hold before AND after -- terrain is untouched): -- * the ROUTE terrain palette still carries BOTH grass green (173,230,90) and -- light-blue (165,214,255), so the grass field keeps its green+blue dither. @@ -65,23 +71,27 @@ return function(game) U.shot(game, DIR .. "/grass_bug150_sgb.png") -- === render-decision gate: fails before the fix, passes after ========= - check(PaletteFX.usesSpriteObp("gbc") == true, - "SGB mode bakes an OBJ palette onto overworld characters") + check(PaletteFX.usesSpriteObp("gbc") == false, + "SGB owns no object palette (it cannot colour an OBJ apart from the BG)") - -- the player's sprite must resolve to a baked OBJ image (not the raw - -- grayscale sheet) in SGB mode -- this is the exact path that colors the cap + -- the player's sprite must still resolve to a baked image (not the raw + -- grayscale sheet) in SGB mode -- that bake is rOBP0 plus the alpha key local spr = p.sprite check(spr and spr.image and spr:resolveImage() ~= spr.image, - "player sprite bakes a distinct OBJ image in SGB mode") + "player sprite bakes a distinct OBP0 image in SGB mode") - -- the baked object palette's shade-2 (the cap) is a green, and specifically - -- NOT the ROUTE light-blue the region shader used to hand it - local obj = PaletteFX.ogObj() -- {white, brightgreen, darkgreen, black} - local cap = obj and obj[3] -- DMG shade 2 -> 3rd palette entry + -- OBP0 (`dc 3,1,0,0`) sends the cap -- sheet shade 2 -- to DMG shade 1, so + -- the zone shader hands it the map palette's entry 2 rather than the entry 3 + -- light-blue an identity shade map used to pick + local obp = PaletteFX.dmgObj() + check(obp and obp[3][1] == 170 and obp[3][2] == 170 and obp[3][3] == 170, + "OBP0 bake puts sheet shade 2 on DMG shade 1 (170)") + local cap = PaletteFX.pal(game.data, ow:paletteNameFor(ow.map)) + cap = cap and cap[2] -- DMG shade 1 -> 2nd palette entry check(cap and cap[2] > cap[1] and cap[2] > cap[3], - "OBJ cap color is green-dominant (g>r and g>b)") + "the cap's resolved map colour is green-dominant (g>r and g>b)") check(cap and not (cap[1] == 165 and cap[2] == 214 and cap[3] == 255), - "OBJ cap color is NOT ROUTE light-blue (165,214,255)") + "the cap's resolved map colour is NOT ROUTE light-blue (165,214,255)") -- === regression guard: terrain palette untouched ===================== local terrain = PaletteFX.pal(game.data, ow:paletteNameFor(ow.map)) diff --git a/tests/drivers/gym_leader_victory_test.lua b/tests/drivers/gym_leader_victory_test.lua index 0f74f596..59a2ef33 100644 --- a/tests/drivers/gym_leader_victory_test.lua +++ b/tests/drivers/gym_leader_victory_test.lua @@ -1,7 +1,7 @@ -- Driver: gym-leader post-battle dialogue chain (#164). -- Invokes checkVictoryRewards for Brock then Misty (same path as a win) -- and screenshots pages that must include badge-effect + TM explanation --- text — not just a synthetic "received badge/TM" stub. +-- text -- not just a synthetic "received badge/TM" stub. -- -- SHOT_DIR=/tmp/gym164 POKEPORT_DRIVER=tests/drivers/gym_leader_victory_test.lua love . return function(game) diff --git a/tests/drivers/jigglypuff_bug249_test.lua b/tests/drivers/jigglypuff_bug249_test.lua new file mode 100644 index 00000000..b30190b4 --- /dev/null +++ b/tests/drivers/jigglypuff_bug249_test.lua @@ -0,0 +1,264 @@ +-- Driver: the Pewter JIGGLYPUFF sings AND dances (#249). +-- scripts/PewterPokecenter.asm PewterPokecenterJigglypuffText: stop the music, +-- DelayFrames 32, MUSIC_JIGGLYPUFF_SONG, then a clockwise quarter turn every 24 +-- frames until the song ends, 48 more frames, PlayDefaultMusic. TextScriptEnd +-- is the only thing that closes the box. Not under POKEPORT_SPEED. +-- POKEPORT_DRIVER=tests/drivers/jigglypuff_bug249_test.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local mapScripts = require("data.scripts.init") + local TextBox = require("src.render.TextBox") + local Music = require("src.core.Music") + + local MAP = "PEWTER_POKECENTER" + local NPC = "PEWTERPOKECENTER_JIGGLYPUFF" + local TEXT = "TEXT_PEWTERPOKECENTER_JIGGLYPUFF" + local SONG, MAP_SONG = "Music_JigglypuffSong", "Music_Pokecenter" + -- scripts/PewterPokecenter.asm .FacingDirections, in order + local RING = { "down", "left", "up", "right" } + local SILENCE, STEP, TAIL = 32, 24, 48 -- the three DelayFrames counts + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local function nextInRing(dir) + for i, d in ipairs(RING) do + if d == dir then return RING[i % #RING + 1] end + end + return nil + end + + -- ---- preconditions the eye cannot check -------------------------------- + -- A missing handler, text entry or song def all look the same on screen: a + -- JIGGLYPUFF that just stands there. + local handler = mapScripts.talkScript(MAP, TEXT) + check(MAP .. "/" .. TEXT .. " has a talk handler", + type(handler) == "function") + + local line = game.data.text and game.data.text._PewterPokecenterJigglypuffText + check("_PewterPokecenterJigglypuffText resolves to a string", + type(line) == "string" and line ~= "") + if type(line) == "string" then + U.log("the box should read:", (line:gsub("\n", " / "))) + end + + -- Music.playOnce returns false on a missing def, and the dance then skips + -- straight to its 48-frame tail with no spin at all. + local songs = game.data.audio and game.data.audio.songs + check("audio.songs." .. SONG .. " resolves (PlayMusic MUSIC_JIGGLYPUFF_SONG)", + songs ~= nil and songs[SONG] ~= nil) + check("audio.songs." .. MAP_SONG .. " resolves (PlayDefaultMusic comes back to it)", + songs ~= nil and songs[MAP_SONG] ~= nil) + local mapSong = game.data.audio and game.data.audio.mapSongs + and game.data.audio.mapSongs[MAP] + check("the Center's map theme is " .. MAP_SONG, mapSong == MAP_SONG) + + -- SPRITE_FAIRY has to be a walker or three of the four facings have no + -- frames to draw and the "turn" is invisible even when it happens + local fairy = game.data.sprites and game.data.sprites.SPRITE_FAIRY + check("SPRITE_FAIRY renders all four facings", + fairy ~= nil and fairy.walker == true and (fairy.frames or 0) >= 4) + + local opts = game.save.options or {} + U.log("audio device present:", love.audio ~= nil, + " MUSIC VOL (0-7):", tostring(opts.musicVol), + " SFX VOL (0-7):", tostring(opts.sfxVol)) + if not love.audio or opts.musicVol == 0 then + U.log("WARNING: music output is off, so the cut to silence and the song", + "itself will not be audible; raise MUSIC VOL in OPTION first") + end + + -- ---- park the player against the JIGGLYPUFF ----------------------------- + -- data/maps/objects/PewterPokecenter.asm: object_event 1, 3, SPRITE_FAIRY. + -- It sits against the west wall, so approach from the floor to its east. + local STAND = { x = 2, y = 3, facing = "left" } + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(10) + + local function puffIn(ow) + for _, n in ipairs(ow.npcs or {}) do + if n.def and n.def.name == NPC then return n end + end + return nil + end + + -- re-reads game.overworld every call: the fallback below teleports again, + -- which rebuilds the state and its npc list + local function facingThePuff() + local ow = game.overworld + local puff = ow and puffIn(ow) + if not puff then return false end + local fx, fy = ow.player:facingCell() + return ow:npcAtCell(fx, fy) == puff + end + + local ow = game.overworld + local puff = ow and puffIn(ow) + check("JIGGLYPUFF object loaded on " .. MAP, puff ~= nil) + + if puff and not facingThePuff() then + -- Approach cell is blocked (map edit, or a mod moved the object): take any + -- free neighbour instead. {dx, dy, facing} is the offset from the fairy + -- plus the direction that looks back at it, so +1 on x means facing left. + local sides = { + { 1, 0, "left" }, { 0, 1, "up" }, { 0, -1, "down" }, { -1, 0, "right" }, + } + for _, s in ipairs(sides) do + local cx, cy = puff.cellX + s[1], puff.cellY + s[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log(("approach cell (%d, %d) is blocked, standing on") + :format(STAND.x, STAND.y), cx, cy, "facing", s[3]) + U.teleport(game, MAP, cx, cy, s[3]) + U.wait(10) + break + end + end + puff = game.overworld and puffIn(game.overworld) + end + check("player is standing against the JIGGLYPUFF", facingThePuff()) + + -- ---- one frame, sampling everything at once ---------------------------- + local function boxOnTop() + local top = game.stack:top() + if getmetatable(top) == TextBox then return top end + return nil + end + + local function sample(pressA) + if pressA then U.tap(game, "a") else U.wait(1) end + local box = boxOnTop() + return { + box = box ~= nil, + typed = box ~= nil and box.done == true, + tick = box ~= nil and box.auto ~= nil and type(box.auto.tick) == "function", + facing = puff and puff.facing or nil, + song = Music.oneShotPlaying(), + } + end + + -- ---- 1. talk to it and watch the whole dance, mashing A throughout ----- + U.log("--- run 1: the full dance, with A held down on it ---------------") + local before = puff and puff.facing + U.tap(game, "a") + U.wait(2) + local box = boxOnTop() + check("pressing A opened a text box", box ~= nil) + if box then + U.log("the JIGGLYPUFF turned to face the player:", + tostring(before), "->", tostring(puff.facing)) + check("the box carries an auto.tick hook (the per-frame dance driver)", + box.auto ~= nil and type(box.auto.tick) == "function") + check("the box is an auto box, so no blinking arrow and no A dismissal", + box.auto ~= nil) + end + + -- Mash A every 3rd frame for the whole run; in the original the box cannot + -- be dismissed at all, so this must change nothing. + local log, turns = {}, {} + local typedAt, songAt, songEnd, closedAt = nil, nil, nil, nil + local last = puff and puff.facing + for i = 1, 3000 do + log[i] = sample(i % 3 == 0) + if not typedAt and log[i].typed then typedAt = i end + if not songAt and log[i].song then songAt = i end + if songAt and not songEnd and not log[i].song then songEnd = i end + if log[i].facing and log[i].facing ~= last then + turns[#turns + 1] = { at = i, from = last, to = log[i].facing } + last = log[i].facing + end + if not log[i].box then closedAt = i break end + end + + U.log(("frames: text finished typing at %s, song started at %s, song ended " .. + "at %s, box closed at %s") + :format(tostring(typedAt), tostring(songAt), tostring(songEnd), + tostring(closedAt))) + for n, t in ipairs(turns) do + U.log((" turn %d on frame %d: %s -> %s"):format(n, t.at, tostring(t.from), + tostring(t.to))) + end + + if check("the song played", songAt ~= nil) then + -- SFX_STOP_ALL_MUSIC, DelayFrames 32, PlayMusic + local gap = songAt - (typedAt or 1) + U.log(("silence between the text finishing and the song starting: %d frames " .. + "(pokered waits %d)"):format(gap, SILENCE)) + check(("that silence is about %d frames"):format(SILENCE), + gap >= SILENCE - 4 and gap <= SILENCE + 8) + end + + check("the JIGGLYPUFF turned at least four times", #turns >= 4) + local ringOk, spacingOk = true, true + for n, t in ipairs(turns) do + if t.to ~= nextInRing(t.from) then + ringOk = false + U.log((" turn %d is not a clockwise quarter turn: %s -> %s (expected %s)") + :format(n, tostring(t.from), tostring(t.to), + tostring(nextInRing(t.from)))) + end + if n > 1 then + local d = t.at - turns[n - 1].at + if d < STEP - 6 or d > STEP + 6 then + spacingOk = false + U.log((" turn %d came %d frames after the last one (expected %d)") + :format(n, d, STEP)) + end + end + end + check("every turn is one clockwise quarter turn (DOWN->LEFT->UP->RIGHT)", + ringOk and #turns >= 4) + check(("the turns are %d frames apart"):format(STEP), + spacingOk and #turns >= 2) + + -- not just "it eventually closed": an A-dismissable box closes too, only far + -- too early, and that is the bug + check("mashing A never closed the box early", + (closedAt or #log) >= SILENCE + 4 * STEP) + if check("the box closed itself with no button press", closedAt ~= nil) then + check("it stayed up for the whole song, A mashing and all", + songEnd ~= nil and closedAt > songEnd) + if songEnd then + local tail = closedAt - songEnd + U.log(("the box lingered %d frames after the song (pokered waits %d, " .. + "plus the box's own pop delay)"):format(tail, TAIL)) + check(("that tail is about %d frames"):format(TAIL), + tail >= TAIL - 6 and tail <= TAIL + 20) + end + end + + -- ---- 2. do it again, screenshot each quarter turn, hand off mid-song --- + U.log("--- run 2: screenshots, then the pad is yours -------------------") + U.wait(30) + puff = game.overworld and puffIn(game.overworld) + check("still standing against the JIGGLYPUFF", facingThePuff()) + U.tap(game, "a") + U.wait(2) + U.shot(game, DIR .. "/bug249_0_box.png") + + local shots, seen = 0, puff and puff.facing + for _ = 1, 600 do + U.wait(1) + if not boxOnTop() then break end + if puff and puff.facing ~= seen then + seen = puff.facing + shots = shots + 1 + -- U.shot costs a frame or two, which is why run 1 did the timing + local path = ("%s/bug249_%d_%s.png"):format(DIR, shots, seen) + if U.shot(game, path) then U.log("captured", path) end + if shots >= 4 then break end + end + end + check("captured four quarter turns", shots >= 4) + + U.log("The JIGGLYPUFF has been talked to and is mid-song. Mash A: the box has no") + U.log("arrow and must not close. The fairy should turn a quarter turn clockwise") + U.log("(down, left, up, right) every 24 frames until the song ends, then close") + U.log("itself (#249). The Center theme returns just before it does; expected.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/lowhp_alarm_bug293_test.lua b/tests/drivers/lowhp_alarm_bug293_test.lua new file mode 100644 index 00000000..005a4471 --- /dev/null +++ b/tests/drivers/lowhp_alarm_bug293_test.lua @@ -0,0 +1,237 @@ +-- Driver: manual audio check for the low-health siren cutting out (#293). +-- pokered sets wLowHealthAlarm on a red bar (core.asm:1858-1875) and only +-- RemoveFaintedPlayerMon (core.asm:1011-1016) clears it, so a sounding siren +-- rides through the next hit. No POKEPORT_SPEED: audio has its own clock. +-- POKEPORT_DRIVER=tests/drivers/lowhp_alarm_bug293_test.lua \ +-- POKEPORT_IDENTITY=bug293 POKEPORT_TOUCH=0 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local Sound = require("src.core.Sound") + + local ALARM = "Low_Health_Alarm" + local function siren() return Sound.isLooping(ALARM) end + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- ---- preconditions the ear cannot check -------------------------------- + -- A siren with no source and a siren stopped every frame sound the same. + check("BattleState:lowHealthAlarmActive exists", + type(BattleState.lowHealthAlarmActive) == "function") + check("BattleState:updateFx exists (it is what latches the siren)", + type(BattleState.updateFx) == "function") + + -- Sound.startLoop uses the registered sfx def when there is one and + -- otherwise synthesizes the siren; a ROM import has no def, so the synth + -- path is the normal one. + local def = game.data.audio and game.data.audio.sfx + and game.data.audio.sfx[ALARM] + if def then + check("data.audio.sfx." .. ALARM .. " resolves", true) + else + local ok, src = pcall(require("src.core.ChipAudio").newLowHealthAlarm) + check("no " .. ALARM .. " def, so ChipAudio synthesizes the siren", + ok and src ~= nil) + if ok and src and src.stop then pcall(src.stop, src) end + end + + local vol = game.save.options and game.save.options.sfxVol + U.log("audio device present:", love.audio ~= nil, + " SFX VOL (0-7):", tostring(vol)) + if not love.audio or vol == 0 then + U.log("WARNING: sound output is off, so nothing below will be audible;", + "raise SFX VOL in OPTION first -- a muted run and a dead siren", + "are the same thing to an ear") + end + + -- ---- a lead whose bar is red and who cannot end the fight -------------- + -- :L20 so a weak foe cannot one-shot it, and TAIL_WHIP only (power 0), so + -- the wild mon survives every turn and keeps hitting back. + local function freshLead() + local mon = Pokemon.new(game.data, "RATTATA", 20) + mon.moves = { { id = "TAIL_WHIP", pp = 30 } } + game.save.party = { mon } + return mon + end + + -- put the drawn bar at 9 of 48 px: HP_BAR_RED is < 10 px + -- (GetHealthBarColor, core.asm), the same threshold HudTiles.drawHPBar + -- tints with + local function intoTheRed(mon) + mon.hp = math.max(1, math.floor(mon.stats.hp * 9 / 48)) + return math.max(1, math.floor(mon.hp * 48 / math.max(1, mon.stats.hp))) + end + + local lead = freshLead() + local px = intoTheRed(lead) + U.log(("lead: RATTATA :L20 %d/%d HP -> %d of 48 px") + :format(lead.hp, lead.stats.hp, px)) + check("the lead's bar is red (red is under 10 px)", px < 10) + check("the lead has HP to spare for a non-lethal hit", lead.hp >= 6) + + U.teleport(game, "ROUTE_1", 5, 5, "down") + + local function sample(battle, pressA) + if pressA then U.tap(game, "a") else U.wait(1) end + return { + on = siren(), + hp = battle.player.mon.hp, + shown = battle.player.shownHP, + text = battle.current and battle.current.text or "", + } + end + + -- longest run of consecutive silent frames in [from, to], and where it + -- started: this is the number a human hears as a gap + local function longestGap(log, from, to) + local best, bestAt, run, runAt = 0, nil, 0, nil + for i = from, math.min(to, #log) do + if log[i].on then + run, runAt = 0, nil + else + if run == 0 then runAt = i end + run = run + 1 + if run > best then best, bestAt = run, runAt end + end + end + return best, bestAt + end + + local function startBattle(species, level, enemyMoves) + local battle = BattleState.newWild(game, species, level) + battle.onFinish = function() end + if enemyMoves then + -- BOTH: the battler's curMoves aliases mon.moves at construction + -- (BattleState.lua:345) and TrainerAI.chooseMove reads curMoves, so + -- replacing only mon.moves leaves the AI on the original moveset + battle.enemy.mon.moves = enemyMoves + battle.enemy.curMoves = enemyMoves + end + game.overworld:pushBattle(battle) + for _ = 1, 120 do + if battle.phase == "menu" then break end + U.tap(game, "a") + U.wait(4) + end + return battle + end + + -- The A presses are folded into the sampling loop rather than done with + -- U.tap first: applyDamage takes the HP off the model at queue-build time, + -- so a faster foe can land its hit within a couple of frames of the move + -- being chosen and a U.tap would sample straight past it. + local function playTurn(battle, maxFrames) + local pre = battle.player.mon.hp + local log, hitAt, emptyAt, settleAt = {}, nil, nil, nil + for i = 1, maxFrames or 900 do + local pressA = i == 1 or i == 6 + or (i > 6 and i % 6 == 0 and battle.phase ~= "menu") + log[i] = sample(battle, pressA) + if not hitAt and log[i].hp < pre then hitAt = i end + if hitAt and not emptyAt and (log[i].shown or 0) <= 0 then emptyAt = i end + if hitAt and not settleAt and log[i].shown == log[i].hp then + settleAt = i + end + if settleAt and i > settleAt + 40 then break end + end + return log, pre, hitAt, settleAt, emptyAt + end + + -- ===================================================================== + -- 1. the non-lethal hit: the siren must ride it out unbroken + -- ===================================================================== + U.log("--- battle 1: a hit you survive -------------------------------") + -- PIDGEY :L3 knows only GUST (SAND_ATTACK is not until :L5), accurate and + -- worth a couple of HP against a :L20 lead, so it lands and never kills. + local b1 = startBattle("PIDGEY", 3) + check("reached the action menu", b1.phase == "menu") + check("the siren is sounding before anything happens", siren()) + U.shot(game, DIR .. "/bug293_1_menu.png") + + local log, pre, hitAt, settleAt = playTurn(b1) + U.log(("turn: %d frames sampled, HP %d -> %d, hit landed on frame %s, ") + :format(#log, pre, b1.player.mon.hp, tostring(hitAt)) + .. ("bar settled on frame %s"):format(tostring(settleAt))) + if check("the foe's attack connected", hitAt ~= nil) then + local to = settleAt and (settleAt + 20) or #log + local gap, gapAt = longestGap(log, hitAt, to) + -- the reported symptom, in frames: 60 frames is one second + U.log(("longest silence between the hit and the bar settling: %d frames%s") + :format(gap, gapAt and (" (from frame " .. gapAt .. ")") or "")) + check("the siren never dropped out across the hit (#293)", gap == 0) + if gap > 0 then + U.log(" it went quiet during:", log[gapAt].text ~= "" and + log[gapAt].text:gsub("\n", " / ") or "(no text on screen)") + end + check("and it is still sounding once the bar has settled", + siren() and b1.player.mon.hp > 0) + U.shot(game, DIR .. "/bug293_2_after_hit.png") + end + + -- ===================================================================== + -- 2. the killing blow: it must hold until the bar has drained empty + -- ===================================================================== + U.log("--- battle 2: the hit that kills you --------------------------") + U.teleport(game, "ROUTE_1", 5, 5, "down") + local lead2 = freshLead() + local px2 = intoTheRed(lead2) + check("the lead's bar is red again", px2 < 10) + -- One guaranteed-lethal move instead of the wild AI's random pick: only + -- one of a :L40 PIDGEY's four real moves does any damage. + local b2 = startBattle("PIDGEY", 40, { { id = "WING_ATTACK", pp = 35 } }) + check("reached the action menu", b2.phase == "menu") + check("the siren is sounding before the killing blow", siren()) + + local log2, pre2, hitAt2, settleAt2, emptyAt2 = playTurn(b2) + U.log(("lethal turn: %d frames sampled, HP %d -> %d, hit on frame %s, ") + :format(#log2, pre2, b2.player.mon.hp, tostring(hitAt2)) + .. ("bar empty on frame %s"):format(tostring(emptyAt2))) + if check("the killing blow landed", hitAt2 ~= nil and b2.player.mon.hp <= 0) then + local to = emptyAt2 or settleAt2 or #log2 + local gap, gapAt = longestGap(log2, hitAt2, to) + U.log(("longest silence between the killing blow and the empty bar: " .. + "%d frames%s"):format(gap, gapAt and (" (from frame " .. gapAt .. + ")") or "")) + check("the siren held all the way down to an empty bar (#293)", gap == 0) + if gap > 0 then + U.log(" it went quiet during:", log2[gapAt].text ~= "" and + log2[gapAt].text:gsub("\n", " / ") or "(no text on screen)") + end + if emptyAt2 then + -- and only then does it stop (RemoveFaintedPlayerMon) + local stopped = false + for i = emptyAt2, #log2 do + if not log2[i].on then stopped = true break end + end + check("and went quiet once the bar was empty (RemoveFaintedPlayerMon)", + stopped) + end + U.shot(game, DIR .. "/bug293_3_ko.png") + end + + -- ===================================================================== + -- 3. hand off, already at the FIGHT menu with the siren going + -- ===================================================================== + U.teleport(game, "ROUTE_1", 5, 5, "down") + local lead3 = freshLead() + intoTheRed(lead3) + local b3 = startBattle("PIDGEY", 3) + check("handing off at the action menu", b3.phase == "menu") + check("handing off with the siren sounding", siren()) + U.shot(game, DIR .. "/bug293_4_handoff.png") + + U.log("FIGHT menu, wild PIDGEY, RATTATA in the red and the siren going.") + U.log("A, A uses TAIL WHIP (power 0), so the PIDGEY keeps hitting back;") + U.log("it takes several turns to die.") + U.log("The siren must not break under the foe's move, the animation or the") + U.log("bar drain, and on the lethal hit it holds to an empty bar (#293).") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/moveselect_border_bug240_test.lua b/tests/drivers/moveselect_border_bug240_test.lua new file mode 100644 index 00000000..5741be39 --- /dev/null +++ b/tests/drivers/moveselect_border_bug240_test.lua @@ -0,0 +1,99 @@ +-- Driver: the seam where the TYPE/PP box meets the move box (#240). pokered +-- MoveSelectionMenu (engine/battle/core.asm:2492-2501) writes '─' at (4,12) and +-- '┘' at (10,12) into the tilemap, REPLACING the tile. Eye check, no SPEED. +-- POKEPORT_DRIVER=tests/drivers/moveselect_border_bug240_test.lua \ +-- POKEPORT_IDENTITY=bug240 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 Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local Font = require("src.render.Font") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- ---- preconditions ----------------------------------------------------- + -- a missing glyph page draws nothing in those cells, which reads as a clean + -- seam and would pass by accident + + check("Font.BORDER.h (the '─' patch glyph) is defined", + Font.BORDER ~= nil and Font.BORDER.h ~= nil) + check("Font.BORDER.br (the '┘' patch glyph) is defined", + Font.BORDER ~= nil and Font.BORDER.br ~= nil) + check("Font.drawCode exists (the transparent blit at the heart of this)", + type(Font.drawCode) == "function") + check("Font.drawBox exists (the white fill the patch has to match)", + type(Font.drawBox) == "function") + local extra = love.filesystem.getInfo("assets/generated/fonts/font_extra.png") + check("assets/generated/fonts/font_extra.png is in the cache", extra ~= nil) + + game.save.player.name = "bryan" + -- CHARIZARD at 50 knows four moves, so the list fills all four rows and the + -- PP figure sits directly above the (10,12) cell being judged + game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) } + local lead = game.save.party[1] + check("the lead knows at least one move", #lead.moves >= 1) + U.log("move list:", (function() + local names = {} + for _, m in ipairs(lead.moves) do + names[#names + 1] = game.data.moves[m.id].name + end + return table.concat(names, ", ") + end)()) + + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(20) + local ow = game.overworld + check("overworld is up to push the battle from", ow ~= nil) + + local battle = BattleState.newWild(game, "PIDGEY", 8) + battle.onFinish = function() end + ow:pushBattle(battle) + + local function tapUntil(cond, taps, gap) + for _ = 1, (taps or 60) do + if cond() then return true end + U.tap(game, "a") + for _ = 1, (gap or 6) do + if cond() then return true end + U.wait(1) + end + end + return cond() + end + + check("reached the FIGHT/PKMN/ITEM/RUN menu", + tapUntil(function() return battle.phase == "menu" end, 60)) + check("cursor starts on FIGHT", battle.menuIndex == 1) + + -- A on FIGHT opens the move list: this is the screen being judged. + U.tap(game, "a") + U.wait(20) + check("the FIGHT move list is open (#240 lives on this screen)", + battle.phase == "moveSelect") + check("move-list screenshot reached disk", + U.shot(game, DIR .. "/bug240_move_list.png")) + U.log("captured", DIR .. "/bug240_move_list.png") + + -- move the cursor down one row too: the PP figure changes, and the '┘' + -- corner under it must stay identical + U.tap(game, "down") + U.wait(20) + check("move-list screenshot on the second move reached disk", + U.shot(game, DIR .. "/bug240_move_list_row2.png")) + U.log("captured", DIR .. "/bug240_move_list_row2.png") + + -- ---- hand off ---------------------------------------------------------- + U.log("The FIGHT move list is open. Judge tiles (4,12) and (10,12) on the row") + U.log("where the TYPE/PP box meets the move box; (10,12) sits under the PP") + U.log("count. Both want clean border: no black blobs showing through, no") + U.log("white gap, and no change as you move the cursor with UP/DOWN. (#240)") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/nidoran_cry_bug247_test.lua b/tests/drivers/nidoran_cry_bug247_test.lua index 5688fe69..59bbe670 100644 --- a/tests/drivers/nidoran_cry_bug247_test.lua +++ b/tests/drivers/nidoran_cry_bug247_test.lua @@ -1,35 +1,9 @@ --- Driver: manual audio check for the silent Pewter NIDORAN (#247). --- --- pokered scripts/PewterNidoranHouse.asm, PewterNidoranHouseNidoranText: --- text_far _PewterNidoranHouseNidoranText --- text_asm --- ld a, NIDORAN_M --- call PlayCry --- call WaitForSoundToFinish --- jp TextScriptEnd --- so the box types "NIDORAN: Bowbow!" first and the male NIDORAN cry sounds --- second, once the typewriter is done. The box then still waits for a --- button: PewterNidoranHouse_Script is `jp EnableAutoTextBoxDrawing`, and --- AutoTextBoxDrawingCommon (pokered home/window.asm) zeroes --- wDoNotWaitForButtonPressAfterDisplayingText, so DisplayTextID falls through --- to WaitForTextScrollButtonPress like any other NPC line. --- data/scripts/flavor/pewter_nidoran_house.lua ported the dialogue row but --- never the cry row, so the NIDORAN was silent. --- --- This is an audio fix, so nothing here can assert it. What this driver does --- is check the halves an ear cannot check (the cry row is wired into the talk --- script in the adjacency Commands.play_cry/show_text require, and the --- NIDORAN_M cry sample resolves the way Sound.playCry looks it up), park the --- player one cell below the NIDORAN already facing it, and then hand input --- straight back so a human hears the cry live. --- --- Do NOT add POKEPORT_SPEED to this run: fast-forward scales only the logic --- clock while audio runs on its own real-time 60 Hz accumulator --- (src/core/Game.lua), so the cry would stop lining up with the box it --- belongs to. --- --- POKEPORT_DRIVER=tests/drivers/nidoran_cry_bug247_test.lua \ --- POKEPORT_IDENTITY=bug247 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +-- Audio check for the silent Pewter NIDORAN (#247). pokered +-- scripts/PewterNidoranHouse.asm types the line, then `ld a, NIDORAN_M / call +-- PlayCry / call WaitForSoundToFinish`, and the box still waits for a button; +-- the port ported the dialogue row and not the cry row. +-- Don't add POKEPORT_SPEED: audio runs on its own real-time accumulator. +-- POKEPORT_DRIVER=tests/drivers/nidoran_cry_bug247_test.lua POKEPORT_IDENTITY=bug247 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . return function(game) local U = dofile("tests/drivers/util.lua") local mapScripts = require("data.scripts.init") @@ -44,11 +18,9 @@ return function(game) return ok end - -- ---- preconditions the ear cannot check -------------------------------- -- Commands.play_cry only stashes ctx.pendingCry; Commands.show_text is what - -- turns it into the box's opts.auto. The two rows therefore have to be - -- adjacent and in that order, or the cry is handed to the wrong box or - -- never consumed at all -- and either way the NIDORAN stays quiet. + -- turns it into the box's opts.auto, so the two rows have to be adjacent and + -- in that order or the cry goes to the wrong box, or to none at all local rows = mapScripts.talkScript(MAP, TEXT) local cryAt, textAt for i, row in ipairs(rows or {}) do @@ -58,16 +30,15 @@ return function(game) local cry = cryAt and rows[cryAt] check("talk script carries a play_cry row", cry ~= nil) check("cry species is " .. SPECIES, cry ~= nil and cry[2] == SPECIES) - -- the third arg is play_cry's waitForButton form, which keeps - -- DisplayTextID's trailing WaitForTextScrollButtonPress instead of letting - -- the box pop itself the instant the cry goes quiet + -- the third arg is play_cry's waitForButton form, which keeps DisplayTextID's + -- trailing WaitForTextScrollButtonPress instead of letting the box pop itself + -- the instant the cry goes quiet check("cry row keeps the button wait", cry ~= nil and cry[3] == true) check("play_cry sits immediately before show_text", cryAt ~= nil and textAt == cryAt + 1) - -- Sound.playCry reads data.audio.cries[species]; a missing or misspelled - -- key is a silent no-op with no error, which is indistinguishable by ear - -- from the bug itself + -- Sound.playCry reads data.audio.cries[species]; a missing or misspelled key + -- is a silent no-op with no error, which sounds exactly like the bug local cries = game.data.audio and game.data.audio.cries check("data.audio.cries." .. SPECIES .. " resolves", cries ~= nil and cries[SPECIES] ~= nil) @@ -80,10 +51,9 @@ return function(game) "raise SFX VOL in OPTION first") end - -- ---- park the player in front of the NIDORAN --------------------------- - -- pokered data/maps/objects/PewterNidoranHouse.asm puts the NIDORAN on - -- (4, 5) facing LEFT at the little boy on (3, 5), so the free approach cell - -- is the floor directly below it. + -- pokered data/maps/objects/PewterNidoranHouse.asm puts the NIDORAN on (4, 5) + -- facing LEFT at the little boy on (3, 5), so the free approach cell is the + -- floor directly below it U.teleport(game, MAP, 4, 6, "up") U.wait(10) @@ -94,8 +64,8 @@ return function(game) return nil end - -- re-reads game.overworld every call: the fallback below teleports again, - -- which rebuilds the whole state and its npc list + -- re-reads game.overworld every call: the fallback below teleports again and + -- that rebuilds the state and its npc list local function facingTheMon() local ow = game.overworld local mon = ow and nidoranIn(ow) @@ -109,10 +79,9 @@ return function(game) check("NIDORAN object loaded on " .. MAP, mon ~= nil) if mon and not facingTheMon() then - -- the hard-coded approach cell stopped working (map edit, or a mod moved - -- the object): take any free walkable neighbour and turn toward the mon. - -- {dx, dy, facing} is the offset from the mon to the stand cell plus the - -- direction that looks back at it, so +1 on x means facing left. + -- a map edit or a mod moved the object: fall back to any free walkable + -- neighbour. {dx, dy, facing} is the offset from the mon to the stand cell + -- plus the direction that looks back at it, so +1 on x means left. local sides = { { 0, 1, "up" }, { 1, 0, "left" }, { -1, 0, "right" }, { 0, -1, "down" }, } @@ -129,20 +98,10 @@ return function(game) end check("player is standing in front of the NIDORAN", facingTheMon()) - -- ---- hand off, then stay out of the way -------------------------------- - U.log("........................................................") - U.log("LISTEN NOW: press A to talk to the NIDORAN in front of you.") - U.log(" RIGHT: the box types out \"NIDORAN: Bowbow!\", the male NIDORAN") - U.log(" cry sounds ONCE after the last character lands, then the") - U.log(" arrow blinks and the box waits for your A or B.") - U.log(" BUG #247 sounds like: the line types out and then nothing, dead") - U.log(" silence under the house music, arrow blinking.") - U.log(" ALSO WRONG: the cry fires while the text is still typing (row") - U.log(" order broken), or the box closes itself the moment the cry") - U.log(" ends with no button press (waitForButton lost).") - U.log("Input is yours from here on -- talk again as often as you like, and") - U.log("the little boy on the left is the silent control to compare against.") - U.log("........................................................") + U.log("Press A to talk to the NIDORAN in front of you.") + U.log("The box types \"NIDORAN: Bowbow!\", then the cry sounds once after the") + U.log("last character lands and the box waits for A or B; under #247 the line") + U.log("typed and nothing followed. The boy on the left is the silent control.") while true do coroutine.yield() diff --git a/tests/drivers/party_cursor_bug278_test.lua b/tests/drivers/party_cursor_bug278_test.lua index 7fd40f7f..899a3453 100644 --- a/tests/drivers/party_cursor_bug278_test.lua +++ b/tests/drivers/party_cursor_bug278_test.lua @@ -1,29 +1,8 @@ --- Driver: party menu cursor alignment (#278). A manual eye check, not a --- pass/fail run -- no assertion in this repo can judge where a triangle --- sits against a reference screenshot. --- --- pokered evidence. home/pokemon.asm PartyMenuInit seeds the shared menu --- cursor coordinates: --- --- ld hl, wTopMenuItemY --- inc a ; a = 1 --- ld [hli], a ; top menu item Y --- xor a --- ld [hli], a ; top menu item X --- --- and home/window.asm PlaceMenuCursor walks that many rows down from --- hlcoord 0, 0. Meanwhile party_menu.asm RedrawPartyMenu_ starts the name --- column at hlcoord 3, 0. So a party entry's name is on tile row 0 while --- its cursor belongs on tile row 1: the level/HP line, level with the --- middle of the two-row icon. --- --- The bug: PartyMenu drew the cursor at entryY(i), the name row, putting it --- a full tile (8px) too high on every slot. The fix draws it at y + 8. --- --- Do NOT run this under POKEPORT_SPEED. Fast-forward scales only the logic --- clock while rendering and audio run on their own real-time accumulators --- (src/core/Game.lua), so a sped-up run can capture a half-drawn frame. --- +-- Driver: party menu cursor alignment (#278). Eye check, not pass/fail. +-- pokered home/pokemon.asm PartyMenuInit seeds wTopMenuItemY = 1 while +-- party_menu.asm RedrawPartyMenu_ starts the name column at hlcoord 3, 0, +-- so the cursor belongs on an entry's second row (level/HP), not its name +-- row. No POKEPORT_SPEED: rendering runs on its own real-time clock. -- POKEPORT_DRIVER=tests/drivers/party_cursor_bug278_test.lua POKEPORT_IDENTITY=bug278 love . return function(game) local U = dofile("tests/drivers/util.lua") @@ -36,12 +15,8 @@ return function(game) return ok end - -- ---- preconditions a human's eye cannot separate from the bug ---------- - -- A cursor drawn off-screen, or a party too short to show the stride, - -- both look exactly like "the offset is wrong". Check them first. - - -- the geometry contract the fix depends on: 16px stride, name row at the - -- top of each entry. If entryY ever changes, the +8 has to be revisited. + -- the geometry the +8 depends on: 16px stride, name row at the top of + -- each entry. If entryY changes, the cursor offset has to be revisited. check("entryY stride is 16px", PartyMenu.entryY(2) - PartyMenu.entryY(1) == 16) check("slot 1 name row is y=0", PartyMenu.entryY(1) == 0) @@ -54,7 +29,6 @@ return function(game) game.save.player.name = "bryan" check("party has enough slots to judge the stride", #game.save.party >= 3) - -- the window actually rendered: a black frame is not an offset bug check("renderer is up", game.renderer ~= nil) U.teleport(game, "PALLET_TOWN", 10, 8, "down") @@ -66,8 +40,7 @@ return function(game) U.shot(game, "bug278_party_cursor_slot1.png") - -- move the cursor down so the stride is visible too: a fix that is right - -- on slot 1 and wrong further down would otherwise read as a pass + -- step down as well, so a stride bug cannot hide behind a correct slot 1 U.tap(game, "down") U.wait(15) U.shot(game, "bug278_party_cursor_slot2.png") @@ -75,20 +48,10 @@ return function(game) U.wait(15) U.shot(game, "bug278_party_cursor_slot3.png") - U.log("........................................................") - U.log("LOOK NOW: the party menu is open. Screenshots were written to the") - U.log(" LOVE save dir as bug278_party_cursor_slot1/2/3.png.") - U.log(" RIGHT: the black triangle sits on the LOWER of each entry's two") - U.log(" rows, level with the LEVEL/HP line and with the middle of") - U.log(" the two-row party icon.") - U.log(" BUG #278 looks like: the triangle riding up on the NAME row, its") - U.log(" tip level with the first letter of the nickname.") - U.log(" ALSO WRONG: correct on slot 1 but drifting on slots 2-4 (that is") - U.log(" a stride bug, not an offset bug), or the triangle sliding") - U.log(" a half tile so it straddles both rows.") - U.log("Compare against the reference shot in issue #278. Input is yours") - U.log("from here: up/down re-checks every slot, B closes the menu.") - U.log("........................................................") + U.log("Party menu is open; shots are in the LOVE save dir as") + U.log("bug278_party_cursor_slot1/2/3.png. The cursor should sit on the") + U.log("lower row of each entry, level with LEVEL/HP, not up on the name") + U.log("row (#278). Up/down re-checks every slot, B closes the menu.") while true do coroutine.yield() diff --git a/tests/drivers/party_heal_bug252_test.lua b/tests/drivers/party_heal_bug252_test.lua new file mode 100644 index 00000000..2e295169 --- /dev/null +++ b/tests/drivers/party_heal_bug252_test.lua @@ -0,0 +1,278 @@ +-- Driver: watch a POTION fill the party menu's HP bar (#252). +-- pokered engine/items/item_effects.asm .doneHealing runs UpdateHPBar2 with +-- the party menu still drawn; status cures branch off and never touch the bar. +-- Not under POKEPORT_SPEED: SFX_HEAL_HP rides the real-time audio clock. +-- POKEPORT_DRIVER=tests/drivers/party_heal_bug252_test.lua \ +-- POKEPORT_IDENTITY=bug252 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 Pokemon = require("src.pokemon.Pokemon") + local Bag = require("src.inventory.Bag") + local ItemEffects = require("src.inventory.ItemEffects") + local PartyMenu = require("src.ui.PartyMenu") + local TextBox = require("src.render.TextBox") + + local pass, fail = 0, 0 + local function check(label, ok) + if ok then pass = pass + 1 else fail = fail + 1 end + U.log(ok and "PASS" or "FAIL", label) + return ok + end + local function top() return game.stack:top() end + local function isPicker(s) + return s ~= nil and (s.screenId == "PartyMenu" or getmetatable(s) == PartyMenu) + end + local function isBox(s) return getmetatable(s) == TextBox end + local function inStack(pred) + for _, s in ipairs(game.stack.states or {}) do + if pred(s) then return true end + end + return false + end + + -- ---- preconditions ------------------------------------------------------ + -- A lost item id, a healsHP gate that dropped an item, or a use() that stops + -- handing back the pre-heal HP all read as "the animation is broken". + U.log("======== #252 party-menu HP fill: machine checks ========") + check("ItemEffects.healsHP exists", type(ItemEffects.healsHP) == "function") + check("PartyMenu:animateTo exists", type(PartyMenu.animateTo) == "function") + check("PartyMenu:close exists", type(PartyMenu.close) == "function") + if type(ItemEffects.healsHP) == "function" then + -- .doneHealing is reached by exactly the .healHP items; FULL_HEAL and the + -- single-status cures jump to the no-bar branch + for _, id in ipairs({ "POTION", "SUPER_POTION", "HYPER_POTION", + "MAX_POTION", "FULL_RESTORE", "REVIVE", + "MAX_REVIVE" }) do + check(id .. " takes the .healHP (animated) path", + ItemEffects.healsHP(id) == true) + end + for _, id in ipairs({ "ANTIDOTE", "PARLYZ_HEAL", "AWAKENING", "BURN_HEAL", + "ICE_HEAL", "FULL_HEAL", "ETHER" }) do + check(id .. " does NOT animate the bar (status cure / PP)", + ItemEffects.healsHP(id) ~= true) + end + end + for _, id in ipairs({ "POTION", "MAX_POTION", "REVIVE", "ANTIDOTE" }) do + check(id .. " resolves in the item table", game.data.items[id] ~= nil) + end + + -- use() must hand back the PRE-heal HP so the fill has a start (wHPBarOldHP) + do + local scratch = Pokemon.new(game.data, "BULBASAUR", 20) + scratch.hp = 3 + local scratchSave = { party = { scratch }, inventory = {}, flags = {}, + pokedex = { seen = {}, owned = {} } } + local result, msgs, extra = ItemEffects.use(game.data, scratchSave, + "POTION", scratch) + check("POTION on a hurt mon reports consumed", result == "consumed") + check("...and hands back extra.healedFrom = the pre-heal HP", + type(extra) == "table" and extra.healedFrom == 3) + check("...with the restored-HP message", + type(msgs) == "table" and type(msgs[1]) == "string" + and msgs[1]:find("restored", 1, true) ~= nil) + local _, _, cureExtra = ItemEffects.use(game.data, scratchSave, + "ANTIDOTE", scratch) + check("ANTIDOTE hands back no healedFrom (no fill)", + cureExtra == nil or cureExtra.healedFrom == nil) + end + + -- ---- the fixture -------------------------------------------------------- + -- CHARIZARD L50 sits near 150 max HP, so a MAX_POTION from 1 HP is the full + -- 96-frame fill across the whole 48-pixel bar. + local lead = Pokemon.new(game.data, "CHARIZARD", 50) + local fainted = Pokemon.new(game.data, "PIKACHU", 30) + local poisoned = Pokemon.new(game.data, "SNORLAX", 40) + lead.hp = 1 + fainted.hp = 0 + poisoned.status = "PSN" + game.save.party = { lead, fainted, poisoned } + game.save.player.name = "RED" + for _, row in ipairs({ { "MAX_POTION", 9 }, { "POTION", 9 }, + { "REVIVE", 9 }, { "ANTIDOTE", 9 } }) do + Bag.add(game.save, row[1], row[2]) + end + U.log(("lead: %s %d/%d HP"):format(lead.species, lead.hp, lead.stats.hp)) + + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(10) + + -- ---- menu navigation --------------------------------------------------- + local function cursorTo(menu, want) + for _ = 1, 40 do + if not menu or menu.index == want then return menu and menu.index == want end + U.tap(game, menu.index < want and "down" or "up") + U.wait(3) + end + return menu.index == want + end + + -- START -> ITEM -> -> USE, leaving the party picker open. Returns the + -- picker, or nil plus a reason. + local function openPickerFor(id) + U.tap(game, "start") + U.wait(10) + local menu = top() + if not (menu and menu.screenId == "StartMenu") then + return nil, "start menu never opened" + end + -- the ITEM row shifts with POKéDEX / LINK / MODS, so never hardcode it + local itemRow + for i, it in ipairs(menu.items or {}) do + if it.label == "ITEM" then itemRow = i break end + end + if not itemRow or not cursorTo(menu, itemRow) then return nil, "no ITEM row" end + U.tap(game, "a") + U.wait(10) + + local bag = top() + if not (bag and bag.screenId == "BagMenu") then return nil, "bag never opened" end + local bagRow + for i, r in ipairs(bag.items or {}) do + if r.value == id then bagRow = i break end + end + if not bagRow or not cursorTo(bag, bagRow) then return nil, id .. " not in bag" end + U.tap(game, "a") + U.wait(10) + + -- outside battle every usable item offers USE / TOSS first; USE is row 1 + local ut = top() + if ut and ut.items and ut.items[1] and ut.items[1].label == "USE" then + if not cursorTo(ut, 1) then return nil, "USE row unreachable" end + U.tap(game, "a") + U.wait(10) + end + local picker = top() + if not isPicker(picker) then return nil, "party picker never opened" end + return picker + end + + local function backToOverworld() + for _ = 1, 30 do + if top() == game.overworld then return true end + U.tap(game, "b") + U.wait(6) + end + return top() == game.overworld + end + + -- ======== scripted run: MAX_POTION on the 1 HP lead ====================== + U.log("======== #252 scripted run: MAX POTION on a 1 HP CHARIZARD ========") + local picker, why = openPickerFor("MAX_POTION") + check("party picker opened for MAX POTION" .. (why and (" (" .. why .. ")") or ""), + picker ~= nil) + + if picker then + check("cursor sits on the hurt lead", cursorTo(picker, 1)) + U.shot(game, DIR .. "/bug252_picker_before.png") + + local hpBefore = lead.hp + U.tap(game, "a") -- choose the lead + + -- THE DEFECT: the picker used to pop here, before the item had even run. + check("the party menu is STILL the top state after the A press", + isPicker(top())) + check("the fill is running (picker.heal is set)", + type(picker.heal) == "table") + if type(picker.heal) == "table" then + check("the fill starts from the pre-heal HP (wHPBarOldHP)", + math.floor(picker.heal.shown + 0.5) == hpBefore) + end + + -- Sample the climb and prove input is ignored for its duration + -- (UpdateHPBar2 blocks). U.frame() is the real yield count: the taps below + -- each burn a frame, so an iteration counter would under-report. + local startFrame, samples, blocked = U.frame(), {}, true + local iter, shot1, shot2 = 0, false, false + for _ = 1, 400 do + if not picker.heal then break end + local shown = picker.heal.shown + samples[#samples + 1] = shown + local frac = shown / math.max(1, lead.stats.hp) + if not shot1 and frac > 0.33 then + shot1 = true + U.shot(game, DIR .. "/bug252_fill_third.png") + elseif not shot2 and frac > 0.66 then + shot2 = true + U.shot(game, DIR .. "/bug252_fill_two_thirds.png") + else + -- mash B and A: neither may do anything while the bar is filling + U.tap(game, (iter % 2 == 0) and "b" or "a") + end + if picker.heal and not isPicker(top()) then blocked = false end + iter = iter + 1 + U.wait(1) + end + local frames = U.frame() - startFrame + U.log(("fill ran ~%d frames (%.2f s at 60 Hz)"):format(frames, frames / 60)) + check("the fill took more than half a second (it animates, not snaps)", + frames > 30) + check("the fill is not absurdly long (< 3 s)", frames < 180) + check("A and B did nothing while the bar filled", blocked) + local rose = #samples >= 2 and samples[#samples] > samples[1] + check("the drawn HP climbed over those frames", rose) + if #samples >= 2 then + U.log(("shown HP walked %.1f -> %.1f of %d") + :format(samples[1], samples[#samples], lead.stats.hp)) + end + check("the mon really is at full HP now", lead.hp == lead.stats.hp) + + -- .showHealingItemMessage: the message prints with the menu still drawn + for _ = 1, 60 do + if isBox(top()) then break end + U.wait(1) + end + check("the message box opened", isBox(top())) + check("...over the STILL-drawn party menu", inStack(isPicker)) + U.wait(60) -- let the line type out, so the shot shows the text not an empty box + U.shot(game, DIR .. "/bug252_message_over_party.png") + + -- TextBox pops BEFORE it fires onDone, which is what makes + -- PartyMenu:close's identity check land on the picker + for _ = 1, 30 do + if not inStack(isPicker) then break end + U.tap(game, "a") + U.wait(8) + end + check("the picker is gone once the message is dismissed", not inStack(isPicker)) + local back = top() + check("and we are back on the ITEM list", back ~= nil and back.screenId == "BagMenu") + U.shot(game, DIR .. "/bug252_back_on_bag.png") + end + backToOverworld() + + -- ======== contrast: ANTIDOTE must NOT animate =========================== + U.log("======== #252 contrast: ANTIDOTE (no bar fill at all) ========") + local cure = openPickerFor("ANTIDOTE") + if check("party picker opened for ANTIDOTE", cure ~= nil) then + check("keepOpen is off for a status cure", cure.keepOpen ~= true) + cursorTo(cure, 3) -- the poisoned SNORLAX + U.tap(game, "a") + U.wait(6) + check("no fill was started for a status cure", cure.heal == nil) + check("the picker popped itself, like every non-medicine item", + not inStack(isPicker)) + U.shot(game, DIR .. "/bug252_antidote_message.png") + check("PSN was cured", poisoned.status == nil) + end + backToOverworld() + + -- ---- verdict, then re-arm and hand off ---------------------------------- + U.log(("======== machine checks: %d passed, %d failed ========"):format(pass, fail)) + + lead.hp = 1 + fainted.hp = 0 + poisoned.status = "PSN" + local rearmed = openPickerFor("MAX_POTION") + if rearmed then cursorTo(rearmed, 1) end + + U.log("The bag, USE and the party picker are re-opened with the cursor on a") + U.log("1 HP CHARIZARD and a MAX POTION chosen. Press A, watch slot 1's bar:") + U.log("the list stays up, the bar lengthens over ~1.5s with the number, and") + U.log("buttons do nothing until it lands. #252 was the list snapping shut.") + U.log("Spare items are in the bag if you want to run it again.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/party_hp_palette_bug274_test.lua b/tests/drivers/party_hp_palette_bug274_test.lua new file mode 100644 index 00000000..a0f31bd8 --- /dev/null +++ b/tests/drivers/party_hp_palette_bug274_test.lua @@ -0,0 +1,281 @@ +-- Driver: eyeball the party screen's colors (#274, absorbing #272). pokered +-- SetPal_PartyMenu (engine/gfx/palettes.asm:90) sends a four-palette block +-- packet: MEWMON over the icon column, GREENBAR elsewhere, and one block per +-- HP-bar row from GetHealthBarColor (palettes.asm:293-325). +-- POKEPORT_DRIVER=tests/drivers/party_hp_palette_bug274_test.lua \ +-- POKEPORT_IDENTITY=bug274 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 PaletteFX = require("src.render.PaletteFX") + local PartyMenu = require("src.ui.PartyMenu") + local Pokemon = require("src.pokemon.Pokemon") + local Screens = require("src.ui.Screens") + local Strings = require("src.core.Strings") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- Set the SAVED option, not just the live mode: Game:applyOptions re-reads + -- save.options.colors, so a bare setMode gets reverted. + game.save.options = game.save.options or {} + game.save.options.colors = "redpp" + PaletteFX.setMode("redpp") + check("COLORS is ADVANCED (the mode #274 was reported in)", + PaletteFX.mode == "redpp" and PaletteFX.modeLabel() == "ADVANCED") + + -- A 48/48 bar is one pixel per HP, so GetHealthBarColor's 27 / 10 pixel + -- thresholds land on plain numbers. + local ROWS = { + { species = "BULBASAUR", hp = 48, pal = "GREENBAR", fill = true }, + { species = "PIDGEY", hp = 26, pal = "YELLOWBAR", fill = true }, + { species = "RATTATA", hp = 9, pal = "REDBAR", fill = true }, + { species = "CATERPIE", hp = 0, pal = "REDBAR", fill = false }, + } + + -- ---- preconditions the eye cannot check -------------------------------- + -- A missing palette name, an absent shader or a stale zone rect all read as + -- "the colors look wrong", exactly like the bug did. + local function pal(name) return PaletteFX.pal(game.data, name) end + local function rgb(c) + return c and ("{" .. c[1] .. "," .. c[2] .. "," .. c[3] .. "}") or "nil" + end + + local mew = pal("MEWMON") + local green = pal("GREENBAR") + check("MEWMON and the three bar palettes all resolve", + mew ~= nil and green ~= nil and pal("YELLOWBAR") ~= nil + and pal("REDBAR") ~= nil) + -- the exact purple the reporter's screenshot was full of + check("MEWMON color 2 is the reported purple {115,33,165}", + mew ~= nil and mew[3][1] == 115 and mew[3][2] == 33 and mew[3][3] == 165) + U.log(" MEWMON :", rgb(mew and mew[1]), rgb(mew and mew[2]), + rgb(mew and mew[3]), rgb(mew and mew[4])) + for _, n in ipairs({ "GREENBAR", "YELLOWBAR", "REDBAR" }) do + local c = pal(n) + U.log((" %-9s fill color 2: %s"):format(n, rgb(c and c[3]))) + end + -- the base zone swapped from MEWMON to GREENBAR, which stays invisible only + -- because the two agree on paper and ink: the nicknames, level line, HP + -- numbers, box border and cursor are all color 3 on color 0 + check("MEWMON and GREENBAR share color 0 and color 3 (text/box unchanged)", + mew ~= nil and green ~= nil + and mew[1][1] == green[1][1] and mew[1][2] == green[1][2] + and mew[1][3] == green[1][3] + and mew[4][1] == green[4][1] and mew[4][2] == green[4][2] + and mew[4][3] == green[4][3]) + -- with no shade-remap shader nothing colorizes the canvas and the pixel + -- counts below are meaningless + check("the SGB shade-remap shader compiled", PaletteFX.shader() ~= nil) + + for _, row in ipairs(ROWS) do + check(row.species .. " is a known species", + game.data.pokemon[row.species] ~= nil) + check(("%d/48 HP is a %s bar"):format(row.hp, row.pal), + PaletteFX.barPalName(row.hp, 48) == row.pal) + end + + -- ---- park the player, then open the party list yourself ----------------- + -- pokered data/maps/objects/PalletTown.asm: (10, 12) carries no warp_event, + -- bg_event or object_event. Any open cell would do; the fallback below + -- finds one if a map edit ever closes this one. + local party = {} + for i, row in ipairs(ROWS) do + local mon = Pokemon.new(game.data, row.species, 20) + mon.stats.hp = 48 + mon.hp = row.hp + party[i] = mon + end + game.save.party = party + game.save.player.name = game.save.player.name or "RED" + + U.teleport(game, "PALLET_TOWN", 10, 12, "down") + U.wait(10) + local ow = game.overworld + if ow and ow.map and not ow.map:isWalkableCell(10, 12) then + local fx, fy + for y = 1, 16 do + for x = 1, 18 do + if not fx and ow.map:isWalkableCell(x, y) and not ow:npcAtCell(x, y) then + fx, fy = x, y + end + end + end + if fx then + U.log("(10, 12) is blocked now; standing on", fx, fy) + U.teleport(game, "PALLET_TOWN", fx, fy, "down") + U.wait(10) + end + end + + -- START -> POKéMON -> A, walked rather than pushed, so a broken start menu + -- shows up here instead of masquerading as a palette bug + U.tap(game, "start") + U.wait(12) + local menu = game.stack:top() + local target = 1 + if menu and menu.items then + local want = Strings("POKéMON") + for i, it in ipairs(menu.items) do + if it.label == want then target = i break end + end + check("START menu lists POKéMON", menu.items[target] ~= nil + and menu.items[target].label == want) + for _ = 2, target do + U.tap(game, "down") + U.wait(4) + end + else + check("START opened the start menu", false) + end + U.tap(game, "a") + U.wait(16) + + local pm = game.stack:top() + if getmetatable(pm) ~= PartyMenu then + U.log("start-menu walk did not land on the party list; pushing it directly") + Screens.push(game, "PartyMenu", {}) + U.wait(12) + pm = game.stack:top() + end + check("the party list is open", getmetatable(pm) == PartyMenu) + + -- ---- the block packet this screen asks for ------------------------------ + local zones = getmetatable(pm) == PartyMenu and pm:sgbPalettes(game) or nil + check("sgbPalettes returns base + icon column + one block per bar row", + type(zones) == "table" and #zones == 2 + #ROWS) + zones = zones or {} + check("the base zone is GREENBAR over the whole screen", + zones[1] ~= nil and zones[1].colors == green and zones[1].x == 0 + and zones[1].y == 0 and zones[1].w == 160 and zones[1].h == 144) + check("the icon column is a MEWMON block at tiles 1-2, rows 0-11", + zones[2] ~= nil and zones[2].colors == mew and zones[2].x == 8 + and zones[2].y == 0 and zones[2].w == 16 and zones[2].h == 96) + for i, row in ipairs(ROWS) do + local z = zones[2 + i] + check(("row %d (%d/48 HP) carries the %s block palette") + :format(i, row.hp, row.pal), + z ~= nil and z.colors == pal(row.pal)) + check(("row %d's block covers the bar's cap + six fill tiles"):format(i), + z ~= nil and z.x == 48 and z.w == 56 and z.y == (i * 2 - 1) * 8 + and z.h == 8) + end + + -- ---- what the screen actually holds ------------------------------------- + -- Render the live menu into a clean 160x144 canvas and run the same zone + -- pass Renderer:endFrame does (shade-remap shader, sendColors per zone, + -- scissor to its rect, redraw), at scale 1 so a pixel is a pixel. + local function colorized() + local raw = love.graphics.newCanvas(160, 144) + love.graphics.setCanvas(raw) + love.graphics.clear(1, 1, 1, 1) + love.graphics.setColor(1, 1, 1, 1) + pm:draw() + love.graphics.setCanvas() + + local out = love.graphics.newCanvas(160, 144) + local shader = PaletteFX.shader() + love.graphics.setCanvas(out) + love.graphics.clear(0, 0, 0, 1) + love.graphics.setColor(1, 1, 1, 1) + if shader then love.graphics.setShader(shader) end + for _, z in ipairs(pm:sgbPalettes(game) or {}) do + if shader and z.colors then PaletteFX.sendColors(shader, z.colors) end + love.graphics.setScissor(z.x, z.y, z.w, z.h) + love.graphics.draw(raw, 0, 0) + end + love.graphics.setScissor() + love.graphics.setShader() + love.graphics.setCanvas() + love.graphics.setColor(1, 1, 1, 1) + return out:newImageData() + end + + -- count pixels in a rect that match a palette color (the zone pass writes + -- palette colors exactly, so the tolerance only absorbs 8-bit rounding) + local function countColor(id, c, x0, y0, x1, y1) + if not c then return -1 end + local want = { c[1] / 255, c[2] / 255, c[3] / 255 } + local n = 0 + for y = y0, y1 do + for x = x0, x1 do + local r, g, b = id:getPixel(x, y) + if math.abs(r - want[1]) < 0.02 and math.abs(g - want[2]) < 0.02 + and math.abs(b - want[3]) < 0.02 then + n = n + 1 + end + end + end + return n + end + + if getmetatable(pm) == PartyMenu and love.graphics.newCanvas + and PaletteFX.shader() then + local id = colorized() + + -- (a) the icons: OBP0 "3100" means an object never displays shade 2, so + -- MEWMON's third color must appear nowhere on the screen at all + local purple = countColor(id, mew and mew[3], 0, 0, 159, 143) + U.log(" MEWMON color-2 (purple) pixels on the whole screen:", purple) + check("no purple anywhere -- the icons never show shade 2", purple == 0) + -- and the icons are there at all: MEWMON color 1 is the warm orange they + -- read as after the OBP bake + local flesh = countColor(id, mew and mew[2], 8, 0, 23, 95) + U.log(" MEWMON color-1 (orange) pixels in the icon column:", flesh) + check("the icon column is drawn (not blank)", flesh > 0) + + -- (b) the bars: each row's fill must be ITS OWN bar color and none of the + -- other two. Fill tiles run x 56..103 (drawHPBar at tile 5: "HP" 40, + -- ":[" 48, six fill tiles 56..103, cap 104). + for i, row in ipairs(ROWS) do + local y0 = (i * 2 - 1) * 8 + local own = pal(row.pal) + -- the three bar palettes share color 0/1/3; only color 2, the fill + -- shade, tells them apart + local mine = countColor(id, own and own[3], 56, y0, 103, y0 + 7) + U.log((" row %d (%d/48 HP) %s fill pixels: %d") + :format(i, row.hp, row.pal, mine)) + if row.fill then + check(("row %d's bar is filled with %s"):format(i, row.pal), mine > 0) + end + for _, other in ipairs({ "GREENBAR", "YELLOWBAR", "REDBAR" }) do + if other ~= row.pal then + local c = pal(other) + local n = countColor(id, c and c[3], 56, y0, 103, y0 + 7) + check(("row %d's bar carries no %s"):format(i, other), n == 0) + end + end + end + end + + -- ---- screenshots --------------------------------------------------------- + U.wait(4) + if U.shot(game, DIR .. "/bug274_party_advanced.png") then + U.log("captured", DIR .. "/bug274_party_advanced.png") + end + -- the same screen in SGB: the block packet changed for every mode, not just + -- ADVANCED + game.save.options.colors = "gbc" + PaletteFX.setMode("gbc") + U.wait(10) + if U.shot(game, DIR .. "/bug274_party_sgb.png") then + U.log("captured", DIR .. "/bug274_party_sgb.png") + end + game.save.options.colors = "redpp" + PaletteFX.setMode("redpp") + U.wait(10) + + -- ---- hand off ----------------------------------------------------------- + U.log("The party list is open in COLORS = ADVANCED: BULBASAUR 48/48, PIDGEY") + U.log("26/48, RATTATA 9/48, CATERPIE 0/48. Three different bar colors run") + U.log("down the screen (green, yellow, red) and the icons carry no purple.") + U.log("#274 was every bar solid black at full health and purple-blotched icons.") + U.log("OPTION -> COLORS cycles modes; OG and CLASSIC are mono by design.") + U.log("Captures: bug274_party_advanced.png and bug274_party_sgb.png in " .. DIR) + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/party_icon_mirror_bug276_test.lua b/tests/drivers/party_icon_mirror_bug276_test.lua new file mode 100644 index 00000000..8b4d8739 --- /dev/null +++ b/tests/drivers/party_icon_mirror_bug276_test.lua @@ -0,0 +1,246 @@ +-- Driver: eyeball the party icons after the OAM mirror landed (#276, #238). +-- pokered engine/gfx/mon_icons.asm:234-251 sends every class but ICON_HELIX +-- through WriteSymmetricMonPartySpriteOAM (engine/items/town_map.asm:494-534), +-- which writes each tile twice, attributes 0 then OAM_XFLIP, so the frame's +-- right column never reaches the screen. Geometry: parity_party_icon_mirror. +-- POKEPORT_DRIVER=tests/drivers/party_icon_mirror_bug276_test.lua POKEPORT_IDENTITY=bug276 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 Assets = require("src.render.Assets") + local PartyMenu = require("src.ui.PartyMenu") + local Pokemon = require("src.pokemon.Pokemon") + local Screens = require("src.ui.Screens") + local Strings = require("src.core.Strings") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- One mon per icon class, in party order. `asym` is which of the two + -- frames carries the asymmetric art (measured below, not assumed): that + -- decides when the icon is worth looking at, since AnimatePartyMon only + -- animates the icon under the cursor. + local ROWS = { + { species = "CHARMANDER", icon = "MON", asym = "rest" }, + { species = "PIKACHU", icon = "FAIRY", asym = "rest" }, + { species = "SPEAROW", icon = "BIRD", asym = "alt" }, + { species = "OMANYTE", icon = "HELIX", asym = "whole" }, + { species = "WEEDLE", icon = "BUG", asym = "none" }, + { species = "RATTATA", icon = "QUADRUPED", asym = "none" }, + } + + -- ---- preconditions the eye cannot check -------------------------------- + -- A wrong icon class, a sheet that fails to load and a missing mirrorsIcon + -- all end in "the icons look odd", same as the bug did. + local icons = game.data.icons + check("data.icons carries the icon registry", + icons ~= nil and icons.icons ~= nil and icons.byDex ~= nil) + + check("PartyMenu.mirrorsIcon is exported", + type(PartyMenu.mirrorsIcon) == "function") + local mirrors = type(PartyMenu.mirrorsIcon) == "function" + and PartyMenu.mirrorsIcon or function() return nil end + -- data/pokemon/menu_icons.asm gives ICON_HELIX to Shellder/Cloyster, + -- Staryu/Starmie and the Omanyte/Kabuto lines, and to nothing else + check("HELIX is the one class that keeps the whole frame", + mirrors("HELIX") == false and mirrors("MON") == true) + check("mod-supplied icon art (no built-in name) still draws whole", + mirrors(nil) == false) + + -- how many mirrored pixel PAIRS of a 16x16 frame disagree in the source + -- art; 0 means the mirror is a pixel-for-pixel no-op for that frame + local function frameAsymmetry(path, frame) + local ok, id = pcall(Assets.imageData, path) + if not (ok and id) then return nil end + local h = id:getHeight() + if (frame + 1) * 16 > h then return nil end + local n = 0 + for dy = 0, 15 do + for dx = 0, 7 do + local r1, g1, b1, a1 = id:getPixel(dx, frame * 16 + dy) + local r2, g2, b2, a2 = id:getPixel(15 - dx, frame * 16 + dy) + if r1 ~= r2 or g1 ~= g2 or b1 ~= b2 or a1 ~= a2 then n = n + 1 end + end + end + return n + end + + for _, row in ipairs(ROWS) do + local def = game.data.pokemon[row.species] + local name = def and def.dex and icons.byDex[def.dex] + check(row.species .. " uses the " .. row.icon .. " icon", name == row.icon) + local path = name and icons.icons[name] + check(row.icon .. " resolves a sheet path", + type(path) == "string" and path ~= "") + row.path = path + if path then + local rest = PartyMenu.frameFor(name, false, 96) + local alt = PartyMenu.frameFor(name, true, 96) + local ar = frameAsymmetry(path, rest) + local aa = (row.icon ~= "HELIX" and row.icon ~= "BALL") + and frameAsymmetry(path, alt) or nil + check(row.icon .. " sheet art loads", ar ~= nil) + U.log((" %-9s rest frame %d: %s animated frame %s: %s") + :format(row.icon, rest, + ar and (ar .. " mirrored pixel pairs differ") or "?", + aa and tostring(alt) or "-", + aa and (aa .. " differ") or "-")) + if row.asym == "rest" then + check(row.icon .. " rest frame really is asymmetric art", + (ar or 0) > 0) + elseif row.asym == "alt" then + check(row.icon .. " animated frame really is asymmetric art", + (aa or 0) > 0) + check(row.icon .. " rest frame was already symmetric", ar == 0) + elseif row.asym == "none" then + check(row.icon .. " is untouched by the mirror (both frames symmetric)", + ar == 0 and aa == 0) + end + end + end + + -- ---- park the player, then open the party list ------------------------- + -- (10,12) in PALLET_TOWN carries no warp/bg/object event; any open cell + -- would do, and the fallback below finds one if a map edit closes it. + local party = {} + for i, row in ipairs(ROWS) do + party[i] = Pokemon.new(game.data, row.species, 20 + i) + end + game.save.party = party + game.save.player.name = game.save.player.name or "RED" + + U.teleport(game, "PALLET_TOWN", 10, 12, "down") + U.wait(10) + local ow = game.overworld + if ow and ow.map and not ow.map:isWalkableCell(10, 12) then + -- take the first free walkable cell rather than park inside scenery + local fx, fy + for y = 1, 16 do + for x = 1, 18 do + if not fx and ow.map:isWalkableCell(x, y) and not ow:npcAtCell(x, y) then + fx, fy = x, y + end + end + end + if fx then + U.log("(10, 12) is blocked now; standing on", fx, fy) + U.teleport(game, "PALLET_TOWN", fx, fy, "down") + U.wait(10) + end + end + + -- walked for real rather than pushed, so a broken start menu surfaces here + -- instead of masquerading as an icon bug + U.tap(game, "start") + U.wait(12) + local menu = game.stack:top() + local target = 1 + if menu and menu.items then + local want = Strings("POKéMON") + for i, it in ipairs(menu.items) do + if it.label == want then target = i break end + end + check("START menu lists POKéMON", menu.items[target] ~= nil + and menu.items[target].label == want) + for _ = 2, target do + U.tap(game, "down") + U.wait(4) + end + else + check("START opened the start menu", false) + end + U.tap(game, "a") + U.wait(16) + + local pm = game.stack:top() + if getmetatable(pm) ~= PartyMenu then + U.log("start-menu walk did not land on the party list; pushing it directly") + Screens.push(game, "PartyMenu", {}) + U.wait(12) + pm = game.stack:top() + end + check("the party list is open", getmetatable(pm) == PartyMenu) + + -- ---- what the screen actually holds ------------------------------------ + -- Render the live menu into a clean 160x144 canvas and fold each icon block + -- (x 8..23) down its middle. No SGB zone pass needed: mirror symmetry is + -- geometry, and colorization maps both sides the same way. + local function snapshot() + local canvas = love.graphics.newCanvas(160, 144) + love.graphics.setCanvas(canvas) + love.graphics.clear(1, 1, 1, 1) + love.graphics.setColor(1, 1, 1, 1) + pm:draw() + love.graphics.setCanvas() + love.graphics.setColor(1, 1, 1, 1) + return canvas:newImageData() + end + + local function foldRow(id, i) + local y0 = PartyMenu.entryY(i) + local n = 0 + for dy = 0, 15 do + for dx = 0, 7 do + local r1, g1, b1 = id:getPixel(8 + dx, y0 + dy) + local r2, g2, b2 = id:getPixel(23 - dx, y0 + dy) + if math.abs(r1 - r2) > 0.01 or math.abs(g1 - g2) > 0.01 + or math.abs(b1 - b2) > 0.01 then + n = n + 1 + end + end + end + return n + end + + if getmetatable(pm) == PartyMenu and love.graphics.newCanvas then + local keepIndex, keepBlink = pm.index, pm.blink + -- everything at rest, cursor parked on the fossil so no other row animates + pm.index, pm.blink = 4, 0 + local rest = snapshot() + for i, row in ipairs(ROWS) do + local n = foldRow(rest, i) + U.log((" row %d %-10s folded: %d mismatched pixel pairs") + :format(i, row.species, n)) + if row.icon == "HELIX" then + check("the fossil icon is still asymmetric on purpose", n > 0) + else + check(row.species .. "'s icon is left-right symmetric on screen", n == 0) + end + end + + -- AnimatePartyMon runs the selected icon at 5 frames per phase while its + -- HP bar is green, so blink 5 is the alternate frame (asymmetric for BIRD) + pm.index, pm.blink = 3, 5 + local bird = snapshot() + check("SPEAROW's animated frame is symmetric too", foldRow(bird, 3) == 0) + + pm.index, pm.blink = keepIndex, keepBlink + end + + -- ---- screenshots ------------------------------------------------------- + pm.index = 1 + U.wait(4) + if U.shot(game, DIR .. "/bug276_party_rest.png") then + U.log("captured", DIR .. "/bug276_party_rest.png") + end + -- put the cursor on SPEAROW so the capture catches the animated bird + U.tap(game, "down") + U.wait(6) + U.tap(game, "down") + U.wait(20) + if U.shot(game, DIR .. "/bug276_party_bird_selected.png") then + U.log("captured", DIR .. "/bug276_party_bird_selected.png") + end + + -- ---- hand off ---------------------------------------------------------- + U.log("Party list is open, cursor on SPEAROW: CHARMANDER, PIKACHU, SPEAROW,") + U.log("OMANYTE, WEEDLE, RATTATA. Fold any icon but row 4 down its middle and") + U.log("the halves should match, at rest and while the selected one bounces") + U.log("(#276). Row 4, the fossil, is the deliberate exception.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/pewter_guide_exit_bug241_test.lua b/tests/drivers/pewter_guide_exit_bug241_test.lua new file mode 100644 index 00000000..e1024d73 --- /dev/null +++ b/tests/drivers/pewter_guide_exit_bug241_test.lua @@ -0,0 +1,224 @@ +-- Driver: the Pewter gym guide leaves without walking through you (#241). +-- scripts/PewterCity.asm:133 snaps him to map 16/22 (cell 12,18 after the +4 +-- border offset) and runs MovementData_PewterGymGuyExit, five NPC_MOVEMENT_RIGHT, +-- then hides him and puts him back on his object_event spawn. The port retraced +-- the 41-step escort route instead, first step LEFT into the player's own cell. +-- POKEPORT_DRIVER=tests/drivers/pewter_guide_exit_bug241_test.lua 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 Flags = require("src.script.Flags") + local TextBox = require("src.render.TextBox") + local mapScripts = require("data.scripts.init") + + local MAP = "PEWTER_CITY" + local NPC = "PEWTERCITY_YOUNGSTER" + local TEXT = "TEXT_PEWTERCITY_YOUNGSTER" + -- engine/events/pewter_guys.asm PewterGymGuyCoords: (35,17) is the plain + -- east-exit trigger, the one with no head-start pause. + local TRIG = { x = 35, y = 17 } + local PARK = { x = 11, y = 18 } -- where the escort leaves the player + local DROP = { x = 12, y = 18 } -- where it leaves the guide + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- ---- state the escort needs -------------------------------------------- + -- Brock beaten permanently HideObject's him (PewterGym.asm .gymVictory), + -- so a stale save would show an empty street and read as a broken script. + Flags.clear(game.save, "EVENT_BEAT_BROCK") + game.save.objectToggles = game.save.objectToggles or {} + game.save.objectToggles[MAP] = nil + game.save.player = game.save.player or {} + game.save.player.name = game.save.player.name or "RED" + + -- ---- preconditions the eye cannot check -------------------------------- + local t = game.data.text + for _, key in ipairs({ "_PewterCityYoungsterYoureATrainerFollowMeText", + "_PewterCityYoungsterGoTakeOnBrockText" }) do + check(key .. " resolves to a string", + type(t[key]) == "string" and t[key] ~= "") + end + + local script = mapScripts.get(MAP) + local escort = script and script.escort + check("PEWTER_CITY exposes the escort tables", escort ~= nil) + check(("PewterGymGuyCoords has a plan for the trigger tile (%d,%d)") + :format(TRIG.x, TRIG.y), + escort ~= nil and escort.playerPlan(TRIG.x, TRIG.y) ~= nil) + + local spawn + for _, o in ipairs(game.data.maps[MAP].objects or {}) do + if o.name == NPC then spawn = o end + end + check(NPC .. " has an object_event", spawn ~= nil) + check("it spawns on (35,16) as in data/maps/objects/PewterCity.asm", + spawn ~= nil and spawn.x == 35 and spawn.y == 16) + + -- ---- dry-run the escort and the exit before anything is on screen ------ + -- Same shape as the parity test: the whole callback chain runs inside one + -- call because the stubbed TextBox fires its continuation immediately. + local talk = mapScripts.talkScript(MAP, TEXT) + check("the youngster's talk handler is the escort", type(talk) == "function") + local Music = require("src.core.Music") + local realPlay, realPlayMap = Music.play, Music.playMap + local realNew = TextBox.new + local D = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } + local moves, dryGuy, dryPlayer + if type(talk) == "function" then + Music.play, Music.playMap = function() end, function() end + TextBox.new = function(_, s, done) return { text = s, done = done } end + local ok, err = pcall(function() + moves = {} + dryGuy = { cellX = spawn.x, cellY = spawn.y, facing = "down", moving = false } + dryPlayer = { cellX = TRIG.x, cellY = TRIG.y, facing = "left" } + local mockGame = { + data = { text = {} }, + save = { flags = {} }, + stack = { push = function(_, box) if box.done then box.done() end end }, + } + local mockOw = { + scriptMoves = {}, + runner = { isRunning = function() return false end }, + player = dryPlayer, + npcByIndex = function(_, i) return (i == 5) and dryGuy or nil end, + scriptMove = function(_, ent, dir, tiles, onDone) + local v = D[dir] + for _ = 1, (tiles or 1) do + ent.cellX, ent.cellY = ent.cellX + v[1], ent.cellY + v[2] + end + ent.facing = dir + moves[#moves + 1] = { who = (ent == dryGuy) and "guy" or "player", dir = dir } + if onDone then onDone() end + end, + } + talk(mockGame, mockOw, dryGuy, nil) + end) + Music.play, Music.playMap = realPlay, realPlayMap + TextBox.new = realNew + check("the dry run completed", ok) + if not ok then U.log("dry run error:", tostring(err)) end + end + + if moves and dryGuy and dryPlayer then + local lastPlayer = 0 + for i, m in ipairs(moves) do if m.who == "player" then lastPlayer = i end end + local exit = {} + for i = lastPlayer + 1, #moves do exit[#exit + 1] = moves[i].dir end + U.log("planned exit:", table.concat(exit, ", "), + ("then snap to (%d,%d) facing %s") + :format(dryGuy.cellX, dryGuy.cellY, tostring(dryGuy.facing))) + check(("the escort parks the player on (%d,%d)"):format(PARK.x, PARK.y), + dryPlayer.cellX == PARK.x and dryPlayer.cellY == PARK.y) + check("MovementData_PewterGymGuyExit is five steps", #exit == 5) + local allRight = #exit == 5 + for _, d in ipairs(exit) do if d ~= "right" then allRight = false end end + check("every exit step is RIGHT, away from the player", allRight) + check("the guide is snapped back to his spawn (35,16) facing down", + dryGuy.cellX == spawn.x and dryGuy.cellY == spawn.y + and dryGuy.facing == "down") + -- a stale target reserves the vacated cell forever in + -- OverworldState:npcAtCell (OverworldController.lua:1451) + check("the snap clears the move target so (17,18) is not reserved", + dryGuy.targetX == nil and dryGuy.targetY == nil + and dryGuy.moving == false) + end + + -- ---- park below the trigger tile --------------------------------------- + -- (35,18) is open road under the trigger and the youngster is on (35,16), so + -- stepping up onto (35,17) puts the player between them and fires the script. + U.teleport(game, MAP, TRIG.x, TRIG.y + 1, "up") + U.wait(10) + local ow = game.overworld + + for _, cell in ipairs({ { PARK.x, PARK.y }, { DROP.x, DROP.y }, + { 13, 18 }, { 14, 18 }, { 15, 18 }, + { 16, 18 }, { 17, 18 } }) do + check(("PEWTER_CITY (%d,%d) is walkable gym road"):format(cell[1], cell[2]), + ow.map:isWalkableCell(cell[1], cell[2])) + end + check("PEWTER_CITY (18,18) is the fence that ends the road", + not ow.map:isWalkableCell(18, 18)) + check("PEWTER_CITY (17,17) is wall, so (17,18) is a dead-end pocket", + not ow.map:isWalkableCell(17, 17)) + + local function guideNpc() + for _, n in ipairs(game.overworld.npcs or {}) do + if n.def and n.def.name == NPC then return n end + end + end + check("the youngster is on the map", guideNpc() ~= nil) + if not ow.map:isWalkableCell(TRIG.x, TRIG.y + 1) then + -- a map edit moved the road: degrade to any free neighbour of the + -- trigger tile rather than leaving the player facing a wall + for _, s in ipairs({ { 0, 1, "up" }, { 1, 0, "left" }, { -1, 0, "right" } }) do + local cx, cy = TRIG.x + s[1], TRIG.y + s[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log("approach cell blocked, standing on", cx, cy, "facing", s[3]) + U.teleport(game, MAP, cx, cy, s[3]) + U.wait(10) + ow = game.overworld + break + end + end + end + if U.shot(game, DIR .. "/bug241_0_before.png") then + U.log("captured", DIR .. "/bug241_0_before.png") + end + + -- ---- step onto the trigger and ride out the escort ---------------------- + -- About 41 lockstep tiles. A is only pressed while a TextBox is up, so a + -- stray press can never re-arm the escort by talking to him again. + U.hold(game, "up", 24) + + local function isBox() + return getmetatable(game.stack:top()) == TextBox + end + local function boxText() + local top = game.stack:top() + if getmetatable(top) ~= TextBox then return "" end + local parts = {} + for _, page in ipairs(top.pages or {}) do + if type(page) == "table" then + for _, line in ipairs(page) do parts[#parts + 1] = tostring(line) end + end + end + return table.concat(parts, " ") + end + + local arrived = false + for _ = 1, 1200 do + local parked = ow.player.cellX == PARK.x and ow.player.cellY == PARK.y + if parked and isBox() and #ow.scriptMoves == 0 then arrived = true break end + if isBox() and #ow.scriptMoves == 0 then U.tap(game, "a") end + U.wait(2) + end + check("the escort walked and the last box is up", arrived) + U.log(("player is on (%d,%d)"):format(ow.player.cellX, ow.player.cellY)) + local guide = guideNpc() + if guide then + U.log(("guide is on (%d,%d) facing %s") + :format(guide.cellX, guide.cellY, tostring(guide.facing))) + end + check(("the escort parked the player on (%d,%d)"):format(PARK.x, PARK.y), + ow.player.cellX == PARK.x and ow.player.cellY == PARK.y) + check(("the guide is standing beside him on (%d,%d)"):format(DROP.x, DROP.y), + guide ~= nil and guide.cellX == DROP.x and guide.cellY == DROP.y) + U.log("box reads:", (boxText():gsub("%s+", " "))) + U.wait(20) + if U.shot(game, DIR .. "/bug241_1_atgym.png") then + U.log("captured", DIR .. "/bug241_1_atgym.png") + end + + -- ---- hand off, then stay out of the way -------------------------------- + U.log("The youngster has walked you to the PEWTER GYM door and is on your right.") + U.log("Press A to close the box and watch him go: five steps RIGHT, away from") + U.log("you, then he blinks out and turns up again in the north-east of town") + U.log("facing down. He must never step onto your cell (#241).") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/pokedex_nidoran_bug285_test.lua b/tests/drivers/pokedex_nidoran_bug285_test.lua index 2dbeeed8..09d25fdd 100644 --- a/tests/drivers/pokedex_nidoran_bug285_test.lua +++ b/tests/drivers/pokedex_nidoran_bug285_test.lua @@ -1,23 +1,8 @@ --- Driver: Pokedex owned-ball alignment for NIDORAN (#285). A manual eye --- check, not a pass/fail run. --- --- The list draws each owned entry's pokeball marker one blank glyph after --- the name. ListMenu measured that gap with `#item.label`, the Lua string's --- BYTE length, but the charmap's gender symbols are multi-byte UTF-8: the --- eight glyphs of "NIDORAN" occupy ten bytes, so those two rows put --- their ball 16px further right than every other species. --- --- src/render/Font.lua already says this out loud in Font.width's own --- docstring ("callers that right-align with `#text * 8` mis-place them"), --- which is exactly the call this fix switches to. --- --- This one has a built-in control case: NIDORINA, NIDORINO and NIDOKING sit --- immediately around the two Nidoran rows in dex order and carry no gender --- symbol, so a correct fix leaves all five balls in one vertical column. --- --- Do NOT run this under POKEPORT_SPEED: fast-forward scales only the logic --- clock, so a sped-up run can capture a half-drawn frame. --- +-- Driver: Pokedex owned-ball alignment for NIDORAN (#285). Eye check. +-- The ball goes one blank glyph past the name, but ListMenu measured that +-- gap with `#item.label` (bytes) and the charmap's gender symbols are +-- multi-byte UTF-8, so those two rows sat 16px right. src/render/Font.lua +-- warns about `#text * 8`. No POKEPORT_SPEED, rendering is real-time. -- POKEPORT_DRIVER=tests/drivers/pokedex_nidoran_bug285_test.lua POKEPORT_IDENTITY=bug285 love . return function(game) local U = dofile("tests/drivers/util.lua") @@ -30,10 +15,8 @@ return function(game) return ok end - -- ---- preconditions the eye cannot separate from the bug ---------------- - -- A dex with nothing owned shows no balls at all, which looks the same as - -- balls in the wrong place. Seed the neighbourhood and prove the premise. - + -- an empty dex shows no balls at all, which looks the same as balls in + -- the wrong place; the gender-free neighbours are the control column local dexRow = { "NIDORAN_F", "NIDORINA", "NIDOQUEEN", "NIDORAN_M", "NIDORINO", "NIDOKING" } @@ -49,8 +32,7 @@ return function(game) check("owned: " .. id, game.save.pokedex.owned[id] == true) end - -- the premise itself: the gender names really are multi-byte, so byte - -- length and glyph width really do disagree for exactly these two rows + -- byte length and glyph width disagree for exactly these two rows local male = game.data.pokemon.NIDORAN_M.name local plain = game.data.pokemon.NIDORINO.name U.log("NIDORAN male name:", male, "bytes:", #male, "glyph width:", Font.width(male)) @@ -67,11 +49,8 @@ return function(game) Screens.push(game, "PokedexMenu") U.wait(30) - -- Walk down far enough that the whole Nidoran block is on screen at once. - -- The list shows seven rows with the cursor on the last, so stopping at - -- dex 29 would leave NIDORAN female alone above six empty rows and there - -- would be nothing to compare her ball against. Dex 35 puts 029-035 in - -- view: both Nidoran rows plus four gender-free neighbours. + -- the list shows seven rows with the cursor on the last, so stop at dex + -- 35: that puts 029-035 in view, both Nidoran rows plus their neighbours for _ = 1, 34 do U.tap(game, "down") U.wait(2) @@ -79,22 +58,10 @@ return function(game) U.wait(20) U.shot(game, "bug285_pokedex_nidoran.png") - U.log("........................................................") - U.log("LOOK NOW: the Pokedex list is parked on the NIDORAN block.") - U.log(" Screenshot: bug285_pokedex_nidoran.png in the LOVE save dir.") - U.log(" RIGHT: every owned row's pokeball sits one blank space after its") - U.log(" name, so NIDORAN, NIDORINA, NIDOQUEEN, NIDORAN,") - U.log(" NIDORINO and NIDOKING all show a ragged-but-consistent gap") - U.log(" of exactly one space.") - U.log(" BUG #285 looks like: the two NIDORAN rows alone kicking their") - U.log(" ball two extra characters to the right, out of line with") - U.log(" the four neighbours that have no gender symbol.") - U.log(" ALSO WRONG: every ball moving together (that would mean the base") - U.log(" offset changed, not the measurement), or the ball landing") - U.log(" on top of the last letter with no gap at all.") - U.log("Compare against the screenshot in issue #285. Input is yours from") - U.log("here: up/down scrolls, and any other owned species is a control.") - U.log("........................................................") + U.log("Pokedex is parked on the NIDORAN block; the shot is") + U.log("bug285_pokedex_nidoran.png in the LOVE save dir. Every ball should") + U.log("sit exactly one space after its name, so the two NIDORAN rows line") + U.log("up with their gender-free neighbours (#285 kicked them two right).") while true do coroutine.yield() diff --git a/tests/drivers/rock_tunnel_dark_bug322_test.lua b/tests/drivers/rock_tunnel_dark_bug322_test.lua new file mode 100644 index 00000000..a520af29 --- /dev/null +++ b/tests/drivers/rock_tunnel_dark_bug322_test.lua @@ -0,0 +1,369 @@ +-- Driver: a dark cave shifts the WHOLE screen's BG palette; it never cuts a +-- window of light around the player (#322). home/overworld.asm:498-501 sets +-- wMapPalOffset to 6 and home/fade.asm:4-20,66 makes that FadePal2 (BGP $FE) +-- for the whole screen. No POKEPORT_SPEED on this run. +-- POKEPORT_DRIVER=tests/drivers/rock_tunnel_dark_bug322_test.lua \ +-- POKEPORT_IDENTITY=bug322 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Probe = dofile("tests/drivers/shot_probe.lua") + local PaletteFX = require("src.render.PaletteFX") + local Screens = require("src.ui.Screens") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local fails = 0 + local function check(ok, msg) + U.log(ok and "PASS" or "FAIL", msg) + if not ok then fails = fails + 1 end + return ok + end + local function rgb(c) + return c and ("(%d,%d,%d)"):format(c[1], c[2], c[3]) or "nil" + end + local function ramp(p) + if not p then return "nil" end + local s = {} + for i = 1, 4 do s[i] = rgb(p[i]) end + return table.concat(s, " ") + end + local function samePal(a, b) + if not a or not b then return false end + for i = 1, 4 do + if not a[i] or not b[i] then return false end + for k = 1, 3 do if a[i][k] ~= b[i][k] then return false end end + end + return true + end + + local Pokemon = require("src.pokemon.Pokemon") + game.save.flags.EVENT_GOT_STARTER = true + if #game.save.party == 0 then + table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 20)) + end + game.save.options = game.save.options or {} + game.save.options.textSpeed = 1 + game.save.options.colors = "gbc" + PaletteFX.setMode("gbc") + + -- ===================================================================== + -- Part 1: the arithmetic. A darkening one shade off still looks plausible + -- on screen. + -- ===================================================================== + local darkDef = game.data.field.darkMaps + check(darkDef ~= nil, "field.darkMaps exists (extracted from the ROM)") + local listed = false + for _, m in ipairs(darkDef and darkDef.maps or {}) do + if m == "ROCK_TUNNEL_1F" then listed = true end + end + check(listed, "ROCK_TUNNEL_1F is in field.darkMaps.maps") + U.log("darkMaps.entryMap =", tostring(darkDef and darkDef.entryMap), + " flashBadge =", tostring(darkDef and darkDef.flashBadge)) + + local BGP = PaletteFX.DARK_BGP + check(BGP ~= nil and BGP[0] == 2 and BGP[1] == 3 and BGP[2] == 3 + and BGP[3] == 3, + "DARK_BGP is FadePal2's `dc 3,3,3,2` (shade 0 -> 2, 1/2/3 -> 3)") + + local CAVE = PaletteFX.pal(game.data, "CAVE") + U.log("CAVE palette:", ramp(CAVE)) + local caveDark = PaletteFX.permute(CAVE, BGP) + U.log("CAVE darkened:", ramp(caveDark)) + check(samePal(caveDark, { CAVE[3], CAVE[4], CAVE[4], CAVE[4] }), + "SGB's CAVE becomes light-teal paper over near-black") + + local ogDark = PaletteFX.permute(PaletteFX.GBC_BG, BGP) + U.log("OG RED darkened:", ramp(ogDark)) + check(ogDark[1][1] == 148 and ogDark[1][2] == 58 and ogDark[1][3] == 58 + and ogDark[2][1] == 0 and ogDark[3][1] == 0 and ogDark[4][1] == 0, + "OG RED reduces to exactly (148,58,58) and (0,0,0) -- the issue's image") + + -- The shade map has to compose AFTER the mono/inverted replacement, or the + -- modes that throw the incoming palette away throw the darkness out with it. + PaletteFX.setShadeMap(BGP) + PaletteFX.setMode("og") + local dmgDark = PaletteFX.effectiveColors(CAVE) + U.log("plain DMG darkened:", ramp(dmgDark)) + check(dmgDark[1][1] == 85 and dmgDark[2][1] == 0, + "plain DMG darkens too (grey 85 over black), not silently skipped") + PaletteFX.setMode("classic") + local classicDark = PaletteFX.effectiveColors(CAVE) + U.log("CLASSIC darkened:", ramp(classicDark)) + check(samePal(classicDark, { PaletteFX.CLASSIC[3], PaletteFX.CLASSIC[4], + PaletteFX.CLASSIC[4], PaletteFX.CLASSIC[4] }), + "CLASSIC darkens inside its own pea-green ramp") + PaletteFX.setShadeMap(nil) + PaletteFX.setMode("gbc") + -- tests/mod_graphics_tests.lua compares with ==, so the unarmed path must + -- hand back the very table it was given, not a copy + check(PaletteFX.effectiveColors(CAVE) == CAVE, + "with nothing armed, effectiveColors still returns its input by identity") + + -- ===================================================================== + -- Part 2: reach the moment. + -- ===================================================================== + -- data/maps/objects/RockTunnel1F.asm: the Route 10 entrance is warp_event + -- 15, 3, so two cells south of it is floor without re-triggering the warp. + local MAP, STAND = "ROCK_TUNNEL_1F", { x = 15, y = 5, facing = "down" } + + local function enter(flashLit) + game.save.flashLit = flashLit or nil + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(12) + local ow = game.overworld + -- if a map edit or a mod took that cell away, fall back to the nearest + -- walkable cell that is not itself a warp + if ow and ow.map and not ow.map:isWalkableCell(STAND.x, STAND.y) then + local found + for r = 1, 8 do + for dy = -r, r do + for dx = -r, r do + local cx, cy = STAND.x + dx, STAND.y + dy + if not found and ow.map:isWalkableCell(cx, cy) + and not ow.map:warpAtCell(cx, cy) then + found = { x = cx, y = cy } + end + end + end + if found then break end + end + if found then + U.log(("(%d,%d) is not floor any more -- standing on"):format( + STAND.x, STAND.y), found.x, found.y) + STAND.x, STAND.y = found.x, found.y + game.save.flashLit = flashLit or nil + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(12) + ow = game.overworld + end + end + return ow + end + + local ow = enter(nil) + check(ow ~= nil and ow.map and ow.map.id == MAP, "player is inside " .. MAP) + check(ow ~= nil and ow.dark == true, + "the map reports itself dark with no FLASH used") + check(ow ~= nil and ow:paletteNameFor(ow.map) == "CAVE", + "and resolves the CAVE palette (tileset CAVERN)") + check(ow ~= nil and ow:darkNeedsOverlay() == false, + "SGB needs NO composited veil -- its palette carries the darkening") + + local function probe(label, wanted, rect) + local shot = Probe.grab() + if not shot then + U.log("WARN pixel probe unavailable; judge", label, "by eye only") + return nil, nil + end + local counts, total = Probe.count(shot, wanted, 3, rect) + local parts = {} + for name, n in pairs(counts) do + parts[#parts + 1] = ("%s=%.1f%%"):format(name, total > 0 and n * 100 / total or 0) + end + table.sort(parts) + U.log(("probe[%s] %d px: %s"):format(label, total, table.concat(parts, " "))) + return counts, total, shot + end + + -- A palette shift darkens the corner and leaves it legible; a light window + -- around the player leaves it black. This rect is well outside any window + -- centred on a centred player. + local FAR_CORNER = { 0.02, 0.02, 0.28, 0.28 } + + -- ---- SGB, dark --------------------------------------------------------- + U.wait(30) + U.shot(game, DIR .. "/bug322_1_dark_sgb.png") + local c = probe("SGB dark / far corner", { + darkPaper = caveDark[1], darkInk = caveDark[2], + litPaper = CAVE[1], litMid = CAVE[2], + }, FAR_CORNER) + if c then + check(c.darkPaper > 0, + "the FAR CORNER of the screen is drawn, not blacked out (no light window)") + check(c.litPaper == 0 and c.litMid == 0, + "and it is darkened -- none of CAVE's undimmed colours survive there") + end + local full = probe("SGB dark / whole frame", { + darkPaper = caveDark[1], darkInk = caveDark[2], litPaper = CAVE[1], + }) + if full then + check(full.litPaper == 0, + "nowhere on the screen keeps CAVE's paper white -- the shift is global") + end + + -- Renderer:beginFrame clears the shade map and OverworldState:drawWorld + -- re-arms it, so what is armed right now is what the LAST drawn frame used. + check(PaletteFX.shadeMap() == PaletteFX.DARK_BGP, + "the last drawn frame really did run with DARK_BGP armed") + + -- ---- OG RED, dark: the reporter's reference image ---------------------- + game.save.options.colors = "ogred" + PaletteFX.setMode("ogred") + enter(nil) + U.wait(30) + U.shot(game, DIR .. "/bug322_2_dark_ogred.png") + local shot = Probe.grab() + if shot then + local top, total = Probe.top(shot, 6) + U.log(("probe[OG RED dark] %d px, top colours: %s") + :format(total, Probe.fmt(top))) + local extra = {} + for _, col in ipairs(top) do + local isDark = (col[1] == 148 and col[2] == 58 and col[3] == 58) + or (col[1] == 0 and col[2] == 0 and col[3] == 0) + if not isDark and col.share > 0.002 then + extra[#extra + 1] = ("(%d,%d,%d) %.1f%%") + :format(col[1], col[2], col[3], col.share * 100) + end + end + check(#extra == 0, + "OG RED's dark tunnel is EXACTLY (148,58,58) + (0,0,0), like the " + .. "issue's expected screenshot" + .. (#extra > 0 and (" -- extra: " .. table.concat(extra, ", ")) or "")) + local corner = Probe.count(shot, { red = { 148, 58, 58 } }, 3, FAR_CORNER) + check(corner.red > 0, "and the far corner still carries that red, not a void") + + -- Not a FAIL: OG RED replays its OBP-baked sprites on top of the finished + -- zone pass (PaletteFX.markSpriteRedraw) and that replay is unshaded, so + -- the player keeps his green where FadePal2's OBP0 would darken him too. + local obj = PaletteFX.ogObj() + local sprite, spriteTotal = Probe.count(shot, + { green = obj[2], darkGreen = obj[3] }) + local share = (sprite.green + sprite.darkGreen) * 100 + / math.max(1, spriteTotal) + U.log(("known gap: OG RED's player keeps his green in the dark " + .. "(%.2f%% of the frame); hardware darkens OBP0 too"):format(share)) + end + + -- ---- the modes that darken in their own ramps -------------------------- + for _, m in ipairs({ { "og", "plain DMG" }, { "classic", "CLASSIC" }, + { "redpp", "ADVANCED / RED++" } }) do + game.save.options.colors = m[1] + PaletteFX.setMode(m[1]) + enter(nil) + U.wait(30) + U.shot(game, DIR .. "/bug322_3_dark_" .. m[1] .. ".png") + local s = Probe.grab() + if s then + local top, total = Probe.top(s, 5) + U.log(("probe[%s dark] %d px, top colours: %s") + :format(m[2], total, Probe.fmt(top))) + -- "Not blacked out" cannot be asked as "not the colour black": the old + -- overlay drew black INTO the world canvas and the zone shader then + -- coloured it (in CLASSIC the void came out pea green). Ask for + -- structure instead: a shifted palette leaves two tones in the corner, + -- a window of light leaves one flat fill. + local corner = Probe.top(s, 3, 3, FAR_CORNER) + U.log(" far corner:", Probe.fmt(corner)) + local second = corner[2] and corner[2].share or 0 + check(second > 0.02, + m[2] .. ": the far corner still has two tones (no light window)") + end + if m[1] == "redpp" then + -- RED++ has no palette left to shift: TileRenderer bakes true colour + -- into the atlas, so the darkness has to be composited by hand. + local o = game.overworld + U.log("RED++ gbcAtlas present:", + tostring(o and o.map and o.map.renderer and o.map.renderer.gbcAtlas ~= nil)) + check(o ~= nil and o:darkNeedsOverlay() == true, + "RED++ is the ONE mode that still composites a flat veil") + end + end + + game.save.options.colors = "gbc" + PaletteFX.setMode("gbc") + + -- ---- FLASH lifts it ---------------------------------------------------- + -- PartyMenu sets save.flashLit when the move is used; entering with it set + -- is the same state (OverworldState.enterMap:305). + ow = enter(true) + check(ow ~= nil and ow.dark == false, "after FLASH the map is no longer dark") + U.wait(30) + U.shot(game, DIR .. "/bug322_4_flash_lit_sgb.png") + -- Once the shift is armed every entry comes from CAVE[3] or CAVE[4], so + -- CAVE[1] or CAVE[2] appearing proves the map is lit. caveDark[1] is + -- CAVE[3] and occurs in the lit palette too, so it is not evidence alone. + c = probe("SGB after FLASH", + { litPaper = CAVE[1], litMid = CAVE[2], darkPaper = caveDark[1] }) + if c then + check(c.litPaper > 0 and c.litMid > 0, + "FLASH restores CAVE's full brightness (paper white and brown are back)") + end + check(PaletteFX.shadeMap() == nil, "and nothing is armed any more") + + -- ---- a dialog over a dark map darkens with it -------------------------- + -- rBGP is one register for the whole screen, so a START menu over a dark + -- map darkens with it. Easy to mistake for a bug. + ow = enter(nil) + U.wait(15) + U.tap(game, "start") + U.wait(30) + U.shot(game, DIR .. "/bug322_5_startmenu_dark.png") + c = probe("START menu over the dark map", + { litPaper = CAVE[1], darkPaper = caveDark[1] }) + if c then + check(c.darkPaper > 0 and c.litPaper == 0, + "the START menu over a dark map darkens WITH it (one rBGP, as on hardware)") + end + U.tap(game, "b") + U.wait(20) + + -- ---- a full-screen menu, and a battle, come out LIT -------------------- + -- Neither draws a map, so drawWorld never runs and beginFrame's clear + -- stands: the same result as init_battle_variables.asm's wMapPalOffset + -- reset on hardware. + Screens.push(game, "PartyMenu") + U.wait(30) + U.shot(game, DIR .. "/bug322_6_partymenu_lit.png") + c = probe("PARTY menu over the dark map", + { litPaper = CAVE[1], darkPaper = caveDark[1] }) + if c then + check(c.litPaper > 0, + "an opaque full-screen menu comes out LIT (nothing inherited the shift)") + end + check(PaletteFX.shadeMap() == nil, "no shade map armed while it is up") + U.tap(game, "b") + U.wait(20) + + local BattleState = require("src.battle.BattleState") + local ok = pcall(function() + local battle = BattleState.newWild(game, "ZUBAT", 15) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + end) + if ok then + U.wait(90) -- through the transition wipe and the intro slide-in + U.shot(game, DIR .. "/bug322_7_battle_lit.png") + c = probe("wild battle inside the dark tunnel", + { litPaper = CAVE[1], darkPaper = caveDark[1] }) + if c then + check(c.litPaper > 0, + "a battle in a dark cave is LIT (init_battle_variables.asm)") + end + else + U.log("WARN could not force a wild battle; the battle-is-lit case is", + "unphotographed") + end + + U.log(fails == 0 and "all #322 preconditions passed" + or (fails .. " #322 precondition(s) FAILED -- read up")) + + -- ===================================================================== + -- Part 3: hand off, standing in the dark tunnel. + -- ===================================================================== + for _ = 1, 60 do + if game.stack:top() == game.overworld then break end + U.tap(game, "b") + U.wait(4) + end + enter(nil) + + U.log("You are in ROCK TUNNEL 1F, no FLASH used, default SGB colours.") + U.log("Walk in every direction: the whole screen's palette should be") + U.log("shifted down and still legible out to the corners, with no window") + U.log("of light following you around (#322). Shots are in " .. DIR .. ".") + U.log("The START menu darkens with the map (one rBGP), which is correct.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/route22_rival_exit_bug236_test.lua b/tests/drivers/route22_rival_exit_bug236_test.lua new file mode 100644 index 00000000..6cc86d4c --- /dev/null +++ b/tests/drivers/route22_rival_exit_bug236_test.lua @@ -0,0 +1,275 @@ +-- Driver: watch the Route 22 rival leave without clipping the cliff (#236). +-- scripts/Route22.asm keys the ambush on which coord you stepped on: (29,4) +-- parks him at (29,5) with ExitMovementData1, (29,5) at (28,5) with ...Data2. +-- POKEPORT_DRIVER=tests/drivers/route22_rival_exit_bug236_test.lua \ +-- POKEPORT_IDENTITY=bug236 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \ +-- SHOT_DIR=/tmp/shots love . (BUG236_TILE=5 runs the other tile) +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local Flags = require("src.script.Flags") + local TextBox = require("src.render.TextBox") + local mapScripts = require("data.scripts.init") + + local MAP = "ROUTE_22" + local TILE_Y = tonumber(os.getenv("BUG236_TILE") or "4") + if TILE_Y ~= 4 and TILE_Y ~= 5 then TILE_Y = 4 end + local TRIG_X = 29 + -- what pokered does on this tile + local WANT = (TILE_Y == 4) + and { rx = 29, ry = 5, rivalFacing = "up", playerFacing = "down", + -- Route22Rival1ExitMovementData1 + dirs = { "right", "right", "down", "down", "down", "down", "down" }, + endX = 31, endY = 10 } + or { rx = 28, ry = 5, rivalFacing = "right", playerFacing = "left", + -- Route22Rival1ExitMovementData2 + dirs = { "up", "right", "right", "right", + "down", "down", "down", "down", "down", "down" }, + endX = 31, endY = 10 } + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- ---- a party that ends OPP_RIVAL1 in one turn -------------------------- + -- the walk-off only exists after a win, and the fight is not under test + local tank = Pokemon.new(game.data, "MEWTWO", 100) + tank.moves = { + { id = "PSYCHIC_M", pp = 99 }, + { id = "THUNDERBOLT", pp = 99 }, + { id = "ICE_BEAM", pp = 99 }, + { id = "RECOVER", pp = 99 }, + } + game.save.party = { tank } + game.save.player = game.save.player or {} + game.save.player.name = game.save.player.name or "RED" + game.save.player.rival = game.save.player.rival or "BLUE" + game.save.defeatedTrainers = {} + game.save.objectToggles = game.save.objectToggles or {} + game.save.objectToggles[MAP] = nil + -- the window Route22DefaultScript arms: Pokedex in hand, Brock not yet + -- beaten, this rival not yet fought + Flags.set(game.save, "EVENT_CHOSE_SQUIRTLE") + Flags.set(game.save, "EVENT_GOT_POKEDEX") + Flags.clear(game.save, "EVENT_BEAT_BROCK") + Flags.clear(game.save, "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE") + Flags.clear(game.save, "EVENT_BEAT_GIOVANNI") + Flags.clear(game.save, "EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE") + + -- ---- park one cell west of the trigger --------------------------------- + -- rows 4 and 5 are open path from x=24 to x=33; row 3 above them is the + -- cliff the buggy exit walked into + U.teleport(game, MAP, TRIG_X - 1, TILE_Y, "right") + U.wait(10) + local ow = game.overworld + + -- ---- preconditions ----------------------------------------------------- + -- a missing text entry, a moved object and an onStep that never fires all + -- look like a movement bug on screen: nothing happens + local t = game.data.text + for _, key in ipairs({ "_Route22RivalBeforeBattleText1", + "_Route22Rival1DefeatedText", + "_Route22RivalAfterBattleText1" }) do + check(key .. " resolves to a string", + type(t[key]) == "string" and t[key] ~= "") + end + + local spawn + for _, o in ipairs(game.data.maps[MAP].objects or {}) do + if o.name == "ROUTE22_RIVAL1" then spawn = o end + end + check("ROUTE22_RIVAL1 has an object_event", spawn ~= nil) + check("it spawns on (25,5) as in data/maps/objects/Route22.asm", + spawn ~= nil and spawn.x == 25 and spawn.y == 5) + check("ROUTE_22 (28,3) is solid cliff (the cell #236 walked into)", + not ow.map:isWalkableCell(28, 3)) + + -- Dry-run the scene on a throwaway overworld so the exit list can be walked + -- against real collision before anything happens on screen. Music.play is + -- silenced or the rival sting fires the scene early. + local Music = require("src.core.Music") + local realPlay = Music.play + Music.play = function() end + local rows, probeFacing + do + local probe = { + runner = { isRunning = function() return false end, + run = function(_, r) rows = r end }, + player = { facing = "down" }, + npcByIndex = function() return { def = { name = "X" } } end, + } + local script = mapScripts.get(MAP) + check("ROUTE_22 has an onStep hook", script ~= nil and script.onStep ~= nil) + if script and script.onStep then + check(("onStep fires on the ambush tile (%d,%d)"):format(TRIG_X, TILE_Y), + script.onStep(game, probe, TRIG_X, TILE_Y) == true) + end + probeFacing = probe.player.facing + end + Music.play = realPlay + + local moveTo, face, walk + for _, r in ipairs(rows or {}) do + if r[1] == "move_npc_to" then moveTo = r end + if r[1] == "face_object" then face = r end + if r[1] == "walk_npc" then walk = r end + end + check("the scene carries move/face/walk rows", + moveTo ~= nil and face ~= nil and walk ~= nil) + if moveTo and face and walk then + U.log(("plan: rival to (%d,%d) facing %s, player turned %s, exit %s") + :format(moveTo[3], moveTo[4], tostring(face[3]), + tostring(probeFacing), table.concat(walk[3], ", "))) + check(("rival parks on (%d,%d), where Route22MoveRivalRightScript leaves him") + :format(WANT.rx, WANT.ry), + moveTo[3] == WANT.rx and moveTo[4] == WANT.ry) + check("rival faces " .. WANT.rivalFacing .. " at the player", + face[3] == WANT.rivalFacing) + check("player is turned " .. WANT.playerFacing .. " at the rival", + probeFacing == WANT.playerFacing) + local same = #walk[3] == #WANT.dirs + for i = 1, #WANT.dirs do + if walk[3][i] ~= WANT.dirs[i] then same = false end + end + check("exit list is " .. table.concat(WANT.dirs, ", "), same) + -- replay it cell by cell against the real map: this is the assertion the + -- cliff clip failed + local D = { up = { 0, -1 }, down = { 0, 1 }, + left = { -1, 0 }, right = { 1, 0 } } + local x, y, clean = moveTo[3], moveTo[4], true + for i, d in ipairs(walk[3]) do + x, y = x + D[d][1], y + D[d][2] + if not ow.map:isWalkableCell(x, y) then + clean = false + U.log((" exit step %d (%s) walks into solid ground at (%d,%d)") + :format(i, d, x, y)) + end + if x == TRIG_X and y == TILE_Y then + clean = false + U.log((" exit step %d (%s) walks through the player on (%d,%d)") + :format(i, d, x, y)) + end + end + check("every exit cell is walkable and none is the player's", clean) + check(("the exit ends on (%d,%d), off the bottom of the screen") + :format(WANT.endX, WANT.endY), + x == WANT.endX and y == WANT.endY) + end + + if U.shot(game, DIR .. "/bug236_0_before.png") then + U.log("captured", DIR .. "/bug236_0_before.png") + end + + -- ---- step onto the ambush tile ----------------------------------------- + U.hold(game, "right", 24) + + local function boxText() + local top = game.stack:top() + if getmetatable(top) ~= TextBox then return "" end + local parts = {} + for _, page in ipairs(top.pages or {}) do + if type(page) == "table" then + for _, line in ipairs(page) do parts[#parts + 1] = tostring(line) end + end + end + return table.concat(parts, " ") + end + + local function rivalNpc() + for _, n in ipairs(game.overworld.npcs or {}) do + if n.def and n.def.name and n.def.name:find("RIVAL") then return n end + end + end + + -- wait for the approach walk to finish: he is on his mark when the script + -- moves drain and the pre-battle box is up + local parked + for _ = 1, 400 do + local r = rivalNpc() + if r and #ow.scriptMoves == 0 and not r.moving and boxText() ~= "" then + parked = r + break + end + U.wait(2) + end + check("the rival showed up and stopped walking", parked ~= nil) + if parked then + U.log(("rival parked on (%d,%d) facing %s; player on (%d,%d) facing %s") + :format(parked.cellX, parked.cellY, tostring(parked.facing), + ow.player.cellX, ow.player.cellY, tostring(ow.player.facing))) + check(("he is on (%d,%d) live, not just on paper"):format(WANT.rx, WANT.ry), + parked.cellX == WANT.rx and parked.cellY == WANT.ry) + check("he is not standing on top of the player", + not (parked.cellX == ow.player.cellX and parked.cellY == ow.player.cellY)) + end + if U.shot(game, DIR .. "/bug236_1_ambush.png") then + U.log("captured", DIR .. "/bug236_1_ambush.png") + end + + -- ---- fight it for the human -------------------------------------------- + -- FIGHT + first move every prompt; PSYCHIC one-shots the level 5-9 party. + local sawBattle = false + for f = 1, 4000 do + local top = game.stack:top() + if top and top.phase then + sawBattle = true + if top.phase == "menu" then top.menuIndex = 1 + elseif top.phase == "moveSelect" then top.moveIndex = 1 end + U.tap(game, "a") + if f > 2400 and top.onFinish then + -- safety valve: never leave the run wedged in a stalled battle + U.log("force-finishing a stalled battle") + top.onFinish("win") + if game.stack:top() == top then game.stack:pop() end + end + elseif sawBattle then + break + elseif top ~= ow then + U.tap(game, "a") -- pre-battle dialogue + end + U.wait(2) + end + check("the battle ran and ended", sawBattle) + check("EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE is set", + game.save.flags.EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE == true) + + -- ---- stop on the LAST post-battle box ---------------------------------- + -- Rows 8 and 9 are DefeatedText then AfterBattleText; the walk (row 10) + -- starts when row 9's box closes, so hand over with row 9 on screen. + local after = t._Route22RivalAfterBattleText1 or "" + local needle = after:match("dawdling") and "dawdling" or "LEAGUE" + local onLastBox = false + for _ = 1, 600 do + if boxText():find(needle, 1, true) then onLastBox = true break end + if game.stack:top() ~= ow then U.tap(game, "a") end + U.wait(3) + end + check("the last post-battle box is on screen", onLastBox) + U.wait(20) + if U.shot(game, DIR .. "/bug236_2_lastbox.png") then + U.log("captured", DIR .. "/bug236_2_lastbox.png") + end + + -- ---- hand off ---------------------------------------------------------- + local r = rivalNpc() + U.log(("BLUE is beaten on the (%d,%d) ambush tile and the box on screen is") + :format(TRIG_X, TILE_Y)) + U.log("his last line. Press A and watch him leave: that walk is #236. He") + U.log("must not clip the cliff at the top, step on your cell, or vanish") + U.log("before he is off the bottom of the screen.") + if r then + U.log(("He is standing on (%d,%d); you are on (%d,%d)."):format( + r.cellX, r.cellY, ow.player.cellX, ow.player.cellY)) + end + if TILE_Y == 4 then + U.log("Expect two steps right, then five straight down.") + else + U.log("Expect one step up around you, three right, then six down.") + end + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/route4_downsweep_bug223.lua b/tests/drivers/route4_downsweep_bug223.lua index 738cbed1..0dac284f 100644 --- a/tests/drivers/route4_downsweep_bug223.lua +++ b/tests/drivers/route4_downsweep_bug223.lua @@ -10,13 +10,18 @@ -- directly below, teleport the player onto it facing DOWN, hold DOWN, and -- assert the Gen1 hop fires (p.hopFrames>0) and lands two cells south. -- --- Finding: all 139 reachable/functional south ledges hop. The only two that --- do not -- (12,16) and (13,16) -- sit on the SOUTH boundary of the Mt Moon --- Poke Center plaza, where the cell two south is off the map onto the border --- mountain (tile 17); there is no landing, so the hop is correctly refused --- (checkLedgeHop's landing-walkable gate). These are NOT the reporter's spot --- (an open EAST plateau, cells ~62-80) and refusing a hop into the map border --- is correct, so they are treated as EXPECTED refusals here. +-- Finding: all 139 reachable/functional south ledges hop with one exception: +-- (12,16) and (13,16), the SOUTH edge of the Mt Moon Poke Center plaza. The +-- earlier read of those two as "correctly refused, the landing is border +-- mountain" was WRONG. ROUTE_4 has a south connection to ROUTE_3 (offset -25, +-- destX = curX + 50), so the cell two south is ROUTE_3 (62,0)/(63,0), the +-- walkable $39/$23 top of the ramp; pokered hops straight onto it, because +-- HandleLedges never checks the landing at all. THAT is the reporter's +-- "bottom-most cliff on the right side" (the two round Route 3 boulders in +-- the screenshot sit just below-left of it), and it is issue #223. +-- Post-fix the hop crosses the seam, so the player ends on ROUTE_3 rather +-- than at (cx, cy+2); this sweep's same-map landing assertion cannot express +-- that, so the two cells stay listed below and are reported, not failed. -- -- Run: -- POKEPORT_DRIVER=tests/drivers/route4_downsweep_bug223.lua \ @@ -64,9 +69,11 @@ return function(game) return p.cellX, p.cellY, hop end - -- (12,16)/(13,16): south ledges on the plaza's map-border edge; the cell two - -- south is off-map (border mountain), so the refusal is correct, not a bug. - local expectedRefusal = { ["12,16"] = true, ["13,16"] = true } + -- (12,16)/(13,16): the #223 seam ledges. Their landing is on ROUTE_3, so + -- the same-map "(cx, cy+2)" assertion below cannot judge them either way; + -- they are reported separately rather than failed. Verify them with a + -- driver that watches for a map change to ROUTE_3. + local seamLedge = { ["12,16"] = true, ["13,16"] = true } local standers = {} for cy = 0, H - 2 do @@ -87,9 +94,9 @@ return function(game) local ex, ey, hop = holdDown(cx, cy, 44) local ok = hop and ex == cx and ey == cy + 2 if not ok then - if expectedRefusal[cx .. "," .. cy] then + if seamLedge[cx .. "," .. cy] then refusals = refusals + 1 - U.log((" refused (expected, map-border edge) (%d,%d) -> (%d,%d) hop=%s") + U.log((" seam ledge onto ROUTE_3 (%d,%d) -> (%d,%d) hop=%s") :format(cx, cy, ex, ey, tostring(hop))) else fails = fails + 1 @@ -97,7 +104,7 @@ return function(game) end end end - U.log(("#223 sweep DONE: %d hopped, %d expected border-refusals, %d unexpected FAILS") + U.log(("#223 sweep DONE: %d hopped, %d seam ledges (ROUTE_3 landing), %d unexpected FAILS") :format(#standers - fails - refusals, refusals, fails)) if fails > 0 then error(fails .. " unexpected south-ledge DOWN-hop failure(s)") end end diff --git a/tests/drivers/sgb_people_palette_bug301_test.lua b/tests/drivers/sgb_people_palette_bug301_test.lua new file mode 100644 index 00000000..44ec53d1 --- /dev/null +++ b/tests/drivers/sgb_people_palette_bug301_test.lua @@ -0,0 +1,317 @@ +-- Driver: in SGB mode a character wears the palette of the map it stands on, +-- not the GBC boot ROM's object palette (#301). pokered never sends OBJ_TRN +-- (data/sgb/sgb_packets.asm), and home/fade.asm:68 FadePal4 leaves rOBP0 = $D0, +-- so OBJ colours lift to DMG shades 0/1/3 and the zone shader owns the colour. +-- POKEPORT_DRIVER=tests/drivers/sgb_people_palette_bug301_test.lua \ +-- POKEPORT_IDENTITY=bug301 POKEPORT_VERSION=red SHOT_DIR=/tmp/shots love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Probe = dofile("tests/drivers/shot_probe.lua") + local PaletteFX = require("src.render.PaletteFX") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local fails = 0 + local function check(ok, msg) + U.log(ok and "PASS" or "FAIL", msg) + if not ok then fails = fails + 1 end + return ok + end + local function rgb(c) + return c and ("(%d,%d,%d)"):format(c[1], c[2], c[3]) or "nil" + end + local function ramp(p) + if not p then return "nil" end + local s = {} + for i = 1, 4 do s[i] = rgb(p[i]) end + return table.concat(s, " ") + end + + -- a party + starter flag so the overworld behaves like a real save + game.save.flags.EVENT_GOT_STARTER = true + local Pokemon = require("src.pokemon.Pokemon") + if #game.save.party == 0 then + table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 5)) + end + -- Game:applyOptions re-reads save.options.colors every frame's worth of + -- option handling, so a bare setMode() would be reverted under us. + game.save.options = game.save.options or {} + game.save.options.colors = "gbc" + PaletteFX.setMode("gbc") + + -- ---- Part 1: the render decisions no screenshot can spell out ----------- + check(PaletteFX.usesSpriteObp("gbc") == false, + "SGB bakes NO object palette (it cannot colour an OBJ apart from the BG)") + check(PaletteFX.usesSpriteObp("ogred") == true, + "OG RED still does (the GBC boot ROM really does hand out one OBJ palette)") + check(PaletteFX.usesSpriteObp("og") == false + and PaletteFX.usesSpriteObp("og_inv") == false + and PaletteFX.usesSpriteObp("gbc_inv") == false + and PaletteFX.usesSpriteObp("classic") == false + and PaletteFX.usesSpriteObp("redpp") == false, + "no other mode bakes one either") + + local obp = PaletteFX.dmgObj() + U.log("rOBP0 bake ramp:", ramp(obp)) + -- FadePal4's second entry, `dc 3,1,0,0` = $D0. Index 1 is never read (OBJ + -- colour 0 is keyed to alpha); 2..4 are OBJ colours 1..3 as shades 0/1/3. + check(obp ~= nil and obp[2][1] == 255, "OBJ colour 1 -> DMG shade 0 (255)") + check(obp ~= nil and obp[3][1] == 170, "OBJ colour 2 -> DMG shade 1 (170)") + check(obp ~= nil and obp[4][1] == 0, "OBJ colour 3 -> DMG shade 3 (0)") + local grey = true + for i = 1, 4 do + if obp[i][1] ~= obp[i][2] or obp[i][2] ~= obp[i][3] then grey = false end + end + check(grey, "the bake stays in DMG GREYS, so the zone shader still owns the colour") + + local OGOBJ = PaletteFX.ogObj() + local clash = false + for i = 1, 4 do + for j = 1, 4 do + if obp[i][1] == OGOBJ[j][1] and obp[i][2] == OGOBJ[j][2] + and obp[i][3] == OGOBJ[j][3] and OGOBJ[j][1] ~= OGOBJ[j][2] then + clash = true + end + end + end + check(not clash, "no boot-ROM object colour is baked into an SGB sprite") + + -- ---- Part 2: stand next to a person, on two maps with unlike palettes --- + local function npcNamed(ow, name) + for _, n in ipairs(ow.npcs or {}) do + if n.def and n.def.name == name then return n end + end + return nil + end + + -- Teleport to `stand`; if a map edit or a mod moved the NPC out of reach, + -- take any free walkable neighbour of where it actually is and face back. + local function standNextTo(mapId, npcName, stand) + U.teleport(game, mapId, stand.x, stand.y, stand.facing) + U.wait(12) + local ow = game.overworld + local npc = ow and npcNamed(ow, npcName) + if not npc then + check(false, npcName .. " object loaded on " .. mapId) + return ow, nil + end + local fx, fy = ow.player:facingCell() + if ow:npcAtCell(fx, fy) ~= npc then + local sides = { + { 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" }, + } + for _, s in ipairs(sides) do + local cx, cy = npc.cellX + s[1], npc.cellY + s[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log(("stand cell (%d,%d) is no longer beside %s -- using") + :format(stand.x, stand.y, npcName), cx, cy, "facing", s[3]) + U.teleport(game, mapId, cx, cy, s[3]) + U.wait(12) + ow = game.overworld + npc = npcNamed(ow, npcName) + break + end + end + end + local ax, ay = ow.player:facingCell() + check(npc ~= nil and ow:npcAtCell(ax, ay) == npc, + "player is standing face to face with " .. npcName .. " on " .. mapId) + return ow, npc + end + + -- Every colour a correct SGB frame may contain: the map's own four plus the + -- letterbox black. Before the fix the boot-ROM greens were on screen and + -- belonged to no palette the map ever asked for. + local function paletteAudit(label, palette) + local shot = Probe.grab() + if not shot then + U.log("WARN pixel probe unavailable; judge", label, "by eye only") + return + end + local top, total = Probe.top(shot, 8) + U.log(("probe[%s] %d px sampled, top colours: %s") + :format(label, total, Probe.fmt(top))) + local allowed = { { 0, 0, 0 } } + for i = 1, 4 do allowed[#allowed + 1] = palette[i] end + local strays = {} + for _, c in ipairs(top) do + local ok = false + for _, a in ipairs(allowed) do + if c[1] == a[1] and c[2] == a[2] and c[3] == a[3] then ok = true end + end + -- ignore the thin filtered edges: only a colour with real area on + -- screen is evidence of a wrong palette + if not ok and c.share > 0.002 then + strays[#strays + 1] = ("(%d,%d,%d) %.1f%%") + :format(c[1], c[2], c[3], c.share * 100) + end + end + check(#strays == 0, label + .. ": every colour with real area comes from the map's own palette" + .. (#strays > 0 and (" -- strays: " .. table.concat(strays, ", ")) or "")) + return shot + end + + local function countIn(shot, wanted) + if not shot then return nil end + local counts = Probe.count(shot, wanted) + local parts = {} + for name, n in pairs(counts) do parts[#parts + 1] = ("%s=%d"):format(name, n) end + table.sort(parts) + U.log(" ", table.concat(parts, " ")) + return counts + end + + -- OBJ colour 0 is transparent on the hardware and the bake keys it to alpha. + -- A love Image exposes no pixels, so the only way to read that back is to + -- draw it into a cleared canvas. A guard, not a gate: the extracted sheets + -- already ship a tRNS key, so this only fires if a later change hands a + -- pipeline or the tilt pass an unkeyed image and the sprite grows a backdrop. + local function transparentShare(spr) + if not (spr and spr.resolveImage and love.graphics.newCanvas) then return nil end + local ok, share = pcall(function() + local img = spr:resolveImage() + local w, h = img:getWidth(), img:getHeight() + local cv = love.graphics.newCanvas(w, h) + love.graphics.setCanvas(cv) + love.graphics.clear(0, 0, 0, 0) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(img, 0, 0) + love.graphics.setCanvas() + local id = cv:newImageData() + local clear = 0 + for y = 0, h - 1 do + for x = 0, w - 1 do + local _, _, _, a = id:getPixel(x, y) + if a < 0.02 then clear = clear + 1 end + end + end + return clear / (w * h) + end) + love.graphics.setCanvas() + return ok and share or nil + end + + local GBC_OBJ = PaletteFX.ogObj() + local BOOT = { bootGreen = GBC_OBJ[2], bootDarkGreen = GBC_OBJ[3] } + + -- ---- ROUTE_1, beside a YOUNGSTER, in grass ----------------------------- + -- ../pokered/data/maps/objects/Route1.asm: ROUTE1_YOUNGSTER2 walks + -- left/right on (15, 13), so (14, 13) facing right is beside it. + local ROUTE = PaletteFX.pal(game.data, "ROUTE") + U.log("ROUTE palette:", ramp(ROUTE)) + local ow = standNextTo("ROUTE_1", "ROUTE1_YOUNGSTER2", + { x = 14, y = 13, facing = "right" }) + U.wait(40) -- let the grass / flower tile animation cycle + U.shot(game, DIR .. "/bug301_1_route1_sgb.png") + local shot = paletteAudit("ROUTE_1 / SGB", ROUTE) + local c = countIn(shot, { + bootGreen = BOOT.bootGreen, bootDarkGreen = BOOT.bootDarkGreen, + routeGrassGreen = ROUTE[2], routeLightBlue = ROUTE[3], + }) + if c then + check(c.bootGreen == 0 and c.bootDarkGreen == 0, + "no Game Boy Color boot-ROM green anywhere on the SGB screen (#301)") + check(c.routeGrassGreen > 0, + "ROUTE's own grass green IS on screen (the cap and the grass share it, #150)") + end + + local spr = ow and ow.player and ow.player.sprite + check(spr ~= nil and spr.image ~= nil and spr:resolveImage() ~= spr.image, + "the player's sprite resolves to a BAKED image in SGB, not the raw sheet") + local share = transparentShare(spr) + if share == nil then + U.log("WARN could not read the baked sprite back; judge transparency by eye") + else + U.log(("baked player sheet is %.1f%% fully transparent"):format(share * 100)) + check(share > 0.1, + "OBJ colour 0 stays keyed to alpha, so a character has no backdrop") + end + + -- ---- LAVENDER_TOWN, beside a COOLTRAINER, on a PINK palette ------------ + -- ../pokered/data/maps/objects/LavenderTown.asm: LAVENDERTOWN_COOLTRAINER_M + -- stands on (9, 10), so (9, 9) facing down is beside it. No green anywhere + -- in this palette, so a green person here is unmistakable. + local LAVENDER = PaletteFX.pal(game.data, "LAVENDER") + U.log("LAVENDER palette:", ramp(LAVENDER)) + standNextTo("LAVENDER_TOWN", "LAVENDERTOWN_COOLTRAINER_M", + { x = 9, y = 9, facing = "down" }) + U.wait(24) + U.shot(game, DIR .. "/bug301_2_lavender_sgb.png") + shot = paletteAudit("LAVENDER_TOWN / SGB", LAVENDER) + c = countIn(shot, { + bootGreen = BOOT.bootGreen, bootDarkGreen = BOOT.bootDarkGreen, + lavenderPink = LAVENDER[2], routeGrassGreen = ROUTE[2], + }) + if c then + check(c.bootGreen == 0 and c.bootDarkGreen == 0, + "no boot-ROM green in LAVENDER TOWN either") + check(c.lavenderPink > 0, + "the characters here wear LAVENDER's pink -- they changed with the map") + check(c.routeGrassGreen == 0, + "and no ROUTE green followed them over (nothing is palette-pinned)") + end + + -- ---- OG RED, the mode that IS supposed to have an object palette ------- + -- A GBC boots the cartridge with one BG and one OBJ palette, so there the + -- people really are green. Losing that green means the fix went too far. + game.save.options.colors = "ogred" + PaletteFX.setMode("ogred") + standNextTo("ROUTE_1", "ROUTE1_YOUNGSTER2", + { x = 14, y = 13, facing = "right" }) + U.wait(30) + U.shot(game, DIR .. "/bug301_3_route1_ogred.png") + local ogShot = Probe.grab() + c = countIn(ogShot, { + bootGreen = BOOT.bootGreen, bootDarkGreen = BOOT.bootDarkGreen, + ogBgRed = PaletteFX.GBC_BG[3], ogBgPink = PaletteFX.GBC_BG[2], + }) + if c then + check(c.bootGreen + c.bootDarkGreen > 0, + "OG RED keeps its boot-ROM green characters (regression guard)") + check(c.ogBgRed + c.ogBgPink > 0, "over OG RED's red terrain") + end + game.save.options.colors = "gbc" + PaletteFX.setMode("gbc") + U.wait(20) + + -- ---- tilt mode, as a guard rather than a gate -------------------------- + -- Tilt colorizes each billboard on its own, outside the whole-canvas zone + -- pass, so it is the path most likely to disagree with the flat one. Pure + -- white and raw DMG grey belong to no SGB palette (they all open on paper, + -- 255,239,255), so either one here means an uncolorized sheet got through. + standNextTo("ROUTE_1", "ROUTE1_YOUNGSTER2", + { x = 14, y = 13, facing = "right" }) + game.save.options.tilt = 2 + require("src.render.Tilt").setLevel(2) + U.wait(70) -- the tilt tween is presentational and runs on real time + U.shot(game, DIR .. "/bug301_4_route1_tilt_sgb.png") + local tiltShot = Probe.grab() + c = countIn(tiltShot, { + pureWhite = { 255, 255, 255 }, rawGrey170 = { 170, 170, 170 }, + bootGreen = BOOT.bootGreen, paper = ROUTE[1], + }) + if c then + check(c.pureWhite == 0 and c.rawGrey170 == 0, + "tilt billboards carry no uncolorized sheet pixels") + check(c.bootGreen == 0, "and no boot-ROM green in tilt mode either") + end + game.save.options.tilt = 0 + require("src.render.Tilt").setLevel(0) + U.wait(50) + + U.log(fails == 0 and "all #301 preconditions passed" + or (fails .. " #301 precondition(s) FAILED -- read up")) + + -- ---- Part 3: hand off, standing next to somebody, in the default mode --- + standNextTo("ROUTE_1", "ROUTE1_YOUNGSTER2", + { x = 14, y = 13, facing = "right" }) + + U.log("You are on ROUTE 1 in SGB colour, face to face with a YOUNGSTER. Both") + U.log("of you should take colour from the ground's own palette (Red's cap on") + U.log("ROUTE's grass green) and change with every map you walk onto. #301") + U.log("was everyone painted boot-ROM lime green everywhere. Shots in " .. DIR) + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/shot_probe.lua b/tests/drivers/shot_probe.lua new file mode 100644 index 00000000..29394149 --- /dev/null +++ b/tests/drivers/shot_probe.lua @@ -0,0 +1,107 @@ +-- Exact-colour probe over the presented frame, for the palette drivers +-- (#279, #301, #322). The colours those fixes argue about are exact byte +-- triples out of data/generated/palettes.lua and the shade-remap shader writes +-- them verbatim, so counting exact matches puts a number in the log. grab() +-- is a coroutine helper (captureScreenshot only lands at the next present) and +-- returns nil where capture is unavailable, so callers WARN instead of failing. + +local Probe = {} + +-- Grab the presented frame as ImageData, or nil if capture is unavailable. +function Probe.grab(maxFrames) + if not (love.graphics and love.graphics.captureScreenshot) then return nil end + local shot = nil + local ok = pcall(love.graphics.captureScreenshot, function(id) shot = id end) + if not ok then return nil end + for _ = 1, maxFrames or 180 do + if shot then break end + coroutine.yield() + end + return shot +end + +local function byteAt(shot, x, y) + local r, g, b = shot:getPixel(x, y) + return math.floor(r * 255 + 0.5), math.floor(g * 255 + 0.5), + math.floor(b * 255 + 0.5) +end + +-- Count exact matches for a { name = {r, g, b} } table. `step` subsamples both +-- axes (default 3), `rect` limits the scan to { x0, y0, x1, y1 } in 0..1 +-- fractions of the frame. Returns the counts and the pixels sampled. +-- The window blit can be filtered, so glyph and sprite EDGES blend into inexact +-- triples: ask about flat interiors, never a whole-frame exact match. +function Probe.count(shot, wanted, step, rect) + local out = {} + for name in pairs(wanted) do out[name] = 0 end + if not shot then return out, 0 end + step = step or 3 + local w, h = shot:getDimensions() + local x0, y0, x1, y1 = 0, 0, w - 1, h - 1 + if rect then + x0 = math.floor(rect[1] * (w - 1)) + y0 = math.floor(rect[2] * (h - 1)) + x1 = math.floor(rect[3] * (w - 1)) + y1 = math.floor(rect[4] * (h - 1)) + end + local total = 0 + for y = y0, y1, step do + for x = x0, x1, step do + local r, g, b = byteAt(shot, x, y) + total = total + 1 + for name, c in pairs(wanted) do + if r == c[1] and g == c[2] and b == c[3] then out[name] = out[name] + 1 end + end + end + end + return out, total +end + +-- The n most common exact colours, biggest first, as +-- { { r, g, b, count = n, share = 0..1 }, ... }. For claims about the SIZE of +-- the palette on screen rather than one named colour being present. +function Probe.top(shot, n, step, rect) + if not shot then return {} end + step = step or 3 + local w, h = shot:getDimensions() + local x0, y0, x1, y1 = 0, 0, w - 1, h - 1 + if rect then + x0 = math.floor(rect[1] * (w - 1)) + y0 = math.floor(rect[2] * (h - 1)) + x1 = math.floor(rect[3] * (w - 1)) + y1 = math.floor(rect[4] * (h - 1)) + end + local seen, order, total = {}, {}, 0 + for y = y0, y1, step do + for x = x0, x1, step do + local r, g, b = byteAt(shot, x, y) + local key = r * 65536 + g * 256 + b + local e = seen[key] + if not e then + e = { r, g, b, count = 0 } + seen[key] = e + order[#order + 1] = e + end + e.count = e.count + 1 + total = total + 1 + end + end + table.sort(order, function(a, b) return a.count > b.count end) + local out = {} + for i = 1, math.min(n or 6, #order) do + order[i].share = total > 0 and order[i].count / total or 0 + out[i] = order[i] + end + return out, total +end + +function Probe.fmt(list) + local parts = {} + for _, c in ipairs(list) do + parts[#parts + 1] = ("(%d,%d,%d)=%.1f%%") + :format(c[1], c[2], c[3], (c.share or 0) * 100) + end + return table.concat(parts, " ") +end + +return Probe diff --git a/tests/drivers/status_poison_test.lua b/tests/drivers/status_poison_test.lua index f2a57680..34280d56 100644 --- a/tests/drivers/status_poison_test.lua +++ b/tests/drivers/status_poison_test.lua @@ -42,7 +42,7 @@ return function(game) U.tap(game, "a"); U.wait(8) -- FIGHT U.tap(game, "a"); U.wait(2) -- POISONPOWDER - -- announce: "BULBASAUR used POISONPOWDER!" — no PSN on HUD yet + -- announce: "BULBASAUR used POISONPOWDER!" -- no PSN on HUD yet waitFor(function() return battle.current and battle.current.text and battle.current.text:find("POISONPOWDER", 1, true) diff --git a/tests/drivers/status_screen_bug280_test.lua b/tests/drivers/status_screen_bug280_test.lua new file mode 100644 index 00000000..6c42b961 --- /dev/null +++ b/tests/drivers/status_screen_bug280_test.lua @@ -0,0 +1,325 @@ +-- Driver: the status screen against engine/pokemon/status_screen.asm (#280). +-- The screen loads its OWN VRAM overlay (:86-97), which is the only reason +-- $73/$74 can be the and № glyphs here and cannot be in battle. Also +-- covered: the dex column :109-113/:143-146, the mirrored pic :170, "№/" +-- :205-210, DrawLineBox :219-234, no level on page 2 :303-305, '' :393-403. +-- POKEPORT_DRIVER=tests/drivers/status_screen_bug280_test.lua POKEPORT_IDENTITY=bug280 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 Pokemon = require("src.pokemon.Pokemon") + local Assets = require("src.render.Assets") + local Font = require("src.render.Font") + local HudTiles = require("src.render.HudTiles") + local SummaryMenu = require("src.ui.SummaryMenu") + + local pass, fail = 0, 0 + local function check(label, ok) + if ok then pass = pass + 1 else fail = fail + 1 end + U.log(ok and "PASS" or "FAIL", label) + return ok + end + local function top() return game.stack:top() end + + -- ---- preconditions no eye can check ------------------------------------ + -- A code that falls off the end of its sheet draws NOTHING (HudTiles' put + -- returns early on a nil tile), which looks exactly like the old bug. + U.log("======== #280 status screen: machine checks ========") + check("HudTiles.statusTile exists", type(HudTiles.statusTile) == "function") + check("HudTiles.tile still exists (battle layout untouched)", + type(HudTiles.tile) == "function") + local function tileCount(path) + local ok, img = pcall(Assets.image, path) + if not ok or not img then return 0 end + local w, h = img:getDimensions() + return math.floor(w / 8) * math.floor(h / 8) + end + local extra = tileCount("assets/generated/battle/font_battle_extra.png") + U.log("font_battle_extra holds " .. extra .. " tiles at base $62") + check("font_battle_extra reaches $70 (needs 15 tiles)", extra >= 0x70 - 0x62 + 1) + check("font_battle_extra reaches $73 (needs 18)", extra >= 0x73 - 0x62 + 1) + check("font_battle_extra reaches $74 № (needs 19)", extra >= 0x74 - 0x62 + 1) + check("battle_hud_2 has the 1 tile the status overlay copies to $78", + tileCount("assets/generated/battle/battle_hud_2.png") >= 1) + check("battle_hud_3 has the 2 tiles the status overlay copies to $76", + tileCount("assets/generated/battle/battle_hud_3.png") >= 2) + + -- ---- the fixture ------------------------------------------------------- + -- NIDORINO: the intro shows the same sprite flipped (src/ui/OakSpeech.lua) + -- so the mirror has a reference, and dex 33 makes the leading zero visible. + local mon = Pokemon.new(game.data, "NIDORINO", 42) + local maxed = Pokemon.new(game.data, "MEW", 100) + mon.hp = math.max(1, math.floor(mon.stats.hp * 0.4)) + game.save.party = { mon, maxed } + game.save.player.name = "RED" + local dex = game.data.pokemon.NIDORINO.dex + check("NIDORINO's dex number resolves", type(dex) == "number") + U.log(("fixture: NIDORINO :L%d, dex %03d, %d/%d HP; MEW :L100 in slot 2") + :format(mon.level, dex or 0, mon.hp, mon.stats.hp)) + + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(10) + + -- ---- open the status screen through the real UI ------------------------ + local function cursorTo(menu, field, want) + for _ = 1, 40 do + if not menu or menu[field] == want then return menu and menu[field] == want end + U.tap(game, menu[field] < want and "down" or "up") + U.wait(3) + end + return menu[field] == want + end + + local function openStatsFor(slot) + U.tap(game, "start") + U.wait(10) + local menu = top() + if not (menu and menu.screenId == "StartMenu") then return nil, "no start menu" end + local row + for i, it in ipairs(menu.items or {}) do + if it.label == "POKéMON" then row = i break end + end + if not row or not cursorTo(menu, "index", row) then return nil, "no POKéMON row" end + U.tap(game, "a") + U.wait(10) + + local party = top() + if not (party and party.screenId == "PartyMenu") then return nil, "no party menu" end + if not cursorTo(party, "index", slot) then return nil, "cursor never reached the slot" end + U.tap(game, "a") -- opens the STATS/SWITCH/... submenu + U.wait(8) + if not party.submenu then return nil, "submenu never opened" end + local subRow + for i, it in ipairs(party.subItems or {}) do + if it.action == "stats" then subRow = i break end + end + if not subRow or not cursorTo(party, "subIndex", subRow) then + return nil, "no STATS row" + end + U.tap(game, "a") + U.wait(20) + local screen = top() + if getmetatable(screen) ~= SummaryMenu and screen.screenId ~= "SummaryMenu" then + return nil, "status screen never opened" + end + return screen + end + + -- ---- record one real rendered frame ------------------------------------ + -- SummaryMenu.isOpaque is true, so StateStack:draw starts at it: everything + -- recorded below belongs to this screen and nothing under it. + local rec + local realStatusTile, realTile = HudTiles.statusTile, HudTiles.tile + local realDraw, realCode = Font.draw, Font.drawCode + local realGDraw = love.graphics.draw + + local function instrument() + rec = { status = {}, battle = {}, text = {}, code = {}, quads = {}, imgs = {} } + HudTiles.statusTile = function(code, x, y, tint) + rec.status[#rec.status + 1] = { code = code, x = x, y = y } + return realStatusTile(code, x, y, tint) + end + HudTiles.tile = function(code, x, y, tint) + rec.battle[#rec.battle + 1] = { code = code, x = x, y = y } + return realTile(code, x, y, tint) + end + Font.draw = function(text, x, y) + rec.text[#rec.text + 1] = { s = tostring(text), x = x, y = y } + return realDraw(text, x, y) + end + Font.drawCode = function(code, x, y) + rec.code[#rec.code + 1] = { code = code, x = x, y = y } + return realCode(code, x, y) + end + love.graphics.draw = function(...) + local a = { ... } + -- draw(image, quad, x, y) vs draw(image, x, y, r, sx, sy). Key off + -- "argument 2 is not a number" rather than its type: the real Quad is + -- userdata but the tests/love_stub one is a table. + if a[2] ~= nil and type(a[2]) ~= "number" and type(a[3]) == "number" then + -- a tile that actually reached the screen, so a recorded statusTile + -- call with no draw behind it means a MISSING glyph + rec.quads[#rec.quads + 1] = { x = a[3], y = a[4] } + else + rec.imgs[#rec.imgs + 1] = { img = a[1], x = a[2], y = a[3], + r = a[4], sx = a[5], sy = a[6] } + end + return realGDraw(...) + end + end + + local function release() + HudTiles.statusTile, HudTiles.tile = realStatusTile, realTile + Font.draw, Font.drawCode = realDraw, realCode + love.graphics.draw = realGDraw + end + + local function capture() + instrument() + U.wait(2) + release() + return rec + end + + local function hasTile(list, code, x, y) + for _, e in ipairs(list) do + if e.code == code and e.x == x and e.y == y then return true end + end + return false + end + local function anyTile(list, code) + for _, e in ipairs(list) do if e.code == code then return e end end + return nil + end + local function hasText(list, s, x, y) + for _, e in ipairs(list) do + if e.s == s and e.x == x and e.y == y then return true end + end + return false + end + local function drewAt(list, x, y) + for _, e in ipairs(list) do + if e.x == x and e.y == y then return true end + end + return false + end + + -- ======== page 1 ========================================================= + U.log("======== #280 page 1 ========") + local screen, why = openStatsFor(1) + if not check("status screen opened" .. (why and (" (" .. why .. ")") or ""), + screen ~= nil) then + U.log("cannot continue without the status screen") + else + local p1 = capture() + U.shot(game, DIR .. "/bug280_page1.png") + + -- (1) MIRRORED PIC: status_screen.asm:170 + local flipped + for _, e in ipairs(p1.imgs) do + if e.img == screen.sprite and type(e.sx) == "number" and e.sx < 0 then + flipped = e + end + end + check("the pic is drawn mirrored (negative x scale, asm:170)", flipped ~= nil) + if flipped then + U.log(("pic drawn at x=%d y=%d scale %d,%d"):format(flipped.x, flipped.y, + flipped.sx, flipped.sy)) + end + + -- (2) № GLYPH + DEX COLUMN: asm:109-113 and :143-146 + check("№ is the single tile $74 at (1,7) = px (8,56)", + hasTile(p1.status, 0x74, 8, 56)) + check("...and it actually reached the screen (tile exists in the sheet)", + drewAt(p1.quads, 8, 56)) + check(" is drawn as the charmap glyph $F2 at (2,7) = px (16,56)", + hasTile(p1.code, 0xF2, 16, 56)) + check("the 3 dex digits start at (3,7) = px (24,56), a column left of the old \"No.\"", + hasText(p1.text, ("%03d"):format(dex or 0), 24, 56)) + local spelled = false + for _, e in ipairs(p1.text) do + if e.s:find("No.", 1, true) or e.s:find("IDNo", 1, true) then spelled = true end + end + check("nothing spells \"No.\" out of letter tiles any more", not spelled) + + -- (3) LEVEL ON PAGE 1: PrintLevel at (14,2) = px (112,16) + check("PrintLevel's :L tile $6E sits at (14,2) = px (112,16)", + hasTile(p1.status, 0x6E, 112, 16)) + check("...with the level digits right after it at px (120,16)", + hasText(p1.text, tostring(mon.level), 120, 16)) + + -- (4) "№/": asm:205-210, three columns, not five letters + check(" is the single tile $73 at (10,13) = px (80,104)", + hasTile(p1.status, 0x73, 80, 104)) + check("№ repeats at (11,13) = px (88,104)", hasTile(p1.status, 0x74, 88, 104)) + check("...and both reached the screen", drewAt(p1.quads, 80, 104) + and drewAt(p1.quads, 88, 104)) + check("the slash follows at px (96,104)", hasText(p1.text, "/", 96, 104)) + + -- (5) DrawLineBox rides the STATUS overlay, so $73 stays free for + check("DrawLineBox's vertical is $78, not $73 (asm:222)", + anyTile(p1.status, 0x78) ~= nil) + local wrongVertical = false + for _, e in ipairs(p1.status) do + -- a $73 anywhere other than the two columns would be a line tile + if e.code == 0x73 and not (e.x == 80 and e.y == 104) then wrongVertical = true end + end + check("no $73 is used as a line tile on this screen", not wrongVertical) + check("the corner $77 and the run $76 come off the status overlay too", + anyTile(p1.status, 0x77) ~= nil and anyTile(p1.status, 0x76) ~= nil) + check("the HP bar still comes off the BATTLE table ($62-$6D are identical)", + anyTile(p1.battle, 0x71) ~= nil) + + -- ======== page 2 ======================================================= + U.log("======== #280 page 2 ========") + U.tap(game, "a") -- A flips the page (WaitForTextScrollButtonPress) + U.wait(10) + check("A flipped to page 2", screen.page == 2) + local p2 = capture() + U.shot(game, DIR .. "/bug280_page2.png") + + -- (6) NO LEVEL IN THE HEADER: ClearScreenArea (9,2) 5x10, asm:303-305 + check("no :L tile at the header slot (14,2) on page 2", + not hasTile(p2.status, 0x6E, 112, 16)) + check("and no level digits there either", + not hasText(p2.text, tostring(mon.level), 120, 16)) + + -- (7) EXP RIGHT-ALIGNED: PrintNumber 7 columns at (12,4) = px (96,32) + check("the exp is padded to 7 columns at px (96,32), not left-aligned", + hasText(p2.text, ("%7d"):format(mon.exp), 96, 32)) + + -- (8) THE MISSING '': asm:393-397, tile $70 at (14,6) = px (112,48) + check("'' is the single tile $70 at (14,6) = px (112,48)", + hasTile(p2.status, 0x70, 112, 48)) + check("...and it reached the screen", drewAt(p2.quads, 112, 48)) + check("PrintLevel follows at (16,6) = px (128,48)", + hasTile(p2.status, 0x6E, 128, 48)) + check("the № header line is still on page 2 (it is shared)", + hasTile(p2.status, 0x74, 8, 56)) + + -- (9) the shared PrintLevel port: level 100 loses the ':L' to its third + -- digit (home/pokemon.asm), which is the side effect to expect + U.tap(game, "a") -- page 2 + A closes the screen + U.wait(20) + for _ = 1, 20 do + if top() and top().screenId == "PartyMenu" then break end + U.tap(game, "b") + U.wait(6) + end + for _ = 1, 20 do + if top() == game.overworld then break end + U.tap(game, "b") + U.wait(6) + end + local mewScreen = openStatsFor(2) + if check("status screen opened for the :L100 MEW", mewScreen ~= nil) then + local pm = capture() + U.shot(game, DIR .. "/bug280_level100.png") + check("at level 100 PrintLevel drops the :L tile entirely", + not hasTile(pm.status, 0x6E, 112, 16)) + check("...and the three digits start where the :L tile was, px (112,16)", + hasText(pm.text, "100", 112, 16)) + end + end + + U.log(("======== machine checks: %d passed, %d failed ========"):format(pass, fail)) + + -- ---- hand off with the screen open ------------------------------------- + -- back to slot 1, page 1: the screen the reporter photographed + for _ = 1, 30 do + if top() == game.overworld then break end + U.tap(game, "b") + U.wait(6) + end + openStatsFor(1) + + U.log("NIDORINO's status page 1 is up; A flips to page 2, A again closes.") + U.log("Page 1: pic faces RIGHT, the dex line is one '№' glyph + raised dot +") + U.log("'033', ':L42' in the header, ID line is 'ID' '№' '/'. Page 2: no") + U.log("level, EXP right-aligned, a narrow 'to' before ':L43' (#280).") + U.log("Slot 2's :L100 MEW drops its ':L': that is PrintLevel, not a bug.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/text_scroll_bug314_test.lua b/tests/drivers/text_scroll_bug314_test.lua index 8d3a22fc..259338bd 100644 --- a/tests/drivers/text_scroll_bug314_test.lua +++ b/tests/drivers/text_scroll_bug314_test.lua @@ -1,28 +1,9 @@ --- Driver: manual look at scrolling text staying inside its box (#314). --- --- Reported against Oak's speech after the champion, which is where players --- meet it, but the defect was in every text box that scrolls. TextBox:draw --- added the scroll offset to both visible lines, so for the four frames of --- the slide the incoming line was drawn 8px low -- exactly on the box's --- bottom border row -- and whenever the typewriter got a character out --- during those frames, that character appeared on the border. --- --- pokered does not do a sub-tile scroll at all: ScrollTextUpOneLine --- (home/text.asm:283) copies the three text rows up a whole row, blanks the --- bottom one, and waits 5 frames. Nothing is ever drawn between two rows. --- --- tests/parity_text_scroll_bounds.lua asserts the invariant directly (no --- body glyph below line2Y at any point in the scroll), which is a tighter --- check than an eye can make. This driver exists so the exact reported --- moment can be watched without beating the Elite Four: it opens the real --- _HallOfFameOakText, the same string the Hall of Fame script shows. --- --- Do NOT add POKEPORT_SPEED: the scroll is four frames long and the --- typewriter races it, so scaling the logic clock changes the very overlap --- being judged. --- --- POKEPORT_DRIVER=tests/drivers/text_scroll_bug314_test.lua \ --- POKEPORT_IDENTITY=bug314 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +-- Driver: scrolling text must stay inside its box (#314). Opens the real +-- _HallOfFameOakText so the reported moment is watchable without beating +-- the Elite Four; tests/parity_text_scroll_bounds.lua asserts the bound. +-- pokered never scrolls sub-tile: ScrollTextUpOneLine (home/text.asm:283) +-- copies whole rows. No POKEPORT_SPEED: the typewriter races the scroll. +-- POKEPORT_DRIVER=tests/drivers/text_scroll_bug314_test.lua POKEPORT_IDENTITY=bug314 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . return function(game) local U = dofile("tests/drivers/util.lua") local TextBox = require("src.render.TextBox") @@ -34,10 +15,8 @@ return function(game) return ok end - -- ---- preconditions the eye cannot check -------------------------------- - -- The bug only shows on a box that scrolls, and a box only scrolls when - -- the text carries \v (cont) markers. A text with none would look fine - -- while proving nothing. + -- a box only scrolls when the text carries \v (cont) markers, so a text + -- with none would look fine while proving nothing local text = game.data.text[TEXT_KEY] check(TEXT_KEY .. " is in the extracted text", type(text) == "string") local conts = 0 @@ -46,7 +25,6 @@ return function(game) U.log(" " .. TEXT_KEY .. " has", conts, "cont markers and", select(2, tostring(text):gsub("\f", "")), "page breaks") - -- the page geometry the glyphs have to stay inside local probe = TextBox.new(game, "probe") U.log(" box rows: line1Y =", probe.line1Y, " line2Y =", probe.line2Y, " bottom border row starts at y =", @@ -61,9 +39,7 @@ return function(game) U.log(" the typewriter most often beats the four-frame scroll.") end - -- ---- put the speech on screen ------------------------------------------ - -- somewhere quiet with a plain background, so the box border is easy to - -- read against it + -- a plain background, so the box border is easy to read against it U.teleport(game, "PALLET_TOWN", 5, 6, "down") U.wait(10) @@ -74,10 +50,8 @@ return function(game) U.shot(game, SHOT_DIR .. "/bug314_hof_speech.png") U.log("captured", SHOT_DIR .. "/bug314_hof_speech.png") - -- Drive the first few cont scrolls and capture one mid-slide: scrollPx - -- starts at 8 and drains 2 per drawn frame, so the border overlap (when - -- it exists at all) is on screen for about four frames and is very easy - -- to blink past by hand. + -- scrollPx starts at 8 and drains 2 per drawn frame, so any overlap is + -- on screen for about four frames: capture it rather than blink past it local shots, scrolls = 0, 0 for _ = 1, 240 do if box.waiting then @@ -98,26 +72,11 @@ return function(game) end check("the box actually scrolled at least once", scrolls > 0) - -- ---- hand off, then stay out of the way -------------------------------- - U.log("........................................................") - U.log("LOOK NOW: Oak's Hall of Fame speech is open, the same string the") - U.log("champion cutscene shows, already advanced through its first few") - U.log("scrolls (see the mid-scroll captures above). Press A to walk") - U.log("through the rest and watch the BOTTOM BORDER of the box each") - U.log("time a line scrolls up.") - U.log(" RIGHT: the old line slides up into the top row while the new") - U.log(" line types along the bottom row, always clear of the") - U.log(" border. The blinking arrow sits in the border corner --") - U.log(" that one belongs there.") - U.log(" BUG #314 looks like: as a line scrolls, the first character or") - U.log(" two of the incoming line flash ON the bottom border,") - U.log(" cutting through the box edge, then jump up into place.") - U.log(" ALSO WRONG: the two lines bunch together mid-scroll and never") - U.log(" separate, or the top line slides up through the top") - U.log(" border instead of stopping at the first text row.") - U.log("Input is yours from here on. Re-run with TEXT SPEED on FAST if") - U.log("nothing shows -- that is when the typewriter beats the scroll.") - U.log("........................................................") + -- hand off, then stay out of the way + U.log("Oak's Hall of Fame speech is open and already a few scrolls in.") + U.log("Press A through the rest: as each line slides up, no glyph may") + U.log("touch the box's bottom border (#314). The blinking arrow is fine.") + U.log("Re-run on FAST text speed if nothing shows.") while true do coroutine.yield() diff --git a/tests/drivers/trainer_ball_bug291_test.lua b/tests/drivers/trainer_ball_bug291_test.lua new file mode 100644 index 00000000..a7cddba3 --- /dev/null +++ b/tests/drivers/trainer_ball_bug291_test.lua @@ -0,0 +1,151 @@ +-- Driver: a ball thrown at a trainer's mon is blocked, with the animation and +-- the turn it costs (#291). ItemUseBall branches to ThrowBallAtTrainerMon +-- before it prints the "used " line (engine/items/item_effects.asm:109-113, +-- 2292-2303), and TossBallAnimation's .BlockBall plays TOSS_ANIM, +-- SFX_FAINT_THUD, BLOCKBALL_ANIM (engine/battle/animations.asm:2629-2637). +-- POKEPORT_DRIVER=tests/drivers/trainer_ball_bug291_test.lua 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 Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local OPP, ROSTER, BALL = "OPP_YOUNGSTER", 1, "POKE_BALL" + + -- ---- preconditions neither the eye nor the ear can separate ------------ + -- A missing anim program plays nothing, a missing sfx key is a silent no-op + -- in Sound.play, and a missing text entry falls back to a differently cased + -- literal. All three read as the bug. + local ma = game.data.battle_anims and game.data.battle_anims.moveAnims + check("TOSS_ANIM has an extracted animation program", + ma ~= nil and ma.TOSS_ANIM ~= nil) + check("BLOCKBALL_ANIM has an extracted animation program", + ma ~= nil and ma.BLOCKBALL_ANIM ~= nil) + local sfx = game.data.audio and game.data.audio.sfx + check("SFX_FAINT_THUD resolves as Faint_Thud", + sfx ~= nil and sfx.Faint_Thud ~= nil) + local t1 = game.data.text and game.data.text._ThrowBallAtTrainerMonText1 + local t2 = game.data.text and game.data.text._ThrowBallAtTrainerMonText2 + check("_ThrowBallAtTrainerMonText1 is in the generated text", + type(t1) == "string") + check("_ThrowBallAtTrainerMonText2 is in the generated text", + type(t2) == "string") + if type(t1) == "string" then + U.log("block line reads:", (t1:gsub("\n", " / "))) + check("it is the ROM's lower-case \"trainer\", not \"The TRAINER\"", + t1:find("The trainer", 1, true) ~= nil) + end + check("trainer class " .. OPP .. " is in the data", + game.data.trainers ~= nil and game.data.trainers[OPP] ~= nil) + check("the ball item exists", game.data.items[BALL] ~= nil) + + -- the thud is the middle beat of the three, and a muted run cannot tell it + -- from a thud that never plays + local vol = game.save.options and game.save.options.sfxVol + U.log("audio device present:", love.audio ~= nil, + " SFX VOL (0-7):", tostring(vol)) + if not love.audio or vol == 0 then + U.log("WARNING: sound output is off, so the FAINT THUD below will be", + "inaudible; raise SFX VOL in OPTION first") + end + + game.save.player.name = "bryan" + game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) } + -- exactly one bag entry, so the bag cursor starts on the ball + game.save.inventory = { [BALL] = 5 } + check("the bag holds " .. BALL, (game.save.inventory[BALL] or 0) > 0) + + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(20) + local ow = game.overworld + check("overworld is up to push the battle from", ow ~= nil) + + local ok, battle = pcall(BattleState.newTrainer, game, OPP, ROSTER) + check("trainer battle constructed", ok and battle ~= nil) + if not ok then + U.log("could not start", OPP, "->", tostring(battle)) + while true do coroutine.yield() end + end + check("battle kind is trainer (a wild throw is the control, not this)", + battle.kind == "trainer") + battle.onFinish = function() end + ow:pushBattle(battle) + + local function tapUntil(cond, taps, gap) + for _ = 1, (taps or 60) do + if cond() then return true end + U.tap(game, "a") + for _ = 1, (gap or 6) do + if cond() then return true end + U.wait(1) + end + end + return cond() + end + + local function waitUntil(cond, frames) + for _ = 1, (frames or 120) do + if cond() then return true end + U.wait(1) + end + return cond() + end + + check("reached the FIGHT/PKMN/ITEM/RUN menu", + tapUntil(function() return battle.phase == "menu" end, 60)) + + -- The 2x2 command menu is fight/pkmn/item/run (BattleState.lua:1378-1387, + -- DisplayBattleMenu): one press of DOWN from FIGHT lands on ITEM. + U.tap(game, "down") + U.wait(6) + check("cursor is on ITEM", battle.menuIndex == 3) + U.tap(game, "a") -- open the bag + U.wait(20) + U.tap(game, "a") -- A on the only entry: no USE/TOSS box mid-battle + U.wait(6) + + -- Poll rather than sleep, so the arc is caught on the frame it starts. + local sawToss = waitUntil(function() return battle.animName == "TOSS_ANIM" end, + 180) + check("the ball was thrown from the real bag (TOSS_ANIM playing)", sawToss) + if sawToss then + check("toss screenshot reached disk", + U.shot(game, DIR .. "/bug291_toss.png")) + U.log("captured", DIR .. "/bug291_toss.png") + end + + local sawBlock = waitUntil( + function() return battle.animName == "BLOCKBALL_ANIM" end, 240) + check("the trainer's block animation played (#291)", sawBlock) + if sawBlock then + check("block screenshot reached disk", + U.shot(game, DIR .. "/bug291_blockball.png")) + U.log("captured", DIR .. "/bug291_blockball.png") + end + + -- Stop on the block text, so the box is up at hand-off and the human's own A + -- press is what starts the foe's turn. + waitUntil(function() + return battle.shown and battle.shown[1] ~= nil and not battle.animPlaying + end, 240) + check("block text screenshot reached disk", + U.shot(game, DIR .. "/bug291_block_text.png")) + U.log("captured", DIR .. "/bug291_block_text.png") + check("the ball was consumed by the throw", + (game.save.inventory[BALL] or 0) == 4) + + -- ---- hand off, then stay out of the way -------------------------------- + U.log("A POKe BALL has been thrown at the trainer's RATTATA; the box is the") + U.log("result. No \"bryan used POKe BALL!\" line: just the arc, a FAINT THUD, the") + U.log("BLOCKBALL flash, the block text, then the foe attacking, because the") + U.log("throw costs the turn (#291). Wild battles do still print the used line.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/trainer_run_bug239_test.lua b/tests/drivers/trainer_run_bug239_test.lua index 67d9f078..48ed5537 100644 --- a/tests/drivers/trainer_run_bug239_test.lua +++ b/tests/drivers/trainer_run_bug239_test.lua @@ -1,24 +1,8 @@ -- Driver: the trainer-battle run refusal prints its third line (#239). --- A manual eye check, not a pass/fail run. --- --- pokered evidence. The string is three lines wide in a two-line box, and -- data/generated/text.lua keeps the original's markers: --- -- _NoRunningText = "No! There's no\nrunning from a\011trainer battle!" --- --- \011 is \v, which src/render/TextBox.lua treats as ContText: show the --- down-arrow, wait for a button, then scroll the third line in. The port --- had hard-coded the same words with three \n instead, so all three lines --- were queued onto a page that only shows two, "trainer battle!" was never --- reached, and the command menu came straight back. --- --- The fix reads _NoRunningText from the generated data, with a correctly --- marked literal as the fallback for an older cache. --- --- Do NOT run this under POKEPORT_SPEED. Fast-forward scales only the logic --- clock while the typewriter runs on its own real-time accumulator --- (src/core/Game.lua), so the pause being judged here would desynchronize. --- +-- \011 is \v (ContText): down-arrow, wait, then scroll line 3 in; the port +-- hard-coded three \n. No POKEPORT_SPEED, the typewriter is real-time. -- POKEPORT_DRIVER=tests/drivers/trainer_run_bug239_test.lua POKEPORT_IDENTITY=bug239 love . return function(game) local U = dofile("tests/drivers/util.lua") @@ -31,10 +15,8 @@ return function(game) return ok end - -- ---- preconditions the eye cannot separate from the bug ---------------- - -- A wild battle instead of a trainer one shows no refusal at all, which - -- is not the same failure as a refusal that truncates. - + -- a wild battle shows no refusal at all, which is not the same failure + -- as a refusal that truncates local text = game.data.text and game.data.text._NoRunningText check("_NoRunningText is in the generated text", type(text) == "string") if type(text) == "string" then @@ -54,8 +36,7 @@ return function(game) U.teleport(game, "PALLET_TOWN", 10, 8, "down") U.wait(20) - -- any trainer class works; the refusal is keyed on kind == "trainer", - -- not on who the trainer is + -- any class works: the refusal is keyed on kind == "trainer" local OPP = "OPP_YOUNGSTER" check("trainer class " .. OPP .. " exists in the data", game.data.trainers and game.data.trainers[OPP] ~= nil) @@ -72,24 +53,10 @@ return function(game) U.wait(120) -- let the intro text and the throw settle end - U.log("........................................................") - U.log("READ NOW: choose RUN from the battle command menu.") - U.log(" RIGHT: 'No! There's no / running from a' fills the box, it holds") - U.log(" with a down-arrow until you press a button, and only then") - U.log(" does 'trainer battle!' scroll in. The command menu comes") - U.log(" back after that.") - U.log(" BUG #239 looks like: the box showing the first two lines and the") - U.log(" command menu snapping straight back, so the sentence ends") - U.log(" mid-phrase at 'running from a'.") - U.log(" ALSO WRONG: all three lines appearing at once with no pause (the") - U.log(" box only holds two, so something is overflowing), or the") - U.log(" third line arriving on a cleared page (that is \\f, not") - U.log(" \\v -- the original scrolls, it does not blank the box).") - U.log("Control case: run from a WILD battle and you should get the escape") - U.log(" roll instead, with no refusal text at all.") - U.log("Input is yours from here, so RUN can be re-selected as often as") - U.log("you like.") - U.log("........................................................") + U.log("Choose RUN from the battle command menu. The box should hold on") + U.log("'running from a' with a down-arrow until you press a button, then") + U.log("scroll 'trainer battle!' in (#239 cut the sentence off there).") + U.log("Control: running from a WILD battle gets the escape roll, no text.") while true do coroutine.yield() diff --git a/tests/drivers/trainer_sight_walls_test.lua b/tests/drivers/trainer_sight_walls_test.lua index 3b27fedb..845f1732 100644 --- a/tests/drivers/trainer_sight_walls_test.lua +++ b/tests/drivers/trainer_sight_walls_test.lua @@ -5,9 +5,9 @@ -- Forest / Victory Road trainers aggro through trees and walls. -- -- Scene A: VIRIDIAN_FOREST (18,33), west of tree wall vs Bug Catcher --- (30,33) LEFT range 4 — dx=12 pixel-wrap must NOT engage. --- Scene B: same trainer at (26,33) on-screen — MUST engage + walk-up. --- Scene C: VICTORY_ROAD_2F (0,9) vs Hiker (12,9) — dx=12 must NOT engage. +-- (30,33) LEFT range 4 -- dx=12 pixel-wrap must NOT engage. +-- Scene B: same trainer at (26,33) on-screen -- MUST engage + walk-up. +-- Scene C: VICTORY_ROAD_2F (0,9) vs Hiker (12,9) -- dx=12 must NOT engage. return function(game) local U = dofile("tests/drivers/util.lua") diff --git a/tests/drivers/trainer_victory_bug282_test.lua b/tests/drivers/trainer_victory_bug282_test.lua new file mode 100644 index 00000000..26b4132c --- /dev/null +++ b/tests/drivers/trainer_victory_bug282_test.lua @@ -0,0 +1,183 @@ +-- Driver: the beaten trainer scrolls back in and says his own line ON the +-- battle screen, before the prize money (#282). Order is pokered +-- engine/battle/core.asm:915-949: defeat text, scroll-in, 40 frames, +-- EndBattleText, money. No POKEPORT_SPEED on this run. +-- POKEPORT_DRIVER=tests/drivers/trainer_victory_bug282_test.lua \ +-- POKEPORT_IDENTITY=bug282 POKEPORT_TOUCH=0 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 Pokemon = require("src.pokemon.Pokemon") + local Growth = require("src.pokemon.Growth") + local BattleState = require("src.battle.BattleState") + + -- pokered data/maps/objects/Route3.asm: object_event 10, 6, ... RIGHT, + -- OPP_BUG_CATCHER, 4. The sight line runs east along row 6, so (13,6) is + -- one cell outside it; party 4 is three bug mons a :L15 CHARMANDER beats. + local MAP = "ROUTE_3" + local MAP_LABEL = "Route3" + local TRAINER = "ROUTE3_YOUNGSTER1" + local TRAINER_INDEX = 2 + local STAND = { x = 13, y = 6, facing = "left" } + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- ---- preconditions the eye cannot check -------------------------------- + -- A missing EndBattleText and a fix that never fires look identical on + -- screen: defeat text straight to money, nothing in between. + local header = game.data:trainerHeader(MAP_LABEL, TRAINER_INDEX) + check(TRAINER .. " has a trainer header", header ~= nil) + local wonKey = header and header.won + check("the header names an EndBattleText", wonKey ~= nil) + local wonText = wonKey and game.data.text[wonKey] + check("that text resolves to a string", + type(wonText) == "string" and wonText ~= "") + if type(wonText) == "string" then + U.log(" " .. tostring(wonKey) .. " reads:", (wonText:gsub("\n", " / "))) + end + + local mapDef = game.data.maps[MAP] + local trainerDef + for _, o in ipairs(mapDef and mapDef.objects or {}) do + if o.name == TRAINER then trainerDef = o end + end + check(TRAINER .. " is still on " .. MAP, trainerDef ~= nil) + local partyDef = trainerDef + and game.data.trainers[trainerDef.trainerClass] + and game.data.trainers[trainerDef.trainerClass].parties[trainerDef.trainerParty] + check("its party is still loadable", partyDef ~= nil) + if partyDef then + local names = {} + for _, slot in ipairs(partyDef) do + names[#names + 1] = slot.species .. " :L" .. slot.level + end + U.log(" fighting:", trainerDef.trainerClass, "party", + trainerDef.trainerParty, "--", table.concat(names, ", ")) + end + + -- ---- a mon that will evolve off the back of this fight ----------------- + -- The pacing half of the report: with the loss line inside the battle, the + -- evolution follows it directly instead of sitting between two overworld + -- cuts. Park the mon one exp point short of its evolution level. + local def = game.data.pokemon.CHARMANDER + local evoLevel + for _, evo in ipairs((def and def.evolutions) or {}) do + if evo.level then evoLevel = evo.level end + end + check("CHARMANDER still has a level evolution", evoLevel ~= nil) + local mon = Pokemon.new(game.data, "CHARMANDER", (evoLevel or 16) - 1) + mon.exp = Growth.expForLevel(def.growthRate, evoLevel or 16) - 1 + game.save.party = { mon } + U.log(" party: CHARMANDER :L" .. mon.level, "exp", mon.exp, + "-- one point short of :L" .. tostring(evoLevel)) + + -- ---- walk into the trainer's sight line -------------------------------- + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(10) + local ow = game.overworld + check("the overworld is up on " .. MAP, ow ~= nil) + + local function liveBattle() + for _, s in ipairs(game.stack.states or {}) do + if getmetatable(s) == BattleState then return s end + end + return nil + end + + local npc + for _, n in ipairs((ow and ow.npcs) or {}) do + if n.def and n.def.name == TRAINER then npc = n end + end + check("the trainer object loaded on the live map", npc ~= nil) + if npc and npc.cellY ~= STAND.y then + -- a map edit moved him: approach from wherever he stands now + U.log("trainer moved to", npc.cellX, npc.cellY, "-- re-approaching") + U.teleport(game, MAP, npc.cellX + 3, npc.cellY, "left") + U.wait(10) + end + + local battle + for _ = 1, 12 do + U.hold(game, "left", 12) + U.wait(20) + battle = liveBattle() + if battle then break end + -- the pre-battle line ("Hey! You have POKéMON!") holds the overworld + U.tap(game, "a") + U.wait(20) + battle = liveBattle() + if battle then break end + end + check("the trainer battle started", battle ~= nil) + if not battle then + U.log("FAIL nothing to show -- walk left along row 6 into the BUG CATCHER") + while true do coroutine.yield() end + end + + -- The loss line has to be handed to the battle (engageTrainer sets + -- battle.endBattleText) instead of left for the overworld to print. + check("the battle carries the trainer's EndBattleText (#282)", + type(battle.endBattleText) == "string" and battle.endBattleText ~= "") + if type(battle.endBattleText) == "string" then + U.log(" battle.endBattleText =", + (battle.endBattleText:gsub("\n", " / "):gsub("\f", " "))) + end + + -- ---- win it ------------------------------------------------------------ + -- Stop the moment the battle is decided, so the whole victory sequence is + -- still ahead of the player. + for _ = 1, 900 do + if battle.result then break end + U.tap(game, "a") + U.wait(4) + end + check("the battle was won", battle.result == "win") + if battle.result ~= "win" then + U.log("FAIL result was", tostring(battle.result), + "-- the party may have been too weak; nothing below applies") + end + + -- the victory sequence is still queued; the loss line has to be one of its + -- rows, not something the overworld prints later + local queuedLoss, queuedMoney = false, false + local firstWord = (battle.endBattleText or ""):match("^%S+") or "\1" + for _, row in ipairs(battle.queue or {}) do + if row.text then + if row.text:find(firstWord, 1, true) then queuedLoss = true end + if row.text:find("winning", 1, true) then queuedMoney = true end + end + end + check("the trainer's loss line is queued INSIDE the battle", queuedLoss) + check("the prize money is queued behind it", queuedMoney) + + -- step past the exp / level-up rows so the player's very next A press is + -- the one that starts the scroll-in + for _ = 1, 400 do + local cur = battle.current + if cur and cur.text and cur.text:find("defeated", 1, true) then break end + U.tap(game, "a") + U.wait(4) + end + local cur = battle.current + check("stopped on the defeat line", + cur ~= nil and cur.text ~= nil + and cur.text:find("defeated", 1, true) ~= nil) + if not U.shot(game, DIR .. "/bug282_1_defeat_text.png") then + U.log("FAIL could not capture the defeat line") + end + + -- ---- hand off ---------------------------------------------------------- + U.log("The fight is won and the defeat line is up. Press A once, then") + U.log("watch without touching anything for a couple of seconds.") + U.log("His picture should scroll in from the right and settle two tiles") + U.log("right of the battle slot with no pokeballs beside it, pause, print") + U.log("his own line, then the prize money, and only then cut back to") + U.log("Route 3 (#282). The CHARMANDER evolves straight off the fight.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/victory_road_reset_bug258_test.lua b/tests/drivers/victory_road_reset_bug258_test.lua new file mode 100644 index 00000000..ea9d3395 --- /dev/null +++ b/tests/drivers/victory_road_reset_bug258_test.lua @@ -0,0 +1,168 @@ +-- Driver: the Victory Road boulder puzzle resets on re-entry via Route 23 +-- (#258). scripts/Route23.asm:8 clears the 2F/3F switch events on every entry +-- and VictoryRoad2F.asm:19 clears 1F's, so each floor restamps its own block. +-- POKEPORT_DRIVER=tests/drivers/victory_road_reset_bug258_test.lua \ +-- POKEPORT_IDENTITY=bug258 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \ +-- SHOT_DIR=/tmp/shots love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local mapScripts = require("data.scripts.init") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local SW1F = "EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH" + local SW2A = "EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1" + local SW2B = "EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2" + local SW3A = "EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1" + local SW3B = "EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2" + + -- Barriers are BLOCK coords: block (bx,by) covers cells (2bx,2by) .. + -- (2bx+1,2by+1). sx/sy is the walkable cell immediately west of each one, + -- so the barrier fills the view just right of the player. + local B1F = { map = "VICTORY_ROAD_1F", bx = 4, by = 6, open = 0x1D, shut = 0x25, + sx = 7, sy = 12 } + local B2A = { map = "VICTORY_ROAD_2F", bx = 3, by = 4, open = 0x15, shut = 0x37, + sx = 5, sy = 8 } + local B2B = { map = "VICTORY_ROAD_2F", bx = 11, by = 7, open = 0x1D, shut = 0x25, + sx = 21, sy = 14 } + local B3 = { map = "VICTORY_ROAD_3F", bx = 3, by = 5, open = 0x1D, shut = 0x25, + sx = 5, sy = 10 } + -- the hole at 3F (23,15) and the boulder that lives beside it + local HOLE = { x = 23, y = 15 } + local ROCK3F = { name = "VICTORYROAD3F_BOULDER4", x = 22, y = 15, sx = 21, sy = 15 } + local ROCK2F = { name = "VICTORYROAD2F_BOULDER3", x = 23, y = 16, sx = 22, sy = 16 } + + -- ---- preconditions ----------------------------------------------------- + -- a missing onEnter, a renamed object and a barrier that never redraws all + -- look the same on screen: the same picture twice + + for _, id in ipairs({ "ROUTE_23", "VICTORY_ROAD_1F", "VICTORY_ROAD_2F", + "VICTORY_ROAD_3F" }) do + local h = mapScripts.get(id) + check(id .. " has an onEnter hook", h ~= nil and type(h.onEnter) == "function") + end + + local function blockAt(mapId, bx, by) + local def = game.data.maps[mapId] + return def.blocks[by * def.width + bx + 1] + end + + local function objectNamed(mapId, name) + for _, o in ipairs(game.data.maps[mapId].objects or {}) do + if o.name == name then return o end + end + return nil + end + check("VICTORY_ROAD_3F really has " .. ROCK3F.name, + objectNamed("VICTORY_ROAD_3F", ROCK3F.name) ~= nil) + check("VICTORY_ROAD_2F really has " .. ROCK2F.name, + objectNamed("VICTORY_ROAD_2F", ROCK2F.name) ~= nil) + + -- ---- helpers ----------------------------------------------------------- + local function goto_(mapId, x, y, facing) + U.teleport(game, mapId, x, y, facing or "right") + U.wait(12) + return game.overworld + end + + -- stand west of a barrier and record the live block id, so the screenshot is + -- never the only evidence + local function shootBarrier(b, tag, wantOpen) + goto_(b.map, b.sx, b.sy, "right") + local got = blockAt(b.map, b.bx, b.by) + local want = wantOpen and b.open or b.shut + check(("%s block (%d,%d) is 0x%02X (%s) -- %s") + :format(b.map, b.bx, b.by, want, wantOpen and "open" or "solid rock", tag), + got == want) + if got ~= want then + U.log((" actually 0x%02X"):format(got or -1)) + end + local path = ("%s/bug258_%s.png"):format(SHOT_DIR, tag) + if not U.shot(game, path) then check("screenshot reached disk: " .. tag, false) end + end + + local function npcNamed(ow, name) + for _, n in ipairs(ow.npcs or {}) do + if n.def and n.def.name == name then return n end + end + return nil + end + + -- ---- 2F: solve both switches, then walk Route 23 ----------------------- + game.save.flags[SW2A] = true + game.save.flags[SW2B] = true + shootBarrier(B2A, "2f_barrier1_solved", true) + shootBarrier(B2B, "2f_barrier2_solved", true) + + goto_("ROUTE_23", 8, 71, "down") + check("Route 23 cleared 2F switch 1", not game.save.flags[SW2A]) + check("Route 23 cleared 2F switch 2", not game.save.flags[SW2B]) + check("Route 23 cleared 3F switch 1", not game.save.flags[SW3A]) + check("Route 23 cleared 3F switch 2", not game.save.flags[SW3B]) + + shootBarrier(B2A, "2f_barrier1_after_route23", false) + shootBarrier(B2B, "2f_barrier2_after_route23", false) + + -- ---- 3F: same barrier, and the boulder that fell through the hole ------ + game.save.flags[SW3A] = true + shootBarrier(B3, "3f_barrier_solved", true) + + -- push the boulder into the hole for real: the map's own object moved onto + -- (23,15), then onBoulderMoved, the same call checkBoulderPush makes + local ow = goto_("VICTORY_ROAD_3F", ROCK3F.sx, ROCK3F.sy, "right") + local rock = npcNamed(ow, ROCK3F.name) + if check("the 3F boulder is standing beside the hole", rock ~= nil) then + U.shot(game, SHOT_DIR .. "/bug258_3f_boulder_before_hole.png") + rock.cellX, rock.cellY = HOLE.x, HOLE.y + rock.px, rock.py = HOLE.x * 16, HOLE.y * 16 + local hooks = mapScripts.get("VICTORY_ROAD_3F") + hooks.onBoulderMoved(game, ow, rock) + U.wait(10) + check("the hole sets 3F switch 2 (CheckAndSetEvent)", game.save.flags[SW3B] == true) + check("the boulder is gone from 3F", npcNamed(game.overworld, ROCK3F.name) == nil) + U.shot(game, SHOT_DIR .. "/bug258_3f_boulder_gone.png") + end + + local ow2 = goto_("VICTORY_ROAD_2F", ROCK2F.sx, ROCK2F.sy, "right") + check("the boulder has arrived on 2F beside switch 2", + npcNamed(ow2, ROCK2F.name) ~= nil) + U.shot(game, SHOT_DIR .. "/bug258_2f_boulder_arrived.png") + + goto_("ROUTE_23", 8, 71, "down") + shootBarrier(B3, "3f_barrier_after_route23", false) + + local ow3 = goto_("VICTORY_ROAD_3F", ROCK3F.sx, ROCK3F.sy, "right") + check("after Route 23 the boulder is back beside the 3F hole", + npcNamed(ow3, ROCK3F.name) ~= nil) + U.shot(game, SHOT_DIR .. "/bug258_3f_boulder_back.png") + + local ow4 = goto_("VICTORY_ROAD_2F", ROCK2F.sx, ROCK2F.sy, "right") + check("and the 2F copy is gone again", npcNamed(ow4, ROCK2F.name) == nil) + U.shot(game, SHOT_DIR .. "/bug258_2f_boulder_gone.png") + + -- ---- 1F: entering 2F closes the barrier behind you --------------------- + game.save.flags[SW1F] = true + shootBarrier(B1F, "1f_barrier_solved", true) + goto_("VICTORY_ROAD_2F", B2A.sx, B2A.sy, "right") + check("entering 2F cleared the 1F switch event", not game.save.flags[SW1F]) + shootBarrier(B1F, "1f_barrier_after_2f", false) + + -- ---- hand off ---------------------------------------------------------- + -- leave the player looking at the reset 3F barrier + game.save.flags[SW3A] = nil + goto_(B3.map, B3.sx, B3.sy, "right") + + U.log("Victory Road 3F, just west of the barrier at block (3,5). The puzzle") + U.log("was solved and reset once already, so expect solid rock and the boulder") + U.log("back beside the 3F hole at (22,15); #258 was the gap staying open.") + U.log("Shots in " .. SHOT_DIR .. "/bug258_*.png (_solved / _after_route23).") + U.log("A switch you solve now still opens its barrier while you stay on 3F.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/viridian_oldman_bug234_test.lua b/tests/drivers/viridian_oldman_bug234_test.lua new file mode 100644 index 00000000..c5e0d9a1 --- /dev/null +++ b/tests/drivers/viridian_oldman_bug234_test.lua @@ -0,0 +1,230 @@ +-- Driver: the Viridian old-man swap is re-applied for an imported save (#234). +-- pokered data/maps/toggleable_objects.asm:48 ships the sleeper ON and the +-- walker OFF; only OaksLabOakGivesPokedexScript flips the pair. GenSave does +-- not model wToggleableObjectFlags, so story.lua's onEnter re-derives it. +-- POKEPORT_DRIVER=tests/drivers/viridian_oldman_bug234_test.lua \ +-- POKEPORT_IDENTITY=bug234 POKEPORT_VERSION=red SHOT_DIR=/tmp/shots love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local mapScripts = require("data.scripts.init") + local OverworldState = require("src.world.OverworldController") + local Pokemon = require("src.pokemon.Pokemon") + + local MAP = "VIRIDIAN_CITY" + local SLEEPER = "VIRIDIANCITY_OLD_MAN_SLEEPY" + local WALKER = "VIRIDIANCITY_OLD_MAN" + local MAP_LABEL = "ViridianCity" + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + -- ../pokered/data/maps/objects/ViridianCity.asm: + -- object_event 18, 9, SPRITE_GAMBLER_ASLEEP, STAY, NONE, ..._OLD_MAN_SLEEPY + -- object_event 17, 5, SPRITE_GAMBLER, WALK, LEFT_RIGHT, ..._OLD_MAN + local SLEEPER_XY = { x = 18, y = 9 } + local WALKER_XY = { x = 17, y = 5 } + -- (18,7) sits in the three-cell north corridor with the sleeper's patch two + -- cells south and the walker two north, so one screen holds both + local VIEW = { x = 18, y = 7, facing = "down" } + -- (17,6) is the only free floor under the walker's home cell ((16,6) is wall) + local TALK = { x = 17, y = 6, facing = "up" } + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local function objDef(name) + for _, o in ipairs(game.data.maps[MAP].objects or {}) do + if o.name == name then return o end + end + return nil + end + + local function npcNamed(name) + local ow = game.overworld + for _, n in ipairs((ow and ow.npcs) or {}) do + if n.def and n.def.name == name then return n end + end + return nil + end + + -- ---- preconditions the eye cannot check -------------------------------- + local hooks = mapScripts.get(MAP) + check(MAP .. " has a map-script table", type(hooks) == "table") + -- a base file that replaces the whole M.VIRIDIAN_CITY entry leaves the + -- sleeper exactly where the bug left him, with nothing on screen to say why + check(MAP .. ".onEnter survived the base-script merge", + type(hooks) == "table" and type(hooks.onEnter) == "function") + + local sleeperDef, walkerDef = objDef(SLEEPER), objDef(WALKER) + check(SLEEPER .. " exists in the map data", sleeperDef ~= nil) + check(WALKER .. " exists in the map data", walkerDef ~= nil) + if sleeperDef then + check(("%s sits at (%d,%d) as in ViridianCity.asm") + :format(SLEEPER, SLEEPER_XY.x, SLEEPER_XY.y), + sleeperDef.x == SLEEPER_XY.x and sleeperDef.y == SLEEPER_XY.y) + -- toggleable_objects.asm has him ON, so the extractor must NOT mark him + -- hidden: that default is the whole reason he is still there + check(SLEEPER .. " defaults to VISIBLE (toggle_object_state ... ON)", + not sleeperDef.hidden) + end + if walkerDef then + check(("%s sits at (%d,%d) as in ViridianCity.asm") + :format(WALKER, WALKER_XY.x, WALKER_XY.y), + walkerDef.x == WALKER_XY.x and walkerDef.y == WALKER_XY.y) + check(WALKER .. " defaults to HIDDEN (toggle_object_state ... OFF)", + walkerDef.hidden == true) + check(WALKER .. " is the pacing GAMBLER, not the asleep sprite", + walkerDef.sprite == "SPRITE_GAMBLER") + end + + -- A at the walker has to reach the catch-tutorial script, or the right + -- sprite in the right place is still the wrong NPC + local talkRows = mapScripts.talkScript(MAP, "TEXT_VIRIDIANCITY_OLD_MAN") + check("TEXT_VIRIDIANCITY_OLD_MAN has a talk script", + type(talkRows) == "table" and #talkRows > 0) + local sawAsk, sawDemo = false, false + for _, row in ipairs(talkRows or {}) do + if row[1] == "ask" then sawAsk = true end + if row[1] == "old_man_demo" then sawDemo = true end + end + check("the walker's script opens the \"in a hurry?\" ask", sawAsk) + check("the walker's script leads into the catch demo", sawDemo) + -- ViridianCityOldManText vs ViridianCityOldManSleepyText are one label apart + -- in the ROM, so check the pointer lands on his line and not the sleeper's + local hurry = game.data:resolveText(MAP_LABEL, "TEXT_VIRIDIANCITY_OLD_MAN") + check("TEXT_VIRIDIANCITY_OLD_MAN resolves to a string", + type(hurry) == "string" and hurry ~= "") + check("it is the coffee/hurry line, not the sleeper's grumble", + type(hurry) == "string" and hurry:find("hurry", 1, true) ~= nil) + if type(hurry) == "string" then + U.log("walker's opening line reads:", (hurry:gsub("\n", " / "))) + end + local sleepyText = game.data:resolveText(MAP_LABEL, + "TEXT_VIRIDIANCITY_OLD_MAN_SLEEPY") + check("the sleeper's own line still resolves (he is not deleted, just hidden)", + type(sleepyText) == "string" + and sleepyText:find("private", 1, true) ~= nil) + + -- a playable party so the hand-off is a real game, not a locked window + game.save.party = { + Pokemon.new(game.data, "CHARMANDER", 12), + Pokemon.new(game.data, "PIDGEY", 10), + } + + -- ---- BEFORE control: no Pokedex ---------------------------------------- + -- Proof that the sleeper renders at all; without it, "the ground is empty" + -- could just mean the object never spawns. + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_GOT_POKEDEX = nil + game.save.objectToggles = {} -- exactly what an import leaves behind + U.teleport(game, MAP, VIEW.x, VIEW.y, VIEW.facing) + U.wait(20) + + local ctrlSleeper = npcNamed(SLEEPER) + local ctrlWalker = npcNamed(WALKER) + check("BEFORE (no Pokedex): the sleeper is on the map", ctrlSleeper ~= nil) + if ctrlSleeper then + check(("BEFORE: he is lying on (%d,%d)"):format(SLEEPER_XY.x, SLEEPER_XY.y), + ctrlSleeper.cellX == SLEEPER_XY.x and ctrlSleeper.cellY == SLEEPER_XY.y) + end + check("BEFORE: the walking old man is NOT on the map", ctrlWalker == nil) + if not U.shot(game, SHOT_DIR .. "/bug234_before_pokedex.png") then + U.log("FAIL could not capture the BEFORE screenshot") + else + U.log("captured", SHOT_DIR .. "/bug234_before_pokedex.png") + end + + -- ---- AFTER: the reported case ------------------------------------------ + -- EVENT_GOT_POKEDEX set and objectToggles empty is byte-for-byte the state + -- a converted .sav arrives in; re-entering the map is what the reporter did. + game.save.flags.EVENT_GOT_POKEDEX = true + game.save.objectToggles = {} + U.teleport(game, MAP, VIEW.x, VIEW.y, VIEW.facing) + U.wait(20) + + local toggles = (game.save.objectToggles or {})[MAP] or {} + check("onEnter recorded HideObject TOGGLE_LYING_OLD_MAN", + toggles[SLEEPER] == false) + check("onEnter recorded ShowObject TOGGLE_OLD_MAN", toggles[WALKER] == true) + if sleeperDef then + check("objectVisible now says the sleeper is hidden", + OverworldState.objectVisible(game.save, MAP, sleeperDef) == false) + end + if walkerDef then + check("objectVisible now says the walker is shown", + OverworldState.objectVisible(game.save, MAP, walkerDef) == true) + end + + local liveSleeper, liveWalker = npcNamed(SLEEPER), npcNamed(WALKER) + check("AFTER: the sleeper is gone from the live NPC list", liveSleeper == nil) + check("AFTER: the walking old man is in the live NPC list", liveWalker ~= nil) + local ow = game.overworld + check(("AFTER: nothing occupies (%d,%d) any more") + :format(SLEEPER_XY.x, SLEEPER_XY.y), + ow and ow:npcAtCell(SLEEPER_XY.x, SLEEPER_XY.y) == nil) + if liveWalker then + U.log(("walker is pacing around (%d,%d)"):format(liveWalker.cellX, liveWalker.cellY)) + end + if not U.shot(game, SHOT_DIR .. "/bug234_after_pokedex.png") then + U.log("FAIL could not capture the AFTER screenshot") + else + U.log("captured", SHOT_DIR .. "/bug234_after_pokedex.png") + end + + -- ---- walk up to him and talk, so the box is open on hand-off ---------- + -- He paces LEFT_RIGHT, so TALK is only right while he is on his home column; + -- fall back to any free walkable neighbour of wherever he actually is. + local stand = TALK + if liveWalker and not (ow.map:isWalkableCell(stand.x, stand.y) + and not ow:npcAtCell(stand.x, stand.y)) then + -- {dx, dy, facing}: offset from the walker to the stand cell, plus the + -- direction that looks back at him (+1 on x means facing left) + local sides = { { 0, 1, "up" }, { 0, -1, "down" }, + { 1, 0, "left" }, { -1, 0, "right" } } + for _, s in ipairs(sides) do + local cx, cy = liveWalker.cellX + s[1], liveWalker.cellY + s[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log(("approach cell (%d,%d) is blocked, standing on") + :format(TALK.x, TALK.y), cx, cy, "facing", s[3]) + stand = { x = cx, y = cy, facing = s[3] } + break + end + end + end + U.teleport(game, MAP, stand.x, stand.y, stand.facing) + U.wait(15) + + local talked = false + for _ = 1, 90 do + local cur = game.overworld + local man = npcNamed(WALKER) + if cur and man then + local fx, fy = cur.player:facingCell() + if cur:npcAtCell(fx, fy) == man then + U.tap(game, "a") + U.wait(30) + if game.stack:top() ~= cur then + talked = true + break + end + end + end + U.wait(4) + end + check("pressing A at the walking old man opened his box", talked) + if talked then + if U.shot(game, SHOT_DIR .. "/bug234_oldman_talk.png") then + U.log("captured", SHOT_DIR .. "/bug234_oldman_talk.png") + end + end + + -- ---- hand off ----------------------------------------------------------- + U.log("You are under the walking old man at (17,5) with his box already up.") + U.log("With the Pokedex, (18,9) should be bare ground and a GAMBLER should") + U.log("be pacing at (17,5); #234 was the sleeper still lying at (18,9) and") + U.log("nobody at (17,5). The other imported-save toggles are a separate job.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/wild_cry_bug303_test.lua b/tests/drivers/wild_cry_bug303_test.lua index 8e4e4c36..85e1153b 100644 --- a/tests/drivers/wild_cry_bug303_test.lua +++ b/tests/drivers/wild_cry_bug303_test.lua @@ -1,41 +1,16 @@ -- Driver: manual audio check for the wild battle intro cry (#303). --- --- pokered engine/battle/core.asm:9-100, SlidePlayerAndEnemySilhouettesOnScreen, --- ends with `jpfar PrintBeginningBattleText`, and that routine --- (engine/battle/common_text.asm:10-19) is: --- ld a, [wEnemyMonSpecies2] --- call PlayCry --- ld hl, WildMonAppearedText --- ... --- call PrintText --- so the cry sounds at the instant the silhouettes finish sliding in and the --- "Wild X appeared!" box opens, not before and not after. The enemy HP bar --- comes later still: _InitBattleCommon only reaches DrawEnemyHUDAndHPBar --- (core.asm:6762) once that text has been dismissed. --- --- Two wrong versions of this have shipped. First the cry was queued behind --- the intro text, so it waited on the player's A press. Then it was queued --- ahead of the text but the message queue was never held for the slide, so --- it fired on the slide's first frame, a full 40 frames early. The queue --- hold is asserted in tests/parity_battle_intro_cry.lua; the thing no test --- can judge is whether the cry and the box land together to an ear. --- --- Do NOT add POKEPORT_SPEED to this run. Fast-forward scales only the logic --- clock, while audio runs on its own real-time 60 Hz accumulator in --- Game:update (src/core/Game.lua) -- so the two halves of the exact --- coincidence under test would drift apart and the run would prove nothing. --- --- POKEPORT_DRIVER=tests/drivers/wild_cry_bug303_test.lua \ --- POKEPORT_IDENTITY=bug303 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +-- pokered SlidePlayerAndEnemySilhouettesOnScreen (engine/battle/core.asm) +-- ends in PrintBeginningBattleText (engine/battle/common_text.asm:10-19), +-- which calls PlayCry then PrintText, so the cry lands with the "Wild X +-- appeared!" box. No POKEPORT_SPEED: audio has its own real-time clock. +-- POKEPORT_DRIVER=tests/drivers/wild_cry_bug303_test.lua POKEPORT_IDENTITY=bug303 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . return function(game) local U = dofile("tests/drivers/util.lua") local Pokemon = require("src.pokemon.Pokemon") - -- Route 1 is the shortest walk to a wild encounter from a fresh start and - -- its table is all PIDGEY and RATTATA, whose cries are short and distinct. - -- (10, 6) is the west end of the northern grass patch -- taken from - -- data/generated/maps.lua, not guessed, and re-derived below if a map edit - -- ever moves it. + -- Route 1 is all PIDGEY and RATTATA, whose cries are short and distinct. + -- (10, 6) is the west end of the northern grass patch, read out of + -- data/generated/maps.lua and re-derived below if a map edit moves it. local MAP = "ROUTE_1" local GRASS = { x = 10, y = 6 } @@ -44,9 +19,8 @@ return function(game) return ok end - -- ---- preconditions the ear cannot check -------------------------------- -- Sound.playCry reads data.audio.cries[species]; a missing key is a silent - -- no-op with no error, which sounds exactly like the bug. + -- no-op with no error, which sounds exactly like the bug local cries = game.data.audio and game.data.audio.cries check("data.audio.cries resolves", cries ~= nil) local encounters = game.data.encounters and game.data.encounters[MAP] @@ -78,19 +52,16 @@ return function(game) "raise SFX VOL in OPTION first") end - -- ---- give the player something to fight with --------------------------- if #game.save.party == 0 then table.insert(game.save.party, Pokemon.new(game.data, "SQUIRTLE", 12)) U.log("party was empty; added a level 12 SQUIRTLE") end - -- ---- park the player in the grass -------------------------------------- U.teleport(game, MAP, GRASS.x, GRASS.y, "up") U.wait(10) - -- the hard-coded cell stopping being grass (map edit, or a mod) would park - -- the player on stone where no encounter can ever roll, which looks exactly - -- like a broken fix; sweep for a real one instead + -- if a map edit or a mod turns that cell to stone no encounter can ever + -- roll, which looks exactly like a broken fix; sweep for a real one local function firstGrassCell(map) for y = 0, (map.heightCells or 0) - 1 do for x = 0, (map.widthCells or 0) - 1 do @@ -117,24 +88,13 @@ return function(game) game.overworld and game.overworld.player.cellX, game.overworld and game.overworld.player.cellY) - -- ---- say what to listen for, THEN trigger it ---------------------------- - -- The moment under test is about a second long and cannot be replayed - -- once it has passed, so the ear has to be ready before the encounter - -- rolls. Print first, pause, then walk. - U.log("........................................................") - U.log("LISTEN NOW: a wild encounter is about to be walked into for you.") - U.log(" RIGHT: the two silhouettes slide in, and the moment they land") - U.log(" the cry sounds AT THE SAME TIME as the \"Wild X") - U.log(" appeared!\" box opens. The HP bar only shows up after") - U.log(" you dismiss that box -- that is correct, not a bug.") - U.log(" BUG #303 sounds like: the cry fires while the silhouettes are") - U.log(" still sliding, well before any text -- or (the older") - U.log(" form) not until after you press A to clear the box.") - U.log(" ALSO WRONG: the cry sounds twice, or a trainer battle now cries") - U.log(" at the wrong moment too -- trainers should stay silent") - U.log(" until the foe's first mon is actually sent out.") - U.log("........................................................") - U.log("walking into the grass in 3 seconds -- ears up") + -- the moment lasts about a second and cannot be replayed, so say what to + -- listen for before the encounter rolls, not after + U.log("An encounter is about to be walked into for you. The cry should") + U.log("sound the instant the silhouettes land and the \"Wild X appeared!\"") + U.log("box opens (#303 fired it mid-slide). The HP bar only arrives once") + U.log("you clear that box, which is correct.") + U.log("walking into the grass in 3 seconds, ears up") U.wait(180) -- ---- walk until the encounter rolls ------------------------------------ @@ -146,8 +106,7 @@ return function(game) return nil end - -- pace back and forth over the grass; each step gets its own encounter - -- roll, and the transition wipe puts the battle on the stack under us + -- pace back and forth; each step gets its own encounter roll local DIRS = { "up", "down", "left", "right" } local battle for i = 1, 400 do @@ -167,11 +126,9 @@ return function(game) U.log("no encounter after 400 steps -- walk into the grass yourself") end - -- ---- hand off, then stay out of the way -------------------------------- - U.log("........................................................") - U.log("Input is yours from here on -- run from the battle and walk back") - U.log("into the grass to hear it as many times as you want.") - U.log("........................................................") + -- hand off, then stay out of the way + U.log("Controls are yours; run from the battle and walk back into the") + U.log("grass to hear it again.") while true do coroutine.yield() diff --git a/tests/mod_qol_hooks_tests.lua b/tests/mod_qol_hooks_tests.lua index 6ce77dfe..2d4157ef 100644 --- a/tests/mod_qol_hooks_tests.lua +++ b/tests/mod_qol_hooks_tests.lua @@ -43,7 +43,7 @@ do field = { playerSprites = { walk = "SPRITE_RED" } }, constants = { world = { stepFrames = 16, bikeStepFrames = 8, turnFrames = 2 } }, } - -- FieldDefaults reads from data; Player.new needs Collision for tryMove — + -- FieldDefaults reads from data; Player.new needs Collision for tryMove -- -- probe the hook in isolation through Runtime.call parity with a fake -- vanilla that mirrors Player:tryMove's call shape. local unsub = wrap("movement.speed", function(next, frames, ctx) diff --git a/tests/parity_ai_switch_faint.lua b/tests/parity_ai_switch_faint.lua index da66bdbb..068d1f9e 100644 --- a/tests/parity_ai_switch_faint.lua +++ b/tests/parity_ai_switch_faint.lua @@ -113,7 +113,7 @@ end -- #162: SHIFT style with 2+ party slots announces the next mon and -- offers a free switch (TrainerAboutToUseText + YES/NO). Party count --- gates the offer (pokered wPartyCount), not living-HP count — a fainted +-- gates the offer (pokered wPartyCount), not living-HP count -- a fainted -- reserve still unlocks the prompt. do local Game = freshGame() @@ -169,4 +169,50 @@ do check(not about, "SET style skips about-to-use") end +-- #275: taking the SHIFT offer hands the whole exp share to the mon coming +-- in. EnemySendOutFirstMon zeroes wPartyGainExpFlags before jumping to +-- SwitchPlayerMon, which FLAG_SETs only the incoming mon's bit +-- (core.asm:1436-1443, 2424-2433), so the mon that was out when the enemy +-- fainted stops counting; leaving it in halved the switch-in's exp. +do + local Game = freshGame() + Game.save.options.battleStyle = "shift" + local reserve = Pokemon.new(Data, "SQUIRTLE", 40) + table.insert(Game.save.party, reserve) + local b = BattleState.newTrainer(Game, "OPP_YOUNGSTER", 1) + b.enemyParty[1].hp = 0 + b.enemyIndex = 1 + b.enemy.mon = b.enemyParty[1] + b.participants = { [Game.save.party[1]] = true } + -- the prompt's YES opens the battle party menu; answer it in place + local Screens = require("src.ui.Screens") + local origPush = Screens.push + Screens.push = function(_, id, opts) + if id == "PartyMenu" and opts and opts.onSwitch then + opts.onSwitch(reserve) + end + end + local ok, err = pcall(function() + b:enemyMonFainted() + local n = 0 + while #b.queue > 0 and n < 400 do + n = n + 1 + local item = table.remove(b.queue, 1) + if item.fn then + b.nextInsert = 0 + item.fn() + elseif item.choice and item.text + and item.text:find("change POKéMON", 1, true) then + item.choice(true) -- take the free switch + end + end + end) + Screens.push = origPush + check(ok, "the SHIFT switch pumped without error: " .. tostring(err)) + check(b.player.mon == reserve, "the SHIFT switch sent the reserve out") + check(b.participants[reserve] == true, "the switch-in gains exp") + check(b.participants[Game.save.party[1]] == nil, + "the mon that was out when the enemy fainted drops out (#275)") +end + S.finish() diff --git a/tests/parity_android_permissions.lua b/tests/parity_android_permissions.lua new file mode 100644 index 00000000..ebe72ee6 --- /dev/null +++ b/tests/parity_android_permissions.lua @@ -0,0 +1,129 @@ +-- #287: the Android build must ship android.permission.INTERNET, or every +-- link-play socket dies with EPERM before it reaches the network. The +-- permission is declared in the tracked manifest and scripts/build_android.sh +-- rewrites that same file on every build, so both halves are asserted here +-- plus a replay of the real trim over the real manifest. + +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("android link permissions") +local check, eq = S.check, S.eq + +local MANIFEST = "mobile/android/app/src/main/AndroidManifest.xml" +local SCRIPT = "scripts/build_android.sh" +local INTERNET = "android.permission.INTERNET" + +local function readFile(path) + local f = io.open(path, "rb") + if not f then return nil end + local body = f:read("*a") + f:close() + return body +end + +local function writeFile(path, body) + local f = io.open(path, "wb") + if not f then return false end + f:write(body) + f:close() + return true +end + +local manifest = readFile(MANIFEST) +local script = readFile(SCRIPT) +check(type(manifest) == "string", MANIFEST .. " is readable") +check(type(script) == "string", SCRIPT .. " is readable") + +-- ---------------------------------------------------------------- the two files +-- The trim is only dangerous because it edits the tracked manifest in place; if +-- that stops being true, everything below tests a file the build never touches. +if script then + local target = script:match('local manifest="([^"]+)"') + check(target ~= nil, "build_android.sh names the manifest it rewrites") + check(target ~= nil + and target:find("app/src/main/AndroidManifest.xml", 1, true) ~= nil, + "the per-build permission trim rewrites the checked-in manifest, so" + .. " both halves of #287 have to hold at once") +end + +if manifest then + check(manifest:find(INTERNET, 1, true) ~= nil, + "the manifest declares INTERNET (link play cannot open a socket" + .. " without it -- #287)") + check(manifest:find("android.permission.VIBRATE", 1, true) ~= nil, + "VIBRATE is still declared (love.system.vibrate)") + check(manifest:find("android.permission.BLUETOOTH", 1, true) ~= nil, + "BLUETOOTH is still declared (optional gamepads)") +end + +-- Read the python tuple only, never the comment around it: that comment now +-- mentions INTERNET on purpose, and matching it would pass for the wrong reason. +if script then + local tuple = script:match("for perm in %((.-)%):") + check(tuple ~= nil, "build_android.sh still has a permission strip list") + if tuple then + check(tuple:find("INTERNET", 1, true) == nil, + "the strip list no longer deletes INTERNET (#287)") + check(tuple:find("RECORD_AUDIO", 1, true) ~= nil, + "RECORD_AUDIO is still stripped (the game records no audio)") + check(tuple:find("WRITE_EXTERNAL_STORAGE", 1, true) ~= nil, + "WRITE_EXTERNAL_STORAGE is still stripped (legacy storage)") + end +end + +-- ---------------------------------------------------------------- the real trim +-- Run the build's own python block over a copy of the real manifest and look at +-- what an APK would carry. Skipped with a note, not a failure, where python3 +-- is missing (the build needs python3 anyway). +local function haveCommand(name) + local probe = io.popen("command -v " .. name .. " 2>/dev/null") + if not probe then return false end + local out = probe:read("*a") + probe:close() + return out ~= nil and out:match("%S") ~= nil +end + +if manifest and script and haveCommand("python3") then + local block = script:match("python3 %- \"%$manifest\" <<'PY'\n(.-)\nPY\n") + check(block ~= nil, "the manifest trim is a python heredoc we can replay") + if block then + local tmpDir = (os.getenv("TMPDIR") or "/tmp"):gsub("[/\\]+$", "") + local stamp = ("%d_%d"):format(os.time(), math.random(1, 999999)) + local pyPath = tmpDir .. "/pokeport_bug287_trim_" .. stamp .. ".py" + local xmlPath = tmpDir .. "/pokeport_bug287_manifest_" .. stamp .. ".xml" + local wrotePy = writeFile(pyPath, block .. "\n") + local wroteXml = writeFile(xmlPath, manifest) + check(wrotePy and wroteXml, "staged the trim and a manifest copy in " .. tmpDir) + if wrotePy and wroteXml then + os.execute(('python3 "%s" "%s"'):format(pyPath, xmlPath)) + local trimmed = readFile(xmlPath) or "" + check(trimmed:find(INTERNET, 1, true) ~= nil, + "INTERNET survives a real build's permission trim -- the APK can" + .. " bind and connect (#287)") + check(trimmed:find("RECORD_AUDIO", 1, true) == nil, + "the trim still drops RECORD_AUDIO") + check(trimmed:find("WRITE_EXTERNAL_STORAGE", 1, true) == nil, + "the trim still drops WRITE_EXTERNAL_STORAGE") + check(trimmed:find("android.permission.VIBRATE", 1, true) ~= nil, + "the trim keeps VIBRATE") + check(trimmed:find("android.permission.BLUETOOTH", 1, true) ~= nil, + "the trim keeps BLUETOOTH") + check(trimmed:find("usesCleartextTraffic", 1, true) == nil, + "the trim still removes usesCleartextTraffic (it gates Android's" + .. " own HTTP stacks, never the raw sockets link play uses)") + -- a trim that kept INTERNET but broke the XML fails later, at merge time + local parsed = os.execute(('python3 -c "import sys,xml.etree.ElementTree' + .. ' as E; E.parse(sys.argv[1])" "%s" >/dev/null 2>&1'):format(xmlPath)) + check(parsed == 0 or parsed == true, + "the trimmed manifest is still well-formed XML") + os.remove(pyPath) + os.remove(xmlPath) + end + end +else + print("[#287] python3 not found: replaying the real trim was skipped," + .. " the static checks above still ran") +end + +S.finish() diff --git a/tests/parity_battle_blackout_pals.lua b/tests/parity_battle_blackout_pals.lua new file mode 100644 index 00000000..ea398041 --- /dev/null +++ b/tests/parity_battle_blackout_pals.lua @@ -0,0 +1,199 @@ +-- Parity: SET_PAL_BATTLE_BLACK darkens the whole battle screen while the +-- blackout text is up (#292). HandlePlayerBlackOut (engine/battle/core.asm: +-- 1147-1159) runs the palette command before PrintText, and returns early in +-- OAKS_LAB. SetPal_BattleBlack (engine/gfx/palettes.asm:22-25) sends +-- PAL_BLACK into all four BlkPacket_Battle zones (sgb_packets.asm:220): both +-- HP bars and both mon regions. +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 battle blackout pals") +local check, eq = S.check, S.eq + +local Data = require("src.core.Data") +if not Data.maps then Data:load() end +local Font = require("src.render.Font") +Font.load(Data) + +local BattleState = require("src.battle.BattleState") +local PaletteFX = require("src.render.PaletteFX") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Sound = require("src.core.Sound") +local Music = require("src.core.Music") + +Sound.playCry = function() end +Sound.play = function() end +Sound.playMove = function() end +Sound.playMoveCry = function() end +Sound.stopLoop = function() end +Music.playBattle = function() end +Music.play = function() end + +local press = {} +local function makeGame(party) + local save = SaveData.newGame() + save.party = party + local stack = { states = {} } + function stack:push(state) self.states[#self.states + 1] = state end + function stack:pop() return table.remove(self.states) end + function stack:top() return self.states[#self.states] end + return { data = Data, save = save, stack = stack, + input = { wasPressed = function(_, b) return press[b] == true end } } +end + +local function step(battle) + press.a = true + battle:update(1 / 60) + press.a = false +end + +-- Lose for real: 0 HP everywhere, then run the faint through onFaint. Drain +-- to the onFinish callback, NOT to battle.result: playerMonFainted sets +-- result = "lose" in the same act that queues the blackout lines, so stopping +-- on result would miss every page under test. +local function wipeOut(battle) + local done = false + local prev = battle.onFinish + battle.onFinish = function(r) done = true if prev then prev(r) end end + battle.player.mon.hp = 0 + for _, mon in ipairs(battle.game.save.party) do mon.hp = 0 end + battle.phase = "messages" + battle.nextInsert = 0 + battle:onFaint(battle.player) + local pages, blackAt = {}, {} + for _ = 1, 1200 do + step(battle) + local cur = battle.current + local text = cur and cur.text + if text and pages[#pages] ~= text then + pages[#pages + 1] = text + blackAt[text] = battle.blackedOut and true or false + end + if done then break end + end + return pages, blackAt +end + +local function indexOf(pages, fragment) + for i, p in ipairs(pages) do + if p:find(fragment, 1, true) then return i end + end + return nil +end + +-- ------------------------------------------------------------- the palette +local pack = PaletteFX.pack(Data) +check(pack ~= nil and pack.palettes ~= nil, "the active COLORS pack has palettes") +local BLACK = pack and pack.palettes and pack.palettes.BLACK +check(BLACK ~= nil, + "PAL_BLACK exists in the active pack (data/generated/palettes.lua:86, " + .. "data/palettes_gbc.lua:329)") +if BLACK then + -- sgb_palettes.asm:46 -- color 0 near-white, colors 1..3 near-black + check(BLACK[1][1] > 200 and BLACK[1][2] > 200, + "PAL_BLACK color 0 is the near-white paper") + for i = 2, 4 do + check(BLACK[i][1] < 90 and BLACK[i][2] < 90 and BLACK[i][3] < 90, + "PAL_BLACK color " .. (i - 1) .. " is near-black ink") + end +end + +-- ------------------------------------------------------- a live wild wipe +local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 5) }) +local wild = BattleState.newWild(game, "RATTATA", 40) +wild.onFinish = function() end +wild:enter() +for _ = 1, 400 do + step(wild) + if wild.phase == "menu" then break end +end +eq(wild.phase, "menu", "the wild battle reaches its menu") +eq(wild.blackedOut, nil, "nothing is blacked out while the battle is live") +local before = wild:sgbBattlePals() +check(before ~= nil, "sgbBattlePals builds the four BlkPacket_Battle zones") +if before and BLACK then + check(before[0] ~= BLACK and before[1] ~= BLACK + and before[2] ~= BLACK and before[3] ~= BLACK, + "no zone is PAL_BLACK during normal play") +end + +local pages, blackAt = wipeOut(wild) +eq(wild.result, "lose", "losing the last mon resolves the battle as a loss") +local outOf = indexOf(pages, "out of") +local blacked = indexOf(pages, "blacked") +check(outOf ~= nil, "\" is out of useable POKeMON!\" prints") +check(blacked ~= nil, "\" blacked out!\" prints") +if outOf then + eq(blackAt[pages[outOf]], true, + "the screen is ALREADY dark under the first blackout line " + .. "(RunPaletteCommand runs before PrintText, core.asm:1151-1156)") +end +if blacked then + eq(blackAt[pages[blacked]], true, "and stays dark under the second line") +end + +local after = wild:sgbBattlePals() +check(after ~= nil, "sgbBattlePals still builds four zones once blacked out") +if after and BLACK then + eq(after[0], BLACK, "zone 0 (player HP bar) is PAL_BLACK") + eq(after[1], BLACK, "zone 1 (enemy HP bar) is PAL_BLACK") + eq(after[2], BLACK, "zone 2 (player mon region) is PAL_BLACK") + eq(after[3], BLACK, "zone 3 (enemy mon region) is PAL_BLACK") +end +-- zoneColorsAt reads the same table, so the shade shader cannot disagree with +-- the packet; sample the enemy HP bar corner and the enemy pic +if BLACK then + local barColors = wild:zoneColorsAt(24, 24) + local picColors = wild:zoneColorsAt(120, 24) + check(barColors == nil or barColors == BLACK, + "the enemy HP bar's zone reads PAL_BLACK") + check(picColors == nil or picColors == BLACK, + "the enemy pic's zone reads PAL_BLACK") +end +-- picImage is the funnel every battler pic is drawn through, so it still has +-- to resolve something for the darkened frame +check(wild:picImage(wild.playerBackPic) ~= nil or wild.playerBackPic == nil, + "picImage still resolves a pic while blacked out") + +-- ---------------------------------------------- the Oak's Lab exception +-- core.asm:1147-1149 returns above the palette command when the starter rival +-- wins in OAKS_LAB, so that screen never darkens. +local game2 = makeGame({ Pokemon.new(Data, "BULBASAUR", 5) }) +game2.save.player.map = "OAKS_LAB" +local lab = BattleState.newTrainer(game2, "OPP_RIVAL1", 1) +lab.onFinish = function() end +lab:enter() +for _ = 1, 500 do + step(lab) + if lab.phase == "menu" then break end +end +eq(BattleState.currentMapId(lab), "OAKS_LAB", "the battle knows it is in Oak's lab") +check(BattleState.isOaksLabStarterRival(lab), "and that this is the starter rival") +local labPages = wipeOut(lab) +eq(lab.result, "lose", "the lab rival still wins the battle") +eq(lab.blackedOut, nil, + "the Oak's Lab starter rival never darkens the screen (core.asm:1147-1149)") +check(indexOf(labPages, "blacked") == nil, + "and prints no blackout line either") +if BLACK then + local labPals = lab:sgbBattlePals() + check(labPals == nil or labPals[3] ~= BLACK, + "the lab screen keeps its live palettes") +end + +-- ------------------------------------- Route 22 RIVAL1 still blacks out +-- Same OPP_RIVAL1 class, a different map: only the lab is excepted. +local game3 = makeGame({ Pokemon.new(Data, "BULBASAUR", 5) }) +game3.save.player.map = "ROUTE_22" +local r22 = BattleState.newTrainer(game3, "OPP_RIVAL1", 1) +r22.onFinish = function() end +r22:enter() +for _ = 1, 500 do + step(r22) + if r22.phase == "menu" then break end +end +check(not BattleState.isOaksLabStarterRival(r22), "Route 22 is not the lab") +wipeOut(r22) +eq(r22.blackedOut, true, "a Route 22 RIVAL1 wipe darkens like any other") + +S.finish() diff --git a/tests/parity_battle_intro_chrome.lua b/tests/parity_battle_intro_chrome.lua new file mode 100644 index 00000000..368c357b --- /dev/null +++ b/tests/parity_battle_intro_chrome.lua @@ -0,0 +1,209 @@ +-- Parity test: the battle intro's pokeball rows, HUD chrome and pic slides +-- (#317). PrintBeginningBattleText calls DrawAllPokeballs right before +-- PrintText (engine/battle/common_text.asm:22-27), and a wild battle gets the +-- player's row only (engine/battle/draw_hud_pokeball_gfx.asm:1-7); the enemy +-- HUD comes up after, in _InitBattleCommon (engine/battle/core.asm:6755-6764). +-- Both pics slide off screen before their send-out text (core.asm:236-240, 1308-1310). +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 battle intro chrome") +local check, eq = S.check, S.eq + +local Data = require("src.core.Data") +if not Data.maps then Data:load() end +local Font = require("src.render.Font") +Font.load(Data) + +local BattleState = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Sound = require("src.core.Sound") +local Music = require("src.core.Music") + +-- Silence audio: BattleState reaches both modules through require() at the +-- call site, so patching the fields here is what the battle ends up calling. +Sound.playCry = function() end +Sound.play = function() end +Sound.playMove = function() end +Sound.playMoveCry = function() end +Sound.stopLoop = function() end +Music.playBattle = function() end +Music.play = function() end + +-- stub stack + input, like the other headless battle probes. `press` is the +-- one-frame button state the battle reads through input:wasPressed. +local press = {} +local function makeGame(party) + local save = SaveData.newGame() + save.party = party + local stack = { states = {} } + function stack:push(state) self.states[#self.states + 1] = state end + function stack:pop() return table.remove(self.states) end + function stack:top() return self.states[#self.states] end + return { data = Data, save = save, stack = stack, + input = { wasPressed = function(_, b) return press[b] == true end } } +end + +-- One fixed step with A held. updateQueue only reads the button once a page +-- has typed out, so an early press is ignored and these ordering checks stay +-- independent of the prompt flag they also assert. +local function step(battle) + press.a = true + battle:update(1 / 60) + press.a = false +end + +-- the text of the message row currently on screen, or nil +local function currentText(battle) + local cur = battle.current + return cur and cur.text or nil +end + +-- Record what drawHUDs actually puts on screen this frame. drawBallRow is a +-- method, so an instance field shadows it; Font.draw is the module upvalue +-- BattleState draws HUD strings through. +local function snapshotHUD(battle) + local rows, strings = {}, {} + local realRow = battle.drawBallRow + local realFont = Font.draw + battle.drawBallRow = function(_, party, x, y, dx) + rows[#rows + 1] = { count = #party, x = x, y = y, step = dx } + end + Font.draw = function(text, x, y) strings[#strings + 1] = tostring(text) end + local ok, err = pcall(battle.drawHUDs, battle, 0) + battle.drawBallRow = realRow + Font.draw = realFont + return ok, err, rows, strings +end + +local function drewString(strings, want) + for _, s in ipairs(strings) do + if s:find(want, 1, true) then return true end + end + return false +end + +-- ------------------------------------------------------------------ wild +local party = { Pokemon.new(Data, "BULBASAUR", 50), Pokemon.new(Data, "PIDGEY", 5) } +local game = makeGame(party) +local wild = BattleState.newWild(game, "RATTATA", 2) +wild.onFinish = function() end +wild:enter() + +eq(wild.introBalls, true, "the wild intro opens the DrawAllPokeballs window") +-- introBalls is a plain field, not a queue row, so it cannot displace the +-- wild cry PrintBeginningBattleText plays before PrintText (#303) +check(wild.queue[1] and wild.queue[1].fn ~= nil, + "the cry act is still the first queue row (#303)") +check(wild.queue[2] and wild.queue[2].text == wild.introText, + "the intro text is still the second queue row") + +-- let the silhouette slide land so the intro box is genuinely up +for _ = 1, 45 do wild:update(1 / 60) end +eq(wild.introSlide or 0, 0, "the silhouette slide has landed") +eq(wild.introBalls, true, "the window is still open under the intro text") +eq(currentText(wild), wild.introText, "the intro box is the row on screen") + +local ok, err, rows, strings = snapshotHUD(wild) +check(ok, "drawHUDs runs during the wild intro: " .. tostring(err)) +eq(#rows, 1, "a WILD intro draws exactly one ball row (SetupOwnPartyPokeballs)") +if rows[1] then + eq(rows[1].count, #party, "the row carries every party slot") + eq(rows[1].x, 88, "player ball row x = 88 (wBaseCoordX $60)") + eq(rows[1].y, 80, "player ball row y = 80 (wBaseCoordY $60)") + eq(rows[1].step, 8, "player ball row steps +8 rightward") +end +check(not drewString(strings, wild.enemy.name), + "the enemy HUD is NOT up during the intro box (DrawEnemyHUDAndHPBar " + .. "runs after PrintBeginningBattleText returns)") + +-- the page finishes typing and PromptText's blinking arrow takes over +local prompted = false +for _ = 1, 200 do + if wild.msgPrompt then prompted = true break end + press.a = false + wild:update(1 / 60) +end +check(prompted, "a typed-out intro page raises the prompt flag (blinking arrow)") +eq(wild.introBalls, true, "the ball row is still up while the arrow blinks") + +-- press A: ClearSprites + both ClearScreenAreas, then the enemy HUD +step(wild) +eq(wild.msgPrompt, nil, "the prompt flag clears on the A press") +for _ = 1, 2 do press.a = false wild:update(1 / 60) end +eq(wild.introBalls, nil, "dismissing the intro box closes the window") +local ok2, err2, rows2, strings2 = snapshotHUD(wild) +check(ok2, "drawHUDs runs after the intro box: " .. tostring(err2)) +eq(#rows2, 0, "no ball row survives the dismissal (ClearSprites)") +check(drewString(strings2, wild.enemy.name), + "the enemy HUD appears once the intro box is gone") + +-- the back pic walks off the LEFT edge before "Go! X!" +local slideLow, slideDone, goFrame, shownDuringSlide = 0, nil, nil, true +for f = 1, 240 do + step(wild) + local off = wild:picOffset("back") + if off < slideLow then slideLow = off end + if off < 0 and not wild.showPlayerBack then shownDuringSlide = false end + if not slideDone and off <= -72 then slideDone = f end + if not goFrame and currentText(wild) and currentText(wild):find("Go!", 1, true) then + goFrame = f + end + if goFrame then break end +end +eq(slideLow, -72, "the back pic walks a full 9 tiles off the left edge") +check(shownDuringSlide, "the back pic stays drawn for the whole slide") +check(slideDone ~= nil and goFrame ~= nil and slideDone < goFrame, + "the slide finishes BEFORE the send-out text (core.asm:236-240)") +eq(wild.showPlayerBack, false, "only then is the back pic taken down") +eq(wild:picOffset("back"), 0, "the slide program is cleared with the pic") + +-- --------------------------------------------------------------- trainer +local game2 = makeGame({ Pokemon.new(Data, "BULBASAUR", 50) }) +local tr = BattleState.newTrainer(game2, "OPP_YOUNGSTER", 1) +tr.onFinish = function() end +tr:enter() +eq(tr.introBalls, true, "the trainer intro opens the same window") +for _ = 1, 45 do tr:update(1 / 60) end + +local ok3, err3, rows3 = snapshotHUD(tr) +check(ok3, "drawHUDs runs during the trainer intro: " .. tostring(err3)) +eq(#rows3, 2, "a TRAINER intro draws both ball rows") +if rows3[1] and rows3[2] then + eq(rows3[1].x, 64, "enemy ball row x = 64 (wBaseCoordX $48)") + eq(rows3[1].y, 16, "enemy ball row y = 16 (wBaseCoordY $20)") + eq(rows3[1].step, -8, "enemy ball row steps -8 leftward") + eq(rows3[2].x, 88, "player ball row x = 88") + eq(rows3[2].count, 1, "player row carries this save's one party slot") +end + +-- the foe's pic walks off the RIGHT edge before "X sent out Y!" +local foeHigh, foeDone, sentFrame, foeShown = 0, nil, nil, true +for f = 1, 300 do + step(tr) + local off = tr:picOffset("foe") + if off > foeHigh then foeHigh = off end + if off > 0 and not tr.showEnemyTrainer then foeShown = false end + if not foeDone and off >= 64 then foeDone = f end + local t = currentText(tr) + if not sentFrame and t and t:find("sent", 1, true) then sentFrame = f end + if sentFrame then break end +end +eq(foeHigh, 64, "the foe's pic walks a full 8 tiles off the right edge") +check(foeShown, "the foe's pic stays drawn for the whole slide") +check(foeDone ~= nil and sentFrame ~= nil and foeDone < sentFrame, + "the slide finishes BEFORE TrainerSentOutText (core.asm:1308-1310)") +eq(tr.showEnemyTrainer, false, "only then is the trainer pic taken down") + +-- and the window never reopens: drive the rest of the intro out +for _ = 1, 400 do + step(tr) + if tr.phase == "menu" then break end +end +eq(tr.phase, "menu", "the trainer intro reaches the battle menu") +eq(tr.introBalls, nil, "the DrawAllPokeballs window stays closed") +local ok4, err4, rows4 = snapshotHUD(tr) +check(ok4, "drawHUDs runs at the battle menu: " .. tostring(err4)) +eq(#rows4, 0, "no ball row is drawn once the battle proper starts") + +S.finish() diff --git a/tests/parity_bike_walk_anim.lua b/tests/parity_bike_walk_anim.lua index 1a688825..d0d6a702 100644 --- a/tests/parity_bike_walk_anim.lua +++ b/tests/parity_bike_walk_anim.lua @@ -3,7 +3,7 @@ -- walkPhase used to return 0 whenever moving was false. A step clears -- moving on its final FixedStep tick, so the draw after landing snapped -- to stand even when animClock was mid walk-cycle. Bike steps are 8 --- frames, so they land at animClock % 16 == 8 (walk) every tile — always +-- frames, so they land at animClock % 16 == 8 (walk) every tile -- always -- stuttery. Walking after a bike ride inherits a desynced animClock and -- hit the same stand flash "sometimes." -- diff --git a/tests/parity_box_mon_stats.lua b/tests/parity_box_mon_stats.lua new file mode 100644 index 00000000..d0bf98ae --- /dev/null +++ b/tests/parity_box_mon_stats.lua @@ -0,0 +1,284 @@ +-- Parity / regression: a box mon has no stat block, so the engine derives one +-- on demand (#233 STATS in the box, #304 withdraw). macros/ram.asm's +-- box_struct is a prefix of party_struct that stops before MON_STATS; +-- status_screen.asm:64-77 recomputes on BOX_DATA before it draws, and +-- add_mon.asm's _MoveMon runs CalcStats on the way back to the party. The +-- formula itself is home/move_mon.asm CalcStats, ported in Stats.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.pokemon and Data.pokemon.ODDISH) then Data:load() end +local S = require("tests.harness").suite("parity box mon stats") +local check, eq = S.check, S.eq + +local Stats = require("src.pokemon.Stats") +local SaveData = require("src.core.SaveData") +local Boxes = require("src.pokemon.Boxes") +local HudTiles = require("src.render.HudTiles") + +-- A mon shaped like GenSave.decodeMon's box output: no `stats` key at all. +-- Gen 1 has no HP DV byte, it is the low bit of each of the other four +-- (home/pokemon.asm GetMonHeader), so 13/11/7/5 being odd makes the HP DV +-- 15. The .sav round trip below only compares because of that. +local DVS = { hp = 15, attack = 13, defense = 11, speed = 7, special = 5 } +local STATEXP = { hp = 1200, attack = 900, defense = 400, speed = 0, special = 250 } + +local function boxShapedMon(species, level, hp) + return { + species = species, level = level, exp = 1000, + dvs = { hp = DVS.hp, attack = DVS.attack, defense = DVS.defense, + speed = DVS.speed, special = DVS.special }, + statExp = { hp = STATEXP.hp, attack = STATEXP.attack, + defense = STATEXP.defense, speed = STATEXP.speed, + special = STATEXP.special }, + hp = hp, status = nil, otId = 12345, catchRate = 45, + moves = { { id = "ABSORB", pp = 20 } }, + typeBytes = { 22, 3 }, + } +end + +-- ================================================================== 1 +-- Stats.ensure gives the same numbers as Stats.calc and never leaves a +-- current HP above the maximum it derived. +do + local def = Data.pokemon.ODDISH + local want = Stats.calc(def, 13, DVS, STATEXP) + + local mon = boxShapedMon("ODDISH", 13, 999) -- a PKHeX-tampered current HP + eq(mon.stats, nil, "premise: a box-shaped mon starts with no stat block") + Stats.ensure(def, mon) + check(type(mon.stats) == "table", "ensure gives the box mon a stat block") + for _, key in ipairs(Stats.ORDER) do + eq(mon.stats[key], want[key], "ensure's " .. key .. " matches CalcStats") + end + eq(mon.hp, want.hp, "a tampered 999 HP clamps to the recomputed maximum") + + -- a stored HP under the maximum is what box_struct really holds; keep it + local hurt = boxShapedMon("ODDISH", 13, 7) + Stats.ensure(def, hurt) + eq(hurt.hp, 7, "a stored current HP below the max is kept as stored") + eq(hurt.stats.hp, want.hp, "and the maximum is the derived one") + + -- a fainted box mon stays fainted (the REVIVE case in the party menu) + local fainted = boxShapedMon("ODDISH", 13, 0) + Stats.ensure(def, fainted) + eq(fainted.hp, 0, "a fainted box mon stays at 0 HP") + + -- idempotent, so a vanilla save round-trips with the numbers it was saved + -- with rather than recomputed ones + local party = boxShapedMon("ODDISH", 13, 7) + party.stats = { hp = 1, attack = 2, defense = 3, speed = 4, special = 5 } + local before = party.stats + Stats.ensure(def, party) + check(party.stats == before, "ensure leaves an existing stat block alone") + eq(party.stats.hp, 1, "and does not clamp HP against a block it did not build") + eq(party.hp, 7, "nor rewrite the stored HP") + + -- a species the merged data does not know must not raise here: validate's + -- own quarantine pass owns that mon, and ensure runs before it + local unknown = boxShapedMon("NOT_A_SPECIES", 13, 7) + Stats.ensure(nil, unknown) + eq(unknown.stats, nil, "an unknown species leaves stats nil instead of raising") +end + +-- ================================================================== 2 +-- The real .sav path: encode -> decode is what an imported battery save does +-- to a box mon (SaveFileIO -> SaveConvert.importSav -> GenSave.decode). +do + local GenSave = require("src.save_convert.GenSave") + GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")()) + + local save = SaveData.newGame({ playerName = "RED", rivalName = "BLUE" }) + Boxes.ensure(save) + -- the same mon in the party and in box 1, so the two structs can be + -- compared against each other after the round trip + save.party = { boxShapedMon("ODDISH", 13, 20) } + Stats.ensure(Data.pokemon.ODDISH, save.party[1]) + save.boxes[1] = { boxShapedMon("ODDISH", 13, 20) } + Stats.ensure(Data.pokemon.ODDISH, save.boxes[1][1]) + + local bytes = GenSave.encode(save, Data, nil) + eq(#bytes, GenSave.SAVE_SIZE, "the fixture save encodes to a 32768-byte .sav") + + local imported = GenSave.decode(bytes, Data) + check(type(imported.party[1]) == "table", "the .sav decodes a party mon") + check(type((imported.boxes[1] or {})[1]) == "table", "and a box 1 mon") + local pmon, bmon = imported.party[1], imported.boxes[1][1] + check(type(pmon.stats) == "table", + "premise: party_struct carries stats, so the party mon has them") + eq(bmon.stats, nil, + "premise: box_struct does not, so the imported box mon has none (#233)") + + -- Game:restoreSave runs this on every load, which is the only thing that + -- reaches a save already sitting on disk + SaveData.validate(imported, Data) + -- guarded: without the repair every line below raises the reported crash + -- itself, which would hide the rest of the suite + if check(type(bmon.stats) == "table", "validate repairs the imported box mon") then + local want = Stats.calc(Data.pokemon[bmon.species], bmon.level, bmon.dvs, + bmon.statExp) + for _, key in ipairs(Stats.ORDER) do + eq(bmon.stats[key], want[key], "repaired box " .. key .. " matches CalcStats") + end + eq(bmon.stats.hp, pmon.stats.hp, + "the box copy ends up with the same max HP the party copy stored") + check(bmon.hp <= bmon.stats.hp, "current HP can never sit above the maximum") + -- the exact expression src/render/HudTiles.lua drawHPBar died on + check(type(bmon.stats.hp) == "number", + "mon.stats.hp is a number (the nil index)") + end + + -- the call SummaryMenu:draw and PartyMenu:draw both make (#233 / #304) + local okDraw, err = pcall(HudTiles.drawHPBar, Data, 11, 3, bmon, 1) + check(okDraw, "drawHPBar over a repaired box mon does not raise: " .. tostring(err)) + + -- box_struct has no stat fields, so the repair must not move a byte of the + -- exported .sav + local reexported = GenSave.encode(imported, Data) + eq(#reexported, GenSave.SAVE_SIZE, "the repaired save re-exports to 32768 bytes") + local firstDiff + for i = 1, #bytes do + if bytes:byte(i) ~= reexported:byte(i) then firstDiff = i break end + end + eq(firstDiff, nil, + "the stat repair leaves the .sav byte-identical on export (box_struct " + .. "has no stat fields)") +end + +-- ================================================================== 3 +-- Every list a stats-less mon can sit in: party, all twelve boxes, and the +-- daycare (status_screen.asm treats DAYCARE_DATA like BOX_DATA). +do + local save = SaveData.newGame() + Boxes.ensure(save) + save.party = { boxShapedMon("PIDGEY", 9, 5) } + save.boxes[1] = { boxShapedMon("RATTATA", 4, 3) } + save.boxes[7] = { boxShapedMon("ZUBAT", 22, 40) } + save.daycare = { mon = boxShapedMon("ODDISH", 13, 6) } + + SaveData.validate(save, Data) + check(type(save.party[1].stats) == "table", "validate repairs a party slot") + check(type(save.boxes[1][1].stats) == "table", "validate repairs box 1") + check(type(save.boxes[7][1].stats) == "table", "validate repairs box 7") + check(type(save.daycare.mon.stats) == "table", "validate repairs the daycare mon") + + -- the level clamp runs first, so the derived stats use a sane level + local wild = boxShapedMon("PIDGEY", 250, 5) + local save2 = SaveData.newGame() + Boxes.ensure(save2) + save2.boxes[1] = { wild } + SaveData.validate(save2, Data) + eq(wild.level, 100, "an out-of-range level clamps to 100") + eq(wild.stats and wild.stats.hp, + Stats.calc(Data.pokemon.PIDGEY, 100, wild.dvs, wild.statExp).hp, + "and the derived stats use the clamped level, not the stored one") +end + +-- ================================================================== 4 +-- #233's own site: SummaryMenu.new ports status_screen.asm's +-- .DontRecalculate branch, and Bill's PC hands it the box mon directly. +do + local realSound = package.loaded["src.core.Sound"] + package.loaded["src.core.Sound"] = { play = function() end, + playCry = function() end } + local SummaryMenu = require("src.ui.SummaryMenu") + local mon = boxShapedMon("ODDISH", 13, 20) + local game = { data = Data, save = SaveData.newGame() } + local ok, screen = pcall(SummaryMenu.new, game, mon) + check(ok, "opening STATS on a stats-less box mon does not raise: " + .. tostring(screen)) + check(type(mon.stats) == "table", + "SummaryMenu.new recalculates the block before it draws (#233)") + if ok and screen then + local okDraw, err = pcall(function() + HudTiles.drawHPBar(Data, 11, 3, screen.mon, 1) + end) + check(okDraw, "and its HP bar draws: " .. tostring(err)) + end + package.loaded["src.core.Sound"] = realSound +end + +-- ================================================================== 5 +-- #304's own site: the box -> party transfer, which is the only thing that +-- covers a mon a mod drops into a box mid-session. withdraw is a +-- file-local, reached through upvalues as tests/parity_bills_pc.lua does. +do + local BoxMenu = require("src.ui.BoxMenu") + + local function getUpvalue(fn, name) + local i = 1 + while true do + local n, v = debug.getupvalue(fn, i) + if not n then return nil end + if n == name then return v end + i = i + 1 + end + end + local function setUpvalue(fn, name, val) + local i = 1 + while true do + local n = debug.getupvalue(fn, i) + if not n then return false end + if n == name then debug.setupvalue(fn, i, val); return true end + i = i + 1 + end + end + + local withdraw = getUpvalue(BoxMenu.new, "withdraw") + check(type(withdraw) == "function", "found BoxMenu's withdraw upvalue") + + if type(withdraw) == "function" then + -- capture the list menu's opts instead of building a real one + local captured + check(setUpvalue(withdraw, "ListMenu", { + new = function(_, _, items, opts) + captured = { items = items, opts = opts } + return { kind = "list", items = items, close = function() end } + end, + }), "stubbed ListMenu on withdraw") + -- the WITHDRAW row is what fires onAction; run it straight + check(setUpvalue(withdraw, "monSubmenu", + function(_, _, _, onAction) onAction() end), + "stubbed monSubmenu on withdraw") + check(setUpvalue(withdraw, "afterTransfer", function() end), + "stubbed afterTransfer on withdraw") + + local realSound = package.loaded["src.core.Sound"] + package.loaded["src.core.Sound"] = { play = function() end, + playCry = function() end } + + local save = SaveData.newGame() + Boxes.ensure(save) + save.party = {} + save.boxes[1] = { boxShapedMon("ODDISH", 13, 20) } + local mon = save.boxes[1][1] + local game = { data = Data, save = save, + stack = { push = function() end, pop = function() end } } + + withdraw(game) + check(captured ~= nil, "withdraw opened its box list") + if captured then + eq(#captured.items, 1, "the list has the one box mon in it") + captured.opts.onChoose(captured.items[1], + { index = 1, close = function() end }) + eq(#save.boxes[1], 0, "the mon left the box") + eq(#save.party, 1, "and landed in the party") + eq(save.party[1], mon, "it is the same mon table") + check(type(mon.stats) == "table", + "withdraw ran CalcStats on the way out (#304)") + local want = Stats.calc(Data.pokemon.ODDISH, 13, mon.dvs, mon.statExp) + eq(mon.stats and mon.stats.hp, want.hp, + "the withdrawn mon's max HP is the derived one") + local okDraw, err = pcall(HudTiles.drawHPBar, Data, 5, 5, mon, 2) + check(okDraw, "the party row's HP bar draws for it: " .. tostring(err)) + end + + package.loaded["src.core.Sound"] = realSound + -- run_tests.lua dofiles the parity suites in one interpreter, so a later + -- suite would inherit these stubs + package.loaded["src.ui.BoxMenu"] = nil + end +end + +S.finish() diff --git a/tests/parity_cycling_road_brake.lua b/tests/parity_cycling_road_brake.lua new file mode 100644 index 00000000..3b1bffc6 --- /dev/null +++ b/tests/parity_cycling_road_brake.lua @@ -0,0 +1,137 @@ +-- Regression: holding A or B on Cycling Road stops the downhill roll (#255). +-- home/overworld.asm:1825 JoypadOverworld masks PAD_CTRL_PAD | PAD_B | PAD_A +-- before forcing PAD_DOWN, so a HELD A or B brakes the bike just like a held +-- direction. The port read only the four directions, and A reached the roll +-- through an edge-only wasPressed, so it stalled for one fixed step and rolled +-- on. Every case below holds the button with no press edge queued. +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_17) 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 cycling road brake") +local check, eq = S.check, S.eq + +-- ground truth for the map the rule is keyed on +local fm = Data.field.forcedMovement +local slope = {} +for _, id in ipairs((fm and fm.slopeMaps) or {}) do slope[id] = true end +check(slope.ROUTE_17, "field.forcedMovement.slopeMaps names ROUTE_17") + +Game.data = Data +Game.input = Input; Input:init() +Game.renderer = Renderer; Renderer:init() +Game.stack = StateStack; StateStack:init() +Game.save = SaveData.newGame() +Game.overworld = OW + +-- (1,20) and the two cells below it are open road, so nothing but the brake +-- can explain a stalled roll. +local START_X, START_Y = 1, 20 + +-- Every case starts from a fresh state: a half-finished step would carry into +-- the next one and read as a roll that never stopped. +local function freshRoad() + Input:reset() + Game.save.onBike = true + while Game.stack:top() do Game.stack:pop() end + Game.stack:push(OW, "ROUTE_17", START_X, START_Y, "down") + local ow = Game.stack:top() + ow.player.moving = false + ow.player.turnTimer = 0 + return ow +end + +local ow = freshRoad() +eq(ow.map.id, "ROUTE_17", "standing on Cycling Road") +check(ow.map:isWalkableCell(START_X, START_Y + 1), "the cell south is open road") +check(ow.map:isWalkableCell(START_X, START_Y + 2), "and the one after it") + +-- Hands off the pad: the bike rolls south on its own. +do + local o = freshRoad() + o:handleInput() + check(o.player.moving, "no buttons held: the bike starts a step") + eq(o.player.facing, "down", "the forced step faces south") + eq(o.player.targetY, START_Y + 1, "and targets the cell below") +end + +-- Held A / held B brake and keep braking. 120 fixed steps is ~2 seconds, far +-- past the single-step stall the old wasPressed edge gave. +local function heldBrakes(btn) + local o = freshRoad() + Input.state[btn] = true + Input.pressed = {} -- HELD, with no press edge: the case A got wrong + check(not Input:wasPressed(btn), + ("holding %s queues no press edge"):format(btn:upper())) + check(Input:isDown(btn), ("Input:isDown reports %s held"):format(btn:upper())) + local moved = false + for _ = 1, 120 do + o:handleInput() + o.player:update() + if o.player.cellY ~= START_Y or o.player.moving then moved = true end + end + check(not moved, + ("holding %s stops the roll for as long as it is held"):format(btn:upper())) + eq(o.player.cellY, START_Y, ("player has not drifted south under %s"):format(btn:upper())) + + -- ...and letting go hands the hill back + Input.state[btn] = false + o:handleInput() + check(o.player.moving, + ("releasing %s resumes the roll immediately"):format(btn:upper())) + Input:reset() +end + +heldBrakes("a") +heldBrakes("b") + +-- A held direction still wins: pokered's mask suppresses the simulated +-- PAD_DOWN, never the player's own step. +do + local o = freshRoad() + Input.state.a = true + Input.state.up = true + Input.pressed = {} + o:handleInput() -- first press turns; pokered turns in place too + o.player.turnTimer = 0 + o:handleInput() + check(o.player.moving, "holding A + UP still walks north") + eq(o.player.facing, "up", "the held direction, not the forced south, wins") + Input:reset() +end + +-- Off the bike there is no roll to brake: the whole block is gated on +-- save.onBike, and Route 17 force-bikes anyway. +do + local o = freshRoad() + Game.save.onBike = false + o:handleInput() + check(not o.player.moving, "on foot the hill does not push at all") + Game.save.onBike = true +end + +-- Any other map is untouched: no forced step with or without the brake. +do + Input:reset() + Game.save.onBike = true + while Game.stack:top() do Game.stack:pop() end + Game.stack:push(OW, "PALLET_TOWN", 10, 8, "down") + local o = Game.stack:top() + o:handleInput() + check(not o.player.moving, "PALLET_TOWN has no downhill pull") + Input.state.a = true + Input.pressed = {} + o:handleInput() + check(not o.player.moving, "and holding A there changes nothing") + Input:reset() +end + +S.finish() diff --git a/tests/parity_ledge_seam_hop.lua b/tests/parity_ledge_seam_hop.lua new file mode 100644 index 00000000..65401096 --- /dev/null +++ b/tests/parity_ledge_seam_hop.lua @@ -0,0 +1,174 @@ +-- Regression: a ledge hop whose landing is on the CONNECTED map still hops +-- (#223). engine/overworld/ledges.asm HandleLedges only simulates two button +-- presses and never looks at where the hop lands, so a ledge on a map's last +-- row legitimately drops onto the neighbour. The port gated every hop on +-- self.map:inBounds(lx, ly), which refused all nine such cells in the game. +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_4) then Data:load() end + +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local Map = require("src.world.Map") +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 ledge seam hop") +local check, eq = S.check, S.eq + +-- ---- ground truth: the ledge rows, the cells, the connection ------------- +local route4 = MapLoader.load(Data, "ROUTE_4") +local route3 = MapLoader.load(Data, "ROUTE_3") +local route17 = MapLoader.load(Data, "ROUTE_17") +local route18 = MapLoader.load(Data, "ROUTE_18") + +-- data/tilesets/ledge_tiles.asm has two DOWN rows: $39 over $36, $39 over $37 +local rows = {} +for _, l in ipairs(Data.field.ledges) do + if l.facing == "down" and l.input == "down" and l.standingTile == 0x39 then + rows[l.ledgeTile] = true + end +end +check(rows[0x36] and rows[0x37], + "LedgeTiles has both DOWN rows ($39 over $36 and $39 over $37)") + +eq(route4:cellTile(13, 16), 0x39, "ROUTE_4 (13,16) is the ledge's standing tile") +eq(route4:cellTile(13, 17), 0x37, "ROUTE_4 (13,17) is a DOWN ledge tile") +eq(route4:cellTile(12, 16), 0x39, "ROUTE_4 (12,16) is the ledge's standing tile") +eq(route4:cellTile(12, 17), 0x36, "ROUTE_4 (12,17) is a DOWN ledge tile") +eq(route4.def.height * 2, 18, "ROUTE_4 is 18 cells tall, so row 18 is off the map") +check(not route4:inBounds(13, 18), "the landing two south is off ROUTE_4") + +local south4 = route4:connection("south") +check(south4 and south4.map == "ROUTE_3", "ROUTE_4 connects south to ROUTE_3") +eq(south4 and south4.offset, -25, "at block offset -25 (destX = curX + 50)") +check(route3:isWalkableCell(63, 0), "the landing ROUTE_3 (63,0) is walkable") +check(route3:isWalkableCell(62, 0), "and ROUTE_3 (62,0) beside it") + +eq(route17:cellTile(7, 142), 0x39, "ROUTE_17 (7,142) is the ledge's standing tile") +eq(route17:cellTile(7, 143), 0x37, "ROUTE_17 (7,143) is a DOWN ledge tile") +local south17 = route17:connection("south") +check(south17 and south17.map == "ROUTE_18", "ROUTE_17 connects south to ROUTE_18") +check(route18:isWalkableCell(7, 0), "the landing ROUTE_18 (7,0) is walkable") + +-- ---- live engine --------------------------------------------------------- +Game.data = Data +Game.input = Input; Input:init() +Game.renderer = Renderer; Renderer:init() +Game.stack = StateStack; StateStack:init() +Game.save = SaveData.newGame() +Game.overworld = OW + +local function stand(mapId, x, y) + Input:reset() + Game.save.onBike = false + while Game.stack:top() do Game.stack:pop() end + Game.stack:push(OW, mapId, x, y, "down") + local ow = Game.stack:top() + ow.player.moving = false + ow.player.turnTimer = 0 + return ow +end + +-- One fixed step of the parts of OverworldState:update a hop rides on: +-- updateScriptMoves retires a finished move (its onDone is where the seam +-- crossing is handed to checkEdgeExit) and starts the next; Player:update +-- advances the current one. +local function runFrames(ow, n) + for _ = 1, n do + ow:updateScriptMoves() + ow.player:update() + end +end + +-- The Mt Moon plaza drop: ROUTE_4 (13,16) -> ROUTE_3 (63,0). +do + local ow = stand("ROUTE_4", 13, 16) + Input.state.down = true + Input.pressed = {} + local hopped = ow:checkLedgeHop("down") + check(hopped, "holding DOWN on ROUTE_4 (13,16) starts the hop") + eq(ow.player.hopFrames, 32, "the 32-frame jump arc is armed") + runFrames(ow, 120) + eq(ow.map.id, "ROUTE_3", "the hop carries the player across the seam") + eq(ow.player.cellX, 63, "landing x on ROUTE_3") + eq(ow.player.cellY, 0, "landing y on ROUTE_3 (the top row)") + check(not ow.player.moving, "the two-cell hop has finished") + eq(#ow.scriptMoves, 0, "no scripted move is left hanging") + Input:reset() +end + +-- Its neighbour, the other half of the 2-cell terrace. +do + local ow = stand("ROUTE_4", 12, 16) + Input.state.down = true + Input.pressed = {} + check(ow:checkLedgeHop("down"), "ROUTE_4 (12,16) hops too") + runFrames(ow, 120) + eq(ow.map.id, "ROUTE_3", "(12,16) also lands on ROUTE_3") + eq(ow.player.cellX, 62, "landing x on ROUTE_3") + eq(ow.player.cellY, 0, "landing y on ROUTE_3") + Input:reset() +end + +-- The last drop off Cycling Road: ROUTE_17 (7,142) -> ROUTE_18 (7,0). +do + local ow = stand("ROUTE_17", 7, 142) + Input.state.down = true + Input.pressed = {} + check(ow:checkLedgeHop("down"), "holding DOWN on ROUTE_17 (7,142) starts the hop") + runFrames(ow, 120) + eq(ow.map.id, "ROUTE_18", "Cycling Road's last ledge crosses onto ROUTE_18") + eq(ow.player.cellX, 7, "landing x on ROUTE_18 (south offset 0)") + eq(ow.player.cellY, 0, "landing y on ROUTE_18") + Input:reset() +end + +-- Ordinary in-map ledges are untouched: two cells, same map, no seam. +do + local ow = stand("ROUTE_4", 72, 4) + Input.state.down = true + Input.pressed = {} + check(ow:checkLedgeHop("down"), "the in-map ledge at ROUTE_4 (72,4) still hops") + runFrames(ow, 120) + eq(ow.map.id, "ROUTE_4", "an in-map hop does not change maps") + eq(ow.player.cellX, 72, "x unchanged") + eq(ow.player.cellY, 6, "landed two cells south") + Input:reset() +end + +-- Refusals that must survive the split. All nine real off-map ledges do have +-- a connection, so the stub below is the only way to express "none behind it". +do + local ow = stand("ROUTE_4", 13, 16) + Input.state.down = true + Input.pressed = {} + local realConnection = ow.map.connection + ow.map.connection = function() return nil end + check(ow:checkLedgeHop("down") == false, + "an off-map landing with no connection is still refused") + eq(#ow.scriptMoves, 0, "and nothing was queued") + ow.map.connection = realConnection + + -- ...and the landing is validated the way crossConnection validates it + local dest, ts, cx, cy = ow:connectionLanding("down") + check(dest ~= nil and ts ~= nil, "connectionLanding resolves the ROUTE_3 strip") + eq(cx, 63, "connectionLanding agrees on the landing x") + eq(cy, 0, "connectionLanding agrees on the landing y") + check(Map.defPassable(dest, ts, cx, cy, false), + "Map.defPassable passes the ROUTE_3 landing on foot") + + -- a plain floor cell is not a ledge no matter which way you lean + local plain = stand("ROUTE_4", 13, 14) + Input.state.down = true + Input.pressed = {} + check(plain:checkLedgeHop("down") == false, "a non-ledge cell does not hop") + check(plain:checkLedgeHop("left") == false, "and neither does a sideways lean") + Input:reset() +end + +S.finish() diff --git a/tests/parity_low_health_alarm.lua b/tests/parity_low_health_alarm.lua new file mode 100644 index 00000000..94fdf193 --- /dev/null +++ b/tests/parity_low_health_alarm.lua @@ -0,0 +1,190 @@ +-- Parity test: the low-health alarm is a LATCH, not a per-frame function of the +-- model's HP (#293). pokered keeps it in wLowHealthAlarm bit 7, set by +-- DrawPlayerHUDAndHPBar (engine/battle/core.asm:1858-1875) and cleared by +-- RemoveFaintedPlayerMon (core.asm:1011-1016). That redraw does not run until +-- UpdateHPBar2 finishes (core.asm:4727-4729), so a siren already sounding rides +-- through the next hit. This walks that timeline frame by frame. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local BattleState = require("src.battle.BattleState") +local Sound = require("src.core.Sound") +local S = require("tests.harness").suite("parity low health alarm") +local check, eq = S.check, S.eq + +-- A stand-in carrying only what updateFx/stepHPDrain/lowHealthAlarmActive read. +-- The point is the frame-by-frame decision, not the damage roll behind it. +local function battler(maxHP, hp) + return { mon = { hp = hp, stats = { hp = maxHP } }, shownHP = hp } +end + +-- BattleState.__index = BattleState (BattleState.lua:32), so the stand-in +-- gets the real methods without newWild's Data/RNG/queue setup +local function battleAt(maxHP, hp) + return setmetatable({ + data = {}, -- only ever handed to the stubbed Sound.startLoop + introSlide = 0, -- HUD already drawn (post send-out) + showPlayerBack = false, + player = battler(maxHP, hp), + enemy = battler(40, 40), + }, BattleState) +end + +-- Record what the siren did instead of making noise. updateFx re-requires +-- src.core.Sound each frame, so it picks these up off the same module table. +local siren = false +local realStart, realStop = Sound.startLoop, Sound.stopLoop +Sound.startLoop = function(_, name) + if name == "Low_Health_Alarm" then siren = true end +end +Sound.stopLoop = function(name) + if name == "Low_Health_Alarm" then siren = false end +end + +-- One battle frame in BattleState:update's order: updateFx first +-- (BattleState.lua:1281), then the queue, whose {drain=true} row steps the bar +-- (BattleState.lua:798). Returns one siren sample per frame, oldest first. +local function frames(b, n, draining) + local log = {} + for _ = 1, n do + BattleState.updateFx(b) + if draining then BattleState.stepHPDrain(b) end + log[#log + 1] = siren + end + return log +end + +local function allOn(log) + for _, on in ipairs(log) do + if not on then return false end + end + return #log > 0 +end + +local function allOff(log) + for _, on in ipairs(log) do + if on then return false end + end + return #log > 0 +end + +-- drain the bar to the model one frame at a time, sampling as we go; capped so +-- a broken stepHPDrain cannot spin forever +local function drainToModel(b, cap) + local log = {} + for _ = 1, cap or 200 do + if b.player.shownHP == b.player.mon.hp then break end + local f = frames(b, 1, true) + log[#log + 1] = f[1] + end + return log +end + +-- --------------------------------------------------------------------- +-- the regression: a hit lands while the siren is already sounding +-- --------------------------------------------------------------------- +do + -- 9/48 = 9 px of a 48-px bar, one under HP_BAR_RED's threshold + local b = battleAt(48, 9) + check(allOn(frames(b, 10)), "a red bar starts the siren") + check(b.lowHealthAlarmOn == true, "and latches wLowHealthAlarm's bit 7") + + -- applyDamage: the model loses the HP while the turn is still being queued, + -- and the bar will not move until the {drain} row runs. + b.player.mon.hp = 4 + eq(b.player.shownHP, 9, "the drawn bar has not moved yet") + + -- "FOE RATTATA used TACKLE!" plus the move animation: dozens of frames with + -- no drain running at all, which is where #293 went silent + check(allOn(frames(b, 60)), + "the siren holds through the announcement and the move animation (#293)") + + -- ...and then the bar drains, maxHP/96 per frame (BattleState:stepHPDrain, + -- porting engine/gfx/hp_bar.asm UpdateHPBar) + local drain = drainToModel(b) + check(#drain > 1, "the drain really took multiple frames") + check(allOn(drain), "the siren holds for every frame of the drain (#293)") + check(allOn(frames(b, 5)), "and is still sounding once the bar settles") +end + +-- --------------------------------------------------------------------- +-- a lethal hit: the siren must last until the bar is visibly empty +-- (RemoveFaintedPlayerMon, core.asm:1011-1016, runs after the drain) +-- --------------------------------------------------------------------- +do + local b = battleAt(48, 4) + check(allOn(frames(b, 5)), "siren sounding before the killing blow") + + b.player.mon.hp = 0 -- applyDamage zeroes the model at queue-build time + check(allOn(frames(b, 45)), + "a lethal hit keeps the siren through \"used X!\" and the animation (#293)") + + local drain = drainToModel(b) + check(allOn(drain), "and through the bar draining to empty (#293)") + eq(b.player.shownHP, 0, "the bar reached empty") + + -- updateFx samples before the drain step, so the frame that lands on + -- empty is decided from the previous value; the siren stops on the next + local after = frames(b, 3) + check(after[#after] == false, "the empty bar silences it") +end + +-- --------------------------------------------------------------------- +-- the over-correction guard: a STOPPED alarm must not start during a drain. +-- DrawPlayerHUDAndHPBar sets the bit and does not run until UpdateHPBar2 has +-- finished, so the siren begins when the BAR lands in the red, not the model. +-- --------------------------------------------------------------------- +do + local b = battleAt(48, 48) + check(allOff(frames(b, 5)), "a full bar is silent") + + b.player.mon.hp = 9 -- big hit: model in the red, bar still at the top + local drain = drainToModel(b) + check(#drain > 1, "the long drain really took multiple frames") + check(allOff(drain), "the siren does not start until the bar lands (#293)") + check(allOn(frames(b, 3)), "and starts the moment it does") +end + +-- --------------------------------------------------------------------- +-- healing out of the red silences it AT ONCE, not after the bar animates: +-- engine/items/item_effects.asm:991-994 clears the alarm before the heal +-- draws. This is the one place the latch must not hold. +-- --------------------------------------------------------------------- +do + local b = battleAt(48, 9) + check(allOn(frames(b, 5)), "siren sounding on a red bar") + b.player.mon.hp = 30 -- SUPER POTION: model jumps, the bar climbs after + local after = frames(b, 1) + check(after[1] == false, "a heal out of the red silences it on the spot") + check(b.player.shownHP < b.player.mon.hp, + "and it was silenced while the bar was still climbing") +end + +-- --------------------------------------------------------------------- +-- the pre-existing gates still win over the latch: a decided battle +-- (EndLowHealthAlarm) and a fainted battler both stop it dead +-- --------------------------------------------------------------------- +do + local b = battleAt(48, 9) + check(allOn(frames(b, 3)), "siren sounding") + b.result = "win" + check(allOff(frames(b, 3)), "a decided battle stops it (EndLowHealthAlarm)") + + local c = battleAt(48, 9) + check(allOn(frames(c, 3)), "siren sounding") + c.player.fainted = true + check(allOff(frames(c, 3)), "a fainted battler stops it") + + local d = battleAt(48, 9) + check(allOn(frames(d, 3)), "siren sounding") + d.lowHealthAlarmDisabled = true + check(allOff(frames(d, 3)), "wLowHealthAlarmDisabled stops it") + + -- safari/old-man battles draw no player HUD, so they have no alarm to latch + local e = battleAt(48, 9) + e.safari = true + check(allOff(frames(e, 3)), "the safari battle never starts one") +end + +Sound.startLoop, Sound.stopLoop = realStart, realStop + +S.finish() diff --git a/tests/parity_npc_exit_clearance.lua b/tests/parity_npc_exit_clearance.lua new file mode 100644 index 00000000..11321358 --- /dev/null +++ b/tests/parity_npc_exit_clearance.lua @@ -0,0 +1,280 @@ +-- Parity: scripted NPC exit walks must stay on walkable ground and must never +-- route through the player's parking cell (#236, #241). scriptMove is a pure +-- tween with no collision, like pokered's MoveSprite, so the movement lists +-- are the only thing keeping an NPC out of solid world. #236 picks the +-- Route 22 exit list off wSavedCoordIndex (home/map_objects.asm:107); #241 +-- never walks the Pewter guide home (PewterCity.asm:133 + SetSpritePosition2). + +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_22) then Data:load() end +local MapLoader = require("src.world.MapLoader") + +local S = require("tests.harness").suite("parity npc exit clearance") +local check, eq = S.check, S.eq + +-- restored at the bottom for the suites run after this file +local realMusic = package.loaded["src.core.Music"] +local realCommands = package.loaded["src.script.Commands"] +local realPicBox = package.loaded["src.ui.PicBox"] +local realTextBox = package.loaded["src.render.TextBox"] +package.loaded["src.core.Music"] = { + play = function() end, playMap = function() end, + playOnce = function() return true end, stop = function() end, +} +package.loaded["src.script.Commands"] = { hide_object = function() end } +package.loaded["src.ui.PicBox"] = { new = function() return {} end } +-- the mock stack below runs a TextBox's continuation the moment it is handed +-- one, so the whole callback chain (escort -> "take on BROCK" -> walk home) +-- plays out inside one call with no frame pump +package.loaded["src.render.TextBox"] = { + new = function(_, s, done) return { text = s, done = done } end, +} + +local story5 = dofile("data/scripts/story5.lua") + +local DIRV = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } + +-- Replay a movement list one cell at a time. `avoid` is the player's parking +-- cell: pokered's lists are authored so the NPC walks around him. +local function replay(map, sx, sy, dirs, avoid, label) + local x, y = sx, sy + for i, d in ipairs(dirs) do + local v = DIRV[d] + check(v ~= nil, ("%s step %d is a real direction (%s)"):format(label, i, tostring(d))) + if not v then return x, y end + x, y = x + v[1], y + v[2] + check(map:isWalkableCell(x, y), + ("%s step %d (%s) lands on walkable ground at (%d,%d)") + :format(label, i, d, x, y)) + check(not (x == avoid.x and y == avoid.y), + ("%s step %d (%s) does not walk through the player on (%d,%d)") + :format(label, i, d, avoid.x, avoid.y)) + end + return x, y +end + +-- ------------------------------------------------------------ #236 +-- Route 22: both ambush tiles, both rivals. +do + local r22 = MapLoader.load(Data, "ROUTE_22") + + -- ground truth first, so a later map/tileset change reads as "the cliff + -- moved" rather than as a script regression + check(not r22:isWalkableCell(28, 3), + "ROUTE_22 (28,3) is the cliff face, not walkable (the #236 crash site)") + check(not r22:isWalkableCell(30, 7), + "ROUTE_22 (30,7) is solid too (the old y=5 branch crossed it)") + for x = 28, 31 do + for y = 4, 5 do + check(r22:isWalkableCell(x, y), + ("ROUTE_22 (%d,%d) is walkable ambush ground"):format(x, y)) + end + end + check(r22:isWalkableCell(31, 10), + "ROUTE_22 (31,10) is walkable (pokered's exit end cell)") + + -- the object_event spawn both rivals share + local spawn + for _, o in ipairs(Data.maps.ROUTE_22.objects or {}) do + if o.name == "ROUTE22_RIVAL1" then spawn = o end + end + check(spawn ~= nil, "ROUTE22_RIVAL1 has an object_event") + eq(spawn and spawn.x, 25, "ROUTE22_RIVAL1 spawns at x=25") + eq(spawn and spawn.y, 5, "ROUTE22_RIVAL1 spawns at y=5") + + -- drive onStep the way ScriptRunner would: capture the queued rows without + -- executing them + local function capture(game, x, y) + local rows + local ow = { + runner = { + isRunning = function() return false end, + run = function(_, r) rows = r end, + }, + player = { facing = "down" }, + npcByIndex = function() return { def = { name = "X" } } end, + } + check(story5.ROUTE_22.onStep(game, ow, x, y), + ("ROUTE_22 onStep fires at (%d,%d)"):format(x, y)) + return rows, ow.player.facing + end + + local function row(rows, name) + for _, r in ipairs(rows or {}) do + if r[1] == name then return r end + end + end + + -- (trigger y, expected end cell) for rival 1; rival 2 always ends back + -- on his spawn because Route22Rival2ExitMovementData1 falls through + -- into Data2 (Route22.asm:355). + local cases = { + { n = 1, y = 4, endX = 31, endY = 10, + flags = { EVENT_GOT_POKEDEX = true } }, + { n = 1, y = 5, endX = 31, endY = 10, + flags = { EVENT_GOT_POKEDEX = true } }, + { n = 2, y = 4, endX = 25, endY = 5, + flags = { EVENT_BEAT_BROCK = true, + EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE = true, + EVENT_BEAT_GIOVANNI = true } }, + { n = 2, y = 5, endX = 25, endY = 5, + flags = { EVENT_BEAT_BROCK = true, + EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE = true, + EVENT_BEAT_GIOVANNI = true } }, + } + + for _, c in ipairs(cases) do + local tag = ("rival%d from (29,%d)"):format(c.n, c.y) + local rows, playerFacing = + capture({ save = { flags = c.flags }, data = {} }, 29, c.y) + local moveTo = row(rows, "move_npc_to") + local face = row(rows, "face_object") + local walk = row(rows, "walk_npc") + check(moveTo and face and walk, tag .. ": scene has move/face/walk rows") + if moveTo and face and walk then + local rx, ry = moveTo[3], moveTo[4] + -- Route22MoveRivalRightScript only ever walks him RIGHT along his + -- own spawn row, so he cannot end up anywhere but row 5. + eq(ry, 5, tag .. ": rival stops on his own row 5") + check(r22:isWalkableCell(rx, ry), + ("%s: rival stops on walkable ground (%d,%d)"):format(tag, rx, ry)) + check(not (rx == 29 and ry == c.y), + tag .. ": rival does not stop on top of the player") + check(math.abs(rx - 29) + math.abs(ry - c.y) == 1, + tag .. ": rival stops in a cell adjacent to the player") + + -- the two sprites have to be looking at each other, or the battle text + -- reads as a conversation with empty air + local fv = DIRV[face[3]] + check(fv and rx + fv[1] == 29 and ry + fv[2] == c.y, + ("%s: rival faces %s, straight at the player"):format(tag, tostring(face[3]))) + local pv = DIRV[playerFacing] + check(pv and 29 + pv[1] == rx and c.y + pv[2] == ry, + ("%s: player is turned %s, straight at the rival") + :format(tag, tostring(playerFacing))) + + local ex, ey = replay(r22, rx, ry, walk[3], { x = 29, y = c.y }, tag .. " exit") + eq(ex, c.endX, tag .. ": exit ends at x=" .. c.endX) + eq(ey, c.endY, tag .. ": exit ends at y=" .. c.endY) + end + end +end + +-- ------------------------------------------------------------ #241 +-- Pewter City: the gym guide's walk after the escort. +do + local pew = MapLoader.load(Data, "PEWTER_CITY") + + check(pew:isWalkableCell(11, 18), "PEWTER_CITY (11,18) is the player's parking cell") + check(pew:isWalkableCell(12, 18), "PEWTER_CITY (12,18) is where the escort leaves the guide") + for x = 13, 17 do + check(pew:isWalkableCell(x, 18), + ("PEWTER_CITY (%d,18) is walkable gym road"):format(x)) + end + -- why the original teleports him instead of walking him home: (17,18) + -- is a dead-end pocket, so there is no route out that misses the player + check(not pew:isWalkableCell(18, 18), "PEWTER_CITY (18,18) is the fence") + check(not pew:isWalkableCell(17, 17), "PEWTER_CITY (17,17) is wall") + + local spawn + for _, o in ipairs(Data.maps.PEWTER_CITY.objects or {}) do + if o.name == "PEWTERCITY_YOUNGSTER" then spawn = o end + end + check(spawn ~= nil, "PEWTERCITY_YOUNGSTER has an object_event") + eq(spawn and spawn.x, 35, "PEWTERCITY_YOUNGSTER spawns at x=35") + eq(spawn and spawn.y, 16, "PEWTERCITY_YOUNGSTER spawns at y=16") + + -- Run the escort through a mock overworld. scriptMove lands synchronously + -- here instead of over 16 frames, and the real pump advances guide and + -- player in the same frame, so only per-entity cell SEQUENCES are + -- meaningful, which is all these assertions read. + local function runEscort(tx, ty) + local moves = {} + local guy = { cellX = spawn.x, cellY = spawn.y, facing = "down", moving = false } + local player = { cellX = tx, cellY = ty, facing = "left" } + local game = { + data = { text = {} }, + save = { flags = {} }, + stack = { push = function(_, box) if box.done then box.done() end end }, + } + local ow = { + scriptMoves = {}, + runner = { isRunning = function() return false end }, + player = player, + npcByIndex = function(_, i) return (i == 5) and guy or nil end, + scriptMove = function(_, ent, dir, tiles, onDone) + local v = DIRV[dir] + for _ = 1, (tiles or 1) do + ent.cellX, ent.cellY = ent.cellX + v[1], ent.cellY + v[2] + end + ent.facing = dir + moves[#moves + 1] = { + who = (ent == guy) and "guy" or "player", + dir = dir, x = ent.cellX, y = ent.cellY, + } + if onDone then onDone() end + end, + } + story5.PEWTER_CITY.talk.TEXT_PEWTERCITY_YOUNGSTER(game, ow, guy, nil) + return moves, guy, player + end + + -- PewterGymGuyCoords: every tile that arms the escort + for _, trig in ipairs({ { 35, 17 }, { 36, 17 }, { 37, 18 }, { 37, 19 }, { 34, 16 } }) do + local tag = ("guide from (%d,%d)"):format(trig[1], trig[2]) + local moves, guy, player = runEscort(trig[1], trig[2]) + check(#moves > 0, tag .. ": the escort actually walked") + + -- the escort's landing pair decides what the walk home has to avoid + local lastPlayer + for i, m in ipairs(moves) do + if m.who == "player" then lastPlayer = i end + end + check(lastPlayer ~= nil, tag .. ": the player was walked too") + eq(player.cellX, 11, tag .. ": player parks on x=11") + eq(player.cellY, 18, tag .. ": player parks on y=18") + + -- everything after the player's last step is the walk home + local home = {} + for i = (lastPlayer or 0) + 1, #moves do + check(moves[i].who == "guy", tag .. ": only the guide moves after the escort") + home[#home + 1] = moves[i] + end + eq(#home, 5, tag .. ": MovementData_PewterGymGuyExit is five steps") + local hx, hy = 12, 18 + for i, m in ipairs(home) do + eq(m.dir, "right", ("%s: exit step %d is RIGHT"):format(tag, i)) + local v = DIRV[m.dir] or { 0, 0 } + hx, hy = hx + v[1], hy + v[2] + check(pew:isWalkableCell(hx, hy), + ("%s: exit step %d lands on walkable ground (%d,%d)") + :format(tag, i, hx, hy)) + check(not (hx == 11 and hy == 18), + ("%s: exit step %d does not walk through the player on (11,18)") + :format(tag, i)) + end + eq(hx, 17, tag .. ": the exit ends on x=17") + eq(hy, 18, tag .. ": the exit ends on y=18") + + -- SetSpritePosition2 + ShowObject: back on the object_event spawn + eq(guy.cellX, spawn.x, tag .. ": guide is snapped back to spawn x") + eq(guy.cellY, spawn.y, tag .. ": guide is snapped back to spawn y") + eq(guy.facing, "down", tag .. ": guide faces DOWN again at his spawn") + -- npcAtCell (OverworldController.lua:1451) also matches on targetX/Y, so + -- a teleport that leaves them set reserves the vacated cell forever and + -- silently walls the player out of the pocket + eq(guy.targetX, nil, tag .. ": the snap clears targetX") + eq(guy.targetY, nil, tag .. ": the snap clears targetY") + eq(guy.moving, false, tag .. ": the snap clears the moving flag") + end +end + +package.loaded["src.core.Music"] = realMusic +package.loaded["src.script.Commands"] = realCommands +package.loaded["src.ui.PicBox"] = realPicBox +package.loaded["src.render.TextBox"] = realTextBox + +S.finish() diff --git a/tests/parity_party_hp_bar_palette.lua b/tests/parity_party_hp_bar_palette.lua new file mode 100644 index 00000000..2efd7cf2 --- /dev/null +++ b/tests/parity_party_hp_bar_palette.lua @@ -0,0 +1,188 @@ +-- Parity test: the party screen's SGB block packet (#274, absorbing #272). +-- SetPal_PartyMenu sends PalPacket_PartyMenu plus BlkPacket_PartyMenu +-- (engine/gfx/palettes.asm:90, data/sgb/sgb_packets.asm:149-158 and :219), and +-- each bar block takes its palette from that mon's wPartyMenuHPBarColors entry +-- (palettes.asm:293-325). The port gave the whole screen one MEWMON zone, and +-- the pre-tinted fill then double-applied on top of it (the #229 hazard). +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 party hp bar palette") +local check, eq = S.check, S.eq + +local Data = require("src.core.Data") +if not Data.maps then Data:load() end +local Font = require("src.render.Font") +Font.load(Data) + +local PaletteFX = require("src.render.PaletteFX") +local HudTiles = require("src.render.HudTiles") +local PartyMenu = require("src.ui.PartyMenu") +local Pokemon = require("src.pokemon.Pokemon") + +local prevMode = PaletteFX.mode + +-- A 48/48 bar is one pixel per HP, so GetHealthBarColor's 27 / 10 pixel +-- thresholds land on plain numbers here. +local LEVELS = { + { species = "BULBASAUR", hp = 48, pal = "GREENBAR" }, + { species = "PIDGEY", hp = 26, pal = "YELLOWBAR" }, + { species = "RATTATA", hp = 9, pal = "REDBAR" }, + { species = "CATERPIE", hp = 0, pal = "REDBAR" }, +} + +local party = {} +for i, l in ipairs(LEVELS) do + local mon = Pokemon.new(Data, l.species, 20) + mon.stats.hp = 48 + mon.hp = l.hp + party[i] = mon + eq(PaletteFX.barPalName(l.hp, 48), l.pal, + ("%d/48 HP is a %s bar"):format(l.hp, l.pal)) +end + +local game = { + data = Data, + save = { party = party, options = {} }, + stack = { push = function() end, pop = function() end, + top = function() end }, +} + +-- ---- the block packet ---------------------------------------------------- +local function zonesIn(mode, menu) + PaletteFX.setMode(mode) + return menu:sgbPalettes(game), mode +end + +for _, mode in ipairs({ "gbc", "redpp" }) do + local menu = PartyMenu.new(game, {}) + local zones = zonesIn(mode, menu) + local tag = " [" .. PaletteFX.modeLabel(mode) .. "]" + + check(type(zones) == "table", "sgbPalettes returns a zone list" .. tag) + zones = zones or {} + eq(#zones, 2 + #party, + "base + icon column + one block per HP bar row" .. tag) + + local green = PaletteFX.pal(Data, "GREENBAR") + local mew = PaletteFX.pal(Data, "MEWMON") + check(green and mew, "GREENBAR and MEWMON both resolve" .. tag) + + -- pal 1 of the PAL_SET is the screen's base, not MEWMON + local base = zones[1] or {} + eq(base.colors, green, "the base zone is GREENBAR, not MEWMON" .. tag) + eq(base.x, 0, "base zone x" .. tag) + eq(base.y, 0, "base zone y" .. tag) + eq(base.w, 160, "base zone spans the screen width" .. tag) + eq(base.h, 144, "base zone spans the screen height" .. tag) + + -- ATTR_BLK_DATA ... 01,00, 02,12 -> tiles x 1..2, rows 0..11 here: the block + -- stops at row 11 because row 12 is this port's message-box edge + local col = zones[2] or {} + eq(col.colors, mew, "the icon column keeps MEWMON" .. tag) + eq(col.x, 8, "icon column starts at tile 1" .. tag) + eq(col.y, 0, "icon column starts at row 0" .. tag) + eq(col.w, 16, "icon column is the two-tile-wide icon block" .. tag) + eq(col.h, 96, "icon column stops above the message box" .. tag) + + -- the base swap is invisible outside the icons and the bars only because + -- MEWMON and GREENBAR agree on paper and ink in every pack: names, levels, + -- HP numbers, border and cursor are all color 3 on color 0 + for _, ci in ipairs({ 1, 4 }) do + local a, b = mew and mew[ci], green and green[ci] + check(a and b and a[1] == b[1] and a[2] == b[2] and a[3] == b[3], + ("MEWMON and GREENBAR share color %d (text/box unchanged)%s") + :format(ci - 1, tag)) + end + + for i, l in ipairs(LEVELS) do + local z = zones[2 + i] or {} + local want = PaletteFX.pal(Data, l.pal) + eq(z.colors, want, + ("row %d (%d/48 HP) carries the %s block palette%s") + :format(i, l.hp, l.pal, tag)) + -- 05,YY - 11,YY shifted one tile right, because this port draws the bar at + -- tile 5 where party_menu.asm:71-76 draws it at 4; still cap + six fill + eq(z.x, 48, ("row %d bar block starts at tile 6%s"):format(i, tag)) + eq(z.w, 56, ("row %d bar block is seven tiles wide%s"):format(i, tag)) + eq(z.y, (i * 2 - 1) * 8, ("row %d bar block sits on its HP row%s"):format(i, tag)) + eq(z.h, 8, ("row %d bar block is one tile tall%s"):format(i, tag)) + end +end + +-- ---- TM/HM list: ABLE / NOT ABLE where the bar would be (#210) ------------ +do + PaletteFX.setMode("redpp") + local menu = PartyMenu.new(game, { tmhm = { move = "TM01", kind = "TM" } }) + local zones = menu:sgbPalettes(game) or {} + eq(#zones, 2, "the TM/HM list has no bar rows to color") +end + +-- ---- a medicine's fill holds the PRE-heal block palette (#252) ------------ +do + PaletteFX.setMode("redpp") + local menu = PartyMenu.new(game, {}) + menu.heal = { mon = party[3], from = 9, shown = 30 } + local zones = menu:sgbPalettes(game) or {} + eq((zones[5] or {}).colors, PaletteFX.pal(Data, "REDBAR"), + "a healing row keeps its pre-heal bar palette until the redraw") +end + +-- ---- the bar the rects are aimed at -------------------------------------- +-- grayFill: with a zone pass coming, the fill must stay raw DMG shade-2 gray +-- or the tint and the zone double-apply (a GREENBAR fill has red channel 0, so +-- the red-keyed shade shader maps the whole bar to color 3 = black). +-- PaletteFX.shader() is stubbed rather than the love global because the real +-- one caches its compile and another suite may already have resolved it. +do + PaletteFX.setMode("redpp") + local menu = PartyMenu.new(game, {}) + local realShader, realBar = PaletteFX.shader, HudTiles.drawHPBar + local realDraw = love.graphics.draw + local seen = {} + local function record() + HudTiles.drawHPBar = function(data, tx, ty, mon, barType, grayFill) + seen[#seen + 1] = { tx = tx, ty = ty, barType = barType, + gray = grayFill and true or false } + end + love.graphics.draw = function() end + end + local function restore() + HudTiles.drawHPBar, love.graphics.draw = realBar, realDraw + PaletteFX.shader = realShader + end + + PaletteFX.shader = function() return { send = function() end } end + record() + local ok, err = pcall(function() menu:draw() end) + restore() + check(ok, "PartyMenu:draw runs headless" .. (ok and "" or (": " .. tostring(err)))) + eq(#seen, #party, "one HP bar per party row") + for i, bar in ipairs(seen) do + eq(bar.gray, true, ("row %d draws a gray fill when a zone pass will run") + :format(i)) + -- the placement the sgbPalettes rects above are keyed to + eq(bar.tx, 5, ("row %d bar starts at tile 5"):format(i)) + eq(bar.ty, i * 2 - 1, ("row %d bar sits on its HP row"):format(i)) + -- wHPBarType 2: the party menu closes with the $6C nub, not the + -- player-battle double bar (home/pokemon.asm DrawHPBar "Right") + eq(bar.barType, nil, ("row %d keeps the party-menu right cap"):format(i)) + eq(HudTiles.capTile(bar.barType), 0x6C, + ("row %d cap tile is the party nub"):format(i)) + end + + -- and the other way: with no shade-remap shader nothing will colorize the + -- canvas, so the per-pixel tint is the only color the bar can get + seen = {} + PaletteFX.shader = function() return nil end + record() + pcall(function() menu:draw() end) + restore() + eq(#seen, #party, "one HP bar per party row (unshaded build)") + for i, bar in ipairs(seen) do + eq(bar.gray, false, + ("row %d keeps its tinted fill with no shader to colorize it"):format(i)) + end +end + +PaletteFX.setMode(prevMode) +S.finish() diff --git a/tests/parity_party_icon_mirror.lua b/tests/parity_party_icon_mirror.lua new file mode 100644 index 00000000..51fa81da --- /dev/null +++ b/tests/parity_party_icon_mirror.lua @@ -0,0 +1,169 @@ +-- Parity test: the party list draws each icon as a mirrored LEFT half (#276). +-- pokered engine/gfx/mon_icons.asm:234-251 sends every icon but ICON_HELIX +-- through WriteSymmetricMonPartySpriteOAM (engine/items/town_map.asm:494-534), +-- whose inner loop writes the same tile twice (plain, then OAM_XFLIP) before +-- bumping the tile number by 2, so the frame's right column never reaches the +-- screen. Pixels: tests/drivers/party_icon_mirror_bug276_test.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 party icon mirror") +local check, eq = S.check, S.eq + +local Data = require("src.core.Data") +if not Data.maps then Data:load() end +local Font = require("src.render.Font") +Font.load(Data) + +local PartyMenu = require("src.ui.PartyMenu") +local Pokemon = require("src.pokemon.Pokemon") + +-- ---- the HELIX carve-out, as a pure function ----------------------------- +-- data/pokemon/menu_icons.asm gives ICON_HELIX to Shellder/Cloyster, +-- Staryu/Starmie and the fossil lines, and to nothing else. +check(type(PartyMenu.mirrorsIcon) == "function", + "PartyMenu.mirrorsIcon is exported") +-- keep going when it is missing, so the draw geometry below still reports +local mirrors = type(PartyMenu.mirrorsIcon) == "function" + and PartyMenu.mirrorsIcon or function() return nil end +eq(mirrors("MON"), true, "MON mirrors") +eq(mirrors("FAIRY"), true, "FAIRY mirrors") +eq(mirrors("BIRD"), true, "BIRD mirrors") +eq(mirrors("BALL"), true, "BALL mirrors") +eq(mirrors("HELIX"), false, "HELIX takes the asymmetric path") +eq(mirrors(nil), false, "mod art (no built-in icon name) draws whole") + +-- ---- one party row per icon class, each on its own sheet ----------------- +-- Distinct sheets so a recorded draw can be attributed back to its row. +-- byDex is the extractor's copy of MonPartyData; the frames come from +-- PartyMenu.iconFrames (data/icon_pointers.asm MonPartySpritePointers). +local CASES = { + { species = "CHARMANDER", icon = "MON", rest = 3, alt = 0 }, + { species = "PIKACHU", icon = "FAIRY", rest = 3, alt = 0 }, + { species = "SPEAROW", icon = "BIRD", rest = 3, alt = 0 }, + -- HELIX and BALL carry no iconFrames row: one 16x16 frame, and + -- AnimatePartyMon nudges them a pixel down instead of swapping frames. + { species = "OMANYTE", icon = "HELIX", rest = 0 }, + { species = "WEEDLE", icon = "BUG", rest = 1, alt = 0 }, + { species = "RATTATA", icon = "QUADRUPED", rest = 0, alt = 1 }, +} + +local icons = Data.icons +check(icons and icons.icons and icons.byDex, "data.icons carries icons/byDex") + +local party = {} +for i, c in ipairs(CASES) do + local def = Data.pokemon[c.species] + local name = def and def.dex and icons.byDex[def.dex] + eq(name, c.icon, c.species .. " uses the " .. c.icon .. " icon") + local path = name and icons.icons[name] + check(type(path) == "string" and path ~= "", + c.icon .. " resolves a sheet path") + c.path = path + eq(PartyMenu.frameFor(name, false, 96), c.rest, c.icon .. " rest frame") + if c.alt then + eq(PartyMenu.frameFor(name, true, 96), c.alt, c.icon .. " animated frame") + end + party[i] = Pokemon.new(Data, c.species, 20) +end + +-- every case must sit on its own sheet or the per-row attribution below lies +do + local seen = {} + for _, c in ipairs(CASES) do + check(c.path and not seen[c.path], (c.icon or "?") .. " has its own sheet") + seen[c.path] = true + end +end + +local game = { + data = Data, + save = { party = party, options = {} }, + stack = { push = function() end, pop = function() end, + top = function() end }, +} +local menu = PartyMenu.new(game, {}) + +-- ---- record the icon draws ------------------------------------------------ +-- love.graphics.draw(image, quad, x, y, r, sx, sy). The stub's newImage +-- keeps the resolved path, which is how a draw is tied back to a row. +local function drawsFor(index, blink) + menu.index = index or 1 + menu.blink = blink or 0 + local real = love.graphics.draw + local rec = {} + love.graphics.draw = function(img, a, b, c, d, e, f) + rec[#rec + 1] = { img = img, a = a, b = b, c = c, d = d, e = e, f = f } + end + local ok, err = pcall(function() menu:draw() end) + love.graphics.draw = real + check(ok, "PartyMenu:draw runs headless" .. (ok and "" or (": " .. tostring(err)))) + local byRow = {} + for _, r in ipairs(rec) do + local p = type(r.img) == "table" and r.img.path + if type(p) == "string" then + for ci, c in ipairs(CASES) do + -- Assets.resolve may prefix an override dir, so match on the tail + if p:sub(-#c.path) == c.path then + byRow[ci] = byRow[ci] or {} + table.insert(byRow[ci], r) + end + end + end + end + return byRow +end + +-- Park the cursor on row 4 (the fossil) so the other five rows are at rest +-- and no alt frame is in play. +local rows = drawsFor(4, 0) + +for i, c in ipairs(CASES) do + local d = rows[i] or {} + local y = PartyMenu.entryY(i) + if c.icon == "HELIX" then + -- WriteAsymmetricMonPartySpriteOAM (town_map.asm:461-492): the one icon + -- whose four tile patterns are all distinct, so it draws whole + eq(#d, 1, "HELIX icon is one whole draw") + if d[1] then + eq(d[1].a, 8, "HELIX draws at x=8 with no quad") + eq(d[1].b, y, "HELIX draws on its own row") + end + else + eq(#d, 2, c.icon .. " icon is a half plus its mirror") + local half, flip = d[1], d[2] + if half and flip then + check(type(half.a) == "table" and half.a.w == 8 and half.a.h == 16, + c.icon .. " left half is an 8x16 quad") + eq(type(half.a) == "table" and half.a.x, 0, + c.icon .. " left half starts at the frame's left edge") + eq(type(half.a) == "table" and half.a.y, c.rest * 16, + c.icon .. " left half reads the rest frame") + eq(half.b, 8, c.icon .. " left half lands at x=8") + eq(half.c, y, c.icon .. " left half lands on its row") + eq(flip.a, half.a, c.icon .. " mirror reuses the same left-half quad") + eq(flip.b, 8 + 16, c.icon .. " mirror is anchored on the block's right edge") + eq(flip.c, y, c.icon .. " mirror lands on its row") + eq(flip.e, -1, c.icon .. " mirror is x-flipped (OAM_XFLIP)") + eq(flip.f, 1, c.icon .. " mirror is not y-flipped") + end + end +end + +-- ---- the animated frame mirrors too -------------------------------------- +-- AnimatePartyMon only animates the selected mon, and BIRD is the class whose +-- animated frame is the asymmetric one, so a Spearow only goes lopsided while +-- the cursor is on it. At full HP the phase is 5 frames, so blink 5 is alt. +do + local bird = 3 + local alt = drawsFor(bird, 5) + local d = alt[bird] or {} + eq(#d, 2, "the animated BIRD frame is still a half plus its mirror") + if d[1] and d[2] then + eq(type(d[1].a) == "table" and d[1].a.y, CASES[bird].alt * 16, + "the animated BIRD frame is tile 0, not the rest frame") + eq(d[1].a.w, 8, "the animated BIRD half is 8 wide") + eq(d[2].e, -1, "the animated BIRD mirror is x-flipped") + end +end + +S.finish() diff --git a/tests/parity_picker_pointer_grab.lua b/tests/parity_picker_pointer_grab.lua new file mode 100644 index 00000000..f7b1a4e2 --- /dev/null +++ b/tests/parity_picker_pointer_grab.lua @@ -0,0 +1,187 @@ +-- #254: the launcher's native file pickers must not open while a mouse button +-- is still held. All three (ROM, mod .zip, .sav) block the LOVE loop in +-- io.popen straight out of mousepressed, so SDL never processes the button-up +-- and never drops its pointer capture; on X11 the chooser then draws but +-- ignores the mouse. The X11 half needs a human on a Linux box; the fake +-- mouse here only stays down until pump() is called. + +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("launcher picker pointer grab") +local check, eq = S.check, S.eq + +local RomImporter = require("src.import.RomImporter") + +-- ---------------------------------------------------------------- the funnel +-- The release lives in commandOutput because all three pickers reach popen +-- through it; a fourth picker calling io.popen directly would bring #254 back. +do + local f = io.open("src/import/RomImporter.lua", "rb") + check(f ~= nil, "RomImporter source is readable") + if f then + local src = f:read("*a") + f:close() + local calls = 0 + for _ in src:gmatch("io%.popen%(") do calls = calls + 1 end + eq(calls, 1, "every desktop picker still funnels through the one io.popen" + .. " call, which is where the pointer grab is released (#254)") + end +end + +-- ---------------------------------------------------------------- instrumentation +-- The love stub is shared with every later suite in run_tests.lua, so the getOS +-- FIELD is saved as well as the table: restoring only the reference would hand +-- the next suite this file's Linux answer. +local saved = { + mouse = love.mouse, + event = love.event, + timer = love.timer, + system = love.system, + getOS = love.system and love.system.getOS, + popen = io.popen, +} + +local W -- the world one scenario runs in + +-- releaseAfterPumps: how many pumps SDL needs before it reports the button +-- up. math.huge models a physically stuck button. +local function newWorld(releaseAfterPumps) + W = { + held = true, -- a button is down, the way it is during a click + polls = 0, -- love.mouse.isDown calls + pumps = 0, -- love.event.pump calls + clock = 0, -- fake monotonic seconds, advanced only by sleep + popens = {}, -- one entry per picker launch + releaseAfterPumps = releaseAfterPumps or 3, + overran = false, + } +end + +love.mouse = { + isDown = function() + W.polls = W.polls + 1 + return W.held + end, +} +love.event = { + pump = function() + W.pumps = W.pumps + 1 + -- SDL learns about the release here and nowhere else + if W.pumps >= W.releaseAfterPumps then W.held = false end + -- runaway guard: an unbounded wait would take the whole suite with it + if W.pumps > 20000 then + W.overran = true + W.held = false + end + end, +} +love.timer = { + getTime = function() return W.clock end, + sleep = function(seconds) W.clock = W.clock + (seconds or 0) end, +} +love.system = love.system or {} +love.system.getOS = function() return "Linux" end + +io.popen = function(command) + W.popens[#W.popens + 1] = { + command = command, + heldAtLaunch = W.held, + pumps = W.pumps, + clock = W.clock, + } + -- an empty answer = the player cancelled, so nothing downstream runs + return { + read = function() return "" end, + close = function() return true end, + } +end + +local function fakeImporter() + return setmetatable({ + android = false, + workState = nil, + ready = { red = false, blue = false }, + chooseVersion = nil, + saveNotice = {}, + startPath = function(self, path) self._startedPath = path end, + setError = function(self, msg) self._error = msg end, + _installMod = function(self, path) self._installed = path end, + _importSave = function(self, version, path) self._imported = path end, + }, RomImporter) +end + +-- every picker launch in this scenario found the pointer already released +local function assertReleasedBeforeEveryPicker(label) + check(#W.popens >= 1, label .. ": the picker actually opened") + for i, call in ipairs(W.popens) do + check(call.heldAtLaunch == false, + ("%s: picker %d blocked in io.popen with no mouse button still held" + .. " -- SDL got to drop its pointer capture first (#254)"):format(label, i)) + end + check(W.pumps >= 1, + label .. ": the event queue was pumped before blocking, which is what lets" + .. " SDL see the button-up at all") +end + +local function run() + -- ---- a normal click: the release lands a few pumps in ------------------ + newWorld(3) + local ri = fakeImporter() + ri:choose("red") + assertReleasedBeforeEveryPicker("ROM picker") + eq(ri._error, nil, "a cancelled Linux pick reports no error") + check(W.clock < 1, + "the wait costs nothing a player can perceive on a normal click (waited " + .. tostring(W.clock) .. "s)") + + -- ---- the same for the mod .zip picker ---------------------------------- + newWorld(2) + ri = fakeImporter() + ri:chooseMod() + assertReleasedBeforeEveryPicker("mod .zip picker") + + -- ---- and the .sav picker ------------------------------------------------ + newWorld(2) + ri = fakeImporter() + ri:chooseSaveImport("red") + assertReleasedBeforeEveryPicker(".sav picker") + + -- ---- a button that never comes up must not hang the launcher ----------- + newWorld(math.huge) + ri = fakeImporter() + ri:choose("red") + check(#W.popens >= 1, + "a stuck button still opens the picker rather than hanging the launcher") + check(not W.overran, "the wait ends on its own instead of spinning forever") + local previous = 0 + for i, call in ipairs(W.popens) do + local waited = call.clock - previous + previous = call.clock + check(waited <= 1.05, + ("picker %d waited %.3fs for a stuck button, bounded at one second") + :format(i, waited)) + end + + -- ---- no mouse module at all (headless): the guard bails out ------------ + newWorld(3) + local mouseModule = love.mouse + love.mouse = nil + ri = fakeImporter() + local ok, err = pcall(function() ri:choose("red") end) + love.mouse = mouseModule + check(ok, "a build with no love.mouse still opens the picker instead of" + .. " erroring (" .. tostring(err) .. ")") + check(#W.popens >= 1, "and the picker still ran") +end + +local ok, err = pcall(run) + +love.mouse, love.event, love.timer = saved.mouse, saved.event, saved.timer +if love.system then love.system.getOS = saved.getOS end +love.system = saved.system +io.popen = saved.popen + +if not ok then check(false, "suite raised: " .. tostring(err)) end + +S.finish() diff --git a/tests/parity_portable_mods.lua b/tests/parity_portable_mods.lua new file mode 100644 index 00000000..a417cc91 --- /dev/null +++ b/tests/parity_portable_mods.lua @@ -0,0 +1,378 @@ +-- #330: a portable install must keep installed mods in the game folder. +-- love.filesystem has one write directory (the OS save dir) and it is not +-- relocatable, so installs landed in appdata and remove() could not touch the +-- game folder at all. Reads merge both trees, which is why nothing looked +-- wrong. The fake below reads from three places and writes to one, like +-- physfs; the game folder is real on disk, so the assertions are real files. + +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("portable mod install") +local check, eq = S.check, S.eq + +local FsIo = require("tests.fs_io") +local SaveData = require("src.core.SaveData") + +-- ---------------------------------------------------------------- sandbox +local WIN = FsIo.isWindows + +local function mkdirp(path) + if WIN then + os.execute('mkdir "' .. path:gsub("/", "\\") .. '" 2>nul') + else + os.execute('mkdir -p "' .. path .. '" 2>/dev/null') + end +end + +local function rmrf(path) + if WIN then + os.execute('rmdir /s /q "' .. path:gsub("/", "\\") .. '" 2>nul') + else + os.execute('rm -rf "' .. path .. '" 2>/dev/null') + end +end + +local function fileExists(path) + local f = io.open(path, "rb") + if not f then return false end + f:close() + return true +end + +local function writeReal(path, body) + local f = io.open(path, "wb") + if not f then return false end + f:write(body) + f:close() + return true +end + +local function fileBody(path) + local f = io.open(path, "rb") + if not f then return nil end + local body = f:read("*a") + f:close() + return body +end + +local TMP = (os.getenv("TMPDIR") or os.getenv("TEMP") or "/tmp") + :gsub("[/\\]+$", "") +local SANDBOX = ("%s/pokeport_bug330_%d_%d"):format(TMP, os.time(), + math.random(1, 999999)) +-- Where portable.txt would sit, next to the executable. Deliberately NOT +-- given one: SaveData's detection is cached process-wide, and a later suite +-- must not inherit a temp directory. portableBaseDir() is stubbed instead. +local GAME_FOLDER = SANDBOX .. "/game" +local ZIP_PATH = SANDBOX .. "/portable_probe.zip" + +mkdirp(GAME_FOLDER) +do + local f = io.open(ZIP_PATH, "wb") + if f then + -- readArchive only has to read SOME bytes: the mount is stubbed below + f:write("PK\003\004 stand-in for a mod .zip") + f:close() + end +end +check(FsIo.isDir(GAME_FOLDER), "sandbox game folder created at " .. GAME_FOLDER) +check(fileExists(ZIP_PATH), "sandbox .zip staged at " .. ZIP_PATH) + +local MOD_ID = "portable_probe" + +local function manifestJson(id, name) + return ('{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua"}') + :format(id, name) +end + +-- one top-level mod folder, one nested subfolder, so the recursive copy and +-- the recursive delete are both exercised +local ARCHIVE = { + [MOD_ID .. "/manifest.json"] = manifestJson(MOD_ID, "Portable Probe"), + [MOD_ID .. "/main.lua"] = "return function() end\n", + [MOD_ID .. "/data/extra.txt"] = "nested payload\n", +} + +-- ---------------------------------------------------------------- fake physfs +-- Three read sources, one write sink -- the real asymmetry of the bug. +local realFs = FsIo.new(GAME_FOLDER) +local save, saveDirs, arch = {}, {}, {} + +local function resetTrees() + for k in pairs(save) do save[k] = nil end + for k in pairs(saveDirs) do saveDirs[k] = nil end + for k in pairs(arch) do arch[k] = nil end +end + +-- the immediate child of `name` that `key` lies under, or nil +local function dirChild(key, name) + if name == nil or name == "" then return key:match("^[^/]+") end + local prefix = name .. "/" + if key:sub(1, #prefix) ~= prefix then return nil end + return key:sub(#prefix + 1):match("^[^/]+") +end + +local function mapInfo(map, name, kind) + if map[name] ~= nil then return { type = kind or "file" } end + for key in pairs(map) do + if dirChild(key, name) then return { type = "directory" } end + end + return nil +end + +local vfs = {} + +function vfs.write(name, data) + -- physfs writes to the save directory and nowhere else, portable or not + save[name] = data + return true +end + +function vfs.read(name) + if arch[name] ~= nil then return arch[name] end + if save[name] ~= nil then return save[name] end + local body = realFs.read(name) + return body +end + +function vfs.remove(name) + -- likewise: love.filesystem.remove never reaches outside the save directory + save[name] = nil + saveDirs[name] = nil + return true +end + +function vfs.createDirectory(name) + saveDirs[name] = true + return true +end + +function vfs.getInfo(name, kind) + local info = mapInfo(arch, name) + or mapInfo(save, name) + or mapInfo(saveDirs, name, "directory") + or realFs.getInfo(name) + if info and kind and info.type ~= kind then return nil end + return info +end + +function vfs.getDirectoryItems(name) + local seen, items = {}, {} + local function add(child) + if child and not seen[child] then + seen[child] = true + items[#items + 1] = child + end + end + for key in pairs(arch) do add(dirChild(key, name)) end + for key in pairs(save) do add(dirChild(key, name)) end + for key in pairs(saveDirs) do add(dirChild(key, name)) end + local info = realFs.getInfo(name) + if info and info.type == "directory" then + for _, child in ipairs(realFs.getDirectoryItems(name)) do add(child) end + end + table.sort(items) + return items +end + +function vfs.load(name) + local body = vfs.read(name) + if not body then return nil, "no file" end + return load(body, name) +end + +function vfs.mount(_, point) + for rel, body in pairs(ARCHIVE) do arch[point .. "/" .. rel] = body end + return true +end + +function vfs.unmount() + for k in pairs(arch) do arch[k] = nil end + return true +end + +-- a source run: the game folder IS the physfs source, which is the branch +-- CacheFs takes when it does not need an FFI mount +function vfs.getSource() return GAME_FOLDER end +function vfs.getSaveDirectory() return SANDBOX .. "/os-save-dir" end + +-- ---------------------------------------------------------------- module swap +local savedLoveFs = love.filesystem +local savedPortableBaseDir = SaveData.portableBaseDir +local savedCacheFs = package.loaded["src.import.CacheFs"] +local savedLauncherMods = package.loaded["src.mods.LauncherMods"] + +-- CacheFs caches its resolved root (and its mkdir probe) at module level, so +-- each half of this suite needs its own copy. Both are restored at the end. +local function freshModules(portableDir) + package.loaded["src.import.CacheFs"] = nil + package.loaded["src.mods.LauncherMods"] = nil + SaveData.portableBaseDir = function() return portableDir end + local LauncherMods = require("src.mods.LauncherMods") + local CacheFs = require("src.import.CacheFs") + return LauncherMods, CacheFs +end + +local function saveTreeKeys(prefix) + local hits = {} + for key in pairs(save) do + if key:sub(1, #prefix) == prefix then hits[#hits + 1] = key end + end + table.sort(hits) + return hits +end + +local function listedIds(rows) + local ids = {} + for _, row in ipairs(rows) do ids[#ids + 1] = row.id end + table.sort(ids) + return ids +end + +local function contains(list, want) + for _, v in ipairs(list) do if v == want then return true end end + return false +end + +local ffiAvailable = pcall(require, "ffi") + +local function run() + love.filesystem = vfs + + -- ------------------------------------------------- not portable: unchanged + -- No portable.txt: the mods tree stays in the OS save directory. + local plainMods, plainCache = freshModules(nil) + resetTrees() + eq(plainCache.root(), nil, "with no portable folder the cache root stays unset") + + local ok, id = plainMods.installZip(ZIP_PATH) + check(ok == true, "a non-portable install still succeeds (" .. tostring(id) .. ")") + eq(id, MOD_ID, "and reports the manifest id") + check(save["mods/" .. MOD_ID .. "/manifest.json"] ~= nil, + "a non-portable install still lands in the OS save directory") + check(save["mods/" .. MOD_ID .. "/data/extra.txt"] ~= nil, + "including nested files") + check(not FsIo.isDir(GAME_FOLDER .. "/mods"), + "and writes nothing at all next to the game") + + check(contains(listedIds(plainMods.list()), MOD_ID), + "the mods panel lists it") + local uok = plainMods.uninstall(MOD_ID) + check(uok == true, "a non-portable uninstall succeeds") + eq(#saveTreeKeys("mods/"), 0, + "and clears the save-directory tree, so the row stays gone") + + if plainCache.removeDir then + saveDirs["mods/ghost"] = true + plainCache.removeDir("mods/ghost") + check(saveDirs["mods/ghost"] == nil, + "CacheFs.removeDir falls back to love.filesystem when not portable") + else + check(false, "CacheFs.removeDir exists (the uninstall counterpart to its" + .. " windowless mkdir -- #330)") + end + + -- ------------------------------------------------- portable: the fix + if not ffiAvailable then + print("[#330] no FFI on this interpreter, so CacheFs keeps the cache in" + .. " the save directory by design: the portable half was skipped") + return + end + + local portableMods, portableCache = freshModules(GAME_FOLDER) + resetTrees() + eq(portableCache.root(), GAME_FOLDER, + "portable.txt's folder becomes the cache root") + + -- The launcher leaves CacheFs.prefix on whichever version it last imported, + -- but the mods tree is shared, so this must NOT land in blue/mods/. + portableCache.prefix = "blue/" + ok, id = portableMods.installZip(ZIP_PATH) + eq(portableCache.prefix, "blue/", + "installZip hands the version prefix back to the launcher") + portableCache.prefix = "" + + check(ok == true, "a portable install succeeds (" .. tostring(id) .. ")") + eq(id, MOD_ID, "and reports the manifest id") + + local installed = GAME_FOLDER .. "/mods/" .. MOD_ID + check(fileExists(installed .. "/manifest.json"), + "the mod's manifest is a real file in the game folder, next to the" + .. " executable (#330)") + eq(fileBody(installed .. "/manifest.json"), + ARCHIVE[MOD_ID .. "/manifest.json"], + "with the bytes from the .zip") + check(fileExists(installed .. "/main.lua"), "the entry chunk came with it") + check(fileExists(installed .. "/data/extra.txt"), + "and so did the nested folder's contents") + check(not FsIo.isDir(GAME_FOLDER .. "/blue"), + "nothing landed under the version prefix (mods are shared by Red and Blue)") + eq(#saveTreeKeys("mods/"), 0, + "and nothing was stranded in the OS save directory") + + check(contains(listedIds(portableMods.list()), MOD_ID), + "the mods panel lists the game-folder copy") + + -- ------------------------------------------------- portable uninstall + local uerr + uok, uerr = portableMods.uninstall(MOD_ID) + check(uok == true, "a portable uninstall succeeds (" .. tostring(uerr) .. ")") + check(not fileExists(installed .. "/manifest.json"), + "uninstall deletes the real files out of the game folder (#330)") + check(not fileExists(installed .. "/data/extra.txt"), + "including the nested ones") + check(not FsIo.isDir(installed .. "/data"), + "the emptied subfolder is removed too") + check(not FsIo.isDir(installed), + "and so is the mod folder, so the row does not come back on the next launch") + check(not contains(listedIds(portableMods.list()), MOD_ID), + "the mods panel no longer lists it") + + -- ------------------------------------------------- a mod already on disk + -- The other half of the report: a mod carried over from another machine. + -- Uninstall used to delete nothing, so the row was back on the next launch. + local shipped = "shipped_probe" + local shippedDir = GAME_FOLDER .. "/mods/" .. shipped + mkdirp(shippedDir .. "/data") + local wrote = writeReal(shippedDir .. "/manifest.json", + manifestJson(shipped, "Shipped Probe")) + and writeReal(shippedDir .. "/main.lua", "return function() end\n") + and writeReal(shippedDir .. "/data/extra.txt", "carried over\n") + check(wrote, "staged a mod that was already sitting in the game folder") + check(contains(listedIds(portableMods.list()), shipped), + "the panel lists it (reads have always found the game folder)") + uok, uerr = portableMods.uninstall(shipped) + check(uok == true, "uninstalling it succeeds (" .. tostring(uerr) .. ")") + check(not fileExists(shippedDir .. "/manifest.json"), + "and it really is deleted off the disk this time (#330)") + check(not fileExists(shippedDir .. "/data/extra.txt"), "nested files too") + check(not FsIo.isDir(shippedDir), + "the folder is gone, so it does not come back on the next launch") + check(not contains(listedIds(portableMods.list()), shipped), + "and the panel row stays gone") + + -- ------------------------------------------------- the pre-fix leftover + -- Upgrading from a buggy build leaves a copy in appdata, which physfs + -- searches first, so uninstall has to clear that too. + local legacy = "legacy_twin" + save["mods/" .. legacy .. "/manifest.json"] = manifestJson(legacy, "Legacy Twin") + save["mods/" .. legacy .. "/main.lua"] = "return function() end\n" + check(contains(listedIds(portableMods.list()), legacy), + "a pre-fix copy in the OS save directory shows up in the panel") + uok = portableMods.uninstall(legacy) + check(uok == true, "uninstalling it succeeds") + eq(#saveTreeKeys("mods/" .. legacy), 0, + "and a pre-fix copy stranded in the OS save directory is cleared too") +end + +local ok, err = pcall(run) + +love.filesystem = savedLoveFs +SaveData.portableBaseDir = savedPortableBaseDir +package.loaded["src.import.CacheFs"] = savedCacheFs +package.loaded["src.mods.LauncherMods"] = savedLauncherMods +rmrf(SANDBOX) + +if not ok then check(false, "suite raised: " .. tostring(err)) end + +S.finish() diff --git a/tests/parity_rival_walkoff.lua b/tests/parity_rival_walkoff.lua index 2c78a864..5a27469d 100644 --- a/tests/parity_rival_walkoff.lua +++ b/tests/parity_rival_walkoff.lua @@ -61,13 +61,20 @@ local function capture(mapMod, game, x, y) return rows end --- Route22Rival1ExitMovementData1 / Data2 -local R1_Y5 = { "right", "right", "down", "down", "down", "down", "down" } -local R1_Y4 = { "up", "right", "right", "right", +-- Route22Rival1ExitMovementData1 / Data2, keyed on wSavedCoordIndex (which +-- Route22RivalBattleCoords entry matched, counted from 1) and NOT on the +-- rival's own row: index 1 is the (29,4) tile, where he stops BELOW the +-- player on (29,5) and leaves east; index 2 is the (29,5) tile, where he +-- stops LEFT of him on (28,5) and must step UP to row 4 to get around him. +-- This mapping was backwards until #236 (the y=4 walk began UP into the +-- cliff cell (28,3)), so these constants flipped with the fix. +local R1_Y4 = { "right", "right", "down", "down", "down", "down", "down" } +local R1_Y5 = { "up", "right", "right", "right", "down", "down", "down", "down", "down", "down" } --- Route22Rival2ExitMovementData1 falls through Data2 on y=5 -local R2_Y5 = { "left", "left", "left", "left" } -local R2_Y4 = { "left", "left", "left" } +-- Route22Rival2ExitMovementData1 falls through Data2, so index 1 (y=4) is +-- LEFT x4 from (29,5) and index 2 (y=5) LEFT x3 from (28,5) +local R2_Y4 = { "left", "left", "left", "left" } +local R2_Y5 = { "left", "left", "left" } local CER_X20 = { "right", "down", "down", "down", "down", "down", "down" } local CER_X21 = { "left", "down", "down", "down", "down", "down", "down" } @@ -75,8 +82,8 @@ do local game = { save = { flags = { EVENT_GOT_POKEDEX = true } }, data = {} } local w5 = findWalk(capture(story5.ROUTE_22, game, 29, 5)) local w4 = findWalk(capture(story5.ROUTE_22, game, 29, 4)) - check(dirsEqual(w5[3], R1_Y5), "Rival1 y=5 exits toward Viridian (R,R,D×5)") - check(dirsEqual(w4[3], R1_Y4), "Rival1 y=4 exits U,R×3,D×6") + check(dirsEqual(w5[3], R1_Y5), "Rival1 y=5 goes U,R×3,D×6 around the player") + check(dirsEqual(w4[3], R1_Y4), "Rival1 y=4 exits toward Viridian (R,R,D×5)") local rows = capture(story5.ROUTE_22, { save = { flags = { EVENT_GOT_POKEDEX = true } }, data = {} }, 29, 5) for _, r in ipairs(rows) do @@ -93,8 +100,8 @@ do } local w5 = findWalk(capture(story5.ROUTE_22, { save = { flags = flags }, data = {} }, 29, 5)) local w4 = findWalk(capture(story5.ROUTE_22, { save = { flags = flags }, data = {} }, 29, 4)) - check(dirsEqual(w5[3], R2_Y5), "Rival2 y=5 exits left×4 toward League") - check(dirsEqual(w4[3], R2_Y4), "Rival2 y=4 exits left×3 toward League") + check(dirsEqual(w5[3], R2_Y5), "Rival2 y=5 exits left×3 toward League") + check(dirsEqual(w4[3], R2_Y4), "Rival2 y=4 exits left×4 toward League") end do diff --git a/tests/parity_save_fly_towns.lua b/tests/parity_save_fly_towns.lua new file mode 100644 index 00000000..46efec9f --- /dev/null +++ b/tests/parity_save_fly_towns.lua @@ -0,0 +1,240 @@ +-- Parity test: imported saves keep their FLY town set (#263). pokered's +-- wTownVisitedFlag (ram/wram.asm:2057) is a NUM_CITY_MAPS = 11 bit array whose +-- bit index IS the town's map number, LSB first: town_map.asm +-- BuildFlyLocationsList does `srl d / rr e` with b counting up from 0, so bit 0 +-- is map 0 = PALLET_TOWN. src/save_convert/GenSave.lua never modeled it, so an +-- imported .sav arrived with save.visited == nil and FLY offered one town. +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 save fly towns") +local check, eq = S.check, S.eq + +local bit = require("bit") +local GenSave = require("src.save_convert.GenSave") +local SaveConvert = require("src.save_convert.SaveConvert") +local SaveData = require("src.core.SaveData") +local FlyMenu = require("src.ui.FlyMenu") + +-- loadData() hands back the crosswalk set GenSave wants ({pokemon, moves, +-- items, maps, eventFlags}) and installs the charmap as a side effect +local cwData = assert(SaveConvert.loadData(), "save-convert crosswalk data") +local O = GenSave.OFFSETS + +-- ------------------------------------------------------------------ +-- The wTownVisitedFlag offset, re-derived here rather than read off +-- GenSave.OFFSETS: a suite trusting the codec's own constant cannot fail when +-- that constant is wrong. O.mainData mirrors wMainDataStart, and the flag sits +-- 1044 bytes in -- 359 past wPlayerCoins (mainData + 685) and 60 before +-- wEventFlags (mainData + 1104), both of which predate this fix. +-- ------------------------------------------------------------------ +local TOWN_VISITED = O.mainData + 1044 +local NUM_CITY_MAPS = 11 + +-- Independent re-implementation of CalcCheckSum (complement of the additive +-- byte sum, engine/menus/save.asm), so a hand-poked image can be re-sealed +-- without going back through the encoder under test. +local function rawChecksum(bytes, from, to) + local sum = 0 + for i = from, to - 1 do sum = bit.band(sum + bytes:byte(i + 1), 0xFF) end + return bit.band(bit.bnot(sum), 0xFF) +end + +local function reseal(bytes) + local ck = rawChecksum(bytes, O.checksumStart, O.checksumEnd) + return bytes:sub(1, O.mainChecksum) .. string.char(ck) + .. bytes:sub(O.mainChecksum + 2) +end + +-- write the two wTownVisitedFlag bytes straight into an image, the way a +-- cartridge would have left them, and re-seal so importSav accepts it +local function pokeTownBytes(bytes, b0, b1) + return reseal(bytes:sub(1, TOWN_VISITED) .. string.char(b0, b1) + .. bytes:sub(TOWN_VISITED + 3)) +end + +local function townBytesOf(bytes) + return bytes:byte(TOWN_VISITED + 1), bytes:byte(TOWN_VISITED + 2) +end + +-- ------------------------------------------------------------------ +-- 1) the bit-index-is-the-map-index premise, straight off generated data +-- ------------------------------------------------------------------ +-- data/generated/maps.lua carries pokered's map constant order, so indices 0..10 +-- must be PALLET_TOWN..SAFFRON_CITY, the same 11 the flag_array covers. An +-- extractor that renumbered them would leave every bit assertion meaningless. +local TOWN_BY_BIT = {} +for id, def in pairs(Data.maps) do + if type(def.index) == "number" and def.index >= 0 + and def.index < NUM_CITY_MAPS then + TOWN_BY_BIT[def.index] = id + end +end +local EXPECTED_ORDER = { + [0] = "PALLET_TOWN", "VIRIDIAN_CITY", "PEWTER_CITY", "CERULEAN_CITY", + "LAVENDER_TOWN", "VERMILION_CITY", "CELADON_CITY", "FUCHSIA_CITY", + "CINNABAR_ISLAND", "INDIGO_PLATEAU", "SAFFRON_CITY", +} +for i = 0, NUM_CITY_MAPS - 1 do + eq(TOWN_BY_BIT[i], EXPECTED_ORDER[i], + ("map index %d is %s (wTownVisitedFlag bit %d)"):format(i, EXPECTED_ORDER[i], i)) +end + +local function bitsFor(set) + local b0, b1 = 0, 0 + for i = 0, NUM_CITY_MAPS - 1 do + if set[TOWN_BY_BIT[i]] then + if i < 8 then b0 = bit.bor(b0, bit.lshift(1, i)) + else b1 = bit.bor(b1, bit.lshift(1, i - 8)) end + end + end + return b0, b1 +end + +-- ------------------------------------------------------------------ +-- 2) decode: the town bits become save.visited +-- ------------------------------------------------------------------ + +local fresh = SaveData.newGame({ playerName = "RED", rivalName = "BLUE" }) +fresh.flags = { EVENT_GOT_POKEDEX = true, EVENT_GOT_STARTER = true } +fresh.money = 54321 +local base = GenSave.encode(fresh, cwData, nil) +eq(#base, GenSave.SAVE_SIZE, "baseline image is 32768 bytes") + +-- the reported case: a completed cartridge save, all eleven bits set +local completed = pokeTownBytes(base, 0xFF, 0x07) +local compSave, compErr = SaveConvert.importSav(completed, 2) +check(compSave ~= nil, "a completed-cartridge image imports (" .. tostring(compErr) .. ")") +check(type(compSave and compSave.visited) == "table", + "an imported save arrives with a visited table, not nil (#263)") +local compVisited = (compSave and compSave.visited) or {} +local compCount = 0 +for _ in pairs(compVisited) do compCount = compCount + 1 end +eq(compCount, NUM_CITY_MAPS, + "all eleven towns come back visited from a full wTownVisitedFlag") +for i = 0, NUM_CITY_MAPS - 1 do + check(compVisited[EXPECTED_ORDER[i]] == true, + EXPECTED_ORDER[i] .. " is visited on a completed save") +end +-- decode must stop at bit 10: ROUTE_1 is map index 12 and can never be a +-- FLY town, so a loop that ran past NUM_CITY_MAPS would show up here +check(compVisited.ROUTE_1 == nil, + "a route never lands in the visited set (the loop stops at NUM_CITY_MAPS)") + +-- the reported 3DS VC save: Cerulean and Fuchsia visited, Celadon and Saffron +-- not. Bits 0,1,2,3,7 -> 0x8F 0x00. +local partialSet = { + PALLET_TOWN = true, VIRIDIAN_CITY = true, PEWTER_CITY = true, + CERULEAN_CITY = true, FUCHSIA_CITY = true, +} +local pb0, pb1 = bitsFor(partialSet) +eq(pb0, 0x8F, "bits 0,1,2,3,7 pack LSB-first into byte 0 = 0x8F") +eq(pb1, 0x00, "no town above bit 7 is set, so byte 1 = 0x00") +local partial = pokeTownBytes(base, pb0, pb1) +local partSave = assert(SaveConvert.importSav(partial, 2), "partial image imports") +local partVisited = partSave.visited or {} +for id in pairs(partialSet) do + check(partVisited[id] == true, id .. " decodes as visited") +end +check(partVisited.CELADON_CITY == nil, + "CELADON_CITY was never visited and does not come back visited") +check(partVisited.SAFFRON_CITY == nil, + "SAFFRON_CITY was never visited and does not come back visited") +check(partVisited.INDIGO_PLATEAU == nil, + "INDIGO_PLATEAU was never visited and does not come back visited") + +-- ------------------------------------------------------------------ +-- 3) the offset is right: poking the town bytes disturbs nothing else +-- ------------------------------------------------------------------ +-- An offset a few bytes off still "works" for the checks above while quietly +-- corrupting a neighbour: wEventFlags starts 60 bytes later, wPlayerCoins 359 +-- earlier, so re-decode the poked image and confirm both survived. +local baseDec = GenSave.decode(base, cwData) +local compDec = GenSave.decode(completed, cwData) +eq(#compDec.warnings, 0, "the re-sealed image passes its own checksum") +eq(compDec.money, baseDec.money, "poking wTownVisitedFlag leaves money alone") +check(compDec.flags.EVENT_GOT_POKEDEX and compDec.flags.EVENT_GOT_STARTER, + "poking wTownVisitedFlag leaves the event flags alone") +local baseFlagCount, compFlagCount = 0, 0 +for _ in pairs(baseDec.flags) do baseFlagCount = baseFlagCount + 1 end +for _ in pairs(compDec.flags) do compFlagCount = compFlagCount + 1 end +eq(compFlagCount, baseFlagCount, + "setting all eleven town bits sets no event flag (the arrays do not overlap)") + +-- ------------------------------------------------------------------ +-- 4) encode: the set goes back out in pokered's bit layout +-- ------------------------------------------------------------------ +-- Hand-picked so an MSB-first writer cannot pass: PALLET is bit 0 and +-- FUCHSIA bit 7 (byte 0 = 0x81), SAFFRON is bit 10 (byte 1 = 0x04). +local outSave = SaveData.newGame({ playerName = "RED", rivalName = "BLUE" }) +outSave.visited = { + PALLET_TOWN = true, FUCHSIA_CITY = true, SAFFRON_CITY = true, +} +local outBytes = GenSave.encode(outSave, cwData, nil) +local ob0, ob1 = townBytesOf(outBytes) +eq(ob0, 0x81, "PALLET (bit 0) + FUCHSIA (bit 7) write byte 0 = 0x81") +eq(ob1, 0x04, "SAFFRON (bit 10) writes byte 1 = 0x04") + +-- and the full loop the reporter cares about: cartridge -> port -> cartridge +local rtBytes = GenSave.encode(compSave, cwData, nil) +local r0, r1 = townBytesOf(rtBytes) +eq(r0, 0xFF, "a completed save exports byte 0 = 0xFF") +eq(r1, 0x07, "a completed save exports byte 1 = 0x07") +local rtSave = assert(SaveConvert.importSav(reseal(rtBytes), 2), + "the exported image re-imports") +local rtVisited = rtSave.visited or {} +for i = 0, NUM_CITY_MAPS - 1 do + check(rtVisited[EXPECTED_ORDER[i]] == true, + EXPECTED_ORDER[i] .. " survives export and re-import") +end + +-- A save with no `visited` key says nothing about the set, so encoding over a +-- template keeps the template's bits rather than un-flying it on the way back +-- to hardware. +local silent = SaveData.newGame({ playerName = "RED", rivalName = "BLUE" }) +silent.visited = nil +local kept = GenSave.encode(silent, cwData, completed) +local k0, k1 = townBytesOf(kept) +eq(k0, 0xFF, "a save with no visited key keeps the template's byte 0") +eq(k1, 0x07, "a save with no visited key keeps the template's byte 1") + +-- ------------------------------------------------------------------ +-- 5) the payoff: the real FLY picker lists them +-- ------------------------------------------------------------------ +-- src/ui/FlyMenu.lua walks data.field.flyOrder and keeps the entries in +-- save.visited, so this is the list the player sees under FLY. Before #263 an +-- imported save reached it with visited == nil and the list came back empty. +local function flyLabels(save) + local menu = FlyMenu.new({ data = Data, save = save }) + local out = {} + for _, item in ipairs(menu.items or {}) do out[#out + 1] = item.value end + return out +end + +local compList = flyLabels(compSave) +eq(#compList, NUM_CITY_MAPS, + "FLY on a completed imported save lists all eleven destinations") +-- flyOrder puts the towns in map-constant order after the dungeon escape +-- spots, which is the order the picker shows +for i = 0, NUM_CITY_MAPS - 1 do + eq(compList[i + 1], EXPECTED_ORDER[i], + ("FLY entry %d is %s"):format(i + 1, EXPECTED_ORDER[i])) +end + +local partList = flyLabels(partSave) +eq(#partList, 5, "FLY on the partial save lists exactly the five visited towns") +local partSeen = {} +for _, id in ipairs(partList) do partSeen[id] = true end +check(partSeen.CERULEAN_CITY and partSeen.FUCHSIA_CITY, + "Cerulean AND Fuchsia are both offered (the reported 3DS VC case)") +check(not partSeen.CELADON_CITY and not partSeen.SAFFRON_CITY, + "unvisited towns stay out of the FLY list") + +-- negative control, the pre-#263 shape: no visited table is an empty picker +local blank = SaveData.newGame({ playerName = "RED", rivalName = "BLUE" }) +blank.visited = nil +eq(#flyLabels(blank), 0, "a save with no visited set has no FLY destinations") + +S.finish() diff --git a/tests/parity_save_nicknames.lua b/tests/parity_save_nicknames.lua new file mode 100644 index 00000000..9bc30999 --- /dev/null +++ b/tests/parity_save_nicknames.lua @@ -0,0 +1,282 @@ +-- Parity: Gen1 has no "is nicknamed" bit, so a declined nickname stores the +-- species' own display name in the slot, and the save codec has to translate +-- that both ways (#257). engine/menus/naming_screen.asm AskName's +-- .declinedNickname copies wNameBuffer over the nickname field, and +-- engine/pokemon/evos_moves.asm RenameEvolvedMon recovers the distinction by +-- comparing the two. This port models it as mon.nickname == nil. +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 save nicknames") +local check, eq = S.check, S.eq + +local GenSave = require("src.save_convert.GenSave") +local SaveConvert = require("src.save_convert.SaveConvert") +local SaveData = require("src.core.SaveData") +local Pokemon = require("src.pokemon.Pokemon") +local Evolution = require("src.pokemon.Evolution") + +local cwData = assert(SaveConvert.loadData(), "save-convert crosswalk data") +local O = GenSave.OFFSETS +local NAME_LENGTH = 11 +local charmap = assert(loadfile("src/save_convert/data/charmap.lua"))() + +-- ------------------------------------------------------------------ +-- Independent name codec, so the byte assertions below cannot inherit an +-- encoder bug from the module under test. Same rule as GenSave's encodeName: +-- one charmap byte per game character (UTF-8 aware, since names.asm carries +-- the male/female signs), then a single $50 terminator. +-- ------------------------------------------------------------------ +local function charsOf(text) + local out, pos = {}, 1 + while pos <= #text do + local b0 = text:byte(pos) + local len = (b0 < 0x80 and 1) or (b0 < 0xE0 and 2) or (b0 < 0xF0 and 3) or 4 + out[#out + 1] = text:sub(pos, pos + len - 1) + pos = pos + len + end + return out +end + +local function expectBytes(text) + local out = {} + for _, ch in ipairs(charsOf(text)) do + out[#out + 1] = string.char(charmap.byToken[ch] or charmap.byToken["?"]) + end + out[#out + 1] = string.char(0x50) + return table.concat(out) +end + +-- the raw nickname slot of party index i (0-based) up to and including its +-- first $50: what a cartridge or PKHeX would read out of the field +local function partyNickSlot(bytes, i) + local off = O.partyMonNicks + i * NAME_LENGTH + local raw = bytes:sub(off + 1, off + NAME_LENGTH) + local term = raw:find("\80", 1, true) + return term and raw:sub(1, term) or raw +end + +local QUESTION = string.char(charmap.byToken["?"]) -- $E6 + +-- name slots are raw game bytes, so a failure printed verbatim is mojibake; +-- every byte assertion below compares hex so the diff is readable +local function hex(s) + return (s:gsub(".", function(c) return ("%02X "):format(c:byte()) end)):gsub("%s+$", "") +end + +-- ------------------------------------------------------------------ +-- 0) the premise: def.name is byte-for-byte the cartridge's own display +-- name (extracted from data/pokemon/names.asm), and the ROM constant is NOT. +-- ------------------------------------------------------------------ +-- UTF-8 for U+2642 MALE SIGN / U+2640 FEMALE SIGN, spelled as bytes because +-- LuaJIT is Lua 5.1 and this file should not depend on \u escapes +eq(Data.pokemon.NIDORAN_M.name, "NIDORAN\226\153\130", + "NIDORAN_M's display name carries the male sign") +eq(Data.pokemon.NIDORAN_F.name, "NIDORAN\226\153\128", + "NIDORAN_F's display name carries the female sign") +eq(Data.pokemon.MR_MIME.name, "MR.MIME", "MR_MIME's display name is MR.MIME") +eq(Data.pokemon.FARFETCHD.name, "FARFETCH'D", "FARFETCHD's display name keeps its apostrophe") +-- "_" has no glyph in the charmap, so encoding the ROM constant falls back to +-- "?" ($E6): exactly the corruption the old encode wrote out +check(charmap.byToken["_"] == nil, + "the charmap has no glyph for \"_\", so a ROM constant cannot encode cleanly") +check(expectBytes("NIDORAN_M"):find(QUESTION, 1, true) ~= nil, + "encoding the constant NIDORAN_M really does produce a \"?\" byte") +check(expectBytes(Data.pokemon.NIDORAN_M.name):find(QUESTION, 1, true) == nil, + "encoding the display name NIDORAN(male) produces no \"?\" byte") + +-- every species name has to survive the charmap exactly, or the equality test +-- the fix rests on mis-fires on some species +local unmappable = {} +for id, def in pairs(Data.pokemon) do + for _, ch in ipairs(charsOf(def.name or "")) do + if not charmap.byToken[ch] then unmappable[#unmappable + 1] = id .. ":" .. ch end + end +end +eq(#unmappable, 0, + "every species display name maps to real charmap glyphs (" .. + table.concat(unmappable, ",") .. ")") + +-- ------------------------------------------------------------------ +-- 1) decode: a stored name equal to the species name is NOT a nickname +-- ------------------------------------------------------------------ +-- the nickname field is set explicitly on every fixture, so encode writes +-- those exact characters and the decode half is tested on its own +local function fixtureMon(species, level, storedName) + local mon = Pokemon.new(Data, species, level) + mon.nickname = storedName + mon.ot = "ASH" + mon.otId = 12345 + mon.catchRate = Data.pokemon[species].catchRate + return mon +end + +local cart = SaveData.newGame({ playerName = "ASH", rivalName = "GARY" }) +cart.party = { + -- the reported case: an un-nicknamed SQUIRTLE, one level short of evolving + fixtureMon("SQUIRTLE", 15, "SQUIRTLE"), + -- the control: a genuinely nicknamed one, same species, same level + fixtureMon("SQUIRTLE", 15, "SHELLY"), + -- the awkward display names, stored as the cartridge stores them + fixtureMon("NIDORAN_M", 10, Data.pokemon.NIDORAN_M.name), + fixtureMon("MR_MIME", 20, Data.pokemon.MR_MIME.name), + fixtureMon("FARFETCHD", 20, Data.pokemon.FARFETCHD.name), + -- the trade-evolution pair from the report + fixtureMon("GRAVELER", 30, "GRAVELER"), +} +cart.boxes = {} +for i = 1, 12 do cart.boxes[i] = {} end +cart.boxes[3] = { + fixtureMon("KADABRA", 20, "KADABRA"), + fixtureMon("PIKACHU", 10, "PIKA"), +} +cart.currentBox = 1 + +local cartBytes = GenSave.encode(cart, cwData, nil) +local imported = assert(SaveConvert.importSav(cartBytes, 2), "cartridge image imports") +local p = imported.party + +eq(p[1] and p[1].species, "SQUIRTLE", "party slot 1 is a SQUIRTLE") +eq(p[1] and p[1].nickname, nil, + "a stored \"SQUIRTLE\" on a SQUIRTLE imports as NOT nicknamed (#257)") +eq(p[2] and p[2].nickname, "SHELLY", + "a genuine nickname survives import untouched") +eq(p[3] and p[3].nickname, nil, + "an un-nicknamed NIDORAN(male) imports as NOT nicknamed") +eq(p[4] and p[4].nickname, nil, + "an un-nicknamed MR.MIME imports as NOT nicknamed") +eq(p[5] and p[5].nickname, nil, + "an un-nicknamed FARFETCH'D imports as NOT nicknamed") +eq(p[6] and p[6].nickname, nil, + "an un-nicknamed GRAVELER imports as NOT nicknamed") + +local box = imported.boxes[3] +eq(box[1] and box[1].species, "KADABRA", "box 3 slot 1 is a KADABRA") +eq(box[1] and box[1].nickname, nil, + "the boxed un-nicknamed KADABRA imports as NOT nicknamed") +eq(box[2] and box[2].nickname, "PIKA", + "a boxed genuine nickname survives import untouched") + +-- ------------------------------------------------------------------ +-- 2) the payoff: an imported mon renames itself when it evolves +-- ------------------------------------------------------------------ +-- RenameEvolvedMon as this port expresses it: the display name is +-- `mon.nickname or def.name`, so a nil nickname follows the species. +local evoGame = { + data = Data, + save = { pokedex = { seen = {}, owned = {} } }, +} +local function displayName(mon) + return mon.nickname or Data.pokemon[mon.species].name +end + +local plain, named = p[1], p[2] +eq(displayName(plain), "SQUIRTLE", "the imported un-nicknamed mon reads SQUIRTLE before evolving") +eq(displayName(named), "SHELLY", "the imported nicknamed mon reads SHELLY before evolving") +plain.level, named.level = 16, 16 +eq(Evolution.pendingLevelEvo(Data, plain), "WARTORTLE", "SQUIRTLE evolves at 16") +Evolution.apply(evoGame, plain, "WARTORTLE", "LEVEL") +Evolution.apply(evoGame, named, "WARTORTLE", "LEVEL") +eq(displayName(plain), "WARTORTLE", + "an imported un-nicknamed SQUIRTLE reads WARTORTLE after evolving (#257)") +eq(displayName(named), "SHELLY", + "an imported nicknamed SQUIRTLE keeps SHELLY after evolving") + +-- the trade-evolution half: GRAVELER -> GOLEM and KADABRA -> ALAKAZAM, both +-- un-nicknamed on the cartridge +local grav, kad = p[6], box[1] +-- box_struct stores no computed stats (macros/ram.asm: they are recalculated +-- on withdrawal), so a boxed mon decodes without mon.stats; give the KADABRA +-- the stats it would get out of the PC before evolving it +kad.stats = require("src.pokemon.Stats").calc(Data.pokemon.KADABRA, kad.level, + kad.dvs, kad.statExp) +kad.hp = kad.stats.hp +Evolution.apply(evoGame, grav, "GOLEM", "TRADE") +Evolution.apply(evoGame, kad, "ALAKAZAM", "TRADE") +eq(displayName(grav), "GOLEM", "a trade-evolved imported GRAVELER reads GOLEM") +eq(displayName(kad), "ALAKAZAM", "a trade-evolved imported KADABRA reads ALAKAZAM") + +-- ------------------------------------------------------------------ +-- 3) encode: a nil nickname writes the DISPLAY name, not the ROM constant +-- ------------------------------------------------------------------ +local eng = SaveData.newGame({ playerName = "OAK", rivalName = "BLUE" }) +local ENG_PARTY = { "NIDORAN_M", "NIDORAN_F", "MR_MIME", "FARFETCHD", "SQUIRTLE" } +eng.party = {} +for _, species in ipairs(ENG_PARTY) do + local mon = Pokemon.new(Data, species, 10) + mon.nickname = nil -- never nicknamed, the engine's own spelling + mon.ot = "OAK" + mon.otId = eng.player.id + mon.catchRate = Data.pokemon[species].catchRate + eng.party[#eng.party + 1] = mon +end +-- one real nickname in the same image, so the rule cannot regress into +-- dropping every nickname on export +local nicked = Pokemon.new(Data, "PIKACHU", 10) +nicked.nickname = "SPARKY" +nicked.ot = "OAK" +nicked.otId = eng.player.id +eng.party[#eng.party + 1] = nicked + +local engBytes = GenSave.encode(eng, cwData, nil) +for i, species in ipairs(ENG_PARTY) do + local slot = partyNickSlot(engBytes, i - 1) + local want = expectBytes(Data.pokemon[species].name) + eq(hex(slot), hex(want), + species .. " with no nickname exports the cartridge's display name \"" .. + Data.pokemon[species].name .. "\"") + check(slot:find(QUESTION, 1, true) == nil, + species .. "'s exported nickname field holds no \"?\" byte") +end +eq(hex(partyNickSlot(engBytes, #ENG_PARTY)), hex(expectBytes("SPARKY")), + "a real nickname is exported verbatim") + +-- and the loop closes: those exports read back as un-nicknamed +local engBack = assert(SaveConvert.importSav(engBytes, 2), "engine-origin image re-imports") +for i, species in ipairs(ENG_PARTY) do + eq(engBack.party[i] and engBack.party[i].species, species, + "engine-origin party slot " .. i .. " is " .. species) + eq(engBack.party[i] and engBack.party[i].nickname, nil, + species .. " round-trips through export and import still un-nicknamed") +end +eq(engBack.party[#ENG_PARTY + 1] and engBack.party[#ENG_PARTY + 1].nickname, "SPARKY", + "SPARKY round-trips through export and import as a real nickname") + +-- ------------------------------------------------------------------ +-- 4) the box side of the export, same rule +-- ------------------------------------------------------------------ +local boxOut = SaveData.newGame({ playerName = "OAK", rivalName = "BLUE" }) +boxOut.boxes = {} +for i = 1, 12 do boxOut.boxes[i] = {} end +local boxMon = Pokemon.new(Data, "NIDORAN_F", 12) +boxMon.nickname = nil +boxMon.ot = "OAK" +boxMon.otId = boxOut.player.id +local boxNamed = Pokemon.new(Data, "MR_MIME", 22) +boxNamed.nickname = "MIMEY" +boxNamed.ot = "OAK" +boxNamed.otId = boxOut.player.id +boxOut.boxes[1] = { boxMon, boxNamed } +boxOut.currentBox = 1 + +local boxBytes = GenSave.encode(boxOut, cwData, nil) +local boxBack = assert(SaveConvert.importSav(boxBytes, 2), "boxed image re-imports") +eq(boxBack.boxes[1][1] and boxBack.boxes[1][1].species, "NIDORAN_F", + "boxed slot 1 is a NIDORAN(female)") +eq(boxBack.boxes[1][1] and boxBack.boxes[1][1].nickname, nil, + "a boxed un-nicknamed NIDORAN(female) round-trips still un-nicknamed") +eq(boxBack.boxes[1][2] and boxBack.boxes[1][2].nickname, "MIMEY", + "a boxed real nickname round-trips untouched") +-- the raw box nickname slot must hold the display name, "?" free +do + local base = O.curBoxData + local off = base + 22 + 20 * (33 + NAME_LENGTH) -- boxMonNicks, slot 0 + local raw = boxBytes:sub(off + 1, off + NAME_LENGTH) + local term = raw:find("\80", 1, true) + eq(hex(term and raw:sub(1, term) or raw), hex(expectBytes(Data.pokemon.NIDORAN_F.name)), + "the boxed NIDORAN(female)'s stored name is the display name, not NIDORAN_F") +end + +S.finish() diff --git a/tests/parity_shift_exp_share.lua b/tests/parity_shift_exp_share.lua new file mode 100644 index 00000000..601af1a7 --- /dev/null +++ b/tests/parity_shift_exp_share.lua @@ -0,0 +1,206 @@ +-- Parity test: the SHIFT free switch hands the WHOLE exp share to the mon +-- coming in (#275). EnemySendOutFirstMon zeroes wPartyGainExpFlags and +-- wPartyFoughtCurrentEnemyFlags before jumping to SwitchPlayerMon, which sets +-- only the incoming mon's bit (engine/battle/core.asm:1436-1443, 2424-2433); +-- GiveExperiencePoints divides by the set bits (experience.asm:295-300), so a +-- leftover flag halves the payout. The reset was never ported. +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.pokemon and Data.pokemon.RATTATA) then Data:load() end +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) + +local Pokemon = require("src.pokemon.Pokemon") +local BattleState = require("src.battle.BattleState") +local Experience = require("src.battle.Experience") +local Screens = require("src.ui.Screens") +local S = require("tests.harness").suite("parity shift exp share") +local check, eq = S.check, S.eq + +-- Minimal game stub: what BattleState.newTrainer / enemyMonFainted touch. +-- battleStyle is per-scenario, so the caller sets it. +local function freshGame(style) + return { + data = Data, + save = { + party = { + Pokemon.new(Data, "BULBASAUR", 50), + Pokemon.new(Data, "SQUIRTLE", 40), + }, + player = { name = "RED" }, + inventory = {}, + options = { battleStyle = style }, + pokedex = { seen = {}, owned = {} }, + flags = {}, + money = 0, + }, + stack = { push = function() end, pop = function() end, top = function() end }, + } +end + +-- Drain the queue, running act rows and answering the SHIFT prompt. `yes` +-- picks YES (the free switch) or NO; `pick` is the party mon the battle +-- PartyMenu would hand back. Text rows are collected in order so the exp +-- line can be read the way the player reads it. +local function pump(b, yes, pick, seen) + local origPush = Screens.push + Screens.push = function(_, id, opts) + if id == "PartyMenu" and opts and opts.onSwitch and pick then + opts.onSwitch(pick) + end + end + local ok, err = pcall(function() + local n = 0 + while #b.queue > 0 and n < 500 do + n = n + 1 + local item = table.remove(b.queue, 1) + if item.fn then + b.nextInsert = 0 + item.fn() + elseif item.text then + seen[#seen + 1] = item.text + if item.choice and item.text:find("change POKéMON", 1, true) then + item.choice(yes) + end + end + end + end) + Screens.push = origPush + return ok, err +end + +-- the number _ExpPointsText prints (wExpAmountGained), out of the port's +-- "%s gained\n%d EXP. Points!" row +local function expLine(seen) + for _, t in ipairs(seen) do + local n = t:match("gained\n(%d+) EXP%. Points!") + if n then return tonumber(n), t end + end + return nil +end + +-- OPP_YOUNGSTER 1 is RATTATA 11 / EKANS 11 in both versions: two slots, so +-- there is a second mon to KO after the switch. +local YOUNGSTER, ROSTER = "OPP_YOUNGSTER", 1 + +-- Set up the fight at the moment the first enemy mon drops, with the lead the +-- only participant (as markParticipant left it), so the caller only has to pump. +local function atFirstKO(style) + local Game = freshGame(style) + local b = BattleState.newTrainer(Game, YOUNGSTER, ROSTER) + b.enemyParty[1].hp = 0 + b.enemyIndex = 1 + b.enemy.mon = b.enemyParty[1] + b.participants = { [Game.save.party[1]] = true } + b:enemyMonFainted() + return Game, b +end + +-- KO whatever is out now and read back the exp line for it. +local function koAndRead(b) + local before = {} + for i, mon in ipairs(b.game.save.party) do before[i] = mon.exp end + local seen = {} + b.enemy.mon.hp = 0 + -- updateQueue zeroes this before every act row it runs; calling + -- enemyMonFainted straight from the test has to do the same, or the *Next + -- inserters index past the end of the drained queue and leave a hole + b.nextInsert = 0 + b:enemyMonFainted() + local ok, err = pump(b, false, nil, seen) + local delta = {} + for i, mon in ipairs(b.game.save.party) do delta[i] = mon.exp - before[i] end + return ok, err, seen, delta +end + +do + local Game, b = atFirstKO("shift") + eq(#b.enemyParty, 2, "OPP_YOUNGSTER roster " .. ROSTER .. " has two mons") + local lead, reserve = Game.save.party[1], Game.save.party[2] + + -- KO one: the SHIFT prompt, answered YES with the reserve picked. + local seen = {} + local ok, err = pump(b, true, reserve, seen) + check(ok, "the SHIFT switch pumped without error: " .. tostring(err)) + check(b.player.mon == reserve, "the free switch put the reserve on the field") + check(b.enemy.mon.hp > 0, "the foe's second mon is out") + + -- The participant set is the mechanism; the exp number below is the symptom. + check(b.participants[reserve] == true, "the switch-in is a participant") + check(b.participants[lead] == nil, + "the mon that was out when the foe fainted is no longer one (#275)") + + -- KO two: the reserve fights alone, so it must be paid as a single + -- participant. + local ok2, err2, seen2, delta = koAndRead(b) + check(ok2, "the second KO pumped without error: " .. tostring(err2)) + + local foeDef = Data.pokemon[b.enemyParty[2].species] + local solo = Experience.gainFor(foeDef, b.enemyParty[2].level, true, 1, nil, + Data.constants) + local halved = Experience.gainFor(foeDef, b.enemyParty[2].level, true, 2, nil, + Data.constants) + check(solo > halved, + "the two divisors are distinguishable for this foe (" .. + solo .. " vs " .. halved .. ")") + + local shown, line = expLine(seen2) + check(shown ~= nil, "the KO printed an EXP. Points! line") + eq(shown, solo, "the switch-in is paid a whole share, not a split one (#275)") + check(shown ~= halved, + "the printed number is not the two-way split (" .. tostring(line) .. ")") + eq(delta[2], solo, "the reserve's exp rose by exactly that share") + eq(delta[1], 0, "the mon left behind is paid nothing for a KO it missed") + + local lines = 0 + for _, t in ipairs(seen2) do + if t:find("EXP%. Points!") then lines = lines + 1 end + end + eq(lines, 1, "exactly one mon is announced as gaining exp") +end + +-- Control: SET style has no free switch, so the lead fights both mons and is +-- paid a whole share for each. Pin it here: the SHIFT switch-in above must +-- earn the same number. +do + local Game, b = atFirstKO("set") + local seen = {} + local ok = pump(b, false, nil, seen) + check(ok, "SET style pumped without error") + check(b.player.mon == Game.save.party[1], "SET style never offered a switch") + + local ok2, _, seen2, delta = koAndRead(b) + check(ok2, "the SET second KO pumped without error") + local foeDef = Data.pokemon[b.enemyParty[2].species] + local solo = Experience.gainFor(foeDef, b.enemyParty[2].level, true, 1, nil, + Data.constants) + local shown = expLine(seen2) + eq(shown, solo, "SET style pays the lead a whole share") + eq(delta[1], solo, "and the lead's exp rises by it") +end + +-- The path the reset must NOT touch: the party-menu SwitchPlayerMon +-- (core.asm:2424-2433, from PartyMenuOrRockOrRun) sets the incoming mon's bit +-- without zeroing the flag bytes, which is the exp-share trick every player +-- uses: send a weak mon in, switch it straight out, it still splits the KO. +do + local Game = freshGame("shift") + local b = BattleState.newTrainer(Game, YOUNGSTER, ROSTER) + local lead, reserve = Game.save.party[1], Game.save.party[2] + b.participants = { [lead] = true } + b:resolveSwitch(reserve) + local n = 0 + while #b.queue > 0 and n < 200 do + n = n + 1 + local item = table.remove(b.queue, 1) + if item.fn then b.nextInsert = 0; item.fn() end + end + check(b.player.mon == reserve, "the voluntary switch went through") + check(b.participants[reserve] == true, "the mon coming in participates") + check(b.participants[lead] == true, + "a VOLUNTARY switch keeps the outgoing mon flagged (the exp share)") +end + +S.finish() diff --git a/tests/parity_starter_dex.lua b/tests/parity_starter_dex.lua index 30ba9cb4..49935232 100644 --- a/tests/parity_starter_dex.lua +++ b/tests/parity_starter_dex.lua @@ -2,7 +2,7 @@ -- pret StarterDex (engine/events/starter_dex.asm) temporarily sets the -- owned bits so ShowPokedexData prints the full entry before the player -- has caught anything. Also: English R/B prints only the kind string --- (no " POKéMON" suffix — that clipped "LIZARD" to "LIZARD POKé"). +-- (no " POKéMON" suffix -- that clipped "LIZARD" to "LIZARD POKé"). 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") diff --git a/tests/parity_trainer_ball_block.lua b/tests/parity_trainer_ball_block.lua new file mode 100644 index 00000000..6f1039f2 --- /dev/null +++ b/tests/parity_trainer_ball_block.lua @@ -0,0 +1,184 @@ +-- Parity test: a ball thrown at a trainer's mon is blocked, animated, and +-- costs the turn (#291). pokered engine/items/item_effects.asm:109-113 +-- branches to ThrowBallAtTrainerMon before ItemUseText00 ever prints; +-- :2292-2303 plays TOSS_ANIM and prints the two block texts; animations.asm: +-- 2629-2637 .BlockBall is the plain arc whatever the ball tier; and the turn +-- is still spent (core.asm:2257-2259). Pixels: the #291 driver. +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.pokemon and Data.pokemon.RATTATA) then Data:load() end +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) + +local Pokemon = require("src.pokemon.Pokemon") +local BattleState = require("src.battle.BattleState") +local S = require("tests.harness").suite("parity trainer ball block") +local check, eq = S.check, S.eq + +local function freshGame() + return { + data = Data, + save = { + party = { Pokemon.new(Data, "CHARIZARD", 50) }, + player = { name = "RED" }, + inventory = { POKE_BALL = 5, MASTER_BALL = 1 }, + options = { battleStyle = "set", battleAnim = "on" }, + pokedex = { seen = {}, owned = {} }, + flags = {}, + money = 0, + }, + stack = { push = function() end, pop = function() end, top = function() end }, + } +end + +-- Throw `ball` and drain the queue, recording what the player would see. +-- executeAction / endOfTurn are wrapped and counted: their absence is the +-- "free turn" half of the report. +local function throwAndRecord(b, ball) + local rec = { anims = {}, texts = {}, actions = 0, endTurns = 0 } + local realAction, realEnd = b.executeAction, b.endOfTurn + b.executeAction = function(self, ...) + rec.actions = rec.actions + 1 + return realAction(self, ...) + end + b.endOfTurn = function(self, ...) + rec.endTurns = rec.endTurns + 1 + return realEnd(self, ...) + end + b.phase = "messages" -- what openItems leaves behind before BagMenu + b.afterQueue = "menu" + b.nextInsert = 0 + local ok, err = pcall(function() + b:throwBall(ball) + local n = 0 + while #b.queue > 0 and n < 400 do + n = n + 1 + local item = table.remove(b.queue, 1) + if item.fn then + b.nextInsert = 0 + item.fn() + elseif item.anim then + rec.anims[#rec.anims + 1] = item.anim + elseif item.text then + rec.texts[#rec.texts + 1] = item.text + end + end + end) + rec.ok, rec.err = ok, err + return rec +end + +local function joined(list) return table.concat(list, " | ") end + +local function has(list, needle) + for _, v in ipairs(list) do + if v == needle or (type(v) == "string" and v:find(needle, 1, true)) then + return true + end + end + return false +end + +-- ItemUseText00 is " used\n!". Anchor on the player name, or +-- the foe's own "Enemy RATTATA used TACKLE!" reads as the item line. +local function itemUseLine(texts) + for _, t in ipairs(texts) do + if t:find("^RED used") then return t end + end + return nil +end + +local function indexOf(list, needle) + for i, v in ipairs(list) do + if v == needle or (type(v) == "string" and v:find(needle, 1, true)) then + return i + end + end + return nil +end + +-- The ROM's own wording, used verbatim: a hard-coded copy is how the port +-- ended up with "The TRAINER" in upper case. +do + check(type(Data.text._ThrowBallAtTrainerMonText1) == "string", + "_ThrowBallAtTrainerMonText1 is in the generated text") + check(type(Data.text._ThrowBallAtTrainerMonText2) == "string", + "_ThrowBallAtTrainerMonText2 is in the generated text") + check(Data.battle_anims and Data.battle_anims.moveAnims + and Data.battle_anims.moveAnims.BLOCKBALL_ANIM ~= nil, + "BLOCKBALL_ANIM has an extracted animation program") + check(Data.battle_anims and Data.battle_anims.moveAnims + and Data.battle_anims.moveAnims.TOSS_ANIM ~= nil, + "TOSS_ANIM has an extracted animation program") + check(Data.audio and Data.audio.sfx and Data.audio.sfx.Faint_Thud ~= nil, + "SFX_FAINT_THUD is in the generated audio") +end + +do + local Game = freshGame() + local b = BattleState.newTrainer(Game, "OPP_YOUNGSTER", 1) + eq(b.kind, "trainer", "the fixture really is a trainer battle") + local rec = throwAndRecord(b, "POKE_BALL") + check(rec.ok, "the trainer throw pumped without error: " .. tostring(rec.err)) + + -- ItemUseBall branches out before ItemUseText00. + check(itemUseLine(rec.texts) == nil, + "no \" used !\" line in a trainer battle (#291): " + .. joined(rec.texts)) + + -- .BlockBall: the arc, then the block. + check(has(rec.anims, "TOSS_ANIM"), + "the toss arc is queued (#291): " .. joined(rec.anims)) + check(has(rec.anims, "BLOCKBALL_ANIM"), + "the block animation is queued (#291): " .. joined(rec.anims)) + local toss, block = indexOf(rec.anims, "TOSS_ANIM"), + indexOf(rec.anims, "BLOCKBALL_ANIM") + check(toss and block and toss < block, "the arc plays before the block") + check(not has(rec.anims, "GREATTOSS_ANIM") + and not has(rec.anims, "ULTRATOSS_ANIM"), + ".BlockBall hardcodes the plain TOSS arc, not the per-tier one") + + -- The ROM's texts, in order, after the animation. + eq(rec.texts[1], Data.text._ThrowBallAtTrainerMonText1, + "the block line is the ROM's own text, lower-case \"trainer\" and all") + eq(rec.texts[2], Data.text._ThrowBallAtTrainerMonText2, + "followed by _ThrowBallAtTrainerMonText2") + + -- The turn is spent: this is the half that made a throw free scouting. + eq(rec.actions, 1, "the foe takes its turn after the block (#291)") + eq(rec.endTurns, 1, "and end-of-turn effects run (#291)") +end + +-- .BlockBall ignores wCurItem for the arc it picks. It still flickers OBP0 +-- for a Master/Ultra toss, but that rides on the row's `ball` field. +do + local Game = freshGame() + local b = BattleState.newTrainer(Game, "OPP_YOUNGSTER", 1) + local rec = throwAndRecord(b, "MASTER_BALL") + check(rec.ok, "the Master Ball throw pumped without error") + check(has(rec.anims, "TOSS_ANIM") and has(rec.anims, "BLOCKBALL_ANIM"), + "a Master Ball is blocked with the same plain arc: " .. joined(rec.anims)) + check(itemUseLine(rec.texts) == nil, + "and still prints no \"used\" line: " .. joined(rec.texts)) + eq(rec.actions, 1, "and still costs the turn") +end + +-- Control: wIsInBattle == 1 in the wild, so ItemUseText00 does print and the +-- block branch is never reached. If this regresses, every catch in the game +-- lost its "used" line. +do + local Game = freshGame() + local b = BattleState.newWild(Game, "PIDGEY", 8) + eq(b.kind, "wild", "the control fixture is a wild battle") + local rec = throwAndRecord(b, "POKE_BALL") + check(rec.ok, "the wild throw pumped without error: " .. tostring(rec.err)) + check(itemUseLine(rec.texts) ~= nil, + "a wild throw still prints \"RED used POKé BALL!\": " .. joined(rec.texts)) + check(not has(rec.anims, "BLOCKBALL_ANIM"), + "and nothing blocks it: " .. joined(rec.anims)) + check(not has(rec.texts, "thief"), "no \"Don't be a thief!\" in the wild") +end + +S.finish() diff --git a/tests/parity_trainer_victory_text.lua b/tests/parity_trainer_victory_text.lua new file mode 100644 index 00000000..47a24a8e --- /dev/null +++ b/tests/parity_trainer_victory_text.lua @@ -0,0 +1,195 @@ +-- Parity: the beaten trainer's own loss line prints ON the battle screen, +-- between the pic scrolling back in and the prize money (#282). +-- TrainerBattleVictory (engine/battle/core.asm:915-949) runs TrainerDefeatedText, +-- ScrollTrainerPicAfterBattle, PrintEndBattleText, then MoneyForWinningText. +-- The scroll (scroll_draw_trainer_pic.asm:1-31) rewrites tilemap columns only, +-- so the pokeball row ClearSprites emptied does not come back with the pic. +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 trainer victory text") +local check, eq = S.check, S.eq + +local Data = require("src.core.Data") +if not Data.maps then Data:load() end +local Font = require("src.render.Font") +Font.load(Data) + +local BattleState = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Sound = require("src.core.Sound") +local Music = require("src.core.Music") + +Sound.playCry = function() end +Sound.play = function() end +Sound.playMove = function() end +Sound.playMoveCry = function() end +Sound.stopLoop = function() end +Music.playBattle = function() end +Music.play = function() end + +local press = {} +local function makeGame(party) + local save = SaveData.newGame() + save.party = party + local stack = { states = {} } + function stack:push(state) self.states[#self.states + 1] = state end + function stack:pop() return table.remove(self.states) end + function stack:top() return self.states[#self.states] end + return { data = Data, save = save, stack = stack, + input = { wasPressed = function(_, b) return press[b] == true end } } +end + +-- A held: updateQueue only reads the button once a page is typed out, so an +-- early press is ignored and the queue drains at a player's pace. +local function step(battle) + press.a = true + battle:update(1 / 60) + press.a = false +end + +-- Fight a YOUNGSTER, wipe its party, and record every message row in the order +-- it reached the screen plus what the foe's pic slot was doing at the time. +local function fightAndWin(endBattleText) + local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 60) }) + local battle = BattleState.newTrainer(game, "OPP_YOUNGSTER", 1) + battle.endBattleText = endBattleText + local result, resultAt + battle.onFinish = function(r) result = r end + battle:enter() + for _ = 1, 500 do + step(battle) + if battle.phase == "menu" then break end + end + + -- the KO itself, through the real faint path (onFaint queues the slide, + -- the faint text and the enemyMonFainted act) + for _, mon in ipairs(battle.enemyParty) do mon.hp = 0 end + battle.enemy.mon.hp = 0 + battle.phase = "messages" + battle.nextInsert = 0 + battle:onFaint(battle.enemy) + + local pages, foeOffAt, foeShownAt = {}, {}, {} + local ballRowsSeen, frame, foeMax, foeSteps = 0, 0, 0, 0 + local realRow = battle.drawBallRow + battle.drawBallRow = function() ballRowsSeen = ballRowsSeen + 1 end + local lastOff = battle:picOffset("foe") + for f = 1, 2000 do + frame = f + step(battle) + local cur = battle.current + local text = cur and cur.text + if text and pages[#pages] ~= text then + pages[#pages + 1] = text + foeOffAt[text] = battle:picOffset("foe") + foeShownAt[text] = battle.showEnemyTrainer and true or false + end + local off = battle:picOffset("foe") + if off > foeMax then foeMax = off end + -- count only the inward frames; the jump from 0 to 64 is the program + -- being armed off-screen, not a step of the scroll + if off < lastOff then foeSteps = foeSteps + 1 end + lastOff = off + -- drawHUDs is the only place a ball row can come from; sample it while + -- the beaten trainer is back on screen + if battle.showEnemyTrainer then pcall(battle.drawHUDs, battle, 0) end + if result then resultAt = f break end + end + battle.drawBallRow = realRow + return { + battle = battle, pages = pages, result = result, resultAt = resultAt, + foeOffAt = foeOffAt, foeShownAt = foeShownAt, ballRowsSeen = ballRowsSeen, + frames = frame, foeMax = foeMax, foeSteps = foeSteps, + } +end + +local function indexOf(pages, fragment) + for i, p in ipairs(pages) do + if p:find(fragment, 1, true) then return i end + end + return nil +end + +-- ------------------------------------------------- the full victory order +local LOSS = "What a total\nwaste of time!" +local run = fightAndWin(LOSS) + +eq(run.result, "win", "the battle resolves as a win") +local defeated = indexOf(run.pages, "defeated") +local loss = indexOf(run.pages, "waste of time") +local money = indexOf(run.pages, "for winning") +check(defeated ~= nil, "TrainerDefeatedText prints (\"RED defeated YOUNGSTER!\")") +check(loss ~= nil, + "the trainer's own EndBattleText prints INSIDE the battle (#282)") +check(money ~= nil, "MoneyForWinningText prints") +check(defeated and loss and defeated < loss, + "the defeat line comes before the trainer's loss line") +check(loss and money and loss < money, + "PrintEndBattleText comes before MoneyForWinningText (core.asm:942-949)") +check(money == #run.pages, + "the prize money is the LAST thing on the battle screen") + +-- the pic is back, at rest, with no ball row beside it +if loss then + local text = run.pages[loss] + eq(run.foeShownAt[text], true, + "the beaten trainer's pic is on screen for his loss line") + eq(run.foeOffAt[text], 16, + "the pic has come to rest two tiles right of the battle slot " + .. "(_ScrollTrainerPicAfterBattle ends at hlcoord 14,0)") +end +eq(run.ballRowsSeen, 0, + "no pokeball row comes back with the pic (ClearSprites emptied that OAM; " + .. "_ScrollTrainerPicAfterBattle only rewrites tilemap columns)") +eq(run.battle.introBalls, nil, "the DrawAllPokeballs window stays closed") + +-- The pic is on screen well before the LAST page: the plain act() this used to +-- ride appended to the end of the queue, so the trainer flashed up one row +-- before finish() popped the battle. +if money then + eq(run.foeShownAt[run.pages[money]], true, + "the trainer's pic is already back for the money line, not flashed up " + .. "one row before the battle pops (#282)") +end + +-- and it really scrolls rather than popping into place: 64px off the right +-- edge, then 2px a frame down to the resting 16 +eq(run.foeMax, 64, "the scroll-in starts 8 tiles off the right edge") +eq(run.foeSteps, 24, + "it takes 24 frames to walk in (six 4-frame columns, " + .. "scroll_draw_trainer_pic.asm:1-31)") + +-- ------------------------------------------------------ ordering vs onFinish +-- finish() pops the battle, so anything the overworld pushes afterwards is a +-- second screen cut. Every trainer-victory row must be consumed before it. +check(run.resultAt ~= nil and run.resultAt >= run.frames, + "onFinish fires only once the whole sequence has drained") + +-- ------------------------------------------------------------- \f pages +-- Five EndBattleTexts carry a `para` (e.g. _Route9Youngster1EndBattleText). +-- BattleState:startMessage only splits \n and \v, so an unsplit \f would +-- render as a garbage glyph instead of starting a new page. +local para = fightAndWin("Oh well.\fI give up!") +check(indexOf(para.pages, "Oh well.") ~= nil, + "a \\f EndBattleText prints its first page") +check(indexOf(para.pages, "I give up!") ~= nil, + "a \\f EndBattleText prints its second page") +local p1, p2 = indexOf(para.pages, "Oh well."), indexOf(para.pages, "I give up!") +check(p1 and p2 and p2 == p1 + 1, "the two pages are consecutive rows") +for _, page in ipairs(para.pages) do + check(page:find("\f", 1, true) == nil, + "no page still carries a raw \\f: " .. (page:gsub("\n", " / "))) +end + +-- --------------------------------------------------- scripted battles +-- Commands.start_battle never sets endBattleText; those scripts print their +-- own follow-up, so the sequence must simply skip the row. +local none = fightAndWin(nil) +eq(none.result, "win", "a battle with no EndBattleText still resolves") +local d2, m2 = indexOf(none.pages, "defeated"), indexOf(none.pages, "for winning") +check(d2 and m2 and d2 < m2, + "defeat text then money, with nothing between them") +eq(m2, #none.pages, "the prize money is still last") + +S.finish() diff --git a/tests/parity_victory_road_reset.lua b/tests/parity_victory_road_reset.lua new file mode 100644 index 00000000..80e80c34 --- /dev/null +++ b/tests/parity_victory_road_reset.lua @@ -0,0 +1,222 @@ +-- Regression: Route 23 resets the Victory Road boulder puzzle, and a solved +-- barrier closes again on the next map load (#258). scripts/Route23.asm:8 and +-- scripts/VictoryRoad2F.asm:19 reset the switch events on entry; neither was +-- ported. scripts/VictoryRoad1F.asm:14 only ever stamps the OPEN block, but +-- the port's Map:setBlock writes through to the shared Game.data record, so it +-- has to stamp the CLOSED block itself or the barrier stays open all session. +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.VICTORY_ROAD_2F) then Data:load() end + +local mapScripts = require("data.scripts.init") +local S = require("tests.harness").suite("parity victory road reset") +local check, eq = S.check, S.eq + +local SW1F = "EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH" +local SW2A = "EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1" +local SW2B = "EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2" +local SW3A = "EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1" +local SW3B = "EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2" + +-- ---- ground truth: the shipped .blk bytes and the object names ----------- +-- The closed ids the onEnter hooks stamp back must BE the map's own bytes, +-- so read them out of data/generated rather than typing them in. +local function blockAt(mapId, bx, by) + local def = Data.maps[mapId] + return def.blocks[by * def.width + bx + 1] +end + +local CLOSED = { + { "VICTORY_ROAD_1F", 4, 6, 0x25 }, + { "VICTORY_ROAD_2F", 3, 4, 0x37 }, + { "VICTORY_ROAD_2F", 11, 7, 0x25 }, + { "VICTORY_ROAD_3F", 3, 5, 0x25 }, +} +for _, b in ipairs(CLOSED) do + eq(blockAt(b[1], b[2], b[3]), b[4], + ("%s block (%d,%d) ships closed as 0x%02X"):format(b[1], b[2], b[3], b[4])) +end + +-- data/maps/toggleable_objects.asm:202 names the last 2F entry +-- VICTORYROAD2F_BOULDER3 and the last 3F one VICTORYROAD3F_BOULDER4 +local function objectNames(mapId) + local names = {} + for _, o in ipairs(Data.maps[mapId].objects or {}) do names[o.name or "?"] = true end + return names +end +local names2F, names3F = objectNames("VICTORY_ROAD_2F"), objectNames("VICTORY_ROAD_3F") +check(names2F.VICTORYROAD2F_BOULDER3, + "VICTORY_ROAD_2F really has an object named VICTORYROAD2F_BOULDER3") +check(names3F.VICTORYROAD3F_BOULDER4, + "VICTORY_ROAD_3F really has an object named VICTORYROAD3F_BOULDER4") +check(not names2F.VICTORYROAD2F_BOULDER, + "and nothing is named VICTORYROAD2F_BOULDER (the old, never-matching key)") + +-- Map:setBlock writes into the shared Game.data record, which is the invariant +-- the both-ways stamp exists for. Restored immediately: every later suite in +-- this process reads the same table. +do + local def = Data.maps.VICTORY_ROAD_1F + local MapLoader = require("src.world.MapLoader") + local map = MapLoader.load(Data, "VICTORY_ROAD_1F") + local before = blockAt("VICTORY_ROAD_1F", 4, 6) + map:setBlock(4, 6, 0x1D) + eq(blockAt("VICTORY_ROAD_1F", 4, 6), 0x1D, + "replaceBlock/setBlock writes through to the SHARED map record") + map:setBlock(4, 6, before) + eq(def.blocks[6 * def.width + 4 + 1], 0x25, "restored for the rest of the run") +end + +-- ---- fakes --------------------------------------------------------------- +-- The hooks only need a save with flags and an ow that records replaceBlock. +-- Commands.toggleObject returns early once it has written save.objectToggles +-- when the toggle targets another map, which it always does here. +local function world(mapId) + local game = { data = Data, save = { flags = {}, objectToggles = {} } } + local ow = { + -- toggleObject walks these when the toggle names the CURRENT map; empty + -- lists make that a no-op and leave objectToggles as the assertion surface + map = { id = mapId, def = Data.maps[mapId] or { objects = {} } }, + npcs = {}, entities = {}, npcPool = {}, + stamped = {}, + replaceBlock = function(self, bx, by, block) + self.stamped[bx .. "," .. by] = block + end, + npcAtCell = function() return nil end, + } + return game, ow +end + +local function hooks(mapId) + local h = mapScripts.get(mapId) + check(h ~= nil, mapId .. " has hand-ported hooks") + return h or {} +end + +local route23 = hooks("ROUTE_23") +local vr1 = hooks("VICTORY_ROAD_1F") +local vr2 = hooks("VICTORY_ROAD_2F") +local vr3 = hooks("VICTORY_ROAD_3F") +check(route23.talk ~= nil, "ROUTE_23's badge-guard talk table survived the merge") + +-- A missing hook should read as a wall of failed expectations, not as one +-- traceback that hides every later assertion. +local function hookFn(h, key, label) + local fn = h[key] + if check(type(fn) == "function", label .. " exists") then return fn end + return function() end +end + +local r23Enter = hookFn(route23, "onEnter", "ROUTE_23.onEnter (Route23SetVictoryRoadBoulders)") +local vr1Enter = hookFn(vr1, "onEnter", "VICTORY_ROAD_1F.onEnter") +local vr2Enter = hookFn(vr2, "onEnter", "VICTORY_ROAD_2F.onEnter") +local vr3Enter = hookFn(vr3, "onEnter", "VICTORY_ROAD_3F.onEnter") +local vr2Boulder = hookFn(vr2, "onBoulderMoved", "VICTORY_ROAD_2F.onBoulderMoved") +local vr3Boulder = hookFn(vr3, "onBoulderMoved", "VICTORY_ROAD_3F.onBoulderMoved") + +-- ---- entering Route 23 resets everything -------------------------------- +do + local game, ow = world("ROUTE_23") + local f = game.save.flags + f[SW2A], f[SW2B], f[SW3A], f[SW3B] = true, true, true, true + f[SW1F] = true + r23Enter(game, ow) + check(not f[SW2A], "entering Route 23 clears 2F switch 1") + check(not f[SW2B], "entering Route 23 clears 2F switch 2") + check(not f[SW3A], "entering Route 23 clears 3F switch 1") + check(not f[SW3B], "entering Route 23 clears 3F switch 2") + check(f[SW1F], "1F's event is NOT reset here (the lobby and 2F own it)") + local t = game.save.objectToggles + eq(t.VICTORY_ROAD_3F and t.VICTORY_ROAD_3F.VICTORYROAD3F_BOULDER4, true, + "the 3F boulder is shown again (ShowObject TOGGLE_VICTORY_ROAD_3F_BOULDER)") + eq(t.VICTORY_ROAD_2F and t.VICTORY_ROAD_2F.VICTORYROAD2F_BOULDER3, false, + "the 2F copy is hidden again (HideObject TOGGLE_VICTORY_ROAD_2F_BOULDER)") +end + +-- ---- each floor stamps its barrier BOTH ways on entry -------------------- +do + local game, ow = world("VICTORY_ROAD_1F") + vr1Enter(game, ow) + eq(ow.stamped["4,6"], 0x25, "1F: no switch solved -> the barrier is solid rock") + game.save.flags[SW1F] = true + vr1Enter(game, ow) + eq(ow.stamped["4,6"], 0x1D, "1F: switch solved -> the barrier is open") +end + +do + local game, ow = world("VICTORY_ROAD_2F") + game.save.flags[SW1F] = true + vr2Enter(game, ow) + check(not game.save.flags[SW1F], + "entering 2F clears the 1F switch event (VictoryRoad2FResetBoulderEventScript)") + eq(ow.stamped["3,4"], 0x37, "2F: switch 1 unsolved -> solid") + eq(ow.stamped["11,7"], 0x25, "2F: switch 2 unsolved -> solid") + game.save.flags[SW2A], game.save.flags[SW2B] = true, true + vr2Enter(game, ow) + eq(ow.stamped["3,4"], 0x15, "2F: switch 1 solved -> open") + eq(ow.stamped["11,7"], 0x1D, "2F: switch 2 solved -> open") +end + +do + local game, ow = world("VICTORY_ROAD_3F") + vr3Enter(game, ow) + eq(ow.stamped["3,5"], 0x25, "3F: switch unsolved -> solid") + game.save.flags[SW3A] = true + vr3Enter(game, ow) + eq(ow.stamped["3,5"], 0x1D, "3F: switch solved -> open") +end + +-- ---- the 3F hole hands its boulder to 2F, once --------------------------- +do + local game, ow = world("VICTORY_ROAD_3F") + local boulder = { cellX = 23, cellY = 15, def = { name = "VICTORYROAD3F_BOULDER4" } } + vr3Boulder(game, ow, boulder) + check(game.save.flags[SW3B], + "dropping a boulder down the hole sets switch-2's event (CheckAndSetEvent)") + local t = game.save.objectToggles + eq(t.VICTORY_ROAD_3F and t.VICTORY_ROAD_3F.VICTORYROAD3F_BOULDER4, false, + "the boulder disappears from 3F") + eq(t.VICTORY_ROAD_2F and t.VICTORY_ROAD_2F.VICTORYROAD2F_BOULDER3, true, + "and appears on 2F under its REAL object name") + + -- CheckAndSetEvent: the second boulder down the same hole is a no-op + t.VICTORY_ROAD_2F.VICTORYROAD2F_BOULDER3 = "untouched" + local second = { cellX = 23, cellY = 15, def = { name = "VICTORYROAD3F_BOULDER3" } } + vr3Boulder(game, ow, second) + eq(t.VICTORY_ROAD_2F.VICTORYROAD2F_BOULDER3, "untouched", + "a second boulder into the already-used hole changes nothing") +end + +-- ---- the reporter's round trip ------------------------------------------ +-- Solve 2F switch 1, walk out to Route 23, come back: the barrier must be rock +-- again and the fallen boulder must be back upstairs. +do + local game, ow2 = world("VICTORY_ROAD_2F") + vr2Enter(game, ow2) + eq(ow2.stamped["3,4"], 0x37, "arrive on 2F with the puzzle untouched") + + local boulder = { cellX = 1, cellY = 16, def = { name = "VICTORYROAD2F_BOULDER1" } } + vr2Boulder(game, ow2, boulder) + check(game.save.flags[SW2A], "pushing the boulder onto the switch solves it") + eq(ow2.stamped["3,4"], 0x15, "and the barrier opens immediately") + + -- ...and stays open while you are still on the floor + vr2Enter(game, ow2) + eq(ow2.stamped["3,4"], 0x15, "re-entering 2F with the switch still set keeps it open") + + local _, ow23 = world("ROUTE_23") + ow23.map.id = "ROUTE_23" + r23Enter(game, ow23) + vr2Enter(game, ow2) + eq(ow2.stamped["3,4"], 0x37, + "after a trip through Route 23 the 2F barrier is solid rock again") + eq(ow2.stamped["11,7"], 0x25, "and so is the other one") + + local _, ow3 = world("VICTORY_ROAD_3F") + vr3Enter(game, ow3) + eq(ow3.stamped["3,5"], 0x25, "3F's barrier is closed again too") +end + +S.finish() diff --git a/tests/parity_warp_after_warp_step.lua b/tests/parity_warp_after_warp_step.lua new file mode 100644 index 00000000..75e494db --- /dev/null +++ b/tests/parity_warp_after_warp_step.lua @@ -0,0 +1,141 @@ +-- Regression: the first completed step after a warp still checks for a warp +-- under the player's feet (#265). home/overworld.asm:391 CheckWarpsNoCollision +-- runs on EVERY completed step with no first-step-after-a-warp counter, so the +-- arrival disable is POSITIONAL (the cell you came in on is inert until you +-- leave it), which is what warpEntryCell models. #230's stand-still bonk guard, +-- still backed by justWarped, is asserted here too. +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.SEAFOAM_ISLANDS_B3F) 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 Warp = require("src.world.Warp") +local OW = require("src.world.OverworldController") +local S = require("tests.harness").suite("parity warp after warp step") +local check, eq = S.check, S.eq + +-- ---- ground truth: two ladders one cell apart ---------------------------- +local b3 = MapLoader.load(Data, "SEAFOAM_ISLANDS_B3F") +eq(b3:cellTile(25, 3), 0x1A, "B3F (25,3) is a CAVERN ladder tile") +eq(b3:cellTile(25, 4), 0x18, "B3F (25,4) is the other CAVERN ladder tile") +local up = Warp.onArrive(b3, 25, 3) +local down = Warp.onArrive(b3, 25, 4) +check(up ~= nil, "(25,3) carries a warp") +check(down ~= nil, "(25,4) carries a warp") +eq(up and up.def.destMap, "SEAFOAM_ISLANDS_B2F", "(25,3) climbs back to B2F") +eq(down and down.def.destMap, "SEAFOAM_ISLANDS_B4F", "(25,4) drops to B4F") + +local reds2 = MapLoader.load(Data, "REDS_HOUSE_2F") +eq(reds2:cellTile(7, 1), 0x1A, "REDS_HOUSE_2F (7,1) is the staircase warp tile") +eq(reds2.def.width * 2, 8, "REDS_HOUSE_2F is 8 cells wide, so x=7 is the east edge") + +-- ---- live engine --------------------------------------------------------- +Game.data = Data +Game.input = Input; Input:init() +Game.renderer = Renderer; Renderer:init() +Game.stack = StateStack; StateStack:init() +Game.save = SaveData.newGame() +Game.overworld = OW + +-- Reproduce a warp ARRIVAL without running the fade: startWarpTo sets exactly +-- these two fields once setMap has placed the player (OverworldController.lua, +-- inside the Transition callback). takeWarp is stubbed per instance so the +-- assertion is which warp the engine decided to take, with no Transition to pump. +local function arriveOn(mapId, x, y, facing) + Input:reset() + while Game.stack:top() do Game.stack:pop() end + Game.stack:push(OW, mapId, x, y, facing or "down") + local ow = Game.stack:top() + ow.player.moving = false + ow.player.turnTimer = 0 + ow.justWarped = true + ow.warpEntryCell = { x = x, y = y } + ow.taken = nil + ow.takeWarp = function(self, def) self.taken = def end + return ow +end + +-- Walk one cell the way Player:update would leave things, then run the +-- completed-step handler. +local function stepTo(ow, x, y, facing) + local p = ow.player + p.facing = facing or p.facing + p.cellX, p.cellY = x, y + p.px, p.py = x * 16, y * 16 + p.moving = false + ow:onStepComplete() +end + +-- The reported case: down the B2F ladder onto (25,3), then one press of DOWN +-- onto the adjacent ladder at (25,4). +do + local ow = arriveOn("SEAFOAM_ISLANDS_B3F", 25, 3, "down") + check(ow:onWarpArrivalCell(), "the cell just arrived on is inert") + stepTo(ow, 25, 4, "down") + check(ow.taken ~= nil, "the step onto (25,4) fires a warp") + eq(ow.taken and ow.taken.destMap, "SEAFOAM_ISLANDS_B4F", + "and it is the ladder down to B4F") + check(ow.justWarped == false, "the arrival record is cleared by that step") +end + +-- The arrival cell stays inert while you stand on it, even if a scripted nudge +-- re-runs the step handler there. That is warpEntryCell's job, not justWarped's. +do + local ow = arriveOn("SEAFOAM_ISLANDS_B3F", 25, 3, "down") + stepTo(ow, 25, 3, "down") + check(ow.taken == nil, "standing on the arrival ladder does not re-fire it") + eq(ow.warpEntryCell and ow.warpEntryCell.x, 25, "the entry cell is remembered") + eq(ow.warpEntryCell and ow.warpEntryCell.y, 3, "at the arrival row") +end + +-- Once you have left it, that cell is live again (a real second visit). +do + local ow = arriveOn("SEAFOAM_ISLANDS_B3F", 25, 3, "down") + stepTo(ow, 25, 2, "up") + check(ow.taken == nil, "stepping onto a plain floor cell warps nowhere") + check(ow.warpEntryCell == nil, "leaving clears the entry record") + stepTo(ow, 25, 3, "down") + check(ow.taken ~= nil, "coming back onto the ladder now takes it") + eq(ow.taken and ow.taken.destMap, "SEAFOAM_ISLANDS_B2F", "back up to B2F") +end + +-- #230 must not come back: justWarped still guards the two STAND-STILL warp +-- triggers, where no step ever completes. Red's house 2F staircase is on the +-- map's east edge, so pushing into that edge has to bonk, not bounce floors. +do + local ow = arriveOn("REDS_HOUSE_2F", 7, 1, "down") + check(ow:onWarpArrivalCell(), "the staircase arrival cell reports inert") + check(ow:checkEdgeExit("right") == false, + "pushing east off the map edge from it does not warp (#230)") + Input.state.right = true + Input.pressed = {} + for _ = 1, 30 do + ow.player.turnTimer = 0 + ow:handleInput() + ow.player:update() + end + check(ow.taken == nil, "holding RIGHT into the edge never fires the staircase") + eq(ow.player.cellX, 7, "and the player has not moved") + eq(ow.player.cellY, 1, "in either axis") + Input:reset() + + -- the same from the blocked-step path: north of (7,1) is solid wall + Input.state.up = true + Input.pressed = {} + for _ = 1, 30 do + ow.player.turnTimer = 0 + ow:handleInput() + ow.player:update() + end + check(ow.taken == nil, "bonking the wall from the arrival cell does not warp") + Input:reset() +end + +S.finish() diff --git a/tests/run_tests.lua b/tests/run_tests.lua index eec7849f..3a2d0486 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -2240,20 +2240,34 @@ end local SummaryMenu = require("src.ui.SummaryMenu") local HudTiles = require("src.render.HudTiles") local drawn = {} - local savedDraw, savedTile = Font.draw, HudTiles.tile + local savedDraw, savedCode = Font.draw, Font.drawCode + -- The status screen draws its HUD glyphs through HudTiles.statusTile, not + -- HudTiles.tile: it loads a DIFFERENT VRAM overlay from the battle screen + -- (status_screen.asm:86-97 vs core.asm LoadHudTilePatterns), which is what + -- keeps $73/$74 as the and № glyphs there. Stub both so this test + -- keeps seeing every tile the page puts down. #280 + local savedTile, savedStatusTile = HudTiles.tile, HudTiles.statusTile Font.draw = function(text, x, y) drawn[#drawn + 1] = { text = tostring(text), x = x, y = y } return Font.width(text) end + Font.drawCode = function(code, x, y) + drawn[#drawn + 1] = { glyph = code, x = x, y = y } + end HudTiles.tile = function(code, x, y) drawn[#drawn + 1] = { tile = code, x = x, y = y } end + HudTiles.statusTile = function(code, x, y) + drawn[#drawn + 1] = { tile = code, x = x, y = y } + end local summary = SummaryMenu.new(Game, mon) summary.page = 2 summary:draw() Font.draw = savedDraw + Font.drawCode = savedCode HudTiles.tile = savedTile - local nextExp, lvTile, lvNum + HudTiles.statusTile = savedStatusTile + local nextExp, lvTile, lvNum, toTile, lvOnPage2 for _, d in ipairs(drawn) do if d.text == "LEVEL UP" then eq(d.y, 40, "LEVEL UP sits on tile row 5") @@ -2261,6 +2275,10 @@ end nextExp = d elseif d.tile == 0x6E and d.y == 48 then lvTile = d + elseif d.tile == 0x70 and d.y == 48 then + toTile = d + elseif d.tile == 0x6E and d.y == 16 then + lvOnPage2 = d elseif d.text == "28" and d.y == 48 then lvNum = d end @@ -2268,6 +2286,13 @@ end check(nextExp, "next-exp prints at (7,6)") check(lvTile and lvTile.x == 128, "next level uses the tile at col 16") check(lvNum and lvNum.x == 136, "next level digits follow ") + -- status_screen.asm:393-397 writes the narrow '' tile at (14,6) + -- between the next-exp number and PrintLevel; the port used to skip it (#280) + check(toTile and toTile.x == 112, "the '' tile sits at (14,6)") + -- StatusScreen2 opens with ClearScreenArea over (9,2) 5x10 + -- (status_screen.asm:303-305), which wipes PrintLevel's (14,2): page 2 + -- must show no level in the header (#280) + check(not lvOnPage2, "page 2 draws no in the header") local edge = 19 * 8 -- DrawLineBox vertical at col 19 check(lvNum.x + Font.width(lvNum.text) <= edge, "next level digits stay left of the status line-box") @@ -2443,6 +2468,21 @@ do lp.shownHP = 20 check(not lhb:lowHealthAlarmActive(), "alarm waits for the HP drain to catch up") lp.shownHP = 9 + -- ...but once it IS sounding it follows the drawn bar, not the model: + -- applyDamage drops mon.hp while the turn is still being queued, so + -- keying the running siren off it silenced the whole "used X!" line + + -- move animation + drain window (#293) + lhb.lowHealthAlarmOn = true + lp.mon.hp, lp.shownHP = 3, 9 + check(lhb:lowHealthAlarmActive(), "a sounding alarm rides the hit's drain out (#293)") + lp.mon.hp, lp.shownHP = 0, 9 + check(lhb:lowHealthAlarmActive(), "a lethal hit holds it until the bar empties (#293)") + lp.mon.hp, lp.shownHP = 0, 0 + check(not lhb:lowHealthAlarmActive(), "the empty bar silences it (RemoveFaintedPlayerMon)") + lp.mon.hp, lp.shownHP = 30, 9 + check(not lhb:lowHealthAlarmActive(), "a heal out of the red silences it at once") + lhb.lowHealthAlarmOn = nil + lp.mon.hp, lp.shownHP = 9, 9 lhb.result = "win" check(not lhb:lowHealthAlarmActive(), "decided battle keeps the alarm off (EndLowHealthAlarm)") lhb.result = nil diff --git a/tests/save_convert_tests.lua b/tests/save_convert_tests.lua index 94cf92b2..87ff9b3f 100644 --- a/tests/save_convert_tests.lua +++ b/tests/save_convert_tests.lua @@ -112,6 +112,8 @@ save.bagOrder = { "POKE_BALL", "ANTIDOTE" } save.pcItems = { REVIVE = 2 } save.pokedex = { seen = { MEW = true, PIKACHU = true }, owned = { PIKACHU = true } } save.flags = { EVENT_GOT_STARTER = true, EVENT_GOT_POKEDEX = true } +-- the FLY destination set (pokered's wTownVisitedFlag), keyed by map id +save.visited = { PALLET_TOWN = true, CELADON_CITY = true } save.boxes = {} save.party = { { @@ -151,6 +153,13 @@ check(decoded2.pokedex.seen.MEW and decoded2.pokedex.seen.PIKACHU and decoded2.p check(not decoded2.pokedex.owned.MEW, "a species only marked seen doesn't also come back owned") check(decoded2.flags.EVENT_GOT_STARTER and decoded2.flags.EVENT_GOT_POKEDEX, "event flags round-trip") +-- wTownVisitedFlag: bit index == map index, LSB first (PALLET_TOWN is bit 0, +-- CELADON_CITY bit 6). Before #263 decode never touched it, so an imported +-- save reached FLY with no destinations at all. +check(decoded2.visited and decoded2.visited.PALLET_TOWN and decoded2.visited.CELADON_CITY, + "visited towns (the FLY destination set) round-trip") +check(decoded2.visited and not decoded2.visited.PEWTER_CITY, + "a town that was never visited does not come back visited") local mon1 = decoded2.party[1] check(mon1 and mon1.species == "MEW", "party mon species round-trips") @@ -160,7 +169,15 @@ check(mon1 and mon1.hp == 399 and mon1.stats and mon1.stats.hp == 399, check(mon1 and mon1.dvs.attack == 15 and mon1.dvs.speed == 12, "party mon DVs round-trip") check(mon1 and mon1.moves[1].id == "TRANSFORM" and mon1.moves[1].pp == 16 and mon1.moves[1].ppUps == 3, "party mon move/PP/PP-Up round-trip") -check(mon1 and mon1.nickname == "MEW" and mon1.otId == 55721, "party mon nickname/OT ID round-trip") +-- Gen1 has no "is nicknamed" bit: an un-nicknamed mon stores its species' +-- standard name in the nickname slot (engine/menus/naming_screen.asm AskName +-- .declinedNickname) and the game reads exactly that back as "not nicknamed" +-- (engine/pokemon/evos_moves.asm RenameEvolvedMon). This project spells that +-- state mon.nickname == nil, so this fixture's MEW named "MEW" must come back +-- with no nickname at all, or it would refuse to rename itself on evolution +-- and export as a forced nickname (#257). +check(mon1 and mon1.nickname == nil and mon1.otId == 55721, + "a stored name equal to the species name decodes as NOT nicknamed, OT ID round-trips") check(mon1 and mon1.ot == "LtAsh", "an OT name containing a bracketed charmap token (\"\") round-trips as one unit, ".. "not per-byte \"?\" (got " .. tostring(mon1 and mon1.ot) .. ")") @@ -168,6 +185,10 @@ check(mon1 and mon1.ot == "LtAsh", local box3mon = decoded2.boxes[3][1] check(box3mon and box3mon.species == "PIKACHU" and box3mon.status == "PSN", "a boxed mon's species and status condition round-trip") +-- the positive half of the #257 rule: a name that differs from the species +-- name IS a real nickname and must survive untouched +check(box3mon and box3mon.nickname == "PIKA", + "a real nickname (different from the species name) still round-trips") check(box3mon and box3mon.moves[1].id == "THUNDERSHOCK", "a boxed mon's move round-trips") check(decoded2.currentBox == 1, "current box selection round-trips") diff --git a/tools/save-editor/App.lua b/tools/save-editor/App.lua index 58aa54a0..8d6caccd 100644 --- a/tools/save-editor/App.lua +++ b/tools/save-editor/App.lua @@ -75,7 +75,21 @@ local function applyLoaded(path, statusVerb) S._quitArmed = false S._openArmed = false S.editingMon = nil - require("src.pokemon.Boxes").ensure(S.save) + local boxes = require("src.pokemon.Boxes").ensure(S.save) + -- Imported .sav box mons have no stat block (box_struct stops before + -- MON_STATS). The game derives them in SaveData.validate; the editor + -- only validates a copy, so hydrate here for Boxes/Party/MonEditor. + local Stats = require("src.pokemon.Stats") + local function ensureStats(mon) + Stats.ensure(Data.pokemon and Data.pokemon[mon.species], mon) + end + for _, mon in ipairs(S.save.party or {}) do ensureStats(mon) end + for _, box in ipairs(boxes) do + for _, mon in ipairs(box) do ensureStats(mon) end + end + if S.save.daycare and S.save.daycare.mon then + ensureStats(S.save.daycare.mon) + end -- what the running game would quarantine, computed on a copy so the -- editor never mutates the file behind the user's back local SaveData = require("src.core.SaveData")