diskstack 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- diskstack/__init__.py +5 -0
- diskstack/_vendor/__init__.py +0 -0
- diskstack/_vendor/greaseweazle/COPYING +24 -0
- diskstack/_vendor/greaseweazle/__init__.py +2 -0
- diskstack/_vendor/greaseweazle/codec/__init__.py +0 -0
- diskstack/_vendor/greaseweazle/codec/amiga/__init__.py +0 -0
- diskstack/_vendor/greaseweazle/codec/amiga/amigados.py +221 -0
- diskstack/_vendor/greaseweazle/codec/apple2/__init__.py +0 -0
- diskstack/_vendor/greaseweazle/codec/apple2/apple2_gcr.py +223 -0
- diskstack/_vendor/greaseweazle/codec/bitcell.py +117 -0
- diskstack/_vendor/greaseweazle/codec/codec.py +362 -0
- diskstack/_vendor/greaseweazle/codec/commodore/__init__.py +0 -0
- diskstack/_vendor/greaseweazle/codec/commodore/c64_gcr.py +229 -0
- diskstack/_vendor/greaseweazle/codec/datageneral/__init__.py +0 -0
- diskstack/_vendor/greaseweazle/codec/datageneral/datageneral.py +211 -0
- diskstack/_vendor/greaseweazle/codec/hp/__init__.py +0 -0
- diskstack/_vendor/greaseweazle/codec/hp/hp_mmfm.py +258 -0
- diskstack/_vendor/greaseweazle/codec/ibm/__init__.py +0 -0
- diskstack/_vendor/greaseweazle/codec/ibm/ibm.py +1005 -0
- diskstack/_vendor/greaseweazle/codec/macintosh/__init__.py +0 -0
- diskstack/_vendor/greaseweazle/codec/macintosh/mac_gcr.py +238 -0
- diskstack/_vendor/greaseweazle/codec/micropolis/__init__.py +0 -0
- diskstack/_vendor/greaseweazle/codec/micropolis/micropolis.py +233 -0
- diskstack/_vendor/greaseweazle/codec/northstar/__init__.py +0 -0
- diskstack/_vendor/greaseweazle/codec/northstar/northstar.py +210 -0
- diskstack/_vendor/greaseweazle/data/__init__.py +0 -0
- diskstack/_vendor/greaseweazle/data/diskdefs.cfg +81 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_acorn.cfg +119 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_akai.cfg +27 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_amiga.cfg +17 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_apple2.cfg +31 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_apricot.cfg +25 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_atari.cfg +33 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_atarist.cfg +76 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_coco.cfg +98 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_commodore.cfg +127 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_datageneral.cfg +8 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_dec.cfg +37 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_dragon.cfg +61 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_eagle.cfg +25 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_ensoniq.cfg +39 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_epson.cfg +95 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_gem.cfg +15 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_hp.cfg +25 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_ibm.cfg +140 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_kaypro.cfg +58 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_luxor.cfg +96 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_mac.cfg +71 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_micropolis.cfg +73 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_mm1.cfg +69 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_msx.cfg +45 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_northstar.cfg +37 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_occ1.cfg +23 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_olivetti.cfg +18 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_pc98.cfg +69 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_raw.cfg +25 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_rm.cfg +98 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_sci.cfg +15 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_sega.cfg +13 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_sharp.cfg +13 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_thomson.cfg +78 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_tsc.cfg +81 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_xerox.cfg +59 -0
- diskstack/_vendor/greaseweazle/data/diskdefs_zx.cfg +245 -0
- diskstack/_vendor/greaseweazle/error.py +19 -0
- diskstack/_vendor/greaseweazle/flux.py +315 -0
- diskstack/_vendor/greaseweazle/image/__init__.py +0 -0
- diskstack/_vendor/greaseweazle/image/a2r.py +133 -0
- diskstack/_vendor/greaseweazle/image/acorn.py +28 -0
- diskstack/_vendor/greaseweazle/image/adf.py +15 -0
- diskstack/_vendor/greaseweazle/image/apple2.py +18 -0
- diskstack/_vendor/greaseweazle/image/apridisk.py +107 -0
- diskstack/_vendor/greaseweazle/image/caps.py +464 -0
- diskstack/_vendor/greaseweazle/image/d64.py +51 -0
- diskstack/_vendor/greaseweazle/image/d81.py +28 -0
- diskstack/_vendor/greaseweazle/image/d88.py +211 -0
- diskstack/_vendor/greaseweazle/image/dcp.py +45 -0
- diskstack/_vendor/greaseweazle/image/dim.py +53 -0
- diskstack/_vendor/greaseweazle/image/dmk.py +166 -0
- diskstack/_vendor/greaseweazle/image/dsk.py +31 -0
- diskstack/_vendor/greaseweazle/image/edsk.py +591 -0
- diskstack/_vendor/greaseweazle/image/fd.py +23 -0
- diskstack/_vendor/greaseweazle/image/fdi.py +41 -0
- diskstack/_vendor/greaseweazle/image/hdm.py +15 -0
- diskstack/_vendor/greaseweazle/image/hfe.py +742 -0
- diskstack/_vendor/greaseweazle/image/image.py +130 -0
- diskstack/_vendor/greaseweazle/image/imd.py +201 -0
- diskstack/_vendor/greaseweazle/image/img.py +111 -0
- diskstack/_vendor/greaseweazle/image/kryoflux.py +304 -0
- diskstack/_vendor/greaseweazle/image/mgt.py +15 -0
- diskstack/_vendor/greaseweazle/image/msa.py +153 -0
- diskstack/_vendor/greaseweazle/image/nfd.py +113 -0
- diskstack/_vendor/greaseweazle/image/nsi.py +47 -0
- diskstack/_vendor/greaseweazle/image/scp.py +471 -0
- diskstack/_vendor/greaseweazle/image/sf7.py +15 -0
- diskstack/_vendor/greaseweazle/image/sharp2d.py +15 -0
- diskstack/_vendor/greaseweazle/image/td0.py +141 -0
- diskstack/_vendor/greaseweazle/image/xdf.py +15 -0
- diskstack/_vendor/greaseweazle/optimised/__init__.py +22 -0
- diskstack/_vendor/greaseweazle/tools/__init__.py +0 -0
- diskstack/_vendor/greaseweazle/tools/util.py +133 -0
- diskstack/_vendor/greaseweazle/track.py +494 -0
- diskstack/cache.py +210 -0
- diskstack/candidates.py +396 -0
- diskstack/cli.py +348 -0
- diskstack/errors.py +5 -0
- diskstack/filler.py +29 -0
- diskstack/formats.py +381 -0
- diskstack/parallel.py +73 -0
- diskstack/report.py +353 -0
- diskstack/stack.py +317 -0
- diskstack-1.0.0.dist-info/METADATA +329 -0
- diskstack-1.0.0.dist-info/RECORD +119 -0
- diskstack-1.0.0.dist-info/WHEEL +5 -0
- diskstack-1.0.0.dist-info/entry_points.txt +2 -0
- diskstack-1.0.0.dist-info/licenses/LICENSE +21 -0
- diskstack-1.0.0.dist-info/licenses/NOTICE +55 -0
- diskstack-1.0.0.dist-info/licenses/diskstack/_vendor/greaseweazle/COPYING +24 -0
- diskstack-1.0.0.dist-info/top_level.txt +1 -0
diskstack/cache.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Keep decoded flux between runs, so the loop only decodes what is new.
|
|
2
|
+
|
|
3
|
+
Adding one capture to a stack of three re-decodes all four, and the PLL
|
|
4
|
+
walking the flux is nearly the whole runtime. An entry is named for a digest
|
|
5
|
+
of the input's bytes together with every setting that changes the decode, so a
|
|
6
|
+
stale entry is never found rather than being found and then discarded.
|
|
7
|
+
|
|
8
|
+
The file is a plain record of sector payloads, not a pickle: a cache sitting
|
|
9
|
+
in a working directory should not be able to run code when it is read.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import struct
|
|
18
|
+
import zlib
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Iterator, List, Optional, Sequence, Tuple
|
|
21
|
+
|
|
22
|
+
from diskstack import __version__
|
|
23
|
+
from diskstack.candidates import Candidate, SourceInfo
|
|
24
|
+
|
|
25
|
+
DIR_NAME = '.diskstack-cache'
|
|
26
|
+
SUFFIX = '.dsc'
|
|
27
|
+
NOTE_SUFFIX = '.dsn'
|
|
28
|
+
MAGIC = b'DSKC'
|
|
29
|
+
FORMAT_VERSION = 1
|
|
30
|
+
MAX_BYTES = 512 * 1024 * 1024
|
|
31
|
+
|
|
32
|
+
_HEADER = struct.Struct('<4sHI')
|
|
33
|
+
# cyl, head, sector id, revolution, crc flags, codec, address mark, lengths.
|
|
34
|
+
_RECORD = struct.Struct('<HBHHBBBHB')
|
|
35
|
+
_BLOCK = 1 << 20
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def settings(fmt_name: str, pll, revs: Optional[int],
|
|
39
|
+
detect_filler: bool) -> str:
|
|
40
|
+
"""Everything besides the input bytes that changes what a decode yields.
|
|
41
|
+
|
|
42
|
+
``str(pll)`` is Greaseweazle's own summary of the three knobs the PLL has,
|
|
43
|
+
so two ways of spelling one setting share an entry.
|
|
44
|
+
"""
|
|
45
|
+
return '\n'.join([__version__, str(FORMAT_VERSION), fmt_name,
|
|
46
|
+
'default' if pll is None else str(pll),
|
|
47
|
+
'all' if revs is None else str(revs),
|
|
48
|
+
str(bool(detect_filler))])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def key(paths: Sequence[Path], spec: str) -> str:
|
|
52
|
+
"""Name for the entry holding these inputs decoded with these settings.
|
|
53
|
+
|
|
54
|
+
The bytes are hashed rather than the size and timestamp. Re-dumping a
|
|
55
|
+
disk over its own file is the normal move in this loop, and a cache that
|
|
56
|
+
hands back the previous decode of it would be worse than no cache at all.
|
|
57
|
+
"""
|
|
58
|
+
digest = hashlib.blake2b(digest_size=16)
|
|
59
|
+
digest.update(spec.encode('utf-8'))
|
|
60
|
+
for path in paths:
|
|
61
|
+
digest.update(f'\n{path.name}\n'.encode('utf-8'))
|
|
62
|
+
with path.open('rb') as f:
|
|
63
|
+
for block in iter(lambda: f.read(_BLOCK), b''):
|
|
64
|
+
digest.update(block)
|
|
65
|
+
return digest.hexdigest()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _records(blob: bytes, path: Path,
|
|
69
|
+
codecs: Sequence[str]) -> Iterator[Candidate]:
|
|
70
|
+
pos, end = 0, len(blob)
|
|
71
|
+
while pos < end:
|
|
72
|
+
(cyl, head, sec_id, rev, flags, codec, mark, n_data,
|
|
73
|
+
n_check) = _RECORD.unpack_from(blob, pos)
|
|
74
|
+
pos += _RECORD.size
|
|
75
|
+
data, pos = blob[pos:pos + n_data], pos + n_data
|
|
76
|
+
check, pos = blob[pos:pos + n_check], pos + n_check
|
|
77
|
+
if len(data) != n_data or len(check) != n_check:
|
|
78
|
+
raise ValueError('truncated entry')
|
|
79
|
+
yield Candidate(cyl=cyl, head=head, sec_id=sec_id, data=data,
|
|
80
|
+
id_crc_ok=bool(flags & 1), data_crc_ok=bool(flags & 2),
|
|
81
|
+
source=path, rev=rev, codec=codecs[codec],
|
|
82
|
+
check=check, mark=mark)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _parse(raw: bytes, path: Path) -> Tuple[List[Candidate], SourceInfo]:
|
|
86
|
+
magic, version, meta_len = _HEADER.unpack_from(raw)
|
|
87
|
+
if magic != MAGIC or version != FORMAT_VERSION:
|
|
88
|
+
raise ValueError('not a diskstack cache entry')
|
|
89
|
+
start = _HEADER.size + meta_len
|
|
90
|
+
meta = json.loads(raw[_HEADER.size:start].decode('utf-8'))
|
|
91
|
+
info = SourceInfo(path=path, kind=meta['kind'],
|
|
92
|
+
revolutions=meta['revolutions'],
|
|
93
|
+
candidates=meta['candidates'], good=meta['good'],
|
|
94
|
+
unexpected=[tuple(u) for u in meta['unexpected']],
|
|
95
|
+
size=meta['size'], cached=True)
|
|
96
|
+
cands = list(_records(zlib.decompress(raw[start:]), path, meta['codecs']))
|
|
97
|
+
return cands, info
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def load(directory: Path, name: str,
|
|
101
|
+
path: Path) -> Optional[Tuple[List[Candidate], SourceInfo]]:
|
|
102
|
+
"""The decode stored under ``name``, or None if there is not a usable one.
|
|
103
|
+
|
|
104
|
+
``path`` replaces the one recorded, so a capture that has been renamed or
|
|
105
|
+
moved since it was decoded still hits.
|
|
106
|
+
"""
|
|
107
|
+
entry = directory / (name + SUFFIX)
|
|
108
|
+
try:
|
|
109
|
+
raw = entry.read_bytes()
|
|
110
|
+
except OSError:
|
|
111
|
+
return None
|
|
112
|
+
try:
|
|
113
|
+
cands, info = _parse(raw, path)
|
|
114
|
+
except (ValueError, KeyError, IndexError, struct.error, zlib.error,
|
|
115
|
+
UnicodeDecodeError):
|
|
116
|
+
return None
|
|
117
|
+
try:
|
|
118
|
+
os.utime(entry) # Read counts as use: _prune drops what nothing reads.
|
|
119
|
+
except OSError:
|
|
120
|
+
pass
|
|
121
|
+
return cands, info
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _put(directory: Path, filename: str, blob: bytes) -> bool:
|
|
125
|
+
"""Write one cache file, or say it could not be written.
|
|
126
|
+
|
|
127
|
+
A cache that will not write is not an error. The merge is the product; the
|
|
128
|
+
cache only makes the next one quick.
|
|
129
|
+
"""
|
|
130
|
+
tmp = directory / f'{filename}.{os.getpid()}.tmp'
|
|
131
|
+
try:
|
|
132
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
133
|
+
tmp.write_bytes(blob)
|
|
134
|
+
os.replace(tmp, directory / filename)
|
|
135
|
+
except OSError:
|
|
136
|
+
# Cleaning up can fail for the same reason the write did: on Linux a
|
|
137
|
+
# cache directory that is really a file makes unlink raise ENOTDIR.
|
|
138
|
+
try:
|
|
139
|
+
tmp.unlink(missing_ok=True)
|
|
140
|
+
except OSError:
|
|
141
|
+
pass
|
|
142
|
+
return False
|
|
143
|
+
return True
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def note(directory: Path, name: str, value: dict) -> None:
|
|
147
|
+
"""Remember a small fact about a set of inputs, such as their format."""
|
|
148
|
+
_put(directory, name + NOTE_SUFFIX,
|
|
149
|
+
json.dumps(value).encode('utf-8'))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def recall(directory: Path, name: str) -> Optional[dict]:
|
|
153
|
+
"""The fact stored under ``name``, or None if there is not a usable one."""
|
|
154
|
+
try:
|
|
155
|
+
value = json.loads((directory / (name + NOTE_SUFFIX)).read_text('utf-8'))
|
|
156
|
+
except (OSError, ValueError):
|
|
157
|
+
return None
|
|
158
|
+
return value if isinstance(value, dict) else None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def store(directory: Path, name: str, cands: Sequence[Candidate],
|
|
162
|
+
info: SourceInfo) -> None:
|
|
163
|
+
"""Record a decode under ``name``."""
|
|
164
|
+
codecs = sorted({c.codec for c in cands})
|
|
165
|
+
index = {codec: i for i, codec in enumerate(codecs)}
|
|
166
|
+
body = bytearray()
|
|
167
|
+
for c in cands:
|
|
168
|
+
body += _RECORD.pack(c.cyl, c.head, c.sec_id, c.rev,
|
|
169
|
+
c.id_crc_ok | (c.data_crc_ok << 1),
|
|
170
|
+
index[c.codec], c.mark, len(c.data),
|
|
171
|
+
len(c.check))
|
|
172
|
+
body += c.data + c.check
|
|
173
|
+
meta = json.dumps({'path': info.path.name, 'kind': info.kind,
|
|
174
|
+
'revolutions': info.revolutions,
|
|
175
|
+
'candidates': info.candidates, 'good': info.good,
|
|
176
|
+
'unexpected': info.unexpected, 'size': info.size,
|
|
177
|
+
'codecs': codecs}).encode('utf-8')
|
|
178
|
+
blob = (_HEADER.pack(MAGIC, FORMAT_VERSION, len(meta)) + meta
|
|
179
|
+
+ zlib.compress(bytes(body), 1))
|
|
180
|
+
if _put(directory, name + SUFFIX, blob):
|
|
181
|
+
prune(directory)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def prune(directory: Path, max_bytes: int = MAX_BYTES) -> None:
|
|
185
|
+
"""Drop the least recently used entries once the cache passes its cap.
|
|
186
|
+
|
|
187
|
+
Nothing here is precious: an entry that goes can be rebuilt by decoding
|
|
188
|
+
the flux again, which is what happens for a new capture anyway.
|
|
189
|
+
"""
|
|
190
|
+
entries, total = [], 0
|
|
191
|
+
for entry in (list(directory.glob('*' + SUFFIX))
|
|
192
|
+
+ list(directory.glob('*' + NOTE_SUFFIX))):
|
|
193
|
+
try:
|
|
194
|
+
st = entry.stat()
|
|
195
|
+
except OSError:
|
|
196
|
+
continue
|
|
197
|
+
entries.append((st.st_mtime, st.st_size, entry))
|
|
198
|
+
total += st.st_size
|
|
199
|
+
for _, size, entry in sorted(entries, key=lambda e: e[0]):
|
|
200
|
+
if total <= max_bytes:
|
|
201
|
+
return
|
|
202
|
+
try:
|
|
203
|
+
entry.unlink()
|
|
204
|
+
except OSError:
|
|
205
|
+
continue
|
|
206
|
+
total -= size
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
__all__ = ['DIR_NAME', 'MAX_BYTES', 'key', 'load', 'note', 'prune', 'recall',
|
|
210
|
+
'settings', 'store']
|
diskstack/candidates.py
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""Turn each input file into a stream of per-sector read attempts.
|
|
2
|
+
|
|
3
|
+
One :class:`Candidate` is one attempt at one sector: a single revolution of a
|
|
4
|
+
single flux capture, or a single sector of an already-decoded image. Nothing
|
|
5
|
+
here decides which attempt wins -- that is :mod:`diskstack.stack`.
|
|
6
|
+
|
|
7
|
+
Flux captures are decoded revolution by revolution. Greaseweazle's own codec
|
|
8
|
+
collapses the revolutions as it goes (``codec/ibm/ibm.py``: if two sectors
|
|
9
|
+
start within 1000 bitcells of each other the good one replaces the bad one)
|
|
10
|
+
and defaults to ``default_revs = 2``, so a five-revolution SCP normally yields
|
|
11
|
+
at most two views of each sector. diskstack keeps every one of them.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import bisect
|
|
17
|
+
import itertools
|
|
18
|
+
import struct
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import (Callable, Dict, Iterator, List, Optional, Sequence,
|
|
22
|
+
Set, Tuple)
|
|
23
|
+
|
|
24
|
+
from diskstack._vendor.greaseweazle import error as gw_error
|
|
25
|
+
from diskstack._vendor.greaseweazle.codec import codec as gw_codec
|
|
26
|
+
from diskstack._vendor.greaseweazle.codec.amiga import amigados
|
|
27
|
+
from diskstack._vendor.greaseweazle.codec.ibm import ibm
|
|
28
|
+
from diskstack._vendor.greaseweazle.track import PLL, PLLRevolution, PLLTrack
|
|
29
|
+
|
|
30
|
+
from diskstack.errors import DiskStackError
|
|
31
|
+
from diskstack.filler import is_filler
|
|
32
|
+
|
|
33
|
+
IBM_FM = 'ibm.fm'
|
|
34
|
+
IBM_MFM = 'ibm.mfm'
|
|
35
|
+
AMIGA = 'amiga'
|
|
36
|
+
|
|
37
|
+
_IBM_MODE = {ibm.Mode.FM: IBM_FM, ibm.Mode.MFM: IBM_MFM}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def verify_sector(codec: str, mark: int, data: bytes, check: bytes) -> bool:
|
|
41
|
+
"""True if ``check`` is the correct check value for ``data``.
|
|
42
|
+
|
|
43
|
+
``check`` is the trailing CRC/checksum exactly as it came off the disk, so
|
|
44
|
+
this answers "would a drive have accepted this sector?" for a payload that
|
|
45
|
+
diskstack has reconstructed rather than read.
|
|
46
|
+
"""
|
|
47
|
+
if not check:
|
|
48
|
+
return False
|
|
49
|
+
if codec == AMIGA:
|
|
50
|
+
return check == struct.pack('>I', amigados.checksum(data))
|
|
51
|
+
prefix = b'\xa1\xa1\xa1' if codec == IBM_MFM else b''
|
|
52
|
+
return ibm.crc16.new(prefix + bytes([mark]) + data + check).crcValue == 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class Candidate:
|
|
57
|
+
"""One read attempt at one sector."""
|
|
58
|
+
|
|
59
|
+
cyl: int
|
|
60
|
+
head: int
|
|
61
|
+
sec_id: int
|
|
62
|
+
data: bytes
|
|
63
|
+
id_crc_ok: bool
|
|
64
|
+
data_crc_ok: bool
|
|
65
|
+
source: Path
|
|
66
|
+
rev: int
|
|
67
|
+
codec: str = IBM_MFM
|
|
68
|
+
check: bytes = b''
|
|
69
|
+
mark: int = ibm.Mark.DAM
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def key(self) -> Tuple[int, int, int]:
|
|
73
|
+
return (self.cyl, self.head, self.sec_id)
|
|
74
|
+
|
|
75
|
+
def recheck(self, data: bytes) -> bool:
|
|
76
|
+
"""True if ``data`` satisfies the check value this attempt read."""
|
|
77
|
+
return verify_sector(self.codec, self.mark, data, self.check)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class SourceInfo:
|
|
82
|
+
"""What one input file turned out to be."""
|
|
83
|
+
|
|
84
|
+
path: Path
|
|
85
|
+
kind: str
|
|
86
|
+
revolutions: int = 0
|
|
87
|
+
candidates: int = 0
|
|
88
|
+
good: int = 0
|
|
89
|
+
unexpected: List[Tuple[int, int, int]] = field(default_factory=list)
|
|
90
|
+
size: int = 0
|
|
91
|
+
expected_size: int = 0
|
|
92
|
+
cached: bool = False
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def wrong_size(self) -> bool:
|
|
96
|
+
"""True if a sector image is not the size this disk format implies.
|
|
97
|
+
|
|
98
|
+
The reader simply walks the format's track list, so a dump of a
|
|
99
|
+
different disk is silently truncated or padded rather than rejected.
|
|
100
|
+
"""
|
|
101
|
+
return bool(self.expected_size) and self.size != self.expected_size
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _revolution_index(bounds: Sequence[int], offset: int) -> int:
|
|
105
|
+
return bisect.bisect_right(bounds, offset)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _absolute_areas(raw: PLLTrack, mode) -> List[object]:
|
|
109
|
+
"""Decode a track, keeping bit offsets absolute across all revolutions.
|
|
110
|
+
|
|
111
|
+
Upstream rebases each area onto its own revolution and then discards which
|
|
112
|
+
revolution that was, but diskstack needs both the revolution number and the
|
|
113
|
+
raw bits (for the on-disk CRC, which the decoder drops). Presenting the
|
|
114
|
+
track as a single revolution makes the rebase a no-op.
|
|
115
|
+
"""
|
|
116
|
+
saved = raw.revolutions
|
|
117
|
+
raw.revolutions = [PLLRevolution(sum(r.nr_bits for r in saved))]
|
|
118
|
+
try:
|
|
119
|
+
if mode is ibm.Mode.FM:
|
|
120
|
+
return ibm.IBMTrack.fm_decode_raw(raw)
|
|
121
|
+
return ibm.IBMTrack.mfm_decode_raw(raw)
|
|
122
|
+
finally:
|
|
123
|
+
raw.revolutions = saved
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _pll_track(flux, clock: float, time_per_rev: float,
|
|
127
|
+
pll: Optional[PLL], revs: Optional[int]) -> PLLTrack:
|
|
128
|
+
flux = flux.flux()
|
|
129
|
+
flux.cue_at_index()
|
|
130
|
+
if revs is not None and len(flux.index_list) > revs:
|
|
131
|
+
flux.set_nr_revs(revs)
|
|
132
|
+
return PLLTrack(time_per_rev=time_per_rev, clock=clock, data=flux, pll=pll)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _ibm_flux_candidates(raw: PLLTrack, proto: ibm.IBMTrack, cyl: int,
|
|
136
|
+
head: int, source: Path,
|
|
137
|
+
expected: Dict[int, Tuple[int, int, int]],
|
|
138
|
+
info: SourceInfo) -> Iterator[Candidate]:
|
|
139
|
+
codec_name = _IBM_MODE[proto.mode]
|
|
140
|
+
bits, _ = raw.get_all_data()
|
|
141
|
+
bounds = list(itertools.accumulate(r.nr_bits for r in raw.revolutions))
|
|
142
|
+
for area in _absolute_areas(raw, proto.mode):
|
|
143
|
+
if not isinstance(area, ibm.Sector):
|
|
144
|
+
continue
|
|
145
|
+
idam, dam = area.idam, area.dam
|
|
146
|
+
want = expected.get(idam.r)
|
|
147
|
+
# A failed IDAM CRC may be a corrupt r/c/h/n or just corrupt CRC bytes.
|
|
148
|
+
# Keep the sector if the fields still name a sector this track should
|
|
149
|
+
# have -- its data may be perfect -- and report the rest as unexpected.
|
|
150
|
+
if want is None or (idam.c, idam.h, idam.n) != want:
|
|
151
|
+
info.unexpected.append((idam.c, idam.h, idam.r))
|
|
152
|
+
continue
|
|
153
|
+
check = ibm.decode(bits[dam.end - 32:dam.end].tobytes())
|
|
154
|
+
data = bytes(dam.data)
|
|
155
|
+
info.candidates += 1
|
|
156
|
+
if dam.crc == 0:
|
|
157
|
+
info.good += 1
|
|
158
|
+
yield Candidate(
|
|
159
|
+
cyl=cyl, head=head, sec_id=idam.r, data=data,
|
|
160
|
+
id_crc_ok=idam.crc == 0, data_crc_ok=dam.crc == 0,
|
|
161
|
+
source=source, rev=_revolution_index(bounds, area.start),
|
|
162
|
+
codec=codec_name, check=check, mark=dam.mark)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _amiga_flux_candidates(raw: PLLTrack, proto: amigados.AmigaDOS, cyl: int,
|
|
166
|
+
head: int, source: Path,
|
|
167
|
+
info: SourceInfo) -> Iterator[Candidate]:
|
|
168
|
+
"""Scan a track for AmigaDOS sectors, keeping the ones that fail.
|
|
169
|
+
|
|
170
|
+
Upstream's :meth:`AmigaDOS.decode_flux` stops at the first good copy of
|
|
171
|
+
each sector and drops every sector whose checksum fails, which is exactly
|
|
172
|
+
the material diskstack votes on.
|
|
173
|
+
"""
|
|
174
|
+
bits, _ = raw.get_all_data()
|
|
175
|
+
bounds = list(itertools.accumulate(r.nr_bits for r in raw.revolutions))
|
|
176
|
+
tracknr = cyl * 2 + head
|
|
177
|
+
for offs in bits.search(amigados.sync):
|
|
178
|
+
sec = bits[offs:offs + 544 * 16].tobytes()
|
|
179
|
+
if len(sec) != 1088:
|
|
180
|
+
continue
|
|
181
|
+
header = amigados.decode(sec[4:12])
|
|
182
|
+
fmt_byte, sec_tracknr, sec_id, togo = tuple(header)
|
|
183
|
+
if fmt_byte != 0xff or sec_tracknr != tracknr:
|
|
184
|
+
continue
|
|
185
|
+
if not (sec_id < proto.nsec and 0 < togo <= proto.nsec):
|
|
186
|
+
info.unexpected.append((cyl, head, sec_id))
|
|
187
|
+
continue
|
|
188
|
+
label = amigados.decode(sec[12:44])
|
|
189
|
+
hsum, = struct.unpack('>I', amigados.decode(sec[44:52]))
|
|
190
|
+
check = amigados.decode(sec[52:60])
|
|
191
|
+
data = amigados.decode(sec[60:1084])
|
|
192
|
+
data_ok = check == struct.pack('>I', amigados.checksum(data))
|
|
193
|
+
info.candidates += 1
|
|
194
|
+
if data_ok:
|
|
195
|
+
info.good += 1
|
|
196
|
+
yield Candidate(
|
|
197
|
+
cyl=cyl, head=head, sec_id=sec_id, data=data,
|
|
198
|
+
id_crc_ok=hsum == amigados.checksum(header + label),
|
|
199
|
+
data_crc_ok=data_ok, source=source,
|
|
200
|
+
rev=_revolution_index(bounds, offs),
|
|
201
|
+
codec=AMIGA, check=check)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _expected_ibm(track: ibm.IBMTrack) -> Dict[int, Tuple[int, int, int]]:
|
|
205
|
+
return {s.idam.r: (s.idam.c, s.idam.h, s.idam.n) for s in track.sectors}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def track_candidates(image, fmt: gw_codec.DiskDef, cyl: int, head: int,
|
|
209
|
+
source: Path, info: SourceInfo,
|
|
210
|
+
pll: Optional[PLL] = None,
|
|
211
|
+
revs: Optional[int] = None) -> List[Candidate]:
|
|
212
|
+
"""Every read attempt on one track of a flux capture.
|
|
213
|
+
|
|
214
|
+
This is where a run spends nearly all of its time: the PLL walks the flux
|
|
215
|
+
of every revolution. :mod:`diskstack.parallel` calls it one track per
|
|
216
|
+
worker process.
|
|
217
|
+
"""
|
|
218
|
+
flux = image.get_track(cyl, head)
|
|
219
|
+
if flux is None:
|
|
220
|
+
return []
|
|
221
|
+
proto = fmt.mk_track(cyl, head)
|
|
222
|
+
if proto is None:
|
|
223
|
+
return []
|
|
224
|
+
raw = _pll_track(flux, proto.clock, proto.time_per_rev, pll, revs)
|
|
225
|
+
info.revolutions = max(info.revolutions, len(raw.revolutions))
|
|
226
|
+
if isinstance(proto, amigados.AmigaDOS):
|
|
227
|
+
return list(_amiga_flux_candidates(raw, proto, cyl, head,
|
|
228
|
+
source, info))
|
|
229
|
+
inner = proto.raw if isinstance(proto, ibm.IBMTrack_Fixed) else proto
|
|
230
|
+
return list(_ibm_flux_candidates(raw, inner, cyl, head, source,
|
|
231
|
+
_expected_ibm(proto), info))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def flux_candidates(image, fmt: gw_codec.DiskDef, source: Path,
|
|
235
|
+
info: SourceInfo, pll: Optional[PLL] = None,
|
|
236
|
+
revs: Optional[int] = None,
|
|
237
|
+
on_track: Optional[Callable[[], None]] = None
|
|
238
|
+
) -> Iterator[Candidate]:
|
|
239
|
+
"""Every sector read attempt in a flux image, across every revolution."""
|
|
240
|
+
for cyl, head in track_list(fmt):
|
|
241
|
+
if on_track is not None:
|
|
242
|
+
on_track()
|
|
243
|
+
yield from track_candidates(image, fmt, cyl, head, source, info,
|
|
244
|
+
pll, revs)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _img_layout(track) -> List[Tuple[int, int]]:
|
|
248
|
+
"""(sector id, byte length) in the order a raw sector image stores them."""
|
|
249
|
+
if isinstance(track, amigados.AmigaDOS):
|
|
250
|
+
return [(i, 512) for i in range(track.nsec)]
|
|
251
|
+
bps = getattr(track, 'img_bps', None)
|
|
252
|
+
return [(s.idam.r, bps or len(s.dam.data))
|
|
253
|
+
for s in sorted(track.sectors, key=lambda s: s.idam.r)]
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def backed_by_file(image, fmt: gw_codec.DiskDef,
|
|
257
|
+
size: int) -> Set[Tuple[int, int, int]]:
|
|
258
|
+
"""Sectors of a raw image the file actually holds the bytes for.
|
|
259
|
+
|
|
260
|
+
A short image is zero-filled to the format's length by the reader, with
|
|
261
|
+
every fabricated sector marked CRC-clean, so without this a truncated dump
|
|
262
|
+
would win the merge outright with 512 bytes of nothing.
|
|
263
|
+
"""
|
|
264
|
+
order = image.track_list() if hasattr(image, 'track_list') else None
|
|
265
|
+
out, pos = set(), 0
|
|
266
|
+
for cyl, head in order or track_list(fmt):
|
|
267
|
+
track = image.get_track(cyl, head)
|
|
268
|
+
if track is None:
|
|
269
|
+
continue
|
|
270
|
+
for sec_id, length in _img_layout(track):
|
|
271
|
+
if pos + length <= size:
|
|
272
|
+
out.add((cyl, head, sec_id))
|
|
273
|
+
pos += length
|
|
274
|
+
return out
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _image_track_candidates(track, cyl: int, head: int, source: Path,
|
|
278
|
+
detect_filler: bool, info: SourceInfo,
|
|
279
|
+
backed: Optional[Set[Tuple[int, int, int]]] = None
|
|
280
|
+
) -> Iterator[Candidate]:
|
|
281
|
+
def present(sec_id: int) -> bool:
|
|
282
|
+
return backed is None or (cyl, head, sec_id) in backed
|
|
283
|
+
|
|
284
|
+
if isinstance(track, amigados.AmigaDOS):
|
|
285
|
+
for sec_id, sec in enumerate(track.sector):
|
|
286
|
+
if not present(sec_id):
|
|
287
|
+
continue
|
|
288
|
+
data = bytes(512) if sec is None else bytes(sec[1])
|
|
289
|
+
ok = sec is not None and not (detect_filler and is_filler(data))
|
|
290
|
+
info.candidates += 1
|
|
291
|
+
info.good += ok
|
|
292
|
+
yield Candidate(cyl=cyl, head=head, sec_id=sec_id, data=data,
|
|
293
|
+
id_crc_ok=True, data_crc_ok=ok, source=source,
|
|
294
|
+
rev=0, codec=AMIGA)
|
|
295
|
+
return
|
|
296
|
+
if isinstance(track, ibm.IBMTrack_Fixed):
|
|
297
|
+
mode = track.mode
|
|
298
|
+
else: # ibm.scan wrapper, or a bare IBMTrack
|
|
299
|
+
mode = getattr(track, 'mode', None) or track.track.mode
|
|
300
|
+
codec_name = _IBM_MODE[mode]
|
|
301
|
+
for sec in track.sectors:
|
|
302
|
+
if not present(sec.idam.r):
|
|
303
|
+
continue
|
|
304
|
+
data = bytes(sec.dam.data)
|
|
305
|
+
ok = sec.dam.crc == 0 and not (detect_filler and is_filler(data))
|
|
306
|
+
info.candidates += 1
|
|
307
|
+
info.good += ok
|
|
308
|
+
yield Candidate(cyl=cyl, head=head, sec_id=sec.idam.r, data=data,
|
|
309
|
+
id_crc_ok=sec.idam.crc == 0, data_crc_ok=ok,
|
|
310
|
+
source=source, rev=0, codec=codec_name,
|
|
311
|
+
mark=sec.dam.mark)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def image_candidates(image, fmt: gw_codec.DiskDef, source: Path,
|
|
315
|
+
info: SourceInfo, detect_filler: bool = True,
|
|
316
|
+
raw_size: Optional[int] = None) -> Iterator[Candidate]:
|
|
317
|
+
"""Every sector of an already-decoded image, filler counted as unread.
|
|
318
|
+
|
|
319
|
+
Decoded images carry no CRC, so a sector is trusted unless the format can
|
|
320
|
+
say otherwise (IMD's error flag) or it holds Greaseweazle's bad-sector
|
|
321
|
+
filler. With no check value, these attempts can never confirm a voted
|
|
322
|
+
reconstruction -- only supply bytes to vote on.
|
|
323
|
+
|
|
324
|
+
``raw_size`` is the file's size for the flat formats, where the tail of a
|
|
325
|
+
short file is padding rather than data.
|
|
326
|
+
"""
|
|
327
|
+
info.revolutions = 1
|
|
328
|
+
backed = (None if raw_size is None
|
|
329
|
+
else backed_by_file(image, fmt, raw_size))
|
|
330
|
+
for cyl, head in track_list(fmt):
|
|
331
|
+
track = image.get_track(cyl, head)
|
|
332
|
+
if track is None:
|
|
333
|
+
continue
|
|
334
|
+
yield from _image_track_candidates(track, cyl, head, source,
|
|
335
|
+
detect_filler, info, backed)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def track_list(fmt: gw_codec.DiskDef) -> List[Tuple[int, int]]:
|
|
339
|
+
"""Physical (cylinder, head) pairs a disk format covers, in order."""
|
|
340
|
+
return [(t.cyl, t.head) for t in fmt.tracks]
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def expected_sectors(fmt: gw_codec.DiskDef) -> List[Tuple[int, int, int]]:
|
|
344
|
+
"""Every (cyl, head, sec_id) the format says the disk should hold."""
|
|
345
|
+
out = []
|
|
346
|
+
for cyl, head in track_list(fmt):
|
|
347
|
+
track = fmt.mk_track(cyl, head)
|
|
348
|
+
if track is None:
|
|
349
|
+
continue
|
|
350
|
+
if isinstance(track, amigados.AmigaDOS):
|
|
351
|
+
out += [(cyl, head, i) for i in range(track.nsec)]
|
|
352
|
+
else:
|
|
353
|
+
out += [(cyl, head, s.idam.r) for s in track.sectors]
|
|
354
|
+
return out
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def sector_size(fmt: gw_codec.DiskDef, cyl: int, head: int,
|
|
358
|
+
sec_id: int) -> int:
|
|
359
|
+
"""Payload size of one sector according to the format."""
|
|
360
|
+
track = fmt.mk_track(cyl, head)
|
|
361
|
+
if isinstance(track, amigados.AmigaDOS):
|
|
362
|
+
return 512
|
|
363
|
+
for sec in track.sectors:
|
|
364
|
+
if sec.idam.r == sec_id:
|
|
365
|
+
return len(sec.dam.data)
|
|
366
|
+
return 512
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def load(path: Path, fmt: gw_codec.DiskDef, *, pll: Optional[PLL] = None,
|
|
370
|
+
revs: Optional[int] = None, detect_filler: bool = True,
|
|
371
|
+
jobs: int = 1,
|
|
372
|
+
on_track: Optional[Callable[[], None]] = None
|
|
373
|
+
) -> Tuple[List[Candidate], SourceInfo]:
|
|
374
|
+
"""Read one input file and return all of its sector read attempts."""
|
|
375
|
+
from diskstack import formats, parallel # circular at module scope
|
|
376
|
+
|
|
377
|
+
image, kind = formats.open_image(path, fmt)
|
|
378
|
+
info = SourceInfo(path=path, kind=kind,
|
|
379
|
+
size=formats.input_size(path, kind))
|
|
380
|
+
try:
|
|
381
|
+
if kind not in formats.FLUX_KINDS:
|
|
382
|
+
flat = info.size if kind in formats.FLAT_KINDS else None
|
|
383
|
+
cands = list(image_candidates(image, fmt, path, info,
|
|
384
|
+
detect_filler=detect_filler,
|
|
385
|
+
raw_size=flat))
|
|
386
|
+
elif jobs > 1:
|
|
387
|
+
cands = parallel.flux_candidates(path, fmt, info, pll=pll,
|
|
388
|
+
revs=revs, jobs=jobs,
|
|
389
|
+
on_track=on_track)
|
|
390
|
+
else:
|
|
391
|
+
cands = list(flux_candidates(image, fmt, path, info,
|
|
392
|
+
pll=pll, revs=revs,
|
|
393
|
+
on_track=on_track))
|
|
394
|
+
except gw_error.Fatal as exc:
|
|
395
|
+
raise DiskStackError(f'{path}: {exc}') from exc
|
|
396
|
+
return cands, info
|