From 7aa075b59424dfcce425bff6739b7efc40b4faab Mon Sep 17 00:00:00 2001 From: Zellyn Hunter Date: Fri, 28 Oct 2016 21:20:20 -0400 Subject: [PATCH] Initial commit Includes "applesoft decode" command to convert Applesoft bytes to listings. --- .travis.yml | 4 + LICENSE | 21 +++ README.md | 9 ++ cmd/applesoft.go | 27 ++++ cmd/decode.go | 67 ++++++++++ cmd/root.go | 61 +++++++++ lib/applesoft/applesoft.go | 247 ++++++++++++++++++++++++++++++++++++ lib/applesoft/hello_test.go | 53 ++++++++ lib/applesoft/notes.org | 10 ++ lib/helpers/helpers.go | 17 +++ main.go | 9 ++ 11 files changed, 525 insertions(+) create mode 100644 .travis.yml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cmd/applesoft.go create mode 100644 cmd/decode.go create mode 100644 cmd/root.go create mode 100644 lib/applesoft/applesoft.go create mode 100644 lib/applesoft/hello_test.go create mode 100644 lib/applesoft/notes.org create mode 100644 lib/helpers/helpers.go create mode 100644 main.go diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..8b66227 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,4 @@ +language: go + +go: + - 1.7 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cccf226 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright © 2016 Zellyn Hunter + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0da47a2 --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +diskii +====== +diskii is a commandline tool for working with Apple II disk images. + +Eventually, it aims to be a comprehensive disk image manipulation tool. + +It is pronounced so as to rhyme with "whiskey". + +[![Build Status](https://travis-ci.org/zellyn/diskii.svg?branch=master)](https://travis-ci.org/zellyn/diskii) diff --git a/cmd/applesoft.go b/cmd/applesoft.go new file mode 100644 index 0000000..081a593 --- /dev/null +++ b/cmd/applesoft.go @@ -0,0 +1,27 @@ +// Copyright © 2016 Zellyn Hunter + +package cmd + +import "github.com/spf13/cobra" + +// applesoftCmd represents the applesoft command +var applesoftCmd = &cobra.Command{ + Use: "applesoft", + Short: "work with applesoft programs", + Long: `diskii applesoft contains the subcommands useful for working + with Applesoft programs.`, +} + +func init() { + RootCmd.AddCommand(applesoftCmd) + + // Here you will define your flags and configuration settings. + + // Cobra supports Persistent Flags which will work for this command + // and all subcommands, e.g.: + // applesoftCmd.PersistentFlags().String("foo", "", "A help for foo") + + // Cobra supports local flags which will only run when this command + // is called directly, e.g.: + // applesoftCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") +} diff --git a/cmd/decode.go b/cmd/decode.go new file mode 100644 index 0000000..717daa6 --- /dev/null +++ b/cmd/decode.go @@ -0,0 +1,67 @@ +// Copyright © 2016 Zellyn Hunter + +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/zellyn/diskii/lib/applesoft" + "github.com/zellyn/diskii/lib/helpers" +) + +var location uint16 // flag for starting location in memory +var rawControlCodes bool // flag for whether to skip escaping control codes + +// decodeCmd represents the decode command +var decodeCmd = &cobra.Command{ + Use: "decode filename", + Short: "convert a binary applesoft program to a LISTing", + Long: ` +decode converts a binary Applesoft program to a text LISTing. + +Examples: +decode filename # read filename +decode - # read stdin`, + Run: func(cmd *cobra.Command, args []string) { + if err := runDecode(args); err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(-1) + } + }, +} + +// runDecode performs the actual decode logic. +func runDecode(args []string) error { + if len(args) != 1 { + return fmt.Errorf("decode expects one argument: the filename (or - for stdin)") + } + contents, err := helpers.FileContentsOrStdIn(args[0]) + if err != nil { + return err + } + listing, err := applesoft.Decode(contents, location) + if err != nil { + return err + } + if rawControlCodes { + os.Stdout.WriteString(listing.String()) + } else { + os.Stdout.WriteString(applesoft.ChevronControlCodes(listing.String())) + } + return nil +} + +func init() { + applesoftCmd.AddCommand(decodeCmd) + + // Here you will define your flags and configuration settings. + + // Cobra supports Persistent Flags which will work for this command + // and all subcommands, e.g.: + // decodeCmd.PersistentFlags().String("foo", "", "A help for foo") + + decodeCmd.Flags().Uint16VarP(&location, "location", "l", 0x801, "Starting program location in memory") + decodeCmd.Flags().BoolVarP(&rawControlCodes, "raw", "r", false, "Print raw control codes (no escaping)") +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..3044fd8 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,61 @@ +// Copyright © 2016 Zellyn Hunter + +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var cfgFile string + +// RootCmd represents the base command when called without any subcommands +var RootCmd = &cobra.Command{ + Use: "diskii", + Short: "Operate on Apple II disk images and their contents", + Long: `diskii is a commandline tool for working with Apple II disk +images. + +Eventually, it aims to be a comprehensive disk image manipulation tool.`, +} + +// Execute adds all child commands to the root command sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + if err := RootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(-1) + } +} + +func init() { + cobra.OnInitialize(initConfig) + + // Here you will define your flags and configuration settings. + // Cobra supports Persistent Flags, which, if defined here, + // will be global for your application. + + RootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.diskii.yaml)") + // Cobra also supports local flags, which will only run + // when this action is called directly. + // RootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") +} + +// initConfig reads in config file and ENV variables if set. +func initConfig() { + if cfgFile != "" { // enable ability to specify config file via flag + viper.SetConfigFile(cfgFile) + } + + viper.SetConfigName(".diskii") // name of config file (without extension) + viper.AddConfigPath("$HOME") // adding home directory as first search path + viper.AutomaticEnv() // read in environment variables that match + + // If a config file is found, read it in. + if err := viper.ReadInConfig(); err == nil { + fmt.Println("Using config file:", viper.ConfigFileUsed()) + } +} diff --git a/lib/applesoft/applesoft.go b/lib/applesoft/applesoft.go new file mode 100644 index 0000000..3dfc654 --- /dev/null +++ b/lib/applesoft/applesoft.go @@ -0,0 +1,247 @@ +// Copyright © 2016 Zellyn Hunter + +// Package applesoft provides routines for working with Applesoft +// files. +package applesoft + +import ( + "bytes" + "fmt" + "regexp" +) + +// TokensByCode is a map from byte value to token text. +var TokensByCode = map[byte]string{ + 0x80: "END", + 0x81: "FOR", + 0x82: "NEXT", + 0x83: "DATA", + 0x84: "INPUT", + 0x85: "DEL", + 0x86: "DIM", + 0x87: "READ", + 0x88: "GR", + 0x89: "TEXT", + 0x8A: "PR #", + 0x8B: "IN #", + 0x8C: "CALL", + 0x8D: "PLOT", + 0x8E: "HLIN", + 0x8F: "VLIN", + 0x90: "HGR2", + 0x91: "HGR", + 0x92: "HCOLOR=", + 0x93: "HPLOT", + 0x94: "DRAW", + 0x95: "XDRAW", + 0x96: "HTAB", + 0x97: "HOME", + 0x98: "ROT=", + 0x99: "SCALE=", + 0x9A: "SHLOAD", + 0x9B: "TRACE", + 0x9C: "NOTRACE", + 0x9D: "NORMAL", + 0x9E: "INVERSE", + 0x9F: "FLASH", + 0xA0: "COLOR=", + 0xA1: "POP", + 0xA2: "VTAB", + 0xA3: "HIMEM:", + 0xA4: "LOMEM:", + 0xA5: "ONERR", + 0xA6: "RESUME", + 0xA7: "RECALL", + 0xA8: "STORE", + 0xA9: "SPEED=", + 0xAA: "LET", + 0xAB: "GOTO", + 0xAC: "RUN", + 0xAD: "IF", + 0xAE: "RESTORE", + 0xAF: "&", + 0xB0: "GOSUB", + 0xB1: "RETURN", + 0xB2: "REM", + 0xB3: "STOP", + 0xB4: "ON", + 0xB5: "WAIT", + 0xB6: "LOAD", + 0xB7: "SAVE", + 0xB8: "DEF FN", + 0xB9: "POKE", + 0xBA: "PRINT", + 0xBB: "CONT", + 0xBC: "LIST", + 0xBD: "CLEAR", + 0xBE: "GET", + 0xBF: "NEW", + 0xC0: "TAB", + 0xC1: "TO", + 0xC2: "FN", + 0xC3: "SPC(", + 0xC4: "THEN", + 0xC5: "AT", + 0xC6: "NOT", + 0xC7: "STEP", + 0xC8: "+", + 0xC9: "-", + 0xCA: "*", + 0xCB: "/", + 0xCC: ";", + 0xCD: "AND", + 0xCE: "OR", + 0xCF: ">", + 0xD0: "=", + 0xD1: "<", + 0xD2: "SGN", + 0xD3: "INT", + 0xD4: "ABS", + 0xD5: "USR", + 0xD6: "FRE", + 0xD7: "SCRN (", + 0xD8: "PDL", + 0xD9: "POS", + 0xDA: "SQR", + 0xDB: "RND", + 0xDC: "LOG", + 0xDD: "EXP", + 0xDE: "COS", + 0xDF: "SIN", + 0xE0: "TAN", + 0xE1: "ATN", + 0xE2: "PEEK", + 0xE3: "LEN", + 0xE4: "STR$", + 0xE5: "VAL", + 0xE6: "ASC", + 0xE7: "CHR$", + 0xE8: "LEFT$", + 0xE9: "RIGHT$", + 0xEA: "MID$", +} + +// Listing holds a listing of an entire BASIC program. +type Listing []Line + +// Line holds a single BASIC line, with line number and text. +type Line struct { + Num int + Bytes []byte +} + +// Decode turns a raw binary file into a basic program. Location +// specifies the program's location in RAM (0x801 for in-ROM Applesoft, 0x3001 for tape-loaded Applesoft). +func Decode(raw []byte, location uint16) (Listing, error) { + // First two bytes of Applesoft files on disk are length. Let's be + // tolerant to getting either format. + if len(raw) >= 2 { + size := int(raw[0]) + (256 * int(raw[1])) + if size == len(raw)-2 || size == len(raw)-3 { + raw = raw[2:] + } + } + + bounds := fmt.Sprintf("$%X to $%X", location, int(location)+len(raw)) + + calcOffset := func(address int) int { + return address - int(location) + } + listing := []Line{} + last := 0 // last line number + next := int(location) + for next != 0 { + ofs := calcOffset(next) + if ofs < -1 || ofs+1 >= len(raw) { + return nil, fmt.Errorf("line %d has next line at $%X, which is outside the input range of %s", last, next, bounds) + } + next = int(raw[ofs]) + 256*int(raw[ofs+1]) + ofs += 2 + if next == 0 { + break + } + if ofs+1 >= len(raw) { + if len(listing) == 0 { + return nil, fmt.Errorf("ran out of input trying to read the first line number") + } + return nil, fmt.Errorf("ran out of input trying to read line number of line after %d", last) + } + line := Line{Num: int(raw[ofs]) + 256*int(raw[ofs+1])} + ofs += 2 + for { + if ofs >= len(raw) { + return nil, fmt.Errorf("Ran out of input at location $%X in line %d", ofs+int(location), line.Num) + } + char := raw[ofs] + if char == 0 { + break + } + if char < 0x80 { + line.Bytes = append(line.Bytes, char) + } else { + token := TokensByCode[char] + if token == "" { + return nil, fmt.Errorf("unknown token $%X in line %d", char, line.Num) + } + line.Bytes = append(line.Bytes, char) + } + ofs++ + } + listing = append(listing, line) + } + + return listing, nil +} + +func (l Line) String() string { + var buf bytes.Buffer + fmt.Fprintf(&buf, "%d ", l.Num) + for _, char := range l.Bytes { + if char < 0x80 { + buf.WriteByte(char) + } else { + token := TokensByCode[char] + buf.WriteString(" " + token + " ") + } + } + return buf.String() +} + +func (l Listing) String() string { + var buf bytes.Buffer + for _, line := range l { + buf.WriteString(line.String()) + buf.WriteByte('\n') + } + return buf.String() +} + +var controlCharRegexp = regexp.MustCompile(`[\x00-\x1F]`) + +// ChevronControlCodes converts ASCII control characters like chr(4) +// to chevron-surrounded codes like «ctrl-D». +func ChevronControlCodes(s string) string { + return controlCharRegexp.ReplaceAllStringFunc(s, func(s string) string { + if s == "\n" || s == "\t" { + return s + } + if s >= "\x01" && s <= "\x1a" { + return "«ctrl-" + string('A'-1+s[0]) + "»" + } + code := "?" + switch s[0] { + case '\x00': + code = "NUL" + case '\x1C': + code = "FS" + case '\x1D': + code = "GS" + case '\x1E': + code = "RS" + case '\x1F': + code = "US" + } + + return "«" + code + "»" + }) +} diff --git a/lib/applesoft/hello_test.go b/lib/applesoft/hello_test.go new file mode 100644 index 0000000..f6ab956 --- /dev/null +++ b/lib/applesoft/hello_test.go @@ -0,0 +1,53 @@ +// Copyright © 2016 Zellyn Hunter + +package applesoft + +import "testing" + +// helloBinary is a simple basic program used for testing. Listing +// below. +var helloBinary = []byte{ + 0x11, 0x08, 0x0A, 0x00, 0xBA, 0x22, 0x04, 0x43, 0x41, 0x54, 0x41, 0x4C, 0x4F, 0x47, 0x22, 0x00, + 0x17, 0x08, 0x14, 0x00, 0xBA, 0x00, 0x25, 0x08, 0x1E, 0x00, 0xBA, 0x22, 0x48, 0x45, 0x4C, 0x4C, + 0x4F, 0x22, 0x3B, 0x00, 0x34, 0x08, 0x28, 0x00, 0xBA, 0x22, 0x2C, 0x20, 0x57, 0x4F, 0x52, 0x4C, + 0x44, 0x22, 0x00, 0x49, 0x08, 0x32, 0x00, 0x81, 0x49, 0xD0, 0x31, 0xC1, 0x34, 0x30, 0x3A, 0xBA, + 0x22, 0x2A, 0x22, 0x3B, 0x3A, 0x82, 0x49, 0x00, 0x6B, 0x08, 0x3C, 0x00, 0x81, 0x49, 0xD0, 0x31, + 0xC1, 0x31, 0x30, 0x3A, 0x81, 0x4A, 0xD0, 0x31, 0xC1, 0x49, 0x3A, 0xBA, 0x22, 0x20, 0x22, 0x3B, + 0x3A, 0x82, 0x3A, 0xBA, 0x22, 0x2A, 0x22, 0x3A, 0x82, 0x00, 0x98, 0x08, 0x46, 0x00, 0xB2, 0x22, + 0x54, 0x48, 0x49, 0x53, 0x20, 0x49, 0x53, 0x20, 0x41, 0x20, 0x54, 0x45, 0x53, 0x54, 0x20, 0x4F, + 0x46, 0x20, 0x41, 0x20, 0x4C, 0x4F, 0x4E, 0x47, 0x20, 0x4C, 0x49, 0x4E, 0x45, 0x20, 0x4F, 0x46, + 0x20, 0x54, 0x45, 0x58, 0x54, 0x22, 0x00, 0xEC, 0x08, 0x50, 0x00, 0xBA, 0x22, 0x54, 0x48, 0x49, + 0x53, 0x20, 0x49, 0x53, 0x20, 0x41, 0x20, 0x54, 0x45, 0x53, 0x54, 0x20, 0x4F, 0x46, 0x20, 0x41, + 0x4E, 0x20, 0x45, 0x56, 0x45, 0x4E, 0x20, 0x4C, 0x4F, 0x4E, 0x47, 0x45, 0x52, 0x20, 0x4C, 0x49, + 0x4E, 0x45, 0x20, 0x4F, 0x46, 0x20, 0x54, 0x45, 0x58, 0x54, 0x20, 0x54, 0x48, 0x41, 0x54, 0x20, + 0x49, 0x53, 0x20, 0x45, 0x56, 0x45, 0x4E, 0x20, 0x4D, 0x4F, 0x52, 0x45, 0x20, 0x54, 0x48, 0x41, + 0x4E, 0x20, 0x38, 0x30, 0x20, 0x43, 0x4F, 0x4C, 0x53, 0x22, 0x00, 0x04, 0x09, 0x5A, 0x00, 0xBA, + 0x22, 0x41, 0x4C, 0x4C, 0x20, 0x44, 0x4F, 0x4E, 0x45, 0x20, 0x54, 0x45, 0x53, 0x54, 0x49, 0x4E, + 0x47, 0x22, 0x00, 0x00, 0x00, 0x0A, +} + +// helloListing is the text version of the basic program above. Note +// that there are trailing newlines on lines 20 and 60. +var helloListing = `10 PRINT "«ctrl-D»CATALOG" +20 PRINT +30 PRINT "HELLO"; +40 PRINT ", WORLD" +50 FOR I = 1 TO 40: PRINT "*";: NEXT I +60 FOR I = 1 TO 10: FOR J = 1 TO I: PRINT " ";: NEXT : PRINT "*": NEXT +70 REM "THIS IS A TEST OF A LONG LINE OF TEXT" +80 PRINT "THIS IS A TEST OF AN EVEN LONGER LINE OF TEXT THAT IS EVEN MORE THAN 80 COLS" +90 PRINT "ALL DONE TESTING" +` + +// TestParse tests the full parsing and output of a basic program from +// bytes. +func TestParse(t *testing.T) { + listing, err := Decode(helloBinary, 0x801) + if err != nil { + t.Fatal(err) + } + text := ChevronControlCodes(listing.String()) + if text != helloListing { + t.Fatalf("Wrong listing; want:\n%s\ngot:\n%s", helloListing, text) + } +} diff --git a/lib/applesoft/notes.org b/lib/applesoft/notes.org new file mode 100644 index 0000000..f0eb5c3 --- /dev/null +++ b/lib/applesoft/notes.org @@ -0,0 +1,10 @@ +** Program location +Defaults to $801 for ROM-based Applesoft. Locations $67/$68 point to +the start of the program, and the byte preceding it must be #0. + +Details: +- http://www.atarimagazines.com/compute/issue11/36_1_THE_APPLE_GAZETTE_RESOLVING_APPLESOFT_AND_HIRES_GRAPHICS_MEMORY_CONFLICTS.php +- http://retrocomputing.stackexchange.com/questions/1604 + +** Format +DOS stores an additional byte on the end, which should be ignored. diff --git a/lib/helpers/helpers.go b/lib/helpers/helpers.go new file mode 100644 index 0000000..6b41b32 --- /dev/null +++ b/lib/helpers/helpers.go @@ -0,0 +1,17 @@ +// Copyright © 2016 Zellyn Hunter + +// Package helpers contains various routines used to help cobra +// commands stay succinct. +package helpers + +import ( + "io/ioutil" + "os" +) + +func FileContentsOrStdIn(s string) ([]byte, error) { + if s == "-" { + return ioutil.ReadAll(os.Stdin) + } + return ioutil.ReadFile(s) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..202f506 --- /dev/null +++ b/main.go @@ -0,0 +1,9 @@ +// Copyright © 2016 Zellyn Hunter + +package main + +import "github.com/zellyn/diskii/cmd" + +func main() { + cmd.Execute() +}