github.com/ha1tch/plus3/pkg/diskimg is the library that backs the plus3
command-line tool. It can be embedded directly in other Go programs that need to
read or write +3DOS disk images -- an emulator loading a disk, an assembler writing
its output straight onto an image, a build tool packaging a release, and so on.
This guide is the consumer-facing companion to the API. For the format itself and
its pitfalls, see PLUS3DOS-PITFALLS.md; this document is
about the Go API.
import "github.com/ha1tch/plus3/pkg/diskimg"Requires Go 1.25 or later.
The package exposes more than the command-line tool uses. To set expectations honestly:
- Proven -- exercised by the CLI and covered by tests, and verified against real +3 hardware: creating an image, importing CODE and BASIC files, listing the directory, exporting files byte-exactly, deleting files, detokenising BASIC, and raw sector read/write. Build on these with confidence.
- Present but less travelled -- screen import/export has not had the same hardware verification; treat it as a usable starting point, not a settled contract, and test against real targets before relying on it. TAP<->disk conversion now delegates to github.com/ha1tch/zentools (pkg/tap) for encoding and decoding, which is validated against independent tools and real files, and has round-trip tests in pkg/diskimg.
When in doubt, the rule that governed the whole project applies: verify against a real disk or a real machine, because a reader and writer that share an assumption will agree with each other even when both are wrong.
A *DiskImage is the in-memory handle for one disk image. You obtain one in one of
three ways:
di := diskimg.NewDiskImage() // a fresh, formatted blank disk
di, err := diskimg.LoadFromFile("game.dsk") // load from a path
di, err := diskimg.Load(reader) // load from any io.ReaderNewDiskImage returns a fully formatted single-sided +3 image (every track has a
track-information block and 0xE5-filled sectors, and the directory area is
initialised). You can import files into it immediately.
Changes are in memory until you write them out:
err := di.SaveToFile("game.dsk") // write to a path
err := di.Save(writer) // write to any io.WriterSave flushes the in-memory directory to its sectors before writing, so you do not
need to call FlushDirectory yourself in the normal path.
This is the most common path for an assembler or build tool: take a binary, write it onto a disk as a CODE file with a correct PLUS3DOS header.
di := diskimg.NewDiskImage()
// Import a host file as a CODE file with the given load address.
// The on-disk name is derived from the host filename (first 8 chars + ".BIN").
if err := di.ImportCode("build/game.bin", 0x8000); err != nil {
return err
}
if err := di.SaveToFile("game.dsk"); err != nil {
return err
}The resulting file has file type 3 (CODE), the load address in the first header parameter, and the conventions a real +3 expects (see the pitfalls document for why those details matter).
// line is the auto-run line number, or 0x8000 for "no auto-run".
err := di.ImportBasicProgram("loader.bas", 10)This imports an already-tokenised BASIC file. To import plain-text BASIC
source and have it tokenised on the way in, use ImportBasicText:
err := di.ImportBasicText("loader.txt", 10) // tokenises, then storesOr tokenise in memory directly with TokeniseBasic (the inverse of
DetokeniseBasic):
tok, err := diskimg.TokeniseBasic(`10 PRINT "HELLO"`)The tokeniser covers keywords, integer constants (0-65535), string literals, and REM comments; it does not produce floating-point literals, DEF FN calculator slots, or embedded colour-control bytes.
GetDirectory returns a snapshot slice of all 64 entries. Filter out the empty
ones:
entries, err := di.GetDirectory()
if err != nil {
return err
}
for _, e := range entries {
if e.IsUnused() || e.GetFilename() == "" {
continue
}
fmt.Println(e.GetFilename()) // e.g. "GAME.BIN"
}DirectoryEntry is read via its methods: GetFilename() (the 8.3 name),
IsUnused(), IsDeleted(), and GetAttributes() (read-only / hidden / system).
The raw fields (Status, RecordCount, AllocationBlocks) are also exported if you
need them, but prefer the methods.
The returned slice is a copy; mutating it does not change the disk. Use the
DiskImage methods (ImportCode, DeleteFile, ...) to modify the image.
// stripHeader removes the 128-byte PLUS3DOS header so you get just the data.
// The second argument is a host FILE path (the file is created), not a directory.
err := di.ExportFile("GAME.BIN", "outfile.bin", true)Extraction uses the PLUS3DOS header's length field, so it is byte-exact rather than rounded to the record size.
text, err := di.ReadBasicText("LOADER.BAS") // reads + detokenises in one call
fmt.Print(text)Or, if you already have the raw (header-stripped) program bytes in memory -- for example bytes you read some other way -- call the decoder directly:
text, err := diskimg.DetokeniseBasic(programBytes)DetokeniseBasic handles keyword tokens (including the 128K PLAY and SPECTRUM),
numeric constants (skipping the hidden 5-byte binary form), strings, and statement
separators. It does not fully reconstruct embedded colour-control argument bytes.
err := di.DeleteFile("GAME.BIN") // frees blocks, flushes directoryTwo lower-level surfaces are available when the file-level API is not enough.
Direct sector read/write, addressed by track and sector. Sectors are 512 bytes; sector indices passed here are 0-based within the track. This is what an emulator's FDC layer would sit on top of.
data, err := di.GetSectorData(track, sector, side) // returns 512 bytes
err = di.SetSectorData(track, sector, side, data) // data must be 512 bytesFor the geometry (track 0 reserved, directory on track 1, the block-to-sector mapping), see the pitfalls document -- those rules matter if you compute sector addresses yourself.
OpenFile returns a *File that implements the standard io interfaces
(io.Reader, io.Writer, io.Seeker, plus ReaderAt/WriterAt), so you can
stream rather than load whole files:
f, err := di.OpenFile("GAME.BIN", false) // false = must already exist
if err != nil {
return err
}
defer f.Close()
buf := make([]byte, 256)
n, err := f.Read(buf)Pass createNew = true to create the file if it does not exist. After writing,
Close updates the directory entry. Remember to SaveToFile / Save afterwards to
persist the image.
err := di.DiskCheck() // structural sanity check
ok := di.IsPlus3Format() // is this the +3 format?DiskCheck is the same check the info command runs. It is a sanity check on the
image structure, not a guarantee of +3DOS acceptance -- the only guarantee of that
is a real +3 (which is the lesson the pitfalls document exists to pass on).
Create a disk, add a loader and a code file, and save it -- the shape an ecosystem build step would take:
package main
import (
"log"
"github.com/ha1tch/plus3/pkg/diskimg"
)
func main() {
di := diskimg.NewDiskImage()
if err := di.ImportCode("build/game.bin", 0x8000); err != nil {
log.Fatalf("import code: %v", err)
}
if err := di.ImportBasicProgram("build/loader.bas", 10); err != nil {
log.Fatalf("import loader: %v", err)
}
if err := di.SaveToFile("dist/game.dsk"); err != nil {
log.Fatalf("save: %v", err)
}
// Confirm what landed on the image.
entries, _ := di.GetDirectory()
for _, e := range entries {
if !e.IsUnused() && e.GetFilename() != "" {
log.Printf("on disk: %s", e.GetFilename())
}
}
}A few points specific to the two most likely first consumers:
-
Emulator (loading a disk): the proven read path is
LoadFromFile->GetDirectory->ExportFile/OpenFile, orGetSectorDatafor raw FDC-level access. The reader handles both the standard (MV - CPC) and extended (EXTENDED CPC).dskcontainer variants; real disks are usually extended. The writer emits the standard variant, which round-trips and is accepted by a real +3. -
Assembler (writing output onto a disk): the proven write path is
NewDiskImage->ImportCode(or build a header and useImportFilewithImportOptions) ->SaveToFile. If you need the CODE header parameters set a particular way,ImportOptions{AddHeader: true, FileType: FileTypeCode, LoadAddr: addr}gives you control; the package fills in the header-version and second-parameter conventions a real +3 expects. -
Both: the package tokenises plain-text BASIC source into the on-disk form (
TokeniseBasic/ImportBasicText) and detokenises the other way (DetokeniseBasic/ReadBasicText). The tokeniser covers keywords, integer constants, strings, and REM; it does not emit floating-point literals or DEF FN calculator slots, so programs relying on those should be tokenised with a full toolchain and added with-t basic.