Skip to content

Instantly share code, notes, and snippets.

@X547
Created July 28, 2026 15:52
Show Gist options
  • Select an option

  • Save X547/7bd5fe02e5d6215d9ee7e41973d47f41 to your computer and use it in GitHub Desktop.

Select an option

Save X547/7bd5fe02e5d6215d9ee7e41973d47f41 to your computer and use it in GitHub Desktop.
The BlackBox Object Code File (`.ocf`) Format (assisted with Claude Code)

The BlackBox Object Code File (.ocf) Format

A specification of the on-disk layout of BlackBox Component Builder object code files, as produced by the Component Pascal compiler's object emitter and consumed by the dynamic module loader.

One .ocf file holds exactly one compiled module: its executable code, its statically initialised data, its runtime meta-information (type descriptors, export directory, debug references), the relocation information needed to place it at an arbitrary address, and the list of imports that must be resolved before it can run. There is no section table and no symbol table in the ELF/PE sense — the file is a memory image plus a set of chained fixup lists.

The format is target-independent in structure. The same emitter produces files for every supported processor; only a handful of fields and one optional indirection differ. Target-specific points are marked [target] throughout and summarised in §11.


1. Notation and primitive encodings

All multi-byte quantities fall into one of two classes, and the distinction matters on big-endian targets.

Notation Meaning
Byte one octet
Word 32-bit integer, always little-endian, independent of target
TWord 32-bit integer in target byte order
THalf 16-bit integer in target byte order
Num variable-length signed integer (see below) — a byte stream, endian-neutral
Name UTF-8 byte string terminated by 00H — endian-neutral

Everything that the loader reads sequentially from the file (the header, the fixup lists, the use block) is encoded with Word, Num and Name, so those parts are byte-order-independent — a file can be inspected on any host. In contrast, everything that becomes part of the loaded image (the meta, desc and code blocks) is written in target byte order, because the target CPU will read it directly with ordinary loads.

1.1 Num encoding

A signed integer in little-endian 7-bit groups. Each byte carries 7 payload bits in bits 0..6; bit 7 set means "another byte follows". The final byte is sign-extended from 7 bits, so the encoding is self-signing.

Writing:

while i < -64 or i > 63:
    emit((i mod 128) - 128)      # payload with bit 7 set
    i := i div 128               # floored division
emit(i mod 128)                  # final byte, bit 7 clear

Reading:

shift := 0; acc := 0; b := next_byte()
while b >= 128:
    acc := acc + ((b - 128) << shift); shift := shift + 7; b := next_byte()
result := acc + (sign_extend_7(b) << shift)

A Num of value 0 is the single byte 00H. This is used as a list terminator in several places.

1.2 Name encoding

Module, object and field names are UTF-8, 00H-terminated, with no length prefix. The empty name is a single 00H byte and is meaningful: it marks anonymous type descriptors in the use block.


2. File layout

offset 0                                  ┐
  ┌──────────────────────────────────┐    │
  │ header                           │    │ hs bytes
  │   tag, processor, 5 sizes,       │    │
  │   import count, module names,    │    │
  │   zero padding                   │    │
offset hs ─────────────────────────────    ┘
  │ meta block                       │    ms bytes
  │   ref block, signatures,         │
  │   export directory, pointer      │
  │   table, import table, name      │
  │   table, constant pools          │
offset hs+ms ──────────────────────────
  │ desc block                       │    ds bytes
  │   module descriptor, proc table, │
  │   type descriptors               │
offset hs+ms+ds ───────────────────────
  │ code block                       │    cs bytes
offset hs+ms+ds+cs ────────────────────
  │ fixup lists (6, in fixed order)  │    variable
  ├──────────────────────────────────┤
  │ use block                        │    variable
  └──────────────────────────────────┘
end of file

The three middle blocks are a memory image: the loader copies them verbatim into freshly allocated memory and then patches them. Everything after the code block is loader metadata and is never mapped.

