This commit is contained in:
bryanthaboi
2026-08-19 06:41:10 -04:00
parent cb4647daf0
commit 63448ca640
8 changed files with 347 additions and 39 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
* **Persistent custom options** stored separately from game saves * **Persistent custom options** stored separately from game saves
* **Optional widescreen battle layout** * **Optional widescreen battle layout**
* **Mobile touch controls** with editable layouts, vibration, and orientation settings * **Mobile touch controls** with editable layouts, vibration, and orientation settings
* **Touch skins** in RetroArch overlay format, with bezel art, per-button press states, and Super Game Boy borders * **Touch skins** in RetroArch overlay format and Delta `.deltaskin` (including PDF-wrapped bezel art), with per-button press states and Super Game Boy borders
* **Pokédex diploma and printer image exports** * **Pokédex diploma and printer image exports**
## Gen 2 Specifics ## Gen 2 Specifics
+14 -9
View File
@@ -95,18 +95,22 @@ auto-rotates like a RetroArch one. Item `frame` rects are top-left plus size in
`extendedEdges` merge per key into the reach fields; `mask: "circle"` becomes a `extendedEdges` merge per key into the reach fields; `mask: "circle"` becomes a
radial hitbox. A `dpad` or `thumbstick` item expands into the 3x3 grid, so the radial hitbox. A `dpad` or `thumbstick` item expands into the 3x3 grid, so the
corners fire two directions. `screens[1].outputFrame` (or the legacy corners fire two directions. `screens[1].outputFrame` (or the legacy
`gameScreenFrame`) becomes the screen cutout, and the skin stretches to the `gameScreenFrame`) becomes the screen cutout. A portrait page with neither
window the way Delta does rather than letterboxing. Host functions map to keeps `mappingSize` as the overlay aspect, sits at the bottom of the
window, and puts the Game Boy picture in the leftover space above -- the
usual GBA4iOS controller-deck layout. Pages that name a screen rect still
stretch to the window the way Delta does. Host functions map to
engine hotkeys: `menu` to `menu_toggle`, `fastForward` to engine hotkeys: `menu` to `menu_toggle`, `fastForward` to
`hold_fast_forward`, `toggleFastForward` to `toggle_fast_forward`; `hold_fast_forward`, `toggleFastForward` to `toggle_fast_forward`;
`quickSave` and `quickLoad` have nothing to bind to and drop to decoration. `quickSave` and `quickLoad` have nothing to bind to and drop to decoration.
Both `com.rileytestut.delta.game.*` and Manic's `public.aoshuang.game.*` Both `com.rileytestut.delta.game.*` and Manic's `public.aoshuang.game.*`
identifiers are accepted, and a non Game Boy system warns instead of failing. identifiers are accepted, and a non Game Boy system warns instead of failing.
PDF artwork is the one thing that does not come across: Delta's own templates PDF artwork is usually a JPEG wrapped so iOS can scale it (Delta's
are all-PDF and this engine has no rasterizer, so such a skin is refused with Image-to-PDF skins, Preview exports, and the like). Import extracts that
the message asking for a PNG version. GBA4iOS `.gbcskin` / `.gbaskin` files are JPEG and draws it; a true vector PDF with no embedded image is still refused,
an older, incompatible schema and are refused by name. with a message asking for a PNG version. GBA4iOS `.gbcskin` / `.gbaskin` files
are an older, incompatible schema and are refused by name.
## Bindable actions ## Bindable actions
@@ -268,6 +272,7 @@ exported file** opens that folder.
## Not implemented ## Not implemented
Delta skins whose art is PDF only. Rasterizing them needs a PDF renderer this True vector Delta skins (PDF artwork with no embedded JPEG). Those still need
engine does not carry, so they are refused with a message rather than imported a PDF renderer this engine does not carry, so they are refused with a message
half-drawn. rather than imported half-drawn. PDF files that wrap a JPEG, the usual Delta
skin case, extract on import.
+21 -6
View File
@@ -116,11 +116,13 @@ function DeltaSkin.pickAsset(assets, opts, pdfFiles)
if type(assets) ~= "table" then return nil end if type(assets) ~= "table" then return nil end
pdfFiles = pdfFiles or {} pdfFiles = pdfFiles or {}
local raster = {} local raster = {}
local pdfName
for _, key in ipairs(DeltaSkin.ASSET_LADDER) do for _, key in ipairs(DeltaSkin.ASSET_LADDER) do
local name = pick(assets, key) local name = pick(assets, key)
if key == "medium" and type(name) ~= "string" then name = pick(assets, "normal") end if key == "medium" and type(name) ~= "string" then name = pick(assets, "normal") end
if type(name) == "string" and name ~= "" then if type(name) == "string" and name ~= "" then
if name:lower():match("%.pdf$") then if name:lower():match("%.pdf$") then
pdfName = name
pdfFiles[#pdfFiles + 1] = name pdfFiles[#pdfFiles + 1] = name
else else
raster[#raster + 1] = { key = key, name = name } raster[#raster + 1] = { key = key, name = name }
@@ -130,12 +132,14 @@ function DeltaSkin.pickAsset(assets, opts, pdfFiles)
local resizable = pick(assets, "resizable") local resizable = pick(assets, "resizable")
if type(resizable) == "string" and resizable ~= "" then if type(resizable) == "string" and resizable ~= "" then
if resizable:lower():match("%.pdf$") then if resizable:lower():match("%.pdf$") then
pdfName = resizable
pdfFiles[#pdfFiles + 1] = resizable pdfFiles[#pdfFiles + 1] = resizable
else else
raster[#raster + 1] = { key = "large", name = resizable } raster[#raster + 1] = { key = "large", name = resizable }
end end
end end
if #raster == 0 then return nil end local pdfPath = pdfName and DeltaSkin.resolveName(pdfName, opts) or nil
if #raster == 0 then return nil, pdfPath end
local target = numOr(opts and opts.targetWidth, DeltaSkin.DEFAULT_TARGET_WIDTH) local target = numOr(opts and opts.targetWidth, DeltaSkin.DEFAULT_TARGET_WIDTH)
local chosen local chosen
@@ -145,7 +149,7 @@ function DeltaSkin.pickAsset(assets, opts, pdfFiles)
end end
end end
if not chosen then chosen = raster[#raster].name end if not chosen then chosen = raster[#raster].name end
return DeltaSkin.resolveName(chosen, opts) return DeltaSkin.resolveName(chosen, opts), nil
end end
function DeltaSkin.mergeEdges(base, item) function DeltaSkin.mergeEdges(base, item)
@@ -288,10 +292,12 @@ function DeltaSkin.buildPage(obj, orient, opts, warnings, pdfFiles)
addWarning(warnings, orient .. " has no mappingSize; assuming 320x240") addWarning(warnings, orient .. " has no mappingSize; assuming 320x240")
end end
local imagePath, pdfPath = DeltaSkin.pickAsset(pick(obj, "assets"), opts, pdfFiles)
local page = { local page = {
name = orient, name = orient,
orient = orient, orient = orient,
imagePath = DeltaSkin.pickAsset(pick(obj, "assets"), opts, pdfFiles), imagePath = imagePath,
pdfPath = pdfPath,
fullScreen = true, fullScreen = true,
normalized = true, normalized = true,
pixelCoords = false, pixelCoords = false,
@@ -309,6 +315,14 @@ function DeltaSkin.buildPage(obj, orient, opts, warnings, pdfFiles)
if screen then if screen then
page.viewport = screen page.viewport = screen
page.viewportFill = false page.viewportFill = false
else
-- mappingSize is the overlay, not the device. Portrait controller
-- skins (GBA4iOS-era 320x240 decks, this Pikachu skin, etc.) keep
-- that aspect, sit at the bottom, and leave the leftover for the
-- Game Boy picture. A screens/gameScreenFrame rect still fills.
page.aspectFromCfg = true
page.screenFit = "remainder"
if orient == "portrait" then page.anchor = "bottom" end
end end
local baseEdges = pick(obj, "extendedEdges") local baseEdges = pick(obj, "extendedEdges")
@@ -368,9 +382,6 @@ function DeltaSkin.parse(text, opts)
end end
end end
if #pages == 0 then return nil, "info.json has no usable representation" end if #pages == 0 then return nil, "info.json has no usable representation" end
if #pdfFiles > 0 then
addWarning(warnings, "PDF artwork cannot be imported yet")
end
return { return {
pages = pages, pages = pages,
@@ -390,6 +401,10 @@ function DeltaSkin.needsConversion(skin)
local files = skin.pdfFiles local files = skin.pdfFiles
if type(files) ~= "table" or #files == 0 then return nil end if type(files) ~= "table" or #files == 0 then return nil end
for _, page in ipairs(skin.pages or {}) do for _, page in ipairs(skin.pages or {}) do
-- A raster asset, or a JPEG recovered from the PDF at load, means the
-- skin can draw. Parse-only callers still see pdfOnly because they
-- have not run extract yet.
if page.rasterData then return nil end
if page.imagePath then return nil end if page.imagePath then return nil end
end end
return { pdfOnly = true, files = files } return { pdfOnly = true, files = files }
+115
View File
@@ -0,0 +1,115 @@
-- Recover a raster from a PDF that is really a wrapped JPEG. Delta skins
-- ship artwork that way so iOS can scale it; LOVE has no PDF renderer, so
-- import pulls the embedded image out instead of refusing the skin. True
-- vector PDFs (no Image XObject, no JPEG) still fail.
local PdfImage = {}
local function isPdf(bytes)
return type(bytes) == "string" and bytes:sub(1, 5) == "%PDF-"
end
-- After the `stream` keyword the spec allows \n or \r\n before the bytes.
-- `endstream` also contains the letters "stream", so skip that match.
local function streamDataStart(bytes, from)
local s, e = bytes:find("stream", from, true)
while s do
if s == 1 or bytes:sub(s - 3, s - 1) ~= "end" then
local p = e + 1
if bytes:sub(p, p) == "\r" then p = p + 1 end
if bytes:sub(p, p) == "\n" then p = p + 1 end
return p, s
end
s, e = bytes:find("stream", e + 1, true)
end
return nil
end
local function dictWindow(bytes, imageAt)
local from = imageAt > 400 and (imageAt - 400) or 1
local to = math.min(#bytes, imageAt + 800)
return bytes:sub(from, to)
end
local function dictNumber(window, key)
-- Prefer an indirect ref so `/Length 5 0 R` is not read as length 5.
if window:find("/" .. key .. "%s+%d+%s+%d+%s+R") then return nil end
return tonumber(window:match("/" .. key .. "%s+(%d+)"))
end
local function dictFilter(window)
local named = window:match("/Filter%s*/(%w+)")
if named then return named end
return window:match("/Filter%s*%[%s*/(%w+)")
end
local function jpegIn(bytes, from, to)
if from < 1 then from = 1 end
if not to or to > #bytes then to = #bytes end
if to < from then return nil end
local region = bytes:sub(from, to)
local soi = region:find("\255\216\255", 1, true)
if not soi then return nil end
local eoi = region:find("\255\217", soi + 3, true)
if not eoi then return nil end
return region:sub(soi, eoi + 1)
end
local function candidate(data, width, height, ext)
if not data or data == "" then return nil end
return {
data = data,
ext = ext or "jpg",
width = width or 0,
height = height or 0,
}
end
local function bigger(a, b)
if not a then return b end
if not b then return a end
local as = (a.width or 0) * (a.height or 0)
local bs = (b.width or 0) * (b.height or 0)
if bs ~= as then return bs > as and b or a end
return #b.data > #a.data and b or a
end
-- Walk Image XObjects and take the largest DCTDecode (JPEG) stream.
local function fromImageXObjects(bytes)
local best
local i = 1
while true do
local s, e = bytes:find("/Subtype%s*/Image", i)
if not s then break end
local window = dictWindow(bytes, s)
local filter = dictFilter(window)
local width = dictNumber(window, "Width")
local height = dictNumber(window, "Height")
local dataStart = streamDataStart(bytes, e)
i = e + 1
if dataStart and filter == "DCTDecode" then
local es = bytes:find("endstream", dataStart, true)
local jpeg = jpegIn(bytes, dataStart, es and (es - 1) or nil)
best = bigger(best, candidate(jpeg, width, height, "jpg"))
end
end
return best
end
-- Image-to-PDF converters (3-Heights, Preview, etc.) leave a single JPEG
-- body even when /Length is an indirect object we do not resolve.
local function fromBareJpeg(bytes)
local jpeg = jpegIn(bytes, 1, #bytes)
if not jpeg then return nil end
return candidate(jpeg, 0, 0, "jpg")
end
function PdfImage.extract(bytes)
if not isPdf(bytes) then return nil, "not a pdf" end
local best = fromImageXObjects(bytes)
if not best then best = fromBareJpeg(bytes) end
if not best then return nil, "no extractable image" end
return best
end
return PdfImage
+105 -11
View File
@@ -432,6 +432,10 @@ function TouchSkin.parseNative(text)
aspectFromCfg = raw.fitAspect == true, aspectFromCfg = raw.fitAspect == true,
orient = (raw.orient == "portrait" or raw.orient == "landscape" orient = (raw.orient == "portrait" or raw.orient == "landscape"
or raw.orient == "any") and raw.orient or nil, or raw.orient == "any") and raw.orient or nil,
screenFit = raw.screenFit == "remainder" and "remainder" or nil,
anchor = (raw.anchor == "top" or raw.anchor == "bottom"
or raw.anchor == "left" or raw.anchor == "right")
and raw.anchor or nil,
rect = { x = 0, y = 0, w = 1, h = 1 }, rect = { x = 0, y = 0, w = 1, h = 1 },
controls = {}, controls = {},
} }
@@ -507,6 +511,8 @@ function TouchSkin.toNative(skin)
alphaMod = page.alphaMod, alphaMod = page.alphaMod,
aspect = page.aspect, aspect = page.aspect,
fitAspect = page.aspectFromCfg or nil, fitAspect = page.aspectFromCfg or nil,
screenFit = page.screenFit == "remainder" and "remainder" or nil,
anchor = page.anchor,
orient = (page.orient == "portrait" or page.orient == "landscape" orient = (page.orient == "portrait" or page.orient == "landscape"
or page.orient == "any") and page.orient or nil, or page.orient == "any") and page.orient or nil,
controls = {}, controls = {},
@@ -570,6 +576,40 @@ local function loadImage(path)
return img return img
end end
-- FileData so LOVE sniffs JPEG/PNG from the name, not a path inside the zip.
local function loadImageFromBytes(bytes, name)
if not bytes or bytes == "" then return nil end
if not (love and love.graphics and love.graphics.newImage) then return nil end
if not (love.filesystem and love.filesystem.newFileData) then return nil end
local key = "bytes:" .. tostring(name) .. ":" .. tostring(#bytes)
local cached = imageCache[key]
if cached then return cached end
local okFd, fd = pcall(love.filesystem.newFileData, bytes, name or "bezel.jpg")
if not okFd or not fd then return nil end
local ok, img = pcall(love.graphics.newImage, fd)
if not ok or not img then
if love.image and love.image.newImageData then
local okData, data = pcall(love.image.newImageData, fd)
if okData and data then ok, img = pcall(love.graphics.newImage, data) end
end
end
if not ok or not img then return nil end
if img.setFilter then img:setFilter("linear", "linear") end
imageCache[key] = img
return img
end
local function rasterizePdfPage(page, root)
if not page or page.image or not page.pdfPath then return end
local pdf = readFile(joinPath(root, page.pdfPath))
local raster = require("src.core.PdfImage").extract(pdf)
if not raster then return end
local name = tostring(page.pdfPath):gsub("%.[Pp][Dd][Ff]$", "") .. "." .. raster.ext
page.rasterData = raster.data
page.rasterName = name:match("([^/]+)$") or name
page.image = loadImageFromBytes(raster.data, page.rasterName)
end
local function pixelScalePending(page) local function pixelScalePending(page)
if page.pixelCoords then return true end if page.pixelCoords then return true end
for _, ctl in ipairs(page.controls or {}) do for _, ctl in ipairs(page.controls or {}) do
@@ -622,6 +662,8 @@ function TouchSkin.load(root, id)
for _, page in ipairs(skin.pages) do for _, page in ipairs(skin.pages) do
if page.imagePath then if page.imagePath then
page.image = loadImage(joinPath(root, page.imagePath)) page.image = loadImage(joinPath(root, page.imagePath))
elseif page.pdfPath then
rasterizePdfPage(page, root)
end end
if not applyPixelScale(page) then if not applyPixelScale(page) then
return nil, "could not read " .. tostring(page.imagePath) return nil, "could not read " .. tostring(page.imagePath)
@@ -646,7 +688,7 @@ end
TouchSkin.ARCHIVE_EXTS = { zip = true, deltaskin = true } TouchSkin.ARCHIVE_EXTS = { zip = true, deltaskin = true }
TouchSkin.LEGACY_EXTS = { gbcskin = true, gbaskin = true, gbskin = true } TouchSkin.LEGACY_EXTS = { gbcskin = true, gbaskin = true, gbskin = true }
TouchSkin.PDF_ONLY_MESSAGE = TouchSkin.PDF_ONLY_MESSAGE =
"This skin uses PDF artwork, which cannot be imported yet. " "This skin uses PDF artwork with no extractable image. "
.. "Ask the author for a PNG version." .. "Ask the author for a PNG version."
function TouchSkin.archiveId(name) function TouchSkin.archiveId(name)
@@ -1246,12 +1288,27 @@ function TouchSkin.pageBox(page, w, h, ox, oy)
and page.aspect and page.aspect > 0 and h > 0 and page.aspect and page.aspect > 0 and h > 0
if fit then if fit then
local displayAspect = w / h local displayAspect = w / h
local anchor = page.anchor
if displayAspect > page.aspect then if displayAspect > page.aspect then
bw = h * page.aspect bw = h * page.aspect
bx = ox + (w - bw) * 0.5 local extra = w - bw
if anchor == "right" then
bx = ox + extra
elseif anchor == "left" then
bx = ox
else
bx = ox + extra * 0.5
end
else else
bh = w / page.aspect bh = w / page.aspect
by = oy + (h - bh) * 0.5 local extra = h - bh
if anchor == "bottom" then
by = oy + extra
elseif anchor == "top" then
by = oy
else
by = oy + extra * 0.5
end
end end
end end
local r = page.rect local r = page.rect
@@ -1308,18 +1365,55 @@ end
function TouchSkin.hasViewport() function TouchSkin.hasViewport()
local page = TouchSkin.page() local page = TouchSkin.page()
return page ~= nil and page.viewport ~= nil and TouchSkin.drawable() if not page or not TouchSkin.drawable() then return false end
return page.viewport ~= nil or page.screenFit == "remainder"
end
-- Largest strip of (ox,oy,w,h) that does not overlap the overlay box.
local function remainderBox(ox, oy, w, h, bx, by, bw, bh)
local right, bottom = ox + w, oy + h
local cand = {
{ ox, oy, w, by - oy },
{ ox, by + bh, w, bottom - (by + bh) },
{ ox, oy, bx - ox, h },
{ bx + bw, oy, right - (bx + bw), h },
}
local best, bestArea
for _, r in ipairs(cand) do
if r[3] > 1 and r[4] > 1 then
local area = r[3] * r[4]
if not best or area > bestArea then
best, bestArea = r, area
end
end
end
if not best then return nil end
return best[1], best[2], best[3], best[4]
end
function TouchSkin.pageViewport(page, w, h, ox, oy)
if not page then return nil end
ox, oy = ox or 0, oy or 0
local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy)
if page.viewport then
local v = page.viewport
local x, y = bx + v.x * bw, by + v.y * bh
local vw, vh = v.w * bw, v.h * bh
if vw <= 0 or vh <= 0 then return nil end
return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true
end
if page.screenFit == "remainder" then
local x, y, vw, vh = remainderBox(ox, oy, w, h, bx, by, bw, bh)
if not x then return nil end
return x, y, vw, vh, false, false
end
return nil
end end
function TouchSkin.viewport(w, h, ox, oy) function TouchSkin.viewport(w, h, ox, oy)
local page = TouchSkin.page() local page = TouchSkin.page()
if not page or not page.viewport or not TouchSkin.drawable() then return nil end if not page or not TouchSkin.drawable() then return nil end
local v = page.viewport return TouchSkin.pageViewport(page, w, h, ox, oy)
local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy)
local x, y = bx + v.x * bw, by + v.y * bh
local vw, vh = v.w * bw, v.h * bh
if vw <= 0 or vh <= 0 then return nil end
return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true
end end
return TouchSkin return TouchSkin
+2 -1
View File
@@ -3119,7 +3119,8 @@ function RomImporter:_ensureSkins(force)
format = skin and skin.format or nil, format = skin and skin.format or nil,
pages = skin and #skin.pages or 0, pages = skin and #skin.pages or 0,
controls = controls, controls = controls,
screen = page ~= nil and page.viewport ~= nil, screen = page ~= nil
and (page.viewport ~= nil or page.screenFit == "remainder"),
ok = skin ~= nil, ok = skin ~= nil,
} }
end end
+13 -11
View File
@@ -851,6 +851,7 @@ function Studio.detectViewport()
end end
Studio.pushUndo() Studio.pushUndo()
page.viewport = rect page.viewport = rect
page.screenFit = nil
setStatus(("Screen detected: %dx%d px in the bezel art"):format(pw, ph)) setStatus(("Screen detected: %dx%d px in the bezel art"):format(pw, ph))
Studio.dirty = true Studio.dirty = true
end end
@@ -860,8 +861,9 @@ function Studio.toggleViewport()
if not page then return end if not page then return end
if Studio.canvas().lockViewport then return end if Studio.canvas().lockViewport then return end
Studio.pushUndo() Studio.pushUndo()
if page.viewport then if page.viewport or page.screenFit == "remainder" then
page.viewport = nil page.viewport = nil
page.screenFit = nil
else else
page.viewport = { x = 0.1, y = 0.05, w = 0.8, h = 0.45 } page.viewport = { x = 0.1, y = 0.05, w = 0.8, h = 0.45 }
end end
@@ -892,7 +894,7 @@ function Studio.pageLabel(index)
local orient = TouchSkin.pageOrient(page) local orient = TouchSkin.pageOrient(page)
local bits = { #(page.controls or {}) .. " controls" } local bits = { #(page.controls or {}) .. " controls" }
if orient then bits[#bits + 1] = orient end if orient then bits[#bits + 1] = orient end
if page.viewport then bits[#bits + 1] = "screen" end if page.viewport or page.screenFit == "remainder" then bits[#bits + 1] = "screen" end
return name, table.concat(bits, " \194\183 ") return name, table.concat(bits, " \194\183 ")
end end
@@ -1092,10 +1094,9 @@ function Studio.snapLines(page, r, skipIndex)
end end
local function viewportRect(page, r) local function viewportRect(page, r)
local v = page.viewport local x, y, w, h = TouchSkin.pageViewport(page, r.w, r.h, r.x, r.y)
if not v then return nil end if not x then return nil end
local bx, by, bw, bh = TouchSkin.pageBox(page, r.w, r.h, r.x, r.y) return x, y, w, h
return bx + v.x * bw, by + v.y * bh, v.w * bw, v.h * bh
end end
local function handleRects(bx, by, bw, bh) local function handleRects(bx, by, bw, bh)
@@ -1138,7 +1139,7 @@ function Studio.beginCanvasDrag(mx, my, r)
end end
local vx, vy, vw, vh = viewportRect(page, r) local vx, vy, vw, vh = viewportRect(page, r)
if vx and not Studio.canvas().lockViewport then if vx and not Studio.canvas().lockViewport and page.screenFit ~= "remainder" then
for _, h in ipairs(handleRects(vx, vy, vw, vh)) do for _, h in ipairs(handleRects(vx, vy, vw, vh)) do
if mx >= h.x and mx <= h.x + h.w and my >= h.y and my <= h.y + h.h then if mx >= h.x and mx <= h.x + h.w and my >= h.y and my <= h.y + h.h then
Studio.pushUndo() Studio.pushUndo()
@@ -1162,7 +1163,7 @@ function Studio.beginCanvasDrag(mx, my, r)
end end
end end
if vx and not Studio.canvas().lockViewport if vx and not Studio.canvas().lockViewport and page.screenFit ~= "remainder"
and mx >= vx and mx <= vx + vw and my >= vy and my <= vy + vh then and mx >= vx and mx <= vx + vw and my >= vy and my <= vy + vh then
Studio.selected = nil Studio.selected = nil
Studio.pushUndo() Studio.pushUndo()
@@ -1270,7 +1271,7 @@ local function drawCanvas(x, y, w, h)
if vx then if vx then
Theme.strokeRounded(vx, vy, vw, vh, PAL.blue, 0.9, 2, 2) Theme.strokeRounded(vx, vy, vw, vh, PAL.blue, 0.9, 2, 2)
Kit.text("small", "SCREEN", vx + 4 * Kit.scale, vy + 4 * Kit.scale, PAL.blue) Kit.text("small", "SCREEN", vx + 4 * Kit.scale, vy + 4 * Kit.scale, PAL.blue)
if not Studio.canvas().lockViewport then if not Studio.canvas().lockViewport and page.screenFit ~= "remainder" then
local a = Studio.selectedControl() and 0.45 or 1 local a = Studio.selectedControl() and 0.45 or 1
for _, hd in ipairs(handleRects(vx, vy, vw, vh)) do for _, hd in ipairs(handleRects(vx, vy, vw, vh)) do
Theme.fill(hd.x, hd.y, hd.w, hd.h, PAL.blue, a) Theme.fill(hd.x, hd.y, hd.w, hd.h, PAL.blue, a)
@@ -1374,7 +1375,7 @@ local function inspectorBody(x, y, w)
cy = cy + rowH + gap cy = cy + rowH + gap
if page then if page then
local bezel = page.imagePath or "(none)" local bezel = page.imagePath or page.rasterName or "(none)"
local pickW = 82 * Kit.scale local pickW = 82 * Kit.scale
local cycleW = w - pickW - gap local cycleW = w - pickW - gap
if Kit.button(x, cy, cycleW, rowH, "Bezel: " .. bezel, { id = "bezel" }) then if Kit.button(x, cy, cycleW, rowH, "Bezel: " .. bezel, { id = "bezel" }) then
@@ -1385,7 +1386,8 @@ local function inspectorBody(x, y, w)
Studio.importImageFile("bezel") Studio.importImageFile("bezel")
end end
cy = cy + rowH + gap cy = cy + rowH + gap
local vpLabel = page.viewport and "Screen cutout: ON" or "Screen cutout: OFF" local vpLabel = (page.viewport or page.screenFit == "remainder")
and "Screen cutout: ON" or "Screen cutout: OFF"
if Kit.button(x, cy, half, rowH, vpLabel, { id = "vp", if Kit.button(x, cy, half, rowH, vpLabel, { id = "vp",
enabled = not Studio.canvas().lockViewport }) then enabled = not Studio.canvas().lockViewport }) then
Studio.toggleViewport() Studio.toggleViewport()
+76
View File
@@ -364,6 +364,31 @@ eq(bx, 0, "delta page box x") eq(by, 0, "delta page box y")
eq(bw, 1000, "delta page box fills the width") eq(bw, 1000, "delta page box fills the width")
eq(bh, 500, "delta page box fills the height") eq(bh, 500, "delta page box fills the height")
local DECK_JSON = [[
{ "name": "Deck", "gameTypeIdentifier": "com.rileytestut.delta.game.gbc",
"representations": { "iphone": { "standard": { "portrait": {
"assets": { "large": "deck.png" },
"mappingSize": {"width":320,"height":240},
"items": [ { "inputs": ["a"], "frame": {"x":240,"y":60,"width":64,"height":64} } ]
} } } } }
]]
local deck = assert(DeltaSkin.parse(DECK_JSON))
local deckPage = deck.pages[1]
check(deckPage.aspectFromCfg, "a portrait deck without screens keeps mapping aspect")
eq(deckPage.anchor, "bottom", "and sits at the bottom of the window")
eq(deckPage.screenFit, "remainder", "with the leftover given to the GB picture")
eq(deckPage.viewport, nil, "no screens[] means no baked cutout")
local dbx, dby, dbw, dbh = TouchSkin.pageBox(deckPage, 1080, 1920)
eq(dbx, 0, "deck overlay is full width")
eq(dbw, 1080, "deck overlay width")
near(dbh, 1080 * 240 / 320, "deck overlay height is mapping aspect")
near(dby, 1920 - dbh, "pinned to the bottom, not stretched")
local vx, vy, vw, vh = TouchSkin.pageViewport(deckPage, 1080, 1920)
eq(vx, 0, "screen leftover x") eq(vy, 0, "screen leftover y")
eq(vw, 1080, "screen leftover is full width")
near(vh, dby, "and fills everything above the overlay")
check(vh > dbh, "there is more room for the picture than for the pad")
local LEGACY_SCREEN = [[ local LEGACY_SCREEN = [[
{ "gameTypeIdentifier": "public.aoshuang.game.gbc", { "gameTypeIdentifier": "public.aoshuang.game.gbc",
"representations": { "iphone": { "standard": { "landscape": { "representations": { "iphone": { "standard": { "landscape": {
@@ -419,6 +444,8 @@ local PDF_JSON = [[
]] ]]
local pdf = assert(DeltaSkin.parse(PDF_JSON)) local pdf = assert(DeltaSkin.parse(PDF_JSON))
eq(pdf.pages[1].imagePath, nil, "a PDF asset is not pretended to be art") eq(pdf.pages[1].imagePath, nil, "a PDF asset is not pretended to be art")
eq(pdf.pages[1].pdfPath, "iphone_portrait.pdf",
"but the PDF path is kept so load can extract a JPEG from it")
local convert = DeltaSkin.needsConversion(pdf) local convert = DeltaSkin.needsConversion(pdf)
check(convert ~= nil, "PDF-only skins report that they need conversion") check(convert ~= nil, "PDF-only skins report that they need conversion")
if convert then if convert then
@@ -471,6 +498,55 @@ check(tostring(vectorErr):find("PDF artwork", 1, true) ~= nil,
eq(love.filesystem.read("skins/vector.deltaskin"), nil, eq(love.filesystem.read("skins/vector.deltaskin"), nil,
"and the refused archive is not left behind") "and the refused archive is not left behind")
local PdfImage = require("src.core.PdfImage")
local function unhex(s)
return (s:gsub("..", function(cc)
return string.char(tonumber(cc, 16))
end))
end
-- 1x1 JFIF JPEG, so extract tests do not need a file on disk.
local TINY_JPEG = unhex(
"ffd8ffe000104a46494600010100000100010000ffdb0043000806060706050807070709" ..
"09080a0c140d0c0b0b0c1912130f141d1a1f1e1d1a1c1c20242e2720222c231c1c283729" ..
"2c30313434341f27393d38323c2e333432ffc0000b080001000101011100ffc400140001" ..
"0000000000000000000000000000000008ffc40014100100000000000000000000000000" ..
"00000000ffda0008010100003f007f3fffd9")
local function jpegPdf(jpeg, w, h)
return "%PDF-1.7\n3 0 obj\n<< /Type /XObject /Subtype /Image /Width "
.. tostring(w) .. " /Height " .. tostring(h)
.. " /BitsPerComponent 8 /ColorSpace /DeviceRGB /Filter /DCTDecode /Length "
.. tostring(#jpeg) .. " >>\nstream\n" .. jpeg .. "\nendstream\nendobj\n%%EOF\n"
end
local extracted = assert(PdfImage.extract(jpegPdf(TINY_JPEG, 1, 1)))
eq(extracted.ext, "jpg", "a JPEG-in-PDF yields a jpg")
eq(extracted.data, TINY_JPEG, "and the JPEG body is recovered byte for byte")
eq(extracted.width, 1, "width comes from the Image XObject")
eq(extracted.height, 1, "and so does height")
local indirect = "%PDF-1.7\n3 0 obj\n<< /Type /XObject /Subtype /Image /Width 1"
.. " /Height 1 /Filter /DCTDecode /Length 5 0 R >>\nstream\n" .. TINY_JPEG
.. "\nendstream\nendobj\n5 0 obj\n" .. tostring(#TINY_JPEG) .. "\nendobj\n%%EOF\n"
local fromRef = assert(PdfImage.extract(indirect))
eq(fromRef.data, TINY_JPEG,
"an indirect /Length (the 3-Heights Image-to-PDF layout) still extracts")
eq(select(1, PdfImage.extract("%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n")),
nil, "a vector PDF with no image is not pretended to be art")
eq(select(1, PdfImage.extract("not a pdf")), nil, "and neither is garbage")
love.filesystem.write("skins/pikapdf.deltaskin/info.json", PDF_JSON)
love.filesystem.write("skins/pikapdf.deltaskin/iphone_portrait.pdf",
jpegPdf(TINY_JPEG, 1, 1))
local pikaId, pikaErr = TouchSkin.installArchive("pikapdf.deltaskin", "PK\3\4stub")
eq(pikaId, "pikapdf", "a Delta skin whose PDF wraps a JPEG installs: "
.. tostring(pikaErr))
local pika = assert(TouchSkin.load("skins/_mounted/pikapdf", "pikapdf"))
check(pika.pages[1].rasterData == TINY_JPEG,
"load recovers the JPEG from the PDF")
check(pika.pages[1].image ~= nil, "and LOVE gets an image from those bytes")
eq(DeltaSkin.needsConversion(pika), nil,
"so the skin no longer reports that it needs conversion")
eq(select(1, TouchSkin.installArchive("skin.gbcskin", "PK\3\4stub")), nil, eq(select(1, TouchSkin.installArchive("skin.gbcskin", "PK\3\4stub")), nil,
"a GBA4iOS .gbcskin is refused at the door") "a GBA4iOS .gbcskin is refused at the door")
local _, legacyErr = TouchSkin.installArchive("skin.gbaskin", "PK\3\4stub") local _, legacyErr = TouchSkin.installArchive("skin.gbaskin", "PK\3\4stub")