Merge pull request #2128 from 1Jamie/crystal-toes

This commit is contained in:
bryanthaboi
2026-09-02 17:23:31 -04:00
committed by GitHub
18 changed files with 1411 additions and 52 deletions
+91 -2
View File
@@ -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
+63
View File
@@ -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
+23 -5
View File
@@ -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)
+18 -5
View File
@@ -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
+82
View File
@@ -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
+16 -2
View File
@@ -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
+5 -5
View File
@@ -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
+80
View File
@@ -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
+29
View File
@@ -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
+3 -2
View File
@@ -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
+94
View File
@@ -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
+325 -25
View File
@@ -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.
+6
View File
@@ -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
+6 -5
View File
@@ -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 "
@@ -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
+451
View File
@@ -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()
+12 -1
View File
@@ -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")
+1
View File
@@ -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",