The fifth size, vs, describes the module's global variables. They occupy no space in the file — the loader allocates and zeroes them.


3. Header

Offset Type Field Description
0 Word tag 6F4F4346H; on disk the bytes read 46 43 4F 6F = "FCOo"
4 Word processor target CPU, see below
8 Word hs header size = file offset of the meta block
12 Word ms meta block size
16 Word ds desc block size
20 Word cs code block size
24 Word vs size of the module's global variables
28 Num nofImports number of import names following selfName
Name selfName the module's own name
Name × nofImports importNames see below
Byte × n padding zero bytes up to hs

hs is padded to a multiple of 16 (8 in pre-1.4 files; see §12). Both hs and ms are therefore multiples of 16, which keeps the module descriptor 16-byte aligned once loaded.

Processor codes

Value Target
10 i386 / IA-32
20 Motorola 68020

The loader rejects a file whose tag or processor does not match the running kernel, reporting a syntax error. Together with selfName — which the loader also checks against the name it was asked to load — this is the only validation performed before the image is mapped.

Import names. Names are emitted in a fixed order that the import table (§5.5), the fixup pass and the use block (§9) all rely on:

  1. all shared-library imports first, each written as "$" followed by the library's file name (e.g. $kernel32.dll);
  2. then all Component Pascal module imports, by name.

The special name "$$" denotes the kernel itself: the loader substitutes the kernel module rather than looking for a library. A module that imports nothing has nofImports = 0.


4. Load-time memory layout

The loader allocates two blocks and reads the three image sections into them:

desc block                     module block
┌────────────┐ dAdr            ┌──────────────┐ mAdr
│ desc  (ds) │                 │ meta   (ms)  │
└────────────┘                 ├──────────────┤ mAdr + ms
                               │ code   (cs)  │
                               ├──────────────┤ mAdr + ms + cs
                               │ vars   (vs)  │   (zeroed, not in file)
                               └──────────────┘

Read order is meta → desc → code; note this is not the file order, so the loader seeks or reads in three steps. The module descriptor sits at the start of the desc block, so dAdr is also the address of the loaded Module record and is what the rest of the runtime uses as the module's identity.

The two blocks are independent allocations and need not be adjacent. Fixup link sites are nevertheless addressed as if desc immediately followed meta (§8.1).

The split exists so that the code and variables can be released independently of the descriptor when a module is unloaded: the descriptor must survive to record that the module is gone (refcnt < 0).


5. Meta block

The meta block is the module's runtime reflection data. Its components appear in the order below; every component's position is recorded in the module descriptor, so nothing here needs to be parsed sequentially at load time.

5.1 Ref block (debug information)

Starts at meta offset 0 (so refs in the descriptor points at it) and occupies rsize bytes. It is a byte-coded stream describing procedure boundaries, source positions and local variables, emitted only when the corresponding compiler options are set; a minimal ref block is the single terminator byte.

Grammar, read as a stream of tagged items until the 00H terminator:

First byte Item Payload
01H..FBH source position the byte is a code-offset delta (1..250); a Num follows giving the source-position delta
FCH procedure start Num code offset, Name procedure name
FDH local variable see below
FFH VAR parameter see below
00H end of ref block

A variable item (FDH/FFH) is: one Byte form code, then — only if that form code is 10H — a TWord reference to a type descriptor, then a Num address (offset from varBase for globals, frame offset for locals), then a Name.

Form codes reuse the meta type codes of §10.2. Code and source deltas are cumulative from zero, which is why a run longer than 250 code bytes is emitted as repeated FAH/Num 0 pairs.

5.2 Signature blocks

Optional; present when the compiler emits full procedure signatures. Each block is referenced from an export entry or a procedure-type descriptor and is laid out as:

