homelan / mdfs-disk-reader
Reads hard disk images from the SJ Research MDFS and HDFS file servers for Acorn Econet
Requires
- php: >=8.0
Requires (Dev)
- phpunit/phpunit: ^11.0
README
A PHP library for reading and writing hard disk images from the SJ Research MDFS (Modular Disc File Server) and HDFS (Hard Disk File Server) Econet file servers.
Both server types use the same underlying filesystem format. The only difference is where the filesystem partition starts on the physical disk image.
Install
composer require homelan/mdfs-disk-reader
Usage
There are two classes:
| Class | Purpose |
|---|---|
MdfsReader |
Read-only access to a disk image |
MdfsWriter |
Read and write access; extends MdfsReader |
Use MdfsReader when you only need to inspect an image. Use MdfsWriter when you need to create, modify, or delete files and directories. MdfsWriter exposes the full read API in addition to the write methods.
Creating a reader
For a standard MDFS hard disk image (filesystem begins 1024 bytes into the image, at physical sector 2):
$oMdfs = new \HomeLan\Retro\Acorn\Disk\MdfsReader('/path/to/mdfs.img');
For an HDFS image (the first 98 × 1K blocks are firmware; the filesystem begins at block 98):
$oMdfs = \HomeLan\Retro\Acorn\Disk\MdfsReader::createHdfs('/path/to/hdfs.img');
You can also supply the partition byte offset directly if you need a non-standard value:
$oMdfs = new \HomeLan\Retro\Acorn\Disk\MdfsReader('/path/to/image.img', $iByteOffset);
Creating a writer
MdfsWriter opens the image file in read/write mode. The image must already exist and be writable.
$oMdfs = new \HomeLan\Retro\Acorn\Disk\MdfsWriter('/path/to/mdfs.img');
$oMdfs = \HomeLan\Retro\Acorn\Disk\MdfsWriter::createHdfs('/path/to/hdfs.img');
Reading disk metadata
$oMdfs->getTitle(); // disk title string (up to 10 chars) $oMdfs->getIdent(); // disk identification string, e.g. "SJ Research File Server"
Getting the catalogue
getCatalogue() returns the entire directory tree as a nested associative array. The top-level keys are the names of objects in the root directory ($).
$aCatalogue = $oMdfs->getCatalogue();
Each entry in the array contains:
| Key | Type | Description |
|---|---|---|
filename |
string | Object name |
type |
string | 'file' or 'dir' |
load |
int | Load address (32-bit) |
exec |
int | Execution address (32-bit) |
size |
int | File length in bytes (0 for directories) |
access |
int | Raw access byte (MPDLWRwr) |
locked |
bool | File is locked |
owner_read |
bool | Owner has read permission |
owner_write |
bool | Owner has write permission |
public_read |
bool | Public has read permission |
public_write |
bool | Public has write permission |
date_created |
string | Creation date as YYYY-MM-DD (empty if not set) |
date_modified |
string | Modification date as YYYY-MM-DD (empty if not set) |
alloc_vector |
int[] | Array of 16 block numbers from the allocation vector |
alloc_mode |
int | 0 = direct block pointers, 1 = indirect block pointers |
dir |
array | Sub-catalogue (present only when type is 'dir') |
Directory entries additionally carry:
| Key | Type | Description |
|---|---|---|
cycle |
int | Cycle number (increments on changes) |
parent_block |
int | Block number of the parent directory |
parent_entry |
int | Entry number within the parent |
entry_count |
int | Number of objects in this directory |
Example — listing the root directory:
$aCatalogue = $oMdfs->getCatalogue(); foreach ($aCatalogue as $sName => $aEntry) { $sType = $aEntry['type'] === 'dir' ? 'DIR ' : 'FILE'; $iSize = $aEntry['size']; printf("%s %-20s %d bytes\n", $sType, $sName, $iSize); }
Example — walking the full tree recursively:
function printTree(array $aCat, string $sPrefix = '$'): void { foreach ($aCat as $sName => $aEntry) { $sPath = $sPrefix . '.' . $sName; echo $sPath . "\n"; if ($aEntry['type'] === 'dir') { printTree($aEntry['dir'], $sPath); } } } printTree($oMdfs->getCatalogue());
Reading a file
Pass a dot-separated path from the root. Path matching is case-insensitive.
$sData = $oMdfs->getFile('UTILS.DISASSEM');
$sData = $oMdfs->getFile('BBC.PROGRAMS.ADVENT');
Checking what a path is
$oMdfs->isFile('BBC.PROGRAMS.ADVENT'); // true if path resolves to a file $oMdfs->isDir('BBC.PROGRAMS'); // true if path resolves to a directory
Getting file stats
$aStat = $oMdfs->getStat('BBC.PROGRAMS.ADVENT'); // ['size' => 12288, 'alloc_vector' => [142, 143, 144, ...]]
Writing a file
writeFile() writes raw binary data to the filesystem. If a file already exists at the given path it is overwritten. The path is dot-separated from the root, matching the same convention as getFile().
$oMdfs->writeFile('UTILS.MYTOOLS.EDITOR', $sBinaryData);
Optional parameters control the Acorn load/exec addresses and the access byte:
$oMdfs->writeFile( path: 'BBC.PROGRAMS.ADVENT', data: $sBinaryData, load: 0x00FF0E00, // 32-bit load address exec: 0x00FF0E00, // 32-bit exec address access: 0x0C, // owner read+write (default) );
Files up to 16 384 bytes are stored using direct block allocation. Files larger than 16 384 bytes are stored using indirect block allocation, supporting files up to 8 MB.
Access byte values
| Value | Meaning |
|---|---|
0x0C |
Owner read + write (default) |
0x04 |
Owner read only |
0x1C |
Owner read + write, locked |
0x0F |
Owner read + write, public read + write |
0x0D |
Owner read + write, public read |
Creating a directory
$oMdfs->createDir('UTILS.MYTOOLS');
The parent directory must already exist. The name must be 10 characters or fewer. Creating a path that already exists (as either a file or directory) throws an exception.
Deleting a file
$oMdfs->deleteFile('BBC.PROGRAMS.ADVENT');
Throws an exception if the file does not exist or is locked. To delete a locked file, clear the lock first with setAccess().
Deleting a directory
$oMdfs->deleteDir('BBC.PROGRAMS');
The directory must be empty. Delete its contents first if needed.
Updating metadata
Lock or unlock a file or directory:
$oMdfs->lock('BBC.PROGRAMS.ADVENT'); // set the locked flag $oMdfs->unlock('BBC.PROGRAMS.ADVENT'); // clear the locked flag
A locked file cannot be deleted or overwritten until unlocked.
Change the raw access byte:
$oMdfs->setAccess('BBC.PROGRAMS.ADVENT', 0x0D); // owner read+write, public read
Change the load and exec addresses:
$oMdfs->setLoadExec('BBC.PROGRAMS.ADVENT', 0x00FF0E00, 0x00FF0E00);
On-Disk Format
This section documents the MDFS on-disk format as implemented by SJ Research. The authoritative reference is mdfs.net/Docs/Comp/Disk/Format/SJ/MDFS.
Physical layout
Hard disk images are raw sector dumps. Physical sectors are 512 bytes. The filesystem works in 1024-byte logical blocks (two physical sectors each). Block N is located at:
byte_offset = partition_offset + (N × 1024)
The partition offset depends on the server type:
| Server type | Partition offset | Explanation |
|---|---|---|
| MDFS hard disk | 1024 bytes | First 2 sectors are reserved (controller boot record) |
| HDFS (dedicated hardware) | 98 × 1024 bytes | First 98 blocks hold Z80 firmware (JPROC and utilities) |
| MDFS floppy (FDFS) | 0 bytes | Block 0 is at the physical start of the disk |
The HDFS and MDFS filesystem structures are identical once the partition offset is accounted for.
Block 0 — filesystem header
Block 0 is the filesystem header block. It is not a regular data block; it serves as the anchor for the entire filesystem.
| Offset | Length | Field | Notes |
|---|---|---|---|
0x000 |
64 | Root directory entry ($) |
Full 64-byte directory entry (see below) |
0x040 |
23 | Identification string | e.g. "SJ Research File Server" |
0x057 |
10 | Disk title | CR-terminated, up to 10 characters |
0x061 |
1 | Disk type | 0x00 = FDFS (floppy); 0xFF = hard disk |
0x062 |
2 | Disk size in blocks / part offset | Size of this partition; chains to next partition |
0x064 |
1 | Message channel identifier | |
0x065 |
8 | Auto-print configuration | |
0x06D |
1 | Initial printer number | |
0x06E |
1 | Trace level | |
0x06F |
1 | Clock-setting privilege flag | |
0x07A |
2 | Mount date | Standard 2-byte date format (see below) |
0x07C |
3 | Mount time | Hours, Minutes, Seconds |
0x07F |
1 | Random number | Changed on each mount; used for change detection |
0x080 |
64 | %PASSWORDS directory entry |
Full 64-byte entry for the passwords file |
0x0C0 |
256 | Printer entries | 16 × 16-byte or 8 × 32-byte entries |
0x1C0 |
64 | %PRINTQ directory entry |
Full 64-byte entry for the print queue |
0x200 |
512 | Account balance table | 256 × 16-bit balances (extends into blocks 1–4) |
The $ root directory entry at 0x000 contains an allocation vector (at entry offset 0x020) pointing to the 16 blocks that hold the root directory's contents (the actual listing of files and subdirectories in $).
Directory structure
Each directory occupies exactly 16 consecutive 1024-byte blocks (16 384 bytes total), which is large enough for 256 × 64-byte entries (entries 0–255).
Entry 0 — directory header
The first 64-byte slot in a directory block is the directory header, not a file entry.
| Offset | Length | Field | Notes |
|---|---|---|---|
0x000 |
32 | Occupied entry bitmap | 256 bits; bit N = 1 means entry N is in use (bit 0 = entry 0) |
0x020 |
30 | Alphabetical chain heads | 14 entries of 2 bytes each (count + first entry number) |
0x03E |
2 | Unused |
The alphabetical chain heads index entries by the first letter of their name (<A, A–B, B–C, … Z, >Z), allowing the server firmware to find entries quickly without scanning all 255 slots. Each chain entry holds a count byte and the entry number of the first item; the chain continues via each entry's next field (0x00B).
To enumerate all objects in a directory, scan the occupied bitmap and read each set entry (skipping entry 0). This library uses the bitmap approach.
Entries 1–255 — file and directory entries
Each entry is 64 bytes:
| Offset | Length | Field | Notes |
|---|---|---|---|
0x000 |
1 | Account number | Low 8 bits of owner account |
0x001 |
10 | Object name | CR (0x0D) or null (0x00) terminated; up to 10 characters |
0x00B |
1 | Next entry (chain) | Entry number of next item in the alphabetical chain; 0 = end |
0x00C |
4 | Load address | 32-bit little-endian |
0x010 |
4 | Exec address | 32-bit little-endian |
0x014 |
3 | Length | 24-bit little-endian file length in bytes; 0 for directories |
0x017 |
1 | Access byte | See access byte table below |
0x018 |
2 | Creation date | Standard 2-byte date format (see below) |
0x01A |
2 | Modification date | Standard 2-byte date format (see below) |
0x01C |
2 | Modification time | Hours (byte 0), Minutes (byte 1) |
0x01E |
1 | Aux account (low) | Low 8 bits of auxiliary account number |
0x01F |
1 | Mode flags | Bit 0: allocation mode (see below); bits 1–6: account high bits |
0x020 |
32 | Allocation vector | 16 × 16-bit little-endian block numbers |
For directory entries, the load and exec fields carry additional metadata instead of their normal values:
| Offset | Field | Notes |
|---|---|---|
0x00D |
Cycle number | Incremented each time the directory changes |
0x00E |
Parent block (low) | 16-bit block number of the parent directory |
0x010 |
Parent entry number | Entry number within the parent directory |
0x011 |
Entry count | Number of objects in this directory |
Access byte
Bit 7 is the most significant bit.
| Bit | Mask | Symbol | Meaning |
|---|---|---|---|
| 7 | 0x80 |
M | Multiple access (can be opened simultaneously) |
| 6 | 0x40 |
P | Private (not visible to non-owners) |
| 5 | 0x20 |
D | Directory |
| 4 | 0x10 |
L | Locked (cannot be deleted or overwritten) |
| 3 | 0x08 |
W | Owner write |
| 2 | 0x04 |
R | Owner read |
| 1 | 0x02 |
w | Public write |
| 0 | 0x01 |
r | Public read |
Allocation vector
Each directory entry contains an allocation vector of 16 × 16-bit little-endian block numbers at entry offset 0x020–0x03F. A block number of 0x0000 means the corresponding slot is unallocated and reads as 1024 zero bytes.
The interpretation depends on bit 0 of entry offset 0x01F:
Direct mode (bit 0 = 0)
Each non-zero block number in the allocation vector directly addresses 1024 bytes of the object's data. The 16 slots give a maximum file size of 16 384 bytes (16K).
File data = block[0] ++ block[1] ++ ... ++ block[15] (truncated to the Length field)
Indirect mode (bit 0 = 1)
Each non-zero block number in the allocation vector points to an indirect block: a 1024-byte block containing 512 × 16-bit data block pointers. Those pointers in turn address the actual file data, one 1024-byte block each.
For each indirect_block in allocation_vector:
For each data_block_ptr in indirect_block (512 pointers × 2 bytes):
File data += block[data_block_ptr]
The maximum file size in indirect mode is 16 × 512 × 1024 = 8 388 608 bytes (8M).
Date and time encoding
Dates are stored as 2 bytes. The year is encoded as an offset from 1981.
| Source bits | Destination |
|---|---|
| Byte 0, bits 0–4 | Day (1–31) |
| Byte 0, bits 5–7 | Year offset bits 4–6 |
| Byte 1, bits 0–3 | Month (1–12) |
| Byte 1, bits 4–7 | Year offset bits 0–3 |
Decoding:
year_offset = ((byte0 & 0xE0) >> 1) | ((byte1 & 0xF0) >> 4)
year = 1981 + year_offset
month = byte1 & 0x0F
day = byte0 & 0x1F
Times are stored as 2 or 3 separate bytes: hours (0–23), minutes (0–59), and optionally seconds (0–59).
A date of all-zero bytes indicates that no date was recorded.
Printer entries in block 0
The 256 bytes at 0x0C0–0x1BF in block 0 hold printer configuration. These can be laid out as:
- 16 × 16-byte entries: name (6 bytes, space-padded), then 10 undefined bytes.
- 8 × 32-byte entries: name (6 bytes), flags byte, account number (2 bytes), banner filename (23 bytes, CR-terminated).
Flags byte bits: bit 0 = printer exists; bit 1 = anonymous (no login required); bit 2 = account required; bit 3 = non-spooling.