read/write WOZ2 support

This commit is contained in:
4am 2019-02-15 14:49:03 -05:00
parent 163f38457e
commit df33c71223
1 changed files with 298 additions and 96 deletions

View File

@ -12,7 +12,7 @@ import itertools
import os import os
__version__ = "2.0-alpha" # https://semver.org __version__ = "2.0-alpha" # https://semver.org
__date__ = "2019-02-12" __date__ = "2019-02-15"
__progname__ = "wozardry" __progname__ = "wozardry"
__displayname__ = __progname__ + " " + __version__ + " by 4am (" + __date__ + ")" __displayname__ = __progname__ + " " + __version__ + " by 4am (" + __date__ + ")"
@ -22,6 +22,7 @@ kWOZ2 = b"WOZ2"
kINFO = b"INFO" kINFO = b"INFO"
kTMAP = b"TMAP" kTMAP = b"TMAP"
kTRKS = b"TRKS" kTRKS = b"TRKS"
kWRIT = b"WRIT"
kMETA = b"META" kMETA = b"META"
kBitstreamLengthInBytes = 6646 kBitstreamLengthInBytes = 6646
kLanguages = ("English","Spanish","French","German","Chinese","Japanese","Italian","Dutch","Portuguese","Danish","Finnish","Norwegian","Swedish","Russian","Polish","Turkish","Arabic","Thai","Czech","Hungarian","Catalan","Croatian","Greek","Hebrew","Romanian","Slovak","Ukrainian","Indonesian","Malay","Vietnamese","Other") kLanguages = ("English","Spanish","French","German","Chinese","Japanese","Italian","Dutch","Portuguese","Danish","Finnish","Norwegian","Swedish","Russian","Polish","Turkish","Arabic","Thai","Czech","Hungarian","Catalan","Croatian","Greek","Hebrew","Romanian","Slovak","Ukrainian","Indonesian","Malay","Vietnamese","Other")
@ -33,8 +34,10 @@ sEOF = "Unexpected EOF"
sBadChunkSize = "Bad chunk size" sBadChunkSize = "Bad chunk size"
dNoYes = {False:"no",True:"yes"} dNoYes = {False:"no",True:"yes"}
tQuarters = (".00",".25",".50",".75") tQuarters = (".00",".25",".50",".75")
tDiskType = ("", "5.25-inch", "3.5-inch") tDiskType = {(1,1,False): "5.25-inch (140K)",
tDiskSides = ("", "400K", "800K") (2,1,False): "3.5-inch (400K)",
(2,2,False): "3.5-inch (800K)",
(2,2,True): "3.5-inch (1.44MB)"}
tBootSectorFormat = ("unknown", "16-sector", "13-sector", "hybrid (13- and 16-sector)") tBootSectorFormat = ("unknown", "16-sector", "13-sector", "hybrid (13- and 16-sector)")
# errors that may be raised # errors that may be raised
@ -69,6 +72,7 @@ class WozMETAFormatError_BadMachine(WozFormatError): pass
def from_uint32(b): def from_uint32(b):
return int.from_bytes(b, byteorder="little") return int.from_bytes(b, byteorder="little")
from_uint16=from_uint32 from_uint16=from_uint32
from_uint8=from_uint32
def to_uint32(b): def to_uint32(b):
return b.to_bytes(4, byteorder="little") return b.to_bytes(4, byteorder="little")
@ -79,6 +83,50 @@ def to_uint16(b):
def to_uint8(b): def to_uint8(b):
return b.to_bytes(1, byteorder="little") return b.to_bytes(1, byteorder="little")
def is_booleanish(v):
if type(v) is str:
try:
return is_booleanish(int(v))
except:
return v.lower() in ("true","false","yes","no")
elif type(v) is bytes:
try:
return is_booleanish(int.from_bytes(v, byteorder="little"))
except:
return False
return v in (0, 1)
def from_booleanish(v, errorClass, errorString):
raise_if(not is_booleanish(v), errorClass, errorString % v)
if type(v) is str:
return v.lower() in ("true","yes")
elif type(v) is bytes:
return v == b"\x01"
return v == 1
def is_intish(v):
if type(v) is str:
try:
int(v)
return True
except:
return False
if type(v) is bytes:
try:
int.from_bytes(v, byteorder="little")
return True
except:
return False
return type(v) is int
def from_intish(v, errorClass, errorString):
raise_if(not is_intish(v), errorClass, errorString % v)
if type(v) is str:
return int(v)
elif type(v) is bytes:
return int.from_bytes(v, byteorder="little")
return v
def raise_if(cond, e, s=""): def raise_if(cond, e, s=""):
if cond: raise e(s) if cond: raise e(s)
@ -136,6 +184,7 @@ class WozDiskImage:
self.woz_version = None self.woz_version = None
self.tmap = [0xFF]*160 self.tmap = [0xFF]*160
self.tracks = [] self.tracks = []
self.writ = None
self.info = collections.OrderedDict() self.info = collections.OrderedDict()
self.meta = collections.OrderedDict() self.meta = collections.OrderedDict()
@ -200,22 +249,31 @@ class WozDiskImage:
class WozValidator: class WozValidator:
def validate_info_version(self, version): def validate_info_version(self, version):
""" |version| can be str, bytes, or int. returns same value as int"""
version = from_intish(version, WozINFOFormatError_BadVersion, "Unknown version (expected numeric value, found %s)")
if self.woz_version == 1: if self.woz_version == 1:
raise_if(version != b'\x01', WozINFOFormatError_BadVersion, "Unknown version (expected 1, found %s)" % version) raise_if(version != 1, WozINFOFormatError_BadVersion, "Unknown version (expected 1, found %s)" % version)
else: else:
raise_if(version in (b'\x00', b'\x01'), WozINFOFormatError_BadVersion, "Unknown version (expected 2 or more, found %s)" % version) raise_if(version < 2, WozINFOFormatError_BadVersion, "Unknown version (expected 2 or more, found %s)" % version)
return version
def validate_info_disk_type(self, disk_type): def validate_info_disk_type(self, disk_type):
raise_if(disk_type not in (b'\x01',b'\x02'), WozINFOFormatError_BadDiskType, "Unknown disk type (expected 1 or 2, found %s)" % disk_type) """ |disk_type| can be str, bytes, or int. returns same value as int"""
disk_type = from_intish(disk_type, WozINFOFormatError_BadDiskType, "Unknown disk type (expected numeric value, found %s)")
raise_if(disk_type not in (1, 2), WozINFOFormatError_BadDiskType, "Unknown disk type (expected 1 or 2, found %s)" % disk_type)
return disk_type
def validate_info_write_protected(self, write_protected): def validate_info_write_protected(self, write_protected):
raise_if(write_protected not in (b'\x00',b'\x01'), WozINFOFormatError_BadWriteProtected, "Unknown write protected flag (expected 0 or 1, found %s)" % write_protected) """|write_protected| can be str, bytes, or int. returns same value as bool"""
return from_booleanish(write_protected, WozINFOFormatError_BadWriteProtected, "Unknown write protected flag (expected Boolean value, found %s)")
def validate_info_synchronized(self, synchronized): def validate_info_synchronized(self, synchronized):
raise_if(synchronized not in (b'\x00',b'\x01'), WozINFOFormatError_BadSynchronized, "Unknown synchronized flag (expected 0, or 1, found %s)" % synchronized) """|synchronized| can be str, bytes, or int. returns same value as bool"""
return from_booleanish(synchronized, WozINFOFormatError_BadSynchronized, "Unknown synchronized flag (expected Boolean value, found %s)")
def validate_info_cleaned(self, cleaned): def validate_info_cleaned(self, cleaned):
raise_if(cleaned not in (b'\x00',b'\x01'), WozINFOFormatError_BadCleaned, "Unknown cleaned flag (expected 0 or 1, found %s)" % cleaned) """|cleaned| can be str, bytes, or int. returns same value as bool"""
return from_booleanish(cleaned, WozINFOFormatError_BadCleaned, "Unknown cleaned flag (expected Boolean value, found %s)")
def validate_info_creator(self, creator_as_bytes): def validate_info_creator(self, creator_as_bytes):
raise_if(len(creator_as_bytes) > 32, WozINFOFormatError_BadCreator, "Creator is longer than 32 bytes") raise_if(len(creator_as_bytes) > 32, WozINFOFormatError_BadCreator, "Creator is longer than 32 bytes")
@ -234,26 +292,40 @@ class WozValidator:
return creator_as_bytes.decode("UTF-8").strip() return creator_as_bytes.decode("UTF-8").strip()
def validate_info_disk_sides(self, disk_sides): def validate_info_disk_sides(self, disk_sides):
"""|disk_sides| can be str, bytes, or int. returns same value as int"""
# assumes WOZ version 2 or later # assumes WOZ version 2 or later
disk_sides = from_intish(disk_sides, WozINFOFormatError_BadDiskSides, "Bad disk sides (expected numeric value, found %s)")
if self.info["disk_type"] == 1: # 5.25-inch disk if self.info["disk_type"] == 1: # 5.25-inch disk
raise_if(disk_sides != b'\x01', WozINFOFormatError_BadDiskSides, "Bad disk sides (expected 1 for a 5.25-inch disk, found %s)" % disk_sides) raise_if(disk_sides != 1, WozINFOFormatError_BadDiskSides, "Bad disk sides (expected 1 for a 5.25-inch disk, found %s)")
elif self.info["disk_type"] == 2: # 3.5-inch disk elif self.info["disk_type"] == 2: # 3.5-inch disk
raise_if(disk_sides not in (b'\x01', b'\x02'), WozINFOFormatError_BadDiskSides, "Bad disk sides (expected 1 or 2 for a 3.5-inch disk, found %s)" % disk_sides) raise_if(disk_sides not in (1, 2), WozINFOFormatError_BadDiskSides, "Bad disk sides (expected 1 or 2 for a 3.5-inch disk, found %s)" % disk_sides)
return disk_sides
def validate_info_boot_sector_format(self, boot_sector_format): def validate_info_boot_sector_format(self, boot_sector_format):
"""|boot_sector_format| can be str, bytes, or int. returns same value as int"""
# assumes WOZ version 2 or later # assumes WOZ version 2 or later
boot_sector_format = from_intish(boot_sector_format, WozINFOFormatError_BadBootSectorFormat, "Bad boot sector format (expected numeric value, found %s)")
if self.info["disk_type"] == 1: # 5.25-inch disk if self.info["disk_type"] == 1: # 5.25-inch disk
raise_if(boot_sector_format not in (b'\x00',b'\x01',b'\x02',b'\x03'), WozINFOFormatError_BadBootSectorFormat, "Bad boot sector format (expected 0,1,2,3 for a 5.25-inch disk, found %s)" % boot_sector_format) raise_if(boot_sector_format not in (0,1,2,3), WozINFOFormatError_BadBootSectorFormat, "Bad boot sector format (expected 0,1,2,3 for a 5.25-inch disk, found %s)" % boot_sector_format)
elif self.info["disk_type"] == 2: # 3.5-inch disk elif self.info["disk_type"] == 2: # 3.5-inch disk
raise_if(boot_sector_format != b'\x00', WozINFOFormatError_BadBootSectorFormat, "Bad boot sector format (expected 0 for a 3.5-inch disk, found %s)" % boot_sector_format) raise_if(boot_sector_format != 0, WozINFOFormatError_BadBootSectorFormat, "Bad boot sector format (expected 0 for a 3.5-inch disk, found %s)" % boot_sector_format)
return boot_sector_format
def validate_info_optimal_bit_timing(self, optimal_bit_timing): def validate_info_optimal_bit_timing(self, optimal_bit_timing):
"""|optimal_bit_timing| can be str, bytes, or int. returns same value as int"""
# assumes WOZ version 2 or later # assumes WOZ version 2 or later
# |optimal_bit_timing| is int, not bytes optimal_bit_timing = from_intish(optimal_bit_timing, WozINFOFormatError_BadOptimalBitTiming, "Bad optimal bit timing (expected numeric value, found %s)")
if self.info["disk_type"] == 1: # 5.25-inch disk if self.info["disk_type"] == 1: # 5.25-inch disk
raise_if(optimal_bit_timing not in range(24, 41), WozINFOFormatError_BadOptimalBitTiming, "Bad optimal bit timing (expected 24-40 for a 5.25-inch disk, found %s)" % optimal_bit_timing) raise_if(optimal_bit_timing not in range(24, 41), WozINFOFormatError_BadOptimalBitTiming, "Bad optimal bit timing (expected 24-40 for a 5.25-inch disk, found %s)" % optimal_bit_timing)
elif self.info["disk_type"] == 2: # 3.5-inch disk elif self.info["disk_type"] == 2: # 3.5-inch disk
raise_if(optimal_bit_timing not in range(8, 25), WozINFOFormatError_BadOptimalBitTiming, "Bad optimal bit timing (expected 8-24 for a 3.5-inch disk, found %s)" % optimal_bit_timing) raise_if(optimal_bit_timing not in range(8, 25), WozINFOFormatError_BadOptimalBitTiming, "Bad optimal bit timing (expected 8-24 for a 3.5-inch disk, found %s)" % optimal_bit_timing)
return optimal_bit_timing
def validate_info_required_ram(self, required_ram):
"""|required_ram| can be str, bytes, or int. returns same value as int"""
# assumes WOZ version 2 or later
required_ram = from_intish(required_ram, WozINFOFormatError_BadOptimalBitTiming, "Bad required RAM (expected numeric value, found %s)")
return required_ram
def validate_metadata(self, metadata_as_bytes): def validate_metadata(self, metadata_as_bytes):
try: try:
@ -311,6 +383,8 @@ class WozReader(WozDiskImage, WozValidator):
self.__process_tmap(data) self.__process_tmap(data)
elif chunk_id == kTRKS: elif chunk_id == kTRKS:
self.__process_trks(data) self.__process_trks(data)
elif chunk_id == kWRIT:
self.__process_writ(data)
elif chunk_id == kMETA: elif chunk_id == kMETA:
self.__process_meta(data) self.__process_meta(data)
if crc: if crc:
@ -323,35 +397,23 @@ class WozReader(WozDiskImage, WozValidator):
raise_if(data[5:8] != b"\x0A\x0D\x0A", WozHeaderError_NoLF, "Magic bytes 0x0A0D0A not present at offset 5") raise_if(data[5:8] != b"\x0A\x0D\x0A", WozHeaderError_NoLF, "Magic bytes 0x0A0D0A not present at offset 5")
def __process_info(self, data): def __process_info(self, data):
version = data[0] self.info["version"] = self.validate_info_version(data[0]) # int
self.validate_info_version(to_uint8(version)) self.info["disk_type"] = self.validate_info_disk_type(data[1]) # int
self.info["version"] = version # int self.info["write_protected"] = self.validate_info_write_protected(data[2]) # boolean
disk_type = data[1] self.info["synchronized"] = self.validate_info_synchronized(data[3]) # boolean
self.validate_info_disk_type(to_uint8(disk_type)) self.info["cleaned"] = self.validate_info_cleaned(data[4]) # boolean
self.info["disk_type"] = disk_type # int self.info["creator"] = self.decode_info_creator(data[5:37]) # string
write_protected = data[2] if self.info["version"] >= 2:
self.validate_info_write_protected(to_uint8(write_protected)) self.info["disk_sides"] = self.validate_info_disk_sides(data[37]) # int
self.info["write_protected"] = (write_protected == 1) # boolean self.info["boot_sector_format"] = self.validate_info_boot_sector_format(data[38]) # int
synchronized = data[3] self.info["optimal_bit_timing"] = self.validate_info_optimal_bit_timing(data[39]) # int
self.info["synchronized"] = (synchronized == 1) # boolean compatible_hardware_bitfield = from_uint16(data[40:42])
self.validate_info_synchronized(to_uint8(synchronized)) compatible_hardware_list = []
cleaned = data[4] for offset in range(9):
self.validate_info_cleaned(to_uint8(cleaned)) if compatible_hardware_bitfield & (1 << offset):
self.info["cleaned"] = (cleaned == 1) # boolean compatible_hardware_list.append(kRequiresMachine[offset])
creator = self.decode_info_creator(data[5:37]) self.info["compatible_hardware"] = compatible_hardware_list
self.info["creator"] = creator # string self.info["required_ram"] = self.validate_info_required_ram(data[42:44])
if version >= 2:
disk_sides = data[37]
self.validate_info_disk_sides(to_uint8(disk_sides))
self.info["disk_sides"] = disk_sides # int
boot_sector_format = data[38]
self.validate_info_boot_sector_format(to_uint8(boot_sector_format))
self.info["boot_sector_format"] = boot_sector_format # int
optimal_bit_timing = data[39]
self.validate_info_optimal_bit_timing(optimal_bit_timing)
self.info["optimal_bit_timing"] = optimal_bit_timing # int
#TODO self.info["compatible_hardware"] = from_uint16(data[40:42])
self.info["required_ram"] = from_uint16(data[42:44])
self.info["largest_track"] = from_uint16(data[44:46]) self.info["largest_track"] = from_uint16(data[44:46])
def __process_tmap(self, data): def __process_tmap(self, data):
@ -365,23 +427,6 @@ class WozReader(WozDiskImage, WozValidator):
for trk, i in zip(self.tmap, itertools.count()): for trk, i in zip(self.tmap, itertools.count()):
raise_if(trk != 0xFF and trk >= len(self.tracks), WozTMAPFormatError_BadTRKS, "Invalid TMAP entry: track %d%s points to non-existent TRKS chunk %d" % (i/4, tQuarters[i%4], trk)) raise_if(trk != 0xFF and trk >= len(self.tracks), WozTMAPFormatError_BadTRKS, "Invalid TMAP entry: track %d%s points to non-existent TRKS chunk %d" % (i/4, tQuarters[i%4], trk))
def __process_trks_v2(self, data):
for trk in range(160):
i = trk * 8
starting_block = from_uint16(data[i:i+2])
raise_if(starting_block in (1,2), WozTRKSFormatError, "TRKS TRK %d starting_block out of range (expected 3+ or 0, found %s)" % (trk, starting_block))
block_count = from_uint16(data[i+2:i+4])
bit_count = from_uint32(data[i+4:i+8])
if starting_block == 0:
raise_if(block_count != 0, WozTRKSFormatError, "TRKS unused TRK %d block_count must be 0 (found %s)" % (trk, block_count))
raise_if(bit_count != 0, WozTRKSFormatError, "TRKS unused TRK %d bit_count must be 0 (found %s)" % (trk, bit_count))
break
bits_index_into_data = 1280 + (starting_block-3)*512
raw_bytes = data[bits_index_into_data : bits_index_into_data + block_count*512]
bits = bitarray.bitarray(endian="big")
bits.frombytes(raw_bytes)
self.tracks.append(WozTrack(bits, bit_count))
def __process_trks_v1(self, data): def __process_trks_v1(self, data):
i = 0 i = 0
while i < len(data): while i < len(data):
@ -413,6 +458,26 @@ class WozReader(WozDiskImage, WozValidator):
bits.frombytes(raw_bytes) bits.frombytes(raw_bytes)
self.tracks.append(WozTrack(bits, bit_count, splice_point, splice_nibble, splice_bit_count)) self.tracks.append(WozTrack(bits, bit_count, splice_point, splice_nibble, splice_bit_count))
def __process_trks_v2(self, data):
for trk in range(160):
i = trk * 8
starting_block = from_uint16(data[i:i+2])
raise_if(starting_block in (1,2), WozTRKSFormatError, "TRKS TRK %d starting_block out of range (expected 3+ or 0, found %s)" % (trk, starting_block))
block_count = from_uint16(data[i+2:i+4])
bit_count = from_uint32(data[i+4:i+8])
if starting_block == 0:
raise_if(block_count != 0, WozTRKSFormatError, "TRKS unused TRK %d block_count must be 0 (found %s)" % (trk, block_count))
raise_if(bit_count != 0, WozTRKSFormatError, "TRKS unused TRK %d bit_count must be 0 (found %s)" % (trk, bit_count))
break
bits_index_into_data = 1280 + (starting_block-3)*512
raw_bytes = data[bits_index_into_data : bits_index_into_data + block_count*512]
bits = bitarray.bitarray(endian="big")
bits.frombytes(raw_bytes)
self.tracks.append(WozTrack(bits, bit_count))
def __process_writ(self, data):
self.writ = data
def __process_meta(self, metadata_as_bytes): def __process_meta(self, metadata_as_bytes):
metadata = self.decode_metadata(metadata_as_bytes) metadata = self.decode_metadata(metadata_as_bytes)
for line in metadata.split("\n"): for line in metadata.split("\n"):
@ -431,14 +496,22 @@ class WozReader(WozDiskImage, WozValidator):
self.meta[key] = len(values) == 1 and values[0] or tuple(values) self.meta[key] = len(values) == 1 and values[0] or tuple(values)
class WozWriter(WozDiskImage, WozValidator): class WozWriter(WozDiskImage, WozValidator):
def __init__(self, creator): def __init__(self, woz_version, creator):
WozDiskImage.__init__(self) WozDiskImage.__init__(self)
self.info["version"] = 1 self.validate_info_version(woz_version)
self.woz_version = woz_version
self.info["version"] = woz_version
self.info["disk_type"] = 1 self.info["disk_type"] = 1
self.info["write_protected"] = False self.info["write_protected"] = False
self.info["synchronized"] = False self.info["synchronized"] = False
self.info["cleaned"] = False self.info["cleaned"] = False
self.info["creator"] = creator self.info["creator"] = creator
if woz_version >= 2:
self.info["disk_sides"] = 1
self.info["boot_sector_format"] = 0
self.info["optimal_bit_timing"] = 32
self.info["compatible_hardware"] = []
self.info["required_ram"] = 0
def build_info(self): def build_info(self):
chunk = bytearray() chunk = bytearray()
@ -455,13 +528,37 @@ class WozWriter(WozDiskImage, WozValidator):
cleaned_raw = to_uint8(self.info["cleaned"]) cleaned_raw = to_uint8(self.info["cleaned"])
self.validate_info_cleaned(cleaned_raw) self.validate_info_cleaned(cleaned_raw)
creator_raw = self.encode_info_creator(self.info["creator"]) creator_raw = self.encode_info_creator(self.info["creator"])
chunk.extend(version_raw) # version (int, probably 1) chunk.extend(version_raw) # 1 byte, 1 or 2
chunk.extend(disk_type_raw) # disk type (1=5.25 inch, 2=3.5 inch) chunk.extend(disk_type_raw) # 1 byte, 1=5.25 inch, 2=3.5 inch
chunk.extend(write_protected_raw) # write-protected (0=no, 1=yes) chunk.extend(write_protected_raw) # 1 byte, 0=no, 1=yes
chunk.extend(synchronized_raw) # tracks synchronized (0=no, 1=yes) chunk.extend(synchronized_raw) # 1 byte, 0=no, 1=yes
chunk.extend(cleaned_raw) # weakbits cleaned (0=no, 1=yes) chunk.extend(cleaned_raw) # 1 byte, 0=no, 1=yes
chunk.extend(creator_raw) # creator chunk.extend(creator_raw) # 32 bytes, UTF-8 encoded string
chunk.extend(b"\x00" * 23) # reserved if self.woz_version == 1:
chunk.extend(b"\x00" * 23) # 23 bytes of unused space
else:
disk_sides_raw = to_uint8(self.info["disk_sides"])
self.validate_info_disk_sides(disk_sides_raw)
boot_sector_format_raw = to_uint8(self.info["boot_sector_format"])
self.validate_info_boot_sector_format(boot_sector_format_raw)
optimal_bit_timing_raw = to_uint8(self.info["optimal_bit_timing"])
self.validate_info_optimal_bit_timing(optimal_bit_timing_raw)
compatible_hardware_bitfield = 0
for offset in range(9):
if kRequiresMachine[offset] in self.info["compatible_hardware"]:
compatible_hardware_bitfield |= (1 << offset)
compatible_hardware_raw = to_uint16(compatible_hardware_bitfield)
required_ram_raw = to_uint16(self.info["required_ram"])
largest_bit_count = max([track.bit_count for track in self.tracks])
largest_block_count = (((largest_bit_count+7)//8)+511)//512
largest_track_raw = to_uint16(largest_block_count)
chunk.extend(disk_sides_raw) # 1 byte, 1 or 2
chunk.extend(boot_sector_format_raw) # 1 byte, 0,1,2,3
chunk.extend(optimal_bit_timing_raw) # 1 byte
chunk.extend(compatible_hardware_raw) # 2 bytes, bitfield
chunk.extend(required_ram_raw) # 2 bytes
chunk.extend(largest_track_raw) # 2 bytes
chunk.extend(b"\x00" * 14) # 14 bytes of unused space
return chunk return chunk
def build_tmap(self): def build_tmap(self):
@ -472,6 +569,12 @@ class WozWriter(WozDiskImage, WozValidator):
return chunk return chunk
def build_trks(self): def build_trks(self):
if self.woz_version == 1:
return self.build_trks_v1()
else:
return self.build_trks_v2()
def build_trks_v1(self):
chunk = bytearray() chunk = bytearray()
chunk.extend(kTRKS) # chunk ID chunk.extend(kTRKS) # chunk ID
chunk_size = len(self.tracks)*6656 chunk_size = len(self.tracks)*6656
@ -488,6 +591,39 @@ class WozWriter(WozDiskImage, WozValidator):
chunk.extend(b"\x00\x00") # reserved chunk.extend(b"\x00\x00") # reserved
return chunk return chunk
def build_trks_v2(self):
starting_block = 3
trk_chunk = bytearray()
bits_chunk = bytearray()
for track in self.tracks:
# get bitstream as bytes and pad to multiple of 512
padded_bytes = track.bits.tobytes()
padded_bytes += (512 - (len(padded_bytes) % 512))*b"\x00"
trk_chunk.extend(to_uint16(starting_block))
block_size = len(padded_bytes) // 512
starting_block += block_size
trk_chunk.extend(to_uint16(block_size))
trk_chunk.extend(to_uint32(track.bits.length()))
bits_chunk.extend(padded_bytes)
for i in range(len(self.tracks), 160):
trk_chunk.extend(to_uint16(0))
trk_chunk.extend(to_uint16(0))
trk_chunk.extend(to_uint32(0))
chunk = bytearray()
chunk.extend(kTRKS) # chunk ID
chunk.extend(to_uint32(len(trk_chunk) + len(bits_chunk)))
chunk.extend(trk_chunk)
chunk.extend(bits_chunk)
return chunk
def build_writ(self):
chunk = bytearray()
if self.writ:
chunk.extend(kWRIT) # chunk ID
chunk.extend(to_uint32(len(self.writ))) # chunk size
chunk.extend(self.writ)
return chunk
def build_meta(self): def build_meta(self):
if not self.meta: return b"" if not self.meta: return b""
meta_tmp = {} meta_tmp = {}
@ -508,7 +644,7 @@ class WozWriter(WozDiskImage, WozValidator):
[k.encode("UTF-8") + \ [k.encode("UTF-8") + \
b"\x09" + \ b"\x09" + \
"|".join(v).encode("UTF-8") \ "|".join(v).encode("UTF-8") \
for k, v in meta_tmp.items()]) for k, v in meta_tmp.items()]) + b'\x0A'
chunk = bytearray() chunk = bytearray()
chunk.extend(kMETA) # chunk ID chunk.extend(kMETA) # chunk ID
chunk.extend(to_uint32(len(data))) # chunk size chunk.extend(to_uint32(len(data))) # chunk size
@ -517,7 +653,10 @@ class WozWriter(WozDiskImage, WozValidator):
def build_head(self, crc): def build_head(self, crc):
chunk = bytearray() chunk = bytearray()
chunk.extend(kWOZ1) # magic bytes if self.woz_version == 1:
chunk.extend(kWOZ1) # magic bytes
else:
chunk.extend(kWOZ2) # magic bytes
chunk.extend(b"\xFF\x0A\x0D\x0A") # more magic bytes chunk.extend(b"\xFF\x0A\x0D\x0A") # more magic bytes
chunk.extend(to_uint32(crc)) # CRC32 of rest of file (calculated in caller) chunk.extend(to_uint32(crc)) # CRC32 of rest of file (calculated in caller)
return chunk return chunk
@ -526,13 +665,15 @@ class WozWriter(WozDiskImage, WozValidator):
info = self.build_info() info = self.build_info()
tmap = self.build_tmap() tmap = self.build_tmap()
trks = self.build_trks() trks = self.build_trks()
meta = self.build_meta() writ = self.build_writ() # will be zero-length if no WRIT chunk
crc = binascii.crc32(info + tmap + trks + meta) meta = self.build_meta() # will be zero-length if no META chunk
crc = binascii.crc32(info + tmap + trks + writ + meta)
head = self.build_head(crc) head = self.build_head(crc)
stream.write(head) stream.write(head)
stream.write(info) stream.write(info)
stream.write(tmap) stream.write(tmap)
stream.write(trks) stream.write(trks)
stream.write(writ)
stream.write(meta) stream.write(meta)
#---------- command line interface ---------- #---------- command line interface ----------
@ -574,23 +715,39 @@ class CommandDump(BaseCommand):
self.print_info() self.print_info()
def print_info(self): def print_info(self):
print("INFO: File format version:".ljust(self.kWidth), "%d" % self.woz_image.info["version"]) info = self.woz_image.info
print("INFO: Disk type:".ljust(self.kWidth), tDiskType[self.woz_image.info["disk_type"]]) info_version = info["version"]
print("INFO: Write protected:".ljust(self.kWidth), dNoYes[self.woz_image.info["write_protected"]]) print("INFO: File format version:".ljust(self.kWidth), "%d" % info_version)
print("INFO: Track synchronized:".ljust(self.kWidth), dNoYes[self.woz_image.info["synchronized"]]) disk_type = info["disk_type"]
print("INFO: Weakbits cleaned:".ljust(self.kWidth), dNoYes[self.woz_image.info["cleaned"]]) disk_sides = info_version >= 2 and info["disk_sides"] or 1
print("INFO: Creator:".ljust(self.kWidth), self.woz_image.info["creator"]) large_disk = disk_sides == 2 and info["largest_track"] >= 0x20
if self.woz_image.info["version"] >= 2: print("INFO: Disk type:".ljust(self.kWidth), tDiskType[(disk_type,disk_sides,large_disk)])
if self.woz_image.info["disk_type"] == 2: # 3.5-inch disk print("INFO: Write protected:".ljust(self.kWidth), dNoYes[info["write_protected"]])
# only print this for 3.5-inch disks print("INFO: Tracks synchronized:".ljust(self.kWidth), dNoYes[info["synchronized"]])
disk_sides = self.woz_image.info["disk_sides"] print("INFO: Weakbits cleaned:".ljust(self.kWidth), dNoYes[info["cleaned"]])
print("INFO: Disk sides:".ljust(self.kWidth), "%s (%s)" % (disk_sides, tDiskSides[disk_sides])) print("INFO: Creator:".ljust(self.kWidth), info["creator"])
if self.woz_image.info["disk_type"] == 1: # 5.25-inch disk if info_version == 1: return
print("INFO: Boot sector format:".ljust(self.kWidth), tBootSectorFormat[self.woz_image.info["boot_sector_format"]]) if disk_type == 1: # 5.25-inch disk
print("INFO: Optimal bit timing:".ljust(self.kWidth), self.woz_image.info["optimal_bit_timing"]) boot_sector_format = info["boot_sector_format"]
#TODO self.info["compatible_hardware"] print("INFO: Boot sector format:".ljust(self.kWidth), "%s (%s)" % (boot_sector_format, tBootSectorFormat[boot_sector_format]))
ram = self.woz_image.info["required_ram"] default_bit_timing = 32
print("INFO: Required RAM:".ljust(self.kWidth), ram and "%sK" % ram or "unknown") else: # 3.5-inch disk
print("INFO: Disk sides:".ljust(self.kWidth), disk_sides)
default_bit_timing = 16
optimal_bit_timing = info["optimal_bit_timing"]
print("INFO: Optimal bit timing:".ljust(self.kWidth), optimal_bit_timing,
optimal_bit_timing == default_bit_timing and "(standard)" or
optimal_bit_timing < default_bit_timing and "(fast)" or "(slow)")
compatible_hardware_list = info["compatible_hardware"]
if not compatible_hardware_list:
print("INFO: Compatible hardware:".ljust(self.kWidth), "unknown")
else:
print("INFO: Compatible hardware:".ljust(self.kWidth), compatible_hardware_list[0])
for value in compatible_hardware_list[1:]:
print("INFO: ".ljust(self.kWidth), value)
ram = info["required_ram"]
print("INFO: Required RAM:".ljust(self.kWidth), ram and "%sK" % ram or "unknown")
print("INFO: Largest track:".ljust(self.kWidth), info["largest_track"], "blocks")
def print_tmap(self): def print_tmap(self):
i = 0 i = 0
@ -623,16 +780,23 @@ class WriterBaseCommand(BaseCommand):
def __call__(self, args): def __call__(self, args):
BaseCommand.__call__(self, args) BaseCommand.__call__(self, args)
self.args = args self.args = args
# maintain creator if there is one, otherwise use default self.output = WozWriter(self.woz_image.info.get("version", 2),
self.output = WozWriter(self.woz_image.info.get("creator", __displayname__)) self.woz_image.info.get("creator", __displayname__))
self.output.tmap = self.woz_image.tmap self.output.tmap = self.woz_image.tmap
self.output.tracks = self.woz_image.tracks self.output.tracks = self.woz_image.tracks
self.output.info = self.woz_image.info.copy() self.output.info = self.woz_image.info.copy()
self.output.writ = self.woz_image.writ
self.output.meta = self.woz_image.meta.copy() self.output.meta = self.woz_image.meta.copy()
self.update() self.update()
tmpfile = args.file + ".ardry" tmpfile = args.file + ".ardry"
with open(tmpfile, "wb") as f: with open(tmpfile, "wb") as f:
self.output.write(f) self.output.write(f)
try:
WozReader(tmpfile)
except Exception as e:
sys.stderr.write("WozInternalError: refusing to write an invalid .woz file (this is the developer's fault)\n")
os.remove(tmpfile)
raise Exception from e
os.rename(tmpfile, args.file) os.rename(tmpfile, args.file)
class CommandEdit(WriterBaseCommand): class CommandEdit(WriterBaseCommand):
@ -655,6 +819,7 @@ class CommandEdit(WriterBaseCommand):
help="""change information field. help="""change information field.
INFO format is "key:value". INFO format is "key:value".
Acceptable keys are disk_type, write_protected, synchronized, cleaned, creator, version. Acceptable keys are disk_type, write_protected, synchronized, cleaned, creator, version.
Additional keys for WOZ2 files are disk_sides, required_ram, boot_sector_format, compatible_hardware, optimal_bit_timing.
Other keys are ignored. Other keys are ignored.
For boolean fields, use "1" or "true" or "yes" for true, "0" or "false" or "no" for false.""") For boolean fields, use "1" or "true" or "yes" for true, "0" or "false" or "no" for false.""")
self.parser.add_argument("-m", "--meta", type=str, action="append", self.parser.add_argument("-m", "--meta", type=str, action="append",
@ -664,12 +829,49 @@ Standard keys are title, subtitle, publisher, developer, copyright, version, lan
requires_machine, notes, side, side_name, contributor, image_date. Other keys are allowed.""") requires_machine, notes, side, side_name, contributor, image_date. Other keys are allowed.""")
def update(self): def update(self):
# add all new info fields # 1st update version info field
for i in self.args.info or (): for i in self.args.info or ():
k, v = i.split(":", 1) k, v = i.split(":", 1)
if k in ("write_protected","synchronized","cleaned"): if k == "version":
v = v.lower() in ("1", "true", "yes") self.output.info["version"] = self.output.validate_info_version(v)
self.output.info[k] = v
# 2nd update disk_type info field
for i in self.args.info or ():
k, v = i.split(":", 1)
if k == "disk_type":
self.output.info["disk_type"] = self.output.validate_info_disk_type(v)
# then update all other info fields
for i in self.args.info or ():
k, v = i.split(":", 1)
if k == "version": continue
if k == "disk_type": continue
if k == "write_protected":
self.output.info[k] = self.output.validate_info_write_protected(v)
elif k == "synchronized":
self.output.info[k] = self.output.validate_info_synchronized(v)
elif k == "cleaned":
self.output.info[k] == self.output.validate_info_cleaned(v)
if self.output.info["version"] == 1: continue
# remaining fields are only recognized in WOZ2 files (v2+ INFO chunk)
if k == "disk_sides":
self.output.info[k] = self.output.validate_info_disk_sides(v)
elif k == "boot_sector_format":
self.output.info[k] = self.output.validate_info_boot_sector_format(v)
elif k == "optimal_bit_timing":
self.output.info[k] = self.output.validate_info_optimal_bit_timing(v)
elif k == "required_ram":
if v.lower().endswith("k"):
# forgive user for typing "128K" instead of "128"
v = v[:-1]
self.output.info[k] = self.output.validate_info_required_ram(v)
elif k == "compatible_hardware":
machines = v.split("|")
for machine in machines:
self.output.validate_metadata_requires_machine(machine)
self.output.info[k] = machines
# add all new metadata fields, and delete empty ones # add all new metadata fields, and delete empty ones
for m in self.args.meta or (): for m in self.args.meta or ():
k, v = m.split(":", 1) k, v = m.split(":", 1)