AntOS Library Design

How AntOS's functionality is split into libraries — native cores, thin Luau bindings, where they live on disk, the command search path, and the rule that keeps the scripting API compatible with standard Lua. This is the canonical spec for the CM5 rewrite; the per-library API pages linked at the end document each library's calls.

The pre-CM5 libraries were, honestly, practice — one blob of bindings, an os that had drifted from stock Lua, and file handling that didn't behave the way a Lua programmer expects. The rewrite fixes that with a small number of firm rules. This page is those rules.


1. Two layers: native core + Luau binding

Every library is two units, built and versioned separately:

  1. Native core — libantos_<name> (e.g. libantos_fs, libantos_net). Plain C/C++, a clean public header, and no dependency on Luau at all. This is the real functionality. Anything can link it — AntOS, a standalone CLI tool, a unit test, a future daemon — without pulling in the script VM.
  2. Luau binding — a thin adapter (luaopen_<name>) that marshals the core's API into Luau: argument checking, userdata/metatables, return-value shaping, and nothing else. It depends on the core plus the Luau headers. The core never depends on the binding.

The dependency arrow points one way only: binding → core, never back. If you find yourself wanting to put behaviour in a binding, it belongs in the core.

Why bindings are separate libraries too — one binding per core, independent of each other:

  • the core stays language-agnostic and unit-testable without a VM;
  • require("net") loads only the net binding — a script that never touches the network never pulls it in;
  • a library can be added or replaced by dropping in one core + one binding, without relinking the rest;
  • if AntOS ever exposes the same cores to something other than Luau (an RPC surface, a REPL, a remote console), that's a new binding layer over the same, unchanged cores.

2. Where the libraries live

Everything below lives inside the running OS image, not on a data partition — see §3 for why.

<os-image>/
├── antos                 # the executable
├── libs/                 # native cores      → libantos_*.so   (linker rpath)
├── bindings/             # Luau C modules    → fs.so, net.so …  (package.cpath)
├── lua/                  # pure-Luau modules (no native code)   (package.path)
├── scripts/              # system commands (reached as SYS:/scripts)
└── doc/                  # one .md per library

The AntOS executable links the cores it needs like any other program (-lantos_fs -lantos_term …, resolved from libs/ via rpath). At runtime:

  • require("term") resolves bindings/term.so (on package.cpath), which in turn resolves libantos_term.so from libs/;
  • require("json") — a pure-Luau helper with no C behind it — resolves lua/json.lua (on package.path).

Keeping bindings/ (native modules) and lua/ (pure modules) as separate search roots means the native cpath and the pure path never get conflated — one of the things that was muddy in the practice version.

Note — this is not PATH. package.path/package.cpath resolve required modules. The command PATH (§4) resolves typed commands (speedtest → <dir>/speedtest.lua). Two different namespaces; don't conflate them.


3. The image slot and SYS:

The system scripts, libs, and bindings must always match the executable that links them, so they version, update, and roll back atomically with the OS. They therefore live in the A/B OS-image slot from the firmware design, never on a mutable data partition.

That slot physically flips on every OS update (slot A ↔ slot B), so nothing may hardcode the slot path. Instead a reserved prefix resolves it:

  • SYS: — "the currently-running OS image." It resolves to whichever A/B slot is live, so SYS:/scripts is valid forever, across every update.

