mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-27 14:02:01 -04:00
Merge pull request #2128 from 1Jamie/crystal-toes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user