Place object sprites by their ROM subsprite offsets and drop the sailor stand-ins for the Seagallop and SS Anne

This commit is contained in:
bryanthaboi
2026-10-05 05:43:14 -04:00
parent 586d56e6c7
commit c6ea960ed2
11 changed files with 388 additions and 61 deletions
+5 -2
View File
@@ -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)
-2
View File
@@ -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"
+44 -31
View File
@@ -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
+5 -5
View File
@@ -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"
+80 -10
View File
@@ -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
@@ -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
@@ -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
+3 -3
View File
@@ -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")
@@ -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")
+41 -3
View File
@@ -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 }
+6 -4
View File
@@ -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")