goldbox.d64

Commodore 1541 .D64 disk image reader/writer.

Read the directory, read a file’s sector chain, overwrite a file in place over its existing chain – and, since #118, build a disk from nothing: D64.blank() formats a 35-track image with a valid BAM and an empty directory, and D64.write_file() allocates blocks, threads the sector chain and links a directory entry, growing the directory chain when eight entries are not enough.

That last part is what lets a DOS save be imported without a .d64 the player already had. Converting onto somebody else’s save means every byte nobody has decoded keeps a value belonging to a different party in a different place, which is wrong data that looks right because the file loads.

goldbox/amiga_adf.py is the same job on the other port and this is written to read as its sibling.

What was measured rather than looked up

The BAM, the directory and the allocator were all checked against the player’s own fifteen PORSAVE*.D64 saves before a byte was written, because a filesystem write that is nearly right corrupts a disk silently:

  • the BAM’s per-track entry is free count, then three bitmap bytes, a set bit meaning free – recomputing all 35 entries reproduces every byte of track 18 sector 0 on all fifteen disks;

  • the placement of a file’s blocks is not something the game insists on. Thirteen of the fifteen space a file’s blocks six sectors apart and two space them ten apart, and both kinds are saves the player made with the game itself. FILE_INTERLEAVE is 6 because that is what thirteen of them carry, and it is a parameter because the other two prove it is a choice;

  • interleave 6, wrapping by the track’s sector count and then stepping forward to the next free sector, reproduces the sector chain of both files on the thirteen clean specimens exactly – 38 blocks, including the spill from track 17 to track 16, which keeps the running sector number across the track change rather than restarting at 0;

  • the directory chain is the same rule with DIRECTORY_INTERLEAVE 3, which reproduces all thirteen directory sectors of POOL1.D64.orig’s 103-entry directory;

  • an unused sector reads as 256 zero bytes – true of all 643 of them on PORSAVE13.D64, and of every unused sector on fourteen of the fifteen disks. The exception is one sector on PORSAVE.D64 holding the tail of something scratched, which is ordinary 1541 behaviour: the drive frees a block without wiping it.

What this does not do

  • A file is never grown or replaced. D64.write_file() refuses a name already in the directory; D64.write_file_inplace() is what rewrites one, and only at the same block count. Nothing here scratches a file, so no block is ever freed.

  • Only 35-track plain images are built. D64.blank() makes the one variant that is writable at all.

Geometry (35 tracks, 683 sectors, 174848 bytes):

tracks  1-17: 21 sectors
tracks 18-24: 19 sectors
tracks 25-30: 18 sectors
tracks 31-35: 17 sectors
tracks 36-42: 17 sectors   -- the 40- and 42-track extensions

Sectors are stored back to back in track order, so the byte offset of (track, sector) is (blocks before track + sector) * 256. Tracks 1-35 sit at the same offsets in every variant, which is why a 40-track image can be read by code that only knows about 35.

Variants

A .D64 comes in six sizes and this reader knows all six. Anything else is still refused – a size we cannot name is a file we cannot claim to understand, and guessing at one is how a reader starts returning plausible nonsense.

Size

Tracks

Error bytes

Where it comes from

174848

35

no

the plain image; every save disk this project writes

175531

35

yes

a copier that recorded the read status of each of the 683 sectors (Curse side 4 is one)

196608

40

no

a 40-track format

197376

40

yes

Champions of Krynn side A is one

205312

42

no

a 42-track format; unseen here

206114

42

yes

likewise

Error bytes are advisory and this reader does not act on them. They are one byte per sector appended after the sector data, 1 meaning “read cleanly”. They are exposed through D64.error_code() and nothing else consults them, because on the specimens we hold they mark padding, not damage: all 85 sectors on Champions of Krynn side A’s tracks 36-40 carry code 3, which is DOS error 21, “no sync character” – what an unformatted track reads as – and no sector chain on that disk leaves track 35. Refusing an image because it reports errors would refuse a perfectly readable disk; hiding the codes would lose the evidence that says the padding is padding.

Only the plain 174848-byte image is writable. Every other variant is read-only, enforced rather than documented: D64.write_sector(), D64.write_file_inplace() and D64.save() raise ReadOnlyImageError. The reason is that the variants are rips of other people’s disks, not save disks. Writing to one would have to maintain an error map this reader does not model, and the directories on those images are not always the drive’s own work – Death Knights of Krynn’s has PETSCII art in the entries and a zero block count against every real file. A rewrite that trusts such a directory misbehaves. Nothing in this project needs to write to anything but a save disk, so the safe rule costs nothing.

Module Attributes

ENTRY_FIELDS

Bytes of a directory slot that belong to it.

BAM_TRACK_ENTRIES

