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_INTERLEAVEis 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_INTERLEAVE3, which reproduces all thirteen directory sectors ofPOOL1.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 onPORSAVE.D64holding 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
Bytes of a directory slot that belong to it. |
|
|
Byte offsets inside track 18 sector 0, the BAM. |
|
The DOS version byte at BAM offset 2. |
How far apart |
|
The same spacing for directory sectors on track 18. |
|
What |
|
Size -> |
|
|
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
|
Prepend a 2-byte little-endian load address to |
|
Read one file off a disk and drop its 2-byte PRG load address. |
|
Byte offset of |
|
Number of sectors on |
|
Split a PRG into |
|
How many sectors an image of |
Classes
A 1541 disk image held in memory: 35, 40 or 42 tracks, error bytes or not. |
|
One 32-byte directory slot. |
|
One recognised |
Exceptions
New data does not occupy the same number of blocks as the original. |
|
Base class for disk image errors. |
|
Track 18 has no room for another directory sector: 144 files is the lot. |
|
Not enough free blocks for the file; nothing was written. |
|
A file of that name is already in the directory. |
|
No directory entry matched the requested name. |
|
The image is not one of the D64 sizes in |
|
A write was attempted on a variant this reader will not modify. |
|
A file's sector chain is malformed (bad link or a loop). |
- exception goldbox.d64.BlockCountMismatch[source]¶
Bases:
goldbox.d64.D64Error,ValueErrorNew data does not occupy the same number of blocks as the original.
- class goldbox.d64.D64[source]¶
Bases:
objectA 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.
- 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.nameis at most 16 bytes anddisk_idis exactly 2. Both default to what the player’s own disks carry; seeDEFAULT_DISK_NAME.
- 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.
- 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.
- 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.
- find(name)[source]¶
First directory entry whose name matches, or
None.- Parameters:
- Return type:
goldbox.d64.DirEntry | None
- iter_directory(include_empty=False)[source]¶
Walk the directory chain from track 18 sector 1.
Empty (never-used) slots are skipped unless
include_emptyis set.- Parameters:
include_empty (bool)
- Return type:
- classmethod open(path)[source]¶
- Parameters:
path (str | os.PathLike)
- Return type:
- 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:
- 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.replaceis 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_bytesstill 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
- property variant: goldbox.d64.Variant¶
- 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).
datais 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.
- write_file_inplace(entry_or_name, new_data)[source]¶
Overwrite a file’s contents, reusing its existing sector chain.
Raises
BlockCountMismatchunlessnew_dataoccupies 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:
entry_or_name (goldbox.d64.DirEntry | bytes | bytearray | str)
new_data (bytes)
- Return type:
None
- goldbox.d64.DEFAULT_DISK_NAME = b' '¶
What
D64.blank()names a disk when the caller does not.A single space and
00are 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 readBLANK, 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:
objectOne 32-byte directory slot.
- __init__(index, dir_track, dir_sector, slot, type_byte, first_track, first_sector, raw_name, block_count)¶
- exception goldbox.d64.DirectoryFullError[source]¶
Bases:
goldbox.d64.D64ErrorTrack 18 has no room for another directory sector: 144 files is the lot.
- exception goldbox.d64.DiskFullError[source]¶
Bases:
goldbox.d64.D64ErrorNot enough free blocks for the file; nothing was written.
- exception goldbox.d64.DuplicateFileError[source]¶
Bases:
goldbox.d64.D64ErrorA 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,KeyErrorNo directory entry matched the requested name.
- exception goldbox.d64.InvalidImageError[source]¶
Bases:
goldbox.d64.D64ErrorThe image is not one of the D64 sizes in
VARIANTS.
- exception goldbox.d64.ReadOnlyImageError[source]¶
Bases:
goldbox.d64.D64ErrorA write was attempted on a variant this reader will not modify.
- exception goldbox.d64.SectorChainError[source]¶
Bases:
goldbox.d64.D64ErrorA 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:
objectOne recognised
.D64shape, keyed by file size.writableis the plain 35-track image and nothing else; see the module docstring for why, andReadOnlyImageErrorfor what enforces it.- __init__(size, tracks, sectors, has_error_bytes, description)¶
- goldbox.d64.attach_load_address(load_address, payload)[source]¶
Prepend a 2-byte little-endian load address to
payload.
- 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.
- goldbox.d64.sector_offset(track, sector, track_count=TRACK_COUNT)[source]¶
Byte offset of
(track, sector)within a D64 image.
- goldbox.d64.sectors_per_track(track, track_count=TRACK_COUNT)[source]¶
Number of sectors on
track(1-based).track_countdefaults to 35, so a bare call still refuses track 36 – which is what every existing caller means by it.