From 11092ed1d89d1098462a5628937607d85beb0fe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Izaguirre?= Date: Wed, 5 Aug 2026 18:21:29 +0200 Subject: [PATCH] Keep the disk images unmodified with a save directory The emulation writes to a disk in the file it was loaded from, so saving a game modifies the image the user has. With a save directory the changes go to an overlay file there instead and the image is only ever read. An overlay stores just the parts the software wrote, indexed by a bitmap in its header: tracks of 4096 bytes for the diskettes, blocks of 512 for the ProDOS devices. Nothing is written for the rest, so the file takes as much space as what was saved and not the size of the disk, which matters for the hard disks where an image can be 32Mb and a saved game a handful of blocks. The header also holds a checksum of the image it was made from, so an overlay is never applied to a different disk with the same name. As the image is only read, this also makes writable the disks that could not be written at all: the ones inside a zip or a gzip, the ones loaded from an URL and the ones embedded in the executable. The directory comes from the new -saveDir option. The cards that write to their disks take a savedir parameter of their own that defaults to it, with 'none' to write back to the image. Also fixes saveTrack leaking a file descriptor on every track written, and going on to write through a nil file when it could not be opened. --- README.md | 1 + cardBuilder.go | 36 ++++++- cardDisk2.go | 12 ++- cardSmartport.go | 15 ++- configs/_base.cfg | 1 + configuration.go | 6 +- doc/command_line.md | 38 ++++++++ doc/usage.txt | 2 + resources.go | 54 ++++++++--- setup.go | 9 +- smartPortHardDisk.go | 7 +- storage/blockDisk.go | 76 +++++++++++++++ storage/diskette.go | 28 +++++- storage/fileNib.go | 49 +++++++--- storage/overlay.go | 210 ++++++++++++++++++++++++++++++++++++++++ storage/overlay_test.go | 175 +++++++++++++++++++++++++++++++++ 16 files changed, 675 insertions(+), 44 deletions(-) create mode 100644 storage/overlay.go create mode 100644 storage/overlay_test.go diff --git a/README.md b/README.md index 8b614d21..35340c75 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ Portable emulator of an Apple II+ or //e. Written in Go. - Mouse support. No mouse capture needed - Adjustable speed - Fast disk mode to set max speed while using the disks + - Save directory with `-saveDir`: what the software writes goes to an overlay file per disk, keeping the images untouched and making writable the disks loaded from a compressed file, an URL or the embedded resources - Single file executable with embedded ROMs and DOS 3.3 - Pause (thanks a2geek) - Passes the [A2AUDIT 1.06](https://github.com/zellyn/a2audit) tests as II+, //e, and //e Enhanced. diff --git a/cardBuilder.go b/cardBuilder.go index 6ac1d2e8..0307356d 100644 --- a/cardBuilder.go +++ b/cardBuilder.go @@ -26,6 +26,32 @@ type cardBuilder struct { const noCardName = "empty" +const saveDirParamName = "savedir" + +/* +saveDirParamSpec is declared by the cards that write to their disk images, so +that the changes can be kept apart and the images left alone. + +Left empty, the card uses the save directory of the machine, the saveDir +command line option. Set to 'none' it writes back to the image even when the +machine has one. +*/ +var saveDirParamSpec = paramSpec{ + saveDirParamName, + "directory to keep what is written to the disks of this card, leaving the images unmodified. Defaults to the saveDir of the machine, 'none' to write back to the images", + "", +} + +// paramsGetSaveDir returns the directory a card keeps the changes to its disks +// in, empty when they go back to the images +func paramsGetSaveDir(params map[string]string) string { + value := paramsGetPath(params, saveDirParamName) + if value == "none" { + return "" + } + return value +} + var commonParams = []paramSpec{ {"trace", "Enable debug messages", "false"}, {"tracess", "Trace softswitches", "false"}, @@ -91,7 +117,9 @@ func (cb *cardBuilder) fullDefaultParams() map[string]string { return finalParams } -func setupCard(a *Apple2, slot int, paramString string) (Card, error) { +// setupCard builds the card of a slot. saveDir is the one of the machine, used +// by the cards that write to their disk images and do not name one themselves. +func setupCard(a *Apple2, slot int, paramString string, saveDir string) (Card, error) { actualArgs := splitConfigurationString(paramString, ',') cardName := actualArgs[0] @@ -126,6 +154,12 @@ func setupCard(a *Apple2, slot int, paramString string) (Card, error) { } } + // A card that writes to its disks and does not name a save directory of its + // own uses the one of the machine + if value, ok := finalParams[saveDirParamName]; ok && value == "" { + finalParams[saveDirParamName] = saveDir + } + card, err := builder.buildFunc(finalParams) if err != nil { return nil, err diff --git a/cardDisk2.go b/cardDisk2.go index fb20e29f..3815b08b 100644 --- a/cardDisk2.go +++ b/cardDisk2.go @@ -44,6 +44,11 @@ type drive interface { } type cardDisk2Drive struct { + // saveDirectory keeps the changes written to the diskette apart from the + // image, when it is not empty. It comes from the savedir parameter of the + // card and is kept here for the diskettes inserted later on. + saveDirectory string + name string diskette storage.Diskette phases uint8 // q3, q2, q1 and q0 with q0 on the LSB. Magnets that are active on the stepper motor @@ -60,11 +65,16 @@ func newCardDisk2Builder() *cardBuilder { {"tracktracer", "Trace how the disk head moves between tracks", "false"}, {"fast", "Enable CPU burst when accessing the disk", "true"}, {"sectors13", "Use 13 sectors per track ROM", "false"}, + saveDirParamSpec, }, buildFunc: func(params map[string]string) (Card, error) { var c CardDisk2 c.sectors13 = paramsGetBool(params, "sectors13") + saveDirectory := paramsGetSaveDir(params) + c.drive[0].saveDirectory = saveDirectory + c.drive[1].saveDirectory = saveDirectory + disk1 := paramsGetPath(params, "disk1") if disk1 != "" { err := c.drive[0].insertDiskette(disk1) @@ -292,7 +302,7 @@ func (c *CardDisk2) processQ6Q7(in uint8) { } func (d *cardDisk2Drive) insertDiskette(name string) error { - diskette, err := LoadDiskette(name) + diskette, err := LoadDiskette(name, d.saveDirectory) if err != nil { return err } diff --git a/cardSmartport.go b/cardSmartport.go index 41eb9497..b3cc5895 100644 --- a/cardSmartport.go +++ b/cardSmartport.go @@ -43,15 +43,17 @@ func newCardSmartPortStorageBuilder() *cardBuilder { {"image8", "Disk image for unit 8", ""}, {"tracesp", "Trace SmartPort calls", "false"}, {"tracehd", "Trace image accesses", "false"}, + saveDirParamSpec, }, buildFunc: func(params map[string]string) (Card, error) { var c CardSmartPort c.trace = paramsGetBool(params, "tracesp") traceHD := paramsGetBool(params, "tracehd") + saveDirectory := paramsGetSaveDir(params) for i := 1; i <= 8; i++ { image := paramsGetPath(params, "image"+strconv.Itoa(i)) if image != "" { - err := c.LoadImage(image, traceHD) + err := c.LoadImage(image, traceHD, saveDirectory) if err != nil { return nil, err } @@ -70,14 +72,16 @@ func newCardProDOSBlockStorageBuilder() *cardBuilder { {"image1", "Disk image for unit 1", ""}, {"image2", "Disk image for unit 2", ""}, {"tracehd", "Trace image accesses", "false"}, + saveDirParamSpec, }, buildFunc: func(params map[string]string) (Card, error) { var c CardSmartPort traceHD := paramsGetBool(params, "tracehd") + saveDirectory := paramsGetSaveDir(params) for i := 1; i <= 8; i++ { image := paramsGetPath(params, "image"+strconv.Itoa(i)) if image != "" { - err := c.LoadImage(image, traceHD) + err := c.LoadImage(image, traceHD, saveDirectory) if err != nil { return nil, err } @@ -122,9 +126,10 @@ func (c *CardSmartPort) GetInfo() map[string]string { return info } -// LoadImage loads a disk image -func (c *CardSmartPort) LoadImage(filename string, trace bool) error { - device, err := NewSmartPortHardDisk(c, filename) +// LoadImage loads a disk image. An empty saveDirectory writes the changes back +// to the image. +func (c *CardSmartPort) LoadImage(filename string, trace bool, saveDirectory string) error { + device, err := NewSmartPortHardDisk(c, filename, saveDirectory) if err == nil { device.trace = trace c.devices = append(c.devices, device) diff --git a/configs/_base.cfg b/configs/_base.cfg index 7030d00c..346cb02f 100644 --- a/configs/_base.cfg +++ b/configs/_base.cfg @@ -8,6 +8,7 @@ ramworks: none nsc: none mods: tape: none +saveDir: none rgb: false romx: false chargenmap: 2e diff --git a/configuration.go b/configuration.go index 75c92f71..327969f8 100644 --- a/configuration.go +++ b/configuration.go @@ -36,6 +36,7 @@ const ( confRomx = "romx" confMods = "mods" confTape = "tape" + confSaveDir = "saveDir" confS0 = "s0" confS1 = "s1" @@ -232,6 +233,7 @@ func setupFlags(models *configurationModels, configuration *configuration) error confRgb: "emulate the RGB modes of the 80col RGB card for DHGR", confRomx: "emulate a RomX", confTape: "WAV file with a tape recording for the cassette input", + confSaveDir: "directory to keep what the software writes to the disks, leaving the images unmodified. 'none' to write back to the images", confS0: "slot 0 configuration.", confS1: "slot 1 configuration.", confS2: "slot 2 configuration.", @@ -306,7 +308,7 @@ func applyDiskAliases(filename string) string { // Returns true if the file is a diskette, false if it's a block device func classifyFile(filename string) bool { filename = applyDiskAliases(filename) - _, err := LoadDiskette(filename) + _, err := LoadDiskette(filename, "") return err == nil } @@ -435,7 +437,7 @@ func expandSlotConfiguration(configString string) (string, error) { } // Try to load as diskette - _, err := LoadDiskette(filename) + _, err := LoadDiskette(filename, "") if err == nil { diskettes = append(diskettes, part) // Keep original part (may have quotes) } else { diff --git a/doc/command_line.md b/doc/command_line.md index ba2eaf50..807ec16a 100644 --- a/doc/command_line.md +++ b/doc/command_line.md @@ -7,6 +7,7 @@ This guide explains how to configure the izapple2 emulator using command line op - [Basic Usage](#basic-usage) - [Models](#models) - [Slot Configuration](#slot-configuration) +- [Keeping the Disk Images Unmodified](#keeping-the-disk-images-unmodified) - [Simplified Filename Configuration](#simplified-filename-configuration) - [Reference](#reference) - [Examples](#examples) @@ -115,6 +116,41 @@ The default model (`2enh`) configures slots as follows: - **Slot 6:** Disk II with DOS 3.3 - **Slot 7:** Empty +## Keeping the Disk Images Unmodified + +When the software saves, the emulator writes to the disk it was given, in the file +it was loaded from. With `-saveDir` the changes go to an overlay file in that +directory instead, and the image is only ever read: + +```bash +izapple2 -saveDir ~/.izapple2/saves mygame.dsk +``` + +The overlay is named after the disk with `.ovl` added, and holds only the parts +the software wrote, so it is small: a few kilobytes for a diskette, and for a +32Mb hard disk only the blocks that were saved. Loading the same disk again puts +them back on top of the image. + +Because the image is only read, this also makes writable the disks that could +not be written at all: the ones inside a zip or a gzip, the ones loaded from an +URL, and the ones embedded in the executable. + +An overlay carries a checksum of the image it was made from and is refused if it +does not match, so it is never applied to a different disk that happens to have +the same name. + +The cards that write to their disks, `diskii`, `smartport` and `prodos`, take a +`savedir` parameter of their own. It defaults to the `-saveDir` of the machine, +and `none` writes back to the image even when the machine has one: + +```bash +# The hard disk keeps its saves apart, the diskette is written in place +izapple2 -saveDir ~/saves -s7 smartport,image1=big.hdv -s6 diskii,disk1=work.dsk,savedir=none +``` + +WOZ and NIB diskettes are read only by nature and never write, with or without a +save directory. + ## Simplified Filename Configuration ### Positional Filename Arguments @@ -254,6 +290,8 @@ Usage: izapple2 [file] slot 6 configuration. (default "diskii,disk1=/dos33.dsk") -s7 string slot 7 configuration. (default "empty") + -saveDir string + directory to keep what the software writes to the disks, leaving the images unmodified. 'none' to write back to the images (default "none") -showConfig show the calculated configuration and exit -speed string diff --git a/doc/usage.txt b/doc/usage.txt index 7dafbaf1..062f9fd5 100644 --- a/doc/usage.txt +++ b/doc/usage.txt @@ -39,6 +39,8 @@ Usage: izapple2 [file] slot 6 configuration. (default "diskii,disk1=/dos33.dsk") -s7 string slot 7 configuration. (default "empty") + -saveDir string + directory to keep what the software writes to the disks, leaving the images unmodified. 'none' to write back to the images (default "none") -showConfig show the calculated configuration and exit -speed string diff --git a/resources.go b/resources.go index ecd1c440..a086c3ea 100644 --- a/resources.go +++ b/resources.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "os" + "path/filepath" "strings" "github.com/ivanizag/izapple2/storage" @@ -129,23 +130,42 @@ func LoadResource(filename string) ([]uint8, bool, error) { return data, writeable, nil } -// LoadDiskette returns a Diskette by detecting the format -func LoadDiskette(filename string) (storage.Diskette, error) { +/* +overlayFilename returns where to keep the changes a disk gets, or an empty +string when they go back to the image. + +With a save directory, the changes the software writes go to an overlay file +there instead of to the image, which is then never modified. It also makes +writable the disks that could not be written at all, the ones loaded from a +compressed file, an URL or the embedded resources. +*/ +func overlayFilename(filename string, saveDirectory string) string { + if saveDirectory == "" { + return "" + } + return filepath.Join(saveDirectory, filepath.Base(filename)+".ovl") +} + +// LoadDiskette returns a Diskette by detecting the format. An empty +// saveDirectory writes the changes back to the image. +func LoadDiskette(filename string, saveDirectory string) (storage.Diskette, error) { data, writeable, err := LoadResource(filename) if err != nil { return nil, err } - return storage.MakeDiskette(data, filename, writeable) + return storage.MakeDiskette(data, filename, writeable, + overlayFilename(filename, saveDirectory)) } // LoadDisketteFromBytes returns a Diskette from byte array (useful for WASM/browser file loading) func LoadDisketteFromBytes(data []byte, filename string, writeable bool) (storage.Diskette, error) { - return storage.MakeDiskette(data, filename, writeable) + return storage.MakeDiskette(data, filename, writeable, "") } -// LoadBlockDisk returns a BlockDisk -func LoadBlockDisk(filename string) (storage.BlockDisk, error) { +// LoadBlockDisk returns a BlockDisk. An empty saveDirectory writes the changes +// back to the image. +func LoadBlockDisk(filename string, saveDirectory string) (storage.BlockDisk, error) { filename = normalizeFilename(filename) // Try to open as a file @@ -156,15 +176,25 @@ func LoadBlockDisk(filename string) (storage.BlockDisk, error) { readOnly = true file, _ = os.OpenFile(filename, os.O_RDONLY, 0) } - if file != nil { - return storage.NewBlockDiskFile(file, readOnly) - } - // Load as a resource - data, _, err := LoadResource(filename) + var blockDisk storage.BlockDisk + if file != nil { + blockDisk, err = storage.NewBlockDiskFile(file, readOnly) + } else { + // Load as a resource + var data []uint8 + data, _, err = LoadResource(filename) + if err != nil { + return nil, err + } + blockDisk, err = storage.NewBlockDiskMemory(data) + } if err != nil { return nil, err } - return storage.NewBlockDiskMemory(data) + // With a save directory, the changes go to an overlay and the image is + // left as it is, even the ones opened read only + return storage.NewBlockDiskOverlay(blockDisk, + overlayFilename(filename, saveDirectory)) } diff --git a/setup.go b/setup.go index aa310884..46a19c14 100644 --- a/setup.go +++ b/setup.go @@ -80,11 +80,18 @@ func configure(configuration *configuration) (*Apple2, error) { return nil, err } + // Where the cards that write to their disks keep the changes, unless they + // name a directory of their own + saveDir := configuration.get(confSaveDir) + if saveDir == "none" { + saveDir = "" + } + // Add cards on the slots for i := range 8 { cardConfig := configuration.get(fmt.Sprintf("s%v", i)) if cardConfig != "" { - _, err := setupCard(&a, i, cardConfig) + _, err := setupCard(&a, i, cardConfig, saveDir) if err != nil { return nil, err } diff --git a/smartPortHardDisk.go b/smartPortHardDisk.go index 8cb3c340..883f04af 100644 --- a/smartPortHardDisk.go +++ b/smartPortHardDisk.go @@ -23,13 +23,14 @@ type SmartPortHardDisk struct { disk storage.BlockDisk } -// NewSmartPortHardDisk creates a new hard disk with the smartPort interface -func NewSmartPortHardDisk(host *CardSmartPort, filename string) (*SmartPortHardDisk, error) { +// NewSmartPortHardDisk creates a new hard disk with the smartPort interface. +// An empty saveDirectory writes the changes back to the image. +func NewSmartPortHardDisk(host *CardSmartPort, filename string, saveDirectory string) (*SmartPortHardDisk, error) { var d SmartPortHardDisk d.host = host d.filename = filename - hd, err := LoadBlockDisk(filename) + hd, err := LoadBlockDisk(filename, saveDirectory) if err != nil { return nil, err } diff --git a/storage/blockDisk.go b/storage/blockDisk.go index 5fb371ee..ea4f3adf 100644 --- a/storage/blockDisk.go +++ b/storage/blockDisk.go @@ -4,6 +4,7 @@ import ( "bytes" "errors" "fmt" + "hash/crc64" "io" "os" ) @@ -150,3 +151,78 @@ func (bd *blockDiskFile) Write(block uint32, data []uint8) error { func (bd *blockDiskMemory) Write(block uint32, data []uint8) error { return errors.New("can't write in a readonly disk") } + +/* +blockDiskOverlay sends the blocks written to an overlay, leaving the image +untouched, and reads back from it the ones already saved. The blocks are taken +one at a time as ProDOS asks for them, so a 32 Mb hard disk costs nothing to +open however few blocks a game saves. +*/ +type blockDiskOverlay struct { + base BlockDisk + overlay *overlay +} + +// NewBlockDiskOverlay wraps a block device so that the writes go to the +// overlay kept in overlayFilename instead of to the image. It also makes +// writable a device that was not, as the image is only read. An empty +// overlayFilename returns the device untouched. +func NewBlockDiskOverlay(base BlockDisk, overlayFilename string) (BlockDisk, error) { + if overlayFilename == "" { + return base, nil + } + + checksum, err := checksumOfBlockDisk(base) + if err != nil { + return nil, err + } + + o, err := openOverlay(overlayFilename, int(ProDosBlockSize), + int(base.GetSizeInBlocks()), checksum) + if err != nil { + return nil, err + } + return &blockDiskOverlay{base: base, overlay: o}, nil +} + +// checksumOfBlockDisk identifies the image an overlay was made from. It reads +// the whole device, which for a 32 Mb hard disk is the price of opening it +// once with the overlays in use. +func checksumOfBlockDisk(base BlockDisk) (uint64, error) { + hash := crc64.New(overlayChecksumTable) + for block := range base.GetSizeInBlocks() { + data, err := base.Read(block) + if err != nil { + return 0, err + } + hash.Write(data) + } + return hash.Sum64(), nil +} + +func (bd *blockDiskOverlay) GetSizeInBlocks() uint32 { + return bd.base.GetSizeInBlocks() +} + +func (bd *blockDiskOverlay) IsReadOnly() bool { + return false +} + +func (bd *blockDiskOverlay) Read(block uint32) ([]uint8, error) { + if !bd.overlay.has(int(block)) { + return bd.base.Read(block) + } + + buf := make([]uint8, ProDosBlockSize) + if err := bd.overlay.read(int(block), buf); err != nil { + return nil, err + } + return buf, nil +} + +func (bd *blockDiskOverlay) Write(block uint32, data []uint8) error { + if block >= bd.base.GetSizeInBlocks() { + return errors.New("disk block number is too big") + } + return bd.overlay.write(int(block), data) +} diff --git a/storage/diskette.go b/storage/diskette.go index 1a1ebce2..ea4f143e 100644 --- a/storage/diskette.go +++ b/storage/diskette.go @@ -18,8 +18,16 @@ func IsDiskette(data []byte) bool { return isFileNib(data) || isFileDsk(data) || isFileWoz(data) } -// MakeDiskette returns a Diskette by detecting the format -func MakeDiskette(data []byte, filename string, writeable bool) (Diskette, error) { +/* +MakeDiskette returns a Diskette by detecting the format. + +With an overlayFilename, the changes the software writes are kept in that file +and the image loaded is never modified. It also makes writable the diskettes +that could not be written otherwise, the ones loaded from a compressed file, an +URL or the embedded resources. An empty overlayFilename writes the changes back +to the image, which only works for the DSK and PO files on disk. +*/ +func MakeDiskette(data []byte, filename string, writeable bool, overlayFilename string) (Diskette, error) { if isFileD13(data) { return nil, errors.New("files with .d13 format are not supported for 13 sectors disk, use .nib or .woz") } @@ -31,9 +39,23 @@ func MakeDiskette(data []byte, filename string, writeable bool) (Diskette, error } if isFileDsk(data) { + // The checksum is of the image as it was loaded, before anything + // saved before is put back on top of it + overlay, err := openOverlay(overlayFilename, bytesPerTrack, numberOfTracks, + checksumOfBytes(data)) + if err != nil { + return nil, err + } + if overlay != nil { + if err := overlay.applyTo(data); err != nil { + return nil, err + } + } + var d disketteNibWritable d.nib = newFileDsk(data, filename) - d.nib.supportsWrite = d.nib.supportsWrite && writeable + d.nib.overlay = overlay + d.nib.supportsWrite = overlay != nil || (d.nib.supportsWrite && writeable) return &d, nil } diff --git a/storage/fileNib.go b/storage/fileNib.go index 1a73bb91..3f18f711 100644 --- a/storage/fileNib.go +++ b/storage/fileNib.go @@ -33,6 +33,9 @@ type fileNib struct { supportsWrite bool filename string logicalOrder *[16]int + + // When set, the changes go to the overlay and the image is left alone + overlay *overlay } func isFileNib(data []uint8) bool { @@ -78,26 +81,40 @@ func newFileDsk(data []uint8, filename string) *fileNib { } func (f *fileNib) saveTrack(track int) { - if f.supportsWrite { - file, err := os.OpenFile(f.filename, os.O_RDWR, 0) - if err != nil { - // We can't open the file for writing" - f.supportsWrite = false - fmt.Printf("Data can't be written for %v\n", f.filename) - } + if !f.supportsWrite { + return + } - data, err := nibDecodeTrack(f.track[track], f.logicalOrder) - if err != nil { - f.supportsWrite = false - fmt.Printf("Data written can't be decoded from nibbles\n") - } + data, err := nibDecodeTrack(f.track[track], f.logicalOrder) + if err != nil { + f.supportsWrite = false + fmt.Printf("Data written can't be decoded from nibbles\n") + return + } - offset := int64(track * bytesPerTrack) - _, err = file.WriteAt(data, offset) - if err != nil { + if f.overlay != nil { + // The changes are kept apart, the image loaded is not modified + if err := f.overlay.write(track, data); err != nil { f.supportsWrite = false - fmt.Printf("Data can't be written\n") + fmt.Printf("Data can't be written to the overlay: %v\n", err) } + return + } + + file, err := os.OpenFile(f.filename, os.O_RDWR, 0) + if err != nil { + // We can't open the file for writing + f.supportsWrite = false + fmt.Printf("Data can't be written for %v\n", f.filename) + return + } + defer file.Close() + + offset := int64(track * bytesPerTrack) + _, err = file.WriteAt(data, offset) + if err != nil { + f.supportsWrite = false + fmt.Printf("Data can't be written\n") } } diff --git a/storage/overlay.go b/storage/overlay.go new file mode 100644 index 00000000..5cf45d76 --- /dev/null +++ b/storage/overlay.go @@ -0,0 +1,210 @@ +package storage + +import ( + "encoding/binary" + "fmt" + "hash/crc64" + "os" +) + +/* +An overlay keeps the changes made to a disk apart from the image it was loaded +from, so that saving a game does not modify the file the user has. + +Only the parts the software writes are stored, indexed by a bitmap in the +header. Nothing is written for the rest, so the file has holes and takes on +disk as much as what was actually saved and not the size of the disk. That +matters for the hard disks, where an image can be 32 Mb and a saved game a +handful of blocks. + +The unit is whatever the disk writes at a time: a track of 4096 bytes for the +diskettes, a block of 512 bytes for the ProDOS devices. + +The header holds a checksum of the image the overlay was made from. An overlay +belongs to one image, and applying the one of another disk would corrupt it, so +a mismatch is refused instead of guessed. +*/ + +const ( + // overlayMagic is 24 bytes, the version is the last character + overlayMagic = "IZAPPLE2 DISK OVERLAY 2\n" + + // overlayHeaderSize leaves room after the magic for the shape of the disk + // and the checksum, and keeps the bitmap aligned + overlayHeaderSize = 64 +) + +var overlayChecksumTable = crc64.MakeTable(crc64.ECMA) + +type overlay struct { + filename string + unitSize int + units int + checksum uint64 + + // present has a bit set for each unit stored in the overlay + present []byte + + // file is kept open, opened on the first write. There is one overlay per + // disk in use, so the handles are few. + file *os.File +} + +/* +openOverlay returns the overlay kept in filename for a disk of the given shape +and content. An empty filename means no overlay. The file is not created until +something is written to it, and an overlay of another disk is refused. +*/ +func openOverlay(filename string, unitSize int, units int, checksum uint64) (*overlay, error) { + if filename == "" { + return nil, nil + } + + o := &overlay{ + filename: filename, + unitSize: unitSize, + units: units, + checksum: checksum, + present: make([]byte, (units+7)/8), + } + + file, err := os.Open(filename) + if os.IsNotExist(err) { + // Nothing saved yet + return o, nil + } + if err != nil { + return nil, err + } + defer file.Close() + + header := make([]byte, overlayHeaderSize) + if _, err := file.ReadAt(header, 0); err != nil { + return nil, fmt.Errorf("the overlay %s is damaged: %w", filename, err) + } + if string(header[:len(overlayMagic)]) != overlayMagic { + return nil, fmt.Errorf("%s is not a disk overlay", filename) + } + + storedUnitSize := binary.LittleEndian.Uint32(header[24:]) + storedUnits := binary.LittleEndian.Uint32(header[28:]) + storedChecksum := binary.LittleEndian.Uint64(header[32:]) + + if int(storedUnitSize) != unitSize || int(storedUnits) != units { + return nil, fmt.Errorf( + "the overlay %s was made for a disk of a different shape, delete it to start over", + filename) + } + if storedChecksum != checksum { + return nil, fmt.Errorf( + "the overlay %s belongs to another disk image, delete it to start over", + filename) + } + + if _, err := file.ReadAt(o.present, overlayBitmapOffset); err != nil { + return nil, fmt.Errorf("the overlay %s is damaged: %w", filename, err) + } + + return o, nil +} + +// has returns whether the overlay holds the unit +func (o *overlay) has(unit int) bool { + return o.present[unit/8]&(1< 64*1024 { + t.Errorf("the overlay of one block grew to %v bytes", info.Size()) + } +}