diff --git a/.github/build b/.github/build index 40a18c5..3d4c247 100755 --- a/.github/build +++ b/.github/build @@ -12,9 +12,9 @@ if [ -d /github/workspace ] ; then fi # -# We build on only a single platform/arch. +# We build for many platforms. # -BUILD_PLATFORMS="linux darwin freebsd openbsd netbsd" +BUILD_PLATFORMS="linux darwin freebsd openbsd netbsd windows" BUILD_ARCHS="amd64 386" # For each platform diff --git a/.gitignore b/.gitignore index 64676f8..3ddeff4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ cpmulator cpmulator-* +*.exe diff --git a/README.md b/README.md index dade379..c91d4d9 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Over time this project has become more complete, and I've now implemented enough * BBC and Microsoft BASIC. * Wordstar. -As things stand this project is "complete". I'd like to increase the test-coverage for my own reassurance, but I've now reached a point where all the binaries I've tried to execute work as expected. If you find a program that _doesn't_ work please [open an issue](https://github.com/skx/cpmulator/issues), beyond that I think this project is "complete" and future development will be minimal, and sporadic. +As things stand this project is "complete". I'd like to increase the test-coverage for my own reassurance, but I've now reached a point where all the binaries I've tried to execute work as expected. If you find a program that _doesn't_ work please [open an issue](https://github.com/skx/cpmulator/issues), otherwise I suspect ongoing development will be minimal, and sporadic. > **NOTE** I've not implemented any notion of disk-support. This means that opening, reading/writing, and closing files is absolutely fine, but any API call that refers to tracks, sectors, or disks will fail (with an "unimplemented syscall" error). @@ -62,22 +62,6 @@ Releases will be made as/when features seem to justify it, but it should be note -# Portability - -The CP/M input handlers need to disable echoing when reading (single) characters from STDIN. There isn't a simple and portable solution for this in golang, although the appropriate primitives exist so building such support isn't impossible. - -Usage of is demonstrated in the standard library: - -* [x/term package](https://pkg.go.dev/golang.org/x/term) - * [ReadPassword](https://pkg.go.dev/golang.org/x/term#ReadPassword) - ReadPassword reads a line of input from a terminal without local echo. - -Unfortunately there is no code there for reading only a _single character_, rather than a complete line. In the interest of expediency I resort to executing the `stty` binary, rather than attempting to use the `x/term` package to manage echo/noecho state myself, and this means the code in this repository isn't 100% portable; it will work on Linux and MacOS hosts, but not Windows. - -I've got an open bug about fixing the console (input), [#65](https://github.com/skx/cpmulator/issues/65). - - - - # Usage If you launch `cpmulator` with no arguments then the default CCP ("console command processor") will be launched, dropping you into a familiar shell: @@ -139,7 +123,7 @@ $ cpmulator /path/to/binary [optional-args] ## Command Line Flags -There are several command-line options which are shown in the output of `cpmulator -help`, but the following summary shows the most important/useful options: +There are many available command-line options, which are shown in the output of `cpmulator -help`, but the following summary shows the most important/useful options: * `-cd /path/to/directory` * Change to the given directory before running. @@ -152,6 +136,7 @@ There are several command-line options which are shown in the output of `cpmulat * All output which CP/M sends to the "printer" will be written to the given file. * `-list-syscalls` * Dump the list of implemented BDOS and BIOS syscalls. +* `-list-input-drivers` and `-list-output-drivers` to see the available I/O driver-names, which may then be selected via the `-input` and `-output` flags. * `-version` * Show the version number of the emulator, and exit. @@ -171,9 +156,9 @@ This allows you to customize the emulator, or perform other "one-time" setup via ## Runtime Behaviour Changes -There are a small number of [extensions](EXTENSIONS.md) added to the BIOS functionality we provide, and these extensions allow changing the behaviour of the emulator at runtime. +There are a small number of [extensions](EXTENSIONS.md) added to the BIOS functionality we provide, and these extensions allow changing some aspects of the emulator at runtime. -The behaviour changing is achieved by having a small number of .COM files invoke the extension functions, and these binaries are embedded within our emulator to improve ease of use, via the [static/](static/) directory in our source-tree - This means no matter what you'll always find some binaries installed on A:, despite not being present in reality. +The behaviour changing is achieved by having a small number of .COM files invoke the extension functions, and these binaries are embedded within our emulator to improve ease of use, via the [static/](static/) directory in our source-tree. This means no matter what you'll always find some binaries installed on A:, despite not being present in reality. > **NOTE** To avoid naming collisions all our embedded binaries are named with a `!` prefix, except for `#.COM` which is designed to be used as a comment-binary. @@ -192,12 +177,14 @@ The binary `A:!CTRLC.COM` which lets you change this at runtime. Run `A:!CTRLC ### Console Output -We default to pretending our output device is an ADM-3A terminal, this can be changed via the `-console` command-line flag at startup. Additionally it can be changed at runtime via `A:!CONSOLE.COM`. +We default to pretending our output device is an ADM-3A terminal, this can be changed via the `-output` command-line flag (previously `-console`) at startup. Additionally it can be changed at runtime via `A:!CONSOLE.COM`. Run `A:!CONSOLE ansi` to disable the output emulation, or `A:!CONSOLE adm-3a` to restore it. You'll see that the [cpm-dist](https://github.com/skx/cpm-dist) repository contains a version of Wordstar, and that behaves differently depending on the selected output handler. Changing the handler at run-time is a neat bit of behaviour. +You'll note it is **not** possible to change the console _input_ driver at runtime, I think once you know which works best upon your system it doesn't make sense to change this interactively. + ### Debug Handling @@ -207,6 +194,8 @@ runtime. `A:!DEBUG.COM` will show the state of the flag, and it can be enabled with `A:!DEBUG 1` or disabled with `!DEBUG 0`. +Finally `A:!VERSION.COM` will show you the version of the emulator you're running. + @@ -326,6 +315,22 @@ The implementation of the syscalls is the core of our emulator, and they can be +# Portability + +The CP/M input handlers need to disable echoing when reading (single) characters from STDIN. There isn't a simple and portable solution for this in golang, although the appropriate primitives exist so building such support isn't impossible, it just relies upon writing per-environment support, using something like the [ReadPassword](https://pkg.go.dev/golang.org/x/term#ReadPassword) function from the standard-library. + +I sidestepped this whole problem initially, just invoking the `stty` binary to enable/disable the echoing of characters on-demand, but that only works on Linux, BSD, and Mac hosts. To be properly portable I had to use the [termbox](https://github.com/nsf/termbox-go) library for all input, but that means we get no scrollback/history so there's a tradeoff to be made. + +By default input will be read via `termbox` but you may you specify a different driver via the CLI arguments: + +* `cpmulator -input xxx` + * Use the input-driver named `xxx`. +* `cpmulator -list-input-drivers` + * List all available input-drivers. + + + + # Debugging Failures & Tweaking Behaviour When an unimplemented BIOS call is attempted the program it will abort with a fatal error, for example: diff --git a/ccp/ccp.go b/ccp/ccp.go index 8b052b6..e798d80 100644 --- a/ccp/ccp.go +++ b/ccp/ccp.go @@ -15,19 +15,21 @@ import ( // Flavour contains details about a possible CCP the user might run. type Flavour struct { - // Name has the name of the CCP. + // Name contains the public-facing name of the CCP. // // NOTE: This name is visible to end-users, and will be used in the "-ccp" command-line flag, - // or as the name when changing at run-time via the "CCP.COM" utility. + // or as the name when changing at run-time via the "A:!CCP.COM" binary. Name string - // Description has the description of the CCP. + // Description contains the description of the CCP. Description string // Bytes contains the raw binary content. Bytes []uint8 - // Origin contains the start/load location of the CCP. + // Start specifies the memory-address, within RAM, to which the raw bytes should be loaded and to which control should be passed. + // + // (i.e. This must match the ORG specified in the CCP source code.) Start uint16 } diff --git a/consolein/consolein.go b/consolein/consolein.go index 9d54a54..5530b91 100644 --- a/consolein/consolein.go +++ b/consolein/consolein.go @@ -1,210 +1,202 @@ -// Package consolein handles the reading of console input -// for our emulator. +// Package consolein is an abstraction over console input. // -// The package supports the minimum required functionality -// we need - which boils down to reading a single character -// of input, with and without echo, and reading a line of text. -// -// Note that no output functions are handled by this package, -// it is exclusively used for input. +// We support two methods of getting input, whilst selectively +// disabling/enabling echo - the use of `termbox' and the use of +// the `stty` binary. package consolein import ( "fmt" - "os" - "os/exec" "strings" "unicode" - - "golang.org/x/term" ) -// Status is used to record our current state -type Status int +// ErrInterrupted is returned if the user presses Ctrl-C when in our ReadLine function. +var ErrInterrupted error = fmt.Errorf("INTERRUPTED") -var ( - // Unknown means we don't know the status of echo/noecho - Unknown Status = 0 +// ConsoleInput is the interface that must be implemented by anything +// that wishes to be used as an input driver. +// +// Providing this interface is implemented an object may register itself, +// by name, via the Register method. +// +// You can compare this interface to the corresponding ConsoleOutput one, +// that delegates everything to the drivers rather than having some wrapper +// methods building upon the drivers as we do here. +type ConsoleInput interface { - // Echo means that input will echo characters. - Echo Status = 1 + // Setup performs any specific setup which is required. + Setup() - // NoEcho means that input will not echo characters. - NoEcho Status = 2 + // TearDown performs any specific cleanup which is required. + TearDown() - // ErrInterrupted is returned if the user presses Ctrl-C when in our ReadLine function. - ErrInterrupted = fmt.Errorf("INTERRUPTED") -) + // PendingInput returns true if there is pending input available to be read. + PendingInput() bool -// ConsoleIn holds our state -type ConsoleIn struct { - // State holds our current echo state; either Echo, NoEcho, or Unknown. - State Status + // BlockForCharacterNoEcho reads a single character from the console, without + // echoing it. + BlockForCharacterNoEcho() (byte, error) + + // GetName will return the name of the driver. + GetName() string +} + +// This is a map of known-drivers +var handlers = struct { + m map[string]Constructor +}{m: make(map[string]Constructor)} + +// interruptCount is the count of consecutive Ctrl-Cs which will trigger a "reboot". +var interruptCount int = 2 + +// stuffed holds pending input +var stuffed string = "" - // InterruptCount holds the number of consecutive Ctrl-Cs which are necessary - // to trigger an interrupt response from ReadLine - InterruptCount int +// history holds previous (line) input. +var history []string - // stuffed holds fake input which has been forced into the buffer used - // by ReadLine - stuffed string +// Constructor is the signature of a constructor-function +// which is used to instantiate an instance of a driver. +type Constructor func() ConsoleInput - // history holds previous (line) input. - history []string +// Register makes a console driver available, by name. +// +// When one needs to be created the constructor can be called +// to create an instance of it. +func Register(name string, obj Constructor) { + // Downcase for consistency. + name = strings.ToLower(name) + + handlers.m[name] = obj } -// New is our constructor. -func New() *ConsoleIn { - t := &ConsoleIn{ - State: Unknown, - InterruptCount: 2, - } - return t +// ConsoleIn holds our state, which is basically just a +// pointer to the object handling our input +type ConsoleIn struct { + + // driver is the thing that actually reads our output. + driver ConsoleInput } -// StuffInput forces input into the buffer which our ReadLine function will -// return. It is used solely for the AUTOEXEC.SUB behaviour by our driver. -func (ci *ConsoleIn) StuffInput(text string) { - ci.stuffed = text +// New is our constructore, it creates an input device which uses +// the specified driver. +func New(name string) (*ConsoleIn, error) { + + // Downcase for consistency. + name = strings.ToLower(name) + + // Do we have a constructor with the given name? + ctor, ok := handlers.m[name] + if !ok { + return nil, fmt.Errorf("failed to lookup driver by name '%s'", name) + } + + // OK we do, return ourselves with that driver. + return &ConsoleIn{ + driver: ctor(), + }, nil } -// SetInterruptCount updates the number of consecutive Ctrl-Cs which are necessary -// to trigger an interrupt in ReadLine. -func (ci *ConsoleIn) SetInterruptCount(val int) { - ci.InterruptCount = val +// GetDriver allows getting our driver at runtime. +func (co *ConsoleIn) GetDriver() ConsoleInput { + return co.driver } -// GetInterruptCount returns the number of consecutive Ctrl-Cs which are necessary -// to trigger an interrupt in ReadLine. -func (ci *ConsoleIn) GetInterruptCount() int { - return ci.InterruptCount +// GetName returns the name of our selected driver. +func (co *ConsoleIn) GetName() string { + return co.driver.GetName() } -// PendingInput returns true if there is pending input from STDIN.. -// -// Note that we have to set RAW mode, without this input is laggy -// and zork doesn't run. -func (ci *ConsoleIn) PendingInput() bool { +// GetDrivers returns all available driver-names. +func (co *ConsoleIn) GetDrivers() []string { + valid := []string{} - // Do we have faked/stuffed input to process? - if len(ci.stuffed) > 0 { - return true + for x := range handlers.m { + valid = append(valid, x) } + return valid +} - // switch stdin into 'raw' mode - oldState, err := term.MakeRaw(int(os.Stdin.Fd())) - if err != nil { - return false - } +// Setup proxies into our registered console-input driver. +func (co *ConsoleIn) Setup() { + co.driver.Setup() +} - // Platform-specific code in select_XXXX.go - res := canSelect() +// TearDown proxies into our registered console-input driver. +func (co *ConsoleIn) TearDown() { + co.driver.TearDown() +} - // restore the state of the terminal to avoid mixing RAW/Cooked - err = term.Restore(int(os.Stdin.Fd()), oldState) - if err != nil { - return false - } +// StuffInput proxies into our registered console-input driver. +func (co *ConsoleIn) StuffInput(input string) { + stuffed = input +} - // Return true if we have something ready to read. - return res +// SetInterruptCount sets the number of consecutive Ctrl-C characters +// are required to trigger a reboot. +// +// This function DOES NOT proxy to our registered console-input driver. +func (co *ConsoleIn) SetInterruptCount(val int) { + interruptCount = val } -// BlockForCharacterNoEcho returns the next character from the console, blocking until -// one is available. +// GetInterruptCount retrieves the number of consecutive Ctrl-C characters are required to trigger a reboot. // -// NOTE: This function should not echo keystrokes which are entered. -func (ci *ConsoleIn) BlockForCharacterNoEcho() (byte, error) { +// This function DOES NOT proxy to our registered console-input driver. +func (co *ConsoleIn) GetInterruptCount() int { + return interruptCount +} - // Do we have faked/stuffed input to process? - if len(ci.stuffed) > 0 { - c := ci.stuffed[0] - ci.stuffed = ci.stuffed[1:] - return c, nil - } +// PendingInput proxies into our registered console-input driver. +func (co *ConsoleIn) PendingInput() bool { - // Do we need to change state? If so then do it. - if ci.State != NoEcho { - ci.disableEcho() + // if there is stuffed input we have something ready to read + if len(stuffed) > 0 { + return true } - // switch stdin into 'raw' mode - oldState, err := term.MakeRaw(int(os.Stdin.Fd())) - if err != nil { - return 0x00, fmt.Errorf("error making raw terminal %s", err) - } + return co.driver.PendingInput() +} - // read only a single byte - b := make([]byte, 1) - _, err = os.Stdin.Read(b) - if err != nil { - return 0x00, fmt.Errorf("error reading a byte from stdin %s", err) - } +// BlockForCharacterNoEcho proxies into our registered console-input driver. +func (co *ConsoleIn) BlockForCharacterNoEcho() (byte, error) { - // restore the state of the terminal to avoid mixing RAW/Cooked - err = term.Restore(int(os.Stdin.Fd()), oldState) - if err != nil { - return 0x00, fmt.Errorf("error restoring terminal state %s", err) + // Do we have faked/stuffed input to process? + if len(stuffed) > 0 { + c := stuffed[0] + stuffed = stuffed[1:] + return c, nil } - // Return the character we read - return b[0], nil + return co.driver.BlockForCharacterNoEcho() } -// BlockForCharacterWithEcho returns the next character from the console, -// blocking until one is available. +// BlockForCharacterWithEcho blocks for input and shows that input before it +// is returned. // -// NOTE: Characters should be echo'd as they are input. -func (ci *ConsoleIn) BlockForCharacterWithEcho() (byte, error) { +// This function DOES NOT proxy to our registered console-input driver. +func (co *ConsoleIn) BlockForCharacterWithEcho() (byte, error) { // Do we have faked/stuffed input to process? - if len(ci.stuffed) > 0 { - c := ci.stuffed[0] - ci.stuffed = ci.stuffed[1:] + if len(stuffed) > 0 { + c := stuffed[0] + stuffed = stuffed[1:] + fmt.Printf("%c", c) return c, nil } - // Do we need to change state? If so then do it. - if ci.State != Echo { - ci.enableEcho() - } - - // switch stdin into 'raw' mode - oldState, err := term.MakeRaw(int(os.Stdin.Fd())) - if err != nil { - return 0x00, fmt.Errorf("error making raw terminal %s", err) + c, err := co.driver.BlockForCharacterNoEcho() + if err == nil { + fmt.Printf("%c", c) } - - // read only a single byte - b := make([]byte, 1) - _, err = os.Stdin.Read(b) - if err != nil { - return 0x00, fmt.Errorf("error reading a byte from stdin %s", err) - } - - // restore the state of the terminal to avoid mixing RAW/Cooked - err = term.Restore(int(os.Stdin.Fd()), oldState) - if err != nil { - return 0x00, fmt.Errorf("error restoring terminal state %s", err) - } - - fmt.Printf("%c", b[0]) - return b[0], nil + return c, err } -// ReadLine reads a line of input from the console, truncating to the -// length specified. -// -// Note: We should enable echo in this function. -// -// NOTE: A user pressing Ctrl-C will be caught, and this will trigger the BDOS -// function to reboot. We have a variable holding the number of consecutive -// Ctrl-C characters are required to trigger this behaviour. +// ReadLine handles the input of a single line of text. // -// NOTE: We erase the input buffer with ESC, and allow history movement via -// Ctrl-p and Ctrl-n. -func (ci *ConsoleIn) ReadLine(max uint8) (string, error) { - +// This function DOES NOT proxy to our registered console-input driver. +func (co *ConsoleIn) ReadLine(max uint8) (string, error) { // Text the user entered text := "" @@ -223,7 +215,7 @@ func (ci *ConsoleIn) ReadLine(max uint8) (string, error) { } } - // Wwe're expecting the user to enter a line of text, + // We're expecting the user to enter a line of text, // but we process their input in terms of characters. // // We do that so that we can react to special characters @@ -234,7 +226,7 @@ func (ci *ConsoleIn) ReadLine(max uint8) (string, error) { for { // Get a character, with no echo. - x, err := ci.BlockForCharacterNoEcho() + x, err := co.BlockForCharacterNoEcho() if err != nil { return "", err } @@ -255,9 +247,9 @@ func (ci *ConsoleIn) ReadLine(max uint8) (string, error) { eraseInput() - if len(ci.history)-offset < len(ci.history) { + if len(history)-offset < len(history) { // replace with a suitable value, and show it - text = ci.history[len(ci.history)-offset] + text = history[len(history)-offset] fmt.Printf("%s", text) } } @@ -266,7 +258,7 @@ func (ci *ConsoleIn) ReadLine(max uint8) (string, error) { // Ctrl-P? if x == 16 { - if offset >= len(ci.history) { + if offset >= len(history) { continue } offset += 1 @@ -274,7 +266,7 @@ func (ci *ConsoleIn) ReadLine(max uint8) (string, error) { eraseInput() // replace with a suitable value, and show it - text = ci.history[len(ci.history)-offset] + text = history[len(history)-offset] fmt.Printf("%s", text) continue @@ -290,7 +282,7 @@ func (ci *ConsoleIn) ReadLine(max uint8) (string, error) { // If we've hit our limit of consecutive Ctrl-Cs // then we return the interrupted error-code - if ctrlCount == ci.InterruptCount { + if ctrlCount == interruptCount { return "", ErrInterrupted } } @@ -305,12 +297,12 @@ func (ci *ConsoleIn) ReadLine(max uint8) (string, error) { if text != "" { // If we have no history, save it. - if len(ci.history) == 0 { - ci.history = append(ci.history, text) + if len(history) == 0 { + history = append(history, text) } else { // otherwise only add if different to previous entry. - if text != ci.history[len(ci.history)-1] { - ci.history = append(ci.history, text) + if text != history[len(history)-1] { + history = append(history, text) } } } @@ -355,20 +347,3 @@ func (ci *ConsoleIn) ReadLine(max uint8) (string, error) { // Return the text return text, nil } - -// Reset restores echo. -func (ci *ConsoleIn) Reset() { - ci.enableEcho() -} - -// disableEcho is the single place where we disable echoing. -func (ci *ConsoleIn) disableEcho() { - _ = exec.Command("stty", "-F", "/dev/tty", "-echo").Run() - ci.State = NoEcho -} - -// enableEcho is the single place where we enable echoing. -func (ci *ConsoleIn) enableEcho() { - _ = exec.Command("stty", "-F", "/dev/tty", "echo").Run() - ci.State = Echo -} diff --git a/consolein/consolein_test.go b/consolein/consolein_test.go index a951280..bd46e03 100644 --- a/consolein/consolein_test.go +++ b/consolein/consolein_test.go @@ -2,18 +2,21 @@ package consolein import "testing" -func TestReadline(t *testing.T) { +func TestReadlineSTTY(t *testing.T) { - x := New() - x.State = Echo + // Create a helper + x := STTYInput{} + + ch := ConsoleIn{} + ch.driver = &x // Simple readline // Here \x10 is the Ctrl-P which would use the previous history // as we're just created we have none so it is ignored. - x.stuffed = "s\x10teve\n" - out, err := x.ReadLine(20) + ch.StuffInput("s\x10teve\n") + out, err := ch.ReadLine(20) if err != nil { - t.Fatalf("unexpected error") + t.Fatalf("unexpected error: %s", err) } if out != "steve" { t.Fatalf("Unexpected output '%s'", out) @@ -21,17 +24,15 @@ func TestReadline(t *testing.T) { // Ctrl-C at start of the line should trigger a reboot-error // x.stuffed = string([]byte{0x03, 0x03, 0x00}a) - x.stuffed = "\x03\x03steve" - x.State = Echo - _, err = x.ReadLine(20) + ch.StuffInput("\x03\x03steve") + _, err = ch.ReadLine(20) if err != ErrInterrupted { t.Fatalf("unexpected error %s", err) } // Ctrl-C at the middle of a line should not - x.stuffed = "steve\x03\x03steve\n" - x.State = Echo - out, err = x.ReadLine(20) + ch.StuffInput("steve\x03\x03steve\n") + out, err = ch.ReadLine(20) if err != nil { t.Fatalf("unexpected error %s", err) } @@ -40,9 +41,8 @@ func TestReadline(t *testing.T) { } // Ctrl-B overwrites - x.stuffed = "steve\b\b\b\b\bHello\n" - x.State = Echo - out, err = x.ReadLine(20) + ch.StuffInput("steve\b\b\b\b\bHello\n") + out, err = ch.ReadLine(20) if err != nil { t.Fatalf("unexpected error %s", err) } @@ -51,9 +51,8 @@ func TestReadline(t *testing.T) { } // ESC resets input - x.stuffed = "steve\x1BHello\n" - x.State = Echo - out, err = x.ReadLine(20) + ch.StuffInput("steve\x1BHello\n") + out, err = ch.ReadLine(20) if err != nil { t.Fatalf("unexpected error %s", err) } @@ -62,9 +61,8 @@ func TestReadline(t *testing.T) { } // Too much input? We truncate - x.stuffed = "I like to move it, move it\n" - x.State = Echo - out, err = x.ReadLine(5) + ch.StuffInput("I like to move it, move it\n") + out, err = ch.ReadLine(5) if err != nil { t.Fatalf("unexpected error %s", err) } @@ -73,10 +71,9 @@ func TestReadline(t *testing.T) { } // Add some history, and return the last value - x.history = append(x.history, "I like to move it") - x.stuffed = "ste\x10\n" - x.State = Echo - out, err = x.ReadLine(5) + history = append(history, "I like to move it") + ch.StuffInput("ste\x10\n") + out, err = ch.ReadLine(5) if err != nil { t.Fatalf("unexpected error %s", err) } @@ -84,63 +81,131 @@ func TestReadline(t *testing.T) { t.Fatalf("unexpected output %s", out) } - // Go back and forwardd in history - - x.stuffed = "\x10\x10\x10\x0e\n" - x.State = Echo - out, err = x.ReadLine(10) + // Go back and forward in history + ch.StuffInput("\x10\x10\x10\x0e\n") + out, err = ch.ReadLine(10) if err != nil { t.Fatalf("unexpected error %s", err) } if out != "Hello" { t.Fatalf("unexpected output %s", out) } +} + +// TestOverview just calls most of the methods, as an overview, to bump coverage. +func TestOverview(t *testing.T) { + + // Create a helper + x := STTYInput{} + + ch := ConsoleIn{} + ch.driver = &x + + ch.Setup() + defer ch.TearDown() + + ch.StuffInput("1.2.3.4.5.6.7.8.9.0\n") + if !ch.PendingInput() { + t.Fatalf("should have pending input") + } + + c, err := ch.BlockForCharacterNoEcho() + if err != nil { + t.Fatalf("unexpected error") + } + if c != '1' { + t.Fatalf("wrong character") + } + + c, err = ch.BlockForCharacterWithEcho() + if err != nil { + t.Fatalf("unexpected error") + } + if c != '.' { + t.Fatalf("wrong character") + } } func TestCtrlC(t *testing.T) { - x := New() + ch := ConsoleIn{} - if x.InterruptCount != 2 { + if interruptCount != 2 { t.Fatalf("unexpected default interrupt count") } - x.SetInterruptCount((3)) - if x.GetInterruptCount() != 3 { + ch.SetInterruptCount((3)) + if ch.GetInterruptCount() != 3 { t.Fatalf("unexpected interrupt count") } } func TestPending(t *testing.T) { - x := New() + // Create a helper + x := STTYInput{} + + ch := ConsoleIn{} + ch.driver = &x + + ch.Setup() + defer ch.TearDown() - x.StuffInput("foo") - if !x.PendingInput() { + ch.StuffInput("foo") + if !ch.PendingInput() { t.Fatalf("we should have pending input, but see none") } } -// TestExec is just here for coverate -func TestExec(t *testing.T) { +// TestDriverRegistration performs some sanity-check on our driver-registration. +func TestDriverRegistration(t *testing.T) { - x := New() + if len(handlers.m) != 2 { + t.Fatalf("wrong number of handlers") + } - // No echo - x.disableEcho() - if x.State != NoEcho { - t.Fatalf("unexpected state") + _, ok := handlers.m["term"] + if !ok { + t.Fatalf("failed to find expected handler, term") + } + _, err := New("term") + if err != nil { + t.Fatalf("failed to find expected handler, term") } - x.enableEcho() - if x.State != Echo { - t.Fatalf("unexpected state") + _, ok = handlers.m["stty"] + if !ok { + t.Fatalf("failed to find expected handler, stty") + } + _, err = New("stty") + if err != nil { + t.Fatalf("failed to find expected handler, term") + } + + _, ok = handlers.m["bogus"] + if ok { + t.Fatalf("found unexpected handler!") + } + _, err = New("bogus") + if err == nil { + t.Fatalf("failed to find expected handler, term") } - x.Reset() - if x.State != Echo { - t.Fatalf("unexpected state") + obj, err2 := New("stty") + if err2 != nil { + t.Fatalf("error looking up driver") } + drv := obj.GetDriver() + if drv.GetName() != "stty" { + t.Fatalf("naming mismatch on driver!") + } + if obj.GetName() != "stty" { + t.Fatalf("naming mismatch on driver!") + } + if len(obj.GetDrivers()) != 2 { + t.Fatalf("driver count is wrong") + } + } diff --git a/consolein/drv_stty.go b/consolein/drv_stty.go new file mode 100644 index 0000000..65ab44a --- /dev/null +++ b/consolein/drv_stty.go @@ -0,0 +1,158 @@ +//go:build unix + +// drv_stty creates a console input-driver which uses the +// `stty` binary to set our echo/no-echo state. +// +// This is obviously not portable outwith Unix-like systems. + +package consolein + +import ( + "fmt" + "os" + "os/exec" + + "golang.org/x/sys/unix" + "golang.org/x/term" +) + +// EchoStatus is used to record our current state. +type EchoStatus int + +var ( + // Unknown means we don't know the status of echo/noecho + Unknown EchoStatus = 0 + + // Echo means that input will echo characters. + Echo EchoStatus = 1 + + // NoEcho means that input will not echo characters. + NoEcho EchoStatus = 2 +) + +// STTYInput is an input-driver that executes the 'stty' binary +// to toggle between echoing character input, and disabling the +// echo. +// +// This is slow, as you can imagine, and non-portable outwith Unix-like +// systems. To mitigate against the speed-issue we keep track of "echo" +// versus "noecho" states, to minimise the executions. +type STTYInput struct { + + // state holds our state + state EchoStatus +} + +// Setup is a NOP. +func (si *STTYInput) Setup() { + // NOP +} + +// TearDown resets the state of the terminal. +func (si *STTYInput) TearDown() { + if si.state != Echo { + si.enableEcho() + } +} + +// canSelect contains a platform-specific implementation of code that tries to use +// SELECT to read from STDIN. +func canSelect() bool { + + fds := new(unix.FdSet) + fds.Set(int(os.Stdin.Fd())) + + // See if input is pending, for a while. + tv := unix.Timeval{Usec: 200} + + // via select with timeout + nRead, err := unix.Select(1, fds, nil, nil, &tv) + if err != nil { + return false + } + + return (nRead > 0) +} + +// PendingInput returns true if there is pending input from STDIN.. +// +// Note that we have to set RAW mode, without this input is laggy +// and zork doesn't run. +func (si *STTYInput) PendingInput() bool { + + // switch stdin into 'raw' mode + oldState, err := term.MakeRaw(int(os.Stdin.Fd())) + if err != nil { + return false + } + + // Can we read from STDIN? + res := canSelect() + + // restore the state of the terminal to avoid mixing RAW/Cooked + err = term.Restore(int(os.Stdin.Fd()), oldState) + if err != nil { + return false + } + + // Return true if we have something ready to read. + return res +} + +// BlockForCharacterNoEcho returns the next character from the console, blocking until +// one is available. +// +// NOTE: This function should not echo keystrokes which are entered. +func (si *STTYInput) BlockForCharacterNoEcho() (byte, error) { + + // Do we need to change state? If so then do it. + if si.state != NoEcho { + si.disableEcho() + } + + // switch stdin into 'raw' mode + oldState, err := term.MakeRaw(int(os.Stdin.Fd())) + if err != nil { + return 0x00, fmt.Errorf("error making raw terminal %s", err) + } + + // read only a single byte + b := make([]byte, 1) + _, err = os.Stdin.Read(b) + if err != nil { + return 0x00, fmt.Errorf("error reading a byte from stdin %s", err) + } + + // restore the state of the terminal to avoid mixing RAW/Cooked + err = term.Restore(int(os.Stdin.Fd()), oldState) + if err != nil { + return 0x00, fmt.Errorf("error restoring terminal state %s", err) + } + + // Return the character we read + return b[0], nil +} + +// disableEcho is the single place where we disable echoing. +func (si *STTYInput) disableEcho() { + _ = exec.Command("stty", "-F", "/dev/tty", "-echo").Run() + si.state = NoEcho +} + +// enableEcho is the single place where we enable echoing. +func (si *STTYInput) enableEcho() { + _ = exec.Command("stty", "-F", "/dev/tty", "echo").Run() + si.state = Echo +} + +// GetName is part of the module API, and returns the name of this driver. +func (si *STTYInput) GetName() string { + return "stty" +} + +// init registers our driver, by name. +func init() { + Register("stty", func() ConsoleInput { + return new(STTYInput) + }) +} diff --git a/consolein/drv_term.go b/consolein/drv_term.go new file mode 100644 index 0000000..4d25701 --- /dev/null +++ b/consolein/drv_term.go @@ -0,0 +1,142 @@ +// drv_term.go uses the Termbox library to handle console-based input. +// +// A goroutine is launched which collects any keyboard input and +// saves that to a buffer where it can be peeled off on-demand. +// +// The portability of this solution is unknown, however this driver +// _seems_ reasonable and is the default. + +package consolein + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/nsf/termbox-go" + "golang.org/x/term" +) + +// TermboxInput is our input-driver, using termbox +type TermboxInput struct { + + // oldState contains the state of the terminal, before switching to RAW mode + oldState *term.State + + // Cancel holds a context which can be used to close our polling goroutine + Cancel context.CancelFunc + + // keyBuffer builds up keys read "in the background", via termbox + keyBuffer []rune +} + +// Setup ensures that the termbox init functions are called, and our +// terminal is set into RAW mode. +func (ti *TermboxInput) Setup() { + + var err error + + // switch STDIN into 'raw' mode - we must do this before + // we setup termbox. + ti.oldState, err = term.MakeRaw(int(os.Stdin.Fd())) + if err != nil { + panic(err) + } + + // Setup the terminal. + err = termbox.Init() + if err != nil { + panic(err) + } + + // This is "Show Cursor" which termbox hides by default. + // + // Sigh. + fmt.Printf("\x1b[?25h") + + // Allow our polling of keyboard to be canceled + ctx, cancel := context.WithCancel(context.Background()) + ti.Cancel = cancel + + // Start polling for keyboard input "in the background". + go ti.pollKeyboard(ctx) +} + +// pollKeyboard runs in a goroutine and collects keyboard input +// into a buffer where it will be read from in the future. +func (ti *TermboxInput) pollKeyboard(ctx context.Context) { + for { + // Are we done? + select { + case <-ctx.Done(): + return + default: + // NOP + } + + // Now look for keyboard input + switch ev := termbox.PollEvent(); ev.Type { + case termbox.EventKey: + if ev.Ch != 0 { + ti.keyBuffer = append(ti.keyBuffer, ev.Ch) + } else { + ti.keyBuffer = append(ti.keyBuffer, rune(ev.Key)) + } + } + } +} + +// TearDown resets the state of the terminal, disables the background polling of characters +// and generally gets us ready for exit. +func (ti *TermboxInput) TearDown() { + // Cancel the keyboard reading + if ti.Cancel != nil { + ti.Cancel() + } + + // Terminate the GUI. + termbox.Close() + + // Restore the terminal + if ti.oldState != nil { + err := term.Restore(int(os.Stdin.Fd()), ti.oldState) + if err != nil { + fmt.Printf("failed to restore terminal:%s\n", err) + } + } +} + +// PendingInput returns true if there is pending input from STDIN. +func (ti *TermboxInput) PendingInput() bool { + + return len(ti.keyBuffer) > 0 +} + +// BlockForCharacterNoEcho returns the next character from the console, blocking until +// one is available. +// +// NOTE: This function should not echo keystrokes which are entered. +func (ti *TermboxInput) BlockForCharacterNoEcho() (byte, error) { + + for len(ti.keyBuffer) == 0 { + time.Sleep(1 * time.Millisecond) + } + + // Return the character + c := ti.keyBuffer[0] + ti.keyBuffer = ti.keyBuffer[1:] + return byte(c), nil +} + +// GetName is part of the module API, and returns the name of this driver. +func (ti *TermboxInput) GetName() string { + return "term" +} + +// init registers our driver, by name. +func init() { + Register("term", func() ConsoleInput { + return new(TermboxInput) + }) +} diff --git a/consolein/select_unix.go b/consolein/select_unix.go deleted file mode 100644 index e968fad..0000000 --- a/consolein/select_unix.go +++ /dev/null @@ -1,28 +0,0 @@ -//go:build unix - -package consolein - -import ( - "os" - - "golang.org/x/sys/unix" -) - -// canSelect contains a platform-specific implementation of code that tries to use -// SELECT to read from STDIN. -func canSelect() bool { - - fds := new(unix.FdSet) - fds.Set(int(os.Stdin.Fd())) - - // See if input is pending, for a while. - tv := unix.Timeval{Usec: 200} - - // via select with timeout - nRead, err := unix.Select(1, fds, nil, nil, &tv) - if err != nil { - return false - } - - return (nRead > 0) -} diff --git a/consoleout/consoleout.go b/consoleout/consoleout.go index b750d1c..1ca821f 100644 --- a/consoleout/consoleout.go +++ b/consoleout/consoleout.go @@ -8,16 +8,22 @@ package consoleout import ( "fmt" "io" + "strings" ) -// ConsoleDriver is the interface that must be implemented by anything +// ConsoleOutput is the interface that must be implemented by anything // that wishes to be used as a console driver. // // Providing this interface is implemented an object may register itself, // by name, via the Register method. -type ConsoleDriver interface { +// +// You can compare this to the ConsoleInput interface, which is similar, although +// in that case the wrapper which creates the instances also implements some common methods. +type ConsoleOutput interface { - // PutCharacter will output the specified character to STDOUT. + // PutCharacter will output the specified character to the defined writer. + // + // The writer will default to STDOUT, but can be changed, via SetWriter. PutCharacter(c uint8) // GetName will return the name of the driver. @@ -47,13 +53,16 @@ var handlers = struct { // Constructor is the signature of a constructor-function // which is used to instantiate an instance of a driver. -type Constructor func() ConsoleDriver +type Constructor func() ConsoleOutput // Register makes a console driver available, by name. // // When one needs to be created the constructor can be called // to create an instance of it. func Register(name string, obj Constructor) { + // Downcase for consistency. + name = strings.ToLower(name) + handlers.m[name] = obj } @@ -62,12 +71,14 @@ func Register(name string, obj Constructor) { type ConsoleOut struct { // driver is the thing that actually writes our output. - driver ConsoleDriver + driver ConsoleOutput } // New is our constructore, it creates an output device which uses // the specified driver. func New(name string) (*ConsoleOut, error) { + // Downcase for consistency. + name = strings.ToLower(name) // Do we have a constructor with the given name? ctor, ok := handlers.m[name] @@ -82,7 +93,7 @@ func New(name string) (*ConsoleOut, error) { } // GetDriver allows getting our driver at runtime. -func (co *ConsoleOut) GetDriver() ConsoleDriver { +func (co *ConsoleOut) GetDriver() ConsoleOutput { return co.driver } diff --git a/consoleout/drv_adm3a.go b/consoleout/drv_adm3a.go index 018262b..1667f76 100644 --- a/consoleout/drv_adm3a.go +++ b/consoleout/drv_adm3a.go @@ -155,7 +155,7 @@ func (a3a *Adm3AOutputDriver) SetWriter(w io.Writer) { // init registers our driver, by name. func init() { - Register("adm-3a", func() ConsoleDriver { + Register("adm-3a", func() ConsoleOutput { return &Adm3AOutputDriver{ writer: os.Stdout, } diff --git a/consoleout/drv_ansi.go b/consoleout/drv_ansi.go index 07e44fb..f9bff37 100644 --- a/consoleout/drv_ansi.go +++ b/consoleout/drv_ansi.go @@ -33,7 +33,7 @@ func (ad *AnsiOutputDriver) SetWriter(w io.Writer) { // init registers our driver, by name. func init() { - Register("ansi", func() ConsoleDriver { + Register("ansi", func() ConsoleOutput { return &AnsiOutputDriver{ writer: os.Stdout, } diff --git a/consoleout/drv_logger.go b/consoleout/drv_logger.go index aeea693..91d29bd 100644 --- a/consoleout/drv_logger.go +++ b/consoleout/drv_logger.go @@ -52,7 +52,7 @@ func (ol *OutputLoggingDriver) Reset() { // init registers our driver, by name. func init() { - Register("logger", func() ConsoleDriver { + Register("logger", func() ConsoleOutput { return &OutputLoggingDriver{ writer: os.Stdout, } diff --git a/consoleout/drv_null.go b/consoleout/drv_null.go index 5513f6c..205530b 100644 --- a/consoleout/drv_null.go +++ b/consoleout/drv_null.go @@ -35,7 +35,7 @@ func (no *NullOutputDriver) SetWriter(w io.Writer) { // init registers our driver, by name. func init() { - Register("null", func() ConsoleDriver { + Register("null", func() ConsoleOutput { return &NullOutputDriver{ writer: os.Stdout, } diff --git a/cpm/cpm.go b/cpm/cpm.go index 14bf8b6..8946ed8 100644 --- a/cpm/cpm.go +++ b/cpm/cpm.go @@ -47,6 +47,12 @@ var ( // // It should be handled and expected by callers. ErrUnimplemented = errors.New("UNIMPLEMENTED") + + // DefaultInputDriver contains the name of the default console input driver. + DefaultInputDriver string = "term" + + // DefaultOutputDriver contains the name of the default console output driver. + DefaultOutputDriver string = "adm-3a" ) // CPMHandlerType contains the signature of a function we use to @@ -217,9 +223,8 @@ func WithPrinterPath(path string) cpmoption { } } -// WithConsoleDriver allows the console driver to be created in our -// constructor. -func WithConsoleDriver(name string) cpmoption { +// WithOutputDriver allows the default console output driver to be changed in our constructor. +func WithOutputDriver(name string) cpmoption { return func(c *CPM) error { @@ -233,8 +238,23 @@ func WithConsoleDriver(name string) cpmoption { } } +// WithInputDriver allows the default console input driver to be changed in our constructor. +func WithInputDriver(name string) cpmoption { + + return func(c *CPM) error { + + driver, err := consolein.New(name) + if err != nil { + return err + } + + c.input = driver + return nil + } +} + // New returns a new emulation object. We support default options, -// and new defaults may be specified via WithConsoleDriver, etc, etc. +// and new defaults may be specified via WithOutputDriver, etc, etc. func New(options ...cpmoption) (*CPM, error) { // @@ -496,7 +516,13 @@ func New(options ...cpmoption) (*CPM, error) { } // Default output driver - driver, err := consoleout.New("adm-3a") + oDriver, err := consoleout.New(DefaultOutputDriver) + if err != nil { + return nil, err + } + + // Default input driver + iDriver, err := consolein.New(DefaultInputDriver) if err != nil { return nil, err } @@ -509,8 +535,8 @@ func New(options ...cpmoption) (*CPM, error) { dma: 0x0080, drives: make(map[string]string), files: make(map[uint16]FileCache), - input: consolein.New(), - output: driver, // default + input: iDriver, // default + output: oDriver, // default prnPath: "printer.log", // default start: 0x0100, launchTime: time.Now(), @@ -527,13 +553,23 @@ func New(options ...cpmoption) (*CPM, error) { return tmp, nil } -// Cleanup cleans up the state of the terminal, if necessary. -func (cpm *CPM) Cleanup() { - cpm.input.Reset() +// IOSetup ensures that our I/O is ready. +func (cpm *CPM) IOSetup() { + cpm.input.Setup() +} + +// IOTearDown cleans up the state of the terminal, if necessary. +func (cpm *CPM) IOTearDown() { + cpm.input.TearDown() +} + +// GetInputDriver returns the configured input driver. +func (cpm *CPM) GetInputDriver() consolein.ConsoleInput { + return cpm.input.GetDriver() } // GetOutputDriver returns the configured output driver. -func (cpm *CPM) GetOutputDriver() consoleout.ConsoleDriver { +func (cpm *CPM) GetOutputDriver() consoleout.ConsoleOutput { return cpm.output.GetDriver() } diff --git a/cpm/cpm_bdos_test.go b/cpm/cpm_bdos_test.go index f1808bb..c0c7e8f 100644 --- a/cpm/cpm_bdos_test.go +++ b/cpm/cpm_bdos_test.go @@ -12,15 +12,17 @@ import ( "github.com/skx/cpmulator/static" ) +// Flaky with our new implementation. func TestConsoleInput(t *testing.T) { + // Create a new helper - c, err := New(WithPrinterPath("11.log")) + c, err := New(WithPrinterPath("11.log"), WithInputDriver("term")) if err != nil { t.Fatalf("failed to create CPM") } c.Memory = new(memory.Memory) c.fixupRAM() - defer c.Cleanup() + defer c.IOTearDown() // ReadChar c.StuffText("s") @@ -28,10 +30,6 @@ func TestConsoleInput(t *testing.T) { if err != nil { t.Fatalf("failed to call CP/M") } - err = BdosSysCallReadChar(c) - if err == nil { - t.Fatalf("expected error, got none") - } if c.CPU.States.AF.Hi != 's' { t.Fatalf("got the wrong input") } @@ -45,10 +43,6 @@ func TestConsoleInput(t *testing.T) { if c.CPU.States.AF.Hi != 'k' { t.Fatalf("got the wrong input") } - err = BdosSysCallAuxRead(c) - if err == nil { - t.Fatalf("expected to get an error, but didn't") - } // RawIO c.StuffText("x") @@ -106,7 +100,7 @@ func TestUnimplemented(t *testing.T) { } c.Memory = new(memory.Memory) c.fixupRAM() - defer c.Cleanup() + defer c.IOTearDown() // Create a binary var file *os.File @@ -155,7 +149,7 @@ func TestBoot(t *testing.T) { } c.Memory = new(memory.Memory) c.fixupRAM() - defer c.Cleanup() + defer c.IOTearDown() // Create a binary var file *os.File @@ -208,7 +202,7 @@ func TestFind(t *testing.T) { c.fixupRAM() c.SetDrives(false) c.SetStaticFilesystem(static.GetContent()) - defer c.Cleanup() + defer c.IOTearDown() // Create a pattern in an FCB name := "*.GO" @@ -288,7 +282,7 @@ func TestFind(t *testing.T) { } - if found != 4 { + if found != 5 { t.Fatalf("found wrong number of embedded files, got %d", found) } @@ -349,7 +343,7 @@ func TestReadLine(t *testing.T) { c.Memory = new(memory.Memory) // Stuff some fake input - c.input = consolein.New() + c.input, _ = consolein.New("stty") c.StuffText("steve\n") // Setup a buffer, so we can read 5 characters @@ -1163,7 +1157,7 @@ func TestFileOpen(t *testing.T) { c.Memory = new(memory.Memory) c.fixupRAM() c.SetDrives(false) - defer c.Cleanup() + defer c.IOTearDown() // Create a binary diff --git a/cpm/cpm_bios.go b/cpm/cpm_bios.go index fb0754a..81d2730 100644 --- a/cpm/cpm_bios.go +++ b/cpm/cpm_bios.go @@ -173,6 +173,7 @@ func BiosSysCallReserved1(cpm *CPM) error { // Get our version vers := version.GetVersionBanner() + vers = strings.ReplaceAll(vers, "\n", "\n\r") // Fill the DMA area with NULL bytes addr := cpm.dma diff --git a/cpm/cpm_bios_test.go b/cpm/cpm_bios_test.go index 3884f10..a69d584 100644 --- a/cpm/cpm_bios_test.go +++ b/cpm/cpm_bios_test.go @@ -14,13 +14,13 @@ func TestStatus(t *testing.T) { if err != nil { t.Fatalf("failed to create CPM") } - + c.StuffText("") err = BiosSysCallConsoleStatus(c) if err != nil { t.Fatalf("failed to call CPM") } if c.CPU.States.AF.Hi != 0x00 { - t.Fatalf("console status was wrong") + t.Fatalf("console status was wrong %02X", c.CPU.States.AF.Hi) } c.input.StuffInput("S") @@ -294,7 +294,7 @@ func TestBIOSConsoleInput(t *testing.T) { } c.Memory = new(memory.Memory) c.fixupRAM() - defer c.Cleanup() + defer c.IOTearDown() c.input.StuffInput("s") err = BiosSysCallConsoleInput(c) @@ -317,7 +317,7 @@ func TestBIOSError(t *testing.T) { } c.Memory = new(memory.Memory) c.fixupRAM() - defer c.Cleanup() + defer c.IOTearDown() c.simpleDebug = true diff --git a/cpm/cpm_test.go b/cpm/cpm_test.go index 95fa541..febadc5 100644 --- a/cpm/cpm_test.go +++ b/cpm/cpm_test.go @@ -11,7 +11,7 @@ import ( func TestSimple(t *testing.T) { // Create a new CP/M helper - obj, err := New(WithConsoleDriver("null")) + obj, err := New(WithOutputDriver("null"), WithInputDriver("stty")) if err != nil { t.Fatalf("failed to create CPM") } @@ -51,6 +51,9 @@ func TestSimple(t *testing.T) { if obj.GetOutputDriver().GetName() != "null" { t.Fatalf("console driver name mismatch!") } + if obj.GetInputDriver().GetName() != "stty" { + t.Fatalf("console driver name mismatch!") + } if obj.GetCCPName() != "ccp" { t.Fatalf("ccp name mismatch!") } @@ -95,15 +98,24 @@ func TestSimple(t *testing.T) { t.Fatalf("failed to run binary!") } - defer obj.Cleanup() + defer obj.IOTearDown() } func TestBogusConstructor(t *testing.T) { - _, err := New(WithConsoleDriver("bogus")) + _, err := New(WithOutputDriver("bogus")) if err == nil { t.Fatalf("expected error, bogus console driver, got none") } + + _, err = New(WithInputDriver("bogus")) + if err == nil { + t.Fatalf("expected error, bogus console driver, got none") + } + + x, _ := New(WithInputDriver("stty")) + x.IOSetup() + x.IOTearDown() } func TestLoadCCP(t *testing.T) { diff --git a/go.mod b/go.mod index cbb2ede..5dce9e4 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,15 @@ go 1.22.2 require ( github.com/koron-go/z80 v0.10.1 - golang.org/x/term v0.22.0 + golang.org/x/term v0.27.0 ) -require golang.org/x/sys v0.22.0 +require ( + github.com/nsf/termbox-go v1.1.1 + golang.org/x/sys v0.28.0 +) + +require ( + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/rivo/uniseg v0.4.7 // indirect +) diff --git a/go.sum b/go.sum index 5e2d4f7..0755092 100644 --- a/go.sum +++ b/go.sum @@ -2,7 +2,15 @@ github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/koron-go/z80 v0.10.1 h1:Jfb0esP/QFL4cvcr+eFECVG0Y/mA9JBLC4EKbMU5zAY= github.com/koron-go/z80 v0.10.1/go.mod h1:ry+Zl9kRKelzaDG9UzEtUpUnXy0Yv/kk1YEaX958xdk= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= -golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/nsf/termbox-go v1.1.1 h1:nksUPLCb73Q++DwbYUBEglYBRPZyoXJdrj5L+TkjyZY= +github.com/nsf/termbox-go v1.1.1/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= diff --git a/main.go b/main.go index d444099..2346a15 100644 --- a/main.go +++ b/main.go @@ -7,10 +7,12 @@ import ( "fmt" "log/slog" "os" + "slices" "sort" "strings" cpmccp "github.com/skx/cpmulator/ccp" + "github.com/skx/cpmulator/consolein" "github.com/skx/cpmulator/consoleout" "github.com/skx/cpmulator/cpm" "github.com/skx/cpmulator/static" @@ -28,20 +30,23 @@ func main() { // // Parse the command-line flags for this driver-application // + ccp := flag.String("ccp", "ccpz", "The name of the CCP that we should run (ccp vs. ccpz).") cd := flag.String("cd", "", "Change to this directory before launching") + console := flag.String("console", "", "The name of the console output driver to use (-list-output-drivers will show valid choices).") createDirectories := flag.Bool("create", false, "Create subdirectories on the host computer for each CP/M drive.") - console := flag.String("console", "adm-3a", "The name of the console output driver to use (adm-3a or ansi).") - ccp := flag.String("ccp", "ccpz", "The name of the CCP that we should run (ccp vs. ccpz).") - useDirectories := flag.Bool("directories", false, "Use subdirectories on the host computer for CP/M drives.") + input := flag.String("input", cpm.DefaultInputDriver, "The name of the console input driver to use (-list-input-drivers will show valid choices).") + output := flag.String("output", cpm.DefaultOutputDriver, "The name of the console output driver to use (-list-output-drivers will show valid choices).") + logAll := flag.Bool("log-all", false, "Log all function invocations, including the noisy console I/O ones.") logPath := flag.String("log-path", "", "Specify the file to write debug logs to.") - logAll := flag.Bool("log-all", false, "Log the output of all functions, including the noisy Console I/O ones.") prnPath := flag.String("prn-path", "print.log", "Specify the file to write printer-output to.") showVersion := flag.Bool("version", false, "Report our version, and exit.") + useDirectories := flag.Bool("directories", false, "Use subdirectories on the host computer for CP/M drives.") // listing - listCcps := flag.Bool("list-ccp", false, "Dump the list of embedded CCPs.") - listConsole := flag.Bool("list-console-drivers", false, "Dump the list of valid console drivers.") - listSyscalls := flag.Bool("list-syscalls", false, "Dump the list of implemented BIOS/BDOS syscall functions.") + listCcps := flag.Bool("list-ccp", false, "Dump the list of embedded CCPs, and exit.") + listOutput := flag.Bool("list-output-drivers", false, "Dump the list of valid console output drivers, and exit.") + listInput := flag.Bool("list-input-drivers", false, "Dump the list of valid console input drivers, and exit.") + listSyscalls := flag.Bool("list-syscalls", false, "Dump the list of implemented BIOS/BDOS syscall functions, and exit.") // drives drive := make(map[string]*string) @@ -73,16 +78,39 @@ func main() { return } - // Are we dumping console drivers? - if *listConsole { + // Are we dumping console input drivers? + if *listInput { + obj, _ := consolein.New("null") + valid := obj.GetDrivers() + slices.Sort(valid) + + for _, name := range valid { + suffix := "" + if name == cpm.DefaultInputDriver { + suffix = "\t[default]" + } + fmt.Printf("%s%s\n", name, suffix) + } + return + } + + // Are we dumping console output drivers? + if *listOutput { obj, _ := consoleout.New("null") valid := obj.GetDrivers() + slices.Sort(valid) + for _, name := range valid { - fmt.Printf("%s\n", name) + suffix := "" + if name == cpm.DefaultOutputDriver { + suffix = "\t[default]" + } + fmt.Printf("%s%s\n", name, suffix) } return } + // Are we dumping syscalls? if *listSyscalls { @@ -173,9 +201,17 @@ func main() { // Set the logger now we've updated as appropriate. slog.SetDefault(log) + // We used to use "-console", but now we prefer "-output" to match with "-input". + out := *output + if *console != "" { + out = *console + fmt.Printf("WARNING: -console is a deprecated flag, prefer to use -output.\r\n") + } + // Create a new emulator. obj, err := cpm.New(cpm.WithPrinterPath(*prnPath), - cpm.WithConsoleDriver(*console), + cpm.WithOutputDriver(out), + cpm.WithInputDriver(*input), cpm.WithCCP(*ccp)) if err != nil { fmt.Printf("error creating CPM object: %s\n", err) @@ -187,8 +223,12 @@ func main() { obj.LogNoisy() } + // I/O SETUP + obj.IOSetup() + + // I/O TearDown // When we're finishing we'll reset some (console) state. - defer obj.Cleanup() + defer obj.IOTearDown() // change directory? // @@ -233,20 +273,20 @@ func main() { } } if found == 0 { - fmt.Printf("WARNING: You've chosen to use subdirectories as drives.\n") - fmt.Printf(" i.e. A/ would be used for the contents of A:\n") - fmt.Printf(" i.e. B/ would be used for the contents of B:\n") - fmt.Printf("\n") - fmt.Printf(" However no drive-directories are present!\n") - fmt.Printf("\n") - fmt.Printf("You could fix this, like so:\n") - fmt.Printf(" mkdir A\n") - fmt.Printf(" mkdir B\n") - fmt.Printf(" mkdir C\n") - fmt.Printf(" etc\n") - fmt.Printf("\n") - fmt.Printf("Or you could launch this program with the '-create' flag.\n") - fmt.Printf("That would automatically create directories for drives A-P.\n") + fmt.Printf("WARNING: You've chosen to use subdirectories as drives.\r\n") + fmt.Printf(" i.e. A/ would be used for the contents of A:\r\n") + fmt.Printf(" i.e. B/ would be used for the contents of B:\r\n") + fmt.Printf("\r\n") + fmt.Printf(" However no drive-directories are present!\r\n") + fmt.Printf("\r\n") + fmt.Printf("You could fix this, like so:\r\n") + fmt.Printf(" mkdir A\r\n") + fmt.Printf(" mkdir B\r\n") + fmt.Printf(" mkdir C\r\n") + fmt.Printf(" etc\r\n") + fmt.Printf("\r\n") + fmt.Printf("Or you could launch this program with the '-create' flag.\r\n") + fmt.Printf("That would automatically create directories for drives A-P.\r\n") } } @@ -296,7 +336,7 @@ func main() { } // Show a startup-banner. - fmt.Printf("\ncpmulator %s loaded CCP %s, with %s output driver\n", cpmver.GetVersionString(), obj.GetCCPName(), obj.GetOutputDriver().GetName()) + fmt.Printf("\ncpmulator %s\r\nCCP:%s input driver:%s output driver:%s\n", cpmver.GetVersionString(), obj.GetCCPName(), obj.GetInputDriver().GetName(), obj.GetOutputDriver().GetName()) // We will load AUTOEXEC.SUB, once, if it exists (*) // diff --git a/main_test.go b/main_test.go index f9ac300..e293dc5 100644 --- a/main_test.go +++ b/main_test.go @@ -16,7 +16,7 @@ import ( // changing drives. func TestDriveChange(t *testing.T) { - obj, err := cpm.New(cpm.WithConsoleDriver("logger")) + obj, err := cpm.New(cpm.WithOutputDriver("logger")) if err != nil { t.Fatalf("Create CP/M failed") } @@ -95,7 +95,7 @@ func TestReadWriteRand(t *testing.T) { // However it is a great test to see that things work as expected. func TestCompleteLighthouse(t *testing.T) { - obj, err := cpm.New(cpm.WithConsoleDriver("logger")) + obj, err := cpm.New(cpm.WithOutputDriver("logger")) if err != nil { t.Fatalf("Create CP/M failed") } diff --git a/static/A/!VERSION.COM b/static/A/!VERSION.COM new file mode 100644 index 0000000..51219d7 Binary files /dev/null and b/static/A/!VERSION.COM differ diff --git a/static/Makefile b/static/Makefile index b5a3cb5..081fef0 100644 --- a/static/Makefile +++ b/static/Makefile @@ -2,7 +2,7 @@ # # The files we wish to generate. # -all: A/\#.COM A/!CCP.COM A/!CONSOLE.COM A/!CTRLC.COM A/!DEBUG.COM +all: A/\#.COM A/!CCP.COM A/!CONSOLE.COM A/!CTRLC.COM A/!DEBUG.COM A/!VERSION.COM # cleanup clean: @@ -26,3 +26,5 @@ A/!CTRLC.COM: ctrlc.z80 A/!DEBUG.COM: debug.z80 pasmo debug.z80 A/!DEBUG.COM +A/!VERSION.COM: version.z80 + pasmo version.z80 A/!VERSION.COM diff --git a/static/version.z80 b/static/version.z80 new file mode 100644 index 0000000..315b536 --- /dev/null +++ b/static/version.z80 @@ -0,0 +1,65 @@ +;; version.z80 - Show the version of the emulator we're running on. +;; +;; This uses the custom BIOS function we've added to the BIOS, which was never +;; present in real CP/M. Consider it a hook into the emulator. +;; + +FCB1: EQU 0x5C +BDOS_ENTRY_POINT: EQU 5 +BDOS_OUTPUT_STRING: EQU 9 + + ;; + ;; CP/M programs start at 0x100. + ;; + ORG 100H + + ;; Test that we're running under cpmulator by calling the + ;; "is cpmulator" function. + ld HL, 0x0000 + ld a, 31 + out (0xff), a + + ;; We expect SKX to appear in registers HLA + CP 'X' + jr nz, not_cpmulator + + LD A, H + CP 'S' + jr nz, not_cpmulator + + LD A, L + CP 'K' + jr nz, not_cpmulator + + ;; Okay then the version will be stored in the DMA area. + ;; unknown value + LD HL, 0x0080 +print_loop: + LD A, (HL) + CP 0 + JR Z, exit + LD E, A + INC HL + PUSH HL + LD C, 0x02 + CALL 0x0005 + POP HL + JR print_loop + + ;; Exit +exit: + LD C,0x00 + CALL BDOS_ENTRY_POINT + +not_cpmulator: + LD DE, WRONG_EMULATOR + LD C, BDOS_OUTPUT_STRING + call BDOS_ENTRY_POINT + jr exit + +;; +;; Text output strings. +;; +WRONG_EMULATOR: + db "This binary is not running under cpmulator, aborting.", 0x0a, 0x0d, "$" +END