Offset Type Field
0 TWord result type (§10.2 struct reference; 0 for proper procedures)
4 TWord number of parameters n
8 + 8*i TWord parameter i id = nameIdx * 256 + kind
12 + 8*i TWord parameter i type (struct reference)

Parameter kinds: 10 = value, 11 = IN, 12 = OUT, 13 = VAR.

5.3 Export directory

8-byte aligned. One TWord entry count, followed by that many 16-byte entries:

Offset Type Field
0 TWord fprint — fingerprint (for record types: the public fingerprint)
4 TWord offs — meaning depends on kind (below); for record types: the private fingerprint
8 TWord id = nameIdx * 256 + visibility * 16 + kind
12 TWord struct — type reference (§10.2), or a reference to a signature block for procedures

offs by kind: procedures → byte offset from procBase; variables → byte offset from varBase; types → private fingerprint; constants → 0.

Entries are sorted by name, which the runtime exploits with a binary search over the name table. Anonymous type descriptors (name index 0) therefore sort first, and the runtime finds them by scanning that leading run and matching fingerprints.

Kind and visibility codes are in §10.1.

5.4 Pointer table

A TWord array of byte offsets from varBase, listing every global variable that the garbage collector must trace as a root:

ptrs[0 .. nofptrs-1]     traced pointer offsets
[ iptrs[0..k-1]          interface pointer offsets ]   ─┐ present only if
[ -1 ]                   terminator                     ─┘ bit 30 of opts is set

The first run has no terminator: its length is nofptrs in the module descriptor. The optional second run holds COM/interface pointers, which are released by reference counting rather than traced, and is terminated by -1.

5.5 Import table

nofImports TWord slots, written as zeroes. The loader overwrites slot i with the descriptor address of the i-th imported module, in the order the names appear in the header. Shared-library imports leave their slot at 0.

5.6 Name table

A concatenation of 00H-terminated UTF-8 strings, beginning with a single 00H so that name index 0 always denotes the empty name. Every nameIdx field elsewhere in the file is a byte offset into this table.

5.7 Constant pools

Four pools in ascending alignment order, each 4-byte aligned (the 64-bit pool 8-byte aligned):

Pool Contents Element size
8-bit SHORTCHAR string literals, UTF-8 padded to a multiple of 4
16-bit CHAR string literals, UTF-16 in target byte order padded to a multiple of 4
32-bit SHORTREAL literals; GUIDs 4 / 16
64-bit REAL and INTEGER (64-bit) literals 8

Identical literals are pooled. Code references into the pools are relocated by the meta fixup list, so pool contents carry no internal relocation.

GUIDs are stored in the binary layout of the target: the first three components are byte-swapped on little-endian targets and stored big-endian on big-endian targets; the trailing eight bytes are always in order.


6. Desc block

6.1 Module descriptor

At desc offset 0. This is the loaded Module record; the addresses in it are absolute and are produced by fixups, not stored in the file.

Offset Type Field Notes
0 TWord next 0; the kernel links loaded modules through it
4 TWord opts bits 0..15 compiler options, 16..31 kernel flags (§10.3)
8 TWord refcnt 0; negative once the module is invalidated
12 THalf × 6 compTime year, month, day, hour, minute, second
24 THalf × 6 loadTime zero; filled in at registration
36 TWord ext reserved, 0
40 TWord term address of the module's terminator procedure, or 0
44 TWord nofimps number of imported modules = header nofImports
48 TWord nofptrs number of traced global pointers
52 TWord csize code size = header cs
56 TWord dsize global variable size = header vs (see naming note)
60 TWord rsize ref block size
64 TWord code → code block base
68 TWord data → variable area base
72 TWord refs → ref block (meta offset 0)
76 TWord procBase base for procedure offs values [target]
80 TWord varBase base for variable offs values
84 TWord names → name table
88 TWord ptrs → pointer table
92 TWord imports → import table
96 TWord export → export directory count word
100 Byte[] name module name, UTF-8, 00H-terminated, padded to 4

