mirror of
https://github.com/JetSetIlly/Gopher2600.git
synced 2024-05-20 13:48:02 -04:00
536 lines
12 KiB
Go
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
|
|
}
|