homelan/mdfs-disk-reader

Reads hard disk images from the SJ Research MDFS and HDFS file servers for Acorn Econet

Maintainers

Package info

github.com/johnhomelan/mdfs-fs

pkg:composer/homelan/mdfs-disk-reader

Transparency log

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.1 2026-08-11 22:32 UTC

This package is auto-updated.

Last update: 2026-08-11 22:36:21 UTC


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 0x0200x03F. 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 0x0C00x1BF 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.

Reference