Plan: full multi-object-block support in NDFS (2026-08-01)¶
Scope requested: read more than 1000 files in a user area, create more than 1000, an API for adding (and possibly removing) object blocks, unit tests that exercise all of it, and per-user block metadata surfaced in the RetroCommander UI.
Applies to all four implementations: E:\Dev\Ronny\norskdata-ndfs (C, Python, TypeScript) and
E:\Dev\Ronny\RetroFS\src\RetroFS.NDFS (C#), plus the RetroCommander UI.
Ground truth: NDFS-OBJECT-BLOCKS-DECODED-2026-08-01.md.
Already landed: byte 47 is parsed, written and documented in all four ports; the three
norskdata-ndfs documents that asserted a 256-file limit are corrected.
1. Analysis of the request - three things worth correcting up front¶
1.1 "Read > 1000 files" needs the read path fixed, not just the field¶
Reading beyond 256 is not currently possible, and the reason is not the byte-47 field (now
done). Every implementation walks only pages U*8 .. U*8+7 - one object block. Files in a second
block are simply never seen. Worse, the ones that ARE seen get their file number from raw physical
position, so a second-block file would report a number and owner that are both wrong.
So the read work is two changes, and both are required before a single file above 256 is visible:
walk all ACOBL blocks, and compute the number as block*256 + slot.
1.2 "Add/remove object blocks" - SINTRAN only adds¶
@GIVE-OBJECT-BLOCKS adds to what the user already has; the manual is explicit ("The number of
object blocks is added to the object blocks already given to the user area"). No remove command
is documented anywhere in ND-30.003.007, and none was found while carving.
That is not an oversight in the manual - removing a block is dangerous by construction. Blocks are
allocated on demand and a user's files live in them; freeing block n silently orphans every file
numbered n*256 .. n*256+255, and file numbers are stable identifiers a client may be holding.
Recommendation. Provide:
- GiveObjectBlocks(user, count) - raises MXOBL, matching SINTRAN. Capped at 16.
- Block allocation stays implicit, on demand during create, exactly as SINTRAN does it (this is
why our verified pack has MXOBL 4 but ACOBL 2).
- SetMaxObjectBlocks(user, n) for lowering, refusing if any allocated block would fall above
the new maximum.
- No "free an allocated block" API unless we find evidence SINTRAN has one. If we ever add
one it must refuse while the block holds any entry, and it must be documented as OUR extension,
not SINTRAN behaviour.
1.3 The object file must be SubIndexed - this is the real structural cost [VERIFIED]¶
A user's object block n occupies pages n*512 + U*8 .. +7. An Indexed object file is one
index block = 512 page pointers, so it can address pages 0..511 only - which is exactly block 0
for users 0..63. Block 1 begins at page 512 and is unreachable in an Indexed object file.
Measured on the pack that has a second block:
object_file_pointer type=SubIndexed block_id=18079
user_file_pointer type=Indexed block_id=18686
bit_file_pointer type=Contiguous block_id=18468
So "give this user another object block" is not always a nibble change. On a pack whose object file
is Indexed it requires converting the object file to SubIndexed first: allocate a sub-index
block, move the existing index block under it, and rewrite MasterBlock.ObjectFilePointer. That is
a structural rewrite of a live filesystem and must be treated as such - the riskiest operation in
this plan.
UNKNOWN: whether SINTRAN's own @GIVE-OBJECT-BLOCKS performs that conversion, refuses, or
assumes the object file is already sub-indexed. Our pack was already SubIndexed before the
experiment, so the experiment does not answer it. See section 5.
2. An unknown that must not be guessed: users above 63¶
512 pages per index block divided by 8 pages per user gives 64 users per index block. But the user file holds 256 users (8 pointers x 32 entries).
Under the naive reading of n*512 + U*8, user 64's block 0 would land on page 512 - the same page
as user 0's block 1. They cannot both be right.
All our evidence is user 8, blocks 0 and 1. Possible resolutions include a different stride for high users, a per-user block table we have not found, or a genuine limit of 64 users on a pack with multi-block users. Do not implement any behaviour for users >= 64 until this is settled. The implementation should assert/refuse rather than silently compute a colliding page.
How to settle it: create a user with index >= 64 on a test pack, give it files, and see where its entries land.
3. Work breakdown¶
3.1 Read path (unblocks "read > 1000 files")¶
For each implementation:
- A helper that enumerates a user's object-block pages:
for n in 0 .. ACOBL-1: pages n*512 + U*8 .. +7. - Directory walk uses it instead of the fixed
U*8..U*8+7. - File number computed, not taken from physical position:
Keep the physical position as a separate field - the write-back path needs it.
block = page / 512 slot = (page % 512 - U*8) * 32 + entryInPage fileNumber = block * 256 + slot
Ready-made regression vector: on the BIGMAN pack, F0500 must report FILE 307, owner 8. A
position-derived implementation reports index 18483 and owner 72.
3.2 Create path (unblocks "add > 1000 files")¶
- Free-slot search spans all allocated blocks.
- When all allocated blocks are full and
ACOBL < MXOBL: allocate the next block (8 pages, wired in atn*512 + U*8), raise ACOBL, continue. - When
ACOBL == MXOBL: fail with the "user object table is full" error - which is now correct rather than premature. - If the object file is Indexed and block >= 1 is needed: either convert to SubIndexed (section 1.3) or refuse with a clear message. Refusing is the correct first version - conversion is a separate, larger piece of work with its own tests.
3.3 API¶
GiveObjectBlocks(user, count)/SetMaxObjectBlocks(user, n)per section 1.2, in all four ports, with the 1..16 clamp already present in the byte-47 writers.- Expose read-only derived values:
MaxFiles = MXOBL * 256,AllocatedFiles = ACOBL * 256, and the count of entries actually in use.
3.4 Tests¶
- Byte-47 round trip for a multi-block user (already verified against the real pack - promote it to a unit test with a synthetic fixture so it runs without the pack).
- Directory walk finds entries in block 2+.
- File number for a second-block file - the
F0500= 307 vector. - Create past 256 allocates a block and raises ACOBL, rather than failing.
- Create past
MXOBL*256fails cleanly. - Create past 256 on an Indexed object file fails with the structural message (section 3.2.7).
- A large-scale test: give 4 blocks, create 1000+ files, read them all back, verify numbering is contiguous across the block boundary and that number 255 -> 256 crosses correctly.
Test 16 is the one the request is really about, and it is the one that would have caught all of this years ago.
3.5 RetroCommander UI¶
- Surface per user: allocated blocks, max blocks, max files (
MXOBL*256), files in use, alongside the existing pages used / reserved. The natural place is wherever user quota is already displayed. - A user at
ACOBL == MXOBLwith a full last block is at a hard limit and should be visibly distinct from one that can still grow - that distinction is invisible today, which is precisely how "creating file 257 fails" becomes a mystery.
4. Ordering¶
| Step | Why this order |
|---|---|
| 3.1 read path | Nothing else can be verified until multi-block files are visible |
| 3.4 tests 10-12 | Lock the read path against the real vector before touching create |
| 3.2 create path | Depends on a correct read path to verify against |
| 3.4 tests 13-16 | Including the 1000-file test |
| 3.3 API | Thin once the mechanics work |
| 3.5 UI | Needs the API |
| section 1.3 conversion | Separate piece, only if we decide we need it |
5. Experiments still outstanding¶
- Users >= 64 (section 2) - blocks any implementation for high user indices.
- Does
@GIVE-OBJECT-BLOCKSconvert an Indexed object file? Take a pack with an Indexed object file, run the command, and compare the master block pointer before and after. Determines whether section 3.2.7 refuses forever or is a stopgap. - A third block - our stride evidence is one transition (block 0 -> 1). A user with >512 files
would confirm
n*512generalises. Test 16 produces exactly that if it creates 1000+ files, so this is nearly free once the create path works.