diff --git a/.gitignore b/.gitignore index c1b84ea1..7737dbc0 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ armcode.map *.csv *.jpg *.bin +*.macro diff --git a/debugger/debugger.go b/debugger/debugger.go index 2a919e27..0c927dd1 100644 --- a/debugger/debugger.go +++ b/debugger/debugger.go @@ -1015,9 +1015,6 @@ func (dbg *Debugger) attachCartridge(cartload cartridgeloader.Loader) (e error) dbg.endPlayback() dbg.endRecording() dbg.endComparison() - if dbg.macro != nil { - dbg.macro.Quit() - } dbg.bots.Quit() // attching a cartridge implies the initialise state @@ -1425,6 +1422,10 @@ func (dbg *Debugger) reloadCartridge() error { return fmt.Errorf("debugger: %w", err) } + if dbg.macro != nil { + dbg.macro.Reset() + } + return nil } diff --git a/macro/doc.go b/macro/doc.go index 93a218db..3178b348 100644 --- a/macro/doc.go +++ b/macro/doc.go @@ -16,6 +16,8 @@ // Package macro implements an input system that processes instructions from a // macro script. // +// The first line of the macro must be the string "gopher2600macro". +// // The macro language is very simple and does not implement any flow control // except basic loops. // diff --git a/macro/macro.go b/macro/macro.go index d4b8ea70..ea73a1bd 100644 --- a/macro/macro.go +++ b/macro/macro.go @@ -63,14 +63,13 @@ type Macro struct { frameNum chan int } +const headerID = "gopher2600macro" + const ( headerLineID = iota - headerLineVersion headerNumLines ) -const headerID = "gopher2600macro" - // 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{ @@ -83,32 +82,10 @@ func NewMacro(filename string, emulation Emulation, input Input, tv TV, gui GUI) frameNum: make(chan int), } - f, err := os.Open(filename) + err := mcr.readFile() if err != nil { return nil, fmt.Errorf("macro: %w", err) } - buffer, err := io.ReadAll(f) - if err != nil { - return nil, fmt.Errorf("macro: %w", err) - } - err = f.Close() - if err != nil { - return nil, fmt.Errorf("macro: %w", err) - } - - // convert file contents to an array of lines - mcr.instructions = strings.Split(string(buffer), "\n") - if len(mcr.instructions) < headerNumLines { - return nil, fmt.Errorf("macro: %s: not a macro file", filename) - } - if mcr.instructions[0] != headerID { - return nil, fmt.Errorf("macro: %s: not a macro file", filename) - } - - // ignore version string for now - - // we no longer need the header - mcr.instructions = mcr.instructions[headerNumLines:] // attach TV to macro mcr.tv.AddFrameTrigger(mcr) @@ -116,18 +93,87 @@ func NewMacro(filename string, emulation Emulation, input Input, tv TV, gui GUI) return mcr, nil } -// Run a macro to completion -func (mcr *Macro) Run() { - log := func(ln int, msg string) { - logger.Logf("macro", "%s: %d: %s", mcr.filename, ln+headerNumLines, msg) +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 } - // wait function is used by the WAIT macro instruction but is also used by - // the controller instructions (LEFT, FIRE, etc.) to indicate a short wait - // of two frames before moving onto the next instruction in the macro - // script. this ensures that the controller input has the chance to take - // effect in the emulation - wait := func(w int) bool { + // 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 @@ -135,300 +181,345 @@ func (mcr *Macro) Run() { select { case fn := <-mcr.frameNum: if fn >= target { + if onEnd != nil { + onEnd() + } done = true } case <-mcr.quit: - return true + done = true + quit = true } } - return false } - convertAddress := func(s string) (uint16, error) { - // convert hex indicator to one that ParseUint can deal with - if s[0] == '$' { - s = fmt.Sprintf("0x%s", s[1:]) + var loops []loop + variables := make(map[string]any) + + lookupVariable := func(n string) (int, bool, error) { + // convert value + if n[0] != '%' { + return 0, false, nil } - // convert address - a, err := strconv.ParseUint(s, 0, 16) - return uint16(a), err + 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 } - convertValue := func(s string) (uint8, error) { - // convert hex indicator to one that ParseUint can deal with - if s[0] == '$' { - s = fmt.Sprintf("0x%s", s[1:]) + 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 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 - } - - go func() { - var loops []loop - variables := make(map[string]any) - - lookupVariable := func(n string) (int, bool, error) { - // convert value - if n[0] != '%' { - return 0, false, nil + // convert argument to number + number := func(s string) int { + n, err := strconv.Atoi(s) + if err != nil { + logf(err.Error()) + quit = true } - - 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 + return n } - for ln := 0; ln < len(mcr.instructions); ln++ { - s := mcr.instructions[ln] + select { + case <-mcr.quit: + break // for loop + default: + } - toks := strings.Fields(s) - if len(toks) == 0 { - continue // for loop - } + // ignore commented lines + if strings.HasPrefix(strings.TrimSpace(mcr.instructions[ln]), "--") { + continue + } - switch toks[0] { - default: - log(ln, fmt.Sprintf("unrecognised command: %s", toks[0])) + // 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 "--": - // ignore comment lines - - case "DO": - tl := len(toks) - switch tl { - case 1: - log(ln, "too few arguments for DO") - return - case 3: - fallthrough - case 2: - ct, err := strconv.Atoi(toks[1]) - if err != nil { - log(ln, err.Error()) - return - } - lp := loop{ - line: ln, - countEnd: ct, - } - if tl == 3 { - lp.countName = toks[2] - variables[lp.countName] = lp.count - } - loops = append(loops, lp) - default: - log(ln, "too many arguments for DO") - return - } - - case "LOOP": - if len(toks) > 1 { - log(ln, "too many arguments for LOOP") - 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 { - log(ln, "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(toks) { - case 2: - var err error - w, err = strconv.Atoi(toks[1]) - if err != nil { - log(ln, err.Error()) - return - } - fallthrough - - case 1: - if wait(w) { - return - } - - default: - log(ln, "too many arguments for WAIT") - return - } - - case "SCREENSHOT": - var s strings.Builder - for _, c := range toks[1:] { - v, ok, err := lookupVariable(c) - if err != nil { - log(ln, 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) - - case "QUIT": - if len(toks) > 1 { - log(ln, "too many arguments for QUIT") - return - } - mcr.emulation.UserInput() <- userinput.EventQuit{} - - case "FIRE": - mcr.input.PushEvent(ports.InputEvent{Port: plugging.PortLeft, Ev: ports.Fire, D: true}) - wait(2) - case "NOFIRE": - mcr.input.PushEvent(ports.InputEvent{Port: plugging.PortLeft, Ev: ports.Fire, D: false}) - wait(2) - case "LEFT": - mcr.input.PushEvent(ports.InputEvent{Port: plugging.PortLeft, Ev: ports.Left, D: ports.DataStickTrue}) - wait(2) - case "RIGHT": - mcr.input.PushEvent(ports.InputEvent{Port: plugging.PortLeft, Ev: ports.Right, D: ports.DataStickTrue}) - wait(2) - case "UP": - mcr.input.PushEvent(ports.InputEvent{Port: plugging.PortLeft, Ev: ports.Up, D: ports.DataStickTrue}) - wait(2) - case "DOWN": - mcr.input.PushEvent(ports.InputEvent{Port: plugging.PortLeft, Ev: ports.Down, D: ports.DataStickTrue}) - wait(2) - case "LEFTUP": - mcr.input.PushEvent(ports.InputEvent{Port: plugging.PortLeft, Ev: ports.LeftUp, D: ports.DataStickTrue}) - wait(2) - case "LEFTDOWN": - mcr.input.PushEvent(ports.InputEvent{Port: plugging.PortLeft, Ev: ports.LeftDown, D: ports.DataStickTrue}) - wait(2) - case "RIGHTUP": - mcr.input.PushEvent(ports.InputEvent{Port: plugging.PortLeft, Ev: ports.RightUp, D: ports.DataStickTrue}) - wait(2) - case "RIGHTDOWN": - mcr.input.PushEvent(ports.InputEvent{Port: plugging.PortLeft, Ev: ports.RightDown, D: ports.DataStickTrue}) - wait(2) - case "CENTER": + case 2: fallthrough - case "CENTRE": - mcr.input.PushEvent(ports.InputEvent{Port: plugging.PortLeft, Ev: ports.Centre}) - wait(2) - case "SELECT": - held := false - if len(toks) == 1 { - held = true + case 1: + lp := loop{ + line: ln, + countEnd: number(args[0]), } - mcr.input.PushEvent(ports.InputEvent{Port: plugging.PortPanel, Ev: ports.PanelSelect, D: held}) - wait(2) - case "RESET": - held := false - if len(toks) == 1 { - held = true - } - mcr.input.PushEvent(ports.InputEvent{Port: plugging.PortPanel, Ev: ports.PanelReset, D: held}) - wait(2) - case "POKE": - if len(toks) != 3 { - log(ln, "not enough arguments for POKE") - return + if ct == 2 { + lp.countName = args[1] + variables[lp.countName] = lp.count } + loops = append(loops, lp) + default: + logf("too many arguments for DO") + return + } - // convert address - addr, err := convertAddress(toks[1]) + 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 { - log(ln, fmt.Sprintf("unrecognised address for POKE: %s", toks[1])) - return - } - - var val uint8 - - v, ok, err := lookupVariable(toks[2]) - if err != nil { - log(ln, err.Error()) + logf(err.Error()) return } if ok { - val = uint8(v) + s.WriteString(fmt.Sprintf("%d", v)) } else { - v, err := convertValue(toks[2]) - if err != nil { - log(ln, fmt.Sprintf("cannot use value for POKE: %s", toks[2])) - return - } - val = uint8(v) + s.WriteString(c) } - - // poke address with value - mem := mcr.emulation.VCS().Mem - mem.Poke(addr, val) + 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