Files
gen1recomp/tests/engine/touch_skin_test.lua
T
2026-08-17 06:47:33 -04:00

326 lines
14 KiB
Lua

-- RetroArch-format touch skins (#1386): .cfg parsing, hitboxes, the press /
-- hotkey path through TouchControls, and the screen viewport the renderer
-- fits the Game Boy picture into.
-- luajit tests/engine/touch_skin_test.lua
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 TouchControls = require("src.core.TouchControls")
local Input = require("src.core.Input")
local CFG = [[
overlays = 2
overlay0_name = "shell"
overlay0_overlay = img/back.png
overlay0_full_screen = true
overlay0_normalized = true
overlay0_alpha_mod = 0.001
overlay0_range_mod = 1.5
overlay0_viewport = "0.0,0.0,1.0,0.5"
overlay0_viewport_fill = true
overlay0_descs = 7
overlay0_desc0 = "a,0.80,0.75,radial,0.10,0.05"
overlay0_desc0_overlay = img/a.png
overlay0_desc1 = "left|down,0.20,0.90,rect,0.05,0.05"
overlay0_desc2 = "nul,0.20,0.75,rect,0.15,0.10"
overlay0_desc2_overlay = img/dpad.png
overlay0_desc3 = "overlay_next,0.95,0.55,radial,0.03,0.02"
overlay0_desc3_next_target = "second"
overlay0_desc4 = "hold_fast_forward,0.05,0.55,radial,0.03,0.02"
overlay0_desc5 = "reset,0.50,0.98,rect,0.04,0.02"
overlay0_desc6 = "key:f5,0.10,0.98,rect,0.04,0.02"
overlay1_name = "second"
overlay1_full_screen = true
overlay1_normalized = true
overlay1_descs = 1
overlay1_desc0 = "start,0.50,0.50,rect,0.10,0.10"
]]
local skin = assert(TouchSkin.parse(CFG))
eq(#skin.pages, 2, "two overlay pages parsed")
local page = skin.pages[1]
eq(page.name, "shell", "page name")
eq(page.imagePath, "img/back.png", "page background path")
check(page.fullScreen, "full_screen parsed")
eq(page.alphaMod, 0.001, "overlay alpha_mod parsed")
eq(#page.controls, 7, "seven descs parsed")
check(page.viewport ~= nil, "viewport parsed")
eq(page.viewport.h, 0.5, "viewport height")
check(page.viewportFill, "viewport_fill parsed")
local a, diag, decor, nextBtn, ff, reset, keyBtn = unpack(page.controls)
eq(a.buttons[1], "a", "desc0 binds GB a")
eq(a.shape, "radial", "desc0 is radial")
eq(a.alphaMod, 0.001, "desc inherits the overlay alpha_mod")
eq(a.rangeMod, 1.5, "desc inherits the overlay range_mod")
eq(table.concat(diag.buttons, "+"), "left+down", "pipe-separated binds are one control")
check(decor.decorative, "nul desc is decoration only")
eq(decor.imagePath, "img/dpad.png", "decoration still carries art")
eq(nextBtn.hotkeys[1], "overlay_next", "overlay_next mapped")
eq(nextBtn.nextTarget, "second", "next_target parsed")
eq(ff.hotkeys[1], "fast_forward_hold", "hold_fast_forward mapped")
eq(reset.hotkeys[1], "soft_reset", "reset mapped")
eq(keyBtn.keys[1], "f5", "key: binds a keyboard key")
-- geometry: x/y are the centre, range_x/range_y are half extents, both
-- normalized to the window when full_screen is set
local W, H = 400, 800
local cx, cy, halfW, halfH = TouchSkin.controlGeometry(page, a, W, H)
eq(cx, 320, "control centre x")
eq(cy, 600, "control centre y")
eq(halfW, 40, "control half width")
eq(halfH, 40, "control half height")
-- range_mod 1.5 widens the hitbox but not the art
check(TouchSkin.hits(page, a, W, H, 320, 600), "centre hits")
check(TouchSkin.hits(page, a, W, H, 320 + 55, 600), "inside range_mod hits")
check(not TouchSkin.hits(page, a, W, H, 320 + 65, 600), "past range_mod misses")
-- radial, so the corner of the bounding box is outside
check(not TouchSkin.hits(page, a, W, H, 320 + 55, 600 + 55), "radial corner misses")
-- rect hitbox does take its corner
check(TouchSkin.hits(page, diag, W, H, 80 + 19, 720 + 19), "rect corner hits")
-- ---------------------------------------------------------------- runtime
TouchControls:init()
TouchControls.active = true
TouchControls.enabled = true
TouchSkin.setOverlayLive(true)
TouchSkin.setActive(skin)
Input:init()
local ww, wh = love.graphics.getDimensions()
local function at(nx, ny) return nx * ww, ny * wh end
check(TouchSkin.hasViewport(), "active skin reports a viewport")
-- the renderer fits the Game Boy picture into this rect instead of the window
local sx, sy, sw, sh, fill = TouchSkin.viewport(W, H)
eq(sx, 0, "viewport x") eq(sy, 0, "viewport y")
eq(sw, 400, "viewport w") eq(sh, 400, "viewport h")
check(fill, "viewport fill flag returned")
eq(TouchSkin.viewport(W, H), 0, "viewport is window-relative, not page-relative")
check(TouchControls:touchpressed("f1", at(0.80, 0.75)), "press on A is captured")
check(Input:isDown("a"), "skin A presses GB a")
TouchControls:touchreleased("f1", at(0.80, 0.75))
check(not Input:isDown("a"), "lifting releases GB a")
-- one finger inside a combined desc holds both directions
TouchControls:touchpressed("f2", at(0.20, 0.90))
check(Input:isDown("left") and Input:isDown("down"), "combined desc holds both buttons")
-- sliding onto the decoration drops them without capturing anything new
TouchControls:touchmoved("f2", at(0.20, 0.75))
check(not Input:isDown("left") and not Input:isDown("down"),
"sliding off a control releases it")
TouchControls:touchreleased("f2", at(0.20, 0.75))
check(TouchControls:touchpressed("f3", at(0.20, 0.75)) == nil,
"a press on decoration alone is not captured")
-- hotkeys reach the host once per press edge and once per release
local fired = {}
TouchControls:setHotkeyHandler(function(action, pressed)
fired[#fired + 1] = action .. ":" .. tostring(pressed)
end)
TouchControls:touchpressed("f4", at(0.05, 0.55))
eq(fired[1], "fast_forward_hold:true", "held hotkey fires on press")
TouchControls:touchreleased("f4", at(0.05, 0.55))
eq(fired[2], "fast_forward_hold:false", "held hotkey fires on release")
-- a stranded finger unwinds through reset, not just through touchreleased
fired = {}
TouchControls:touchpressed("f5", at(0.05, 0.55))
TouchControls:touchpressed("f6", at(0.80, 0.75))
check(Input:isDown("a"), "second finger holds A")
TouchControls:reset()
eq(fired[2], "fast_forward_hold:false", "reset releases held hotkeys")
check(not Input:isDown("a"), "reset releases held buttons")
-- overlay_next swaps the page, and the new page's controls are what hit
eq(TouchSkin.page().name, "shell", "starts on page 1")
TouchControls:touchpressed("f7", at(0.95, 0.55))
eq(TouchSkin.page().name, "second", "overlay_next honours next_target")
TouchControls:touchreleased("f7", at(0.95, 0.55))
TouchControls:touchpressed("f8", at(0.50, 0.50))
check(Input:isDown("start"), "page 2 control presses GB start")
TouchControls:touchreleased("f8", at(0.50, 0.50))
TouchSkin.setPage("shell")
-- ------------------------------------------------------------ persistence
local cfg = TouchControls.normalizeConfig({ enabled = true, skin = "gb_anim" })
eq(cfg.skin, "gb_anim", "normalizeConfig keeps the skin id")
eq(TouchControls.normalizeConfig({ skin = "" }).skin, nil, "empty skin id drops")
eq(TouchControls.normalizeConfig({ skin = 7 }).skin, nil, "non-string skin id drops")
TouchControls.skinId = "gb_anim"
eq(TouchControls:config().skin, "gb_anim", "config() round-trips the skin id")
-- ----------------------------------------------- screen-hole detection
-- Border art usually ships with a transparent screen hole and no viewport
-- key. Detection is verified against real art by
-- tests/drivers/tv_skin_shot.lua; here it only has to fail safely.
eq(TouchSkin.detectViewport("assets/skins/tv_crt", nil), nil,
"no bezel path detects nothing")
eq(TouchSkin.detectViewport("assets/skins/tv_crt", ""), nil,
"empty bezel path detects nothing")
eq(TouchSkin.detectViewport("assets/skins/tv_crt", "img/does_not_exist.png"), nil,
"a missing bezel detects nothing")
-- --------------------------------------------------------- bundled skin
local bundled = TouchSkin.load("assets/skins/gb_anim", "gb_anim")
check(bundled ~= nil, "bundled gb_anim skin loads")
if bundled then
eq(#bundled.pages, 2, "gb_anim has a DMG and a Color page")
eq(bundled.pages[1].name, "GameBoy", "gb_anim page 1")
check(bundled.pages[1].viewport ~= nil, "gb_anim declares a screen viewport")
eq(bundled.pages[1].imagePath, "img/gb_back.png", "gb_anim bezel art")
local named = {}
for _, ctl in ipairs(bundled.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], "gb_anim binds GB " .. btn)
end
end
local tv = TouchSkin.load("assets/skins/tv_crt", "tv_crt")
check(tv ~= nil, "bundled tv_crt desktop bezel loads")
if tv then
eq(#tv.pages, 1, "tv_crt is a single page")
eq(#tv.pages[1].controls, 0, "tv_crt binds nothing: it is a frame")
check(tv.pages[1].viewport ~= nil, "tv_crt names the tube as its viewport")
eq(tv.pages[1].imagePath, "img/tv-integer.png", "tv_crt bezel art")
TouchSkin.setActive(tv)
TouchSkin.setOverlayLive(false)
check(TouchSkin.decorativeOnly(), "tv_crt counts as decoration")
check(TouchSkin.drawable(), "so it draws on a desktop window")
end
-- ------------------------------------------- decorative desktop bezels
-- A skin whose page binds nothing is a frame, not a pad: it draws where the
-- touch overlay does not (desktop), and a gamepad must not hide it.
local BEZEL = [[
overlays = 1
overlay0_name = "tv"
overlay0_overlay = img/tv.png
overlay0_full_screen = true
overlay0_normalized = true
overlay0_descs = 1
overlay0_desc0 = "nul,0.5,0.5,rect,0.5,0.5"
overlay0_viewport = "0.2,0.1,0.6,0.8"
]]
local bezel = assert(TouchSkin.parse(BEZEL))
TouchSkin.setActive(bezel)
TouchSkin.setOverlayLive(false)
check(TouchSkin.decorativeOnly(), "a descs-only-nul skin is decoration")
check(TouchSkin.drawable(), "a decorative skin draws with the overlay off (desktop)")
check(TouchSkin.hasViewport(), "and its viewport still places the picture")
TouchControls.active = false
TouchControls.enabled = true
TouchControls.controllerHidden = true
check(TouchControls:visible(), "a gamepad does not hide a decorative bezel")
-- a skin that binds buttons keeps following the mobile / POKEPORT_TOUCH gate
TouchSkin.setActive(skin)
TouchSkin.setOverlayLive(false)
check(not TouchSkin.decorativeOnly(), "a skin with binds is not decoration")
check(not TouchSkin.drawable(), "and it does not draw where the overlay is off")
check(not TouchSkin.hasViewport(), "so it cannot shrink the picture either")
check(not TouchControls:visible(), "nor draw over a desktop window")
TouchSkin.setOverlayLive(true)
check(TouchSkin.drawable(), "with the overlay live it draws again")
TouchControls.active = true
TouchControls.controllerHidden = false
-- ------------------------------------------------------- native format
local native = TouchSkin.serialize(skin)
check(native:find("^return {"), "serialize emits a Lua data chunk")
local back, nerr = TouchSkin.parseNative(native)
check(back ~= nil, "native round-trip parses: " .. tostring(nerr))
if back then
eq(#back.pages, #skin.pages, "round-trip keeps the page count")
local bp, sp = back.pages[1], skin.pages[1]
eq(#bp.controls, #sp.controls, "round-trip keeps the control count")
eq(bp.controls[1].spec, sp.controls[1].spec, "round-trip keeps binds")
eq(bp.controls[1].rangeX, sp.controls[1].rangeX, "round-trip keeps half extents")
eq(bp.controls[1].shape, sp.controls[1].shape, "round-trip keeps the hitbox shape")
eq(bp.viewport.h, sp.viewport.h, "round-trip keeps the viewport")
check(bp.viewportFill, "round-trip keeps viewport_fill")
eq(bp.alphaMod, sp.alphaMod, "round-trip keeps alpha_mod")
eq(bp.controls[3].decorative, true, "round-trip keeps decoration")
eq(bp.controls[4].nextTarget, "second", "round-trip keeps next_target")
end
-- the native format carries the separate pressed image a .cfg cannot
local twoState = assert(TouchSkin.parseNative([[
return { pages = { { name = "p", controls = {
{ bind = "a", x = 0.5, y = 0.5, w = 0.2, h = 0.2, shape = "radial",
image = "up.png", imagePressed = "down.png" },
} } } }
]]))
eq(twoState.pages[1].controls[1].imagePath, "up.png", "native idle image")
eq(twoState.pages[1].controls[1].pressedImagePath, "down.png", "native pressed image")
eq(twoState.pages[1].controls[1].rangeX, 0.1, "native w is a full width, not a range")
check(TouchSkin.parseNative("return 5") == nil, "a non-table skin.lua is rejected")
check(TouchSkin.parseNative("return { pages = {} }") == nil, "a pageless skin.lua is rejected")
check(TouchSkin.parseNative("this is not lua") == nil, "a broken skin.lua is rejected")
-- skins are third-party data: the chunk must not see the host globals
check(TouchSkin.parseNative("return { pages = { { controls = {} } }, hit = love ~= nil }")
~= nil, "skin.lua loads in an empty environment")
-- --------------------------------------------------------------- export
local SkinZip = require("src.core.SkinZip")
local blob = SkinZip.encode({
{ name = "skin.lua", data = "return {}\n" },
{ name = "img/a.png", data = "\137PNG\r\n\26\n binary \0 bytes" },
})
eq(blob:sub(1, 4), "PK\3\4", "zip starts with a local file header")
check(blob:find("PK\5\6", 1, true) ~= nil, "zip ends with a central directory record")
check(blob:find("img/a.png", 1, true) ~= nil, "zip carries the entry name")
check(blob:find("binary", 1, true) ~= nil, "stored entries keep their bytes verbatim")
local bundledSkin = TouchSkin.load("assets/skins/gb_anim", "gb_anim")
if bundledSkin then
eq(#TouchSkin.assetPaths(bundledSkin), 17, "gb_anim names 17 distinct images")
local tmp = os.tmpname() .. ".zip"
local path, missing = TouchSkin.export(bundledSkin, tmp)
eq(path, tmp, "export writes to the requested path")
eq(#missing, 0, "every gb_anim image was found")
local f = io.open(tmp, "rb")
check(f ~= nil, "exported zip reached disk")
if f then
local bytes = f:read("*a")
f:close()
eq(bytes:sub(1, 4), "PK\3\4", "exported file is a zip")
check(bytes:find("skin.lua", 1, true) ~= nil, "export includes the native skin.lua")
check(bytes:find("img/gb_back.png", 1, true) ~= nil, "export includes the bezel")
check(bytes:find("overlay.cfg", 1, true) ~= nil,
"a .cfg-sourced skin also exports its original config")
os.remove(tmp)
end
end
TouchSkin.setActive(nil)
TouchControls:setHotkeyHandler(nil)
T.finish("touch_skin")