oaknut-hfe 13.6.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Robert Smallshire
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,94 @@
1
+ Metadata-Version: 2.4
2
+ Name: oaknut-hfe
3
+ Version: 13.6.0
4
+ Summary: Python library for reading HFE (HxC Floppy Emulator) flux-level disc images
5
+ Author-email: Robert Smallshire <robert@smallshire.org.uk>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/rob-smallshire/oaknut/tree/master/packages/oaknut-hfe
8
+ Project-URL: Documentation, https://rob-smallshire.github.io/oaknut/disc/api/reference/hfe.html
9
+ Project-URL: Repository, https://github.com/rob-smallshire/oaknut
10
+ Project-URL: Issues, https://github.com/rob-smallshire/oaknut/issues
11
+ Classifier: Development Status :: 2 - Pre-Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Requires-Python: >=3.12
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: oaknut-exception>=10.0
23
+ Requires-Dist: oaknut-extension>=10.0
24
+ Requires-Dist: oaknut-filesystem>=13.5
25
+ Provides-Extra: cli
26
+ Requires-Dist: oaknut-cli>=10.0; extra == "cli"
27
+ Requires-Dist: oaknut-file>=10.0; extra == "cli"
28
+ Dynamic: license-file
29
+
30
+ # oaknut-hfe
31
+
32
+ [![PyPI version](https://img.shields.io/pypi/v/oaknut-hfe)](https://pypi.org/project/oaknut-hfe/)
33
+ [![CI](https://github.com/rob-smallshire/oaknut/actions/workflows/ci.yml/badge.svg)](https://github.com/rob-smallshire/oaknut/actions/workflows/ci.yml)
34
+ [![Python versions](https://img.shields.io/pypi/pyversions/oaknut-hfe)](https://pypi.org/project/oaknut-hfe/)
35
+ [![License: MIT](https://img.shields.io/pypi/l/oaknut-hfe)](https://github.com/rob-smallshire/oaknut/blob/master/packages/oaknut-hfe/LICENSE)
36
+
37
+ HFE (HxC Floppy Emulator) disc-image support for the
38
+ [oaknut](https://github.com/rob-smallshire/oaknut) family of packages.
39
+
40
+ An HFE file records each track of a floppy disc as the raw bitstream the
41
+ drive head delivers. It is the native format of the HxC and Gotek floppy
42
+ emulators and a common output of preservation capture hardware such as
43
+ the Greaseweazle. Because it sits below the sector level, an HFE image
44
+ keeps everything an original disc carries, including the non-standard
45
+ sector IDs, deliberate CRC errors, deleted-data marks and extra tracks
46
+ that copy protection relies on.
47
+
48
+ This package reads HFE v1 and v3 images, decodes their FM and MFM tracks
49
+ into sectors, and flattens a decoded disc into the logical sector image
50
+ that the oaknut filesystems read.
51
+
52
+ ## Usage
53
+
54
+ Every `disc` command that reads an image opens an HFE file as the sector
55
+ image decoded from it:
56
+
57
+ ```sh
58
+ disc ls 'Dunjunz_D1S1.hfe:$'
59
+ disc export Dunjunz_D1S1.hfe dunjunz/
60
+ ```
61
+
62
+ The `disc hfe` commands look below the sector level:
63
+
64
+ ```sh
65
+ disc hfe info Dunjunz_D1S1.hfe # header and decoded layout
66
+ disc hfe sectors --anomalies Dunjunz_D1S1.hfe # what protection put there
67
+ disc hfe flatten Dunjunz_D1S1.hfe Dunjunz_D1S1.ssd
68
+ ```
69
+
70
+ From Python:
71
+
72
+ ```python
73
+ from oaknut.hfe import HFEImage, analyse, decode_image, flatten
74
+
75
+ disc = decode_image(HFEImage.from_filepath("Dunjunz_D1S1.hfe"))
76
+ flat = flatten(disc) # flat.data is the .ssd image
77
+ for finding in analyse(disc, flat.layout):
78
+ if finding.anomalous:
79
+ print(finding.track, finding.side, finding.remarks)
80
+ ```
81
+
82
+ ## Status
83
+
84
+ Pre-alpha, read-only.
85
+
86
+ ## Installation
87
+
88
+ ```sh
89
+ uv add oaknut-hfe
90
+ ```
91
+
92
+ ## License
93
+
94
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,65 @@
1
+ # oaknut-hfe
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/oaknut-hfe)](https://pypi.org/project/oaknut-hfe/)
4
+ [![CI](https://github.com/rob-smallshire/oaknut/actions/workflows/ci.yml/badge.svg)](https://github.com/rob-smallshire/oaknut/actions/workflows/ci.yml)
5
+ [![Python versions](https://img.shields.io/pypi/pyversions/oaknut-hfe)](https://pypi.org/project/oaknut-hfe/)
6
+ [![License: MIT](https://img.shields.io/pypi/l/oaknut-hfe)](https://github.com/rob-smallshire/oaknut/blob/master/packages/oaknut-hfe/LICENSE)
7
+
8
+ HFE (HxC Floppy Emulator) disc-image support for the
9
+ [oaknut](https://github.com/rob-smallshire/oaknut) family of packages.
10
+
11
+ An HFE file records each track of a floppy disc as the raw bitstream the
12
+ drive head delivers. It is the native format of the HxC and Gotek floppy
13
+ emulators and a common output of preservation capture hardware such as
14
+ the Greaseweazle. Because it sits below the sector level, an HFE image
15
+ keeps everything an original disc carries, including the non-standard
16
+ sector IDs, deliberate CRC errors, deleted-data marks and extra tracks
17
+ that copy protection relies on.
18
+
19
+ This package reads HFE v1 and v3 images, decodes their FM and MFM tracks
20
+ into sectors, and flattens a decoded disc into the logical sector image
21
+ that the oaknut filesystems read.
22
+
23
+ ## Usage
24
+
25
+ Every `disc` command that reads an image opens an HFE file as the sector
26
+ image decoded from it:
27
+
28
+ ```sh
29
+ disc ls 'Dunjunz_D1S1.hfe:$'
30
+ disc export Dunjunz_D1S1.hfe dunjunz/
31
+ ```
32
+
33
+ The `disc hfe` commands look below the sector level:
34
+
35
+ ```sh
36
+ disc hfe info Dunjunz_D1S1.hfe # header and decoded layout
37
+ disc hfe sectors --anomalies Dunjunz_D1S1.hfe # what protection put there
38
+ disc hfe flatten Dunjunz_D1S1.hfe Dunjunz_D1S1.ssd
39
+ ```
40
+
41
+ From Python:
42
+
43
+ ```python
44
+ from oaknut.hfe import HFEImage, analyse, decode_image, flatten
45
+
46
+ disc = decode_image(HFEImage.from_filepath("Dunjunz_D1S1.hfe"))
47
+ flat = flatten(disc) # flat.data is the .ssd image
48
+ for finding in analyse(disc, flat.layout):
49
+ if finding.anomalous:
50
+ print(finding.track, finding.side, finding.remarks)
51
+ ```
52
+
53
+ ## Status
54
+
55
+ Pre-alpha, read-only.
56
+
57
+ ## Installation
58
+
59
+ ```sh
60
+ uv add oaknut-hfe
61
+ ```
62
+
63
+ ## License
64
+
65
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,55 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "oaknut-hfe"
7
+ dynamic = ["version"]
8
+ authors = [{ name = "Robert Smallshire", email = "robert@smallshire.org.uk" }]
9
+ description = "Python library for reading HFE (HxC Floppy Emulator) flux-level disc images"
10
+ readme = "README.md"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ requires-python = ">=3.12"
14
+ classifiers = [
15
+ "Development Status :: 2 - Pre-Alpha",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ ]
24
+ dependencies = [
25
+ "oaknut-exception>=10.0",
26
+ "oaknut-extension>=10.0",
27
+ "oaknut-filesystem>=13.5",
28
+ ]
29
+
30
+ # The `cli` extra pulls the shared CLI toolkit so this package can
31
+ # contribute `disc hfe` subcommands. The library core never imports it.
32
+ [project.optional-dependencies]
33
+ cli = ["oaknut-cli>=10.0", "oaknut-file>=10.0"]
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/rob-smallshire/oaknut/tree/master/packages/oaknut-hfe"
37
+ Documentation = "https://rob-smallshire.github.io/oaknut/disc/api/reference/hfe.html"
38
+ Repository = "https://github.com/rob-smallshire/oaknut"
39
+ Issues = "https://github.com/rob-smallshire/oaknut/issues"
40
+
41
+ # The image decoder contributed to the oaknut.decoder axis: any `disc`
42
+ # command reading an image opens an HFE file as its flattened sectors.
43
+ [project.entry-points."oaknut.decoder"]
44
+ hfe = "oaknut.hfe.decoder:HFEDecoder"
45
+
46
+ # Subcommands contributed to the `disc` CLI on the oaknut.command axis:
47
+ # `disc hfe <subcommand>`. Loaded only with the [cli] extra.
48
+ [project.entry-points."oaknut.command"]
49
+ hfe = "oaknut.hfe.cli:hfe"
50
+
51
+ [tool.setuptools.dynamic]
52
+ version = { attr = "oaknut.hfe.__version__" }
53
+
54
+ [tool.setuptools.packages.find]
55
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,89 @@
1
+ """HFE (HxC Floppy Emulator) flux-level disc images.
2
+
3
+ An HFE file records each track of a floppy disc as the raw bitstream a
4
+ drive's read head would deliver, sampled on a fixed cell clock. It is the
5
+ native format of the HxC and Gotek floppy emulators and a common output
6
+ of preservation capture hardware such as the Greaseweazle. Because it sits
7
+ below the sector level, an HFE image keeps everything an original disc
8
+ carries — including the non-standard sector IDs, deliberate CRC errors,
9
+ deleted-data marks and extra tracks that copy protection relies on.
10
+
11
+ This package reads HFE v1 (``HXCPICFE``) and v3 (``HXCHFEV3``) images,
12
+ decodes their FM and MFM tracks into sectors, and flattens a decoded disc
13
+ into the logical sector image that the oaknut filesystems read.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ __version__ = "13.6.0"
19
+
20
+ from oaknut.hfe.build import (
21
+ TRACK_SAMPLES_300RPM_250KBPS,
22
+ FormattedSector,
23
+ build_track,
24
+ samples_per_half_cell,
25
+ track_samples,
26
+ )
27
+ from oaknut.hfe.container import (
28
+ HFE_V1_SIGNATURE,
29
+ HFE_V3_SIGNATURE,
30
+ HFEHeader,
31
+ HFEImage,
32
+ TrackEncoding,
33
+ TrackSamples,
34
+ write_hfe,
35
+ )
36
+ from oaknut.hfe.decode import (
37
+ DataMark,
38
+ DecodedDisc,
39
+ DecodedSector,
40
+ DecodedTrack,
41
+ Encoding,
42
+ SectorID,
43
+ decode_image,
44
+ decode_track,
45
+ )
46
+ from oaknut.hfe.decoder import HFEDecoder
47
+ from oaknut.hfe.exceptions import HFEError, HFEFormatError
48
+ from oaknut.hfe.flat import FlatImage, SectorLayout, flatten, flatten_hfe
49
+ from oaknut.hfe.report import SectorFinding, TrackFinding, analyse
50
+
51
+ __all__ = [
52
+ # The container
53
+ "HFEImage",
54
+ "HFEHeader",
55
+ "TrackSamples",
56
+ "TrackEncoding",
57
+ "HFE_V1_SIGNATURE",
58
+ "HFE_V3_SIGNATURE",
59
+ "write_hfe",
60
+ # Decoding
61
+ "Encoding",
62
+ "DataMark",
63
+ "SectorID",
64
+ "DecodedSector",
65
+ "DecodedTrack",
66
+ "DecodedDisc",
67
+ "decode_track",
68
+ "decode_image",
69
+ # Flattening
70
+ "SectorLayout",
71
+ "FlatImage",
72
+ "flatten",
73
+ "flatten_hfe",
74
+ # The sector report
75
+ "SectorFinding",
76
+ "TrackFinding",
77
+ "analyse",
78
+ # Building tracks
79
+ "FormattedSector",
80
+ "build_track",
81
+ "track_samples",
82
+ "samples_per_half_cell",
83
+ "TRACK_SAMPLES_300RPM_250KBPS",
84
+ # The oaknut.decoder plug-in
85
+ "HFEDecoder",
86
+ # Exceptions
87
+ "HFEError",
88
+ "HFEFormatError",
89
+ ]
@@ -0,0 +1,194 @@
1
+ """Build FM and MFM tracks from sector descriptions.
2
+
3
+ The inverse of :mod:`oaknut.hfe.decode`: lay sectors out as an Acorn
4
+ controller would format them, then convert the half-cells to HFE samples
5
+ for :func:`oaknut.hfe.container.write_hfe`. Each :class:`FormattedSector`
6
+ can carry the defects copy protection uses — any ID, a bad ID or data
7
+ CRC, a deleted-data mark, a missing data field — so decoder behaviour can
8
+ be tested without real captures of every trick.
9
+
10
+ Ported from Beebium's ``TrackBuilder``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Iterable
16
+ from dataclasses import dataclass
17
+
18
+ from oaknut.hfe.decode import Encoding, SectorID
19
+ from oaknut.hfe.ibm import (
20
+ DATA_MARK,
21
+ DELETED_DATA_MARK,
22
+ FM_DATA_MARK,
23
+ FM_DELETED_DATA_MARK,
24
+ FM_ID_MARK,
25
+ ID_MARK,
26
+ MFM_SYNC,
27
+ MFM_SYNC_CRC,
28
+ crc16,
29
+ fm_encode,
30
+ mfm_encode,
31
+ )
32
+
33
+ __all__ = [
34
+ "FormattedSector",
35
+ "build_track",
36
+ "track_samples",
37
+ "samples_per_half_cell",
38
+ "TRACK_SAMPLES_300RPM_250KBPS",
39
+ ]
40
+
41
+ #: Samples in one revolution at 300 rpm on a 250 kbit/s HFE clock.
42
+ TRACK_SAMPLES_300RPM_250KBPS = 100_000
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class FormattedSector:
47
+ """One sector as formatted onto a track."""
48
+
49
+ id: SectorID
50
+ #: The data field; padded with zeros, or truncated, to the ID's size.
51
+ data: bytes = b""
52
+ deleted: bool = False
53
+ #: Write no data field at all after the ID.
54
+ omit_data: bool = False
55
+ #: Corrupt the stored ID CRC.
56
+ bad_id_crc: bool = False
57
+ #: Corrupt the stored data CRC.
58
+ bad_data_crc: bool = False
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class _Layout:
63
+ gap1: int
64
+ sync: int
65
+ gap2: int
66
+ gap3: int
67
+ gap_byte: int
68
+
69
+
70
+ _FM_LAYOUT = _Layout(gap1=16, sync=6, gap2=11, gap3=21, gap_byte=0xFF)
71
+ _MFM_LAYOUT = _Layout(gap1=50, sync=12, gap2=22, gap3=24, gap_byte=0x4E)
72
+
73
+
74
+ def build_track(
75
+ sectors: Iterable[FormattedSector],
76
+ encoding: Encoding,
77
+ *,
78
+ half_cells: int | None = None,
79
+ ) -> str:
80
+ """The half-cells of a track holding *sectors* in the order given.
81
+
82
+ The track is padded with gap bytes to *half_cells* when given (one
83
+ revolution at 300 rpm is 50,000 FM or 100,000 MFM half-cells).
84
+
85
+ Raises:
86
+ ValueError: The sectors do not fit in *half_cells*.
87
+ """
88
+ if encoding is Encoding.FM:
89
+ cells = _fm_track(sectors)
90
+ filler = fm_encode(bytes([_FM_LAYOUT.gap_byte]))
91
+ else:
92
+ cells = _mfm_track(sectors)
93
+ filler = mfm_encode(bytes([_MFM_LAYOUT.gap_byte]))
94
+ if half_cells is not None:
95
+ if len(cells) > half_cells:
96
+ raise ValueError(f"track needs {len(cells)} half-cells; only {half_cells} fit")
97
+ cells += (filler * (half_cells // len(filler) + 1))[: half_cells - len(cells)]
98
+ return cells
99
+
100
+
101
+ def _field(sector: FormattedSector, encoding: Encoding) -> tuple[bytes, bytes]:
102
+ identifier = sector.id
103
+ id_bytes = bytes(
104
+ [identifier.cylinder, identifier.head, identifier.record, identifier.size_code]
105
+ )
106
+ data = sector.data[: identifier.size(encoding)].ljust(identifier.size(encoding), b"\x00")
107
+ return id_bytes, data
108
+
109
+
110
+ def _crc_bytes(crc: int, corrupt: bool) -> bytes:
111
+ return (crc ^ (0x5A5A if corrupt else 0)).to_bytes(2, "big")
112
+
113
+
114
+ def _fm_track(sectors: Iterable[FormattedSector]) -> str:
115
+ layout = _FM_LAYOUT
116
+ gap = bytes([layout.gap_byte])
117
+ parts = [fm_encode(gap * layout.gap1)]
118
+ for sector in sectors:
119
+ id_bytes, data = _field(sector, Encoding.FM)
120
+ parts.append(fm_encode(b"\x00" * layout.sync))
121
+ parts.append(FM_ID_MARK)
122
+ id_crc = crc16(bytes([ID_MARK]) + id_bytes)
123
+ parts.append(fm_encode(id_bytes + _crc_bytes(id_crc, sector.bad_id_crc)))
124
+ parts.append(fm_encode(gap * layout.gap2))
125
+ if not sector.omit_data:
126
+ mark = DELETED_DATA_MARK if sector.deleted else DATA_MARK
127
+ parts.append(fm_encode(b"\x00" * layout.sync))
128
+ parts.append(FM_DELETED_DATA_MARK if sector.deleted else FM_DATA_MARK)
129
+ data_crc = crc16(bytes([mark]) + data)
130
+ parts.append(fm_encode(data + _crc_bytes(data_crc, sector.bad_data_crc)))
131
+ parts.append(fm_encode(gap * layout.gap3))
132
+ return "".join(parts)
133
+
134
+
135
+ class _MFMWriter:
136
+ def __init__(self):
137
+ self._parts: list[str] = []
138
+ self._last_bit = 0
139
+
140
+ def bytes(self, data: bytes) -> None:
141
+ if data:
142
+ self._parts.append(mfm_encode(data, self._last_bit))
143
+ self._last_bit = data[-1] & 1
144
+
145
+ def sync(self) -> None:
146
+ self._parts.append(MFM_SYNC * 3)
147
+ self._last_bit = 1 # 0xA1 ends in a 1
148
+
149
+ def cells(self) -> str:
150
+ return "".join(self._parts)
151
+
152
+
153
+ def _mfm_track(sectors: Iterable[FormattedSector]) -> str:
154
+ layout = _MFM_LAYOUT
155
+ gap = bytes([layout.gap_byte])
156
+ writer = _MFMWriter()
157
+ writer.bytes(gap * layout.gap1)
158
+ for sector in sectors:
159
+ id_bytes, data = _field(sector, Encoding.MFM)
160
+ writer.bytes(b"\x00" * layout.sync)
161
+ writer.sync()
162
+ id_crc = crc16(bytes([ID_MARK]) + id_bytes, MFM_SYNC_CRC)
163
+ writer.bytes(bytes([ID_MARK]) + id_bytes + _crc_bytes(id_crc, sector.bad_id_crc))
164
+ writer.bytes(gap * layout.gap2)
165
+ if not sector.omit_data:
166
+ mark = DELETED_DATA_MARK if sector.deleted else DATA_MARK
167
+ writer.bytes(b"\x00" * layout.sync)
168
+ writer.sync()
169
+ data_crc = crc16(bytes([mark]) + data, MFM_SYNC_CRC)
170
+ writer.bytes(bytes([mark]) + data + _crc_bytes(data_crc, sector.bad_data_crc))
171
+ writer.bytes(gap * layout.gap3)
172
+ return writer.cells()
173
+
174
+
175
+ def samples_per_half_cell(encoding: Encoding, bitrate_kbps: int = 250) -> int:
176
+ """HFE samples per half-cell: an FM half-cell is 4 µs, an MFM one 2 µs.
177
+
178
+ Raises:
179
+ ValueError: The bit rate does not give a whole number of samples.
180
+ """
181
+ nominal = 125 if encoding is Encoding.FM else 250
182
+ count, remainder = divmod(bitrate_kbps, nominal)
183
+ if remainder or count == 0:
184
+ raise ValueError(f"{encoding.value} cannot be sampled at {bitrate_kbps} kbit/s")
185
+ return count
186
+
187
+
188
+ def track_samples(half_cells: str, encoding: Encoding, bitrate_kbps: int = 250) -> str:
189
+ """HFE samples for *half_cells*, each transition at the end of its cell."""
190
+ count = samples_per_half_cell(encoding, bitrate_kbps)
191
+ if count == 1:
192
+ return half_cells
193
+ pad = "0" * (count - 1)
194
+ return "".join(pad + cell for cell in half_cells)