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/formats.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""Disk formats, image files, and working out which is which.
|
|
2
|
+
|
|
3
|
+
diskstack needs one disk format for the whole run: it is the list of sectors
|
|
4
|
+
the disk is supposed to have, and without it a merge cannot say what is
|
|
5
|
+
missing. Auto-detection reads the geometry out of whichever input can state
|
|
6
|
+
it most directly -- an IMG boot sector, an IMD track header, or failing that
|
|
7
|
+
a scan of the first few cylinders of a flux capture.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import functools
|
|
13
|
+
import struct
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Dict, Iterable, List, Optional, Sequence, Tuple
|
|
16
|
+
|
|
17
|
+
from diskstack._vendor.greaseweazle import error as gw_error
|
|
18
|
+
from diskstack._vendor.greaseweazle.codec import codec as gw_codec
|
|
19
|
+
from diskstack._vendor.greaseweazle.codec.amiga import amigados
|
|
20
|
+
from diskstack._vendor.greaseweazle.codec.ibm import ibm
|
|
21
|
+
from diskstack._vendor.greaseweazle.image.adf import ADF
|
|
22
|
+
from diskstack._vendor.greaseweazle.image.hfe import HFE
|
|
23
|
+
from diskstack._vendor.greaseweazle.image.imd import IMD
|
|
24
|
+
from diskstack._vendor.greaseweazle.image.img import IMG
|
|
25
|
+
from diskstack._vendor.greaseweazle.image.kryoflux import KryoFlux
|
|
26
|
+
from diskstack._vendor.greaseweazle.image.scp import SCP
|
|
27
|
+
from diskstack._vendor.greaseweazle.track import PLL
|
|
28
|
+
|
|
29
|
+
from diskstack.errors import DiskStackError
|
|
30
|
+
|
|
31
|
+
# v1 is IBM FM/MFM and AmigaDOS MFM only. ibm.scan is excluded on purpose:
|
|
32
|
+
# it makes each track its own format, so it cannot state what the disk should
|
|
33
|
+
# contain, which is the one thing a merge needs.
|
|
34
|
+
SUPPORTED_PREFIXES = ('ibm.', 'amiga.')
|
|
35
|
+
EXCLUDED_FORMATS = frozenset({'ibm.scan'})
|
|
36
|
+
SCAN_CYLS = 5
|
|
37
|
+
PROBE_CYLS = 84
|
|
38
|
+
|
|
39
|
+
READERS = {'.scp': (SCP, 'scp'), '.img': (IMG, 'img'), '.ima': (IMG, 'img'),
|
|
40
|
+
'.adf': (ADF, 'adf'), '.imd': (IMD, 'imd'), '.st': (IMG, 'img'),
|
|
41
|
+
'.raw': (KryoFlux, 'kryoflux'), '.hfe': (HFE, 'hfe')}
|
|
42
|
+
WRITERS = {'.img': IMG, '.ima': IMG, '.adf': ADF, '.imd': IMD, '.st': IMG}
|
|
43
|
+
|
|
44
|
+
# Inputs that have to be decoded, and inputs that are a flat run of sector
|
|
45
|
+
# payloads with no per-sector framing at all.
|
|
46
|
+
FLUX_KINDS = frozenset({'scp', 'kryoflux', 'hfe'})
|
|
47
|
+
FLAT_KINDS = frozenset({'img', 'adf'})
|
|
48
|
+
|
|
49
|
+
# Every standard geometry this tool supports has a distinct raw image size.
|
|
50
|
+
SIZE_FORMATS = {
|
|
51
|
+
163840: 'ibm.160', 184320: 'ibm.180', 327680: 'ibm.320',
|
|
52
|
+
368640: 'ibm.360', 737280: 'ibm.720', 819200: 'ibm.800',
|
|
53
|
+
901120: 'amiga.amigados', 1228800: 'ibm.1200', 1474560: 'ibm.1440',
|
|
54
|
+
1720320: 'ibm.1680', 1802240: 'amiga.amigados_hd', 2949120: 'ibm.2880',
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def supported_formats() -> List[str]:
|
|
59
|
+
"""Names of the disk formats diskstack can merge."""
|
|
60
|
+
all_fmts = gw_codec.get_all_formats('', gw_codec.DiskDef_File(name=None))
|
|
61
|
+
return sorted(f for f in all_fmts
|
|
62
|
+
if f.startswith(SUPPORTED_PREFIXES)
|
|
63
|
+
and f not in EXCLUDED_FORMATS)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_format(name: str) -> gw_codec.DiskDef:
|
|
67
|
+
"""Look up a disk format by name, or explain why it is not usable."""
|
|
68
|
+
if name in EXCLUDED_FORMATS or not name.startswith(SUPPORTED_PREFIXES):
|
|
69
|
+
raise DiskStackError(
|
|
70
|
+
f"Unsupported format '{name}'. diskstack v1 handles IBM FM/MFM "
|
|
71
|
+
f"and AmigaDOS MFM only.\nSupported: "
|
|
72
|
+
f"{', '.join(supported_formats())}")
|
|
73
|
+
try:
|
|
74
|
+
fmt = gw_codec.get_diskdef(name)
|
|
75
|
+
except gw_error.Fatal as exc:
|
|
76
|
+
raise DiskStackError(f"Bad format '{name}': {exc}") from exc
|
|
77
|
+
if fmt is None:
|
|
78
|
+
raise DiskStackError(
|
|
79
|
+
f"Unknown format '{name}'.\nSupported: "
|
|
80
|
+
f"{', '.join(supported_formats())}")
|
|
81
|
+
return fmt
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Geometry:
|
|
85
|
+
"""Enough shape to pick a disk format: cylinders, heads, sectors, size."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, cyls: int, heads: int, nsec: int, secsz: int,
|
|
88
|
+
amiga: bool = False) -> None:
|
|
89
|
+
self.cyls, self.heads = cyls, heads
|
|
90
|
+
self.nsec, self.secsz, self.amiga = nsec, secsz, amiga
|
|
91
|
+
|
|
92
|
+
def __str__(self) -> str:
|
|
93
|
+
kind = 'AmigaDOS' if self.amiga else 'IBM'
|
|
94
|
+
return (f'{kind} {self.cyls} cyls, {self.heads} heads, '
|
|
95
|
+
f'{self.nsec} sectors of {self.secsz} bytes')
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@functools.lru_cache(maxsize=None)
|
|
99
|
+
def _format_geometry(name: str) -> Optional[Geometry]:
|
|
100
|
+
fmt = gw_codec.get_diskdef(name)
|
|
101
|
+
track = fmt.mk_track(0, 0)
|
|
102
|
+
if track is None:
|
|
103
|
+
return None
|
|
104
|
+
if isinstance(track, amigados.AmigaDOS):
|
|
105
|
+
return Geometry(fmt.cyls, fmt.heads, track.nsec, 512, amiga=True)
|
|
106
|
+
sizes = {len(s.dam.data) for s in track.sectors}
|
|
107
|
+
if len(sizes) != 1:
|
|
108
|
+
return None
|
|
109
|
+
return Geometry(fmt.cyls, fmt.heads, len(track.sectors), sizes.pop())
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def match_geometry(geom: Geometry) -> Optional[str]:
|
|
113
|
+
"""Name of the supported format matching ``geom`` exactly, if any."""
|
|
114
|
+
for name in supported_formats():
|
|
115
|
+
other = _format_geometry(name)
|
|
116
|
+
if other is None:
|
|
117
|
+
continue
|
|
118
|
+
if (other.cyls, other.heads, other.nsec, other.secsz, other.amiga) == \
|
|
119
|
+
(geom.cyls, geom.heads, geom.nsec, geom.secsz, geom.amiga):
|
|
120
|
+
return name
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _bpb_geometry(dat: bytes) -> Optional[Geometry]:
|
|
125
|
+
"""Geometry from a DOS BIOS Parameter Block, if the boot sector has one."""
|
|
126
|
+
if len(dat) < 512:
|
|
127
|
+
return None
|
|
128
|
+
bps, = struct.unpack('<H', dat[11:13])
|
|
129
|
+
total, = struct.unpack('<H', dat[19:21])
|
|
130
|
+
spt, heads = struct.unpack('<HH', dat[24:28])
|
|
131
|
+
if bps not in (128, 256, 512, 1024) or spt == 0 or heads not in (1, 2):
|
|
132
|
+
return None
|
|
133
|
+
if total == 0 or total * bps != len(dat) or total % (spt * heads):
|
|
134
|
+
return None
|
|
135
|
+
return Geometry(total // (spt * heads), heads, spt, bps)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _flux_extent(image) -> Optional[Tuple[int, int]]:
|
|
139
|
+
"""(cylinders, heads) a flux container holds, found by probing for tracks.
|
|
140
|
+
|
|
141
|
+
Flux readers do not agree on how to list their tracks, and the KryoFlux
|
|
142
|
+
one cannot list them at all: it goes looking for a file per track.
|
|
143
|
+
"""
|
|
144
|
+
top = next((cyl for cyl in reversed(range(PROBE_CYLS))
|
|
145
|
+
if image.get_track(cyl, 0) is not None
|
|
146
|
+
or image.get_track(cyl, 1) is not None), None)
|
|
147
|
+
if top is None:
|
|
148
|
+
return None
|
|
149
|
+
return top + 1, 2 if image.get_track(0, 1) is not None else 1
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _scan_flux_geometry(path: Path, pll: Optional[PLL]) -> Optional[Geometry]:
|
|
153
|
+
"""Decode the first few cylinders of a flux capture to see what is there.
|
|
154
|
+
|
|
155
|
+
A track with a damaged sector reports one sector fewer than the disk
|
|
156
|
+
really has, which would pick a smaller format, so several tracks are
|
|
157
|
+
scanned and the fullest one wins.
|
|
158
|
+
"""
|
|
159
|
+
reader, _ = READERS[path.suffix.lower()]
|
|
160
|
+
image = reader.from_file(str(path), None, {})
|
|
161
|
+
extent = _flux_extent(image)
|
|
162
|
+
if extent is None:
|
|
163
|
+
return None
|
|
164
|
+
cyls, heads = extent
|
|
165
|
+
|
|
166
|
+
scan_fmt = gw_codec.get_diskdef('ibm.scan')
|
|
167
|
+
best = None
|
|
168
|
+
for cyl in range(min(SCAN_CYLS, cyls)):
|
|
169
|
+
for head in range(heads):
|
|
170
|
+
flux = image.get_track(cyl, head)
|
|
171
|
+
if flux is None:
|
|
172
|
+
continue
|
|
173
|
+
track = scan_fmt.mk_track(cyl, head)
|
|
174
|
+
track.decode_flux(flux)
|
|
175
|
+
inner = track.track
|
|
176
|
+
sizes = {len(s.dam.data) for s in inner.sectors}
|
|
177
|
+
if inner.sectors and len(sizes) == 1:
|
|
178
|
+
geom = Geometry(cyls, heads, len(inner.sectors), sizes.pop())
|
|
179
|
+
if best is None or geom.nsec > best.nsec:
|
|
180
|
+
best = geom
|
|
181
|
+
elif best is None:
|
|
182
|
+
# No IBM sectors: try AmigaDOS, which uses a different sync.
|
|
183
|
+
for cls in (amigados.AmigaDOS_DD, amigados.AmigaDOS_HD):
|
|
184
|
+
proto = cls(cyl, head)
|
|
185
|
+
proto.decode_flux(flux, pll)
|
|
186
|
+
if proto.nr_missing() < proto.nsec:
|
|
187
|
+
return Geometry(cyls, heads, proto.nsec, 512,
|
|
188
|
+
amiga=True)
|
|
189
|
+
return best
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _imd_geometry(path: Path) -> Optional[Geometry]:
|
|
193
|
+
image = IMD.from_file(str(path), None, {})
|
|
194
|
+
if not image.to_track:
|
|
195
|
+
return None
|
|
196
|
+
cyls = max(c for c, _ in image.to_track) + 1
|
|
197
|
+
heads = max(h for _, h in image.to_track) + 1
|
|
198
|
+
track = image.to_track[min(image.to_track)]
|
|
199
|
+
sizes = {len(s.dam.data) for s in track.sectors}
|
|
200
|
+
if len(sizes) != 1:
|
|
201
|
+
return None
|
|
202
|
+
return Geometry(cyls, heads, len(track.sectors), sizes.pop())
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def detect_format(paths: Sequence[Path],
|
|
206
|
+
pll: Optional[PLL] = None) -> Tuple[str, str]:
|
|
207
|
+
"""Guess the disk format from the inputs.
|
|
208
|
+
|
|
209
|
+
Returns ``(format_name, how_it_was_detected)``. Sector images go first:
|
|
210
|
+
their size and boot sector say the geometry outright, where a flux capture
|
|
211
|
+
has to be decoded to find out.
|
|
212
|
+
"""
|
|
213
|
+
ordered = sorted(paths, key=is_flux)
|
|
214
|
+
tried = []
|
|
215
|
+
for path in ordered:
|
|
216
|
+
kind = READERS.get(path.suffix.lower(), (None, None))[1]
|
|
217
|
+
try:
|
|
218
|
+
if kind in FLAT_KINDS:
|
|
219
|
+
size = path.stat().st_size
|
|
220
|
+
# An ADF is pure sector data, with no boot sector to read.
|
|
221
|
+
geom = (None if kind == 'adf'
|
|
222
|
+
else _bpb_geometry(path.read_bytes()))
|
|
223
|
+
if geom is not None and (name := match_geometry(geom)):
|
|
224
|
+
return name, f'{path.name} boot sector ({geom})'
|
|
225
|
+
if size in SIZE_FORMATS:
|
|
226
|
+
return SIZE_FORMATS[size], f'{path.name} size ({size} bytes)'
|
|
227
|
+
tried.append(f'{path.name}: {size} bytes matches no format')
|
|
228
|
+
continue
|
|
229
|
+
if kind == 'imd':
|
|
230
|
+
geom = _imd_geometry(path)
|
|
231
|
+
elif kind in FLUX_KINDS:
|
|
232
|
+
geom = _scan_flux_geometry(path, pll)
|
|
233
|
+
else:
|
|
234
|
+
continue
|
|
235
|
+
except (gw_error.Fatal, OSError, struct.error) as exc:
|
|
236
|
+
tried.append(f'{path.name}: {exc}')
|
|
237
|
+
continue
|
|
238
|
+
if geom is None:
|
|
239
|
+
tried.append(f'{path.name}: no sectors decoded on its first cylinders')
|
|
240
|
+
continue
|
|
241
|
+
name = match_geometry(geom)
|
|
242
|
+
if name is not None:
|
|
243
|
+
return name, f'{path.name} ({geom})'
|
|
244
|
+
tried.append(f'{path.name}: {geom} matches no supported format')
|
|
245
|
+
|
|
246
|
+
detail = '\n '.join(tried) if tried else 'no usable inputs'
|
|
247
|
+
raise DiskStackError(
|
|
248
|
+
'Could not work out the disk format automatically:\n ' + detail
|
|
249
|
+
+ '\nPass --format explicitly, e.g. --format ibm.720.\nSupported: '
|
|
250
|
+
+ ', '.join(supported_formats()))
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def kind_of(path: Path) -> Optional[str]:
|
|
254
|
+
"""Which reader this extension maps to, or None if diskstack has none."""
|
|
255
|
+
return READERS.get(path.suffix.lower(), (None, None))[1]
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def is_flux(path: Path) -> bool:
|
|
259
|
+
"""True if this input has to be decoded rather than simply read."""
|
|
260
|
+
return kind_of(path) in FLUX_KINDS
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def input_files(path: Path, kind: str) -> List[Path]:
|
|
264
|
+
"""Every file one input is made of. A KryoFlux input names one of a set.
|
|
265
|
+
|
|
266
|
+
The set is enumerated the way the reader opens it, one name per track, so
|
|
267
|
+
a second capture sitting in the same directory is not part of this one.
|
|
268
|
+
"""
|
|
269
|
+
if kind != 'kryoflux':
|
|
270
|
+
return [path]
|
|
271
|
+
base = KryoFlux(str(path), None).basename
|
|
272
|
+
return [f for f in (Path(f'{base}{cyl:02d}.{head}.raw')
|
|
273
|
+
for cyl in range(PROBE_CYLS) for head in (0, 1))
|
|
274
|
+
if f.exists()]
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def input_size(path: Path, kind: str) -> int:
|
|
278
|
+
"""Bytes an input occupies, the whole stream set for a KryoFlux one."""
|
|
279
|
+
return sum(f.stat().st_size for f in input_files(path, kind))
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def check_readable(path: Path) -> str:
|
|
283
|
+
"""Kind of input this is, rejecting one diskstack cannot open at all.
|
|
284
|
+
|
|
285
|
+
Called for every input before any of them is decoded, so a typo in the
|
|
286
|
+
last filename does not cost a capture's worth of decoding first.
|
|
287
|
+
"""
|
|
288
|
+
if not path.exists():
|
|
289
|
+
raise DiskStackError(f'{path}: no such file')
|
|
290
|
+
if path.is_dir():
|
|
291
|
+
raise DiskStackError(f'{path}: is a directory, not a disk image')
|
|
292
|
+
entry = READERS.get(path.suffix.lower())
|
|
293
|
+
if entry is None:
|
|
294
|
+
raise DiskStackError(
|
|
295
|
+
f'{path}: unrecognised extension. diskstack reads '
|
|
296
|
+
+ ', '.join(sorted(READERS)))
|
|
297
|
+
if entry[1] == 'kryoflux':
|
|
298
|
+
# The name is how the reader finds the rest of the set, so it is part
|
|
299
|
+
# of the input rather than a detail of this one file.
|
|
300
|
+
try:
|
|
301
|
+
KryoFlux(str(path), None)
|
|
302
|
+
except gw_error.Fatal as exc:
|
|
303
|
+
raise DiskStackError(
|
|
304
|
+
f'{path}: a KryoFlux input is named for one track of a set, '
|
|
305
|
+
f'like track00.0.raw. Name any one of them and diskstack '
|
|
306
|
+
f'reads the rest of the set from the same directory.') from exc
|
|
307
|
+
return entry[1]
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def open_image(path: Path, fmt: gw_codec.DiskDef):
|
|
311
|
+
"""Open one input file, returning ``(image, kind)``."""
|
|
312
|
+
kind = check_readable(path)
|
|
313
|
+
cls = READERS[path.suffix.lower()][0]
|
|
314
|
+
try:
|
|
315
|
+
return cls.from_file(str(path), fmt, {}), kind
|
|
316
|
+
except gw_error.Fatal as exc:
|
|
317
|
+
raise DiskStackError(f'{path}: {exc}') from exc
|
|
318
|
+
except (struct.error, IndexError) as exc:
|
|
319
|
+
raise DiskStackError(f'{path}: truncated or corrupt {kind} file '
|
|
320
|
+
f'({exc})') from exc
|
|
321
|
+
except OSError as exc:
|
|
322
|
+
raise DiskStackError(f'{path}: {exc.strerror or exc}') from exc
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def fill_track(track, data: Dict[int, bytes], bad: Iterable[int]):
|
|
326
|
+
"""Put merged sector payloads into a freshly-made track object."""
|
|
327
|
+
bad = set(bad)
|
|
328
|
+
if isinstance(track, amigados.AmigaDOS):
|
|
329
|
+
track.map = list(range(track.nsec))
|
|
330
|
+
for sec_id in range(track.nsec):
|
|
331
|
+
payload = data.get(sec_id)
|
|
332
|
+
if payload is not None:
|
|
333
|
+
track.sector[sec_id] = bytes(16), payload
|
|
334
|
+
return track
|
|
335
|
+
for sec in track.sectors:
|
|
336
|
+
payload = data.get(sec.idam.r)
|
|
337
|
+
if payload is None:
|
|
338
|
+
continue
|
|
339
|
+
sec.dam.data = payload
|
|
340
|
+
# IMD carries a per-sector error flag and writes it from dam.crc, so
|
|
341
|
+
# an unresolved sector survives a round trip through .imd.
|
|
342
|
+
bad_crc = 0xffff if sec.idam.r in bad else 0
|
|
343
|
+
sec.crc = sec.idam.crc = sec.dam.crc = bad_crc
|
|
344
|
+
return track
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def check_writable(path: Path) -> None:
|
|
348
|
+
"""Reject an output path now rather than after decoding every input."""
|
|
349
|
+
if path.suffix.lower() not in WRITERS:
|
|
350
|
+
raise DiskStackError(
|
|
351
|
+
f'{path}: cannot write that format. diskstack writes '
|
|
352
|
+
+ ', '.join(sorted(WRITERS)))
|
|
353
|
+
if path.parent and not path.parent.exists():
|
|
354
|
+
raise DiskStackError(f'{path.parent}: output directory does not exist')
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def write_image(path: Path, fmt: gw_codec.DiskDef,
|
|
358
|
+
data: Dict[Tuple[int, int], Dict[int, bytes]],
|
|
359
|
+
bad: Dict[Tuple[int, int], Iterable[int]]) -> None:
|
|
360
|
+
"""Write the merged sectors out as a sector image."""
|
|
361
|
+
check_writable(path)
|
|
362
|
+
cls = WRITERS[path.suffix.lower()]
|
|
363
|
+
try:
|
|
364
|
+
with cls.to_file(str(path), fmt, False, {}) as out:
|
|
365
|
+
for cyl, head in [(t.cyl, t.head) for t in fmt.tracks]:
|
|
366
|
+
track = fmt.mk_track(cyl, head)
|
|
367
|
+
if track is None:
|
|
368
|
+
continue
|
|
369
|
+
fill_track(track, data.get((cyl, head), {}),
|
|
370
|
+
bad.get((cyl, head), ()))
|
|
371
|
+
out.emit_track(cyl, head, track)
|
|
372
|
+
except gw_error.Fatal as exc:
|
|
373
|
+
raise DiskStackError(f'{path}: {exc}') from exc
|
|
374
|
+
except OSError as exc:
|
|
375
|
+
raise DiskStackError(f'{path}: {exc.strerror or exc}') from exc
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
__all__ = ['Geometry', 'check_readable', 'check_writable', 'detect_format',
|
|
379
|
+
'fill_track', 'get_format', 'ibm', 'input_files', 'input_size',
|
|
380
|
+
'is_flux', 'kind_of', 'match_geometry', 'open_image',
|
|
381
|
+
'supported_formats', 'write_image']
|
diskstack/parallel.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Decode a flux capture one track per worker process.
|
|
2
|
+
|
|
3
|
+
Nearly all of a run is the PLL walking flux, and tracks are independent, so
|
|
4
|
+
this is the one place worth parallelising. Results are collected in track
|
|
5
|
+
order, so a parallel run produces byte-identical candidates to a serial one.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Callable, List, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
from diskstack._vendor.greaseweazle.codec import codec as gw_codec
|
|
16
|
+
from diskstack._vendor.greaseweazle.track import PLL
|
|
17
|
+
from diskstack.candidates import (Candidate, SourceInfo, track_candidates,
|
|
18
|
+
track_list)
|
|
19
|
+
|
|
20
|
+
MAX_JOBS = 8
|
|
21
|
+
|
|
22
|
+
_worker: dict = {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def default_jobs() -> int:
|
|
26
|
+
"""Worker count to use when the user did not ask for one."""
|
|
27
|
+
return max(1, min(os.cpu_count() or 1, MAX_JOBS))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _init(path: Path, fmt: gw_codec.DiskDef, pll: Optional[PLL],
|
|
31
|
+
revs: Optional[int]) -> None:
|
|
32
|
+
from diskstack import formats
|
|
33
|
+
|
|
34
|
+
image, kind = formats.open_image(path, fmt)
|
|
35
|
+
_worker.update(path=path, fmt=fmt, image=image, kind=kind, pll=pll,
|
|
36
|
+
revs=revs)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _decode(track: Tuple[int, int]) -> Tuple[List[Candidate], SourceInfo]:
|
|
40
|
+
cyl, head = track
|
|
41
|
+
info = SourceInfo(path=_worker['path'], kind=_worker['kind'])
|
|
42
|
+
cands = track_candidates(_worker['image'], _worker['fmt'], cyl, head,
|
|
43
|
+
_worker['path'], info, _worker['pll'],
|
|
44
|
+
_worker['revs'])
|
|
45
|
+
return cands, info
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def merge_info(into: SourceInfo, part: SourceInfo) -> None:
|
|
49
|
+
"""Fold one track's tally into the tally for the whole file."""
|
|
50
|
+
into.revolutions = max(into.revolutions, part.revolutions)
|
|
51
|
+
into.candidates += part.candidates
|
|
52
|
+
into.good += part.good
|
|
53
|
+
into.unexpected += part.unexpected
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def flux_candidates(path: Path, fmt: gw_codec.DiskDef, info: SourceInfo, *,
|
|
57
|
+
pll: Optional[PLL] = None, revs: Optional[int] = None,
|
|
58
|
+
jobs: int = 2,
|
|
59
|
+
on_track: Optional[Callable[[], None]] = None
|
|
60
|
+
) -> List[Candidate]:
|
|
61
|
+
"""Same result as :func:`diskstack.candidates.flux_candidates`, in
|
|
62
|
+
parallel."""
|
|
63
|
+
tracks = track_list(fmt)
|
|
64
|
+
out: List[Candidate] = []
|
|
65
|
+
with ProcessPoolExecutor(max_workers=min(jobs, len(tracks) or 1),
|
|
66
|
+
initializer=_init,
|
|
67
|
+
initargs=(path, fmt, pll, revs)) as pool:
|
|
68
|
+
for cands, part in pool.map(_decode, tracks, chunksize=2):
|
|
69
|
+
merge_info(info, part)
|
|
70
|
+
out += cands
|
|
71
|
+
if on_track is not None:
|
|
72
|
+
on_track()
|
|
73
|
+
return out
|