mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
241 lines
9.1 KiB
Lua
241 lines
9.1 KiB
Lua
-- Gold's frame and input seams: proof that all six shared hooks fire on a Gold
|
|
-- boot, with the Gen 1 payloads, and that subscribing to them does not change
|
|
-- the picture.
|
|
--
|
|
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_frame_seams.lua \
|
|
-- POKEPORT_SHOT_DIR=/tmp/gold-seams love .
|
|
--
|
|
-- Two halves, and the second is the one that matters. A hook that fires is
|
|
-- easy; a hook that fires and MOVES A PIXEL is worse than no hook at all,
|
|
-- because it silently changes what every existing Gold screenshot means. So
|
|
-- this shoots the overworld, a menu over the overworld, CLASSIC, and CLASSIC +
|
|
-- GBC FX with nothing subscribed, wraps all six hooks with pass-throughs that
|
|
-- draw nothing, shoots the same four frames again, and compares the PNG bytes.
|
|
-- Identical files are the claim; the shots are left on disk either way so a
|
|
-- human can look at the picture the port is actually producing.
|
|
--
|
|
-- The four frames are chosen to cover every branch of Game2:draw: no canvas at
|
|
-- all, the zone pass alone, the zone pass into a texture GBC FX then reads, and
|
|
-- (once the wraps are on) the render.compose path that forces a canvas even
|
|
-- when no display mode wanted one.
|
|
local U = require("tests.drivers.util")
|
|
|
|
local Runtime = require("src.mods.Runtime")
|
|
local Hooks = require("src.mods.Hooks")
|
|
local GbcPalette = require("src.render.GbcPalette")
|
|
local GBCFX = require("src.render.GBCFX")
|
|
|
|
local OWNER = "driver_frame_seams"
|
|
|
|
local function readFile(path)
|
|
local f = io.open(path, "rb")
|
|
if not f then return nil end
|
|
local body = f:read("*a")
|
|
f:close()
|
|
return body
|
|
end
|
|
|
|
return function(game)
|
|
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-seams"
|
|
local failures = 0
|
|
local function check(ok, what)
|
|
if ok then
|
|
print("[driver] ok " .. what)
|
|
else
|
|
failures = failures + 1
|
|
print("[driver] FAIL " .. what)
|
|
end
|
|
end
|
|
|
|
U.wait(45)
|
|
assert(game.world and game.world.map, "gold world did not boot")
|
|
|
|
-- The four frames, each named for the display state it exercises. `fx` is
|
|
-- requested rather than asserted: GBCFX.isSupported refuses on mobile GPUs
|
|
-- and a headless checkout may have no shader at all, in which case that
|
|
-- frame simply repeats the CLASSIC one and the comparison still means
|
|
-- something.
|
|
local frames = {
|
|
{ name = "world", color = "gbc", fx = 0 },
|
|
{ name = "classic", color = "classic", fx = 0 },
|
|
{ name = "classicfx", color = "classic", fx = 2 },
|
|
{ name = "menu", color = "gbc", fx = 0, menu = true },
|
|
}
|
|
|
|
local function shoot(tag)
|
|
local menuOpen = false
|
|
for _, frame in ipairs(frames) do
|
|
GbcPalette.setMode(frame.color)
|
|
GBCFX.setLevel(frame.fx)
|
|
if frame.menu and not menuOpen then
|
|
game:openStartMenu()
|
|
menuOpen = true
|
|
U.wait(6)
|
|
end
|
|
U.wait(2)
|
|
U.shot(game, ("%s/%s-%s.png"):format(out, frame.name, tag))
|
|
end
|
|
if menuOpen then
|
|
-- B closes the START menu; leave the stack the way it was found so the
|
|
-- second pass shoots the same scene the first one did
|
|
U.tap(game, "b")
|
|
U.wait(8)
|
|
end
|
|
GbcPalette.setMode("gbc")
|
|
GBCFX.setLevel(0)
|
|
end
|
|
|
|
-- POKEPORT_SEAM_BASELINE=<tag> shoots the four frames under that tag and
|
|
-- stops, with nothing ever subscribed. That is how the composite was A/B'd
|
|
-- against the revision of Game2:draw that predates these seams: run it once
|
|
-- with the old draw in place, once with the new one, and diff the PNGs.
|
|
local baseline = os.getenv("POKEPORT_SEAM_BASELINE")
|
|
if baseline and baseline ~= "" then
|
|
shoot(baseline)
|
|
print(("[driver] baseline shots (%s) in %s"):format(baseline, out))
|
|
return
|
|
end
|
|
|
|
shoot("before")
|
|
|
|
-- A live bus, in case this checkout booted with no mods (Runtime's null
|
|
-- object has no chains table and wantsHook is false for everything).
|
|
if not (Runtime.hooks and Runtime.hooks.wrap) then
|
|
Runtime.hooks = Hooks.new()
|
|
end
|
|
local hooks = Runtime.hooks
|
|
|
|
local seen = {}
|
|
local payload = {}
|
|
local function record(name, ctx)
|
|
seen[name] = (seen[name] or 0) + 1
|
|
payload[name] = payload[name] or ctx
|
|
end
|
|
|
|
-- input.step: (game, dt), before the pad is read
|
|
hooks:wrap("input.step", function(nextFn, g, dt)
|
|
record("input.step", { game = g, dt = dt })
|
|
return nextFn(g, dt)
|
|
end, 0, OWNER)
|
|
|
|
-- input.pointer: (game, event); returning false is "not consumed"
|
|
hooks:wrap("input.pointer", function(nextFn, g, ev)
|
|
record("input.pointer", { game = g, ev = ev })
|
|
return nextFn(g, ev)
|
|
end, 0, OWNER)
|
|
|
|
-- render.zones: (game, zones) -> zones. Pass the list straight through; a
|
|
-- wrap that returned a new list would be testing itself, not the seam.
|
|
hooks:wrap("render.zones", function(nextFn, g, zones)
|
|
record("render.zones", { game = g, zones = zones })
|
|
return nextFn(g, zones)
|
|
end, 0, OWNER)
|
|
|
|
-- render.compose: (renderer, ctx) -> true to take the window. Declines, so
|
|
-- the engine composite still runs -- which is the case the byte comparison
|
|
-- below is about.
|
|
hooks:wrap("render.compose", function(nextFn, r, ctx)
|
|
record("render.compose", { renderer = r, ctx = ctx })
|
|
return nextFn(r, ctx)
|
|
end, 0, OWNER)
|
|
|
|
-- render.letterbox: (ctx), draws nothing
|
|
hooks:wrap("render.letterbox", function(nextFn, ctx)
|
|
record("render.letterbox", { ctx = ctx })
|
|
return nextFn(ctx)
|
|
end, 0, OWNER)
|
|
|
|
-- render.hud: (game, viewport), draws nothing
|
|
hooks:wrap("render.hud", function(nextFn, g, viewport)
|
|
record("render.hud", { game = g, viewport = viewport })
|
|
return nextFn(g, viewport)
|
|
end, 0, OWNER)
|
|
|
|
U.wait(4)
|
|
-- a pointer the engine itself never generates headlessly
|
|
game:mousepressed(40, 30, 1, false)
|
|
game:mousemoved(48, 36, 8, 6, false)
|
|
game:mousereleased(48, 36, 1, false)
|
|
U.wait(2)
|
|
|
|
shoot("after")
|
|
|
|
-- ---- the seams fired, with the Gen 1 payloads -----------------------------
|
|
|
|
for _, name in ipairs({ "input.step", "input.pointer", "render.zones",
|
|
"render.compose", "render.letterbox",
|
|
"render.hud" }) do
|
|
check((seen[name] or 0) > 0, name .. " fires on Gold (" ..
|
|
tostring(seen[name] or 0) .. " calls)")
|
|
end
|
|
|
|
local step = payload["input.step"]
|
|
check(step and step.game == game, "input.step receives the live Game object")
|
|
check(step and math.abs((step.dt or 0) - 1 / 60) < 1e-9,
|
|
"input.step receives the fixed-step dt")
|
|
|
|
local ptr = payload["input.pointer"]
|
|
check(ptr and ptr.game == game, "input.pointer receives the live Game object")
|
|
check(ptr and ptr.ev and ptr.ev.phase == "pressed"
|
|
and ptr.ev.source == "mouse" and ptr.ev.id == "mouse"
|
|
and ptr.ev.x == 40 and ptr.ev.y == 30 and ptr.ev.button == 1
|
|
and ptr.ev.dx == 0 and ptr.ev.dy == 0,
|
|
"input.pointer carries phase/source/id/x/y/dx/dy/button")
|
|
|
|
local zones = payload["render.zones"]
|
|
check(zones and zones.game == game, "render.zones receives the live Game")
|
|
|
|
local hud = payload["render.hud"]
|
|
local vp = hud and hud.viewport
|
|
local ww, wh = love.graphics.getDimensions()
|
|
check(vp and vp.width == ww and vp.height == wh,
|
|
"render.hud viewport carries the window size")
|
|
check(vp and vp.gameWidth == 160 * vp.scale
|
|
and vp.gameHeight == 144 * vp.scale,
|
|
"render.hud viewport playfield is 160x144 at the fit scale")
|
|
check(vp and vp.gameX == math.floor((ww - vp.gameWidth) / 2)
|
|
and vp.gameY == math.floor((wh - vp.gameHeight) / 2),
|
|
"render.hud viewport playfield is centred")
|
|
check(vp and vp.dpiX ~= nil and vp.dpiY ~= nil,
|
|
"render.hud viewport carries the dpi scales")
|
|
|
|
local lb = payload["render.letterbox"] and payload["render.letterbox"].ctx
|
|
check(lb and lb.ww == ww and lb.wh == wh,
|
|
"render.letterbox carries ww/wh")
|
|
check(lb and lb.pw ~= nil and lb.ph ~= nil and lb.ox ~= nil and lb.oy ~= nil
|
|
and lb.vpw ~= nil and lb.vph ~= nil and lb.scale ~= nil
|
|
and lb.dpiX ~= nil and lb.dpiY ~= nil,
|
|
"render.letterbox carries pw/ph/ox/oy/vpw/vph/scale/dpi")
|
|
check(lb and type(lb.worldActive) == "boolean",
|
|
"render.letterbox carries worldActive")
|
|
|
|
local ctx = payload["render.compose"] and payload["render.compose"].ctx
|
|
check(payload["render.compose"]
|
|
and payload["render.compose"].renderer == game,
|
|
"render.compose receives the compositor in the renderer position")
|
|
check(ctx and ctx.uiCanvas ~= nil and ctx.worldCanvas == ctx.uiCanvas,
|
|
"render.compose hands over Gold's one scene canvas under both keys")
|
|
check(ctx and ctx.ww == ww and ctx.wh == wh and ctx.uiw == 160
|
|
and ctx.uih == 144 and ctx.scale ~= nil and ctx.ox ~= nil
|
|
and ctx.oy ~= nil and ctx.vpw ~= nil and ctx.vph ~= nil
|
|
and ctx.dpiX ~= nil and ctx.secondScreen ~= nil,
|
|
"render.compose carries the Gen 1 frame metrics")
|
|
|
|
-- ---- and moved nothing ----------------------------------------------------
|
|
|
|
for _, frame in ipairs(frames) do
|
|
local a = readFile(("%s/%s-before.png"):format(out, frame.name))
|
|
local b = readFile(("%s/%s-after.png"):format(out, frame.name))
|
|
check(a ~= nil and b ~= nil and a == b,
|
|
("subscribing does not change the %s frame"):format(frame.name))
|
|
end
|
|
|
|
hooks:removeOwner(OWNER)
|
|
print(("[driver] shots in %s"):format(out))
|
|
if failures > 0 then
|
|
print(("[driver] FAILED (%d)"):format(failures))
|
|
else
|
|
print("[driver] PASS")
|
|
end
|
|
end
|