├── floppytools ├── __init__.py ├── base │ ├── __init__.py │ ├── rev_bits.py │ ├── cache_file.py │ ├── kryostream.py │ ├── fluxstream.py │ ├── chsset.py │ ├── media.py │ └── media_abc.py ├── formats │ ├── __init__.py │ ├── zilog_mcz.py │ ├── wang_wcs.py │ ├── intel_isis.py │ ├── hp98xx.py │ ├── dec_rx02.py │ ├── make_index.py │ ├── dg_nova.py │ ├── index.py │ ├── ohio_scientific.py │ ├── cbm64.py │ ├── apple.py │ ├── ibm.py │ └── q1_microlite.py ├── __main__.py └── main.py ├── LICENSE.md ├── README.md ├── setup.py ├── .gitignore └── Pict └── image_hack.py /floppytools/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /floppytools/base/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /floppytools/formats/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /floppytools/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | ''' 4 | Try all formats 5 | ~~~~~~~~~~~~~~~ 6 | ''' 7 | 8 | from . import main 9 | 10 | if __name__ == "__main__": 11 | main.Main() 12 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2023, Poul-Henning Kamp 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 16 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 17 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 19 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 20 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 21 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 22 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 23 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 24 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | 26 | -------------------------------------------------------------------------------- /floppytools/formats/zilog_mcz.py: -------------------------------------------------------------------------------- 1 | #/usr/bin/env python3 2 | 3 | ''' 4 | Zilog MCZ/1 8" floppies 5 | ~~~~~~~~~~~~~~~~~~~~~~~ 6 | 7 | ref: 03-3018-03_ZDS_1_40_Hardware_Reference_Manual_May79.pdf 8 | ''' 9 | 10 | import crcmod 11 | 12 | from ..base import media 13 | from ..base import fluxstream 14 | 15 | crc_func = crcmod.predefined.mkCrcFun('crc-16-buypass') 16 | 17 | class ZilogMCZ(media.Media): 18 | ''' ... ''' 19 | 20 | SECTOR_SIZE = 136 21 | GEOMETRY = ((0, 0, 0), (77, 0, 31), SECTOR_SIZE) 22 | 23 | GAP = fluxstream.fm_gap(32) 24 | 25 | def process_stream(self, stream): 26 | schs = (stream.chs[0], stream.chs[1], 0) 27 | if not self.defined_chs(schs): 28 | return None 29 | 30 | flux = stream.fm_flux() 31 | 32 | retval = False 33 | for data_pos in stream.iter_pattern(flux, pattern=self.GAP): 34 | data_pos -= 4 35 | 36 | data = stream.flux_data_fm(flux[data_pos:data_pos+((2+self.SECTOR_SIZE)*32)]) 37 | if data is None: 38 | continue 39 | 40 | data_crc = crc_func(data) 41 | if data_crc != 0: 42 | continue 43 | 44 | chs = (data[1], 0, data[0] & 0x7f) 45 | if not self.defined_chs(chs): 46 | continue 47 | 48 | self.did_read_sector(stream, data_pos, chs, data[:-2]) 49 | retval = True 50 | return retval 51 | 52 | ALL = [ 53 | ZilogMCZ, 54 | ] 55 | -------------------------------------------------------------------------------- /floppytools/base/rev_bits.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | ''' Reverse bits in a byte ''' 4 | 5 | REV_BITS = [ 6 | 0x00, 0x80, 0x40, 0xc0, 0x20, 0xa0, 0x60, 0xe0, 0x10, 0x90, 0x50, 0xd0, 0x30, 0xb0, 0x70, 0xf0, 7 | 0x08, 0x88, 0x48, 0xc8, 0x28, 0xa8, 0x68, 0xe8, 0x18, 0x98, 0x58, 0xd8, 0x38, 0xb8, 0x78, 0xf8, 8 | 0x04, 0x84, 0x44, 0xc4, 0x24, 0xa4, 0x64, 0xe4, 0x14, 0x94, 0x54, 0xd4, 0x34, 0xb4, 0x74, 0xf4, 9 | 0x0c, 0x8c, 0x4c, 0xcc, 0x2c, 0xac, 0x6c, 0xec, 0x1c, 0x9c, 0x5c, 0xdc, 0x3c, 0xbc, 0x7c, 0xfc, 10 | 0x02, 0x82, 0x42, 0xc2, 0x22, 0xa2, 0x62, 0xe2, 0x12, 0x92, 0x52, 0xd2, 0x32, 0xb2, 0x72, 0xf2, 11 | 0x0a, 0x8a, 0x4a, 0xca, 0x2a, 0xaa, 0x6a, 0xea, 0x1a, 0x9a, 0x5a, 0xda, 0x3a, 0xba, 0x7a, 0xfa, 12 | 0x06, 0x86, 0x46, 0xc6, 0x26, 0xa6, 0x66, 0xe6, 0x16, 0x96, 0x56, 0xd6, 0x36, 0xb6, 0x76, 0xf6, 13 | 0x0e, 0x8e, 0x4e, 0xce, 0x2e, 0xae, 0x6e, 0xee, 0x1e, 0x9e, 0x5e, 0xde, 0x3e, 0xbe, 0x7e, 0xfe, 14 | 0x01, 0x81, 0x41, 0xc1, 0x21, 0xa1, 0x61, 0xe1, 0x11, 0x91, 0x51, 0xd1, 0x31, 0xb1, 0x71, 0xf1, 15 | 0x09, 0x89, 0x49, 0xc9, 0x29, 0xa9, 0x69, 0xe9, 0x19, 0x99, 0x59, 0xd9, 0x39, 0xb9, 0x79, 0xf9, 16 | 0x05, 0x85, 0x45, 0xc5, 0x25, 0xa5, 0x65, 0xe5, 0x15, 0x95, 0x55, 0xd5, 0x35, 0xb5, 0x75, 0xf5, 17 | 0x0d, 0x8d, 0x4d, 0xcd, 0x2d, 0xad, 0x6d, 0xed, 0x1d, 0x9d, 0x5d, 0xdd, 0x3d, 0xbd, 0x7d, 0xfd, 18 | 0x03, 0x83, 0x43, 0xc3, 0x23, 0xa3, 0x63, 0xe3, 0x13, 0x93, 0x53, 0xd3, 0x33, 0xb3, 0x73, 0xf3, 19 | 0x0b, 0x8b, 0x4b, 0xcb, 0x2b, 0xab, 0x6b, 0xeb, 0x1b, 0x9b, 0x5b, 0xdb, 0x3b, 0xbb, 0x7b, 0xfb, 20 | 0x07, 0x87, 0x47, 0xc7, 0x27, 0xa7, 0x67, 0xe7, 0x17, 0x97, 0x57, 0xd7, 0x37, 0xb7, 0x77, 0xf7, 21 | 0x0f, 0x8f, 0x4f, 0xcf, 0x2f, 0xaf, 0x6f, 0xef, 0x1f, 0x9f, 0x5f, 0xdf, 0x3f, 0xbf, 0x7f, 0xff, 22 | ] 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FloppyTools 2 | 3 | Some python tools we use for preservation of rare floppy disks in 4 | Datamuseum.dk 5 | 6 | There are many other and good tools for reading floppy disks, 7 | FluxEngine, HXC and so on, so why another one ? 8 | 9 | We have some uncommon floppies in our collection, and more often 10 | than not, have to reverse-engineer the format ourselves, so 11 | FloppyTools is written in python and structured to make experiments 12 | and hacking easier. 13 | 14 | One particular focus is being able to manually salvage individual 15 | sectors, using whatever heuristics are deemed justified by the 16 | person behind the keyboard. 17 | 18 | We have made an example of this available in: 19 | 20 | https://github.com/Datamuseum-DK/FloppyToolsExamples 21 | 22 | FloppyTools will at all times be a work-in-progress, but I hope 23 | somebody will find it useful, and I would be very happy to see 24 | support for the weird formats migrate from FloppyTools to other 25 | packages, where they will run faster. 26 | 27 | Formats currently supported: 28 | 29 | * Apple ][ DOS 3.3 (GCR coding, non-use of index pulse, WOZ-code) 30 | 31 | * Commodore 64/1541/4040 (GCR coding, non-use of index pulse) 32 | 33 | * DEC RX02 (dec_rx02.py, uses special M²FM encoding) 34 | 35 | * Data General Nova (dg_nova.py, uses the worlds second worst CRC-16) 36 | 37 | * HP9885 (hp98xx.py, almost, but not quite like IBM format) 38 | 39 | * IBM (ibm.py, mostly to be able to fix individual sectors) 40 | 41 | * Intel ISIS-II (intel_isis.py, M²FM encoding) 42 | 43 | * Ohio Scientific OS65U 44 | 45 | * Q1 MicroLite (q1_microlite.py, per-track non 2^N sector lengths) 46 | 47 | * WANG WCS (wang_wcs.py, very old text-processing) 48 | 49 | * Zilog MCZ/1 (zilog_mcz.py, sectors form doubly-linked lists) 50 | 51 | /phk 52 | -------------------------------------------------------------------------------- /floppytools/base/cache_file.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | ''' 4 | Cache file input/output 5 | ~~~~~~~~~~~~~~~~~~~~~~~ 6 | ''' 7 | 8 | from . import media_abc 9 | 10 | class CacheFile(): 11 | ''' ... ''' 12 | 13 | def __init__(self, fname, mode): 14 | self.fname = fname 15 | self.mode = mode 16 | assert mode in ("r", "a") 17 | self.cache_file = open(fname, mode, encoding="utf8") 18 | 19 | def read(self): 20 | self.cache_file.seek(0) 21 | for line in self.cache_file: 22 | flds = line.split() 23 | if not flds or flds[0][0] == '#': 24 | continue 25 | 26 | if flds[0] == "file": 27 | yield "file", flds[1] 28 | continue 29 | 30 | assert flds[0] == "sector" 31 | yield "sector", media_abc.ReadSector( 32 | source=flds[1], 33 | rel_pos=int(flds[2]), 34 | phys_chs=tuple(int(x) for x in flds[3].split(",")), 35 | am_chs=tuple(int(x) for x in flds[4].split(",")), 36 | octets=bytes.fromhex(flds[5]), 37 | flags=flds[6:], 38 | ) 39 | 40 | def write_sector(self, read_sector): 41 | ''' ... ''' 42 | 43 | l = [ 44 | "sector", 45 | read_sector.source, 46 | str(read_sector.rel_pos), 47 | "%d,%d,%d" % read_sector.phys_chs, 48 | "%d,%d,%d" % read_sector.am_chs, 49 | read_sector.octets.hex(), 50 | ] + list(sorted(read_sector.flags)) 51 | self.cache_file.write(" ".join(l) + "\n") 52 | self.cache_file.flush() 53 | 54 | def write_file(self, filename): 55 | self.cache_file.write("file " + filename + "\n") 56 | self.cache_file.flush() 57 | -------------------------------------------------------------------------------- /floppytools/formats/wang_wcs.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | ''' 4 | WANG WCS 8" floppy disks 5 | ~~~~~~~~~~~~~~~~~~~~~~~~ 6 | 7 | 742-0652_928_Sys-10-20-30_Vol3_Theory_Of_Operation_19840817.pdf (page 73) 8 | 9 | ''' 10 | 11 | import crcmod 12 | 13 | from ..base import media 14 | 15 | crc_func = crcmod.predefined.mkCrcFun('crc-16-buypass') 16 | 17 | AM_MARK = '--|-' * 32 + '|-' * 3 18 | DATA_MARK = '--|-' * 24 + '|-' * 3 19 | 20 | class WangWcs(media.Media): 21 | 22 | ''' WANG WCS format 8" floppy disks ''' 23 | 24 | SECTOR_SIZE = 256 25 | GEOMETRY = ((0, 0, 0), (76, 0, 15), SECTOR_SIZE) 26 | 27 | def process_stream(self, stream): 28 | 29 | schs = (stream.chs[0], stream.chs[1], 1) 30 | if not self.defined_chs(schs): 31 | return None 32 | 33 | flux = stream.fm_flux() 34 | 35 | retval = False 36 | for am_pos in stream.iter_pattern(flux, pattern=AM_MARK): 37 | 38 | address_mark = stream.flux_data_fm(flux[am_pos:am_pos+6*32]) 39 | if address_mark is None: 40 | continue 41 | if max(address_mark[2:]): 42 | continue 43 | chs = (address_mark[0], 0, address_mark[1]) 44 | if not self.defined_chs(chs): 45 | continue 46 | 47 | data_pos = flux.find(DATA_MARK, am_pos + 500) 48 | if data_pos < 0 or am_pos + 800 < data_pos: 49 | continue 50 | data_pos += len(DATA_MARK) 51 | 52 | data = stream.flux_data_fm(flux[data_pos:data_pos+((2+self.SECTOR_SIZE)*32)]) 53 | if data is None: 54 | continue 55 | 56 | data_crc = crc_func(b'\x03' + data) 57 | if data_crc: 58 | continue 59 | 60 | self.did_read_sector(stream, am_pos, chs, data[:self.SECTOR_SIZE]) 61 | retval = True 62 | return retval 63 | 64 | ALL = [ 65 | WangWcs, 66 | ] 67 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright (c) 2021 Poul-Henning Kamp 4 | # All rights reserved. 5 | # 6 | # SPDX-License-Identifier: BSD-2-Clause 7 | # 8 | # Redistribution and use in source and binary forms, with or without 9 | # modification, are permitted provided that the following conditions 10 | # are met: 11 | # 1. Redistributions of source code must retain the above copyright 12 | # notice, this list of conditions and the following disclaimer. 13 | # 2. Redistributions in binary form must reproduce the above copyright 14 | # notice, this list of conditions and the following disclaimer in the 15 | # documentation and/or other materials provided with the distribution. 16 | # 17 | # THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND 18 | # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 19 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 20 | # ARE DISCLAIMED. IN NO EVENT SHALL AUTHOR OR CONTRIBUTORS BE LIABLE 21 | # FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 22 | # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS 23 | # OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 24 | # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 25 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY 26 | # OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF 27 | # SUCH DAMAGE. 28 | 29 | ''' 30 | Datamuseum.dk Floppy decoding tools 31 | ''' 32 | 33 | from setuptools import setup, find_packages 34 | 35 | def readme(): 36 | ''' emit README ''' 37 | with open('README.md') as file: 38 | return file.read() 39 | 40 | setup( 41 | name='FloppyTools', 42 | version='1.0', 43 | description='Datamuseum.dk Floppy decoding tools', 44 | long_description=readme(), 45 | classifiers=[], 46 | keywords='datamuseum.dk floppy diskette', 47 | author='Poul-Henning Kamp', 48 | author_email='phk@FreeBSD.org', 49 | license='BSD', 50 | packages=find_packages(), 51 | zip_safe=False 52 | ) 53 | -------------------------------------------------------------------------------- /floppytools/formats/intel_isis.py: -------------------------------------------------------------------------------- 1 | 2 | ''' 3 | Decode Intel ISIS double density 8" floppies 4 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 5 | ''' 6 | 7 | import crcmod 8 | 9 | from ..base import media 10 | from ..base import fluxstream as fs 11 | 12 | crc_func = crcmod.predefined.mkCrcFun('xmodem') 13 | 14 | class IntelIsis(media.Media): 15 | 16 | ''' Intel ISIS format 8" floppy disks ''' 17 | 18 | SECTOR_SIZE = 128 19 | GEOMETRY = ((0,0,1), (76, 0, 52), SECTOR_SIZE) 20 | 21 | def process_stream(self, stream): 22 | ''' ... ''' 23 | 24 | if stream.chs[1] != 0: 25 | return None 26 | 27 | am_pattern = '|-' * 16 + fs.make_mark(0x87, 0x70) 28 | data_pattern = '|-' * 16 + fs.make_mark(0x85, 0x70) 29 | 30 | flux = stream.m2fm_flux() 31 | 32 | retval = False 33 | for am_pos in stream.iter_pattern(flux, pattern=am_pattern): 34 | 35 | am_flux = flux[am_pos-16:am_pos+(7*16)] 36 | address_mark = stream.flux_data_mfm(am_flux[1:]) 37 | if address_mark is None: 38 | continue 39 | 40 | am_crc = crc_func(address_mark) 41 | if am_crc: 42 | continue 43 | 44 | chs = (address_mark[1], address_mark[2], address_mark[3]) 45 | ms = self.sectors.get(chs) 46 | if ms is None: 47 | continue 48 | 49 | data_pos = flux.find(data_pattern, am_pos + 200) 50 | if data_pos < 0: 51 | continue 52 | if data_pos > am_pos + 1000: 53 | continue 54 | 55 | data_pos += len(data_pattern) 56 | data_pos -= 16 57 | 58 | #print(flux[data_pos-100:data_pos+16]) 59 | data_flux = flux[data_pos:data_pos+(132*16)] 60 | data = stream.flux_data_mfm(data_flux[1:]) 61 | if data is None: 62 | continue 63 | 64 | data_crc = crc_func(data[:131]) 65 | if data_crc: 66 | continue 67 | 68 | self.did_read_sector(stream, am_pos, chs, data[1:self.SECTOR_SIZE+1]) 69 | retval = True 70 | return retval 71 | 72 | ALL = [ 73 | IntelIsis, 74 | ] 75 | -------------------------------------------------------------------------------- /floppytools/formats/hp98xx.py: -------------------------------------------------------------------------------- 1 | #/usr/bin/env python3 2 | 3 | ''' 4 | HP98xx M2MFM mode 5 | ~~~~~~~~~~~~~~~~~ 6 | ''' 7 | 8 | import crcmod 9 | 10 | from ..base import media 11 | from ..base import rev_bits 12 | 13 | crc_func = crcmod.predefined.mkCrcFun('crc-16-buypass') 14 | 15 | # d d d d d d d d 16 | # c c c c c c c c 17 | AM = '--|-' * 10 + '-|' * 32 + '--|-|-|--|-|-|--' 18 | DM = '--|-' * 10 + '-|' * 32 + '--|-|-|--|---|--' 19 | 20 | crc_func = crcmod.predefined.mkCrcFun('crc-ccitt-false') 21 | 22 | class HP9885(media.Media): 23 | 24 | ''' HP9885 8" floppies for MX21 ''' 25 | 26 | SECTOR_SIZE = 256 27 | GEOMETRY = ((0, 0, 0), (66, 0, 29), SECTOR_SIZE) 28 | 29 | def process_stream(self, stream): 30 | schs = (stream.chs[0], stream.chs[1], 0) 31 | if not self.defined_chs(schs): 32 | return None 33 | 34 | flux = stream.m2fm_flux() 35 | prev = 0 36 | retval = False 37 | for am_pos in stream.iter_pattern(flux, pattern=AM): 38 | amf = flux[am_pos:am_pos + 80] 39 | am = stream.flux_data_mfm(amf) 40 | amc = crc_func(am) 41 | am = bytes(rev_bits.REV_BITS[x] for x in am) 42 | if amc: 43 | print("AMC", am.hex()) 44 | continue 45 | data_pos = flux.find(DM, am_pos + 200, am_pos + 500) 46 | if data_pos < 0: 47 | print( 48 | "%7d" % (am_pos - prev), 49 | "%5d" % data_pos, 50 | am.hex(), 51 | "%04x" % amc, 52 | flux[am_pos-32:am_pos + 700], 53 | ) 54 | else: 55 | o = len(DM) - 0 56 | dataf = flux[data_pos + o:data_pos + o + (256 + 2) * 16] 57 | data = stream.flux_data_mfm(dataf) 58 | datac = crc_func(data) 59 | data = bytes(rev_bits.REV_BITS[x] for x in data) 60 | if amc: 61 | print("DATAC", "%04x" % datac) 62 | continue 63 | txt = [] 64 | for i in data: 65 | if 32 <= i <= 126: 66 | txt.append("%c" % i) 67 | else: 68 | txt.append("…") 69 | t2 = [] 70 | for i in range(0, len(txt), 2): 71 | t2.append(txt[i+1]) 72 | t2.append(txt[i]) 73 | txt = ''.join(t2) 74 | if False: 75 | print( 76 | #"%7d" % (am_pos - prev), 77 | #"%5d" % (data_pos - am_pos), 78 | am.hex(), 79 | "%04x" % amc, 80 | dataf[:10], 81 | "%04x" % datac, 82 | "%04x" % len(data), 83 | txt 84 | ) 85 | 86 | self.did_read_sector(stream, am_pos, (am[0], 0, am[1]), data[:self.SECTOR_SIZE]) 87 | retval = True 88 | prev = am_pos 89 | return retval 90 | 91 | ALL = [ 92 | HP9885, 93 | ] 94 | -------------------------------------------------------------------------------- /floppytools/formats/dec_rx02.py: -------------------------------------------------------------------------------- 1 | #/usr/bin/env python3 2 | 3 | ''' 4 | DEC RX01/RX02 formats 5 | ~~~~~~~~~~~~~~~~~~~~~ 6 | ''' 7 | 8 | import crcmod 9 | 10 | from ..base import media 11 | from ..base import fluxstream as fs 12 | 13 | crc_func = crcmod.predefined.mkCrcFun('crc-ccitt-false') 14 | 15 | class DecRx02(media.Media): 16 | 17 | ''' IBM format 8" floppy disks ''' 18 | 19 | SECTOR_SIZE = 256 20 | GEOMETRY = ((0, 0, 1), (76, 0, 26), SECTOR_SIZE) 21 | 22 | ADDRESS_MARK = (0xc7, 0xfe) 23 | HDDATA_MARK = (0xc7, 0xfd) 24 | GAP1 = 32 25 | DATA_WIN_LO = 550 26 | DATA_WIN_HI = 800 27 | 28 | def validate_address_mark(self, address_mark): 29 | ''' ... ''' 30 | 31 | return self.validate_chs(address_mark[1:4]) 32 | 33 | def process_stream(self, stream): 34 | ''' ... ''' 35 | 36 | schs = (stream.chs[0], stream.chs[1], 1) 37 | if not self.defined_chs(schs): 38 | return None 39 | 40 | am_pattern = '|---' * self.GAP1 + fs.make_mark_fm(*self.ADDRESS_MARK) 41 | hddata_pattern = '|---' * self.GAP1 + fs.make_mark_fm(*self.HDDATA_MARK) 42 | 43 | flux = stream.mfm_flux() 44 | 45 | retval = False 46 | for am_pos in stream.iter_pattern(flux, pattern=am_pattern): 47 | 48 | address_mark = stream.flux_data_fm(flux[am_pos-32:am_pos+(6*32)]) 49 | #print("AM", address_mark.hex(), flux[am_pos-32:am_pos+(6*32)]) 50 | if address_mark is None: 51 | continue 52 | 53 | am_crc = crc_func(address_mark) 54 | if am_crc: 55 | continue 56 | 57 | chs = (address_mark[1], address_mark[2], address_mark[3]) 58 | if not self.defined_chs(chs): 59 | continue 60 | 61 | data_pos = flux.find( 62 | hddata_pattern, 63 | am_pos + self.DATA_WIN_LO, 64 | am_pos + self.DATA_WIN_HI 65 | ) 66 | if data_pos < 0: 67 | continue 68 | data_pos += len(hddata_pattern) 69 | 70 | data_flux = flux[data_pos:data_pos+(2 + self.SECTOR_SIZE) * 16 + 32] 71 | if ' ' in data_flux: 72 | continue 73 | 74 | data = bytes([0xfd]) + self.flux_to_bytes(data_flux[1:]) 75 | 76 | data_crc = crc_func(data) 77 | self.did_read_sector(stream, am_pos, chs, data[1:self.SECTOR_SIZE+1]) 78 | retval = True 79 | return retval 80 | 81 | def flux_to_bytes(self, flux): 82 | ''' RX02 uses a modified MFM encoding ''' 83 | l = [] 84 | i = 0 85 | fflux = flux + '||||||||||||||||' 86 | while i < 2*(2+self.SECTOR_SIZE)*8: 87 | if fflux[i] == '|': 88 | j = '1' 89 | elif fflux[i:i+10] == '-|---|---|': 90 | j = '01111' 91 | else: 92 | j = '0' 93 | l.append(j) 94 | i += len(j) * 2 95 | l = "".join(l) 96 | j = [] 97 | for i in range(0, len(l), 8): 98 | j.append(int(l[i:i+8], 2)) 99 | data = bytes(j) 100 | return data 101 | 102 | ALL = [ 103 | DecRx02, 104 | ] 105 | -------------------------------------------------------------------------------- /floppytools/formats/make_index.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | ''' 4 | I wish I could come up with a better design pattern here, but 5 | until that happens, this will have to do: 6 | 7 | I really dont want to import all the formats, just to ask them 8 | all which one I should use, so I want a name->format lookup 9 | facility, and the names should live in the implementation of 10 | the format, not in some central table. 11 | 12 | This is the program which builds that lookup facility. 13 | ''' 14 | 15 | 16 | import glob 17 | import importlib 18 | 19 | #print("#P", __package__) 20 | 21 | 22 | def main(): 23 | 24 | inventory = {} 25 | mods = {} 26 | 27 | def add_format(name, module, index): 28 | if name not in inventory: 29 | inventory[name] = [] 30 | inventory[name].append((module, index)) 31 | 32 | for fn in sorted(glob.glob("*.py")): 33 | if fn in ( 34 | "__init__.py", 35 | "index.py", 36 | "make_index.py", 37 | "_.py", 38 | ): 39 | continue 40 | bn = fn[:-3] 41 | m = importlib.import_module("." + bn, "floppytools.formats") 42 | #print("#M", m) 43 | for idx, fmt in enumerate(m.ALL): 44 | i = fmt("/tmp", load_cache=False, save_cache=False) 45 | mods[(bn, idx)] = (i.name, i.aliases, i.__doc__) 46 | add_format("all", bn, idx) 47 | add_format(i.name, bn, idx) 48 | for j in i.aliases: 49 | add_format(j, bn, idx) 50 | 51 | with open("index.py", "w") as file: 52 | file.write('#!/usr/bin/env python3\n') 53 | file.write('\n') 54 | file.write("''' MACHINE GENERATED FILE, see make_index.py'''\n") 55 | file.write('\n') 56 | 57 | if True: 58 | file.write('documentation = {\n') 59 | for i, j in sorted(inventory.items()): 60 | mod = mods[j[0]] 61 | if i != mod[0]: 62 | continue 63 | file.write(' "%s": [\n' % i) 64 | for x, y in j: 65 | mod = mods[(x, y)] 66 | file.write(' %s,\n' % str([mod[2].strip()])) 67 | file.write(' ],\n') 68 | file.write('}\n') 69 | file.write('\n') 70 | if True: 71 | file.write('aliases = {\n') 72 | for i, j in sorted(inventory.items()): 73 | mod = mods[j[0]] 74 | if i == mod[0] or i == "all": 75 | continue 76 | file.write(' "%s": [\n' % i) 77 | for x, y in j: 78 | mod = mods[(x, y)] 79 | file.write(' "%s",\n' % mod[0]) 80 | file.write(' ],\n') 81 | file.write('}\n') 82 | file.write('\n') 83 | 84 | file.write('def find_formats(target):\n') 85 | pfx = "" 86 | for i, j in sorted(inventory.items()): 87 | file.write(' %sif target == "%s":\n' % (pfx, i)) 88 | pfx = "el" 89 | seen = set() 90 | for modname, idx in j: 91 | if modname not in seen: 92 | file.write(' from . import %s\n' % modname) 93 | seen.add(modname) 94 | clsname = mods[(modname, idx)][0] 95 | file.write(' yield ("%s", %s.ALL[%d])\n' % (clsname, modname, idx)) 96 | 97 | 98 | if __name__ == "__main__": 99 | main() 100 | -------------------------------------------------------------------------------- /floppytools/formats/dg_nova.py: -------------------------------------------------------------------------------- 1 | #/usr/bin/env python3 2 | 3 | ''' 4 | Data General Nova 8" floppy disks 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | ''' 7 | 8 | from ..base import media 9 | 10 | class DataGeneralNova(media.Media): 11 | 12 | ''' Data General Nova 8" floppy disks ''' 13 | 14 | SECTOR_SIZE = 512 15 | GEOMETRY = ((0, 0, 0), (76, 0, 7), SECTOR_SIZE) 16 | 17 | GAP1 = '|---' * 16 + '|-|-' 18 | GAP2 = '|---' * 2 + '|-|-' 19 | 20 | def process_stream(self, stream): 21 | schs = (stream.chs[0], stream.chs[1], 0) 22 | if not self.defined_chs(schs): 23 | return None 24 | 25 | flux = stream.fm_flux() 26 | 27 | retval = False 28 | for am_pos in stream.iter_pattern(flux, pattern=self.GAP1): 29 | address_mark = stream.flux_data_fm(flux[am_pos:am_pos+2*32]) 30 | if address_mark is None: 31 | continue 32 | 33 | chs = (address_mark[0], 0, address_mark[1]>>2) 34 | if not self.defined_chs(chs): 35 | continue 36 | 37 | data_pos = flux.find(self.GAP2, am_pos + 5*32) 38 | if data_pos < 0 or data_pos - am_pos > 10*32: 39 | continue 40 | data_pos += len(self.GAP2) 41 | 42 | data = stream.flux_data_fm(flux[data_pos:data_pos+((2+self.SECTOR_SIZE)*32)]) 43 | if data is None or len(data) < self.SECTOR_SIZE+2: 44 | continue 45 | 46 | data_crc = self.bogo_crc(data[:self.SECTOR_SIZE]) 47 | disc_crc = (data[self.SECTOR_SIZE]<<8) | data[self.SECTOR_SIZE + 1] 48 | if data_crc != disc_crc: 49 | continue 50 | 51 | self.did_read_sector(stream, am_pos, chs, data[:self.SECTOR_SIZE]) 52 | retval = True 53 | 54 | return retval 55 | 56 | def bogo_crc(self, data): 57 | ''' 58 | The worlds second worst CRC-16 algorithm 59 | ======================================== 60 | 61 | Meet the worlds second-worst CRC-16 error detection function: 62 | 63 | x16 + x8 +1 aka 0x8080 aka 0x10101 64 | 65 | See page 1, top left corner of: 66 | http://bitsavers.org/pdf/dg/disc/4046_4047_4049/4046_4047_schematic.pdf 67 | 68 | This CRC16 has staggeringly bad performance according to Prof. Koopman: 69 | 0x8080  HD=3  len=8  Example: Len=9 {0} (0x8000) (Bits=2) 70 | 0x8080  HD=4  NONE  Example: Len=1 {0} (0x8080) (Bits=3) 71 | 72 | For comparison the standarized CCITT CRC16 has: 73 | 0x8810 HD=3 len=32751 Example: Len=32752 {0} (0x8000) (Bits=2) 74 | 0x8810 HD=4 len=32751 Example: Len=32752 {0} (0x8000) (Bits=2) 75 | 0x8810 HD=5 NONE Example: Len=1 {0} (0x8810) (Bits=4) 76 | 77 | But it is even worse than that, bceause it does not even detect 78 | all two-bit errors, both of these inputs gets the result 0x0100: 79 | 80 | 0x01 0x00 0x00 0x00 81 | 0x00 0x00 0x00 0x01 82 | 83 | Because the tap is between the two bytes of the CRC, the function 84 | reduces to the following: 85 | ''' 86 | 87 | crc = 0 88 | for n, b in enumerate(data): 89 | if (n % 3) == 0: 90 | crc ^= b 91 | crc ^= b << 8 92 | elif (n % 3) == 1: 93 | crc ^= b 94 | else: 95 | crc ^= b << 8 96 | 97 | return crc 98 | 99 | ALL = [ 100 | DataGeneralNova, 101 | ] 102 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | -------------------------------------------------------------------------------- /floppytools/formats/index.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | ''' MACHINE GENERATED FILE, see make_index.py''' 4 | 5 | documentation = { 6 | "AppleII": [ 7 | ['Apple ][ floppy disks'], 8 | ], 9 | "CBM64": [ 10 | ['Commodore 4040/1541 floppy disks'], 11 | ], 12 | "DataGeneralNova": [ 13 | ['Data General Nova 8" floppy disks'], 14 | ], 15 | "DecRx02": [ 16 | ['IBM format 8" floppy disks'], 17 | ], 18 | "HP9885": [ 19 | ['HP9885 8" floppies for MX21'], 20 | ], 21 | "Ibm": [ 22 | ['IBM format floppy disks'], 23 | ], 24 | "IntelIsis": [ 25 | ['Intel ISIS format 8" floppy disks'], 26 | ], 27 | "OhioScientificU": [ 28 | ['Ohio Scientific OS65U format'], 29 | ], 30 | "Q1MicroLiteFM": [ 31 | ['Q1 Corporation MicroLite FM format floppy disks\n\n\tBla\n\n\tFOo'], 32 | ], 33 | "Q1MicroLiteMFM28": [ 34 | ['Q1 Corporation MicroLite MFM format floppy disks'], 35 | ], 36 | "Q1MicroLiteMFM39": [ 37 | ['Q1 Corporation MicroLite MFM format floppy disks'], 38 | ], 39 | "WangWcs": [ 40 | ['WANG WCS format 8" floppy disks'], 41 | ], 42 | "ZilogMCZ": [ 43 | ['...'], 44 | ], 45 | } 46 | 47 | aliases = { 48 | "IBM": [ 49 | "Ibm", 50 | ], 51 | "Q1": [ 52 | "Q1MicroLiteMFM28", 53 | "Q1MicroLiteMFM39", 54 | "Q1MicroLiteFM", 55 | ], 56 | } 57 | 58 | def find_formats(target): 59 | if target == "AppleII": 60 | from . import apple 61 | yield ("AppleII", apple.ALL[0]) 62 | elif target == "CBM64": 63 | from . import cbm64 64 | yield ("CBM64", cbm64.ALL[0]) 65 | elif target == "DataGeneralNova": 66 | from . import dg_nova 67 | yield ("DataGeneralNova", dg_nova.ALL[0]) 68 | elif target == "DecRx02": 69 | from . import dec_rx02 70 | yield ("DecRx02", dec_rx02.ALL[0]) 71 | elif target == "HP9885": 72 | from . import hp98xx 73 | yield ("HP9885", hp98xx.ALL[0]) 74 | elif target == "IBM": 75 | from . import ibm 76 | yield ("Ibm", ibm.ALL[0]) 77 | elif target == "Ibm": 78 | from . import ibm 79 | yield ("Ibm", ibm.ALL[0]) 80 | elif target == "IntelIsis": 81 | from . import intel_isis 82 | yield ("IntelIsis", intel_isis.ALL[0]) 83 | elif target == "OhioScientificU": 84 | from . import ohio_scientific 85 | yield ("OhioScientificU", ohio_scientific.ALL[0]) 86 | elif target == "Q1": 87 | from . import q1_microlite 88 | yield ("Q1MicroLiteMFM28", q1_microlite.ALL[0]) 89 | yield ("Q1MicroLiteMFM39", q1_microlite.ALL[1]) 90 | yield ("Q1MicroLiteFM", q1_microlite.ALL[2]) 91 | elif target == "Q1MicroLiteFM": 92 | from . import q1_microlite 93 | yield ("Q1MicroLiteFM", q1_microlite.ALL[2]) 94 | elif target == "Q1MicroLiteMFM28": 95 | from . import q1_microlite 96 | yield ("Q1MicroLiteMFM28", q1_microlite.ALL[0]) 97 | elif target == "Q1MicroLiteMFM39": 98 | from . import q1_microlite 99 | yield ("Q1MicroLiteMFM39", q1_microlite.ALL[1]) 100 | elif target == "WangWcs": 101 | from . import wang_wcs 102 | yield ("WangWcs", wang_wcs.ALL[0]) 103 | elif target == "ZilogMCZ": 104 | from . import zilog_mcz 105 | yield ("ZilogMCZ", zilog_mcz.ALL[0]) 106 | elif target == "all": 107 | from . import apple 108 | yield ("AppleII", apple.ALL[0]) 109 | from . import cbm64 110 | yield ("CBM64", cbm64.ALL[0]) 111 | from . import dec_rx02 112 | yield ("DecRx02", dec_rx02.ALL[0]) 113 | from . import dg_nova 114 | yield ("DataGeneralNova", dg_nova.ALL[0]) 115 | from . import hp98xx 116 | yield ("HP9885", hp98xx.ALL[0]) 117 | from . import ibm 118 | yield ("Ibm", ibm.ALL[0]) 119 | from . import intel_isis 120 | yield ("IntelIsis", intel_isis.ALL[0]) 121 | from . import ohio_scientific 122 | yield ("OhioScientificU", ohio_scientific.ALL[0]) 123 | from . import q1_microlite 124 | yield ("Q1MicroLiteMFM28", q1_microlite.ALL[0]) 125 | yield ("Q1MicroLiteMFM39", q1_microlite.ALL[1]) 126 | yield ("Q1MicroLiteFM", q1_microlite.ALL[2]) 127 | from . import wang_wcs 128 | yield ("WangWcs", wang_wcs.ALL[0]) 129 | from . import zilog_mcz 130 | yield ("ZilogMCZ", zilog_mcz.ALL[0]) 131 | -------------------------------------------------------------------------------- /floppytools/formats/ohio_scientific.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | ''' 4 | Ohio Scientific 'OS65U' format 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | ''' 7 | 8 | from ..base import media 9 | 10 | class RxErr(Exception): 11 | ''' ... ''' 12 | 13 | class OhioScientificU(media.Media): 14 | 15 | ''' Ohio Scientific OS65U format ''' 16 | 17 | SECTOR_SIZE = 0xf << 8 18 | GEOMETRY = ((0, 0, 0), (76, 0, 0), SECTOR_SIZE) 19 | 20 | def process_stream(self, stream): 21 | self.retval = False 22 | 23 | flux = stream.fm_flux() 24 | 25 | def fm(): 26 | ''' One FM coded bit ''' 27 | n = 0 28 | while n < len(flux): 29 | cell = flux[n:n+4] 30 | if cell == '|---': 31 | yield 1 32 | n += 4 33 | elif cell == '|-|-': 34 | yield 0 35 | n += 4 36 | else: 37 | n += 1 38 | 39 | fmi = fm() 40 | 41 | def element(nbit): 42 | ''' One async element (byte+parity+stopbit) ''' 43 | g = 0 44 | while next(fmi): 45 | g += 1 46 | continue 47 | c = 0 48 | for i in range(nbit): 49 | c += next(fmi) << i 50 | return g, c 51 | 52 | def rx8e(): 53 | ''' 8 bits with even parity ''' 54 | gap, bits = element(10) 55 | if bits == 0: 56 | raise RxErr("Break") 57 | if not bits & 0x200: 58 | raise RxErr("Stop-Bit") 59 | par = bin(bits).count('1') 60 | if not par & 1: 61 | raise RxErr("Parity") 62 | return gap, bits & 0xff 63 | 64 | def rx8n(): 65 | ''' 8 bits with no parity ''' 66 | gap, bits = element(9) 67 | if bits == 0: 68 | raise RxErr("Break") 69 | if not bits & 0x100: 70 | raise RxErr("Stop-Bit") 71 | return gap, bits & 0xff 72 | 73 | def got(l): 74 | ''' we think we got a sector (=track) ''' 75 | if len(l) > 3000: 76 | b = bytes(l) 77 | if stream.chs[0] == 0: 78 | w = b[2] << 8 79 | if len(b) >= w: 80 | self.did_read_sector( 81 | stream, 82 | 0, 83 | (0, 0, 0), 84 | b[:w] + bytes(0 for i in range(0xf00 - w)), 85 | ) 86 | self.retval = True 87 | check = None 88 | elif len(b) >= 3590: 89 | cs = sum(b[:3588]) & 0xffff 90 | rs = (b[3588] << 8) | b[3589] 91 | check = cs == rs 92 | if check: 93 | self.did_read_sector( 94 | stream, 95 | 1, 96 | (b[2], 0, 0), 97 | b[:3590] + bytes(0 for i in range(0xf00 - 3590)), 98 | ) 99 | self.retval = True 100 | elif check is False: 101 | check = hex(cs) 102 | else: 103 | check = None 104 | self.trace("GOT", len(l), check, stream.chs, b[:3].hex(), hex(b[4]), b[3588:].hex()) 105 | return [] 106 | 107 | l = [] 108 | while True: 109 | try: 110 | if stream.chs[0] == 0 or len(l) < 3: 111 | gap, val = rx8e() 112 | else: 113 | gap, val = rx8n() 114 | if stream.chs[0] > 0 and len(l) == 3 and gap: 115 | if len(l) == 3 and gap < 30 and val >= 0xf0: 116 | # Ignore transient from UART being switched from 8E to 8N 117 | self.trace("gap", gap, hex(val), len(l)) 118 | continue 119 | self.trace("GAP", gap, hex(val), len(l)) 120 | except RxErr: 121 | l = got(l) 122 | continue 123 | except StopIteration: 124 | l = got(l) 125 | break 126 | if gap > 400: 127 | l = got(l) 128 | l.append(val) 129 | 130 | return self.retval 131 | 132 | ALL = [ 133 | OhioScientificU, 134 | ] 135 | -------------------------------------------------------------------------------- /floppytools/base/kryostream.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | ''' 4 | Take a KryoFlux stream file apart 5 | --------------------------------- 6 | ''' 7 | 8 | import struct 9 | import math 10 | 11 | import crcmod 12 | 13 | from . import fluxstream 14 | 15 | crc16_func = crcmod.predefined.mkCrcFun('crc-16-buypass') 16 | 17 | #sck=24027428.5714285 18 | #ick=3003428.5714285625 19 | 20 | class NotAKryofluxStream(Exception): 21 | ''' ... ''' 22 | 23 | class KryoStream(fluxstream.FluxStream): 24 | ''' A Kryoflux Stream file ''' 25 | def __init__(self, filename): 26 | super().__init__() 27 | i = filename.split('.') 28 | if i[-1] != 'raw': 29 | raise NotAKryofluxStream(filename + " Does not end in ….raw") 30 | if not i[-2].isdigit(): 31 | raise NotAKryofluxStream(filename + " Does not end in ….%d.raw") 32 | if not i[-3][-2:].isdigit(): 33 | raise NotAKryofluxStream(filename + " Does not end in …bin%d.%d.raw") 34 | #if i[-3][:3] != "bin": 35 | # raise NotAKryofluxStream(filename + " Does not end in …bin%d.%d.raw") 36 | self.chs = (int(i[-3][-2:]), int(i[-2]), 0) 37 | 38 | self.filename = filename 39 | self.flux = {} 40 | self.strm = {} 41 | self.index = [] 42 | self.oob = [] 43 | self.stream_end = None 44 | self.result_code = None 45 | self.sck = None 46 | self.ick = None 47 | self.kfattrs = [] 48 | 49 | 50 | def __str__(self): 51 | return "" 52 | 53 | def __lt__(self, other): 54 | return self.filename < other.filename 55 | 56 | def serialize(self): 57 | return self.filename 58 | 59 | def iter_dt(self): 60 | if not self.flux: 61 | self.deframe() 62 | last = 0 63 | for i in self.strm.values(): 64 | dt = i - last 65 | self.histo[min(dt//self.histo_scale, len(self.histo)-1)] += 1 66 | yield dt 67 | last = i 68 | 69 | def do_index(self): 70 | for idx in self.index: 71 | samp = self.strm.get(idx[3]) 72 | yield samp - idx[4] 73 | 74 | def handle_oob(self, _strm, oob): 75 | if oob[1] == 2: 76 | i = struct.unpack(" peak: 185 | peak = self.histo[probe] 186 | dt = probe * self.histo_scale 187 | return dt 188 | 189 | class RawStream(FluxStream): 190 | ''' Raw stream file ''' 191 | 192 | # As found in https://github.com/MattisLind/q1decode/tree/main/Q1DISKS 193 | # (CatWeasel ?) 194 | 195 | def __init__(self, filename): 196 | self.chs = (None, None, None) 197 | 198 | self.filename = filename 199 | self.histo = [0] * 80 200 | 201 | def __str__(self): 202 | return "" 203 | 204 | def __lt__(self, other): 205 | return self.filename < other.filename 206 | 207 | def iter_dt(self): 208 | for i in open(self.filename, "rb").read(): 209 | i &= 0x7f 210 | dt = int(i * 2.5) 211 | self.histo[min(dt//3, 79)] += 1 212 | yield dt 213 | -------------------------------------------------------------------------------- /floppytools/formats/cbm64.py: -------------------------------------------------------------------------------- 1 | #/usr/bin/env python3 2 | 3 | ''' 4 | Commodore 4040/1541 floppy disks 5 | ================================ 6 | ''' 7 | 8 | from ..base import media 9 | 10 | GCR = { 11 | 0x0a: 0x0, 12 | 0x0b: 0x1, 13 | 0x12: 0x2, 14 | 0x13: 0x3, 15 | 0x0e: 0x4, 16 | 0x0f: 0x5, 17 | 0x16: 0x6, 18 | 0x17: 0x7, 19 | 0x09: 0x8, 20 | 0x19: 0x9, 21 | 0x1a: 0xa, 22 | 0x1b: 0xb, 23 | 0x0d: 0xc, 24 | 0x1d: 0xd, 25 | 0x1e: 0xe, 26 | 0x15: 0xf, 27 | } 28 | 29 | class CBM64(media.Media): 30 | ''' Commodore 4040/1541 floppy disks ''' 31 | 32 | def __init__(self, *args, **kwargs): 33 | super().__init__(*args, **kwargs) 34 | self.define_geometry((1, 0, 0), (17, 0, 20), 256) 35 | self.define_geometry((18, 0, 0), (24, 0, 18), 256) 36 | self.define_geometry((25, 0, 0), (30, 0, 17), 256) 37 | self.define_geometry((31, 0, 0), (35, 0, 16), 256) 38 | self.x = 0 39 | self.dx = 0 40 | self.clock = 64 41 | 42 | def process_stream(self, stream): 43 | 44 | retval = False 45 | 46 | if stream.chs[1] & 1: 47 | # I wonder if we can decode the B side of "flip" disks backwards on the odd tracks ? 48 | return retval 49 | 50 | self.x = 0 51 | self.dx = 0 52 | self.clock = 64 53 | 54 | def bit_slicer(): 55 | ''' Estimate clock frequency and decode bits ''' 56 | 57 | for dt in stream.iter_dt(): 58 | self.x += dt 59 | if dt < 96: 60 | self.clock += (dt - self.clock) / 200 61 | w = dt / self.clock 62 | if w < 1.5: 63 | yield 1 64 | elif w < 2.5: 65 | yield 0 66 | yield 1 67 | else: 68 | yield 0 69 | yield 0 70 | yield 1 71 | 72 | def nibble_slicer(): 73 | ''' Locate sync and decode GCR nibbles ''' 74 | 75 | bsl_iter = bit_slicer() 76 | while True: 77 | acc = [0] * 20 78 | while True: 79 | try: 80 | b = next(bsl_iter) 81 | except StopIteration: 82 | yield -1 83 | return 84 | w = acc[-5] << 4 85 | w += acc[-4] << 3 86 | w += acc[-3] << 2 87 | w += acc[-2] << 1 88 | w += acc[-1] << 0 89 | if 0 not in acc and not b: 90 | self.dx = self.x 91 | break 92 | acc.append(b) 93 | acc.pop(0) 94 | while True: 95 | h = b << 4 96 | try: 97 | h += next(bsl_iter) << 3 98 | h += next(bsl_iter) << 2 99 | h += next(bsl_iter) << 1 100 | h += next(bsl_iter) << 0 101 | except StopIteration: 102 | yield -1 103 | return 104 | hh = GCR.get(h, -1) 105 | yield hh 106 | if hh < 0: 107 | break 108 | try: 109 | b = next(bsl_iter) 110 | except StopIteration: 111 | yield -1 112 | return 113 | 114 | def data_slicer(): 115 | ''' Decode octets and check checksums ''' 116 | 117 | nsl_iter = nibble_slicer() 118 | run = True 119 | while run: 120 | csum = 0 121 | octets = [] 122 | fail = False 123 | while True: 124 | try: 125 | h = next(nsl_iter) 126 | if h < 0: 127 | break 128 | l = next(nsl_iter) 129 | if l < 0: 130 | break 131 | if fail: 132 | continue 133 | o = (h << 4) | l 134 | octets.append(o) 135 | if len(octets) > 1: 136 | csum ^= o 137 | if octets[0] == 8 and len(octets) == 6: 138 | if csum == 0: 139 | yield bytes(octets) 140 | else: 141 | self.trace("BAD SUM", "%02x" % csum, bytes(octets).hex()) 142 | fail = True 143 | elif octets[0] == 7 and len(octets) == 258: 144 | if csum == 0: 145 | yield bytes(octets) 146 | else: 147 | self.trace("BAD SUM", "%02x" % csum, bytes(octets).hex()) 148 | fail = True 149 | elif octets[0] not in (7, 8): 150 | self.trace("BAD DATA", bytes(octets).hex()) 151 | fail = True 152 | except StopIteration: 153 | run = False 154 | break 155 | 156 | amx = -1 157 | am = b'' 158 | for r in data_slicer(): 159 | if r[0] == 8: 160 | am = r 161 | amx = self.dx 162 | elif r[0] == 7 and amx > 0 and 9000 < self.dx - amx < 20000: 163 | self.trace( 164 | "%8d" % amx, 165 | "%8d" % self.dx, 166 | "%8d" % (self.dx - amx), 167 | stream.chs, 168 | am.hex(), 169 | r.hex() 170 | ) 171 | am_chs = (am[3],0, am[2]) 172 | self.did_read_sector( 173 | stream, 174 | amx, 175 | am_chs, 176 | r[1:257], 177 | (am[4:6].hex()), 178 | ) 179 | retval = True 180 | am = b'' 181 | amx = 0 182 | elif r[0] == 7 and amx < 0: 183 | pass 184 | elif r[0] == 7 and amx == 0: 185 | self.trace("BAD NO_AM", self.clock, len(r), amx, self.dx, self.dx - amx) 186 | elif r[0] == 7: 187 | self.trace("BAD AM_DISTANCE", self.clock, len(r), amx, self.dx, self.dx - amx) 188 | am = b'' 189 | amx = 0 190 | 191 | return retval 192 | 193 | ALL = [ 194 | CBM64, 195 | ] 196 | -------------------------------------------------------------------------------- /floppytools/formats/apple.py: -------------------------------------------------------------------------------- 1 | #/usr/bin/env python3 2 | 3 | ''' 4 | Apple ][ DOS 3.3 floppies 5 | ========================= 6 | ''' 7 | 8 | from ..base import media 9 | 10 | GCR5 = { 11 | 0xab: 0x00, 0xad: 0x01, 0xae: 0x02, 0xaf: 0x03, 12 | 0xb5: 0x04, 0xb6: 0x05, 0xb7: 0x06, 0xba: 0x07, 13 | 0xbb: 0x08, 0xbd: 0x09, 0xbe: 0x0a, 0xbf: 0x0b, 14 | 0xd6: 0x0c, 0xd7: 0x0d, 0xda: 0x0e, 0xdb: 0x0f, 15 | 0xdd: 0x10, 0xde: 0x11, 0xdf: 0x12, 0xea: 0x13, 16 | 0xeb: 0x14, 0xed: 0x15, 0xee: 0x16, 0xef: 0x17, 17 | 0xf5: 0x18, 0xf6: 0x19, 0xf7: 0x1a, 0xfa: 0x1b, 18 | 0xfb: 0x1c, 0xfd: 0x1d, 0xfe: 0x1e, 0xff: 0x1f, 19 | } 20 | 21 | GCR6 = { 22 | 0x96: 0x00, 0x97: 0x01, 0x9A: 0x02, 0x9B: 0x03, 23 | 0x9D: 0x04, 0x9E: 0x05, 0x9F: 0x06, 0xA6: 0x07, 24 | 0xA7: 0x08, 0xAB: 0x09, 0xAC: 0x0A, 0xAD: 0x0B, 25 | 0xAE: 0x0C, 0xAF: 0x0D, 0xB2: 0x0E, 0xB3: 0x0F, 26 | 0xB4: 0x10, 0xB5: 0x11, 0xB6: 0x12, 0xB7: 0x13, 27 | 0xB9: 0x14, 0xBA: 0x15, 0xBB: 0x16, 0xBC: 0x17, 28 | 0xBD: 0x18, 0xBE: 0x19, 0xBF: 0x1A, 0xCB: 0x1B, 29 | 0xCD: 0x1C, 0xCE: 0x1D, 0xCF: 0x1E, 0xD3: 0x1F, 30 | 0xD6: 0x20, 0xD7: 0x21, 0xD9: 0x22, 0xDA: 0x23, 31 | 0xDB: 0x24, 0xDC: 0x25, 0xDD: 0x26, 0xDE: 0x27, 32 | 0xDF: 0x28, 0xE5: 0x29, 0xE6: 0x2A, 0xE7: 0x2B, 33 | 0xE9: 0x2C, 0xEA: 0x2D, 0xEB: 0x2E, 0xEC: 0x2F, 34 | 0xED: 0x30, 0xEE: 0x31, 0xEF: 0x32, 0xF2: 0x33, 35 | 0xF3: 0x34, 0xF4: 0x35, 0xF5: 0x36, 0xF6: 0x37, 36 | 0xF7: 0x38, 0xF9: 0x39, 0xFA: 0x3A, 0xFB: 0x3B, 37 | 0xFC: 0x3C, 0xFD: 0x3D, 0xFE: 0x3E, 0xFF: 0x3F, 38 | } 39 | 40 | class AppleII(media.Media): 41 | ''' Apple ][ floppy disks ''' 42 | 43 | def __init__(self, *args, **kwargs): 44 | super().__init__(*args, **kwargs) 45 | self.define_geometry((0, 0, 0), (34, 0, 15), 256) 46 | self.x = 0 47 | self.clock = 80 48 | 49 | def process_stream(self, stream): 50 | 51 | retval = False 52 | 53 | if stream.chs[1] & 1: 54 | # I wonder if we can decode the B side of "flip" disks backwards on the odd tracks ? 55 | return retval 56 | 57 | self.x = 0 58 | self.clock = 80 59 | amx = 0 60 | am = b'\xff\xff\xff\xff' 61 | 62 | def bit_slicer(): 63 | ''' Estimate clock frequency and decode bits ''' 64 | 65 | for dt in stream.iter_dt(): 66 | self.x += dt 67 | if dt < 120: 68 | self.clock += (dt - self.clock) / 200 69 | w = dt / self.clock 70 | if w < 1.5: 71 | yield 1 72 | elif w < 2.5: 73 | yield 0 74 | yield 1 75 | else: 76 | yield 0 77 | yield 0 78 | yield 1 79 | 80 | def gw(n): 81 | ''' Get a word of N bits ''' 82 | x = 0 83 | for _i in range(n): 84 | x <<= 1 85 | x |= next(bsl_iter) 86 | while n == 8 and not x & 0x80: 87 | x <<= 1 88 | x |= next(bsl_iter) 89 | return x 90 | 91 | def g44(): 92 | ''' Get a byte encoded in interleaved FM format ''' 93 | x = 0 94 | y = [7, 5, 3, 1, 6, 4, 2, 0] 95 | for i in range(8): 96 | if not next(bsl_iter): 97 | return -1 98 | x |= next(bsl_iter) << y[i] 99 | return x 100 | 101 | try: 102 | bsl_iter = bit_slicer() 103 | sync = 0xff << 2 104 | acc = 0 105 | while True: 106 | while True: 107 | acc <<= 1 108 | acc |= next(bsl_iter) 109 | acc &= 0x3ff 110 | if acc == sync: 111 | break 112 | 113 | while acc == sync: 114 | acc = gw(10) 115 | 116 | if acc == 0x3ff: 117 | # Address mark 118 | 119 | acc &= 0x3 120 | acc <<= 22 121 | acc |= gw(22) 122 | if acc != 0xd5aa96: 123 | self.trace("D %06x" % acc) 124 | continue 125 | 126 | hdr = list(g44() for i in range(4)) 127 | if -1 in hdr: 128 | self.trace("E ", hdr) 129 | continue 130 | 131 | am = bytes(hdr) 132 | if am[0] ^ am[1] ^ am[2] ^ am[3]: 133 | self.trace("F ", am.hex(), hex(am[0] ^ am[1] ^ am[2] ^ am[3])) 134 | continue 135 | 136 | acc = gw(19) << 5 137 | if acc != 0xdeaae0: 138 | self.trace("G", am.hex(), "%06x" % acc) 139 | continue 140 | 141 | self.trace("AM", am.hex()) 142 | amx = self.x 143 | 144 | elif acc == 0x3fd: 145 | # Sector data 146 | 147 | dx = self.x 148 | if amx == 0 or not 0x1500 < dx - amx < 0x1700: 149 | self.trace("BAD MISSING AM", hex(dx), hex(amx), hex(dx - amx)) 150 | continue 151 | 152 | acc &= 0x1 153 | acc <<= 23 154 | acc |= gw(23) 155 | if acc != 0xd5aaad: 156 | self.trace("BAD SECTOR HEAD", am.hex(), "%06x" % acc) 157 | continue 158 | 159 | d6 = [] 160 | for _i in range(343): 161 | d6.append(GCR6.get(gw(8), -1)) 162 | 163 | if -1 in d6: 164 | self.trace("BAD GCR DECODE", am.hex(), d6) 165 | continue 166 | 167 | # 2 bytes to catch surplus bits 168 | data = [0] * 258 169 | csum = 0 170 | 171 | # 0x56 = round_up(256/3) 172 | for i, b in enumerate(d6[:0x56]): 173 | csum ^= b 174 | data[i + 0x00] |= ((csum >> 1) & 1) | ((csum << 1) & 2) 175 | data[i + 0x56] |= ((csum >> 3) & 1) | ((csum >> 1) & 2) 176 | data[i + 0xac] |= ((csum >> 5) & 1) | ((csum >> 3) & 2) 177 | 178 | for i, b in enumerate(d6[0x56:]): 179 | csum ^= b 180 | data[i] |= (csum << 2) 181 | 182 | if csum: 183 | self.trace("BAD CHECKUM", am.hex(), "%02x" % csum) 184 | continue 185 | 186 | tail = gw(24) 187 | if tail != 0xdeaaeb: 188 | self.trace("BAD TAIL", am.hex(), "%06x" % tail) 189 | continue 190 | 191 | self.trace( 192 | "GOOD READ", 193 | am.hex(), 194 | hex(dx - amx), 195 | "%04x" % sum(data), 196 | "CS %02x" % csum, 197 | data[256:], 198 | "%06x" % tail, 199 | ) 200 | self.did_read_sector( 201 | stream, 202 | amx, 203 | (am[1], 0, am[2]), 204 | bytes(data[:256]), 205 | ) 206 | retval = True 207 | 208 | except StopIteration: 209 | pass 210 | 211 | return retval 212 | 213 | ALL = [ 214 | AppleII, 215 | ] 216 | -------------------------------------------------------------------------------- /floppytools/base/chsset.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | ''' 4 | Summarizing of CHS lists 5 | ~~~~~~~~~~~~~~~~~~~~~~~~ 6 | 7 | ''' 8 | 9 | def ranges(numbers): 10 | ''' Reduce list of numbers to ranges ''' 11 | 12 | diff = None 13 | for i,j in enumerate(sorted(numbers)): 14 | if i - j != diff: 15 | if diff is not None: 16 | yield first,last 17 | first = j 18 | diff = i -j 19 | last = j 20 | if diff is not None: 21 | yield first,last 22 | 23 | def make_ranges(data): 24 | for low, high in ranges(data): 25 | if low == high: 26 | yield low, low 27 | elif low + 1 == high: 28 | yield low, low 29 | yield high, high 30 | else: 31 | yield low, high 32 | 33 | def summarize_ints(data): 34 | ''' 35 | Summarize a sequence of integers using intervals 36 | 37 | called with: returns: 38 | [3,] "3" 39 | [3,4,] "{3,4}" 40 | [3,4,5,] "{3-5}" 41 | [1,2,3,4,5,6,8,9,12,13,14] "{1-6,8,9,12-14}" 42 | [1,2,3,4,5,6,9,12,13,14] "{1-6,9,12-14}" 43 | [2,4,6,8] "{2-8/2}" 44 | [1,3,5,7] "{1-7/2}" 45 | ''' 46 | 47 | # Special trivial case 48 | if len(data) == 1: 49 | return "{" + str(list(data)[0]) + "}" 50 | 51 | l = [] 52 | for low, high in make_ranges(data): 53 | if low == high: 54 | l.append(str(low)) 55 | else: 56 | l.append(str(low) + "-" + str(high)) 57 | 58 | # It helped & we're done 59 | if len(l) < len(data): 60 | return "{" + ",".join(l) + "}" 61 | 62 | # Special case all-even and all-odd data 63 | sex = set(x & 1 for x in data) 64 | if len(sex) != 1: 65 | return "{" + ",".join(l) + "}" 66 | 67 | sex = list(sex)[0] 68 | l = [] 69 | for low, high in make_ranges(x // 2 for x in data): 70 | if low == high: 71 | l.append(str(low * 2 + sex)) 72 | else: 73 | l.append(str(low * 2 + sex) + "-" + str(high * 2 + sex)) 74 | return "{" + ",".join(l) + "/2}" 75 | 76 | class Cluster(): 77 | ''' Some group of sectors ''' 78 | 79 | def __init__(self, *args): 80 | self.c = set() 81 | self.h = set() 82 | self.s = set() 83 | self.b = set() 84 | self.n = 0 85 | 86 | for i in args: 87 | self.add(i) 88 | 89 | def __repr__(self): 90 | return "" 91 | 92 | def metadata_format(self): 93 | ''' Render in DDHF bitstore metadata format ''' 94 | for cl, ch in ranges(self.c): 95 | for hl, hh in ranges(self.h): 96 | for sl, sh in ranges(self.s): 97 | for b in self.b: 98 | yield "%d…%dc %d…%dh %d…%ds %db" % (cl, ch, hl, hh, sl, sh, b) 99 | 100 | def __iter__(self): 101 | for c in sorted(self.c): 102 | for h in sorted(self.h): 103 | for s in sorted(self.s): 104 | for b in sorted(self.b): 105 | yield (c, h, s, b) 106 | 107 | def pad(self): 108 | ''' Pad any holes ''' 109 | for i in range(min(self.c), max(self.c) + 1): 110 | self.c.add(i) 111 | for i in range(min(self.h), max(self.h) + 1): 112 | self.h.add(i) 113 | for i in range(min(self.s), max(self.s) + 1): 114 | self.s.add(i) 115 | 116 | def same(self, other, pivot): 117 | ''' Check of two clusters are the same, except for the pivot element ''' 118 | cc = self.c != other.c 119 | hh = self.h != other.h 120 | ss = self.s != other.s 121 | bb = self.b != other.b 122 | if pivot == 0 and (hh or ss or bb): 123 | return False 124 | if pivot == 1 and (cc or ss or bb): 125 | return False 126 | if pivot == 2 and (cc or hh or bb): 127 | return False 128 | if pivot == 3 and (cc or hh or ss): 129 | return False 130 | return True 131 | 132 | def add(self, chsb): 133 | ''' Add element to this cluster ''' 134 | c,h,s,b = chsb 135 | self.c.add(c) 136 | self.h.add(h) 137 | self.s.add(s) 138 | self.b.add(b) 139 | self.n += 1 140 | 141 | def merge(self, other): 142 | ''' Merge two clusters ''' 143 | self.c |= other.c 144 | self.h |= other.h 145 | self.s |= other.s 146 | self.b |= other.b 147 | self.n += other.n 148 | 149 | class CHSSet(): 150 | ''' Summarize sets of CHS values ''' 151 | 152 | def __init__(self): 153 | self.chs = [] 154 | self.clusters = None 155 | 156 | def add(self, chs, payload=0): 157 | ''' add an entry in CHS format ''' 158 | if payload is None: 159 | payload = 0 160 | self.chs.append((*chs, payload)) 161 | self.clusters = None 162 | 163 | def __len__(self): 164 | return len(self.chs) 165 | 166 | def cylinders(self): 167 | ''' Summarize just the cylinders ''' 168 | cyls = set(chs[0] for chs in self.chs) 169 | return "c" + summarize_ints(cyls) 170 | 171 | def cluster(self): 172 | ''' Cluster the geometry into clusters of like tracks ''' 173 | 174 | if self.clusters: 175 | return self.clusters 176 | 177 | wl = list(Cluster(x) for x in sorted(self.chs)) 178 | for pivot in (2, 1, 0): 179 | i = 0 180 | while i < len(wl) - 1: 181 | if wl[i].same(wl[i+1], pivot): 182 | wl[i].merge(wl[i+1]) 183 | wl.pop(i+1) 184 | else: 185 | i += 1 186 | self.clusters = wl 187 | return self.clusters 188 | 189 | def cuboids(self): 190 | ''' Cluster the geometry into likely cuboids ''' 191 | cl = list(self.cluster()) 192 | i = 0 193 | while i < len(cl) - 1: 194 | if cl[i].b != cl[i+1].b: 195 | i += 1 196 | else: 197 | cl[i].merge(cl.pop(i+1)) 198 | yield from cl 199 | 200 | def seq(self): 201 | ''' String representation of clusters ''' 202 | for cl in self.cluster(): 203 | yield str(cl) 204 | 205 | def __iter__(self): 206 | 207 | wl = [] 208 | for c, h, s, p in sorted(self.chs): 209 | if wl: 210 | prev = wl[-1] 211 | if len(prev[0]) == 1 and c in prev[0] and len(prev[1]) == 1 and h in prev[1]: 212 | prev[2].add(s) 213 | prev[3].add(p) 214 | continue 215 | wl.append([set((c,)), set((h,)), set((s,)), set((p,))]) 216 | i = 0 217 | while i < len(wl) -1: 218 | if wl[i][0] == wl[i+1][0] and wl[i][2] == wl[i+1][2]: 219 | wl[i][1] |= wl[i+1][1] 220 | wl.pop(i+1) 221 | else: 222 | i += 1 223 | while wl: 224 | c, h, s, p = wl.pop(0) 225 | i = 0 226 | while i < len(wl): 227 | if wl[i][1] == h and wl[i][2] == s: 228 | c |= wl[i][0] 229 | wl.pop(i) 230 | else: 231 | i += 1 232 | c = summarize_ints(c) 233 | h = summarize_ints(h) 234 | s = summarize_ints(s) 235 | p = summarize_ints(p) 236 | yield "c" + c + "h" + h + "s" + s + "b" + p 237 | 238 | def main(): 239 | ''' Test code ''' 240 | print(summarize_ints([3,])) 241 | print(summarize_ints([3,4,])) 242 | print(summarize_ints([3,4,5,])) 243 | print(summarize_ints([1,2,3,4,5,6,9,12,13,14])) 244 | print(summarize_ints([1,2,3,4,5,6,8,9,12,13,14])) 245 | print(summarize_ints([2,4,6,8,12,14,16,20])) 246 | print(summarize_ints([1,3,5,7,11,13,15,19])) 247 | 248 | cs = CHSSet() 249 | 250 | for c in range(0, 5): 251 | for h in range(0, 2): 252 | for s in range(0, 8): 253 | if h + s != 4: 254 | cs.add((c,h,s)) 255 | 256 | print(len(cs)) 257 | for i in cs: 258 | print("\t", i) 259 | 260 | print(len(cs)) 261 | for i in cs.seq(): 262 | print("\t", i) 263 | 264 | if __name__ == "__main__": 265 | main() 266 | -------------------------------------------------------------------------------- /floppytools/base/media.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | ''' 4 | Main program for floppy tools 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | ''' 7 | 8 | import os 9 | 10 | import time 11 | 12 | from . import media_abc 13 | from . import kryostream 14 | from . import chsset 15 | from . import cache_file 16 | 17 | class Media(media_abc.MediaAbc): 18 | ''' A Directory representing a Media ''' 19 | 20 | # ((first_c, first_h, first_s), (last_c, last_h, last_s), sector_size) 21 | GEOMETRY = None 22 | 23 | META_SUFFIX = ".BIN" 24 | META_FORMAT = "BINARY" 25 | 26 | # Other names or groups this format belongs to. 27 | aliases = [ ] 28 | 29 | def __init__(self, dirname, load_cache=False, save_cache=False): 30 | super().__init__() 31 | self.dirname = dirname 32 | os.makedirs(self.dirname, exist_ok=True) 33 | self.medianame = os.path.basename(self.dirname) 34 | self.files_done = set() 35 | self.log_files = [ 36 | (True, open("_.trace", "a")), 37 | (False, open(self.file_name(".trace"), "a")), 38 | ] 39 | # print("DEFGEOM", type(self), self.GEOMETRY) 40 | if self.GEOMETRY is not None: 41 | self.define_geometry(*self.GEOMETRY) 42 | 43 | self.cache_file = None 44 | if load_cache: 45 | self.read_cache() 46 | if save_cache: 47 | self.cache_file = cache_file.CacheFile(self.cache_file_name(), "a") 48 | 49 | def define_geometry(self, first_chs, last_chs, sector_size): 50 | ''' Define which sectors we expect to find ''' 51 | for c in range(first_chs[0], last_chs[0] + 1, 1): 52 | for h in range(first_chs[1], last_chs[1] + 1, 1): 53 | for s in range(first_chs[2], last_chs[2] + 1, 1): 54 | self.define_sector((c, h, s), sector_size) 55 | 56 | def defined_chs(self, chs): 57 | ''' Is this chs defined ? ''' 58 | chs = (chs[0], chs[1], chs[2]) 59 | ms = self.sectors.get(chs) 60 | if not ms: 61 | return None 62 | return ms.has_flag('defined') 63 | 64 | def file_name(self, ext): 65 | ''' Convenience function to create our file names ''' 66 | return os.path.join(self.dirname, "_.ft." + self.name + ext) 67 | 68 | def cache_file_name(self): 69 | return self.file_name(".cache") 70 | 71 | def bin_file_name(self): 72 | suf = os.path.basename(self.dirname) 73 | return self.file_name("." + suf + ".bin") 74 | 75 | def meta_file_name(self): 76 | return self.bin_file_name() + ".meta" 77 | 78 | def message(self, *args): 79 | txt = super().message(*args) 80 | self.trace(txt) 81 | 82 | def trace(self, *args): 83 | txt = " ".join(str(x) for x in args) 84 | for pfx, fn in self.log_files: 85 | if pfx: 86 | fn.write(self.dirname + ": ") 87 | fn.write(txt + "\n") 88 | fn.flush() 89 | 90 | def did_read_sector(self, source, rel_pos, am_chs, octets, flags=()): 91 | rs = media_abc.ReadSector(source, rel_pos, am_chs, octets, flags) 92 | self.add_read_sector(rs) 93 | return rs 94 | 95 | def add_read_sector(self, read_sector): 96 | ''' Add a reading of a sector ''' 97 | super().add_read_sector(read_sector) 98 | if not self.cache_file: 99 | return 100 | self.cache_file.write_sector(read_sector) 101 | 102 | def process_file(self, streamfilename): 103 | ''' ... ''' 104 | 105 | rel_filename = os.path.relpath(streamfilename, self.dirname) 106 | if rel_filename in self.files_done: 107 | self.trace("File already done", streamfilename, rel_filename) 108 | return False 109 | self.trace("Process", streamfilename, rel_filename) 110 | #try: 111 | if 1: 112 | stream = kryostream.KryoStream(streamfilename) 113 | #except kryostream.NotAKryofluxStream: 114 | #stream = fluxstream.RawStream(streamfilename) 115 | retval = self.process_stream(stream) 116 | if retval is None: 117 | self.trace("Ignored", streamfilename) 118 | return False 119 | if retval != None: 120 | for i in stream.dt_histogram(): 121 | self.trace(i) 122 | if self.cache_file: 123 | self.cache_file.write_file(rel_filename) 124 | return retval 125 | 126 | def read_cache(self): 127 | try: 128 | for kind, obj in cache_file.CacheFile(self.cache_file_name(), "r").read(): 129 | if kind == "file": 130 | self.files_done.add(obj) 131 | elif kind == "sector": 132 | self.add_read_sector(obj) 133 | else: 134 | assert False 135 | self.trace("# cache read", self.cache_file_name()) 136 | except FileNotFoundError: 137 | return 138 | 139 | def metadata_media_description(self): 140 | yield from [] 141 | 142 | def write_result(self, metaproto=""): 143 | geom = chsset.CHSSet() 144 | kit = {} 145 | for ms in sorted(self.sectors.values()): 146 | maj = ms.find_majority() 147 | chs = ms.phys_chs 148 | if maj: 149 | geom.add(chs, len(maj)) 150 | kit[chs] = maj 151 | elif ms.has_flag("unused"): 152 | kit[chs] = b'\x00' * ms.sector_length 153 | geom.add(chs, ms.sector_length) 154 | elif ms.has_flag("defined"): 155 | geom.add(chs, ms.sector_length) 156 | else: 157 | # ??? 158 | geom.add(chs, 0) 159 | 160 | badsects = chsset.CHSSet() 161 | with open(self.bin_file_name(), "wb") as binfile: 162 | 163 | for pr in geom.cuboids(): 164 | assert len(pr.b) == 1 165 | sector_length = list(pr.b)[0] 166 | for c,h,s,b in pr: 167 | t = kit.get((c,h,s)) 168 | if t is None: 169 | badsects.add((c,h,s), payload=sector_length) 170 | fill = (b'_UNREAD_' * (sector_length // 8 + 1))[:sector_length] 171 | binfile.write(fill) 172 | else: 173 | binfile.write(t) 174 | self.write_result_meta(metaproto, geom, badsects) 175 | 176 | def write_result_meta(self, metaproto="", geom=None, badsects=None): 177 | 178 | with open(self.meta_file_name(), "w") as metafile: 179 | metafile.write("BitStore.Metadata_version:\n") 180 | metafile.write("\t1.0\n") 181 | 182 | metafile.write("\nBitStore.Access:\n") 183 | metafile.write("\tpublic\n") 184 | 185 | metafile.write("\nBitStore.Filename:\n") 186 | metafile.write("\t" + self.dirname + self.META_SUFFIX + "\n") 187 | 188 | metafile.write("\nBitStore.Format:\n") 189 | metafile.write("\t" + self.META_FORMAT + "\n") 190 | 191 | try: 192 | qr = int(self.dirname) 193 | if 50000000 <= qr <= 50999999: 194 | metafile.write("\nDDHF.QR:\n\t%d\n" % qr) 195 | except Exception as err: 196 | print("Note: QR", err, self.dirname) 197 | pass 198 | 199 | if geom: 200 | metafile.write("\nMedia.Geometry:\n") 201 | for pr in geom.cuboids(): 202 | for fmt in pr.metadata_format(): 203 | metafile.write("\t" + fmt + "\n") 204 | 205 | if "Media.Summary:" not in metaproto: 206 | metafile.write("\nMedia.Summary:\n") 207 | metafile.write("\t" + self.dirname + "\n") 208 | 209 | if metaproto: 210 | metafile.write(metaproto) 211 | 212 | if "Media.Description:" not in metaproto: 213 | metafile.write("\nMedia.Description:\n") 214 | for ln in self.label_text(): 215 | metafile.write("\t" + ln + "\n") 216 | 217 | metafile.write("\tFloppyTools format: " + self.name + "\n") 218 | 219 | for i in self.metadata_media_description(): 220 | metafile.write("\t" + i + "\n") 221 | 222 | if badsects: 223 | metafile.write("\t\n\tBad (unread) sectors:\n") 224 | for cl in badsects.cluster(): 225 | for fmt in cl.metadata_format(): 226 | metafile.write("\t\t" + fmt + "\n") 227 | 228 | metafile.write("\n*END*\n") 229 | 230 | def label_text(self): 231 | try: 232 | file = open("labels.txt") 233 | except FileNotFoundError: 234 | return [] 235 | l = None 236 | for ln in file: 237 | ln = ln.rstrip() 238 | if ln == self.dirname: 239 | l = ["Label:"] 240 | continue 241 | if l is None: 242 | continue 243 | if ln[0] != '\t': 244 | return l 245 | l.append(ln) 246 | if l: 247 | return l 248 | print("#", self.dirname, "NB: Nothing in labels.txt") 249 | return [] 250 | 251 | 252 | 253 | -------------------------------------------------------------------------------- /floppytools/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | ''' 4 | Main program for floppy tools 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | ''' 7 | 8 | import sys 9 | import os 10 | import glob 11 | import time 12 | 13 | from .formats import index 14 | 15 | # Dont touch files if mtime is newer than this 16 | COOLDOWN = 2 17 | 18 | class Main(): 19 | ''' Common main() implementation ''' 20 | 21 | def __init__(self): 22 | self.files_done = set() 23 | self.verbose = 0 24 | self.defects = {} 25 | self.mdir = None 26 | 27 | 28 | run_mode = None 29 | self.ignore_cache = False 30 | self.just_try = False 31 | self.end_when_complete = False 32 | self.metaproto = "" 33 | format_names = [] 34 | ttymode = os.isatty(sys.stdout.fileno()) 35 | sys.argv.pop(0) 36 | while len(sys.argv) > 0: 37 | if sys.argv[0] == '-a': 38 | self.ignore_cache = True 39 | sys.argv.pop(0) 40 | elif sys.argv[0] == '-d': 41 | sys.argv.pop(0) 42 | run_mode = self.dir_mode 43 | elif sys.argv[0] == '-e': 44 | self.end_when_complete = True 45 | sys.argv.pop(0) 46 | elif sys.argv[0] in ('-h', '-?', '--help'): 47 | self.usage() 48 | sys.exit(0) 49 | elif sys.argv[0][:2] == '-f' and len(sys.argv[0]) > 2: 50 | format_names += sys.argv[0][2:].split(",") 51 | sys.argv.pop(0) 52 | elif sys.argv[0] == '-f': 53 | sys.argv.pop(0) 54 | format_names += sys.argv.pop(0).split(",") 55 | elif sys.argv[0] == '-m': 56 | sys.argv.pop(0) 57 | run_mode = self.monitor_mode 58 | elif sys.argv[0] == '-n': 59 | sys.argv.pop(0) 60 | self.just_try = True 61 | elif sys.argv[0] == "-p": 62 | sys.argv.pop(0) 63 | self.metaproto = open(sys.argv.pop(0)).read() 64 | elif sys.argv[0] == '-t': 65 | sys.argv.pop(0) 66 | ttymode = True 67 | elif sys.argv[0] == '-w': 68 | sys.argv.pop(0) 69 | run_mode = self.write_mode 70 | elif sys.argv[0][0] == '-': 71 | print("Unknown flag", sys.argv[0]) 72 | self.usage() 73 | sys.exit(2) 74 | else: 75 | break 76 | 77 | if ttymode: 78 | self.esc_home = "\x1b[H" 79 | self.esc_eol = "\x1b[K" 80 | self.esc_eos = "\x1b[J" 81 | else: 82 | self.esc_home = "" 83 | self.esc_eol = "" 84 | self.esc_eos = "" 85 | if run_mode is None: 86 | print("Specify run mode with -d or -m ") 87 | self.usage() 88 | sys.exit(2) 89 | 90 | if not format_names: 91 | format_names.append("all") 92 | self.format_classes = {} 93 | for fnm in format_names: 94 | i = list(index.find_formats(fnm)) 95 | if not i: 96 | print("Format name", fnm, "unrecognized") 97 | self.usage() 98 | sys.exit(2) 99 | for fnm, fcls in i: 100 | self.format_classes[fnm] = fcls 101 | run_mode() 102 | 103 | def usage(self, err=None): 104 | ''' ... ''' 105 | 106 | if err: 107 | print(err) 108 | print("") 109 | print("Usage:") 110 | print("------") 111 | opt = "[options]" 112 | print(" python3 -m", __package__, opt, "-m [project_directory]") 113 | print(" python3 -m", __package__, opt, "-d media_directory [stream_files]…") 114 | print("") 115 | print("Options:") 116 | print("--------") 117 | print("") 118 | print(" -a - ignore cache (= read everything)") 119 | print(" -e - end when complete") 120 | print(" -f format[,format]* - formats to try") 121 | print(" -n - dont write cache (= just try)") 122 | print(" -t - force tty mode (= use escape sequences)") 123 | print("") 124 | print("Formats:") 125 | print("--------") 126 | for nm, doc in index.documentation.items(): 127 | print("\n " + nm + "\n\t" + "\n\t".join(doc[0])) 128 | print("") 129 | print("Aliases:") 130 | print("--------") 131 | for nm, which in index.aliases.items(): 132 | print("\n " + nm + "\n\t" + ", ".join(sorted(which))) 133 | print("") 134 | 135 | def sync_media(self): 136 | ''' Close a media directory ''' 137 | if not self.mdir: 138 | return 139 | self.defects[self.mdir.medianame] = self.mdir.summary(long=True) 140 | with open(self.mdir.file_name(".status"), "w", encoding="utf8") as file: 141 | file.write("Dirname " + self.mdir.medianame + "\n") 142 | for i in self.mdir.picture(): 143 | file.write(i + '\n') 144 | for i in sorted(self.mdir.messages): 145 | file.write(i + '\n') 146 | file.write(self.mdir.summary(long=True) + '\n') 147 | for i, j in self.mdir.missing(): 148 | file.write("\t" + i + " " + j + "\n") 149 | self.mdir = None 150 | 151 | def mystatus(self, filename): 152 | ''' Single line status ''' 153 | l0 = [filename] + list(self.mdir.messages) + [self.mdir.summary()] 154 | sys.stdout.write(" ".join(l0) + self.esc_eol + '\n') 155 | 156 | def mypicture(self, filename): 157 | ''' Full Picture ''' 158 | sys.stdout.write(self.esc_home) 159 | self.mystatus(filename) 160 | for line in self.mdir.picture(): 161 | print(line + self.esc_eol) 162 | for line in self.mdir.summary(long=True).split('\n'): 163 | print(self.esc_eol + line) 164 | sys.stdout.write(self.esc_eos) 165 | 166 | def process_file(self, filename): 167 | ''' Process one file ''' 168 | retval = self.mdir.process_file(filename) 169 | if retval: 170 | self.mypicture(filename) 171 | else: 172 | self.mystatus(filename) 173 | sys.stdout.flush() 174 | return retval 175 | 176 | def process_dir(self, dirname, files): 177 | ''' Process some files in one directory ''' 178 | if self.mdir: 179 | self.sync_media() 180 | self.mdir = None 181 | for fn in files: 182 | if not self.mdir: 183 | for cls in self.format_classes.values(): 184 | self.mdir = cls( 185 | dirname, 186 | load_cache = not self.ignore_cache, 187 | save_cache = not self.just_try, 188 | ) 189 | self.process_file(fn) 190 | if self.mdir.any_good(): 191 | break 192 | self.mdir = None 193 | else: 194 | self.process_file(fn) 195 | self.files_done.add(fn) 196 | 197 | def dir_mode(self): 198 | ''' Process a specific directory ''' 199 | 200 | if not sys.argv: 201 | print("Specify directory for -d mode") 202 | self.usage() 203 | sys.exit(2) 204 | dirname = sys.argv.pop(0) 205 | 206 | if len(sys.argv) == 0: 207 | sys.argv = list( 208 | sorted( 209 | glob.glob(os.path.join(dirname, "*", "*.raw")) 210 | ) 211 | ) 212 | 213 | if len(sys.argv) == 0: 214 | print("Nothing to do ?") 215 | sys.exit(2) 216 | 217 | sys.stdout.write(self.esc_home + self.esc_eos) 218 | self.process_dir(dirname, sys.argv) 219 | if self.mdir: 220 | self.mypicture("") 221 | self.sync_media() 222 | 223 | def report_incomplete(self): 224 | ''' Report incomplete media ''' 225 | l = [] 226 | for dirname, defects in sorted(self.defects.items()): 227 | if 'COMPLETE' not in defects: 228 | print(dirname, defects) 229 | 230 | def monitor_mode(self): 231 | ''' Monitor a directory while media are being read ''' 232 | 233 | if len(sys.argv) > 1: 234 | print("Too many arguments for -m mode") 235 | self.usage() 236 | sys.exit(2) 237 | if sys.argv: 238 | os.chdir(sys.argv.pop(0)) 239 | self.path = "." 240 | 241 | m = 0 242 | sys.stdout.write(self.esc_home + self.esc_eos) 243 | while True: 244 | before = len(self.files_done) 245 | self.monitor_process_pending_files() 246 | after = len(self.files_done) 247 | sys.stdout.flush() 248 | if after > before: 249 | m = 0 250 | continue 251 | m += 1 252 | if m == 3: 253 | self.sync_media() 254 | self.report_incomplete() 255 | print() 256 | print("Waiting for stream files…") 257 | sys.stdout.flush() 258 | if m < 10: 259 | time.sleep(1) 260 | else: 261 | time.sleep(5) 262 | 263 | def monitor_process_pending_files(self): 264 | ''' Process pending files ''' 265 | 266 | for dirname in sorted(glob.glob("*")): 267 | if os.path.isdir(dirname): 268 | fns = list(sorted(self.monitor_files_todo(dirname))) 269 | if fns: 270 | if dirname not in self.defects: 271 | self.defects[dirname] = " - NOTHING" 272 | self.process_dir(dirname, fns) 273 | 274 | def monitor_files_todo(self, dirname): 275 | ''' Yield a list of new ${dirname}/*/*.raw files which have cooled down ''' 276 | 277 | for fn in sorted(glob.glob(os.path.join(dirname, "*/*.raw"))): 278 | if fn[-4:] != ".raw": 279 | continue 280 | if fn in self.files_done: 281 | continue 282 | st = os.stat(fn) 283 | if st.st_mtime + COOLDOWN < time.time(): 284 | yield fn 285 | 286 | def write_mode(self): 287 | ''' Write files for the bitstore ''' 288 | for dirname in sys.argv: 289 | for cls in self.format_classes.values(): 290 | mdir = cls(dirname, load_cache = True, save_cache = False) 291 | if mdir.any_good(): 292 | mdir.write_result(metaproto=self.metaproto) 293 | -------------------------------------------------------------------------------- /Pict/image_hack.py: -------------------------------------------------------------------------------- 1 | 2 | import math 3 | import glob 4 | 5 | from floppytools.base import kryostream 6 | 7 | SCALE = 6 8 | RAD76 = 51.537 9 | TPI = 25.4 / 48 10 | RADI = RAD76 - 5 * TPI 11 | MARGIN = 40 12 | 13 | WIDTH = 2 * int(SCALE * (RAD76 + TPI * 76) + MARGIN) 14 | 15 | SLOW = 200 16 | SLOW_OFF = 25 17 | 18 | class Histogram2(): 19 | def __init__(self): 20 | self.data = [] 21 | for i in range(SLOW): 22 | self.data.append([0] * SLOW) 23 | 24 | def add(self, prev, item): 25 | prev -= SLOW_OFF 26 | item -= SLOW_OFF 27 | if 0 <= prev < SLOW and 0 <= item < SLOW: 28 | self.data[prev][item] += 1 29 | 30 | def paint(self, pic, peaks): 31 | peak = .1 32 | for i in self.data: 33 | j = max(i) 34 | peak = max(peak, j) 35 | peak = math.log(peak) 36 | print("H2", "peak", peak) 37 | 38 | y0 = SLOW 39 | x0 = -SLOW 40 | for x, i in enumerate(self.data): 41 | for y, j in enumerate(i): 42 | if 0 and (x-75)**2 + (y-75)**2 > 8000: 43 | continue 44 | color = (0x00, 0x55, 0x55) 45 | elif j == 0: 46 | color = (0x55, 0x00, 0x55) 47 | else: 48 | j = int(255 * math.log(j) / peak) 49 | color = (j, j, j) 50 | pic.color( x + x + x0, y0 - (y + y), color) 51 | pic.color(1 + x + x + x0, y0 - (y + y), color) 52 | pic.color( x + x + x0, 1 + y0 - (y + y), color) 53 | pic.color(1 + x + x + x0, 1 + y0 - (y + y), color) 54 | 55 | class Histogram(): 56 | def __init__(self): 57 | self.data = [0] * SLOW 58 | 59 | def add(self, item): 60 | item -= SLOW_OFF 61 | if 0 <= item < SLOW: 62 | self.data[item] += 1 63 | 64 | def peaks(self): 65 | pks = [] 66 | left = [0] * SLOW 67 | smear = 5 68 | minimis = 0 69 | minimis = 100 70 | tot = sum(self.data) 71 | for n, i in enumerate(self.data): 72 | left[n // smear] += i 73 | for i in range(4): 74 | pk = max(left) 75 | xc = left.index(pk) 76 | 77 | xlo = xc 78 | while xlo > 0 and left[xlo - 1] < left[xlo] and left[xlo-1] > minimis: 79 | xlo -= 1 80 | 81 | xhi = xc 82 | while xhi < len(left) - 1 and left[xhi + 1] < left[xhi] and left[xhi+1] > minimis: 83 | xhi += 1 84 | 85 | u = xc 86 | vol = 0 87 | for x in range(xlo, xhi+1): 88 | vol += left[x] 89 | left[x] = 0 90 | if tot > 0 and vol / tot > .01: 91 | pks.append((xlo, xc, xhi, vol)) 92 | print("pk", xlo * smear, xc * smear, xhi * smear, vol) 93 | 94 | ll = [] 95 | for xlo,xc,xhi,vol in pks: 96 | print("PK", pk, xlo * smear, xc * smear, xhi * smear, vol, vol/tot) 97 | ll.append(xlo * smear) 98 | ll.append(xhi * smear + smear - 1) 99 | print("LL", ll) 100 | while len(ll) < 6: 101 | ll.append(99999) 102 | return ll 103 | 104 | def paint(self, pic, peaks): 105 | peak = max(.1, max(self.data)) 106 | 107 | for i, j in enumerate(self.data): 108 | x = 2 * (i - SLOW // 2) 109 | y0 = SLOW 110 | yx = y0 + +10 * int(math.log(1 + 1000 * j / peak)) 111 | if peaks[0] <= i <= peaks[1]: 112 | rgb = (64, 255, 64) 113 | elif peaks[2] <= i <= peaks[3]: 114 | rgb = (64, 64, 255) 115 | elif peaks[4] <= i <= peaks[5]: 116 | rgb = (255, 0, 0) 117 | else: 118 | rgb = (192, 192, 192) 119 | #for y in range(yx, y0 + 1): 120 | for y in range(y0 - 1, yx): 121 | pic.color(x, y, rgb) 122 | pic.color(x+1, y, rgb) 123 | 124 | def dump(self, fn): 125 | peak = max(self.data) 126 | threshold = peak ** .5 127 | with open(fn, "w") as file: 128 | for i, j in enumerate(self.data): 129 | if j == 0: 130 | j = 1 131 | file.write("%d %d\n" % (i, j)) 132 | 133 | class Pixel(): 134 | def __init__(self): 135 | self.data = [] 136 | 137 | def rgb(self, peaks): 138 | r = 0 139 | g = 0 140 | b = 0 141 | if True: 142 | for k in self.data: 143 | k -= SLOW_OFF 144 | if peaks[0] <= k <= peaks[1]: 145 | g += 1 146 | elif peaks[2] <= k <= peaks[3]: 147 | b += 1 148 | elif peaks[4] <= k <= peaks[5]: 149 | r += 1 150 | r /= len(self.data) 151 | g /= len(self.data) 152 | b /= len(self.data) 153 | r *= 255 154 | g *= 255 155 | b *= 255 156 | return int(r), int(g), int(b) 157 | 158 | class Projection(): 159 | 160 | def __init__(self, width): 161 | self.pic = {} 162 | self.rgb = {} 163 | self.width = width 164 | 165 | def add(self, x, y, z): 166 | p = self.pic.get((x,y)) 167 | if p is None: 168 | p = Pixel() 169 | self.pic[(x,y)] = p 170 | p.data.append(z) 171 | 172 | def color(self, x, y, rgb): 173 | self.rgb[(x, y)] = rgb 174 | 175 | def dump(self, fn, peaks): 176 | self.xmin = -self.width // 2 177 | self.ymin = -self.width // 2 178 | self.xmax = self.width // 2 179 | self.ymax = self.width // 2 180 | with open(fn, "w") as file: 181 | file.write("P3\n") 182 | file.write("%d %d 255\n" % (1 + self.xmax - self.xmin, 1 + self.ymax - self.ymin)) 183 | for y in range(self.ymin, self.ymax + 1): 184 | for x in range(self.xmin, self.xmax + 1): 185 | color = self.rgb.get((x, y)) 186 | if color: 187 | file.write("%d %d %d\n" % color) 188 | continue 189 | p = self.pic.get((x, y)) 190 | if True: 191 | if not p: 192 | p = self.pic.get((x-1, y)) 193 | if not p: 194 | p = self.pic.get((x, y-1)) 195 | if not p: 196 | p = self.pic.get((x, y+1)) 197 | if not p: 198 | p = self.pic.get((x+1, y)) 199 | if not p: 200 | p = self.pic.get((x-1, y-1)) 201 | if not p: 202 | p = self.pic.get((x-1, y+1)) 203 | if not p: 204 | p = self.pic.get((x+1, y-1)) 205 | if not p: 206 | p = self.pic.get((x+1, y+1)) 207 | if not p: 208 | p = self.pic.get((x+2, y+0)) 209 | if not p: 210 | p = self.pic.get((x+0, y+2)) 211 | if not p: 212 | p = self.pic.get((x-2, y+0)) 213 | if not p: 214 | p = self.pic.get((x+0, y-2)) 215 | if p: 216 | file.write("%d %d %d\n" % p.rgb(peaks)) 217 | else: 218 | file.write("%d %d %d\n" % (.5,.5,.5)) 219 | 220 | class KryoFile(): 221 | 222 | def __init__(self, fname): 223 | self.fname = fname 224 | self.ks = kryostream.KryoStream(fname) 225 | self.chs = self.ks.chs 226 | 227 | def __repr__(self): 228 | return "" 229 | 230 | def find_first_rotation(self, histo, histo2): 231 | self.ks.deframe() 232 | 233 | if len(self.ks.index) < 2: 234 | print("Too few index pulses",len(self.ks.index)) 235 | self.rev0 = -1 236 | self.rev1 = -1 237 | return 238 | 239 | self.idx = {} 240 | for n, i in enumerate(self.ks.index): 241 | self.idx[i[3]] = i 242 | 243 | dur = 0 244 | p = 0 245 | for n, dt in enumerate(self.ks.iter_dt()): 246 | histo.add(dt) 247 | histo2.add(p, dt) 248 | p = dt 249 | if n in self.idx: 250 | self.idx[n].append(dur) 251 | dur += dt 252 | 253 | if len(self.ks.index) < 12: 254 | idx0 = 0 255 | idx1 = 1 256 | iholes = [] 257 | else: 258 | p = 0 259 | l = [] 260 | for i, j in self.idx.items(): 261 | d = j[-1] - p 262 | l.append(d) 263 | p = j[-1] 264 | l.sort() 265 | m = (l[3] + l[-8]) / 2 266 | 267 | p = 0 268 | pd = 0 269 | iholes = [] 270 | n = 0 271 | for i, j in enumerate(self.idx.values()): 272 | d = j[-1] - p 273 | if d < m and pd > m: 274 | iholes.append(i) 275 | n = 0 276 | pd = d 277 | p = j[-1] 278 | n += 1 279 | 280 | idx0 = iholes[0] 281 | idx1 = iholes[1] 282 | 283 | self.rev0 = self.ks.index[idx0][3] 284 | self.rev1 = self.ks.index[idx1][3] 285 | self.rdur = self.ks.index[idx1][-1] - self.ks.index[idx0][-1] 286 | print( 287 | "R0", 288 | idx0, 289 | self.ks.index[idx0], 290 | ) 291 | print( 292 | "R1", 293 | idx1, 294 | self.ks.index[idx1], 295 | ) 296 | print( 297 | " ", 298 | self.rdur, 299 | iholes, 300 | ) 301 | 302 | def process(self, pict): 303 | if self.rev0 < 0: 304 | return 305 | 306 | omega = 0 307 | dur = 0 308 | rho = 2 * 3.141592 / self.rdur 309 | 310 | rad = SCALE * (RAD76 + TPI * (76 - self.ks.chs[0])) 311 | radi = SCALE * RADI - self.ks.chs[0] * .2 312 | 313 | prev = 0 314 | for n, dt in enumerate(self.ks.iter_dt()): 315 | if self.rev0 > n: 316 | continue 317 | if n > self.rev1: 318 | break 319 | omega = dur * rho 320 | dur += dt 321 | if n in self.idx: 322 | x = int(radi*math.sin(omega)) 323 | y = int(-radi*math.cos(omega)) 324 | for dx in (-1, 0, +1): 325 | for dy in (-1, 0, +1): 326 | pict.color(x+dx, y+dy, (255,255,255)) 327 | 328 | x = int(rad*math.sin(omega)) 329 | y = int(-rad*math.cos(omega)) 330 | pict.add(x,y,dt) 331 | 332 | class DiskImg(): 333 | 334 | 335 | def __init__(self, dirname, side=0): 336 | 337 | self.dirname = dirname 338 | 339 | self.fns = list(sorted(glob.glob(dirname + "/*??.%d.raw" % side))) 340 | 341 | kfs = list(KryoFile(fn) for fn in self.fns) 342 | 343 | p = Projection(WIDTH) 344 | 345 | h = Histogram() 346 | h2 = Histogram2() 347 | 348 | for kf in kfs[:200]: 349 | print("kf", kf) 350 | kf.find_first_rotation(h, h2) 351 | 352 | peaks = h.peaks() 353 | h2.paint(p, peaks) 354 | h.paint(p, peaks) 355 | h.dump("/tmp/_h") 356 | 357 | for kf in kfs[:200]: 358 | kf.process(p) 359 | 360 | p.dump("/tmp/pix.ppm", peaks) 361 | exit(0) 362 | 363 | if __name__ == "__main__": 364 | 365 | import sys 366 | 367 | d = DiskImg(sys.argv[1], len(sys.argv) - 2) 368 | -------------------------------------------------------------------------------- /floppytools/formats/ibm.py: -------------------------------------------------------------------------------- 1 | #/usr/bin/env python3 2 | 3 | ''' 4 | IBM format 5 | ~~~~~~~~~~ 6 | ''' 7 | 8 | import os 9 | import time 10 | import crcmod 11 | 12 | from ..base import media 13 | from ..base import fluxstream as fs 14 | 15 | from ..base import chsset 16 | 17 | crc_func = crcmod.predefined.mkCrcFun('crc-ccitt-false') 18 | 19 | class IbmTrack(): 20 | ''' ... ''' 21 | 22 | class IbmFmTrack(IbmTrack): 23 | 24 | GAP1 = 4 25 | SYNC = '|---' * GAP1 26 | 27 | FM_ADDRESS_MARK = (0xc7, 0xfe) 28 | AM_PATTERN = SYNC + fs.make_mark_fm(*FM_ADDRESS_MARK) 29 | 30 | DATA_MARK = (0xc7, 0xfb) 31 | DATA_PATTERN = SYNC + fs.make_mark_fm(*DATA_MARK) 32 | 33 | DELETE_MARK = (0xc7, 0xf8) 34 | DELETE_PATTERN = SYNC + fs.make_mark_fm(*DELETE_MARK) 35 | 36 | MAX_GAP2 = 100 37 | 38 | def process_stream(self, thismedia, stream, clock=50): 39 | flux = stream.fm_flux(clock) 40 | for am_pos in stream.iter_pattern(flux, pattern=self.AM_PATTERN): 41 | address_mark = stream.flux_data_fm(flux[am_pos-32:am_pos+(6*32)]) 42 | if address_mark is None: 43 | thismedia.trace("NOAM", am_pos) 44 | continue 45 | am_crc = crc_func(address_mark) 46 | if am_crc != 0: 47 | thismedia.trace("AMCRC", am_pos, address_mark.hex()) 48 | chs = (address_mark[1], address_mark[2], address_mark[3]) 49 | else: 50 | chs = (address_mark[1], address_mark[2], address_mark[3]) 51 | sector_size = 128 << address_mark[4] 52 | extra = [ "mode=FM", "clock=%d" % clock] 53 | data_pos = flux.find(self.DATA_PATTERN, am_pos, am_pos + self.MAX_GAP2 * 32) 54 | if data_pos < 0: 55 | data_pos = flux.find(self.DELETE_PATTERN, am_pos, am_pos + self.MAX_GAP2 * 32) 56 | if data_pos >= 0: 57 | extra.append("deleted") 58 | if data_pos < 0: 59 | thismedia.trace( 60 | "NOFLAG", 61 | "%10d" % am_pos, 62 | address_mark.hex(), 63 | flux[am_pos:am_pos + 6000] 64 | ) 65 | continue 66 | 67 | data_pos += len(self.DATA_PATTERN) 68 | data = stream.flux_data_fm(flux[data_pos-32:data_pos+((2+sector_size)*32)]) 69 | if data is None: 70 | thismedia.trace("NODATA", am_pos) 71 | continue 72 | 73 | data_crc = crc_func(data) 74 | if data_crc: 75 | thismedia.trace( 76 | "DATACRC", 77 | "%10d" % am_pos, 78 | address_mark.hex(), 79 | hex(data_crc), 80 | len(data), 81 | data.hex() 82 | ) 83 | thismedia.trace( 84 | "FLUX", 85 | "%10d" % am_pos, 86 | address_mark.hex(), 87 | flux[am_pos:am_pos + 6000] 88 | # flux[data_pos-32:data_pos+(8+sector_size*32)] 89 | ) 90 | continue 91 | 92 | yield am_pos, chs, data[1:1+sector_size], extra 93 | 94 | class IbmMfmTrack(IbmTrack): 95 | 96 | GAP1 = 32 97 | SYNC = '|-' * GAP1 98 | 99 | MFM_ADDRESS_MARK = ((0x0a, 0xa1), (0x0a, 0xa1), (0x0a, 0xa1), (0x00, 0xfe)) 100 | AM_PATTERN = SYNC + ''.join(fs.make_mark(*i) for i in MFM_ADDRESS_MARK) 101 | 102 | DATA_MARK = ((0x0a, 0xa1), (0x0a, 0xa1), (0x0a, 0x0a1), (0x00, 0xfb)) 103 | DATA_PATTERN = SYNC + ''.join(fs.make_mark(*i) for i in DATA_MARK) 104 | 105 | DELETE_MARK = ((0x0a, 0xa1), (0x0a, 0xa1), (0x0a, 0x0a1), (0x03, 0xf8)) 106 | DELETE_PATTERN = SYNC + ''.join(fs.make_mark(*i) for i in DELETE_MARK) 107 | 108 | MAX_GAP2 = 60 109 | 110 | def process_stream(self, thismedia, stream, clock=50): 111 | flux = stream.mfm_flux(clock) 112 | for am_pos in stream.iter_pattern(flux, pattern=self.AM_PATTERN): 113 | address_mark = stream.flux_data_mfm(flux[am_pos-64:am_pos+(6*16)]) 114 | if address_mark is None: 115 | thismedia.trace("NOAM", am_pos) 116 | continue 117 | am_crc = crc_func(address_mark) 118 | if am_crc != 0: 119 | thismedia.trace("AMCRC", am_pos) 120 | continue 121 | chs = (address_mark[4], address_mark[5], address_mark[6]) 122 | 123 | extra = [ "mode=MFM", "clock=%d" % clock] 124 | data_pos = flux.find(self.DATA_PATTERN, am_pos + 20 * 16, am_pos + self.MAX_GAP2 * 16) 125 | if data_pos < 0: 126 | data_pos = flux.find(self.DELETE_PATTERN, am_pos, am_pos + self.MAX_GAP2 * 16) 127 | if data_pos >= 0: 128 | extra.append("deleted") 129 | if data_pos < 0: 130 | thismedia.trace("NOFLAG", am_pos) 131 | continue 132 | data_pos += len(self.DATA_PATTERN) 133 | 134 | sector_size = 128 << address_mark[7] 135 | 136 | off = -4*16 137 | width = (6 + sector_size) * 16 138 | data = stream.flux_data_mfm(flux[data_pos+off:data_pos+width+off]) 139 | if data is None: 140 | thismedia.trace("NODATA", am_pos) 141 | continue 142 | 143 | data_crc = crc_func(data) 144 | 145 | if data_crc != 0: 146 | thismedia.trace("DATACRC", am_pos, len(data), hex(data_crc), data[:32].hex()) 147 | continue 148 | 149 | yield am_pos, chs, data[4:4+sector_size], extra 150 | 151 | class Ibm(media.Media): 152 | 153 | ''' IBM format floppy disks ''' 154 | 155 | aliases = ["IBM"] 156 | 157 | FM_ADDRESS_MARK = (0xc7, 0xfe) 158 | MFM_ADDRESS_MARK = ((0x0a, 0xa1), (0x0a, 0xa1), (0x0a, 0xa1), (0x00, 0xfe)) 159 | DATA_MARK = (0xc7, 0xfb) 160 | DELETE_MARK = (0xc7, 0xf8) 161 | GAP1 = 16 162 | MAX_GAP2 = 100 163 | 164 | CLOCKS = [50, 80, 100] # 500, 300 and 250 kHz 165 | 166 | FMTRACK = IbmFmTrack() 167 | MFMTRACK = IbmMfmTrack() 168 | 169 | META_SUFFIX = ".IMD" 170 | META_FORMAT = "IMAGEDISK" 171 | 172 | def __init__(self, *args, **kwargs): 173 | super().__init__(*args, **kwargs) 174 | self.todo = [] 175 | for i in self.CLOCKS: 176 | self.todo.append((self.FMTRACK, i,)) 177 | self.todo.append((self.MFMTRACK, i,)) 178 | 179 | def process_stream(self, stream): 180 | ''' ... ''' 181 | 182 | retval = False 183 | for _i in range(len(self.todo)): 184 | track, clock = self.todo[0] 185 | for rel_pos, chs, data, extra in track.process_stream(self, stream, clock): 186 | self.did_read_sector( 187 | stream, 188 | rel_pos, 189 | chs, 190 | data, 191 | flags=extra, 192 | ) 193 | retval = True 194 | if retval: 195 | return retval 196 | self.todo.append(self.todo.pop(0)) 197 | return False 198 | 199 | def bin_file_name(self): 200 | suf = os.path.basename(self.dirname) 201 | return self.file_name("." + suf + ".imd") 202 | 203 | def imd_mode_byte(self, flags): 204 | if 'clock=50' in flags: 205 | retval = 0x00 206 | elif 'clock=80' in flags: 207 | retval = 0x01 208 | elif 'clock=100' in flags: 209 | retval = 0x02 210 | else: 211 | assert False 212 | if 'mode=FM' in flags: 213 | return retval 214 | if 'mode=MFM' in flags: 215 | return retval + 3 216 | assert False 217 | 218 | def write_result(self, metaproto=""): 219 | 220 | tracks = {} 221 | for ms in sorted(self.sectors.values()): 222 | maj = ms.find_majority() 223 | if not maj: 224 | continue 225 | chs = ms.phys_chs 226 | trk = tracks.get(chs[:2]) 227 | if trk is None: 228 | trk = [] 229 | tracks[chs[:2]] = trk 230 | trk.append((ms.phys_chs, ms.flags, maj)) 231 | 232 | with open(self.bin_file_name(), "wb") as binfile: 233 | gm = time.gmtime(time.time()) 234 | hdr = time.strftime('IMD 1.19: %d/%m/%Y %H:%M:%S\r\n', gm) 235 | binfile.write(hdr.encode('ascii')) 236 | binfile.write(b'Create by Datamuseum-DK/FloppyTools\r\n') 237 | binfile.write(b'\x1a') 238 | for chs, trk in sorted(tracks.items()): 239 | th = ( 240 | self.imd_mode_byte(trk[0][1]), 241 | chs[0], 242 | chs[1], 243 | len(trk), 244 | { 245 | 128: 0, 246 | 256: 1, 247 | 512: 2, 248 | 1024: 3, 249 | 2048: 4, 250 | 4096: 5, 251 | 8192: 6, 252 | }[len(trk[0][2])], 253 | ) 254 | binfile.write(bytes(th)) 255 | snm = bytes(x[0][2] for x in trk) 256 | binfile.write(snm) 257 | for chs, flg, dat in trk: 258 | if "deleted" in flg: 259 | binfile.write(b'\x03') 260 | else: 261 | binfile.write(b'\x01') 262 | binfile.write(dat) 263 | 264 | self.write_result_meta(metaproto) 265 | return 266 | 267 | with open(self.meta_file_name(), "w") as metafile: 268 | metafile.write("BitStore.Metadata_version:\n") 269 | metafile.write("\t1.0\n") 270 | 271 | metafile.write("\nBitStore.Access:\n") 272 | metafile.write("\tpublic\n") 273 | 274 | metafile.write("\nBitStore.Filename:\n") 275 | metafile.write("\t" + self.dirname + ".IMD\n") 276 | 277 | metafile.write("\nBitStore.Format:\n") 278 | metafile.write("\tIMAGEDISK\n") 279 | 280 | if "Media.Summary:" not in metaproto: 281 | metafile.write("\nMedia.Summary:\n") 282 | metafile.write("\t" + self.dirname + "\n") 283 | 284 | if metaproto: 285 | metafile.write(metaproto) 286 | 287 | if "Media.Description:" not in metaproto: 288 | metafile.write("\nMedia.Description:\n") 289 | 290 | metafile.write("\tFloppyTools format: " + self.name + "\n") 291 | 292 | for i in self.metadata_media_description(): 293 | metafile.write("\t" + i + "\n") 294 | 295 | metafile.write("\n*END*\n") 296 | 297 | """ 298 | class IbmFm128Ss(IbmFm): 299 | ''' ... ''' 300 | #FIRST_CHS = (0, 0, 1) 301 | #LAST_CHS = (76, 1, 26) 302 | #SECTOR_SIZE = 128 303 | 304 | class IbmFm128Ds(IbmFm): 305 | ''' ... ''' 306 | FIRST_CHS = (0, 0, 1) 307 | LAST_CHS = (76, 1, 26) 308 | SECTOR_SIZE = 128 309 | 310 | class IbmFm256Ss(IbmFm): 311 | ''' ... ''' 312 | FIRST_CHS = (0, 0, 1) 313 | LAST_CHS = (76, 0, 15) 314 | SECTOR_SIZE = 256 315 | 316 | class IbmFm256Ds(IbmFm): 317 | ''' ... ''' 318 | FIRST_CHS = (0, 0, 1) 319 | LAST_CHS = (76, 1, 15) 320 | SECTOR_SIZE = 256 321 | 322 | class IbmFm512Ss(IbmFm): 323 | ''' ... ''' 324 | FIRST_CHS = (0, 0, 1) 325 | LAST_CHS = (76, 0, 8) 326 | SECTOR_SIZE = 512 327 | 328 | class IbmFm512Ds(IbmFm): 329 | ''' ... ''' 330 | FIRST_CHS = (0, 0, 1) 331 | LAST_CHS = (76, 1, 8) 332 | SECTOR_SIZE = 512 333 | 334 | class Osborne(IbmFm): 335 | ''' ... ''' 336 | GEOMETRY = ((0, 0, 1), (39, 0, 10), 256) 337 | CLOCK_FM = 80 338 | 339 | class PC360(IbmFm): 340 | ''' ... ''' 341 | GEOMETRY = ((0, 0, 1), (39, 0, 9), 512) 342 | CLOCK_FM = 80 343 | CLOCK_MFM = 80 344 | 345 | """ 346 | 347 | ALL = ( 348 | #IbmFm128Ss, 349 | #IbmFm128Ds, 350 | #IbmFm256Ss, 351 | #IbmFm256Ds, 352 | #IbmFm512Ss, 353 | #IbmFm512Ds, 354 | #Osborne, 355 | #PC360, 356 | Ibm, 357 | ) 358 | -------------------------------------------------------------------------------- /floppytools/formats/q1_microlite.py: -------------------------------------------------------------------------------- 1 | #/usr/bin/env python3 2 | 3 | ''' 4 | Q1 Microlite floppies 5 | ~~~~~~~~~~~~~~~~~~~~~ 6 | 7 | This is in top five of strange floppy formats. 8 | 9 | Please note that the following information is based on analysis 10 | of a single floppy-disk. 11 | 12 | Each file has a record length, and the tracks allocated to that 13 | file are formatted with that sector length. The surplus space 14 | seems to be distributed evenly between sectors. 15 | 16 | Modulation is MFM with an atypical frequency. 17 | 18 | Synchronization is (64?) zeros, 3½ bit times with a clock-violation, 19 | followed by either 0x9e (address-mark) or 0x9b (data) 20 | 21 | The error check is a trivial byte checksum. 22 | For address-marks the 0x9e is not included, for data the 0x9b is. 23 | 24 | Track zero has 40 byte sectors, each of which contains the catalog 25 | entry for a single file, containing the filename, first and last 26 | tracks, length of records, number of records per track and the 27 | record after the last one. 28 | 29 | Unused sectors, including the sectors past end of file, has address 30 | marks, but the data may not pass the error check. 31 | 32 | ''' 33 | 34 | import struct 35 | 36 | from collections import Counter 37 | 38 | from ..base import media 39 | from ..base import fluxstream 40 | 41 | def most_common(lst): 42 | ''' Return most common element ''' 43 | 44 | return Counter(lst).most_common(1)[0][0] 45 | 46 | 47 | class Q1MicroLiteCommon(media.Media): 48 | ''' ... ''' 49 | 50 | aliases = ["Q1"] 51 | 52 | def __init__(self, *args, **kwargs): 53 | super().__init__(*args, **kwargs) 54 | self.catalog_todo = [] 55 | self.catalog_entries = {} 56 | self.cyl_contains = [""] * 77 57 | self.cyl_skew = False 58 | for chs, ms in list(self.sectors.items()): 59 | if chs[0] > 0: 60 | continue 61 | for rs in ms.readings: 62 | self.catalog_entry(chs, rs.octets) 63 | 64 | def split_stream(self, flux): 65 | ''' Two level split of stream at AM and then Data ''' 66 | 67 | for i in flux.split(self.AM_PATTERN)[1:]: 68 | j = i.split(self.DATA_PATTERN) 69 | # There must be a data part 70 | if len(j) < 2: 71 | self.trace("no DATA_PATTERN", len(i), [len(x) for x in j]) 72 | continue 73 | # Close to the address mark 74 | if len(j[0]) > self.GAPLEN: 75 | self.trace("Too much gap", len(j[0]), self.GAPLEN) 76 | continue 77 | yield j 78 | 79 | def sector_length(self, stream, chs): 80 | if chs[0] != stream.chs[0] and not self.cyl_skew: 81 | self.message("CYL_SKEW") 82 | self.trace( 83 | "Cylinder skew", 84 | "AM is", 85 | chs, 86 | "Stream", 87 | stream, 88 | ) 89 | self.cyl_skew = True 90 | if chs[0] == 0: 91 | return None, 40 92 | ms = self.sectors.get(chs) 93 | if ms: 94 | return ms, ms.sector_length 95 | return None, None 96 | 97 | def actually_do_catalog_entry(self, chs, data): 98 | if chs[2] == 0: 99 | pass 100 | elif chs not in self.sectors: 101 | print("CAT bad chs", chs) 102 | return 103 | elif self.sectors[chs].has_flag("unused"): 104 | return 105 | 106 | fmt = "= 80: 124 | self.trace("LAST", str(last)) 125 | return 126 | 127 | for cyl in range(first, last +1): 128 | self.cyl_contains[cyl] = name.decode('ascii') 129 | for sect in range(0, nsect): 130 | i = (cyl, 0, sect) 131 | ms = self.define_sector(i, length) 132 | if count == 0: 133 | ms.set_flag("unused") 134 | else: 135 | count -= 1 136 | 137 | def catalog_entry(self, chs, data): 138 | ''' 139 | Process a catalog sector from track zero 140 | 141 | We pile up entries until we have seen an "INDEX" 142 | entry in sector zero, so avoid processing junk 143 | in a sector outside INDEX.count. 144 | ''' 145 | 146 | if chs[2] != 0 and self.catalog_todo is not None: 147 | self.catalog_todo.append((chs, data)) 148 | return 149 | 150 | self.actually_do_catalog_entry(chs, data) 151 | 152 | if self.catalog_todo is None: 153 | return 154 | 155 | todo = self.catalog_todo 156 | self.catalog_todo = None 157 | for i, j in todo: 158 | self.actually_do_catalog_entry(i, j) 159 | 160 | def attempt_sector(self, chs, data, stream, ms=None, sector_length=None, also_bad=False): 161 | good = True 162 | flags = [] 163 | if ms: 164 | sector_length = ms.sector_length 165 | if sector_length is None: 166 | return False 167 | 168 | if len(data) < sector_length + 2: 169 | if ms: 170 | self.trace(chs, "short", len(data), sector_length + 2) 171 | good=False 172 | flags.append("Short") 173 | elif ms and ms.has_flag('unused'): 174 | good=True 175 | flags.append("unused") 176 | elif not self.good_checksum(data, sector_length): 177 | if ms: 178 | self.trace(chs, "bad checksum", data.hex()) 179 | good=False 180 | flags.append("SumError") 181 | 182 | if good: 183 | self.did_read_sector( 184 | stream, 185 | "0", 186 | chs, 187 | data[:sector_length], 188 | flags=flags, 189 | ) 190 | if not self.defined_chs((0, 0, 0)): 191 | self.define_sector((0, 0, 0), 40) 192 | if good and chs[0] == 0: 193 | self.catalog_entry(chs, data) 194 | 195 | return good 196 | 197 | def sector_status(self, media_sector): 198 | x = media_sector.sector_status() 199 | i, j, k = media_sector.sector_status() 200 | unused = media_sector.has_flag("unused") 201 | if j == 'x' and unused: 202 | j = 'u' 203 | if j == '╬' and unused: 204 | j = 'ü' 205 | if unused: 206 | i = True 207 | return i, j, k 208 | 209 | def picture(self, *args, **kwargs): 210 | yield from self.picture_sec_x(*args, **kwargs) 211 | 212 | def pic_sec_x_line(self, cyl_no, head_no): 213 | l = super().pic_sec_x_line(cyl_no, head_no) 214 | l.insert(1, self.cyl_contains[cyl_no].ljust(10)) 215 | return l 216 | 217 | def guess_sector_length(self, stream, later, conv): 218 | # We dont know the sector lenght for this track (yet): Try to guess it 219 | 220 | # Find the most common flux-length for data part 221 | 222 | common_length = most_common(len(x[1][1])//16 for x in later) 223 | self.trace("Most common length", common_length) 224 | 225 | # Convert from MFM to bytes and locate the last 0x10 value 226 | sectors = [] 227 | tens = [] 228 | retval = False 229 | for chs, parts in later: 230 | data = conv(parts[1][:(common_length+2) * 16]) 231 | sectors.append((chs, data)) 232 | ten_pos = data.rfind(b'\x10') 233 | 234 | if ten_pos > 0: 235 | tens.append(ten_pos) 236 | 237 | if tens: 238 | sector_length = most_common(tens) - 1 239 | self.trace("Sector_length", sector_length) 240 | for chs, data in sectors: 241 | #self.trace(chs, data.hex()) 242 | if self.attempt_sector(chs, data, stream, None, sector_length): 243 | retval = True 244 | return retval 245 | 246 | def metadata_media_description(self): 247 | yield "" 248 | yield "Catalog Entries:" 249 | yield "\tName Used Rec-Len Alloc Tracks" 250 | yield "\t-------- ---- ------- ----- ------" 251 | for i, j in sorted(self.catalog_entries.items()): 252 | l = [ 253 | j[1].decode('ascii').ljust(8), 254 | "", 255 | "%4d" % j[2], 256 | "", 257 | "%7d" % j[3], 258 | "", 259 | "%5d" % j[4], 260 | "", 261 | ("%d-%d" % (j[5], j[6])).rjust(6), 262 | ] 263 | yield "\t" + " ".join(l) 264 | 265 | class Q1MicroLiteFM(Q1MicroLiteCommon): 266 | ''' 267 | Q1 Corporation MicroLite FM format floppy disks 268 | 269 | Bla 270 | 271 | FOo 272 | ''' 273 | 274 | SYNC = '|---' * 16 275 | AM_PATTERN = SYNC + fluxstream.make_mark_fm(0xc7, 0xfe) 276 | DATA_PATTERN = SYNC + fluxstream.make_mark_fm(0xc7, 0xfb) 277 | GAPLEN = 100*32 278 | 279 | def good_checksum(self, data, sector_length): 280 | csum = sum(data[:sector_length + 1]) & 0xff 281 | return csum == 0 282 | 283 | def am_to_chs(self, stream, flux): 284 | am_data = stream.flux_data_fm(flux[:6*32]) 285 | if len(am_data) != 6: 286 | return None 287 | if am_data[0] != 0x00: 288 | return None 289 | if am_data[1] != 0x00: 290 | return None 291 | if am_data[5] != 0x10: 292 | return None 293 | if sum(am_data[:5]) & 0xff: 294 | return None 295 | return (am_data[2], 0, am_data[3]) 296 | 297 | def process_stream(self, stream): 298 | ''' process a stream ''' 299 | 300 | if stream.chs[1] != 0: 301 | return None 302 | later = [] 303 | retval = False 304 | 305 | flux = stream.fm_flux() 306 | for parts in self.split_stream(flux): 307 | chs = self.am_to_chs(stream, parts[0]) 308 | if chs is None: 309 | continue 310 | ms, sector_length = self.sector_length(stream, chs) 311 | 312 | if sector_length: 313 | data = stream.flux_data_fm(parts[1][:(sector_length+2)*32]) 314 | if self.attempt_sector( 315 | chs, 316 | data, 317 | stream, 318 | ms, 319 | sector_length, 320 | ): 321 | retval = True 322 | else: 323 | later.append((chs, parts)) 324 | 325 | if later and self.guess_sector_length(stream, later, stream.flux_data_fm): 326 | retval = True 327 | return retval 328 | 329 | class ClockRecoveryMFM(fluxstream.ClockRecovery): 330 | ''' MFM at non-standard rate ''' 331 | 332 | def __init__(self, dt=None): 333 | if dt is None: 334 | dt = 28 335 | self.SPEC = {} 336 | self.SPEC[2*dt] = "-|" 337 | self.SPEC[3*dt] = "--|" 338 | self.SPEC[4*dt] = "---|" 339 | self.SPEC[5*dt] = "----|" 340 | self.SPEC[6*dt] = "-----|" 341 | 342 | 343 | class Q1MicroLiteMFM28(Q1MicroLiteCommon): 344 | ''' 345 | Q1 Corporation MicroLite MFM format floppy disks 346 | ''' 347 | 348 | SYNC = '|-' * 8 + '---|-' 349 | AM_PATTERN = SYNC + fluxstream.make_mark(0x20, 0x9e) 350 | DATA_PATTERN = SYNC + fluxstream.make_mark(0x20, 0x9b) 351 | GAPLEN = 10*16 352 | 353 | CLOCK = 28 354 | 355 | def am_to_chs(self, stream, flux): 356 | am_data = stream.flux_data_mfm(flux[:4*16]) 357 | if len(am_data) != 4: 358 | return None 359 | if am_data[3] != 0x10: 360 | return None 361 | if (am_data[0] + am_data[1]) & 0xff != am_data[2]: 362 | return None 363 | return (am_data[0], 0, am_data[1]) 364 | 365 | def good_checksum(self, data, sector_length): 366 | csum = (0x9b + sum(data[:sector_length])) & 0xff 367 | return csum == data[sector_length] 368 | 369 | def process_stream(self, stream): 370 | ''' process a stream ''' 371 | 372 | if stream.chs[1] != 0: 373 | return None 374 | later = [] 375 | retval = False 376 | 377 | flux = ClockRecoveryMFM(self.CLOCK).process(stream.iter_dt()) 378 | for parts in self.split_stream(flux): 379 | chs = self.am_to_chs(stream, parts[0]) 380 | if chs is None: 381 | continue 382 | 383 | ms, sector_length = self.sector_length(stream, chs) 384 | 385 | if sector_length: 386 | data = stream.flux_data_mfm(parts[1][:(sector_length+2) * 16]) 387 | if self.attempt_sector( 388 | chs, 389 | data, 390 | stream, 391 | ms, 392 | sector_length, 393 | ): 394 | retval = True 395 | else: 396 | later.append((chs, parts)) 397 | 398 | if later and self.guess_sector_length(stream, later, stream.flux_data_mfm): 399 | retval = True 400 | return retval 401 | 402 | class Q1MicroLiteMFM39(Q1MicroLiteMFM28): 403 | ''' 404 | Q1 Corporation MicroLite MFM format floppy disks 405 | ''' 406 | CLOCK = 39 407 | 408 | ALL = [ 409 | Q1MicroLiteMFM28, 410 | Q1MicroLiteMFM39, 411 | Q1MicroLiteFM, 412 | ] 413 | -------------------------------------------------------------------------------- /floppytools/base/media_abc.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | ''' 4 | Abstract Base Class for disk-like media 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | ''' 7 | 8 | from .chsset import CHSSet 9 | from collections import Counter 10 | 11 | class ReadSector(): 12 | ''' One reading of a sector ''' 13 | 14 | def __init__(self, source, rel_pos, am_chs, octets, flags=(), good=True, phys_chs=None): 15 | assert len(am_chs) == 3 16 | if phys_chs is None: 17 | phys_chs = source.chs 18 | self.rel_pos = rel_pos 19 | self.am_chs = am_chs 20 | self.phys_chs = (phys_chs[0], phys_chs[1], am_chs[2]) 21 | self.octets = octets 22 | 23 | # we dont want to hold on to the source and all it's bits 24 | if hasattr(source, "serialize"): 25 | self.source = source.serialize() 26 | else: 27 | self.source = str(source) 28 | 29 | self.good = good 30 | self.flags = set(flags) 31 | if not good: 32 | self.flags.add("bad") 33 | 34 | def __str__(self): 35 | return str(("ReadSector", self.phys_chs, self.am_chs, self.good, len(self.octets), self.flags)) 36 | 37 | def __eq__(self, other): 38 | return self.octets == other.octets and self.good == other.good 39 | 40 | def __len__(self): 41 | return len(self.octets) 42 | 43 | class MediaSector(): 44 | 45 | ''' What we know about a sector on the media ''' 46 | 47 | def __init__(self, am_chs, phys_chs, sector_length=None): 48 | assert am_chs is None or len(am_chs) == 3 49 | assert len(phys_chs) == 3 50 | self.am_chs = am_chs 51 | if am_chs is not None: 52 | phys_chs = (phys_chs[0], phys_chs[1], am_chs[2]) 53 | self.phys_chs = phys_chs 54 | self.readings = [] 55 | self.values = {} 56 | self.sector_length = sector_length 57 | self.lengths = set() 58 | self.flags = set() 59 | self.status_cache = {} 60 | 61 | def __str__(self): 62 | return str(("MediaSector", self.phys_chs, self.sector_length, self.flags)) 63 | 64 | def __lt__(self, other): 65 | return self.phys_chs < other.phys_chs 66 | 67 | def set_flag(self, flag): 68 | self.flags.add(flag) 69 | 70 | def has_flag(self, flag): 71 | return flag in self.flags 72 | 73 | def add_read_sector(self, read_sector): 74 | ''' Add a reading of this sector ''' 75 | 76 | if self.am_chs is None: 77 | self.am_chs = read_sector.am_chs 78 | assert read_sector.am_chs == self.am_chs 79 | assert read_sector.phys_chs == self.phys_chs 80 | self.readings.append(read_sector) 81 | i = self.values.get(read_sector.octets) 82 | if i is None: 83 | i = [] 84 | self.values[read_sector.octets] = i 85 | i.append(read_sector) 86 | self.lengths.add(len(read_sector.octets)) 87 | if len(self.lengths) == 1: 88 | self.sector_length = len(read_sector.octets) 89 | else: 90 | self.sector_length = None 91 | self.flags |= read_sector.flags 92 | self.status_cache = {} 93 | 94 | def find_majority(self): 95 | t = self.status_cache.get("majority") 96 | if t: 97 | return t 98 | chosen = None 99 | majority = 0 100 | count = 0 101 | for i, j in self.values.items(): 102 | if self.sector_length and len(i) != self.sector_length: 103 | continue 104 | count += 1 105 | if len(j) > majority: 106 | majority = len(j) 107 | chosen = i 108 | minority = count - majority 109 | retval = None 110 | if majority > 2 * minority: 111 | retval = chosen 112 | self.status_cache["majority"] = retval 113 | return retval 114 | 115 | def real_sector_status(self, vert=False): 116 | ''' Report status and visual aid ''' 117 | 118 | if len(self.values) == 0: 119 | return False, 'x', None 120 | maj = self.find_majority() 121 | if len(self.values) > 1 and maj: 122 | return True, "░", len(maj) 123 | if len(self.values) > 1: 124 | return False, '╬', None 125 | if self.sector_length: 126 | k = list(self.values.keys())[0] 127 | if len(k) > self.sector_length: 128 | return False, '>', len(maj) 129 | if len(k) < self.sector_length: 130 | return False, '<', len(maj) 131 | if vert: 132 | # 01234567 133 | return True, "×▏▎▌▋▊▉█"[min(len(self.readings), 7)], len(maj) 134 | else: 135 | return True, "×▁▂▃▄▅▆▇█"[min(len(self.readings), 8)], len(maj) 136 | 137 | def sector_status(self, **kwargs): 138 | ''' Report status and visual aid ''' 139 | 140 | i = self.status_cache.get("sector") 141 | if not i: 142 | i = self.real_sector_status(**kwargs) 143 | self.status_cache["sector"] = i 144 | return i 145 | 146 | class MediaAbc(): 147 | ''' 148 | An abstract disk-like media. 149 | 150 | This is the superclass by the actual formats 151 | ''' 152 | 153 | def __init__(self, name=None): 154 | if name is None: 155 | name = self.__class__.__name__ 156 | self.name = name 157 | self.sectors = {} 158 | self.cyl_no = set() 159 | self.hd_no = set() 160 | self.sec_no = set() 161 | self.lengths = set() 162 | self.messages = set() 163 | self.n_expected = 0 164 | self.status_cache = {} 165 | self.goodset = set() 166 | self.weird_ams = 0 167 | 168 | def __str__(self): 169 | return "{MEDIA " + self.__class__.__name__ + " " + self.name + "}" 170 | 171 | def __getitem__(self, chs): 172 | return self.sectors.get(chs) 173 | 174 | def message(self, *args): 175 | txt = " ".join(str(x) for x in args) 176 | if txt in self.messages: 177 | return txt 178 | self.messages.add(txt) 179 | return txt 180 | 181 | def get_sector(self, chs): 182 | assert len(chs) == 3 183 | ms = self.sectors.get(chs) 184 | if ms is None: 185 | ms = MediaSector(chs) 186 | self.sectors[chs] = ms 187 | return ms 188 | 189 | def add_read_sector(self, rs): 190 | assert len(rs.am_chs) == 3 191 | assert len(rs.phys_chs) == 3 192 | if rs.am_chs != rs.phys_chs: 193 | self.weird_ams += 1 194 | if rs.phys_chs not in self.sectors: 195 | self.sectors[rs.phys_chs] = MediaSector(rs.am_chs, rs.phys_chs) 196 | self.trace("AMS", rs.phys_chs, rs.am_chs, self.sectors[rs.phys_chs]) 197 | self.sectors[rs.phys_chs].add_read_sector(rs) 198 | self.cyl_no.add(rs.phys_chs[0]) 199 | self.hd_no.add(rs.phys_chs[1]) 200 | self.sec_no.add(rs.phys_chs[2]) 201 | self.lengths.add(len(rs)) 202 | self.status_cache = {} 203 | 204 | def define_sector(self, chs, sector_length=None): 205 | ms = self.sectors.get(chs) 206 | if ms is None: 207 | ms = MediaSector(None, chs, sector_length) 208 | self.sectors[chs] = ms 209 | if not ms.has_flag("defined"): 210 | ms.sector_length = sector_length 211 | ms.set_flag("defined") 212 | self.n_expected += 1 213 | elif ms.sector_length is None: 214 | ms.sector_length = sector_length 215 | else: 216 | if ms.sector_length != sector_length: 217 | self.trace("Different defined sector lengths", chs, sector_length, ms) 218 | self.message("SECTOR_LENGTH_CONFUSION") 219 | self.cyl_no.add(chs[0]) 220 | self.hd_no.add(chs[1]) 221 | self.sec_no.add(chs[2]) 222 | return ms 223 | 224 | def picture(self): 225 | if not self.hd_no or not self.cyl_no: 226 | return 227 | if max(self.sec_no) > 32: 228 | yield from self.picture_sec_x() 229 | else: 230 | yield from self.picture_sec_y() 231 | 232 | def sector_status(self, media_sector, **kwargs): 233 | return media_sector.sector_status(**kwargs) 234 | 235 | def pic_sec_x_line(self, cyl_no, head_no): 236 | l = [] 237 | if len(self.hd_no) == 1: 238 | l.append("%4d " % cyl_no) 239 | else: 240 | l.append("%4d,%2d " % (cyl_no, head_no)) 241 | lens = [] 242 | nsec = 0 243 | for sec_no in range(min(self.sec_no), max(self.sec_no) + 1): 244 | ms = self.sectors.get((cyl_no, head_no, sec_no)) 245 | if ms is None: 246 | l.append(' ') 247 | else: 248 | nsec += 1 249 | _i, j, k = self.sector_status(ms) 250 | l.append(j) 251 | if k is not None: 252 | lens.append(k) 253 | if lens: 254 | i = Counter(lens).most_common() 255 | l.insert(1, ("%d*%d" % (nsec, i[0][0])).ljust(9)) 256 | else: 257 | l.insert(1, " " * 9) 258 | return l 259 | 260 | def pic_sec_y_line(self, head_no, sec_no): 261 | l = [] 262 | l.append("%2d " % sec_no) 263 | lens = [] 264 | nsec = 0 265 | for cyl_no in range(min(self.cyl_no), max(self.cyl_no) + 1): 266 | ms = self.sectors.get((cyl_no, head_no, sec_no)) 267 | if ms is None: 268 | l.append(' ') 269 | else: 270 | nsec += 1 271 | _i, j, k = self.sector_status(ms, vert=True) 272 | l.append(j) 273 | if k is not None: 274 | lens.append(k) 275 | return l 276 | if lens: 277 | i = Counter(lens).most_common() 278 | l.insert(1, ("%d*%d" % (nsec, i[0][0])).ljust(9)) 279 | else: 280 | l.insert(1, " " * 9) 281 | 282 | def picture_sec_x(self): 283 | l = [] 284 | for cyl_no in range(min(self.cyl_no), max(self.cyl_no) + 1): 285 | l.append(list()) 286 | for head_no in range(min(self.hd_no), max(self.hd_no) + 1): 287 | l[-1].append("".join(self.pic_sec_x_line(cyl_no, head_no))) 288 | w = [] 289 | for col in range(len(l[0])): 290 | w.append(max(len(x[col]) for x in l)) 291 | for line in l: 292 | x = [] 293 | for width,col in zip(w, line): 294 | x.append(col.ljust(width + 3)) 295 | yield "".join(x).rstrip() 296 | 297 | def picture_sec_y(self): 298 | l1 = [] 299 | l2 = [] 300 | for cyl_no in range(min(self.cyl_no), max(self.cyl_no) + 1): 301 | l2.append("%d" % (cyl_no % 10)) 302 | if l2[-1] == '0': 303 | l1.append("%d" % (cyl_no // 10)) 304 | else: 305 | l1.append(" ") 306 | 307 | 308 | for hd_no in sorted(self.hd_no): 309 | yield " " + "".join(l1) 310 | yield "h%d " % hd_no + "".join(l2) 311 | for sec_no in range(min(self.sec_no), max(self.sec_no) + 1): 312 | yield "".join(self.pic_sec_y_line(hd_no, sec_no)) 313 | 314 | def missing(self): 315 | why = {} 316 | for ms in self.sectors.values(): 317 | i, j, k = self.sector_status(ms) 318 | if i: 319 | continue 320 | if j not in why: 321 | why[j] = CHSSet() 322 | why[j].add(ms.phys_chs) 323 | for i, j in sorted(why.items()): 324 | for x in j: 325 | yield i, x 326 | 327 | def summary(self, long=False): 328 | retval = self.status_cache.get("summary") 329 | if retval is None: 330 | ngood = 0 331 | nextra = 0 332 | badones = {} 333 | goodset = CHSSet() 334 | badset = CHSSet() 335 | l = [ self.name ] 336 | for ms in self.sectors.values(): 337 | i, j, k = self.sector_status(ms) 338 | defd = ms.has_flag("defined") 339 | if i and defd: 340 | ngood += 1 341 | goodset.add(ms.phys_chs) 342 | elif i: 343 | nextra += 1 344 | goodset.add(ms.phys_chs, payload=ms.sector_length) 345 | else: 346 | badset.add(ms.phys_chs) 347 | if j not in badones: 348 | badones[j] = [] 349 | badones[j].append(ms) 350 | if ngood == 0 and nextra == 0: 351 | l.append("NOTHING") 352 | elif self.n_expected and ngood == self.n_expected: 353 | l.append("COMPLETE") 354 | if nextra: 355 | l.append("EXTRA") 356 | else: 357 | l.append("✓: %d " % len(goodset)) 358 | if False: 359 | if not self.n_expected and not badones: 360 | l.append("SOMETHING") 361 | l.append("") 362 | for x in goodset: 363 | if len(l[-1]) + len(x) < 64: 364 | l[-1] += "," + x 365 | else: 366 | l[-1] += "[…]" 367 | break 368 | l[-1] = l[-1][1:] 369 | else: 370 | l.append("MISSING") 371 | l.append(badset.cylinders()) 372 | for c in sorted(badones): 373 | l.append(c + ": %d" % len(badones[c])) 374 | if self.weird_ams: 375 | l.append("AM!%d" % self.weird_ams) 376 | retval = " ".join(l) 377 | self.goodset = goodset 378 | self.status_cache["summary"] = retval 379 | if long: 380 | for x in self.goodset: 381 | retval += "\n\t" + x 382 | return retval 383 | 384 | def any_good(self): 385 | for ms in sorted(self.sectors.values()): 386 | i, _j, k = self.sector_status(ms) 387 | if i: 388 | return True 389 | return False 390 | 391 | def process_stream(self, _source): 392 | ''' ... ''' 393 | print(self, "lacks process_stream method") 394 | assert False 395 | --------------------------------------------------------------------------------