# Psion SIBO / EPOC — Operating-System Programming Model

Reference notes for the SIBO family (HC, MC, Series 3/3a/3c, Siena, Workabout /
Workabout MX) running the ROM-resident **EPOC** operating system, programmed
through the **PLIB** library.

Sources, cited inline as:

- **[GEN]** — *SIBO 'C' SDK, General Programming Manual*, v2.30
- **[PLIB]** — *PLIB Reference*
- **[SYS]** — *EPOC OS System Services* (assembly-level service interface)

Everything below is drawn from those manuals. Items derived from prior
reverse-engineering, rather than the manuals, are explicitly flagged
**[RE — reverse-engineered]**. Workabout-MX-specific points are flagged
**[MX]**; where the manuals do not distinguish the MX, that is noted as an
uncertainty rather than invented.

> **Terminology.** *SIBO* is the hardware architecture (8086-class CPU, ASICs,
> SSD storage). *EPOC* is the operating system that runs on it. This is the
> 16-bit SIBO-era EPOC, not the later 32-bit EPOC32/Symbian. [GEN §2]

---

## 1. The memory model

### 1.1 Small model, segments, and memory moving

EPOC programs are compiled in the 8086 **small model**: the code segment and the
data segment are each limited to **64 KB**. The restriction is deliberate — it
lets EPOC **move memory segments** (including a process's own code and data
segments) around physical RAM without any cooperation from the application, which
is essential for efficient RAM use in a multitasking system. [GEN §1; PLIB
"Introduction"]

Physical memory (address 0 to 0xFFFFF, up to 1 MB addressable; banked above
512 KB) is laid out roughly as: interrupt vectors (1 KB) → screen bitmap (small
displays) → OS data space → **allocated memory segments** → unallocated memory →
internal RAM drive (`LOC::M:`) → environment variables (≤4 KB) → screen bitmap
(large displays) → ROM (~256 KB). [PLIB §7 "Overview of system memory usage"]

All unallocated RAM is kept in a **single contiguous chunk**. Consequently
creating, deleting, or resizing one segment forces others to move. Segment
addresses and sizes are always expressed in **16-byte paragraphs**; segments
start on 16-byte boundaries. The segment allocator's maximum segment size is
**512 KB**. [PLIB §7]

**How moving is safe.** The supervisor process (`SYS$MANG`) does the moving. It
runs at higher priority than any other process (so it is not interrupted mid-move)
and its own data/code segments never move. After a move it walks each process
context and **adjusts the 8086 segment registers** (CS, DS, SS, ES) by the amount
the relevant segment moved. A register is adjusted only if it holds a value inside
a moved segment's range. [GEN §1; PLIB §7]

Two rules the application must obey so the supervisor's fix-up works:

- Never store a segment register to memory and later reload it (the memory may
  move in between). In assembler, protect any unavoidable save/restore by
  disabling interrupts across it (which blocks the context switch).
