package.path = "./?.lua;./?/init.lua;" .. package.path if not _G.love then _G.love = require("tests.love_stub") end local T = require("tests.harness") local check, eq = T.check, T.eq local TouchSkin = require("src.core.TouchSkin") local DeltaSkin = require("src.core.DeltaSkin") local Json = require("src.link.Json") local function near(got, want, msg) return check(type(got) == "number" and math.abs(got - want) < 1e-6, ("%s (got %s, want %s)"):format(msg, tostring(got), tostring(want))) end local function hasWarning(skin, fragment) for _, w in ipairs(skin.warnings or {}) do if tostring(w):find(fragment, 1, true) then return true end end return false end local function unzip(bytes) local out, i = {}, 1 while bytes:sub(i, i + 3) == "PK\3\4" do local function u16(off) local a, b = bytes:byte(i + off, i + off + 1) return a + b * 256 end local function u32(off) local a, b, c, d = bytes:byte(i + off, i + off + 3) return a + b * 256 + c * 65536 + d * 16777216 end local size, nameLen, extraLen = u32(18), u16(26), u16(28) local name = bytes:sub(i + 30, i + 29 + nameLen) local start = i + 30 + nameLen + extraLen out[name] = bytes:sub(start, start + size - 1) out[#out + 1] = name i = start + size end return out end local function readBytes(path) local f = assert(io.open(path, "rb")) local data = f:read("*a") f:close() return data end local GAMEBOY_CFG = [[ overlays = 4 overlay0_name = "landscape" overlay0_full_screen = true overlay0_normalized = true overlay0_range_mod = 1.5 overlay0_alpha_mod = 2.0 overlay0_aspect_ratio = 2.22222222222222 overlay0_descs = 13 overlay0_desc0 = "nul,0.0985,0.6825,rect,0.0525,0.0875" overlay0_desc0_overlay = img/dpad.png overlay0_desc1 = "up,0.0985,0.5950,rect,0.0175,0.0292" overlay0_desc2 = "down,0.0985,0.7700,rect,0.0175,0.0292" overlay0_desc3 = "left,0.0460,0.6825,rect,0.0175,0.0292" overlay0_desc4 = "right,0.1510,0.6825,rect,0.0175,0.0292" overlay0_desc5 = "left|up,0.0460,0.5950,rect,0.0175,0.0292" overlay0_desc6 = "right|up,0.1510,0.5950,rect,0.0175,0.0292" overlay0_desc7 = "left|down,0.0460,0.7700,rect,0.0175,0.0292" overlay0_desc8 = "right|down,0.1510,0.7700,rect,0.0175,0.0292" overlay0_desc9 = "a,0.8975,0.6300,radial,0.0525,0.0875" overlay0_desc9_overlay = img/a.png overlay0_desc10 = "b,0.8100,0.7350,radial,0.0525,0.0875" overlay0_desc10_overlay = img/b.png overlay0_desc11 = "start,0.5500,0.9000,rect,0.0500,0.0400" overlay0_desc12 = "select,0.4500,0.9000,rect,0.0500,0.0400" overlay1_name = "portrait" overlay1_full_screen = true overlay1_normalized = true overlay1_aspect_ratio = 0.45 overlay1_descs = 2 overlay1_desc0 = "a,0.8975,0.6300,radial,0.0875,0.0525" overlay1_desc1 = "b,0.8100,0.7350,radial,0.0875,0.0525" overlay2_name = "menu" overlay2_full_screen = true overlay2_normalized = true overlay2_descs = 1 overlay2_desc0 = "menu_toggle,0.5,0.5,rect,0.1,0.1" overlay3_name = "hide" overlay3_full_screen = true overlay3_normalized = true overlay3_descs = 1 overlay3_desc0 = "overlay_next,0.95,0.05,radial,0.04,0.04" overlay3_desc0_next_target = "landscape" ]] local gameboy = assert(TouchSkin.parse(GAMEBOY_CFG)) eq(#gameboy.pages, 4, "the canonical gameboy overlay has four pages") eq(gameboy.pages[1].orient, "landscape", "page 1 auto-rotates landscape") eq(gameboy.pages[2].orient, "portrait", "page 2 auto-rotates portrait") check(TouchSkin.hasOrientPair(gameboy), "so it is an auto-rotate overlay") eq(gameboy.pages[3].orient, nil, "the menu page is not part of the pair") eq(#gameboy.pages[1].controls, 13, "every landscape desc parsed") check(gameboy.pages[1].controls[1].decorative, "the d-pad art desc binds nothing") eq(gameboy.pages[1].controls[1].imagePath, "img/dpad.png", "and carries the art") eq(gameboy.pages[1].imagePath, nil, "the overlay ships no page background") eq(gameboy.pages[4].controls[1].nextTarget, "landscape", "hide jumps back by name") local named = {} for _, ctl in ipairs(gameboy.pages[1].controls) do for _, btn in ipairs(ctl.buttons) do named[btn] = true end end for _, btn in ipairs({ "a", "b", "start", "select", "up", "down", "left", "right" }) do check(named[btn], "landscape binds GB " .. btn) end eq(#gameboy.warnings, 0, "a well-formed overlay warns about nothing") local SPACED_CFG = [[ overlays = 1 overlay0_name = "spaced" overlay0_normalized = true overlay0_descs = 2 overlay0_desc0 = "a 0.5 0.5 rect 0.05 0.05" overlay0_desc0_saturate_pct = 0.6 overlay0_desc0_exclusive = true overlay0_desc0_movable = true overlay0_desc1 = b,0.25,0.5,radial,0.05,0.05 ]] local spaced = assert(TouchSkin.parse(SPACED_CFG)) near(spaced.pages[1].controls[1].saturatePct, 0.6, "_saturate_pct is parsed") check(spaced.pages[1].controls[1].exclusive, "_exclusive is parsed") check(spaced.pages[1].controls[1].movable, "_movable is parsed on a plain desc") eq(spaced.pages[1].controls[2].exclusive, nil, "and is not inherited") eq(#spaced.pages[1].controls, 2, "a space-separated desc still parses") eq(spaced.pages[1].controls[1].buttons[1], "a", "space-separated bind") near(spaced.pages[1].controls[1].x, 0.5, "space-separated position") eq(spaced.pages[1].controls[2].buttons[1], "b", "an unquoted desc parses too") eq(spaced.pages[1].controls[2].shape, "radial", "and keeps its hitbox shape") local SHORT_CFG = [[ overlays = 1 overlay0_name = "short" overlay0_normalized = true overlay0_descs = 2 overlay0_desc0 = "a,0.5,0.5,rect,0.05,0.05" ]] local short = assert(TouchSkin.parse(SHORT_CFG)) eq(#short.pages[1].controls, 1, "a missing desc is skipped, not faked") check(hasWarning(short, "missing desc 1"), "and the importer says so") eq(select(1, TouchSkin.parse("overlay0_descs = 1\n")), nil, "a cfg without the overlays key is refused") local AREA_CFG = [[ overlays = 1 overlay0_name = "portrait" overlay0_full_screen = true overlay0_normalized = true overlay0_descs = 3 overlay0_desc0 = "dpad_area,0.2,0.7,rect,0.15,0.1" overlay0_desc0_overlay = img/dpad.png overlay0_desc0_reach_x = 1.5 overlay0_desc0_movable = true overlay0_desc1 = "abxy_area,0.8,0.7,radial,0.12,0.08" overlay0_desc1_up = "start" overlay0_desc2 = "analog_left,0.2,0.3,radial,0.1,0.1" overlay0_desc2_saturate_pct = 0.6 overlay0_desc2_exclusive = true ]] local area = assert(TouchSkin.parse(AREA_CFG)) local ap = area.pages[1] near(ap.aspect, 0.5625, "a portrait-named overlay defaults to 9:16") check(not ap.aspectFromCfg, "and that default is not a cfg aspect lock") eq(#ap.controls, 1 + 8 + 8 + 8, "each area desc expands into eight hitboxes") local art = ap.controls[1] check(art.decorative, "the dpad_area art is carried by a decoration") eq(art.imagePath, "img/dpad.png", "with the desc's own overlay image") near(art.rangeX, 0.15, "sized like the area it replaces") local sectorE = ap.controls[2] eq(sectorE.spec, "right", "the first sector is the one pointing right") near(sectorE.x, 0.2, "every sector sits on the area centre") near(sectorE.y, 0.7, "on both axes") near(sectorE.rangeX, 0.15, "and covers the whole area, not a ninth of it") near(sectorE.reachLeft, 1.5, "the desc reach_x rides onto the sectors as it is") near(sectorE.reachRight, 1.5, "on both sides") eq(sectorE.sector, 1, "the sector index is kept for the hit test") eq(ap.controls[3].spec, "right|down", "the next sector is the lower-right corner") eq(ap.controls[4].spec, "down", "then straight down, y growing downwards") eq(ap.controls[8].spec, "up", "and straight up seven sectors along") check(ap.controls[1].movable, "_movable is parsed") local abxy = ap.controls[10] eq(abxy.spec, "a", "abxy right is RetroPad a, which is GB A") eq(abxy.buttons[1], "a", "and reaches that GB button") eq(ap.controls[16].spec, "start", "abxy_area honours an _up override") eq(ap.controls[15].spec, "y|start", "the up-left sector combines both sides") check(ap.controls[15].exclusive == nil, "and inherits nothing the desc did not set") check(ap.controls[14].decorative, "RetroPad Y has no GB button, so that sector is inert") eq(abxy.shape, "radial", "a radial area keeps its ellipse") eq(ap.controls[18].spec, "right", "analog_left degrades to a directional pad") check(ap.controls[18].exclusive, "_exclusive rides onto the expanded sectors") eq(ap.controls[23].spec, "left|up", "with all eight sectors") near(ap.controls[18].rangeX, 0.1, "analog sectors share the whole stick area") local sq = { x = 0.5, y = 0.5, rangeX = 0.25, rangeY = 0.25, shape = "rect", rangeMod = 1, alphaMod = 1, reachUp = 1, reachDown = 1, reachLeft = 1, reachRight = 1 } local sectors = TouchSkin.expandSectors(sq, TouchSkin.AREA_DEFAULTS.dpad_area) eq(#sectors, 8, "a dpad area expands into eight sector hitboxes") local page = { rect = { x = 0, y = 0, w = 1, h = 1 }, fullScreen = true, aspect = 1, controls = sectors } local function hitSpecs(px, py) local out = {} for _, ctl in ipairs(sectors) do if TouchSkin.hits(page, ctl, 100, 100, px, py, 0, 0) then out[#out + 1] = ctl.spec end end return table.concat(out, "+") end eq(hitSpecs(50, 50), "right", "the exact centre still fires a direction: no dead zone") eq(hitSpecs(60, 50), "right", "a touch to the right of centre is right") eq(hitSpecs(50, 60), "down", "a touch below centre is down, y growing downwards") eq(hitSpecs(50, 40), "up", "a touch above centre is up") eq(hitSpecs(40, 40), "left|up", "a diagonal touch fires both directions") eq(hitSpecs(58, 52), "right", "17 degrees off the axis is still a pure direction") eq(hitSpecs(55, 53), "right|down", "and 31 degrees is the diagonal, not a grid corner") eq(hitSpecs(50, 80), "", "outside the area nothing fires") local PIXEL_NO_IMAGE = [[ overlays = 1 overlay0_name = "pixels" overlay0_descs = 1 overlay0_desc0 = "a,120,80,rect,20,10" ]] local noImage = assert(TouchSkin.parse(PIXEL_NO_IMAGE)) check(hasWarning(noImage, "no base image"), "pixel coords without a base image are called out") check(noImage.pages[1].pixelCoords == false, "and read as normalized rather than dividing by nothing") love.filesystem.write("skins/px/overlay.cfg", [[ overlays = 1 overlay0_name = "px" overlay0_overlay = img/base.png overlay0_full_screen = true overlay0_descs = 2 overlay0_desc0 = "a,4,4,rect,2,1" overlay0_desc1 = "b,6,2,rect,1,1" overlay0_desc1_normalized = true ]]) local px = assert(TouchSkin.load("skins/px", "px")) local pxPage = px.pages[1] check(pxPage.image ~= nil, "the base overlay image loads") local iw, ih = pxPage.image:getDimensions() near(pxPage.controls[1].x, 4 / iw, "pixel x is divided by the base image width") near(pxPage.controls[1].y, 4 / ih, "pixel y is divided by the base image height") near(pxPage.controls[1].rangeX, 2 / iw, "and so are the half extents") near(pxPage.controls[2].x, 6, "a per-desc normalized flag opts that desc out") check(pxPage.pixelCoords == false, "the page is normalized once converted") love.filesystem.write("skins/pxbad/overlay.cfg", [[ overlays = 1 overlay0_name = "pxbad" overlay0_overlay = img/broken.png overlay0_descs = 1 overlay0_desc0 = "a,4,4,rect,2,1" ]]) local savedNewImage = love.graphics.newImage love.graphics.newImage = function() error("unreadable image") end local badPx, badPxErr = TouchSkin.load("skins/pxbad", "pxbad") love.graphics.newImage = savedNewImage eq(badPx, nil, "a skin whose pixel coordinates have no base image fails to load") check(tostring(badPxErr):find("img/broken.png", 1, true) ~= nil, "and the error names the image it could not read") local DELTA_JSON = [[ { "name": "Test GBC", "identifier": "com.example.gbc.test", "gameTypeIdentifier": "com.rileytestut.delta.game.gbc", "debug": false, "representations": { "iphone": { "edgeToEdge": { "portrait": { "assets": { "small": "p_small.png", "medium": "p_medium.png", "large": "p_large.png" }, "items": [ { "inputs": ["a"], "frame": {"x":240,"y":320,"width":64,"height":64}, "mask": "circle" }, { "inputs": ["b"], "frame": {"x":160,"y":360,"width":64,"height":64}, "extendedEdges": {"right":16} }, { "inputs": {"up":"up","down":"down","left":"left","right":"right"}, "frame": {"x":16,"y":320,"width":96,"height":96} }, { "inputs": ["start","select"], "frame": {"x":128,"y":448,"width":64,"height":32} }, { "inputs": ["menu"], "frame": {"x":0,"y":0,"width":32,"height":32} }, { "inputs": ["quickSave"], "frame": {"x":288,"y":0,"width":32,"height":32} } ], "mappingSize": {"width":320,"height":480}, "extendedEdges": {"top":8,"bottom":8,"left":8,"right":8}, "translucent": false, "screens": [{ "inputFrame": {"x":0,"y":0,"width":160,"height":144}, "outputFrame": {"x":0,"y":32,"width":320,"height":288} }] } }, "standard": { "portrait": { "items": [], "mappingSize": {"width":320,"height":480} } } } } } ]] local delta = assert(TouchSkin ~= nil and DeltaSkin.parse(DELTA_JSON)) eq(delta.format, "delta", "a .deltaskin parses into the native model") eq(delta.name, "Test GBC", "info.json name") eq(delta.system, "gbc", "gbc covers both GB and GBC") eq(#delta.pages, 1, "only the orientations present become pages") local dp = delta.pages[1] eq(dp.name, "portrait", "the page is named for its orientation") eq(dp.orient, "portrait", "and locked to it") eq(dp.imagePath, "p_large.png", "the PNG ladder picks the largest for a phone") check(dp.fullScreen, "Delta stretches its skin over the whole surface") check(not dp.aspectFromCfg, "so nothing letterboxes it") near(dp.aspect, 320 / 480, "the page aspect is the mappingSize aspect") eq(#dp.controls, 13, "edgeToEdge wins over standard, so all six items parsed") local dA = dp.controls[1] eq(dA.buttons[1], "a", "an inputs array binds its button") eq(dA.shape, "radial", 'mask "circle" becomes a radial hitbox') near(dA.x, 0.85, "frame top-left plus half width is the native centre") near(dA.y, 352 / 480, "and the same for y") near(dA.rangeX, 0.1, "frame width halves into the native half extent") near(dA.reachLeft, 1.25, "orientation extendedEdges become reach") local dB = dp.controls[2] near(dB.reachRight, 1.5, "a per-item extendedEdges key overrides that side") near(dB.reachLeft, 1.25, "and leaves the others inherited") eq(dp.controls[3].spec, "left|up", "a dpad input object expands to a 3x3 grid") near(dp.controls[3].x, 0.1, "dpad top-left cell x") near(dp.controls[3].rangeX, 0.05, "dpad cells are a third of the frame") near(dp.controls[3].reachLeft, 1.5, "with the extended edge re-scaled onto them") eq(dp.controls[4].spec, "up", "dpad top-centre cell") eq(dp.controls[10].spec, "right|down", "dpad bottom-right cell") eq(dp.controls[11].spec, "start|select", "a multi-input item fires both") eq(dp.controls[12].hotkeys[1], "menu", "the Delta menu button becomes a hotkey") check(dp.controls[13].decorative, "quickSave has no engine hotkey, so it is inert rather than a game button") check(dp.viewport ~= nil, "screens[] places the emulator picture") near(dp.viewport.y, 32 / 480, "outputFrame y normalizes by mappingSize") near(dp.viewport.h, 288 / 480, "outputFrame height normalizes by mappingSize") local bx, by, bw, bh = TouchSkin.pageBox(dp, 1000, 500) eq(bx, 0, "delta page box x") eq(by, 0, "delta page box y") eq(bw, 1000, "delta page box fills the width") eq(bh, 500, "delta page box fills the height") local LEGACY_SCREEN = [[ { "gameTypeIdentifier": "public.aoshuang.game.gbc", "representations": { "iphone": { "standard": { "landscape": { "mappingSize": {"width":640,"height":320}, "gameScreenFrame": {"x":160,"y":0,"width":320,"height":288}, "translucent": true, "items": [ { "inputs": {"up":"analogStickUp","down":"analogStickDown", "left":"analogStickLeft","right":"analogStickRight"}, "frame": {"x":0,"y":0,"width":120,"height":120} } ] } } } } } ]] local legacy = assert(DeltaSkin.parse(LEGACY_SCREEN)) eq(legacy.system, "gbc", "the Manic public.aoshuang prefix is accepted") eq(#legacy.pages, 1, "landscape only") eq(legacy.pages[1].orient, "landscape", "orientation key drives the lock") near(legacy.pages[1].viewport.x, 0.25, "gameScreenFrame is the legacy screen rect") near(legacy.pages[1].alphaMod, 0.7, "translucent dims the controls") eq(#legacy.pages[1].controls, 8, "a thumbstick degrades to a directional pad") eq(legacy.pages[1].controls[1].spec, "left|up", "with the analog names mapped") local snes = assert(DeltaSkin.parse([[ { "gameTypeIdentifier": "com.rileytestut.delta.game.snes", "representations": { "iphone": { "standard": { "portrait": { "mappingSize": {"width":320,"height":480}, "items": [] } } } } } ]])) check(hasWarning(snes, "not Game Boy"), "a non Game Boy skin warns") eq(#snes.pages, 1, "but still imports") eq(select(1, DeltaSkin.parse([[ { "name": "old", "gameTypeIdentifier": "com.rileytestut.GBA4iOS.gba", "representations": { "iphone": { "portrait": { "assets": {} } } } } ]])), nil, "a GBA4iOS skin is refused") local _, gbaErr = DeltaSkin.parse([[ { "gameTypeIdentifier": "com.rileytestut.GBA4iOS.gbc", "representations": {} } ]]) check(tostring(gbaErr):find("GBA4iOS", 1, true) ~= nil, "and the message names the old format") local _, noTypeErr = DeltaSkin.parse('{ "representations": {} }') check(tostring(noTypeErr):find("gameTypeIdentifier", 1, true) ~= nil, "info.json without a gameTypeIdentifier is refused by name") eq(select(1, DeltaSkin.parse("not json at all")), nil, "garbage is refused") eq(select(1, DeltaSkin.parse([[ { "gameTypeIdentifier": "com.rileytestut.delta.game.gbc", "representations": {} } ]])), nil, "an empty representations tree is refused") local PDF_JSON = [[ { "name": "Vector", "gameTypeIdentifier": "com.rileytestut.delta.game.gbc", "representations": { "iphone": { "standard": { "portrait": { "assets": { "resizable": "iphone_portrait.pdf" }, "mappingSize": {"width":320,"height":480}, "items": [ { "inputs": ["a"], "frame": {"x":0,"y":0,"width":32,"height":32} } ] } } } } } ]] local pdf = assert(DeltaSkin.parse(PDF_JSON)) eq(pdf.pages[1].imagePath, nil, "a PDF asset is not pretended to be art") local convert = DeltaSkin.needsConversion(pdf) check(convert ~= nil, "PDF-only skins report that they need conversion") if convert then check(convert.pdfOnly, "the report is flagged pdfOnly") eq(convert.files[1], "iphone_portrait.pdf", "and names the file to convert") end eq(DeltaSkin.needsConversion(delta), nil, "a PNG skin needs no conversion") local mixed = assert(DeltaSkin.parse([[ { "gameTypeIdentifier": "com.rileytestut.delta.game.gb", "representations": { "iphone": { "standard": { "portrait": { "assets": { "resizable": "art.pdf", "medium": "art.png" }, "mappingSize": {"width":320,"height":480}, "items": [] } } } } } ]])) eq(mixed.pages[1].imagePath, "art.png", "a raster asset beats the PDF") eq(DeltaSkin.needsConversion(mixed), nil, "so no conversion is needed") eq(DeltaSkin.pickAsset({ small = "s.png" }, { targetWidth = 1080 }, {}), "s.png", "the ladder falls back to the largest shipped asset") eq(DeltaSkin.pickAsset({ small = "s.png", medium = "m.png", large = "l.png" }, { targetWidth = 640 }, {}), "s.png", "a small target takes the small asset") eq(DeltaSkin.pickAsset({ normal = "n.png" }, { targetWidth = 640 }, {}), "n.png", 'the Manic "normal" alias is accepted') love.filesystem.write("skins/wrapped.deltaskin/MySkin/info.json", [[ { "name": "Wrapped", "gameTypeIdentifier": "com.rileytestut.delta.game.gbc", "representations": { "iphone": { "standard": { "portrait": { "assets": { "large": "Portrait.PNG" }, "mappingSize": {"width":320,"height":480}, "items": [ { "inputs": ["a"], "frame": {"x":0,"y":0,"width":64,"height":64} } ] } } } } } ]]) love.filesystem.write("skins/wrapped.deltaskin/MySkin/portrait.png", "\137PNG\r\n\26\n") local wrappedId, wrappedErr = TouchSkin.installArchive("wrapped.deltaskin", "PK\3\4stub") eq(wrappedId, "wrapped", "a .deltaskin installs under its bare name: " .. tostring(wrappedErr)) local wrapped = assert(TouchSkin.load("skins/_mounted/wrapped", "wrapped")) eq(wrapped.format, "delta", "the mounted archive is recognised as a Delta skin") eq(wrapped.name, "Wrapped", "and its name comes from info.json") eq(wrapped.pages[1].imagePath, "MySkin/portrait.png", "the wrapping folder is prefixed onto assets and the real file name wins") eq(#wrapped.pages[1].controls, 1, "the wrapped items parsed") love.filesystem.write("skins/vector.deltaskin/info.json", PDF_JSON) local vectorId, vectorErr = TouchSkin.installArchive("vector.deltaskin", "PK\3\4stub") eq(vectorId, nil, "a PDF-only skin is refused instead of installing invisible") check(tostring(vectorErr):find("PDF artwork", 1, true) ~= nil, "with the message that asks for a PNG version") eq(love.filesystem.read("skins/vector.deltaskin"), nil, "and the refused archive is not left behind") eq(select(1, TouchSkin.installArchive("skin.gbcskin", "PK\3\4stub")), nil, "a GBA4iOS .gbcskin is refused at the door") local _, legacyErr = TouchSkin.installArchive("skin.gbaskin", "PK\3\4stub") check(tostring(legacyErr):find("GBA4iOS", 1, true) ~= nil, "with a message that names the format") eq(select(1, TouchSkin.installArchive("skin.rar", "PK\3\4stub")), nil, "an unknown archive extension is refused") eq(TouchSkin.archiveId("pad.deltaskin"), "pad", "archiveId strips .deltaskin") eq(TouchSkin.archiveId("pad.zip"), "pad", "archiveId strips .zip") eq(TouchSkin.archiveId("pad"), nil, "a bare name is not an archive") local AUTHORED = [[ return { name = "Authored", pages = { { name = "portrait", orient = "portrait", fullScreen = true, viewport = { x = 0, y = 0, w = 1, h = 0.5 }, controls = { { bind = "a", x = 0.8, y = 0.75, w = 0.2, h = 0.1, shape = "radial" }, { bind = "b", x = 0.6, y = 0.8, w = 0.2, h = 0.1, shape = "radial", reachRight = 1.5 }, { bind = "start", x = 0.5, y = 0.95, w = 0.1, h = 0.04 }, { bind = "menu_toggle", x = 0.05, y = 0.05, w = 0.08, h = 0.04 }, { bind = "nul", x = 0.2, y = 0.7, w = 0.3, h = 0.2, image = "img/dpad.png" }, } }, { name = "landscape", orient = "landscape", fullScreen = true, controls = { { bind = "a", x = 0.9, y = 0.8, w = 0.1, h = 0.15, shape = "radial" }, } }, } } ]] local authored = assert(TouchSkin.parseNative(AUTHORED)) authored.id = "authored" authored.root = "skins/authored" local cfgText = TouchSkin.toRetroArchConfig(authored) check(cfgText:find("overlays = 2", 1, true) ~= nil, "the cfg declares its overlays") local reparsed = assert(TouchSkin.parse(cfgText)) eq(#reparsed.pages, 2, "the generated cfg round-trips both pages") eq(reparsed.pages[1].name, "portrait", "and their names") eq(#reparsed.pages[1].controls, 5, "and every desc") eq(reparsed.pages[1].controls[1].spec, "a", "binds survive the round trip") near(reparsed.pages[1].controls[1].x, 0.8, "centres survive the round trip") near(reparsed.pages[1].controls[1].rangeX, 0.1, "half extents survive") eq(reparsed.pages[1].controls[1].shape, "radial", "hitbox shape survives") near(reparsed.pages[1].controls[2].reachRight, 1.5, "per-side reach survives") eq(reparsed.pages[1].controls[4].hotkeys[1], "menu", "hotkeys survive") check(reparsed.pages[1].controls[5].decorative, "decoration stays decoration") eq(reparsed.pages[1].controls[5].imagePath, "img/dpad.png", "and keeps its art") near(reparsed.pages[1].viewport.h, 0.5, "the screen cutout survives") eq(reparsed.pages[1].orient, "portrait", "the orientation lock survives by name") local KEY_SKIN = [[ return { name = "Keys", pages = { { name = "portrait", fullScreen = true, controls = { { bind = "key:escape", x = 0.5, y = 0.5, w = 0.1, h = 0.1 }, } }, } } ]] local keySkin = assert(TouchSkin.parseNative(KEY_SKIN)) local keyCfg = TouchSkin.toRetroArchConfig(keySkin) check(keyCfg:find("retrok_escape", 1, true) ~= nil, "a key bind exports in the grammar RetroArch understands") check(keyCfg:find("key:escape", 1, true) == nil, "and not in the native spelling") eq(assert(TouchSkin.parse(keyCfg)).pages[1].controls[1].keys[1], "escape", "which this importer still reads back as the same key") local areaCfg = TouchSkin.toRetroArchConfig({ pages = area.pages }) local areaBack = assert(TouchSkin.parse(areaCfg)) eq(#areaBack.pages[1].controls, #ap.controls, "an area desc exports as one desc, not eight overlapping ones") check(areaCfg:find("dpad_area", 1, true) ~= nil, "the area kind is written back") check(areaCfg:find('_up = "start"', 1, true) ~= nil, "with its output override") eq(areaBack.pages[1].controls[16].spec, "start", "which survives the round trip") local areaInfo = assert(DeltaSkin.build({ id = "area", pages = area.pages })) local areaRep = areaInfo.representations.iphone.edgeToEdge.portrait local dpadItem for _, item in ipairs(areaRep.items) do if not dpadItem and type(item.inputs) == "table" and item.inputs.up then dpadItem = item end end check(dpadItem ~= nil, "the same area exports to Delta as one d-pad item") eq(dpadItem.inputs.left, "left", "carrying each direction") eq(#areaRep.items, 3, "one per area desc, not eight stacked on one another") local raPath = os.tmpname() .. "-ra.zip" local raWritten, raMissing = TouchSkin.exportRetroArch(authored, raPath) eq(raWritten, raPath, "exportRetroArch writes where it was told") eq(raMissing[1], "img/dpad.png", "and reports art it could not find") local raZip = unzip(readBytes(raPath)) eq(raZip[1], "overlay.cfg", "the RetroArch zip leads with overlay.cfg") check(raZip["overlay.cfg"] ~= nil, "and the entry has bytes") check(TouchSkin.parse(raZip["overlay.cfg"]) ~= nil, "which RetroArch grammar accepts") os.remove(raPath) local dsPath = os.tmpname() .. ".deltaskin" local dsWritten, _, dsWarnings = TouchSkin.exportDelta(authored, { path = dsPath }) eq(dsWritten, dsPath, "exportDelta writes where it was told") check(#dsWarnings > 0, "and warns that per-button art has nowhere to go") local dsZip = unzip(readBytes(dsPath)) eq(dsZip[1], "info.json", "the .deltaskin leads with info.json") local info = assert(Json.decode(dsZip["info.json"])) eq(info.gameTypeIdentifier, "com.rileytestut.delta.game.gbc", "the export claims the GBC game type") eq(info.name, "Authored", "and carries the skin name") check(info.identifier:find("authored", 1, true) ~= nil, "identifier names the skin") local rep = info.representations.iphone.edgeToEdge.portrait check(rep ~= nil, "an iPhone edgeToEdge portrait representation is emitted") eq(info.representations.iphone.standard.portrait.mappingSize.width, 1080, "standard portrait maps 1080 wide") eq(rep.mappingSize.height, 1920, "portrait maps 1920 tall") eq(#rep.items, 4, "only bound controls become Delta items") eq(rep.items[1].inputs[1], "a", "the first item is A") eq(rep.items[1].mask, "circle", "a radial hitbox exports as a circle mask") eq(rep.items[1].frame.x, 756, "frame x is top-left, not centre") eq(rep.items[1].frame.width, 216, "frame width is the full extent") eq(rep.items[2].extendedEdges.right, 54, "reach exports as extendedEdges") eq(rep.items[4].inputs[1], "menu", "the menu hotkey exports as a Delta host input") eq(rep.screens[1].inputFrame.width, 160, "the screen crop is a full GB frame") eq(rep.screens[1].outputFrame.height, 960, "and the output frame follows the viewport") eq(info.representations.iphone.edgeToEdge.landscape.mappingSize.width, 1920, "the landscape page maps 1920 wide") local back = assert(DeltaSkin.parse(dsZip["info.json"])) eq(#back.pages, 2, "the exported skin re-imports both orientations") local bp = back.pages[1] eq(#bp.controls, 4, "with every bound control") near(bp.controls[1].x, 0.8, "and the same centres it started with") near(bp.controls[1].rangeX, 0.1, "and the same half extents") eq(bp.controls[1].shape, "radial", "and the same hitbox shape") near(bp.controls[2].reachRight, 1.5, "and the same reach") near(bp.viewport.h, 0.5, "and the same screen cutout") os.remove(dsPath) love.filesystem.write("skins/collide/overlay.cfg", [[ overlays = 1 overlay0_name = "collide" overlay0_descs = 1 overlay0_desc0 = "a,0.5,0.5,rect,0.05,0.05" ]]) local collide = assert(TouchSkin.load("skins/collide", "collide")) local defaultDelta = assert(TouchSkin.exportDelta(collide)) eq(defaultDelta, "skins/_export/collide.deltaskin", "a default export lands outside the folder the skin list scans") local listedRoot, listedExport for _, entry in ipairs(TouchSkin.list()) do if entry.id == "collide" then listedRoot = entry.root end if entry.id == "_export" then listedExport = true end end eq(listedRoot, "skins/collide", "so the export cannot shadow the skin it came from") check(not listedExport, "and the export folder is not a skin of its own") T.finish("skin_format_import")