Naming note. The header's fifth size and the descriptor's dsize are the same quantity — the size of the module's global variables. The header's third size (ds, the desc block size) has no descriptor counterpart. Do not conflate them.

6.2 Proc table

Immediately after the descriptor. An array of 8-byte slots, one per exported or interrupt procedure, in export order, followed by two zero words and a zero sentinel word (so the table is non-empty even for a module that exports no procedure).

Each slot is a jump thunk to the procedure's entry point. On 68k it holds JMP.L target — the opcode word 4EF9H, the 4-byte target address supplied by a fixup, and a 2-byte pad. On i386 the table is emitted with the same shape but nothing refers to it: procBase points at the code block instead, and export offs values are code offsets. See §11.

The zero-terminated, fixed-stride shape lets the runtime walk the table without consulting the export directory — used when a module is unloaded, to overwrite every entry point with a trapping instruction.

6.3 Type descriptors

One per record, pointer, array and procedure type that the module defines or needs to describe. A descriptor is referenced by its descriptor address D, which points at the size word; record descriptors have a method area at negative offsets from D.

For a record type:

D - 4*(n+1)   TWord   -1              sentinel ending the method area
D - 4*n       TWord   method n-1      ┐ method entry points, highest index first;
   ...                                │ 0 for an abstract method
D - 4         TWord   method 0        ┘
D + 0         TWord   size            instance size in bytes
D + 4         TWord   mod             → this module's descriptor
D + 8         TWord   id              nameIdx*256 + extLevel*16 + attr*4 + form
D + 12        TWord   base[0]         ┐ ancestry: base[i] = descriptor of the
   ...                                │ ancestor at extension level i, 0 beyond
D + 72        TWord   base[15]        ┘ this type's own level (16 entries)
D + 76        TWord   fields          → field directory in the meta block
D + 80        TWord   ptroffs[0]      ┐ byte offsets of traced pointer fields
   ...                                ┘
              TWord   -(4*k + 4)      terminator after k offsets
            [ TWord   interface pointer offsets ... ]
            [ TWord   -1 ]

attr is 0 for a plain record, 1 EXTENSIBLE, 2 LIMITED, 3 ABSTRACT. form is 1 for records (see §10.2).

The pointer-offset terminator is not merely negative — its value -(4*k + 4) is chosen so that a collector which has walked k entries can recover D by adding terminator + 4 to its cursor. Interface-pointer offsets, if any, follow the terminator and are themselves terminated by -1.

The field directory is a meta-block structure identical in shape to the export directory (a count word followed by 16-byte entries) describing the record's own fields in declaration order; inherited fields are not repeated.

For a non-record type the layout is just the four words:

Offset Field Value
D + 0 size fixed array: element count; dynamic array: 0; procedure type: signature fingerprint; pointer: 0
D + 4 mod → this module's descriptor
D + 8 id nameIdx * 256 + level * 16 + form, where level is the number of open dimensions + 1 for a dynamic array, else 0
D + 12 base element/target type (§10.2), or → signature block for procedure types

7. Code block

A flat image of the module's executable code, target-specific in every respect. Padded to a multiple of 4 bytes with 90H filler; cs includes the padding.

Code offset 0 is the start of the module's body procedure — but note that code offset 0 can never be a fixup link site, because a link value of 0 terminates a chain (§8.1).

Immediate operands that reference anything outside the block — imported procedures, constants, type descriptors, global variables — are not final: each such operand word is a link in a fixup chain and holds chain data until the loader patches it.


8. Fixup lists

Six lists appear consecutively after the code block, always in this order:

# Base address the loader passes Patches references to
1 address of the kernel's NewRec routine record allocation calls
2 address of the kernel's NewArr routine array allocation calls
3 meta block base constant pools, name/field/signature references
4 desc block base module descriptor, type descriptors, proc table
5 code block base procedures and code labels within this module
6 variable area base this module's global variables