Byte offsets inside track 18 sector 0, the BAM.

DOS_VERSION

The DOS version byte at BAM offset 2.

FILE_INTERLEAVE

How far apart D64.write_file() spaces a file's blocks round a track.

DIRECTORY_INTERLEAVE

The same spacing for directory sectors on track 18.

DEFAULT_DISK_NAME

What D64.blank() names a disk when the caller does not.

VARIANTS

Size -> Variant.

ERROR_OK

An error byte of 1 is "read cleanly"; anything else is the 1541 error the copier saw: bytes 2 to 11 are DOS errors 20 to 29 in order, and byte 15 is error 74, drive not ready.

Functions

attach_load_address(load_address, payload)

Prepend a 2-byte little-endian load address to payload.

load_payload(disk, name)

Read one file off a disk and drop its 2-byte PRG load address.

sector_offset(track, sector[, track_count])

Byte offset of (track, sector) within a D64 image.

sectors_per_track(track[, track_count])

Number of sectors on track (1-based).

split_load_address(data)

Split a PRG into (load_address, payload).

total_sectors([track_count])

How many sectors an image of track_count tracks holds.

Classes

D64

A 1541 disk image held in memory: 35, 40 or 42 tracks, error bytes or not.

DirEntry

One 32-byte directory slot.

Variant

One recognised .D64 shape, keyed by file size.

Exceptions

BlockCountMismatch

New data does not occupy the same number of blocks as the original.

D64Error

Base class for disk image errors.

DirectoryFullError

Track 18 has no room for another directory sector: 144 files is the lot.

DiskFullError

Not enough free blocks for the file; nothing was written.

DuplicateFileError

A file of that name is already in the directory.

FileNotFoundInImage

No directory entry matched the requested name.

InvalidImageError

The image is not one of the D64 sizes in VARIANTS.

ReadOnlyImageError

A write was attempted on a variant this reader will not modify.

SectorChainError

A file's sector chain is malformed (bad link or a loop).

exception goldbox.d64.BlockCountMismatch[source]

Bases: goldbox.d64.D64Error, ValueError

New data does not occupy the same number of blocks as the original.

class goldbox.d64.D64[source]

Bases: object

A 1541 disk image held in memory: 35, 40 or 42 tracks, error bytes or not.

An unrecognised size is still an error. The point of the variant table is that every accepted size is one whose geometry we can state exactly, not that the reader will try its luck on anything handed to it.

__init__(data)[source]
Parameters:

data (bytes | bytearray)

classmethod blank(name=DEFAULT_DISK_NAME, disk_id=DEFAULT_DISK_ID)[source]

