Gopher2600/macro/macro.go
2023-08-03 08:09:44 +01:00

536 lines
12 KiB
Go

// This file is part of Gopher2600.
//
// Gopher2600 is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Gopher2600 is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Gopher2600. If not, see <https://www.gnu.org/licenses/>.
package macro
import (
"fmt"
"io"
"os"
"strconv"
"strings"
"github.com/jetsetilly/gopher2600/gui"
"github.com/jetsetilly/gopher2600/hardware"
"github.com/jetsetilly/gopher2600/hardware/riot/ports"
"github.com/jetsetilly/gopher2600/hardware/riot/ports/plugging"
"github.com/jetsetilly/gopher2600/hardware/television"
"github.com/jetsetilly/gopher2600/logger"
"github.com/jetsetilly/gopher2600/userinput"
)
type Emulation interface {
UserInput() chan userinput.Event
VCS() *hardware.VCS
}
type Input interface {
PushEvent(ports.InputEvent) error
}
type TV interface {
AddFrameTrigger(f television.FrameTrigger)
GetFrameInfo() television.FrameInfo
}
type GUI interface {
SetFeature(request gui.FeatureReq, args ...gui.FeatureReqData) error
}
// Macro is a type that allows control of an emulation from a series of instructions
type Macro struct {
emulation Emulation
input Input
tv TV
gui GUI
filename string
instructions []string
quit chan bool
frameNum chan int
}
const headerID = "gopher2600macro"
const (
headerLineID = iota
headerNumLines
)
// NewMacro is the preferred method of initialisation for the Macro type
func NewMacro(filename string, emulation Emulation, input Input, tv TV, gui GUI) (*Macro, error) {
mcr := &Macro{
emulation: emulation,
input: input,
tv: tv,
gui: gui,
filename: filename,
quit: make(chan bool),
frameNum: make(chan int),
}
err := mcr.readFile()
if err != nil {
return nil, fmt.Errorf("macro: %w", err)
}
// attach TV to macro
mcr.tv.AddFrameTrigger(mcr)
return mcr, nil
}
func (mcr *Macro) readFile() error {
f, err := os.Open(mcr.filename)
if err != nil {
return err
}
buffer, err := io.ReadAll(f)
if err != nil {
return err
}
err = f.Close()
if err != nil {
return err
}
// convert file contents to an array of lines
mcr.instructions = strings.Split(string(buffer), "\n")
if len(mcr.instructions) < headerNumLines {
return fmt.Errorf("macro: %s: not a macro file", mcr.filename)
}
if mcr.instructions[0] != headerID {
return fmt.Errorf("macro: %s: not a macro file", mcr.filename)
}
// ignore version string for now
// we no longer need the header
mcr.instructions = mcr.instructions[headerNumLines:]
return nil
}
func convertAddress(s string) (uint16, error) {
// convert hex indicator to one that ParseUint can deal with
if s[0] == '$' {
s = fmt.Sprintf("0x%s", s[1:])
}
// convert address
a, err := strconv.ParseUint(s, 0, 16)
return uint16(a), err
}
func convertValue(s string) (uint8, error) {
// convert hex indicator to one that ParseUint can deal with
if s[0] == '$' {
s = fmt.Sprintf("0x%s", s[1:])
}
// convert address
a, err := strconv.ParseUint(s, 0, 8)
return uint8(a), err
}
type loop struct {
line int
// loop counters count upwards because it is more natural when
// referencing the counter value to think of the counter as counting
// upwards
count int
countEnd int
// if loop counter has been named then we need to know it so that we can
// update the entry in the variables table
countName string
}
// Run a macro to completion
func (mcr *Macro) Run() {
go mcr.run()
}
func (mcr *Macro) run() {
logger.Logf("macro", "running %s", mcr.filename)
// quit instructs the main script loop to end
var quit bool
// wait for the specificed number of frames and run the onEnd() function
// (can be nil)
wait := func(w int, onEnd func()) {
target := w + <-mcr.frameNum
var done bool
for !done {
select {
case fn := <-mcr.frameNum:
if fn >= target {
if onEnd != nil {
onEnd()
}
done = true
}
case <-mcr.quit:
done = true
quit = true
}
}
}
var loops []loop
variables := make(map[string]any)
lookupVariable := func(n string) (int, bool, error) {
// convert value
if n[0] != '%' {
return 0, false, nil
}
n = n[1:]
v, ok := variables[n]
if !ok {
return 0, true, fmt.Errorf("cannot use variable '%s' in POKE because it does not exist", n)
}
return v.(int), true, nil
}
for ln := 0; ln < len(mcr.instructions) && !quit; ln++ {
logf := func(msg string, args ...any) {
logger.Logf("macro", "%s: %d: %s", mcr.filename, ln+headerNumLines, fmt.Sprintf(msg, args...))
}
// convert argument to number
number := func(s string) int {
n, err := strconv.Atoi(s)
if err != nil {
logf(err.Error())
quit = true
}
return n
}
select {
case <-mcr.quit:
break // for loop
default:
}
// ignore commented lines
if strings.HasPrefix(strings.TrimSpace(mcr.instructions[ln]), "--") {
continue
}
// split input line into a command and its arguments
toks := strings.Fields(mcr.instructions[ln])
if len(toks) == 0 {
continue // for loop
}
cmd := toks[0]
args := toks[1:]
switch cmd {
default:
logf("unrecognised command: %s", cmd)
return
case "DO":
ct := len(args)
switch ct {
case 0:
logf("not enough arguments for %s", cmd)
return
case 2:
fallthrough
case 1:
lp := loop{
line: ln,
countEnd: number(args[0]),
}
if ct == 2 {
lp.countName = args[1]
variables[lp.countName] = lp.count
}
loops = append(loops, lp)
default:
logf("too many arguments for DO")
return
}
case "LOOP":
if len(args) > 0 {
logf("too many arguments for %s", cmd)
return
}
// check for a quit signal but don't wait for it
select {
case <-mcr.quit:
return
default:
}
idx := len(loops) - 1
if idx == -1 {
logf("LOOP without a DO")
return
}
lp := &loops[idx]
lp.count++
if lp.count < lp.countEnd {
// loop is ongoing so return to start of loop
ln = lp.line
// update named variable
if lp.countName != "" {
variables[lp.countName] = lp.count
}
} else {
// loop has ended. remove from loop stack and delete variable name
loops = loops[:idx]
delete(variables, lp.countName)
}
case "WAIT":
// default to 60 frames
w := 60
switch len(args) {
case 1:
w = number(args[0])
fallthrough
case 0:
wait(w, nil)
default:
logf("too many arguments for %s", cmd)
return
}
case "SCREENSHOT":
var s strings.Builder
for _, c := range args {
v, ok, err := lookupVariable(c)
if err != nil {
logf(err.Error())
return
}
if ok {
s.WriteString(fmt.Sprintf("%d", v))
} else {
s.WriteString(c)
}
s.WriteRune(' ')
}
// the filename suffix is all the "words" in string builder
// joined with an underscore
filenameSuffix := strings.Join(strings.Fields(s.String()), "_")
mcr.gui.SetFeature(gui.ReqScreenshot, filenameSuffix)
wait(10, nil)
case "QUIT":
if len(args) > 0 {
logf("too many arguments for %s", cmd)
return
}
mcr.emulation.UserInput() <- userinput.EventQuit{}
case "FIRE":
mcr.input.PushEvent(ports.InputEvent{Port: plugging.PortLeft, Ev: ports.Fire, D: true})
switch len(args) {
case 1:
wait(number(args[0]), func() {
mcr.input.PushEvent(ports.InputEvent{Port: plugging.PortLeft, Ev: ports.Fire, D: false})
})
case 0:
wait(2, nil)
default:
logf("too many arguments for %s", cmd)
}
case "NOFIRE":
if len(args) > 0 {
logf("too many arguments for %s", cmd)
return
}
mcr.input.PushEvent(ports.InputEvent{Port: plugging.PortLeft, Ev: ports.Fire, D: false})
wait(2, nil)
case "LEFT":
if len(args) > 0 {
logf("too many arguments for %s", cmd)
return
}
mcr.input.PushEvent(ports.InputEvent{Port: plugging.PortLeft, Ev: ports.Left, D: ports.DataStickTrue})
wait(2, nil)
case "RIGHT":
if len(args) > 0 {
logf("too many arguments for %s", cmd)
return
}
mcr.input.PushEvent(ports.InputEvent{Port: plugging.PortLeft, Ev: ports.Right, D: ports.DataStickTrue})
wait(2, nil)
case "UP":
if len(args) > 0 {
logf("too many arguments for %s", cmd)
return
}
mcr.input.PushEvent(ports.InputEvent{Port: plugging.PortLeft, Ev: ports.Up, D: ports.DataStickTrue})
wait(2, nil)
case "DOWN":
if len(args) > 0 {
logf("too many arguments for %s", cmd)
return
}
mcr.input.PushEvent(ports.InputEvent{Port: plugging.PortLeft, Ev: ports.Down, D: ports.DataStickTrue})
wait(2, nil)
case "LEFTUP":
if len(args) > 0 {
logf("too many arguments for %s", cmd)
return
}
mcr.input.PushEvent(ports.InputEvent{Port: plugging.PortLeft, Ev: ports.LeftUp, D: ports.DataStickTrue})
wait(2, nil)
case "LEFTDOWN":
if len(args) > 0 {
logf("too many arguments for %s", cmd)
return
}
mcr.input.PushEvent(ports.InputEvent{Port: plugging.PortLeft, Ev: ports.LeftDown, D: ports.DataStickTrue})
wait(2, nil)
case "RIGHTUP":
if len(args) > 0 {
logf("too many arguments for %s", cmd)
return
}
mcr.input.PushEvent(ports.InputEvent{Port: plugging.PortLeft, Ev: ports.RightUp, D: ports.DataStickTrue})
wait(2, nil)
case "RIGHTDOWN":
if len(args) > 0 {
logf("too many arguments for %s", cmd)
return
}
mcr.input.PushEvent(ports.InputEvent{Port: plugging.PortLeft, Ev: ports.RightDown, D: ports.DataStickTrue})
wait(2, nil)
case "CENTER":
fallthrough
case "CENTRE":
if len(args) > 0 {
logf("too many arguments for %s", cmd)
return
}
mcr.input.PushEvent(ports.InputEvent{Port: plugging.PortLeft, Ev: ports.Centre})
wait(2, nil)
case "SELECT":
if len(args) > 0 {
logf("too many arguments for %s", cmd)
return
}
mcr.input.PushEvent(ports.InputEvent{Port: plugging.PortPanel, Ev: ports.PanelSelect, D: true})
wait(2, func() {
mcr.input.PushEvent(ports.InputEvent{Port: plugging.PortPanel, Ev: ports.PanelSelect, D: false})
})
case "RESET":
if len(args) > 0 {
logf("too many arguments for %s", cmd)
return
}
mcr.input.PushEvent(ports.InputEvent{Port: plugging.PortPanel, Ev: ports.PanelReset, D: true})
wait(2, func() {
mcr.input.PushEvent(ports.InputEvent{Port: plugging.PortPanel, Ev: ports.PanelReset, D: false})
})
case "POKE":
if len(args) != 2 {
logf("not enough arguments for POKE")
return
}
// convert address
addr, err := convertAddress(args[0])
if err != nil {
logf("unrecognised address for POKE: %s", args[0])
return
}
var val uint8
v, ok, err := lookupVariable(args[1])
if err != nil {
logf(err.Error())
return
}
if ok {
val = uint8(v)
} else {
v, err := convertValue(args[1])
if err != nil {
logf("cannot use value for POKE: %s", args[1])
return
}
val = uint8(v)
}
// poke address with value
mem := mcr.emulation.VCS().Mem
mem.Poke(addr, val)
}
}
logger.Logf("macro", "finished %s", mcr.filename)
}
// Quit forces a running macro (ie. one that has been triggered) to end. Does
// nothing is macro is not currently running
func (mcr *Macro) Quit() {
select {
case <-mcr.frameNum:
default:
}
select {
case mcr.quit <- true:
default:
}
}
// Reset restarts the macro
func (mcr *Macro) Reset() {
mcr.Quit()
mcr.Run()
}
// NewFrame implements the television.FrameTrigger interface
func (mcr *Macro) NewFrame(frameInfo television.FrameInfo) error {
// drain any frameNum channel before pushing a new value
select {
case <-mcr.frameNum:
default:
}
select {
case mcr.frameNum <- frameInfo.FrameNum:
default:
}
return nil
}