Skip to content

Phase 17 library access

Simon Dick edited this page Jun 20, 2026 · 1 revision

Phase 17 — Native AmigaOS library access (amiga.library)

Part of the Amiga port design log.

Step 7 (callback thunks) is deferred — see below.

Generic library-call mechanism. Any function in any library — system or third-party — callable from Python with no port-side C per library.

Why this is tractable on AmigaOS

  • Every library function lives at a negative offset (LVO) from the base.
  • Args go in a fixed set of D/A registers — never on the stack.
  • Return value always in D0. Library base in A6.
  • All values 32-bit; no struct-in-register, no varargs.

NDK ships a .fd (Function Definition) file per library at /opt/amiga/m68k-amigaos/ndk-include/fd/*.fd — enough to mechanically look up call signatures.

Two-layer design

Layer 1 — low-level trampoline. ports/amiga/amiga_lib_call.S is a single hand-written 68k routine. C entry:

uint32_t amiga_lib_call_asm(
    uint32_t base, int32_t offset,
    uint32_t d0..d7, uint32_t a0..a5);

Saves callee-saved set (d2-d7/a2-a6), loads A6 with the base, loads the 14 register slots from the stack. The jump to base + offset uses an RTS-trick rather than computed JSR: with all D/A regs committed to user values, no scratch register is free for the call target. Pushing target + local return label and issuing rts transfers to the library; the library's own rts pops the local label and returns to our cleanup.

C extension is registered as _amiga; three entry points:

amiga.lib_open(name, version=0)         # OpenLibrary; OSError(ENOENT) on fail
amiga.lib_close(base)                   # CloseLibrary; tolerates 0 base
amiga.lib_call(base, offset, **regs, ret="d0")

Register kwargs: d0d7, a0a5 (A6 is the base; A7 is SP). ret="d0" signed, "d0u" unsigned, "void" returns None.

Layer 2 — .fd-driven proxy. Frozen amiga.py re-exports everything from _amiga, adds a Library class on top of the FD table baked in from _amiga_fd.py:

with amiga.library("intuition.library", 37) as intuition:
    intuition.DisplayBeep(0)
    win = intuition.OpenWindow(nw_ptr)
    intuition.CloseWindow(win)

Library.__getattr__ looks up the signature, builds a closure that maps positional args into the right register kwargs of _amiga.lib_call, and setattrs the closure onto the instance so subsequent reads skip __getattr__. Context-manager support; explicit close(); GC-time __del__ cleanup. Errors: unknown library → OSError(ENOENT); missing function → AttributeError; wrong arg count → TypeError; call after close → ValueError.

FD table generator

tools/amiga-fdgen.py parses every .fd under one or more NDK trees and emits a Python module whose LIBRARIES dict maps each openable name to {function_name: (lvo, regs_csv, since)}.

  • Chronological ordering. Hyperion's 3.1.4 → 3.2 → 3.2.x are calendar-newer than 3.5/3.9 (development restarted years after 3.9). Hand-maintained AMIGA_OS_RELEASE_ORDER list avoids 3.2 < 3.9 numeric sort wrecking the since stamps.
  • Drift detection. LVOs are append-only; a function with a different offset/register list in a later NDK is a hard warning. Earlier entry wins.
  • Public-only. ##private sections still consume LVO slots (so offsets are correct) but functions are dropped unless --include-private.
  • CIA and mathieeedoub* exceptions don't fit the convention; the tool warns and skips them.

Against current bebbo NDK: 76 .fd files → 75 openable names → 1146 public function signatures, ~80 KB Python source.

Runtime .fd loading + explicit signatures

When Library(name) doesn't find name in the frozen table, it falls back to a pure-Python .fd parser in amiga.py and walks PROGDIR:fd/ then LIBS:fd/, trying both bebbo <base>_lib.fd and bare <base>.fd. Alternatively, Library(name, version, signatures=...) or amiga.library_from_signatures(name, version, sigs) bypasses both lookups.

Tag lists (peek/poke + TagList)

Tag lists are pervasive in OS3.x. Three pieces in modamiga.c:

  • Memory primitives: peek_b/w/l/bytes(addr[, n]), poke_b/w/l/bytes(addr, v).
  • TagList class wrapping an AllocVec'd TagItem[]. (tag, value) slots are 8 bytes; ints go straight into ti_Data; bytes/str get their own AllocVec'd NUL-terminated buffer with the address stored in ti_Data. All allocations released by close() / __exit__ / GC __del__. Defines __int__ returning the head address.
  • amiga.taglist(...) factory. Kwargs resolved against _amiga_tags.TAGS; positional args support (tag, value) pairs, iterable, or alternating tag, value, tag, value. Unknown tag names raise KeyError.
  • Library proxy runs int(v) on any non-int arg so TagList passes through cleanly:
with amiga.taglist(WA_Width=640, WA_Height=480, WA_Title="hi") as tags:
    with amiga.library("intuition.library", 37) as intuition:
        intuition.OpenWindowTagList(0, tags)

tools/amiga-taggen.py drives bebbo's m68k-gcc to harvest tag IDs from NDK headers (preprocess with -E -dM, compile as const ULONG _x_<NAME> = (ULONG)(<NAME>); with -S -O2, parse .long from the assembly). Keeps only TAG_USER-namespace values (bit 31 set) plus universal TAG_* markers. 1072 tag IDs in _amiga_tags.py, ~48 KB frozen.

amiga.parse_taglist(addr, max_items=64) walks a TagItem array back into a {tag_id: value} dict, following TAG_MORE chains, dropping TAG_IGNORE, honouring TAG_SKIP. taglist(..., more=other) writes a TAG_MORE terminator pointing at other.addr and pins it alive on the outer.

Struct ctypes-lite

amiga.Struct(addr, layout, name=None) wraps a C struct at a fixed address. layout is {field_name: (offset, type_code)} with codes B/b/H/h/L/l/P/sN/S. Defines __int__ so an instance passes straight to a Library call. Starter layouts shipped in _amiga_structs.py:

factory struct
amiga.Node(addr) struct Node
amiga.Task(addr) struct Task
amiga.Library_struct(addr) struct Library (trailing _struct to avoid clash with the proxy class)
amiga.DateStamp(addr) struct DateStamp
amiga.FileInfoBlock(addr) struct FileInfoBlock
amiga.IntuiMessage(addr) struct IntuiMessage

Step 7 (deferred)

Callback thunks for hook-driven library calls (CreateNewProc, blit hooks, ARexx command dispatchers). Needs executable memory and per-callable thunks. Defer until a concrete user need lands; most OS3.x scripting doesn't need it.

Manifest plumbing

ports/amiga/manifest.py is freeze("$(PORT_DIR)/modules"). Makefile sets FROZEN_MANIFEST = manifest.py and MPY_TOOL_FLAGS = -mlongint-impl=longlong to match MICROPY_LONGINT_IMPL_LONGLONG (else mpy-tool.py emits MPZ literals which fail the static_assert in frozen_content.c).

Clone this wiki locally