263 lines
9.4 KiB
Text
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
|