A freshly formatted 35-track disk with nothing on it (#118).

The BAM on track 18 sector 0 marks every sector free but its own and the first directory sector, and the directory is one sector at track 18 sector 1 whose link reads 00 FF – track 0, so end of chain. Every other sector is 256 zero bytes, which is what an unused sector reads as on fourteen of the player’s fifteen save disks.

name is at most 16 bytes and disk_id is exactly 2. Both default to what the player’s own disks carry; see DEFAULT_DISK_NAME.

Parameters:
Return type:

goldbox.d64.D64

property blocks_free: int

Free blocks outside the directory track – the drive’s own BLOCKS FREE.

Track 18 is left out because the drive leaves it out: its sectors are the BAM and the directory, and a file is never given one.

static blocks_needed(length)[source]

How many 254-byte blocks a file of length bytes occupies.

Parameters:

length (int)

Return type:

int

property data: bytearray

Live mutable buffer. Mutating it mutates the image.

Deliberately not guarded by writable – it is the escape hatch, and a caller reaching past the accessors has said so.

directory(include_empty=False)[source]
Parameters:

include_empty (bool)

Return type:

list[goldbox.d64.DirEntry]

property disk_id: bytes
property disk_name: bytes

The 16-byte disk name from track 18 sector 0, 0xA0 padding stripped.

entry(name)[source]

Like find() but raises if the name is absent.

Parameters:

name (bytes | bytearray | str)

Return type:

goldbox.d64.DirEntry

error_code(track, sector)[source]

The copier’s error byte for a sector, or None if the image has none.

Advisory: nothing in this reader consults it. 1 means the sector read cleanly; 3 – “no header found” – is what an unformatted track reports, and is why tracks 36-40 of a 40-track rip are padding rather than damage.

Parameters:
Return type:

int | None

find(name)[source]

First directory entry whose name matches, or None.

Parameters:

name (bytes | bytearray | str)

Return type:

goldbox.d64.DirEntry | None

classmethod from_bytes(data)[source]
Parameters:

data (bytes | bytearray)

Return type:

goldbox.d64.D64

is_free(track, sector)[source]

Whether the BAM calls (track, sector) free. A set bit is free.

Parameters:
Return type:

bool

iter_directory(include_empty=False)[source]

Walk the directory chain from track 18 sector 1.

Empty (never-used) slots are skipped unless include_empty is set.

Parameters:

include_empty (bool)

Return type:

Iterator[goldbox.d64.DirEntry]

classmethod open(path)[source]
Parameters:

path (str | os.PathLike)

Return type:

goldbox.d64.D64

read_file(entry_or_name)[source]

Full raw byte stream of a file (for a PRG this includes the load address).

Parameters:

entry_or_name (goldbox.d64.DirEntry | bytes | bytearray | str)

Return type:

bytes

read_sector(track, sector)[source]
Parameters:
Return type:

bytes

save(path)[source]

Write the image, atomically.

A save disk is often the only copy of hours of play, and the editor writes back over the file it opened. A plain truncate-and-write loses the lot if the process dies or the filesystem fills half way through, so write a temporary beside the target, flush it to the platter, and rename over. os.replace is atomic on POSIX: after it, the file is either entirely the old image or entirely the new one.

Refused on a read-only variant. to_bytes still works, so copying one out remains possible; what is refused is this module putting its name to a written image whose format it does not fully model.

Parameters:

path (str | os.PathLike)

Return type:

None

sector_chain(entry_or_name)[source]

The ordered list of (track, sector) blocks holding a file.

Parameters:

entry_or_name (goldbox.d64.DirEntry | bytes | bytearray | str)

Return type:

list[tuple[int, int]]

to_bytes()[source]
Return type:

bytes

property total_sectors: int
property track_count: int
track_free(track)[source]

The free-block count the BAM stores for track.

Parameters:

track (int)

Return type:

int

property variant: goldbox.d64.Variant
property writable: bool

False for every variant but the plain 174848-byte image.

write_file(name, data, file_type=FILE_TYPE_PRG, interleave=FILE_INTERLEAVE)[source]

Put a new file on the disk: allocate, chain, and link an entry (#118).

data is the file’s whole byte stream, so for a PRG that includes the two-byte load address – attach_load_address() is what puts it there. Returns the directory entry that now names it.

The name has to be new. Growing or replacing a file is deliberately not implemented, because nothing here scratches a file and so no block is ever freed; write_file_inplace() rewrites one at its existing block count.

A failure leaves the image byte for byte as it was – a disk that ran out of room half way through a write is a corrupt disk, and the caller cannot tell from the exception which half happened.

Parameters:
Return type:

goldbox.d64.DirEntry

write_file_inplace(entry_or_name, new_data)[source]

Overwrite a file’s contents, reusing its existing sector chain.

Raises BlockCountMismatch unless new_data occupies exactly the same number of blocks as the file already on disk. Nothing outside the file’s own payload bytes is touched – links, the directory entry, and any slack after the payload in the final sector are left alone – so rewriting a file with its current contents is a no-op on the image.

Parameters:
Return type:

None

write_sector(track, sector, data)[source]
Parameters:
Return type:

None

exception goldbox.d64.D64Error[source]

Bases: Exception

Base class for disk image errors.

goldbox.d64.DEFAULT_DISK_NAME = b' '

What D64.blank() names a disk when the caller does not.

A single space and 00 are what thirteen of the player’s own save disks carry, so a disk built here is indistinguishable from one the drive formatted. The other two read BLANK, which is how we know the game does not care what a save disk is called.

goldbox.d64.DIRECTORY_INTERLEAVE = 3

The same spacing for directory sectors on track 18. Three reproduces all thirteen directory sectors of POOL1.D64.orig’s 103-entry directory.

class goldbox.d64.DirEntry[source]

Bases: object

One 32-byte directory slot.

__init__(index, dir_track, dir_sector, slot, type_byte, first_track, first_sector, raw_name, block_count)
Parameters:
  • index (int)

  • dir_track (int)

  • dir_sector (int)

  • slot (int)

  • type_byte (int)

  • first_track (int)

  • first_sector (int)

  • raw_name (bytes)

  • block_count (int)

Return type:

None

block_count: int
dir_sector: int
dir_track: int
property display_name: str

Printable rendering of name; non-ASCII shown as \xNN.

property file_type: int

Low nibble of the type byte (0=DEL, 1=SEQ, 2=PRG, 3=USR, 4=REL).

first_sector: int
first_track: int
index: int

Ordinal position across the whole directory (including empty slots).

property is_closed: bool

True unless the file was left open (*PRG splat file).

property is_empty: bool

no type and no start block.

Type:

An unused slot

property is_locked: bool
property is_prg: bool
property name: bytes

The name with trailing 0xA0 padding removed; nothing else stripped.

property offset: int

Byte offset of this entry within the image.

raw_name: bytes

The 16 name bytes exactly as stored, including 0xA0 padding.

slot: int

Which of the 8 slots inside (dir_track, dir_sector) this is.

type_byte: int
property type_name: str
exception goldbox.d64.DirectoryFullError[source]

Bases: goldbox.d64.D64Error

Track 18 has no room for another directory sector: 144 files is the lot.

exception goldbox.d64.DiskFullError[source]

Bases: goldbox.d64.D64Error

Not enough free blocks for the file; nothing was written.

exception goldbox.d64.DuplicateFileError[source]

Bases: goldbox.d64.D64Error

A file of that name is already in the directory.

goldbox.d64.ENTRY_FIELDS = 30

Bytes of a directory slot that belong to it. Eight 32-byte slots starting two bytes in would need 258, and a sector is 256: the last two bytes of every slot are the next slot’s first two, which nothing uses – except for slot 0, where they are the sector’s link. So a write must stop at 30, or filling slot 7 wipes the link of the sector after it and the rest of the directory disappears. That is not hypothetical; it is what the 144-file test caught.

goldbox.d64.FILE_INTERLEAVE = 6

How far apart D64.write_file() spaces a file’s blocks round a track.

Six, because thirteen of the player’s fifteen save disks space them six apart – and it is a parameter rather than a constant because the other two space them ten apart and the game reads both. Interleave is a matter of how long the drive takes to come round again, not of what the file means: a reader has only the chain to go on and cannot tell the two apart.

exception goldbox.d64.FileNotFoundInImage[source]

Bases: goldbox.d64.D64Error, KeyError

No directory entry matched the requested name.

exception goldbox.d64.InvalidImageError[source]

Bases: goldbox.d64.D64Error

The image is not one of the D64 sizes in VARIANTS.

exception goldbox.d64.ReadOnlyImageError[source]

Bases: goldbox.d64.D64Error

A write was attempted on a variant this reader will not modify.

exception goldbox.d64.SectorChainError[source]

Bases: goldbox.d64.D64Error

A file’s sector chain is malformed (bad link or a loop).

goldbox.d64.VARIANTS: dict[int, goldbox.d64.Variant] = {174848: Variant(size=174848, tracks=35, sectors=683, has_error_bytes=False, description='35 tracks'), 175531: Variant(size=175531, tracks=35, sectors=683, has_error_bytes=True, description='35 tracks plus error bytes'), 196608: Variant(size=196608, tracks=40, sectors=768, has_error_bytes=False, description='40 tracks'), 197376: Variant(size=197376, tracks=40, sectors=768, has_error_bytes=True, description='40 tracks plus error bytes'), 205312: Variant(size=205312, tracks=42, sectors=802, has_error_bytes=False, description='42 tracks'), 206114: Variant(size=206114, tracks=42, sectors=802, has_error_bytes=True, description='42 tracks plus error bytes')}

Size -> Variant. 174848, 175531, 196608, 197376, 205312, 206114.

class goldbox.d64.Variant[source]

Bases: object

One recognised .D64 shape, keyed by file size.

writable is the plain 35-track image and nothing else; see the module docstring for why, and ReadOnlyImageError for what enforces it.

__init__(size, tracks, sectors, has_error_bytes, description)
Parameters:
  • size (int)

  • tracks (int)

  • sectors (int)

  • has_error_bytes (bool)

  • description (str)

Return type:

None

description: str
property error_base: int | None

Offset of the error map, or None when there is not one.

has_error_bytes: bool
sectors: int
size: int
tracks: int
property writable: bool
goldbox.d64.attach_load_address(load_address, payload)[source]

Prepend a 2-byte little-endian load address to payload.

Parameters:
Return type:

bytes

goldbox.d64.load_payload(disk, name)[source]

Read one file off a disk and drop its 2-byte PRG load address.

Four modules were each opening the image, reading a file and splitting the load address by hand. One place to get that wrong is enough.

Parameters:
Return type:

bytes

goldbox.d64.sector_offset(track, sector, track_count=TRACK_COUNT)[source]

Byte offset of (track, sector) within a D64 image.

Parameters:
Return type:

int

goldbox.d64.sectors_per_track(track, track_count=TRACK_COUNT)[source]

Number of sectors on track (1-based).

track_count defaults to 35, so a bare call still refuses track 36 – which is what every existing caller means by it.

Parameters:
  • track (int)

  • track_count (int)

Return type:

int

goldbox.d64.split_load_address(data)[source]

Split a PRG into (load_address, payload).

Parameters:

data (bytes)

Return type:

tuple[int, bytes]

goldbox.d64.total_sectors(track_count=TRACK_COUNT)[source]

How many sectors an image of track_count tracks holds.

Parameters:

track_count (int)

Return type:

int