singe/testScripts/ports/algDrive.singe
2026-09-22 18:32:07 -05:00

263 lines
9.4 KiB
Text

-- Drives an American Laser Games title (the original or its port) from what the game shows,
-- the same way for both. The driver file defines what it takes: a view() of the game's state
-- and algPress(switch, device, down) / algPointer(device, x, y) to press a gun's trigger or
-- put its pointer; this file decides. The trace lines it prints are what compare.sh diffs.
--
-- The policy: the first gun skips the intro on frame 300 of the run and joins at the player
-- select; at the town select it points at the first unbeaten quadrant and shoots; in a level
-- it shoots the first hitbox of the current window's frame three frames into the window (a
-- miss well outside it on every fifth move, no shot on every seventh), reloads by pointing
-- offscreen when its ammo is out, and takes the continue.
ALG_FRAMES = tonumber(os.getenv("PORT_FRAMES")) or 60000
local answered = {} -- Windows shot at, by "level/move/lives".
local tried = {} -- Moves met before, by "level/move".
local frames = 0
local joined = false
local coined = false
local continued = false
local continueCoined = false
local seeks = 0
SWITCH_RETURN_KEY = 13 -- The return key's keysym, as the typing edition names it.
local wrongTyped = {} -- Moves already given their wrong letter.
local pendingRelease = nil -- { switch, device } pressed this frame, let go the next.
function algTraceSeek(frame)
seeks = seeks + 1
debugPrint(string.format("TRACE seek %d", frame))
end
-- PORT_PERFECT=1 plays without a wrong or a missed shot, to walk the whole game in one run.
local perfect = (os.getenv("PORT_PERFECT") == "1")
-- PORT_SERVICE=1 opens the service screen at the attract loop before the game, steps the
-- pointer down it a row at a time with a shot on each (or, on a screen the stick drives, a
-- press and a step down in turn), then leaves it; the trace carries the option under the
-- cursor and every dip while the screen shows. PORT_SHOTS=1 takes a screenshot at the
-- attract loop, on the service screen, in a level just after a shot, at the continue, and
-- at the board, each once, named for the moment.
local walkService = (os.getenv("PORT_SERVICE") == "1")
local shots = (os.getenv("PORT_SHOTS") == "1")
local walked = false
local walkStep = 0
local walkAt = 0
local taken = {}
local shotAt = nil
local function shot(name)
if shots and not taken[name] then
taken[name] = true
singeScreenshot("shot-" .. name)
end
end
local function wrongMove(view)
return (not perfect) and (view.move % 5 == 0) and not view.retry
end
local function missedMove(view)
return (not perfect) and (view.move % 7 == 0) and (view.move % 5 ~= 0) and not view.retry
end
-- Presses a gun's trigger for one frame.
local function tap(switch, device)
algPress(switch, device, true)
pendingRelease = { switch, device }
end
-- view: { screen, step, move, frame, frames, score, lives, ammo, active, window = { from, to },
-- box = { x1, y1, x2, y2 } (the first hitbox of this frame, in the game's pointer
-- coordinates), quadrants = { {x, y}, ... } (the town select's unbeaten choices),
-- offscreen = { x, y } (a pointer position that counts as off the picture),
-- spare (bullets the game never judges, kept back), reloadSwitch (what reloads) }
function algDrive(view)
if pendingRelease then
algPress(pendingRelease[1], pendingRelease[2], false)
pendingRelease = nil
end
frames = frames + 1
local line = string.format("TRACE screen %s step %s move %s score %s lives %s ammo %s", tostring(view.screen), tostring(view.step), tostring(view.move), tostring(view.score), tostring(view.lives), tostring(view.ammo))
if view.service then
line = line .. " opt " .. tostring(view.opt) .. " dips " .. tostring(view.dips)
end
if line ~= lastLine then
local detail = ""
-- PORT_DEBUG=1 adds what the drive decides by: the window, the spare, the free shot, and
-- the pause.
if os.getenv("PORT_DEBUG") == "1" then
detail = string.format(" win %s spare %s free %s paused %s", view.window and (view.window[1] .. "-" .. view.window[2]) or "nil", tostring(view.spare), tostring(view.free), tostring(view.paused))
end
debugPrint(line .. detail .. " disc " .. tostring(view.frame))
lastLine = line
end
if frames >= ALG_FRAMES then
debugPrint("TRACE end after " .. frames .. " frames")
singeQuit()
return
end
-- A typing game (the typing edition of Mad Dog II): the key the view names is released, one
-- a frame; with misses on, every fifth move gets a wrong letter first and every seventh is
-- left untyped (a death), each once per move. The continue is taken once, by the return key.
if view.typed then
if view.continuing then
if (not continued) and (frames % 30 == 0) then
continued = true
algKey(SWITCH_RETURN_KEY)
end
return
end
if view.typing then
local moveKey = tostring(view.screen) .. "/" .. tostring(view.move)
if (not perfect) and (view.move % 7 == 0) and (view.move % 5 ~= 0) and (view.typing ~= SWITCH_RETURN_KEY) then
-- Untyped: the game holds the disc and takes the life.
return
end
if (not perfect) and (view.move % 5 == 0) and (view.typing ~= SWITCH_RETURN_KEY) and not wrongTyped[moveKey] then
wrongTyped[moveKey] = true
algKey((view.typing == 122) and 121 or 122)
return
end
algKey(view.typing)
end
return
end
-- The service screen, walked once before the game.
if walkService and not walked then
if view.service then
if (frames - walkAt) >= 30 then
walkAt = frames
walkStep = walkStep + 1
if walkStep == 3 then
shot("service")
end
if view.serviceKeys then
-- The stick's screen: a press on the option, then a step down; the last option
-- is the way out.
if walkStep % 2 == 1 then
tap(SWITCH_BUTTON1, 0)
else
tap(SWITCH_DOWN, 0)
end
if walkStep > 26 then
walked = true
end
elseif walkStep <= 9 then
algPointer(0, view.width * 0.5, view.height * (walkStep - 0.5) / 9)
tap(SWITCH_BUTTON3, 0)
else
walked = true
tap(SWITCH_BUTTON1, 0)
end
end
return
elseif view.intro and (frames > 200) then
walkAt = frames
tap(SWITCH_SERVICE, nil)
return
end
end
if shots and view.intro and (frames == 330) then
shot("attract")
end
if shots and view.continuing then
shot("continue")
end
if shots and view.board then
shot("board")
end
if shotAt and (frames == shotAt + 3) then
shot("level")
end
-- The player select: START for the first gun, once.
if (view.screen == -2) and not joined and (frames > 60) then
joined = true
tap(SWITCH_START1, 0)
return
end
-- The intro: a coin, then START begins a game (the tutorial or the first level by dip).
-- (A game with no player select, the one-player Johnny Rock, counts as joined from the start.)
if view.intro and (joined or view.noSelect) then
if not coined and (frames > 200) then
coined = true
tap(SWITCH_COIN1, nil)
return
end
if coined and (frames > 300) and (frames % 40 == 0) then
tap(SWITCH_START1, 0)
end
return
end
-- The continue: taken once, with a coin first (Mad Dog II's continue wants a credit).
if view.continuing and not continued and (frames % 30 == 0) then
if not continueCoined then
continueCoined = true
tap(SWITCH_COIN1, nil)
return
end
continued = true
tap(SWITCH_START1, 0)
return
end
-- The town select: the pointer to the first unbeaten quadrant, then the trigger.
if (view.screen == 100) and view.quadrants and view.quadrants[1] and (view.step == 2) and (frames % 20 == 0) then
algPointer(0, view.quadrants[1][1], view.quadrants[1][2])
tap(SWITCH_BUTTON3, 0)
return
end
-- A screen with targets and no windows (the signpost, the mine, Mad Dog II's menu, item, and
-- skull): the first target shot, every twenty frames, while its state waits for shots (the
-- running state unless the view names another).
if view.targets and view.targets[1] and (view.step == (view.targetStep or 2)) and (frames % 20 == 0) then
if (view.ammo ~= nil) and (view.ammo <= (view.spare or 0)) and view.offscreen then
algPointer(0, view.offscreen[1], view.offscreen[2])
tap(view.reloadSwitch or SWITCH_BUTTON3, 0)
else
algPointer(0, view.targets[1][1], view.targets[1][2])
tap(SWITCH_BUTTON3, 0)
end
return
end
-- A level: ammo out (or down to the spare an edition never judges) means a reload by pointing
-- offscreen and pressing the switch the view names (the trigger, or a reload button).
if view.window and (view.ammo ~= nil) and (view.ammo <= (view.spare or 0)) and view.offscreen then
algPointer(0, view.offscreen[1], view.offscreen[2])
tap(view.reloadSwitch or SWITCH_BUTTON3, 0)
return
end
if view.window and view.box then
-- (A move is answered once per life, and once more per life in the game the continue
-- starts over.)
local moveKey = tostring(view.screen) .. "/" .. tostring(view.move)
local key = moveKey .. "/" .. tostring(view.lives) .. (continued and "/c" or "")
view.retry = (tried[moveKey] ~= nil)
if (view.frame >= view.window[1] + 3) and (view.frame <= view.window[2]) and not answered[key] then
answered[key] = true
tried[moveKey] = true
if missedMove(view) then
return
end
local cx, cy = (view.box[1] + view.box[3]) / 2, (view.box[2] + view.box[4]) / 2
if wrongMove(view) then
cx, cy = view.box[3] + 60, view.box[4] + 60
end
algPointer(0, cx, cy)
tap(SWITCH_BUTTON3, 0)
if shotAt == nil then
shotAt = frames
end
end
end
end