diskstack 1.0.0__tar.gz

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 (141) hide show
  1. diskstack-1.0.0/CHANGELOG.md +62 -0
  2. diskstack-1.0.0/LICENSE +21 -0
  3. diskstack-1.0.0/MANIFEST.in +10 -0
  4. diskstack-1.0.0/NOTICE +55 -0
  5. diskstack-1.0.0/PKG-INFO +329 -0
  6. diskstack-1.0.0/README.md +294 -0
  7. diskstack-1.0.0/diskstack/__init__.py +5 -0
  8. diskstack-1.0.0/diskstack/_vendor/__init__.py +0 -0
  9. diskstack-1.0.0/diskstack/_vendor/greaseweazle/COPYING +24 -0
  10. diskstack-1.0.0/diskstack/_vendor/greaseweazle/__init__.py +2 -0
  11. diskstack-1.0.0/diskstack/_vendor/greaseweazle/codec/__init__.py +0 -0
  12. diskstack-1.0.0/diskstack/_vendor/greaseweazle/codec/amiga/__init__.py +0 -0
  13. diskstack-1.0.0/diskstack/_vendor/greaseweazle/codec/amiga/amigados.py +221 -0
  14. diskstack-1.0.0/diskstack/_vendor/greaseweazle/codec/apple2/__init__.py +0 -0
  15. diskstack-1.0.0/diskstack/_vendor/greaseweazle/codec/apple2/apple2_gcr.py +223 -0
  16. diskstack-1.0.0/diskstack/_vendor/greaseweazle/codec/bitcell.py +117 -0
  17. diskstack-1.0.0/diskstack/_vendor/greaseweazle/codec/codec.py +362 -0
  18. diskstack-1.0.0/diskstack/_vendor/greaseweazle/codec/commodore/__init__.py +0 -0
  19. diskstack-1.0.0/diskstack/_vendor/greaseweazle/codec/commodore/c64_gcr.py +229 -0
  20. diskstack-1.0.0/diskstack/_vendor/greaseweazle/codec/datageneral/__init__.py +0 -0
  21. diskstack-1.0.0/diskstack/_vendor/greaseweazle/codec/datageneral/datageneral.py +211 -0
  22. diskstack-1.0.0/diskstack/_vendor/greaseweazle/codec/hp/__init__.py +0 -0
  23. diskstack-1.0.0/diskstack/_vendor/greaseweazle/codec/hp/hp_mmfm.py +258 -0
  24. diskstack-1.0.0/diskstack/_vendor/greaseweazle/codec/ibm/__init__.py +0 -0
  25. diskstack-1.0.0/diskstack/_vendor/greaseweazle/codec/ibm/ibm.py +1005 -0
  26. diskstack-1.0.0/diskstack/_vendor/greaseweazle/codec/macintosh/__init__.py +0 -0
  27. diskstack-1.0.0/diskstack/_vendor/greaseweazle/codec/macintosh/mac_gcr.py +238 -0
  28. diskstack-1.0.0/diskstack/_vendor/greaseweazle/codec/micropolis/__init__.py +0 -0
  29. diskstack-1.0.0/diskstack/_vendor/greaseweazle/codec/micropolis/micropolis.py +233 -0
  30. diskstack-1.0.0/diskstack/_vendor/greaseweazle/codec/northstar/__init__.py +0 -0
  31. diskstack-1.0.0/diskstack/_vendor/greaseweazle/codec/northstar/northstar.py +210 -0
  32. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/__init__.py +0 -0
  33. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs.cfg +81 -0
  34. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_acorn.cfg +119 -0
  35. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_akai.cfg +27 -0
  36. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_amiga.cfg +17 -0
  37. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_apple2.cfg +31 -0
  38. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_apricot.cfg +25 -0
  39. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_atari.cfg +33 -0
  40. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_atarist.cfg +76 -0
  41. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_coco.cfg +98 -0
  42. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_commodore.cfg +127 -0
  43. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_datageneral.cfg +8 -0
  44. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_dec.cfg +37 -0
  45. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_dragon.cfg +61 -0
  46. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_eagle.cfg +25 -0
  47. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_ensoniq.cfg +39 -0
  48. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_epson.cfg +95 -0
  49. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_gem.cfg +15 -0
  50. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_hp.cfg +25 -0
  51. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_ibm.cfg +140 -0
  52. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_kaypro.cfg +58 -0
  53. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_luxor.cfg +96 -0
  54. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_mac.cfg +71 -0
  55. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_micropolis.cfg +73 -0
  56. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_mm1.cfg +69 -0
  57. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_msx.cfg +45 -0
  58. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_northstar.cfg +37 -0
  59. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_occ1.cfg +23 -0
  60. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_olivetti.cfg +18 -0
  61. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_pc98.cfg +69 -0
  62. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_raw.cfg +25 -0
  63. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_rm.cfg +98 -0
  64. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_sci.cfg +15 -0
  65. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_sega.cfg +13 -0
  66. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_sharp.cfg +13 -0
  67. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_thomson.cfg +78 -0
  68. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_tsc.cfg +81 -0
  69. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_xerox.cfg +59 -0
  70. diskstack-1.0.0/diskstack/_vendor/greaseweazle/data/diskdefs_zx.cfg +245 -0
  71. diskstack-1.0.0/diskstack/_vendor/greaseweazle/error.py +19 -0
  72. diskstack-1.0.0/diskstack/_vendor/greaseweazle/flux.py +315 -0
  73. diskstack-1.0.0/diskstack/_vendor/greaseweazle/image/__init__.py +0 -0
  74. diskstack-1.0.0/diskstack/_vendor/greaseweazle/image/a2r.py +133 -0
  75. diskstack-1.0.0/diskstack/_vendor/greaseweazle/image/acorn.py +28 -0
  76. diskstack-1.0.0/diskstack/_vendor/greaseweazle/image/adf.py +15 -0
  77. diskstack-1.0.0/diskstack/_vendor/greaseweazle/image/apple2.py +18 -0
  78. diskstack-1.0.0/diskstack/_vendor/greaseweazle/image/apridisk.py +107 -0
  79. diskstack-1.0.0/diskstack/_vendor/greaseweazle/image/caps.py +464 -0
  80. diskstack-1.0.0/diskstack/_vendor/greaseweazle/image/d64.py +51 -0
  81. diskstack-1.0.0/diskstack/_vendor/greaseweazle/image/d81.py +28 -0
  82. diskstack-1.0.0/diskstack/_vendor/greaseweazle/image/d88.py +211 -0
  83. diskstack-1.0.0/diskstack/_vendor/greaseweazle/image/dcp.py +45 -0
  84. diskstack-1.0.0/diskstack/_vendor/greaseweazle/image/dim.py +53 -0
  85. diskstack-1.0.0/diskstack/_vendor/greaseweazle/image/dmk.py +166 -0
  86. diskstack-1.0.0/diskstack/_vendor/greaseweazle/image/dsk.py +31 -0
  87. diskstack-1.0.0/diskstack/_vendor/greaseweazle/image/edsk.py +591 -0
  88. diskstack-1.0.0/diskstack/_vendor/greaseweazle/image/fd.py +23 -0
  89. diskstack-1.0.0/diskstack/_vendor/greaseweazle/image/fdi.py +41 -0
  90. diskstack-1.0.0/diskstack/_vendor/greaseweazle/image/hdm.py +15 -0
  91. diskstack-1.0.0/diskstack/_vendor/greaseweazle/image/hfe.py +742 -0
  92. diskstack-1.0.0/diskstack/_vendor/greaseweazle/image/image.py +130 -0
  93. diskstack-1.0.0/diskstack/_vendor/greaseweazle/image/imd.py +201 -0
  94. diskstack-1.0.0/diskstack/_vendor/greaseweazle/image/img.py +111 -0
  95. diskstack-1.0.0/diskstack/_vendor/greaseweazle/image/kryoflux.py +304 -0
  96. diskstack-1.0.0/diskstack/_vendor/greaseweazle/image/mgt.py +15 -0
  97. diskstack-1.0.0/diskstack/_vendor/greaseweazle/image/msa.py +153 -0
  98. diskstack-1.0.0/diskstack/_vendor/greaseweazle/image/nfd.py +113 -0
  99. diskstack-1.0.0/diskstack/_vendor/greaseweazle/image/nsi.py +47 -0
  100. diskstack-1.0.0/diskstack/_vendor/greaseweazle/image/scp.py +471 -0
  101. diskstack-1.0.0/diskstack/_vendor/greaseweazle/image/sf7.py +15 -0
  102. diskstack-1.0.0/diskstack/_vendor/greaseweazle/image/sharp2d.py +15 -0
  103. diskstack-1.0.0/diskstack/_vendor/greaseweazle/image/td0.py +141 -0
  104. diskstack-1.0.0/diskstack/_vendor/greaseweazle/image/xdf.py +15 -0
  105. diskstack-1.0.0/diskstack/_vendor/greaseweazle/optimised/__init__.py +22 -0
  106. diskstack-1.0.0/diskstack/_vendor/greaseweazle/tools/__init__.py +0 -0
  107. diskstack-1.0.0/diskstack/_vendor/greaseweazle/tools/util.py +133 -0
  108. diskstack-1.0.0/diskstack/_vendor/greaseweazle/track.py +494 -0
  109. diskstack-1.0.0/diskstack/cache.py +210 -0
  110. diskstack-1.0.0/diskstack/candidates.py +396 -0
  111. diskstack-1.0.0/diskstack/cli.py +348 -0
  112. diskstack-1.0.0/diskstack/errors.py +5 -0
  113. diskstack-1.0.0/diskstack/filler.py +29 -0
  114. diskstack-1.0.0/diskstack/formats.py +381 -0
  115. diskstack-1.0.0/diskstack/parallel.py +73 -0
  116. diskstack-1.0.0/diskstack/report.py +353 -0
  117. diskstack-1.0.0/diskstack/stack.py +317 -0
  118. diskstack-1.0.0/diskstack.egg-info/PKG-INFO +329 -0
  119. diskstack-1.0.0/diskstack.egg-info/SOURCES.txt +139 -0
  120. diskstack-1.0.0/diskstack.egg-info/dependency_links.txt +1 -0
  121. diskstack-1.0.0/diskstack.egg-info/entry_points.txt +2 -0
  122. diskstack-1.0.0/diskstack.egg-info/requires.txt +10 -0
  123. diskstack-1.0.0/diskstack.egg-info/top_level.txt +1 -0
  124. diskstack-1.0.0/examples/README.md +9 -0
  125. diskstack-1.0.0/examples/console-session.txt +49 -0
  126. diskstack-1.0.0/examples/diskstack-report.json +28489 -0
  127. diskstack-1.0.0/pyproject.toml +65 -0
  128. diskstack-1.0.0/setup.cfg +4 -0
  129. diskstack-1.0.0/tests/conftest.py +70 -0
  130. diskstack-1.0.0/tests/fixtures/make_fixtures.py +194 -0
  131. diskstack-1.0.0/tests/test_amiga.py +154 -0
  132. diskstack-1.0.0/tests/test_cache.py +289 -0
  133. diskstack-1.0.0/tests/test_cli.py +296 -0
  134. diskstack-1.0.0/tests/test_filler.py +83 -0
  135. diskstack-1.0.0/tests/test_images.py +169 -0
  136. diskstack-1.0.0/tests/test_parallel.py +38 -0
  137. diskstack-1.0.0/tests/test_report.py +238 -0
  138. diskstack-1.0.0/tests/test_scp_flux.py +54 -0
  139. diskstack-1.0.0/tests/test_stacking.py +81 -0
  140. diskstack-1.0.0/tests/test_vote.py +217 -0
  141. diskstack-1.0.0/tools/vendor.py +213 -0
