diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index cf3404f6..ca6b69fa 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -613,14 +613,94 @@ function RomExtractorGen2:battleObjectPals() return out end +-- GBC BG attribute bits (constants/hardware.inc B_BG_*). +local BG_BANK1_BIT = 0x08 +local BG_XFLIP_BIT = 0x20 +local BG_YFLIP_BIT = 0x40 +local BG_PRIO_BIT = 0x80 + +local function decodePalNibble(n) + n = n % 16 + return { + palette = (n % 8) + 1, + vramBank = math.floor(n / 8) % 2, + xFlip = false, + yFlip = false, + priority = false, + } +end + +local function decodePalByte(b) + return { + palette = (b % 8) + 1, + vramBank = math.floor(b / BG_BANK1_BIT) % 2, + xFlip = bit.band(b, BG_XFLIP_BIT) ~= 0, + yFlip = bit.band(b, BG_YFLIP_BIT) ~= 0, + priority = bit.band(b, BG_PRIO_BIT) ~= 0, + } +end + +local function assignPalPair(tileId, byte, palettes, attrs, useBytes) + if useBytes then + local a = decodePalByte(byte) + attrs[tileId + 1] = a + palettes[tileId + 1] = a.palette + return + end + local low = byte % 16 + local high = math.floor(byte / 16) % 16 + for i, n in ipairs({ low, high }) do + local id = tileId + (i - 1) + local a = decodePalNibble(n) + attrs[id + 1] = a + palettes[id + 1] = a.palette + end +end + +-- Crystal: 48 bytes bank-0 + 16 bytes $ff + 48 bytes bank-1 (gfx/tilesets/*_palette_map.asm). +-- Bank-1 attrs land on tile ids $80-$df, not on the linear indices after padding. +function RomExtractorGen2:readCrystalPalMap(address) + local raw = self.rom:bytes(self:palMapBank(), address, CRYSTAL_PAL_MAP_BYTES) + local palettes = {} + local attrs = {} + local i = 1 + local tileId = 0 + local bank0Count = 0 + while i <= #raw and bank0Count < 48 do + local byte = raw[i] + if byte == 0xff then break end + assignPalPair(tileId, byte, palettes, attrs, false) + tileId = tileId + 2 + bank0Count = bank0Count + 1 + i = i + 1 + end + while i <= #raw and raw[i] == 0xff do + i = i + 1 + end + tileId = 0x80 + local bank1Count = 0 + while i <= #raw and bank1Count < 48 do + local byte = raw[i] + if byte == 0xff then break end + assignPalPair(tileId, byte, palettes, attrs, false) + tileId = tileId + 2 + bank1Count = bank1Count + 1 + i = i + 1 + end + return palettes, attrs +end + -- A tileset's PalMap: 48 bytes on Gold and 112 on Crystal, two tiles apiece. -- `tilepal` emits `dn (bank | PAL_BG_second), (bank | PAL_BG_first)`, so the -- low nibble is the even tile and the high nibble the odd one; masking to 3 -- bits drops the OAM_BANK flag and leaves the PAL_BG_* slot. Returned 1-based -- so the value indexes an 8-entry Lua palette set directly. function RomExtractorGen2:readPalMap(address) + if self.edition == "crystal" then + local palettes = self:readCrystalPalMap(address) + return palettes + end local length = TILESET_TILE_COUNT / 2 - if self.edition == "crystal" then length = CRYSTAL_PAL_MAP_BYTES end local raw = self.rom:bytes(self:palMapBank(), address, length) local out = {} for i, byte in ipairs(raw) do @@ -1002,6 +1082,13 @@ function RomExtractorGen2:extractTilesets() } end + local tilePalettes, tileAttrs + if twoBank then + tilePalettes, tileAttrs = self:readCrystalPalMap(palMapAddress) + else + tilePalettes = self:readPalMap(palMapAddress) + end + out[constName] = { id = constName, generation = 2, @@ -1017,7 +1104,9 @@ function RomExtractorGen2:extractTilesets() palMap = { bank = self:palMapBank(), address = palMapAddress }, -- Which of the eight loaded BG palettes each sheet tile draws with, -- 1-based into palettes.bg slots (see readPalMap). - tilePalettes = self:readPalMap(palMapAddress), + tilePalettes = tilePalettes, + -- Crystal only: full GBC attribute byte per tile id (palette, bank, flip, priority). + tileAttrs = tileAttrs, } self:tick("World tiles", index, #order) end diff --git a/src/render/GbcPalette.lua b/src/render/GbcPalette.lua index 5b12a81e..bfb7b24e 100644 --- a/src/render/GbcPalette.lua +++ b/src/render/GbcPalette.lua @@ -66,6 +66,30 @@ vec4 effect(vec4 tint, Image tex, vec2 uv, vec2 screen) { } ]] +-- BG drawn over OBJ with OAM priority: palette index 0 is transparent to the +-- sprite underneath (hardware OBJ-behind-BG rule), colours 1-3 are opaque. +local KEYED_SHADER_SOURCE = [[ +extern vec3 pal0; +extern vec3 pal1; +extern vec3 pal2; +extern vec3 pal3; + +vec4 effect(vec4 tint, Image tex, vec2 uv, vec2 screen) { + vec4 px = Texel(tex, uv); + float shade = floor((1.0 - px.r) * 3.0 + 0.5); + vec3 rgb = pal0; + if (shade > 2.5) { + rgb = pal3; + } else if (shade > 1.5) { + rgb = pal2; + } else if (shade > 0.5) { + rgb = pal1; + } + float alpha = shade < 0.5 ? 0.0 : px.a; + return vec4(rgb, alpha) * tint; +} +]] + -- rBGP, the DMG background palette register, as a remap of an ALREADY DRAWN -- texture. -- @@ -117,6 +141,8 @@ GbcPalette.REMAP_TOLERANCE = (3 / 255) ^ 2 local shader = nil local failed = false +local keyedShader = nil +local keyedFailed = false local remapShader = nil local remapFailed = false @@ -137,6 +163,21 @@ function GbcPalette.shader() return shader end +function GbcPalette.keyedShader() + if keyedShader or keyedFailed then return keyedShader end + if not (love and love.graphics and love.graphics.newShader) then + keyedFailed = true + return nil + end + local ok, result = pcall(love.graphics.newShader, KEYED_SHADER_SOURCE) + if not ok then + keyedFailed = true + return nil + end + keyedShader = result + return keyedShader +end + -- The same contract as GbcPalette.shader for the backwards pass: nil rather -- than an error, so a caller can fall back to its own approximation. function GbcPalette.remapShader() @@ -331,6 +372,18 @@ function GbcPalette.useRaw(colors) return true end +function GbcPalette.useKeyed(colors) + local sh = GbcPalette.keyedShader() + if not sh then return false end + local resolved = GbcPalette.remap(GbcPalette.resolve(colors), GbcPalette.bgp) + for i = 0, 3 do + local r, g, b = channel(resolved, i + 1) + sh:send("pal" .. i, { r, g, b }) + end + love.graphics.setShader(sh) + return true +end + function GbcPalette.clear() if love and love.graphics then love.graphics.setShader() end end @@ -476,6 +529,16 @@ function GbcPalette.with(colors, body) return applied end +function GbcPalette.keyedWith(colors, body) + local previous = love and love.graphics and love.graphics.getShader + and love.graphics.getShader() or nil + local applied = GbcPalette.useKeyed(colors) + local ok, err = pcall(body) + if love and love.graphics then love.graphics.setShader(previous) end + if not ok then error(err, 0) end + return applied +end + function GbcPalette.withRaw(colors, body) local previous = love and love.graphics and love.graphics.getShader and love.graphics.getShader() or nil diff --git a/src/render/SpriteRenderer.lua b/src/render/SpriteRenderer.lua index 3d02d4f9..2b74ea69 100644 --- a/src/render/SpriteRenderer.lua +++ b/src/render/SpriteRenderer.lua @@ -286,9 +286,10 @@ end -- row's: FacingWeirdTree3 is FacingWeirdTree1's four tiles with the columns -- swapped and OAM_XFLIP on each (data/sprites/facings.asm:192-197). Optional -- and trailing, so every existing call site is unchanged. +-- `oamRow`: nil draws the whole frame; "top" / "bottom" draw only the +-- facings.asm row at y=0 or y=8 (InitSprite + RELATIVE_ATTRIBUTES). function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, - topHalf, forceFlip, frameOverride) - local x, y = self:getScreenOrigin(px, py, camX, camY) + topHalf, forceFlip, frameOverride, oamRow) local image = self.image local redraw = false -- True-color sheets bypass every palette bake; the screen-space exemption @@ -341,17 +342,34 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, if forceFlip then flip = true end local quad = self.frames[frame] local drawHeight = self.frameHeight - if topHalf and self.frameCount > 1 then + local rowH = math.min(8, self.frameHeight) + local bottomSkip = math.max(0, self.frameHeight - rowH) + if oamRow == "bottom" then topHalf = false + elseif oamRow == "top" then topHalf = true end + -- facings.asm splits at y = 8 inside a 16 px-tall frame; do not require + -- multiple animation frames (standing sheets are often frames = 1). + if topHalf and self.frameHeight > rowH then self.halfFrames = self.halfFrames or {} if not self.halfFrames[frame] then local iw, ih = self.image:getDimensions() - local topHeight = math.max(1, self.frameHeight - math.min(8, self.frameHeight)) + local topHeight = math.max(1, self.frameHeight - rowH) self.halfFrames[frame] = love.graphics.newQuad( 0, frame * self.frameHeight, self.frameWidth, topHeight, iw, ih) end quad = self.halfFrames[frame] - drawHeight = math.max(1, self.frameHeight - math.min(8, self.frameHeight)) + drawHeight = math.max(1, self.frameHeight - rowH) + elseif oamRow == "bottom" and self.frameHeight > rowH then + self.bottomFrames = self.bottomFrames or {} + if not self.bottomFrames[frame] then + local iw, ih = self.image:getDimensions() + self.bottomFrames[frame] = love.graphics.newQuad( + 0, frame * self.frameHeight + bottomSkip, self.frameWidth, rowH, iw, ih) + end + quad = self.bottomFrames[frame] + drawHeight = rowH end + local x, y = self:getScreenOrigin(px, py, camX, camY) + if oamRow == "bottom" then y = y + bottomSkip end -- Full-color art claims exactly the portion of the frame that was drawn. if liveTrueColor(self.def) then PaletteFX.markTrueColor(x, y, self.frameWidth, drawHeight) diff --git a/src/world/gen2/BorderFill.lua b/src/world/gen2/BorderFill.lua index be694dd5..64541fd2 100644 --- a/src/world/gen2/BorderFill.lua +++ b/src/world/gen2/BorderFill.lua @@ -25,6 +25,8 @@ local GbcPalette = require("src.render.GbcPalette") local PixelCanvas = require("src.render.PixelCanvas") +local GameVersion = require("src.core.GameVersion") +local TileAttrs = require("src.world.gen2.TileAttrs") local BorderFill = {} @@ -83,7 +85,12 @@ function BorderFill.bake(atlas, tileset, blockId, bgSet, waterFrame) local colored = bgSet and tilePalettes and GbcPalette.available() local aw, ah = atlas:getDimensions() local quads = {} + local crystal = GameVersion.engine() == "crystal" local function quadFor(tile) + if crystal then + local attr = TileAttrs.forTile(tileset, tile) + return TileAttrs.quadFor(atlas, tile, attr, tilesPerRow, quads) + end local q = quads[tile] if q then return q end q = love.graphics.newQuad((tile % tilesPerRow) * 8, @@ -94,12 +101,18 @@ function BorderFill.bake(atlas, tileset, blockId, bgSet, waterFrame) local function drawTiles(slot) for i = 0, 15 do local tile = block[i + 1] or 0 - -- tilePalettes is 1-based over the sheet tiles; anything past it takes - -- slot 1, exactly as the map bake does. - local tileSlot = tilePalettes and tilePalettes[tile + 1] or 1 + local tileSlot = crystal + and TileAttrs.paletteSlot(tileset, tile) + or (tilePalettes and tilePalettes[tile + 1] or 1) if not slot or tileSlot == slot then - love.graphics.draw(atlas, quadFor(tile), - (i % 4) * 8, math.floor(i / 4) * 8) + local tx = (i % 4) * 8 + local ty = math.floor(i / 4) * 8 + if crystal then + local attr = TileAttrs.forTile(tileset, tile) + TileAttrs.drawFlippedTile(atlas, quadFor(tile), tx, ty, attr) + else + love.graphics.draw(atlas, quadFor(tile), tx, ty) + end end end end diff --git a/src/world/gen2/MapAttrGrid.lua b/src/world/gen2/MapAttrGrid.lua new file mode 100644 index 00000000..9349387c --- /dev/null +++ b/src/world/gen2/MapAttrGrid.lua @@ -0,0 +1,82 @@ +-- Per 8x8 cell tile id + GBC attributes, mirroring pret wSurroundingTiles + +-- wAttrmap as built by LoadOverworldAttrmapPals (engine/tilesets/map_palettes.asm). +-- +-- Retail Crystal derives palette (+ VRAM bank in the palmap nybble) from the +-- tileset PalMap indexed by the metatile byte; bit 7 of the tile id is cleared +-- into the attr bank bit and the normalized id is what VRAM fetches. + +local BorderFill = require("src.world.gen2.BorderFill") +local TileAttrs = require("src.world.gen2.TileAttrs") + +local MapAttrGrid = {} + +-- pret _LoadOverworldAttrmapPals: srl a / carry picks upper vs lower nybble; +-- res 7,[hl] clears tile bit 7 after the lookup. +function MapAttrGrid.normalizeTile(rawTileId, tileset) + if not rawTileId then return nil, nil end + local bankFromTile = (rawTileId >= 0x80) and 1 or 0 + local normId = rawTileId % 0x80 + + local attrs = tileset and tileset.tileAttrs + local attr + if attrs then + if bankFromTile == 1 then + attr = attrs[0x80 + normId + 1] or attrs[normId + 1] + else + attr = attrs[normId + 1] or attrs[0x80 + normId + 1] + end + end + if not attr then + attr = TileAttrs.forTile(tileset, rawTileId) + end + + local out = { + palette = attr.palette, + vramBank = (attr.vramBank ~= 0 and attr.vramBank or bankFromTile), + priority = attr.priority, + xFlip = attr.xFlip, + yFlip = attr.yFlip, + } + return normId, out +end + +function MapAttrGrid.tileAt(map, tileset, mx, my) + local bx, by = math.floor(mx / 32), math.floor(my / 32) + if bx < 0 or by < 0 or bx >= map.width or by >= map.height then return nil end + local blockId = BorderFill.blockFor( + map.blocks[by * map.width + bx + 1], map.borderBlock) + local block = tileset.blocks and tileset.blocks[(blockId or 0) + 1] + if not block then return nil end + local i = math.floor((my % 32) / 8) * 4 + math.floor((mx % 32) / 8) + return block[i + 1] +end + +function MapAttrGrid.cellAt(map, tileset, mx, my) + local raw = MapAttrGrid.tileAt(map, tileset, mx, my) + if raw == nil then return nil end + local tileId, attr = MapAttrGrid.normalizeTile(raw, tileset) + return { tileId = tileId, rawTileId = raw, attr = attr } +end + +-- Full map grid keyed by "mx,my" for fast lookup during overdraw. +function MapAttrGrid.build(map, tileset) + local grid = {} + if not (map and tileset) then return grid end + local pw, ph = map.width * 32, map.height * 32 + for my = 0, ph - 1, 8 do + for mx = 0, pw - 1, 8 do + local cell = MapAttrGrid.cellAt(map, tileset, mx, my) + if cell then grid[mx .. "," .. my] = cell end + end + end + return grid +end + +function MapAttrGrid.lookup(grid, mx, my) + if not grid then return nil end + local tx = math.floor(mx / 8) * 8 + local ty = math.floor(my / 8) * 8 + return grid[tx .. "," .. ty] +end + +return MapAttrGrid diff --git a/src/world/gen2/MapPreview.lua b/src/world/gen2/MapPreview.lua index 9defa1c5..805283d8 100644 --- a/src/world/gen2/MapPreview.lua +++ b/src/world/gen2/MapPreview.lua @@ -5,8 +5,10 @@ local Assets = require("src.render.Assets") local BorderFill = require("src.world.gen2.BorderFill") local GbcPalette = require("src.render.GbcPalette") +local GameVersion = require("src.core.GameVersion") local Palettes = require("src.world.gen2.Palettes") local PixelCanvas = require("src.render.PixelCanvas") +local TileAttrs = require("src.world.gen2.TileAttrs") local MapPreview = {} @@ -100,7 +102,12 @@ function MapPreview.bake(baker, map, daytime) local okCanvas, canvas = pcall(PixelCanvas.new, pw, ph, "nearest") if not okCanvas or not canvas then return nil end local quads = {} + local crystal = GameVersion.engine() == "crystal" local function quadFor(tile) + if crystal then + local attr = TileAttrs.forTile(tileset, tile) + return TileAttrs.quadFor(atlas, tile, attr, tilesPerRow, quads) + end local q = quads[tile] if q then return q end local sx = (tile % tilesPerRow) * 8 @@ -129,11 +136,18 @@ function MapPreview.bake(baker, map, daytime) if block then for i = 0, 15 do local tile = block[i + 1] or 0 - local tileSlot = tilePalettes and tilePalettes[tile + 1] or 1 + local tileSlot = crystal + and TileAttrs.paletteSlot(tileset, tile) + or (tilePalettes and tilePalettes[tile + 1] or 1) if not slot or tileSlot == slot then local tx = bx * 32 + (i % 4) * 8 local ty = by * 32 + math.floor(i / 4) * 8 - love.graphics.draw(atlas, quadFor(tile), tx, ty) + if crystal then + local attr = TileAttrs.forTile(tileset, tile) + TileAttrs.drawFlippedTile(atlas, quadFor(tile), tx, ty, attr) + else + love.graphics.draw(atlas, quadFor(tile), tx, ty) + end end end end diff --git a/src/world/gen2/Npc.lua b/src/world/gen2/Npc.lua index 7f7ec7e9..4c33c4e2 100644 --- a/src/world/gen2/Npc.lua +++ b/src/world/gen2/Npc.lua @@ -741,12 +741,12 @@ function NPC:drawBigAsym() end end -function NPC:draw(ox, oy, scale) +function NPC:draw(ox, oy, scale, oamRow) -- Gen 1 spells this draw(camX, camY) and SpriteRenderer subtracts them -- (src/world/NPC.lua:129). Two arguments means that call, not a missing -- scale: G.scale(nil, nil) would either raise or draw unscaled at an -- offset, which is the silent wrong answer. - if scale == nil then return self:draw(-(ox or 0), -(oy or 0), 1) end + if scale == nil then return self:draw(-(ox or 0), -(oy or 0), 1, oamRow) end local G = love.graphics G.push() G.translate(ox, oy) @@ -769,7 +769,7 @@ function NPC:draw(ox, oy, scale) local facing = (q == 1 or q == 3) and "up" or "down" self.sprite:draw( self.px, self.py + yOffset, 0, 0, - facing, 0, false, false, q == 3) + facing, 0, false, false, q == 3, oamRow) elseif self.rockSmash then -- engine/overworld/map_objects.asm:1462 if (self.rockSmash.frame % 2) == 0 then @@ -778,12 +778,12 @@ function NPC:draw(ox, oy, scale) end self.sprite:draw( self.px, self.py + yOffset, 0, 0, - self.facing, self:walkPhase(), self.stepFlip) + self.facing, self:walkPhase(), self.stepFlip, nil, nil, nil, oamRow) else self.sprite:draw( self.px, self.py + yOffset, 0, 0, self.facing, self:walkPhase(), self.stepFlip, - false, false, self:bounceFrame()) + false, false, self:bounceFrame(), oamRow) end G.pop() end diff --git a/src/world/gen2/OamFacings.lua b/src/world/gen2/OamFacings.lua new file mode 100644 index 00000000..f40f5bab --- /dev/null +++ b/src/world/gen2/OamFacings.lua @@ -0,0 +1,80 @@ +-- pret data/sprites/facings.asm: per-facing OAM tables for standard 16x16 walkers. +-- RELATIVE_ATTRIBUTES (bit 3) on an entry means InitSprite ORs hCurSpriteOAMFlags +-- into that tile's attribute byte — IN_GRASS sets OAM_PRIO there, so only those +-- rows sit behind BG palette shades 1-3. + +local OAM_XFLIP = 0x20 +local RELATIVE_ATTRIBUTES = 0x08 -- RELATIVE_ATTRIBUTES_F in map_object_constants.asm + +local OamFacings = {} + +OamFacings.OAM_XFLIP = OAM_XFLIP +OamFacings.RELATIVE_ATTRIBUTES = RELATIVE_ATTRIBUTES + +-- Each entry: { y, x, attr, tileIndex } relative to InitSprite anchor. +local function row(y, x, attr, tile) + return { y = y, x = x, attr = attr, tile = tile } +end + +local function facing(rows) + return rows +end + +-- FacingStepDown0 / FacingStepDown2 (standing down) +OamFacings.standingDown = facing({ + row(0, 0, 0, 0x00), + row(0, 8, 0, 0x01), + row(8, 0, RELATIVE_ATTRIBUTES, 0x02), + row(8, 8, RELATIVE_ATTRIBUTES, 0x03), +}) + +OamFacings.walkDown1 = facing({ + row(0, 0, 0, 0x80), + row(0, 8, 0, 0x81), + row(8, 0, RELATIVE_ATTRIBUTES, 0x82), + row(8, 8, RELATIVE_ATTRIBUTES, 0x83), +}) + +OamFacings.walkDown2 = facing({ + row(0, 8, OAM_XFLIP, 0x80), + row(0, 0, OAM_XFLIP, 0x81), + row(8, 8, RELATIVE_ATTRIBUTES + OAM_XFLIP, 0x82), + row(8, 0, RELATIVE_ATTRIBUTES + OAM_XFLIP, 0x83), +}) + +OamFacings.standingUp = facing({ + row(0, 0, 0, 0x04), + row(0, 8, 0, 0x05), + row(8, 0, RELATIVE_ATTRIBUTES, 0x06), + row(8, 8, RELATIVE_ATTRIBUTES, 0x07), +}) + +OamFacings.standingLeft = facing({ + row(0, 0, 0, 0x08), + row(0, 8, 0, 0x09), + row(8, 0, RELATIVE_ATTRIBUTES, 0x0a), + row(8, 8, RELATIVE_ATTRIBUTES, 0x0b), +}) + +OamFacings.standingRight = facing({ + row(0, 8, OAM_XFLIP, 0x08), + row(0, 0, OAM_XFLIP, 0x09), + row(8, 8, RELATIVE_ATTRIBUTES + OAM_XFLIP, 0x0a), + row(8, 0, RELATIVE_ATTRIBUTES + OAM_XFLIP, 0x0b), +}) + +function OamFacings.relativeAttrRows(facingTable) + local out = {} + for _, entry in ipairs(facingTable or {}) do + if bit.band(entry.attr, RELATIVE_ATTRIBUTES) ~= 0 then + out[#out + 1] = entry + end + end + return out +end + +function OamFacings.bottomRowY() + return 8 +end + +return OamFacings diff --git a/src/world/gen2/OamFootprint.lua b/src/world/gen2/OamFootprint.lua new file mode 100644 index 00000000..a442e65e --- /dev/null +++ b/src/world/gen2/OamFootprint.lua @@ -0,0 +1,29 @@ +-- Map-pixel coverage for standard 16x16 overworld walkers, derived from pret +-- pokecrystal data/sprites/facings.asm and map_objects.asm InitSprite. +-- +-- FacingStep* tables place RELATIVE_ATTRIBUTES on the bottom OAM row (y = 8). +-- InitSprite writes each OBJ at object Y + OAM_Y_OFS - 4, so that row covers +-- map pixels py+4 .. py+12. OBJECT_SPRITE_Y_OFFSET moves the whole sprite +-- without moving the object off its tile. + +local OamFootprint = {} + +function OamFootprint.spriteYOffset(entity) + return entity and (entity.spriteYOffset or 0) or 0 +end + +-- Bottom OAM row when IN_GRASS sets OAM_PRIO (facings.asm RELATIVE_ATTRIBUTES). +function OamFootprint.feetStrip(entity) + local px = entity.px or 0 + local py = (entity.py or 0) + OamFootprint.spriteYOffset(entity) + return px, py + 4, px + 16, py + 12 +end + +-- Full OBJ footprint for wAttrmap B_BG_PRIO (bit 7) overdraw. +function OamFootprint.spriteBBox(entity) + local px = entity.px or 0 + local py = (entity.py or 0) + OamFootprint.spriteYOffset(entity) + return px, py, px + 16, py + 24 +end + +return OamFootprint diff --git a/src/world/gen2/Player.lua b/src/world/gen2/Player.lua index 08e38a3d..3a068961 100644 --- a/src/world/gen2/Player.lua +++ b/src/world/gen2/Player.lua @@ -284,7 +284,7 @@ function Player:drawFishing(yOffset) self.fishQuads.rod[oam.tile]) end -function Player:draw(ox, oy, scale) +function Player:draw(ox, oy, scale, oamRow) local G = love.graphics -- OBJECT_SPRITE_Y_OFFSET: added to the OBJ's y as it is written to OAM, so -- it moves the sprite without moving the player off the tile they are @@ -307,7 +307,8 @@ function Player:draw(ox, oy, scale) phase = 0 end self.sprite:draw( - self.px, self.py + yOffset, 0, 0, facing, phase, flip) + self.px, self.py + yOffset, 0, 0, facing, phase, flip, + nil, nil, nil, oamRow) end G.pop() return diff --git a/src/world/gen2/TileAttrs.lua b/src/world/gen2/TileAttrs.lua new file mode 100644 index 00000000..80944e0c --- /dev/null +++ b/src/world/gen2/TileAttrs.lua @@ -0,0 +1,94 @@ +-- Per-tile GBC attribute lookup for Crystal tilesets (palette, VRAM bank, +-- flips, BG priority). Gold and Silver keep tilePalettes only; missing +-- tileAttrs entries fall back to palette slot 1 with no bank or flags. + +local TileAttrs = {} + +local DEFAULT = { + palette = 1, + vramBank = 0, + priority = false, + xFlip = false, + yFlip = false, +} + +function TileAttrs.forTile(tileset, tileId) + local attrs = tileset and tileset.tileAttrs + if attrs then + local a = attrs[tileId + 1] + if a then return a end + -- Metatile bytes are often 0-95 with bank 1 in the attr nybble; pret stores + -- those attrs at $80+ on the PalMap (RomExtractorGen2.readCrystalPalMap). + if tileId < 0x80 then + a = attrs[0x80 + tileId + 1] + if a and a.vramBank == 1 then return a end + end + end + local bankFromId = (tileId >= 0x80 and tileId < 0xe0) and 1 or 0 + local normId = tileId % 0x80 + local slot = tileset and tileset.tilePalettes + and (tileset.tilePalettes[tileId + 1] + or (bankFromId == 1 and tileset.tilePalettes[0x80 + normId + 1]) + or tileset.tilePalettes[normId + 1]) or 1 + return { + palette = slot, + vramBank = bankFromId, + priority = false, + xFlip = false, + yFlip = false, + } +end + +function TileAttrs.paletteSlot(tileset, tileId) + return TileAttrs.forTile(tileset, tileId).palette +end + +-- VRAM tile index in the baked 256-tile sheet (Crystal bank 0 at 0-127, +-- bank 1 at 128-255 per crystalTilesetSheet). Metatile bytes are often +-- 0-95 with B_BG_BANK1 in the attr nibble; hardware fetches bank 1 VRAM. +function TileAttrs.sheetTileId(tileId, attr) + if tileId >= 0x80 then return tileId end + if attr and attr.vramBank == 1 then return 0x80 + tileId end + return tileId +end + +function TileAttrs.quadFor(atlas, tileId, attr, tilesPerRow, cache) + local sheetId = TileAttrs.sheetTileId(tileId, attr) + tilesPerRow = tilesPerRow or 16 + cache = cache or {} + local q = cache[sheetId] + if q then return q end + local aw, ah = atlas:getDimensions() + q = love.graphics.newQuad( + (sheetId % tilesPerRow) * 8, + math.floor(sheetId / tilesPerRow) * 8, + 8, 8, aw, ah) + cache[sheetId] = q + return q +end + +local function quadSize(quad) + if quad and quad.w and quad.h then return quad.w, quad.h end + if quad and quad.getViewport then + local ok, _, _, w, h = pcall(quad.getViewport, quad) + if ok and w then return w, h end + end + return 8, 8 +end + +-- Draw an 8x8 tile (or sub-rect quad) at map/screen pixel (tx, ty). +-- Flips scale around the sub-rect centre so the tile stays in its cell. +function TileAttrs.drawFlippedTile(atlas, quad, tx, ty, attr, sx, sy) + sx = sx or 1 + sy = sy or 1 + if not attr or (not attr.xFlip and not attr.yFlip) then + love.graphics.draw(atlas, quad, tx, ty, 0, sx, sy) + return + end + local qw, qh = quadSize(quad) + local ox, oy = qw / 2, qh / 2 + love.graphics.draw(atlas, quad, tx + ox, ty + oy, 0, + (attr.xFlip and -1 or 1) * sx, (attr.yFlip and -1 or 1) * sy, ox, oy) +end + +return TileAttrs diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index 0b94d299..64226dc6 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -60,6 +60,9 @@ local Screens = require("src.ui.Screens") local Sound = require("src.core.Sound") local SpriteRenderer = require("src.render.SpriteRenderer") local StepEvents = require("src.world.gen2.StepEvents") +local TileAttrs = require("src.world.gen2.TileAttrs") +local OamFootprint = require("src.world.gen2.OamFootprint") +local MapAttrGrid = require("src.world.gen2.MapAttrGrid") local Tilt = require("src.render.Tilt") local Strings = require("src.core.Strings") local TextBox = require("src.render.TextBox") @@ -1771,6 +1774,14 @@ function World:gsVersion() return version == "silver" and 1 or 0 end +function World:isCrystal() + local GameVersion = require("src.core.GameVersion") + -- Rendering follows the loaded ROM column (BorderFill / MapPreview do the + -- same), not save.version — a Crystal save under the Gold column still bakes + -- and draws with Gold's single-bank tilesets. + return GameVersion.engine() == "crystal" +end + -- ENGINE_* flags (data/events/engine_flags.asm), the namespace `setflag` / -- `clearflag` / `checkflag` write. Kept on the save under its own key rather -- than merged into `events`, because the two tables index different arrays on @@ -5771,6 +5782,7 @@ function World:refreshMapImages() if not self.mapImage then return false end self:dropMapImages(self.map and self.map.id) self.mapImage = self:imageFor(self.map.id) + self:rebuildAttrGrid() self:rebuildNeighbors() return true end @@ -8546,7 +8558,12 @@ function World:bakeMapImage(map, daytime, flicker) -- (#208, see src/render/PixelCanvas.lua). local canvas = PixelCanvas.new(pw, ph, "nearest") local quads = {} + local crystal = self:isCrystal() local function quadFor(tile) + if crystal then + local attr = TileAttrs.forTile(tileset, tile) + return TileAttrs.quadFor(atlas, tile, attr, tilesPerRow, quads) + end local q = quads[tile] if q then return q end local sx = (tile % tilesPerRow) * 8 @@ -8593,13 +8610,18 @@ function World:bakeMapImage(map, daytime, flicker) if block then for i = 0, 15 do local tile = block[i + 1] or 0 - -- tilePalettes is 1-based over the 96 sheet tiles; anything past - -- the sheet (window/text tiles) has no entry and takes slot 1. - local tileSlot = tilePalettes and tilePalettes[tile + 1] or 1 + local tileSlot = crystal + and TileAttrs.paletteSlot(tileset, tile) + or (tilePalettes and tilePalettes[tile + 1] or 1) if not slot or tileSlot == slot then local tx = bx * 32 + (i % 4) * 8 local ty = by * 32 + math.floor(i / 4) * 8 - love.graphics.draw(atlas, quadFor(tile), tx, ty) + if crystal then + local attr = TileAttrs.forTile(tileset, tile) + TileAttrs.drawFlippedTile(atlas, quadFor(tile), tx, ty, attr) + else + love.graphics.draw(atlas, quadFor(tile), tx, ty) + end end end end @@ -8685,6 +8707,7 @@ function World:animCellsFor(map, tileset) if not wanted then return nil end local blocks = tileset.blocks local tilePalettes = tileset.tilePalettes + local crystal = self:isCrystal() local out = nil for by = 0, map.height - 1 do for bx = 0, map.width - 1 do @@ -8702,7 +8725,8 @@ function World:animCellsFor(map, tileset) list = { layer = layer, tile = tile, - slot = tilePalettes and tilePalettes[tile + 1] or 1, + slot = crystal and TileAttrs.paletteSlot(tileset, tile) + or (tilePalettes and tilePalettes[tile + 1] or 1), cells = {}, } out[tile] = list @@ -8804,12 +8828,217 @@ function World:bgTileAt(map, tileset, mx, my) return block[i + 1] end --- IN_GRASS puts OAM_PRIO on the sprite's lower 16x8 only: .InitSprite ORs it --- into hCurSpriteOAMFlags (engine/overworld/map_objects.asm:2850) and only the --- bottom two OAM entries of a walking facing carry RELATIVE_ATTRIBUTES --- (data/sprites/facings.asm:45-56). The strip starts at py+4 because a sprite --- draws 4 px above its cell (map_objects.asm:2876). -function World:drawGrassOver(entity, ox, oy, s) +function World:rebuildAttrGrid() + if not self:isCrystal() then + self.attrGrid = nil + return + end + if not self.map then + self.attrGrid = nil + return + end + local _, tileset = self:atlasFor(self.map.def) + if not tileset then + self.attrGrid = nil + return + end + self.attrGrid = MapAttrGrid.build(self.map, tileset) +end + +function World:bgTileAttrAt(map, tileset, mx, my) + local cell = self.attrGrid and MapAttrGrid.lookup(self.attrGrid, mx, my) + if cell then return cell end + local tile = self:bgTileAt(map, tileset, mx, my) + if not tile then return nil end + local tileId, attr = MapAttrGrid.normalizeTile(tile, tileset) + return { tileId = tileId, rawTileId = tile, attr = attr } +end + +-- LÖVE scissor is window/canvas space; Playfield.push translates draws but not +-- scissor rects. Map-local (ox, oy, s) feet/bbox regions must be lifted. +local function playfieldOrigin() + if Playfield.entered and Playfield.box then + return Playfield.box.x or 0, Playfield.box.y or 0 + end + return 0, 0 +end + +local function intersectScissor(x, y, w, h, prev) + if not prev then return x, y, w, h end + local px, py, pw, ph = prev[1], prev[2], prev[3], prev[4] + if px == nil then px, py, pw, ph = prev.x, prev.y, prev.width, prev.height end + if not (px and py and pw and ph) then return x, y, w, h end + local x2 = math.max(x, px) + local y2 = math.max(y, py) + local x3 = math.min(x + w, px + pw) + local y3 = math.min(y + h, py + ph) + local iw, ih = x3 - x2, y3 - y2 + if iw < 1 or ih < 1 then return nil end + return x2, y2, iw, ih +end + +function World:feetCompositeCanvas(w, h) + local G = love.graphics + if not (G and G.newCanvas) then return nil end + self._feetCanvases = self._feetCanvases or {} + local key = w .. "x" .. h + local canvas = self._feetCanvases[key] + if canvas and canvas:getWidth() == w and canvas:getHeight() == h then + return canvas + end + if canvas and canvas.release then canvas:release() end + local ok, made = pcall(G.newCanvas, w, h) + if not ok or not made then return nil end + made:setFilter("nearest", "nearest") + self._feetCanvases[key] = made + return made +end + +-- Blit BG tiles over a map-pixel region already in the current transform. +function World:blitBgOverRegionLocal(mapDef, originX, originY, rx0, ry0, rx1, ry1, + keyed, tileFilter, scale) + local atlas, tileset = self:atlasFor(mapDef) + if not (atlas and tileset) then return end + local G = love.graphics + local cacheKey = self:mapCacheKey(self.map.id) + local bgSet = self.bgSets[cacheKey] or nil + local animCells = self.animCells and self.animCells[cacheKey] or nil + local tilesPerRow = tileset.tilesPerRow or 16 + self.bgOverQuads = self.bgOverQuads or {} + local map = self.map + scale = scale or 1 + G.setColor(1, 1, 1, 1) + + for ty = math.floor(ry0 / 8) * 8, math.floor((ry1 - 1) / 8) * 8, 8 do + for tx = math.floor(rx0 / 8) * 8, math.floor((rx1 - 1) / 8) * 8, 8 do + local info = self:bgTileAttrAt(map, tileset, tx, ty) + if info and tileFilter(info) then + local attr = info.attr + local drawX = math.floor(originX + (tx - rx0) * scale) + local drawY = math.floor(originY + (ty - ry0) * scale) + local animList = self:animListAt(animCells, tx, ty) + + local function blitTile(img, quad, drawAttr) + TileAttrs.drawFlippedTile(img, quad, drawX, drawY, drawAttr, scale, scale) + end + + local paletteSlot = attr.palette + if animList and animList.slot then paletteSlot = animList.slot end + local set = bgSet and bgSet[paletteSlot] + + local function runBlit(img, quad) + local function body() blitTile(img, quad, attr) end + if set and GbcPalette.available() then + if keyed then GbcPalette.keyedWith(set, body) + else GbcPalette.with(set, body) end + else + body() + end + end + + if animList then + local layer = animList.layer + local sheet + if layer.kind == "scroll" then + sheet = self:scrollStrip(mapDef, tileset, animList.tile, layer.scroll) + else + sheet = self:animSheet(layer.sheet) + end + if sheet then + local row = self:animRow(layer) + local quad = self:animQuad( + layer.sheet or ("scroll|" .. animList.tile), row, layer.frames) + runBlit(sheet, quad) + else + local tile = info.tileId + local quad = TileAttrs.quadFor( + atlas, tile, attr, tilesPerRow, self.bgOverQuads) + runBlit(atlas, quad) + end + else + local tile = info.tileId + local quad = TileAttrs.quadFor( + atlas, tile, attr, tilesPerRow, self.bgOverQuads) + runBlit(atlas, quad) + end + end + end + end +end + +-- Whether (tx, ty) is repainted by this map's tileset anim program. +function World:animListAt(animCells, tx, ty) + if not animCells then return nil end + for _, list in pairs(animCells) do + local xy = list.cells + for i = 1, #xy, 2 do + if xy[i] == tx and xy[i + 1] == ty then return list end + end + end + return nil +end + +-- Shared BG-over-OBJ blit. Each intersecting 8x8 cell is drawn whole; the +-- hardware clips to the region via scissor (scanline compositing), not sub-quad +-- viewports. `keyed` selects the OBJ-behind-BG rule for IN_GRASS feet; +-- BG_PRIO tiles draw fully opaque. +function World:blitBgOverRegion(mapDef, ox, oy, s, rx0, ry0, rx1, ry1, keyed, tileFilter) + local G = love.graphics + local pfX, pfY = playfieldOrigin() + local scissorX = pfX + math.floor(ox + rx0 * s) + local scissorY = pfY + math.floor(oy + ry0 * s) + local scissorW = math.ceil((rx1 - rx0) * s) + local scissorH = math.ceil((ry1 - ry0) * s) + local prevScissor = G.getScissor and G.getScissor() + local clipX, clipY, clipW, clipH = + intersectScissor(scissorX, scissorY, scissorW, scissorH, prevScissor) + if G.setScissor and clipX then G.setScissor(clipX, clipY, clipW, clipH) end + + -- originX/Y is the screen position of map pixel (rx0, ry0): ox/oy are the + -- playfield-local offset of map (0,0), so a tile at (tx, ty) lands at + -- origin + (tx - rx0) * s — same convention as drawGrassOverGoldSilver's + -- ox + cx0 * s with absolute map coordinates. + self:blitBgOverRegionLocal(mapDef, + math.floor(ox + rx0 * s), math.floor(oy + ry0 * s), + rx0, ry0, rx1, ry1, keyed, tileFilter, s) + + if G.setScissor then + if prevScissor then G.setScissor(prevScissor) else G.setScissor() end + end +end + +-- IN_GRASS feet strip: bottom OAM + keyed grass on a 16x8 canvas, then one blit. +-- Avoids scissor/transform bugs under Playfield letterboxing (issue #2080). +function World:drawFeetComposite(entity, ox, oy, s, drawBottomOam) + local G = love.graphics + local x0, y0, x1, y1 = OamFootprint.feetStrip(entity) + local fw, fh = x1 - x0, y1 - y0 + local canvas = self:feetCompositeCanvas(fw, fh) + if not canvas then + drawBottomOam() + self:blitBgOverRegion(self.map.def, ox, oy, s, + x0, y0, x1, y1, true, function() return true end) + return + end + + local prev = G.getCanvas() + G.push("all") + G.origin() + G.setCanvas(canvas) + G.clear(0, 0, 0, 0) + G.translate(-x0, -y0) + drawBottomOam() + self:blitBgOverRegionLocal(self.map.def, 0, 0, x0, y0, x1, y1, + true, function() return true end, 1) + G.setCanvas(prev) + G.pop() + + G.setColor(1, 1, 1, 1) + G.draw(canvas, ox + x0 * s, oy + y0 * s, 0, s, s) +end + +-- Gold/Silver: keyed grass atlas + feet-strip sub-quad blit (no attrmap / OAM split). +function World:drawGrassOverGoldSilver(entity, ox, oy, s) local map = self.map if not (entity and entity.inGrass and map) then return end local atlas, tileset = self:grassAtlasFor(map.def) @@ -8819,7 +9048,6 @@ function World:drawGrassOver(entity, ox, oy, s) local tilePalettes = tileset.tilePalettes local tilesPerRow = tileset.tilesPerRow or 16 local aw, ah = atlas:getDimensions() - -- engine/overworld/map_objects.asm:2876, data/sprites/facings.asm:45-56 local rx, ry = entity.px, entity.py + 4 self.grassQuad = self.grassQuad or G.newQuad(0, 0, 8, 8, aw, ah) local quad = self.grassQuad @@ -8853,6 +9081,38 @@ function World:drawGrassOver(entity, ox, oy, s) end end +-- Crystal: attrmap tiles, full 8x8 cells, keyed BG-over-OAM in the feet strip. +function World:drawGrassOverCrystal(entity, ox, oy, s) + if not (entity and entity.inGrass and self.map) then return end + local x0, y0, x1, y1 = OamFootprint.feetStrip(entity) + self:blitBgOverRegion(self.map.def, ox, oy, s, + x0, y0, x1, y1, true, function() return true end) +end + +function World:drawGrassOver(entity, ox, oy, s) + if self:isCrystal() then + self:drawGrassOverCrystal(entity, ox, oy, s) + else + self:drawGrassOverGoldSilver(entity, ox, oy, s) + end +end + +-- wAttrmap BG_PRIO (attribute bit 7): BG draws over the full sprite footprint. +function World:drawBgPriorityOver(entity, ox, oy, s) + if not self:isCrystal() then return end + local x0, y0, x1, y1 = OamFootprint.spriteBBox(entity) + self:blitBgOverRegion(self.map.def, ox, oy, s, + x0, y0, x1, y1, false, function(info) return info.attr.priority end) +end + +function World:drawPriorityOver(entity, ox, oy, s) + if not self:isCrystal() then return end + self:drawBgPriorityOver(entity, ox, oy, s) + if entity.inGrass and not (entity.grassShake and entity.moving) then + self:drawGrassOver(entity, ox, oy, s) + end +end + -- ShakeGrass' object (engine/overworld/map_objects.asm:2031): FacingGrass1 is -- the tile at (0,+8) and (+8,+8) from the sprite's origin, FacingGrass2 at -- (-1,+9) and (+9,+9), and SetFacingGrassShake's `and 4` alternates them every @@ -9457,6 +9717,7 @@ function World:setMap(mapId, cx, cy, facing, opts) self.fade = nil self.shake = nil self.map = Map.new(def, tileset) + self:rebuildAttrGrid() -- A follow pairing points at two live objects, and a map load rebuilds them -- (RefreshMapSprites); nothing on the cart survives that either. self.followState = nil @@ -10808,6 +11069,30 @@ end -- takes a foot point in flat screen pixels and a draw callback, and slides the -- draw onto that point's projection: only the ground tilts, so a standing -- thing stays upright and unscaled and the one thing that moves is its anchor. +-- GoldSilverIntro order for one standing map object: optional jump shadow, +-- bottom OAM (when IN_GRASS), keyed grass feet, top OAM, then BG_PRIO + shake. +-- Exposed for World:drawPipeline mods so 3D passes reuse the same compositor. +function World:drawEntityComposite(entity, ox, oy, s, drawSpriteFn, withExtras) + if not self:isCrystal() then return end + local grassComposite = entity.inGrass + and not (entity.grassShake and entity.moving) + if grassComposite then + -- Pret / GoldSilverIntro: composite on the framebuffer so keyed grass shade + -- 0 reveals the bottom-OAM pixels already there. An offscreen feet canvas + -- left shade-0 holes transparent and showed baked ground through the legs + -- instead of grass over the feet (issue #2080). + drawSpriteFn("bottom", ox, oy, s) + self:drawGrassOver(entity, ox, oy, s) + drawSpriteFn("top", ox, oy, s) + else + drawSpriteFn(nil, ox, oy, s) + end + if withExtras then + self:drawBgPriorityOver(entity, ox, oy, s) + self:drawGrassShake(entity, ox, oy, s) + end +end + function World:drawPeople(s, billboard) local G = love.graphics local p = self.player @@ -10845,20 +11130,31 @@ function World:drawPeople(s, billboard) local function body() -- map_objects.asm:221-227 self:drawJumpShadow(entity, ox, oy, s) - if entry.kind == "player" then - self.player:draw(ox, oy, s) - else - entry.npc:draw(ox, oy, s) - end - -- ShakeGrass rustle only while moving; drawGrassOver when standing/in grass - -- so the BG tuft covers the feet. - -- Only the current map's own entities: a ghost's cells belong to a - -- neighbour's block list. - if entry.ox == 0 and entry.oy == 0 then - if entity.inGrass and not (entity.grassShake and entity.moving) then - self:drawGrassOver(entity, ox, oy, s) + local onMap = entry.ox == 0 and entry.oy == 0 + if self:isCrystal() then + local function drawSprite(oamRow, localOx, localOy, localS) + local lx = localOx or ox + local ly = localOy or oy + local ls = localS or s + if entry.kind == "player" then + self.player:draw(lx, ly, ls, oamRow) + else + entry.npc:draw(lx, ly, ls, oamRow) + end + end + self:drawEntityComposite(entity, ox, oy, s, drawSprite, onMap) + else + if entry.kind == "player" then + self.player:draw(ox, oy, s) + else + entry.npc:draw(ox, oy, s) + end + if onMap then + if entity.inGrass and not (entity.grassShake and entity.moving) then + self:drawGrassOver(entity, ox, oy, s) + end + self:drawGrassShake(entity, ox, oy, s) end - self:drawGrassShake(entity, ox, oy, s) end end if billboard then @@ -10947,6 +11243,10 @@ function World:drawPipeline(id, w, h, s) heal = function() self:drawHealAnim(1, nil) end, bird = function() self:drawFlyAnim(1, nil) end, }, + -- Crystal-only: IN_GRASS OAM split + attrmap BG_PRIO (not on Gold/Silver). + drawEntity = self:isCrystal() and function(entity, ox, oy, scale, drawSpriteFn, withExtras) + self:drawEntityComposite(entity, ox, oy, scale or s, drawSpriteFn, withExtras) + end or nil, } -- `project(wx, wy)` -> canvas pixels, nil behind the camera. s = 1 lays the -- closures out in world pixels off the flat foot, the unit Gen 1 uses. diff --git a/tests/crystal_world_test.lua b/tests/crystal_world_test.lua index d86d2226..ede2212e 100644 --- a/tests/crystal_world_test.lua +++ b/tests/crystal_world_test.lua @@ -149,6 +149,12 @@ eq(kanto and kanto.palMap and kanto.palMap.bank, 0x13, eq(kanto and kanto.palMap and kanto.palMap.address, 0x4075, "at $4075") check(johto and johto.tilePalettes and #johto.tilePalettes > 0, "and the map decoded to a per-tile palette list") +check(johto and johto.tileAttrs ~= nil, + "Crystal Johto carries per-tile GBC attrs") +if johto and johto.tileAttrs then + check(johto.tileAttrs[0x80 + 1] ~= nil, + "bank-1 tile $80 has attrs from the second PalMap section") +end -- ---- 5. the special table the cache pins diff --git a/tests/drivers/grass_over_bug2080_test.lua b/tests/drivers/grass_over_bug2080_test.lua index 8d73dea3..8f292817 100644 --- a/tests/drivers/grass_over_bug2080_test.lua +++ b/tests/drivers/grass_over_bug2080_test.lua @@ -50,8 +50,8 @@ return function(game) ok(p and p.inGrass == true, "player.inGrass is set standing in the grass") ok(p and not p.moving, "player is standing still") - local atlas = world:grassAtlasFor(world.map.def) - ok(atlas ~= nil, "the colour-0 keyed grass atlas exists") + local atlas = world:atlasFor(world.map.def) + ok(atlas ~= nil, "the map tileset atlas exists for feet overdraw") local records = {} local origDraw = love.graphics.draw @@ -75,11 +75,12 @@ return function(game) end local distinctYs = 0 for _ in pairs(ys) do distinctYs = distinctYs + 1 end - ok(maxH == 4, - "strip is tile-unaligned (py+4): every slice is 4 px tall, saw max " + ok(maxH == 8, + "each blit is a full 8x8 tile (not a sub-quad sliver), saw max h=" .. tostring(maxH)) ok(distinctYs >= 2, - "strip crosses both tile rows: " .. distinctYs .. " distinct slice rows") + "feet strip crosses two tile rows when py is not 8-aligned: " + .. distinctYs .. " distinct rows") U.shot(game, SHOT_DIR .. "/2080-grass-over.png") say("compare the shot against issue #2080's expected screenshot: grass " diff --git a/tests/drivers/grass_over_feet_strip_test.lua b/tests/drivers/grass_over_feet_strip_test.lua new file mode 100644 index 00000000..5c339f22 --- /dev/null +++ b/tests/drivers/grass_over_feet_strip_test.lua @@ -0,0 +1,106 @@ +-- Crystal IN_GRASS feet-strip regression (#2080): grass must not appear above +-- the RELATIVE_ATTRIBUTES row (map y = py+4 .. py+12). Samples the feet +-- canvas composite and counts keyed grass pixels outside the 8px strip. +-- +-- Run: POKEPORT_DRIVER=tests/drivers/grass_over_feet_strip_test.lua \ +-- POKEPORT_VERSION=crystal love . + +local U = require("tests.drivers.util") + +local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/pokeport-shots" + +return function(game) + local fails = 0 + + local function say(line) print("[2080-feet] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "PASS " or "FAIL ") .. line) + end + + U.wait(60) + local world = game.world + if not (world and world.map) then + say("FAIL the crystal world did not boot") + love.event.quit(1) + return + end + + world:warpToMapId("ROUTE_29", 10, 5, "down") + U.wait(20) + + local map = world.map + local gx, gy + for cy = 0, (map.height or 0) * 2 - 1 do + for cx = 0, (map.width or 0) * 2 - 1 do + if world:grassAt(cx, cy) and world:grassAt(cx + 1, cy) + and world:grassAt(cx, cy + 1) then + gx, gy = cx, cy + break + end + end + if gx then break end + end + ok(gx ~= nil, "found tall grass on ROUTE_29") + if not gx then + love.event.quit(1) + return + end + + world:warpToMapId("ROUTE_29", gx, gy, "down") + U.wait(20) + local p = world.player + ok(p and p.inGrass, "player.inGrass standing in grass") + + local OamFootprint = require("src.world.gen2.OamFootprint") + local x0, y0, x1, y1 = OamFootprint.feetStrip(p) + local fw, fh = x1 - x0, y1 - y0 + + local canvas = world:feetCompositeCanvas(fw, fh) + ok(canvas ~= nil, "feet composite canvas exists") + + if canvas and love.graphics.readbackTexture then + local G = love.graphics + G.push("all") + G.origin() + G.setCanvas(canvas) + G.clear(0, 0, 0, 0) + G.translate(-x0, -y0) + if p.sprite then + p.sprite:draw(p.px, p.py, 0, 0, p.facing, p:walkPhase(), p:drawFlip(), + nil, nil, nil, "bottom") + end + world:blitBgOverRegionLocal(world.map.def, 0, 0, x0, y0, x1, y1, + true, function() return true end, 1) + G.setCanvas() + G.pop() + + local data = G.readbackTexture(canvas) + if data then + local leakAbove = 0 + for y = 0, fh - 1 do + for x = 0, fw - 1 do + local _, _, _, a = data:getPixel(x, y) + if a > 0.05 and y < 0 then leakAbove = leakAbove + 1 end + end + end + ok(leakAbove == 0, "no grass pixels above feet canvas origin") + local opaque = 0 + for y = 0, fh - 1 do + for x = 0, fw - 1 do + local _, _, _, a = data:getPixel(x, y) + if a > 0.5 then opaque = opaque + 1 end + end + end + ok(opaque > 0, "feet canvas has visible grass/sprite pixels") + else + say("SKIP readbackTexture unavailable; screenshot only") + end + end + + ok(U.shot(game, SHOT_DIR .. "/2080-feet-strip.png"), + "screenshot captured for manual compare") + + say(fails == 0 and "ALL PASS" or (fails .. " FAILURES")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/gen2_crystal_tile_attrs_test.lua b/tests/gen2_crystal_tile_attrs_test.lua new file mode 100644 index 00000000..870d5069 --- /dev/null +++ b/tests/gen2_crystal_tile_attrs_test.lua @@ -0,0 +1,451 @@ +-- 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() diff --git a/tests/gen2_unown_words_test.lua b/tests/gen2_unown_words_test.lua index c58f4ae7..1d425e15 100644 --- a/tests/gen2_unown_words_test.lua +++ b/tests/gen2_unown_words_test.lua @@ -153,6 +153,8 @@ do 1, true) ~= nil, "and extractTilesets uses it") check(source:find("local CRYSTAL_PAL_MAP_BYTES = 112", 1, true) ~= nil, "the PalMap read covers the bank 1 rows too") + check(source:find("function RomExtractorGen2:readCrystalPalMap", 1, true) + ~= nil, "Crystal PalMap skips $ff padding and maps bank 1 to $80+") check(source:find("out.unownWalls = walls", 1, true) ~= nil, "and readEventTables emits the wall words") end @@ -226,7 +228,16 @@ else eq(roa.imageWidth, 128, "the sheet is 16 tiles across") eq(roa.imageHeight, 128, "and 16 down: 256 tile ids, both VRAM banks") eq(roa.tilesPerRow, 16, "so a tile id indexes it directly") - eq(#roa.tilePalettes, 224, "the PalMap covers every id the blocks use") + check(roa.tileAttrs ~= nil, "Crystal tilesets carry tileAttrs") + local bank1 = roa.tileAttrs and roa.tileAttrs[0x80 + 1] + check(bank1 ~= nil, "bank-1 tile $80 has attrs after re-import") + if bank1 then + eq(bank1.vramBank, 1, "bank-1 tile $80 maps to VRAM bank 1") + end + check(roa.tilePalettes[0x80 + 1] ~= nil, + "bank-1 palette slot is not shifted by $ff padding") + eq(roa.tilePalettes[97], nil, + "padding bytes do not assign palettes to tile 96") local png = readFile(cache .. "/assets/generated/tilesets/ruins_of_alph.png") if not png then check(false, "the sheet PNG is actually in the cache") diff --git a/tests/run_tests.lua b/tests/run_tests.lua index ba64a1fd..420a5780 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -3745,6 +3745,7 @@ runSuites(orderedGlob( "tests/gen2_crystal_anim_test.lua", "tests/gen2_crystal_caught_data_test.lua", "tests/gen2_crystal_gender_test.lua", + "tests/gen2_crystal_tile_attrs_test.lua", -- Pinned in the order the glob already ran them in, alphabetically last. "tests/gen2_battle_cursor_test.lua", "tests/gen2_battle_options_test.lua",