oaknut-dfs 4.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.
- oaknut_dfs-4.0.0/LICENSE +21 -0
- oaknut_dfs-4.0.0/PKG-INFO +312 -0
- oaknut_dfs-4.0.0/README.md +287 -0
- oaknut_dfs-4.0.0/pyproject.toml +52 -0
- oaknut_dfs-4.0.0/setup.cfg +4 -0
- oaknut_dfs-4.0.0/src/oaknut/dfs/__init__.py +92 -0
- oaknut_dfs-4.0.0/src/oaknut/dfs/acorn_dfs_catalogue.py +726 -0
- oaknut_dfs-4.0.0/src/oaknut/dfs/acorn_encoding.py +241 -0
- oaknut_dfs-4.0.0/src/oaknut/dfs/adfs.py +2118 -0
- oaknut_dfs-4.0.0/src/oaknut/dfs/adfs_directory.py +445 -0
- oaknut_dfs-4.0.0/src/oaknut/dfs/adfs_free_space_map.py +355 -0
- oaknut_dfs-4.0.0/src/oaknut/dfs/basic.py +59 -0
- oaknut_dfs-4.0.0/src/oaknut/dfs/boot_option.py +15 -0
- oaknut_dfs-4.0.0/src/oaknut/dfs/catalogue.py +300 -0
- oaknut_dfs-4.0.0/src/oaknut/dfs/catalogued_surface.py +209 -0
- oaknut_dfs-4.0.0/src/oaknut/dfs/dfs.py +889 -0
- oaknut_dfs-4.0.0/src/oaknut/dfs/exceptions.py +165 -0
- oaknut_dfs-4.0.0/src/oaknut/dfs/formats.py +209 -0
- oaknut_dfs-4.0.0/src/oaknut/dfs/host_bridge.py +282 -0
- oaknut_dfs-4.0.0/src/oaknut/dfs/sectors_view.py +218 -0
- oaknut_dfs-4.0.0/src/oaknut/dfs/surface.py +278 -0
- oaknut_dfs-4.0.0/src/oaknut/dfs/unified_disc.py +116 -0
- oaknut_dfs-4.0.0/src/oaknut/dfs/watford_dfs_catalogue.py +973 -0
- oaknut_dfs-4.0.0/src/oaknut_dfs.egg-info/PKG-INFO +312 -0
- oaknut_dfs-4.0.0/src/oaknut_dfs.egg-info/SOURCES.txt +64 -0
- oaknut_dfs-4.0.0/src/oaknut_dfs.egg-info/dependency_links.txt +1 -0
- oaknut_dfs-4.0.0/src/oaknut_dfs.egg-info/requires.txt +2 -0
- oaknut_dfs-4.0.0/src/oaknut_dfs.egg-info/top_level.txt +1 -0
- oaknut_dfs-4.0.0/tests/test_acorn_dfs_catalogue.py +1373 -0
- oaknut_dfs-4.0.0/tests/test_acorn_encoding.py +379 -0
- oaknut_dfs-4.0.0/tests/test_adfs.py +500 -0
- oaknut_dfs-4.0.0/tests/test_adfs_access.py +293 -0
- oaknut_dfs-4.0.0/tests/test_adfs_compact.py +180 -0
- oaknut_dfs-4.0.0/tests/test_adfs_directory_serialize.py +236 -0
- oaknut_dfs-4.0.0/tests/test_adfs_directory_title.py +125 -0
- oaknut_dfs-4.0.0/tests/test_adfs_export_import.py +216 -0
- oaknut_dfs-4.0.0/tests/test_adfs_free_space_map_mutate.py +209 -0
- oaknut_dfs-4.0.0/tests/test_adfs_hard_disc.py +235 -0
- oaknut_dfs-4.0.0/tests/test_adfs_mkdir.py +116 -0
- oaknut_dfs-4.0.0/tests/test_adfs_properties.py +82 -0
- oaknut_dfs-4.0.0/tests/test_adfs_rename_cross_dir.py +115 -0
- oaknut_dfs-4.0.0/tests/test_adfs_rename_lock.py +174 -0
- oaknut_dfs-4.0.0/tests/test_adfs_rmdir.py +91 -0
- oaknut_dfs-4.0.0/tests/test_adfs_unlink.py +78 -0
- oaknut_dfs-4.0.0/tests/test_adfs_write.py +303 -0
- oaknut_dfs-4.0.0/tests/test_basic.py +66 -0
- oaknut_dfs-4.0.0/tests/test_beebem_images.py +400 -0
- oaknut_dfs-4.0.0/tests/test_catalogue_operations.py +408 -0
- oaknut_dfs-4.0.0/tests/test_catalogued_surface.py +355 -0
- oaknut_dfs-4.0.0/tests/test_create_adfs.py +131 -0
- oaknut_dfs-4.0.0/tests/test_create_adfs_hard_disc.py +210 -0
- oaknut_dfs-4.0.0/tests/test_create_dfs.py +208 -0
- oaknut_dfs-4.0.0/tests/test_dfs.py +955 -0
- oaknut_dfs-4.0.0/tests/test_dfs_advanced.py +342 -0
- oaknut_dfs-4.0.0/tests/test_dfs_export_import.py +363 -0
- oaknut_dfs-4.0.0/tests/test_dfs_path.py +339 -0
- oaknut_dfs-4.0.0/tests/test_dfs_path_new_methods.py +239 -0
- oaknut_dfs-4.0.0/tests/test_exceptions.py +254 -0
- oaknut_dfs-4.0.0/tests/test_game_images.py +207 -0
- oaknut_dfs-4.0.0/tests/test_host_bridge.py +244 -0
- oaknut_dfs-4.0.0/tests/test_reference_images_base.py +113 -0
- oaknut_dfs-4.0.0/tests/test_reference_integration.py +326 -0
- oaknut_dfs-4.0.0/tests/test_reference_metadata.py +80 -0
- oaknut_dfs-4.0.0/tests/test_surface.py +776 -0
- oaknut_dfs-4.0.0/tests/test_unified_disc.py +153 -0
- oaknut_dfs-4.0.0/tests/test_watford_dfs_catalogue.py +334 -0
oaknut_dfs-4.0.0/LICENSE
ADDED
|
@@ -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,312 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: oaknut-dfs
|
|
3
|
+
Version: 4.0.0
|
|
4
|
+
Summary: Python library for handling Acorn DFS disc images (SSD/DSD format) and ADFS 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
|
|
8
|
+
Project-URL: Repository, https://github.com/rob-smallshire/oaknut
|
|
9
|
+
Project-URL: Issues, https://github.com/rob-smallshire/oaknut/issues
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: oaknut-file>=1.0
|
|
23
|
+
Requires-Dist: typename>=1.0.4
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# oaknut-dfs
|
|
27
|
+
|
|
28
|
+
[](https://pypi.org/project/oaknut-dfs/)
|
|
29
|
+
[](https://github.com/rob-smallshire/oaknut-dfs/actions/workflows/tests.yml)
|
|
30
|
+
[](https://pypi.org/project/oaknut-dfs/)
|
|
31
|
+
[](https://github.com/rob-smallshire/oaknut-dfs/blob/master/LICENSE)
|
|
32
|
+
|
|
33
|
+
A Python library for reading, writing, and creating
|
|
34
|
+
[Acorn DFS](https://en.wikipedia.org/wiki/Disc_Filing_System) and
|
|
35
|
+
[ADFS](https://en.wikipedia.org/wiki/Advanced_Disc_Filing_System)
|
|
36
|
+
disc images, as used by the
|
|
37
|
+
[BBC Micro](https://en.wikipedia.org/wiki/BBC_Micro),
|
|
38
|
+
[Acorn Electron](https://en.wikipedia.org/wiki/Acorn_Electron),
|
|
39
|
+
and [BBC Master](https://en.wikipedia.org/wiki/BBC_Master).
|
|
40
|
+
|
|
41
|
+
With oaknut-dfs you can open DFS floppy images (SSD/DSD), ADFS floppy
|
|
42
|
+
images (ADF/ADL), and ADFS hard disc images (DAT/DSC) to browse
|
|
43
|
+
directories, read and write files, inspect metadata, and create new
|
|
44
|
+
formatted disc images --- all from Python, with a pathlib-inspired API.
|
|
45
|
+
|
|
46
|
+
## Supported formats
|
|
47
|
+
|
|
48
|
+
### DFS (Disc Filing System)
|
|
49
|
+
|
|
50
|
+
- **Acorn DFS**: 40-track and 80-track, single-sided (SSD) and double-sided (DSD)
|
|
51
|
+
- **Watford DFS**: Extended catalogue supporting up to 62 files
|
|
52
|
+
- **DSD interleaving**: Both interleaved and sequential double-sided layouts
|
|
53
|
+
|
|
54
|
+
### ADFS (Advanced Disc Filing System)
|
|
55
|
+
|
|
56
|
+
- **ADFS S/M/L**: Single- and double-sided floppy images (ADF/ADL)
|
|
57
|
+
- **ADFS hard disc**: SCSI hard disc images (DAT + DSC sidecar pairs)
|
|
58
|
+
- **Hierarchical directories**: Full directory tree navigation with pathlib-inspired API
|
|
59
|
+
- **Old map format**: Free space map parsing and validation
|
|
60
|
+
|
|
61
|
+
### Common
|
|
62
|
+
|
|
63
|
+
- **Acorn character encoding**: Custom codec for the BBC Micro character set (`£`, `¦`)
|
|
64
|
+
|
|
65
|
+
## Prerequisites
|
|
66
|
+
|
|
67
|
+
oaknut-dfs is a standard Python package and can be installed with any Python
|
|
68
|
+
package manager, including `pip`. The instructions below use
|
|
69
|
+
[`uv`](https://docs.astral.sh/uv/), which handles Python installation,
|
|
70
|
+
dependency resolution, and virtual environments automatically.
|
|
71
|
+
|
|
72
|
+
### Installing uv
|
|
73
|
+
|
|
74
|
+
**macOS (Homebrew):**
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
brew install uv
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**Linux / macOS (standalone installer):**
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**Windows:**
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
See the [uv installation docs](https://docs.astral.sh/uv/getting-started/installation/)
|
|
93
|
+
for other methods including pip, pipx, Cargo, Conda, Winget, and Scoop.
|
|
94
|
+
|
|
95
|
+
## Installation
|
|
96
|
+
|
|
97
|
+
### As a library dependency
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
uv add oaknut-dfs
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
or with pip:
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
pip install oaknut-dfs
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### For development
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
uv sync
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Usage
|
|
116
|
+
|
|
117
|
+
### DFS disc images
|
|
118
|
+
|
|
119
|
+
#### Opening and reading files
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from oaknut.dfs import DFS, ACORN_DFS_80T_SINGLE_SIDED
|
|
123
|
+
|
|
124
|
+
with DFS.from_file("Zalaga.ssd", ACORN_DFS_80T_SINGLE_SIDED) as dfs:
|
|
125
|
+
print(dfs.title) # 'ZALAG-L'
|
|
126
|
+
|
|
127
|
+
# Navigate with pathlib-inspired API
|
|
128
|
+
for entry in dfs.root / "$":
|
|
129
|
+
s = entry.stat()
|
|
130
|
+
print(f"{entry.name:10s} {s.length:6d} load={s.load_address:08X}")
|
|
131
|
+
|
|
132
|
+
# Read file data
|
|
133
|
+
data = (dfs.root / "$" / "ZALAGA").read_bytes()
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
#### Creating a new DFS disc
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from oaknut.dfs import DFS, ACORN_DFS_80T_SINGLE_SIDED
|
|
140
|
+
|
|
141
|
+
with DFS.create_file("demo.ssd", ACORN_DFS_80T_SINGLE_SIDED, title="DEMO") as dfs:
|
|
142
|
+
dfs.save("$.HELLO", b"Hello, World!", load_address=0x1900)
|
|
143
|
+
dfs.save("$.README", b"oaknut-dfs demo disc")
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
#### Double-sided discs (DSD)
|
|
147
|
+
|
|
148
|
+
DSD images contain two independent sides, each with its own catalogue.
|
|
149
|
+
This mirrors the BBC Micro, where double-sided discs were accessed as
|
|
150
|
+
separate drives using `*DRIVE 0` and `*DRIVE 2`.
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
from oaknut.dfs import DFS, ACORN_DFS_80T_DOUBLE_SIDED_INTERLEAVED
|
|
154
|
+
|
|
155
|
+
with DFS.from_file("game.dsd", ACORN_DFS_80T_DOUBLE_SIDED_INTERLEAVED) as side0:
|
|
156
|
+
print(side0.title)
|
|
157
|
+
|
|
158
|
+
with DFS.from_file("game.dsd", ACORN_DFS_80T_DOUBLE_SIDED_INTERLEAVED, side=1) as side1:
|
|
159
|
+
print(side1.title)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
#### Walking the disc
|
|
163
|
+
|
|
164
|
+
DFS directories (`$`, `A`--`Z`) appear as children of a virtual root:
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
with DFS.from_file("disc.ssd", ACORN_DFS_80T_SINGLE_SIDED) as dfs:
|
|
168
|
+
for dirpath, dirnames, filenames in dfs.root.walk():
|
|
169
|
+
for name in filenames:
|
|
170
|
+
print(dirpath / name)
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### ADFS floppy disc images
|
|
174
|
+
|
|
175
|
+
#### Opening and navigating
|
|
176
|
+
|
|
177
|
+
ADFS supports hierarchical directories. The format is auto-detected from
|
|
178
|
+
the image size:
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
from oaknut.dfs import ADFS
|
|
182
|
+
|
|
183
|
+
with ADFS.from_file("MasterWelcome.adl") as adfs:
|
|
184
|
+
print(adfs.title) # '80T Welcome & Utils'
|
|
185
|
+
|
|
186
|
+
# Navigate with / operator
|
|
187
|
+
for entry in adfs.root / "LIBRARY":
|
|
188
|
+
print(entry.name, entry.stat().length)
|
|
189
|
+
|
|
190
|
+
# Read a file
|
|
191
|
+
data = (adfs.root / "HELP" / "aform").read_bytes()
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
#### Walking the directory tree
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
with ADFS.from_file("disc.adl") as adfs:
|
|
198
|
+
for dirpath, dirnames, filenames in adfs.root.walk():
|
|
199
|
+
for name in filenames:
|
|
200
|
+
print(dirpath / name)
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
#### Creating a new ADFS floppy
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
from oaknut.dfs import ADFS, ADFS_L
|
|
207
|
+
|
|
208
|
+
with ADFS.create_file("blank.adl", ADFS_L, title="My Disc") as adfs:
|
|
209
|
+
pass # empty formatted disc ready for use
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Available floppy formats: `ADFS_S` (160KB), `ADFS_M` (320KB), `ADFS_L` (640KB).
|
|
213
|
+
|
|
214
|
+
### ADFS hard disc images
|
|
215
|
+
|
|
216
|
+
Hard disc images consist of a `.dat` file (raw sector data) and a `.dsc`
|
|
217
|
+
sidecar file (SCSI disc geometry). Pass either file to `from_file` ---
|
|
218
|
+
the companion is located automatically.
|
|
219
|
+
|
|
220
|
+
#### Opening a hard disc image
|
|
221
|
+
|
|
222
|
+
```python
|
|
223
|
+
from oaknut.dfs import ADFS
|
|
224
|
+
|
|
225
|
+
with ADFS.from_file("scsi0.dat") as adfs:
|
|
226
|
+
print(adfs.title)
|
|
227
|
+
print(f"{adfs.total_size // 1024}KB, {adfs.free_space // 1024}KB free")
|
|
228
|
+
|
|
229
|
+
for dirpath, dirnames, filenames in adfs.root.walk():
|
|
230
|
+
for name in filenames:
|
|
231
|
+
p = dirpath / name
|
|
232
|
+
print(f"{p} {p.stat().length}")
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
#### Creating a new hard disc image
|
|
236
|
+
|
|
237
|
+
Specify a capacity and the geometry is chosen automatically (4 heads,
|
|
238
|
+
33 sectors/track --- the Acorn convention):
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
from oaknut.dfs import ADFS
|
|
242
|
+
|
|
243
|
+
# Create a 20MB hard disc image
|
|
244
|
+
with ADFS.create_file("scsi0.dat", capacity_bytes=20 * 1024 * 1024, title="Data") as adfs:
|
|
245
|
+
pass # creates both scsi0.dat and scsi0.dsc
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
For explicit geometry control:
|
|
249
|
+
|
|
250
|
+
```python
|
|
251
|
+
with ADFS.create_file("scsi0.dat", cylinders=306, heads=4) as adfs:
|
|
252
|
+
pass
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
## Development
|
|
256
|
+
|
|
257
|
+
After cloning, install the pre-commit hooks:
|
|
258
|
+
|
|
259
|
+
```
|
|
260
|
+
uv run --group dev pre-commit install
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### Running the tests
|
|
264
|
+
|
|
265
|
+
```
|
|
266
|
+
uv run --group test pytest tests/ -v
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
## Architecture
|
|
270
|
+
|
|
271
|
+
The library uses a layered architecture with dependencies flowing downward:
|
|
272
|
+
|
|
273
|
+
1. **Sector access** (`surface.py`, `sectors_view.py`, `unified_disc.py`) ---
|
|
274
|
+
operates on buffers to convert logical sector numbers to physical byte
|
|
275
|
+
offsets. Handles disc geometry, interleaving schemes, and multi-surface
|
|
276
|
+
aggregation.
|
|
277
|
+
|
|
278
|
+
2. **Catalogue and directory management** --- two parallel implementations:
|
|
279
|
+
- **DFS** (`catalogue.py`, `acorn_dfs_catalogue.py`,
|
|
280
|
+
`watford_dfs_catalogue.py`) --- flat catalogue in sectors 0--1. Supports
|
|
281
|
+
Acorn DFS (31 files) and Watford DFS (62 files).
|
|
282
|
+
- **ADFS** (`adfs_directory.py`, `adfs_free_space_map.py`) --- hierarchical
|
|
283
|
+
directories stored as disc objects, with an explicit free space map.
|
|
284
|
+
|
|
285
|
+
3. **Filesystem API** --- user-facing interfaces with pathlib-inspired navigation:
|
|
286
|
+
- **DFS** (`dfs.py`) --- `DFS`, `DFSPath`, `DFSStat`
|
|
287
|
+
- **ADFS** (`adfs.py`) --- `ADFS`, `ADFSPath`, `ADFSStat`
|
|
288
|
+
|
|
289
|
+
## References
|
|
290
|
+
|
|
291
|
+
### Format specifications
|
|
292
|
+
|
|
293
|
+
- [Acorn DFS disc format](https://beebwiki.mdfs.net/Acorn_DFS_disc_format) ---
|
|
294
|
+
BeebWiki specification for the Acorn DFS catalogue layout.
|
|
295
|
+
- [Disc Filing System](https://en.wikipedia.org/wiki/Disc_Filing_System) ---
|
|
296
|
+
Wikipedia overview of DFS and its variants.
|
|
297
|
+
- [Advanced Disc Filing System](https://en.wikipedia.org/wiki/Advanced_Disc_Filing_System) ---
|
|
298
|
+
Wikipedia overview of ADFS and its evolution.
|
|
299
|
+
- [Guide to Disc Formats](https://github.com/geraldholdsworth/DiscImageManager) ---
|
|
300
|
+
Gerald Holdsworth's detailed technical reference for DFS, ADFS, and other formats.
|
|
301
|
+
- [INF file format](https://beebwiki.mdfs.net/INF_file_format) ---
|
|
302
|
+
BeebWiki specification for the `.inf` sidecar metadata format.
|
|
303
|
+
|
|
304
|
+
### Related tools and projects
|
|
305
|
+
|
|
306
|
+
- [oaknut-zip](https://github.com/rob-smallshire/oaknut-zip) ---
|
|
307
|
+
Sister project for extracting ZIP files containing Acorn metadata.
|
|
308
|
+
|
|
309
|
+
### Forum discussions
|
|
310
|
+
|
|
311
|
+
- [Stardot forum: DFS format](https://stardot.org.uk/forums/viewtopic.php?t=4714) ---
|
|
312
|
+
Community discussion of DFS disc image formats and variants.
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# oaknut-dfs
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/oaknut-dfs/)
|
|
4
|
+
[](https://github.com/rob-smallshire/oaknut-dfs/actions/workflows/tests.yml)
|
|
5
|
+
[](https://pypi.org/project/oaknut-dfs/)
|
|
6
|
+
[](https://github.com/rob-smallshire/oaknut-dfs/blob/master/LICENSE)
|
|
7
|
+
|
|
8
|
+
A Python library for reading, writing, and creating
|
|
9
|
+
[Acorn DFS](https://en.wikipedia.org/wiki/Disc_Filing_System) and
|
|
10
|
+
[ADFS](https://en.wikipedia.org/wiki/Advanced_Disc_Filing_System)
|
|
11
|
+
disc images, as used by the
|
|
12
|
+
[BBC Micro](https://en.wikipedia.org/wiki/BBC_Micro),
|
|
13
|
+
[Acorn Electron](https://en.wikipedia.org/wiki/Acorn_Electron),
|
|
14
|
+
and [BBC Master](https://en.wikipedia.org/wiki/BBC_Master).
|
|
15
|
+
|
|
16
|
+
With oaknut-dfs you can open DFS floppy images (SSD/DSD), ADFS floppy
|
|
17
|
+
images (ADF/ADL), and ADFS hard disc images (DAT/DSC) to browse
|
|
18
|
+
directories, read and write files, inspect metadata, and create new
|
|
19
|
+
formatted disc images --- all from Python, with a pathlib-inspired API.
|
|
20
|
+
|
|
21
|
+
## Supported formats
|
|
22
|
+
|
|
23
|
+
### DFS (Disc Filing System)
|
|
24
|
+
|
|
25
|
+
- **Acorn DFS**: 40-track and 80-track, single-sided (SSD) and double-sided (DSD)
|
|
26
|
+
- **Watford DFS**: Extended catalogue supporting up to 62 files
|
|
27
|
+
- **DSD interleaving**: Both interleaved and sequential double-sided layouts
|
|
28
|
+
|
|
29
|
+
### ADFS (Advanced Disc Filing System)
|
|
30
|
+
|
|
31
|
+
- **ADFS S/M/L**: Single- and double-sided floppy images (ADF/ADL)
|
|
32
|
+
- **ADFS hard disc**: SCSI hard disc images (DAT + DSC sidecar pairs)
|
|
33
|
+
- **Hierarchical directories**: Full directory tree navigation with pathlib-inspired API
|
|
34
|
+
- **Old map format**: Free space map parsing and validation
|
|
35
|
+
|
|
36
|
+
### Common
|
|
37
|
+
|
|
38
|
+
- **Acorn character encoding**: Custom codec for the BBC Micro character set (`£`, `¦`)
|
|
39
|
+
|
|
40
|
+
## Prerequisites
|
|
41
|
+
|
|
42
|
+
oaknut-dfs is a standard Python package and can be installed with any Python
|
|
43
|
+
package manager, including `pip`. The instructions below use
|
|
44
|
+
[`uv`](https://docs.astral.sh/uv/), which handles Python installation,
|
|
45
|
+
dependency resolution, and virtual environments automatically.
|
|
46
|
+
|
|
47
|
+
### Installing uv
|
|
48
|
+
|
|
49
|
+
**macOS (Homebrew):**
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
brew install uv
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Linux / macOS (standalone installer):**
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Windows:**
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
See the [uv installation docs](https://docs.astral.sh/uv/getting-started/installation/)
|
|
68
|
+
for other methods including pip, pipx, Cargo, Conda, Winget, and Scoop.
|
|
69
|
+
|
|
70
|
+
## Installation
|
|
71
|
+
|
|
72
|
+
### As a library dependency
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
uv add oaknut-dfs
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
or with pip:
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
pip install oaknut-dfs
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### For development
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
uv sync
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Usage
|
|
91
|
+
|
|
92
|
+
### DFS disc images
|
|
93
|
+
|
|
94
|
+
#### Opening and reading files
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from oaknut.dfs import DFS, ACORN_DFS_80T_SINGLE_SIDED
|
|
98
|
+
|
|
99
|
+
with DFS.from_file("Zalaga.ssd", ACORN_DFS_80T_SINGLE_SIDED) as dfs:
|
|
100
|
+
print(dfs.title) # 'ZALAG-L'
|
|
101
|
+
|
|
102
|
+
# Navigate with pathlib-inspired API
|
|
103
|
+
for entry in dfs.root / "$":
|
|
104
|
+
s = entry.stat()
|
|
105
|
+
print(f"{entry.name:10s} {s.length:6d} load={s.load_address:08X}")
|
|
106
|
+
|
|
107
|
+
# Read file data
|
|
108
|
+
data = (dfs.root / "$" / "ZALAGA").read_bytes()
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
#### Creating a new DFS disc
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from oaknut.dfs import DFS, ACORN_DFS_80T_SINGLE_SIDED
|
|
115
|
+
|
|
116
|
+
with DFS.create_file("demo.ssd", ACORN_DFS_80T_SINGLE_SIDED, title="DEMO") as dfs:
|
|
117
|
+
dfs.save("$.HELLO", b"Hello, World!", load_address=0x1900)
|
|
118
|
+
dfs.save("$.README", b"oaknut-dfs demo disc")
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
#### Double-sided discs (DSD)
|
|
122
|
+
|
|
123
|
+
DSD images contain two independent sides, each with its own catalogue.
|
|
124
|
+
This mirrors the BBC Micro, where double-sided discs were accessed as
|
|
125
|
+
separate drives using `*DRIVE 0` and `*DRIVE 2`.
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from oaknut.dfs import DFS, ACORN_DFS_80T_DOUBLE_SIDED_INTERLEAVED
|
|
129
|
+
|
|
130
|
+
with DFS.from_file("game.dsd", ACORN_DFS_80T_DOUBLE_SIDED_INTERLEAVED) as side0:
|
|
131
|
+
print(side0.title)
|
|
132
|
+
|
|
133
|
+
with DFS.from_file("game.dsd", ACORN_DFS_80T_DOUBLE_SIDED_INTERLEAVED, side=1) as side1:
|
|
134
|
+
print(side1.title)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
#### Walking the disc
|
|
138
|
+
|
|
139
|
+
DFS directories (`$`, `A`--`Z`) appear as children of a virtual root:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
with DFS.from_file("disc.ssd", ACORN_DFS_80T_SINGLE_SIDED) as dfs:
|
|
143
|
+
for dirpath, dirnames, filenames in dfs.root.walk():
|
|
144
|
+
for name in filenames:
|
|
145
|
+
print(dirpath / name)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### ADFS floppy disc images
|
|
149
|
+
|
|
150
|
+
#### Opening and navigating
|
|
151
|
+
|
|
152
|
+
ADFS supports hierarchical directories. The format is auto-detected from
|
|
153
|
+
the image size:
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
from oaknut.dfs import ADFS
|
|
157
|
+
|
|
158
|
+
with ADFS.from_file("MasterWelcome.adl") as adfs:
|
|
159
|
+
print(adfs.title) # '80T Welcome & Utils'
|
|
160
|
+
|
|
161
|
+
# Navigate with / operator
|
|
162
|
+
for entry in adfs.root / "LIBRARY":
|
|
163
|
+
print(entry.name, entry.stat().length)
|
|
164
|
+
|
|
165
|
+
# Read a file
|
|
166
|
+
data = (adfs.root / "HELP" / "aform").read_bytes()
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
#### Walking the directory tree
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
with ADFS.from_file("disc.adl") as adfs:
|
|
173
|
+
for dirpath, dirnames, filenames in adfs.root.walk():
|
|
174
|
+
for name in filenames:
|
|
175
|
+
print(dirpath / name)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
#### Creating a new ADFS floppy
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
from oaknut.dfs import ADFS, ADFS_L
|
|
182
|
+
|
|
183
|
+
with ADFS.create_file("blank.adl", ADFS_L, title="My Disc") as adfs:
|
|
184
|
+
pass # empty formatted disc ready for use
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Available floppy formats: `ADFS_S` (160KB), `ADFS_M` (320KB), `ADFS_L` (640KB).
|
|
188
|
+
|
|
189
|
+
### ADFS hard disc images
|
|
190
|
+
|
|
191
|
+
Hard disc images consist of a `.dat` file (raw sector data) and a `.dsc`
|
|
192
|
+
sidecar file (SCSI disc geometry). Pass either file to `from_file` ---
|
|
193
|
+
the companion is located automatically.
|
|
194
|
+
|
|
195
|
+
#### Opening a hard disc image
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
from oaknut.dfs import ADFS
|
|
199
|
+
|
|
200
|
+
with ADFS.from_file("scsi0.dat") as adfs:
|
|
201
|
+
print(adfs.title)
|
|
202
|
+
print(f"{adfs.total_size // 1024}KB, {adfs.free_space // 1024}KB free")
|
|
203
|
+
|
|
204
|
+
for dirpath, dirnames, filenames in adfs.root.walk():
|
|
205
|
+
for name in filenames:
|
|
206
|
+
p = dirpath / name
|
|
207
|
+
print(f"{p} {p.stat().length}")
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
#### Creating a new hard disc image
|
|
211
|
+
|
|
212
|
+
Specify a capacity and the geometry is chosen automatically (4 heads,
|
|
213
|
+
33 sectors/track --- the Acorn convention):
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
from oaknut.dfs import ADFS
|
|
217
|
+
|
|
218
|
+
# Create a 20MB hard disc image
|
|
219
|
+
with ADFS.create_file("scsi0.dat", capacity_bytes=20 * 1024 * 1024, title="Data") as adfs:
|
|
220
|
+
pass # creates both scsi0.dat and scsi0.dsc
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
For explicit geometry control:
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
with ADFS.create_file("scsi0.dat", cylinders=306, heads=4) as adfs:
|
|
227
|
+
pass
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## Development
|
|
231
|
+
|
|
232
|
+
After cloning, install the pre-commit hooks:
|
|
233
|
+
|
|
234
|
+
```
|
|
235
|
+
uv run --group dev pre-commit install
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Running the tests
|
|
239
|
+
|
|
240
|
+
```
|
|
241
|
+
uv run --group test pytest tests/ -v
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
## Architecture
|
|
245
|
+
|
|
246
|
+
The library uses a layered architecture with dependencies flowing downward:
|
|
247
|
+
|
|
248
|
+
1. **Sector access** (`surface.py`, `sectors_view.py`, `unified_disc.py`) ---
|
|
249
|
+
operates on buffers to convert logical sector numbers to physical byte
|
|
250
|
+
offsets. Handles disc geometry, interleaving schemes, and multi-surface
|
|
251
|
+
aggregation.
|
|
252
|
+
|
|
253
|
+
2. **Catalogue and directory management** --- two parallel implementations:
|
|
254
|
+
- **DFS** (`catalogue.py`, `acorn_dfs_catalogue.py`,
|
|
255
|
+
`watford_dfs_catalogue.py`) --- flat catalogue in sectors 0--1. Supports
|
|
256
|
+
Acorn DFS (31 files) and Watford DFS (62 files).
|
|
257
|
+
- **ADFS** (`adfs_directory.py`, `adfs_free_space_map.py`) --- hierarchical
|
|
258
|
+
directories stored as disc objects, with an explicit free space map.
|
|
259
|
+
|
|
260
|
+
3. **Filesystem API** --- user-facing interfaces with pathlib-inspired navigation:
|
|
261
|
+
- **DFS** (`dfs.py`) --- `DFS`, `DFSPath`, `DFSStat`
|
|
262
|
+
- **ADFS** (`adfs.py`) --- `ADFS`, `ADFSPath`, `ADFSStat`
|
|
263
|
+
|
|
264
|
+
## References
|
|
265
|
+
|
|
266
|
+
### Format specifications
|
|
267
|
+
|
|
268
|
+
- [Acorn DFS disc format](https://beebwiki.mdfs.net/Acorn_DFS_disc_format) ---
|
|
269
|
+
BeebWiki specification for the Acorn DFS catalogue layout.
|
|
270
|
+
- [Disc Filing System](https://en.wikipedia.org/wiki/Disc_Filing_System) ---
|
|
271
|
+
Wikipedia overview of DFS and its variants.
|
|
272
|
+
- [Advanced Disc Filing System](https://en.wikipedia.org/wiki/Advanced_Disc_Filing_System) ---
|
|
273
|
+
Wikipedia overview of ADFS and its evolution.
|
|
274
|
+
- [Guide to Disc Formats](https://github.com/geraldholdsworth/DiscImageManager) ---
|
|
275
|
+
Gerald Holdsworth's detailed technical reference for DFS, ADFS, and other formats.
|
|
276
|
+
- [INF file format](https://beebwiki.mdfs.net/INF_file_format) ---
|
|
277
|
+
BeebWiki specification for the `.inf` sidecar metadata format.
|
|
278
|
+
|
|
279
|
+
### Related tools and projects
|
|
280
|
+
|
|
281
|
+
- [oaknut-zip](https://github.com/rob-smallshire/oaknut-zip) ---
|
|
282
|
+
Sister project for extracting ZIP files containing Acorn metadata.
|
|
283
|
+
|
|
284
|
+
### Forum discussions
|
|
285
|
+
|
|
286
|
+
- [Stardot forum: DFS format](https://stardot.org.uk/forums/viewtopic.php?t=4714) ---
|
|
287
|
+
Community discussion of DFS disc image formats and variants.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "oaknut-dfs"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
authors = [{ name = "Robert Smallshire", email = "robert@smallshire.org.uk" }]
|
|
9
|
+
description = "Python library for handling Acorn DFS disc images (SSD/DSD format) and ADFS disc images"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
requires-python = ">=3.11"
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - 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.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"oaknut-file>=1.0",
|
|
27
|
+
"typename>=1.0.4",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/rob-smallshire/oaknut"
|
|
32
|
+
Repository = "https://github.com/rob-smallshire/oaknut"
|
|
33
|
+
Issues = "https://github.com/rob-smallshire/oaknut/issues"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.dynamic]
|
|
36
|
+
version = { attr = "oaknut.dfs.__version__" }
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.packages.find]
|
|
39
|
+
where = ["src"]
|
|
40
|
+
|
|
41
|
+
[tool.bumpversion]
|
|
42
|
+
current_version = "4.0.0"
|
|
43
|
+
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
|
44
|
+
serialize = ["{major}.{minor}.{patch}"]
|
|
45
|
+
tag = true
|
|
46
|
+
commit = true
|
|
47
|
+
message = "Bump version: {current_version} → {new_version}"
|
|
48
|
+
tag_name = "oaknut-dfs-v{new_version}"
|
|
49
|
+
tag_message = "Bump version: {current_version} → {new_version}"
|
|
50
|
+
files = [
|
|
51
|
+
{ filename = "src/oaknut/dfs/__init__.py" },
|
|
52
|
+
]
|