Each list is a sequence of pairs terminated by a Num 0:

list  := { chainHead: Num, offset: Num } 00H

chainHead addresses the first link site; offset is added to the list's base address to form the value that the chain resolves to.

8.1 Link site addressing

A link value L is interpreted relative to the loaded image, treating the desc block as if it directly followed the meta block:

L Site address
L > 0 codeBase + L
L < 0, -L < ms metaBase + (-L)
L < 0, -L >= ms descBase + (-L) - ms
L = 0 end of chain

8.2 Chain words

Each link site holds a 32-bit word in target byte order:

 31    24 23                          0
┌────────┬─────────────────────────────┐
│  type  │  next link (24-bit signed)  │
└────────┴─────────────────────────────┘

The loader reads the word, computes the patched value from type, writes it back, and follows next — which is interpreted exactly as chainHead above. Because the next-link field is 24-bit signed, a chain can only thread sites within ±8 MB of the image origin, which bounds the practical size of a module.

8.3 Fixup types

Let A be the list's base address, F the offset of the current pair, S the site address, and N the sign-extended low 24 bits of the site word.

Code Name Patched value Chain continues at
100 absolute A + F N
101 relative A + F - S - 4 N
102 copy word read from A + F N
103 table A + N S + 4 (next word)
104 tableend A + N end of chain
105 deref word read from A + 2, plus F N [68k]
106 halfword split hi/lo across this word and S + 4 N

absolute and relative cover ordinary data references and PC-relative calls. copy propagates a value already computed elsewhere — used to inherit a base type's method or ancestry entry. table and tableend handle runs of consecutive words such as jump tables, where the low 24 bits of each word carry the target offset instead of a chain pointer and the chain simply advances word by word. deref reads a target address out of an already-relocated jump thunk (§11). halfword splits a 32-bit address across the immediate fields of two instructions, as RISC targets require; it is generated by neither of the targets in §11.


9. Use block

The last part of the file. It resolves imports: one section per imported module or library, in header import order (libraries first). A section is a sequence of entries terminated by a 00H byte:

section := { entry } 00H
entry   := kind: Num, name: Name, fprint: Num, [ opt: Num ], [ fixupChain ]

kind selects what is being imported and what follows:

kind Import opt Fixup chain resolves to
1 (const) constant none — fingerprint check only
2 (type) type present the imported type's descriptor address
3 (var) variable varBase + offs of the imported variable
4 (proc) procedure procBase + offs of the imported procedure

The fixup chain is written in the same form as one entry of a fixup list — a chainHead/offset pair sequence terminated by Num 0 — and is walked with the resolved address as base.

Name lookup. A non-empty name is looked up in the imported module's export directory. An empty name means "the anonymous type descriptor with this fingerprint", resolved by scanning the leading unnamed run of the export directory.

Fingerprint checking. After lookup, the loader compares fprint with the found object's fingerprint and fails the load on mismatch — this is what makes BlackBox's separate compilation safe against silently changed interfaces. For type imports, opt modifies the check:

  • bit 0 set — compare against the type's private fingerprint (the importer depends on the type's implementation, e.g. it extends it or allocates it) rather than its public one;
  • bit 1 set — the type is actually used, so it must additionally be exported (not merely visible).

Library imports. For a section belonging to a $library name, variables and procedures are resolved through the host's dynamic-symbol lookup using the library name and the entry name; the fingerprint is passed through unchecked since the host has no notion of one. A type entry in such a section must carry an empty fixup chain; anything else is an error.

Each section ends by storing the resolved module's descriptor address into the corresponding import table slot (§5.5) — 0 for library sections.


10. Constant tables

10.1 Meta interface codes

Kind Value Visibility Value
const 1 internal 1
type 2 read-only 2
var 3 private 3
proc 4 exported 4
field 5

10.2 Struct references

