From e2114f7c85795d52903ea98deab493a4f181bace Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Wed, 16 Sep 2026 16:42:59 -0400 Subject: [PATCH] importers / lttp example --- .gitignore | 4 + CONTRIBUTING-mods.md | 81 ++++++ docs/new-features.md | 1 + mods/example_lttp_overhaul/main.lua | 49 ++++ mods/example_lttp_overhaul/manifest.json | 20 ++ mods/example_lttp_overhaul/town.lua | 170 ++++++++++++ src/import/Importers.lua | 314 ++++++++++++++++++++++ src/import/LauncherView.lua | 205 +++++++++++++-- src/import/RomImporter.lua | 153 ++++++++++- src/import/lttp/LttpImport.lua | 315 +++++++++++++++++++++++ src/import/lttp/Lz2.lua | 87 +++++++ src/import/lttp/Overworld.lua | 88 +++++++ src/import/lttp/SnesGfx.lua | 119 +++++++++ src/mods/AssetPacks.lua | 126 +++++++++ src/mods/Loader.lua | 14 + src/mods/Manifest.lua | 43 ++++ src/mods/Schemas.lua | 21 ++ src/render/SpriteRenderer.lua | 108 +++++++- tests/engine/crystal_version_test.lua | 4 +- tests/engine/importers_asset_packs.lua | 195 ++++++++++++++ tests/engine/launcher_sync_modal.lua | 12 +- tests/engine/lttp_codec.lua | 103 ++++++++ tests/engine/sprite_multicell.lua | 92 +++++++ 23 files changed, 2293 insertions(+), 31 deletions(-) create mode 100644 mods/example_lttp_overhaul/main.lua create mode 100644 mods/example_lttp_overhaul/manifest.json create mode 100644 mods/example_lttp_overhaul/town.lua create mode 100644 src/import/Importers.lua create mode 100644 src/import/lttp/LttpImport.lua create mode 100644 src/import/lttp/Lz2.lua create mode 100644 src/import/lttp/Overworld.lua create mode 100644 src/import/lttp/SnesGfx.lua create mode 100644 src/mods/AssetPacks.lua create mode 100644 tests/engine/importers_asset_packs.lua create mode 100644 tests/engine/lttp_codec.lua create mode 100644 tests/engine/sprite_multicell.lua diff --git a/.gitignore b/.gitignore index 5053e814..fcf547cb 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,10 @@ data/generated/ assets/generated/ +# Decoded from a user-provided dump of another game they own (IMPORTERS tab). +# Never committed and never redistributed; re-import from the launcher. +asset_packs/ + # LÖVE packages & archives *.love *.zip diff --git a/CONTRIBUTING-mods.md b/CONTRIBUTING-mods.md index 2d59d5e4..30ee3a52 100644 --- a/CONTRIBUTING-mods.md +++ b/CONTRIBUTING-mods.md @@ -254,6 +254,87 @@ with a `MK4xx` finding per site and an `unresolved:` note, with a file and a line, for every reach a static scan could not follow. Neither substitutes for a real Gold boot. +### 4b. Imported asset packs (`required_assets`) + +`required_imports` is a file *you* ask the player for and parse yourself. An +**asset pack** is the other direction: the launcher's IMPORTERS tab runs an +engine-owned importer over a dump the player supplies, and that importer +exports a named pack of finished assets that any mod may consume. One dump, +imported once, shared by every mod that declares it. + +The importers and the packs they export are the registry in +`src/import/Importers.lua`. A pack has a `kind`, and the kind fixes what the +files in it may be: + +| Kind | Files | Typical contents | +| --- | --- | --- | +| `sprite` | `.png` | sprite sheets | +| `tileset` | `.png` | tile sheets | +| `palette` | `.png`, `.lua` | palettes | +| `font` | `.png` | glyph sheets | +| `sfx` | `.wav`, `.ogg` | one file per effect | +| `music` | `.ogg`, `.wav` | one file per track | +| `sample` | `.wav`, `.ogg` | instrument samples | + +Packs are installation-wide, not per playthrough. They live at +`asset_packs///`, next to a `pack.lua` the importer writes: + +```lua +return { + format = 1, + importer = "lttp", + pack = "sprites", + kind = "sprite", + version = "1.2.0", + source = { name = "A Link to the Past (USA)", md5 = "...", size = 1048576 }, + entries = { + ["link/walk_down"] = { file = "link/walk_down.png", size = 2048, + width = 16, height = 24, frames = 4 }, + }, +} +``` + +`version` is the *export* version: bump it when the importer changes what it +writes, so a mod can pin what it was built against. `source` records the dump +the pack came from, which is how a pack built from a different regional dump +is told apart from yours. + +Declare what you need in the manifest: + +```json +"required_assets": [ + { "importer": "lttp", "pack": "sprites", "version": ">=1.2.0" } +], +"optional_assets": [ + { "importer": "lttp", "pack": "music" } +] +``` + +A `required_assets` entry that is not installed, or that is installed at a +version outside your range, fails the mod at load with a reason that points +the player at the IMPORTERS tab. Your entry file never runs half-configured. +An `optional_assets` entry is yours to feature-detect. + +Read them through `mod.packs`, which can only address packs you declared: + +```lua +local info = mod.packs:info("lttp", "sprites") -- kind, version, count +for _, entry in ipairs(mod.packs:entries("lttp", "sprites")) do + print(entry.id, entry.width, entry.height, entry.frames) +end +local png = mod.packs:read("lttp", "sprites", "link/walk_down") +``` + +`:read` returns bytes, capped at 8 MiB per entry, and refuses an entry whose +size does not match what the pack manifest recorded. `mod.packs:list()` answers +for every pack you declared, installed or not, so a settings screen can say +what is missing. No host path crosses out, and an undeclared importer or pack +is refused the same way an undeclared import id is. + +Legal posture is unchanged and non-negotiable: no pack, and nothing derived +from one, is ever redistributed. An importer reads a dump the player already +owns, on their machine, into their own save directory. + ### 5. What a mod's code can reach Your code runs in a sandbox (`src/mods/Sandbox.lua`), not against the diff --git a/docs/new-features.md b/docs/new-features.md index cc051b56..45211009 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -38,3 +38,4 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow * **Battle screen options** on Gold, Silver and Crystal: BATTLE SIZE (fixed or window-filling) and BATTLE BG (white, black or the dimmed map as the surround) * **Widescreen battle layout** on Gold, Silver and Crystal: BATTLE LAYOUT -> WIDE spreads the HUDs and bottom windows across a 304-wide screen * **Extended battle HUD** on Gold, Silver and Crystal: BATTLE HUD -> EXTENDED docks the WIDE layout's foe panel to the top of the window and the text area and player panel to the bottom +* **Importers**, a launcher tab that reads a dump of another game you own and exports its sprites, tiles and sound as versioned asset packs mods can require diff --git a/mods/example_lttp_overhaul/main.lua b/mods/example_lttp_overhaul/main.lua new file mode 100644 index 00000000..ea776483 --- /dev/null +++ b/mods/example_lttp_overhaul/main.lua @@ -0,0 +1,49 @@ +local mod = ... +local build = assert(load(assert(mod:read("town.lua")), "@lttp/town.lua"))() +local player = assert(mod.packs:entry("lttp", "sprites", "player")) +local town = assert(mod.packs:entry("lttp", "tiles", "kakariko")) +local base = assert(mod.content.tilesets:get("OVERWORLD")) +local map = assert(mod.content.maps:get("PALLET_TOWN")) + +-- Bank00.asm $9396/$95F4 DMA tables; player_oam.asm $85FB, $8000. +local function frame(head, body, flip, headY, headX) + local out = {} + local function part(tile, dx, dy) + for y = 0, 1 do + for x = 0, 1 do + out[#out + 1] = { tile = tile + y * 16 + x, + dx = dx + (flip and 1 - x or x) * 8, dy = dy + y * 8, + flipX = flip or false } + end + end + end + part(body, 0, 8) + part(head, headX or 0, headY or 0) + return out +end + +local tileset, mapBlocks, border = build(mod, base, map, + "asset_packs/lttp/tiles/" .. town.file) +mod.content.tilesets:register("LTTP_PALLET", tileset) +mod.content.maps:patch("PALLET_TOWN", { + tileset = "LTTP_PALLET", blocks = mapBlocks, borderBlock = border, + outdoor = true, +}) +local waterTilesets = mod.content.field:get("waterTilesets") or {} +waterTilesets[#waterTilesets + 1] = "LTTP_PALLET" +mod.content.field:patch("waterTilesets", waterTilesets) +mod.content.sprites:patch("SPRITE_RED", { + image = "asset_packs/lttp/sprites/" .. player.file, + frames = 6, walker = true, frameWidth = 16, frameHeight = 24, + frameColumns = 1, frameOffset = 0, + anchorX = 8, anchorY = 24, + cellWidth = 8, cellHeight = 8, cellColumns = 16, + cells = { + frame(2, 38), frame(4, 66), frame(0, 32, true, 0, 1), + frame(2, 42, false, -1), frame(4, 64, false, -1), + frame(0, 34, true, -1, 1), + }, + trueColor = true, +}) +mod.exports.binding = { player = "player", town = "kakariko" } +mod.log:info("Link assembled from head/body cells; Pallet retains all three entrances") diff --git a/mods/example_lttp_overhaul/manifest.json b/mods/example_lttp_overhaul/manifest.json new file mode 100644 index 00000000..2d4f719b --- /dev/null +++ b/mods/example_lttp_overhaul/manifest.json @@ -0,0 +1,20 @@ +{ + "id": "example_lttp_overhaul", + "name": "Link to the Past Import Example", + "version": "1.1.0", + "api": 2, + "entry": "main.lua", + "profile": "content", + "category": "GRAPHICS", + "game_version": ">=0.0.0-0 <2.0.0", + "priority": 100, + "games": ["red", "blue", "yellow"], + "required_assets": [ + { "importer": "lttp", "pack": "sprites", "version": ">=1.1.0" }, + { "importer": "lttp", "pack": "tiles", "version": ">=1.1.0" } + ], + "optional_assets": [ + { "importer": "lttp", "pack": "palettes" } + ], + "description": "Draws the player and Pallet Town from asset packs you imported off your own A Link to the Past cartridge dump. Ships no art of its own: every pixel is read at runtime from your own import, and the mod refuses to load without it." +} diff --git a/mods/example_lttp_overhaul/town.lua b/mods/example_lttp_overhaul/town.lua new file mode 100644 index 00000000..7b36232e --- /dev/null +++ b/mods/example_lttp_overhaul/town.lua @@ -0,0 +1,170 @@ +-- Bank02.asm Map32ToMap16; Kakariko $18/$19/$20/$21; Bank0F.asm Map16ToMap8. +local HOUSE = { + { 0x3F7, 0x3F8, 0x3F9, 0x3F9, 0x3FA, 0x3FB }, + { 0x3FC, 0x3FD, 0x3FE, 0x3FE, 0x3FF, 0x400 }, + { 0x402, 0x403, 0x404, 0x404, 0x405, 0x406 }, + { 0x40A, 0x40B, 0x40C, 0x40D, 0x40B, 0x40E }, + { 0x0F9, 0x0FA, 0x0FB, 0x0FC, 0x0FD, 0x0FE }, +} +local LAB = { + { 0x5AC, 0x5AD, 0x5AE, 0x5AE, 0x5AF, 0x5B0 }, + { 0x5B1, 0x5B6, 0x5B3, 0x5B3, 0x5B7, 0x5B5 }, + { 0x5B8, 0x5B9, 0x5BA, 0x5BA, 0x5BB, 0x5BC }, + { 0x5BD, 0x5BE, 0x5BF, 0x5C0, 0x5BE, 0x5C1 }, + HOUSE[5], +} +local TREE = { + { 0x0AE, 0x0AF, 0x07E, 0x07F }, + { 0x0B0, 0x014, 0x015, 0x0A8 }, + { 0x089, 0x01C, 0x01D, 0x076 }, + { 0x0F1, 0x04E, 0x04F, 0x0D9 }, + { 0x09A, 0x09B, 0x09C, 0x095 }, +} + +return function(mod, base, map, imagePath) + local source = love.image.newImageData(imagePath) + local function tile(image, id, x, y) + image:paste(source, x, y, id % 64 * 16, math.floor(id / 64) * 16, 16, 16) + end + local function mosaic(rows) + local image = love.image.newImageData(#rows[1] * 16, #rows * 16) + for y, row in ipairs(rows) do + for x, id in ipairs(row) do tile(image, id, (x - 1) * 16, (y - 1) * 16) end + end + return image + end + local function scale(target, image, x, y, w, h, sx, sy, sw, sh) + sx, sy = sx or 0, sy or 0 + sw, sh = sw or image:getWidth(), sh or image:getHeight() + for py = 0, h - 1 do + for px = 0, w - 1 do + target:setPixel(x + px, y + py, image:getPixel( + sx + math.floor((px + 0.5) * sw / w), + sy + math.floor((py + 0.5) * sh / h))) + end + end + end + local width, height = map.width * 32, map.height * 32 + local canvas = love.image.newImageData(width, height + 32) + local tree = mosaic(TREE) + local Map = require("src.world.Map") + local function water(x, y) + if x < 0 or y < 0 or x >= width / 16 or y >= height / 16 then return true end + local id = Map.defCellTile(map, base, x, y) + return id == 0x14 or id == 0x32 + end + local function shoreline(x, y) + local sx, sy = 0x2DB % 64 * 16, math.floor(0x2DB / 64) * 16 + for n = 0, 15 do + for depth = 0, 3 do + local r, g, b, a = source:getPixel(sx + n, sy + 12 + depth) + if not water(x - 1, y) then canvas:setPixel(x * 16 + 3 - depth, y * 16 + n, r, g, b, a) end + if not water(x + 1, y) then canvas:setPixel(x * 16 + 12 + depth, y * 16 + n, r, g, b, a) end + if not water(x, y - 1) then canvas:setPixel(x * 16 + n, y * 16 + 3 - depth, r, g, b, a) end + if not water(x, y + 1) then canvas:setPixel(x * 16 + n, y * 16 + 12 + depth, r, g, b, a) end + end + end + end + for y = 0, height / 16 - 1 do + for x = 0, width / 16 - 1 do + local original = Map.defCellTile(map, base, x, y) + tile(canvas, (x + y) % 7 == 0 and 0x10E or 0x034, x * 16, y * 16) + if original == 0x3A then + scale(canvas, tree, x * 16, y * 16, 16, 16) + elseif original == 0x14 or original == 0x32 then + local ripple = (x + y * 3) % 5 + tile(canvas, ripple == 0 and 0x2D8 or ripple == 2 and 0x2D9 or 0x2CD, + x * 16, y * 16) + shoreline(x, y) + elseif original == 0x55 then + tile(canvas, 0x334, x * 16, y * 16) + elseif original == 0x56 then + tile(canvas, 0x413, x * 16, y * 16) + elseif original == 0x52 then + tile(canvas, 0x3F6, x * 16, y * 16) + elseif (y == 6 and x >= 3 and x <= 15) + or (x == 9 and y >= 2 and y <= 16) + or (y == 12 and x >= 9 and x <= 15) then + tile(canvas, 0x3D2, x * 16, y * 16) + elseif original == 0x2C and x > 1 and x < 18 and y > 1 then + tile(canvas, 0x3C6, x * 16, y * 16) + end + end + end + local function building(rows, x, y, w, h, doorX) + local image = mosaic(rows) + local door = love.image.newImageData(32, 32) + door:paste(image, 0, 0, 32, 48, 32, 32) + tile(image, rows[4][2], 32, 48) + tile(image, rows[4][2], 48, 48) + tile(image, rows[5][2], 32, 64) + tile(image, rows[5][5], 48, 64) + scale(canvas, image, x, y, w, h - 16, 0, 0, 96, 64) + scale(canvas, image, x, y + h - 16, w, 16, 0, 64, 96, 16) + scale(canvas, door, doorX, y + h - 24, 16, 24) + local r, g, b = source:getPixel(0x0FB % 64 * 16 + 14, + math.floor(0x0FB / 64) * 16 + 7) + for py = y + h - 17, y + h - 2 do + for px = doorX + 4, doorX + 11 do canvas:setPixel(px, py, r * 0.15, g * 0.15, b * 0.15, 1) end + end + image:release() + door:release() + end + building(HOUSE, 64, 48, 64, 48, 80) + building(HOUSE, 192, 48, 64, 48, 208) + building(LAB, 160, 128, 96, 64, 192) + for y = 0, 1 do + for x = 0, 1 do scale(canvas, tree, x * 16, height + y * 16, 16, 16) end + end + local atlas = love.image.newImageData(256, math.ceil((width * height / 64 + 16) / 32) * 8) + local blocks, mapBlocks, variants, count = {}, {}, {}, 0 + local sets = {} + for _, name in ipairs({ "walkable", "doorTiles", "warpTiles", "counterTiles", "waterTiles", "shoreTiles" }) do + sets[name] = {} + local fallback = name == "waterTiles" and { 0x14 } + or name == "shoreTiles" and { 0x32, 0x48 } or {} + for _, id in ipairs(base[name] or fallback) do sets[name][id] = true end + end + local result = { image = "mod_cache/" .. mod.id .. "/pallet.png", + imageWidth = atlas:getWidth(), imageHeight = atlas:getHeight(), + tilesPerRow = 32, blocks = blocks, trueColor = true, + walkable = {}, doorTiles = {}, warpTiles = {}, counterTiles = {}, waterTiles = {}, shoreTiles = {} } + local piece = love.image.newImageData(8, 8) + local function addBlock(original, bx, by) + local row = {} + for i, old in ipairs(original) do + local tx, ty = (i - 1) % 4, math.floor((i - 1) / 4) + piece:paste(canvas, 0, 0, bx * 32 + tx * 8, by * 32 + ty * 8, 8, 8) + if old == base.grassTile then + piece:paste(source, 0, 0, 0x3F6 % 64 * 16, math.floor(0x3F6 / 64) * 16, 8, 8) + end + local key = old .. ":" .. piece:getString() + local id = variants[key] + if id == nil then + id = count + count = count + 1 + variants[key] = id + atlas:paste(piece, id % 32 * 8, math.floor(id / 32) * 8, 0, 0, 8, 8) + for name, set in pairs(sets) do + if set[old] then result[name][#result[name] + 1] = id end + end + if old == base.grassTile then result.grassTile = id end + end + row[i] = id + end + blocks[#blocks + 1] = row + return #blocks - 1 + end + for i, id in ipairs(map.blocks) do + mapBlocks[i] = addBlock(assert(base.blocks[id + 1]), (i - 1) % map.width, + math.floor((i - 1) / map.width)) + end + local border = addBlock(assert(base.blocks[(map.borderBlock or 0) + 1]), 0, map.height) + assert(mod.cache:write("pallet.png", atlas:encode("png"):getString())) + source:release() + tree:release() + canvas:release() + atlas:release() + piece:release() + return result, mapBlocks, border +end diff --git a/src/import/Importers.lua b/src/import/Importers.lua new file mode 100644 index 00000000..9c3a77f8 --- /dev/null +++ b/src/import/Importers.lua @@ -0,0 +1,314 @@ +local SafePath = require("src.mods.SafePath") +local Semver = require("src.mods.Semver") + +local Importers = {} + +Importers.ROOT = "asset_packs" +Importers.PACK_FILE = "pack.lua" +Importers.PACK_FORMAT = 1 +Importers.MAX_ENTRY_BYTES = 8 * 1024 * 1024 + +Importers.KINDS = { + sprite = { ext = { png = true } }, + tileset = { ext = { png = true } }, + palette = { ext = { png = true, lua = true } }, + font = { ext = { png = true } }, + sfx = { ext = { wav = true, ogg = true } }, + music = { ext = { ogg = true, wav = true } }, + sample = { ext = { wav = true, ogg = true } }, +} + +Importers.STATUS = { planned = true, beta = true, ready = true } + +local ID_PATTERN = "^[%l%d_%-]+$" +local ENTRY_PATTERN = "^[%l%d_%-]+[%l%d_%-/]*$" + +local LIST = { + { + id = "lttp", + name = "A Link to the Past", + status = "beta", + summary = "Sprites, tiles and sound out of your own Link to the Past cartridge dump.", + source = { + name = "A Link to the Past (SNES) cartridge dump", + formats = { "sfc", "smc" }, + sizes = { 1048576, 2097152 }, + }, + packs = { + { id = "sprites", kind = "sprite", name = "Sprites", + description = "Overworld, menu and enemy sprite sheets." }, + { id = "tiles", kind = "tileset", name = "Tiles", + description = "Overworld and dungeon tilesets with their palettes." }, + { id = "palettes", kind = "palette", name = "Palettes", + description = "Sprite and armor palette rows the sheets are drawn with." }, + { id = "sfx", kind = "sfx", name = "Sound effects", planned = true, + description = "The SPC sound effect bank, one file per effect." }, + { id = "music", kind = "music", name = "Music", planned = true, + description = "The SPC music bank, rendered per track." }, + }, + }, +} + +local BY_ID = {} +for _, desc in ipairs(LIST) do BY_ID[desc.id] = desc end + +function Importers.all() return LIST end + +function Importers.get(id) return BY_ID[id] end + +function Importers.pack(importerId, packId) + local desc = BY_ID[importerId] + for _, p in ipairs(desc and desc.packs or {}) do + if p.id == packId then return p end + end + return nil +end + +local function badId(value) + return type(value) ~= "string" or not value:match(ID_PATTERN) +end + +function Importers.validateDescriptor(desc) + if type(desc) ~= "table" then return false, "importer must be a table" end + if badId(desc.id) then + return false, "importer id must be lowercase letters, numbers, _ or -" + end + if type(desc.name) ~= "string" or desc.name == "" then + return false, "importer name is required" + end + if not Importers.STATUS[desc.status] then + return false, "importer status must be planned, beta or ready" + end + if type(desc.source) ~= "table" or type(desc.source.name) ~= "string" then + return false, "importer source must name the dump it reads" + end + if type(desc.packs) ~= "table" or #desc.packs == 0 then + return false, "importer must declare at least one pack" + end + local seen = {} + for _, p in ipairs(desc.packs) do + if badId(p.id) then + return false, "pack id must be lowercase letters, numbers, _ or -" + end + if seen[p.id] then return false, "duplicate pack id: " .. p.id end + seen[p.id] = true + if not Importers.KINDS[p.kind] then + return false, ("pack %s has unknown kind %s"):format(p.id, tostring(p.kind)) + end + if type(p.name) ~= "string" or p.name == "" then + return false, "pack " .. p.id .. " needs a name" + end + end + return true +end + +function Importers.packRoot(importerId, packId) + return Importers.ROOT .. "/" .. importerId .. "/" .. packId +end + +function Importers.manifestPath(importerId, packId) + return Importers.packRoot(importerId, packId) .. "/" .. Importers.PACK_FILE +end + +function Importers.assetPath(importerId, packId, file) + local rel = SafePath.safe(file) + if not rel then return nil, "pack file must stay inside its pack" end + return Importers.packRoot(importerId, packId) .. "/" .. rel +end + +local function extensionOf(file) + return (file:match("%.([%a%d]+)$") or ""):lower() +end + +function Importers.validatePack(manifest, importerId, packId) + if type(manifest) ~= "table" then return false, "pack manifest must be a table" end + if manifest.format ~= Importers.PACK_FORMAT then + return false, ("pack format %s is not %d") + :format(tostring(manifest.format), Importers.PACK_FORMAT) + end + if importerId and manifest.importer ~= importerId then + return false, "pack manifest names a different importer" + end + if packId and manifest.pack ~= packId then + return false, "pack manifest names a different pack" + end + if badId(manifest.importer) or badId(manifest.pack) then + return false, "pack manifest importer and pack ids are malformed" + end + local kind = Importers.KINDS[manifest.kind] + if not kind then + return false, "pack kind is unknown: " .. tostring(manifest.kind) + end + if not Semver.parse(manifest.version or "") then + return false, "pack version must be semver" + end + if type(manifest.source) ~= "table" or type(manifest.source.md5) ~= "string" then + return false, "pack must record the md5 of the dump it came from" + end + if type(manifest.entries) ~= "table" then + return false, "pack entries must be a table" + end + local count = 0 + for id, entry in pairs(manifest.entries) do + if type(id) ~= "string" or not id:match(ENTRY_PATTERN) then + return false, "pack entry id is malformed: " .. tostring(id) + end + if type(entry) ~= "table" then + return false, "pack entry " .. id .. " must be a table" + end + local rel = type(entry.file) == "string" and SafePath.safe(entry.file) + if not rel then + return false, "pack entry " .. id .. " must name a file inside the pack" + end + if not kind.ext[extensionOf(rel)] then + return false, ("pack entry %s is not a %s file"):format(id, manifest.kind) + end + if type(entry.size) ~= "number" or entry.size < 0 + or entry.size % 1 ~= 0 then + return false, "pack entry " .. id .. " must record its byte size" + end + if entry.size > Importers.MAX_ENTRY_BYTES then + return false, "pack entry " .. id .. " exceeds the 8 MiB entry limit" + end + count = count + 1 + end + return true, nil, count +end + +local function persistFs(fs) + if fs then return fs end + local SaveData = require("src.core.SaveData") + local base = love and love.filesystem or nil + return SaveData.persistenceFs(base) or base +end + +Importers.fs = persistFs + +local function decode(bytes, chunkName) + local loader = loadstring or load + local chunk, err = loader(bytes, "@" .. chunkName) + if not chunk then return nil, err end + if setfenv then setfenv(chunk, {}) end + local ok, value = pcall(chunk) + if not ok then return nil, value end + return value +end + +function Importers.readPack(importerId, packId, fs) + fs = persistFs(fs) + local path = Importers.manifestPath(importerId, packId) + local bytes = fs and fs.read and fs.read(path) + if type(bytes) ~= "string" then return nil, "pack is not installed" end + local manifest, err = decode(bytes, path) + if type(manifest) ~= "table" then + return nil, "pack manifest could not be read: " .. tostring(err) + end + local ok, reason = Importers.validatePack(manifest, importerId, packId) + if not ok then return nil, reason end + return manifest +end + +function Importers.installed(importerId, fs) + fs = persistFs(fs) + local desc = BY_ID[importerId] + local out = {} + for _, p in ipairs(desc and desc.packs or {}) do + local manifest = Importers.readPack(importerId, p.id, fs) + if manifest then + local count = 0 + for _ in pairs(manifest.entries) do count = count + 1 end + out[p.id] = { version = manifest.version, entries = count, + kind = manifest.kind, source = manifest.source } + end + end + return out +end + +function Importers.state(importerId, fs) + local desc = BY_ID[importerId] + if not desc then return "unknown" end + if desc.status == "planned" then return "planned" end + if not (fs or (love and love.filesystem)) then return "missing" end + local installed = Importers.installed(importerId, fs) + local have, total = 0, 0 + for _, p in ipairs(desc.packs) do + if not p.planned then total = total + 1 end + end + for id in pairs(installed) do + if not (Importers.pack(importerId, id) or {}).planned then + have = have + 1 + end + end + if have == 0 then return "missing" end + if have < total then return "partial" end + return "ready" +end + +function Importers.resolve(spec, fs) + if type(spec) ~= "table" then return nil, "asset spec must be a table" end + local desc = BY_ID[spec.importer] + if not desc then return nil, "unknown importer: " .. tostring(spec.importer) end + if not Importers.pack(spec.importer, spec.pack) then + return nil, ("%s has no pack %s"):format(desc.name, tostring(spec.pack)) + end + local manifest, err = Importers.readPack(spec.importer, spec.pack, fs) + if not manifest then + return nil, ("%s / %s: %s"):format(desc.name, spec.pack, tostring(err)) + end + if spec.version then + local ok, reason = Semver.satisfies(manifest.version, spec.version) + if not ok then + return nil, ("%s / %s is v%s, needs %s%s"):format(desc.name, spec.pack, + tostring(manifest.version), spec.version, + reason and (" (" .. reason .. ")") or "") + end + end + return manifest +end + +function Importers.writeAsset(importerId, packId, file, bytes, fs) + local path, err = Importers.assetPath(importerId, packId, file) + if not path then return nil, err end + if type(bytes) ~= "string" then return nil, "pack asset must be bytes" end + if #bytes > Importers.MAX_ENTRY_BYTES then + return nil, "pack asset exceeds the 8 MiB entry limit" + end + fs = persistFs(fs) + if not (fs and fs.write) then return nil, "pack writes are unavailable" end + if fs.createDirectory then + local parts, walked = {}, Importers.ROOT + fs.createDirectory(walked) + walked = walked .. "/" .. importerId + fs.createDirectory(walked) + walked = walked .. "/" .. packId + fs.createDirectory(walked) + for segment in path:sub(#walked + 2):gmatch("[^/]+") do + parts[#parts + 1] = segment + end + for i = 1, #parts - 1 do + walked = walked .. "/" .. parts[i] + fs.createDirectory(walked) + end + end + local ok, writeErr = fs.write(path, bytes) + if not ok then return nil, writeErr or "could not write pack asset" end + return #bytes +end + +function Importers.writePack(importerId, packId, manifest, fs) + local ok, reason = Importers.validatePack(manifest, importerId, packId) + if not ok then return nil, reason end + fs = persistFs(fs) + if not (fs and fs.write) then return nil, "pack writes are unavailable" end + local LuaWriter = require("src.import.LuaWriter") + local root = Importers.packRoot(importerId, packId) + if fs.createDirectory then + fs.createDirectory(Importers.ROOT) + fs.createDirectory(Importers.ROOT .. "/" .. importerId) + fs.createDirectory(root) + end + return fs.write(Importers.manifestPath(importerId, packId), + LuaWriter.encode(manifest)) +end + +return Importers diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index cb04b80b..e7dd7a03 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -1462,6 +1462,32 @@ local function drawSyncGlyph(x, y, w, h, hot) end end +local function drawImporterGlyph(x, y, w, h, hot) + local box = math.min(w, h) + local bx = x + (w - box) / 2 + local by = y + (h - box) / 2 + local pad = box * 0.24 + local ink = hot and PAL.inverse or PAL.ink + local lw = math.max(1, math.floor(Kit.scale + 0.5)) + local d = box - 2 * pad + local cx = bx + box / 2 + + local trayTop = by + pad + d * 0.48 + Theme.strokeRounded(bx + pad, trayTop, d, by + pad + d - trayTop, + ink, 1, lw, d * 0.18) + + local head = d * 0.30 + local tipY = trayTop - d * 0.06 + Theme.fill(cx - lw / 2, by + pad, lw, tipY - head * 0.7 - (by + pad), ink, 1) + if love.graphics.polygon then + Theme.col(ink, 1) + love.graphics.polygon("fill", + cx - head * 0.5, tipY - head * 0.7, cx + head * 0.5, tipY - head * 0.7, + cx, tipY) + love.graphics.setColor(1, 1, 1, 1) + end +end + local function drawCross(x, y, size, color) love.graphics.push("all") love.graphics.setColor(color) @@ -1530,6 +1556,7 @@ local HEADER_TABS = { { id = "find", key = "tab-find" }, { id = "online", key = "tab-online", glyph = true, beta = true }, { id = "skins", key = "tab-skins", glyph = true, beta = true }, + { id = "importers", key = "tab-importers", glyph = true }, } LauncherView.HEADER_TABS = HEADER_TABS @@ -1549,7 +1576,8 @@ end for _, t in ipairs(HEADER_TABS) do t.opts = { face = "tab", font = "tab", color = t.color, letter = t.letter } if t.glyph then - t.opts.drawFn = t.id == "online" and drawOnlineGlyph or drawSkinGlyph + t.opts.drawFn = (t.id == "online" and drawOnlineGlyph) + or (t.id == "importers" and drawImporterGlyph) or drawSkinGlyph end end @@ -1587,7 +1615,7 @@ local function headerChrome(imp) hot and QUIT_INK_HOT or QUIT_INK_REST) end }, tab = {}, - sync = { face = "tab", drawFn = drawSyncGlyph, + sync = { face = "invert", drawFn = drawSyncGlyph, action = function() imp:_openSync() end }, game = { face = "tab", font = "tab", action = function() @@ -1632,16 +1660,20 @@ local function buildHeader(imp, m) -- overlap at any window size. -- iOS has no quit button (the OS owns app exit), so the cluster is the -- gear alone and the wordmark gets that width back - local clusterW = (imp.ios and gear or 2 * gear + math.floor(6 * m.s)) + m.pad - local boxX = m.x + clusterW - local boxW = math.max(0, m.w - 2 * clusterW) + local clusterN = imp.ios and 2 or 3 + local clusterW = clusterN * gear + (clusterN - 1) * math.floor(6 * m.s) + m.pad + local mobile = not m.twoCol + local boxX = mobile and (m.x + m.pad) or (m.x + clusterW) + local boxW = mobile and math.max(0, m.w - clusterW - m.pad) + or math.max(0, m.w - 2 * clusterW) if imp.logo and boxW > 0 then local lw, lh = imp.logo:getDimensions() local maxW = math.min(320 * m.s, boxW) local scale = math.min(maxW / lw, m.logoH / lh) local dw, dh = lw * scale, lh * scale love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(imp.logo, Theme.snap(boxX + (boxW - dw) / 2), + love.graphics.draw(imp.logo, + Theme.snap(mobile and boxX or (boxX + (boxW - dw) / 2)), Theme.snap(y + (rowH - dh) / 2), 0, scale, scale) end @@ -1679,6 +1711,16 @@ local function buildHeader(imp, m) chrome.gear.image = imp._gearIcon btn(imp, rx, by, gear, gear, "gear", "", chrome.gear) + rx = rx - math.floor(6 * m.s) - gear + btn(imp, rx, by, gear, gear, "tab-sync", "", chrome.sync) + do + local eng = imp._sync + if eng and eng.busy and eng:busy() then + Kit.spinner(rx + gear - math.floor(8 * m.s), by + math.floor(8 * m.s), + math.max(2, math.floor(4 * m.s))) + end + end + if quitX then btn(imp, quitX, by, gear, gear, "quit", "", chrome.quit) end -- The self-update control lives in the FOOTER next to the BCG mark (small, @@ -1754,23 +1796,6 @@ local function buildHeader(imp, m) end for _, t in ipairs(tabs) do headerTab(t) end - do - local w = tabH - if tx > tabLeft and tx + w > tabRight then - tx = tabLeft - ty = ty + tabH + tabRowGap - end - local o = chrome.sync - o.active = imp._syncModal ~= nil - btn(imp, tx, ty, w, tabH, "tab-sync", "", o) - local eng = imp._sync - if eng and eng.busy and eng:busy() then - Kit.spinner(tx + w - math.floor(8 * m.s), ty + math.floor(8 * m.s), - math.max(2, math.floor(4 * m.s))) - end - tx = tx + w + tabGap - end - -- `ty` has walked down with the wraps, so this stays correct at one row too. y = ty + tabH + math.floor(8 * m.s) Theme.fill(m.x, y, m.w, 1, PAL.line, Theme.A.hairline) @@ -2989,6 +3014,138 @@ local function buildModsPanel(imp, x, y, w, availH, m) return (listTop + contentH + gap) - y end +-- --------------------------------------------------------- importers panel + +local IMPORTER_STATE_TEXT = { + planned = { "Not available yet", "steel" }, + missing = { "No dump imported", "yellow" }, + partial = { "Partly imported", "yellow" }, + ready = { "Imported", "green" }, +} + +local function importerStateLine(state, have, total) + local row = IMPORTER_STATE_TEXT[state] or IMPORTER_STATE_TEXT.planned + local text = Strings(row[1]) + if state == "partial" or state == "ready" then + text = text .. Strings(" %d of %d packs", have, total) + end + return text, PAL[row[2]] or PAL.steel +end + +local IMPORTER_RESCAN = 3 + +local function importerRows(imp) + local rows = imp._importerRows + if rows and (Kit.time - (imp._importerRowsAt or 0)) < IMPORTER_RESCAN then + return rows + end + local Importers = require("src.import.Importers") + rows = {} + for _, desc in ipairs(Importers.all()) do + local installed = Importers.installed(desc.id) + local have, total = 0, 0 + for _, pack in ipairs(desc.packs) do + if not pack.planned then + total = total + 1 + if installed[pack.id] then have = have + 1 end + end + end + rows[#rows + 1] = { desc = desc, state = Importers.state(desc.id), + have = have, total = total } + end + imp._importerRows, imp._importerRowsAt = rows, Kit.time + return rows +end + +local function buildImportersPanel(imp, x, y, w, availH, m) + local gap = m.gap + local cy = y + + Kit.text("title", Strings("IMPORTERS"), x, cy, PAL.heading) + cy = cy + Kit.textHeight("title") + math.floor(10 * m.s) + + local listTop = cy + local pad = math.floor(12 * m.s) + + for _, row in ipairs(importerRows(imp)) do + local desc, state = row.desc, row.state + local packsText = {} + for _, p in ipairs(desc.packs) do + packsText[#packsText + 1] = p.planned + and (p.name .. Strings(" (soon)")) or p.name + end + local textH = Kit.textHeight("button") + math.floor(4 * m.s) + + Kit.textHeight("small") + math.floor(2 * m.s) + + Kit.textHeight("small") + math.floor(2 * m.s) + Kit.textHeight("small") + local running0 = imp._importerJob ~= nil and imp._importerJob.id == desc.id + local rowH = math.floor(8 * m.s) + textH + math.floor(8 * m.s) + m.btnH + + math.floor(8 * m.s) + + (running0 and math.floor(8 * m.s) or 0) + local ry = cy + Kit.card(x, ry, w, rowH, "muted") + local px, inner = x + pad, w - 2 * pad + local ly = ry + math.floor(10 * m.s) + + local badge = Strings(desc.status:upper()) + local badgeW = Kit.textWidth("micro", badge) + math.floor(12 * m.s) + local nameShown = Kit.ellipsize("button", desc.name, + inner - badgeW - math.floor(12 * m.s)) + Kit.text("button", nameShown, px, ly, PAL.muted) + Kit.tag(px + Kit.textWidth("button", nameShown) + math.floor(8 * m.s), ly, + badgeW, Kit.textHeight("button"), badge, + state == "ready" and PAL.green or PAL.steel) + ly = ly + Kit.textHeight("button") + math.floor(4 * m.s) + + local stateText, stateCol = importerStateLine(state, row.have, row.total) + Kit.text("small", stateText, px, ly, stateCol) + ly = ly + Kit.textHeight("small") + math.floor(2 * m.s) + Kit.text("small", Kit.ellipsize("small", Strings(desc.summary), inner), + px, ly, PAL.detail) + ly = ly + Kit.textHeight("small") + math.floor(2 * m.s) + Kit.text("small", Kit.ellipsize("small", + Strings("Packs: ") .. table.concat(packsText, ", "), inner), + px, ly, PAL.muted) + ly = ly + Kit.textHeight("small") + math.floor(8 * m.s) + + local job = imp._importerJob + local running = job ~= nil and job.id == desc.id + local runnable = desc.status ~= "planned" and not imp._importerJob + local label = running and Strings("Importing...") or Strings("Import dump") + local bw = math.min(inner, + Kit.textWidth("small", label) + math.floor(28 * m.s)) + btn(imp, px, ly, bw, m.btnH, "importer-" .. desc.id, label, { + kind = runnable and "accent" or "ghost", font = "small", + enabled = runnable, + action = runnable + and function() imp:_beginImporterImport(desc.id) end or nil }) + local hx = px + bw + math.floor(8 * m.s) + local hw = math.max(0, px + inner - hx) + if hw > 0 then + local hint, hintCol + if running then + hint, hintCol = job.status or Strings("Working..."), PAL.yellow + elseif imp._importerNotice then + hint = imp._importerNotice.text + hintCol = imp._importerNotice.ok and PAL.green or PAL.red + elseif desc.status == "planned" then + hint, hintCol = Strings("Not wired up yet."), PAL.muted + else + hint, hintCol = Strings("Reads %s.", desc.source.name), PAL.muted + end + Kit.text("small", Kit.ellipsize("small", hint, hw), hx, + ly + (m.btnH - Kit.textHeight("small")) / 2, hintCol) + end + if running then + ly = ly + m.btnH + math.floor(4 * m.s) + Kit.progress(px, ly, inner, math.floor(4 * m.s), job.progress or 0) + end + + cy = ry + rowH + gap + end + + return (cy - y) +end + -- ---------------------------------------------------------- find mods panel -- SKINS tab: pick the on-screen skin, import one, or open Skin Studio. @@ -6404,6 +6561,8 @@ local function buildTabPanel(imp, x, y, w, availH, budgetH, m) return buildFindPanel(imp, x, y, w, budgetH, m) elseif imp.tab == "skins" then return buildSkinsPanel(imp, x, y, w, budgetH, m) + elseif imp.tab == "importers" then + return buildImportersPanel(imp, x, y, w, budgetH, m) elseif imp.tab == "online" then return require("src.import.OnlinePanel") .buildOnlinePanel(imp, x, y, w, budgetH, m) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 8cf8d111..077ca1ae 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1136,6 +1136,46 @@ local function chooseRom(promptName) return nil end +-- chooseRom for an importer's own dump: same per-OS dialogs, but the file +-- types come from the importer descriptor rather than being Game Boy ROMs. +local function chooseImporterFile(promptName, exts) + local prompt = shellSafe("Choose your " .. (promptName or "game") .. " dump") + local platform = love.system.getOS() + local quoted, globs, semis = {}, {}, {} + for _, ext in ipairs(exts) do + quoted[#quoted + 1] = ('"%s"'):format(ext) + globs[#globs + 1] = "*." .. ext + semis[#semis + 1] = "*." .. ext + end + if platform == "OS X" then + return commandOutput( + ([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {%s})' 2>/dev/null]]) + :format(prompt, table.concat(quoted, ", "))) + elseif platform == "Windows" then + local script = table.concat({ + "Add-Type -AssemblyName System.Windows.Forms;", + "$d=New-Object System.Windows.Forms.OpenFileDialog;", + "$d.Title='" .. prompt .. "';", + "$d.Filter='Cartridge dump (" .. table.concat(semis, ";") .. ")|" + .. table.concat(semis, ";") .. "|All files (*.*)|*.*';", + "if($d.ShowDialog() -eq 'OK'){", + "[Console]::OutputEncoding=[Text.Encoding]::UTF8;", + "[Console]::Write($d.FileName)}", + }) + return commandOutput( + 'powershell -NoProfile -STA -Command "' .. script .. '"') + elseif platform == "Linux" then + local path = commandOutput( + ([[zenity --file-selection --title="%s" --file-filter="Cartridge dump | %s" 2>/dev/null]]) + :format(prompt, table.concat(globs, " "))) + if path then return path end + return commandOutput( + ([[kdialog --getopenfilename "$HOME" "%s|Cartridge dump" 2>/dev/null]]) + :format(table.concat(globs, " "))) + end + return nil +end + -- Open a native picker for a mod .zip (mirrors chooseRom's per-OS dialogs). -- Returns the chosen absolute path or nil. Android uses love.system.pickFile -- ("mod") instead -- see RomImporter:chooseMod. @@ -2016,6 +2056,12 @@ function RomImporter:filedropped(file) self:setError("Could not read the dropped file: " .. tostring(readError)) return end + if self._importerDropFor then + local importerId = self._importerDropFor + self._importerDropFor = nil + self:_runImporterData(importerId, data) + return + end self:startData(data, file:getFilename()) end @@ -2868,6 +2914,7 @@ end function RomImporter:update(dt) self.pulse = self.pulse + dt + if self._importerJob then self:_stepImporter() end if not Transition.armed then self._motionFrames = (self._motionFrames or 0) + 1 if self._motionFrames > 1 then Transition.armed = true end @@ -3057,6 +3104,11 @@ function RomImporter:update(dt) if Platform.isUWP() and self.modNotice and self.modNotice.ok then os.remove(path) end + elseif kind == "importer" then + local importerId = self.pickerPendingImporterId + self.pickerPendingImporterId = nil + if importerId then self:_runImporter(importerId, path) end + if Platform.isUWP() then os.remove(path) end elseif kind == "skin" then self:_installSkinZip(path) if Platform.isUWP() and self._skinNotice and self._skinNotice.ok then @@ -3236,7 +3288,7 @@ function RomImporter:resumeAfterOverlay() end function RomImporter:_cycleTab(delta) - local order = { "mods", "find", "skins" } + local order = { "mods", "find", "skins", "importers" } for i = #GameVersion.ORDER, 1, -1 do table.insert(order, 1, GameVersion.ORDER[i]) end @@ -3878,6 +3930,104 @@ end -- Switch the active tab (chips, shoulder buttons). The find search caret and -- the soft keyboard drop with the panel they belonged to; each tab's scroll -- offset persists inside the view's per-tab scroll container. +function RomImporter:_beginImporterImport(importerId) + local Importers = require("src.import.Importers") + local desc = Importers.get(importerId) + if not desc or desc.status == "planned" then return end + if self._importerJob then return end + self._importerNotice = nil + if self.nativePicker and love.system.getPickedFile then + self.pickerPendingKind = "importer" + self.pickerPendingImporterId = importerId + if not pickFile("rom") then + self.pickerPendingKind, self.pickerPendingImporterId = nil, nil + self._importerNotice = { text = "Could not open the file picker." } + end + return + end + local path = chooseImporterFile(desc.name, desc.source.formats or { "sfc" }) + if path then + self:_runImporter(importerId, path) + return + end + local okKit, Kit = pcall(require, "src.ui.kit.Kit") + if okKit and Kit.FileBrowser then + self._padCursorActive = false + Kit.FileBrowser.open({ + title = "Select " .. desc.name, + mode = "rom", + onSelect = function(pickedPath) + self:_runImporter(importerId, pickedPath) + end, + }) + return + end + self._importerNotice = { + text = "Drop your " .. desc.name .. " dump onto this window.", + } + self._importerDropFor = importerId +end + +function RomImporter:_runImporter(importerId, path) + local data, readErr = readExternalPath(path) + if not data then + self._importerNotice = { text = "Could not read that file: " + .. tostring(readErr) } + return + end + return self:_runImporterData(importerId, data) +end + +function RomImporter:_runImporterData(importerId, data) + local LttpImport = require("src.import.lttp.LttpImport") + if importerId ~= LttpImport.IMPORTER then return end + local source, err = LttpImport.identify(data) + if not source then + self._importerNotice = { text = tostring(err) } + return + end + self._importerJob = { + id = importerId, + co = LttpImport.job(data), + progress = 0, + status = "Reading " .. source.name, + } + self._importerNotice = nil +end + +function RomImporter:_stepImporter() + local job = self._importerJob + if not job then return end + local budget = 24 + while budget > 0 do + budget = budget - 1 + if coroutine.status(job.co) == "dead" then break end + local ok, value = coroutine.resume(job.co) + if not ok then + self._importerJob = nil + self._importerNotice = { text = "Import failed: " .. tostring(value) } + self._importerRows = nil + return + end + if coroutine.status(job.co) == "dead" then + self._importerJob = nil + self._importerRows = nil + local packs = {} + for id, count in pairs(type(value) == "table" and value.packs or {}) do + packs[#packs + 1] = id .. " (" .. count .. ")" + end + table.sort(packs) + self._importerNotice = { ok = true, + text = "Imported " .. table.concat(packs, ", ") } + return + end + if type(value) == "table" and value.total and value.total > 0 then + job.progress = value.done / value.total + job.status = value.status or job.status + end + end +end + function RomImporter:_switchTab(id) if id == "bug" then return self:_openBugPanel() end if self.tab and self.tab ~= id then @@ -3896,6 +4046,7 @@ function RomImporter:_switchTab(id) -- the skins list is cheap and can change behind the launcher's back -- (an export, a hand-dropped folder), so re-read it on every visit if id == "skins" then self:_ensureSkins(true) end + if id == "importers" then self._importerRows = nil end if GameVersion.VERSIONS[id] then self:_setModScope(id) end diff --git a/src/import/lttp/LttpImport.lua b/src/import/lttp/LttpImport.lua new file mode 100644 index 00000000..10d7c620 --- /dev/null +++ b/src/import/lttp/LttpImport.lua @@ -0,0 +1,315 @@ +local Lz2 = require("src.import.lttp.Lz2") +local SnesGfx = require("src.import.lttp.SnesGfx") +local Importers = require("src.import.Importers") +local StreamMD5 = require("src.mods.StreamMD5") +local LuaWriter = require("src.import.LuaWriter") + +local LttpImport = {} + +LttpImport.IMPORTER = "lttp" +LttpImport.EXPORT_VERSION = "1.1.0" + +LttpImport.SOURCES = { + ["608c22b8ff930c62dc2de54bcd6eba72"] = { + name = "The Legend of Zelda: A Link to the Past (USA)", + size = 1048576, + }, +} + +LttpImport.SHEET_COUNT = 0xDF +LttpImport.SPRITE_FIRST = 115 +-- Bank00.asm Decomp .bg_variable / .spr_variable +LttpImport.POINTERS = { bank = 0x4F80, high = 0x505F, low = 0x513E } +LttpImport.RAW_FIRST, LttpImport.RAW_LAST = 115, 126 +LttpImport.RAW_SIZE = 0x600 +LttpImport.SHEET_TILES_WIDE = 16 + +-- palettes.asm; a 3bpp sheet uses the lower 8 colors, so a row's first 7 +-- entries are the drawable ones and index 0 is transparent. +LttpImport.PALETTE_SETS = { + { id = "sprites_light", offset = 0xDD218, rows = 4, perRow = 15 }, + { id = "sprites_dark", offset = 0xDD290, rows = 4, perRow = 15 }, + { id = "armor", offset = 0xDD308, rows = 5, perRow = 15 }, + { id = "sprites_aux3", offset = 0xDD39E, rows = 12, perRow = 7 }, + { id = "bg_dungeon", offset = 0xDD734, rows = 16, perRow = 15 }, + { id = "bg_overworld_aux3", offset = 0xDE604, rows = 12, perRow = 7 }, +} + +-- Bank00.asm: the core sprite DMA sets source bank $10 and writes straight to +-- $2118, so the player's frames are 4bpp in bank $10 and are NOT in the sheet +-- pointer table. $80000 + 0x7000 lands exactly on $87000, where the table's +-- first sprite entry starts. +LttpImport.PLAYER = { offset = 0x80000, bytes = 0x7000, bits = 4, tiles = 896 } + +LttpImport.COLORS_3BPP = 7 +LttpImport.COLORS_4BPP = 15 +LttpImport.DEFAULT_PALETTE = { + sprites = { set = "sprites_light", row = 1 }, + tiles = { set = "bg_dungeon", row = 1 }, +} + +function LttpImport.identify(rom) + if type(rom) ~= "string" then return nil, "no rom bytes" end + if #rom % 1024 == 512 then rom = rom:sub(513) end + local digest = StreamMD5.new():update(rom):final() + local source = LttpImport.SOURCES[digest] + if not source then + return nil, "this is not a Link to the Past dump this importer knows (" + .. digest .. ")" + end + if #rom ~= source.size then + return nil, ("expected %d bytes, got %d"):format(source.size, #rom) + end + return { md5 = digest, name = source.name, size = #rom }, rom +end + +function LttpImport.sheetOffset(rom, index) + local p = LttpImport.POINTERS + local bank = rom:byte(p.bank + index + 1) + local high = rom:byte(p.high + index + 1) + local low = rom:byte(p.low + index + 1) + if not (bank and high and low) then return nil end + return SnesGfx.loRomOffset(bank, high, low) +end + +function LttpImport.isRaw(index) + return index >= LttpImport.RAW_FIRST and index <= LttpImport.RAW_LAST +end + +function LttpImport.readSheet(rom, index) + local offset = LttpImport.sheetOffset(rom, index) + if not offset then return nil, "sheet pointer is out of range" end + local raw, err + if LttpImport.isRaw(index) then + raw = rom:sub(offset + 1, offset + LttpImport.RAW_SIZE) + if #raw ~= LttpImport.RAW_SIZE then return nil, "raw sheet is truncated" end + else + raw, err = Lz2.decompress(rom, offset) + if not raw then return nil, err end + end + return SnesGfx.decodeSheet(raw) +end + +function LttpImport.readPlayer(rom) + local p = LttpImport.PLAYER + local raw = rom:sub(p.offset + 1, p.offset + p.bytes) + if #raw ~= p.bytes then return nil, "player graphics are truncated" end + return SnesGfx.decodeTiles(raw, p.bits) +end + +function LttpImport.readPalettes(rom) + local out = {} + for _, set in ipairs(LttpImport.PALETTE_SETS) do + local colors, err = SnesGfx.readPalette(rom, set.offset, + set.rows * set.perRow) + if not colors then return nil, err end + local rows = {} + for row = 0, set.rows - 1 do + local one = {} + for i = 1, set.perRow do one[i] = colors[row * set.perRow + i] end + rows[row + 1] = one + end + out[set.id] = rows + end + return out +end + +local function sheetImage(tiles, palette) + local wide = LttpImport.SHEET_TILES_WIDE + local rows = math.ceil(#tiles / wide) + local image = love.image.newImageData(wide * 8, rows * 8) + for index, pixels in ipairs(tiles) do + local tx = ((index - 1) % wide) * 8 + local ty = math.floor((index - 1) / wide) * 8 + for y = 0, 7 do + for x = 0, 7 do + local value = pixels[y * 8 + x + 1] + if value == 0 then + image:setPixel(tx + x, ty + y, 0, 0, 0, 0) + else + local color = palette and palette[value] + if color then + image:setPixel(tx + x, ty + y, + color[1] / 255, color[2] / 255, color[3] / 255, 1) + else + local shade = value / LttpImport.COLORS_3BPP + image:setPixel(tx + x, ty + y, shade, shade, shade, 1) + end + end + end + end + end + return image +end + +local function encodePng(image) + local ok, fileData = pcall(image.encode, image, "png") + if not ok then return nil, tostring(fileData) end + return fileData:getString() +end + +-- Yields { done, total, status } as it walks the sheets, so the launcher can +-- draw progress instead of freezing for the length of a 223-sheet import. +function LttpImport.job(rom, fs) + return coroutine.create(function() + local source, normalized = LttpImport.identify(rom) + if not source then error(normalized, 0) end + rom = normalized + + local packs = { + tiles = { kind = "tileset", entries = {}, first = 0, + last = LttpImport.SPRITE_FIRST - 1 }, + sprites = { kind = "sprite", entries = {}, + first = LttpImport.SPRITE_FIRST, last = LttpImport.SHEET_COUNT - 1 }, + } + local total = LttpImport.SHEET_COUNT + #LttpImport.PALETTE_SETS + 2 + local done = 0 + local skipped = {} + + local palettes, paletteErr = LttpImport.readPalettes(rom) + if not palettes then error(paletteErr, 0) end + + local function paletteRow(set, row, count) + local src = palettes[set] and palettes[set][row] + if not src then return nil end + local out = {} + for i = 1, count do out[i] = src[i] end + return out + end + + local function defaultPalette(packId) + local pick = LttpImport.DEFAULT_PALETTE[packId] + local row = pick and palettes[pick.set] and palettes[pick.set][pick.row] + if not row then return nil, nil end + local out = {} + for i = 1, LttpImport.COLORS_3BPP do out[i] = row[i] end + return out, pick.set .. "/" .. pick.row + end + + for _, packId in ipairs({ "tiles", "sprites" }) do + local palette, paletteName = defaultPalette(packId) + local pack = packs[packId] + for index = pack.first, pack.last do + local tiles, bits = LttpImport.readSheet(rom, index) + if tiles then + local file = ("sheet_%03d.png"):format(index) + local bytes, encodeErr = encodePng(sheetImage(tiles, palette)) + if bytes then + local size, writeErr = Importers.writeAsset(LttpImport.IMPORTER, + packId, file, bytes, fs) + if size then + pack.entries[("sheet_%03d"):format(index)] = { + file = file, size = size, + width = LttpImport.SHEET_TILES_WIDE * 8, + height = math.ceil(#tiles / LttpImport.SHEET_TILES_WIDE) * 8, + frames = #tiles, + colors = bits == 3 and 8 or 4, + palette = paletteName, + note = "colour 0 is transparent; recolour from the palettes pack", + } + else + skipped[#skipped + 1] = index .. ": " .. tostring(writeErr) + end + else + skipped[#skipped + 1] = index .. ": " .. tostring(encodeErr) + end + else + skipped[#skipped + 1] = index .. ": " .. tostring(bits) + end + done = done + 1 + coroutine.yield({ done = done, total = total, + status = "Reading sheet " .. index }) + end + end + + do + local tiles, playerErr = LttpImport.readPlayer(rom) + if tiles then + local palette = paletteRow("armor", 1, LttpImport.COLORS_4BPP) + local bytes, encodeErr = encodePng(sheetImage(tiles, palette)) + if bytes then + local size, writeErr = Importers.writeAsset(LttpImport.IMPORTER, + "sprites", "player.png", bytes, fs) + if size then + packs.sprites.entries.player = { + file = "player.png", size = size, + width = LttpImport.SHEET_TILES_WIDE * 8, + height = math.ceil(#tiles / LttpImport.SHEET_TILES_WIDE) * 8, + frames = #tiles, colors = 16, + palette = "armor/1", + note = "bank $10 player frames, 4bpp; colour 0 is transparent", + } + else + skipped[#skipped + 1] = "player: " .. tostring(writeErr) + end + else + skipped[#skipped + 1] = "player: " .. tostring(encodeErr) + end + else + skipped[#skipped + 1] = "player: " .. tostring(playerErr) + end + end + + done = done + 1 + coroutine.yield({ done = done, total = total, status = "Reading Link graphics" }) + + local overworld = require("src.import.lttp.Overworld") + local townImage = overworld.image(rom, LttpImport.readSheet) + local townBytes = assert(encodePng(townImage)) + townImage:release() + local townSize = assert(Importers.writeAsset(LttpImport.IMPORTER, + "tiles", "kakariko.png", townBytes, fs)) + packs.tiles.entries.kakariko = { + file = "kakariko.png", size = townSize, width = 1024, height = 1024, + frames = 4096, colors = 128, + note = "16x16 overworld metatiles with Kakariko graphics and palettes", + } + done = done + 1 + coroutine.yield({ done = done, total = total, status = "Assembling Kakariko tiles" }) + + local paletteEntries = {} + for _, set in ipairs(LttpImport.PALETTE_SETS) do + local file = set.id .. ".lua" + local bytes = LuaWriter.encode(palettes[set.id]) + local size, writeErr = Importers.writeAsset(LttpImport.IMPORTER, + "palettes", file, bytes, fs) + if size then + paletteEntries[set.id] = { file = file, size = size, + frames = set.rows, note = set.rows .. " rows of " .. set.perRow } + else + skipped[#skipped + 1] = set.id .. ": " .. tostring(writeErr) + end + done = done + 1 + coroutine.yield({ done = done, total = total, + status = "Reading palette " .. set.id }) + end + + local written = {} + local function finish(packId, kind, entries) + local manifest = { + format = Importers.PACK_FORMAT, + importer = LttpImport.IMPORTER, + pack = packId, + kind = kind, + version = LttpImport.EXPORT_VERSION, + source = { name = source.name, md5 = source.md5, size = source.size }, + entries = entries, + } + local ok, err = Importers.writePack(LttpImport.IMPORTER, packId, + manifest, fs) + if not ok then error("could not write the " .. packId .. " pack: " + .. tostring(err), 0) end + local count = 0 + for _ in pairs(entries) do count = count + 1 end + written[packId] = count + end + + finish("tiles", "tileset", packs.tiles.entries) + finish("sprites", "sprite", packs.sprites.entries) + finish("palettes", "palette", paletteEntries) + + return { source = source, packs = written, skipped = skipped } + end) +end + +return LttpImport diff --git a/src/import/lttp/Lz2.lua b/src/import/lttp/Lz2.lua new file mode 100644 index 00000000..517a14ea --- /dev/null +++ b/src/import/lttp/Lz2.lua @@ -0,0 +1,87 @@ +local Lz2 = {} + +Lz2.CMD_DIRECT = 0x00 +Lz2.CMD_BYTE_FILL = 0x20 +Lz2.CMD_WORD_FILL = 0x40 +Lz2.CMD_INCREASING = 0x60 +Lz2.CMD_COPY = 0x80 + +Lz2.MAX_OUTPUT = 0x10000 + +local byte = string.byte +local char = string.char +local concat = table.concat + +function Lz2.decompress(rom, offset, limit) + limit = limit or Lz2.MAX_OUTPUT + local out, n = {}, 0 + local p = offset + 1 + local size = #rom + while true do + if p > size then return nil, "ran past the end of the rom" end + local b = byte(rom, p) + p = p + 1 + if b == 0xFF then break end + local cmd, len + if b >= 0xE0 then + cmd = (b * 8) % 256 + cmd = cmd - (cmd % 32) + if p > size then return nil, "truncated long length" end + len = (b % 4) * 256 + byte(rom, p) + 1 + p = p + 1 + else + cmd = b - (b % 32) + len = (b % 32) + 1 + end + if n + len > limit then return nil, "output exceeded " .. limit .. " bytes" end + if cmd == Lz2.CMD_DIRECT then + if p + len - 1 > size then return nil, "truncated direct copy" end + for i = 0, len - 1 do out[n + 1 + i] = byte(rom, p + i) end + n = n + len + p = p + len + elseif cmd == Lz2.CMD_BYTE_FILL then + if p > size then return nil, "truncated byte fill" end + local v = byte(rom, p) + p = p + 1 + for i = 1, len do out[n + i] = v end + n = n + len + elseif cmd == Lz2.CMD_WORD_FILL then + if p + 1 > size then return nil, "truncated word fill" end + local a, c = byte(rom, p), byte(rom, p + 1) + p = p + 2 + for i = 1, len do out[n + i] = (i % 2 == 1) and a or c end + n = n + len + elseif cmd == Lz2.CMD_INCREASING then + if p > size then return nil, "truncated increasing fill" end + local v = byte(rom, p) + p = p + 1 + for i = 1, len do + out[n + i] = v % 256 + v = v + 1 + end + n = n + len + else + if p + 1 > size then return nil, "truncated back reference" end + local src = byte(rom, p + 1) * 256 + byte(rom, p) + p = p + 2 + for i = 1, len do + local from = out[src + i] + if from == nil then return nil, "back reference points past the output" end + out[n + i] = from + end + n = n + len + end + end + local chunk, pieces = {}, {} + for i = 1, n do + chunk[#chunk + 1] = char(out[i]) + if #chunk == 4096 then + pieces[#pieces + 1] = concat(chunk) + chunk = {} + end + end + pieces[#pieces + 1] = concat(chunk) + return concat(pieces), p - 1 - offset +end + +return Lz2 diff --git a/src/import/lttp/Overworld.lua b/src/import/lttp/Overworld.lua new file mode 100644 index 00000000..9c060875 --- /dev/null +++ b/src/import/lttp/Overworld.lua @@ -0,0 +1,88 @@ +local SnesGfx = require("src.import.lttp.SnesGfx") + +local Overworld = {} + +local function word(rom, offset) + local lo, hi = rom:byte(offset + 1, offset + 2) + assert(hi, "overworld data is truncated") + return lo + hi * 256 +end + +-- Bank00.asm InitTilesets, LoadBgGfx; Bank02.asm Overworld_LoadMapProperties. +function Overworld.sheets(rom, area) + local main = area < 0x40 and 0x20 or 0x21 + local aux = assert(rom:byte(0x7C9C + area + 1)) + local sheets = {} + for slot = 0, 7 do + local id = rom:byte(0x6073 + main * 8 + slot + 1) + if slot >= 3 and slot <= 6 then + local replacement = rom:byte(0x5D97 + aux * 4 + slot - 3 + 1) + if replacement ~= 0 then id = replacement end + end + sheets[slot + 1] = id + end + return sheets +end + +-- palettes.asm Palette_OverworldBgMain/Aux1/Aux2/Aux3; Bank0E.asm Overworld_LoadPalettes. +function Overworld.palettes(rom, area) + local group = assert(rom:byte(0x7D1C + area + 1)) + local aux1, aux2, aux3 = rom:byte(0x75504 + group * 4 + 1, + 0x75504 + group * 4 + 3) + local main = area < 0x40 and 0 or 1 + local rows = {} + local function row(palette, first, offset) + rows[palette] = rows[palette] or {} + local colors = assert(SnesGfx.readPalette(rom, offset, 7)) + for i, color in ipairs(colors) do rows[palette][first + i - 1] = color end + end + for p = 2, 6 do + row(p, 1, 0xDE6C8 + word(rom, 0xDEC3B + main * 2) + (p - 2) * 14) + end + for p = 2, 4 do + row(p, 9, 0xDE86C + word(rom, 0xDEC13 + aux1 * 2) + (p - 2) * 14) + end + for p = 5, 7 do + row(p, 9, 0xDE86C + word(rom, 0xDEC13 + aux2 * 2) + (p - 5) * 14) + end + row(7, 1, 0xDE604 + rom:byte(0xDEBC6 + aux3 + 1)) + return rows +end + +-- Bank02.asm Map16ChunkToMap8; Bank0F.asm $0F8000 Map16 to Map8 descriptors. +function Overworld.image(rom, readSheet) + local sheets, pixels = Overworld.sheets(rom, 0x18), {} + for slot, id in ipairs(sheets) do pixels[slot] = assert(readSheet(rom, id)) end + local palettes = Overworld.palettes(rom, 0x18) + local r, g, b = SnesGfx.color(0x2669) + local background = { r, g, b } + local image = love.image.newImageData(1024, 1024) + local high = { [0] = true, [3] = true, [4] = true, [5] = true } + for tile = 0, 4095 do + for cell = 0, 3 do + local descriptor = word(rom, 0x78000 + tile * 8 + cell * 2) + local index = descriptor % 1024 + local slot = math.floor(index / 64) + local source = pixels[slot + 1] and pixels[slot + 1][index % 64 + 1] + local palette = palettes[math.floor(descriptor / 1024) % 8] + local flipX = math.floor(descriptor / 0x4000) % 2 == 1 + local flipY = descriptor >= 0x8000 + local ox = tile % 64 * 16 + cell % 2 * 8 + local oy = math.floor(tile / 64) * 16 + math.floor(cell / 2) * 8 + for y = 0, 7 do + for x = 0, 7 do + local sx, sy = flipX and 7 - x or x, flipY and 7 - y or y + local value = source and source[sy * 8 + sx + 1] or 0 + local color = value ~= 0 and palette + and palette[value + (high[slot] and 8 or 0)] or background + color = color or background + image:setPixel(ox + x, oy + y, color[1] / 255, + color[2] / 255, color[3] / 255, 1) + end + end + end + end + return image +end + +return Overworld diff --git a/src/import/lttp/SnesGfx.lua b/src/import/lttp/SnesGfx.lua new file mode 100644 index 00000000..07f8fde9 --- /dev/null +++ b/src/import/lttp/SnesGfx.lua @@ -0,0 +1,119 @@ +local SnesGfx = {} + +SnesGfx.TILE_PIXELS = 8 + +function SnesGfx.loRomOffset(bank, high, low) + local addr = high * 0x100 + low + if addr < 0x8000 then return nil end + return (bank % 0x80) * 0x8000 + (addr % 0x8000) +end + +function SnesGfx.color(word) + local r = word % 32 + local g = math.floor(word / 32) % 32 + local b = math.floor(word / 1024) % 32 + return r * 255 / 31, g * 255 / 31, b * 255 / 31 +end + +function SnesGfx.readPalette(rom, offset, count) + local out = {} + for i = 0, count - 1 do + local lo = rom:byte(offset + i * 2 + 1) + local hi = rom:byte(offset + i * 2 + 2) + if not lo or not hi then return nil, "palette runs past the end of the rom" end + local r, g, b = SnesGfx.color(hi * 256 + lo) + out[i + 1] = { math.floor(r + 0.5), math.floor(g + 0.5), math.floor(b + 0.5) } + end + return out +end + +local function planeBit(value, x) + return math.floor(value / (2 ^ (7 - x))) % 2 +end + +function SnesGfx.decodeTile3bpp(raw, tile) + local base = tile * 24 + local out = {} + for y = 0, 7 do + local p0 = raw:byte(base + y * 2 + 1) + local p1 = raw:byte(base + y * 2 + 2) + local p2 = raw:byte(base + 16 + y + 1) + if not p0 or not p1 or not p2 then return nil end + for x = 0, 7 do + out[y * 8 + x + 1] = planeBit(p0, x) + + planeBit(p1, x) * 2 + + planeBit(p2, x) * 4 + end + end + return out +end + +function SnesGfx.decodeTile2bpp(raw, tile) + local base = tile * 16 + local out = {} + for y = 0, 7 do + local p0 = raw:byte(base + y * 2 + 1) + local p1 = raw:byte(base + y * 2 + 2) + if not p0 or not p1 then return nil end + for x = 0, 7 do + out[y * 8 + x + 1] = planeBit(p0, x) + planeBit(p1, x) * 2 + end + end + return out +end + +function SnesGfx.decodeTile4bpp(raw, tile) + local base = tile * 32 + local out = {} + for y = 0, 7 do + local p0 = raw:byte(base + y * 2 + 1) + local p1 = raw:byte(base + y * 2 + 2) + local p2 = raw:byte(base + 16 + y * 2 + 1) + local p3 = raw:byte(base + 16 + y * 2 + 2) + if not (p0 and p1 and p2 and p3) then return nil end + for x = 0, 7 do + out[y * 8 + x + 1] = planeBit(p0, x) + + planeBit(p1, x) * 2 + + planeBit(p2, x) * 4 + + planeBit(p3, x) * 8 + end + end + return out +end + +function SnesGfx.decodeTiles(raw, bits) + local perTile = bits == 4 and 32 or (bits == 3 and 24 or 16) + local decode = bits == 4 and SnesGfx.decodeTile4bpp + or (bits == 3 and SnesGfx.decodeTile3bpp or SnesGfx.decodeTile2bpp) + if #raw % perTile ~= 0 then + return nil, "data is not a whole number of " .. bits .. "bpp tiles" + end + local out = {} + for tile = 0, #raw / perTile - 1 do + local pixels = decode(raw, tile) + if not pixels then return nil, "tile " .. tile .. " runs past the data" end + out[tile + 1] = pixels + end + return out +end + +function SnesGfx.sheetFormat(size) + if size % 24 == 0 and size >= 24 then return 3, size / 24 end + if size % 16 == 0 and size >= 16 then return 2, size / 16 end + return nil +end + +function SnesGfx.decodeSheet(raw) + local bits, tiles = SnesGfx.sheetFormat(#raw) + if not bits then return nil, "sheet is not a whole number of tiles" end + local decode = bits == 3 and SnesGfx.decodeTile3bpp or SnesGfx.decodeTile2bpp + local out = {} + for tile = 0, tiles - 1 do + local pixels = decode(raw, tile) + if not pixels then return nil, "tile " .. tile .. " runs past the sheet" end + out[tile + 1] = pixels + end + return out, bits +end + +return SnesGfx diff --git a/src/mods/AssetPacks.lua b/src/mods/AssetPacks.lua new file mode 100644 index 00000000..8d91b8d0 --- /dev/null +++ b/src/mods/AssetPacks.lua @@ -0,0 +1,126 @@ +local Importers = require("src.import.Importers") +local SaveData = require("src.core.SaveData") + +local AssetPacks = {} + +AssetPacks.MAX_READ_BYTES = Importers.MAX_ENTRY_BYTES + +local function specs(manifest) + local out = {} + for _, list in ipairs({ (manifest and manifest.required_assets) or {}, + (manifest and manifest.optional_assets) or {} }) do + for _, spec in ipairs(list) do + out[spec.importer .. "/" .. spec.pack] = spec + end + end + return out +end + +AssetPacks.specs = function(manifest) + local out = {} + for _, spec in ipairs((manifest and manifest.required_assets) or {}) do + out[#out + 1] = spec + end + for _, spec in ipairs((manifest and manifest.optional_assets) or {}) do + out[#out + 1] = spec + end + return out +end + +local function copyEntry(id, entry) + return { id = id, file = entry.file, size = entry.size, + width = entry.width, height = entry.height, frames = entry.frames, + seconds = entry.seconds, note = entry.note } +end + +function AssetPacks.new(manifest, fs) + local declared = specs(manifest) + local packFs = SaveData.persistenceFs(fs) or fs + local api = {} + + local function specFor(importerId, packId) + local spec = declared[tostring(importerId) .. "/" .. tostring(packId)] + if not spec then + return nil, ("undeclared asset pack: %s/%s") + :format(tostring(importerId), tostring(packId)) + end + return spec + end + + local function resolved(importerId, packId) + local spec, err = specFor(importerId, packId) + if not spec then return nil, err end + return Importers.resolve(spec, packFs) + end + + function api:list() + local out = {} + for _, spec in ipairs(AssetPacks.specs(manifest)) do + local pack = Importers.resolve(spec, packFs) + out[#out + 1] = { + importer = spec.importer, + pack = spec.pack, + version = pack and pack.version or nil, + kind = pack and pack.kind or nil, + required = spec.required ~= false, + installed = pack ~= nil, + } + end + return out + end + + function api:info(importerId, packId) + local pack, err = resolved(importerId, packId) + if not pack then return nil, err end + local count = 0 + for _ in pairs(pack.entries) do count = count + 1 end + return { importer = pack.importer, pack = pack.pack, kind = pack.kind, + version = pack.version, entries = count, source = pack.source.name } + end + + function api:entries(importerId, packId) + local pack, err = resolved(importerId, packId) + if not pack then return nil, err end + local out = {} + for id, entry in pairs(pack.entries) do + out[#out + 1] = copyEntry(id, entry) + end + table.sort(out, function(a, b) return a.id < b.id end) + return out + end + + function api:entry(importerId, packId, entryId) + local pack, err = resolved(importerId, packId) + if not pack then return nil, err end + local entry = pack.entries[entryId] + if not entry then + return nil, "no such pack entry: " .. tostring(entryId) + end + return copyEntry(entryId, entry) + end + + function api:read(importerId, packId, entryId) + local pack, err = resolved(importerId, packId) + if not pack then return nil, err end + local entry = pack.entries[entryId] + if not entry then + return nil, "no such pack entry: " .. tostring(entryId) + end + if entry.size > AssetPacks.MAX_READ_BYTES then + return nil, "pack entry exceeds the 8 MiB read limit" + end + local path, pathErr = Importers.assetPath(pack.importer, pack.pack, entry.file) + if not path then return nil, pathErr end + if not (packFs and packFs.read) then + return nil, "pack reads are unavailable" + end + local bytes = packFs.read(path) + if type(bytes) ~= "string" then return nil, "pack entry is missing" end + if #bytes ~= entry.size then return nil, "pack entry size does not match" end + return bytes + end + + return api +end + +return AssetPacks diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 82cb3b76..eff0c25f 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -869,6 +869,17 @@ function Loader:_validate() end end end + if not reason and #(manifest.required_assets or {}) > 0 then + local Importers = engineRequire("src.import.Importers") + for _, spec in ipairs(Importers and manifest.required_assets or {}) do + local pack, packErr = Importers.resolve(spec, self.fs) + if not pack then + reason = "required asset pack unavailable: " .. tostring(packErr) + .. " -- import it from the launcher's IMPORTERS tab" + break + end + end + end if not reason and manifest.game_version and not devEngine() then local ok, err = Semver.satisfies(Version.engine, manifest.game_version) if not ok then @@ -1245,6 +1256,8 @@ function Loader:_api(mod) local Checkpoint = engineRequire("src.core.Checkpoint") local ImportAccess = engineRequire("src.mods.ImportAccess") local importApi, installCache = ImportAccess.new(mod.manifest, loader.fs) + local AssetPacks = engineRequire("src.mods.AssetPacks") + local packApi = AssetPacks and AssetPacks.new(mod.manifest, loader.fs) local api = { id = modId, version = mod.manifest.version, @@ -1460,6 +1473,7 @@ function Loader:_api(mod) -- Read-only bounded access to this mod's manifest-declared, launcher-validated -- imports. No host path is exposed; large sources are read in bounded ranges. imports = importApi, + packs = packApi, -- Installation-scoped generated data, independent from Pokémon save slots. -- This is where ROM-derived caches belong; mod.storage remains playthrough-scoped. cache = installCache, diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index 865b9f03..c394e1f6 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -217,6 +217,35 @@ local function parseImports(value, field, required) return out end +local function parseAssetPacks(value, field, required) + local out, seen = {}, {} + for _, entry in ipairs(array(value)) do + assert(type(entry) == "table", field .. " entries must be objects") + local importer, pack = entry.importer, entry.pack + assert(type(importer) == "string" and importer:match("^[%l%d_%-]+$"), + field .. " importer must be lowercase letters, numbers, _ or -") + assert(type(pack) == "string" and pack:match("^[%l%d_%-]+$"), + field .. " pack must be lowercase letters, numbers, _ or -") + local key = importer .. "/" .. pack + assert(not seen[key], "duplicate " .. field .. " entry: " .. key) + seen[key] = true + local range = entry.version + if range ~= nil then + assert(type(range) == "string" and range ~= "", + field .. " version must be a semver range string") + local ok, err = Semver.validRange(range) + assert(ok, field .. " version range is malformed: " .. tostring(err)) + end + out[#out + 1] = { + importer = importer, + pack = pack, + version = range, + required = required ~= false, + } + end + return out +end + -- Drop bytes that are not valid UTF-8 (malformed sequences, overlongs, -- surrogates, > U+10FFFF) and a leading BOM. LÖVE's text renderer raises -- "Invalid UTF-8" from love.graphics.print/printf, so any manifest string a @@ -397,6 +426,18 @@ function Manifest.validate(raw, path) "required_imports", true) local optionalImports = parseImports(raw.optional_imports, "optional_imports", false) + local requiredAssets = parseAssetPacks(raw.required_assets, + "required_assets", true) + local optionalAssets = parseAssetPacks(raw.optional_assets, + "optional_assets", false) + local assetKeys = {} + for _, list in ipairs({ requiredAssets, optionalAssets }) do + for _, spec in ipairs(list) do + local key = spec.importer .. "/" .. spec.pack + assert(not assetKeys[key], "duplicate asset pack: " .. key) + assetKeys[key] = true + end + end local importIds, importFiles = {}, {} for _, list in ipairs({ requiredImports, optionalImports }) do for _, import in ipairs(list) do @@ -437,6 +478,8 @@ function Manifest.validate(raw, path) assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"), required_imports = requiredImports, optional_imports = optionalImports, + required_assets = requiredAssets, + optional_assets = optionalAssets, -- an env var name, not a path, so it keeps the plain string check force_enable_env = optionalString(raw.force_enable_env, "force_enable_env"), path = path, diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 37615e58..a5e2115e 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -1229,6 +1229,27 @@ R.sprites = { -- top-left in pixels (default: bottom-center). frameWidth = f.opt(f.int(1)), frameHeight = f.opt(f.int(1)), + -- how many frames to skip from the start of the sheet, for art whose + -- usable frames do not begin at index 0 + frameOffset = f.opt(f.int(0)), + -- frames per sheet row; 1 (the default) is the vanilla vertical strip + frameColumns = f.opt(f.int(1)), + -- Multi-part frames: `cells` is one list per animation frame, and each + -- cell blits a piece of the sheet at an offset inside the frame. A sprite + -- whose parts move independently (a head over a body) is assembled here + -- rather than being baked into the sheet. cellWidth/cellHeight give the + -- piece size and cellColumns how many pieces a sheet row holds, so `tile` + -- is an index into that grid. + cells = f.opt(f.list(f.list(f.rec{ + tile = f.int(0), + dx = f.opt(f.int(-256, 256)), + dy = f.opt(f.int(-256, 256)), + flipX = f.opt(f.bool), + flipY = f.opt(f.bool), + }))), + cellWidth = f.opt(f.int(1)), + cellHeight = f.opt(f.int(1)), + cellColumns = f.opt(f.int(1)), anchorX = f.opt(f.num), anchorY = f.opt(f.num), trueColor = f.opt(f.bool), diff --git a/src/render/SpriteRenderer.lua b/src/render/SpriteRenderer.lua index 2b74ea69..9e48effe 100644 --- a/src/render/SpriteRenderer.lua +++ b/src/render/SpriteRenderer.lua @@ -145,12 +145,53 @@ function SpriteRenderer.new(spriteDef, seed) self.anchorX = numberOr(spriteDef.anchorX, self.frameWidth / 2) self.anchorY = numberOr(spriteDef.anchorY, self.frameHeight) local iw, ih = self.image:getDimensions() + -- Vanilla sheets are a single column of frames, so both default to the + -- original arithmetic. Grid sheets (imported art) set frameColumns to + -- walk across a row before stepping down, and frameOffset to start past + -- whatever leads the sheet. + self.frameColumns = positiveInteger(spriteDef.frameColumns, 1) + self.frameOffset = math.max(0, math.floor(numberOr(spriteDef.frameOffset, 0))) self.frames = {} for f = 0, self.frameCount - 1 do - self.frames[f] = love.graphics.newQuad(0, f * self.frameHeight, + local index = f + self.frameOffset + local col = index % self.frameColumns + local row = math.floor(index / self.frameColumns) + self.frames[f] = love.graphics.newQuad(col * self.frameWidth, + row * self.frameHeight, self.frameWidth, self.frameHeight, iw, ih) end + + -- Multi-part frames: each cell is a piece of the sheet placed at an offset + -- inside the frame, so parts that move independently stay separate art. + if type(spriteDef.cells) == "table" and #spriteDef.cells > 0 then + local cw = positiveInteger(spriteDef.cellWidth, 8) + local ch = positiveInteger(spriteDef.cellHeight, 8) + local cols = positiveInteger(spriteDef.cellColumns, + math.max(1, math.floor(iw / cw))) + self.cellFrames = {} + for f = 0, self.frameCount - 1 do + local spec = spriteDef.cells[f + 1] + if type(spec) == "table" and #spec > 0 then + local built = {} + for i, cell in ipairs(spec) do + local index = math.max(0, math.floor(finiteNumber(cell.tile) or 0)) + local col = index % cols + local row = math.floor(index / cols) + built[i] = { + quad = love.graphics.newQuad(col * cw, row * ch, cw, ch, iw, ih), + dx = math.floor(numberOr(cell.dx, 0)), + dy = math.floor(numberOr(cell.dy, 0)), + flipX = cell.flipX == true, + flipY = cell.flipY == true, + w = cw, h = ch, + sourceX = col * cw, sourceY = row * ch, + } + end + self.cellFrames[f] = built + end + end + end return self end @@ -276,6 +317,55 @@ local function blitFrame(image, quad, x, y, flip, redraw, frameWidth) end end +-- Composite a multi-part frame. A whole-frame `flip` mirrors the layout as +-- well as each piece, so a facing built by mirroring another lands its parts +-- on the correct side. +-- Where a cell lands when the whole frame is mirrored, and whether the piece +-- itself is then drawn flipped. +function SpriteRenderer.mirrorCell(dx, width, frameWidth, flipX) + return frameWidth - dx - width, not flipX +end + +local function blitCells(image, cells, x, y, flip, redraw, frameWidth) + for _, c in ipairs(cells) do + local dx, flipX = c.dx, c.flipX + if flip then + dx, flipX = SpriteRenderer.mirrorCell(c.dx, c.w, frameWidth, c.flipX) + end + local sx = flipX and -1 or 1 + local sy = c.flipY and -1 or 1 + local ox = flipX and c.w or 0 + local oy = c.flipY and c.h or 0 + love.graphics.draw(image, c.quad, x + dx + ox, y + c.dy + oy, 0, sx, sy) + if redraw then + PaletteFX.markSpriteRedraw(image, c.quad, x + dx + ox, y + c.dy + oy, sx) + end + end +end + +local function clippedCells(self, frame, first, last) + self.clippedCells = self.clippedCells or {} + local key = frame .. ":" .. first .. ":" .. last + if self.clippedCells[key] then return self.clippedCells[key] end + local result = {} + local iw, ih = self.image:getDimensions() + for _, cell in ipairs(self.cellFrames[frame]) do + local top = math.max(first, cell.dy) + local bottom = math.min(last, cell.dy + cell.h) + if bottom > top then + local sourceY = cell.flipY and cell.h - (bottom - cell.dy) or top - cell.dy + result[#result + 1] = { + quad = love.graphics.newQuad(cell.sourceX, cell.sourceY + sourceY, + cell.w, bottom - top, iw, ih), + dx = cell.dx, dy = top - first, w = cell.w, h = bottom - top, + flipX = cell.flipX, flipY = cell.flipY, + } + end + end + self.clippedCells[key] = result + return result +end + -- topHalf blits everything above the bottom 8-pixel tile row: FishingAnim -- overwrites that row of the standing frames with fishing pose art, which the -- caller then draws itself through :drawTile (Player:draw, #384). Vanilla @@ -346,6 +436,22 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, local bottomSkip = math.max(0, self.frameHeight - rowH) if oamRow == "bottom" then topHalf = false elseif oamRow == "top" then topHalf = true end + local cells = self.cellFrames and self.cellFrames[frame] + if cells then + local first, last = 0, self.frameHeight + if topHalf then last = bottomSkip + elseif oamRow == "bottom" then first = bottomSkip end + local x, y = self:getScreenOrigin(px, py, camX, camY) + y = y + first + if liveTrueColor(self.def) then + PaletteFX.markTrueColor(x, y, self.frameWidth, last - first) + end + if first ~= 0 or last ~= self.frameHeight then + cells = clippedCells(self, frame, first, last) + end + blitCells(image, cells, x, y, flip, redraw, self.frameWidth) + return + 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 diff --git a/tests/engine/crystal_version_test.lua b/tests/engine/crystal_version_test.lua index b4ca44c3..e79739ae 100644 --- a/tests/engine/crystal_version_test.lua +++ b/tests/engine/crystal_version_test.lua @@ -250,8 +250,8 @@ repeat fake:_cycleTab(1) cycle = cycle + 1 until fake.tab == "crystal" or cycle > 40 -eq(cycle, #GameVersion.ORDER + 3, - "the ring is the six games plus mods/find/skins") +eq(cycle, #GameVersion.ORDER + 4, + "the ring is the six games plus mods/find/skins/importers") fake.tab = "crystal" fake:_cycleTab(-1) diff --git a/tests/engine/importers_asset_packs.lua b/tests/engine/importers_asset_packs.lua new file mode 100644 index 00000000..243f669e --- /dev/null +++ b/tests/engine/importers_asset_packs.lua @@ -0,0 +1,195 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Importers = require("src.import.Importers") +local Manifest = require("src.mods.Manifest") +local LuaWriter = require("src.import.LuaWriter") + +for _, desc in ipairs(Importers.all()) do + local ok, err = Importers.validateDescriptor(desc) + T.check(ok, "shipped importer " .. tostring(desc.id) .. " validates: " + .. tostring(err)) +end + +local lttp = Importers.get("lttp") +T.check(lttp ~= nil, "A Link to the Past is registered") +T.eq(lttp.status, "beta", "and its graphics side is runnable") +T.check(Importers.pack("lttp", "sprites") ~= nil, "it exports a sprite pack") +T.check(Importers.pack("lttp", "sfx").planned, + "its audio packs are still declared but not produced") +T.check(not Importers.pack("lttp", "palettes").planned, + "while the palette pack is real") + +T.eq(Importers.packRoot("lttp", "sprites"), "asset_packs/lttp/sprites", + "packs live under their own root, clear of the NX imports/ inbox") + +local function packManifest(overrides) + local out = { + format = Importers.PACK_FORMAT, + importer = "lttp", + pack = "sprites", + kind = "sprite", + version = "1.2.0", + source = { name = "A Link to the Past (USA)", + md5 = "777aac2f7b26c0b0d67d2b1bf5d4ecb0", size = 1048576 }, + entries = { + ["link/walk_down"] = { file = "link/walk_down.png", size = 4, + width = 16, height = 24, frames = 4 }, + }, + } + for key, value in pairs(overrides or {}) do out[key] = value end + return out +end + +do + local ok = Importers.validatePack(packManifest(), "lttp", "sprites") + T.check(ok, "a well-formed pack manifest validates") + + local badKind = packManifest({ kind = "voxels" }) + T.check(not Importers.validatePack(badKind), "an unknown asset kind is refused") + + local badExt = packManifest({ entries = { + ["link/walk_down"] = { file = "link/walk_down.exe", size = 4 } } }) + T.check(not Importers.validatePack(badExt), + "a sprite pack refuses a file that is not an image") + + local badVersion = packManifest({ version = "one" }) + T.check(not Importers.validatePack(badVersion), "pack versions must be semver") + + local escape = packManifest({ entries = { + ["link/walk_down"] = { file = "../../escape.png", size = 4 } } }) + T.check(not Importers.validatePack(escape), "a pack entry cannot climb out") + + local wrongPack = Importers.validatePack(packManifest(), "lttp", "tiles") + T.check(not wrongPack, "a manifest cannot claim a pack it was not written for") +end + +local files = { + ["mods/pack_probe/manifest.json"] = [[{ + "id": "pack_probe", + "name": "Pack Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 2, + "required_assets": [{ "importer": "lttp", "pack": "sprites", + "version": ">=1.0.0" }], + "optional_assets": [{ "importer": "lttp", "pack": "music" }] + }]], + ["mods/pack_probe/main.lua"] = [[ + local mod = ... + local info, infoErr = mod.packs:info("lttp", "sprites") + local entries = mod.packs:entries("lttp", "sprites") + local bytes, bytesErr = mod.packs:read("lttp", "sprites", "link/walk_down") + local missing, missingErr = mod.packs:info("lttp", "music") + local undeclared, undeclaredErr = mod.packs:read("lttp", "tiles", "x") + mod.exports.result = { + info = info, infoErr = infoErr, + entries = entries, + bytes = bytes, bytesErr = bytesErr, + missing = missing, missingErr = missingErr, + undeclared = undeclared, undeclaredErr = undeclaredErr, + list = mod.packs:list(), + } + ]], + ["asset_packs/lttp/sprites/pack.lua"] = LuaWriter.encode(packManifest()), + ["asset_packs/lttp/sprites/link/walk_down.png"] = "PNG!", +} + +do + local base = { id = "pack_probe", name = "Pack Probe", version = "1.0.0", + entry = "main.lua", api = 2 } + local function withAssets(required, optional) + local raw = {} + for k, v in pairs(base) do raw[k] = v end + raw.required_assets, raw.optional_assets = required, optional + return raw + end + + local parsed = Manifest.validate(withAssets( + { { importer = "lttp", pack = "sprites", version = ">=1.0.0" } }, + { { importer = "lttp", pack = "music" } }), "mods/pack_probe") + T.eq(#parsed.required_assets, 1, "the manifest declares one required pack") + T.eq(parsed.required_assets[1].version, ">=1.0.0", "with its semver range") + T.eq(parsed.optional_assets[1].required, false, "and one optional pack") + + T.check(not pcall(Manifest.validate, withAssets( + { { importer = "lttp", pack = "sprites" } }, + { { importer = "lttp", pack = "sprites" } }), "mods/d"), + "the same pack cannot be both required and optional") + + T.check(not pcall(Manifest.validate, withAssets( + { { importer = "lttp", pack = "sprites", version = "wat" } }), "mods/b"), + "a malformed version range is refused at parse time") + + T.check(not pcall(Manifest.validate, withAssets( + { { importer = "LTTP", pack = "sprites" } }), "mods/c"), + "importer ids stay lowercase") +end + +local fs = T.sdk.memfs(files) + +do + local roundtrip = Importers.readPack("lttp", "sprites", fs) + T.check(roundtrip ~= nil, "an installed pack reads back") + T.eq(roundtrip and roundtrip.version, "1.2.0", "with its export version") + T.eq(Importers.installed("lttp", fs).sprites.entries, 1, + "and is reported as installed with its entry count") + T.eq(Importers.state("lttp", fs), "partial", + "one installed pack of three reads as partial") + + local resolved, err = Importers.resolve( + { importer = "lttp", pack = "sprites", version = ">=2.0.0" }, fs) + T.eq(resolved, nil, "a range the installed pack misses does not resolve") + T.check(type(err) == "string" and err:find("1.2.0", 1, true), + "and the refusal names the version that is installed") +end + +do + local run = T.sdk.loadMods({ "mods/pack_probe" }, { fs = fs }) + T.eq(#run.errors, 0, "a mod whose required pack is installed loads") + local out = run.loader.exports.pack_probe.result + T.eq(out.info and out.info.kind, "sprite", "mod.packs:info reports the kind") + T.eq(out.info and out.info.entries, 1, "and the entry count") + T.eq(#out.entries, 1, "mod.packs:entries lists the pack") + T.eq(out.entries[1].width, 16, "with the metadata the importer recorded") + T.eq(out.bytes, "PNG!", "mod.packs:read returns the exported bytes") + T.eq(out.missing, nil, "an optional pack that is not installed is not exposed") + T.check(type(out.missingErr) == "string", "and says so") + T.eq(out.undeclared, nil, "an undeclared pack is refused") + T.check(type(out.undeclaredErr) == "string" + and out.undeclaredErr:find("undeclared", 1, true), + "with an explicit refusal") + T.eq(#out.list, 2, "mod.packs:list answers for every declared pack") + run.release() +end + +do + local gated = {} + for key, value in pairs(files) do gated[key] = value end + gated["asset_packs/lttp/sprites/pack.lua"] = nil + local run = T.sdk.loadMods({ "mods/pack_probe" }, + { fs = T.sdk.memfs(gated) }) + local mod = run.loader.mods.pack_probe + T.eq(mod and mod.state, "invalid", + "a mod whose required pack is missing is gated, not half-loaded") + T.check(mod and type(mod.failure) == "string" + and mod.failure:find("IMPORTERS", 1, true), + "and the reason points at the tab that would fix it") + T.eq(run.loader.exports.pack_probe, nil, "its entry chunk never ran") + run.release() +end + +do + local out = {} + local memfs = T.sdk.memfs(out) + local ok = Importers.writePack("lttp", "sprites", packManifest(), memfs) + T.check(ok, "an importer can write a pack manifest through the contract") + T.check(out["asset_packs/lttp/sprites/pack.lua"] ~= nil, + "at the path the readers look in") + local bad, reason = Importers.writePack("lttp", "sprites", + packManifest({ kind = "voxels" }), memfs) + T.eq(bad, nil, "an invalid manifest is never written") + T.check(type(reason) == "string", "and the writer says why") +end + +T.finish("importers_asset_packs") diff --git a/tests/engine/launcher_sync_modal.lua b/tests/engine/launcher_sync_modal.lua index 6e298cbe..d2efd950 100644 --- a/tests/engine/launcher_sync_modal.lua +++ b/tests/engine/launcher_sync_modal.lua @@ -379,14 +379,18 @@ local view = read("src/import/LauncherView.lua") local impSrc = read("src/import/RomImporter.lua") check(view:find('"tab-sync"', 1, true) ~= nil, - "the header tab row carries a Save Sync button") + "the header carries a Save Sync button") local header = view:match("local HEADER_TABS = %{(.-)%}\n") check(header and header:find('id = "skins"', 1, true) ~= nil, - "and it sits beside the skins tab") + "the skins tab is still in the tab row") check(header and header:find("beta = true", 1, true) ~= nil, "the skins tab carries a BETA badge too") -local syncTab = view:match('btn%(imp, tx, ty, w, tabH, "tab%-sync", "", o%)\n(.-)\n') -check(syncTab and not syncTab:find("overlayBeta", 1, true), +check(header and not header:find("tab-sync", 1, true), + "Save Sync is not one of the per-tab chips") +check(view:find('btn(imp, rx, by, gear, gear, "tab-sync", "", chrome.sync)', + 1, true) ~= nil, + "it rides the top-level cluster beside the settings gear") +check(view:find("overlayBeta(rx", 1, true) == nil, "the Save Sync button carries no BETA badge") local title = view:match("local function syncTitle%(.-\nend") check(title and not title:find("Beta", 1, true), diff --git a/tests/engine/lttp_codec.lua b/tests/engine/lttp_codec.lua new file mode 100644 index 00000000..8797f5a6 --- /dev/null +++ b/tests/engine/lttp_codec.lua @@ -0,0 +1,103 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Lz2 = require("src.import.lttp.Lz2") +local SnesGfx = require("src.import.lttp.SnesGfx") +local LttpImport = require("src.import.lttp.LttpImport") + +local function bytes(...) + local out = {} + for _, v in ipairs({ ... }) do out[#out + 1] = string.char(v) end + return table.concat(out) +end + +do + local stream = bytes(0x02, 0x41, 0x42, 0x43, 0xFF) + local out = Lz2.decompress(stream, 0) + T.eq(out, "ABC", "direct copy emits the literal run") + + out = Lz2.decompress(bytes(0x23, 0xAB, 0xFF), 0) + T.eq(#out, 4, "byte fill emits length+1 bytes") + T.eq(out, string.rep(string.char(0xAB), 4), "and every byte is the fill") + + out = Lz2.decompress(bytes(0x44, 0x11, 0x22, 0xFF), 0) + T.eq(out, bytes(0x11, 0x22, 0x11, 0x22, 0x11), + "word fill alternates the pair and stops mid-pair") + + out = Lz2.decompress(bytes(0x63, 0x10, 0xFF), 0) + T.eq(out, bytes(0x10, 0x11, 0x12, 0x13), "increasing fill counts up") + + out = Lz2.decompress(bytes(0x63, 0xFE, 0xFF), 0) + T.eq(out, bytes(0xFE, 0xFF, 0x00, 0x01), "increasing fill wraps at 0xFF") + + -- write "ABCD", then copy 2 bytes back from output offset 1 + out = Lz2.decompress(bytes(0x03, 0x41, 0x42, 0x43, 0x44, 0x81, 0x01, 0x00, 0xFF), 0) + T.eq(out, "ABCDBC", "a back reference copies from the output at its offset") + + -- long form: command 000, length 300 + local long = bytes(0xE1, 43) .. string.rep("Z", 300) .. bytes(0xFF) + out = Lz2.decompress(long, 0) + T.eq(#out, 300, "the long-length escape carries a 10-bit length") + + T.eq(Lz2.decompress(bytes(0x05, 0x41), 0), nil, + "a direct copy running past the end is refused") + T.eq(Lz2.decompress(bytes(0x81, 0x10, 0x00, 0xFF), 0), nil, + "a back reference past the written output is refused") + T.eq(Lz2.decompress(bytes(0x00, 0x41), 0), nil, + "a stream with no terminator is refused") + local capped = select(2, Lz2.decompress(bytes(0x23, 0xAB, 0xFF), 0, 2)) + T.check(type(capped) == "string" and capped:find("exceeded", 1, true), + "the output cap is enforced") +end + +do + T.eq(SnesGfx.loRomOffset(0x00, 0xB8, 0x11), 0x3811, + "a bank 0 pointer maps into the first half bank") + T.eq(SnesGfx.loRomOffset(0x11, 0xB8, 0x11), 0x08B811, + "LoROM drops the high bit of the bank and the $8000 window") + T.eq(SnesGfx.loRomOffset(0x00, 0x00, 0x10), nil, + "an address below $8000 is not a LoROM pointer") + + local r, g, b = SnesGfx.color(0x7FFF) + T.eq(math.floor(r + 0.5) .. "," .. math.floor(g + 0.5) .. "," .. math.floor(b + 0.5), + "255,255,255", "BGR555 white expands to white") + r, g, b = SnesGfx.color(0x0000) + T.eq(r + g + b, 0, "BGR555 zero is black") + r, g, b = SnesGfx.color(0x001F) + T.eq(math.floor(r + 0.5) .. "," .. math.floor(g) .. "," .. math.floor(b), + "255,0,0", "the low five bits are red, so the channel order is BGR") +end + +do + -- one 3bpp tile: row 0 all index 7, row 1 all index 1, rows 2-7 index 0 + local planes01 = bytes(0xFF, 0xFF) .. bytes(0xFF, 0x00) .. string.rep(bytes(0, 0), 6) + local plane2 = bytes(0xFF) .. string.rep(bytes(0), 7) + local tile = SnesGfx.decodeTile3bpp(planes01 .. plane2, 0) + T.eq(tile[1], 7, "3bpp row 0 reads all three planes") + T.eq(tile[8], 7, "across the whole row") + T.eq(tile[9], 1, "row 1 uses plane 0 only") + T.eq(tile[17], 0, "and an empty row is index 0") + + local two = SnesGfx.decodeTile2bpp(bytes(0xAA, 0x00) .. string.rep(bytes(0, 0), 7), 0) + T.eq(two[1], 1, "2bpp picks up plane 0 at the high bit") + T.eq(two[2], 0, "and the alternating bit is clear") + + local bits, tiles = SnesGfx.sheetFormat(0x600) + T.eq(bits, 3, "a 0x600 sheet is 3bpp") + T.eq(tiles, 64, "with 64 tiles") + bits, tiles = SnesGfx.sheetFormat(2048) + T.eq(bits, 2, "a 2048-byte sheet is 2bpp") + T.eq(tiles, 128, "with 128 tiles") + T.eq(SnesGfx.sheetFormat(7), nil, "a stray length is neither") +end + +do + T.eq(LttpImport.identify("not a rom"), nil, "a foreign blob is not accepted") + T.check(LttpImport.isRaw(115) and LttpImport.isRaw(126), + "the uncompressed sheet band covers 115 through 126") + T.check(not LttpImport.isRaw(114) and not LttpImport.isRaw(127), + "and stops either side of it") + T.eq(LttpImport.RAW_SIZE, 0x600, "a raw sheet is one 3bpp sheet wide") +end + +T.finish("lttp_codec") diff --git a/tests/engine/sprite_multicell.lua b/tests/engine/sprite_multicell.lua new file mode 100644 index 00000000..ab79da9f --- /dev/null +++ b/tests/engine/sprite_multicell.lua @@ -0,0 +1,92 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") + +local quads = {} +local realLove = _G.love +_G.love = _G.love or {} +local realGraphics = love.graphics +love.graphics = { + newQuad = function(x, y, w, h, iw, ih) + local q = { x = x, y = y, w = w, h = h, iw = iw, ih = ih } + quads[#quads + 1] = q + return q + end, + draw = function() end, + setColor = function() end, + getColor = function() return 1, 1, 1, 1 end, +} + +local Assets = require("src.render.Assets") +local realImage = Assets.image +Assets.image = function() + return { getDimensions = function() return 128, 64 end } +end + +local SpriteRenderer = require("src.render.SpriteRenderer") +SpriteRenderer.invalidate() + +do + local def = { + image = "fake.png", frames = 2, + frameWidth = 16, frameHeight = 16, + cellWidth = 8, cellHeight = 8, cellColumns = 16, + cells = { + { { tile = 0, dx = 0, dy = 0 }, { tile = 17, dx = 8, dy = 8 } }, + { { tile = 3, dx = 4, dy = 2, flipX = true } }, + }, + } + local r = SpriteRenderer.new(def, 1) + T.check(r.cellFrames ~= nil, "a sprite with cells builds composite frames") + + local first = r.cellFrames[0] + T.eq(#first, 2, "frame 0 keeps both of its pieces") + T.eq(first[1].quad.x, 0, "cell tile 0 sits at column 0") + T.eq(first[1].quad.y, 0, "and row 0") + -- tile 17 over 16 columns of 8px = column 1, row 1 + T.eq(first[2].quad.x, 8, "cell tile 17 wraps to column 1") + T.eq(first[2].quad.y, 8, "and steps down a row") + T.eq(first[2].dx, 8, "the piece keeps its offset inside the frame") + T.eq(first[2].dy, 8, "on both axes") + T.eq(first[1].w, 8, "pieces are cellWidth wide") + + local second = r.cellFrames[1] + T.eq(#second, 1, "a frame may hold a single piece") + T.eq(second[1].flipX, true, "a per-cell flip is carried through") + T.eq(second[1].quad.x, 24, "cell tile 3 is column 3") +end + +do + -- mirroring a frame has to move each piece to the opposite side AND flip it, + -- or a mirrored facing assembles inside-out + local dx, flip = SpriteRenderer.mirrorCell(0, 8, 16, false) + T.eq(dx, 8, "a left-hand piece mirrors to the right") + T.eq(flip, true, "and is drawn flipped") + + dx, flip = SpriteRenderer.mirrorCell(8, 8, 16, false) + T.eq(dx, 0, "a right-hand piece mirrors to the left") + + dx, flip = SpriteRenderer.mirrorCell(4, 8, 16, true) + T.eq(dx, 4, "a centred piece stays centred") + T.eq(flip, false, "and an already-flipped piece flips back") + + local w = 16 + dx = SpriteRenderer.mirrorCell(0, w, w, false) + T.eq(dx, 0, "a full-width piece is unmoved by the mirror") +end + +do + local plain = SpriteRenderer.new({ + image = "fake.png", frames = 3, frameWidth = 16, frameHeight = 16, + }, 1) + T.eq(plain.cellFrames, nil, + "a sprite without cells keeps the single-quad path") + T.eq(plain.frameColumns, 1, "and the vanilla one-frame-per-row layout") + T.eq(plain.frameOffset, 0, "starting at the top of the sheet") +end + +Assets.image = realImage +love.graphics = realGraphics +if realLove == nil then _G.love = nil end + +T.finish("sprite_multicell")