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

Disk
The Disk window on the dithered gray desktop: a header line reading 'Drive B:  2 files', a Refresh button at the top right, and two file rows -- MINES with a small mine icon, selected as white text on a solid black band, with 1593 right-aligned, and HELLO with a plain generic application icon and 171.
The whole software library, on one floppy. Two packages, 1,593 and 171 bytes. MINES carries its own 16x16 icon inside the file; HELLO deliberately carries none, so the file manager draws the built-in generic one. Double-click a row and the kernel reads that file into free memory, patches it, and runs it.

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:

The three ways a word can differ between the two assemblies
DifferenceWhat it isFixup at load time
Went up by 0x800An embedded package address -- an imm16, a disp16, a dw label. It moved with the image.Class 0: add the load delta.
Went down by 0x800The 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.
UnchangedOrdinary 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.

Relocation tables of the two shipped packages
MINESHELLO
Image (resident bytes)1,375151
Zeroed bss4260
Relocation entries10910
Class 0 (package addresses)885
Class 1 (calls into the kernel)215
Table on disk218 bytes20 bytes
File total1,593 bytes171 bytes
Region it needs2,048 bytes512 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:

  1. Validate the directory entry. Index in range, type 1, size non-zero and no larger than the 0x5000 budget.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. Relocate. Walk the table, patching each word by the delta -- add for class 0, subtract for class 1.
  7. 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.
  8. 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 packages resident
Two windows on the dithered desktop: a Minesweeper window with a gray 9 by 9 grid of unopened cells and a counter reading 10, and below and left of it a smaller window titled Hello showing two centered lines, 'Hello from a' and '.o88 package!'. Three tiles sit in the dock at the bottom left; the first is drawn inverted, white on black.
Two programs off one floppy, at two different addresses. With these sizes, MINES took the region at 0xA000 -- a delta of zero, so not one of its 109 words actually changed -- and HELLO went in above it at 0xA800, delta 0x800, which happens to be the exact offset the packaging tool used as its probe. Together they occupy 2,560 bytes of the 20,480-byte pool. The Disk window that launched them has been minimized, so its dock tile draws inverted.

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.

The os88fs v2 on-disk layout
LBAContents
0Superblock: the magic OS88FS2 and a zero byte, then sectors per track, heads, and the file count.
1 to 2Directory: 32 entries of 32 bytes -- a 15-character NUL-padded name, a type word, the start LBA, and the size in bytes.
3 to 6Icon table: 32 entries of 64 bytes, one per directory slot -- 16 mask words then 16 data words. All zero means "no icon".
7 onwardFile 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.

Minesweeper
A Minesweeper window sitting on top of the Disk window, which still lists MINES at 1593 and HELLO at 171 behind it. The Minesweeper status strip reads 8 on the left and F=FLAG in the middle; the 9 by 9 board has a large revealed white region with blue 1s and green 2s, two small red flags on covered gray cells, and the rest still covered. Two tiles sit in the dock.
1,593 bytes of relocatable program, running like a built-in. Its paint, key and click procs are called from the same dispatch sites as the Note Pad's, and the Task Manager bills the cycles they burn to its instance row. F toggles flag mode, N starts a new game, and the first click is always safe because the mines are not placed until after it.

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