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.
Files changed (119) hide show
  1. diskstack/__init__.py +5 -0
  2. diskstack/_vendor/__init__.py +0 -0
  3. diskstack/_vendor/greaseweazle/COPYING +24 -0
  4. diskstack/_vendor/greaseweazle/__init__.py +2 -0
  5. diskstack/_vendor/greaseweazle/codec/__init__.py +0 -0
  6. diskstack/_vendor/greaseweazle/codec/amiga/__init__.py +0 -0
  7. diskstack/_vendor/greaseweazle/codec/amiga/amigados.py +221 -0
  8. diskstack/_vendor/greaseweazle/codec/apple2/__init__.py +0 -0
  9. diskstack/_vendor/greaseweazle/codec/apple2/apple2_gcr.py +223 -0
  10. diskstack/_vendor/greaseweazle/codec/bitcell.py +117 -0
  11. diskstack/_vendor/greaseweazle/codec/codec.py +362 -0
  12. diskstack/_vendor/greaseweazle/codec/commodore/__init__.py +0 -0
  13. diskstack/_vendor/greaseweazle/codec/commodore/c64_gcr.py +229 -0
  14. diskstack/_vendor/greaseweazle/codec/datageneral/__init__.py +0 -0
  15. diskstack/_vendor/greaseweazle/codec/datageneral/datageneral.py +211 -0
  16. diskstack/_vendor/greaseweazle/codec/hp/__init__.py +0 -0
  17. diskstack/_vendor/greaseweazle/codec/hp/hp_mmfm.py +258 -0
  18. diskstack/_vendor/greaseweazle/codec/ibm/__init__.py +0 -0
  19. diskstack/_vendor/greaseweazle/codec/ibm/ibm.py +1005 -0
  20. diskstack/_vendor/greaseweazle/codec/macintosh/__init__.py +0 -0
  21. diskstack/_vendor/greaseweazle/codec/macintosh/mac_gcr.py +238 -0
  22. diskstack/_vendor/greaseweazle/codec/micropolis/__init__.py +0 -0
  23. diskstack/_vendor/greaseweazle/codec/micropolis/micropolis.py +233 -0
  24. diskstack/_vendor/greaseweazle/codec/northstar/__init__.py +0 -0
  25. diskstack/_vendor/greaseweazle/codec/northstar/northstar.py +210 -0
  26. diskstack/_vendor/greaseweazle/data/__init__.py +0 -0
  27. diskstack/_vendor/greaseweazle/data/diskdefs.cfg +81 -0
  28. diskstack/_vendor/greaseweazle/data/diskdefs_acorn.cfg +119 -0
  29. diskstack/_vendor/greaseweazle/data/diskdefs_akai.cfg +27 -0
  30. diskstack/_vendor/greaseweazle/data/diskdefs_amiga.cfg +17 -0
  31. diskstack/_vendor/greaseweazle/data/diskdefs_apple2.cfg +31 -0
  32. diskstack/_vendor/greaseweazle/data/diskdefs_apricot.cfg +25 -0
  33. diskstack/_vendor/greaseweazle/data/diskdefs_atari.cfg +33 -0
  34. diskstack/_vendor/greaseweazle/data/diskdefs_atarist.cfg +76 -0
  35. diskstack/_vendor/greaseweazle/data/diskdefs_coco.cfg +98 -0
  36. diskstack/_vendor/greaseweazle/data/diskdefs_commodore.cfg +127 -0
  37. diskstack/_vendor/greaseweazle/data/diskdefs_datageneral.cfg +8 -0
  38. diskstack/_vendor/greaseweazle/data/diskdefs_dec.cfg +37 -0
  39. diskstack/_vendor/greaseweazle/data/diskdefs_dragon.cfg +61 -0
  40. diskstack/_vendor/greaseweazle/data/diskdefs_eagle.cfg +25 -0
  41. diskstack/_vendor/greaseweazle/data/diskdefs_ensoniq.cfg +39 -0
  42. diskstack/_vendor/greaseweazle/data/diskdefs_epson.cfg +95 -0
  43. diskstack/_vendor/greaseweazle/data/diskdefs_gem.cfg +15 -0
  44. diskstack/_vendor/greaseweazle/data/diskdefs_hp.cfg +25 -0
  45. diskstack/_vendor/greaseweazle/data/diskdefs_ibm.cfg +140 -0
  46. diskstack/_vendor/greaseweazle/data/diskdefs_kaypro.cfg +58 -0
  47. diskstack/_vendor/greaseweazle/data/diskdefs_luxor.cfg +96 -0
  48. diskstack/_vendor/greaseweazle/data/diskdefs_mac.cfg +71 -0
  49. diskstack/_vendor/greaseweazle/data/diskdefs_micropolis.cfg +73 -0
  50. diskstack/_vendor/greaseweazle/data/diskdefs_mm1.cfg +69 -0
  51. diskstack/_vendor/greaseweazle/data/diskdefs_msx.cfg +45 -0
  52. diskstack/_vendor/greaseweazle/data/diskdefs_northstar.cfg +37 -0
  53. diskstack/_vendor/greaseweazle/data/diskdefs_occ1.cfg +23 -0
  54. diskstack/_vendor/greaseweazle/data/diskdefs_olivetti.cfg +18 -0
  55. diskstack/_vendor/greaseweazle/data/diskdefs_pc98.cfg +69 -0
  56. diskstack/_vendor/greaseweazle/data/diskdefs_raw.cfg +25 -0
  57. diskstack/_vendor/greaseweazle/data/diskdefs_rm.cfg +98 -0
  58. diskstack/_vendor/greaseweazle/data/diskdefs_sci.cfg +15 -0
  59. diskstack/_vendor/greaseweazle/data/diskdefs_sega.cfg +13 -0
  60. diskstack/_vendor/greaseweazle/data/diskdefs_sharp.cfg +13 -0
  61. diskstack/_vendor/greaseweazle/data/diskdefs_thomson.cfg +78 -0
  62. diskstack/_vendor/greaseweazle/data/diskdefs_tsc.cfg +81 -0
  63. diskstack/_vendor/greaseweazle/data/diskdefs_xerox.cfg +59 -0
  64. diskstack/_vendor/greaseweazle/data/diskdefs_zx.cfg +245 -0
  65. diskstack/_vendor/greaseweazle/error.py +19 -0
  66. diskstack/_vendor/greaseweazle/flux.py +315 -0
  67. diskstack/_vendor/greaseweazle/image/__init__.py +0 -0
  68. diskstack/_vendor/greaseweazle/image/a2r.py +133 -0
  69. diskstack/_vendor/greaseweazle/image/acorn.py +28 -0
  70. diskstack/_vendor/greaseweazle/image/adf.py +15 -0
  71. diskstack/_vendor/greaseweazle/image/apple2.py +18 -0
  72. diskstack/_vendor/greaseweazle/image/apridisk.py +107 -0
  73. diskstack/_vendor/greaseweazle/image/caps.py +464 -0
  74. diskstack/_vendor/greaseweazle/image/d64.py +51 -0
  75. diskstack/_vendor/greaseweazle/image/d81.py +28 -0
  76. diskstack/_vendor/greaseweazle/image/d88.py +211 -0
  77. diskstack/_vendor/greaseweazle/image/dcp.py +45 -0
  78. diskstack/_vendor/greaseweazle/image/dim.py +53 -0
  79. diskstack/_vendor/greaseweazle/image/dmk.py +166 -0
  80. diskstack/_vendor/greaseweazle/image/dsk.py +31 -0
  81. diskstack/_vendor/greaseweazle/image/edsk.py +591 -0
  82. diskstack/_vendor/greaseweazle/image/fd.py +23 -0
  83. diskstack/_vendor/greaseweazle/image/fdi.py +41 -0
  84. diskstack/_vendor/greaseweazle/image/hdm.py +15 -0
  85. diskstack/_vendor/greaseweazle/image/hfe.py +742 -0
  86. diskstack/_vendor/greaseweazle/image/image.py +130 -0
  87. diskstack/_vendor/greaseweazle/image/imd.py +201 -0
  88. diskstack/_vendor/greaseweazle/image/img.py +111 -0
  89. diskstack/_vendor/greaseweazle/image/kryoflux.py +304 -0
  90. diskstack/_vendor/greaseweazle/image/mgt.py +15 -0
  91. diskstack/_vendor/greaseweazle/image/msa.py +153 -0
  92. diskstack/_vendor/greaseweazle/image/nfd.py +113 -0
  93. diskstack/_vendor/greaseweazle/image/nsi.py +47 -0
  94. diskstack/_vendor/greaseweazle/image/scp.py +471 -0
  95. diskstack/_vendor/greaseweazle/image/sf7.py +15 -0
  96. diskstack/_vendor/greaseweazle/image/sharp2d.py +15 -0
  97. diskstack/_vendor/greaseweazle/image/td0.py +141 -0
  98. diskstack/_vendor/greaseweazle/image/xdf.py +15 -0
  99. diskstack/_vendor/greaseweazle/optimised/__init__.py +22 -0
  100. diskstack/_vendor/greaseweazle/tools/__init__.py +0 -0
  101. diskstack/_vendor/greaseweazle/tools/util.py +133 -0
  102. diskstack/_vendor/greaseweazle/track.py +494 -0
  103. diskstack/cache.py +210 -0
  104. diskstack/candidates.py +396 -0
  105. diskstack/cli.py +348 -0
  106. diskstack/errors.py +5 -0
  107. diskstack/filler.py +29 -0
  108. diskstack/formats.py +381 -0
  109. diskstack/parallel.py +73 -0
  110. diskstack/report.py +353 -0
  111. diskstack/stack.py +317 -0
  112. diskstack-1.0.0.dist-info/METADATA +329 -0
  113. diskstack-1.0.0.dist-info/RECORD +119 -0
  114. diskstack-1.0.0.dist-info/WHEEL +5 -0
  115. diskstack-1.0.0.dist-info/entry_points.txt +2 -0
  116. diskstack-1.0.0.dist-info/licenses/LICENSE +21 -0
  117. diskstack-1.0.0.dist-info/licenses/NOTICE +55 -0
  118. diskstack-1.0.0.dist-info/licenses/diskstack/_vendor/greaseweazle/COPYING +24 -0
  119. 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']
@@ -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