mirror of
https://github.com/ivanizag/izapple2.git
synced 2026-10-22 00:02:27 +00:00
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:
1 parent
ea93599af9
commit
11092ed1d8
16 files changed
+675
-44
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -8,6 +8,7 @@ ramworks: none
|
||||
nsc: none
|
||||
mods:
|
||||
tape: none
|
||||
saveDir: none
|
||||
rgb: false
|
||||
romx: false
|
||||
chargenmap: 2e
|
||||
|
||||
+4
-2
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user