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.
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.
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.
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.
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.
| 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:
- all shared-library imports first, each written as
"$"followed by the library's file name (e.g.$kernel32.dll); - 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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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
dsizeare 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.
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.
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 |
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.
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.
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 |
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.
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.
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.
| Kind | Value | Visibility | Value | |
|---|---|---|---|---|
| const | 1 | internal | 1 | |
| type | 2 | read-only | 2 | |
| var | 3 | private | 3 | |
| proc | 4 | exported | 4 | |
| field | 5 |
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.
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) |
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.
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.
- Read and verify
tagandprocessor. Readhs,ms,ds,cs,vs,nofImports,selfNameand the import names. CheckselfNameagainst the name requested. - Resolve every import name to a loaded module (recursively loading it) or to a host shared library. Detect cycles by tracking the import chain.
- Allocate a desc block of
dsbytes and a module block ofms + cs + vsbytes. Seek tohsand readmsbytes into the module block,dsbytes into the desc block, thencsbytes into the module block at offsetms. Zero the variable area. - Apply the six fixup lists in order, with the bases listed in §8.
- 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.
- 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.