@@ -0,0 +1,62 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0 - 2026-09-12
4
+
5
+ First release.
6
+
7
+ - Merges two or more dumps of one floppy disk into a single sector image.
8
+ - Reads flux (`.scp`, KryoFlux `.raw` streams, `.hfe`) and sector images
9
+ (`.img`, `.ima`, `.st`, `.adf`, `.imd`), in any mix.
10
+ - Decodes every revolution of every flux capture, not just the first two.
11
+ - Groups read attempts by the decoded `(cylinder, head, sector id)`, so
12
+ captures with different index phases and motor speeds still line up.
13
+ - Three tiers per sector: a passing CRC wins, else the attempts are rebuilt
14
+ into candidate payloads that have to satisfy a check value off the disk,
15
+ else unresolved.
16
+ - Three reconstruction routes, reported per sector as `method`: a byte-wise
17
+ majority, a majority of one payload per input file so a capture with more
18
+ revolutions cannot outvote the rest, and each read on its own, which
19
+ recovers a sector whose CRC bytes rather than payload were damaged.
20
+ - Treats Greaseweazle's `-=[BAD SECTOR]=-` filler as a failed read.
21
+ - Prints a `gw read --tracks=` command naming only the tracks still unresolved.
22
+ - Writes `diskstack-report.json` with per-sector provenance, schema 1.
23
+ - IBM FM/MFM and AmigaDOS MFM formats, auto-detected.
24
+ - Decodes flux one track per core, `-j/--jobs` to change it, falling back to a
25
+ single process if the worker pool cannot start.
26
+ - Flags a sector as unstable when one capture read it two different ways across
27
+ its own revolutions, and says so beside the re-read command, because weak
28
+ bits do not settle down however many more passes you make.
29
+ - Refuses to write the merged image or the report over one of the inputs.
30
+ - Warns when a sector image is not the size the chosen format implies, since
31
+ the extra sectors get ignored and a missing tail counts as unread.
32
+ - Takes only the sectors a truncated `.img` or `.adf` actually holds. The
33
+ readers pad a short image to the format length and mark what they invented
34
+ CRC-clean, which would otherwise win the merge with zeroes.
35
+ - Rejects an output path it cannot write before reading any input rather than
36
+ after decoding all of them.
37
+ - Calls a sector verified only when some read of it satisfied a check value off
38
+ the disk, and ends a merge of sector images by saying nothing confirmed them
39
+ instead of claiming a CRC did.
40
+ - Flags a sector contested when two dumps both read it cleanly and disagreed,
41
+ keeps the best-ranked dump's copy, and names those sectors on stdout, since
42
+ nothing in a sector image can arbitrate between them.
43
+ - Applies a repeated `--pll` to every flux container. KryoFlux streams and HFE
44
+ images previously took the first setting and ignored the rest.
45
+ - Refuses a report path equal to the merged image.
46
+ - Counts a KryoFlux input as the whole stream set rather than the single file
47
+ named on the command line.
48
+ - Compares each run against the report it replaces and says how many sectors
49
+ the new capture recovered, or that nothing moved, which is the loop's stop
50
+ condition. Also in the report as `since_last_report`.
51
+ - Stops calling a sector unstable when a repeated `--pll` read one revolution
52
+ two ways. Weak bits are revolutions disagreeing with each other, not two
53
+ decodes of the same revolution.
54
+ - Rejects an input it cannot open, including a KryoFlux name that does not fit
55
+ the per-track pattern, before decoding any of the others.
56
+ - Measures a KryoFlux set by the track files the reader will open, so a second
57
+ capture sitting in the same directory is not counted in.
58
+ - Keeps decoded flux and the detected format in `.diskstack-cache` beside the
59
+ output, so adding a capture to the stack only decodes the new one. Three
60
+ captures of a 360K disk go from 6.5 seconds to 0.4 on a repeat run.
61
+ `--cache DIR` moves it, `--no-cache` turns it off, and every input says in
62
+ the report whether it was decoded or reused.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Christian Bosch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,10 @@
1
+ include CHANGELOG.md
2
+ graft examples
3
+ graft tests
4
+ graft tools
5
+
6
+ # The captures are 19 MB each and are rebuilt by make_fixtures.py.
7
+ prune tests/fixtures
8
+ include tests/fixtures/make_fixtures.py
9
+
10
+ global-exclude *.py[cod]
diskstack-1.0.0/NOTICE ADDED
@@ -0,0 +1,55 @@
1
+ diskstack bundles part of Greaseweazle.
2
+
3
+ https://github.com/keirf/greaseweazle
4
+ commit 26690f89967d519e0106ab9566019a026b920bb4 (master, post-v1.23)
5
+
6
+ Greaseweazle is released into the public domain under the Unlicense. The full
7
+ text is in diskstack/_vendor/greaseweazle/COPYING.
8
+
9
+ Greaseweazle is not published on PyPI, so its flux and sector decoders are
10
+ vendored rather than depended on. tools/vendor.py regenerates the whole tree
11
+ from that commit; nothing under diskstack/_vendor/ is edited by hand.
12
+
13
+ What is vendored: src/greaseweazle/ minus usb.py, cli.py and tools/. TrackSet,
14
+ range_str and columnify are extracted from tools/util.py into a standalone
15
+ module because the rest of that file drives the USB hardware and needs
16
+ pyserial.
17
+
18
+ Local changes, all applied automatically by tools/vendor.py:
19
+
20
+ * imports rewritten from `greaseweazle.*` to
21
+ `diskstack._vendor.greaseweazle.*`
22
+
23
+ * greaseweazle/__init__.py is generated. It carries the vendored commit as
24
+ __version__ and imports codec.codec, because codec/codec.py imports every
25
+ codec at module scope and those codecs import back into codec.ibm.ibm, so
26
+ importing e.g. image.imd first lands on a partially-initialised module.
27
+ Upstream never hits this because its CLI always reaches codec.codec first.
28
+
29
+ * codec/ibm/ibm.py: the two `print("Unknown mark %02x")` warnings are
30
+ dropped. Damaged tracks produce them constantly and they would corrupt the
31
+ table diskstack prints on stdout.
32
+
33
+ * optimised/__init__.py: the two warnings about the missing C extension are
34
+ dropped, for the same reason. The pure-Python fallback is used and is fast
35
+ enough (about 75 ms per three-revolution track).
36
+
37
+ * image/imd.py: the reader now records the per-sector data-error flag
38
+ (rec & 4) and the data-unavailable code (rec == 0) as a failed CRC instead
39
+ of discarding both. An IMD full of error sectors is exactly the input
40
+ diskstack exists to merge.
41
+
42
+ * tools/util.py: TrackSet.TrackIter gets an __iter__ returning itself. An
43
+ iterator has to be iterable, and Python 3.13 enforces it, so without this
44
+ every `for track in diskdef.tracks` raises TypeError there.
45
+
46
+ * codec/codec.py: importlib.resources.open_text is replaced with
47
+ importlib.resources.files(...).joinpath(...).open(), which is the form
48
+ that still works once the package is installed as a wheel.
49
+
50
+ The test fixtures download Transylvania.img from the fluxfox test corpus:
51
+
52
+ https://github.com/dbalsom/fluxfox/tree/main/tests/images/transylvania
53
+
54
+ See the LICENSE.txt in that directory for its terms. Nothing from it is
55
+ redistributed here; tests/fixtures/make_fixtures.py fetches it on demand.
@@ -0,0 +1,329 @@
1
+ Metadata-Version: 2.4
2
+ Name: diskstack
3
+ Version: 1.0.0
4
+ Summary: Merge several dumps of one floppy disk into the best possible image, and say what is still missing
5
+ Author-email: Christian Bosch <cbosch101@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Booyaka101/diskstack
8
+ Project-URL: Repository, https://github.com/Booyaka101/diskstack
9
+ Project-URL: Issues, https://github.com/Booyaka101/diskstack/issues
10
+ Project-URL: Changelog, https://github.com/Booyaka101/diskstack/blob/main/CHANGELOG.md
11
+ Keywords: floppy,flux,greaseweazle,supercard-pro,scp,preservation,disk-image,data-recovery,retrocomputing,amiga,mfm
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: System :: Archiving
20
+ Classifier: Topic :: System :: Recovery Tools
21
+ Requires-Python: >=3.11
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ License-File: NOTICE
25
+ License-File: diskstack/_vendor/greaseweazle/COPYING
26
+ Requires-Dist: click>=8.1
27
+ Requires-Dist: bitarray>=2.9
28
+ Requires-Dist: crcmod>=1.7
29
+ Provides-Extra: test
30
+ Requires-Dist: pytest>=7; extra == "test"
31
+ Provides-Extra: dev
32
+ Requires-Dist: pytest>=7; extra == "dev"
33
+ Requires-Dist: ruff>=0.16; extra == "dev"
34
+ Dynamic: license-file
35
+
36
+ # diskstack
37
+
38
+ Merge several dumps of one floppy disk into the best possible image, and get
39
+ told exactly what is still missing.
40
+
41
+ ## The loop
42
+
43
+ Read the disk twice, stack the dumps, and diskstack hands you back a
44
+ Greaseweazle command that re-reads only the tracks that are still bad:
45
+
46
+ ```
47
+ $ diskstack disk_a.scp disk_b.scp -o merged.img
48
+
49
+ 720 sectors: 714 clean (from 2 files), 6 unresolved
50
+
51
+ Tracks needing attention
52
+ cyl head clean voted unresolved missing sector ids still bad
53
+ 17 0 7 0 2 0 4,7
54
+ 18 0 7 0 2 0 4,7
55
+ 19 0 7 0 2 0 4,7
56
+
57
+ Re-read just these tracks, then run diskstack again with the new capture added:
58
+ gw read retry.scp --tracks=c=17-19:h=0
59
+ ```
60
+
61
+ Three tracks instead of eighty, so the drive spends seconds on the media
62
+ rather than minutes. Feed the new capture back in:
63
+
64
+ ```
65
+ $ gw read disk_c.scp --tracks=c=17-19:h=0
66
+ $ diskstack disk_a.scp disk_b.scp disk_c.scp -o merged.img
67
+
68
+ 720 sectors: 714 clean (from 3 files), 6 recovered by vote
69
+ Since the last report: 6 recovered.
70
+
71
+ Every sector confirmed by CRC. Nothing left to re-read.
72
+ ```
73
+
74
+ That run produced a file byte-identical to the original disk image. Repeat
75
+ until the unresolved count reaches zero or stops moving. diskstack reads the
76
+ report it is about to overwrite, so it tells you which of the two happened:
77
+ either how many sectors the new capture recovered, or that nothing changed and
78
+ another pass at the same settings is unlikely to. It also keeps the decoded
79
+ flux, so the second run only decodes the capture you just added: re-running
80
+ the three-capture merge above takes 0.4 seconds against 6.5 the first time.
81
+ The full session is in
82
+ [examples/console-session.txt](examples/console-session.txt) and the report it
83
+ wrote is in [examples/diskstack-report.json](examples/diskstack-report.json).
84
+
85
+ ## Install
86
+
87
+ ```
88
+ pip install diskstack
89
+ ```
90
+
91
+ Python 3.11 or newer. No hardware, no network, no configuration.
92
+
93
+ Until the PyPI upload lands, `pip install
94
+ git+https://github.com/Booyaka101/diskstack` gets you the same thing.
95
+
96
+ ## Use
97
+
98
+ ```
99
+ diskstack disk_a.scp disk_b.scp disk_c.img -o merged.img
100
+ ```
101
+
102
+ Inputs are two or more dumps of the same disk, in any mix of:
103
+
104
+ | what | extensions |
105
+ | --- | --- |
106
+ | flux | `.scp` (SuperCard Pro), `.raw` (KryoFlux streams), `.hfe` (HxC) |
107
+ | sector images | `.img`, `.ima`, `.st`, `.adf`, `.imd` |
108
+
109
+ For KryoFlux, name any one of the per-track stream files and the rest of the
110
+ set is picked up from the directory.
111
+
112
+ Output is a sector image: `.img`, `.ima`, `.st`, `.adf` or `.imd`. Flux goes in
113
+ but never comes out. Along with it you get `diskstack-report.json` next to the
114
+ image and the table above on stdout.
115
+
116
+ Formats understood are IBM FM and MFM (PC 160K through 2.88M, Atari ST) and
117
+ AmigaDOS MFM. `diskstack --list-formats` prints the list. Detection is
118
+ automatic; `--format ibm.720` overrides it.
119
+
120
+ The exit code is 0 when every sector is confirmed and 2 when something is still
121
+ unresolved, so a retry loop can be scripted.
122
+
123
+ ## How it decides
124
+
125
+ Every sector of every revolution of every input is a separate read attempt.
126
+ Attempts are grouped by the `(cylinder, head, sector id)` in their ID address
127
+ mark, never by where they sit in the flux, because two captures of the same
128
+ disk do not line up: the index phase differs and so does the motor speed.
129
+
130
+ Per sector, in order:
131
+
132
+ 1. **clean** - an attempt whose own CRC or checksum passes. Taken as is.
133
+ 2. **recovered by vote** - no attempt passed, so the attempts get recombined
134
+ into candidate payloads and the first one that satisfies a check value off
135
+ the disk wins. Three routes are tried, best evidence first:
136
+ `majority` votes byte by byte across every attempt, `per_source_majority`
137
+ collapses each input file to one payload first so a capture with more
138
+ revolutions cannot outvote the files that read the sector correctly, and
139
+ `cross_check` tries each individual read as it stands, which is what
140
+ recovers a sector whose payload was fine and whose own CRC bytes were the
141
+ damaged part. The CRC bytes came off the same damaged track, so they get
142
+ voted on too and the voted check value is tried alongside the ones read.
143
+ Ties go to the dump that read the rest of the disk best. The report says
144
+ which route it was.
145
+ 3. **unresolved** - the vote still failed. The largest cluster of attempts that
146
+ agree exactly is written out, the sector is listed in the table, and its
147
+ track goes into the re-read command.
148
+
149
+ A sector that is good in any input is never worse in the output. There is a
150
+ test for that.
151
+
152
+ Only a handful of reconstructions are tried, deliberately. A CRC16 accepts the
153
+ wrong payload once in 65536 tries, so every extra candidate thrown at the check
154
+ value buys recovery at the price of a small chance of confidently writing out
155
+ garbage. Three routes that each mean something beats a brute-force sweep over
156
+ the contested bytes.
157
+
158
+ The check value has to come off the disk, which means a stack of only sector
159
+ images can never reach step 2. `.img` and `.adf` files carry data and nothing
160
+ to verify it with, so identical bytes in two images prove only that both dumps
161
+ read the same thing. Mixing in one flux capture is what gives the vote
162
+ something to check against.
163
+
164
+ That distinction runs through the whole report. A sector is `verified` when
165
+ some read of it satisfied a check value off the disk, and a merge of sector
166
+ images alone never gets that, so the closing line says the sectors came from
167
+ inputs that carry no check value instead of claiming a CRC confirmed them. A
168
+ sector is `contested` when two dumps both looked good and held different
169
+ bytes. Nothing in a sector image can arbitrate that, so diskstack keeps the
170
+ best-ranked dump's copy, prints which sectors it happened to, and leaves the
171
+ judgement to you. It is what deliberate weak bits look like, and also what two
172
+ dumps of two different disks look like.
173
+
174
+ A sector the merge could not confirm is marked `unstable` if one capture read
175
+ it two different ways across its own revolutions. That is what weak bits look
176
+ like, and they are usually deliberate, so more passes over the disk will not
177
+ settle them. The stdout note says so next to the re-read command. Two `--pll`
178
+ settings reading one revolution two ways is a different thing, a decode that
179
+ went marginal rather than a disk that moved, and does not count.
180
+
181
+ Sectors Greaseweazle filled with `-=[BAD SECTOR]=-` are treated as failed
182
+ reads, not as data, so a `.img` from an earlier bad session still contributes
183
+ its good sectors. `--keep-filler` turns that off.
184
+
185
+ A truncated `.img` or `.adf` contributes only the sectors the file actually
186
+ holds bytes for. The readers pad a short image out to the format's length and
187
+ mark every sector they invented as CRC-clean, so without that check a dump that
188
+ stopped halfway would win the merge with 512 bytes of nothing per sector.
189
+
190
+ ## Options
191
+
192
+ ```
193
+ -o, --output PATH Merged sector image to write (.img, .ima,
194
+ .st, .adf, .imd). [required]
195
+ -r, --report PATH JSON report path [default: diskstack-
196
+ report.json beside the output]
197
+ --no-report Do not write the JSON report.
198
+ -f, --format TEXT Disk format, e.g. ibm.720 or amiga.amigados.
199
+ Detected from the inputs when not given.
200
+ --list-formats Print the disk formats diskstack understands
201
+ and exit.
202
+ --pll SPEC Flux PLL settings, e.g.
203
+ period=5:phase=60:lowpass=1.5. Repeat to
204
+ decode each capture several ways and let the
205
+ results compete.
206
+ --revs N Use only the first N revolutions of each
207
+ flux capture [default: all of them] [x>=1]
208
+ --keep-filler Trust sectors written as '-=[BAD SECTOR]=-'
209
+ filler instead of treating them as failed
210
+ reads.
211
+ --fill-unresolved [best|filler|zero]
212
+ What to write for a sector no input
213
+ confirmed: its best guess, bad-sector
214
+ filler, or zeroes. [default: best]
215
+ --no-vote Skip the byte-wise majority vote; keep only
216
+ sectors whose own CRC passes.
217
+ -j, --jobs N Worker processes for decoding flux [default:
218
+ one per core, up to 8] [x>=0]
219
+ --cache DIR Where to keep decoded flux between runs
220
+ [default: .diskstack-cache beside the
221
+ output]
222
+ --no-cache Decode every flux capture again instead of
223
+ reusing the decode a previous run stored.
224
+ --retry-name NAME Filename used in the printed gw read
225
+ command. [default: retry.scp]
226
+ -q, --quiet Print the summary and the re-read command,
227
+ no tables.
228
+ -V, --version Show the version and exit.
229
+ -h, --help Show this message and exit.
230
+ ```
231
+
232
+ Decoding flux is the slow part, so it runs one track per core by default. A
233
+ three-capture 40-cylinder merge takes about six seconds on eight cores against
234
+ half a minute on one. `--jobs 1` forces the single-process path, which is also
235
+ what happens automatically if the worker pool cannot start.
236
+
237
+ `--pll` is worth knowing about. Repeating it decodes each flux capture several
238
+ ways, and since two PLL settings disagree about different marginal bitcells,
239
+ they become independent attempts that the vote can use:
240
+
241
+ ```
242
+ diskstack a.scp b.scp -o merged.img --pll period=5:phase=60 --pll lowpass=1.5
243
+ ```
244
+
245
+ It applies to every flux container, `.scp`, `.raw` and `.hfe` alike.
246
+
247
+ Decoded flux is kept in `.diskstack-cache` beside the output, along with the
248
+ detected disk format, so a re-run only pays for the captures it has not seen.
249
+ An entry is named for a hash of the input's bytes and of every setting that
250
+ changes the decode, which means a fresh dump written over the same filename is
251
+ a new entry rather than a stale hit, and a capture that moved or was renamed
252
+ still counts as one it has seen. `--cache DIR` puts it somewhere else,
253
+ `--no-cache` turns it off, and deleting the directory costs nothing but the
254
+ next decode. It drops its least recently used entries once it passes 512 MB;
255
+ three captures of a 360K disk take 377 KB.
256
+
257
+ ## The report
258
+
259
+ `diskstack-report.json` carries one entry per sector of the disk: its status,
260
+ how many attempts it got, how many agreed, and which file and revolution each
261
+ contributing read came from. Plus per-input totals and the re-read trackspecs.
262
+ `schema` is versioned and starts at 1.
263
+
264
+ ```json
265
+ {
266
+ "cyl": 17, "head": 0, "sec_id": 4, "size": 512,
267
+ "status": "unresolved", "attempts": 6, "good": 0, "agreement": 3,
268
+ "discarded": 0, "unstable": false, "contested": false, "verified": false,
269
+ "method": null,
270
+ "sources": [{"path": "capture_a.scp", "rev": 0}]
271
+ }
272
+ ```
273
+
274
+ `method` names the route that rebuilt a recovered sector and is null for any
275
+ other status. `discarded` counts attempts thrown away because they decoded to
276
+ the wrong length for the sector. `verified` and `contested` are the two above,
277
+ and `totals` counts both across the disk.
278
+
279
+ `since_last_report` holds the `recovered`, `lost` and `still_bad` counts
280
+ against the report this run replaced, or null on the first run and whenever
281
+ the old report covers a different set of sectors. Each input carries `cached`,
282
+ saying whether its sectors were decoded on this run or reused from an earlier
283
+ one.
284
+
285
+ ## What it does not do
286
+
287
+ v1 is deliberately narrow. No GCR, so no Apple II and no Commodore. No writing
288
+ flux back out. No hardware access, so nothing here talks to a Greaseweazle
289
+ or a KryoFlux. No copy-protection preservation: this produces sector images,
290
+ which is the wrong container for weak bits and long tracks. No network calls.
291
+
292
+ ## Validation, honestly
293
+
294
+ The three-capture test builds genuine SuperCard Pro flux from a real PC floppy
295
+ image, damages each capture a different way, gives each its own index phase and
296
+ its own motor speed within 1.5 percent, and asserts the merged output is
297
+ byte-identical to the original. The AmigaDOS codec gets the same treatment one
298
+ track at a time, since the fixture disk is a PC one. That is a real decode of
299
+ real flux through Greaseweazle's own decoders, but the damage is synthetic. It
300
+ has not yet been run against dumps of a genuinely deteriorated disk.
301
+
302
+ If you have a disk that needs several passes, I would like to see the report.
303
+ Open an issue with the `diskstack-report.json` and what the disk is.
304
+
305
+ ## Tests
306
+
307
+ ```
308
+ pip install -e ".[dev]"
309
+ python tests/fixtures/make_fixtures.py # downloads 360 KB, writes 55 MB
310
+ python -m pytest tests -q
311
+ ruff check .
312
+ ```
313
+
314
+ The fixture builder needs the network once. Everything else runs offline.
315
+
316
+ ## Built on Greaseweazle
317
+
318
+ The flux and sector decoders are [Greaseweazle's](
319
+ https://github.com/keirf/greaseweazle), vendored under
320
+ `diskstack/_vendor/greaseweazle` because Greaseweazle is not published on PyPI.
321
+ It is public domain under the Unlicense. `tools/vendor.py` regenerates the tree
322
+ from a pinned commit and applies every local change; `NOTICE` lists them.
323
+
324
+ Runtime dependencies are `click`, `bitarray` and `crcmod`. The last two are
325
+ what the vendored decoders need.
326
+
327
+ ## License
328
+
329
+ MIT. See `LICENSE`, and `NOTICE` for the vendored code.