SYS: is read-only at runtime (it's the signed OS image) and is the anchor for libs/, bindings/, lua/, scripts/, and doc/. User code and user data live on DBFS (D:); code in the image, data in DBFS.


4. The command PATH

The old hardcoded D:/scripts becomes a real ordered search path, Windows-style — and Windows-style is not just familiar here, it's required: the separator must be ;, because : is already a drive letter (D:/scripts;C:/scripts is unambiguous; a colon-separated path would collide with D:).

Layered default + DBFS copy, the same robustness pattern used elsewhere in AntOS:

  • a compiled-in default is the guaranteed baseline — the system commands are always reachable even with no D: mounted (first boot, recovery), so the console never becomes unusable because DBFS is missing;
  • a DBFS copy (a path key in sys.db config) is the persistent, user-editable version, seeded from the default at first boot.

Effective path = default (always) + DBFS value (when D: is present), first-match-wins.

Default order — SYS: is last:

D:/scripts ; C:/scripts ; SYS:/scripts

User scripts (D:) resolve first, then an inserted cartridge (C:), then the built-in system commands (SYS:). Because SYS: is last, a user can override any system command simply by dropping a script of the same name in D:/scripts — in keeping with the AntOS "let me do something weird with it" ethos. The order is a single editable string, so anyone who wants system-first can reorder it.

Supporting commands (all persisting to DBFS via the os.arguments convention):

Command Does
path view / set the path · path +add D:/bin · path +remove … (the analogue of Windows path / set PATH)
where (canonical) / which (alias) resolve a command name and show which path entry won; flags shadowed duplicates
help scans every path entry in order (not just D:/scripts), so system, user, and cartridge commands all self-document; notes when one shadows another

This is really the first instance of a general env/config concept, but it ships as a single named config key with the default+DBFS layering — the wider env system is deferred until a second variable actually needs it.


5. The standard-compatibility contract

The single most important rule, and the one the practice version broke:

Where an AntOS library overlaps a standard Lua/Luau library, it matches the reference contract exactly. AntOS only ever adds — new names and optional arguments, never redefined behaviour.

Concretely:

  • os time functions are stock Luau, untouched. os.time, os.date, os.clock, os.difftime keep reference semantics — os.clock() stays process-CPU seconds (the benchmarking delta), not wall time or uptime, which was the classic deviation. A Lua user "gets the time" exactly as they always have.
  • Files are pure Lua io. io.open(path, mode) returns a normal Lua file handle with :read/:write/:lines/:seek/:flush/:close, the standard read formats, and nil, errmsg on failure. A Lua programmer's file code runs unchanged. The only AntOS-visible thing is that path is a drive-letter string (D:/scripts/foo.lua) — transparent, because it's just a string the VFS resolves underneath.
  • Genuinely-new functionality goes in its own namespace. Directory listing, stat, mkdir, existence, DBFS tags/queries — Lua never had these, so they live in *`fs.**, not bolted ontoos/io. That separation is what prevents the old confusion:osandiomean exactly what a Lua user expects;fs` is "the AntOS extras."

Per-function tagging. Every function in every library doc is tagged so the boundary is explicit per call:

  • [standard] — behaves as reference Lua/Luau; a portable Lua program can rely on it.
  • [antos] — an AntOS addition; no portability guarantee, this is ours.

A small compat test runs the reference os/io expectations (host-independent cases) against the AntOS implementations, so drift is caught mechanically, not by review.


6. Extending an API call

Extensions attach as an optional trailing argument that stock code never passes, so the reference contract stays byte-for-byte intact:

io.open("D:/notes.txt", "w")                       -- pure standard, unchanged
io.open("D:/notes.txt", "w", { tags = {"note"},    -- AntOS extension (3rd arg)
                               meta = {author="anthony"} })

Two rules keep this honest:

  1. the extension argument is always last and always optional;
  2. it is only meaningful on DBFS (D:) — a plain FAT volume (A:/B:/E:) has nowhere to store tags. Policy: the optional io.open table is silently ignored off-D: (so casual cross-volume code doesn't break), while an explicit fs.tag() call off-D: returns nil, err (so intent-to-tag fails loudly). (Open — see §12.)

7. DBFS auto-cataloguing (below the binding)

Automatic cataloguing lives in the DBFS / VFS layer, not in any Luau binding. If it lived in the binding, a file written from C, from the debug server, or over the network wouldn't be catalogued — two classes of file. In the VFS it can't be bypassed:

  • any create / update / delete / rename on D: — from io.open, from C, from a copy command, from the network — writes/updates the catalogue row (path, size, mtime, checksum, kind) in the same SQLite transaction as the data BLOB. It's another statement in a commit you're already making, so it's transactional and nearly free;
  • the explicit tags / meta from the extended API (§6) are extra rows/columns layered on top of that automatic baseline — user intent added to system-maintained facts;
  • fs.query{ tag = "draft", modified_after = … } then works uniformly regardless of who created the file.

Division of labour: DBFS auto-catalogues everything structurally (can't be skipped); the binding surface adds optional user semantics (tags, meta).


8. The binding macro layer — libantos_bind

The ESP32-P4 bindings already show where the boilerplate is. Counting the idioms across the shipped lib_*.cpp files:

Idiom Uses What it is
luau_setfunc(L, "name", fn, …) ~510 function registration (already a helper)
lua_setfield + lua_newtable ~500 + ~160 building result tables a field at a time
luaL_check* / luaL_opt* ~360 typed argument extraction
pushnil/pushboolean(0) + message + return 2 ~220 the nil, err error return, hand-written each time
luaL_checkudata ~11 fetching a handle userdata

So the rewrite gives every binding one shared header, libantos_bind.h — used by every binding and by no core — that turns each of those into a one-liner. It is marshalling only; no behaviour ever hides in a macro.

Registration. The existing luau_setfunc(L, "name", fn, "lib.name") primitive stays; ANTOS_MODULE(net, …) wraps a module's whole list of them plus the luaopen_<lib> and the package.preload registration, so adding a call is one line.

Argument extraction. ARG_STR(1, path), ARG_INT(2, count), ARG_OPT_INT(3, limit, 100), ARG_OPT_STR, ARG_NUM, ARG_BOOL — each expands to the luaL_check*/opt* call plus a named local, with a uniform lib.fn: bad argument N message. This also replaces the ad-hoc optional idiom seen in the source (lua_isnumber(L,n) ? … : def).

Result tables — the biggest single win, since ~660 lua_newtable/lua_setfield calls are just building tables field by field. A tiny table DSL:

TBL_BEGIN(L);
TBL_STR (L, "server", host);
TBL_INT (L, "unix",   ts);
TBL_BOOL(L, "valid",  ok);
TBL_END();                 // table left on the stack — return 1

with TBL_NUM, TBL_NIL, and TBL_SUB (a nested table). A status() / info() / stat() that was fifteen stack calls becomes five readable lines.

The return contract. The nil, err shape appears ~220 times, written out by hand each time — from the source, lua_pushboolean(L,0); lua_pushstring(L,msg); return 2;. Macros make it one line and uniform:

Macro Pushes / returns
RET_OK(L) true — return 1
RET_BOOL(L, c) · RET_INT(L, n) · RET_STR(L, s) · RET_NIL(L) the value — return 1
RET_ERR(L, "msg") nil, "lib.fn: msg" — return 2
RET_ERRF(L, fmt, …) formatted error — return 2
RET_OR_ERR(L, cond, "msg") the value, or nil, msg — the one-liner

This is what makes the §5 contract un-skippable: a binding returns the reference value / nil, msg shape by construction, and can't accidentally throw, luaL_error, or return 0 where Lua expects nil, err.

Userdata handles. UD_NEW(L, Type) / UD_CHECK(L, 1, Type) wrap the allocation + metatable and the checked fetch for the handle types the source already uses this way (HTTP easy/multi, sockets, WebSocket, prepared statements, file handles), paired with a standard __gc.

One rule, unchanged: macros marshal, they never carry behaviour — the moment logic wants to live in a macro, it belongs in the core.


9. Dynamic vs static

Default is dynamic — .so cores + .so bindings, required on demand (§2). That is what "proper separate libraries" implies, and it lets a third party ship one library as a pair of files.

A single self-contained binary is still supported: statically register the bindings into antos and link the cores in. The two-layer split and the docs are unchanged; only runtime modularity is lost. Reasonable path: start dynamic, statically fold in the stable libraries later.


10. The library manifest

A single manifest — SYS:/libs/manifest.json (mirrored into sys.db when D: is present) — lists every library: name, version, core file, binding file, one-line summary, doc page. require, help, where, and the doc build all read the manifest rather than hard-coding the list, so adding a library is a manifest entry plus the two .so files.


11. Documentation convention

  • One .md per library, in doc/.
  • Each page serves two audiences: the script-facing surface (require, functions, args, examples — what luau promises authors) and, in a final section, the native C API for contributors. One file with both, or <lib>.md + <lib>-native.md for large libraries.
  • Every function is tagged [standard] or [antos] (§5).
  • Signature, one-line description, arguments, return values (including the nil, err form), and at least one example per function.

11. Naming conventions

API names are snake_case throughout — send_text, on_click, set_texture, buzzer_clear — for the native core and the Luau binding. Two reasons:

  • Core ↔ binding symmetry. The native cores are C/C++ (snake_case by convention), so sys_send_text maps to send_text with no case translation — the binding name is the core name minus the prefix.
  • Standard-Lua fit. The stdlib AntOS extends is lowercase (io.read, os.time); snake_case is its natural multi-word extension and never collides with a reference-Lua name (§5).

Rules: functions and fields are snake_case; a namespace is the library/table name (os.settings.get); acronyms lower-case inside a name (set_rgbw, node_id — not setRGBW/nodeID); event handlers are on_<event> (on_click, on_draw, on_change); booleans read as predicates where natural (is_breakpoint, ready). The P4 sources carried a few camelCase holdovers (buzzerClear, unpackFile, invalidateAll, dbInit, addBreakpoint, …) — all normalized to snake_case in the rewrite.

11a. Retired and reconceived from the ESP32-P4 libraries

The CM5 rewrite drops or folds several P4 libraries. Recorded here so the absence is intentional, not an oversight:

P4 library Disposition
rtos Folded → sys. The task view survives (sys.tasks/spawn/wait/kill); the FreeRTOS primitives (queues, semaphores, mutexes, event groups, software timers, tick math) are dropped — kernel-specific and misconceived on Linux, where Lua coroutines and the OS serve instead.
mem Folded → sys as sys.mem.* (bounds-checked peek/poke).
rtc Folded → os as os.rtc.* (the hardware clock the OS reads).
settings Folded → os as os.settings.*; the settings window is a UI registry (subsystem), not a scripting library.
curl, internet, udp, icmp Merged → net — one networking library (HTTP/WS/TCP/UDP/DNS/ICMP/NTP/mail).
data.crypto, data.ca Split → crypto (Ed25519, HMAC, TLS CA store); plain hashing stays in data.
buzzer Folded → audio (system tones; the home for wider audio).
screenshot Folded → image (image.screenshot() — a screenshot is "encode the framebuffer").
sqlite Merged → db (one raw-SQL surface over DBFS).
mipi Dropped — an ESP32-P4 display bring-up crutch; on the CM5, MIPI is Pulse / the keyboard TFT / the cartridge port (hardware), not a script library.
ppa Dropped — the ESP32-P4 Pixel Processing Accelerator; its work (sprites, scaling) moves to FireStorm / VideoCore. See sprite.
phreak Skipped — empty in the source (the C5 wireless bring-up is elsewhere).

The rule throughout: nothing is kept for compatibility's sake. Where a P4 concept still makes sense on the CM5 it's reconceived onto the new hardware; where it was kernel- or chip-specific, it's dropped.

12. Open policy questions

Settle these in this doc rather than rediscovering them mid-rewrite:

  1. Tags off-D: — silently ignore the io.open table but hard-fail explicit fs.tag()? (§6 assumes yes.)
  2. Tag lifetime on rewrite — when io.open("D:/x","w") truncates an existing tagged file, keep the user tags or clear them? (Proposed: system columns always refreshed; user tags sticky across content rewrites, cleared on delete.)
  3. PATH precedence — ship D: → C: → SYS: (user-first, decided) and leave reordering to the user; no system-first lock.
  4. Dynamic vs static default — ship dynamic; fold stable libraries into the binary later? (§9.)
  5. Cartridge scripts (C:) in the default path — include by default (convenient) or opt-in (a cartridge shouldn't silently add commands)?

The standard libraries

Library require Overlaps standard? Page
os built-in / require("os") Yes — time/process is stock; host bits added antos_os
io built-in / require("io") Yes — stock file handles; drive paths + optional tags antos_io
fs require("fs") No — AntOS filesystem extras (dir, stat, tags, query) antos_fs
terminal require("terminal") (alias term) No antos_terminal
net require("net") No antos_net
data require("data") No antos_data
sys require("sys") No antos_sys

(A hw library — I²C / SPI / GPIO / LED / cartridge access over the CM's Linux interfaces — is planned but not yet specified; it will follow the same two-layer model and land as antos_hw when its surface is settled.)


Related documents

Important: The Ant64 family of home computers are at early design/prototype stage, everything you see here is subject to change.