mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-26 13:33:27 -04:00
395c0542a3
wire up Crystal PalMap tile attrs (bank 1, flips, BG_PRIO) through map bake, attr grid, and BG-over-OAM blits instead of the gold/silver grassAtlasFor shortcut. crystal-only: MapAttrGrid + TileAttrs, OAM bottom/top split for IN_GRASS, keyed grass over feet strip via attrmap, drawBgPriorityOver for wAttrmap bit 7. gold/silver left alone on the old path — all of this gated behind isCrystal(). fixes standing still in grass with tufts on torso / feet on top of grass (#2080). RomExtractorGen2 pulls crystal PalMap attrs; SpriteRenderer splits standing sheets on frameHeight; tests for tile attrs + feet strip regression.
452 lines
16 KiB
Lua
452 lines
16 KiB
Lua
-- Crystal PalMap decoder and TileAttrs helper (bank-0 / $ff skip / bank-1 $80+).
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
|
|
local S = require("tests.harness").suite("gen2 crystal tile attrs")
|
|
local check, eq = S.check, S.eq
|
|
|
|
love = require("tests.love_stub")
|
|
|
|
local Extractor = require("src.import.RomExtractorGen2")
|
|
local GameVersion = require("src.core.GameVersion")
|
|
local TileAttrs = require("src.world.gen2.TileAttrs")
|
|
local OamFootprint = require("src.world.gen2.OamFootprint")
|
|
|
|
local priorVersion = GameVersion.get()
|
|
GameVersion.set("crystal")
|
|
|
|
local PAL_BANK = 0x13
|
|
local PAL_ADDR = 0x5000
|
|
|
|
local function fakeRom(bytes)
|
|
return {
|
|
bytes = function(_, bank, address, length)
|
|
local out = {}
|
|
if bank ~= PAL_BANK then return out end
|
|
for i = 0, length - 1 do
|
|
out[#out + 1] = bytes[address + i] or 0
|
|
end
|
|
return out
|
|
end,
|
|
}
|
|
end
|
|
|
|
local function crystalExtractor(palBytes)
|
|
local romBytes = {}
|
|
for i, b in ipairs(palBytes) do romBytes[PAL_ADDR + i - 1] = b end
|
|
return setmetatable({
|
|
rom = fakeRom(romBytes),
|
|
edition = "crystal",
|
|
}, Extractor)
|
|
end
|
|
|
|
-- Bank 0: one data byte, padding, bank 1: one data byte.
|
|
do
|
|
local pal = {}
|
|
pal[1] = 0x12 -- tile 0 pal 3, tile 1 pal 2
|
|
for i = 2, 48 do pal[i] = 0x00 end
|
|
for i = 49, 64 do pal[i] = 0xff end
|
|
pal[65] = 0x08 -- tile $80: nibble 8 -> pal 1, VRAM bank 1
|
|
for i = 66, 112 do pal[i] = 0x00 end
|
|
|
|
local ex = crystalExtractor(pal)
|
|
local palettes, attrs = ex:readCrystalPalMap(PAL_ADDR)
|
|
eq(palettes[1], 3, "bank-0 tile 0 palette")
|
|
eq(palettes[2], 2, "bank-0 tile 1 palette")
|
|
eq(palettes[97], nil, "padding does not land on tile 96")
|
|
eq(palettes[0x80 + 1], 1, "bank-1 tile $80 palette")
|
|
eq(attrs[0x80 + 1].vramBank, 1, "bank-1 tile $80 VRAM bank")
|
|
eq(attrs[0x80 + 1].priority, false, "retail nibble has no BG_PRIO")
|
|
end
|
|
|
|
-- TileAttrs lookup and backward-compat paletteSlot.
|
|
do
|
|
local tileset = {
|
|
tilePalettes = { [1] = 2, [0x81] = 5 },
|
|
tileAttrs = {
|
|
[1] = { palette = 2, vramBank = 0, priority = false,
|
|
xFlip = false, yFlip = false },
|
|
[0x81] = { palette = 5, vramBank = 1, priority = true,
|
|
xFlip = false, yFlip = false },
|
|
},
|
|
}
|
|
eq(TileAttrs.paletteSlot(tileset, 0), 2, "paletteSlot reads attrs")
|
|
eq(TileAttrs.forTile(tileset, 0x80).vramBank, 1, "bank-1 default when attrs sparse")
|
|
check(TileAttrs.forTile(tileset, 0x80).priority, "priority flag preserved")
|
|
local bankAlias = {
|
|
tileAttrs = {
|
|
[0x85 + 1] = { palette = 4, vramBank = 1, priority = false,
|
|
xFlip = false, yFlip = false },
|
|
},
|
|
}
|
|
eq(TileAttrs.forTile(bankAlias, 5).palette, 4,
|
|
"low tile id aliases bank-1 attrs at $80+")
|
|
end
|
|
|
|
-- MapAttrGrid pret normalization (bit 7 -> bank, norm id).
|
|
do
|
|
local MapAttrGrid = require("src.world.gen2.MapAttrGrid")
|
|
local tileset = {
|
|
tileAttrs = {
|
|
[0x05 + 1] = { palette = 2, vramBank = 0, priority = false,
|
|
xFlip = false, yFlip = false },
|
|
[0x85 + 1] = { palette = 6, vramBank = 1, priority = false,
|
|
xFlip = false, yFlip = false },
|
|
},
|
|
}
|
|
local norm, attr = MapAttrGrid.normalizeTile(0x85, tileset)
|
|
eq(norm, 5, "tile $85 normalizes to $05")
|
|
eq(attr.vramBank, 1, "bank from tile bit 7")
|
|
eq(attr.palette, 6, "bank-1 palmap slot")
|
|
end
|
|
|
|
-- OamFacings: RELATIVE_ATTRIBUTES only on bottom row (y = 8).
|
|
do
|
|
local OamFacings = require("src.world.gen2.OamFacings")
|
|
eq(OamFacings.bottomRowY(), 8, "facings.asm bottom row at y=8")
|
|
local rel = OamFacings.relativeAttrRows(OamFacings.standingDown)
|
|
eq(#rel, 2, "standing down has two RELATIVE_ATTRIBUTES tiles")
|
|
for _, entry in ipairs(rel) do
|
|
eq(entry.y, 8, "relative attr row is y=8")
|
|
end
|
|
end
|
|
|
|
-- sheetTileId maps B_BG_BANK1 attrs onto the Crystal 256-tile sheet.
|
|
do
|
|
eq(TileAttrs.sheetTileId(5, { vramBank = 0 }), 5, "bank 0 keeps tile id")
|
|
eq(TileAttrs.sheetTileId(5, { vramBank = 1 }), 0x85, "bank 1 remaps to $80+")
|
|
eq(TileAttrs.sheetTileId(0x85, { vramBank = 1 }), 0x85, "already $80+ unchanged")
|
|
end
|
|
|
|
-- drawFlippedTile keeps an 8x8 cell anchored (centre origin on flip).
|
|
do
|
|
local calls = {}
|
|
local orig = love.graphics.draw
|
|
love.graphics.draw = function(_, _, x, y, _, sx, sy, ox, oy)
|
|
calls[#calls + 1] = { x = x, y = y, sx = sx, sy = sy, ox = ox, oy = oy }
|
|
end
|
|
local img = love.graphics.newImage("missing.png")
|
|
local quad = { w = 8, h = 8 }
|
|
TileAttrs.drawFlippedTile(img, quad, 16, 16,
|
|
{ xFlip = false, yFlip = false }, 1, 1)
|
|
eq(#calls, 1, "no-flip draws once")
|
|
eq(calls[1].x, 16, "no-flip x at corner")
|
|
eq(calls[1].y, 16, "no-flip y at corner")
|
|
calls = {}
|
|
TileAttrs.drawFlippedTile(img, quad, 16, 16,
|
|
{ xFlip = true, yFlip = false }, 1, 1)
|
|
eq(calls[1].x, 20, "xFlip origin at cell centre")
|
|
eq(calls[1].y, 20, "xFlip y at cell centre")
|
|
eq(calls[1].sx, -1, "xFlip scale")
|
|
eq(calls[1].ox, 4, "xFlip pivot x")
|
|
eq(calls[1].oy, 4, "xFlip pivot y")
|
|
love.graphics.draw = orig
|
|
end
|
|
|
|
-- OamFootprint matches facings.asm RELATIVE_ATTRIBUTES row + yOffset.
|
|
do
|
|
local x0, y0, x1, y1 = OamFootprint.feetStrip({ px = 16, py = 80 })
|
|
eq(x0, 16, "feet strip x0")
|
|
eq(y0, 84, "feet strip y0 = py + 4")
|
|
eq(x1, 32, "feet strip x1")
|
|
eq(y1, 92, "feet strip y1 = py + 12")
|
|
x0, y0, x1, y1 = OamFootprint.feetStrip(
|
|
{ px = 0, py = 0, spriteYOffset = -4 })
|
|
eq(y0, 0, "feet strip follows spriteYOffset")
|
|
eq(y1, 8, "feet strip height stays 8 px")
|
|
end
|
|
|
|
-- drawPriorityOver blits when a tile carries priority.
|
|
do
|
|
local World = require("src.world.gen2.World")
|
|
local world = setmetatable({
|
|
map = { id = "TEST", def = {}, width = 1, height = 1,
|
|
blocks = { 0 }, borderBlock = 0 },
|
|
bgSets = {},
|
|
animCells = {},
|
|
}, { __index = World })
|
|
|
|
local draws = {}
|
|
local orig = love.graphics.draw
|
|
love.graphics.draw = function(img, quad, x, y, ...)
|
|
draws[#draws + 1] = { img = img, x = x, y = y }
|
|
return orig(img, quad, x, y, ...)
|
|
end
|
|
|
|
local atlas = love.graphics.newImage("missing.png")
|
|
local block = {}
|
|
for i = 1, 16 do block[i] = 0 end
|
|
block[6] = 0x42 -- 8x8 cell at map pixel (8, 8)
|
|
local tileset = {
|
|
tilesPerRow = 16,
|
|
blocks = { block },
|
|
tileAttrs = {
|
|
[0x42 + 1] = { palette = 1, vramBank = 0, priority = true,
|
|
xFlip = false, yFlip = false },
|
|
},
|
|
}
|
|
function world:grassAtlasFor() return atlas, tileset end
|
|
function world:atlasFor() return atlas, tileset end
|
|
function world:mapCacheKey() return "TEST|DAY" end
|
|
|
|
local entity = { px = 8, py = 8, inGrass = false, grassShake = false, moving = false }
|
|
world:drawPriorityOver(entity, 0, 0, 2)
|
|
check(#draws > 0, "priority tile redraws over the sprite")
|
|
love.graphics.draw = orig
|
|
end
|
|
|
|
-- IN_GRASS only covers the RELATIVE_ATTRIBUTES row (py+4 .. py+12), not the torso.
|
|
do
|
|
local World = require("src.world.gen2.World")
|
|
local world = setmetatable({
|
|
map = { id = "TEST", def = {}, width = 2, height = 2,
|
|
blocks = { 0, 0, 0, 0 }, borderBlock = 0 },
|
|
bgSets = {},
|
|
animCells = {},
|
|
}, { __index = World })
|
|
|
|
local draws = {}
|
|
local scissorCalls = {}
|
|
local origDraw = love.graphics.draw
|
|
local origScissor = love.graphics.setScissor
|
|
love.graphics.draw = function(img, quad, x, y, r, sx, sy, ox, oy)
|
|
local h = 8
|
|
if quad and quad.getViewport then
|
|
local okv, _, _, _, qh = pcall(quad.getViewport, quad)
|
|
if okv and qh then h = qh end
|
|
elseif quad and quad.h then h = quad.h end
|
|
draws[#draws + 1] = { x = x, y = y, h = h }
|
|
return origDraw(img, quad, x, y, r, sx, sy, ox, oy)
|
|
end
|
|
love.graphics.setScissor = function(x, y, w, h)
|
|
scissorCalls[#scissorCalls + 1] = { x = x, y = y, w = w, h = h }
|
|
end
|
|
|
|
local atlas = love.graphics.newImage("missing.png")
|
|
local block = {}
|
|
for i = 1, 16 do block[i] = 0x18 end
|
|
local tileset = { tilesPerRow = 16, blocks = { block } }
|
|
function world:grassAtlasFor() return atlas, tileset end
|
|
function world:atlasFor() return atlas, tileset end
|
|
function world:mapCacheKey() return "TEST|DAY" end
|
|
|
|
-- py=0: feet strip is map y=4..12 (facings.asm y=8 + OAM_Y_OFS-4).
|
|
world:drawGrassOver(
|
|
{ px = 0, py = 0, inGrass = true, grassShake = false, moving = false },
|
|
0, 0, 1)
|
|
check(#draws > 0, "IN_GRASS redraws grass over the feet")
|
|
for _, d in ipairs(draws) do
|
|
eq(d.h, 8, "IN_GRASS draws full 8x8 tiles, not sub-quad slivers")
|
|
end
|
|
check(#scissorCalls > 0, "IN_GRASS sets scissor for the feet strip")
|
|
eq(scissorCalls[1].y, 4, "scissor y0 matches feet strip")
|
|
eq(scissorCalls[1].h, 8, "scissor height is 8 px")
|
|
|
|
-- py=8 (not 8-aligned feet): two full tile rows, scissor clips to y=12..20.
|
|
draws = {}
|
|
scissorCalls = {}
|
|
world:drawGrassOver(
|
|
{ px = 0, py = 8, inGrass = true, grassShake = false, moving = false },
|
|
0, 0, 1)
|
|
eq(#draws, 4, "misaligned py blits every intersecting 8x8 cell whole")
|
|
for _, d in ipairs(draws) do
|
|
eq(d.h, 8, "misaligned py still uses full 8x8 quads")
|
|
end
|
|
check(#scissorCalls > 0, "misaligned py sets scissor")
|
|
eq(scissorCalls[1].y, 12, "misaligned scissor y0 = py + 4")
|
|
eq(scissorCalls[1].h, 8, "misaligned scissor height = 8")
|
|
|
|
love.graphics.draw = origDraw
|
|
love.graphics.setScissor = origScissor
|
|
end
|
|
|
|
-- Playfield letterbox offset must reach scissor (LÖVE screen space).
|
|
do
|
|
local World = require("src.world.gen2.World")
|
|
local Playfield = require("src.render.Playfield")
|
|
local world = setmetatable({
|
|
map = { id = "TEST", def = {}, width = 2, height = 2,
|
|
blocks = { 0, 0, 0, 0 }, borderBlock = 0 },
|
|
bgSets = {},
|
|
animCells = {},
|
|
}, { __index = World })
|
|
|
|
local scissorCalls = {}
|
|
local origScissor = love.graphics.setScissor
|
|
love.graphics.setScissor = function(x, y, w, h)
|
|
scissorCalls[#scissorCalls + 1] = { x = x, y = y, w = w, h = h }
|
|
return origScissor(x, y, w, h)
|
|
end
|
|
|
|
local atlas = love.graphics.newImage("missing.png")
|
|
local block = {}
|
|
for i = 1, 16 do block[i] = 0x18 end
|
|
local tileset = { tilesPerRow = 16, blocks = { block } }
|
|
function world:atlasFor() return atlas, tileset end
|
|
function world:mapCacheKey() return "TEST|DAY" end
|
|
|
|
Playfield.enter(48, 32, 160, 144)
|
|
world:drawGrassOver(
|
|
{ px = 0, py = 0, inGrass = true, grassShake = false, moving = false },
|
|
10, 20, 2)
|
|
Playfield.leave()
|
|
|
|
check(#scissorCalls > 0, "scissor set under Playfield offset")
|
|
eq(scissorCalls[1].x, 48 + 10, "scissor x includes Playfield.box.x")
|
|
eq(scissorCalls[1].y, 32 + 20 + 4 * 2, "scissor y includes Playfield.box.y")
|
|
eq(scissorCalls[1].h, 16, "scissor height scaled")
|
|
|
|
love.graphics.setScissor = origScissor
|
|
end
|
|
|
|
-- IN_GRASS uses screen-space compositing (bottom OAM, keyed grass, top OAM).
|
|
do
|
|
local World = require("src.world.gen2.World")
|
|
local world = setmetatable({
|
|
map = { id = "TEST", def = {}, width = 2, height = 2,
|
|
blocks = { 0, 0, 0, 0 }, borderBlock = 0 },
|
|
bgSets = { ["TEST|DAY"] = { [1] = { {0,0,0}, {1,1,1}, {2,2,2}, {3,3,3} } } },
|
|
animCells = {},
|
|
}, { __index = World })
|
|
|
|
local order = {}
|
|
local origGrass = world.drawGrassOver
|
|
function world:drawGrassOver(e, ox, oy, sc)
|
|
order[#order + 1] = "grass"
|
|
return origGrass(self, e, ox, oy, sc)
|
|
end
|
|
|
|
local atlas = love.graphics.newImage("missing.png")
|
|
local block = {}
|
|
for i = 1, 16 do block[i] = 0x18 end
|
|
local tileset = { tilesPerRow = 16, blocks = { block } }
|
|
function world:atlasFor() return atlas, tileset end
|
|
function world:mapCacheKey() return "TEST|DAY" end
|
|
|
|
world:drawEntityComposite(
|
|
{ px = 0, py = 0, inGrass = true, grassShake = false, moving = false },
|
|
0, 0, 1,
|
|
function(row)
|
|
order[#order + 1] = row or "full"
|
|
end,
|
|
false)
|
|
eq(order[1], "bottom", "IN_GRASS draws bottom OAM first")
|
|
eq(order[2], "grass", "IN_GRASS redraws keyed grass over bottom OAM")
|
|
eq(order[3], "top", "IN_GRASS draws top OAM last")
|
|
end
|
|
|
|
-- Animated feet cells use the live anim frame, not the baked atlas tile.
|
|
do
|
|
local World = require("src.world.gen2.World")
|
|
local world = setmetatable({
|
|
map = { id = "TEST", def = { tileset = "JOHTO" }, width = 1, height = 1,
|
|
blocks = { 0 }, borderBlock = 0 },
|
|
bgSets = { ["TEST|DAY"] = { [2] = { {0,0,0}, {1,1,1}, {2,2,2}, {3,3,3} } } },
|
|
animCells = {
|
|
["TEST|DAY"] = {
|
|
[0x03] = {
|
|
layer = { kind = "flower", sheet = "flower.png", frames = 4 },
|
|
tile = 0x03,
|
|
slot = 2,
|
|
cells = { 0, 8, 8, 8 },
|
|
},
|
|
},
|
|
},
|
|
}, { __index = World })
|
|
|
|
local atlas = love.graphics.newImage("missing.png")
|
|
local animImg = love.graphics.newImage("missing2.png")
|
|
local block = {}
|
|
for i = 1, 16 do block[i] = 0 end
|
|
block[5] = 0x03 -- map pixel (0, 8)
|
|
block[6] = 0x03 -- map pixel (8, 8)
|
|
local tileset = { tilesPerRow = 16, blocks = { block } }
|
|
function world:atlasFor() return atlas, tileset end
|
|
function world:mapCacheKey() return "TEST|DAY" end
|
|
function world:animSheet(path)
|
|
if path == "flower.png" then return animImg end
|
|
end
|
|
|
|
local drewAnim, drewAtlas = false, false
|
|
local orig = love.graphics.draw
|
|
love.graphics.draw = function(img, ...)
|
|
if img == animImg then drewAnim = true end
|
|
if img == atlas then drewAtlas = true end
|
|
return orig(img, ...)
|
|
end
|
|
|
|
world:drawGrassOver(
|
|
{ px = 0, py = 4, inGrass = true, grassShake = false, moving = false },
|
|
0, 0, 1)
|
|
check(drewAnim, "feet overdraw uses live anim sheet for animated cells")
|
|
check(not drewAtlas, "feet overdraw skips static atlas for animated cells")
|
|
love.graphics.draw = orig
|
|
end
|
|
|
|
-- blitBgOverRegion must land tiles at ox + tx*s (absolute map px), not ox.
|
|
do
|
|
local World = require("src.world.gen2.World")
|
|
local world = setmetatable({
|
|
map = { id = "TEST", def = {}, width = 4, height = 4,
|
|
blocks = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 },
|
|
borderBlock = 0 },
|
|
bgSets = { ["TEST|DAY"] = { [1] = { {0,0,0}, {1,1,1}, {2,2,2}, {3,3,3} } } },
|
|
animCells = {},
|
|
}, { __index = World })
|
|
|
|
local atlas = love.graphics.newImage("missing.png")
|
|
local block = {}
|
|
for i = 1, 16 do block[i] = 0x18 end
|
|
local tileset = { tilesPerRow = 16, blocks = { block },
|
|
tileAttrs = { [0x19] = { palette = 1, vramBank = 0, priority = false,
|
|
xFlip = false, yFlip = false } } }
|
|
function world:atlasFor() return atlas, tileset end
|
|
function world:mapCacheKey() return "TEST|DAY" end
|
|
|
|
local draws = {}
|
|
local orig = love.graphics.draw
|
|
love.graphics.draw = function(img, quad, x, y, ...)
|
|
if img == atlas then draws[#draws + 1] = { x = x, y = y } end
|
|
return orig(img, quad, x, y, ...)
|
|
end
|
|
|
|
-- Camera at 0,0; entity at map px (32, 40); feet strip py+4 = 44.
|
|
world:drawGrassOverCrystal(
|
|
{ px = 32, py = 40, inGrass = true, grassShake = false, moving = false },
|
|
0, 0, 1)
|
|
check(#draws > 0, "grass overdraw blits when entity is off map origin")
|
|
eq(draws[1].x, 32, "blit x uses absolute map pixel tx")
|
|
check(draws[1].y >= 40 and draws[1].y < 52,
|
|
"blit y is tile-aligned under the feet strip, not stuck at oy")
|
|
check(draws[1].y ~= 0, "blit y is not the pre-fix map-origin bug")
|
|
|
|
love.graphics.draw = orig
|
|
end
|
|
|
|
-- OAM row split works on standing sheets (frames = 1, height = 16).
|
|
do
|
|
local SpriteRenderer = require("src.render.SpriteRenderer")
|
|
local calls = {}
|
|
local orig = love.graphics.draw
|
|
love.graphics.draw = function(_, quad, x, y)
|
|
calls[#calls + 1] = { x = x, y = y, quad = quad }
|
|
return orig(_, quad, x, y)
|
|
end
|
|
local sr = SpriteRenderer.new({
|
|
image = "missing.png", frames = 1, frameWidth = 16, frameHeight = 16,
|
|
})
|
|
sr:draw(0, 16, 0, 0, "down", 0, false, nil, nil, nil, "bottom")
|
|
sr:draw(0, 16, 0, 0, "down", 0, false, nil, nil, nil, "top")
|
|
eq(#calls, 2, "bottom and top OAM rows each draw once")
|
|
check(sr.bottomFrames and sr.bottomFrames[0] ~= nil,
|
|
"bottom OAM uses a dedicated 8 px quad")
|
|
check(sr.halfFrames and sr.halfFrames[0] ~= nil,
|
|
"top OAM uses a dedicated 8 px quad")
|
|
check(calls[1].y > calls[2].y,
|
|
"bottom row draws below the top row (facings.asm y = 8)")
|
|
love.graphics.draw = orig
|
|
end
|
|
|
|
GameVersion.set(priorVersion)
|
|
|
|
S.finish()
|