Loadable programs, in real mode, with no linker
A program on the software floppy is a flat binary that loads into the kernel's own 64K segment and calls the kernel through a table of near jumps at a pinned address. Because it shares the segment, its paint and click handlers are ordinary near function pointers the kernel calls exactly like a built-in's. Because it is a flat binary, it has no relocation information -- so making two of them fit in memory at once took the cleverest trick in the project: assemble every program twice and diff the results.
20,480-byte pool · 26 API slots · .o88 format v2 · 109 fixups in Minesweeper · 151-byte HELLO
The setup: one segment, one pool, one jump table
Loaded programs do not get an address space. There is nothing on an 8086 to give them one. They run in the same tiny model as the kernel -- CS = DS = SS = 0x1000 -- and are placed in a pool of offsets 0xA000 to 0xEFFF inside that segment: 20,480 bytes, sitting immediately above the kernel's code and .bss, which a build-time assertion keeps below 0xA000.
That arrangement is the whole reason a package feels like a built-in. Its window record
holds near pointers to its paint, key and click routines, and the window manager calls them
with the same call [bx+W_PAINT] it uses for the Note Pad. There is no dispatch
layer, no thunk, and no segment reload -- a package's paint proc costs exactly what a
built-in's costs.
The traffic in the other direction goes through a jump table. At offset 0x0010 in the
kernel segment sit 26 four-byte cells, each a near jmp to a kernel routine:
drawing primitives, the font, window creation, sleep and yield, the tick counter, mouse
state, and a pseudo-random generator. A package writes call OSAPI_WM_CREATE,
which assembles to call 0x0044. Nothing is looked up at run time.
The table's start offset and its exact length are both assembly-time assertions in
kernel/kernel.asm, because the layout is a binary interface: a package built
last year has those numbers baked into its instruction stream. If the table moved or changed
length, every existing package would call into the wrong routine, so the build refuses to
produce a kernel at all.
OSAPI_TABLE_OFF equ osapi_table - $$
OSAPI_TABLE_LEN equ osapi_table_end - osapi_table
%if OSAPI_TABLE_OFF != 0x0010
%error "osapi table must start at offset 0x0010"
%endif
%if OSAPI_TABLE_LEN != 26 * 4
%error "osapi table length changed - it is a pinned ABI"
%endif
The problem
A flat binary out of NASM is just bytes. Every address inside it -- the pointer to a window
template, the address of a string, the displacement of a call -- was computed at assembly time
from the org directive at the top of the file. Assemble at org 0xA000
and the binary contains mov si, 0xA5C3. Load that binary anywhere other than
0xA000 and SI now points at somebody else's memory.
So a package could only ever run at the bottom of the pool, one at a time. No two programs
resident together, and certainly not two copies of Minesweeper. The usual answer is a linker
that emits a relocation section, and there is no linker anywhere in this project -- the entire
build is nasm -f bin flat binaries, deliberately, because Apple's toolchain
ships only a Mach-O linker.
The fix is not to add a linker. It is to make the assembler tell on itself.
The trick: assemble it twice and subtract
The Makefile builds every package twice from the identical source, at two link bases 0x800 apart:
nasm -f bin -w+error -I apps/ -o build/mines.bin apps/mines/mines.asm
nasm -f bin -w+error -I apps/ -o build/mines.alt.bin -DOS88_ORG=0xA800 apps/mines/mines.asm
The SDK's header macro reads OS88_ORG and emits org 0xA800
instead of org 0xA000 when it is defined. Nothing else in the source changes.
Then tools/os88pkg.py walks the two files byte by byte, and every place they
differ is, provably, a word the assembler computed from the base address. There are exactly
three cases:
| Difference | What it is | Fixup at load time |
|---|---|---|
| Went up by 0x800 | An embedded package address -- an imm16, a disp16, a dw label. It moved with the image. | Class 0: add the load delta. |
| Went down by 0x800 | The rel16 displacement of a call OSAPI_*. The call site moved; the kernel routine did not, so the distance between them shrank. | Class 1: subtract the load delta. |
| Unchanged | Ordinary code and data -- including package-internal jumps, whose two ends moved together. | None. Not in the table. |
The probe delta is 0x800 for a reason: its low byte is zero, so a relocated word differs from its twin in exactly its high byte and nowhere else. The scanner can therefore find each fixup by looking for a single differing byte whose predecessor matches, which makes the detection deterministic rather than heuristic. Anything else -- a differing low byte, a difference that is not 0x800, two fixups overlapping -- is a hard error and the package is not written.
The reconstruction check
Finding the fixups is not enough; the tool has to prove the list is complete. So it takes
the first binary, applies its own table to it, and requires the result to equal the second
binary byte for byte. If a single address slipped through -- because the author
split it across two dbs, or shifted it, or folded it into an arithmetic constant
-- the reconstruction diverges and the build fails there, on the developer's machine, instead
of producing a program that runs only at 0xA000 and corrupts memory anywhere else.
That check is what turns one binding author rule into something mechanical:
A package address may only be embedded as a whole 16-bit word. Never byte-truncated, never shifted, never split, never folded into a non-address constant. It is the only rule the SDK imposes that a compiler would normally handle for you, and it is enforced by a byte comparison rather than by discipline.
What ships
The table is appended to the image as one little-endian word per fixup: the low 15 bits are the image offset of the word to patch, bit 15 is the class. Image sizes are capped at 0x5000, so bit 15 is free to carry the class flag. The tool stamps the count into the header's relocation-count field -- which NASM must emit as zero -- and writes the file.
| MINES | HELLO | |
|---|---|---|
| Image (resident bytes) | 1,375 | 151 |
| Zeroed bss | 426 | 0 |
| Relocation entries | 109 | 10 |
| Class 0 (package addresses) | 88 | 5 |
| Class 1 (calls into the kernel) | 21 | 5 |
| Table on disk | 218 bytes | 20 bytes |
| File total | 1,593 bytes | 171 bytes |
| Region it needs | 2,048 bytes | 512 bytes |
Every figure above is the header of the shipped .o88 file, read
back with struct.unpack. Minesweeper's 218-byte table is 14% of the file and
costs nothing resident -- see the loader's step 8.
The loader, in order
Loading runs on the UI task only, with the drawing lock not held.
kernel/loader.inc is 433 lines and does this:
- Validate the directory entry. Index in range, type 1, size non-zero and no larger than the 0x5000 budget.
- Peek the header. One sector into the mount scratch buffer. Check the magic
O8, version 2, the link base, the entry offset, and that image size plus twice the relocation count equals the file size -- which catches a truncated file or a stale directory. - Size the allocation. Round up to a whole number of sectors the larger of image-plus-bss and the file size. Sector granularity is what makes the next read safe: the read writes whole sectors, and the region is a whole number of them, so it can never spill into a neighbour.
- Reserve. An instance record, then a region: first fit, lowest base, walking the pool from 0xA000 and stepping past any in-use package record that overlaps. There is no free list. A region is occupied if and only if some instance record claims it, which is why closing a program frees its memory with one byte store.
- Read the file in, one sector per BIOS call, three attempts each. Then re-validate the header against the copy now in memory, because the floppy could have been swapped between the peek and the read.
- Relocate. Walk the table, patching each word by the delta -- add for class 0, subtract for class 1.
- Zero the bss over the top of the table. The table is dead the instant it has been applied, and it sits exactly where the bss goes, so the zeroing is also the disposal.
- Call the entry. It creates its window and returns; the loader publishes the instance record, shows the window, and from then on the program is event-driven code that only runs when something is painted, typed or clicked.
The patch loop
The delta is the load base minus 0xA000. For the first package into an empty pool it is zero, and the loop runs anyway, adding nothing to 109 words.
mov si, [ld_base]
add si, [ld_img] ; SI -> reloc table
mov cx, [ld_rcnt]
mov dx, [ld_base]
sub dx, APP_LOAD_OFF ; DX = delta (0 for a base-of-pool load)
jcxz .reloc_done
cld
.reloc:
lodsw ; AX = table entry
mov di, ax
and di, 0x7FFF ; DI = image offset
cmp di, LD_HDR_SIZE
jb .bad2 ; a fixup may never touch the header
mov bx, [ld_img]
sub bx, 2
cmp di, bx
ja .bad2 ; or run past the image end
add di, [ld_base] ; DI -> the word to patch
test ax, 0x8000
jnz .relsub
add [di], dx ; class 0: embedded package address
jmp short .relnext
.relsub:
sub [di], dx ; class 1: rel16 displacement into the kernel
.relnext:
loop .reloc
Note the two bounds checks inside the loop. The table came off a floppy and is trusted no further than that: a fixup offset below 0x20 would rewrite the header, one past image minus two would write outside the image, and either aborts the load with "Bad package" rather than scribbling.
Every failure path leaves running programs untouched. The instance record is reserved but not published until the very last step, and an unpublished record claims no region -- so a load that runs out of memory, hits a bad sector or aborts in the entry point simply stops, and the pool is exactly as it was.
Two copies of the same program
Nothing in the loader knows or cares that a file has been loaded before. Launch Minesweeper twice and you get two independent 2,048-byte regions, each holding its own fully relocated copy of the image and its own 426 bytes of zeroed bss. The board, the mine positions, the flag mode and the remaining-mines counter are all offsets from the package's own end-of-image label, so they are per-instance automatically -- there is no shared-state problem to solve because there is no shared state.
The cost is that the code is duplicated too. A second Minesweeper is another 1,375 bytes of identical instructions in RAM. Sharing them would mean position-independent code or a data register convention, and on an 8086 with no base register to spare, duplicating 1,375 bytes is the cheaper answer.
os88fs: a filesystem for exactly this
The software floppy is not FAT. It is a read-only format built around what the kernel actually has to do, which is: find out the disk's geometry, list up to a couple of dozen files, draw an icon for each of them, and read one of them into memory at a sector boundary. FAT12 would mean a BIOS parameter block, a cluster allocation table, chain walking and a directory format with attributes and timestamps that nothing here reads -- code that has to fit under the 0xA000 ceiling alongside a window manager and a scheduler.
| LBA | Contents |
|---|---|
| 0 | Superblock: the magic OS88FS2 and a zero byte, then sectors per track, heads, and the file count. |
| 1 to 2 | Directory: 32 entries of 32 bytes -- a 15-character NUL-padded name, a type word, the start LBA, and the size in bytes. |
| 3 to 6 | Icon table: 32 entries of 64 bytes, one per directory slot -- 16 mask words then 16 data words. All zero means "no icon". |
| 7 onward | File data, each file starting on a sector boundary. |
Two details earn their place. The superblock names the disk's own geometry, so the same mount code reads a 1.44MB image at 18 sectors per track and a 360KB image at 9 with no conditional logic -- it reads LBA 0 with the fallback geometry, which is the same physical sector under either, and takes the real numbers from what it finds. And the icon table is separate from the files, so the Disk window can draw a program's real icon without loading the program: the host tool lifts the 64-byte block out of each package and copies it to LBA 3.
The honest cost of a private format: you cannot mount one of these floppies on a PC, and
the OS cannot write one. Building a software disk needs tools/os88disk.py on a
host machine.
Writing one
The SDK is a single NASM include, apps/os88api.inc. It gives you the
OSAPI_* offsets for all 26 jump-table entries, the window record and template
layouts, the 16 EGA color indices, and three macros:
OS88_HEADER 'NAME', entry emits the 32-byte header,
OS88_BSS n declares how many bytes the loader should zero after the image, and
OS88_IMAGE_END seals the image -- it defines the label your bss offsets hang off,
computes the image size for the header's forward reference, and fails the assembly if image
plus bss exceeds 0x5000.
Here is a complete package. This is apps/hello/hello.asm in full, minus its
file-header comment. It assembles to 151 bytes and ships as a 171-byte file.
%include "os88api.inc"
OS88_HEADER 'HELLO', hl_entry
HL_CONT_W equ 238 ; content width: 240 outer - 2px borders
; -----------------------------------------------------------------------------
; hl_entry - package entry point (SPEC.md 20.2)
; in: DS=ES=KERNEL_SEG, IF=1, gfx lock NOT held
; out: BX = window ptr, CF clear (CF set = abort, propagated from wm_create)
; The loader wm_shows the window; we must not show, draw or spawn here.
; -----------------------------------------------------------------------------
hl_entry:
push si
mov si, hl_tpl
call OSAPI_WM_CREATE ; BX = window ptr, CF on table full
pop si
ret
; -----------------------------------------------------------------------------
; hl_paint - W_PAINT: two centred lines on the white content
; in: SI = window ptr; caller holds the gfx lock
; out: nothing; preserves all registers
; -----------------------------------------------------------------------------
hl_paint:
push ax
push bx
push cx
push dx
push si
mov bx, si
call OSAPI_WM_CONTENT ; AX = content left, DX = content top
mov bx, ax ; keep the content left in BX
mov al, CBLACK
call OSAPI_SET_COLOR
mov si, hl_s_line1 ; content is 71px tall; two 8px lines
add dx, 25 ; 12px apart, centred as a 20px block
call hl_line
mov si, hl_s_line2
add dx, 12
call hl_line
pop si
pop dx
pop cx
pop bx
pop ax
ret
; -----------------------------------------------------------------------------
; hl_line - draw one line centred in the content width
; in: SI = NUL string, BX = content left, DX = y
; out: nothing; preserves all registers
; -----------------------------------------------------------------------------
hl_line:
push ax
push cx
call OSAPI_FONT_WIDTH ; AX = pixel width
mov cx, HL_CONT_W
sub cx, ax
shr cx, 1
add cx, bx
call OSAPI_FONT_STR
pop cx
pop ax
ret
; --- window template (SPEC.md 11: 16 bytes, 8 words) ---------------------------
hl_tpl:
dw 200, 150, 240, 90 ; x, y, w, h -> content 238 x 71
dw hl_ttl, hl_paint, 0, 0 ; no onkey, no onclick
hl_ttl: db 'Hello', 0
hl_s_line1: db 'Hello from a', 0
hl_s_line2: db '.o88 package!', 0
OS88_IMAGE_END
Ten of the words in that listing need patching: the three string and template addresses
loaded into SI, the two pointers inside the window template, and the five
call OSAPI_* displacements. The two call hl_lines do not -- both
ends of those moved together. The packaging tool works all of that out without being told,
because those ten words are the only bytes that change when the base changes.
Building it is three commands, which the Makefile runs for you:
nasm -f bin -w+error -I apps/ -o build/hello.bin apps/hello/hello.asm
nasm -f bin -w+error -I apps/ -DOS88_ORG=0xA800 -o build/hello.alt.bin apps/hello/hello.asm
python3 tools/os88pkg.py build/hello.bin --alt build/hello.alt.bin -o build/hello.o88
python3 tools/os88disk.py -o build/apps.img --size 1440 build/mines.o88 build/hello.o88
To give it an icon, pass a third argument to the header macro and follow it with 32
hand-written dw rows -- 16 mask words, then 16 data words, bit 15 leftmost. The
macros assert that the block starts at file offset 32 and ends at 96, so a miscounted row is
an assembly error and not a garbled picture. Minesweeper does this; HELLO deliberately does
not, which is how the generic-icon fallback stays tested.
What a package cannot do
- It cannot spawn a task. Packages are event-driven only: the entry creates a window and returns, and everything after that happens inside a paint, key or click callback. There is no Bounce-style animation loop available to a loaded program.
- Its entry must not draw, or show, hide or raise a window. The loader does that, under the drawing lock, after the instance is registered.
- It gets no protection and offers none. One segment, no MMU, and a bad pointer in a package is a bad pointer in the kernel.
- Image plus bss must fit 0x5000 bytes, and the pool it shares with every other resident program is 20,480 bytes total. A load that will not fit fails with "Out of memory" and disturbs nothing that is already running.
- A disk holds at most 32 files, names are at most 15 characters, and file sizes are a single 16-bit word.
- Nothing is written back. The filesystem is read-only, so a program cannot save anything -- not a high score, not a note.
Load one in your browser All the internals Download the software floppy