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.
This commit is contained in:
Iván Izaguirre authored and Iván Izaguirre committed 2026-08-07 08:53:22 +02:00
1 parent ea93599af9
commit 11092ed1d8
16 files changed
+675 -44

No files matched your search

+1
View File
@@ -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.
+35 -1
View File
@@ -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
+11 -1
View File
@@ -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
}
+10 -5
View File
@@ -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)
+1
View File
@@ -8,6 +8,7 @@ ramworks: none
nsc: none
mods:
tape: none
saveDir: none
rgb: false
romx: false
chargenmap: 2e
+4 -2
View File
@@ -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 {
+38
View File
@@ -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=<internal>/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
+2
View File
@@ -39,6 +39,8 @@ Usage: izapple2 [file]
slot 6 configuration. (default "diskii,disk1=<internal>/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
+42 -12
View File
@@ -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))
}
+8 -1
View File
@@ -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
}
+4 -3
View File
@@ -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
}
+76
View File
@@ -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)
}
+25 -3
View File
@@ -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
}
+33 -16
View File
@@ -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")
}
}
+210
View File
@@ -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<<uint(unit%8)) != 0
}
// read fills the buffer with a unit stored in the overlay
func (o *overlay) read(unit int, buffer []uint8) error {
file, err := o.open()
if err != nil {
return err
}
if _, err := file.ReadAt(buffer, o.unitOffset(unit)); err != nil {
return fmt.Errorf("can't read from the overlay %s: %w", o.filename, err)
}
return nil
}
// write stores a unit, creating the overlay the first time
func (o *overlay) write(unit int, data []uint8) error {
file, err := o.open()
if err != nil {
return err
}
if _, err := file.WriteAt(data, o.unitOffset(unit)); err != nil {
return err
}
o.present[unit/8] |= 1 << uint(unit%8)
if _, err := file.WriteAt(o.present[unit/8:unit/8+1], overlayBitmapOffset+int64(unit/8)); err != nil {
return err
}
return nil
}
// applyTo replaces in the image the units kept in the overlay. It is for the
// disks read whole into memory, the diskettes.
func (o *overlay) applyTo(data []uint8) error {
for unit := range o.units {
if !o.has(unit) {
continue
}
destination := data[unit*o.unitSize : (unit+1)*o.unitSize]
if err := o.read(unit, destination); err != nil {
return err
}
}
return nil
}
// open returns the overlay file, creating it with its header the first time
func (o *overlay) open() (*os.File, error) {
if o.file != nil {
return o.file, nil
}
_, err := os.Stat(o.filename)
isNew := os.IsNotExist(err)
file, err := os.OpenFile(o.filename, os.O_RDWR|os.O_CREATE, 0666)
if err != nil {
return nil, err
}
if isNew {
header := make([]byte, overlayHeaderSize)
copy(header, overlayMagic)
binary.LittleEndian.PutUint32(header[24:], uint32(o.unitSize))
binary.LittleEndian.PutUint32(header[28:], uint32(o.units))
binary.LittleEndian.PutUint64(header[32:], o.checksum)
if _, err := file.WriteAt(header, 0); err != nil {
file.Close()
return nil, err
}
if _, err := file.WriteAt(o.present, overlayBitmapOffset); err != nil {
file.Close()
return nil, err
}
}
o.file = file
return file, nil
}
const overlayBitmapOffset = int64(overlayHeaderSize)
// unitOffset is where a unit is stored, after the header and the bitmap
func (o *overlay) unitOffset(unit int) int64 {
bitmapSize := int64(len(o.present))
// Keep the units aligned, the bitmap is 5 bytes for a diskette and 8 Kb
// for a full hard disk
bitmapSize = (bitmapSize + overlayHeaderSize - 1) / overlayHeaderSize * overlayHeaderSize
return overlayBitmapOffset + bitmapSize + int64(unit*o.unitSize)
}
// checksumOfBytes identifies the content an overlay was made from
func checksumOfBytes(data []uint8) uint64 {
return crc64.Checksum(data, overlayChecksumTable)
}
+175
View File
@@ -0,0 +1,175 @@
package storage
import (
"bytes"
"os"
"path/filepath"
"testing"
)
const testBlocks = 16
// makeTestImage writes a raw ProDOS image with recognizable content
func makeTestImage(t *testing.T, path string) []uint8 {
t.Helper()
data := make([]uint8, testBlocks*int(ProDosBlockSize))
for i := range data {
data[i] = uint8(i)
}
if err := os.WriteFile(path, data, 0666); err != nil {
t.Fatal(err)
}
return data
}
func openTestDisk(t *testing.T, path string, overlayPath string) BlockDisk {
t.Helper()
file, err := os.OpenFile(path, os.O_RDWR, 0)
if err != nil {
t.Fatal(err)
}
base, err := NewBlockDiskFile(file, false)
if err != nil {
t.Fatal(err)
}
disk, err := NewBlockDiskOverlay(base, overlayPath)
if err != nil {
t.Fatal(err)
}
return disk
}
func TestBlockOverlayKeepsTheImageUntouched(t *testing.T) {
dir := t.TempDir()
image := filepath.Join(dir, "disk.hdv")
overlayPath := filepath.Join(dir, "disk.hdv.ovl")
original := makeTestImage(t, image)
written := bytes.Repeat([]uint8{0xaa}, int(ProDosBlockSize))
disk := openTestDisk(t, image, overlayPath)
if err := disk.Write(3, written); err != nil {
t.Fatal(err)
}
// The block written comes back from the overlay, the others from the image
got, err := disk.Read(3)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, written) {
t.Error("the block written did not come back")
}
got, err = disk.Read(4)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, original[4*int(ProDosBlockSize):5*int(ProDosBlockSize)]) {
t.Error("an untouched block did not come from the image")
}
// The image on disk is exactly as it was
after, err := os.ReadFile(image)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(after, original) {
t.Error("the image was modified, it should have been left alone")
}
}
func TestBlockOverlaySurvivesReopening(t *testing.T) {
dir := t.TempDir()
image := filepath.Join(dir, "disk.hdv")
overlayPath := filepath.Join(dir, "disk.hdv.ovl")
makeTestImage(t, image)
written := bytes.Repeat([]uint8{0x5a}, int(ProDosBlockSize))
disk := openTestDisk(t, image, overlayPath)
if err := disk.Write(7, written); err != nil {
t.Fatal(err)
}
reopened := openTestDisk(t, image, overlayPath)
got, err := reopened.Read(7)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, written) {
t.Error("what was saved did not survive reopening the disk")
}
}
func TestOverlayIsRefusedForAnotherImage(t *testing.T) {
dir := t.TempDir()
image := filepath.Join(dir, "disk.hdv")
overlayPath := filepath.Join(dir, "disk.hdv.ovl")
makeTestImage(t, image)
disk := openTestDisk(t, image, overlayPath)
if err := disk.Write(2, bytes.Repeat([]uint8{1}, int(ProDosBlockSize))); err != nil {
t.Fatal(err)
}
// Another disk with the same name, the overlay does not belong to it
changed := makeTestImage(t, image)
changed[0] ^= 0xff
if err := os.WriteFile(image, changed, 0666); err != nil {
t.Fatal(err)
}
file, err := os.OpenFile(image, os.O_RDWR, 0)
if err != nil {
t.Fatal(err)
}
base, err := NewBlockDiskFile(file, false)
if err != nil {
t.Fatal(err)
}
if _, err := NewBlockDiskOverlay(base, overlayPath); err == nil {
t.Error("expected the overlay of another image to be refused")
}
}
func TestOverlayIsRefusedForAnotherShape(t *testing.T) {
dir := t.TempDir()
overlayPath := filepath.Join(dir, "shared.ovl")
o, err := openOverlay(overlayPath, int(ProDosBlockSize), testBlocks, 1234)
if err != nil {
t.Fatal(err)
}
if err := o.write(1, bytes.Repeat([]uint8{9}, int(ProDosBlockSize))); err != nil {
t.Fatal(err)
}
// Same content, but taken as a diskette of tracks instead of blocks
if _, err := openOverlay(overlayPath, bytesPerTrack, numberOfTracks, 1234); err == nil {
t.Error("expected the overlay of a differently shaped disk to be refused")
}
}
func TestOverlayStoresOnlyWhatWasWritten(t *testing.T) {
dir := t.TempDir()
overlayPath := filepath.Join(dir, "sparse.ovl")
// A full 32 Mb hard disk, with a single block saved
o, err := openOverlay(overlayPath, int(ProDosBlockSize), int(proDosMaxBlocks), 0)
if err != nil {
t.Fatal(err)
}
if err := o.write(10, bytes.Repeat([]uint8{7}, int(ProDosBlockSize))); err != nil {
t.Fatal(err)
}
info, err := os.Stat(overlayPath)
if err != nil {
t.Fatal(err)
}
// The header, the bitmap of 8 Kb and the eleven blocks up to the one
// written, far from the 32 Mb of the disk
if info.Size() > 64*1024 {
t.Errorf("the overlay of one block grew to %v bytes", info.Size())
}
}