From 717313a65724fa7be07a57ca1481eab22503ab0c Mon Sep 17 00:00:00 2001 From: Steve Kemp Date: Wed, 22 May 2024 22:09:13 +0300 Subject: [PATCH] Make the writing of characters to the console pluggable. (#86) * Make the writing of characters to the console pluggable. Once complete this pull-request will close #85 by allowing us to change output drivers at runtime. We'll also remove the messy ADM-3A output handling from our core CPM package. * Make the console output driver selectable via CLI flag * Working selectable output, via BIOS. --- EXTENSIONS.md | 12 ++- README.md | 28 ++++--- cpm/outc.go => consoleout/console_adm3a.go | 85 ++++++++++++-------- consoleout/console_ansi.go | 28 +++++++ consoleout/consoleout.go | 87 +++++++++++++++++++++ cpm/cpm.go | 33 ++++---- cpm/cpm_bdos.go | 6 +- cpm/cpm_bios.go | 39 ++++++++- main.go | 15 +++- samples/Makefile | 2 +- samples/console.com | Bin 0 -> 63 bytes samples/console.z80 | 45 +++++++++++ samples/ctrlc.z80 | 11 ++- 13 files changed, 319 insertions(+), 72 deletions(-) rename cpm/outc.go => consoleout/console_adm3a.go (67%) create mode 100644 consoleout/console_ansi.go create mode 100644 consoleout/consoleout.go create mode 100644 samples/console.com create mode 100644 samples/console.z80 diff --git a/EXTENSIONS.md b/EXTENSIONS.md index a343fe4..d740df3 100644 --- a/EXTENSIONS.md +++ b/EXTENSIONS.md @@ -15,7 +15,7 @@ Currently we have one function implemented, demonstrated in [samples/ctrlc.z80]( -## Function 01 +## Function 0x01 * If C == 0xFF return the value of the Ctrl-C count in A. * IF C != 0xFF set the Ctrl-C count to be C. @@ -37,6 +37,14 @@ Example: -## Function 0xx +## Function 0x02 +On entry DE points to a text-string, terminated by NULL, which represents the name of the +console output driver to use. + +Demonstrated in [samples/console.z80](samples/console.z80) + + + +## Function 0x00 * TODO diff --git a/README.md b/README.md index f300d35..9466e18 100644 --- a/README.md +++ b/README.md @@ -118,10 +118,6 @@ You can terminate the CCP by typing `EXIT`. The following built-in commands are -Traditionally pressing `Ctrl-C` would reload the CCP, via a soft boot. I think that combination is likely to be entered by accident, so in `cpmulator` pressing Ctrl-C _twice_ will reboot the CCP. - -> I've added a binary `samples/ctrlc.com` which lets you change this at runtime, via an internal [BIOS extension](EXTENSIONS.md). Run `ctrlc 0` to disable the Ctrl-C behaviour, or `ctrlc N` to require N consecutive Ctrl-C keystrokes to trigger the restart-behaviour. Neat. - There are currently a pair of CCP implementations included within the emulator, and they can be selected via the `-ccp` command-line flag: * "ccp" @@ -157,6 +153,24 @@ Other options are shown in the output of `cpmulator -help`, but in brief: +## Runtime Changes + +Traditionally pressing `Ctrl-C` would reload the CCP, via a soft boot. I think that combination is likely to be entered by accident, so in `cpmulator` we default to requiring you to press Ctrl-C _twice_ to reboot the CCP. + +> I've added a binary `samples/ctrlc.com` which lets you change this at runtime, via an internal [BIOS extension](EXTENSIONS.md). +> Run `ctrlc 0` to disable the Ctrl-C behaviour, or `ctrlc N` to require N consecutive Ctrl-C keystrokes to trigger the restart-behaviour. Neat. + +Similarly we default to using emulation to pretend our output device is an ADM-3A terminal, this can be changed via a command-line flag at startup. + +> I've added a binary `samples/console.com` which lets you change this at runtime, via an internal [BIOS extension](EXTENSIONS.md). +> Run `console ansi` to disable the output emulation, or `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. + +> The `cpm-dist` repository also includes both CTRLC.COM and CONSOLE.COM on the A: drive, for ease of use. + + + # Sample Binaries @@ -292,12 +306,6 @@ If things are _mostly_ working, but something is not quite producing the correct * [DEBUGGING.md](DEBUGGING.md) -The following environmental variables influence runtime behaviour: - -| Variable | Purpose | -|-------------|---------------------------------------------------------------| -| SIMPLE_CHAR | Avoid the attempted VT52 output conversion. | - For reference the memory map of our CP/M looks like this: * 0x0000 - Start of RAM diff --git a/cpm/outc.go b/consoleout/console_adm3a.go similarity index 67% rename from cpm/outc.go rename to consoleout/console_adm3a.go index da94b15..26fd95e 100644 --- a/cpm/outc.go +++ b/consoleout/console_adm3a.go @@ -1,23 +1,33 @@ -package cpm +package consoleout -import ( - "fmt" - "os" -) +import "fmt" -// outC attempts to write a single character output, but converting to -// ANSI from vt. This means tracking state and handling multi-byte -// output properly. +// Adm3AOutputDriver holds our state. +type Adm3AOutputDriver struct { + + // status contains our state, in the state-machine + status int + + // x stores the cursor X + x uint8 + + // y stores the cursor Y + y uint8 +} + +// GetName returns the name of this driver. // -// This is all a bit sleazy. -func (cpm *CPM) outC(c uint8) { +// This is part of the OutputDriver interface. +func (a3a *Adm3AOutputDriver) GetName() string { + return "adm-3a" +} - if os.Getenv("SIMPLE_CHAR") != "" { - fmt.Printf("%c", c) - return - } +// PutCharacter writes the character to the console. +// +// This is part of the OutputDriver interface. +func (a3a *Adm3AOutputDriver) PutCharacter(c uint8) { - switch cpm.auxStatus { + switch a3a.status { case 0: switch c { case 0x07: /* BEL: flash screen */ @@ -31,9 +41,9 @@ func (cpm *CPM) outC(c uint8) { case 0x1E: /* adm3a cursor home */ fmt.Printf("\033[H") case 0x1B: - cpm.auxStatus = 1 /* esc-prefix */ + a3a.status = 1 /* esc-prefix */ case 1: - cpm.auxStatus = 2 /* cursor motion prefix */ + a3a.status = 2 /* cursor motion prefix */ case 2: /* insert line */ fmt.Printf("\033[L") case 3: /* delete line */ @@ -50,32 +60,32 @@ func (cpm *CPM) outC(c uint8) { case 0x1B: fmt.Printf("%c", c) case '=', 'Y': - cpm.auxStatus = 2 + a3a.status = 2 case 'E': /* insert line */ fmt.Printf("\033[L") case 'R': /* delete line */ fmt.Printf("\033[M") case 'B': /* enable attribute */ - cpm.auxStatus = 4 + a3a.status = 4 case 'C': /* disable attribute */ - cpm.auxStatus = 5 + a3a.status = 5 case 'L', 'D': /* set line */ /* delete line */ - cpm.auxStatus = 6 + a3a.status = 6 case '*', ' ': /* set pixel */ /* clear pixel */ - cpm.auxStatus = 8 + a3a.status = 8 default: /* some true ANSI sequence? */ - cpm.auxStatus = 0 + a3a.status = 0 fmt.Printf("%c%c", 0x1B, c) } case 2: - cpm.y = c - ' ' + 1 - cpm.auxStatus = 3 + a3a.y = c - ' ' + 1 + a3a.status = 3 case 3: - cpm.x = c - ' ' + 1 - cpm.auxStatus = 0 - fmt.Printf("\033[%d;%dH", cpm.y, cpm.x) + a3a.x = c - ' ' + 1 + a3a.status = 0 + fmt.Printf("\033[%d;%dH", a3a.y, a3a.x) case 4: /* +B prefix */ - cpm.auxStatus = 0 + a3a.status = 0 switch c { case '0': /* start reverse video */ fmt.Printf("\033[7m") @@ -97,7 +107,7 @@ func (cpm *CPM) outC(c uint8) { fmt.Printf("%cB%c", 0x1B, c) } case 5: /* +C prefix */ - cpm.auxStatus = 0 + a3a.status = 0 switch c { case '0': /* stop reverse video */ fmt.Printf("\033[27m") @@ -120,13 +130,20 @@ func (cpm *CPM) outC(c uint8) { } /* set/clear line/point */ case 6: - cpm.auxStatus++ + a3a.status++ case 7: - cpm.auxStatus++ + a3a.status++ case 8: - cpm.auxStatus++ + a3a.status++ case 9: - cpm.auxStatus = 0 + a3a.status = 0 } } + +// init registers our driver, by name. +func init() { + Register("adm-3a", func() ConsoleDriver { + return &Adm3AOutputDriver{} + }) +} diff --git a/consoleout/console_ansi.go b/consoleout/console_ansi.go new file mode 100644 index 0000000..3142179 --- /dev/null +++ b/consoleout/console_ansi.go @@ -0,0 +1,28 @@ +package consoleout + +import "fmt" + +// AnsiOutputDriver holds our state. +type AnsiOutputDriver struct { +} + +// GetName returns the name of this driver. +// +// This is part of the OutputDriver interface. +func (ad *AnsiOutputDriver) GetName() string { + return "ansi" +} + +// PutCharacter writes the specified character to the console. +// +// This is part of the OutputDriver interface. +func (ad *AnsiOutputDriver) PutCharacter(c uint8) { + fmt.Printf("%c", c) +} + +// init registers our driver, by name. +func init() { + Register("ansi", func() ConsoleDriver { + return &AnsiOutputDriver{} + }) +} diff --git a/consoleout/consoleout.go b/consoleout/consoleout.go new file mode 100644 index 0000000..3430009 --- /dev/null +++ b/consoleout/consoleout.go @@ -0,0 +1,87 @@ +// Package consoleout is an abstruction over console output. +// +// We know we need an ANSI/RAW output, and we have an ADM-3A driver, +// so we want to create a factory that can instantiate and change a driver, +// given just a name. +package consoleout + +import "fmt" + +// ConsoleDriver 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 { + + // PutCharacter will output the specified character to STDOUT. + PutCharacter(c uint8) + + // 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)} + +// Constructor is the signature of a constructor-function +// which is used to instantiate an instance of a driver. +type Constructor func() ConsoleDriver + +// 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) { + handlers.m[name] = obj +} + +// ConsoleOut holds our state, which is basically just a +// pointer to the object handling our output. +type ConsoleOut struct { + + // driver is the thing that actually writes our output. + driver ConsoleDriver +} + +// New is our constructore, it creates an output device which uses +// the specified driver. +func New(name string) (*ConsoleOut, error) { + + // 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 &ConsoleOut{ + driver: ctor(), + }, nil +} + +// ChangeDriver allows changing our driver at runtime. +func (co *ConsoleOut) ChangeDriver(name string) error { + + // Do we have a constructor with the given name? + ctor, ok := handlers.m[name] + if !ok { + return fmt.Errorf("failed to lookup driver by name '%s'", name) + } + + // change the driver by creating a new object + co.driver = ctor() + return nil +} + +// GetName returns the name of our selected driver. +func (co *ConsoleOut) GetName() string { + return co.driver.GetName() +} + +// PutCharacter outputs a character, using our selected driver. +func (co *ConsoleOut) PutCharacter(c byte) { + co.driver.PutCharacter(c) +} diff --git a/cpm/cpm.go b/cpm/cpm.go index 9aa1e8f..eb43cba 100644 --- a/cpm/cpm.go +++ b/cpm/cpm.go @@ -17,6 +17,7 @@ import ( "github.com/koron-go/z80" "github.com/skx/cpmulator/ccp" "github.com/skx/cpmulator/consolein" + "github.com/skx/cpmulator/consoleout" "github.com/skx/cpmulator/fcb" "github.com/skx/cpmulator/memory" ) @@ -104,6 +105,9 @@ type CPM struct { // This needs to take account of echo/no-echo status. input *consolein.ConsoleIn + // output is used for writing characters to the conolse + output consoleout.ConsoleDriver + // dma contains the address of the DMA area in RAM. // // The DMA area is used for all file I/O, and is 128 bytes in length. @@ -119,19 +123,6 @@ type CPM struct { // the programs it launches. start uint16 - // auxStatus handles storing the state for the auxiliary / punch output - // device. This is used by MBASIC amongst other things, and we use it - // to basically keep track of multibyte output - auxStatus int - - // x holds the character X position, when using AUX I/O. - // It is set/used by escape sequences. - x uint8 - - // y holds the character Y position, when using AUX I/O. - // It is set/used by escape sequences. - y uint8 - // BDOSSyscalls contains details of the BDOS syscalls we // know how to emulate, indexed by their ID. BDOSSyscalls map[uint8]CPMHandler @@ -182,7 +173,7 @@ type CPM struct { } // New returns a new emulation object -func New(logger *slog.Logger, prn string) *CPM { +func New(logger *slog.Logger, prn string, condriver string) (*CPM, error) { // // Create and populate our syscall table for the BDOS syscalls. @@ -408,10 +399,17 @@ func New(logger *slog.Logger, prn string) *CPM { Fake: true, } + // Output driver needs to be created + driver, err := consoleout.New(condriver) + if err != nil { + return nil, err + } + // Create the emulator object and return it tmp := &CPM{ Logger: logger, input: consolein.New(), + output: driver, BDOSSyscalls: sys, BIOSSyscalls: b, dma: 0x0080, @@ -419,7 +417,7 @@ func New(logger *slog.Logger, prn string) *CPM { files: make(map[uint16]FileCache), prnPath: prn, } - return tmp + return tmp, nil } // Cleanup cleans up the state of the terminal, if necessary. @@ -427,6 +425,11 @@ func (cpm *CPM) Cleanup() { cpm.input.Reset() } +// GetOutputDriver returns the name of our configured output driver. +func (cpm *CPM) GetOutputDriver() string { + return cpm.output.GetName() +} + // LoadBinary loads the given CP/M binary at the default address of 0x0100, // where it can then be launched by Execute. func (cpm *CPM) LoadBinary(filename string) error { diff --git a/cpm/cpm_bdos.go b/cpm/cpm_bdos.go index 7a551d3..24728f9 100644 --- a/cpm/cpm_bdos.go +++ b/cpm/cpm_bdos.go @@ -51,7 +51,7 @@ func SysCallReadChar(cpm *CPM) error { // SysCallWriteChar writes the single character in the E register to STDOUT. func SysCallWriteChar(cpm *CPM) error { - cpm.outC(cpm.CPU.States.DE.Lo) + cpm.output.PutCharacter(cpm.CPU.States.DE.Lo) return nil } @@ -92,7 +92,7 @@ func SysCallAuxWrite(cpm *CPM) error { // The character we're going to write c := cpm.CPU.States.BC.Lo - cpm.outC(c) + cpm.output.PutCharacter(c) return nil } @@ -109,7 +109,7 @@ func SysCallRawIO(cpm *CPM) error { cpm.CPU.States.AF.Hi = out return nil default: - cpm.outC(cpm.CPU.States.DE.Lo) + cpm.output.PutCharacter(cpm.CPU.States.DE.Lo) } return nil } diff --git a/cpm/cpm_bios.go b/cpm/cpm_bios.go index 72ed861..5419568 100644 --- a/cpm/cpm_bios.go +++ b/cpm/cpm_bios.go @@ -9,6 +9,9 @@ package cpm import ( "fmt" "log/slog" + "strings" + + "github.com/skx/cpmulator/consoleout" ) // BiosSysCallBoot handles a warm/cold boot. @@ -47,7 +50,7 @@ func BiosSysCallConsoleOutput(cpm *CPM) error { // Write the character in C to the screen. c := cpm.CPU.States.BC.Lo - cpm.outC(c) + cpm.output.PutCharacter(c) return nil } @@ -167,8 +170,13 @@ func BiosSysCallReserved1(cpm *CPM) error { // H == 1 // C == 0xff to get the ctrl-c count // C != 0xff to set the ctrl-c count + // + // H == 2 + // DE points to a string containing the console driver to use. + // h := cpm.CPU.States.HL.Hi c := cpm.CPU.States.BC.Lo + de := cpm.CPU.States.DE.U16() switch h { case 01: @@ -177,6 +185,35 @@ func BiosSysCallReserved1(cpm *CPM) error { } else { cpm.input.SetInterruptCount(int(c)) } + case 02: + str := "" + + c := cpm.Memory.Get(de) + for c != ' ' && c != 0x00 { + str += string(c) + de++ + c = cpm.Memory.Get(de) + } + + str = strings.ToLower(str) + + // Output driver needs to be created + driver, err := consoleout.New(str) + + // If it failed we're not going to terminate the syscall, or + // the emulator, just ignore the attempt. + if err != nil { + fmt.Printf("%s", err) + return nil + } + + old := cpm.output.GetName() + if old != str { + fmt.Printf("Console driver changed from %s to %s.\n", cpm.output.GetName(), driver.GetName()) + cpm.output = driver + } else { + fmt.Printf("console driver is already %s, making no change.\n", str) + } default: return fmt.Errorf("unknown custom BIOS function H:%02X", h) } diff --git a/main.go b/main.go index 3a301a8..04cce18 100644 --- a/main.go +++ b/main.go @@ -31,6 +31,7 @@ func main() { // cd := flag.String("cd", "", "Change to this directory before launching") 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", "ccp", "The name of the CCP that we should run (ccp vs. ccpz).") ccps := flag.Bool("ccps", false, "Dump the list of embedded CCPs.") useDirectories := flag.Bool("directories", false, "Use subdirectories on the host computer for CP/M drives.") @@ -76,7 +77,11 @@ func main() { } // Create helper - c := cpm.New(nil, "print.log") + c, err := cpm.New(nil, "print.log", "ansi") + if err != nil { + fmt.Printf("error creating CPM object: %s\n", err) + return + } dumper("BDOS", c.BDOSSyscalls) dumper("BIOS", c.BIOSSyscalls) @@ -134,7 +139,11 @@ func main() { })) // Create a new emulator. - obj := cpm.New(log, *prnPath) + obj, err := cpm.New(log, *prnPath, *console) + if err != nil { + fmt.Printf("error creating CPM object: %s\n", err) + return + } // When we're finishing we'll reset some (console) state. defer obj.Cleanup() @@ -240,7 +249,7 @@ func main() { // for { // Show a startup-banner. - fmt.Printf("\ncpmulator %s loaded CCP %s\n", version, *ccp) + fmt.Printf("\ncpmulator %s loaded CCP %s, with %s output driver\n", version, *ccp, obj.GetOutputDriver()) // Load the CCP binary - resetting RAM in the process. err := obj.LoadCCP(*ccp) diff --git a/samples/Makefile b/samples/Makefile index b57c874..550c423 100644 --- a/samples/Makefile +++ b/samples/Makefile @@ -2,7 +2,7 @@ # # The files we wish to generate. # -all: cli-args.com ctrlc.com create.com delete.com drive.com find.com intest.com read.com ret.com user-num.com version.com write.com +all: cli-args.com ctrlc.com console.com create.com delete.com drive.com find.com intest.com read.com ret.com user-num.com version.com write.com # diff --git a/samples/console.com b/samples/console.com new file mode 100644 index 0000000000000000000000000000000000000000..1f618a647dd83ec1179ff7c4871aa4154c9ae54c GIT binary patch literal 63 zcmcDDW%#FXN{msBNidedPX6+LK8CZb41x-be4Id9;$vuWVtT5TLUMjyaehv!LUdwE RuC8%nO=4bgW-J%43IIeL63+kt literal 0 HcmV?d00001 diff --git a/samples/console.z80 b/samples/console.z80 new file mode 100644 index 0000000..2091921 --- /dev/null +++ b/samples/console.z80 @@ -0,0 +1,45 @@ +;; console.z80 - Set the name of the console driver to use for output +;; +;; 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 + + ;; The FCB will be populated with the first argument, + ;; if the first character of that region is a space-character + ;; then we've got nothing specified + ld a, (FCB1 + 1) + cp 0x20 ; 0x20 = 32 == SPACE + jp z, missing_argument ; Got a space, report the error + + ld H, 02 + ld de, FCB1 + 1 + ld a, 31 + out (0xff), a + +exit: + LD C,0x00 + CALL BDOS_ENTRY_POINT + +missing_argument: + LD DE, MISSING_ARGUMENT_STR + LD C, 0x09 + call BDOS_ENTRY_POINT + jr exit + + +;;; +;;; Text output strings. +;;; +MISSING_ARGUMENT_STR: + db "Usage: console [adm-3a|ansi]", 0x0a, 0x0d, "$" + +END diff --git a/samples/ctrlc.z80 b/samples/ctrlc.z80 index c94ec83..ce4e65d 100644 --- a/samples/ctrlc.z80 +++ b/samples/ctrlc.z80 @@ -1,4 +1,11 @@ ;; ctrlc.asm - Get/Set the number of consecutive Ctrl-Cs required to reboot +;; +;; 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. +;; +;; TODO: +;; I guess we need a custom "is cpmulator" BIOS function too. +;; FCB1: EQU 0x5C BDOS_ENTRY_POINT: EQU 5 @@ -57,12 +64,10 @@ show_value: ;;; -;;; The message displayed if no command-line argument was present. +;;; Text output strings. ;;; SHOW_PREFIX: db "The Ctrl-C count is currently set to $" - - ;; note fall-through here :) NEWLINE: db 0xa, 0xd, "$"