-- 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