- Never load a non-segment value into a segment register (it might fall inside a
  moved segment's range and get "adjusted"). [GEN §1]

### 1.2 Segment kinds and naming

Allocated segments are described by a **name**, a **handle**, a **size**, and an
**address**. [PLIB §7]

- **Device segments** — created when an external device is installed, deleted on
  removal; normally do not resize. Placed at **lower addresses** than dynamic
  segments so device drivers (e.g. serial) are not disturbed by volatile dynamic
  activity. The first two are special: the data segments of `SYS$NULL` and
  `SYS$MANG`, created at startup and never resized. Extensions `.LDD` / `.PDD`
  denote Logical / Physical Device Driver segments.
- **Dynamic segments** — code and data segments created/deleted as processes come
  and go; process data segments grow/shrink to fit heap demand. Extensions:
  `.$SC` = shared primary code segment, `.DYL` = dynamic library code, `.$nn` =
  process data segment (nn = process slot number).

The **segment handle** is the relative address of the segment's 16-byte
index-table entry in OS data space; that entry holds the segment address, a
**usage count**, and the name. Usage count = number of processes using the
segment; when it drops to zero the segment is (normally) deleted. The index table
has fixed capacity — typically **96 total = 32 device + 64 dynamic**. [PLIB §7]

Segment API (names follow file-name rules, ≤8 chars + optional `.ext`):
`p_sgcreate`, `p_sgdelete`, `p_sgopen`, `p_sgclose`, `p_sgcopyto`, `p_sgcopyfr`,
`p_sgsize`, `p_sgadjust`, `p_sgfind`, `p_sglock` / `p_sgunlock` (usage-count
inc/dec). Access to data in an **external segment** is less convenient than to the
process data segment, but a single external segment can grow to the 512 KB limit —
the standard way to hold data structures larger than 64 KB (e.g. a document).
[GEN §1; PLIB §7]

### 1.3 The process data segment and heap

A process data segment is at most **0xFFE0** bytes (32 bytes short of 64 KB, so a
stack underflow always trips an address trap). Layout, low → high:

1. reserved static variables (0x40 bytes; "magic statics")
2. floating-point emulator data space (0x200 bytes from offset 0x100)
3. processor stack
4. initialised static variables
5. uninitialised static variables (also zero-initialised)
6. **process heap** — at the top so it can grow by growing the data segment

The word at offset 0 is initialised to **0xDEAD**; many OS calls check it and
panic (`PanicDead0`, panic 68) if a stray NULL-pointer write has changed it. Stack
bytes are pre-filled with 0xFF (the count of 0xFF from 0x40 measures free stack,
unless the FP emulator is in use, in which case the stack floor is 0x300). [PLIB
§7 "Process data segments"; §12 "Reserved statics"]

### 1.4 Heap cell allocation

Cells are referenced **directly by address** and **do not move** to compact freed
space. Each cell is prefixed by a hidden 16-bit length word (skipped by the
address returned to you; not counted by `p_alen`). Writing past a cell's bounds
corrupts the length word (or a free-list pointer) — a "time bomb" that a later
allocator call detects and turns into a panic.

The heap is **not fixed size**: EPOC grows it (up to the 0xFFE0 limit) to satisfy
requests and shrinks it to hand memory back, but never below the **initial /
minimum heap** value stored in the image (default 2 KB). Allocation is
**first-fit** over the free list; if nothing fits, the data segment is grown by
the requested amount **plus the heap granularity** (default `E_GROWBY_DEFAULT`
2 KB; max `E_MAX_GROWBY` 16 KB, set via `p_hgran`). [PLIB §7 "The heap allocator"]

| Function | Purpose |
|---|---|
| `void *p_alloc(UINT size)` | Allocate ≥`size` bytes; returns address or **NULL** on failure. Always test for NULL. |
| `void *f_alloc(UINT size)` | As `p_alloc` but calls `p_leave(E_GEN_NOMEMORY)` instead of returning NULL. |
| `void p_free(void *pcell)` | Free a cell. No-op if `pcell` is 0 (handy in cleanup). Double-free corrupts the heap. |
| `void *p_realloc(void *pcell, UINT size)` | Resize; returns new address or NULL (original untouched). `pcell==0` ⇒ acts like `p_alloc`. |
| `void *f_realloc(...)` | As `p_realloc` but leaves on failure. |
| `void *p_adjust(void *pcell, UINT offset, INT amount)` | Open (`amount`>0) or close (`amount`<0) a gap inside a cell — insert/delete records in place. |
| `UINT p_alen(void *pcell)` | Cell length (may be a few bytes > requested). |
| `void p_hgran(UINT nparas)` | Set heap growth granularity (paragraphs). |
| `void p_allwalk(fn, fpar)` | Walk every cell (diagnostic); panics on inconsistency. |
| `void p_allchk(INT num)` | Force a heap-integrity check; `p_panic(0xFF)` if corrupt. |
| `UINT p_allspc(void **pheap)` | Potential free space + heap start address. |

**Design cautions.** *Alloc heaven* — if a multi-cell structure is built and a
later allocation fails, free the already-allocated cells or they leak. *Internal
fragmentation* — avoid churning tiny transient cells (use the stack), and
"granularise" many-cell variable-length structures. Do not let an unbounded
heap structure grow until allocation fails at the 0xFFE0 limit: leave enough heap
for the user's expected actions (e.g. saving to file). Note EPOC does **not**
distinguish "no system RAM" from "hit the 64 KB data-segment limit" — both surface
as an allocation failure. [PLIB §7]

System-memory queries: `p_getram` (RAM in paragraphs, capped at 32768 = 512 KB
due to banking), `p_totalK` (total KB ignoring banking; **EPOC ≥ 3.50 only**),
`p_sgfree` (free segmented memory), `p_sgramdisk` (RAM-disk usage). [PLIB §7]

---

## 2. Processes

### 2.1 What a process is

A process is a running program, normally created by loading an image (`.img` /
`.app`) file. EPOC is a **single-user, preemptive multitasking** OS. Max
concurrent processes `E_MAX_PROCESSES` = **24**. A process consists of at least: a
**process control block** (`E_proc`), a **data segment**, a (possibly shared)
**primary code segment**, and an **I/O semaphore**. [PLIB §12]

**Process ID** — a positive 16-bit value: low 12 bits = PCB offset in OS data
space (the "slot"); high 4 bits = a generation counter (0–7, incremented mod 8 on
slot reuse) so a stale PID from a terminated process is rejected. A bad PID panics
the caller with **panic 7**. [PLIB §12]

**Process names** — `<name>.$nn` where `<name>` is 1–8 chars and `nn` is the
2-digit slot index. For a **task** (a subsidiary process sharing its owner's data
segment — a lightweight thread) the `$` becomes `a`, e.g. `SYS$WSRV.a05`. Max name
`E_MAX_NAME` = 12. Loading the same image twice yields distinct names (e.g.
`SORT.$07`, `SORT.$11`); `p_pidfind("SORT.*")` / `p_pfind` resolve names→PIDs with
wildcards. [PLIB §12]

The standard system processes created at startup:

| Name | Role | Priority |
|---|---|---|
| `SYS$NULL` (`.$01`) | Null process; runs when nothing else is ready; powers the machine off after inactivity | 0 |
| `SYS$MANG` (`.$02`) | Supervisor: memory moving, resource cleanup, critical functions | 248 |
| `SYS$FSRV` (`.$03`) | File server; also loads images to create processes | 240 |
| `SYS$WSRV` (`.$04`) | Window server: screen/keyboard/digitiser | — |
| `SYS$SHLL` (`.$05`) | Shell / launcher | — |

Name/priority/suspend operations **fail** on `SYS$NULL`, `SYS$MANG`, `SYS$FSRV`.
[PLIB §12]

### 2.2 Creating a process

```c
HANDLE p_execc(TEXT *pName, VOID *pCommand, INT length);   /* usual route */
```

Loads the image `pName` (parsed with default extension `.IMG`) and returns a
**positive PID** — but the new process is created **SUSPENDED**, so the creator
can initialise it (e.g. set priority) before starting it with `p_presume`. A
command cell is allocated in the *new* process's heap containing the full parsed
path, a length byte, and `length` bytes copied from `pCommand`
(`length` ≤ `E_MAX_COMMAND_BUFFER` = 127); its address lands in the reserved
static `DatCommandPtr`. Loading is done by the file server, so **the caller must
already be connected to the file server** (the standard PLIB startup does this
before `main`; otherwise panic 41). [PLIB §12]

Error returns from `p_execc` (illustrative of the E_FILE_*/E_GEN_* families):
`E_GEN_NOMEMORY`, `E_FILE_DEVICE`, `E_FILE_NOTREADY`, `E_FILE_DIR`,
`E_FILE_NXIST`, `E_FILE_EXIST` (a different program of the same name — checksum
mismatch — already runs), `E_GEN_IMAGE` (not a valid/corrupt image),
`E_GEN_NOPROC` (no free slots), `E_GEN_ARG` (data+stack+heap > 0xFFE0, or command
too long). [PLIB §12]

- `p_execcasync(pName, pCommand, length, pStatus, pPid)` — asynchronous load
  (completion via status word; see §5).
- `p_pcreate(E_CPB *pBlock)` — the **primitive** creator that does *not* involve
  the file server; `p_execc` eventually calls it. The `E_CPB` block carries code/
  stack/data/heap paragraph counts, initial IP, checksum, `minHeap`, initial
  `priority`, RAM/ROM flag, and name. [PLIB §12]

**Shared code.** A second instance of the same program shares the existing
`<name>.$SC` code segment (only the initialised statics are reloaded). The load
verifies the code segment checksum against the image header — so you cannot run
two different programs (or versions) of the same name simultaneously. [PLIB §12]

### 2.3 Priorities and scheduling

Priority is an **unsigned byte, 1–255**. At each reschedule the highest-priority
**ready** process runs; if several share the top priority they run **round-robin**,
each for a **4-tick** slice (the system ticks **32×/second**). A lower-priority
process is otherwise blocked indefinitely. Preemption is real: a process need not
call the OS to be switched out. [PLIB §12; SYS §10]

A process leaves the running state by moving to: `SEMAPHORE` (via `p_iowait` /
`p_wait`), `DELTA` (via `p_sleep`/`p_sleept`/`p_sleepa`), or `SUSPENDED` (via
`p_psuspend` on itself). Process states: `E_PROC_READY`, `E_PROC_SEMAPHORE`,
`E_PROC_DELTA`, `E_PROC_SUSPENDED` (plus `E_PROC_FREE` for a vacated slot). The
READY queue is priority-ordered; SEMAPHORE queues are FIFO; the DELTA queue stores
inter-timer tick deltas. [PLIB §12]

Priority guidance and constants:

- Applications should stay within **`E_MIN_PRIORITY` (64)** … **`E_MAX_PRIORITY`
  (192)** inclusive; values outside are OS-reserved. [PLIB §12]
- Window-server clients are typically created at **`E_PRIORITY_FORE` (128 =
  0x80)** and then let the window server raise/lower them as they gain/lose focus.
  A default image priority of **0x80** is the norm. [PLIB §12; GEN §3]
- Supervisor 248, file server 240; interrupts run at the priority of the process
  they interrupt. [PLIB §12; SYS §10]

> **Uncertainty / cross-source discrepancy.** [PLIB] states `E_MIN_PRIORITY` = 64
> and `E_MAX_PRIORITY` = 192. [SYS §10] speaks only of reserved bands
> `cPBMinPriority` / `cPBMaxPriority` without giving numbers, and the [SYS]
> service-number appendix lists a symbol `E_MAX_PRIORITY 154`. Treat the PLIB
> pair (64/192) as the documented application range; the 154 appendix value is
> unexplained in prose and should not be relied on.

Priority API: `INT p_getpri(HANDLE pid)`, `INT p_setpri(HANDLE pid, INT nPriority)`
(reschedules; `E_GEN_RANGE` out of range, `E_FILE_NXIST` no such process,
`E_GEN_FAIL` on null/supervisor/file-server), `p_presume`, `p_psuspend`,
`p_marka`/`p_unmarka` (mark this process (in)active — servers mark themselves
non-active), `p_getpid`, `p_pname`/`p_prename`, `p_getowner`. Cross-process data
access: `p_pcpyfr`, `p_pcpyto`, `p_piscpyfr`. [PLIB §12]

### 2.4 Termination

Self-termination:

- `void p_exit(INT nReason)` — normal, graceful. `nReason` in −127..128; **0** =
  clean exit; negative = an init-failure error code; positive = an exit status to
  a parent. Falling off the end of `main` calls `p_exit` with `main`'s return
  value (falling off a `void main` is poor practice — it exits with garbage).
- `void p_panic(INT nPanic)` — abnormal (see §3). Application panic numbers should
  count **down from 254** to avoid the system ranges.

Terminating another process:

- `INT p_pterminate(HANDLE pid, INT nReason)` — **preferred**. If the target
  called `p_onterminate(nMessage)`, it is merely *sent* that message and gets to
  run cleanup and exit itself; otherwise it behaves like `p_pkill`.
- `INT p_pkill(HANDLE pid, INT nReason)` — summary kill, no cleanup chance.
- `INT p_ppanic(HANDLE pid, INT nPanic)` — panic another process (e.g. a server
  panicking a client that sent garbage).

All three return `E_FILE_NXIST` (no such process) or `E_GEN_FAIL` (target is the
null/supervisor/file-server process). [PLIB §6]

**Termination notification.** `p_logona(pid,&status)` (async, signals the
caller's I/O semaphore), `p_logon(pid,mType)` (delivers an IPC message — for
servers tracking clients), `p_watchall` (message on *any* process termination —
used by the shell). Each delivers a 16-bit **process termination word** whose high
byte is one of: `E_NORMAL_EXIT` (low byte = the `nReason`), `E_PANIC_EXIT` (low
byte = the panic number), or `E_TASK_PANIC_EXIT` (a task it owned panicked).
`p_logoffa`/`p_logoff` cancel; after `p_logoffa` the status word gets
`E_FILE_CANCEL` and you must still `p_waitstat`. [PLIB §6]

### 2.5 Inter-process communication (IPC)

Choices, roughly in order of coupling:

1. **Inter-process messaging** — the primary client/server mechanism (below).
2. **Shared memory segments** — a named external segment opened by several
   processes, guarded by a semaphore.
3. **Cross-process copy** — `p_pcpyfr` / `p_pcpyto` (a server pulling/pushing a
   client's data by reference passed in a message).
4. **Termination logon** and the **notifier** service as lightweight signalling.

**Messaging model** [PLIB §12; SYS §5]. Designed for client/server, one server /
many clients. A receiver first calls `p_minit(nMess, lMess)` to allocate `nMess`
**message slots** from its heap, each an `E_MESSAGE` header
(`next`, `status`, `type`, `pid`) + `lMess` data bytes. The message length is
fixed by the **server**; the file server and supervisor both use `lMess` = 8. When
data exceeds the slot, the message carries an **address+length by reference** and
the server uses `p_pcpyfr`/`p_pcpyto`.

Lifecycle: sender `p_msend*` copies the message into a free slot and enqueues it →
server `p_mreceivew` (or async `p_mreceive`) dequeues from the **front** and gets
the slot address → server processes it → `p_mfree` frees the slot **and**, if the
client used a "receive" variant, writes back a completion status and signals the
client's I/O semaphore.

- Client: `p_msend` (blind), `p_msendreceivew` (send + wait for reply),
  `p_msendreceivea` (send + set up an async reply). `p_mcancel` cancels a pending
  `p_mreceive`.
- If the server has no free slot, the send **blocks on a mutual-exclusion
  semaphore** — even the "async" variant can block indefinitely. Multi-client
  servers therefore pre-allocate a slot per potential client (≈ `E_MAX_PROCESSES`
  minus known processes).
- **Priority queue-jumping:** a message from a client of priority **≥ 0x80** is
  inserted at the **front** of the server queue (overtaking others), so a
  foreground task's requests are served first. [PLIB §12; SYS §5]
- For relative priorities to work, a **multi-client server should run at a higher
  priority than any client**; a client waiting on a low-priority server is
  effectively lowered to the server's priority. [PLIB §12]

The message and I/O systems share status-word + I/O-semaphore mechanics, so async
messaging and async I/O can be mixed freely. [SYS §5]

---

## 3. Panics

A **panic** is a fatal exception. When EPOC detects a condition that "could only
arise from a bug", it **terminates the process immediately** with a panic number
in **0–255**. Applications cannot trap or survive a panic (contrast `p_leave`,
which is recoverable). Panicking is cheaper than an error return and enforces
discipline by killing the defect at the point of detection. The OS still uses
**error returns** where a condition can arise from legitimate user action — e.g.
`p_alloc` returns NULL on genuine memory exhaustion but **panics** if it finds the
heap *corrupted*. [GEN §2; PLIB §6]

Panics are raised by `p_panic` (self) or `p_ppanic` (another process — used by a
server against a misbehaving client). Applications may call `p_panic` themselves
to catch "impossible" cases (e.g. a `switch` default). [PLIB §6]

### 3.1 Panic-number ranges — how to interpret one

| Range | Origin | Where documented |
|---|---|---|
| **0–80, and 255** | PLIB library | PLIB Reference (§6) |
| **81–129** | Window Server library | Window Server Reference |
| **130–160** | OLIB object library | OLIB Reference |
| **160–254** | Non-ROM code (e.g. ISAM and other loadable libraries) | the relevant library's manual |

[GEN §2; PLIB §6]

Notes on reading a panic:

- A given number in the **160–254** band may be used by **more than one**
  non-ROM component, so it is ambiguous — the only definitive way to locate the
  source is to catch it under the **SIBO Debugger** and trace back. [GEN §2]
- The manual overview says PLIB owns 0–80 & 255; the detailed PLIB list also
  documents specific panics at **60, 65–69, 77, 78** etc., and warns that a panic
  **79** or anything **81–254** may belong to another component. Window-server
  panics are cited as 81–110 in one place and OLIB as 130–158 in another (both
  inside the summary bands above). [PLIB §6]
- Panics described as *"Invalid function number for …"* rarely indicate a
  specific coding bug; they usually mean a **trashed return address** sent the IP
  wandering into arbitrary code. [PLIB §6]

### 3.2 Selected PLIB system panic numbers (0–80, 255)

| # | Meaning |
|---|---|
| 0 | Test-code failure |
| 1–5 | Semaphore manager: bad function no. / handle / not allocated / negative initial count / negative signal count |
| 6–8 | Process manager: bad function no. / invalid PID / task tried to create a task |
| 9 | Time manager: bad function number |
| 10–14 | Segment manager: bad fn / negative size / bad type / bad handle / copy out of range |
| 15–19 | Heap manager: bad fn / heap not initialised / cell reduced by more than its size / granularity > `E_MAX_GROWBY` / **cell address outside heap (heap corrupted — try `p_allchk`)** |
| 20–23 | IPC message manager (bad fn; already init'd (double `p_minit`); not init'd; zero-length queue) |
| 24–26 | I/O manager: bad fn / **invalid I/O channel** (unchecked `p_open`, closed/overwritten handle) / device requested panic |
| 27 | Invalid wait-handler handle (often really a low-address overwrite at addr 2 via an uninitialised pointer) |
| 28–29 | Key/pointer already hooked / requester not a task |
| 30–31 | Device manager: bad fn / bad device handle |
| 32–34 | File manager: bad fn / already connected to file server / reserved |
| 35–41 | Library manager & file-server connection (bad handle/fn, invalid LIB channel, bad DYL index, invalid message to file server, **not connected to file server** = 41) |
| 42–47 | Conversion / general manager fns; unhook-notify-when-not-hooked; invalid revector address |
| 48 | `p_leave` called before `p_enter` |
| 49–56 | OOP errors (no method, invalid reclass, unknown category/class, supersend outside method, handle before link, missing external categories, object not a valid class) |
| 57 | Invalid link-layer completion code |
| 58–59 | Invalid fn for window server / hardware manager |
| 60 | **Write outside process data segment** (uninitialised pointer / corrupt structure) |
| 61 | Interrupts disabled too long |
| 63–64 | Divide-by-zero / overflow interrupt |
| 65–67 | Dbf manager: bad fn / bad DBF I/O channel / bad DBF parameter |
| 68 | **Address-zero overwrite** (the `0xDEAD` word changed — uninitialised pointer) |
| 69 | Less than 0x100 bytes of stack remain (large automatics — make them static or heap) |
| 70 | Environment name > `EnvMaxNameSize` |
| 71 / 72 | Single-step (INT 1) / breakpoint (INT 3) |
| 73 | **A request was made while an async request of the same type on the same channel was still pending** |
| 74–76 | Serial-I/O bad fn / ASIC1 call on an ASIC9 machine / DYL not in a visible bank |
| 77 | Floating-point emulator exception |
| 78 | Semaphore count exceeds 0x7FFF |
| 80 | Library fatal error (preceded by a specific-error notification) |
| 255 | `p_allchk` detected a corrupted heap |

[PLIB §6 "System panic numbers"]

---

## 4. Error codes

System functions signal failure by convention:

- Functions returning an **address** return **NULL (0)** on failure.
- Otherwise: **zero or positive = success, −1 = failure** (`E_GEN_FAIL` = −1; you
  may just test the sign).
- When the error is elaborated, a **negative system error number in −1..−128** is
  returned, allocated in bands: [PLIB §6 "Error returns"]

| Band | Family | Header |
|---|---|---|
| −1 … −31 | General errors `E_GEN_xxx` | `p_gen.h` |
| −32 … −63 | I/O / device / file errors `E_FILE_xxx` | `p_file.h` |
| −64 … −95 | Reserved | — |
| −96 … −128 | OPL run-time errors | — |

`p_errs(TEXT *str, INT errno)` converts a system error number to a
language-dependent string (buffer ≥ `E_MAX_ERROR_TEXT_SIZE` = 64; unknown numbers
give `"Unknown error [xx]"`). The notifier services `p_notify` / `p_notifyerr`
present errors to the user; `p_setnotify(FALSE)` suppresses automatic notification
for unattended/server processes. [PLIB §6]

### 4.1 `E_GEN_*` (general) family

The named generics referenced across the manuals (all defined in `p_gen.h`,
values in −1..−31):

| Symbol | Value | Meaning |
|---|---|---|
| `E_GEN_NONE` | 0 | No error / success |
| `E_GEN_FAIL` | **−1** | General failure (also the generic "just test the sign") |
| `E_GEN_ARG` | *−n* | Bad argument (e.g. invalid `double`, buffer too small, out-of-range command length) |
| `E_GEN_OVER` | *−n* | Overflow — value too large for the target type/representation |
| `E_GEN_UNDER` | *−n* | Underflow — magnitude too small to represent |
| `E_GEN_RANGE` | *−n* | Value outside a permitted range (e.g. priority) |
| `E_GEN_INUSE` | **−9** | Resource in use (e.g. segment usage count > 0) |
| `E_GEN_NOMEMORY` | *−n* | Insufficient (system) memory; `f_alloc`/`f_realloc` `p_leave` with this |
| `E_GEN_NOPROC` | *−n* | No free process slots |
| `E_GEN_NOSEM` | *−n* | No semaphores available (`p_semcrt`) |
| `E_GEN_IMAGE` | *−n* | File is not a valid image, or is corrupt |

> **Uncertainty.** Of the `E_GEN_*` family only two exact numeric values are
> **fixed by the task's cross-check set and consistent with the manuals**:
> `E_GEN_FAIL = −1` and `E_GEN_INUSE = −9`. The manuals reference the other
> `E_GEN_*` symbols by name and behaviour but the two source files do **not**
> tabulate their individual numeric values (they live in `p_gen.h`). Values shown
> as *−n* above are known only to fall in the −1..−31 band; do not assume specific
> numbers without the header. [PLIB §6, and usage throughout]

### 4.2 `E_FILE_*` (I/O / file / device) family

Defined in `p_file.h`, values in −32..−63. Anchor values confirmed against the
task's cross-check set:

| Symbol | Value | Meaning |
|---|---|---|
| `E_FILE_EXIST` | **−32** | Already exists (file, or a differently-checksummed process/segment of the same name) |
| `E_FILE_NAME` | **−38** | Invalid name (file / memory-segment name) |
| `E_FILE_DEVICE` | **−41** | Device does not exist / device driver not found |
| `E_FILE_PENDING` | *−n* | Async request still outstanding (see §5) — the status-word "in progress" marker |
| `E_FILE_CANCEL` | *−n* | Request was cancelled (written to the status word by a cancel) |
| `E_FILE_NXIST` | *−n* | Does not exist (file, directory entry, or **process**) |
| `E_FILE_DIR` | *−n* | Directory does not exist |
| `E_FILE_NOTREADY` | *−n* | Device present but no medium (e.g. no SSD in the pack) |

> **Uncertainty.** Three exact values are pinned by the cross-check and match the
> manuals' usage: **`E_FILE_EXIST = −32`, `E_FILE_NAME = −38`,
> `E_FILE_DEVICE = −41`.** The remaining `E_FILE_*` symbols appear by name in the
> manuals (e.g. as `p_execc`, `p_setpri`, segment, and async return values) but
> the two source files do **not** enumerate their numeric values — those are in
> `p_file.h`. Serial-port variants such as `E_FILE_PARITY` are mentioned in prose
> without numbers. [PLIB §6/§7/§12]

Observed usage tying symbols to operations: `p_execc` → `E_FILE_DEVICE`,
`E_FILE_NOTREADY`, `E_FILE_DIR`, `E_FILE_NXIST`, `E_FILE_EXIST`; `p_setpri` /
`p_presume` / termination calls → `E_FILE_NXIST`; segment create/open →
`E_FILE_EXIST`, `E_FILE_NAME`; segment delete → `E_GEN_INUSE`. [PLIB §7, §12, §6]

---

## 5. Asynchronous I/O model

### 5.1 Two-step services, status words, the I/O semaphore

Most services exist in two halves: **make the request**, then **wait for
completion**. A *synchronous* function does both; the internal *asynchronous*
function issues the request and returns at once. Async request functions include
`p_ioa`/`p_ioc` (on an open I/O channel), `p_mreceive` (IPC), `p_execcasync`
(image load), and `p_logona` (termination). [PLIB §8]

Every process gets an **I/O semaphore** at creation ("asynchronous-request
semaphore" would have been the accurate name). Each async request is tied to a
caller-supplied **status word** (a signed 16-bit `WORD`). The invariant for
*every* async request: [GEN §5; PLIB §8]

1. On issue, the status word is set to the negative **`E_FILE_PENDING`**.
2. On completion, a value **other than** `E_FILE_PENDING` is written — **≥ 0** for
   success, a **negative error number** for failure.
3. The write to the status word happens **before** the caller's I/O semaphore is
   signalled. Both steps are integral to the mechanism.

**`p_iowait`** decrements the I/O semaphore and blocks until it is non-negative,
i.e. until *something* has been signalled. It does **not** tell you *which*
request completed — after it returns you **poll the status words** for the first
that is no longer `E_FILE_PENDING`. The semaphore starts at 0; because `p_iowait`
decrements first, it returns only when a signal has arrived. [GEN §5; PLIB §8]

```c
p_ioc(SerialChannel, P_FWRITE, &SerialStatus, str, &len);   /* async write */
p_ioc(TimerChannel,  P_FRELATIVE, &TimerStatus, &timeout);  /* async 5s timer */
p_iowait();                                                  /* block on one */
if (SerialStatus == E_FILE_PENDING) { /* timed out: cancel the write */
    p_iow(SerialChannel, P_FCANCEL);
    p_waitstat(&SerialStatus);
    p_leave(SERIAL_TIMEOUT);
}
p_iow(TimerChannel, P_FCANCEL);
p_waitstat(&TimerStatus);
```
[PLIB §8, adapted]

**Prioritisation** is implicit in **poll order**: if two requests have completed,
whichever status word you poll first is serviced first. A low-priority source can
be "locked out" by a rapidly firing higher one. [GEN §5]

**Where to declare status words** — in static data or in a heap control block,
**never** on a stack frame that will return before the request completes. A stale
status word is later written to random memory. [GEN §5]

### 5.2 The `p_io?` calls and channels

I/O devices share a uniform interface: `p_open` (by name, returns a channel
handle) → request services → `p_close`. Synchronous requests use **`p_iow`**
(convenience wrappers `p_iowN`); asynchronous use **`p_ioa`** / **`p_ioc`**
(wrappers `p_ioaN`, `p_iocN`). The async form always takes **one extra parameter**
— the status-word address. `p_ioa` vs `p_ioc` are the async requestors used in the
examples ("a" = asynchronous). [GEN §5; PLIB §8/§9]

### 5.3 The semaphore & I/O-semaphore primitives

EPOC semaphores are **counting** semaphores (signed value): `p_signal`
increments, `p_wait` decrements; a negative value means processes are queued
(released FIFO). [PLIB §8]

| Function | Effect |
|---|---|
| `HANDLE p_semcrt(INT nCount)` | Create a semaphore, initial count `nCount` (≥0); returns handle or `E_GEN_NOSEM`. Auto-deleted on process exit. |
| `void p_semdel(HANDLE s)` | Delete (waiters are released). Rarely needed pre-v3. |
| `void p_wait(HANDLE s)` | Decrement; block if it goes negative. |
| `void p_signal(HANDLE s)` | Increment; release the first waiter and reschedule. |
| `void p_signaln(HANDLE s, INT n)` | `p_signal` × n. |
| `void p_signalnr(HANDLE s)` | Signal **without** rescheduling (reschedule waits for the next tick, or force one with `p_sleept(0)`). |
| `void p_iosignal(void)` | Increment **this** process's I/O semaphore (used by wait handlers, or to raise an "internal event"). |
| `void p_iosignalbypid(HANDLE pid)` | Signal another process's I/O semaphore (after setting its status word via `p_pcpyto`). |
| `void p_iowait(void)` | Wait on the I/O semaphore; runs active wait handlers. |
| `void p_waitstat(WORD *pstat)` | Wait until `*pstat != E_FILE_PENDING`, correctly re-signalling any other completions seen meanwhile. |
| `void p_ioyield(void)` | `p_iosignal` + `p_iowait` — give installed wait handlers a chance to run. |

[PLIB §8]

**Discipline.** Every `p_iosignal` must be matched by a `p_iowait`, and its status
word must already be complete when signalled. An unmatched signal is a **"stray
signal"** at Psion — the next `p_iowait` returns spuriously with nothing ready.
Guard against it (e.g. `p_panic` if the post-`p_iowait` poll finds no completed
word); the **Spy** app helps spot accumulated stray signals. Making a second
request while one of the same type is pending on the same channel → **panic 73**.
[PLIB §8; GEN §5]

**Waiting for one specific request:** use **`p_waitstat`**, not `p_iowait` — it
returns only when *that* status word completes and re-issues signals for other
completions it absorbs. General-purpose synchronous wrappers **must** use
`p_waitstat` (they cannot assume no other requests are pending). [PLIB §8]

### 5.4 Cancelling a request

General principles for all cancels (`p_iow(P_FCANCEL)` for channels, `p_mcancel`
for IPC, `p_logoffa` for termination): [GEN §5; PLIB §8]

- A cancel **precipitates completion**; it does **not** un-issue the request.
- It **may or may not** be effective — the operation may finish naturally first.
- **You must still consume the completion**, normally with an immediate
  `p_waitstat`. If the request was still outstanding, `E_FILE_CANCEL` is written
  to the status word and the process is signalled.

Canonical timer cancel:

```c
p_iow2(timH, P_FCANCEL);   /* synchronous; completes at once */
p_waitstat(&timstat);      /* use up the resulting signal */
```

Forgetting the `p_waitstat` after `P_FCANCEL` is a **common bug**: the next
`p_iowait` then returns immediately although nothing is actually ready. [GEN §5]

### 5.5 Wait handlers and interrupt-driven completion

**Wait handlers** run *inside* `p_iowait`/`p_waitstat`, just before it would
return. Install with `p_svecadd`, remove with `p_svecrem`, (de)activate with
`p_sveccall`. Each returns `P_SIGNAL_DISABLE` (completion handled, deactivate),
`P_SIGNAL_ENABLE` (handled, keep active — more pending), or `P_SIGNAL_UNUSED`
(nothing of mine — the signal is external). `p_iowait` returns to the caller only
when all active handlers report `P_SIGNAL_UNUSED`. The handler queue hangs off a
4-byte header in the reserved static at **address 2**; corrupting it usually
yields **panic 27**. [PLIB §8]

This matters because a **hardware interrupt handler cannot write to the requesting
process's data segment** (it may be mid-move). Instead the driver writes to a
fixed location and signals the I/O semaphore; the driver's **wait handler** later
copies the data into the process data segment when the process next enters
`p_iowait`. Therefore, if you **poll** a status word (e.g. between chunks of a long
computation) instead of blocking, call **`p_ioyield` before each poll** so those
handlers can run — and still consume the signal with `p_iowait` once the poll sees
completion, or you get a stray signal. Tight-loop polling without yielding is
"anti-social" (it hogs the CPU). Server-implemented drivers (e.g. the window
server) need no wait handler. [PLIB §8]

---

## 6. The SIBO OS executive call (assembly-level interface)

At the C level, `p_iow`/`p_ioa`/`p_ioc` are thin shells over EPOC's ROM services.
Those services are reached by an **80C86 software interrupt** (`INT XXH`). Two
flavours: **single-service** (`INT XXH` — one dedicated interrupt per common
service, minimal overhead) and **multi-service** (`MOV AH, ZZH` then `INT XXH`,
where `ZZ` selects the function). [SYS §"System services"]

**General calling convention** [SYS §"Calling conventions"]:

- All registers except **AX** are preserved unless they carry a return value; AX
  is a scratch/return register.
- **Error** ⇒ **carry flag set**, error number in **AL** (always negative); other
  result registers are then indeterminate.
- Handles are 16-bit, guaranteed positive, non-zero and **even**; passed in **BX**
  where possible; results returned in **AX**.
- A programmatically invalid argument ⇒ the process is **terminated immediately**
  (a panic), not an error return.

**The I/O executive specifically.** The manual documents the underlying I/O
services at register level:

- **`IoAsynchronous`** — `AL` = I/O function number, `BX` = I/O handle,
  `DS:CX` → arg1, `DS:DX` → arg2, `DS:DI` → status word; returns `AX` = device
  driver result (carry set ⇒ error). This is exactly the layer beneath `p_ioa`/
  `p_ioc`: on success the driver later signals completion via
  `IoSignal`/`IoSignalByPid`, and you wait with `IoWaitForSignal` /
  `IoWaitForStatus` (the `p_iowait`/`p_waitstat` primitives). Panics `PanicIo1`
  (bad channel), `PanicLib1`/`PanicLib2` (bad library handle/function).
- **`IoWithWait`** — same inputs minus the status word (`AL`, `BX`, `DS:CX`,
  `DS:DX`), returns `AX` = result; the **synchronous** service beneath `p_iow`.
- `IoAsynchronousNoError` — as `IoAsynchronous` but start-up errors are reported
  through the status word + signal rather than the carry flag. [SYS §8 "Input
  Output Management"]

**[RE — reverse-engineered]** Prior reverse-engineering of a SIBO ROM identifies
the I/O executive concretely as **`INT 0xCF`**, with **`CL` = function code,
`BX` = channel handle, `DX` = argument, result in `AX`** — i.e. the actual
interrupt vector and register wiring under `p_iow`. This is *consistent in shape*
with the documented `IoWithWait`/`IoAsynchronous` services above (handle in BX,
result in AX, args passed by register), but note two things the manuals do **not**
corroborate and that should be treated as RE findings only:

- The manuals **never name the interrupt number** for any service (`INT XXH` is
  left abstract throughout `[SYS]`); **`0xCF` comes from RE, not the SDK docs.**
- The documented multi-service convention puts the **function code in `AH`**, and
  the I/O services put it in **`AL`** with args in `CX`/`DX` and the status word in
  `DI`. The RE note's **`CL` = function / `DX` = argument** differs from the
  published register layout. The discrepancy may reflect a particular wrapper,
  ROM version, or single-service entry point observed during RE; **do not treat
  the `INT 0xCF` / `CL`/`BX`/`DX`/`AX` mapping as documented** — it is an
  RE observation to be verified against the specific ROM.

---

## 7. Workabout / Workabout MX notes

The manuals cover the SIBO family collectively (HC, MC, Series 3/3a, Workabout);
the programming model above — small model, segment moving, panics, error
families, async I/O, the executive interrupt — applies to the Workabout as it does
to the rest of the range. **[MX]** The Workabout MX is a later, faster
(higher-clock, more-RAM) member of the same SIBO/EPOC-16 line; **the SDK source
manuals used here (v2.30, 1999) do not carry MX-specific programming differences**,
so any MX deltas (RAM sizing, ROM version, clock/timer specifics) are **not
established from these sources** and are left as an explicit uncertainty rather
than guessed. Version-gated APIs seen above (`p_totalK` needs EPOC ≥ 3.50;
`p_semdel` recommended only from v3) are the kind of thing to check against the
actual MX ROM version via `p_getver` / the version services. [GEN §7; PLIB §7]

---

### Source map

- Memory model & allocation: **[PLIB §1, §7]**, **[GEN §1]**
- Processes, priorities, IPC, termination: **[PLIB §6, §12]**, **[SYS §5, §10]**
- Panics & panic ranges: **[GEN §2]**, **[PLIB §6]**
- Error families: **[PLIB §6]** (+ usage in §7/§12)
- Async I/O, semaphores, wait handlers, cancel: **[GEN §5]**, **[PLIB §8, §9]**
- Executive interrupt / register conventions: **[SYS]** "System services",
  "Calling conventions", §8; **`INT 0xCF` mapping = [RE], not in the manuals.**
