├── .github ├── FUNDING.yml ├── build ├── run-tests.sh └── workflows │ ├── docker-publish.yml │ ├── pull_request.yml │ ├── push.yml │ └── release.yml ├── .gitignore ├── DEBUGGING.md ├── Dockerfile ├── EXTENSIONS.md ├── LICENSE ├── Makefile ├── README.md ├── ccp ├── CCPZ.ASM ├── CCPZ.BIN ├── DR.ASM ├── DR.BIN ├── Makefile ├── README.md ├── ccp.go └── ccp_test.go ├── consolein ├── FUZZING.md ├── consolein.go ├── consolein_test.go ├── drv_error.go ├── drv_file.go ├── drv_file_fuzz_test.go ├── drv_file_test.go ├── drv_stty.go └── drv_term.go ├── consoleout ├── consoleout.go ├── consoleout_test.go ├── drv_adm3a.go ├── drv_ansi.go ├── drv_logger.go └── drv_null.go ├── cpm ├── cpm.go ├── cpm_bdos.go ├── cpm_bdos_test.go ├── cpm_bios.go ├── cpm_bios_test.go ├── cpm_test.go ├── prnc.go └── prnc_test.go ├── dist ├── LIHOUSE.COM └── README.md ├── fcb ├── fcb.go └── fcb_test.go ├── go.mod ├── go.sum ├── main.go ├── main_test.go ├── memory ├── memory.go └── memory_test.go ├── samples ├── Makefile ├── README.md ├── cli-args.com ├── cli-args.z80 ├── create.com ├── create.z80 ├── delete.com ├── delete.z80 ├── filesize.com ├── filesize.z80 ├── find.com ├── find.z80 ├── intest.com ├── intest.z80 ├── read.com ├── read.z80 ├── ret.com ├── ret.z80 ├── tsize.com ├── tsize.z80 ├── write.com └── write.z80 ├── static ├── A │ ├── !CCP.COM │ ├── !CTRLC.COM │ ├── !DEBUG.COM │ ├── !DISABLE.COM │ ├── !HOSTCMD.COM │ ├── !INPUT.COM │ ├── !OUTPUT.COM │ ├── !PRNPATH.COM │ ├── !VERSION.COM │ └── #.COM ├── Makefile ├── README.md ├── ccp.z80 ├── comment.z80 ├── common.inc ├── ctrlc.z80 ├── debug.z80 ├── disable.z80 ├── hostcmd.z80 ├── input.z80 ├── output.z80 ├── prnpath.z80 ├── static.go ├── static_test.go └── version.z80 ├── test ├── README.md ├── a1.in ├── a1.pat ├── bbcbasic1.in ├── bbcbasic1.pat ├── bbcbasic2.in ├── bbcbasic2.pat ├── ccp1.in ├── ccp1.pat ├── ccp2.in ├── ccp2.pat ├── ddt.in ├── ddt.pat ├── echo.in ├── echo.pat ├── hello.in ├── hello.pat ├── input.in ├── input.pat ├── lighthouse1.in ├── lighthouse1.pat ├── lighthouse2.in ├── lighthouse2.pat ├── lighthouse3.in ├── lighthouse3.pat ├── mbasic1.in ├── mbasic1.pat ├── mbasic2.in ├── mbasic2.pat ├── output.in ├── output.pat ├── run-test.sh ├── run-tests.sh ├── tp1.in ├── tp1.pat ├── tp2.in ├── tp2.pat ├── zork1.in ├── zork1.pat ├── zork2.in └── zork2.pat └── version ├── version.go └── version_test.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | github: skx 3 | custom: https://steve.fi/donate/ 4 | -------------------------------------------------------------------------------- /.github/build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # The basename of our binary 4 | BASE="cpmulator" 5 | 6 | # I don't even .. 7 | go env -w GOFLAGS="-buildvcs=false" 8 | 9 | # Disable fatal error on workers 10 | if [ -d /github/workspace ] ; then 11 | git config --global --add safe.directory /github/workspace 12 | fi 13 | 14 | # 15 | # We build for many platforms. 16 | # 17 | BUILD_PLATFORMS="linux darwin freebsd openbsd netbsd windows" 18 | BUILD_ARCHS="amd64 386" 19 | 20 | # For each platform 21 | for OS in ${BUILD_PLATFORMS[@]}; do 22 | 23 | # For each arch 24 | for ARCH in ${BUILD_ARCHS[@]}; do 25 | 26 | # Setup a suffix for the binary 27 | SUFFIX="${OS}" 28 | 29 | # i386 is better than 386 30 | if [ "$ARCH" = "386" ]; then 31 | SUFFIX="${SUFFIX}-i386" 32 | else 33 | SUFFIX="${SUFFIX}-${ARCH}" 34 | fi 35 | 36 | # Windows binaries should end in .EXE 37 | if [ "$OS" = "windows" ]; then 38 | SUFFIX="${SUFFIX}.exe" 39 | fi 40 | 41 | echo "Building for ${OS} [${ARCH}] -> ${BASE}-${SUFFIX}" 42 | 43 | # Run the build 44 | export GOARCH=${ARCH} 45 | export GOOS=${OS} 46 | export CGO_ENABLED=0 47 | 48 | go build -ldflags "-X github.com/skx/cpmulator/version.version=$(git describe --tags)" -o "${BASE}-${SUFFIX}" 49 | 50 | done 51 | done 52 | -------------------------------------------------------------------------------- /.github/run-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # I don't even .. 4 | go env -w GOFLAGS="-buildvcs=false" 5 | 6 | # Install the tools we use to test our code-quality. 7 | # 8 | # Here we setup the tools to install only if the "CI" environmental variable 9 | # is not empty. This is because locally I have them installed. 10 | # 11 | # NOTE: Github Actions always set CI=true 12 | # 13 | if [ -n "${CI}" ] ; then 14 | go install golang.org/x/lint/golint@latest 15 | go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow@latest 16 | go install honnef.co/go/tools/cmd/staticcheck@latest 17 | fi 18 | 19 | # Run the static-check tool - we ignore errors in goserver/static.go 20 | t=$(mktemp) 21 | staticcheck -checks all ./... > "$t" 22 | if [ -s "$t" ]; then 23 | echo "Found errors via 'staticcheck'" 24 | cat "$t" 25 | rm "$t" 26 | exit 1 27 | fi 28 | rm "$t" 29 | 30 | # At this point failures cause aborts 31 | set -e 32 | 33 | # Run the linter 34 | echo "Launching linter .." 35 | golint -set_exit_status ./... 36 | echo "Completed linter .." 37 | 38 | # Run the shadow-checker 39 | echo "Launching shadowed-variable check .." 40 | go vet -vettool="$(which shadow)" ./... 41 | echo "Completed shadowed-variable check .." 42 | 43 | # Run golang tests 44 | go test ./... 45 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | # This workflow uses actions that are not certified by GitHub. 4 | # They are provided by a third-party and are governed by 5 | # separate terms of service, privacy policy, and support 6 | # documentation. 7 | 8 | on: 9 | push: 10 | branches: [ "master" ] 11 | tags: [ 'v*.*.*' ] 12 | workflow_dispatch: 13 | 14 | env: 15 | # Use docker.io for Docker Hub if empty 16 | REGISTRY: ghcr.io 17 | # github.repository as / 18 | IMAGE_NAME: ${{ github.repository }} 19 | 20 | 21 | jobs: 22 | build: 23 | 24 | runs-on: ubuntu-latest 25 | permissions: 26 | contents: read 27 | packages: write 28 | id-token: write 29 | 30 | steps: 31 | - name: Checkout repository 32 | uses: actions/checkout@v3 33 | with: 34 | fetch-depth: 0 35 | 36 | - name: Get our version 37 | id: get-build-version 38 | run: | 39 | echo "MYVERSION="`git describe --tags`"" >> $GITHUB_ENV 40 | echo "Our version is $(git describe --tags)" 41 | 42 | # Install the cosign tool except on PR 43 | # https://github.com/sigstore/cosign-installer 44 | - name: Install cosign 45 | if: github.event_name != 'pull_request' 46 | uses: sigstore/cosign-installer@v3.7.0 47 | with: 48 | cosign-release: 'v2.4.1' # optional 49 | 50 | # Set up BuildKit Docker container builder to be able to build 51 | # multi-platform images and export cache 52 | # https://github.com/docker/setup-buildx-action 53 | - name: Set up Docker Buildx 54 | uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 55 | 56 | # Login against a Docker registry except on PR 57 | # https://github.com/docker/login-action 58 | - name: Log into registry ${{ env.REGISTRY }} 59 | if: github.event_name != 'pull_request' 60 | uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 61 | with: 62 | registry: ${{ env.REGISTRY }} 63 | username: ${{ github.actor }} 64 | password: ${{ secrets.GITHUB_TOKEN }} 65 | 66 | # Extract metadata (tags, labels) for Docker 67 | # https://github.com/docker/metadata-action 68 | - name: Extract Docker metadata 69 | id: meta 70 | uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 71 | with: 72 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 73 | 74 | # Build and push Docker image with Buildx (don't push on PR) 75 | # https://github.com/docker/build-push-action 76 | - name: Build and push Docker image 77 | id: build-and-push 78 | uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0 79 | with: 80 | context: . 81 | platforms: linux/amd64,linux/arm64 82 | push: ${{ github.event_name != 'pull_request' }} 83 | tags: ${{ steps.meta.outputs.tags }} 84 | labels: ${{ steps.meta.outputs.labels }} 85 | cache-from: type=gha 86 | cache-to: type=gha,mode=max 87 | build-args: | 88 | VERSION=${{ env.MYVERSION }} 89 | 90 | # Sign the resulting Docker image digest except on PRs. 91 | # This will only write to the public Rekor transparency log when the Docker 92 | # repository is public to avoid leaking data. If you would like to publish 93 | # transparency data even for private images, pass --force to cosign below. 94 | # https://github.com/sigstore/cosign 95 | - name: Sign the published Docker image 96 | if: ${{ github.event_name != 'pull_request' }} 97 | env: 98 | # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable 99 | TAGS: ${{ steps.meta.outputs.tags }} 100 | DIGEST: ${{ steps.build-and-push.outputs.digest }} 101 | # This step uses the identity token to provision an ephemeral certificate 102 | # against the sigstore community Fulcio instance. 103 | run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} 104 | -------------------------------------------------------------------------------- /.github/workflows/pull_request.yml: -------------------------------------------------------------------------------- 1 | on: pull_request 2 | name: Pull Request 3 | jobs: 4 | test: 5 | name: Test 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@master 9 | - name: Test 10 | uses: skx/github-action-tester@master 11 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | name: Push Event 6 | jobs: 7 | test: 8 | name: Test 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@master 12 | - name: Test 13 | uses: skx/github-action-tester@master 14 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: release 2 | name: Handle Release 3 | jobs: 4 | upload: 5 | name: Upload 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Checkout the repository 9 | uses: actions/checkout@master 10 | - name: Generate the artifacts 11 | uses: skx/github-action-build@master 12 | - name: Upload the artifacts 13 | uses: skx/github-action-publish-binaries@master 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | with: 17 | args: cpmulator-* 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | cpmulator 2 | cpmulator-* 3 | *.exe 4 | test/*.out 5 | -------------------------------------------------------------------------------- /DEBUGGING.md: -------------------------------------------------------------------------------- 1 | # Brief notes on Debugging 2 | 3 | 4 | 5 | ## Enabling Logging 6 | 7 | The emulator can be configured to log all the syscalls, or bios requests, that it was asked to carry out. 8 | 9 | To enable logging specify the path to a file, with the `-log-path` command-line argument: 10 | 11 | ```sh 12 | cpmulator -log-path debug.log [args] 2>logs.out 13 | ``` 14 | 15 | The logging is produced via the golang `slog` package, and will be written in JSON format for ease of processing. However note that each line is a distinct record and we don't have an array of logs. 16 | 17 | You can convert the flat file to a JSON array object using `jq` like so: 18 | 19 | ```sh 20 | jq --slurp '.' debug.log > debug.json 21 | ``` 22 | 23 | Once you have the logs as a JSON array you can use the `jq` tool to count unique syscalls, or perform further processing like so: 24 | 25 | ```sh 26 | cat debug.json | jq '.[].name' | sort | grep -v null | uniq --count | sort --numeric-sort 27 | ``` 28 | This will give output like this: 29 | 30 | ``` 31 | 1 "F_CLOSE" 32 | 2 "C_READ" 33 | 2 "F_SFIRST" 34 | 2 "P_TERMCPM" 35 | 3 "DRV_ALLRESET" 36 | 4 "DUMP" 37 | 4 "F_OPEN" 38 | 4 "LIHOUSE" 39 | 5 "F_SNEXT" 40 | 8 "DRV_SET" 41 | 14 "C_READSTRING" 42 | 14 "DRV_GET" 43 | 60 "F_USERNUM" 44 | 105 "F_READRAND" 45 | 180 "F_READ" 46 | 293 "F_DMAOFF" 47 | 61728 "C_WRITE" 48 | ``` 49 | 50 | 51 | 52 | ## Notes on Syscalls 53 | 54 | There will be two kinds of syscalls BIOS calls and BDOS calls. 55 | 56 | We deploy a fake BIOS jump-table and a fake BDOS when the emulator launches. By default the BIOS jump-table is located at 0xFE00 and the BDOS is located at 0xF000. 57 | 58 | * The BIOS syscalls jump to some code that stores the syscall number in the A-register 59 | * Then OUT 0xFF, A is executed. 60 | * This sends configures the emulator to carry out the appropriate action. 61 | * The BDOS entrypoint has a tiny piece of code that just runs "OUT (C),C" 62 | * This out instruction is trapped by the emulator and the syscall number can be taken from the C-register. 63 | 64 | 65 | 66 | ## Register Values 67 | 68 | Register values matter for some calls, for example the `C_RAWIO` function is invoked when the `C` register contains `06`, however the behaviour is modified by the value of the `E` register: 69 | 70 | * If `E` is `0xFF` 71 | * Read a character from STDIN, blocking. 72 | * If `E` is `0xFE` 73 | * Test if pending input is available, without blocking. 74 | * Other special cases 75 | * Have different actions. 76 | * Finally if no special handling is setup then output the character in `E` to STDOUT. 77 | 78 | Most other syscalls are more static, but there are examples where a call's behaviour requires seeing the input register such as `F_USERNUM`. 79 | 80 | 81 | 82 | ## Automated Testing 83 | 84 | We have the ability to paste controlled (console) input into the emulator, and this facility is used by the test-script beneath `test/`. 85 | 86 | Run `./test/run-tests.sh` or `make test` to run the tests. Individual tests can be launched via `./test/run-test.sh foo` for example. 87 | 88 | Note that you will need to have the cpm-dist repository cloned above this directory. 89 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 2 | # Trivial Dockerfile for cpmulator, which contains our sample binaries 3 | # 4 | # Build it like so: 5 | # 6 | # docker build -t cpmulator . 7 | # 8 | # Launch it like so to run "all the things": 9 | # 10 | # docker run -t -i cpmulator:latest 11 | # 12 | # Or like this to be more specific: 13 | # 14 | # docker run -t -i cpm:latest -input stty -cd /app/G /app/G/ZORK1.COM 15 | # 16 | # 17 | 18 | # STEP1 - Build-image 19 | ########################################################################### 20 | FROM golang:alpine AS builder 21 | 22 | ARG VERSION 23 | 24 | LABEL org.opencontainers.image.source="https://github.com/skx/cpmulator/" 25 | LABEL org.opencontainers.image.description="CP/M Emulator written in Golang" 26 | 27 | # Create a working-directory 28 | WORKDIR $GOPATH/src/github.com/skx/cpmulator/ 29 | 30 | # Copy the source to it 31 | COPY . . 32 | 33 | # Build the binary - ensuring we pass the build-argument 34 | RUN go build -ldflags "-X main.version=$VERSION" -o /go/bin/cpmulator 35 | 36 | # STEP2 - Deploy-image 37 | ########################################################################### 38 | FROM alpine 39 | 40 | # Copy the binary. 41 | COPY --from=builder /go/bin/cpmulator /usr/local/bin/ 42 | 43 | # Create a group and user 44 | RUN addgroup app && adduser -D -G app -h /apps app 45 | 46 | # Clone the binaries to test with 47 | RUN apk add git && git clone https://github.com/skx/cpm-dist.git /app && chown -R app:app /app 48 | 49 | # Tell docker that all future commands should run as the app user 50 | USER app 51 | 52 | # Set working directory 53 | WORKDIR /app 54 | 55 | # Set entrypoint 56 | ENTRYPOINT [ "/usr/local/bin/cpmulator" ] 57 | 58 | # And ensure we use the subdirectory-support. 59 | CMD [ "-directories" ] 60 | -------------------------------------------------------------------------------- /EXTENSIONS.md: -------------------------------------------------------------------------------- 1 | # BIOS Extensions 2 | 3 | Traditionally there are two _reserved_ BIOS functions, RESERVE1(31) and RESERVE2(32), I've claimed the first as virtual syscalls that the emulator will handle. 4 | 5 | They can be called like so: 6 | 7 | ; Set the function-number to call in HL 8 | ld hl, 0x00 9 | 10 | ; Invoke the BIOS function 11 | ld a, 31 12 | out (0xff), a 13 | 14 | We've implemented a small number of custom BIOS calls, documented below. 15 | 16 | 17 | 18 | ## Function 0x00: CPMUlator? 19 | 20 | Test to see if the code is running under cpmulator, the return value is split into two parts: 21 | 22 | * Registers are set to specific values: 23 | * H -> `S` 24 | * L -> `K` 25 | * A -> `X` 26 | * The DMA buffer is filled with a text-banner, null-terminated. 27 | 28 | 29 | 30 | ## Function 0x01: Get/Set Ctrl-C Count 31 | 32 | * If C == 0xFF return the value of the Ctrl-C count in A. 33 | * IF C != 0xFF set the Ctrl-C count to be C. 34 | 35 | Example: 36 | 37 | ;: get the value 38 | LD HL, 0x01 39 | LD C, 0xFF 40 | LD A, 31 41 | OUT (0xFF), A 42 | ;; Now A has the result 43 | 44 | ;; Set the value to 4 45 | LD HL, 0x01 46 | LD C, 0x04 47 | LD A, 31 48 | OUT (0xFF), A 49 | 50 | Demonstrated in [static/ctrlc.z80](static/ctrlc.z80). 51 | 52 | 53 | 54 | ## Function 0x02: Get/Set Console Output Driver 55 | 56 | On entry DE points to a text-string, terminated by NULL, which represents the name of the 57 | console output driver to use. 58 | 59 | If DE is 0x0000 then the DMA area is filled with the name of the current driver, NULL-terminated. 60 | 61 | Demonstrated in [static/output.z80](static/output.z80) 62 | 63 | See also function 0x07. 64 | 65 | 66 | 67 | ## Function 0x03: Get/Set CCP 68 | 69 | On entry DE points to a text-string, terminated by NULL, which represents the name of the 70 | CCP to use. 71 | 72 | If DE is 0x0000 then the DMA area is filled with the name of the currently active CCP, NULL-terminated. 73 | 74 | Demonstrated in [static/ccp.z80](static/ccp.z80) 75 | 76 | 77 | 78 | ## Function 0x04: NOP 79 | 80 | This is an obsolete function, which does nothing. 81 | 82 | 83 | 84 | ## Function 0x05: Get Terminal Size 85 | 86 | * Returns the height of the terminal in H. 87 | * Returns the width of the terminal in L. 88 | 89 | 90 | 91 | ## Function 0x06: Get/Set Debug flag 92 | 93 | * If C is 0x01 debug-mode is enabled. 94 | * If C is 0x00 debug-mode is disabled. 95 | * If C is 0xFF debug-mode is queried. 96 | * 0x00 means it is not active. 97 | * 0x01 means it is enabled. 98 | 99 | Debug mode shows a summary of syscalls, and their results. It is faster 100 | than using the logfile and it is useful to be able to toggle it at runtime. 101 | 102 | Demonstrated in [static/debug.z80](static/debug.z80) 103 | 104 | 105 | 106 | ## Function 0x07: Get/Set Console Input Driver 107 | 108 | On entry DE points to a text-string, terminated by NULL, which represents the name of the 109 | console input driver to use. 110 | 111 | If DE is 0x0000 then the DMA area is filled with the name of the current driver, NULL-terminated. 112 | 113 | Demonstrated in [static/input.z80](static/input.z80) 114 | 115 | See also function 0x02. 116 | 117 | 118 | 119 | ## Function 0x08: Get/Set Prefix for running commands on the host 120 | 121 | On entry DE points to a text-string, terminated by NULL, which represents the prefix which will 122 | be used to allow executing commands on the host-system. 123 | 124 | For example if you were to run `!hostcmd !!` then enter `!!uptime` within the CCP prompt you'd 125 | actually see the output of running the `uptime` command. 126 | 127 | If DE is 0x0000 then the DMA area is filled with the name of the current prefix, NULL-terminated. 128 | 129 | Demonstrated in [static/hostcmd.z80](static/hostcmd.z80) 130 | 131 | 132 | 133 | ## Function 0x09: Disable the BIOS extensions documented in this page. 134 | 135 | This function is used to disable the embedded filesystem we use to host our utility functions, and 136 | the BIOS extensions documented upon this page. On entry DE is used to determine what to disable: 137 | 138 | * 0x0001 - Disable the embedded filesystem. 139 | * 0x0002 - Disable the custom BIOS functions. 140 | * 0x0003 - Disable both the embedded filesystem, and the custom BIOS functions. 141 | * 0x0004 - Disable both the embedded filesystem, and the custom BIOS functions, but do so quietly. 142 | 143 | Demonstrated in [static/disable.z80](static/disable.z80) 144 | 145 | 146 | ## Function 0x0A: Get/Set Printer Log Path 147 | 148 | On entry DE points to a text-string, terminated by NULL, which represents the name of the 149 | file to write printer-output to. 150 | 151 | If DE is 0x0000 then the DMA area is filled with the name of the printer log-file, NULL-terminated. 152 | 153 | Demonstrated in [static/prnpath.z80](static/prnpath.z80) 154 | 155 | See also function 0x02. 156 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Steve Kemp 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ### 2 | ## Hacky makefile which does too much recompilation. 3 | ## 4 | ## "go build" / "go install" will do the right thing, unless 5 | ## you're changing the CCP or modifying the static binaries we 6 | ## added. 7 | ## 8 | ### 9 | 10 | 11 | ALL: ccp static cpmulator 12 | 13 | 14 | # 15 | # CCP is fast to build. 16 | # 17 | .PHONY: ccp 18 | ccp: $(wildcard ccp/*.ASM) 19 | cd ccp && make 20 | 21 | # 22 | # Static helpers are fast to build. 23 | # 24 | .PHONY: static 25 | static: $(wildcard ccp/*.z80) 26 | cd static && make 27 | 28 | # 29 | # Run the end to end tests 30 | # 31 | .PHONY: test 32 | test: 33 | ./test/run-tests.sh 34 | 35 | # 36 | # Run the golang tests 37 | # 38 | .PHONY: tests 39 | tests: 40 | rm test/*.out 41 | go test ./... 42 | 43 | 44 | # 45 | # cpmulator is fast to build. 46 | # 47 | cpmulator: $(wildcard *.go */*.go) ccp 48 | go build . 49 | -------------------------------------------------------------------------------- /ccp/CCPZ.BIN: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skx/cpmulator/356af0594e8822ca8dc1d5405f1c623e31b2abe2/ccp/CCPZ.BIN -------------------------------------------------------------------------------- /ccp/DR.BIN: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skx/cpmulator/356af0594e8822ca8dc1d5405f1c623e31b2abe2/ccp/DR.BIN -------------------------------------------------------------------------------- /ccp/Makefile: -------------------------------------------------------------------------------- 1 | ALL: DR.BIN CCPZ.BIN 2 | 3 | DR.BIN: DR.ASM 4 | pasmo DR.ASM DR.BIN 5 | 6 | CCPZ.BIN: CCPZ.ASM 7 | pasmo CCPZ.ASM CCPZ.BIN 8 | 9 | clean: 10 | rm *.BIN 11 | -------------------------------------------------------------------------------- /ccp/README.md: -------------------------------------------------------------------------------- 1 | # CCP 2 | 3 | CCP stands for "console command processor" and is basically the "shell". 4 | 5 | This directory contains the two CCP flavours: 6 | 7 | 8 | 9 | ## DR CCP 10 | 11 | * [DR.ASM](DR.ASM) 12 | * The source-code, to be compiled by `pasmo` with the included `Makefile`. 13 | * `DR.BIN` 14 | * The compiled binary, which is embedded in `cpmulator`, via [ccp.go](ccp.go). 15 | 16 | 17 | 18 | ## CCPZ 19 | 20 | * [CCPZ.ASM](CCPZ.ASM) 21 | * The source-code, to be compiled by `pasmo` with the included `Makefile`. 22 | * `CCPZ.BIN` 23 | * The compiled binary, which is embedded in `cpmulator`, via [ccp.go](ccp.go). 24 | -------------------------------------------------------------------------------- /ccp/ccp.go: -------------------------------------------------------------------------------- 1 | // Package ccp contains a pair of embedded CCP binaries, which can 2 | // be used by the emulator as shells. 3 | // 4 | // At build-time we include "*.BIN" from the ccp/ directory, which 5 | // means it's easy to add a new CCP driver - however we must also 6 | // ensure there is a matching name-entry added to the code, so it isn't 7 | // 100% automatic. 8 | package ccp 9 | 10 | import ( 11 | "embed" 12 | "fmt" 13 | "strings" 14 | ) 15 | 16 | // Flavour contains details about a possible CCP the user might run. 17 | type Flavour struct { 18 | // Name contains the public-facing name of the CCP. 19 | // 20 | // NOTE: This name is visible to end-users, and will be used in the "-ccp" command-line flag, 21 | // or as the name when changing at run-time via the "A:!CCP.COM" binary. 22 | Name string 23 | 24 | // Description contains the description of the CCP. 25 | Description string 26 | 27 | // Bytes contains the raw binary content. 28 | Bytes []uint8 29 | 30 | // Start specifies the memory-address, within RAM, to which the raw bytes should be loaded and to which control should be passed. 31 | // 32 | // (i.e. This must match the ORG specified in the CCP source code.) 33 | Start uint16 34 | } 35 | 36 | var ( 37 | // ccps contains the global array of the CCP variants we have. 38 | ccps []Flavour 39 | 40 | //go:embed *.BIN 41 | ccpFiles embed.FS 42 | ) 43 | 44 | // init sets up our global ccp array, by adding the two embedded CCPs to 45 | // the array, with suitable names/offsets. 46 | func init() { 47 | 48 | // Load the CCP from DR 49 | ccp, _ := ccpFiles.ReadFile("DR.BIN") 50 | ccps = append(ccps, Flavour{ 51 | Name: "ccp", 52 | Description: "CP/M v2.2skx", 53 | Start: 0xDE00, 54 | Bytes: ccp, 55 | }) 56 | 57 | // Load the alternative CCP 58 | ccpz, _ := ccpFiles.ReadFile("CCPZ.BIN") 59 | ccps = append(ccps, Flavour{ 60 | Name: "ccpz", 61 | Description: "CCPZ v4.1skx", 62 | Start: 0xDE00, 63 | Bytes: ccpz, 64 | }) 65 | } 66 | 67 | // GetAll returns the details of all known CCPs we have embedded. 68 | func GetAll() []Flavour { 69 | return ccps 70 | } 71 | 72 | // Get returns the CCP version specified, by name, if it exists. 73 | // 74 | // If the given name is invalid then an error will be returned instead. 75 | func Get(name string) (Flavour, error) { 76 | 77 | valid := []string{} 78 | 79 | for _, ent := range ccps { 80 | 81 | // When changing at runtime, via "CCP.COM", we will have had 82 | // the name upper-cased by the CCP so we need to downcase here. 83 | if strings.ToLower(name) == ent.Name { 84 | return ent, nil 85 | } 86 | valid = append(valid, ent.Name) 87 | } 88 | 89 | return Flavour{}, fmt.Errorf("CCP %s not found - valid choices are: %s", name, strings.Join(valid, ",")) 90 | } 91 | -------------------------------------------------------------------------------- /ccp/ccp_test.go: -------------------------------------------------------------------------------- 1 | package ccp 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | // TestCCPTrivial is a trivial test that we have contents 9 | func TestCCPTrivial(t *testing.T) { 10 | 11 | // Test that we have two CCPs 12 | if len(ccps) != 2 { 13 | t.Fatalf("we should have two CCPs") 14 | } 15 | 16 | // Get each one 17 | for _, n := range ccps { 18 | 19 | // Get the size 20 | bytes := n.Bytes 21 | 22 | // The CCPs are small, but bigger than 1k and smaller than 8k 23 | if len(bytes) < 1024 { 24 | t.Fatalf("CCP %s is too small got %d bytes", n.Name, len(bytes)) 25 | } 26 | 27 | if len(bytes) > 8192 { 28 | t.Fatalf("CCP %s is too large", n.Name) 29 | } 30 | } 31 | 32 | } 33 | 34 | // TestInvalidCCP tests that a CCP with a bogus name isn't found, and 35 | // that the error contains the known values which exist. 36 | func TestInvalidCCP(t *testing.T) { 37 | 38 | _, err := Get("foo") 39 | if err == nil { 40 | t.Fatalf("expected failure to load CCP, but got it") 41 | } 42 | 43 | if !strings.Contains(err.Error(), "ccp") { 44 | t.Fatalf("error message didn't include valid ccp: ccp") 45 | } 46 | if !strings.Contains(err.Error(), "ccpz") { 47 | t.Fatalf("error message didn't include valid ccp: ccpz") 48 | } 49 | 50 | } 51 | 52 | func TestRetrieveAll(t *testing.T) { 53 | all := GetAll() 54 | 55 | for _, item := range all { 56 | obj, err := Get(item.Name) 57 | if err != nil { 58 | t.Fatalf("failure to get by name %v:%s", item, err) 59 | } 60 | if item.Name != obj.Name { 61 | t.Fatalf("bogus result") 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /consolein/FUZZING.md: -------------------------------------------------------------------------------- 1 | # Fuzz Testing 2 | 3 | Fuzz-testing involves creating random input, and running the program with it, to see what happens. 4 | 5 | The expectation is that most of the random inputs will be invalid, so you'll be able to test your error-handling and see where you failed to consider things appropriately. 6 | 7 | 8 | 9 | ## Usage 10 | 11 | Within this directory just run: 12 | 13 | ``` 14 | $ go test -fuzztime=300s -parallel=1 -fuzz=FuzzOptionsParser -v 15 | ``` 16 | 17 | You'll see output like so, reporting on progress: 18 | 19 | ``` 20 | .. 21 | === RUN FuzzOptionsParser 22 | fuzz: elapsed: 0s, gathering baseline coverage: 0/106 completed 23 | fuzz: elapsed: 2s, gathering baseline coverage: 106/106 completed, now fuzzing with 1 workers 24 | fuzz: elapsed: 3s, execs: 145 (48/sec), new interesting: 0 (total: 106) 25 | fuzz: elapsed: 6s, execs: 359 (71/sec), new interesting: 0 (total: 106) 26 | fuzz: elapsed: 9s, execs: 359 (0/sec), new interesting: 0 (total: 106) 27 | fuzz: elapsed: 12s, execs: 359 (0/sec), new interesting: 0 (total: 106) 28 | fuzz: elapsed: 15s, execs: 359 (0/sec), new interesting: 0 (total: 106) 29 | ``` 30 | 31 | If you have a beefier host you can drop the `-parallel=1` flag, to run more jobs in parallel, and if you wish you can drop the `-fuzztime=300s` flag to continue running fuzz-testing indefinitely (or until an issue has been found). 32 | -------------------------------------------------------------------------------- /consolein/consolein.go: -------------------------------------------------------------------------------- 1 | // Package consolein is an abstraction over console input. 2 | // 3 | // We support two methods of getting input, whilst selectively 4 | // disabling/enabling echo - the use of `termbox' and the use of 5 | // the `stty` binary. 6 | package consolein 7 | 8 | import ( 9 | "bytes" 10 | "fmt" 11 | "log/slog" 12 | "os" 13 | "os/exec" 14 | "strings" 15 | "unicode" 16 | ) 17 | 18 | // ErrInterrupted is returned if the user presses Ctrl-C when in our ReadLine function. 19 | var ErrInterrupted error = fmt.Errorf("INTERRUPTED") 20 | 21 | // ErrShowOutput is a special sentinel value that is used to signal that the ReadLine 22 | // function needs to write some output to the console. As that would be a layering 23 | // violation it is handled indirectly. 24 | var ErrShowOutput error = fmt.Errorf("SHOW_OUTPUT") 25 | 26 | // ConsoleInput is the interface that must be implemented by anything 27 | // that wishes to be used as an input driver. 28 | // 29 | // Providing this interface is implemented an object may register itself, 30 | // by name, via the Register method. 31 | // 32 | // You can compare this interface to the corresponding ConsoleOutput one, 33 | // that delegates everything to the drivers rather than having some wrapper 34 | // methods building upon the drivers as we do here. 35 | type ConsoleInput interface { 36 | 37 | // Setup performs any specific setup which is required. 38 | Setup() error 39 | 40 | // TearDown performs any specific cleanup which is required. 41 | TearDown() error 42 | 43 | // PendingInput returns true if there is pending input available to be read. 44 | PendingInput() bool 45 | 46 | // BlockForCharacterNoEcho reads a single character from the console, without 47 | // echoing it. 48 | BlockForCharacterNoEcho() (byte, error) 49 | 50 | // GetName will return the name of the driver. 51 | GetName() string 52 | } 53 | 54 | // This is a map of known-drivers 55 | var handlers = struct { 56 | m map[string]Constructor 57 | }{m: make(map[string]Constructor)} 58 | 59 | // interruptCount is the count of consecutive Ctrl-Cs which will trigger a "reboot". 60 | var interruptCount int = 2 61 | 62 | // stuffed holds pending input 63 | var stuffed string = "" 64 | 65 | // history holds previous (line) input. 66 | var history []string 67 | 68 | // Constructor is the signature of a constructor-function 69 | // which is used to instantiate an instance of a driver. 70 | type Constructor func() ConsoleInput 71 | 72 | // Register makes a console driver available, by name. 73 | // 74 | // When one needs to be created the constructor can be called 75 | // to create an instance of it. 76 | func Register(name string, obj Constructor) { 77 | // Downcase for consistency. 78 | name = strings.ToLower(name) 79 | 80 | handlers.m[name] = obj 81 | } 82 | 83 | // ConsoleIn holds our state, which is basically just a 84 | // pointer to the object handling our input 85 | type ConsoleIn struct { 86 | 87 | // driver is the thing that actually reads our output. 88 | driver ConsoleInput 89 | 90 | // options store per-driver options which might be passed in the 91 | // constructor. Right now these are undocumented 92 | options string 93 | 94 | // systemPrefix is the prefix to use to trigger the execution 95 | // of system commands, on the host, in the ReadLine function 96 | systemPrefix string 97 | } 98 | 99 | // New is our constructor, it creates an input device which uses 100 | // the specified driver. 101 | func New(name string) (*ConsoleIn, error) { 102 | 103 | // Do we have trailing options? 104 | options := "" 105 | 106 | // If we do save them 107 | val := strings.Split(name, ":") 108 | if len(val) == 2 { 109 | name = val[0] 110 | options = val[1] 111 | } 112 | 113 | // Downcase for consistency. 114 | name = strings.ToLower(name) 115 | 116 | // Do we have a constructor with the given name? 117 | ctor, ok := handlers.m[name] 118 | if !ok { 119 | return nil, fmt.Errorf("failed to lookup driver by name '%s'", name) 120 | } 121 | 122 | // OK we do, return ourselves with that driver. 123 | return &ConsoleIn{ 124 | driver: ctor(), 125 | options: strings.ToUpper(options), 126 | }, nil 127 | } 128 | 129 | // SetSystemCommandPrefix enables the use of system-commands in our readline 130 | // function. 131 | func (co *ConsoleIn) SetSystemCommandPrefix(str string) { 132 | co.systemPrefix = str 133 | } 134 | 135 | // GetSystemCommandPrefix returns the value of the system-command prefix. 136 | func (co *ConsoleIn) GetSystemCommandPrefix() string { 137 | return co.systemPrefix 138 | } 139 | 140 | // GetDriver allows getting our driver at runtime. 141 | func (co *ConsoleIn) GetDriver() ConsoleInput { 142 | return co.driver 143 | } 144 | 145 | // GetName returns the name of our selected driver. 146 | func (co *ConsoleIn) GetName() string { 147 | return co.driver.GetName() 148 | } 149 | 150 | // GetDrivers returns all available driver-names. 151 | // 152 | // We hide the internal "file" driver. 153 | func (co *ConsoleIn) GetDrivers() []string { 154 | valid := []string{} 155 | 156 | for x := range handlers.m { 157 | if x != "file" && x != "error" { 158 | valid = append(valid, x) 159 | } 160 | } 161 | return valid 162 | } 163 | 164 | // Setup proxies into our registered console-input driver. 165 | func (co *ConsoleIn) Setup() error { 166 | return co.driver.Setup() 167 | } 168 | 169 | // TearDown proxies into our registered console-input driver. 170 | func (co *ConsoleIn) TearDown() error { 171 | return co.driver.TearDown() 172 | } 173 | 174 | // StuffInput proxies into our registered console-input driver. 175 | func (co *ConsoleIn) StuffInput(input string) { 176 | stuffed = input 177 | } 178 | 179 | // SetInterruptCount sets the number of consecutive Ctrl-C characters 180 | // are required to trigger a reboot. 181 | // 182 | // This function DOES NOT proxy to our registered console-input driver. 183 | func (co *ConsoleIn) SetInterruptCount(val int) { 184 | interruptCount = val 185 | } 186 | 187 | // GetInterruptCount retrieves the number of consecutive Ctrl-C characters are required to trigger a reboot. 188 | // 189 | // This function DOES NOT proxy to our registered console-input driver. 190 | func (co *ConsoleIn) GetInterruptCount() int { 191 | return interruptCount 192 | } 193 | 194 | // PendingInput proxies into our registered console-input driver. 195 | func (co *ConsoleIn) PendingInput() bool { 196 | 197 | // if there is stuffed input we have something ready to read 198 | if len(stuffed) > 0 { 199 | return true 200 | } 201 | 202 | return co.driver.PendingInput() 203 | } 204 | 205 | // BlockForCharacterNoEcho proxies into our registered console-input driver. 206 | func (co *ConsoleIn) BlockForCharacterNoEcho() (byte, error) { 207 | 208 | // Do we have faked/stuffed input to process? 209 | if len(stuffed) > 0 { 210 | c := stuffed[0] 211 | stuffed = stuffed[1:] 212 | return c, nil 213 | } 214 | 215 | return co.driver.BlockForCharacterNoEcho() 216 | } 217 | 218 | // BlockForCharacterWithEcho blocks for input and shows that input before it 219 | // is returned. 220 | // 221 | // This function DOES NOT proxy to our registered console-input driver. 222 | func (co *ConsoleIn) BlockForCharacterWithEcho() (byte, error) { 223 | 224 | // Do we have faked/stuffed input to process? 225 | if len(stuffed) > 0 { 226 | c := stuffed[0] 227 | stuffed = stuffed[1:] 228 | fmt.Printf("%c", c) 229 | return c, nil 230 | } 231 | 232 | c, err := co.driver.BlockForCharacterNoEcho() 233 | fmt.Printf("%c", c) 234 | return c, err 235 | } 236 | 237 | // ReadLine handles the input of a single line of text. 238 | // 239 | // This function DOES NOT proxy to our registered console-input driver. 240 | func (co *ConsoleIn) ReadLine(max uint8) (string, error) { 241 | // Text the user entered 242 | text := "" 243 | 244 | // count of consecutive Ctrl-C 245 | ctrlCount := 0 246 | 247 | // offset from history 248 | offset := 0 249 | 250 | // Erase the text the user has entered, both on the screen 251 | // and in the input buffer. 252 | eraseInput := func() { 253 | for len(text) > 0 { 254 | text = text[:len(text)-1] 255 | fmt.Printf("\b \b") 256 | } 257 | } 258 | 259 | // We're expecting the user to enter a line of text, 260 | // but we process their input in terms of characters. 261 | // 262 | // We do that so that we can react to special characters 263 | // such as Esc, Ctrl-N, Ctrl-C, etc. 264 | // 265 | // We don't implement Readline, or anything too advanced, 266 | // but we make a decent effort regardless. 267 | for { 268 | 269 | // Get a character, with no echo. 270 | x, err := co.BlockForCharacterNoEcho() 271 | if err != nil { 272 | return "", err 273 | } 274 | 275 | // Esc? or Ctrl-X 276 | if x == 27 || x == 24 { 277 | 278 | eraseInput() 279 | 280 | continue 281 | } 282 | 283 | // Ctrl-N? 284 | if x == 14 { 285 | if offset >= 1 { 286 | 287 | offset-- 288 | 289 | eraseInput() 290 | 291 | if len(history)-offset < len(history) { 292 | // replace with a suitable value, and show it 293 | text = history[len(history)-offset] 294 | fmt.Printf("%s", text) 295 | } 296 | } 297 | continue 298 | } 299 | 300 | // Ctrl-P? 301 | if x == 16 { 302 | if offset >= len(history) { 303 | continue 304 | } 305 | offset++ 306 | 307 | eraseInput() 308 | 309 | // replace with a suitable value, and show it 310 | text = history[len(history)-offset] 311 | fmt.Printf("%s", text) 312 | 313 | continue 314 | } 315 | 316 | // Ctrl-C ? 317 | if x == 0x03 { 318 | 319 | // Ctrl-C should only take effect at the start of the line. 320 | // i.e. When the text is empty. 321 | if text == "" { 322 | ctrlCount++ 323 | 324 | // If we've hit our limit of consecutive Ctrl-Cs 325 | // then we return the interrupted error-code 326 | if ctrlCount == interruptCount { 327 | return "", ErrInterrupted 328 | } 329 | } 330 | continue 331 | } 332 | 333 | // Not a ctrl-c so reset our count 334 | ctrlCount = 0 335 | 336 | // Newline? 337 | if x == '\n' || x == '\r' { 338 | 339 | if text != "" { 340 | // If we have no history, save it. 341 | if len(history) == 0 { 342 | history = append(history, text) 343 | } else { 344 | // otherwise only add if different to previous entry. 345 | if text != history[len(history)-1] { 346 | history = append(history, text) 347 | } 348 | } 349 | } 350 | 351 | // Add the newline and return 352 | text += "\n" 353 | break 354 | } 355 | 356 | // Backspace / Delete? Remove a single character. 357 | if x == '\b' || x == 127 { 358 | 359 | // remove the character from our text, and overwrite on the console 360 | if len(text) > 0 { 361 | text = text[:len(text)-1] 362 | fmt.Printf("\b \b") 363 | } 364 | continue 365 | } 366 | 367 | // If the user has entered the maximum then we'll say their 368 | // input-time is over now. 369 | if len(text) >= int(max) { 370 | break 371 | } 372 | 373 | // Finally if it was a printable character we'll keep it. 374 | if unicode.IsPrint(rune(x)) { 375 | fmt.Printf("%c", x) 376 | text += string(x) 377 | } 378 | } 379 | 380 | // remove any trailing newline 381 | text = strings.TrimSuffix(text, "\n") 382 | 383 | // Too much entered? Truncate the text. 384 | if len(text) > int(max) { 385 | text = text[:max] 386 | } 387 | 388 | // 389 | // Execution of commands, if enabled 390 | // 391 | if co.systemPrefix != "" && strings.HasPrefix(text, co.systemPrefix) { 392 | 393 | // Strip the prefix, and any spaces. 394 | text = text[len(co.systemPrefix):] 395 | text = strings.TrimSpace(text) 396 | 397 | // cd is a special command. 398 | if strings.HasPrefix(text, "cd ") { 399 | 400 | // strip off the "cd " prefix 401 | dir := strings.TrimSpace(text[3:]) 402 | 403 | // try to change directory 404 | err := os.Chdir(dir) 405 | if err != nil { 406 | return fmt.Sprintf("\r\nError changing to directory '%s': %s\r\n", dir, err), ErrShowOutput 407 | } 408 | 409 | // No error, just recurse 410 | return co.ReadLine(max) 411 | } 412 | 413 | // Wrap the command so it is executed via a shell 414 | bits := []string{"bash", "-c", text} 415 | 416 | // Prepare to run the command, capturing STDOUT & STDERR 417 | cmd := exec.Command(bits[0], bits[1:]...) 418 | var execOut bytes.Buffer 419 | var execErr bytes.Buffer 420 | cmd.Stdout = &execOut 421 | cmd.Stderr = &execErr 422 | 423 | // Actually run the command 424 | err := cmd.Run() 425 | if err != nil { 426 | return fmt.Sprintf("\r\nerror running command '%s' %s%s\r\n", text, err.Error(), execErr.Bytes()), ErrShowOutput 427 | } 428 | 429 | // Log the command 430 | slog.Debug("ReadLine executed a command", slog.String("cmd", text)) 431 | 432 | // No error. 433 | out := execOut.String() 434 | out += execErr.String() 435 | out = strings.ReplaceAll(out, "\n", "\n\r") 436 | 437 | if len(out) > 0 { 438 | return fmt.Sprintf("\r\n%s\r\n", out), ErrShowOutput 439 | } 440 | 441 | // No output just recurse 442 | return co.ReadLine(max) 443 | } 444 | 445 | // Return the text 446 | return text, nil 447 | } 448 | -------------------------------------------------------------------------------- /consolein/drv_error.go: -------------------------------------------------------------------------------- 1 | // drv_error is a console input-driver which only returns errors. 2 | // 3 | // This driver is only used for testing purposes. 4 | 5 | package consolein 6 | 7 | import "fmt" 8 | 9 | var ( 10 | // ErrorInputName contains the name of this driver. 11 | ErrorInputName = "error" 12 | ) 13 | 14 | // ErrorInput is an input-driver that only returns errors, and 15 | // is used for testing. 16 | type ErrorInput struct { 17 | } 18 | 19 | // Setup is a NOP. 20 | func (ei *ErrorInput) Setup() error { 21 | return nil 22 | } 23 | 24 | // TearDown is a NOP. 25 | func (ei *ErrorInput) TearDown() error { 26 | return nil 27 | } 28 | 29 | // PendingInput always pretends input is pending. 30 | // 31 | // However when input is polled for, via BlockForCharacterNoEcho, 32 | // an error will always be returned. 33 | func (ei *ErrorInput) PendingInput() bool { 34 | return true 35 | } 36 | 37 | // GetName returns the name of this driver, "error". 38 | func (ei *ErrorInput) GetName() string { 39 | return ErrorInputName 40 | } 41 | 42 | // BlockForCharacterNoEcho always returns an error when 43 | // invoked to read pending input. 44 | func (ei *ErrorInput) BlockForCharacterNoEcho() (byte, error) { 45 | return 0x00, fmt.Errorf("DRV_ERROR") 46 | } 47 | 48 | // init registers our driver, by name. 49 | func init() { 50 | Register(ErrorInputName, func() ConsoleInput { 51 | return new(ErrorInput) 52 | }) 53 | } 54 | -------------------------------------------------------------------------------- /consolein/drv_file.go: -------------------------------------------------------------------------------- 1 | // drv_file creates a console input-driver which reads and 2 | // returns fake input from a file. It is used for end-to-end 3 | // or functional-testing of our emulator. 4 | 5 | package consolein 6 | 7 | import ( 8 | "fmt" 9 | "io" 10 | "os" 11 | "strings" 12 | "time" 13 | ) 14 | 15 | var ( 16 | // FileInputName contains the name of this driver 17 | FileInputName = "file" 18 | ) 19 | 20 | // FileInput is an input-driver that returns fake console input 21 | // by reading and returning the contents of a file ("input.txt" 22 | // by default, but this may be changed). 23 | // 24 | // The input-driver is primarily designed for testing and automation. 25 | // We make a tiny pause between our functions and for every input 26 | // we allow some conditional support for changing the line-endings 27 | // which are used when replaying the various test-cases. 28 | type FileInput struct { 29 | 30 | // content contains the content of the file we're returning 31 | // input from. 32 | content []byte 33 | 34 | // offset shows the offset into the buffer we're at. 35 | offset int 36 | 37 | // a test-case can set an arbitrary number of options and here 38 | // is where we record them. 39 | options map[string]string 40 | 41 | // fakeInput is input we should return in the future. 42 | // 43 | // This is used to return fake Ctrl-M characters when 44 | // newlines are hit, if required. It is general-purpose 45 | // though so we could fake/modify other input options. 46 | fakeInput string 47 | 48 | // delayUntil is used to see if we're in the middle of a delay, 49 | // where we pretend we have no input. 50 | delayUntil time.Time 51 | 52 | // delaySmall is the time we delay before polling input or characters 53 | delaySmall time.Duration 54 | 55 | // delayLarge is the time we delay when we see '#' in the input file 56 | delayLarge time.Duration 57 | } 58 | 59 | // Setup reads the contents of the file specified by the 60 | // environmental variable $INPUT_FILE, and saves it away as 61 | // a source of fake console input. 62 | // 63 | // If no filename is chosen "input.txt" will be used as a default. 64 | func (fi *FileInput) Setup() error { 65 | 66 | // We allow the input file to be overridden from the 67 | // default via the environmental-variable. 68 | fileName := os.Getenv("INPUT_FILE") 69 | if fileName == "" { 70 | fileName = "input.txt" 71 | } 72 | 73 | // Read the content. 74 | dat, err := os.ReadFile(fileName) 75 | if err != nil { 76 | return err 77 | } 78 | 79 | // Create a map for storing per-test options 80 | fi.options = make(map[string]string) 81 | 82 | // The data might be updated to strip off the header. 83 | fi.offset = 0 84 | fi.content = fi.parseOptions(dat) 85 | 86 | // We're not delaying by default. 87 | fi.delayUntil = time.Now() 88 | 89 | // Setup the default delay times 90 | fi.delaySmall = time.Millisecond * 15 91 | fi.delayLarge = time.Second * 5 92 | 93 | return nil 94 | } 95 | 96 | // parseOptions strips out any options from the given data, recording them 97 | // internally and returning the data after that. 98 | func (fi *FileInput) parseOptions(data []byte) []byte { 99 | 100 | // Ensure that we have a map to store options. 101 | if fi.options == nil { 102 | fi.options = make(map[string]string) 103 | } 104 | 105 | // Length and current offset 106 | l := len(data) 107 | i := 0 108 | position := -1 109 | 110 | // Do we find "--\n" in the data? If not then there are no options 111 | for i < l { 112 | if data[i] == '-' && 113 | (i+1) < l && 114 | data[i+1] == '-' && 115 | (i+2) < l && 116 | data[i+2] == '\n' && 117 | position < 0 { 118 | position = i 119 | } 120 | 121 | i++ 122 | } 123 | 124 | // We didn't find "--" so we can just return the data as-is 125 | // because there are no options. 126 | if position < 0 { 127 | return data 128 | } 129 | 130 | // Header is 0 - position. 131 | // Text is position + 3 (the length of "--\n"). 132 | header := data[0:position] 133 | data = data[position+3:] 134 | 135 | // Split the header by newlines and process 136 | h := string(header) 137 | for _, line := range strings.Split(h, "\n") { 138 | 139 | // Trim any leading/trailing whitespace. 140 | line = strings.TrimSpace(line) 141 | 142 | // lines in the header prefixed by "#" are comments 143 | if strings.HasPrefix(line, "#") { 144 | continue 145 | } 146 | 147 | // otherwise the header is key:val pairs 148 | d := strings.Split(line, ":") 149 | if len(d) == 2 { 150 | key := d[0] 151 | val := d[1] 152 | 153 | // Trim leading/trailing space and down-case. 154 | key = strings.ToLower(strings.TrimSpace(key)) 155 | val = strings.ToLower(strings.TrimSpace(val)) 156 | 157 | // save away 158 | fi.options[key] = val 159 | } 160 | } 161 | 162 | return data 163 | } 164 | 165 | // TearDown is a NOP. 166 | func (fi *FileInput) TearDown() error { 167 | return nil 168 | } 169 | 170 | // PendingInput returns true if there is pending input which we 171 | // can return. This is always true unless we've exhausted the contents 172 | // of our input-file. 173 | func (fi *FileInput) PendingInput() bool { 174 | 175 | time.Sleep(fi.delaySmall) 176 | 177 | // If we're not in a delay period return the real result 178 | if time.Now().After(fi.delayUntil) { 179 | return (fi.offset < len(fi.content)) 180 | } 181 | 182 | // We're in a delay period, so just pretend nothing is happening. 183 | return false 184 | } 185 | 186 | // BlockForCharacterNoEcho returns the next character from the file we 187 | // use to fake our input. 188 | func (fi *FileInput) BlockForCharacterNoEcho() (byte, error) { 189 | 190 | time.Sleep(fi.delaySmall) 191 | 192 | // Ensure we block, of we're supposed to. 193 | for !time.Now().After(fi.delayUntil) { 194 | time.Sleep(fi.delaySmall) 195 | } 196 | 197 | // If we have to deal with \r\n instead of just \n handle that first. 198 | if len(fi.fakeInput) > 0 { 199 | c := fi.fakeInput[0] 200 | fi.fakeInput = fi.fakeInput[1:] 201 | return c, nil 202 | } 203 | 204 | // If we have input available 205 | if fi.offset < len(fi.content) { 206 | 207 | // Get the next character, and move past it. 208 | x := fi.content[fi.offset] 209 | fi.offset++ 210 | 211 | // We've found a newline in our input. 212 | // 213 | // We allow newlines to be handled a couple of different 214 | // ways, and optionally trigger a delay, so we'll have to 215 | // handle that here. 216 | // 217 | if x == '\n' { 218 | 219 | // Does we pause on newlines? 220 | pause, pausePresent := fi.options["pause-on-newline"] 221 | if pausePresent && pause == "true" { 222 | fi.delayUntil = time.Now().Add(fi.delayLarge) 223 | } 224 | 225 | // Does newline handling have special config? 226 | opt, ok := fi.options["newline"] 227 | 228 | // Nope. Return the newline 229 | if !ok { 230 | return x, nil 231 | } 232 | 233 | // Look at way we have been configured to return newlines. 234 | switch opt { 235 | case "n": 236 | // newline: n -> just return "\n" 237 | return x, nil 238 | case "m": 239 | // newline: m -> just return "\r" 240 | return '\r', nil 241 | case "both": 242 | // newline: both -> first return "\r" then "\n" 243 | fi.fakeInput = "\n" + fi.fakeInput 244 | return '\r', nil 245 | default: 246 | return x, fmt.Errorf("unknown setting 'newline:%s' in test-case", opt) 247 | } 248 | } 249 | 250 | return x, nil 251 | } 252 | 253 | // Input is over. 254 | return 0x00, io.EOF 255 | } 256 | 257 | // GetName is part of the module API, and returns the name of this driver. 258 | func (fi *FileInput) GetName() string { 259 | return FileInputName 260 | } 261 | 262 | // init registers our driver, by name. 263 | func init() { 264 | Register(FileInputName, func() ConsoleInput { 265 | return new(FileInput) 266 | }) 267 | } 268 | -------------------------------------------------------------------------------- /consolein/drv_file_fuzz_test.go: -------------------------------------------------------------------------------- 1 | package consolein 2 | 3 | import ( 4 | "io" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | // FuzzOptionsParser does some simple fuzz-testing of our options-parser. 10 | func FuzzOptionsParser(f *testing.F) { 11 | 12 | // empty + whitespace 13 | f.Add([]byte(nil)) 14 | f.Add([]byte("")) 15 | f.Add([]byte(`\n\r\t`)) 16 | 17 | // One option 18 | f.Add([]byte("foo:bar\n--\nbar")) 19 | 20 | // Two options 21 | f.Add([]byte("foo:bar\nbar:baz'\n--\nbar")) 22 | 23 | // misc 24 | f.Add([]byte("foo:bar\nbar:baz--\nbar")) 25 | f.Add([]byte("foo:bar\nbar:\n--\nbaz--\nbar")) 26 | f.Add([]byte("foo\n--\nbaz--\nbarfoo\n--\nbaz--\nbarfoo\n--\nbaz--\nbarfoo\n--\nbaz--\nbar")) 27 | f.Add([]byte("foo\n--\n#")) 28 | f.Add([]byte("foo\n--\n##")) 29 | f.Add([]byte("foo\n--\n###")) 30 | f.Add([]byte("#")) 31 | f.Add([]byte("##")) 32 | f.Add([]byte("####")) 33 | 34 | f.Fuzz(func(t *testing.T, input []byte) { 35 | 36 | // Create a new object using the (fuzzed) input 37 | tmp := new(FileInput) 38 | 39 | // We don't want to deal with long-sleeps 40 | tmp.delaySmall = 1 * time.Millisecond 41 | tmp.delayLarge = 1 * time.Millisecond 42 | 43 | tmp.parseOptions(input) 44 | 45 | // Make sure we can get a character, or EOF. 46 | // 47 | // any other error/failure is noteworthy. 48 | _, err := tmp.BlockForCharacterNoEcho() 49 | if err != nil && err != io.EOF { 50 | t.Fatalf("failed to read character %v:%v", input, err) 51 | } 52 | }) 53 | } 54 | -------------------------------------------------------------------------------- /consolein/drv_file_test.go: -------------------------------------------------------------------------------- 1 | package consolein 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestFileSetup(t *testing.T) { 11 | 12 | // Create a temporary file 13 | file, err := os.CreateTemp("", "in0.txt") 14 | if err != nil { 15 | t.Fatalf("failed to create temporary file") 16 | } 17 | defer os.Remove(file.Name()) 18 | 19 | _, err = file.Write([]byte("newline: both\n--\nhi\n")) 20 | if err != nil { 21 | t.Fatalf("failed to write to temporary file") 22 | } 23 | 24 | t.Setenv("INPUT_FILE", file.Name()) 25 | 26 | // Create a helper 27 | x := FileInput{} 28 | 29 | ch := ConsoleIn{} 30 | ch.driver = &x 31 | 32 | sErr := ch.Setup() 33 | if sErr != nil { 34 | t.Fatalf("failed to setup driver %s", sErr.Error()) 35 | } 36 | 37 | if !ch.PendingInput() { 38 | t.Fatalf("expected pending input (a)") 39 | } 40 | 41 | c := 0 42 | str := "" 43 | 44 | x.delayUntil = time.Now().Add(1 * time.Second) 45 | 46 | for c < 3 { 47 | var out byte 48 | out, err = ch.BlockForCharacterNoEcho() 49 | if err != nil { 50 | t.Fatalf("failed to get character") 51 | } 52 | str += string(out) 53 | 54 | c++ 55 | } 56 | if str != "hi " { 57 | t.Fatalf("error in string, got '%v' '%s'", str, str) 58 | } 59 | 60 | if ch.PendingInput() { 61 | t.Fatalf("expected no pending input (we read the text)") 62 | } 63 | 64 | // After we've read all the characters we should just get EOF 65 | _, _ = ch.BlockForCharacterNoEcho() 66 | c = 0 67 | for c < 10 { 68 | _, err = ch.BlockForCharacterNoEcho() 69 | if err != io.EOF { 70 | t.Fatalf("expected EOF, got %v", err) 71 | } 72 | c++ 73 | } 74 | 75 | sErr = ch.TearDown() 76 | if sErr != nil { 77 | t.Fatalf("failed to teardown") 78 | } 79 | } 80 | 81 | func TestSetupFail(t *testing.T) { 82 | 83 | // Create a helper 84 | x := FileInput{} 85 | 86 | ch := ConsoleIn{} 87 | ch.driver = &x 88 | 89 | // input.txt doesn't exist so we get an error.. 90 | sErr := ch.Setup() 91 | if sErr == nil { 92 | t.Fatalf("expected error, got none") 93 | } 94 | 95 | sErr = ch.TearDown() 96 | if sErr != nil { 97 | t.Fatalf("failed to teardown") 98 | } 99 | 100 | } 101 | 102 | // TestNoOptions tests some small data with no options 103 | func TestNoOptions(t *testing.T) { 104 | 105 | f := new(FileInput) 106 | 107 | // 0 108 | out := f.parseOptions([]byte{}) 109 | if len(out) != 0 { 110 | t.Fatalf("error got the wrong data: %v", out) 111 | } 112 | if len(f.options) != 0 { 113 | t.Fatalf("unexpected options present") 114 | } 115 | 116 | // 1 117 | out = f.parseOptions([]byte{0x01}) 118 | if len(out) != 1 { 119 | t.Fatalf("error got the wrong data: %v", out) 120 | } 121 | if len(f.options) != 0 { 122 | t.Fatalf("unexpected options present") 123 | } 124 | 125 | // 2 126 | out = f.parseOptions([]byte{0x01, 0x02}) 127 | if len(out) != 2 { 128 | t.Fatalf("error got the wrong data: %v", out) 129 | } 130 | if len(f.options) != 0 { 131 | t.Fatalf("unexpected options present") 132 | } 133 | 134 | } 135 | 136 | // TestOptions tests some small options 137 | func TestOptions(t *testing.T) { 138 | 139 | f := new(FileInput) 140 | 141 | // One option. 142 | out := f.parseOptions([]byte("Foo: bar\n--\none")) 143 | if len(out) != 3 { 144 | t.Fatalf("error got the wrong data: %s", out) 145 | } 146 | if len(f.options) != 1 { 147 | t.Fatalf("unexpected options present") 148 | } 149 | if f.options["foo"] != "bar" { 150 | t.Fatalf("bogus options %v", f.options) 151 | } 152 | 153 | // One comment 154 | f = new(FileInput) 155 | out = f.parseOptions([]byte("# Foo: bar\n--\none")) 156 | if len(out) != 3 { 157 | t.Fatalf("error got the wrong data: %v", out) 158 | } 159 | if len(f.options) != 0 { 160 | t.Fatalf("unexpected options present") 161 | } 162 | 163 | // Comment and option 164 | f = new(FileInput) 165 | out = f.parseOptions([]byte("# Test\nFoo: bar\nsteve:kemp \n--\none")) 166 | if len(out) != 3 { 167 | t.Fatalf("error got the wrong data: %v", out) 168 | } 169 | if len(f.options) != 2 { 170 | t.Fatalf("unexpected options present") 171 | } 172 | if f.options["foo"] != "bar" { 173 | t.Fatalf("bogus options %v", f.options) 174 | } 175 | if f.options["steve"] != "kemp" { 176 | t.Fatalf("bogus options %v", f.options) 177 | } 178 | 179 | // Most recent option takes precedence 180 | f = new(FileInput) 181 | out = f.parseOptions([]byte("# Test\nFoo: bar\nsteve:kemp \nFoo: steve--\none")) 182 | if len(out) != 3 { 183 | t.Fatalf("error got the wrong data: %v", out) 184 | } 185 | if len(f.options) != 2 { 186 | t.Fatalf("unexpected options present") 187 | } 188 | if f.options["foo"] != "steve" { 189 | t.Fatalf("bogus options %v", f.options) 190 | } 191 | if f.options["steve"] != "kemp" { 192 | t.Fatalf("bogus options %v", f.options) 193 | } 194 | 195 | } 196 | 197 | // TestNewlineN ensures "newline: n" returns the expected character. (\n) 198 | func TestNewlineN(t *testing.T) { 199 | 200 | // Create a temporary file 201 | file, err := os.CreateTemp("", "in2.txt") 202 | if err != nil { 203 | t.Fatalf("failed to create temporary file") 204 | } 205 | defer os.Remove(file.Name()) 206 | 207 | _, err = file.Write([]byte("newline: n\npause-on-newline:true\n--\nhi\n")) 208 | if err != nil { 209 | t.Fatalf("failed to write to temporary file") 210 | } 211 | 212 | t.Setenv("INPUT_FILE", file.Name()) 213 | 214 | // Create a helper 215 | x := FileInput{} 216 | 217 | ch := ConsoleIn{} 218 | ch.driver = &x 219 | 220 | sErr := ch.Setup() 221 | if sErr != nil { 222 | t.Fatalf("failed to setup driver %s", sErr.Error()) 223 | } 224 | 225 | if !ch.PendingInput() { 226 | t.Fatalf("expected pending input (a)") 227 | } 228 | 229 | c := 0 230 | str := "" 231 | 232 | for c < 3 { 233 | var out byte 234 | out, err = ch.BlockForCharacterNoEcho() 235 | if err != nil { 236 | t.Fatalf("failed to get character") 237 | } 238 | str += string(out) 239 | 240 | c++ 241 | } 242 | if str != "hi\n" { 243 | t.Fatalf("error in string, got '%v' '%s'", str, str) 244 | } 245 | 246 | } 247 | 248 | // TestNewlineM ensures "newline: m" returns the expected character. (Ctrl-M) 249 | func TestNewlineM(t *testing.T) { 250 | 251 | // Create a temporary file 252 | file, err := os.CreateTemp("", "in3.txt") 253 | if err != nil { 254 | t.Fatalf("failed to create temporary file") 255 | } 256 | defer os.Remove(file.Name()) 257 | 258 | _, err = file.Write([]byte("newline: m\npause-on-newline:false\n--\nhi\n")) 259 | if err != nil { 260 | t.Fatalf("failed to write to temporary file") 261 | } 262 | 263 | t.Setenv("INPUT_FILE", file.Name()) 264 | 265 | // Create a helper 266 | x := FileInput{} 267 | 268 | ch := ConsoleIn{} 269 | ch.driver = &x 270 | 271 | sErr := ch.Setup() 272 | if sErr != nil { 273 | t.Fatalf("failed to setup driver %s", sErr.Error()) 274 | } 275 | 276 | if !ch.PendingInput() { 277 | t.Fatalf("expected pending input (a)") 278 | } 279 | 280 | c := 0 281 | str := "" 282 | 283 | for c < 3 { 284 | var out byte 285 | out, err = ch.BlockForCharacterNoEcho() 286 | if err != nil { 287 | t.Fatalf("failed to get character") 288 | } 289 | str += string(out) 290 | 291 | c++ 292 | } 293 | if str != "hi"+string(' ') { 294 | t.Fatalf("error in string, got '%v' '%s'", str, str) 295 | } 296 | } 297 | 298 | // TestNewlineBoth ensures "newline: both" returns both expected characters. 299 | func TestNewlineBoth(t *testing.T) { 300 | 301 | // Create a temporary file 302 | file, err := os.CreateTemp("", "in1.txt") 303 | if err != nil { 304 | t.Fatalf("failed to create temporary file") 305 | } 306 | defer os.Remove(file.Name()) 307 | 308 | _, err = file.Write([]byte("newline: both\n--\nhi\n")) 309 | if err != nil { 310 | t.Fatalf("failed to write to temporary file") 311 | } 312 | 313 | t.Setenv("INPUT_FILE", file.Name()) 314 | 315 | // Create a helper 316 | x := FileInput{} 317 | 318 | ch := ConsoleIn{} 319 | ch.driver = &x 320 | 321 | sErr := ch.Setup() 322 | if sErr != nil { 323 | t.Fatalf("failed to setup driver %s", sErr.Error()) 324 | } 325 | 326 | if !ch.PendingInput() { 327 | t.Fatalf("expected pending input (a)") 328 | } 329 | 330 | c := 0 331 | str := "" 332 | 333 | for c < 4 { 334 | var out byte 335 | out, err = ch.BlockForCharacterNoEcho() 336 | if err != nil { 337 | t.Fatalf("failed to get character") 338 | } 339 | str += string(out) 340 | 341 | c++ 342 | } 343 | if str != "hi"+string(' ')+"\n" { 344 | t.Fatalf("error in string, got '%v' '%s'", str, str) 345 | } 346 | } 347 | 348 | // TestNewlineBogus ensures "newline: foo" returns just \n by default 349 | func TestNewlineBogus(t *testing.T) { 350 | 351 | // Create a temporary file 352 | file, err := os.CreateTemp("", "in4.txt") 353 | if err != nil { 354 | t.Fatalf("failed to create temporary file") 355 | } 356 | defer os.Remove(file.Name()) 357 | 358 | _, err = file.Write([]byte("newline: bogus\n--\n\n")) 359 | if err != nil { 360 | t.Fatalf("failed to write to temporary file") 361 | } 362 | 363 | t.Setenv("INPUT_FILE", file.Name()) 364 | 365 | // Create a helper 366 | x := FileInput{} 367 | 368 | ch := ConsoleIn{} 369 | ch.driver = &x 370 | 371 | sErr := ch.Setup() 372 | if sErr != nil { 373 | t.Fatalf("failed to setup driver %s", sErr.Error()) 374 | } 375 | 376 | _, err = ch.BlockForCharacterNoEcho() 377 | if err == nil { 378 | t.Fatalf("expected to get an error, got none") 379 | } 380 | } 381 | 382 | // TestNewlineMissing ensures we returns just \n by default 383 | func TestNewlineMissing(t *testing.T) { 384 | 385 | // Create a temporary file 386 | file, err := os.CreateTemp("", "in5.txt") 387 | if err != nil { 388 | t.Fatalf("failed to create temporary file") 389 | } 390 | defer os.Remove(file.Name()) 391 | 392 | _, err = file.Write([]byte("nothing: bogus\n--\nhi\n")) 393 | if err != nil { 394 | t.Fatalf("failed to write to temporary file") 395 | } 396 | 397 | t.Setenv("INPUT_FILE", file.Name()) 398 | 399 | // Create a helper 400 | x := FileInput{} 401 | 402 | ch := ConsoleIn{} 403 | ch.driver = &x 404 | 405 | sErr := ch.Setup() 406 | if sErr != nil { 407 | t.Fatalf("failed to setup driver %s", sErr.Error()) 408 | } 409 | 410 | if !ch.PendingInput() { 411 | t.Fatalf("expected pending input (a)") 412 | } 413 | 414 | c := 0 415 | str := "" 416 | 417 | for c < 3 { 418 | var out byte 419 | out, err = ch.BlockForCharacterNoEcho() 420 | if err != nil { 421 | t.Fatalf("failed to get character") 422 | } 423 | str += string(out) 424 | 425 | c++ 426 | } 427 | if str != "hi\n" { 428 | t.Fatalf("error in string, got '%v' '%s'", str, str) 429 | } 430 | } 431 | 432 | func TestTimer(t *testing.T) { 433 | 434 | // Create a temporary file 435 | file, err := os.CreateTemp("", "in5.txt") 436 | if err != nil { 437 | t.Fatalf("failed to create temporary file") 438 | } 439 | defer os.Remove(file.Name()) 440 | 441 | _, err = file.Write([]byte("nothing: bogus\n--\nhi\n")) 442 | if err != nil { 443 | t.Fatalf("failed to write to temporary file") 444 | } 445 | 446 | t.Setenv("INPUT_FILE", file.Name()) 447 | 448 | // Create a helper 449 | x := FileInput{} 450 | 451 | ch := ConsoleIn{} 452 | ch.driver = &x 453 | 454 | sErr := ch.Setup() 455 | if sErr != nil { 456 | t.Fatalf("failed to setup driver %s", sErr.Error()) 457 | } 458 | 459 | x.delayUntil = time.Now().Add(48 * time.Hour) 460 | 461 | if x.PendingInput() { 462 | t.Fatalf("expected no input") 463 | } 464 | } 465 | -------------------------------------------------------------------------------- /consolein/drv_stty.go: -------------------------------------------------------------------------------- 1 | //go:build unix 2 | 3 | // drv_stty creates a console input-driver which uses the 4 | // `stty` binary to set our echo/no-echo state. 5 | // 6 | // This is obviously not portable outwith Unix-like systems. 7 | 8 | package consolein 9 | 10 | import ( 11 | "fmt" 12 | "os" 13 | "os/exec" 14 | 15 | "golang.org/x/sys/unix" 16 | "golang.org/x/term" 17 | ) 18 | 19 | var ( 20 | // STTYInputName contains the name of this driver. 21 | STTYInputName = "stty" 22 | ) 23 | 24 | // EchoStatus is used to record our current state. 25 | type EchoStatus int 26 | 27 | var ( 28 | // Unknown means we don't know the status of echo/noecho 29 | Unknown EchoStatus = 0 30 | 31 | // Echo means that input will echo characters. 32 | Echo EchoStatus = 1 33 | 34 | // NoEcho means that input will not echo characters. 35 | NoEcho EchoStatus = 2 36 | ) 37 | 38 | // STTYInput is an input-driver that executes the 'stty' binary 39 | // to toggle between echoing character input, and disabling the 40 | // echo. 41 | // 42 | // This is slow, as you can imagine, and non-portable outwith Unix-like 43 | // systems. To mitigate against the speed-issue we keep track of "echo" 44 | // versus "noecho" states, to minimise the executions. 45 | type STTYInput struct { 46 | 47 | // state holds our state 48 | state EchoStatus 49 | } 50 | 51 | // Setup is a NOP. 52 | func (si *STTYInput) Setup() error { 53 | return nil 54 | } 55 | 56 | // TearDown resets the state of the terminal. 57 | func (si *STTYInput) TearDown() error { 58 | if si.state != Echo { 59 | si.enableEcho() 60 | } 61 | 62 | return nil 63 | } 64 | 65 | // canSelect contains a platform-specific implementation of code that tries to use 66 | // SELECT to read from STDIN. 67 | func canSelect() bool { 68 | 69 | fds := new(unix.FdSet) 70 | fds.Set(int(os.Stdin.Fd())) 71 | 72 | // See if input is pending, for a while. 73 | tv := unix.Timeval{Usec: 200} 74 | 75 | // via select with timeout 76 | nRead, err := unix.Select(1, fds, nil, nil, &tv) 77 | if err != nil { 78 | return false 79 | } 80 | 81 | return (nRead > 0) 82 | } 83 | 84 | // PendingInput returns true if there is pending input from STDIN.. 85 | // 86 | // Note that we have to set RAW mode, without this input is laggy 87 | // and zork doesn't run. 88 | func (si *STTYInput) PendingInput() bool { 89 | 90 | // switch stdin into 'raw' mode 91 | oldState, err := term.MakeRaw(int(os.Stdin.Fd())) 92 | if err != nil { 93 | return false 94 | } 95 | 96 | // Can we read from STDIN? 97 | res := canSelect() 98 | 99 | // restore the state of the terminal to avoid mixing RAW/Cooked 100 | err = term.Restore(int(os.Stdin.Fd()), oldState) 101 | if err != nil { 102 | return false 103 | } 104 | 105 | // Return true if we have something ready to read. 106 | return res 107 | } 108 | 109 | // BlockForCharacterNoEcho returns the next character from the console, blocking until 110 | // one is available. 111 | // 112 | // NOTE: This function should not echo keystrokes which are entered. 113 | func (si *STTYInput) BlockForCharacterNoEcho() (byte, error) { 114 | 115 | // Do we need to change state? If so then do it. 116 | if si.state != NoEcho { 117 | si.disableEcho() 118 | } 119 | 120 | // switch stdin into 'raw' mode 121 | oldState, err := term.MakeRaw(int(os.Stdin.Fd())) 122 | if err != nil { 123 | return 0x00, fmt.Errorf("error making raw terminal %s", err) 124 | } 125 | 126 | // read only a single byte 127 | b := make([]byte, 1) 128 | _, err = os.Stdin.Read(b) 129 | if err != nil { 130 | return 0x00, fmt.Errorf("error reading a byte from stdin %s", err) 131 | } 132 | 133 | // restore the state of the terminal to avoid mixing RAW/Cooked 134 | err = term.Restore(int(os.Stdin.Fd()), oldState) 135 | if err != nil { 136 | return 0x00, fmt.Errorf("error restoring terminal state %s", err) 137 | } 138 | 139 | // Return the character we read 140 | return b[0], nil 141 | } 142 | 143 | // disableEcho is the single place where we disable echoing. 144 | func (si *STTYInput) disableEcho() { 145 | _ = exec.Command("stty", "-F", "/dev/tty", "-echo").Run() 146 | si.state = NoEcho 147 | } 148 | 149 | // enableEcho is the single place where we enable echoing. 150 | func (si *STTYInput) enableEcho() { 151 | _ = exec.Command("stty", "-F", "/dev/tty", "echo").Run() 152 | si.state = Echo 153 | } 154 | 155 | // GetName is part of the module API, and returns the name of this driver. 156 | func (si *STTYInput) GetName() string { 157 | return STTYInputName 158 | } 159 | 160 | // init registers our driver, by name. 161 | func init() { 162 | Register(STTYInputName, func() ConsoleInput { 163 | return new(STTYInput) 164 | }) 165 | } 166 | -------------------------------------------------------------------------------- /consolein/drv_term.go: -------------------------------------------------------------------------------- 1 | // drv_term.go uses the Termbox library to handle console-based input. 2 | // 3 | // A goroutine is launched which collects any keyboard input and 4 | // saves that to a buffer where it can be peeled off on-demand. 5 | // 6 | // The portability of this solution is unknown, however this driver 7 | // _seems_ reasonable and is the default. 8 | 9 | package consolein 10 | 11 | import ( 12 | "context" 13 | "fmt" 14 | "os" 15 | "time" 16 | 17 | "github.com/nsf/termbox-go" 18 | "golang.org/x/term" 19 | ) 20 | 21 | var ( 22 | // TermboxInputName contains the name of this driver 23 | TermboxInputName = "term" 24 | ) 25 | 26 | // TermboxInput is our input-driver, using termbox 27 | type TermboxInput struct { 28 | 29 | // oldState contains the state of the terminal, before switching to RAW mode 30 | oldState *term.State 31 | 32 | // Cancel holds a context which can be used to close our polling goroutine 33 | Cancel context.CancelFunc 34 | 35 | // keyBuffer builds up keys read "in the background", via termbox 36 | keyBuffer []rune 37 | } 38 | 39 | // Setup ensures that the termbox init functions are called, and our 40 | // terminal is set into RAW mode. 41 | func (ti *TermboxInput) Setup() error { 42 | 43 | var err error 44 | 45 | // switch STDIN into 'raw' mode - we must do this before 46 | // we setup termbox. 47 | ti.oldState, err = term.MakeRaw(int(os.Stdin.Fd())) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | // Setup the terminal. 53 | err = termbox.Init() 54 | if err != nil { 55 | return err 56 | } 57 | 58 | // This is "Show Cursor" which termbox hides by default. 59 | // 60 | // Sigh. 61 | fmt.Printf("\x1b[?25h") 62 | 63 | // Allow our polling of keyboard to be canceled 64 | ctx, cancel := context.WithCancel(context.Background()) 65 | ti.Cancel = cancel 66 | 67 | // Start polling for keyboard input "in the background". 68 | go ti.pollKeyboard(ctx) 69 | 70 | // No error 71 | return nil 72 | } 73 | 74 | // pollKeyboard runs in a goroutine and collects keyboard input 75 | // into a buffer where it will be read from in the future. 76 | func (ti *TermboxInput) pollKeyboard(ctx context.Context) { 77 | for { 78 | // Are we done? 79 | select { 80 | case <-ctx.Done(): 81 | return 82 | default: 83 | // NOP 84 | } 85 | 86 | // Now look for keyboard input 87 | switch ev := termbox.PollEvent(); ev.Type { 88 | case termbox.EventKey: 89 | if ev.Ch != 0 { 90 | ti.keyBuffer = append(ti.keyBuffer, ev.Ch) 91 | } else { 92 | ti.keyBuffer = append(ti.keyBuffer, rune(ev.Key)) 93 | } 94 | } 95 | } 96 | } 97 | 98 | // TearDown resets the state of the terminal, disables the background polling of characters 99 | // and generally gets us ready for exit. 100 | func (ti *TermboxInput) TearDown() error { 101 | // Cancel the keyboard reading 102 | if ti.Cancel != nil { 103 | ti.Cancel() 104 | } 105 | 106 | // Terminate the GUI. 107 | termbox.Close() 108 | 109 | // Restore the terminal 110 | if ti.oldState != nil { 111 | err := term.Restore(int(os.Stdin.Fd()), ti.oldState) 112 | return err 113 | } 114 | 115 | return nil 116 | } 117 | 118 | // PendingInput returns true if there is pending input from STDIN. 119 | func (ti *TermboxInput) PendingInput() bool { 120 | 121 | return len(ti.keyBuffer) != 0 122 | } 123 | 124 | // BlockForCharacterNoEcho returns the next character from the console, blocking until 125 | // one is available. 126 | // 127 | // NOTE: This function should not echo keystrokes which are entered. 128 | func (ti *TermboxInput) BlockForCharacterNoEcho() (byte, error) { 129 | 130 | for len(ti.keyBuffer) == 0 { 131 | time.Sleep(1 * time.Millisecond) 132 | } 133 | 134 | // Return the character 135 | c := ti.keyBuffer[0] 136 | ti.keyBuffer = ti.keyBuffer[1:] 137 | return byte(c), nil 138 | } 139 | 140 | // GetName is part of the module API, and returns the name of this driver. 141 | func (ti *TermboxInput) GetName() string { 142 | return TermboxInputName 143 | } 144 | 145 | // init registers our driver, by name. 146 | func init() { 147 | Register(TermboxInputName, func() ConsoleInput { 148 | return new(TermboxInput) 149 | }) 150 | } 151 | -------------------------------------------------------------------------------- /consoleout/consoleout.go: -------------------------------------------------------------------------------- 1 | // Package consoleout is an abstraction over console output. 2 | // 3 | // We know we need an ANSI/RAW output, and we have an ADM-3A driver, 4 | // so we want to create a factory that can instantiate and change a driver, 5 | // given just a name. 6 | package consoleout 7 | 8 | import ( 9 | "fmt" 10 | "io" 11 | "strings" 12 | ) 13 | 14 | // ConsoleOutput is the interface that must be implemented by anything 15 | // that wishes to be used as a console driver. 16 | // 17 | // Providing this interface is implemented an object may register itself, 18 | // by name, via the Register method. 19 | // 20 | // You can compare this to the ConsoleInput interface, which is similar, although 21 | // in that case the wrapper which creates the instances also implements some common methods. 22 | type ConsoleOutput interface { 23 | 24 | // PutCharacter will output the specified character to the defined writer. 25 | // 26 | // The writer will default to STDOUT, but can be changed, via SetWriter. 27 | PutCharacter(c uint8) 28 | 29 | // GetName will return the name of the driver. 30 | GetName() string 31 | 32 | // SetWriter will update the writer. 33 | SetWriter(io.Writer) 34 | } 35 | 36 | // ConsoleRecorder is an interface that allows returning the contents that 37 | // have been previously sent to the console. 38 | // 39 | // This is used solely for integration tests. 40 | type ConsoleRecorder interface { 41 | 42 | // GetOutput returns the contents which have been displayed. 43 | GetOutput() string 44 | 45 | // Reset removes any stored state. 46 | Reset() 47 | } 48 | 49 | // This is a map of known-drivers 50 | var handlers = struct { 51 | m map[string]Constructor 52 | }{m: make(map[string]Constructor)} 53 | 54 | // Constructor is the signature of a constructor-function 55 | // which is used to instantiate an instance of a driver. 56 | type Constructor func() ConsoleOutput 57 | 58 | // Register makes a console driver available, by name. 59 | // 60 | // When one needs to be created the constructor can be called 61 | // to create an instance of it. 62 | func Register(name string, obj Constructor) { 63 | // Downcase for consistency. 64 | name = strings.ToLower(name) 65 | 66 | handlers.m[name] = obj 67 | } 68 | 69 | // ConsoleOut holds our state, which is basically just a 70 | // pointer to the object handling our output. 71 | type ConsoleOut struct { 72 | 73 | // driver is the thing that actually writes our output. 74 | driver ConsoleOutput 75 | 76 | // options store per-driver options which might be passed in the 77 | // constructor. Right now these are undocumented 78 | options string 79 | } 80 | 81 | // New is our constructor, it creates an output device which uses 82 | // the specified driver. 83 | func New(name string) (*ConsoleOut, error) { 84 | 85 | // Do we have trailing options? 86 | options := "" 87 | 88 | // If we do save them 89 | val := strings.Split(name, ":") 90 | if len(val) == 2 { 91 | name = val[0] 92 | options = val[1] 93 | } 94 | 95 | // Downcase for consistency. 96 | name = strings.ToLower(name) 97 | 98 | // Do we have a constructor with the given name? 99 | ctor, ok := handlers.m[name] 100 | if !ok { 101 | return nil, fmt.Errorf("failed to lookup driver by name '%s'", name) 102 | } 103 | 104 | // OK we do, return ourselves with that driver. 105 | return &ConsoleOut{ 106 | driver: ctor(), 107 | options: options, 108 | }, nil 109 | } 110 | 111 | // GetDriver allows getting our driver at runtime. 112 | func (co *ConsoleOut) GetDriver() ConsoleOutput { 113 | return co.driver 114 | } 115 | 116 | // WriteString writes the given string, character by character, via our 117 | // selected output driver. 118 | func (co *ConsoleOut) WriteString(str string) { 119 | for _, c := range str { 120 | co.PutCharacter(uint8(c)) 121 | } 122 | } 123 | 124 | // ChangeDriver allows changing our driver at runtime. 125 | func (co *ConsoleOut) ChangeDriver(name string) error { 126 | 127 | // Do we have a constructor with the given name? 128 | ctor, ok := handlers.m[name] 129 | if !ok { 130 | return fmt.Errorf("failed to lookup driver by name '%s'", name) 131 | } 132 | 133 | // change the driver by creating a new object 134 | co.driver = ctor() 135 | return nil 136 | } 137 | 138 | // GetName returns the name of our selected driver. 139 | func (co *ConsoleOut) GetName() string { 140 | return co.driver.GetName() 141 | } 142 | 143 | // GetDrivers returns all available driver-names. 144 | // 145 | // We hide the internal "null", and "logger" drivers. 146 | func (co *ConsoleOut) GetDrivers() []string { 147 | valid := []string{} 148 | 149 | for x := range handlers.m { 150 | if x != "null" && x != "logger" { 151 | valid = append(valid, x) 152 | } 153 | } 154 | return valid 155 | } 156 | 157 | // PutCharacter outputs a character, using our selected driver. 158 | func (co *ConsoleOut) PutCharacter(c byte) { 159 | 160 | // If we have no options then just output the 161 | // character and have an early return. 162 | if co.options == "" { 163 | co.driver.PutCharacter(c) 164 | return 165 | } 166 | 167 | // Options only change our newline handling at the moment. 168 | // so anything that is a different character can also get 169 | // printed and an early termination. 170 | if c != '\r' && c != '\n' { 171 | co.driver.PutCharacter(c) 172 | return 173 | } 174 | 175 | // Right so we've got a CR or a LF, and we have non-empty options. 176 | if c == '\r' { 177 | 178 | // NO CR allowed? Ignore the character 179 | if strings.Contains(co.options, "CR=NONE") { 180 | return 181 | } 182 | 183 | // CR should do "both"? Do that 184 | if strings.Contains(co.options, "CR=BOTH") { 185 | co.driver.PutCharacter('\r') 186 | co.driver.PutCharacter('\n') 187 | return 188 | } 189 | 190 | // CR is just CR? Okay 191 | if strings.Contains(co.options, "CR=CR") { 192 | co.driver.PutCharacter('\r') 193 | return 194 | } 195 | 196 | // CR is actually LF? Okay 197 | if strings.Contains(co.options, "CR=LF") { 198 | co.driver.PutCharacter('\n') 199 | return 200 | } 201 | 202 | } 203 | if c == '\n' { 204 | 205 | // No LF allowed? Ignore the character 206 | if strings.Contains(co.options, "LF=NONE") { 207 | return 208 | } 209 | 210 | // LF should do "both"? Do that. 211 | if strings.Contains(co.options, "LF=BOTH") { 212 | co.driver.PutCharacter('\r') 213 | co.driver.PutCharacter('\n') 214 | return 215 | } 216 | 217 | // LF is just LF? Okay. 218 | if strings.Contains(co.options, "LF=LF") { 219 | co.driver.PutCharacter('\n') 220 | return 221 | } 222 | 223 | // LF is actually CR? Okay 224 | if strings.Contains(co.options, "LF=CR") { 225 | co.driver.PutCharacter('\r') 226 | return 227 | } 228 | } 229 | 230 | // 231 | // At this point we had CR or LF and yet none of our 232 | // options made a change. 233 | // 234 | // Just print the character. 235 | // 236 | co.driver.PutCharacter(c) 237 | } 238 | -------------------------------------------------------------------------------- /consoleout/consoleout_test.go: -------------------------------------------------------------------------------- 1 | package consoleout 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | ) 7 | 8 | // TestName ensures we can lookup a driver by name 9 | func TestName(t *testing.T) { 10 | 11 | valid := []string{"ansi", "adm-3a"} 12 | 13 | for _, nm := range valid { 14 | 15 | d, e := New(nm) 16 | if e != nil { 17 | t.Fatalf("failed to lookup driver by name %s:%s", nm, e) 18 | } 19 | if d.GetName() != nm { 20 | t.Fatalf("%s != %s", d.GetName(), nm) 21 | } 22 | if d.GetDriver().GetName() != nm { 23 | t.Fatalf("%s != %s", d.GetDriver().GetName(), nm) 24 | } 25 | } 26 | 27 | // Lookup a driver that wont exist 28 | _, err := New("foo.bar.ba") 29 | if err == nil { 30 | t.Fatalf("we got a driver that shouldn't exist") 31 | } 32 | } 33 | 34 | // TestOptions ensures we have some options 35 | func TestOptions(t *testing.T) { 36 | 37 | d, e := New("ansi:CAKE/IS/A/LIE") 38 | if e != nil { 39 | t.Fatalf("failed to lookup driver by name %s", e) 40 | } 41 | if d.GetName() != "ansi" { 42 | t.Fatalf("setting options broke the name") 43 | } 44 | } 45 | 46 | // TestChangeDriver ensures we can change a driver 47 | func TestChangeDriver(t *testing.T) { 48 | 49 | // Start with a known-good driver 50 | ansi, err := New("ansi") 51 | if err != nil { 52 | t.Fatalf("failed to load starting driver %s", err) 53 | } 54 | 55 | // Change to another known-good driver 56 | err = ansi.ChangeDriver("adm-3a") 57 | if err != nil { 58 | t.Fatalf("failed to change to new driver %s", err) 59 | } 60 | if ansi.GetName() != "adm-3a" { 61 | t.Fatalf("driver change didnt work?") 62 | } 63 | 64 | // Change to a bogus driver 65 | err = ansi.ChangeDriver("fofdsf-fsdfsd-fsdfdsf-") 66 | if err == nil { 67 | t.Fatalf("expected failure to change to new driver, didn't happen") 68 | } 69 | if ansi.GetName() != "adm-3a" { 70 | t.Fatalf("driver changed unexpectedly") 71 | } 72 | } 73 | 74 | // TestOutput ensures that our two "real" drivers output, as expected 75 | func TestOutput(t *testing.T) { 76 | 77 | // Drivers that should produce output 78 | valid := []string{"ansi", "adm-3a"} 79 | 80 | for _, nm := range valid { 81 | 82 | d, e := New(nm) 83 | if e != nil { 84 | t.Fatalf("failed to lookup driver by name %s:%s", nm, e) 85 | } 86 | 87 | // ensure we redirect the output 88 | tmp := new(bytes.Buffer) 89 | 90 | d.driver.SetWriter(tmp) 91 | 92 | // Write character by character 93 | for _, c := range "Steve " { 94 | d.PutCharacter(byte(c)) 95 | } 96 | 97 | // Now write via the helper which routes correctly to the driver 98 | // and does it character by character. 99 | d.WriteString("Kemp") 100 | 101 | // Test we got the output we expected 102 | if tmp.String() != "Steve Kemp" { 103 | t.Fatalf("output driver %s produced '%s'", d.GetName(), tmp.String()) 104 | } 105 | } 106 | 107 | } 108 | 109 | // TestNull ensures nothing is written by the null output driver 110 | func TestNull(t *testing.T) { 111 | 112 | // Start with a known-good driver 113 | null, err := New("null") 114 | if err != nil { 115 | t.Fatalf("failed to load starting driver %s", err) 116 | } 117 | if null.GetName() != "null" { 118 | t.Fatalf("null driver has the wrong name") 119 | } 120 | 121 | if null.GetDriver().GetName() != null.GetName() { 122 | t.Fatalf("getting driver went wrong") 123 | } 124 | 125 | // ensure we redirect the output 126 | tmp := new(bytes.Buffer) 127 | 128 | null.driver.SetWriter(tmp) 129 | 130 | null.PutCharacter('s') 131 | 132 | if tmp.String() != "" { 133 | t.Fatalf("got output, expected none: '%s'", tmp.String()) 134 | } 135 | } 136 | 137 | // TestLogger ensures nothing is written by the logging output driver 138 | func TestLogger(t *testing.T) { 139 | 140 | // Start with a known-good driver 141 | drv, err := New("logger") 142 | if err != nil { 143 | t.Fatalf("failed to load starting driver %s", err) 144 | } 145 | if drv.GetName() != "logger" { 146 | t.Fatalf("driver has the wrong name") 147 | } 148 | 149 | if drv.GetDriver().GetName() != drv.GetName() { 150 | t.Fatalf("getting driver went wrong") 151 | } 152 | 153 | // ensure we redirect the output 154 | tmp := new(bytes.Buffer) 155 | 156 | drv.driver.SetWriter(tmp) 157 | 158 | drv.PutCharacter('s') 159 | drv.PutCharacter('t') 160 | drv.PutCharacter('e') 161 | drv.PutCharacter('v') 162 | drv.PutCharacter('e') 163 | 164 | if tmp.String() != "" { 165 | t.Fatalf("got output, expected none: '%s'", tmp.String()) 166 | } 167 | 168 | // Cast the driver to get the history 169 | o, ok := drv.GetDriver().(*OutputLoggingDriver) 170 | if !ok { 171 | t.Fatalf("failed to cast driver") 172 | } 173 | 174 | // ensure we have the history we expect. 175 | if o.GetOutput() != "steve" { 176 | t.Fatalf("wrong history") 177 | } 178 | 179 | // And that this keeps updating. 180 | drv.PutCharacter(' ') 181 | if o.GetOutput() != "steve " { 182 | t.Fatalf("wrong history") 183 | } 184 | 185 | // reset the history, and confirm it worked. 186 | o.Reset() 187 | if o.GetOutput() != "" { 188 | t.Fatalf("reseting the history didn't succeed") 189 | } 190 | } 191 | 192 | // TestList ensures that we have the right number of drivers 193 | func TestList(t *testing.T) { 194 | x, _ := New("foo") 195 | 196 | valid := x.GetDrivers() 197 | 198 | if len(valid) != 2 { 199 | t.Fatalf("unexpected number of console drivers") 200 | } 201 | } 202 | 203 | // TestADM outputs every possible byte, at every possible status. 204 | // This is a fake-test for coverage only. 205 | func TestADM(t *testing.T) { 206 | 207 | x := Adm3AOutputDriver{} 208 | 209 | // ensure we redirect the output 210 | tmp := new(bytes.Buffer) 211 | x.SetWriter(tmp) 212 | 213 | // status 214 | s := 0 215 | for s < 10 { 216 | 217 | // Output each character 218 | i := 0 219 | for i <= 255 { 220 | x.status = s 221 | x.PutCharacter(byte(i)) 222 | i++ 223 | } 224 | 225 | s++ 226 | } 227 | } 228 | 229 | // TestOptionsNewline tests that the various options are tested 230 | func TestOptionsNewline(t *testing.T) { 231 | 232 | known := []string{"CR=NONE", "CR=BOTH", "CR=CR", "CR=LF", "LF=LF", "LF=BOTH", "LF=NONE", "LF=CR", "ok"} 233 | 234 | for _, str := range known { 235 | 236 | x, e := New("ansi") 237 | if e != nil { 238 | t.Fatalf("error creating driver") 239 | } 240 | 241 | // set the options 242 | x.options = str 243 | 244 | // test a range of characters 245 | x.PutCharacter('\n') 246 | x.PutCharacter('\r') 247 | x.PutCharacter('s') 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /consoleout/drv_adm3a.go: -------------------------------------------------------------------------------- 1 | package consoleout 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | ) 8 | 9 | // Adm3AOutputDriver holds our state. 10 | type Adm3AOutputDriver struct { 11 | 12 | // status contains our state, in the state-machine 13 | status int 14 | 15 | // x stores the cursor X 16 | x uint8 17 | 18 | // y stores the cursor Y 19 | y uint8 20 | 21 | // writer is where we send our output 22 | writer io.Writer 23 | } 24 | 25 | // GetName returns the name of this driver. 26 | // 27 | // This is part of the OutputDriver interface. 28 | func (a3a *Adm3AOutputDriver) GetName() string { 29 | return "adm-3a" 30 | } 31 | 32 | // PutCharacter writes the character to the console. 33 | // 34 | // This is part of the OutputDriver interface. 35 | func (a3a *Adm3AOutputDriver) PutCharacter(c uint8) { 36 | 37 | switch a3a.status { 38 | case 0: 39 | switch c { 40 | case 0x07: /* BEL: flash screen */ 41 | fmt.Fprintf(a3a.writer, "\033[?5h\033[?5l") 42 | case 0x7F: /* DEL: echo BS, space, BS */ 43 | fmt.Fprintf(a3a.writer, "\b \b") 44 | case 0x1A: /* adm3a clear screen */ 45 | fmt.Fprintf(a3a.writer, "\033[H\033[2J") 46 | case 0x0C: /* vt52 clear screen */ 47 | fmt.Fprintf(a3a.writer, "\033[H\033[2J") 48 | case 0x1E: /* adm3a cursor home */ 49 | fmt.Fprintf(a3a.writer, "\033[H") 50 | case 0x1B: 51 | a3a.status = 1 /* esc-prefix */ 52 | case 1: 53 | a3a.status = 2 /* cursor motion prefix */ 54 | case 2: /* insert line */ 55 | fmt.Fprintf(a3a.writer, "\033[L") 56 | case 3: /* delete line */ 57 | fmt.Fprintf(a3a.writer, "\033[M") 58 | case 0x18, 5: /* clear to eol */ 59 | fmt.Fprintf(a3a.writer, "\033[K") 60 | case 0x12, 0x13: 61 | // nop 62 | default: 63 | fmt.Fprintf(a3a.writer, "%c", c) 64 | } 65 | case 1: /* we had an esc-prefix */ 66 | switch c { 67 | case 0x1B: 68 | fmt.Fprintf(a3a.writer, "%c", c) 69 | case '=', 'Y': 70 | a3a.status = 2 71 | case 'E': /* insert line */ 72 | fmt.Fprintf(a3a.writer, "\033[L") 73 | case 'R': /* delete line */ 74 | fmt.Fprintf(a3a.writer, "\033[M") 75 | case 'B': /* enable attribute */ 76 | a3a.status = 4 77 | case 'C': /* disable attribute */ 78 | a3a.status = 5 79 | case 'L', 'D': /* set line */ /* delete line */ 80 | a3a.status = 6 81 | case '*', ' ': /* set pixel */ /* clear pixel */ 82 | a3a.status = 8 83 | default: /* some true ANSI sequence? */ 84 | a3a.status = 0 85 | fmt.Fprintf(a3a.writer, "%c%c", 0x1B, c) 86 | } 87 | case 2: 88 | a3a.y = c - ' ' + 1 89 | a3a.status = 3 90 | case 3: 91 | a3a.x = c - ' ' + 1 92 | a3a.status = 0 93 | fmt.Fprintf(a3a.writer, "\033[%d;%dH", a3a.y, a3a.x) 94 | case 4: /* +B prefix */ 95 | a3a.status = 0 96 | switch c { 97 | case '0': /* start reverse video */ 98 | fmt.Fprintf(a3a.writer, "\033[7m") 99 | case '1': /* start half intensity */ 100 | fmt.Fprintf(a3a.writer, "\033[1m") 101 | case '2': /* start blinking */ 102 | fmt.Fprintf(a3a.writer, "\033[5m") 103 | case '3': /* start underlining */ 104 | fmt.Fprintf(a3a.writer, "\033[4m") 105 | case '4': /* cursor on */ 106 | fmt.Fprintf(a3a.writer, "\033[?25h") 107 | case '5': /* video mode on */ 108 | // nop 109 | case '6': /* remember cursor position */ 110 | fmt.Fprintf(a3a.writer, "\033[s") 111 | case '7': /* preserve status line */ 112 | // nop 113 | default: 114 | fmt.Fprintf(a3a.writer, "%cB%c", 0x1B, c) 115 | } 116 | case 5: /* +C prefix */ 117 | a3a.status = 0 118 | switch c { 119 | case '0': /* stop reverse video */ 120 | fmt.Fprintf(a3a.writer, "\033[27m") 121 | case '1': /* stop half intensity */ 122 | fmt.Fprintf(a3a.writer, "\033[m") 123 | case '2': /* stop blinking */ 124 | fmt.Fprintf(a3a.writer, "\033[25m") 125 | case '3': /* stop underlining */ 126 | fmt.Fprintf(a3a.writer, "\033[24m") 127 | case '4': /* cursor off */ 128 | fmt.Fprintf(a3a.writer, "\033[?25l") 129 | case '6': /* restore cursor position */ 130 | fmt.Fprintf(a3a.writer, "\033[u") 131 | case '5': /* video mode off */ 132 | // nop 133 | case '7': /* don't preserve status line */ 134 | // nop 135 | default: 136 | fmt.Fprintf(a3a.writer, "%cC%c", 0x1B, c) 137 | } 138 | /* set/clear line/point */ 139 | case 6: 140 | a3a.status++ 141 | case 7: 142 | a3a.status++ 143 | case 8: 144 | a3a.status++ 145 | case 9: 146 | a3a.status = 0 147 | } 148 | 149 | } 150 | 151 | // SetWriter will update the writer. 152 | func (a3a *Adm3AOutputDriver) SetWriter(w io.Writer) { 153 | a3a.writer = w 154 | } 155 | 156 | // init registers our driver, by name. 157 | func init() { 158 | Register("adm-3a", func() ConsoleOutput { 159 | return &Adm3AOutputDriver{ 160 | writer: os.Stdout, 161 | } 162 | }) 163 | } 164 | -------------------------------------------------------------------------------- /consoleout/drv_ansi.go: -------------------------------------------------------------------------------- 1 | package consoleout 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | ) 8 | 9 | // AnsiOutputDriver holds our state. 10 | type AnsiOutputDriver struct { 11 | // writer is where we send our output 12 | writer io.Writer 13 | } 14 | 15 | // GetName returns the name of this driver. 16 | // 17 | // This is part of the OutputDriver interface. 18 | func (ad *AnsiOutputDriver) GetName() string { 19 | return "ansi" 20 | } 21 | 22 | // PutCharacter writes the specified character to the console. 23 | // 24 | // This is part of the OutputDriver interface. 25 | func (ad *AnsiOutputDriver) PutCharacter(c uint8) { 26 | fmt.Fprintf(ad.writer, "%c", c) 27 | } 28 | 29 | // SetWriter will update the writer. 30 | func (ad *AnsiOutputDriver) SetWriter(w io.Writer) { 31 | ad.writer = w 32 | } 33 | 34 | // init registers our driver, by name. 35 | func init() { 36 | Register("ansi", func() ConsoleOutput { 37 | return &AnsiOutputDriver{ 38 | writer: os.Stdout, 39 | } 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /consoleout/drv_logger.go: -------------------------------------------------------------------------------- 1 | package consoleout 2 | 3 | import ( 4 | "io" 5 | "os" 6 | ) 7 | 8 | // OutputLoggingDriver holds our state. 9 | type OutputLoggingDriver struct { 10 | 11 | // writer is where we send our output 12 | writer io.Writer 13 | 14 | // history stores our history 15 | history string 16 | } 17 | 18 | // GetName returns the name of this driver. 19 | // 20 | // This is part of the OutputDriver interface. 21 | func (ol *OutputLoggingDriver) GetName() string { 22 | return "logger" 23 | } 24 | 25 | // PutCharacter writes the specified character to the console, 26 | // as this is a recording-driver nothing happens and instead the output 27 | // is discarded saved into our history 28 | // 29 | // This is part of the OutputDriver interface. 30 | func (ol *OutputLoggingDriver) PutCharacter(c uint8) { 31 | ol.history += string(c) 32 | } 33 | 34 | // SetWriter will update the writer. 35 | func (ol *OutputLoggingDriver) SetWriter(w io.Writer) { 36 | ol.writer = w 37 | } 38 | 39 | // GetOutput returns our history. 40 | // 41 | // This is part of the ConsoleRecorder interface. 42 | func (ol *OutputLoggingDriver) GetOutput() string { 43 | return ol.history 44 | } 45 | 46 | // Reset truncates our saved history. 47 | // 48 | // This is part of the ConsoleRecorder interface. 49 | func (ol *OutputLoggingDriver) Reset() { 50 | ol.history = "" 51 | } 52 | 53 | // init registers our driver, by name. 54 | func init() { 55 | Register("logger", func() ConsoleOutput { 56 | return &OutputLoggingDriver{ 57 | writer: os.Stdout, 58 | } 59 | }) 60 | } 61 | -------------------------------------------------------------------------------- /consoleout/drv_null.go: -------------------------------------------------------------------------------- 1 | package consoleout 2 | 3 | import ( 4 | "io" 5 | "os" 6 | ) 7 | 8 | // NullOutputDriver holds our state. 9 | type NullOutputDriver struct { 10 | 11 | // writer is where we send our output 12 | writer io.Writer 13 | } 14 | 15 | // GetName returns the name of this driver. 16 | // 17 | // This is part of the OutputDriver interface. 18 | func (no *NullOutputDriver) GetName() string { 19 | return "null" 20 | } 21 | 22 | // PutCharacter writes the specified character to the console, 23 | // as this is a null-driver nothing happens and instead the output 24 | // is discarded. 25 | // 26 | // This is part of the OutputDriver interface. 27 | func (no *NullOutputDriver) PutCharacter(c uint8) { 28 | // NOTHING HAppens 29 | } 30 | 31 | // SetWriter will update the writer. 32 | func (no *NullOutputDriver) SetWriter(w io.Writer) { 33 | no.writer = w 34 | } 35 | 36 | // init registers our driver, by name. 37 | func init() { 38 | Register("null", func() ConsoleOutput { 39 | return &NullOutputDriver{ 40 | writer: os.Stdout, 41 | } 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /cpm/prnc.go: -------------------------------------------------------------------------------- 1 | package cpm 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | // File is an interface that we mock so that we can fake failures to write and close 9 | // our printer logfile. 10 | type File interface { 11 | // Write is designed to write data, but it does not. 12 | // It will either do nothing, or return an error under testing. 13 | Write([]byte) (int, error) 14 | 15 | // Close is designed to close a file, but it does not. 16 | // It will either do nothing, or return an error under testing. 17 | Close() error 18 | } 19 | 20 | // opener is the factory that will allow creating either a real os.File, or a mockFile 21 | var opener func(name string, flag int, perm os.FileMode) (File, error) 22 | 23 | // prnC attempts to write the character specified to the "printer". 24 | // 25 | // We redirect printing to use a file, which defaults to "print.log", but 26 | // which can be changed via the CLI argument 27 | func (cpm *CPM) prnC(char uint8) error { 28 | 29 | if opener == nil { 30 | opener = func(name string, flag int, perm os.FileMode) (File, error) { 31 | return os.OpenFile(name, flag, perm) 32 | } 33 | } 34 | // If the file doesn't exist, create it. 35 | f, err := opener(cpm.prnPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 36 | if err != nil { 37 | return fmt.Errorf("prnC: Failed to open file %s:%s", cpm.prnPath, err) 38 | } 39 | 40 | data := make([]byte, 1) 41 | data[0] = char 42 | _, err = f.Write(data) 43 | if err != nil { 44 | return fmt.Errorf("prnC: Failed to write to file %s:%s", cpm.prnPath, err) 45 | } 46 | 47 | err = f.Close() 48 | if err != nil { 49 | return fmt.Errorf("prnC: Failed to close file %s:%s", cpm.prnPath, err) 50 | } 51 | 52 | return nil 53 | } 54 | -------------------------------------------------------------------------------- /cpm/prnc_test.go: -------------------------------------------------------------------------------- 1 | package cpm 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | var ( 11 | // ErrClose is the error we send when mocking a File Close operation. 12 | ErrClose = fmt.Errorf("CLOSE") 13 | 14 | // ErrWrite is the error we send when mocking a File Write operation. 15 | ErrWrite = fmt.Errorf("WRITE") 16 | ) 17 | 18 | // mockFile is a structure used for mocking file failures 19 | type mockFile struct { 20 | // failWrite will trigger the Write method, from our File interface, to return an error. 21 | failWrite bool 22 | 23 | // failClose will trigger the Close method, from our File interface, to return an error. 24 | failClose bool 25 | } 26 | 27 | // Write is the mocked Write method from the File interface, used for testing. 28 | func (m *mockFile) Write(p []byte) (int, error) { 29 | if m.failWrite { 30 | return 0, ErrWrite 31 | } 32 | return len(p), nil 33 | } 34 | 35 | // Close is the mocked Write method from the File interface, used for testing. 36 | func (m *mockFile) Close() error { 37 | if m.failClose { 38 | return ErrClose 39 | } 40 | return nil 41 | } 42 | 43 | // TestPrinterOutput tests that printer output goes to the file as 44 | // expected. 45 | func TestPrinterOutput(t *testing.T) { 46 | 47 | // Create a printer-output file 48 | file, err := os.CreateTemp("", "tst-*.prn") 49 | if err != nil { 50 | t.Fatalf("failed to create temporary file") 51 | } 52 | defer os.Remove(file.Name()) 53 | 54 | // Create a new CP/M helper - valid 55 | var obj *CPM 56 | obj, err = New(WithPrinterPath(file.Name())) 57 | if err != nil { 58 | t.Fatalf("failed to create CPM") 59 | } 60 | 61 | if obj.prnPath != file.Name() { 62 | t.Fatalf("unexpected filename for printer log") 63 | } 64 | 65 | // Now output some characters 66 | err = obj.prnC('s') 67 | if err != nil { 68 | t.Fatalf("failed to write character to printer-file") 69 | } 70 | 71 | obj.CPU.States.DE.Lo = 'k' 72 | err = BdosSysCallPrinterWrite(obj) 73 | if err != nil { 74 | t.Fatalf("failed to write character to printer-file") 75 | } 76 | 77 | obj.CPU.States.BC.Lo = 'x' 78 | err = BiosSysCallPrintChar(obj) 79 | if err != nil { 80 | t.Fatalf("failed to write character to printer-file") 81 | } 82 | 83 | // Read back the file. 84 | var data []byte 85 | data, err = os.ReadFile(file.Name()) 86 | if err != nil { 87 | t.Fatalf("failed to read from file") 88 | } 89 | 90 | if string(data) != "skx" { 91 | t.Fatalf("printer output had the wrong content") 92 | } 93 | } 94 | 95 | // TestWriteFail tests what happens when a file Write method fails. 96 | func TestWriteFail(t *testing.T) { 97 | 98 | opener = func(name string, flag int, perm os.FileMode) (File, error) { 99 | return &mockFile{failWrite: true}, nil 100 | } 101 | 102 | // Create a printer-output file 103 | file, err := os.CreateTemp("", "tst-*.prn") 104 | if err != nil { 105 | t.Fatalf("failed to create temporary file") 106 | } 107 | defer os.Remove(file.Name()) 108 | 109 | // Create a new CP/M helper - valid 110 | var obj *CPM 111 | obj, err = New(WithPrinterPath(file.Name())) 112 | if err != nil { 113 | t.Fatalf("failed to create CPM") 114 | } 115 | 116 | if obj.prnPath != file.Name() { 117 | t.Fatalf("unexpected filename for printer log") 118 | } 119 | 120 | // Now output a character, which we expect to fail. 121 | err = obj.prnC('s') 122 | if err == nil { 123 | t.Fatalf("expected error, got none %s", err) 124 | } 125 | if !strings.Contains(err.Error(), ErrWrite.Error()) { 126 | t.Fatalf("got an error, but the wrong one") 127 | } 128 | } 129 | 130 | // TestWriteClose tests what happens when a file Close method fails. 131 | func TestWriteClose(t *testing.T) { 132 | 133 | opener = func(name string, flag int, perm os.FileMode) (File, error) { 134 | return &mockFile{failClose: true}, nil 135 | } 136 | 137 | // Create a printer-output file 138 | file, err := os.CreateTemp("", "tst-*.prn") 139 | if err != nil { 140 | t.Fatalf("failed to create temporary file") 141 | } 142 | defer os.Remove(file.Name()) 143 | 144 | // Create a new CP/M helper - valid 145 | var obj *CPM 146 | obj, err = New(WithPrinterPath(file.Name())) 147 | if err != nil { 148 | t.Fatalf("failed to create CPM") 149 | } 150 | 151 | if obj.prnPath != file.Name() { 152 | t.Fatalf("unexpected filename for printer log") 153 | } 154 | 155 | // Now output a character, which we expect to fail. 156 | err = obj.prnC('s') 157 | if err == nil { 158 | t.Fatalf("expected error, got none %s", err) 159 | } 160 | if !strings.Contains(err.Error(), ErrClose.Error()) { 161 | t.Fatalf("got an error, but the wrong one %v", err) 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /dist/LIHOUSE.COM: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skx/cpmulator/356af0594e8822ca8dc1d5405f1c623e31b2abe2/dist/LIHOUSE.COM -------------------------------------------------------------------------------- /dist/README.md: -------------------------------------------------------------------------------- 1 | # CP/M Binaries 2 | 3 | This directory contains a single binary which is used to test the emulator: 4 | 5 | * [The lighthouse of Doom](https://github.com/skx/lighthouse-of-doom/) 6 | 7 | Other CP/M code within the repository includes: 8 | 9 | * The top-level [samples/](../samples/) directory contains some code which was useful to me to test my understanding, when writing the emulator. 10 | * The top-level [static/](../static/) directory contains some binaries which are always available when launching our emulator. 11 | 12 | 13 | 14 | ## Other Binaries 15 | 16 | There is a curated collection of binaries known to work with cpmulator in our sister repository: 17 | 18 | * https://github.com/skx/cpm-dist 19 | 20 | This collection includes Turbo Pascal, several BASIC interpreters, and various games from InfoCom including Zork 1, 2, and 3. 21 | -------------------------------------------------------------------------------- /fcb/fcb.go: -------------------------------------------------------------------------------- 1 | // Package fcb contains helpers for reading, writing, and working with the CP/M FCB structure. 2 | package fcb 3 | 4 | import ( 5 | "log/slog" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | "unicode" 10 | ) 11 | 12 | // SIZE contains the size of the FCB structure 13 | var SIZE = 36 14 | 15 | // FCB is a structure which is used to hold details about file entries, although 16 | // later versions of CP/M support directories we do not. 17 | // 18 | // We largely focus upon Name, Type, and the various read/write offsets. Most of 19 | // the other fields are maintained but ignored. 20 | type FCB struct { 21 | // Drive holds the drive letter for this entry. 22 | Drive uint8 23 | 24 | // Name holds the name of the file. 25 | Name [8]uint8 26 | 27 | // Type holds the suffix. 28 | Type [3]uint8 29 | 30 | // Ex holds the logical extent. 31 | Ex uint8 32 | 33 | // S1 is reserved, and ignored. 34 | S1 uint8 35 | 36 | // S2 is reserved, and ignored. 37 | S2 uint8 38 | 39 | // RC holds the record count. 40 | // (i.e. The size of the file in 128-byte records.) 41 | RC uint8 42 | 43 | // Allocation map, ignored. 44 | Al [16]uint8 45 | 46 | // Cr holds the current record offset. 47 | Cr uint8 48 | 49 | // R0, holds part of the random-record offset. 50 | R0 uint8 51 | 52 | // R1 holds part of the random-record offset. 53 | R1 uint8 54 | 55 | // R2 holds part of the random-record offset. 56 | R2 uint8 57 | } 58 | 59 | // Find is the structure which is returned for files found via FindFirst / FindNext. 60 | // 61 | // This structure exists to make it easy for us to work with both the path on the host, 62 | // and the path within the CP/M disk. Specifically we need to populate the size of 63 | // files when we return their FCB entries from either call - and that means we need 64 | // access to the host filesystem (i.e. cope when directories are used to represent 65 | // drives). 66 | type Find struct { 67 | // Host is the location on the host for the file. 68 | // This might refer to the current directory, or a drive-based sub-directory. 69 | Host string 70 | 71 | // Name is the name as CP/M would see it. 72 | // This will be upper-cased and in 8.3 format. 73 | Name string 74 | } 75 | 76 | // GetName returns the name component of an FCB entry. 77 | func (f *FCB) GetName() string { 78 | t := "" 79 | 80 | for _, c := range f.Name { 81 | if c != 0x00 { 82 | t += string(c) 83 | } 84 | } 85 | return strings.TrimSpace(t) 86 | } 87 | 88 | // GetType returns the type/extension component of an FCB entry. 89 | // 90 | // If the extension is null, or empty, we return the empty string. 91 | func (f *FCB) GetType() string { 92 | t := "" 93 | 94 | for _, c := range f.Type { 95 | if unicode.IsPrint(rune(c)) { 96 | t += string(c) 97 | } else { 98 | t += " " 99 | } 100 | } 101 | return t 102 | } 103 | 104 | // GetFileName returns the name and suffix, but importantly it removes 105 | // any trailing spaces. 106 | func (f *FCB) GetFileName() string { 107 | name := f.GetName() 108 | ext := f.GetType() 109 | 110 | if ext != "" && ext != " " { 111 | name += "." 112 | name += ext 113 | } 114 | 115 | return strings.TrimSpace(name) 116 | } 117 | 118 | // GetCacheKey returns a string which can be used for caching this 119 | // object in some way - it's the name of the file, as seen by the 120 | // CP/M system. 121 | func (f *FCB) GetCacheKey() string { 122 | t := "" 123 | 124 | // Name 125 | for _, c := range f.Name { 126 | if unicode.IsPrint(rune(c)) { 127 | t += string(c) 128 | } else { 129 | t += " " 130 | } 131 | } 132 | 133 | // Suffix 134 | for _, c := range f.Type { 135 | if unicode.IsPrint(rune(c)) { 136 | t += string(c) 137 | } else { 138 | t += " " 139 | } 140 | } 141 | return t 142 | 143 | } 144 | 145 | // AsBytes returns the entry of the FCB in a format suitable 146 | // for copying to RAM. 147 | func (f *FCB) AsBytes() []uint8 { 148 | 149 | var r []uint8 150 | 151 | r = append(r, f.Drive) 152 | r = append(r, f.Name[:]...) 153 | r = append(r, f.Type[:]...) 154 | r = append(r, f.Ex) 155 | r = append(r, f.S1) 156 | r = append(r, f.S2) 157 | r = append(r, f.RC) 158 | r = append(r, f.Al[:]...) 159 | r = append(r, f.Cr) 160 | r = append(r, f.R0) 161 | r = append(r, f.R1) 162 | r = append(r, f.R2) 163 | 164 | return r 165 | } 166 | 167 | // UpdateSequentialOffset updates the offset used for sequential reads/writes 168 | // to use the given value. 169 | func (f *FCB) UpdateSequentialOffset(offset int64) { 170 | seqCR := func(n int64) int64 { 171 | return (((n) % 16384) / 128) 172 | } 173 | 174 | seqExtent := func(n int64) int64 { 175 | return n / 16384 176 | } 177 | 178 | seqEx := func(n int64) int64 { 179 | return (seqExtent(n) % 32) 180 | } 181 | 182 | seqS2 := func(n int64) int64 { 183 | return (seqExtent(n) / 32) 184 | } 185 | 186 | f.Cr = uint8(seqCR(offset)) 187 | f.Ex = uint8(seqEx(offset)) 188 | f.S2 = uint8((0x80 | seqS2(offset))) 189 | 190 | // confirm this works 191 | x := f.GetSequentialOffset() 192 | if x != offset { 193 | slog.Error("updating the sequential offset failed", 194 | slog.Int64("expected", offset), 195 | slog.Int64("real", x)) 196 | } 197 | } 198 | 199 | // GetSequentialOffset returns the offset the FCB contains for 200 | // the sequential read/write calls - as used by the BDOS functions 201 | // F_READ and F_WRITE. 202 | // 203 | // IncreaseSequentialOffset updates the value. 204 | func (f *FCB) GetSequentialOffset() int64 { 205 | 206 | // Helpers 207 | BlkS2 := 4096 208 | BlkEx := 128 209 | MaxS2 := 15 210 | blkSize := 128 211 | 212 | offset := int64((int(f.S2)&MaxS2)*BlkS2*blkSize + 213 | int(f.Ex)*BlkEx*blkSize + 214 | int(f.Cr)*blkSize) 215 | return offset 216 | } 217 | 218 | // IncreaseSequentialOffset updates the read/write offset which 219 | // would be used for the sequential read functions. 220 | func (f *FCB) IncreaseSequentialOffset() { 221 | 222 | MaxCR := 128 223 | MaxEX := 31 224 | 225 | f.S2 &= 0x7F // reset unmodified flag 226 | f.Cr++ 227 | if int(f.Cr) > MaxCR { 228 | f.Cr = 1 229 | f.Ex++ 230 | } 231 | if int(f.Ex) > MaxEX { 232 | f.Ex = 0 233 | f.S2++ 234 | } 235 | } 236 | 237 | // FromString returns an FCB entry from the given string. 238 | // 239 | // This is currently just used for processing command-line arguments. 240 | func FromString(str string) FCB { 241 | 242 | // Return value 243 | tmp := FCB{} 244 | 245 | // Filenames are always upper-case 246 | str = strings.ToUpper(str) 247 | 248 | // Does the string have a drive-prefix? 249 | if len(str) > 2 && str[1] == ':' { 250 | tmp.Drive = str[0] - 'A' 251 | str = str[2:] 252 | } else { 253 | tmp.Drive = 0x00 254 | } 255 | 256 | // Suffix defaults to " " 257 | copy(tmp.Type[:], " ") 258 | 259 | // Now we have to parse the string. 260 | // 261 | // 1. is there a suffix? 262 | parts := strings.Split(str, ".") 263 | 264 | // No suffix? 265 | if len(parts) == 1 { 266 | t := "" 267 | 268 | // pad the value 269 | name := parts[0] 270 | for len(name) < 8 { 271 | name += " " 272 | } 273 | 274 | // process to change "*" to "????" 275 | for _, c := range name { 276 | if c == '*' { 277 | t += "?????????" 278 | break 279 | } else { 280 | t += string(c) 281 | } 282 | } 283 | 284 | // Copy the result into place, noting that copy will truncate 285 | copy(tmp.Name[:], t) 286 | } 287 | if len(parts) == 2 { 288 | t := "" 289 | 290 | // pad the value 291 | name := parts[0] 292 | for len(name) < 8 { 293 | name += " " 294 | } 295 | 296 | // process to change "*" to "????" 297 | for _, c := range name { 298 | if c == '*' { 299 | t += "?????????" 300 | break 301 | } else { 302 | t += string(c) 303 | } 304 | } 305 | 306 | // Copy the result into place, noting that copy will truncate 307 | copy(tmp.Name[:], t) 308 | 309 | // pad the value 310 | ext := parts[1] 311 | for len(ext) < 3 { 312 | ext += " " 313 | } 314 | 315 | // process to change "*" to "????" 316 | t = "" 317 | for _, c := range ext { 318 | if c == '*' { 319 | t += "???" 320 | break 321 | } else { 322 | t += string(c) 323 | } 324 | } 325 | 326 | // Copy the result into place, noting that copy will truncate 327 | copy(tmp.Type[:], t) 328 | } 329 | 330 | return tmp 331 | } 332 | 333 | // FromBytes returns an FCB entry from the given bytes 334 | func FromBytes(bytes []uint8) FCB { 335 | // Return value 336 | tmp := FCB{} 337 | 338 | tmp.Drive = bytes[0] 339 | copy(tmp.Name[:], bytes[1:]) 340 | copy(tmp.Type[:], bytes[9:]) 341 | tmp.Ex = bytes[12] 342 | tmp.S1 = bytes[13] 343 | tmp.S2 = bytes[14] 344 | tmp.RC = bytes[15] 345 | copy(tmp.Al[:], bytes[16:]) 346 | tmp.Cr = bytes[32] 347 | tmp.R0 = bytes[33] 348 | tmp.R1 = bytes[34] 349 | tmp.R2 = bytes[35] 350 | 351 | return tmp 352 | } 353 | 354 | // DoesMatch returns true if the filename specified matches the pattern in the FCB. 355 | func (f *FCB) DoesMatch(name string) bool { 356 | 357 | // If the file doesn't have a dot then it can't be visible if it is too long 358 | if len(name) > 8 && !strings.Contains(name, ".") { 359 | return false 360 | } 361 | 362 | // Having a .extension is fine, but if the 363 | // suffix is longer than three characters we're 364 | // not going to use it. 365 | parts := strings.Split(name, ".") 366 | if len(parts) == 2 { 367 | // filename is over 8 characters 368 | if len(parts[0]) > 8 { 369 | return false 370 | } 371 | // suffix is over 3 characters 372 | if len(parts[1]) > 3 { 373 | return false 374 | } 375 | } 376 | 377 | // Create a temporary FCB for the specified filename. 378 | tmp := FromString(name) 379 | 380 | // Now test if the name we've got matches that in the 381 | // search-pattern: Name. 382 | // 383 | // Either a literal match, or a wildcard match with "?". 384 | for i, c := range f.Name { 385 | if (tmp.Name[i] != c) && (f.Name[i] != '?') { 386 | return false 387 | } 388 | } 389 | 390 | // Repeat for the suffix. 391 | for i, c := range f.Type { 392 | if (tmp.Type[i] != c) && (f.Type[i] != '?') { 393 | return false 394 | } 395 | } 396 | 397 | // Got a match 398 | return true 399 | } 400 | 401 | // GetMatches returns the files matching the pattern in the given FCB record. 402 | // 403 | // We try to do this by converting the entries of the named directory into FCBs 404 | // after ignoring those with impossible formats - i.e. not FILENAME.EXT length. 405 | func (f *FCB) GetMatches(prefix string) ([]Find, error) { 406 | var ret []Find 407 | 408 | // Find files in the directory 409 | files, err := os.ReadDir(prefix) 410 | if err != nil { 411 | return ret, err 412 | } 413 | 414 | // For each file 415 | for _, file := range files { 416 | 417 | // Ignore directories, we only care about files. 418 | if file.IsDir() { 419 | continue 420 | } 421 | 422 | name := strings.ToUpper(file.Name()) 423 | if f.DoesMatch(name) { 424 | 425 | var ent Find 426 | 427 | // Populate the host-path before we do anything else. 428 | ent.Host = filepath.Join(prefix, file.Name()) 429 | 430 | // populate the name, but note it needs to be upper-cased 431 | ent.Name = name 432 | 433 | // append 434 | ret = append(ret, ent) 435 | } 436 | } 437 | 438 | // Return the entries we found, if any. 439 | return ret, nil 440 | } 441 | -------------------------------------------------------------------------------- /fcb/fcb_test.go: -------------------------------------------------------------------------------- 1 | package fcb 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | // TestFCBSize ensures our size matches expectations. 9 | func TestFCBSize(t *testing.T) { 10 | x := FromString("blah") 11 | b := x.AsBytes() 12 | 13 | if len(b) != 36 { 14 | t.Fatalf("FCB struct is %d bytes", len(b)) 15 | } 16 | 17 | if x.GetFileName() != "BLAH" { 18 | t.Fatalf("wrong name returned, got %v", x.GetFileName()) 19 | } 20 | } 21 | 22 | // Test we can convert an FCB to bytes, and back, without losing data in the round-trip. 23 | func TestCopy(t *testing.T) { 24 | f1 := FromString("blah") 25 | copy(f1.Al[:], "0123456789abcdef") 26 | f1.Ex = 'X' 27 | f1.S1 = 'S' 28 | f1.S2 = '?' 29 | f1.RC = 'f' 30 | f1.R0 = 'R' 31 | f1.R1 = '0' 32 | f1.R2 = '1' 33 | f1.Cr = '*' 34 | b := f1.AsBytes() 35 | 36 | f2 := FromBytes(b) 37 | if fmt.Sprintf("%s", f2.Al) != "0123456789abcdef" { 38 | t.Fatalf("copy failed") 39 | } 40 | if f2.Ex != 'X' { 41 | t.Fatalf("copy failed") 42 | } 43 | if f2.S1 != 'S' { 44 | t.Fatalf("copy failed") 45 | } 46 | if f2.Cr != '*' { 47 | t.Fatalf("copy failed") 48 | } 49 | if f2.S2 != '?' { 50 | t.Fatalf("copy failed") 51 | } 52 | if f2.RC != 'f' { 53 | t.Fatalf("copy failed") 54 | } 55 | if f2.R0 != 'R' { 56 | t.Fatalf("copy failed") 57 | } 58 | if f2.R1 != '0' { 59 | t.Fatalf("copy failed") 60 | } 61 | if f2.R2 != '1' { 62 | t.Fatalf("copy failed") 63 | } 64 | 65 | } 66 | 67 | // TestFCBFromString is a trivial test to only cover the basics right now. 68 | func TestFCBFromString(t *testing.T) { 69 | 70 | // Simple test to ensure the basic one works. 71 | f := FromString("b:foo") 72 | if f.Drive != 1 { 73 | t.Fatalf("drive wrong") 74 | } 75 | if f.GetName() != "FOO" { 76 | t.Fatalf("name wrong, got '%v'", f.GetName()) 77 | } 78 | if f.GetType() != " " { 79 | t.Fatalf("unexpected suffix '%v'", f.GetType()) 80 | } 81 | if f.GetCacheKey() != "FOO " { 82 | t.Fatalf("name wrong, got '%v'", f.GetCacheKey()) 83 | } 84 | 85 | // Try a long name, to confirm it is truncated 86 | f = FromString("c:this-is-a-long-name") 87 | if f.Drive != 2 { 88 | t.Fatalf("drive wrong") 89 | } 90 | if f.GetName() != "THIS-IS-" { 91 | t.Fatalf("name wrong, got '%v'", f.GetName()) 92 | } 93 | if f.GetType() != " " { 94 | t.Fatalf("unexpected suffix '%v'", f.GetType()) 95 | } 96 | 97 | // Try a long suffix, to confirm it is truncated 98 | f = FromString("c:this-is-a-.long-name") 99 | if f.Drive != 2 { 100 | t.Fatalf("drive wrong") 101 | } 102 | if f.GetName() != "THIS-IS-" { 103 | t.Fatalf("name wrong, got '%v'", f.GetName()) 104 | } 105 | if f.GetType() != "LON" { 106 | t.Fatalf("unexpected suffix '%v'", f.GetType()) 107 | } 108 | if f.GetFileName() != "THIS-IS-.LON" { 109 | t.Fatalf("wrong name returned, got %v", f.GetFileName()) 110 | } 111 | if f.GetCacheKey() != "THIS-IS-LON" { 112 | t.Fatalf("wrong cache returned, got %v", f.GetCacheKey()) 113 | } 114 | 115 | // wildcard 116 | f = FromString("c:steve*.*") 117 | if f.Drive != 2 { 118 | t.Fatalf("drive wrong") 119 | } 120 | if f.GetName() != "STEVE???" { 121 | t.Fatalf("name wrong, got '%v'", f.GetName()) 122 | } 123 | if f.GetType() != "???" { 124 | t.Fatalf("type wrong, got '%v'", f.GetName()) 125 | } 126 | 127 | f = FromString("c:test.C*") 128 | if f.Drive != 2 { 129 | t.Fatalf("drive wrong") 130 | } 131 | if f.GetName() != "TEST" { 132 | t.Fatalf("name wrong, got '%v'", f.GetName()) 133 | } 134 | if f.GetType() != "C??" { 135 | t.Fatalf("name wrong, got '%v'", f.GetName()) 136 | } 137 | 138 | f = FromString("") 139 | f.Name[0] = 0x00 140 | f.Name[1] = 0x01 141 | f.Type[0] = 0x00 142 | f.Type[1] = 0x01 143 | if f.GetCacheKey() != " " { 144 | t.Fatalf("wrong cache returned, got %v", f.GetCacheKey()) 145 | } 146 | 147 | } 148 | 149 | func TestDoesMatch(t *testing.T) { 150 | 151 | type testcase struct { 152 | // pattern contains a pattern 153 | pattern string 154 | 155 | // yes contains a list of filenames that should match that pattern 156 | yes []string 157 | 158 | // no contains a list of filenames that should NOT match that pattern 159 | no []string 160 | } 161 | 162 | tests := []testcase{ 163 | { 164 | pattern: "*.com", 165 | yes: []string{"A.COM", "B:FOO.COM"}, 166 | no: []string{"A", "BOB", "C.GO"}, 167 | }, 168 | { 169 | pattern: "A*", 170 | yes: []string{"ANIMAL", "B:AUGUST"}, 171 | no: []string{"ANIMAL.COM", "BOB", "AURORA.COM"}, 172 | }, 173 | { 174 | pattern: "A*.*", 175 | yes: []string{"ANIMAL.com", "B:AUGUST.com", "AURORA"}, 176 | no: []string{"Test", "BOB"}, 177 | }, 178 | } 179 | 180 | for _, test := range tests { 181 | 182 | f := FromString(test.pattern) 183 | 184 | for _, ei := range test.no { 185 | 186 | if f.DoesMatch(ei) { 187 | t.Fatalf("file %s matched pattern %s and it should not have done", ei, test.pattern) 188 | } 189 | } 190 | 191 | for _, joo := range test.yes { 192 | 193 | if !f.DoesMatch(joo) { 194 | t.Fatalf("file %s did not match pattern %s and it should have done", joo, test.pattern) 195 | } 196 | } 197 | } 198 | } 199 | 200 | // TestGetMatches ensures we can use our matcher. 201 | func TestGetMatches(t *testing.T) { 202 | 203 | f := FromString("*.GO") 204 | 205 | out, err := f.GetMatches("..") 206 | if err != nil { 207 | t.Fatalf("failed to get matches") 208 | } 209 | 210 | if len(out) != 1 { 211 | t.Fatalf("unexpected number of matches") 212 | } 213 | if out[0].Host != "../main.go" { 214 | t.Fatalf("unexpected name %s", out[0].Host) 215 | } 216 | 217 | _, err = f.GetMatches("!>>//path/not/found") 218 | if err == nil { 219 | t.Fatalf("expected error on bogus directory, got none") 220 | } 221 | } 222 | 223 | // TestOffset does a trivial test that increases go in steps of 128 224 | func TestOffset(t *testing.T) { 225 | 226 | f := FromString("test") 227 | 228 | // before 229 | cur := f.GetSequentialOffset() 230 | if cur != 0 { 231 | t.Fatalf("unexpected initial offset") 232 | } 233 | 234 | // bump 235 | f.IncreaseSequentialOffset() 236 | 237 | // after 238 | after := f.GetSequentialOffset() 239 | if after == 0 { 240 | t.Fatalf("unexpected offset after increase") 241 | } 242 | 243 | // Should have gone up by 128 244 | if after-128 != cur { 245 | t.Fatalf("offset should rise by 128") 246 | } 247 | 248 | // Do a bunch more increases 249 | remain := 128 * 128 250 | for remain > 0 { 251 | f.IncreaseSequentialOffset() 252 | remain-- 253 | } 254 | 255 | if f.GetSequentialOffset()%128 != 0 { 256 | t.Fatalf("weird remainder - we should rise in 128-steps") 257 | } 258 | 259 | } 260 | 261 | // TestSuffix ensures that the non-printable extensions are replaced with spaces, as expected. 262 | func TestSuffix(t *testing.T) { 263 | 264 | b := make([]byte, 128) 265 | f := FromBytes(b) 266 | 267 | typ := f.GetType() 268 | if typ != " " { 269 | t.Fatalf("type was weird '%s'", typ) 270 | } 271 | } 272 | 273 | // TestIssue238 tests that #238 is closed - files that were too 274 | // long were showing up. 275 | func TestIssue238(t *testing.T) { 276 | 277 | f := FromString("*.*") 278 | 279 | if f.DoesMatch("DOCKERFILE") { 280 | t.Fatalf("Dockerfile showed up, and it shouldn't have done.") 281 | } 282 | if f.DoesMatch("cpmulator") { 283 | t.Fatalf("Ourself showed up, and it shouldn't have done.") 284 | } 285 | 286 | } 287 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/skx/cpmulator 2 | 3 | go 1.21.5 4 | 5 | require ( 6 | github.com/koron-go/z80 v0.10.1 7 | golang.org/x/term v0.28.0 8 | ) 9 | 10 | require ( 11 | github.com/nsf/termbox-go v1.1.1 12 | golang.org/x/sys v0.29.0 13 | ) 14 | 15 | require ( 16 | github.com/mattn/go-runewidth v0.0.16 // indirect 17 | github.com/rivo/uniseg v0.4.7 // indirect 18 | ) 19 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 2 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 3 | github.com/koron-go/z80 v0.10.1 h1:Jfb0esP/QFL4cvcr+eFECVG0Y/mA9JBLC4EKbMU5zAY= 4 | github.com/koron-go/z80 v0.10.1/go.mod h1:ry+Zl9kRKelzaDG9UzEtUpUnXy0Yv/kk1YEaX958xdk= 5 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 6 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 7 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 8 | github.com/nsf/termbox-go v1.1.1 h1:nksUPLCb73Q++DwbYUBEglYBRPZyoXJdrj5L+TkjyZY= 9 | github.com/nsf/termbox-go v1.1.1/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo= 10 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 11 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 12 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 13 | golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= 14 | golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 15 | golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= 16 | golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= 17 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | // Integration tests :) 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/skx/cpmulator/consoleout" 13 | "github.com/skx/cpmulator/cpm" 14 | ) 15 | 16 | // TestDriveChange ensures the drive-letter changes after 17 | // changing drives. 18 | func TestDriveChange(t *testing.T) { 19 | 20 | t.Setenv("BDOS_ADDRESS", "0xb000") 21 | t.Setenv("BIOS_ADDRESS", "0xbf00") 22 | 23 | obj, err := cpm.New(cpm.WithOutputDriver("logger")) 24 | if err != nil { 25 | t.Fatalf("Create CP/M failed") 26 | } 27 | 28 | // Load the CCP binary - resetting RAM in the process. 29 | err = obj.LoadCCP() 30 | if err != nil { 31 | t.Fatalf("load CCP failed") 32 | } 33 | 34 | obj.SetDrives(false) 35 | 36 | obj.StuffText("C:\r\nEXIT\r\n") 37 | // Run it 38 | err = obj.Execute([]string{}) 39 | if err != nil && err != cpm.ErrHalt { 40 | t.Fatalf("failed to run: %s", err) 41 | } 42 | 43 | // Get our output handle 44 | helper := obj.GetOutputDriver() 45 | l, ok := helper.(*consoleout.OutputLoggingDriver) 46 | if !ok { 47 | t.Fatalf("failed to cast output driver") 48 | } 49 | 50 | // Get output written to the screen, and remove newlines 51 | out := l.GetOutput() 52 | out = strings.ReplaceAll(out, "\n", "") 53 | out = strings.ReplaceAll(out, "\r", "") 54 | if out != `A>C>C>` { 55 | t.Fatalf("unexpected output '%v'", out) 56 | } 57 | 58 | // Reset the text - confirm it is now empty 59 | l.Reset() 60 | if l.GetOutput() != "" { 61 | t.Fatalf("resetting our history didn't work") 62 | } 63 | } 64 | 65 | // TestReadWriteRand invokes our help-samples to read/write 66 | // records - via the external API. 67 | func TestReadWriteRand(t *testing.T) { 68 | 69 | t.Setenv("BDOS_ADDRESS", "0xb000") 70 | t.Setenv("BIOS_ADDRESS", "0xbf00") 71 | 72 | obj, err := cpm.New() 73 | if err != nil { 74 | t.Fatalf("Create CP/M failed") 75 | } 76 | 77 | // Load the CCP binary - resetting RAM in the process. 78 | err = obj.LoadCCP() 79 | if err != nil { 80 | t.Fatalf("load CCP failed") 81 | } 82 | 83 | obj.SetDrives(false) 84 | obj.SetDrivePath("A", "samples/") 85 | obj.StuffText("WRITE foo\nREAD foo\nEXIT\n") 86 | 87 | // Run it 88 | err = obj.Execute([]string{}) 89 | if err != nil && err != cpm.ErrBoot { 90 | t.Fatalf("failed to run: %s", err) 91 | } 92 | 93 | // Remove the generated file 94 | os.Remove(filepath.Join("samples", "FOO")) 95 | } 96 | 97 | // TestCompleteLighthouse plays our Lighthouse game, to completion. 98 | // 99 | // It uses the fast/hacky solution rather than the slow/normal/real one 100 | // just to cut down on the scripting. 101 | // 102 | // However it is a great test to see that things work as expected. 103 | func TestCompleteLighthouse(t *testing.T) { 104 | 105 | t.Setenv("BDOS_ADDRESS", "0xb000") 106 | t.Setenv("BIOS_ADDRESS", "0xbf00") 107 | 108 | obj, err := cpm.New(cpm.WithOutputDriver("logger")) 109 | if err != nil { 110 | t.Fatalf("Create CP/M failed") 111 | } 112 | 113 | // Load the CCP binary - resetting RAM in the process. 114 | err = obj.LoadCCP() 115 | if err != nil { 116 | t.Fatalf("load CCP failed") 117 | } 118 | 119 | obj.SetDrives(false) 120 | obj.SetDrivePath("A", "dist/") 121 | obj.StuffText("\nLIHOUSE\nAAAA\ndown\nEXAMINE DESK\nTAKE METEOR\nUP\n\nn\nquit\n") 122 | 123 | // Run it 124 | err = obj.Execute([]string{}) 125 | if err != nil && err != cpm.ErrBoot { 126 | t.Fatalf("failed to run: %s", err) 127 | } 128 | 129 | // Get our output handle 130 | helper := obj.GetOutputDriver() 131 | l, ok := helper.(*consoleout.OutputLoggingDriver) 132 | if !ok { 133 | t.Fatalf("failed to cast output driver") 134 | } 135 | 136 | // Get the text written to the screen 137 | out := l.GetOutput() 138 | 139 | fmt.Printf("\n\nOUTPUT: %s\n\n", out) 140 | // Ensure the game was completed - easy path. 141 | if !strings.Contains(out, "Congratulations") { 142 | t.Fatalf("failed to win") 143 | } 144 | if !strings.Contains(out, "You won") { 145 | t.Fatalf("failed to win") 146 | } 147 | 148 | // Reset the text - confirm it is now empty 149 | l.Reset() 150 | if l.GetOutput() != "" { 151 | t.Fatalf("resetting our history didn't work") 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /memory/memory.go: -------------------------------------------------------------------------------- 1 | // Package memory is a package that provides the 64k of RAM 2 | // within which the emulator executes its programs. 3 | package memory 4 | 5 | import "os" 6 | 7 | // Memory is our structure for representing the 64k of RAM 8 | // that we run our programs within. 9 | type Memory struct { 10 | buf [65536]uint8 11 | } 12 | 13 | // FillRange fills an area of memory with the given byte 14 | func (m *Memory) FillRange(addr uint16, size int, char uint8) { 15 | for size > 0 { 16 | m.buf[addr] = char 17 | addr++ 18 | size-- 19 | } 20 | } 21 | 22 | // Get returns a byte at addr of memory. 23 | func (m *Memory) Get(addr uint16) uint8 { 24 | return m.buf[addr] 25 | } 26 | 27 | // GetRange returns the contents of a given range 28 | func (m *Memory) GetRange(addr uint16, size int) []uint8 { 29 | var ret []uint8 30 | for size > 0 { 31 | ret = append(ret, m.buf[addr]) 32 | addr++ 33 | size-- 34 | } 35 | return ret 36 | } 37 | 38 | // GetU16 returns a word from the given address of memory. 39 | func (m *Memory) GetU16(addr uint16) uint16 { 40 | l := m.Get(addr) 41 | h := m.Get(addr + 1) 42 | return (uint16(h) << 8) | uint16(l) 43 | } 44 | 45 | // LoadFile loads a file into the RAM, at the specified offset. 46 | // 47 | // Before loading the file all memory is filled with 0x00 (NOP). 48 | func (m *Memory) LoadFile(offset uint16, name string) error { 49 | 50 | // Fill the 64k with NOP instructions 51 | for i := range m.buf { 52 | m.buf[i] = 0x00 53 | } 54 | 55 | // Load the binary 56 | prog, err := os.ReadFile(name) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | // Put it into the starting locatioe. 62 | m.SetRange(offset, prog...) 63 | 64 | return nil 65 | } 66 | 67 | // Set sets a byte at addr of memory. 68 | func (m *Memory) Set(addr uint16, value uint8) { 69 | m.buf[addr] = value 70 | } 71 | 72 | // SetRange copies bytes from the given data to the specified 73 | // starting address in RAM. 74 | func (m *Memory) SetRange(addr uint16, data ...uint8) { 75 | copy(m.buf[int(addr):int(addr)+len(data)], data) 76 | } 77 | -------------------------------------------------------------------------------- /memory/memory_test.go: -------------------------------------------------------------------------------- 1 | package memory 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | // TestMemoryTrivial just does basic get/set tests 9 | func TestMemoryTrivial(t *testing.T) { 10 | 11 | mem := new(Memory) 12 | 13 | // Set 14 | mem.Set(0x00, 0x01) 15 | mem.Set(0x01, 0x02) 16 | 17 | // Get 18 | if mem.Get(0x00) != 0x01 { 19 | t.Fatalf("failed to get expected result") 20 | } 21 | if mem.Get(0x01) != 0x02 { 22 | t.Fatalf("failed to get expected result") 23 | } 24 | // GetU16 25 | if mem.GetU16(0x00) != 0x0201 { 26 | t.Fatalf("failed to get expected result") 27 | } 28 | 29 | // Fill with 0xCD 30 | mem.FillRange(0x00, 0xFFFF, 0xCD) 31 | 32 | if mem.Get(0xFFFE) != 0xCD { 33 | t.Fatalf("failed to get expected result") 34 | } 35 | // GetU16 36 | if mem.GetU16(0x0100) != 0xCDCD { 37 | t.Fatalf("failed to get expected result") 38 | } 39 | 40 | // Get a random range 41 | out := mem.GetRange(0x300, 0x00FF) 42 | for _, d := range out { 43 | if d != 0xCD { 44 | t.Fatalf("wrong result in GetRange") 45 | } 46 | } 47 | 48 | // Put a (small) range 49 | out = []uint8{0x01, 0x02, 0x03} 50 | mem.SetRange(0x0000, out[:]...) 51 | 52 | if mem.Get(0x00) != 0x01 { 53 | t.Fatalf("failed to get expected result") 54 | } 55 | if mem.Get(0x01) != 0x02 { 56 | t.Fatalf("failed to get expected result") 57 | } 58 | // GetU16 59 | if mem.GetU16(0x00) != 0x0201 { 60 | t.Fatalf("failed to get expected result") 61 | } 62 | if mem.GetU16(0x02) != 0xCD03 { 63 | t.Fatalf("failed to get expected result") 64 | } 65 | } 66 | 67 | // TestLoadFile ensures we can load a file 68 | func TestLoadFile(t *testing.T) { 69 | 70 | // Create memory 71 | mem := new(Memory) 72 | 73 | err := mem.LoadFile(0, "/this/file-does/not/exist") 74 | if err == nil { 75 | t.Fatalf("expected error, got none") 76 | } 77 | 78 | // Now write out a temporary file, with static contents. 79 | var file *os.File 80 | file, err = os.CreateTemp("", "tst-*.mem") 81 | if err != nil { 82 | t.Fatalf("failed to create temporary file") 83 | } 84 | defer os.Remove(file.Name()) 85 | 86 | // Write some known-text to the file 87 | _, err = file.WriteString("Steve Kemp") 88 | if err != nil { 89 | t.Fatalf("failed to write program to temporary file") 90 | } 91 | 92 | // Close the file 93 | file.Close() 94 | 95 | // Load the file 96 | err = mem.LoadFile(0, file.Name()) 97 | if err != nil { 98 | t.Errorf("failed to load file") 99 | } 100 | 101 | // Confirm the contents are OK 102 | x := "Steve Kemp" 103 | for i, c := range x { 104 | chr := mem.Get(uint16(i)) 105 | if string(chr) != string(c) { 106 | t.Fatalf("RAM had wrong contents at %d: %c != %c\n", i, c, chr) 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /samples/Makefile: -------------------------------------------------------------------------------- 1 | 2 | # 3 | # The files we wish to generate. 4 | # 5 | all: cli-args.com create.com \ 6 | delete.com filesize.com find.com \ 7 | intest.com read.com ret.com tsize.com \ 8 | write.com 9 | 10 | 11 | # 12 | # Cleanup 13 | # 14 | clean: 15 | rm *.com 16 | 17 | 18 | # 19 | # Convert an .asm file into a .com file, via pasmo. 20 | # 21 | %.com: %.z80 22 | pasmo "$<" "$@" 23 | -------------------------------------------------------------------------------- /samples/README.md: -------------------------------------------------------------------------------- 1 | # Sample CP/M Binaries 2 | 3 | This directory contains some sample code, written in Z80 assembly, which was useful in testing my understanding when writing the emulator. 4 | 5 | Other CP/M code within the repository includes: 6 | 7 | * The top-level [dist/](../dist) directory contains a complete program used to test the emulator. 8 | * The top-level [static/](../static/) directory contains some binaries which are always available when launching our emulator. 9 | 10 | More significant programs are available within the sister-repository: 11 | 12 | * https://github.com/skx/cpm-dist 13 | 14 | 15 | 16 | ## Contents 17 | 18 | * [cli-args.z80](cli-args.z80) 19 | * Shows command-line arguments passed to binaries launched from CCP 20 | * [create.z80](create.z80) 21 | * Create a file, given the name. 22 | * [delete.z80](delete.z80) 23 | * Delete files matching the given name/pattern. 24 | * [filesize.z80](filesize.z80) 25 | * Show the size of the given file. 26 | * [find.z80](find.z80) 27 | * Find files via their names. 28 | * [intest.z80](intest.z80) 29 | * Test the various character/line input methods for correctness. 30 | * [read.z80](read.z80) & [write.z80](write.z80) 31 | * Write populates the named file with 255 records of fixed content. 32 | * Read processes the named file and aborts if the records contain surprising content. 33 | * Used to test sequential read/write operations. 34 | * [ret.z80](ret.z80) 35 | * Terminate the execution of a binary in four different ways. 36 | * [tsize.z80](tsize.z80) 37 | * Show the dimensions of the terminal we're running within. 38 | -------------------------------------------------------------------------------- /samples/cli-args.com: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skx/cpmulator/356af0594e8822ca8dc1d5405f1c623e31b2abe2/samples/cli-args.com -------------------------------------------------------------------------------- /samples/cli-args.z80: -------------------------------------------------------------------------------- 1 | ;; cli-args.z80 - Show the CLI arguments supplied, if any, and default FCBs 2 | 3 | 4 | ; This is where CP/M stores the args. 5 | CMDLINE EQU 80H 6 | 7 | ; Entry point, after the PSP. 8 | ORG 0x0100 9 | 10 | ; show prefix 11 | LD DE,COM1 12 | LD C,0x09 13 | CALL 0x0005 14 | 15 | ; Display the command line (if present). 16 | LD HL,CMDLINE 17 | LD A,(HL) 18 | INC HL 19 | OR A 20 | JR Z,COMDONE 21 | 22 | ; A command line was entered. 23 | ; B has the length of the string. 24 | LD B,A 25 | 26 | COMMORE: 27 | ; Show it character by character 28 | LD E,(HL) 29 | INC HL 30 | PUSH BC 31 | PUSH HL 32 | LD C, 0x02 33 | CALL 0x0005 34 | POP HL 35 | POP BC 36 | DJNZ COMMORE 37 | 38 | COMDONE: 39 | ; Display trailing message 40 | LD DE,COM2 41 | LD C, 0x09 42 | CALL 0x0005 43 | 44 | ; Show first FCB 45 | LD DE,FCB1 46 | LD C,0x09 47 | CALL 0x0005 48 | LD HL, 0x005C ; FCB1 offset 49 | CALL DUMP_FCB 50 | 51 | ; Show second FCB 52 | LD DE,FCB2 53 | LD C,0x09 54 | CALL 0x0005 55 | LD HL,0x006C ; FCB2 offset 56 | CALL DUMP_FCB 57 | 58 | ; Exit 59 | LD C,0x00 60 | CALL 0x0005 61 | 62 | ; Dump the contents of an FCB 63 | ; HL points to the FCB 64 | DUMP_FCB: 65 | PUSH HL 66 | LD A, (HL) 67 | 68 | ;; If the drive is not-zero that means we have an explicit drive 69 | cp 0x00 70 | jr nz, letter_drive 71 | 72 | ;; So the drive is 0x00, which means we're using the current, 73 | ;; or default, drive. Find it. 74 | ld c,25 75 | call 0x0005 76 | 77 | ;; Add one, now we can fall-through to the ASCII conversiion 78 | inc a 79 | 80 | letter_drive: 81 | ; 1 means A, 2 for B, etc 82 | add a,'A' -1 83 | 84 | show_drive: 85 | ;; Show the drive letter 86 | LD E,A 87 | LD C,0x02 88 | CALL 0x0005 89 | 90 | ;; And the ":" 91 | LD E,':' 92 | LD C,0x02 93 | CALL 0x0005 94 | 95 | POP HL 96 | 97 | ; Show the filename which is 11 characters 98 | LD B, 11 99 | CHAR_SHOW: 100 | INC HL 101 | PUSH HL 102 | PUSH BC 103 | LD A, (HL) 104 | LD E, A 105 | LD C, 0x02 106 | CALL 0x0005 107 | POP BC 108 | POP HL 109 | DJNZ CHAR_SHOW 110 | 111 | ; newline 112 | LD E,0x0a 113 | LD C,0x02 114 | CALL 0x0005 115 | ; carriage-return 116 | LD E,0x0d 117 | LD C,0x02 118 | CALL 0x0005 119 | RET 120 | 121 | COM1: 122 | DB "The command-line argument(s) were '$" 123 | COM2: 124 | DB "'", 0x0a, 0x0d, "$" 125 | FCB1: 126 | DB "FCB 01: $" 127 | FCB2: 128 | DB "FCB 02: $" 129 | -------------------------------------------------------------------------------- /samples/create.com: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skx/cpmulator/356af0594e8822ca8dc1d5405f1c623e31b2abe2/samples/create.com -------------------------------------------------------------------------------- /samples/create.z80: -------------------------------------------------------------------------------- 1 | ;; create.asm - Create the named file 2 | 3 | FCB1: EQU 0x5C 4 | 5 | BDOS_ENTRY_POINT: EQU 5 6 | BDOS_OUTPUT_STRING: EQU 9 7 | BDOS_OPEN_FILE: EQU 15 8 | BDOS_MAKE_FILE: EQU 22 9 | 10 | ;; 11 | ;; CP/M programs start at 0x100. 12 | ;; 13 | ORG 100H 14 | 15 | ;; The FCB will be populated with the pattern/first argument, 16 | ;; if the first character of that region is a space-character 17 | ;; then we've got nothing to search for. 18 | ld a, (FCB1 + 1) 19 | cp 0x20 ; 0x20 = 32 == SPACE 20 | jr nz, got_argument ; Not a space, so we can proceed 21 | 22 | ;; 23 | ;; No argument, so show the error and exit 24 | ;; 25 | ld de, usage_message 26 | ld c, BDOS_OUTPUT_STRING 27 | call BDOS_ENTRY_POINT 28 | jr exit_fn 29 | 30 | got_argument: 31 | ;; First of all try to open the file 32 | ;; if this succeeds it means the file 33 | ;; exists, so we cannot create it, and we 34 | ;; must terminate. 35 | call can_open 36 | jr z, already_present 37 | 38 | ;; Now try to create the file. 39 | ld de, FCB1 40 | ld c, BDOS_MAKE_FILE 41 | call BDOS_ENTRY_POINT 42 | 43 | ;; If we can no open it then we created the 44 | ;; file, and all is good. 45 | call can_open 46 | jr nz, failed_create 47 | 48 | exit_fn: 49 | ;; exit 50 | ld c,0x00 51 | call BDOS_ENTRY_POINT 52 | 53 | 54 | ;; Test if we can open the file in the first FCB 55 | can_open: 56 | ;; Open the file 57 | ld de, FCB1 58 | ld c, BDOS_OPEN_FILE 59 | call BDOS_ENTRY_POINT 60 | 61 | ;; Did that succeed? 62 | cp 00 63 | ret 64 | 65 | already_present: 66 | ld de, present_message 67 | ld c, BDOS_OUTPUT_STRING 68 | call BDOS_ENTRY_POINT 69 | jr exit_fn 70 | 71 | failed_create: 72 | ld de, failed_message 73 | ld c, BDOS_OUTPUT_STRING 74 | call BDOS_ENTRY_POINT 75 | jr exit_fn 76 | 77 | ;;; Message area 78 | failed_message: 79 | DB "Failed to create the file.", 0x0a, 0x0d, "$" 80 | present_message: 81 | DB "The file is already present, we cannot create it.", 0x0a, 0x0d, "$" 82 | usage_message: 83 | db "Usage: CREATE FILENAME.EXT", 0xa, 0xd, "$" 84 | 85 | END 86 | -------------------------------------------------------------------------------- /samples/delete.com: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skx/cpmulator/356af0594e8822ca8dc1d5405f1c623e31b2abe2/samples/delete.com -------------------------------------------------------------------------------- /samples/delete.z80: -------------------------------------------------------------------------------- 1 | ;; delete.asm - Delete the named file 2 | ;; 3 | 4 | FCB1: EQU 0x5C 5 | 6 | BDOS_ENTRY_POINT: EQU 5 7 | 8 | BDOS_OUTPUT_STRING: EQU 9 9 | BDOS_OPEN_FILE: EQU 15 10 | BDOS_DELETE_FILE: EQU 19 11 | 12 | ;; 13 | ;; CP/M programs start at 0x100. 14 | ;; 15 | ORG 100H 16 | 17 | ;; The FCB will be populated with the pattern/first argument, 18 | ;; if the first character of that region is a space-character 19 | ;; then we've got nothing to search for. 20 | ld a, (FCB1 + 1) 21 | cp 0x20 ; 0x20 = 32 == SPACE 22 | jr nz, got_argument ; Not a space, so we can proceed 23 | 24 | ;; 25 | ;; No argument, so show the error and exit 26 | ;; 27 | ld de, usage_message 28 | ld c, BDOS_OUTPUT_STRING 29 | call BDOS_ENTRY_POINT 30 | jr exit_fn 31 | 32 | got_argument: 33 | ;; Can we open the file? 34 | ;; i.e. if it doesn't exist we must abort 35 | call can_open 36 | jr nz, not_found 37 | 38 | ;; file exists, we can delete it. 39 | LD DE, FCB1 40 | LD C, BDOS_DELETE_FILE 41 | CALL BDOS_ENTRY_POINT 42 | 43 | ;; did it work? 44 | call can_open 45 | jr z, delete_failed 46 | 47 | exit_fn: 48 | ;; exit 49 | LD C,0x00 50 | CALL BDOS_ENTRY_POINT 51 | 52 | not_found: 53 | ld de, NOT_FOUND_MESSAGE 54 | ld c, BDOS_OUTPUT_STRING 55 | call BDOS_ENTRY_POINT 56 | jr exit_fn 57 | 58 | delete_failed: 59 | ld de, DELETE_FAILED_MESSAGE 60 | ld c, BDOS_OUTPUT_STRING 61 | call BDOS_ENTRY_POINT 62 | jr exit_fn 63 | 64 | ;; Test if we can open the file in the first FCB 65 | can_open: 66 | ;; Open the file 67 | ld de, FCB1 68 | ld c, BDOS_OPEN_FILE 69 | call BDOS_ENTRY_POINT 70 | 71 | ;; Did that succeed? 72 | cp 00 73 | ret 74 | 75 | ;;; 76 | ;;; The message displayed if no command-line argument was present. 77 | ;;; 78 | DELETE_FAILED_MESSAGE: 79 | db "Deleting the file failed.", 0x0a, 0x0d, "$" 80 | NOT_FOUND_MESSAGE: 81 | db "The file does does not exist.", 0x0a, 0x0d, "$" 82 | usage_message: 83 | db "Usage: DELETE FILENAME.EXT", 0xa, 0xd, "$" 84 | 85 | END 86 | -------------------------------------------------------------------------------- /samples/filesize.com: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skx/cpmulator/356af0594e8822ca8dc1d5405f1c623e31b2abe2/samples/filesize.com -------------------------------------------------------------------------------- /samples/filesize.z80: -------------------------------------------------------------------------------- 1 | ;; filesize.z80 - Show the size of the named file 2 | ;; 3 | 4 | FCB1: EQU 0x5C 5 | 6 | BDOS_ENTRY_POINT: EQU 5 7 | 8 | BDOS_OUTPUT_STRING: EQU 9 9 | BDOS_FILE_SIZE: EQU 35 10 | 11 | ;; 12 | ;; CP/M programs start at 0x100. 13 | ;; 14 | ORG 100H 15 | 16 | ;; The FCB will be populated with the pattern/first argument, 17 | ;; if the first character of that region is a space-character 18 | ;; then we've got nothing to search for. 19 | ld a, (FCB1 + 1) 20 | cp 0x20 ; 0x20 = 32 == SPACE 21 | jr nz, got_argument ; Not a space, so we can proceed 22 | 23 | ;; 24 | ;; No argument, so show the error and exit 25 | ;; 26 | ld de, usage_message 27 | ld c, BDOS_OUTPUT_STRING 28 | call BDOS_ENTRY_POINT 29 | jr exit_fn 30 | 31 | got_argument: 32 | LD DE, FCB1 33 | LD C, BDOS_FILE_SIZE 34 | CALL BDOS_ENTRY_POINT 35 | 36 | ; Now show the size 37 | LD HL, FCB1 + 35 38 | LD A,(HL) 39 | call show_a_register 40 | 41 | LD HL, FCB1 + 34 42 | LD A,(HL) 43 | call show_a_register 44 | 45 | LD HL, FCB1 + 33 46 | LD A,(HL) 47 | call show_a_register 48 | 49 | LD DE, newline 50 | LD C, BDOS_OUTPUT_STRING 51 | CALL BDOS_ENTRY_POINT 52 | 53 | exit_fn: 54 | ;; exit 55 | LD C,0x00 56 | CALL BDOS_ENTRY_POINT 57 | 58 | 59 | 60 | show_a_register: 61 | PUSH AF ; save right nibble 62 | RRCA ; move left nibble to right 63 | RRCA 64 | RRCA 65 | RRCA 66 | CALL PRHEX ; display left nibble 67 | POP AF ; get back right nibble 68 | PRHEX: 69 | AND 0FH ; convert to ascii 70 | ADD A,90H 71 | DAA 72 | ADC A,40H 73 | DAA 74 | LD E,A 75 | LD C, 2 76 | CALL 0x0005 77 | RET 78 | 79 | 80 | ;;; 81 | ;;; The message displayed if no command-line argument was present. 82 | ;;; 83 | usage_message: 84 | db "Usage: FILESIZE FILENAME.EXT" 85 | 86 | ;; note fall-through here :) 87 | newline: 88 | db 0xa, 0xd, "$" 89 | 90 | END 91 | -------------------------------------------------------------------------------- /samples/find.com: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skx/cpmulator/356af0594e8822ca8dc1d5405f1c623e31b2abe2/samples/find.com -------------------------------------------------------------------------------- /samples/find.z80: -------------------------------------------------------------------------------- 1 | ;; find.asm - Show all files which match the given glob/pattern. 2 | ;; 3 | 4 | FCB1: EQU 0x5C 5 | DMA: EQU 0x80 6 | 7 | BDOS_ENTRY_POINT: EQU 5 8 | 9 | BDOS_OUTPUT_SINGLE_CHARACTER: EQU 2 10 | BDOS_OUTPUT_STRING: EQU 9 11 | BDOS_FIND_FIRST: EQU 17 12 | BDOS_FIND_NEXT: EQU 18 13 | 14 | ;; 15 | ;; CP/M programs start at 0x100. 16 | ;; 17 | ORG 100H 18 | 19 | ;; 20 | ;; Before the program is the zero-page, or PSP: 21 | ;; 22 | ;; https://en.wikipedia.org/wiki/Zero_page_(CP/M) 23 | ;; 24 | ;; At offset 0x5C is the FCB for the first argument 25 | ;; 26 | ;; https://en.wikipedia.org/wiki/File_Control_Block 27 | ;; 28 | 29 | 30 | ;; The FCB will be populated with the pattern/first argument, 31 | ;; if the first character of that region is a space-character 32 | ;; then we've got nothing to search for. 33 | ld a, (FCB1 + 1) 34 | cp 0x20 ; 0x20 = 32 == SPACE 35 | jr nz, got_argument ; Not a space, so we can proceed 36 | 37 | ;; 38 | ;; No argument, so show the error and exit 39 | ;; 40 | ld de, usage_message 41 | ld c, BDOS_OUTPUT_STRING 42 | call BDOS_ENTRY_POINT 43 | jr exit_fn 44 | 45 | got_argument: 46 | call find_files_on_drive 47 | 48 | exit_fn: 49 | ;; exit 50 | LD C,0x00 51 | CALL BDOS_ENTRY_POINT 52 | 53 | 54 | 55 | ;;; *** 56 | ;;; 57 | ;;; Find all files that match the pattern in our FCB 58 | ;;; 59 | find_files_on_drive: 60 | 61 | ;; Call the find-first BIOS function 62 | ld c, BDOS_FIND_FIRST 63 | ld de, FCB1 64 | call BDOS_ENTRY_POINT 65 | 66 | find_more: 67 | ;; If nothing was found then return. 68 | cp 255 69 | ret z 70 | 71 | ;; Show the thing we did find. 72 | call show_result 73 | 74 | ;; After the find-first function we need to keep calling 75 | ;; find-next, until that returns a failure. 76 | ld c, BDOS_FIND_NEXT 77 | ld de, FCB1 78 | call BDOS_ENTRY_POINT 79 | 80 | jr find_more ; Test return code and loop again 81 | 82 | 83 | 84 | ;;; *** 85 | ;;; 86 | ;;; This is called after find-first/find-next returns a positive result 87 | ;;; and is supposed to show the name of the file that was found. 88 | ;;; 89 | ;;; We show the drive-letter and the resulting match. 90 | ;;; 91 | show_result: 92 | 93 | push af ; preserve return code from find first/next 94 | 95 | ;; If the drive is not-zero that means we have an explicit drive 96 | ld a,(FCB1) 97 | cp 0x00 98 | jr nz, letter_drive 99 | 100 | ;; So the drive is 0x00, which means we're using the current, 101 | ;; or default, drive. Find it. 102 | ld c,25 103 | call 0x0005 104 | 105 | ;; Add one, now we can fall-through to the ASCII conversiion 106 | inc a 107 | 108 | letter_drive: 109 | ; 1 means A, 2 for B, etc 110 | add a,'A' -1 111 | call print_character 112 | 113 | ld a, ':' 114 | call print_character 115 | 116 | pop af ; restore return code from find first/next 117 | call print_matching_filename ; print the entry 118 | 119 | ld de, newline ; Add a trailing newline 120 | ld c, BDOS_OUTPUT_STRING 121 | call call_bdos_and_return 122 | ret 123 | 124 | 125 | ;;; *** 126 | ;;; 127 | ;;; When we call find-first/find-next we get a result which we now show. 128 | ;;; 129 | ;;; The return code of the find-first/next will be preserved when we're 130 | ;;; called here, and it should be multiplied by 32, as per: 131 | ;;; 132 | ;;; http://www.gaby.de/cpm/manuals/archive/cpm22htm/ch5.htm 133 | ;;; 134 | ;;; See documentation for "Function 17: Search for First " 135 | ;;; 136 | ;;; NOTE: We assume the default DMA address of 0x0080 137 | ;;; 138 | print_matching_filename: 139 | 140 | ;; Return code from find-first, or find-next, will be 0, 1, 2, or 141 | ;; 3 - and should be multiplied by 32 then added to the DMA area 142 | ;; 143 | ;; What we could do is: 144 | ;; 145 | ;; hl = DMA 146 | ;; a = a * 32 147 | ;; hl = hl + a 148 | ;; 149 | ;; However we know the maximum we can have in A is 150 | ;; 3 x 32 = 96, and we know the default DMA area is 0x80 (128). 151 | ;; 152 | ;; So instead what we'll do is: 153 | ;; 154 | ;; a = a * 32 155 | ;; a = a + 128 (DMA offset) 156 | ;; h = 0 157 | ;; l = a 158 | ;; 159 | ;; Leaving the correct value in HL, and saving several bytes. 160 | ;; 161 | and 3 ; Mask the bits since ret is 0/1/2/3 162 | add A,A ; MULTIPLY... 163 | add A,A ; ..BY 32 BECAUSE 164 | add A,A ; ..EACH DIRECTORY 165 | add A,A ; ..ENTRY IS 32 166 | add A,A ; ..BYTES LONG 167 | 168 | add A, DMA + 1 ; Make offset from DMA 169 | xor h ; high byte is zero 170 | ld l, a ; low bye is offset 171 | 172 | ld b,11 ; filename is 11 bytes 173 | print_matching_filename_loop: 174 | ld a,(hl) 175 | push hl 176 | push bc 177 | call print_character 178 | pop bc 179 | pop hl 180 | inc hl 181 | djnz print_matching_filename_loop 182 | ret 183 | 184 | 185 | ;;; *** 186 | ;;; Helper routine to print a single character, stored in the A-register 187 | ;;; 188 | print_character: 189 | ld c, BDOS_OUTPUT_SINGLE_CHARACTER 190 | ld e, a 191 | call_bdos_and_return: 192 | call BDOS_ENTRY_POINT 193 | ret 194 | 195 | 196 | 197 | ;;; *** 198 | ;;; The message displayed if no command-line argument was present. 199 | ;;; 200 | usage_message: 201 | db "Usage: FIND pattern" 202 | ;; note fall-through here :) 203 | newline: 204 | db 0xa, 0xd, "$" 205 | 206 | END 207 | -------------------------------------------------------------------------------- /samples/intest.com: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skx/cpmulator/356af0594e8822ca8dc1d5405f1c623e31b2abe2/samples/intest.com -------------------------------------------------------------------------------- /samples/intest.z80: -------------------------------------------------------------------------------- 1 | ;; intest.z80 - Input-test for the various console functions 2 | 3 | ; Entry point, after the PSP. 4 | ORG 0x0100 5 | 6 | ; show our introduction 7 | LD DE, INTRO_MSG 8 | LD C,0x09 9 | CALL 0x0005 10 | 11 | ;; function 1 (C_READ) 12 | call C_READ_test 13 | 14 | ;; function 3 (A_READ) - mbasic.com 15 | call A_READ_test 16 | 17 | ;; function 6 (C_RAWIO) 18 | call C_RAWIO_test 19 | 20 | ;; function 9 (C_READSTRING) 21 | call C_READSTRING_test 22 | 23 | ;; Exit 24 | LD C,0x00 25 | CALL 0x0005 26 | 27 | 28 | ;; Prompt for characters, echoing and returning them 29 | C_READ_test: 30 | LD DE, C_READ_PROMPT 31 | LD C,0x09 32 | CALL 0x0005 33 | 34 | ; we allow five input characters 35 | LD B, 5 36 | ; We save the characters into the input area 37 | LD HL,READ_STRING 38 | C_READ_test_loop 39 | PUSH HL 40 | PUSH BC 41 | LD C, 0x01 42 | CALL 0x0005 43 | POP BC 44 | POP HL 45 | LD (HL), A 46 | INC HL 47 | DJNZ C_READ_test_loop 48 | 49 | LD DE, NEWLINE 50 | LD C, 0x09 51 | CALL 0x0005 52 | 53 | LD DE, C_READ_OVER_1 54 | LD C, 0x09 55 | CALL 0x0005 56 | 57 | ; Show the characters 58 | LD HL, READ_STRING 59 | LD A, (HL) 60 | LD B,5 61 | SHOW_LOOP_C_READ: 62 | PUSH HL 63 | PUSH BC 64 | LD A,(HL) 65 | LD E, A 66 | LD C, 0x02 67 | CALL 0x0005 68 | POP BC 69 | POP HL 70 | INC HL 71 | DJNZ SHOW_LOOP_C_READ 72 | 73 | LD DE, C_READ_OVER_2 74 | LD C, 0x09 75 | CALL 0x0005 76 | 77 | RET 78 | 79 | 80 | 81 | ;; Prompt for characters returning them - NOTE: No echo is expected 82 | A_READ_test: 83 | LD DE, A_READ_PROMPT 84 | LD C,0x09 85 | CALL 0x0005 86 | 87 | ; we allow five input characters 88 | LD B, 5 89 | ; We save the characters into the input area 90 | LD HL,READ_STRING 91 | A_READ_test_loop 92 | PUSH HL 93 | PUSH BC 94 | LD C, 0x03 95 | CALL 0x0005 96 | POP BC 97 | POP HL 98 | LD (HL), A 99 | INC HL 100 | DJNZ A_READ_test_loop 101 | 102 | LD DE, A_READ_OVER_1 103 | LD C, 0x09 104 | CALL 0x0005 105 | 106 | ; Show the characters 107 | LD HL, READ_STRING 108 | LD A, (HL) 109 | LD B,5 110 | SHOW_LOOP_A_READ: 111 | PUSH HL 112 | PUSH BC 113 | LD A,(HL) 114 | LD E, A 115 | LD C, 0x02 116 | CALL 0x0005 117 | POP BC 118 | POP HL 119 | INC HL 120 | DJNZ SHOW_LOOP_A_READ 121 | 122 | LD DE, A_READ_OVER_2 123 | LD C, 0x09 124 | CALL 0x0005 125 | 126 | RET 127 | 128 | 129 | ;; This works in a non-blocking way. 130 | C_RAWIO_test: 131 | LD DE, C_RAWIO_PROMPT 132 | LD C, 0x09 133 | CALL 0x0005 134 | 135 | C_RAWIO_Test_loop: 136 | LD DE, C_RAWIO_SPINNER_1 137 | LD C, 0x09 138 | CALL 0x0005 139 | 140 | ; see if a character is pending 141 | LD C, 0x06 142 | LD E, 0xff 143 | CALL 0x0005 144 | 145 | push af 146 | LD DE, C_RAWIO_SPINNER_2 147 | LD C, 0x09 148 | CALL 0x0005 149 | pop af 150 | 151 | ; was there nothing pending? then try again 152 | CP 0x00 153 | jr z, C_RAWIO_Test_loop 154 | 155 | push af 156 | LD DE, C_RAWIO_SPINNER_3 157 | LD C, 0x09 158 | CALL 0x0005 159 | pop af 160 | 161 | ; got a character, was it q? 162 | cp 'q' 163 | jr nz, C_RAWIO_Test_loop 164 | 165 | LD DE, NEWLINE 166 | LD C, 0x09 167 | CALL 0x0005 168 | 169 | ret 170 | 171 | ;; Prompt the user to enter text, and echo it back. 172 | C_READSTRING_test: 173 | LD DE, C_READSTRING_PROMPT 174 | LD C,0x09 175 | CALL 0x0005 176 | 177 | ; Point to the buffer 178 | LD HL, READ_STRING 179 | 180 | ; first byte is how many characters to allow (20 here) 181 | LD A, 20 182 | LD (HL), A 183 | 184 | ; DE points to the buffer 185 | PUSH HL 186 | POP DE 187 | 188 | ; call C_READSTRING 189 | LD C, 10 190 | CALL 0x005 191 | 192 | ;; Show the result 193 | LD DE, NEWLINE 194 | LD C, 0x09 195 | CALL 0x0005 196 | 197 | LD DE, C_READSTRING_OVER_1 198 | LD C,0x09 199 | CALL 0x0005 200 | 201 | ;; Now get the length, and show the output 202 | LD HL, READ_STRING + 1 203 | LD A, (HL) 204 | LD B,A 205 | SHOW_LOOP 206 | INC HL 207 | PUSH HL 208 | PUSH BC 209 | LD A,(HL) 210 | LD E, A 211 | LD C, 0x02 212 | CALL 0x0005 213 | POP BC 214 | POP HL 215 | DJNZ SHOW_LOOP 216 | 217 | ;; And finish 218 | LD DE, C_READSTRING_OVER_2 219 | LD C,0x09 220 | CALL 0x0005 221 | 222 | RET 223 | 224 | 225 | 226 | ;; 227 | ;; Text area 228 | ;; 229 | INTRO_MSG: 230 | DB "Simple input-test program, by Steve.", 0x0a, 0x0d, 0x0a, 0x0d,"$" 231 | 232 | ;; C_READ 233 | C_READ_PROMPT: 234 | DB 0x0a, 0x0d, "C_READ Test:", 0x0a, 0x0d 235 | DB " This test allows you to enter FIVE characters, one by one.", 0x0a, 0x0d 236 | DB " The characters SHOULD be echoed as you type them.", 0x0a, 0x0d, "$" 237 | C_READ_OVER_1: 238 | DB " Test complete - you entered '$" 239 | C_READ_OVER_2: 240 | DB "'." ; fall-through 241 | NEWLINE: 242 | DB 0x0a, 0x0d, "$" 243 | 244 | ;; A_READ 245 | A_READ_PROMPT: 246 | DB 0x0a, 0x0d, "A_READ Test:", 0x0a, 0x0d 247 | DB " This test allows you to enter FIVE characters, one by one.", 0x0a, 0x0d 248 | DB " The characters should NOT be echoed as you type them.", 0x0a, 0x0d, "$" 249 | A_READ_OVER_1: 250 | DB " Test complete - you entered '$" 251 | A_READ_OVER_2: 252 | DB "'.", 0x0a, 0x0d, "$" 253 | 254 | 255 | ;; C_RAWIO 256 | C_RAWIO_PROMPT: 257 | DB 0x0a, 0x0d, "C_RAWIO Test:", 0x0a, 0x0d 258 | DB " This uses polling to read characters.", 0x0a, 0x0d 259 | DB " Echo should NOT be enabled.", 0x0a, 0x0d 260 | DB " Press 'q' to proceed/complete this test.", 0x0a, 0x0d, "$" 261 | 262 | C_RAWIO_SPINNER_1: 263 | DB "x", 0x08, "$" 264 | C_RAWIO_SPINNER_2: 265 | DB "X", 0x08, "$" 266 | C_RAWIO_SPINNER_3: 267 | DB "+", 0x08, "$" 268 | 269 | ;; C_READSTRING 270 | C_READSTRING_PROMPT: 271 | DB 0x0a, 0x0d, "C_READSTRING Test:", 0x0a, 0x0d 272 | DB " Enter a string, terminated by newline..", 0x0a, 0x0d, "$" 273 | C_READSTRING_OVER_1: 274 | DB " Test complete - you entered '$" 275 | C_READSTRING_OVER_2: 276 | DB "'.", 0x0a, 0x0d, "$" 277 | ;; 278 | ;; DATA area 279 | ;; 280 | READ_STRING: -------------------------------------------------------------------------------- /samples/read.com: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skx/cpmulator/356af0594e8822ca8dc1d5405f1c623e31b2abe2/samples/read.com -------------------------------------------------------------------------------- /samples/read.z80: -------------------------------------------------------------------------------- 1 | ;; Read sequential records from a file. 2 | ;; 3 | ;; Given a filename open it, and read 256 records to it - testing that the contents 4 | ;; match what we expect. 5 | ;; 6 | ;; A record of 128 bytes each of 0x00 7 | ;; A record of 128 bytes each of 0x01 8 | ;; A record of 128 bytes each of 0x02 9 | ;; A record of 128 bytes each of 0x03 10 | ;; .. 11 | ;; A record of 128 bytes each of 0xFE 12 | ;; A record of 128 bytes each of 0xFE 13 | ;; 14 | ;; 15 | 16 | FCB1: EQU 0x5C 17 | DMA: EQU 0x80 18 | BDOS_ENTRY_POINT: EQU 5 19 | BDOS_OUTPUT_STRING: EQU 9 20 | BDOS_READ_FILE: EQU 20 21 | BDOS_OPEN_FILE: EQU 15 22 | BDOS_CLOSE_FILE: EQU 16 23 | 24 | ; Simple macro to push all (important) registers. 25 | ; 26 | MACRO PUSH_ALL 27 | push af 28 | push bc 29 | push de 30 | push hl 31 | ENDM 32 | 33 | 34 | ; 35 | ; Simple macro to pop all (important) registers. 36 | ; 37 | MACRO POP_ALL 38 | pop hl 39 | pop de 40 | pop bc 41 | pop af 42 | ENDM 43 | ; }} 44 | 45 | 46 | ORG 0x0100 47 | 48 | ;; The FCB will be populated with the pattern/first argument, 49 | ;; if the first character of that region is a space-character 50 | ;; then we've got nothing to search for. 51 | ld a, (FCB1 + 1) 52 | cp 0x20 ; 0x20 = 32 == SPACE 53 | jr nz, got_argument ; Not a space, so we can proceed 54 | 55 | ;; 56 | ;; No argument, so show the error and exit 57 | ;; 58 | ld de, usage_message 59 | ld c, BDOS_OUTPUT_STRING 60 | call BDOS_ENTRY_POINT 61 | 62 | exit_fn: 63 | LD C,0x00 64 | CALL BDOS_ENTRY_POINT 65 | 66 | 67 | got_argument: 68 | ;; Open the file 69 | LD DE, FCB1 70 | LD C, BDOS_OPEN_FILE 71 | CALL BDOS_ENTRY_POINT 72 | 73 | ;; Did that succeed? 74 | cp 00 75 | jr z, open_ok 76 | 77 | LD DE, OPEN_FAILED 78 | ld c, BDOS_OUTPUT_STRING 79 | call BDOS_ENTRY_POINT 80 | jr exit_fn 81 | 82 | open_ok: 83 | ;; Right here we loop from 0x00 - 0xFF starting at 0x00 84 | ld a, 0x00 85 | read_record: 86 | ;; show the record we're reading 87 | PUSH_ALL 88 | call show_a_register 89 | ld c, 0x02 90 | ld e, "\n" 91 | call 0x0005 92 | ld c, 0x02 93 | ld e, "\r" 94 | call 0x0005 95 | POP_ALL 96 | 97 | ;; read the next record into the DMA area 98 | PUSH AF 99 | LD C, BDOS_READ_FILE 100 | LD DE, FCB1 101 | CALL BDOS_ENTRY_POINT 102 | POP AF 103 | 104 | ; Does this record contain the current record number? 105 | LD HL, DMA 106 | LD b, 128 107 | loopy: 108 | CP (hl) 109 | JR NZ, RECORD_FAILED 110 | inc hl 111 | DEC b 112 | JR NZ, loopy 113 | 114 | INC A 115 | cp 0x00 116 | jr nz, read_record 117 | 118 | ;; Close the file 119 | LD DE,FCB1 120 | LD C, BDOS_CLOSE_FILE 121 | CALL BDOS_ENTRY_POINT 122 | 123 | ;; Exit 124 | jr exit_fn 125 | 126 | RECORD_FAILED: 127 | ;; show the failure message 128 | push af 129 | ld de, FAILURE 130 | ld c, BDOS_OUTPUT_STRING 131 | call BDOS_ENTRY_POINT 132 | 133 | pop af 134 | 135 | ;; show the record number 136 | call show_a_register 137 | 138 | ;; newline 139 | ld c, 0x02 140 | ld e, "\r" 141 | call 0x0005 142 | ld c, 0x02 143 | ld e, "\n" 144 | call 0x0005 145 | 146 | ;; now dump the DMA-areas memory 147 | ld b, 128 148 | ld hl, DMA 149 | show_mem: 150 | push bc 151 | push hl 152 | ld a,(hl) 153 | call show_a_register 154 | ld c, 0x02 155 | ld e, " " 156 | call 0x0005 157 | pop hl 158 | inc hl 159 | pop bc 160 | djnz show_mem 161 | jp exit_fn 162 | 163 | 164 | ;; Display a number from HL 165 | DispHL: 166 | ld bc,-100 167 | call Num1 168 | ld c,-10 169 | call Num1 170 | ld c,-1 171 | Num1: ld a,'0'-1 172 | Num2: inc a 173 | add hl,bc 174 | jr c,Num2 175 | sbc hl,bc 176 | PUSH_ALL 177 | ld e, a 178 | ld c, 0x02 179 | call 0x0005 180 | POP_ALL 181 | ret 182 | 183 | ;; Show the A register contents as a number 184 | show_a_register: 185 | ld h,0x00 186 | ld l,a 187 | call DispHL 188 | ret 189 | 190 | usage_message: 191 | db "Usage: READ FILENAME.EXT", 0xa, 0xd, "$" 192 | FAILURE: 193 | db "Unexpected value reading file at record $" 194 | 195 | OPEN_FAILED: 196 | db "opening the file failed.", 0x0a, 0x0d, "$" 197 | 198 | END 199 | -------------------------------------------------------------------------------- /samples/ret.com: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skx/cpmulator/356af0594e8822ca8dc1d5405f1c623e31b2abe2/samples/ret.com -------------------------------------------------------------------------------- /samples/ret.z80: -------------------------------------------------------------------------------- 1 | ;; ret.z80 - Demonstrate how to exit cleanly. 2 | ;; 3 | ;; Call this with an argument, and you'll see it terminate 4 | ;; in a different fashion. 5 | ;; 6 | ;; 1. jp 0x0000 7 | ;; 2. rst 0 8 | ;; 3. ret 9 | ;; 4. via syscall 10 | ;; 11 | ;; 12 | 13 | ;; Address of first/default FCB 14 | ;; Used for argument-testing. 15 | FCB1: EQU 0x5C 16 | 17 | ;; start at 0x0100 18 | org 0x0100 19 | 20 | ; Get the first character of any argument. 21 | ld a, (FCB1 + 1) 22 | 23 | ; test for valid options 24 | cp '1' 25 | jr z, one 26 | cp '2' 27 | jr z, two 28 | cp '3' 29 | jr z, three 30 | cp '4' 31 | jr z, four 32 | 33 | ; nothing useful, show usage message 34 | ld de, usage_message 35 | ld c, 9 36 | call 0x0005 37 | 38 | ;; exit after showing the usage. 39 | ;; but also for option 4. 40 | exit_fn: 41 | ld c,0x00 42 | call 0x0005 43 | 44 | one: 45 | jp 0x0000 46 | two: 47 | rst 0 48 | three: 49 | ret 50 | four: 51 | jr exit_fn 52 | 53 | usage_message: 54 | DB "Usage: RET [1|2|3|4]", 0x0a, 0x0d 55 | DB " 1 - Exit via 'JP 0x0000'.", 0x0a, 0x0d 56 | DB " 2 - Exit via 'RST 0' instruction.", 0x0a, 0x0d 57 | DB " 3 - Exit via 'RET'.", 0x0a, 0x0d 58 | DB " 4 - Exit via 'P_TERMCPM' syscall.", 0x0a, 0x0d 59 | DB "$" 60 | -------------------------------------------------------------------------------- /samples/tsize.com: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skx/cpmulator/356af0594e8822ca8dc1d5405f1c623e31b2abe2/samples/tsize.com -------------------------------------------------------------------------------- /samples/tsize.z80: -------------------------------------------------------------------------------- 1 | ;; tsize.z80 - Show the terminal size. 2 | 3 | 4 | 5 | ; 6 | ; Simple macro to push all (important) registers. 7 | ; 8 | MACRO PUSH_ALL 9 | push af 10 | push bc 11 | push de 12 | push hl 13 | ENDM 14 | 15 | 16 | ; 17 | ; Simple macro to pop all (important) registers. 18 | ; 19 | MACRO POP_ALL 20 | pop hl 21 | pop de 22 | pop bc 23 | pop af 24 | ENDM 25 | ; }} 26 | 27 | BDOS_ENTRY_POINT: EQU 5 28 | BDOS_OUTPUT_STRING: EQU 9 29 | 30 | ;; 31 | ;; CP/M programs start at 0x100. 32 | ;; 33 | ORG 100H 34 | 35 | ;; Test that we're running under cpmulator by calling the 36 | ;; "is cpmulator" function. 37 | ld HL, 0x0000 38 | ld a, 31 39 | out (0xff), a 40 | 41 | ;; We expect SKX to appear in registers HLA 42 | CP 'X' 43 | jr nz, not_cpmulator 44 | 45 | LD A, H 46 | CP 'S' 47 | jr nz, not_cpmulator 48 | 49 | LD A, L 50 | CP 'K' 51 | jr nz, not_cpmulator 52 | 53 | ;; get the terminal size 54 | ld HL, 0x05 55 | ld a, 31 56 | out (0xff), a 57 | 58 | ;; save the result 59 | push hl 60 | push hl 61 | 62 | LD DE, HEIGHT_PREFIX 63 | LD C, BDOS_OUTPUT_STRING 64 | call BDOS_ENTRY_POINT 65 | 66 | ;; show width 67 | pop hl 68 | ld a, h 69 | call show_a_register 70 | 71 | LD DE, NEWLINE 72 | LD C, BDOS_OUTPUT_STRING 73 | call BDOS_ENTRY_POINT 74 | 75 | LD DE, WIDTH_PREFIX 76 | LD C, BDOS_OUTPUT_STRING 77 | call BDOS_ENTRY_POINT 78 | 79 | ;; show height 80 | pop hl 81 | ld a, l 82 | call show_a_register 83 | 84 | LD DE, NEWLINE 85 | LD C, BDOS_OUTPUT_STRING 86 | call BDOS_ENTRY_POINT 87 | 88 | exit: 89 | LD C,0x00 90 | CALL BDOS_ENTRY_POINT 91 | 92 | 93 | DispHL: 94 | ld bc,-10000 95 | call Num1 96 | ld bc,-1000 97 | call Num1 98 | ld bc,-100 99 | call Num1 100 | ld c,-10 101 | call Num1 102 | ld c,-1 103 | Num1: ld a,'0'-1 104 | Num2: inc a 105 | add hl,bc 106 | jr c,Num2 107 | sbc hl,bc 108 | PUSH_ALL 109 | ld e, a 110 | ld c, 0x02 111 | call 0x0005 112 | POP_ALL 113 | ret 114 | 115 | show_a_register: 116 | ld h,0 117 | ld l,a 118 | jr DispHL 119 | 120 | ;; 121 | ;; Error Routines 122 | ;; 123 | not_cpmulator: 124 | LD DE, WRONG_EMULATOR 125 | LD C, BDOS_OUTPUT_STRING 126 | call BDOS_ENTRY_POINT 127 | jr exit 128 | 129 | ;; 130 | ;; Text output strings. 131 | ;; 132 | WRONG_EMULATOR: 133 | db "This binary is not running under cpmulator, aborting.", 0x0a, 0x0d, "$" 134 | WIDTH_PREFIX: 135 | db "The terminal width is $" 136 | HEIGHT_PREFIX: 137 | db "The terminal height is $" 138 | NEWLINE: 139 | db 0x0a, 0x0d, "$" 140 | END 141 | -------------------------------------------------------------------------------- /samples/write.com: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skx/cpmulator/356af0594e8822ca8dc1d5405f1c623e31b2abe2/samples/write.com -------------------------------------------------------------------------------- /samples/write.z80: -------------------------------------------------------------------------------- 1 | ;; Write random records to a file. 2 | ;; 3 | ;; Given a filename open it, and write 256 records to it: 4 | ;; 5 | ;; A record of 0x00 6 | ;; A record of 0x01 7 | ;; .. 8 | ;; A record of 0xFF 9 | ;; 10 | ;; Then close the file. 11 | ;; 12 | ;; The end result should be a file of size 32768 bytes (256 * 128) 13 | ;; 14 | 15 | FCB1: EQU 0x5C 16 | DMA: EQU 0x80 17 | DMA_LEN: EQU 128 18 | BDOS_ENTRY_POINT: EQU 5 19 | BDOS_OUTPUT_STRING: EQU 9 20 | BDOS_WRITE_FILE: EQU 21 21 | BDOS_MAKE_FILE: EQU 22 22 | BDOS_CLOSE_FILE: EQU 16 23 | 24 | ORG 0x0100 25 | 26 | ;; The FCB will be populated with the pattern/first argument, 27 | ;; if the first character of that region is a space-character 28 | ;; then we've got nothing to search for. 29 | ld a, (FCB1 + 1) 30 | cp 0x20 ; 0x20 = 32 == SPACE 31 | jr nz, got_argument ; Not a space, so we can proceed 32 | 33 | ;; 34 | ;; No argument, so show the error and exit 35 | ;; 36 | ld de, usage_message 37 | ld c, BDOS_OUTPUT_STRING 38 | call BDOS_ENTRY_POINT 39 | 40 | exit_fn: 41 | LD C,0x00 42 | CALL BDOS_ENTRY_POINT 43 | 44 | 45 | got_argument: 46 | ;; Create the file 47 | LD DE, FCB1 48 | LD C, BDOS_MAKE_FILE 49 | CALL BDOS_ENTRY_POINT 50 | 51 | ;; Right here we loop from 0x00 - 0xFF starting at 0x00 52 | ld a, 0x00 53 | write_record: 54 | PUSH AF 55 | LD B, DMA_LEN 56 | LD HL,DMA 57 | FILL_DMA: 58 | LD (HL),A 59 | INC HL 60 | DJNZ FILL_DMA 61 | 62 | ; write record 63 | LD C, BDOS_WRITE_FILE 64 | LD DE, FCB1 65 | CALL BDOS_ENTRY_POINT 66 | 67 | POP AF 68 | INC A 69 | cp 0 70 | jr nz, write_record 71 | 72 | ;; Close the file 73 | LD DE,FCB1 74 | LD C, BDOS_CLOSE_FILE 75 | CALL BDOS_ENTRY_POINT 76 | 77 | ;; Exit 78 | jr exit_fn 79 | 80 | usage_message: 81 | db "Usage: WRITE FILENAME.EXT", 0xa, 0xd, "$" 82 | 83 | END 84 | -------------------------------------------------------------------------------- /static/A/!CCP.COM: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skx/cpmulator/356af0594e8822ca8dc1d5405f1c623e31b2abe2/static/A/!CCP.COM -------------------------------------------------------------------------------- /static/A/!CTRLC.COM: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skx/cpmulator/356af0594e8822ca8dc1d5405f1c623e31b2abe2/static/A/!CTRLC.COM -------------------------------------------------------------------------------- /static/A/!DEBUG.COM: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skx/cpmulator/356af0594e8822ca8dc1d5405f1c623e31b2abe2/static/A/!DEBUG.COM -------------------------------------------------------------------------------- /static/A/!DISABLE.COM: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skx/cpmulator/356af0594e8822ca8dc1d5405f1c623e31b2abe2/static/A/!DISABLE.COM -------------------------------------------------------------------------------- /static/A/!HOSTCMD.COM: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skx/cpmulator/356af0594e8822ca8dc1d5405f1c623e31b2abe2/static/A/!HOSTCMD.COM -------------------------------------------------------------------------------- /static/A/!INPUT.COM: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skx/cpmulator/356af0594e8822ca8dc1d5405f1c623e31b2abe2/static/A/!INPUT.COM -------------------------------------------------------------------------------- /static/A/!OUTPUT.COM: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skx/cpmulator/356af0594e8822ca8dc1d5405f1c623e31b2abe2/static/A/!OUTPUT.COM -------------------------------------------------------------------------------- /static/A/!PRNPATH.COM: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skx/cpmulator/356af0594e8822ca8dc1d5405f1c623e31b2abe2/static/A/!PRNPATH.COM -------------------------------------------------------------------------------- /static/A/!VERSION.COM: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skx/cpmulator/356af0594e8822ca8dc1d5405f1c623e31b2abe2/static/A/!VERSION.COM -------------------------------------------------------------------------------- /static/A/#.COM: -------------------------------------------------------------------------------- 1 | � -------------------------------------------------------------------------------- /static/Makefile: -------------------------------------------------------------------------------- 1 | 2 | # 3 | # The files we wish to generate. 4 | # 5 | all: A/\#.COM A/!CCP.COM A/!CTRLC.COM A/!DEBUG.COM A/!DISABLE.COM A/!HOSTCMD.COM A/!INPUT.COM A/!OUTPUT.COM A/!PRNPATH.COM A/!VERSION.COM 6 | 7 | # cleanup 8 | clean: 9 | rm A/*.COM 10 | 11 | # 12 | # How to build them all - repetitive. 13 | # 14 | A/\#.COM: comment.z80 15 | pasmo comment.z80 A/#.COM 16 | 17 | A/!CCP.COM: ccp.z80 common.inc 18 | pasmo ccp.z80 A/!CCP.COM 19 | 20 | A/!CTRLC.COM: ctrlc.z80 common.inc 21 | pasmo ctrlc.z80 A/!CTRLC.COM 22 | 23 | A/!DEBUG.COM: debug.z80 common.inc 24 | pasmo debug.z80 A/!DEBUG.COM 25 | 26 | A/!DISABLE.COM: disable.z80 common.inc 27 | pasmo disable.z80 A/!DISABLE.COM 28 | 29 | A/!HOSTCMD.COM: hostcmd.z80 common.inc 30 | pasmo hostcmd.z80 A/!HOSTCMD.COM 31 | 32 | A/!INPUT.COM: input.z80 common.inc 33 | pasmo input.z80 A/!INPUT.COM 34 | 35 | A/!OUTPUT.COM: output.z80 common.inc 36 | pasmo output.z80 A/!OUTPUT.COM 37 | 38 | A/!PRNPATH.COM: prnpath.z80 common.inc 39 | pasmo prnpath.z80 A/!PRNPATH.COM 40 | 41 | A/!VERSION.COM: version.z80 common.inc 42 | pasmo version.z80 A/!VERSION.COM 43 | -------------------------------------------------------------------------------- /static/README.md: -------------------------------------------------------------------------------- 1 | # Embedded Resources 2 | 3 | This directory contains some binaries which are embedded into the main `cpmulator` binary, the intention is that these binaries will always 4 | appear present upon the local disc. These make use of our [custom BIOS functions](../EXTENSIONS.md), making them tied to this emulator and useless without it. 5 | 6 | Other CP/M code within the repository includes: 7 | 8 | * The top-level [dist/](../dist) directory contains a complete program used to test the emulator. 9 | * The top-level [samples/](../samples/) directory contains some code which was useful to me to test my understanding, when writing the emulator. 10 | 11 | More significant programs are available within the sister-repository: 12 | 13 | * https://github.com/skx/cpm-dist 14 | 15 | 16 | 17 | ## Contents 18 | 19 | The embedded resources do not have 100% full functionality, you cannot bundle a game such as ZORK, because not all I/O primitives work upon them, but simple binaries to be executed by the CCP work just fine. 20 | 21 | 22 | * [ccp.z80](ccp.z80) 23 | * Change the CCP in-use at runtime. 24 | * [comment.z80](comment.z80) 25 | * Source to a program that does nothing, output to "`#.COM`". 26 | * This is used to allow "`# FOO`" to act as a comment inside submit-files. 27 | * [console.z80](console.z80) 28 | * Toggle between ADM-3A and ANSI console output. 29 | * [ctrlc.z80](ctrlc.z80) 30 | * By default we reboot the CCP whenever the user presses Ctrl-C twice in a row. 31 | * Here you can tweak that behaviour to change the number of consecutive Ctrl-Cs that will reboot. 32 | * Require only a single Ctrl-C (`ctrlc 1`) 33 | * Disable the Ctrl-C reboot behaviour entirely (`ctrlc 0`) 34 | * [debug.z80](debug.z80) 35 | * Get/Set the state of the "quick debug" flag. 36 | * [test.z80](test.z80) 37 | * A program that determines whether it is running under cpmulator. 38 | * If so it shows the version banner. 39 | -------------------------------------------------------------------------------- /static/ccp.z80: -------------------------------------------------------------------------------- 1 | ;; ccp.z80 - Set the name of the CCP to load. 2 | ;; 3 | ;; This uses the custom BIOS function we've added to the BIOS, which was never 4 | ;; present in real CP/M. Consider it a hook into the emulator. 5 | ;; 6 | 7 | BDOS_OUTPUT_STRING: EQU 9 8 | BDOS_ENTRY_POINT: EQU 5 9 | CMDLINE: EQU 0x80 ; default DMA area too 10 | 11 | 12 | ;; 13 | ;; CP/M programs start at 0x100. 14 | ;; 15 | ORG 100H 16 | 17 | ;; Copy the DMA area, which holds our command-line 18 | ;; flags, to a safe area at the foot of our binary. 19 | ;; 20 | LD HL, CMDLINE 21 | LD DE, DEST 22 | LD BC, 128 23 | LDIR 24 | 25 | ;; 26 | ;; Now we can test if we're running under cpmulator 27 | ;; which will trash the DMA area 28 | ;; 29 | call exit_if_not_cpmulator 30 | 31 | ;; 32 | ;; If we didn't get an argument then show the CCP 33 | ;; 34 | LD HL,DEST 35 | LD A,(HL) 36 | INC HL 37 | CP 0x00 38 | JR Z,show_value 39 | 40 | 41 | ;; OK we're running under cpmulator, and we did get a parameter 42 | ;; Point DE to that and invoke the function. 43 | ;; 44 | ;; DEST+0 contains the length of the command-line. i.e. pascal-string 45 | ;; DEST+1 contains " " 46 | ;; DEST+2 contains the argument, assuming no extra space. 47 | ;; 48 | ld hl, 03 49 | ld de, DEST+2 50 | ld a, 31 51 | out (0xff), a 52 | 53 | exit: 54 | rst 0x00 55 | 56 | 57 | ;; Show the current value 58 | show_value: 59 | ld de, CCP_PREFIX ; show a prefix 60 | ld c, BDOS_OUTPUT_STRING 61 | call BDOS_ENTRY_POINT 62 | 63 | ;; Use the given function to call a custom BIOS 64 | ;; routine which will the result as ASCII text 65 | ;; in the DMA-buffer. 66 | ld hl, 0x0003 67 | call show_bios_value 68 | 69 | ld de, CCP_SUFFIX ; show a suffix 70 | ld c, BDOS_OUTPUT_STRING 71 | call BDOS_ENTRY_POINT 72 | jr exit 73 | 74 | 75 | ;; 76 | ;; Text output strings. 77 | ;; 78 | CCP_PREFIX: 79 | db "CCP is set to $" 80 | CCP_SUFFIX: 81 | db ".", 0x0a, 0x0d, "$" 82 | 83 | include "common.inc" 84 | 85 | DEST: 86 | END 87 | -------------------------------------------------------------------------------- /static/comment.z80: -------------------------------------------------------------------------------- 1 | ;; comment.z80 - A "do nothing" binary. 2 | ;; 3 | ;; This binary is useful because it allows SUBMIT-files 4 | ;; to contain comments. As everything is a binary in 5 | ;; CP/M we can implement comments by just creating a command 6 | ;; named "#" then the following will do nothing: 7 | ;; 8 | ;; # FOO BAR 9 | ;; 10 | ;; In reality the #.COM binary is loaded and "FOO BAR" are passed 11 | ;; to it as arguments. But the end result is "nothing" as this 12 | ;; binary immediately terminates. 13 | ;; 14 | 15 | 16 | ;; 17 | ;; CP/M programs start at 0x100. 18 | ;; 19 | ORG 100H 20 | 21 | ;; 22 | ;; JP 0x0000, but shorter 23 | ;; 24 | rst 0 25 | 26 | END -------------------------------------------------------------------------------- /static/common.inc: -------------------------------------------------------------------------------- 1 | ;; common.inc - Some common routines held in one place for consistency. 2 | ;; 3 | ;; We're not going to go extreme and save every byte so some of the code 4 | ;; here is not used by every binary we embed. 5 | ;; 6 | 7 | 8 | ;; Test that we're running under cpmulator by calling the 9 | ;; "is cpmulator" function. 10 | ;; 11 | ;; If we're running under out emulator, return. 12 | ;; 13 | ;; Otherwise show an error message and exit the process. 14 | exit_if_not_cpmulator: 15 | ld hl, 0x0000 16 | ld a, 31 17 | out (0xff), a 18 | 19 | ;; We expect SKX to appear in registers HLA 20 | cp 'X' 21 | jr nz, not_cpmulator 22 | 23 | ld a, h 24 | cp 'S' 25 | jr nz, not_cpmulator 26 | 27 | ld a, l 28 | cp 'K' 29 | ret z 30 | 31 | ;; Fall-through 32 | not_cpmulator: 33 | ld de, WRONG_EMULATOR 34 | ld c, BDOS_OUTPUT_STRING 35 | call BDOS_ENTRY_POINT 36 | ;; exit 37 | rst 0x00 38 | 39 | WRONG_EMULATOR: 40 | db "This binary is not running under cpmulator, aborting.", 0x0a, 0x0d, "$" 41 | 42 | 43 | 44 | ;; Output the string pointed to by HL to the console. 45 | ;; 46 | ;; Continue printing until a NULL by has been returned. 47 | print_string: 48 | ld a, (hl) ; Get the character 49 | cp 0 ; Is it null? 50 | or a 51 | ret z ; If so return 52 | inc hl 53 | push hl ; Save our index 54 | ld c, 0x02 ; setup for printing 55 | ld e, a 56 | call 0x0005 ; print the character 57 | pop hl 58 | jr print_string ; repeat, forever. 59 | 60 | 61 | ;; Call a custom BIOS function and print the value which 62 | ;; was stored as NULL-terminated ASCII in the DMA area. 63 | ;; 64 | ;; This works because many of our embedded binaries set the 65 | ;; same parameters "DE==NULL" to mean "get the value, and store 66 | ;; in the DMA area". 67 | show_bios_value: 68 | ld de, 0x0000 69 | ld a, 31 70 | out (0xff), a 71 | 72 | ld hl, 0x0080 ; print the contents as a string 73 | call print_string 74 | ret -------------------------------------------------------------------------------- /static/ctrlc.z80: -------------------------------------------------------------------------------- 1 | ;; ctrlc.asm - Get/Set the number of consecutive Ctrl-Cs required to reboot 2 | ;; 3 | ;; This uses the custom BIOS function we've added to the BIOS, which was never 4 | ;; present in real CP/M. Consider it a hook into the emulator. 5 | ;; 6 | 7 | BDOS_ENTRY_POINT: EQU 5 8 | BDOS_OUTPUT_STRING: EQU 9 9 | CMDLINE: EQU 0x80 ; default DMA area too 10 | 11 | ;; 12 | ;; CP/M programs start at 0x100. 13 | ;; 14 | ORG 100H 15 | 16 | 17 | ;; Copy the DMA area, which holds our command-line 18 | ;; flags, to a safe area at the foot of our binary. 19 | ;; 20 | LD HL, CMDLINE 21 | LD DE, DEST 22 | LD BC, 128 23 | LDIR 24 | 25 | ;; 26 | ;; Now we can test if we're running under cpmulator 27 | ;; which will trash the DMA area 28 | ;; 29 | call exit_if_not_cpmulator 30 | 31 | ;; 32 | ;; If we didn't get an argument then show the current value 33 | ;; 34 | LD HL,DEST 35 | LD A,(HL) 36 | INC HL 37 | CP 0x00 38 | jr z, show_value 39 | 40 | ;; OK we're running under cpmulator, and we did get a parameter: 41 | ;; 42 | ;; DEST+0 contains the length of the command-line. i.e. pascal-string 43 | ;; DEST+1 contains " " 44 | ;; DEST+2 contains the argument, assuming no extra space. 45 | ;; 46 | ;; Read the first digit of the parameter, only, and use it. 47 | ;; 48 | ld A,(DEST+2) 49 | sub '0' 50 | ld c, a 51 | 52 | ;; set the value 53 | ld a, 31 54 | ld HL, 01 55 | out (0xff), a 56 | 57 | ;; fall-through to showing the (updated) value. 58 | 59 | show_value: 60 | ld de, SHOW_PREFIX 61 | ld c, BDOS_OUTPUT_STRING 62 | call BDOS_ENTRY_POINT 63 | 64 | ;; get the value 65 | ld a, 31 66 | ld HL, 01 67 | ld c, 0xff 68 | out (0xff), a 69 | 70 | ;; display it 71 | add a, '0' 72 | ld e, a 73 | ld c, 0x02 74 | call 0x0005 75 | 76 | ;; finish with a newline. 77 | ld DE, NEWLINE 78 | ld C, 0x09 79 | call 0x0005 80 | 81 | ;; Exit 82 | RST 0x00 83 | 84 | 85 | ;; 86 | ;; Text output strings. 87 | ;; 88 | SHOW_PREFIX: 89 | db "The Ctrl-C count is currently set to $" 90 | NEWLINE: 91 | db 0xa, 0xd, "$" 92 | 93 | include "common.inc" 94 | 95 | DEST: 96 | END 97 | -------------------------------------------------------------------------------- /static/debug.z80: -------------------------------------------------------------------------------- 1 | ;; debug.asm - Enable/Disable debug-mode 2 | ;; 3 | ;; This uses the custom BIOS function we've added to the BIOS, which was never 4 | ;; present in real CP/M. Consider it a hook into the emulator. 5 | ;; 6 | ;; Debug mode, once enabled, shows a summary of syscalls made and their 7 | ;; results. It is best to use the logfile, but this can be enabled/disabled 8 | ;; at runtime which makes it nicer. 9 | ;; 10 | 11 | BDOS_ENTRY_POINT: EQU 5 12 | BDOS_OUTPUT_STRING: EQU 9 13 | CMDLINE: EQU 0x80 ; default DMA area too 14 | 15 | ;; 16 | ;; CP/M programs start at 0x100. 17 | ;; 18 | ORG 100H 19 | 20 | 21 | ;; Copy the DMA area, which holds our command-line 22 | ;; flags, to a safe area at the foot of our binary. 23 | ;; 24 | LD HL, CMDLINE 25 | LD DE, DEST 26 | LD BC, 128 27 | LDIR 28 | 29 | ;; 30 | ;; Now we can test if we're running under cpmulator 31 | ;; which will trash the DMA area 32 | ;; 33 | call exit_if_not_cpmulator 34 | 35 | ;; 36 | ;; If we didn't get an argument then show the current value 37 | ;; 38 | LD HL,DEST 39 | LD A,(HL) 40 | INC HL 41 | CP 0x00 42 | jr z, show_value 43 | 44 | ;; OK we're running under cpmulator, and we did get a parameter: 45 | ;; 46 | ;; DEST+0 contains the length of the command-line. i.e. pascal-string 47 | ;; DEST+1 contains " " 48 | ;; DEST+2 contains the argument, assuming no extra space. 49 | ;; 50 | ;; Read the first digit of the parameter, only, and use it. 51 | ;; 52 | ld A,(DEST+2) 53 | cp '1' 54 | jr z, set_debug 55 | cp '0' 56 | jr z, unset_debug 57 | 58 | jr unknown_argument 59 | 60 | 61 | set_debug: 62 | ld c, 0x01 63 | jr set_debug_middle 64 | 65 | unset_debug: 66 | ld c, 0x00 67 | set_debug_middle: 68 | ld HL, 0x06 69 | ld a, 31 70 | out (0xff), a 71 | 72 | ;; fall-through to show the value 73 | 74 | ;; get the value of the flag 75 | show_value: 76 | ld c, 0xff 77 | ld HL, 0x06 78 | ld a, 31 79 | out (0xff), a 80 | 81 | ld a,c 82 | cp 0x00 83 | jr z,show_debug_off 84 | cp 0x01 85 | jr z, show_debug_on 86 | 87 | ;; unknown value 88 | LD DE, MODE_UNKNOWN 89 | LD C, BDOS_OUTPUT_STRING 90 | call BDOS_ENTRY_POINT 91 | 92 | ;; fall-through 93 | 94 | ;; Exit 95 | exit: 96 | RST 0x00 97 | 98 | show_debug_off: 99 | LD DE, MODE_OFF 100 | LD C, BDOS_OUTPUT_STRING 101 | call BDOS_ENTRY_POINT 102 | jr exit 103 | 104 | show_debug_on: 105 | LD DE, MODE_ON 106 | LD C, BDOS_OUTPUT_STRING 107 | call BDOS_ENTRY_POINT 108 | jr exit 109 | 110 | ;; 111 | ;; Error Routines 112 | ;; 113 | unknown_argument: 114 | LD DE, WRONG_ARGUMENT 115 | LD C, BDOS_OUTPUT_STRING 116 | call BDOS_ENTRY_POINT 117 | jr exit 118 | 119 | ;; 120 | ;; Text output strings. 121 | ;; 122 | WRONG_ARGUMENT: 123 | db "Usage: DEBUG [0|1]", 0x0a, 0x0d, "$" 124 | MODE_ON: 125 | db "debug mode is on.", 0x0a, 0x0d, "$" 126 | MODE_OFF: 127 | db "debug mode is off.", 0x0a, 0x0d, "$" 128 | MODE_UNKNOWN: 129 | db "Failed to determine the state of debug mode.", 0x0a, 0x0d, "$" 130 | 131 | include "common.inc" 132 | 133 | DEST: 134 | END 135 | -------------------------------------------------------------------------------- /static/disable.z80: -------------------------------------------------------------------------------- 1 | ;; disable.z80 - Disable the embedded filesystem we present, and custom BIOS. 2 | ;; 3 | ;; This uses the custom BIOS function we've added to the BIOS, which was never 4 | ;; present in real CP/M. Consider it a hook into the emulator. 5 | ;; 6 | 7 | BDOS_ENTRY_POINT: EQU 5 8 | BDOS_OUTPUT_STRING: EQU 9 9 | CMDLINE: EQU 0x80 ; default DMA area too 10 | 11 | ;; 12 | ;; CP/M programs start at 0x100. 13 | ;; 14 | ORG 100H 15 | 16 | ;; Copy the DMA area, which holds our command-line 17 | ;; flags, to a safe area at the foot of our binary. 18 | ;; 19 | LD HL, CMDLINE 20 | LD DE, DEST 21 | LD BC, 128 22 | LDIR 23 | 24 | ;; 25 | ;; Now we can test if we're running under cpmulator 26 | ;; which will trash the DMA area 27 | ;; 28 | call exit_if_not_cpmulator 29 | 30 | 31 | ;; 32 | ;; If we didn't get an argument then show an error 33 | ;; 34 | LD HL,DEST 35 | LD A,(HL) 36 | INC HL 37 | CP 0x00 38 | jr nz, got_value ; Got a value. 39 | 40 | ;; Nothing specified, show error and terminate 41 | LD DE, USAGE_INFORMATION 42 | LD C, BDOS_OUTPUT_STRING 43 | call BDOS_ENTRY_POINT 44 | jr exit 45 | 46 | 47 | ;; 48 | ;; DEST+0 contains the length of the command-line. i.e. pascal-string 49 | ;; DEST+1 contains " " 50 | ;; DEST+2 contains the argument, assuming no extra space. 51 | ;; 52 | got_value: 53 | ld hl, DEST+2 54 | ld de, ALL 55 | call StrCmp 56 | jr z, disable_all 57 | 58 | ld hl, DEST+2 59 | ld de, BIOS 60 | call StrCmp 61 | jr z, disable_bios 62 | 63 | ld hl, DEST+2 64 | ld de, BOTH 65 | call StrCmp 66 | jr z, disable_both 67 | 68 | ld hl, DEST+2 69 | ld de, FS 70 | call StrCmp 71 | jr z, disable_fs 72 | 73 | 74 | ;; Unknown argument 75 | LD DE, WRONG_ARGUMENT 76 | LD C, BDOS_OUTPUT_STRING 77 | call BDOS_ENTRY_POINT 78 | ;; fall-through 79 | exit: 80 | RST 0x00 81 | 82 | disable_all: 83 | ld hl, 0x0009 84 | ld de, 0x0004 85 | ld a, 31 86 | out (0xff), a 87 | jr exit 88 | 89 | disable_bios: 90 | ld hl, 0x0009 91 | ld de, 0x0002 92 | ld a, 31 93 | out (0xff), a 94 | jr exit 95 | 96 | disable_both: 97 | ld hl, 0x0009 98 | ld de, 0x0003 99 | ld a, 31 100 | out (0xff), a 101 | jr exit 102 | 103 | disable_fs: 104 | ld hl, 0x0009 105 | ld de, 0x0001 106 | ld a, 31 107 | out (0xff), a 108 | jr exit 109 | 110 | 111 | 112 | ;; strcmp: Compares string at DE with string at HL. 113 | ;; result in the Z-flag 114 | StrCmp: 115 | ld a, (hl) 116 | cp $0 117 | ret z 118 | ld b, a 119 | ld a, (de) 120 | cp $0 121 | ret z 122 | cp b 123 | ret nz 124 | inc hl 125 | inc de 126 | jr StrCmp 127 | 128 | ;; 129 | ;; Text output strings. 130 | ;; 131 | WRONG_ARGUMENT: 132 | db "Unknown argument, ignoring it.", 0x0a, 0x0d 133 | ;; FALL-THROUGH 134 | USAGE_INFORMATION: 135 | db "Usage: DISABLE [ALL|BIOS|BOTH|FS]", 0x0a, 0x0d 136 | db " BIOS - Disable our BIOS extensions.", 0x0a, 0x0d 137 | db " FS - Disable the embedded filesystem which hosts our extension binaries.", 0x0a, 0x0d 138 | db " ALL - Disable both things, quietly.", 0x0a, 0x0d 139 | db " BOTH - Disable both things.", 0x0a, 0x0d 140 | db "$" 141 | 142 | 143 | ;; arguments are upper-cased. 144 | ALL: 145 | db "ALL", 0x00 146 | BIOS: 147 | db "BIOS", 0x00 148 | BOTH: 149 | db "BOTH", 0x00 150 | FS: 151 | db "FS", 0x00 152 | 153 | include "common.inc" 154 | 155 | DEST: 156 | 157 | END 158 | -------------------------------------------------------------------------------- /static/hostcmd.z80: -------------------------------------------------------------------------------- 1 | ;; hostcmd.z80 - Enable executing commands on the host 2 | ;; 3 | ;; This uses the custom BIOS function we've added to the BIOS, which was never 4 | ;; present in real CP/M. Consider it a hook into the emulator. 5 | ;; 6 | 7 | CMDLINE: EQU 0x80 8 | BDOS_ENTRY_POINT: EQU 5 9 | BDOS_OUTPUT_STRING: EQU 9 10 | 11 | ;; 12 | ;; CP/M programs start at 0x100. 13 | ;; 14 | ORG 100H 15 | 16 | ;; Copy the DMA area, which holds our command-line 17 | ;; flags, to a safe area at the foot of our binary. 18 | ;; 19 | LD HL, CMDLINE 20 | LD DE, DEST 21 | LD BC, 128 22 | LDIR 23 | 24 | ;; 25 | ;; Now we can test if we're running under cpmulator 26 | ;; which will trash the DMA area 27 | ;; 28 | call exit_if_not_cpmulator 29 | 30 | 31 | ;; 32 | ;; If we didn't get an argument then show the current value 33 | ;; 34 | LD HL,DEST 35 | LD A,(HL) 36 | INC HL 37 | CP 0x00 38 | JR Z,show_value 39 | 40 | ;; OK we're running under cpmulator, and we did get a parameter 41 | ;; Point DE to that and invoke the function. 42 | ;; 43 | ;; DEST+0 contains the length of the command-line. i.e. pascal-string 44 | ;; DEST+1 contains " " 45 | ;; DEST+2 contains the argument, assuming no extra space. 46 | ;; 47 | ld HL, 08 48 | ld de, DEST + 2 49 | ld a, 31 50 | out (0xff), a 51 | 52 | exit: 53 | RST 0x00 54 | 55 | ;; Show the current value 56 | show_value: 57 | ;; just set the first byte of the DMA area to null 58 | LD HL, 0x0080 59 | LD (HL), 0x00 60 | 61 | ld HL, 08 62 | ld de, 0x0000 63 | ld a, 31 64 | out (0xff), a 65 | 66 | ;; Now the DMA area, defaulting to 0x0080 67 | ;; will contain the value which is set. 68 | LD HL, 0x0080 69 | 70 | ;; Is the value null? 71 | LD A, (HL) 72 | CP 0 73 | jr z, unset 74 | 75 | push hl ; hl points to the DMA area. 76 | 77 | LD DE, OUTPUT_PREFIX ;; Show a prefix 78 | LD C, BDOS_OUTPUT_STRING 79 | CALL BDOS_ENTRY_POINT 80 | 81 | pop hl ; HL points back to the DMA area; one byte shorter to save/restore than set. 82 | call print_string 83 | 84 | LD DE, OUTPUT_SUFFIX ;; Show a suffix 85 | LD C, BDOS_OUTPUT_STRING 86 | CALL BDOS_ENTRY_POINT 87 | jr exit 88 | 89 | 90 | ;; The host command-prefix is unset 91 | unset: 92 | LD DE, UNSET_STRING 93 | LD C, BDOS_OUTPUT_STRING 94 | CALL BDOS_ENTRY_POINT 95 | jr exit 96 | 97 | 98 | ;; 99 | ;; Text output strings. 100 | ;; 101 | UNSET_STRING: 102 | db "The prefix for executing commands on the host is unset.", 0x0a, 0x0d 103 | db "Running commands on the host is disabled.", 0x0a, 0x0d, "$" 104 | OUTPUT_PREFIX: 105 | db "The command-prefix for executing commands on the host is '$" 106 | OUTPUT_SUFFIX: 107 | db "'.", 0x0a, 0x0d 108 | db "Run '!hostcmd /clear' to disable running commands on the host", 0x0a, 0x0d, "$" 109 | 110 | include "common.inc" 111 | 112 | 113 | ;; Copied area 114 | DEST: 115 | END 116 | -------------------------------------------------------------------------------- /static/input.z80: -------------------------------------------------------------------------------- 1 | ;; input.z80 - Set the name of the console driver to use for input. 2 | ;; 3 | ;; This uses the custom BIOS function we've added to the BIOS, which was never 4 | ;; present in real CP/M. Consider it a hook into the emulator. 5 | ;; 6 | 7 | BDOS_ENTRY_POINT: EQU 5 8 | BDOS_OUTPUT_STRING: EQU 9 9 | CMDLINE: EQU 0x80 ; default DMA area too 10 | 11 | ;; 12 | ;; CP/M programs start at 0x100. 13 | ;; 14 | ORG 100H 15 | 16 | ;; Copy the DMA area, which holds our command-line 17 | ;; flags, to a safe area at the foot of our binary. 18 | ;; 19 | LD HL, CMDLINE 20 | LD DE, DEST 21 | LD BC, 128 22 | LDIR 23 | 24 | ;; 25 | ;; Now we can test if we're running under cpmulator 26 | ;; which will trash the DMA area 27 | ;; 28 | call exit_if_not_cpmulator 29 | 30 | 31 | ;; 32 | ;; If we didn't get an argument then show the driver 33 | ;; 34 | LD HL,DEST 35 | LD A,(HL) 36 | INC HL 37 | CP 0x00 38 | JR Z,show_value 39 | 40 | 41 | ;; OK we're running under cpmulator, and we did get a parameter 42 | ;; Point DE to that and invoke the function. 43 | ;; 44 | ;; DEST+0 contains the length of the command-line. i.e. pascal-string 45 | ;; DEST+1 contains " " 46 | ;; DEST+2 contains the argument, assuming no extra space. 47 | ;; 48 | ld HL, 07 49 | ld de, DEST+2 50 | ld a, 31 51 | out (0xff), a 52 | 53 | exit: 54 | RST 0x00 55 | 56 | 57 | ;; Show the current value 58 | show_value: 59 | LD DE, CONSOLE_PREFIX ; Print a prefix 60 | LD C, BDOS_OUTPUT_STRING 61 | CALL BDOS_ENTRY_POINT 62 | 63 | ;; Use the given function to call a custom BIOS 64 | ;; routine which will the result as ASCII text 65 | ;; in the DMA-buffer. 66 | ld hl, 0x0007 67 | call show_bios_value 68 | 69 | LD DE, CONSOLE_SUFFIX ; Print a suffix 70 | LD C, BDOS_OUTPUT_STRING 71 | CALL BDOS_ENTRY_POINT 72 | jr exit 73 | 74 | 75 | ;; 76 | ;; Text output strings. 77 | ;; 78 | CONSOLE_PREFIX: 79 | db "The input driver is set to $" 80 | CONSOLE_SUFFIX: 81 | db ".", 0x0a, 0x0d, "$" 82 | 83 | include "common.inc" 84 | 85 | ;; Copied area 86 | DEST: 87 | 88 | END 89 | -------------------------------------------------------------------------------- /static/output.z80: -------------------------------------------------------------------------------- 1 | ;; output.z80 - Set the name of the console driver to use for output 2 | ;; 3 | ;; This uses the custom BIOS function we've added to the BIOS, which was never 4 | ;; present in real CP/M. Consider it a hook into the emulator. 5 | ;; 6 | 7 | CMDLINE: EQU 0x80 8 | BDOS_ENTRY_POINT: EQU 5 9 | BDOS_OUTPUT_STRING: EQU 9 10 | 11 | ;; 12 | ;; CP/M programs start at 0x100. 13 | ;; 14 | ORG 100H 15 | 16 | ;; Copy the DMA area, which holds our command-line 17 | ;; flags, to a safe area at the foot of our binary. 18 | ;; 19 | LD HL, CMDLINE 20 | LD DE, DEST 21 | LD BC, 128 22 | LDIR 23 | 24 | ;; 25 | ;; Now we can test if we're running under cpmulator 26 | ;; which will trash the DMA area 27 | ;; 28 | call exit_if_not_cpmulator 29 | 30 | ;; 31 | ;; If we didn't get an argument then show the driver 32 | ;; 33 | LD HL,DEST 34 | LD A,(HL) 35 | INC HL 36 | CP 0x00 37 | JR Z,show_value 38 | 39 | 40 | ;; OK we're running under cpmulator, and we did get a parameter 41 | ;; Point DE to that and invoke the function. 42 | ;; 43 | ;; DEST+0 contains the length of the command-line. i.e. pascal-string 44 | ;; DEST+1 contains " " 45 | ;; DEST+2 contains the argument, assuming no extra space. 46 | ;; 47 | ld de, DEST+2 48 | ld HL, 02 49 | ld a, 31 50 | out (0xff), a 51 | 52 | exit: 53 | RST 0x00 54 | 55 | 56 | ;; Show the current value. 57 | show_value: 58 | ld de, CONSOLE_PREFIX ; show a prefix 59 | ld c, BDOS_OUTPUT_STRING 60 | call BDOS_ENTRY_POINT 61 | 62 | ;; Use the given function to call a custom BIOS 63 | ;; routine which will the result as ASCII text 64 | ;; in the DMA-buffer. 65 | ld hl, 0x0002 66 | call show_bios_value 67 | 68 | ld de, CONSOLE_SUFFIX ; Show a suffix 69 | ld c, BDOS_OUTPUT_STRING 70 | call BDOS_ENTRY_POINT 71 | jr exit 72 | 73 | ;; 74 | ;; Text output strings. 75 | ;; 76 | CONSOLE_PREFIX: 77 | db "The output driver is set to $" 78 | CONSOLE_SUFFIX: 79 | db ".", 0x0a, 0x0d, "$" 80 | 81 | include "common.inc" 82 | 83 | ;; Copied area 84 | DEST: 85 | 86 | END 87 | -------------------------------------------------------------------------------- /static/prnpath.z80: -------------------------------------------------------------------------------- 1 | ;; prnpath.z80 - Set the filename to log printer output to. 2 | ;; 3 | ;; This uses the custom BIOS function we've added to the BIOS, which was never 4 | ;; present in real CP/M. Consider it a hook into the emulator. 5 | ;; 6 | 7 | BDOS_OUTPUT_STRING: EQU 9 8 | BDOS_ENTRY_POINT: EQU 5 9 | CMDLINE: EQU 0x80 ; default DMA area too 10 | 11 | 12 | ;; 13 | ;; CP/M programs start at 0x100. 14 | ;; 15 | ORG 100H 16 | 17 | ;; Copy the DMA area, which holds our command-line 18 | ;; flags, to a safe area at the foot of our binary. 19 | ;; 20 | LD HL, CMDLINE 21 | LD DE, DEST 22 | LD BC, 128 23 | LDIR 24 | 25 | ;; 26 | ;; Now we can test if we're running under cpmulator 27 | ;; which will trash the DMA area 28 | ;; 29 | call exit_if_not_cpmulator 30 | 31 | ;; 32 | ;; If we didn't get an argument then show the value 33 | ;; 34 | LD HL,DEST 35 | LD A,(HL) 36 | INC HL 37 | CP 0x00 38 | JR Z,show_value 39 | 40 | 41 | ;; OK we're running under cpmulator, and we did get a parameter 42 | ;; Point DE to that and invoke the function. 43 | ;; 44 | ;; DEST+0 contains the length of the command-line. i.e. pascal-string 45 | ;; DEST+1 contains " " 46 | ;; DEST+2 contains the argument, assuming no extra space. 47 | ;; 48 | ld hl, 0x0a 49 | ld de, DEST+2 50 | ld a, 31 51 | out (0xff), a 52 | 53 | exit: 54 | rst 0x00 55 | 56 | 57 | ;; Show the current value 58 | show_value: 59 | ld de, OUTPUT_PREFIX ; show a prefix 60 | ld c, BDOS_OUTPUT_STRING 61 | call BDOS_ENTRY_POINT 62 | 63 | ;; Use the given function to call a custom BIOS 64 | ;; routine which will the result as ASCII text 65 | ;; in the DMA-buffer. 66 | ld hl, 0x000a 67 | call show_bios_value 68 | 69 | ld de, OUTPUT_SUFFIX ; show a suffix 70 | ld c, BDOS_OUTPUT_STRING 71 | call BDOS_ENTRY_POINT 72 | jr exit 73 | 74 | 75 | ;; 76 | ;; Text output strings. 77 | ;; 78 | OUTPUT_PREFIX: 79 | db "The printer is logging output to the file '$" 80 | OUTPUT_SUFFIX: 81 | db "'.", 0x0a, 0x0d, "$" 82 | 83 | include "common.inc" 84 | 85 | DEST: 86 | END 87 | -------------------------------------------------------------------------------- /static/static.go: -------------------------------------------------------------------------------- 1 | // Package static is a hierarchy of files that are added to 2 | // the generated emulator. 3 | // 4 | // The intention is that we can ship a number of binary CP/M 5 | // files within our emulator. 6 | package static 7 | 8 | import "embed" 9 | 10 | //go:embed */* 11 | var content embed.FS 12 | 13 | // empty has no contents. 14 | var empty embed.FS 15 | 16 | // GetContent returns the embedded filesystem we store within this package. 17 | func GetContent() embed.FS { 18 | return content 19 | } 20 | 21 | // GetEmptyContent returns the embedded filesystem we store within this package which has no contents. 22 | func GetEmptyContent() embed.FS { 23 | return empty 24 | } 25 | -------------------------------------------------------------------------------- /static/static_test.go: -------------------------------------------------------------------------------- 1 | package static 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | // TestStatic just ensures we have some files. 9 | func TestStatic(t *testing.T) { 10 | 11 | // Read the subdirectory 12 | files, err := GetContent().ReadDir("A") 13 | if err != nil { 14 | t.Fatalf("error reading contents") 15 | } 16 | 17 | // Ensure each file is a .COM files 18 | for _, entry := range files { 19 | name := entry.Name() 20 | if !strings.HasSuffix(name, ".COM") { 21 | t.Fatalf("file '%s' is not a .COM file", name) 22 | } 23 | } 24 | } 25 | 26 | // TestEmpty ensures we have no files. 27 | func TestEmpty(t *testing.T) { 28 | 29 | // Read the subdirectory 30 | files, err := GetEmptyContent().ReadDir(".") 31 | if err != nil { 32 | t.Fatalf("error reading contents") 33 | } 34 | 35 | if len(files) != 0 { 36 | t.Fatalf("got files, but expected none") 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /static/version.z80: -------------------------------------------------------------------------------- 1 | ;; version.z80 - Show the version of the emulator we're running on. 2 | ;; 3 | ;; This uses the custom BIOS function we've added to the BIOS, which was never 4 | ;; present in real CP/M. Consider it a hook into the emulator. 5 | ;; 6 | 7 | BDOS_ENTRY_POINT: EQU 5 8 | BDOS_OUTPUT_STRING: EQU 9 9 | 10 | ;; 11 | ;; CP/M programs start at 0x100. 12 | ;; 13 | ORG 100H 14 | 15 | ;; This call will a) test that we're running under 16 | ;; our emulator, and b) setup a version string in the DMA 17 | ;; area. 18 | call exit_if_not_cpmulator 19 | 20 | ;; Okay then the version will be stored in the DMA area. 21 | ;; print it. 22 | ld hl, 0x0080 23 | call print_string 24 | 25 | ;; Exit 26 | RST 0x00 27 | 28 | include "common.inc" 29 | END 30 | -------------------------------------------------------------------------------- /test/README.md: -------------------------------------------------------------------------------- 1 | # Test 2 | 3 | This directory contains a some simple test-cases which can be used upon a Linux/MacOS host to run functional tests of the emulator. 4 | 5 | The general approach is to fake input to the emulator, such that we tell it to run "A:FOO", or similar, and confirm we get the output we expect. 6 | 7 | 8 | 9 | ## Assumptions 10 | 11 | * For this to work you'll need to have `cpm-dist` cloned in the same parent-directory to this repository. 12 | * You'll need to create a suitable pre-cooked input file. 13 | 14 | 15 | 16 | ## Usage 17 | 18 | To run all tests, from the parent-directory, execute: 19 | 20 | ``` 21 | $ ./test/run-tests.sh 22 | ``` 23 | 24 | To run a specific test: 25 | 26 | ``` 27 | $ ./test/run-test.sh zork1 28 | ``` 29 | 30 | 31 | 32 | ## Implementation 33 | 34 | The idea is that to write a new test there will be two files created: 35 | 36 | * `foo.in` 37 | * This will contain the input which the test should pass to the emulator. 38 | * **NOTE**: That a `#` character will insert a 1 second delay in input reading. 39 | * `foo.pat` 40 | * This file contains one regular expression per line. 41 | * If the output of running the test DOES NOT MATCH each pattern that is a fatal error. 42 | * **NOTE**: Don't forget the trailing newline. 43 | 44 | 45 | 46 | ## Configuration 47 | 48 | We've had to had some configuration to the test-cases to work with assumptions, at the moment 49 | the only real config options relate to newline handling. 50 | 51 | * `newline` specifies which kind of line-endings to send: 52 | * "n" 53 | * A newline is \n 54 | * "m" 55 | * A newline is Ctrl-M 56 | * "both" 57 | * A newline is the pair "Ctrl-m" & "\n", in that order. 58 | * `pause-on-newline:true` 59 | * Will sleep for five seconds after sending a newline. 60 | -------------------------------------------------------------------------------- /test/a1.in: -------------------------------------------------------------------------------- 1 | # A1.com is an Apple Emulator :) 2 | # 3 | # We test the Apple BASIC interpreter 4 | -- 5 | A:A1 6 | E000R 7 | 10 FOR I = 1 TO 10 8 | 20 PRINT I, I*I*I 9 | 30 NEXT I 10 | LIST 11 | RUN 12 |  13 | 14 | EXIT 15 | EXIT -------------------------------------------------------------------------------- /test/a1.pat: -------------------------------------------------------------------------------- 1 | I,I 2 | 729 3 | -------------------------------------------------------------------------------- /test/bbcbasic1.in: -------------------------------------------------------------------------------- 1 | # 2 | # The BBC BASIC interpreter needs the fake ^M 3 | # to be inserted with our newlines, otherwise 4 | # it won't react to keyboard input. 5 | # 6 | newline: both 7 | pause-on-newline:true 8 | -- 9 | B: 10 | BBCBASIC 11 | PRINT 1 + 2 * 3 12 | *BYE 13 | 14 | EXIT 15 | -------------------------------------------------------------------------------- /test/bbcbasic1.pat: -------------------------------------------------------------------------------- 1 | 7 2 | BBC BASIC (Z80) Version 3.00 3 | -------------------------------------------------------------------------------- /test/bbcbasic2.in: -------------------------------------------------------------------------------- 1 | # 2 | # The BBC BASIC interpreter needs the fake ^M 3 | # to be inserted with our newlines, otherwise 4 | # it won't react to keyboard input. 5 | # 6 | newline: both 7 | pause-on-newline:true 8 | -- 9 | B: 10 | BBCBASIC 11 | 10 FOR I=1 TO 5 12 | 20 PRINT I,I*I 13 | 30 NEXT I 14 | LIST 15 | RUN 16 | *BYE 17 | 18 | EXIT 19 | -------------------------------------------------------------------------------- /test/bbcbasic2.pat: -------------------------------------------------------------------------------- 1 | I,I 2 | 16 3 | 25 4 | Copyright R.T.Russell 1987 5 | -------------------------------------------------------------------------------- /test/ccp1.in: -------------------------------------------------------------------------------- 1 | # 2 | # This is the shortest test ever! 3 | # 4 | -- 5 | EXIT 6 | -------------------------------------------------------------------------------- /test/ccp1.pat: -------------------------------------------------------------------------------- 1 | Console input:file 2 | Console output:adm-3a 3 | BIOS:0xFE00 4 | BDOS:0xFA00 5 | CCP:ccp 6 | -------------------------------------------------------------------------------- /test/ccp2.in: -------------------------------------------------------------------------------- 1 | # 2 | # This is a more substantial CCP-test 3 | # 4 | -- 5 | A:!ctrlc 6 | !hostcmd 7 | !ccp ccp 8 | !ccp ccp 9 | !ccp ccpz 10 | !ccp ccpz 11 | !ccp Monday 12 | Exit 13 | -------------------------------------------------------------------------------- /test/ccp2.pat: -------------------------------------------------------------------------------- 1 | The Ctrl-C count is currently set to 2 | The prefix for executing commands on the host is unset. 3 | Running commands on the host is disabled. 4 | CCP is already set to ccp, doing nothing. 5 | CCP changed to ccpz 6 | CCP is already set to ccpz, doing nothing. 7 | Error changing CCP to monday, CCP monday not found - valid choices are: ccp,ccpz 8 | -------------------------------------------------------------------------------- /test/ddt.in: -------------------------------------------------------------------------------- 1 | # 2 | # DDT is the debugging utility supplised with CP/M 3 | # 4 | # Here we erase the binary artifacts, then compile 5 | # HELLO.COM from HELLO.ASM, and use the (T)race 6 | # command to single-step through some execution. 7 | # 8 | -- 9 | A: 10 | ERA HELLO.COM 11 | ERA HELLO.HEX 12 | ERA HELLO.PRN 13 | hello 14 | ASM HELLO 15 | LOAD HELLO 16 | DDT HELLO.com 17 | t 18 | t 19 | t 20 | t 21 | t 22 | t 23 | t 24 | t 25 | t 26 | t 27 | t 28 | t 29 | t 30 | t 31 | t 32 | t 33 |  34 | EXIT 35 | -------------------------------------------------------------------------------- /test/ddt.pat: -------------------------------------------------------------------------------- 1 | DDT VERS 2.2 2 | Hello, World! 3 | CALL 0005 4 | -------------------------------------------------------------------------------- /test/echo.in: -------------------------------------------------------------------------------- 1 | # 2 | # This test is annoying because it is logically simple, but 3 | # complex in execution. 4 | # 5 | # In theory we just run "CC ECHO", "AS ECHO", and "LN ECHO.O C.LIB" 6 | # however the compiler, assembler, and linker all seem to poll 7 | # for keyboard input - and if they see input they abort. 8 | # 9 | # So we have to either use "#" to block execution for a few seconds 10 | # here or there, or do the same for each newline. 11 | # 12 | pause-on-newline:true 13 | -- 14 | C: 15 | ERA ECHO.ASM 16 | ERA ECHO.O 17 | ERA ECHO.COM 18 | DIR ECHO.* 19 | echo 20 | CC ECHO 21 | AS ECHO 22 | LN ECHO.O C.LIB 23 | ECHO foo bar cake 24 | EXIT 25 | -------------------------------------------------------------------------------- /test/echo.pat: -------------------------------------------------------------------------------- 1 | ECHO? 2 | FOO BAR CAKE 3 | -------------------------------------------------------------------------------- /test/hello.in: -------------------------------------------------------------------------------- 1 | # 2 | # This test echos something done in the DDT.IN test 3 | # however it is distinct and simpler. 4 | # 5 | -- 6 | A: 7 | ERA HELLO.COM 8 | ERA HELLO.HEX 9 | ERA HELLO.PRN 10 | hello 11 | ASM HELLO 12 | LOAD HELLO 13 | HELLO 14 | EXIT 15 | -------------------------------------------------------------------------------- /test/hello.pat: -------------------------------------------------------------------------------- 1 | HELLO? 2 | CP/M ASSEMBLER - VER 2.0 3 | FIRST ADDRESS 0100 4 | Hello, World! 5 | -------------------------------------------------------------------------------- /test/input.in: -------------------------------------------------------------------------------- 1 | # 2 | # This is a test of A:!INPUT.COM 3 | # 4 | -- 5 | A:!input 6 | !input file 7 | !input steve 8 | Exit 9 | -------------------------------------------------------------------------------- /test/input.pat: -------------------------------------------------------------------------------- 1 | The input driver is set to file. 2 | The input driver is already file, doing nothing. 3 | Error creating the new driver, failed to lookup driver by name 'steve'. 4 | -------------------------------------------------------------------------------- /test/lighthouse1.in: -------------------------------------------------------------------------------- 1 | # 2 | # This test-case just launches the lighthouse of doom game, 3 | # and then quits. 4 | # 5 | # It tests that the game starts, which was a problem in the 6 | # past because our CP/M BIOS was located within a region of 7 | # RAM the game used. 8 | # 9 | -- 10 | G: 11 | LIHOUSE 12 | f 13 | QUIT 14 | N 15 | 16 | EXIT 17 | -------------------------------------------------------------------------------- /test/lighthouse1.pat: -------------------------------------------------------------------------------- 1 | A small torch 2 | -------------------------------------------------------------------------------- /test/lighthouse2.in: -------------------------------------------------------------------------------- 1 | # 2 | # This test-case launches the lighthouse of doom game, 3 | # and then plays it to completion. 4 | # 5 | # It also tests that the game starts, which was a problem in the 6 | # past because our CP/M BIOS was located within a region of 7 | # RAM the game used. 8 | # 9 | -- 10 | G: 11 | LIHOUSE 12 | f 13 | TAKE TORCH 14 | DOWN 15 | DOWN 16 | EXAMINE RUG 17 | USE TORCH 18 | OPEN TRAPDOOR 19 | DOWN 20 | TAKE GENERATOR 21 | Up 22 | UP 23 | UP 24 | DROP GENERATOR 25 | USE GENERATOR 26 | N 27 | 28 | EXIT 29 | -------------------------------------------------------------------------------- /test/lighthouse2.pat: -------------------------------------------------------------------------------- 1 | Paw Patrol 2 | Congratulations 3 | You won! 4 | Resetting 5 | -------------------------------------------------------------------------------- /test/lighthouse3.in: -------------------------------------------------------------------------------- 1 | # 2 | # This test-case launches the lighthouse of doom game, 3 | # and then plays it to completion. 4 | # 5 | # It also tests that the game starts, which was a problem in the 6 | # past because our CP/M BIOS was located within a region of 7 | # RAM the game used. 8 | # 9 | -- 10 | G: 11 | LIHOUSE 12 | f 13 | DOWN 14 | CALL ME 15 | CALL STEVE 16 | CALL POLICE 17 | CALL RUBBLE 18 | CALL SKYE 19 | CALL RYDER 20 | CALL GHOSTBUSTERS 21 | QUIT 22 | N 23 | 24 | EXIT 25 | -------------------------------------------------------------------------------- /test/lighthouse3.pat: -------------------------------------------------------------------------------- 1 | Debbie Harry says 'hello' 2 | steve@steve.fi 3 | lack of a functioning police force. 4 | Who you gonna call? 5 | No pup is too small, no job is too big 6 | enjoying a nap 7 | -------------------------------------------------------------------------------- /test/mbasic1.in: -------------------------------------------------------------------------------- 1 | # 2 | # The BBC BASIC interpreter needs the fake ^M 3 | # to be inserted with our newlines, otherwise 4 | # it won't react to keyboard input. 5 | # 6 | # The pause here isn't as necessary as in mbasic2. 7 | # 8 | newline: both 9 | pause-on-newline:true 10 | -- 11 | B: 12 | MBASIC 13 | PRINT 1 + 2 * 3 14 | SYSTEM 15 | 16 | 17 | EXIT 18 | -------------------------------------------------------------------------------- /test/mbasic1.pat: -------------------------------------------------------------------------------- 1 | 7 2 | 5.29 3 | Microsoft 4 | -------------------------------------------------------------------------------- /test/mbasic2.in: -------------------------------------------------------------------------------- 1 | # 2 | # The BBC BASIC interpreter needs the fake ^M 3 | # to be inserted with our newlines, otherwise 4 | # it won't react to keyboard input. 5 | # 6 | # We also have to pause on newlines, because otheriwse 7 | # the "LIST" will poll for keyboard input and swallow 8 | # some of the following characters. 9 | # 10 | # That will manifest itself as "LIST" working as expected 11 | # but the next command being "N" showing a syntax error 12 | # as the "RU" have been swallowed. 13 | # 14 | newline: both 15 | pause-on-newline:true 16 | -- 17 | B: 18 | MBASIC 19 | 10 FOR I=1 TO 5 20 | 20 PRINT I,I*I 21 | 30 NEXT I 22 | LIST 23 | RUN 24 | SYSTEM 25 | 26 | 27 | EXIT 28 | -------------------------------------------------------------------------------- /test/mbasic2.pat: -------------------------------------------------------------------------------- 1 | 16 2 | 5.29 3 | Microsoft 4 | -------------------------------------------------------------------------------- /test/output.in: -------------------------------------------------------------------------------- 1 | # 2 | # This is a test of A:!OUTPUT.COM 3 | # 4 | -- 5 | A:!output 6 | !output ansi 7 | !output ansi 8 | A:!output null 9 | A:!output null 10 | !output adm-3a 11 | !output easter.eggs 12 | Exit 13 | -------------------------------------------------------------------------------- /test/output.pat: -------------------------------------------------------------------------------- 1 | The output driver is set to adm-3a. 2 | The output driver is already ansi, doing nothing. 3 | The output driver has been changed from adm-3a to ansi. 4 | The output driver has been changed from null to adm-3a. 5 | Changing output driver failed, failed to lookup driver by name 'easter.eggs'. 6 | -------------------------------------------------------------------------------- /test/run-test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Run a single test: 4 | # 5 | # 1. feed some pre-cooked input to CPMUlater 6 | # 7 | # 2. Collect all the output. 8 | # 9 | # 3. Ensure that every line of patterns we expect was found in the output 10 | # 11 | 12 | 13 | # 14 | # Sanity-check 15 | # 16 | if [ ! -d ../cpm-dist ] ; then 17 | echo "cpm-dist is not present at ../cpm-dist" 18 | echo "Aborting" 19 | exit 1 20 | fi 21 | 22 | if [ ! -x "$(command -v ansifilter)" ]; then 23 | echo "ansifilter is not in your \$PATH" 24 | echo " brew install ansifilter" 25 | echo " apt-get install ansifilter" 26 | echo " etc" 27 | exit 1 28 | fi 29 | 30 | echo "Running test case: $1" 31 | input=test/$1.in 32 | pattern=test/$1.pat 33 | output=test/$1.out 34 | 35 | 36 | # 37 | # Ensure we have a test-input and a set of patterns 38 | # 39 | if [ ! -e "$input" ] ; then 40 | echo " TATAL: Test $1 has no input." 41 | exit 1 42 | fi 43 | 44 | if [ ! -e "$pattern" ] ; then 45 | echo " TATAL: Test $1 has no patterns to look for." 46 | exit 1 47 | fi 48 | 49 | 50 | # 51 | # Remove any output from previous runs. 52 | # 53 | if [ -e "$output" ] ; then 54 | rm "$output" 55 | fi 56 | 57 | 58 | # 59 | # Spawn run the emulator with the cooked input. 60 | # 61 | export INPUT_FILE="${input}" 62 | start=$(date +%s) 63 | echo " Starting $(date)" 64 | ./cpmulator -input file -cd ../cpm-dist/ -directories -timeout 120 -ccp ccp | ansifilter &> "$output" 65 | end=$(date +%s) 66 | runtime=$((end-start)) 67 | echo " Completed in ${runtime} seconds" 68 | 69 | 70 | # 71 | # Test that the patterns we expect are present in the output. 72 | # 73 | while read -r line; do 74 | if ! grep -q "$line" "$output"; then 75 | echo " FAIL: $line" 76 | echo " Test output saved in $output" 77 | exit 1 78 | else 79 | echo " OK: $line" 80 | fi 81 | done < "$pattern" 82 | 83 | 84 | # 85 | # All done 86 | # 87 | exit 0 88 | -------------------------------------------------------------------------------- /test/run-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Driver: Run all test-cases. 4 | # 5 | 6 | 7 | # 8 | # For each test-case we see 9 | # 10 | for i in test/*.in ; do 11 | 12 | # Get the name, and run that test 13 | name=$(basename "$i" .in) 14 | 15 | ./test/run-test.sh "${name}" 16 | 17 | if [ $? -eq 1 ]; then 18 | echo "Previous test, ${name}, failed. Aborting" 19 | exit 1 20 | fi 21 | 22 | done 23 | -------------------------------------------------------------------------------- /test/tp1.in: -------------------------------------------------------------------------------- 1 | # 2 | # Run the turbo pascal driver, change the compilation 3 | # options, then quit. 4 | # 5 | -- 6 | P: 7 | TURBO 8 | YOCQQ 9 | 10 | EXIT 11 | -------------------------------------------------------------------------------- /test/tp1.pat: -------------------------------------------------------------------------------- 1 | Include error messages 2 | TURBO Pascal system 3 | Main file: 4 | Work file: 5 | Version 3.01A 6 | -------------------------------------------------------------------------------- /test/tp2.in: -------------------------------------------------------------------------------- 1 | # 2 | # Compile a program, with turbo pascal. 3 | # 4 | # The multiple "exits" at the foot of this 5 | # test-case are annoying. 6 | # 7 | newline: both 8 | pause-on-newline: true 9 | -- 10 | P: 11 | ERA HELLO.COM 12 | 13 | TURBO 14 | YOCQMhello.pas 15 | CNNNNQ 16 | DIR HELLO.COM 17 | HELLO 18 |  19 | EXIT 20 | -------------------------------------------------------------------------------- /test/tp2.pat: -------------------------------------------------------------------------------- 1 | Include error messages 2 | TURBO Pascal system 3 | Main file: 4 | Work file: 5 | Version 3.01A 6 | Compiling 7 | 59 bytes 8 | 29868 bytes 9 | Hello, world. 10 | -------------------------------------------------------------------------------- /test/zork1.in: -------------------------------------------------------------------------------- 1 | newline: both 2 | -- 3 | G: 4 | ERA ZORK1.SAV 5 | ZORK1 6 | NORTH 7 | EAST 8 | OPEN WINDOW 9 | ENTER WINDOW 10 | TAKE BOTTLE 11 | OPEN BOTTLE 12 | DRINK WATER 13 | SAVE 14 | 15 | QUIT 16 | Y 17 | EXIT 18 | -------------------------------------------------------------------------------- /test/zork1.pat: -------------------------------------------------------------------------------- 1 | I was rather thirsty 2 | Load SAVE disk then enter 3 | in 7 moves 4 | -------------------------------------------------------------------------------- /test/zork2.in: -------------------------------------------------------------------------------- 1 | newline: both 2 | -- 3 | G: 4 | DIR ZORK1.* 5 | 6 | ZORK1 7 | RESTORE 8 | 9 | OPEN SACK 10 | EAT SANDWICH 11 | QUIT 12 | Y 13 | EXIT 14 | -------------------------------------------------------------------------------- /test/zork2.pat: -------------------------------------------------------------------------------- 1 | It really hit the spot 2 | Your score is 10 3 | in 9 moves 4 | -------------------------------------------------------------------------------- /version/version.go: -------------------------------------------------------------------------------- 1 | // Package version exists solely so that we can store the version of this application 2 | // in one location, despite needing it in two places within the application. 3 | // 4 | // First, and foremost, we need the version to be available by the main.go driver-package, 5 | // but secondly we also want to report our version in one of our expanded BIOS functions. 6 | // 7 | // Duplicating the version number/tag in two places is a recipe for drift and confusion, 8 | // so this internal-package is the result. 9 | package version 10 | 11 | import "fmt" 12 | 13 | var ( 14 | // version is populated with our release tag, via a Github Action. 15 | // 16 | // See .github/build in the source distribution for details. 17 | version = "unreleased" 18 | ) 19 | 20 | // GetVersionBanner returns a banner which is suitable for printing, to show our name, 21 | // version, and homepage link. 22 | func GetVersionBanner() string { 23 | 24 | str := fmt.Sprintf("cpmulator %s\n%s\n", version, "https://github.com/skx/cpmulator/") 25 | return str 26 | } 27 | 28 | // GetVersionString returns our version number as a string. 29 | func GetVersionString() string { 30 | return version 31 | } 32 | -------------------------------------------------------------------------------- /version/version_test.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | // TestVersion is a nop-test that performs coverage of our version package. 9 | func TestVersion(t *testing.T) { 10 | x := GetVersionString() 11 | y := GetVersionBanner() 12 | 13 | // Banner should have our version 14 | if !strings.Contains(y, x) { 15 | t.Fatalf("banner doesn't contain our version") 16 | } 17 | } 18 | --------------------------------------------------------------------------------