From c6ea960ed26f6c265c61005e33ccff116ebe5275 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Mon, 5 Oct 2026 05:43:14 -0400 Subject: [PATCH] Place object sprites by their ROM subsprite offsets and drop the sailor stand-ins for the Seagallop and SS Anne --- src/core/game3/ow_sprites.lua | 7 +- src/core/game3/scripting/gfx_ids.lua | 2 - src/core/game3/ss_anne_cutscene.lua | 75 +++++++----- src/import/CacheContract.lua | 10 +- src/import/gba/ow_extract.lua | 90 ++++++++++++-- .../game3_cinnabar_seagallop_arrival.lua | 110 ++++++++++++++++++ .../game3_ss_anne_exterior_departure.lua | 88 ++++++++++++++ tests/engine/cache_contract_emerald_test.lua | 6 +- .../engine/game3_ow_reflection_meta_test.lua | 7 +- tests/game3_cinnabar_seagallop_test.lua | 44 ++++++- tests/game3_ss_anne_departure_test.lua | 10 +- 11 files changed, 388 insertions(+), 61 deletions(-) create mode 100644 tests/drivers/game3_cinnabar_seagallop_arrival.lua create mode 100644 tests/drivers/game3_ss_anne_exterior_departure.lua diff --git a/src/core/game3/ow_sprites.lua b/src/core/game3/ow_sprites.lua index 434519b8..5cb15540 100644 --- a/src/core/game3/ow_sprites.lua +++ b/src/core/game3/ow_sprites.lua @@ -455,8 +455,11 @@ function OwSprites.draw(graphicsId, px, py, camX, camY, facing, walkPhase, stepF local frame, flip = OwSprites.pose(spr, facing, walkPhase, stepFlip, opts) local q = spr.quads[frame] if not q then return false end - local sx = px - camX + (16 - spr.width) / 2 - local sy = py - camY + 16 - spr.height + local offX = spr.drawOffX or 0 + -- src/sprite.c:1669 + if flip then offX = -offX end + local sx = px - camX + (16 - spr.width) / 2 + offX + local sy = py - camY + 16 - spr.height + (spr.drawOffY or 0) love.graphics.setColor(1, 1, 1, opts and opts.alpha or 1) if flip then love.graphics.draw(spr.image, q, sx + spr.width, sy, 0, -1, 1) diff --git a/src/core/game3/scripting/gfx_ids.lua b/src/core/game3/scripting/gfx_ids.lua index 68f0f465..556b9db6 100644 --- a/src/core/game3/scripting/gfx_ids.lua +++ b/src/core/game3/scripting/gfx_ids.lua @@ -44,8 +44,6 @@ GfxIds.TO_SPRITE = { [88] = "SPRITE_MOM", -- player's PC / mom-adjacent [89] = "SPRITE_SUPER_NERD", -- CELIO [92] = "SPRITE_POKE_BALL", - [108] = "SPRITE_SAILOR", -- OBJ_EVENT_GFX_SEAGALLOP fallback - [109] = "SPRITE_SAILOR", -- OBJ_EVENT_GFX_SS_ANNE fallback } local S, N, W, E = "down", "up", "left", "right" diff --git a/src/core/game3/ss_anne_cutscene.lua b/src/core/game3/ss_anne_cutscene.lua index 1ddaf469..8dae7e1f 100644 --- a/src/core/game3/ss_anne_cutscene.lua +++ b/src/core/game3/ss_anne_cutscene.lua @@ -11,7 +11,6 @@ local SE = require("src.core.game3.se_ids") local INIT_FRAMES = 50 -- Task_SSAnneInit countdown local SMOKE_INTERVAL = 70 -- Task_SSAnneRun smoke puff period local SLIDE_SPEED_DIV = 5 -- 1 pixel movement every 5 frames (x = data[2] / 5) -local TRAVEL_DISTANCE = 216 -- pixels to travel until boat is fully off-screen local FINISH_FRAMES = 40 -- Task_SSAnneFinish delay after exit horn SSAnneCutscene._active = false @@ -58,6 +57,18 @@ local function loadGfx() end end +local function screenOrigin() + local P = package.loaded["src.core.game3.player"] + local px = P and (P.cellX or 0) * 16 or 0 + local py = P and (P.cellY or 0) * 16 or 0 + return px - 112, py - 72 +end + +local function boatScreenX(eo) + local ox = screenOrigin() + return (eo.px or (eo.cellX * 16)) + 8 - ox +end + function SSAnneCutscene.isActive() return SSAnneCutscene._active end @@ -117,33 +128,41 @@ function SSAnneCutscene.step() SSAnneCutscene._runTimer1 = SSAnneCutscene._runTimer1 + 1 SSAnneCutscene._runTimer2 = SSAnneCutscene._runTimer2 + 1 - -- Smoke puff creation every 70 frames + -- pokefirered/src/ss_anne.c:112 if SSAnneCutscene._runTimer1 == SMOKE_INTERVAL then SSAnneCutscene._runTimer1 = 0 - table.insert(SSAnneCutscene._smokes, { - timer = 0, - x2 = 0, - frame = 0, - animEnded = false, - boatOffsetAtSpawn = SSAnneCutscene._boatOffset, - }) + if not eo or boatScreenX(eo) - SSAnneCutscene._boatOffset + 49 >= -32 then + table.insert(SSAnneCutscene._smokes, { + timer = 0, + x2 = 0, + frame = 0, + animEnded = false, + boatOffsetAtSpawn = SSAnneCutscene._boatOffset, + }) + end end - -- Boat movement: x = data[2] / 5 (1 pixel every 5 frames) - SSAnneCutscene._boatOffset = math.floor(SSAnneCutscene._runTimer2 / SLIDE_SPEED_DIV) - if eo then - eo.raiseX = -SSAnneCutscene._boatOffset + -- pokefirered/src/ss_anne.c:119 + local departed = eo and boatScreenX(eo) - SSAnneCutscene._boatOffset < -120 + if not departed then + SSAnneCutscene._boatOffset = math.floor(SSAnneCutscene._runTimer2 / SLIDE_SPEED_DIV) + if eo then + eo.raiseX = -SSAnneCutscene._boatOffset + end end - -- Update wake sprite + -- pokefirered/src/ss_anne.c:159 if SSAnneCutscene._wake then local w = SSAnneCutscene._wake if math.floor(w.timer / 6) < 22 then w.timer = w.timer + 1 end w.x2 = math.floor(w.timer / 6) - -- 12 ticks per frame, looping between frame 0 and frame 1 - w.frame = (math.floor(w.timer / 12) % 2 == 0) and 0 or 1 + w.anim = (w.anim or 0) + 1 + w.frame = (math.floor((w.anim - 1) / 12) % 2 == 0) and 0 or 1 + if eo and boatScreenX(eo) - SSAnneCutscene._boatOffset + 80 + w.x2 < -18 then + SSAnneCutscene._wake = nil + end end -- Update smoke sprites @@ -168,8 +187,7 @@ function SSAnneCutscene.step() end SSAnneCutscene._smokes = activeSmokes - -- Exit check: when boat moves completely off-screen - if SSAnneCutscene._boatOffset >= TRAVEL_DISTANCE then + if departed then -- Final horn sound playSe(SE.SE_SS_ANNE_HORN) SSAnneCutscene._phase = "finish" @@ -204,16 +222,13 @@ function SSAnneCutscene.drawWake(camX, camY) local eo = Objects and Objects.find and Objects.find(1) if not eo then return end - local curBoatPx = (eo.px or (eo.cellX * 16)) - SSAnneCutscene._boatOffset - local boatPy = eo.py or (eo.cellY * 16) - local boatLeft = curBoatPx - camX - 56 - local boatTop = boatPy - camY - 48 - + local _, oy = screenOrigin() local w = SSAnneCutscene._wake local q = SSAnneCutscene._wakeQuads[w.frame or 0] if q then - local wx = boatLeft + 106 + (w.x2 or 0) - local wy = boatTop + 24 + -- pokefirered/src/ss_anne.c:153 + local wx = (eo.px or (eo.cellX * 16)) - SSAnneCutscene._boatOffset + 80 + (w.x2 or 0) - camX + local wy = oy + 109 - 16 - camY love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(SSAnneCutscene._wakeImage, q, wx, wy) end @@ -228,15 +243,13 @@ function SSAnneCutscene.drawSmoke(camX, camY) local eo = Objects and Objects.find and Objects.find(1) if not eo then return end - local boatPy = eo.py or (eo.cellY * 16) - local boatTop = boatPy - camY - 48 - + local _, oy = screenOrigin() for _, s in ipairs(SSAnneCutscene._smokes) do local q = SSAnneCutscene._smokeQuads[s.frame or 0] if q then - local spawnBoatLeft = (eo.px or (eo.cellX * 16)) - s.boatOffsetAtSpawn - camX - 56 - local sx = spawnBoatLeft + 78 + (s.x2 or 0) - local sy = boatTop + 2 + -- pokefirered/src/ss_anne.c:185 + local sx = (eo.px or (eo.cellX * 16)) - s.boatOffsetAtSpawn + 49 + (s.x2 or 0) - camX + local sy = oy + 78 - 8 - camY love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(SSAnneCutscene._smokeImage, q, sx, sy) end diff --git a/src/import/CacheContract.lua b/src/import/CacheContract.lua index 4b639bba..5d415b7e 100644 --- a/src/import/CacheContract.lua +++ b/src/import/CacheContract.lua @@ -22,12 +22,12 @@ CacheContract.VERSION_FORMAT = { -- data/pikachu/pikachu_pic_animation.asm:340 yellow = "rom-cache-v12-yellow2:", -- v8: M4A tracks retain reachable patterns and explicit entry offsets. - firered = "rom-cache-v23-firered:", - leafgreen = "rom-cache-v8-leafgreen:", - emerald = "rom-cache-v3-emerald:", + firered = "rom-cache-v24-firered:", + leafgreen = "rom-cache-v9-leafgreen:", + emerald = "rom-cache-v4-emerald:", -- pokeruby/src/string_util.c:408 - ruby = "rom-cache-v5-ruby:", - sapphire = "rom-cache-v5-sapphire:", + ruby = "rom-cache-v6-ruby:", + sapphire = "rom-cache-v6-sapphire:", } CacheContract.MARKER_PATH = "rom-cache.complete" diff --git a/src/import/gba/ow_extract.lua b/src/import/gba/ow_extract.lua index 8118549b..09da2221 100644 --- a/src/import/gba/ow_extract.lua +++ b/src/import/gba/ow_extract.lua @@ -8,7 +8,7 @@ local ExtractMapEvents = require("src.import.gba.extract_map_events") local OwExtract = {} OwExtract.MAGIC = "SVOW" -OwExtract.FORMAT_VERSION = 3 +OwExtract.FORMAT_VERSION = 4 OwExtract.REQUIRED = { "ow/manifest.lua", "ow/palette_manifest.lua" } @@ -130,6 +130,7 @@ local function read_graphics_info(rom, infoOff) local tracks = rom:get(infoOff + 13) local imagesPtr = rom:u32(infoOff + 0x1C) local animsPtr = rom:u32(infoOff + 0x18) + local subspriteTablesPtr = rom:u32(infoOff + 0x14) return { tileTag = tileTag, paletteTag = paletteTag, @@ -143,6 +144,59 @@ local function read_graphics_info(rom, infoOff) tracks = tracks, imagesPtr = imagesPtr, animsPtr = animsPtr, + subspriteTablesPtr = subspriteTablesPtr, + } +end + +-- src/sprite.c:246 +local OAM_DIMS = { + [0] = { { 8, 8 }, { 16, 16 }, { 32, 32 }, { 64, 64 } }, + [1] = { { 16, 8 }, { 32, 8 }, { 32, 16 }, { 64, 32 } }, + [2] = { { 8, 16 }, { 8, 32 }, { 16, 32 }, { 32, 64 } }, +} + +local function signed(v, bits) + local half = 2 ^ (bits - 1) + if v >= half then return v - 2 * half end + return v +end + +-- src/sprite.c:1669 +local function read_subsprite_layout(rom, tablesPtr, width, height) + local tableOff = tablesPtr ~= 0 and gba_off(tablesPtr) + if not tableOff then return nil end + local count = rom:get(tableOff) + local subsOff = gba_off(rom:u32(tableOff + 4)) + if not subsOff or count < 1 or count > 64 then return nil end + local game = Versions.GAME + -- pokeruby/include/sprite.h:153 + local wide = game == "ruby" or game == "sapphire" + local stride = wide and 8 or 4 + local subs = {} + local minX, minY, maxX, maxY = math.huge, math.huge, -math.huge, -math.huge + for i = 0, count - 1 do + local off = subsOff + i * stride + local x, y, bits + if wide then + x, y, bits = signed(rom:u16(off), 16), signed(rom:u16(off + 2), 16), rom:u16(off + 4) + else + x, y, bits = signed(rom:get(off), 8), signed(rom:get(off + 1), 8), rom:u16(off + 2) + end + local dims = OAM_DIMS[bits % 4] + dims = dims and dims[math.floor(bits / 4) % 4 + 1] + if not dims then return nil end + subs[#subs + 1] = { x = x, y = y, w = dims[1], h = dims[2], tile = math.floor(bits / 16) % 1024 } + minX, minY = math.min(minX, x), math.min(minY, y) + maxX, maxY = math.max(maxX, x + dims[1]), math.max(maxY, y + dims[2]) + end + if maxX - minX ~= width or maxY - minY ~= height then return nil end + for _, sub in ipairs(subs) do + sub.x, sub.y = sub.x - minX, sub.y - minY + end + return { + subs = subs, + offX = minX + math.floor(width / 2), + offY = minY + math.floor(height / 2), } end @@ -205,7 +259,7 @@ local function sym_frame_count(imagesOff) end --- Decode one 4bpp sprite frame (tile order: L→R, T→B 8×8) → indexed [w*h]. -local function decode_frame_4bpp(raw, width, height) +local function decode_frame_4bpp(raw, width, height, layout) local pixels = {} local tilesX = math.floor(width / 8) local tilesY = math.floor(height / 8) @@ -221,18 +275,24 @@ local function decode_frame_4bpp(raw, width, height) if high then return math.floor(b / 16) % 16 end return b % 16 end + local function tileAt(tx, ty) + if not layout then return ty * tilesX + tx end + local px, py = tx * 8, ty * 8 + for _, sub in ipairs(layout.subs) do + if px >= sub.x and px < sub.x + sub.w and py >= sub.y and py < sub.y + sub.h then + return sub.tile + ((py - sub.y) / 8) * (sub.w / 8) + (px - sub.x) / 8 + end + end + return nil + end for ty = 0, tilesY - 1 do for tx = 0, tilesX - 1 do - local tileIndex = ty * tilesX + tx - if width == 128 and height == 64 then - tileIndex = math.floor(ty / 4) * 64 + math.floor(tx / 8) * 32 - + (ty % 4) * 8 + tx % 8 - end - local tileOff = tileIndex * 32 + local tileIndex = tileAt(tx, ty) + local tileOff = (tileIndex or 0) * 32 for y = 0, 7 do for x = 0, 7 do local byteIndex = tileOff + y * 4 + math.floor(x / 2) - local idx = nybble(byteIndex, x % 2 == 1) + local idx = tileIndex and nybble(byteIndex, x % 2 == 1) or 0 local px = tx * 8 + x local py = ty * 8 + y pixels[py * width + px + 1] = idx @@ -305,6 +365,7 @@ function OwExtract.extractOne(rom, graphicsId, palsByTag, version, reflectionMap local imagesOff = gba_off(info.imagesPtr) if not imagesOff then return nil, "bad images ptr" end + local layout = read_subsprite_layout(rom, info.subspriteTablesPtr, w, h) local expected = math.floor(w * h / 2) local frames = {} @@ -320,7 +381,7 @@ function OwExtract.extractOne(rom, graphicsId, palsByTag, version, reflectionMap frames[i + 1] = {} for p = 1, w * h do frames[i + 1][p] = 0 end else - frames[i + 1] = decode_frame_4bpp(bytes, w, h) + frames[i + 1] = decode_frame_4bpp(bytes, w, h, layout) end end @@ -347,6 +408,8 @@ function OwExtract.extractOne(rom, graphicsId, palsByTag, version, reflectionMap reflectionPalette = rawReflectPal, mappedReflectionPalette = reflectPal, inanimate = info.inanimate, + drawOffX = layout and layout.offX or 0, + drawOffY = layout and layout.offY or 0, } end @@ -398,6 +461,8 @@ function OwExtract.encodeMeta(sprite) for c = 0, 15 do out[#out + 1] = u16le(sprite.palette and sprite.palette[c] or 0) end for c = 0, 15 do out[#out + 1] = u16le(sprite.reflectionPalette and sprite.reflectionPalette[c] or 0) end for c = 0, 15 do out[#out + 1] = u16le(sprite.mappedReflectionPalette and sprite.mappedReflectionPalette[c] or 0) end + out[#out + 1] = u8(sprite.drawOffX or 0) + out[#out + 1] = u8(sprite.drawOffY or 0) return table.concat(out) end @@ -431,6 +496,11 @@ function OwExtract.decodeMeta(blob) if meta.reflectionPaletteTag == PALETTE_TAG_NONE then meta.reflectionPaletteTag = nil end if meta.reflectionPaletteMappedTag == PALETTE_TAG_NONE then meta.reflectionPaletteMappedTag = nil end end + meta.drawOffX, meta.drawOffY = 0, 0 + if meta.formatVersion >= 4 and #blob >= 120 then + local function s8(v) return v >= 128 and v - 256 or v end + meta.drawOffX, meta.drawOffY = s8(blob:byte(119)), s8(blob:byte(120)) + end return meta end diff --git a/tests/drivers/game3_cinnabar_seagallop_arrival.lua b/tests/drivers/game3_cinnabar_seagallop_arrival.lua new file mode 100644 index 00000000..d98792d2 --- /dev/null +++ b/tests/drivers/game3_cinnabar_seagallop_arrival.lua @@ -0,0 +1,110 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_cinnabar_seagallop_arrival" + +-- pokefirered/include/constants/vars.h:165 +local VAR_MAP_SCENE_CINNABAR_ISLAND = 0x4071 +-- pokefirered/include/constants/flags.h:114 +local FLAG_HIDE_CINNABAR_BILL = 0x062 +-- pokefirered/include/constants/event_objects.h:114 +local OBJ_EVENT_GFX_SEAGALLOP = 108 +local CINNABAR = "FR_CINNABAR_ISLAND" +local SEAGALLOP_LOCALID = 4 + +local failures = 0 +local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end + +local function finish() + print(failures == 0 and "PASS cinnabar_seagallop_arrival" + or ("FAIL cinnabar_seagallop_arrival failures=" .. failures)) + love.event.quit(failures == 0 and 0 or 1) +end + +return function(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(240) + + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Space = require("src.core.game3.scripting.space") + local Flags = require("src.core.game3.scripting.flags") + local Player = require("src.core.game3.player") + local Message = require("src.ui.game3.message") + local Objects = require("src.core.game3.objects") + local OwSprites = require("src.core.game3.ow_sprites") + local GfxIds = require("src.core.game3.scripting.gfx_ids") + + if not result(Runtime.getSession() ~= nil, "new game reached the game3 field") then return finish() end + local function ctx() return Space.vm and Space.vm.ctx end + + local function goTo(x, y) + Map.load(nil, game, CINNABAR, { x = x, y = y, facing = "down" }) + Player.cellX, Player.cellY = x, y + Player.px, Player.py = x * 16, y * 16 + Player.targetX, Player.targetY = x, y + Player.facing = "down" + U.wait(90) + end + + goTo(20, 5) + -- pokefirered/data/maps/CinnabarIsland_Gym/scripts.inc:61 + Flags.setVar(Space.store, ctx(), VAR_MAP_SCENE_CINNABAR_ISLAND, 1) + Flags.setFlag(Space.store, ctx(), FLAG_HIDE_CINNABAR_BILL, false) + goTo(20, 5) + + result(OwSprites.ready(), "FRLG OW sprites are ready") + result(GfxIds.TO_SPRITE[OBJ_EVENT_GFX_SEAGALLOP] == nil, "no host sprite fallback for the Seagallop") + + local shots, boatSeen, arrived, arrivedMsg = 0, false, false, false + local lastX + for _ = 1, 2400 do + local boat = Objects.find(SEAGALLOP_LOCALID) + local visible = boat and boat.visible and not boat.hidden and not boat.invisible + if visible and not boatSeen then + boatSeen = true + local spr = OwSprites.getDraw(boat.graphicsId or (boat.def and boat.def.graphicsId)) + print(("[driver] boat added gid=%s cell=(%s,%s) spr=%sx%s frames=%s slot=%s"):format( + tostring(boat.graphicsId or (boat.def and boat.def.graphicsId)), tostring(boat.cellX), tostring(boat.cellY), + tostring(spr and spr.width), tostring(spr and spr.height), tostring(spr and spr.frameCount), + tostring(spr and spr.paletteSlot))) + result(spr and spr.width == 64 and spr.height == 64, "Seagallop draws the 64x64 ROM sheet") + end + if boatSeen and not arrived and boat and boat.px ~= lastX then + lastX = boat.px + if shots < 12 and (boat.px % 8 == 0) then + shots = shots + 1 + U.shot(game, ("%s/arrive_%02d_px%d.png"):format(DIR, shots, boat.px)) + end + end + if boatSeen and not arrived and boat and boat.cellX == 25 and not boat.moving then + arrived = true + U.wait(2) + U.shot(game, DIR .. "/arrive_docked.png") + print(("[driver] boat docked cell=(%d,%d) px=(%s,%s)"):format(boat.cellX, boat.cellY, + tostring(boat.px), tostring(boat.py))) + end + if arrived and Message.isOpen and Message.isOpen() and not arrivedMsg then + arrivedMsg = true + U.wait(30) + U.shot(game, DIR .. "/arrive_message.png") + break + end + if not boatSeen and Message.isOpen and Message.isOpen() then + U.tap(game, "a") + U.wait(8) + else + U.wait(1) + end + end + result(boatSeen, "the Seagallop was added by the Bill scene") + result(arrived, "the Seagallop docked at x=25") + result(arrivedMsg, "Bill's boat-arrived message opened") + finish() +end diff --git a/tests/drivers/game3_ss_anne_exterior_departure.lua b/tests/drivers/game3_ss_anne_exterior_departure.lua new file mode 100644 index 00000000..ee05eec4 --- /dev/null +++ b/tests/drivers/game3_ss_anne_exterior_departure.lua @@ -0,0 +1,88 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_ss_anne_exterior_departure" + +-- pokefirered/include/constants/vars.h:178 +local VAR_MAP_SCENE_VERMILION_CITY = 0x407E +-- pokefirered/include/constants/event_objects.h:157 +local OBJ_EVENT_GFX_SS_ANNE = 151 +local EXTERIOR = "FR_SSANNE_EXTERIOR" + +local failures = 0 +local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end + +local function finish() + print(failures == 0 and "PASS ss_anne_exterior_departure" + or ("FAIL ss_anne_exterior_departure failures=" .. failures)) + love.event.quit(failures == 0 and 0 or 1) +end + +return function(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(240) + + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Space = require("src.core.game3.scripting.space") + local Flags = require("src.core.game3.scripting.flags") + local Player = require("src.core.game3.player") + local Objects = require("src.core.game3.objects") + local OwSprites = require("src.core.game3.ow_sprites") + local Cutscene = require("src.core.game3.ss_anne_cutscene") + local GfxIds = require("src.core.game3.scripting.gfx_ids") + + if not result(Runtime.getSession() ~= nil, "new game reached the game3 field") then return finish() end + local function ctx() return Space.vm and Space.vm.ctx end + + local function goTo(x, y) + Map.load(nil, game, EXTERIOR, { x = x, y = y, facing = "down" }) + Player.cellX, Player.cellY = x, y + Player.px, Player.py = x * 16, y * 16 + Player.targetX, Player.targetY = x, y + Player.facing = "down" + end + + goTo(32, 14) + U.wait(30) + -- pokefirered/data/maps/SSAnne_Exterior/scripts.inc:11 + Flags.setVar(Space.store, ctx(), VAR_MAP_SCENE_VERMILION_CITY, 1) + goTo(32, 14) + + result(GfxIds.TO_SPRITE[OBJ_EVENT_GFX_SS_ANNE] == nil, "no host sprite fallback for the SS Anne") + local ship = Objects.find(1) + local gid = ship and (ship.graphicsId or (ship.def and ship.def.graphicsId)) + local spr = OwSprites.getDraw(gid) + print(("[driver] ship gid=%s cell=(%s,%s) spr=%sx%s slot=%s"):format(tostring(gid), + tostring(ship and ship.cellX), tostring(ship and ship.cellY), tostring(spr and spr.width), + tostring(spr and spr.height), tostring(spr and spr.paletteSlot))) + result(gid == OBJ_EVENT_GFX_SS_ANNE, "SS Anne object uses OBJ_EVENT_GFX_SS_ANNE") + result(spr and spr.width == 128 and spr.height == 64, "SS Anne draws the 128x64 ROM sheet") + + local started = false + for _ = 1, 600 do + if Cutscene.isActive() then started = true break end + U.wait(1) + end + result(started, "the departure cutscene started") + U.shot(game, DIR .. "/ssanne_00_start.png") + local shots = 0 + for _ = 1, 1800 do + if not Cutscene.isActive() then break end + local off = Cutscene._boatOffset or 0 + if off >= (shots + 1) * 40 and shots < 5 then + shots = shots + 1 + U.shot(game, ("%s/ssanne_%02d_off%d.png"):format(DIR, shots, off)) + else + U.wait(1) + end + end + result(not Cutscene.isActive(), "the departure cutscene finished") + finish() +end diff --git a/tests/engine/cache_contract_emerald_test.lua b/tests/engine/cache_contract_emerald_test.lua index d2c8c07e..83f6850f 100644 --- a/tests/engine/cache_contract_emerald_test.lua +++ b/tests/engine/cache_contract_emerald_test.lua @@ -16,8 +16,8 @@ local function digest(list) return h end -eq(CacheContract.markerFor("emerald", SHA), "rom-cache-v3-emerald:" .. SHA, "emerald marker") -check(CacheContract.markerMatches("emerald", "rom-cache-v3-emerald:" .. SHA), "emerald marker matches") +eq(CacheContract.markerFor("emerald", SHA), "rom-cache-v4-emerald:" .. SHA, "emerald marker") +check(CacheContract.markerMatches("emerald", "rom-cache-v4-emerald:" .. SHA), "emerald marker matches") check(not CacheContract.markerMatches("emerald", "rom-cache-v17-firered:" .. SHA), "a FireRed marker does not") eq(#CacheContract.requiredFiles("firered"), 456, "FireRed required list size") @@ -83,7 +83,7 @@ check(not CacheContract.cacheVersionCurrent("emerald", stale), "an emerald meta local complete = {} for _, p in ipairs(em) do complete["emerald/" .. p] = "x" end -complete["emerald/" .. CacheContract.MARKER_PATH] = "rom-cache-v3-emerald:" .. SHA +complete["emerald/" .. CacheContract.MARKER_PATH] = "rom-cache-v4-emerald:" .. SHA complete["emerald/data/generated/gba/meta.json"] = emMeta check(CacheContract.isReady("emerald", memfs(complete)), "a complete emerald cache is ready") diff --git a/tests/engine/game3_ow_reflection_meta_test.lua b/tests/engine/game3_ow_reflection_meta_test.lua index 6093618d..fbd40f0e 100644 --- a/tests/engine/game3_ow_reflection_meta_test.lua +++ b/tests/engine/game3_ow_reflection_meta_test.lua @@ -86,10 +86,14 @@ local blob = OwExtract.encodeMeta({ palette = source, reflectionPalette = rawReflection, mappedReflectionPalette = mappedReflection, + drawOffX = 32, + drawOffY = -4, }) local meta = OwExtract.decodeMeta(blob) check(meta ~= nil, "reflection metadata decodes") -eq(meta.formatVersion, 3, "new metadata format is explicit") +eq(meta.formatVersion, OwExtract.FORMAT_VERSION, "new metadata format is explicit") +eq(meta.drawOffX, 32, "subsprite draw x offset is retained") +eq(meta.drawOffY, -4, "negative subsprite draw y offset is retained") eq(meta.paletteTag, 0x1100, "base palette tag is retained") eq(meta.reflectionPaletteTag, 0x1102, "ROM reflection palette tag is retained") eq(meta.paletteSlot, 0, "ROM palette slot is retained") @@ -109,4 +113,5 @@ check(unavailable ~= nil, "metadata without a mapped reflection palette decodes" eq(unavailable.reflectionPaletteMappedTag, nil, "missing mapped palette stays unavailable") eq(unavailable.reflectionPalette, nil, "missing palette colors are not synthesized") eq(unavailable.mappedReflectionPalette, nil, "missing mapped colors are not synthesized") +eq(unavailable.drawOffX, 0, "missing draw offset is centered") T.finish("game3_ow_reflection_meta_test") diff --git a/tests/game3_cinnabar_seagallop_test.lua b/tests/game3_cinnabar_seagallop_test.lua index d7d8e11c..4d728478 100755 --- a/tests/game3_cinnabar_seagallop_test.lua +++ b/tests/game3_cinnabar_seagallop_test.lua @@ -16,9 +16,47 @@ local GfxIds = require("src.core.game3.scripting.gfx_ids") local Flags = require("src.core.game3.scripting.flags") local Space = require("src.core.game3.scripting.space") -print("[test] 1. GfxIds maps OBJ_EVENT_GFX_SEAGALLOP (108)") -local sprName = GfxIds.spriteFor(108) -check(sprName == "SPRITE_SAILOR", "108 maps to SPRITE_SAILOR fallback (got " .. tostring(sprName) .. ")") +print("[test] 1. OBJ_EVENT_GFX_SEAGALLOP (108) / OBJ_EVENT_GFX_SS_ANNE (151) come from the ROM") +check(GfxIds.TO_SPRITE[108] == nil, "108 has no host sprite fallback") +check(GfxIds.TO_SPRITE[109] == nil, "109 has no host sprite fallback") +check(GfxIds.TO_SPRITE[151] == nil, "151 has no host sprite fallback") + +local FR_ROOT = os.getenv("POKEFIRERED") or "../pokefirered" +local f = io.open(FR_ROOT .. "/pokefirered.gba", "rb") +if f then + local data = f:read("*a") + f:close() + local GV = require("src.core.GameVersion") + local Rom = require("src.import.gba.rom") + local OwExtract = require("src.import.gba.ow_extract") + local sha = GV.VERSIONS.firered.sha1 + local rom = assert(Rom.open({ + info = function() return { size = #data, md5 = sha } end, + read = function(_, _, off, len) return data:sub(off + 1, off + len) end, + }, GV.forSha1(sha))) + local pals = OwExtract.loadPaletteTable(rom, {}) + -- src/data/object_events/object_event_graphics_info.h:2889 + local boat = assert(OwExtract.extractOne(rom, 108, pals, {})) + check(boat.width == 64 and boat.height == 64, "Seagallop is a 64x64 sheet") + check(boat.paletteTag == 0x1114 and boat.paletteSlot == 10, "Seagallop uses OBJ_EVENT_PAL_TAG_SEAGALLOP in PALSLOT_NPC_SPECIAL") + check(boat.drawOffX == 0 and boat.drawOffY == 0, "Seagallop 64x64 subsprite is centered") + check(pals[0x1114] ~= nil, "Seagallop palette loaded from sObjectEventSpritePalettes") + local ext = OwExtract.decodeMeta(OwExtract.encodeMeta(boat)) + check(ext.width == 64 and ext.drawOffX == 0, "Seagallop meta round-trips") + -- src/data/object_events/object_event_graphics_info.h:2908 + local anne = assert(OwExtract.extractOne(rom, 151, pals, {})) + check(anne.width == 128 and anne.height == 64, "SS Anne is a 128x64 sheet") + check(anne.paletteTag == 0x1115 and anne.paletteSlot == 10, "SS Anne uses OBJ_EVENT_PAL_TAG_SS_ANNE in PALSLOT_NPC_SPECIAL") + -- src/data/object_events/object_event_subsprites.h:999 + check(anne.drawOffX == 32 and anne.drawOffY == 16, "SS Anne draws +32,+16 off center (got " + .. tostring(anne.drawOffX) .. "," .. tostring(anne.drawOffY) .. ")") + local meta = OwExtract.decodeMeta(OwExtract.encodeMeta(anne)) + check(meta.drawOffX == 32 and meta.drawOffY == 16, "SS Anne draw offset round-trips through the meta") + local player = assert(OwExtract.extractOne(rom, 0, pals, {})) + check(player.drawOffX == 0 and player.drawOffY == 0, "16x32 player subsprite stays centered") +else + print("[skip] pokefirered.gba not found, ROM sprite checks skipped") +end print("[test] 2. offMap bounds checking") local bounds = { w = 24, h = 20 } diff --git a/tests/game3_ss_anne_departure_test.lua b/tests/game3_ss_anne_departure_test.lua index 5f25aa68..1d3862e5 100644 --- a/tests/game3_ss_anne_departure_test.lua +++ b/tests/game3_ss_anne_departure_test.lua @@ -8,6 +8,7 @@ local Std = require("src.core.game3.scripting.stdscripts") local Natives = require("src.core.game3.scripting.natives") local SSAnneCutscene = require("src.core.game3.ss_anne_cutscene") local Objects = require("src.core.game3.objects") +local Player = require("src.core.game3.player") local playedSEs = {} local Audio = require("src.core.game3.audio") @@ -50,6 +51,7 @@ local mockBoat = { } Objects._byId[1] = mockBoat Objects._order = { 1 } +Player.cellX, Player.cellY = 32, 14 local task = SSAnneCutscene.start() assert_true(SSAnneCutscene.isActive(), "cutscene is active") @@ -89,16 +91,16 @@ assert_eq(mockBoat.raiseX, -14, "boat object raiseX is -14") -- Verify wake animation assert_true(SSAnneCutscene._wake.x2 > 0, "wake drifting right relative to boat") --- Advance to near exit (travel distance 216 px = 216 * 5 = 1080 frames) -local prevSmokesCount = #SSAnneCutscene._smokes -for f = 71, 1079 do +-- pokefirered/src/ss_anne.c:119 +for f = 71, 1045 do task() end +assert_eq(SSAnneCutscene._boatOffset, 209, "boat offset is 209 px at frame 1045") assert_eq(SSAnneCutscene._phase, "run", "still run phase before distance reached") assert_true(#playedSEs == 1, "only 1 horn so far") --- Frame 1080 reaches 216 px offset and triggers exit horn +-- pokefirered/src/ss_anne.c:119 task() assert_eq(SSAnneCutscene._phase, "finish", "switched to finish phase") assert_eq(#playedSEs, 2, "second horn played on departure")