Wherever a type is referenced (export entries, field entries, signature parameters, non-record descriptor base fields), the TWord is either a small code denoting a basic type, or — if its value divided by 256 is non-zero — a relocated pointer to a type descriptor.

Code Type Code Type
0 none / untyped 8 REAL (64-bit)
1 BOOLEAN 9 SET
2 SHORTCHAR 10 INTEGER (64-bit)
3 CHAR 11 ANYREC
4 BYTE 12 ANYPTR
5 SHORTINT 13 untagged pointer
6 INTEGER (32-bit) 32 COM interface pointer
7 SHORTREAL 33 GUID
34 COM result code

Type forms, as stored in the low nibble of a descriptor's id: 0 procedure type, 1 record, 2 array, 3 pointer.

10.3 Module option bits

Bits 0..15 of opts are compiler options carried through from compilation. The kernel flags above them are:

Bit Meaning
16 module body has been executed
17 module was loaded dynamically (its memory may be released on unload)
24 module image is hosted inside a shared library
30 the pointer table has an interface-pointer run (§5.4)

11. Target variants

The structure above is identical for every target. Exactly three things vary, plus one derived fixup type.

i386 68020
processor 10 20
Image byte order little-endian big-endian — all TWord/THalf in the meta, desc and code blocks, including chain words and constant pools
Procedure values code addresses proc table slot addresses
Fixup types used 100..104 100..105

The header, the fixup lists and the use block are byte-order-independent on both targets: their Word fields are little-endian everywhere and their Num/Name fields are byte streams.

Indirect procedure values. On 68k a procedure value — what a procedure variable, a method table slot or a looked-up command holds — is the address of that procedure's 8-byte thunk in the proc table (§6.2), not its entry point; procBase points at the table accordingly. Static calls do not pay for the extra jump: a call to an imported procedure is emitted with the deref fixup, which reads the real entry point out of the thunk (at slot + 2, past the JMP.L opcode) at load time, so the call lands directly on the code.

The indirection exists so that unloading a module has a single point at which every entry into it can be revoked: the runtime walks the table and replaces each thunk with a trap instruction, and rewrites every method slot of the module's exported record types to point at the poisoned table. This matters on hosts without memory protection, where freed module memory is immediately reusable and a stale procedure value would otherwise jump into arbitrary data. On i386 the same guarantee comes from the memory manager — module memory is decommitted on unload and a stale call faults — so the indirection is switched off and the emitted proc table is inert padding.


12. Version differences

Files produced by different compiler generations differ in ways that a reader should tolerate. None of these change the meaning of any field.

Early (pre-1.4) Current
Header padding to a multiple of 8 to a multiple of 16
Constant pools each pool aligned to its own element width all pools 4-byte aligned, 64-bit pool 8-byte aligned
Procedure signatures absent emitted into the meta block; export entries for procedures point at them
Module descriptor name padded to 4 with repeated 00H single 00H then zero padding to 4

Because every offset that matters is stored explicitly in the header or the module descriptor, a reader that never assumes an alignment will read both.


13. Reading an .ocf: procedure summary

  1. Read and verify tag and processor. Read hs, ms, ds, cs, vs, nofImports, selfName and the import names. Check selfName against the name requested.
  2. Resolve every import name to a loaded module (recursively loading it) or to a host shared library. Detect cycles by tracking the import chain.
  3. Allocate a desc block of ds bytes and a module block of ms + cs + vs bytes. Seek to hs and read ms bytes into the module block, ds bytes into the desc block, then cs bytes into the module block at offset ms. Zero the variable area.
  4. Apply the six fixup lists in order, with the bases listed in §8.
  5. For each import section of the use block, look up each entry, verify its fingerprint, apply its fixup chain, and store the module's descriptor address in the import table slot.
  6. On any failure, release both blocks — the module is not registered. On success, link the descriptor into the module list, increment the reference count of every imported module, stamp the load time, and call the module body at code offset 0.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment